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.
@@ -0,0 +1,75 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { basename } from 'path';
3
+ import { detectProvider, readSessionRegistry, transcriptsMatch } from './obsidian-common.mjs';
4
+
5
+ function parseLines(path) {
6
+ if (!path || !existsSync(path)) return [];
7
+ return readFileSync(path, 'utf-8').split('\n').filter(Boolean).map((line) => {
8
+ try { return JSON.parse(line); } catch { return null; }
9
+ }).filter(Boolean);
10
+ }
11
+
12
+ export function inspectTranscriptIdentity(transcriptPath) {
13
+ const lines = parseLines(transcriptPath);
14
+ const codexMeta = lines.find((event) => event.type === 'session_meta')?.payload;
15
+ if (codexMeta) {
16
+ return {
17
+ transcriptProvider: 'openai',
18
+ provider: 'codex',
19
+ canonicalConversationId: codexMeta.session_id || codexMeta.id || '',
20
+ transcriptId: codexMeta.id || basename(transcriptPath, '.jsonl'),
21
+ parentConversationId: codexMeta.parent_thread_id || codexMeta.forked_from_id || '',
22
+ };
23
+ }
24
+ const claudeEvent = lines.find((event) => event.sessionId);
25
+ if (claudeEvent) {
26
+ return {
27
+ transcriptProvider: 'anthropic',
28
+ provider: 'claude',
29
+ canonicalConversationId: claudeEvent.sessionId,
30
+ transcriptId: basename(transcriptPath, '.jsonl'),
31
+ parentConversationId: '',
32
+ };
33
+ }
34
+ return { transcriptProvider: 'unknown', provider: 'unknown', canonicalConversationId: '', transcriptId: '', parentConversationId: '' };
35
+ }
36
+
37
+ function compatible(provider, transcriptProvider) {
38
+ return (provider === 'codex' && transcriptProvider === 'openai')
39
+ || (provider === 'claude' && transcriptProvider === 'anthropic');
40
+ }
41
+
42
+ export function resolveSessionIdentity(vaultBase, input = {}, provider = detectProvider()) {
43
+ const transcriptPath = input.transcript_path || input.transcriptPath || '';
44
+ const inspected = inspectTranscriptIdentity(transcriptPath);
45
+ const hookId = input.session_id || input.sessionId || '';
46
+ if (!transcriptPath || !inspected.canonicalConversationId) {
47
+ return { state: 'deferred', provider, transcriptPath, diagnostics: ['transcript ausente ou sem identidade canônica'] };
48
+ }
49
+ if (!compatible(provider, inspected.transcriptProvider)) {
50
+ return { state: 'deferred', provider, transcriptPath, diagnostics: [`provider ${provider} incompatível com transcript ${inspected.transcriptProvider}`] };
51
+ }
52
+
53
+ const registry = readSessionRegistry(vaultBase);
54
+ const byTranscript = Object.entries(registry.sessions || {}).find(([, entry]) => {
55
+ const paths = [...(Array.isArray(entry?.transcript_paths) ? entry.transcript_paths : []), entry?.transcript_path].filter(Boolean);
56
+ return paths.some((path) => transcriptsMatch(path, transcriptPath));
57
+ });
58
+ const canonicalConversationId = byTranscript?.[0] || inspected.canonicalConversationId;
59
+ return {
60
+ state: 'resolved',
61
+ provider,
62
+ canonicalConversationId,
63
+ hookSessionId: hookId,
64
+ transcriptPath,
65
+ transcriptId: inspected.transcriptId,
66
+ parentConversationId: inspected.parentConversationId,
67
+ diagnostics: [],
68
+ };
69
+ }
70
+
71
+ export function resolveSessionEntry(vaultBase, input = {}, provider = detectProvider()) {
72
+ const identity = resolveSessionIdentity(vaultBase, input, provider);
73
+ if (identity.state !== 'resolved') return { identity, entry: null };
74
+ return { identity, entry: readSessionRegistry(vaultBase).sessions?.[identity.canonicalConversationId] || null };
75
+ }
@@ -0,0 +1,176 @@
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
+ import { inspectTranscriptIdentity } from './session-identity.mjs';
6
+
7
+ const HEADING = '## Agentes, tokens e custos';
8
+ const LEGACY_HEADINGS = ['## Uso de tokens e custos', '## Subagents & Workflows'];
9
+ const fmt = (n) => Math.trunc(Number(n) || 0).toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.');
10
+ const usd = (n) => `$${(Number(n) || 0).toFixed(4)}`;
11
+ const round4 = (n) => Math.round((Number(n) || 0) * 10000) / 10000;
12
+ const effort = (value) => {
13
+ const normalized = String(value || '').trim().toLowerCase();
14
+ return ['none', 'low', 'medium', 'high', 'xhigh'].includes(normalized) ? normalized : (normalized || 'unknown');
15
+ };
16
+ const usageTotal = (u = {}) => Number(u.total || 0) || (Number(u.input || 0) + Number(u.cached || 0) + Number(u.cacheWrite || 0) + Number(u.output || 0));
17
+
18
+ function setFrontmatterField(content, key, value) {
19
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
20
+ if (!match) return content;
21
+ const re = new RegExp(`^${key}:.*$`, 'm');
22
+ const line = `${key}: ${value}`;
23
+ const body = re.test(match[1]) ? match[1].replace(re, line) : `${match[1]}\n${line}`;
24
+ return content.replace(match[0], `---\n${body}\n---`);
25
+ }
26
+
27
+ function removeSection(content, heading, { preserveOrphanIterations = false } = {}) {
28
+ const start = content.indexOf(`\n${heading}`);
29
+ if (start < 0) return content;
30
+ const next = content.indexOf('\n## ', start + heading.length + 1);
31
+ const body = next < 0 ? content.slice(start) : content.slice(start, next);
32
+ const orphanAt = preserveOrphanIterations ? body.search(/\n### \d{2}:\d{2} - /) : -1;
33
+ const preserved = orphanAt >= 0 ? body.slice(orphanAt).trim() : '';
34
+ const rest = next < 0 ? '' : content.slice(next + 1).trimStart();
35
+ return [content.slice(0, start).trimEnd(), preserved, rest].filter(Boolean).join('\n\n').trimEnd() + '\n';
36
+ }
37
+
38
+ export function upsertObservabilitySection(content, section) {
39
+ let base = content;
40
+ base = removeSection(base, HEADING);
41
+ base = removeSection(base, LEGACY_HEADINGS[0], { preserveOrphanIterations: true });
42
+ base = removeSection(base, LEGACY_HEADINGS[1]);
43
+ const anchors = ['\n## Pendências', '\n## Issues Linear', '\n## Encerramento'];
44
+ const indexes = anchors.map((a) => base.indexOf(a)).filter((i) => i >= 0).sort((a, b) => a - b);
45
+ if (!indexes.length) return `${base.trimEnd()}\n\n${section.trimEnd()}\n`;
46
+ const at = indexes[0];
47
+ return `${base.slice(0, at).trimEnd()}\n\n${section.trimEnd()}\n\n${base.slice(at).trimStart()}`;
48
+ }
49
+
50
+ function mainLedger(main) {
51
+ return (main.summary.modelRows || []).map((row) => ({
52
+ provider: row.provider || 'unknown', model: row.model || 'unknown', source: 'main',
53
+ effort: effort(main.summary.pensamento), calls: row.calls || 0,
54
+ input: row.usage.input || 0, cacheWrite: row.usage.cacheWrite || 0, cached: row.usage.cached || 0,
55
+ output: row.usage.output || 0, reasoning: row.usage.reasoning || 0, total: usageTotal(row.usage),
56
+ cost: round4(row.costs?.model || 0),
57
+ }));
58
+ }
59
+
60
+ function subagentLedger(collected) {
61
+ return (collected?.aggregate.modelRows || []).map((row) => ({
62
+ provider: row.provider || 'unknown', model: row.model || 'unknown', source: 'subagent',
63
+ effort: effort(row.effort), calls: row.calls || 0,
64
+ input: row.usage?.input || 0, cacheWrite: row.usage?.cacheWrite || 0, cached: row.usage?.cached || 0,
65
+ output: row.usage?.output || 0, reasoning: row.usage?.reasoning || 0, total: usageTotal(row.usage || row),
66
+ cost: round4(row.cost || 0),
67
+ }));
68
+ }
69
+
70
+ function renderLedger(rows) {
71
+ if (!rows.length) return 'Nenhum modelo registrado.';
72
+ return ['| Modelo | Provider | Origem | Effort | Chamadas | Input | Cache W | Cache R | Output | Reasoning | Total | Custo |',
73
+ '|---|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|',
74
+ ...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)} |`),
75
+ ].join('\n');
76
+ }
77
+
78
+ function renderHistory(entries) {
79
+ if (!entries.length) return 'Nenhuma reabertura registrada.';
80
+ return ['| Transcript | Modelo(s) | Effort | Input | Cache W | Cache R | Output | Reasoning | Total | Custo | Atualizado |',
81
+ '|---|---|---|---:|---:|---:|---:|---:|---:|---:|---|',
82
+ ...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 || ''} |`),
83
+ ].join('\n');
84
+ }
85
+
86
+ function renderSubagents(collected) {
87
+ if (!collected) return '### Subagents e workflows\n\nNenhum subagent registrado.';
88
+ const a = collected.aggregate;
89
+ const workflows = collected.workflows.length
90
+ ? collected.workflows.map((w) => `${w.name} (${w.runId}${w.status ? ` · ${w.status}` : ''} · ${w.agents} agentes · ${usd(w.cost)})`).join('; ')
91
+ : '(nenhum)';
92
+ 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');
93
+ return `### Subagents e workflows
94
+
95
+ - **Subagents:** ${a.count} · ${a.calls} chamadas · ${fmt(a.tokens)} tokens · ${usd(a.cost)}
96
+ - **Workflows:** ${workflows}
97
+ - **Tools:** ${(a.tools || []).join(', ') || '(nenhuma)'}${a.wasted ? `\n- **Desperdiçado:** ${usd(a.wasted)}` : ''}
98
+
99
+ #### Por subagent (${a.count})
100
+
101
+ | Agent | Tipo | Workflow | Modelo | Effort | Tools | Tokens | Custo |
102
+ |---|---|---|---|---|---:|---:|---:|
103
+ ${rows}`;
104
+ }
105
+
106
+ export function renderSessionObservability(snapshot) {
107
+ const { main, subagents, ledger } = snapshot;
108
+ const sub = subagents?.aggregate || { count: 0, tokens: 0, cost: 0 };
109
+ const combinedTokens = main.aggregate.total + sub.tokens;
110
+ const combinedCost = round4(main.aggregate.custo + sub.cost);
111
+ return `${HEADING}
112
+
113
+ > Estimativa API-equivalente baseada nos transcripts locais. Reasoning e effort são observacionais e não acrescentam tarifa separada.
114
+
115
+ | Métrica | Principal | Subagents | Total |
116
+ |---|---:|---:|---:|
117
+ | Chamadas com uso | ${fmt(main.aggregate.calls)} | ${fmt(sub.calls)} | ${fmt(main.aggregate.calls + (sub.calls || 0))} |
118
+ | Input tokens | ${fmt(main.aggregate.input)} | ${fmt(sub.usage?.input)} | ${fmt(main.aggregate.input + (sub.usage?.input || 0))} |
119
+ | Cache write | ${fmt(main.aggregate.cacheWrite)} | ${fmt(sub.usage?.cacheWrite)} | ${fmt(main.aggregate.cacheWrite + (sub.usage?.cacheWrite || 0))} |
120
+ | Cache read | ${fmt(main.aggregate.cached)} | ${fmt(sub.usage?.cached)} | ${fmt(main.aggregate.cached + (sub.usage?.cached || 0))} |
121
+ | Output tokens | ${fmt(main.aggregate.output)} | ${fmt(sub.usage?.output)} | ${fmt(main.aggregate.output + (sub.usage?.output || 0))} |
122
+ | Reasoning tokens | ${fmt(main.aggregate.reasoning)} | ${fmt(sub.usage?.reasoning)} | ${fmt(main.aggregate.reasoning + (sub.usage?.reasoning || 0))} |
123
+ | Total tokens | ${fmt(main.aggregate.total)} | ${fmt(sub.tokens)} | ${fmt(combinedTokens)} |
124
+ | Custo estimado | ${usd(main.aggregate.custo)} | ${usd(sub.cost)} | ${usd(combinedCost)} |
125
+
126
+ ### Por modelo e origem
127
+
128
+ ${renderLedger(ledger)}
129
+
130
+ ### Por reabertura
131
+
132
+ ${renderHistory(main.entries)}
133
+
134
+ ${renderSubagents(subagents)}`;
135
+ }
136
+
137
+ export function buildSessionObservability({ sessionContent, transcriptPath }) {
138
+ const main = collectSessionUsage({ sessionContent, transcriptPath });
139
+ if (!main) return null;
140
+ const subagents = collectSubagentUsage(sessionDirFromTranscript(transcriptPath));
141
+ const ledger = [...mainLedger(main), ...subagentLedger(subagents)];
142
+ const sub = subagents?.aggregate || { count: 0, tokens: 0, cost: 0, wasted: 0, tools: [] };
143
+ let content = main.content;
144
+ content = setFrontmatterField(content, 'subagents_count', sub.count || 0);
145
+ content = setFrontmatterField(content, 'subagents_tokens_total', sub.tokens || 0);
146
+ content = setFrontmatterField(content, 'subagents_custo_usd', sub.cost || 0);
147
+ content = setFrontmatterField(content, 'subagents_tools', `"${(sub.tools || []).join(', ')}"`);
148
+ content = setFrontmatterField(content, 'subagents_wasted_usd', sub.wasted || 0);
149
+ content = setFrontmatterField(content, 'tokens_total_incl_subagents', main.aggregate.total + (sub.tokens || 0));
150
+ content = setFrontmatterField(content, 'custo_total_incl_subagents_usd', round4(main.aggregate.custo + (sub.cost || 0)));
151
+ content = setFrontmatterField(content, 'observability_schema', 1);
152
+ content = setFrontmatterField(content, 'custo_por_modelo_json', `'${JSON.stringify(ledger).replaceAll("'", "''")}'`);
153
+ const snapshot = { version: 1, main, subagents, ledger };
154
+ return { snapshot, content: upsertObservabilitySection(content, renderSessionObservability(snapshot)) };
155
+ }
156
+
157
+ export function updateSessionObservability({ sessionPath, transcriptPath, caller = 'unknown', canonicalConversationId = '' }) {
158
+ if (!sessionPath || !existsSync(sessionPath)) return null;
159
+ const sessionContent = readFileSync(sessionPath, 'utf8');
160
+ const noteProvider = sessionContent.match(/^provider:\s*"?([^"\n]+)"?/m)?.[1]?.trim() || 'unknown';
161
+ const identity = inspectTranscriptIdentity(transcriptPath);
162
+ if ((noteProvider === 'codex' && identity.transcriptProvider !== 'openai')
163
+ || (noteProvider === 'claude' && identity.transcriptProvider !== 'anthropic')) {
164
+ throw new Error(`observability provider mismatch: note=${noteProvider}, transcript=${identity.transcriptProvider}`);
165
+ }
166
+ let annotated = setFrontmatterField(sessionContent, 'observability_caller', `"${caller}"`);
167
+ annotated = setFrontmatterField(annotated, 'observability_session_id', `"${canonicalConversationId || identity.canonicalConversationId || ''}"`);
168
+ annotated = setFrontmatterField(annotated, 'observability_transcript_id', `"${identity.transcriptId || ''}"`);
169
+ if (!/^observability_updated_at:/m.test(annotated)) {
170
+ annotated = setFrontmatterField(annotated, 'observability_updated_at', `"${new Date().toISOString()}"`);
171
+ }
172
+ const result = buildSessionObservability({ sessionContent: annotated, transcriptPath });
173
+ if (!result) return null;
174
+ writeFileSync(sessionPath, result.content, 'utf8');
175
+ return result.snapshot;
176
+ }
@@ -29,7 +29,8 @@ import {
29
29
  writeControl,
30
30
  writeHookOutput,
31
31
  yamlQuote,
32
- } from './obsidian-common.mjs';
32
+ } from './obsidian-common.mjs';
33
+ import { resolveSessionIdentity } from './session-identity.mjs';
33
34
 
34
35
  export function buildSessionContent({ relPath, now, summary = 'session', provider: providerId, sessionId = '' }) {
35
36
  const date = formatDate(now);
@@ -150,16 +151,29 @@ function buildAdditionalContext({ relPath, startedAt, vaultBase }) {
150
151
 
151
152
  function main() {
152
153
  const input = readHookInput();
153
- const vaultBase = getVaultBase(input);
154
- warnIfDefaultVault(input);
155
- const now = new Date();
156
- const sessionId = input.session_id || input.sessionId || '';
157
- const control = readControl(vaultBase);
154
+ const vaultBase = getVaultBase(input);
155
+ warnIfDefaultVault(input);
156
+ const now = new Date();
157
+ const provider = providerMeta();
158
+ const identity = resolveSessionIdentity(vaultBase, input, provider.id);
159
+ if (identity.state !== 'resolved') {
160
+ writeHookOutput({
161
+ hookSpecificOutput: {
162
+ hookEventName: 'SessionStart',
163
+ additionalContext: `<obsidian_session_deferred>Memória global disponível, mas nenhuma escrita de sessão foi feita: ${identity.diagnostics.join('; ')}.</obsidian_session_deferred>`,
164
+ },
165
+ systemMessage: `[wendkeep] Identidade de sessão adiada: ${identity.diagnostics.join('; ')}`,
166
+ });
167
+ return;
168
+ }
169
+ const sessionId = identity.canonicalConversationId;
170
+ const transcriptPath = identity.transcriptPath;
171
+ const control = readControl(vaultBase);
158
172
 
159
173
  // Fecha sessões `active` órfãs (sem evento de fim — janela fechada/crash) antes
160
174
  // de seguir. Preserva a deste transcript: pode ser reaproveitada logo abaixo.
161
175
  try {
162
- sweepStaleSessionsFile(vaultBase, now, undefined, input.transcript_path || input.transcriptPath || '');
176
+ sweepStaleSessionsFile(vaultBase, now, undefined, transcriptPath);
163
177
  } catch (error) {
164
178
  process.stderr.write(`[wendkeep] sweep de sessões falhou: ${error.message}\n`);
165
179
  }
@@ -172,12 +186,15 @@ function main() {
172
186
  if (control.status === 'active' && control.session_file && control.session_id === sessionId) {
173
187
  const activePath = join(vaultBase, control.session_file);
174
188
  if (existsSync(activePath)) {
175
- upsertSessionRegistry(vaultBase, sessionId, {
189
+ upsertSessionRegistry(vaultBase, sessionId, {
176
190
  session_file: control.session_file,
177
191
  status: 'active',
178
192
  started_at: control.started_at,
179
- ended_at: '',
180
- });
193
+ ended_at: '',
194
+ provider: provider.id,
195
+ transcript_path: transcriptPath,
196
+ transcript_id: identity.transcriptId,
197
+ });
181
198
  writeHookOutput({
182
199
  hookSpecificOutput: {
183
200
  hookEventName: 'SessionStart',
@@ -220,7 +237,9 @@ function main() {
220
237
  status: 'active',
221
238
  started_at: startedAt,
222
239
  ended_at: '',
223
- transcript_path: input.transcript_path || input.transcriptPath || known.transcript_path || '',
240
+ transcript_path: transcriptPath || known.transcript_path || '',
241
+ transcript_id: identity.transcriptId,
242
+ provider: provider.id,
224
243
  });
225
244
  writeHookOutput({
226
245
  hookSpecificOutput: {
@@ -235,8 +254,7 @@ function main() {
235
254
  // Re-init da conversa (compactação/resume) traz um session_id novo e cai fora
236
255
  // da janela de reuso; o transcript continua o mesmo. Reaproveita a sessão ativa
237
256
  // desse transcript em vez de criar um placeholder `HH-MM-codex`.
238
- const transcriptPath = input.transcript_path || input.transcriptPath || '';
239
- if (transcriptPath) {
257
+ if (transcriptPath) {
240
258
  const match = findActiveSessionByTranscript(vaultBase, transcriptPath);
241
259
  if (match) {
242
260
  // fail-safe: a nota do registro pode ter sumido do disco (git stash/checkout/
@@ -262,7 +280,9 @@ function main() {
262
280
  status: 'active',
263
281
  started_at: startedAt,
264
282
  ended_at: '',
265
- transcript_path: transcriptPath,
283
+ transcript_path: transcriptPath,
284
+ transcript_id: identity.transcriptId,
285
+ provider: provider.id,
266
286
  });
267
287
  writeHookOutput({
268
288
  hookSpecificOutput: {
@@ -277,7 +297,7 @@ function main() {
277
297
  const summary = sessionSummaryFromInput(input);
278
298
  const { absPath, relPath } = allocateSessionPath(vaultBase, now, summary);
279
299
  const startedAt = formatLocalIso(now);
280
- writeFileSync(absPath, buildSessionContent({ relPath, now, summary, sessionId }), 'utf-8');
300
+ writeFileSync(absPath, buildSessionContent({ relPath, now, summary, sessionId, provider: provider.id }), 'utf-8');
281
301
  writeControl(vaultBase, {
282
302
  status: 'active',
283
303
  session_file: relPath,
@@ -289,8 +309,11 @@ function main() {
289
309
  session_file: relPath,
290
310
  status: 'active',
291
311
  started_at: startedAt,
292
- ended_at: '',
293
- });
312
+ ended_at: '',
313
+ provider: provider.id,
314
+ transcript_path: transcriptPath,
315
+ transcript_id: identity.transcriptId,
316
+ });
294
317
 
295
318
  writeHookOutput({
296
319
  hookSpecificOutput: {
@@ -298,7 +321,7 @@ function main() {
298
321
  additionalContext: buildAdditionalContext({ relPath, startedAt, vaultBase }),
299
322
  },
300
323
  systemMessage: [
301
- `Sessão ${providerMeta().label} criada em ${relPath}.`,
324
+ `Sessão ${provider.label} criada em ${relPath}.`,
302
325
  `${basename(controlPath(vaultBase))} atualizado.`,
303
326
  'Iterações devem ser anexadas, nunca sobrescritas.',
304
327
  ].join(' '),
@@ -4,11 +4,12 @@ import { join } from 'path';
4
4
  import { request } from 'http';
5
5
  import { pathToFileURL } from 'url';
6
6
  import { createLinkedNotes } from './linked-notes.mjs';
7
- import { addUsage, costBreakdown, emptyTokenUsage, normalizeClaudeUsage, normalizeCodexUsage, priceForModel, updateSessionUsage } from './token-usage.mjs';
7
+ import { addUsage, costBreakdown, emptyTokenUsage, normalizeClaudeUsage, normalizeCodexUsage, priceForModel } from './token-usage.mjs';
8
8
  import { buildBrainDigest, buildBrainIndex } from './brain-core.mjs';
9
9
  import { activeChangeLink, pruneChangeSentinels } from './change-core.mjs';
10
10
  import { getLocale } from './locale.mjs';
11
- import { upsertSubagentUsage } from './subagent-usage.mjs';
11
+ import { updateSessionObservability } from './session-observability.mjs';
12
+ import { resolveSessionEntry } from './session-identity.mjs';
12
13
  import {
13
14
  ensureDir,
14
15
  findActiveSessionByTranscript,
@@ -704,8 +705,9 @@ function applyDedicatedSections(content, tx) {
704
705
  function insertIntoIteracoes(content, block) {
705
706
  const iter = content.indexOf('\n## Iterações');
706
707
  if (iter !== -1) {
707
- const anchors = [
708
- '\n## Uso de tokens e custos',
708
+ const anchors = [
709
+ '\n## Agentes, tokens e custos',
710
+ '\n## Uso de tokens e custos',
709
711
  '\n## Decisões geradas nesta sessão',
710
712
  '\n## Bugs gerados nesta sessão',
711
713
  '\n## Aprendizados gerados nesta sessão',
@@ -855,7 +857,7 @@ function replacePendingSection(content, pending) {
855
857
  const end = content.indexOf(closingMarker, start + marker.length);
856
858
  if (end === -1) return content;
857
859
 
858
- // Preserva seções que outros writers inseriram dentro do span (## Subagents & Workflows,
860
+ // Preserva seções que outros writers inseriram dentro do span (observabilidade,
859
861
  // ## Progresso do plano, ## Mudanças…) — só o texto das Pendências em si é regenerado.
860
862
  const span = content.slice(start + marker.length, end);
861
863
  const innerIdx = span.indexOf('\n## ');
@@ -1021,15 +1023,20 @@ function main() {
1021
1023
 
1022
1024
  const vaultBase = getVaultBase(input);
1023
1025
  warnIfDefaultVault(input);
1024
- const control = readControl(vaultBase);
1025
- const transcriptPath = input.transcript_path || input.transcriptPath || '';
1026
+ const control = readControl(vaultBase);
1027
+ const transcriptPath = input.transcript_path || input.transcriptPath || '';
1028
+ const { identity, entry } = resolveSessionEntry(vaultBase, input);
1029
+ if (identity.state !== 'resolved' || !entry?.session_file) {
1030
+ process.stderr.write(`[wendkeep] Stop sem identidade segura: ${identity.diagnostics?.join('; ') || 'sessão não registrada'}\n`);
1031
+ writeHookOutput({});
1032
+ return;
1033
+ }
1026
1034
 
1027
1035
  // Roteia o turn pela sessão DO PRÓPRIO transcript (registry), não pelo
1028
1036
  // CURRENT_SESSION global — que sessões concorrentes sobrescrevem, fazendo
1029
1037
  // o turn cair na nota de outra conversa. Sem match por transcript NÃO caímos
1030
1038
  // no global (contaminaria nota alheia): pulamos e o backfill recupera depois.
1031
- const matched = transcriptPath ? findActiveSessionByTranscript(vaultBase, transcriptPath) : null;
1032
- const sessionRel = matched?.session_file || '';
1039
+ const sessionRel = entry.session_file;
1033
1040
  if (!sessionRel) {
1034
1041
  writeHookOutput({});
1035
1042
  return;
@@ -1043,7 +1050,7 @@ function main() {
1043
1050
 
1044
1051
  const tx = parseTranscript(input.transcript_path || input.transcriptPath);
1045
1052
  const turnId = input.turn_id || tx.latestTurnId || String(Date.now());
1046
- const sessionId = matched?.sessionId || control.session_id || tx.sessionId;
1053
+ const sessionId = identity.canonicalConversationId;
1047
1054
  const logged = insertIteration(sessionPath, buildIterationBlock(tx, input), turnId, tx);
1048
1055
 
1049
1056
  try {
@@ -1053,24 +1060,11 @@ function main() {
1053
1060
  }
1054
1061
 
1055
1062
  try {
1056
- updateSessionUsage({
1057
- vaultBase,
1058
- sessionRel,
1059
- sessionPath,
1060
- transcriptPath,
1061
- });
1063
+ updateSessionObservability({ sessionPath, transcriptPath, caller: 'stop', canonicalConversationId: sessionId });
1062
1064
  } catch (error) {
1063
1065
  process.stderr.write(`[wendkeep] Token usage falhou: ${error.message}\n`);
1064
1066
  }
1065
1067
 
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
1068
  if (!shouldFinalizeSession()) {
1075
1069
  writeControl(vaultBase, {
1076
1070
  ...control,
@@ -1088,7 +1082,9 @@ function main() {
1088
1082
  // started_at de sessões concorrentes que sobrescrevem o ponteiro global.
1089
1083
  ended_at: '',
1090
1084
  last_turn_id: logged ? turnId : control.last_logged_turn_id,
1091
- transcript_path: transcriptPath,
1085
+ transcript_path: transcriptPath,
1086
+ transcript_id: identity.transcriptId,
1087
+ provider: identity.provider,
1092
1088
  });
1093
1089
  pingObsidianVault(input.obsidian_api_key);
1094
1090
  writeHookOutput({});
@@ -1107,7 +1103,9 @@ function main() {
1107
1103
  // grafo quando a change fechava antes do turno seguinte. Aqui sobrevive ao reopen e acumula toda
1108
1104
  // change que passou pela sessão (upsertListSection deduplica). Fail-quiet: nunca derruba o Stop.
1109
1105
  try {
1110
- const chgLink = activeChangeLink(vaultBase);
1106
+ const chgLink = entry.change_slug
1107
+ ? `Change ativa: [[${getLocale(vaultBase).folders.changes}/${entry.change_slug}/proposta]]`
1108
+ : activeChangeLink(vaultBase);
1111
1109
  const wl = (chgLink.match(/\[\[[^\]]+\]\]/) || [])[0];
1112
1110
  if (wl) {
1113
1111
  let cur = readFileSync(sessionPath, 'utf8');
@@ -1131,7 +1129,9 @@ function main() {
1131
1129
  // started_at omitido: preserva o da própria entry (ver branch acima).
1132
1130
  ended_at: endedAt,
1133
1131
  last_turn_id: turnId,
1134
- transcript_path: transcriptPath,
1132
+ transcript_path: transcriptPath,
1133
+ transcript_id: identity.transcriptId,
1134
+ provider: identity.provider,
1135
1135
  });
1136
1136
 
1137
1137
  // Reconstrói índice (camada fria) + digest (camada quente) ao finalizar. Nunca derruba o Stop.
@@ -1,25 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
  // SubagentStop hook: refresh this session's subagent/workflow telemetry the MOMENT a subagent
3
- // finishes — not only at the main Stop. Resilience: a session that never reaches Stop (crash,
4
- // window closed) still gets its subagent cost notes. Reuses the same upsertSubagentUsage the Stop
5
- // hook runs, so the output is identical; it just runs earlier + incrementally. Fail-open.
3
+ // finishes — not only at the main Stop. It recomposes the complete main + subagent snapshot
4
+ // through the same atomic writer used by Stop/import/rebuild. Fail-open.
6
5
  //
7
6
  // Model choice for subagents stays the harness's job (agent frontmatter `model:` / the Task/
8
7
  // workflow `model` param). wendkeep OBSERVES (this telemetry) rather than dictating a routing rule.
9
8
  import { existsSync } from 'fs';
10
9
  import { join } from 'path';
11
10
  import { pathToFileURL } from 'url';
12
- import { readHookInput, writeHookOutput, getVaultBase, findActiveSessionByTranscript, readControl } from './obsidian-common.mjs';
13
- import { upsertSubagentUsage } from './subagent-usage.mjs';
11
+ import { readHookInput, writeHookOutput, getVaultBase } from './obsidian-common.mjs';
12
+ import { updateSessionObservability } from './session-observability.mjs';
13
+ import { resolveSessionEntry } from './session-identity.mjs';
14
14
 
15
15
  export function refreshSubagents(vaultBase, input) {
16
- const transcriptPath = input.transcript_path || input.transcriptPath || '';
17
- const matched = transcriptPath ? findActiveSessionByTranscript(vaultBase, transcriptPath) : null;
18
- const sessionRel = matched?.session_file || readControl(vaultBase).session_file || '';
16
+ const { identity, entry } = resolveSessionEntry(vaultBase, input);
17
+ if (identity.state !== 'resolved') return false;
18
+ const transcriptPath = identity.transcriptPath;
19
+ const sessionRel = entry?.session_file || '';
19
20
  if (!sessionRel) return false;
20
21
  const sessionPath = join(vaultBase, sessionRel);
21
22
  if (!existsSync(sessionPath)) return false;
22
- upsertSubagentUsage(sessionPath, transcriptPath);
23
+ updateSessionObservability({ sessionPath, transcriptPath, caller: 'subagent-stop', canonicalConversationId: identity.canonicalConversationId });
23
24
  return true;
24
25
  }
25
26
 
@@ -69,7 +69,7 @@ function readWorkflowRuns(sessionDir) {
69
69
  }
70
70
 
71
71
  function tokensTotal(t = {}) {
72
- return (t.input || 0) + (t.cached || 0) + (t.cacheWrite || 0) + (t.output || 0);
72
+ return Number(t.total || 0) || ((t.input || 0) + (t.cached || 0) + (t.cacheWrite || 0) + (t.output || 0));
73
73
  }
74
74
 
75
75
  const round4 = (n) => Math.round((Number(n) || 0) * 10000) / 10000;
@@ -87,7 +87,7 @@ export function collectSubagentUsage(sessionDir) {
87
87
  const subagents = [];
88
88
  const wf = {};
89
89
  const allTools = new Set();
90
- const usageAgg = { input: 0, cached: 0, cacheWrite: 0, output: 0 };
90
+ const usageAgg = { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0, total: 0 };
91
91
  let count = 0;
92
92
  let calls = 0;
93
93
  let cost = 0;
@@ -108,6 +108,7 @@ export function collectSubagentUsage(sessionDir) {
108
108
  agentType,
109
109
  workflow,
110
110
  model: summary.models[0] || '?',
111
+ effort: summary.pensamento || '',
111
112
  tools: summary.tools.length,
112
113
  toolNames: summary.tools,
113
114
  calls: summary.calls,
@@ -117,11 +118,14 @@ export function collectSubagentUsage(sessionDir) {
117
118
  });
118
119
 
119
120
  for (const row of summary.modelRows || []) {
120
- const key = `${row.provider || '?'}\u0000${row.model || '?'}`;
121
- const current = modelMap.get(key) || { provider: row.provider || '?', model: row.model || '?', calls: 0, tokens: 0, cost: 0 };
121
+ const rowEffort = summary.pensamento || '';
122
+ const key = `${row.provider || '?'}\u0000${row.model || '?'}\u0000${rowEffort}`;
123
+ const current = modelMap.get(key) || { provider: row.provider || '?', model: row.model || '?', effort: rowEffort, calls: 0, tokens: 0, cost: 0,
124
+ usage: { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0, total: 0 } };
122
125
  current.calls += row.calls || 0;
123
126
  current.tokens += tokensTotal(row.usage);
124
127
  current.cost += row.costs?.model || 0;
128
+ for (const k of Object.keys(current.usage)) current.usage[k] += row.usage?.[k] || 0;
125
129
  modelMap.set(key, current);
126
130
  }
127
131
 
@@ -8,8 +8,9 @@
8
8
  import { existsSync, readFileSync, writeFileSync } from 'fs';
9
9
  import { join } from 'path';
10
10
  import { pathToFileURL } from 'url';
11
- import { readHookInput, writeHookOutput, getVaultBase, findActiveSessionByTranscript, readControl, formatHourMinute } from './obsidian-common.mjs';
11
+ import { readHookInput, writeHookOutput, getVaultBase, formatHourMinute } from './obsidian-common.mjs';
12
12
  import { getLocale } from './locale.mjs';
13
+ import { resolveSessionEntry } from './session-identity.mjs';
13
14
 
14
15
  // Pull the task's human text from whatever field the payload carries.
15
16
  export function taskText(input) {
@@ -41,9 +42,9 @@ export function appendProgress(content, line, heading) {
41
42
  export function logTask(vaultBase, input) {
42
43
  const text = taskText(input);
43
44
  if (!text) return false;
44
- const transcriptPath = input.transcript_path || input.transcriptPath || '';
45
- const matched = transcriptPath ? findActiveSessionByTranscript(vaultBase, transcriptPath) : null;
46
- const sessionRel = matched?.session_file || readControl(vaultBase).session_file || '';
45
+ const { identity, entry } = resolveSessionEntry(vaultBase, input);
46
+ if (identity.state !== 'resolved') return false;
47
+ const sessionRel = entry?.session_file || '';
47
48
  if (!sessionRel) return false;
48
49
  const sessionPath = join(vaultBase, sessionRel);
49
50
  if (!existsSync(sessionPath)) return false;