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