wendkeep 0.66.3 → 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/CHANGELOG.md +12 -0
- package/README.en.md +10 -5
- package/README.md +10 -5
- package/docs/en/commands/memory.md +31 -2
- package/docs/pt-BR/commands/memory.md +32 -2
- package/hooks/vault-health.mjs +6 -0
- package/package.json +1 -1
- package/packages/cli/src/index.mjs +1 -0
- package/packages/vault/src/memory-store.mjs +5 -0
- package/src/memory.mjs +603 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ All notable changes to **wendkeep** are documented here. Format based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.66.4] — 2026-07-30
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **`memory repair` fecha o acknowledgement apenas para a outbox que a própria execução
|
|
12
|
+
consumiu integralmente.** O receipt do projetor preserva idempotência e impede que attempts
|
|
13
|
+
históricos, parciais, causalmente divergentes ou concorrentes sejam reclassificados.
|
|
14
|
+
- **`memory recover-attempt <session>` recupera com segurança o estado já projetado pela
|
|
15
|
+
0.66.3.** O dry-run é padrão e `--apply` altera somente o registry/checkpoint sob prova física,
|
|
16
|
+
lock e CAS; A→B→A, ledger/sidecars divergentes, outbox pendente e links inseguros falham sem
|
|
17
|
+
escrita. O diagnóstico agora orienta esse comando quando o acknowledgement ficou pendente.
|
|
18
|
+
|
|
7
19
|
## [0.66.3] — 2026-07-30
|
|
8
20
|
|
|
9
21
|
### Fixed
|
package/README.en.md
CHANGED
|
@@ -287,8 +287,10 @@ lost publication, or mismatched checkpoint blocks. See [migration](docs/en/comma
|
|
|
287
287
|
and [diagnostics](docs/en/commands/maintenance-and-diagnostics.md).
|
|
288
288
|
|
|
289
289
|
If status blocks, preserve the evidence and run `wendkeep memory repair --vault <vault>` to back up
|
|
290
|
-
the corrupt ledger, retain valid lines, and re-project. Repair
|
|
291
|
-
|
|
290
|
+
the corrupt ledger, retain valid lines, and re-project. Repair remains structural: its only narrow
|
|
291
|
+
acknowledgement exception covers attempts entirely represented by the outbox consumed by that same
|
|
292
|
+
run; it does not scan or reclassify historical attempts. Valid pre-0.59 causal checkpoints and
|
|
293
|
+
exactly re-derived assert-only historical prefixes are
|
|
292
294
|
CAS-migrated on both the attempt and `memory_checkpoint` to the correct physical boundary with
|
|
293
295
|
backup/audit; divergent mirrors fail closed. A demonstrably superseded
|
|
294
296
|
ambiguity uses `memory reconcile <session> --by-session <successor>
|
|
@@ -303,9 +305,12 @@ final modern source: the same session/activation/epoch and a higher turn advance
|
|
|
303
305
|
superseded. Repair migrates the checkpoint and mirror only when it proves the exact previous
|
|
304
306
|
replay, attempt identity, and absence of a real conflict; it creates a backup and audit without
|
|
305
307
|
appending or rewriting events. Ambiguity stays queued for explicit curation. Do not publish or
|
|
306
|
-
install 0.66.2; use 0.66.3. Decisions survive repair/replay;
|
|
307
|
-
`blocked_by_core` cannot override CORE. Doctor only
|
|
308
|
-
|
|
308
|
+
install 0.66.2; use 0.66.3 or later. Decisions survive repair/replay;
|
|
309
|
+
`blocked_by_core` cannot override CORE. Doctor only diagnoses. When status/doctor reports projected
|
|
310
|
+
acknowledgement pending on 0.66.4 or later, first run the targeted dry run
|
|
311
|
+
`memory recover-attempt <session> --vault <vault>`, then authorize `--apply`; it changes only
|
|
312
|
+
registry/checkpoint. See syntax, preconditions, and fail-closed behavior in
|
|
313
|
+
[memory and curation](docs/en/commands/memory.md).
|
|
309
314
|
|
|
310
315
|
Session notes use one live `## Agentes, tokens e custos` snapshot. Main-agent and subagent hooks recompose it atomically, with costs, token dimensions, reasoning tokens and effort per model/source. Every hook that rewrites a session note takes a per-file lock and writes through a temp file + rename, so the `SubagentStop` fan-out (one hook run per subagent) can never leave a note half-written; a note whose frontmatter reads back damaged is left untouched rather than patched.
|
|
311
316
|
|
package/README.md
CHANGED
|
@@ -287,8 +287,10 @@ lost publication, or mismatched checkpoint blocks. See [migration](docs/en/comma
|
|
|
287
287
|
and [diagnostics](docs/en/commands/maintenance-and-diagnostics.md).
|
|
288
288
|
|
|
289
289
|
If status blocks, preserve the evidence and run `wendkeep memory repair --vault <vault>` to back up
|
|
290
|
-
the corrupt ledger, retain valid lines, and re-project. Repair
|
|
291
|
-
|
|
290
|
+
the corrupt ledger, retain valid lines, and re-project. Repair remains structural: its only narrow
|
|
291
|
+
acknowledgement exception covers attempts entirely represented by the outbox consumed by that same
|
|
292
|
+
run; it does not scan or reclassify historical attempts. Valid pre-0.59 causal checkpoints and
|
|
293
|
+
exactly re-derived assert-only historical prefixes are
|
|
292
294
|
CAS-migrated on both the attempt and `memory_checkpoint` to the correct physical boundary with
|
|
293
295
|
backup/audit; divergent mirrors fail closed. A demonstrably superseded
|
|
294
296
|
ambiguity uses `memory reconcile <session> --by-session <successor>
|
|
@@ -303,9 +305,12 @@ final modern source: the same session/activation/epoch and a higher turn advance
|
|
|
303
305
|
superseded. Repair migrates the checkpoint and mirror only when it proves the exact previous
|
|
304
306
|
replay, attempt identity, and absence of a real conflict; it creates a backup and audit without
|
|
305
307
|
appending or rewriting events. Ambiguity stays queued for explicit curation. Do not publish or
|
|
306
|
-
install 0.66.2; use 0.66.3. Decisions survive repair/replay;
|
|
307
|
-
`blocked_by_core` cannot override CORE. Doctor only
|
|
308
|
-
|
|
308
|
+
install 0.66.2; use 0.66.3 or later. Decisions survive repair/replay;
|
|
309
|
+
`blocked_by_core` cannot override CORE. Doctor only diagnoses. When status/doctor reports projected
|
|
310
|
+
acknowledgement pending on 0.66.4 or later, first run the targeted dry run
|
|
311
|
+
`memory recover-attempt <session> --vault <vault>`, then authorize `--apply`; it changes only
|
|
312
|
+
registry/checkpoint. See syntax, preconditions, and fail-closed behavior in
|
|
313
|
+
[memory and curation](docs/en/commands/memory.md).
|
|
309
314
|
|
|
310
315
|
Session notes use one live `## Agentes, tokens e custos` snapshot. Main-agent and subagent hooks recompose it atomically, with costs, token dimensions, reasoning tokens and effort per model/source. Every hook that rewrites a session note takes a per-file lock and writes through a temp file + rename, so the `SubagentStop` fan-out (one hook run per subagent) can never leave a note half-written; a note whose frontmatter reads back damaged is left untouched rather than patched.
|
|
311
316
|
|
|
@@ -25,6 +25,7 @@ Pass the vault explicitly in automation. Preserve backups and evidence before re
|
|
|
25
25
|
```bash
|
|
26
26
|
npx wendkeep memory status [--gate] --vault <vault>
|
|
27
27
|
npx wendkeep memory repair --vault <vault>
|
|
28
|
+
npx wendkeep memory recover-attempt <session> [--apply] --vault <vault>
|
|
28
29
|
npx wendkeep memory reconcile <ambiguous-session> --by-session <successor-session> --reason <reason> [--apply] --vault <vault>
|
|
29
30
|
npx wendkeep memory promote <candidate> [--event <event-id>] --vault <vault>
|
|
30
31
|
npx wendkeep memory reject <candidate> --vault <vault>
|
|
@@ -51,8 +52,23 @@ npx wendkeep validate-memory --vault <v2-vault>
|
|
|
51
52
|
historical prefix only when revision, cursor, hash, identity, turns, and the
|
|
52
53
|
`memory_checkpoint` mirror exactly reproduce the old semantics; the target is the current replay
|
|
53
54
|
of that prefix, without absorbing later events. Both paths CAS-check the attempt and mirror and
|
|
54
|
-
record backup/audit.
|
|
55
|
-
|
|
55
|
+
record backup/audit. The only narrow acknowledgement exception covers `enqueued`/`degraded`
|
|
56
|
+
attempts whose outbox was frozen and whose event IDs that same repair run consumed in full;
|
|
57
|
+
partial coverage does not change the attempt. Repair does not scan or reclassify historical
|
|
58
|
+
attempts and does not accept a tuple, operation, or mirror that cannot be fully re-derived.
|
|
59
|
+
- As of 0.66.4, `memory recover-attempt` targets one session and is a dry run by default. The
|
|
60
|
+
session must exist in the registry and its latest attempt must be `v2`, `applied`, and `enqueued`
|
|
61
|
+
or `degraded`, with non-empty, unique `event_ids`. Every event must be present in the ledger and
|
|
62
|
+
belong to the attempt's project/session/activation/epoch/turn; no later event from that session
|
|
63
|
+
or target event still in the outbox may exist, and SHARED/candidates must byte-for-byte reproduce
|
|
64
|
+
the full ledger projection. An already `projected` attempt is accepted only with a valid
|
|
65
|
+
checkpoint and returns `unchanged`.
|
|
66
|
+
- With `--apply`, `memory recover-attempt` changes only `SESSION_REGISTRY`: it marks
|
|
67
|
+
`last_memory_attempt`/`memory_status` as `projected` and stores the same checkpoint in the
|
|
68
|
+
attempt and `memory_checkpoint`. Ledger, CORE, SHARED, candidates, outbox, and notes remain
|
|
69
|
+
byte-identical. The command validates all authority again under `MEMORY.lock`, CAS-checks the
|
|
70
|
+
attempt, activation, epoch, turn, and checkpoint, and fails closed if any byte/context changes.
|
|
71
|
+
A busy lock is not reaped; retry after application returns `unchanged` without writing.
|
|
56
72
|
- `memory reconcile` is a dry run by default. `--apply` requires two named sessions plus a reason,
|
|
57
73
|
CAS-checks the exact attempt, backs up the registry, and limits mutation to the ambiguous attempt
|
|
58
74
|
and its successor. Replay is CORE-aware, checkpoints use the physical ledger cursor, and the
|
|
@@ -81,11 +97,16 @@ npx wendkeep validate-memory --vault <v2-vault>
|
|
|
81
97
|
identity, backup, audit, and CAS; it does not reorder, rewrite, or append a ledger event.
|
|
82
98
|
- `validate-memory <CORE.md>` checks the 25-line cap, required sections, and secrets.
|
|
83
99
|
- `validate-memory --vault` requires a complete v2 bundle and is not the legacy-vault gate.
|
|
100
|
+
- For `recover-attempt`, exit `0` means a valid dry run/apply, including `unchanged`; exit `1`
|
|
101
|
+
means a precondition, authority, CAS, topology, or lock check failed; exit `2` means a missing
|
|
102
|
+
session/`--vault`, unknown or duplicate option, extra argument, or invalid value.
|
|
84
103
|
|
|
85
104
|
## Examples
|
|
86
105
|
|
|
87
106
|
```bash
|
|
88
107
|
npx wendkeep memory status --gate --vault .MyApp-vault
|
|
108
|
+
npx wendkeep memory recover-attempt session-123 --vault .MyApp-vault
|
|
109
|
+
npx wendkeep memory recover-attempt session-123 --apply --vault .MyApp-vault
|
|
89
110
|
npx wendkeep memory reconcile old --by-session current --reason "delivery continued" --vault .MyApp-vault
|
|
90
111
|
npx wendkeep memory reconcile old --by-session current --reason "delivery continued" --apply --vault .MyApp-vault
|
|
91
112
|
npx wendkeep validate-memory .MyApp-vault/.brain/CORE.md
|
|
@@ -107,6 +128,14 @@ of a global projection that has already advanced with concurrent events.
|
|
|
107
128
|
repair merely to manufacture the first event.
|
|
108
129
|
- `degraded` with every event ID present in either the ledger or an intact outbox is recoverable;
|
|
109
130
|
let idempotent replay finish. An event ID absent from both locations means lost publication.
|
|
131
|
+
- Status/doctor reports `projected acknowledgement pending` and suggests
|
|
132
|
+
`memory recover-attempt <session>`: preserve the artifacts, inspect the dry-run JSON first, and
|
|
133
|
+
use `--apply` only when `eligible: true`. `dry-run` confirms eligibility; `applied` updates
|
|
134
|
+
registry/checkpoint; `unchanged` means the recovery was already applied idempotently.
|
|
135
|
+
- `recover-attempt` rejects a missing/divergent event, a target still in the outbox, stale
|
|
136
|
+
SHARED/candidates, a historical attempt, mismatched session/causal context, invalid checkpoint,
|
|
137
|
+
or busy lock. Do not bypass the gate by editing files: rerun `memory status --gate`, preserve
|
|
138
|
+
evidence, and resolve the divergent authority.
|
|
110
139
|
- An `ambiguous` attempt, an `applied` attempt without event IDs, a `projected` event found only in
|
|
111
140
|
the outbox, or a mismatched checkpoint is blocking: preserve the artifacts and investigate
|
|
112
141
|
before repair. If the ambiguity is demonstrably superseded by a successor session, inspect the
|
|
@@ -25,6 +25,7 @@ Informe o vault explicitamente em automações. Preserve backups e evidências a
|
|
|
25
25
|
```bash
|
|
26
26
|
npx wendkeep memory status [--gate] --vault <cofre>
|
|
27
27
|
npx wendkeep memory repair --vault <cofre>
|
|
28
|
+
npx wendkeep memory recover-attempt <sessão> [--apply] --vault <cofre>
|
|
28
29
|
npx wendkeep memory reconcile <sessão-ambígua> --by-session <sessão-sucessora> --reason <motivo> [--apply] --vault <cofre>
|
|
29
30
|
npx wendkeep memory promote <candidate> [--event <event-id>] --vault <cofre>
|
|
30
31
|
npx wendkeep memory reject <candidate> --vault <cofre>
|
|
@@ -51,8 +52,24 @@ npx wendkeep validate-memory --vault <cofre-v2>
|
|
|
51
52
|
assert-only somente quando revision, cursor, hash, identidade, turns e o espelho
|
|
52
53
|
`memory_checkpoint` reproduzem exatamente a semântica antiga; o alvo é o replay atual daquele
|
|
53
54
|
prefixo, sem absorver eventos posteriores. Ambos os casos fazem CAS do attempt e do espelho e
|
|
54
|
-
registram backup/auditoria.
|
|
55
|
-
|
|
55
|
+
registram backup/auditoria. A única exceção estreita de acknowledgement cobre attempts
|
|
56
|
+
`enqueued`/`degraded` cuja outbox foi congelada e cujos event IDs a mesma execução do repair
|
|
57
|
+
consumiu integralmente; cobertura parcial não altera o attempt. O repair não varre nem
|
|
58
|
+
reclassifica attempts históricos e não aceita tuple, operação ou espelho que não seja
|
|
59
|
+
rederivado integralmente.
|
|
60
|
+
- Desde a 0.66.4, `memory recover-attempt` é dirigido a uma única sessão e faz dry-run por padrão.
|
|
61
|
+
A sessão deve existir no registry e possuir o último attempt `v2`, `applied`, em `enqueued` ou
|
|
62
|
+
`degraded`, com `event_ids` não vazios e únicos. Todos os eventos devem estar integralmente no
|
|
63
|
+
ledger, pertencer ao mesmo projeto/sessão/activation/epoch/turn do attempt, não pode haver evento
|
|
64
|
+
posterior da mesma sessão nem evento alvo restante na outbox, e SHARED/candidates devem
|
|
65
|
+
reproduzir byte a byte a projeção integral do ledger. Um attempt já `projected` só é aceito com
|
|
66
|
+
checkpoint válido e retorna `unchanged`.
|
|
67
|
+
- Com `--apply`, `memory recover-attempt` altera somente `SESSION_REGISTRY`: marca
|
|
68
|
+
`last_memory_attempt`/`memory_status` como `projected` e grava o checkpoint idêntico no attempt e
|
|
69
|
+
em `memory_checkpoint`. Ledger, CORE, SHARED, candidates, outbox e notas permanecem byte-intactos.
|
|
70
|
+
O comando valida novamente toda a autoridade sob `MEMORY.lock`, faz CAS do attempt, activation,
|
|
71
|
+
epoch, turno e checkpoint e falha fechado se qualquer byte/contexto mudar. Lock ocupado não é
|
|
72
|
+
colhido; retry após aplicação retorna `unchanged` sem escrita.
|
|
56
73
|
- `memory reconcile` é dry-run por padrão. `--apply` exige duas sessões nomeadas e motivo, faz CAS
|
|
57
74
|
do attempt exato, salva backup do registry e limita a mutação ao attempt ambíguo e à sucessora.
|
|
58
75
|
O replay é CORE-aware, usa cursor físico do ledger no checkpoint e não reescreve ledger, CORE ou
|
|
@@ -78,11 +95,16 @@ npx wendkeep validate-memory --vault <cofre-v2>
|
|
|
78
95
|
CAS; ele não reordena, reescreve nem acrescenta evento ao ledger.
|
|
79
96
|
- `validate-memory <CORE.md>` valida cap de 25 linhas, seções e segredos.
|
|
80
97
|
- `validate-memory --vault` exige bundle v2 completo; não é o gate correto para vault legado.
|
|
98
|
+
- Para `recover-attempt`, exit `0` indica dry-run/apply válido, inclusive `unchanged`; exit `1`
|
|
99
|
+
indica falha de pré-condição, autoridade, CAS, topologia ou lock; exit `2` indica
|
|
100
|
+
sessão/`--vault` ausente, opção desconhecida/duplicada, argumento extra ou valor inválido.
|
|
81
101
|
|
|
82
102
|
## Exemplos
|
|
83
103
|
|
|
84
104
|
```bash
|
|
85
105
|
npx wendkeep memory status --gate --vault .MeuApp-vault
|
|
106
|
+
npx wendkeep memory recover-attempt sessao-123 --vault .MeuApp-vault
|
|
107
|
+
npx wendkeep memory recover-attempt sessao-123 --apply --vault .MeuApp-vault
|
|
86
108
|
npx wendkeep memory reconcile antiga --by-session atual --reason "entrega continuada" --vault .MeuApp-vault
|
|
87
109
|
npx wendkeep memory reconcile antiga --by-session atual --reason "entrega continuada" --apply --vault .MeuApp-vault
|
|
88
110
|
npx wendkeep validate-memory .MeuApp-vault/.brain/CORE.md
|
|
@@ -104,6 +126,14 @@ prefixo válido de uma projeção global que já avançou com eventos concorrent
|
|
|
104
126
|
fabricar o primeiro evento.
|
|
105
127
|
- `degraded` com todos os event IDs presentes no ledger ou na outbox íntegra é recuperável; deixe o
|
|
106
128
|
replay idempotente concluir. Event ID ausente nos dois lugares indica publicação perdida.
|
|
129
|
+
- Status/doctor informa `acknowledgement projetado pendente` e sugere
|
|
130
|
+
`memory recover-attempt <sessão>`: preserve os artefatos, revise primeiro o JSON do dry-run e só
|
|
131
|
+
use `--apply` se `eligible: true`. `dry-run` confirma elegibilidade; `applied` atualiza
|
|
132
|
+
registry/checkpoint; `unchanged` indica que a recuperação já foi aplicada de forma idempotente.
|
|
133
|
+
- `recover-attempt` recusa evento ausente/divergente, outbox alvo ainda presente, SHARED/candidates
|
|
134
|
+
stale, attempt histórico, sessão/contexto causal divergente, checkpoint inválido ou lock ocupado.
|
|
135
|
+
Não tente contornar o gate com edição manual: rode novamente `memory status --gate`, preserve a
|
|
136
|
+
evidência e resolva a autoridade divergente.
|
|
107
137
|
- Attempt `ambiguous`, attempt `applied` sem event IDs, evento `projected` apenas na outbox ou
|
|
108
138
|
checkpoint divergente são bloqueantes: preserve os artefatos e investigue antes de repair. Se a
|
|
109
139
|
ambiguidade for comprovadamente substituída por uma sessão sucessora, revise o dry-run de
|
package/hooks/vault-health.mjs
CHANGED
|
@@ -338,6 +338,12 @@ function checkMemoryAttempts(registry, {
|
|
|
338
338
|
const missing = eventIds.filter((eventId) => !ledgerEventIds.has(eventId) && !outboxEventIds.has(eventId));
|
|
339
339
|
if (missing.length) {
|
|
340
340
|
failures.push(`Attempt v2 perdeu ${missing.length} evento(s): ausentes do ledger e da outbox. Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
|
|
341
|
+
} else if (
|
|
342
|
+
state === 'enqueued'
|
|
343
|
+
&& eventIds.every((eventId) => ledgerEventIds.has(eventId))
|
|
344
|
+
&& eventIds.every((eventId) => !outboxEventIds.has(eventId))
|
|
345
|
+
) {
|
|
346
|
+
warnings.push(`Attempt de memória v2 possui acknowledgement projetado pendente. Recupere com: wendkeep memory recover-attempt ${sessionId}.`);
|
|
341
347
|
} else {
|
|
342
348
|
warnings.push(`Attempt de memória v2 ${state} permanece recuperável: ${eventIds.length} evento(s) durável(is) no ledger e/ou outbox.`);
|
|
343
349
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.66.
|
|
3
|
+
"version": "0.66.4",
|
|
4
4
|
"description": "Vault-first persistent memory for AI coding agents, with an optional profile-aware governance runtime: OFF, FLOW, GUIDE, GOVERN, or ASSURE. Local-first and agent-agnostic (Claude Code, Codex, Cursor…).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -100,6 +100,7 @@ Usage:
|
|
|
100
100
|
behind the closing block. Dry-run by default · --apply · --json.
|
|
101
101
|
wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
|
|
102
102
|
wendkeep memory <sub> Shared memory v2: status | migrate [--apply] | repair |
|
|
103
|
+
recover-attempt <session> [--apply] |
|
|
103
104
|
reconcile <session> --by-session <session> --reason <text> [--apply] |
|
|
104
105
|
promote <candidate> [--event <event-id>] | reject <candidate>. --vault P.
|
|
105
106
|
Reconcile is dry-run by default; the original attempt remains audited.
|
|
@@ -313,6 +313,8 @@ export function readMemoryLedger(vaultBase) {
|
|
|
313
313
|
if (eventIds.has(parsed.event_id)) {
|
|
314
314
|
if (eventPayloads.get(parsed.event_id) !== payload) {
|
|
315
315
|
errors.push(ledgerError(lineNumber, `event_id collision: ${parsed.event_id}`, partial));
|
|
316
|
+
} else {
|
|
317
|
+
errors.push(ledgerError(lineNumber, `duplicate event_id: ${parsed.event_id}`, partial));
|
|
316
318
|
}
|
|
317
319
|
return;
|
|
318
320
|
}
|
|
@@ -911,15 +913,18 @@ function projectLocked(vaultBase, { faultAt } = {}) {
|
|
|
911
913
|
const projection = publishMemoryProjection(vaultBase, prepared);
|
|
912
914
|
injectFault(faultAt, 'after-projection');
|
|
913
915
|
|
|
916
|
+
const consumedEventIds = [];
|
|
914
917
|
for (const entry of outbox) {
|
|
915
918
|
unlinkVaultFile(vaultBase, entry.path, {
|
|
916
919
|
missingOk: false, label: 'evento consumido do outbox de memória',
|
|
917
920
|
});
|
|
921
|
+
consumedEventIds.push(entry.event.event_id);
|
|
918
922
|
}
|
|
919
923
|
return {
|
|
920
924
|
status: 'projected',
|
|
921
925
|
appended: newEvents.length,
|
|
922
926
|
consumed: outbox.length,
|
|
927
|
+
consumedEventIds,
|
|
923
928
|
pending: 0,
|
|
924
929
|
...projection,
|
|
925
930
|
};
|
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';
|
|
@@ -538,6 +538,520 @@ function memoryCheckpointFingerprint(entry) {
|
|
|
538
538
|
}));
|
|
539
539
|
}
|
|
540
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
|
+
|
|
541
1055
|
function matchingAppliedReconciliation(entry, request) {
|
|
542
1056
|
const attempt = entry?.last_memory_attempt;
|
|
543
1057
|
if (attempt?.memory_mode !== 'v2' || attempt?.state !== 'skipped' || attempt?.disposition !== 'superseded') return null;
|
|
@@ -1217,13 +1731,28 @@ export function migrateLegacyMemoryCheckpoints(vault, {
|
|
|
1217
1731
|
}
|
|
1218
1732
|
|
|
1219
1733
|
export function repairMemory(vault, options = {}) {
|
|
1220
|
-
|
|
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);
|
|
1221
1743
|
if (repaired.status === 'busy') return repaired;
|
|
1222
1744
|
const projection = projectMemoryOutbox(vault);
|
|
1223
1745
|
if (projection.status === 'busy') return { status: 'busy', repaired, projection };
|
|
1746
|
+
const attemptAcknowledgements = acknowledgeRepairAttempts(
|
|
1747
|
+
vault, frozenAttempts, projection, options,
|
|
1748
|
+
);
|
|
1224
1749
|
const checkpointMigration = migrateLegacyMemoryCheckpoints(vault, options);
|
|
1225
1750
|
return {
|
|
1226
|
-
status: 'repaired',
|
|
1751
|
+
status: 'repaired',
|
|
1752
|
+
repaired,
|
|
1753
|
+
projection,
|
|
1754
|
+
attemptAcknowledgements,
|
|
1755
|
+
checkpointMigration,
|
|
1227
1756
|
};
|
|
1228
1757
|
}
|
|
1229
1758
|
|
|
@@ -1317,9 +1846,61 @@ function parseReconcileArgs(argv) {
|
|
|
1317
1846
|
};
|
|
1318
1847
|
}
|
|
1319
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
|
+
|
|
1320
1900
|
export function runMemory(argv) {
|
|
1321
1901
|
const [sub, positional] = argv;
|
|
1322
1902
|
let reconcileArgs = null;
|
|
1903
|
+
let recoverAttemptArgs = null;
|
|
1323
1904
|
if (sub === 'reconcile') {
|
|
1324
1905
|
try {
|
|
1325
1906
|
reconcileArgs = parseReconcileArgs(argv);
|
|
@@ -1329,7 +1910,18 @@ export function runMemory(argv) {
|
|
|
1329
1910
|
return;
|
|
1330
1911
|
}
|
|
1331
1912
|
}
|
|
1332
|
-
|
|
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;
|
|
1333
1925
|
if (!vault) { process.stderr.write('wendkeep memory: passe --vault <path>.\n'); process.exitCode = 2; return; }
|
|
1334
1926
|
if (!existsSync(vault)) { process.stderr.write(`wendkeep memory: not found: ${vault}\n`); process.exitCode = 2; return; }
|
|
1335
1927
|
try {
|
|
@@ -1345,6 +1937,12 @@ export function runMemory(argv) {
|
|
|
1345
1937
|
apply: reconcileArgs.apply,
|
|
1346
1938
|
});
|
|
1347
1939
|
}
|
|
1940
|
+
else if (sub === 'recover-attempt') {
|
|
1941
|
+
result = recoverProjectedAttempt(vault, {
|
|
1942
|
+
sessionId: recoverAttemptArgs.sessionId,
|
|
1943
|
+
apply: recoverAttemptArgs.apply,
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1348
1946
|
else if (sub === 'promote' || sub === 'reject') {
|
|
1349
1947
|
const eventId = option(argv, '--event');
|
|
1350
1948
|
if (sub === 'reject' && eventId) throw memoryUsageError('--event é permitido somente em memory promote.');
|
|
@@ -1352,7 +1950,7 @@ export function runMemory(argv) {
|
|
|
1352
1950
|
action: sub, candidateId: positional, ...(eventId ? { eventId } : {}),
|
|
1353
1951
|
});
|
|
1354
1952
|
}
|
|
1355
|
-
else { process.stderr.write('wendkeep memory: use status | migrate [--apply] | repair | reconcile <session> --by-session <session> --reason <text> [--apply] | promote <candidate> [--event <event-id>] | reject <candidate>.\n'); process.exitCode = 2; return; }
|
|
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; }
|
|
1356
1954
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
1357
1955
|
if (sub === 'status' && argv.includes('--gate')) process.exitCode = result.status === 'blocked' ? 1 : 0;
|
|
1358
1956
|
else if (sub === 'reconcile' && reconcileArgs.apply) process.exitCode = result.health?.status === 'blocked' ? 1 : 0;
|