@sonnechasser/ntrp 0.3.2 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/config/store.ts","../../src/output/formatters.ts","../../src/services/exports-registry-smoke.ts","../../src/services/exports-registry.ts","../../src/output/path-safety.ts","../../src/services/context-doc.ts","../../src/cli/context.ts","../../src/services/transcript.ts","../../src/services/terminal-capture.ts"],"sourcesContent":["import { readFileSync, writeFileSync, existsSync, mkdirSync } from \"fs\";\nimport { homedir } from \"os\";\nimport { join, resolve } from \"path\";\nimport type { CLIConfig } from \"../types.js\";\n\nconst NTRP_DIR = process.env.NTRP_HOME ? resolve(process.env.NTRP_HOME) : join(homedir(), \".ntrp\");\nconst CONFIG_PATH = join(NTRP_DIR, \"config.json\");\nlet cachedConfig: CLIConfig | null = null;\n\nexport function ntrpHome(): string {\n return NTRP_DIR;\n}\n\nfunction ensureDir(): void {\n if (!existsSync(NTRP_DIR)) {\n mkdirSync(NTRP_DIR, { recursive: true });\n }\n}\n\nexport function loadConfig(): CLIConfig {\n if (cachedConfig) return cachedConfig;\n ensureDir();\n if (!existsSync(CONFIG_PATH)) {\n cachedConfig = {};\n return cachedConfig;\n }\n try {\n cachedConfig = JSON.parse(readFileSync(CONFIG_PATH, \"utf-8\")) as CLIConfig;\n } catch {\n cachedConfig = {};\n }\n return cachedConfig;\n}\n\nexport function saveConfig(config: CLIConfig): void {\n ensureDir();\n writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + \"\\n\");\n cachedConfig = config;\n}\n\n/** Clear the in-memory config cache (e.g. after deleting config.json on disk). */\nexport function resetConfigCache(): void {\n cachedConfig = null;\n}\n\nexport function getConfigValue(key: string): string | undefined {\n // api-key is config-file only; env vars are never picked up automatically (see ai/repl-api.ts).\n if (key === \"api-key\") return loadConfig()[\"api-key\"];\n if (key === \"license-key\") return process.env.NTRP_LICENSE_KEY ?? (loadConfig() as Record<string, string | undefined>)[\"license-key\"];\n const config = loadConfig();\n return (config as Record<string, string | undefined>)[key];\n}\n\nexport function setConfigValue(key: string, value: string): void {\n const config = loadConfig();\n (config as Record<string, string>)[key] = value;\n saveConfig(config);\n}\n\nexport function deleteConfigValue(key: string): void {\n const config = loadConfig();\n delete (config as Record<string, unknown>)[key];\n saveConfig(config);\n}\n\nexport function getExportsDir(): string {\n const config = loadConfig();\n const dir = resolve(config[\"export-dir\"] ?? join(NTRP_DIR, \"exports\"));\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true });\n }\n return dir;\n}\n\n/** Optional desktop-AI inbox path from config (no mkdir). Null when unset. */\nexport function getConfiguredAiInboxDir(): string | null {\n const raw = loadConfig()[\"ai-inbox-dir\"];\n return raw ? resolve(raw) : null;\n}\n\nexport function getStrategiesDir(): string {\n const dir = join(NTRP_DIR, \"strategies\");\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true });\n writeFileSync(join(dir, \"README.md\"), `# Strategies\n\nThis directory holds your GTM strategy files. Each file describes a strategy you're executing.\n\n## How to use\n\n1. Create a markdown file for each active strategy (e.g., \\`multi-thread-q2.md\\`)\n2. Describe the goal, target segment, and success criteria\n3. Reference playbook plays that support this strategy\n4. After diagnosis, check if vital signs improved in the targeted area\n\n## Example\n\n\\`\\`\\`markdown\n# Multi-Thread Enterprise Deals — Q2\n\n**Goal:** Reduce single-threaded deals from 65% to under 30%\n**Segment:** Enterprise accounts > $100K\n**Play:** Multi-Thread Your Deals\n**Success metric:** Thread depth score > 70\n\\`\\`\\`\n`);\n }\n return dir;\n}\n\nexport function getMemoryDir(): string {\n const dir = join(NTRP_DIR, \"memory\");\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true });\n }\n return dir;\n}\n\nexport function getKnowledgeDir(): string {\n const dir = join(NTRP_DIR, \"knowledge\");\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true });\n writeFileSync(join(dir, \"README.md\"), `# Knowledge Packs\n\nDrop case studies, GTM frameworks, benchmark reports, or playbooks here as\nmarkdown, text, or PDF. NTRP ingests them with \\`/knowledge add <file>\\` and\nreferences the most relevant passages during analysis — so the agent can learn\nfrom work done outside this platform.\n\n## How to use\n\n1. Add a file: \\`/knowledge add ~/Downloads/plg-benchmarks-2026.pdf\\`\n2. List what's indexed: \\`/knowledge list\\`\n3. Ask a question — relevant passages are pulled in automatically.\n`);\n }\n return dir;\n}\n\nexport function getWinsDir(): string {\n const dir = join(NTRP_DIR, \"wins\");\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true });\n writeFileSync(join(dir, \"README.md\"), `# Wins\n\nThis directory logs outcomes when a strategy or play succeeds. Each win creates a record that future diagnoses can reference.\n\n## How to use\n\n1. After executing a play, log the result here (e.g., \\`2026-04-clean-pipeline.md\\`)\n2. Include: what you did, what changed, before/after scores\n3. Future AI findings will reference wins to track improvement over time\n\n## Example\n\n\\`\\`\\`markdown\n# Pipeline Cleanup — April 2026\n\n**Play:** Clean Dead Pipeline\n**Before:** Freshness 29/100, $3.1M stale pipeline\n**After:** Freshness 72/100, removed 45 zombie deals\n**Impact:** Forecast accuracy improved from 62% to 84%\n\\`\\`\\`\n`);\n }\n return dir;\n}\n","import type { VitalSign, VitalSignStatus } from \"../types.js\";\n\nexport const VITAL_SIGN_LABELS: Record<VitalSign, string> = {\n freshness: \"Freshness\",\n flow_rate: \"Flow Rate\",\n drop_rate: \"Drop Rate\",\n signal_to_noise: \"Signal:Noise\",\n thread_depth: \"Thread Depth\",\n};\n\n/** Markdown/notes export only — the TTY path renders status via `statusDot`. */\nexport function statusEmoji(status: VitalSignStatus): string {\n switch (status) {\n case \"green\": return \"🟢\";\n case \"yellow\": return \"🟡\";\n case \"red\": return \"🔴\";\n }\n}\n\nexport function formatDollarImpact(value: number | null | undefined, label: string | null | undefined): string {\n if (value != null && value > 0) return `${formatDollarValue(value)} ${label ?? \"\"}`.trim();\n return \"N/A\";\n}\n\nexport function formatScore(score: number): string {\n return `${Math.round(score)}`;\n}\n\nexport function formatPercent(value: number): string {\n return `${Math.round(value)}%`;\n}\n\nexport function formatNumber(value: number): string {\n return value.toLocaleString();\n}\n\nexport function formatCurrency(value: number): string {\n if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;\n if (value >= 1_000) return `$${(value / 1_000).toFixed(0)}K`;\n return `$${value.toFixed(0)}`;\n}\n\nexport interface PipelineMetrics {\n total_pipeline_value: number;\n at_risk_value: number;\n at_risk_deal_count: number;\n total_open_deals: number;\n}\n\ninterface VitalSignResultLike {\n vital_sign: string;\n components: Record<string, unknown>;\n}\n\nexport function extractPipelineMetrics(vitals: VitalSignResultLike[]): PipelineMetrics | null {\n const flowRate = vitals.find((v) => v.vital_sign === \"flow_rate\");\n if (!flowRate) return null;\n const openDeals = flowRate.components.open_deals as Record<string, unknown> | undefined;\n if (!openDeals) return null;\n const total = typeof openDeals.total_amount === \"number\" ? openDeals.total_amount : 0;\n if (total === 0) return null;\n return {\n total_pipeline_value: total,\n at_risk_value: typeof openDeals.stuck_total_amount === \"number\" ? openDeals.stuck_total_amount : 0,\n at_risk_deal_count: typeof openDeals.stuck_count === \"number\" ? openDeals.stuck_count : 0,\n total_open_deals: typeof openDeals.count === \"number\" ? openDeals.count : 0,\n };\n}\n\nexport function formatPipelineLine(metrics: PipelineMetrics): string {\n const total = formatCurrency(metrics.total_pipeline_value);\n if (metrics.at_risk_deal_count > 0) {\n const atRisk = formatCurrency(metrics.at_risk_value);\n return `Pipeline: ${total} total \\u00B7 ${atRisk} at risk (${metrics.at_risk_deal_count} deals)`;\n }\n return `Pipeline: ${total} open (${metrics.total_open_deals} deals)`;\n}\n\nexport const DOLLAR_LABELS: Record<VitalSign, string> = {\n freshness: \"pipeline at risk\",\n flow_rate: \"stuck in pipeline\",\n drop_rate: \"est. lost at handoff\",\n signal_to_noise: \"misdirected effort\",\n thread_depth: \"single-threaded\",\n};\n\nexport function formatDollarValue(value: number | null | undefined): string {\n if (value == null || value === 0) return \"N/A\";\n return formatCurrency(value);\n}\n\nexport function severityLabel(severity: string): string {\n switch (severity) {\n case \"critical\": return \"CRITICAL\";\n case \"warning\": return \"WARNING\";\n case \"info\": return \"INFO\";\n default: return severity.toUpperCase();\n }\n}\n","/**\n * Offline smoke for the exports registry — durable INDEX/manifest, kind\n * folders, AI inbox latest-* pointers, and move trail. No network, no DB.\n * Run via `npm run test:exports` (wrapper isolates NTRP_HOME).\n */\n\nimport { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { ntrpHome, resetConfigCache } from \"../config/store.js\";\nimport {\n archiveIndexPath,\n ensureExportsLayout,\n getAiInboxDir,\n getArchiveKindDir,\n inboxLatestHandoffPath,\n listExports,\n moveExport,\n recordExportWrite,\n resolveArchivePath,\n setAiInboxDir,\n exportStamp,\n readManifestEvents,\n} from \"./exports-registry.js\";\nimport { isInsideNtrp, resolveUserPath } from \"../output/path-safety.js\";\nimport { buildSessionContextDoc } from \"./context-doc.js\";\nimport { defaultSessionAnalysis, type SessionFile } from \"../cli/context.js\";\n\nconst failures: string[] = [];\n\nfunction assert(cond: boolean, msg: string): void {\n if (!cond) failures.push(msg);\n}\n\nfunction section(name: string): void {\n console.log(` · ${name}`);\n}\n\n// ─── Layout + write ───────────────────────────────────────────────────\n\nsection(\"archive layout + write\");\n{\n resetConfigCache();\n const root = ensureExportsLayout();\n assert(existsSync(join(root, \"handoffs\")), \"handoffs/ created\");\n assert(existsSync(join(root, \"README.md\")), \"archive README seeded\");\n assert(existsSync(join(root, \"manifest.jsonl\")), \"manifest.jsonl seeded\");\n\n const out = resolveArchivePath(\"prompt:deck\", `handoff-deck-${exportStamp()}.md`);\n writeFileSync(out, \"# Deck handoff\\n\\nBuild slides from this.\\n\", \"utf-8\");\n const event = recordExportWrite({\n kind: \"prompt:deck\",\n path: out,\n sessionId: \"sess-abcd1234\",\n title: \"deck handoff prompt\",\n });\n\n assert(event.kind === \"prompt:deck\", \"kind recorded\");\n assert(existsSync(join(root, \"latest\", \"handoff-deck.md\")), \"latest/handoff-deck.md\");\n assert(existsSync(join(root, \"latest\", \"handoff.md\")), \"latest/handoff.md generic\");\n const index = readFileSync(archiveIndexPath(), \"utf-8\");\n assert(index.includes(\"prompt:deck\"), \"INDEX mentions kind\");\n assert(index.includes(out), \"INDEX mentions path\");\n assert(readManifestEvents().some((e) => e.op === \"write\" && e.path === out), \"manifest write event\");\n}\n\n// ─── AI inbox ─────────────────────────────────────────────────────────\n\nsection(\"ai inbox sync\");\n{\n const inbox = join(ntrpHome(), \"claude-inbox\");\n setAiInboxDir(inbox);\n assert(getAiInboxDir() === inbox, \"inbox config set\");\n assert(existsSync(join(inbox, \"README.md\")), \"inbox README\");\n\n const out = resolveArchivePath(\"prompt:plan\", `handoff-plan-${exportStamp()}.md`);\n writeFileSync(out, \"# Plan handoff\\n\", \"utf-8\");\n const event = recordExportWrite({ kind: \"prompt:plan\", path: out, title: \"plan\" });\n\n assert(typeof event.inbox_path === \"string\", \"inbox_path on write event\");\n assert(existsSync(join(inbox, \"latest-handoff.md\")), \"latest-handoff.md\");\n assert(existsSync(join(inbox, \"latest-handoff-plan.md\")), \"latest-handoff-plan.md\");\n assert(existsSync(join(inbox, \"archive\")), \"inbox archive dir\");\n assert(readdirSync(join(inbox, \"archive\")).length >= 1, \"dated copy in inbox archive\");\n const inboxIndex = readFileSync(join(inbox, \"INDEX.md\"), \"utf-8\");\n assert(inboxIndex.includes(\"latest-handoff\"), \"inbox INDEX lists latest\");\n assert(inboxLatestHandoffPath() === join(inbox, \"latest-handoff.md\"), \"inboxLatestHandoffPath\");\n}\n\n// ─── Move trail ───────────────────────────────────────────────────────\n\nsection(\"move trail\");\n{\n const items = listExports({ limit: 10, kind: \"prompt\" });\n assert(items.length >= 1, \"listExports returns items\");\n const target = items.find((e) => e.kind === \"prompt:deck\") ?? items[0]!;\n const dest = join(ntrpHome(), \"moved-exports\");\n mkdirSync(dest, { recursive: true });\n const moved = moveExport(target.id, dest);\n assert(moved.op === \"move\", \"move op\");\n assert(moved.path.startsWith(dest), `moved under dest (got ${moved.path})`);\n assert((moved.previous_paths?.length ?? 0) >= 1, \"previous_paths recorded\");\n assert(existsSync(moved.path), \"file exists at new path\");\n assert(!existsSync(target.path), \"old path gone\");\n const index = readFileSync(archiveIndexPath(), \"utf-8\");\n assert(index.includes(\"Location history\") || index.includes(\"Recent moves\"), \"INDEX has history section\");\n assert(index.includes(moved.previous_paths![0]!), \"INDEX shows previous path\");\n}\n\n// ─── Kind folders for notes/csv helpers ───────────────────────────────\n\nsection(\"kind dirs\");\n{\n assert(getArchiveKindDir(\"notes\").endsWith(`${join(\"exports\", \"notes\")}`) || getArchiveKindDir(\"notes\").includes(\"/notes\"), \"notes kind dir\");\n assert(getArchiveKindDir(\"csv\").includes(\"/csv\") || getArchiveKindDir(\"csv\").includes(\"\\\\csv\"), \"csv kind dir\");\n assert(getArchiveKindDir(\"report\").includes(\"reports\"), \"reports kind dir\");\n}\n\n// ─── Path safety honors NTRP_HOME ─────────────────────────────────────\n\nsection(\"path-safety NTRP_HOME\");\n{\n const home = ntrpHome();\n assert(isInsideNtrp(join(home, \"exports\")), \"exports inside NTRP_HOME\");\n assert(!isInsideNtrp(\"/tmp/not-ntrp-exports\"), \"foreign path outside\");\n const expanded = resolveUserPath(\"~/Documents/test-inbox\");\n assert(expanded.includes(\"Documents\"), \"tilde expands\");\n}\n\n// ─── Context brief exports section ────────────────────────────────────\n\nsection(\"context brief exports blurb\");\n{\n const file: SessionFile = {\n id: \"sess-exports-test\",\n created_at: new Date().toISOString(),\n messages: [],\n stage: \"delivered\",\n analysis: defaultSessionAnalysis(),\n deliverables: [{ kind: \"prompt:deck\", at: new Date().toISOString(), path: \"/tmp/deck.md\" }],\n };\n const doc = buildSessionContextDoc(file);\n assert(doc.includes(\"## Exports\"), \"context doc has Exports section\");\n assert(doc.includes(\"## Deliverables\"), \"context doc has Deliverables\");\n assert(doc.includes(\"/tmp/deck.md\"), \"deliverable path shown\");\n assert(doc.includes(\"AI inbox\"), \"mentions AI inbox\");\n}\n\n// ─── Summary ──────────────────────────────────────────────────────────\n\nif (failures.length > 0) {\n console.error(\"\\nexports-registry smoke FAILED:\");\n for (const f of failures) console.error(` ✗ ${f}`);\n process.exit(1);\n}\nconsole.log(\"\\nexports-registry smoke OK\");\n","/**\n * Exports registry — durable catalog for handoffs and other deliverables.\n *\n * Canonical archive lives under export-dir (default ~/.ntrp/exports), organized\n * by kind. An optional ai-inbox-dir receives copies + stable latest-* pointers\n * so desktop AI apps (Claude Desktop, etc.) can find the newest handoff without\n * hunting timestamped filenames. Every write/move appends to manifest.jsonl;\n * INDEX.md is regenerated from that log.\n */\n\nimport {\n appendFileSync,\n copyFileSync,\n cpSync,\n existsSync,\n mkdirSync,\n readFileSync,\n readdirSync,\n renameSync,\n rmSync,\n statSync,\n writeFileSync,\n} from \"node:fs\";\nimport { basename, dirname, join, resolve, sep } from \"node:path\";\nimport { randomUUID } from \"node:crypto\";\nimport {\n deleteConfigValue,\n getConfigValue,\n getConfiguredAiInboxDir,\n getExportsDir,\n setConfigValue,\n} from \"../config/store.js\";\nimport { resolveUserPath } from \"../output/path-safety.js\";\n\nexport type ExportOp = \"write\" | \"move\" | \"inbox_sync\";\n\nexport interface ExportManifestEvent {\n id: string;\n op: ExportOp;\n at: string;\n kind: string;\n path: string;\n previous_paths?: string[];\n inbox_path?: string;\n session_id?: string;\n title?: string;\n}\n\nexport interface RecordExportWriteOpts {\n kind: string;\n path: string;\n sessionId?: string;\n title?: string;\n}\n\nexport interface ListExportsOpts {\n limit?: number;\n kind?: string;\n}\n\nconst KIND_DIRS = [\"handoffs\", \"reports\", \"notes\", \"csv\", \"publish\"] as const;\nconst INBOX_ARCHIVE_KEEP = 20;\n\n// ─── Kind helpers ─────────────────────────────────────────────────────\n\nexport function archiveSubdirForKind(kind: string): string {\n if (kind.startsWith(\"prompt:\")) return \"handoffs\";\n if (kind === \"report\") return \"reports\";\n if (kind === \"notes\") return \"notes\";\n if (kind === \"csv\") return \"csv\";\n if (kind === \"publish\") return \"publish\";\n return \"handoffs\";\n}\n\n/** Stable basename under exports/latest/ (no \"latest-\" prefix). */\nexport function latestBasenameForKind(kind: string): string {\n if (kind.startsWith(\"prompt:\")) {\n const target = kind.slice(\"prompt:\".length);\n return target ? `handoff-${target}.md` : \"handoff.md\";\n }\n if (kind === \"report\") return \"report.md\";\n if (kind === \"notes\") return \"notes.md\";\n if (kind === \"csv\") return \"csv\";\n if (kind === \"publish\") return \"publish\";\n return \"handoff.md\";\n}\n\n/** Stable filename in the AI inbox root. */\nexport function inboxLatestNameForKind(kind: string): string {\n if (kind.startsWith(\"prompt:\")) {\n const target = kind.slice(\"prompt:\".length);\n return target ? `latest-handoff-${target}.md` : \"latest-handoff.md\";\n }\n if (kind === \"report\") return \"latest-report.md\";\n if (kind === \"notes\") return \"latest-notes.md\";\n if (kind === \"csv\") return \"latest-csv\";\n if (kind === \"publish\") return \"latest-publish\";\n return \"latest-handoff.md\";\n}\n\nexport function exportStamp(d = new Date()): string {\n return d.toISOString().replace(/T/, \"-\").replace(/:/g, \"\").slice(0, 15);\n}\n\n// ─── Layout ───────────────────────────────────────────────────────────\n\nexport function ensureExportsLayout(root = getExportsDir()): string {\n mkdirSync(root, { recursive: true });\n mkdirSync(join(root, \"latest\"), { recursive: true });\n for (const sub of KIND_DIRS) {\n mkdirSync(join(root, sub), { recursive: true });\n }\n const readme = join(root, \"README.md\");\n if (!existsSync(readme)) {\n writeFileSync(readme, ARCHIVE_README, \"utf-8\");\n }\n if (!existsSync(join(root, \"INDEX.md\"))) {\n writeFileSync(join(root, \"INDEX.md\"), \"# NTRP exports\\n\\n_No exports yet._\\n\", \"utf-8\");\n }\n if (!existsSync(join(root, \"manifest.jsonl\"))) {\n writeFileSync(join(root, \"manifest.jsonl\"), \"\", \"utf-8\");\n }\n return root;\n}\n\nexport function getArchiveKindDir(kind: string): string {\n const root = ensureExportsLayout();\n const dir = join(root, archiveSubdirForKind(kind));\n mkdirSync(dir, { recursive: true });\n return dir;\n}\n\nexport function resolveArchivePath(kind: string, filename: string): string {\n return join(getArchiveKindDir(kind), filename);\n}\n\n// ─── AI inbox config ──────────────────────────────────────────────────\n\nexport function getAiInboxDir(): string | null {\n return getConfiguredAiInboxDir();\n}\n\nexport function setAiInboxDir(path: string): string {\n const resolved = resolveUserPath(path);\n mkdirSync(resolved, { recursive: true });\n setConfigValue(\"ai-inbox-dir\", resolved);\n ensureInboxLayout(resolved);\n return resolved;\n}\n\nexport function clearAiInboxDir(): void {\n deleteConfigValue(\"ai-inbox-dir\");\n}\n\nfunction ensureInboxLayout(inbox: string): void {\n mkdirSync(inbox, { recursive: true });\n mkdirSync(join(inbox, \"archive\"), { recursive: true });\n const readme = join(inbox, \"README.md\");\n writeFileSync(readme, buildInboxReadme(), \"utf-8\");\n if (!existsSync(join(inbox, \"INDEX.md\"))) {\n writeFileSync(join(inbox, \"INDEX.md\"), \"# NTRP AI inbox\\n\\n_No exports synced yet._\\n\", \"utf-8\");\n }\n}\n\n// ─── Manifest I/O ─────────────────────────────────────────────────────\n\nfunction manifestPath(root = getExportsDir()): string {\n return join(root, \"manifest.jsonl\");\n}\n\nexport function readManifestEvents(root = getExportsDir()): ExportManifestEvent[] {\n const path = manifestPath(root);\n if (!existsSync(path)) return [];\n const text = readFileSync(path, \"utf-8\");\n const events: ExportManifestEvent[] = [];\n for (const line of text.split(\"\\n\")) {\n const trimmed = line.trim();\n if (!trimmed) continue;\n try {\n events.push(JSON.parse(trimmed) as ExportManifestEvent);\n } catch {\n // skip corrupt lines\n }\n }\n return events;\n}\n\nfunction appendManifestEvent(event: ExportManifestEvent, root = getExportsDir()): void {\n ensureExportsLayout(root);\n appendFileSync(manifestPath(root), JSON.stringify(event) + \"\\n\", \"utf-8\");\n}\n\n/** Latest write/move event per current path (most recent op wins). */\nexport function listExports(opts: ListExportsOpts = {}): ExportManifestEvent[] {\n const limit = opts.limit ?? 20;\n const events = readManifestEvents();\n const byId = new Map<string, ExportManifestEvent>();\n // Replay in order so later move/write updates replace earlier state for same id\n for (const e of events) {\n if (e.op === \"inbox_sync\") continue;\n byId.set(e.id, e);\n }\n let items = [...byId.values()].sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));\n if (opts.kind) {\n const k = opts.kind.toLowerCase();\n items = items.filter((e) => e.kind === opts.kind || e.kind.startsWith(k) || e.kind.includes(k));\n }\n return items.slice(0, limit);\n}\n\nfunction findExportByIdOrName(idOrPath: string): ExportManifestEvent | null {\n const items = listExports({ limit: 500 });\n const needle = idOrPath.trim();\n const byId = items.find((e) => e.id === needle || e.id.startsWith(needle));\n if (byId) return byId;\n const base = basename(needle);\n const byName = items.find((e) => basename(e.path) === base || e.path.endsWith(needle));\n if (byName) return byName;\n // Also accept absolute path match against current path\n const resolved = resolveUserPath(needle);\n return items.find((e) => e.path === resolved) ?? null;\n}\n\n// ─── Latest pointers (archive) ────────────────────────────────────────\n\nfunction updateArchiveLatest(kind: string, sourcePath: string, root: string): void {\n const latestDir = join(root, \"latest\");\n mkdirSync(latestDir, { recursive: true });\n const name = latestBasenameForKind(kind);\n const dest = join(latestDir, name);\n copyPath(sourcePath, dest);\n\n // Any prompt:* also refreshes the generic latest/handoff.md\n if (kind.startsWith(\"prompt:\")) {\n copyPath(sourcePath, join(latestDir, \"handoff.md\"));\n }\n}\n\nfunction copyPath(src: string, dest: string): void {\n mkdirSync(dirname(dest), { recursive: true });\n if (existsSync(dest)) {\n rmSync(dest, { recursive: true, force: true });\n }\n const st = statSync(src);\n if (st.isDirectory()) {\n cpSync(src, dest, { recursive: true });\n } else {\n copyFileSync(src, dest);\n }\n}\n\n// ─── INDEX regeneration ───────────────────────────────────────────────\n\nexport function regenerateIndex(root = getExportsDir()): void {\n ensureExportsLayout(root);\n const items = listExports({ limit: 50 });\n const events = readManifestEvents(root);\n const withHistory = items.filter((e) => (e.previous_paths?.length ?? 0) > 0);\n\n const latestDir = join(root, \"latest\");\n const latestLines: string[] = [];\n if (existsSync(latestDir)) {\n for (const name of readdirSync(latestDir).sort()) {\n latestLines.push(`- \\`latest/${name}\\` → \\`${join(latestDir, name)}\\``);\n }\n }\n\n const lines: string[] = [\n \"# NTRP exports\",\n \"\",\n `Archive root: \\`${root}\\``,\n \"\",\n \"Desktop AI tip: set an inbox with `/inbox set <folder>` and open that folder's `latest-handoff.md` or `INDEX.md`.\",\n \"\",\n \"## Latest pointers\",\n \"\",\n ];\n if (latestLines.length > 0) lines.push(...latestLines);\n else lines.push(\"_None yet._\");\n lines.push(\"\", \"## Recent exports\", \"\");\n\n if (items.length === 0) {\n lines.push(\"_No exports yet._\");\n } else {\n for (const e of items) {\n const title = e.title ? ` — ${e.title}` : \"\";\n const session = e.session_id ? ` · session ${e.session_id.slice(-4)}` : \"\";\n lines.push(`- **${e.kind}** (${e.at})${title}${session}`);\n lines.push(` - id: \\`${e.id}\\``);\n lines.push(` - path: \\`${e.path}\\``);\n if (e.inbox_path) lines.push(` - inbox: \\`${e.inbox_path}\\``);\n }\n }\n\n lines.push(\"\", \"## Location history\", \"\");\n if (withHistory.length === 0) {\n lines.push(\"_No moves recorded._\");\n } else {\n for (const e of withHistory) {\n lines.push(`- **${e.kind}** \\`${e.id}\\``);\n for (const prev of e.previous_paths ?? []) {\n lines.push(` - was: \\`${prev}\\``);\n }\n lines.push(` - now: \\`${e.path}\\``);\n }\n }\n\n // Also surface raw move ops from the event log (in case id was re-written)\n const moveOps = events.filter((e) => e.op === \"move\").slice(-20).reverse();\n if (moveOps.length > 0) {\n lines.push(\"\", \"## Recent moves\", \"\");\n for (const e of moveOps) {\n const from = e.previous_paths?.[e.previous_paths.length - 1] ?? \"?\";\n lines.push(`- ${e.at}: \\`${from}\\` → \\`${e.path}\\` (${e.kind}, \\`${e.id}\\`)`);\n }\n }\n\n lines.push(\"\");\n writeFileSync(join(root, \"INDEX.md\"), lines.join(\"\\n\"), \"utf-8\");\n}\n\nfunction regenerateInboxIndex(inbox: string): void {\n ensureInboxLayout(inbox);\n const items = listExports({ limit: 15 });\n const archiveRoot = getExportsDir();\n const lines: string[] = [\n \"# NTRP AI inbox\",\n \"\",\n \"Start here. Prefer `latest-handoff.md` (or `latest-handoff-<target>.md`) for the newest agent prompt.\",\n \"\",\n `Canonical archive: \\`${archiveRoot}\\` (see \\`${join(archiveRoot, \"INDEX.md\")}\\`).`,\n \"\",\n \"## Latest pointers\",\n \"\",\n ];\n const latestNames = readdirSync(inbox)\n .filter((n) => n.startsWith(\"latest-\"))\n .sort();\n if (latestNames.length === 0) lines.push(\"_None yet — run a handoff after `/inbox set`._\");\n else {\n for (const name of latestNames) {\n lines.push(`- [\\`${name}\\`](./${name})`);\n }\n }\n lines.push(\"\", \"## Recent exports\", \"\");\n if (items.length === 0) lines.push(\"_No exports yet._\");\n else {\n for (const e of items) {\n lines.push(`- **${e.kind}** (${e.at}): \\`${e.path}\\``);\n if (e.inbox_path) lines.push(` - inbox copy: \\`${e.inbox_path}\\``);\n }\n }\n lines.push(\"\");\n writeFileSync(join(inbox, \"INDEX.md\"), lines.join(\"\\n\"), \"utf-8\");\n writeFileSync(join(inbox, \"README.md\"), buildInboxReadme(), \"utf-8\");\n}\n\nfunction buildInboxReadme(): string {\n const archive = getExportsDir();\n return `# NTRP AI inbox\n\nThis folder is the Claude Desktop / desktop-AI landing zone for NTRP handoffs.\n\n## Start here\n\n1. Open \\`INDEX.md\\` for the catalog\n2. Or open \\`latest-handoff.md\\` (or \\`latest-handoff-deck.md\\`, etc.) for the newest prompt\n\nStable \\`latest-*\\` files are overwritten on every export. Dated copies live in \\`archive/\\`.\n\n## Canonical archive\n\nThe full history (with move trail) lives at:\n\n\\`${archive}\\`\n\nSee \\`${join(archive, \"INDEX.md\")}\\` and \\`${join(archive, \"manifest.jsonl\")}\\`.\n\nConfigure with \\`/inbox set <path>\\` · clear with \\`/inbox clear\\` · list with \\`/exports\\`.\n`;\n}\n\nconst ARCHIVE_README = `# NTRP exports archive\n\nHandoffs, reports, notes, CSV receipts, and publish packages land here by kind:\n\n- \\`handoffs/\\` — agent prompts (\\`handoff-deck-*.md\\`, …)\n- \\`reports/\\` — markdown reports\n- \\`notes/\\` — Obsidian-style notes\n- \\`csv/\\` — backmeup receipt folders\n- \\`publish/\\` — repository export packages\n- \\`latest/\\` — stable copies of the newest file per kind\n\n\\`INDEX.md\\` is regenerated from \\`manifest.jsonl\\` on every write/move.\n\nPoint a desktop AI app at a dedicated inbox instead of this folder:\n\n\\`\\`\\`\n/inbox set ~/Documents/Claude/ntrp-inbox\n\\`\\`\\`\n`;\n\n// ─── Inbox sync ───────────────────────────────────────────────────────\n\nfunction pruneInboxArchive(archiveDir: string, keep = INBOX_ARCHIVE_KEEP): void {\n if (!existsSync(archiveDir)) return;\n const entries = readdirSync(archiveDir)\n .map((name) => {\n const p = join(archiveDir, name);\n try {\n return { name, path: p, mtime: statSync(p).mtimeMs };\n } catch {\n return null;\n }\n })\n .filter((e): e is { name: string; path: string; mtime: number } => e != null)\n .sort((a, b) => b.mtime - a.mtime);\n for (const old of entries.slice(keep)) {\n rmSync(old.path, { recursive: true, force: true });\n }\n}\n\n/** Copy into AI inbox; returns the stable latest-* path, or null if no inbox. */\nexport function syncAiInbox(entry: Pick<ExportManifestEvent, \"kind\" | \"path\">): string | null {\n const inbox = getAiInboxDir();\n if (!inbox) return null;\n if (!existsSync(entry.path)) return null;\n\n ensureInboxLayout(inbox);\n const archiveDir = join(inbox, \"archive\");\n mkdirSync(archiveDir, { recursive: true });\n\n const base = basename(entry.path);\n const archiveDest = join(archiveDir, base);\n copyPath(entry.path, archiveDest);\n pruneInboxArchive(archiveDir);\n\n const latestName = inboxLatestNameForKind(entry.kind);\n const latestDest = join(inbox, latestName);\n copyPath(entry.path, latestDest);\n\n if (entry.kind.startsWith(\"prompt:\")) {\n copyPath(entry.path, join(inbox, \"latest-handoff.md\"));\n }\n\n regenerateInboxIndex(inbox);\n return latestDest;\n}\n\n/** After `/inbox set`, copy recent exports into the new inbox. */\nexport function syncRecentToInbox(limit = 10): number {\n const inbox = getAiInboxDir();\n if (!inbox) return 0;\n ensureInboxLayout(inbox);\n const items = listExports({ limit });\n let n = 0;\n for (const item of items) {\n if (!existsSync(item.path)) continue;\n const inboxPath = syncAiInbox(item);\n if (inboxPath) {\n appendManifestEvent({\n ...item,\n op: \"inbox_sync\",\n at: new Date().toISOString(),\n inbox_path: inboxPath,\n });\n n++;\n }\n }\n regenerateInboxIndex(inbox);\n regenerateIndex();\n return n;\n}\n\n// ─── Public write / move ──────────────────────────────────────────────\n\nexport function recordExportWrite(opts: RecordExportWriteOpts): ExportManifestEvent {\n const root = ensureExportsLayout();\n const path = resolve(opts.path);\n if (!existsSync(path)) {\n throw new Error(`Export path does not exist: ${path}`);\n }\n\n updateArchiveLatest(opts.kind, path, root);\n const inboxPath = syncAiInbox({ kind: opts.kind, path });\n\n const event: ExportManifestEvent = {\n id: randomUUID().slice(0, 8),\n op: \"write\",\n at: new Date().toISOString(),\n kind: opts.kind,\n path,\n session_id: opts.sessionId,\n title: opts.title,\n inbox_path: inboxPath ?? undefined,\n };\n appendManifestEvent(event, root);\n regenerateIndex(root);\n return event;\n}\n\nexport function moveExport(idOrPath: string, destDir: string): ExportManifestEvent {\n const item = findExportByIdOrName(idOrPath);\n if (!item) {\n throw new Error(`No export matching \"${idOrPath}\". Try /exports list.`);\n }\n if (!existsSync(item.path)) {\n throw new Error(`Export file missing on disk: ${item.path}`);\n }\n\n const destRoot = resolveUserPath(destDir);\n mkdirSync(destRoot, { recursive: true });\n const name = basename(item.path);\n let destPath = join(destRoot, name);\n if (existsSync(destPath)) {\n destPath = join(destRoot, `${exportStamp()}-${name}`);\n }\n\n renameSync(item.path, destPath);\n\n const previous = [...(item.previous_paths ?? []), item.path];\n updateArchiveLatest(item.kind, destPath, ensureExportsLayout());\n const inboxPath = syncAiInbox({ kind: item.kind, path: destPath });\n\n const event: ExportManifestEvent = {\n id: item.id,\n op: \"move\",\n at: new Date().toISOString(),\n kind: item.kind,\n path: destPath,\n previous_paths: previous,\n session_id: item.session_id,\n title: item.title,\n inbox_path: inboxPath ?? undefined,\n };\n appendManifestEvent(event);\n regenerateIndex();\n return event;\n}\n\n// ─── UX helpers ───────────────────────────────────────────────────────\n\nexport function archiveIndexPath(): string {\n return join(ensureExportsLayout(), \"INDEX.md\");\n}\n\nexport function archiveLatestHandoffPath(): string | null {\n const p = join(ensureExportsLayout(), \"latest\", \"handoff.md\");\n return existsSync(p) ? p : null;\n}\n\nexport function inboxLatestHandoffPath(): string | null {\n const inbox = getAiInboxDir();\n if (!inbox) return null;\n const p = join(inbox, \"latest-handoff.md\");\n return existsSync(p) ? p : null;\n}\n\n/** One-time dim nudge after a prompt handoff when inbox is unset. */\nexport function maybePrintAiInboxNudge(print: (line: string) => void): void {\n if (getAiInboxDir()) return;\n if (getConfigValue(\"ai-inbox-nudge-seen\") === \"true\") return;\n setConfigValue(\"ai-inbox-nudge-seen\", \"true\");\n print(\"Point Claude at a folder: /inbox set ~/Documents/Claude/ntrp-inbox\");\n}\n\nexport function formatExportLocationLines(event: ExportManifestEvent): string[] {\n const lines = [`Archive: ${event.path}`];\n if (event.inbox_path) {\n lines.push(`Claude can open: ${event.inbox_path}`);\n }\n return lines;\n}\n\n/** Whether a path is inside the configured export archive (or NTRP home exports). */\nexport function isUnderExportsDir(path: string): boolean {\n const root = resolve(getExportsDir());\n const resolved = resolve(path);\n return resolved === root || resolved.startsWith(root + sep);\n}\n","import { homedir } from \"node:os\";\nimport { resolve, sep } from \"node:path\";\nimport { ntrpHome } from \"../config/store.js\";\n\n/** Resolved NTRP home at module load (honors NTRP_HOME). Prefer ntrpHome() for new code. */\nexport const NTRP_HOME = ntrpHome();\n\nexport function resolveUserPath(path: string): string {\n if (path === \"~\" || path.startsWith(\"~/\") || path.startsWith(\"~\\\\\")) {\n return resolve(homedir(), path.slice(2));\n }\n return resolve(path);\n}\n\nexport function isInsideNtrp(path: string): boolean {\n const home = ntrpHome();\n const resolved = resolve(path);\n return resolved === home || resolved.startsWith(home + sep);\n}\n","/**\n * Session context brief — a human/agent-readable markdown summary written to\n * ~/.ntrp/sessions/<id>.context.md alongside the session JSON and the raw\n * transcript.\n *\n * Purpose: triage and pickup. The JSON is for the program, the transcript is\n * the full terminal record, and this brief is the 1-page \"what happened here\"\n * an operator or coding agent reads first: dataset, scope, what was computed\n * (scores + dollars), what was asked, what was delivered, and how to resume.\n *\n * Deterministic — no LLM required. Regenerated on every session checkpoint\n * (recordMessage / saveSessionState / finalize), so it always reflects the\n * latest state.\n */\n\nimport { writeFileSync } from \"node:fs\";\nimport type { Context, SessionFile } from \"../cli/context.js\";\nimport {\n buildSessionFileSnapshot,\n contextDocPathForSession,\n datasetPathForSession,\n getSessionsDir,\n transcriptPathForSession,\n} from \"../cli/context.js\";\nimport type { FullComputeResult } from \"../vitals/health-score.js\";\nimport { VITAL_SIGN_LABELS, formatCurrency } from \"../output/formatters.js\";\nimport { getExportsDir } from \"../config/store.js\";\nimport { archiveIndexPath, getAiInboxDir } from \"./exports-registry.js\";\nimport { redactSecrets } from \"./terminal-capture.js\";\n\nconst AGENT_EXCERPT_CHARS = 400;\n\n// ============================================================\n// Builder\n// ============================================================\n\nexport function buildSessionContextDoc(\n file: SessionFile,\n opts: { snapshot?: FullComputeResult | null } = {},\n): string {\n const id = file.id;\n const shortId = id.slice(-4);\n const exchanges = file.exchange_count ?? Math.floor(file.messages.length / 2);\n const lines: string[] = [];\n\n lines.push(`# Session context — ${id}${file.name ? ` (${file.name})` : \"\"}`);\n lines.push(\"\");\n\n // Status\n lines.push(\"## Status\");\n lines.push(\"\");\n lines.push(`- Stage: ${file.stage ?? \"new\"}`);\n lines.push(`- Created: ${file.created_at}`);\n if (file.ended_at) lines.push(`- Ended: ${file.ended_at}`);\n lines.push(`- Updated: ${new Date().toISOString()}`);\n lines.push(`- Exchanges: ${exchanges}`);\n if (file.summary) lines.push(`- Summary: ${file.summary}`);\n if (file.resumed_from) lines.push(`- Resumed from: ${file.resumed_from}`);\n lines.push(\"\");\n\n // Dataset\n lines.push(\"## Dataset\");\n lines.push(\"\");\n if (file.dataset?.label || file.dataset?.source) {\n lines.push(`- Label: ${file.dataset.label ?? \"(unlabeled)\"}`);\n if (file.dataset.source) lines.push(`- Source: ${file.dataset.source}`);\n if (file.dataset.ingested_at) lines.push(`- Ingested: ${file.dataset.ingested_at}`);\n const counts = Object.entries(file.dataset.counts ?? {}).filter(([, n]) => n > 0);\n if (counts.length > 0) {\n lines.push(`- Counts: ${counts.map(([k, n]) => `${n.toLocaleString()} ${k}`).join(\", \")}`);\n }\n } else {\n lines.push(\"- No data loaded.\");\n }\n if (file.attachments && file.attachments.length > 0) {\n for (const a of file.attachments) {\n const detail = [a.entity_type, a.row_count != null ? `${a.row_count} rows` : null]\n .filter(Boolean)\n .join(\", \");\n lines.push(`- Attachment: ${a.path}${detail ? ` (${detail})` : \"\"}`);\n }\n }\n lines.push(\"\");\n\n // Scope\n if (file.scope) {\n lines.push(\"## Scope\");\n lines.push(\"\");\n lines.push(`- Intent: ${file.scope.intent_summary}`);\n lines.push(`- Lens: ${file.scope.primary_lens}`);\n if (file.scope.audience) lines.push(`- Audience: ${file.scope.audience}`);\n if (file.scope.time_horizon) lines.push(`- Time horizon: ${file.scope.time_horizon}`);\n if (file.scope.segments?.length) lines.push(`- Segments: ${file.scope.segments.join(\", \")}`);\n if (file.scope.confirmed_at) lines.push(`- Confirmed: ${file.scope.confirmed_at}`);\n lines.push(\"\");\n }\n\n // Analysis\n lines.push(\"## Analysis\");\n lines.push(\"\");\n if (file.analysis) {\n lines.push(`- Primary lens: ${file.analysis.primary}`);\n lines.push(`- Completed: ${file.analysis.completed.join(\", \") || \"none\"}`);\n if (file.analysis.coverage) {\n lines.push(\n `- Coverage: ${file.analysis.coverage.distinct_months} months · recommended cadence ${file.analysis.coverage.recommended_cadence}`,\n );\n }\n if (file.analysis.data_source_type) {\n lines.push(`- Data source type: ${file.analysis.data_source_type}`);\n }\n if (file.analysis.headline?.length) {\n lines.push(\"\");\n lines.push(\"### Headline metrics\");\n lines.push(\"\");\n for (const h of file.analysis.headline) {\n lines.push(`- ${h.label}: ${h.formatted}`);\n }\n }\n } else {\n lines.push(\"- No analysis recorded.\");\n }\n\n const health = opts.snapshot?.aggregate;\n if (health) {\n lines.push(\"\");\n lines.push(\"### GTM health snapshot\");\n lines.push(\"\");\n lines.push(`- Overall: ${Math.round(health.overall_score)} (${health.overall_status})`);\n lines.push(`- Gating vital sign: ${health.gating_vital_sign.replace(/_/g, \" \")}`);\n if (health.total_value_at_risk != null && health.total_value_at_risk > 0) {\n lines.push(`- Total value at risk: ${formatCurrency(health.total_value_at_risk)}`);\n }\n for (const vs of health.vital_signs) {\n const label = VITAL_SIGN_LABELS[vs.vital_sign] ?? vs.vital_sign;\n const dollars =\n vs.dollar_value != null\n ? ` — ${formatCurrency(vs.dollar_value)}${vs.dollar_label ? ` ${vs.dollar_label}` : \"\"}`\n : \"\";\n lines.push(`- ${label}: ${Math.round(vs.score)} (${vs.status})${dollars}`);\n }\n }\n lines.push(\"\");\n\n // Strategist\n if (file.strategist) {\n lines.push(\"## Strategist (in flight)\");\n lines.push(\"\");\n lines.push(`- Step: ${file.strategist.step}`);\n if (file.strategist.objective) lines.push(`- Objective: ${file.strategist.objective}`);\n if (file.strategist.constraintsNote) {\n lines.push(`- Constraints: ${file.strategist.constraintsNote}`);\n }\n if (file.strategist.origin) lines.push(`- Origin: ${file.strategist.origin}`);\n lines.push(\"\");\n }\n\n // Deliverables\n lines.push(\"## Deliverables\");\n lines.push(\"\");\n if (file.deliverables && file.deliverables.length > 0) {\n for (const d of file.deliverables) {\n const detail = [d.path, d.note].filter(Boolean).join(\" — \");\n lines.push(`- ${d.kind} (${d.at})${detail ? `: ${detail}` : \"\"}`);\n }\n } else {\n lines.push(\"- None yet.\");\n }\n lines.push(\"\");\n\n // Exports catalog\n lines.push(\"## Exports\");\n lines.push(\"\");\n try {\n lines.push(`- Archive index: \\`${archiveIndexPath()}\\``);\n lines.push(`- Archive root: \\`${getExportsDir()}\\``);\n const inbox = getAiInboxDir();\n if (inbox) {\n lines.push(`- AI inbox: \\`${inbox}\\` (open \\`latest-handoff.md\\` or \\`INDEX.md\\`)`);\n } else {\n lines.push(\"- AI inbox: unset — `/inbox set <folder>` for Claude Desktop\");\n }\n } catch {\n lines.push(\"- Export catalog unavailable.\");\n }\n lines.push(\"\");\n\n // Conversation\n lines.push(`## Conversation (${exchanges} exchange${exchanges === 1 ? \"\" : \"s\"})`);\n lines.push(\"\");\n if (file.messages.length === 0) {\n lines.push(\"- No exchanges yet.\");\n } else {\n let n = 0;\n for (const msg of file.messages) {\n if (msg.role === \"user\") {\n n++;\n lines.push(`${n}. ❯ ${excerpt(msg.content, AGENT_EXCERPT_CHARS)}`);\n } else {\n lines.push(` ↳ ${excerpt(msg.content, AGENT_EXCERPT_CHARS)}`);\n }\n }\n }\n lines.push(\"\");\n\n // Files + pickup\n lines.push(\"## Files\");\n lines.push(\"\");\n lines.push(`- Transcript (raw terminal): \\`${transcriptPathForSession(id)}\\``);\n lines.push(`- Session data (JSON): \\`${sessionJsonPath(id)}\\``);\n lines.push(`- Dataset (DuckDB): \\`${datasetPathForSession(id)}\\``);\n lines.push(\"\");\n lines.push(\"## Pick up this session\");\n lines.push(\"\");\n lines.push(`Run \\`ntrp\\`, then \\`/session ${shortId}\\` — rebinds the dataset and reloads the`);\n lines.push(\"conversation thread in place. Read the transcript above for the full terminal\");\n lines.push(\"history before continuing.\");\n lines.push(\"\");\n\n return lines.map(redactSecrets).join(\"\\n\");\n}\n\nfunction excerpt(content: string, max: number): string {\n const flat = content.replace(/\\s+/g, \" \").trim();\n return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;\n}\n\nfunction sessionJsonPath(id: string): string {\n return `${getSessionsDir()}/${id}.json`;\n}\n\n// ============================================================\n// Writers (best-effort — never break the session over doc IO)\n// ============================================================\n\n/** Write the context brief for the live context. Skipped in one-shot mode. */\nexport function writeSessionContextDoc(ctx: Context): void {\n if (ctx.oneShot) return;\n try {\n const file = buildSessionFileSnapshot(ctx);\n const doc = buildSessionContextDoc(file, { snapshot: ctx.snapshot.computeResult });\n writeFileSync(contextDocPathForSession(ctx.sessionId), doc);\n } catch {\n // best-effort\n }\n}\n\n/** Write the context brief from an already-built session file (close paths). */\nexport function writeContextDocForSessionFile(\n file: SessionFile,\n opts: { snapshot?: FullComputeResult | null } = {},\n): void {\n try {\n writeFileSync(contextDocPathForSession(file.id), buildSessionContextDoc(file, opts));\n } catch {\n // best-effort\n }\n}\n","/**\n * Shared execution context passed into every handler + the REPL.\n *\n * Caches:\n * - session ID + session file path (for Last Activity persistence)\n * - lazily-computed FullComputeResult (reused across NL questions)\n * - current config snapshot\n */\n\nimport { basename, join, resolve, sep } from \"node:path\";\nimport { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, statSync, rmSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { randomUUID } from \"node:crypto\";\nimport type { Interface as ReadlineInterface } from \"node:readline/promises\";\nimport type { LlmMessage } from \"../ai/llm/types.js\";\nimport { normalizeThread } from \"../ai/llm/thread-compat.js\";\nimport type { FullComputeResult } from \"../vitals/health-score.js\";\nimport type { Divergence } from \"../pipeline/divergence.js\";\nimport { buildExecutionOptions } from \"../io/context.js\";\nimport type { ExecutionOptions } from \"../io/types.js\";\nimport type { AnalysisLens, LlmSessionOverride, SessionAnalysis } from \"../types.js\";\nimport type { AnalysisScope, ChatAttachment, GapAuditResult } from \"../conversation/types.js\";\nimport { writeSessionContextDoc, writeContextDocForSessionFile } from \"../services/context-doc.js\";\nimport { rebindSessionTranscript, discardSessionTranscript } from \"../services/transcript.js\";\n\n// ============================================================\n// Types\n// ============================================================\n\nexport interface SessionMessage {\n role: \"user\" | \"agent\";\n content: string;\n at: string;\n}\n\n/**\n * Lifecycle of a point-in-time analysis:\n * new — session created, no data loaded / not yet diagnosed\n * analyzed — data loaded and diagnosed; ready for questions\n * delivered — an output / action was produced (the session reached action)\n * ended — user explicitly closed without producing an output (/end)\n * \"Unfinished\" work = stage \"analyzed\" (reached insight, never shipped).\n */\nexport type SessionStage = \"new\" | \"analyzed\" | \"delivered\" | \"ended\";\n\n/** What data a session is anchored to — the heart of the point-in-time model. */\nexport interface DatasetMeta {\n /** Human label, e.g. \"Acme Q2 export\" or \"hidden_crisis demo\". */\n label?: string;\n /** Where the data came from: a file path, \"demo:<scenario>\", etc. */\n source?: string;\n /** Entity counts captured at ingest time. */\n counts?: Record<string, number>;\n /** When the data was loaded. */\n ingested_at?: string;\n}\n\n/** A produced output / action taken from the analysis. */\nexport interface Deliverable {\n kind: string;\n at: string;\n path?: string;\n note?: string;\n}\n\n/** Multi-turn strategist flow state — drives the strategize conversation phase. */\nexport interface StrategistFlowState {\n /**\n * awaiting_analysis — strategist requested pre-analysis; auto-resumes after compute\n * awaiting_connect — keyless skeleton shown; resume objective confirm after /connect\n * objective_confirm — objective card printed, awaiting yes/adjust\n * objective_input — waiting for the user to state the objective in their words\n */\n step: \"awaiting_analysis\" | \"awaiting_connect\" | \"objective_confirm\" | \"objective_input\";\n /** Candidate objective (user's seed text or proposed from the gating vital sign). */\n objective?: string;\n /** Operator-stated constraints captured inline (capacity, deadlines). */\n constraintsNote?: string;\n /** Which door the session came through. */\n origin?: \"command\" | \"nl\" | \"ai\";\n}\n\n/** Queued NL question carried through scope/data/compute/connect gates. */\nexport interface PendingAskState {\n text: string;\n queued_at: string;\n origin: \"orient\" | \"explore\" | \"post_connect\";\n keylessAnswered?: boolean;\n}\n\nexport interface SessionFile {\n id: string;\n created_at: string;\n messages: SessionMessage[];\n ended_at?: string;\n exchange_count?: number;\n summary?: string;\n resumed_from?: string;\n name?: string;\n /** Lifecycle stage of this point-in-time analysis. */\n stage?: SessionStage;\n /** The dataset this session is anchored to. */\n dataset?: DatasetMeta;\n /** Outputs / actions produced from this analysis. */\n deliverables?: Deliverable[];\n /**\n * Compacted Anthropic message thread (text-only Q&A) for true cross-session\n * continuity. Re-seeded into the agent on resume/switch so it remembers the\n * actual prior exchanges, not just an 80-char summary.\n */\n thread?: LlmMessage[];\n /** Primary and completed analysis lenses for this session. */\n analysis?: SessionAnalysis;\n /** Conversation-first analysis scope. */\n scope?: AnalysisScope;\n /** Files ingested via chat. */\n attachments?: ChatAttachment[];\n /** Session-scoped LLM engine overrides (provider, tier, model). */\n llm?: LlmSessionOverride;\n /** In-flight strategist flow (resumes across REPL restarts). */\n strategist?: StrategistFlowState;\n /** Queued NL ask carried through setup gates (carry-the-question). */\n pending_ask?: PendingAskState;\n}\n\nexport interface SessionListEntry {\n id: string;\n created_at: string;\n ended_at?: string;\n exchange_count: number;\n summary?: string;\n name?: string;\n stage?: SessionStage;\n dataset?: DatasetMeta;\n deliverables?: Deliverable[];\n analysis?: SessionAnalysis;\n scope?: AnalysisScope;\n mtime: number;\n}\n\n/** In-progress sessions untouched this long group under \"Stale\" in lists. */\nexport const STALE_SESSION_MS = 14 * 24 * 60 * 60 * 1000;\n\n/** True when the session file hasn't been touched in STALE_SESSION_MS. */\nexport function isSessionStale(s: SessionListEntry): boolean {\n return Date.now() - s.mtime > STALE_SESSION_MS;\n}\n\nexport interface Context {\n /** Unique session ID — new one per REPL launch. */\n sessionId: string;\n /** Absolute path to the session file on disk. */\n sessionFile: string;\n /** True when running a single command and exiting. */\n oneShot: boolean;\n /** Output and process behavior for terminal, JSON, and agent use. */\n execution: ExecutionOptions;\n /** Lazily-computed health snapshot. Populated on first NL question. */\n snapshot: {\n computeResult: FullComputeResult | null;\n divergences: Divergence[];\n };\n /** In-memory session message log. Persisted to disk after each exchange. */\n messages: SessionMessage[];\n /**\n * Compacted cross-turn conversation thread (text-only Q&A) fed back into the\n * agent on every turn so it has continuity and never answers from a blank\n * slate. Persisted to the session file and rehydrated on resume/switch.\n */\n conversation: LlmMessage[];\n /**\n * When running inside the REPL, the REPL's readline interface is stored\n * here so interactive commands (wizards, confirms) can reuse it instead\n * of opening a second interface on stdin — two interfaces on the same\n * TTY produces double-echo keystrokes. Undefined in one-shot mode and\n * during first-run onboarding (before the REPL has started).\n */\n rl?: ReadlineInterface;\n /** Summary loaded from a resumed session. */\n resumedSessionSummary?: string;\n /** Session ID that was resumed. */\n resumedFromId?: string;\n /** Human-readable session name set via /name or /switch. */\n sessionName?: string;\n /** Absolute path of this session's dataset DB file (interactive REPL only). */\n datasetPath?: string;\n /** Lifecycle stage of the current point-in-time analysis. */\n stage: SessionStage;\n /** The dataset the current session is anchored to. */\n dataset?: DatasetMeta;\n /** Outputs / actions produced from the current analysis. */\n deliverables: Deliverable[];\n /** The most recent NL question + answer, for lightweight /rate feedback. */\n lastExchange?: { question: string; answer: string };\n /** Primary and completed analysis lenses. */\n analysis: SessionAnalysis;\n /** Active interactive wizard depth (REPL readline shared with prompts). */\n wizardDepth: number;\n /** True while masked secret entry owns stdin — REPL must not echo keypresses. */\n secretInputActive?: boolean;\n /** Conversation-first scope for this analysis. */\n scope?: AnalysisScope;\n /** Files ingested through chat. */\n attachments?: ChatAttachment[];\n /** Cached data gap audit (invalidated on ingest). */\n gapAudit?: GapAuditResult;\n /** User signaled deliverable intent — drives deliver phase. */\n deliverIntent?: boolean;\n /** Transient flag while formula compute runs. */\n computeInProgress?: boolean;\n /** Session-scoped LLM engine overrides — cleared on /new, persisted on resume. */\n llm?: LlmSessionOverride;\n /** In-flight strategist flow — drives the strategize phase. */\n strategistState?: StrategistFlowState;\n /** Queued NL ask — auto-resumes after compute / connect. */\n pendingAsk?: PendingAskState;\n /**\n * Interactive line blocked by a missing/expired license — replayed once\n * after /activate or /upgrade succeeds (same REPL process only).\n */\n pendingBlockedLine?: string;\n /** True once the interactive REPL loop has started (false during first-run onboard). */\n replStarted?: boolean;\n /** True after the welcome ASCII logo has painted this session — /home skips it. */\n welcomeLogoShown?: boolean;\n /** Background update check when cache is stale (REPL startup). */\n pendingUpdateCheck?: Promise<import(\"../update/registry.js\").UpdateCheckResult | null>;\n /** Transient — conversation compute credits gap_compute instead of full diagnose. */\n skipTimeBankDiagnoseCredit?: boolean;\n /** Transient — conversation compute owns the turn's closing output (carried-question answer). */\n suppressCompanionFooter?: boolean;\n}\n\n/** True when a report has been produced and the user can ask questions. */\nexport function isAnalysisReady(ctx: Context): boolean {\n // \"delivered\" keeps post-handoff explore alive — analysis is still ready;\n // only the funnel gate must not bounce back to awaiting_data.\n if (\n (ctx.stage !== \"analyzed\" && ctx.stage !== \"delivered\") ||\n ctx.analysis.completed.length === 0\n ) {\n return false;\n }\n if (!ctx.dataset) return false;\n const counts = ctx.dataset.counts ?? {};\n return Object.values(counts).some((n) => n > 0);\n}\n\n// ============================================================\n// Directories\n// ============================================================\n\nconst SESSION_ID_RE = /^\\d{4}-\\d{2}-\\d{2}-[a-f0-9]{4}$/i;\n\nfunction ntrpHomeDir(): string {\n return process.env.NTRP_HOME ? resolve(process.env.NTRP_HOME) : join(homedir(), \".ntrp\");\n}\n\nexport function getSessionsDir(): string {\n const dir = join(ntrpHomeDir(), \"sessions\");\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true });\n }\n return dir;\n}\n\nexport function getDatasetsDir(): string {\n const dir = join(ntrpHomeDir(), \"datasets\");\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true });\n }\n return dir;\n}\n\n/** Absolute path of the per-session dataset DB file for a session id. */\nexport function datasetPathForSession(id: string): string {\n return join(getDatasetsDir(), `${id}.duckdb`);\n}\n\n/** Absolute path of the raw terminal transcript markdown for a session id. */\nexport function transcriptPathForSession(id: string): string {\n return join(getSessionsDir(), `${id}.transcript.md`);\n}\n\n/** Absolute path of the summarized context brief markdown for a session id. */\nexport function contextDocPathForSession(id: string): string {\n return join(getSessionsDir(), `${id}.context.md`);\n}\n\n// ============================================================\n// Session lifecycle\n// ============================================================\n\nexport function makeSessionId(): string {\n const now = new Date();\n const date = now.toISOString().slice(0, 10);\n const uuid = randomUUID().slice(0, 4);\n return `${date}-${uuid}`;\n}\n\nfunction isValidSessionId(id: string): boolean {\n return SESSION_ID_RE.test(id);\n}\n\nfunction sessionPathForId(id: string): string | null {\n if (!isValidSessionId(id)) return null;\n const dir = resolve(getSessionsDir());\n const filePath = resolve(dir, `${id}.json`);\n if (filePath !== dir && !filePath.startsWith(dir + sep)) return null;\n return filePath;\n}\n\nexport function initContext(oneShot: boolean, execution?: Partial<ExecutionOptions>): Context {\n const sessionId = makeSessionId();\n const sessionFile = join(getSessionsDir(), `${sessionId}.json`);\n\n return {\n sessionId,\n sessionFile,\n oneShot,\n execution: buildExecutionOptions({\n mode: oneShot ? \"one_shot\" : \"interactive\",\n ...execution,\n }),\n snapshot: { computeResult: null, divergences: [] },\n messages: [],\n conversation: [],\n stage: \"new\",\n deliverables: [],\n analysis: defaultSessionAnalysis(),\n wizardDepth: 0,\n attachments: [],\n deliverIntent: false,\n computeInProgress: false,\n };\n}\n\n/** Snapshot the live context as a SessionFile (used for persistence + context brief). */\nexport function buildSessionFileSnapshot(ctx: Context): SessionFile {\n const file: SessionFile = {\n id: ctx.sessionId,\n created_at: ctx.messages[0]?.at ?? new Date().toISOString(),\n messages: ctx.messages,\n stage: ctx.stage,\n };\n if (ctx.sessionName) file.name = ctx.sessionName;\n if (ctx.dataset) file.dataset = ctx.dataset;\n if (ctx.deliverables.length > 0) file.deliverables = ctx.deliverables;\n if (ctx.conversation.length > 0) file.thread = ctx.conversation;\n if (ctx.resumedFromId) file.resumed_from = ctx.resumedFromId;\n if (ctx.analysis) file.analysis = ctx.analysis;\n if (ctx.scope) file.scope = ctx.scope;\n if (ctx.attachments && ctx.attachments.length > 0) file.attachments = ctx.attachments;\n if (ctx.llm && Object.keys(ctx.llm).length > 0) file.llm = ctx.llm;\n if (ctx.strategistState) file.strategist = ctx.strategistState;\n if (ctx.pendingAsk) file.pending_ask = ctx.pendingAsk;\n return file;\n}\n\nexport function defaultSessionAnalysis(primary: AnalysisLens = \"gtm_health\"): SessionAnalysis {\n return { primary, completed: [] };\n}\n\nexport function setPrimaryLens(ctx: Context, lens: AnalysisLens): void {\n ctx.analysis = { ...ctx.analysis, primary: lens };\n}\n\nexport function markLensCompleted(ctx: Context, lens: AnalysisLens): void {\n const completed = ctx.analysis.completed.includes(lens)\n ? ctx.analysis.completed\n : [...ctx.analysis.completed, lens];\n ctx.analysis = { ...ctx.analysis, completed };\n}\n\nexport function lensBadgeLabel(analysis?: SessionAnalysis): string {\n if (!analysis) return \"health\";\n const hasHealth = analysis.completed.includes(\"gtm_health\") || analysis.primary === \"gtm_health\";\n const hasMetrics = analysis.completed.includes(\"revenue_metrics\") || analysis.primary === \"revenue_metrics\";\n if (hasHealth && hasMetrics) return \"both\";\n if (hasMetrics) return \"metrics\";\n return \"health\";\n}\n\n/** Session lens context injected into NL / ask agent prompts. */\nexport function buildAnalysisBlock(ctx: Context): string {\n return [\n `Primary lens: ${ctx.analysis.primary}`,\n `Completed: ${ctx.analysis.completed.join(\", \") || \"none\"}`,\n `Badge: ${lensBadgeLabel(ctx.analysis)}`,\n ctx.analysis.coverage\n ? `Coverage: ${ctx.analysis.coverage.distinct_months} months, ${ctx.analysis.coverage.recommended_cadence} cadence`\n : null,\n ctx.analysis.data_source_type ? `Data source: ${ctx.analysis.data_source_type}` : null,\n ].filter(Boolean).join(\"\\n\");\n}\n\n/** Metrics-primary session with metrics done but no GTM health run yet. */\nexport function prefersMetricsFirstContext(ctx: Context): boolean {\n return (\n ctx.analysis.primary === \"revenue_metrics\" &&\n ctx.analysis.completed.includes(\"revenue_metrics\") &&\n !ctx.analysis.completed.includes(\"gtm_health\")\n );\n}\n\n/**\n * Rehydrate analysis lens state for headless / MCP paths that skip the REPL.\n * Prefers the most recent persisted session; falls back to DB lane signals.\n */\nexport async function hydrateAnalysisFromPersistedState(ctx: Context): Promise<void> {\n const sessions = listSessions({ limit: 10 });\n const withAnalysis = sessions.find(\n (s) =>\n s.analysis &&\n (s.analysis.completed.length > 0 ||\n s.analysis.primary !== \"gtm_health\" ||\n s.stage === \"analyzed\"),\n );\n if (withAnalysis?.analysis) {\n ctx.analysis = {\n ...defaultSessionAnalysis(withAnalysis.analysis.primary),\n ...withAnalysis.analysis,\n completed: [...withAnalysis.analysis.completed],\n };\n if (withAnalysis.stage) ctx.stage = withAnalysis.stage;\n if (withAnalysis.dataset) ctx.dataset = withAnalysis.dataset;\n return;\n }\n\n const { loadLatestDiagnosis, loadLatestMetricsAnalysis } = await import(\"../db/queries.js\");\n const [diagnosis, metrics] = await Promise.all([\n loadLatestDiagnosis(),\n loadLatestMetricsAnalysis(),\n ]);\n const completed: AnalysisLens[] = [];\n if (diagnosis) completed.push(\"gtm_health\");\n if (metrics?.metrics.length) completed.push(\"revenue_metrics\");\n if (completed.length === 0) return;\n\n let primary = ctx.analysis.primary;\n if (completed.includes(\"revenue_metrics\") && !completed.includes(\"gtm_health\")) {\n primary = \"revenue_metrics\";\n } else if (completed.includes(\"gtm_health\") && !completed.includes(\"revenue_metrics\")) {\n primary = \"gtm_health\";\n }\n ctx.analysis = { ...ctx.analysis, primary, completed };\n if (ctx.stage === \"new\") ctx.stage = \"analyzed\";\n}\n\n/** Headless agent context with session / DB analysis hydration. */\nexport async function initHeadlessAgentContext(): Promise<Context> {\n const ctx = initContext(true, { mode: \"headless\", output: \"json\" });\n await hydrateAnalysisFromPersistedState(ctx);\n return ctx;\n}\n\n/** Append a message to the session and persist to disk. */\nexport function recordMessage(ctx: Context, role: \"user\" | \"agent\", content: string): void {\n const msg: SessionMessage = { role, content, at: new Date().toISOString() };\n ctx.messages.push(msg);\n if (ctx.oneShot) return; // don't persist one-shot noise\n\n try {\n writeFileSync(ctx.sessionFile, JSON.stringify(buildSessionFileSnapshot(ctx), null, 2) + \"\\n\");\n } catch {\n // best-effort; don't crash REPL\n }\n writeSessionContextDoc(ctx);\n}\n\n/**\n * Persist the session's current stage/dataset/deliverables without requiring an\n * NL exchange. Called by /new and /handoff to checkpoint lifecycle progress so\n * the welcome dashboard can surface unfinished work accurately.\n */\nexport function saveSessionState(ctx: Context): void {\n if (ctx.oneShot) return;\n try {\n writeFileSync(ctx.sessionFile, JSON.stringify(buildSessionFileSnapshot(ctx), null, 2) + \"\\n\");\n } catch {\n // best-effort\n }\n writeSessionContextDoc(ctx);\n}\n\n// ============================================================\n// Last-activity lookup for the welcome dashboard\n// ============================================================\n\n/**\n * Find the most recent session file's modification time and return a\n * compact relative-time string (\"2h ago\", \"just now\", \"2026-04-11\").\n * Used on the welcome dashboard — the session mtime is the truest signal\n * of \"when did I last use ntrp\" because every REPL exchange touches it.\n */\nexport function getLastActivityRelative(): string | null {\n const dir = getSessionsDir();\n let mostRecent = 0;\n try {\n for (const name of readdirSync(dir)) {\n if (!name.endsWith(\".json\")) continue;\n const m = statSync(join(dir, name)).mtimeMs;\n if (m > mostRecent) mostRecent = m;\n }\n } catch {\n return null;\n }\n\n if (mostRecent === 0) return null;\n return formatRelativeTime(new Date(mostRecent));\n}\n\nfunction formatRelativeTime(then: Date): string {\n const diffMs = Date.now() - then.getTime();\n if (diffMs < 0) return \"just now\";\n const s = Math.floor(diffMs / 1000);\n if (s < 60) return \"just now\";\n const m = Math.floor(s / 60);\n if (m < 60) return `${m}m ago`;\n const h = Math.floor(m / 60);\n if (h < 24) return `${h}h ago`;\n const d = Math.floor(h / 24);\n if (d < 7) return `${d}d ago`;\n // Older than a week — show the date\n return then.toISOString().slice(0, 10);\n}\n\n// ============================================================\n// Session close + listing\n// ============================================================\n\n/** Read and parse a session JSON file. Returns null on any error. */\nexport function loadSessionFile(id: string): SessionFile | null {\n const filePath = sessionPathForId(id);\n if (!filePath) return null;\n try {\n const raw = readFileSync(filePath, \"utf-8\");\n const session = JSON.parse(raw) as SessionFile;\n if (session.thread?.length) {\n session.thread = normalizeThread(session.thread as unknown[]);\n }\n return session;\n } catch {\n return null;\n }\n}\n\n/** List all session files, sorted by mtime desc. Optional limit. */\nexport function listSessions(opts?: { limit?: number }): SessionListEntry[] {\n const dir = getSessionsDir();\n const entries: SessionListEntry[] = [];\n try {\n const files = readdirSync(dir)\n .filter((name) => name.endsWith(\".json\"))\n .map((name) => {\n const filePath = join(dir, name);\n return { name, filePath, mtime: statSync(filePath).mtimeMs };\n })\n .sort((a, b) => b.mtime - a.mtime);\n const filesToRead = opts?.limit ? files.slice(0, opts.limit) : files;\n\n for (const { name, filePath, mtime } of filesToRead) {\n const id = basename(name, \".json\");\n if (!isValidSessionId(id)) continue;\n try {\n const raw = readFileSync(filePath, \"utf-8\");\n const session = JSON.parse(raw) as SessionFile;\n entries.push({\n id,\n created_at: session.created_at,\n ended_at: session.ended_at,\n exchange_count: session.exchange_count ?? Math.floor(session.messages.length / 2),\n summary: session.summary,\n name: session.name,\n stage: session.stage,\n dataset: session.dataset,\n deliverables: session.deliverables,\n analysis: session.analysis,\n scope: session.scope,\n mtime,\n });\n } catch {\n // skip malformed files\n }\n }\n } catch {\n return [];\n }\n entries.sort((a, b) => b.mtime - a.mtime);\n if (opts?.limit) return entries.slice(0, opts.limit);\n return entries;\n}\n\n/** Convenience: get the N most recent sessions. */\nexport function getRecentSessions(n: number): SessionListEntry[] {\n return listSessions({ limit: n });\n}\n\n/**\n * Sessions that reached insight but never shipped an output — \"unfinished\"\n * work the welcome flow nudges the user to pick back up. Excludes the active\n * session and delivered/empty ones.\n */\nexport function getUnfinishedSessions(excludeId?: string): SessionListEntry[] {\n return listSessions().filter(\n (s) =>\n s.id !== excludeId &&\n s.stage === \"analyzed\" &&\n (s.deliverables?.length ?? 0) === 0,\n );\n}\n\n/** True when a session has started work but is not closed or delivered. */\nexport function isSessionInProgress(s: SessionListEntry): boolean {\n if (s.stage === \"ended\") return false;\n if ((s.deliverables?.length ?? 0) > 0 || s.stage === \"delivered\") return false;\n if (s.stage === \"analyzed\") return true;\n return (s.exchange_count ?? 0) > 0 || !!s.dataset?.label;\n}\n\n/** Sessions still open — analyzed awaiting handoff, or new with data/exchanges. */\nexport function getActiveSessions(): SessionListEntry[] {\n return listSessions().filter(isSessionInProgress);\n}\n\n/**\n * Mark every in-progress session as ended, then rotate the REPL to a fresh shell.\n * Session JSON and dataset files are preserved on disk.\n */\nexport async function closeAllActiveSessions(\n ctx: Context,\n): Promise<{ closed: string[]; skipped: string[] }> {\n const active = getActiveSessions();\n const closed: string[] = [];\n const skipped: string[] = [];\n const endedAt = new Date().toISOString();\n\n for (const s of active) {\n if (s.id === ctx.sessionId) continue;\n const file = loadSessionFile(s.id);\n if (!file) {\n skipped.push(s.id);\n continue;\n }\n file.stage = \"ended\";\n file.ended_at = endedAt;\n const filePath = sessionPathForId(s.id);\n if (!filePath) {\n skipped.push(s.id);\n continue;\n }\n writeFileSync(filePath, JSON.stringify(file, null, 2) + \"\\n\");\n writeContextDocForSessionFile(file);\n closed.push(s.id);\n }\n\n const currentActive = active.some((s) => s.id === ctx.sessionId);\n if (currentActive) {\n const alreadyClosed = ctx.stage === \"delivered\" || ctx.stage === \"ended\";\n const hasWork =\n ctx.stage === \"analyzed\" ||\n !!ctx.dataset ||\n ctx.messages.length > 0 ||\n ctx.deliverables.length > 0;\n\n if (!alreadyClosed && hasWork) {\n await endSession(ctx);\n if (!closed.includes(ctx.sessionId)) closed.push(ctx.sessionId);\n } else if (!alreadyClosed && (ctx.messages.length > 0 || !!ctx.dataset?.label)) {\n await endSession(ctx);\n if (!closed.includes(ctx.sessionId)) closed.push(ctx.sessionId);\n }\n }\n\n await rotateToFreshSession(ctx);\n const { initSchema } = await import(\"../db/schema.js\");\n await initSchema();\n\n return { closed, skipped };\n}\n\n/**\n * Most recently touched session with real work (skips empty shells).\n * listSessions() is already sorted by mtime desc.\n */\nexport function getLastWorkedSession(): SessionListEntry | null {\n for (const s of listSessions()) {\n if (\n (s.exchange_count ?? 0) > 0 ||\n s.stage === \"analyzed\" ||\n s.stage === \"delivered\" ||\n !!s.dataset?.label\n ) {\n return s;\n }\n }\n return null;\n}\n\n/** Finalize the session: compute exchange_count, set ended_at, generate AI summary, write file. */\nexport async function closeSession(ctx: Context): Promise<string | undefined> {\n return finalizeSession(ctx, ctx.stage);\n}\n\n/**\n * Close the current analysis without a handoff — marks stage \"ended\" so it\n * drops off the unfinished list. Preserves transcript, dataset anchor, and\n * optional AI summary like closeSession.\n */\nexport async function endSession(ctx: Context): Promise<string | undefined> {\n return finalizeSession(ctx, \"ended\");\n}\n\n/** Rotate to a brand-new empty session + dataset file (caller finalizes the prior session first). */\nexport async function rotateToFreshSession(ctx: Context): Promise<void> {\n const { setActiveDbPath } = await import(\"../db/connection.js\");\n const newId = makeSessionId();\n resetContextForSwitch(ctx, {\n sessionId: newId,\n sessionFile: join(getSessionsDir(), `${newId}.json`),\n messages: [],\n stage: \"new\",\n analysis: defaultSessionAnalysis(),\n llm: undefined,\n });\n ctx.datasetPath = datasetPathForSession(newId);\n await setActiveDbPath(ctx.datasetPath);\n}\n\n/** Compact one-line summary for session lists and close — no LLM. */\nexport function buildLightweightSessionSummary(ctx: Context): string {\n const parts: string[] = [];\n const intent = ctx.scope?.intent_summary?.trim();\n if (intent) parts.push(intent.length > 90 ? `${intent.slice(0, 87)}…` : intent);\n\n const gating = ctx.snapshot.computeResult?.aggregate.gating_vital_sign;\n if (gating) {\n parts.push(`gated by ${gating.replace(/_/g, \" \")}`);\n } else if (ctx.dataset?.label) {\n parts.push(ctx.dataset.label);\n }\n\n const exchanges = Math.floor(ctx.messages.length / 2);\n if (exchanges > 0) parts.push(`${exchanges} exchange${exchanges === 1 ? \"\" : \"s\"}`);\n if (ctx.deliverables.length > 0) {\n parts.push(`${ctx.deliverables.length} deliverable${ctx.deliverables.length === 1 ? \"\" : \"s\"}`);\n }\n\n return parts.join(\" · \") || `Session ${ctx.sessionId.slice(0, 8)}`;\n}\n\nasync function finalizeSession(ctx: Context, stage: SessionStage): Promise<string | undefined> {\n if (ctx.oneShot) return undefined;\n\n const exchangeCount = Math.floor(ctx.messages.length / 2);\n const endedAt = new Date().toISOString();\n\n // Nothing happened in this session — no questions, no data, no output.\n // Don't leave an empty session file or an empty per-session dataset behind.\n if (\n ctx.messages.length === 0 &&\n ctx.stage === \"new\" &&\n !ctx.dataset &&\n ctx.deliverables.length === 0\n ) {\n if (ctx.datasetPath) {\n try {\n const { close } = await import(\"../db/connection.js\");\n await close();\n } catch { /* best-effort */ }\n for (const path of [ctx.datasetPath, `${ctx.datasetPath}.wal`]) {\n try { rmSync(path, { force: true }); } catch { /* best-effort */ }\n }\n }\n discardSessionTranscript(ctx.sessionId);\n try { rmSync(contextDocPathForSession(ctx.sessionId), { force: true }); } catch { /* best-effort */ }\n return undefined;\n }\n\n if (ctx.deliverables.length > 0) {\n const { creditSessionDeliverableWrapup } = await import(\"../whimsy/time-bank.js\");\n creditSessionDeliverableWrapup(ctx);\n }\n\n const { recordSessionClosed } = await import(\"../whimsy/usage-stats.js\");\n recordSessionClosed();\n\n const summary = buildLightweightSessionSummary(ctx);\n\n const file: SessionFile = {\n id: ctx.sessionId,\n created_at: ctx.messages[0]?.at ?? endedAt,\n messages: ctx.messages,\n ended_at: endedAt,\n exchange_count: exchangeCount,\n stage,\n summary,\n };\n\n if (ctx.resumedFromId) {\n file.resumed_from = ctx.resumedFromId;\n }\n if (ctx.sessionName) {\n file.name = ctx.sessionName;\n }\n if (ctx.dataset) {\n file.dataset = ctx.dataset;\n }\n if (ctx.deliverables.length > 0) {\n file.deliverables = ctx.deliverables;\n }\n if (ctx.conversation.length > 0) {\n file.thread = ctx.conversation;\n }\n if (ctx.analysis) {\n file.analysis = ctx.analysis;\n }\n if (ctx.scope) {\n file.scope = ctx.scope;\n }\n if (ctx.attachments && ctx.attachments.length > 0) {\n file.attachments = ctx.attachments;\n }\n if (ctx.llm && Object.keys(ctx.llm).length > 0) {\n file.llm = ctx.llm;\n }\n // Persist in-flight strategist state consistently with buildSessionFileSnapshot\n // — a mid-flow close must not silently drop (or silently keep) the confirm\n // gate depending on the exit path. Pickup announces it.\n if (ctx.strategistState) {\n file.strategist = ctx.strategistState;\n }\n if (ctx.pendingAsk) {\n file.pending_ask = ctx.pendingAsk;\n }\n\n try {\n writeFileSync(ctx.sessionFile, JSON.stringify(file, null, 2) + \"\\n\");\n } catch {\n // best-effort\n }\n writeContextDocForSessionFile(file, { snapshot: ctx.snapshot.computeResult });\n\n // Learning loop: distill durable facts — race with a short timeout so close\n // never blocks on a slow LLM; distill continues in background if needed.\n let closeNote = summary;\n if (exchangeCount > 0) {\n try {\n const { distillSessionFactsWithTimeout } = await import(\"../memory/distill.js\");\n const { count } = await distillSessionFactsWithTimeout(ctx, ctx.sessionId);\n if (count > 0) {\n closeNote = `${summary} · noted ${count} for memory (/recall)`;\n }\n } catch {\n // never block session close on memory writes\n }\n }\n\n return closeNote;\n}\n\n// ============================================================\n// Named-session helpers (for /name and /switch)\n// ============================================================\n\n/**\n * Resolve a session by full id, 4-char suffix, or name.\n * undefined = no match, null = ambiguous (message printed when printErrors is true).\n */\nexport function resolveSessionByToken(\n idArg: string,\n options?: { printErrors?: boolean },\n): SessionListEntry | null | undefined {\n const printErrors = options?.printErrors !== false;\n const all = listSessions();\n const lower = idArg.toLowerCase();\n let matches = all.filter((s) => s.id === idArg);\n if (matches.length === 0) matches = all.filter((s) => s.name?.toLowerCase() === lower);\n if (matches.length === 0 && idArg.length >= 4) {\n matches = all.filter((s) => s.id.endsWith(idArg));\n }\n if (matches.length === 0) return undefined;\n if (matches.length > 1) {\n if (printErrors) {\n console.log(\n ` Ambiguous \"${idArg}\" — matches ${matches.length} sessions. Use a longer id.`,\n );\n }\n return null;\n }\n return matches[0]!;\n}\n\n/** Find the most recent session with a given name (case-insensitive). */\nexport function findSessionByName(name: string): SessionListEntry | null {\n const lower = name.toLowerCase();\n const all = listSessions();\n return all.find((s) => s.name?.toLowerCase() === lower) ?? null;\n}\n\n/** Build a richer context string for a resumed/switched session: summary + last 3 user messages. */\nexport function buildSwitchContext(session: SessionFile): string {\n const parts: string[] = [];\n if (session.summary) parts.push(session.summary);\n\n const userMsgs = session.messages\n .filter((m) => m.role === \"user\")\n .slice(-3);\n for (const m of userMsgs) {\n parts.push(m.content.slice(0, 300));\n }\n\n return parts.join(\"\\n\");\n}\n\n/**\n * Mutate ctx in-place for a session switch. Resets session identity\n * and messages but preserves snapshot, rl, and oneShot.\n */\nexport function resetContextForSwitch(\n ctx: Context,\n opts: {\n sessionId: string;\n sessionFile: string;\n sessionName?: string;\n messages: SessionMessage[];\n conversation?: LlmMessage[];\n resumedFromId?: string;\n resumedSessionSummary?: string;\n stage?: SessionStage;\n dataset?: DatasetMeta;\n deliverables?: Deliverable[];\n analysis?: SessionAnalysis;\n scope?: AnalysisScope;\n attachments?: ChatAttachment[];\n llm?: LlmSessionOverride;\n strategistState?: StrategistFlowState;\n pendingAsk?: PendingAskState;\n },\n): void {\n ctx.sessionId = opts.sessionId;\n ctx.sessionFile = opts.sessionFile;\n ctx.sessionName = opts.sessionName;\n ctx.messages = opts.messages;\n ctx.conversation = opts.conversation ?? [];\n ctx.resumedFromId = opts.resumedFromId;\n ctx.resumedSessionSummary = opts.resumedSessionSummary;\n ctx.stage = opts.stage ?? \"new\";\n ctx.dataset = opts.dataset;\n ctx.deliverables = opts.deliverables ?? [];\n ctx.analysis = opts.analysis ?? defaultSessionAnalysis();\n ctx.scope = opts.scope;\n ctx.attachments = opts.attachments ?? [];\n ctx.llm = opts.llm;\n ctx.strategistState = opts.strategistState;\n ctx.pendingAsk = opts.pendingAsk;\n ctx.gapAudit = undefined;\n ctx.deliverIntent = false;\n ctx.computeInProgress = false;\n ctx.wizardDepth = 0;\n // Fresh session deserves the welcome logo again on next /home paint.\n ctx.welcomeLogoShown = false;\n // The cached health snapshot belongs to the previous dataset — clear it so\n // the next question recomputes against the newly-bound dataset.\n ctx.snapshot = { computeResult: null, divergences: [] };\n // Re-point the transcript recorder at the new session's file.\n rebindSessionTranscript(ctx);\n}\n","/**\n * Session transcript recorder — persists the raw terminal session to\n * ~/.ntrp/sessions/<id>.transcript.md so a session can be triaged after the\n * fact (what was computed, what the operator typed, what was printed).\n *\n * How it works:\n * - Tees process.stdout / process.stderr writes into a TerminalCapture\n * (spinner frames collapse, ANSI is resolved to visible text).\n * - Capture pauses while the REPL prompt is idle; the submitted line is\n * recorded as an explicit `❯ <prompt><input>` marker instead, so\n * keystroke echo / ghost autocompletion never pollute the file.\n * - The file is fully rewritten on a short throttle so it is valid\n * markdown at all times — a crash loses at most ~1s of output, which is\n * exactly when a transcript matters most.\n * - Session switches (/new, /session <id>, /end) rebind the recorder to\n * the new session's file; picking up an existing session appends a\n * \"Continued\" segment rather than overwriting history.\n *\n * Interactive REPL only — one-shot commands print to the terminal the user\n * already controls and are not session-scoped.\n */\n\nimport { existsSync, readFileSync, writeFileSync, rmSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { Context } from \"../cli/context.js\";\nimport { getSessionsDir, transcriptPathForSession } from \"../cli/context.js\";\nimport { TerminalCapture, stripAnsi, redactSecrets } from \"./terminal-capture.js\";\n\nconst FLUSH_THROTTLE_MS = 250;\nconst FLUSH_MAX_STALENESS_MS = 900;\n\ninterface RecorderState {\n sessionId: string;\n filePath: string;\n /** Prior file content when continuing an existing session's transcript. */\n base: string;\n segmentStartedAt: string;\n capture: TerminalCapture;\n paused: boolean;\n discarded: boolean;\n lastFlushMs: number;\n flushTimer: NodeJS.Timeout | null;\n}\n\nlet state: RecorderState | null = null;\n\ntype WriteFn = typeof process.stdout.write;\nlet originalStdoutWrite: WriteFn | null = null;\nlet originalStderrWrite: WriteFn | null = null;\n\n// ============================================================\n// Lifecycle\n// ============================================================\n\n/** Begin recording the interactive session. No-op in one-shot mode. */\nexport function startSessionTranscript(ctx: Context): void {\n if (ctx.oneShot || state) return;\n installTees();\n state = createState(ctx.sessionId);\n flushNow();\n}\n\n/** Rebind the recorder when the context rotates/picks up another session. */\nexport function rebindSessionTranscript(ctx: Context): void {\n if (!state || state.sessionId === ctx.sessionId) return;\n // A session that never persisted any state (no JSON) has nothing to triage —\n // don't leave a welcome-screen-only transcript behind (mirrors the\n // empty-session cleanup in finalizeSession).\n const priorJson = join(getSessionsDir(), `${state.sessionId}.json`);\n if (existsSync(priorJson)) {\n finalizeCurrentFile(\"switched session\");\n } else {\n discardSessionTranscript(state.sessionId);\n }\n state = createState(ctx.sessionId);\n flushNow();\n}\n\n/** Stop recording and write the final flush. */\nexport function stopSessionTranscript(): void {\n if (!state) return;\n finalizeCurrentFile(\"session closed\");\n state = null;\n removeTees();\n}\n\n/**\n * Delete the transcript of a session that turned out to be empty (no\n * exchanges, no data, no deliverables) — mirrors finalizeSession's policy of\n * not leaving empty session files behind.\n */\nexport function discardSessionTranscript(sessionId: string): void {\n if (state && state.sessionId === sessionId) {\n state.discarded = true;\n clearFlushTimer();\n }\n try {\n rmSync(transcriptPathForSession(sessionId), { force: true });\n } catch {\n // best-effort\n }\n}\n\n/** Suspend capture while the REPL prompt is idle (input echo is noise). */\nexport function pauseTranscriptCapture(): void {\n if (state) state.paused = true;\n}\n\nexport function resumeTranscriptCapture(): void {\n if (state) state.paused = false;\n}\n\n/** Record a submitted input line with its phase prompt, e.g. `❯ ask › high what is arr`. */\nexport function noteTranscriptInput(promptLabel: string, input: string): void {\n if (!state || state.discarded) return;\n state.capture.note(\"\");\n state.capture.note(`❯ ${stripAnsi(promptLabel)}${input}`.trimEnd());\n flushNow();\n}\n\n/** True when the recorder is active for this session id. */\nexport function isTranscriptActive(sessionId?: string): boolean {\n if (!state || state.discarded) return false;\n return sessionId === undefined || state.sessionId === sessionId;\n}\n\n// ============================================================\n// Stream tees\n// ============================================================\n\nfunction installTees(): void {\n if (originalStdoutWrite) return;\n originalStdoutWrite = process.stdout.write.bind(process.stdout) as WriteFn;\n originalStderrWrite = process.stderr.write.bind(process.stderr) as WriteFn;\n\n const tee =\n (original: WriteFn): WriteFn =>\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n ((chunk: any, encoding?: any, callback?: any) => {\n try {\n if (state && !state.paused && !state.discarded) {\n const text =\n typeof chunk === \"string\"\n ? chunk\n : Buffer.isBuffer(chunk)\n ? chunk.toString(\"utf-8\")\n : String(chunk);\n state.capture.feed(text);\n scheduleFlush();\n }\n } catch {\n // The transcript must never break the live terminal.\n }\n return original(chunk, encoding, callback);\n }) as WriteFn;\n\n process.stdout.write = tee(originalStdoutWrite);\n process.stderr.write = tee(originalStderrWrite);\n}\n\nfunction removeTees(): void {\n if (originalStdoutWrite) {\n process.stdout.write = originalStdoutWrite;\n originalStdoutWrite = null;\n }\n if (originalStderrWrite) {\n process.stderr.write = originalStderrWrite;\n originalStderrWrite = null;\n }\n}\n\n// ============================================================\n// Rendering + flushing\n// ============================================================\n\nfunction createState(sessionId: string): RecorderState {\n const filePath = transcriptPathForSession(sessionId);\n let base = \"\";\n if (existsSync(filePath)) {\n try {\n base = readFileSync(filePath, \"utf-8\").trimEnd() + \"\\n\";\n } catch {\n base = \"\";\n }\n }\n return {\n sessionId,\n filePath,\n base,\n segmentStartedAt: new Date().toISOString(),\n capture: new TerminalCapture(),\n paused: false,\n discarded: false,\n lastFlushMs: 0,\n flushTimer: null,\n };\n}\n\nfunction renderHeader(sessionId: string): string {\n return [\n `# ntrp transcript — ${sessionId}`,\n \"\",\n `- Session data: \\`${sessionId}.json\\` · Context brief: \\`${sessionId}.context.md\\``,\n \"- Raw terminal text (ANSI stripped, spinner frames collapsed). Lines starting with `❯` are operator input.\",\n \"\",\n \"\",\n ].join(\"\\n\");\n}\n\nfunction renderSegment(s: RecorderState, closedNote?: string): string {\n const lines = s.capture.snapshot().map(redactSecrets);\n const dropped = s.capture.droppedLineCount;\n\n // Fenced block must survive terminal output that itself contains backticks\n // (handoff prompts print fenced markdown) — grow the fence past the longest\n // backtick run in the content.\n let longestRun = 0;\n for (const line of lines) {\n for (const match of line.matchAll(/`+/g)) {\n if (match[0].length > longestRun) longestRun = match[0].length;\n }\n }\n const fence = \"`\".repeat(Math.max(3, longestRun + 1));\n\n const heading = s.base\n ? `## Continued — ${s.segmentStartedAt}`\n : `## Session start — ${s.segmentStartedAt}`;\n\n const parts: string[] = [heading, \"\"];\n if (dropped > 0) {\n parts.push(`_(${dropped.toLocaleString()} earlier lines dropped to bound file size)_`, \"\");\n }\n parts.push(`${fence}text`, ...lines, fence, \"\");\n parts.push(\n closedNote\n ? `_Closed: ${new Date().toISOString()} (${closedNote})_`\n : `_Last write: ${new Date().toISOString()}_`,\n );\n parts.push(\"\");\n return parts.join(\"\\n\");\n}\n\nfunction render(s: RecorderState, closedNote?: string): string {\n const prefix = s.base ? s.base + \"\\n\" : renderHeader(s.sessionId);\n return prefix + renderSegment(s, closedNote);\n}\n\nfunction flushNow(closedNote?: string): void {\n const s = state;\n if (!s || s.discarded) return;\n clearFlushTimer();\n s.lastFlushMs = Date.now();\n try {\n getSessionsDir(); // ensure the directory exists (e.g. after /scratch)\n writeFileSync(s.filePath, render(s, closedNote));\n } catch {\n // best-effort — never break the session over transcript IO\n }\n}\n\nfunction scheduleFlush(): void {\n const s = state;\n if (!s || s.discarded) return;\n if (Date.now() - s.lastFlushMs >= FLUSH_MAX_STALENESS_MS) {\n flushNow();\n return;\n }\n if (s.flushTimer) return;\n s.flushTimer = setTimeout(() => {\n if (state) state.flushTimer = null;\n flushNow();\n }, FLUSH_THROTTLE_MS);\n s.flushTimer.unref?.();\n}\n\nfunction clearFlushTimer(): void {\n if (state?.flushTimer) {\n clearTimeout(state.flushTimer);\n state.flushTimer = null;\n }\n}\n\nfunction finalizeCurrentFile(reason: string): void {\n if (!state) return;\n clearFlushTimer();\n if (!state.discarded) flushNow(reason);\n}\n","/**\n * Terminal capture — reconstructs the visible terminal text from a raw\n * stdout/stderr stream.\n *\n * The REPL paints with ANSI escapes: ora spinners rewrite the same row many\n * times per second, readline repaints the prompt, /clear wipes the screen.\n * Persisting the raw byte stream would be unreadable, so this module runs a\n * tiny single-row terminal emulator: it tracks the current line + cursor\n * column, applies carriage returns / erase-line / cursor-column sequences,\n * and commits a line only when a newline arrives. Spinner frames therefore\n * collapse to their final state — the transcript reads like what the\n * operator actually saw.\n *\n * Pure and side-effect free — the stream tee lives in transcript.ts.\n */\n\nconst MAX_LINES_DEFAULT = 20_000;\nconst DROP_CHUNK = 500;\n\n/** Matches CSI, OSC, and other escape sequences for one-off stripping. */\nconst ANSI_ANY =\n // eslint-disable-next-line no-control-regex\n /\\x1B(?:\\[[0-9;?]*[ -/]*[@-~]|\\][^\\x07\\x1B]*(?:\\x07|\\x1B\\\\)?|[()][0-9A-Za-z]|[@-Z\\\\-_=><])/g;\n\n/** Strip all ANSI escapes + non-newline control chars from a string. */\nexport function stripAnsi(value: string): string {\n // eslint-disable-next-line no-control-regex\n return value.replace(ANSI_ANY, \"\").replace(/[\\x00-\\x08\\x0b-\\x1f\\x7f]/g, \"\");\n}\n\n// ============================================================\n// Secret redaction\n// ============================================================\n\n/**\n * Provider API keys and license keys must never persist in a transcript that\n * is meant to be shared for triage. Masked prompts already print bullets, but\n * keys typed inline (`/connect --key sk-…`, `ntrp activate NTRP-…`) would\n * otherwise land verbatim.\n */\nconst SECRET_PATTERNS: RegExp[] = [\n /\\bsk-ant-[A-Za-z0-9_-]{8,}/g, // Anthropic\n /\\bsk-or-[A-Za-z0-9_-]{8,}/g, // OpenRouter\n /\\bsk-proj-[A-Za-z0-9_-]{8,}/g, // OpenAI project keys\n /\\bsk-[A-Za-z0-9_-]{20,}/g, // OpenAI / generic sk-\n /\\bgsk_[A-Za-z0-9_-]{8,}/g, // Groq\n /\\bxai-[A-Za-z0-9_-]{8,}/g, // xAI\n /\\bfw_[A-Za-z0-9_-]{8,}/g, // Fireworks\n /\\bAIza[A-Za-z0-9_-]{10,}/g, // Google\n /\\bNTRP-[A-Z0-9][A-Z0-9-]{8,}/g, // license keys\n];\n\n/** Replace key-shaped tokens with a short prefix + redaction marker. */\nexport function redactSecrets(line: string): string {\n let out = line;\n for (const pattern of SECRET_PATTERNS) {\n out = out.replace(pattern, (m) => `${m.slice(0, 6)}…[redacted]`);\n }\n return out;\n}\n\n// ============================================================\n// Capture emulator\n// ============================================================\n\nexport const SCREEN_CLEAR_MARKER = \"── screen cleared ──\";\n\nexport class TerminalCapture {\n private lines: string[] = [];\n private cur = \"\";\n private col = 0;\n /** Partial escape sequence held across chunk boundaries. */\n private carry = \"\";\n /** A bare \\r at a chunk boundary — CRLF vs overwrite is decided by the next char. */\n private pendingCr = false;\n private dropped = 0;\n\n constructor(private readonly maxLines = MAX_LINES_DEFAULT) {}\n\n /** Feed a raw chunk of terminal output. */\n feed(chunk: string): void {\n const data = this.carry + chunk;\n this.carry = \"\";\n let i = 0;\n\n while (i < data.length) {\n const c = data[i]!;\n\n if (this.pendingCr) {\n this.pendingCr = false;\n if (c === \"\\n\") {\n this.newline();\n i++;\n continue;\n }\n // Bare CR — cursor returns to column 0; following text overwrites.\n this.col = 0;\n }\n\n if (c === \"\\n\") {\n this.newline();\n i++;\n continue;\n }\n if (c === \"\\r\") {\n this.pendingCr = true;\n i++;\n continue;\n }\n if (c === \"\\x1b\") {\n const consumed = this.consumeEscape(data, i);\n if (consumed === -1) {\n // Incomplete sequence — hold for the next chunk.\n this.carry = data.slice(i);\n return;\n }\n i += consumed;\n continue;\n }\n if (c === \"\\b\") {\n this.col = Math.max(0, this.col - 1);\n i++;\n continue;\n }\n if (c === \"\\t\") {\n const next = Math.floor(this.col / 8) * 8 + 8;\n while (this.col < next) this.writeChar(\" \");\n i++;\n continue;\n }\n if (c < \" \" || c === \"\\x7f\") {\n i++;\n continue; // bell + misc control chars\n }\n\n this.writeChar(c);\n i++;\n }\n }\n\n /** Append a standalone line (operator input markers, section notes). */\n note(line: string): void {\n this.commit(line);\n }\n\n /** Committed lines + the in-progress line (e.g. a live spinner row). */\n snapshot(): string[] {\n const out = [...this.lines];\n if (this.cur.trim().length > 0) out.push(this.cur.trimEnd());\n return out;\n }\n\n /** Lines evicted from the front once maxLines was exceeded. */\n get droppedLineCount(): number {\n return this.dropped;\n }\n\n // ----------------------------------------------------------\n\n private writeChar(c: string): void {\n if (this.col < this.cur.length) {\n this.cur = this.cur.slice(0, this.col) + c + this.cur.slice(this.col + 1);\n } else {\n this.cur = this.cur.padEnd(this.col, \" \") + c;\n }\n this.col++;\n }\n\n private newline(): void {\n this.commit(this.cur.trimEnd());\n this.cur = \"\";\n this.col = 0;\n }\n\n private commit(line: string): void {\n this.lines.push(line);\n if (this.lines.length > this.maxLines) {\n this.lines.splice(0, DROP_CHUNK);\n this.dropped += DROP_CHUNK;\n }\n }\n\n /**\n * Consume one escape sequence starting at data[start] (which is ESC).\n * Returns the number of chars consumed, or -1 if the sequence is\n * incomplete at the end of the chunk.\n */\n private consumeEscape(data: string, start: number): number {\n if (start + 1 >= data.length) return -1;\n const kind = data[start + 1]!;\n\n // CSI — ESC [ params final\n if (kind === \"[\") {\n let i = start + 2;\n while (i < data.length && /[0-9;?]/.test(data[i]!)) i++;\n while (i < data.length && data[i]! >= \" \" && data[i]! <= \"/\") i++;\n if (i >= data.length) return -1;\n const final = data[i]!;\n const params = data.slice(start + 2, i).replace(/[?]/g, \"\");\n this.applyCsi(params, final);\n return i - start + 1;\n }\n\n // OSC — ESC ] ... (BEL | ESC \\)\n if (kind === \"]\") {\n let i = start + 2;\n while (i < data.length) {\n if (data[i] === \"\\x07\") return i - start + 1;\n if (data[i] === \"\\x1b\" && data[i + 1] === \"\\\\\") return i - start + 2;\n i++;\n }\n return -1;\n }\n\n // Charset designators — ESC ( X / ESC ) X\n if (kind === \"(\" || kind === \")\") {\n if (start + 2 >= data.length) return -1;\n return 3;\n }\n\n // Other two-char escapes (ESC =, ESC >, ESC 7, ESC 8, …)\n return 2;\n }\n\n private applyCsi(params: string, final: string): void {\n const first = Number.parseInt(params.split(\";\")[0] ?? \"\", 10);\n const n = Number.isFinite(first) ? first : undefined;\n\n switch (final) {\n case \"K\": // erase in line\n if (n === 2) {\n this.cur = \"\";\n } else if (n === 1) {\n const keep = this.cur.slice(this.col);\n this.cur = \" \".repeat(Math.min(this.col, this.cur.length)) + keep;\n } else {\n this.cur = this.cur.slice(0, this.col);\n }\n break;\n case \"G\": // cursor to column\n this.col = Math.max(0, (n ?? 1) - 1);\n break;\n case \"J\": // erase in display\n if (n === 2 || n === 3) {\n if (this.cur.trim().length > 0) this.commit(this.cur.trimEnd());\n this.commit(SCREEN_CLEAR_MARKER);\n this.cur = \"\";\n this.col = 0;\n } else {\n this.cur = this.cur.slice(0, this.col);\n }\n break;\n case \"C\": // cursor right\n this.col += n ?? 1;\n break;\n case \"D\": // cursor left\n this.col = Math.max(0, this.col - (n ?? 1));\n break;\n case \"E\": // next line\n case \"F\": // previous line\n this.col = 0;\n break;\n case \"H\": // cursor home (row ignored — single-row model)\n case \"f\":\n this.col = 0;\n break;\n default:\n // SGR colors, cursor show/hide, scroll regions, … — no text effect.\n break;\n }\n }\n}\n"],"mappings":"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","names":["existsSync","mkdirSync","readdirSync","readFileSync","writeFileSync","join","existsSync","mkdirSync","readFileSync","writeFileSync","join","resolve","sep","homedir","resolve","mkdirSync","join","existsSync","writeFileSync","mkdirSync","join","writeFileSync","existsSync","readFileSync","resolve","existsSync","mkdirSync","join","join","existsSync","writeFileSync","basename","join","resolve","sep","existsSync","mkdirSync","writeFileSync","readFileSync","readdirSync","statSync","rmSync","homedir","randomUUID","existsSync","readFileSync","writeFileSync","rmSync","join","resolve","join","homedir","existsSync","mkdirSync","existsSync","join","writeFileSync","readFileSync","readdirSync","mkdirSync"]}
@@ -25,9 +25,57 @@ var init_formatters = __esm({
25
25
  }
26
26
  });
27
27
 
28
+ // src/config/store.ts
29
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
30
+ import { homedir } from "os";
31
+ import { join, resolve } from "path";
32
+ function ntrpHome() {
33
+ return NTRP_DIR;
34
+ }
35
+ function ensureDir() {
36
+ if (!existsSync(NTRP_DIR)) {
37
+ mkdirSync(NTRP_DIR, { recursive: true });
38
+ }
39
+ }
40
+ function loadConfig() {
41
+ if (cachedConfig) return cachedConfig;
42
+ ensureDir();
43
+ if (!existsSync(CONFIG_PATH)) {
44
+ cachedConfig = {};
45
+ return cachedConfig;
46
+ }
47
+ try {
48
+ cachedConfig = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
49
+ } catch {
50
+ cachedConfig = {};
51
+ }
52
+ return cachedConfig;
53
+ }
54
+ function getExportsDir() {
55
+ const config = loadConfig();
56
+ const dir = resolve(config["export-dir"] ?? join(NTRP_DIR, "exports"));
57
+ if (!existsSync(dir)) {
58
+ mkdirSync(dir, { recursive: true });
59
+ }
60
+ return dir;
61
+ }
62
+ function getConfiguredAiInboxDir() {
63
+ const raw = loadConfig()["ai-inbox-dir"];
64
+ return raw ? resolve(raw) : null;
65
+ }
66
+ var NTRP_DIR, CONFIG_PATH, cachedConfig;
67
+ var init_store = __esm({
68
+ "src/config/store.ts"() {
69
+ "use strict";
70
+ NTRP_DIR = process.env.NTRP_HOME ? resolve(process.env.NTRP_HOME) : join(homedir(), ".ntrp");
71
+ CONFIG_PATH = join(NTRP_DIR, "config.json");
72
+ cachedConfig = null;
73
+ }
74
+ });
75
+
28
76
  // src/services/transcript-smoke.ts
29
- import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "fs";
30
- import { join as join3 } from "path";
77
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync6 } from "fs";
78
+ import { join as join5 } from "path";
31
79
 
32
80
  // src/services/terminal-capture.ts
33
81
  var MAX_LINES_DEFAULT = 2e4;
@@ -252,18 +300,91 @@ var TerminalCapture = class {
252
300
  };
253
301
 
254
302
  // src/services/transcript.ts
255
- import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync3, rmSync as rmSync2 } from "fs";
256
- import { join as join2 } from "path";
303
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync5, rmSync as rmSync3 } from "fs";
304
+ import { join as join4 } from "path";
257
305
 
258
306
  // src/cli/context.ts
259
- import { basename, join, resolve, sep } from "path";
260
- import { existsSync, mkdirSync, writeFileSync as writeFileSync2, readFileSync, readdirSync, statSync, rmSync } from "fs";
261
- import { homedir } from "os";
262
- import { randomUUID } from "crypto";
307
+ import { basename as basename2, join as join3, resolve as resolve4, sep as sep3 } from "path";
308
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync2, rmSync as rmSync2 } from "fs";
309
+ import { homedir as homedir3 } from "os";
310
+ import { randomUUID as randomUUID2 } from "crypto";
263
311
 
264
312
  // src/services/context-doc.ts
265
- import { writeFileSync } from "fs";
313
+ import { writeFileSync as writeFileSync3 } from "fs";
266
314
  init_formatters();
315
+ init_store();
316
+
317
+ // src/services/exports-registry.ts
318
+ init_store();
319
+ import {
320
+ appendFileSync,
321
+ copyFileSync,
322
+ cpSync,
323
+ existsSync as existsSync2,
324
+ mkdirSync as mkdirSync2,
325
+ readFileSync as readFileSync2,
326
+ readdirSync,
327
+ renameSync,
328
+ rmSync,
329
+ statSync,
330
+ writeFileSync as writeFileSync2
331
+ } from "fs";
332
+ import { basename, dirname, join as join2, resolve as resolve3, sep as sep2 } from "path";
333
+ import { randomUUID } from "crypto";
334
+
335
+ // src/output/path-safety.ts
336
+ init_store();
337
+ import { homedir as homedir2 } from "os";
338
+ import { resolve as resolve2, sep } from "path";
339
+ var NTRP_HOME = ntrpHome();
340
+
341
+ // src/services/exports-registry.ts
342
+ var KIND_DIRS = ["handoffs", "reports", "notes", "csv", "publish"];
343
+ function ensureExportsLayout(root = getExportsDir()) {
344
+ mkdirSync2(root, { recursive: true });
345
+ mkdirSync2(join2(root, "latest"), { recursive: true });
346
+ for (const sub of KIND_DIRS) {
347
+ mkdirSync2(join2(root, sub), { recursive: true });
348
+ }
349
+ const readme = join2(root, "README.md");
350
+ if (!existsSync2(readme)) {
351
+ writeFileSync2(readme, ARCHIVE_README, "utf-8");
352
+ }
353
+ if (!existsSync2(join2(root, "INDEX.md"))) {
354
+ writeFileSync2(join2(root, "INDEX.md"), "# NTRP exports\n\n_No exports yet._\n", "utf-8");
355
+ }
356
+ if (!existsSync2(join2(root, "manifest.jsonl"))) {
357
+ writeFileSync2(join2(root, "manifest.jsonl"), "", "utf-8");
358
+ }
359
+ return root;
360
+ }
361
+ function getAiInboxDir() {
362
+ return getConfiguredAiInboxDir();
363
+ }
364
+ var ARCHIVE_README = `# NTRP exports archive
365
+
366
+ Handoffs, reports, notes, CSV receipts, and publish packages land here by kind:
367
+
368
+ - \`handoffs/\` \u2014 agent prompts (\`handoff-deck-*.md\`, \u2026)
369
+ - \`reports/\` \u2014 markdown reports
370
+ - \`notes/\` \u2014 Obsidian-style notes
371
+ - \`csv/\` \u2014 backmeup receipt folders
372
+ - \`publish/\` \u2014 repository export packages
373
+ - \`latest/\` \u2014 stable copies of the newest file per kind
374
+
375
+ \`INDEX.md\` is regenerated from \`manifest.jsonl\` on every write/move.
376
+
377
+ Point a desktop AI app at a dedicated inbox instead of this folder:
378
+
379
+ \`\`\`
380
+ /inbox set ~/Documents/Claude/ntrp-inbox
381
+ \`\`\`
382
+ `;
383
+ function archiveIndexPath() {
384
+ return join2(ensureExportsLayout(), "INDEX.md");
385
+ }
386
+
387
+ // src/services/context-doc.ts
267
388
  var AGENT_EXCERPT_CHARS = 400;
268
389
  function buildSessionContextDoc(file, opts = {}) {
269
390
  const id = file.id;
@@ -376,6 +497,21 @@ function buildSessionContextDoc(file, opts = {}) {
376
497
  lines.push("- None yet.");
377
498
  }
378
499
  lines.push("");
500
+ lines.push("## Exports");
501
+ lines.push("");
502
+ try {
503
+ lines.push(`- Archive index: \`${archiveIndexPath()}\``);
504
+ lines.push(`- Archive root: \`${getExportsDir()}\``);
505
+ const inbox = getAiInboxDir();
506
+ if (inbox) {
507
+ lines.push(`- AI inbox: \`${inbox}\` (open \`latest-handoff.md\` or \`INDEX.md\`)`);
508
+ } else {
509
+ lines.push("- AI inbox: unset \u2014 `/inbox set <folder>` for Claude Desktop");
510
+ }
511
+ } catch {
512
+ lines.push("- Export catalog unavailable.");
513
+ }
514
+ lines.push("");
379
515
  lines.push(`## Conversation (${exchanges} exchange${exchanges === 1 ? "" : "s"})`);
380
516
  lines.push("");
381
517
  if (file.messages.length === 0) {
@@ -418,7 +554,7 @@ function writeSessionContextDoc(ctx) {
418
554
  try {
419
555
  const file = buildSessionFileSnapshot(ctx);
420
556
  const doc2 = buildSessionContextDoc(file, { snapshot: ctx.snapshot.computeResult });
421
- writeFileSync(contextDocPathForSession(ctx.sessionId), doc2);
557
+ writeFileSync3(contextDocPathForSession(ctx.sessionId), doc2);
422
558
  } catch {
423
559
  }
424
560
  }
@@ -426,30 +562,30 @@ function writeSessionContextDoc(ctx) {
426
562
  // src/cli/context.ts
427
563
  var STALE_SESSION_MS = 14 * 24 * 60 * 60 * 1e3;
428
564
  function ntrpHomeDir() {
429
- return process.env.NTRP_HOME ? resolve(process.env.NTRP_HOME) : join(homedir(), ".ntrp");
565
+ return process.env.NTRP_HOME ? resolve4(process.env.NTRP_HOME) : join3(homedir3(), ".ntrp");
430
566
  }
431
567
  function getSessionsDir() {
432
- const dir = join(ntrpHomeDir(), "sessions");
433
- if (!existsSync(dir)) {
434
- mkdirSync(dir, { recursive: true });
568
+ const dir = join3(ntrpHomeDir(), "sessions");
569
+ if (!existsSync3(dir)) {
570
+ mkdirSync3(dir, { recursive: true });
435
571
  }
436
572
  return dir;
437
573
  }
438
574
  function getDatasetsDir() {
439
- const dir = join(ntrpHomeDir(), "datasets");
440
- if (!existsSync(dir)) {
441
- mkdirSync(dir, { recursive: true });
575
+ const dir = join3(ntrpHomeDir(), "datasets");
576
+ if (!existsSync3(dir)) {
577
+ mkdirSync3(dir, { recursive: true });
442
578
  }
443
579
  return dir;
444
580
  }
445
581
  function datasetPathForSession(id) {
446
- return join(getDatasetsDir(), `${id}.duckdb`);
582
+ return join3(getDatasetsDir(), `${id}.duckdb`);
447
583
  }
448
584
  function transcriptPathForSession(id) {
449
- return join(getSessionsDir(), `${id}.transcript.md`);
585
+ return join3(getSessionsDir(), `${id}.transcript.md`);
450
586
  }
451
587
  function contextDocPathForSession(id) {
452
- return join(getSessionsDir(), `${id}.context.md`);
588
+ return join3(getSessionsDir(), `${id}.context.md`);
453
589
  }
454
590
  function buildSessionFileSnapshot(ctx) {
455
591
  const file = {
@@ -468,6 +604,7 @@ function buildSessionFileSnapshot(ctx) {
468
604
  if (ctx.attachments && ctx.attachments.length > 0) file.attachments = ctx.attachments;
469
605
  if (ctx.llm && Object.keys(ctx.llm).length > 0) file.llm = ctx.llm;
470
606
  if (ctx.strategistState) file.strategist = ctx.strategistState;
607
+ if (ctx.pendingAsk) file.pending_ask = ctx.pendingAsk;
471
608
  return file;
472
609
  }
473
610
  function defaultSessionAnalysis(primary = "gtm_health") {
@@ -488,8 +625,8 @@ function startSessionTranscript(ctx) {
488
625
  }
489
626
  function rebindSessionTranscript(ctx) {
490
627
  if (!state || state.sessionId === ctx.sessionId) return;
491
- const priorJson = join2(getSessionsDir(), `${state.sessionId}.json`);
492
- if (existsSync2(priorJson)) {
628
+ const priorJson = join4(getSessionsDir(), `${state.sessionId}.json`);
629
+ if (existsSync4(priorJson)) {
493
630
  finalizeCurrentFile("switched session");
494
631
  } else {
495
632
  discardSessionTranscript(state.sessionId);
@@ -509,7 +646,7 @@ function discardSessionTranscript(sessionId) {
509
646
  clearFlushTimer();
510
647
  }
511
648
  try {
512
- rmSync2(transcriptPathForSession(sessionId), { force: true });
649
+ rmSync3(transcriptPathForSession(sessionId), { force: true });
513
650
  } catch {
514
651
  }
515
652
  }
@@ -563,9 +700,9 @@ function removeTees() {
563
700
  function createState(sessionId) {
564
701
  const filePath = transcriptPathForSession(sessionId);
565
702
  let base = "";
566
- if (existsSync2(filePath)) {
703
+ if (existsSync4(filePath)) {
567
704
  try {
568
- base = readFileSync2(filePath, "utf-8").trimEnd() + "\n";
705
+ base = readFileSync4(filePath, "utf-8").trimEnd() + "\n";
569
706
  } catch {
570
707
  base = "";
571
708
  }
@@ -625,7 +762,7 @@ function flushNow(closedNote) {
625
762
  s.lastFlushMs = Date.now();
626
763
  try {
627
764
  getSessionsDir();
628
- writeFileSync3(s.filePath, render(s, closedNote));
765
+ writeFileSync5(s.filePath, render(s, closedNote));
629
766
  } catch {
630
767
  }
631
768
  }
@@ -841,13 +978,13 @@ pauseTranscriptCapture();
841
978
  console.log("PROMPT-ECHO-NOISE-SHOULD-NOT-APPEAR");
842
979
  resumeTranscriptCapture();
843
980
  noteTranscriptInput("ntrp \u203A ", "use demo data");
844
- writeFileSync4(join3(getSessionsDir(), `${ctxA.sessionId}.json`), "{}\n");
845
- writeFileSync4(join3(getSessionsDir(), `${ctxB.sessionId}.json`), "{}\n");
981
+ writeFileSync6(join5(getSessionsDir(), `${ctxA.sessionId}.json`), "{}\n");
982
+ writeFileSync6(join5(getSessionsDir(), `${ctxB.sessionId}.json`), "{}\n");
846
983
  rebindSessionTranscript(ctxB);
847
984
  console.log("hello from session B");
848
985
  stopSessionTranscript();
849
- var fileA = readFileSync3(transcriptPathForSession(ctxA.sessionId), "utf-8");
850
- var fileB = readFileSync3(transcriptPathForSession(ctxB.sessionId), "utf-8");
986
+ var fileA = readFileSync5(transcriptPathForSession(ctxA.sessionId), "utf-8");
987
+ var fileB = readFileSync5(transcriptPathForSession(ctxB.sessionId), "utf-8");
851
988
  assert(fileA.includes("# ntrp transcript \u2014 2026-07-29-aaaa"), "transcript A header");
852
989
  assert(fileA.includes("hello from session A"), "stdout captured in A");
853
990
  assert(fileA.includes("stderr also captured"), "stderr captured in A");
@@ -862,7 +999,7 @@ assert(fileB.includes("(session closed)"), "B closed on stop");
862
999
  startSessionTranscript(ctxA);
863
1000
  console.log("picked this back up");
864
1001
  stopSessionTranscript();
865
- var fileA2 = readFileSync3(transcriptPathForSession(ctxA.sessionId), "utf-8");
1002
+ var fileA2 = readFileSync5(transcriptPathForSession(ctxA.sessionId), "utf-8");
866
1003
  assert(fileA2.includes("hello from session A"), "continue keeps prior history");
867
1004
  assert(fileA2.includes("## Continued \u2014 "), "continue adds Continued segment");
868
1005
  assert(fileA2.includes("picked this back up"), "continue captures new output");
@@ -871,19 +1008,19 @@ startSessionTranscript(ctxC);
871
1008
  console.log("ephemeral");
872
1009
  discardSessionTranscript(ctxC.sessionId);
873
1010
  stopSessionTranscript();
874
- assert(!existsSync3(transcriptPathForSession(ctxC.sessionId)), "discarded transcript deleted");
1011
+ assert(!existsSync5(transcriptPathForSession(ctxC.sessionId)), "discarded transcript deleted");
875
1012
  var ctxE = makeCtx("2026-07-29-eeee");
876
1013
  var ctxF = makeCtx("2026-07-29-ffff");
877
1014
  startSessionTranscript(ctxE);
878
1015
  console.log("throwaway shell before pickup");
879
1016
  rebindSessionTranscript(ctxF);
880
1017
  stopSessionTranscript();
881
- assert(!existsSync3(transcriptPathForSession(ctxE.sessionId)), "unpersisted session transcript discarded on switch");
1018
+ assert(!existsSync5(transcriptPathForSession(ctxE.sessionId)), "unpersisted session transcript discarded on switch");
882
1019
  var ctxD = makeCtx("2026-07-29-dddd");
883
1020
  ctxD.messages.push({ role: "user", content: "why is freshness red?", at: (/* @__PURE__ */ new Date()).toISOString() });
884
1021
  ctxD.messages.push({ role: "agent", content: "Because 61% of contacts are stale.", at: (/* @__PURE__ */ new Date()).toISOString() });
885
1022
  writeSessionContextDoc(ctxD);
886
- var docD = readFileSync3(contextDocPathForSession(ctxD.sessionId), "utf-8");
1023
+ var docD = readFileSync5(contextDocPathForSession(ctxD.sessionId), "utf-8");
887
1024
  assert(docD.includes("# Session context \u2014 2026-07-29-dddd"), "live context doc written");
888
1025
  assert(docD.includes("why is freshness red?"), "live context doc includes exchange");
889
1026
  if (failures.length > 0) {