wtt-connect 0.2.62 → 0.2.64

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wtt-connect",
3
- "version": "0.2.62",
3
+ "version": "0.2.64",
4
4
  "private": false,
5
5
  "description": "WTT-native connector daemon for Codex, Claude Code, Cursor, Gemini, ACP, and other coding agent surfaces.",
6
6
  "type": "module",
package/src/runner.js CHANGED
@@ -21,6 +21,7 @@ import { runShellCommand } from './shell-runner.js';
21
21
  import { TerminalSessionManager } from './terminal-session.js';
22
22
  import { isModelSwitcherEnabled, switchModelProvider } from './model-switcher.js';
23
23
  import { formatSlashCommandHelp, isAgentSlashCommand, parseSlashCommand, slashCommandsForAdapter } from './slash.js';
24
+ import { createUsageCollector, taskUsagePatch } from './usage.js';
24
25
 
25
26
  const TERMINAL_STATUSES = new Set(['review', 'done', 'approved', 'cancelled']);
26
27
  const GENERATED_FILE_EXTENSIONS = new Set([
@@ -184,12 +185,14 @@ export class Runner {
184
185
  await this.wtt.typing(topicId, 'start', { statusText: 'Agent 已接收消息,正在准备执行', statusKind: 'queued', ttlMs: 30000 });
185
186
  let runtimeSelection = null;
186
187
  let chatStream = null;
188
+ let usageCollector = null;
187
189
  try {
188
190
  const transcripts = await this.transcribeAttachments(staged.files);
189
191
  const modelConfig = modelConfigFromMessage(m);
190
192
  const adapter = this.registry.select({ ...m, content, model_config: modelConfig });
193
+ usageCollector = createUsageCollector(adapter.name, modelConfig);
191
194
  runtimeSelection = { adapter: adapter.name, modelConfig };
192
- chatStream = this.createChatStream(topicId, adapter.name, modelConfig);
195
+ chatStream = this.createChatStream(topicId, adapter.name, modelConfig, () => usageCollector?.summary());
193
196
  await chatStream.start();
194
197
  this.recordRuntimeSelection(adapter.name, modelConfig, 'running');
195
198
  await this.wtt.typing(topicId, 'start', { statusText: `${adapterDisplayName(adapter.name)} 正在执行${modelConfig.model ? ` · ${modelConfig.model}` : ''}`, statusKind: 'running', adapter: adapter.name, model: modelConfig.model || undefined, ttlMs: 30000 });
@@ -211,18 +214,23 @@ export class Runner {
211
214
  modelConfig,
212
215
  files: staged.files,
213
216
  images: staged.images,
214
- onProgress: (event) => this.handleChatProgress(topicId, event, adapter.name, chatStream),
217
+ onProgress: (event) => {
218
+ usageCollector?.observe(event);
219
+ return this.handleChatProgress(topicId, event, adapter.name, chatStream);
220
+ },
215
221
  });
216
222
  if (localSlash !== null) {
217
223
  const reply = stripHiddenContextLeak(localSlash || '(empty response)') || '(empty response)';
218
224
  await chatStream.finish(reply);
219
225
  await this.wtt.publish(topicId, reply, 'CHAT_REPLY', chatStream.finalMetadata());
226
+ await this.reportUsageBestEffort(usageCollector?.summary(), { runId: chatStream.id, topicId, messageId: m.id });
220
227
  log('info', 'slash command replied', { topicId, adapter: adapter.name, chars: reply.length });
221
228
  return;
222
229
  }
223
230
  }
231
+ const skillSlashPrompt = isSlashPassthrough ? this.skillSlashPrompt(content, m.metadata) : '';
224
232
  const compactSummary = this.store.getSession(sessionKey).compactSummary || '';
225
- const prompt = isSlashPassthrough ? content : [
233
+ const prompt = isSlashPassthrough ? (skillSlashPrompt || content) : [
226
234
  'You are replying to a WTT Web conversation. Do not mention implementation internals unless asked.',
227
235
  `WTT topic_id: ${topicId}`,
228
236
  `WTT topic_type: ${messageTopicType(m) || 'unknown'}`,
@@ -255,13 +263,17 @@ export class Runner {
255
263
  modelConfig,
256
264
  files: staged.files,
257
265
  images: staged.images,
258
- onProgress: (event) => this.handleChatProgress(topicId, event, adapter.name, chatStream),
266
+ onProgress: (event) => {
267
+ usageCollector?.observe(event);
268
+ return this.handleChatProgress(topicId, event, adapter.name, chatStream);
269
+ },
259
270
  });
260
271
  const prepared = prepareGeneratedFileArtifacts(stripHiddenContextLeak(output || '(empty response)') || '(empty response)');
261
272
  const reply = prepared.text || '(empty response)';
262
273
  await chatStream.finish(reply);
263
274
  await this.maybeAttachSpeech(topicId, reply, `chat-${topicId}`);
264
275
  await this.wtt.publish(topicId, reply, 'CHAT_REPLY', chatStream.finalMetadata());
276
+ await this.reportUsageBestEffort(usageCollector?.summary(), { runId: chatStream.id, topicId, messageId: m.id });
265
277
  await this.publishGeneratedFileArtifacts(topicId, prepared.refs, { source: 'chat', sourceId: topicId, adapter: adapter.name });
266
278
  log('info', 'chat replied', { topicId, chars: reply.length });
267
279
  } catch (err) {
@@ -284,6 +296,7 @@ export class Runner {
284
296
  const transcripts = await this.transcribeAttachments(staged.files);
285
297
  const modelConfig = modelConfigFromMessage(task);
286
298
  const adapter = this.registry.select({ ...task, model_config: modelConfig });
299
+ const usageCollector = createUsageCollector(adapter.name, modelConfig);
287
300
  this.recordRuntimeSelection(adapter.name, modelConfig, 'running');
288
301
  const agentProfile = await this.getAgentProfile();
289
302
  if (topicId) await this.wtt.typing(topicId, 'start', { statusText: `${adapterDisplayName(adapter.name)} 正在执行任务${modelConfig.model ? ` · ${modelConfig.model}` : ''}`, statusKind: 'running', adapter: adapter.name, model: modelConfig.model || undefined, ttlMs: 30000 });
@@ -314,14 +327,18 @@ export class Runner {
314
327
  modelConfig,
315
328
  files: staged.files,
316
329
  images: staged.images,
317
- onProgress: (ev) => topicId ? this.maybePublishProgress(topicId, ev, adapter.name) : Promise.resolve(),
330
+ onProgress: (ev) => {
331
+ usageCollector.observe(ev);
332
+ return topicId ? this.maybePublishProgress(topicId, ev, adapter.name) : Promise.resolve();
333
+ },
318
334
  });
319
335
  const prepared = prepareGeneratedFileArtifacts(output || '(empty response)');
320
336
  const summary = prepared.text || '(empty response)';
321
337
  await this.maybeRelayArenaOpenCLResult(task, summary);
322
338
  const artifact = await this.materializeTaskArtifact(taskId, summary);
323
339
  const commitIds = extractCommitIds(summary);
324
- const patch = { status: 'review', progress: 100, output: summary, summary, usage_source: `wtt-connect/${adapter.name}` };
340
+ const usage = usageCollector.summary();
341
+ const patch = { status: 'review', progress: 100, output: summary, summary, usage_source: `wtt-connect/${adapter.name}`, ...taskUsagePatch(usage) };
325
342
  if (commitIds.length) patch.commit_id = commitIds[0];
326
343
  await this.api.patchTask(taskId, patch);
327
344
  if (topicId) {
@@ -329,6 +346,7 @@ export class Runner {
329
346
  await this.publishGeneratedFileArtifacts(topicId, prepared.refs, { source: 'task', sourceId: taskId, adapter: adapter.name });
330
347
  if (artifact?.asset?.url) await this.wtt.publish(topicId, `Artifact: ${artifact.asset.url}`, 'TASK_ARTIFACT');
331
348
  }
349
+ await this.reportUsageBestEffort(usage, { runId: `task-${taskId}-${Date.now().toString(36)}`, topicId, taskId });
332
350
  log('info', 'task completed', { taskId, chars: summary.length });
333
351
  } catch (err) {
334
352
  await this.api.patchTask(taskId, { status: 'blocked', output: String(err.message || err) });
@@ -417,6 +435,12 @@ export class Runner {
417
435
  return `${adapterDisplayName(adapter.name)} session cleared for this WTT topic. The next turn starts a fresh agent thread/session.`;
418
436
  }
419
437
 
438
+ skillSlashPrompt(content, metadata = null) {
439
+ const runtime = this.runtimeInfo();
440
+ const commands = Array.isArray(runtime.skill_commands) ? runtime.skill_commands : [];
441
+ return buildSkillSlashPrompt(content, metadata, commands);
442
+ }
443
+
420
444
  async compactAdapterSession(adapter, context = {}, command = '/compact', args = '') {
421
445
  const sessionKey = context.sessionKey || 'default';
422
446
  const topicId = String(context.topicId || '').trim();
@@ -626,7 +650,7 @@ export class Runner {
626
650
  }
627
651
  }
628
652
 
629
- createChatStream(topicId, adapterName = 'agent', modelConfig = {}) {
653
+ createChatStream(topicId, adapterName = 'agent', modelConfig = {}, usageProvider = null) {
630
654
  const enabled = this.config.messageStream !== false && Boolean(topicId);
631
655
  const streamId = `wtt-${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 8)}`;
632
656
  let seq = 0;
@@ -649,11 +673,13 @@ export class Runner {
649
673
  return {
650
674
  id: streamId,
651
675
  finalMetadata() {
676
+ const usage = typeof usageProvider === 'function' ? usageProvider() : null;
652
677
  return {
653
678
  stream_id: streamId,
654
679
  streamId,
655
680
  adapter: adapterName,
656
681
  model: modelConfig.model || modelConfig.model_id || modelConfig.modelId || '',
682
+ ...(usage ? { usage } : {}),
657
683
  };
658
684
  },
659
685
  async start() {
@@ -699,6 +725,17 @@ export class Runner {
699
725
  ]);
700
726
  }
701
727
 
728
+ async reportUsageBestEffort(usage, { runId = '', topicId = '', taskId = '', messageId = '' } = {}) {
729
+ if (!usage) return;
730
+ await this.api.reportAgentUsage({
731
+ ...usage,
732
+ run_id: runId,
733
+ topic_id: topicId,
734
+ task_id: taskId,
735
+ message_id: messageId,
736
+ });
737
+ }
738
+
702
739
  async maybePublishProgress(topicId, event, adapterName = 'agent') {
703
740
  if (!topicId) return;
704
741
  const normalized = normalizeAgentEvent(adapterName, event);
@@ -734,6 +771,33 @@ export class Runner {
734
771
  }
735
772
  }
736
773
 
774
+ export function buildSkillSlashPrompt(content, metadata = null, commands = []) {
775
+ const parsed = parseSlashCommand(content);
776
+ if (!parsed) return '';
777
+ const meta = parseMetadata(metadata);
778
+ const metadataSkillId = String(meta?.skill_id || meta?.skillId || '').trim();
779
+ const metadataFamily = String(meta?.command_family || meta?.commandFamily || '').trim().toLowerCase();
780
+ const matched = (Array.isArray(commands) ? commands : []).find((item) => String(item?.cmd || '').trim().toLowerCase() === parsed.command)
781
+ || (metadataFamily === 'skill' || metadataSkillId ? {
782
+ cmd: parsed.command,
783
+ skill_id: metadataSkillId || parsed.command.replace(/^\/+/, ''),
784
+ name: metadataSkillId || parsed.command.replace(/^\/+/, ''),
785
+ desc: '',
786
+ } : null);
787
+ if (!matched) return '';
788
+ const skillName = String(matched.name || matched.skill_id || matched.cmd || '').trim().replace(/^\/+/, '');
789
+ const skillId = String(matched.skill_id || skillName).trim();
790
+ const args = String(parsed.args || '').trim();
791
+ return [
792
+ `Use the ${skillName} skill${skillId && skillId !== skillName ? ` (${skillId})` : ''}.`,
793
+ matched.desc ? `Skill description: ${matched.desc}` : null,
794
+ '',
795
+ args ? `User request: ${args}` : 'User request: Run this skill for the current conversation.',
796
+ '',
797
+ `Original slash command: ${content}`,
798
+ ].filter(Boolean).join('\n');
799
+ }
800
+
737
801
  function adapterDisplayName(name) {
738
802
  if (name === 'claude-code') return 'Claude Code';
739
803
  if (name === 'codex') return 'Codex';
@@ -3,6 +3,9 @@ import path from 'node:path';
3
3
  import fs from 'node:fs';
4
4
  import { execFileSync } from 'node:child_process';
5
5
 
6
+ let skillCommandCache = { at: 0, commands: [] };
7
+ const SKILL_COMMAND_CACHE_MS = 60_000;
8
+
6
9
  export function buildRuntimeInfo(config, runtimeState = {}) {
7
10
  const workdir = resolveWorkDir(config.workDir);
8
11
  const git = gitInfo(workdir);
@@ -30,10 +33,145 @@ export function buildRuntimeInfo(config, runtimeState = {}) {
30
33
  node: process.version,
31
34
  mode: config.mode,
32
35
  git,
36
+ skill_commands: discoverSkillCommands(config, workdir),
33
37
  updated_at: new Date().toISOString(),
34
38
  };
35
39
  }
36
40
 
41
+ export function discoverSkillCommands(config = {}, workdir = resolveWorkDir(config.workDir)) {
42
+ const now = Date.now();
43
+ if (now - skillCommandCache.at < SKILL_COMMAND_CACHE_MS) return skillCommandCache.commands;
44
+ const roots = skillRoots(config, workdir);
45
+ const seenCommands = new Set();
46
+ const commands = [];
47
+ for (const root of roots) {
48
+ for (const skillFile of findSkillFiles(root, 5)) {
49
+ const parsed = parseSkillFile(skillFile);
50
+ if (!parsed.id) continue;
51
+ const commandName = normalizeSkillCommandName(parsed.name || parsed.id);
52
+ if (!commandName) continue;
53
+ const cmd = `/${commandName}`;
54
+ const key = cmd.toLowerCase();
55
+ if (seenCommands.has(key)) continue;
56
+ seenCommands.add(key);
57
+ commands.push({
58
+ cmd,
59
+ desc: parsed.description || `Use ${parsed.name || parsed.id} skill`,
60
+ family: 'skill',
61
+ mode: 'passthrough',
62
+ skill_id: parsed.id,
63
+ name: parsed.name || parsed.id,
64
+ source: parsed.source,
65
+ });
66
+ if (commands.length >= 120) break;
67
+ }
68
+ if (commands.length >= 120) break;
69
+ }
70
+ skillCommandCache = { at: now, commands };
71
+ return commands;
72
+ }
73
+
74
+ function skillRoots(config = {}, workdir = '') {
75
+ const home = os.homedir();
76
+ const roots = [
77
+ path.join(home, '.codex', 'skills'),
78
+ path.join(home, '.agents', 'skills'),
79
+ path.join(home, '.claude', 'skills'),
80
+ path.join(home, '.claude', 'mattpocock-skills', 'skills'),
81
+ workdir ? path.join(workdir, '.codex', 'skills') : '',
82
+ workdir ? path.join(workdir, '.claude', 'skills') : '',
83
+ process.cwd() ? path.join(process.cwd(), '.codex', 'skills') : '',
84
+ process.cwd() ? path.join(process.cwd(), '.claude', 'skills') : '',
85
+ ];
86
+ const extra = String(process.env.WTT_CONNECT_SKILL_ROOTS || config.skillRoots || '').split(path.delimiter);
87
+ return Array.from(new Set(
88
+ [...roots, ...extra]
89
+ .map((item) => String(item || '').trim())
90
+ .filter(Boolean)
91
+ .map((item) => path.resolve(item))
92
+ ));
93
+ }
94
+
95
+ function findSkillFiles(root, maxDepth = 4) {
96
+ const out = [];
97
+ if (!root || !fs.existsSync(root)) return out;
98
+ const visit = (dir, depth) => {
99
+ if (depth < 0 || out.length >= 160) return;
100
+ let entries = [];
101
+ try {
102
+ entries = fs.readdirSync(dir, { withFileTypes: true });
103
+ } catch {
104
+ return;
105
+ }
106
+ if (entries.some((entry) => entry.isFile() && entry.name === 'SKILL.md')) {
107
+ out.push(path.join(dir, 'SKILL.md'));
108
+ return;
109
+ }
110
+ for (const entry of entries) {
111
+ if (!entry.isDirectory()) continue;
112
+ if (entry.name === 'node_modules' || entry.name.startsWith('.git')) continue;
113
+ visit(path.join(dir, entry.name), depth - 1);
114
+ if (out.length >= 160) return;
115
+ }
116
+ };
117
+ visit(root, maxDepth);
118
+ return out;
119
+ }
120
+
121
+ function parseSkillFile(file) {
122
+ try {
123
+ const text = fs.readFileSync(file, 'utf8');
124
+ const fallbackId = path.basename(path.dirname(file));
125
+ const frontmatter = text.match(/^---\s*\n([\s\S]*?)\n---/);
126
+ const meta = frontmatter ? parseSimpleYaml(frontmatter[1]) : {};
127
+ const id = String(meta.name || fallbackId || '').trim();
128
+ return {
129
+ id,
130
+ name: id,
131
+ description: String(meta.description || firstMarkdownSentence(text) || '').trim(),
132
+ source: sourceForSkillPath(file),
133
+ };
134
+ } catch {
135
+ return { id: '', name: '', description: '', source: 'local' };
136
+ }
137
+ }
138
+
139
+ function parseSimpleYaml(text) {
140
+ const out = {};
141
+ for (const line of String(text || '').split(/\r?\n/)) {
142
+ const match = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
143
+ if (!match) continue;
144
+ const key = match[1];
145
+ let value = match[2].trim();
146
+ value = value.replace(/^['"]|['"]$/g, '');
147
+ out[key] = value;
148
+ }
149
+ return out;
150
+ }
151
+
152
+ function firstMarkdownSentence(text) {
153
+ const body = String(text || '').replace(/^---\s*\n[\s\S]*?\n---/, '').trim();
154
+ const line = body.split(/\r?\n/).map((item) => item.trim().replace(/^#+\s*/, '')).find(Boolean);
155
+ return line || '';
156
+ }
157
+
158
+ function normalizeSkillCommandName(value) {
159
+ const raw = String(value || '').trim().split(':').pop() || '';
160
+ return raw
161
+ .replace(/[^A-Za-z0-9_-]+/g, '-')
162
+ .replace(/^-+|-+$/g, '')
163
+ .slice(0, 80)
164
+ .toLowerCase();
165
+ }
166
+
167
+ function sourceForSkillPath(file) {
168
+ const normalized = String(file || '');
169
+ if (normalized.includes(`${path.sep}.codex${path.sep}`)) return 'codex-local';
170
+ if (normalized.includes(`${path.sep}.claude${path.sep}`)) return 'claude-local';
171
+ if (normalized.includes(`${path.sep}.agents${path.sep}`)) return 'agents-local';
172
+ return 'local';
173
+ }
174
+
37
175
  function runtimeModel(config, adapter, runtimeState = {}) {
38
176
  const fromRun = String(runtimeState.current_model || runtimeState.model || '').trim();
39
177
  if (fromRun) return fromRun;
package/src/usage.js ADDED
@@ -0,0 +1,207 @@
1
+ export function createUsageCollector(adapter = 'agent', modelConfig = {}) {
2
+ let best = null;
3
+ let responseModel = '';
4
+ let rawUsage = null;
5
+
6
+ function observe(event) {
7
+ if (!event || typeof event !== 'object') return;
8
+ responseModel = extractModel(event) || responseModel;
9
+ const usage = extractUsage(event);
10
+ if (!usage) return;
11
+ rawUsage = usage.raw_usage || usage;
12
+ if (!best || scoreUsage(usage) >= scoreUsage(best)) best = usage;
13
+ }
14
+
15
+ function summary() {
16
+ if (!best) return null;
17
+ const inputTokens = safeInt(best.input_tokens);
18
+ const outputTokens = safeInt(best.output_tokens);
19
+ const cacheReadTokens = safeInt(best.cache_read_tokens);
20
+ const cacheWriteTokens = safeInt(best.cache_write_tokens);
21
+ const cacheTokens = safeInt(best.cache_tokens) + cacheReadTokens + cacheWriteTokens;
22
+ const totalTokens = safeInt(best.total_tokens) || inputTokens + outputTokens + cacheTokens;
23
+ if (!totalTokens && !inputTokens && !outputTokens && !cacheTokens) return null;
24
+ return {
25
+ adapter,
26
+ provider: providerForAdapter(adapter),
27
+ request_model: modelFromConfig(modelConfig),
28
+ response_model: responseModel,
29
+ input_tokens: inputTokens,
30
+ output_tokens: outputTokens,
31
+ cache_read_tokens: cacheReadTokens,
32
+ cache_write_tokens: cacheWriteTokens,
33
+ cache_tokens: cacheTokens,
34
+ total_tokens: totalTokens,
35
+ source: sourceForAdapter(adapter),
36
+ raw_usage: compactRawUsage(rawUsage),
37
+ };
38
+ }
39
+
40
+ return { observe, summary };
41
+ }
42
+
43
+ export function taskUsagePatch(usage) {
44
+ if (!usage) return {};
45
+ return {
46
+ usage_prompt_tokens: safeInt(usage.input_tokens),
47
+ usage_completion_tokens: safeInt(usage.output_tokens),
48
+ usage_cache_read_tokens: safeInt(usage.cache_read_tokens) + safeInt(usage.cache_tokens),
49
+ usage_cache_write_tokens: safeInt(usage.cache_write_tokens),
50
+ usage_total_tokens: safeInt(usage.total_tokens),
51
+ usage_source: usage.source || 'wtt-connect',
52
+ };
53
+ }
54
+
55
+ function extractUsage(event) {
56
+ const direct = findUsageObject(event);
57
+ if (direct) return normalizeUsage(direct);
58
+ const stats = event?.stats;
59
+ if (stats && typeof stats === 'object') {
60
+ const models = stats.models;
61
+ if (models && typeof models === 'object' && !Array.isArray(models)) {
62
+ return normalizeUsage(aggregateModelStats(models));
63
+ }
64
+ return normalizeUsage(stats);
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function findUsageObject(value, depth = 0) {
70
+ if (!value || typeof value !== 'object' || depth > 5) return null;
71
+ for (const key of ['usage', 'usage_metadata', 'usageMetadata', 'token_usage', 'tokenUsage']) {
72
+ const candidate = value[key];
73
+ if (candidate && typeof candidate === 'object') return candidate;
74
+ }
75
+ if (looksLikeUsage(value)) return value;
76
+ for (const key of ['message', 'response', 'result', 'item', 'delta', 'event']) {
77
+ const found = findUsageObject(value[key], depth + 1);
78
+ if (found) return found;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ function looksLikeUsage(value) {
84
+ if (!value || typeof value !== 'object') return false;
85
+ return [
86
+ 'input_tokens',
87
+ 'prompt_tokens',
88
+ 'output_tokens',
89
+ 'completion_tokens',
90
+ 'total_tokens',
91
+ 'promptTokenCount',
92
+ 'candidatesTokenCount',
93
+ 'totalTokenCount',
94
+ ].some((key) => key in value);
95
+ }
96
+
97
+ function normalizeUsage(raw) {
98
+ if (!raw || typeof raw !== 'object') return null;
99
+ const inputTokens = firstInt(raw, [
100
+ 'input_tokens',
101
+ 'prompt_tokens',
102
+ 'promptTokens',
103
+ 'prompt_token_count',
104
+ 'promptTokenCount',
105
+ 'inputTokenCount',
106
+ ]);
107
+ const candidateTokens = firstInt(raw, [
108
+ 'output_tokens',
109
+ 'completion_tokens',
110
+ 'completionTokens',
111
+ 'completion_token_count',
112
+ 'candidatesTokenCount',
113
+ 'outputTokenCount',
114
+ ]);
115
+ const thoughtsTokens = firstInt(raw, ['thoughtsTokenCount', 'reasoning_tokens', 'reasoningTokens']);
116
+ const outputTokens = candidateTokens + thoughtsTokens;
117
+ const cacheReadTokens = firstInt(raw, [
118
+ 'cache_read_input_tokens',
119
+ 'cached_tokens',
120
+ 'cachedTokens',
121
+ 'cachedContentTokenCount',
122
+ ]);
123
+ const cacheWriteTokens = firstInt(raw, ['cache_creation_input_tokens', 'cache_write_input_tokens']);
124
+ const cacheTokens = firstInt(raw, ['cache_tokens', 'cacheTokens']);
125
+ const totalTokens = firstInt(raw, ['total_tokens', 'totalTokens', 'totalTokenCount'])
126
+ || inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + cacheTokens;
127
+ if (!inputTokens && !outputTokens && !cacheReadTokens && !cacheWriteTokens && !cacheTokens && !totalTokens) return null;
128
+ return {
129
+ input_tokens: inputTokens,
130
+ output_tokens: outputTokens,
131
+ cache_read_tokens: cacheReadTokens,
132
+ cache_write_tokens: cacheWriteTokens,
133
+ cache_tokens: cacheTokens,
134
+ total_tokens: totalTokens,
135
+ raw_usage: raw,
136
+ };
137
+ }
138
+
139
+ function aggregateModelStats(models) {
140
+ const totals = {};
141
+ for (const value of Object.values(models || {})) {
142
+ if (!value || typeof value !== 'object') continue;
143
+ const usage = normalizeUsage(value);
144
+ if (!usage) continue;
145
+ for (const key of ['input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'cache_tokens', 'total_tokens']) {
146
+ totals[key] = safeInt(totals[key]) + safeInt(usage[key]);
147
+ }
148
+ }
149
+ return totals;
150
+ }
151
+
152
+ function extractModel(event) {
153
+ if (!event || typeof event !== 'object') return '';
154
+ for (const key of ['model', 'model_id', 'modelId', 'current_model', 'response_model']) {
155
+ const value = event[key];
156
+ if (typeof value === 'string' && value.trim()) return value.trim();
157
+ }
158
+ return extractModel(event.message) || extractModel(event.response) || '';
159
+ }
160
+
161
+ function modelFromConfig(modelConfig = {}) {
162
+ return String(modelConfig.model || modelConfig.model_id || modelConfig.modelId || '').trim();
163
+ }
164
+
165
+ function firstInt(raw, keys) {
166
+ for (const key of keys) {
167
+ const value = raw?.[key];
168
+ const num = safeInt(value);
169
+ if (num) return num;
170
+ }
171
+ return 0;
172
+ }
173
+
174
+ function safeInt(value) {
175
+ const num = Number(value || 0);
176
+ if (!Number.isFinite(num) || num <= 0) return 0;
177
+ return Math.round(num);
178
+ }
179
+
180
+ function scoreUsage(usage) {
181
+ return safeInt(usage?.total_tokens) || safeInt(usage?.input_tokens) + safeInt(usage?.output_tokens) + safeInt(usage?.cache_tokens);
182
+ }
183
+
184
+ function providerForAdapter(adapter) {
185
+ if (adapter === 'claude-code') return 'anthropic-compatible';
186
+ if (adapter === 'codex') return 'openai-compatible';
187
+ if (adapter === 'gemini') return 'gemini';
188
+ return adapter || 'agent';
189
+ }
190
+
191
+ function sourceForAdapter(adapter) {
192
+ if (adapter === 'claude-code') return 'claude_code_stream_json';
193
+ if (adapter === 'codex') return 'codex_json';
194
+ if (adapter === 'gemini') return 'gemini_stream_json';
195
+ return `wtt-connect/${adapter || 'agent'}`;
196
+ }
197
+
198
+ function compactRawUsage(value) {
199
+ if (!value || typeof value !== 'object') return undefined;
200
+ try {
201
+ const json = JSON.stringify(value);
202
+ if (json.length <= 4000) return value;
203
+ return { truncated: true, total_tokens: safeInt(value.total_tokens || value.totalTokens || value.totalTokenCount) };
204
+ } catch {
205
+ return undefined;
206
+ }
207
+ }
package/src/wtt-api.js CHANGED
@@ -117,4 +117,17 @@ export class WTTApi {
117
117
  });
118
118
  }
119
119
 
120
+ async reportAgentUsage(usage) {
121
+ if (!usage || !this.config.agentId || !this.config.token) return null;
122
+ try {
123
+ return await this.request('POST', `/agents/${encodeURIComponent(this.config.agentId)}/usage`, {
124
+ headers: this.agentHeaders(),
125
+ json: usage,
126
+ });
127
+ } catch (err) {
128
+ log('warn', 'report_agent_usage failed', { error: err.message });
129
+ return null;
130
+ }
131
+ }
132
+
120
133
  }