diff --git a/packages/amico-run/src/session_retention.ts b/packages/amico-run/src/session_retention.ts new file mode 100644 index 00000000..ecc6eb7a --- /dev/null +++ b/packages/amico-run/src/session_retention.ts @@ -0,0 +1,142 @@ +// session_retention.ts — D4 (spec-20260905-045114) slice 3, pure core: the +// retention policy's workspace preference + the session-index renderer. +// +// Retention RELOCATES, never deletes (D4 invariant): the archive command moves +// sessions from the active tail to the archived set by stamping the engine's +// `time_archived` field — the same field the vendored engine's list endpoint +// already filters on — and restore clears it. Deletion is out of the +// vocabulary. Archive affects product lists only; the vault ledger plane and +// the coordination board are separate transports this module never touches. +// +// The archive cutoff is a WORKSPACE PREFERENCE, not a constant: it lives in +// `$AMICODE_OPS_DIR/session-retention.json` (default `~/.amico/amicode/`), +// the same ops-dir convention as solver-mode.json. Reads fail SAFE to the 30-day +// default on an absent, malformed, or out-of-range file — a corrupt preference +// must never widen what gets archived. + +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export const DEFAULT_ARCHIVE_DAYS = 30; + +function amicodeOpsDir(env: NodeJS.ProcessEnv): string { + const v = env.AMICODE_OPS_DIR; + return v && v.trim() !== "" ? v : join(homedir(), ".amico", "amicode"); +} + +export function retentionPrefsFile(env: NodeJS.ProcessEnv = process.env): string { + return join(amicodeOpsDir(env), "session-retention.json"); +} + +/** The archive cutoff in days. Fails safe to DEFAULT_ARCHIVE_DAYS. */ +export function readArchiveDays(env: NodeJS.ProcessEnv = process.env): number { + try { + const parsed = JSON.parse(readFileSync(retentionPrefsFile(env), "utf8")) as { archive_days?: unknown }; + const d = parsed.archive_days; + if (typeof d === "number" && Number.isInteger(d) && d >= 1) return d; + return DEFAULT_ARCHIVE_DAYS; + } catch { + return DEFAULT_ARCHIVE_DAYS; + } +} + +export type WritePrefsResult = { ok: true; file: string; days: number } | { ok: false; error: string }; + +export function writeArchiveDays(days: number, env: NodeJS.ProcessEnv = process.env): WritePrefsResult { + if (!Number.isInteger(days) || days < 1) { + return { ok: false, error: `archive_days must be a positive integer, got ${days}` }; + } + const file = retentionPrefsFile(env); + mkdirSync(join(file, ".."), { recursive: true }); + writeFileSync(file, `${JSON.stringify({ schema_version: 1, archive_days: days }, null, 2)}\n`); + return { ok: true, file, days }; +} + +// ── the generated session index (pure renderer) ───────────────────────────── + +/** One session row as the index consumes it — exactly the fields the DB query + * returns. Directory provenance is carried in full; the table renders the + * basename (the hand-written reference shape), the distribution header carries + * the full paths. */ +export interface IndexSession { + id: string; + title: string; + directory: string; + time_updated: number; + time_archived: number | null; +} + +export interface IndexInput { + generated_at: string; + source_db: string; + sessions: IndexSession[]; +} + +function basename(p: string): string { + const parts = p.split("/"); + return parts[parts.length - 1] || p; +} + +function monthOf(ms: number): string { + const d = new Date(ms); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +} + +function dayOf(ms: number): string { + const d = new Date(ms); + return `${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +/** Render SESSION-INDEX.md. Deterministic: month sections newest-first, rows + * newest-first with id as tie-break. Every row in the DB appears exactly once; + * archived rows carry their state in the table, and the distribution header + * carries the directory provenance + visible/archived counts. */ +export function renderSessionIndex(input: IndexInput): string { + const { sessions } = input; + const visible = sessions.filter((s) => s.time_archived === null).length; + const archived = sessions.length - visible; + + const byDir = new Map(); + for (const s of sessions) byDir.set(s.directory, (byDir.get(s.directory) ?? 0) + 1); + const dirs = [...byDir.entries()].sort((a, b) => a[0].localeCompare(b[0])); + + const byMonth = new Map(); + for (const s of sessions) { + const m = monthOf(s.time_updated); + if (!byMonth.has(m)) byMonth.set(m, []); + byMonth.get(m)!.push(s); + } + const months = [...byMonth.keys()].sort().reverse(); + + const lines: string[] = []; + lines.push("# Session index"); + lines.push(""); + lines.push( + `*Generated ${input.generated_at} from the chat DB at \`${input.source_db}\` — ` + + `${sessions.length} sessions: ${visible} visible · ${archived} archived.`, + ); + lines.push( + "Archived sessions are hidden from default lists (query: `amico sessions list --archived`; " + + "restore: `amico sessions restore `). Retention relocates — nothing is ever deleted.", + ); + lines.push(`Directory provenance — ${dirs.length} ${dirs.length === 1 ? "directory" : "directories"}:`); + for (const [dir, n] of dirs) lines.push(`- \`${dir}\` — ${n} ${n === 1 ? "session" : "sessions"}`); + lines.push("Generated by `amico sessions index`; regenerate, never edit by hand.*"); + lines.push(""); + + for (const m of months) { + lines.push(`## ${m}`); + lines.push(""); + lines.push("| last active | title | home | state | id |"); + lines.push("|---|---|---|---|---|"); + const rows = byMonth.get(m)!.sort((a, b) => b.time_updated - a.time_updated || a.id.localeCompare(b.id)); + for (const s of rows) { + const state = s.time_archived === null ? "active" : "archived"; + const title = s.title.replace(/\|/g, "\\|"); + lines.push(`| ${dayOf(s.time_updated)} | ${title} | ${basename(s.directory)} | ${state} | \`${s.id}\` |`); + } + lines.push(""); + } + return lines.join("\n"); +} diff --git a/packages/amico-run/src/sessions_verb.ts b/packages/amico-run/src/sessions_verb.ts new file mode 100644 index 00000000..9e248d00 --- /dev/null +++ b/packages/amico-run/src/sessions_verb.ts @@ -0,0 +1,277 @@ +// sessions_verb.ts — `amico sessions` (D4 slice 3, issue #795): the retention +// lifecycle as a CLI verb — list (visibility rules), archive (relocate, never +// delete), restore (clear one field), index (generate SESSION-INDEX.md). +// +// The engine owns the archived-field mechanics (time_archived on the session +// table; its list endpoint's archived query param). THIS is the product layer: +// the visibility rules the product respects, the retention policy as a +// workspace preference, and the generated session index — over the chat DB +// the hub serves. The vault ledger plane and the coordination board are +// separate transports; this verb never reads or writes them (D4 disjointness). +// +// DB ACCESS CONVENTION (mirrors the open-threads skill, the store's other +// reader): `--db` flag → $OPENCODE_DB → ~/.local/share/opencode/opencode.db. +// Reads open READ-ONLY. Writes (archive --apply / restore) open read-write — +// the verb is the deterministic surface for what the 2026-09-05 consolidation +// did by hand SQL. `archive` is DRY-RUN BY DEFAULT: an agent running it +// against the live DB without --apply must not relocate anything. +// +// The driver is the python3 stdlib sqlite3 bridge (src/sqlite_bridge.ts) — +// NOT node:sqlite, which does not exist on the repo's CI node (20.x). See the +// bridge module header for the full rationale. +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + readArchiveDays, + renderSessionIndex, + writeArchiveDays, + type IndexSession, +} from "./session_retention.js"; +import { sqliteBatch, type BridgeStatement } from "./sqlite_bridge.js"; +import type { VerbResult } from "./verbs.js"; + +export const DEFAULT_LIST_LIMIT = 100; + +function flagValue(argv: string[], name: string): string | undefined { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; +} + +function hasFlag(argv: string[], name: string): boolean { + return argv.includes(name); +} + +/** `--db` flag → $OPENCODE_DB → the XDG default. Same resolution order the + * open-threads skill documents for the store's readers. */ +export function resolveSessionDb(argv: string[], env: NodeJS.ProcessEnv = process.env): string { + const flag = flagValue(argv, "--db"); + if (flag) return flag; + const envDb = env.OPENCODE_DB; + if (envDb && envDb.trim() !== "") return envDb; + return join(env.XDG_DATA_HOME && env.XDG_DATA_HOME.trim() !== "" ? join(env.XDG_DATA_HOME, "opencode") : join(homedir(), ".local", "share", "opencode"), "opencode.db"); +} + +function fail(error: string, extra: Record = {}): VerbResult { + return { json: { verb: "sessions", error, ...extra }, code: 64 }; +} + +interface SessionRow { + id: string; + parent_id: string | null; + directory: string; + title: string; + time_created: number; + time_updated: number; + time_archived: number | null; +} + +function rowOf(r: Record): SessionRow { + return { + id: String(r.id), + parent_id: (r.parent_id as string | null) ?? null, + directory: String(r.directory), + title: String(r.title), + time_created: Number(r.time_created), + time_updated: Number(r.time_updated), + time_archived: r.time_archived === null || r.time_archived === undefined ? null : Number(r.time_archived), + }; +} + +// ── list ──────────────────────────────────────────────────────────────────── + +function sessionsList(argv: string[]): VerbResult { + const dbPath = resolveSessionDb(argv); + if (!existsSync(dbPath)) return fail(`session DB not found: ${dbPath}`); + const archived = hasFlag(argv, "--archived"); + const limit = Math.min(Math.max(Number(flagValue(argv, "--limit") ?? DEFAULT_LIST_LIMIT) || DEFAULT_LIST_LIMIT, 1), 1000); + const cursor = Number(flagValue(argv, "--cursor") ?? 0) || 0; + const where = archived ? "time_archived IS NOT NULL" : "time_archived IS NULL"; + + let batch; + try { + batch = sqliteBatch(dbPath, "ro", [ + { sql: `SELECT count(*) AS n FROM session WHERE ${where}` }, + { + sql: `SELECT id, parent_id, directory, title, time_created, time_updated, time_archived + FROM session WHERE ${where} ORDER BY time_updated DESC, id LIMIT ? OFFSET ?`, + params: [limit, cursor], + }, + ]); + } catch (e) { + return fail(e instanceof Error ? e.message : String(e)); + } + const rows = batch.results[1].rows.map(rowOf); + const total = Number(batch.results[0].rows[0]?.n ?? 0); + const next = cursor + rows.length; + return { + json: { + verb: "sessions", + subcommand: "list", + archived, + count: rows.length, + total, + next_cursor: next < total ? next : null, + sessions: rows.map((r) => ({ ...r, archived: r.time_archived !== null })), + }, + code: 0, + }; +} + +// ── archive (relocate; dry-run by default) ────────────────────────────────── + +function sessionsArchive(argv: string[], env: NodeJS.ProcessEnv): VerbResult { + const dbPath = resolveSessionDb(argv); + if (!existsSync(dbPath)) return fail(`session DB not found: ${dbPath}`); + const days = flagValue(argv, "--days") ? Number(flagValue(argv, "--days")) : readArchiveDays(env); + if (!Number.isInteger(days) || days < 1) return fail(`--days must be a positive integer, got ${days}`); + const apply = hasFlag(argv, "--apply"); + const cutoff = Date.now() - days * 86_400_000; + + const statements: BridgeStatement[] = [ + { + sql: "SELECT id FROM session WHERE time_archived IS NULL AND time_updated < ? ORDER BY time_updated DESC, id", + params: [cutoff], + }, + ]; + if (apply) { + statements.push({ + sql: "UPDATE session SET time_archived = ? WHERE time_archived IS NULL AND time_updated < ?", + params: [Date.now(), cutoff], + }); + } + + let batch; + try { + batch = sqliteBatch(dbPath, apply ? "rw" : "ro", statements); + } catch (e) { + return fail(e instanceof Error ? e.message : String(e)); + } + const candidates = batch.results[0].rows.map((r) => String(r.id)); + return { + json: { + verb: "sessions", + subcommand: "archive", + dry_run: !apply, + days, + cutoff_ms: cutoff, + cutoff_iso: new Date(cutoff).toISOString(), + candidates: candidates.length, + candidate_ids: candidates.slice(0, 50), + archived: apply ? Number(batch.results[1]?.changes ?? candidates.length) : 0, + note: apply ? undefined : "dry-run: nothing written — pass --apply to stamp time_archived", + }, + code: 0, + }; +} + +// ── restore (clear the one field) ─────────────────────────────────────────── + +function sessionsRestore(argv: string[]): VerbResult { + const id = argv.find((a) => !a.startsWith("--")); + if (!id) return fail("session id is required: amico sessions restore "); + const dbPath = resolveSessionDb(argv); + if (!existsSync(dbPath)) return fail(`session DB not found: ${dbPath}`); + + let probe; + try { + probe = sqliteBatch(dbPath, "ro", [{ sql: "SELECT time_archived FROM session WHERE id = ?", params: [id] }]); + } catch (e) { + return fail(e instanceof Error ? e.message : String(e)); + } + const row = probe.results[0].rows[0] as { time_archived: number | null } | undefined; + if (!row) return fail(`no such session: ${id}`); + if (row.time_archived === null || row.time_archived === undefined) { + return { json: { verb: "sessions", subcommand: "restore", session_id: id, restored: false }, code: 0 }; + } + try { + sqliteBatch(dbPath, "rw", [{ sql: "UPDATE session SET time_archived = NULL WHERE id = ?", params: [id] }]); + } catch (e) { + return fail(e instanceof Error ? e.message : String(e)); + } + return { json: { verb: "sessions", subcommand: "restore", session_id: id, restored: true }, code: 0 }; +} + +// ── index (generate; never author) ────────────────────────────────────────── + +function sessionsIndex(argv: string[]): VerbResult { + const dbPath = resolveSessionDb(argv); + if (!existsSync(dbPath)) return fail(`session DB not found: ${dbPath}`); + const out = flagValue(argv, "--out") ?? "SESSION-INDEX.md"; + + let batch; + try { + batch = sqliteBatch(dbPath, "ro", [ + { sql: "SELECT id, directory, title, time_updated, time_archived FROM session ORDER BY time_updated DESC, id" }, + ]); + } catch (e) { + return fail(e instanceof Error ? e.message : String(e)); + } + const rows = batch.results[0].rows.map(rowOf).map((r) => ({ id: r.id, directory: r.directory, title: r.title, time_updated: r.time_updated, time_archived: r.time_archived })); + const visible = rows.filter((r) => r.time_archived === null).length; + const markdown = renderSessionIndex({ + generated_at: new Date().toISOString(), + source_db: dbPath, + sessions: rows as IndexSession[], + }); + mkdirSync(join(out, ".."), { recursive: true }); + writeFileSync(out, markdown); + return { + json: { + verb: "sessions", + subcommand: "index", + path: out, + sessions_indexed: rows.length, + visible, + archived: rows.length - visible, + source_db: dbPath, + }, + code: 0, + }; +} + +// ── prefs (the workspace preference surface) ──────────────────────────────── + +function sessionsPrefs(argv: string[], env: NodeJS.ProcessEnv): VerbResult { + const days = flagValue(argv, "--days"); + if (days !== undefined) { + const n = Number(days); + const w = writeArchiveDays(n, env); + if (!w.ok) return fail(w.error); + return { json: { verb: "sessions", subcommand: "prefs", archive_days: w.days, file: w.file }, code: 0 }; + } + return { + json: { verb: "sessions", subcommand: "prefs", archive_days: readArchiveDays(env), file: join(env.AMICODE_OPS_DIR && env.AMICODE_OPS_DIR.trim() !== "" ? env.AMICODE_OPS_DIR : join(homedir(), ".amico", "amicode"), "session-retention.json") }, + code: 0, + }; +} + +// ── dispatch ──────────────────────────────────────────────────────────────── + +export async function sessionsVerb(argv: string[]): Promise { + const sub = argv[0]; + const rest = argv.slice(1); + const env = process.env; + switch (sub) { + case "list": + return sessionsList(rest); + case "archive": + return sessionsArchive(rest, env); + case "restore": + return sessionsRestore(rest); + case "index": + return sessionsIndex(rest); + case "prefs": + return sessionsPrefs(rest, env); + default: + return { + json: { + verb: "sessions", + error: `unknown subcommand ${sub ? `"${sub}"` : "(none)"}`, + usage: + "amico sessions list [--archived] [--limit ] [--cursor ] [--db ] | amico sessions archive [--days ] [--apply] | amico sessions restore | amico sessions index [--out ] | amico sessions prefs [--days ]", + }, + code: 64, + }; + } +} diff --git a/packages/amico-run/src/sqlite_bridge.ts b/packages/amico-run/src/sqlite_bridge.ts new file mode 100644 index 00000000..0d7e9489 --- /dev/null +++ b/packages/amico-run/src/sqlite_bridge.ts @@ -0,0 +1,88 @@ +// sqlite_bridge.ts — the sessions verb's SQLite access over python3's stdlib +// sqlite3 (D4 slice 3, #795; CI portability fix). +// +// Why python3 and not node:sqlite: the repo's CI pins node 20 (ci.yml) and the +// engines say >= 20, but node:sqlite only exists on node >= 22.5 — a node:sqlite +// driver silently breaks the verb (and its tests) on the repo's own CI node. +// The store's OTHER reader already uses python3 stdlib sqlite3 (the open-threads +// skill: file:?mode=ro, timeout=5), and AMICO_PYTHON→python3 is the +// product's existing interpreter resolution (pasqal_launch.ts). Zero new +// dependencies; the write path runs through the real sqlite engine (proper +// locking/commit), never a load-whole-file rewrite. +// +// Shape: ONE python3 invocation per verb operation, statements batched, JSON +// over stdio. `ro` opens READ-ONLY (the open-threads discipline: the hub owns +// the live DB); `rw` opens read-write and commits at the end. + +import { spawnSync } from "node:child_process"; + +export interface BridgeStatement { + sql: string; + params?: unknown[]; +} + +export interface BridgeStatementResult { + rows: Record[]; + changes: number; +} + +export interface BridgeResult { + results: BridgeStatementResult[]; +} + +const PYTHON_SCRIPT = ` +import json, sqlite3, sys + +req = json.load(sys.stdin) +db = req["db"] +if req["mode"] == "rw": + con = sqlite3.connect(db, timeout=5) +else: + con = sqlite3.connect("file:" + db + "?mode=ro", uri=True, timeout=5) +try: + results = [] + for st in req["statements"]: + cur = con.execute(st["sql"], st.get("params", [])) + rows = [] + if cur.description is not None: + cols = [d[0] for d in cur.description] + rows = [dict(zip(cols, r)) for r in cur.fetchall()] + results.append({"rows": rows, "changes": max(cur.rowcount, 0)}) + if req["mode"] == "rw": + con.commit() + json.dump({"results": results}, sys.stdout) +finally: + con.close() +`; + +export class SqliteBridgeError extends Error {} + +export function sqliteBatch( + dbPath: string, + mode: "ro" | "rw", + statements: BridgeStatement[], + env: NodeJS.ProcessEnv = process.env, +): BridgeResult { + const py = env.AMICO_PYTHON && env.AMICO_PYTHON.trim() !== "" ? env.AMICO_PYTHON : "python3"; + const res = spawnSync(py, ["-c", PYTHON_SCRIPT, "--"], { + input: JSON.stringify({ db: dbPath, mode, statements }), + encoding: "utf8", + timeout: 60_000, + }); + if (res.error) { + throw new SqliteBridgeError( + `could not run ${py}: ${res.error.message} — install Python 3, or set AMICO_PYTHON to your interpreter`, + ); + } + if (res.status !== 0) { + throw new SqliteBridgeError(`sqlite bridge failed (exit ${res.status}): ${(res.stderr || res.stdout || "").trim()}`); + } + let parsed: BridgeResult & { error?: string }; + try { + parsed = JSON.parse(res.stdout) as BridgeResult & { error?: string }; + } catch { + throw new SqliteBridgeError(`sqlite bridge returned unparseable output: ${(res.stdout || "").slice(0, 200)}`); + } + if (parsed.error) throw new SqliteBridgeError(parsed.error); + return parsed; +} diff --git a/packages/amico-run/src/verbs.ts b/packages/amico-run/src/verbs.ts index e92c77a5..d2b71ac7 100644 --- a/packages/amico-run/src/verbs.ts +++ b/packages/amico-run/src/verbs.ts @@ -26,6 +26,7 @@ import { planVerb } from "./plan_verb.js"; import { handoffVerb } from "./handoff_verb.js"; import { campaignVerb } from "./campaign_verb.js"; import { projectVerb } from "./project_verb.js"; +import { sessionsVerb } from "./sessions_verb.js"; export interface VerbResult { json: unknown; // structured result (stdout as JSON for the CLI; tool content for MCP) @@ -227,6 +228,27 @@ const project: Verb = { run: projectVerb, }; +// sessions — the D4 retention surface (spec-20260905-045114 slice 3, #795): +// the chat DB's lifecycle as product verbs. `list` respects the visibility +// rules (default hides archived; `--archived` is the explicit opt-in); `archive` +// RELOCATES old sessions by stamping the engine's time_archived (dry-run by +// default; deletion is out of the vocabulary); `restore` clears the field; +// `index` regenerates sessions/SESSION-INDEX.md from the DB. The archive cutoff +// is the workspace preference ($AMICODE_OPS_DIR/session-retention.json, default +// 30 days). The vault ledger plane and the coordination board are separate +// transports — this verb never touches them. +// +// NOTE: the DB access runs through the python3 stdlib sqlite3 bridge +// (sqlite_bridge.ts) — the repo's CI pins node 20, where node:sqlite does not +// exist, and the store's other reader (open-threads) already uses python3. +const sessions: Verb = { + name: "sessions", + summary: "session retention over the chat DB: list (visibility rules) / archive (relocate) / restore / index (generated) / prefs", + generalizes: "the hand-built 2026-09-05 SESSION-INDEX.md + hand SQL consolidation (the D4 anti-pattern, made contractual)", + slice: "session-device lifecycle D4 (slice 3, #795)", + run: (args) => sessionsVerb(args), +}; + export const SPINE_VERBS: Verb[] = [ catalog, vault, @@ -241,4 +263,5 @@ export const SPINE_VERBS: Verb[] = [ papers, campaign, project, + sessions, ]; diff --git a/packages/amico-run/test/sessions_verb.test.ts b/packages/amico-run/test/sessions_verb.test.ts new file mode 100644 index 00000000..ad39f773 --- /dev/null +++ b/packages/amico-run/test/sessions_verb.test.ts @@ -0,0 +1,496 @@ +// `amico sessions` — D4 slice 3 (issue #795): session retention that relocates + +// the generated session index. Pure core (session_retention.ts) is unit-tested +// against src; the verb bodies run through `dist/amico.js` against SEEDED COPY +// databases in temp dirs — never the live chat DB (shared with a running hub). +// Run: `pnpm --filter @amicode/amico-run test`. +import { beforeAll, describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + DEFAULT_ARCHIVE_DAYS, + readArchiveDays, + writeArchiveDays, + retentionPrefsFile, +} from "../src/session_retention.js"; + +// ── AC 3: the archive cutoff is a workspace preference with default 30 days ── +describe("retention preference — the archive cutoff", () => { + let ops: string; + beforeEach(() => { + ops = mkdtempSync(join(tmpdir(), "amico-sessions-ops-")); + }); + afterEach(() => rmSync(ops, { recursive: true, force: true })); + + it("defaults to 30 days when no preference file exists (fresh install)", () => { + expect(readArchiveDays({ AMICODE_OPS_DIR: ops })).toBe(30); + expect(DEFAULT_ARCHIVE_DAYS).toBe(30); + }); + + it("fails safe to 30 on a malformed or out-of-range preference file", () => { + writeFileSync(join(ops, "session-retention.json"), "{not json"); + expect(readArchiveDays({ AMICODE_OPS_DIR: ops })).toBe(30); + writeFileSync(join(ops, "session-retention.json"), JSON.stringify({ archive_days: 0 })); + expect(readArchiveDays({ AMICODE_OPS_DIR: ops })).toBe(30); + writeFileSync(join(ops, "session-retention.json"), JSON.stringify({ archive_days: -5 })); + expect(readArchiveDays({ AMICODE_OPS_DIR: ops })).toBe(30); + writeFileSync(join(ops, "session-retention.json"), JSON.stringify({ archive_days: "thirty" })); + expect(readArchiveDays({ AMICODE_OPS_DIR: ops })).toBe(30); + }); + + it("reads a written preference (7 days) and reports the file it came from", () => { + writeArchiveDays(7, { AMICODE_OPS_DIR: ops }); + expect(readArchiveDays({ AMICODE_OPS_DIR: ops })).toBe(7); + expect(retentionPrefsFile({ AMICODE_OPS_DIR: ops })).toBe(join(ops, "session-retention.json")); + const parsed = JSON.parse(readFileSync(retentionPrefsFile({ AMICODE_OPS_DIR: ops }), "utf8")); + expect(parsed).toMatchObject({ schema_version: 1, archive_days: 7 }); + }); + + it("refuses to write a non-positive or non-integer cutoff", () => { + expect(writeArchiveDays(0, { AMICODE_OPS_DIR: ops }).ok).toBe(false); + expect(writeArchiveDays(2.5, { AMICODE_OPS_DIR: ops }).ok).toBe(false); + expect(existsSync(join(ops, "session-retention.json"))).toBe(false); + }); + + it("falls back to ~/.amico/amicode when AMICODE_OPS_DIR is unset", () => { + expect(retentionPrefsFile({})).toBe(join(homedir(), ".amico", "amicode", "session-retention.json")); + }); +}); + +// ── the seeded-DB harness (NEVER the live chat DB — hub-shared production state) ── +import { execFileSync } from "node:child_process"; +import { mkdirSync } from "node:fs"; + +const BUNDLE = join(__dirname, "..", "dist", "amico.js"); +beforeAll(() => { + execFileSync("node", [join(__dirname, "..", "esbuild.config.mjs")], { cwd: join(__dirname, "..") }); +}); + +function run(args: string[], env: Record = {}): { code: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync("node", [BUNDLE, ...args], { encoding: "utf8", env: { ...process.env, ...env } }); + return { code: 0, stdout, stderr: "" }; + } catch (e) { + const err = e as { status?: number; stdout?: string; stderr?: string }; + return { code: err.status ?? -1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; + } +} + +const DAY = 86_400_000; + +interface SeedOpts { + id: string; + title?: string; + directory?: string; + updatedDaysAgo: number; + createdDaysAgo?: number; + archived?: boolean; + parent?: string; +} + +/** Seed a minimal session table shaped like the live DB's (the columns the verb + * queries exist in both; the engine owns the real DDL). Seeding runs through + * python3's stdlib sqlite3 — the same driver the verb uses (sqlite_bridge.ts), + * which exists on the repo's CI node (20.x) where node:sqlite does not. */ +function seedDb(dbPath: string, seeds: SeedOpts[]): void { + mkdirSync(join(dbPath, ".."), { recursive: true }); + const script = ` +import json, sqlite3, sys + +seeds = json.loads(sys.argv[2]) +now = int(sys.argv[3]) +day = ${DAY} +con = sqlite3.connect(sys.argv[1], timeout=5) +con.executescript(""" +CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT, + parent_id TEXT, + directory TEXT NOT NULL, + title TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_archived INTEGER +); +CREATE TABLE project ( + id TEXT PRIMARY KEY, + worktree TEXT, + vcs TEXT, + name TEXT, + time_created INTEGER, + time_updated INTEGER +); +""") +con.execute("INSERT INTO project (id, worktree, vcs, name, time_created, time_updated) VALUES (?,?,?,?,?,?)", + ("proj_armonia", "/home/aaron/armonia", None, "armonia", now - 100 * day, now - 1 * day)) +for s in seeds: + updated = now - s["updatedDaysAgo"] * day + created = now - (s["createdDaysAgo"] if s.get("createdDaysAgo") is not None else s["updatedDaysAgo"] + 1) * day + con.execute( + "INSERT INTO session (id, parent_id, directory, title, time_created, time_updated, time_archived) VALUES (?,?,?,?,?,?,?)", + (s["id"], s.get("parent"), s.get("directory", "/home/aaron/armonia"), s.get("title", "session " + s["id"]), + created, updated, now - s["updatedDaysAgo"] * day if s.get("archived") else None), + ) +con.commit() +con.close() + `; + execFileSync(pythonBin(), ["-c", script, dbPath, JSON.stringify(seeds), String(Date.now())], { encoding: "utf8" }); +} + +/** The interpreter the verb's bridge resolves: $AMICO_PYTHON → python3. */ +function pythonBin(): string { + return process.env.AMICO_PYTHON && process.env.AMICO_PYTHON.trim() !== "" ? process.env.AMICO_PYTHON : "python3"; +} + +// ── AC 1: the archive visibility matrix ───────────────────────────────────── +describe("amico sessions list/archive/restore — the visibility matrix (bundle)", () => { + let tmp: string; + let db: string; + let ops: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "amico-sessions-")); + db = join(tmp, "opencode.db"); + ops = join(tmp, "ops"); + mkdirSync(ops, { recursive: true }); + }); + afterEach(() => rmSync(tmp, { recursive: true, force: true })); + + function ids(r: { stdout: string }): string[] { + return (JSON.parse(r.stdout).sessions as { id: string }[]).map((s) => s.id); + } + + it("default list excludes archived; --archived opt-in lists them; restore clears the field", () => { + seedDb(db, [ + { id: "ses_active", updatedDaysAgo: 1 }, + { id: "ses_old", updatedDaysAgo: 60 }, + { id: "ses_archived", updatedDaysAgo: 90, archived: true }, + ]); + const env = { OPENCODE_DB: db, AMICODE_OPS_DIR: ops }; + + // default: active only — the archived session is invisible + expect(ids(run(["sessions", "list"], env))).toEqual(["ses_active", "ses_old"]); + + // explicit opt-in: archived only + const arch = JSON.parse(run(["sessions", "list", "--archived"], env).stdout); + expect(arch.sessions.map((s: { id: string }) => s.id)).toEqual(["ses_archived"]); + expect(arch.sessions[0].time_archived).toBeGreaterThan(0); + + // apply the retention policy (default cutoff = the 30-day preference): + // the 60-day-old session relocates; the active one stays. + const a = JSON.parse(run(["sessions", "archive", "--apply"], env).stdout); + expect(a).toMatchObject({ days: 30, archived: 1 }); + expect(ids(run(["sessions", "list"], env))).toEqual(["ses_active"]); + expect(ids(run(["sessions", "list", "--archived"], env))).toEqual(["ses_old", "ses_archived"]); + + // restore clears the one field — the session returns to the default list + const r = JSON.parse(run(["sessions", "restore", "ses_old"], env).stdout); + expect(r).toMatchObject({ restored: true }); + expect(ids(run(["sessions", "list"], env))).toEqual(["ses_active", "ses_old"]); + }); + + it("archive is DRY-RUN by default (reports candidates, writes nothing) — --apply is the write", () => { + seedDb(db, [ + { id: "ses_active", updatedDaysAgo: 1 }, + { id: "ses_old", updatedDaysAgo: 60 }, + ]); + const env = { OPENCODE_DB: db, AMICODE_OPS_DIR: ops }; + const dry = JSON.parse(run(["sessions", "archive"], env).stdout); + expect(dry).toMatchObject({ dry_run: true, days: 30, candidates: 1 }); + expect(ids(run(["sessions", "list"], env))).toEqual(["ses_active", "ses_old"]); // untouched + }); + + it("restore of an already-active session is an idempotent no-op; unknown id is a usage error", () => { + seedDb(db, [{ id: "ses_active", updatedDaysAgo: 1 }]); + const env = { OPENCODE_DB: db, AMICODE_OPS_DIR: ops }; + expect(run(["sessions", "restore", "ses_active"], env).code).toBe(0); + expect(JSON.parse(run(["sessions", "restore", "ses_active"], env).stdout)).toMatchObject({ restored: false }); + expect(run(["sessions", "restore", "ses_nope"], env).code).toBe(64); + }); + + it("refuses to run without a resolvable session DB (missing default is an honest 64, not a live-DB guess)", () => { + const r = run(["sessions", "list"], { OPENCODE_DB: join(tmp, "nope", "missing.db"), AMICODE_OPS_DIR: ops }); + expect(r.code).toBe(64); + expect(JSON.parse(r.stdout).error).toMatch(/not found|no session/); + }); +}); + +// ── AC 4: the index is generated, never authored — and matches the DB ─────── +describe("amico sessions index — regeneration vs the seeded DB (bundle)", () => { + let tmp: string; + let db: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "amico-sessions-idx-")); + db = join(tmp, "opencode.db"); + }); + afterEach(() => rmSync(tmp, { recursive: true, force: true })); + + interface ParsedRow { id: string; month: string; home: string; state: string } + + /** Parse the generated markdown back into rows — the shape check is part of + * the contract: month sections, one row per session, provenance columns. */ + function parseIndex(text: string): { rows: ParsedRow[]; header: string; distribution: string[]; visible: number; archived: number } { + const header = text.split("\n").find((l) => l.startsWith("*Generated"))!; + const distribution = text.split("\n").filter((l) => l.startsWith("- `")); + const visibleM = header.match(/(\d+) visible · (\d+) archived/)!; + const rows: ParsedRow[] = []; + let month = ""; + for (const line of text.split("\n")) { + const m = line.match(/^## (\d{4}-\d{2})$/); + if (m) { month = m[1]; continue; } + const r = line.match(/^\| [\d-]+ \| .* \| (\S+) \| (\w+) \| `(ses_\w+)` \|$/); + if (r) rows.push({ month, home: r[1], state: r[2], id: r[3] }); + } + return { rows, header, distribution, visible: Number(visibleM[1]), archived: Number(visibleM[2]) }; + } + + it("regenerates a complete, provenance-carrying index that matches the seeded DB", () => { + seedDb(db, [ + { id: "ses_a1", title: "Fleet sync", directory: "/home/aaron/armonia", updatedDaysAgo: 2 }, + { id: "ses_a2", title: "Gate solve", directory: "/home/aaron/harmoniqs/amicode", updatedDaysAgo: 3 }, + { id: "ses_b1", title: "Old work", directory: "/home/aaron/armonia", updatedDaysAgo: 40, archived: true }, + { id: "ses_sub1", title: "child", directory: "/home/aaron/armonia", updatedDaysAgo: 2, parent: "ses_a1" }, + ]); + const out = join(tmp, "sessions", "SESSION-INDEX.md"); + const r = JSON.parse( + run(["sessions", "index", "--db", db, "--out", out], { AMICODE_OPS_DIR: tmp }).stdout, + ); + expect(r).toMatchObject({ sessions_indexed: 4, visible: 3, archived: 1, source_db: db }); + expect(existsSync(out)).toBe(true); + + const text = readFileSync(out, "utf8"); + const parsed = parseIndex(text); + // complete: every DB row appears exactly once + expect(parsed.rows.map((x) => x.id).sort()).toEqual(["ses_a1", "ses_a2", "ses_b1", "ses_sub1"].sort()); + // provenance: home column is the directory basename from the DB + expect(parsed.rows.find((x) => x.id === "ses_a2")!.home).toBe("amicode"); + expect(parsed.rows.find((x) => x.id === "ses_a1")!.home).toBe("armonia"); + // archive state per row matches the DB + expect(parsed.rows.find((x) => x.id === "ses_b1")!.state).toBe("archived"); + expect(parsed.rows.find((x) => x.id === "ses_a1")!.state).toBe("active"); + // month sections follow time_updated + const nowMonth = new Date().toISOString().slice(0, 7); + expect(parsed.rows.find((x) => x.id === "ses_a1")!.month).toBe(nowMonth); + // the distribution header carries the full-path provenance + counts + expect(parsed.distribution).toContain("- `/home/aaron/armonia` — 3 sessions"); + expect(parsed.distribution).toContain("- `/home/aaron/harmoniqs/amicode` — 1 session"); + expect(parsed.visible).toBe(3); + expect(parsed.archived).toBe(1); + }); + + it("regeneration is deterministic apart from the generated-at stamp (idempotent rewrite)", () => { + seedDb(db, [{ id: "ses_x1", title: "Only", updatedDaysAgo: 1 }]); + const out = join(tmp, "SESSION-INDEX.md"); + const env = { AMICODE_OPS_DIR: tmp }; + run(["sessions", "index", "--db", db, "--out", out], env); + const first = readFileSync(out, "utf8"); + run(["sessions", "index", "--db", db, "--out", out], env); + const second = readFileSync(out, "utf8"); + expect(second.replace(/\*Generated [^*]+\*/, "")).toBe(first.replace(/\*Generated [^*]+\*/, "")); + }); + + it("an empty DB yields an honest empty index (0 visible · 0 archived), not an error", () => { + seedDb(db, []); + const out = join(tmp, "SESSION-INDEX.md"); + const r = JSON.parse(run(["sessions", "index", "--db", db, "--out", out], { AMICODE_OPS_DIR: tmp }).stdout); + expect(r).toMatchObject({ sessions_indexed: 0, visible: 0, archived: 0 }); + expect(readFileSync(out, "utf8")).toMatch(/0 sessions: 0 visible · 0 archived/); + }); +}); + +// ── AC 5: the boot list fetch remains paginated under growth (D4: "the recent +// tail first, so the refetch contract does not degrade as the list grows") ── +describe("amico sessions list — pagination under a 1000+ session store (bundle)", () => { + let tmp: string; + let db: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "amico-sessions-pg-")); + db = join(tmp, "opencode.db"); + }); + afterEach(() => rmSync(tmp, { recursive: true, force: true })); + + it("a seeded 1200-session store walks fully through bounded pages, newest first, exactly once", () => { + const N = 1200; + const seeds: SeedOpts[] = []; + for (let i = 0; i < N; i++) { + seeds.push({ id: `ses_pg${String(i).padStart(4, "0")}`, title: `s${i}`, updatedDaysAgo: (i % 400) + (i / 400) * 0.01, createdDaysAgo: 500 }); + } + seedDb(db, seeds); + const env = { OPENCODE_DB: db, AMICODE_OPS_DIR: tmp }; + + // first page (the boot page): bounded at the default limit, recent tail first + const first = JSON.parse(run(["sessions", "list"], env).stdout); + expect(first.count).toBe(100); + expect(first.total).toBe(N); + expect(first.next_cursor).toBe(100); + + // walk every page: 1200 sessions, each exactly once, no archive leakage + const seen: string[] = []; + let cursor: number | null = 0; + let pages = 0; + while (cursor !== null) { + const page = JSON.parse(run(["sessions", "list", "--cursor", String(cursor)], env).stdout); + expect(page.count).toBeLessThanOrEqual(100); + for (const s of page.sessions as { id: string }[]) seen.push(s.id); + cursor = page.next_cursor; + pages++; + expect(pages).toBeLessThan(50); // termination guard + } + expect(seen.length).toBe(N); + expect(new Set(seen).size).toBe(N); + + // ordering: time_updated DESC — the first page is the recent tail, and the + // walk is globally descending (allowing the id tie-break within a tick) + const firstIds = (first.sessions as { id: string }[]).map((s) => s.id); + expect(firstIds[0]).toBe("ses_pg0000"); // most recently updated + const byUpdated = JSON.parse(run(["sessions", "list", "--limit", "1000"], env).stdout); + expect(byUpdated.count).toBe(1000); // --limit is honored up to a hard cap + }); + + it("pagination respects the visibility rules (archived rows never leak into a default walk)", () => { + const seeds: SeedOpts[] = []; + for (let i = 0; i < 250; i++) { + seeds.push({ id: `ses_v${String(i).padStart(3, "0")}`, updatedDaysAgo: i % 200 }); + } + seeds.push({ id: "ses_hidden", updatedDaysAgo: 10, archived: true }); + seedDb(db, seeds); + const env = { OPENCODE_DB: db, AMICODE_OPS_DIR: tmp }; + + const seen: string[] = []; + let cursor: number | null = 0; + while (cursor !== null) { + const page = JSON.parse(run(["sessions", "list", "--limit", "50", "--cursor", String(cursor)], env).stdout); + for (const s of page.sessions as { id: string }[]) seen.push(s.id); + cursor = page.next_cursor; + } + expect(seen).toHaveLength(250); + expect(seen).not.toContain("ses_hidden"); + }); +}); + +// ── AC 2: archive affects product lists only — the vault ledger plane and the +// coordination plane are separate transports (D4 disjointness invariant) ── +describe("amico sessions archive — disjointness from the vault/coordination planes (bundle)", () => { + let tmp: string; + let db: string; + let vault: string; + let claims: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "amico-sessions-disj-")); + db = join(tmp, "opencode.db"); + vault = join(tmp, "vault"); + mkdirSync(join(vault, "sessions"), { recursive: true }); + writeFileSync(join(vault, "sessions", "session-ledger.md"), "# ledger plane — never touched by archive\n"); + claims = join(tmp, "claims.jsonl"); + writeFileSync(claims, '{"type":"claim","work_id":"w1"}\n'); + mkdirSync(join(tmp, "board"), { recursive: true }); + writeFileSync(join(tmp, "board", "m5-board.md"), "# coordination board — never touched\n"); + }); + afterEach(() => rmSync(tmp, { recursive: true, force: true })); + + function dbRows(dbPath: string): string { + return execFileSync( + pythonBin(), + ["-c", ` +import json, sqlite3, sys +con = sqlite3.connect("file:" + sys.argv[1] + "?mode=ro", uri=True, timeout=5) +con.row_factory = sqlite3.Row +print(json.dumps({ + "sessions": [dict(r) for r in con.execute("SELECT * FROM session ORDER BY id").fetchall()], + "projects": [dict(r) for r in con.execute("SELECT * FROM project ORDER BY id").fetchall()], +})) + `, dbPath], + { encoding: "utf8" }, + ); + } + + it("archive --apply and restore change ONLY time_archived on session rows; sibling planes are byte-identical", () => { + seedDb(db, [ + { id: "ses_active", updatedDaysAgo: 1 }, + { id: "ses_old", updatedDaysAgo: 60, directory: "/home/aaron/armonia" }, + ]); + const env = { OPENCODE_DB: db, AMICODE_OPS_DIR: tmp }; + const before = dbRows(db); + const vaultBefore = readFileSync(join(vault, "sessions", "session-ledger.md"), "utf8"); + const claimsBefore = readFileSync(claims, "utf8"); + const boardBefore = readFileSync(join(tmp, "board", "m5-board.md"), "utf8"); + const vaultListBefore = execFileSync("find", [vault], { encoding: "utf8" }); + + run(["sessions", "archive", "--apply"], env); + run(["sessions", "restore", "ses_old"], env); + + // sibling planes byte-identical, no new vault-plane files + expect(readFileSync(join(vault, "sessions", "session-ledger.md"), "utf8")).toBe(vaultBefore); + expect(readFileSync(claims, "utf8")).toBe(claimsBefore); + expect(readFileSync(join(tmp, "board", "m5-board.md"), "utf8")).toBe(boardBefore); + expect(execFileSync("find", [vault], { encoding: "utf8" })).toBe(vaultListBefore); + + // the DB delta is exactly the time_archived stamp + its clear — project + // rows and every other session column untouched (archive writes ONE field) + const after = dbRows(db); + const beforeParsed = JSON.parse(before) as { sessions: Record[]; projects: unknown[] }; + const afterParsed = JSON.parse(after) as { sessions: Record[]; projects: unknown[] }; + expect(afterParsed.projects).toEqual(beforeParsed.projects); + const diffs: string[] = []; + for (let i = 0; i < beforeParsed.sessions.length; i++) { + for (const k of Object.keys(beforeParsed.sessions[i])) { + if (String(beforeParsed.sessions[i][k]) !== String(afterParsed.sessions[i][k])) diffs.push(`${k}:${beforeParsed.sessions[i][k]}->${afterParsed.sessions[i][k]}`); + } + } + // ses_old was archived then restored → net zero; nothing else may differ + expect(diffs).toEqual([]); + }); +}); + +// ── AC 3 (end-to-end): the cutoff the archive applies comes from the workspace +// preference, overridable per call — never a hardcoded constant ──────────── +describe("amico sessions prefs/archive — the cutoff is the preference (bundle)", () => { + let tmp: string; + let db: string; + let ops: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "amico-sessions-prefs-")); + db = join(tmp, "opencode.db"); + ops = join(tmp, "ops"); + mkdirSync(ops, { recursive: true }); + }); + afterEach(() => rmSync(tmp, { recursive: true, force: true })); + + it("prefs --days writes the preference; archive --apply honors it (7d cutoff, not 30)", () => { + seedDb(db, [ + { id: "ses_5d", updatedDaysAgo: 5 }, + { id: "ses_10d", updatedDaysAgo: 10 }, + ]); + const env = { OPENCODE_DB: db, AMICODE_OPS_DIR: ops }; + + const p = JSON.parse(run(["sessions", "prefs", "--days", "7"], env).stdout); + expect(p).toMatchObject({ archive_days: 7 }); + expect(JSON.parse(readFileSync(join(ops, "session-retention.json"), "utf8")).archive_days).toBe(7); + + // dry-run first: exactly the 10-day-old session crosses the 7-day cutoff + expect(JSON.parse(run(["sessions", "archive"], env).stdout)).toMatchObject({ days: 7, candidates: 1, candidate_ids: ["ses_10d"] }); + run(["sessions", "archive", "--apply"], env); + const after = JSON.parse(run(["sessions", "list"], env).stdout); + expect(after.sessions.map((s: { id: string }) => s.id)).toEqual(["ses_5d"]); + }); + + it("--days overrides the preference for a single call without writing it", () => { + seedDb(db, [{ id: "ses_1d", updatedDaysAgo: 1 }]); + const env = { OPENCODE_DB: db, AMICODE_OPS_DIR: ops }; + const dry = JSON.parse(run(["sessions", "archive", "--days", "1"], env).stdout); + expect(dry).toMatchObject({ days: 1, candidates: 1 }); + expect(existsSync(join(ops, "session-retention.json"))).toBe(false); + expect(JSON.parse(run(["sessions", "prefs"], env).stdout)).toMatchObject({ archive_days: 30 }); + }); + + it("unknown subcommand is a usage error listing the surface", () => { + const r = run(["sessions", "bogus"], { AMICODE_OPS_DIR: ops }); + expect(r.code).toBe(64); + expect(JSON.parse(r.stdout).usage).toMatch(/archive/); + }); +});