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.
- package/CHANGELOG.md +61 -0
- package/README.en.md +37 -16
- package/README.md +37 -16
- package/docs/en/commands/changes-and-verification.md +10 -5
- package/docs/en/commands/maintenance-and-diagnostics.md +17 -9
- package/docs/en/commands/memory.md +16 -1
- package/docs/en/commands/observer.md +8 -1
- package/docs/en/commands/operating-profiles.md +28 -3
- package/docs/pt-BR/commands/changes-and-verification.md +10 -5
- package/docs/pt-BR/commands/maintenance-and-diagnostics.md +12 -5
- 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 +28 -3
- package/hooks/brain-core.mjs +2 -0
- package/hooks/brain-inject.mjs +6 -6
- package/hooks/brain-recall.mjs +5 -1
- package/hooks/change-context.mjs +11 -0
- package/hooks/change-core.mjs +53 -21
- package/hooks/change-warn.mjs +2 -0
- package/hooks/evidence-context.mjs +41 -0
- package/hooks/evidence-recall.mjs +1 -0
- package/hooks/harness-doctor.mjs +13 -5
- package/hooks/memory-scope.mjs +1 -0
- package/hooks/vault-health.mjs +2 -2
- package/package.json +2 -2
- package/packages/cli/src/index.mjs +13 -3
- 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/change.mjs +10 -4
- package/src/delivery.mjs +303 -0
- package/src/doctor.mjs +47 -10
- package/src/memory.mjs +95 -2
- package/src/observer-sql-store.mjs +141 -5
- package/src/release-provenance.mjs +47 -0
- package/src/skills-seed.mjs +25 -9
- package/src/sync-defs.mjs +5 -2
- package/src/sync.mjs +2 -2
- package/src/taxonomy.mjs +4 -0
- 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';
|
package/hooks/harness-doctor.mjs
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
131
|
-
|
|
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)
|
|
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';
|
package/hooks/vault-health.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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]
|
|
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';
|