ruvnet-brain 4.6.4 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +6 -50
  3. package/config/practical-rule-catalog.json +22 -22
  4. package/console/app.js +1 -1
  5. package/console/architecture.html +2 -2
  6. package/package.json +1 -2
  7. package/plugin/.claude-plugin/plugin.json +1 -1
  8. package/plugin/.codex-plugin/plugin.json +1 -1
  9. package/plugin/commands/checkpoint.md +22 -40
  10. package/plugin/commands/rnb-brief.md +18 -15
  11. package/plugin/hooks/codex-hooks.json +1 -28
  12. package/plugin/hooks/hook-contracts.json +6 -127
  13. package/plugin/hooks/hooks.json +1 -40
  14. package/plugin/mcp/managed-cli-interface.mjs +0 -56
  15. package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
  16. package/plugin/scripts/agentdb-recall.mjs +8 -14
  17. package/plugin/scripts/brain-footprint.mjs +1 -1
  18. package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
  19. package/plugin/scripts/completion-claim-evidence.mjs +1 -1
  20. package/plugin/scripts/continuity-events.mjs +90 -2
  21. package/plugin/scripts/continuity-hook-policy.mjs +5 -14
  22. package/plugin/scripts/decision-gate.mjs +2 -2
  23. package/plugin/scripts/decision-record.mjs +11 -40
  24. package/plugin/scripts/hook-failures.mjs +16 -0
  25. package/plugin/scripts/hook-shim.mjs +1 -2
  26. package/plugin/scripts/learn-capture.mjs +60 -42
  27. package/plugin/scripts/learning-store.mjs +70 -60
  28. package/plugin/scripts/node-sqlite.mjs +1 -1
  29. package/plugin/scripts/ruflo-bin.mjs +71 -1
  30. package/plugin/scripts/ruflo-memory-hooks.mjs +132 -0
  31. package/plugin/scripts/runtime-preferences.mjs +1 -1
  32. package/plugin/scripts/session-snapshot-hook.mjs +85 -341
  33. package/plugin/scripts/session-start-core.mjs +2 -12
  34. package/plugin/scripts/signal-watch.mjs +1 -1
  35. package/plugin/scripts/substitution-wall.mjs +222 -0
  36. package/plugin/scripts/turn-outcome-capture.mjs +68 -74
  37. package/scripts/codex-host-execution-proof.mjs +1 -1
  38. package/scripts/console-engine.mjs +0 -32
  39. package/scripts/health-repair.mjs +1 -81
  40. package/scripts/hook-qualify-core.mjs +2 -2
  41. package/scripts/model-managed-workflow-service.mjs +9 -10
  42. package/scripts/model-routing-controller.mjs +9 -8
  43. package/scripts/no-silent-substitution.mjs +65 -1
  44. package/scripts/onboarding-console.mjs +8 -23
  45. package/scripts/product-integrity-contract.mjs +1 -1
  46. package/scripts/release-qualification-contract.mjs +9 -27
  47. package/scripts/remedy-registry.mjs +0 -18
  48. package/scripts/wired-check.mjs +1 -5
  49. package/plugin/scripts/continuity-brief.mjs +0 -351
  50. package/plugin/scripts/continuity-journal.mjs +0 -553
  51. package/plugin/scripts/learn-flush.mjs +0 -96
  52. package/plugin/scripts/learning-observation.mjs +0 -51
  53. package/plugin/scripts/learning-queue.mjs +0 -135
  54. package/plugin/scripts/learning-worker-supervisor.mjs +0 -79
  55. package/plugin/scripts/project-capture-queue.mjs +0 -357
  56. package/plugin/scripts/project-memory-enrollment.mjs +0 -203
  57. package/plugin/scripts/project-progression-checkpoint.mjs +0 -145
  58. package/plugin/scripts/project-progression-contract.mjs +0 -496
  59. package/plugin/scripts/project-progression-hook.mjs +0 -233
  60. package/plugin/scripts/project-progression-outbox.mjs +0 -225
  61. package/plugin/scripts/project-progression-producer.mjs +0 -309
  62. package/plugin/scripts/project-progression-reader.mjs +0 -304
  63. package/plugin/scripts/project-progression-session-start.mjs +0 -320
  64. package/plugin/scripts/project-progression-sources.mjs +0 -246
  65. package/plugin/scripts/project-progression-store.mjs +0 -723
  66. package/plugin/scripts/project-progression-suspension.mjs +0 -60
  67. package/plugin/scripts/project-transition-hook.mjs +0 -267
  68. package/plugin/scripts/session-snapshot-contract.mjs +0 -105
  69. package/plugin/scripts/turn-transport-journal.mjs +0 -108
  70. package/scripts/qa/progression-validation-benchmark.mjs +0 -66
  71. package/scripts/session-snapshot-contract.mjs +0 -1
@@ -1,309 +0,0 @@
1
- /**
2
- * project-progression-producer.mjs — the thing that was missing.
3
- *
4
- * `captureProjectTransition` has always been able to store a complete project snapshot. Nothing ever
5
- * BUILT one: it was only reachable when a host payload already carried a `projectProgression`
6
- * extension, and no host emits one. So the North Star's "append-only full project snapshots" had a
7
- * verified writer, a verified reader, and no producer — which is why the canonical store held zero
8
- * rows in the project-progression namespace while 378 hand-written `project-state-current` notes
9
- * accumulated beside it. This module is the producer.
10
- *
11
- * EVERY FIELD CARRIES PROVENANCE, and the order of authority is fixed:
12
- *
13
- * ledger the user's own work ledger — they wrote it, so it wins
14
- * owner-note the newest `project-state-current%` note — the owner's own narrative
15
- * prior-head the previous snapshot: sequence, parent links, carried acceptance
16
- * git branch / HEAD / the three tree digests — mechanical, never inferred
17
- * transcript-derived a bounded single-sentence reduction, used ONLY where nothing above spoke
18
- *
19
- * Anything derived from the transcript or from repository contents is marked
20
- * `authoritative: false`. A later session reading this snapshot can then tell the difference between
21
- * "the user said this" and "we guessed this from the tail of a conversation" — a distinction that
22
- * disappears the moment provenance is dropped, and whose disappearance is how an inferred goal ends
23
- * up being obeyed as an instruction.
24
- *
25
- * THE PRIVACY BOUNDARY. The user's prompt and the assistant's reply are never persisted. The
26
- * transcript contributes a REFERENCE (path, byte span, record count, sha256 of the exact bytes read)
27
- * and at most two derived sentences of DERIVED_TEXT_LIMIT each. redactProgression then runs over the
28
- * whole snapshot inside createProgressionSnapshot, so secret-shaped material cannot survive even
29
- * within those bounds.
30
- */
31
- import crypto from 'node:crypto';
32
- import { enrichStateWithObservation } from './project-progression-hook.mjs';
33
- import fs from 'node:fs';
34
- import path from 'node:path';
35
- import { digestCanonical, fieldAuthorityAllows, redactProgression, restoreProjectProgression } from './project-progression-contract.mjs';
36
- import { readOwnerNote, readSourceIdentity, readTranscriptReference, readWorkLedger } from './project-progression-sources.mjs';
37
- import { withProgressionReader } from './project-progression-reader.mjs';
38
- import os from 'node:os';
39
- import { resolveTurnDb } from './turn-outcome-capture.mjs';
40
- import { privateProgressionState, maskExcludedPaths } from './turn-capture-privacy.mjs';
41
-
42
- const PROGRESSION_NAMESPACE = 'project-progression';
43
- const OWNER_NOTE_NAMESPACES = Object.freeze(['default']);
44
-
45
- /**
46
- * `none` is not a filler value, it is the honest answer to "where did this come from?" when the
47
- * answer is "nowhere — no source had one". Labelling an empty goal `git` (as the first version did,
48
- * simply because git was the last branch in the chain) claims a provenance the field does not have,
49
- * and provenance that can be wrong is worse than no provenance at all.
50
- */
51
- export const PROVENANCE_SOURCES = Object.freeze([
52
- 'ledger', 'owner-note', 'prior-head', 'git', 'transcript-derived', 'model-checkpoint', 'none',
53
- ]);
54
-
55
- const AUTHORITATIVE = Object.freeze({
56
- ledger: true,
57
- 'owner-note': true,
58
- 'prior-head': true,
59
- git: true,
60
- 'transcript-derived': false,
61
- 'model-checkpoint': false,
62
- none: false,
63
- });
64
-
65
- function marker(source) {
66
- if (!PROVENANCE_SOURCES.includes(source)) throw new Error(`unknown provenance source: ${source}`);
67
- return { source, authoritative: AUTHORITATIVE[source] };
68
- }
69
-
70
- /**
71
- * Read the owner's `project-state-current%` notes from the canonical store WITHOUT a CLI spawn.
72
- * The owner's convention writes them into the project's own namespace and into `default`; both are
73
- * checked, because which one a given session used depends on whether `-n` was passed.
74
- */
75
- function ownerNoteRows(canonicalAgentDbPath, projectNamespace, options = {}) {
76
- const namespaces = [...new Set([projectNamespace, ...OWNER_NOTE_NAMESPACES].filter(Boolean))];
77
- const result = withProgressionReader(canonicalAgentDbPath, (reader) => {
78
- const rows = [];
79
- for (const namespace of namespaces) {
80
- for (const key of reader.listKeys(namespace)) {
81
- if (!key.startsWith('project-state-current')) continue;
82
- const content = reader.readContent(namespace, key);
83
- if (typeof content === 'string') rows.push({ key, namespace, content });
84
- }
85
- }
86
- return rows;
87
- }, options);
88
- return result.ok ? result.value : [];
89
- }
90
-
91
- /** The committed heads, computed the same way a restore computes them. Empty store → no heads. */
92
- function committedHeads(canonicalAgentDbPath, projectIdentity, options = {}) {
93
- const result = withProgressionReader(canonicalAgentDbPath, (reader) => {
94
- const snapshots = [];
95
- for (const key of reader.listKeys(PROGRESSION_NAMESPACE)) {
96
- const content = reader.readContent(PROGRESSION_NAMESPACE, key);
97
- if (typeof content !== 'string') continue;
98
- try { snapshots.push(JSON.parse(content)); } catch { /* a malformed row is the restore's problem */ }
99
- }
100
- return snapshots;
101
- }, options);
102
- if (!result.ok) {
103
- if (fs.existsSync(canonicalAgentDbPath)) throw new Error(`prior progression read unavailable: ${result.reason}`);
104
- return { heads: [], state: null, readPath: `unavailable (${result.reason})` };
105
- }
106
- const restored = restoreProjectProgression(result.value, { expectedProjectIdentity: projectIdentity });
107
- if (result.value.length && !restored.ok) throw new Error('prior progression has no verifiable coherent state');
108
- const byKey = new Map(result.value.map((snapshot) => [snapshot?.eventKey, snapshot]));
109
- return { heads: restored.heads.map((key) => byKey.get(key)).filter(Boolean), state: restored.state, readPath: 'node:sqlite' };
110
- }
111
-
112
- function uniqueStrings(values) {
113
- return [...new Set(values.filter((value) => typeof value === 'string' && value.trim()))];
114
- }
115
-
116
- /**
117
- * Build the `projectProgression` extension for one host payload.
118
- *
119
- * @returns {{ projectProgression: object, provenance: object, skipped?: { reason: string } }}
120
- * `skipped` is set when this capture would add nothing: a snapshot whose complete project state
121
- * and source identity are byte-identical to the previous head is a no-op, and writing it would let
122
- * three capture boundaries per session grow memory.db without bound for sessions that changed
123
- * nothing. The caller does not write when `skipped` is present.
124
- */
125
- export function buildProjectProgression({
126
- resolution,
127
- projectDir = resolution?.checkoutRoot,
128
- payload = {},
129
- host = 'claude',
130
- env = process.env,
131
- now = () => new Date().toISOString(),
132
- trigger = payload.hook_event_name,
133
- deadlineAt = Infinity,
134
- signal,
135
- } = {}) {
136
- if (!resolution || typeof resolution !== 'object') throw new TypeError('resolution must be a project store resolution');
137
- const options = { deadlineAt, signal };
138
- const checkDeadline = () => { if (signal?.aborted || Date.now() >= deadlineAt) throw new Error('restore deadline exceeded'); };
139
- checkDeadline();
140
- const source = readSourceIdentity({ checkoutRoot: resolution.checkoutRoot, kind: resolution.kind, ...options });
141
- source.identity.capturePath = fs.realpathSync.native(projectDir);
142
- const ledger = readWorkLedger({ projectId: resolution.projectIdentity.id, env });
143
- const note = readOwnerNote(() => ownerNoteRows(resolution.canonicalAgentDbPath, path.basename(resolution.projectRoot), options));
144
- const transcript = readTranscriptReference(payload.transcript_path, { host });
145
- const { heads, state: priorState } = committedHeads(resolution.canonicalAgentDbPath, resolution.projectIdentity, options);
146
- checkDeadline();
147
-
148
- const priorSequence = heads.reduce((highest, head) => Math.max(highest, head.sequence ?? 0), 0);
149
-
150
- const provenance = { ...(priorState?.provenance ?? {}) };
151
- const record = (field, sourceName) => {
152
- if (sourceName !== 'none' && !fieldAuthorityAllows(field === 'sourceIdentity' ? 'sourceIdentity' : field, sourceName)) {
153
- throw new Error(`source ${sourceName} is not authoritative for progression field ${field}`);
154
- }
155
- provenance[field] = sourceName === 'prior-head' && priorState?.provenance?.[field]
156
- ? priorState.provenance[field] : marker(sourceName);
157
- };
158
-
159
- // GOAL — the ledger's oldest open item is what the user actually committed to. A coherent prior
160
- // head carries that commitment forward; an owner note or transcript can provide context only when
161
- // no durable goal exists. Neither contextual source is an instruction.
162
- let currentGoal = ledger.open[0] ?? null;
163
- if (currentGoal) record('currentGoal', 'ledger');
164
- else if (priorState) {
165
- currentGoal = priorState.currentGoal;
166
- record('currentGoal', 'prior-head');
167
- } else if (note?.excerpt) {
168
- currentGoal = note.excerpt.split('\n')[0].slice(0, 240);
169
- record('currentGoal', 'owner-note');
170
- } else if (transcript.derivedGoal) {
171
- currentGoal = transcript.derivedGoal;
172
- record('currentGoal', 'transcript-derived');
173
- } else record('currentGoal', 'none');
174
-
175
- // NEXT ACTION — only a ledger commitment or coherent prior state may become a resumable action.
176
- // Transcript text is evidence/context, never an invented structured action.
177
- let nextAction = ledger.open[1] ?? ledger.open[0] ?? null;
178
- if (nextAction) record('nextAction', 'ledger');
179
- else if (priorState) {
180
- nextAction = priorState.nextAction;
181
- record('nextAction', 'prior-head');
182
- } else record('nextAction', 'none');
183
-
184
- const decisions = [...(priorState?.decisions ?? [])];
185
- if (ledger.objective && typeof ledger.objective.text === 'string' && ledger.objective.text) {
186
- decisions.push({ text: ledger.objective.text, state: ledger.objective.state ?? null, source: 'ledger' });
187
- record('decisions', 'ledger');
188
- } else if (note?.excerpt) {
189
- decisions.push({ text: note.excerpt, note: note.key, truncated: note.truncated, source: 'owner-note' });
190
- record('decisions', 'owner-note');
191
- } else record('decisions', 'prior-head');
192
-
193
- // A partial ledger speaks only for its own matching items; absence is not deletion.
194
- const priorPlan = priorState?.plan ?? [];
195
- const doneIds = new Set(ledger.done.map((text) => text.slice(0, 64)));
196
- const openIds = new Set(ledger.open.map((text) => text.slice(0, 64)));
197
- const ownedDone = new Set(priorPlan.filter((item) => item?.source === 'ledger' && doneIds.has(item.id)).map((item) => item.id));
198
- const plan = priorPlan.map((item) => item?.source !== 'ledger' ? item
199
- : doneIds.has(item.id) ? { ...item, status: 'done' }
200
- : openIds.has(item.id) ? { ...item, status: 'open' } : item);
201
- for (const text of ledger.open) {
202
- const id = text.slice(0, 64);
203
- if (!plan.some((item) => item?.source === 'ledger' && item.id === id)) plan.push({ id, status: 'open', source: 'ledger' });
204
- }
205
- const completed = [...(priorState?.completed ?? [])];
206
- for (const text of ledger.done) if (!completed.includes(text)) completed.push(text);
207
- const inProgress = uniqueStrings([...(priorState?.inProgress ?? []).filter((text) => !ownedDone.has(text.slice(0, 64))), ...ledger.open]);
208
- for (const field of ['plan', 'completed', 'inProgress']) {
209
- record(field, ledger.open.length || ledger.done.length ? 'ledger' : 'prior-head');
210
- if (priorState && (ledger.open.length || ledger.done.length)) provenance[field] = { source: 'prior-head', authoritative: priorState.provenance?.[field]?.authoritative ?? true, sources: ['prior-head', 'ledger'] };
211
- }
212
- record('changedFiles', 'git');
213
- record('sourceIdentity', 'git');
214
-
215
- // Reducer annotations describe the prior heads, not application state in the new snapshot.
216
- const { sourceIdentity: priorSource, journalHeads: priorHeads, ...carriedState } = priorState ?? {};
217
- let completeProjectState = {
218
- ...carriedState,
219
- currentGoal,
220
- nextAction,
221
- acceptanceContract: priorState?.acceptanceContract ?? null,
222
- activeProcess: priorState ? priorState.activeProcess : 'ProjectContinuity',
223
- activeStep: priorState ? priorState.activeStep : trigger ?? 'unknown',
224
- plan,
225
- completed,
226
- inProgress,
227
- blockers: priorState?.blockers ?? [],
228
- failures: priorState?.failures ?? [],
229
- decisions: [...new Map(decisions.map((decision) => [digestCanonical(decision), decision])).values()],
230
- // Retain previously recorded paths; the new tree's digests never invent additional filenames.
231
- changedFiles: priorState?.changedFiles ?? [],
232
- commands: priorState?.commands ?? [],
233
- proofArtifacts: priorState?.proofArtifacts ?? [],
234
- untested: priorState?.untested ?? [],
235
- resumeConflicts: priorState?.resumeConflicts ?? [],
236
- provenance,
237
- evidence: {
238
- workLedger: { file: ledger.file, present: ledger.present, open: ledger.open.length, done: ledger.done.length },
239
- ownerNote: note ? { key: note.key, excerptSha256: note.excerptSha256, truncated: note.truncated } : null,
240
- transcript: transcript.reference ?? { skipped: transcript.skipped },
241
- sourceCapture: { headStable: source.headStable, headAfter: source.headAfter ?? source.identity.head, kind: source.kind },
242
- },
243
- };
244
-
245
- const privacy = resolveTurnDb({ projectDir, brainHome: env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain') });
246
- // Pure derivation may run before adoption; the capture boundary still refuses an absent store.
247
- if (privacy.skipped && !privacy.skipped.startsWith('no project memory db')) return { skipped: { reason: privacy.skipped } };
248
- if ([...ledger.open, ...ledger.done].some((text) => maskExcludedPaths(text, privacy.contentPathExcludes, projectDir) !== text)) throw new Error('content exclusion conflicts with immutable work-ledger plan binding');
249
- completeProjectState = privateProgressionState(completeProjectState, privacy.contentPathExcludes, projectDir);
250
-
251
- // REDACT HERE, NOT ONLY AT THE STORE.
252
- //
253
- // createProgressionSnapshot already redacts, so the STORED row was always safe. What was not safe
254
- // was everything between: this function's return value is passed through a hook payload, appears
255
- // in a receipt, and is exactly the kind of object a diagnostic line prints. A secret that is
256
- // scrubbed on the way into the database but readable on the way there has not been protected, it
257
- // has been moved. Redacting at the point of derivation makes the store's own pass a no-op second
258
- // check rather than the only one (redactProgression is idempotent, so running it twice is free).
259
- const { value: redactedProgression } = redactProgression({
260
- canonicalAgentDbPath: resolution.canonicalAgentDbPath,
261
- sourceIdentity: source.identity,
262
- sequence: priorSequence + 1,
263
- occurredAt: now(),
264
- parentEventKeys: heads.map((head) => head.eventKey),
265
- dedupId: `${host}:${payload.session_id ?? 'unknown-session'}:${trigger ?? 'unknown'}:${priorSequence + 1}`,
266
- completeProjectState,
267
- });
268
- const projectProgression = redactedProgression;
269
- // Compare the same redacted observation the native writer will append, without returning it
270
- // twice. Tool input and terminal evidence must participate before the no-op decision.
271
- const observedState = redactProgression(enrichStateWithObservation(
272
- projectProgression.completeProjectState, { ...payload, hook_event_name: trigger },
273
- { contentPathExcludes: privacy.contentPathExcludes, projectDir },
274
- )).value;
275
- // A committed sequence is not an event identity: several boundaries may freeze before replay.
276
- // Bind all immutable captured fields (including the native observation and occurrence time).
277
- projectProgression.dedupId += `:${digestCanonical({
278
- ...projectProgression, completeProjectState: observedState,
279
- })}`;
280
-
281
- // RETENTION (ADR-073). Three capture boundaries per session times every session is unbounded
282
- // growth unless a capture that changes nothing writes nothing. Compare what a snapshot MEANS —
283
- // the project state and the tree it describes — while deliberately ignoring the fields that always
284
- // differ (sequence, timestamp, dedup id, the trigger that happens to be firing, and the evidence
285
- // block's own timestamps), because comparing those would make every capture look novel.
286
- const meaning = digestCanonical({
287
- state: { ...observedState, activeStep: null, evidence: null },
288
- source: projectProgression.sourceIdentity,
289
- });
290
- const priorMeaning = heads.length === 1 ? digestCanonical({
291
- state: { ...carriedState, activeStep: null, evidence: null },
292
- source: heads[0].sourceIdentity,
293
- }) : null;
294
-
295
- return {
296
- projectProgression,
297
- provenance,
298
- meaningDigest: meaning,
299
- // Compaction requires a fresh durable boundary even when application state is unchanged.
300
- ...(trigger !== 'PreCompact' && priorMeaning === meaning
301
- ? { skipped: { reason: 'no-op capture: project state and source identity are identical to the current head' } }
302
- : {}),
303
- };
304
- }
305
-
306
- /** Stable identifier for one produced snapshot, for logs and receipts. */
307
- export function producedDigest(produced) {
308
- return crypto.createHash('sha256').update(JSON.stringify(produced.projectProgression)).digest('hex');
309
- }
@@ -1,304 +0,0 @@
1
- /**
2
- * project-progression-reader.mjs — the READ-ONLY fast path for project continuity restoration.
3
- *
4
- * WHY THIS EXISTS (measured, not supposed). `restoreProgressionForSession` gives the whole restore
5
- * SESSION_CONTINUITY_DEADLINE_MS = 2500ms, and every read it performs is a separate `ruflo memory`
6
- * PROCESS. Measured on this machine (ruflo 3.41.2, Node v24.18.0, warm Transformers cache), in an
7
- * isolated temp git repo, with `plugin/scripts/project-progression-session-start.mjs` unchanged:
8
- *
9
- * 1 snapshot → restore 923 / 952 / 1001 ms → "PROJECT CONTINUITY RESTORED"
10
- * 6 snapshots → restore 2519 / 2530 / 3181 ms → "PROJECT CONTINUITY UNKNOWN" (restore-failed)
11
- *
12
- * The cost is O(N) PROCESS SPAWNS: one `memory list` plus one `memory retrieve` per snapshot, each
13
- * ~400ms of Node + CLI boot. So continuity does not fail loudly on day one — it decays silently as
14
- * the journal grows, and the seventh checkpoint is the one that switches a project's memory off.
15
- * A grep of the installed CLI (memory/memory-initializer.js, commands/memory.js, memory/memory-bridge.js)
16
- * found exactly three memory env switches — CLAUDE_FLOW_DB_PATH, CLAUDE_FLOW_DISABLE_BRIDGE,
17
- * CLAUDE_FLOW_MEMORY_PATH (plus RUFLO_MEMORY_SCAN_ON_WRITE) — and none of them skips CLI boot, so
18
- * there is no flag to reach for. The per-call floor stays ~400ms however it is invoked.
19
- *
20
- * WHAT THIS DOES. Node 24 ships `node:sqlite`, so the same rows can be read in-process, read-only,
21
- * in about a millisecond. `~/.claude/hooks/agentdb-ensure.sh` already reads this exact file directly
22
- * with `sqlite3`, so direct reads of memory.db are the established precedent, not a new liberty.
23
- *
24
- * WHAT THIS DELIBERATELY DOES NOT DO. It never writes. `ruflo memory store` remains the ONLY writer
25
- * of memory.db (upstream #2786/#3155/#3196 keep memory.db and agentdb-memory.db separate on purpose;
26
- * a second writer is exactly the two-writers-one-path footgun the memory policy exists to stop). It
27
- * is also strictly a FALLBACK-ABLE optimisation: any reason it cannot answer authoritatively —
28
- * no node:sqlite, absent file, an encrypted image (CLAUDE_FLOW_ENCRYPT_AT_REST writes an "RFE1"
29
- * blob, not a SQLite file), a WAL sidecar it cannot open read-only, a schema that is not this one —
30
- * raises ProgressionReaderUnavailable, and the caller replays the whole operation through the CLI.
31
- *
32
- * Reading directly also AVOIDS a hazard the CLI has: `getEntry` bumps access_count and then rewrites
33
- * the WHOLE database image (memory-initializer.js:3070-3077). Every "read" during a restore is
34
- * therefore a full-image write under a lock. This path takes no lock and mutates nothing.
35
- *
36
- * STRUCTURAL GUARANTEES ARE UNCHANGED, because they are the point of the restore and not negotiable:
37
- * • COMPLETE ENUMERATION — one statement returns every active row in the namespace; a count past
38
- * the caller's bound is an error, never a silent truncation (the CLI path's own failure mode).
39
- * • EXACT KEY/PAYLOAD IDENTITY — rows are fetched by (namespace, key); two active rows sharing one
40
- * key is a structural error, not a LIMIT-1 coin toss.
41
- * • DIGEST VERIFICATION — untouched: the caller still validates payloadDigest on every snapshot.
42
- */
43
- import fs from 'node:fs';
44
- import { loadNodeSqlite } from './node-sqlite.mjs';
45
-
46
- /** Installed Ruflo live-memory-row.js visibility; bind current time on every read. */
47
- const ACTIVE_ROW_SQL = "(status = 'active' OR status IS NULL) AND (expires_at IS NULL OR expires_at > ?)";
48
-
49
- /**
50
- * Every SQLite database file begins with this 16-byte header (sqlite.org/fileformat.html §1.3):
51
- * the ASCII text "SQLite format 3" followed by one NUL. Built from bytes rather than written as a
52
- * string escape so no editor, patch tool, or copy-paste can silently turn that NUL into a space —
53
- * which would make the header never match and quietly disable this whole fast path.
54
- */
55
- const SQLITE_HEADER = Buffer.concat([Buffer.from('SQLite format 3', 'latin1'), Buffer.of(0)]);
56
-
57
- /**
58
- * Raised when this reader cannot answer AUTHORITATIVELY. It is never a verdict about the data — it
59
- * means "ask the CLI instead". A structural violation of the guarantees above throws a plain Error,
60
- * which must propagate: downgrading "two rows claim one key" to a CLI retry would convert a
61
- * detectable corruption into a silent, successful, wrong restore.
62
- */
63
- export class ProgressionReaderUnavailable extends Error {
64
- constructor(reason) {
65
- super(`canonical progression reader unavailable: ${reason}`);
66
- this.name = 'ProgressionReaderUnavailable';
67
- this.reason = reason;
68
- }
69
- }
70
-
71
- /**
72
- * THE PINNED SCHEMA FINGERPRINT — captured from the REAL canonical store on this machine
73
- * (ruflo 3.41.2, a real project's own <project>/.swarm/memory.db), not transcribed from source:
74
- *
75
- * PRAGMA user_version -> 0
76
- * PRAGMA table_info(memory_entries) -> the 18 columns below
77
- *
78
- * WHY PIN IT. Reading someone else's table is only safe while it is the table you measured. If a
79
- * later ruflo renames `content`, adds a second liveness column, or partitions rows, a reader that
80
- * merely SELECTs would answer confidently and WRONGLY — and the wrong answer here is "this project
81
- * has no history", which is indistinguishable from a fresh project. So a fingerprint mismatch is not
82
- * an error: it is this module standing down so the CLI, which OWNS the schema, answers instead.
83
- * Slower is a cost. Silently empty is a lie.
84
- */
85
- const SCHEMA_FINGERPRINT = Object.freeze({
86
- userVersion: 0,
87
- columns: Object.freeze([
88
- 'access_count', 'content', 'created_at', 'embedding', 'embedding_dimensions', 'embedding_model',
89
- 'expires_at', 'id', 'key', 'last_accessed_at', 'metadata', 'namespace', 'owner_id',
90
- 'provenance_type', 'status', 'tags', 'type', 'updated_at',
91
- ]),
92
- });
93
-
94
- // The current managed store on the host also carries `session_id` on memory_entries, although the
95
- // installed Ruflo memory_entries DDL does not declare it. This reader has no reason to attribute
96
- // that column to Ruflo: it is an observed local compatibility shape only. It is safe for this
97
- // projection because the reader never selects or interprets it; every required column must still
98
- // match, and every other addition remains a hard fallback to the managed CLI.
99
- const OBSERVED_COMPATIBILITY_COLUMNS = Object.freeze(['session_id']);
100
-
101
- /** The fingerprint this module requires, so the doctor and tests can name it exactly. */
102
- export function expectedSchemaFingerprint() {
103
- return { userVersion: SCHEMA_FINGERPRINT.userVersion, columns: [...SCHEMA_FINGERPRINT.columns] };
104
- }
105
-
106
- function assertSchemaFingerprint(database) {
107
- let columns;
108
- let userVersion;
109
- try {
110
- columns = database.prepare('PRAGMA table_info(memory_entries)').all().map((row) => String(row.name)).sort();
111
- userVersion = database.prepare('PRAGMA user_version').get()?.user_version;
112
- } catch (error) {
113
- throw new ProgressionReaderUnavailable(`schema mismatch: ${error.message}`);
114
- }
115
- if (columns.length === 0) throw new ProgressionReaderUnavailable('schema mismatch: memory_entries is absent');
116
- if (userVersion !== SCHEMA_FINGERPRINT.userVersion) {
117
- throw new ProgressionReaderUnavailable(
118
- `schema fingerprint mismatch: user_version ${userVersion} is not ${SCHEMA_FINGERPRINT.userVersion}`);
119
- }
120
- const required = SCHEMA_FINGERPRINT.columns;
121
- const compatibleObservedShape = columns.length === required.length + 1
122
- && OBSERVED_COMPATIBILITY_COLUMNS.every((name) => columns.includes(name))
123
- && required.every((name) => columns.includes(name));
124
- if (columns.join(',') !== required.join(',') && !compatibleObservedShape) {
125
- const missing = SCHEMA_FINGERPRINT.columns.filter((name) => !columns.includes(name));
126
- const added = columns.filter((name) => !SCHEMA_FINGERPRINT.columns.includes(name));
127
- throw new ProgressionReaderUnavailable('schema fingerprint mismatch: memory_entries columns differ'
128
- + `${missing.length ? ` (missing ${missing.join('/')})` : ''}`
129
- + `${added.length ? ` (unexpected ${added.join('/')})` : ''}`);
130
- }
131
- }
132
-
133
- let sqliteBinding;
134
- function databaseSync() {
135
- if (sqliteBinding === undefined) {
136
- try {
137
- // node-sqlite.mjs: loaded on first real use and without Node 22's SQLite ExperimentalWarning,
138
- // which otherwise lands in every SessionStart hook's output.
139
- sqliteBinding = loadNodeSqlite()?.DatabaseSync ?? null;
140
- } catch { sqliteBinding = null; }
141
- }
142
- return sqliteBinding;
143
- }
144
-
145
- /** True when this Node build exposes node:sqlite at all. Exported for diagnostics and tests. */
146
- export function canonicalReaderSupported() {
147
- return typeof databaseSync() === 'function';
148
- }
149
-
150
- function looksLikeSqliteFile(dbPath) {
151
- let handle;
152
- try {
153
- handle = fs.openSync(dbPath, 'r');
154
- } catch {
155
- return false;
156
- }
157
- try {
158
- const head = Buffer.alloc(SQLITE_HEADER.length);
159
- const read = fs.readSync(handle, head, 0, head.length, 0);
160
- return read === head.length && head.equals(SQLITE_HEADER);
161
- } catch {
162
- return false;
163
- } finally {
164
- fs.closeSync(handle);
165
- }
166
- }
167
-
168
- function requireKey(value) {
169
- if (typeof value !== 'string' || !value) throw new Error('malformed progression row: missing key');
170
- return value;
171
- }
172
-
173
- /**
174
- * Open a read-only view of one canonical memory.db.
175
- *
176
- * @returns {{ listKeys: Function, readContent: Function, close: Function }}
177
- * @throws {ProgressionReaderUnavailable} when the CLI must be used instead.
178
- */
179
- export function openProgressionReader(dbPath, { deadlineAt = Infinity, signal } = {}) {
180
- const checkDeadline = () => { if (signal?.aborted || Date.now() >= deadlineAt) throw new Error('restore deadline exceeded'); };
181
- checkDeadline();
182
- const DatabaseSync = databaseSync();
183
- if (typeof DatabaseSync !== 'function') throw new ProgressionReaderUnavailable('node:sqlite is unavailable');
184
- if (typeof dbPath !== 'string' || !dbPath) throw new ProgressionReaderUnavailable('no canonical store path');
185
- let stat;
186
- try { stat = fs.statSync(dbPath); } catch { throw new ProgressionReaderUnavailable('canonical store does not exist'); }
187
- if (!stat.isFile()) throw new ProgressionReaderUnavailable('canonical store is not a regular file');
188
- // An encrypted image (CLAUDE_FLOW_ENCRYPT_AT_REST) starts with the vault's "RFE1" magic, so the
189
- // header check covers encryption, truncation and any other non-SQLite blob in one test — before
190
- // node:sqlite gets a chance to report the generic "file is not a database".
191
- if (!looksLikeSqliteFile(dbPath)) throw new ProgressionReaderUnavailable('canonical store is not a plain SQLite image');
192
-
193
- let database;
194
- try {
195
- database = new DatabaseSync(dbPath, { readOnly: true });
196
- } catch (error) {
197
- throw new ProgressionReaderUnavailable(`read-only open failed: ${error.message}`);
198
- }
199
-
200
- try {
201
- assertSchemaFingerprint(database);
202
- } catch (error) {
203
- try { database.close(); } catch { /* the fingerprint verdict is the news */ }
204
- throw error;
205
- }
206
-
207
- const prepare = (sql) => {
208
- try { return database.prepare(sql); } catch (error) {
209
- throw new ProgressionReaderUnavailable(`schema mismatch: ${error.message}`);
210
- }
211
- };
212
- let listStatement;
213
- let readStatement;
214
- let allStatement;
215
- try {
216
- // Prepared eagerly so an unexpected schema (or a WAL image this process cannot read) is
217
- // reported as UNAVAILABLE now, before the caller has committed to the fast path.
218
- listStatement = prepare(`SELECT key FROM memory_entries WHERE ${ACTIVE_ROW_SQL} AND namespace = ? ORDER BY key`);
219
- readStatement = prepare(`SELECT content FROM memory_entries WHERE ${ACTIVE_ROW_SQL} AND namespace = ? AND key = ?`);
220
- allStatement = prepare(`SELECT namespace, key, content FROM memory_entries WHERE ${ACTIVE_ROW_SQL} ORDER BY namespace, key`);
221
- } catch (error) {
222
- try { database.close(); } catch { /* the open failure is the news */ }
223
- throw error;
224
- }
225
-
226
- const query = (statement, params) => {
227
- try { return statement.all(Date.now(), ...params); } catch (error) {
228
- throw new ProgressionReaderUnavailable(`read failed: ${error.message}`);
229
- }
230
- };
231
-
232
- return {
233
- /** Every active key in `namespace`, sorted, deduplicated-by-error, bounded. */
234
- listKeys(namespace, { maxEntries = 10_000 } = {}) {
235
- checkDeadline();
236
- if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) throw new TypeError('maxEntries must be a positive safe integer');
237
- let rows = [];
238
- if (Number.isFinite(deadlineAt) || signal) {
239
- for (const row of listStatement.iterate(Date.now(), namespace)) {
240
- checkDeadline(); rows.push(row);
241
- if (rows.length > maxEntries) throw new Error('progression enumeration exceeds its bound');
242
- }
243
- } else rows = query(listStatement, [namespace]);
244
- if (rows.length > maxEntries) throw new Error('progression enumeration exceeds its bound');
245
- const keys = [];
246
- const seen = new Set();
247
- for (const row of rows) {
248
- const key = requireKey(row?.key);
249
- if (seen.has(key)) throw new Error(`duplicate progression key in store: ${key}`);
250
- seen.add(key);
251
- keys.push(key);
252
- }
253
- return keys;
254
- },
255
-
256
- /** The exact stored value for one (namespace, key), or null when that row does not exist. */
257
- readContent(namespace, key) {
258
- checkDeadline();
259
- requireKey(key);
260
- const rows = query(readStatement, [namespace, key]);
261
- if (rows.length === 0) return null;
262
- if (rows.length > 1) throw new Error(`duplicate progression key in store: ${key}`);
263
- const content = rows[0]?.content;
264
- // A non-text column is not this schema; fall back rather than guess at an encoding.
265
- if (typeof content !== 'string') throw new ProgressionReaderUnavailable('progression row content is not text');
266
- return content;
267
- },
268
-
269
- /** Every active (namespace, key, content) row — bounded, never truncated (past the bound is an error). */
270
- allRows({ maxRows = 100_000 } = {}) {
271
- const rows = query(allStatement, []);
272
- if (rows.length > maxRows) throw new ProgressionReaderUnavailable(`store holds more than ${maxRows} active rows`);
273
- return rows.map((row) => ({ namespace: String(row.namespace ?? ''), key: String(row.key ?? ''),
274
- content: typeof row.content === 'string' ? row.content : null }));
275
- },
276
-
277
- close() {
278
- try { database.close(); } catch { /* closing a spent read handle is never news */ }
279
- },
280
- };
281
- }
282
-
283
- /**
284
- * Run `work(reader)` against a read-only view, closing it afterwards.
285
- * Returns `{ ok: true, value }`, or `{ ok: false, reason }` when the CLI must be used instead.
286
- * Structural errors are NOT converted — they propagate, by design (see ProgressionReaderUnavailable).
287
- */
288
- export function withProgressionReader(dbPath, work, options = {}) {
289
- let reader;
290
- try {
291
- reader = openProgressionReader(dbPath, options);
292
- } catch (error) {
293
- if (error instanceof ProgressionReaderUnavailable) return { ok: false, reason: error.reason };
294
- throw error;
295
- }
296
- try {
297
- return { ok: true, value: work(reader) };
298
- } catch (error) {
299
- if (error instanceof ProgressionReaderUnavailable) return { ok: false, reason: error.reason };
300
- throw error;
301
- } finally {
302
- reader.close();
303
- }
304
- }