wendkeep 0.35.0 → 0.37.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,21 @@ 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.37.0] — 2026-07-11
8
+
9
+ ### Added
10
+
11
+ - Observabilidade consolidada em `## Agentes, tokens e custos`: Stop, SubagentStop, importação e rebuild usam um único writer atômico para main + subagents.
12
+ - Ledger por modelo/origem com reasoning tokens e effort, sem alterar a regra de preço do modelo.
13
+ - Novo `wendkeep cost rebuild`, dry-run por padrão; `--apply` reconstrói sessões antigas via `SESSION_REGISTRY` e grava `.brain/COST_REBUILD.json`.
14
+ - Preços API-equivalentes de GPT-5.6 Sol, Terra e Luna.
15
+
16
+ ### Fixed
17
+
18
+ - Custos de subagents são atribuídos ao modelo que realmente os executou, em vez do modelo principal.
19
+ - Migração remove os headings legados sem perder reaberturas ou iterações mal posicionadas.
20
+ - Totais combinados de tokens/custo são atualizados também no `SubagentStop` e persistidos em campos compatíveis com os dashboards existentes.
21
+
7
22
  ## [0.35.0] — 2026-07-11
8
23
 
9
24
  ### Fixed
package/README.md CHANGED
@@ -118,6 +118,9 @@ 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`. |
122
+
123
+ 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.
121
124
  | `wendkeep lesson add "t" "l"` | Record a project-local lesson (injected at the next SessionStart). |
122
125
  | `wendkeep sync-defs` | Copy `.brain/agents\|skills` into `.codex/agents`, `.claude/skills`, `.agents/skills`; `--check` detects drift. |
123
126
  | `wendkeep validate-memory [path]` | Validate `.brain/CORE.md` (cap 25, 3 sections, no secrets/PII). |
package/README.pt-BR.md CHANGED
@@ -115,6 +115,9 @@ 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`. |
119
+
120
+ As notas de sessão usam um único snapshot vivo `## Agentes, tokens e custos`. Os hooks do agente principal e dos subagents recompõem o bloco atomicamente, incluindo custo, dimensões de tokens, reasoning e effort por modelo/origem.
118
121
  | `wendkeep stats [--vault P]` | Uma linha compartilhável: sessões · prompts · gasto · período · modelos (`--json`). |
119
122
  | `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
123
  | `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
@@ -16,8 +16,7 @@ import {
16
16
  } from './session-stop.mjs';
17
17
  import { buildSessionContent, allocateSessionPath } from './session-start.mjs';
18
18
  import { createLinkedNotes } from './linked-notes.mjs';
19
- import { updateSessionUsage } from './token-usage.mjs';
20
- import { upsertSubagentUsage } from './subagent-usage.mjs';
19
+ import { updateSessionObservability } from './session-observability.mjs';
21
20
  import { readSessionRegistry, upsertSessionRegistry, formatLocalIso, formatDate, providerMeta } from './obsidian-common.mjs';
22
21
  import { getLocale } from './locale.mjs';
23
22
  import { captureProseDecisions } from './decision-capture.mjs';
@@ -211,11 +210,8 @@ export function importSession(vaultBase, txPath, opts = {}) {
211
210
 
212
211
  // Cost + subagent telemetry, exactly like the live Stop hook. Fail-open.
213
212
  try {
214
- updateSessionUsage({ vaultBase, sessionRel: relPath, sessionPath: absPath, transcriptPath: txPath });
215
- } catch { /* usage is best-effort */ }
216
- try {
217
- upsertSubagentUsage(absPath, txPath);
218
- } catch { /* subagent telemetry is best-effort */ }
213
+ updateSessionObservability({ sessionPath: absPath, transcriptPath: txPath });
214
+ } catch { /* observability is best-effort */ }
219
215
 
220
216
  // Finalize: derived notes + closing section + ended_at from the last turn.
221
217
  const endedAt = formatLocalIso(endDate);
@@ -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",
@@ -0,0 +1,162 @@
1
+ // Single atomic writer for session usage, models, reasoning/effort and subagents.
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { collectSessionUsage } from './token-usage.mjs';
4
+ import { collectSubagentUsage, sessionDirFromTranscript } from './subagent-usage.mjs';
5
+
6
+ const HEADING = '## Agentes, tokens e custos';
7
+ const LEGACY_HEADINGS = ['## Uso de tokens e custos', '## Subagents & Workflows'];
8
+ const fmt = (n) => Math.trunc(Number(n) || 0).toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.');
9
+ const usd = (n) => `$${(Number(n) || 0).toFixed(4)}`;
10
+ const round4 = (n) => Math.round((Number(n) || 0) * 10000) / 10000;
11
+ const effort = (value) => {
12
+ const normalized = String(value || '').trim().toLowerCase();
13
+ return ['none', 'low', 'medium', 'high', 'xhigh'].includes(normalized) ? normalized : (normalized || 'unknown');
14
+ };
15
+ const usageTotal = (u = {}) => Number(u.total || 0) || (Number(u.input || 0) + Number(u.cached || 0) + Number(u.cacheWrite || 0) + Number(u.output || 0));
16
+
17
+ function setFrontmatterField(content, key, value) {
18
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
19
+ if (!match) return content;
20
+ const re = new RegExp(`^${key}:.*$`, 'm');
21
+ const line = `${key}: ${value}`;
22
+ const body = re.test(match[1]) ? match[1].replace(re, line) : `${match[1]}\n${line}`;
23
+ return content.replace(match[0], `---\n${body}\n---`);
24
+ }
25
+
26
+ function removeSection(content, heading, { preserveOrphanIterations = false } = {}) {
27
+ const start = content.indexOf(`\n${heading}`);
28
+ if (start < 0) return content;
29
+ const next = content.indexOf('\n## ', start + heading.length + 1);
30
+ const body = next < 0 ? content.slice(start) : content.slice(start, next);
31
+ const orphanAt = preserveOrphanIterations ? body.search(/\n### \d{2}:\d{2} - /) : -1;
32
+ const preserved = orphanAt >= 0 ? body.slice(orphanAt).trim() : '';
33
+ const rest = next < 0 ? '' : content.slice(next + 1).trimStart();
34
+ return [content.slice(0, start).trimEnd(), preserved, rest].filter(Boolean).join('\n\n').trimEnd() + '\n';
35
+ }
36
+
37
+ export function upsertObservabilitySection(content, section) {
38
+ let base = content;
39
+ base = removeSection(base, HEADING);
40
+ base = removeSection(base, LEGACY_HEADINGS[0], { preserveOrphanIterations: true });
41
+ base = removeSection(base, LEGACY_HEADINGS[1]);
42
+ const anchors = ['\n## Pendências', '\n## Issues Linear', '\n## Encerramento'];
43
+ const indexes = anchors.map((a) => base.indexOf(a)).filter((i) => i >= 0).sort((a, b) => a - b);
44
+ if (!indexes.length) return `${base.trimEnd()}\n\n${section.trimEnd()}\n`;
45
+ const at = indexes[0];
46
+ return `${base.slice(0, at).trimEnd()}\n\n${section.trimEnd()}\n\n${base.slice(at).trimStart()}`;
47
+ }
48
+
49
+ function mainLedger(main) {
50
+ return (main.summary.modelRows || []).map((row) => ({
51
+ provider: row.provider || 'unknown', model: row.model || 'unknown', source: 'main',
52
+ effort: effort(main.summary.pensamento), calls: row.calls || 0,
53
+ input: row.usage.input || 0, cacheWrite: row.usage.cacheWrite || 0, cached: row.usage.cached || 0,
54
+ output: row.usage.output || 0, reasoning: row.usage.reasoning || 0, total: usageTotal(row.usage),
55
+ cost: round4(row.costs?.model || 0),
56
+ }));
57
+ }
58
+
59
+ function subagentLedger(collected) {
60
+ return (collected?.aggregate.modelRows || []).map((row) => ({
61
+ provider: row.provider || 'unknown', model: row.model || 'unknown', source: 'subagent',
62
+ effort: effort(row.effort), calls: row.calls || 0,
63
+ input: row.usage?.input || 0, cacheWrite: row.usage?.cacheWrite || 0, cached: row.usage?.cached || 0,
64
+ output: row.usage?.output || 0, reasoning: row.usage?.reasoning || 0, total: usageTotal(row.usage || row),
65
+ cost: round4(row.cost || 0),
66
+ }));
67
+ }
68
+
69
+ function renderLedger(rows) {
70
+ if (!rows.length) return 'Nenhum modelo registrado.';
71
+ return ['| Modelo | Provider | Origem | Effort | Chamadas | Input | Cache W | Cache R | Output | Reasoning | Total | Custo |',
72
+ '|---|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|',
73
+ ...rows.map((r) => `| ${r.model} | ${r.provider} | ${r.source} | ${r.effort} | ${fmt(r.calls)} | ${fmt(r.input)} | ${fmt(r.cacheWrite)} | ${fmt(r.cached)} | ${fmt(r.output)} | ${fmt(r.reasoning)} | ${fmt(r.total)} | ${usd(r.cost)} |`),
74
+ ].join('\n');
75
+ }
76
+
77
+ function renderHistory(entries) {
78
+ if (!entries.length) return 'Nenhuma reabertura registrada.';
79
+ return ['| Transcript | Modelo(s) | Effort | Input | Cache W | Cache R | Output | Reasoning | Total | Custo | Atualizado |',
80
+ '|---|---|---|---:|---:|---:|---:|---:|---:|---:|---|',
81
+ ...entries.map((e) => `| ${String(e.transcript_id).slice(0, 12)}… | ${(e.modelos || []).join(' + ')} | ${effort(e.pensamento)} | ${fmt(e.input)} | ${fmt(e.cache_write)} | ${fmt(e.cache_read)} | ${fmt(e.output)} | ${fmt(e.reasoning)} | ${fmt(e.total)} | ${usd(e.custo_usd)} | ${e.atualizado_em || ''} |`),
82
+ ].join('\n');
83
+ }
84
+
85
+ function renderSubagents(collected) {
86
+ if (!collected) return '### Subagents e workflows\n\nNenhum subagent registrado.';
87
+ const a = collected.aggregate;
88
+ const workflows = collected.workflows.length
89
+ ? collected.workflows.map((w) => `${w.name} (${w.runId}${w.status ? ` · ${w.status}` : ''} · ${w.agents} agentes · ${usd(w.cost)})`).join('; ')
90
+ : '(nenhum)';
91
+ const rows = collected.subagents.map((s) => `| ${s.id} | ${s.agentType || '-'} | ${s.workflow || '-'} | ${s.model} | ${effort(s.effort)} | ${s.tools} | ${fmt(s.tokens)} | ${usd(s.cost)} |`).join('\n');
92
+ return `### Subagents e workflows
93
+
94
+ - **Subagents:** ${a.count} · ${a.calls} chamadas · ${fmt(a.tokens)} tokens · ${usd(a.cost)}
95
+ - **Workflows:** ${workflows}
96
+ - **Tools:** ${(a.tools || []).join(', ') || '(nenhuma)'}${a.wasted ? `\n- **Desperdiçado:** ${usd(a.wasted)}` : ''}
97
+
98
+ #### Por subagent (${a.count})
99
+
100
+ | Agent | Tipo | Workflow | Modelo | Effort | Tools | Tokens | Custo |
101
+ |---|---|---|---|---|---:|---:|---:|
102
+ ${rows}`;
103
+ }
104
+
105
+ export function renderSessionObservability(snapshot) {
106
+ const { main, subagents, ledger } = snapshot;
107
+ const sub = subagents?.aggregate || { count: 0, tokens: 0, cost: 0 };
108
+ const combinedTokens = main.aggregate.total + sub.tokens;
109
+ const combinedCost = round4(main.aggregate.custo + sub.cost);
110
+ return `${HEADING}
111
+
112
+ > Estimativa API-equivalente baseada nos transcripts locais. Reasoning e effort são observacionais e não acrescentam tarifa separada.
113
+
114
+ | Métrica | Principal | Subagents | Total |
115
+ |---|---:|---:|---:|
116
+ | Chamadas com uso | ${fmt(main.aggregate.calls)} | ${fmt(sub.calls)} | ${fmt(main.aggregate.calls + (sub.calls || 0))} |
117
+ | Input tokens | ${fmt(main.aggregate.input)} | ${fmt(sub.usage?.input)} | ${fmt(main.aggregate.input + (sub.usage?.input || 0))} |
118
+ | Cache write | ${fmt(main.aggregate.cacheWrite)} | ${fmt(sub.usage?.cacheWrite)} | ${fmt(main.aggregate.cacheWrite + (sub.usage?.cacheWrite || 0))} |
119
+ | Cache read | ${fmt(main.aggregate.cached)} | ${fmt(sub.usage?.cached)} | ${fmt(main.aggregate.cached + (sub.usage?.cached || 0))} |
120
+ | Output tokens | ${fmt(main.aggregate.output)} | ${fmt(sub.usage?.output)} | ${fmt(main.aggregate.output + (sub.usage?.output || 0))} |
121
+ | Reasoning tokens | ${fmt(main.aggregate.reasoning)} | ${fmt(sub.usage?.reasoning)} | ${fmt(main.aggregate.reasoning + (sub.usage?.reasoning || 0))} |
122
+ | Total tokens | ${fmt(main.aggregate.total)} | ${fmt(sub.tokens)} | ${fmt(combinedTokens)} |
123
+ | Custo estimado | ${usd(main.aggregate.custo)} | ${usd(sub.cost)} | ${usd(combinedCost)} |
124
+
125
+ ### Por modelo e origem
126
+
127
+ ${renderLedger(ledger)}
128
+
129
+ ### Por reabertura
130
+
131
+ ${renderHistory(main.entries)}
132
+
133
+ ${renderSubagents(subagents)}`;
134
+ }
135
+
136
+ export function buildSessionObservability({ sessionContent, transcriptPath }) {
137
+ const main = collectSessionUsage({ sessionContent, transcriptPath });
138
+ if (!main) return null;
139
+ const subagents = collectSubagentUsage(sessionDirFromTranscript(transcriptPath));
140
+ const ledger = [...mainLedger(main), ...subagentLedger(subagents)];
141
+ const sub = subagents?.aggregate || { count: 0, tokens: 0, cost: 0, wasted: 0, tools: [] };
142
+ let content = main.content;
143
+ content = setFrontmatterField(content, 'subagents_count', sub.count || 0);
144
+ content = setFrontmatterField(content, 'subagents_tokens_total', sub.tokens || 0);
145
+ content = setFrontmatterField(content, 'subagents_custo_usd', sub.cost || 0);
146
+ content = setFrontmatterField(content, 'subagents_tools', `"${(sub.tools || []).join(', ')}"`);
147
+ content = setFrontmatterField(content, 'subagents_wasted_usd', sub.wasted || 0);
148
+ content = setFrontmatterField(content, 'tokens_total_incl_subagents', main.aggregate.total + (sub.tokens || 0));
149
+ content = setFrontmatterField(content, 'custo_total_incl_subagents_usd', round4(main.aggregate.custo + (sub.cost || 0)));
150
+ content = setFrontmatterField(content, 'observability_schema', 1);
151
+ content = setFrontmatterField(content, 'custo_por_modelo_json', `'${JSON.stringify(ledger).replaceAll("'", "''")}'`);
152
+ const snapshot = { version: 1, main, subagents, ledger };
153
+ return { snapshot, content: upsertObservabilitySection(content, renderSessionObservability(snapshot)) };
154
+ }
155
+
156
+ export function updateSessionObservability({ sessionPath, transcriptPath }) {
157
+ if (!sessionPath || !existsSync(sessionPath)) return null;
158
+ const result = buildSessionObservability({ sessionContent: readFileSync(sessionPath, 'utf8'), transcriptPath });
159
+ if (!result) return null;
160
+ writeFileSync(sessionPath, result.content, 'utf8');
161
+ return result.snapshot;
162
+ }
@@ -4,11 +4,11 @@ import { join } from 'path';
4
4
  import { request } from 'http';
5
5
  import { pathToFileURL } from 'url';
6
6
  import { createLinkedNotes } from './linked-notes.mjs';
7
- import { addUsage, costBreakdown, emptyTokenUsage, normalizeClaudeUsage, normalizeCodexUsage, priceForModel, updateSessionUsage } from './token-usage.mjs';
7
+ import { addUsage, costBreakdown, emptyTokenUsage, normalizeClaudeUsage, normalizeCodexUsage, priceForModel } from './token-usage.mjs';
8
8
  import { buildBrainDigest, buildBrainIndex } from './brain-core.mjs';
9
9
  import { activeChangeLink, pruneChangeSentinels } from './change-core.mjs';
10
10
  import { getLocale } from './locale.mjs';
11
- import { upsertSubagentUsage } from './subagent-usage.mjs';
11
+ import { updateSessionObservability } from './session-observability.mjs';
12
12
  import {
13
13
  ensureDir,
14
14
  findActiveSessionByTranscript,
@@ -704,8 +704,9 @@ function applyDedicatedSections(content, tx) {
704
704
  function insertIntoIteracoes(content, block) {
705
705
  const iter = content.indexOf('\n## Iterações');
706
706
  if (iter !== -1) {
707
- const anchors = [
708
- '\n## Uso de tokens e custos',
707
+ const anchors = [
708
+ '\n## Agentes, tokens e custos',
709
+ '\n## Uso de tokens e custos',
709
710
  '\n## Decisões geradas nesta sessão',
710
711
  '\n## Bugs gerados nesta sessão',
711
712
  '\n## Aprendizados gerados nesta sessão',
@@ -855,7 +856,7 @@ function replacePendingSection(content, pending) {
855
856
  const end = content.indexOf(closingMarker, start + marker.length);
856
857
  if (end === -1) return content;
857
858
 
858
- // Preserva seções que outros writers inseriram dentro do span (## Subagents & Workflows,
859
+ // Preserva seções que outros writers inseriram dentro do span (observabilidade,
859
860
  // ## Progresso do plano, ## Mudanças…) — só o texto das Pendências em si é regenerado.
860
861
  const span = content.slice(start + marker.length, end);
861
862
  const innerIdx = span.indexOf('\n## ');
@@ -1053,24 +1054,11 @@ function main() {
1053
1054
  }
1054
1055
 
1055
1056
  try {
1056
- updateSessionUsage({
1057
- vaultBase,
1058
- sessionRel,
1059
- sessionPath,
1060
- transcriptPath,
1061
- });
1057
+ updateSessionObservability({ sessionPath, transcriptPath });
1062
1058
  } catch (error) {
1063
1059
  process.stderr.write(`[wendkeep] Token usage falhou: ${error.message}\n`);
1064
1060
  }
1065
1061
 
1066
- // Subagent/workflow telemetry (0.10.0): fold sibling subagent transcripts into the note.
1067
- // Provider-gated by structure + fail-open — never derruba o Stop.
1068
- try {
1069
- upsertSubagentUsage(sessionPath, transcriptPath);
1070
- } catch (error) {
1071
- process.stderr.write(`[wendkeep] Subagent usage falhou: ${error.message}\n`);
1072
- }
1073
-
1074
1062
  if (!shouldFinalizeSession()) {
1075
1063
  writeControl(vaultBase, {
1076
1064
  ...control,
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // SubagentStop hook: refresh this session's subagent/workflow telemetry the MOMENT a subagent
3
- // finishes — not only at the main Stop. Resilience: a session that never reaches Stop (crash,
4
- // window closed) still gets its subagent cost notes. Reuses the same upsertSubagentUsage the Stop
5
- // hook runs, so the output is identical; it just runs earlier + incrementally. Fail-open.
3
+ // finishes — not only at the main Stop. It recomposes the complete main + subagent snapshot
4
+ // through the same atomic writer used by Stop/import/rebuild. Fail-open.
6
5
  //
7
6
  // Model choice for subagents stays the harness's job (agent frontmatter `model:` / the Task/
8
7
  // workflow `model` param). wendkeep OBSERVES (this telemetry) rather than dictating a routing rule.
@@ -10,7 +9,7 @@ import { existsSync } from 'fs';
10
9
  import { join } from 'path';
11
10
  import { pathToFileURL } from 'url';
12
11
  import { readHookInput, writeHookOutput, getVaultBase, findActiveSessionByTranscript, readControl } from './obsidian-common.mjs';
13
- import { upsertSubagentUsage } from './subagent-usage.mjs';
12
+ import { updateSessionObservability } from './session-observability.mjs';
14
13
 
15
14
  export function refreshSubagents(vaultBase, input) {
16
15
  const transcriptPath = input.transcript_path || input.transcriptPath || '';
@@ -19,7 +18,7 @@ export function refreshSubagents(vaultBase, input) {
19
18
  if (!sessionRel) return false;
20
19
  const sessionPath = join(vaultBase, sessionRel);
21
20
  if (!existsSync(sessionPath)) return false;
22
- upsertSubagentUsage(sessionPath, transcriptPath);
21
+ updateSessionObservability({ sessionPath, transcriptPath });
23
22
  return true;
24
23
  }
25
24
 
@@ -69,7 +69,7 @@ function readWorkflowRuns(sessionDir) {
69
69
  }
70
70
 
71
71
  function tokensTotal(t = {}) {
72
- return (t.input || 0) + (t.cached || 0) + (t.cacheWrite || 0) + (t.output || 0);
72
+ return Number(t.total || 0) || ((t.input || 0) + (t.cached || 0) + (t.cacheWrite || 0) + (t.output || 0));
73
73
  }
74
74
 
75
75
  const round4 = (n) => Math.round((Number(n) || 0) * 10000) / 10000;
@@ -87,10 +87,11 @@ export function collectSubagentUsage(sessionDir) {
87
87
  const subagents = [];
88
88
  const wf = {};
89
89
  const allTools = new Set();
90
- const usageAgg = { input: 0, cached: 0, cacheWrite: 0, output: 0 };
90
+ const usageAgg = { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0, total: 0 };
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));
@@ -107,13 +108,27 @@ export function collectSubagentUsage(sessionDir) {
107
108
  agentType,
108
109
  workflow,
109
110
  model: summary.models[0] || '?',
111
+ effort: summary.pensamento || '',
110
112
  tools: summary.tools.length,
111
113
  toolNames: summary.tools,
112
114
  calls: summary.calls,
113
115
  tokens,
114
116
  cost: round4(summary.costs.model),
117
+ modelRows: summary.modelRows,
115
118
  });
116
119
 
120
+ for (const row of summary.modelRows || []) {
121
+ const rowEffort = summary.pensamento || '';
122
+ const key = `${row.provider || '?'}\u0000${row.model || '?'}\u0000${rowEffort}`;
123
+ const current = modelMap.get(key) || { provider: row.provider || '?', model: row.model || '?', effort: rowEffort, calls: 0, tokens: 0, cost: 0,
124
+ usage: { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0, total: 0 } };
125
+ current.calls += row.calls || 0;
126
+ current.tokens += tokensTotal(row.usage);
127
+ current.cost += row.costs?.model || 0;
128
+ for (const k of Object.keys(current.usage)) current.usage[k] += row.usage?.[k] || 0;
129
+ modelMap.set(key, current);
130
+ }
131
+
117
132
  count += 1;
118
133
  calls += summary.calls;
119
134
  cost += summary.costs.model;
@@ -151,7 +166,9 @@ export function collectSubagentUsage(sessionDir) {
151
166
  return {
152
167
  subagents,
153
168
  workflows,
154
- aggregate: { count, calls, tokens: tokensTotal(usageAgg), cost: round4(cost), wasted, usage: usageAgg, tools: [...allTools] },
169
+ aggregate: { count, calls, tokens: tokensTotal(usageAgg), cost: round4(cost), wasted, usage: usageAgg, tools: [...allTools],
170
+ modelRows: [...modelMap.values()].map((r) => ({ ...r, cost: round4(r.cost), source: 'subagent' })),
171
+ },
155
172
  };
156
173
  }
157
174
 
@@ -173,19 +190,22 @@ export function renderSubagentSection(c) {
173
190
  .map((s) => `| ${s.id} | ${s.agentType || '-'} | ${s.workflow || '-'} | ${s.model} | ${s.tools} | ${fmt(s.tokens)} | ${usd(s.cost)} |`)
174
191
  .join('\n');
175
192
  const wasteLine = a.wasted ? `\n- **Desperdiçado (runs killed/failed):** ${usd(a.wasted)}` : '';
193
+ const combinedLine = c.combined ? `\n- **Sessão completa (main + subagents):** ${fmt(c.combined.tokens)} tokens · ${usd(c.combined.cost)}` : '';
194
+ const modelRows = c.combined?.models?.map((m) => `| ${m.model} | ${m.source} | ${fmt(m.tokens)} | ${usd(m.cost)} |`).join('\n') || '';
195
+ const models = modelRows ? `\n\n### Por modelo (sessão completa)\n\n| Modelo | Origem | Tokens | Custo |\n|---|---|---:|---:|\n${modelRows}` : '';
176
196
  return `## Subagents & Workflows
177
197
 
178
- > Custo de subagents/workflows desta sessão NÃO incluído no total principal acima.
198
+ > Custo de subagents/workflows desta sessão, seguido do total combinado e da atribuição por modelo.
179
199
 
180
200
  - **Subagents:** ${a.count} · ${a.calls} chamadas · ${fmt(a.tokens)} tokens · ${usd(a.cost)}
181
201
  - **Workflows:** ${wf}
182
- - **Tools (subagents):** ${tools}${wasteLine}
202
+ - **Tools (subagents):** ${tools}${wasteLine}${combinedLine}
183
203
 
184
204
  ### Por subagent (${a.count})
185
205
 
186
206
  | Agent | Tipo | Workflow | Modelo | Tools | Tokens | Custo |
187
207
  |---|---|---|---|---:|---:|---:|
188
- ${rows}`;
208
+ ${rows}${models}`;
189
209
  }
190
210
 
191
211
  function setFrontmatterField(content, key, value) {
@@ -228,6 +248,26 @@ export function upsertSubagentUsage(sessionPath, transcriptPath) {
228
248
  content = setFrontmatterField(content, 'subagents_tools', `"${(a.tools || []).join(', ')}"`);
229
249
  content = setFrontmatterField(content, 'subagents_wasted_usd', a.wasted || 0);
230
250
  content = setFrontmatterField(content, 'tokens_total_incl_subagents', frontmatterNumber(content, 'tokens_total') + a.tokens);
251
+ content = setFrontmatterField(content, 'custo_total_incl_subagents_usd', round4(frontmatterNumber(content, 'custo_modelo_usd') + a.cost));
252
+ let mainRows = [];
253
+ try {
254
+ const main = summarizeTokenUsage(parseTokenUsageFromTranscript(transcriptPath));
255
+ mainRows = (main.modelRows || []).map((r) => ({
256
+ provider: r.provider || '?', model: r.model || '?', source: 'main', calls: r.calls || 0,
257
+ tokens: tokensTotal(r.usage), cost: round4(r.costs?.model || 0),
258
+ }));
259
+ } catch { /* preserve legacy aggregate fallback */ }
260
+ if (!mainRows.length) {
261
+ const mainModel = (content.match(/^custo_modelo_label:\s*["']?([^"'\r\n]+)["']?\s*$/m) || [])[1] || '?';
262
+ mainRows = [{ model: mainModel, source: 'main', cost: round4(frontmatterNumber(content, 'custo_modelo_usd')), tokens: frontmatterNumber(content, 'tokens_total') }];
263
+ }
264
+ const ledger = [...mainRows, ...(a.modelRows || [])];
265
+ collected.combined = {
266
+ tokens: frontmatterNumber(content, 'tokens_total') + a.tokens,
267
+ cost: round4(frontmatterNumber(content, 'custo_modelo_usd') + a.cost),
268
+ models: ledger,
269
+ };
270
+ content = setFrontmatterField(content, 'custo_por_modelo_json', `'${JSON.stringify(ledger).replaceAll("'", "''")}'`);
231
271
  content = upsertSection(content, '## Subagents & Workflows', renderSubagentSection(collected));
232
272
  writeFileSync(sessionPath, content, 'utf8');
233
273
  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',
@@ -139,7 +153,16 @@ const MANAGED_FRONTMATTER_KEYS = new Set([
139
153
  'custo_modelo_label',
140
154
  'custo_modelo_usd',
141
155
  'custo_por_modelo',
142
- 'usage_por_transcript',
156
+ 'usage_por_transcript',
157
+ 'subagents_count',
158
+ 'subagents_tokens_total',
159
+ 'subagents_custo_usd',
160
+ 'subagents_tools',
161
+ 'subagents_wasted_usd',
162
+ 'tokens_total_incl_subagents',
163
+ 'custo_total_incl_subagents_usd',
164
+ 'observability_schema',
165
+ 'custo_por_modelo_json',
143
166
  // Legado: chaves antigas removidas ao reprocessar a sessão.
144
167
  'custo_estimado_gpt55_usd',
145
168
  'custo_estimado_opus47_usd',
@@ -428,8 +451,9 @@ function parseClaudeLines(lines, result) {
428
451
  result.provider = 'anthropic';
429
452
  const seenUsage = new Set();
430
453
  const seenTools = new Set();
431
- const seenThinking = new Set();
432
- let thinkingChars = 0;
454
+ const seenThinking = new Set();
455
+ let thinkingChars = 0;
456
+ const thinkingCharsByModel = new Map();
433
457
  let latestPrompt = '';
434
458
 
435
459
  for (const line of lines) {
@@ -462,8 +486,9 @@ function parseClaudeLines(lines, result) {
462
486
  if (block?.type === 'thinking' && block.thinking) {
463
487
  const thinkKey = `${msg.id || ''}:${block.thinking.slice(0, 60)}`;
464
488
  if (!seenThinking.has(thinkKey)) {
465
- seenThinking.add(thinkKey);
466
- thinkingChars += block.thinking.length;
489
+ seenThinking.add(thinkKey);
490
+ thinkingChars += block.thinking.length;
491
+ thinkingCharsByModel.set(model, (thinkingCharsByModel.get(model) || 0) + block.thinking.length);
467
492
  }
468
493
  }
469
494
  }
@@ -488,10 +513,14 @@ function parseClaudeLines(lines, result) {
488
513
  // Thinking estimado: ~3,5 chars por token. Distribuído no total como informação à parte
489
514
  // (já contido em output_tokens — não somar de novo).
490
515
  const thinkingTokens = Math.round(thinkingChars / 3.5);
491
- if (thinkingTokens > 0) {
492
- result.totals.reasoning = thinkingTokens;
493
- result.pensamento = `thinking ~${formatTokensShort(thinkingTokens)}`;
494
- }
516
+ if (thinkingTokens > 0) {
517
+ result.totals.reasoning = thinkingTokens;
518
+ result.pensamento = `thinking ~${formatTokensShort(thinkingTokens)}`;
519
+ for (const [model, chars] of thinkingCharsByModel) {
520
+ const entry = result.byModel.get(`anthropic:${model}`);
521
+ if (entry) entry.usage.reasoning = Math.round(chars / 3.5);
522
+ }
523
+ }
495
524
 
496
525
  return result;
497
526
  }
@@ -882,40 +911,52 @@ function legacyEntryFromNote(content, summary) {
882
911
  };
883
912
  }
884
913
 
885
- export function updateSessionUsage({ vaultBase, sessionRel, sessionPath, transcriptPath }) {
886
- if (!sessionPath || !existsSync(sessionPath) || !transcriptPath || !existsSync(transcriptPath)) {
887
- return null;
888
- }
914
+ export function collectSessionUsage({ sessionContent, transcriptPath }) {
915
+ if (!transcriptPath || !existsSync(transcriptPath)) {
916
+ return null;
917
+ }
889
918
 
890
919
  const parsed = parseTokenUsageFromTranscript(transcriptPath);
891
920
  const summary = summarizeTokenUsage(parsed);
892
921
  if (!summary.calls) return null;
893
922
 
894
- const sessionContent = readFileSync(sessionPath, 'utf-8');
895
- const fmMatch = sessionContent.match(/^---\n([\s\S]*?)\n---/);
923
+ const fmMatch = sessionContent.match(/^---\n([\s\S]*?)\n---/);
896
924
  const existingEntries = fmMatch ? parseUsageHistory(fmMatch[1]) : [];
897
925
 
898
- const transcriptId = transcriptIdFromPath(transcriptPath);
899
- let entries = existingEntries.filter((e) => e.transcript_id !== transcriptId);
926
+ const transcriptId = transcriptIdFromPath(transcriptPath);
927
+ const previous = existingEntries.find((entry) => entry.transcript_id === transcriptId);
928
+ const current = entryFromSummary(summary, transcriptId);
929
+ if (previous) {
930
+ const comparable = (entry) => JSON.stringify({ ...entry, atualizado_em: undefined });
931
+ if (comparable(previous) === comparable(current)) current.atualizado_em = previous.atualizado_em;
932
+ }
933
+ let entries = existingEntries.filter((e) => e.transcript_id !== transcriptId);
900
934
 
901
935
  if (!existingEntries.length) {
902
936
  const legacy = legacyEntryFromNote(sessionContent, summary);
903
937
  if (legacy) entries.push(legacy);
904
938
  }
905
939
 
906
- entries.push(entryFromSummary(summary, transcriptId));
907
-
908
- const agg = aggregateEntries(entries);
909
- const withFrontmatter = upsertSessionFrontmatter(sessionContent, agg, entries);
910
- const withSection = upsertUsageSection(withFrontmatter, buildUsageSection(agg, entries, summary));
911
- writeFileSync(sessionPath, withSection, 'utf-8');
912
-
913
- return {
914
- summary,
915
- aggregate: agg,
916
- entries,
917
- };
918
- }
940
+ entries.push(current);
941
+
942
+ const agg = aggregateEntries(entries);
943
+ const withFrontmatter = upsertSessionFrontmatter(sessionContent, agg, entries);
944
+ return {
945
+ summary,
946
+ aggregate: agg,
947
+ entries,
948
+ content: withFrontmatter,
949
+ };
950
+ }
951
+
952
+ export function updateSessionUsage({ vaultBase, sessionRel, sessionPath, transcriptPath }) {
953
+ if (!sessionPath || !existsSync(sessionPath)) return null;
954
+ const result = collectSessionUsage({ sessionContent: readFileSync(sessionPath, 'utf-8'), transcriptPath });
955
+ if (!result) return null;
956
+ const withSection = upsertUsageSection(result.content, buildUsageSection(result.aggregate, result.entries, result.summary));
957
+ writeFileSync(sessionPath, withSection, 'utf-8');
958
+ return result;
959
+ }
919
960
 
920
961
  function parseCliArgs(argv) {
921
962
  const args = {};
@@ -72,7 +72,9 @@ function hasDefaultPending(content) {
72
72
  }
73
73
 
74
74
  function usageSectionIsPlaced(content, { active = false } = {}) {
75
- const usage = content.indexOf('\n## Uso de tokens e custos');
75
+ const unified = content.indexOf('\n## Agentes, tokens e custos');
76
+ const legacy = content.indexOf('\n## Uso de tokens e custos');
77
+ const usage = unified !== -1 ? unified : legacy;
76
78
  if (usage === -1) return true;
77
79
  const changed = content.indexOf('\n## Arquivos criados ou alterados');
78
80
  const pending = content.indexOf('\n## Pendências');
@@ -108,9 +110,12 @@ function checkSession({ vaultBase, sessionRel, control, registry }) {
108
110
  metrics.turnMarkers = (content.match(/<!-- (?:wk-turn|codex-turn):/g) || []).length;
109
111
  metrics.duplicateTurnMarkers = duplicates.length;
110
112
 
111
- if (duplicates.length) failures.push(`Marcadores de turno duplicados: ${duplicates.join(', ')}`);
112
- if (hasHeadingAfterClosing(content)) failures.push('Há headings/iterações após ## Encerramento.');
113
- if (!usageSectionIsPlaced(content, { active: activeSession })) failures.push('## Uso de tokens e custos está fora da posição esperada.');
113
+ if (duplicates.length) failures.push(`Marcadores de turno duplicados: ${duplicates.join(', ')}`);
114
+ if (hasHeadingAfterClosing(content)) failures.push('Há headings/iterações após ## Encerramento.');
115
+ if (!usageSectionIsPlaced(content, { active: activeSession })) failures.push('A seção de agentes, tokens e custos está fora da posição esperada.');
116
+ if (content.includes('\n## Agentes, tokens e custos') && (content.includes('\n## Uso de tokens e custos') || content.includes('\n## Subagents & Workflows'))) {
117
+ failures.push('A sessão mistura observabilidade consolidada e seções legadas.');
118
+ }
114
119
  if (hasDefaultPending(content)) warnings.push('Pendências ainda contém placeholders padrão.');
115
120
 
116
121
  const registryEntry = registry.sessions?.[control.session_id];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.35.0",
3
+ "version": "0.37.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/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,38 @@
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 { updateSessionObservability } from '../hooks/session-observability.mjs';
7
+
8
+ export function rebuildSessionCosts(vaultBase, { apply = false, session = '', limit = 0 } = {}) {
9
+ const registry = readSessionRegistry(vaultBase);
10
+ const report = { version: 1, generatedAt: new Date().toISOString(), mode: apply ? 'apply' : 'dry-run', scanned: 0, changed: 0, unchanged: 0, missing: [], errors: [], sessions: [] };
11
+ const entries = Object.entries(registry.sessions || {}).map(([sessionId, value]) => ({ sessionId, ...value }))
12
+ .filter((e) => e.session_file && e.transcript_path)
13
+ .filter((e) => !session || e.sessionId === session || e.session_file === session);
14
+ for (const entry of entries) {
15
+ if (limit && report.scanned >= limit) break;
16
+ report.scanned += 1;
17
+ const note = join(vaultBase, entry.session_file);
18
+ if (!existsSync(note) || !existsSync(entry.transcript_path)) {
19
+ report.missing.push({ sessionId: entry.sessionId, session: entry.session_file, note: existsSync(note), transcript: existsSync(entry.transcript_path) });
20
+ continue;
21
+ }
22
+ const before = readFileSync(note, 'utf8');
23
+ try {
24
+ updateSessionObservability({ sessionPath: note, transcriptPath: entry.transcript_path });
25
+ const after = readFileSync(note, 'utf8');
26
+ const changed = before !== after;
27
+ if (changed) report.changed += 1; else report.unchanged += 1;
28
+ report.sessions.push({ sessionId: entry.sessionId, session: entry.session_file, transcript: entry.transcript_path, changed });
29
+ if (!apply && changed) writeFileSync(note, before, 'utf8');
30
+ } catch (error) {
31
+ if (!apply) writeFileSync(note, before, 'utf8');
32
+ report.errors.push({ sessionId: entry.sessionId, session: entry.session_file, error: error.message });
33
+ }
34
+ }
35
+ report.ok = report.errors.length === 0;
36
+ if (apply) writeFileSync(join(vaultBase, '.brain', 'COST_REBUILD.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
37
+ return report;
38
+ }
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,