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.
- package/CHANGELOG.md +29 -6
- package/README.md +4 -0
- package/README.pt-BR.md +4 -0
- package/bin/wendkeep.mjs +7 -1
- package/hooks/brain-inject.mjs +13 -6
- package/hooks/change-context.mjs +8 -4
- package/hooks/decision-capture.mjs +4 -2
- package/hooks/import-sessions.mjs +3 -7
- package/hooks/obsidian-common.mjs +116 -34
- package/hooks/plan-capture.mjs +8 -7
- package/hooks/session-ensure.mjs +36 -15
- package/hooks/session-identity.mjs +75 -0
- package/hooks/session-observability.mjs +176 -0
- package/hooks/session-start.mjs +41 -18
- package/hooks/session-stop.mjs +27 -27
- package/hooks/subagent-stop.mjs +10 -9
- package/hooks/subagent-usage.mjs +8 -4
- package/hooks/task-log.mjs +5 -4
- package/hooks/token-usage.mjs +58 -31
- package/hooks/vault-health.mjs +21 -13
- package/package.json +1 -1
- package/src/change.mjs +14 -2
- package/src/rebuild-costs.mjs +6 -8
- package/src/session.mjs +37 -0
|
@@ -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
|
+
}
|
package/hooks/session-start.mjs
CHANGED
|
@@ -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
|
|
157
|
-
const
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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 ${
|
|
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(' '),
|
package/hooks/session-stop.mjs
CHANGED
|
@@ -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
|
|
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
|
+
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##
|
|
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 (
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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.
|
package/hooks/subagent-stop.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
9
8
|
import { existsSync } from 'fs';
|
|
10
9
|
import { join } from 'path';
|
|
11
10
|
import { pathToFileURL } from 'url';
|
|
12
|
-
import { readHookInput, writeHookOutput, getVaultBase
|
|
13
|
-
import {
|
|
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
|
|
17
|
-
|
|
18
|
-
const
|
|
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
|
-
|
|
23
|
+
updateSessionObservability({ sessionPath, transcriptPath, caller: 'subagent-stop', canonicalConversationId: identity.canonicalConversationId });
|
|
23
24
|
return true;
|
|
24
25
|
}
|
|
25
26
|
|
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,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
|
|
121
|
-
const
|
|
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
|
|
package/hooks/task-log.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
45
|
-
|
|
46
|
-
const sessionRel =
|
|
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;
|