wendkeep 0.58.0 → 0.58.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.en.md +28 -36
  3. package/README.md +28 -36
  4. package/docs/en/commands/changes-and-verification.md +79 -0
  5. package/docs/en/commands/costs-and-observability.md +65 -0
  6. package/docs/en/commands/getting-started.md +82 -0
  7. package/docs/en/commands/maintenance-and-diagnostics.md +77 -0
  8. package/docs/en/commands/memory-migration.md +73 -0
  9. package/docs/en/commands/memory.md +84 -0
  10. package/docs/en/commands/notes-and-knowledge.md +70 -0
  11. package/docs/en/commands/retroactive-import.md +67 -0
  12. package/docs/en/commands/sessions-and-import.md +85 -0
  13. package/docs/en/commands/verify.md +86 -0
  14. package/docs/pt-BR/commands/changes-and-verification.md +80 -0
  15. package/docs/pt-BR/commands/costs-and-observability.md +65 -0
  16. package/docs/pt-BR/commands/getting-started.md +83 -0
  17. package/docs/pt-BR/commands/maintenance-and-diagnostics.md +77 -0
  18. package/docs/pt-BR/commands/memory-migration.md +73 -0
  19. package/docs/pt-BR/commands/memory.md +83 -0
  20. package/docs/pt-BR/commands/notes-and-knowledge.md +69 -0
  21. package/docs/pt-BR/commands/retroactive-import.md +67 -0
  22. package/docs/pt-BR/commands/sessions-and-import.md +85 -0
  23. package/docs/pt-BR/commands/verify.md +87 -0
  24. package/hooks/brain-inject.mjs +2 -1
  25. package/hooks/memory-mode.mjs +39 -0
  26. package/hooks/memory-schema.mjs +15 -0
  27. package/hooks/obsidian-common.mjs +80 -29
  28. package/hooks/session-ensure.mjs +15 -8
  29. package/hooks/session-memory-lifecycle.mjs +330 -0
  30. package/hooks/session-stop.mjs +122 -42
  31. package/hooks/vault-health.mjs +124 -5
  32. package/package.json +3 -1
  33. package/src/memory.mjs +32 -7
  34. package/src/taxonomy.mjs +1 -0
@@ -293,3 +293,18 @@ export function validateSharedMemory(content, { eventIds } = {}) {
293
293
  sections: parsed.sections,
294
294
  };
295
295
  }
296
+
297
+ /**
298
+ * Classify SHARED from its own bytes without accepting malformed v2 as legacy.
299
+ * Empty v2 sidecar files are intentionally not part of this pure classification;
300
+ * vault-level operational evidence is handled by memory-mode.mjs.
301
+ */
302
+ export function classifySharedMemory(content) {
303
+ const text = String(content ?? '').replace(/\r\n/g, '\n');
304
+ if (validateSharedMemory(text).ok) return { mode: 'v2', reason: 'valid-v2' };
305
+ const hasV2Signature = /^(?:schema_version|event_cursor|state_hash)\s*:/m.test(text)
306
+ || /^# SHARED_MEMORY\s+[—-]\s+proje[cç][aã]o operacional gerada\s*$/mi.test(text);
307
+ return hasV2Signature
308
+ ? { mode: 'v2', reason: 'v2-signature' }
309
+ : { mode: 'legacy', reason: text.trim() ? 'legacy-shared' : 'shared-absent' };
310
+ }
@@ -355,7 +355,7 @@ export function mutateSessionRegistry(vaultBase, mutator, { timeoutMs = 2000 } =
355
355
  function meaningfulPatch(patch = {}) {
356
356
  const protectedNonEmpty = new Set(['session_file', 'transcript_path', 'transcript_id', 'provider', 'started_at', 'change_slug', 'activation_id']);
357
357
  return Object.fromEntries(Object.entries(patch).filter(([key, value]) => {
358
- if (key === 'advance_turn_sequence' || key === 'turn_sequence') return false;
358
+ if (['advance_turn_sequence', 'turn_sequence', 'turn_id', 'last_turn_sequence', 'recovery_activation_id', 'recovery_started_at'].includes(key)) return false;
359
359
  if (value === undefined || value === null) return false;
360
360
  if (value === '' && protectedNonEmpty.has(key)) return false;
361
361
  return true;
@@ -390,9 +390,13 @@ export function openActivation(registry, session = {}, explicitActivationId = ''
390
390
 
391
391
  const current = next.sessions[sessionId] || {};
392
392
  const activations = { ...(current.activations || {}) };
393
+ const openedAfterSequence = nonNegativeSequence(
394
+ session.opened_after_turn_sequence,
395
+ nonNegativeSequence(current.last_turn_sequence, 0),
396
+ );
393
397
  const requestedSequence = nonNegativeSequence(
394
398
  session.turn_sequence ?? session.last_turn_sequence,
395
- 0,
399
+ openedAfterSequence,
396
400
  );
397
401
 
398
402
  if (current.active_activation_id === activationId && activations[activationId]?.status === 'active') {
@@ -430,7 +434,9 @@ export function openActivation(registry, session = {}, explicitActivationId = ''
430
434
  epoch,
431
435
  status: 'active',
432
436
  started_at: session.activation_started_at || session.started_at || '',
433
- last_turn_sequence: requestedSequence,
437
+ opened_after_turn_sequence: openedAfterSequence,
438
+ ...(current.last_turn_id ? { opened_after_turn_id: current.last_turn_id } : {}),
439
+ last_turn_sequence: Math.max(openedAfterSequence, requestedSequence),
434
440
  ...(session.transcript_id ? { transcript_id: session.transcript_id } : {}),
435
441
  ...(session.transcript_path ? { transcript_path: session.transcript_path } : {}),
436
442
  ...(session.provider ? { provider: session.provider } : {}),
@@ -441,19 +447,40 @@ export function openActivation(registry, session = {}, explicitActivationId = ''
441
447
  active_activation_id: activationId,
442
448
  activation_epoch: epoch,
443
449
  activation_started_at: session.activation_started_at || session.started_at || '',
444
- last_turn_sequence: requestedSequence,
450
+ last_turn_sequence: Math.max(openedAfterSequence, requestedSequence),
445
451
  activations,
446
452
  };
447
453
  return next;
448
454
  }
449
455
 
450
456
  export function advanceActivationTurn(registry, turn = {}) {
451
- const next = cloneRegistry(registry);
457
+ let next = cloneRegistry(registry);
452
458
  const sessionId = turn.session_id || turn.canonical_session_id || '';
453
- const current = next.sessions[sessionId];
459
+ let current = next.sessions[sessionId];
454
460
  if (!current) return next;
455
461
 
456
- const activeId = current.active_activation_id || '';
462
+ const turnId = String(turn.turn_id || '');
463
+ const knownTurn = Boolean(turnId && (
464
+ current.last_turn_id === turnId
465
+ || current.last_prompt_turn_id === turnId
466
+ || Object.prototype.hasOwnProperty.call(current.turn_sequences || {}, turnId)
467
+ ));
468
+ if (knownTurn) return next;
469
+
470
+ let activeId = current.active_activation_id || '';
471
+ if ((!activeId || current.activations?.[activeId]?.status !== 'active') && turn.recovery_activation_id) {
472
+ next = openActivation(next, {
473
+ session_id: sessionId,
474
+ activation_id: turn.recovery_activation_id,
475
+ activation_started_at: turn.recovery_started_at || turn.started_at || '',
476
+ turn_sequence: turn.turn_sequence ?? current.last_turn_sequence,
477
+ transcript_id: turn.transcript_id || current.transcript_id,
478
+ transcript_path: turn.transcript_path || current.transcript_path,
479
+ provider: turn.provider || current.provider,
480
+ });
481
+ current = next.sessions[sessionId];
482
+ activeId = current.active_activation_id || '';
483
+ }
457
484
  if (turn.activation_id && activeId && turn.activation_id !== activeId) return next;
458
485
  const previous = nonNegativeSequence(current.last_turn_sequence, 0);
459
486
  const explicit = nonNegativeSequence(turn.turn_sequence ?? turn.last_turn_sequence);
@@ -466,9 +493,18 @@ export function advanceActivationTurn(registry, turn = {}) {
466
493
  nonNegativeSequence(activations[activeId].last_turn_sequence, 0),
467
494
  sequence,
468
495
  ),
496
+ ...(turnId ? { last_prompt_turn_id: turnId } : {}),
469
497
  };
470
498
  }
471
- next.sessions[sessionId] = { ...current, last_turn_sequence: sequence, activations };
499
+ next.sessions[sessionId] = {
500
+ ...current,
501
+ last_turn_sequence: sequence,
502
+ ...(turnId ? {
503
+ last_prompt_turn_id: turnId,
504
+ turn_sequences: { ...(current.turn_sequences || {}), [turnId]: sequence },
505
+ } : {}),
506
+ activations,
507
+ };
472
508
  return next;
473
509
  }
474
510
 
@@ -478,23 +514,22 @@ export function resolveStopActivation(registry, stop = {}) {
478
514
  if (!entry) return '';
479
515
  if (stop.activation_id) return String(stop.activation_id);
480
516
 
517
+ const activeId = entry.active_activation_id || '';
518
+ const active = entry.activations?.[activeId];
519
+ if (!activeId || active?.status !== 'active') return '';
481
520
  const transcriptId = String(stop.transcript_id || '');
482
521
  const transcriptPath = String(stop.transcript_path || '');
483
522
  if (!transcriptId && !transcriptPath) return '';
484
- const matches = Object.entries(entry.activations || {}).filter(([, activation]) => {
485
- if (!activation) return false;
486
- if (transcriptId && activation.transcript_id && activation.transcript_id !== transcriptId) return false;
487
- const paths = [
488
- ...(Array.isArray(activation.transcript_paths) ? activation.transcript_paths : []),
489
- activation.transcript_path,
490
- ].filter(Boolean);
491
- if (transcriptPath && paths.length && !paths.some((path) => transcriptsMatch(path, transcriptPath))) return false;
492
- return Boolean(
493
- (transcriptId && activation.transcript_id === transcriptId)
494
- || (transcriptPath && paths.some((path) => transcriptsMatch(path, transcriptPath))),
495
- );
496
- });
497
- return matches.length === 1 ? matches[0][0] : '';
523
+ if (transcriptId && active.transcript_id && active.transcript_id !== transcriptId) return '';
524
+ const paths = [
525
+ ...(Array.isArray(active.transcript_paths) ? active.transcript_paths : []),
526
+ active.transcript_path,
527
+ ].filter(Boolean);
528
+ if (transcriptPath && paths.length && !paths.some((path) => transcriptsMatch(path, transcriptPath))) return '';
529
+ return (
530
+ (transcriptId && active.transcript_id === transcriptId)
531
+ || (transcriptPath && paths.some((path) => transcriptsMatch(path, transcriptPath)))
532
+ ) ? activeId : '';
498
533
  }
499
534
 
500
535
  function stopResult(registry, stopDisposition, canPromoteMemory = false) {
@@ -531,21 +566,31 @@ export function applyStopActivation(registry, stop = {}) {
531
566
  const stopSequence = nonNegativeSequence(stop.turn_sequence ?? stop.last_turn_sequence);
532
567
  const lastSequence = nonNegativeSequence(current.last_turn_sequence, 0);
533
568
  if (stopSequence === null) return stopResult(next, 'ambiguous');
569
+ const active = current.activations?.[activeId] || {};
570
+ const openedAfterSequence = nonNegativeSequence(active.opened_after_turn_sequence, 0);
571
+ if (stopSequence <= openedAfterSequence && active.epoch > 1) {
572
+ return stopResult(next, 'superseded');
573
+ }
574
+ const stopTurnId = String(stop.turn_id || '');
575
+ if (stopTurnId && active.last_stop_turn_id === stopTurnId) {
576
+ return stopResult(next, 'duplicate');
577
+ }
534
578
  if (stopSequence < lastSequence) return stopResult(next, 'stale_turn');
535
579
 
536
580
  const activations = { ...(current.activations || {}) };
537
581
  activations[activeId] = {
538
582
  ...(activations[activeId] || { activation_id: activeId, epoch: current.activation_epoch }),
539
- status: 'done',
540
- ended_at: stop.ended_at || '',
583
+ status: 'active',
541
584
  last_turn_sequence: stopSequence,
585
+ last_stop_turn_sequence: stopSequence,
586
+ ...(stopTurnId ? { last_stop_turn_id: stopTurnId } : {}),
542
587
  };
543
588
  next.sessions[sessionId] = {
544
589
  ...current,
545
- status: 'done',
546
- ended_at: stop.ended_at || current.ended_at || '',
547
- active_activation_id: '',
548
- last_turn_sequence: stopSequence,
590
+ status: 'active',
591
+ active_activation_id: activeId,
592
+ last_turn_sequence: Math.max(lastSequence, stopSequence),
593
+ ...(stopTurnId ? { last_turn_id: stopTurnId } : {}),
549
594
  activations,
550
595
  };
551
596
  return stopResult(next, 'applied', true);
@@ -584,7 +629,7 @@ export function upsertSessionRegistry(vaultBase, sessionId, patch) {
584
629
  activation_id: clean.activation_id,
585
630
  activation_started_at: clean.activation_started_at,
586
631
  started_at: clean.activation_started_at || clean.started_at,
587
- last_turn_sequence: clean.last_turn_sequence,
632
+ last_turn_sequence: patch.last_turn_sequence,
588
633
  transcript_id: clean.transcript_id || current.transcript_id,
589
634
  transcript_path: clean.transcript_path || current.transcript_path,
590
635
  provider: clean.provider || current.provider,
@@ -597,7 +642,13 @@ export function upsertSessionRegistry(vaultBase, sessionId, patch) {
597
642
  {
598
643
  session_id: sessionId,
599
644
  activation_id: causalCurrent.active_activation_id || '',
645
+ recovery_activation_id: patch.recovery_activation_id || '',
646
+ recovery_started_at: patch.recovery_started_at || '',
647
+ turn_id: patch.turn_id || '',
600
648
  turn_sequence: patch.turn_sequence,
649
+ transcript_id: clean.transcript_id || causalCurrent.transcript_id,
650
+ transcript_path: clean.transcript_path || causalCurrent.transcript_path,
651
+ provider: clean.provider || causalCurrent.provider,
601
652
  },
602
653
  ).sessions[sessionId];
603
654
  }
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { randomUUID } from 'crypto';
2
3
  import { existsSync, renameSync, statSync, writeFileSync } from 'fs';
3
4
  import { basename, dirname, join } from 'path';
4
5
  import {
@@ -43,6 +44,16 @@ function turnSequenceFromInput(input = {}) {
43
44
  return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
44
45
  }
45
46
 
47
+ function causalTurnPatch(input, now) {
48
+ return {
49
+ advance_turn_sequence: true,
50
+ turn_sequence: turnSequenceFromInput(input),
51
+ turn_id: input.turn_id || input.turnId || '',
52
+ recovery_activation_id: randomUUID(),
53
+ recovery_started_at: formatLocalIso(now),
54
+ };
55
+ }
56
+
46
57
  function buildSessionContent({ relPath, now, summary = 'session', sessionId = '', reason = 'Sessão criada automaticamente pelo hook UserPromptSubmit.' }) {
47
58
  const date = formatDate(now);
48
59
  const startedAt = formatLocalIso(now);
@@ -268,8 +279,7 @@ function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, inp
268
279
  transcript_path: identity.transcriptPath,
269
280
  transcript_id: identity.transcriptId,
270
281
  provider: identity.provider,
271
- advance_turn_sequence: true,
272
- turn_sequence: turnSequenceFromInput(input),
282
+ ...causalTurnPatch(input, now),
273
283
  });
274
284
  return true;
275
285
  }
@@ -296,8 +306,7 @@ function createSession({ vaultBase, sessionId, input, now, identity }) {
296
306
  transcript_path: identity.transcriptPath,
297
307
  transcript_id: identity.transcriptId,
298
308
  provider: identity.provider,
299
- advance_turn_sequence: true,
300
- turn_sequence: turnSequenceFromInput(input),
309
+ ...causalTurnPatch(input, now),
301
310
  });
302
311
  return { relPath, startedAt };
303
312
  }
@@ -348,8 +357,7 @@ function main() {
348
357
  transcript_path: identity.transcriptPath,
349
358
  transcript_id: identity.transcriptId,
350
359
  provider: identity.provider,
351
- advance_turn_sequence: true,
352
- turn_sequence: turnSequenceFromInput(input),
360
+ ...causalTurnPatch(input, now),
353
361
  });
354
362
  writeHookOutput({});
355
363
  return;
@@ -393,8 +401,7 @@ function main() {
393
401
  transcript_path: identity.transcriptPath,
394
402
  transcript_id: identity.transcriptId,
395
403
  provider: identity.provider,
396
- advance_turn_sequence: true,
397
- turn_sequence: turnSequenceFromInput(input),
404
+ ...causalTurnPatch(input, now),
398
405
  });
399
406
  writeHookOutput({});
400
407
  return;
@@ -0,0 +1,330 @@
1
+ // Durable SessionStop memory publication split into three explicit phases:
2
+ // registry-guarded outbox staging -> independent projection -> registry CAS outcome.
3
+ // Keeping the MEMORY lock out of the registry critical section avoids lock inversion,
4
+ // while the immutable outbox is the durable hand-off between both locks.
5
+ import { buildSessionMemoryEvents } from './memory-handoff.mjs';
6
+ import { detectMemoryMode } from './memory-mode.mjs';
7
+ import { sanitizeMemoryText } from './memory-schema.mjs';
8
+ import { enqueueMemoryEvent, projectMemoryOutbox } from './memory-store.mjs';
9
+ import { mutateSessionRegistry } from './obsidian-common.mjs';
10
+
11
+ const DEFAULT_OBSERVED_AT = '1970-01-01T00:00:00.000Z';
12
+
13
+ const DEFAULT_DEPS = Object.freeze({
14
+ buildSessionMemoryEvents,
15
+ detectMemoryMode,
16
+ enqueueMemoryEvent,
17
+ mutateSessionRegistry,
18
+ projectMemoryOutbox,
19
+ sanitizeMemoryText,
20
+ });
21
+
22
+ function dependencies(overrides = {}) {
23
+ return { ...DEFAULT_DEPS, ...(overrides || {}) };
24
+ }
25
+
26
+ function nonNegativeInteger(value, fallback = 0) {
27
+ const parsed = Number(value);
28
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback;
29
+ }
30
+
31
+ function normalizeContext(context = {}) {
32
+ const handoff = context.handoff && typeof context.handoff === 'object'
33
+ ? context.handoff
34
+ : context;
35
+ const identity = handoff.identity || context.identity || {};
36
+ const activation = handoff.activation || context.activation || {};
37
+ const turn = handoff.turn || context.turn || {};
38
+ const sessionId = String(
39
+ context.sessionId
40
+ || context.canonicalSessionId
41
+ || identity.canonicalConversationId
42
+ || '',
43
+ );
44
+ const activationId = String(context.activationId || activation.id || '');
45
+ const activationEpoch = nonNegativeInteger(
46
+ context.activationEpoch ?? activation.epoch,
47
+ 0,
48
+ );
49
+ const turnId = String(context.turnId || turn.id || '');
50
+ const turnSequence = nonNegativeInteger(context.turnSequence ?? turn.sequence, 0);
51
+ const observedAt = String(context.observedAt || handoff.observedAt || DEFAULT_OBSERVED_AT);
52
+ const disposition = String(
53
+ context.disposition
54
+ || context.stopDisposition
55
+ || 'applied',
56
+ );
57
+
58
+ return {
59
+ sessionId,
60
+ activationId,
61
+ activationEpoch,
62
+ turnId,
63
+ turnSequence,
64
+ observedAt,
65
+ disposition,
66
+ handoff: {
67
+ ...handoff,
68
+ identity: { ...identity, canonicalConversationId: sessionId },
69
+ activation: { ...activation, id: activationId, epoch: activationEpoch },
70
+ turn: { ...turn, id: turnId, sequence: turnSequence },
71
+ observedAt,
72
+ },
73
+ };
74
+ }
75
+
76
+ function attemptIdentity(context, memoryMode) {
77
+ return {
78
+ v: 1,
79
+ memory_mode: memoryMode,
80
+ canonical_session_id: context.sessionId,
81
+ activation_id: context.activationId,
82
+ activation_epoch: context.activationEpoch,
83
+ turn_id: context.turnId,
84
+ turn_sequence: context.turnSequence,
85
+ observed_at: context.observedAt,
86
+ };
87
+ }
88
+
89
+ function sameAttempt(left, right) {
90
+ if (!left || !right) return false;
91
+ return String(left.canonical_session_id || '') === String(right.canonical_session_id || '')
92
+ && String(left.activation_id || '') === String(right.activation_id || '')
93
+ && nonNegativeInteger(left.activation_epoch, -1) === nonNegativeInteger(right.activation_epoch, -1)
94
+ && String(left.turn_id || '') === String(right.turn_id || '')
95
+ && nonNegativeInteger(left.turn_sequence, -1) === nonNegativeInteger(right.turn_sequence, -1);
96
+ }
97
+
98
+ function skippedAttempt(context, memoryMode, disposition) {
99
+ return {
100
+ ...attemptIdentity(context, memoryMode),
101
+ disposition,
102
+ state: 'skipped',
103
+ event_ids: [],
104
+ checkpoint: null,
105
+ };
106
+ }
107
+
108
+ function causalDisposition(entry, context) {
109
+ if (!entry || !context.sessionId || !context.activationId || !context.turnId) return 'ambiguous';
110
+ if (['ambiguous', 'stale_turn', 'superseded'].includes(context.disposition)) {
111
+ return context.disposition;
112
+ }
113
+
114
+ const activeId = String(entry.active_activation_id || '');
115
+ const active = entry.activations?.[activeId];
116
+ if (!activeId || activeId !== context.activationId || active?.status !== 'active') {
117
+ return 'superseded';
118
+ }
119
+ if (nonNegativeInteger(active.epoch, -1) !== context.activationEpoch) return 'superseded';
120
+
121
+ const openedAfter = nonNegativeInteger(active.opened_after_turn_sequence, 0);
122
+ if (context.activationEpoch > 1 && context.turnSequence <= openedAfter) return 'superseded';
123
+
124
+ const lastStopSequence = nonNegativeInteger(
125
+ active.last_stop_turn_sequence,
126
+ nonNegativeInteger(entry.last_turn_sequence, 0),
127
+ );
128
+ const lastStopTurnId = String(active.last_stop_turn_id || entry.last_turn_id || '');
129
+ if (lastStopSequence > context.turnSequence) return 'stale_turn';
130
+ if (lastStopTurnId && lastStopTurnId !== context.turnId) return 'stale_turn';
131
+ return 'applied';
132
+ }
133
+
134
+ function retryAttempt(previous) {
135
+ if (previous.state === 'projected' || previous.state === 'duplicate') {
136
+ return { ...previous, disposition: 'duplicate', state: 'duplicate', retry: true };
137
+ }
138
+ if (previous.state === 'enqueued' || previous.state === 'degraded') {
139
+ return { ...previous, state: 'enqueued', retry: true };
140
+ }
141
+ return null;
142
+ }
143
+
144
+ function canPersistAmbiguousSkip(entry, candidate) {
145
+ if (candidate.disposition !== 'ambiguous') return false;
146
+ const entryEpoch = nonNegativeInteger(entry.activation_epoch, -1);
147
+ if (entryEpoch > candidate.activation_epoch) return false;
148
+ if (entryEpoch === candidate.activation_epoch
149
+ && nonNegativeInteger(entry.last_turn_sequence, -1) > candidate.turn_sequence) {
150
+ return false;
151
+ }
152
+
153
+ const previous = entry.last_memory_attempt;
154
+ if (!previous) return true;
155
+ const previousEpoch = nonNegativeInteger(previous.activation_epoch, -1);
156
+ if (previousEpoch !== candidate.activation_epoch) return previousEpoch < candidate.activation_epoch;
157
+ const previousTurn = nonNegativeInteger(previous.turn_sequence, -1);
158
+ if (previousTurn !== candidate.turn_sequence) return previousTurn < candidate.turn_sequence;
159
+ return sameAttempt(previous, candidate) && previous.state === 'skipped';
160
+ }
161
+
162
+ /**
163
+ * Revalidate the Stop under SESSION_REGISTRY.lock, durably enqueue every event, and only
164
+ * then acknowledge `last_memory_attempt.state = enqueued` in the same registry mutation.
165
+ */
166
+ export function stageStopMemoryAttempt(vaultBase, rawContext, overrides = {}) {
167
+ const deps = dependencies(overrides);
168
+ const context = normalizeContext(rawContext);
169
+ const mode = deps.detectMemoryMode(vaultBase).mode;
170
+ if (mode === 'legacy') return skippedAttempt(context, 'legacy', 'legacy');
171
+
172
+ const identity = attemptIdentity(context, 'v2');
173
+ let staged = null;
174
+ deps.mutateSessionRegistry(vaultBase, (registry) => {
175
+ const entry = registry.sessions?.[context.sessionId];
176
+ const disposition = causalDisposition(entry, context);
177
+ if (disposition !== 'applied') {
178
+ staged = skippedAttempt(context, 'v2', disposition);
179
+ if (entry && canPersistAmbiguousSkip(entry, staged)) {
180
+ entry.last_memory_attempt = staged;
181
+ entry.memory_status = 'skipped';
182
+ if (context.activationId) entry.memory_activation_id = context.activationId;
183
+ }
184
+ return staged;
185
+ }
186
+
187
+ const previous = entry.last_memory_attempt;
188
+ if (sameAttempt(previous, identity)) {
189
+ const retry = retryAttempt(previous);
190
+ if (retry) {
191
+ staged = retry;
192
+ return staged;
193
+ }
194
+ }
195
+
196
+ const events = deps.buildSessionMemoryEvents(context.handoff);
197
+ if (!Array.isArray(events) || events.length === 0) {
198
+ throw new TypeError('Session memory staging requires at least one event.');
199
+ }
200
+ for (const event of events) deps.enqueueMemoryEvent(vaultBase, event);
201
+
202
+ staged = {
203
+ ...identity,
204
+ disposition: 'applied',
205
+ state: 'enqueued',
206
+ event_ids: events.map((event) => String(event.event_id || '')),
207
+ checkpoint: null,
208
+ };
209
+ entry.last_memory_attempt = staged;
210
+ entry.memory_status = 'enqueued';
211
+ entry.memory_activation_id = context.activationId;
212
+ return staged;
213
+ });
214
+
215
+ return staged || skippedAttempt(context, 'v2', 'ambiguous');
216
+ }
217
+
218
+ function outcome(attempt, state, extra = {}) {
219
+ const eventIds = Array.isArray(attempt?.event_ids) ? [...attempt.event_ids] : [];
220
+ return {
221
+ ...attempt,
222
+ ...extra,
223
+ state,
224
+ status: state,
225
+ event_ids: eventIds,
226
+ eventIds,
227
+ eventCount: eventIds.length,
228
+ };
229
+ }
230
+
231
+ /** Project outside the registry lock. The outbox remains the recovery authority on failure. */
232
+ export function projectStopMemoryAttempt(vaultBase, attempt, overrides = {}) {
233
+ const deps = dependencies(overrides);
234
+ if (attempt?.memory_mode === 'legacy') {
235
+ return { ...outcome(attempt, 'skipped'), status: 'legacy' };
236
+ }
237
+ if (attempt?.state === 'duplicate') return outcome(attempt, 'duplicate');
238
+ if (attempt?.state === 'skipped') return outcome(attempt, 'skipped');
239
+
240
+ try {
241
+ const projection = deps.projectMemoryOutbox(vaultBase, overrides.projectOptions || {});
242
+ if (projection?.status === 'busy') {
243
+ return outcome(attempt, 'degraded', {
244
+ error: 'memory projector busy; outbox preserved for replay',
245
+ checkpoint: null,
246
+ });
247
+ }
248
+ return outcome(attempt, 'projected', {
249
+ checkpoint: {
250
+ revision: projection.revision,
251
+ event_cursor: projection.eventCursor,
252
+ state_hash: projection.stateHash,
253
+ },
254
+ });
255
+ } catch (error) {
256
+ return outcome(attempt, 'degraded', {
257
+ error: deps.sanitizeMemoryText(error?.message || String(error)),
258
+ checkpoint: null,
259
+ });
260
+ }
261
+ }
262
+
263
+ function activeContextMatches(entry, attempt) {
264
+ const activeId = String(entry?.active_activation_id || '');
265
+ const active = entry?.activations?.[activeId];
266
+ return activeId === String(attempt.activation_id || '')
267
+ && active?.status === 'active'
268
+ && nonNegativeInteger(active.epoch, -1) === nonNegativeInteger(attempt.activation_epoch, -1);
269
+ }
270
+
271
+ function storedAttempt(outcomeValue) {
272
+ const stored = {
273
+ v: 1,
274
+ memory_mode: 'v2',
275
+ canonical_session_id: String(outcomeValue.canonical_session_id || ''),
276
+ activation_id: String(outcomeValue.activation_id || ''),
277
+ activation_epoch: nonNegativeInteger(outcomeValue.activation_epoch, 0),
278
+ turn_id: String(outcomeValue.turn_id || ''),
279
+ turn_sequence: nonNegativeInteger(outcomeValue.turn_sequence, 0),
280
+ disposition: String(outcomeValue.disposition || 'applied'),
281
+ state: outcomeValue.state,
282
+ event_ids: Array.isArray(outcomeValue.event_ids) ? [...outcomeValue.event_ids] : [],
283
+ observed_at: String(outcomeValue.observed_at || DEFAULT_OBSERVED_AT),
284
+ };
285
+ if (outcomeValue.state === 'projected' && outcomeValue.checkpoint) {
286
+ stored.checkpoint = { ...outcomeValue.checkpoint };
287
+ }
288
+ if (outcomeValue.state === 'degraded' && outcomeValue.error) {
289
+ stored.error = String(outcomeValue.error);
290
+ }
291
+ return stored;
292
+ }
293
+
294
+ /** Persist a final outcome only while the exact staged activation/epoch/turn still owns it. */
295
+ export function recordStopMemoryOutcome(vaultBase, attempt, outcomeValue, overrides = {}) {
296
+ if (attempt?.memory_mode === 'legacy' || outcomeValue?.status === 'legacy') {
297
+ return { ...outcomeValue, persisted: false, reason: 'legacy' };
298
+ }
299
+ if (outcomeValue?.state === 'duplicate' || outcomeValue?.state === 'skipped') {
300
+ return { ...outcomeValue, persisted: false, reason: outcomeValue.state };
301
+ }
302
+ if (!sameAttempt(attempt, outcomeValue)) {
303
+ return { ...outcomeValue, persisted: false, reason: 'stale-causal-context' };
304
+ }
305
+ if (!['projected', 'degraded'].includes(outcomeValue?.state)) {
306
+ return { ...outcomeValue, persisted: false, reason: 'non-final-outcome' };
307
+ }
308
+
309
+ const deps = dependencies(overrides);
310
+ let result = { ...outcomeValue, persisted: false, reason: 'stale-causal-context' };
311
+ deps.mutateSessionRegistry(vaultBase, (registry) => {
312
+ const entry = registry.sessions?.[attempt.canonical_session_id];
313
+ if (!entry || !activeContextMatches(entry, attempt)) return result;
314
+ if (!sameAttempt(entry.last_memory_attempt, attempt)) return result;
315
+
316
+ entry.last_memory_attempt = storedAttempt(outcomeValue);
317
+ entry.memory_status = outcomeValue.state;
318
+ entry.memory_activation_id = attempt.activation_id;
319
+ if (outcomeValue.state === 'projected') {
320
+ entry.memory_checkpoint = { ...outcomeValue.checkpoint };
321
+ } else {
322
+ // This branch is reachable only after the exact attempt CAS above. A stale outcome
323
+ // cannot clear a checkpoint owned by a newer activation/turn.
324
+ delete entry.memory_checkpoint;
325
+ }
326
+ result = { ...outcomeValue, persisted: true, reason: 'recorded' };
327
+ return result;
328
+ });
329
+ return result;
330
+ }