feat(neuron): local API server, aspect kit (traced/metered/guarded), conversation picker data

This commit is contained in:
2026-08-22 22:26:59 -05:00
parent 4bd656d3a9
commit 581220b718
6 changed files with 562 additions and 172 deletions
+87
View File
@@ -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<A extends unknown[], R> = (...args: A) => Promise<R>
function record(graph: Graph | undefined, kind: string, payload: Record<string, unknown>) {
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<A extends unknown[], R>(
graph: Graph | undefined,
name: string,
fn: AsyncFn<A, R>,
): AsyncFn<A, R> {
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<A extends unknown[], R>(graph: Graph | undefined, name: string, fn: AsyncFn<A, R>): AsyncFn<A, R> {
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<A extends unknown[], R>(
graph: Graph | undefined,
policy: () => Policy,
fn: AsyncFn<A, R>,
): AsyncFn<A, R> {
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)
}
}
+4
View File
@@ -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 = ?`)
+178 -38
View File
@@ -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)
+18
View File
@@ -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}`)
+220 -131
View File
@@ -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<string, unknown>) => {
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<string, (req: Request, url: URL) => Promise<Response> | 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<string, string> } | 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<string, string>) => Promise<Response> | 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<string, string> {
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<string, string> = {}
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 }
}
+55 -3
View File
@@ -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<ConversationSummary>
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<void>
}
interface Provider {
baseURL: string
authHeaders: Record<string, string>
@@ -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))