sessions: export as markdown — the sidebar right-click, the route, the practice join (release polish 3)

Owner round (2026-10-06): markdown export of sessions, actioned from
the sidebar context menu — "Export as Markdown…" sits with the
management actions, above Delete (the destructive action stays last).

- GET /api/chats/:id/markdown (404 unknown): the whole session as one
  attachment — text/markdown; charset=utf-8, filename from the slugified
  title ("relational-databases-joins-normal-forms.md", non-ascii titles
  fall back to "session.md"). Both session kinds export.
- The client is a same-origin anchor download; the route's
  Content-Disposition names the file.
- sessionMarkdown's user-facing debut — minted ANCHORLESS (message-id
  anchors are extraction provenance, not learner-facing). The engine
  joins the core barrel (pure, browser-safe) for its first consumer.

The practice join (owner-approved mid-round): practice tool results are
bare acks — statement, outcome, verdict, and the model solution all
live in the assistant's call arguments. The engine now folds them the
same way the view seam folds cards: Problem blocks render the pose's
statement (solution never leaks at pose time); Concluded blocks render
the outcome as prose ("Solved with hints.") + verdict, then the
pose-committed model solution as a blockquoted reveal via
problemInputForConclude (clean-poses-only, its refusal law). A
dismissed problem concludes as "The learner skipped this problem." —
no reveal. Errored poses render nothing (the failed-tool law).

1427 tests (route pins: headers, slug, anchorless body, 404; engine
pins: pose-time statement, the reveal, the dismissal refusal, the
errored-pose silence); tsc, lint, build green.
This commit is contained in:
amosblomqvist
2026-10-06 10:23:16 +03:00
parent 8a7b6b30d9
commit 0b2e4e03fb
6 changed files with 293 additions and 4 deletions
+37
View File
@@ -0,0 +1,37 @@
/**
* THE SESSION MARKDOWN EXPORT (owner, release polish 2026-10-06): the
* user-facing export the session-markdown engine was built for — the
* same document the extractor reads, minted anchorless (anchors are
* extraction provenance; a learner's export carries no message ids).
*
* GET /api/chats/:id/markdown → the whole session as one markdown file
* (Content-Disposition attachment; filename from the slugified title).
* 404 unknown chat. Both session kinds export — practice sessions ride
* the same engine (problem/conclude results render as tool blocks,
* markers are invisible by the engine's law).
*/
import { sessionMarkdown } from "@/src/core";
import { getChat } from "@/src/server";
/** Filename-safe title (own module scope on purpose: tiny + pure). */
function slugify(title: string): string {
const slug = title
.toLowerCase()
.normalize("NFKD")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug.length > 0 ? slug : "session";
}
export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }): Promise<Response> {
const { id } = await ctx.params;
const record = getChat(id);
if (!record) return Response.json({ error: "Unknown chat" }, { status: 404 });
const markdown = sessionMarkdown(record.messages, { title: record.title, anchors: false });
return new Response(markdown, {
headers: {
"content-type": "text/markdown; charset=utf-8",
"content-disposition": `attachment; filename="${slugify(record.title)}.md"`,
},
});
}
+22
View File
@@ -864,6 +864,28 @@ function SessionsSidebar() {
>
Rename
</button>
{/* Export as Markdown (owner, release polish 2026-10-06): the
session-markdown engine's user-facing debut — same-origin
anchor download; the route's Content-Disposition names the
file. Sits with the management actions, above Delete (the
destructive action stays last — the danger-fence law). */}
<button
type="button"
data-testid="ctx-export-md"
onClick={() => {
const chatId = ctxChatId;
setCtxChatId(null);
const a = document.createElement("a");
a.href = `/api/chats/${chatId}/markdown`;
a.download = ""; // the route's filename wins
document.body.appendChild(a);
a.click();
a.remove();
}}
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-foreground transition-colors hover:bg-accent"
>
Export as Markdown…
</button>
<button
type="button"
data-testid="ctx-delete"
+4
View File
@@ -69,3 +69,7 @@ export * from "./auth-keys.ts";
// keyboard.ts: the shortcut registry + binding normalization (pure, browser-safe).
export * from "./keyboard.ts";
export * from "./coverage.ts";
// session-markdown.ts (2026-09-27 engine; first consumer 2026-10-06): the
// ONE session→markdown formatter — the extractor's view and the user-facing
// export share it. Pure, browser-safe.
export * from "./session-markdown.ts";
+75 -4
View File
@@ -21,6 +21,11 @@
*/
import type { LhMessage } from "./native-messages.ts";
import {
PRACTICE_CONCLUDE_TOOL,
PRACTICE_PROBLEM_TOOL,
problemInputForConclude,
} from "./native-messages.ts";
import type { ToolResultMessage } from "@earendil-works/pi-ai";
/** The user-facing text blocks of a native message (string or text parts). */
@@ -33,8 +38,45 @@ function textOf(content: unknown): string[] {
.filter((t) => t.trim().length > 0);
}
/** The practice join context (owner-approved, 2026-10-06 export round):
* practice tool RESULTS are acks — the statement, outcome, and verdict
* all live in the assistant's call arguments, and the model solution
* commits at POSE time. The markdown blocks join them the same way the
* view seam folds cards (clean-only: a refused pose's result is errored
* and renders nothing before this join is ever reached). */
export interface MarkdownContext {
messages: ReadonlyArray<LhMessage>;
/** toolCallId → the call part's name + arguments. */
calls: Map<string, { name: string; args: Record<string, unknown> | undefined }>;
}
function markdownContext(messages: ReadonlyArray<LhMessage>): MarkdownContext {
const calls: MarkdownContext["calls"] = new Map();
for (const message of messages) {
if ((message as { role?: string }).role !== "assistant") continue;
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content)) continue;
for (const part of content) {
const block = part as { type?: string; id?: string; name?: string; arguments?: unknown };
if (block.type === "toolCall" && typeof block.id === "string") {
calls.set(block.id, {
name: typeof block.name === "string" ? block.name : "",
args: (block.arguments ?? undefined) as Record<string, unknown> | undefined,
});
}
}
}
return { messages, calls };
}
/** solvedWithHints → "Solved with hints" — the outcome enum reads as prose. */
function outcomeLabel(outcome: string): string {
const spaced = outcome.replace(/([a-z])([A-Z])/g, (_, lo: string, hi: string) => `${lo} ${hi.toLowerCase()}`);
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
/** One tool result's learner-visible outcome, rendered as body lines. */
function toolResultBody(result: ToolResultMessage): { heading: string; body: string[] } {
function toolResultBody(result: ToolResultMessage, ctx?: MarkdownContext): { heading: string; body: string[] } {
const out = result.details as
| Partial<{
questionText: string;
@@ -61,6 +103,29 @@ function toolResultBody(result: ToolResultMessage): { heading: string; body: str
return { heading, body };
}
if (ctx !== undefined && result.toolName === PRACTICE_PROBLEM_TOOL) {
const statement = ctx.calls.get(result.toolCallId)?.args?.statement;
return {
heading: "Problem",
body: [typeof statement === "string" && statement.trim() ? statement.trim() : "(no statement)"],
};
}
if (ctx !== undefined && result.toolName === PRACTICE_CONCLUDE_TOOL) {
const args = ctx.calls.get(result.toolCallId)?.args;
const outcome = typeof args?.outcome === "string" && args.outcome.trim() ? args.outcome.trim() : "";
const verdict = typeof args?.verdict === "string" && args.verdict.trim() ? args.verdict.trim() : undefined;
if (outcome === "dismissed") return { heading: "Concluded", body: ["The learner skipped this problem."] };
const label = outcome !== "" ? `**${outcomeLabel(outcome)}.**` : "**Concluded.**";
const body = [verdict !== undefined ? `${label} ${verdict}` : label];
// The reveal (owner round, 2026-10-05): the solution committed at
// pose time — the newest cleanly-posed problem before this conclude.
const input = problemInputForConclude(ctx.messages, result.toolCallId);
const solution = typeof input?.modelSolution === "string" && input.modelSolution.trim() ? input.modelSolution.trim() : undefined;
if (solution !== undefined) body.push(`> **Model solution**\n>\n> ${solution.replace(/\n/g, "\n> ")}`);
return { heading: "Concluded", body };
}
if (result.toolName === "ask_user_question") {
const question = typeof out?.questionText === "string" && out.questionText ? out.questionText : "(no question text)";
const body = [`**Q:** ${question}`];
@@ -84,7 +149,12 @@ function toolResultBody(result: ToolResultMessage): { heading: string; body: str
* nothing (a failed tool call, a bare marker, an empty text part set).
* `mid` is the provenance anchor shown when `anchors` is on.
*/
export function messageMarkdown(message: LhMessage, mid: string, anchors = true): string | null {
export function messageMarkdown(
message: LhMessage,
mid: string,
anchors = true,
ctx?: MarkdownContext,
): string | null {
const role = (message as { role?: string }).role;
const anchor = anchors ? ` · ${mid}` : "";
const parts: string[] = [];
@@ -100,7 +170,7 @@ export function messageMarkdown(message: LhMessage, mid: string, anchors = true)
} else if (role === "toolResult") {
const result = message as ToolResultMessage;
if (result.isError) return null; // retry context, not learning evidence
const { heading, body } = toolResultBody(result);
const { heading, body } = toolResultBody(result, ctx);
parts.push(`### ${heading}${anchor}\n\n${body.join("\n\n")}`);
}
@@ -120,8 +190,9 @@ export function sessionMarkdown(
): string {
const blocks: string[] = [];
if (opts.title !== undefined && opts.title.length > 0) blocks.push(`# ${opts.title}`);
const ctx = markdownContext(messages);
for (const message of messages) {
const block = messageMarkdown(message, (message as { id?: string }).id ?? "", opts.anchors ?? true);
const block = messageMarkdown(message, (message as { id?: string }).id ?? "", opts.anchors ?? true, ctx);
if (block !== null) blocks.push(block);
}
return blocks.join("\n\n");
+81
View File
@@ -0,0 +1,81 @@
/**
* THE SESSION MARKDOWN EXPORT ROUTE (owner, release polish 2026-10-06):
* GET /api/chats/:id/markdown — the user-facing export. The engine
* itself is pinned by tests/session-markdown.test.ts; this file pins
* the ROUTE contract: 404 unknown chat, the attachment headers, the
* slugified filename, the anchorless document with the title header.
*
* Runs under an isolated LH_DATA_DIR (the chat-birth-stamp discipline:
* the seam is set before any config-loading import, so every
* app-module import is dynamic).
*/
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, before, describe, it } from "node:test";
// ── the seam, BEFORE any config-loading import ──────────────────────────────
const TMP_DATA = fs.mkdtempSync(path.join(os.tmpdir(), "lh-md-export-"));
process.env.LH_DATA_DIR = TMP_DATA;
/** The route handler + the store seam, loaded AFTER the env is set. */
type Get = (req: Request, ctx: { params: Promise<{ id: string }> }) => Promise<Response>;
let GET: Get;
let createChat: (title: string, kind?: "practice") => { id: string; title: string };
let saveMessages: (id: string, messages: unknown[]) => void;
before(async () => {
const route = await import("../app/api/chats/[id]/markdown/route.ts");
GET = route.GET;
const server = await import("../src/server/chats.ts");
createChat = server.createChat;
const { chatStore } = await import("../src/store/chats.ts");
saveMessages = (id, messages) => chatStore.saveMessages(id, messages as never);
});
describe("GET /api/chats/:id/markdown", () => {
it("404s an unknown chat", async () => {
const res = await GET(new Request("http://x/api/chats/nope/markdown"), { params: Promise.resolve({ id: "nope" }) });
assert.equal(res.status, 404);
});
it("exports the session as an anchorless markdown attachment, filename from the title", async () => {
const chat = createChat("Relational Databases — Joins & Normal Forms");
saveMessages(chat.id, [
{ id: "m1", role: "user", content: "Teach me joins" },
{ id: "m2", role: "assistant", content: "A join combines rows." },
]);
const res = await GET(new Request("http://x/api"), { params: Promise.resolve({ id: chat.id }) });
assert.equal(res.status, 200);
assert.equal(res.headers.get("content-type"), "text/markdown; charset=utf-8");
const disposition = res.headers.get("content-disposition") ?? "";
assert.ok(disposition.startsWith("attachment;"), "it is a download");
assert.ok(disposition.includes('filename="relational-databases-joins-normal-forms.md"'), "slugified title names the file");
const body = await res.text();
assert.ok(body.startsWith("# Relational Databases"), "the title mints the document header");
assert.ok(body.includes("### Learner"), "learner blocks render");
assert.ok(body.includes("### Teacher"), "teacher blocks render");
assert.ok(!body.includes("m1"), "anchorless — no message-id provenance in the user-facing export");
});
it("an unsalvageable title falls back to 'session'", async () => {
const chat = createChat("日本語"); // no ascii alphanumerics survive the slug
saveMessages(chat.id, [{ id: "m1", role: "user", content: "hello" }]);
const res = await GET(new Request("http://x/api"), { params: Promise.resolve({ id: chat.id }) });
assert.equal(res.status, 200);
assert.ok((res.headers.get("content-disposition") ?? "").includes('filename="session.md"'));
});
it("an empty session exports the bare header document", async () => {
const chat = createChat("Quiet one");
const res = await GET(new Request("http://x/api"), { params: Promise.resolve({ id: chat.id }) });
assert.equal(res.status, 200);
const body = await res.text();
assert.equal(body, "# Quiet one");
});
});
after(() => {
fs.rmSync(TMP_DATA, { recursive: true, force: true });
});
+74
View File
@@ -51,6 +51,22 @@ function result(
} as never;
}
/** An assistant message carrying one toolCall part (the practice join's
* call side — args live HERE, the results are bare acks). */
function callMsg(id: string, callId: string, name: string, args: unknown): LhMessage {
return {
id,
role: "assistant",
content: [{ type: "toolCall", id: callId, name, arguments: args }],
api: "openai-completions",
provider: "openrouter",
model: "test",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
stopReason: "stop",
timestamp: Date.now(),
} as never;
}
describe("messageMarkdown", () => {
it("renders speaker blocks with the id anchor", () => {
const md = messageMarkdown(user("u1", "teach me the chain rule"), "u1");
@@ -105,6 +121,64 @@ describe("messageMarkdown", () => {
});
});
describe("sessionMarkdown — the practice join (owner-approved, 2026-10-06)", () => {
it("a problem result renders the POSE's statement, joined off the call id", () => {
const messages = [
callMsg("a1", "call-p1", "problem", {
statement: "Push the prior through the learning transition.",
mode: "recall",
target: [],
modelSolution: "Two phases, in forced order.",
}),
result("r1", "call-p1", "problem", null),
];
const md = sessionMarkdown(messages);
assert.ok(md.includes("### Problem"), "the Problem heading renders");
assert.ok(md.includes("Push the prior through the learning transition."), "the statement renders");
assert.ok(!md.includes("Two phases"), "the solution does NOT leak at pose time");
});
it("a conclude result renders outcome + verdict + the pose's model solution as the reveal", () => {
const messages = [
callMsg("a1", "call-p1", "problem", {
statement: "Compute P(knows | correct).",
modelSolution: "Bayes, twice over.",
}),
result("r1", "call-p1", "problem", null),
callMsg("a2", "call-c1", "conclude", { outcome: "solvedWithHints", verdict: "Right answer, one nudge." }),
result("r2", "call-c1", "conclude", null),
];
const md = sessionMarkdown(messages);
assert.ok(md.includes("### Concluded"), "the Concluded heading renders");
assert.ok(md.includes("**Solved with hints.** Right answer, one nudge."), "outcome reads as prose, then the verdict");
assert.ok(md.includes("> **Model solution**"), "the reveal block quotes the committed solution");
assert.ok(md.includes("> Bayes, twice over."), "the solution text carries the blockquote prefix");
});
it("a dismissed problem concludes without leaking the model solution", () => {
const messages = [
callMsg("a1", "call-p1", "problem", { statement: "Derive the posterior.", modelSolution: "The answer." }),
result("r1", "call-p1", "problem", null),
callMsg("a2", "call-c1", "conclude", { outcome: "dismissed" }),
result("r2", "call-c1", "conclude", null),
];
const md = sessionMarkdown(messages);
assert.ok(md.includes("The learner skipped this problem."), "the dismissal reads as a skip");
assert.ok(!md.includes("The answer."), "no reveal on a skipped problem — the card's refusal law");
assert.ok(md.includes("### Problem"), "the statement still renders (it was on screen)");
});
it("an errored pose renders nothing — the failed-tool law holds for practice", () => {
const messages = [
callMsg("a1", "call-p1", "problem", { statement: "Never acked." }),
result("r1", "call-p1", "problem", null, true),
];
const md = sessionMarkdown(messages);
assert.ok(!md.includes("### Problem"), "no problem block");
assert.ok(!md.includes("Never acked."), "no statement either — the pose never happened");
});
});
describe("sessionMarkdown", () => {
it("assembles blocks in order under the title header, skipping nothings", () => {
const md = sessionMarkdown([user("u1", "go"), teacher("a1", "First."), user("u2", " ")], { title: "My session" });