wendkeep 0.36.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
@@ -870,9 +885,3 @@ Initial release — the capture engine, extracted from a system in daily product
870
885
  [0.2.7]: https://www.npmjs.com/package/wendkeep/v/0.2.7
871
886
  [0.2.0]: https://www.npmjs.com/package/wendkeep/v/0.2.0
872
887
  [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
@@ -119,6 +119,8 @@ No re‑copying, no snapshot to re‑sync — the package is the single source o
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
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.
122
124
  | `wendkeep lesson add "t" "l"` | Record a project-local lesson (injected at the next SessionStart). |
123
125
  | `wendkeep sync-defs` | Copy `.brain/agents\|skills` into `.codex/agents`, `.claude/skills`, `.agents/skills`; `--check` detects drift. |
124
126
  | `wendkeep validate-memory [path]` | Validate `.brain/CORE.md` (cap 25, 3 sections, no secrets/PII). |
package/README.pt-BR.md CHANGED
@@ -116,6 +116,8 @@ Sem recopiar, sem snapshot pra re‑sincronizar — o pacote é a única fonte d
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
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.
119
121
  | `wendkeep stats [--vault P]` | Uma linha compartilhável: sessões · prompts · gasto · período · modelos (`--json`). |
120
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`. |
121
123
  | `wendkeep dashboard [--force]` | (Re)gera os Bases filtrados por pasta + o MOC `00-Dashboard`. |
@@ -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);
@@ -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,7 +87,7 @@ 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;
@@ -108,6 +108,7 @@ export function collectSubagentUsage(sessionDir) {
108
108
  agentType,
109
109
  workflow,
110
110
  model: summary.models[0] || '?',
111
+ effort: summary.pensamento || '',
111
112
  tools: summary.tools.length,
112
113
  toolNames: summary.tools,
113
114
  calls: summary.calls,
@@ -117,11 +118,14 @@ export function collectSubagentUsage(sessionDir) {
117
118
  });
118
119
 
119
120
  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 };
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 } };
122
125
  current.calls += row.calls || 0;
123
126
  current.tokens += tokensTotal(row.usage);
124
127
  current.cost += row.costs?.model || 0;
128
+ for (const k of Object.keys(current.usage)) current.usage[k] += row.usage?.[k] || 0;
125
129
  modelMap.set(key, current);
126
130
  }
127
131
 
@@ -153,7 +153,16 @@ const MANAGED_FRONTMATTER_KEYS = new Set([
153
153
  'custo_modelo_label',
154
154
  'custo_modelo_usd',
155
155
  'custo_por_modelo',
156
- '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',
157
166
  // Legado: chaves antigas removidas ao reprocessar a sessão.
158
167
  'custo_estimado_gpt55_usd',
159
168
  'custo_estimado_opus47_usd',
@@ -442,8 +451,9 @@ function parseClaudeLines(lines, result) {
442
451
  result.provider = 'anthropic';
443
452
  const seenUsage = new Set();
444
453
  const seenTools = new Set();
445
- const seenThinking = new Set();
446
- let thinkingChars = 0;
454
+ const seenThinking = new Set();
455
+ let thinkingChars = 0;
456
+ const thinkingCharsByModel = new Map();
447
457
  let latestPrompt = '';
448
458
 
449
459
  for (const line of lines) {
@@ -476,8 +486,9 @@ function parseClaudeLines(lines, result) {
476
486
  if (block?.type === 'thinking' && block.thinking) {
477
487
  const thinkKey = `${msg.id || ''}:${block.thinking.slice(0, 60)}`;
478
488
  if (!seenThinking.has(thinkKey)) {
479
- seenThinking.add(thinkKey);
480
- thinkingChars += block.thinking.length;
489
+ seenThinking.add(thinkKey);
490
+ thinkingChars += block.thinking.length;
491
+ thinkingCharsByModel.set(model, (thinkingCharsByModel.get(model) || 0) + block.thinking.length);
481
492
  }
482
493
  }
483
494
  }
@@ -502,10 +513,14 @@ function parseClaudeLines(lines, result) {
502
513
  // Thinking estimado: ~3,5 chars por token. Distribuído no total como informação à parte
503
514
  // (já contido em output_tokens — não somar de novo).
504
515
  const thinkingTokens = Math.round(thinkingChars / 3.5);
505
- if (thinkingTokens > 0) {
506
- result.totals.reasoning = thinkingTokens;
507
- result.pensamento = `thinking ~${formatTokensShort(thinkingTokens)}`;
508
- }
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
+ }
509
524
 
510
525
  return result;
511
526
  }
@@ -896,40 +911,52 @@ function legacyEntryFromNote(content, summary) {
896
911
  };
897
912
  }
898
913
 
899
- export function updateSessionUsage({ vaultBase, sessionRel, sessionPath, transcriptPath }) {
900
- if (!sessionPath || !existsSync(sessionPath) || !transcriptPath || !existsSync(transcriptPath)) {
901
- return null;
902
- }
914
+ export function collectSessionUsage({ sessionContent, transcriptPath }) {
915
+ if (!transcriptPath || !existsSync(transcriptPath)) {
916
+ return null;
917
+ }
903
918
 
904
919
  const parsed = parseTokenUsageFromTranscript(transcriptPath);
905
920
  const summary = summarizeTokenUsage(parsed);
906
921
  if (!summary.calls) return null;
907
922
 
908
- const sessionContent = readFileSync(sessionPath, 'utf-8');
909
- const fmMatch = sessionContent.match(/^---\n([\s\S]*?)\n---/);
923
+ const fmMatch = sessionContent.match(/^---\n([\s\S]*?)\n---/);
910
924
  const existingEntries = fmMatch ? parseUsageHistory(fmMatch[1]) : [];
911
925
 
912
- const transcriptId = transcriptIdFromPath(transcriptPath);
913
- 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);
914
934
 
915
935
  if (!existingEntries.length) {
916
936
  const legacy = legacyEntryFromNote(sessionContent, summary);
917
937
  if (legacy) entries.push(legacy);
918
938
  }
919
939
 
920
- entries.push(entryFromSummary(summary, transcriptId));
921
-
922
- const agg = aggregateEntries(entries);
923
- const withFrontmatter = upsertSessionFrontmatter(sessionContent, agg, entries);
924
- const withSection = upsertUsageSection(withFrontmatter, buildUsageSection(agg, entries, summary));
925
- writeFileSync(sessionPath, withSection, 'utf-8');
926
-
927
- return {
928
- summary,
929
- aggregate: agg,
930
- entries,
931
- };
932
- }
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
+ }
933
960
 
934
961
  function parseCliArgs(argv) {
935
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.36.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": {
@@ -3,8 +3,7 @@
3
3
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { readSessionRegistry } from '../hooks/obsidian-common.mjs';
6
- import { updateSessionUsage } from '../hooks/token-usage.mjs';
7
- import { upsertSubagentUsage } from '../hooks/subagent-usage.mjs';
6
+ import { updateSessionObservability } from '../hooks/session-observability.mjs';
8
7
 
9
8
  export function rebuildSessionCosts(vaultBase, { apply = false, session = '', limit = 0 } = {}) {
10
9
  const registry = readSessionRegistry(vaultBase);
@@ -22,8 +21,7 @@ export function rebuildSessionCosts(vaultBase, { apply = false, session = '', li
22
21
  }
23
22
  const before = readFileSync(note, 'utf8');
24
23
  try {
25
- updateSessionUsage({ vaultBase, sessionRel: entry.session_file, sessionPath: note, transcriptPath: entry.transcript_path });
26
- upsertSubagentUsage(note, entry.transcript_path);
24
+ updateSessionObservability({ sessionPath: note, transcriptPath: entry.transcript_path });
27
25
  const after = readFileSync(note, 'utf8');
28
26
  const changed = before !== after;
29
27
  if (changed) report.changed += 1; else report.unchanged += 1;