src / commands.ts
src / commands.ts
// /tl commands, typed by the user as the whole message. The preprocessor replaces the
// message with the result and an instruction for the model to relay it in a short reply.
import * as fs from "fs";
import * as path from "path";
import { ChatState, listChatStates, loadChatState, newChatState, saveChatState } from "./chat";
import { absorbPendingEdit } from "./checks";
import { renderFull } from "./digest";
import { commit, createLedger, isEmpty, Ledger, listProjects, loadLedger, loadMeta, markdownFile, progress, saveMeta, uniqueSlug } from "./ledger";
import { addConstraint, OpError, setGoal, undoLast } from "./ops";
import { deleteIndex } from "./recall";
import { displayPath, ensureDir, projectDir, trashDir, writeFileAtomic } from "./store";
import { clip, stripThroughLineBlocks } from "./text";
export interface CommandResult {
forModel: string;
status: string;
}
export const HELP = [
"throughLine commands:",
"/tl start [goal]: start a project in this chat (without a goal, your first message here is used)",
"/tl open <name>: continue an existing project in this chat",
"/tl list: list projects",
"/tl goal <text>: set the goal",
"/tl constraint <text>: add a rule for this project",
"/tl status: show the project's full state",
"/tl undo: undo the last change to the ledger (repeat to go further back)",
"/tl handoff: write handoff.md, the project's state plus the last exchanges, in the project folder",
"/tl recall off: stop recall in this chat and delete its search copy (/tl recall on to resume)",
"/tl off: stop tracking this chat",
"/tl forget <name>: move a project to the trash",
].join("\n");
export function isCommand(text: string): boolean {
return /^\/tl(\s|$)/i.test(text.trim());
}
// Added to whatever a command reports when the user's pending edit was merged first.
let currentEditNote = "";
function relay(text: string, status: string): CommandResult {
text = text + currentEditNote;
return {
forModel: `[throughLine handled a command from the user. Reply with one or two short sentences telling the user this result, and add nothing else.]\n${text}`,
status: `throughLine: ${status}`,
};
}
function showVerbatim(text: string, status: string): CommandResult {
text = text + currentEditNote;
return {
forModel: `[throughLine handled a command from the user. Show the user the following exactly as written, in a code block, and add nothing else.]\n${text}`,
status: `throughLine: ${status}`,
};
}
function findProject(name: string): string | null | "ambiguous" {
const all = listProjects();
const q = name.trim().toLowerCase();
if (all.includes(q)) return q;
const matches = all.filter((p) => p.startsWith(q));
if (matches.length === 1) return matches[0];
return matches.length > 1 ? "ambiguous" : null;
}
function bind(state: ChatState, ledger: Ledger): void {
if (state.project && state.project !== ledger.name) unbindFrom(state.project, state.chatId);
const meta = loadMeta(ledger.name);
if (meta.orphanedSince) { delete meta.orphanedSince; saveMeta(ledger.name, meta); }
state.project = ledger.name;
state.autostartOff = false;
state.lastSeq = 0;
saveChatState(state);
if (!ledger.chats.includes(state.chatId)) {
ledger.chats.push(state.chatId);
commit(ledger, [{ actor: "user", op: "chat_bound", chat: state.chatId }]);
}
}
function unbindFrom(slug: string, chatId: string): void {
const l = loadLedger(slug);
if (l && l.chats.includes(chatId)) {
l.chats = l.chats.filter((c) => c !== chatId);
commit(l, [{ actor: "user", op: "chat_unbound", chat: chatId }]);
}
}
// Renames a project's folder and every chat bound to it. Used once, when a project started
// without a goal gets one, so it is not left with a placeholder name.
function renameProject(ledger: Ledger, newName: string): string {
const old = ledger.name;
if (newName === old) return old;
fs.renameSync(projectDir(old), projectDir(newName));
ledger.name = newName;
commit(ledger, [{ actor: "user", op: "project_renamed", text: `${old} -> ${newName}` }]);
for (const s of listChatStates()) {
if (s.project === old) { s.project = newName; saveChatState(s); }
}
return newName;
}
export function firstUserText(userTexts: string[]): string {
for (const t of userTexts) {
const own = stripThroughLineBlocks(t);
if (own && !/^\/tl(\s|$)/i.test(own) && !own.startsWith("[throughLine")) return own.slice(0, 600).trim();
}
return "";
}
export interface Exchange {
role: "user" | "assistant";
text: string;
}
export function handleCommand(chatId: string, text: string, earlierUserTexts: string[], fullCapTokens: number, recent: Exchange[] = []): CommandResult {
const m = text.trim().match(/^\/tl\s*(\S*)\s*([\s\S]*)$/i);
const sub = (m?.[1] ?? "").toLowerCase();
const arg = (m?.[2] ?? "").trim();
const state = loadChatState(chatId) ?? newChatState(chatId);
const bound = state.project ? loadLedger(state.project) : null;
// An edit the user saved to ledger.md since the last message is merged before any command
// reads or writes the ledger, so a command never overwrites it.
const pending = bound ? absorbPendingEdit(bound) : ({ kind: "none" } as const);
currentEditNote =
pending.kind === "applied" ? ` (Your edit to ledger.md was applied first: ${pending.summary}.)`
: pending.kind === "failed" ? ` (Your edit to ledger.md could not be read and was saved as ${pending.savedAs}.)`
: "";
try {
switch (sub) {
case "start": {
if (bound) return relay(`This chat is already tracking the project "${bound.name}".`, `already tracking ${bound.name}`);
const goal = arg || firstUserText(earlierUserTexts);
const ledger = createLedger(goal, goal ? (arg ? "command" : "user_message") : "none", chatId);
bind(state, ledger);
const goalNote = goal ? `Goal: ${clip(goal, 300)}` : "No goal yet. Set one with /tl goal followed by the goal.";
return relay(`Started the project "${ledger.name}" in this chat. ${goalNote}`, `started ${ledger.name}`);
}
case "open": {
if (!arg) return relay("Name the project to open. /tl list shows them.", "no project named");
const slug = findProject(arg);
if (slug === "ambiguous") return relay(`More than one project starts with "${arg}". /tl list shows the full names.`, "ambiguous name");
const ledger = slug ? loadLedger(slug) : null;
if (!ledger) return relay(`There is no project named "${arg}". /tl list shows the projects.`, "project not found");
bind(state, ledger);
const p = progress(ledger);
return relay(`This chat now continues the project "${ledger.name}" (${p.done} of ${p.total} steps done). Its full state appears with the next message.`, `opened ${ledger.name}`);
}
case "list": {
const names = listProjects();
if (!names.length) return relay("There are no projects yet. /tl start begins one in this chat.", "no projects");
const lines = names.map((n) => {
const l = loadLedger(n)!;
const p = progress(l);
const mark = n === state.project ? " (this chat)" : "";
return `${n}${mark}: ${clip(l.goal || "no goal", 70)} | ${p.done}/${p.total} steps | updated ${l.updated.slice(0, 10)}`;
});
return showVerbatim(lines.join("\n"), `${names.length} projects`);
}
case "goal": {
if (!bound) return relay("This chat has no project. /tl start begins one.", "no project");
if (!arg) return relay(`The goal is: ${bound.goal || "not set"}`, "goal shown");
const placeholder = bound.goalSource === "none" && /^project(-\d+)?$/.test(bound.name);
setGoal(bound, arg, chatId);
if (placeholder) {
const renamed = renameProject(bound, uniqueSlug(arg));
return relay(`Goal set, and the project is now named "${renamed}": ${clip(arg, 300)}`, `goal set, renamed ${renamed}`);
}
return relay(`Goal of "${bound.name}" set to: ${clip(arg, 300)}`, "goal set");
}
case "constraint": {
if (!bound) return relay("This chat has no project. /tl start begins one.", "no project");
if (!arg) return relay("Give the rule after /tl constraint.", "no constraint given");
const msg = addConstraint(bound, arg, chatId);
return relay(`Added to "${bound.name}": ${clip(arg, 300)} (${msg}).`, "constraint added");
}
case "status": {
if (!bound) return relay("This chat has no project. /tl start begins one, and /tl list shows existing projects.", "no project");
const where = `Ledger file: ${displayPath(markdownFile(bound.name))}`;
return showVerbatim(`${renderFull(bound, Math.max(fullCapTokens, 1500))}\n${where}`, `status of ${bound.name}`);
}
case "off": {
state.project = undefined;
state.autostartOff = true;
state.staleTurns = 0;
saveChatState(state);
if (bound && bound.autoStarted && isEmpty(bound) && bound.chats.every((c) => c === chatId)) {
// A project that started on its own and never got any content was a false start.
fs.rmSync(projectDir(bound.name), { recursive: true, force: true });
return relay(`Stopped. The project "${bound.name}" had started automatically and was still empty, so it was removed. This chat will not start one on its own again.`, "tracking off, empty project removed");
}
if (bound) unbindFrom(bound.name, chatId);
return relay(bound ? `This chat no longer tracks "${bound.name}". The project is kept and can be reopened with /tl open.` : "This chat is not tracking a project, and will not start one on its own.", "tracking off");
}
case "forget": {
if (!arg) {
return relay(bound ? `To move "${bound.name}" to the trash, send /tl forget ${bound.name}` : "Name the project to forget. /tl list shows them.", "confirmation needed");
}
const slug = findProject(arg);
if (!slug || slug === "ambiguous" || slug !== arg.trim().toLowerCase()) {
return relay(`To forget a project, give its full name exactly. /tl list shows them.`, "name not matched");
}
ensureDir(trashDir());
const dest = path.join(trashDir(), `${slug}-${Date.now()}`);
fs.renameSync(projectDir(slug), dest);
for (const s of listChatStates()) {
if (s.project === slug) { s.project = undefined; saveChatState(s); }
}
return relay(`Moved "${slug}" to the trash. It is deleted for good after 30 days.`, `forgot ${slug}`);
}
case "undo": {
if (!bound) return relay("This chat has no project. /tl start begins one.", "no project");
const msg = undoLast(bound, chatId);
return relay(`${msg} The ledger of "${bound.name}" is back to how it was before that.`, "undone");
}
case "handoff": {
if (!bound) return relay("This chat has no project. /tl start begins one.", "no project");
const file = path.join(projectDir(bound.name), "handoff.md");
writeFileAtomic(file, renderHandoff(bound, recent));
return relay(`Wrote the handoff for "${bound.name}" to ${displayPath(file)}.`, "handoff written");
}
case "recall": {
const on = /^on$/i.test(arg);
const off = /^off$/i.test(arg);
if (!on && !off) return relay("Use /tl recall off to stop recall in this chat, or /tl recall on to resume it.", "recall unchanged");
state.recallOff = off || undefined;
saveChatState(state);
if (off) deleteIndex(chatId);
return relay(off ? "Recall is off in this chat, and its search copy of the chat was deleted." : "Recall is on again in this chat. It resumes once the chat is long enough.", off ? "recall off" : "recall on");
}
case "":
case "help":
return showVerbatim(HELP, "help");
default:
return relay(`Unknown command "/tl ${sub}". /tl help lists the commands.`, "unknown command");
}
} catch (e) {
if (e instanceof OpError) return relay(e.message, "command failed");
return relay("The command failed because a throughLine file could not be read or written.", "command failed");
}
}
// handoff.md: the project's state in three layers, as in throughLine v1, for sharing or archiving.
export function renderHandoff(l: Ledger, recent: Exchange[]): string {
const one = (t: string) => t.replace(/\s*\n\s*/g, " ").trim();
const out: string[] = [`# Handoff: ${l.name}`, "", `Written ${new Date().toISOString().slice(0, 16).replace("T", " ")} UTC by throughLine.`, ""];
out.push("## Permanent core", "", "### Goal", "", one(l.goal) || "(not set)", "", "### Constraints", "");
out.push(...(l.constraints.length ? l.constraints.map((c) => `- ${one(c)}`) : ["(none)"]), "", "### Decisions", "");
out.push(...(l.decisions.length ? l.decisions.map((d) => `- ${d.id}: ${one(d.text)}${d.reason ? ` Reason: ${one(d.reason)}` : ""}`) : ["(none)"]), "");
out.push("## Current state", "", "### Plan", "");
out.push(...(l.plan.length ? l.plan.map((s) => `${s.id}. [${s.status}] ${one(s.text)}${s.note ? ` (${one(s.note)})` : ""}`) : ["(no steps)"]), "", "### Findings", "");
out.push(...(l.findings.length ? l.findings.map((f) => `- ${f.id}: ${one(f.text)} Source: ${one(f.source)} (${f.verification.replace("_", " ")})`) : ["(none)"]), "", "### Open questions", "");
const open = l.questions.filter((q) => q.status === "open");
out.push(...(open.length ? open.map((q) => `- ${q.id}: ${one(q.text)}`) : ["(none)"]), "");
out.push("## Recent conversation", "");
if (!recent.length) out.push("(no earlier exchanges in this chat)");
for (const e of recent) out.push(`**${e.role === "user" ? "User" : "Assistant"}:** ${e.text.trim()}`, "");
return out.join("\n").trimEnd() + "\n";
}
// /tl commands, typed by the user as the whole message. The preprocessor replaces the
// message with the result and an instruction for the model to relay it in a short reply.
import * as fs from "fs";
import * as path from "path";
import { ChatState, listChatStates, loadChatState, newChatState, saveChatState } from "./chat";
import { absorbPendingEdit } from "./checks";
import { renderFull } from "./digest";
import { commit, createLedger, isEmpty, Ledger, listProjects, loadLedger, loadMeta, markdownFile, progress, saveMeta, uniqueSlug } from "./ledger";
import { addConstraint, OpError, setGoal, undoLast } from "./ops";
import { deleteIndex } from "./recall";
import { displayPath, ensureDir, projectDir, trashDir, writeFileAtomic } from "./store";
import { clip, stripThroughLineBlocks } from "./text";
export interface CommandResult {
forModel: string;
status: string;
}
export const HELP = [
"throughLine commands:",
"/tl start [goal]: start a project in this chat (without a goal, your first message here is used)",
"/tl open <name>: continue an existing project in this chat",
"/tl list: list projects",
"/tl goal <text>: set the goal",
"/tl constraint <text>: add a rule for this project",
"/tl status: show the project's full state",
"/tl undo: undo the last change to the ledger (repeat to go further back)",
"/tl handoff: write handoff.md, the project's state plus the last exchanges, in the project folder",
"/tl recall off: stop recall in this chat and delete its search copy (/tl recall on to resume)",
"/tl off: stop tracking this chat",
"/tl forget <name>: move a project to the trash",
].join("\n");
export function isCommand(text: string): boolean {
return /^\/tl(\s|$)/i.test(text.trim());
}
// Added to whatever a command reports when the user's pending edit was merged first.
let currentEditNote = "";
function relay(text: string, status: string): CommandResult {
text = text + currentEditNote;
return {
forModel: `[throughLine handled a command from the user. Reply with one or two short sentences telling the user this result, and add nothing else.]\n${text}`,
status: `throughLine: ${status}`,
};
}
function showVerbatim(text: string, status: string): CommandResult {
text = text + currentEditNote;
return {
forModel: `[throughLine handled a command from the user. Show the user the following exactly as written, in a code block, and add nothing else.]\n${text}`,
status: `throughLine: ${status}`,
};
}
function findProject(name: string): string | null | "ambiguous" {
const all = listProjects();
const q = name.trim().toLowerCase();
if (all.includes(q)) return q;
const matches = all.filter((p) => p.startsWith(q));
if (matches.length === 1) return matches[0];
return matches.length > 1 ? "ambiguous" : null;
}
function bind(state: ChatState, ledger: Ledger): void {
if (state.project && state.project !== ledger.name) unbindFrom(state.project, state.chatId);
const meta = loadMeta(ledger.name);
if (meta.orphanedSince) { delete meta.orphanedSince; saveMeta(ledger.name, meta); }
state.project = ledger.name;
state.autostartOff = false;
state.lastSeq = 0;
saveChatState(state);
if (!ledger.chats.includes(state.chatId)) {
ledger.chats.push(state.chatId);
commit(ledger, [{ actor: "user", op: "chat_bound", chat: state.chatId }]);
}
}
function unbindFrom(slug: string, chatId: string): void {
const l = loadLedger(slug);
if (l && l.chats.includes(chatId)) {
l.chats = l.chats.filter((c) => c !== chatId);
commit(l, [{ actor: "user", op: "chat_unbound", chat: chatId }]);
}
}
// Renames a project's folder and every chat bound to it. Used once, when a project started
// without a goal gets one, so it is not left with a placeholder name.
function renameProject(ledger: Ledger, newName: string): string {
const old = ledger.name;
if (newName === old) return old;
fs.renameSync(projectDir(old), projectDir(newName));
ledger.name = newName;
commit(ledger, [{ actor: "user", op: "project_renamed", text: `${old} -> ${newName}` }]);
for (const s of listChatStates()) {
if (s.project === old) { s.project = newName; saveChatState(s); }
}
return newName;
}
export function firstUserText(userTexts: string[]): string {
for (const t of userTexts) {
const own = stripThroughLineBlocks(t);
if (own && !/^\/tl(\s|$)/i.test(own) && !own.startsWith("[throughLine")) return own.slice(0, 600).trim();
}
return "";
}
export interface Exchange {
role: "user" | "assistant";
text: string;
}
export function handleCommand(chatId: string, text: string, earlierUserTexts: string[], fullCapTokens: number, recent: Exchange[] = []): CommandResult {
const m = text.trim().match(/^\/tl\s*(\S*)\s*([\s\S]*)$/i);
const sub = (m?.[1] ?? "").toLowerCase();
const arg = (m?.[2] ?? "").trim();
const state = loadChatState(chatId) ?? newChatState(chatId);
const bound = state.project ? loadLedger(state.project) : null;
// An edit the user saved to ledger.md since the last message is merged before any command
// reads or writes the ledger, so a command never overwrites it.
const pending = bound ? absorbPendingEdit(bound) : ({ kind: "none" } as const);
currentEditNote =
pending.kind === "applied" ? ` (Your edit to ledger.md was applied first: ${pending.summary}.)`
: pending.kind === "failed" ? ` (Your edit to ledger.md could not be read and was saved as ${pending.savedAs}.)`
: "";
try {
switch (sub) {
case "start": {
if (bound) return relay(`This chat is already tracking the project "${bound.name}".`, `already tracking ${bound.name}`);
const goal = arg || firstUserText(earlierUserTexts);
const ledger = createLedger(goal, goal ? (arg ? "command" : "user_message") : "none", chatId);
bind(state, ledger);
const goalNote = goal ? `Goal: ${clip(goal, 300)}` : "No goal yet. Set one with /tl goal followed by the goal.";
return relay(`Started the project "${ledger.name}" in this chat. ${goalNote}`, `started ${ledger.name}`);
}
case "open": {
if (!arg) return relay("Name the project to open. /tl list shows them.", "no project named");
const slug = findProject(arg);
if (slug === "ambiguous") return relay(`More than one project starts with "${arg}". /tl list shows the full names.`, "ambiguous name");
const ledger = slug ? loadLedger(slug) : null;
if (!ledger) return relay(`There is no project named "${arg}". /tl list shows the projects.`, "project not found");
bind(state, ledger);
const p = progress(ledger);
return relay(`This chat now continues the project "${ledger.name}" (${p.done} of ${p.total} steps done). Its full state appears with the next message.`, `opened ${ledger.name}`);
}
case "list": {
const names = listProjects();
if (!names.length) return relay("There are no projects yet. /tl start begins one in this chat.", "no projects");
const lines = names.map((n) => {
const l = loadLedger(n)!;
const p = progress(l);
const mark = n === state.project ? " (this chat)" : "";
return `${n}${mark}: ${clip(l.goal || "no goal", 70)} | ${p.done}/${p.total} steps | updated ${l.updated.slice(0, 10)}`;
});
return showVerbatim(lines.join("\n"), `${names.length} projects`);
}
case "goal": {
if (!bound) return relay("This chat has no project. /tl start begins one.", "no project");
if (!arg) return relay(`The goal is: ${bound.goal || "not set"}`, "goal shown");
const placeholder = bound.goalSource === "none" && /^project(-\d+)?$/.test(bound.name);
setGoal(bound, arg, chatId);
if (placeholder) {
const renamed = renameProject(bound, uniqueSlug(arg));
return relay(`Goal set, and the project is now named "${renamed}": ${clip(arg, 300)}`, `goal set, renamed ${renamed}`);
}
return relay(`Goal of "${bound.name}" set to: ${clip(arg, 300)}`, "goal set");
}
case "constraint": {
if (!bound) return relay("This chat has no project. /tl start begins one.", "no project");
if (!arg) return relay("Give the rule after /tl constraint.", "no constraint given");
const msg = addConstraint(bound, arg, chatId);
return relay(`Added to "${bound.name}": ${clip(arg, 300)} (${msg}).`, "constraint added");
}
case "status": {
if (!bound) return relay("This chat has no project. /tl start begins one, and /tl list shows existing projects.", "no project");
const where = `Ledger file: ${displayPath(markdownFile(bound.name))}`;
return showVerbatim(`${renderFull(bound, Math.max(fullCapTokens, 1500))}\n${where}`, `status of ${bound.name}`);
}
case "off": {
state.project = undefined;
state.autostartOff = true;
state.staleTurns = 0;
saveChatState(state);
if (bound && bound.autoStarted && isEmpty(bound) && bound.chats.every((c) => c === chatId)) {
// A project that started on its own and never got any content was a false start.
fs.rmSync(projectDir(bound.name), { recursive: true, force: true });
return relay(`Stopped. The project "${bound.name}" had started automatically and was still empty, so it was removed. This chat will not start one on its own again.`, "tracking off, empty project removed");
}
if (bound) unbindFrom(bound.name, chatId);
return relay(bound ? `This chat no longer tracks "${bound.name}". The project is kept and can be reopened with /tl open.` : "This chat is not tracking a project, and will not start one on its own.", "tracking off");
}
case "forget": {
if (!arg) {
return relay(bound ? `To move "${bound.name}" to the trash, send /tl forget ${bound.name}` : "Name the project to forget. /tl list shows them.", "confirmation needed");
}
const slug = findProject(arg);
if (!slug || slug === "ambiguous" || slug !== arg.trim().toLowerCase()) {
return relay(`To forget a project, give its full name exactly. /tl list shows them.`, "name not matched");
}
ensureDir(trashDir());
const dest = path.join(trashDir(), `${slug}-${Date.now()}`);
fs.renameSync(projectDir(slug), dest);
for (const s of listChatStates()) {
if (s.project === slug) { s.project = undefined; saveChatState(s); }
}
return relay(`Moved "${slug}" to the trash. It is deleted for good after 30 days.`, `forgot ${slug}`);
}
case "undo": {
if (!bound) return relay("This chat has no project. /tl start begins one.", "no project");
const msg = undoLast(bound, chatId);
return relay(`${msg} The ledger of "${bound.name}" is back to how it was before that.`, "undone");
}
case "handoff": {
if (!bound) return relay("This chat has no project. /tl start begins one.", "no project");
const file = path.join(projectDir(bound.name), "handoff.md");
writeFileAtomic(file, renderHandoff(bound, recent));
return relay(`Wrote the handoff for "${bound.name}" to ${displayPath(file)}.`, "handoff written");
}
case "recall": {
const on = /^on$/i.test(arg);
const off = /^off$/i.test(arg);
if (!on && !off) return relay("Use /tl recall off to stop recall in this chat, or /tl recall on to resume it.", "recall unchanged");
state.recallOff = off || undefined;
saveChatState(state);
if (off) deleteIndex(chatId);
return relay(off ? "Recall is off in this chat, and its search copy of the chat was deleted." : "Recall is on again in this chat. It resumes once the chat is long enough.", off ? "recall off" : "recall on");
}
case "":
case "help":
return showVerbatim(HELP, "help");
default:
return relay(`Unknown command "/tl ${sub}". /tl help lists the commands.`, "unknown command");
}
} catch (e) {
if (e instanceof OpError) return relay(e.message, "command failed");
return relay("The command failed because a throughLine file could not be read or written.", "command failed");
}
}
// handoff.md: the project's state in three layers, as in throughLine v1, for sharing or archiving.
export function renderHandoff(l: Ledger, recent: Exchange[]): string {
const one = (t: string) => t.replace(/\s*\n\s*/g, " ").trim();
const out: string[] = [`# Handoff: ${l.name}`, "", `Written ${new Date().toISOString().slice(0, 16).replace("T", " ")} UTC by throughLine.`, ""];
out.push("## Permanent core", "", "### Goal", "", one(l.goal) || "(not set)", "", "### Constraints", "");
out.push(...(l.constraints.length ? l.constraints.map((c) => `- ${one(c)}`) : ["(none)"]), "", "### Decisions", "");
out.push(...(l.decisions.length ? l.decisions.map((d) => `- ${d.id}: ${one(d.text)}${d.reason ? ` Reason: ${one(d.reason)}` : ""}`) : ["(none)"]), "");
out.push("## Current state", "", "### Plan", "");
out.push(...(l.plan.length ? l.plan.map((s) => `${s.id}. [${s.status}] ${one(s.text)}${s.note ? ` (${one(s.note)})` : ""}`) : ["(no steps)"]), "", "### Findings", "");
out.push(...(l.findings.length ? l.findings.map((f) => `- ${f.id}: ${one(f.text)} Source: ${one(f.source)} (${f.verification.replace("_", " ")})`) : ["(none)"]), "", "### Open questions", "");
const open = l.questions.filter((q) => q.status === "open");
out.push(...(open.length ? open.map((q) => `- ${q.id}: ${one(q.text)}`) : ["(none)"]), "");
out.push("## Recent conversation", "");
if (!recent.length) out.push("(no earlier exchanges in this chat)");
for (const e of recent) out.push(`**${e.role === "user" ? "User" : "Assistant"}:** ${e.text.trim()}`, "");
return out.join("\n").trimEnd() + "\n";
}