wendkeep 0.66.0 → 0.66.3

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 CHANGED
@@ -4,6 +4,42 @@ All notable changes to **wendkeep** are documented here. Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.66.3] — 2026-07-30
8
+
9
+ ### Fixed
10
+
11
+ - **O replay reavalia candidates transitórios contra a fonte moderna final.** Um Stop da mesma
12
+ sessão/activation/epoch e turno maior agora avança depois que a correção causal já presente se
13
+ torna ativa; turno menor fica superseded e divergências reais continuam para curadoria.
14
+ - **`memory repair` migra o checkpoint antigo somente com prova e CAS.** O repair compara a
15
+ semântica anterior e a atual, faz backup, atualiza attempt e espelho e registra auditoria sem
16
+ reordenar, reescrever ou acrescentar evento ao ledger. Prova incompleta continua bloqueada. A
17
+ 0.66.2 não deve ser publicada no npm; publique e instale a 0.66.3.
18
+
19
+ ## [0.66.2] — 2026-07-29
20
+
21
+ ### Fixed
22
+
23
+ - **Promoção preserva valor e identidade causal do evento escolhido.** `memory promote` não
24
+ converte mais objetos/arrays JSON em texto e mantém sessão, activation/epoch, turno de origem e
25
+ sequência; o próximo `SessionStop` da mesma cadeia avança o handoff sem recriar candidate.
26
+ - **A recuperação 0.66.1 cobre inversão física/temporal sem ampliar autoridade.** Quando uma
27
+ promoção legada projetada fica fora do candidate por `observed_at`, `memory promote` só a inclui
28
+ em `supersedes` sob prova de ancestralidade e mesma linhagem; fonte moderna ou alheia falha antes
29
+ de anexar. O E2E percorre o dispatcher público e prova Stop posterior e duplicado idempotentes.
30
+
31
+ ## [0.66.1] — 2026-07-29
32
+
33
+ ### Fixed
34
+
35
+ - **A curadoria de candidates agora é durável e idempotente.** `memory promote` e
36
+ `memory reject` registram decisões causais no ledger; repair/replay não recriam o conflito
37
+ resolvido, retries não duplicam eventos, candidates sobrepostos permanecem isolados e a
38
+ promoção recupera o checkpoint divergente do attempt correspondente sem tocar attempt novo.
39
+ - **Promoção de conflito exige escolha explícita.** `memory promote <candidate> --event
40
+ <event-id>` publica exatamente o evento escolhido, rejeita IDs externos ao candidate e não
41
+ permite que `blocked_by_core` sobrescreva a autoridade canônica de CORE.
42
+
7
43
  ## [0.66.0] — 2026-07-29
8
44
 
9
45
  ### Added
package/README.en.md CHANGED
@@ -256,7 +256,7 @@ Hot memory now separates human authorship, operational state, and evidence:
256
256
  - **`CORE.md` is canonical.** It is the short, hand-curated nucleus for durable preferences, active patterns, and open issues; no projector may infer or overwrite it.
257
257
  - **`SHARED_MEMORY.md` is generated operational state.** The `Stop` hook turns the session handoff into sanitized events; the projector deterministically reduces the ledger and publishes a verifiable revision, cursor, and hash. Facts are `verified` only with local evidence; unsupported reports remain `reported`, and disagreements become candidates for human judgment.
258
258
  - **`MEMORY_EVENTS.jsonl` is the append-only authority.** `Stop` makes events durable in the outbox before acknowledging the attempt; the projector runs outside the registry lock and retries reuse the same IDs. Repeating an identical `event_id`/payload is a no-op; reusing the ID with different bytes is observable corruption.
259
- - **`MEMORY_CANDIDATES.jsonl` is the curation queue.** Conflicts and legacy content are never silently promoted. `promote` and `reject` record the decision as a new event.
259
+ - **`MEMORY_CANDIDATES.jsonl` is the curation queue.** Conflicts and legacy content are never silently promoted. `promote` and `reject` record the decision as a new event; promotion preserves the selected event's JSON type, session, activation/epoch, and source turn.
260
260
 
261
261
  Artifacts stay under `.brain/` only. Sanitization strips secrets, tokens, local paths, transcripts, and harness payloads both before persistence and before injection. Events carry a `project_id`, and one vault never accepts another project's events.
262
262
 
@@ -294,7 +294,18 @@ backup/audit; divergent mirrors fail closed. A demonstrably superseded
294
294
  ambiguity uses `memory reconcile <session> --by-session <successor>
295
295
  --reason <reason>` as a dry run and requires `--apply`; the decision is backed up and audited
296
296
  without rewriting ledger, CORE, or notes. Run `status --gate` again afterwards. Conflicts require
297
- explicit curation with `memory promote <id>` or `memory reject <id>`; doctor only diagnoses.
297
+ explicit, durable curation: `memory promote <id> --event <event-id>` selects one event from the
298
+ candidate, while `memory reject <id>` keeps the current value. The decision is idempotent, and a
299
+ new promotion accepts a later Stop from the same session/activation without recreating a conflict.
300
+ A promotion written by 0.66.1 remains historical: if the next Stop forms a new candidate, update
301
+ to 0.66.3 and run `memory repair`. During replay, a transient candidate is re-evaluated against the
302
+ final modern source: the same session/activation/epoch and a higher turn advances; a lower turn is
303
+ superseded. Repair migrates the checkpoint and mirror only when it proves the exact previous
304
+ replay, attempt identity, and absence of a real conflict; it creates a backup and audit without
305
+ appending or rewriting events. Ambiguity stays queued for explicit curation. Do not publish or
306
+ install 0.66.2; use 0.66.3. Decisions survive repair/replay;
307
+ `blocked_by_core` cannot override CORE. Doctor only
308
+ diagnoses. See [memory and curation](docs/en/commands/memory.md).
298
309
 
299
310
  Session notes use one live `## Agentes, tokens e custos` snapshot. Main-agent and subagent hooks recompose it atomically, with costs, token dimensions, reasoning tokens and effort per model/source. Every hook that rewrites a session note takes a per-file lock and writes through a temp file + rename, so the `SubagentStop` fan-out (one hook run per subagent) can never leave a note half-written; a note whose frontmatter reads back damaged is left untouched rather than patched.
300
311
 
package/README.md CHANGED
@@ -256,7 +256,7 @@ Hot memory now separates human authorship, operational state, and evidence:
256
256
  - **`CORE.md` is canonical.** It is the short, hand-curated nucleus for durable preferences, active patterns, and open issues; no projector may infer or overwrite it.
257
257
  - **`SHARED_MEMORY.md` is generated operational state.** The `Stop` hook turns the session handoff into sanitized events; the projector deterministically reduces the ledger and publishes a verifiable revision, cursor, and hash. Facts are `verified` only with local evidence; unsupported reports remain `reported`, and disagreements become candidates for human judgment.
258
258
  - **`MEMORY_EVENTS.jsonl` is the append-only authority.** `Stop` makes events durable in the outbox before acknowledging the attempt; the projector runs outside the registry lock and retries reuse the same IDs. Repeating an identical `event_id`/payload is a no-op; reusing the ID with different bytes is observable corruption.
259
- - **`MEMORY_CANDIDATES.jsonl` is the curation queue.** Conflicts and legacy content are never silently promoted. `promote` and `reject` record the decision as a new event.
259
+ - **`MEMORY_CANDIDATES.jsonl` is the curation queue.** Conflicts and legacy content are never silently promoted. `promote` and `reject` record the decision as a new event; promotion preserves the selected event's JSON type, session, activation/epoch, and source turn.
260
260
 
261
261
  Artifacts stay under `.brain/` only. Sanitization strips secrets, tokens, local paths, transcripts, and harness payloads both before persistence and before injection. Events carry a `project_id`, and one vault never accepts another project's events.
262
262
 
@@ -294,7 +294,18 @@ backup/audit; divergent mirrors fail closed. A demonstrably superseded
294
294
  ambiguity uses `memory reconcile <session> --by-session <successor>
295
295
  --reason <reason>` as a dry run and requires `--apply`; the decision is backed up and audited
296
296
  without rewriting ledger, CORE, or notes. Run `status --gate` again afterwards. Conflicts require
297
- explicit curation with `memory promote <id>` or `memory reject <id>`; doctor only diagnoses.
297
+ explicit, durable curation: `memory promote <id> --event <event-id>` selects one event from the
298
+ candidate, while `memory reject <id>` keeps the current value. The decision is idempotent, and a
299
+ new promotion accepts a later Stop from the same session/activation without recreating a conflict.
300
+ A promotion written by 0.66.1 remains historical: if the next Stop forms a new candidate, update
301
+ to 0.66.3 and run `memory repair`. During replay, a transient candidate is re-evaluated against the
302
+ final modern source: the same session/activation/epoch and a higher turn advances; a lower turn is
303
+ superseded. Repair migrates the checkpoint and mirror only when it proves the exact previous
304
+ replay, attempt identity, and absence of a real conflict; it creates a backup and audit without
305
+ appending or rewriting events. Ambiguity stays queued for explicit curation. Do not publish or
306
+ install 0.66.2; use 0.66.3. Decisions survive repair/replay;
307
+ `blocked_by_core` cannot override CORE. Doctor only
308
+ diagnoses. See [memory and curation](docs/en/commands/memory.md).
298
309
 
299
310
  Session notes use one live `## Agentes, tokens e custos` snapshot. Main-agent and subagent hooks recompose it atomically, with costs, token dimensions, reasoning tokens and effort per model/source. Every hook that rewrites a session note takes a per-file lock and writes through a temp file + rename, so the `SubagentStop` fan-out (one hook run per subagent) can never leave a note half-written; a note whose frontmatter reads back damaged is left untouched rather than patched.
300
311
 
@@ -64,7 +64,8 @@ to revision 1. Replaying that prompt or Stop does not duplicate the event/revisi
64
64
  - Partial/corrupt v2 bundle: use status and repair; migration is not a corruption tool.
65
65
  - First post-migration Stop is `ambiguous`: verify that its `turn_id` belongs to the transcript and
66
66
  that `UserPromptSubmit` opened/advanced the recovery activation.
67
- - Many candidates: curate gradually with `memory promote`/`memory reject`.
67
+ - Many candidates: curate gradually with `memory promote`/`memory reject`; conflicts require
68
+ `memory promote <candidate> --event <event-id>` to choose the winner explicitly.
68
69
  - Legacy warning remains after apply: verify the selected vault and project binding.
69
70
 
70
71
  ## Next steps
@@ -26,7 +26,7 @@ Pass the vault explicitly in automation. Preserve backups and evidence before re
26
26
  npx wendkeep memory status [--gate] --vault <vault>
27
27
  npx wendkeep memory repair --vault <vault>
28
28
  npx wendkeep memory reconcile <ambiguous-session> --by-session <successor-session> --reason <reason> [--apply] --vault <vault>
29
- npx wendkeep memory promote <candidate> --vault <vault>
29
+ npx wendkeep memory promote <candidate> [--event <event-id>] --vault <vault>
30
30
  npx wendkeep memory reject <candidate> --vault <vault>
31
31
  npx wendkeep validate-memory [CORE-path]
32
32
  npx wendkeep validate-memory --vault <v2-vault>
@@ -63,7 +63,22 @@ npx wendkeep validate-memory --vault <v2-vault>
63
63
  Junctions, symlinks, reparse points, or hardlinks fail closed without touching external bytes.
64
64
  Locks publish owner and lease atomically, never reap a live PID by age alone, and release only
65
65
  the lease they acquired.
66
- - `promote`/`reject` append auditable decisions and never rewrite the ledger in place.
66
+ - `promote`/`reject` append an auditable, idempotent decision to the ledger. Replay and repair
67
+ preserve that decision and do not recreate the resolved candidate. For a `conflict` candidate,
68
+ `promote` requires an `--event <event-id>` that belongs to the candidate; date or random ID
69
+ never picks an implicit winner. `reject` preserves the current operational value. A
70
+ `blocked_by_core` candidate can only be rejected: promotion first requires canonical CORE
71
+ curation. If the selected event still belongs to the matching latest `projected` attempt,
72
+ promotion also refreshes its checkpoint and mirror causally; JSON reports
73
+ `checkpointRefreshed`, and a newer concurrent attempt remains untouched. The decision keeps
74
+ the already validated JSON value without string coercion and copies the selected event's
75
+ `canonical_session_id`, activation/epoch, `source_turn_id`, and `turn_sequence`. A later Stop
76
+ from the same session/activation therefore advances the value instead of opening another candidate.
77
+ During replay, a transient candidate is re-evaluated against the final modern source. The same
78
+ session/activation/epoch and a higher turn applies the Stop; a lower turn is superseded. A
79
+ different, incomplete, or ambiguous identity keeps the candidate queued for curation. `memory
80
+ repair` compares the old and current replay and migrates checkpoint+mirror only with exact
81
+ identity, backup, audit, and CAS; it does not reorder, rewrite, or append a ledger event.
67
82
  - `validate-memory <CORE.md>` checks the 25-line cap, required sections, and secrets.
68
83
  - `validate-memory --vault` requires a complete v2 bundle and is not the legacy-vault gate.
69
84
 
@@ -74,7 +89,8 @@ npx wendkeep memory status --gate --vault .MyApp-vault
74
89
  npx wendkeep memory reconcile old --by-session current --reason "delivery continued" --vault .MyApp-vault
75
90
  npx wendkeep memory reconcile old --by-session current --reason "delivery continued" --apply --vault .MyApp-vault
76
91
  npx wendkeep validate-memory .MyApp-vault/.brain/CORE.md
77
- npx wendkeep memory promote candidate-123 --vault .MyApp-vault
92
+ npx wendkeep memory promote candidate-123 --event mem-selected --vault .MyApp-vault
93
+ npx wendkeep memory reject candidate-456 --vault .MyApp-vault
78
94
  ```
79
95
 
80
96
  ## Expected result
@@ -97,6 +113,15 @@ of a global projection that has already advanced with concurrent events.
97
113
  `memory reconcile` dry run before authorizing `--apply`; the command fails when the ambiguous
98
114
  attempt already contains event IDs.
99
115
  - Ordinary pending candidate: recoverable warning, requiring human choice when appropriate.
116
+ - `promote` reports that `--event` is required: inspect the candidate `event_ids`, compare their
117
+ provenance/value, and name the winner explicitly. An ID outside the candidate fails without
118
+ mutating the ledger or projections.
119
+ - A promotion made by 0.66.1 followed by a transient candidate: update to 0.66.3, preserve a backup,
120
+ and run `memory repair`. Do not publish or install 0.66.2. Repair migrates the checkpoint only
121
+ when the old replay, attempt identity, and new event match exactly; a real conflict stays blocked
122
+ for a human `promote`/`reject` choice.
123
+ - `promote` says that the candidate no longer matches the causal projection: no event was appended.
124
+ Run `memory status`, inspect the current candidate again, and do not force a different lineage.
100
125
  - Missing `event_cursor` or mismatched v2 hash: preserve the bundle and assess `memory repair`.
101
126
  - `validate-memory --vault` fails on legacy: validate CORE only or migrate first.
102
127
 
@@ -64,7 +64,8 @@ revision 1. Repetir esse prompt ou Stop não duplica evento/revision.
64
64
  - Bundle v2 parcial/corrompido: use status e repair; migração não é ferramenta de corrupção.
65
65
  - Primeiro Stop pós-migração fica `ambiguous`: confirme que o `turn_id` pertence ao transcript e
66
66
  que `UserPromptSubmit` abriu/avançou a activation de recuperação.
67
- - Candidates numerosos: curate gradualmente com `memory promote`/`memory reject`.
67
+ - Candidates numerosos: curate gradualmente com `memory promote`/`memory reject`; conflitos
68
+ exigem `memory promote <candidate> --event <event-id>` para escolher o vencedor explicitamente.
68
69
  - Warning legado após apply: confirme o vault efetivamente selecionado e o vínculo do projeto.
69
70
 
70
71
  ## Próximos passos
@@ -26,7 +26,7 @@ Informe o vault explicitamente em automações. Preserve backups e evidências a
26
26
  npx wendkeep memory status [--gate] --vault <cofre>
27
27
  npx wendkeep memory repair --vault <cofre>
28
28
  npx wendkeep memory reconcile <sessão-ambígua> --by-session <sessão-sucessora> --reason <motivo> [--apply] --vault <cofre>
29
- npx wendkeep memory promote <candidate> --vault <cofre>
29
+ npx wendkeep memory promote <candidate> [--event <event-id>] --vault <cofre>
30
30
  npx wendkeep memory reject <candidate> --vault <cofre>
31
31
  npx wendkeep validate-memory [caminho-do-CORE]
32
32
  npx wendkeep validate-memory --vault <cofre-v2>
@@ -61,7 +61,21 @@ npx wendkeep validate-memory --vault <cofre-v2>
61
61
  candidates, registry, notas, backups, temporários e sidecars antes de ler ou escrever. Junction,
62
62
  symlink, reparse point ou hardlink falham fechados sem tocar bytes externos. Locks publicam owner
63
63
  e lease atomicamente, não colhem PID vivo apenas por idade e só liberam a lease adquirida.
64
- - `promote`/`reject` acrescentam decisão auditável; nunca reescrevem o ledger no lugar.
64
+ - `promote`/`reject` acrescentam uma decisão auditável e idempotente ao ledger. Replay e repair
65
+ preservam a decisão e não recriam o candidate resolvido. Para candidate `conflict`, `promote`
66
+ exige `--event <event-id>` pertencente ao candidate; não há vencedor implícito por data ou ID.
67
+ `reject` preserva o valor operacional atual. Candidate `blocked_by_core` só pode ser rejeitado:
68
+ promover exige antes alterar CORE pela curadoria canônica. Se o evento escolhido ainda pertence
69
+ ao último attempt `projected` correspondente, a promoção também atualiza causalmente checkpoint
70
+ e espelho; o JSON retorna `checkpointRefreshed`, e um attempt concorrente mais novo não é tocado.
71
+ A decisão conserva, sem coerção para string, o valor JSON já validado e copia do evento escolhido
72
+ `canonical_session_id`, activation/epoch, `source_turn_id` e `turn_sequence`. Por isso, um Stop
73
+ posterior da mesma sessão/activation avança o valor em vez de abrir outro candidate. Durante o
74
+ replay, um candidate transitório é reavaliado contra a fonte moderna final. Mesma
75
+ sessão/activation/epoch e turno maior aplica o Stop; turno menor fica superseded. Identidade
76
+ divergente, incompleta ou ambígua mantém o candidate para curadoria. `memory repair` compara o
77
+ replay anterior e o atual e só migra checkpoint+espelho com identidade exata, backup, audit e
78
+ CAS; ele não reordena, reescreve nem acrescenta evento ao ledger.
65
79
  - `validate-memory <CORE.md>` valida cap de 25 linhas, seções e segredos.
66
80
  - `validate-memory --vault` exige bundle v2 completo; não é o gate correto para vault legado.
67
81
 
@@ -72,7 +86,8 @@ npx wendkeep memory status --gate --vault .MeuApp-vault
72
86
  npx wendkeep memory reconcile antiga --by-session atual --reason "entrega continuada" --vault .MeuApp-vault
73
87
  npx wendkeep memory reconcile antiga --by-session atual --reason "entrega continuada" --apply --vault .MeuApp-vault
74
88
  npx wendkeep validate-memory .MeuApp-vault/.brain/CORE.md
75
- npx wendkeep memory promote candidate-123 --vault .MeuApp-vault
89
+ npx wendkeep memory promote candidate-123 --event mem-escolhido --vault .MeuApp-vault
90
+ npx wendkeep memory reject candidate-456 --vault .MeuApp-vault
76
91
  ```
77
92
 
78
93
  ## Resultado esperado
@@ -94,6 +109,15 @@ prefixo válido de uma projeção global que já avançou com eventos concorrent
94
109
  ambiguidade for comprovadamente substituída por uma sessão sucessora, revise o dry-run de
95
110
  `memory reconcile` antes de autorizar `--apply`; o comando falha se o attempt ambíguo tiver IDs.
96
111
  - Candidate pendente comum: warning recuperável, exige decisão humana quando apropriado.
112
+ - `promote` informa que `--event` é obrigatório: leia os `event_ids` do candidate, compare a
113
+ proveniência/valor e indique explicitamente o vencedor. ID que não pertence ao candidate falha
114
+ sem mutar ledger ou projeções.
115
+ - Promoção feita pela 0.66.1 seguida de candidate transitório: atualize para a 0.66.3, preserve um
116
+ backup e rode `memory repair`. Não publique nem instale a 0.66.2. O repair só migra o checkpoint
117
+ quando o replay anterior, a identidade do attempt e o evento novo coincidem exatamente; conflito
118
+ real continua bloqueado para escolha humana com `promote`/`reject`.
119
+ - `promote` informa que o candidate não corresponde mais à projeção causal: nenhum evento foi
120
+ anexado. Rode `memory status`, releia o candidate atual e não force uma linhagem diferente.
97
121
  - `event_cursor` ausente ou hash divergente em v2: preserve o bundle e avalie `memory repair`.
98
122
  - `validate-memory --vault` falha no legado: valide apenas CORE ou migre primeiro.
99
123
 
@@ -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
+ }
@@ -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
+ }