wendkeep 0.68.6 → 0.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/docs/en/commands/memory.md +10 -1
- package/docs/pt-BR/commands/memory.md +10 -1
- package/hooks/brain-core.mjs +46 -2
- package/hooks/brain-inject.mjs +3 -3
- package/hooks/session-ensure.mjs +23 -0
- package/hooks/session-identity.mjs +4 -2
- package/hooks/session-stop.mjs +17 -0
- package/hooks/vault-health.mjs +13 -0
- package/package.json +2 -2
- package/packages/cli/src/index.mjs +1 -1
- package/packages/vault/src/memory-handoff.mjs +75 -9
- package/packages/vault/src/memory-store.mjs +34 -6
- package/packages/vault/src/validate-core.mjs +29 -15
- package/packages/vault/src/validate-memory.mjs +209 -6
- package/src/doctor.mjs +4 -0
- package/src/memory.mjs +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,15 @@ 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.69.0] — 2026-08-16
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **A Shared Project Memory v2 preserva handoffs estruturados entre providers.** Stop publica
|
|
12
|
+
objetivo, entrega, restrições, decisões, próximas ações, bloqueios e riscos com `work_session_id`;
|
|
13
|
+
CORE continua manual com cap 40/alerta 35, e status/validate-memory diagnosticam cobertura
|
|
14
|
+
semântica sem expor valores privados.
|
|
15
|
+
|
|
7
16
|
## [0.68.6] — 2026-08-16
|
|
8
17
|
|
|
9
18
|
### Fixed
|
package/README.en.md
CHANGED
|
@@ -357,7 +357,9 @@ Codex uses `session_id`/`turn_id` plus transcript order, with no artificial caus
|
|
|
357
357
|
|
|
358
358
|
### Injection and budgets
|
|
359
359
|
|
|
360
|
-
`brain-inject` delivers the same revision/hash on `startup`, `/clear`, and `/compact` `SessionStart` events, always placing CORE and SHARED before change context. The full envelope is capped at 24 KiB;
|
|
360
|
+
`CORE.md` is the only manual layer: it accepts up to 40 lines, warns from 35, keeps a 4 KiB ceiling, and caps each line at 320 characters. `SHARED_MEMORY.md` is generated exclusively by the projector and ledger; never edit it to repair state. `brain-inject` delivers the same revision/hash on `startup`, `/clear`, and `/compact` `SessionStart` events, always placing CORE and SHARED before change context. The full envelope is capped at 24 KiB; SHARED reserves up to 6 KiB. Under pressure, lessons are removed first, then non-current changes. CORE and SHARED are never prefix-sliced: a missing, invalid, or over-budget layer becomes a visible, repairable `<wk_memory_error>`.
|
|
361
|
+
|
|
362
|
+
`memory status --gate` and `validate-memory --vault` also check semantic coverage: they report a code, counts, and active/projected/missing keys. An empty v2 bundle is neutral; a missing projectable event, placeholders as the only content, or an unresolved decision link becomes an explicit degraded/blocking diagnosis without printing memory values.
|
|
361
363
|
|
|
362
364
|
`DIGEST.md` is no longer the operational handoff: it remains the `/brain-recall` bridge and legacy-vault fallback. A vault without SHARED receives CORE+DIGEST with a deprecation warning; migrate during the compatibility window:
|
|
363
365
|
|
package/README.md
CHANGED
|
@@ -357,7 +357,9 @@ Codex uses `session_id`/`turn_id` plus transcript order, with no artificial caus
|
|
|
357
357
|
|
|
358
358
|
### Injection and budgets
|
|
359
359
|
|
|
360
|
-
`brain-inject` delivers the same revision/hash on `startup`, `/clear`, and `/compact` `SessionStart` events, always placing CORE and SHARED before change context. The full envelope is capped at 24 KiB;
|
|
360
|
+
`CORE.md` is the only manual layer: it accepts up to 40 lines, warns from 35, keeps a 4 KiB ceiling, and caps each line at 320 characters. `SHARED_MEMORY.md` is generated exclusively by the projector and ledger; never edit it to repair state. `brain-inject` delivers the same revision/hash on `startup`, `/clear`, and `/compact` `SessionStart` events, always placing CORE and SHARED before change context. The full envelope is capped at 24 KiB; SHARED reserves up to 6 KiB. Under pressure, lessons are removed first, then non-current changes. CORE and SHARED are never prefix-sliced: a missing, invalid, or over-budget layer becomes a visible, repairable `<wk_memory_error>`.
|
|
361
|
+
|
|
362
|
+
`memory status --gate` and `validate-memory --vault` also check semantic coverage: they report a code, counts, and active/projected/missing keys. An empty v2 bundle is neutral; a missing projectable event, placeholders as the only content, or an unresolved decision link becomes an explicit degraded/blocking diagnosis without printing memory values.
|
|
361
363
|
|
|
362
364
|
`DIGEST.md` is no longer the operational handoff: it remains the `/brain-recall` bridge and legacy-vault fallback. A vault without SHARED receives CORE+DIGEST with a deprecation warning; migrate during the compatibility window:
|
|
363
365
|
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
Inspect and curate CORE, SHARED, ledger, outbox, attempts, and candidates without confusing
|
|
8
8
|
canonical authorship with generated operational state.
|
|
9
9
|
|
|
10
|
+
`CORE.md` is the only manual, canonical layer: it accepts up to 40 lines, warns from 35,
|
|
11
|
+
keeps a 4 KiB ceiling, and caps each line at 320 characters. `SHARED_MEMORY.md` is generated
|
|
12
|
+
from the ledger and must not be hand-edited.
|
|
13
|
+
|
|
10
14
|
## When to use
|
|
11
15
|
|
|
12
16
|
Use in CI, before verify/archive, after doctor warnings, or when deciding candidates.
|
|
@@ -115,7 +119,12 @@ npx wendkeep validate-memory --vault <v2-vault>
|
|
|
115
119
|
different, incomplete, or ambiguous identity keeps the candidate queued for curation. `memory
|
|
116
120
|
repair` compares the old and current replay and migrates checkpoint+mirror only with exact
|
|
117
121
|
identity, backup, audit, and CAS; it does not reorder, rewrite, or append a ledger event.
|
|
118
|
-
- `validate-memory <CORE.md>` checks the
|
|
122
|
+
- `validate-memory <CORE.md>` checks the hard 40-line cap, warns from 35, enforces 4 KiB and
|
|
123
|
+
320 characters per line, and checks required sections and secrets/PII.
|
|
124
|
+
- `validate-memory --vault` also compares semantic ledger coverage with SHARED and prints a code,
|
|
125
|
+
counts, and active/projected/missing keys. An empty v2 bundle is neutral; a missing projectable
|
|
126
|
+
event, exclusive placeholders, or a dead decision link produces a degraded/blocking diagnosis
|
|
127
|
+
without exposing values.
|
|
119
128
|
- `validate-memory --vault` requires a complete v2 bundle and is not the legacy-vault gate.
|
|
120
129
|
- For `recover-attempt`, exit `0` means a valid dry run/apply, including `unchanged`; exit `1`
|
|
121
130
|
means a precondition, authority, CAS, topology, or lock check failed; exit `2` means a missing
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
Inspecionar e curar CORE, SHARED, ledger, outbox, attempts e candidates sem confundir autoria
|
|
8
8
|
canônica com estado operacional gerado.
|
|
9
9
|
|
|
10
|
+
`CORE.md` é a única camada manual e canônica: aceita até 40 linhas, alerta a partir de 35,
|
|
11
|
+
mantém o teto de 4 KiB e limita cada linha a 320 caracteres. `SHARED_MEMORY.md` é uma projeção
|
|
12
|
+
gerada pelo ledger e não deve ser editado à mão.
|
|
13
|
+
|
|
10
14
|
## Quando usar
|
|
11
15
|
|
|
12
16
|
Use no CI, antes de verify/archive, diante de avisos do doctor ou para decidir candidates.
|
|
@@ -113,7 +117,12 @@ npx wendkeep validate-memory --vault <cofre-v2>
|
|
|
113
117
|
divergente, incompleta ou ambígua mantém o candidate para curadoria. `memory repair` compara o
|
|
114
118
|
replay anterior e o atual e só migra checkpoint+espelho com identidade exata, backup, audit e
|
|
115
119
|
CAS; ele não reordena, reescreve nem acrescenta evento ao ledger.
|
|
116
|
-
- `validate-memory <CORE.md>` valida cap de
|
|
120
|
+
- `validate-memory <CORE.md>` valida cap rígido de 40 linhas, alerta em 35, 4 KiB, 320 caracteres
|
|
121
|
+
por linha, seções e segredos/PII.
|
|
122
|
+
- `validate-memory --vault` também compara a cobertura semântica do ledger com SHARED e imprime
|
|
123
|
+
código, contagens e chaves ativas/projetadas/ausentes. Bundle v2 vazio é neutro; evento
|
|
124
|
+
projetável ausente, placeholders exclusivos ou link de decisão morto geram diagnóstico
|
|
125
|
+
degradado/bloqueante sem expor valores.
|
|
117
126
|
- `validate-memory --vault` exige bundle v2 completo; não é o gate correto para vault legado.
|
|
118
127
|
- Para `recover-attempt`, exit `0` indica dry-run/apply válido, inclusive `unchanged`; exit `1`
|
|
119
128
|
indica falha de pré-condição, autoridade, CAS, topologia ou lock; exit `2` indica
|
package/hooks/brain-core.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
4
4
|
import { basename, join } from 'node:path';
|
|
5
5
|
import { ensureDir, stripYamlQuotes, toVaultRelative } from './obsidian-common.mjs';
|
|
6
6
|
import { getLocale } from './locale.mjs';
|
|
7
|
+
import { sanitizeMemoryText } from './memory-schema.mjs';
|
|
7
8
|
|
|
8
9
|
export function brainDir(vaultBase) {
|
|
9
10
|
return join(vaultBase, '.brain');
|
|
@@ -104,6 +105,41 @@ function adrNumber(path) {
|
|
|
104
105
|
return m ? Number(m[1]) : -1;
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
function decisionTitle(content, path) {
|
|
109
|
+
const frontmatter = parseFrontmatter(content);
|
|
110
|
+
const heading = content.match(/^#\s+(.+?)\s*$/m)?.[1] || '';
|
|
111
|
+
const raw = frontmatter.title || frontmatter.name || heading;
|
|
112
|
+
if (!raw) return '';
|
|
113
|
+
const title = sanitizeMemoryText(String(raw)
|
|
114
|
+
.replace(/^ADR-\d+\s*(?:[-:—]\s*)?/i, '')
|
|
115
|
+
.replace(/\s+/g, ' ')
|
|
116
|
+
.trim());
|
|
117
|
+
return title.slice(0, 180).trim();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function decisionNotes(vaultBase) {
|
|
121
|
+
const byPath = new Map();
|
|
122
|
+
const byBasename = new Map();
|
|
123
|
+
const decisionsDir = join(vaultBase, getLocale(vaultBase).folders.decisions);
|
|
124
|
+
for (const filePath of walkMd(decisionsDir)) {
|
|
125
|
+
let content;
|
|
126
|
+
try { content = readFileSync(filePath, 'utf8'); } catch { continue; }
|
|
127
|
+
const rel = toVaultRelative(vaultBase, filePath).replace(/\.md$/i, '');
|
|
128
|
+
const note = { path: rel, title: decisionTitle(content, rel) };
|
|
129
|
+
byPath.set(rel, note);
|
|
130
|
+
const key = basename(rel);
|
|
131
|
+
if (!byBasename.has(key)) byBasename.set(key, note);
|
|
132
|
+
}
|
|
133
|
+
return { byPath, byBasename };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function resolveDecisionTarget(target, notes) {
|
|
137
|
+
const normalized = String(target || '').replace(/\.md$/i, '').trim();
|
|
138
|
+
if (!normalized || normalized.includes('...') || normalized.includes('…')) return null;
|
|
139
|
+
const note = notes.byPath.get(normalized) || notes.byBasename.get(basename(normalized));
|
|
140
|
+
return note?.title ? note : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
107
143
|
// Destila index.jsonl em .brain/DIGEST.md (camada quente, determinístico, 0 token LLM).
|
|
108
144
|
// Cap por construção: 1 header + 13 itens (5/4/2/2) + 1 pointer = máx 15 linhas.
|
|
109
145
|
export function buildBrainDigest(vaultBase, rows = null) {
|
|
@@ -139,13 +175,21 @@ export function buildBrainDigest(vaultBase, rows = null) {
|
|
|
139
175
|
};
|
|
140
176
|
const pickLive = (kind, max) => pick(kind, max * 4).filter(resolves).slice(0, max);
|
|
141
177
|
|
|
142
|
-
const
|
|
178
|
+
const notes = decisionNotes(vaultBase);
|
|
179
|
+
const decisions = [];
|
|
180
|
+
for (const target of pick('decisions', DIGEST_CAPS.decisions * 4)) {
|
|
181
|
+
const resolved = resolveDecisionTarget(target, notes);
|
|
182
|
+
if (!resolved || decisions.some((item) => item.path === resolved.path)) continue;
|
|
183
|
+
decisions.push(resolved);
|
|
184
|
+
if (decisions.length >= DIGEST_CAPS.decisions) break;
|
|
185
|
+
}
|
|
186
|
+
decisions.sort((a, b) => adrNumber(b.path) - adrNumber(a.path));
|
|
143
187
|
const sessions = byDateDesc.slice(0, DIGEST_CAPS.sessions);
|
|
144
188
|
const bugs = pickLive('bugs', DIGEST_CAPS.bugs);
|
|
145
189
|
const learnings = pickLive('learnings', DIGEST_CAPS.learnings);
|
|
146
190
|
|
|
147
191
|
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}]]`);
|
|
192
|
+
for (const d of decisions) lines.push(`- Decisão: [[${d.path}]] — ${d.title}`);
|
|
149
193
|
for (const s of sessions) lines.push(`- Sessão ${s.date} (${s.provider || '?'}): ${s.summary || s.file} → [[${String(s.file || '').replace(/\.md$/, '')}]]`);
|
|
150
194
|
for (const b of bugs) lines.push(`- Bug: [[${b}]]`);
|
|
151
195
|
for (const l of learnings) lines.push(`- Aprendizado: [[${l}]]`);
|
package/hooks/brain-inject.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
resolveHookOperatingProfile,
|
|
20
20
|
} from './operating-profile-runtime.mjs';
|
|
21
21
|
import { assertVaultPathSafe } from './vault-path-safety.mjs';
|
|
22
|
-
import { validateCore } from '../src/validate-core.mjs';
|
|
22
|
+
import { CORE_LIMITS, validateCore } from '../src/validate-core.mjs';
|
|
23
23
|
|
|
24
24
|
// The process ROUTER — the enforcement layer. The wk-* skills are passive files; without a
|
|
25
25
|
// standing instruction the model plans in chat, leaves the change scaffold raw and forces the
|
|
@@ -52,8 +52,8 @@ function processRouter(localeId) {
|
|
|
52
52
|
|
|
53
53
|
const INJECTION_LIMITS = Object.freeze({
|
|
54
54
|
totalBytes: 24 * 1024,
|
|
55
|
-
lineChars:
|
|
56
|
-
coreBytes:
|
|
55
|
+
lineChars: CORE_LIMITS.lineChars,
|
|
56
|
+
coreBytes: CORE_LIMITS.bytes,
|
|
57
57
|
sharedBytes: 6 * 1024,
|
|
58
58
|
attentionBytes: 1024,
|
|
59
59
|
recallBytes: 512,
|
package/hooks/session-ensure.mjs
CHANGED
|
@@ -36,11 +36,29 @@ import { resolveSessionIdentity } from './session-identity.mjs';
|
|
|
36
36
|
import { readCodexRolloutMeta } from './codex-rollout-meta.mjs';
|
|
37
37
|
import { mutateSessionNote } from './session-note-io.mjs';
|
|
38
38
|
import { captureProjectScope, projectScopePatch } from './project-scope.mjs';
|
|
39
|
+
import { sanitizeMemoryText } from './memory-schema.mjs';
|
|
39
40
|
|
|
40
41
|
function sessionIdFromInput(input) {
|
|
41
42
|
return input.session_id || input.sessionId || input.codex_session_id || '';
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
function workSessionIdFromInput(input = {}) {
|
|
46
|
+
const shared = input.shared || input.handoff?.shared;
|
|
47
|
+
const value = input.work_session_id
|
|
48
|
+
|| input.workSessionId
|
|
49
|
+
|| shared?.work_session_id
|
|
50
|
+
|| shared?.workSessionId
|
|
51
|
+
|| input.handoff?.work_session_id
|
|
52
|
+
|| input.handoff?.workSessionId
|
|
53
|
+
|| '';
|
|
54
|
+
return sanitizeMemoryText(value).trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function workSessionPatch(input = {}) {
|
|
58
|
+
const workSessionId = workSessionIdFromInput(input);
|
|
59
|
+
return workSessionId ? { work_session_id: workSessionId } : {};
|
|
60
|
+
}
|
|
61
|
+
|
|
44
62
|
function turnSequenceFromInput(input = {}) {
|
|
45
63
|
const value = input.turn_sequence ?? input.turnSequence;
|
|
46
64
|
const parsed = Number(value);
|
|
@@ -290,6 +308,7 @@ function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, inp
|
|
|
290
308
|
transcript_path: identity.transcriptPath,
|
|
291
309
|
transcript_id: identity.transcriptId,
|
|
292
310
|
provider: identity.provider,
|
|
311
|
+
...workSessionPatch(input),
|
|
293
312
|
...scopePatch,
|
|
294
313
|
...causalTurnPatch(input, now),
|
|
295
314
|
});
|
|
@@ -318,6 +337,7 @@ function createSession({ vaultBase, sessionId, input, now, identity, scopePatch
|
|
|
318
337
|
transcript_path: identity.transcriptPath,
|
|
319
338
|
transcript_id: identity.transcriptId,
|
|
320
339
|
provider: identity.provider,
|
|
340
|
+
...workSessionPatch(input),
|
|
321
341
|
...scopePatch,
|
|
322
342
|
...causalTurnPatch(input, now),
|
|
323
343
|
});
|
|
@@ -361,6 +381,7 @@ function main() {
|
|
|
361
381
|
upsertSessionRegistry(vaultBase, sessionId, {
|
|
362
382
|
transcript_paths: [identity.transcriptPath],
|
|
363
383
|
provider: identity.provider,
|
|
384
|
+
...workSessionPatch(input),
|
|
364
385
|
});
|
|
365
386
|
}
|
|
366
387
|
writeHookOutput({});
|
|
@@ -398,6 +419,7 @@ function main() {
|
|
|
398
419
|
transcript_path: identity.transcriptPath,
|
|
399
420
|
transcript_id: identity.transcriptId,
|
|
400
421
|
provider: identity.provider,
|
|
422
|
+
...workSessionPatch(input),
|
|
401
423
|
...scopePatch,
|
|
402
424
|
...causalTurnPatch(input, now),
|
|
403
425
|
});
|
|
@@ -443,6 +465,7 @@ function main() {
|
|
|
443
465
|
transcript_path: identity.transcriptPath,
|
|
444
466
|
transcript_id: identity.transcriptId,
|
|
445
467
|
provider: identity.provider,
|
|
468
|
+
...workSessionPatch(input),
|
|
446
469
|
...scopePatch,
|
|
447
470
|
...causalTurnPatch(input, now),
|
|
448
471
|
});
|
|
@@ -65,8 +65,10 @@ export function resolveSessionIdentity(vaultBase, input = {}, provider = detectP
|
|
|
65
65
|
export function resolveSessionEntry(vaultBase, input = {}, provider = detectProvider()) {
|
|
66
66
|
const identity = resolveSessionIdentity(vaultBase, input, provider);
|
|
67
67
|
if (identity.state !== 'resolved') return { identity, entry: null };
|
|
68
|
+
const entry = readSessionRegistry(vaultBase).sessions?.[identity.canonicalConversationId] || null;
|
|
69
|
+
const workSessionId = entry?.work_session_id ? String(entry.work_session_id) : '';
|
|
68
70
|
return {
|
|
69
|
-
identity,
|
|
70
|
-
entry
|
|
71
|
+
identity: workSessionId ? { ...identity, work_session_id: workSessionId } : identity,
|
|
72
|
+
entry,
|
|
71
73
|
};
|
|
72
74
|
}
|
package/hooks/session-stop.mjs
CHANGED
|
@@ -521,6 +521,21 @@ function shouldFinalizeSession() {
|
|
|
521
521
|
return process.env.OBSIDIAN_NO_AUTO_FINALIZE !== '1';
|
|
522
522
|
}
|
|
523
523
|
|
|
524
|
+
function sharedHandoffFromInput(input = {}, entry = {}) {
|
|
525
|
+
const supplied = input.shared || input.handoff?.shared;
|
|
526
|
+
const shared = supplied && typeof supplied === 'object' && !Array.isArray(supplied)
|
|
527
|
+
? { ...supplied }
|
|
528
|
+
: {};
|
|
529
|
+
const workSessionId = shared.work_session_id
|
|
530
|
+
|| shared.workSessionId
|
|
531
|
+
|| input.work_session_id
|
|
532
|
+
|| input.workSessionId
|
|
533
|
+
|| entry?.work_session_id
|
|
534
|
+
|| '';
|
|
535
|
+
if (!shared.work_session_id && workSessionId) shared.work_session_id = workSessionId;
|
|
536
|
+
return Object.keys(shared).length ? shared : null;
|
|
537
|
+
}
|
|
538
|
+
|
|
524
539
|
export function commitSessionMemory(vaultBase, handoff, { projectOptions = {} } = {}) {
|
|
525
540
|
if (detectMemoryMode(vaultBase).mode === 'legacy') {
|
|
526
541
|
return { status: 'legacy', eventCount: 0, eventIds: [], checkpoint: null };
|
|
@@ -1378,6 +1393,7 @@ export async function main({
|
|
|
1378
1393
|
summary: finalSummary,
|
|
1379
1394
|
noteRel: sessionRel,
|
|
1380
1395
|
});
|
|
1396
|
+
const sharedHandoff = sharedHandoffFromInput(input, entry);
|
|
1381
1397
|
memoryHandoff = {
|
|
1382
1398
|
projectId,
|
|
1383
1399
|
identity,
|
|
@@ -1390,6 +1406,7 @@ export async function main({
|
|
|
1390
1406
|
observedAt: turnIdentity.observedAt || new Date(0).toISOString(),
|
|
1391
1407
|
summary: finalSummary,
|
|
1392
1408
|
evidence: memoryEvidence,
|
|
1409
|
+
...(sharedHandoff ? { shared: sharedHandoff } : {}),
|
|
1393
1410
|
};
|
|
1394
1411
|
memoryAttempt = stageMemory(vaultBase, {
|
|
1395
1412
|
handoff: memoryHandoff,
|
package/hooks/vault-health.mjs
CHANGED
|
@@ -231,6 +231,12 @@ function memoryMetrics() {
|
|
|
231
231
|
pendingOutbox: 0,
|
|
232
232
|
candidates: 0,
|
|
233
233
|
activeConflicts: 0,
|
|
234
|
+
semanticStatus: null,
|
|
235
|
+
semanticCode: null,
|
|
236
|
+
semanticActiveKeys: [],
|
|
237
|
+
semanticProjectedKeys: [],
|
|
238
|
+
semanticMissingKeys: [],
|
|
239
|
+
semanticCounts: {},
|
|
234
240
|
};
|
|
235
241
|
}
|
|
236
242
|
|
|
@@ -505,6 +511,7 @@ export function checkMemoryBundle(vaultBase, { registry } = {}) {
|
|
|
505
511
|
for (const warning of bundle.warnings || []) warnings.push(warning);
|
|
506
512
|
|
|
507
513
|
const ok = failures.length === 0;
|
|
514
|
+
const semantic = bundle.semantic || {};
|
|
508
515
|
return {
|
|
509
516
|
ok,
|
|
510
517
|
status: ok ? (warnings.length ? 'warning' : 'healthy') : 'blocked',
|
|
@@ -519,6 +526,12 @@ export function checkMemoryBundle(vaultBase, { registry } = {}) {
|
|
|
519
526
|
pendingOutbox: outbox.count,
|
|
520
527
|
candidates: candidates.items.length,
|
|
521
528
|
activeConflicts: activeConflicts.length,
|
|
529
|
+
semanticStatus: semantic.status ?? null,
|
|
530
|
+
semanticCode: semantic.code ?? null,
|
|
531
|
+
semanticActiveKeys: semantic.activeKeys || [],
|
|
532
|
+
semanticProjectedKeys: semantic.projectedKeys || [],
|
|
533
|
+
semanticMissingKeys: semantic.missingKeys || [],
|
|
534
|
+
semanticCounts: semantic.counts || {},
|
|
522
535
|
},
|
|
523
536
|
};
|
|
524
537
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.69.0",
|
|
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": [
|
|
@@ -70,6 +70,6 @@
|
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"acorn": "^8.18.0",
|
|
73
|
-
"wendkeep": "^0.68.
|
|
73
|
+
"wendkeep": "^0.68.6"
|
|
74
74
|
}
|
|
75
75
|
}
|
|
@@ -107,7 +107,7 @@ Usage:
|
|
|
107
107
|
promote <candidate> [--event <event-id>] | reject <candidate>. --vault P.
|
|
108
108
|
Reconcile is dry-run by default; the original attempt remains audited.
|
|
109
109
|
wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
|
|
110
|
-
protocol (cap
|
|
110
|
+
protocol (cap 40, warning 35, 4 KiB, 320 chars/line, 3 sections, no secrets/PII).
|
|
111
111
|
--vault <path> validates the complete v2 bundle.
|
|
112
112
|
wendkeep sync-defs [opts] Copy versioned defs from the vault's .brain into the
|
|
113
113
|
project: .brain/agents/*.toml -> .codex/agents,
|
|
@@ -4,12 +4,55 @@ import { basename, join, relative } from 'node:path';
|
|
|
4
4
|
|
|
5
5
|
import { sanitizeMemoryText } from './memory-schema.mjs';
|
|
6
6
|
|
|
7
|
+
const SHARED_HANDOFF_FIELDS = Object.freeze([
|
|
8
|
+
['objective', 'objective.current'],
|
|
9
|
+
['delivered', 'state.delivered'],
|
|
10
|
+
['constraints', 'constraint.active'],
|
|
11
|
+
['decisions', 'decision.active'],
|
|
12
|
+
['next_actions', 'next.action'],
|
|
13
|
+
['blockers', 'blocker.active'],
|
|
14
|
+
['risks', 'risk.known'],
|
|
15
|
+
]);
|
|
16
|
+
|
|
7
17
|
function canonicalValue(value) {
|
|
8
18
|
if (Array.isArray(value)) return value.map(canonicalValue);
|
|
9
19
|
if (!value || typeof value !== 'object') return value;
|
|
10
20
|
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])]));
|
|
11
21
|
}
|
|
12
22
|
|
|
23
|
+
function sanitizeValue(value) {
|
|
24
|
+
if (typeof value === 'string') return sanitizeMemoryText(value);
|
|
25
|
+
if (Array.isArray(value)) return value.map(sanitizeValue);
|
|
26
|
+
if (value && typeof value === 'object') {
|
|
27
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sanitizeValue(value[key])]));
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hasMeaningfulValue(value) {
|
|
33
|
+
if (typeof value === 'string') return value.trim().length > 0;
|
|
34
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
35
|
+
if (value && typeof value === 'object') return Object.keys(value).length > 0;
|
|
36
|
+
return value !== undefined && value !== null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Normalize the portable operational handoff without inventing missing identity. */
|
|
40
|
+
export function normalizeSharedHandoff(shared) {
|
|
41
|
+
if (!shared || typeof shared !== 'object' || Array.isArray(shared)) return null;
|
|
42
|
+
|
|
43
|
+
const normalized = {};
|
|
44
|
+
const workSessionId = sanitizeMemoryText(shared.work_session_id ?? shared.workSessionId ?? '').trim();
|
|
45
|
+
if (workSessionId) normalized.work_session_id = workSessionId;
|
|
46
|
+
|
|
47
|
+
for (const [field] of SHARED_HANDOFF_FIELDS) {
|
|
48
|
+
if (!Object.hasOwn(shared, field)) continue;
|
|
49
|
+
const value = sanitizeValue(shared[field]);
|
|
50
|
+
if (hasMeaningfulValue(value)) normalized[field] = value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return Object.keys(normalized).length ? normalized : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
13
56
|
function eventId(context, memoryKey, value) {
|
|
14
57
|
const digest = createHash('sha256')
|
|
15
58
|
.update(JSON.stringify([
|
|
@@ -26,8 +69,8 @@ function eventId(context, memoryKey, value) {
|
|
|
26
69
|
}
|
|
27
70
|
|
|
28
71
|
function makeEvent(context, { memoryKey, value, authority, evidence }) {
|
|
29
|
-
const cleanValue =
|
|
30
|
-
|
|
72
|
+
const cleanValue = sanitizeValue(value);
|
|
73
|
+
const event = {
|
|
31
74
|
v: 1,
|
|
32
75
|
event_id: eventId(context, memoryKey, cleanValue),
|
|
33
76
|
project_id: String(context.projectId || ''),
|
|
@@ -43,6 +86,8 @@ function makeEvent(context, { memoryKey, value, authority, evidence }) {
|
|
|
43
86
|
observed_at: context.observedAt,
|
|
44
87
|
evidence: (evidence || []).filter(Boolean).map((item) => sanitizeMemoryText(item)),
|
|
45
88
|
};
|
|
89
|
+
if (context.workSessionId) event.work_session_id = context.workSessionId;
|
|
90
|
+
return event;
|
|
46
91
|
}
|
|
47
92
|
|
|
48
93
|
function readJson(path) {
|
|
@@ -133,14 +178,35 @@ export function buildSessionMemoryEvents({
|
|
|
133
178
|
observedAt,
|
|
134
179
|
summary,
|
|
135
180
|
evidence = {},
|
|
181
|
+
shared,
|
|
136
182
|
}) {
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
183
|
+
const normalizedShared = normalizeSharedHandoff(shared);
|
|
184
|
+
const context = {
|
|
185
|
+
projectId, identity, activation, turn, observedAt,
|
|
186
|
+
workSessionId: normalizedShared?.work_session_id || '',
|
|
187
|
+
};
|
|
188
|
+
const events = [];
|
|
189
|
+
|
|
190
|
+
if (normalizedShared) {
|
|
191
|
+
for (const [field, memoryKey] of SHARED_HANDOFF_FIELDS) {
|
|
192
|
+
if (!Object.hasOwn(normalizedShared, field)) continue;
|
|
193
|
+
events.push(makeEvent(context, {
|
|
194
|
+
memoryKey,
|
|
195
|
+
value: normalizedShared[field],
|
|
196
|
+
authority: 'reported',
|
|
197
|
+
evidence: [noteRel],
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!events.length) {
|
|
203
|
+
events.push(makeEvent(context, {
|
|
204
|
+
memoryKey: 'handoff.latest',
|
|
205
|
+
value: sanitizeMemoryText(summary),
|
|
206
|
+
authority: 'reported',
|
|
207
|
+
evidence: [noteRel],
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
144
210
|
|
|
145
211
|
if (evidence.change?.slug && evidence.change?.status && evidence.change?.adr) {
|
|
146
212
|
events.push(makeEvent(context, {
|
|
@@ -437,6 +437,29 @@ function conflictCandidate(memoryKey, events, currentEvent = null) {
|
|
|
437
437
|
};
|
|
438
438
|
}
|
|
439
439
|
|
|
440
|
+
function conflictReviewEvent(candidate, candidateCount = 1) {
|
|
441
|
+
const source = candidate.events?.[0] || {};
|
|
442
|
+
const memoryKey = sanitizeMemoryText(candidate.memory_key || 'unknown');
|
|
443
|
+
const eventCount = Array.isArray(candidate.event_ids) ? candidate.event_ids.length : 0;
|
|
444
|
+
return {
|
|
445
|
+
v: 1,
|
|
446
|
+
event_id: `mem-review-${candidate.candidate_id}`,
|
|
447
|
+
project_id: source.project_id || '',
|
|
448
|
+
memory_key: candidate.memory_key,
|
|
449
|
+
operation: 'assert',
|
|
450
|
+
value: `[revisão pendente: ${memoryKey}; candidates: ${candidateCount}; events: ${eventCount}]`,
|
|
451
|
+
authority: 'candidate',
|
|
452
|
+
canonical_session_id: source.canonical_session_id || 'memory-reducer',
|
|
453
|
+
activation_id: source.activation_id || 'memory-reducer',
|
|
454
|
+
activation_epoch: Number.isInteger(source.activation_epoch) ? source.activation_epoch : 0,
|
|
455
|
+
turn_sequence: 0,
|
|
456
|
+
source_turn_id: 'memory-review',
|
|
457
|
+
observed_at: source.observed_at || new Date(0).toISOString(),
|
|
458
|
+
evidence: ['MEMORY_CANDIDATES.jsonl'],
|
|
459
|
+
review_pending: true,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
440
463
|
function sortedObject(entries) {
|
|
441
464
|
return Object.fromEntries([...entries].sort(([left], [right]) => left.localeCompare(right)));
|
|
442
465
|
}
|
|
@@ -753,14 +776,19 @@ export function reduceMemoryEvents(inputEvents = [], {
|
|
|
753
776
|
&& !resolvedCandidateIds.has(item.candidate_id));
|
|
754
777
|
unresolvedCandidates.sort((left, right) => left.candidate_id.localeCompare(right.candidate_id));
|
|
755
778
|
superseded.sort((left, right) => left.event_id.localeCompare(right.event_id));
|
|
756
|
-
const activeEvents = Object.entries(recordObject).map(([memoryKey, record]) => ({
|
|
757
|
-
...record.source,
|
|
758
|
-
memory_key: memoryKey,
|
|
759
|
-
operation: 'assert',
|
|
760
|
-
value: record.value,
|
|
761
|
-
}));
|
|
762
779
|
const eventCursor = events.at(-1)?.event_id || 'none';
|
|
763
780
|
const stateHash = hashMemoryValue({ state, tombstones: tombstoneObject });
|
|
781
|
+
const activeEvents = [
|
|
782
|
+
...Object.entries(recordObject).map(([memoryKey, record]) => ({
|
|
783
|
+
...record.source,
|
|
784
|
+
memory_key: memoryKey,
|
|
785
|
+
operation: 'assert',
|
|
786
|
+
value: record.value,
|
|
787
|
+
})),
|
|
788
|
+
...unresolvedCandidates.map((candidate) => (
|
|
789
|
+
conflictReviewEvent(candidate, unresolvedCandidates.length)
|
|
790
|
+
)),
|
|
791
|
+
];
|
|
764
792
|
|
|
765
793
|
return {
|
|
766
794
|
state,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Memory-compaction protocol for the curated .brain/CORE.md layer.
|
|
2
2
|
// Ported from NutriGym-Vision's scripts/validate-brain-core.js to ESM:
|
|
3
|
-
// - cap
|
|
3
|
+
// - cap 40 lines (hard), 35 (soft warning) — 1 durable item per line
|
|
4
|
+
// - 4 KiB and 320 characters per line
|
|
4
5
|
// - 3 required sections
|
|
5
6
|
// - no secrets / no real-provider PII emails
|
|
6
7
|
// Plus the seeded skeleton and the protocol reference doc.
|
|
@@ -8,8 +9,12 @@
|
|
|
8
9
|
import { existsSync, readFileSync } from 'node:fs';
|
|
9
10
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
10
11
|
|
|
11
|
-
const
|
|
12
|
-
|
|
12
|
+
export const CORE_LIMITS = Object.freeze({
|
|
13
|
+
lines: 40,
|
|
14
|
+
warningLines: 35,
|
|
15
|
+
bytes: 4 * 1024,
|
|
16
|
+
lineChars: 320,
|
|
17
|
+
});
|
|
13
18
|
|
|
14
19
|
// Bilingual (0.8.0): a CORE is valid when it carries the COMPLETE section set of either
|
|
15
20
|
// locale — pt-BR or en. Mixed/partial sets fail (the 3 sections are one contract).
|
|
@@ -40,16 +45,25 @@ const SECRET_PATTERNS = [
|
|
|
40
45
|
|
|
41
46
|
const PII_EMAIL_REGEX = /\b[A-Za-z0-9._%+-]+@(?!example\.(?:com|org|net)\b)(?:gmail|hotmail|yahoo|outlook|live|icloud|protonmail)\.[A-Za-z]{2,}\b/i;
|
|
42
47
|
|
|
43
|
-
// Validate CORE.md content. Returns { ok, errors, warnings, lineCount }.
|
|
48
|
+
// Validate CORE.md content. Returns { ok, errors, warnings, lineCount, byteCount }.
|
|
44
49
|
export function validateCore(content) {
|
|
45
50
|
const text = String(content ?? '');
|
|
46
51
|
const lines = text.split('\n');
|
|
47
52
|
const lineCount = text.endsWith('\n') ? lines.length - 1 : lines.length;
|
|
53
|
+
const byteCount = Buffer.byteLength(text, 'utf8');
|
|
48
54
|
const errors = [];
|
|
49
55
|
|
|
50
|
-
if (lineCount >
|
|
51
|
-
errors.push(`Tamanho ${lineCount} > ${
|
|
56
|
+
if (lineCount > CORE_LIMITS.lines) {
|
|
57
|
+
errors.push(`Tamanho ${lineCount} > ${CORE_LIMITS.lines} linhas (hard limit). Curar: remover itens resolvidos (detalhe vive no vault/git).`);
|
|
52
58
|
}
|
|
59
|
+
if (byteCount > CORE_LIMITS.bytes) {
|
|
60
|
+
errors.push(`Tamanho ${byteCount} > ${CORE_LIMITS.bytes} bytes (budget do CORE). Curar: manter apenas estado durável.`);
|
|
61
|
+
}
|
|
62
|
+
lines.forEach((line, index) => {
|
|
63
|
+
if (line.length > CORE_LIMITS.lineChars) {
|
|
64
|
+
errors.push(`Linha ${index + 1} tem ${line.length} caracteres; limite ${CORE_LIMITS.lineChars}.`);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
53
67
|
// Pick the locale set that matches best; require it to be complete.
|
|
54
68
|
const missingBySet = Object.values(SECTION_SETS).map((set) => set.filter(({ regex }) => !regex.test(text)));
|
|
55
69
|
const best = missingBySet.reduce((a, b) => (b.length < a.length ? b : a));
|
|
@@ -62,11 +76,11 @@ export function validateCore(content) {
|
|
|
62
76
|
if (em) errors.push(`Email real detectado: "${em[0]}" — usar user@example.com.`);
|
|
63
77
|
|
|
64
78
|
const warnings = [];
|
|
65
|
-
if (lineCount >=
|
|
66
|
-
warnings.push(`Tamanho ${lineCount}/${
|
|
79
|
+
if (lineCount >= CORE_LIMITS.warningLines && lineCount <= CORE_LIMITS.lines) {
|
|
80
|
+
warnings.push(`Tamanho ${lineCount}/${CORE_LIMITS.lines} linhas — perto do limite; remover itens resolvidos (≥${CORE_LIMITS.warningLines}).`);
|
|
67
81
|
}
|
|
68
82
|
|
|
69
|
-
return { ok: errors.length === 0, errors, warnings, lineCount };
|
|
83
|
+
return { ok: errors.length === 0, errors, warnings, lineCount, byteCount };
|
|
70
84
|
}
|
|
71
85
|
|
|
72
86
|
// The seeded CORE.md (must pass validateCore). Bootstraps the 3 sections so the
|
|
@@ -75,7 +89,7 @@ export function renderCoreSkeleton(localeId = 'pt-BR') {
|
|
|
75
89
|
if (localeId === 'en') {
|
|
76
90
|
return `# CORE — curated memory core (.brain)
|
|
77
91
|
|
|
78
|
-
> RULE #1 — the project's canonical memory. Hand-curated,
|
|
92
|
+
> RULE #1 — the project's canonical memory. Hand-curated, 40-line cap (validate: \`wendkeep validate-memory\`). Volatile facts live in DIGEST.md (auto). Depth: /brain-recall <topic>.
|
|
79
93
|
|
|
80
94
|
## User Preferences
|
|
81
95
|
- (durable preferences: language, style, conventions)
|
|
@@ -89,7 +103,7 @@ export function renderCoreSkeleton(localeId = 'pt-BR') {
|
|
|
89
103
|
}
|
|
90
104
|
return `# CORE — núcleo curado da memória (.brain)
|
|
91
105
|
|
|
92
|
-
> REGRA #1 — memória canônica do projeto. Curado à mão, cap
|
|
106
|
+
> REGRA #1 — memória canônica do projeto. Curado à mão, cap 40 linhas (valide: \`wendkeep validate-memory\`). Volátil vive no DIGEST.md (auto). Profundidade: /brain-recall <tópico>.
|
|
93
107
|
|
|
94
108
|
## Preferências do Usuário
|
|
95
109
|
- (preferências duráveis: idioma, estilo, convenções)
|
|
@@ -110,8 +124,8 @@ export function renderCompactionProtocol() {
|
|
|
110
124
|
|
|
111
125
|
## 1. Duas camadas
|
|
112
126
|
|
|
113
|
-
- **QUENTE** (auto-injetada por sessão,
|
|
114
|
-
- \`.brain/CORE.md\` — curado à mão, **≤
|
|
127
|
+
- **QUENTE** (auto-injetada por sessão, com budgets por camada):
|
|
128
|
+
- \`.brain/CORE.md\` — curado à mão, **≤40 linhas** (alerta em 35; 4 KiB; 320 caracteres por linha): preferências, padrões, pendências.
|
|
115
129
|
- \`.brain/DIGEST.md\` — auto-gerado (0 token LLM, ≤15 linhas): decisões/sessões/bugs/aprendizados recentes.
|
|
116
130
|
- **FRIA** (sob demanda):
|
|
117
131
|
- \`.brain/index.jsonl\` — índice de todas as sessões (1/linha, frontmatter).
|
|
@@ -120,7 +134,7 @@ export function renderCompactionProtocol() {
|
|
|
120
134
|
## 2. Compactação = regra de geração (sem trabalho manual)
|
|
121
135
|
|
|
122
136
|
- **DIGEST se auto-compacta**: caps determinísticos (5 decisões, 4 sessões, 2 bugs, 2 aprendizados + \`+N mais\`). O velho cai do quente sozinho e permanece no índice/vault. **NUNCA editar** \`DIGEST.md\`/\`index.jsonl\`.
|
|
123
|
-
- **CORE**: quando ≥
|
|
137
|
+
- **CORE**: quando ≥35 linhas (soft warning), remover itens resolvidos/obsoletos — o detalhe já vive no vault e no histórico do git.
|
|
124
138
|
|
|
125
139
|
## 3. O que escrever no CORE
|
|
126
140
|
|
|
@@ -139,7 +153,7 @@ wendkeep validate-memory # valida <vault>/.brain/CORE.md
|
|
|
139
153
|
wendkeep validate-memory <path> # valida outro arquivo
|
|
140
154
|
\`\`\`
|
|
141
155
|
|
|
142
|
-
Checa: cap
|
|
156
|
+
Checa: cap 40 (soft 35), 4 KiB, 320 caracteres por linha, 3 seções, sem segredos/PII. Exit 0 = OK, 1 = falha.
|
|
143
157
|
`;
|
|
144
158
|
}
|
|
145
159
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { validateMemoryEvent, validateSharedMemory } from './memory-schema.mjs';
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { basename, join, relative } from 'node:path';
|
|
3
|
+
import { sanitizeMemoryText, validateMemoryEvent, validateSharedMemory } from './memory-schema.mjs';
|
|
4
|
+
import { deriveMemoryProjection } from './memory-store.mjs';
|
|
4
5
|
import { assertVaultPathSafe } from './vault-path-safety.mjs';
|
|
5
6
|
import { validateCore } from './validate-core.mjs';
|
|
6
7
|
|
|
@@ -104,8 +105,208 @@ function validateSharedArtifact(vaultBase, eventIds) {
|
|
|
104
105
|
return { ...validateSharedMemory(read.content, { eventIds }), path, content: read.content };
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
const TERMINAL_CANDIDATE_STATUSES = new Set(['resolved', 'rejected', 'superseded']);
|
|
109
|
+
const DECISION_LINK_RE = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]/g;
|
|
110
|
+
|
|
111
|
+
function readCandidateInventory(vaultBase) {
|
|
112
|
+
const path = join(vaultBase, '.brain', 'MEMORY_CANDIDATES.jsonl');
|
|
113
|
+
let checked;
|
|
114
|
+
try {
|
|
115
|
+
checked = assertVaultPathSafe(vaultBase, path, {
|
|
116
|
+
allowMissing: true, expectedType: 'file', label: 'MEMORY_CANDIDATES.jsonl',
|
|
117
|
+
});
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return failedComponent(`MEMORY_CANDIDATES.jsonl inseguro: ${error?.message || error}`, {
|
|
120
|
+
count: 0, activeCount: 0, path,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (!checked.exists) return { ok: true, errors: [], warnings: [], count: 0, activeCount: 0, path };
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
checked = assertVaultPathSafe(vaultBase, checked.target, {
|
|
127
|
+
allowMissing: false, expectedType: 'file', label: 'MEMORY_CANDIDATES.jsonl',
|
|
128
|
+
});
|
|
129
|
+
const lines = readFileSync(checked.target, 'utf8').replace(/\r\n/g, '\n')
|
|
130
|
+
.split('\n').filter((line) => line.trim());
|
|
131
|
+
const errors = [];
|
|
132
|
+
let count = 0;
|
|
133
|
+
let activeCount = 0;
|
|
134
|
+
for (const [index, line] of lines.entries()) {
|
|
135
|
+
try {
|
|
136
|
+
const item = JSON.parse(line);
|
|
137
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
138
|
+
errors.push(`MEMORY_CANDIDATES.jsonl linha ${index + 1} deve conter um objeto.`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
count += 1;
|
|
142
|
+
if (!TERMINAL_CANDIDATE_STATUSES.has(item.status || 'active')) activeCount += 1;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
errors.push(`MEMORY_CANDIDATES.jsonl linha ${index + 1} contém JSON inválido: ${error?.message || error}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { ok: errors.length === 0, errors, warnings: [], count, activeCount, path };
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return failedComponent(`MEMORY_CANDIDATES.jsonl ilegível: ${error?.message || error}`, {
|
|
150
|
+
count: 0, activeCount: 0, path,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function sharedEventIds(content) {
|
|
156
|
+
const ids = new Set();
|
|
157
|
+
for (const line of String(content || '').split('\n')) {
|
|
158
|
+
const match = line.match(/^\s*-\s+\[([^\]]+)\]/);
|
|
159
|
+
if (match?.[1]) ids.add(match[1]);
|
|
160
|
+
}
|
|
161
|
+
return ids;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function walkDecisionFiles(root, vaultBase, output = []) {
|
|
165
|
+
let entries;
|
|
166
|
+
try { entries = readdirSync(root, { withFileTypes: true }); } catch { return output; }
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
const path = join(root, entry.name);
|
|
169
|
+
if (entry.isDirectory()) walkDecisionFiles(path, vaultBase, output);
|
|
170
|
+
else if (entry.isFile() && /^ADR-\d+.*\.md$/i.test(entry.name)) {
|
|
171
|
+
const rel = relative(vaultBase, path).replace(/\\/g, '/').replace(/\.md$/i, '');
|
|
172
|
+
output.push(rel);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return output;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function decisionInventory(vaultBase) {
|
|
179
|
+
const paths = [];
|
|
180
|
+
for (const folder of ['04-Decisões', '04-Decisions']) {
|
|
181
|
+
walkDecisionFiles(join(vaultBase, folder), vaultBase, paths);
|
|
182
|
+
}
|
|
183
|
+
const byPath = new Set(paths);
|
|
184
|
+
const byBasename = new Set(paths.map((path) => basename(path)));
|
|
185
|
+
return { byPath, byBasename };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function unresolvedDecisionLinks(vaultBase, content) {
|
|
189
|
+
const inventory = decisionInventory(vaultBase);
|
|
190
|
+
const unresolved = new Set();
|
|
191
|
+
DECISION_LINK_RE.lastIndex = 0;
|
|
192
|
+
let match;
|
|
193
|
+
while ((match = DECISION_LINK_RE.exec(String(content || '')))) {
|
|
194
|
+
const target = String(match[1] || '').trim().replace(/\\/g, '/').replace(/\.md$/i, '');
|
|
195
|
+
const targetBase = basename(target);
|
|
196
|
+
const isDecision = /^ADR-\d+/i.test(targetBase)
|
|
197
|
+
|| /(^|\/)(?:04-Decis(?:õ|o)es|04-Decisions)(?:\/|$)/i.test(target);
|
|
198
|
+
if (!isDecision) continue;
|
|
199
|
+
if (!target || target.includes('...') || target.includes('…')
|
|
200
|
+
|| (!inventory.byPath.has(target) && !inventory.byBasename.has(targetBase))) {
|
|
201
|
+
unresolved.add(sanitizeMemoryText(target).slice(0, 180));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return [...unresolved].sort();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function semanticMemoryHealth(vaultBase, { ledger, shared, candidates }) {
|
|
208
|
+
const base = {
|
|
209
|
+
ok: true,
|
|
210
|
+
status: 'unavailable',
|
|
211
|
+
code: 'MEMORY_SEMANTIC_STRUCTURAL_UNAVAILABLE',
|
|
212
|
+
errors: [],
|
|
213
|
+
warnings: [],
|
|
214
|
+
activeKeys: [],
|
|
215
|
+
projectedKeys: [],
|
|
216
|
+
missingKeys: [],
|
|
217
|
+
unresolvedDecisionLinks: [],
|
|
218
|
+
placeholderOnly: false,
|
|
219
|
+
counts: { activeKeys: 0, projectedKeys: 0, missingKeys: 0, candidates: candidates?.activeCount || 0, placeholderSections: 0, unresolvedDecisionLinks: 0 },
|
|
220
|
+
};
|
|
221
|
+
if (!ledger?.ok || !shared?.ok) return base;
|
|
222
|
+
|
|
223
|
+
let replay;
|
|
224
|
+
try { replay = deriveMemoryProjection(vaultBase, ledger.events); }
|
|
225
|
+
catch (error) {
|
|
226
|
+
return {
|
|
227
|
+
...base,
|
|
228
|
+
ok: false,
|
|
229
|
+
status: 'degraded',
|
|
230
|
+
code: 'MEMORY_SEMANTIC_REPLAY_UNAVAILABLE',
|
|
231
|
+
errors: ['[MEMORY_SEMANTIC_REPLAY_UNAVAILABLE] não foi possível rederivar as chaves ativas do ledger.'],
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const active = Object.entries(replay.records || {}).map(([memoryKey, record]) => ({
|
|
236
|
+
memoryKey: sanitizeMemoryText(memoryKey),
|
|
237
|
+
eventId: record?.source?.event_id,
|
|
238
|
+
}));
|
|
239
|
+
const activeKeys = active.map(({ memoryKey }) => memoryKey).sort();
|
|
240
|
+
const projectedIds = sharedEventIds(shared.content);
|
|
241
|
+
const projectedKeys = active
|
|
242
|
+
.filter(({ eventId }) => projectedIds.has(eventId))
|
|
243
|
+
.map(({ memoryKey }) => memoryKey)
|
|
244
|
+
.sort();
|
|
245
|
+
const missingKeys = active
|
|
246
|
+
.filter(({ eventId }) => !projectedIds.has(eventId))
|
|
247
|
+
.map(({ memoryKey }) => memoryKey)
|
|
248
|
+
.sort();
|
|
249
|
+
const sectionValues = [...(shared.sections?.values?.() || [])].flat();
|
|
250
|
+
const placeholderSections = sectionValues.filter((line) => /^-\s*\(vazio\)\s*$/i.test(line)).length;
|
|
251
|
+
const nonPlaceholderLines = sectionValues.filter((line) => !/^-\s*\(vazio\)\s*$/i.test(line));
|
|
252
|
+
const placeholderOnly = sectionValues.length > 0 && nonPlaceholderLines.length === 0;
|
|
253
|
+
const unresolvedLinks = unresolvedDecisionLinks(vaultBase, shared.content);
|
|
254
|
+
const candidateCount = Math.max(candidates?.activeCount || 0, replay.candidates?.length || 0);
|
|
255
|
+
const errors = [];
|
|
256
|
+
const warnings = [];
|
|
257
|
+
const codes = [];
|
|
258
|
+
|
|
259
|
+
if (missingKeys.length && placeholderOnly) {
|
|
260
|
+
codes.push('MEMORY_SEMANTIC_PLACEHOLDER_ONLY');
|
|
261
|
+
errors.push(`[MEMORY_SEMANTIC_PLACEHOLDER_ONLY] SHARED contém somente placeholders para ${activeKeys.length} chave(s) ativa(s); candidates=${candidateCount}.`);
|
|
262
|
+
} else if (missingKeys.length) {
|
|
263
|
+
codes.push('MEMORY_SEMANTIC_COVERAGE_MISSING');
|
|
264
|
+
errors.push(`[MEMORY_SEMANTIC_COVERAGE_MISSING] SHARED não cobre ${missingKeys.length} chave(s) ativa(s): ${missingKeys.join(', ')}.`);
|
|
265
|
+
}
|
|
266
|
+
if (unresolvedLinks.length) {
|
|
267
|
+
codes.push('MEMORY_SEMANTIC_DECISION_LINK_UNRESOLVED');
|
|
268
|
+
errors.push(`[MEMORY_SEMANTIC_DECISION_LINK_UNRESOLVED] ${unresolvedLinks.length} link(s) de decisão não resolvido(s).`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let status = 'healthy';
|
|
272
|
+
let code = 'MEMORY_SEMANTIC_COVERAGE_OK';
|
|
273
|
+
if (!ledger.events.length && !candidateCount) {
|
|
274
|
+
status = 'neutral';
|
|
275
|
+
code = 'MEMORY_SEMANTIC_EMPTY_NEUTRAL';
|
|
276
|
+
} else if (!activeKeys.length && candidateCount) {
|
|
277
|
+
status = 'degraded';
|
|
278
|
+
code = 'MEMORY_SEMANTIC_CANDIDATES_PENDING';
|
|
279
|
+
warnings.push(`[MEMORY_SEMANTIC_CANDIDATES_PENDING] ${candidateCount} candidate(s) preservado(s); o estado não é memória vazia.`);
|
|
280
|
+
} else if (codes.length) {
|
|
281
|
+
status = 'degraded';
|
|
282
|
+
code = codes[0];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
ok: errors.length === 0,
|
|
287
|
+
status,
|
|
288
|
+
code,
|
|
289
|
+
codes,
|
|
290
|
+
errors,
|
|
291
|
+
warnings,
|
|
292
|
+
activeKeys,
|
|
293
|
+
projectedKeys,
|
|
294
|
+
missingKeys,
|
|
295
|
+
unresolvedDecisionLinks: unresolvedLinks,
|
|
296
|
+
placeholderOnly,
|
|
297
|
+
counts: {
|
|
298
|
+
activeKeys: activeKeys.length,
|
|
299
|
+
projectedKeys: projectedKeys.length,
|
|
300
|
+
missingKeys: missingKeys.length,
|
|
301
|
+
candidates: candidateCount,
|
|
302
|
+
placeholderSections,
|
|
303
|
+
unresolvedDecisionLinks: unresolvedLinks.length,
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function combineMemoryResults({ project, core, ledger, shared, candidates, semantic }) {
|
|
309
|
+
const components = { project, core, ledger, shared, candidates, semantic };
|
|
109
310
|
const errors = [];
|
|
110
311
|
const warnings = [];
|
|
111
312
|
for (const [name, result] of Object.entries(components)) {
|
|
@@ -124,5 +325,7 @@ export function validateMemoryBundle(vaultBase) {
|
|
|
124
325
|
const core = validateCoreArtifact(vaultBase);
|
|
125
326
|
const ledger = readLedgerForValidation(vaultBase, { projectId: project.projectId });
|
|
126
327
|
const shared = validateSharedArtifact(vaultBase, ledger.eventIds);
|
|
127
|
-
|
|
328
|
+
const candidates = readCandidateInventory(vaultBase);
|
|
329
|
+
const semantic = semanticMemoryHealth(vaultBase, { ledger, shared, candidates });
|
|
330
|
+
return combineMemoryResults({ project, core, ledger, shared, candidates, semantic });
|
|
128
331
|
}
|
package/src/doctor.mjs
CHANGED
|
@@ -39,6 +39,10 @@ export function renderVaultHealthLines(result) {
|
|
|
39
39
|
`[memória] ${healthStatusLabel(result.memoryStatus)} — schema: ${metricValue(memory.schemaVersion)} · revisão: ${metricValue(memory.revision)} · cursor: ${metricValue(memory.eventCursor)} · hash: ${metricValue(memory.stateHash)}`,
|
|
40
40
|
);
|
|
41
41
|
lines.push(` ledger: ${metricValue(memory.ledgerEvents)} evento(s) · outbox: ${metricValue(memory.pendingOutbox)} · candidates: ${metricValue(memory.candidates)} · conflitos: ${metricValue(memory.activeConflicts)}`);
|
|
42
|
+
const semanticKeys = memory.semanticActiveKeys || [];
|
|
43
|
+
const semanticProjected = memory.semanticProjectedKeys || [];
|
|
44
|
+
const semanticMissing = memory.semanticMissingKeys || [];
|
|
45
|
+
lines.push(` semântica: ${metricValue(memory.semanticCode)} · ativas: ${semanticKeys.length} [${semanticKeys.join(', ')}] · projetadas: ${semanticProjected.length} · ausentes: ${semanticMissing.length}`);
|
|
42
46
|
for (const failure of memoryFailures) lines.push(` ✗ ${failure}`);
|
|
43
47
|
for (const warning of memoryWarnings) lines.push(` ! ${warning}`);
|
|
44
48
|
if (result.memoryStatus === 'healthy' && !memoryFailures.length && !memoryWarnings.length) {
|
package/src/memory.mjs
CHANGED
|
@@ -1874,13 +1874,16 @@ export function runValidateMemoryBundle(argv) {
|
|
|
1874
1874
|
return;
|
|
1875
1875
|
}
|
|
1876
1876
|
const result = validateMemoryBundle(vault);
|
|
1877
|
+
const semantic = result.semantic || {};
|
|
1878
|
+
const semanticSummary = `semântica ${semantic.code || 'n/a'} · ativas: ${semantic.counts?.activeKeys ?? 0} · projetadas: ${semantic.counts?.projectedKeys ?? 0} · ausentes: ${semantic.counts?.missingKeys ?? 0}`;
|
|
1877
1879
|
if (!result.ok) {
|
|
1878
1880
|
process.stderr.write(`❌ bundle de memória inválido (${result.errors.length} erro(s)):\n`);
|
|
1881
|
+
process.stderr.write(` ${semanticSummary}\n`);
|
|
1879
1882
|
for (const error of result.errors) process.stderr.write(` - ${error}\n`);
|
|
1880
1883
|
process.exitCode = 1;
|
|
1881
1884
|
return;
|
|
1882
1885
|
}
|
|
1883
|
-
process.stdout.write(
|
|
1886
|
+
process.stdout.write(`✅ bundle de memória v2 OK (CORE + ledger + SHARED; ${semanticSummary}).\n`);
|
|
1884
1887
|
process.exitCode = 0;
|
|
1885
1888
|
}
|
|
1886
1889
|
|