wendkeep 0.73.0 → 0.75.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.en.md +13 -9
  3. package/README.md +13 -9
  4. package/docs/en/commands/memory.md +16 -1
  5. package/docs/en/commands/observer.md +29 -11
  6. package/docs/en/commands/operating-profiles.md +1 -1
  7. package/docs/pt-BR/commands/memory.md +16 -1
  8. package/docs/pt-BR/commands/observer.md +28 -11
  9. package/docs/pt-BR/commands/operating-profiles.md +1 -1
  10. package/hooks/brain-core.mjs +2 -0
  11. package/hooks/brain-recall.mjs +5 -1
  12. package/hooks/evidence-context.mjs +41 -0
  13. package/hooks/evidence-recall.mjs +1 -0
  14. package/hooks/memory-scope.mjs +1 -0
  15. package/package.json +2 -2
  16. package/packages/cli/src/index.mjs +2 -2
  17. package/packages/integrations/src/host-hooks.mjs +1 -0
  18. package/packages/vault/src/evidence-recall.mjs +343 -0
  19. package/packages/vault/src/index.mjs +2 -0
  20. package/packages/vault/src/memory-handoff.mjs +58 -3
  21. package/packages/vault/src/memory-schema.mjs +12 -2
  22. package/packages/vault/src/memory-scope.mjs +119 -0
  23. package/packages/vault/src/memory-store.mjs +86 -24
  24. package/schema/observer/004-evidence-recall.sql +25 -0
  25. package/schema/observer/005-project-scoped-identities.sql +217 -0
  26. package/src/change.mjs +41 -1
  27. package/src/doctor.mjs +5 -0
  28. package/src/init.mjs +2 -2
  29. package/src/memory.mjs +95 -2
  30. package/src/note.mjs +8 -1
  31. package/src/observer-publish.mjs +9 -34
  32. package/src/observer-server.mjs +38 -63
  33. package/src/observer-sql-migrate.mjs +1 -1
  34. package/src/observer-sql-publish.mjs +372 -12
  35. package/src/observer-sql-store.mjs +248 -32
  36. package/src/observer-store.mjs +15 -3
  37. package/src/observer.mjs +104 -14
  38. package/src/taxonomy.mjs +4 -0
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ // UserPromptSubmit: bounded, read-only retrieval from the local evidence index.
3
+ import { pathToFileURL } from 'node:url';
4
+ import { readHookInput, writeHookOutput } from './obsidian-common.mjs';
5
+ import { loadEvidenceIndex, recallEvidence, renderEvidenceContext } from './evidence-recall.mjs';
6
+ import { sanitizeMemoryText } from './memory-schema.mjs';
7
+ import { resolveHookOperatingProfile } from './operating-profile-runtime.mjs';
8
+ import { isBootstrapPrompt } from '../packages/integrations/src/prompt-content.mjs';
9
+
10
+ export function buildPromptEvidenceContext(vaultBase, prompt, {
11
+ topK = 3, maxBytes = 3072, rows = null,
12
+ } = {}) {
13
+ const query = sanitizeMemoryText(String(prompt || '')).trim();
14
+ if (!query || isBootstrapPrompt(query)) return '';
15
+ const evidence = rows || loadEvidenceIndex(vaultBase);
16
+ if (!evidence.length) return '';
17
+ return sanitizeMemoryText(renderEvidenceContext(
18
+ recallEvidence(evidence, query, { topK }),
19
+ { maxBytes },
20
+ ));
21
+ }
22
+
23
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
24
+ try {
25
+ const input = readHookInput();
26
+ const runtime = resolveHookOperatingProfile({ input });
27
+ if (runtime.bindingError) {
28
+ writeHookOutput({});
29
+ } else {
30
+ const context = buildPromptEvidenceContext(
31
+ runtime.vaultBase,
32
+ input.prompt || input.user_prompt || '',
33
+ );
34
+ writeHookOutput(context ? {
35
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: context },
36
+ } : {});
37
+ }
38
+ } catch {
39
+ writeHookOutput({});
40
+ }
41
+ }
@@ -0,0 +1 @@
1
+ export * from '../packages/vault/src/evidence-recall.mjs';
@@ -0,0 +1 @@
1
+ export * from '../packages/vault/src/memory-scope.mjs';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.73.0",
3
+ "version": "0.75.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": [
@@ -41,7 +41,7 @@
41
41
  "node": ">=18"
42
42
  },
43
43
  "scripts": {
44
- "check": "node --check scripts/release.mjs && node --check scripts/release-plan.mjs && node --check scripts/release-provenance.mjs && node --check scripts/run-scope.mjs && node --check src/release-provenance.mjs && node --check bin/wendkeep.mjs && node --check packages/cli/src/index.mjs && node --check src/init.mjs && node --check src/doctor.mjs && node --check src/project-vault.mjs && node --check src/observer-auth.mjs && node --check src/observer-privacy.mjs && node --check src/observer-snapshot.mjs && node --check src/observer-store.mjs && node --check src/observer-memory.mjs && node --check src/observer-memory-publish.mjs && node --check src/observer-sql-store.mjs && node --check src/observer-sql-migrate.mjs && node --check src/observer-sql-publish.mjs && node --check src/observer-transcript-store.mjs && node --check src/observer-server.mjs && node --check src/observer.mjs && node --check src/observer-publish.mjs && node --check src/operating-profile.mjs && node --check src/profile.mjs && node --check src/flow.mjs && node --check src/work-kind.mjs && node --check src/delivery.mjs && node --check web/observer/app.mjs && node --check hooks/observer-publish.mjs && node --check hooks/operating-profile-runtime.mjs && node --check hooks/operating-profile-task-store.mjs && node --check hooks/flow-core.mjs && node --check hooks/flow-protected-policy.mjs && node --check hooks/git-snapshot.mjs && node --check hooks/vault-path-safety.mjs && node --check hooks/vault-runtime-store.mjs && node --check packages/harness/src/index.mjs && node --check packages/harness/src/flow-store.mjs && node --check packages/harness/src/operating-profile.mjs && node --check packages/harness/src/sensors-core.mjs && node --check packages/integrations/src/host-hooks.mjs && node --check packages/integrations/src/hook-envelope.mjs && node --check packages/integrations/src/prompt-content.mjs && node --check packages/integrations/src/transcript-usage.mjs && node --check packages/integrations/src/transcripts.mjs && node --check packages/integrations/src/session-identity.mjs && node --check packages/integrations/src/index.mjs && node --check packages/mcp/src/config.mjs && node --check packages/mcp/src/index.mjs && node --check packages/vault/src/index.mjs && node --check packages/vault/src/project-vault.mjs && node --check packages/vault/src/vault-path-safety.mjs && node --check packages/vault/src/locale.mjs && node --check packages/vault/src/memory-schema.mjs && node --check packages/vault/src/memory-mode.mjs && node --check packages/vault/src/memory-handoff.mjs && node --check packages/vault/src/memory-store.mjs && node --check packages/vault/src/validate-core.mjs && node --check packages/vault/src/validate-memory.mjs",
44
+ "check": "node --check scripts/release.mjs && node --check scripts/release-plan.mjs && node --check scripts/release-provenance.mjs && node --check scripts/run-scope.mjs && node --check src/release-provenance.mjs && node --check bin/wendkeep.mjs && node --check packages/cli/src/index.mjs && node --check src/init.mjs && node --check src/doctor.mjs && node --check src/project-vault.mjs && node --check src/observer-auth.mjs && node --check src/observer-privacy.mjs && node --check src/observer-snapshot.mjs && node --check src/observer-store.mjs && node --check src/observer-memory.mjs && node --check src/observer-memory-publish.mjs && node --check src/observer-sql-store.mjs && node --check src/observer-sql-migrate.mjs && node --check src/observer-sql-publish.mjs && node --check src/observer-transcript-store.mjs && node --check src/observer-server.mjs && node --check src/observer.mjs && node --check src/observer-publish.mjs && node --check src/operating-profile.mjs && node --check src/profile.mjs && node --check src/flow.mjs && node --check src/work-kind.mjs && node --check src/delivery.mjs && node --check web/observer/app.mjs && node --check hooks/observer-publish.mjs && node --check hooks/evidence-context.mjs && node --check hooks/evidence-recall.mjs && node --check hooks/memory-scope.mjs && node --check hooks/operating-profile-runtime.mjs && node --check hooks/operating-profile-task-store.mjs && node --check hooks/flow-core.mjs && node --check hooks/flow-protected-policy.mjs && node --check hooks/git-snapshot.mjs && node --check hooks/vault-path-safety.mjs && node --check hooks/vault-runtime-store.mjs && node --check packages/harness/src/index.mjs && node --check packages/harness/src/flow-store.mjs && node --check packages/harness/src/operating-profile.mjs && node --check packages/harness/src/sensors-core.mjs && node --check packages/integrations/src/host-hooks.mjs && node --check packages/integrations/src/hook-envelope.mjs && node --check packages/integrations/src/prompt-content.mjs && node --check packages/integrations/src/transcript-usage.mjs && node --check packages/integrations/src/transcripts.mjs && node --check packages/integrations/src/session-identity.mjs && node --check packages/integrations/src/index.mjs && node --check packages/mcp/src/config.mjs && node --check packages/mcp/src/index.mjs && node --check packages/vault/src/index.mjs && node --check packages/vault/src/project-vault.mjs && node --check packages/vault/src/vault-path-safety.mjs && node --check packages/vault/src/locale.mjs && node --check packages/vault/src/memory-schema.mjs && node --check packages/vault/src/memory-mode.mjs && node --check packages/vault/src/memory-scope.mjs && node --check packages/vault/src/evidence-recall.mjs && node --check packages/vault/src/memory-handoff.mjs && node --check packages/vault/src/memory-store.mjs && node --check packages/vault/src/validate-core.mjs && node --check packages/vault/src/validate-memory.mjs",
45
45
  "test": "node --test --test-concurrency=2",
46
46
  "test:core": "node scripts/run-scope.mjs core",
47
47
  "release": "node scripts/release.mjs",
@@ -49,7 +49,7 @@ Usage:
49
49
  cannot replace itself. · --vault P · --profile <name> · --yes.
50
50
 
51
51
  wendkeep doctor [--vault P] Health check. --scope core|runtime · --strict for CI/release.
52
- wendkeep observer <sub> Local multi-project Observer: serve | register | publish | status.
52
+ wendkeep observer <sub> Local multi-project Observer: serve | register | publish | reconcile | status.
53
53
  wendkeep change <sub> Change lifecycle: new [--simple|--guide] | use | bind <slug> --session <id> | continue | list | show |
54
54
  status | done <id> | undone <id> | diff | archive [--force] | abandon | relink | backlink.
55
55
  archive exige verdict (rode verify --deep); abandon descarta sem ADR.
@@ -104,7 +104,7 @@ Usage:
104
104
  wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
105
105
  wendkeep memory curate Guide one semantic conflict at a time in an interactive terminal.
106
106
  Every promote/reject requires confirmation; --vault P.
107
- wendkeep memory <sub> Shared memory v2: status | candidates [--active] | curate | migrate [--apply] | repair |
107
+ wendkeep memory <sub> Shared memory v2: status | candidates [--active] | curate | migrate [--apply] | rescope [--apply] | repair |
108
108
  recover-attempt <session> [--apply] |
109
109
  reconcile <session> --by-session <session> --reason <text> [--apply] |
110
110
  promote <candidate> [--event <event-id>] | reject <candidate>. --vault P.
@@ -19,6 +19,7 @@ export const SESSION_HOOKS = [
19
19
  { event: 'Stop', matcher: null, name: 'session-stop', timeout: 60, codex: true, statusMessage: 'wendkeep: writing session checkpoint' },
20
20
  { event: 'Stop', matcher: null, name: 'observer-publish', timeout: 5, order: 20, codex: true, statusMessage: 'wendkeep: publishing local observer snapshot' },
21
21
  { event: 'UserPromptSubmit', matcher: null, name: 'session-ensure', timeout: 30, codex: true, statusMessage: 'wendkeep: ensuring active session' },
22
+ { event: 'UserPromptSubmit', matcher: null, name: 'evidence-context', timeout: 10, order: 5, codex: true, statusMessage: 'wendkeep: retrieving relevant evidence' },
22
23
  // Capture an interactive decision (AskUserQuestion) — options + the user's choice — into 04-Decisões.
23
24
  // codex: AskUserQuestion is a Claude-only tool; there is nothing to match on.
24
25
  { event: 'PostToolUse', matcher: 'AskUserQuestion', name: 'decision-capture', timeout: 15, statusMessage: 'wendkeep: recording decision' },
@@ -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 line = `- [${sanitizeMemoryText(event.event_id)}] ${value} · authority:${sanitizeMemoryText(event.authority)} · source:${source} · as_of:${sanitizeMemoryText(event.observed_at)} · evidence:${evidence}`;
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()}…`;