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.
- package/CHANGELOG.md +61 -0
- package/README.en.md +28 -36
- package/README.md +28 -36
- package/docs/en/commands/changes-and-verification.md +79 -0
- package/docs/en/commands/costs-and-observability.md +65 -0
- package/docs/en/commands/getting-started.md +82 -0
- package/docs/en/commands/maintenance-and-diagnostics.md +77 -0
- package/docs/en/commands/memory-migration.md +73 -0
- package/docs/en/commands/memory.md +84 -0
- package/docs/en/commands/notes-and-knowledge.md +70 -0
- package/docs/en/commands/retroactive-import.md +67 -0
- package/docs/en/commands/sessions-and-import.md +85 -0
- package/docs/en/commands/verify.md +86 -0
- package/docs/pt-BR/commands/changes-and-verification.md +80 -0
- package/docs/pt-BR/commands/costs-and-observability.md +65 -0
- package/docs/pt-BR/commands/getting-started.md +83 -0
- package/docs/pt-BR/commands/maintenance-and-diagnostics.md +77 -0
- package/docs/pt-BR/commands/memory-migration.md +73 -0
- package/docs/pt-BR/commands/memory.md +83 -0
- package/docs/pt-BR/commands/notes-and-knowledge.md +69 -0
- package/docs/pt-BR/commands/retroactive-import.md +67 -0
- package/docs/pt-BR/commands/sessions-and-import.md +85 -0
- package/docs/pt-BR/commands/verify.md +87 -0
- package/hooks/brain-inject.mjs +2 -1
- package/hooks/memory-mode.mjs +39 -0
- package/hooks/memory-schema.mjs +15 -0
- package/hooks/obsidian-common.mjs +80 -29
- package/hooks/session-ensure.mjs +15 -8
- package/hooks/session-memory-lifecycle.mjs +330 -0
- package/hooks/session-stop.mjs +122 -42
- package/hooks/vault-health.mjs +124 -5
- package/package.json +3 -1
- package/src/memory.mjs +32 -7
- package/src/taxonomy.mjs +1 -0
package/hooks/memory-schema.mjs
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
457
|
+
let next = cloneRegistry(registry);
|
|
452
458
|
const sessionId = turn.session_id || turn.canonical_session_id || '';
|
|
453
|
-
|
|
459
|
+
let current = next.sessions[sessionId];
|
|
454
460
|
if (!current) return next;
|
|
455
461
|
|
|
456
|
-
const
|
|
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] = {
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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: '
|
|
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: '
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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:
|
|
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
|
}
|
package/hooks/session-ensure.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|