@shadowforge0/aquifer-memory 1.5.9 → 1.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 (65) hide show
  1. package/.env.example +23 -0
  2. package/README.md +96 -73
  3. package/README_CN.md +659 -0
  4. package/README_TW.md +680 -0
  5. package/aquifer.config.example.json +34 -0
  6. package/consumers/claude-code.js +11 -11
  7. package/consumers/cli.js +374 -39
  8. package/consumers/codex-handoff.js +152 -0
  9. package/consumers/codex.js +1549 -0
  10. package/consumers/default/daily-entries.js +23 -4
  11. package/consumers/default/index.js +2 -2
  12. package/consumers/default/prompts/summary.js +6 -6
  13. package/consumers/mcp.js +131 -7
  14. package/consumers/openclaw-ext/index.js +0 -1
  15. package/consumers/openclaw-plugin.js +44 -4
  16. package/consumers/shared/config.js +28 -0
  17. package/consumers/shared/factory.js +2 -0
  18. package/consumers/shared/ingest.js +1 -1
  19. package/consumers/shared/normalize.js +14 -3
  20. package/consumers/shared/recall-format.js +53 -0
  21. package/consumers/shared/summary-parser.js +151 -0
  22. package/core/aquifer.js +384 -18
  23. package/core/finalization-review.js +319 -0
  24. package/core/insights.js +210 -58
  25. package/core/mcp-manifest.js +69 -2
  26. package/core/memory-bootstrap.js +188 -0
  27. package/core/memory-consolidation.js +1236 -0
  28. package/core/memory-promotion.js +544 -0
  29. package/core/memory-recall.js +247 -0
  30. package/core/memory-records.js +581 -0
  31. package/core/memory-safety-gate.js +224 -0
  32. package/core/session-finalization.js +350 -0
  33. package/core/storage.js +456 -2
  34. package/docs/getting-started.md +99 -0
  35. package/docs/postprocess-contract.md +2 -2
  36. package/docs/setup.md +51 -2
  37. package/package.json +31 -9
  38. package/pipeline/normalize/adapters/codex.js +106 -0
  39. package/pipeline/normalize/detect.js +3 -2
  40. package/schema/001-base.sql +3 -0
  41. package/schema/007-v1-foundation.sql +273 -0
  42. package/schema/008-session-finalizations.sql +50 -0
  43. package/schema/009-v1-assertion-plane.sql +193 -0
  44. package/schema/010-v1-finalization-review.sql +160 -0
  45. package/schema/011-v1-compaction-claim.sql +46 -0
  46. package/schema/012-v1-compaction-lease.sql +39 -0
  47. package/schema/013-v1-compaction-lineage.sql +193 -0
  48. package/scripts/backfill-canonical-key.js +250 -0
  49. package/scripts/codex-recovery.js +532 -0
  50. package/consumers/miranda/context-inject.js +0 -119
  51. package/consumers/miranda/daily-entries.js +0 -224
  52. package/consumers/miranda/index.js +0 -364
  53. package/consumers/miranda/instance.js +0 -55
  54. package/consumers/miranda/llm.js +0 -99
  55. package/consumers/miranda/profile.json +0 -145
  56. package/consumers/miranda/prompts/summary.js +0 -303
  57. package/consumers/miranda/recall-format.js +0 -76
  58. package/consumers/miranda/render-daily-md.js +0 -186
  59. package/consumers/miranda/workspace-files.js +0 -91
  60. package/scripts/drop-entity-state-history.sql +0 -17
  61. package/scripts/drop-insights.sql +0 -12
  62. package/scripts/install-openclaw.sh +0 -59
  63. package/scripts/queries.json +0 -45
  64. package/scripts/retro-recall-bench.js +0 -409
  65. package/scripts/sample-bench-queries.sql +0 -75
@@ -0,0 +1,1549 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Aquifer — Codex CLI session consumer.
5
+ *
6
+ * Codex writes JSONL rollout files while the TUI is running. This consumer is a
7
+ * source adapter: it knows Codex file layout, session_meta ids, token_count
8
+ * events, and the local marker/claim files needed for safe pull-style sync.
9
+ *
10
+ * Core Aquifer stays generic. The adapter owns:
11
+ * - JSONL -> commit-ready session normalization
12
+ * - idle gating so actively-written files are not imported
13
+ * - DB count reconciliation so later, fuller JSONL snapshots can re-commit
14
+ * - short/empty session skip policy
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const os = require('os');
20
+ const crypto = require('crypto');
21
+
22
+ const DEFAULT_CODEX_HOME = path.join(os.homedir(), '.codex');
23
+ const { normalizeMessages } = require('./shared/normalize');
24
+ const { applyEnrichSafetyGate } = require('../core/memory-safety-gate');
25
+ const DEFAULT_IDLE_MS = 5 * 60 * 1000;
26
+ const DEFAULT_CLAIM_TTL_MS = 5 * 60 * 1000;
27
+ const DEFAULT_MIN_BYTES = 1000;
28
+ const DEFAULT_MAX_IMPORTS = 1;
29
+ const DEFAULT_MAX_AFTERBURNS = 1;
30
+ const DEFAULT_MIN_IMPORT_USER_MESSAGES = 3;
31
+ const MAX_RETRY_COUNT = 3;
32
+ const SAFE_SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
33
+ const DEFAULT_RECOVERY_MAX_BYTES = 1024 * 1024;
34
+ const DEFAULT_RECOVERY_MAX_MESSAGES = 80;
35
+ const DEFAULT_RECOVERY_MAX_CHARS = 24000;
36
+ const DEFAULT_RECOVERY_MAX_PROMPT_TOKENS = 9000;
37
+ const RECOVERY_DECISIONS = new Set(['declined', 'deferred', 'skipped']);
38
+
39
+ function ensureDirs(...dirs) {
40
+ for (const dir of dirs.filter(Boolean)) fs.mkdirSync(dir, { recursive: true });
41
+ }
42
+
43
+ function readJsonlEntries(filePath) {
44
+ const raw = fs.readFileSync(filePath, 'utf8');
45
+ const entries = [];
46
+ for (const line of raw.split('\n')) {
47
+ if (!line.trim()) continue;
48
+ try {
49
+ entries.push(JSON.parse(line));
50
+ } catch {
51
+ // Codex can leave a partial trailing write. Ignore malformed lines.
52
+ }
53
+ }
54
+ return entries;
55
+ }
56
+
57
+ function assertSafeSessionId(sessionId, field = 'sessionId') {
58
+ const value = String(sessionId || '').trim();
59
+ if (!SAFE_SESSION_ID_RE.test(value)) {
60
+ throw new Error(`Invalid ${field}: must match ${SAFE_SESSION_ID_RE}`);
61
+ }
62
+ return value;
63
+ }
64
+
65
+ function encodeMarkerValue(value) {
66
+ return Buffer.from(String(value || ''), 'utf8').toString('base64url');
67
+ }
68
+
69
+ function decodeMarkerValue(value) {
70
+ try {
71
+ return Buffer.from(String(value || ''), 'base64url').toString('utf8');
72
+ } catch {
73
+ return '';
74
+ }
75
+ }
76
+
77
+ function safeMarkerKey(sessionId) {
78
+ const safeSessionId = assertSafeSessionId(sessionId);
79
+ return crypto.createHash('sha256').update(safeSessionId).digest('hex').slice(0, 32);
80
+ }
81
+
82
+ function legacyMarkerPath(dir, sessionId) {
83
+ try {
84
+ const safeSessionId = assertSafeSessionId(sessionId);
85
+ return path.join(dir, safeSessionId);
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ function markerPath(dir, sessionId) {
92
+ return path.join(dir, safeMarkerKey(sessionId));
93
+ }
94
+
95
+ function readMarkerFile(dir, sessionId) {
96
+ if (!dir) return null;
97
+ const digestPath = markerPath(dir, sessionId);
98
+ try {
99
+ return { path: digestPath, content: fs.readFileSync(digestPath, 'utf8').trim(), legacy: false };
100
+ } catch {}
101
+
102
+ const legacyPath = legacyMarkerPath(dir, sessionId);
103
+ if (!legacyPath) return null;
104
+ try {
105
+ return { path: legacyPath, content: fs.readFileSync(legacyPath, 'utf8').trim(), legacy: true };
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ function readMarkerSessionId(content, fallback = null) {
112
+ const match = String(content || '').match(/^session:([A-Za-z0-9_-]+)$/m);
113
+ const decoded = match ? decodeMarkerValue(match[1]) : fallback;
114
+ if (!decoded) return null;
115
+ try {
116
+ return assertSafeSessionId(decoded);
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ function readMarkerMetadataFromContent(content) {
123
+ const match = String(content || '').match(/^metadata:([A-Za-z0-9_-]+)$/m);
124
+ if (!match) return {};
125
+ try {
126
+ const parsed = JSON.parse(decodeMarkerValue(match[1]));
127
+ return parsed && typeof parsed === 'object' ? parsed : {};
128
+ } catch {
129
+ return {};
130
+ }
131
+ }
132
+
133
+ function listMarkerEntries(dir) {
134
+ if (!dir) return [];
135
+ let names = [];
136
+ try {
137
+ names = fs.readdirSync(dir).filter(Boolean);
138
+ } catch {
139
+ return [];
140
+ }
141
+ const entries = [];
142
+ for (const name of names) {
143
+ const filePath = path.join(dir, name);
144
+ try {
145
+ const stat = fs.statSync(filePath);
146
+ if (!stat.isFile()) continue;
147
+ const content = fs.readFileSync(filePath, 'utf8').trim();
148
+ const sessionId = readMarkerSessionId(content, name);
149
+ if (!sessionId) continue;
150
+ entries.push({
151
+ sessionId,
152
+ markerPath: filePath,
153
+ markerName: name,
154
+ content,
155
+ metadata: readMarkerMetadataFromContent(content),
156
+ stat,
157
+ });
158
+ } catch {}
159
+ }
160
+ return entries;
161
+ }
162
+
163
+ function recoveryDecisionKey(candidate = {}) {
164
+ const metadata = candidate.metadata || {};
165
+ const payload = {
166
+ sessionId: candidate.sessionId || null,
167
+ fileSessionId: candidate.fileSessionId || metadata.fileSessionId || null,
168
+ transcriptHash: candidate.transcriptHash || metadata.transcriptHash || metadata.transcript_hash || null,
169
+ filePath: candidate.filePath || metadata.filePath || null,
170
+ size: candidate.size || metadata.size || null,
171
+ mtimeMs: candidate.mtimeMs || metadata.mtimeMs || null,
172
+ phase: candidate.phase || metadata.phase || 'curated_memory_v1',
173
+ };
174
+ return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 32);
175
+ }
176
+
177
+ function recoveryDecisionPath(dir, candidate = {}) {
178
+ return path.join(dir, recoveryDecisionKey(candidate));
179
+ }
180
+
181
+ function readRecoveryDecision(paths, candidate = {}) {
182
+ if (!paths.decisionDir) return null;
183
+ try {
184
+ const content = fs.readFileSync(recoveryDecisionPath(paths.decisionDir, candidate), 'utf8').trim();
185
+ const firstLine = content.split(/\r?\n/)[0] || '';
186
+ const [, status] = firstLine.split(/\s+/);
187
+ if (!RECOVERY_DECISIONS.has(status)) return null;
188
+ return {
189
+ status,
190
+ metadata: readMarkerMetadataFromContent(content),
191
+ };
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ function writeRecoveryDecision(paths, candidate = {}, status, metadata = {}) {
198
+ if (!RECOVERY_DECISIONS.has(status)) throw new Error(`Invalid recovery decision: ${status}`);
199
+ ensureDirs(paths.decisionDir);
200
+ const content = [
201
+ `${new Date().toISOString()} ${status}`,
202
+ `metadata:${encodeMarkerValue(JSON.stringify({
203
+ sessionId: candidate.sessionId || null,
204
+ fileSessionId: candidate.fileSessionId || null,
205
+ transcriptHash: candidate.transcriptHash || null,
206
+ filePath: candidate.filePath || null,
207
+ ...metadata,
208
+ }))}`,
209
+ ].join('\n');
210
+ fs.writeFileSync(recoveryDecisionPath(paths.decisionDir, candidate), `${content}\n`, 'utf8');
211
+ }
212
+
213
+ function hashNormalizedTranscript(normalized = {}) {
214
+ const messages = Array.isArray(normalized.messages) ? normalized.messages : [];
215
+ const payload = {
216
+ messages: messages.map(message => ({
217
+ role: message.role || null,
218
+ content: message.content || '',
219
+ timestamp: message.timestamp || null,
220
+ })),
221
+ };
222
+ return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
223
+ }
224
+
225
+ function normalizeCodexEntries(entries, opts = {}) {
226
+ const fileSessionId = opts.fileSessionId || null;
227
+ let sessionId = fileSessionId ? assertSafeSessionId(fileSessionId, 'fileSessionId') : null;
228
+
229
+ for (const obj of entries || []) {
230
+ if (!obj || typeof obj !== 'object') continue;
231
+
232
+ if (obj.type === 'session_meta') {
233
+ sessionId = obj.payload?.id ? assertSafeSessionId(obj.payload.id, 'session_meta.id') : sessionId;
234
+ }
235
+ }
236
+
237
+ const normalized = normalizeMessages(entries || [], { adapter: 'codex' });
238
+ const result = {
239
+ sessionId,
240
+ fileSessionId,
241
+ ...normalized,
242
+ model: normalized.model || 'codex-cli',
243
+ };
244
+
245
+ return {
246
+ ...result,
247
+ transcriptHash: hashNormalizedTranscript(result),
248
+ };
249
+ }
250
+
251
+ function parseCodexSessionFile(filePath) {
252
+ const fileSessionId = path.basename(filePath, '.jsonl');
253
+ const rawEntries = readJsonlEntries(filePath);
254
+ const normalized = normalizeCodexEntries(rawEntries, { fileSessionId });
255
+ return {
256
+ path: filePath,
257
+ fileSessionId,
258
+ sessionId: normalized.sessionId || fileSessionId,
259
+ rawEntries,
260
+ normalized,
261
+ };
262
+ }
263
+
264
+ function walkJsonlFiles(dir, acc = []) {
265
+ let entries = [];
266
+ try {
267
+ entries = fs.readdirSync(dir, { withFileTypes: true });
268
+ } catch {
269
+ return acc;
270
+ }
271
+
272
+ for (const entry of entries) {
273
+ const full = path.join(dir, entry.name);
274
+ if (entry.isDirectory()) {
275
+ walkJsonlFiles(full, acc);
276
+ continue;
277
+ }
278
+ if (entry.isFile() && entry.name.endsWith('.jsonl')) {
279
+ try {
280
+ const stat = fs.statSync(full);
281
+ acc.push({ path: full, fileSessionId: path.basename(entry.name, '.jsonl'), stat });
282
+ } catch {}
283
+ }
284
+ }
285
+ return acc;
286
+ }
287
+
288
+ function normalizeStringSet(values) {
289
+ const out = new Set();
290
+ const input = Array.isArray(values) ? values : String(values || '').split(',');
291
+ for (const value of input) {
292
+ const cleaned = String(value || '').trim();
293
+ if (cleaned) out.add(cleaned);
294
+ }
295
+ return out;
296
+ }
297
+
298
+ function normalizePathSet(values) {
299
+ const out = new Set();
300
+ for (const value of normalizeStringSet(values)) out.add(path.resolve(value));
301
+ return out;
302
+ }
303
+
304
+ function shouldExcludeFile(entry, opts = {}) {
305
+ const excludePaths = normalizePathSet(opts.excludePaths || opts.excludeFilePaths);
306
+ return excludePaths.has(path.resolve(entry.path));
307
+ }
308
+
309
+ function shouldExcludeCandidate(candidate, opts = {}) {
310
+ const excludeSessionIds = normalizeStringSet(opts.excludeSessionIds);
311
+ if (excludeSessionIds.has(candidate.sessionId)) return true;
312
+ if (excludeSessionIds.has(candidate.fileSessionId)) return true;
313
+ return false;
314
+ }
315
+
316
+ function matchesRecoveryProvenance(metadata = {}, opts = {}, defaults = {}) {
317
+ const expected = {
318
+ source: opts.source || defaults.source || 'codex',
319
+ agentId: opts.agentId || defaults.agentId || 'main',
320
+ sessionKey: opts.sessionKey || null,
321
+ workspace: opts.workspace || opts.workspacePath || null,
322
+ project: opts.project || opts.projectKey || null,
323
+ repoPath: opts.repoPath || null,
324
+ };
325
+ for (const [key, expectedValue] of Object.entries(expected)) {
326
+ if (!expectedValue || !metadata[key]) continue;
327
+ if (String(metadata[key]) !== String(expectedValue)) return false;
328
+ }
329
+ if (metadata.source && metadata.source !== expected.source) return false;
330
+ if (metadata.agentId && metadata.agentId !== expected.agentId) return false;
331
+ return true;
332
+ }
333
+
334
+ function isIdleEnough(entry, idleMs, now = Date.now()) {
335
+ if (!Number.isFinite(idleMs) || idleMs <= 0) return true;
336
+ return now - entry.stat.mtimeMs >= idleMs;
337
+ }
338
+
339
+ function readMarker(dir, sessionId) {
340
+ const marker = readMarkerFile(dir, sessionId);
341
+ return marker ? marker.content : null;
342
+ }
343
+
344
+ function readMarkerLabel(dir, sessionId) {
345
+ const marker = readMarker(dir, sessionId);
346
+ if (!marker) return null;
347
+ const firstLine = marker.split(/\r?\n/)[0] || '';
348
+ return firstLine.split(/\s+/)[1] || null;
349
+ }
350
+
351
+ function readMarkerRetries(dir, sessionId) {
352
+ const marker = readMarker(dir, sessionId);
353
+ if (!marker) return 0;
354
+ const match = marker.match(/retries:(\d+)/);
355
+ return match ? parseInt(match[1], 10) : 0;
356
+ }
357
+
358
+ function isTerminalAfterburnLabel(label) {
359
+ if (!label) return false;
360
+ return label === 'done' || label.startsWith('fatal:');
361
+ }
362
+
363
+ function hasFinalizationSummary(summary = {}) {
364
+ if (typeof summary === 'string') return String(summary).trim().length > 0;
365
+ const input = summary && typeof summary === 'object' ? summary : {};
366
+ const summaryText = String(input.summaryText || input.summary || '').trim();
367
+ const structuredSummary = input.structuredSummary || {};
368
+ return Boolean(summaryText) || Object.keys(structuredSummary).length > 0;
369
+ }
370
+
371
+ function writeMarker(dir, sessionId, label = 'done', metadata = {}) {
372
+ if (!dir) return;
373
+ ensureDirs(dir);
374
+ const safeSessionId = assertSafeSessionId(sessionId);
375
+ const suffix = label ? ` ${label}` : '';
376
+ const lines = [
377
+ `${new Date().toISOString()}${suffix}`,
378
+ `session:${encodeMarkerValue(safeSessionId)}`,
379
+ ];
380
+ if (metadata && Object.keys(metadata).length > 0) {
381
+ lines.push(`metadata:${encodeMarkerValue(JSON.stringify(metadata))}`);
382
+ }
383
+ fs.writeFileSync(markerPath(dir, safeSessionId), `${lines.join('\n')}\n`, 'utf8');
384
+ }
385
+
386
+ function deleteMarker(dir, sessionId) {
387
+ if (!dir) return;
388
+ try { fs.unlinkSync(markerPath(dir, sessionId)); } catch {}
389
+ const legacyPath = legacyMarkerPath(dir, sessionId);
390
+ if (legacyPath) {
391
+ try { fs.unlinkSync(legacyPath); } catch {}
392
+ }
393
+ }
394
+
395
+ function claimSession(claimDir, sessionId) {
396
+ if (!claimDir) return true;
397
+ ensureDirs(claimDir);
398
+ try {
399
+ const fd = fs.openSync(markerPath(claimDir, sessionId), 'wx');
400
+ fs.writeSync(fd, `${process.pid}:${Date.now()}\n`);
401
+ fs.closeSync(fd);
402
+ return true;
403
+ } catch (err) {
404
+ if (err.code === 'EEXIST') return false;
405
+ throw err;
406
+ }
407
+ }
408
+
409
+ function releaseClaim(claimDir, sessionId) {
410
+ deleteMarker(claimDir, sessionId);
411
+ }
412
+
413
+ function isClaimActive(claimDir, sessionId, claimTtlMs = DEFAULT_CLAIM_TTL_MS) {
414
+ if (!claimDir) return false;
415
+ try {
416
+ const [pidStr, tsStr] = fs.readFileSync(markerPath(claimDir, sessionId), 'utf8').trim().split(':');
417
+ const pid = parseInt(pidStr, 10);
418
+ const ts = parseInt(tsStr, 10);
419
+
420
+ if (!Number.isFinite(pid) || !Number.isFinite(ts)) {
421
+ deleteMarker(claimDir, sessionId);
422
+ return false;
423
+ }
424
+ try {
425
+ process.kill(pid, 0);
426
+ } catch {
427
+ deleteMarker(claimDir, sessionId);
428
+ return false;
429
+ }
430
+ if (Date.now() - ts > claimTtlMs) {
431
+ deleteMarker(claimDir, sessionId);
432
+ return false;
433
+ }
434
+ return true;
435
+ } catch {
436
+ return false;
437
+ }
438
+ }
439
+
440
+ function defaultPaths(opts = {}) {
441
+ const codexHome = opts.codexHome || DEFAULT_CODEX_HOME;
442
+ const stateDir = opts.stateDir || path.join(codexHome, 'data');
443
+ return {
444
+ codexHome,
445
+ sessionsDir: opts.sessionsDir || path.join(codexHome, 'sessions'),
446
+ importedDir: opts.importedDir || path.join(stateDir, 'codex-sessions-imported'),
447
+ afterburnedDir: opts.afterburnedDir || path.join(stateDir, 'codex-sessions-afterburned'),
448
+ claimDir: opts.claimDir || path.join(stateDir, 'codex-sessions-claiming'),
449
+ decisionDir: opts.decisionDir || path.join(stateDir, 'codex-recovery-decisions'),
450
+ };
451
+ }
452
+
453
+ async function getExistingSession(aquifer, sessionId, agentId, opts = {}) {
454
+ if (!aquifer || typeof aquifer.getSession !== 'function') return null;
455
+ return aquifer.getSession(sessionId, { agentId, source: opts.source || undefined });
456
+ }
457
+
458
+ function readCommittedTranscriptHash(session = {}) {
459
+ const messages = session.messages || session.rawMessages || null;
460
+ if (!messages || typeof messages !== 'object') return null;
461
+ const metadata = messages.metadata || {};
462
+ return metadata.transcript_hash || metadata.transcriptHash || null;
463
+ }
464
+
465
+ function committedSnapshotMatchesView(session = {}, view = {}) {
466
+ if (!session) return false;
467
+ const viewCount = view.counts?.safeMessageCount || (Array.isArray(view.messages) ? view.messages.length : 0);
468
+ const dbMsgCount = Number(session.msg_count || session.msgCount || 0);
469
+ if (viewCount !== dbMsgCount) return false;
470
+
471
+ const committedHash = readCommittedTranscriptHash(session);
472
+ if (committedHash && view.transcriptHash && committedHash !== view.transcriptHash) return false;
473
+ return true;
474
+ }
475
+
476
+ async function needsImport(aquifer, candidate, opts = {}) {
477
+ const { importedDir, agentId = 'main', minImportUserMessages = DEFAULT_MIN_IMPORT_USER_MESSAGES } = opts;
478
+ const norm = candidate.normalized;
479
+ const marker = readMarker(importedDir, candidate.sessionId);
480
+
481
+ if (norm.userCount < minImportUserMessages || norm.messages.length === 0) {
482
+ return !(marker && marker.includes('skip:'));
483
+ }
484
+ if (!marker) return true;
485
+
486
+ const existing = await getExistingSession(aquifer, candidate.sessionId, agentId);
487
+ if (!existing) return true;
488
+
489
+ const dbMsgCount = Number(existing.msg_count || existing.msgCount || 0);
490
+ const dbUserCount = Number(existing.user_count || existing.userCount || 0);
491
+ return norm.messages.length > dbMsgCount || norm.userCount > dbUserCount;
492
+ }
493
+
494
+ async function findImportCandidates(aquifer, opts = {}) {
495
+ const paths = defaultPaths(opts);
496
+ const minBytes = opts.minSessionBytes ?? DEFAULT_MIN_BYTES;
497
+ const idleMs = opts.idleMs ?? DEFAULT_IDLE_MS;
498
+ const maxImports = opts.maxImports ?? DEFAULT_MAX_IMPORTS;
499
+ if (!Number.isFinite(maxImports) || maxImports <= 0) return [];
500
+
501
+ ensureDirs(paths.importedDir, paths.afterburnedDir, paths.claimDir);
502
+ let files = walkJsonlFiles(paths.sessionsDir)
503
+ .filter((entry) => !shouldExcludeFile(entry, opts))
504
+ .filter((entry) => entry.stat.size >= minBytes)
505
+ .filter((entry) => isIdleEnough(entry, idleMs, opts.now))
506
+ .sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
507
+ if (opts.excludeNewest && files.length > 0) files = files.slice(1);
508
+
509
+ const candidates = [];
510
+ for (const entry of files) {
511
+ let candidate;
512
+ try {
513
+ candidate = { ...entry, ...parseCodexSessionFile(entry.path) };
514
+ } catch {
515
+ continue;
516
+ }
517
+ if (shouldExcludeCandidate(candidate, opts)) continue;
518
+ if (isClaimActive(paths.claimDir, candidate.sessionId, opts.claimTtlMs)) continue;
519
+ if (!(await needsImport(aquifer, candidate, { ...opts, importedDir: paths.importedDir }))) continue;
520
+ candidates.push(candidate);
521
+ if (candidates.length >= maxImports) break;
522
+ }
523
+ return candidates;
524
+ }
525
+
526
+ async function needsAfterburn(aquifer, candidate, opts = {}) {
527
+ const paths = defaultPaths(opts);
528
+ const { agentId = 'main' } = opts;
529
+ const importedMarker = readMarkerFile(paths.importedDir, candidate.sessionId);
530
+ const imported = importedMarker ? importedMarker.content : null;
531
+ if (!imported || imported.includes('skip:')) return false;
532
+ const importedMetadata = candidate.metadata || readMarkerMetadataFromContent(imported);
533
+
534
+ const finalization = await readFinalization(aquifer, {
535
+ sessionId: candidate.sessionId,
536
+ agentId: importedMetadata.agentId || agentId,
537
+ source: importedMetadata.source || opts.source || 'codex',
538
+ transcriptHash: candidate.transcriptHash || importedMetadata.transcriptHash || importedMetadata.transcript_hash || null,
539
+ });
540
+ if (isRecoverySuppressed(finalization)) return false;
541
+
542
+ const afterburnedLabel = readMarkerLabel(paths.afterburnedDir, candidate.sessionId);
543
+ if (isTerminalAfterburnLabel(afterburnedLabel) && afterburnedLabel !== 'done') return false;
544
+ if ((afterburnedLabel === 'timeout' || afterburnedLabel === 'not-found' || afterburnedLabel?.startsWith('failed:'))
545
+ && readMarkerRetries(paths.afterburnedDir, candidate.sessionId) >= MAX_RETRY_COUNT) {
546
+ return false;
547
+ }
548
+
549
+ const existing = await getExistingSession(aquifer, candidate.sessionId, agentId);
550
+ if (!existing) return false;
551
+ const status = existing.processing_status || existing.processingStatus;
552
+
553
+ if (afterburnedLabel === 'enrich-only' || afterburnedLabel === 'backfill-pending') return true;
554
+ if (afterburnedLabel === 'done') {
555
+ return finalization ? finalization.status === 'pending' || finalization.status === 'failed' : true;
556
+ }
557
+ return status === 'pending' || status === 'failed';
558
+ }
559
+
560
+ async function findAfterburnCandidates(aquifer, opts = {}) {
561
+ const paths = defaultPaths(opts);
562
+ const maxAfterburns = opts.maxAfterburns ?? DEFAULT_MAX_AFTERBURNS;
563
+ if (!Number.isFinite(maxAfterburns) || maxAfterburns <= 0) return [];
564
+
565
+ ensureDirs(paths.importedDir, paths.afterburnedDir, paths.claimDir);
566
+ const markers = listMarkerEntries(paths.importedDir)
567
+ .map((marker) => {
568
+ const sessionId = marker.sessionId;
569
+ const label = readMarkerLabel(paths.afterburnedDir, sessionId);
570
+ const imported = marker.content;
571
+ if (!imported || imported.includes('skip:')) return null;
572
+ const transcriptHash = marker.metadata.transcriptHash || marker.metadata.transcript_hash || null;
573
+ if (isTerminalAfterburnLabel(label) && label !== 'done') return null;
574
+ if (isClaimActive(paths.claimDir, sessionId, opts.claimTtlMs)) return null;
575
+ if ((label === 'timeout' || label === 'not-found' || label?.startsWith('failed:'))
576
+ && readMarkerRetries(paths.afterburnedDir, sessionId) >= MAX_RETRY_COUNT) {
577
+ return null;
578
+ }
579
+ const priority = label === 'backfill-pending' ? 2 : (label === 'enrich-only' || label === 'done' ? 1 : 0);
580
+ return { sessionId, stat: marker.stat, priority, metadata: marker.metadata, transcriptHash };
581
+ })
582
+ .filter(Boolean)
583
+ .sort((a, b) => b.priority - a.priority || b.stat.mtimeMs - a.stat.mtimeMs);
584
+
585
+ const candidates = [];
586
+ for (const marker of markers) {
587
+ const candidate = { sessionId: marker.sessionId };
588
+ if (shouldExcludeCandidate(candidate, opts)) continue;
589
+ if (!(await needsAfterburn(aquifer, candidate, opts))) continue;
590
+ candidates.push(candidate);
591
+ if (candidates.length >= maxAfterburns) break;
592
+ }
593
+ return candidates;
594
+ }
595
+
596
+ async function importCandidate(aquifer, candidate, opts = {}) {
597
+ const paths = defaultPaths(opts);
598
+ const {
599
+ agentId = 'main',
600
+ source = 'codex',
601
+ sessionKey = 'codex:cli',
602
+ minImportUserMessages = DEFAULT_MIN_IMPORT_USER_MESSAGES,
603
+ } = opts;
604
+ const norm = candidate.normalized;
605
+
606
+ if (norm.userCount < minImportUserMessages || norm.messages.length === 0) {
607
+ writeMarker(paths.importedDir, candidate.sessionId, 'skip:short-import', {
608
+ transcriptHash: norm.transcriptHash,
609
+ filePath: candidate.path,
610
+ fileSessionId: candidate.fileSessionId,
611
+ messageCount: norm.messages.length,
612
+ userCount: norm.userCount,
613
+ assistantCount: norm.assistantCount,
614
+ source,
615
+ agentId,
616
+ sessionKey,
617
+ reason: `user_count=${norm.userCount} < min=${minImportUserMessages}`,
618
+ });
619
+ writeMarker(paths.afterburnedDir, candidate.sessionId, `skip:short-import user_count=${norm.userCount}`, {
620
+ transcriptHash: norm.transcriptHash,
621
+ source,
622
+ agentId,
623
+ sessionKey,
624
+ });
625
+ return { status: 'skipped_empty', sessionId: candidate.sessionId, counts: norm };
626
+ }
627
+
628
+ await aquifer.commit(candidate.sessionId, norm.messages, {
629
+ rawMessages: {
630
+ normalized: norm.messages,
631
+ metadata: {
632
+ transcript_hash: norm.transcriptHash,
633
+ skipStats: norm.skipStats,
634
+ boundaries: norm.boundaries,
635
+ toolsUsed: norm.toolsUsed,
636
+ raw_entry_count: Array.isArray(candidate.rawEntries) ? candidate.rawEntries.length : 0,
637
+ },
638
+ },
639
+ agentId,
640
+ source,
641
+ sessionKey,
642
+ model: norm.model,
643
+ tokensIn: norm.tokensIn,
644
+ tokensOut: norm.tokensOut,
645
+ startedAt: norm.startedAt,
646
+ lastMessageAt: norm.lastMessageAt,
647
+ });
648
+
649
+ if (aquifer.finalization && typeof aquifer.finalization.createTask === 'function') {
650
+ await aquifer.finalization.createTask({
651
+ sessionId: candidate.sessionId,
652
+ agentId,
653
+ source,
654
+ host: 'codex',
655
+ transcriptHash: norm.transcriptHash,
656
+ mode: 'afterburn',
657
+ status: 'pending',
658
+ metadata: {
659
+ filePath: candidate.path,
660
+ fileSessionId: candidate.fileSessionId,
661
+ messageCount: norm.messages.length,
662
+ userCount: norm.userCount,
663
+ assistantCount: norm.assistantCount,
664
+ importedAt: new Date().toISOString(),
665
+ },
666
+ });
667
+ }
668
+
669
+ writeMarker(paths.importedDir, candidate.sessionId, '', {
670
+ transcriptHash: norm.transcriptHash,
671
+ filePath: candidate.path,
672
+ fileSessionId: candidate.fileSessionId,
673
+ messageCount: norm.messages.length,
674
+ userCount: norm.userCount,
675
+ assistantCount: norm.assistantCount,
676
+ source,
677
+ agentId,
678
+ sessionKey,
679
+ });
680
+ deleteMarker(paths.afterburnedDir, candidate.sessionId);
681
+ return { status: 'imported', sessionId: candidate.sessionId, counts: norm };
682
+ }
683
+
684
+ async function afterburnCandidate(aquifer, candidate, opts = {}) {
685
+ const paths = defaultPaths(opts);
686
+ const {
687
+ agentId = 'main',
688
+ source = 'codex',
689
+ sessionKey = 'codex:cli',
690
+ minUserMessages = 3,
691
+ finalizationSummary = null,
692
+ finalizationSummaryFn = null,
693
+ summaryFn = null,
694
+ entityParseFn = null,
695
+ postProcess = null,
696
+ replayPostProcess = null,
697
+ buildHooks = null,
698
+ logger = console,
699
+ } = opts;
700
+
701
+ const existing = await getExistingSession(aquifer, candidate.sessionId, agentId);
702
+ if (!existing) return { status: 'missing', sessionId: candidate.sessionId };
703
+
704
+ const importedMetadata = candidate.metadata
705
+ || readMarkerMetadataFromContent(readMarker(paths.importedDir, candidate.sessionId) || '');
706
+ const finalization = await readFinalization(aquifer, {
707
+ sessionId: candidate.sessionId,
708
+ agentId: importedMetadata.agentId || agentId,
709
+ source: importedMetadata.source || source,
710
+ transcriptHash: candidate.transcriptHash || importedMetadata.transcriptHash || importedMetadata.transcript_hash || null,
711
+ });
712
+ if (isRecoverySuppressed(finalization)) {
713
+ writeMarker(paths.afterburnedDir, candidate.sessionId, 'done', {
714
+ finalizationStatus: finalization.status,
715
+ transcriptHash: candidate.transcriptHash || importedMetadata.transcriptHash || importedMetadata.transcript_hash || null,
716
+ });
717
+ return { status: 'suppressed', sessionId: candidate.sessionId, finalizationStatus: finalization.status };
718
+ }
719
+
720
+ const markerLabel = readMarkerLabel(paths.afterburnedDir, candidate.sessionId);
721
+ if (markerLabel === 'enrich-only') {
722
+ if (typeof replayPostProcess !== 'function') {
723
+ return { status: 'missing_replay', sessionId: candidate.sessionId };
724
+ }
725
+ await replayPostProcess(candidate.sessionId, { agentId, candidate, existing });
726
+ writeMarker(paths.afterburnedDir, candidate.sessionId, 'done');
727
+ return { status: 'afterburned', sessionId: candidate.sessionId, replayed: true };
728
+ }
729
+
730
+ const userCount = Number(existing.user_count || existing.userCount || candidate.normalized?.userCount || 0);
731
+ if (userCount < minUserMessages) {
732
+ try {
733
+ await aquifer.skip(candidate.sessionId, { agentId, reason: `user_count=${userCount} < min=${minUserMessages}` });
734
+ } catch (err) {
735
+ if (logger && logger.warn) logger.warn(`[codex-consumer] skip failed for ${candidate.sessionId}: ${err.message}`);
736
+ }
737
+ await markFinalizationSkipped(aquifer, {
738
+ ...candidate,
739
+ transcriptHash: candidate.transcriptHash || importedMetadata.transcriptHash || importedMetadata.transcript_hash || null,
740
+ metadata: importedMetadata,
741
+ }, {
742
+ agentId,
743
+ source,
744
+ reason: `user_count=${userCount} < min=${minUserMessages}`,
745
+ mode: 'afterburn',
746
+ });
747
+ writeMarker(paths.afterburnedDir, candidate.sessionId, `skip:short user_count=${userCount}`);
748
+ return { status: 'skipped_short', sessionId: candidate.sessionId, userCount };
749
+ }
750
+
751
+ const hooks = typeof buildHooks === 'function'
752
+ ? (await buildHooks(candidate.sessionId, agentId, candidate))
753
+ : {};
754
+ const summaryInput = hooks?.finalizationSummary || finalizationSummary;
755
+ const summaryProvider = hooks?.finalizationSummaryFn || finalizationSummaryFn || hooks?.summaryFn || summaryFn || null;
756
+ const recoveryCandidate = {
757
+ ...candidate,
758
+ filePath: candidate.filePath || candidate.path || importedMetadata.filePath || null,
759
+ transcriptHash: candidate.transcriptHash || importedMetadata.transcriptHash || importedMetadata.transcript_hash || null,
760
+ metadata: { ...importedMetadata, ...(candidate.metadata || {}) },
761
+ };
762
+ const view = materializeRecoveryTranscriptView(recoveryCandidate, opts);
763
+ if (view.status !== 'ok') {
764
+ writeMarker(paths.afterburnedDir, candidate.sessionId, 'backfill-pending', {
765
+ reason: view.status,
766
+ transcriptHash: recoveryCandidate.transcriptHash,
767
+ });
768
+ return { status: 'backfill_pending', sessionId: candidate.sessionId, reason: view.status, view };
769
+ }
770
+
771
+ let resolvedSummary = summaryInput;
772
+ if (!hasFinalizationSummary(resolvedSummary) && typeof summaryProvider === 'function') {
773
+ resolvedSummary = await summaryProvider(view.messages, {
774
+ aquifer,
775
+ candidate: recoveryCandidate,
776
+ existing,
777
+ view,
778
+ agentId,
779
+ source,
780
+ sessionKey,
781
+ entityParseFn: hooks?.entityParseFn || entityParseFn || null,
782
+ postProcess: hooks?.postProcess || postProcess || null,
783
+ });
784
+ }
785
+ if (!hasFinalizationSummary(resolvedSummary)) {
786
+ writeMarker(paths.afterburnedDir, candidate.sessionId, 'backfill-pending', {
787
+ reason: 'missing_summary',
788
+ transcriptHash: view.transcriptHash,
789
+ });
790
+ return {
791
+ status: 'missing_summary',
792
+ sessionId: candidate.sessionId,
793
+ finalizationStatus: finalization ? finalization.status : 'pending',
794
+ view,
795
+ };
796
+ }
797
+
798
+ const result = await finalizeCodexSession(aquifer, {
799
+ view,
800
+ summary: typeof resolvedSummary === 'string' ? { summaryText: resolvedSummary, structuredSummary: {} } : resolvedSummary,
801
+ mode: 'afterburn',
802
+ agentId,
803
+ source,
804
+ sessionKey,
805
+ finalizerModel: hooks?.finalizerModel || opts.finalizerModel || null,
806
+ }, opts);
807
+ writeMarker(paths.afterburnedDir, candidate.sessionId, 'done', {
808
+ finalizationStatus: result.status,
809
+ transcriptHash: view.transcriptHash,
810
+ });
811
+ return {
812
+ status: 'afterburned',
813
+ sessionId: candidate.sessionId,
814
+ finalization: result,
815
+ reviewText: result.reviewText || result.humanReviewText || '',
816
+ humanReviewText: result.humanReviewText || '',
817
+ sessionStartText: result.sessionStartText || '',
818
+ };
819
+ }
820
+
821
+ async function readFinalization(aquifer, input = {}) {
822
+ if (!aquifer || !aquifer.finalization || typeof aquifer.finalization.get !== 'function') return null;
823
+ if (!input.transcriptHash) return null;
824
+ try {
825
+ return await aquifer.finalization.get(input);
826
+ } catch {
827
+ return null;
828
+ }
829
+ }
830
+
831
+ function isRecoverySuppressed(finalization, opts = {}) {
832
+ if (!finalization) return false;
833
+ if (finalization.status === 'deferred' && opts.includeDeferredRecovery) return false;
834
+ return ['finalized', 'skipped', 'declined', 'deferred'].includes(finalization.status);
835
+ }
836
+
837
+ async function markFinalizationSkipped(aquifer, candidate = {}, opts = {}) {
838
+ const transcriptHash = candidate.transcriptHash || candidate.metadata?.transcriptHash || candidate.metadata?.transcript_hash || null;
839
+ if (!aquifer || !aquifer.finalization || !transcriptHash) return null;
840
+ const input = {
841
+ sessionId: candidate.sessionId,
842
+ agentId: opts.agentId || candidate.agentId || candidate.metadata?.agentId || 'main',
843
+ source: opts.source || candidate.source || candidate.metadata?.source || 'codex',
844
+ transcriptHash,
845
+ phase: opts.phase || candidate.phase || 'curated_memory_v1',
846
+ status: 'skipped',
847
+ error: opts.reason || 'skipped',
848
+ metadata: {
849
+ reason: opts.reason || 'skipped',
850
+ skippedAt: new Date().toISOString(),
851
+ ...(opts.metadata || {}),
852
+ },
853
+ };
854
+ try {
855
+ if (typeof aquifer.finalization.updateStatus === 'function') {
856
+ const updated = await aquifer.finalization.updateStatus(input);
857
+ if (updated) return updated;
858
+ }
859
+ if (typeof aquifer.finalization.createTask === 'function') {
860
+ return await aquifer.finalization.createTask({
861
+ ...input,
862
+ mode: opts.mode || 'session_start_recovery',
863
+ });
864
+ }
865
+ } catch {
866
+ return null;
867
+ }
868
+ return null;
869
+ }
870
+
871
+ async function findRecoveryCandidates(aquifer, opts = {}) {
872
+ const paths = defaultPaths(opts);
873
+ const {
874
+ agentId = 'main',
875
+ source = 'codex',
876
+ maxRecoveryCandidates = 3,
877
+ includeJsonlPreviews = false,
878
+ } = opts;
879
+ if (!Number.isFinite(maxRecoveryCandidates) || maxRecoveryCandidates <= 0) return [];
880
+ ensureDirs(paths.importedDir, paths.afterburnedDir, paths.claimDir, paths.decisionDir);
881
+
882
+ const candidates = [];
883
+ const seenFiles = new Set();
884
+
885
+ for (const marker of listMarkerEntries(paths.importedDir)) {
886
+ const metadata = marker.metadata || {};
887
+ if (!matchesRecoveryProvenance(metadata, opts, { source, agentId })) continue;
888
+ const transcriptHash = metadata.transcriptHash || metadata.transcript_hash || null;
889
+ const sessionId = marker.sessionId;
890
+ if (shouldExcludeCandidate({ sessionId, fileSessionId: metadata.fileSessionId }, opts)) continue;
891
+ if (isClaimActive(paths.claimDir, sessionId, opts.claimTtlMs)) continue;
892
+ if (marker.content.includes('skip:')) continue;
893
+
894
+ const finalization = await readFinalization(aquifer, {
895
+ sessionId,
896
+ agentId: metadata.agentId || agentId,
897
+ source: metadata.source || source,
898
+ transcriptHash,
899
+ });
900
+ if (isRecoverySuppressed(finalization, opts)) continue;
901
+
902
+ const filePath = metadata.filePath || null;
903
+ if (!filePath) continue;
904
+ const candidatePreview = {
905
+ sessionId,
906
+ fileSessionId: metadata.fileSessionId || null,
907
+ filePath,
908
+ transcriptHash,
909
+ phase: opts.phase || 'curated_memory_v1',
910
+ metadata,
911
+ };
912
+ const localDecision = readRecoveryDecision(paths, candidatePreview);
913
+ if (!finalization && isRecoverySuppressed(localDecision, opts)) continue;
914
+
915
+ if (filePath) seenFiles.add(path.resolve(filePath));
916
+ candidates.push({
917
+ ...candidatePreview,
918
+ origin: 'imported_marker',
919
+ status: 'needs_consent',
920
+ source: metadata.source || source,
921
+ agentId: metadata.agentId || agentId,
922
+ sessionKey: metadata.sessionKey || null,
923
+ userCount: metadata.userCount || null,
924
+ messageCount: metadata.messageCount || null,
925
+ finalizationStatus: finalization ? finalization.status : null,
926
+ recoveryDecisionStatus: localDecision ? localDecision.status : null,
927
+ markerPath: marker.markerPath,
928
+ updatedAt: marker.stat.mtime,
929
+ });
930
+ if (candidates.length >= maxRecoveryCandidates) return candidates;
931
+ }
932
+
933
+ if (!includeJsonlPreviews) return candidates;
934
+
935
+ const minBytes = opts.minSessionBytes ?? DEFAULT_MIN_BYTES;
936
+ const idleMs = opts.idleMs ?? DEFAULT_IDLE_MS;
937
+ let files = walkJsonlFiles(paths.sessionsDir)
938
+ .filter((entry) => !shouldExcludeFile(entry, opts))
939
+ .filter((entry) => !seenFiles.has(path.resolve(entry.path)))
940
+ .filter((entry) => entry.stat.size >= minBytes)
941
+ .filter((entry) => isIdleEnough(entry, idleMs, opts.now))
942
+ .sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
943
+ if (opts.excludeNewest && files.length > 0) files = files.slice(1);
944
+
945
+ for (const entry of files) {
946
+ const fileSessionId = entry.fileSessionId;
947
+ let safeFileSessionId;
948
+ try {
949
+ safeFileSessionId = assertSafeSessionId(fileSessionId, 'fileSessionId');
950
+ } catch {
951
+ continue;
952
+ }
953
+ if (shouldExcludeCandidate({ sessionId: safeFileSessionId, fileSessionId: safeFileSessionId }, opts)) continue;
954
+ if (isClaimActive(paths.claimDir, safeFileSessionId, opts.claimTtlMs)) continue;
955
+ const candidatePreview = {
956
+ origin: 'jsonl_preview',
957
+ status: 'needs_consent',
958
+ sessionId: safeFileSessionId,
959
+ fileSessionId: safeFileSessionId,
960
+ filePath: entry.path,
961
+ transcriptHash: null,
962
+ source,
963
+ agentId,
964
+ sessionKey: null,
965
+ userCount: null,
966
+ messageCount: null,
967
+ finalizationStatus: null,
968
+ updatedAt: entry.stat.mtime,
969
+ size: entry.stat.size,
970
+ metadata: {
971
+ filePath: entry.path,
972
+ fileSessionId: safeFileSessionId,
973
+ size: entry.stat.size,
974
+ mtimeMs: entry.stat.mtimeMs,
975
+ source,
976
+ agentId,
977
+ },
978
+ };
979
+ const localDecision = readRecoveryDecision(paths, candidatePreview);
980
+ if (isRecoverySuppressed(localDecision, opts)) continue;
981
+ candidates.push({
982
+ ...candidatePreview,
983
+ recoveryDecisionStatus: localDecision ? localDecision.status : null,
984
+ });
985
+ if (candidates.length >= maxRecoveryCandidates) break;
986
+ }
987
+ return candidates;
988
+ }
989
+
990
+ function dbEligibilityFromRecoveryView(view = {}, opts = {}) {
991
+ if (!view || view.status !== 'ok') {
992
+ return { eligible: false, status: view?.status || 'unavailable', reason: view?.reason || null };
993
+ }
994
+ const minUserMessages = opts.minUserMessages ?? opts.minImportUserMessages ?? DEFAULT_MIN_IMPORT_USER_MESSAGES;
995
+ const userCount = Number(view.counts?.userCount || 0);
996
+ if (userCount < minUserMessages) {
997
+ return {
998
+ eligible: false,
999
+ status: 'skipped_short',
1000
+ reason: `user_count=${userCount} < min=${minUserMessages}`,
1001
+ userCount,
1002
+ };
1003
+ }
1004
+ return { eligible: true, status: 'eligible', userCount };
1005
+ }
1006
+
1007
+ function candidateFromRecoveryView(candidate = {}, view = {}) {
1008
+ return {
1009
+ ...candidate,
1010
+ sessionId: view.sessionId || candidate.sessionId,
1011
+ fileSessionId: view.fileSessionId || candidate.fileSessionId,
1012
+ filePath: view.filePath || candidate.filePath,
1013
+ transcriptHash: view.transcriptHash || candidate.transcriptHash || null,
1014
+ userCount: view.counts?.userCount ?? candidate.userCount ?? null,
1015
+ messageCount: view.counts?.messageCount ?? candidate.messageCount ?? null,
1016
+ safeMessageCount: view.counts?.safeMessageCount ?? null,
1017
+ assistantCount: view.counts?.assistantCount ?? null,
1018
+ charCount: view.charCount ?? null,
1019
+ approxPromptTokens: view.approxPromptTokens ?? null,
1020
+ metadata: {
1021
+ ...(candidate.metadata || {}),
1022
+ filePath: view.filePath || candidate.filePath || candidate.metadata?.filePath || null,
1023
+ fileSessionId: view.fileSessionId || candidate.fileSessionId || candidate.metadata?.fileSessionId || null,
1024
+ transcriptHash: view.transcriptHash || candidate.transcriptHash || candidate.metadata?.transcriptHash || null,
1025
+ dbRecoveryEligible: true,
1026
+ },
1027
+ };
1028
+ }
1029
+
1030
+ async function findDbEligibleRecoveryCandidates(aquifer, opts = {}) {
1031
+ const paths = defaultPaths(opts);
1032
+ const maxEligible = Number.isFinite(opts.maxRecoveryCandidates) && opts.maxRecoveryCandidates > 0
1033
+ ? opts.maxRecoveryCandidates
1034
+ : 3;
1035
+ const rawScanLimit = Number.isFinite(opts.maxRecoveryCandidateScan) && opts.maxRecoveryCandidateScan > 0
1036
+ ? opts.maxRecoveryCandidateScan
1037
+ : Math.max(maxEligible * 5, maxEligible);
1038
+ const rawCandidates = await findRecoveryCandidates(aquifer, {
1039
+ ...opts,
1040
+ maxRecoveryCandidates: rawScanLimit,
1041
+ });
1042
+ const eligible = [];
1043
+
1044
+ for (const candidate of rawCandidates) {
1045
+ let view;
1046
+ try {
1047
+ view = materializeRecoveryTranscriptView(candidate, opts);
1048
+ } catch {
1049
+ continue;
1050
+ }
1051
+ const eligibility = dbEligibilityFromRecoveryView(view, opts);
1052
+ if (!eligibility.eligible) continue;
1053
+
1054
+ const enriched = candidateFromRecoveryView(candidate, view);
1055
+ const finalization = await readFinalization(aquifer, {
1056
+ sessionId: enriched.sessionId,
1057
+ agentId: opts.agentId || enriched.agentId || 'main',
1058
+ source: opts.source || enriched.source || 'codex',
1059
+ transcriptHash: enriched.transcriptHash,
1060
+ phase: opts.phase || enriched.phase || 'curated_memory_v1',
1061
+ });
1062
+ if (isRecoverySuppressed(finalization, opts)) continue;
1063
+
1064
+ const canonicalDecision = readRecoveryDecision(paths, enriched);
1065
+ if (isRecoverySuppressed(canonicalDecision, opts)) continue;
1066
+
1067
+ eligible.push({
1068
+ ...enriched,
1069
+ status: 'needs_consent',
1070
+ eligibilityStatus: eligibility.status,
1071
+ finalizationStatus: finalization ? finalization.status : enriched.finalizationStatus || null,
1072
+ recoveryDecisionStatus: canonicalDecision ? canonicalDecision.status : enriched.recoveryDecisionStatus || null,
1073
+ });
1074
+ if (eligible.length >= maxEligible) break;
1075
+ }
1076
+
1077
+ return eligible;
1078
+ }
1079
+
1080
+ function formatRecoveryTranscript(messages = []) {
1081
+ return messages
1082
+ .map((message) => {
1083
+ const role = message.role || 'unknown';
1084
+ const timestamp = message.timestamp ? ` ${message.timestamp}` : '';
1085
+ const content = String(message.content || '').trim();
1086
+ return `[${role}${timestamp}]\n${content}`;
1087
+ })
1088
+ .filter(Boolean)
1089
+ .join('\n\n');
1090
+ }
1091
+
1092
+ function approxPromptTokens(text) {
1093
+ return Math.ceil(String(text || '').length / 3);
1094
+ }
1095
+
1096
+ function materializeRecoveryTranscriptView(candidate = {}, opts = {}) {
1097
+ const filePath = candidate.filePath || candidate.metadata?.filePath;
1098
+ if (!filePath) {
1099
+ return { status: 'missing_file_path', sessionId: candidate.sessionId || null };
1100
+ }
1101
+ if (shouldExcludeFile({ path: filePath }, opts)) {
1102
+ return { status: 'excluded', sessionId: candidate.sessionId || null, filePath };
1103
+ }
1104
+
1105
+ const maxBytes = opts.maxRecoveryBytes ?? DEFAULT_RECOVERY_MAX_BYTES;
1106
+ const maxMessages = opts.maxRecoveryMessages ?? DEFAULT_RECOVERY_MAX_MESSAGES;
1107
+ const maxChars = opts.maxRecoveryChars ?? DEFAULT_RECOVERY_MAX_CHARS;
1108
+ const maxPromptTokens = opts.maxRecoveryPromptTokens ?? DEFAULT_RECOVERY_MAX_PROMPT_TOKENS;
1109
+
1110
+ let stat;
1111
+ try {
1112
+ stat = fs.statSync(filePath);
1113
+ } catch {
1114
+ return { status: 'not_found', sessionId: candidate.sessionId || null, filePath };
1115
+ }
1116
+ if (Number.isFinite(maxBytes) && maxBytes > 0 && stat.size > maxBytes) {
1117
+ return { status: 'deferred', reason: 'max_bytes', sessionId: candidate.sessionId || null, filePath, size: stat.size };
1118
+ }
1119
+
1120
+ const parsed = parseCodexSessionFile(filePath);
1121
+ if (shouldExcludeCandidate(parsed, opts)) {
1122
+ return { status: 'excluded', sessionId: parsed.sessionId, filePath };
1123
+ }
1124
+
1125
+ const transcriptHash = parsed.normalized.transcriptHash;
1126
+ if (candidate.transcriptHash && candidate.transcriptHash !== transcriptHash) {
1127
+ return {
1128
+ status: 'hash_mismatch',
1129
+ sessionId: parsed.sessionId,
1130
+ filePath,
1131
+ expectedTranscriptHash: candidate.transcriptHash,
1132
+ transcriptHash,
1133
+ };
1134
+ }
1135
+
1136
+ const safety = applyEnrichSafetyGate(parsed.normalized.messages);
1137
+ const safeMessages = safety.messages;
1138
+ if (safeMessages.length === 0) {
1139
+ return {
1140
+ status: 'skipped_empty',
1141
+ sessionId: parsed.sessionId,
1142
+ fileSessionId: parsed.fileSessionId,
1143
+ filePath,
1144
+ transcriptHash,
1145
+ safetyGate: safety.meta,
1146
+ };
1147
+ }
1148
+ if (Number.isFinite(maxMessages) && maxMessages > 0 && safeMessages.length > maxMessages) {
1149
+ return {
1150
+ status: 'deferred',
1151
+ reason: 'max_messages',
1152
+ sessionId: parsed.sessionId,
1153
+ fileSessionId: parsed.fileSessionId,
1154
+ filePath,
1155
+ transcriptHash,
1156
+ messageCount: safeMessages.length,
1157
+ safetyGate: safety.meta,
1158
+ };
1159
+ }
1160
+
1161
+ const text = formatRecoveryTranscript(safeMessages);
1162
+ const promptTokens = approxPromptTokens(text);
1163
+ if ((Number.isFinite(maxChars) && maxChars > 0 && text.length > maxChars)
1164
+ || (Number.isFinite(maxPromptTokens) && maxPromptTokens > 0 && promptTokens > maxPromptTokens)) {
1165
+ return {
1166
+ status: 'deferred',
1167
+ reason: 'prompt_budget',
1168
+ sessionId: parsed.sessionId,
1169
+ fileSessionId: parsed.fileSessionId,
1170
+ filePath,
1171
+ transcriptHash,
1172
+ charCount: text.length,
1173
+ approxPromptTokens: promptTokens,
1174
+ safetyGate: safety.meta,
1175
+ };
1176
+ }
1177
+
1178
+ return {
1179
+ status: 'ok',
1180
+ sessionId: parsed.sessionId,
1181
+ fileSessionId: parsed.fileSessionId,
1182
+ filePath,
1183
+ transcriptHash,
1184
+ messages: safeMessages,
1185
+ text,
1186
+ charCount: text.length,
1187
+ approxPromptTokens: promptTokens,
1188
+ safetyGate: safety.meta,
1189
+ counts: {
1190
+ messageCount: parsed.normalized.messages.length,
1191
+ safeMessageCount: safeMessages.length,
1192
+ userCount: parsed.normalized.userCount,
1193
+ assistantCount: parsed.normalized.assistantCount,
1194
+ },
1195
+ metadata: {
1196
+ model: parsed.normalized.model,
1197
+ startedAt: parsed.normalized.startedAt,
1198
+ lastMessageAt: parsed.normalized.lastMessageAt,
1199
+ skipStats: parsed.normalized.skipStats,
1200
+ boundaries: parsed.normalized.boundaries,
1201
+ toolsUsed: parsed.normalized.toolsUsed,
1202
+ },
1203
+ };
1204
+ }
1205
+
1206
+ function buildFinalizationPrompt(view = {}, opts = {}) {
1207
+ if (!view || view.status !== 'ok') {
1208
+ throw new Error('buildFinalizationPrompt requires an ok transcript view');
1209
+ }
1210
+ const maxFacts = opts.maxFacts || 8;
1211
+ return [
1212
+ 'You are finalizing an Aquifer memory session for Codex.',
1213
+ 'Use only the sanitized transcript below. Do not infer from hidden tool output or injected context.',
1214
+ 'Return compact JSON with this shape:',
1215
+ '{"summaryText":"...","structuredSummary":{"facts":[],"decisions":[],"open_loops":[],"preferences":[],"constraints":[],"conclusions":[],"entity_notes":[],"states":[]}}',
1216
+ `Keep facts/decisions/open_loops concrete and scoped. Use at most ${maxFacts} facts.`,
1217
+ '',
1218
+ `sessionId: ${view.sessionId}`,
1219
+ `transcriptHash: ${view.transcriptHash}`,
1220
+ `approxPromptTokens: ${view.approxPromptTokens}`,
1221
+ '',
1222
+ '<sanitized_transcript>',
1223
+ view.text || '',
1224
+ '</sanitized_transcript>',
1225
+ ].join('\n');
1226
+ }
1227
+
1228
+ function normalizeFinalizationSummary(summary = {}) {
1229
+ const input = summary && typeof summary === 'object' ? summary : {};
1230
+ const summaryText = String(input.summaryText || input.summary || '').trim();
1231
+ const structuredSummary = input.structuredSummary || {};
1232
+ if (!summaryText && (!structuredSummary || Object.keys(structuredSummary).length === 0)) {
1233
+ throw new Error('summaryText or structuredSummary is required for finalization');
1234
+ }
1235
+ return { summaryText, structuredSummary };
1236
+ }
1237
+
1238
+ async function ensureCommittedForFinalization(aquifer, view = {}, opts = {}) {
1239
+ const {
1240
+ agentId = 'main',
1241
+ source = 'codex',
1242
+ sessionKey = 'codex:cli',
1243
+ } = opts;
1244
+ const existing = await getExistingSession(aquifer, view.sessionId, agentId, { source });
1245
+ if (committedSnapshotMatchesView(existing, view)) {
1246
+ return { status: 'already_committed', session: existing };
1247
+ }
1248
+
1249
+ if (!aquifer || typeof aquifer.commit !== 'function') {
1250
+ throw new Error('aquifer.commit is required to finalize an uncommitted or stale Codex session');
1251
+ }
1252
+
1253
+ await aquifer.commit(view.sessionId, view.messages, {
1254
+ rawMessages: {
1255
+ normalized: view.messages,
1256
+ metadata: {
1257
+ transcript_hash: view.transcriptHash,
1258
+ recovery_finalization: true,
1259
+ safetyGate: view.safetyGate || {},
1260
+ sourceMetadata: view.metadata || {},
1261
+ },
1262
+ },
1263
+ agentId,
1264
+ source,
1265
+ sessionKey,
1266
+ model: view.metadata?.model || null,
1267
+ tokensIn: 0,
1268
+ tokensOut: 0,
1269
+ startedAt: view.metadata?.startedAt || null,
1270
+ lastMessageAt: view.metadata?.lastMessageAt || null,
1271
+ });
1272
+ return { status: existing ? 'recommitted' : 'committed', previous: existing || null };
1273
+ }
1274
+
1275
+ async function finalizeTranscriptView(aquifer, view = {}, summary = {}, opts = {}) {
1276
+ const finalizeSession = aquifer?.finalizeSession
1277
+ || aquifer?.finalization?.finalizeSession;
1278
+ if (typeof finalizeSession !== 'function') {
1279
+ throw new Error('aquifer.finalizeSession or aquifer.finalization.finalizeSession is required');
1280
+ }
1281
+ if (!view || view.status !== 'ok') {
1282
+ throw new Error(`Cannot finalize transcript view with status: ${view && view.status}`);
1283
+ }
1284
+ const {
1285
+ agentId = 'main',
1286
+ source = 'codex',
1287
+ sessionKey = 'codex:cli',
1288
+ mode = 'handoff',
1289
+ } = opts;
1290
+ const finalSummary = normalizeFinalizationSummary(summary);
1291
+ const commitResult = await ensureCommittedForFinalization(aquifer, view, {
1292
+ agentId,
1293
+ source,
1294
+ sessionKey,
1295
+ });
1296
+ const metadata = {
1297
+ filePath: view.filePath || null,
1298
+ fileSessionId: view.fileSessionId || null,
1299
+ source: opts.metadataSource || 'codex_consumer',
1300
+ sessionKey,
1301
+ charCount: view.charCount || null,
1302
+ approxPromptTokens: view.approxPromptTokens || null,
1303
+ safetyGate: view.safetyGate || {},
1304
+ trigger: mode,
1305
+ ...(opts.metadata || {}),
1306
+ };
1307
+ const result = await finalizeSession({
1308
+ sessionId: view.sessionId,
1309
+ agentId,
1310
+ source,
1311
+ host: 'codex',
1312
+ transcriptHash: view.transcriptHash,
1313
+ mode,
1314
+ summaryText: finalSummary.summaryText,
1315
+ structuredSummary: finalSummary.structuredSummary,
1316
+ finalizerModel: opts.finalizerModel || opts.model || view.metadata?.model || null,
1317
+ msgCount: view.counts?.safeMessageCount || view.messages.length,
1318
+ userCount: view.counts?.userCount || view.messages.filter(m => m.role === 'user').length,
1319
+ assistantCount: view.counts?.assistantCount || view.messages.filter(m => m.role === 'assistant').length,
1320
+ startedAt: view.metadata?.startedAt || null,
1321
+ endedAt: view.metadata?.lastMessageAt || null,
1322
+ embedding: opts.embedding || null,
1323
+ scopeKind: opts.scopeKind || null,
1324
+ scopeKey: opts.scopeKey || null,
1325
+ contextKey: opts.contextKey || null,
1326
+ topicKey: opts.topicKey || null,
1327
+ authority: opts.authority || 'verified_summary',
1328
+ metadata,
1329
+ });
1330
+ const humanReviewText = result.humanReviewText || '';
1331
+ const sessionStartText = result.sessionStartText || '';
1332
+ return {
1333
+ status: result.status || 'finalized',
1334
+ commit: commitResult,
1335
+ finalization: result,
1336
+ sessionId: view.sessionId,
1337
+ transcriptHash: view.transcriptHash,
1338
+ summary: result.summary || null,
1339
+ memoryResult: result.memoryResult || {},
1340
+ memoryResults: result.memoryResults || [],
1341
+ reviewText: humanReviewText,
1342
+ humanReviewText,
1343
+ sessionStartText,
1344
+ };
1345
+ }
1346
+
1347
+ async function recordRecoveryDecision(aquifer, candidate = {}, status, opts = {}) {
1348
+ if (!RECOVERY_DECISIONS.has(status)) throw new Error(`Invalid recovery decision: ${status}`);
1349
+ const paths = defaultPaths(opts);
1350
+ ensureDirs(paths.decisionDir);
1351
+ const metadata = {
1352
+ reason: opts.reason || null,
1353
+ source: opts.source || candidate.source || 'codex',
1354
+ agentId: opts.agentId || candidate.agentId || 'main',
1355
+ decidedAt: new Date().toISOString(),
1356
+ };
1357
+ writeRecoveryDecision(paths, candidate, status, metadata);
1358
+
1359
+ const transcriptHash = candidate.transcriptHash || candidate.metadata?.transcriptHash || null;
1360
+ if (aquifer && aquifer.finalization && transcriptHash) {
1361
+ const finalizationInput = {
1362
+ sessionId: candidate.sessionId,
1363
+ agentId: opts.agentId || candidate.agentId || 'main',
1364
+ source: opts.source || candidate.source || 'codex',
1365
+ transcriptHash,
1366
+ phase: opts.phase || candidate.phase || 'curated_memory_v1',
1367
+ status,
1368
+ error: opts.reason || null,
1369
+ metadata,
1370
+ };
1371
+ try {
1372
+ if (typeof aquifer.finalization.updateStatus === 'function') {
1373
+ const updated = await aquifer.finalization.updateStatus(finalizationInput);
1374
+ if (updated) return { status, persisted: 'db', finalization: updated };
1375
+ }
1376
+ if (typeof aquifer.finalization.createTask === 'function') {
1377
+ const created = await aquifer.finalization.createTask({
1378
+ ...finalizationInput,
1379
+ mode: opts.mode || 'session_start_recovery',
1380
+ });
1381
+ return { status, persisted: 'db', finalization: created };
1382
+ }
1383
+ } catch {
1384
+ // Local decision marker still prevents repeated prompts. DB may be
1385
+ // unavailable or the session may not be committed yet.
1386
+ }
1387
+ }
1388
+ return { status, persisted: 'local' };
1389
+ }
1390
+
1391
+ async function prepareSessionStartRecovery(aquifer, opts = {}) {
1392
+ const recoveryOpts = {
1393
+ ...opts,
1394
+ excludeNewest: opts.excludeNewest !== undefined ? opts.excludeNewest : true,
1395
+ };
1396
+ const candidates = await findRecoveryCandidates(aquifer, recoveryOpts);
1397
+ if (candidates.length === 0) return { status: 'none', candidates: [] };
1398
+ if (opts.consent !== true) return { status: 'needs_consent', candidates };
1399
+
1400
+ const candidate = opts.candidate || candidates[0];
1401
+ const view = materializeRecoveryTranscriptView(candidate, recoveryOpts);
1402
+ if (view.status !== 'ok') {
1403
+ return { status: view.status, candidate, view };
1404
+ }
1405
+ const minUserMessages = opts.minUserMessages ?? opts.minImportUserMessages ?? DEFAULT_MIN_IMPORT_USER_MESSAGES;
1406
+ const userCount = Number(view.counts?.userCount || 0);
1407
+ if (userCount < minUserMessages) {
1408
+ const skippedCandidate = {
1409
+ ...candidate,
1410
+ sessionId: view.sessionId || candidate.sessionId,
1411
+ fileSessionId: view.fileSessionId || candidate.fileSessionId,
1412
+ filePath: view.filePath || candidate.filePath,
1413
+ transcriptHash: view.transcriptHash,
1414
+ metadata: {
1415
+ ...(candidate.metadata || {}),
1416
+ filePath: view.filePath || candidate.filePath,
1417
+ fileSessionId: view.fileSessionId || candidate.fileSessionId,
1418
+ transcriptHash: view.transcriptHash,
1419
+ },
1420
+ };
1421
+ await recordRecoveryDecision(aquifer, skippedCandidate, 'skipped', {
1422
+ ...recoveryOpts,
1423
+ reason: `user_count=${userCount} < min=${minUserMessages}`,
1424
+ mode: 'session_start_recovery',
1425
+ });
1426
+ if (candidate.origin === 'jsonl_preview') {
1427
+ writeRecoveryDecision(defaultPaths(recoveryOpts), candidate, 'skipped', {
1428
+ reason: `user_count=${userCount} < min=${minUserMessages}`,
1429
+ source: recoveryOpts.source || candidate.source || 'codex',
1430
+ agentId: recoveryOpts.agentId || candidate.agentId || 'main',
1431
+ });
1432
+ }
1433
+ return { status: 'skipped_short', candidate: skippedCandidate, view, userCount };
1434
+ }
1435
+ return {
1436
+ status: 'needs_agent_summary',
1437
+ candidate,
1438
+ view,
1439
+ prompt: buildFinalizationPrompt(view, recoveryOpts),
1440
+ };
1441
+ }
1442
+
1443
+ async function finalizeCodexSession(aquifer, input = {}, opts = {}) {
1444
+ let view = input.view || null;
1445
+ const mode = input.mode || opts.mode || 'handoff';
1446
+ if (!view) {
1447
+ const candidate = input.candidate || {
1448
+ filePath: input.filePath,
1449
+ transcriptHash: input.transcriptHash || null,
1450
+ sessionId: input.sessionId || null,
1451
+ metadata: input.metadata || {},
1452
+ };
1453
+ view = materializeRecoveryTranscriptView(candidate, opts);
1454
+ }
1455
+ if (view.status !== 'ok') {
1456
+ return { status: view.status, view };
1457
+ }
1458
+ const summary = input.summary || {
1459
+ summaryText: input.summaryText,
1460
+ structuredSummary: input.structuredSummary,
1461
+ };
1462
+ return finalizeTranscriptView(aquifer, view, summary, {
1463
+ ...opts,
1464
+ mode,
1465
+ agentId: input.agentId || opts.agentId || 'main',
1466
+ source: input.source || opts.source || 'codex',
1467
+ sessionKey: input.sessionKey || opts.sessionKey || 'codex:cli',
1468
+ finalizerModel: input.finalizerModel || opts.finalizerModel || null,
1469
+ scopeKind: input.scopeKind || opts.scopeKind || null,
1470
+ scopeKey: input.scopeKey || opts.scopeKey || null,
1471
+ contextKey: input.contextKey || opts.contextKey || null,
1472
+ topicKey: input.topicKey || opts.topicKey || null,
1473
+ });
1474
+ }
1475
+
1476
+ async function runSync(aquifer, opts = {}) {
1477
+ if (!aquifer) throw new Error('aquifer is required');
1478
+ const paths = defaultPaths(opts);
1479
+ ensureDirs(paths.importedDir, paths.afterburnedDir, paths.claimDir);
1480
+
1481
+ const logger = opts.logger || console;
1482
+ const results = { imported: [], afterburned: [], skipped: [], failed: [] };
1483
+ const importCandidates = await findImportCandidates(aquifer, opts);
1484
+
1485
+ for (const candidate of importCandidates) {
1486
+ if (!claimSession(paths.claimDir, candidate.sessionId)) continue;
1487
+ try {
1488
+ const result = await importCandidate(aquifer, candidate, opts);
1489
+ if (result.status === 'imported') results.imported.push(result);
1490
+ else results.skipped.push(result);
1491
+ } catch (err) {
1492
+ results.failed.push({ stage: 'import', sessionId: candidate.sessionId, error: err.message });
1493
+ if (logger && logger.warn) logger.warn(`[codex-consumer] import failed for ${candidate.sessionId}: ${err.message}`);
1494
+ } finally {
1495
+ releaseClaim(paths.claimDir, candidate.sessionId);
1496
+ }
1497
+ }
1498
+
1499
+ const afterburnCandidates = results.imported.length
1500
+ ? results.imported.map((r) => {
1501
+ const source = importCandidates.find((c) => c.sessionId === r.sessionId);
1502
+ return source || { sessionId: r.sessionId, normalized: r.counts };
1503
+ })
1504
+ : await findAfterburnCandidates(aquifer, opts);
1505
+
1506
+ const maxAfterburns = opts.maxAfterburns ?? DEFAULT_MAX_AFTERBURNS;
1507
+ for (const candidate of afterburnCandidates.slice(0, Math.max(0, maxAfterburns))) {
1508
+ if (!claimSession(paths.claimDir, candidate.sessionId)) continue;
1509
+ try {
1510
+ const result = await afterburnCandidate(aquifer, candidate, opts);
1511
+ if (result.status === 'afterburned') results.afterburned.push(result);
1512
+ else results.skipped.push(result);
1513
+ } catch (err) {
1514
+ results.failed.push({ stage: 'afterburn', sessionId: candidate.sessionId, error: err.message });
1515
+ if (logger && logger.warn) logger.warn(`[codex-consumer] afterburn failed for ${candidate.sessionId}: ${err.message}`);
1516
+ } finally {
1517
+ releaseClaim(paths.claimDir, candidate.sessionId);
1518
+ }
1519
+ }
1520
+
1521
+ return results;
1522
+ }
1523
+
1524
+ module.exports = {
1525
+ normalizeCodexEntries,
1526
+ parseCodexSessionFile,
1527
+ findImportCandidates,
1528
+ findAfterburnCandidates,
1529
+ findRecoveryCandidates,
1530
+ findDbEligibleRecoveryCandidates,
1531
+ materializeRecoveryTranscriptView,
1532
+ buildFinalizationPrompt,
1533
+ prepareSessionStartRecovery,
1534
+ recordRecoveryDecision,
1535
+ finalizeTranscriptView,
1536
+ finalizeCodexSession,
1537
+ importCandidate,
1538
+ afterburnCandidate,
1539
+ runSync,
1540
+ // exposed for focused unit tests
1541
+ readJsonlEntries,
1542
+ isIdleEnough,
1543
+ defaultPaths,
1544
+ assertSafeSessionId,
1545
+ safeMarkerKey,
1546
+ markerPath,
1547
+ hashNormalizedTranscript,
1548
+ readMarkerMetadataFromContent,
1549
+ };