@shadowforge0/aquifer-memory 1.7.0 → 1.8.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 (39) hide show
  1. package/.env.example +8 -0
  2. package/README.md +66 -0
  3. package/aquifer.config.example.json +19 -0
  4. package/consumers/cli.js +192 -12
  5. package/consumers/codex-active-checkpoint.js +186 -0
  6. package/consumers/codex-current-memory.js +106 -0
  7. package/consumers/codex-handoff.js +442 -3
  8. package/consumers/codex.js +164 -107
  9. package/consumers/mcp.js +144 -6
  10. package/consumers/shared/config.js +60 -1
  11. package/consumers/shared/factory.js +10 -3
  12. package/core/aquifer.js +351 -840
  13. package/core/backends/capabilities.js +89 -0
  14. package/core/backends/local.js +430 -0
  15. package/core/legacy-bootstrap.js +140 -0
  16. package/core/mcp-manifest.js +66 -2
  17. package/core/memory-promotion.js +157 -26
  18. package/core/memory-recall.js +341 -22
  19. package/core/memory-records.js +128 -8
  20. package/core/memory-serving.js +132 -0
  21. package/core/postgres-migrations.js +533 -0
  22. package/core/public-session-filter.js +40 -0
  23. package/core/recall-runtime.js +115 -0
  24. package/core/scope-attribution.js +279 -0
  25. package/core/session-checkpoint-producer.js +412 -0
  26. package/core/session-checkpoints.js +432 -0
  27. package/core/session-finalization.js +82 -1
  28. package/core/storage-checkpoints.js +546 -0
  29. package/core/storage.js +121 -8
  30. package/docs/setup.md +22 -0
  31. package/package.json +8 -4
  32. package/schema/014-v1-checkpoint-runs.sql +349 -0
  33. package/schema/015-v1-evidence-items.sql +92 -0
  34. package/schema/016-v1-evidence-ref-multi-item.sql +19 -0
  35. package/schema/017-v1-memory-record-embeddings.sql +25 -0
  36. package/schema/018-v1-finalization-candidate-envelope.sql +39 -0
  37. package/scripts/codex-checkpoint-commands.js +464 -0
  38. package/scripts/codex-checkpoint-runtime.js +520 -0
  39. package/scripts/codex-recovery.js +105 -0
package/.env.example CHANGED
@@ -1,4 +1,7 @@
1
1
  DATABASE_URL=postgresql://aquifer:aquifer@localhost:5432/aquifer
2
+ AQUIFER_BACKEND=postgres
3
+ # AQUIFER_BACKEND=local
4
+ # AQUIFER_LOCAL_PATH=.aquifer/aquifer.local.json
2
5
  AQUIFER_SCHEMA=aquifer
3
6
  AQUIFER_TENANT_ID=default
4
7
 
@@ -9,6 +12,11 @@ AQUIFER_MEMORY_SERVING_MODE=legacy
9
12
  # AQUIFER_MEMORY_ACTIVE_SCOPE_KEY=project:example
10
13
  # AQUIFER_MEMORY_ACTIVE_SCOPE_PATH=global,project:example
11
14
 
15
+ # Optional Codex active-session checkpoint heartbeat policy.
16
+ # AQUIFER_CODEX_CHECKPOINT_CHECK_INTERVAL_MINUTES=10
17
+ # AQUIFER_CODEX_CHECKPOINT_EVERY_MESSAGES=20
18
+ # AQUIFER_CODEX_CHECKPOINT_QUIET_MS=3000
19
+
12
20
  AQUIFER_EMBED_BASE_URL=http://localhost:11434/v1
13
21
  AQUIFER_EMBED_MODEL=bge-m3
14
22
  # EMBED_PROVIDER=ollama
package/README.md CHANGED
@@ -74,11 +74,15 @@ Keep `AQUIFER_MEMORY_SERVING_MODE=legacy` for first rollout. Switch to `curated`
74
74
  | Goal | Command |
75
75
  |---|---|
76
76
  | Verify setup | `npx aquifer quickstart` |
77
+ | Inspect selected backend capabilities without DB connection | `AQUIFER_BACKEND=local npx aquifer backend-info --json` |
77
78
  | Start the MCP server | `npx aquifer mcp` |
78
79
  | Search memory manually | `npx aquifer recall "auth middleware"` |
79
80
  | Plan curated memory compaction | `npx aquifer compact --cadence daily --period-start 2026-04-27T00:00:00Z --period-end 2026-04-28T00:00:00Z` |
80
81
  | Generate a timer synthesis prompt | `npx aquifer operator compaction daily --include-synthesis-prompt --json` |
81
82
  | Apply reviewed timer synthesis candidates | `npx aquifer operator compaction daily --synthesis-summary-file /tmp/timer-summary.json --apply --promote-candidates --json` |
83
+ | Generate a finalized-session checkpoint prompt | `npx aquifer operator checkpoint --scope-key project:aquifer --min-finalizations 10 --include-synthesis-prompt --json` |
84
+ | Heartbeat-check an active Codex session for checkpoint work | `npx aquifer codex-recovery checkpoint-heartbeat --hook-stdin --scope-key project:aquifer` |
85
+ | Preview a Codex UserPromptSubmit heartbeat hook install | `npx aquifer codex-recovery checkpoint-heartbeat-hook --scope-key project:aquifer --hooks-path "$CODEX_HOME/hooks.json" --json` |
82
86
  | Inspect storage health | `npx aquifer stats` |
83
87
  | Enrich pending sessions | `npx aquifer backfill` |
84
88
 
@@ -86,6 +90,15 @@ Timer synthesis output is candidate material until an operator applies it with
86
90
  `--promote-candidates`; it does not become active curated memory from the
87
91
  prompt or summary file alone.
88
92
 
93
+ Checkpoint output follows the same boundary. `operator checkpoint` plans from
94
+ finalized session summaries and only writes `checkpoint_runs` when you pass an
95
+ explicit reviewed synthesis summary with `--apply`. `codex-recovery
96
+ checkpoint-heartbeat` is the active-session hook heartbeat for Codex JSONL
97
+ files: it first checks a tiny local scheduler marker, reads the transcript only
98
+ when the time window is due, then writes local spool process material only when
99
+ the message threshold is also due. It does not print prompt text by default and
100
+ does not write DB memory.
101
+
89
102
  Need LLM summarization, the knowledge graph, OpenAI embeddings, reranking, or operations details? See [docs/setup.md](docs/setup.md) and [Environment Variables](#environment-variables).
90
103
 
91
104
  ---
@@ -139,6 +152,8 @@ Sessions, summaries, turn-level embeddings, entity graph — all live in one dat
139
152
  | Variable | Required? | Purpose | Example |
140
153
  |----------|-----------|---------|---------|
141
154
  | `DATABASE_URL` | Yes | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/mydb` |
155
+ | `AQUIFER_BACKEND` | No | Backend profile selector: `postgres` full backend or explicit degraded `local` starter | `postgres` |
156
+ | `AQUIFER_LOCAL_PATH` | No | Local starter JSON store path | `.aquifer/aquifer.local.json` |
142
157
  | `AQUIFER_SCHEMA` | No | PG schema name (default: `aquifer`) | `memory` |
143
158
  | `AQUIFER_TENANT_ID` | No | Multi-tenant key (default: `default`) | `my-app` |
144
159
  | `AQUIFER_EMBED_BASE_URL` | Yes (for recall) | Embedding API base URL | `http://localhost:11434/v1` |
@@ -157,6 +172,10 @@ Sessions, summaries, turn-level embeddings, entity graph — all live in one dat
157
172
  | `AQUIFER_MEMORY_SERVING_MODE` | No | Public serving mode: `legacy` default, or opt-in `curated` | `curated` |
158
173
  | `AQUIFER_MEMORY_ACTIVE_SCOPE_KEY` | No | Default active curated scope for recall/bootstrap | `project:aquifer` |
159
174
  | `AQUIFER_MEMORY_ACTIVE_SCOPE_PATH` | No | Ordered curated scope path for inheritance | `global,project:aquifer` |
175
+ | `AQUIFER_CODEX_CHECKPOINT_CHECK_INTERVAL_MINUTES` | No | Active Codex checkpoint heartbeat time gate (default: `10`) | `10` |
176
+ | `AQUIFER_CODEX_CHECKPOINT_EVERY_MESSAGES` | No | Active Codex checkpoint message delta gate (default: `20`) | `20` |
177
+ | `AQUIFER_CODEX_CHECKPOINT_EVERY_USER_MESSAGES` | No | Optional user-message delta gate | `10` |
178
+ | `AQUIFER_CODEX_CHECKPOINT_QUIET_MS` | No | Quiet period before reading due transcripts (default: `3000`) | `3000` |
160
179
  | `AQUIFER_MIGRATIONS_MODE` | No | Startup handshake mode: `apply` (default), `check`, `off` | `apply` |
161
180
  | `AQUIFER_MIGRATION_LOCK_TIMEOUT_MS` | No | Advisory-lock wait before `AQ_MIGRATION_LOCK_TIMEOUT` (default 30000) | `30000` |
162
181
  | `AQUIFER_INSIGHTS_DEDUP_MODE` | No | Insights semantic dedup mode: `off` (default), `shadow`, `enforce` — env wins over code for this field only, so operators can kill-switch without redeploy | `shadow` |
@@ -219,6 +238,53 @@ Add to your project's `.claude.json` or user-level MCP config:
219
238
 
220
239
  Tools appear as `mcp__aquifer__session_recall`, `mcp__aquifer__evidence_recall`, `mcp__aquifer__session_bootstrap`, `mcp__aquifer__session_feedback`, `mcp__aquifer__memory_feedback`, `mcp__aquifer__feedback_stats`, `mcp__aquifer__memory_stats`, `mcp__aquifer__memory_pending`.
221
240
 
241
+ For Codex long sessions, Aquifer exposes a UserPromptSubmit-friendly heartbeat
242
+ command instead of installing a daemon:
243
+
244
+ ```bash
245
+ npx aquifer codex-recovery checkpoint-heartbeat \
246
+ --hook-stdin \
247
+ --scope-key project:aquifer
248
+ ```
249
+
250
+ Run that from a host hook with Codex hook JSON on stdin. The heartbeat uses a
251
+ time-first gate: if the local marker says the next check is not due, it exits
252
+ without validating or reading the transcript. When due, it validates the
253
+ `transcript_path` realpath under the Codex sessions directory, waits for the
254
+ quiet period, checks the configured message delta, and writes a local spool file
255
+ for later review. Scheduler, claim, and spool files live under the Codex state
256
+ directory by default; they are process-control files, not DB memory.
257
+
258
+ Heartbeat policy resolves as command flags first, then Aquifer env/config, then
259
+ defaults. The default policy is 10 minutes, 20 safe messages, no user-message
260
+ gate, 3000 ms quiet period, and 60000 ms claim TTL. In config files this lives at
261
+ `codex.checkpoint`:
262
+
263
+ ```json
264
+ {
265
+ "codex": {
266
+ "checkpoint": {
267
+ "checkIntervalMinutes": 10,
268
+ "everyMessages": 20,
269
+ "quietMs": 3000
270
+ }
271
+ }
272
+ }
273
+ ```
274
+
275
+ To prepare the Codex hook entry, generate or apply the merged `hooks.json`:
276
+
277
+ ```bash
278
+ npx aquifer codex-recovery checkpoint-heartbeat-hook \
279
+ --scope-key project:aquifer \
280
+ --hooks-path "$CODEX_HOME/hooks.json" \
281
+ --json
282
+ ```
283
+
284
+ The hook installer is dry-run by default. Add `--apply` only after reviewing the
285
+ merged `UserPromptSubmit` command. `codex-recovery doctor --json` reports whether
286
+ the heartbeat hook is present.
287
+
222
288
  ### OpenClaw
223
289
 
224
290
  Add to `openclaw.json` under `mcp.servers`:
@@ -1,4 +1,14 @@
1
1
  {
2
+ "storage": {
3
+ "backend": "postgres",
4
+ "postgres": {
5
+ "url": "postgresql://user:password@localhost:5432/mydb",
6
+ "max": 10
7
+ },
8
+ "local": {
9
+ "path": ".aquifer/aquifer.local.json"
10
+ }
11
+ },
2
12
  "db": {
3
13
  "url": "postgresql://user:password@localhost:5432/mydb",
4
14
  "max": 10
@@ -14,6 +24,15 @@
14
24
  "activeScopeKey": null,
15
25
  "activeScopePath": null
16
26
  },
27
+ "codex": {
28
+ "checkpoint": {
29
+ "checkIntervalMinutes": 10,
30
+ "everyMessages": 20,
31
+ "everyUserMessages": null,
32
+ "quietMs": 3000,
33
+ "claimTtlMs": 60000
34
+ }
35
+ },
17
36
  "embed": {
18
37
  "baseUrl": "http://localhost:11434/v1",
19
38
  "model": "bge-m3",
package/consumers/cli.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * aquifer recall <query> [options] Search sessions
11
11
  * aquifer backfill [options] Enrich pending sessions
12
12
  * aquifer stats [options] Show database statistics
13
+ * aquifer backend-info [--json] Show selected backend capabilities
13
14
  * aquifer export [options] Export sessions
14
15
  * aquifer operator ... Run operator-safe consolidation jobs
15
16
  * aquifer mcp Start MCP server
@@ -17,7 +18,9 @@
17
18
 
18
19
  const fs = require('fs');
19
20
  const { createAquiferFromConfig } = require('./shared/factory');
21
+ const { loadConfig } = require('./shared/config');
20
22
  const { formatRecallResults } = require('./shared/recall-format');
23
+ const { backendCapabilities } = require('../core/backends/capabilities');
21
24
 
22
25
  function formatDate(value, fallback) {
23
26
  if (!value) return fallback;
@@ -109,6 +112,7 @@ function buildQuickstartSetupHints(env, detected, err) {
109
112
  if (!hasDb) {
110
113
  hints.push('If you expect local defaults, make sure PostgreSQL is running on localhost:5432.');
111
114
  hints.push('Otherwise set DATABASE_URL or AQUIFER_DB_URL explicitly and run quickstart again.');
115
+ hints.push('To inspect the degraded local starter profile without PostgreSQL, run `AQUIFER_BACKEND=local aquifer backend-info --json`.');
112
116
  }
113
117
  return hints;
114
118
  }
@@ -124,6 +128,7 @@ function buildQuickstartSetupHints(env, detected, err) {
124
128
  if (!hasDb) hints.push('PostgreSQL was not autodetected and no DATABASE_URL is set.');
125
129
  if (!hasEmbed) hints.push('No embedding provider was autodetected and no embed env is set.');
126
130
  hints.push('Try `docker compose up -d`, then run `npx aquifer quickstart` again.');
131
+ hints.push('To inspect the degraded local starter profile without PostgreSQL, run `AQUIFER_BACKEND=local aquifer backend-info --json`.');
127
132
  }
128
133
 
129
134
  hints.push(`Raw error: ${message}`);
@@ -176,8 +181,8 @@ function parseArgs(argv) {
176
181
  'verdict', 'feedback-type', 'note', 'db', 'since', 'min-messages', 'lookback-days', 'max-chars',
177
182
  'out', 'active-scope-key', 'active-scope-path', 'cadence', 'period-start', 'period-end',
178
183
  'policy-version', 'worker-id', 'apply-token', 'claim-lease-seconds', 'snapshot-as-of',
179
- 'scope-key', 'scope-keys', 'scope-kind', 'context-key', 'topic-key', 'authority', 'input',
180
- 'synthesis-summary', 'synthesis-summary-file',
184
+ 'scope-id', 'scope-key', 'scope-keys', 'scope-kind', 'context-key', 'topic-key', 'authority', 'input',
185
+ 'synthesis-summary', 'synthesis-summary-file', 'min-finalizations', 'checkpoint-key',
181
186
  ]);
182
187
  for (let i = 0; i < argv.length; i++) {
183
188
  if (argv[i] === '--') { args._.push(...argv.slice(i + 1)); break; }
@@ -381,15 +386,122 @@ async function cmdStats(aquifer, args) {
381
386
  if (args.flags.json) {
382
387
  console.log(JSON.stringify(stats, null, 2));
383
388
  } else {
389
+ console.log(`Backend: ${stats.backendKind || 'unknown'} (${stats.backendProfile || 'unknown'})`);
390
+ console.log(`Serving mode: ${stats.serving?.mode || 'legacy'}`);
391
+ console.log(`Active scope: ${stats.serving?.activeScopePath?.join(' > ') || stats.serving?.activeScopeKey || 'none'}`);
384
392
  console.log(`Sessions: ${stats.sessionTotal} (${Object.entries(stats.sessions).map(([k, v]) => `${k}: ${v}`).join(', ')})`);
385
393
  console.log(`Summaries: ${stats.summaries}`);
386
394
  console.log(`Turn embeddings: ${stats.turnEmbeddings}`);
387
395
  console.log(`Entities: ${stats.entities}`);
396
+ if (stats.memoryRecords) {
397
+ console.log(`Memory records: ${stats.memoryRecords.total} (${stats.memoryRecords.active} active, ${stats.memoryRecords.visibleInRecall} recall-visible, ${stats.memoryRecords.visibleInBootstrap} bootstrap-visible)`);
398
+ if (stats.memoryRecords.latest) console.log(`Memory record range: ${formatDate(stats.memoryRecords.earliest, '?')} — ${formatDate(stats.memoryRecords.latest, '?')}`);
399
+ }
400
+ if (stats.sessionFinalizations?.available) {
401
+ const statusText = Object.entries(stats.sessionFinalizations.statuses || {})
402
+ .map(([status, count]) => `${status}: ${count}`)
403
+ .join(', ') || 'none';
404
+ console.log(`Session finalizations: ${stats.sessionFinalizations.total} (${statusText})`);
405
+ if (stats.sessionFinalizations.latestFinalizedAt) console.log(`Latest finalization: ${formatDate(stats.sessionFinalizations.latestFinalizedAt, '?')}`);
406
+ }
388
407
  if (stats.earliest) console.log(`Range: ${formatDate(stats.earliest, '?')} — ${formatDate(stats.latest, '?')}`);
408
+ if ((stats.serving?.mode || 'legacy') !== 'curated') {
409
+ console.log('Warning: legacy serving returns session/evidence material; configure curated serving with an active scope for current-memory answers.');
410
+ }
411
+ }
412
+ }
413
+
414
+ function selectedBackendInfo(opts = {}) {
415
+ const config = loadConfig(opts);
416
+ const backendKind = config.storage.backend;
417
+ const capabilities = backendCapabilities(backendKind);
418
+ return {
419
+ backendKind,
420
+ backendProfile: capabilities.profile,
421
+ label: capabilities.label,
422
+ summary: capabilities.summary,
423
+ capabilities: capabilities.capabilities,
424
+ storage: {
425
+ localPath: config.storage.local.path,
426
+ postgresUrlConfigured: Boolean(config.storage.postgres.url || config.db.url),
427
+ },
428
+ upgradeHint: capabilities.upgradeHint,
429
+ };
430
+ }
431
+
432
+ async function cmdBackendInfo(args) {
433
+ const info = selectedBackendInfo();
434
+ if (args.flags.json) {
435
+ console.log(JSON.stringify(info, null, 2));
436
+ return;
437
+ }
438
+
439
+ console.log(`Backend: ${info.label} (${info.backendKind}/${info.backendProfile})`);
440
+ console.log(info.summary);
441
+ console.log(`PostgreSQL URL configured: ${info.storage.postgresUrlConfigured ? 'yes' : 'no'}`);
442
+ if (info.backendKind === 'local') {
443
+ console.log(`Local path: ${info.storage.localPath}`);
444
+ }
445
+ if (info.upgradeHint) {
446
+ console.log(`Upgrade: ${info.upgradeHint}`);
447
+ }
448
+ console.log('Capabilities:');
449
+ for (const [name, status] of Object.entries(info.capabilities)) {
450
+ console.log(` ${name}: ${status}`);
451
+ }
452
+ }
453
+
454
+ async function cmdLocalQuickstart(aquifer) {
455
+ const cfg = aquifer.getConfig();
456
+ console.log('Aquifer quickstart — verifying local starter backend.\n');
457
+
458
+ console.log('1/5 Preparing local store...');
459
+ await aquifer.init();
460
+ console.log(` OK — ${cfg.backendPath}\n`);
461
+
462
+ const sessionId = `quickstart-local-${Date.now()}`;
463
+ console.log('2/5 Committing test session...');
464
+ await aquifer.commit(sessionId, [
465
+ { role: 'user', content: 'Aquifer local starter can store a session without PostgreSQL.' },
466
+ { role: 'assistant', content: 'Local starter uses JSON persistence and degraded lexical recall.' },
467
+ ], { agentId: 'quickstart', source: 'quickstart' });
468
+ console.log(' OK\n');
469
+
470
+ console.log('3/5 Checking degraded enrich path...');
471
+ const enrichResult = await aquifer.enrich(sessionId, {
472
+ agentId: 'quickstart',
473
+ skipSummary: true,
474
+ skipEntities: true,
475
+ });
476
+ console.log(` OK — ${enrichResult.turnsEmbedded} turns embedded (local starter is lexical only)\n`);
477
+
478
+ console.log('4/5 Recalling "JSON persistence"...');
479
+ const results = await aquifer.recall('JSON persistence', { agentId: 'quickstart', limit: 3 });
480
+ if (results.length === 0) {
481
+ printQuickstartFailure('local starter could not recall its own test session.', [
482
+ 'The write step succeeded, but lexical recall returned no matches.',
483
+ ]);
484
+ process.exitCode = 1;
485
+ return;
389
486
  }
487
+ console.log(` OK — ${results.length} result(s), top score: ${results[0].score?.toFixed(3)}\n`);
488
+
489
+ console.log('5/5 Cleaning up test data...');
490
+ if (typeof aquifer.deleteSession === 'function') {
491
+ await aquifer.deleteSession(sessionId, { agentId: 'quickstart' });
492
+ }
493
+ console.log(' OK\n');
494
+
495
+ console.log('✓ Aquifer local starter is working.');
496
+ console.log(' This backend is degraded: use PostgreSQL quickstart for semantic recall, migrations, curated memory, and operator workflows.');
390
497
  }
391
498
 
392
499
  async function cmdQuickstart(aquifer) {
500
+ if (aquifer.getConfig().backendKind === 'local') {
501
+ await cmdLocalQuickstart(aquifer);
502
+ return;
503
+ }
504
+
393
505
  console.log('Aquifer quickstart — verifying end-to-end setup.\n');
394
506
 
395
507
  // 1. Migrate
@@ -514,6 +626,51 @@ async function cmdOperator(aquifer, args) {
514
626
  const operatorVerb = args._[1] || 'compaction';
515
627
  const cadenceVerbs = new Set(['manual', 'daily', 'weekly', 'monthly']);
516
628
 
629
+ if (operatorVerb === 'checkpoint') {
630
+ const synthesisSummary = readSynthesisSummaryFromFlags(args.flags);
631
+ const result = await aquifer.checkpoints.runProducer({
632
+ scopeId: args.flags['scope-id'] || undefined,
633
+ scopeKind: args.flags['scope-kind'] || undefined,
634
+ scopeKey: args.flags['scope-key'] || undefined,
635
+ source: args.flags.source || undefined,
636
+ agentId: args.flags['agent-id'] || undefined,
637
+ minFinalizations: args.flags['min-finalizations']
638
+ ? parsePositiveInt(args.flags['min-finalizations'], 10)
639
+ : undefined,
640
+ limit: parsePositiveInt(args.flags.limit, 50),
641
+ checkpointKey: args.flags['checkpoint-key'] || undefined,
642
+ policyVersion: args.flags['policy-version'] || undefined,
643
+ force: args.flags.force === true,
644
+ apply: args.flags.apply === true,
645
+ finalize: args.flags.finalize === true,
646
+ includeSynthesisPrompt: args.flags['include-synthesis-prompt'] === true,
647
+ synthesisSummary,
648
+ });
649
+
650
+ if (args.flags.json) {
651
+ console.log(JSON.stringify(result, null, 2));
652
+ return;
653
+ }
654
+
655
+ console.log(result.due
656
+ ? `Checkpoint due for ${result.scope.scopeKey}: ${result.sourceFinalizationCount} finalized source(s).`
657
+ : `Checkpoint not ready for ${result.scope.scopeKey}: ${result.sourceFinalizationCount}/${result.minFinalizations} finalized source(s).`);
658
+ if (result.range) {
659
+ console.log(`Range: finalization ${result.range.fromFinalizationIdExclusive} -> ${result.range.toFinalizationIdInclusive}`);
660
+ }
661
+ if (result.synthesisPrompt) {
662
+ console.log('\nCheckpoint synthesis prompt:\n');
663
+ console.log(result.synthesisPrompt);
664
+ }
665
+ if (result.run) {
666
+ console.log(`Run: #${result.run.id} status=${result.run.status}`);
667
+ } else if (result.runInput) {
668
+ console.log(`Run input prepared: status=${result.runInput.status}`);
669
+ console.log('Mode: dry-run only. Re-run with --apply and an explicit synthesis summary to write checkpoint_runs.');
670
+ }
671
+ return;
672
+ }
673
+
517
674
  if (operatorVerb === 'archive-distill') {
518
675
  const inputPath = args.flags.input || args._[2];
519
676
  if (!inputPath) {
@@ -544,7 +701,7 @@ async function cmdOperator(aquifer, args) {
544
701
  }
545
702
 
546
703
  if (operatorVerb !== 'compaction' && operatorVerb !== 'compact' && !cadenceVerbs.has(operatorVerb)) {
547
- console.error('Usage: aquifer operator <compaction|archive-distill> [...]');
704
+ console.error('Usage: aquifer operator <compaction|checkpoint|archive-distill> [...]');
548
705
  process.exit(1);
549
706
  }
550
707
 
@@ -650,6 +807,7 @@ async function main() {
650
807
  Commands:
651
808
  quickstart Verify end-to-end setup (migrate → commit → enrich → recall)
652
809
  migrate Run database migrations
810
+ backend-info Show selected backend capabilities without connecting to a database
653
811
  recall <query> Search sessions (requires embed config)
654
812
  evidence-recall <query> Search legacy session/evidence plane explicitly
655
813
  feedback Record trust feedback on a session
@@ -661,7 +819,7 @@ Commands:
661
819
  stats Show database statistics
662
820
  export Export sessions as JSONL
663
821
  bootstrap Show recent session context (for new session start)
664
- codex-recovery ... Inspect or run Codex SessionStart recovery flow
822
+ codex-recovery ... Inspect or run Codex recovery/checkpoint flows
665
823
  ingest-opencode Import sessions from OpenCode's local SQLite DB
666
824
  mcp Start MCP server
667
825
 
@@ -697,7 +855,10 @@ Options:
697
855
  --include-synthesis-prompt Include timer synthesis prompt in operator output
698
856
  --synthesis-summary JSON Timer synthesis summary JSON to attach to a compaction plan
699
857
  --synthesis-summary-file P Read timer synthesis summary JSON from file
858
+ --min-finalizations N Min finalized session summaries before checkpoint proposal
859
+ --checkpoint-key KEY Explicit checkpoint key when applying checkpoint producer output
700
860
  --scope-key A,B Limit compaction snapshot to specific scope keys
861
+ --scope-id ID Target scope row id for checkpoint producer
701
862
  --scope-kind KIND Explicit synthesis target scope kind
702
863
  --snapshot-as-of ISO Read active snapshot as of a specific instant
703
864
  --claim-lease-seconds N Override compaction apply lease
@@ -711,6 +872,11 @@ Operator examples:
711
872
  aquifer operator compaction daily --include-synthesis-prompt --json
712
873
  aquifer operator compaction manual --period-start 2026-04-27T00:00:00Z --period-end 2026-04-28T00:00:00Z --apply
713
874
  aquifer operator compaction daily --synthesis-summary-file /tmp/timer-summary.json --apply --promote-candidates --json
875
+ aquifer operator checkpoint --scope-key project:aquifer --min-finalizations 10 --include-synthesis-prompt --json
876
+ aquifer operator checkpoint --scope-id 7 --synthesis-summary-file /tmp/checkpoint-summary.json --apply --finalize --json
877
+ AQUIFER_BACKEND=local aquifer backend-info --json
878
+ aquifer codex-recovery checkpoint-heartbeat --hook-stdin --scope-key project:aquifer
879
+ aquifer codex-recovery checkpoint-heartbeat-hook --scope-key project:aquifer --hooks-path "$CODEX_HOME/hooks.json" --json
714
880
  aquifer operator archive-distill --input /tmp/archive-snapshot.json --json`);
715
881
  process.exit(0);
716
882
  }
@@ -743,6 +909,14 @@ Operator examples:
743
909
  return;
744
910
  }
745
911
 
912
+ if (command === 'backend-info') {
913
+ if (args.flags.config) {
914
+ process.env.AQUIFER_CONFIG = args.flags.config;
915
+ }
916
+ await cmdBackendInfo(args);
917
+ return;
918
+ }
919
+
746
920
  // All other commands need an Aquifer instance
747
921
  const configOverrides = {};
748
922
  if (args.flags.config) {
@@ -755,15 +929,18 @@ Operator examples:
755
929
  // Production commands (migrate, mcp, recall, ...) stay strict — they expect
756
930
  // the operator to have set env explicitly.
757
931
  if (command === 'quickstart') {
758
- const { autodetectForQuickstart } = require('./shared/autodetect');
759
- quickstartDetected = await autodetectForQuickstart(process.env);
760
- if (Object.keys(quickstartDetected).length > 0) {
761
- console.log('Autodetected localhost services (env not set):');
762
- for (const [k, v] of Object.entries(quickstartDetected)) {
763
- console.log(` ${k}=${v}`);
764
- process.env[k] = v;
932
+ const selected = loadConfig({ overrides: configOverrides });
933
+ if (selected.storage.backend !== 'local') {
934
+ const { autodetectForQuickstart } = require('./shared/autodetect');
935
+ quickstartDetected = await autodetectForQuickstart(process.env);
936
+ if (Object.keys(quickstartDetected).length > 0) {
937
+ console.log('Autodetected localhost services (env not set):');
938
+ for (const [k, v] of Object.entries(quickstartDetected)) {
939
+ console.log(` ${k}=${v}`);
940
+ process.env[k] = v;
941
+ }
942
+ console.log(' Export these in your shell (or MCP client env) to make them permanent.\n');
765
943
  }
766
- console.log(' Export these in your shell (or MCP client env) to make them permanent.\n');
767
944
  }
768
945
  }
769
946
 
@@ -837,6 +1014,9 @@ Operator examples:
837
1014
  // Export for testing; execute only when run directly
838
1015
  module.exports = {
839
1016
  parseArgs,
1017
+ selectedBackendInfo,
1018
+ cmdBackendInfo,
1019
+ cmdLocalQuickstart,
840
1020
  cmdOperator,
841
1021
  readSynthesisSummaryFromFlags,
842
1022
  };
@@ -0,0 +1,186 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ buildCheckpointCoverageFromView,
5
+ hashSnapshot,
6
+ promptSafeSynthesisInput,
7
+ stableJson,
8
+ } = require('../core/session-checkpoint-producer');
9
+ const { compactCurrentMemorySnapshot } = require('./codex-current-memory');
10
+
11
+ function positiveInt(value, fallback, max = 100000) {
12
+ const n = Number(value === undefined || value === null || value === '' ? fallback : value);
13
+ if (!Number.isFinite(n)) return fallback;
14
+ return Math.max(1, Math.min(max, Math.trunc(n)));
15
+ }
16
+
17
+ function buildActiveCheckpointScopeEnvelope(opts = {}) {
18
+ const envelope = opts.scopeEnvelope || opts.scope_envelope;
19
+ if (envelope && typeof envelope === 'object') return envelope;
20
+ const scopeKey = String(opts.activeScopeKey || opts.scopeKey || opts.scope_key || '').trim();
21
+ if (!scopeKey) {
22
+ throw new Error('active session checkpoint requires activeScopeKey or scopeKey');
23
+ }
24
+ const scopeKind = String(opts.activeScopeKind || opts.scopeKind || opts.scope_kind || 'project').trim();
25
+ const slotId = scopeKind === 'host_runtime' ? 'host' : (
26
+ ['workspace', 'project', 'repo', 'task', 'session'].includes(scopeKind) ? scopeKind : 'target'
27
+ );
28
+ const promotable = !['session', 'task'].includes(slotId);
29
+ const slot = {
30
+ id: slotId,
31
+ slot: slotId,
32
+ scopeKind,
33
+ scopeKey,
34
+ label: scopeKey,
35
+ promotable,
36
+ allowedScopeKeys: promotable ? ['global', scopeKey] : ['global'],
37
+ };
38
+ if (!promotable) {
39
+ throw new Error(`active session checkpoint target scope is not promotable: ${scopeKind}`);
40
+ }
41
+ return {
42
+ policyVersion: 'scope_envelope_v1',
43
+ activeSlotId: slot.id,
44
+ activeScopeKey: scopeKey,
45
+ allowedScopeKeys: slot.allowedScopeKeys,
46
+ slots: [slot],
47
+ scopeById: { [slot.id]: slot },
48
+ };
49
+ }
50
+
51
+ function buildActiveSessionCheckpointInput(view = {}, opts = {}) {
52
+ if (!view || view.status !== 'ok') {
53
+ throw new Error(`active session checkpoint requires an ok transcript view; got ${view && view.status ? view.status : 'missing'}`);
54
+ }
55
+ const messageCount = Number.isFinite(Number(view.counts?.safeMessageCount))
56
+ ? Number(view.counts.safeMessageCount)
57
+ : (Array.isArray(view.messages) ? view.messages.length : 0);
58
+ const userCount = Number(view.counts?.userCount || view.messages?.filter?.(m => m.role === 'user').length || 0);
59
+ const everyMessages = positiveInt(opts.checkpointEveryMessages || opts.everyMessages, 20, 1000);
60
+ const everyUserMessages = opts.checkpointEveryUserMessages || opts.everyUserMessages
61
+ ? positiveInt(opts.checkpointEveryUserMessages || opts.everyUserMessages, 10, 1000)
62
+ : null;
63
+ const force = opts.force === true;
64
+ const due = force
65
+ || messageCount >= everyMessages
66
+ || (everyUserMessages !== null && userCount >= everyUserMessages);
67
+ const base = {
68
+ kind: 'codex_active_session_checkpoint_input_v1',
69
+ policyVersion: opts.policyVersion || 'codex_active_session_checkpoint_v1',
70
+ sourceOfTruth: 'codex_sanitized_live_transcript_view',
71
+ triggerKind: opts.triggerKind || 'message_count',
72
+ promotion: {
73
+ default: 'checkpoint_proposal_only',
74
+ requires: 'handoff_or_operator_review',
75
+ },
76
+ guards: {
77
+ checkpointIsProcessMaterial: true,
78
+ activeMemoryCommitExcluded: true,
79
+ dbWriteExcluded: true,
80
+ rawToolOutputExcluded: true,
81
+ debugIdsExcluded: true,
82
+ },
83
+ threshold: {
84
+ everyMessages,
85
+ everyUserMessages,
86
+ messageCount,
87
+ userCount,
88
+ due,
89
+ },
90
+ targetScope: {
91
+ activeScopeKey: opts.activeScopeKey || opts.scopeKey || null,
92
+ activeScopePath: opts.activeScopePath || null,
93
+ },
94
+ scopeEnvelope: buildActiveCheckpointScopeEnvelope(opts),
95
+ coverage: buildCheckpointCoverageFromView(view, opts),
96
+ transcript: {
97
+ sessionId: view.sessionId || null,
98
+ charCount: view.charCount ?? String(view.text || '').length,
99
+ fullCharCount: view.fullCharCount ?? view.counts?.fullCharCount ?? view.charCount ?? String(view.text || '').length,
100
+ approxPromptTokens: view.approxPromptTokens || Math.ceil(String(view.text || '').length / 3),
101
+ fullApproxPromptTokens: view.fullApproxPromptTokens || view.counts?.fullApproxPromptTokens || null,
102
+ truncated: Boolean(view.truncated || view.transcriptWindow?.truncated),
103
+ transcriptWindow: view.transcriptWindow || null,
104
+ text: view.text || '',
105
+ },
106
+ currentMemory: compactCurrentMemorySnapshot(opts.currentMemory || null, opts),
107
+ };
108
+ return {
109
+ ...base,
110
+ inputHash: hashSnapshot(base),
111
+ };
112
+ }
113
+
114
+ function buildActiveSessionCheckpointPrompt(checkpointInput = {}, opts = {}) {
115
+ if (!checkpointInput || checkpointInput.kind !== 'codex_active_session_checkpoint_input_v1') {
116
+ throw new Error('buildActiveSessionCheckpointPrompt requires an active session checkpoint input');
117
+ }
118
+ const promptInput = promptSafeSynthesisInput(checkpointInput);
119
+ const maxFacts = Math.max(1, Math.min(24, opts.maxFacts || 8));
120
+ return [
121
+ 'You are producing an Aquifer active-session checkpoint proposal for Codex.',
122
+ 'Use only the <active_checkpoint_input> block. Do not use hidden tool output, injected context, or debug material.',
123
+ 'This checkpoint is process material for later handoff. It is not active current memory and must not be treated as final truth.',
124
+ 'Return compact JSON with this shape:',
125
+ '{"summaryText":"...","structuredSummary":{"facts":[],"decisions":[],"open_loops":[],"preferences":[],"constraints":[],"conclusions":[],"entity_notes":[],"states":[]},"coverage":{"coordinateSystem":"codex_sanitized_view_v1","coveredUntilMessageIndex":0,"coveredUntilChar":0}}',
126
+ `Keep facts/decisions/open_loops concrete and scoped. Use at most ${maxFacts} facts.`,
127
+ 'Preserve the coverage object so a later handoff can skip the already-covered transcript prefix.',
128
+ '',
129
+ '<active_checkpoint_input>',
130
+ stableJson(promptInput),
131
+ '</active_checkpoint_input>',
132
+ ].join('\n');
133
+ }
134
+
135
+ async function prepareActiveSessionCheckpoint(aquifer, opts = {}, deps = {}) {
136
+ const materializeRecoveryTranscriptView = deps.materializeRecoveryTranscriptView;
137
+ const resolveCurrentMemoryForFinalization = deps.resolveCurrentMemoryForFinalization || (async () => null);
138
+ const view = opts.view || (opts.filePath && typeof materializeRecoveryTranscriptView === 'function'
139
+ ? materializeRecoveryTranscriptView({
140
+ filePath: opts.filePath,
141
+ sessionId: opts.sessionId,
142
+ }, {
143
+ ...opts,
144
+ maxRecoveryBytes: opts.maxCheckpointBytes ?? opts.maxRecoveryBytes,
145
+ maxRecoveryMessages: opts.maxCheckpointMessages ?? opts.maxRecoveryMessages,
146
+ maxRecoveryChars: opts.maxCheckpointChars ?? opts.maxRecoveryChars,
147
+ maxRecoveryPromptTokens: opts.maxCheckpointPromptTokens ?? opts.maxRecoveryPromptTokens,
148
+ })
149
+ : null);
150
+ if (!view || view.status !== 'ok') {
151
+ return {
152
+ status: view?.status || 'missing_view',
153
+ reason: view?.reason || null,
154
+ view,
155
+ };
156
+ }
157
+ const currentMemory = await resolveCurrentMemoryForFinalization(aquifer, opts);
158
+ const checkpointInput = buildActiveSessionCheckpointInput(view, {
159
+ ...opts,
160
+ currentMemory,
161
+ });
162
+ if (!checkpointInput.threshold.due) {
163
+ return {
164
+ status: 'not_ready',
165
+ due: false,
166
+ checkpointInput,
167
+ view,
168
+ currentMemory,
169
+ };
170
+ }
171
+ return {
172
+ status: 'needs_agent_checkpoint',
173
+ due: true,
174
+ outputSchemaVersion: 'codex_active_session_checkpoint_v1',
175
+ checkpointInput,
176
+ view,
177
+ currentMemory,
178
+ prompt: buildActiveSessionCheckpointPrompt(checkpointInput, opts),
179
+ };
180
+ }
181
+
182
+ module.exports = {
183
+ buildActiveSessionCheckpointInput,
184
+ buildActiveSessionCheckpointPrompt,
185
+ prepareActiveSessionCheckpoint,
186
+ };