wendkeep 0.66.0 → 0.66.1
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 +4 -1
- package/README.md +4 -1
- package/docs/en/commands/memory-migration.md +2 -1
- package/docs/en/commands/memory.md +14 -3
- package/docs/pt-BR/commands/memory-migration.md +2 -1
- package/docs/pt-BR/commands/memory.md +13 -3
- package/hooks/brain-core.mjs +159 -159
- package/hooks/brain-recall.mjs +32 -32
- package/hooks/brain-reindex.mjs +13 -13
- package/package.json +1 -1
- package/packages/cli/src/index.mjs +1 -1
- package/packages/vault/src/memory-schema.mjs +25 -0
- package/packages/vault/src/memory-store.mjs +22 -2
- package/src/memory.mjs +177 -17
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.1] — 2026-07-29
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **A curadoria de candidates agora é durável e idempotente.** `memory promote` e
|
|
12
|
+
`memory reject` registram decisões causais no ledger; repair/replay não recriam o conflito
|
|
13
|
+
resolvido, retries não duplicam eventos, candidates sobrepostos permanecem isolados e a
|
|
14
|
+
promoção recupera o checkpoint divergente do attempt correspondente sem tocar attempt novo.
|
|
15
|
+
- **Promoção de conflito exige escolha explícita.** `memory promote <candidate> --event
|
|
16
|
+
<event-id>` publica exatamente o evento escolhido, rejeita IDs externos ao candidate e não
|
|
17
|
+
permite que `blocked_by_core` sobrescreva a autoridade canônica de CORE.
|
|
18
|
+
|
|
7
19
|
## [0.66.0] — 2026-07-29
|
|
8
20
|
|
|
9
21
|
### Added
|
package/README.en.md
CHANGED
|
@@ -294,7 +294,10 @@ backup/audit; divergent mirrors fail closed. A demonstrably superseded
|
|
|
294
294
|
ambiguity uses `memory reconcile <session> --by-session <successor>
|
|
295
295
|
--reason <reason>` as a dry run and requires `--apply`; the decision is backed up and audited
|
|
296
296
|
without rewriting ledger, CORE, or notes. Run `status --gate` again afterwards. Conflicts require
|
|
297
|
-
explicit curation
|
|
297
|
+
explicit, durable curation: `memory promote <id> --event <event-id>` selects one event from the
|
|
298
|
+
candidate, while `memory reject <id>` keeps the current value. Decisions are idempotent and
|
|
299
|
+
survive repair/replay; `blocked_by_core` cannot override CORE. Doctor only diagnoses. See
|
|
300
|
+
[memory and curation](docs/en/commands/memory.md).
|
|
298
301
|
|
|
299
302
|
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.
|
|
300
303
|
|
package/README.md
CHANGED
|
@@ -294,7 +294,10 @@ backup/audit; divergent mirrors fail closed. A demonstrably superseded
|
|
|
294
294
|
ambiguity uses `memory reconcile <session> --by-session <successor>
|
|
295
295
|
--reason <reason>` as a dry run and requires `--apply`; the decision is backed up and audited
|
|
296
296
|
without rewriting ledger, CORE, or notes. Run `status --gate` again afterwards. Conflicts require
|
|
297
|
-
explicit curation
|
|
297
|
+
explicit, durable curation: `memory promote <id> --event <event-id>` selects one event from the
|
|
298
|
+
candidate, while `memory reject <id>` keeps the current value. Decisions are idempotent and
|
|
299
|
+
survive repair/replay; `blocked_by_core` cannot override CORE. Doctor only diagnoses. See
|
|
300
|
+
[memory and curation](docs/en/commands/memory.md).
|
|
298
301
|
|
|
299
302
|
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.
|
|
300
303
|
|
|
@@ -64,7 +64,8 @@ to revision 1. Replaying that prompt or Stop does not duplicate the event/revisi
|
|
|
64
64
|
- Partial/corrupt v2 bundle: use status and repair; migration is not a corruption tool.
|
|
65
65
|
- First post-migration Stop is `ambiguous`: verify that its `turn_id` belongs to the transcript and
|
|
66
66
|
that `UserPromptSubmit` opened/advanced the recovery activation.
|
|
67
|
-
- Many candidates: curate gradually with `memory promote`/`memory reject
|
|
67
|
+
- Many candidates: curate gradually with `memory promote`/`memory reject`; conflicts require
|
|
68
|
+
`memory promote <candidate> --event <event-id>` to choose the winner explicitly.
|
|
68
69
|
- Legacy warning remains after apply: verify the selected vault and project binding.
|
|
69
70
|
|
|
70
71
|
## Next steps
|
|
@@ -26,7 +26,7 @@ Pass the vault explicitly in automation. Preserve backups and evidence before re
|
|
|
26
26
|
npx wendkeep memory status [--gate] --vault <vault>
|
|
27
27
|
npx wendkeep memory repair --vault <vault>
|
|
28
28
|
npx wendkeep memory reconcile <ambiguous-session> --by-session <successor-session> --reason <reason> [--apply] --vault <vault>
|
|
29
|
-
npx wendkeep memory promote <candidate> --vault <vault>
|
|
29
|
+
npx wendkeep memory promote <candidate> [--event <event-id>] --vault <vault>
|
|
30
30
|
npx wendkeep memory reject <candidate> --vault <vault>
|
|
31
31
|
npx wendkeep validate-memory [CORE-path]
|
|
32
32
|
npx wendkeep validate-memory --vault <v2-vault>
|
|
@@ -63,7 +63,14 @@ npx wendkeep validate-memory --vault <v2-vault>
|
|
|
63
63
|
Junctions, symlinks, reparse points, or hardlinks fail closed without touching external bytes.
|
|
64
64
|
Locks publish owner and lease atomically, never reap a live PID by age alone, and release only
|
|
65
65
|
the lease they acquired.
|
|
66
|
-
- `promote`/`reject` append auditable
|
|
66
|
+
- `promote`/`reject` append an auditable, idempotent decision to the ledger. Replay and repair
|
|
67
|
+
preserve that decision and do not recreate the resolved candidate. For a `conflict` candidate,
|
|
68
|
+
`promote` requires an `--event <event-id>` that belongs to the candidate; date or random ID
|
|
69
|
+
never picks an implicit winner. `reject` preserves the current operational value. A
|
|
70
|
+
`blocked_by_core` candidate can only be rejected: promotion first requires canonical CORE
|
|
71
|
+
curation. If the selected event still belongs to the matching latest `projected` attempt,
|
|
72
|
+
promotion also refreshes its checkpoint and mirror causally; JSON reports
|
|
73
|
+
`checkpointRefreshed`, and a newer concurrent attempt remains untouched.
|
|
67
74
|
- `validate-memory <CORE.md>` checks the 25-line cap, required sections, and secrets.
|
|
68
75
|
- `validate-memory --vault` requires a complete v2 bundle and is not the legacy-vault gate.
|
|
69
76
|
|
|
@@ -74,7 +81,8 @@ npx wendkeep memory status --gate --vault .MyApp-vault
|
|
|
74
81
|
npx wendkeep memory reconcile old --by-session current --reason "delivery continued" --vault .MyApp-vault
|
|
75
82
|
npx wendkeep memory reconcile old --by-session current --reason "delivery continued" --apply --vault .MyApp-vault
|
|
76
83
|
npx wendkeep validate-memory .MyApp-vault/.brain/CORE.md
|
|
77
|
-
npx wendkeep memory promote candidate-123 --vault .MyApp-vault
|
|
84
|
+
npx wendkeep memory promote candidate-123 --event mem-selected --vault .MyApp-vault
|
|
85
|
+
npx wendkeep memory reject candidate-456 --vault .MyApp-vault
|
|
78
86
|
```
|
|
79
87
|
|
|
80
88
|
## Expected result
|
|
@@ -97,6 +105,9 @@ of a global projection that has already advanced with concurrent events.
|
|
|
97
105
|
`memory reconcile` dry run before authorizing `--apply`; the command fails when the ambiguous
|
|
98
106
|
attempt already contains event IDs.
|
|
99
107
|
- Ordinary pending candidate: recoverable warning, requiring human choice when appropriate.
|
|
108
|
+
- `promote` reports that `--event` is required: inspect the candidate `event_ids`, compare their
|
|
109
|
+
provenance/value, and name the winner explicitly. An ID outside the candidate fails without
|
|
110
|
+
mutating the ledger or projections.
|
|
100
111
|
- Missing `event_cursor` or mismatched v2 hash: preserve the bundle and assess `memory repair`.
|
|
101
112
|
- `validate-memory --vault` fails on legacy: validate CORE only or migrate first.
|
|
102
113
|
|
|
@@ -64,7 +64,8 @@ revision 1. Repetir esse prompt ou Stop não duplica evento/revision.
|
|
|
64
64
|
- Bundle v2 parcial/corrompido: use status e repair; migração não é ferramenta de corrupção.
|
|
65
65
|
- Primeiro Stop pós-migração fica `ambiguous`: confirme que o `turn_id` pertence ao transcript e
|
|
66
66
|
que `UserPromptSubmit` abriu/avançou a activation de recuperação.
|
|
67
|
-
- Candidates numerosos: curate gradualmente com `memory promote`/`memory reject
|
|
67
|
+
- Candidates numerosos: curate gradualmente com `memory promote`/`memory reject`; conflitos
|
|
68
|
+
exigem `memory promote <candidate> --event <event-id>` para escolher o vencedor explicitamente.
|
|
68
69
|
- Warning legado após apply: confirme o vault efetivamente selecionado e o vínculo do projeto.
|
|
69
70
|
|
|
70
71
|
## Próximos passos
|
|
@@ -26,7 +26,7 @@ Informe o vault explicitamente em automações. Preserve backups e evidências a
|
|
|
26
26
|
npx wendkeep memory status [--gate] --vault <cofre>
|
|
27
27
|
npx wendkeep memory repair --vault <cofre>
|
|
28
28
|
npx wendkeep memory reconcile <sessão-ambígua> --by-session <sessão-sucessora> --reason <motivo> [--apply] --vault <cofre>
|
|
29
|
-
npx wendkeep memory promote <candidate> --vault <cofre>
|
|
29
|
+
npx wendkeep memory promote <candidate> [--event <event-id>] --vault <cofre>
|
|
30
30
|
npx wendkeep memory reject <candidate> --vault <cofre>
|
|
31
31
|
npx wendkeep validate-memory [caminho-do-CORE]
|
|
32
32
|
npx wendkeep validate-memory --vault <cofre-v2>
|
|
@@ -61,7 +61,13 @@ npx wendkeep validate-memory --vault <cofre-v2>
|
|
|
61
61
|
candidates, registry, notas, backups, temporários e sidecars antes de ler ou escrever. Junction,
|
|
62
62
|
symlink, reparse point ou hardlink falham fechados sem tocar bytes externos. Locks publicam owner
|
|
63
63
|
e lease atomicamente, não colhem PID vivo apenas por idade e só liberam a lease adquirida.
|
|
64
|
-
- `promote`/`reject` acrescentam decisão auditável
|
|
64
|
+
- `promote`/`reject` acrescentam uma decisão auditável e idempotente ao ledger. Replay e repair
|
|
65
|
+
preservam a decisão e não recriam o candidate resolvido. Para candidate `conflict`, `promote`
|
|
66
|
+
exige `--event <event-id>` pertencente ao candidate; não há vencedor implícito por data ou ID.
|
|
67
|
+
`reject` preserva o valor operacional atual. Candidate `blocked_by_core` só pode ser rejeitado:
|
|
68
|
+
promover exige antes alterar CORE pela curadoria canônica. Se o evento escolhido ainda pertence
|
|
69
|
+
ao último attempt `projected` correspondente, a promoção também atualiza causalmente checkpoint
|
|
70
|
+
e espelho; o JSON retorna `checkpointRefreshed`, e um attempt concorrente mais novo não é tocado.
|
|
65
71
|
- `validate-memory <CORE.md>` valida cap de 25 linhas, seções e segredos.
|
|
66
72
|
- `validate-memory --vault` exige bundle v2 completo; não é o gate correto para vault legado.
|
|
67
73
|
|
|
@@ -72,7 +78,8 @@ npx wendkeep memory status --gate --vault .MeuApp-vault
|
|
|
72
78
|
npx wendkeep memory reconcile antiga --by-session atual --reason "entrega continuada" --vault .MeuApp-vault
|
|
73
79
|
npx wendkeep memory reconcile antiga --by-session atual --reason "entrega continuada" --apply --vault .MeuApp-vault
|
|
74
80
|
npx wendkeep validate-memory .MeuApp-vault/.brain/CORE.md
|
|
75
|
-
npx wendkeep memory promote candidate-123 --vault .MeuApp-vault
|
|
81
|
+
npx wendkeep memory promote candidate-123 --event mem-escolhido --vault .MeuApp-vault
|
|
82
|
+
npx wendkeep memory reject candidate-456 --vault .MeuApp-vault
|
|
76
83
|
```
|
|
77
84
|
|
|
78
85
|
## Resultado esperado
|
|
@@ -94,6 +101,9 @@ prefixo válido de uma projeção global que já avançou com eventos concorrent
|
|
|
94
101
|
ambiguidade for comprovadamente substituída por uma sessão sucessora, revise o dry-run de
|
|
95
102
|
`memory reconcile` antes de autorizar `--apply`; o comando falha se o attempt ambíguo tiver IDs.
|
|
96
103
|
- Candidate pendente comum: warning recuperável, exige decisão humana quando apropriado.
|
|
104
|
+
- `promote` informa que `--event` é obrigatório: leia os `event_ids` do candidate, compare a
|
|
105
|
+
proveniência/valor e indique explicitamente o vencedor. ID que não pertence ao candidate falha
|
|
106
|
+
sem mutar ledger ou projeções.
|
|
97
107
|
- `event_cursor` ausente ou hash divergente em v2: preserve o bundle e avalie `memory repair`.
|
|
98
108
|
- `validate-memory --vault` falha no legado: valide apenas CORE ou migre primeiro.
|
|
99
109
|
|
package/hooks/brain-core.mjs
CHANGED
|
@@ -1,159 +1,159 @@
|
|
|
1
|
-
// .agent/hooks/brain-core.mjs
|
|
2
|
-
// Camada fria do brain: indexa o frontmatter das notas de sessão (0 token LLM).
|
|
3
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { basename, join } from 'node:path';
|
|
5
|
-
import { ensureDir, stripYamlQuotes, toVaultRelative } from './obsidian-common.mjs';
|
|
6
|
-
import { getLocale } from './locale.mjs';
|
|
7
|
-
|
|
8
|
-
export function brainDir(vaultBase) {
|
|
9
|
-
return join(vaultBase, '.brain');
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
// Frontmatter YAML simples: escalares `k: v` + listas `k:` seguido de ` - item`.
|
|
13
|
-
export function parseFrontmatter(content) {
|
|
14
|
-
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
15
|
-
if (!m) return {};
|
|
16
|
-
const data = {};
|
|
17
|
-
const lines = m[1].split('\n');
|
|
18
|
-
for (let i = 0; i < lines.length; i++) {
|
|
19
|
-
const kv = lines[i].match(/^([\w-]+):\s*(.*)$/);
|
|
20
|
-
if (!kv) continue;
|
|
21
|
-
const key = kv[1];
|
|
22
|
-
const val = kv[2];
|
|
23
|
-
if (val === '') {
|
|
24
|
-
const list = [];
|
|
25
|
-
while (i + 1 < lines.length && /^\s+-\s+/.test(lines[i + 1])) {
|
|
26
|
-
list.push(stripYamlQuotes(lines[++i].replace(/^\s+-\s+/, '').trim()));
|
|
27
|
-
}
|
|
28
|
-
data[key] = list.length ? list : '';
|
|
29
|
-
} else {
|
|
30
|
-
data[key] = stripYamlQuotes(val.trim());
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
return data;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function walkMd(dir) {
|
|
37
|
-
const out = [];
|
|
38
|
-
let entries;
|
|
39
|
-
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
40
|
-
for (const e of entries) {
|
|
41
|
-
const fp = join(dir, e.name);
|
|
42
|
-
if (e.isDirectory()) out.push(...walkMd(fp));
|
|
43
|
-
else if (e.name.endsWith('.md')) out.push(fp);
|
|
44
|
-
}
|
|
45
|
-
return out;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const DERIVED_RE = /\[\[(0[456]-[^\]|]+?)(?:\|[^\]]*)?\]\]/g;
|
|
49
|
-
function derivedLinks(content) {
|
|
50
|
-
const dec = new Set(), bug = new Set(), lea = new Set();
|
|
51
|
-
let m;
|
|
52
|
-
while ((m = DERIVED_RE.exec(content))) {
|
|
53
|
-
const t = m[1];
|
|
54
|
-
if (t.startsWith('04-')) dec.add(t);
|
|
55
|
-
else if (t.startsWith('05-')) bug.add(t);
|
|
56
|
-
else if (t.startsWith('06-')) lea.add(t);
|
|
57
|
-
}
|
|
58
|
-
return { decisions: [...dec], bugs: [...bug], learnings: [...lea] };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Varre 02-Sessões/** e regrava .brain/index.jsonl inteiro. Provider-agnóstico.
|
|
62
|
-
export function buildBrainIndex(vaultBase) {
|
|
63
|
-
const rows = [];
|
|
64
|
-
for (const fp of walkMd(join(vaultBase, getLocale(vaultBase).folders.sessions))) {
|
|
65
|
-
let content;
|
|
66
|
-
try { content = readFileSync(fp, 'utf8'); } catch { continue; }
|
|
67
|
-
const fm = parseFrontmatter(content);
|
|
68
|
-
if (fm.type && fm.type !== 'session') continue;
|
|
69
|
-
const der = derivedLinks(content);
|
|
70
|
-
rows.push({
|
|
71
|
-
session_id: fm.session_id || '',
|
|
72
|
-
date: fm.date || '',
|
|
73
|
-
provider: fm.provider || '',
|
|
74
|
-
status: fm.status || '',
|
|
75
|
-
summary: fm.summary || '',
|
|
76
|
-
file: toVaultRelative(vaultBase, fp),
|
|
77
|
-
tags: Array.isArray(fm.tags) ? fm.tags : (fm.tags ? [fm.tags] : []),
|
|
78
|
-
decisions: der.decisions,
|
|
79
|
-
bugs: der.bugs,
|
|
80
|
-
learnings: der.learnings,
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
rows.sort((a, b) => (a.date + a.file).localeCompare(b.date + b.file));
|
|
84
|
-
ensureDir(brainDir(vaultBase));
|
|
85
|
-
const out = rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : '');
|
|
86
|
-
writeFileSync(join(brainDir(vaultBase), 'index.jsonl'), out, 'utf8');
|
|
87
|
-
return rows;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Lê o índice gravado (linhas JSONL). Usado pelo recall e pelo digest.
|
|
91
|
-
export function loadIndex(vaultBase) {
|
|
92
|
-
try {
|
|
93
|
-
return readFileSync(join(brainDir(vaultBase), 'index.jsonl'), 'utf8')
|
|
94
|
-
.split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
95
|
-
} catch {
|
|
96
|
-
return [];
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const DIGEST_CAPS = { decisions: 5, sessions: 4, bugs: 2, learnings: 2 };
|
|
101
|
-
|
|
102
|
-
function adrNumber(path) {
|
|
103
|
-
const m = path.match(/ADR-(\d+)/);
|
|
104
|
-
return m ? Number(m[1]) : -1;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Destila index.jsonl em .brain/DIGEST.md (camada quente, determinístico, 0 token LLM).
|
|
108
|
-
// Cap por construção: 1 header + 13 itens (5/4/2/2) + 1 pointer = máx 15 linhas.
|
|
109
|
-
export function buildBrainDigest(vaultBase, rows = null) {
|
|
110
|
-
const data = rows ?? loadIndex(vaultBase);
|
|
111
|
-
const byDateDesc = [...data].sort((a, b) =>
|
|
112
|
-
String(b.date || '').localeCompare(String(a.date || '')) || String(b.file || '').localeCompare(String(a.file || '')));
|
|
113
|
-
|
|
114
|
-
const seen = new Set();
|
|
115
|
-
const pick = (kind, max) => {
|
|
116
|
-
const out = [];
|
|
117
|
-
for (const r of byDateDesc) {
|
|
118
|
-
for (const p of r[kind] || []) {
|
|
119
|
-
if (out.length >= max) return out;
|
|
120
|
-
if (!seen.has(p)) { seen.add(p); out.push(p); }
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return out;
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
// The digest is INJECTED into every session, so a dead wikilink is dead weight in the model's
|
|
127
|
-
// context. Keep only targets that resolve to a real note (by vault-relative path or basename)
|
|
128
|
-
// and drop placeholder paths (a truncated `…` from a summary line). `pick` collects extra so
|
|
129
|
-
// caps still fill after filtering.
|
|
130
|
-
const known = new Set();
|
|
131
|
-
for (const r of data) {
|
|
132
|
-
const rel = String(r.file || '').replace(/\.md$/i, '');
|
|
133
|
-
if (rel) { known.add(rel); known.add(basename(rel)); }
|
|
134
|
-
}
|
|
135
|
-
const resolves = (p) => {
|
|
136
|
-
const t = String(p || '').replace(/\.md$/i, '').trim();
|
|
137
|
-
if (!t || t.includes('...') || t.includes('…')) return false;
|
|
138
|
-
return known.has(t) || known.has(basename(t)) || existsSync(join(vaultBase, `${t}.md`));
|
|
139
|
-
};
|
|
140
|
-
const pickLive = (kind, max) => pick(kind, max * 4).filter(resolves).slice(0, max);
|
|
141
|
-
|
|
142
|
-
const decisions = pickLive('decisions', DIGEST_CAPS.decisions).sort((a, b) => adrNumber(b) - adrNumber(a));
|
|
143
|
-
const sessions = byDateDesc.slice(0, DIGEST_CAPS.sessions);
|
|
144
|
-
const bugs = pickLive('bugs', DIGEST_CAPS.bugs);
|
|
145
|
-
const learnings = pickLive('learnings', DIGEST_CAPS.learnings);
|
|
146
|
-
|
|
147
|
-
const lines = ['<!-- AUTO-GERADO por brain-core.mjs (0 token LLM). NÃO editar. Rebuild: node .agent/hooks/brain-reindex.mjs -->'];
|
|
148
|
-
for (const d of decisions) lines.push(`- Decisão: [[${d}]]`);
|
|
149
|
-
for (const s of sessions) lines.push(`- Sessão ${s.date} (${s.provider || '?'}): ${s.summary || s.file} → [[${String(s.file || '').replace(/\.md$/, '')}]]`);
|
|
150
|
-
for (const b of bugs) lines.push(`- Bug: [[${b}]]`);
|
|
151
|
-
for (const l of learnings) lines.push(`- Aprendizado: [[${l}]]`);
|
|
152
|
-
|
|
153
|
-
const shown = sessions.length;
|
|
154
|
-
if (data.length > shown) lines.push(`- +${data.length - shown} mais no índice — use /brain-recall <tópico>`);
|
|
155
|
-
|
|
156
|
-
ensureDir(brainDir(vaultBase));
|
|
157
|
-
writeFileSync(join(brainDir(vaultBase), 'DIGEST.md'), lines.join('\n') + '\n', 'utf8');
|
|
158
|
-
return lines;
|
|
159
|
-
}
|
|
1
|
+
// .agent/hooks/brain-core.mjs
|
|
2
|
+
// Camada fria do brain: indexa o frontmatter das notas de sessão (0 token LLM).
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
|
+
import { ensureDir, stripYamlQuotes, toVaultRelative } from './obsidian-common.mjs';
|
|
6
|
+
import { getLocale } from './locale.mjs';
|
|
7
|
+
|
|
8
|
+
export function brainDir(vaultBase) {
|
|
9
|
+
return join(vaultBase, '.brain');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Frontmatter YAML simples: escalares `k: v` + listas `k:` seguido de ` - item`.
|
|
13
|
+
export function parseFrontmatter(content) {
|
|
14
|
+
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
15
|
+
if (!m) return {};
|
|
16
|
+
const data = {};
|
|
17
|
+
const lines = m[1].split('\n');
|
|
18
|
+
for (let i = 0; i < lines.length; i++) {
|
|
19
|
+
const kv = lines[i].match(/^([\w-]+):\s*(.*)$/);
|
|
20
|
+
if (!kv) continue;
|
|
21
|
+
const key = kv[1];
|
|
22
|
+
const val = kv[2];
|
|
23
|
+
if (val === '') {
|
|
24
|
+
const list = [];
|
|
25
|
+
while (i + 1 < lines.length && /^\s+-\s+/.test(lines[i + 1])) {
|
|
26
|
+
list.push(stripYamlQuotes(lines[++i].replace(/^\s+-\s+/, '').trim()));
|
|
27
|
+
}
|
|
28
|
+
data[key] = list.length ? list : '';
|
|
29
|
+
} else {
|
|
30
|
+
data[key] = stripYamlQuotes(val.trim());
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return data;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function walkMd(dir) {
|
|
37
|
+
const out = [];
|
|
38
|
+
let entries;
|
|
39
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
40
|
+
for (const e of entries) {
|
|
41
|
+
const fp = join(dir, e.name);
|
|
42
|
+
if (e.isDirectory()) out.push(...walkMd(fp));
|
|
43
|
+
else if (e.name.endsWith('.md')) out.push(fp);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DERIVED_RE = /\[\[(0[456]-[^\]|]+?)(?:\|[^\]]*)?\]\]/g;
|
|
49
|
+
function derivedLinks(content) {
|
|
50
|
+
const dec = new Set(), bug = new Set(), lea = new Set();
|
|
51
|
+
let m;
|
|
52
|
+
while ((m = DERIVED_RE.exec(content))) {
|
|
53
|
+
const t = m[1];
|
|
54
|
+
if (t.startsWith('04-')) dec.add(t);
|
|
55
|
+
else if (t.startsWith('05-')) bug.add(t);
|
|
56
|
+
else if (t.startsWith('06-')) lea.add(t);
|
|
57
|
+
}
|
|
58
|
+
return { decisions: [...dec], bugs: [...bug], learnings: [...lea] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Varre 02-Sessões/** e regrava .brain/index.jsonl inteiro. Provider-agnóstico.
|
|
62
|
+
export function buildBrainIndex(vaultBase) {
|
|
63
|
+
const rows = [];
|
|
64
|
+
for (const fp of walkMd(join(vaultBase, getLocale(vaultBase).folders.sessions))) {
|
|
65
|
+
let content;
|
|
66
|
+
try { content = readFileSync(fp, 'utf8'); } catch { continue; }
|
|
67
|
+
const fm = parseFrontmatter(content);
|
|
68
|
+
if (fm.type && fm.type !== 'session') continue;
|
|
69
|
+
const der = derivedLinks(content);
|
|
70
|
+
rows.push({
|
|
71
|
+
session_id: fm.session_id || '',
|
|
72
|
+
date: fm.date || '',
|
|
73
|
+
provider: fm.provider || '',
|
|
74
|
+
status: fm.status || '',
|
|
75
|
+
summary: fm.summary || '',
|
|
76
|
+
file: toVaultRelative(vaultBase, fp),
|
|
77
|
+
tags: Array.isArray(fm.tags) ? fm.tags : (fm.tags ? [fm.tags] : []),
|
|
78
|
+
decisions: der.decisions,
|
|
79
|
+
bugs: der.bugs,
|
|
80
|
+
learnings: der.learnings,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
rows.sort((a, b) => (a.date + a.file).localeCompare(b.date + b.file));
|
|
84
|
+
ensureDir(brainDir(vaultBase));
|
|
85
|
+
const out = rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : '');
|
|
86
|
+
writeFileSync(join(brainDir(vaultBase), 'index.jsonl'), out, 'utf8');
|
|
87
|
+
return rows;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Lê o índice gravado (linhas JSONL). Usado pelo recall e pelo digest.
|
|
91
|
+
export function loadIndex(vaultBase) {
|
|
92
|
+
try {
|
|
93
|
+
return readFileSync(join(brainDir(vaultBase), 'index.jsonl'), 'utf8')
|
|
94
|
+
.split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
95
|
+
} catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const DIGEST_CAPS = { decisions: 5, sessions: 4, bugs: 2, learnings: 2 };
|
|
101
|
+
|
|
102
|
+
function adrNumber(path) {
|
|
103
|
+
const m = path.match(/ADR-(\d+)/);
|
|
104
|
+
return m ? Number(m[1]) : -1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Destila index.jsonl em .brain/DIGEST.md (camada quente, determinístico, 0 token LLM).
|
|
108
|
+
// Cap por construção: 1 header + 13 itens (5/4/2/2) + 1 pointer = máx 15 linhas.
|
|
109
|
+
export function buildBrainDigest(vaultBase, rows = null) {
|
|
110
|
+
const data = rows ?? loadIndex(vaultBase);
|
|
111
|
+
const byDateDesc = [...data].sort((a, b) =>
|
|
112
|
+
String(b.date || '').localeCompare(String(a.date || '')) || String(b.file || '').localeCompare(String(a.file || '')));
|
|
113
|
+
|
|
114
|
+
const seen = new Set();
|
|
115
|
+
const pick = (kind, max) => {
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const r of byDateDesc) {
|
|
118
|
+
for (const p of r[kind] || []) {
|
|
119
|
+
if (out.length >= max) return out;
|
|
120
|
+
if (!seen.has(p)) { seen.add(p); out.push(p); }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// The digest is INJECTED into every session, so a dead wikilink is dead weight in the model's
|
|
127
|
+
// context. Keep only targets that resolve to a real note (by vault-relative path or basename)
|
|
128
|
+
// and drop placeholder paths (a truncated `…` from a summary line). `pick` collects extra so
|
|
129
|
+
// caps still fill after filtering.
|
|
130
|
+
const known = new Set();
|
|
131
|
+
for (const r of data) {
|
|
132
|
+
const rel = String(r.file || '').replace(/\.md$/i, '');
|
|
133
|
+
if (rel) { known.add(rel); known.add(basename(rel)); }
|
|
134
|
+
}
|
|
135
|
+
const resolves = (p) => {
|
|
136
|
+
const t = String(p || '').replace(/\.md$/i, '').trim();
|
|
137
|
+
if (!t || t.includes('...') || t.includes('…')) return false;
|
|
138
|
+
return known.has(t) || known.has(basename(t)) || existsSync(join(vaultBase, `${t}.md`));
|
|
139
|
+
};
|
|
140
|
+
const pickLive = (kind, max) => pick(kind, max * 4).filter(resolves).slice(0, max);
|
|
141
|
+
|
|
142
|
+
const decisions = pickLive('decisions', DIGEST_CAPS.decisions).sort((a, b) => adrNumber(b) - adrNumber(a));
|
|
143
|
+
const sessions = byDateDesc.slice(0, DIGEST_CAPS.sessions);
|
|
144
|
+
const bugs = pickLive('bugs', DIGEST_CAPS.bugs);
|
|
145
|
+
const learnings = pickLive('learnings', DIGEST_CAPS.learnings);
|
|
146
|
+
|
|
147
|
+
const lines = ['<!-- AUTO-GERADO por brain-core.mjs (0 token LLM). NÃO editar. Rebuild: node .agent/hooks/brain-reindex.mjs -->'];
|
|
148
|
+
for (const d of decisions) lines.push(`- Decisão: [[${d}]]`);
|
|
149
|
+
for (const s of sessions) lines.push(`- Sessão ${s.date} (${s.provider || '?'}): ${s.summary || s.file} → [[${String(s.file || '').replace(/\.md$/, '')}]]`);
|
|
150
|
+
for (const b of bugs) lines.push(`- Bug: [[${b}]]`);
|
|
151
|
+
for (const l of learnings) lines.push(`- Aprendizado: [[${l}]]`);
|
|
152
|
+
|
|
153
|
+
const shown = sessions.length;
|
|
154
|
+
if (data.length > shown) lines.push(`- +${data.length - shown} mais no índice — use /brain-recall <tópico>`);
|
|
155
|
+
|
|
156
|
+
ensureDir(brainDir(vaultBase));
|
|
157
|
+
writeFileSync(join(brainDir(vaultBase), 'DIGEST.md'), lines.join('\n') + '\n', 'utf8');
|
|
158
|
+
return lines;
|
|
159
|
+
}
|
package/hooks/brain-recall.mjs
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
// .agent/hooks/brain-recall.mjs
|
|
2
|
-
// Query engine read-only: pontua o índice por tópico. Token só no resultado.
|
|
3
|
-
// Uso: node .agent/hooks/brain-recall.mjs <termos da busca>
|
|
4
|
-
import { pathToFileURL } from 'node:url';
|
|
5
|
-
import { getVaultBase } from './obsidian-common.mjs';
|
|
6
|
-
import { loadIndex } from './brain-core.mjs';
|
|
7
|
-
|
|
8
|
-
export { loadIndex };
|
|
9
|
-
|
|
10
|
-
export function scoreRows(rows, query, topK = 5) {
|
|
11
|
-
const terms = String(query).toLowerCase().split(/\s+/).filter(Boolean);
|
|
12
|
-
if (!terms.length) return [];
|
|
13
|
-
return rows
|
|
14
|
-
.map((r) => {
|
|
15
|
-
// Inclui slug do file + paths das derivadas (ADR/bug/aprendizado) no haystack:
|
|
16
|
-
// os títulos das sessões e tags são genéricos; o sinal tópico vem dos slugs.
|
|
17
|
-
const hay = `${r.summary || ''} ${(r.tags || []).join(' ')} ${r.file || ''} ${(r.decisions || []).join(' ')} ${(r.bugs || []).join(' ')} ${(r.learnings || []).join(' ')}`.toLowerCase();
|
|
18
|
-
let score = 0;
|
|
19
|
-
for (const t of terms) if (hay.includes(t)) score++;
|
|
20
|
-
return { row: r, score };
|
|
21
|
-
})
|
|
22
|
-
.filter((s) => s.score > 0)
|
|
23
|
-
.sort((a, b) => b.score - a.score || String(b.row.date || '').localeCompare(String(a.row.date || '')))
|
|
24
|
-
.slice(0, topK)
|
|
25
|
-
.map((s) => s.row);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
29
|
-
const vaultBase = getVaultBase();
|
|
30
|
-
const hits = scoreRows(loadIndex(vaultBase), process.argv.slice(2).join(' '));
|
|
31
|
-
process.stdout.write(JSON.stringify(hits, null, 2) + '\n');
|
|
32
|
-
}
|
|
1
|
+
// .agent/hooks/brain-recall.mjs
|
|
2
|
+
// Query engine read-only: pontua o índice por tópico. Token só no resultado.
|
|
3
|
+
// Uso: node .agent/hooks/brain-recall.mjs <termos da busca>
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { getVaultBase } from './obsidian-common.mjs';
|
|
6
|
+
import { loadIndex } from './brain-core.mjs';
|
|
7
|
+
|
|
8
|
+
export { loadIndex };
|
|
9
|
+
|
|
10
|
+
export function scoreRows(rows, query, topK = 5) {
|
|
11
|
+
const terms = String(query).toLowerCase().split(/\s+/).filter(Boolean);
|
|
12
|
+
if (!terms.length) return [];
|
|
13
|
+
return rows
|
|
14
|
+
.map((r) => {
|
|
15
|
+
// Inclui slug do file + paths das derivadas (ADR/bug/aprendizado) no haystack:
|
|
16
|
+
// os títulos das sessões e tags são genéricos; o sinal tópico vem dos slugs.
|
|
17
|
+
const hay = `${r.summary || ''} ${(r.tags || []).join(' ')} ${r.file || ''} ${(r.decisions || []).join(' ')} ${(r.bugs || []).join(' ')} ${(r.learnings || []).join(' ')}`.toLowerCase();
|
|
18
|
+
let score = 0;
|
|
19
|
+
for (const t of terms) if (hay.includes(t)) score++;
|
|
20
|
+
return { row: r, score };
|
|
21
|
+
})
|
|
22
|
+
.filter((s) => s.score > 0)
|
|
23
|
+
.sort((a, b) => b.score - a.score || String(b.row.date || '').localeCompare(String(a.row.date || '')))
|
|
24
|
+
.slice(0, topK)
|
|
25
|
+
.map((s) => s.row);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
29
|
+
const vaultBase = getVaultBase();
|
|
30
|
+
const hits = scoreRows(loadIndex(vaultBase), process.argv.slice(2).join(' '));
|
|
31
|
+
process.stdout.write(JSON.stringify(hits, null, 2) + '\n');
|
|
32
|
+
}
|
package/hooks/brain-reindex.mjs
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
// .agent/hooks/brain-reindex.mjs
|
|
2
|
-
// Backfill manual: reconstrói .brain/index.jsonl + .brain/DIGEST.md varrendo todo 02-Sessões.
|
|
3
|
-
// Uso: node .agent/hooks/brain-reindex.mjs [caminho-do-vault]
|
|
4
|
-
import { pathToFileURL } from 'node:url';
|
|
5
|
-
import { getVaultBase } from './obsidian-common.mjs';
|
|
6
|
-
import { buildBrainDigest, buildBrainIndex, brainDir } from './brain-core.mjs';
|
|
7
|
-
|
|
8
|
-
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
9
|
-
const vaultBase = getVaultBase({ obsidian_vault_path: process.argv[2] });
|
|
10
|
-
const rows = buildBrainIndex(vaultBase);
|
|
11
|
-
const digest = buildBrainDigest(vaultBase, rows);
|
|
12
|
-
process.stdout.write(`[brain] index: ${rows.length} sessões; digest: ${digest.length} linhas → ${brainDir(vaultBase)}\n`);
|
|
13
|
-
}
|
|
1
|
+
// .agent/hooks/brain-reindex.mjs
|
|
2
|
+
// Backfill manual: reconstrói .brain/index.jsonl + .brain/DIGEST.md varrendo todo 02-Sessões.
|
|
3
|
+
// Uso: node .agent/hooks/brain-reindex.mjs [caminho-do-vault]
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { getVaultBase } from './obsidian-common.mjs';
|
|
6
|
+
import { buildBrainDigest, buildBrainIndex, brainDir } from './brain-core.mjs';
|
|
7
|
+
|
|
8
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
9
|
+
const vaultBase = getVaultBase({ obsidian_vault_path: process.argv[2] });
|
|
10
|
+
const rows = buildBrainIndex(vaultBase);
|
|
11
|
+
const digest = buildBrainDigest(vaultBase, rows);
|
|
12
|
+
process.stdout.write(`[brain] index: ${rows.length} sessões; digest: ${digest.length} linhas → ${brainDir(vaultBase)}\n`);
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.66.
|
|
3
|
+
"version": "0.66.1",
|
|
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": [
|
|
@@ -101,7 +101,7 @@ Usage:
|
|
|
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
103
|
reconcile <session> --by-session <session> --reason <text> [--apply] |
|
|
104
|
-
promote <candidate> | reject <candidate>. --vault P.
|
|
104
|
+
promote <candidate> [--event <event-id>] | reject <candidate>. --vault P.
|
|
105
105
|
Reconcile is dry-run by default; the original attempt remains audited.
|
|
106
106
|
wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
|
|
107
107
|
protocol (cap 25, 3 sections, no secrets/PII).
|
|
@@ -108,6 +108,31 @@ export function validateMemoryEvent(event, { projectId } = {}) {
|
|
|
108
108
|
errors.push(`project_id não pertence ao vault esperado (${projectId}).`);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
if (event.candidate_decision !== undefined) {
|
|
112
|
+
const decision = event.candidate_decision;
|
|
113
|
+
if (!decision || typeof decision !== 'object' || Array.isArray(decision)) {
|
|
114
|
+
errors.push('candidate_decision deve ser objeto.');
|
|
115
|
+
} else {
|
|
116
|
+
if (typeof decision.candidate_id !== 'string' || !decision.candidate_id) {
|
|
117
|
+
errors.push('candidate_decision.candidate_id deve ser string não vazia.');
|
|
118
|
+
}
|
|
119
|
+
if (!['promote', 'reject'].includes(decision.action)) {
|
|
120
|
+
errors.push('candidate_decision.action deve ser promote ou reject.');
|
|
121
|
+
}
|
|
122
|
+
if (!Array.isArray(decision.event_ids)
|
|
123
|
+
|| decision.event_ids.some((item) => typeof item !== 'string' || !item)) {
|
|
124
|
+
errors.push('candidate_decision.event_ids deve ser array de strings não vazias.');
|
|
125
|
+
}
|
|
126
|
+
if (decision.action === 'promote' && decision.selected_event_id !== undefined
|
|
127
|
+
&& (typeof decision.selected_event_id !== 'string' || !decision.selected_event_id)) {
|
|
128
|
+
errors.push('candidate_decision.selected_event_id deve ser string não vazia.');
|
|
129
|
+
}
|
|
130
|
+
if (decision.action === 'reject' && decision.selected_event_id !== undefined) {
|
|
131
|
+
errors.push('candidate_decision.selected_event_id não é permitido em reject.');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
111
136
|
for (const field of ['value', 'evidence']) sanitizedField(event, field, errors);
|
|
112
137
|
return { ok: errors.length === 0, errors, warnings };
|
|
113
138
|
}
|
|
@@ -461,6 +461,19 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
|
|
|
461
461
|
if (!existing) unique.set(event.event_id, event);
|
|
462
462
|
}
|
|
463
463
|
const events = [...unique.values()].sort(eventOrder);
|
|
464
|
+
const candidateDecisions = new Map();
|
|
465
|
+
for (const item of events) {
|
|
466
|
+
const decision = item.candidate_decision;
|
|
467
|
+
if (!decision) continue;
|
|
468
|
+
const existing = candidateDecisions.get(decision.candidate_id);
|
|
469
|
+
if (existing && canonicalMemoryJson(existing.decision) !== canonicalMemoryJson(decision)) {
|
|
470
|
+
throw new MemoryEventCollision(
|
|
471
|
+
decision.candidate_id,
|
|
472
|
+
`Ledger contains incompatible decisions for candidate ${decision.candidate_id}`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
if (!existing) candidateDecisions.set(decision.candidate_id, { decision, event: item });
|
|
476
|
+
}
|
|
464
477
|
|
|
465
478
|
const peerGroups = new Map();
|
|
466
479
|
for (const item of events) {
|
|
@@ -488,6 +501,11 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
|
|
|
488
501
|
let revision = 0;
|
|
489
502
|
|
|
490
503
|
for (const item of events) {
|
|
504
|
+
if (item.candidate_decision && item.candidate_decision.action === 'reject') {
|
|
505
|
+
appliedEventIds.push(item.event_id);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
|
|
491
509
|
if (protectedValues.has(item.memory_key)) {
|
|
492
510
|
const coreValue = protectedValues.get(item.memory_key);
|
|
493
511
|
const agreesWithCore = item.operation === 'assert'
|
|
@@ -604,7 +622,9 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
|
|
|
604
622
|
const state = sortedObject(stateEntries);
|
|
605
623
|
const recordObject = sortedObject(recordEntries);
|
|
606
624
|
const tombstoneObject = sortedObject(tombstoneEntries);
|
|
607
|
-
|
|
625
|
+
const unresolvedCandidates = candidates
|
|
626
|
+
.filter((item) => !candidateDecisions.has(item.candidate_id));
|
|
627
|
+
unresolvedCandidates.sort((left, right) => left.candidate_id.localeCompare(right.candidate_id));
|
|
608
628
|
superseded.sort((left, right) => left.event_id.localeCompare(right.event_id));
|
|
609
629
|
const activeEvents = Object.entries(recordObject).map(([memoryKey, record]) => ({
|
|
610
630
|
...record.source,
|
|
@@ -618,7 +638,7 @@ export function reduceMemoryEvents(inputEvents = [], { coreInvariants = new Map(
|
|
|
618
638
|
return {
|
|
619
639
|
state,
|
|
620
640
|
records: recordObject,
|
|
621
|
-
candidates,
|
|
641
|
+
candidates: unresolvedCandidates,
|
|
622
642
|
tombstones: tombstoneObject,
|
|
623
643
|
superseded,
|
|
624
644
|
appliedEventIds,
|
package/src/memory.mjs
CHANGED
|
@@ -237,37 +237,191 @@ function readCandidates(vault) {
|
|
|
237
237
|
.split('\n').filter(Boolean).map((line) => JSON.parse(line));
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
-
|
|
240
|
+
function priorCandidateDecision(vault, candidateId) {
|
|
241
|
+
return readMemoryLedger(vault).events.find(
|
|
242
|
+
(event) => event.candidate_decision?.candidate_id === candidateId,
|
|
243
|
+
) || null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function candidateEvent(candidate, eventId) {
|
|
247
|
+
const events = Array.isArray(candidate.events) ? candidate.events : [];
|
|
248
|
+
if (candidate.reason === 'conflict' && !eventId) {
|
|
249
|
+
throw new Error('Candidate de conflito exige eventId (--event na CLI).');
|
|
250
|
+
}
|
|
251
|
+
if (!eventId) return events.length === 1 ? events[0] : null;
|
|
252
|
+
const selected = events.find((event) => event.event_id === eventId);
|
|
253
|
+
if (!selected) throw new Error(`event_id ${eventId} não pertence ao candidate ${candidate.candidate_id}.`);
|
|
254
|
+
return selected;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function assertCompatibleDecision(prior, { action, eventId }) {
|
|
258
|
+
const decision = prior.candidate_decision;
|
|
259
|
+
const sameSelection = action !== 'promote'
|
|
260
|
+
|| (decision.selected_event_id || null) === (eventId || null);
|
|
261
|
+
if (decision.action !== action || !sameSelection) {
|
|
262
|
+
throw new Error(`Candidate ${decision.candidate_id} já possui decisão incompatível (${decision.action}).`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function matchesPromotedAttempt(attempt, selected) {
|
|
267
|
+
if (attempt?.memory_mode !== 'v2' || attempt.state !== 'projected'
|
|
268
|
+
|| !Array.isArray(attempt.event_ids) || !attempt.event_ids.includes(selected.event_id)) return false;
|
|
269
|
+
if (attempt.activation_id && selected.activation_id
|
|
270
|
+
&& attempt.activation_id !== selected.activation_id) return false;
|
|
271
|
+
if (Number.isInteger(attempt.activation_epoch) && Number.isInteger(selected.activation_epoch)
|
|
272
|
+
&& attempt.activation_epoch !== selected.activation_epoch) return false;
|
|
273
|
+
if (Number.isInteger(attempt.turn_sequence) && Number.isInteger(selected.turn_sequence)
|
|
274
|
+
&& attempt.turn_sequence !== selected.turn_sequence) return false;
|
|
275
|
+
if (attempt.canonical_session_id && selected.canonical_session_id
|
|
276
|
+
&& attempt.canonical_session_id !== selected.canonical_session_id) return false;
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function snapshotPromotedAttemptCheckpoints(vault, selected) {
|
|
281
|
+
if (!selected) return new Map();
|
|
282
|
+
const registry = readSessionRegistry(vault);
|
|
283
|
+
return new Map(Object.entries(registry.sessions || {})
|
|
284
|
+
.filter(([, entry]) => matchesPromotedAttempt(entry?.last_memory_attempt, selected))
|
|
285
|
+
.map(([sessionId, entry]) => [sessionId, {
|
|
286
|
+
attempt: attemptFingerprint(entry.last_memory_attempt),
|
|
287
|
+
checkpoint: memoryCheckpointFingerprint(entry),
|
|
288
|
+
}]));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function refreshPromotedAttemptCheckpoint(vault, {
|
|
292
|
+
candidateId, decisionEventId, selected, checkpoint, decidedAt, expectedAttempts,
|
|
293
|
+
}) {
|
|
294
|
+
if (!selected || !checkpoint || !expectedAttempts?.size) return 0;
|
|
295
|
+
return mutateSessionRegistry(vault, (registry) => {
|
|
296
|
+
let refreshed = 0;
|
|
297
|
+
for (const [sessionId, entry] of Object.entries(registry.sessions || {})) {
|
|
298
|
+
const expected = expectedAttempts.get(sessionId);
|
|
299
|
+
if (!expected) continue;
|
|
300
|
+
const attempt = entry?.last_memory_attempt;
|
|
301
|
+
if (attemptFingerprint(attempt) !== expected.attempt
|
|
302
|
+
|| memoryCheckpointFingerprint(entry) !== expected.checkpoint) continue;
|
|
303
|
+
|
|
304
|
+
const alreadyAudited = (entry.memory_candidate_decisions || [])
|
|
305
|
+
.some((audit) => audit.decision_event_id === decisionEventId);
|
|
306
|
+
if (alreadyAudited && sameCheckpoint(attempt.checkpoint, checkpoint)
|
|
307
|
+
&& sameCheckpoint(entry.memory_checkpoint, checkpoint)) continue;
|
|
308
|
+
const originalCheckpoint = cloneJson(attempt.checkpoint || entry.memory_checkpoint || null);
|
|
309
|
+
attempt.checkpoint = cloneJson(checkpoint);
|
|
310
|
+
entry.memory_checkpoint = cloneJson(checkpoint);
|
|
311
|
+
entry.memory_status = 'projected';
|
|
312
|
+
if (!alreadyAudited) {
|
|
313
|
+
entry.memory_candidate_decisions = [
|
|
314
|
+
...(Array.isArray(entry.memory_candidate_decisions) ? entry.memory_candidate_decisions : []),
|
|
315
|
+
{
|
|
316
|
+
v: 1,
|
|
317
|
+
type: 'candidate_checkpoint_refreshed',
|
|
318
|
+
candidate_id: candidateId,
|
|
319
|
+
decision_event_id: decisionEventId,
|
|
320
|
+
selected_event_id: selected.event_id,
|
|
321
|
+
decided_at: decidedAt,
|
|
322
|
+
original_checkpoint: originalCheckpoint,
|
|
323
|
+
checkpoint: cloneJson(checkpoint),
|
|
324
|
+
},
|
|
325
|
+
];
|
|
326
|
+
}
|
|
327
|
+
refreshed += 1;
|
|
328
|
+
}
|
|
329
|
+
return refreshed;
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function decideMemoryCandidate(vault, {
|
|
334
|
+
action, candidateId, value, eventId, beforeCheckpointRefresh,
|
|
335
|
+
} = {}) {
|
|
241
336
|
if (!['promote', 'reject'].includes(action)) throw new TypeError('action deve ser promote ou reject.');
|
|
242
337
|
if (!candidateId) throw new TypeError('candidateId é obrigatório.');
|
|
338
|
+
const preflight = projectMemoryOutbox(vault);
|
|
339
|
+
if (preflight.status === 'busy') return { status: 'busy', candidateId };
|
|
340
|
+
const prior = priorCandidateDecision(vault, candidateId);
|
|
341
|
+
if (prior) {
|
|
342
|
+
assertCompatibleDecision(prior, { action, eventId });
|
|
343
|
+
const selected = action === 'promote' && prior.candidate_decision.selected_event_id
|
|
344
|
+
? readMemoryLedger(vault).events.find(
|
|
345
|
+
(event) => event.event_id === prior.candidate_decision.selected_event_id,
|
|
346
|
+
)
|
|
347
|
+
: null;
|
|
348
|
+
const expectedAttempts = snapshotPromotedAttemptCheckpoints(vault, selected);
|
|
349
|
+
beforeCheckpointRefresh?.();
|
|
350
|
+
const checkpointRefreshed = action === 'promote'
|
|
351
|
+
? refreshPromotedAttemptCheckpoint(vault, {
|
|
352
|
+
candidateId,
|
|
353
|
+
decisionEventId: prior.event_id,
|
|
354
|
+
selected,
|
|
355
|
+
checkpoint: preflight.checkpoint,
|
|
356
|
+
decidedAt: prior.observed_at,
|
|
357
|
+
expectedAttempts,
|
|
358
|
+
})
|
|
359
|
+
: 0;
|
|
360
|
+
return {
|
|
361
|
+
status: action === 'promote' ? 'promoted' : 'rejected',
|
|
362
|
+
candidateId,
|
|
363
|
+
eventId: prior.event_id,
|
|
364
|
+
alreadyApplied: true,
|
|
365
|
+
checkpointRefreshed,
|
|
366
|
+
projection: preflight,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
243
369
|
const candidates = readCandidates(vault);
|
|
244
370
|
const candidate = candidates.find((item) => item.candidate_id === candidateId);
|
|
245
371
|
if (!candidate) throw new Error(`Candidate não encontrado: ${candidateId}`);
|
|
372
|
+
if (action === 'promote' && candidate.reason === 'blocked_by_core') {
|
|
373
|
+
throw new Error(`Candidate ${candidateId} está blocked_by_core; edite CORE ou rejeite o candidate.`);
|
|
374
|
+
}
|
|
375
|
+
const selected = action === 'promote' ? candidateEvent(candidate, eventId) : null;
|
|
376
|
+
const expectedAttempts = snapshotPromotedAttemptCheckpoints(vault, selected);
|
|
377
|
+
const selectedValue = selected?.value ?? value ?? candidate.value ?? candidate.proposed_value;
|
|
378
|
+
if (action === 'promote' && selectedValue === undefined) {
|
|
379
|
+
throw new Error(`Candidate ${candidateId} não contém valor promovível.`);
|
|
380
|
+
}
|
|
246
381
|
const now = new Date().toISOString();
|
|
382
|
+
const decision = {
|
|
383
|
+
candidate_id: candidateId,
|
|
384
|
+
action,
|
|
385
|
+
event_ids: Array.isArray(candidate.event_ids) ? [...candidate.event_ids].sort() : [],
|
|
386
|
+
...(selected ? { selected_event_id: selected.event_id } : {}),
|
|
387
|
+
};
|
|
247
388
|
const event = {
|
|
248
389
|
v: 1,
|
|
249
|
-
event_id: `cli-${action}-${hash(candidateId).slice(0, 20)}`,
|
|
390
|
+
event_id: `cli-${action}-${hash(`${candidateId}\0${selected?.event_id || ''}`).slice(0, 20)}`,
|
|
250
391
|
project_id: projectId(vault),
|
|
251
|
-
memory_key: action === 'promote' ? candidate.memory_key : `candidate.
|
|
252
|
-
operation: 'assert',
|
|
253
|
-
value: sanitizeMemoryText(action === 'promote' ?
|
|
392
|
+
memory_key: action === 'promote' ? candidate.memory_key : `candidate.decision.${candidateId}`,
|
|
393
|
+
operation: action === 'promote' && selected ? 'replace' : 'assert',
|
|
394
|
+
value: sanitizeMemoryText(action === 'promote' ? selectedValue : 'rejected'),
|
|
254
395
|
authority: 'verified',
|
|
255
|
-
activation_id: 'wendkeep-memory-cli',
|
|
256
|
-
|
|
396
|
+
activation_id: selected?.activation_id || 'wendkeep-memory-cli',
|
|
397
|
+
...(Number.isInteger(selected?.activation_epoch) ? { activation_epoch: selected.activation_epoch } : {}),
|
|
398
|
+
turn_sequence: selected?.turn_sequence ?? 0,
|
|
257
399
|
observed_at: now,
|
|
258
400
|
evidence: [`candidate:${candidateId}`],
|
|
401
|
+
candidate_decision: decision,
|
|
402
|
+
...(selected ? { supersedes: decision.event_ids } : {}),
|
|
259
403
|
};
|
|
260
404
|
enqueueMemoryEvent(vault, event);
|
|
261
405
|
const projection = projectMemoryOutbox(vault);
|
|
262
406
|
if (projection.status === 'busy') return { status: 'busy', candidateId };
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
407
|
+
beforeCheckpointRefresh?.();
|
|
408
|
+
const checkpointRefreshed = action === 'promote'
|
|
409
|
+
? refreshPromotedAttemptCheckpoint(vault, {
|
|
410
|
+
candidateId,
|
|
411
|
+
decisionEventId: event.event_id,
|
|
412
|
+
selected,
|
|
413
|
+
checkpoint: projection.checkpoint,
|
|
414
|
+
decidedAt: now,
|
|
415
|
+
expectedAttempts,
|
|
416
|
+
})
|
|
417
|
+
: 0;
|
|
418
|
+
return {
|
|
419
|
+
status: action === 'promote' ? 'promoted' : 'rejected',
|
|
420
|
+
candidateId,
|
|
421
|
+
eventId: event.event_id,
|
|
422
|
+
checkpointRefreshed,
|
|
423
|
+
projection,
|
|
424
|
+
};
|
|
271
425
|
}
|
|
272
426
|
|
|
273
427
|
function cloneJson(value) {
|
|
@@ -1038,8 +1192,14 @@ export function runMemory(argv) {
|
|
|
1038
1192
|
apply: reconcileArgs.apply,
|
|
1039
1193
|
});
|
|
1040
1194
|
}
|
|
1041
|
-
else if (sub === 'promote' || sub === 'reject')
|
|
1042
|
-
|
|
1195
|
+
else if (sub === 'promote' || sub === 'reject') {
|
|
1196
|
+
const eventId = option(argv, '--event');
|
|
1197
|
+
if (sub === 'reject' && eventId) throw memoryUsageError('--event é permitido somente em memory promote.');
|
|
1198
|
+
result = decideMemoryCandidate(vault, {
|
|
1199
|
+
action: sub, candidateId: positional, ...(eventId ? { eventId } : {}),
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
else { process.stderr.write('wendkeep memory: use status | migrate [--apply] | repair | reconcile <session> --by-session <session> --reason <text> [--apply] | promote <candidate> [--event <event-id>] | reject <candidate>.\n'); process.exitCode = 2; return; }
|
|
1043
1203
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
1044
1204
|
if (sub === 'status' && argv.includes('--gate')) process.exitCode = result.status === 'blocked' ? 1 : 0;
|
|
1045
1205
|
else if (sub === 'reconcile' && reconcileArgs.apply) process.exitCode = result.health?.status === 'blocked' ? 1 : 0;
|