@shadowforge0/aquifer-memory 1.7.0 → 1.8.1
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.
- package/.env.example +8 -0
- package/README.md +66 -0
- package/aquifer.config.example.json +19 -0
- package/consumers/cli.js +217 -14
- package/consumers/codex-active-checkpoint.js +186 -0
- package/consumers/codex-current-memory.js +106 -0
- package/consumers/codex-handoff.js +442 -3
- package/consumers/codex.js +164 -107
- package/consumers/mcp.js +144 -6
- package/consumers/shared/config.js +60 -1
- package/consumers/shared/factory.js +10 -3
- package/core/aquifer.js +351 -840
- package/core/backends/capabilities.js +89 -0
- package/core/backends/local.js +430 -0
- package/core/legacy-bootstrap.js +140 -0
- package/core/mcp-manifest.js +66 -2
- package/core/memory-promotion.js +157 -26
- package/core/memory-recall.js +341 -22
- package/core/memory-records.js +128 -8
- package/core/memory-serving.js +132 -0
- package/core/postgres-migrations.js +533 -0
- package/core/public-session-filter.js +40 -0
- package/core/recall-runtime.js +115 -0
- package/core/scope-attribution.js +279 -0
- package/core/session-checkpoint-producer.js +412 -0
- package/core/session-checkpoints.js +432 -0
- package/core/session-finalization.js +82 -1
- package/core/storage-checkpoints.js +546 -0
- package/core/storage.js +121 -8
- package/docs/setup.md +22 -0
- package/package.json +8 -4
- package/schema/014-v1-checkpoint-runs.sql +349 -0
- package/schema/015-v1-evidence-items.sql +92 -0
- package/schema/016-v1-evidence-ref-multi-item.sql +19 -0
- package/schema/017-v1-memory-record-embeddings.sql +25 -0
- package/schema/018-v1-finalization-candidate-envelope.sql +39 -0
- package/scripts/codex-checkpoint-commands.js +464 -0
- package/scripts/codex-checkpoint-runtime.js +520 -0
- package/scripts/codex-recovery.js +105 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const { loadConfig } = require('../consumers/shared/config');
|
|
8
|
+
const codex = require('../consumers/codex');
|
|
9
|
+
|
|
10
|
+
function parseIntFlag(value, fallback) {
|
|
11
|
+
if (value === undefined || value === null || value === true || value === '') return fallback;
|
|
12
|
+
const parsed = parseInt(value, 10);
|
|
13
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function checkpointMarkerDir(flags = {}, opts = {}) {
|
|
17
|
+
if (flags['checkpoint-marker-dir']) return flags['checkpoint-marker-dir'];
|
|
18
|
+
const paths = codex.defaultPaths(opts);
|
|
19
|
+
return path.join(path.dirname(paths.importedDir), 'codex-active-checkpoints');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function checkpointSchedulerDir(flags = {}, opts = {}) {
|
|
23
|
+
if (flags['checkpoint-scheduler-dir']) return flags['checkpoint-scheduler-dir'];
|
|
24
|
+
const paths = codex.defaultPaths(opts);
|
|
25
|
+
return path.join(path.dirname(paths.importedDir), 'codex-active-checkpoint-scheduler');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function checkpointClaimDir(flags = {}, opts = {}) {
|
|
29
|
+
if (flags['checkpoint-claim-dir']) return flags['checkpoint-claim-dir'];
|
|
30
|
+
const paths = codex.defaultPaths(opts);
|
|
31
|
+
return path.join(path.dirname(paths.importedDir), 'codex-active-checkpoint-claims');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function checkpointSpoolDir(flags = {}, opts = {}) {
|
|
35
|
+
if (flags['checkpoint-spool-dir']) return flags['checkpoint-spool-dir'];
|
|
36
|
+
const paths = codex.defaultPaths(opts);
|
|
37
|
+
return path.join(path.dirname(paths.importedDir), 'codex-active-checkpoint-spool');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function viewMessageCount(view = {}) {
|
|
41
|
+
return Number.isFinite(Number(view.counts?.safeMessageCount))
|
|
42
|
+
? Number(view.counts.safeMessageCount)
|
|
43
|
+
: (Array.isArray(view.messages) ? view.messages.length : 0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function viewUserCount(view = {}) {
|
|
47
|
+
return Number.isFinite(Number(view.counts?.userCount))
|
|
48
|
+
? Number(view.counts.userCount)
|
|
49
|
+
: (Array.isArray(view.messages) ? view.messages.filter(message => message.role === 'user').length : 0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function positiveThreshold(value, fallback) {
|
|
53
|
+
const parsed = parseIntFlag(value, fallback);
|
|
54
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadRuntimeConfig(flags = {}, opts = {}) {
|
|
58
|
+
return loadConfig({
|
|
59
|
+
env: opts.env || process.env,
|
|
60
|
+
configPath: flags.config || (opts.env || process.env).AQUIFER_CONFIG || null,
|
|
61
|
+
cwd: opts.cwd || process.cwd(),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function checkpointPolicy(config = {}) {
|
|
66
|
+
return config.codex?.checkpoint || {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function configuredNumber(value, fallback) {
|
|
70
|
+
return Number.isFinite(Number(value)) ? Number(value) : fallback;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function checkpointCheckIntervalMs(flags = {}, config = {}) {
|
|
74
|
+
const policy = checkpointPolicy(config);
|
|
75
|
+
if (flags['checkpoint-check-interval-ms'] !== undefined) {
|
|
76
|
+
return Math.max(0, parseIntFlag(flags['checkpoint-check-interval-ms'], 600000));
|
|
77
|
+
}
|
|
78
|
+
if (flags['checkpoint-check-interval-minutes'] !== undefined) {
|
|
79
|
+
return Math.max(0, parseIntFlag(flags['checkpoint-check-interval-minutes'], 10) * 60 * 1000);
|
|
80
|
+
}
|
|
81
|
+
if (policy.checkIntervalMs !== undefined && policy.checkIntervalMs !== null) {
|
|
82
|
+
return Math.max(0, configuredNumber(policy.checkIntervalMs, 600000));
|
|
83
|
+
}
|
|
84
|
+
return Math.max(0, configuredNumber(policy.checkIntervalMinutes, 10) * 60 * 1000);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function checkpointQuietMs(flags = {}, config = {}) {
|
|
88
|
+
return Math.max(0, parseIntFlag(
|
|
89
|
+
flags['checkpoint-quiet-ms'],
|
|
90
|
+
configuredNumber(checkpointPolicy(config).quietMs, 3000),
|
|
91
|
+
));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function checkpointClaimTtlMs(flags = {}, config = {}) {
|
|
95
|
+
return Math.max(1000, parseIntFlag(
|
|
96
|
+
flags['checkpoint-claim-ttl-ms'],
|
|
97
|
+
configuredNumber(checkpointPolicy(config).claimTtlMs, 60000),
|
|
98
|
+
));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function checkpointEveryMessages(flags = {}, config = {}) {
|
|
102
|
+
return positiveThreshold(
|
|
103
|
+
flags['checkpoint-every-messages'],
|
|
104
|
+
configuredNumber(checkpointPolicy(config).everyMessages, 20),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function checkpointEveryUserMessages(flags = {}, config = {}) {
|
|
109
|
+
if (flags['checkpoint-every-user-messages']) {
|
|
110
|
+
return positiveThreshold(flags['checkpoint-every-user-messages'], 10);
|
|
111
|
+
}
|
|
112
|
+
const configured = checkpointPolicy(config).everyUserMessages;
|
|
113
|
+
return configured === undefined || configured === null
|
|
114
|
+
? null
|
|
115
|
+
: positiveThreshold(configured, 10);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function checkpointProposalWindow(marker = null, intervalMs = 0, nowMs = Date.now()) {
|
|
119
|
+
const lastProposalMs = marker?.lastProposalAt ? Date.parse(marker.lastProposalAt) : NaN;
|
|
120
|
+
if (!Number.isFinite(lastProposalMs)) return { due: true, lastProposalAt: null, nextProposalAt: null };
|
|
121
|
+
const nextMs = lastProposalMs + Math.max(0, intervalMs);
|
|
122
|
+
return {
|
|
123
|
+
due: nowMs >= nextMs,
|
|
124
|
+
lastProposalAt: marker.lastProposalAt,
|
|
125
|
+
nextProposalAt: isoAt(nextMs),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isoAt(ms) {
|
|
130
|
+
return new Date(ms).toISOString();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function readJsonFile(filePath) {
|
|
134
|
+
try {
|
|
135
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
136
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function readSchedulerMarker(dir, sessionId) {
|
|
143
|
+
if (!dir || !sessionId) return null;
|
|
144
|
+
const filePath = codex.markerPath(dir, sessionId);
|
|
145
|
+
const parsed = readJsonFile(filePath);
|
|
146
|
+
return parsed ? { ...parsed, markerPath: filePath } : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function writeSchedulerMarker(dir, sessionId, patch = {}) {
|
|
150
|
+
if (!dir || !sessionId) return null;
|
|
151
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
152
|
+
const filePath = codex.markerPath(dir, sessionId);
|
|
153
|
+
const existing = readSchedulerMarker(dir, sessionId) || {};
|
|
154
|
+
const marker = {
|
|
155
|
+
kind: 'codex_active_checkpoint_scheduler_v1',
|
|
156
|
+
...existing,
|
|
157
|
+
...patch,
|
|
158
|
+
sessionId,
|
|
159
|
+
updatedAt: new Date().toISOString(),
|
|
160
|
+
};
|
|
161
|
+
delete marker.markerPath;
|
|
162
|
+
fs.writeFileSync(filePath, `${JSON.stringify(marker)}\n`, 'utf8');
|
|
163
|
+
return { ...marker, markerPath: filePath };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function readCheckpointMarker(dir, sessionId) {
|
|
167
|
+
if (!dir || !sessionId) return null;
|
|
168
|
+
const filePath = codex.markerPath(dir, sessionId);
|
|
169
|
+
const parsed = readJsonFile(filePath);
|
|
170
|
+
return parsed ? { ...parsed, markerPath: filePath } : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function writeCheckpointMarker(dir, prepared = {}) {
|
|
174
|
+
const sessionId = prepared.view?.sessionId || prepared.checkpointInput?.transcript?.sessionId;
|
|
175
|
+
if (!dir || !sessionId) return null;
|
|
176
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
177
|
+
const filePath = codex.markerPath(dir, sessionId);
|
|
178
|
+
const marker = {
|
|
179
|
+
kind: 'codex_active_checkpoint_marker_v1',
|
|
180
|
+
sessionId,
|
|
181
|
+
filePath: prepared.view?.filePath || null,
|
|
182
|
+
writtenAt: new Date().toISOString(),
|
|
183
|
+
transcriptHash: prepared.view?.transcriptHash || null,
|
|
184
|
+
inputHash: prepared.checkpointInput?.inputHash || null,
|
|
185
|
+
messageCount: viewMessageCount(prepared.view),
|
|
186
|
+
userCount: viewUserCount(prepared.view),
|
|
187
|
+
coverage: prepared.checkpointInput?.coverage || null,
|
|
188
|
+
};
|
|
189
|
+
fs.writeFileSync(filePath, `${JSON.stringify(marker)}\n`, 'utf8');
|
|
190
|
+
return { ...marker, markerPath: filePath };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function checkpointDueFromMarker(view = {}, marker = null, flags = {}, config = {}) {
|
|
194
|
+
const everyMessages = checkpointEveryMessages(flags, config);
|
|
195
|
+
const everyUserMessages = checkpointEveryUserMessages(flags, config);
|
|
196
|
+
const messageCount = viewMessageCount(view);
|
|
197
|
+
const userCount = viewUserCount(view);
|
|
198
|
+
const markerMessageCount = Number(marker?.messageCount || 0);
|
|
199
|
+
const markerUserCount = Number(marker?.userCount || 0);
|
|
200
|
+
const deltaMessages = Math.max(0, messageCount - markerMessageCount);
|
|
201
|
+
const deltaUserMessages = Math.max(0, userCount - markerUserCount);
|
|
202
|
+
const due = Boolean(flags.force === true
|
|
203
|
+
|| (!marker && (messageCount >= everyMessages || (everyUserMessages !== null && userCount >= everyUserMessages)))
|
|
204
|
+
|| (marker && (deltaMessages >= everyMessages || (everyUserMessages !== null && deltaUserMessages >= everyUserMessages))));
|
|
205
|
+
return {
|
|
206
|
+
due,
|
|
207
|
+
everyMessages,
|
|
208
|
+
everyUserMessages,
|
|
209
|
+
messageCount,
|
|
210
|
+
userCount,
|
|
211
|
+
markerMessageCount,
|
|
212
|
+
markerUserCount,
|
|
213
|
+
deltaMessages,
|
|
214
|
+
deltaUserMessages,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function findNewestJsonlFile(dir) {
|
|
219
|
+
if (!dir) return null;
|
|
220
|
+
const files = [];
|
|
221
|
+
const stack = [dir];
|
|
222
|
+
while (stack.length) {
|
|
223
|
+
const current = stack.pop();
|
|
224
|
+
let entries = [];
|
|
225
|
+
try {
|
|
226
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
227
|
+
} catch {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
for (const entry of entries) {
|
|
231
|
+
const filePath = path.join(current, entry.name);
|
|
232
|
+
if (entry.isDirectory()) {
|
|
233
|
+
stack.push(filePath);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
|
237
|
+
try {
|
|
238
|
+
files.push({ filePath, mtimeMs: fs.statSync(filePath).mtimeMs });
|
|
239
|
+
} catch {}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs || a.filePath.localeCompare(b.filePath));
|
|
243
|
+
return files[0]?.filePath || null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function isPathInside(childPath, parentPath) {
|
|
247
|
+
const relative = path.relative(parentPath, childPath);
|
|
248
|
+
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function validateCheckpointTranscriptPath(filePath, opts = {}) {
|
|
252
|
+
if (!filePath) return { ok: false, status: 'missing_file_path' };
|
|
253
|
+
if (path.extname(filePath) !== '.jsonl') {
|
|
254
|
+
return { ok: false, status: 'invalid_transcript_path', reason: 'not_jsonl', filePath };
|
|
255
|
+
}
|
|
256
|
+
const sessionsDir = opts.sessionsDir || codex.defaultPaths(opts).sessionsDir;
|
|
257
|
+
let realFile;
|
|
258
|
+
let realSessionsDir;
|
|
259
|
+
let stat;
|
|
260
|
+
try {
|
|
261
|
+
realFile = fs.realpathSync(filePath);
|
|
262
|
+
realSessionsDir = fs.realpathSync(sessionsDir);
|
|
263
|
+
stat = fs.statSync(realFile);
|
|
264
|
+
} catch {
|
|
265
|
+
return { ok: false, status: 'not_found', filePath, sessionsDir };
|
|
266
|
+
}
|
|
267
|
+
if (!isPathInside(realFile, realSessionsDir)) {
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
status: 'invalid_transcript_path',
|
|
271
|
+
reason: 'outside_sessions_dir',
|
|
272
|
+
filePath: realFile,
|
|
273
|
+
sessionsDir: realSessionsDir,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (!stat.isFile()) {
|
|
277
|
+
return { ok: false, status: 'invalid_transcript_path', reason: 'not_regular_file', filePath: realFile };
|
|
278
|
+
}
|
|
279
|
+
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
|
280
|
+
return { ok: false, status: 'invalid_transcript_path', reason: 'owner_mismatch', filePath: realFile };
|
|
281
|
+
}
|
|
282
|
+
return { ok: true, filePath: realFile, sessionsDir: realSessionsDir, stat };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function checkpointHeartbeatInput(flags = {}, hookInput = {}) {
|
|
286
|
+
return {
|
|
287
|
+
sessionId: flags['session-id'] || hookInput.session_id || undefined,
|
|
288
|
+
filePath: flags['file-path'] || hookInput.transcript_path || undefined,
|
|
289
|
+
hookEventName: hookInput.hook_event_name || flags['hook-event-name'] || undefined,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function claimPayload(nowMs, ttlMs) {
|
|
294
|
+
return {
|
|
295
|
+
pid: process.pid,
|
|
296
|
+
createdAt: isoAt(nowMs),
|
|
297
|
+
expiresAt: isoAt(nowMs + ttlMs),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function acquireHeartbeatClaim(dir, sessionId, nowMs = Date.now(), ttlMs = 60000) {
|
|
302
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
303
|
+
const filePath = codex.markerPath(dir, sessionId);
|
|
304
|
+
const payload = claimPayload(nowMs, ttlMs);
|
|
305
|
+
const content = `${JSON.stringify(payload)}\n`;
|
|
306
|
+
try {
|
|
307
|
+
const fd = fs.openSync(filePath, 'wx');
|
|
308
|
+
try {
|
|
309
|
+
fs.writeFileSync(fd, content, 'utf8');
|
|
310
|
+
} finally {
|
|
311
|
+
fs.closeSync(fd);
|
|
312
|
+
}
|
|
313
|
+
return { acquired: true, filePath, payload };
|
|
314
|
+
} catch (err) {
|
|
315
|
+
if (err && err.code !== 'EEXIST') {
|
|
316
|
+
return { acquired: false, filePath, reason: err.message || 'claim_failed' };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const existing = readJsonFile(filePath);
|
|
321
|
+
const expiresMs = existing?.expiresAt ? Date.parse(existing.expiresAt) : NaN;
|
|
322
|
+
if (Number.isFinite(expiresMs) && expiresMs > nowMs) {
|
|
323
|
+
return { acquired: false, filePath, reason: 'claim_active', existing };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
fs.unlinkSync(filePath);
|
|
328
|
+
} catch {}
|
|
329
|
+
try {
|
|
330
|
+
const fd = fs.openSync(filePath, 'wx');
|
|
331
|
+
try {
|
|
332
|
+
fs.writeFileSync(fd, content, 'utf8');
|
|
333
|
+
} finally {
|
|
334
|
+
fs.closeSync(fd);
|
|
335
|
+
}
|
|
336
|
+
return { acquired: true, filePath, payload, staleReplaced: true };
|
|
337
|
+
} catch (err) {
|
|
338
|
+
return { acquired: false, filePath, reason: err?.message || 'claim_race' };
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function releaseHeartbeatClaim(claim) {
|
|
343
|
+
if (!claim?.acquired || !claim.filePath) return;
|
|
344
|
+
const existing = readJsonFile(claim.filePath);
|
|
345
|
+
if (existing?.pid !== claim.payload?.pid || existing?.createdAt !== claim.payload?.createdAt) return;
|
|
346
|
+
try {
|
|
347
|
+
fs.unlinkSync(claim.filePath);
|
|
348
|
+
} catch {}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function spoolCheckpointProposal(dir, prepared = {}, meta = {}) {
|
|
352
|
+
const sessionId = prepared.view?.sessionId || meta.sessionId;
|
|
353
|
+
if (!dir || !sessionId || !prepared.prompt) return null;
|
|
354
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
355
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
356
|
+
const filePath = path.join(dir, `${codex.safeMarkerKey(sessionId)}-${stamp}-${process.pid}.json`);
|
|
357
|
+
const payload = {
|
|
358
|
+
kind: 'codex_active_checkpoint_pending_v1',
|
|
359
|
+
createdAt: new Date().toISOString(),
|
|
360
|
+
sessionId,
|
|
361
|
+
source: meta.source || 'codex-heartbeat',
|
|
362
|
+
hookEventName: meta.hookEventName || null,
|
|
363
|
+
triggerKind: meta.triggerKind || 'time_window_message_delta',
|
|
364
|
+
guards: {
|
|
365
|
+
checkpointIsProcessMaterial: true,
|
|
366
|
+
stdoutPromptExcluded: true,
|
|
367
|
+
additionalContextExcluded: true,
|
|
368
|
+
dbWriteExcluded: true,
|
|
369
|
+
activeMemoryCommitExcluded: true,
|
|
370
|
+
rawHookPromptExcluded: true,
|
|
371
|
+
},
|
|
372
|
+
threshold: prepared.checkpointInput?.threshold || null,
|
|
373
|
+
coverage: prepared.checkpointInput?.coverage || null,
|
|
374
|
+
prompt: prepared.prompt,
|
|
375
|
+
};
|
|
376
|
+
fs.writeFileSync(filePath, `${JSON.stringify(payload)}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
377
|
+
return { filePath, createdAt: payload.createdAt };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function shellQuote(value) {
|
|
381
|
+
return `'${String(value || '').replace(/'/g, `'\\''`)}'`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function defaultHooksPath(opts = {}) {
|
|
385
|
+
const codexHome = opts.codexHome || path.join(os.homedir(), '.codex');
|
|
386
|
+
return path.join(codexHome, 'hooks.json');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function checkpointHeartbeatCommand(flags = {}, opts = {}) {
|
|
390
|
+
const parts = [
|
|
391
|
+
process.execPath,
|
|
392
|
+
opts.scriptPath || path.resolve(__dirname, 'codex-recovery.js'),
|
|
393
|
+
'checkpoint-heartbeat',
|
|
394
|
+
'--hook-stdin',
|
|
395
|
+
];
|
|
396
|
+
const pushValue = (flag, value) => {
|
|
397
|
+
if (value !== undefined && value !== null && value !== '' && value !== true) {
|
|
398
|
+
parts.push(flag, String(value));
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
pushValue('--scope-key', flags['scope-key'] || flags['active-scope-key']);
|
|
402
|
+
pushValue('--active-scope-key', flags['active-scope-key']);
|
|
403
|
+
pushValue('--active-scope-path', flags['active-scope-path']);
|
|
404
|
+
pushValue('--config', flags.config);
|
|
405
|
+
pushValue('--checkpoint-check-interval-ms', flags['checkpoint-check-interval-ms']);
|
|
406
|
+
pushValue('--checkpoint-check-interval-minutes', flags['checkpoint-check-interval-minutes']);
|
|
407
|
+
pushValue('--checkpoint-every-messages', flags['checkpoint-every-messages']);
|
|
408
|
+
pushValue('--checkpoint-every-user-messages', flags['checkpoint-every-user-messages']);
|
|
409
|
+
pushValue('--checkpoint-quiet-ms', flags['checkpoint-quiet-ms']);
|
|
410
|
+
pushValue('--checkpoint-scheduler-dir', flags['checkpoint-scheduler-dir']);
|
|
411
|
+
pushValue('--checkpoint-claim-dir', flags['checkpoint-claim-dir']);
|
|
412
|
+
pushValue('--checkpoint-spool-dir', flags['checkpoint-spool-dir']);
|
|
413
|
+
pushValue('--checkpoint-claim-ttl-ms', flags['checkpoint-claim-ttl-ms']);
|
|
414
|
+
pushValue('--agent-id', flags['agent-id']);
|
|
415
|
+
pushValue('--source', flags.source);
|
|
416
|
+
pushValue('--session-key', flags['session-key']);
|
|
417
|
+
pushValue('--workspace', flags.workspace || flags['workspace-path']);
|
|
418
|
+
pushValue('--project', flags.project || flags['project-key']);
|
|
419
|
+
pushValue('--repo-path', flags['repo-path']);
|
|
420
|
+
pushValue('--codex-home', flags['codex-home']);
|
|
421
|
+
return parts.map(shellQuote).join(' ');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function readHooksConfig(filePath) {
|
|
425
|
+
try {
|
|
426
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
427
|
+
if (!raw.trim()) return { hooks: {} };
|
|
428
|
+
const parsed = JSON.parse(raw);
|
|
429
|
+
if (!parsed.hooks || typeof parsed.hooks !== 'object') parsed.hooks = {};
|
|
430
|
+
return parsed;
|
|
431
|
+
} catch (err) {
|
|
432
|
+
if (err && err.code === 'ENOENT') return { hooks: {} };
|
|
433
|
+
throw err;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function mergeCheckpointHeartbeatHook(existing = { hooks: {} }, flags = {}, opts = {}) {
|
|
438
|
+
const out = JSON.parse(JSON.stringify(existing || { hooks: {} }));
|
|
439
|
+
if (!out.hooks || typeof out.hooks !== 'object') out.hooks = {};
|
|
440
|
+
const event = 'UserPromptSubmit';
|
|
441
|
+
if (!Array.isArray(out.hooks[event])) out.hooks[event] = [];
|
|
442
|
+
const group = {
|
|
443
|
+
hooks: [{
|
|
444
|
+
type: 'command',
|
|
445
|
+
command: checkpointHeartbeatCommand(flags, opts),
|
|
446
|
+
}],
|
|
447
|
+
};
|
|
448
|
+
const existingGroup = out.hooks[event].find((candidate) => {
|
|
449
|
+
return Array.isArray(candidate?.hooks)
|
|
450
|
+
&& candidate.hooks.some((hook) => String(hook?.command || '').includes('checkpoint-heartbeat'));
|
|
451
|
+
});
|
|
452
|
+
if (existingGroup) {
|
|
453
|
+
delete existingGroup.matcher;
|
|
454
|
+
existingGroup.hooks = group.hooks;
|
|
455
|
+
} else {
|
|
456
|
+
out.hooks[event].push(group);
|
|
457
|
+
}
|
|
458
|
+
return out;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function inspectCheckpointHeartbeatHook(opts = {}) {
|
|
462
|
+
const hooksPath = opts.hooksPath || defaultHooksPath(opts);
|
|
463
|
+
let parsed;
|
|
464
|
+
try {
|
|
465
|
+
parsed = readHooksConfig(hooksPath);
|
|
466
|
+
} catch (err) {
|
|
467
|
+
return {
|
|
468
|
+
status: 'fail',
|
|
469
|
+
hooksPath,
|
|
470
|
+
installed: false,
|
|
471
|
+
detail: err && err.message ? err.message : String(err),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
const groups = Array.isArray(parsed.hooks?.UserPromptSubmit) ? parsed.hooks.UserPromptSubmit : [];
|
|
475
|
+
const installed = groups.some((group) => {
|
|
476
|
+
return Array.isArray(group?.hooks)
|
|
477
|
+
&& group.hooks.some((hook) => String(hook?.command || '').includes('checkpoint-heartbeat'));
|
|
478
|
+
});
|
|
479
|
+
return {
|
|
480
|
+
status: installed ? 'ok' : 'warn',
|
|
481
|
+
hooksPath,
|
|
482
|
+
installed,
|
|
483
|
+
detail: installed
|
|
484
|
+
? 'UserPromptSubmit checkpoint heartbeat hook is installed.'
|
|
485
|
+
: 'UserPromptSubmit checkpoint heartbeat hook is not installed.',
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
module.exports = {
|
|
490
|
+
acquireHeartbeatClaim,
|
|
491
|
+
checkpointCheckIntervalMs,
|
|
492
|
+
checkpointClaimDir,
|
|
493
|
+
checkpointClaimTtlMs,
|
|
494
|
+
checkpointDueFromMarker,
|
|
495
|
+
checkpointEveryMessages,
|
|
496
|
+
checkpointEveryUserMessages,
|
|
497
|
+
checkpointHeartbeatCommand,
|
|
498
|
+
checkpointHeartbeatInput,
|
|
499
|
+
checkpointMarkerDir,
|
|
500
|
+
checkpointProposalWindow,
|
|
501
|
+
checkpointQuietMs,
|
|
502
|
+
checkpointSchedulerDir,
|
|
503
|
+
checkpointSpoolDir,
|
|
504
|
+
defaultHooksPath,
|
|
505
|
+
findNewestJsonlFile,
|
|
506
|
+
inspectCheckpointHeartbeatHook,
|
|
507
|
+
isoAt,
|
|
508
|
+
loadRuntimeConfig,
|
|
509
|
+
mergeCheckpointHeartbeatHook,
|
|
510
|
+
readCheckpointMarker,
|
|
511
|
+
readHooksConfig,
|
|
512
|
+
readSchedulerMarker,
|
|
513
|
+
releaseHeartbeatClaim,
|
|
514
|
+
spoolCheckpointProposal,
|
|
515
|
+
validateCheckpointTranscriptPath,
|
|
516
|
+
viewMessageCount,
|
|
517
|
+
viewUserCount,
|
|
518
|
+
writeCheckpointMarker,
|
|
519
|
+
writeSchedulerMarker,
|
|
520
|
+
};
|
|
@@ -7,6 +7,36 @@ const path = require('path');
|
|
|
7
7
|
|
|
8
8
|
const { createAquiferFromConfig } = require('../consumers/shared/factory');
|
|
9
9
|
const codex = require('../consumers/codex');
|
|
10
|
+
const {
|
|
11
|
+
cmdCheckpointHeartbeat,
|
|
12
|
+
cmdCheckpointHeartbeatHook,
|
|
13
|
+
cmdCheckpointPrompt,
|
|
14
|
+
cmdCheckpointTick,
|
|
15
|
+
} = require('./codex-checkpoint-commands');
|
|
16
|
+
const {
|
|
17
|
+
acquireHeartbeatClaim,
|
|
18
|
+
checkpointCheckIntervalMs,
|
|
19
|
+
checkpointClaimDir,
|
|
20
|
+
checkpointClaimTtlMs,
|
|
21
|
+
checkpointDueFromMarker,
|
|
22
|
+
checkpointEveryMessages,
|
|
23
|
+
checkpointEveryUserMessages,
|
|
24
|
+
checkpointHeartbeatCommand,
|
|
25
|
+
checkpointMarkerDir,
|
|
26
|
+
checkpointQuietMs,
|
|
27
|
+
checkpointSchedulerDir,
|
|
28
|
+
checkpointSpoolDir,
|
|
29
|
+
defaultHooksPath,
|
|
30
|
+
findNewestJsonlFile,
|
|
31
|
+
inspectCheckpointHeartbeatHook,
|
|
32
|
+
loadRuntimeConfig,
|
|
33
|
+
mergeCheckpointHeartbeatHook,
|
|
34
|
+
readCheckpointMarker,
|
|
35
|
+
readSchedulerMarker,
|
|
36
|
+
releaseHeartbeatClaim,
|
|
37
|
+
writeCheckpointMarker,
|
|
38
|
+
writeSchedulerMarker,
|
|
39
|
+
} = require('./codex-checkpoint-runtime');
|
|
10
40
|
const DB_ENV_KEYS = new Set(['DATABASE_URL', 'AQUIFER_DB_URL', 'AQUIFER_SCHEMA', 'AQUIFER_TENANT_ID']);
|
|
11
41
|
|
|
12
42
|
const VALUE_FLAGS = new Set([
|
|
@@ -16,7 +46,23 @@ const VALUE_FLAGS = new Set([
|
|
|
16
46
|
'except-session-id',
|
|
17
47
|
'file-path',
|
|
18
48
|
'finalizer-model',
|
|
49
|
+
'checkpoint-every-messages',
|
|
50
|
+
'checkpoint-every-user-messages',
|
|
51
|
+
'checkpoint-check-interval-ms',
|
|
52
|
+
'checkpoint-check-interval-minutes',
|
|
53
|
+
'checkpoint-claim-ttl-ms',
|
|
54
|
+
'checkpoint-claim-dir',
|
|
55
|
+
'checkpoint-marker-dir',
|
|
56
|
+
'checkpoint-scheduler-dir',
|
|
57
|
+
'checkpoint-spool-dir',
|
|
58
|
+
'checkpoint-quiet-ms',
|
|
59
|
+
'hook-event-name',
|
|
60
|
+
'hooks-path',
|
|
19
61
|
'idle-ms',
|
|
62
|
+
'max-checkpoint-bytes',
|
|
63
|
+
'max-checkpoint-chars',
|
|
64
|
+
'max-checkpoint-messages',
|
|
65
|
+
'max-checkpoint-prompt-tokens',
|
|
20
66
|
'max-candidates',
|
|
21
67
|
'max-recovery-bytes',
|
|
22
68
|
'max-recovery-chars',
|
|
@@ -27,6 +73,8 @@ const VALUE_FLAGS = new Set([
|
|
|
27
73
|
'reason',
|
|
28
74
|
'scope-kind',
|
|
29
75
|
'scope-key',
|
|
76
|
+
'active-scope-key',
|
|
77
|
+
'active-scope-path',
|
|
30
78
|
'session-id',
|
|
31
79
|
'session-key',
|
|
32
80
|
'sessions-dir',
|
|
@@ -118,6 +166,7 @@ function buildRecoveryOptions(flags = {}, env = process.env) {
|
|
|
118
166
|
project: flags.project || flags['project-key'] || envDefault(env, 'CODEX_AQUIFER_PROJECT', 'CODEX_PROJECT') || undefined,
|
|
119
167
|
repoPath: flags['repo-path'] || envDefault(env, 'CODEX_AQUIFER_REPO_PATH', 'CODEX_REPO_PATH') || undefined,
|
|
120
168
|
codexHome: flags['codex-home'] || envDefault(env, 'CODEX_HOME') || undefined,
|
|
169
|
+
hooksPath: flags['hooks-path'] || undefined,
|
|
121
170
|
stateDir: flags['state-dir'] || undefined,
|
|
122
171
|
sessionsDir: flags['sessions-dir'] || undefined,
|
|
123
172
|
maxRecoveryCandidates: parseIntFlag(flags['max-candidates'], 1),
|
|
@@ -288,6 +337,7 @@ function compactDoctorOptions(opts = {}) {
|
|
|
288
337
|
project: opts.project || null,
|
|
289
338
|
repoPath: opts.repoPath || null,
|
|
290
339
|
codexHome: opts.codexHome || null,
|
|
340
|
+
hooksPath: opts.hooksPath || null,
|
|
291
341
|
sessionsDir: opts.sessionsDir || null,
|
|
292
342
|
stateDir: opts.stateDir || null,
|
|
293
343
|
excludeNewest: opts.excludeNewest !== false,
|
|
@@ -319,6 +369,15 @@ async function buildDoctorReport(aquifer, opts = {}, env = process.env) {
|
|
|
319
369
|
addDoctorCheck(checks, 'current_transcript_guard', 'ok', 'Newest transcript exclusion is enabled.');
|
|
320
370
|
}
|
|
321
371
|
|
|
372
|
+
const heartbeatHook = inspectCheckpointHeartbeatHook(opts);
|
|
373
|
+
addDoctorCheck(
|
|
374
|
+
checks,
|
|
375
|
+
'checkpoint_heartbeat_hook',
|
|
376
|
+
heartbeatHook.status,
|
|
377
|
+
heartbeatHook.detail,
|
|
378
|
+
{ hooksPath: heartbeatHook.hooksPath, installed: heartbeatHook.installed },
|
|
379
|
+
);
|
|
380
|
+
|
|
322
381
|
let candidates = [];
|
|
323
382
|
try {
|
|
324
383
|
candidates = await listDbEligibleCandidates(aquifer, {
|
|
@@ -602,6 +661,10 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
602
661
|
node scripts/codex-recovery.js hook-context [options]
|
|
603
662
|
node scripts/codex-recovery.js preview [options]
|
|
604
663
|
node scripts/codex-recovery.js prompt --session-id ID [options]
|
|
664
|
+
node scripts/codex-recovery.js checkpoint-prompt --file-path FILE --scope-key KEY [options]
|
|
665
|
+
node scripts/codex-recovery.js checkpoint-tick --scope-key KEY [--file-path FILE|--sessions-dir DIR] [options]
|
|
666
|
+
node scripts/codex-recovery.js checkpoint-heartbeat --hook-stdin --scope-key KEY [options]
|
|
667
|
+
node scripts/codex-recovery.js checkpoint-heartbeat-hook --scope-key KEY [--hooks-path FILE] [--apply]
|
|
605
668
|
node scripts/codex-recovery.js finalize --session-id ID --summary-stdin [options]
|
|
606
669
|
node scripts/codex-recovery.js decision --session-id ID --verdict declined|deferred [options]
|
|
607
670
|
node scripts/codex-recovery.js decision --all --verdict declined|deferred [options]
|
|
@@ -620,6 +683,16 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
620
683
|
return;
|
|
621
684
|
}
|
|
622
685
|
|
|
686
|
+
if (command === 'checkpoint-heartbeat') {
|
|
687
|
+
await cmdCheckpointHeartbeat(null, args.flags, opts);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (command === 'checkpoint-heartbeat-hook') {
|
|
692
|
+
await cmdCheckpointHeartbeatHook(args.flags, opts);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
623
696
|
await withAquifer(async (aquifer) => {
|
|
624
697
|
switch (command) {
|
|
625
698
|
case 'preview':
|
|
@@ -631,6 +704,12 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
631
704
|
case 'prompt':
|
|
632
705
|
await cmdPrompt(aquifer, args.flags, opts);
|
|
633
706
|
break;
|
|
707
|
+
case 'checkpoint-prompt':
|
|
708
|
+
await cmdCheckpointPrompt(aquifer, args.flags, opts);
|
|
709
|
+
break;
|
|
710
|
+
case 'checkpoint-tick':
|
|
711
|
+
await cmdCheckpointTick(aquifer, args.flags, opts);
|
|
712
|
+
break;
|
|
634
713
|
case 'finalize':
|
|
635
714
|
await cmdFinalize(aquifer, args.flags, opts);
|
|
636
715
|
break;
|
|
@@ -651,13 +730,39 @@ module.exports = {
|
|
|
651
730
|
cmdDoctorInitFailure,
|
|
652
731
|
cmdFinalize,
|
|
653
732
|
cmdHookContext,
|
|
733
|
+
cmdCheckpointHeartbeat,
|
|
734
|
+
cmdCheckpointHeartbeatHook,
|
|
735
|
+
cmdCheckpointPrompt,
|
|
736
|
+
cmdCheckpointTick,
|
|
654
737
|
cmdPrompt,
|
|
738
|
+
acquireHeartbeatClaim,
|
|
739
|
+
checkpointDueFromMarker,
|
|
740
|
+
checkpointHeartbeatCommand,
|
|
741
|
+
checkpointCheckIntervalMs,
|
|
742
|
+
checkpointEveryMessages,
|
|
743
|
+
checkpointEveryUserMessages,
|
|
744
|
+
checkpointQuietMs,
|
|
745
|
+
checkpointClaimDir,
|
|
746
|
+
checkpointClaimTtlMs,
|
|
747
|
+
checkpointMarkerDir,
|
|
748
|
+
checkpointSchedulerDir,
|
|
749
|
+
checkpointSpoolDir,
|
|
750
|
+
defaultHooksPath,
|
|
751
|
+
findNewestJsonlFile,
|
|
752
|
+
inspectCheckpointHeartbeatHook,
|
|
753
|
+
loadRuntimeConfig,
|
|
655
754
|
loadCodexEnv,
|
|
656
755
|
main,
|
|
756
|
+
mergeCheckpointHeartbeatHook,
|
|
657
757
|
parseArgs,
|
|
758
|
+
readCheckpointMarker,
|
|
759
|
+
readSchedulerMarker,
|
|
760
|
+
releaseHeartbeatClaim,
|
|
658
761
|
renderFinalizeCommand,
|
|
659
762
|
renderHookContext,
|
|
660
763
|
selectCandidate,
|
|
764
|
+
writeCheckpointMarker,
|
|
765
|
+
writeSchedulerMarker,
|
|
661
766
|
};
|
|
662
767
|
|
|
663
768
|
if (require.main === module) {
|