wendkeep 0.34.1 → 0.36.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 CHANGED
@@ -4,6 +4,24 @@ 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.35.0] — 2026-07-11
8
+
9
+ ### Fixed
10
+
11
+ - **Wikilinks para changes arquivadas não quebram mais.** `archive` e `abandon` movem a pasta
12
+ para `_arquivo/<data>-<slug>/` — e todo wikilink gravado ANTES do move (sessões fechadas,
13
+ decisões, outras changes) morria, aparecendo cinza no grafo (visto em produção). Agora o move
14
+ **reescreve os wikilinks no vault inteiro** (`[[08-Mudanças/<slug>/…]]` →
15
+ `[[08-Mudanças/_arquivo/<data>-<slug>/…]]`, full-path com e sem alias; nunca por basename —
16
+ `proposta`/`design` existem em toda change). Fail-quiet: a reescrita nunca derruba o archive.
17
+
18
+ ### Added
19
+
20
+ - **`wendkeep change relink [--apply] [--json]`** — cura retroativa para vaults com links já
21
+ mortos (changes arquivadas antes da 0.35.0): mapeia cada slug morto para o dir datado em
22
+ `_arquivo/` e reescreve. Dry-run por default; slug ambíguo (arquivado 2×) é reportado e pulado
23
+ — nunca chuta; sem archive correspondente vira aviso.
24
+
7
25
  ## [0.34.1] — 2026-07-11
8
26
 
9
27
  ### Fixed
@@ -852,3 +870,9 @@ Initial release — the capture engine, extracted from a system in daily product
852
870
  [0.2.7]: https://www.npmjs.com/package/wendkeep/v/0.2.7
853
871
  [0.2.0]: https://www.npmjs.com/package/wendkeep/v/0.2.0
854
872
  [0.1.0]: https://www.npmjs.com/package/wendkeep/v/0.1.0
873
+ # 0.36.0 — 2026-07-11
874
+
875
+ - Custos de subagents agora são atribuídos ao modelo que realmente os executou por meio de um ledger por sessão; campos legados continuam compatíveis.
876
+ - Novo `wendkeep cost rebuild`, dry-run por padrão, reconstrói sessões antigas via `SESSION_REGISTRY` e, com `--apply`, grava um manifesto auditável em `.brain/COST_REBUILD.json`.
877
+ - Totais combinados de tokens/custo passam a ser persistidos explicitamente nas notas.
878
+ - Preços API-equivalentes de GPT-5.6 Sol, Terra e Luna adicionados à tabela editável e ao fallback interno.
package/README.md CHANGED
@@ -118,6 +118,7 @@ No re‑copying, no snapshot to re‑sync — the package is the single source o
118
118
  | `wendkeep sensors <sub>` | `list` / `add <id> "<command>"` — view/edit `wendkeep.sensors.json` (JSON Schema shipped). |
119
119
  | `wendkeep cost [--since d]` | Aggregate AI-coding spend across the vault's sessions — total, by model, by day (`--json`). |
120
120
  | `wendkeep import [opts]` | **Retroactive memory** — backfill past **Claude + Codex** sessions into the vault (deduped by `session_id`). `--source all\|claude\|codex` / `--from <dir>` / `--codex-from <dir>` / `--since d` / `--limit n` / `--dry-run` / `--json`. |
121
+ | `wendkeep cost rebuild [opts]` | Recalcula custos históricos do transcript principal e subagents usando `SESSION_REGISTRY`. Dry-run por padrão; `--apply` atualiza notas e grava `.brain/COST_REBUILD.json`. Aceita `--session`, `--limit` e `--json`. |
121
122
  | `wendkeep lesson add "t" "l"` | Record a project-local lesson (injected at the next SessionStart). |
122
123
  | `wendkeep sync-defs` | Copy `.brain/agents\|skills` into `.codex/agents`, `.claude/skills`, `.agents/skills`; `--check` detects drift. |
123
124
  | `wendkeep validate-memory [path]` | Validate `.brain/CORE.md` (cap 25, 3 sections, no secrets/PII). |
package/README.pt-BR.md CHANGED
@@ -115,6 +115,7 @@ Sem recopiar, sem snapshot pra re‑sincronizar — o pacote é a única fonte d
115
115
  | `wendkeep spec <sub>` | Specs vivos: `list` / `show <capability>`. |
116
116
  | `wendkeep sensors <sub>` | `list` / `add <id> "<comando>"` — vê/edita `wendkeep.sensors.json` (JSON Schema incluso). |
117
117
  | `wendkeep cost [opts]` | Agrega o gasto de IA nas sessões do cofre — total, por modelo, por dia · `--top [N]` · `--trend [day\|week\|month]` (+ projeção) · `--write` (gera `00-Custo.md`) · `--json`. |
118
+ | `wendkeep cost rebuild [opts]` | Reconstrói custos históricos do transcript principal e subagents via `SESSION_REGISTRY`. Dry-run por padrão; `--apply` grava notas e `.brain/COST_REBUILD.json`. |
118
119
  | `wendkeep stats [--vault P]` | Uma linha compartilhável: sessões · prompts · gasto · período · modelos (`--json`). |
119
120
  | `wendkeep import [opts]` | **Memória retroativa** — importa sessões passadas de **Claude + Codex** pro cofre (dedup por `session_id`). `--source all\|claude\|codex` / `--from <dir>` / `--codex-from <dir>` / `--stamp-ids` / `--since d` / `--limit n` / `--dry-run` / `--json`. |
120
121
  | `wendkeep dashboard [--force]` | (Re)gera os Bases filtrados por pasta + o MOC `00-Dashboard`. |
package/bin/wendkeep.mjs CHANGED
@@ -51,6 +51,9 @@ Usage:
51
51
  wendkeep cost [opts] Aggregate AI-coding spend across the vault's sessions.
52
52
  --since <date> · --top [N] (priciest) · --trend [day|week|month]
53
53
  (+ run-rate projection) · --write (generate 00-Custo.md) · --json.
54
+ wendkeep cost rebuild Recalculate historical parent + subagent costs from SESSION_REGISTRY.
55
+ Dry-run by default · --apply writes notes + .brain/COST_REBUILD.json
56
+ · --session <id|file> · --limit N · --json.
54
57
  wendkeep stats [--vault P] One shareable line: sessions · prompts · spend · span · models (--json).
55
58
  wendkeep import [opts] Backfill: import this project's past Claude + Codex sessions into
56
59
  the vault (deduped by session_id). --source all|claude|codex (default
@@ -381,12 +381,6 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
381
381
  const loc = getLocale(vaultBase);
382
382
  const chDir = loc.folders.changes;
383
383
  const src = join(vaultBase, chDir, slug);
384
- let sourceSessionRel = '';
385
- try {
386
- const proposal = readFileSync(join(src, 'proposta.md'), 'utf8');
387
- const m = proposal.match(/\[\[((?:02-Sessões|02-Sessions)\/[^\]|]+?)(?:\|[^\]]+)?\]\]/);
388
- if (m) sourceSessionRel = m[1].endsWith('.md') ? m[1] : `${m[1]}.md`;
389
- } catch { /* sem source */ }
390
384
  const verdict = gate(src);
391
385
  if (!verdict.ok) return { ok: false, failing: verdict.failing || [] };
392
386
 
@@ -441,16 +435,10 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
441
435
  writeFileSync(pp, c, 'utf8');
442
436
  } catch { /* proposta ilegível — segue */ }
443
437
 
444
- // A sessão guardava o link da change ativa; após o move, reescreva para o caminho arquivado.
445
- if (sourceSessionRel) {
446
- try {
447
- const sessionPath = join(vaultBase, sourceSessionRel);
448
- const oldLink = wikilinkFromRel(join(chDir, slug, 'proposta'));
449
- const archivedLink = wikilinkFromRel(join(destRel, 'proposta'));
450
- const current = readFileSync(sessionPath, 'utf8');
451
- if (current.includes(oldLink)) writeFileSync(sessionPath, current.replaceAll(oldLink, archivedLink), 'utf8');
452
- } catch { /* backlink é reparo auxiliar; archive já está íntegro */ }
453
- }
438
+ // O move quebrava TODO wikilink gravado antes (sessões fechadas, decisões, outras changes
439
+ // links cinza no grafo, visto em produção). Reescreve vault-wide; fail-quiet.
440
+ let linksRewritten = 0;
441
+ try { linksRewritten = rewriteChangeLinks(vaultBase, `${chDir}/${slug}`, destRel.replaceAll('\\', '/')); } catch { /* archive já íntegro */ }
454
442
 
455
443
  // ADR goes in the same dated month folder as session-derived decisions (04-Decisões/ano/MM-MMM/)
456
444
  // — not the year root — so all ADRs sit together in the vault's convention.
@@ -485,7 +473,88 @@ Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}${forcedNo
485
473
  // Only clear the pointer when the archived change IS the active one — archiving some other
486
474
  // slug explicitly must not blank the pointer of a different, still-active change.
487
475
  if (activeChange(vaultBase) === slug) clearActiveChange(vaultBase);
488
- return { ok: true, failing: [], archivedRel: destRel, adrRel, promoted, specWarnings };
476
+ return { ok: true, failing: [], archivedRel: destRel, adrRel, promoted, specWarnings, linksRewritten };
477
+ }
478
+
479
+ // --- reescrita de wikilinks pós-move (0.35.0) ----------------------------------
480
+ // Todo .md do vault (inclui .brain e _arquivo — uma change arquivada pode linkar outra).
481
+ function allVaultMarkdown(vaultBase) {
482
+ const out = [];
483
+ const skip = new Set(['.git', '.obsidian', 'node_modules']);
484
+ const walk = (dir) => {
485
+ let entries;
486
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
487
+ for (const e of entries) {
488
+ if (skip.has(e.name)) continue;
489
+ if (e.name.startsWith('.') && e.name !== '.brain') continue;
490
+ const abs = join(dir, e.name);
491
+ if (e.isDirectory()) walk(abs);
492
+ else if (e.name.endsWith('.md')) out.push(abs);
493
+ }
494
+ };
495
+ walk(vaultBase);
496
+ return out;
497
+ }
498
+
499
+ // Reescreve `[[fromRel/...]]`, `[[fromRel]]` e `[[fromRel|alias]]` em todo o vault.
500
+ // NUNCA por basename: `proposta`/`design` existem em toda change — só full-path é seguro.
501
+ function rewriteChangeLinks(vaultBase, fromRel, toRel) {
502
+ let touched = 0;
503
+ for (const abs of allVaultMarkdown(vaultBase)) {
504
+ let content;
505
+ try { content = readFileSync(abs, 'utf8'); } catch { continue; }
506
+ const next = content
507
+ .split(`[[${fromRel}/`).join(`[[${toRel}/`)
508
+ .split(`[[${fromRel}]]`).join(`[[${toRel}]]`)
509
+ .split(`[[${fromRel}|`).join(`[[${toRel}|`);
510
+ if (next !== content) {
511
+ try { writeFileSync(abs, next, 'utf8'); touched += 1; } catch { /* nota readonly — segue */ }
512
+ }
513
+ }
514
+ return touched;
515
+ }
516
+
517
+ // Cura retroativa (vaults pré-0.35): wikilinks para changes que já moveram sem reescrita.
518
+ // Dry-run por default; match por slug no nome datado do archive (`<data>-<slug>[-abandonada]`);
519
+ // ambíguo (mesmo slug arquivado 2×) é reportado e pulado — nunca chuta.
520
+ export function relinkChanges(vaultBase, { apply = false } = {}) {
521
+ const chDir = getLocale(vaultBase).folders.changes;
522
+ const archiveAbs = join(vaultBase, chDir, ARCHIVE_DIR);
523
+ let archived = [];
524
+ try { archived = readdirSync(archiveAbs, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); } catch { /* sem arquivo */ }
525
+ const slugOf = (name) => name.replace(/^\d{4}-\d{2}-\d{2}-/, '').replace(/-abandonada$/, '');
526
+
527
+ // Slugs mortos referenciados em algum .md: [[<chDir>/<seg>/...]] | [[<chDir>/<seg>]] | [[...|
528
+ const linkRe = new RegExp(`\\[\\[${chDir.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}/([^/\\]|]+)(?=[/\\]|])`, 'g');
529
+ const files = allVaultMarkdown(vaultBase);
530
+ const dead = new Set();
531
+ for (const abs of files) {
532
+ let content;
533
+ try { content = readFileSync(abs, 'utf8'); } catch { continue; }
534
+ for (const m of content.matchAll(linkRe)) {
535
+ const seg = m[1];
536
+ if (seg === ARCHIVE_DIR) continue;
537
+ if (!existsSync(join(vaultBase, chDir, seg, 'proposta.md'))) dead.add(seg);
538
+ }
539
+ }
540
+
541
+ const rewritten = [];
542
+ const ambiguous = [];
543
+ const orphans = [];
544
+ const renames = [];
545
+ for (const seg of dead) {
546
+ const matches = archived.filter((name) => slugOf(name) === seg);
547
+ if (matches.length === 1) renames.push({ from: `${chDir}/${seg}`, to: `${chDir}/${ARCHIVE_DIR}/${matches[0]}` });
548
+ else if (matches.length > 1) ambiguous.push(`${seg} → ${matches.join(', ')}`);
549
+ else orphans.push(seg);
550
+ }
551
+
552
+ let filesTouched = 0;
553
+ if (apply) {
554
+ for (const r of renames) filesTouched += rewriteChangeLinks(vaultBase, r.from, r.to);
555
+ }
556
+ rewritten.push(...renames);
557
+ return { applied: apply, scanned: files.length, filesTouched, rewritten, ambiguous, orphans };
489
558
  }
490
559
 
491
560
  // Abandono (0.31.0): a saída legítima para uma change que não vai adiante — o que antes só o
@@ -504,6 +573,8 @@ export function abandonChange(vaultBase, slug, { dateStr }) {
504
573
  const pp = join(destAbs, 'proposta.md');
505
574
  writeFileSync(pp, readFileSync(pp, 'utf8').replace(/^status:\s*active\s*$/m, 'status: abandoned'), 'utf8');
506
575
  } catch { /* proposta sem frontmatter — segue */ }
576
+ let linksRewritten = 0;
577
+ try { linksRewritten = rewriteChangeLinks(vaultBase, `${chDir}/${slug}`, destRel.replaceAll('\\', '/')); } catch { /* abandono já íntegro */ }
507
578
  if (activeChange(vaultBase) === slug) clearActiveChange(vaultBase);
508
- return { ok: true, failing: [], archivedRel: destRel };
579
+ return { ok: true, failing: [], archivedRel: destRel, linksRewritten };
509
580
  }
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "_nota": "Preços API por milhão de tokens. cachedInput = cache read. Cache write aplica multiplicador no código: 5m = 1.25x input, 1h = 2x input. Editar aqui quando o provedor mudar preços (sem mexer no .mjs). Se o arquivo sumir ou ficar inválido, o hook usa a tabela embutida em token-usage.mjs.",
3
- "_fonte": "Anthropic https://www.anthropic.com/pricing — conferido 2026-06-13 (Opus 4.8: $5 input / $25 output; cache read 0.1x; cache write 1.25x 5m / 2x 1h)",
4
- "models": {
3
+ "_fonte": "OpenAI https://openai.com/index/gpt-5-6/ e https://help.openai.com/en/articles/20001325-a-preview-of-gpt-56-sol-terra-and-luna; Anthropic https://www.anthropic.com/pricing conferido 2026-07-11",
4
+ "models": {
5
+ "gpt-5.6-sol": { "label": "GPT-5.6 Sol API", "provider": "openai", "input": 5, "cachedInput": 0.5, "output": 30 },
6
+ "gpt-5.6-terra": { "label": "GPT-5.6 Terra API", "provider": "openai", "input": 2.5, "cachedInput": 0.25, "output": 15 },
7
+ "gpt-5.6-luna": { "label": "GPT-5.6 Luna API", "provider": "openai", "input": 1, "cachedInput": 0.1, "output": 6 },
5
8
  "gpt-5.5": {
6
9
  "label": "GPT-5.5 API",
7
10
  "provider": "openai",
@@ -91,6 +91,7 @@ export function collectSubagentUsage(sessionDir) {
91
91
  let count = 0;
92
92
  let calls = 0;
93
93
  let cost = 0;
94
+ const modelMap = new Map();
94
95
 
95
96
  for (const f of files) {
96
97
  const summary = summarizeTokenUsage(parseTokenUsageFromTranscript(f));
@@ -112,8 +113,18 @@ export function collectSubagentUsage(sessionDir) {
112
113
  calls: summary.calls,
113
114
  tokens,
114
115
  cost: round4(summary.costs.model),
116
+ modelRows: summary.modelRows,
115
117
  });
116
118
 
119
+ for (const row of summary.modelRows || []) {
120
+ const key = `${row.provider || '?'}\u0000${row.model || '?'}`;
121
+ const current = modelMap.get(key) || { provider: row.provider || '?', model: row.model || '?', calls: 0, tokens: 0, cost: 0 };
122
+ current.calls += row.calls || 0;
123
+ current.tokens += tokensTotal(row.usage);
124
+ current.cost += row.costs?.model || 0;
125
+ modelMap.set(key, current);
126
+ }
127
+
117
128
  count += 1;
118
129
  calls += summary.calls;
119
130
  cost += summary.costs.model;
@@ -151,7 +162,9 @@ export function collectSubagentUsage(sessionDir) {
151
162
  return {
152
163
  subagents,
153
164
  workflows,
154
- aggregate: { count, calls, tokens: tokensTotal(usageAgg), cost: round4(cost), wasted, usage: usageAgg, tools: [...allTools] },
165
+ aggregate: { count, calls, tokens: tokensTotal(usageAgg), cost: round4(cost), wasted, usage: usageAgg, tools: [...allTools],
166
+ modelRows: [...modelMap.values()].map((r) => ({ ...r, cost: round4(r.cost), source: 'subagent' })),
167
+ },
155
168
  };
156
169
  }
157
170
 
@@ -173,19 +186,22 @@ export function renderSubagentSection(c) {
173
186
  .map((s) => `| ${s.id} | ${s.agentType || '-'} | ${s.workflow || '-'} | ${s.model} | ${s.tools} | ${fmt(s.tokens)} | ${usd(s.cost)} |`)
174
187
  .join('\n');
175
188
  const wasteLine = a.wasted ? `\n- **Desperdiçado (runs killed/failed):** ${usd(a.wasted)}` : '';
189
+ const combinedLine = c.combined ? `\n- **Sessão completa (main + subagents):** ${fmt(c.combined.tokens)} tokens · ${usd(c.combined.cost)}` : '';
190
+ const modelRows = c.combined?.models?.map((m) => `| ${m.model} | ${m.source} | ${fmt(m.tokens)} | ${usd(m.cost)} |`).join('\n') || '';
191
+ const models = modelRows ? `\n\n### Por modelo (sessão completa)\n\n| Modelo | Origem | Tokens | Custo |\n|---|---|---:|---:|\n${modelRows}` : '';
176
192
  return `## Subagents & Workflows
177
193
 
178
- > Custo de subagents/workflows desta sessão NÃO incluído no total principal acima.
194
+ > Custo de subagents/workflows desta sessão, seguido do total combinado e da atribuição por modelo.
179
195
 
180
196
  - **Subagents:** ${a.count} · ${a.calls} chamadas · ${fmt(a.tokens)} tokens · ${usd(a.cost)}
181
197
  - **Workflows:** ${wf}
182
- - **Tools (subagents):** ${tools}${wasteLine}
198
+ - **Tools (subagents):** ${tools}${wasteLine}${combinedLine}
183
199
 
184
200
  ### Por subagent (${a.count})
185
201
 
186
202
  | Agent | Tipo | Workflow | Modelo | Tools | Tokens | Custo |
187
203
  |---|---|---|---|---:|---:|---:|
188
- ${rows}`;
204
+ ${rows}${models}`;
189
205
  }
190
206
 
191
207
  function setFrontmatterField(content, key, value) {
@@ -228,6 +244,26 @@ export function upsertSubagentUsage(sessionPath, transcriptPath) {
228
244
  content = setFrontmatterField(content, 'subagents_tools', `"${(a.tools || []).join(', ')}"`);
229
245
  content = setFrontmatterField(content, 'subagents_wasted_usd', a.wasted || 0);
230
246
  content = setFrontmatterField(content, 'tokens_total_incl_subagents', frontmatterNumber(content, 'tokens_total') + a.tokens);
247
+ content = setFrontmatterField(content, 'custo_total_incl_subagents_usd', round4(frontmatterNumber(content, 'custo_modelo_usd') + a.cost));
248
+ let mainRows = [];
249
+ try {
250
+ const main = summarizeTokenUsage(parseTokenUsageFromTranscript(transcriptPath));
251
+ mainRows = (main.modelRows || []).map((r) => ({
252
+ provider: r.provider || '?', model: r.model || '?', source: 'main', calls: r.calls || 0,
253
+ tokens: tokensTotal(r.usage), cost: round4(r.costs?.model || 0),
254
+ }));
255
+ } catch { /* preserve legacy aggregate fallback */ }
256
+ if (!mainRows.length) {
257
+ const mainModel = (content.match(/^custo_modelo_label:\s*["']?([^"'\r\n]+)["']?\s*$/m) || [])[1] || '?';
258
+ mainRows = [{ model: mainModel, source: 'main', cost: round4(frontmatterNumber(content, 'custo_modelo_usd')), tokens: frontmatterNumber(content, 'tokens_total') }];
259
+ }
260
+ const ledger = [...mainRows, ...(a.modelRows || [])];
261
+ collected.combined = {
262
+ tokens: frontmatterNumber(content, 'tokens_total') + a.tokens,
263
+ cost: round4(frontmatterNumber(content, 'custo_modelo_usd') + a.cost),
264
+ models: ledger,
265
+ };
266
+ content = setFrontmatterField(content, 'custo_por_modelo_json', `'${JSON.stringify(ledger).replaceAll("'", "''")}'`);
231
267
  content = upsertSection(content, '## Subagents & Workflows', renderSubagentSection(collected));
232
268
  writeFileSync(sessionPath, content, 'utf8');
233
269
  return true;
@@ -12,7 +12,10 @@ import {
12
12
  // Cache write: 5m = 1.25x input, 1h = 2x input (multiplicadores em calculateCost).
13
13
  // Tabela editável em pricing.json (mesma pasta); esta é o fallback embutido
14
14
  // usado quando o JSON some ou fica inválido — o hook nunca deve quebrar por isso.
15
- const DEFAULT_PRICE_REFERENCE = {
15
+ const DEFAULT_PRICE_REFERENCE = {
16
+ 'gpt-5.6-sol': { label: 'GPT-5.6 Sol API', provider: 'openai', input: 5, cachedInput: 0.5, output: 30 },
17
+ 'gpt-5.6-terra': { label: 'GPT-5.6 Terra API', provider: 'openai', input: 2.5, cachedInput: 0.25, output: 15 },
18
+ 'gpt-5.6-luna': { label: 'GPT-5.6 Luna API', provider: 'openai', input: 1, cachedInput: 0.1, output: 6 },
16
19
  'gpt-5.5': {
17
20
  label: 'GPT-5.5 API',
18
21
  provider: 'openai',
@@ -82,7 +85,18 @@ export function loadPriceReference(file = PRICING_FILE) {
82
85
 
83
86
  const PRICE_REFERENCE = loadPriceReference();
84
87
 
85
- const MODEL_ALIASES = {
88
+ const MODEL_ALIASES = {
89
+ 'gpt-5.6': 'gpt-5.6-sol',
90
+ 'gpt-5.6-sol': 'gpt-5.6-sol',
91
+ 'gpt-5-6-sol': 'gpt-5.6-sol',
92
+ 'openai/gpt-5.6': 'gpt-5.6-sol',
93
+ 'openai/gpt-5.6-sol': 'gpt-5.6-sol',
94
+ 'gpt-5.6-terra': 'gpt-5.6-terra',
95
+ 'gpt-5-6-terra': 'gpt-5.6-terra',
96
+ 'openai/gpt-5.6-terra': 'gpt-5.6-terra',
97
+ 'gpt-5.6-luna': 'gpt-5.6-luna',
98
+ 'gpt-5-6-luna': 'gpt-5.6-luna',
99
+ 'openai/gpt-5.6-luna': 'gpt-5.6-luna',
86
100
  'gpt-5.5': 'gpt-5.5',
87
101
  'gpt-5_5': 'gpt-5.5',
88
102
  'openai/gpt-5.5': 'gpt-5.5',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.34.1",
3
+ "version": "0.36.0",
4
4
  "description": "A persistent-memory harness for AI coding agents on your Obsidian vault: turn-by-turn session capture plus a native, zero-dependency spec→change→verify→archive loop (sensor-gated, independent verdict, mutation discrimination). Local-first, agent-agnostic (Claude Code, Codex, Cursor…).",
5
5
  "type": "module",
6
6
  "bin": {
package/src/change.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  setTaskDone,
14
14
  archiveChange,
15
15
  abandonChange,
16
+ relinkChanges,
16
17
  scaffoldPlaceholders,
17
18
  } from '../hooks/change-core.mjs';
18
19
  import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
@@ -284,6 +285,17 @@ export function runChange(argv) {
284
285
  process.exit(0);
285
286
  }
286
287
 
288
+ if (sub === 'relink') {
289
+ const r = relinkChanges(vaultBase, { apply: rest.includes('--apply') });
290
+ if (rest.includes('--json')) { process.stdout.write(`${JSON.stringify(r, null, 2)}\n`); process.exit(0); }
291
+ process.stdout.write(`${r.rewritten.length} slug(s) morto(s) mapeado(s)${r.applied ? ` · ${r.filesTouched} arquivo(s) reescritos` : ''}\n`);
292
+ for (const m of r.rewritten) process.stdout.write(` ${m.from} → ${m.to}\n`);
293
+ for (const a of r.ambiguous) process.stdout.write(` ambíguo (pulado): ${a}\n`);
294
+ for (const o of r.orphans) process.stdout.write(` sem archive correspondente: ${o}\n`);
295
+ if (!r.applied) process.stdout.write('\ndry-run — nada foi escrito. Rode com --apply para reescrever os wikilinks.\n');
296
+ process.exit(0);
297
+ }
298
+
287
299
  if (sub === 'abandon') {
288
300
  const slug = slugArg() || activeChange(vaultBase);
289
301
  if (!slug) { process.stderr.write('wendkeep change abandon: missing <slug> and no active change\n'); process.exit(2); }
@@ -293,6 +305,6 @@ export function runChange(argv) {
293
305
  process.exit(0);
294
306
  }
295
307
 
296
- process.stderr.write(`wendkeep change: unknown subcommand "${sub}". Known: new, use, continue, list, show, status, done, undone, diff, archive, abandon.\n`);
308
+ process.stderr.write(`wendkeep change: unknown subcommand "${sub}". Known: new, use, continue, list, show, status, done, undone, diff, archive, abandon, relink.\n`);
297
309
  process.exit(2);
298
310
  }
package/src/cost.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
5
5
  import { isAbsolute, join, resolve } from 'node:path';
6
6
  import { getLocale } from '../hooks/locale.mjs';
7
+ import { rebuildSessionCosts } from './rebuild-costs.mjs';
7
8
 
8
9
  const round4 = (n) => Math.round((Number(n) || 0) * 10000) / 10000;
9
10
  const usd = (n) => `$${(Number(n) || 0).toFixed(4)}`;
@@ -20,6 +21,11 @@ function fmValue(content, key) {
20
21
  // Parse the cost-relevant frontmatter of one note; null if it is not a session note.
21
22
  export function parseSessionCost(content) {
22
23
  if (!/^type:\s*session\s*$/m.test(content)) return null;
24
+ let ledger = [];
25
+ try {
26
+ const raw = fmValue(content, 'custo_por_modelo_json').replaceAll("''", "'");
27
+ if (raw) ledger = JSON.parse(raw);
28
+ } catch { /* legacy/malformed note: use safe fallback below */ }
23
29
  return {
24
30
  date: (fmValue(content, 'date') || '').slice(0, 10),
25
31
  model: fmValue(content, 'custo_modelo_label') || fmValue(content, 'modelo') || '?',
@@ -29,6 +35,7 @@ export function parseSessionCost(content) {
29
35
  tokens: Number(fmValue(content, 'tokens_total')) || 0,
30
36
  subTokens: Number(fmValue(content, 'subagents_tokens_total')) || 0,
31
37
  prompts: Number(fmValue(content, 'prompts')) || 0,
38
+ ledger,
32
39
  };
33
40
  }
34
41
 
@@ -46,8 +53,16 @@ export function aggregateCosts(entries) {
46
53
  const d = e.date || '?';
47
54
  (byDay[d] = byDay[d] || { cost: 0, count: 0 }).cost += e.mainCost + e.subCost;
48
55
  byDay[d].count += 1;
49
- (byModel[e.model] = byModel[e.model] || { cost: 0, count: 0 }).cost += e.mainCost + e.subCost;
50
- byModel[e.model].count += 1;
56
+ const rows = e.ledger?.length ? e.ledger : [
57
+ { model: e.model, cost: e.mainCost },
58
+ ...(e.subCost ? [{ model: 'subagents (legado, modelo desconhecido)', cost: e.subCost }] : []),
59
+ ];
60
+ const seen = new Set();
61
+ for (const row of rows) {
62
+ const model = row.model || '?';
63
+ (byModel[model] = byModel[model] || { cost: 0, count: 0 }).cost += Number(row.cost) || 0;
64
+ if (!seen.has(model)) { byModel[model].count += 1; seen.add(model); }
65
+ }
51
66
  }
52
67
  const total = main + sub;
53
68
  return {
@@ -186,6 +201,16 @@ export function runCost(argv) {
186
201
  if (!vaultRaw) { process.stderr.write('wendkeep cost: no vault (--vault or OBSIDIAN_VAULT_PATH).\n'); process.exit(2); }
187
202
  const vaultBase = isAbsolute(vaultRaw) ? vaultRaw : resolve(process.cwd(), vaultRaw);
188
203
  if (!existsSync(vaultBase)) { process.stderr.write(`wendkeep cost: vault not found: ${vaultBase}\n`); process.exit(2); }
204
+ if (argv[0] === 'rebuild') {
205
+ const report = rebuildSessionCosts(vaultBase, {
206
+ apply: argv.includes('--apply'),
207
+ session: opt(argv, '--session') || '',
208
+ limit: Number(opt(argv, '--limit')) || 0,
209
+ });
210
+ if (argv.includes('--json')) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
211
+ else process.stdout.write(`cost rebuild (${report.mode}): ${report.scanned} lidas · ${report.changed} alteradas · ${report.unchanged} iguais · ${report.missing.length} sem fonte · ${report.errors.length} erros\n${report.mode === 'apply' ? 'Relatório: .brain/COST_REBUILD.json\n' : 'Nenhum arquivo foi alterado; use --apply para gravar.\n'}`);
212
+ process.exit(report.ok ? 0 : 1);
213
+ }
189
214
  const agg = collectVaultCost(vaultBase, { since: opt(argv, '--since') });
190
215
 
191
216
  if (argv.includes('--json')) { process.stdout.write(`${JSON.stringify(agg, null, 2)}\n`); process.exit(0); }
@@ -0,0 +1,40 @@
1
+ // Deterministic cost reconstruction for historical sessions.
2
+ // Registry is authoritative: session_file <-> transcript_path. Dry-run restores every note.
3
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { readSessionRegistry } from '../hooks/obsidian-common.mjs';
6
+ import { updateSessionUsage } from '../hooks/token-usage.mjs';
7
+ import { upsertSubagentUsage } from '../hooks/subagent-usage.mjs';
8
+
9
+ export function rebuildSessionCosts(vaultBase, { apply = false, session = '', limit = 0 } = {}) {
10
+ const registry = readSessionRegistry(vaultBase);
11
+ const report = { version: 1, generatedAt: new Date().toISOString(), mode: apply ? 'apply' : 'dry-run', scanned: 0, changed: 0, unchanged: 0, missing: [], errors: [], sessions: [] };
12
+ const entries = Object.entries(registry.sessions || {}).map(([sessionId, value]) => ({ sessionId, ...value }))
13
+ .filter((e) => e.session_file && e.transcript_path)
14
+ .filter((e) => !session || e.sessionId === session || e.session_file === session);
15
+ for (const entry of entries) {
16
+ if (limit && report.scanned >= limit) break;
17
+ report.scanned += 1;
18
+ const note = join(vaultBase, entry.session_file);
19
+ if (!existsSync(note) || !existsSync(entry.transcript_path)) {
20
+ report.missing.push({ sessionId: entry.sessionId, session: entry.session_file, note: existsSync(note), transcript: existsSync(entry.transcript_path) });
21
+ continue;
22
+ }
23
+ const before = readFileSync(note, 'utf8');
24
+ try {
25
+ updateSessionUsage({ vaultBase, sessionRel: entry.session_file, sessionPath: note, transcriptPath: entry.transcript_path });
26
+ upsertSubagentUsage(note, entry.transcript_path);
27
+ const after = readFileSync(note, 'utf8');
28
+ const changed = before !== after;
29
+ if (changed) report.changed += 1; else report.unchanged += 1;
30
+ report.sessions.push({ sessionId: entry.sessionId, session: entry.session_file, transcript: entry.transcript_path, changed });
31
+ if (!apply && changed) writeFileSync(note, before, 'utf8');
32
+ } catch (error) {
33
+ if (!apply) writeFileSync(note, before, 'utf8');
34
+ report.errors.push({ sessionId: entry.sessionId, session: entry.session_file, error: error.message });
35
+ }
36
+ }
37
+ report.ok = report.errors.length === 0;
38
+ if (apply) writeFileSync(join(vaultBase, '.brain', 'COST_REBUILD.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
39
+ return report;
40
+ }
package/src/stats.mjs CHANGED
@@ -14,7 +14,7 @@ export function statsFrom(agg) {
14
14
  sessions: agg.count,
15
15
  prompts: agg.prompts || 0,
16
16
  cost: agg.total,
17
- models: (agg.byModel || []).length,
17
+ models: (agg.byModel || []).filter((m) => m.model !== 'subagents (legado, modelo desconhecido)').length,
18
18
  firstDay: days[0] || '',
19
19
  lastDay: days[days.length - 1] || '',
20
20
  spanDays: days.length,