feat(neuron): local API server, aspect kit (traced/metered/guarded), conversation picker data
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user