@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
@@ -0,0 +1,92 @@
1
+ -- Aquifer v1 retrieval-grade evidence items
2
+ -- Requires: 001-base.sql, 007-v1-foundation.sql, 008-session-finalizations.sql
3
+ -- Usage: replace ${schema} with actual schema name
4
+
5
+ CREATE TABLE IF NOT EXISTS ${schema}.evidence_items (
6
+ id BIGSERIAL PRIMARY KEY,
7
+ tenant_id TEXT NOT NULL DEFAULT 'default',
8
+ source_kind TEXT NOT NULL
9
+ CHECK (source_kind IN (
10
+ 'session','session_summary','turn_embedding','insight',
11
+ 'entity_state','evidence_item','raw_event','external'
12
+ )),
13
+ source_ref TEXT NOT NULL CHECK (btrim(source_ref) <> ''),
14
+ session_row_id BIGINT REFERENCES ${schema}.sessions(id) ON DELETE SET NULL,
15
+ turn_embedding_id BIGINT REFERENCES ${schema}.turn_embeddings(id) ON DELETE SET NULL,
16
+ summary_row_id BIGINT REFERENCES ${schema}.session_summaries(session_row_id) ON DELETE SET NULL,
17
+ created_by_finalization_id BIGINT REFERENCES ${schema}.session_finalizations(id) ON DELETE SET NULL,
18
+ excerpt_text TEXT NOT NULL CHECK (btrim(excerpt_text) <> ''),
19
+ excerpt_hash TEXT NOT NULL CHECK (btrim(excerpt_hash) <> ''),
20
+ embedding vector(1024),
21
+ search_tsv TSVECTOR,
22
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
23
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
24
+ );
25
+
26
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_evidence_items_dedupe
27
+ ON ${schema}.evidence_items (tenant_id, source_kind, source_ref, excerpt_hash);
28
+
29
+ CREATE INDEX IF NOT EXISTS idx_evidence_items_source
30
+ ON ${schema}.evidence_items (tenant_id, source_kind, source_ref);
31
+
32
+ CREATE INDEX IF NOT EXISTS idx_evidence_items_finalization
33
+ ON ${schema}.evidence_items (tenant_id, created_by_finalization_id)
34
+ WHERE created_by_finalization_id IS NOT NULL;
35
+
36
+ CREATE INDEX IF NOT EXISTS idx_evidence_items_search_tsv
37
+ ON ${schema}.evidence_items USING GIN (search_tsv);
38
+
39
+ CREATE INDEX IF NOT EXISTS idx_evidence_items_excerpt_trgm
40
+ ON ${schema}.evidence_items USING GIN (excerpt_text gin_trgm_ops);
41
+
42
+ CREATE OR REPLACE FUNCTION ${schema}.evidence_items_search_tsv_update()
43
+ RETURNS trigger
44
+ LANGUAGE plpgsql
45
+ AS $$
46
+ BEGIN
47
+ BEGIN
48
+ NEW.search_tsv := to_tsvector('zhcfg', COALESCE(NEW.excerpt_text, ''));
49
+ EXCEPTION WHEN undefined_object OR undefined_function THEN
50
+ NEW.search_tsv := to_tsvector('simple', COALESCE(NEW.excerpt_text, ''));
51
+ END;
52
+ RETURN NEW;
53
+ END;
54
+ $$;
55
+
56
+ DROP TRIGGER IF EXISTS trg_evidence_items_search_tsv
57
+ ON ${schema}.evidence_items;
58
+
59
+ CREATE TRIGGER trg_evidence_items_search_tsv
60
+ BEFORE INSERT OR UPDATE OF excerpt_text
61
+ ON ${schema}.evidence_items
62
+ FOR EACH ROW
63
+ EXECUTE FUNCTION ${schema}.evidence_items_search_tsv_update();
64
+
65
+ ALTER TABLE ${schema}.evidence_refs
66
+ ADD COLUMN IF NOT EXISTS evidence_item_id BIGINT;
67
+
68
+ CREATE INDEX IF NOT EXISTS idx_evidence_refs_evidence_item
69
+ ON ${schema}.evidence_refs (tenant_id, evidence_item_id)
70
+ WHERE evidence_item_id IS NOT NULL;
71
+
72
+ DO $$
73
+ BEGIN
74
+ IF NOT EXISTS (
75
+ SELECT 1
76
+ FROM pg_constraint
77
+ WHERE conrelid = '${schema}.evidence_refs'::regclass
78
+ AND conname = 'evidence_refs_evidence_item_fk'
79
+ ) THEN
80
+ ALTER TABLE ${schema}.evidence_refs
81
+ ADD CONSTRAINT evidence_refs_evidence_item_fk
82
+ FOREIGN KEY (evidence_item_id)
83
+ REFERENCES ${schema}.evidence_items(id)
84
+ ON DELETE SET NULL;
85
+ END IF;
86
+ END$$;
87
+
88
+ COMMENT ON TABLE ${schema}.evidence_items IS
89
+ 'Retrieval-grade evidence units. Unlike coarse session_summary refs, these are searchable anchors that can support individual memory_records.';
90
+
91
+ COMMENT ON COLUMN ${schema}.evidence_refs.evidence_item_id IS
92
+ 'Optional typed link to a retrieval-grade evidence item. source_kind/source_ref remain the audit-compatible source identity.';
@@ -0,0 +1,19 @@
1
+ -- Aquifer v1 evidence refs can point at multiple retrieval-grade evidence items
2
+ -- Requires: 007-v1-foundation.sql, 015-v1-evidence-items.sql
3
+ -- Usage: replace ${schema} with actual schema name
4
+
5
+ DROP INDEX IF EXISTS ${schema}.idx_evidence_refs_dedupe;
6
+
7
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_evidence_refs_source_dedupe
8
+ ON ${schema}.evidence_refs (tenant_id, owner_kind, owner_id, source_kind, source_ref, relation_kind)
9
+ WHERE evidence_item_id IS NULL;
10
+
11
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_evidence_refs_evidence_item_dedupe
12
+ ON ${schema}.evidence_refs (tenant_id, owner_kind, owner_id, evidence_item_id, relation_kind)
13
+ WHERE evidence_item_id IS NOT NULL;
14
+
15
+ COMMENT ON INDEX ${schema}.idx_evidence_refs_source_dedupe IS
16
+ 'Legacy/coarse provenance dedupe for refs that do not yet point at retrieval-grade evidence_items.';
17
+
18
+ COMMENT ON INDEX ${schema}.idx_evidence_refs_evidence_item_dedupe IS
19
+ 'Allows multiple evidence_items for the same owner/source while deduping each typed evidence item link.';
@@ -0,0 +1,25 @@
1
+ -- Aquifer v1 current-memory semantic recall anchors
2
+ -- Requires: 007-v1-foundation.sql
3
+ -- Usage: replace ${schema} with actual schema name
4
+
5
+ ALTER TABLE ${schema}.memory_records
6
+ ADD COLUMN IF NOT EXISTS embedding vector(1024);
7
+
8
+ DO $$
9
+ BEGIN
10
+ BEGIN
11
+ EXECUTE 'CREATE INDEX IF NOT EXISTS idx_memory_records_embedding_hnsw
12
+ ON ${schema}.memory_records USING hnsw (embedding vector_cosine_ops)
13
+ WHERE status = ''active'' AND visible_in_recall = true AND embedding IS NOT NULL';
14
+ EXCEPTION
15
+ WHEN undefined_object THEN
16
+ RAISE WARNING '[aquifer] pgvector HNSW operator class unavailable; memory_records semantic recall will use lexical/coarse anchors until vector index is available';
17
+ WHEN out_of_memory THEN
18
+ RAISE WARNING '[aquifer] HNSW build on memory_records.embedding ran out of memory; raise maintenance_work_mem and re-run migrate()';
19
+ WHEN program_limit_exceeded THEN
20
+ RAISE WARNING '[aquifer] HNSW build on memory_records.embedding exceeded an internal limit; inspect pgvector logs';
21
+ END;
22
+ END$$;
23
+
24
+ COMMENT ON COLUMN ${schema}.memory_records.embedding IS
25
+ 'Optional current-memory embedding used by curated semantic/hybrid session_recall. Legacy summaries remain evidence anchors, not current truth.';
@@ -0,0 +1,39 @@
1
+ -- Aquifer v1 finalization candidate envelope
2
+ -- Requires: 008-session-finalizations.sql, 010-v1-finalization-review.sql
3
+ -- Usage: replace ${schema} with actual schema name
4
+ --
5
+ -- The candidate envelope is producer material, not serving truth. It records
6
+ -- the structured synthesis input/output that core finalization validated before
7
+ -- promoting active current memory.
8
+
9
+ ALTER TABLE ${schema}.session_finalizations
10
+ ADD COLUMN IF NOT EXISTS candidate_envelope JSONB NOT NULL DEFAULT '{}'::jsonb,
11
+ ADD COLUMN IF NOT EXISTS candidate_envelope_hash TEXT,
12
+ ADD COLUMN IF NOT EXISTS candidate_envelope_version TEXT,
13
+ ADD COLUMN IF NOT EXISTS coverage JSONB NOT NULL DEFAULT '{}'::jsonb;
14
+
15
+ COMMENT ON COLUMN ${schema}.session_finalizations.candidate_envelope IS
16
+ 'Structured current-memory candidate envelope produced by handoff/recovery synthesis; producer material, not serving truth.';
17
+
18
+ COMMENT ON COLUMN ${schema}.session_finalizations.candidate_envelope_hash IS
19
+ 'Stable hash of the candidate envelope used for audit and replay comparison.';
20
+
21
+ COMMENT ON COLUMN ${schema}.session_finalizations.candidate_envelope_version IS
22
+ 'Version of the producer envelope contract, for example handoff_current_memory_synthesis_v1.';
23
+
24
+ COMMENT ON COLUMN ${schema}.session_finalizations.coverage IS
25
+ 'Coverage metadata for partial transcript, previous bootstrap, checkpoint, or other synthesis inputs.';
26
+
27
+ CREATE INDEX IF NOT EXISTS idx_session_finalizations_candidate_envelope_hash
28
+ ON ${schema}.session_finalizations (tenant_id, candidate_envelope_hash)
29
+ WHERE candidate_envelope_hash IS NOT NULL;
30
+
31
+ ALTER TABLE ${schema}.finalization_candidates
32
+ ADD COLUMN IF NOT EXISTS candidate_hash TEXT;
33
+
34
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_finalization_candidates_hash
35
+ ON ${schema}.finalization_candidates (tenant_id, finalization_id, candidate_hash)
36
+ WHERE candidate_hash IS NOT NULL;
37
+
38
+ COMMENT ON COLUMN ${schema}.finalization_candidates.candidate_hash IS
39
+ 'Stable per-candidate hash. candidate_index remains an ordered audit position.';
@@ -0,0 +1,464 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const codex = require('../consumers/codex');
7
+ const {
8
+ acquireHeartbeatClaim,
9
+ checkpointCheckIntervalMs,
10
+ checkpointClaimDir,
11
+ checkpointClaimTtlMs,
12
+ checkpointDueFromMarker,
13
+ checkpointEveryMessages,
14
+ checkpointEveryUserMessages,
15
+ checkpointHeartbeatCommand,
16
+ checkpointHeartbeatInput,
17
+ checkpointMarkerDir,
18
+ checkpointProposalWindow,
19
+ checkpointSchedulerDir,
20
+ checkpointSpoolDir,
21
+ defaultHooksPath,
22
+ findNewestJsonlFile,
23
+ isoAt,
24
+ loadRuntimeConfig,
25
+ mergeCheckpointHeartbeatHook,
26
+ readCheckpointMarker,
27
+ readHooksConfig,
28
+ readSchedulerMarker,
29
+ releaseHeartbeatClaim,
30
+ spoolCheckpointProposal,
31
+ validateCheckpointTranscriptPath,
32
+ writeCheckpointMarker,
33
+ writeSchedulerMarker,
34
+ } = require('./codex-checkpoint-runtime');
35
+
36
+ function parseIntFlag(value, fallback) {
37
+ if (value === undefined || value === null || value === true || value === '') return fallback;
38
+ const parsed = parseInt(value, 10);
39
+ return Number.isFinite(parsed) ? parsed : fallback;
40
+ }
41
+
42
+ function parseScopePath(value) {
43
+ if (!value || value === true) return undefined;
44
+ const parts = String(value).split(',').map(part => part.trim()).filter(Boolean);
45
+ return parts.length ? parts : undefined;
46
+ }
47
+
48
+ function readHookInputFromStdin() {
49
+ try {
50
+ const raw = fs.readFileSync(0, 'utf8');
51
+ if (!raw.trim()) return {};
52
+ const parsed = JSON.parse(raw);
53
+ return parsed && typeof parsed === 'object' ? parsed : {};
54
+ } catch {
55
+ return {};
56
+ }
57
+ }
58
+
59
+ async function cmdCheckpointPrompt(aquifer, flags, opts) {
60
+ const filePath = flags['file-path'];
61
+ if (!filePath) throw new Error('checkpoint-prompt requires --file-path');
62
+ const prepared = await codex.prepareActiveSessionCheckpoint(aquifer, {
63
+ ...opts,
64
+ filePath,
65
+ sessionId: flags['session-id'] || undefined,
66
+ scopeKind: flags['scope-kind'] || undefined,
67
+ scopeKey: flags['scope-key'] || flags['active-scope-key'] || undefined,
68
+ activeScopeKey: flags['active-scope-key'] || flags['scope-key'] || undefined,
69
+ activeScopePath: parseScopePath(flags['active-scope-path']),
70
+ checkpointEveryMessages: parseIntFlag(flags['checkpoint-every-messages'], undefined),
71
+ checkpointEveryUserMessages: parseIntFlag(flags['checkpoint-every-user-messages'], undefined),
72
+ maxCheckpointBytes: parseIntFlag(flags['max-checkpoint-bytes'], undefined),
73
+ maxCheckpointMessages: parseIntFlag(flags['max-checkpoint-messages'], undefined),
74
+ maxCheckpointChars: parseIntFlag(flags['max-checkpoint-chars'], undefined),
75
+ maxCheckpointPromptTokens: parseIntFlag(flags['max-checkpoint-prompt-tokens'], undefined),
76
+ force: flags.force === true,
77
+ });
78
+ if (flags.json) {
79
+ console.log(JSON.stringify({
80
+ status: prepared.status,
81
+ due: prepared.due === true,
82
+ threshold: prepared.checkpointInput?.threshold || null,
83
+ coverage: prepared.checkpointInput?.coverage || null,
84
+ prompt: prepared.prompt || null,
85
+ }, null, 2));
86
+ return;
87
+ }
88
+ if (prepared.status !== 'needs_agent_checkpoint') {
89
+ const threshold = prepared.checkpointInput?.threshold;
90
+ if (threshold) {
91
+ console.log(`Checkpoint prompt unavailable: ${prepared.status} (${threshold.messageCount}/${threshold.everyMessages} messages)`);
92
+ } else {
93
+ console.log(`Checkpoint prompt unavailable: ${prepared.status}`);
94
+ }
95
+ return;
96
+ }
97
+ console.log([
98
+ prepared.prompt,
99
+ '',
100
+ '[AQUIFER CHECKPOINT]',
101
+ 'Use the returned JSON as checkpoint process material for a later handoff or operator-reviewed checkpoint write.',
102
+ ].join('\n'));
103
+ }
104
+
105
+ async function cmdCheckpointTick(aquifer, flags, opts) {
106
+ const paths = codex.defaultPaths(opts);
107
+ const filePath = flags['file-path'] || findNewestJsonlFile(opts.sessionsDir || paths.sessionsDir);
108
+ if (!filePath) throw new Error('checkpoint-tick requires --file-path or a readable --sessions-dir');
109
+ const view = codex.materializeRecoveryTranscriptView({
110
+ filePath,
111
+ sessionId: flags['session-id'] || undefined,
112
+ }, {
113
+ ...opts,
114
+ tailOnMaxBudget: true,
115
+ maxRecoveryBytes: parseIntFlag(flags['max-checkpoint-bytes'], opts.maxRecoveryBytes),
116
+ maxRecoveryMessages: parseIntFlag(flags['max-checkpoint-messages'], opts.maxRecoveryMessages),
117
+ maxRecoveryChars: parseIntFlag(flags['max-checkpoint-chars'], opts.maxRecoveryChars),
118
+ maxRecoveryPromptTokens: parseIntFlag(flags['max-checkpoint-prompt-tokens'], opts.maxRecoveryPromptTokens),
119
+ });
120
+ if (!view || view.status !== 'ok') {
121
+ const result = {
122
+ status: view?.status || 'missing_view',
123
+ due: false,
124
+ filePath,
125
+ reason: view?.reason || null,
126
+ view,
127
+ };
128
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
129
+ else console.log(`Checkpoint tick unavailable: ${result.status}${result.reason ? ` (${result.reason})` : ''}`);
130
+ return result;
131
+ }
132
+
133
+ const markerDir = checkpointMarkerDir(flags, opts);
134
+ const marker = readCheckpointMarker(markerDir, view.sessionId);
135
+ const threshold = checkpointDueFromMarker(view, marker, flags);
136
+ if (!threshold.due) {
137
+ const result = {
138
+ status: 'not_ready',
139
+ due: false,
140
+ filePath,
141
+ sessionId: view.sessionId,
142
+ marker: marker ? {
143
+ markerPath: marker.markerPath,
144
+ messageCount: marker.messageCount || 0,
145
+ userCount: marker.userCount || 0,
146
+ writtenAt: marker.writtenAt || null,
147
+ } : null,
148
+ threshold,
149
+ };
150
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
151
+ else {
152
+ console.log(`Checkpoint tick not ready: ${threshold.deltaMessages}/${threshold.everyMessages} new messages`);
153
+ }
154
+ return result;
155
+ }
156
+
157
+ const prepared = await codex.prepareActiveSessionCheckpoint(aquifer, {
158
+ ...opts,
159
+ filePath,
160
+ view,
161
+ sessionId: flags['session-id'] || undefined,
162
+ scopeKind: flags['scope-kind'] || undefined,
163
+ scopeKey: flags['scope-key'] || flags['active-scope-key'] || undefined,
164
+ activeScopeKey: flags['active-scope-key'] || flags['scope-key'] || undefined,
165
+ activeScopePath: parseScopePath(flags['active-scope-path']),
166
+ checkpointEveryMessages: parseIntFlag(flags['checkpoint-every-messages'], undefined),
167
+ checkpointEveryUserMessages: parseIntFlag(flags['checkpoint-every-user-messages'], undefined),
168
+ maxCheckpointBytes: parseIntFlag(flags['max-checkpoint-bytes'], undefined),
169
+ maxCheckpointMessages: parseIntFlag(flags['max-checkpoint-messages'], undefined),
170
+ maxCheckpointChars: parseIntFlag(flags['max-checkpoint-chars'], undefined),
171
+ maxCheckpointPromptTokens: parseIntFlag(flags['max-checkpoint-prompt-tokens'], undefined),
172
+ force: true,
173
+ triggerKind: marker ? 'message_count_delta' : 'message_count',
174
+ });
175
+ const writtenMarker = prepared.status === 'needs_agent_checkpoint' && flags['dry-run'] !== true
176
+ ? writeCheckpointMarker(markerDir, prepared)
177
+ : null;
178
+ const result = {
179
+ status: prepared.status,
180
+ due: prepared.due === true,
181
+ filePath,
182
+ sessionId: view.sessionId,
183
+ marker: writtenMarker,
184
+ previousMarker: marker ? {
185
+ markerPath: marker.markerPath,
186
+ messageCount: marker.messageCount || 0,
187
+ userCount: marker.userCount || 0,
188
+ writtenAt: marker.writtenAt || null,
189
+ } : null,
190
+ threshold,
191
+ coverage: prepared.checkpointInput?.coverage || null,
192
+ prompt: prepared.prompt || null,
193
+ };
194
+ if (flags.json) {
195
+ console.log(JSON.stringify(result, null, 2));
196
+ return result;
197
+ }
198
+ if (prepared.status !== 'needs_agent_checkpoint') {
199
+ console.log(`Checkpoint tick unavailable: ${prepared.status}`);
200
+ return result;
201
+ }
202
+ console.log([
203
+ prepared.prompt,
204
+ '',
205
+ '[AQUIFER CHECKPOINT TICK]',
206
+ writtenMarker
207
+ ? `Marker written: ${writtenMarker.markerPath}`
208
+ : 'Dry run: marker not written.',
209
+ 'Use the returned JSON as checkpoint process material for a later handoff or operator-reviewed checkpoint write.',
210
+ ].join('\n'));
211
+ return result;
212
+ }
213
+
214
+ function emitCheckpointHeartbeatResult(result, flags = {}) {
215
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
216
+ }
217
+
218
+ async function cmdCheckpointHeartbeat(aquifer, flags, opts, hookInputArg) {
219
+ const hookInput = hookInputArg || (flags['hook-stdin'] === true ? readHookInputFromStdin() : {});
220
+ const input = checkpointHeartbeatInput(flags, hookInput);
221
+ const config = loadRuntimeConfig(flags, opts);
222
+ const nowMs = Date.now();
223
+ const intervalMs = checkpointCheckIntervalMs(flags, config);
224
+ const schedulerDir = checkpointSchedulerDir(flags, opts);
225
+
226
+ let safeSessionId;
227
+ try {
228
+ safeSessionId = codex.assertSafeSessionId(input.sessionId, 'sessionId');
229
+ } catch {
230
+ const result = { status: 'missing_or_invalid_session_id', due: false };
231
+ emitCheckpointHeartbeatResult(result, flags);
232
+ return result;
233
+ }
234
+
235
+ const marker = readSchedulerMarker(schedulerDir, safeSessionId);
236
+ const proposalWindow = checkpointProposalWindow(marker, intervalMs, nowMs);
237
+ const nextCheckAt = proposalWindow.nextProposalAt || null;
238
+ if (flags.force !== true && !proposalWindow.due) {
239
+ const result = {
240
+ status: 'not_due_time',
241
+ due: false,
242
+ sessionId: safeSessionId,
243
+ lastProposalAt: proposalWindow.lastProposalAt,
244
+ nextCheckAt: proposalWindow.nextProposalAt,
245
+ markerPath: marker.markerPath,
246
+ };
247
+ emitCheckpointHeartbeatResult(result, flags);
248
+ return result;
249
+ }
250
+
251
+ const claim = acquireHeartbeatClaim(
252
+ checkpointClaimDir(flags, opts),
253
+ safeSessionId,
254
+ nowMs,
255
+ checkpointClaimTtlMs(flags, config),
256
+ );
257
+ if (!claim.acquired) {
258
+ const result = {
259
+ status: 'checkpoint_heartbeat_claimed',
260
+ due: false,
261
+ sessionId: safeSessionId,
262
+ reason: claim.reason || 'claim_active',
263
+ };
264
+ emitCheckpointHeartbeatResult(result, flags);
265
+ return result;
266
+ }
267
+
268
+ try {
269
+ const pathCheck = validateCheckpointTranscriptPath(input.filePath, opts);
270
+ if (!pathCheck.ok) {
271
+ const written = writeSchedulerMarker(schedulerDir, safeSessionId, {
272
+ lastCheckAt: isoAt(nowMs),
273
+ nextCheckAt,
274
+ lastStatus: pathCheck.status,
275
+ lastReason: pathCheck.reason || null,
276
+ hookEventName: input.hookEventName || null,
277
+ });
278
+ const result = {
279
+ status: pathCheck.status,
280
+ due: false,
281
+ sessionId: safeSessionId,
282
+ reason: pathCheck.reason || null,
283
+ nextCheckAt,
284
+ markerPath: written?.markerPath || null,
285
+ };
286
+ emitCheckpointHeartbeatResult(result, flags);
287
+ return result;
288
+ }
289
+
290
+ const view = codex.materializeRecoveryTranscriptView({
291
+ filePath: pathCheck.filePath,
292
+ sessionId: safeSessionId,
293
+ }, {
294
+ ...opts,
295
+ tailOnMaxBudget: true,
296
+ maxRecoveryBytes: parseIntFlag(flags['max-checkpoint-bytes'], opts.maxRecoveryBytes),
297
+ maxRecoveryMessages: parseIntFlag(flags['max-checkpoint-messages'], opts.maxRecoveryMessages),
298
+ maxRecoveryChars: parseIntFlag(flags['max-checkpoint-chars'], opts.maxRecoveryChars),
299
+ maxRecoveryPromptTokens: parseIntFlag(flags['max-checkpoint-prompt-tokens'], opts.maxRecoveryPromptTokens),
300
+ });
301
+ if (!view || view.status !== 'ok') {
302
+ const written = writeSchedulerMarker(schedulerDir, safeSessionId, {
303
+ lastCheckAt: isoAt(nowMs),
304
+ nextCheckAt,
305
+ lastStatus: view?.status || 'missing_view',
306
+ lastReason: view?.reason || null,
307
+ hookEventName: input.hookEventName || null,
308
+ });
309
+ const result = {
310
+ status: view?.status || 'missing_view',
311
+ due: false,
312
+ sessionId: safeSessionId,
313
+ reason: view?.reason || null,
314
+ nextCheckAt,
315
+ markerPath: written?.markerPath || null,
316
+ };
317
+ emitCheckpointHeartbeatResult(result, flags);
318
+ return result;
319
+ }
320
+
321
+ const coveredMessages = Number(marker?.lastCoveredMessageCount || 0);
322
+ const coveredUsers = Number(marker?.lastCoveredUserCount || 0);
323
+ const threshold = checkpointDueFromMarker(view, coveredMessages > 0 || coveredUsers > 0
324
+ ? { messageCount: coveredMessages, userCount: coveredUsers }
325
+ : null, flags, config);
326
+ if (!threshold.due) {
327
+ const written = writeSchedulerMarker(schedulerDir, safeSessionId, {
328
+ lastCheckAt: isoAt(nowMs),
329
+ nextCheckAt,
330
+ lastStatus: 'not_enough_messages',
331
+ lastReason: null,
332
+ hookEventName: input.hookEventName || null,
333
+ lastObservedMessageCount: threshold.messageCount,
334
+ lastObservedUserCount: threshold.userCount,
335
+ });
336
+ const result = {
337
+ status: 'not_enough_messages',
338
+ due: false,
339
+ sessionId: safeSessionId,
340
+ nextCheckAt,
341
+ markerPath: written?.markerPath || null,
342
+ threshold,
343
+ };
344
+ emitCheckpointHeartbeatResult(result, flags);
345
+ return result;
346
+ }
347
+
348
+ const prepared = await codex.prepareActiveSessionCheckpoint(aquifer, {
349
+ ...opts,
350
+ filePath: pathCheck.filePath,
351
+ view,
352
+ sessionId: safeSessionId,
353
+ scopeKind: flags['scope-kind'] || undefined,
354
+ scopeKey: flags['scope-key'] || flags['active-scope-key'] || undefined,
355
+ activeScopeKey: flags['active-scope-key'] || flags['scope-key'] || undefined,
356
+ activeScopePath: parseScopePath(flags['active-scope-path']),
357
+ checkpointEveryMessages: checkpointEveryMessages(flags, config),
358
+ checkpointEveryUserMessages: checkpointEveryUserMessages(flags, config),
359
+ force: true,
360
+ includeCurrentMemory: false,
361
+ triggerKind: 'heartbeat_time_window',
362
+ });
363
+ if (prepared.status !== 'needs_agent_checkpoint') {
364
+ const written = writeSchedulerMarker(schedulerDir, safeSessionId, {
365
+ lastCheckAt: isoAt(nowMs),
366
+ nextCheckAt,
367
+ lastStatus: prepared.status,
368
+ hookEventName: input.hookEventName || null,
369
+ });
370
+ const result = {
371
+ status: prepared.status,
372
+ due: false,
373
+ sessionId: safeSessionId,
374
+ nextCheckAt,
375
+ markerPath: written?.markerPath || null,
376
+ };
377
+ emitCheckpointHeartbeatResult(result, flags);
378
+ return result;
379
+ }
380
+
381
+ const spool = flags['dry-run'] === true
382
+ ? null
383
+ : spoolCheckpointProposal(checkpointSpoolDir(flags, opts), prepared, {
384
+ sessionId: safeSessionId,
385
+ source: opts.source || 'codex',
386
+ hookEventName: input.hookEventName || null,
387
+ });
388
+ const proposalAt = isoAt(nowMs);
389
+ const nextProposalAt = isoAt(nowMs + intervalMs);
390
+ const markerPatch = {
391
+ lastCheckAt: proposalAt,
392
+ lastProposalAt: flags['dry-run'] === true ? marker?.lastProposalAt || null : proposalAt,
393
+ nextCheckAt: flags['dry-run'] === true ? nextCheckAt : nextProposalAt,
394
+ lastStatus: flags['dry-run'] === true ? 'checkpoint_due_dry_run' : 'checkpoint_spooled',
395
+ lastReason: null,
396
+ hookEventName: input.hookEventName || null,
397
+ lastSpoolPath: spool?.filePath || marker?.lastSpoolPath || null,
398
+ };
399
+ if (flags['dry-run'] !== true) {
400
+ markerPatch.lastCoveredMessageCount = threshold.messageCount;
401
+ markerPatch.lastCoveredUserCount = threshold.userCount;
402
+ }
403
+ const written = writeSchedulerMarker(schedulerDir, safeSessionId, markerPatch);
404
+ const result = {
405
+ status: flags['dry-run'] === true ? 'checkpoint_due_dry_run' : 'checkpoint_spooled',
406
+ due: true,
407
+ sessionId: safeSessionId,
408
+ nextCheckAt: flags['dry-run'] === true ? nextCheckAt : nextProposalAt,
409
+ markerPath: written?.markerPath || null,
410
+ spool,
411
+ threshold,
412
+ coverage: prepared.checkpointInput?.coverage || null,
413
+ };
414
+ emitCheckpointHeartbeatResult(result, flags);
415
+ return result;
416
+ } finally {
417
+ releaseHeartbeatClaim(claim);
418
+ }
419
+ }
420
+
421
+ async function cmdCheckpointHeartbeatHook(flags, opts) {
422
+ if (!flags['scope-key'] && !flags['active-scope-key']) {
423
+ throw new Error('checkpoint-heartbeat-hook requires --scope-key or --active-scope-key');
424
+ }
425
+ const hooksPath = flags['hooks-path'] || defaultHooksPath(opts);
426
+ const before = readHooksConfig(hooksPath);
427
+ const after = mergeCheckpointHeartbeatHook(before, flags, opts);
428
+ const changed = JSON.stringify(before) !== JSON.stringify(after);
429
+ const apply = flags.apply === true;
430
+ if (apply) {
431
+ fs.mkdirSync(path.dirname(hooksPath), { recursive: true });
432
+ fs.writeFileSync(hooksPath, JSON.stringify(after, null, 2) + '\n', 'utf8');
433
+ }
434
+ const result = {
435
+ status: apply ? 'applied' : 'dry_run',
436
+ hooksPath,
437
+ changed,
438
+ event: 'UserPromptSubmit',
439
+ command: checkpointHeartbeatCommand(flags, opts),
440
+ hooks: after,
441
+ };
442
+ if (flags.json) {
443
+ console.log(JSON.stringify(result, null, 2));
444
+ return result;
445
+ }
446
+ console.log([
447
+ `Codex heartbeat hook ${apply ? 'applied' : 'dry run'}: ${hooksPath}`,
448
+ `Changed: ${changed ? 'yes' : 'no'}`,
449
+ 'Command:',
450
+ result.command,
451
+ apply ? '' : 'Pass --apply to write the merged hooks.json.',
452
+ ].filter(Boolean).join('\n'));
453
+ return result;
454
+ }
455
+
456
+ module.exports = {
457
+ cmdCheckpointHeartbeat,
458
+ cmdCheckpointHeartbeatHook,
459
+ cmdCheckpointPrompt,
460
+ cmdCheckpointTick,
461
+ emitCheckpointHeartbeatResult,
462
+ parseScopePath,
463
+ readHookInputFromStdin,
464
+ };