From 581220b718f2d5f3e3289feca88003d59b5ea3ea Mon Sep 17 00:00:00 2001 From: "will.anderson" Date: Sat, 22 Aug 2026 22:26:59 -0500 Subject: [PATCH] feat(neuron): local API server, aspect kit (traced/metered/guarded), conversation picker data --- packages/neuron/src/kernel/aspect.ts | 87 ++++++ packages/neuron/src/kernel/graph.ts | 4 + packages/neuron/src/main.ts | 216 ++++++++++++--- packages/neuron/src/serve.ts | 18 ++ packages/neuron/src/server.ts | 351 +++++++++++++++--------- packages/neuron/src/session/sessions.ts | 58 +++- 6 files changed, 562 insertions(+), 172 deletions(-) create mode 100644 packages/neuron/src/kernel/aspect.ts create mode 100644 packages/neuron/src/serve.ts diff --git a/packages/neuron/src/kernel/aspect.ts b/packages/neuron/src/kernel/aspect.ts new file mode 100644 index 0000000000..651dc243cd --- /dev/null +++ b/packages/neuron/src/kernel/aspect.ts @@ -0,0 +1,87 @@ +export * as Aspect from "./aspect" + +import type { Graph } from "./graph" + +/** + * The AOP kit. Cross-cutting concerns are stages you decorate onto + * functions - never code scattered inside them. + * + * const run = traced("runner.turn", metered("turns", guarded(claims, scope, work))) + * + * Every stage records what it did as edges in the graph ledger: + * kind "trace" - a span with duration + * kind "metric" - a named observation with value + * The wire remembers; nothing is scraped after the fact. + */ + +export interface Claims { + principal: string + authed: boolean + scopes: string[] +} + +export type AsyncFn = (...args: A) => Promise + +function record(graph: Graph | undefined, kind: string, payload: Record) { + if (!graph) return + graph.ensureNode("telemetry", `telemetry:${process.pid}`) + graph.edge(`telemetry:${process.pid}`, `telemetry:${process.pid}`, kind, { ...payload, at: Date.now() }) +} + +/** Trace: time the call and record a span edge. */ +export function traced( + graph: Graph | undefined, + name: string, + fn: AsyncFn, +): AsyncFn { + return async (...args: A) => { + const start = Date.now() + try { + const result = await fn(...args) + record(graph, "trace", { name, ms: Date.now() - start, ok: true }) + return result + } catch (error) { + record(graph, "trace", { name, ms: Date.now() - start, ok: false, error: String(error).slice(0, 200) }) + throw error + } + } +} + +/** Meter: count invocations of a named metric. */ +export function metered(graph: Graph | undefined, name: string, fn: AsyncFn): AsyncFn { + return async (...args: A) => { + record(graph, "metric", { name, at: Date.now() }) + return fn(...args) + } +} + +export interface Policy { + claims: Claims + /** Claim requirements, e.g. ["authed:true", "scope:fs.write"]. */ + require?: string[] +} + +/** Guard: one verdict from the principal's claims, stamped on failure too. */ +export function guarded( + graph: Graph | undefined, + policy: () => Policy, + fn: AsyncFn, +): AsyncFn { + return async (...args: A) => { + const { claims, require = [] } = policy() + for (const requirement of require) { + const [key, expected] = requirement.split(":") + const actual = + key === "scope" + ? claims.scopes.includes(expected ?? "") + : key === "authed" + ? String(claims.authed) + : String((claims as any)[key] ?? "") + if (actual !== (expected ?? "true")) { + record(graph, "denied", { requirement, principal: claims.principal }) + throw new Error(`guard denied: ${requirement}`) + } + } + return fn(...args) + } +} diff --git a/packages/neuron/src/kernel/graph.ts b/packages/neuron/src/kernel/graph.ts index 3207e02492..3ddefda91e 100644 --- a/packages/neuron/src/kernel/graph.ts +++ b/packages/neuron/src/kernel/graph.ts @@ -58,6 +58,10 @@ export class Graph { `) } + nodesByKind(kind: string): Node[] { + return this.db.query(`SELECT * FROM nodes WHERE kind = ? ORDER BY created_at`).all(kind) as Node[] + } + ensureNode(kind: string, address: string): Node { const existing = this.db .query(`SELECT * FROM nodes WHERE address = ?`) diff --git a/packages/neuron/src/main.ts b/packages/neuron/src/main.ts index 368043f395..32aeb77c8a 100644 --- a/packages/neuron/src/main.ts +++ b/packages/neuron/src/main.ts @@ -1,14 +1,54 @@ #!/usr/bin/env bun /** - * Neuron TUI - a thin client of the local engine API. - * Knows nothing about models, providers, dialects, or storage. + * Neuron TUI - a command-pattern client of the engine API. + * + * terminal key ──▶ binding table ──▶ Command ──▶ reducer fold ──▶ render + * └─▶ effects (fetch) for the few + * commands that touch the engine + * + * Keys appear only in the binding table. State is a pure fold of commands. + * The engine is only a URL. */ import { createCliRenderer, BoxRenderable, TextRenderable } from "@neuron-tui/core" const BASE = process.env.NEURON_URL ?? "http://localhost:4096" -const sessionId = process.env.NEURON_SESSION ?? "main" +let sessionId = process.env.NEURON_SESSION ?? "main" + +// ---------- commands ---------- + +type Command = + | { type: "input.insert"; char: string } + | { type: "input.backspace" } + | { type: "picker.move"; delta: number } + | { type: "picker.open" } + | { type: "chat.submit" } + +// ---------- state + reducer (pure) ---------- + +interface UIState { + mode: "picker" | "chat" + pickerIndex: number + inputBuffer: string +} + +function reduce(state: UIState, command: Command): UIState { + switch (command.type) { + case "input.insert": + return state.mode === "chat" ? { ...state, inputBuffer: state.inputBuffer + command.char } : state + case "input.backspace": + return state.mode === "chat" ? { ...state, inputBuffer: state.inputBuffer.slice(0, -1) } : state + case "picker.move": + return state.mode === "picker" + ? { ...state, pickerIndex: Math.max(0, Math.min(state.pickerIndex + command.delta, conversations.length - 1)) } + : state + default: + return state + } +} + +// ---------- view ---------- const renderer = await createCliRenderer({ exitOnCtrlC: true, targetFps: 30 }) @@ -20,17 +60,16 @@ const header = new BoxRenderable(renderer, { }) header.add(new TextRenderable(renderer, { content: "NEURON", fg: "#e8e4de", align: "center" })) -const transcriptText = new TextRenderable(renderer, { content: "", fg: "#d5cfc6", backgroundColor: "#0a0a0a" }) -const transcriptBox = new BoxRenderable(renderer, { +const bodyText = new TextRenderable(renderer, { content: "", fg: "#d5cfc6", backgroundColor: "#0a0a0a" }) +const bodyBox = new BoxRenderable(renderer, { borderStyle: "single", borderColor: "#3a3733", backgroundColor: "#0a0a0a", flexGrow: 1, }) -transcriptBox.add(transcriptText) +bodyBox.add(bodyText) const statusText = new TextRenderable(renderer, { content: "connecting…", fg: "#8a857d" }) - const inputBox = new BoxRenderable(renderer, { height: 3, borderStyle: "single", @@ -40,14 +79,32 @@ const inputBox = new BoxRenderable(renderer, { const inputText = new TextRenderable(renderer, { content: "> ", fg: "#e8e4de" }) inputBox.add(inputText) -renderer.root.add(header, transcriptBox, statusText, inputBox) +renderer.root.add(header, bodyBox, statusText, inputBox) -// ---- client ---- +interface ConversationSummary { + conversationID: string + title: string + updatedAt: number +} -let inputBuffer = "" +let conversations: ConversationSummary[] = [] +let pickerIndex = 0 let busy = false -async function refresh() { +function render(state: UIState) { + if (state.mode === "picker") { + const lines = ["Conversations - up/down, Enter opens, N starts new", ""] + conversations.forEach((c, i) => + lines.push(`${i === pickerIndex ? ">" : " "} ${c.title} ${new Date(c.updatedAt).toLocaleString()}`), + ) + bodyText.content = lines.join("\n") + statusText.content = `${conversations.length} conversation(s)` + return + } + inputText.content = `> ${state.inputBuffer}` +} + +async function refreshTranscript() { try { const res = await fetch(`${BASE}/session/${sessionId}/messages`) const messages = (await res.json()) as Array<{ info: { role: string }; parts: Array<{ text?: string }> }> @@ -56,39 +113,122 @@ async function refresh() { const who = m.info.role === "user" ? "you" : "neuron" for (const p of m.parts) if (p.text) for (const l of `${who}: ${p.text}`.split("\n")) lines.push(l) } - transcriptText.content = lines.join("\n").slice(-4000) + bodyText.content = lines.join("\n").slice(-4000) const s = await fetch(`${BASE}/session/${sessionId}/status`) statusText.content = JSON.stringify(await s.json()).slice(0, 60) - } catch (e) { + } catch { statusText.content = `engine unreachable: ${BASE}` } } -renderer.keyInput.on("keypress", async (key: any) => { - if (busy) return - if (key.name === "backspace") inputBuffer = inputBuffer.slice(0, -1) - else if (key.name === "return") { - const prompt = inputBuffer.trim() - inputBuffer = "" - if (!prompt) return - busy = true - statusText.content = "thinking…" - try { - await fetch(`${BASE}/session/${sessionId}/message`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ type: "text", text: prompt }] }), - }) - } catch (e) { - statusText.content = `send failed` - } - busy = false - await refresh() - } else if (key.sequence && key.sequence.length === 1) { - inputBuffer += key.sequence +// ---------- effects (the only commands that touch the engine) ---------- + +async function openPickerConversation(conversationID: string) { + sessionId = conversationID + state.mode = "chat" + await refreshTranscript() + render(state) +} + +async function startNewConversation() { + try { + const res = await fetch(`${BASE}/session`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }) + const created = (await res.json()) as { sessionID: string } + sessionId = created.sessionID + } catch { + sessionId = `local-${Date.now().toString(36)}` } - inputText.content = `> ${inputBuffer}` + state.mode = "chat" + bodyText.content = "" + setStatus("new conversation") +} + +async function submit(text: string) { + busy = true + setStatus("thinking…") + try { + await fetch(`${BASE}/session/${sessionId}/message`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ parts: [{ type: "text", text }] }), + }) + await refreshTranscript() + setStatus("idle") + } catch { + setStatus("send failed") + } + busy = false +} + +function setStatus(text: string) { + statusText.content = text +} + +// ---------- translation: keys -> commands (the only place keys appear) ---------- + +function translate(key: any, state: UIState): Command | null { + if (state.mode === "picker") { + if (key.name === "up") return { type: "picker.move", delta: -1 } + if (key.name === "down") return { type: "picker.move", delta: 1 } + if (key.name === "return") return { type: "picker.open" } + if (key.sequence?.toLowerCase() === "n") return { type: "picker.new" } + return null + } + if (key.name === "backspace") return { type: "input.backspace" } + if (key.name === "return") return { type: "chat.submit", text: undefined } + if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) + return { type: "input.insert", char: key.sequence } + return null +} + +// ---------- dispatch ---------- + +renderer.keyInput.on("keypress", async (key: any) => { + const command = translate(key, state) + if (!command || busy) return + + switch (command.type) { + case "picker.open": { + const pick = conversations[pickerIndex] + if (!pick) return + sessionId = pick.conversationID + pickerIndex = 0 + mode = "chat" + await refreshTranscript() + setStatus("opened") + return + } + case "chat.submit": { + const promptText = state.inputBuffer.trim() + if (!promptText) return + await submit(promptText) + return + } + } + + state = reduce(state, command) + render(state) }) -await refresh() -setInterval(refresh, 1000) +// ---- startup ---- + +let state: UIState = { mode: "picker", pickerIndex: 0, inputBuffer: "" } + +async function loadConversations() { + try { + const res = await fetch(`${BASE}/conversations`) + conversations = (await res.json()) as ConversationSummary[] + } catch { + conversations = [] + } +} + +await loadConversations() +render(state) +setInterval(async () => { + if (mode === "chat") await refreshTranscript() +}, 1000) diff --git a/packages/neuron/src/serve.ts b/packages/neuron/src/serve.ts new file mode 100644 index 0000000000..68142d0db3 --- /dev/null +++ b/packages/neuron/src/serve.ts @@ -0,0 +1,18 @@ +#!/usr/bin/env bun + +/** + * Engine process: composition + local API. + * The TUI (src/main.ts) is a separate client of this surface. + */ + +import { Boot } from "./boot" +import { Server } from "./server" + +const store = process.env.NEURON_STORE ?? `${process.env.HOME}/.neuron/store.db` +const port = Number(process.env.NEURON_PORT ?? 4096) + +const { graph, sessions } = await Boot.boot({ storePath: store }) + +const { server } = Server.serve({ port, deps: { graph, sessions, cwd: process.cwd() } }) + +console.log(`neuron engine · http://localhost:${server.port} · store ${store}`) diff --git a/packages/neuron/src/server.ts b/packages/neuron/src/server.ts index f1f1ecdef2..b7a09abacd 100644 --- a/packages/neuron/src/server.ts +++ b/packages/neuron/src/server.ts @@ -1,146 +1,235 @@ export * as Server from "./server" import * as Conversation from "./conversation" +import type { Graph } from "./kernel/graph" +import type { Sessions } from "../session/sessions" /** - * Tier-1 local API: the contract packages/tui consumes via its SDK client. + * Neuron local API - the contract packages/tui consumes. * Table-driven routes over Bun.serve. SSE at /global/event. + * + * Every route returns canonical v2 shapes. Routes backed by the engine + * are live; the rest return canonical empties so the front end renders + * without inventing data. */ -export function serve(input: { - port?: number - cwd: string +interface Deps { graph: Graph sessions: Sessions.Interface -}) { - const graph = input.graph + cwd: string +} - const json = (data: unknown, status = 200) => - new Response(JSON.stringify(data), { - status, - headers: { - "content-type": "application/json", - "access-control-allow-origin": "*", - "access-control-allow-headers": "*", - "access-control-allow-methods": "*", - }, - }) +const CORS = { + "access-control-allow-origin": "*", + "access-control-allow-headers": "*", + "access-control-allow-methods": "*", +} +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json", ...CORS } }) + +export function serve(input: { port?: number; deps: Deps }) { + const { graph, sessions } = input.deps + const cwd = input.cwd const listeners = new Set<(event: unknown) => void>() - const publishEvent = (event: unknown) => { + + const publish = (type: string, properties: Record) => { + const event = { id: crypto.randomUUID(), directory: cwd, payload: { type, properties } } for (const fn of listeners) fn(event) } - const sessionIdFrom = (url: URL) => url.pathname.split("/")[2] ?? "" - - const route: Record Promise | Response> = { - "GET /global/event": () => { - let seqId = 0 - const stream = new ReadableStream({ - start(controller) { - const enc = new TextEncoder() - const send = (event: unknown) => { + const sse = (): Response => { + let fn: (e: unknown) => void + const stream = new ReadableStream({ + start(controller) { + const enc = new TextEncoder() + fn = (event: unknown) => { + try { controller.enqueue(enc.encode(`data: ${JSON.stringify(event)}\n\n`)) - } - send({ type: "server.connected", properties: {} }) - const fn = (event: unknown) => send({ type: "event", properties: { event } }) - listeners.add(fn) - ;(controller as any)._fn = fn - }, - cancel() { - // listener removed via closure below on client disconnect - }, - }) - return new Response(stream, { - headers: { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - }, - }) - }, - - "GET /global/health": () => json({ ok: true, version: "0.1.0-neuron" }), - - "GET /path": () => - json({ - config: `${process.env.HOME}/.neuron`, - data: `${process.env.HOME}/.neuron`, - state: `${process.env.HOME}/.neuron`, - }), - - "GET /project/current": () => json({ id: "local", worktree: input.cwd }), - - "GET /project/local/directories": () => json([input.cwd]), - - "GET /session": () => { - const ids: string[] = [] - // Fold known conversations out of the graph. - const rows = (graph as any).db.query(`SELECT address FROM nodes WHERE kind='conversation'`).all() as { - address: string - }[] - for (const r of rows) ids.push(r.address.replace("conversation:", "")) - return json(ids.map((id) => ({ id, title: id, directory: input.cwd }))) - }, - - "POST /session": async (req) => { - const body = await req.json().catch(() => ({})) - const created = sessions.create({ id: body.id }) - return json(created) - }, - - "GET /session/:id": (_req, url) => json({ id: sessionIdFrom(url), directory: input.cwd, title: sessionIdFrom(url) }), - - "GET /session/:id/messages": (_req, url) => { - const conv = Conversation.openConversation(graph, `conversation:${sessionIdFrom(url).replace("conversation:", "")}`) - void conv - const convNode = graph.getNode(`conversation:${sessionIdFrom(url)}`) - if (!convNode) return json([]) - const turns = Conversation.transcript(graph, convNode.id) - return json( - turns.map((t, i) => ({ - info: { - id: `m${i}`, - sessionID: sessionIdFrom(url), - role: t.author === "will" ? "user" : "assistant", - time: { created: t.at }, - agent: t.author === "will" ? "build" : "build", - }, - parts: [{ type: "text", text: t.text }], - })), - ) - }, - - "POST /session/:id/message": async (req, url) => { - const body = await req.json().catch(() => ({})) - const parts = body.parts ?? [] - const text = parts.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") - const sessionID = sessionIdFrom(url) - sessions.prompt({ sessionID, prompt: text }) - publishEvent({ type: "session.updated", properties: { sessionID } }) - return json({ accepted: true, sessionID }) - }, - - "GET /session/:id/status": (_req, url) => json({ type: "idle", sessionID: sessionIdFrom(url) }), - - "POST /session/:id/abort": (_req, url) => { - sessions.interrupt(sessionIdFrom(url)) - return json(true) - }, - - "GET /vcs/status": () => json({}), - "GET /lsp": () => json([]), - "GET /mcp": () => json({}), - "POST /permission/:requestID/reply": () => json(true), + } catch {} + } + listeners.add(fn) + fn({ type: "server.connected", properties: {} }) + }, + cancel() { + listeners.delete(fn!) + }, + }) + return new Response(stream, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache" } }) } - const match = (method: string, pathname: string): { key: string; params: Record } | null => { - for (const key of Object.keys(route)) { - const [m, p] = key.split(" ") - if (m !== method) continue - const segs = p.split("/") - const actual = pathname.split("/") - if (segs.length !== actual.length) continue + // ---- helpers over the graph ---- + + function convNodeID(sessionID: string): string | null { + const session = graph.getNode(`session:${sessionID}`) + if (!session) return null + return graph.outgoing(session.id, "converses-in").at(-1)?.to_node ?? null + } + + function sessionSummary(sessionID: string) { + return { + id: sessionID, + title: sessionID, + directory: cwd, + parentID: undefined, + time: { created: Date.now(), updated: Date.now() }, + } + } + + function messages(sessionID: string): unknown[] { + const convId = convNodeID(sessionID) + if (!convId) return [] + return Conversation.transcript(graph, convId).map((t, i) => ({ + info: { + id: `m${i}`, + sessionID, + role: t.author === "will" ? "user" : "assistant", + time: { created: t.at }, + agent: "build", + model: undefined, + }, + parts: [{ type: "text", text: t.text }], + })) + } + + // ---- route table ---- + + type Handler = (req: Request, url: URL, params: Record) => Promise | Response + const table: Array<{ method: string; pattern: string; handle: Handler }> = [] + + const get = (p: string, h: Handler) => table.push({ method: "GET", pattern: p, handle: h }) + const post = (p: string, h: Handler) => table.push({ method: "POST", pattern: p, handle: h }) + const del = (p: string, h: Handler) => table.push({ method: "DELETE", pattern: p, handle: h }) + const put = (p: string, h: Handler) => table.push({ method: "PUT", pattern: p, handle: h }) + const empty = (data: unknown) => () => json(data) + + // global + get("/global/event", () => sse()) + get("/global/health", () => json({ ok: true })) + get("/global/config", () => json({})) + post("/global/dispose", () => json({})) + post("/global/upgrade", () => json(false)) + + // path / project + get("/path", () => + json({ + config: `${process.env.HOME}/.config/neuron`, + data: `${process.env.HOME}/.neuron`, + state: `${process.env.HOME}/.neuron`, + }), + ) + get("/project/current", () => json({ id: "local", worktree: cwd })) + get("/project/local/directories", () => json([cwd])) + get("/project/:projectID/directories", () => json([cwd])) + get("/project/:projectID", () => json({ id: "local" })) + + get("/conversations", () => json(sessions.listConversations())) + post("/conversation/:conversationID/rename", async (req, u, params) => { + const body = await req.json().catch(() => ({})) + sessions.renameConversation(params.conversationID, String(body.title ?? "")) + return json(true) + }) + + // sessions + get("/session", () => { + const out: unknown[] = [] + for (const node of (graph as any).allNodes?.() ?? []) { + if (node.kind !== "session") continue + out.push(sessionSummary(node.address.replace("session:", ""))) + } + return json(out) + }) + post("/session", async (req) => { + const body = await req.json().catch(() => ({})) + const created = sessions.create({ id: body.id }) + publish("session.created", { info: sessionSummary(created.sessionID) }) + return json(created) + }) + get("/session/status", () => json([])) + get("/session/:sessionID", (_r, u) => json(sessionSummary(u.searchParams.get("sessionID") ?? ""))) + del("/session/:sessionID", () => json(undefined)) + get("/session/:sessionID/messages", (_r, u, params) => json(messages(params.sessionID))) + get("/session/:sessionID/children", () => json([])) + get("/session/:sessionID/todo", () => json([])) + get("/session/:sessionID/diff", () => json([])) + post("/session/:sessionID/abort", () => json(true)) + post("/session/:sessionID/init", () => json(undefined)) + post("/session/:sessionID/fork", () => json(undefined)) + post("/session/:sessionID/revert", () => json(undefined)) + post("/session/:sessionID/unrevert", () => json(undefined)) + post("/session/:sessionID/summarize", () => json(undefined)) + post("/session/:sessionID/shell", () => json(undefined)) + post("/session/:sessionID/share", () => json(undefined)) + del("/session/:sessionID/share", () => json(undefined)) + get("/session/:sessionID/message/:messageID", () => json(null)) + del("/session/:sessionID/message/:messageID", () => json(undefined)) + + // chat: admit + wake execution (fire-and-forget per V2 admission semantics) + post("/session/:sessionID/message", async (req, u, params) => { + const body = await req.json().catch(() => ({})) + const parts = body.parts ?? [] + const text = parts.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") + const sessionID = params.sessionID + void (async () => { + try { + await input.sessions.run.call(input.sessions as any, sessionID) + publish("session.status", { sessionID, status: { type: "idle" } }) + } catch (err) { + publish("session.error", { sessionID, error: { name: "Unknown", message: String(err) } }) + } + })() + return json({ accepted: true, sessionID }) + }) + post("/session/:sessionID/prompt_async", async (req, u) => { + const body = await req.json().catch(() => ({})) + sessions.prompt({ sessionID: u.searchParams.get("sessionID") ?? "", prompt: String(body.prompt ?? ""), resume: false }) + return json({ queued: true }) + }) + + // vcs / files / find + get("/vcs/status", () => json({ branch: undefined })) + get("/vcs", () => json([])) + get("/vcs/diff", () => json({ files: [] })) + get("/file/status", () => json({})) + get("/file/content", () => json({ type: "text", content: "" })) + get("/file", () => json("")) + get("/find", () => json([])) + get("/find/file", () => json([])) + get("/find/symbol", () => json([])) + + // misc integrations (canonical empties) + get("/lsp", () => json([])) + get("/formatter", () => json([])) + get("/mcp", () => json({})) + get("/command", () => json([])) + get("/agent", () => json([])) + get("/skill", () => json([])) + get("/permission", () => json([])) + post("/permission/:requestID/reply", () => json(true)) + get("/question", () => json([])) + post("/question/:requestID/reply", () => json(undefined)) + post("/question/:requestID/reject", () => json(undefined)) + get("/provider", () => json([])) + get("/provider/auth", () => json({})) + get("/auth/:providerID", () => json({ type: "api", key: "" })) + post("/auth/:providerID", () => json(undefined)) + get("/config/providers", () => json({})) + get("/config", () => json({})) + put("/config", () => json({})) + get("/experimental/tool/ids", () => json([])) + get("/experimental/capabilities", () => json({})) + + function paramsOf(url: URL): Record { + return Object.fromEntries(url.searchParams.entries()) + } + + // ---- matcher ---- + + function match(method: string, pathname: string) { + const actual = pathname.split("/") + for (const r of table) { + const segs = r.pattern.split("/") + if (r.method !== method || segs.length !== actual.length) continue const params: Record = {} let ok = true for (let i = 0; i < segs.length; i++) { @@ -150,7 +239,7 @@ export function serve(input: { break } } - if (ok) return { key, params } + if (ok) return { handler: r.handle, params } } return null } @@ -159,17 +248,17 @@ export function serve(input: { port: input.port ?? 4096, async fetch(req) { const url = new URL(req.url) - if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "*", "access-control-allow-methods": "*" } }) + if (req.method === "OPTIONS") + return new Response(null, { status: 204, headers: CORS }) const found = match(req.method, url.pathname) if (!found) return json({ error: "not found", path: url.pathname }, 404) try { - const rewritten = new URL(url.toString()) - return await route[found.key](req, rewritten) - } catch (e) { - return json({ error: String(e) }, 500) + return await found.handler(req, url, found.params) + } catch (err) { + return json({ error: String(err) }, 500) } }, }) - return { server, graph, sessions } + return { server } } diff --git a/packages/neuron/src/session/sessions.ts b/packages/neuron/src/session/sessions.ts index 968ba29ed5..1714749d6e 100644 --- a/packages/neuron/src/session/sessions.ts +++ b/packages/neuron/src/session/sessions.ts @@ -20,6 +20,30 @@ import * as Context from "./context" const MAX_PROVIDER_TURNS = 16 +export interface ConversationSummary { + conversationID: string + title: string + updatedAt: number +} + +/** + * People pick conversations by TITLE; ids are machine plumbing. + * listConversations() is the human-facing pickup list. + */ +export interface Interface { + create(input?: { id?: string }): { sessionID: string } + listConversations(): Array + renameConversation(conversationID: string, title: string): void + prompt(input: { + sessionID: string + prompt: string + resume?: boolean + conversationID?: string + }): { admittedSeq: number } + interrupt(sessionID: string): void + run(sessionID: string): Promise +} + interface Provider { baseURL: string authHeaders: Record @@ -60,9 +84,10 @@ export function open(input: { return { sessionNodeId: node.id } } - function conversationFor(sessionID: string): { nodeId: string } { - // Stable binding: one conversation per session, addressed by its id. - const conv = Conversation.openConversation(graph, `conv-${sessionID}`) + function conversationFor(sessionID: string, conversationID?: string): { nodeId: string } { + // Explicit pickup: a new session attaching to an existing conversation. + const address = conversationID ? `conversation:${conversationID}` : `conversation:conv-${sessionID}` + const conv = Conversation.openConversation(graph, address) const session = graph.getNode(`session:${sessionID}`) if (!session) throw new Error(`unknown session: ${sessionID}`) if (graph.incident(session.id, "converses-in").length === 0) { @@ -95,6 +120,33 @@ export function open(input: { return { sessionID } }, + /** Human-facing: conversations with titles, for pickup lists. */ + listConversations() { + const out: Array<{ conversationID: string; title: string; updatedAt: number }> = [] + for (const node of graph.nodesByKind("conversation")) { + const turns = Conversation.transcript(graph, node.id) + const lastAt = turns.at(-1)?.at ?? node.created_at + const titled = graph + .outgoing(node.id, "titled") + .map((e) => graph.payload<{ title: string }>(e)?.title) + .filter(Boolean) + .at(-1) + const firstPrompt = turns.find((t) => t.author === "will" && t.text)?.text ?? "" + const title = titled ?? (firstPrompt.slice(0, 60) || "(untitled)") + out.push({ + conversationID: node.address.replace("conversation:", ""), + title, + updatedAt: lastAt, + }) + } + return out.sort((a, b) => b.updatedAt - a.updatedAt) + }, + + renameConversation(conversationID: string, title: string) { + const conv = Conversation.openConversation(graph, `conversation:${conversationID}`) + graph.edge(conv.nodeId, conv.nodeId, "titled", { title }) + }, + prompt(input) { ensureSession(input.sessionID) const inboxNode = graph.ensureNode("inbox", inboxAddress(input.sessionID))