From c55bae0de6d8b2b0d49efaf6191175d9aff87bc2 Mon Sep 17 00:00:00 2001 From: Amos Date: Sat, 22 Aug 2026 09:06:24 +0300 Subject: [PATCH] Wave 0: shared contracts + extension points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/core/contracts.ts: plan tree / graph / ledger / job protocol / artifact registry types — the single source of truth for all workstreams - tools: per-request factory createTeacherTools({chatId}) composing questionTools + planTools (stub) + jobTools (stub); quiz gains nodeId - prompt composition: system-prompt.md + sorted src/agent/prompts/*.md - ChatRecord.tree + saveTree; pending gate scoped to quiz/ask (+findPendingJobCall) - chat.tsx: artifact renderer extension point + ChatActionsContext.syncFromServer --- components/artifacts/index.ts | 19 +++ components/chat.tsx | 37 +++++- src/agent/prompts/README.md | 13 ++ src/agent/teacher-agent.ts | 37 ++++-- src/agent/tools.ts | 46 +++++-- src/agent/tools/jobs.ts | 13 ++ src/agent/tools/plan.ts | 16 +++ src/core/contracts.ts | 227 ++++++++++++++++++++++++++++++++++ src/core/pending.ts | 28 ++++- src/server/chat-handler.ts | 3 +- src/store/chats.ts | 13 ++ 11 files changed, 429 insertions(+), 23 deletions(-) create mode 100644 components/artifacts/index.ts create mode 100644 src/agent/prompts/README.md create mode 100644 src/agent/tools/jobs.ts create mode 100644 src/agent/tools/plan.ts create mode 100644 src/core/contracts.ts diff --git a/components/artifacts/index.ts b/components/artifacts/index.ts new file mode 100644 index 0000000..c8feafc --- /dev/null +++ b/components/artifacts/index.ts @@ -0,0 +1,19 @@ +import type { ComponentType } from "react"; +import type { ArtifactPartProps } from "@/src/core/contracts"; + +/** + * Artifact renderer registry — the extension point for tool-part UI. + * + * chat.tsx renders any tool part it doesn't specially handle (i.e. not + * quiz/ask) by looking the tool name up here. Add your renderer — do NOT + * edit chat.tsx (see docs/plan.md file-ownership rules). + * + * Typical shape: while the part is input-available render an activity card + * (poll GET /api/jobs?chatId=…; on completion call + * ChatActionsContext.syncFromServer()); when output-available render the + * artifact itself from part.output. + */ +export const artifactRenderers: Record> = { + // W2: "research" → job activity card + // W6: "demo" → sandboxed iframe card, "visual" → svg card +}; diff --git a/components/chat.tsx b/components/chat.tsx index aeb0bf3..9eb5bf7 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createContext, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport, isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls } from "ai"; import { useStickToBottomContext } from "use-stick-to-bottom"; @@ -15,16 +15,30 @@ import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-e import { Shimmer } from "@/components/ai-elements/shimmer"; import { Separator } from "@/components/ui/separator"; import { AnsweredCard } from "@/components/answered-card"; +import { artifactRenderers } from "@/components/artifacts"; import { Composer } from "@/components/composer"; import { QuestionDock } from "@/components/question-dock"; import { SidebarToggle } from "@/components/sidebar"; import type { TeacherUIMessage } from "@/src/agent/ui-message"; +import type { ArtifactPartProps } from "@/src/core/contracts"; import type { QuestionAnswerData } from "@/src/core/question-core"; import { findPendingToolCall } from "@/src/core/pending"; /** One width for everything: transcript, cards, and the bottom blob share this column. */ const COLUMN = "mx-auto w-full max-w-3xl px-4"; +/** + * Server-sync channel for artifact renderers (job cards): when a background + * job flips its tool part to output-available server-side, the card polls + * /api/jobs, then calls syncFromServer() — fresh messages replace local state + * and the sendAutomaticallyWhen continuation resumes the teacher. + * (contracts.ts "THE ASYNC PROTOCOL" §4.) + */ +export const ChatActionsContext = createContext<{ + chatId: string; + syncFromServer: () => Promise; +} | null>(null); + /** useLayoutEffect that is silent during SSR (client components still render on the server in Next). */ const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; @@ -48,7 +62,7 @@ export function Chat({ initialMessages: TeacherUIMessage[]; initialPrompt?: string; }) { - const { messages, sendMessage, addToolOutput, status, error, stop } = + const { messages, sendMessage, addToolOutput, setMessages, status, error, stop } = useChat({ id: chatId, messages: initialMessages, @@ -80,6 +94,13 @@ export function Chat({ const [text, setText] = useState(""); const canSend = text.trim().length > 0 && !pending; + const syncFromServer = useCallback(async () => { + const res = await fetch(`/api/chats/${chatId}`); + if (!res.ok) return; + const record = (await res.json()) as { messages?: TeacherUIMessage[] }; + if (Array.isArray(record.messages)) setMessages(record.messages); + }, [chatId, setMessages]); + const onSend = () => { const t = text.trim(); if (!t || busy || pending) return; @@ -88,6 +109,7 @@ export function Chat({ }; return ( +
@@ -138,8 +160,16 @@ export function Chat({ } return null; } - default: + default: { + // Artifact extension point (contracts.ts): job + // parts and future artifacts render via the + // registry — add renderers there, not here. + if (part.type.startsWith("tool-")) { + const Renderer = artifactRenderers[part.type.slice("tool-".length)]; + if (Renderer) return ; + } return null; + } } }) )} @@ -182,6 +212,7 @@ export function Chat({
+
); } diff --git a/src/agent/prompts/README.md b/src/agent/prompts/README.md new file mode 100644 index 0000000..99538ad --- /dev/null +++ b/src/agent/prompts/README.md @@ -0,0 +1,13 @@ +# Prompt fragments + +The teacher's system prompt is composed at agent creation: + +1. `src/agent/system-prompt.md` — the base (owned by the orchestrator; do not edit from a worker). +2. Every `*.md` file in this directory, in **sorted filename order**, joined with `---` separators. + +Rules for adding a fragment: + +- Name it with a numeric order prefix: `10-plan-tree.md`, `20-jobs.md`, `30-phases.md`. +- One fragment per workstream — your brief tells you which filename you own. +- Document YOUR tools/behaviors exactly; reference tool input field names verbatim (they must match the zod schemas). +- Keep it tight: fragments are always in context. No restating the base prompt. diff --git a/src/agent/teacher-agent.ts b/src/agent/teacher-agent.ts index 93e84ba..3b43a39 100644 --- a/src/agent/teacher-agent.ts +++ b/src/agent/teacher-agent.ts @@ -1,8 +1,9 @@ import * as fs from "node:fs"; -import { ToolLoopAgent, stepCountIs, type LanguageModel } from "ai"; +import * as path from "node:path"; +import { ToolLoopAgent, stepCountIs, type LanguageModel, type ToolSet } from "ai"; import { openrouter } from "@openrouter/ai-sdk-provider"; import { PATHS } from "../../config.ts"; -import { teacherTools } from "./tools.ts"; +import { createTeacherTools } from "./tools.ts"; /** * The teacher agent. Everything pi used to provide (agent loop, tool calling, @@ -10,13 +11,32 @@ import { teacherTools } from "./tools.ts"; * * The model is injectable so tests can pass a MockLanguageModelV4 and drive * deterministic scripted lessons (see tests/chat-flow.test.ts). + * + * The system prompt is COMPOSED: system-prompt.md is the base, then every + * src/agent/prompts/*.md fragment in sorted filename order (workers add + * fragments — nobody edits shared sections of the base prompt). Prefix + * fragments with a numeric order key: 10-plan-tree.md, 20-jobs.md, … */ const DEFAULT_MODEL = process.env.LH_MODEL ?? "anthropic/claude-sonnet-4.5"; -const instructions = fs.readFileSync(PATHS.teacherPrompt, "utf-8"); +const PROMPTS_DIR = path.join(PATHS.root, "src", "agent", "prompts"); -export function createTeacherAgent(model?: LanguageModel) { +function composeInstructions(): string { + const base = fs.readFileSync(PATHS.teacherPrompt, "utf-8"); + if (!fs.existsSync(PROMPTS_DIR)) return base; + const fragments = fs + .readdirSync(PROMPTS_DIR) + .filter((f) => f.endsWith(".md")) + .sort() + .map((f) => fs.readFileSync(path.join(PROMPTS_DIR, f), "utf-8").trim()) + .filter(Boolean); + return [base.trim(), ...fragments].join("\n\n---\n\n"); +} + +const instructions = composeInstructions(); + +export function createTeacherAgent(model?: LanguageModel, tools?: ToolSet) { return new ToolLoopAgent({ model: model ?? @@ -26,9 +46,12 @@ export function createTeacherAgent(model?: LanguageModel) { reasoning: { enabled: true, effort: "medium" }, }), instructions, - tools: teacherTools, - // Both tools are client-side (no execute) — a tool call ends the server - // turn automatically. stepCountIs is just the safety net. + // Tools are per-request (plan tools close over chatId); chat-handler + // passes createTeacherTools({ chatId }). Tests pass a mock model and get + // the default question-only set. + tools: tools ?? createTeacherTools({ chatId: "" }), + // Question and job tools are client-side — a call ends the server turn + // automatically. stepCountIs is just the safety net. stopWhen: stepCountIs(20), }); } diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 2b8ba24..db1e778 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -1,16 +1,21 @@ -import { tool } from "ai"; +import { tool, type ToolSet } from "ai"; import { z } from "zod"; +import { jobTools } from "./tools/jobs.ts"; +import { planTools } from "./tools/plan.ts"; /** - * The teacher's interaction tools — CLIENT-SIDE tools (no `execute`). + * The teacher's tools, composed per request by createTeacherTools. * - * The model's tool call is not executed on the server; it streams to the - * browser as a typed tool part in `input-available` state. The learner - * answers in the rendered card; the client grades (quiz) via - * src/core/question-core.ts and sends the verdict back with addToolOutput. + * - questionTools (quiz / ask_user_question): CLIENT-SIDE tools (no `execute`). + * The tool call streams to the browser in `input-available` state; the + * learner answers in the dock; the client grades via question-core and + * sends the verdict with addToolOutput. + * - planTools (src/agent/tools/plan.ts): SERVER-SIDE, mutate the chat's plan + * tree (needs the chatId closure — hence the per-request factory). + * - jobTools (src/agent/tools/jobs.ts): CLIENT-SIDE semantics, server-side + * fulfillment by background sub-agents (see src/core/contracts.ts). * - * Parameter names mirror src/agent/system-prompt.md exactly — the prompt - * documents these fields to the model. Keep them in sync. + * Parameter names mirror the prompt docs exactly — keep them in sync. */ const optionSchema = z.object({ @@ -41,6 +46,13 @@ export const quizInputSchema = z.object({ .describe("Explanation revealed AFTER the learner answers. Always say why the correct answer is correct."), multiSelect: z.boolean().optional().describe("Set true only when more than one option is correct"), details: z.string().optional().describe("Optional extra context or instructions shown under the question"), + nodeId: z + .string() + .optional() + .describe( + "Optional plan-tree node id this question assesses. Set whenever the question targets a tree node — " + + "the outcome feeds the learner's knowledge graph. Omit for ad-hoc probes.", + ), }); export const askInputSchema = z.object({ @@ -76,10 +88,26 @@ export const askUserQuestionTool = tool({ inputSchema: askInputSchema, }); -export const teacherTools = { +export const questionTools = { quiz: quizTool, ask_user_question: askUserQuestionTool, }; +export interface TeacherToolContext { + chatId: string; +} + +/** + * Per-request tool registry. Plan tools close over chatId for persistence; + * question and job tools are context-free. + */ +export function createTeacherTools(ctx: TeacherToolContext): ToolSet { + return { + ...questionTools, + ...planTools(ctx), + ...jobTools(ctx), + }; +} + export type QuizToolInput = z.infer; export type AskToolInput = z.infer; diff --git a/src/agent/tools/jobs.ts b/src/agent/tools/jobs.ts new file mode 100644 index 0000000..ba7fa02 --- /dev/null +++ b/src/agent/tools/jobs.ts @@ -0,0 +1,13 @@ +import type { ToolSet } from "ai"; +import type { TeacherToolContext } from "../tools.ts"; + +/** + * Async sub-agent job tools — W2 workstream (research; W6 adds demo + visual). + * CLIENT-SIDE semantics (no execute): the turn ends with the part pending, a + * background runner fulfills it, and the client continuation resumes the + * teacher. See src/core/contracts.ts "THE ASYNC PROTOCOL" — read it first. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function jobTools(ctx: TeacherToolContext): ToolSet { + return {}; +} diff --git a/src/agent/tools/plan.ts b/src/agent/tools/plan.ts new file mode 100644 index 0000000..a1df1e2 --- /dev/null +++ b/src/agent/tools/plan.ts @@ -0,0 +1,16 @@ +import type { ToolSet } from "ai"; +import type { TeacherToolContext } from "../tools.ts"; + +/** + * Plan-tree tools — W1 workstream (docs/plan.md Wave 1; src/core/contracts.ts + * "Plan tree" section). Server-side tools WITH execute that mutate the chat's + * stored PlanTree and return the full updated tree (PlanToolResult). + * + * Tool names to implement: plan_init, plan_add_node, plan_update_node, + * plan_remove_node, plan_add_edge, plan_remove_edge, plan_set_current, + * plan_mark_done. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function planTools(ctx: TeacherToolContext): ToolSet { + return {}; +} diff --git a/src/core/contracts.ts b/src/core/contracts.ts new file mode 100644 index 0000000..3c64b90 --- /dev/null +++ b/src/core/contracts.ts @@ -0,0 +1,227 @@ +/** + * Shared contracts — the single source of truth every workstream codes against. + * Pure types + protocol docs; no runtime code, no imports (safe everywhere: + * server, client, tests). See docs/plan.md (waves) and docs/vision.md (§ refs). + * + * Owners: orchestrator wrote this file. Workers: do NOT edit it; if a contract + * looks wrong or is missing something you need, ask the orchestrator. + */ + +// ─── Plan tree (vision §2 "formal plan tree") ──────────────────────────────── +// A session-scoped dependency tree authored by the teacher via plan tools. +// One substrate with the global graph: tree nodes fold into graph nodes. + +export interface PlanNode { + /** Opaque, agent-chosen slug, stable across edits: "chain-rule-intuition". */ + id: string; + /** Short label for the tree panel. */ + title: string; + /** 1-2 sentences: what this node covers. */ + summary?: string; +} + +export interface PlanEdge { + /** Prerequisite node id. */ + from: string; + /** Node that depends on `from`. */ + to: string; +} + +export interface PlanTree { + nodes: PlanNode[]; + edges: PlanEdge[]; + currentNodeId: string | null; + doneNodeIds: string[]; + /** Increments on every mutation — lets the UI animate/react to changes. */ + version: number; +} + +/** Derived, never stored: status of a node for the panel (vision §2). */ +export type PlanNodeStatus = "done" | "current" | "upcoming"; + +export function planNodeStatus(tree: PlanTree, nodeId: string): PlanNodeStatus { + if (tree.currentNodeId === nodeId) return "current"; + if (tree.doneNodeIds.includes(nodeId)) return "done"; + return "upcoming"; +} + +/** + * Plan tools (W1 implements; server-side WITH execute, mutate the chat's + * stored tree, and return the full updated tree so the panel can render the + * latest state straight from the stream). Every tool returns PlanToolResult. + * Tool names: plan_init, plan_add_node, plan_update_node, plan_remove_node, + * plan_add_edge, plan_remove_edge, plan_set_current, plan_mark_done. + * The tree panel derives its display from the LATEST plan tool output in the + * transcript (live during streaming) — no separate state channel. + */ +export interface PlanToolResult { + ok: boolean; + error?: string; + tree: PlanTree; +} + +// ─── Knowledge graph (vision §1 — global, cross-session) ──────────────────── + +export type EdgeType = "requires" | "supports"; + +export interface GraphNode { + /** Opaque stable id, store-assigned: "n_" + nanoid-like. */ + id: string; + /** Canonical proposition statement. */ + statement: string; + /** Verbatim alternate statements from sessions (merge target audit trail). */ + aliases: string[]; + kind: "truth" | "misconception"; + createdAt: string; +} + +export interface GraphEdge { + /** Prerequisite GraphNode id. */ + from: string; + to: string; + type: EdgeType; + /** Chat ids that asserted this edge (teacher may simplify — auditable). */ + provenance: string[]; +} + +export type LedgerEventKind = + | "probe-correct" + | "probe-miss" + | "dont-know" + | "misconception-signaled" + | "taught" + | "check-correct" + | "check-incorrect"; + +export interface LedgerEvent { + /** null = not yet attributed (untagged probe events; consolidation assigns). */ + nodeId: string | null; + kind: LedgerEventKind; + chatId: string; + at: string; + /** The learner's quiz note — diagnostic gold (vision §1 calibration). */ + note?: string; + /** The quiz question text, when nodeId is null (later attribution input). */ + question?: string; +} + +/** + * Store format (W4 implements src/store/graph.ts): JSONL ledgers at + * config.LEDGER_FILES (nodes/edges/events/mappings/sessions). The derived + * graph is rebuilt by reading the ledgers — store events, derive state. + * sessions.jsonl tracks which chats have been folded into the graph + * (emission checkpoint — never fold the same turn twice). + * + * v1 emission (naive union, deterministic, no LLM): a chat's plan tree folds + * in — node title → statement (aliases empty), tree edges → requires edges, + * mark_done → "taught" events; quiz parts with input.nodeId → probe / check + * events (+ note); quiz parts without nodeId → events with nodeId null. + */ + +// ─── Async sub-agent jobs (vision §2.5; plan.md Wave 0 protocol) ──────────── + +export type JobKind = "research" | "demo" | "visual"; +export type JobStatus = "running" | "done" | "error"; + +export interface JobRecord { + id: string; + kind: JobKind; + chatId: string; + /** The tool part this job fulfills. */ + toolCallId: string; + status: JobStatus; + /** Latest human-readable activity line, e.g. `web_search: "sigma.js v3"`. */ + activity: string; + createdAt: string; + updatedAt: string; + /** The tool output payload when done (ResearchResult | DemoResult | VisualResult). */ + result?: unknown; + error?: string; +} + +/** + * THE ASYNC PROTOCOL — "client-side-tool semantics, server-side fulfillment": + * + * 1. Job tools (research / demo / visual) are CLIENT-SIDE tools (no execute), + * exactly like quiz/ask. The model calls one; the server turn ends with the + * tool part in `input-available` state. (The teacher is prompted to say its + * piece BEFORE calling, since its turn ends at the call.) + * 2. chat-handler's onFinish (the integration point) scans the new assistant + * message for job tool parts and spawns one background runner per part + * (src/agent/subagents/…, W2/W6). The job store (src/store/jobs.ts) is + * keyed by toolCallId. + * 3. On completion the runner REWRITES the part in the stored chat record to + * `output-available` with the job result as output (read-modify-write of + * the LATEST record — never a stale copy). + * 4. The client polls GET /api/jobs?chatId=… while any job part is pending; + * when a part flips, it re-syncs messages from the server + * (ChatActionsContext.syncFromServer) and the existing + * `sendAutomaticallyWhen` continuation fires — the teacher receives the + * result as a normal tool output and resumes. No new stream machinery. + * + * 5. The composer gate (findPendingToolCall) covers ONLY quiz/ask — but the + * protocol still forbids sending a user message while a job part is + * pending (an unanswered tool call in the outbound list breaks provider + * conversion). So job parts ALSO gate SENDING, with a distinct affordance + * ("waiting for …" + live activity) — the learner can keep reading, + * scrolling, and typing notes in the field; the send unlocks on arrival. + * True mid-job interjection (concurrent turns) is a documented later + * upgrade, not v1. + */ + +export interface ResearchToolInput { + question: string; + /** What the teacher needs it for — focuses the research brief. */ + context?: string; +} + +export interface ResearchResult { + brief: string; + sources: { title: string; url: string }[]; +} + +export interface DemoToolInput { + title: string; + /** Precise brief: concept, parameters/interactions, visual style. */ + brief: string; +} + +export interface DemoResult { + title: string; + /** Self-contained single-file HTML/JS document (sandboxed-iframe ready). */ + html: string; + /** The builder sub-agent headlessly executed the demo before delivery. */ + tested: true; + notes?: string; +} + +export interface VisualToolInput { + /** Precise brief for ONE visual (what it must show, exactly). */ + brief: string; +} + +export interface VisualResult { + /** Complete SVG markup (verified by the maker looking at the render). */ + svg: string; + notes?: string; +} + +// ─── Artifact renderer registry (client extension point) ──────────────────── + +/** + * components/chat.tsx renders any tool part it doesn't specially handle via + * components/artifacts/index.ts's registry, keyed by tool name ("research", + * "demo", "visual", …). Workers add renderers there — chat.tsx is off-limits + * to artifact work. Renderer receives the raw tool part; it typically renders + * an activity card while state is input-available (poll /api/jobs) and the + * artifact (iframe / svg) when output-available. + */ +export interface ArtifactPartProps { + part: { + type: string; + state: string; + toolCallId: string; + input?: unknown; + output?: unknown; + }; +} diff --git a/src/core/pending.ts b/src/core/pending.ts index 51c7390..37c7f4a 100644 --- a/src/core/pending.ts +++ b/src/core/pending.ts @@ -1,9 +1,13 @@ import { isToolUIPart, type UIMessage } from "ai"; /** - * The pending-question gate: an unanswered interaction tool part (quiz/ask) + * The pending-question gate: an unanswered QUESTION tool part (quiz/ask) * blocks the composer — one question at a time, mirroring the teacher's * prompt contract. Derived client state; the server enforces nothing. + * + * Job tool parts (research/demo/visual) are NOT returned here — they gate + * sending separately with a "waiting for " affordance (see contracts.ts + * "THE ASYNC PROTOCOL" §5; use findPendingJobCall). */ export interface PendingToolCall { toolCallId: string; @@ -12,15 +16,33 @@ export interface PendingToolCall { input: unknown; } -export function findPendingToolCall(messages: UIMessage[]): PendingToolCall | null { +/** Tools that render in the question dock and gate the composer. */ +export const QUESTION_TOOLS = ["quiz", "ask_user_question"] as const; + +/** Tools fulfilled by background sub-agent jobs (W2/W6). */ +export const JOB_TOOLS = ["research", "demo", "visual"] as const; + +function findPending(messages: UIMessage[], names: readonly string[]): PendingToolCall | null { for (let m = messages.length - 1; m >= 0; m--) { const msg = messages[m]!; for (let p = msg.parts.length - 1; p >= 0; p--) { const part = msg.parts[p]!; if (isToolUIPart(part) && part.state === "input-available") { - return { toolCallId: part.toolCallId, tool: part.type.slice("tool-".length), input: part.input }; + const tool = part.type.slice("tool-".length); + if (names.includes(tool)) { + return { toolCallId: part.toolCallId, tool, input: part.input }; + } } } } return null; } + +export function findPendingToolCall(messages: UIMessage[]): PendingToolCall | null { + return findPending(messages, QUESTION_TOOLS); +} + +/** The running background job, if any (input-available research/demo/visual part). */ +export function findPendingJobCall(messages: UIMessage[]): PendingToolCall | null { + return findPending(messages, JOB_TOOLS); +} diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index 4c9f835..ab0d261 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -1,5 +1,6 @@ import { createAgentUIStreamResponse, type UIMessage } from "ai"; import { createTeacherAgent, type TeacherAgent } from "../agent/teacher-agent.ts"; +import { createTeacherTools } from "../agent/tools.ts"; import { getChat, saveMessages } from "../store/chats.ts"; /** @@ -27,7 +28,7 @@ export async function handleChatRequest(req: Request, agent?: TeacherAgent): Pro } return createAgentUIStreamResponse({ - agent: agent ?? createTeacherAgent(), + agent: agent ?? createTeacherAgent(undefined, createTeacherTools({ chatId })), uiMessages: body.messages as UIMessage[], onFinish: ({ messages, isAborted }) => { // Persist even when aborted: the partial transcript (including any diff --git a/src/store/chats.ts b/src/store/chats.ts index 7b1dcee..802796e 100644 --- a/src/store/chats.ts +++ b/src/store/chats.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import type { UIMessage } from "ai"; import { PATHS } from "../../config.ts"; +import type { PlanTree } from "../core/contracts.ts"; /** * Chat store — one JSON file per chat in data/chats/. @@ -19,6 +20,8 @@ export interface ChatRecord { createdAt: string; updatedAt: string; messages: UIMessage[]; + /** The session's plan tree (vision §2) — written by plan tools via saveTree. */ + tree?: PlanTree; } export interface ChatSummary { @@ -80,6 +83,16 @@ export function saveMessages(id: string, messages: UIMessage[]): void { fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8"); } +/** Persist the session plan tree (plan tools call this in execute). */ +export function saveTree(id: string, tree: PlanTree): void { + const record = getChat(id); + if (!record) throw new Error(`Chat not found: ${id}`); + record.tree = tree; + // Deliberately does NOT bump updatedAt: tree mutations piggyback on the + // turn's saveMessages, and sidebar ordering shouldn't churn on tree edits. + fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8"); +} + /** Derive a short title from the first user message. */ export function titleFromPrompt(prompt: string): string { const oneLine = prompt.replace(/\s+/g, " ").trim();