@gmickel/gno 2.4.0 → 2.6.0

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.
Files changed (136) hide show
  1. package/README.md +69 -6
  2. package/assets/skill/README.md +5 -1
  3. package/assets/skill/SKILL.md +98 -1
  4. package/assets/skill/cli-reference.md +140 -0
  5. package/assets/skill/examples.md +45 -0
  6. package/assets/skill/mcp-reference.md +62 -0
  7. package/assets/skill/recipes/capture-and-file.md +6 -0
  8. package/assets/skill/recipes/memory-file-decision.md +6 -0
  9. package/assets/skill/recipes/memory-supersede-fact.md +5 -0
  10. package/assets/skill/recipes/session-evidence-lookup.md +98 -0
  11. package/assets/spa-production.json.gz +0 -0
  12. package/browser-extension/artifacts/{gno-browser-clipper-v2.4.0.zip → gno-browser-clipper-v2.6.0.zip} +0 -0
  13. package/browser-extension/artifacts/gno-browser-clipper-v2.6.0.zip.sha256 +1 -0
  14. package/browser-extension/dist/manifest.json +1 -1
  15. package/package.json +2 -1
  16. package/spec/cli.md +358 -24
  17. package/spec/compiled-context.md +68 -0
  18. package/spec/mcp.md +223 -2
  19. package/spec/output-schemas/capture-receipt.schema.json +3 -0
  20. package/spec/output-schemas/compiled-context-check.schema.json +44 -0
  21. package/spec/output-schemas/compiled-context-file.schema.json +165 -0
  22. package/spec/output-schemas/compiled-context-preview.schema.json +142 -0
  23. package/spec/output-schemas/mcp-capture-result.schema.json +3 -0
  24. package/spec/output-schemas/memory-remember.schema.json +8 -2
  25. package/spec/output-schemas/request-status.schema.json +113 -0
  26. package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
  27. package/spec/output-schemas/sessions-discovery.schema.json +38 -0
  28. package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
  29. package/spec/output-schemas/sessions-status.schema.json +432 -0
  30. package/src/app/compiled-context-files.ts +361 -0
  31. package/src/app/compiled-context.ts +240 -0
  32. package/src/app/context-surface.ts +7 -2
  33. package/src/cli/commands/ask.ts +14 -2
  34. package/src/cli/commands/audit.ts +4 -0
  35. package/src/cli/commands/capture.ts +55 -96
  36. package/src/cli/commands/context-compiled.ts +136 -0
  37. package/src/cli/commands/daemon.ts +41 -0
  38. package/src/cli/commands/ls.ts +3 -0
  39. package/src/cli/commands/memory.ts +12 -3
  40. package/src/cli/commands/request-status.ts +59 -0
  41. package/src/cli/commands/reset.ts +39 -5
  42. package/src/cli/commands/sessions.ts +713 -0
  43. package/src/cli/commands/shared.ts +14 -1
  44. package/src/cli/errors.ts +10 -3
  45. package/src/cli/program.ts +458 -1
  46. package/src/cli/session-binding.ts +49 -0
  47. package/src/config/types.ts +8 -0
  48. package/src/core/capture-publish.ts +239 -0
  49. package/src/core/capture-sync.ts +3 -0
  50. package/src/core/compiled-context.ts +254 -0
  51. package/src/core/context-budget.ts +2 -10
  52. package/src/core/file-lock.ts +22 -5
  53. package/src/core/folder-setup-planning.ts +2 -1
  54. package/src/core/memory-remember.ts +233 -122
  55. package/src/core/memory-types.ts +11 -0
  56. package/src/core/network-boundary-inventory.ts +16 -0
  57. package/src/core/request-receipts.ts +671 -0
  58. package/src/core/setup-receipt.ts +27 -20
  59. package/src/core/typed-metadata.ts +4 -0
  60. package/src/core/validation.ts +9 -2
  61. package/src/core/windows-private-path.ts +96 -0
  62. package/src/index.ts +11 -2
  63. package/src/ingestion/compiled-context.ts +15 -0
  64. package/src/ingestion/sync.ts +40 -8
  65. package/src/ingestion/walker.ts +5 -4
  66. package/src/llm/nodeLlamaCpp/simulator-install.ts +6 -2
  67. package/src/mcp/context.ts +8 -0
  68. package/src/mcp/http-egress.ts +15 -3
  69. package/src/mcp/http-transport.ts +2 -0
  70. package/src/mcp/retrieval-warnings.ts +24 -0
  71. package/src/mcp/tools/ask.ts +14 -1
  72. package/src/mcp/tools/capture.ts +87 -83
  73. package/src/mcp/tools/context.ts +46 -2
  74. package/src/mcp/tools/index.ts +119 -7
  75. package/src/mcp/tools/memory-remember.ts +7 -0
  76. package/src/mcp/tools/memory-shared.ts +7 -1
  77. package/src/mcp/tools/query.ts +3 -1
  78. package/src/mcp/tools/request-status.ts +73 -0
  79. package/src/mcp/tools/search.ts +3 -1
  80. package/src/mcp/tools/sessions.ts +208 -0
  81. package/src/mcp/tools/vsearch.ts +3 -1
  82. package/src/sdk/client.ts +252 -89
  83. package/src/sdk/index.ts +13 -0
  84. package/src/sdk/types.ts +82 -3
  85. package/src/serve/capture-service.ts +98 -32
  86. package/src/serve/compiled-context.ts +84 -0
  87. package/src/serve/config-sync.ts +3 -2
  88. package/src/serve/public/app.tsx +15 -1
  89. package/src/serve/public/components/CaptureModal.tsx +26 -8
  90. package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
  91. package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
  92. package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
  93. package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
  94. package/src/serve/public/components/sessions/api.ts +40 -0
  95. package/src/serve/public/components/sessions/snippet.tsx +53 -0
  96. package/src/serve/public/globals.built.css +1 -1
  97. package/src/serve/public/hooks/use-api.ts +10 -2
  98. package/src/serve/public/lib/request-intent.ts +69 -0
  99. package/src/serve/public/lib/workspace-actions.ts +12 -1
  100. package/src/serve/public/lib/workspace-tabs.ts +4 -0
  101. package/src/serve/public/pages/CompiledContext.tsx +364 -0
  102. package/src/serve/public/pages/Dashboard.tsx +17 -0
  103. package/src/serve/public/pages/DocView.tsx +15 -1
  104. package/src/serve/public/pages/DocumentEditor.tsx +139 -96
  105. package/src/serve/public/pages/Sessions.tsx +350 -0
  106. package/src/serve/resident-runtime.ts +43 -3
  107. package/src/serve/routes/api.ts +476 -147
  108. package/src/serve/routes/sessions.ts +766 -0
  109. package/src/serve/security.ts +9 -0
  110. package/src/serve/server.ts +248 -3
  111. package/src/serve/session-automation.ts +146 -0
  112. package/src/serve/spa-production-build.ts +6 -5
  113. package/src/sessions/archive.ts +348 -0
  114. package/src/sessions/automation-state.ts +444 -0
  115. package/src/sessions/automation-status.ts +239 -0
  116. package/src/sessions/automation.ts +1169 -0
  117. package/src/sessions/binding.ts +105 -0
  118. package/src/sessions/claude-hook.ts +240 -0
  119. package/src/sessions/config.ts +176 -0
  120. package/src/sessions/format.ts +191 -0
  121. package/src/sessions/import-child-env.ts +8 -0
  122. package/src/sessions/import-child.ts +152 -0
  123. package/src/sessions/parsers/claude-code.ts +259 -0
  124. package/src/sessions/parsers/codex.ts +303 -0
  125. package/src/sessions/parsers/hermes.ts +248 -0
  126. package/src/sessions/parsers/openclaw.ts +496 -0
  127. package/src/sessions/parsers/shared.ts +184 -0
  128. package/src/sessions/sanitize.ts +222 -0
  129. package/src/sessions/service.ts +1533 -0
  130. package/src/sessions/setup.ts +477 -0
  131. package/src/sessions/sources.ts +518 -0
  132. package/src/sessions/state.ts +118 -0
  133. package/src/sessions/types.ts +457 -0
  134. package/src/store/sqlite/adapter.ts +68 -16
  135. package/src/store/sqlite/scoped-index.ts +9 -0
  136. package/browser-extension/artifacts/gno-browser-clipper-v2.4.0.zip.sha256 +0 -1
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Text renderings of session receipts and status, shared by every text
3
+ * surface (CLI terminal output, MCP text content).
4
+ *
5
+ * @module src/sessions/format
6
+ */
7
+
8
+ import type {
9
+ SessionAutomationRunResult,
10
+ SessionAutomationStatus,
11
+ SessionImportReceipt,
12
+ SessionsStatus,
13
+ } from "./types";
14
+
15
+ /** Plain-text import receipt shared by the CLI and MCP. */
16
+ export function formatImportReceiptText(receipt: SessionImportReceipt): string {
17
+ const c = receipt.counts;
18
+ const lines = [
19
+ `Session import ${receipt.dryRun ? "(dry run, nothing written) " : ""}${receipt.status}`,
20
+ `threads: ${c.imported} imported, ${c.updated} updated, ${c.unchanged} unchanged, ${c.skippedPolicy} skipped by policy`,
21
+ `units: ${c.incomplete} incomplete, ${c.failed} failed, ${c.unsupported} unsupported${receipt.deferredUnits > 0 ? `, ${receipt.deferredUnits} deferred by limit` : ""}`,
22
+ `turns: ${receipt.turns.human} human, ${receipt.turns.assistant} assistant, ${receipt.turns.redactions} redactions, ${receipt.turns.injectedSkipped} injected skipped`,
23
+ `lexical: ${receipt.lexical.status}${receipt.lexical.error ? ` (${receipt.lexical.error})` : ""}; embedding backlog: ${receipt.embedding.backlog ?? "n/a"}`,
24
+ ];
25
+ const notable = receipt.units.filter(
26
+ (unit) =>
27
+ unit.outcome !== "unchanged" &&
28
+ unit.outcome !== "imported" &&
29
+ unit.outcome !== "updated"
30
+ );
31
+ for (const unit of notable.slice(0, 20)) {
32
+ lines.push(
33
+ `- ${unit.sourceId} ${unit.locator}: ${unit.outcome}${unit.reason ? ` (${unit.reason})` : ""}`
34
+ );
35
+ }
36
+ for (const warning of receipt.warnings) lines.push(`warning: ${warning}`);
37
+ return lines.join("\n");
38
+ }
39
+
40
+ /** Plain-text archive status shared by the CLI and MCP. */
41
+ export function formatStatusText(status: SessionsStatus): string {
42
+ const lines = [`Session archive (index ${status.index})`];
43
+ for (const collection of status.collections) {
44
+ lines.push(
45
+ `- collection ${collection.name}: ${collection.threads} threads`
46
+ );
47
+ }
48
+ for (const source of status.sources) {
49
+ lines.push(
50
+ `- source ${source.id} (${source.harness} -> ${source.collection}): ${source.available ? "available" : "UNAVAILABLE"}; units ${source.units.complete} complete, ${source.units.incomplete} incomplete, ${source.units.failed} failed, ${source.units.pending} pending; last import ${source.lastImportAt ?? "never"}`
51
+ );
52
+ if (source.sourceUnavailable > 0) {
53
+ lines.push(
54
+ ` ${source.sourceUnavailable} archived units no longer have a source (archive retained${source.staleParser > 0 ? `, ${source.staleParser} from an older parser` : ""})`
55
+ );
56
+ }
57
+ }
58
+ lines.push(...formatAutomationLines(status.automation));
59
+ for (const warning of status.warnings) lines.push(`warning: ${warning}`);
60
+ return lines.join("\n");
61
+ }
62
+
63
+ /**
64
+ * When a failed run is retried. Only a running daemon retries, so without one
65
+ * no time is promised, and a due time already passed is not shown as upcoming.
66
+ */
67
+ export function retryWording(
68
+ retryAt: string | null,
69
+ daemonRunning: boolean,
70
+ now: Date,
71
+ at: (iso: string | null) => string
72
+ ): string | null {
73
+ if (!retryAt) return null;
74
+ if (!daemonRunning) {
75
+ return "retried when the daemon runs, or run the profile again yourself";
76
+ }
77
+ return Date.parse(retryAt) <= now.getTime()
78
+ ? "retry due on the daemon's next tick"
79
+ : `retried automatically at ${at(retryAt)}`;
80
+ }
81
+
82
+ /** UTC instant shown in the reader's timezone, e.g. `2026-09-24 15:04:05`. */
83
+ export function formatLocalTime(iso: string | null, timezone: string): string {
84
+ if (!iso) return "never";
85
+ const date = new Date(iso);
86
+ if (Number.isNaN(date.getTime())) return iso;
87
+ return date.toLocaleString("sv-SE", { timeZone: timezone });
88
+ }
89
+
90
+ function daemonLine(
91
+ automation: SessionAutomationStatus,
92
+ at: (iso: string | null) => string
93
+ ): string {
94
+ if (automation.daemon.state === "running") {
95
+ return `daemon running (heartbeat ${at(automation.daemon.heartbeatAt)})`;
96
+ }
97
+ if (automation.daemon.state === "stale") {
98
+ return `daemon stale (last heartbeat ${at(automation.daemon.heartbeatAt)})`;
99
+ }
100
+ return "not running: no daemon";
101
+ }
102
+
103
+ function formatAutomationLines(automation: SessionAutomationStatus): string[] {
104
+ if (automation.profiles.length === 0) {
105
+ return ["Automation: off (manual imports only)"];
106
+ }
107
+ const at = (iso: string | null) => formatLocalTime(iso, automation.timezone);
108
+ const lines = [
109
+ `Automation (${daemonLine(automation, at)}; times in ${automation.timezone})`,
110
+ ];
111
+ for (const profile of automation.profiles) {
112
+ const hookMissing =
113
+ profile.hook?.enabled && profile.hook.installed === false
114
+ ? " (entry missing)"
115
+ : "";
116
+ const hook = profile.hook
117
+ ? `hook ${profile.hook.harness} ${profile.hook.enabled ? "on" : "off"}${hookMissing}`
118
+ : "hook off";
119
+ let schedule = "schedule off";
120
+ if (profile.schedule?.enabled) {
121
+ let next = "not running: no daemon";
122
+ if (profile.schedule.nextDueAt) {
123
+ next = `next ${at(profile.schedule.nextDueAt)}`;
124
+ } else if (automation.daemon.state === "running") {
125
+ next = "due time set on the daemon's next tick";
126
+ }
127
+ schedule = `schedule every ${profile.schedule.cadence}, ${next}`;
128
+ }
129
+ lines.push(
130
+ `- profile ${profile.id}: ${profile.state}; sources ${profile.sources.join(", ")} -> ${profile.collections.join(", ") || "(none)"}; ${hook}; ${schedule}`
131
+ );
132
+ // A failure awaiting correction is shown by state and action, not as
133
+ // queued work.
134
+ if (profile.pending && profile.state !== "failed") {
135
+ const wording = retryWording(
136
+ profile.retryAt,
137
+ automation.daemon.state === "running",
138
+ new Date(),
139
+ at
140
+ );
141
+ const retry = wording ? `; ${wording}` : "";
142
+ lines.push(
143
+ ` pending since ${at(profile.pending.since)} (${profile.pending.triggers.join(", ") || "continuation"})${retry}`
144
+ );
145
+ }
146
+ if (profile.running) {
147
+ lines.push(` running since ${at(profile.running.startedAt)}`);
148
+ }
149
+ if (profile.lastRun) {
150
+ const run = profile.lastRun;
151
+ lines.push(
152
+ ` last run ${at(run.finishedAt)}: ${run.outcome}${run.reason ? ` (${run.reason})` : ""}; threads ${run.threads.imported} imported, ${run.threads.updated} updated; units ${run.units.incomplete} incomplete, ${run.units.failed} failed, ${run.units.deferred} deferred`
153
+ );
154
+ }
155
+ lines.push(` last success ${at(profile.lastSuccessAt)}`);
156
+ if (profile.recovery) lines.push(` action: ${profile.recovery}`);
157
+ }
158
+ return lines;
159
+ }
160
+
161
+ /** What follows a run: remaining work, or the next step after a failure. */
162
+ const RETRYABLE_REASONS = new Set(["busy", "runtime_error", "interrupted"]);
163
+
164
+ export function runTail(result: SessionAutomationRunResult): string {
165
+ if (result.outcome === "failed") {
166
+ return RETRYABLE_REASONS.has(result.reason ?? "")
167
+ ? "; a running daemon retries it, or run it again once the archive is free (see gno sessions status)"
168
+ : "; see gno sessions status for the recovery action";
169
+ }
170
+ return result.pending ? "; more work is pending" : "";
171
+ }
172
+
173
+ /** Plain-text automation run result shared by the CLI and MCP. */
174
+ export function formatAutomationRunText(
175
+ result: SessionAutomationRunResult
176
+ ): string {
177
+ if (!result.ran) {
178
+ return `Automation profile ${result.profileId}: not started (${result.reason ?? "unknown"})${result.pending ? "; work stays pending" : ""}`;
179
+ }
180
+ const lines = [
181
+ `Automation profile ${result.profileId}: ${result.outcome}${result.reason ? ` (${result.reason})` : ""}${runTail(result)}`,
182
+ ];
183
+ for (const receipt of result.receipts) {
184
+ const deferred =
185
+ receipt.deferredUnits > 0 ? `, ${receipt.deferredUnits} deferred` : "";
186
+ lines.push(
187
+ `- ${receipt.sourceIds.join(", ")}: ${receipt.status}; ${receipt.counts.imported} imported, ${receipt.counts.updated} updated, ${receipt.counts.unchanged} unchanged; ${receipt.counts.incomplete} incomplete, ${receipt.counts.failed} failed${deferred}`
188
+ );
189
+ }
190
+ return lines.join("\n");
191
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Environment flag a compiled executable sets when it re-runs itself as the
3
+ * session import child. Dependency-free so the CLI entry can check it
4
+ * without loading the sessions service.
5
+ *
6
+ * @module src/sessions/import-child-env
7
+ */
8
+ export const IMPORT_CHILD_ENV = "GNO_INTERNAL_SESSION_IMPORT_CHILD";
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Run a registered-source import in a child process.
3
+ *
4
+ * Parsing, sanitizing and above all the index sync of thousands of archive
5
+ * records are CPU-bound and synchronous (each archive file syncs in one
6
+ * SQLite transaction), so a long-lived server that imported in-process would
7
+ * stop answering every request until the import finished. `gno serve` runs
8
+ * the same `SessionsService.import` here instead, in a child with its own
9
+ * store connection: the archive import lock, receipts and error codes are
10
+ * unchanged.
11
+ *
12
+ * The child reads one request from stdin and writes one JSON result line to
13
+ * stdout. The request carries the caller's loaded config: the child opens
14
+ * the index without syncing any config into it and imports with exactly the
15
+ * config the server holds. From source the child runs this module; a
16
+ * compiled executable re-runs itself with `IMPORT_CHILD_ENV` set, which the
17
+ * CLI entry routes here before any command runs.
18
+ *
19
+ * @module src/sessions/import-child
20
+ */
21
+
22
+ import type { Config } from "../config/types";
23
+ import type { SessionImportReceipt } from "./types";
24
+
25
+ import { getIndexDbPath } from "../app/constants";
26
+ import { isStandaloneExecutable } from "../serve/spa-production-build";
27
+ import { SqliteAdapter } from "../store/sqlite/adapter";
28
+ import { assertSessionBinding } from "./binding";
29
+ import { IMPORT_CHILD_ENV } from "./import-child-env";
30
+ import { SessionsService } from "./service";
31
+ import { type SessionsErrorCode, SessionsError } from "./types";
32
+
33
+ export interface ImportChildRequest {
34
+ config: Config;
35
+ configPath: string;
36
+ indexName: string;
37
+ sourceId: string;
38
+ dryRun: boolean;
39
+ limit?: number;
40
+ }
41
+
42
+ type ImportChildResult =
43
+ | { ok: true; receipt: SessionImportReceipt }
44
+ | { ok: false; code: SessionsErrorCode | null; message: string };
45
+
46
+ async function importInThisProcess(
47
+ request: ImportChildRequest
48
+ ): Promise<SessionImportReceipt> {
49
+ const input = {
50
+ sourceId: request.sourceId,
51
+ dryRun: request.dryRun,
52
+ limit: request.limit,
53
+ };
54
+ if (request.dryRun) {
55
+ return new SessionsService({
56
+ config: request.config,
57
+ configPath: request.configPath,
58
+ indexName: request.indexName,
59
+ }).import(input, { allowPaths: false });
60
+ }
61
+ const dbPath = getIndexDbPath(request.indexName);
62
+ await assertSessionBinding({
63
+ config: request.config,
64
+ configPath: request.configPath,
65
+ indexName: request.indexName,
66
+ dbPath,
67
+ });
68
+ // The server already projected this config into the index; the child only
69
+ // opens it (no collection/context sync).
70
+ const store = new SqliteAdapter();
71
+ store.setConfigPath(request.configPath);
72
+ const opened = await store.open(
73
+ dbPath,
74
+ request.config.ftsTokenizer,
75
+ request.config.busyTimeoutMs
76
+ );
77
+ if (!opened.ok) throw new Error(opened.error.message);
78
+ try {
79
+ return await new SessionsService({
80
+ config: request.config,
81
+ configPath: request.configPath,
82
+ indexName: request.indexName,
83
+ store,
84
+ }).import(input, { allowPaths: false });
85
+ } finally {
86
+ await store.close();
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Import a registered source without blocking the caller's event loop.
92
+ * Throws a `SessionsError` for typed failures and a plain `Error` otherwise.
93
+ */
94
+ export async function importInChildProcess(
95
+ request: ImportChildRequest
96
+ ): Promise<SessionImportReceipt> {
97
+ // A compiled executable cannot run an external TS entry: it re-runs itself.
98
+ // Shared check: covers the POSIX `/$bunfs/` and Windows `B:/~BUN/` roots.
99
+ const compiled = isStandaloneExecutable();
100
+ const child = Bun.spawn({
101
+ cmd: compiled ? [process.execPath] : [process.execPath, import.meta.path],
102
+ env: compiled ? { ...process.env, [IMPORT_CHILD_ENV]: "1" } : process.env,
103
+ stdin: new Blob([JSON.stringify(request)]),
104
+ stdout: "pipe",
105
+ stderr: "pipe",
106
+ });
107
+ const [stdout, stderr, exitCode] = await Promise.all([
108
+ new Response(child.stdout).text(),
109
+ new Response(child.stderr).text(),
110
+ child.exited,
111
+ ]);
112
+ const line = stdout.trim().split("\n").at(-1) ?? "";
113
+ let result: ImportChildResult | null = null;
114
+ try {
115
+ result = JSON.parse(line) as ImportChildResult;
116
+ } catch {
117
+ result = null;
118
+ }
119
+ if (!result) {
120
+ throw new Error(
121
+ `session import child exited ${exitCode}: ${stderr.trim().slice(-2000)}`
122
+ );
123
+ }
124
+ if (result.ok) return result.receipt;
125
+ if (result.code) throw new SessionsError(result.code, result.message);
126
+ throw new Error(result.message);
127
+ }
128
+
129
+ /** Child entry: one request on stdin, one JSON result line on stdout. */
130
+ export async function runImportChild(): Promise<void> {
131
+ // Nothing this child starts may re-enter child mode.
132
+ delete process.env[IMPORT_CHILD_ENV];
133
+ let result: ImportChildResult;
134
+ try {
135
+ const request = JSON.parse(
136
+ await new Response(Bun.stdin.stream()).text()
137
+ ) as ImportChildRequest;
138
+ result = { ok: true, receipt: await importInThisProcess(request) };
139
+ } catch (error) {
140
+ result =
141
+ error instanceof SessionsError
142
+ ? { ok: false, code: error.code, message: error.message }
143
+ : {
144
+ ok: false,
145
+ code: null,
146
+ message: error instanceof Error ? error.message : String(error),
147
+ };
148
+ }
149
+ process.stdout.write(`${JSON.stringify(result)}\n`);
150
+ }
151
+
152
+ if (import.meta.main) await runImportChild();
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Claude Code project JSONL parser.
3
+ *
4
+ * Verified layouts (2026-09, CLI 2.1.2xx): `projects/<encoded-cwd>/<sessionId>.jsonl`
5
+ * for the main thread and `.../<sessionId>/subagents/agent-<agentId>.jsonl`
6
+ * for spawned subagents. Records carry `uuid`, `sessionId`, `timestamp`,
7
+ * `cwd`, `version`, `type`.
8
+ *
9
+ * Human speech is structural: `type=user` records whose `origin.kind` is
10
+ * `human`. Files written before the `origin` field existed fall back to a
11
+ * conservative shape test (plain text, not meta, not a compaction summary,
12
+ * no tool result, no command/notification wrapper). Injected attachments,
13
+ * system records, compaction summaries, slash-command wrappers, task
14
+ * notifications and the parent task prompt inside a subagent file are never
15
+ * human speech. A slash command a person typed (`origin.kind=human`) is
16
+ * archived as `/name args`. Prompts of SDK / `claude -p` sessions
17
+ * (`entrypoint=sdk-*`) come from a program and are not attributed to a
18
+ * person. Assistant turns are the text blocks of `type=assistant` records;
19
+ * thinking and tool calls are excluded.
20
+ *
21
+ * @module src/sessions/parsers/claude-code
22
+ */
23
+
24
+ import {
25
+ emptyDiagnostics,
26
+ normalizeTimestamp,
27
+ noteUnknownKind,
28
+ type ParsedThread,
29
+ type ParsedTurn,
30
+ type ParseUnitResult,
31
+ } from "../types";
32
+ import {
33
+ missingHumanTurns,
34
+ isRecord,
35
+ joinTextBlocks,
36
+ pushTurn,
37
+ readJsonlRecords,
38
+ stringField,
39
+ } from "./shared";
40
+
41
+ export const CLAUDE_CODE_PARSER = "claude-code/1";
42
+
43
+ const SKIPPED_TYPES = new Set([
44
+ "attachment",
45
+ "system",
46
+ "summary",
47
+ "file-history-snapshot",
48
+ "file-history-delta",
49
+ "queue-operation",
50
+ "last-prompt",
51
+ "ai-title",
52
+ "custom-title",
53
+ "permission-mode",
54
+ "mode",
55
+ "bridge-session",
56
+ "atis-latch",
57
+ "frame-link",
58
+ "pr-link",
59
+ "cost-state",
60
+ "tag",
61
+ "agent-name",
62
+ "continued-in",
63
+ "artifact-autoreact-ledger",
64
+ "artifact-comment-monitor",
65
+ "progress",
66
+ "fork-context-ref",
67
+ "history-suppression",
68
+ ]);
69
+
70
+ /** Wrappers the CLI writes around non-speech input. */
71
+ const WRAPPER_PREFIXES = [
72
+ "<command-name>",
73
+ "<command-message>",
74
+ "<command-args>",
75
+ "<local-command-",
76
+ "<task-notification>",
77
+ "<system-reminder>",
78
+ "<bash-input>",
79
+ "<bash-stdout>",
80
+ "<bash-stderr>",
81
+ "<user-memory-input>",
82
+ ];
83
+
84
+ const COMMAND_NAME = /<command-name>([^<]{1,256})<\/command-name>/;
85
+ const COMMAND_ARGS = /<command-args>([\s\S]{0,65536}?)<\/command-args>/;
86
+
87
+ /** A human-typed slash command becomes `/name args`; other wrappers stay out. */
88
+ function slashCommandText(text: string): string | undefined {
89
+ const name = COMMAND_NAME.exec(text)?.[1]?.trim();
90
+ if (!name) return undefined;
91
+ const args = COMMAND_ARGS.exec(text)?.[1]?.trim() ?? "";
92
+ const command = name.startsWith("/") ? name : `/${name}`;
93
+ return args ? `${command} ${args}` : command;
94
+ }
95
+
96
+ interface UserCandidate {
97
+ turn: ParsedTurn;
98
+ originKind?: string;
99
+ hasOrigin: boolean;
100
+ plainShape: boolean;
101
+ }
102
+
103
+ const SUBAGENT_FILE = /(?:^|\/)agent-([A-Za-z0-9_-]{1,128})\.jsonl$/;
104
+
105
+ function userText(message: Record<string, unknown>): {
106
+ text?: string;
107
+ hasToolResult: boolean;
108
+ } {
109
+ const content = message.content;
110
+ if (typeof content === "string")
111
+ return { text: content, hasToolResult: false };
112
+ if (!Array.isArray(content)) return { hasToolResult: false };
113
+ const hasToolResult = content.some(
114
+ (block) => isRecord(block) && block.type === "tool_result"
115
+ );
116
+ return { text: joinTextBlocks(content, ["text"]), hasToolResult };
117
+ }
118
+
119
+ export async function parseClaudeCodeSession(
120
+ path: string
121
+ ): Promise<ParseUnitResult> {
122
+ const diagnostics = emptyDiagnostics();
123
+ const subagentMatch = SUBAGENT_FILE.exec(path.replaceAll("\\", "/"));
124
+ const isSubagentFile = Boolean(subagentMatch);
125
+ const ordered: Array<ParsedTurn | UserCandidate> = [];
126
+ let sessionId: string | undefined;
127
+ let agentId: string | undefined = subagentMatch?.[1];
128
+ let firstCwd: string | undefined;
129
+ let fileHasOrigin = false;
130
+ let programmaticEntry = false;
131
+
132
+ for await (const { lineNumber, record } of readJsonlRecords(
133
+ path,
134
+ diagnostics
135
+ )) {
136
+ const type = record.type;
137
+ sessionId ??= stringField(record.sessionId);
138
+ agentId ??= isSubagentFile ? stringField(record.agentId) : undefined;
139
+ const cwd = stringField(record.cwd);
140
+ firstCwd ??= cwd;
141
+ if (diagnostics.formatVersion === undefined) {
142
+ diagnostics.formatVersion = stringField(record.version);
143
+ }
144
+ if (type === "user" && "origin" in record) fileHasOrigin = true;
145
+ // SDK / `claude -p` sessions receive prompts from a program, which may be
146
+ // another agent; they are not attributed to a person.
147
+ const entrypoint = stringField(record.entrypoint);
148
+ if (entrypoint?.startsWith("sdk")) programmaticEntry = true;
149
+
150
+ if (type !== "user" && type !== "assistant") {
151
+ if (typeof type !== "string" || !SKIPPED_TYPES.has(type)) {
152
+ noteUnknownKind(diagnostics, type);
153
+ }
154
+ continue;
155
+ }
156
+ const message = isRecord(record.message) ? record.message : {};
157
+ const base = {
158
+ turnId: stringField(record.uuid) ?? `line:${lineNumber}`,
159
+ timestamp: normalizeTimestamp(record.timestamp),
160
+ locator: `line:${lineNumber}`,
161
+ cwd,
162
+ };
163
+
164
+ // Sidechain records inside a main file are agent-to-agent traffic.
165
+ if (!isSubagentFile && record.isSidechain === true) {
166
+ diagnostics.injectedSkipped += 1;
167
+ continue;
168
+ }
169
+
170
+ if (type === "assistant") {
171
+ const text = joinTextBlocks(message.content, ["text"]);
172
+ if (text !== undefined) {
173
+ ordered.push({ ...base, role: "assistant", text });
174
+ }
175
+ continue;
176
+ }
177
+
178
+ const { text, hasToolResult } = userText(message);
179
+ if (hasToolResult || text === undefined) continue;
180
+ const trimmed = text.trimStart();
181
+ const wrapped = WRAPPER_PREFIXES.some((prefix) =>
182
+ trimmed.startsWith(prefix)
183
+ );
184
+ const plainShape =
185
+ record.isMeta !== true &&
186
+ record.isCompactSummary !== true &&
187
+ record.isVisibleInTranscriptOnly !== true &&
188
+ !wrapped;
189
+ const origin = isRecord(record.origin) ? record.origin : undefined;
190
+ const originKind = stringField(origin?.kind);
191
+ const command =
192
+ originKind === "human" && wrapped && record.isMeta !== true
193
+ ? slashCommandText(trimmed)
194
+ : undefined;
195
+ ordered.push({
196
+ turn: { ...base, role: "human", text: command ?? text },
197
+ originKind,
198
+ hasOrigin: "origin" in record,
199
+ plainShape: plainShape || command !== undefined,
200
+ });
201
+ }
202
+
203
+ const turns: ParsedTurn[] = [];
204
+ for (const entry of ordered) {
205
+ let turn: ParsedTurn;
206
+ if ("turn" in entry) {
207
+ // In a subagent file the "user" message is the parent agent's task.
208
+ const human =
209
+ !isSubagentFile &&
210
+ !programmaticEntry &&
211
+ entry.plainShape &&
212
+ (fileHasOrigin ? entry.originKind === "human" : true);
213
+ if (!human) {
214
+ diagnostics.injectedSkipped += 1;
215
+ continue;
216
+ }
217
+ turn = entry.turn;
218
+ } else {
219
+ turn = entry;
220
+ }
221
+ if (!pushTurn(turns, turn, diagnostics)) break;
222
+ }
223
+
224
+ if (!sessionId) {
225
+ return {
226
+ threads: [],
227
+ diagnostics,
228
+ complete: !diagnostics.truncatedTail,
229
+ parser: CLAUDE_CODE_PARSER,
230
+ };
231
+ }
232
+ if (!(isSubagentFile || programmaticEntry)) {
233
+ diagnostics.humanTurnsMissing = missingHumanTurns(turns);
234
+ }
235
+ const thread: ParsedThread = isSubagentFile
236
+ ? {
237
+ harness: "claude-code",
238
+ threadId: `${sessionId}/agent-${agentId ?? "unknown"}`,
239
+ sessionId,
240
+ parentThreadId: sessionId,
241
+ kind: "subagent",
242
+ cwd: firstCwd,
243
+ turns,
244
+ }
245
+ : {
246
+ harness: "claude-code",
247
+ threadId: sessionId,
248
+ sessionId,
249
+ kind: "main",
250
+ cwd: firstCwd,
251
+ turns,
252
+ };
253
+ return {
254
+ threads: [thread],
255
+ diagnostics,
256
+ complete: !diagnostics.truncatedTail && !diagnostics.humanTurnsMissing,
257
+ parser: CLAUDE_CODE_PARSER,
258
+ };
259
+ }