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:
@@ -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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user