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");
+ });
+});