@gmickel/gno 2.5.1 → 2.7.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 (160) hide show
  1. package/README.md +60 -5
  2. package/assets/skill/README.md +5 -1
  3. package/assets/skill/SKILL.md +80 -4
  4. package/assets/skill/cli-reference.md +132 -2
  5. package/assets/skill/examples.md +30 -0
  6. package/assets/skill/mcp-reference.md +54 -1
  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.5.1.zip → gno-browser-clipper-v2.7.0.zip} +0 -0
  13. package/browser-extension/artifacts/gno-browser-clipper-v2.7.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 +449 -35
  17. package/spec/mcp.md +234 -4
  18. package/spec/output-schemas/ask.schema.json +1 -1
  19. package/spec/output-schemas/capture-receipt.schema.json +4 -1
  20. package/spec/output-schemas/doctor.schema.json +88 -0
  21. package/spec/output-schemas/error.schema.json +11 -2
  22. package/spec/output-schemas/get.schema.json +1 -1
  23. package/spec/output-schemas/mcp-capture-result.schema.json +4 -2
  24. package/spec/output-schemas/memory-remember.schema.json +10 -4
  25. package/spec/output-schemas/multi-get.schema.json +4 -1
  26. package/spec/output-schemas/peek.schema.json +2 -9
  27. package/spec/output-schemas/request-status.schema.json +113 -0
  28. package/spec/output-schemas/resident-status.schema.json +22 -0
  29. package/spec/output-schemas/search-result.schema.json +1 -1
  30. package/spec/output-schemas/search-results.schema.json +1 -1
  31. package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
  32. package/spec/output-schemas/sessions-discovery.schema.json +38 -0
  33. package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
  34. package/spec/output-schemas/sessions-status.schema.json +432 -0
  35. package/spec/output-schemas/status.schema.json +98 -0
  36. package/src/cli/commands/ask.ts +14 -2
  37. package/src/cli/commands/capture.ts +55 -96
  38. package/src/cli/commands/daemon.ts +41 -0
  39. package/src/cli/commands/doctor.ts +54 -20
  40. package/src/cli/commands/embed.ts +41 -3
  41. package/src/cli/commands/ls.ts +3 -0
  42. package/src/cli/commands/memory.ts +12 -3
  43. package/src/cli/commands/query.ts +5 -0
  44. package/src/cli/commands/request-status.ts +59 -0
  45. package/src/cli/commands/reset.ts +39 -5
  46. package/src/cli/commands/sessions.ts +713 -0
  47. package/src/cli/commands/shared.ts +14 -1
  48. package/src/cli/commands/status.ts +63 -5
  49. package/src/cli/commands/vec.ts +54 -0
  50. package/src/cli/detach.ts +29 -1
  51. package/src/cli/errors.ts +13 -9
  52. package/src/cli/program.ts +441 -2
  53. package/src/cli/session-binding.ts +49 -0
  54. package/src/config/types.ts +8 -0
  55. package/src/core/capture-publish.ts +239 -0
  56. package/src/core/capture-sync.ts +12 -2
  57. package/src/core/host-paths.ts +31 -0
  58. package/src/core/memory-remember.ts +234 -122
  59. package/src/core/memory-types.ts +11 -0
  60. package/src/core/network-boundary-inventory.ts +8 -0
  61. package/src/core/request-receipts.ts +671 -0
  62. package/src/core/shutdown-budget.ts +6 -0
  63. package/src/core/vector-partition-status.ts +52 -0
  64. package/src/embed/backlog.ts +124 -18
  65. package/src/embed/fingerprint.ts +6 -3
  66. package/src/embed/retry.ts +66 -27
  67. package/src/embed/variant-backlog.ts +15 -10
  68. package/src/embed/variant-retry.ts +31 -22
  69. package/src/index.ts +30 -2
  70. package/src/llm/native-worker/dispatcher.ts +2 -0
  71. package/src/llm/native-worker/embedding-identity.ts +42 -0
  72. package/src/llm/native-worker/protocol.ts +1 -0
  73. package/src/llm/types.ts +3 -0
  74. package/src/mcp/context.ts +17 -0
  75. package/src/mcp/http-egress.ts +4 -0
  76. package/src/mcp/http-transport.ts +2 -0
  77. package/src/mcp/resources/index.ts +6 -5
  78. package/src/mcp/tool-descriptions-core.ts +1 -1
  79. package/src/mcp/tools/capture.ts +87 -85
  80. package/src/mcp/tools/index.ts +77 -4
  81. package/src/mcp/tools/memory-remember.ts +8 -1
  82. package/src/mcp/tools/memory-shared.ts +7 -1
  83. package/src/mcp/tools/request-status.ts +73 -0
  84. package/src/mcp/tools/sessions.ts +208 -0
  85. package/src/mcp/tools/status.ts +4 -0
  86. package/src/pipeline/hybrid.ts +37 -7
  87. package/src/pipeline/vsearch.ts +14 -2
  88. package/src/sdk/client.ts +180 -84
  89. package/src/sdk/index.ts +6 -0
  90. package/src/sdk/types.ts +54 -2
  91. package/src/serve/capture-service.ts +98 -32
  92. package/src/serve/config-sync.ts +3 -2
  93. package/src/serve/embed-scheduler.ts +133 -19
  94. package/src/serve/host-path-redaction.ts +79 -0
  95. package/src/serve/public/app.tsx +4 -1
  96. package/src/serve/public/components/CaptureModal.tsx +26 -8
  97. package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
  98. package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
  99. package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
  100. package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
  101. package/src/serve/public/components/sessions/api.ts +40 -0
  102. package/src/serve/public/globals.built.css +1 -1
  103. package/src/serve/public/hooks/use-api.ts +26 -3
  104. package/src/serve/public/lib/request-intent.ts +77 -0
  105. package/src/serve/public/lib/snippet.tsx +52 -0
  106. package/src/serve/public/lib/workspace-actions.ts +12 -1
  107. package/src/serve/public/lib/workspace-tabs.ts +2 -0
  108. package/src/serve/public/pages/Dashboard.tsx +22 -9
  109. package/src/serve/public/pages/DocView.tsx +25 -6
  110. package/src/serve/public/pages/DocumentEditor.tsx +224 -104
  111. package/src/serve/public/pages/Search.tsx +1 -41
  112. package/src/serve/public/pages/Sessions.tsx +350 -0
  113. package/src/serve/resident-runtime.ts +69 -4
  114. package/src/serve/resident-status.ts +13 -1
  115. package/src/serve/routes/api.ts +476 -147
  116. package/src/serve/routes/sessions.ts +766 -0
  117. package/src/serve/security.ts +9 -0
  118. package/src/serve/server.ts +215 -10
  119. package/src/serve/session-automation.ts +146 -0
  120. package/src/serve/status-model.ts +16 -0
  121. package/src/serve/status.ts +2 -0
  122. package/src/serve/watch-reconciliation-shared.ts +3 -0
  123. package/src/serve/watch-service-events.ts +3 -2
  124. package/src/serve/watch-service-run-flush.ts +35 -2
  125. package/src/serve/watch-service.ts +5 -0
  126. package/src/sessions/archive.ts +348 -0
  127. package/src/sessions/automation-state.ts +444 -0
  128. package/src/sessions/automation-status.ts +239 -0
  129. package/src/sessions/automation.ts +1169 -0
  130. package/src/sessions/binding.ts +105 -0
  131. package/src/sessions/claude-hook.ts +240 -0
  132. package/src/sessions/config.ts +176 -0
  133. package/src/sessions/format.ts +191 -0
  134. package/src/sessions/import-child-env.ts +8 -0
  135. package/src/sessions/import-child.ts +152 -0
  136. package/src/sessions/parsers/claude-code.ts +259 -0
  137. package/src/sessions/parsers/codex.ts +303 -0
  138. package/src/sessions/parsers/hermes.ts +248 -0
  139. package/src/sessions/parsers/openclaw.ts +496 -0
  140. package/src/sessions/parsers/shared.ts +184 -0
  141. package/src/sessions/sanitize.ts +222 -0
  142. package/src/sessions/service.ts +1533 -0
  143. package/src/sessions/setup.ts +477 -0
  144. package/src/sessions/sources.ts +518 -0
  145. package/src/sessions/state.ts +118 -0
  146. package/src/sessions/types.ts +457 -0
  147. package/src/store/migrations/031-runtime-independent-vectors.ts +29 -0
  148. package/src/store/migrations/032-vector-runtime-callers.ts +17 -0
  149. package/src/store/migrations/index.ts +4 -0
  150. package/src/store/sqlite/adapter.ts +76 -16
  151. package/src/store/sqlite/scoped-index.ts +9 -0
  152. package/src/store/types.ts +11 -1
  153. package/src/store/vector/lazy.ts +46 -43
  154. package/src/store/vector/runtime-compat.ts +651 -0
  155. package/src/store/vector/sqlite-vec.ts +20 -2
  156. package/src/store/vector/status.ts +276 -35
  157. package/src/store/vector/types.ts +2 -0
  158. package/src/store/vector/variant-search.ts +71 -23
  159. package/src/store/vector/variants.ts +49 -14
  160. package/browser-extension/artifacts/gno-browser-clipper-v2.5.1.zip.sha256 +0 -1
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Codex rollout JSONL parser.
3
+ *
4
+ * Prompts of `codex exec` runs (`session_meta.source = "exec"`) come from a
5
+ * program or script (often another agent) and are not archived as human
6
+ * speech; interactive CLI and editor sessions are.
7
+ *
8
+ * Verified layouts (2026-09): every line is `{timestamp, ordinal?, type,
9
+ * payload}` and the first line is `session_meta`.
10
+ * - CLI >= 0.151: speech arrives as `event_msg` `item_completed` with
11
+ * `item.type` `UserMessage` / `AgentMessage`.
12
+ * - CLI <= 0.150: speech arrives as `event_msg` `user_message` /
13
+ * `agent_message`.
14
+ * `response_item` messages duplicate that speech and mix injected context
15
+ * (environment, instructions, skills) into `role=user`, so they are never
16
+ * used as a human signal. Reasoning and tool records are excluded.
17
+ *
18
+ * Forks and spawned subagents carry `forked_from_id` /
19
+ * `subagent_history_start_ordinal`; records before that ordinal (including a
20
+ * second `session_meta`) are copied parent history and are skipped. In a
21
+ * spawned subagent thread the "user" message is the parent agent's task, not
22
+ * human speech.
23
+ *
24
+ * @module src/sessions/parsers/codex
25
+ */
26
+
27
+ import {
28
+ emptyDiagnostics,
29
+ normalizeTimestamp,
30
+ noteUnknownKind,
31
+ type ParsedThread,
32
+ type ParsedTurn,
33
+ type ParseUnitResult,
34
+ type SessionThreadKind,
35
+ } from "../types";
36
+ import {
37
+ missingHumanTurns,
38
+ isRecord,
39
+ joinTextBlocks,
40
+ pushTurn,
41
+ readJsonlRecords,
42
+ stringField,
43
+ } from "./shared";
44
+
45
+ export const CODEX_PARSER = "codex/1";
46
+
47
+ const SKIPPED_TOP_LEVEL = new Set([
48
+ "response_item",
49
+ "inter_agent_communication_metadata",
50
+ "turn_context",
51
+ "world_state",
52
+ "token_usage_record",
53
+ "compacted",
54
+ ]);
55
+
56
+ const SKIPPED_EVENTS = new Set([
57
+ "token_count",
58
+ "task_started",
59
+ "task_complete",
60
+ "turn_started",
61
+ "turn_complete",
62
+ "turn_aborted",
63
+ "agent_reasoning",
64
+ "agent_reasoning_raw_content",
65
+ "agent_reasoning_section_break",
66
+ "thread_settings_applied",
67
+ "thread_goal_updated",
68
+ "exec_command_begin",
69
+ "exec_command_end",
70
+ "exec_command_output_delta",
71
+ "patch_apply_begin",
72
+ "patch_apply_end",
73
+ "mcp_tool_call_begin",
74
+ "mcp_tool_call_end",
75
+ "web_search_begin",
76
+ "web_search_end",
77
+ "item_started",
78
+ "item_updated",
79
+ "context_compacted",
80
+ "entered_review_mode",
81
+ "exited_review_mode",
82
+ "stream_error",
83
+ "error",
84
+ "warning",
85
+ "background_event",
86
+ "session_configured",
87
+ "plan_update",
88
+ "turn_diff",
89
+ "get_history_entry_response",
90
+ "view_image_tool_call",
91
+ ]);
92
+
93
+ const SKIPPED_ITEMS = new Set([
94
+ "CommandExecution",
95
+ "Reasoning",
96
+ "FileChange",
97
+ "McpToolCall",
98
+ "WebSearch",
99
+ "TodoList",
100
+ "ImageView",
101
+ "CustomToolCall",
102
+ "FunctionCall",
103
+ "ContextCompaction",
104
+ "Plan",
105
+ "Extension",
106
+ "SubAgentActivity",
107
+ "CollabAgentToolCall",
108
+ ]);
109
+
110
+ /** Text block types seen in completed items across CLI versions. */
111
+ const ITEM_TEXT_TYPES = ["text", "Text", "input_text", "output_text"];
112
+
113
+ interface Candidate extends ParsedTurn {
114
+ shape: "item" | "legacy";
115
+ }
116
+
117
+ const itemText = (item: Record<string, unknown>): string | undefined =>
118
+ joinTextBlocks(item.content, ITEM_TEXT_TYPES);
119
+
120
+ /** Report speech items whose content blocks no longer match (format drift). */
121
+ function noteUntextedItem(
122
+ diagnostics: ReturnType<typeof emptyDiagnostics>,
123
+ item: Record<string, unknown>
124
+ ): void {
125
+ const blocks = Array.isArray(item.content) ? item.content : [];
126
+ const first = blocks.find(isRecord);
127
+ noteUnknownKind(
128
+ diagnostics,
129
+ `${typeof item.type === "string" ? item.type : "unknown"}/content:${typeof first?.type === "string" ? first.type : "none"}`
130
+ );
131
+ }
132
+
133
+ export async function parseCodexRollout(
134
+ path: string
135
+ ): Promise<ParseUnitResult> {
136
+ const diagnostics = emptyDiagnostics();
137
+ const candidates: Candidate[] = [];
138
+ let meta: Record<string, unknown> | undefined;
139
+ let historyStart: number | undefined;
140
+ let cwd: string | undefined;
141
+ let agentInstructionThread = false;
142
+ let programmaticEntry = false;
143
+ let copiedParentMeta = false;
144
+
145
+ for await (const { lineNumber, record } of readJsonlRecords(
146
+ path,
147
+ diagnostics
148
+ )) {
149
+ const ordinal =
150
+ typeof record.ordinal === "number" ? record.ordinal : lineNumber - 1;
151
+ const type = record.type;
152
+ const payload = isRecord(record.payload) ? record.payload : {};
153
+
154
+ if (type === "session_meta") {
155
+ if (!meta) {
156
+ meta = payload;
157
+ cwd = stringField(payload.cwd);
158
+ const start = payload.subagent_history_start_ordinal;
159
+ if (typeof start === "number" && start > 0) historyStart = start;
160
+ const source = payload.source;
161
+ const spawnParent =
162
+ isRecord(source) &&
163
+ isRecord(source.subagent) &&
164
+ isRecord(source.subagent.thread_spawn)
165
+ ? stringField(source.subagent.thread_spawn.parent_thread_id)
166
+ : undefined;
167
+ agentInstructionThread =
168
+ payload.thread_source === "subagent" ||
169
+ Boolean(stringField(payload.parent_thread_id) ?? spawnParent);
170
+ // `codex exec` prompts are issued by a program or script, which may
171
+ // be another agent; they are not attributed to a person.
172
+ programmaticEntry = payload.source === "exec";
173
+ } else {
174
+ diagnostics.copiedHistorySkipped += 1;
175
+ copiedParentMeta = true;
176
+ }
177
+ continue;
178
+ }
179
+
180
+ if (historyStart !== undefined && ordinal < historyStart) {
181
+ diagnostics.copiedHistorySkipped += 1;
182
+ continue;
183
+ }
184
+
185
+ if (type === "turn_context") {
186
+ cwd = stringField(payload.cwd) ?? cwd;
187
+ continue;
188
+ }
189
+ if (typeof type === "string" && SKIPPED_TOP_LEVEL.has(type)) continue;
190
+ if (type !== "event_msg") {
191
+ noteUnknownKind(diagnostics, type);
192
+ continue;
193
+ }
194
+
195
+ const eventType = payload.type;
196
+ const timestamp = normalizeTimestamp(record.timestamp);
197
+ const locator = `line:${lineNumber}`;
198
+ if (eventType === "item_completed") {
199
+ const item = isRecord(payload.item) ? payload.item : {};
200
+ const itemType = item.type;
201
+ if (itemType !== "UserMessage" && itemType !== "AgentMessage") {
202
+ if (typeof itemType !== "string" || !SKIPPED_ITEMS.has(itemType)) {
203
+ noteUnknownKind(diagnostics, `item_completed/${String(itemType)}`);
204
+ }
205
+ continue;
206
+ }
207
+ const text = itemText(item);
208
+ if (text === undefined) {
209
+ noteUntextedItem(diagnostics, item);
210
+ continue;
211
+ }
212
+ candidates.push({
213
+ shape: "item",
214
+ turnId: stringField(item.id) ?? `ordinal:${ordinal}`,
215
+ role: itemType === "UserMessage" ? "human" : "assistant",
216
+ text,
217
+ timestamp,
218
+ locator,
219
+ cwd,
220
+ });
221
+ continue;
222
+ }
223
+ if (eventType === "user_message" || eventType === "agent_message") {
224
+ const text = stringField(payload.message);
225
+ if (text === undefined) continue;
226
+ candidates.push({
227
+ shape: "legacy",
228
+ turnId: `ordinal:${ordinal}`,
229
+ role: eventType === "user_message" ? "human" : "assistant",
230
+ text,
231
+ timestamp,
232
+ locator,
233
+ cwd,
234
+ });
235
+ continue;
236
+ }
237
+ if (typeof eventType !== "string" || !SKIPPED_EVENTS.has(eventType)) {
238
+ noteUnknownKind(diagnostics, `event_msg/${String(eventType)}`);
239
+ }
240
+ }
241
+
242
+ // A fork carries copied parent history. Without the recorded start
243
+ // ordinal its boundary is unknown: archive nothing rather than duplicate
244
+ // the parent, and keep the unit incomplete as format drift.
245
+ const forkBoundaryUnknown =
246
+ meta !== undefined &&
247
+ historyStart === undefined &&
248
+ (copiedParentMeta || stringField(meta.forked_from_id) !== undefined);
249
+ if (forkBoundaryUnknown)
250
+ noteUnknownKind(diagnostics, "fork_without_history_start");
251
+ if (!meta || forkBoundaryUnknown) {
252
+ return {
253
+ threads: [],
254
+ diagnostics,
255
+ complete: !diagnostics.truncatedTail && !forkBoundaryUnknown,
256
+ parser: CODEX_PARSER,
257
+ };
258
+ }
259
+
260
+ // A file never mixes shapes in practice; if it did, the item shape wins so
261
+ // the same speech is not archived twice.
262
+ const hasItemShape = candidates.some((turn) => turn.shape === "item");
263
+ const turns: ParsedTurn[] = [];
264
+ for (const candidate of candidates) {
265
+ if (hasItemShape && candidate.shape === "legacy") continue;
266
+ const { shape: _shape, ...turn } = candidate;
267
+ if (
268
+ turn.role === "human" &&
269
+ (agentInstructionThread || programmaticEntry)
270
+ ) {
271
+ diagnostics.injectedSkipped += 1;
272
+ continue;
273
+ }
274
+ if (!pushTurn(turns, turn, diagnostics)) break;
275
+ }
276
+
277
+ const threadId = stringField(meta.id) ?? "unknown";
278
+ const parentThreadId =
279
+ stringField(meta.parent_thread_id) ?? stringField(meta.forked_from_id);
280
+ let kind: SessionThreadKind = "main";
281
+ if (agentInstructionThread) kind = "subagent";
282
+ else if (stringField(meta.forked_from_id)) kind = "fork";
283
+ if (kind === "main" && !programmaticEntry) {
284
+ diagnostics.humanTurnsMissing = missingHumanTurns(turns);
285
+ }
286
+ diagnostics.formatVersion = stringField(meta.cli_version);
287
+
288
+ const thread: ParsedThread = {
289
+ harness: "codex",
290
+ threadId,
291
+ sessionId: stringField(meta.session_id) ?? threadId,
292
+ parentThreadId,
293
+ kind,
294
+ cwd: stringField(meta.cwd),
295
+ turns,
296
+ };
297
+ return {
298
+ threads: [thread],
299
+ diagnostics,
300
+ complete: !diagnostics.truncatedTail && !diagnostics.humanTurnsMissing,
301
+ parser: CODEX_PARSER,
302
+ };
303
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Hermes `state.db` parser.
3
+ *
4
+ * Built from the Hermes v0.19 schema (`SCHEMA_VERSION = 22`); no populated
5
+ * store was available locally, so fixtures are synthetic.
6
+ *
7
+ * - `sessions(id, source, parent_session_id, started_at, cwd, model_config,
8
+ * end_reason, ...)` and `messages(id, session_id, role, content,
9
+ * timestamp, active, compacted, ...)`.
10
+ * - Every session is one thread. `parent_session_id` is overloaded: a
11
+ * delegated subagent (`model_config.$._delegate_from`, or a child that is
12
+ * neither a branch nor a compression continuation) receives the parent's
13
+ * task as its `role=user` message, so its user messages are not human.
14
+ * - In-place compaction soft-archives the original rows (`active=0,
15
+ * compacted=1`) and inserts a summary plus copied tail rows. Original rows
16
+ * are archived; inserted copies of an archived row are skipped, and
17
+ * compaction summaries are recognised by their fixed prefix. Rewound rows
18
+ * (`active=0, compacted=0`) were retracted and are skipped. Continuation
19
+ * sessions skip rows copied from the parent the same way.
20
+ * - Tool, system and reasoning content are excluded.
21
+ *
22
+ * @module src/sessions/parsers/hermes
23
+ */
24
+
25
+ import type { Database } from "bun:sqlite";
26
+
27
+ import {
28
+ emptyDiagnostics,
29
+ normalizeTimestamp,
30
+ type ParsedThread,
31
+ type ParsedTurn,
32
+ type ParseUnitResult,
33
+ SESSION_LIMITS,
34
+ type SessionThreadKind,
35
+ } from "../types";
36
+ import {
37
+ copiedPrefixLength,
38
+ missingHumanTurns,
39
+ hasTables,
40
+ pushTurn,
41
+ tableColumns,
42
+ withReadOnlySnapshot,
43
+ } from "./shared";
44
+
45
+ export const HERMES_PARSER = "hermes/1";
46
+
47
+ const SUMMARY_PREFIXES = ["[CONTEXT COMPACTION", "[CONTEXT SUMMARY]:"];
48
+
49
+ interface SessionRow {
50
+ id: string;
51
+ parent_session_id: string | null;
52
+ started_at: number | null;
53
+ cwd: string | null;
54
+ model_config: string | null;
55
+ end_reason: string | null;
56
+ parent_end_reason: string | null;
57
+ parent_ended_at: number | null;
58
+ }
59
+
60
+ interface MessageRow {
61
+ id: number;
62
+ role: string;
63
+ content: string | null;
64
+ timestamp: number | null;
65
+ active: number | null;
66
+ compacted: number | null;
67
+ }
68
+
69
+ export function isHermesDatabase(db: Database): boolean {
70
+ return (
71
+ hasTables(db, ["sessions", "messages"]) &&
72
+ tableColumns(db, "messages").has("session_id")
73
+ );
74
+ }
75
+
76
+ function configFlag(raw: string | null, key: string): boolean {
77
+ if (!raw) return false;
78
+ try {
79
+ const parsed: unknown = JSON.parse(raw);
80
+ return Boolean(
81
+ parsed &&
82
+ typeof parsed === "object" &&
83
+ (parsed as Record<string, unknown>)[key]
84
+ );
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ function classifyChild(row: SessionRow): SessionThreadKind {
91
+ if (!row.parent_session_id) return "main";
92
+ if (configFlag(row.model_config, "_delegate_from")) return "subagent";
93
+ if (configFlag(row.model_config, "_branched_from")) return "fork";
94
+ if (row.parent_end_reason === "compression") return "continuation";
95
+ if (
96
+ row.parent_end_reason === "branched" &&
97
+ (row.started_at ?? 0) >= (row.parent_ended_at ?? 0)
98
+ ) {
99
+ return "fork";
100
+ }
101
+ return "subagent";
102
+ }
103
+
104
+ const contentKey = (role: string, content: string): string =>
105
+ `${role}\0${content}`;
106
+
107
+ const isSummary = (content: string): boolean => {
108
+ const trimmed = content.trimStart();
109
+ return SUMMARY_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
110
+ };
111
+
112
+ /**
113
+ * Rows a harness copied from earlier history: the contiguous block right
114
+ * after each in-place compaction point that repeats the tail of the
115
+ * compacted originals, and the block at a continuation's start that repeats
116
+ * the tail of its parent. A later genuine repeat ("yes") is kept.
117
+ */
118
+ function copiedRowIds(
119
+ rows: readonly MessageRow[],
120
+ parentSpeech: readonly string[]
121
+ ): Set<number> {
122
+ const copied = new Set<number>();
123
+ const skipBlock = (startIndex: number, source: readonly string[]): void => {
124
+ // Rewound rows and summaries are dropped anyway; they do not break a block.
125
+ const candidates: MessageRow[] = [];
126
+ for (let cursor = startIndex; cursor < rows.length; cursor += 1) {
127
+ const row = rows[cursor]!;
128
+ if (row.compacted === 1) break;
129
+ if (row.active === 0 || isSummary(row.content ?? "")) continue;
130
+ candidates.push(row);
131
+ }
132
+ const length = copiedPrefixLength(
133
+ candidates.map((row) => contentKey(row.role, row.content ?? "")),
134
+ source
135
+ );
136
+ for (const row of candidates.slice(0, length)) copied.add(row.id);
137
+ };
138
+ if (parentSpeech.length > 0) skipBlock(0, parentSpeech);
139
+ const originals: string[] = [];
140
+ for (let index = 0; index < rows.length; index += 1) {
141
+ const row = rows[index]!;
142
+ if (row.compacted === 1) {
143
+ originals.push(contentKey(row.role, row.content ?? ""));
144
+ const next = rows[index + 1];
145
+ if (next && next.compacted !== 1) skipBlock(index + 1, originals);
146
+ }
147
+ }
148
+ return copied;
149
+ }
150
+
151
+ export function parseHermesDatabase(path: string): ParseUnitResult {
152
+ const diagnostics = emptyDiagnostics();
153
+ const threads = withReadOnlySnapshot(path, (db) => {
154
+ if (!isHermesDatabase(db)) return [];
155
+ const sessionColumns = tableColumns(db, "sessions");
156
+ const messageColumns = tableColumns(db, "messages");
157
+ const col = (columns: Set<string>, name: string, alias = name) =>
158
+ columns.has(name) ? `s.${name} AS ${alias}` : `NULL AS ${alias}`;
159
+ const sessions = db
160
+ .query<SessionRow, []>(
161
+ `SELECT s.id AS id, ${col(sessionColumns, "parent_session_id")}, ${col(sessionColumns, "started_at")}, ${col(sessionColumns, "cwd")}, ${col(sessionColumns, "model_config")}, ${col(sessionColumns, "end_reason")},
162
+ ${sessionColumns.has("parent_session_id") && sessionColumns.has("end_reason") ? "p.end_reason" : "NULL"} AS parent_end_reason,
163
+ ${sessionColumns.has("parent_session_id") && sessionColumns.has("ended_at") ? "p.ended_at" : "NULL"} AS parent_ended_at
164
+ FROM sessions s
165
+ ${sessionColumns.has("parent_session_id") ? "LEFT JOIN sessions p ON p.id = s.parent_session_id" : ""}
166
+ ORDER BY ${sessionColumns.has("started_at") ? "s.started_at," : ""} s.id`
167
+ )
168
+ .all();
169
+ const pickMessage = (name: string) =>
170
+ messageColumns.has(name) ? name : `NULL AS ${name}`;
171
+ // Insertion order (id) keeps a copied block contiguous with its origin.
172
+ const messageQuery = db.query<MessageRow, [string]>(
173
+ `SELECT id, role, content, ${pickMessage("timestamp")}, ${pickMessage("active")}, ${pickMessage("compacted")} FROM messages WHERE session_id = ? ORDER BY id`
174
+ );
175
+ const speechBySession = new Map<string, string[]>();
176
+ const result: ParsedThread[] = [];
177
+ diagnostics.threadsOverLimit = Math.max(
178
+ 0,
179
+ sessions.length - SESSION_LIMITS.maxThreadsPerUnit
180
+ );
181
+ for (const session of sessions.slice(0, SESSION_LIMITS.maxThreadsPerUnit)) {
182
+ const kind = classifyChild(session);
183
+ const rows = messageQuery
184
+ .all(session.id)
185
+ .filter(
186
+ (row) =>
187
+ (row.role === "user" || row.role === "assistant") &&
188
+ (row.content ?? "").trim() !== ""
189
+ );
190
+ const copied = copiedRowIds(
191
+ rows,
192
+ kind === "continuation" && session.parent_session_id
193
+ ? (speechBySession.get(session.parent_session_id) ?? [])
194
+ : []
195
+ );
196
+ diagnostics.copiedHistorySkipped += copied.size;
197
+ const speech: string[] = [];
198
+ const turns: ParsedTurn[] = [];
199
+ for (const row of rows) {
200
+ const content = row.content ?? "";
201
+ const rewound = row.active === 0 && row.compacted !== 1;
202
+ if (rewound || copied.has(row.id)) continue;
203
+ if (isSummary(content)) {
204
+ diagnostics.injectedSkipped += 1;
205
+ continue;
206
+ }
207
+ speech.push(contentKey(row.role, content));
208
+ if (row.role === "user" && kind === "subagent") {
209
+ diagnostics.injectedSkipped += 1;
210
+ continue;
211
+ }
212
+ const pushed = pushTurn(
213
+ turns,
214
+ {
215
+ turnId: `message:${row.id}`,
216
+ role: row.role === "user" ? "human" : "assistant",
217
+ text: content,
218
+ timestamp: normalizeTimestamp(row.timestamp),
219
+ locator: `messages/${row.id}`,
220
+ cwd: session.cwd ?? undefined,
221
+ },
222
+ diagnostics
223
+ );
224
+ if (!pushed) break;
225
+ }
226
+ speechBySession.set(session.id, speech);
227
+ if (kind === "main" && missingHumanTurns(turns)) {
228
+ diagnostics.threadsWithoutHuman += 1;
229
+ }
230
+ result.push({
231
+ harness: "hermes",
232
+ threadId: session.id,
233
+ sessionId: session.parent_session_id ?? session.id,
234
+ parentThreadId: session.parent_session_id ?? undefined,
235
+ kind,
236
+ cwd: session.cwd ?? undefined,
237
+ turns,
238
+ });
239
+ }
240
+ return result;
241
+ });
242
+ return {
243
+ threads,
244
+ diagnostics,
245
+ complete: diagnostics.threadsOverLimit === 0,
246
+ parser: HERMES_PARSER,
247
+ };
248
+ }