wendkeep 0.1.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/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # wendkeep
2
+
3
+ **Automatically capture your AI coding agent sessions as local Markdown in your Obsidian vault.** Every Claude Code / Codex session is written turn‑by‑turn into your vault — with token/cost tracking, auto‑extracted decisions, bugs and learnings, and a curated memory layer injected back at the start of the next session. 100% local, open‑core.
4
+
5
+ > Status: `v0.1` — extracted from a system in daily production use. The capture engine, cost tracking and graph wiring are battle‑tested; the cross‑platform installer (`wendkeep init`) is the new part. See [`docs/`](docs/) for the project's strategy and decision log.
6
+
7
+ ---
8
+
9
+ ## Why
10
+
11
+ The pieces to give a coding agent durable memory exist, but fragmented (qmd‑sessions, memsearch, Nexus, hand‑written hooks). wendkeep ships them as one turnkey package that lands the memory **inside the Obsidian graph you already use** — no manual setup, no snapshot to keep in sync.
12
+
13
+ ## What you get
14
+
15
+ - **Automatic session capture** — `SessionStart` / `UserPromptSubmit` / `Stop` hooks write each session to `02-Sessões/<year>/<month>/DIA <dd>/` as Markdown with YAML frontmatter, turn‑by‑turn iterations, and wikilinks.
16
+ - **Multi‑agent** — detects the real provider (Claude Code, Codex, Copilot) at runtime; one install covers all.
17
+ - **Token & cost tracking** — per‑model, cache‑aware pricing (`pricing.json`) with a per‑session usage table.
18
+ - **Auto‑extracted derived notes** — decisions (ADR‑style), bugs and learnings pulled from the transcript into `04-Decisões/`, `05-Bugs/`, `06-Aprendizados/`, backlinked to the session.
19
+ - **Curated memory** — a cold frontmatter index (`.brain/`) plus a budget‑capped `CORE` + `DIGEST` injected at the next `SessionStart`.
20
+ - **Local‑first** — everything is plain Markdown on your disk. The optional MCP server (`@bitbonsai/mcpvault`) lets the agent read/write the vault; no cloud, no account.
21
+
22
+ ## Requirements
23
+
24
+ - Node.js ≥ 18
25
+ - An AI coding agent with hooks (Claude Code today; Codex supported by the same hooks)
26
+ - Obsidian (to view the graph) — optional but the point
27
+
28
+ ## Install & set up
29
+
30
+ ```bash
31
+ # in your project
32
+ npm install --save-dev wendkeep # or: npm install -g wendkeep
33
+ npx wendkeep init
34
+ ```
35
+
36
+ `wendkeep init` is interactive and **idempotent**. It will:
37
+
38
+ 1. Create the vault folder taxonomy (default vault: `<project>/.wendkeep-vault`, override with `--vault`).
39
+ 2. **Merge** the three session hooks and `OBSIDIAN_VAULT_PATH` into `.claude/settings.json` — without clobbering your existing settings (a `.bak` is saved; an unparseable file is left untouched and a `.new` is written for you to merge).
40
+ 3. Add the `wendkeep-vault` MCP server to `.mcp.json` (skip with `--no-mcp`).
41
+
42
+ ```bash
43
+ npx wendkeep init --vault "~/vaults/work" --project . --yes # non-interactive
44
+ ```
45
+
46
+ Then open the vault in Obsidian, send a test prompt in your agent, and confirm a note appears under `02-Sessões/…`.
47
+
48
+ ## Updating
49
+
50
+ Because the hooks live inside the installed package (settings.json calls `npx wendkeep hook <name>`), upgrading is just:
51
+
52
+ ```bash
53
+ npm update wendkeep
54
+ ```
55
+
56
+ No re‑copying, no snapshot to re‑sync — the package is the single source of truth.
57
+
58
+ ## Commands
59
+
60
+ | Command | What it does |
61
+ |---|---|
62
+ | `wendkeep init` | Set up wendkeep in a project (vault taxonomy + settings + MCP). |
63
+ | `wendkeep hook <name>` | Run a session hook; invoked by `settings.json` (reads agent JSON on stdin). |
64
+ | `wendkeep doctor [--vault P]` | Run a vault health check (integrity of sessions, registry, links). |
65
+ | `wendkeep --version` / `--help` | Version / usage. |
66
+
67
+ ## How it works
68
+
69
+ ```
70
+ agent session ──hooks──▶ wendkeep ──▶ Markdown in vault ──▶ .brain index + Obsidian graph
71
+ (Claude/Codex) (Node) (02-Sessões/…) (CORE+DIGEST, backlinks)
72
+ ```
73
+
74
+ The agent's settings.json points each hook at `npx wendkeep hook …`. On `Stop`, wendkeep parses the session transcript, appends the turn, updates the token/cost table, and (idempotently) emits any decision/bug/learning notes. On the next `SessionStart`, the curated memory is injected back into context.
75
+
76
+ ## Known limitations (`v0.1`)
77
+
78
+ - **Vault folder names and month labels are Portuguese** (`02-Sessões`, `04-Decisões`, …), hardcoded in the hooks. Internationalization is not done yet — the taxonomy `wendkeep init` creates matches what the hooks expect on purpose.
79
+ - **Search is keyword/frontmatter scoring**, not on‑device embeddings (that's on the roadmap).
80
+ - **Transcript formats are agent‑internal** and can change between agent versions; parsing is isolated but may need updates.
81
+ - Installer wires **Claude Code** settings + `.mcp.json`. Codex hooks run on the same scripts but are not auto‑wired yet.
82
+
83
+ ## License
84
+
85
+ MIT
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ // wendkeep CLI — single entrypoint.
3
+ // wendkeep init [--vault <path>] [--project <path>] [--no-mcp] [--yes] [--force]
4
+ // wendkeep hook <name> (invoked by the agent's settings.json; pipes stdin/stdout)
5
+ // wendkeep doctor [--vault <path>]
6
+ // wendkeep --version | --help
7
+ import { spawnSync } from 'node:child_process';
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { RUNNABLE_HOOKS } from '../src/taxonomy.mjs';
12
+
13
+ const here = dirname(fileURLToPath(import.meta.url));
14
+ const pkgRoot = join(here, '..');
15
+ const hooksDir = join(pkgRoot, 'hooks');
16
+
17
+ function version() {
18
+ try {
19
+ return JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')).version;
20
+ } catch {
21
+ return '0.0.0';
22
+ }
23
+ }
24
+
25
+ const HELP = `wendkeep ${version()} — capture AI coding agent sessions into your Obsidian vault.
26
+
27
+ Usage:
28
+ wendkeep init [options] Set up wendkeep in a project (cross-platform).
29
+ --vault <path> Obsidian vault folder (default: <project>/.wendkeep-vault).
30
+ --project <path> Project root to wire (default: current directory).
31
+ --no-mcp Do not add the mcpvault MCP server to .mcp.json.
32
+ --yes, -y Non-interactive; accept defaults.
33
+ --force Overwrite existing wendkeep config blocks.
34
+
35
+ wendkeep hook <name> Run a session hook (used by settings.json). Reads the
36
+ agent's JSON on stdin. Names: ${RUNNABLE_HOOKS.join(', ')}.
37
+
38
+ wendkeep doctor [--vault P] Run a vault health check.
39
+ wendkeep --version Print version.
40
+ wendkeep --help Show this help.
41
+ `;
42
+
43
+ function runHook(name) {
44
+ if (!name) {
45
+ process.stderr.write('wendkeep hook: missing hook name\n');
46
+ process.exit(2);
47
+ }
48
+ if (!RUNNABLE_HOOKS.includes(name)) {
49
+ process.stderr.write(`wendkeep hook: unknown hook "${name}". Known: ${RUNNABLE_HOOKS.join(', ')}\n`);
50
+ process.exit(2);
51
+ }
52
+ const file = join(hooksDir, `${name}.mjs`);
53
+ if (!existsSync(file)) {
54
+ process.stderr.write(`wendkeep hook: hook file not found: ${file}\n`);
55
+ process.exit(2);
56
+ }
57
+ // Spawn exactly as the agent would run `node <hook>.mjs`: stdio inherited so the
58
+ // hook's stdin (agent JSON) and stdout (hookSpecificOutput) pass through untouched.
59
+ const r = spawnSync(process.execPath, [file], { stdio: 'inherit' });
60
+ process.exit(r.status ?? 0);
61
+ }
62
+
63
+ async function main() {
64
+ const [cmd, ...rest] = process.argv.slice(2);
65
+ switch (cmd) {
66
+ case 'init': {
67
+ const { runInit } = await import('../src/init.mjs');
68
+ await runInit(rest);
69
+ break;
70
+ }
71
+ case 'hook':
72
+ runHook(rest[0]);
73
+ break;
74
+ case 'doctor': {
75
+ const { runDoctor } = await import('../src/doctor.mjs');
76
+ runDoctor(rest);
77
+ break;
78
+ }
79
+ case '--version':
80
+ case '-v':
81
+ process.stdout.write(`${version()}\n`);
82
+ break;
83
+ case undefined:
84
+ case '--help':
85
+ case '-h':
86
+ case 'help':
87
+ process.stdout.write(HELP);
88
+ break;
89
+ default:
90
+ process.stderr.write(`wendkeep: unknown command "${cmd}"\n\n${HELP}`);
91
+ process.exit(2);
92
+ }
93
+ }
94
+
95
+ main().catch((err) => {
96
+ process.stderr.write(`wendkeep: ${err?.stack || err}\n`);
97
+ process.exit(1);
98
+ });
@@ -0,0 +1,142 @@
1
+ // .agent/hooks/brain-core.mjs
2
+ // Camada fria do brain: indexa o frontmatter das notas de sessão (0 token LLM).
3
+ import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { ensureDir, stripYamlQuotes, toVaultRelative } from './obsidian-common.mjs';
6
+
7
+ export function brainDir(vaultBase) {
8
+ return join(vaultBase, '.brain');
9
+ }
10
+
11
+ // Frontmatter YAML simples: escalares `k: v` + listas `k:` seguido de ` - item`.
12
+ export function parseFrontmatter(content) {
13
+ const m = content.match(/^---\n([\s\S]*?)\n---/);
14
+ if (!m) return {};
15
+ const data = {};
16
+ const lines = m[1].split('\n');
17
+ for (let i = 0; i < lines.length; i++) {
18
+ const kv = lines[i].match(/^([\w-]+):\s*(.*)$/);
19
+ if (!kv) continue;
20
+ const key = kv[1];
21
+ const val = kv[2];
22
+ if (val === '') {
23
+ const list = [];
24
+ while (i + 1 < lines.length && /^\s+-\s+/.test(lines[i + 1])) {
25
+ list.push(stripYamlQuotes(lines[++i].replace(/^\s+-\s+/, '').trim()));
26
+ }
27
+ data[key] = list.length ? list : '';
28
+ } else {
29
+ data[key] = stripYamlQuotes(val.trim());
30
+ }
31
+ }
32
+ return data;
33
+ }
34
+
35
+ function walkMd(dir) {
36
+ const out = [];
37
+ let entries;
38
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
39
+ for (const e of entries) {
40
+ const fp = join(dir, e.name);
41
+ if (e.isDirectory()) out.push(...walkMd(fp));
42
+ else if (e.name.endsWith('.md')) out.push(fp);
43
+ }
44
+ return out;
45
+ }
46
+
47
+ const DERIVED_RE = /\[\[(0[456]-[^\]|]+?)(?:\|[^\]]*)?\]\]/g;
48
+ function derivedLinks(content) {
49
+ const dec = new Set(), bug = new Set(), lea = new Set();
50
+ let m;
51
+ while ((m = DERIVED_RE.exec(content))) {
52
+ const t = m[1];
53
+ if (t.startsWith('04-')) dec.add(t);
54
+ else if (t.startsWith('05-')) bug.add(t);
55
+ else if (t.startsWith('06-')) lea.add(t);
56
+ }
57
+ return { decisions: [...dec], bugs: [...bug], learnings: [...lea] };
58
+ }
59
+
60
+ // Varre 02-Sessões/** e regrava .brain/index.jsonl inteiro. Provider-agnóstico.
61
+ export function buildBrainIndex(vaultBase) {
62
+ const rows = [];
63
+ for (const fp of walkMd(join(vaultBase, '02-Sessões'))) {
64
+ let content;
65
+ try { content = readFileSync(fp, 'utf8'); } catch { continue; }
66
+ const fm = parseFrontmatter(content);
67
+ if (fm.type && fm.type !== 'session') continue;
68
+ const der = derivedLinks(content);
69
+ rows.push({
70
+ session_id: fm.session_id || '',
71
+ date: fm.date || '',
72
+ provider: fm.provider || '',
73
+ status: fm.status || '',
74
+ summary: fm.summary || '',
75
+ file: toVaultRelative(vaultBase, fp),
76
+ tags: Array.isArray(fm.tags) ? fm.tags : (fm.tags ? [fm.tags] : []),
77
+ decisions: der.decisions,
78
+ bugs: der.bugs,
79
+ learnings: der.learnings,
80
+ });
81
+ }
82
+ rows.sort((a, b) => (a.date + a.file).localeCompare(b.date + b.file));
83
+ ensureDir(brainDir(vaultBase));
84
+ const out = rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : '');
85
+ writeFileSync(join(brainDir(vaultBase), 'index.jsonl'), out, 'utf8');
86
+ return rows;
87
+ }
88
+
89
+ // Lê o índice gravado (linhas JSONL). Usado pelo recall e pelo digest.
90
+ export function loadIndex(vaultBase) {
91
+ try {
92
+ return readFileSync(join(brainDir(vaultBase), 'index.jsonl'), 'utf8')
93
+ .split('\n').filter(Boolean).map((l) => JSON.parse(l));
94
+ } catch {
95
+ return [];
96
+ }
97
+ }
98
+
99
+ const DIGEST_CAPS = { decisions: 5, sessions: 4, bugs: 2, learnings: 2 };
100
+
101
+ function adrNumber(path) {
102
+ const m = path.match(/ADR-(\d+)/);
103
+ return m ? Number(m[1]) : -1;
104
+ }
105
+
106
+ // Destila index.jsonl em .brain/DIGEST.md (camada quente, determinístico, 0 token LLM).
107
+ // Cap por construção: 1 header + 13 itens (5/4/2/2) + 1 pointer = máx 15 linhas.
108
+ export function buildBrainDigest(vaultBase, rows = null) {
109
+ const data = rows ?? loadIndex(vaultBase);
110
+ const byDateDesc = [...data].sort((a, b) =>
111
+ String(b.date || '').localeCompare(String(a.date || '')) || String(b.file || '').localeCompare(String(a.file || '')));
112
+
113
+ const seen = new Set();
114
+ const pick = (kind, max) => {
115
+ const out = [];
116
+ for (const r of byDateDesc) {
117
+ for (const p of r[kind] || []) {
118
+ if (out.length >= max) return out;
119
+ if (!seen.has(p)) { seen.add(p); out.push(p); }
120
+ }
121
+ }
122
+ return out;
123
+ };
124
+
125
+ const decisions = pick('decisions', DIGEST_CAPS.decisions).sort((a, b) => adrNumber(b) - adrNumber(a));
126
+ const sessions = byDateDesc.slice(0, DIGEST_CAPS.sessions);
127
+ const bugs = pick('bugs', DIGEST_CAPS.bugs);
128
+ const learnings = pick('learnings', DIGEST_CAPS.learnings);
129
+
130
+ const lines = ['<!-- AUTO-GERADO por brain-core.mjs (0 token LLM). NÃO editar. Rebuild: node .agent/hooks/brain-reindex.mjs -->'];
131
+ for (const d of decisions) lines.push(`- Decisão: [[${d}]]`);
132
+ for (const s of sessions) lines.push(`- Sessão ${s.date} (${s.provider || '?'}): ${s.summary || s.file} → [[${String(s.file || '').replace(/\.md$/, '')}]]`);
133
+ for (const b of bugs) lines.push(`- Bug: [[${b}]]`);
134
+ for (const l of learnings) lines.push(`- Aprendizado: [[${l}]]`);
135
+
136
+ const shown = sessions.length;
137
+ if (data.length > shown) lines.push(`- +${data.length - shown} mais no índice — use /brain-recall <tópico>`);
138
+
139
+ ensureDir(brainDir(vaultBase));
140
+ writeFileSync(join(brainDir(vaultBase), 'DIGEST.md'), lines.join('\n') + '\n', 'utf8');
141
+ return lines;
142
+ }
@@ -0,0 +1,42 @@
1
+ // .agent/hooks/brain-inject.mjs
2
+ // Injeção da camada quente no SessionStart (Claude/Codex/Copilot): CORE curado +
3
+ // DIGEST auto + 1-linha pointer do recall. Budget-capada. Nunca derruba o hook.
4
+ // Uso (hook): node .agent/hooks/brain-inject.mjs (input JSON via stdin)
5
+ import { readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.mjs';
9
+ import { brainDir } from './brain-core.mjs';
10
+
11
+ const MAX_LINES = 45; // CORE ≤25 + DIGEST ≤15 + folga; salvaguarda se o CORE crescer à mão
12
+
13
+ export function buildInjection(vaultBase) {
14
+ const dir = brainDir(vaultBase);
15
+ const read = (name) => {
16
+ try { return readFileSync(join(dir, name), 'utf8').trim(); } catch { return ''; }
17
+ };
18
+ const pointer = 'Memória profunda sob demanda: /brain-recall <tópico> (índice .brain/index.jsonl).';
19
+ // Quando CORE e DIGEST não existem, ''.split('\n') vira [''] — o filter derruba essa
20
+ // linha vazia para o caso "só pointer" ficar com exatamente 3 linhas.
21
+ let lines = [read('CORE.md'), read('DIGEST.md')].filter(Boolean).join('\n\n').split('\n').filter((l, i, a) => a.length > 1 || l);
22
+ if (lines.length > MAX_LINES) {
23
+ lines = lines.slice(0, MAX_LINES);
24
+ lines.push('*…truncado pelo budget — fonte completa: .brain/CORE.md + .brain/DIGEST.md*');
25
+ }
26
+ return ['<brain_memory>', ...lines, pointer, '</brain_memory>'].join('\n');
27
+ }
28
+
29
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
30
+ try {
31
+ const input = readHookInput();
32
+ writeHookOutput({
33
+ hookSpecificOutput: {
34
+ hookEventName: 'SessionStart',
35
+ additionalContext: buildInjection(getVaultBase(input)),
36
+ },
37
+ });
38
+ } catch (error) {
39
+ process.stderr.write(`[brain] inject falhou: ${error.message}\n`);
40
+ writeHookOutput({});
41
+ }
42
+ }
@@ -0,0 +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
+ }
@@ -0,0 +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
+ }