@shadowforge0/aquifer-memory 1.5.12 → 1.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 (60) hide show
  1. package/.env.example +23 -0
  2. package/README.md +84 -73
  3. package/README_CN.md +676 -0
  4. package/README_TW.md +684 -0
  5. package/aquifer.config.example.json +34 -0
  6. package/consumers/claude-code.js +11 -11
  7. package/consumers/cli.js +421 -53
  8. package/consumers/codex-handoff.js +258 -0
  9. package/consumers/codex.js +1676 -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 +96 -5
  14. package/consumers/openclaw-ext/index.js +0 -1
  15. package/consumers/openclaw-plugin.js +1 -1
  16. package/consumers/shared/config.js +8 -0
  17. package/consumers/shared/factory.js +1 -0
  18. package/consumers/shared/ingest.js +1 -1
  19. package/consumers/shared/normalize.js +14 -3
  20. package/consumers/shared/recall-format.js +27 -0
  21. package/consumers/shared/summary-parser.js +151 -0
  22. package/core/aquifer.js +380 -18
  23. package/core/finalization-review.js +319 -0
  24. package/core/mcp-manifest.js +52 -2
  25. package/core/memory-bootstrap.js +200 -0
  26. package/core/memory-consolidation.js +1590 -0
  27. package/core/memory-promotion.js +544 -0
  28. package/core/memory-recall.js +247 -0
  29. package/core/memory-records.js +797 -0
  30. package/core/memory-safety-gate.js +224 -0
  31. package/core/session-finalization.js +365 -0
  32. package/core/storage.js +385 -2
  33. package/docs/getting-started.md +105 -0
  34. package/docs/postprocess-contract.md +2 -2
  35. package/docs/setup.md +92 -2
  36. package/package.json +25 -11
  37. package/pipeline/normalize/adapters/codex.js +106 -0
  38. package/pipeline/normalize/detect.js +3 -2
  39. package/schema/001-base.sql +3 -0
  40. package/schema/007-v1-foundation.sql +273 -0
  41. package/schema/008-session-finalizations.sql +50 -0
  42. package/schema/009-v1-assertion-plane.sql +193 -0
  43. package/schema/010-v1-finalization-review.sql +160 -0
  44. package/schema/011-v1-compaction-claim.sql +46 -0
  45. package/schema/012-v1-compaction-lease.sql +39 -0
  46. package/schema/013-v1-compaction-lineage.sql +193 -0
  47. package/scripts/codex-recovery.js +672 -0
  48. package/consumers/miranda/context-inject.js +0 -120
  49. package/consumers/miranda/daily-entries.js +0 -224
  50. package/consumers/miranda/index.js +0 -364
  51. package/consumers/miranda/instance.js +0 -55
  52. package/consumers/miranda/llm.js +0 -99
  53. package/consumers/miranda/profile.json +0 -145
  54. package/consumers/miranda/prompts/summary.js +0 -303
  55. package/consumers/miranda/recall-format.js +0 -76
  56. package/consumers/miranda/render-daily-md.js +0 -186
  57. package/consumers/miranda/workspace-files.js +0 -91
  58. package/scripts/drop-entity-state-history.sql +0 -17
  59. package/scripts/drop-insights.sql +0 -12
  60. package/scripts/install-openclaw.sh +0 -59
@@ -0,0 +1,672 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+
8
+ const { createAquiferFromConfig } = require('../consumers/shared/factory');
9
+ const codex = require('../consumers/codex');
10
+ const DB_ENV_KEYS = new Set(['DATABASE_URL', 'AQUIFER_DB_URL', 'AQUIFER_SCHEMA', 'AQUIFER_TENANT_ID']);
11
+
12
+ const VALUE_FLAGS = new Set([
13
+ 'agent-id',
14
+ 'codex-home',
15
+ 'config',
16
+ 'except-session-id',
17
+ 'file-path',
18
+ 'finalizer-model',
19
+ 'idle-ms',
20
+ 'max-candidates',
21
+ 'max-recovery-bytes',
22
+ 'max-recovery-chars',
23
+ 'max-recovery-messages',
24
+ 'max-recovery-prompt-tokens',
25
+ 'min-session-bytes',
26
+ 'mode',
27
+ 'reason',
28
+ 'scope-kind',
29
+ 'scope-key',
30
+ 'session-id',
31
+ 'session-key',
32
+ 'sessions-dir',
33
+ 'source',
34
+ 'state-dir',
35
+ 'structured-summary-json',
36
+ 'summary-json',
37
+ 'summary-text',
38
+ 'verdict',
39
+ 'workspace',
40
+ 'workspace-path',
41
+ 'project',
42
+ 'project-key',
43
+ 'repo-path',
44
+ ]);
45
+
46
+ function parseArgs(argv) {
47
+ const args = { _: [], flags: {} };
48
+ for (let i = 0; i < argv.length; i++) {
49
+ const current = argv[i];
50
+ if (current === '--') {
51
+ args._.push(...argv.slice(i + 1));
52
+ break;
53
+ }
54
+ if (current.startsWith('--')) {
55
+ const key = current.slice(2);
56
+ if (VALUE_FLAGS.has(key) && i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
57
+ args.flags[key] = argv[++i];
58
+ } else {
59
+ args.flags[key] = true;
60
+ }
61
+ continue;
62
+ }
63
+ args._.push(current);
64
+ }
65
+ return args;
66
+ }
67
+
68
+ function parseIntFlag(value, fallback) {
69
+ if (value === undefined || value === null || value === true || value === '') return fallback;
70
+ const parsed = parseInt(value, 10);
71
+ return Number.isFinite(parsed) ? parsed : fallback;
72
+ }
73
+
74
+ function envDefault(env, ...keys) {
75
+ for (const key of keys) {
76
+ const value = env[key];
77
+ if (value !== undefined && value !== '') return value;
78
+ }
79
+ return null;
80
+ }
81
+
82
+ function loadEnvFile(filePath, env = process.env, opts = {}) {
83
+ if (!filePath) return;
84
+ const overrideKeys = opts.overrideKeys || null;
85
+ let raw = '';
86
+ try {
87
+ raw = fs.readFileSync(filePath, 'utf8');
88
+ } catch {
89
+ return;
90
+ }
91
+ for (const line of raw.split('\n')) {
92
+ const trimmed = line.trim();
93
+ if (!trimmed || trimmed.startsWith('#')) continue;
94
+ const match = /^([A-Z_][A-Z0-9_]*)=(.*)$/.exec(trimmed);
95
+ if (!match) continue;
96
+ if (env[match[1]] && !(overrideKeys && overrideKeys.has(match[1]))) continue;
97
+ let value = match[2].trim();
98
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
99
+ value = value.slice(1, -1);
100
+ }
101
+ env[match[1]] = value;
102
+ }
103
+ }
104
+
105
+ function loadCodexEnv(env = process.env, opts = {}) {
106
+ const codexHome = env.CODEX_HOME || path.join(os.homedir(), '.codex');
107
+ const fileOpts = opts.overrideDb ? { overrideKeys: DB_ENV_KEYS } : {};
108
+ loadEnvFile(env.CODEX_ENV_PATH, env, fileOpts);
109
+ loadEnvFile(path.join(codexHome, '.env'), env, fileOpts);
110
+ }
111
+
112
+ function buildRecoveryOptions(flags = {}, env = process.env) {
113
+ const opts = {
114
+ agentId: flags['agent-id'] || envDefault(env, 'CODEX_AQUIFER_AGENT_ID', 'AQUIFER_AGENT_ID') || 'main',
115
+ source: flags.source || envDefault(env, 'CODEX_AQUIFER_SOURCE', 'AQUIFER_SOURCE') || 'codex',
116
+ sessionKey: flags['session-key'] || envDefault(env, 'CODEX_AQUIFER_SESSION_KEY') || 'codex:cli',
117
+ workspace: flags.workspace || flags['workspace-path'] || envDefault(env, 'CODEX_AQUIFER_WORKSPACE', 'CODEX_WORKSPACE') || undefined,
118
+ project: flags.project || flags['project-key'] || envDefault(env, 'CODEX_AQUIFER_PROJECT', 'CODEX_PROJECT') || undefined,
119
+ repoPath: flags['repo-path'] || envDefault(env, 'CODEX_AQUIFER_REPO_PATH', 'CODEX_REPO_PATH') || undefined,
120
+ codexHome: flags['codex-home'] || envDefault(env, 'CODEX_HOME') || undefined,
121
+ stateDir: flags['state-dir'] || undefined,
122
+ sessionsDir: flags['sessions-dir'] || undefined,
123
+ maxRecoveryCandidates: parseIntFlag(flags['max-candidates'], 1),
124
+ minSessionBytes: parseIntFlag(flags['min-session-bytes'], undefined),
125
+ idleMs: parseIntFlag(flags['idle-ms'], undefined),
126
+ maxRecoveryBytes: parseIntFlag(flags['max-recovery-bytes'], undefined),
127
+ maxRecoveryMessages: parseIntFlag(flags['max-recovery-messages'], undefined),
128
+ maxRecoveryChars: parseIntFlag(flags['max-recovery-chars'], undefined),
129
+ maxRecoveryPromptTokens: parseIntFlag(flags['max-recovery-prompt-tokens'], undefined),
130
+ includeJsonlPreviews: flags['include-jsonl-previews'] === true,
131
+ includeDeferredRecovery: flags['include-deferred'] === true,
132
+ excludeNewest: flags['include-current'] === true ? false : true,
133
+ strictWrapperEnv: flags['strict-wrapper-env'] === true,
134
+ };
135
+ for (const [key, value] of Object.entries(opts)) {
136
+ if (value === undefined) delete opts[key];
137
+ }
138
+ return opts;
139
+ }
140
+
141
+ function addDoctorCheck(checks, name, status, detail, extra = {}) {
142
+ checks.push({ name, status, detail, ...extra });
143
+ }
144
+
145
+ function shellQuote(value) {
146
+ return `'${String(value || '').replace(/'/g, `'\\''`)}'`;
147
+ }
148
+
149
+ function scriptCommand(subcommand, candidate = {}, opts = {}, extra = []) {
150
+ const scriptPath = path.resolve(__filename);
151
+ const parts = [process.execPath, scriptPath, subcommand];
152
+ if (candidate.sessionId) parts.push('--session-id', candidate.sessionId);
153
+ if (opts.agentId) parts.push('--agent-id', opts.agentId);
154
+ if (opts.source) parts.push('--source', opts.source);
155
+ if (opts.sessionKey) parts.push('--session-key', opts.sessionKey);
156
+ if (opts.maxRecoveryCandidates) parts.push('--max-candidates', String(opts.maxRecoveryCandidates));
157
+ parts.push(...extra);
158
+ return parts.map(shellQuote).join(' ');
159
+ }
160
+
161
+ function oneLine(value) {
162
+ return String(value || '').replace(/\s+/g, ' ').trim();
163
+ }
164
+
165
+ function renderCandidate(candidate = {}) {
166
+ const id = candidate.sessionId || candidate.fileSessionId || '(unknown)';
167
+ const counts = [];
168
+ if (candidate.userCount !== null && candidate.userCount !== undefined) counts.push(`${candidate.userCount} user turns`);
169
+ if (candidate.messageCount !== null && candidate.messageCount !== undefined) counts.push(`${candidate.messageCount} messages`);
170
+ if (candidate.approxPromptTokens !== null && candidate.approxPromptTokens !== undefined) counts.push(`~${candidate.approxPromptTokens} prompt tokens`);
171
+ if (candidate.updatedAt) {
172
+ const updated = new Date(candidate.updatedAt);
173
+ if (!Number.isNaN(updated.getTime())) counts.push(`updated ${updated.toISOString()}`);
174
+ }
175
+ return counts.length > 0 ? `${id} (${counts.join(', ')})` : id;
176
+ }
177
+
178
+ function renderFinalizeCommand(candidate = {}, opts = {}, extra = []) {
179
+ return scriptCommand('finalize', candidate, opts, [
180
+ ...recoveryArgsForCandidate(candidate),
181
+ '--summary-stdin',
182
+ '--mode',
183
+ 'session_start_recovery',
184
+ ...extra,
185
+ ]);
186
+ }
187
+
188
+ function recoveryArgsForCandidate(candidate = {}) {
189
+ return candidate.origin === 'jsonl_preview' ? ['--include-jsonl-previews'] : [];
190
+ }
191
+
192
+ function renderHookContext(candidates = [], opts = {}) {
193
+ if (!candidates.length) return '';
194
+
195
+ const sharedArgs = candidates.some(candidate => candidate.origin === 'jsonl_preview')
196
+ ? ['--include-jsonl-previews']
197
+ : [];
198
+ const deferUnselectedCommand = scriptCommand('decision', {}, opts, [
199
+ ...sharedArgs,
200
+ '--all',
201
+ '--except-session-id',
202
+ 'SELECTED_IDS_COMMA_SEPARATED',
203
+ '--verdict',
204
+ 'deferred',
205
+ '--reason',
206
+ 'not_selected_at_session_start',
207
+ ]);
208
+ const deferAllCommand = scriptCommand('decision', {}, opts, [
209
+ ...sharedArgs,
210
+ '--all',
211
+ '--verdict',
212
+ 'deferred',
213
+ '--reason',
214
+ 'deferred_by_user_at_session_start',
215
+ ]);
216
+ const declineAllCommand = scriptCommand('decision', {}, opts, [
217
+ ...sharedArgs,
218
+ '--all',
219
+ '--verdict',
220
+ 'declined',
221
+ '--reason',
222
+ 'declined_by_user_at_session_start',
223
+ ]);
224
+ const candidateLines = [];
225
+ candidates.forEach((candidate, index) => {
226
+ const previewArgs = recoveryArgsForCandidate(candidate);
227
+ const promptCommand = scriptCommand('prompt', candidate, opts, previewArgs);
228
+ const finalizeCommand = renderFinalizeCommand(candidate, opts);
229
+ candidateLines.push(`${index + 1}. ${renderCandidate(candidate)}`);
230
+ candidateLines.push(` prompt: ${promptCommand}`);
231
+ candidateLines.push(` finalize: ${finalizeCommand}`);
232
+ });
233
+
234
+ return [
235
+ '[AQUIFER RECOVERY]',
236
+ `Aquifer found ${candidates.length} Codex JSONL session(s) eligible for DB recovery.`,
237
+ 'This hook scanned local JSONL only to compute eligibility, counts, hashes, and prompt budget. It did not inject transcript text.',
238
+ 'Recover all: process every candidate below one at a time with its prompt command, summarize with the current Codex agent, then write the JSON result with its finalize command.',
239
+ 'Recover selected: process only selected candidates, then mark the rest for manual recovery later with:',
240
+ deferUnselectedCommand,
241
+ 'Recover none now but keep manual recovery available:',
242
+ deferAllCommand,
243
+ 'Decline all recovery candidates:',
244
+ declineAllCommand,
245
+ 'Manual later: rerun preview or prompt with --include-deferred.',
246
+ '',
247
+ ...candidateLines,
248
+ ].join('\n');
249
+ }
250
+
251
+ function selectCandidate(candidates = [], flags = {}) {
252
+ const wanted = flags['session-id'] ? String(flags['session-id']) : '';
253
+ if (!wanted) return candidates[0] || null;
254
+ return candidates.find((candidate) => {
255
+ return candidate.sessionId === wanted
256
+ || candidate.fileSessionId === wanted
257
+ || candidate.transcriptHash === wanted;
258
+ }) || null;
259
+ }
260
+
261
+ function compactCandidate(candidate = {}) {
262
+ return {
263
+ sessionId: candidate.sessionId || null,
264
+ fileSessionId: candidate.fileSessionId || null,
265
+ origin: candidate.origin || null,
266
+ source: candidate.source || null,
267
+ agentId: candidate.agentId || null,
268
+ sessionKey: candidate.sessionKey || null,
269
+ userCount: candidate.userCount || null,
270
+ messageCount: candidate.messageCount || null,
271
+ safeMessageCount: candidate.safeMessageCount || null,
272
+ charCount: candidate.charCount || null,
273
+ approxPromptTokens: candidate.approxPromptTokens || null,
274
+ transcriptHash: candidate.transcriptHash || null,
275
+ eligibilityStatus: candidate.eligibilityStatus || null,
276
+ finalizationStatus: candidate.finalizationStatus || null,
277
+ recoveryDecisionStatus: candidate.recoveryDecisionStatus || null,
278
+ updatedAt: candidate.updatedAt || null,
279
+ };
280
+ }
281
+
282
+ function compactDoctorOptions(opts = {}) {
283
+ return {
284
+ agentId: opts.agentId || 'main',
285
+ source: opts.source || 'codex',
286
+ sessionKey: opts.sessionKey || 'codex:cli',
287
+ workspace: opts.workspace || null,
288
+ project: opts.project || null,
289
+ repoPath: opts.repoPath || null,
290
+ codexHome: opts.codexHome || null,
291
+ sessionsDir: opts.sessionsDir || null,
292
+ stateDir: opts.stateDir || null,
293
+ excludeNewest: opts.excludeNewest !== false,
294
+ includeDeferredRecovery: opts.includeDeferredRecovery === true,
295
+ maxRecoveryCandidates: opts.maxRecoveryCandidates || null,
296
+ };
297
+ }
298
+
299
+ async function buildDoctorReport(aquifer, opts = {}, env = process.env) {
300
+ const checks = [];
301
+ const hasWrapperEnv = Boolean(
302
+ env.CODEX_AQUIFER_AGENT_ID
303
+ || env.CODEX_AQUIFER_SOURCE
304
+ || env.CODEX_AQUIFER_SESSION_KEY
305
+ || env.CODEX_HOME
306
+ || env.CODEX_ENV_PATH,
307
+ );
308
+ if (hasWrapperEnv) {
309
+ addDoctorCheck(checks, 'wrapper_env', 'ok', 'Codex wrapper env is present.');
310
+ } else if (opts.strictWrapperEnv) {
311
+ addDoctorCheck(checks, 'wrapper_env', 'fail', 'Strict wrapper env requested, but no CODEX_AQUIFER_* or CODEX_HOME env was found.');
312
+ } else {
313
+ addDoctorCheck(checks, 'wrapper_env', 'warn', 'Using CLI defaults; pass --strict-wrapper-env for live wrapper deployment checks.');
314
+ }
315
+
316
+ if (opts.excludeNewest === false) {
317
+ addDoctorCheck(checks, 'current_transcript_guard', 'fail', 'Current/newest transcript exclusion is disabled.');
318
+ } else {
319
+ addDoctorCheck(checks, 'current_transcript_guard', 'ok', 'Newest transcript exclusion is enabled.');
320
+ }
321
+
322
+ let candidates = [];
323
+ try {
324
+ candidates = await listDbEligibleCandidates(aquifer, {
325
+ ...opts,
326
+ idleMs: opts.idleMs ?? 0,
327
+ includeJsonlPreviews: true,
328
+ maxRecoveryCandidates: opts.maxRecoveryCandidates || 1,
329
+ });
330
+ addDoctorCheck(
331
+ checks,
332
+ 'sessionstart_preflight',
333
+ 'ok',
334
+ `Metadata-only recovery scan completed; eligibleCandidates=${candidates.length}.`,
335
+ { eligibleCandidates: candidates.length },
336
+ );
337
+ } catch (err) {
338
+ addDoctorCheck(
339
+ checks,
340
+ 'sessionstart_preflight',
341
+ 'fail',
342
+ err && err.message ? err.message : String(err),
343
+ );
344
+ }
345
+
346
+ const status = checks.some(check => check.status === 'fail')
347
+ ? 'fail'
348
+ : checks.some(check => check.status === 'warn') ? 'warn' : 'ok';
349
+ return {
350
+ status,
351
+ checks,
352
+ options: compactDoctorOptions(opts),
353
+ candidates: candidates.map(compactCandidate),
354
+ };
355
+ }
356
+
357
+ function parseIdList(value) {
358
+ if (!value || value === true) return new Set();
359
+ return new Set(String(value).split(',').map(part => part.trim()).filter(Boolean));
360
+ }
361
+
362
+ function readSummaryJson(flags = {}) {
363
+ if (flags['summary-stdin']) {
364
+ const raw = fs.readFileSync(0, 'utf8').trim();
365
+ if (!raw) throw new Error('summary JSON stdin is empty');
366
+ return JSON.parse(raw);
367
+ }
368
+ if (flags['summary-json']) {
369
+ const raw = fs.readFileSync(flags['summary-json'], 'utf8');
370
+ return JSON.parse(raw);
371
+ }
372
+ const summaryText = oneLine(flags['summary-text']);
373
+ const structuredRaw = flags['structured-summary-json'];
374
+ const structuredSummary = structuredRaw ? JSON.parse(structuredRaw) : {};
375
+ if (!summaryText && Object.keys(structuredSummary).length === 0) {
376
+ throw new Error('finalize requires --summary-stdin, --summary-json, or --summary-text');
377
+ }
378
+ return { summaryText, structuredSummary };
379
+ }
380
+
381
+ function finalizationReviewText(result = {}) {
382
+ return result.humanReviewText
383
+ || result.human_review_text
384
+ || result.finalization?.humanReviewText
385
+ || result.finalization?.human_review_text
386
+ || result.finalization?.finalization?.humanReviewText
387
+ || result.finalization?.finalization?.human_review_text
388
+ || '';
389
+ }
390
+
391
+ async function withAquifer(fn) {
392
+ let aquifer;
393
+ try {
394
+ loadCodexEnv(process.env, { overrideDb: true });
395
+ aquifer = createAquiferFromConfig({});
396
+ return await fn(aquifer);
397
+ } finally {
398
+ if (aquifer && typeof aquifer.close === 'function') {
399
+ await aquifer.close().catch(() => {});
400
+ }
401
+ }
402
+ }
403
+
404
+ async function listCandidates(aquifer, opts) {
405
+ return codex.findRecoveryCandidates(aquifer, opts);
406
+ }
407
+
408
+ async function listDbEligibleCandidates(aquifer, opts) {
409
+ return codex.findDbEligibleRecoveryCandidates(aquifer, opts);
410
+ }
411
+
412
+ async function listOperationalCandidates(aquifer, opts) {
413
+ if (opts && opts.includeJsonlPreviews) {
414
+ return listDbEligibleCandidates(aquifer, opts);
415
+ }
416
+ return listCandidates(aquifer, opts);
417
+ }
418
+
419
+ async function cmdPreview(aquifer, flags, opts) {
420
+ const candidates = await listCandidates(aquifer, opts);
421
+ if (flags.json) {
422
+ console.log(JSON.stringify({ status: candidates.length ? 'needs_consent' : 'none', candidates: candidates.map(compactCandidate) }, null, 2));
423
+ return;
424
+ }
425
+ if (candidates.length === 0) {
426
+ console.log('No Codex recovery candidates.');
427
+ return;
428
+ }
429
+ for (const candidate of candidates) console.log(renderCandidate(candidate));
430
+ }
431
+
432
+ async function cmdHookContext(aquifer, flags, opts) {
433
+ const maxRecoveryCandidates = Number.isFinite(opts.maxRecoveryCandidates)
434
+ ? Math.max(1, opts.maxRecoveryCandidates)
435
+ : 1;
436
+ const candidates = await listDbEligibleCandidates(aquifer, {
437
+ ...opts,
438
+ idleMs: opts.idleMs ?? 0,
439
+ maxRecoveryCandidates,
440
+ includeJsonlPreviews: true,
441
+ });
442
+ const context = renderHookContext(candidates, opts);
443
+ if (flags.json) {
444
+ console.log(JSON.stringify({ status: context ? 'needs_consent' : 'none', context, candidates: candidates.map(compactCandidate) }, null, 2));
445
+ return;
446
+ }
447
+ if (context) console.log(context);
448
+ }
449
+
450
+ async function cmdPrompt(aquifer, flags, opts) {
451
+ const candidates = await listOperationalCandidates(aquifer, opts);
452
+ const candidate = selectCandidate(candidates, flags);
453
+ if (!candidate) throw new Error(`No matching Codex recovery candidate: ${flags['session-id'] || '(first)'}`);
454
+ const prepared = await codex.prepareSessionStartRecovery(aquifer, {
455
+ ...opts,
456
+ consent: true,
457
+ candidate,
458
+ });
459
+ if (flags.json) {
460
+ console.log(JSON.stringify({
461
+ status: prepared.status,
462
+ candidate: compactCandidate(candidate),
463
+ view: prepared.view ? {
464
+ status: prepared.view.status,
465
+ sessionId: prepared.view.sessionId,
466
+ transcriptHash: prepared.view.transcriptHash,
467
+ charCount: prepared.view.charCount,
468
+ approxPromptTokens: prepared.view.approxPromptTokens,
469
+ counts: prepared.view.counts,
470
+ } : null,
471
+ prompt: prepared.prompt || null,
472
+ }, null, 2));
473
+ return;
474
+ }
475
+ if (prepared.status !== 'needs_agent_summary') {
476
+ console.log(`Recovery prompt unavailable: ${prepared.status}`);
477
+ return;
478
+ }
479
+ console.log([
480
+ prepared.prompt,
481
+ '',
482
+ '[AQUIFER FINALIZE]',
483
+ 'After returning the JSON summary, pipe it into:',
484
+ renderFinalizeCommand(candidate, opts),
485
+ ].join('\n'));
486
+ }
487
+
488
+ async function cmdFinalize(aquifer, flags, opts) {
489
+ const candidates = await listOperationalCandidates(aquifer, opts);
490
+ const candidate = selectCandidate(candidates, flags);
491
+ if (!candidate && !flags['file-path']) {
492
+ throw new Error(`No matching Codex recovery candidate: ${flags['session-id'] || '(first)'}`);
493
+ }
494
+ const summary = readSummaryJson(flags);
495
+ const result = await codex.finalizeCodexSession(aquifer, {
496
+ candidate: candidate || null,
497
+ filePath: flags['file-path'] || undefined,
498
+ sessionId: flags['session-id'] || candidate?.sessionId || undefined,
499
+ summary,
500
+ mode: flags.mode || 'handoff',
501
+ agentId: opts.agentId,
502
+ source: opts.source,
503
+ sessionKey: opts.sessionKey,
504
+ finalizerModel: flags['finalizer-model'] || undefined,
505
+ scopeKind: flags['scope-kind'] || undefined,
506
+ scopeKey: flags['scope-key'] || undefined,
507
+ }, opts);
508
+ if (flags.json) {
509
+ console.log(JSON.stringify(result, null, 2));
510
+ return;
511
+ }
512
+ console.log(`Finalization ${result.status}: ${result.sessionId || flags['session-id'] || '(unknown)'}`);
513
+ const review = finalizationReviewText(result);
514
+ if (review) console.log(review);
515
+ }
516
+
517
+ async function cmdDecision(aquifer, flags, opts) {
518
+ const verdict = flags.verdict;
519
+ if (!['declined', 'deferred'].includes(verdict)) {
520
+ throw new Error('decision requires --verdict declined|deferred');
521
+ }
522
+ const candidates = await listOperationalCandidates(aquifer, opts);
523
+ if (flags.all === true) {
524
+ const exceptIds = parseIdList(flags['except-session-id']);
525
+ const selected = candidates.filter(candidate => {
526
+ const ids = [candidate.sessionId, candidate.fileSessionId, candidate.transcriptHash].filter(Boolean);
527
+ return !ids.some(id => exceptIds.has(id));
528
+ });
529
+ const results = [];
530
+ for (const candidate of selected) {
531
+ results.push(await codex.recordRecoveryDecision(aquifer, candidate, verdict, {
532
+ ...opts,
533
+ reason: flags.reason || null,
534
+ mode: 'session_start_recovery',
535
+ }));
536
+ }
537
+ if (flags.json) {
538
+ console.log(JSON.stringify({ status: verdict, count: results.length, results }, null, 2));
539
+ return;
540
+ }
541
+ console.log(`Recovery ${verdict}: ${results.length} candidate(s)`);
542
+ return;
543
+ }
544
+ const candidate = selectCandidate(candidates, flags);
545
+ if (!candidate) throw new Error(`No matching Codex recovery candidate: ${flags['session-id'] || '(first)'}`);
546
+ const result = await codex.recordRecoveryDecision(aquifer, candidate, verdict, {
547
+ ...opts,
548
+ reason: flags.reason || null,
549
+ mode: 'session_start_recovery',
550
+ });
551
+ if (flags.json) {
552
+ console.log(JSON.stringify(result, null, 2));
553
+ return;
554
+ }
555
+ console.log(`Recovery ${verdict}: ${candidate.sessionId}`);
556
+ }
557
+
558
+ async function cmdDoctor(aquifer, flags, opts, env = process.env) {
559
+ const report = await buildDoctorReport(aquifer, opts, env);
560
+ printDoctorReport(report, flags);
561
+ return report;
562
+ }
563
+
564
+ function printDoctorReport(report = {}, flags = {}) {
565
+ if (flags.json) {
566
+ console.log(JSON.stringify(report, null, 2));
567
+ } else {
568
+ console.log(`Codex recovery doctor: ${report.status}`);
569
+ for (const check of report.checks || []) {
570
+ console.log(`- ${check.status} ${check.name}: ${check.detail}`);
571
+ }
572
+ }
573
+ if (report.status === 'fail') process.exitCode = 1;
574
+ }
575
+
576
+ async function cmdDoctorInitFailure(flags, opts, err, env = process.env) {
577
+ let report = await buildDoctorReport(null, opts, env);
578
+ report = {
579
+ ...report,
580
+ status: 'fail',
581
+ checks: [
582
+ {
583
+ name: 'aquifer_init',
584
+ status: 'fail',
585
+ detail: err && err.message ? err.message : String(err),
586
+ },
587
+ ...(report.checks || []),
588
+ ],
589
+ };
590
+ printDoctorReport(report, flags);
591
+ return report;
592
+ }
593
+
594
+ async function main(argv = process.argv.slice(2)) {
595
+ const args = parseArgs(argv);
596
+ const command = args._[0] || 'help';
597
+ if (args.flags.config) process.env.AQUIFER_CONFIG = args.flags.config;
598
+ const opts = buildRecoveryOptions(args.flags);
599
+
600
+ if (command === 'help' || args.flags.help || args.flags.h) {
601
+ console.log(`Usage:
602
+ node scripts/codex-recovery.js hook-context [options]
603
+ node scripts/codex-recovery.js preview [options]
604
+ node scripts/codex-recovery.js prompt --session-id ID [options]
605
+ node scripts/codex-recovery.js finalize --session-id ID --summary-stdin [options]
606
+ node scripts/codex-recovery.js decision --session-id ID --verdict declined|deferred [options]
607
+ node scripts/codex-recovery.js decision --all --verdict declined|deferred [options]
608
+ node scripts/codex-recovery.js doctor [--strict-wrapper-env] [--json]`);
609
+ return;
610
+ }
611
+
612
+ if (command === 'doctor') {
613
+ try {
614
+ await withAquifer(async (aquifer) => {
615
+ await cmdDoctor(aquifer, args.flags, opts);
616
+ });
617
+ } catch (err) {
618
+ await cmdDoctorInitFailure(args.flags, opts, err);
619
+ }
620
+ return;
621
+ }
622
+
623
+ await withAquifer(async (aquifer) => {
624
+ switch (command) {
625
+ case 'preview':
626
+ await cmdPreview(aquifer, args.flags, opts);
627
+ break;
628
+ case 'hook-context':
629
+ await cmdHookContext(aquifer, args.flags, opts);
630
+ break;
631
+ case 'prompt':
632
+ await cmdPrompt(aquifer, args.flags, opts);
633
+ break;
634
+ case 'finalize':
635
+ await cmdFinalize(aquifer, args.flags, opts);
636
+ break;
637
+ case 'decision':
638
+ await cmdDecision(aquifer, args.flags, opts);
639
+ break;
640
+ default:
641
+ throw new Error(`Unknown command: ${command}`);
642
+ }
643
+ });
644
+ }
645
+
646
+ module.exports = {
647
+ buildDoctorReport,
648
+ buildRecoveryOptions,
649
+ cmdDecision,
650
+ cmdDoctor,
651
+ cmdDoctorInitFailure,
652
+ cmdFinalize,
653
+ cmdHookContext,
654
+ cmdPrompt,
655
+ loadCodexEnv,
656
+ main,
657
+ parseArgs,
658
+ renderFinalizeCommand,
659
+ renderHookContext,
660
+ selectCandidate,
661
+ };
662
+
663
+ if (require.main === module) {
664
+ main().catch((err) => {
665
+ if (process.argv[2] !== 'hook-context') {
666
+ console.error(`codex-recovery: ${err.message}`);
667
+ process.exit(1);
668
+ return;
669
+ }
670
+ process.exit(0);
671
+ });
672
+ }