From 4bdef814415e56caba9723480f2fc7e977ce3f7e Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sun, 23 Aug 2026 10:52:56 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feat(plugin):=20session-recap=20injection?= =?UTF-8?q?=20=E2=80=94=20personalized=20onset=20greeting=20from=20session?= =?UTF-8?q?=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads recent sessions (last 7 days) from the opencode SQLite DB via bun:sqlite, extracts mechanical recaps (user prompts + numerical outcomes), caches them to ~/.amico/session-recaps/, and injects a '## Recent sessions' block into the system prompt. The onset router naturally uses this to personalize the greeting without any changes to its own text. Key decisions: - bun:sqlite (Bun built-in) for DB reads — no npm deps, no SDK client needed - Mechanical extraction (not LLM) for v1 — captures topic + fidelity/iteration numbers; LLM summarization deferred to a follow-up - Env-seam pattern (AMICODE_SESSION_RECAP_CACHE_DIR, OPENCODE_DB) for testability - Graceful degradation: returns null under Node/vitest or on any error - Pure logic functions exported for unit testing (26 tests) Acceptance criteria addressed: - Returning user sees ## Recent sessions in system prompt - Current session excluded; subagent sessions excluded - Noise sessions (Compaction, <2 assistant msgs) filtered - Cache prevents re-summarization - Graceful degradation on failure (never crashes prompt build) Closes #523 --- .../opencode-plugin/amicode_context.ts | 27 +- .../opencode-plugin/session_recap.ts | 330 ++++++++++++++++++ packages/extension/test/session_recap.test.ts | 290 +++++++++++++++ 3 files changed, 640 insertions(+), 7 deletions(-) create mode 100644 packages/extension/opencode-plugin/session_recap.ts create mode 100644 packages/extension/test/session_recap.test.ts diff --git a/packages/extension/opencode-plugin/amicode_context.ts b/packages/extension/opencode-plugin/amicode_context.ts index 788d3d13..750638c4 100644 --- a/packages/extension/opencode-plugin/amicode_context.ts +++ b/packages/extension/opencode-plugin/amicode_context.ts @@ -1,12 +1,12 @@ // ============================================================================ // amicode_context — an opencode plugin that injects live stack-state context -// (solver mode, routing, active problem, live runs) into every system prompt -// via the `experimental.chat.system.transform` hook. +// (solver mode, routing, active problem, live runs, recent sessions) into +// every system prompt via the `experimental.chat.system.transform` hook. // // RUNTIME: same constraints as amicode_tools.ts — executes inside opencode's // embedded Bun runtime, registered by absolute path via OPENCODE_CONFIG_CONTENT // `plugin: [""]`. Exactly ONE export (the legacy-plugin scan constraint). -// All imports are sibling modules using node: builtins only. +// All imports are sibling modules using node:/bun: builtins only. // // This is a SECOND plugin file alongside amicode_tools.ts; it is registered as // a separate entry in the `plugin` array and operates independently from the @@ -15,14 +15,16 @@ // ============================================================================ import { buildStackStateBlock } from "./stack_state"; +import { buildRecentSessionsBlock } from "./session_recap"; -console.error("[amicode-context] loaded — stack-state injection plugin (experimental.chat.system.transform)"); +console.error("[amicode-context] loaded — stack-state + session-recap injection plugin"); export const AmicodeContext = async () => ({ - "experimental.chat.system.transform": ( - _input: { sessionID?: string; model?: string }, + "experimental.chat.system.transform": async ( + input: { sessionID?: string; model?: string }, output: { system: string[] }, - ): void => { + ): Promise => { + // Stack state (solver mode, active problem, runs, fleet, vault) try { const block = buildStackStateBlock(); if (block) { @@ -32,5 +34,16 @@ export const AmicodeContext = async () => ({ console.error(`[amicode-context] buildStackStateBlock failed: ${e instanceof Error ? e.message : String(e)}`); // Never throw — a failing hook must not break the prompt build. } + + // Recent sessions recap (from the opencode session DB) + try { + const recapBlock = buildRecentSessionsBlock(input.sessionID); + if (recapBlock) { + output.system.push(recapBlock); + } + } catch (e) { + console.error(`[amicode-context] buildRecentSessionsBlock failed: ${e instanceof Error ? e.message : String(e)}`); + // Never throw — graceful degradation: no recap is better than a crash. + } }, }); diff --git a/packages/extension/opencode-plugin/session_recap.ts b/packages/extension/opencode-plugin/session_recap.ts new file mode 100644 index 00000000..762f8fca --- /dev/null +++ b/packages/extension/opencode-plugin/session_recap.ts @@ -0,0 +1,330 @@ +// ============================================================================ +// session_recap — reads recent sessions from the opencode SQLite DB, caches +// per-session summaries to disk, and composes a markdown block for prompt +// injection. Uses bun:sqlite (Bun built-in, no npm dep) for DB reads. +// +// RUNTIME: Bun-embedded opencode plugin. Imports: bun:sqlite, node:fs, +// node:path, node:os. No npm packages. +// +// The LLM summarization step is deferred to a future iteration — for now we +// extract a mechanical recap from message content (user prompts + assistant +// text parts). This gives us the full pipeline (DB → cache → markdown) without +// requiring an LLM call inside the plugin, which would need provider config +// the plugin doesn't have access to. The mechanical extract is good enough for +// the onset router to personalize the greeting. +// +// TEST SEAMS: +// AMICODE_SESSION_RECAP_CACHE_DIR — override the cache directory +// OPENCODE_DB — override the DB path (shared with opencode itself) +// ============================================================================ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; + +// ── Types (exported for testing) ───────────────────────────────────────────── + +export interface SessionRow { + id: string; + title: string; + parent_id: string | null; + time_created: number; + time_updated: number; +} + +export interface SessionRecap { + session_id: string; + title: string; + created: string; // ISO + recap: string; + summarized_at: string; // ISO +} + +// ── Configuration ──────────────────────────────────────────────────────────── + +export const RECAP_WINDOW_DAYS = 7; +export const MAX_RECAPS = 10; +export const MIN_ASSISTANT_MESSAGES = 2; + +// Titles that indicate noise sessions (internal housekeeping) +export const NOISE_TITLE_PREFIXES = ["Compaction", "compaction"]; + +// ── Path resolution (seam-based, like stack_state.ts) ──────────────────────── + +export function resolveCacheDir(): string { + const env = process.env.AMICODE_SESSION_RECAP_CACHE_DIR; + if (env && env.trim() !== "") return env.trim(); + return path.join(os.homedir(), ".amico", "session-recaps"); +} + +export function resolveDbPath(): string { + const env = process.env.OPENCODE_DB; + if (env && env.trim() !== "" && env !== ":memory:") { + if (path.isAbsolute(env)) return env; + const xdgData = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"); + return path.join(xdgData, "opencode", env); + } + const xdgData = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"); + return path.join(xdgData, "opencode", "opencode.db"); +} + +// ── Cache ──────────────────────────────────────────────────────────────────── + +function ensureCacheDir(): boolean { + try { + fs.mkdirSync(resolveCacheDir(), { recursive: true }); + return true; + } catch { + return false; + } +} + +function cachePathFor(sessionId: string): string { + return path.join(resolveCacheDir(), `${sessionId}.json`); +} + +export function readCachedRecap(sessionId: string): SessionRecap | null { + try { + const raw = fs.readFileSync(cachePathFor(sessionId), "utf8"); + return JSON.parse(raw) as SessionRecap; + } catch { + return null; + } +} + +export function writeCachedRecap(recap: SessionRecap): void { + try { + ensureCacheDir(); + const tmp = cachePathFor(recap.session_id) + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(recap, null, 2), "utf8"); + fs.renameSync(tmp, cachePathFor(recap.session_id)); + } catch { + // Cache write failure is non-fatal + } +} + +// ── Filtering (pure, testable) ─────────────────────────────────────────────── + +/** Filter sessions per the issue's acceptance criteria. */ +export function filterCandidates( + sessions: SessionRow[], + currentSessionId: string | undefined, + assistantCountFn: (sessionId: string) => number, +): SessionRow[] { + const candidates: SessionRow[] = []; + for (const s of sessions) { + if (currentSessionId && s.id === currentSessionId) continue; + if (NOISE_TITLE_PREFIXES.some(p => s.title.startsWith(p))) continue; + if (assistantCountFn(s.id) < MIN_ASSISTANT_MESSAGES) continue; + candidates.push(s); + if (candidates.length >= MAX_RECAPS) break; + } + return candidates; +} + +// ── Mechanical recap extraction (pure, testable) ───────────────────────────── + +/** Extract key outcomes from assistant text parts. */ +export function extractOutcomes(assistantTexts: string[]): string[] { + const outcomes: string[] = []; + for (const text of assistantTexts) { + // Look for fidelity values + const fMatch = text.match(/[Ff]\s*[=:≈]\s*(0\.9\d+|1\.0)/); + if (fMatch && !outcomes.some(o => o.includes("F="))) { + outcomes.push(`F=${fMatch[1]}`); + } + + // Look for iteration counts + const iterMatch = text.match(/(\d+)\s*iteration/i); + if (iterMatch && !outcomes.some(o => o.includes("iter"))) { + outcomes.push(`${iterMatch[1]} iterations`); + } + + // Look for infidelity + const infMatch = text.match(/infidelity\s*[=:≈]\s*([\d.]+[eE][-+]?\d+)/); + if (infMatch && !outcomes.some(o => o.includes("infidelity"))) { + outcomes.push(`infidelity ${infMatch[1]}`); + } + } + return outcomes; +} + +/** Compose a recap string from user prompts and extracted outcomes. */ +export function composeRecapText(userTexts: string[], outcomes: string[]): string | null { + if (userTexts.length === 0) return null; + + const firstPrompt = userTexts[0].slice(0, 120).replace(/\n/g, " "); + const promptSummary = userTexts.length > 1 + ? `${firstPrompt}... (+${userTexts.length - 1} more turns)` + : firstPrompt; + + const parts = [promptSummary]; + if (outcomes.length > 0) { + parts.push(outcomes.join(", ")); + } + + return parts.join(". "); +} + +// ── Markdown composition (pure, testable) ──────────────────────────────────── + +export function composeMarkdown(recaps: SessionRecap[]): string { + const lines = ["## Recent sessions (last 7 days)", ""]; + + for (const r of recaps) { + const date = new Date(r.created); + const dateStr = date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const timeStr = date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); + const titlePart = r.title && r.title !== "New Session" ? `**${r.title}** — ` : ""; + lines.push(`- **${dateStr}, ${timeStr}** — ${titlePart}${r.recap}`); + } + + return lines.join("\n"); +} + +// ── DB interaction (Bun-only, isolated for testability) ────────────────────── + +/** Lazy-loaded Database class from bun:sqlite. Null on non-Bun runtimes. */ +let SqliteDatabase: (new (path: string, opts?: { readonly?: boolean }) => any) | null = null; +try { + // bun:sqlite is a Bun built-in — this import resolves only in the Bun runtime + // (opencode's embedded plugin runner). Under Node/vitest it throws and we + // gracefully degrade (buildRecentSessionsBlock returns null). + SqliteDatabase = require("bun:sqlite").Database; +} catch { + // Not running in Bun — DB access unavailable +} + +/** Query user-prompt and assistant texts from a session. Returns null if DB unavailable. */ +function querySessionContent(db: any, sessionId: string): { userTexts: string[]; assistantTexts: string[] } | null { + try { + // Get user text parts + const userParts = db.prepare(` + SELECT p.data FROM part p + JOIN message m ON p.message_id = m.id + WHERE p.session_id = ? + AND json_extract(m.data, '$.role') = 'user' + AND json_extract(p.data, '$.type') = 'text' + ORDER BY p.time_created ASC + `).all(sessionId) as Array<{ data: string }>; + + const userTexts: string[] = []; + for (const row of userParts) { + try { + const parsed = JSON.parse(row.data) as { text?: string }; + if (parsed.text && parsed.text.trim()) { + userTexts.push(parsed.text.trim()); + } + } catch { /* skip */ } + } + + // Get assistant text parts (latest first) + const assistantParts = db.prepare(` + SELECT p.data FROM part p + JOIN message m ON p.message_id = m.id + WHERE p.session_id = ? + AND json_extract(m.data, '$.role') = 'assistant' + AND json_extract(p.data, '$.type') = 'text' + ORDER BY p.time_created DESC + LIMIT 10 + `).all(sessionId) as Array<{ data: string }>; + + const assistantTexts: string[] = []; + for (const row of assistantParts) { + try { + const parsed = JSON.parse(row.data) as { text?: string }; + if (parsed.text) assistantTexts.push(parsed.text); + } catch { /* skip */ } + } + + return { userTexts, assistantTexts }; + } catch { + return null; + } +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +/** + * List recent sessions, generate/cache recaps, compose a markdown block. + * Returns null if there are no recent sessions, DB is unavailable, or on error. + */ +export function buildRecentSessionsBlock(currentSessionId?: string): string | null { + if (!SqliteDatabase) return null; + + const dbPath = resolveDbPath(); + if (!fs.existsSync(dbPath)) return null; + + let db: any; + try { + db = new SqliteDatabase(dbPath, { readonly: true }); + } catch { + return null; + } + + try { + const cutoff = Date.now() - RECAP_WINDOW_DAYS * 24 * 60 * 60 * 1000; + + // Query recent sessions: non-subagent, non-archived, within window + const sessions = db.prepare(` + SELECT id, title, parent_id, time_created, time_updated + FROM session + WHERE parent_id IS NULL + AND time_archived IS NULL + AND time_created > ? + ORDER BY time_created DESC + LIMIT ? + `).all(cutoff, MAX_RECAPS + 5) as SessionRow[]; + + if (sessions.length === 0) return null; + + // Filter candidates + const assistantCountFn = (sessionId: string): number => { + const row = db.prepare(` + SELECT COUNT(*) as cnt FROM message + WHERE session_id = ? AND json_extract(data, '$.role') = 'assistant' + `).get(sessionId) as { cnt: number } | null; + return row?.cnt ?? 0; + }; + + const candidates = filterCandidates(sessions, currentSessionId, assistantCountFn); + if (candidates.length === 0) return null; + + // Generate recaps (cached or fresh) + const recaps: SessionRecap[] = []; + for (const s of candidates) { + const cached = readCachedRecap(s.id); + if (cached) { + recaps.push(cached); + continue; + } + + const content = querySessionContent(db, s.id); + if (!content || content.userTexts.length === 0) continue; + + const outcomes = extractOutcomes(content.assistantTexts); + const recapText = composeRecapText(content.userTexts, outcomes); + if (!recapText) continue; + + const recap: SessionRecap = { + session_id: s.id, + title: s.title, + created: new Date(s.time_created).toISOString(), + recap: recapText, + summarized_at: new Date().toISOString(), + }; + + writeCachedRecap(recap); + recaps.push(recap); + } + + if (recaps.length === 0) return null; + + return composeMarkdown(recaps); + } catch (e) { + console.error(`[session-recap] failed: ${e instanceof Error ? e.message : String(e)}`); + return null; + } finally { + try { db.close(); } catch { /* ignore */ } + } +} diff --git a/packages/extension/test/session_recap.test.ts b/packages/extension/test/session_recap.test.ts new file mode 100644 index 00000000..f312d383 --- /dev/null +++ b/packages/extension/test/session_recap.test.ts @@ -0,0 +1,290 @@ +// Tests for the session_recap module — the pure/testable layer. +// +// The DB-access layer (bun:sqlite) is not available under vitest/Node, so +// buildRecentSessionsBlock() itself is tested only as "returns null when +// bun:sqlite is unavailable" (graceful degradation). The logic it orchestrates +// — filtering, outcome extraction, recap composition, caching, markdown — is +// all exercised here through the exported pure functions. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; + +import { + filterCandidates, + extractOutcomes, + composeRecapText, + composeMarkdown, + readCachedRecap, + writeCachedRecap, + buildRecentSessionsBlock, + NOISE_TITLE_PREFIXES, + MIN_ASSISTANT_MESSAGES, + MAX_RECAPS, + type SessionRow, + type SessionRecap, +} from "../opencode-plugin/session_recap"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function mkTmp(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function makeSession(overrides: Partial = {}): SessionRow { + return { + id: `ses_${Math.random().toString(36).slice(2)}`, + title: "Test session", + parent_id: null, + time_created: Date.now() - 3600_000, + time_updated: Date.now(), + ...overrides, + }; +} + +// ── filterCandidates ───────────────────────────────────────────────────────── + +describe("filterCandidates — session selection logic", () => { + const alwaysEnough = () => MIN_ASSISTANT_MESSAGES; + const alwaysTooFew = () => MIN_ASSISTANT_MESSAGES - 1; + + it("excludes the current session", () => { + const s = makeSession({ id: "ses_current" }); + const result = filterCandidates([s], "ses_current", alwaysEnough); + expect(result).toHaveLength(0); + }); + + it("excludes sessions with noise title prefixes", () => { + const sessions = NOISE_TITLE_PREFIXES.map(p => + makeSession({ title: `${p} of old messages` }), + ); + const result = filterCandidates(sessions, undefined, alwaysEnough); + expect(result).toHaveLength(0); + }); + + it("excludes sessions with too few assistant messages", () => { + const s = makeSession(); + const result = filterCandidates([s], undefined, alwaysTooFew); + expect(result).toHaveLength(0); + }); + + it("includes sessions meeting all criteria", () => { + const s = makeSession({ title: "Transmon X gate design" }); + const result = filterCandidates([s], undefined, alwaysEnough); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(s.id); + }); + + it("caps at MAX_RECAPS", () => { + const sessions = Array.from({ length: MAX_RECAPS + 5 }, (_, i) => + makeSession({ id: `ses_${i}`, title: `Session ${i}` }), + ); + const result = filterCandidates(sessions, undefined, alwaysEnough); + expect(result).toHaveLength(MAX_RECAPS); + }); + + it("does not exclude sessions with null currentSessionId", () => { + const s = makeSession(); + const result = filterCandidates([s], undefined, alwaysEnough); + expect(result).toHaveLength(1); + }); +}); + +// ── extractOutcomes ────────────────────────────────────────────────────────── + +describe("extractOutcomes — numerical result extraction from assistant text", () => { + it("extracts fidelity values (F=0.9xxx)", () => { + const texts = ["The solve converged to F = 0.99982 in 137 iterations."]; + const outcomes = extractOutcomes(texts); + expect(outcomes).toContain("F=0.99982"); + }); + + it("extracts iteration counts", () => { + const texts = ["Converged after 250 iterations."]; + const outcomes = extractOutcomes(texts); + expect(outcomes).toContain("250 iterations"); + }); + + it("extracts infidelity in scientific notation", () => { + const texts = ["infidelity = 2.1e-4 after smoothing."]; + const outcomes = extractOutcomes(texts); + expect(outcomes).toContain("infidelity 2.1e-4"); + }); + + it("deduplicates: only first fidelity is kept", () => { + const texts = [ + "First run: F = 0.998", + "Second run: F = 0.9995", + ]; + const outcomes = extractOutcomes(texts); + const fEntries = outcomes.filter(o => o.startsWith("F=")); + expect(fEntries).toHaveLength(1); + expect(fEntries[0]).toBe("F=0.998"); + }); + + it("returns empty for text with no numerical outcomes", () => { + const texts = ["Let me help you set up the system model."]; + const outcomes = extractOutcomes(texts); + expect(outcomes).toHaveLength(0); + }); + + it("handles empty input", () => { + expect(extractOutcomes([])).toEqual([]); + }); +}); + +// ── composeRecapText ───────────────────────────────────────────────────────── + +describe("composeRecapText — recap string composition", () => { + it("returns first user prompt truncated to 120 chars", () => { + const longPrompt = "x".repeat(200); + const result = composeRecapText([longPrompt], []); + expect(result).not.toBeNull(); + expect(result!.length).toBeLessThanOrEqual(120 + 10); // prompt + possible suffix + }); + + it("appends turn count for multi-turn sessions", () => { + const result = composeRecapText(["first", "second", "third"], []); + expect(result).toContain("+2 more turns"); + }); + + it("appends outcomes when present", () => { + const result = composeRecapText(["Design X gate"], ["F=0.999", "50 iterations"]); + expect(result).toContain("F=0.999, 50 iterations"); + }); + + it("returns null for empty user texts", () => { + expect(composeRecapText([], ["F=0.999"])).toBeNull(); + }); + + it("replaces newlines in prompts with spaces", () => { + const result = composeRecapText(["line1\nline2\nline3"], []); + expect(result).not.toContain("\n"); + }); +}); + +// ── composeMarkdown ────────────────────────────────────────────────────────── + +describe("composeMarkdown — final prompt section composition", () => { + it("starts with the heading", () => { + const recaps: SessionRecap[] = [{ + session_id: "ses_1", + title: "Test", + created: "2026-08-23T10:30:00.000Z", + recap: "Did some stuff", + summarized_at: "2026-08-23T14:00:00.000Z", + }]; + const md = composeMarkdown(recaps); + expect(md.startsWith("## Recent sessions (last 7 days)")).toBe(true); + }); + + it("renders date and time for each entry", () => { + const recaps: SessionRecap[] = [{ + session_id: "ses_1", + title: "Transmon X gate", + created: "2026-08-23T10:30:00.000Z", + recap: "Launched solve, F=0.999", + summarized_at: "2026-08-23T14:00:00.000Z", + }]; + const md = composeMarkdown(recaps); + expect(md).toContain("Aug 23"); + expect(md).toContain("Launched solve, F=0.999"); + }); + + it("renders title in bold when not 'New Session'", () => { + const recaps: SessionRecap[] = [{ + session_id: "ses_1", + title: "CZ gate design", + created: "2026-08-22T09:00:00.000Z", + recap: "Started interview", + summarized_at: "2026-08-22T09:05:00.000Z", + }]; + const md = composeMarkdown(recaps); + expect(md).toContain("**CZ gate design**"); + }); + + it("omits title when it is 'New Session'", () => { + const recaps: SessionRecap[] = [{ + session_id: "ses_1", + title: "New Session", + created: "2026-08-22T09:00:00.000Z", + recap: "Quick question", + summarized_at: "2026-08-22T09:05:00.000Z", + }]; + const md = composeMarkdown(recaps); + expect(md).not.toContain("**New Session**"); + expect(md).toContain("Quick question"); + }); + + it("renders multiple entries in order", () => { + const recaps: SessionRecap[] = [ + { session_id: "ses_1", title: "First", created: "2026-08-23T10:00:00.000Z", recap: "A", summarized_at: "" }, + { session_id: "ses_2", title: "Second", created: "2026-08-22T10:00:00.000Z", recap: "B", summarized_at: "" }, + ]; + const md = composeMarkdown(recaps); + const posA = md.indexOf("A"); + const posB = md.indexOf("B"); + expect(posA).toBeLessThan(posB); + }); +}); + +// ── Cache read/write ───────────────────────────────────────────────────────── + +describe("cache — read/write SessionRecap to disk", () => { + let tmpDir: string; + const origEnv = process.env.AMICODE_SESSION_RECAP_CACHE_DIR; + + beforeEach(() => { + tmpDir = mkTmp("recap-cache-"); + process.env.AMICODE_SESSION_RECAP_CACHE_DIR = tmpDir; + }); + afterEach(() => { + if (origEnv === undefined) delete process.env.AMICODE_SESSION_RECAP_CACHE_DIR; + else process.env.AMICODE_SESSION_RECAP_CACHE_DIR = origEnv; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("round-trips a recap through write then read", () => { + const recap: SessionRecap = { + session_id: "ses_test123", + title: "My session", + created: "2026-08-23T10:00:00.000Z", + recap: "Tested the thing", + summarized_at: "2026-08-23T14:00:00.000Z", + }; + writeCachedRecap(recap); + const read = readCachedRecap("ses_test123"); + expect(read).toEqual(recap); + }); + + it("returns null for uncached session", () => { + expect(readCachedRecap("ses_nonexistent")).toBeNull(); + }); + + it("write is atomic (uses tmp + rename)", () => { + const recap: SessionRecap = { + session_id: "ses_atomic", + title: "Atomic", + created: "2026-08-23T10:00:00.000Z", + recap: "Test", + summarized_at: "2026-08-23T14:00:00.000Z", + }; + writeCachedRecap(recap); + // No .tmp file should remain + const files = fs.readdirSync(tmpDir); + expect(files.some(f => f.endsWith(".tmp"))).toBe(false); + expect(files).toContain("ses_atomic.json"); + }); +}); + +// ── buildRecentSessionsBlock graceful degradation ──────────────────────────── + +describe("buildRecentSessionsBlock — graceful degradation under Node (no bun:sqlite)", () => { + it("returns null when bun:sqlite is unavailable (Node runtime)", () => { + // Under vitest/Node, bun:sqlite doesn't load — the function should + // degrade gracefully and return null. + const result = buildRecentSessionsBlock("ses_current"); + expect(result).toBeNull(); + }); +}); From 5cf4e1f3e51164230d4e900f385049e48e7c8ba4 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sun, 23 Aug 2026 12:11:01 -0400 Subject: [PATCH 2/4] fix(router): drop 'Design a new pulse' from default onset options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onset router unconditionally offered pulse design as a top-level option, causing the model to recommend pulses even for users whose intent is general coding or product development. The design-a-pulse skill remains invocable by name — it's just no longer the first thing offered on every session. The remaining default options (resume active problem, resume campaign, fleet ops, bring your own problem, just explore) are all state-gated: they only appear when the relevant state exists, so the model builds the greeting from what's actually there rather than defaulting to quantum control. --- packages/extension/src/scores/router.ts | 1 - .../extension/test/scores/entitlements_router.test.ts | 8 +++++--- packages/extension/test/scores/golden/router-section.md | 1 - 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/extension/src/scores/router.ts b/packages/extension/src/scores/router.ts index be5ba3e6..2fac4baa 100644 --- a/packages/extension/src/scores/router.ts +++ b/packages/extension/src/scores/router.ts @@ -24,7 +24,6 @@ export function buildRouterSection(visible: Score[]): string { "", "- **Resume the active problem** — ONLY when the stack state shows one; name it and where it stands (system ✓ / formulation ✓ / mid-solve).", "- **Resume your research campaign** — ONLY when a session ledger exists under the personal vault's `sessions/`; the autoresearch director re-reads the latest ledger and continues the loop.", - "- **Design a new pulse** — invoke the `design-a-pulse` skill for the guided interview (platform → model → formulation → solve).", "- **Fleet & studio ops** — ONLY when fleet state is present; status digest, sync rituals, healthcheck.", "- **Bring your own problem** — papers, notes, or a graph file; extract candidate entities, confirm each one before recording, then join the best-matching workflow.", "- **Just explore** — free-form; no rail.", diff --git a/packages/extension/test/scores/entitlements_router.test.ts b/packages/extension/test/scores/entitlements_router.test.ts index 190b67f9..6ed68248 100644 --- a/packages/extension/test/scores/entitlements_router.test.ts +++ b/packages/extension/test/scores/entitlements_router.test.ts @@ -86,18 +86,20 @@ describe("buildRouterSection", () => { it("renders the onset question with fixed options", () => { const md = buildRouterSection([pub]); expect(md).toContain("What do you want to do today?"); - expect(md).toContain("Design a new pulse"); expect(md).toContain("Bring your own problem"); expect(md).toContain("Resume the active problem"); expect(md).toContain("Resume your research campaign"); expect(md).toContain("Just explore"); + // pulse-designer is invocable by skill name but NOT a default onset option + expect(md).not.toContain("Design a new pulse"); }); - it("pulse-designer is the fixed system option, NOT an entry card", () => { + it("pulse-designer score is NOT surfaced as an entry card or fixed option", () => { const md = buildRouterSection([pub, gated]); const cardBlock = md.slice(md.indexOf("application entry cards")); expect(cardBlock).toContain("pasqal-mis"); - // score #0 must not be duplicated as an application entry card + // pulse-designer must not appear anywhere — neither as entry card nor fixed option expect(md.indexOf("Name of pulse-designer")).toBe(-1); + expect(md).not.toContain("Design a new pulse"); }); it("entry cards carry outcome, duration, and device badge", () => { const md = buildRouterSection([gated]); diff --git a/packages/extension/test/scores/golden/router-section.md b/packages/extension/test/scores/golden/router-section.md index 07356c59..9eda097e 100644 --- a/packages/extension/test/scores/golden/router-section.md +++ b/packages/extension/test/scores/golden/router-section.md @@ -13,7 +13,6 @@ actually shows: - **Resume the active problem** — ONLY when the stack state shows one; name it and where it stands (system ✓ / formulation ✓ / mid-solve). - **Resume your research campaign** — ONLY when a session ledger exists under the personal vault's `sessions/`; the autoresearch director re-reads the latest ledger and continues the loop. -- **Design a new pulse** — invoke the `design-a-pulse` skill for the guided interview (platform → model → formulation → solve). - **Fleet & studio ops** — ONLY when fleet state is present; status digest, sync rituals, healthcheck. - **Bring your own problem** — papers, notes, or a graph file; extract candidate entities, confirm each one before recording, then join the best-matching workflow. - **Just explore** — free-form; no rail. From e8271f97c65443132290c0ad00509ceeecd00e8f Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sun, 23 Aug 2026 12:20:25 -0400 Subject: [PATCH 3/4] fix(router): handle post-completion 'begin onboarding' without invoking skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When onboarding is already complete, the overture score is not compiled into the session's AGENTS.md. Previously, the onset router said 'skip to the overture' for onboarding requests — but the overture wasn't there. The model then improvised by invoking design-a-pulse from the skill index (the closest thing to an interview it could find). Now the router explicitly handles the post-completion case: tell the user their profile exists and offer keep/redo. No skill invocation, no improvised interview. The overture only runs when it's actually compiled in (shouldOnboard = true at boot time). --- packages/extension/src/scores/router.ts | 9 +++++++-- packages/extension/test/scores/golden/router-section.md | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/extension/src/scores/router.ts b/packages/extension/src/scores/router.ts index 2fac4baa..fe2a861c 100644 --- a/packages/extension/src/scores/router.ts +++ b/packages/extension/src/scores/router.ts @@ -12,8 +12,13 @@ export function buildRouterSection(visible: Score[]): string { "## Onset router", "", 'When a session opens with an explicit onboarding request ("Let\'s begin', - 'onboarding", "begin onboarding", "start onboarding", or similar), **skip this', - "router entirely** and go straight into the overture — the user has already chosen.", + 'onboarding", "begin onboarding", "start onboarding", or similar):', + "if the overture interview section is present below, run it. If it is NOT", + "present (onboarding already completed), tell the user their profile is already", + "set up and offer a choice: **keep current profile** or **start fresh** (which", + 'deletes `~/.amico/amicode/onboarding/completed` and `~/.amico/profile.json`,', + "then asks the user to reload the window so the overture compiles in). Do NOT", + "improvise an interview, invoke skills, or chain into any other workflow.", "", "Otherwise, when a session opens without a specific request (a greeting, \"who are", "you?\", \"what is this?\"), do NOT default to any interview — build the moment", diff --git a/packages/extension/test/scores/golden/router-section.md b/packages/extension/test/scores/golden/router-section.md index 9eda097e..8e887d55 100644 --- a/packages/extension/test/scores/golden/router-section.md +++ b/packages/extension/test/scores/golden/router-section.md @@ -1,8 +1,13 @@ ## Onset router When a session opens with an explicit onboarding request ("Let's begin -onboarding", "begin onboarding", "start onboarding", or similar), **skip this -router entirely** and go straight into the overture — the user has already chosen. +onboarding", "begin onboarding", "start onboarding", or similar): +if the overture interview section is present below, run it. If it is NOT +present (onboarding already completed), tell the user their profile is already +set up and offer a choice: **keep current profile** or **start fresh** (which +deletes `~/.amico/amicode/onboarding/completed` and `~/.amico/profile.json`, +then asks the user to reload the window so the overture compiles in). Do NOT +improvise an interview, invoke skills, or chain into any other workflow. Otherwise, when a session opens without a specific request (a greeting, "who are you?", "what is this?"), do NOT default to any interview — build the moment From c953b465aaab5a3092a32e517392ff19aae2c4cb Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sun, 23 Aug 2026 12:30:03 -0400 Subject: [PATCH 4/4] =?UTF-8?q?fix(config):=20always=20compile=20overture?= =?UTF-8?q?=20into=20AGENTS.md=20=E2=80=94=20no=20reload=20needed=20for=20?= =?UTF-8?q?re-onboarding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the overture was only compiled into the session's AGENTS.md when shouldOnboard=true at boot. Post-completion, it was absent — so 'begin onboarding' required a window reload to recompile it in. Bad UX. Now the overture is always compiled in (as a section after the general-purpose copilot stub), gated by the onset router's instructions rather than by presence/absence. The router says 'run the overture if present' — and it always is. No reload needed to re-run onboarding. Cost: ~12KB added to prompts that already had the stub. The overture content is inert unless the user explicitly triggers it via the onset router. --- packages/extension/src/opencode_config.ts | 12 ++++++------ packages/extension/src/scores/router.ts | 2 +- .../extension/test/scores/golden/router-section.md | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index 8499e18f..51553386 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -647,11 +647,10 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro fs.mkdirSync(problemsRoot(), { recursive: true }); fs.writeFileSync(path.join(problemsRoot(), "score_manifest.json"), manifestJson); } else if (score0) { - // Post-onboarding: inject only the onset router. The pulse-designer - // interview protocol is NOT compiled into every session — domain-specific - // workflows (pulse design, autoresearch, etc.) load on-demand via the - // skill system when the user asks for them. - const stub = [ + // Post-onboarding: the model does NOT proactively start any interview, + // but the overture content IS compiled in so "begin onboarding" works + // without a window reload. The onset router gates when it runs. + const preamble = [ "", "", "You are a general-purpose autoresearch copilot. Do NOT proactively start", @@ -659,7 +658,8 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro "user explicitly asks for it. When they do, invoke the relevant skill from", "the Skill index and follow the workflow in the ## Workflow section above.", ].join("\n"); - finalContent = spliceIntoAgentsMd(filled, buildRouterSection(visible), stub); + const overtureBlock = overture ? `\n\n${compileScore(overture)}` : ""; + finalContent = spliceIntoAgentsMd(filled, buildRouterSection(visible), preamble + overtureBlock); // Manifest transport: the opencode plugin (Bun runtime, separate process tree) // reads score_manifest.json from the problems ROOT — that is the guard's // session-scoped manifestDir (per-problem interview state lives in each diff --git a/packages/extension/src/scores/router.ts b/packages/extension/src/scores/router.ts index fe2a861c..0658d728 100644 --- a/packages/extension/src/scores/router.ts +++ b/packages/extension/src/scores/router.ts @@ -17,7 +17,7 @@ export function buildRouterSection(visible: Score[]): string { "present (onboarding already completed), tell the user their profile is already", "set up and offer a choice: **keep current profile** or **start fresh** (which", 'deletes `~/.amico/amicode/onboarding/completed` and `~/.amico/profile.json`,', - "then asks the user to reload the window so the overture compiles in). Do NOT", + "then re-runs the overture from Stage 1). Do NOT", "improvise an interview, invoke skills, or chain into any other workflow.", "", "Otherwise, when a session opens without a specific request (a greeting, \"who are", diff --git a/packages/extension/test/scores/golden/router-section.md b/packages/extension/test/scores/golden/router-section.md index 8e887d55..8269bcf0 100644 --- a/packages/extension/test/scores/golden/router-section.md +++ b/packages/extension/test/scores/golden/router-section.md @@ -6,7 +6,7 @@ if the overture interview section is present below, run it. If it is NOT present (onboarding already completed), tell the user their profile is already set up and offer a choice: **keep current profile** or **start fresh** (which deletes `~/.amico/amicode/onboarding/completed` and `~/.amico/profile.json`, -then asks the user to reload the window so the overture compiles in). Do NOT +then re-runs the overture from Stage 1). Do NOT improvise an interview, invoke skills, or chain into any other workflow. Otherwise, when a session opens without a specific request (a greeting, "who are