@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,496 @@
1
+ /**
2
+ * OpenClaw session parser (SQLite agent store and legacy JSONL).
3
+ *
4
+ * Built from the upstream v2026.9.6 schema; no live OpenClaw store was
5
+ * available when this parser was written, so fixtures are synthetic.
6
+ *
7
+ * - SQLite (`agents/<agentId>/agent/openclaw-agent.sqlite`): each
8
+ * `transcript_events` row stores one legacy JSONL entry in `event_json`
9
+ * (or a single zstd frame of that JSON in `event_zstd`).
10
+ * `transcript_events.session_id` names one transcript generation
11
+ * (`session_windows`); generations of one logical session share
12
+ * `session_windows.session_key`. A logical session is one thread; entries
13
+ * copied between generations or into a fork keep their entry `id` and are
14
+ * archived once.
15
+ * - Legacy JSONL (`agents/<agentId>/sessions/<sessionId>.jsonl`): a
16
+ * `{type:"session"}` header followed by entries. A header `parentSession`
17
+ * marks a child transcript, which may be an operator fork or a spawned
18
+ * subagent with forked context; the sibling `sessions.json` index tells
19
+ * them apart (`spawnedBy` or a `:subagent:` key). Copied entries keep the
20
+ * parent's entry ids and are skipped by reading the parent transcript. An
21
+ * unclassified child withholds its first prompt, and a child whose parent
22
+ * is unreadable archives nothing; both stay incomplete as format drift.
23
+ *
24
+ * Human speech is a `message` entry with `role=user` and text content,
25
+ * unless its provenance marks inter-session or internal-system input, it is
26
+ * wrapped as internal runtime context, or the thread is a spawned subagent
27
+ * (whose first user message is the parent's task). `custom` runtime-context
28
+ * messages, compaction and branch summaries, tool results and thinking are
29
+ * never speech.
30
+ *
31
+ * @module src/sessions/parsers/openclaw
32
+ */
33
+
34
+ import type { Database } from "bun:sqlite";
35
+
36
+ // node:path has no Bun path utilities
37
+ import { basename, dirname, isAbsolute, join } from "node:path";
38
+
39
+ import {
40
+ emptyDiagnostics,
41
+ normalizeTimestamp,
42
+ noteUnknownKind,
43
+ type ParsedThread,
44
+ type ParsedTurn,
45
+ type ParseUnitResult,
46
+ SESSION_LIMITS,
47
+ type SessionThreadKind,
48
+ type UnitDiagnostics,
49
+ } from "../types";
50
+ import {
51
+ missingHumanTurns,
52
+ hasTables,
53
+ isRecord,
54
+ joinTextBlocks,
55
+ type JsonRecord,
56
+ pushTurn,
57
+ readJsonlRecords,
58
+ stringField,
59
+ tableColumns,
60
+ withReadOnlySnapshot,
61
+ } from "./shared";
62
+
63
+ export const OPENCLAW_PARSER = "openclaw/1";
64
+
65
+ const SKIPPED_ENTRY_TYPES = new Set([
66
+ "session",
67
+ "compaction",
68
+ "branch_summary",
69
+ "model_change",
70
+ "thinking_level_change",
71
+ "reset",
72
+ "custom",
73
+ "custom_message",
74
+ "label",
75
+ "session_info",
76
+ "leaf",
77
+ ]);
78
+
79
+ const SKIPPED_ROLES = new Set([
80
+ "toolResult",
81
+ "custom",
82
+ "bashExecution",
83
+ "branchSummary",
84
+ "compactionSummary",
85
+ ]);
86
+
87
+ const INTERNAL_CONTEXT_MARKER = "<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>";
88
+ const RUNTIME_CONTINUATION = "Continue the OpenClaw runtime event.";
89
+ const NON_HUMAN_PROVENANCE = new Set(["inter_session", "internal_system"]);
90
+
91
+ interface EntryContext {
92
+ agentInstruction: boolean;
93
+ /** Withhold the next user prompt (a child that may be a subagent). */
94
+ withholdFirstPrompt?: boolean;
95
+ seen: Set<string>;
96
+ diagnostics: UnitDiagnostics;
97
+ turns: ParsedTurn[];
98
+ cwd?: string;
99
+ }
100
+
101
+ /** Classify one entry; returns false once the thread hit its turn limit. */
102
+ function acceptEntry(
103
+ entry: JsonRecord,
104
+ locator: string,
105
+ context: EntryContext
106
+ ): boolean {
107
+ const type = entry.type;
108
+ if (type !== "message") {
109
+ if (typeof type === "string" && type === "session") {
110
+ context.cwd ??= stringField(entry.cwd);
111
+ }
112
+ if (typeof type !== "string" || !SKIPPED_ENTRY_TYPES.has(type)) {
113
+ noteUnknownKind(context.diagnostics, type);
114
+ }
115
+ return true;
116
+ }
117
+ const entryId = stringField(entry.id);
118
+ if (entryId) {
119
+ if (context.seen.has(entryId)) {
120
+ context.diagnostics.copiedHistorySkipped += 1;
121
+ return true;
122
+ }
123
+ context.seen.add(entryId);
124
+ }
125
+ const message = isRecord(entry.message) ? entry.message : {};
126
+ const role = message.role;
127
+ if (typeof role === "string" && SKIPPED_ROLES.has(role)) return true;
128
+ if (role !== "user" && role !== "assistant") {
129
+ noteUnknownKind(context.diagnostics, `role/${String(role)}`);
130
+ return true;
131
+ }
132
+ const text = joinTextBlocks(message.content, ["text"]);
133
+ if (text === undefined) return true;
134
+ const timestamp =
135
+ normalizeTimestamp(message.timestamp) ??
136
+ normalizeTimestamp(entry.timestamp);
137
+ const turn: ParsedTurn = {
138
+ turnId: entryId ?? locator,
139
+ role: role === "user" ? "human" : "assistant",
140
+ text,
141
+ timestamp,
142
+ locator,
143
+ cwd: context.cwd,
144
+ };
145
+ if (role === "user") {
146
+ const provenance = isRecord(message.provenance)
147
+ ? stringField(message.provenance.kind)
148
+ : undefined;
149
+ const injected =
150
+ context.agentInstruction ||
151
+ (provenance !== undefined && NON_HUMAN_PROVENANCE.has(provenance)) ||
152
+ text.includes(INTERNAL_CONTEXT_MARKER) ||
153
+ text.trim() === RUNTIME_CONTINUATION;
154
+ if (injected || context.withholdFirstPrompt) {
155
+ if (!injected) context.withholdFirstPrompt = false;
156
+ context.diagnostics.injectedSkipped += 1;
157
+ return true;
158
+ }
159
+ }
160
+ return pushTurn(context.turns, turn, context.diagnostics);
161
+ }
162
+
163
+ // ─────────────────────────────────────────────────────────────────────────────
164
+ // Legacy JSONL
165
+ // ─────────────────────────────────────────────────────────────────────────────
166
+
167
+ interface IndexEntry {
168
+ key: string;
169
+ spawnedBy?: string;
170
+ }
171
+
172
+ /** Find this transcript's entry in the sibling `sessions.json` index. */
173
+ async function findIndexEntry(
174
+ path: string,
175
+ sessionId: string
176
+ ): Promise<IndexEntry | undefined> {
177
+ const file = Bun.file(join(dirname(path), "sessions.json"));
178
+ try {
179
+ if (!(await file.exists()) || file.size > SESSION_LIMITS.maxSourceBytes) {
180
+ return undefined;
181
+ }
182
+ const index: unknown = await file.json();
183
+ if (!isRecord(index)) return undefined;
184
+ const name = basename(path);
185
+ for (const [key, entry] of Object.entries(index)) {
186
+ if (!isRecord(entry)) continue;
187
+ const file = stringField(entry.sessionFile);
188
+ if (
189
+ entry.sessionId === sessionId ||
190
+ (file !== undefined && basename(file) === name)
191
+ ) {
192
+ return { key, spawnedBy: stringField(entry.spawnedBy) };
193
+ }
194
+ }
195
+ } catch {
196
+ return undefined;
197
+ }
198
+ return undefined;
199
+ }
200
+
201
+ /** Entry ids of the parent transcript, or undefined when it is unreadable. */
202
+ async function parentEntryIds(
203
+ path: string,
204
+ parentSession: string
205
+ ): Promise<Set<string> | undefined> {
206
+ const file = isAbsolute(parentSession)
207
+ ? parentSession
208
+ : join(
209
+ dirname(path),
210
+ parentSession.endsWith(".jsonl")
211
+ ? basename(parentSession)
212
+ : `${parentSession}.jsonl`
213
+ );
214
+ if (file === path) return undefined;
215
+ const ids = new Set<string>();
216
+ try {
217
+ if (!(await Bun.file(file).exists())) return undefined;
218
+ for await (const { record } of readJsonlRecords(file, emptyDiagnostics())) {
219
+ const id = stringField(record.id);
220
+ if (id && record.type !== "session") ids.add(id);
221
+ }
222
+ } catch {
223
+ return undefined;
224
+ }
225
+ return ids;
226
+ }
227
+
228
+ export async function parseOpenClawJsonl(
229
+ path: string
230
+ ): Promise<ParseUnitResult> {
231
+ const diagnostics = emptyDiagnostics();
232
+ let header: JsonRecord | undefined;
233
+ const context: EntryContext = {
234
+ agentInstruction: false,
235
+ seen: new Set(),
236
+ diagnostics,
237
+ turns: [],
238
+ };
239
+ let full = false;
240
+ let lineage: "main" | "subagent" | "fork" = "main";
241
+ let parentThreadId: string | undefined;
242
+ let unclassified = false;
243
+ for await (const { lineNumber, record } of readJsonlRecords(
244
+ path,
245
+ diagnostics
246
+ )) {
247
+ if (!header && record.type === "session") {
248
+ header = record;
249
+ context.cwd = stringField(record.cwd);
250
+ diagnostics.formatVersion =
251
+ typeof record.version === "number" || typeof record.version === "string"
252
+ ? String(record.version)
253
+ : undefined;
254
+ const threadId = stringField(record.id);
255
+ const parent = stringField(record.parentSession);
256
+ const indexEntry = threadId
257
+ ? await findIndexEntry(path, threadId)
258
+ : undefined;
259
+ const spawned =
260
+ indexEntry !== undefined &&
261
+ (indexEntry.spawnedBy !== undefined ||
262
+ indexEntry.key.includes(":subagent:"));
263
+ if (spawned) {
264
+ lineage = "subagent";
265
+ context.agentInstruction = true;
266
+ parentThreadId = indexEntry.spawnedBy ?? parent;
267
+ } else if (parent) {
268
+ lineage = "fork";
269
+ parentThreadId = parent;
270
+ // A child the index cannot classify may be a subagent with forked
271
+ // context: its first prompt is withheld rather than archived as human.
272
+ unclassified = indexEntry === undefined;
273
+ context.withholdFirstPrompt = unclassified;
274
+ }
275
+ if (parent) {
276
+ const copied = await parentEntryIds(path, parent);
277
+ if (!copied) {
278
+ // Copied-history boundary unknown: archive nothing rather than
279
+ // duplicate the parent.
280
+ noteUnknownKind(diagnostics, "child_parent_unavailable");
281
+ return {
282
+ threads: [],
283
+ diagnostics,
284
+ complete: false,
285
+ parser: OPENCLAW_PARSER,
286
+ };
287
+ }
288
+ for (const id of copied) context.seen.add(id);
289
+ }
290
+ continue;
291
+ }
292
+ if (full) continue;
293
+ full = !acceptEntry(record, `line:${lineNumber}`, context);
294
+ }
295
+ const threadId = stringField(header?.id);
296
+ if (!header || !threadId) {
297
+ return {
298
+ threads: [],
299
+ diagnostics,
300
+ complete: !diagnostics.truncatedTail,
301
+ parser: OPENCLAW_PARSER,
302
+ };
303
+ }
304
+ if (unclassified) noteUnknownKind(diagnostics, "child_session_unclassified");
305
+ if (lineage === "main") {
306
+ diagnostics.humanTurnsMissing = missingHumanTurns(context.turns);
307
+ }
308
+ return {
309
+ threads: [
310
+ {
311
+ harness: "openclaw",
312
+ threadId,
313
+ sessionId: threadId,
314
+ parentThreadId,
315
+ kind: lineage,
316
+ cwd: context.cwd,
317
+ turns: context.turns,
318
+ },
319
+ ],
320
+ diagnostics,
321
+ complete:
322
+ !diagnostics.truncatedTail &&
323
+ !diagnostics.humanTurnsMissing &&
324
+ !unclassified,
325
+ parser: OPENCLAW_PARSER,
326
+ };
327
+ }
328
+
329
+ // ─────────────────────────────────────────────────────────────────────────────
330
+ // SQLite agent store
331
+ // ─────────────────────────────────────────────────────────────────────────────
332
+
333
+ interface WindowRow {
334
+ session_id: string;
335
+ session_key: string;
336
+ created_at: number | null;
337
+ }
338
+
339
+ interface NodeRow {
340
+ session_key: string;
341
+ parent_session_key: string | null;
342
+ spawned_by: string | null;
343
+ fork_source_session_key: string | null;
344
+ }
345
+
346
+ interface EventRow {
347
+ seq: number;
348
+ event_json: string | null;
349
+ event_zstd: Uint8Array | null;
350
+ event_utf8_bytes: number | null;
351
+ }
352
+
353
+ function decodeEvent(row: EventRow): JsonRecord | undefined {
354
+ let text = row.event_json;
355
+ if (text === null && row.event_zstd) {
356
+ const expected = row.event_utf8_bytes ?? -1;
357
+ if (expected < 1 || expected > 4 * 1024 * 1024) return undefined;
358
+ const bytes = Bun.zstdDecompressSync(row.event_zstd);
359
+ if (bytes.byteLength !== expected) return undefined;
360
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
361
+ }
362
+ if (text === null) return undefined;
363
+ const parsed: unknown = JSON.parse(text);
364
+ return isRecord(parsed) ? parsed : undefined;
365
+ }
366
+
367
+ /** Whether a database file carries the OpenClaw agent schema. */
368
+ export function isOpenClawDatabase(db: Database): boolean {
369
+ return hasTables(db, ["transcript_events", "session_windows"]);
370
+ }
371
+
372
+ export function parseOpenClawDatabase(path: string): ParseUnitResult {
373
+ const diagnostics = emptyDiagnostics();
374
+ const threads: ParsedThread[] = withReadOnlySnapshot(path, (db) => {
375
+ if (!isOpenClawDatabase(db)) return [];
376
+ const nodeColumns = hasTables(db, ["session_nodes"])
377
+ ? tableColumns(db, "session_nodes")
378
+ : new Set<string>();
379
+ const nodes = new Map<string, NodeRow>();
380
+ if (nodeColumns.has("session_key")) {
381
+ const pick = (column: string) =>
382
+ nodeColumns.has(column) ? column : `NULL AS ${column}`;
383
+ for (const row of db
384
+ .query<NodeRow, []>(
385
+ `SELECT session_key, ${pick("parent_session_key")}, ${pick("spawned_by")}, ${pick("fork_source_session_key")} FROM session_nodes`
386
+ )
387
+ .all()) {
388
+ nodes.set(row.session_key, row);
389
+ }
390
+ }
391
+ const meta = hasTables(db, ["schema_meta"])
392
+ ? tableColumns(db, "schema_meta")
393
+ : new Set<string>();
394
+ if (meta.has("app_version")) {
395
+ const row = db
396
+ .query<{ app_version: string | null }, []>(
397
+ "SELECT app_version FROM schema_meta LIMIT 1"
398
+ )
399
+ .get();
400
+ if (row?.app_version) diagnostics.formatVersion = row.app_version;
401
+ }
402
+ const windowColumns = tableColumns(db, "session_windows");
403
+ const windows = db
404
+ .query<WindowRow, []>(
405
+ `SELECT session_id, session_key, ${windowColumns.has("created_at") ? "created_at" : "NULL AS created_at"} FROM session_windows ORDER BY created_at, session_id`
406
+ )
407
+ .all();
408
+ const byKey = new Map<string, string[]>();
409
+ for (const window of windows) {
410
+ const list = byKey.get(window.session_key) ?? [];
411
+ list.push(window.session_id);
412
+ byKey.set(window.session_key, list);
413
+ }
414
+ const eventColumns = tableColumns(db, "transcript_events");
415
+ const zstd = eventColumns.has("event_zstd");
416
+ const eventQuery = db.query<EventRow, [string]>(
417
+ `SELECT seq, event_json, ${zstd ? "event_zstd, event_utf8_bytes" : "NULL AS event_zstd, NULL AS event_utf8_bytes"} FROM transcript_events WHERE session_id = ? ORDER BY seq`
418
+ );
419
+
420
+ const seenByKey = new Map<string, Set<string>>();
421
+ const result: ParsedThread[] = [];
422
+ // Parents before forks so copied entry IDs are known when a fork is read.
423
+ const keys = [...byKey.keys()].sort((left, right) => {
424
+ const leftFork = nodes.get(left)?.fork_source_session_key ? 1 : 0;
425
+ const rightFork = nodes.get(right)?.fork_source_session_key ? 1 : 0;
426
+ return leftFork - rightFork || left.localeCompare(right);
427
+ });
428
+ diagnostics.threadsOverLimit = Math.max(
429
+ 0,
430
+ keys.length - SESSION_LIMITS.maxThreadsPerUnit
431
+ );
432
+ for (const key of keys.slice(0, SESSION_LIMITS.maxThreadsPerUnit)) {
433
+ const node = nodes.get(key);
434
+ const spawned = Boolean(node?.spawned_by) || key.includes(":subagent:");
435
+ const forkSource = node?.fork_source_session_key ?? undefined;
436
+ const seen = new Set<string>(
437
+ forkSource ? (seenByKey.get(forkSource) ?? []) : []
438
+ );
439
+ const context: EntryContext = {
440
+ agentInstruction: spawned,
441
+ seen,
442
+ diagnostics,
443
+ turns: [],
444
+ };
445
+ let full = false;
446
+ for (const sessionId of byKey.get(key) ?? []) {
447
+ for (const row of eventQuery.all(sessionId)) {
448
+ if (full) break;
449
+ let entry: JsonRecord | undefined;
450
+ try {
451
+ entry = decodeEvent(row);
452
+ } catch {
453
+ entry = undefined;
454
+ }
455
+ if (!entry) {
456
+ diagnostics.malformedRecords += 1;
457
+ continue;
458
+ }
459
+ if (entry.type === "session") {
460
+ context.cwd ??= stringField(entry.cwd);
461
+ continue;
462
+ }
463
+ full = !acceptEntry(
464
+ entry,
465
+ `transcript_events/${sessionId}/${row.seq}`,
466
+ context
467
+ );
468
+ }
469
+ }
470
+ seenByKey.set(key, seen);
471
+ let kind: SessionThreadKind = "main";
472
+ if (spawned) kind = "subagent";
473
+ else if (forkSource) kind = "fork";
474
+ if (kind === "main" && missingHumanTurns(context.turns)) {
475
+ diagnostics.threadsWithoutHuman += 1;
476
+ }
477
+ result.push({
478
+ harness: "openclaw",
479
+ threadId: key,
480
+ sessionId: node?.parent_session_key ?? forkSource ?? key,
481
+ parentThreadId:
482
+ node?.parent_session_key ?? node?.spawned_by ?? forkSource,
483
+ kind,
484
+ cwd: context.cwd,
485
+ turns: context.turns,
486
+ });
487
+ }
488
+ return result;
489
+ });
490
+ return {
491
+ threads,
492
+ diagnostics,
493
+ complete: diagnostics.threadsOverLimit === 0,
494
+ parser: OPENCLAW_PARSER,
495
+ };
496
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Shared helpers for harness parsers: bounded JSONL iteration, read-only
3
+ * SQLite snapshots and text extraction.
4
+ *
5
+ * @module src/sessions/parsers/shared
6
+ */
7
+
8
+ import { Database } from "bun:sqlite";
9
+
10
+ // Configures the platform SQLite before any Database opens (macOS).
11
+ import "../../store/sqlite/setup";
12
+ import { readBoundedUtf8Lines } from "../../converters/adapters/shared/utf8-lines";
13
+ import {
14
+ type ParsedTurn,
15
+ SESSION_LIMITS,
16
+ type UnitDiagnostics,
17
+ } from "../types";
18
+
19
+ export type JsonRecord = Record<string, unknown>;
20
+
21
+ export interface JsonlLine {
22
+ lineNumber: number;
23
+ record: JsonRecord;
24
+ }
25
+
26
+ export const isRecord = (value: unknown): value is JsonRecord =>
27
+ Boolean(value) && typeof value === "object" && !Array.isArray(value);
28
+
29
+ export const stringField = (value: unknown): string | undefined =>
30
+ typeof value === "string" && value.length > 0 ? value : undefined;
31
+
32
+ /**
33
+ * Iterate the JSON object lines of a file without retaining whole lines
34
+ * beyond the limit. Over-limit and malformed lines are counted; a final line
35
+ * cut mid-write marks the unit incomplete through `truncatedTail`.
36
+ */
37
+ export async function* readJsonlRecords(
38
+ path: string,
39
+ diagnostics: UnitDiagnostics
40
+ ): AsyncGenerator<JsonlLine> {
41
+ const lines = readBoundedUtf8Lines(
42
+ Bun.file(path).stream() as unknown as AsyncIterable<Uint8Array>,
43
+ SESSION_LIMITS.maxLineBytes
44
+ );
45
+ for await (const line of lines) {
46
+ if (!line.ok) {
47
+ if (!line.terminated) {
48
+ diagnostics.truncatedTail = true;
49
+ } else if (line.reason === "line_too_large") {
50
+ diagnostics.overLimitRecords += 1;
51
+ } else {
52
+ diagnostics.malformedRecords += 1;
53
+ }
54
+ continue;
55
+ }
56
+ if (line.text.trim() === "") continue;
57
+ let parsed: unknown;
58
+ try {
59
+ parsed = JSON.parse(line.text);
60
+ } catch {
61
+ if (line.terminated) diagnostics.malformedRecords += 1;
62
+ else diagnostics.truncatedTail = true;
63
+ continue;
64
+ }
65
+ if (!isRecord(parsed)) {
66
+ diagnostics.malformedRecords += 1;
67
+ continue;
68
+ }
69
+ yield { lineNumber: line.lineNumber, record: parsed };
70
+ }
71
+ }
72
+
73
+ /** Join the text blocks of a content value; non-text blocks are ignored. */
74
+ export function joinTextBlocks(
75
+ content: unknown,
76
+ textTypes: readonly string[] = ["text", "input_text", "output_text"]
77
+ ): string | undefined {
78
+ if (typeof content === "string") return content;
79
+ if (!Array.isArray(content)) return undefined;
80
+ const parts: string[] = [];
81
+ for (const block of content) {
82
+ if (!isRecord(block)) continue;
83
+ const type = block.type;
84
+ if (typeof type === "string" && textTypes.includes(type)) {
85
+ const text = block.text;
86
+ if (typeof text === "string") parts.push(text);
87
+ }
88
+ }
89
+ return parts.length > 0 ? parts.join("\n\n") : undefined;
90
+ }
91
+
92
+ /**
93
+ * Accept a classified turn, enforcing per-turn and per-thread bounds.
94
+ * Returns false when the thread reached its turn limit.
95
+ */
96
+ export function pushTurn(
97
+ turns: ParsedTurn[],
98
+ turn: ParsedTurn,
99
+ diagnostics: UnitDiagnostics
100
+ ): boolean {
101
+ const text = turn.text.trim();
102
+ if (!text) return true;
103
+ if (text.length > SESSION_LIMITS.maxTurnChars) {
104
+ diagnostics.overLimitTurns += 1;
105
+ return true;
106
+ }
107
+ if (turns.length >= SESSION_LIMITS.maxTurnsPerThread) {
108
+ diagnostics.overLimitTurns += 1;
109
+ return false;
110
+ }
111
+ turns.push({ ...turn, text });
112
+ return true;
113
+ }
114
+
115
+ /**
116
+ * Open a SQLite source read-only and run `read` inside one read transaction,
117
+ * so a live WAL writer cannot tear the view. No raw copy is made.
118
+ */
119
+ export function withReadOnlySnapshot<T>(
120
+ path: string,
121
+ read: (db: Database) => T
122
+ ): T {
123
+ const db = new Database(path, { readonly: true });
124
+ try {
125
+ db.exec("PRAGMA query_only = ON");
126
+ db.exec("BEGIN");
127
+ try {
128
+ return read(db);
129
+ } finally {
130
+ db.exec("COMMIT");
131
+ }
132
+ } finally {
133
+ db.close();
134
+ }
135
+ }
136
+
137
+ export function tableColumns(db: Database, table: string): Set<string> {
138
+ const rows = db
139
+ .query<{ name: string }, []>(`PRAGMA table_info(${JSON.stringify(table)})`)
140
+ .all();
141
+ return new Set(rows.map((row) => row.name));
142
+ }
143
+
144
+ export function hasTables(db: Database, tables: readonly string[]): boolean {
145
+ const rows = db
146
+ .query<{ name: string }, []>(
147
+ "SELECT name FROM sqlite_master WHERE type = 'table'"
148
+ )
149
+ .all();
150
+ const names = new Set(rows.map((row) => row.name));
151
+ return tables.every((table) => names.has(table));
152
+ }
153
+
154
+ /** Human turns absent while assistant turns exist signals format drift. */
155
+ export function missingHumanTurns(turns: readonly ParsedTurn[]): boolean {
156
+ return (
157
+ turns.some((turn) => turn.role === "assistant") &&
158
+ !turns.some((turn) => turn.role === "human")
159
+ );
160
+ }
161
+
162
+ /**
163
+ * Length of the longest prefix of `candidates` that equals a suffix of
164
+ * `source`: the contiguous block a harness copies from earlier history
165
+ * (a compaction tail or a continuation's inherited tail).
166
+ */
167
+ export function copiedPrefixLength(
168
+ candidates: readonly string[],
169
+ source: readonly string[]
170
+ ): number {
171
+ const max = Math.min(candidates.length, source.length);
172
+ for (let length = max; length > 0; length -= 1) {
173
+ const offset = source.length - length;
174
+ let equal = true;
175
+ for (let index = 0; index < length; index += 1) {
176
+ if (candidates[index] !== source[offset + index]) {
177
+ equal = false;
178
+ break;
179
+ }
180
+ }
181
+ if (equal) return length;
182
+ }
183
+ return 0;
184
+ }