wendkeep 0.73.0 → 0.74.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 +31 -0
- package/README.en.md +12 -8
- package/README.md +12 -8
- package/docs/en/commands/memory.md +16 -1
- package/docs/en/commands/observer.md +8 -1
- package/docs/en/commands/operating-profiles.md +1 -1
- package/docs/pt-BR/commands/memory.md +16 -1
- package/docs/pt-BR/commands/observer.md +7 -1
- package/docs/pt-BR/commands/operating-profiles.md +1 -1
- package/hooks/brain-core.mjs +2 -0
- package/hooks/brain-recall.mjs +5 -1
- package/hooks/evidence-context.mjs +41 -0
- package/hooks/evidence-recall.mjs +1 -0
- package/hooks/memory-scope.mjs +1 -0
- package/package.json +2 -2
- package/packages/cli/src/index.mjs +1 -1
- package/packages/integrations/src/host-hooks.mjs +1 -0
- package/packages/vault/src/evidence-recall.mjs +343 -0
- package/packages/vault/src/index.mjs +2 -0
- package/packages/vault/src/memory-handoff.mjs +58 -3
- package/packages/vault/src/memory-schema.mjs +12 -2
- package/packages/vault/src/memory-scope.mjs +119 -0
- package/packages/vault/src/memory-store.mjs +86 -24
- package/schema/observer/004-evidence-recall.sql +25 -0
- package/src/memory.mjs +95 -2
- package/src/observer-sql-store.mjs +141 -5
- package/src/taxonomy.mjs +4 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { basename, join, relative } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const EVIDENCE_INDEX_FILE = 'EVIDENCE_INDEX.jsonl';
|
|
6
|
+
export const EVIDENCE_INDEX_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
const STOP_WORDS = new Set([
|
|
9
|
+
'a', 'an', 'and', 'as', 'at', 'da', 'das', 'de', 'do', 'dos', 'e', 'em', 'for', 'in',
|
|
10
|
+
'is', 'o', 'os', 'or', 'para', 'por', 'the', 'to', 'um', 'uma', 'with', 'com', 'que',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
function hash(value) {
|
|
14
|
+
return createHash('sha256').update(String(value ?? '')).digest('hex');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function cleanText(value) {
|
|
18
|
+
return String(value ?? '').replace(/\r\n/g, '\n').replace(/[\t ]+/g, ' ').trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function normalizeRecallText(value) {
|
|
22
|
+
return cleanText(value).normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function recallTerms(value) {
|
|
26
|
+
return normalizeRecallText(value).match(/[\p{L}\p{N}]+(?:[._-][\p{L}\p{N}]+)*/gu)
|
|
27
|
+
?.filter((term) => term.length > 1 && !STOP_WORDS.has(term)) || [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseFrontmatter(content) {
|
|
31
|
+
const match = String(content || '').match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
|
|
32
|
+
if (!match) return { data: {}, body: String(content || '') };
|
|
33
|
+
const data = {};
|
|
34
|
+
for (const line of match[1].split('\n')) {
|
|
35
|
+
const item = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/);
|
|
36
|
+
if (!item) continue;
|
|
37
|
+
data[item[1]] = item[2].replace(/^['"]|['"]$/g, '');
|
|
38
|
+
}
|
|
39
|
+
return { data, body: String(content || '').slice(match[0].length) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function inferredChangeSlug(logicalPath, metadata) {
|
|
43
|
+
const explicit = metadata.change_slug || metadata.change || '';
|
|
44
|
+
if (explicit) return String(explicit);
|
|
45
|
+
const segments = String(logicalPath || '').replaceAll('\\', '/').split('/');
|
|
46
|
+
const at = segments.findIndex((segment) => /^(?:08-Mudan[cç]as|08-Changes)$/i.test(segment));
|
|
47
|
+
return at >= 0 ? String(segments[at + 1] || '').replace(/^\d{4}-\d{2}-\d{2}-/, '') : '';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function entityType(logicalPath, heading, block, fallback = 'document') {
|
|
51
|
+
const signal = normalizeRecallText(`${logicalPath} ${heading}`);
|
|
52
|
+
const headingSignal = normalizeRecallText(heading);
|
|
53
|
+
if (/^\s*[-*]\s+\[[ xX]\]/m.test(block) || /\b(tasks?|tarefas?)\b/.test(headingSignal)) return 'task';
|
|
54
|
+
if (/\b(decisions?|decisoes?|adr)\b/.test(signal) || /(^|\/)04-/.test(logicalPath)) return 'decision';
|
|
55
|
+
if (/\b(requirements?|requisitos?|specs?|contratos?)\b/.test(signal) || /(^|\/)07-/.test(logicalPath)) return 'requirement';
|
|
56
|
+
if (/\b(evidence|evidencia|verdict|teste|test)\b/.test(signal)) return 'evidence';
|
|
57
|
+
if (/\b(session|sessao)\b/.test(signal) || /(^|\/)02-/.test(logicalPath)) return 'session';
|
|
58
|
+
return String(fallback || 'document');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function authorityFor(logicalPath, metadata, kind) {
|
|
62
|
+
if (['verified', 'reported', 'candidate'].includes(metadata.authority)) return metadata.authority;
|
|
63
|
+
if (kind === 'decision' || kind === 'requirement' || kind === 'evidence'
|
|
64
|
+
|| /(^|\/)(?:04-|07-)/.test(logicalPath)) return 'verified';
|
|
65
|
+
return kind === 'session' ? 'reported' : 'candidate';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validityFor(metadata, block) {
|
|
69
|
+
const explicit = normalizeRecallText(metadata.validity || metadata.status || '');
|
|
70
|
+
if (/superseded|superado|deprecated|obsoleto|rejected|abandon/.test(explicit)) return 'superseded';
|
|
71
|
+
if (/closed|done|archived|active|ativo|accepted|complete/.test(explicit)) return 'active';
|
|
72
|
+
if (/\b(?:superseded|superado|obsoleto)\b/i.test(block)) return 'superseded';
|
|
73
|
+
return 'active';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function observedAt(metadata) {
|
|
77
|
+
const raw = metadata.observed_at || metadata.updated_at || metadata.ended_at
|
|
78
|
+
|| metadata.date || metadata.created_at || '';
|
|
79
|
+
if (!raw) return new Date(0).toISOString();
|
|
80
|
+
const parsed = Date.parse(raw);
|
|
81
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : new Date(0).toISOString();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function splitLongBlock(block, maxChars = 1200) {
|
|
85
|
+
if (block.length <= maxChars) return [block];
|
|
86
|
+
const out = [];
|
|
87
|
+
let rest = block;
|
|
88
|
+
while (rest.length > maxChars) {
|
|
89
|
+
let cut = rest.lastIndexOf(' ', maxChars);
|
|
90
|
+
if (cut < Math.floor(maxChars * 0.6)) cut = maxChars;
|
|
91
|
+
out.push(rest.slice(0, cut).trim());
|
|
92
|
+
rest = rest.slice(cut).trim();
|
|
93
|
+
}
|
|
94
|
+
if (rest) out.push(rest);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function indexableBlockParts(block) {
|
|
99
|
+
const maxIndexedChars = 4 * 1024 * 1024;
|
|
100
|
+
if (block.length <= maxIndexedChars) return splitLongBlock(block);
|
|
101
|
+
const samples = 256;
|
|
102
|
+
const sampleChars = Math.floor(maxIndexedChars / samples);
|
|
103
|
+
const stride = block.length / samples;
|
|
104
|
+
return Array.from({ length: samples }, (_, index) => {
|
|
105
|
+
const start = Math.min(block.length - sampleChars, Math.floor(index * stride));
|
|
106
|
+
return cleanText(block.slice(Math.max(0, start), Math.max(0, start) + sampleChars));
|
|
107
|
+
}).filter(Boolean);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function chunkMarkdownDocument({
|
|
111
|
+
projectId = '', logicalPath = '', content = '', metadata = {}, entityType: fallbackType = 'document',
|
|
112
|
+
} = {}) {
|
|
113
|
+
const parsed = parseFrontmatter(content);
|
|
114
|
+
const meta = { ...parsed.data, ...(metadata || {}) };
|
|
115
|
+
const lines = parsed.body.replace(/\r\n/g, '\n').split('\n');
|
|
116
|
+
const title = cleanText(meta.title || lines.find((line) => /^#\s+/.test(line))?.replace(/^#\s+/, '')
|
|
117
|
+
|| basename(logicalPath).replace(/\.md$/i, ''));
|
|
118
|
+
let heading = title;
|
|
119
|
+
let buffer = [];
|
|
120
|
+
const blocks = [];
|
|
121
|
+
let inFence = false;
|
|
122
|
+
|
|
123
|
+
const flush = () => {
|
|
124
|
+
const block = cleanText(buffer.join('\n'));
|
|
125
|
+
if (block) indexableBlockParts(block).forEach((part) => blocks.push({ heading, content: part }));
|
|
126
|
+
buffer = [];
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
for (const line of lines) {
|
|
130
|
+
if (/^```/.test(line.trim())) inFence = !inFence;
|
|
131
|
+
const headingMatch = !inFence && line.match(/^#{1,6}\s+(.+?)\s*$/);
|
|
132
|
+
if (headingMatch) {
|
|
133
|
+
flush();
|
|
134
|
+
heading = cleanText(headingMatch[1]);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!inFence && !line.trim()) flush();
|
|
138
|
+
else buffer.push(line);
|
|
139
|
+
}
|
|
140
|
+
flush();
|
|
141
|
+
|
|
142
|
+
const common = {
|
|
143
|
+
index_version: EVIDENCE_INDEX_VERSION,
|
|
144
|
+
project_id: String(projectId || ''),
|
|
145
|
+
logical_path: String(logicalPath || '').replaceAll('\\', '/'),
|
|
146
|
+
title,
|
|
147
|
+
change_slug: inferredChangeSlug(logicalPath, meta),
|
|
148
|
+
session_id: String(meta.session_id || ''),
|
|
149
|
+
work_session_id: String(meta.work_session_id || ''),
|
|
150
|
+
observed_at: observedAt(meta),
|
|
151
|
+
};
|
|
152
|
+
return blocks.map((block, ordinal) => {
|
|
153
|
+
const kind = entityType(common.logical_path, block.heading, block.content, meta.entity_type || fallbackType);
|
|
154
|
+
return {
|
|
155
|
+
...common,
|
|
156
|
+
chunk_id: `chunk-${hash(`${projectId}\0${common.logical_path}\0${block.heading}\0${ordinal}\0${block.content}`).slice(0, 24)}`,
|
|
157
|
+
heading: block.heading,
|
|
158
|
+
entity_type: kind,
|
|
159
|
+
authority: authorityFor(common.logical_path, meta, kind),
|
|
160
|
+
validity: validityFor(meta, block.content),
|
|
161
|
+
ordinal,
|
|
162
|
+
content: block.content,
|
|
163
|
+
content_hash: hash(block.content),
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function walkMarkdown(root, dir = root, found = []) {
|
|
169
|
+
let entries = [];
|
|
170
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return found; }
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
if (entry.name === '.brain' || entry.name === '.obsidian' || entry.name === 'node_modules') continue;
|
|
173
|
+
const path = join(dir, entry.name);
|
|
174
|
+
if (entry.isDirectory()) walkMarkdown(root, path, found);
|
|
175
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) found.push(path);
|
|
176
|
+
}
|
|
177
|
+
return found;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function projectIdForVault(vaultBase) {
|
|
181
|
+
try {
|
|
182
|
+
return String(JSON.parse(readFileSync(join(vaultBase, '.brain', 'PROJECT.json'), 'utf8')).projectId || '');
|
|
183
|
+
} catch {
|
|
184
|
+
return '';
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function buildEvidenceIndex(vaultBase) {
|
|
189
|
+
const projectId = projectIdForVault(vaultBase);
|
|
190
|
+
const chunks = [];
|
|
191
|
+
for (const path of walkMarkdown(vaultBase)) {
|
|
192
|
+
let content = '';
|
|
193
|
+
try { content = readFileSync(path, 'utf8'); } catch { continue; }
|
|
194
|
+
chunks.push(...chunkMarkdownDocument({
|
|
195
|
+
projectId,
|
|
196
|
+
logicalPath: relative(vaultBase, path).replaceAll('\\', '/'),
|
|
197
|
+
content,
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
chunks.sort((left, right) => left.logical_path.localeCompare(right.logical_path)
|
|
201
|
+
|| left.ordinal - right.ordinal || left.chunk_id.localeCompare(right.chunk_id));
|
|
202
|
+
const output = chunks.map((chunk) => JSON.stringify(chunk)).join('\n') + (chunks.length ? '\n' : '');
|
|
203
|
+
writeFileSync(join(vaultBase, '.brain', EVIDENCE_INDEX_FILE), output, 'utf8');
|
|
204
|
+
return chunks;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function loadEvidenceIndex(vaultBase) {
|
|
208
|
+
const path = join(vaultBase, '.brain', EVIDENCE_INDEX_FILE);
|
|
209
|
+
if (!existsSync(path)) return [];
|
|
210
|
+
try {
|
|
211
|
+
return readFileSync(path, 'utf8').split('\n').filter(Boolean).map((line) => JSON.parse(line));
|
|
212
|
+
} catch {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function occurrences(terms, text) {
|
|
218
|
+
const tokens = recallTerms(text);
|
|
219
|
+
const counts = new Map();
|
|
220
|
+
for (const token of tokens) counts.set(token, (counts.get(token) || 0) + 1);
|
|
221
|
+
return terms.reduce((sum, term) => sum + (counts.get(term) || 0), 0);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function excerptFor(content, query, terms, max = 360) {
|
|
225
|
+
const raw = cleanText(content);
|
|
226
|
+
const normalized = normalizeRecallText(raw);
|
|
227
|
+
const phrase = normalizeRecallText(query);
|
|
228
|
+
let at = phrase ? normalized.indexOf(phrase) : -1;
|
|
229
|
+
if (at < 0) at = terms.map((term) => normalized.indexOf(term)).filter((index) => index >= 0).sort((a, b) => a - b)[0] ?? 0;
|
|
230
|
+
const start = Math.max(0, at - Math.floor(max * 0.3));
|
|
231
|
+
const end = Math.min(raw.length, start + max);
|
|
232
|
+
return `${start > 0 ? '…' : ''}${raw.slice(start, end).trim()}${end < raw.length ? '…' : ''}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function recencyScore(observed, now) {
|
|
236
|
+
const instant = Date.parse(observed || '');
|
|
237
|
+
if (!Number.isFinite(instant)) return 0;
|
|
238
|
+
const days = Math.max(0, (now - instant) / 86_400_000);
|
|
239
|
+
return Math.max(0, 1.5 * (1 - Math.min(days, 365) / 365));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function recallEvidence(rows, query, { topK = 5, now = Date.now() } = {}) {
|
|
243
|
+
const terms = [...new Set(recallTerms(query))];
|
|
244
|
+
if (!terms.length || !Array.isArray(rows) || !rows.length) return [];
|
|
245
|
+
const docs = rows.map((row) => ({
|
|
246
|
+
row,
|
|
247
|
+
contentTerms: recallTerms(row.content),
|
|
248
|
+
allTerms: new Set(recallTerms(`${row.title} ${row.heading} ${row.logical_path} ${row.content}`)),
|
|
249
|
+
}));
|
|
250
|
+
const df = new Map(terms.map((term) => [term, docs.filter((doc) => doc.allTerms.has(term)).length]));
|
|
251
|
+
const averageLength = docs.reduce((sum, doc) => sum + doc.contentTerms.length, 0) / docs.length || 1;
|
|
252
|
+
const phrase = normalizeRecallText(query);
|
|
253
|
+
const scored = docs.map(({ row, contentTerms, allTerms }) => {
|
|
254
|
+
let score = 0;
|
|
255
|
+
for (const term of terms) {
|
|
256
|
+
const frequency = occurrences([term], row.content);
|
|
257
|
+
const idf = Math.log(1 + ((docs.length - (df.get(term) || 0) + 0.5) / ((df.get(term) || 0) + 0.5)));
|
|
258
|
+
if (frequency) score += idf * ((frequency * 2.2) / (frequency + 1.2 * (0.25 + 0.75 * contentTerms.length / averageLength)));
|
|
259
|
+
if (recallTerms(row.title).includes(term)) score += idf * 3;
|
|
260
|
+
if (recallTerms(row.heading).includes(term)) score += idf * 2.5;
|
|
261
|
+
if (recallTerms(row.logical_path).includes(term)) score += idf * 1.5;
|
|
262
|
+
}
|
|
263
|
+
if (phrase && normalizeRecallText(`${row.title} ${row.heading} ${row.content}`).includes(phrase)) score += 6;
|
|
264
|
+
if (row.authority === 'verified') score += 2;
|
|
265
|
+
else if (row.authority === 'reported') score += 1;
|
|
266
|
+
if (row.validity === 'superseded') score -= 8;
|
|
267
|
+
else if (row.validity === 'active') score += 1;
|
|
268
|
+
score += recencyScore(row.observed_at, now);
|
|
269
|
+
const matchedTerms = terms.filter((term) => allTerms.has(term));
|
|
270
|
+
return {
|
|
271
|
+
...row,
|
|
272
|
+
score: Number(score.toFixed(6)),
|
|
273
|
+
matched_terms: matchedTerms,
|
|
274
|
+
excerpt: excerptFor(row.content, query, matchedTerms),
|
|
275
|
+
};
|
|
276
|
+
}).filter((row) => row.matched_terms.length && row.score > 0)
|
|
277
|
+
.sort((left, right) => right.score - left.score
|
|
278
|
+
|| String(right.observed_at).localeCompare(String(left.observed_at))
|
|
279
|
+
|| left.logical_path.localeCompare(right.logical_path));
|
|
280
|
+
|
|
281
|
+
const selected = [];
|
|
282
|
+
const perSource = new Map();
|
|
283
|
+
for (const row of scored) {
|
|
284
|
+
const count = perSource.get(row.logical_path) || 0;
|
|
285
|
+
if (count >= 1 && scored.some((candidate) => !perSource.has(candidate.logical_path))) continue;
|
|
286
|
+
selected.push(row);
|
|
287
|
+
perSource.set(row.logical_path, count + 1);
|
|
288
|
+
if (selected.length >= topK) break;
|
|
289
|
+
}
|
|
290
|
+
if (selected.length < topK) {
|
|
291
|
+
for (const row of scored) {
|
|
292
|
+
if (selected.some((item) => item.chunk_id === row.chunk_id)) continue;
|
|
293
|
+
selected.push(row);
|
|
294
|
+
if (selected.length >= topK) break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return selected;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function renderEvidenceContext(results, { maxBytes = 3072 } = {}) {
|
|
301
|
+
const lines = ['<wk_evidence_recall>'];
|
|
302
|
+
for (const [index, item] of results.entries()) {
|
|
303
|
+
const entry = [
|
|
304
|
+
`${index + 1}. ${item.title || item.logical_path} — ${item.heading || '(sem heading)'}`,
|
|
305
|
+
` ${item.excerpt}`,
|
|
306
|
+
` source:${item.logical_path} authority:${item.authority} validity:${item.validity} as_of:${item.observed_at}`,
|
|
307
|
+
];
|
|
308
|
+
const candidate = [...lines, ...entry, '</wk_evidence_recall>'].join('\n');
|
|
309
|
+
if (Buffer.byteLength(candidate, 'utf8') > maxBytes) break;
|
|
310
|
+
lines.push(...entry);
|
|
311
|
+
}
|
|
312
|
+
lines.push('</wk_evidence_recall>');
|
|
313
|
+
return lines.length === 2 ? '' : lines.join('\n');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function benchmarkEvidenceRecall(rows, cases, { topK = 5, now = Date.now() } = {}) {
|
|
317
|
+
let reciprocal = 0;
|
|
318
|
+
let recalled = 0;
|
|
319
|
+
let stale = 0;
|
|
320
|
+
let evidenceCorrect = 0;
|
|
321
|
+
let handoffs = 0;
|
|
322
|
+
let handoffsFound = 0;
|
|
323
|
+
for (const item of cases) {
|
|
324
|
+
const results = recallEvidence(rows, item.query, { topK, now });
|
|
325
|
+
const rank = results.findIndex((row) => row.chunk_id === item.expected_chunk_id
|
|
326
|
+
|| row.logical_path === item.expected_path);
|
|
327
|
+
if (rank >= 0) { recalled += 1; reciprocal += 1 / (rank + 1); }
|
|
328
|
+
if (results[0]?.validity === 'superseded') stale += 1;
|
|
329
|
+
if (results.every((row) => row.logical_path && row.heading && row.authority && row.observed_at)) evidenceCorrect += 1;
|
|
330
|
+
if (item.handoff) {
|
|
331
|
+
handoffs += 1;
|
|
332
|
+
if (rank >= 0) handoffsFound += 1;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const count = Math.max(1, cases.length);
|
|
336
|
+
return {
|
|
337
|
+
recall_at_5: recalled / count,
|
|
338
|
+
mrr: reciprocal / count,
|
|
339
|
+
stale_answer_rate: stale / count,
|
|
340
|
+
evidence_accuracy: evidenceCorrect / count,
|
|
341
|
+
handoff_success: handoffs ? handoffsFound / handoffs : 1,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
@@ -5,5 +5,7 @@ export * from './memory-schema.mjs';
|
|
|
5
5
|
export * from './memory-mode.mjs';
|
|
6
6
|
export * from './memory-handoff.mjs';
|
|
7
7
|
export * from './memory-store.mjs';
|
|
8
|
+
export * from './memory-scope.mjs';
|
|
9
|
+
export * from './evidence-recall.mjs';
|
|
8
10
|
export * from './validate-core.mjs';
|
|
9
11
|
export * from './validate-memory.mjs';
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
2
3
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
4
|
import { basename, join, relative } from 'node:path';
|
|
4
5
|
|
|
5
6
|
import { sanitizeMemoryText } from './memory-schema.mjs';
|
|
7
|
+
import { scopeForMemoryKey } from './memory-scope.mjs';
|
|
6
8
|
|
|
7
9
|
const SHARED_HANDOFF_FIELDS = Object.freeze([
|
|
8
10
|
['objective', 'objective.current'],
|
|
@@ -43,6 +45,10 @@ export function normalizeSharedHandoff(shared) {
|
|
|
43
45
|
const normalized = {};
|
|
44
46
|
const workSessionId = sanitizeMemoryText(shared.work_session_id ?? shared.workSessionId ?? '').trim();
|
|
45
47
|
if (workSessionId) normalized.work_session_id = workSessionId;
|
|
48
|
+
for (const field of ['branch', 'worktree_id', 'repository_id', 'change_slug', 'tasks_hash', 'spec_hash']) {
|
|
49
|
+
const value = sanitizeMemoryText(shared[field] ?? '').trim();
|
|
50
|
+
if (value) normalized[field] = value;
|
|
51
|
+
}
|
|
46
52
|
|
|
47
53
|
for (const [field] of SHARED_HANDOFF_FIELDS) {
|
|
48
54
|
if (!Object.hasOwn(shared, field)) continue;
|
|
@@ -53,7 +59,7 @@ export function normalizeSharedHandoff(shared) {
|
|
|
53
59
|
return Object.keys(normalized).length ? normalized : null;
|
|
54
60
|
}
|
|
55
61
|
|
|
56
|
-
function eventId(context, memoryKey, value) {
|
|
62
|
+
function eventId(context, memoryKey, value, scope = null) {
|
|
57
63
|
const digest = createHash('sha256')
|
|
58
64
|
.update(JSON.stringify([
|
|
59
65
|
context.projectId,
|
|
@@ -61,6 +67,7 @@ function eventId(context, memoryKey, value) {
|
|
|
61
67
|
context.activation?.id,
|
|
62
68
|
context.turn?.id,
|
|
63
69
|
memoryKey,
|
|
70
|
+
scope,
|
|
64
71
|
canonicalValue(value),
|
|
65
72
|
]))
|
|
66
73
|
.digest('hex')
|
|
@@ -68,13 +75,15 @@ function eventId(context, memoryKey, value) {
|
|
|
68
75
|
return `mem-${digest}`;
|
|
69
76
|
}
|
|
70
77
|
|
|
71
|
-
function makeEvent(context, { memoryKey, value, authority, evidence }) {
|
|
78
|
+
function makeEvent(context, { memoryKey, value, authority, evidence, scopeContext = {} }) {
|
|
72
79
|
const cleanValue = sanitizeValue(value);
|
|
80
|
+
const scope = scopeForMemoryKey(memoryKey, { ...context, ...scopeContext });
|
|
73
81
|
const event = {
|
|
74
82
|
v: 1,
|
|
75
|
-
event_id: eventId(context, memoryKey, cleanValue),
|
|
83
|
+
event_id: eventId(context, memoryKey, cleanValue, scope),
|
|
76
84
|
project_id: String(context.projectId || ''),
|
|
77
85
|
memory_key: memoryKey,
|
|
86
|
+
scope,
|
|
78
87
|
operation: 'assert',
|
|
79
88
|
value: cleanValue,
|
|
80
89
|
authority,
|
|
@@ -118,6 +127,26 @@ function nextActionFrom(summary) {
|
|
|
118
127
|
return id && text ? { id, summary: text } : null;
|
|
119
128
|
}
|
|
120
129
|
|
|
130
|
+
function gitScope(cwd = process.cwd(), spawn = spawnSync) {
|
|
131
|
+
const run = (args) => {
|
|
132
|
+
const result = spawn('git', args, { cwd, encoding: 'utf8', windowsHide: true });
|
|
133
|
+
return result.status === 0 ? String(result.stdout || '').trim() : '';
|
|
134
|
+
};
|
|
135
|
+
try {
|
|
136
|
+
const branch = run(['branch', '--show-current']) || `detached:${run(['rev-parse', '--short=12', 'HEAD'])}`;
|
|
137
|
+
const gitDir = run(['rev-parse', '--absolute-git-dir']);
|
|
138
|
+
const remote = run(['remote', 'get-url', 'origin']) || run(['rev-parse', '--show-toplevel']);
|
|
139
|
+
if (!branch || !gitDir || !remote) return null;
|
|
140
|
+
return {
|
|
141
|
+
branch,
|
|
142
|
+
worktree_id: createHash('sha256').update(gitDir).digest('hex').slice(0, 16),
|
|
143
|
+
repository_id: createHash('sha256').update(remote).digest('hex').slice(0, 16),
|
|
144
|
+
};
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
121
150
|
export function collectLifecycleEvidence(vaultBase, { changeSlug = '', summary = '', noteRel = '' } = {}) {
|
|
122
151
|
const evidence = {};
|
|
123
152
|
const slug = String(changeSlug || '').trim();
|
|
@@ -159,11 +188,13 @@ export function collectLifecycleEvidence(vaultBase, { changeSlug = '', summary =
|
|
|
159
188
|
if (nextAction) evidence.nextAction = nextAction;
|
|
160
189
|
const commit = String(summary || '').match(/\b[0-9a-f]{40}\b/i)?.[0];
|
|
161
190
|
if (commit) {
|
|
191
|
+
const scope = gitScope();
|
|
162
192
|
evidence.git = {
|
|
163
193
|
commit: commit.toLowerCase(),
|
|
164
194
|
pushed: !/(?:nenhum|sem)\s+push/i.test(String(summary || '')),
|
|
165
195
|
verified: false,
|
|
166
196
|
path: noteRel,
|
|
197
|
+
...(scope || {}),
|
|
167
198
|
};
|
|
168
199
|
}
|
|
169
200
|
return evidence;
|
|
@@ -184,6 +215,14 @@ export function buildSessionMemoryEvents({
|
|
|
184
215
|
const context = {
|
|
185
216
|
projectId, identity, activation, turn, observedAt,
|
|
186
217
|
workSessionId: normalizedShared?.work_session_id || '',
|
|
218
|
+
canonicalSessionId: identity?.canonicalConversationId || '',
|
|
219
|
+
activation_id: activation?.id || '',
|
|
220
|
+
branch: normalizedShared?.branch || '',
|
|
221
|
+
worktreeId: normalizedShared?.worktree_id || '',
|
|
222
|
+
repositoryId: normalizedShared?.repository_id || '',
|
|
223
|
+
changeSlug: normalizedShared?.change_slug || evidence.change?.slug || '',
|
|
224
|
+
tasksHash: normalizedShared?.tasks_hash || '',
|
|
225
|
+
specHash: normalizedShared?.spec_hash || '',
|
|
187
226
|
};
|
|
188
227
|
const events = [];
|
|
189
228
|
|
|
@@ -195,6 +234,7 @@ export function buildSessionMemoryEvents({
|
|
|
195
234
|
value: normalizedShared[field],
|
|
196
235
|
authority: 'reported',
|
|
197
236
|
evidence: [noteRel],
|
|
237
|
+
scopeContext: { changeSlug: evidence.change?.slug || normalizedShared?.change_slug },
|
|
198
238
|
}));
|
|
199
239
|
}
|
|
200
240
|
}
|
|
@@ -214,6 +254,7 @@ export function buildSessionMemoryEvents({
|
|
|
214
254
|
value: { status: evidence.change.status, adr: evidence.change.adr },
|
|
215
255
|
authority: 'verified',
|
|
216
256
|
evidence: [evidence.change.path || evidence.change.adr],
|
|
257
|
+
scopeContext: { changeSlug: evidence.change.slug },
|
|
217
258
|
}));
|
|
218
259
|
}
|
|
219
260
|
|
|
@@ -227,6 +268,11 @@ export function buildSessionMemoryEvents({
|
|
|
227
268
|
},
|
|
228
269
|
authority: 'verified',
|
|
229
270
|
evidence: [evidence.verdict.path],
|
|
271
|
+
scopeContext: {
|
|
272
|
+
changeSlug: evidence.change?.slug || normalizedShared?.change_slug,
|
|
273
|
+
tasksHash: evidence.verdict.tasks_hash || normalizedShared?.tasks_hash,
|
|
274
|
+
specHash: evidence.verdict.spec_hash || normalizedShared?.spec_hash,
|
|
275
|
+
},
|
|
230
276
|
}));
|
|
231
277
|
}
|
|
232
278
|
|
|
@@ -236,6 +282,10 @@ export function buildSessionMemoryEvents({
|
|
|
236
282
|
value: [...new Set(evidence.sensors.map(String))].sort(),
|
|
237
283
|
authority: 'verified',
|
|
238
284
|
evidence: evidence.sensors,
|
|
285
|
+
scopeContext: {
|
|
286
|
+
changeSlug: evidence.change?.slug || normalizedShared?.change_slug,
|
|
287
|
+
tasksHash: evidence.sensors_tasks_hash || normalizedShared?.tasks_hash,
|
|
288
|
+
},
|
|
239
289
|
}));
|
|
240
290
|
}
|
|
241
291
|
|
|
@@ -249,6 +299,11 @@ export function buildSessionMemoryEvents({
|
|
|
249
299
|
},
|
|
250
300
|
authority: evidence.git.verified === false ? 'reported' : 'verified',
|
|
251
301
|
evidence: [evidence.git.path || evidence.git.commit],
|
|
302
|
+
scopeContext: {
|
|
303
|
+
branch: evidence.git.branch || normalizedShared?.branch,
|
|
304
|
+
worktreeId: evidence.git.worktree_id || normalizedShared?.worktree_id,
|
|
305
|
+
repositoryId: evidence.git.repository_id || normalizedShared?.repository_id,
|
|
306
|
+
},
|
|
252
307
|
}));
|
|
253
308
|
}
|
|
254
309
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { MEMORY_SCOPE_TYPES, normalizeMemoryScope } from './memory-scope.mjs';
|
|
2
3
|
|
|
3
4
|
export const SHARED_LIMITS = Object.freeze({ lines: 48, bytes: 6144, lineChars: 320 });
|
|
4
5
|
|
|
@@ -107,6 +108,11 @@ export function validateMemoryEvent(event, { projectId } = {}) {
|
|
|
107
108
|
if (projectId !== undefined && !eventBelongsToVault(event, projectId)) {
|
|
108
109
|
errors.push(`project_id não pertence ao vault esperado (${projectId}).`);
|
|
109
110
|
}
|
|
111
|
+
if (event.scope !== undefined) {
|
|
112
|
+
if (!normalizeMemoryScope(event.scope, { projectId: event.project_id || projectId || '' })) {
|
|
113
|
+
errors.push(`scope deve conter type (${MEMORY_SCOPE_TYPES.join('|')}) e id não vazio compatível com o projeto.`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
110
116
|
|
|
111
117
|
if (event.candidate_decision !== undefined) {
|
|
112
118
|
const decision = event.candidate_decision;
|
|
@@ -133,7 +139,7 @@ export function validateMemoryEvent(event, { projectId } = {}) {
|
|
|
133
139
|
}
|
|
134
140
|
}
|
|
135
141
|
|
|
136
|
-
for (const field of ['value', 'evidence']) sanitizedField(event, field, errors);
|
|
142
|
+
for (const field of ['value', 'evidence', 'scope']) sanitizedField(event, field, errors);
|
|
137
143
|
return { ok: errors.length === 0, errors, warnings };
|
|
138
144
|
}
|
|
139
145
|
|
|
@@ -161,6 +167,7 @@ function hashProjection(events) {
|
|
|
161
167
|
operation: event.operation,
|
|
162
168
|
value: sanitizeMemoryText(event.value),
|
|
163
169
|
authority: event.authority,
|
|
170
|
+
scope: event.scope,
|
|
164
171
|
observed_at: event.observed_at,
|
|
165
172
|
evidence: Array.isArray(event.evidence) ? event.evidence.map(sanitizeMemoryText) : [],
|
|
166
173
|
}));
|
|
@@ -173,7 +180,10 @@ function eventLine(event) {
|
|
|
173
180
|
? event.evidence.map(sanitizeMemoryText).join(', ')
|
|
174
181
|
: 'none';
|
|
175
182
|
const source = sanitizeMemoryText(event.source_turn_id || event.canonical_session_id || event.activation_id || 'unknown');
|
|
176
|
-
const
|
|
183
|
+
const scope = event.scope?.type && event.scope?.id
|
|
184
|
+
? ` · scope:${sanitizeMemoryText(event.scope.type)}:${sanitizeMemoryText(event.scope.id)}`
|
|
185
|
+
: '';
|
|
186
|
+
const line = `- [${sanitizeMemoryText(event.event_id)}] ${value} · authority:${sanitizeMemoryText(event.authority)}${scope} · source:${source} · as_of:${sanitizeMemoryText(event.observed_at)} · evidence:${evidence}`;
|
|
177
187
|
return line.length <= SHARED_LIMITS.lineChars
|
|
178
188
|
? line
|
|
179
189
|
: `${line.slice(0, SHARED_LIMITS.lineChars - 1).trimEnd()}…`;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const MEMORY_SCOPE_TYPES = Object.freeze([
|
|
4
|
+
'project',
|
|
5
|
+
'work_session',
|
|
6
|
+
'change',
|
|
7
|
+
'branch',
|
|
8
|
+
'worktree',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const SCOPE_TYPES = new Set(MEMORY_SCOPE_TYPES);
|
|
12
|
+
const REGISTER_PATTERNS = [
|
|
13
|
+
/^git\.local-head$/,
|
|
14
|
+
/^handoff\.latest$/,
|
|
15
|
+
/^quality\.latest-(?:sensors|verdict)$/,
|
|
16
|
+
/^change\.[A-Za-z0-9._-]+\.status$/,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function clean(value) {
|
|
20
|
+
return String(value ?? '').trim().replace(/[\r\n\t]+/g, ' ').slice(0, 240);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function digest(value) {
|
|
24
|
+
return createHash('sha256').update(clean(value)).digest('hex').slice(0, 16);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function normalizeMemoryScope(scope, { projectId = '' } = {}) {
|
|
28
|
+
if (!scope || typeof scope !== 'object' || Array.isArray(scope)) return null;
|
|
29
|
+
const type = clean(scope.type);
|
|
30
|
+
const id = clean(scope.id);
|
|
31
|
+
if (!SCOPE_TYPES.has(type) || !id) return null;
|
|
32
|
+
if (type === 'project' && projectId && id !== projectId) return null;
|
|
33
|
+
return { type, id };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function changeSlug(memoryKey, context) {
|
|
37
|
+
const fromKey = String(memoryKey || '').match(/^change\.([A-Za-z0-9._-]+)\.status$/)?.[1];
|
|
38
|
+
return clean(fromKey || context.changeSlug || context.change_slug);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function workSession(context) {
|
|
42
|
+
return clean(
|
|
43
|
+
context.workSessionId || context.work_session_id
|
|
44
|
+
|| context.canonicalSessionId || context.canonical_session_id
|
|
45
|
+
|| context.sessionId || context.session_id,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Deterministic scope policy for operational keys. It never uses an absolute local path. */
|
|
50
|
+
export function scopeForMemoryKey(memoryKey, context = {}) {
|
|
51
|
+
const key = clean(memoryKey);
|
|
52
|
+
const projectId = clean(context.projectId || context.project_id) || 'unknown-project';
|
|
53
|
+
if (key === 'handoff.latest') {
|
|
54
|
+
return { type: 'work_session', id: workSession(context) || `legacy:${projectId}` };
|
|
55
|
+
}
|
|
56
|
+
if (key === 'git.local-head') {
|
|
57
|
+
const branch = clean(context.branch || context.branchName || context.branch_name);
|
|
58
|
+
const worktree = clean(context.worktreeId || context.worktree_id);
|
|
59
|
+
const repository = clean(context.repositoryId || context.repository_id);
|
|
60
|
+
if (branch) {
|
|
61
|
+
return {
|
|
62
|
+
type: 'branch',
|
|
63
|
+
id: [repository && `repo:${repository}`, worktree && `worktree:${worktree}`, `branch:${branch}`]
|
|
64
|
+
.filter(Boolean).join('|'),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const lineage = workSession(context) || clean(context.activation_id) || clean(context.event_id);
|
|
68
|
+
return { type: 'branch', id: `legacy:${projectId}:${digest(lineage || key)}` };
|
|
69
|
+
}
|
|
70
|
+
if (/^quality\.latest-(?:sensors|verdict)$/.test(key)) {
|
|
71
|
+
const slug = changeSlug(key, context) || `legacy:${digest(workSession(context) || key)}`;
|
|
72
|
+
const proof = clean(context.tasksHash || context.tasks_hash || context.specHash || context.spec_hash);
|
|
73
|
+
return { type: 'change', id: proof ? `${slug}|proof:${proof}` : slug };
|
|
74
|
+
}
|
|
75
|
+
if (/^change\.[A-Za-z0-9._-]+\.status$/.test(key)) {
|
|
76
|
+
return { type: 'change', id: changeSlug(key, context) };
|
|
77
|
+
}
|
|
78
|
+
if (/^(?:decision|adr)\b/.test(key)) {
|
|
79
|
+
const slug = changeSlug(key, context);
|
|
80
|
+
return slug ? { type: 'change', id: slug } : { type: 'project', id: projectId };
|
|
81
|
+
}
|
|
82
|
+
if (/^(?:constraint|restriction)\b/.test(key)) {
|
|
83
|
+
const session = workSession(context);
|
|
84
|
+
return session ? { type: 'work_session', id: session } : { type: 'project', id: projectId };
|
|
85
|
+
}
|
|
86
|
+
return { type: 'project', id: projectId };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function effectiveMemoryScope(event = {}) {
|
|
90
|
+
// Ledger rows written before scoped registers existed remain project-scoped until an
|
|
91
|
+
// explicit append-only rescope migration supersedes them. This preserves historic replay.
|
|
92
|
+
return normalizeMemoryScope(event.scope, { projectId: event.project_id })
|
|
93
|
+
|| { type: 'project', id: clean(event.project_id) || 'unknown-project' };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function memoryScopeKey(scope) {
|
|
97
|
+
const normalized = normalizeMemoryScope(scope);
|
|
98
|
+
return normalized ? `${normalized.type}:${normalized.id}` : 'project:unknown-project';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function sameMemoryScope(left, right) {
|
|
102
|
+
return memoryScopeKey(effectiveMemoryScope(left)) === memoryScopeKey(effectiveMemoryScope(right));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Project-scoped keys retain their historic public name; narrower registers are qualified. */
|
|
106
|
+
export function memoryRecordKey(event) {
|
|
107
|
+
const scope = effectiveMemoryScope(event);
|
|
108
|
+
return scope.type === 'project'
|
|
109
|
+
? String(event.memory_key)
|
|
110
|
+
: `${event.memory_key}@${memoryScopeKey(scope)}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function isRegisterMemoryKey(memoryKey) {
|
|
114
|
+
return REGISTER_PATTERNS.some((pattern) => pattern.test(String(memoryKey || '')));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function isHumanCuratedMemoryKey(memoryKey) {
|
|
118
|
+
return /^(?:decision|adr|constraint|restriction|block|blocker)\b/.test(String(memoryKey || ''));
|
|
119
|
+
}
|