wendkeep 0.58.3 → 0.59.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 +73 -0
- package/README.en.md +41 -3
- package/README.md +41 -3
- package/bin/wendkeep.mjs +54 -6
- package/docs/en/commands/changes-and-verification.md +9 -3
- package/docs/en/commands/getting-started.md +7 -3
- package/docs/en/commands/memory.md +20 -2
- package/docs/en/commands/operating-profiles.md +173 -0
- package/docs/en/commands/sessions-and-import.md +8 -4
- package/docs/en/commands/verify.md +12 -6
- package/docs/pt-BR/commands/changes-and-verification.md +9 -4
- package/docs/pt-BR/commands/getting-started.md +7 -3
- package/docs/pt-BR/commands/memory.md +18 -2
- package/docs/pt-BR/commands/operating-profiles.md +171 -0
- package/docs/pt-BR/commands/sessions-and-import.md +7 -3
- package/docs/pt-BR/commands/verify.md +11 -5
- package/hooks/brain-core.mjs +159 -159
- package/hooks/brain-inject.mjs +83 -26
- package/hooks/brain-recall.mjs +32 -32
- package/hooks/brain-reindex.mjs +13 -13
- package/hooks/change-context.mjs +24 -10
- package/hooks/change-core.mjs +174 -37
- package/hooks/change-guard.mjs +115 -16
- package/hooks/change-nag.mjs +20 -5
- package/hooks/change-warn.mjs +27 -9
- package/hooks/decision-capture.mjs +1 -1
- package/hooks/derived-sections.mjs +1 -1
- package/hooks/flow-core.mjs +891 -0
- package/hooks/flow-protected-policy.mjs +218 -0
- package/hooks/frontmatter-repair.mjs +3 -1
- package/hooks/git-snapshot.mjs +722 -0
- package/hooks/import-sessions.mjs +10 -5
- package/hooks/memory-mode.mjs +63 -13
- package/hooks/memory-store.mjs +309 -69
- package/hooks/obsidian-common.mjs +39 -55
- package/hooks/operating-profile-runtime.mjs +157 -0
- package/hooks/plan-capture.mjs +14 -3
- package/hooks/sensors-core.mjs +15 -3
- package/hooks/session-backfill.mjs +7 -2
- package/hooks/session-ensure.mjs +6 -4
- package/hooks/session-iteration.mjs +65 -0
- package/hooks/session-memory-lifecycle.mjs +10 -5
- package/hooks/session-note-io.mjs +130 -15
- package/hooks/session-observability.mjs +4 -2
- package/hooks/session-stop.mjs +65 -19
- package/hooks/spec-core.mjs +91 -12
- package/hooks/subagent-stop.mjs +4 -1
- package/hooks/subagent-usage.mjs +2 -2
- package/hooks/task-log.mjs +3 -1
- package/hooks/token-usage.mjs +1 -1
- package/hooks/vault-health.mjs +183 -37
- package/hooks/vault-path-safety.mjs +558 -0
- package/hooks/vault-runtime-store.mjs +558 -0
- package/package.json +3 -3
- package/src/change.mjs +2 -1
- package/src/flow.mjs +232 -0
- package/src/init.mjs +26 -3
- package/src/memory.mjs +785 -35
- package/src/operating-profile.mjs +133 -0
- package/src/profile.mjs +224 -0
- package/src/project-vault.mjs +110 -5
- package/src/rebuild-costs.mjs +11 -4
- package/src/skills-seed.mjs +38 -16
- package/src/sync-defs.mjs +16 -7
- package/src/sync.mjs +9 -1
- package/src/taxonomy.mjs +8 -0
- package/src/validate-memory.mjs +21 -8
- package/src/verify.mjs +12 -2
package/hooks/brain-core.mjs
CHANGED
|
@@ -1,159 +1,159 @@
|
|
|
1
|
-
// .agent/hooks/brain-core.mjs
|
|
2
|
-
// Camada fria do brain: indexa o frontmatter das notas de sessão (0 token LLM).
|
|
3
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { basename, join } from 'node:path';
|
|
5
|
-
import { ensureDir, stripYamlQuotes, toVaultRelative } from './obsidian-common.mjs';
|
|
6
|
-
import { getLocale } from './locale.mjs';
|
|
7
|
-
|
|
8
|
-
export function brainDir(vaultBase) {
|
|
9
|
-
return join(vaultBase, '.brain');
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
// Frontmatter YAML simples: escalares `k: v` + listas `k:` seguido de ` - item`.
|
|
13
|
-
export function parseFrontmatter(content) {
|
|
14
|
-
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
15
|
-
if (!m) return {};
|
|
16
|
-
const data = {};
|
|
17
|
-
const lines = m[1].split('\n');
|
|
18
|
-
for (let i = 0; i < lines.length; i++) {
|
|
19
|
-
const kv = lines[i].match(/^([\w-]+):\s*(.*)$/);
|
|
20
|
-
if (!kv) continue;
|
|
21
|
-
const key = kv[1];
|
|
22
|
-
const val = kv[2];
|
|
23
|
-
if (val === '') {
|
|
24
|
-
const list = [];
|
|
25
|
-
while (i + 1 < lines.length && /^\s+-\s+/.test(lines[i + 1])) {
|
|
26
|
-
list.push(stripYamlQuotes(lines[++i].replace(/^\s+-\s+/, '').trim()));
|
|
27
|
-
}
|
|
28
|
-
data[key] = list.length ? list : '';
|
|
29
|
-
} else {
|
|
30
|
-
data[key] = stripYamlQuotes(val.trim());
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
return data;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function walkMd(dir) {
|
|
37
|
-
const out = [];
|
|
38
|
-
let entries;
|
|
39
|
-
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
40
|
-
for (const e of entries) {
|
|
41
|
-
const fp = join(dir, e.name);
|
|
42
|
-
if (e.isDirectory()) out.push(...walkMd(fp));
|
|
43
|
-
else if (e.name.endsWith('.md')) out.push(fp);
|
|
44
|
-
}
|
|
45
|
-
return out;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const DERIVED_RE = /\[\[(0[456]-[^\]|]+?)(?:\|[^\]]*)?\]\]/g;
|
|
49
|
-
function derivedLinks(content) {
|
|
50
|
-
const dec = new Set(), bug = new Set(), lea = new Set();
|
|
51
|
-
let m;
|
|
52
|
-
while ((m = DERIVED_RE.exec(content))) {
|
|
53
|
-
const t = m[1];
|
|
54
|
-
if (t.startsWith('04-')) dec.add(t);
|
|
55
|
-
else if (t.startsWith('05-')) bug.add(t);
|
|
56
|
-
else if (t.startsWith('06-')) lea.add(t);
|
|
57
|
-
}
|
|
58
|
-
return { decisions: [...dec], bugs: [...bug], learnings: [...lea] };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Varre 02-Sessões/** e regrava .brain/index.jsonl inteiro. Provider-agnóstico.
|
|
62
|
-
export function buildBrainIndex(vaultBase) {
|
|
63
|
-
const rows = [];
|
|
64
|
-
for (const fp of walkMd(join(vaultBase, getLocale(vaultBase).folders.sessions))) {
|
|
65
|
-
let content;
|
|
66
|
-
try { content = readFileSync(fp, 'utf8'); } catch { continue; }
|
|
67
|
-
const fm = parseFrontmatter(content);
|
|
68
|
-
if (fm.type && fm.type !== 'session') continue;
|
|
69
|
-
const der = derivedLinks(content);
|
|
70
|
-
rows.push({
|
|
71
|
-
session_id: fm.session_id || '',
|
|
72
|
-
date: fm.date || '',
|
|
73
|
-
provider: fm.provider || '',
|
|
74
|
-
status: fm.status || '',
|
|
75
|
-
summary: fm.summary || '',
|
|
76
|
-
file: toVaultRelative(vaultBase, fp),
|
|
77
|
-
tags: Array.isArray(fm.tags) ? fm.tags : (fm.tags ? [fm.tags] : []),
|
|
78
|
-
decisions: der.decisions,
|
|
79
|
-
bugs: der.bugs,
|
|
80
|
-
learnings: der.learnings,
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
rows.sort((a, b) => (a.date + a.file).localeCompare(b.date + b.file));
|
|
84
|
-
ensureDir(brainDir(vaultBase));
|
|
85
|
-
const out = rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : '');
|
|
86
|
-
writeFileSync(join(brainDir(vaultBase), 'index.jsonl'), out, 'utf8');
|
|
87
|
-
return rows;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Lê o índice gravado (linhas JSONL). Usado pelo recall e pelo digest.
|
|
91
|
-
export function loadIndex(vaultBase) {
|
|
92
|
-
try {
|
|
93
|
-
return readFileSync(join(brainDir(vaultBase), 'index.jsonl'), 'utf8')
|
|
94
|
-
.split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
95
|
-
} catch {
|
|
96
|
-
return [];
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const DIGEST_CAPS = { decisions: 5, sessions: 4, bugs: 2, learnings: 2 };
|
|
101
|
-
|
|
102
|
-
function adrNumber(path) {
|
|
103
|
-
const m = path.match(/ADR-(\d+)/);
|
|
104
|
-
return m ? Number(m[1]) : -1;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Destila index.jsonl em .brain/DIGEST.md (camada quente, determinístico, 0 token LLM).
|
|
108
|
-
// Cap por construção: 1 header + 13 itens (5/4/2/2) + 1 pointer = máx 15 linhas.
|
|
109
|
-
export function buildBrainDigest(vaultBase, rows = null) {
|
|
110
|
-
const data = rows ?? loadIndex(vaultBase);
|
|
111
|
-
const byDateDesc = [...data].sort((a, b) =>
|
|
112
|
-
String(b.date || '').localeCompare(String(a.date || '')) || String(b.file || '').localeCompare(String(a.file || '')));
|
|
113
|
-
|
|
114
|
-
const seen = new Set();
|
|
115
|
-
const pick = (kind, max) => {
|
|
116
|
-
const out = [];
|
|
117
|
-
for (const r of byDateDesc) {
|
|
118
|
-
for (const p of r[kind] || []) {
|
|
119
|
-
if (out.length >= max) return out;
|
|
120
|
-
if (!seen.has(p)) { seen.add(p); out.push(p); }
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return out;
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
// The digest is INJECTED into every session, so a dead wikilink is dead weight in the model's
|
|
127
|
-
// context. Keep only targets that resolve to a real note (by vault-relative path or basename)
|
|
128
|
-
// and drop placeholder paths (a truncated `…` from a summary line). `pick` collects extra so
|
|
129
|
-
// caps still fill after filtering.
|
|
130
|
-
const known = new Set();
|
|
131
|
-
for (const r of data) {
|
|
132
|
-
const rel = String(r.file || '').replace(/\.md$/i, '');
|
|
133
|
-
if (rel) { known.add(rel); known.add(basename(rel)); }
|
|
134
|
-
}
|
|
135
|
-
const resolves = (p) => {
|
|
136
|
-
const t = String(p || '').replace(/\.md$/i, '').trim();
|
|
137
|
-
if (!t || t.includes('...') || t.includes('…')) return false;
|
|
138
|
-
return known.has(t) || known.has(basename(t)) || existsSync(join(vaultBase, `${t}.md`));
|
|
139
|
-
};
|
|
140
|
-
const pickLive = (kind, max) => pick(kind, max * 4).filter(resolves).slice(0, max);
|
|
141
|
-
|
|
142
|
-
const decisions = pickLive('decisions', DIGEST_CAPS.decisions).sort((a, b) => adrNumber(b) - adrNumber(a));
|
|
143
|
-
const sessions = byDateDesc.slice(0, DIGEST_CAPS.sessions);
|
|
144
|
-
const bugs = pickLive('bugs', DIGEST_CAPS.bugs);
|
|
145
|
-
const learnings = pickLive('learnings', DIGEST_CAPS.learnings);
|
|
146
|
-
|
|
147
|
-
const lines = ['<!-- AUTO-GERADO por brain-core.mjs (0 token LLM). NÃO editar. Rebuild: node .agent/hooks/brain-reindex.mjs -->'];
|
|
148
|
-
for (const d of decisions) lines.push(`- Decisão: [[${d}]]`);
|
|
149
|
-
for (const s of sessions) lines.push(`- Sessão ${s.date} (${s.provider || '?'}): ${s.summary || s.file} → [[${String(s.file || '').replace(/\.md$/, '')}]]`);
|
|
150
|
-
for (const b of bugs) lines.push(`- Bug: [[${b}]]`);
|
|
151
|
-
for (const l of learnings) lines.push(`- Aprendizado: [[${l}]]`);
|
|
152
|
-
|
|
153
|
-
const shown = sessions.length;
|
|
154
|
-
if (data.length > shown) lines.push(`- +${data.length - shown} mais no índice — use /brain-recall <tópico>`);
|
|
155
|
-
|
|
156
|
-
ensureDir(brainDir(vaultBase));
|
|
157
|
-
writeFileSync(join(brainDir(vaultBase), 'DIGEST.md'), lines.join('\n') + '\n', 'utf8');
|
|
158
|
-
return lines;
|
|
159
|
-
}
|
|
1
|
+
// .agent/hooks/brain-core.mjs
|
|
2
|
+
// Camada fria do brain: indexa o frontmatter das notas de sessão (0 token LLM).
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
|
+
import { ensureDir, stripYamlQuotes, toVaultRelative } from './obsidian-common.mjs';
|
|
6
|
+
import { getLocale } from './locale.mjs';
|
|
7
|
+
|
|
8
|
+
export function brainDir(vaultBase) {
|
|
9
|
+
return join(vaultBase, '.brain');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Frontmatter YAML simples: escalares `k: v` + listas `k:` seguido de ` - item`.
|
|
13
|
+
export function parseFrontmatter(content) {
|
|
14
|
+
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
15
|
+
if (!m) return {};
|
|
16
|
+
const data = {};
|
|
17
|
+
const lines = m[1].split('\n');
|
|
18
|
+
for (let i = 0; i < lines.length; i++) {
|
|
19
|
+
const kv = lines[i].match(/^([\w-]+):\s*(.*)$/);
|
|
20
|
+
if (!kv) continue;
|
|
21
|
+
const key = kv[1];
|
|
22
|
+
const val = kv[2];
|
|
23
|
+
if (val === '') {
|
|
24
|
+
const list = [];
|
|
25
|
+
while (i + 1 < lines.length && /^\s+-\s+/.test(lines[i + 1])) {
|
|
26
|
+
list.push(stripYamlQuotes(lines[++i].replace(/^\s+-\s+/, '').trim()));
|
|
27
|
+
}
|
|
28
|
+
data[key] = list.length ? list : '';
|
|
29
|
+
} else {
|
|
30
|
+
data[key] = stripYamlQuotes(val.trim());
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return data;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function walkMd(dir) {
|
|
37
|
+
const out = [];
|
|
38
|
+
let entries;
|
|
39
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
40
|
+
for (const e of entries) {
|
|
41
|
+
const fp = join(dir, e.name);
|
|
42
|
+
if (e.isDirectory()) out.push(...walkMd(fp));
|
|
43
|
+
else if (e.name.endsWith('.md')) out.push(fp);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DERIVED_RE = /\[\[(0[456]-[^\]|]+?)(?:\|[^\]]*)?\]\]/g;
|
|
49
|
+
function derivedLinks(content) {
|
|
50
|
+
const dec = new Set(), bug = new Set(), lea = new Set();
|
|
51
|
+
let m;
|
|
52
|
+
while ((m = DERIVED_RE.exec(content))) {
|
|
53
|
+
const t = m[1];
|
|
54
|
+
if (t.startsWith('04-')) dec.add(t);
|
|
55
|
+
else if (t.startsWith('05-')) bug.add(t);
|
|
56
|
+
else if (t.startsWith('06-')) lea.add(t);
|
|
57
|
+
}
|
|
58
|
+
return { decisions: [...dec], bugs: [...bug], learnings: [...lea] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Varre 02-Sessões/** e regrava .brain/index.jsonl inteiro. Provider-agnóstico.
|
|
62
|
+
export function buildBrainIndex(vaultBase) {
|
|
63
|
+
const rows = [];
|
|
64
|
+
for (const fp of walkMd(join(vaultBase, getLocale(vaultBase).folders.sessions))) {
|
|
65
|
+
let content;
|
|
66
|
+
try { content = readFileSync(fp, 'utf8'); } catch { continue; }
|
|
67
|
+
const fm = parseFrontmatter(content);
|
|
68
|
+
if (fm.type && fm.type !== 'session') continue;
|
|
69
|
+
const der = derivedLinks(content);
|
|
70
|
+
rows.push({
|
|
71
|
+
session_id: fm.session_id || '',
|
|
72
|
+
date: fm.date || '',
|
|
73
|
+
provider: fm.provider || '',
|
|
74
|
+
status: fm.status || '',
|
|
75
|
+
summary: fm.summary || '',
|
|
76
|
+
file: toVaultRelative(vaultBase, fp),
|
|
77
|
+
tags: Array.isArray(fm.tags) ? fm.tags : (fm.tags ? [fm.tags] : []),
|
|
78
|
+
decisions: der.decisions,
|
|
79
|
+
bugs: der.bugs,
|
|
80
|
+
learnings: der.learnings,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
rows.sort((a, b) => (a.date + a.file).localeCompare(b.date + b.file));
|
|
84
|
+
ensureDir(brainDir(vaultBase));
|
|
85
|
+
const out = rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : '');
|
|
86
|
+
writeFileSync(join(brainDir(vaultBase), 'index.jsonl'), out, 'utf8');
|
|
87
|
+
return rows;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Lê o índice gravado (linhas JSONL). Usado pelo recall e pelo digest.
|
|
91
|
+
export function loadIndex(vaultBase) {
|
|
92
|
+
try {
|
|
93
|
+
return readFileSync(join(brainDir(vaultBase), 'index.jsonl'), 'utf8')
|
|
94
|
+
.split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
95
|
+
} catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const DIGEST_CAPS = { decisions: 5, sessions: 4, bugs: 2, learnings: 2 };
|
|
101
|
+
|
|
102
|
+
function adrNumber(path) {
|
|
103
|
+
const m = path.match(/ADR-(\d+)/);
|
|
104
|
+
return m ? Number(m[1]) : -1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Destila index.jsonl em .brain/DIGEST.md (camada quente, determinístico, 0 token LLM).
|
|
108
|
+
// Cap por construção: 1 header + 13 itens (5/4/2/2) + 1 pointer = máx 15 linhas.
|
|
109
|
+
export function buildBrainDigest(vaultBase, rows = null) {
|
|
110
|
+
const data = rows ?? loadIndex(vaultBase);
|
|
111
|
+
const byDateDesc = [...data].sort((a, b) =>
|
|
112
|
+
String(b.date || '').localeCompare(String(a.date || '')) || String(b.file || '').localeCompare(String(a.file || '')));
|
|
113
|
+
|
|
114
|
+
const seen = new Set();
|
|
115
|
+
const pick = (kind, max) => {
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const r of byDateDesc) {
|
|
118
|
+
for (const p of r[kind] || []) {
|
|
119
|
+
if (out.length >= max) return out;
|
|
120
|
+
if (!seen.has(p)) { seen.add(p); out.push(p); }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// The digest is INJECTED into every session, so a dead wikilink is dead weight in the model's
|
|
127
|
+
// context. Keep only targets that resolve to a real note (by vault-relative path or basename)
|
|
128
|
+
// and drop placeholder paths (a truncated `…` from a summary line). `pick` collects extra so
|
|
129
|
+
// caps still fill after filtering.
|
|
130
|
+
const known = new Set();
|
|
131
|
+
for (const r of data) {
|
|
132
|
+
const rel = String(r.file || '').replace(/\.md$/i, '');
|
|
133
|
+
if (rel) { known.add(rel); known.add(basename(rel)); }
|
|
134
|
+
}
|
|
135
|
+
const resolves = (p) => {
|
|
136
|
+
const t = String(p || '').replace(/\.md$/i, '').trim();
|
|
137
|
+
if (!t || t.includes('...') || t.includes('…')) return false;
|
|
138
|
+
return known.has(t) || known.has(basename(t)) || existsSync(join(vaultBase, `${t}.md`));
|
|
139
|
+
};
|
|
140
|
+
const pickLive = (kind, max) => pick(kind, max * 4).filter(resolves).slice(0, max);
|
|
141
|
+
|
|
142
|
+
const decisions = pickLive('decisions', DIGEST_CAPS.decisions).sort((a, b) => adrNumber(b) - adrNumber(a));
|
|
143
|
+
const sessions = byDateDesc.slice(0, DIGEST_CAPS.sessions);
|
|
144
|
+
const bugs = pickLive('bugs', DIGEST_CAPS.bugs);
|
|
145
|
+
const learnings = pickLive('learnings', DIGEST_CAPS.learnings);
|
|
146
|
+
|
|
147
|
+
const lines = ['<!-- AUTO-GERADO por brain-core.mjs (0 token LLM). NÃO editar. Rebuild: node .agent/hooks/brain-reindex.mjs -->'];
|
|
148
|
+
for (const d of decisions) lines.push(`- Decisão: [[${d}]]`);
|
|
149
|
+
for (const s of sessions) lines.push(`- Sessão ${s.date} (${s.provider || '?'}): ${s.summary || s.file} → [[${String(s.file || '').replace(/\.md$/, '')}]]`);
|
|
150
|
+
for (const b of bugs) lines.push(`- Bug: [[${b}]]`);
|
|
151
|
+
for (const l of learnings) lines.push(`- Aprendizado: [[${l}]]`);
|
|
152
|
+
|
|
153
|
+
const shown = sessions.length;
|
|
154
|
+
if (data.length > shown) lines.push(`- +${data.length - shown} mais no índice — use /brain-recall <tópico>`);
|
|
155
|
+
|
|
156
|
+
ensureDir(brainDir(vaultBase));
|
|
157
|
+
writeFileSync(join(brainDir(vaultBase), 'DIGEST.md'), lines.join('\n') + '\n', 'utf8');
|
|
158
|
+
return lines;
|
|
159
|
+
}
|
package/hooks/brain-inject.mjs
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// Injeção da camada quente no SessionStart (Claude/Codex/Copilot): CORE curado +
|
|
3
3
|
// SHARED operacional no v2; DIGEST fica só no fallback legado/recall. Nunca derruba o hook.
|
|
4
4
|
// Uso (hook): node .agent/hooks/brain-inject.mjs (input JSON via stdin)
|
|
5
|
-
import {
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { pathToFileURL } from 'node:url';
|
|
8
|
-
import {
|
|
8
|
+
import { readHookInput, writeHookOutput } from './obsidian-common.mjs';
|
|
9
9
|
import { brainDir } from './brain-core.mjs';
|
|
10
10
|
import { buildActiveChangeInjection, changeCtxState, writeSentinel } from './change-core.mjs';
|
|
11
11
|
import { buildLessonsInjection } from './lessons-core.mjs';
|
|
@@ -13,6 +13,12 @@ import { getLocale } from './locale.mjs';
|
|
|
13
13
|
import { resolveSessionEntry } from './session-identity.mjs';
|
|
14
14
|
import { sanitizeMemoryText, validateSharedMemory } from './memory-schema.mjs';
|
|
15
15
|
import { detectMemoryMode } from './memory-mode.mjs';
|
|
16
|
+
import {
|
|
17
|
+
hookProfilePolicy,
|
|
18
|
+
profileSentinelId,
|
|
19
|
+
resolveHookOperatingProfile,
|
|
20
|
+
} from './operating-profile-runtime.mjs';
|
|
21
|
+
import { assertVaultPathSafe } from './vault-path-safety.mjs';
|
|
16
22
|
import { validateCore } from '../src/validate-core.mjs';
|
|
17
23
|
|
|
18
24
|
// The process ROUTER — the enforcement layer. The wk-* skills are passive files; without a
|
|
@@ -53,9 +59,27 @@ const INJECTION_LIMITS = Object.freeze({
|
|
|
53
59
|
recallBytes: 512,
|
|
54
60
|
});
|
|
55
61
|
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
function aliasBoundaryError(error) {
|
|
63
|
+
return error?.code === 'VAULT_PATH_UNSAFE'
|
|
64
|
+
&& /link simbólico|junction|reparse|hardlink|nlink|redirecion|escapa logicamente/i
|
|
65
|
+
.test(String(error?.message || error));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readMemoryFile(vaultBase, name) {
|
|
69
|
+
const path = join(brainDir(vaultBase), name);
|
|
70
|
+
const label = `camada de memória ${name}`;
|
|
71
|
+
try {
|
|
72
|
+
let checked = assertVaultPathSafe(vaultBase, path, { expectedType: 'file', label });
|
|
73
|
+
if (!checked.exists) return '';
|
|
74
|
+
checked = assertVaultPathSafe(vaultBase, checked.target, {
|
|
75
|
+
allowMissing: false, expectedType: 'file', label,
|
|
76
|
+
});
|
|
77
|
+
return readFileSync(checked.target, 'utf8').replace(/\r\n/g, '\n').trim();
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (aliasBoundaryError(error)) throw error;
|
|
81
|
+
return '';
|
|
82
|
+
}
|
|
59
83
|
}
|
|
60
84
|
|
|
61
85
|
function byteLength(value) {
|
|
@@ -78,6 +102,17 @@ function safeError(layer, reasons) {
|
|
|
78
102
|
return `<wk_memory_error layer="${layer}" repair="wendkeep memory status --gate">${visible}</wk_memory_error>`;
|
|
79
103
|
}
|
|
80
104
|
|
|
105
|
+
export function profileRuntimeError(diagnostic) {
|
|
106
|
+
if (!diagnostic) return '';
|
|
107
|
+
const code = diagnostic.code || 'WENDKEEP_PROFILE_RESOLUTION_ERROR';
|
|
108
|
+
const detail = sanitizeMemoryText(diagnostic.message || String(diagnostic))
|
|
109
|
+
.replace(/\s+/g, ' ')
|
|
110
|
+
.trim();
|
|
111
|
+
const max = 320;
|
|
112
|
+
const visible = detail.length <= max ? detail : `${detail.slice(0, max - 20)} … [erro resumido]`;
|
|
113
|
+
return `<wk_profile_error code="${xmlAttr(code)}" fallback="GOVERN" repair="wendkeep doctor">${visible}</wk_profile_error>`;
|
|
114
|
+
}
|
|
115
|
+
|
|
81
116
|
function validateCoreLayer(raw) {
|
|
82
117
|
if (!raw) return { ok: false, rendered: safeError('core', ['CORE.md ausente']) };
|
|
83
118
|
const sanitized = sanitizeMemoryText(raw);
|
|
@@ -105,9 +140,9 @@ function validateSharedLayer(raw) {
|
|
|
105
140
|
: { ok: false, rendered: safeError('shared', validation.errors), metadata: validation.metadata };
|
|
106
141
|
}
|
|
107
142
|
|
|
108
|
-
function buildV2Memory(
|
|
109
|
-
const core = validateCoreLayer(readMemoryFile(
|
|
110
|
-
const shared = validateSharedLayer(readMemoryFile(
|
|
143
|
+
function buildV2Memory(vaultBase) {
|
|
144
|
+
const core = validateCoreLayer(readMemoryFile(vaultBase, 'CORE.md'));
|
|
145
|
+
const shared = validateSharedLayer(readMemoryFile(vaultBase, 'SHARED_MEMORY.md'));
|
|
111
146
|
const revision = shared.metadata?.revision ?? 'unknown';
|
|
112
147
|
const stateHash = shared.metadata?.state_hash ?? 'unknown';
|
|
113
148
|
const attention = [core, shared].every((layer) => layer.ok)
|
|
@@ -156,10 +191,10 @@ function validateLegacyLayer(raw, layer, { maxLines, maxBytes }) {
|
|
|
156
191
|
return errors.length ? safeError(layer.toLowerCase(), errors) : sanitized;
|
|
157
192
|
}
|
|
158
193
|
|
|
159
|
-
function buildLegacyMemory(
|
|
160
|
-
const coreRaw = readMemoryFile(
|
|
194
|
+
function buildLegacyMemory(vaultBase) {
|
|
195
|
+
const coreRaw = readMemoryFile(vaultBase, 'CORE.md');
|
|
161
196
|
const coreValidation = coreRaw ? validateCoreLayer(coreRaw) : { ok: true, rendered: '' };
|
|
162
|
-
const digest = validateLegacyLayer(readMemoryFile(
|
|
197
|
+
const digest = validateLegacyLayer(readMemoryFile(vaultBase, 'DIGEST.md'), 'DIGEST', { maxLines: 15, maxBytes: 4096 });
|
|
163
198
|
const pointer = 'Memória profunda sob demanda: /brain-recall <tópico> (índice .brain/index.jsonl).';
|
|
164
199
|
return [
|
|
165
200
|
'<brain_memory>',
|
|
@@ -185,24 +220,34 @@ function budgetNotice(priority, layer, message) {
|
|
|
185
220
|
return `<wk_budget_notice priority="${priority}" layer="${layer}">${message}</wk_budget_notice>`;
|
|
186
221
|
}
|
|
187
222
|
|
|
188
|
-
export function buildInjection(vaultBase, input = {}) {
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
223
|
+
export function buildInjection(vaultBase, input = {}, { profile = 'GOVERN', bindingError = null } = {}) {
|
|
224
|
+
const brain = detectMemoryMode(vaultBase).mode === 'v2'
|
|
225
|
+
? buildV2Memory(vaultBase)
|
|
226
|
+
: buildLegacyMemory(vaultBase);
|
|
227
|
+
const policy = hookProfilePolicy(profile);
|
|
228
|
+
const lessons = buildLessonsInjection(vaultBase, { maxLineChars: INJECTION_LIMITS.lineChars });
|
|
229
|
+
const profileNotice = profileRuntimeError(bindingError);
|
|
230
|
+
|
|
231
|
+
// OFF keeps only the persistent project memory layers. No process/change/gate state crosses
|
|
232
|
+
// the vault -> harness boundary in this profile.
|
|
233
|
+
if (!policy.harness) return joinInjection([brain, profileNotice, lessons]);
|
|
234
|
+
|
|
235
|
+
// FLOW executes and validates without demanding a change. Explicitly opened changes remain
|
|
236
|
+
// visible below, but the standing change router would contradict that contract.
|
|
237
|
+
const router = policy.requiresChange ? processRouter(getLocale(vaultBase).id) : '';
|
|
192
238
|
const { identity, entry } = resolveSessionEntry(vaultBase, input);
|
|
193
239
|
const focus = identity.state === 'resolved' && entry?.change_slug
|
|
194
240
|
? `<session_change>${boundAncillaryText(`Change vinculada a esta sessão: ${entry.change_slug}. Este vínculo prevalece para writes automáticos; todas as pendências continuam visíveis acima.`, '<session_change></session_change>'.length)}</session_change>`
|
|
195
241
|
: '';
|
|
196
242
|
const allChanges = buildActiveChangeInjection(vaultBase, { maxLineChars: INJECTION_LIMITS.lineChars });
|
|
197
|
-
const lessons = buildLessonsInjection(vaultBase, { maxLineChars: INJECTION_LIMITS.lineChars });
|
|
198
243
|
|
|
199
244
|
// Global priority is deterministic: memory/router/focus, then changes, then lessons.
|
|
200
|
-
let output = joinInjection([brain, router, focus, allChanges, lessons]);
|
|
245
|
+
let output = joinInjection([brain, profileNotice, router, focus, allChanges, lessons]);
|
|
201
246
|
if (byteLength(output) <= INJECTION_LIMITS.totalBytes) return output;
|
|
202
247
|
|
|
203
248
|
// First pressure step: lessons are fully removable and remain available in the vault.
|
|
204
249
|
const lessonsEvicted = budgetNotice(1, 'lessons', 'Lessons omitidas primeiro pelo budget global.');
|
|
205
|
-
output = joinInjection([brain, router, focus, allChanges, lessonsEvicted]);
|
|
250
|
+
output = joinInjection([brain, profileNotice, router, focus, allChanges, lessonsEvicted]);
|
|
206
251
|
if (byteLength(output) <= INJECTION_LIMITS.totalBytes) return output;
|
|
207
252
|
|
|
208
253
|
// Second pressure step: non-current changes leave the hot context before the current one.
|
|
@@ -211,12 +256,12 @@ export function buildInjection(vaultBase, input = {}) {
|
|
|
211
256
|
currentOnly: true,
|
|
212
257
|
maxLineChars: INJECTION_LIMITS.lineChars,
|
|
213
258
|
});
|
|
214
|
-
output = joinInjection([brain, router, focus, currentChange, lessonsEvicted, nonCurrentEvicted]);
|
|
259
|
+
output = joinInjection([brain, profileNotice, router, focus, currentChange, lessonsEvicted, nonCurrentEvicted]);
|
|
215
260
|
if (byteLength(output) <= INJECTION_LIMITS.totalBytes) return output;
|
|
216
261
|
|
|
217
262
|
// Last step caps only the current change block, with an explicit marker and closed wrapper.
|
|
218
263
|
const currentSummarized = budgetNotice(3, 'current-change', 'Change atual resumida por último; blocker e início da fila foram preservados.');
|
|
219
|
-
const fixed = joinInjection([brain, router, focus, lessonsEvicted, nonCurrentEvicted, currentSummarized]);
|
|
264
|
+
const fixed = joinInjection([brain, profileNotice, router, focus, lessonsEvicted, nonCurrentEvicted, currentSummarized]);
|
|
220
265
|
const remaining = Math.max(512, INJECTION_LIMITS.totalBytes - byteLength(fixed) - 1);
|
|
221
266
|
const boundedCurrent = buildActiveChangeInjection(vaultBase, {
|
|
222
267
|
currentOnly: true,
|
|
@@ -225,6 +270,7 @@ export function buildInjection(vaultBase, input = {}) {
|
|
|
225
270
|
});
|
|
226
271
|
return joinInjection([
|
|
227
272
|
brain,
|
|
273
|
+
profileNotice,
|
|
228
274
|
router,
|
|
229
275
|
focus,
|
|
230
276
|
lessonsEvicted,
|
|
@@ -237,23 +283,34 @@ export function buildInjection(vaultBase, input = {}) {
|
|
|
237
283
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
238
284
|
try {
|
|
239
285
|
const input = readHookInput();
|
|
240
|
-
const
|
|
286
|
+
const runtime = resolveHookOperatingProfile({ input });
|
|
287
|
+
const vaultBase = runtime.vaultBase;
|
|
241
288
|
writeHookOutput({
|
|
242
289
|
hookSpecificOutput: {
|
|
243
290
|
hookEventName: 'SessionStart',
|
|
244
|
-
additionalContext: buildInjection(vaultBase, input
|
|
291
|
+
additionalContext: buildInjection(vaultBase, input, {
|
|
292
|
+
profile: runtime.profile,
|
|
293
|
+
bindingError: runtime.bindingError,
|
|
294
|
+
}),
|
|
245
295
|
},
|
|
246
296
|
});
|
|
247
297
|
// Sentinela do change-context: o backlog completo acabou de ser injetado aqui, então o hook
|
|
248
298
|
// UserPromptSubmit não precisa re-pingar no 1º prompt. Bônus — nunca derruba a injeção.
|
|
249
|
-
try {
|
|
299
|
+
if (runtime.policy.harness && !runtime.bindingError) try {
|
|
250
300
|
const st = changeCtxState(vaultBase);
|
|
251
|
-
const { identity } =
|
|
301
|
+
const { identity } = runtime;
|
|
252
302
|
const sid = identity.state === 'resolved' ? identity.canonicalConversationId : (input.session_id || input.sessionId || '');
|
|
253
|
-
if (st)
|
|
303
|
+
if (st) {
|
|
304
|
+
writeSentinel(vaultBase, 'ctx', profileSentinelId(sid, runtime.profile), st.hash);
|
|
305
|
+
}
|
|
254
306
|
} catch { /* sentinela é bônus */ }
|
|
255
307
|
} catch (error) {
|
|
256
308
|
process.stderr.write(`[brain] inject falhou: ${error.message}\n`);
|
|
257
|
-
writeHookOutput({
|
|
309
|
+
writeHookOutput({
|
|
310
|
+
hookSpecificOutput: {
|
|
311
|
+
hookEventName: 'SessionStart',
|
|
312
|
+
additionalContext: profileRuntimeError(error),
|
|
313
|
+
},
|
|
314
|
+
});
|
|
258
315
|
}
|
|
259
316
|
}
|
package/hooks/brain-recall.mjs
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
// .agent/hooks/brain-recall.mjs
|
|
2
|
-
// Query engine read-only: pontua o índice por tópico. Token só no resultado.
|
|
3
|
-
// Uso: node .agent/hooks/brain-recall.mjs <termos da busca>
|
|
4
|
-
import { pathToFileURL } from 'node:url';
|
|
5
|
-
import { getVaultBase } from './obsidian-common.mjs';
|
|
6
|
-
import { loadIndex } from './brain-core.mjs';
|
|
7
|
-
|
|
8
|
-
export { loadIndex };
|
|
9
|
-
|
|
10
|
-
export function scoreRows(rows, query, topK = 5) {
|
|
11
|
-
const terms = String(query).toLowerCase().split(/\s+/).filter(Boolean);
|
|
12
|
-
if (!terms.length) return [];
|
|
13
|
-
return rows
|
|
14
|
-
.map((r) => {
|
|
15
|
-
// Inclui slug do file + paths das derivadas (ADR/bug/aprendizado) no haystack:
|
|
16
|
-
// os títulos das sessões e tags são genéricos; o sinal tópico vem dos slugs.
|
|
17
|
-
const hay = `${r.summary || ''} ${(r.tags || []).join(' ')} ${r.file || ''} ${(r.decisions || []).join(' ')} ${(r.bugs || []).join(' ')} ${(r.learnings || []).join(' ')}`.toLowerCase();
|
|
18
|
-
let score = 0;
|
|
19
|
-
for (const t of terms) if (hay.includes(t)) score++;
|
|
20
|
-
return { row: r, score };
|
|
21
|
-
})
|
|
22
|
-
.filter((s) => s.score > 0)
|
|
23
|
-
.sort((a, b) => b.score - a.score || String(b.row.date || '').localeCompare(String(a.row.date || '')))
|
|
24
|
-
.slice(0, topK)
|
|
25
|
-
.map((s) => s.row);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
29
|
-
const vaultBase = getVaultBase();
|
|
30
|
-
const hits = scoreRows(loadIndex(vaultBase), process.argv.slice(2).join(' '));
|
|
31
|
-
process.stdout.write(JSON.stringify(hits, null, 2) + '\n');
|
|
32
|
-
}
|
|
1
|
+
// .agent/hooks/brain-recall.mjs
|
|
2
|
+
// Query engine read-only: pontua o índice por tópico. Token só no resultado.
|
|
3
|
+
// Uso: node .agent/hooks/brain-recall.mjs <termos da busca>
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { getVaultBase } from './obsidian-common.mjs';
|
|
6
|
+
import { loadIndex } from './brain-core.mjs';
|
|
7
|
+
|
|
8
|
+
export { loadIndex };
|
|
9
|
+
|
|
10
|
+
export function scoreRows(rows, query, topK = 5) {
|
|
11
|
+
const terms = String(query).toLowerCase().split(/\s+/).filter(Boolean);
|
|
12
|
+
if (!terms.length) return [];
|
|
13
|
+
return rows
|
|
14
|
+
.map((r) => {
|
|
15
|
+
// Inclui slug do file + paths das derivadas (ADR/bug/aprendizado) no haystack:
|
|
16
|
+
// os títulos das sessões e tags são genéricos; o sinal tópico vem dos slugs.
|
|
17
|
+
const hay = `${r.summary || ''} ${(r.tags || []).join(' ')} ${r.file || ''} ${(r.decisions || []).join(' ')} ${(r.bugs || []).join(' ')} ${(r.learnings || []).join(' ')}`.toLowerCase();
|
|
18
|
+
let score = 0;
|
|
19
|
+
for (const t of terms) if (hay.includes(t)) score++;
|
|
20
|
+
return { row: r, score };
|
|
21
|
+
})
|
|
22
|
+
.filter((s) => s.score > 0)
|
|
23
|
+
.sort((a, b) => b.score - a.score || String(b.row.date || '').localeCompare(String(a.row.date || '')))
|
|
24
|
+
.slice(0, topK)
|
|
25
|
+
.map((s) => s.row);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
29
|
+
const vaultBase = getVaultBase();
|
|
30
|
+
const hits = scoreRows(loadIndex(vaultBase), process.argv.slice(2).join(' '));
|
|
31
|
+
process.stdout.write(JSON.stringify(hits, null, 2) + '\n');
|
|
32
|
+
}
|
package/hooks/brain-reindex.mjs
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
// .agent/hooks/brain-reindex.mjs
|
|
2
|
-
// Backfill manual: reconstrói .brain/index.jsonl + .brain/DIGEST.md varrendo todo 02-Sessões.
|
|
3
|
-
// Uso: node .agent/hooks/brain-reindex.mjs [caminho-do-vault]
|
|
4
|
-
import { pathToFileURL } from 'node:url';
|
|
5
|
-
import { getVaultBase } from './obsidian-common.mjs';
|
|
6
|
-
import { buildBrainDigest, buildBrainIndex, brainDir } from './brain-core.mjs';
|
|
7
|
-
|
|
8
|
-
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
9
|
-
const vaultBase = getVaultBase({ obsidian_vault_path: process.argv[2] });
|
|
10
|
-
const rows = buildBrainIndex(vaultBase);
|
|
11
|
-
const digest = buildBrainDigest(vaultBase, rows);
|
|
12
|
-
process.stdout.write(`[brain] index: ${rows.length} sessões; digest: ${digest.length} linhas → ${brainDir(vaultBase)}\n`);
|
|
13
|
-
}
|
|
1
|
+
// .agent/hooks/brain-reindex.mjs
|
|
2
|
+
// Backfill manual: reconstrói .brain/index.jsonl + .brain/DIGEST.md varrendo todo 02-Sessões.
|
|
3
|
+
// Uso: node .agent/hooks/brain-reindex.mjs [caminho-do-vault]
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { getVaultBase } from './obsidian-common.mjs';
|
|
6
|
+
import { buildBrainDigest, buildBrainIndex, brainDir } from './brain-core.mjs';
|
|
7
|
+
|
|
8
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
9
|
+
const vaultBase = getVaultBase({ obsidian_vault_path: process.argv[2] });
|
|
10
|
+
const rows = buildBrainIndex(vaultBase);
|
|
11
|
+
const digest = buildBrainDigest(vaultBase, rows);
|
|
12
|
+
process.stdout.write(`[brain] index: ${rows.length} sessões; digest: ${digest.length} linhas → ${brainDir(vaultBase)}\n`);
|
|
13
|
+
}
|