wendkeep 0.36.0 → 0.38.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.
@@ -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',
@@ -357,7 +366,7 @@ function parseCodexLines(lines, result) {
357
366
  const payload = event.payload || {};
358
367
 
359
368
  if (event.type === 'session_meta') {
360
- result.sessionId = payload.id || result.sessionId;
369
+ result.sessionId = payload.session_id || payload.id || result.sessionId;
361
370
  currentProvider = normalizeProvider(payload.model_provider || currentProvider);
362
371
  currentModel = normalizeModelName(payload.model || currentModel);
363
372
  result.provider = currentProvider;
@@ -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,18 +110,19 @@ 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
- const registryEntry = registry.sessions?.[control.session_id];
117
- if (control.session_id && !registryEntry) {
118
- failures.push(`SESSION_REGISTRY não possui session_id ativo: ${control.session_id}`);
119
- } else if (registryEntry) {
120
- if (registryEntry.session_file !== sessionRel) {
121
- failures.push('SESSION_REGISTRY diverge do CURRENT_SESSION.md para a sessão ativa.');
122
- }
121
+ const registryPair = Object.entries(registry.sessions || {}).find(([, entry]) => entry?.session_file === sessionRel);
122
+ const registryEntry = registryPair?.[1];
123
+ if (!registryEntry) {
124
+ failures.push(`SESSION_REGISTRY não possui a sessão: ${sessionRel}`);
125
+ } else {
123
126
  if (!registryEntry.transcript_path) {
124
127
  warnings.push('SESSION_REGISTRY não possui transcript_path para a sessão ativa.');
125
128
  } else if (!existsSync(registryEntry.transcript_path)) {
@@ -161,10 +164,15 @@ export function runVaultHealth({ vaultBase, session = '' }) {
161
164
  failures.push(...sessionResult.failures);
162
165
  warnings.push(...sessionResult.warnings);
163
166
 
164
- const staleDone = Object.values(registry.sessions || {})
167
+ const staleDone = Object.values(registry.sessions || {})
165
168
  .filter((item) => item.status === 'active' && item.ended_at)
166
169
  .length;
167
- if (staleDone) warnings.push(`${staleDone} entradas active com ended_at no SESSION_REGISTRY.`);
170
+ if (staleDone) warnings.push(`${staleDone} entradas active com ended_at no SESSION_REGISTRY.`);
171
+ const activeEntries = Object.values(registry.sessions || {}).filter((item) => item?.status === 'active');
172
+ for (const entry of activeEntries) {
173
+ if (!entry.session_file) failures.push('SESSION_REGISTRY possui sessão ativa sem session_file.');
174
+ if (!entry.transcript_path) warnings.push(`Sessão ativa sem transcript_path: ${entry.session_file || '(sem arquivo)'}`);
175
+ }
168
176
 
169
177
  const locF = getLocale(vaultBase).folders;
170
178
  const derivedFolders = [locF.decisions, locF.bugs, locF.learnings];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.36.0",
3
+ "version": "0.38.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
@@ -18,7 +18,7 @@ import {
18
18
  } from '../hooks/change-core.mjs';
19
19
  import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
20
20
  import { buildEffectiveRequirementPackage, evaluateVerdict, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta, validateSpecImpact } from '../hooks/spec-core.mjs';
21
- import { getNextAdrNumber, readControl } from '../hooks/obsidian-common.mjs';
21
+ import { getNextAdrNumber, readControl, readSessionRegistry, upsertSessionRegistry } from '../hooks/obsidian-common.mjs';
22
22
  import { getLocale } from '../hooks/locale.mjs';
23
23
 
24
24
  function resolveVault(argv) {
@@ -51,7 +51,7 @@ function today() {
51
51
  export function runChange(argv) {
52
52
  const [sub, ...rest] = argv;
53
53
  const vaultBase = resolveVault(rest);
54
- const VALUE_FLAGS = new Set(['--vault', '--change', '--project']);
54
+ const VALUE_FLAGS = new Set(['--vault', '--change', '--project', '--session']);
55
55
  const slugArg = () => rest.find((a, i) => !a.startsWith('-') && !VALUE_FLAGS.has(rest[i - 1]));
56
56
 
57
57
  if (sub === 'new') {
@@ -74,6 +74,18 @@ export function runChange(argv) {
74
74
  process.exit(0);
75
75
  }
76
76
 
77
+ if (sub === 'bind') {
78
+ const slug = slugArg();
79
+ const sessionId = opt(rest, '--session');
80
+ if (!slug || !sessionId) { process.stderr.write('wendkeep change bind: use <slug> --session <id>\n'); process.exit(2); }
81
+ const state = allChangesState(vaultBase);
82
+ if (!state.changes.some((item) => item.slug === slug)) { process.stderr.write(`wendkeep change bind: open change not found: ${slug}\n`); process.exit(2); }
83
+ if (!readSessionRegistry(vaultBase).sessions?.[sessionId]) { process.stderr.write(`wendkeep change bind: session not found: ${sessionId}\n`); process.exit(2); }
84
+ upsertSessionRegistry(vaultBase, sessionId, { change_slug: slug });
85
+ process.stdout.write(`session ${sessionId} -> change ${slug}\n`);
86
+ process.exit(0);
87
+ }
88
+
77
89
  if (sub === 'continue') {
78
90
  const positionals = rest.filter((a, i) => !a.startsWith('-') && !VALUE_FLAGS.has(rest[i - 1]));
79
91
  const [archivedSlug, newSlug] = positionals;
@@ -3,27 +3,25 @@
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);
11
10
  const report = { version: 1, generatedAt: new Date().toISOString(), mode: apply ? 'apply' : 'dry-run', scanned: 0, changed: 0, unchanged: 0, missing: [], errors: [], sessions: [] };
12
11
  const entries = Object.entries(registry.sessions || {}).map(([sessionId, value]) => ({ sessionId, ...value }))
13
- .filter((e) => e.session_file && e.transcript_path)
12
+ .filter((e) => e.session_file)
14
13
  .filter((e) => !session || e.sessionId === session || e.session_file === session);
15
14
  for (const entry of entries) {
16
15
  if (limit && report.scanned >= limit) break;
17
16
  report.scanned += 1;
18
17
  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) });
18
+ if (!entry.transcript_path || !existsSync(note) || !existsSync(entry.transcript_path)) {
19
+ report.missing.push({ sessionId: entry.sessionId, session: entry.session_file, note: existsSync(note), transcript: !!entry.transcript_path && existsSync(entry.transcript_path), transcriptPath: entry.transcript_path || '' });
21
20
  continue;
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, caller: 'cost-rebuild', canonicalConversationId: entry.sessionId });
27
25
  const after = readFileSync(note, 'utf8');
28
26
  const changed = before !== after;
29
27
  if (changed) report.changed += 1; else report.unchanged += 1;
@@ -34,7 +32,7 @@ export function rebuildSessionCosts(vaultBase, { apply = false, session = '', li
34
32
  report.errors.push({ sessionId: entry.sessionId, session: entry.session_file, error: error.message });
35
33
  }
36
34
  }
37
- report.ok = report.errors.length === 0;
35
+ report.ok = report.errors.length === 0 && report.missing.length === 0;
38
36
  if (apply) writeFileSync(join(vaultBase, '.brain', 'COST_REBUILD.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
39
37
  return report;
40
38
  }
@@ -0,0 +1,37 @@
1
+ import { isAbsolute, resolve } from 'node:path';
2
+ import { readControl, readSessionRegistry, writeControl } from '../hooks/obsidian-common.mjs';
3
+
4
+ function vaultOf(argv) {
5
+ const i = argv.indexOf('--vault');
6
+ const raw = i >= 0 ? argv[i + 1] : argv.find((a) => a.startsWith('--vault='))?.slice(8) || process.env.OBSIDIAN_VAULT_PATH;
7
+ if (!raw) throw new Error('pass --vault <path> or set OBSIDIAN_VAULT_PATH');
8
+ return isAbsolute(raw) ? raw : resolve(process.cwd(), raw);
9
+ }
10
+
11
+ function positionals(argv) {
12
+ return argv.filter((arg, index) => !arg.startsWith('-') && argv[index - 1] !== '--vault');
13
+ }
14
+
15
+ export function runSession(argv) {
16
+ const vault = vaultOf(argv);
17
+ const [sub, id] = positionals(argv);
18
+ const registry = readSessionRegistry(vault);
19
+ const rows = Object.entries(registry.sessions || {}).sort((a, b) => String(b[1].last_seen || '').localeCompare(String(a[1].last_seen || '')));
20
+ if (sub === 'list') {
21
+ for (const [sessionId, item] of rows) process.stdout.write(`${sessionId}\t${item.status || 'unknown'}\t${item.provider || 'unknown'}\t${item.change_slug || '-'}\t${item.session_file || '-'}\n`);
22
+ return;
23
+ }
24
+ const entry = registry.sessions?.[id];
25
+ if (!entry) throw new Error(`session not found: ${id || '(missing id)'}`);
26
+ if (sub === 'show') {
27
+ process.stdout.write(`${JSON.stringify({ session_id: id, ...entry }, null, 2)}\n`);
28
+ return;
29
+ }
30
+ if (sub === 'use') {
31
+ const control = readControl(vault);
32
+ writeControl(vault, { ...control, status: entry.status || 'active', session_id: id, session_file: entry.session_file || '', started_at: entry.started_at || '' });
33
+ process.stdout.write(`session focus: ${id}\n`);
34
+ return;
35
+ }
36
+ throw new Error('use: wendkeep session list | show <id> | use <id>');
37
+ }