wendkeep 0.66.0 → 0.66.3
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/CHANGELOG.md +36 -0
- package/README.en.md +13 -2
- package/README.md +13 -2
- package/docs/en/commands/memory-migration.md +2 -1
- package/docs/en/commands/memory.md +28 -3
- package/docs/pt-BR/commands/memory-migration.md +2 -1
- package/docs/pt-BR/commands/memory.md +27 -3
- package/hooks/brain-core.mjs +159 -159
- package/hooks/brain-recall.mjs +32 -32
- package/hooks/brain-reindex.mjs +13 -13
- package/package.json +1 -1
- package/packages/cli/src/index.mjs +1 -1
- package/packages/vault/src/memory-schema.mjs +25 -0
- package/packages/vault/src/memory-store.mjs +93 -6
- package/src/memory.mjs +335 -22
package/hooks/brain-reindex.mjs
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
// .agent/hooks/brain-reindex.mjs
|
|
2
|
-
// Backfill manual: reconstrói .brain/index.jsonl + .brain/DIGEST.md varrendo todo 02-Sessões.
|
|
3
|
-
// Uso: node .agent/hooks/brain-reindex.mjs [caminho-do-vault]
|
|
4
|
-
import { pathToFileURL } from 'node:url';
|
|
5
|
-
import { getVaultBase } from './obsidian-common.mjs';
|
|
6
|
-
import { buildBrainDigest, buildBrainIndex, brainDir } from './brain-core.mjs';
|
|
7
|
-
|
|
8
|
-
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
9
|
-
const vaultBase = getVaultBase({ obsidian_vault_path: process.argv[2] });
|
|
10
|
-
const rows = buildBrainIndex(vaultBase);
|
|
11
|
-
const digest = buildBrainDigest(vaultBase, rows);
|
|
12
|
-
process.stdout.write(`[brain] index: ${rows.length} sessões; digest: ${digest.length} linhas → ${brainDir(vaultBase)}\n`);
|
|
13
|
-
}
|
|
1
|
+
// .agent/hooks/brain-reindex.mjs
|
|
2
|
+
// Backfill manual: reconstrói .brain/index.jsonl + .brain/DIGEST.md varrendo todo 02-Sessões.
|
|
3
|
+
// Uso: node .agent/hooks/brain-reindex.mjs [caminho-do-vault]
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { getVaultBase } from './obsidian-common.mjs';
|
|
6
|
+
import { buildBrainDigest, buildBrainIndex, brainDir } from './brain-core.mjs';
|
|
7
|
+
|
|
8
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
9
|
+
const vaultBase = getVaultBase({ obsidian_vault_path: process.argv[2] });
|
|
10
|
+
const rows = buildBrainIndex(vaultBase);
|
|
11
|
+
const digest = buildBrainDigest(vaultBase, rows);
|
|
12
|
+
process.stdout.write(`[brain] index: ${rows.length} sessões; digest: ${digest.length} linhas → ${brainDir(vaultBase)}\n`);
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.66.
|
|
3
|
+
"version": "0.66.3",
|
|
4
4
|
"description": "Vault-first persistent memory for AI coding agents, with an optional profile-aware governance runtime: OFF, FLOW, GUIDE, GOVERN, or ASSURE. Local-first and agent-agnostic (Claude Code, Codex, Cursor…).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -101,7 +101,7 @@ Usage:
|
|
|
101
101
|
wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
|
|
102
102
|
wendkeep memory <sub> Shared memory v2: status | migrate [--apply] | repair |
|
|
103
103
|
reconcile <session> --by-session <session> --reason <text> [--apply] |
|
|
104
|
-
promote <candidate> | reject <candidate>. --vault P.
|
|
104
|
+
promote <candidate> [--event <event-id>] | reject <candidate>. --vault P.
|
|
105
105
|
Reconcile is dry-run by default; the original attempt remains audited.
|
|
106
106
|
wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
|
|
107
107
|
protocol (cap 25, 3 sections, no secrets/PII).
|
|
@@ -108,6 +108,31 @@ export function validateMemoryEvent(event, { projectId } = {}) {
|
|
|
108
108
|
errors.push(`project_id não pertence ao vault esperado (${projectId}).`);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
if (event.candidate_decision !== undefined) {
|
|
112
|
+
const decision = event.candidate_decision;
|
|
113
|
+
if (!decision || typeof decision !== 'object' || Array.isArray(decision)) {
|
|
114
|
+
errors.push('candidate_decision deve ser objeto.');
|
|
115
|
+
} else {
|
|
116
|
+
if (typeof decision.candidate_id !== 'string' || !decision.candidate_id) {
|
|
117
|
+
errors.push('candidate_decision.candidate_id deve ser string não vazia.');
|
|
118
|
+
}
|
|
119
|
+
if (!['promote', 'reject'].includes(decision.action)) {
|
|
120
|
+
errors.push('candidate_decision.action deve ser promote ou reject.');
|
|
121
|
+
}
|
|
122
|
+
if (!Array.isArray(decision.event_ids)
|
|
123
|
+
|| decision.event_ids.some((item) => typeof item !== 'string' || !item)) {
|
|
124
|
+
errors.push('candidate_decision.event_ids deve ser array de strings não vazias.');
|
|
125
|
+
}
|
|
126
|
+
if (decision.action === 'promote' && decision.selected_event_id !== undefined
|
|
127
|
+
&& (typeof decision.selected_event_id !== 'string' || !decision.selected_event_id)) {
|
|
128
|
+
errors.push('candidate_decision.selected_event_id deve ser string não vazia.');
|
|
129
|
+
}
|
|
130
|
+
if (decision.action === 'reject' && decision.selected_event_id !== undefined) {
|
|
131
|
+
errors.push('candidate_decision.selected_event_id não é permitido em reject.');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
111
136
|
for (const field of ['value', 'evidence']) sanitizedField(event, field, errors);
|
|
112
137
|
return { ok: errors.length === 0, errors, warnings };
|
|
113
138
|
}
|
|
@@ -344,6 +344,22 @@ function sameCausalActivation(left, right) {
|
|
|
344
344
|
&& left.activation_id === right?.activation_id;
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
+
function hasCompleteCausalIdentity(event) {
|
|
348
|
+
return Boolean(event?.canonical_session_id)
|
|
349
|
+
&& Boolean(event?.activation_id)
|
|
350
|
+
&& Boolean(event?.source_turn_id)
|
|
351
|
+
&& Number.isInteger(event?.activation_epoch)
|
|
352
|
+
&& Number.isInteger(event?.turn_sequence);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function sameCompleteCausalLineage(left, right) {
|
|
356
|
+
return hasCompleteCausalIdentity(left)
|
|
357
|
+
&& hasCompleteCausalIdentity(right)
|
|
358
|
+
&& left.canonical_session_id === right.canonical_session_id
|
|
359
|
+
&& left.activation_id === right.activation_id
|
|
360
|
+
&& left.activation_epoch === right.activation_epoch;
|
|
361
|
+
}
|
|
362
|
+
|
|
347
363
|
function comparable(left, right) {
|
|
348
364
|
if (sameCausalActivation(left, right)) return true;
|
|
349
365
|
const leftSupersedes = left.supersedes_event_id || left.supersedes;
|
|
@@ -447,7 +463,9 @@ function isCausallyOlder(event, current) {
|
|
|
447
463
|
* Pure deterministic reducer. It pre-detects incomparable scalar siblings so replay order
|
|
448
464
|
* never turns one concurrent writer into an accidental winner.
|
|
449
465
|
*/
|
|
450
|
-
export function reduceMemoryEvents(inputEvents = [], {
|
|
466
|
+
export function reduceMemoryEvents(inputEvents = [], {
|
|
467
|
+
coreInvariants = new Map(), resolveDeferredAsserts = true,
|
|
468
|
+
} = {}) {
|
|
451
469
|
const protectedValues = coreInvariants instanceof Map
|
|
452
470
|
? coreInvariants
|
|
453
471
|
: new Map(Object.entries(coreInvariants || {}));
|
|
@@ -461,6 +479,19 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
|
|
|
461
479
|
if (!existing) unique.set(event.event_id, event);
|
|
462
480
|
}
|
|
463
481
|
const events = [...unique.values()].sort(eventOrder);
|
|
482
|
+
const candidateDecisions = new Map();
|
|
483
|
+
for (const item of events) {
|
|
484
|
+
const decision = item.candidate_decision;
|
|
485
|
+
if (!decision) continue;
|
|
486
|
+
const existing = candidateDecisions.get(decision.candidate_id);
|
|
487
|
+
if (existing && canonicalMemoryJson(existing.decision) !== canonicalMemoryJson(decision)) {
|
|
488
|
+
throw new MemoryEventCollision(
|
|
489
|
+
decision.candidate_id,
|
|
490
|
+
`Ledger contains incompatible decisions for candidate ${decision.candidate_id}`,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
if (!existing) candidateDecisions.set(decision.candidate_id, { decision, event: item });
|
|
494
|
+
}
|
|
464
495
|
|
|
465
496
|
const peerGroups = new Map();
|
|
466
497
|
for (const item of events) {
|
|
@@ -482,12 +513,19 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
|
|
|
482
513
|
const records = new Map();
|
|
483
514
|
const tombstones = new Map();
|
|
484
515
|
const candidates = [];
|
|
516
|
+
const pendingAssertConflicts = [];
|
|
517
|
+
const resolvedCandidateIds = new Set();
|
|
485
518
|
const emittedGroups = new Set();
|
|
486
519
|
const appliedEventIds = [];
|
|
487
520
|
const superseded = [];
|
|
488
521
|
let revision = 0;
|
|
489
522
|
|
|
490
523
|
for (const item of events) {
|
|
524
|
+
if (item.candidate_decision && item.candidate_decision.action === 'reject') {
|
|
525
|
+
appliedEventIds.push(item.event_id);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
|
|
491
529
|
if (protectedValues.has(item.memory_key)) {
|
|
492
530
|
const coreValue = protectedValues.get(item.memory_key);
|
|
493
531
|
const agreesWithCore = item.operation === 'assert'
|
|
@@ -525,7 +563,9 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
|
|
|
525
563
|
appliedEventIds.push(item.event_id);
|
|
526
564
|
continue;
|
|
527
565
|
}
|
|
528
|
-
|
|
566
|
+
const candidate = conflictCandidate(item.memory_key, [currentEventFromRecord(current), item]);
|
|
567
|
+
candidates.push(candidate);
|
|
568
|
+
pendingAssertConflicts.push({ candidate, event: item });
|
|
529
569
|
continue;
|
|
530
570
|
}
|
|
531
571
|
if (!current) {
|
|
@@ -598,13 +638,56 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
|
|
|
598
638
|
appliedEventIds.push(item.event_id);
|
|
599
639
|
}
|
|
600
640
|
|
|
641
|
+
if (resolveDeferredAsserts) {
|
|
642
|
+
// A physically late assert can sort before a corrective promotion because effective time
|
|
643
|
+
// precedes CLI decision time. Revisit only scalar assert conflicts left without an explicit
|
|
644
|
+
// decision, against the final complete causal source; the ledger and global ordering stay put.
|
|
645
|
+
const deferredAsserts = pendingAssertConflicts
|
|
646
|
+
.filter((pending) => !candidateDecisions.has(pending.candidate.candidate_id))
|
|
647
|
+
.sort((left, right) => String(left.event.memory_key).localeCompare(String(right.event.memory_key))
|
|
648
|
+
|| String(left.event.canonical_session_id || '').localeCompare(String(right.event.canonical_session_id || ''))
|
|
649
|
+
|| String(left.event.activation_id || '').localeCompare(String(right.event.activation_id || ''))
|
|
650
|
+
|| Number(left.event.activation_epoch ?? -1) - Number(right.event.activation_epoch ?? -1)
|
|
651
|
+
|| Number(left.event.turn_sequence ?? -1) - Number(right.event.turn_sequence ?? -1)
|
|
652
|
+
|| eventOrder(left.event, right.event));
|
|
653
|
+
let advanced = true;
|
|
654
|
+
while (advanced) {
|
|
655
|
+
advanced = false;
|
|
656
|
+
for (const pending of deferredAsserts) {
|
|
657
|
+
if (resolvedCandidateIds.has(pending.candidate.candidate_id)) continue;
|
|
658
|
+
const current = records.get(pending.event.memory_key);
|
|
659
|
+
const currentSource = current?.source;
|
|
660
|
+
if (!sameCompleteCausalLineage(pending.event, currentSource)) continue;
|
|
661
|
+
if (pending.event.turn_sequence > currentSource.turn_sequence) {
|
|
662
|
+
records.set(pending.event.memory_key, {
|
|
663
|
+
value: pending.event.value,
|
|
664
|
+
revision: current.revision + 1,
|
|
665
|
+
source: pending.event,
|
|
666
|
+
});
|
|
667
|
+
tombstones.delete(pending.event.memory_key);
|
|
668
|
+
superseded.push({ event_id: currentSource.event_id, by_event_id: pending.event.event_id });
|
|
669
|
+
revision += 1;
|
|
670
|
+
appliedEventIds.push(pending.event.event_id);
|
|
671
|
+
resolvedCandidateIds.add(pending.candidate.candidate_id);
|
|
672
|
+
advanced = true;
|
|
673
|
+
} else if (pending.event.turn_sequence < currentSource.turn_sequence) {
|
|
674
|
+
superseded.push({ event_id: pending.event.event_id, by_event_id: currentSource.event_id });
|
|
675
|
+
resolvedCandidateIds.add(pending.candidate.candidate_id);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
601
681
|
const stateEntries = [...records].map(([key, record]) => [key, record.value]);
|
|
602
682
|
const recordEntries = [...records].map(([key, record]) => [key, record]);
|
|
603
683
|
const tombstoneEntries = [...tombstones];
|
|
604
684
|
const state = sortedObject(stateEntries);
|
|
605
685
|
const recordObject = sortedObject(recordEntries);
|
|
606
686
|
const tombstoneObject = sortedObject(tombstoneEntries);
|
|
607
|
-
|
|
687
|
+
const unresolvedCandidates = candidates
|
|
688
|
+
.filter((item) => !candidateDecisions.has(item.candidate_id)
|
|
689
|
+
&& !resolvedCandidateIds.has(item.candidate_id));
|
|
690
|
+
unresolvedCandidates.sort((left, right) => left.candidate_id.localeCompare(right.candidate_id));
|
|
608
691
|
superseded.sort((left, right) => left.event_id.localeCompare(right.event_id));
|
|
609
692
|
const activeEvents = Object.entries(recordObject).map(([memoryKey, record]) => ({
|
|
610
693
|
...record.source,
|
|
@@ -618,7 +701,7 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
|
|
|
618
701
|
return {
|
|
619
702
|
state,
|
|
620
703
|
records: recordObject,
|
|
621
|
-
candidates,
|
|
704
|
+
candidates: unresolvedCandidates,
|
|
622
705
|
tombstones: tombstoneObject,
|
|
623
706
|
superseded,
|
|
624
707
|
appliedEventIds,
|
|
@@ -635,8 +718,12 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
|
|
|
635
718
|
* `eventCursor` is the reducer's deterministic causal cursor; `ledgerCursor` is the
|
|
636
719
|
* physical prefix boundary used by durable checkpoints.
|
|
637
720
|
*/
|
|
638
|
-
export function deriveMemoryProjection(vaultBase, inputEvents = []
|
|
639
|
-
|
|
721
|
+
export function deriveMemoryProjection(vaultBase, inputEvents = [], {
|
|
722
|
+
resolveDeferredAsserts = true,
|
|
723
|
+
} = {}) {
|
|
724
|
+
const reduced = reduceMemoryEvents(inputEvents, {
|
|
725
|
+
coreInvariants: readCoreInvariants(vaultBase), resolveDeferredAsserts,
|
|
726
|
+
});
|
|
640
727
|
const ledgerCursor = inputEvents.at(-1)?.event_id || 'none';
|
|
641
728
|
const checkpoint = {
|
|
642
729
|
revision: reduced.revision,
|
package/src/memory.mjs
CHANGED
|
@@ -237,37 +237,272 @@ function readCandidates(vault) {
|
|
|
237
237
|
.split('\n').filter(Boolean).map((line) => JSON.parse(line));
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
-
|
|
240
|
+
function priorCandidateDecision(vault, candidateId) {
|
|
241
|
+
return readMemoryLedger(vault).events.find(
|
|
242
|
+
(event) => event.candidate_decision?.candidate_id === candidateId,
|
|
243
|
+
) || null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function candidateEvent(candidate, eventId) {
|
|
247
|
+
const events = Array.isArray(candidate.events) ? candidate.events : [];
|
|
248
|
+
if (candidate.reason === 'conflict' && !eventId) {
|
|
249
|
+
throw new Error('Candidate de conflito exige eventId (--event na CLI).');
|
|
250
|
+
}
|
|
251
|
+
if (!eventId) return events.length === 1 ? events[0] : null;
|
|
252
|
+
const selected = events.find((event) => event.event_id === eventId);
|
|
253
|
+
if (!selected) throw new Error(`event_id ${eventId} não pertence ao candidate ${candidate.candidate_id}.`);
|
|
254
|
+
return selected;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function assertCompatibleDecision(prior, { action, eventId }) {
|
|
258
|
+
const decision = prior.candidate_decision;
|
|
259
|
+
const sameSelection = action !== 'promote'
|
|
260
|
+
|| (decision.selected_event_id || null) === (eventId || null);
|
|
261
|
+
if (decision.action !== action || !sameSelection) {
|
|
262
|
+
throw new Error(`Candidate ${decision.candidate_id} já possui decisão incompatível (${decision.action}).`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function promotedMemoryValue(value) {
|
|
267
|
+
return typeof value === 'string' ? sanitizeMemoryText(value) : cloneJson(value);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function eventSupersedes(event) {
|
|
271
|
+
if (Array.isArray(event?.supersedes)) return event.supersedes;
|
|
272
|
+
return event?.supersedes_event_id ? [event.supersedes_event_id] : [];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function hasCompleteCausalIdentity(event) {
|
|
276
|
+
return Boolean(event?.canonical_session_id && event?.activation_id && event?.source_turn_id)
|
|
277
|
+
&& Number.isInteger(event.activation_epoch)
|
|
278
|
+
&& Number.isInteger(event.turn_sequence);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function sameCausalLineage(left, right) {
|
|
282
|
+
return hasCompleteCausalIdentity(left) && hasCompleteCausalIdentity(right)
|
|
283
|
+
&& left.canonical_session_id === right.canonical_session_id
|
|
284
|
+
&& left.activation_id === right.activation_id
|
|
285
|
+
&& left.activation_epoch === right.activation_epoch;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function promotedSupersedes(vault, candidate, selected) {
|
|
289
|
+
const memberIds = new Set(Array.isArray(candidate.event_ids) ? candidate.event_ids : []);
|
|
290
|
+
const ledger = readMemoryLedger(vault);
|
|
291
|
+
if (ledger.status !== 'ok') throw new Error('Ledger de memória inválido durante a promoção.');
|
|
292
|
+
const current = deriveMemoryProjection(vault, ledger.events)
|
|
293
|
+
.records?.[candidate.memory_key]?.source;
|
|
294
|
+
if (!current?.event_id || memberIds.has(current.event_id)) return [...memberIds].sort();
|
|
295
|
+
|
|
296
|
+
const currentSelectedId = current.candidate_decision?.selected_event_id;
|
|
297
|
+
const currentSelected = currentSelectedId
|
|
298
|
+
? ledger.events.find((event) => event.event_id === currentSelectedId)
|
|
299
|
+
: null;
|
|
300
|
+
const selectedLedger = ledger.events.find((event) => event.event_id === selected.event_id);
|
|
301
|
+
const currentAncestors = eventSupersedes(current);
|
|
302
|
+
const decisionMembers = Array.isArray(current.candidate_decision?.event_ids)
|
|
303
|
+
? current.candidate_decision.event_ids
|
|
304
|
+
: [];
|
|
305
|
+
const canonicalAncestors = [...new Set(currentAncestors)].sort();
|
|
306
|
+
const canonicalDecisionMembers = [...new Set(decisionMembers)].sort();
|
|
307
|
+
const currentObserved = String(current.effective_at || current.observed_at || '');
|
|
308
|
+
const selectedObserved = String(selectedLedger?.effective_at || selectedLedger?.observed_at || '');
|
|
309
|
+
const currentPhysicalIndex = ledger.events.findIndex((event) => event.event_id === current.event_id);
|
|
310
|
+
const selectedPhysicalIndex = ledger.events.findIndex((event) => event.event_id === selected.event_id);
|
|
311
|
+
const bridgeChecks = {
|
|
312
|
+
legacyShape: current.operation === 'replace' && current.authority === 'verified'
|
|
313
|
+
&& !current.canonical_session_id && !current.source_turn_id,
|
|
314
|
+
promotion: current.candidate_decision?.action === 'promote',
|
|
315
|
+
selectedPresent: Boolean(currentSelected),
|
|
316
|
+
selectedPersisted: Boolean(selectedLedger),
|
|
317
|
+
exactDecisionMembers: canonicalAncestors.length === currentAncestors.length
|
|
318
|
+
&& canonicalDecisionMembers.length === decisionMembers.length
|
|
319
|
+
&& canonicalAncestors.length === canonicalDecisionMembers.length
|
|
320
|
+
&& canonicalAncestors.every((eventId, index) => eventId === canonicalDecisionMembers[index]),
|
|
321
|
+
selectedSuperseded: currentAncestors.includes(currentSelectedId)
|
|
322
|
+
&& decisionMembers.includes(currentSelectedId),
|
|
323
|
+
candidateAncestor: currentAncestors.some((eventId) => memberIds.has(eventId)),
|
|
324
|
+
sameLineage: sameCausalLineage(currentSelected, selectedLedger),
|
|
325
|
+
laterTurn: selectedLedger?.turn_sequence > currentSelected?.turn_sequence,
|
|
326
|
+
appendedAfterCurrent: selectedPhysicalIndex > currentPhysicalIndex && currentPhysicalIndex >= 0,
|
|
327
|
+
observedBeforeCurrent: selectedObserved < currentObserved,
|
|
328
|
+
};
|
|
329
|
+
const bridgesObservedOrder = Object.values(bridgeChecks).every(Boolean);
|
|
330
|
+
if (!bridgesObservedOrder) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
`Candidate ${candidate.candidate_id} não corresponde mais à projeção causal atual; `
|
|
333
|
+
+ 'recarregue os candidates antes de promover.',
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
memberIds.add(current.event_id);
|
|
337
|
+
return [...memberIds].sort();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function matchesPromotedAttempt(attempt, selected) {
|
|
341
|
+
if (attempt?.memory_mode !== 'v2' || attempt.state !== 'projected'
|
|
342
|
+
|| !Array.isArray(attempt.event_ids) || !attempt.event_ids.includes(selected.event_id)) return false;
|
|
343
|
+
if (attempt.activation_id && selected.activation_id
|
|
344
|
+
&& attempt.activation_id !== selected.activation_id) return false;
|
|
345
|
+
if (Number.isInteger(attempt.activation_epoch) && Number.isInteger(selected.activation_epoch)
|
|
346
|
+
&& attempt.activation_epoch !== selected.activation_epoch) return false;
|
|
347
|
+
if (Number.isInteger(attempt.turn_sequence) && Number.isInteger(selected.turn_sequence)
|
|
348
|
+
&& attempt.turn_sequence !== selected.turn_sequence) return false;
|
|
349
|
+
if (attempt.canonical_session_id && selected.canonical_session_id
|
|
350
|
+
&& attempt.canonical_session_id !== selected.canonical_session_id) return false;
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function snapshotPromotedAttemptCheckpoints(vault, selected) {
|
|
355
|
+
if (!selected) return new Map();
|
|
356
|
+
const registry = readSessionRegistry(vault);
|
|
357
|
+
return new Map(Object.entries(registry.sessions || {})
|
|
358
|
+
.filter(([, entry]) => matchesPromotedAttempt(entry?.last_memory_attempt, selected))
|
|
359
|
+
.map(([sessionId, entry]) => [sessionId, {
|
|
360
|
+
attempt: attemptFingerprint(entry.last_memory_attempt),
|
|
361
|
+
checkpoint: memoryCheckpointFingerprint(entry),
|
|
362
|
+
}]));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function refreshPromotedAttemptCheckpoint(vault, {
|
|
366
|
+
candidateId, decisionEventId, selected, checkpoint, decidedAt, expectedAttempts,
|
|
367
|
+
}) {
|
|
368
|
+
if (!selected || !checkpoint || !expectedAttempts?.size) return 0;
|
|
369
|
+
return mutateSessionRegistry(vault, (registry) => {
|
|
370
|
+
let refreshed = 0;
|
|
371
|
+
for (const [sessionId, entry] of Object.entries(registry.sessions || {})) {
|
|
372
|
+
const expected = expectedAttempts.get(sessionId);
|
|
373
|
+
if (!expected) continue;
|
|
374
|
+
const attempt = entry?.last_memory_attempt;
|
|
375
|
+
if (attemptFingerprint(attempt) !== expected.attempt
|
|
376
|
+
|| memoryCheckpointFingerprint(entry) !== expected.checkpoint) continue;
|
|
377
|
+
|
|
378
|
+
const alreadyAudited = (entry.memory_candidate_decisions || [])
|
|
379
|
+
.some((audit) => audit.decision_event_id === decisionEventId);
|
|
380
|
+
if (alreadyAudited && sameCheckpoint(attempt.checkpoint, checkpoint)
|
|
381
|
+
&& sameCheckpoint(entry.memory_checkpoint, checkpoint)) continue;
|
|
382
|
+
const originalCheckpoint = cloneJson(attempt.checkpoint || entry.memory_checkpoint || null);
|
|
383
|
+
attempt.checkpoint = cloneJson(checkpoint);
|
|
384
|
+
entry.memory_checkpoint = cloneJson(checkpoint);
|
|
385
|
+
entry.memory_status = 'projected';
|
|
386
|
+
if (!alreadyAudited) {
|
|
387
|
+
entry.memory_candidate_decisions = [
|
|
388
|
+
...(Array.isArray(entry.memory_candidate_decisions) ? entry.memory_candidate_decisions : []),
|
|
389
|
+
{
|
|
390
|
+
v: 1,
|
|
391
|
+
type: 'candidate_checkpoint_refreshed',
|
|
392
|
+
candidate_id: candidateId,
|
|
393
|
+
decision_event_id: decisionEventId,
|
|
394
|
+
selected_event_id: selected.event_id,
|
|
395
|
+
decided_at: decidedAt,
|
|
396
|
+
original_checkpoint: originalCheckpoint,
|
|
397
|
+
checkpoint: cloneJson(checkpoint),
|
|
398
|
+
},
|
|
399
|
+
];
|
|
400
|
+
}
|
|
401
|
+
refreshed += 1;
|
|
402
|
+
}
|
|
403
|
+
return refreshed;
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function decideMemoryCandidate(vault, {
|
|
408
|
+
action, candidateId, value, eventId, beforeCheckpointRefresh,
|
|
409
|
+
} = {}) {
|
|
241
410
|
if (!['promote', 'reject'].includes(action)) throw new TypeError('action deve ser promote ou reject.');
|
|
242
411
|
if (!candidateId) throw new TypeError('candidateId é obrigatório.');
|
|
412
|
+
const preflight = projectMemoryOutbox(vault);
|
|
413
|
+
if (preflight.status === 'busy') return { status: 'busy', candidateId };
|
|
414
|
+
const prior = priorCandidateDecision(vault, candidateId);
|
|
415
|
+
if (prior) {
|
|
416
|
+
assertCompatibleDecision(prior, { action, eventId });
|
|
417
|
+
const selected = action === 'promote' && prior.candidate_decision.selected_event_id
|
|
418
|
+
? readMemoryLedger(vault).events.find(
|
|
419
|
+
(event) => event.event_id === prior.candidate_decision.selected_event_id,
|
|
420
|
+
)
|
|
421
|
+
: null;
|
|
422
|
+
const expectedAttempts = snapshotPromotedAttemptCheckpoints(vault, selected);
|
|
423
|
+
beforeCheckpointRefresh?.();
|
|
424
|
+
const checkpointRefreshed = action === 'promote'
|
|
425
|
+
? refreshPromotedAttemptCheckpoint(vault, {
|
|
426
|
+
candidateId,
|
|
427
|
+
decisionEventId: prior.event_id,
|
|
428
|
+
selected,
|
|
429
|
+
checkpoint: preflight.checkpoint,
|
|
430
|
+
decidedAt: prior.observed_at,
|
|
431
|
+
expectedAttempts,
|
|
432
|
+
})
|
|
433
|
+
: 0;
|
|
434
|
+
return {
|
|
435
|
+
status: action === 'promote' ? 'promoted' : 'rejected',
|
|
436
|
+
candidateId,
|
|
437
|
+
eventId: prior.event_id,
|
|
438
|
+
alreadyApplied: true,
|
|
439
|
+
checkpointRefreshed,
|
|
440
|
+
projection: preflight,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
243
443
|
const candidates = readCandidates(vault);
|
|
244
444
|
const candidate = candidates.find((item) => item.candidate_id === candidateId);
|
|
245
445
|
if (!candidate) throw new Error(`Candidate não encontrado: ${candidateId}`);
|
|
446
|
+
if (action === 'promote' && candidate.reason === 'blocked_by_core') {
|
|
447
|
+
throw new Error(`Candidate ${candidateId} está blocked_by_core; edite CORE ou rejeite o candidate.`);
|
|
448
|
+
}
|
|
449
|
+
const selected = action === 'promote' ? candidateEvent(candidate, eventId) : null;
|
|
450
|
+
const expectedAttempts = snapshotPromotedAttemptCheckpoints(vault, selected);
|
|
451
|
+
const selectedValue = selected?.value ?? value ?? candidate.value ?? candidate.proposed_value;
|
|
452
|
+
if (action === 'promote' && selectedValue === undefined) {
|
|
453
|
+
throw new Error(`Candidate ${candidateId} não contém valor promovível.`);
|
|
454
|
+
}
|
|
455
|
+
const supersedes = action === 'promote' && selected
|
|
456
|
+
? promotedSupersedes(vault, candidate, selected)
|
|
457
|
+
: [];
|
|
246
458
|
const now = new Date().toISOString();
|
|
459
|
+
const decision = {
|
|
460
|
+
candidate_id: candidateId,
|
|
461
|
+
action,
|
|
462
|
+
event_ids: Array.isArray(candidate.event_ids) ? [...candidate.event_ids].sort() : [],
|
|
463
|
+
...(selected ? { selected_event_id: selected.event_id } : {}),
|
|
464
|
+
};
|
|
247
465
|
const event = {
|
|
248
466
|
v: 1,
|
|
249
|
-
event_id: `cli-${action}-${hash(candidateId).slice(0, 20)}`,
|
|
467
|
+
event_id: `cli-${action}-${hash(`${candidateId}\0${selected?.event_id || ''}`).slice(0, 20)}`,
|
|
250
468
|
project_id: projectId(vault),
|
|
251
|
-
memory_key: action === 'promote' ? candidate.memory_key : `candidate.
|
|
252
|
-
operation: 'assert',
|
|
253
|
-
value:
|
|
469
|
+
memory_key: action === 'promote' ? candidate.memory_key : `candidate.decision.${candidateId}`,
|
|
470
|
+
operation: action === 'promote' && selected ? 'replace' : 'assert',
|
|
471
|
+
value: action === 'promote' ? promotedMemoryValue(selectedValue) : 'rejected',
|
|
254
472
|
authority: 'verified',
|
|
255
|
-
|
|
256
|
-
|
|
473
|
+
...(selected?.canonical_session_id
|
|
474
|
+
? { canonical_session_id: selected.canonical_session_id }
|
|
475
|
+
: {}),
|
|
476
|
+
activation_id: selected?.activation_id || 'wendkeep-memory-cli',
|
|
477
|
+
...(Number.isInteger(selected?.activation_epoch) ? { activation_epoch: selected.activation_epoch } : {}),
|
|
478
|
+
turn_sequence: selected?.turn_sequence ?? 0,
|
|
479
|
+
...(selected?.source_turn_id ? { source_turn_id: selected.source_turn_id } : {}),
|
|
257
480
|
observed_at: now,
|
|
258
481
|
evidence: [`candidate:${candidateId}`],
|
|
482
|
+
candidate_decision: decision,
|
|
483
|
+
...(selected ? { supersedes } : {}),
|
|
259
484
|
};
|
|
260
485
|
enqueueMemoryEvent(vault, event);
|
|
261
486
|
const projection = projectMemoryOutbox(vault);
|
|
262
487
|
if (projection.status === 'busy') return { status: 'busy', candidateId };
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
488
|
+
beforeCheckpointRefresh?.();
|
|
489
|
+
const checkpointRefreshed = action === 'promote'
|
|
490
|
+
? refreshPromotedAttemptCheckpoint(vault, {
|
|
491
|
+
candidateId,
|
|
492
|
+
decisionEventId: event.event_id,
|
|
493
|
+
selected,
|
|
494
|
+
checkpoint: projection.checkpoint,
|
|
495
|
+
decidedAt: now,
|
|
496
|
+
expectedAttempts,
|
|
497
|
+
})
|
|
498
|
+
: 0;
|
|
499
|
+
return {
|
|
500
|
+
status: action === 'promote' ? 'promoted' : 'rejected',
|
|
501
|
+
candidateId,
|
|
502
|
+
eventId: event.event_id,
|
|
503
|
+
checkpointRefreshed,
|
|
504
|
+
projection,
|
|
505
|
+
};
|
|
271
506
|
}
|
|
272
507
|
|
|
273
508
|
function cloneJson(value) {
|
|
@@ -778,6 +1013,67 @@ function historicalAssertOnlyCheckpoint(vault, attempt, authority, checkpoint) {
|
|
|
778
1013
|
return currentPrefix.checkpoint;
|
|
779
1014
|
}
|
|
780
1015
|
|
|
1016
|
+
function deferredAssertReplayCheckpointMigration(
|
|
1017
|
+
sessionId, entry, authority, legacyReplay, fullReplay,
|
|
1018
|
+
) {
|
|
1019
|
+
const attempt = entry?.last_memory_attempt;
|
|
1020
|
+
const checkpoint = attempt?.checkpoint;
|
|
1021
|
+
if (attempt?.memory_mode !== 'v2' || attempt?.state !== 'projected'
|
|
1022
|
+
|| attempt?.disposition !== 'applied' || !checkpointShape(checkpoint)) return null;
|
|
1023
|
+
if (!Object.prototype.hasOwnProperty.call(entry, 'memory_checkpoint')
|
|
1024
|
+
|| !sameCheckpoint(entry.memory_checkpoint, checkpoint)
|
|
1025
|
+
|| !sameCheckpoint(checkpoint, legacyReplay.checkpoint)
|
|
1026
|
+
|| sameCheckpoint(checkpoint, fullReplay.checkpoint)) return null;
|
|
1027
|
+
|
|
1028
|
+
const requiredEventIds = Array.isArray(attempt.event_ids) ? [...attempt.event_ids] : [];
|
|
1029
|
+
if (!requiredEventIds.length || new Set(requiredEventIds).size !== requiredEventIds.length) return null;
|
|
1030
|
+
const current = fullReplay.records?.['handoff.latest']?.source;
|
|
1031
|
+
if (!current?.event_id || !requiredEventIds.includes(current.event_id)) return null;
|
|
1032
|
+
const relevantLegacyCandidate = legacyReplay.candidates.some(
|
|
1033
|
+
(candidate) => candidate.memory_key === 'handoff.latest'
|
|
1034
|
+
&& candidate.event_ids.includes(current.event_id),
|
|
1035
|
+
);
|
|
1036
|
+
const relevantCandidateStillReal = fullReplay.candidates.some(
|
|
1037
|
+
(candidate) => candidate.memory_key === 'handoff.latest',
|
|
1038
|
+
);
|
|
1039
|
+
if (!relevantLegacyCandidate || relevantCandidateStillReal) return null;
|
|
1040
|
+
|
|
1041
|
+
const currentIdentity = {
|
|
1042
|
+
canonical_session_id: current.canonical_session_id,
|
|
1043
|
+
activation_id: current.activation_id,
|
|
1044
|
+
activation_epoch: current.activation_epoch,
|
|
1045
|
+
source_turn_id: current.source_turn_id,
|
|
1046
|
+
turn_sequence: current.turn_sequence,
|
|
1047
|
+
};
|
|
1048
|
+
if (currentIdentity.canonical_session_id !== sessionId
|
|
1049
|
+
|| attempt.canonical_session_id !== currentIdentity.canonical_session_id
|
|
1050
|
+
|| attempt.activation_id !== currentIdentity.activation_id
|
|
1051
|
+
|| attempt.activation_epoch !== currentIdentity.activation_epoch
|
|
1052
|
+
|| attempt.turn_id !== currentIdentity.source_turn_id
|
|
1053
|
+
|| attempt.turn_sequence !== currentIdentity.turn_sequence) return null;
|
|
1054
|
+
|
|
1055
|
+
const newerAttemptEvent = authority.ledgerEvents.some((event) => (
|
|
1056
|
+
event.canonical_session_id === currentIdentity.canonical_session_id
|
|
1057
|
+
&& event.activation_id === currentIdentity.activation_id
|
|
1058
|
+
&& event.activation_epoch === currentIdentity.activation_epoch
|
|
1059
|
+
&& Number.isInteger(event.turn_sequence)
|
|
1060
|
+
&& event.turn_sequence > currentIdentity.turn_sequence
|
|
1061
|
+
));
|
|
1062
|
+
if (newerAttemptEvent) return null;
|
|
1063
|
+
|
|
1064
|
+
const proof = validateSuccessorProof({ bySessionId: sessionId }, attempt, authority);
|
|
1065
|
+
return {
|
|
1066
|
+
sessionId,
|
|
1067
|
+
expectedFingerprint: attemptFingerprint(attempt),
|
|
1068
|
+
expectedMemoryCheckpointFingerprint: memoryCheckpointFingerprint(entry),
|
|
1069
|
+
originalCheckpoint: cloneJson(checkpoint),
|
|
1070
|
+
checkpoint: cloneJson(fullReplay.checkpoint),
|
|
1071
|
+
proof,
|
|
1072
|
+
migrationType: 'deferred_assert_replay_migrated',
|
|
1073
|
+
eventIds: requiredEventIds,
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
|
|
781
1077
|
function legacyCheckpointMigration(vault, sessionId, entry, authority, fullReplay) {
|
|
782
1078
|
const attempt = entry?.last_memory_attempt;
|
|
783
1079
|
const checkpoint = attempt?.checkpoint;
|
|
@@ -807,6 +1103,8 @@ function legacyCheckpointMigration(vault, sessionId, entry, authority, fullRepla
|
|
|
807
1103
|
originalCheckpoint: cloneJson(checkpoint),
|
|
808
1104
|
checkpoint: cloneJson(nextCheckpoint),
|
|
809
1105
|
proof,
|
|
1106
|
+
migrationType: 'legacy_causal_checkpoint_migrated',
|
|
1107
|
+
eventIds: requiredEventIds,
|
|
810
1108
|
};
|
|
811
1109
|
}
|
|
812
1110
|
|
|
@@ -818,11 +1116,14 @@ export function migrateLegacyMemoryCheckpoints(vault, {
|
|
|
818
1116
|
const authority = readMemoryAuthority(vault);
|
|
819
1117
|
assertAuthorityMatches(expectedAuthority, authority);
|
|
820
1118
|
const inspected = readSessionRegistry(vault);
|
|
1119
|
+
const legacyReplay = deriveMemoryProjection(vault, authority.ledgerEvents, {
|
|
1120
|
+
resolveDeferredAsserts: false,
|
|
1121
|
+
});
|
|
821
1122
|
const fullReplay = deriveMemoryProjection(vault, authority.ledgerEvents);
|
|
822
1123
|
const plans = Object.entries(inspected.sessions || {})
|
|
823
|
-
.map(([sessionId, entry]) =>
|
|
824
|
-
|
|
825
|
-
))
|
|
1124
|
+
.map(([sessionId, entry]) => deferredAssertReplayCheckpointMigration(
|
|
1125
|
+
sessionId, entry, authority, legacyReplay, fullReplay,
|
|
1126
|
+
) || legacyCheckpointMigration(vault, sessionId, entry, authority, fullReplay))
|
|
826
1127
|
.filter(Boolean);
|
|
827
1128
|
assertAuthorityMatches(expectedAuthority, readMemoryAuthority(vault));
|
|
828
1129
|
if (!plans.length) return {
|
|
@@ -861,14 +1162,20 @@ export function migrateLegacyMemoryCheckpoints(vault, {
|
|
|
861
1162
|
|
|
862
1163
|
for (const plan of plans) {
|
|
863
1164
|
const entry = registry.sessions[plan.sessionId];
|
|
864
|
-
const
|
|
1165
|
+
const reconciliationSeed = plan.migrationType === 'legacy_causal_checkpoint_migrated'
|
|
1166
|
+
? `${plan.sessionId}\0${plan.expectedFingerprint}\0${plan.expectedMemoryCheckpointFingerprint}\0${canonicalMemoryJson(plan.checkpoint)}`
|
|
1167
|
+
: `${plan.migrationType}\0${plan.sessionId}\0${plan.expectedFingerprint}\0${plan.expectedMemoryCheckpointFingerprint}\0${canonicalMemoryJson(plan.checkpoint)}`;
|
|
1168
|
+
const reconciliationId = `memcp-${hash(reconciliationSeed).slice(0, 20)}`;
|
|
865
1169
|
entry.memory_reconciliations = [
|
|
866
1170
|
...(Array.isArray(entry.memory_reconciliations) ? entry.memory_reconciliations : []),
|
|
867
1171
|
{
|
|
868
1172
|
v: 1,
|
|
869
1173
|
reconciliation_id: reconciliationId,
|
|
870
|
-
type:
|
|
1174
|
+
type: plan.migrationType,
|
|
871
1175
|
reconciled_at: now,
|
|
1176
|
+
...(plan.migrationType === 'deferred_assert_replay_migrated'
|
|
1177
|
+
? { event_ids: [...plan.eventIds] }
|
|
1178
|
+
: {}),
|
|
872
1179
|
causal_proof: cloneJson(plan.proof),
|
|
873
1180
|
original_checkpoint: cloneJson(plan.originalCheckpoint),
|
|
874
1181
|
checkpoint: cloneJson(plan.checkpoint),
|
|
@@ -1038,8 +1345,14 @@ export function runMemory(argv) {
|
|
|
1038
1345
|
apply: reconcileArgs.apply,
|
|
1039
1346
|
});
|
|
1040
1347
|
}
|
|
1041
|
-
else if (sub === 'promote' || sub === 'reject')
|
|
1042
|
-
|
|
1348
|
+
else if (sub === 'promote' || sub === 'reject') {
|
|
1349
|
+
const eventId = option(argv, '--event');
|
|
1350
|
+
if (sub === 'reject' && eventId) throw memoryUsageError('--event é permitido somente em memory promote.');
|
|
1351
|
+
result = decideMemoryCandidate(vault, {
|
|
1352
|
+
action: sub, candidateId: positional, ...(eventId ? { eventId } : {}),
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
else { process.stderr.write('wendkeep memory: use status | migrate [--apply] | repair | reconcile <session> --by-session <session> --reason <text> [--apply] | promote <candidate> [--event <event-id>] | reject <candidate>.\n'); process.exitCode = 2; return; }
|
|
1043
1356
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
1044
1357
|
if (sub === 'status' && argv.includes('--gate')) process.exitCode = result.status === 'blocked' ? 1 : 0;
|
|
1045
1358
|
else if (sub === 'reconcile' && reconcileArgs.apply) process.exitCode = result.health?.status === 'blocked' ? 1 : 0;
|