wendkeep 0.58.1 → 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +120 -0
- package/README.en.md +70 -40
- package/README.md +70 -40
- package/bin/wendkeep.mjs +54 -6
- package/docs/en/commands/changes-and-verification.md +85 -0
- package/docs/en/commands/costs-and-observability.md +65 -0
- package/docs/en/commands/getting-started.md +86 -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 +102 -0
- package/docs/en/commands/notes-and-knowledge.md +70 -0
- package/docs/en/commands/operating-profiles.md +173 -0
- package/docs/en/commands/retroactive-import.md +67 -0
- package/docs/en/commands/sessions-and-import.md +89 -0
- package/docs/en/commands/verify.md +92 -0
- package/docs/pt-BR/commands/changes-and-verification.md +85 -0
- package/docs/pt-BR/commands/costs-and-observability.md +65 -0
- package/docs/pt-BR/commands/getting-started.md +87 -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 +99 -0
- package/docs/pt-BR/commands/notes-and-knowledge.md +69 -0
- package/docs/pt-BR/commands/operating-profiles.md +171 -0
- package/docs/pt-BR/commands/retroactive-import.md +67 -0
- package/docs/pt-BR/commands/sessions-and-import.md +89 -0
- package/docs/pt-BR/commands/verify.md +93 -0
- package/hooks/brain-core.mjs +159 -159
- package/hooks/brain-inject.mjs +83 -26
- package/hooks/brain-recall.mjs +32 -32
- package/hooks/brain-reindex.mjs +13 -13
- package/hooks/change-context.mjs +24 -10
- package/hooks/change-core.mjs +174 -37
- package/hooks/change-guard.mjs +115 -16
- package/hooks/change-nag.mjs +20 -5
- package/hooks/change-warn.mjs +27 -9
- package/hooks/decision-capture.mjs +1 -1
- package/hooks/derived-sections.mjs +1 -1
- package/hooks/flow-core.mjs +891 -0
- package/hooks/flow-protected-policy.mjs +218 -0
- package/hooks/frontmatter-repair.mjs +3 -1
- package/hooks/git-snapshot.mjs +722 -0
- package/hooks/import-sessions.mjs +10 -5
- package/hooks/memory-mode.mjs +63 -13
- package/hooks/memory-store.mjs +309 -69
- package/hooks/obsidian-common.mjs +119 -84
- package/hooks/operating-profile-runtime.mjs +157 -0
- package/hooks/plan-capture.mjs +14 -3
- package/hooks/sensors-core.mjs +15 -3
- package/hooks/session-backfill.mjs +7 -2
- package/hooks/session-ensure.mjs +21 -12
- package/hooks/session-iteration.mjs +65 -0
- package/hooks/session-memory-lifecycle.mjs +335 -0
- package/hooks/session-note-io.mjs +130 -15
- package/hooks/session-observability.mjs +4 -2
- package/hooks/session-stop.mjs +181 -59
- package/hooks/spec-core.mjs +91 -12
- package/hooks/subagent-stop.mjs +4 -1
- package/hooks/subagent-usage.mjs +2 -2
- package/hooks/task-log.mjs +3 -1
- package/hooks/token-usage.mjs +1 -1
- package/hooks/vault-health.mjs +268 -25
- package/hooks/vault-path-safety.mjs +558 -0
- package/hooks/vault-runtime-store.mjs +558 -0
- package/package.json +5 -3
- package/src/change.mjs +2 -1
- package/src/flow.mjs +232 -0
- package/src/init.mjs +26 -3
- package/src/memory.mjs +785 -35
- package/src/operating-profile.mjs +133 -0
- package/src/profile.mjs +224 -0
- package/src/project-vault.mjs +110 -5
- package/src/rebuild-costs.mjs +11 -4
- package/src/skills-seed.mjs +38 -16
- package/src/sync-defs.mjs +16 -7
- package/src/sync.mjs +9 -1
- package/src/taxonomy.mjs +9 -0
- package/src/validate-memory.mjs +21 -8
- package/src/verify.mjs +12 -2
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);
|
|
@@ -203,7 +214,7 @@ function maybeRetitleSession({ vaultBase, relPath, startedAt, input }) {
|
|
|
203
214
|
const sessionPath = join(vaultBase, nextRelPath);
|
|
204
215
|
const outcome = mutateSessionNote(sessionPath, (content) => (
|
|
205
216
|
updateSessionDescription(content, { relPath: nextRelPath, summary, startedAt })
|
|
206
|
-
));
|
|
217
|
+
), { vaultBase });
|
|
207
218
|
|
|
208
219
|
return { relPath: nextRelPath, summary, changed: nextRelPath !== relPath || outcome.written };
|
|
209
220
|
}
|
|
@@ -215,8 +226,10 @@ function stripClosingSection(content) {
|
|
|
215
226
|
return `${content.slice(0, index).trimEnd()}\n`;
|
|
216
227
|
}
|
|
217
228
|
|
|
218
|
-
function reopenSessionFile(sessionPath) {
|
|
219
|
-
mutateSessionNote(sessionPath, (content) => stripClosingSection(updateSessionFrontmatter(content))
|
|
229
|
+
function reopenSessionFile(vaultBase, sessionPath) {
|
|
230
|
+
mutateSessionNote(sessionPath, (content) => stripClosingSection(updateSessionFrontmatter(content)), {
|
|
231
|
+
vaultBase,
|
|
232
|
+
});
|
|
220
233
|
}
|
|
221
234
|
|
|
222
235
|
function findSessionForInput(vaultBase, input, control) {
|
|
@@ -249,7 +262,7 @@ function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, inp
|
|
|
249
262
|
const sessionPath = join(vaultBase, relPath);
|
|
250
263
|
if (!existsSync(sessionPath)) return false;
|
|
251
264
|
|
|
252
|
-
reopenSessionFile(sessionPath);
|
|
265
|
+
reopenSessionFile(vaultBase, sessionPath);
|
|
253
266
|
const nextStartedAt = startedAt || formatLocalIso(now);
|
|
254
267
|
writeControl(vaultBase, {
|
|
255
268
|
status: 'active',
|
|
@@ -268,8 +281,7 @@ function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, inp
|
|
|
268
281
|
transcript_path: identity.transcriptPath,
|
|
269
282
|
transcript_id: identity.transcriptId,
|
|
270
283
|
provider: identity.provider,
|
|
271
|
-
|
|
272
|
-
turn_sequence: turnSequenceFromInput(input),
|
|
284
|
+
...causalTurnPatch(input, now),
|
|
273
285
|
});
|
|
274
286
|
return true;
|
|
275
287
|
}
|
|
@@ -296,8 +308,7 @@ function createSession({ vaultBase, sessionId, input, now, identity }) {
|
|
|
296
308
|
transcript_path: identity.transcriptPath,
|
|
297
309
|
transcript_id: identity.transcriptId,
|
|
298
310
|
provider: identity.provider,
|
|
299
|
-
|
|
300
|
-
turn_sequence: turnSequenceFromInput(input),
|
|
311
|
+
...causalTurnPatch(input, now),
|
|
301
312
|
});
|
|
302
313
|
return { relPath, startedAt };
|
|
303
314
|
}
|
|
@@ -348,8 +359,7 @@ function main() {
|
|
|
348
359
|
transcript_path: identity.transcriptPath,
|
|
349
360
|
transcript_id: identity.transcriptId,
|
|
350
361
|
provider: identity.provider,
|
|
351
|
-
|
|
352
|
-
turn_sequence: turnSequenceFromInput(input),
|
|
362
|
+
...causalTurnPatch(input, now),
|
|
353
363
|
});
|
|
354
364
|
writeHookOutput({});
|
|
355
365
|
return;
|
|
@@ -393,8 +403,7 @@ function main() {
|
|
|
393
403
|
transcript_path: identity.transcriptPath,
|
|
394
404
|
transcript_id: identity.transcriptId,
|
|
395
405
|
provider: identity.provider,
|
|
396
|
-
|
|
397
|
-
turn_sequence: turnSequenceFromInput(input),
|
|
406
|
+
...causalTurnPatch(input, now),
|
|
398
407
|
});
|
|
399
408
|
writeHookOutput({});
|
|
400
409
|
return;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { hasTurnMarker, normalizeTurnMarkers, turnMarker } from './obsidian-common.mjs';
|
|
2
|
+
import { hasSessionFrontmatter, mutateSessionNote } from './session-note-io.mjs';
|
|
3
|
+
|
|
4
|
+
const ITERATION_ANCHORS = [
|
|
5
|
+
'\n## Agentes, tokens e custos',
|
|
6
|
+
'\n## Uso de tokens e custos',
|
|
7
|
+
'\n## Decisões geradas nesta sessão',
|
|
8
|
+
'\n## Bugs gerados nesta sessão',
|
|
9
|
+
'\n## Aprendizados gerados nesta sessão',
|
|
10
|
+
'\n## Arquivos consultados',
|
|
11
|
+
'\n## Arquivos criados ou alterados',
|
|
12
|
+
'\n## Pendências',
|
|
13
|
+
'\n## Encerramento',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export function insertIterationContent(original, { markerId, block }) {
|
|
17
|
+
if (!markerId) throw new TypeError('markerId é obrigatório');
|
|
18
|
+
let content = normalizeTurnMarkers(String(original || ''));
|
|
19
|
+
if (hasTurnMarker(content, markerId)) return { content, inserted: false };
|
|
20
|
+
|
|
21
|
+
const rendered = `\n${String(block || '').trim()}\n${turnMarker(markerId)}\n`;
|
|
22
|
+
const iterations = content.indexOf('\n## Iterações');
|
|
23
|
+
if (iterations !== -1) {
|
|
24
|
+
const anchors = ITERATION_ANCHORS
|
|
25
|
+
.map((anchor) => content.indexOf(anchor, iterations + 1))
|
|
26
|
+
.filter((index) => index !== -1)
|
|
27
|
+
.sort((left, right) => left - right);
|
|
28
|
+
if (anchors.length) {
|
|
29
|
+
const at = anchors[0];
|
|
30
|
+
content = `${content.slice(0, at).trimEnd()}\n${rendered}\n${content.slice(at).replace(/^\n+/, '')}`;
|
|
31
|
+
} else {
|
|
32
|
+
const lineEnd = content.indexOf('\n', iterations + 1);
|
|
33
|
+
const at = lineEnd === -1 ? content.length : lineEnd + 1;
|
|
34
|
+
content = `${content.slice(0, at).trimEnd()}\n${rendered}\n${content.slice(at).replace(/^\n+/, '')}`;
|
|
35
|
+
}
|
|
36
|
+
return { content, inserted: true };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const closing = content.indexOf('\n## Encerramento');
|
|
40
|
+
if (closing !== -1) {
|
|
41
|
+
content = `${content.slice(0, closing).trimEnd()}\n\n## Iterações\n${rendered}\n${content.slice(closing).replace(/^\n+/, '')}`;
|
|
42
|
+
} else {
|
|
43
|
+
content = `${content.trimEnd()}\n\n## Iterações\n${rendered}`;
|
|
44
|
+
}
|
|
45
|
+
return { content, inserted: true };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function projectSessionIteration(sessionPath, input, options = {}) {
|
|
49
|
+
let inserted = false;
|
|
50
|
+
let invalidFrontmatter = false;
|
|
51
|
+
const outcome = mutateSessionNote(sessionPath, (content) => {
|
|
52
|
+
if (!hasSessionFrontmatter(content)) {
|
|
53
|
+
invalidFrontmatter = true;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const result = insertIterationContent(content, input);
|
|
57
|
+
inserted = result.inserted;
|
|
58
|
+
return result.content;
|
|
59
|
+
}, options);
|
|
60
|
+
return {
|
|
61
|
+
inserted,
|
|
62
|
+
written: outcome.written,
|
|
63
|
+
reason: invalidFrontmatter ? 'invalid-frontmatter' : outcome.reason,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
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: projection.checkpoint && typeof projection.checkpoint === 'object'
|
|
250
|
+
? { ...projection.checkpoint }
|
|
251
|
+
: {
|
|
252
|
+
revision: projection.revision,
|
|
253
|
+
event_cursor: projection.ledgerCursor || projection.eventCursor,
|
|
254
|
+
state_hash: projection.stateHash,
|
|
255
|
+
...(projection.ledgerCursor && projection.eventCursor !== projection.ledgerCursor
|
|
256
|
+
? { causal_event_cursor: projection.eventCursor }
|
|
257
|
+
: {}),
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
} catch (error) {
|
|
261
|
+
return outcome(attempt, 'degraded', {
|
|
262
|
+
error: deps.sanitizeMemoryText(error?.message || String(error)),
|
|
263
|
+
checkpoint: null,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function activeContextMatches(entry, attempt) {
|
|
269
|
+
const activeId = String(entry?.active_activation_id || '');
|
|
270
|
+
const active = entry?.activations?.[activeId];
|
|
271
|
+
return activeId === String(attempt.activation_id || '')
|
|
272
|
+
&& active?.status === 'active'
|
|
273
|
+
&& nonNegativeInteger(active.epoch, -1) === nonNegativeInteger(attempt.activation_epoch, -1);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function storedAttempt(outcomeValue) {
|
|
277
|
+
const stored = {
|
|
278
|
+
v: 1,
|
|
279
|
+
memory_mode: 'v2',
|
|
280
|
+
canonical_session_id: String(outcomeValue.canonical_session_id || ''),
|
|
281
|
+
activation_id: String(outcomeValue.activation_id || ''),
|
|
282
|
+
activation_epoch: nonNegativeInteger(outcomeValue.activation_epoch, 0),
|
|
283
|
+
turn_id: String(outcomeValue.turn_id || ''),
|
|
284
|
+
turn_sequence: nonNegativeInteger(outcomeValue.turn_sequence, 0),
|
|
285
|
+
disposition: String(outcomeValue.disposition || 'applied'),
|
|
286
|
+
state: outcomeValue.state,
|
|
287
|
+
event_ids: Array.isArray(outcomeValue.event_ids) ? [...outcomeValue.event_ids] : [],
|
|
288
|
+
observed_at: String(outcomeValue.observed_at || DEFAULT_OBSERVED_AT),
|
|
289
|
+
};
|
|
290
|
+
if (outcomeValue.state === 'projected' && outcomeValue.checkpoint) {
|
|
291
|
+
stored.checkpoint = { ...outcomeValue.checkpoint };
|
|
292
|
+
}
|
|
293
|
+
if (outcomeValue.state === 'degraded' && outcomeValue.error) {
|
|
294
|
+
stored.error = String(outcomeValue.error);
|
|
295
|
+
}
|
|
296
|
+
return stored;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Persist a final outcome only while the exact staged activation/epoch/turn still owns it. */
|
|
300
|
+
export function recordStopMemoryOutcome(vaultBase, attempt, outcomeValue, overrides = {}) {
|
|
301
|
+
if (attempt?.memory_mode === 'legacy' || outcomeValue?.status === 'legacy') {
|
|
302
|
+
return { ...outcomeValue, persisted: false, reason: 'legacy' };
|
|
303
|
+
}
|
|
304
|
+
if (outcomeValue?.state === 'duplicate' || outcomeValue?.state === 'skipped') {
|
|
305
|
+
return { ...outcomeValue, persisted: false, reason: outcomeValue.state };
|
|
306
|
+
}
|
|
307
|
+
if (!sameAttempt(attempt, outcomeValue)) {
|
|
308
|
+
return { ...outcomeValue, persisted: false, reason: 'stale-causal-context' };
|
|
309
|
+
}
|
|
310
|
+
if (!['projected', 'degraded'].includes(outcomeValue?.state)) {
|
|
311
|
+
return { ...outcomeValue, persisted: false, reason: 'non-final-outcome' };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const deps = dependencies(overrides);
|
|
315
|
+
let result = { ...outcomeValue, persisted: false, reason: 'stale-causal-context' };
|
|
316
|
+
deps.mutateSessionRegistry(vaultBase, (registry) => {
|
|
317
|
+
const entry = registry.sessions?.[attempt.canonical_session_id];
|
|
318
|
+
if (!entry || !activeContextMatches(entry, attempt)) return result;
|
|
319
|
+
if (!sameAttempt(entry.last_memory_attempt, attempt)) return result;
|
|
320
|
+
|
|
321
|
+
entry.last_memory_attempt = storedAttempt(outcomeValue);
|
|
322
|
+
entry.memory_status = outcomeValue.state;
|
|
323
|
+
entry.memory_activation_id = attempt.activation_id;
|
|
324
|
+
if (outcomeValue.state === 'projected') {
|
|
325
|
+
entry.memory_checkpoint = { ...outcomeValue.checkpoint };
|
|
326
|
+
} else {
|
|
327
|
+
// This branch is reachable only after the exact attempt CAS above. A stale outcome
|
|
328
|
+
// cannot clear a checkpoint owned by a newer activation/turn.
|
|
329
|
+
delete entry.memory_checkpoint;
|
|
330
|
+
}
|
|
331
|
+
result = { ...outcomeValue, persisted: true, reason: 'recorded' };
|
|
332
|
+
return result;
|
|
333
|
+
});
|
|
334
|
+
return result;
|
|
335
|
+
}
|