wendkeep 0.66.1 → 0.66.4

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/src/memory.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import {
3
3
  constants as fsConstants, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync,
4
- rmSync, writeFileSync,
4
+ readdirSync, rmSync, statSync, writeFileSync,
5
5
  } from 'node:fs';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { sanitizeMemoryText, renderSharedMemory, validateSharedMemory } from '../hooks/memory-schema.mjs';
@@ -263,6 +263,80 @@ function assertCompatibleDecision(prior, { action, eventId }) {
263
263
  }
264
264
  }
265
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
+
266
340
  function matchesPromotedAttempt(attempt, selected) {
267
341
  if (attempt?.memory_mode !== 'v2' || attempt.state !== 'projected'
268
342
  || !Array.isArray(attempt.event_ids) || !attempt.event_ids.includes(selected.event_id)) return false;
@@ -378,6 +452,9 @@ export function decideMemoryCandidate(vault, {
378
452
  if (action === 'promote' && selectedValue === undefined) {
379
453
  throw new Error(`Candidate ${candidateId} não contém valor promovível.`);
380
454
  }
455
+ const supersedes = action === 'promote' && selected
456
+ ? promotedSupersedes(vault, candidate, selected)
457
+ : [];
381
458
  const now = new Date().toISOString();
382
459
  const decision = {
383
460
  candidate_id: candidateId,
@@ -391,15 +468,19 @@ export function decideMemoryCandidate(vault, {
391
468
  project_id: projectId(vault),
392
469
  memory_key: action === 'promote' ? candidate.memory_key : `candidate.decision.${candidateId}`,
393
470
  operation: action === 'promote' && selected ? 'replace' : 'assert',
394
- value: sanitizeMemoryText(action === 'promote' ? selectedValue : 'rejected'),
471
+ value: action === 'promote' ? promotedMemoryValue(selectedValue) : 'rejected',
395
472
  authority: 'verified',
473
+ ...(selected?.canonical_session_id
474
+ ? { canonical_session_id: selected.canonical_session_id }
475
+ : {}),
396
476
  activation_id: selected?.activation_id || 'wendkeep-memory-cli',
397
477
  ...(Number.isInteger(selected?.activation_epoch) ? { activation_epoch: selected.activation_epoch } : {}),
398
478
  turn_sequence: selected?.turn_sequence ?? 0,
479
+ ...(selected?.source_turn_id ? { source_turn_id: selected.source_turn_id } : {}),
399
480
  observed_at: now,
400
481
  evidence: [`candidate:${candidateId}`],
401
482
  candidate_decision: decision,
402
- ...(selected ? { supersedes: decision.event_ids } : {}),
483
+ ...(selected ? { supersedes } : {}),
403
484
  };
404
485
  enqueueMemoryEvent(vault, event);
405
486
  const projection = projectMemoryOutbox(vault);
@@ -457,6 +538,520 @@ function memoryCheckpointFingerprint(entry) {
457
538
  }));
458
539
  }
459
540
 
541
+ function fieldSnapshot(value, key) {
542
+ const present = Object.prototype.hasOwnProperty.call(value || {}, key);
543
+ return { present, value: present ? (value[key] ?? null) : null };
544
+ }
545
+
546
+ function attemptAuthorityFingerprint(entry, attempt) {
547
+ const activation = entry?.activations?.[attempt?.activation_id];
548
+ return hash(canonicalMemoryJson({
549
+ status: fieldSnapshot(entry, 'status'),
550
+ active_activation_id: fieldSnapshot(entry, 'active_activation_id'),
551
+ activation_epoch: fieldSnapshot(entry, 'activation_epoch'),
552
+ last_turn_id: fieldSnapshot(entry, 'last_turn_id'),
553
+ last_turn_sequence: fieldSnapshot(entry, 'last_turn_sequence'),
554
+ turn_sequence: fieldSnapshot(entry?.turn_sequences, attempt?.turn_id),
555
+ activation: {
556
+ status: fieldSnapshot(activation, 'status'),
557
+ epoch: fieldSnapshot(activation, 'epoch'),
558
+ last_stop_turn_id: fieldSnapshot(activation, 'last_stop_turn_id'),
559
+ last_stop_turn_sequence: fieldSnapshot(activation, 'last_stop_turn_sequence'),
560
+ last_turn_sequence: fieldSnapshot(activation, 'last_turn_sequence'),
561
+ },
562
+ memory_status: fieldSnapshot(entry, 'memory_status'),
563
+ memory_activation_id: fieldSnapshot(entry, 'memory_activation_id'),
564
+ }));
565
+ }
566
+
567
+ function ownsAttemptContext(entry, attempt) {
568
+ const activation = entry?.activations?.[attempt?.activation_id];
569
+ return entry?.status === 'active'
570
+ && entry.active_activation_id === attempt.activation_id
571
+ && entry.activation_epoch === attempt.activation_epoch
572
+ && activation?.status === 'active'
573
+ && activation.epoch === attempt.activation_epoch
574
+ && entry.last_turn_id === attempt.turn_id
575
+ && entry.last_turn_sequence === attempt.turn_sequence
576
+ && entry.turn_sequences?.[attempt.turn_id] === attempt.turn_sequence
577
+ && activation.last_stop_turn_id === attempt.turn_id
578
+ && activation.last_stop_turn_sequence === attempt.turn_sequence
579
+ && activation.last_turn_sequence === attempt.turn_sequence
580
+ && entry.memory_status === attempt.state
581
+ && entry.memory_activation_id === attempt.activation_id;
582
+ }
583
+
584
+ function attemptEventMatches(event, attempt, expectedProjectId) {
585
+ return event?.project_id === expectedProjectId
586
+ && event.canonical_session_id === attempt.canonical_session_id
587
+ && event.activation_id === attempt.activation_id
588
+ && event.activation_epoch === attempt.activation_epoch
589
+ && event.source_turn_id === attempt.turn_id
590
+ && event.turn_sequence === attempt.turn_sequence;
591
+ }
592
+
593
+ function readAttemptOutboxEvents(vault, eventIds) {
594
+ const events = [];
595
+ for (const eventId of eventIds) {
596
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(eventId)) return null;
597
+ const path = brainPath(vault, join('memory-outbox', `${eventId}.json`));
598
+ const checked = checkedVaultFile(vault, path, `outbox do attempt ${eventId}`);
599
+ if (!checked.exists) return null;
600
+ try {
601
+ const event = JSON.parse(readVaultFile(
602
+ vault, checked.target, 'utf8', `outbox do attempt ${eventId}`,
603
+ ));
604
+ if (event?.event_id !== eventId) return null;
605
+ events.push(event);
606
+ } catch (error) {
607
+ if (error?.code === 'VAULT_PATH_UNSAFE') throw error;
608
+ return null;
609
+ }
610
+ }
611
+ return events;
612
+ }
613
+
614
+ function targetOutboxIsAbsent(vault, eventIds) {
615
+ return eventIds.every((eventId) => {
616
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(eventId)) return false;
617
+ const path = brainPath(vault, join('memory-outbox', `${eventId}.json`));
618
+ return !checkedVaultFile(vault, path, `outbox do attempt ${eventId}`).exists;
619
+ });
620
+ }
621
+
622
+ function freezeRepairAttemptAcknowledgements(vault, options = {}) {
623
+ const result = withMemoryLock(vault, () => {
624
+ const expectedProjectId = projectId(vault);
625
+ const registry = readSessionRegistry(vault);
626
+ const frozen = [];
627
+ let pending = false;
628
+ for (const [sessionId, entry] of Object.entries(registry.sessions || {})) {
629
+ const attempt = entry?.last_memory_attempt;
630
+ const eventIds = Array.isArray(attempt?.event_ids) ? [...attempt.event_ids] : [];
631
+ if (attempt?.memory_mode !== 'v2' || attempt.disposition !== 'applied'
632
+ || !['enqueued', 'degraded'].includes(attempt.state)) continue;
633
+ pending = true;
634
+ if (!eventIds.length || new Set(eventIds).size !== eventIds.length
635
+ || eventIds.some((eventId) => typeof eventId !== 'string' || !eventId)
636
+ || sessionId !== attempt.canonical_session_id
637
+ || !ownsAttemptContext(entry, attempt)) continue;
638
+ const outboxEvents = readAttemptOutboxEvents(vault, eventIds);
639
+ if (!outboxEvents
640
+ || outboxEvents.some((event) => !attemptEventMatches(event, attempt, expectedProjectId))) {
641
+ continue;
642
+ }
643
+ frozen.push({
644
+ sessionId,
645
+ projectId: expectedProjectId,
646
+ eventIds,
647
+ eventFingerprints: new Map(outboxEvents.map((event) => [
648
+ event.event_id, hash(canonicalMemoryJson(event)),
649
+ ])),
650
+ attemptFingerprint: attemptFingerprint(attempt),
651
+ checkpointFingerprint: memoryCheckpointFingerprint(entry),
652
+ authorityFingerprint: attemptAuthorityFingerprint(entry, attempt),
653
+ });
654
+ }
655
+ if (pending) {
656
+ const ledger = readLedgerForValidation(vault, { projectId: expectedProjectId });
657
+ if (!ledger.ok) {
658
+ throw new Error(`Ledger físico inválido para acknowledgement de repair: ${(ledger.errors || []).join(' ')}`);
659
+ }
660
+ }
661
+ return { attempts: frozen, pending };
662
+ }, options.memoryLock || {});
663
+ return result;
664
+ }
665
+
666
+ function publishedProjectionMatches(vault, events, projection) {
667
+ const prepared = prepareMemoryProjection(vault, events);
668
+ if (!sameCheckpoint(prepared.checkpoint, projection?.checkpoint)) return false;
669
+ const shared = readVaultFile(
670
+ vault, brainPath(vault, SHARED), 'utf8', 'projeção SHARED_MEMORY.md',
671
+ );
672
+ const candidates = readVaultFile(
673
+ vault, brainPath(vault, CANDIDATES), 'utf8', 'projeção MEMORY_CANDIDATES.jsonl',
674
+ );
675
+ return shared === prepared.sharedContent && candidates === prepared.candidatesContent;
676
+ }
677
+
678
+ function acknowledgeRepairAttempts(vault, frozen, projection, options = {}) {
679
+ const receipt = new Set(Array.isArray(projection?.consumedEventIds)
680
+ ? projection.consumedEventIds
681
+ : []);
682
+ const covered = frozen.filter((item) => item.eventIds.every((eventId) => receipt.has(eventId)));
683
+ if (!covered.length) {
684
+ return {
685
+ status: 'unchanged', eligible: frozen.length, acknowledged: 0, stale: frozen.length,
686
+ };
687
+ }
688
+
689
+ const outcome = withMemoryLock(vault, () => {
690
+ if (options.beforeAttemptAcknowledgement) options.beforeAttemptAcknowledgement();
691
+ const ledger = readMemoryLedger(vault);
692
+ if (ledger.status !== 'ok' || !publishedProjectionMatches(vault, ledger.events, projection)) {
693
+ return {
694
+ status: 'attention', eligible: frozen.length, acknowledged: 0, stale: frozen.length,
695
+ };
696
+ }
697
+ const byId = new Map(ledger.events.map((event) => [event.event_id, event]));
698
+ const valid = covered.filter((item) => {
699
+ if (projectId(vault) !== item.projectId || !targetOutboxIsAbsent(vault, item.eventIds)) {
700
+ return false;
701
+ }
702
+ return item.eventIds.every((eventId) => {
703
+ const event = byId.get(eventId);
704
+ return event
705
+ && item.eventFingerprints.get(eventId) === hash(canonicalMemoryJson(event));
706
+ });
707
+ });
708
+ if (!valid.length) {
709
+ return {
710
+ status: 'attention', eligible: frozen.length, acknowledged: 0, stale: frozen.length,
711
+ };
712
+ }
713
+
714
+ const acknowledged = mutateSessionRegistry(vault, (registry) => {
715
+ const sessionIds = [];
716
+ for (const item of valid) {
717
+ const entry = registry.sessions?.[item.sessionId];
718
+ const attempt = entry?.last_memory_attempt;
719
+ if (!attempt || attemptFingerprint(attempt) !== item.attemptFingerprint
720
+ || memoryCheckpointFingerprint(entry) !== item.checkpointFingerprint
721
+ || attemptAuthorityFingerprint(entry, attempt) !== item.authorityFingerprint
722
+ || !ownsAttemptContext(entry, attempt)
723
+ || item.eventIds.some((eventId) => !attemptEventMatches(
724
+ byId.get(eventId), attempt, item.projectId,
725
+ ))) continue;
726
+ attempt.state = 'projected';
727
+ attempt.checkpoint = cloneJson(projection.checkpoint);
728
+ entry.memory_status = 'projected';
729
+ entry.memory_checkpoint = cloneJson(projection.checkpoint);
730
+ sessionIds.push(item.sessionId);
731
+ }
732
+ return sessionIds;
733
+ });
734
+ return {
735
+ status: acknowledged.length === valid.length ? 'acknowledged' : 'attention',
736
+ eligible: frozen.length,
737
+ acknowledged: acknowledged.length,
738
+ stale: frozen.length - acknowledged.length,
739
+ sessionIds: acknowledged,
740
+ };
741
+ }, options.memoryLock || {});
742
+ return outcome === MEMORY_LOCK_BUSY
743
+ ? {
744
+ status: 'busy', eligible: frozen.length, acknowledged: 0, stale: frozen.length,
745
+ }
746
+ : outcome;
747
+ }
748
+
749
+ function normalizeProjectedAttemptRecoveryRequest({ sessionId } = {}) {
750
+ const normalizedSessionId = String(sessionId || '').trim();
751
+ if (!normalizedSessionId) {
752
+ throw new TypeError('sessionId é obrigatório para recuperar attempt projetado.');
753
+ }
754
+ return { sessionId: normalizedSessionId };
755
+ }
756
+
757
+ function readStrictSessionRegistry(vault) {
758
+ const path = registryPath(vault);
759
+ const checked = checkedVaultFile(vault, path, 'SESSION_REGISTRY da recuperação', {
760
+ allowMissing: false,
761
+ });
762
+ const generationBefore = filesystemGeneration(checked.target);
763
+ let bytes;
764
+ try {
765
+ bytes = readVaultFile(vault, checked.target, undefined, 'SESSION_REGISTRY da recuperação');
766
+ } catch (error) {
767
+ throw new Error(`SESSION_REGISTRY ausente ou ilegível para recuperação: ${error?.message || error}`);
768
+ }
769
+ const generationAfter = filesystemGeneration(checked.target);
770
+ if (canonicalMemoryJson(generationBefore) !== canonicalMemoryJson(generationAfter)) {
771
+ throw new Error('SESSION_REGISTRY mudou durante o preflight da recuperação targeted.');
772
+ }
773
+ let registry;
774
+ try {
775
+ registry = JSON.parse(bytes.toString('utf8'));
776
+ } catch (error) {
777
+ throw new Error(`SESSION_REGISTRY contém JSON inválido para recuperação: ${error?.message || error}`);
778
+ }
779
+ if (!registry || typeof registry !== 'object' || Array.isArray(registry)
780
+ || !Number.isInteger(registry.version) || registry.version < 2
781
+ || !registry.sessions || typeof registry.sessions !== 'object'
782
+ || Array.isArray(registry.sessions)) {
783
+ throw new Error('SESSION_REGISTRY inválido para recuperação targeted.');
784
+ }
785
+ return {
786
+ registry,
787
+ registryHash: hash(canonicalMemoryJson(registry)),
788
+ registryGeneration: generationAfter,
789
+ };
790
+ }
791
+
792
+ function filesystemGeneration(path) {
793
+ const stat = statSync(path, { bigint: true });
794
+ return {
795
+ dev: String(stat.dev),
796
+ ino: String(stat.ino),
797
+ size: String(stat.size),
798
+ mtimeNs: String(stat.mtimeNs),
799
+ ctimeNs: String(stat.ctimeNs),
800
+ birthtimeNs: String(stat.birthtimeNs),
801
+ };
802
+ }
803
+
804
+ function projectedRecoveryOutboxProof(vault) {
805
+ const path = brainPath(vault, 'memory-outbox');
806
+ const checked = assertVaultPathSafe(vault, path, {
807
+ allowMissing: true,
808
+ expectedType: 'directory',
809
+ label: 'diretório memory-outbox da recuperação',
810
+ });
811
+ if (!checked.exists) return { exists: false, entries: [] };
812
+
813
+ const namesBefore = readdirSync(checked.target).sort();
814
+ const entries = namesBefore.map((name) => {
815
+ const memberPath = join(checked.target, name);
816
+ const member = checkedVaultFile(
817
+ vault, memberPath, `membro ${name} da memory-outbox`, { allowMissing: false },
818
+ );
819
+ const bytes = readVaultFile(
820
+ vault, member.target, undefined, `membro ${name} da memory-outbox`,
821
+ );
822
+ let event;
823
+ try {
824
+ event = JSON.parse(bytes.toString('utf8'));
825
+ } catch (error) {
826
+ throw new Error(`Membro ${name} da memory-outbox contém JSON inválido: ${error?.message || error}`);
827
+ }
828
+ if (!event || typeof event !== 'object' || Array.isArray(event)
829
+ || typeof event.event_id !== 'string' || !event.event_id.trim()) {
830
+ throw new Error(`Membro ${name} da memory-outbox não contém event_id válido.`);
831
+ }
832
+ return {
833
+ name,
834
+ eventId: event.event_id,
835
+ generation: filesystemGeneration(member.target),
836
+ hash: byteHash(bytes),
837
+ };
838
+ });
839
+ const namesAfter = readdirSync(checked.target).sort();
840
+ if (canonicalMemoryJson(namesBefore) !== canonicalMemoryJson(namesAfter)) {
841
+ throw new Error('memory-outbox mudou durante o preflight da recuperação targeted.');
842
+ }
843
+ return {
844
+ exists: true,
845
+ generation: filesystemGeneration(checked.target),
846
+ entries,
847
+ };
848
+ }
849
+
850
+ function recoveryContextFingerprint(entry, attempt) {
851
+ return hash(canonicalMemoryJson({
852
+ status: fieldSnapshot(entry, 'status'),
853
+ session_id: fieldSnapshot(entry, 'session_id'),
854
+ activation_id: fieldSnapshot(entry, 'activation_id'),
855
+ active_activation_id: fieldSnapshot(entry, 'active_activation_id'),
856
+ activation_epoch: fieldSnapshot(entry, 'activation_epoch'),
857
+ last_turn_id: fieldSnapshot(entry, 'last_turn_id'),
858
+ last_turn_sequence: fieldSnapshot(entry, 'last_turn_sequence'),
859
+ turn_sequences: cloneJson(entry?.turn_sequences || null),
860
+ activation: cloneJson(entry?.activations?.[attempt?.activation_id] || null),
861
+ memory_status: fieldSnapshot(entry, 'memory_status'),
862
+ memory_activation_id: fieldSnapshot(entry, 'memory_activation_id'),
863
+ }));
864
+ }
865
+
866
+ function recoveryRegistryProof(entry, attempt) {
867
+ return {
868
+ attemptFingerprint: attemptFingerprint(attempt),
869
+ contextFingerprint: recoveryContextFingerprint(entry, attempt),
870
+ checkpointFingerprint: memoryCheckpointFingerprint(entry),
871
+ };
872
+ }
873
+
874
+ function validStoredProjectedCheckpoint(vault, authority, attempt, entry) {
875
+ if (!checkpointShape(attempt?.checkpoint)
876
+ || !sameCheckpoint(attempt.checkpoint, entry?.memory_checkpoint)) return false;
877
+ const cursorIndex = authority.ledgerEvents
878
+ .findIndex((event) => event.event_id === attempt.checkpoint.event_cursor);
879
+ if (cursorIndex < 0) return false;
880
+ const prefix = authority.ledgerEvents.slice(0, cursorIndex + 1);
881
+ const prefixIds = new Set(prefix.map((event) => event.event_id));
882
+ if (attempt.event_ids.some((eventId) => !prefixIds.has(eventId))) return false;
883
+ return sameCheckpoint(
884
+ attempt.checkpoint,
885
+ prepareMemoryProjection(vault, prefix).checkpoint,
886
+ );
887
+ }
888
+
889
+ function prepareProjectedAttemptRecovery(vault, rawRequest) {
890
+ const request = normalizeProjectedAttemptRecoveryRequest(rawRequest);
891
+ const authority = readMemoryAuthority(vault);
892
+ const registrySnapshot = readStrictSessionRegistry(vault);
893
+ const { registry } = registrySnapshot;
894
+ const outboxProof = projectedRecoveryOutboxProof(vault);
895
+ const entry = registry.sessions[request.sessionId];
896
+ if (!entry) throw new Error(`Sessão não encontrada para recuperação: ${request.sessionId}`);
897
+
898
+ const attempt = entry.last_memory_attempt;
899
+ const eventIds = Array.isArray(attempt?.event_ids) ? [...attempt.event_ids] : [];
900
+ if (attempt?.memory_mode !== 'v2' || attempt?.disposition !== 'applied'
901
+ || !['enqueued', 'degraded', 'projected'].includes(attempt?.state)) {
902
+ throw new Error(`Attempt da sessão ${request.sessionId} não é v2/applied recuperável.`);
903
+ }
904
+ if (!eventIds.length
905
+ || eventIds.some((eventId) => typeof eventId !== 'string' || !eventId.trim())
906
+ || new Set(eventIds).size !== eventIds.length) {
907
+ throw new Error('Attempt recuperável deve possuir event_ids não vazios e únicos.');
908
+ }
909
+ if (request.sessionId !== attempt.canonical_session_id
910
+ || (Object.prototype.hasOwnProperty.call(entry, 'session_id')
911
+ && entry.session_id !== request.sessionId)
912
+ || !ownsAttemptContext(entry, attempt)) {
913
+ throw new Error('Contexto causal do attempt não pertence mais à sessão ativa.');
914
+ }
915
+
916
+ const eventIndexes = eventIds.map((eventId) => (
917
+ authority.ledgerEvents.findIndex((event) => event.event_id === eventId)
918
+ ));
919
+ if (eventIndexes.some((index) => index < 0)) {
920
+ throw new Error('Attempt recuperável referencia event_ids ausentes do ledger.');
921
+ }
922
+ for (const eventId of eventIds) {
923
+ if (!attemptEventMatches(authority.ledgerById.get(eventId), attempt, authority.projectId)) {
924
+ throw new Error(`Identidade causal inválida no evento ${eventId}.`);
925
+ }
926
+ }
927
+ const lastAttemptEventIndex = Math.max(...eventIndexes);
928
+ if (authority.ledgerEvents.slice(lastAttemptEventIndex + 1)
929
+ .some((event) => event.canonical_session_id === request.sessionId)) {
930
+ throw new Error('Existe evento posterior da mesma sessão; attempt histórico não pode ser recuperado.');
931
+ }
932
+ if (!targetOutboxIsAbsent(vault, eventIds)
933
+ || outboxProof.entries.some((item) => eventIds.includes(item.eventId))) {
934
+ throw new Error('A outbox ainda contém evento alvo; recuperação targeted recusada.');
935
+ }
936
+
937
+ const projection = prepareMemoryProjection(vault, authority.ledgerEvents);
938
+ const sharedBytes = readVaultFile(
939
+ vault, brainPath(vault, SHARED), undefined, 'projeção SHARED_MEMORY.md',
940
+ );
941
+ const candidatesBytes = readVaultFile(
942
+ vault, brainPath(vault, CANDIDATES), undefined, 'projeção MEMORY_CANDIDATES.jsonl',
943
+ );
944
+ if (!sharedBytes.equals(Buffer.from(projection.sharedContent))
945
+ || !candidatesBytes.equals(Buffer.from(projection.candidatesContent))) {
946
+ throw new Error('SHARED/candidates divergem da autoridade integral do ledger.');
947
+ }
948
+ assertAuthorityMatches(authority, readMemoryAuthority(vault));
949
+
950
+ const alreadyProjected = attempt.state === 'projected';
951
+ if (alreadyProjected && !validStoredProjectedCheckpoint(vault, authority, attempt, entry)) {
952
+ throw new Error('Attempt projected não possui checkpoint armazenado válido.');
953
+ }
954
+
955
+ return {
956
+ request,
957
+ eligible: !alreadyProjected,
958
+ alreadyProjected,
959
+ checkpoint: cloneJson(alreadyProjected ? attempt.checkpoint : projection.checkpoint),
960
+ proof: {
961
+ projectId: authority.projectId,
962
+ projectHash: authority.projectHash,
963
+ coreHash: authority.coreHash,
964
+ ledgerHash: authority.ledgerHash,
965
+ sharedHash: byteHash(sharedBytes),
966
+ candidatesHash: byteHash(candidatesBytes),
967
+ outboxProof,
968
+ registryHash: registrySnapshot.registryHash,
969
+ registryGeneration: registrySnapshot.registryGeneration,
970
+ eventIds,
971
+ eventFingerprints: eventIds.map((eventId) => (
972
+ hash(canonicalMemoryJson(authority.ledgerById.get(eventId)))
973
+ )),
974
+ ...recoveryRegistryProof(entry, attempt),
975
+ },
976
+ };
977
+ }
978
+
979
+ function projectedAttemptRecoveryResult(prepared, status) {
980
+ return {
981
+ status,
982
+ eligible: prepared.eligible,
983
+ sessionId: prepared.request.sessionId,
984
+ checkpoint: cloneJson(prepared.checkpoint),
985
+ };
986
+ }
987
+
988
+ function assertProjectedAttemptRecoveryProof(expected, actual) {
989
+ if (canonicalMemoryJson(expected.proof) !== canonicalMemoryJson(actual.proof)
990
+ || expected.alreadyProjected !== actual.alreadyProjected
991
+ || !sameCheckpoint(expected.checkpoint, actual.checkpoint)) {
992
+ throw new Error('CAS perdido: autoridade, attempt, contexto causal ou checkpoint mudou.');
993
+ }
994
+ }
995
+
996
+ export function inspectProjectedAttemptRecovery(vault, { sessionId } = {}) {
997
+ const prepared = prepareProjectedAttemptRecovery(vault, { sessionId });
998
+ return projectedAttemptRecoveryResult(
999
+ prepared, prepared.alreadyProjected ? 'unchanged' : 'eligible',
1000
+ );
1001
+ }
1002
+
1003
+ export function recoverProjectedAttempt(vault, {
1004
+ sessionId,
1005
+ apply = false,
1006
+ beforeRegistryMutation,
1007
+ memoryLock = {},
1008
+ } = {}) {
1009
+ const prepared = prepareProjectedAttemptRecovery(vault, { sessionId });
1010
+ if (prepared.alreadyProjected) {
1011
+ return projectedAttemptRecoveryResult(prepared, 'unchanged');
1012
+ }
1013
+ if (!apply) return projectedAttemptRecoveryResult(prepared, 'dry-run');
1014
+
1015
+ const outcome = withMemoryLock(vault, () => {
1016
+ const locked = prepareProjectedAttemptRecovery(vault, prepared.request);
1017
+ assertProjectedAttemptRecoveryProof(prepared, locked);
1018
+ if (beforeRegistryMutation) beforeRegistryMutation();
1019
+ const current = prepareProjectedAttemptRecovery(vault, prepared.request);
1020
+ assertProjectedAttemptRecoveryProof(locked, current);
1021
+
1022
+ mutateSessionRegistry(vault, (registry) => {
1023
+ const entry = registry.sessions?.[current.request.sessionId];
1024
+ const attempt = entry?.last_memory_attempt;
1025
+ const expectedRegistryProof = {
1026
+ attemptFingerprint: current.proof.attemptFingerprint,
1027
+ contextFingerprint: current.proof.contextFingerprint,
1028
+ checkpointFingerprint: current.proof.checkpointFingerprint,
1029
+ };
1030
+ if (!entry || !attempt
1031
+ || hash(canonicalMemoryJson(registry)) !== current.proof.registryHash
1032
+ || canonicalMemoryJson(filesystemGeneration(registryPath(vault)))
1033
+ !== canonicalMemoryJson(current.proof.registryGeneration)
1034
+ || canonicalMemoryJson(recoveryRegistryProof(entry, attempt))
1035
+ !== canonicalMemoryJson(expectedRegistryProof)) {
1036
+ throw new Error('CAS perdido: registry mudou antes do acknowledgement targeted.');
1037
+ }
1038
+ attempt.state = 'projected';
1039
+ attempt.checkpoint = cloneJson(current.checkpoint);
1040
+ entry.memory_status = 'projected';
1041
+ entry.memory_activation_id = attempt.activation_id;
1042
+ entry.memory_checkpoint = cloneJson(current.checkpoint);
1043
+ });
1044
+ return projectedAttemptRecoveryResult(current, 'applied');
1045
+ }, memoryLock);
1046
+
1047
+ if (outcome === MEMORY_LOCK_BUSY) {
1048
+ const error = new Error('MEMORY.lock indisponível; recuperação targeted não foi aplicada.');
1049
+ error.code = 'WENDKEEP_MEMORY_LOCK_BUSY';
1050
+ throw error;
1051
+ }
1052
+ return outcome;
1053
+ }
1054
+
460
1055
  function matchingAppliedReconciliation(entry, request) {
461
1056
  const attempt = entry?.last_memory_attempt;
462
1057
  if (attempt?.memory_mode !== 'v2' || attempt?.state !== 'skipped' || attempt?.disposition !== 'superseded') return null;
@@ -932,6 +1527,67 @@ function historicalAssertOnlyCheckpoint(vault, attempt, authority, checkpoint) {
932
1527
  return currentPrefix.checkpoint;
933
1528
  }
934
1529
 
1530
+ function deferredAssertReplayCheckpointMigration(
1531
+ sessionId, entry, authority, legacyReplay, fullReplay,
1532
+ ) {
1533
+ const attempt = entry?.last_memory_attempt;
1534
+ const checkpoint = attempt?.checkpoint;
1535
+ if (attempt?.memory_mode !== 'v2' || attempt?.state !== 'projected'
1536
+ || attempt?.disposition !== 'applied' || !checkpointShape(checkpoint)) return null;
1537
+ if (!Object.prototype.hasOwnProperty.call(entry, 'memory_checkpoint')
1538
+ || !sameCheckpoint(entry.memory_checkpoint, checkpoint)
1539
+ || !sameCheckpoint(checkpoint, legacyReplay.checkpoint)
1540
+ || sameCheckpoint(checkpoint, fullReplay.checkpoint)) return null;
1541
+
1542
+ const requiredEventIds = Array.isArray(attempt.event_ids) ? [...attempt.event_ids] : [];
1543
+ if (!requiredEventIds.length || new Set(requiredEventIds).size !== requiredEventIds.length) return null;
1544
+ const current = fullReplay.records?.['handoff.latest']?.source;
1545
+ if (!current?.event_id || !requiredEventIds.includes(current.event_id)) return null;
1546
+ const relevantLegacyCandidate = legacyReplay.candidates.some(
1547
+ (candidate) => candidate.memory_key === 'handoff.latest'
1548
+ && candidate.event_ids.includes(current.event_id),
1549
+ );
1550
+ const relevantCandidateStillReal = fullReplay.candidates.some(
1551
+ (candidate) => candidate.memory_key === 'handoff.latest',
1552
+ );
1553
+ if (!relevantLegacyCandidate || relevantCandidateStillReal) return null;
1554
+
1555
+ const currentIdentity = {
1556
+ canonical_session_id: current.canonical_session_id,
1557
+ activation_id: current.activation_id,
1558
+ activation_epoch: current.activation_epoch,
1559
+ source_turn_id: current.source_turn_id,
1560
+ turn_sequence: current.turn_sequence,
1561
+ };
1562
+ if (currentIdentity.canonical_session_id !== sessionId
1563
+ || attempt.canonical_session_id !== currentIdentity.canonical_session_id
1564
+ || attempt.activation_id !== currentIdentity.activation_id
1565
+ || attempt.activation_epoch !== currentIdentity.activation_epoch
1566
+ || attempt.turn_id !== currentIdentity.source_turn_id
1567
+ || attempt.turn_sequence !== currentIdentity.turn_sequence) return null;
1568
+
1569
+ const newerAttemptEvent = authority.ledgerEvents.some((event) => (
1570
+ event.canonical_session_id === currentIdentity.canonical_session_id
1571
+ && event.activation_id === currentIdentity.activation_id
1572
+ && event.activation_epoch === currentIdentity.activation_epoch
1573
+ && Number.isInteger(event.turn_sequence)
1574
+ && event.turn_sequence > currentIdentity.turn_sequence
1575
+ ));
1576
+ if (newerAttemptEvent) return null;
1577
+
1578
+ const proof = validateSuccessorProof({ bySessionId: sessionId }, attempt, authority);
1579
+ return {
1580
+ sessionId,
1581
+ expectedFingerprint: attemptFingerprint(attempt),
1582
+ expectedMemoryCheckpointFingerprint: memoryCheckpointFingerprint(entry),
1583
+ originalCheckpoint: cloneJson(checkpoint),
1584
+ checkpoint: cloneJson(fullReplay.checkpoint),
1585
+ proof,
1586
+ migrationType: 'deferred_assert_replay_migrated',
1587
+ eventIds: requiredEventIds,
1588
+ };
1589
+ }
1590
+
935
1591
  function legacyCheckpointMigration(vault, sessionId, entry, authority, fullReplay) {
936
1592
  const attempt = entry?.last_memory_attempt;
937
1593
  const checkpoint = attempt?.checkpoint;
@@ -961,6 +1617,8 @@ function legacyCheckpointMigration(vault, sessionId, entry, authority, fullRepla
961
1617
  originalCheckpoint: cloneJson(checkpoint),
962
1618
  checkpoint: cloneJson(nextCheckpoint),
963
1619
  proof,
1620
+ migrationType: 'legacy_causal_checkpoint_migrated',
1621
+ eventIds: requiredEventIds,
964
1622
  };
965
1623
  }
966
1624
 
@@ -972,11 +1630,14 @@ export function migrateLegacyMemoryCheckpoints(vault, {
972
1630
  const authority = readMemoryAuthority(vault);
973
1631
  assertAuthorityMatches(expectedAuthority, authority);
974
1632
  const inspected = readSessionRegistry(vault);
1633
+ const legacyReplay = deriveMemoryProjection(vault, authority.ledgerEvents, {
1634
+ resolveDeferredAsserts: false,
1635
+ });
975
1636
  const fullReplay = deriveMemoryProjection(vault, authority.ledgerEvents);
976
1637
  const plans = Object.entries(inspected.sessions || {})
977
- .map(([sessionId, entry]) => legacyCheckpointMigration(
978
- vault, sessionId, entry, authority, fullReplay,
979
- ))
1638
+ .map(([sessionId, entry]) => deferredAssertReplayCheckpointMigration(
1639
+ sessionId, entry, authority, legacyReplay, fullReplay,
1640
+ ) || legacyCheckpointMigration(vault, sessionId, entry, authority, fullReplay))
980
1641
  .filter(Boolean);
981
1642
  assertAuthorityMatches(expectedAuthority, readMemoryAuthority(vault));
982
1643
  if (!plans.length) return {
@@ -1015,14 +1676,20 @@ export function migrateLegacyMemoryCheckpoints(vault, {
1015
1676
 
1016
1677
  for (const plan of plans) {
1017
1678
  const entry = registry.sessions[plan.sessionId];
1018
- const reconciliationId = `memcp-${hash(`${plan.sessionId}\0${plan.expectedFingerprint}\0${plan.expectedMemoryCheckpointFingerprint}\0${canonicalMemoryJson(plan.checkpoint)}`).slice(0, 20)}`;
1679
+ const reconciliationSeed = plan.migrationType === 'legacy_causal_checkpoint_migrated'
1680
+ ? `${plan.sessionId}\0${plan.expectedFingerprint}\0${plan.expectedMemoryCheckpointFingerprint}\0${canonicalMemoryJson(plan.checkpoint)}`
1681
+ : `${plan.migrationType}\0${plan.sessionId}\0${plan.expectedFingerprint}\0${plan.expectedMemoryCheckpointFingerprint}\0${canonicalMemoryJson(plan.checkpoint)}`;
1682
+ const reconciliationId = `memcp-${hash(reconciliationSeed).slice(0, 20)}`;
1019
1683
  entry.memory_reconciliations = [
1020
1684
  ...(Array.isArray(entry.memory_reconciliations) ? entry.memory_reconciliations : []),
1021
1685
  {
1022
1686
  v: 1,
1023
1687
  reconciliation_id: reconciliationId,
1024
- type: 'legacy_causal_checkpoint_migrated',
1688
+ type: plan.migrationType,
1025
1689
  reconciled_at: now,
1690
+ ...(plan.migrationType === 'deferred_assert_replay_migrated'
1691
+ ? { event_ids: [...plan.eventIds] }
1692
+ : {}),
1026
1693
  causal_proof: cloneJson(plan.proof),
1027
1694
  original_checkpoint: cloneJson(plan.originalCheckpoint),
1028
1695
  checkpoint: cloneJson(plan.checkpoint),
@@ -1064,13 +1731,28 @@ export function migrateLegacyMemoryCheckpoints(vault, {
1064
1731
  }
1065
1732
 
1066
1733
  export function repairMemory(vault, options = {}) {
1067
- const repaired = repairMemoryLedger(vault);
1734
+ if (options.beforeAttemptFreeze) options.beforeAttemptFreeze();
1735
+ const acknowledgementPreflight = freezeRepairAttemptAcknowledgements(vault, options);
1736
+ if (acknowledgementPreflight === MEMORY_LOCK_BUSY) {
1737
+ return { status: 'busy', stage: 'attempt-freeze' };
1738
+ }
1739
+ const frozenAttempts = acknowledgementPreflight.attempts;
1740
+ const repaired = acknowledgementPreflight.pending
1741
+ ? { status: 'unchanged', repairedLines: 0, backupPath: null }
1742
+ : repairMemoryLedger(vault);
1068
1743
  if (repaired.status === 'busy') return repaired;
1069
1744
  const projection = projectMemoryOutbox(vault);
1070
1745
  if (projection.status === 'busy') return { status: 'busy', repaired, projection };
1746
+ const attemptAcknowledgements = acknowledgeRepairAttempts(
1747
+ vault, frozenAttempts, projection, options,
1748
+ );
1071
1749
  const checkpointMigration = migrateLegacyMemoryCheckpoints(vault, options);
1072
1750
  return {
1073
- status: 'repaired', repaired, projection, checkpointMigration,
1751
+ status: 'repaired',
1752
+ repaired,
1753
+ projection,
1754
+ attemptAcknowledgements,
1755
+ checkpointMigration,
1074
1756
  };
1075
1757
  }
1076
1758
 
@@ -1164,9 +1846,61 @@ function parseReconcileArgs(argv) {
1164
1846
  };
1165
1847
  }
1166
1848
 
1849
+ function parseRecoverAttemptArgs(argv) {
1850
+ const positionals = [];
1851
+ const seen = new Set();
1852
+ let apply = false;
1853
+ let vault = '';
1854
+
1855
+ for (let index = 1; index < argv.length; index += 1) {
1856
+ const token = argv[index];
1857
+ if (!token.startsWith('--')) {
1858
+ positionals.push(token);
1859
+ if (positionals.length > 1) {
1860
+ throw memoryUsageError(`memory recover-attempt recebeu argumento posicional extra: ${token}.`);
1861
+ }
1862
+ continue;
1863
+ }
1864
+
1865
+ const equalAt = token.indexOf('=');
1866
+ const name = equalAt >= 0 ? token.slice(0, equalAt) : token;
1867
+ if (name !== '--apply' && name !== '--vault') {
1868
+ throw memoryUsageError(`memory recover-attempt recebeu opção desconhecida: ${name}.`);
1869
+ }
1870
+ if (seen.has(name)) {
1871
+ throw memoryUsageError(`memory recover-attempt recebeu opção duplicada: ${name}.`);
1872
+ }
1873
+ seen.add(name);
1874
+
1875
+ if (name === '--apply') {
1876
+ if (equalAt >= 0) throw memoryUsageError('--apply não aceita valor.');
1877
+ apply = true;
1878
+ continue;
1879
+ }
1880
+
1881
+ const value = equalAt >= 0 ? token.slice(equalAt + 1) : argv[index + 1];
1882
+ if (!value || !value.trim() || value.startsWith('--')) {
1883
+ throw memoryUsageError('--vault requer valor não vazio que não comece com --.');
1884
+ }
1885
+ vault = value;
1886
+ if (equalAt < 0) index += 1;
1887
+ }
1888
+
1889
+ if (positionals.length !== 1) {
1890
+ throw memoryUsageError('memory recover-attempt requer exatamente uma sessão obrigatória.');
1891
+ }
1892
+
1893
+ return {
1894
+ sessionId: positionals[0],
1895
+ apply,
1896
+ vault,
1897
+ };
1898
+ }
1899
+
1167
1900
  export function runMemory(argv) {
1168
1901
  const [sub, positional] = argv;
1169
1902
  let reconcileArgs = null;
1903
+ let recoverAttemptArgs = null;
1170
1904
  if (sub === 'reconcile') {
1171
1905
  try {
1172
1906
  reconcileArgs = parseReconcileArgs(argv);
@@ -1176,7 +1910,18 @@ export function runMemory(argv) {
1176
1910
  return;
1177
1911
  }
1178
1912
  }
1179
- const vault = (reconcileArgs?.vault || option(argv, '--vault')) || process.env.OBSIDIAN_VAULT_PATH;
1913
+ if (sub === 'recover-attempt') {
1914
+ try {
1915
+ recoverAttemptArgs = parseRecoverAttemptArgs(argv);
1916
+ } catch (error) {
1917
+ process.stderr.write(`wendkeep memory: ${error.message}\n`);
1918
+ process.exitCode = error.code === 'WENDKEEP_MEMORY_USAGE' ? 2 : 1;
1919
+ return;
1920
+ }
1921
+ }
1922
+ const vault = (
1923
+ recoverAttemptArgs?.vault || reconcileArgs?.vault || option(argv, '--vault')
1924
+ ) || process.env.OBSIDIAN_VAULT_PATH;
1180
1925
  if (!vault) { process.stderr.write('wendkeep memory: passe --vault <path>.\n'); process.exitCode = 2; return; }
1181
1926
  if (!existsSync(vault)) { process.stderr.write(`wendkeep memory: not found: ${vault}\n`); process.exitCode = 2; return; }
1182
1927
  try {
@@ -1192,6 +1937,12 @@ export function runMemory(argv) {
1192
1937
  apply: reconcileArgs.apply,
1193
1938
  });
1194
1939
  }
1940
+ else if (sub === 'recover-attempt') {
1941
+ result = recoverProjectedAttempt(vault, {
1942
+ sessionId: recoverAttemptArgs.sessionId,
1943
+ apply: recoverAttemptArgs.apply,
1944
+ });
1945
+ }
1195
1946
  else if (sub === 'promote' || sub === 'reject') {
1196
1947
  const eventId = option(argv, '--event');
1197
1948
  if (sub === 'reject' && eventId) throw memoryUsageError('--event é permitido somente em memory promote.');
@@ -1199,7 +1950,7 @@ export function runMemory(argv) {
1199
1950
  action: sub, candidateId: positional, ...(eventId ? { eventId } : {}),
1200
1951
  });
1201
1952
  }
1202
- 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; }
1953
+ else { process.stderr.write('wendkeep memory: use status | migrate [--apply] | repair | recover-attempt <session> [--apply] | reconcile <session> --by-session <session> --reason <text> [--apply] | promote <candidate> [--event <event-id>] | reject <candidate>.\n'); process.exitCode = 2; return; }
1203
1954
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
1204
1955
  if (sub === 'status' && argv.includes('--gate')) process.exitCode = result.status === 'blocked' ? 1 : 0;
1205
1956
  else if (sub === 'reconcile' && reconcileArgs.apply) process.exitCode = result.health?.status === 'blocked' ? 1 : 0;