wendkeep 0.72.1 → 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.en.md +37 -16
  3. package/README.md +37 -16
  4. package/docs/en/commands/changes-and-verification.md +10 -5
  5. package/docs/en/commands/maintenance-and-diagnostics.md +17 -9
  6. package/docs/en/commands/memory.md +16 -1
  7. package/docs/en/commands/observer.md +8 -1
  8. package/docs/en/commands/operating-profiles.md +28 -3
  9. package/docs/pt-BR/commands/changes-and-verification.md +10 -5
  10. package/docs/pt-BR/commands/maintenance-and-diagnostics.md +12 -5
  11. package/docs/pt-BR/commands/memory.md +16 -1
  12. package/docs/pt-BR/commands/observer.md +7 -1
  13. package/docs/pt-BR/commands/operating-profiles.md +28 -3
  14. package/hooks/brain-core.mjs +2 -0
  15. package/hooks/brain-inject.mjs +6 -6
  16. package/hooks/brain-recall.mjs +5 -1
  17. package/hooks/change-context.mjs +11 -0
  18. package/hooks/change-core.mjs +53 -21
  19. package/hooks/change-warn.mjs +2 -0
  20. package/hooks/evidence-context.mjs +41 -0
  21. package/hooks/evidence-recall.mjs +1 -0
  22. package/hooks/harness-doctor.mjs +13 -5
  23. package/hooks/memory-scope.mjs +1 -0
  24. package/hooks/vault-health.mjs +2 -2
  25. package/package.json +2 -2
  26. package/packages/cli/src/index.mjs +13 -3
  27. package/packages/integrations/src/host-hooks.mjs +1 -0
  28. package/packages/vault/src/evidence-recall.mjs +343 -0
  29. package/packages/vault/src/index.mjs +2 -0
  30. package/packages/vault/src/memory-handoff.mjs +58 -3
  31. package/packages/vault/src/memory-schema.mjs +12 -2
  32. package/packages/vault/src/memory-scope.mjs +119 -0
  33. package/packages/vault/src/memory-store.mjs +86 -24
  34. package/schema/observer/004-evidence-recall.sql +25 -0
  35. package/src/change.mjs +10 -4
  36. package/src/delivery.mjs +303 -0
  37. package/src/doctor.mjs +47 -10
  38. package/src/memory.mjs +95 -2
  39. package/src/observer-sql-store.mjs +141 -5
  40. package/src/release-provenance.mjs +47 -0
  41. package/src/skills-seed.mjs +25 -9
  42. package/src/sync-defs.mjs +5 -2
  43. package/src/sync.mjs +2 -2
  44. package/src/taxonomy.mjs +4 -0
  45. package/src/work-kind.mjs +62 -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';
@@ -102,6 +102,8 @@ export function checkHarness(vaultBase, projectRoot) {
102
102
  const CHANGES_DIR = loc.folders.changes;
103
103
  const errors = [];
104
104
  const warnings = [];
105
+ const attention = [];
106
+ const repairable = [];
105
107
 
106
108
  // 1. wendkeep.sensors.json well-formed.
107
109
  const sensorsPath = join(projectRoot, 'wendkeep.sensors.json');
@@ -114,7 +116,7 @@ export function checkHarness(vaultBase, projectRoot) {
114
116
  }
115
117
 
116
118
  const specState = checkSpecsState(vaultBase);
117
- if (specState.missing) warnings.push('SPECS_STATE ausente — rode `wendkeep spec migrate`; 07-Specs deve ser gerado/read-only');
119
+ if (specState.missing) repairable.push('SPECS_STATE ausente — rode `wendkeep spec migrate`; 07-Specs deve ser gerado/read-only');
118
120
  else if (!specState.ok) errors.push(`07-Specs alterado fora do WendKeep: ${specState.changed.join(', ')} — mova a alteração para 08-Mudanças/<change>/specs`);
119
121
 
120
122
  // 2/3. Changes: malformed dirs; the active change's deltas add to knownReqs.
@@ -127,12 +129,18 @@ export function checkHarness(vaultBase, projectRoot) {
127
129
  try { entries = readdirSync(dir); } catch { continue; } // a file, not a change dir
128
130
  if (!entries.includes('proposta.md')) { errors.push(`change sem proposta.md: ${name}`); continue; }
129
131
  const impact = validateSpecImpact(dir);
130
- errors.push(...impact.errors.map((e) => `${name}: ${e}`));
131
- warnings.push(...impact.warnings.map((w) => `${name}: ${w}`));
132
+ for (const error of impact.errors) {
133
+ const rendered = `${name}: ${error}`;
134
+ if (/spec_impact pending/i.test(error)) attention.push(rendered);
135
+ else errors.push(rendered);
136
+ }
137
+ attention.push(...impact.warnings.map((w) => `${name}: ${w}`));
132
138
  let tasks = [];
133
139
  let tarefasMd = '';
134
140
  try { tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8'); tasks = parseTasks(tarefasMd); } catch { /* sem tarefas */ }
135
141
  const reqIds = [...new Set(tasks.flatMap((t) => t.reqs ?? []))];
142
+ const openTasks = tasks.filter((task) => !task.done);
143
+ if (openTasks.length) attention.push(`${name}: ${openTasks.length} tarefa(s) aberta(s)`);
136
144
  const effective = buildEffectiveRequirementPackage(vaultBase, dir, reqIds);
137
145
  errors.push(...effective.errors.map((e) => `${name}: spec efetiva inválida: ${e}`));
138
146
  if (effective.missing.length) errors.push(`req órfão em ${name}: ${effective.missing.map((id) => `[req:${id}]`).join(', ')} não existe na spec efetiva`);
@@ -140,7 +148,7 @@ export function checkHarness(vaultBase, projectRoot) {
140
148
  try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* sem verdict */ }
141
149
  if (verdict && reqIds.length) {
142
150
  const v = evaluateVerdict(verdict, reqIds, { tasksHash: tasksHashOf(tarefasMd), effectiveSpecHash: effective.hash });
143
- if (!v.ok) warnings.push(`verdict stale/incompleto em ${name}${v.missing.length ? `: falta cobrir ${v.missing.join(', ')}` : ''}`);
151
+ if (!v.ok) attention.push(`verdict stale/incompleto em ${name}${v.missing.length ? `: falta cobrir ${v.missing.join(', ')}` : ''}`);
144
152
  }
145
153
  }
146
154
 
@@ -152,7 +160,7 @@ export function checkHarness(vaultBase, projectRoot) {
152
160
  }
153
161
  }
154
162
 
155
- return { errors, warnings };
163
+ return { errors, warnings, attention, repairable };
156
164
  }
157
165
 
158
166
  // --- diagnóstico de links do grafo (read-only, reusa os reparos em dry-run) -----
@@ -0,0 +1 @@
1
+ export * from '../packages/vault/src/memory-scope.mjs';
@@ -504,7 +504,7 @@ export function checkMemoryBundle(vaultBase, { registry } = {}) {
504
504
  ? '1 conflito ativo'
505
505
  : `${activeConflicts.length} conflitos ativos`;
506
506
  const purposes = groupConflictPurposes(activeConflicts).join('; ');
507
- failures.push(`${label} (${purposes}). Existem versões concorrentes e nenhum dado foi escolhido automaticamente. Conflito semântico exige curadoria humana; memory repair não escolhe vencedor. Próximo passo: ${memoryCurateCommand(vaultBase)}. Inventário avançado: ${memoryCandidatesCommand(vaultBase)}.`);
507
+ warnings.push(`${label} (${purposes}). Existem versões concorrentes e nenhum dado foi escolhido automaticamente. Conflito semântico degrada somente as chaves afetadas e exige curadoria humana; memory repair não escolhe vencedor. Próximo passo: ${memoryCurateCommand(vaultBase)}. Inventário avançado: ${memoryCandidatesCommand(vaultBase)}.`);
508
508
  }
509
509
  if (outbox.count) warnings.push(`${outbox.count} evento(s) pendente(s) na outbox; execute o projector quando seguro.`);
510
510
  if (ordinaryCandidates.length) warnings.push(`${ordinaryCandidates.length} candidate(s) aguardando curadoria humana.`);
@@ -514,7 +514,7 @@ export function checkMemoryBundle(vaultBase, { registry } = {}) {
514
514
  const semantic = bundle.semantic || {};
515
515
  return {
516
516
  ok,
517
- status: ok ? (warnings.length ? 'warning' : 'healthy') : 'blocked',
517
+ status: ok ? (activeConflicts.length ? 'degraded' : warnings.length ? 'warning' : 'healthy') : 'blocked',
518
518
  failures,
519
519
  warnings,
520
520
  metrics: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.72.1",
3
+ "version": "0.74.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 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",
@@ -48,9 +48,9 @@ Usage:
48
48
  package first (npm i -D wendkeep@latest); a running process
49
49
  cannot replace itself. · --vault P · --profile <name> · --yes.
50
50
 
51
- wendkeep doctor [--vault P] Run a vault health check.
51
+ wendkeep doctor [--vault P] Health check. --scope core|runtime · --strict for CI/release.
52
52
  wendkeep observer <sub> Local multi-project Observer: serve | register | publish | status.
53
- wendkeep change <sub> Change lifecycle: new [--simple] | use | bind <slug> --session <id> | continue | list | show |
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.
56
56
  backlink [--apply]: injeta o backlink pro proposta em design/tarefas/spec órfãos (open + _arquivo).
@@ -62,6 +62,8 @@ Usage:
62
62
  the project default. The Vault/session/memory core is always active.
63
63
  wendkeep flow <sub> Low-ceremony E -> V contract: start | status | show | finish | promote.
64
64
  FLOW records scope, sensors and a receipt without creating a change.
65
+ wendkeep delivery <sub> Operational delivery: start | status | finish | abandon.
66
+ Records authorization and an append-only receipt; never creates a change/spec/ADR.
65
67
  wendkeep spec <sub> Specs: list | show | effective [--change] [--json] | migrate | rebase.
66
68
  wendkeep sensors <sub> list | add <id> "<command>" [--severity --type --report].
67
69
  wendkeep cost [opts] Aggregate AI-coding spend across the vault's sessions.
@@ -102,7 +104,7 @@ Usage:
102
104
  wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
103
105
  wendkeep memory curate Guide one semantic conflict at a time in an interactive terminal.
104
106
  Every promote/reject requires confirmation; --vault P.
105
- 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 |
106
108
  recover-attempt <session> [--apply] |
107
109
  reconcile <session> --by-session <session> --reason <text> [--apply] |
108
110
  promote <candidate> [--event <event-id>] | reject <candidate>. --vault P.
@@ -184,6 +186,9 @@ async function main(argv) {
184
186
  if (cmd === 'flow') {
185
187
  const { FLOW_HELP } = await import('../../../src/flow.mjs');
186
188
  process.stdout.write(FLOW_HELP);
189
+ } else if (cmd === 'delivery') {
190
+ const { DELIVERY_HELP } = await import('../../../src/delivery.mjs');
191
+ process.stdout.write(DELIVERY_HELP);
187
192
  } else if (cmd === 'profile') {
188
193
  const { PROFILE_HELP } = await import('../../../src/profile.mjs');
189
194
  process.stdout.write(PROFILE_HELP);
@@ -273,6 +278,11 @@ async function main(argv) {
273
278
  process.exit(await runFlow(rest));
274
279
  break;
275
280
  }
281
+ case 'delivery': {
282
+ const { runDelivery } = await import('../../../src/delivery.mjs');
283
+ process.exit(runDelivery(rest));
284
+ break;
285
+ }
276
286
  case 'theme': {
277
287
  const { runTheme } = await import('../../../src/theme.mjs');
278
288
  runTheme(rest);
@@ -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';