diff --git a/app/api/chats/route.ts b/app/api/chats/route.ts index 34aa713..e908857 100644 --- a/app/api/chats/route.ts +++ b/app/api/chats/route.ts @@ -1,3 +1,5 @@ +import { chainsForSelection, formatChainLine } from "@/src/core"; +import { newGraphContextMessage, type GraphContextPick } from "@/src/core"; import { createChat, isTurnRunning, listChats, readSettingsRecord, stores, titleFromPrompt } from "@/src/server"; import type { ChatListItem } from "@/src/core"; @@ -17,7 +19,7 @@ export async function GET() { } export async function POST(req: Request) { - const body = (await req.json().catch(() => ({}))) as { title?: unknown; kind?: unknown }; + const body = (await req.json().catch(() => ({}))) as { title?: unknown; kind?: unknown; graphContext?: unknown }; const title = typeof body.title === "string" ? body.title : ""; // Practice sessions (owner, 2026-10-04): kind "practice" marks the // record — the practice agent + its tools from turn 1, no extraction. @@ -37,5 +39,32 @@ export async function POST(req: Request) { thinkingLevel: strong.thinkingLevel, }); } + // The session's map context (owner, 2026-10-04): the picked + // propositions ride the CREATION — message[0] is a graph-context + // marker (a separate custom message BEFORE the learner's first user + // message), carrying the picks plus the reasoning chains among them. + // The picks carry their own name/statement (what the learner saw at + // pick time); the chains derive from the CURRENT graph snapshot. + const rawPicks = (body.graphContext as { propositions?: unknown } | undefined)?.propositions; + const picks: GraphContextPick[] = Array.isArray(rawPicks) + ? rawPicks + .filter( + (p): p is GraphContextPick => + typeof p === "object" && + p !== null && + typeof (p as GraphContextPick).id === "string" && + typeof (p as GraphContextPick).name === "string" && + typeof (p as GraphContextPick).statement === "string", + ) + .slice(0, 64) + : []; + if (picks.length > 0) { + const byId = new Map(stores.graphStore.readNodes().map((n) => [n.id, n])); + const pickedNodes = picks.map((p) => byId.get(p.id)).filter((n) => n !== undefined); + const chainLines = chainsForSelection(pickedNodes, stores.graphStore.readChains()).map((c) => + formatChainLine(c, Object.fromEntries(byId)), + ); + stores.chatStore.saveMessages(chat.id, [newGraphContextMessage(picks, chainLines)]); + } return Response.json({ id: chat.id, title: chat.title }); } diff --git a/components/chat.tsx b/components/chat.tsx index 1c0a80e..f9ed13d 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -38,6 +38,7 @@ import type { PlanBlockData } from "@/src/core"; import { QuestionDock } from "@/components/question-dock"; import { PracticeDock } from "@/components/practice-dock"; import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards"; +import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card"; import { TopBar } from "@/components/top-bar"; import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage } from "@/src/core"; import type { LhViewMessage } from "@/src/core"; @@ -1258,6 +1259,13 @@ export function Chat({ ); case "step-start": return pi > 0 ? : null; + case "data-graph-context": + // The session's map pick (2026-10-04): a minimal + // collapsible card — scaffolding above the first + // user message, never a bubble. + return ( + + ); case "data-practice-thinking": // The learner's practice submission (2026-10-04): // user-side — the register (thinking trace / diff --git a/components/graph-context-card.tsx b/components/graph-context-card.tsx new file mode 100644 index 0000000..3ca5901 --- /dev/null +++ b/components/graph-context-card.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useState } from "react"; +import { ChevronRightIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +/** + * The graph-context card (owner, 2026-10-04): the session's map pick — + * message[0] of a session started with propositions picked from the + * graph. Scaffolding, not a turn: minimal, muted, collapsed by default; + * expand reveals the picked propositions (name — statement) and the + * reasoning chains among them (premises → conclusion lines). The model + * reads the same content through the marker's own text (toLlmMessages); + * this card is the learner's face of it. + */ + +/** The graph-context marker's details (native-messages.ts). */ +export interface GraphContextData { + propositions: { id: string; name: string; statement: string }[]; + chains: string[]; +} + +export function GraphContextCard({ data }: { data: GraphContextData }) { + const [open, setOpen] = useState(false); + const { propositions = [], chains = [] } = data ?? {}; + const noun = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; + return ( +
+ + {open && ( +
+ {propositions.map((p) => ( +
+ {p.name} + — {p.statement} +
+ ))} + {chains.length > 0 && ( +
+ {chains.map((c, i) => ( +
+ {c} +
+ ))} +
+ )} +
+ )} +
+ ); +} diff --git a/components/home.tsx b/components/home.tsx index 64e82f2..de566d1 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -112,26 +112,25 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) { } setBusy(true); try { - // The practice seed: the picks ride the OPENING PROMPT (never - // the title — the title stays the learner's own words). - const seed = - mode === "practice" && practiceProps.length > 0 - ? `\n\nPropositions from the map:\n${practiceProps - .map((p) => `- ${p.name}: ${p.statement}`) - .join("\n")}` - : ""; + // The map context (owner, 2026-10-04): picks in EITHER mode ride + // the CREATION as graphContext — the server writes a + // graph-context marker as message[0] (a separate custom message + // before the learner's first user message, propositions + the + // chains among them); the prompt stays the learner's own words. const res = await fetch("/api/chats", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify( - mode === "practice" ? { title: t, kind: "practice" } : { title: t }, - ), + body: JSON.stringify({ + title: t, + ...(mode === "practice" ? { kind: "practice" } : {}), + ...(practiceProps.length > 0 ? { graphContext: { propositions: practiceProps } } : {}), + }), }); const { id } = (await res.json()) as { id: string }; // No settings PUT on create (per-session models, owner // 2026-09-28): the SERVER stamps the newborn from the strong // slot — the same default this surface's model control edits. - router.push(`/chat/${id}?prompt=${encodeURIComponent(t + seed)}`); + router.push(`/chat/${id}?prompt=${encodeURIComponent(t)}`); } catch { setBusy(false); } diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index 01cd048..a0d0190 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -65,6 +65,7 @@ export interface LhMarkerMessage { id: string; role: "custom"; customType: + | "graph-context" | "probe-handoff" | "subagent-steer" | "subagent-abort" @@ -187,6 +188,42 @@ export function newPlanBlockMessage( export const PRACTICE_PROBLEM_TOOL = "problem"; export const PRACTICE_CONCLUDE_TOOL = "conclude"; +/** One picked proposition of the graph-context marker (the learner's map + * pick — name + statement as the learner SAW them at pick time). */ +export interface GraphContextPick { + id: string; + name: string; + statement: string; +} + +/** The session's map context (owner, 2026-10-04): a SEPARATE custom + * message BEFORE the learner's first user message — the picked + * propositions plus the reasoning chains among them (pre-formatted + * lines, formatChainLine shape). The model reads `content` (flattened + * to a user message by toLlmMessages like every marker); `details` + * feeds the transcript's collapsible GraphContextCard. No more seed + * text glued into the first prompt. */ +export function newGraphContextMessage(picks: readonly GraphContextPick[], chains: readonly string[]): LhMarkerMessage { + const lines = [ + "Contextualization — the learner picked these propositions from their knowledge map when starting this session:", + "", + "Propositions:", + ...picks.map((p) => `- ${p.name}: ${p.statement}`), + ]; + if (chains.length > 0) { + lines.push("", "Reasoning chains among them (premises → conclusion):", ...chains.map((c) => `- ${c}`)); + } + return { + id: newMessageId(), + role: "custom", + customType: "graph-context", + content: lines.join("\n"), + display: true, + details: { propositions: picks, chains }, + timestamp: Date.now(), + }; +} + /** The learner's thinking submission (the practice dock's two buttons): * a user-register message the model reads WITH its label (hint vs * commit — the register is the whole difference), while `details` keeps diff --git a/src/core/view.ts b/src/core/view.ts index fde5214..81bb7ac 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -290,6 +290,17 @@ function applyResultTo(part: LhViewPart | undefined, result: ToolResultMessage): /** A marker's projected message (handoff/steers) or splice (plan block). */ function projectMarker(marker: LhMarkerMessage, out: LhViewMessage[]): void { switch (marker.customType) { + case "graph-context": { + // The session's map context (owner, 2026-10-04): user-side — + // a minimal collapsible card ABOVE the first user message; + // never a text bubble (scaffolding, like the thinking card). + out.push({ + id: marker.id, + role: "user", + parts: [{ type: "data-graph-context", id: `gc_${marker.id}`, data: marker.details }], + }); + return; + } case "probe-handoff": { out.push({ id: marker.id, diff --git a/tests/graph-context.test.ts b/tests/graph-context.test.ts new file mode 100644 index 0000000..937a84d --- /dev/null +++ b/tests/graph-context.test.ts @@ -0,0 +1,131 @@ +/** + * THE GRAPH-CONTEXT MARKER (owner, 2026-10-04 late): a session started + * with map picks gets its context as a SEPARATE custom message BEFORE + * the learner's first user message — the picked propositions plus the + * reasoning chains among them (formatChainLine lines), no more seed + * text glued into the opening prompt. The model reads the marker's + * content (toLlmMessages flattens it to a user message like every + * marker); `details` feeds the transcript's collapsible + * GraphContextCard (render-practice pins live in the render suite). + * + * The route pin drives POST /api/chats under an isolated LH_DATA_DIR + * (the birth-stamp discipline): picks ride the CREATION, the marker + * lands as message[0], and the chains derive from the live graph + * snapshot. + */ + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { after, describe, it } from "node:test"; + +// ── the seam, BEFORE any config-loading import ────────────────────────────── +const TMP_DATA = fs.mkdtempSync(path.join(os.tmpdir(), "lh-graph-context-")); +process.env.LH_DATA_DIR = TMP_DATA; + +const { newGraphContextMessage } = await import("../src/core/native-messages.ts"); +const { toLlmMessages } = await import("../src/core/native-messages.ts"); +import type { LhMessage } from "../src/core/native-messages.ts"; +const { projectView } = await import("../src/core/view.ts"); +const { POST } = await import("../app/api/chats/route.ts"); +const { chatStore } = await import("../src/store/chats.ts"); +const { graphStore } = await import("../src/store/graph.ts"); + +after(() => { + fs.rmSync(TMP_DATA, { recursive: true, force: true }); +}); + +const PICKS = [ + { id: "n_a", name: "Modus ponens", statement: "If P implies Q and P holds, Q holds." }, + { id: "n_b", name: "Soundness", statement: "A valid argument from true premises has a true conclusion." }, +]; + +// ── the pure marker ───────────────────────────────────────────────────────── + +describe("newGraphContextMessage", () => { + it("frames the context for the model: propositions listed, chains appended", () => { + const m = newGraphContextMessage(PICKS, ["Modus ponens → Soundness"]); + assert.equal(m.customType, "graph-context"); + assert.match(m.content, /Contextualization — the learner picked these propositions/); + assert.ok(m.content.includes("- Modus ponens: If P implies Q and P holds, Q holds.")); + assert.ok(m.content.includes("Reasoning chains among them")); + assert.ok(m.content.includes("- Modus ponens → Soundness")); + // the UI payload is the structured pick, not the flattened text + assert.deepEqual(m.details, { propositions: PICKS, chains: ["Modus ponens → Soundness"] }); + }); + + it("no chains: the chains section stays out entirely", () => { + const m = newGraphContextMessage(PICKS, []); + assert.ok(!m.content.includes("Reasoning chains")); + assert.deepEqual(m.details, { propositions: PICKS, chains: [] }); + }); + + it("toLlmMessages flattens it to a user message (the model's face)", () => { + const m = newGraphContextMessage(PICKS, ["Modus ponens → Soundness"]); + const wire = toLlmMessages([m as unknown as LhMessage] as never); + assert.equal(wire.length, 1); + assert.equal(wire[0]!.role, "user"); + assert.ok(String((wire[0]!.content as { text?: string }[])[0]!.text).includes("Contextualization")); + }); + + it("projectView: a user-side data part — never a text bubble", () => { + const m = newGraphContextMessage(PICKS, []); + const view = projectView([m as unknown as LhMessage] as never); + assert.equal(view.length, 1); + assert.equal(view[0]!.role, "user"); + const part = view[0]!.parts[0] as { type: string; data: unknown }; + assert.equal(part.type, "data-graph-context"); + assert.deepEqual(part.data, m.details); + }); +}); + +// ── the route (picks ride the creation) ───────────────────────────────────── + +describe("POST /api/chats — graphContext becomes message[0]", () => { + it("writes the marker before any user message; chains derive from the live graph", async () => { + // Seed the map: two picked nodes joined by one chain. + const node = (id: string, name: string, statement: string) => ({ + id, name, statement, aliases: [] as string[], createdAt: "2026-10-01T00:00:00.000Z", provenance: [], + }); + graphStore.appendNode(node("n_a", "Modus ponens", "If P implies Q and P holds, Q holds.")); + graphStore.appendNode(node("n_b", "Soundness", "A valid argument from true premises has a true conclusion.")); + graphStore.appendChain({ + id: "arg_1", premises: ["n_a"], conclusion: "n_b", + provenance: [], createdAt: "2026-10-01T00:00:00.000Z", + }); + + const res = await POST(new Request("http://test/api/chats", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: "drill me on logic", + kind: "practice", + graphContext: { propositions: PICKS }, + }), + })); + assert.equal(res.status, 200); + const { id } = (await res.json()) as { id: string }; + + const record = chatStore.getChat(id)!; + assert.equal(record.messages.length, 1, "the marker is message[0], alone until the first turn"); + const m = record.messages[0] as { role: string; customType: string; content: string }; + assert.equal(m.role, "custom"); + assert.equal(m.customType, "graph-context"); + assert.ok(m.content.includes("- Modus ponens: If P implies Q and P holds, Q holds.")); + // the chain AMONG the picks rides the marker (formatChainLine shape) + assert.ok(m.content.includes("- Modus ponens → Soundness")); + }); + + it("no picks (or malformed) → no marker, an empty record as before", async () => { + for (const body of [{ title: "t" }, { title: "t", graphContext: { propositions: "junk" } }]) { + const res = await POST(new Request("http://test/api/chats", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + })); + const { id } = (await res.json()) as { id: string }; + assert.equal(chatStore.getChat(id)!.messages.length, 0); + } + }); +}); diff --git a/tests/render-practice-surface.test.tsx b/tests/render-practice-surface.test.tsx index 0c30eee..a0a7911 100644 --- a/tests/render-practice-surface.test.tsx +++ b/tests/render-practice-surface.test.tsx @@ -19,6 +19,8 @@ import { describe, it } from "node:test"; import { PracticeDock } from "../components/practice-dock.tsx"; import { ConcludeCard, ProblemCard, ThinkingCard } from "../components/practice-cards.tsx"; +import { GraphContextCard } from "../components/graph-context-card.tsx"; +import { newGraphContextMessage } from "../src/core/native-messages.ts"; import { projectView } from "../src/core/view.ts"; import type { ArtifactPartProps } from "../src/core"; import { @@ -164,3 +166,37 @@ describe("the practice view projection", () => { assert.equal(toolPart.state, "input-available"); }); }); + +// ── the graph-context card (a session's map pick; collapsed by default) ───── + +describe("GraphContextCard", () => { + it("collapsed: counts only — the statements stay hidden until expanded", () => { + const html = renderToStaticMarkup( + , + ); + assert.ok(html.includes('data-testid="graph-context-card"')); + assert.ok(html.includes("2 propositions")); + assert.ok(html.includes("1 chain")); + // minimal: the collapsed bar carries NO statement text + assert.ok(!html.includes("If P implies Q")); + assert.ok(!html.includes("graph-context-body")); + }); + + it("zero chains: the chain count stays out of the summary", () => { + const html = renderToStaticMarkup( + , + ); + assert.ok(html.includes("1 proposition")); + assert.ok(!html.includes("chain")); + }); + + it("the marker projects to the card's part (projectView fold)", () => { + const view = projectView([newGraphContextMessage( + [{ id: "n1", name: "A", statement: "S." }], [], + ) as unknown as LhMessage]); + assert.equal((view[0]!.parts[0] as { type: string }).type, "data-graph-context"); + }); +});