wendkeep 0.57.2 → 0.58.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 +21 -0
- package/README.en.md +42 -8
- package/README.md +42 -8
- package/bin/wendkeep.mjs +16 -4
- package/hooks/brain-inject.mjs +185 -19
- package/hooks/change-core.mjs +42 -5
- package/hooks/lessons-core.mjs +8 -2
- package/hooks/memory-handoff.mjs +199 -0
- package/hooks/memory-schema.mjs +295 -0
- package/hooks/memory-store.mjs +660 -0
- package/hooks/obsidian-common.mjs +241 -6
- package/hooks/session-ensure.mjs +20 -0
- package/hooks/session-start.mjs +13 -4
- package/hooks/session-stop.mjs +134 -16
- package/hooks/vault-health.mjs +139 -1
- package/package.json +2 -2
- package/src/init.mjs +2 -0
- package/src/memory.mjs +210 -0
- package/src/taxonomy.mjs +3 -0
- package/src/validate-memory.mjs +115 -0
package/hooks/vault-health.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { pathToFileURL } from 'url';
|
|
5
5
|
import {
|
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
wikilinkFromRel,
|
|
12
12
|
} from './obsidian-common.mjs';
|
|
13
13
|
import { getLocale } from './locale.mjs';
|
|
14
|
+
import { parseSharedMemory, validateMemoryEvent } from './memory-schema.mjs';
|
|
15
|
+
import { reduceMemoryEvents } from './memory-store.mjs';
|
|
16
|
+
import { validateMemoryBundle } from '../src/validate-memory.mjs';
|
|
14
17
|
|
|
15
18
|
const DEFAULT_PENDING_PATTERNS = [
|
|
16
19
|
/^- \[ \] Revisar resumo da sessão$/i,
|
|
@@ -93,6 +96,124 @@ function linkedNotesFromSession(content) {
|
|
|
93
96
|
return notes;
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
const MEMORY_STATUS_COMMAND = 'wendkeep memory status --gate --vault <vault>';
|
|
100
|
+
const MEMORY_REPAIR_COMMAND = 'wendkeep memory repair --vault <vault>';
|
|
101
|
+
|
|
102
|
+
function readJsonLines(path, label) {
|
|
103
|
+
if (!existsSync(path)) return { items: [], errors: [] };
|
|
104
|
+
const raw = readFileSync(path, 'utf8').replace(/\r\n/g, '\n');
|
|
105
|
+
const lines = raw.endsWith('\n') ? raw.split('\n').slice(0, -1) : raw.split('\n');
|
|
106
|
+
const items = [];
|
|
107
|
+
const errors = [];
|
|
108
|
+
lines.forEach((line, index) => {
|
|
109
|
+
if (!line.trim()) {
|
|
110
|
+
if (raw) errors.push(`${label} linha ${index + 1} está vazia.`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
try { items.push(JSON.parse(line)); }
|
|
114
|
+
catch (error) { errors.push(`${label} linha ${index + 1} contém JSON inválido/parcial: ${error.message}`); }
|
|
115
|
+
});
|
|
116
|
+
return { items, errors };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function inspectOutbox(vaultBase, projectId) {
|
|
120
|
+
const dir = join(vaultBase, '.brain', 'memory-outbox');
|
|
121
|
+
if (!existsSync(dir)) return { count: 0, errors: [] };
|
|
122
|
+
const files = readdirSync(dir).filter((name) => name.endsWith('.json')).sort();
|
|
123
|
+
const errors = [];
|
|
124
|
+
for (const name of files) {
|
|
125
|
+
const path = join(dir, name);
|
|
126
|
+
try {
|
|
127
|
+
const event = JSON.parse(readFileSync(path, 'utf8'));
|
|
128
|
+
const validation = validateMemoryEvent(event, projectId ? { projectId } : {});
|
|
129
|
+
if (!validation.ok) errors.push(`${name}: ${validation.errors.join(' ')}`);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
errors.push(`${name}: JSON inválido: ${error.message}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { count: files.length, errors };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Read-only consistency check for the local memory-v2 bundle. It intentionally
|
|
139
|
+
* does not acquire MEMORY.lock or invoke the projector/repair paths.
|
|
140
|
+
*/
|
|
141
|
+
export function checkMemoryBundle(vaultBase) {
|
|
142
|
+
const brain = join(vaultBase, '.brain');
|
|
143
|
+
const bundle = validateMemoryBundle(vaultBase);
|
|
144
|
+
const failures = [];
|
|
145
|
+
const warnings = [];
|
|
146
|
+
const parsedShared = typeof bundle.shared?.content === 'string'
|
|
147
|
+
? parseSharedMemory(bundle.shared.content)
|
|
148
|
+
: { metadata: {} };
|
|
149
|
+
const metadata = parsedShared.metadata || {};
|
|
150
|
+
const outbox = inspectOutbox(vaultBase, bundle.project?.projectId);
|
|
151
|
+
const candidates = readJsonLines(join(brain, 'MEMORY_CANDIDATES.jsonl'), 'MEMORY_CANDIDATES.jsonl');
|
|
152
|
+
|
|
153
|
+
const ledgerCorrupt = (bundle.ledger?.errors || []).length > 0;
|
|
154
|
+
if (ledgerCorrupt) {
|
|
155
|
+
for (const error of bundle.ledger.errors) {
|
|
156
|
+
failures.push(`${error} Execute com segurança: ${MEMORY_REPAIR_COMMAND}.`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const error of bundle.errors || []) {
|
|
160
|
+
if (error.startsWith('ledger:')) continue;
|
|
161
|
+
failures.push(`${error} Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (outbox.errors.length) {
|
|
165
|
+
failures.push(`Outbox corrompida (${outbox.errors.join('; ')}). Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
|
|
166
|
+
}
|
|
167
|
+
if (candidates.errors.length) {
|
|
168
|
+
failures.push(`${candidates.errors.join('; ')} Execute com segurança: ${MEMORY_REPAIR_COMMAND}.`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let replay = null;
|
|
172
|
+
if (bundle.ledger?.ok) {
|
|
173
|
+
try { replay = reduceMemoryEvents(bundle.ledger.events); }
|
|
174
|
+
catch (error) {
|
|
175
|
+
failures.push(`Ledger não pode ser reduzido: ${error.message}. Execute com segurança: ${MEMORY_REPAIR_COMMAND}.`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (replay && bundle.shared?.ok) {
|
|
179
|
+
const divergences = [];
|
|
180
|
+
if (metadata.revision !== replay.revision) divergences.push(`revision ${metadata.revision} != ${replay.revision}`);
|
|
181
|
+
if (metadata.event_cursor !== replay.eventCursor) divergences.push(`event_cursor ${metadata.event_cursor} != ${replay.eventCursor}`);
|
|
182
|
+
if (metadata.state_hash !== replay.stateHash) divergences.push(`state_hash ${metadata.state_hash} != ${replay.stateHash}`);
|
|
183
|
+
if (divergences.length) {
|
|
184
|
+
failures.push(`Projeção SHARED stale/lag (${divergences.join('; ')}). Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const unresolved = candidates.items.filter((item) => !['resolved', 'rejected', 'superseded'].includes(item?.status));
|
|
189
|
+
const activeConflicts = unresolved.filter((item) => item?.reason === 'conflict');
|
|
190
|
+
const ordinaryCandidates = unresolved.filter((item) => item?.reason !== 'conflict');
|
|
191
|
+
if (activeConflicts.length) {
|
|
192
|
+
failures.push(`${activeConflicts.length} conflito ativo em chave operacional (${activeConflicts.map((item) => item.memory_key || item.candidate_id).join(', ')}). Inspecione com: ${MEMORY_STATUS_COMMAND}.`);
|
|
193
|
+
}
|
|
194
|
+
if (outbox.count) warnings.push(`${outbox.count} evento(s) pendente(s) na outbox; execute o projector quando seguro.`);
|
|
195
|
+
if (ordinaryCandidates.length) warnings.push(`${ordinaryCandidates.length} candidate(s) aguardando curadoria humana.`);
|
|
196
|
+
for (const warning of bundle.warnings || []) warnings.push(warning);
|
|
197
|
+
|
|
198
|
+
const ok = failures.length === 0;
|
|
199
|
+
return {
|
|
200
|
+
ok,
|
|
201
|
+
status: ok ? (warnings.length ? 'warning' : 'healthy') : 'blocked',
|
|
202
|
+
failures,
|
|
203
|
+
warnings,
|
|
204
|
+
metrics: {
|
|
205
|
+
schemaVersion: metadata.schema_version ?? null,
|
|
206
|
+
revision: metadata.revision ?? null,
|
|
207
|
+
eventCursor: metadata.event_cursor ?? null,
|
|
208
|
+
stateHash: metadata.state_hash ?? null,
|
|
209
|
+
ledgerEvents: bundle.ledger?.events?.length || 0,
|
|
210
|
+
pendingOutbox: outbox.count,
|
|
211
|
+
candidates: candidates.items.length,
|
|
212
|
+
activeConflicts: activeConflicts.length,
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
96
217
|
function checkSession({ vaultBase, sessionRel, control, registry }) {
|
|
97
218
|
const failures = [];
|
|
98
219
|
const warnings = [];
|
|
@@ -181,6 +302,21 @@ export function runVaultHealth({ vaultBase, session = '' }) {
|
|
|
181
302
|
return total + (existsSync(dir) ? listMarkdownFiles(dir).length : 0);
|
|
182
303
|
}, 0);
|
|
183
304
|
|
|
305
|
+
const memoryMarkers = [
|
|
306
|
+
join(vaultBase, '.brain', 'SHARED_MEMORY.md'),
|
|
307
|
+
join(vaultBase, '.brain', 'MEMORY_EVENTS.jsonl'),
|
|
308
|
+
join(vaultBase, '.brain', 'MEMORY_CANDIDATES.jsonl'),
|
|
309
|
+
join(vaultBase, '.brain', 'memory-outbox'),
|
|
310
|
+
];
|
|
311
|
+
let memory = { status: 'legacy', metrics: {} };
|
|
312
|
+
if (memoryMarkers.some((path) => existsSync(path))) {
|
|
313
|
+
memory = checkMemoryBundle(vaultBase);
|
|
314
|
+
failures.push(...memory.failures.map((item) => `Memória: ${item}`));
|
|
315
|
+
warnings.push(...memory.warnings.map((item) => `Memória: ${item}`));
|
|
316
|
+
} else {
|
|
317
|
+
warnings.push(`Bundle de memória v2 ausente (vault legado); inspecione com: ${MEMORY_STATUS_COMMAND}.`);
|
|
318
|
+
}
|
|
319
|
+
|
|
184
320
|
return {
|
|
185
321
|
ok: failures.length === 0,
|
|
186
322
|
session: sessionRel,
|
|
@@ -190,7 +326,9 @@ export function runVaultHealth({ vaultBase, session = '' }) {
|
|
|
190
326
|
...sessionResult.metrics,
|
|
191
327
|
registrySessions: Object.keys(registry.sessions || {}).length,
|
|
192
328
|
derivedNotes: derivedCount,
|
|
329
|
+
memory: memory.metrics,
|
|
193
330
|
},
|
|
331
|
+
memoryStatus: memory.status,
|
|
194
332
|
};
|
|
195
333
|
}
|
|
196
334
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.58.0",
|
|
4
4
|
"description": "A persistent-memory harness for AI coding agents on your Obsidian vault: turn-by-turn session capture plus a native, zero-dependency spec→change→verify→archive loop (sensor-gated, independent verdict, mutation discrimination). Local-first, agent-agnostic (Claude Code, Codex, Cursor…).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -49,6 +49,6 @@
|
|
|
49
49
|
"url": "https://github.com/rogersialves/wendkeep/issues"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"wendkeep": "^0.57.
|
|
52
|
+
"wendkeep": "^0.57.2"
|
|
53
53
|
}
|
|
54
54
|
}
|
package/src/init.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import { LOCALES, DEFAULT_LOCALE, getLocale, clearLocaleCache, vaultFolders } fr
|
|
|
39
39
|
import { seedDotcontext, globalHasDotcontext, resolveDotcontextSkipMcp, renderSensorsJson } from './dotcontext-seed.mjs';
|
|
40
40
|
import { adoptSpecsState, ensureSpecsReadme, SPECS_STATE_FILE } from '../hooks/spec-core.mjs';
|
|
41
41
|
import { bindProjectVault, readProjectBinding } from './project-vault.mjs';
|
|
42
|
+
import { seedMemoryV2 } from './memory.mjs';
|
|
42
43
|
|
|
43
44
|
function parseArgs(argv) {
|
|
44
45
|
const args = { mcp: true, yes: false, force: false };
|
|
@@ -506,6 +507,7 @@ export async function runInit(argv) {
|
|
|
506
507
|
if (!existsSync(corePath)) writeFileSync(corePath, renderCoreSkeleton(loc.id), 'utf8');
|
|
507
508
|
const protoPath = join(brainDir, 'COMPACTION_PROTOCOL.md');
|
|
508
509
|
if (!existsSync(protoPath)) writeFileSync(protoPath, renderCompactionProtocol(), 'utf8');
|
|
510
|
+
seedMemoryV2(vaultPath);
|
|
509
511
|
// Seed the definitions layer (.brain/agents + .brain/skills): versioned source of
|
|
510
512
|
// truth for custom agents/skills. `wendkeep sync-defs` copies them to the agent dirs.
|
|
511
513
|
seedDefinitions(brainDir);
|
package/src/memory.mjs
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync,
|
|
4
|
+
} from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { sanitizeMemoryText, renderSharedMemory, validateSharedMemory } from '../hooks/memory-schema.mjs';
|
|
7
|
+
import {
|
|
8
|
+
enqueueMemoryEvent, projectMemoryOutbox, repairMemoryLedger,
|
|
9
|
+
} from '../hooks/memory-store.mjs';
|
|
10
|
+
import { writeFileAtomic } from '../hooks/session-note-io.mjs';
|
|
11
|
+
import { validateMemoryBundle } from './validate-memory.mjs';
|
|
12
|
+
import { checkMemoryBundle } from '../hooks/vault-health.mjs';
|
|
13
|
+
|
|
14
|
+
const BRAIN = '.brain';
|
|
15
|
+
const LEDGER = 'MEMORY_EVENTS.jsonl';
|
|
16
|
+
const SHARED = 'SHARED_MEMORY.md';
|
|
17
|
+
const CANDIDATES = 'MEMORY_CANDIDATES.jsonl';
|
|
18
|
+
|
|
19
|
+
function brainPath(vault, name) { return join(vault, BRAIN, name); }
|
|
20
|
+
function hash(value) { return createHash('sha256').update(String(value)).digest('hex'); }
|
|
21
|
+
|
|
22
|
+
function projectId(vault) {
|
|
23
|
+
const marker = JSON.parse(readFileSync(brainPath(vault, 'PROJECT.json'), 'utf8'));
|
|
24
|
+
if (!marker?.projectId) throw new Error('PROJECT.json inválido: projectId ausente.');
|
|
25
|
+
return marker.projectId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function legacyCandidates(content) {
|
|
29
|
+
const safe = sanitizeMemoryText(content).replace(/\r\n/g, '\n');
|
|
30
|
+
const candidates = [];
|
|
31
|
+
let section = 'Legacy';
|
|
32
|
+
for (const line of safe.split('\n')) {
|
|
33
|
+
const heading = line.match(/^#{1,3}\s+(.+)/);
|
|
34
|
+
if (heading) { section = heading[1].trim(); continue; }
|
|
35
|
+
const value = line.replace(/^[-*]\s+/, '').trim();
|
|
36
|
+
if (!value) continue;
|
|
37
|
+
const candidateId = `legacy-${hash(`${section}\0${value}`).slice(0, 16)}`;
|
|
38
|
+
candidates.push({
|
|
39
|
+
v: 1,
|
|
40
|
+
candidate_id: candidateId,
|
|
41
|
+
reason: 'legacy_shared',
|
|
42
|
+
memory_key: `legacy.${hash(section).slice(0, 8)}.${candidates.length + 1}`,
|
|
43
|
+
value,
|
|
44
|
+
section: sanitizeMemoryText(section),
|
|
45
|
+
source: 'SHARED_MEMORY.md',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return candidates;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function candidateText(candidates) {
|
|
52
|
+
return candidates.map((item) => `${JSON.stringify(item)}\n`).join('');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function seedMemoryV2(vault) {
|
|
56
|
+
const brain = join(vault, BRAIN);
|
|
57
|
+
mkdirSync(brain, { recursive: true });
|
|
58
|
+
const created = [];
|
|
59
|
+
const artifacts = [
|
|
60
|
+
[LEDGER, ''],
|
|
61
|
+
[CANDIDATES, ''],
|
|
62
|
+
[SHARED, renderSharedMemory()],
|
|
63
|
+
];
|
|
64
|
+
for (const [name, content] of artifacts) {
|
|
65
|
+
const path = join(brain, name);
|
|
66
|
+
if (!existsSync(path)) {
|
|
67
|
+
writeFileSync(path, content, 'utf8');
|
|
68
|
+
created.push(name);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { status: created.length ? 'seeded' : 'unchanged', created };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function memoryStatus(vault) {
|
|
75
|
+
return checkMemoryBundle(vault);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function migrateMemory(vault, { apply = false, validateBundle = validateMemoryBundle } = {}) {
|
|
79
|
+
projectId(vault);
|
|
80
|
+
const sharedPath = brainPath(vault, SHARED);
|
|
81
|
+
const hadShared = existsSync(sharedPath);
|
|
82
|
+
const legacy = hadShared ? readFileSync(sharedPath, 'utf8') : '';
|
|
83
|
+
const parsed = validateSharedMemory(legacy);
|
|
84
|
+
const alreadyV2 = parsed.ok && parsed.metadata.schema_version === 2;
|
|
85
|
+
const candidates = alreadyV2 ? [] : legacyCandidates(legacy);
|
|
86
|
+
const backupPath = hadShared ? `${sharedPath}.legacy-${hash(legacy).slice(0, 12)}.bak` : null;
|
|
87
|
+
if (!apply) return { status: 'dry-run', alreadyV2, candidates: candidates.length, backupPath };
|
|
88
|
+
if (alreadyV2) {
|
|
89
|
+
seedMemoryV2(vault);
|
|
90
|
+
return { status: 'unchanged', alreadyV2: true, candidates: 0, backupPath: null };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Validate every generated byte before the first publication. CORE is never written.
|
|
94
|
+
const emptyShared = renderSharedMemory();
|
|
95
|
+
const sharedValidation = validateSharedMemory(emptyShared, { eventIds: new Set() });
|
|
96
|
+
if (!sharedValidation.ok) throw new Error(`Migração inválida: ${sharedValidation.errors.join(' ')}`);
|
|
97
|
+
if (backupPath && !existsSync(backupPath)) copyFileSync(sharedPath, backupPath);
|
|
98
|
+
const targets = [LEDGER, SHARED, CANDIDATES].map((name) => brainPath(vault, name));
|
|
99
|
+
const before = new Map(targets.map((path) => [path, {
|
|
100
|
+
existed: existsSync(path),
|
|
101
|
+
content: existsSync(path) ? readFileSync(path, 'utf8') : '',
|
|
102
|
+
}]));
|
|
103
|
+
try {
|
|
104
|
+
if (!existsSync(brainPath(vault, LEDGER))) writeFileAtomic(brainPath(vault, LEDGER), '');
|
|
105
|
+
writeFileAtomic(sharedPath, emptyShared);
|
|
106
|
+
writeFileAtomic(brainPath(vault, CANDIDATES), candidateText(candidates));
|
|
107
|
+
const validation = validateBundle(vault);
|
|
108
|
+
if (!validation.ok) throw new Error(`Bundle migrado inválido: ${validation.errors.join(' ')}`);
|
|
109
|
+
return { status: 'migrated', alreadyV2: false, candidates: candidates.length, backupPath, validation };
|
|
110
|
+
} catch (error) {
|
|
111
|
+
for (const [path, snapshot] of before) {
|
|
112
|
+
if (snapshot.existed) writeFileAtomic(path, snapshot.content);
|
|
113
|
+
else if (existsSync(path)) unlinkSync(path);
|
|
114
|
+
}
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readCandidates(vault) {
|
|
120
|
+
const path = brainPath(vault, CANDIDATES);
|
|
121
|
+
if (!existsSync(path)) return [];
|
|
122
|
+
return readFileSync(path, 'utf8').split('\n').filter(Boolean).map((line) => JSON.parse(line));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function decideMemoryCandidate(vault, { action, candidateId, value } = {}) {
|
|
126
|
+
if (!['promote', 'reject'].includes(action)) throw new TypeError('action deve ser promote ou reject.');
|
|
127
|
+
if (!candidateId) throw new TypeError('candidateId é obrigatório.');
|
|
128
|
+
const candidates = readCandidates(vault);
|
|
129
|
+
const candidate = candidates.find((item) => item.candidate_id === candidateId);
|
|
130
|
+
if (!candidate) throw new Error(`Candidate não encontrado: ${candidateId}`);
|
|
131
|
+
const now = new Date().toISOString();
|
|
132
|
+
const event = {
|
|
133
|
+
v: 1,
|
|
134
|
+
event_id: `cli-${action}-${hash(candidateId).slice(0, 20)}`,
|
|
135
|
+
project_id: projectId(vault),
|
|
136
|
+
memory_key: action === 'promote' ? candidate.memory_key : `candidate.rejected.${candidateId}`,
|
|
137
|
+
operation: 'assert',
|
|
138
|
+
value: sanitizeMemoryText(action === 'promote' ? (value ?? candidate.value ?? candidate.values?.[0] ?? '') : 'rejected'),
|
|
139
|
+
authority: 'verified',
|
|
140
|
+
activation_id: 'wendkeep-memory-cli',
|
|
141
|
+
turn_sequence: 0,
|
|
142
|
+
observed_at: now,
|
|
143
|
+
evidence: [`candidate:${candidateId}`],
|
|
144
|
+
};
|
|
145
|
+
enqueueMemoryEvent(vault, event);
|
|
146
|
+
const projection = projectMemoryOutbox(vault);
|
|
147
|
+
if (projection.status === 'busy') return { status: 'busy', candidateId };
|
|
148
|
+
const remaining = candidates.filter((item) => item.candidate_id !== candidateId);
|
|
149
|
+
const projected = readCandidates(vault);
|
|
150
|
+
const merged = new Map([...remaining, ...projected].map((item) => [item.candidate_id, item]));
|
|
151
|
+
writeFileAtomic(brainPath(vault, CANDIDATES), candidateText([...merged.values()]));
|
|
152
|
+
return { status: action === 'promote' ? 'promoted' : 'rejected', candidateId, eventId: event.event_id, projection };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function repairMemory(vault) {
|
|
156
|
+
const repaired = repairMemoryLedger(vault);
|
|
157
|
+
if (repaired.status === 'busy') return repaired;
|
|
158
|
+
const projection = projectMemoryOutbox(vault);
|
|
159
|
+
return { status: projection.status === 'busy' ? 'busy' : 'repaired', repaired, projection };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function runValidateMemoryBundle(argv) {
|
|
163
|
+
const vault = option(argv, '--vault');
|
|
164
|
+
if (!vault) {
|
|
165
|
+
process.stderr.write('wendkeep validate-memory: --vault requer um path.\n');
|
|
166
|
+
process.exitCode = 2;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (!existsSync(vault)) {
|
|
170
|
+
process.stderr.write(`wendkeep validate-memory: not found: ${vault}\n`);
|
|
171
|
+
process.exitCode = 2;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const result = validateMemoryBundle(vault);
|
|
175
|
+
if (!result.ok) {
|
|
176
|
+
process.stderr.write(`❌ bundle de memória inválido (${result.errors.length} erro(s)):\n`);
|
|
177
|
+
for (const error of result.errors) process.stderr.write(` - ${error}\n`);
|
|
178
|
+
process.exitCode = 1;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
process.stdout.write('✅ bundle de memória v2 OK (CORE + ledger + SHARED).\n');
|
|
182
|
+
process.exitCode = 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function option(argv, name) {
|
|
186
|
+
const index = argv.indexOf(name);
|
|
187
|
+
if (index >= 0) return argv[index + 1] || '';
|
|
188
|
+
return argv.find((item) => item.startsWith(`${name}=`))?.slice(name.length + 1) || '';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function runMemory(argv) {
|
|
192
|
+
const [sub, positional] = argv;
|
|
193
|
+
const vault = option(argv, '--vault') || process.env.OBSIDIAN_VAULT_PATH;
|
|
194
|
+
if (!vault) { process.stderr.write('wendkeep memory: passe --vault <path>.\n'); process.exitCode = 2; return; }
|
|
195
|
+
try {
|
|
196
|
+
let result;
|
|
197
|
+
if (sub === 'status') result = memoryStatus(vault);
|
|
198
|
+
else if (sub === 'migrate') result = migrateMemory(vault, { apply: argv.includes('--apply') });
|
|
199
|
+
else if (sub === 'repair') result = repairMemory(vault);
|
|
200
|
+
else if (sub === 'promote' || sub === 'reject') result = decideMemoryCandidate(vault, { action: sub, candidateId: positional });
|
|
201
|
+
else { process.stderr.write('wendkeep memory: use status | migrate [--apply] | repair | promote <candidate> | reject <candidate>.\n'); process.exitCode = 2; return; }
|
|
202
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
203
|
+
process.exitCode = sub === 'status' && argv.includes('--gate')
|
|
204
|
+
? (result.status === 'blocked' ? 1 : 0)
|
|
205
|
+
: (result.ok === false ? 1 : 0);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
process.stderr.write(`wendkeep memory: ${error.message}\n`);
|
|
208
|
+
process.exitCode = 1;
|
|
209
|
+
}
|
|
210
|
+
}
|
package/src/taxonomy.mjs
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { validateMemoryEvent, validateSharedMemory } from '../hooks/memory-schema.mjs';
|
|
4
|
+
import { validateCore } from './validate-core.mjs';
|
|
5
|
+
|
|
6
|
+
function failedComponent(errors, extra = {}) {
|
|
7
|
+
return { ok: false, errors: Array.isArray(errors) ? errors : [errors], warnings: [], ...extra };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function readRequired(path, label) {
|
|
11
|
+
if (!existsSync(path)) return { ok: false, error: `${label} ausente: ${path}` };
|
|
12
|
+
try {
|
|
13
|
+
return { ok: true, content: readFileSync(path, 'utf8') };
|
|
14
|
+
} catch (error) {
|
|
15
|
+
return { ok: false, error: `${label} ilegível: ${error?.message || error}` };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function readProjectForValidation(vaultBase) {
|
|
20
|
+
const path = join(vaultBase, '.brain', 'PROJECT.json');
|
|
21
|
+
const read = readRequired(path, 'PROJECT.json');
|
|
22
|
+
if (!read.ok) return failedComponent(read.error, { projectId: '', path });
|
|
23
|
+
try {
|
|
24
|
+
const marker = JSON.parse(read.content);
|
|
25
|
+
if (!marker || typeof marker.projectId !== 'string' || !marker.projectId) {
|
|
26
|
+
return failedComponent('PROJECT.json inválido: projectId ausente.', { projectId: '', path });
|
|
27
|
+
}
|
|
28
|
+
return { ok: true, errors: [], warnings: [], projectId: marker.projectId, marker, path };
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return failedComponent(`PROJECT.json contém JSON inválido: ${error?.message || error}`, { projectId: '', path });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Read and validate the append-only JSONL authority without repairing or mutating it. */
|
|
35
|
+
export function readLedgerForValidation(vaultBase, { projectId } = {}) {
|
|
36
|
+
const path = join(vaultBase, '.brain', 'MEMORY_EVENTS.jsonl');
|
|
37
|
+
const read = readRequired(path, 'MEMORY_EVENTS.jsonl');
|
|
38
|
+
if (!read.ok) return failedComponent(read.error, { events: [], eventIds: new Set(), path });
|
|
39
|
+
|
|
40
|
+
const errors = [];
|
|
41
|
+
const warnings = [];
|
|
42
|
+
const events = [];
|
|
43
|
+
const eventIds = new Set();
|
|
44
|
+
const normalized = read.content.replace(/\r\n/g, '\n');
|
|
45
|
+
const lines = normalized.split('\n');
|
|
46
|
+
const logicalLines = normalized === '' ? [] : (normalized.endsWith('\n') ? lines.slice(0, -1) : lines);
|
|
47
|
+
logicalLines.forEach((line, index) => {
|
|
48
|
+
if (!line.trim()) {
|
|
49
|
+
errors.push(`MEMORY_EVENTS.jsonl linha ${index + 1} está vazia no meio do ledger.`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
let event;
|
|
53
|
+
try {
|
|
54
|
+
event = JSON.parse(line);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
errors.push(`MEMORY_EVENTS.jsonl linha ${index + 1} contém JSON inválido/parcial: ${error?.message || error}`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const validation = validateMemoryEvent(event, projectId ? { projectId } : {});
|
|
60
|
+
for (const error of validation.errors) errors.push(`MEMORY_EVENTS.jsonl linha ${index + 1}: ${error}`);
|
|
61
|
+
for (const warning of validation.warnings) warnings.push(`MEMORY_EVENTS.jsonl linha ${index + 1}: ${warning}`);
|
|
62
|
+
if (typeof event?.event_id === 'string' && event.event_id) {
|
|
63
|
+
if (eventIds.has(event.event_id)) errors.push(`MEMORY_EVENTS.jsonl event_id duplicado: ${event.event_id}.`);
|
|
64
|
+
eventIds.add(event.event_id);
|
|
65
|
+
}
|
|
66
|
+
events.push(event);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
ok: errors.length === 0,
|
|
71
|
+
errors,
|
|
72
|
+
warnings,
|
|
73
|
+
events,
|
|
74
|
+
eventIds,
|
|
75
|
+
lineCount: logicalLines.length,
|
|
76
|
+
path,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function validateCoreArtifact(vaultBase) {
|
|
81
|
+
const path = join(vaultBase, '.brain', 'CORE.md');
|
|
82
|
+
const read = readRequired(path, 'CORE.md');
|
|
83
|
+
if (!read.ok) return failedComponent(read.error, { lineCount: 0, path });
|
|
84
|
+
return { ...validateCore(read.content), path, content: read.content };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validateSharedArtifact(vaultBase, eventIds) {
|
|
88
|
+
const path = join(vaultBase, '.brain', 'SHARED_MEMORY.md');
|
|
89
|
+
const read = readRequired(path, 'SHARED_MEMORY.md');
|
|
90
|
+
if (!read.ok) return failedComponent(read.error, { path });
|
|
91
|
+
return { ...validateSharedMemory(read.content, { eventIds }), path, content: read.content };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function combineMemoryResults({ project, core, ledger, shared }) {
|
|
95
|
+
const components = { project, core, ledger, shared };
|
|
96
|
+
const errors = [];
|
|
97
|
+
const warnings = [];
|
|
98
|
+
for (const [name, result] of Object.entries(components)) {
|
|
99
|
+
for (const error of result?.errors || []) errors.push(`${name}: ${error}`);
|
|
100
|
+
for (const warning of result?.warnings || []) warnings.push(`${name}: ${warning}`);
|
|
101
|
+
}
|
|
102
|
+
return { ok: errors.length === 0, errors, warnings, ...components };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Validate the v2 local memory bundle as a read-only composition. Missing/corrupt
|
|
107
|
+
* artifacts remain explicit failures; they are never silently treated as empty.
|
|
108
|
+
*/
|
|
109
|
+
export function validateMemoryBundle(vaultBase) {
|
|
110
|
+
const project = readProjectForValidation(vaultBase);
|
|
111
|
+
const core = validateCoreArtifact(vaultBase);
|
|
112
|
+
const ledger = readLedgerForValidation(vaultBase, { projectId: project.projectId });
|
|
113
|
+
const shared = validateSharedArtifact(vaultBase, ledger.eventIds);
|
|
114
|
+
return combineMemoryResults({ project, core, ledger, shared });
|
|
115
|
+
}
|