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 +1 -1
- package/src/runner.js +71 -7
- package/src/runtime-info.js +138 -0
- package/src/usage.js +207 -0
- package/src/wtt-api.js +13 -0
package/package.json
CHANGED
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) =>
|
|
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) =>
|
|
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) =>
|
|
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
|
|
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';
|
package/src/runtime-info.js
CHANGED
|
@@ -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
|
}
|