@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.
- package/.env.example +8 -0
- package/README.md +66 -0
- package/aquifer.config.example.json +19 -0
- package/consumers/cli.js +192 -12
- 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,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
|
+
};
|