wendkeep 0.60.0 → 0.61.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.
@@ -0,0 +1,900 @@
1
+ // Durable, local-only store for shared-memory events.
2
+ //
3
+ // Producers only create immutable outbox files. The projector is the sole ledger writer:
4
+ // it takes MEMORY.lock, durably appends events, deterministically replays the complete
5
+ // ledger, then atomically publishes SHARED_MEMORY.md and MEMORY_CANDIDATES.jsonl.
6
+ import { createHash } from 'node:crypto';
7
+ import {
8
+ closeSync, constants as fsConstants, copyFileSync, fstatSync, fsyncSync, openSync,
9
+ readFileSync, readdirSync, statSync, writeFileSync,
10
+ } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import {
13
+ renderSharedMemory,
14
+ sanitizeMemoryText,
15
+ validateMemoryEvent,
16
+ } from './memory-schema.mjs';
17
+ import {
18
+ assertVaultPathSafe, assertVaultPathsSafe, mkdirVaultPath, unlinkVaultFile,
19
+ VAULT_LOCK_BUSY, withVaultPathLock, writeVaultFileAtomic,
20
+ } from './vault-path-safety.mjs';
21
+
22
+ const EVENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
23
+
24
+ export class MemoryEventCollision extends Error {
25
+ constructor(eventId, message = `Memory event ID collision: ${eventId}`) {
26
+ super(message);
27
+ this.name = 'MemoryEventCollision';
28
+ this.code = 'MEMORY_EVENT_COLLISION';
29
+ this.eventId = eventId;
30
+ }
31
+ }
32
+
33
+ export class MemoryLedgerCorruption extends Error {
34
+ constructor(errors, message = 'Memory ledger is corrupt; run `wendkeep memory repair`.') {
35
+ super(message);
36
+ this.name = 'MemoryLedgerCorruption';
37
+ this.code = 'MEMORY_LEDGER_CORRUPT';
38
+ this.errors = Array.isArray(errors) ? errors : [];
39
+ }
40
+ }
41
+
42
+ export class MemoryOutboxCorruption extends Error {
43
+ constructor(path, cause) {
44
+ super(`Memory outbox file is corrupt: ${path}`);
45
+ this.name = 'MemoryOutboxCorruption';
46
+ this.code = 'MEMORY_OUTBOX_CORRUPT';
47
+ this.path = path;
48
+ this.cause = cause;
49
+ }
50
+ }
51
+
52
+ function brainDir(vaultBase) {
53
+ return join(vaultBase, '.brain');
54
+ }
55
+
56
+ function outboxDir(vaultBase) {
57
+ return join(brainDir(vaultBase), 'memory-outbox');
58
+ }
59
+
60
+ function ledgerPath(vaultBase) {
61
+ return join(brainDir(vaultBase), 'MEMORY_EVENTS.jsonl');
62
+ }
63
+
64
+ function sharedPath(vaultBase) {
65
+ return join(brainDir(vaultBase), 'SHARED_MEMORY.md');
66
+ }
67
+
68
+ function corePath(vaultBase) {
69
+ return join(brainDir(vaultBase), 'CORE.md');
70
+ }
71
+
72
+ function candidatesPath(vaultBase) {
73
+ return join(brainDir(vaultBase), 'MEMORY_CANDIDATES.jsonl');
74
+ }
75
+
76
+ function lockTarget(vaultBase) {
77
+ return join(brainDir(vaultBase), 'MEMORY');
78
+ }
79
+
80
+ function unsafeMemoryPath(message) {
81
+ const error = new Error(message);
82
+ error.code = 'VAULT_PATH_UNSAFE';
83
+ return error;
84
+ }
85
+
86
+ function checkedMemoryFile(vaultBase, path, label, {
87
+ allowMissing = true,
88
+ mustNotExist = false,
89
+ } = {}) {
90
+ return assertVaultPathSafe(vaultBase, path, {
91
+ allowMissing,
92
+ expectedType: 'file',
93
+ mustNotExist,
94
+ label,
95
+ });
96
+ }
97
+
98
+ function checkedMemoryDirectory(vaultBase, path, label, { allowMissing = true } = {}) {
99
+ return assertVaultPathSafe(vaultBase, path, {
100
+ allowMissing,
101
+ expectedType: 'directory',
102
+ label,
103
+ });
104
+ }
105
+
106
+ function readCheckedMemoryFile(vaultBase, path, encoding, label, { allowMissing = false } = {}) {
107
+ let checked = checkedMemoryFile(vaultBase, path, label, { allowMissing });
108
+ if (!checked.exists) return null;
109
+ // Deliberately adjacent to readFileSync: aliases created since the first policy check
110
+ // are rejected before Node opens the artifact.
111
+ checked = checkedMemoryFile(vaultBase, checked.target, label, { allowMissing: false });
112
+ return readFileSync(checked.target, encoding);
113
+ }
114
+
115
+ function assertOpenedMemoryFile(vaultBase, path, fd, label) {
116
+ const checked = checkedMemoryFile(vaultBase, path, label, { allowMissing: false });
117
+ const descriptor = fstatSync(fd);
118
+ const target = statSync(checked.target);
119
+ if (!descriptor.isFile() || descriptor.nlink > 1 || target.nlink > 1
120
+ || descriptor.dev !== target.dev || descriptor.ino !== target.ino) {
121
+ throw unsafeMemoryPath(`${label} mudou de inode ou possui hardlink antes da mutação: ${checked.target}`);
122
+ }
123
+ return checked.target;
124
+ }
125
+
126
+ function preflightProjectionTargets(vaultBase) {
127
+ return assertVaultPathsSafe(vaultBase, [
128
+ { path: sharedPath(vaultBase), expectedType: 'file', label: 'projeção SHARED_MEMORY.md' },
129
+ { path: candidatesPath(vaultBase), expectedType: 'file', label: 'projeção MEMORY_CANDIDATES.jsonl' },
130
+ ]);
131
+ }
132
+
133
+ function canonicalize(value) {
134
+ if (Array.isArray(value)) return value.map(canonicalize);
135
+ if (value && typeof value === 'object') {
136
+ const out = {};
137
+ for (const key of Object.keys(value).sort()) {
138
+ if (value[key] !== undefined) out[key] = canonicalize(value[key]);
139
+ }
140
+ return out;
141
+ }
142
+ return value;
143
+ }
144
+
145
+ export function canonicalMemoryJson(value) {
146
+ return JSON.stringify(canonicalize(value));
147
+ }
148
+
149
+ function sha256(value) {
150
+ return createHash('sha256').update(String(value)).digest('hex');
151
+ }
152
+
153
+ export function hashMemoryValue(value) {
154
+ return sha256(canonicalMemoryJson(value));
155
+ }
156
+
157
+ function sanitizeValue(value) {
158
+ if (typeof value === 'string') return sanitizeMemoryText(value);
159
+ if (Array.isArray(value)) return value.map(sanitizeValue);
160
+ if (value && typeof value === 'object') {
161
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeValue(item)]));
162
+ }
163
+ return value;
164
+ }
165
+
166
+ function sanitizeEvent(event) {
167
+ return sanitizeValue(structuredClone(event));
168
+ }
169
+
170
+ function projectIdForVault(vaultBase) {
171
+ const path = join(brainDir(vaultBase), 'PROJECT.json');
172
+ const raw = readCheckedMemoryFile(vaultBase, path, 'utf8', 'autoridade PROJECT.json', {
173
+ allowMissing: true,
174
+ });
175
+ if (raw === null) return undefined;
176
+ try {
177
+ const project = JSON.parse(raw);
178
+ return typeof project.projectId === 'string' && project.projectId ? project.projectId : undefined;
179
+ } catch {
180
+ return undefined;
181
+ }
182
+ }
183
+
184
+ function assertValidEvent(event, vaultBase) {
185
+ const sanitized = sanitizeEvent(event);
186
+ const projectId = vaultBase ? projectIdForVault(vaultBase) : undefined;
187
+ const result = validateMemoryEvent(sanitized, projectId ? { projectId } : {});
188
+ if (!result.ok) {
189
+ const error = new TypeError(`Invalid memory event: ${result.errors.join(' ')}`);
190
+ error.code = 'MEMORY_EVENT_INVALID';
191
+ error.errors = result.errors;
192
+ throw error;
193
+ }
194
+ if (!EVENT_ID.test(sanitized.event_id)) {
195
+ const error = new TypeError('Invalid memory event: event_id is not filename-safe.');
196
+ error.code = 'MEMORY_EVENT_INVALID';
197
+ throw error;
198
+ }
199
+ return sanitized;
200
+ }
201
+
202
+ function eventHash(event) {
203
+ return sha256(canonicalMemoryJson(event));
204
+ }
205
+
206
+ const OUTBOX_PUBLICATION_WAIT_MS = 500;
207
+ const OUTBOX_PUBLICATION_POLL_MS = 5;
208
+ const OUTBOX_PUBLICATION_SIGNAL = new Int32Array(new SharedArrayBuffer(4));
209
+
210
+ function readConcurrentOutboxEvent(vaultBase, path, eventId) {
211
+ const deadline = Date.now() + OUTBOX_PUBLICATION_WAIT_MS;
212
+ while (true) {
213
+ let raw;
214
+ try {
215
+ raw = readCheckedMemoryFile(
216
+ vaultBase, path, 'utf8', 'evento imutável preexistente do outbox',
217
+ );
218
+ } catch (cause) {
219
+ if (cause?.code === 'VAULT_PATH_UNSAFE') throw cause;
220
+ throw new MemoryEventCollision(eventId, `Existing outbox event is unreadable: ${eventId}`);
221
+ }
222
+
223
+ try {
224
+ return JSON.parse(raw);
225
+ } catch (cause) {
226
+ if (!(cause instanceof SyntaxError) || Date.now() >= deadline) {
227
+ throw new MemoryEventCollision(eventId, `Existing outbox event is unreadable: ${eventId}`);
228
+ }
229
+ Atomics.wait(OUTBOX_PUBLICATION_SIGNAL, 0, 0, OUTBOX_PUBLICATION_POLL_MS);
230
+ }
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Persist one immutable producer event using exclusive creation. A retry with the same
236
+ * canonical payload is a no-op; reusing the ID for different bytes is an observable error.
237
+ */
238
+ export function enqueueMemoryEvent(vaultBase, event) {
239
+ const checked = assertValidEvent(event, vaultBase);
240
+ const dir = outboxDir(vaultBase);
241
+ mkdirVaultPath(vaultBase, dir, { label: 'outbox de memória' });
242
+ const path = join(dir, `${checked.event_id}.json`);
243
+ const payload = `${canonicalMemoryJson(checked)}\n`;
244
+ const hash = eventHash(checked);
245
+
246
+ let fd;
247
+ try {
248
+ checkedMemoryFile(vaultBase, path, 'evento imutável do outbox');
249
+ // The final lstat is deliberately adjacent to the exclusive open. A pre-created
250
+ // symlink/hardlink loses the wx race and is revalidated in the EEXIST branch.
251
+ checkedMemoryFile(vaultBase, path, 'evento imutável do outbox');
252
+ fd = openSync(path, 'wx');
253
+ assertOpenedMemoryFile(vaultBase, path, fd, 'evento imutável do outbox');
254
+ writeFileSync(fd, payload, 'utf8');
255
+ fsyncSync(fd);
256
+ assertOpenedMemoryFile(vaultBase, path, fd, 'evento imutável do outbox');
257
+ return { status: 'enqueued', path, eventId: checked.event_id, hash };
258
+ } catch (error) {
259
+ if (error?.code !== 'EEXIST') throw error;
260
+ const existing = readConcurrentOutboxEvent(vaultBase, path, checked.event_id);
261
+ if (eventHash(existing) !== hash || canonicalMemoryJson(existing) !== canonicalMemoryJson(checked)) {
262
+ throw new MemoryEventCollision(checked.event_id);
263
+ }
264
+ return { status: 'duplicate', path, eventId: checked.event_id, hash };
265
+ } finally {
266
+ if (fd !== undefined) closeSync(fd);
267
+ }
268
+ }
269
+
270
+ function ledgerError(line, message, partial = false) {
271
+ return { line, message, partial };
272
+ }
273
+
274
+ /** Read a ledger without hiding a valid prefix when its tail is corrupt or partial. */
275
+ export function readMemoryLedger(vaultBase) {
276
+ const path = ledgerPath(vaultBase);
277
+ const checked = checkedMemoryFile(vaultBase, path, 'ledger MEMORY_EVENTS.jsonl');
278
+ if (!checked.exists) {
279
+ return { status: 'ok', path, raw: '', events: [], eventIds: new Set(), errors: [] };
280
+ }
281
+ const raw = readCheckedMemoryFile(
282
+ vaultBase, checked.target, 'utf8', 'ledger MEMORY_EVENTS.jsonl',
283
+ ).replace(/\r\n/g, '\n');
284
+ const lines = raw.split('\n');
285
+ const hasPartialTail = raw.length > 0 && !raw.endsWith('\n');
286
+ if (!hasPartialTail) lines.pop();
287
+ const events = [];
288
+ const eventIds = new Set();
289
+ const eventPayloads = new Map();
290
+ const errors = [];
291
+ const projectId = projectIdForVault(vaultBase);
292
+
293
+ lines.forEach((line, index) => {
294
+ const lineNumber = index + 1;
295
+ const partial = hasPartialTail && index === lines.length - 1;
296
+ if (!line.trim()) {
297
+ errors.push(ledgerError(lineNumber, 'blank ledger line', partial));
298
+ return;
299
+ }
300
+ let parsed;
301
+ try {
302
+ parsed = JSON.parse(line);
303
+ } catch (error) {
304
+ errors.push(ledgerError(lineNumber, `invalid JSON: ${error.message}`, partial));
305
+ return;
306
+ }
307
+ const validation = validateMemoryEvent(parsed, projectId ? { projectId } : {});
308
+ if (!validation.ok) {
309
+ errors.push(ledgerError(lineNumber, validation.errors.join(' '), partial));
310
+ return;
311
+ }
312
+ const payload = canonicalMemoryJson(parsed);
313
+ if (eventIds.has(parsed.event_id)) {
314
+ if (eventPayloads.get(parsed.event_id) !== payload) {
315
+ errors.push(ledgerError(lineNumber, `event_id collision: ${parsed.event_id}`, partial));
316
+ }
317
+ return;
318
+ }
319
+ eventIds.add(parsed.event_id);
320
+ eventPayloads.set(parsed.event_id, payload);
321
+ events.push(parsed);
322
+ });
323
+
324
+ return {
325
+ status: errors.length ? 'corrupt' : 'ok',
326
+ path,
327
+ raw,
328
+ events,
329
+ eventIds,
330
+ errors,
331
+ };
332
+ }
333
+
334
+ function eventOrder(left, right) {
335
+ return (Number(left.base_revision ?? 0) - Number(right.base_revision ?? 0))
336
+ || String(left.effective_at || left.observed_at).localeCompare(String(right.effective_at || right.observed_at))
337
+ || Number(left.turn_sequence ?? 0) - Number(right.turn_sequence ?? 0)
338
+ || String(left.event_id).localeCompare(String(right.event_id));
339
+ }
340
+
341
+ function sameCausalActivation(left, right) {
342
+ return Boolean(left?.canonical_session_id)
343
+ && left.canonical_session_id === right?.canonical_session_id
344
+ && left.activation_id === right?.activation_id;
345
+ }
346
+
347
+ function comparable(left, right) {
348
+ if (sameCausalActivation(left, right)) return true;
349
+ const leftSupersedes = left.supersedes_event_id || left.supersedes;
350
+ const rightSupersedes = right.supersedes_event_id || right.supersedes;
351
+ return leftSupersedes === right.event_id || rightSupersedes === left.event_id
352
+ || (Array.isArray(leftSupersedes) && leftSupersedes.includes(right.event_id))
353
+ || (Array.isArray(rightSupersedes) && rightSupersedes.includes(left.event_id));
354
+ }
355
+
356
+ function conflictGroupKey(event) {
357
+ if (event.operation !== 'replace') return null;
358
+ if (!Number.isInteger(event.base_revision) || typeof event.base_value_hash !== 'string') return null;
359
+ return `${event.memory_key}\u0000${event.base_revision}\u0000${event.base_value_hash}`;
360
+ }
361
+
362
+ function candidateId(reason, memoryKey, eventIds) {
363
+ return `memcand-${sha256(`${reason}\u0000${memoryKey}\u0000${eventIds.join('\u0000')}`).slice(0, 16)}`;
364
+ }
365
+
366
+ // CORE is prose, not an operational data store. Only an explicit, single-line marker
367
+ // participates in precedence so the projector never guesses meaning from human text:
368
+ // <!-- wk-memory: release.push="manual-only" -->
369
+ function readCoreInvariants(vaultBase) {
370
+ const core = readCheckedMemoryFile(
371
+ vaultBase, corePath(vaultBase), 'utf8', 'autoridade CORE.md', { allowMissing: true },
372
+ );
373
+ if (core === null) return new Map();
374
+ const invariants = new Map();
375
+ const marker = /^<!--\s*wk-memory:\s*([A-Za-z0-9][A-Za-z0-9._-]*)=(.+)\s*-->$/;
376
+ for (const line of core.split('\n')) {
377
+ const match = line.trim().match(marker);
378
+ if (!match) continue;
379
+ try {
380
+ invariants.set(match[1], JSON.parse(match[2].trim()));
381
+ } catch { /* malformed prose markers do not become implicit authority */ }
382
+ }
383
+ return invariants;
384
+ }
385
+
386
+ function blockedByCoreCandidate(event, coreValue) {
387
+ return {
388
+ v: 1,
389
+ candidate_id: candidateId('blocked_by_core', event.memory_key, [event.event_id]),
390
+ reason: 'blocked_by_core',
391
+ status: 'blocked_by_core',
392
+ memory_key: event.memory_key,
393
+ event_ids: [event.event_id],
394
+ proposed_value: event.value,
395
+ core_value: coreValue,
396
+ provenance: {
397
+ authority: 'core',
398
+ source: '.brain/CORE.md',
399
+ core_value_hash: hashMemoryValue(coreValue),
400
+ },
401
+ events: [event],
402
+ };
403
+ }
404
+
405
+ function conflictCandidate(memoryKey, events, currentEvent = null) {
406
+ const ordered = [...events].sort(eventOrder);
407
+ const eventIds = ordered.map((item) => item.event_id).sort();
408
+ const byId = new Map(ordered.map((item) => [item.event_id, item]));
409
+ return {
410
+ v: 1,
411
+ candidate_id: candidateId('conflict', memoryKey, eventIds),
412
+ reason: 'conflict',
413
+ memory_key: memoryKey,
414
+ event_ids: eventIds,
415
+ values: eventIds.map((id) => byId.get(id).value),
416
+ base_revision: ordered[0]?.base_revision ?? currentEvent?.revision ?? 0,
417
+ base_value_hash: ordered[0]?.base_value_hash ?? (currentEvent ? hashMemoryValue(currentEvent.value) : null),
418
+ events: eventIds.map((id) => byId.get(id)),
419
+ };
420
+ }
421
+
422
+ function sortedObject(entries) {
423
+ return Object.fromEntries([...entries].sort(([left], [right]) => left.localeCompare(right)));
424
+ }
425
+
426
+ function currentEventFromRecord(record) {
427
+ if (!record) return null;
428
+ return { ...record.source, value: record.value, revision: record.revision };
429
+ }
430
+
431
+ function isCausallyOlder(event, current) {
432
+ if (!current) return false;
433
+ if (sameCausalActivation(event, current)) {
434
+ return Number(event.turn_sequence) < Number(current.turn_sequence);
435
+ }
436
+ if (Number.isInteger(event.activation_epoch) && Number.isInteger(current.activation_epoch)
437
+ && event.activation_epoch !== current.activation_epoch) {
438
+ return event.activation_epoch < current.activation_epoch;
439
+ }
440
+ const eventEffective = Date.parse(event.effective_at || '');
441
+ const currentEffective = Date.parse(current.effective_at || '');
442
+ return Number.isFinite(eventEffective) && Number.isFinite(currentEffective)
443
+ && eventEffective < currentEffective;
444
+ }
445
+
446
+ /**
447
+ * Pure deterministic reducer. It pre-detects incomparable scalar siblings so replay order
448
+ * never turns one concurrent writer into an accidental winner.
449
+ */
450
+ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map() } = {}) {
451
+ const protectedValues = coreInvariants instanceof Map
452
+ ? coreInvariants
453
+ : new Map(Object.entries(coreInvariants || {}));
454
+ const unique = new Map();
455
+ for (const raw of inputEvents) {
456
+ const event = assertValidEvent(raw);
457
+ const existing = unique.get(event.event_id);
458
+ if (existing && canonicalMemoryJson(existing) !== canonicalMemoryJson(event)) {
459
+ throw new MemoryEventCollision(event.event_id, `Ledger contains divergent payloads for ${event.event_id}`);
460
+ }
461
+ if (!existing) unique.set(event.event_id, event);
462
+ }
463
+ const events = [...unique.values()].sort(eventOrder);
464
+
465
+ const peerGroups = new Map();
466
+ for (const item of events) {
467
+ const key = conflictGroupKey(item);
468
+ if (!key) continue;
469
+ if (!peerGroups.has(key)) peerGroups.set(key, []);
470
+ peerGroups.get(key).push(item);
471
+ }
472
+ const conflictingIds = new Set();
473
+ const groupedCandidates = new Map();
474
+ for (const [key, group] of peerGroups) {
475
+ const incomparable = group.filter((item, index) => group.some((other, otherIndex) => index !== otherIndex && !comparable(item, other)));
476
+ if (incomparable.length < 2) continue;
477
+ const ordered = [...incomparable].sort(eventOrder);
478
+ ordered.forEach((item) => conflictingIds.add(item.event_id));
479
+ groupedCandidates.set(key, ordered);
480
+ }
481
+
482
+ const records = new Map();
483
+ const tombstones = new Map();
484
+ const candidates = [];
485
+ const emittedGroups = new Set();
486
+ const appliedEventIds = [];
487
+ const superseded = [];
488
+ let revision = 0;
489
+
490
+ for (const item of events) {
491
+ if (protectedValues.has(item.memory_key)) {
492
+ const coreValue = protectedValues.get(item.memory_key);
493
+ const agreesWithCore = item.operation === 'assert'
494
+ && hashMemoryValue(item.value) === hashMemoryValue(coreValue);
495
+ if (!agreesWithCore) {
496
+ candidates.push(blockedByCoreCandidate(item, coreValue));
497
+ continue;
498
+ }
499
+ }
500
+
501
+ const groupKey = conflictGroupKey(item);
502
+ if (conflictingIds.has(item.event_id)) {
503
+ if (!emittedGroups.has(groupKey)) {
504
+ candidates.push(conflictCandidate(item.memory_key, groupedCandidates.get(groupKey)));
505
+ emittedGroups.add(groupKey);
506
+ }
507
+ continue;
508
+ }
509
+
510
+ const current = records.get(item.memory_key);
511
+ const currentSource = current?.source;
512
+ if (isCausallyOlder(item, currentSource)) {
513
+ superseded.push({ event_id: item.event_id, by_event_id: currentSource.event_id });
514
+ continue;
515
+ }
516
+
517
+ if (item.operation === 'assert') {
518
+ if (current && hashMemoryValue(current.value) !== hashMemoryValue(item.value)) {
519
+ if (sameCausalActivation(item, currentSource)
520
+ && Number(item.turn_sequence) > Number(currentSource.turn_sequence)) {
521
+ records.set(item.memory_key, { value: item.value, revision: current.revision + 1, source: item });
522
+ tombstones.delete(item.memory_key);
523
+ superseded.push({ event_id: currentSource.event_id, by_event_id: item.event_id });
524
+ revision += 1;
525
+ appliedEventIds.push(item.event_id);
526
+ continue;
527
+ }
528
+ candidates.push(conflictCandidate(item.memory_key, [currentEventFromRecord(current), item]));
529
+ continue;
530
+ }
531
+ if (!current) {
532
+ records.set(item.memory_key, { value: item.value, revision: 1, source: item });
533
+ tombstones.delete(item.memory_key);
534
+ revision += 1;
535
+ }
536
+ appliedEventIds.push(item.event_id);
537
+ continue;
538
+ }
539
+
540
+ if (item.operation === 'add') {
541
+ const oldValues = Array.isArray(current?.value) ? current.value : (current ? [current.value] : []);
542
+ const additions = Array.isArray(item.value) ? item.value : [item.value];
543
+ const byHash = new Map(oldValues.map((value) => [hashMemoryValue(value), value]));
544
+ additions.forEach((value) => byHash.set(hashMemoryValue(value), value));
545
+ const value = [...byHash.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([, entry]) => entry);
546
+ records.set(item.memory_key, { value, revision: (current?.revision || 0) + 1, source: item });
547
+ tombstones.delete(item.memory_key);
548
+ revision += 1;
549
+ appliedEventIds.push(item.event_id);
550
+ continue;
551
+ }
552
+
553
+ if (item.operation === 'remove') {
554
+ if (!current) {
555
+ appliedEventIds.push(item.event_id);
556
+ continue;
557
+ }
558
+ const baseMatches = item.base_revision === undefined
559
+ || (item.base_revision === current.revision
560
+ && (!item.base_value_hash || item.base_value_hash === hashMemoryValue(current.value)));
561
+ if (!baseMatches) {
562
+ candidates.push(conflictCandidate(item.memory_key, [currentEventFromRecord(current), item]));
563
+ continue;
564
+ }
565
+ if (item.value !== null && item.value !== undefined && Array.isArray(current.value)) {
566
+ const removalHash = hashMemoryValue(item.value);
567
+ const value = current.value.filter((entry) => hashMemoryValue(entry) !== removalHash);
568
+ records.set(item.memory_key, { value, revision: current.revision + 1, source: item });
569
+ tombstones.set(`${item.memory_key}:${removalHash}`, {
570
+ event_id: item.event_id, removed_event_id: current.source.event_id, value_hash: removalHash,
571
+ });
572
+ } else {
573
+ records.delete(item.memory_key);
574
+ tombstones.set(item.memory_key, {
575
+ event_id: item.event_id,
576
+ removed_event_id: current.source.event_id,
577
+ value_hash: hashMemoryValue(current.value),
578
+ });
579
+ }
580
+ revision += 1;
581
+ appliedEventIds.push(item.event_id);
582
+ continue;
583
+ }
584
+
585
+ // replace
586
+ const explicitlySupersedes = item.supersedes_event_id === currentSource?.event_id
587
+ || (Array.isArray(item.supersedes) && item.supersedes.includes(currentSource?.event_id));
588
+ const baseMatches = current
589
+ && item.base_revision === current.revision
590
+ && item.base_value_hash === hashMemoryValue(current.value);
591
+ if (!current || (!baseMatches && !explicitlySupersedes)) {
592
+ candidates.push(conflictCandidate(item.memory_key, current ? [currentEventFromRecord(current), item] : [item]));
593
+ continue;
594
+ }
595
+ records.set(item.memory_key, { value: item.value, revision: current.revision + 1, source: item });
596
+ tombstones.delete(item.memory_key);
597
+ revision += 1;
598
+ appliedEventIds.push(item.event_id);
599
+ }
600
+
601
+ const stateEntries = [...records].map(([key, record]) => [key, record.value]);
602
+ const recordEntries = [...records].map(([key, record]) => [key, record]);
603
+ const tombstoneEntries = [...tombstones];
604
+ const state = sortedObject(stateEntries);
605
+ const recordObject = sortedObject(recordEntries);
606
+ const tombstoneObject = sortedObject(tombstoneEntries);
607
+ candidates.sort((left, right) => left.candidate_id.localeCompare(right.candidate_id));
608
+ superseded.sort((left, right) => left.event_id.localeCompare(right.event_id));
609
+ const activeEvents = Object.entries(recordObject).map(([memoryKey, record]) => ({
610
+ ...record.source,
611
+ memory_key: memoryKey,
612
+ operation: 'assert',
613
+ value: record.value,
614
+ }));
615
+ const eventCursor = events.at(-1)?.event_id || 'none';
616
+ const stateHash = hashMemoryValue({ state, tombstones: tombstoneObject });
617
+
618
+ return {
619
+ state,
620
+ records: recordObject,
621
+ candidates,
622
+ tombstones: tombstoneObject,
623
+ superseded,
624
+ appliedEventIds,
625
+ eventIds: events.map((item) => item.event_id),
626
+ activeEvents,
627
+ revision,
628
+ eventCursor,
629
+ stateHash,
630
+ };
631
+ }
632
+
633
+ /**
634
+ * Re-derive one ledger projection using the explicit invariants declared by CORE.
635
+ * `eventCursor` is the reducer's deterministic causal cursor; `ledgerCursor` is the
636
+ * physical prefix boundary used by durable checkpoints.
637
+ */
638
+ export function deriveMemoryProjection(vaultBase, inputEvents = []) {
639
+ const reduced = reduceMemoryEvents(inputEvents, { coreInvariants: readCoreInvariants(vaultBase) });
640
+ const ledgerCursor = inputEvents.at(-1)?.event_id || 'none';
641
+ const checkpoint = {
642
+ revision: reduced.revision,
643
+ event_cursor: ledgerCursor,
644
+ state_hash: reduced.stateHash,
645
+ };
646
+ if (reduced.eventCursor !== ledgerCursor) {
647
+ checkpoint.causal_event_cursor = reduced.eventCursor;
648
+ }
649
+ return {
650
+ ...reduced,
651
+ ledgerCursor,
652
+ checkpoint,
653
+ };
654
+ }
655
+
656
+ function readOutbox(vaultBase) {
657
+ const dir = outboxDir(vaultBase);
658
+ let checkedDir = checkedMemoryDirectory(vaultBase, dir, 'outbox de memória');
659
+ if (!checkedDir.exists) return [];
660
+ checkedDir = checkedMemoryDirectory(vaultBase, checkedDir.target, 'outbox de memória', {
661
+ allowMissing: false,
662
+ });
663
+ const entries = readdirSync(checkedDir.target, { withFileTypes: true });
664
+ for (const entry of entries) {
665
+ assertVaultPathSafe(vaultBase, join(checkedDir.target, entry.name), {
666
+ allowMissing: false,
667
+ label: `entrada ${entry.name} do outbox de memória`,
668
+ });
669
+ }
670
+ return entries
671
+ .filter((entry) => entry.name.endsWith('.json'))
672
+ .sort((left, right) => left.name.localeCompare(right.name))
673
+ .map((entry) => {
674
+ const path = join(checkedDir.target, entry.name);
675
+ try {
676
+ const event = assertValidEvent(JSON.parse(readCheckedMemoryFile(
677
+ vaultBase, path, 'utf8', `evento ${entry.name} do outbox de memória`,
678
+ )), vaultBase);
679
+ if (`${event.event_id}.json` !== entry.name) throw new Error('filename does not match event_id');
680
+ return { event, path };
681
+ } catch (error) {
682
+ if (error?.code === 'VAULT_PATH_UNSAFE') throw error;
683
+ throw new MemoryOutboxCorruption(path, error);
684
+ }
685
+ });
686
+ }
687
+
688
+ function countPendingOutbox(vaultBase) {
689
+ const dir = outboxDir(vaultBase);
690
+ let checked = checkedMemoryDirectory(vaultBase, dir, 'outbox de memória');
691
+ if (!checked.exists) return 0;
692
+ checked = checkedMemoryDirectory(vaultBase, checked.target, 'outbox de memória', {
693
+ allowMissing: false,
694
+ });
695
+ return readdirSync(checked.target, { withFileTypes: true })
696
+ .filter((entry) => entry.name.endsWith('.json'))
697
+ .map((entry) => {
698
+ assertVaultPathSafe(vaultBase, join(checked.target, entry.name), {
699
+ allowMissing: false,
700
+ label: `entrada ${entry.name} do outbox de memória`,
701
+ });
702
+ return entry;
703
+ }).length;
704
+ }
705
+
706
+ function appendLedgerDurably(vaultBase, path, events) {
707
+ if (!events.length) return;
708
+ let fd;
709
+ try {
710
+ let checked = checkedMemoryFile(vaultBase, path, 'ledger MEMORY_EVENTS.jsonl');
711
+ checked = checkedMemoryFile(vaultBase, checked.target, 'ledger MEMORY_EVENTS.jsonl');
712
+ fd = openSync(checked.target, 'a');
713
+ assertOpenedMemoryFile(vaultBase, checked.target, fd, 'ledger MEMORY_EVENTS.jsonl');
714
+ const payload = events.map((item) => canonicalMemoryJson(item)).join('\n') + '\n';
715
+ writeFileSync(fd, payload, 'utf8');
716
+ fsyncSync(fd);
717
+ assertOpenedMemoryFile(vaultBase, checked.target, fd, 'ledger MEMORY_EVENTS.jsonl');
718
+ } finally {
719
+ if (fd !== undefined) closeSync(fd);
720
+ }
721
+ }
722
+
723
+ function writeAtomicIfChanged(vaultBase, path, content, label) {
724
+ const checked = checkedMemoryFile(vaultBase, path, label);
725
+ if (checked.exists) {
726
+ try {
727
+ if (readCheckedMemoryFile(vaultBase, checked.target, 'utf8', label) === content) return false;
728
+ } catch (error) {
729
+ if (error?.code === 'VAULT_PATH_UNSAFE') throw error;
730
+ // A contained but unreadable projection may be atomically replaced.
731
+ }
732
+ }
733
+ writeVaultFileAtomic(vaultBase, checked.target, content, 'utf8', { label });
734
+ return true;
735
+ }
736
+
737
+ function injectFault(faultAt, boundary) {
738
+ if (faultAt === boundary) throw new Error(`Injected memory-store fault: ${boundary}`);
739
+ }
740
+
741
+ /** Build every derived byte from an immutable authority snapshot without publishing it. */
742
+ export function prepareMemoryProjection(vaultBase, allEvents) {
743
+ const reduced = deriveMemoryProjection(vaultBase, allEvents);
744
+ const updatedAt = allEvents
745
+ .map((item) => item.observed_at)
746
+ .filter(Boolean)
747
+ .sort()
748
+ .at(-1);
749
+ const shared = renderSharedMemory({
750
+ revision: reduced.revision,
751
+ eventCursor: reduced.ledgerCursor,
752
+ events: reduced.activeEvents,
753
+ stateHash: reduced.stateHash,
754
+ updatedAt,
755
+ });
756
+ const candidates = reduced.candidates.map((item) => canonicalMemoryJson(item)).join('\n')
757
+ + (reduced.candidates.length ? '\n' : '');
758
+ return {
759
+ sharedContent: shared,
760
+ candidatesContent: candidates,
761
+ revision: reduced.revision,
762
+ eventCursor: reduced.eventCursor,
763
+ ledgerCursor: reduced.ledgerCursor,
764
+ stateHash: reduced.stateHash,
765
+ checkpoint: reduced.checkpoint,
766
+ candidates: reduced.candidates.length,
767
+ };
768
+ }
769
+
770
+ /** Publish a projection prepared from the same locked authority snapshot. */
771
+ export function publishMemoryProjection(vaultBase, prepared) {
772
+ const {
773
+ sharedContent, candidatesContent, ...projection
774
+ } = prepared;
775
+ // Validate the complete publication set before the first rename: a bad candidates
776
+ // alias cannot leave SHARED partially advanced (and vice versa).
777
+ preflightProjectionTargets(vaultBase);
778
+ const sharedWritten = writeAtomicIfChanged(
779
+ vaultBase, sharedPath(vaultBase), sharedContent, 'projeção SHARED_MEMORY.md',
780
+ );
781
+ const candidatesWritten = writeAtomicIfChanged(
782
+ vaultBase, candidatesPath(vaultBase), candidatesContent, 'projeção MEMORY_CANDIDATES.jsonl',
783
+ );
784
+ return { ...projection, projectionsWritten: sharedWritten || candidatesWritten };
785
+ }
786
+
787
+ function publishDerivedProjection(vaultBase, allEvents) {
788
+ return publishMemoryProjection(vaultBase, prepareMemoryProjection(vaultBase, allEvents));
789
+ }
790
+
791
+ // Reconciliation needs to validate proof, perform registry CAS, and publish the exact
792
+ // prepared bytes inside one MEMORY critical section. This primitive deliberately does
793
+ // not create `.brain`: callers must complete their read-only Vault preflight first.
794
+ export const MEMORY_LOCK_BUSY = VAULT_LOCK_BUSY;
795
+ export function withMemoryLock(vaultBase, fn, options = {}) {
796
+ return withVaultPathLock(vaultBase, lockTarget(vaultBase), fn, options);
797
+ }
798
+
799
+ function projectLocked(vaultBase, { faultAt } = {}) {
800
+ const ledger = readMemoryLedger(vaultBase);
801
+ if (ledger.status !== 'ok') throw new MemoryLedgerCorruption(ledger.errors);
802
+ const outbox = readOutbox(vaultBase);
803
+ const ledgerById = new Map(ledger.events.map((item) => [item.event_id, item]));
804
+ const newEvents = [];
805
+
806
+ for (const { event: item } of outbox) {
807
+ const existing = ledgerById.get(item.event_id);
808
+ if (existing) {
809
+ if (canonicalMemoryJson(existing) !== canonicalMemoryJson(item)) throw new MemoryEventCollision(item.event_id);
810
+ continue;
811
+ }
812
+ ledgerById.set(item.event_id, item);
813
+ newEvents.push(item);
814
+ }
815
+
816
+ const allEvents = [...ledger.events, ...newEvents];
817
+ const prepared = prepareMemoryProjection(vaultBase, allEvents);
818
+ // CORE and both sidecars are checked before the append, so an unsafe derived target
819
+ // cannot partially advance the authoritative ledger.
820
+ preflightProjectionTargets(vaultBase);
821
+ appendLedgerDurably(vaultBase, ledger.path, newEvents);
822
+ injectFault(faultAt, 'after-ledger');
823
+
824
+ const projection = publishMemoryProjection(vaultBase, prepared);
825
+ injectFault(faultAt, 'after-projection');
826
+
827
+ for (const entry of outbox) {
828
+ unlinkVaultFile(vaultBase, entry.path, {
829
+ missingOk: false, label: 'evento consumido do outbox de memória',
830
+ });
831
+ }
832
+ return {
833
+ status: 'projected',
834
+ appended: newEvents.length,
835
+ consumed: outbox.length,
836
+ pending: 0,
837
+ ...projection,
838
+ };
839
+ }
840
+
841
+ /** Serialize ledger append + full deterministic replay under .brain/MEMORY.lock. */
842
+ export function projectMemoryOutbox(vaultBase, options = {}) {
843
+ mkdirVaultPath(vaultBase, brainDir(vaultBase), { label: 'raiz .brain da memória' });
844
+ const pending = countPendingOutbox(vaultBase);
845
+ const result = withVaultPathLock(
846
+ vaultBase,
847
+ lockTarget(vaultBase),
848
+ () => projectLocked(vaultBase, options),
849
+ options.lock || {},
850
+ );
851
+ if (result === VAULT_LOCK_BUSY) return { status: 'busy', pending };
852
+ return result;
853
+ }
854
+
855
+ /**
856
+ * Rebuild generated projections from the existing ledger only. This path never
857
+ * enumerates, reads, acknowledges, or consumes producer outbox files.
858
+ */
859
+ export function reprojectMemoryLedger(vaultBase, options = {}) {
860
+ mkdirVaultPath(vaultBase, brainDir(vaultBase), { label: 'raiz .brain da memória' });
861
+ const result = withVaultPathLock(vaultBase, lockTarget(vaultBase), () => {
862
+ const ledger = readMemoryLedger(vaultBase);
863
+ if (ledger.status !== 'ok') throw new MemoryLedgerCorruption(ledger.errors);
864
+ return {
865
+ status: 'reprojected',
866
+ ...publishDerivedProjection(vaultBase, ledger.events),
867
+ };
868
+ }, options.lock || {});
869
+ if (result === VAULT_LOCK_BUSY) return { status: 'busy' };
870
+ return result;
871
+ }
872
+
873
+ /** Explicit repair: preserve exact corrupt bytes, then retain every independently valid line. */
874
+ export function repairMemoryLedger(vaultBase, options = {}) {
875
+ mkdirVaultPath(vaultBase, brainDir(vaultBase), { label: 'raiz .brain da memória' });
876
+ const result = withVaultPathLock(vaultBase, lockTarget(vaultBase), () => {
877
+ const ledger = readMemoryLedger(vaultBase);
878
+ if (ledger.status === 'ok') return { status: 'unchanged', repairedLines: 0, backupPath: null };
879
+ const backupPath = `${ledger.path}.corrupt-${Date.now()}.bak`;
880
+ // copyFileSync preserves the exact evidence before any canonical rewrite.
881
+ checkedMemoryFile(vaultBase, ledger.path, 'ledger corrompido', { allowMissing: false });
882
+ checkedMemoryFile(vaultBase, backupPath, 'backup do ledger corrompido', { mustNotExist: true });
883
+ // Revalidate immediately before the exclusive copy, then verify the created inode.
884
+ checkedMemoryFile(vaultBase, ledger.path, 'ledger corrompido', { allowMissing: false });
885
+ checkedMemoryFile(vaultBase, backupPath, 'backup do ledger corrompido', { mustNotExist: true });
886
+ copyFileSync(ledger.path, backupPath, fsConstants.COPYFILE_EXCL);
887
+ checkedMemoryFile(vaultBase, backupPath, 'backup do ledger corrompido', { allowMissing: false });
888
+ const repaired = ledger.events.map((item) => canonicalMemoryJson(item)).join('\n')
889
+ + (ledger.events.length ? '\n' : '');
890
+ writeVaultFileAtomic(vaultBase, ledger.path, repaired, 'utf8', { label: 'ledger reparado' });
891
+ return {
892
+ status: 'repaired',
893
+ repairedLines: ledger.errors.length,
894
+ retainedEvents: ledger.events.length,
895
+ backupPath,
896
+ };
897
+ }, options.lock || {});
898
+ if (result === VAULT_LOCK_BUSY) return { status: 'busy', repairedLines: 0, backupPath: null };
899
+ return result;
900
+ }