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 +15 -0
- package/README.md +3 -0
- package/README.pt-BR.md +3 -0
- package/bin/wendkeep.mjs +3 -0
- package/hooks/import-sessions.mjs +3 -7
- package/hooks/pricing.json +5 -2
- package/hooks/session-observability.mjs +162 -0
- package/hooks/session-stop.mjs +7 -19
- package/hooks/subagent-stop.mjs +4 -5
- package/hooks/subagent-usage.mjs +46 -6
- package/hooks/token-usage.mjs +73 -32
- package/hooks/vault-health.mjs +9 -4
- package/package.json +1 -1
- package/src/cost.mjs +27 -2
- package/src/rebuild-costs.mjs +38 -0
- package/src/stats.mjs +1 -1
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 {
|
|
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
|
-
|
|
215
|
-
} catch { /*
|
|
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);
|
package/hooks/pricing.json
CHANGED
|
@@ -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": "
|
|
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
|
+
}
|
package/hooks/session-stop.mjs
CHANGED
|
@@ -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
|
|
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 {
|
|
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##
|
|
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 (
|
|
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
|
-
|
|
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,
|
package/hooks/subagent-stop.mjs
CHANGED
|
@@ -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.
|
|
4
|
-
//
|
|
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 {
|
|
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
|
-
|
|
21
|
+
updateSessionObservability({ sessionPath, transcriptPath });
|
|
23
22
|
return true;
|
|
24
23
|
}
|
|
25
24
|
|
package/hooks/subagent-usage.mjs
CHANGED
|
@@ -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
|
|
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;
|
package/hooks/token-usage.mjs
CHANGED
|
@@ -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
|
|
886
|
-
if (!
|
|
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
|
|
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
|
-
|
|
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(
|
|
907
|
-
|
|
908
|
-
const agg = aggregateEntries(entries);
|
|
909
|
-
const withFrontmatter = upsertSessionFrontmatter(sessionContent, agg, entries);
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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 = {};
|
package/hooks/vault-health.mjs
CHANGED
|
@@ -72,7 +72,9 @@ function hasDefaultPending(content) {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
function usageSectionIsPlaced(content, { active = false } = {}) {
|
|
75
|
-
const
|
|
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('
|
|
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.
|
|
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
|
-
|
|
50
|
-
|
|
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,
|