benchgo-plugin 0.0.0-stage → 0.0.2
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/DEEPSEEK-LICENSE +21 -0
- package/LICENSE +21 -0
- package/NOTICE +17 -0
- package/PASEO-LICENSE +211 -0
- package/README.md +256 -2
- package/TOOL-CONFIG.md +33 -0
- package/VERIFICATION.md +297 -0
- package/assets/fonts/README.md +13 -0
- package/assets/fonts/manifest.json +437 -0
- package/assets/fonts/web/fangyuan-latin-238bc49130b1.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u30-0fe8d99641c3.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u32-202a573131a1.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u4e-cc2f8b20be49.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u4f-9189b5f2d321.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u50-e2560e003872.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u51-a9c5e7a8c7c4.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u52-0cfd00b473c8.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u53-18122a07f60a.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u54-8ca41aa9cff2.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u55-7bd55e14647e.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u56-26f093e8a810.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u57-36bde83a8378.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u58-d1df14217098.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u59-4e41ab22c51a.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u5a-746a367174e6.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u5b-3d1dd404d70c.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u5c-fe0a273f69e7.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u5d-af1f0a1c8d2d.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u5e-598c65420e17.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u5f-a92eaaa5265a.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u60-48ee3d26462d.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u61-7a57f35bc077.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u62-d2d0b7b36631.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u63-cade7c6635e6.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u64-ed34e40d7f65.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u65-e13e83e69c38.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u66-1dbcfa5da147.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u67-6b0a26b82da6.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u68-1b8217cfceed.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u69-673e63627790.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u6a-3c94008a4640.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u6b-44572c410fa0.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u6c-1b323b366e88.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u6d-cbc376d6fe3f.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u6e-7f7e211e2fee.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u6f-ff280ce052fa.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u70-cb727eaf73b6.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u71-0bb1747439fe.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u72-cccd39590d69.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u73-e67705133584.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u74-03a4e7ac9df5.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u75-a2d30b327e65.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u76-c08fe029c988.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u77-76f03be1f4cb.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u78-5b721deaf582.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u79-db7713c2eda4.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u7a-3e40d3726a09.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u7b-a8204d1c9dba.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u7c-89bcdae046de.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u7d-bcc45fa6351a.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u7e-49fab96d9269.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u7f-dc110e1ecbf3.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u80-c5ce9be0335e.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u81-b026091b3779.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u82-a7c6960e875d.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u83-4b7d1c2c50d4.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u84-4119128100c8.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u85-ca43dd4c5411.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u86-54fc0e3158fb.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u87-b64f5eb55720.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u88-00feb1251f08.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u89-db419435b316.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u8a-06c11d45fb87.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u8b-5c2f6fda436a.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u8c-2b46d2d1f4cc.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u8d-1a67d064000d.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u8e-eb87baf0091c.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u8f-96c1f0632da6.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u90-86752dba8cbe.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u91-0573b8548552.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u92-f85ecf735974.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u93-e7e0451735db.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u94-f3957ff98bcd.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u95-b31129d29013.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u96-ca7f779c2c13.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u97-100a25198b2c.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u98-59e4b784a94f.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u99-d35af6e30868.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u9a-1eb6f7cdfcfe.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u9b-8f428d81dcbb.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u9c-e10f7f8f0274.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u9e-2be65003b759.woff2 +0 -0
- package/assets/fonts/web/fangyuan-u9f-02c3d5224fdb.woff2 +0 -0
- package/assets/fonts/web/fangyuan-ufb-3eaee54bc476.woff2 +0 -0
- package/assets/fonts/web/fangyuan-ufe-f64da8a3acce.woff2 +0 -0
- package/assets/fonts/web/fangyuan-uff-0a6dcfb689fd.woff2 +0 -0
- package/client/assets/iconsax/LICENSE +16 -0
- package/client/assets/iconsax/SOURCE.md +14 -0
- package/config/routergo-deepseek.example.yml +25 -0
- package/config.example.json +24 -0
- package/cordis.patch.yml +5 -0
- package/lib/LINK-THIRD-PARTY-LICENSES.txt +1197 -0
- package/lib/client.js +3577 -0
- package/lib/link-web/app.css +92 -0
- package/lib/link-web/app.js +599 -0
- package/lib/link-web/index.html +1 -0
- package/lib/link-web/workbench.js +118 -0
- package/lib/link.mjs +244 -0
- package/lib/relay/ip-certificate.mjs +559 -0
- package/lib/relay/relay.example.json +8 -0
- package/lib/relay/run-relay-server.cjs +4100 -0
- package/lib/server/bootstrap.mjs +147 -0
- package/lib/server/bootstrap.sh +39 -0
- package/lib/server/manifest.json +8 -0
- package/lib/server/server-seed.tar.gz +0 -0
- package/locale/en.json +6 -0
- package/locale/zh-CN.json +6 -0
- package/openapi.json +629 -0
- package/package.json +104 -4
- package/shared/plugin-install.mjs +198 -0
- package/shared/tool-quick-settings.mjs +70 -0
- package/shared/update-feed.mjs +191 -0
- package/shared/update-public-key.pem +3 -0
- package/src/cli.mjs +102 -0
- package/src/commands.mjs +17 -0
- package/src/delegation.mjs +47 -0
- package/src/fonts.mjs +17 -0
- package/src/harness-adapter.mjs +228 -0
- package/src/harness-commands.mjs +39 -0
- package/src/harness-selection.mjs +5 -0
- package/src/harness-usage.mjs +31 -0
- package/src/isolated-launch.mjs +22 -0
- package/src/line-process.mjs +114 -0
- package/src/link-plugin.mjs +21 -0
- package/src/mcp.mjs +21 -0
- package/src/model-providers.mjs +125 -0
- package/src/models.mjs +37 -0
- package/src/native-auth.mjs +32 -0
- package/src/picker-catalog.mjs +82 -0
- package/src/plugin.mjs +138 -0
- package/src/projects.mjs +96 -0
- package/src/provider-access.mjs +11 -0
- package/src/providers/acp.mjs +138 -0
- package/src/providers/claude.mjs +115 -0
- package/src/providers/codex.mjs +134 -0
- package/src/providers/opencode.mjs +138 -0
- package/src/providers/pi-permissions.ts +24 -0
- package/src/providers/pi.mjs +97 -0
- package/src/questions.mjs +34 -0
- package/src/recent-workspace.mjs +36 -0
- package/src/runtime.mjs +421 -0
- package/src/session-routes.mjs +89 -0
- package/src/store.mjs +98 -0
- package/src/tool-catalog.mjs +22 -0
- package/src/tool-config.mjs +94 -0
- package/src/tool-install.mjs +31 -0
- package/src/tool-profiles.mjs +114 -0
- package/src/tooling.mjs +90 -0
- package/src/updates.mjs +156 -0
- package/src/usage-import-parsers.mjs +37 -0
- package/src/usage-import.mjs +114 -0
- package/src/usage-timeline.mjs +36 -0
- package/src/usage.mjs +142 -0
- package/src/web.mjs +274 -0
- package/voice/README.md +49 -0
- package/voice/STREAMING.md +141 -0
- package/voice/VERIFICATION.md +36 -0
- package/voice/client/audio-worklet.js +35 -0
- package/voice/locale/en.json +1 -0
- package/voice/locale/zh-CN.json +1 -0
- package/voice/src/asr.mjs +44 -0
- package/voice/src/calls.mjs +37 -0
- package/voice/src/config.mjs +139 -0
- package/voice/src/media.mjs +70 -0
- package/voice/src/plugin.mjs +10 -0
- package/voice/src/realtime-protocols.mjs +30 -0
- package/voice/src/realtime-socket.mjs +86 -0
- package/voice/src/realtime.mjs +130 -0
- package/voice/src/tts.mjs +16 -0
- package/voice/src/web.mjs +171 -0
package/src/updates.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, rename, rm } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { DEFAULT_UPDATE_CONFIG, normalizeUpdateConfig, fetchVerifiedRelease, fetchUpdateBytes, verifyArtifact, isEngineCompatible, isNewerVersion, resolveUpdateUrl } from '../shared/update-feed.mjs';
|
|
5
|
+
import { prepareRelease, activateRelease, getInstallState, rollbackRelease } from '../shared/plugin-install.mjs';
|
|
6
|
+
import { assertSameOrigin } from '../voice/src/web.mjs';
|
|
7
|
+
import { readLimited } from '../voice/src/media.mjs';
|
|
8
|
+
|
|
9
|
+
const json = async path => JSON.parse(await readFile(path, 'utf8'));
|
|
10
|
+
const conflict = message => Object.assign(new Error(message), { status: 409 });
|
|
11
|
+
|
|
12
|
+
/** Read the immutable application's version, never the independently updated plugin version. */
|
|
13
|
+
export async function desktopApplicationVersion(executable = process.execPath, readJson = json) {
|
|
14
|
+
try {
|
|
15
|
+
const app = await readJson(join(dirname(executable), 'resources', 'app.asar', 'package.json'));
|
|
16
|
+
if (app.name === 'benchgo-desktop' && typeof app.version === 'string') {
|
|
17
|
+
isNewerVersion(app.version, app.version);
|
|
18
|
+
return app.version;
|
|
19
|
+
}
|
|
20
|
+
} catch {}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Each immutable release is staged before one profile manifest switch; loaded code stays intact. */
|
|
25
|
+
export async function createUpdates({ directory, profile, runtime, version, engineVersion, appVersion, isBusy = () => false,
|
|
26
|
+
installation = 'desktop', fetcher = globalThis.fetch, publicKey, install = { prepareRelease, activateRelease, getInstallState, rollbackRelease } }) {
|
|
27
|
+
const configFile = join(directory, 'updates.json');
|
|
28
|
+
const lifetime = new AbortController();
|
|
29
|
+
const updateFetch = (url, options = {}) => fetcher(url, { ...options, signal: AbortSignal.any([lifetime.signal, ...(options.signal ? [options.signal] : [])]) });
|
|
30
|
+
let config = { ...DEFAULT_UPDATE_CONFIG }, phase = 'idle', release = null, error = '', checkedAt = null, task = null, lastAction = null, closed = false;
|
|
31
|
+
try { config = normalizeUpdateConfig(await json(configFile)); } catch (failure) { if (failure.code !== 'ENOENT') error = '更新源配置读取失败,请重新选择更新来源'; }
|
|
32
|
+
const installed = async () => profile ? await install.getInstallState(profile) : { managed: false };
|
|
33
|
+
const status = async () => {
|
|
34
|
+
const state = await installed(), busy = !!(await isBusy());
|
|
35
|
+
const restartRequired = !!state.managed && state.version !== version;
|
|
36
|
+
const compatible = !!release && !!engineVersion && isEngineCompatible(engineVersion, release.engine);
|
|
37
|
+
// A source-deployed host has no desktop application to compare. Unknown is
|
|
38
|
+
// not an older version, including desktop profiles without app metadata.
|
|
39
|
+
const currentApp = installation === 'desktop' ? appVersion ?? state.appVersion : undefined;
|
|
40
|
+
return {
|
|
41
|
+
// A check can publish its release before the task's finalizer releases admission.
|
|
42
|
+
// Keep that intermediate response active so clients continue polling readiness.
|
|
43
|
+
phase: restartRequired ? 'ready' : task && phase === 'idle' ? 'checking' : phase, lastAction, installation, notice: phase === 'unpublished' ? '更新通道尚未就绪' : '', config, current: { plugin: version, engine: engineVersion, app: currentApp },
|
|
44
|
+
managed: !!state.managed, busy, restartRequired, checkedAt, error,
|
|
45
|
+
canInstall: !!state.managed && !busy && !restartRequired && !task && compatible && isNewerVersion(release.plugin.version, version),
|
|
46
|
+
canRollback: !!state.managed && !!state.previous && !restartRequired && !task,
|
|
47
|
+
release: release ? { ...release, plugin: { ...release.plugin, available: !restartRequired && isNewerVersion(release.plugin.version, version), compatible, reason: compatible ? undefined : '请先升级应用,以匹配此插件需要的引擎。' },
|
|
48
|
+
...(release.app ? { app: { ...release.app, available: !!currentApp && isNewerVersion(release.app.version, currentApp), url: resolveUpdateUrl(release.app.url, config) } } : {}) } : null,
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
const start = (action, work) => {
|
|
52
|
+
lastAction = action;
|
|
53
|
+
error = '';
|
|
54
|
+
task = Promise.resolve().then(work).catch(failure => {
|
|
55
|
+
if (failure.code === 'UPDATE_UNPUBLISHED') { release = null; error = ''; phase = 'unpublished'; }
|
|
56
|
+
else { error = failure.message; phase = 'error'; }
|
|
57
|
+
}).finally(() => { task = null; });
|
|
58
|
+
};
|
|
59
|
+
const guard = async () => {
|
|
60
|
+
if (closed) throw conflict('更新服务已关闭');
|
|
61
|
+
if (task) throw conflict('更新正在进行,请稍候');
|
|
62
|
+
const state = await installed();
|
|
63
|
+
if (!state.managed) throw conflict(installation === 'server' ? '此主机由服务器部署管理,请由维护者更新' : '此安装需要先升级一次应用,才能独立更新插件');
|
|
64
|
+
if (state.version !== version) throw conflict('请先重启主机应用,使已准备的版本生效');
|
|
65
|
+
if (await isBusy()) throw conflict('有任务正在运行,结束后再更新');
|
|
66
|
+
return state;
|
|
67
|
+
};
|
|
68
|
+
// Reserve mutation admission synchronously, before asynchronous profile/task inspection.
|
|
69
|
+
let dispatching = false;
|
|
70
|
+
const dispatch = async input => {
|
|
71
|
+
if (input.action === 'status') return status();
|
|
72
|
+
if (closed) throw conflict('更新服务已关闭');
|
|
73
|
+
if (dispatching || task) throw conflict('更新正在进行,请稍候');
|
|
74
|
+
dispatching = true;
|
|
75
|
+
try {
|
|
76
|
+
switch (input.action) {
|
|
77
|
+
case 'configure': {
|
|
78
|
+
const state = await installed();
|
|
79
|
+
if (state.managed && state.version !== version) throw conflict('请先重启主机应用');
|
|
80
|
+
const next = normalizeUpdateConfig(input);
|
|
81
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
82
|
+
const temporary = `${configFile}.${randomUUID()}.tmp`;
|
|
83
|
+
try { await writeFile(temporary, JSON.stringify(next), { mode: 0o600 }); await rename(temporary, configFile); }
|
|
84
|
+
finally { await rm(temporary, { force: true }); }
|
|
85
|
+
config = next; release = null; error = ''; phase = 'idle'; checkedAt = null;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case 'check': {
|
|
89
|
+
phase = 'checking';
|
|
90
|
+
start('check', async () => {
|
|
91
|
+
const next = await fetchVerifiedRelease({ config, fetcher: updateFetch, publicKey });
|
|
92
|
+
release = next;
|
|
93
|
+
checkedAt = new Date().toISOString(); phase = 'idle';
|
|
94
|
+
});
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case 'install': {
|
|
98
|
+
const state = await guard();
|
|
99
|
+
if (!release || !engineVersion || !isEngineCompatible(engineVersion, release.engine)) throw conflict('请先检查可兼容的插件版本');
|
|
100
|
+
if (!isNewerVersion(release.plugin.version, version)) throw conflict('插件已是最新版本');
|
|
101
|
+
const selected = release;
|
|
102
|
+
phase = 'downloading';
|
|
103
|
+
start('install', async () => {
|
|
104
|
+
const archive = join(state.root, `.download-${randomUUID()}.tar.gz`);
|
|
105
|
+
try {
|
|
106
|
+
const bytes = verifyArtifact(await fetchUpdateBytes(selected.plugin.url, { config, fetcher: updateFetch, maxBytes: selected.plugin.size, timeoutMs: 5 * 60_000 }), selected.plugin);
|
|
107
|
+
if (closed) throw new Error('更新已取消');
|
|
108
|
+
await mkdir(state.root, { recursive: true, mode: 0o700 });
|
|
109
|
+
await writeFile(archive, bytes, { mode: 0o600 });
|
|
110
|
+
const prepared = await install.prepareRelease({ archive, root: state.root, version: selected.plugin.version, runtime });
|
|
111
|
+
if (closed) throw new Error('更新已取消');
|
|
112
|
+
if (await isBusy()) throw conflict('下载期间有新任务开始,结束后再点更新');
|
|
113
|
+
await install.activateRelease({ profile, release: prepared });
|
|
114
|
+
phase = 'ready';
|
|
115
|
+
} finally { await rm(archive, { force: true }); }
|
|
116
|
+
});
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
case 'rollback': {
|
|
120
|
+
const state = await guard();
|
|
121
|
+
if (!state.previous) throw conflict('暂无可回退版本');
|
|
122
|
+
phase = 'downloading';
|
|
123
|
+
start('rollback', async () => { if (await isBusy()) throw conflict('有任务正在运行'); await install.rollbackRelease({ profile, runtime }); phase = 'ready'; });
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
default: throw new Error('未知更新操作');
|
|
127
|
+
}
|
|
128
|
+
return status();
|
|
129
|
+
} finally { dispatching = false; }
|
|
130
|
+
};
|
|
131
|
+
return { dispatch, status, settled: async () => { await task; }, close: async () => { closed = true; lifetime.abort(); await task; } };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function mountUpdates(ctx, commander, options = {}) {
|
|
135
|
+
const context = ctx.get('profileContext');
|
|
136
|
+
const runtime = context?.installAnchor && dirname(context.installAnchor);
|
|
137
|
+
const pkg = await json(new URL('../package.json', import.meta.url));
|
|
138
|
+
let engineVersion;
|
|
139
|
+
try { engineVersion = (await json(join(runtime, 'node_modules/@deepseek-ai/dsh-app-boot/package.json'))).version; } catch {}
|
|
140
|
+
const appVersion = context?.name === 'desktop' ? await desktopApplicationVersion() : undefined;
|
|
141
|
+
const service = await createUpdates({ directory: commander.store.directory, profile: context?.dir, runtime, installation: context?.name === 'desktop' ? 'desktop' : 'server', version: pkg.version, engineVersion, appVersion,
|
|
142
|
+
isBusy: () => commander.list().some(session => ['running', 'connecting', 'waiting'].includes(session.status)) ||
|
|
143
|
+
(ctx.get('agents')?.list?.() ?? []).some(agent => agent.status === 'running'), ...options });
|
|
144
|
+
const fetch = async request => {
|
|
145
|
+
try {
|
|
146
|
+
assertSameOrigin(request);
|
|
147
|
+
if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) throw Object.assign(new Error('Expected application/json'), { status: 415 });
|
|
148
|
+
const input = JSON.parse(new TextDecoder().decode(await readLimited(request, 4096)));
|
|
149
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('请求必须为 JSON 对象');
|
|
150
|
+
return Response.json(await service.dispatch(input), { headers: { 'cache-control': 'no-store' } });
|
|
151
|
+
} catch (failure) { return Response.json({ error: failure.message }, { status: failure.status || 400, headers: { 'cache-control': 'no-store' } }); }
|
|
152
|
+
};
|
|
153
|
+
ctx.effect(() => ctx.connection.fetch.register({ path: '/api/commander/updates', methods: ['POST'], requestBody: 'buffered', fetch }));
|
|
154
|
+
ctx.effect(() => () => service.close());
|
|
155
|
+
return service;
|
|
156
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 32);
|
|
3
|
+
const known = value => typeof value === 'string' && value.length <= 1024 ? value : null;
|
|
4
|
+
export function parseUsageLine(tool, item, state, normalize) {
|
|
5
|
+
const p = item.payload ?? {}, m = item.message ?? {};
|
|
6
|
+
const time = item.timestamp ?? m.timestamp;
|
|
7
|
+
if (tool === 'codex') {
|
|
8
|
+
if (item.type === 'session_meta') { state.nativeId = p.id ?? p.session_id; state.contextWindow = p.context_window; state.inherited = p.forked_from_ordinal_exclusive ?? null; }
|
|
9
|
+
if (item.type === 'turn_context') { state.model = p.model; state.turnId = p.turn_id; }
|
|
10
|
+
if (state.inherited != null && item.ordinal != null && item.ordinal < state.inherited) return;
|
|
11
|
+
let usage, cumulative, callId;
|
|
12
|
+
if (item.type === 'token_usage_record') { usage = p.usage; cumulative = p.thread_token_usage; callId = p.response_id; state.nativeId ??= p.thread_id ?? p.session_id; state.turnId = p.turn_id ?? state.turnId; }
|
|
13
|
+
else if (item.type === 'event_msg' && p.type === 'token_count') { usage = p.info?.last_token_usage; cumulative = p.info?.total_token_usage; state.contextWindow = p.info?.model_context_window ?? state.contextWindow; }
|
|
14
|
+
if (!usage || !state.nativeId) return;
|
|
15
|
+
const signature = hash(cumulative ?? usage);
|
|
16
|
+
if (state.lastUsageSignature === signature) return;
|
|
17
|
+
state.lastUsageSignature = signature;
|
|
18
|
+
// One stable cumulative accounting position is shared by both modern and legacy records.
|
|
19
|
+
return row(tool, state.nativeId, signature, state.model, time, normalize({ ...usage, contextTokens: usage.input_tokens, contextWindow: state.contextWindow }, 'inclusive'), state.turnId, callId);
|
|
20
|
+
}
|
|
21
|
+
if (tool === 'claude' && item.type === 'assistant' && m.usage && m.id) {
|
|
22
|
+
return row(tool, item.sessionId ?? state.nativeId, m.id, m.model, time, normalize(m.usage), null, m.id);
|
|
23
|
+
}
|
|
24
|
+
if (tool === 'pi') {
|
|
25
|
+
if (item.type === 'session') state.nativeId = item.id;
|
|
26
|
+
if (item.type === 'model_change') state.model = item.modelId;
|
|
27
|
+
if (item.type === 'message' && m.role === 'assistant' && m.usage) return row(tool, state.nativeId, item.id ?? String(m.timestamp), m.model ?? state.model, time, normalize(m.usage), null, item.id);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function row(tool, nativeId, id, model, time, usage, turnId, callId) {
|
|
31
|
+
if (!nativeId || !Number.isFinite(new Date(time).getTime())) return;
|
|
32
|
+
return { id: 'machine:' + tool + ':' + nativeId + ':' + id, source: 'machine', tool, model: known(model), nativeId: known(nativeId), callId: known(callId), sessionId: null, turnId: known(turnId), time: new Date(time).toISOString(), status: 'recorded', ...usage };
|
|
33
|
+
}
|
|
34
|
+
export function parseOpenCodeMessage(info, normalize) {
|
|
35
|
+
if (info.role !== 'assistant' || !info.tokens || !info.id || !info.sessionID) return;
|
|
36
|
+
return row('opencode', info.sessionID, info.id, info.modelID ? (info.providerID ? info.providerID + '/' : '') + info.modelID : null, info.time?.completed ?? info.time?.created, normalize(info.tokens), null, info.id);
|
|
37
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { readdir, stat, open, readFile, writeFile, rename, rm } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, basename } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { parseUsageLine, parseOpenCodeMessage } from './usage-import-parsers.mjs';
|
|
6
|
+
|
|
7
|
+
export function usageRoots(env = process.env, home = homedir()) {
|
|
8
|
+
const codex = env.CODEX_HOME || join(home, '.codex');
|
|
9
|
+
return { codex: [join(codex, 'sessions'), join(codex, 'archived_sessions')], claude: [join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'projects')], pi: [join(env.PI_CODING_AGENT_DIR || join(home, '.pi', 'agent'), 'sessions')], opencode: [env.OPENCODE_DATA_DIR || join(env.XDG_DATA_HOME || join(home, '.local', 'share'), 'opencode')] };
|
|
10
|
+
}
|
|
11
|
+
export class MachineUsageImporter {
|
|
12
|
+
constructor(ledger, normalize, { roots = usageRoots(), maxFiles = 10000, maxBytesPerScan = 32 * 1024 * 1024, maxLineBytes = 8 * 1024 * 1024 } = {}) {
|
|
13
|
+
this.ledger = ledger; this.normalize = normalize; this.roots = roots; this.limits = { maxFiles, maxBytesPerScan, maxLineBytes }; this.path = join(ledger.path, '..', 'usage-import.json'); this.cursors = {}; this.pending = new Map(); this.completeNativeIds = new Set(); this.coverage = { state: 'idle', sources: [], limits: this.limits }; this.closed = false;
|
|
14
|
+
}
|
|
15
|
+
async open() { try { const saved = JSON.parse(await readFile(this.path, 'utf8')); if (saved.version === 1) this.cursors = saved.cursors ?? {}; } catch (e) { if (e.code !== 'ENOENT') this.coverage.error = '扫描游标不可读,将重新安全去重扫描'; } return this; }
|
|
16
|
+
scan() { if (this.running) return this.running; this.running = this.run().catch(error => { this.coverage.state = 'error'; this.coverage.error = error.code || 'scan-failed'; }).finally(() => { this.running = null; }); return this.running; }
|
|
17
|
+
async run() {
|
|
18
|
+
this.completeNativeIds.clear();
|
|
19
|
+
this.coverage = { state: 'scanning', startedAt: new Date().toISOString(), completedAt: null, sources: [], scannedBytes: 0, limits: this.limits };
|
|
20
|
+
let budget = this.limits.maxBytesPerScan, filesCount = 0;
|
|
21
|
+
for (const [tool, roots] of Object.entries(this.roots)) {
|
|
22
|
+
budget = Math.max(1, Math.floor(this.limits.maxBytesPerScan / Math.max(1, Object.keys(this.roots).length)));
|
|
23
|
+
const source = { tool, status: 'missing', files: 0, scannedFiles: 0, records: 0, errors: [], truncated: false, continuable: false }; this.coverage.sources.push(source);
|
|
24
|
+
for (const root of roots) {
|
|
25
|
+
const paths = [];
|
|
26
|
+
const walk = async (directory, depth = 0) => {
|
|
27
|
+
if (depth > 12 || filesCount >= this.limits.maxFiles || this.closed) { source.truncated = true; return; }
|
|
28
|
+
let entries; try { entries = await readdir(directory, { withFileTypes: true }); source.status = 'ready'; } catch (e) { if (e.code !== 'ENOENT') source.errors.push(e.code || 'read-directory'); return; }
|
|
29
|
+
for (const entry of entries) { if (entry.isSymbolicLink()) continue; const path = join(directory, entry.name); if (entry.isDirectory()) await walk(path, depth + 1); else if ((tool === 'opencode' && (entry.name.endsWith('.json') || entry.name === 'opencode.db')) || entry.name.endsWith('.jsonl')) { if (++filesCount > this.limits.maxFiles) { source.truncated = true; break; } paths.push(path); } }
|
|
30
|
+
};
|
|
31
|
+
await walk(root); source.files += paths.length;
|
|
32
|
+
for (const path of paths) {
|
|
33
|
+
if (this.closed || budget <= 0) { source.truncated = true; source.continuable = !this.closed; break; }
|
|
34
|
+
try {
|
|
35
|
+
const info = await stat(path);
|
|
36
|
+
if (tool === 'opencode') { if (path.endsWith('.db')) { const result = await this.sqlite(path, source, budget); budget -= result; this.coverage.scannedBytes += result; } else if (path.includes('/message/')) { if (info.size > this.limits.maxLineBytes || info.size > budget) { source.truncated = true; continue; } const key = path, prior = this.cursors[key]; if (prior?.mtime === info.mtimeMs && prior.size === info.size) continue; const item = JSON.parse(await readFile(path, 'utf8')); const row = parseOpenCodeMessage(item, this.normalize); if (row) this.ledger.put(row); this.cursors[key] = { mtime: info.mtimeMs, size: info.size }; budget -= info.size; this.coverage.scannedBytes += info.size; source.scannedFiles++; } continue; }
|
|
37
|
+
let cursor = this.cursors[path];
|
|
38
|
+
if (!cursor || cursor.ino !== info.ino || info.size < cursor.offset) cursor = { ino: info.ino, offset: 0, state: { nativeId: basename(path, '.jsonl') } };
|
|
39
|
+
let pending = this.pending.get(path);
|
|
40
|
+
if (pending && (pending.ino !== info.ino || pending.offset !== cursor.offset || info.size < pending.nextOffset)) { this.pending.delete(path); pending = null; }
|
|
41
|
+
if (pending?.tail && pending.size === info.size && pending.mtime === info.mtimeMs) { source.truncated = true; continue; }
|
|
42
|
+
if (pending?.tail) { this.pending.delete(path); pending = null; }
|
|
43
|
+
const readOffset = pending?.nextOffset ?? cursor.offset;
|
|
44
|
+
const length = Math.min(info.size - readOffset, budget); if (length <= 0) { if (cursor.offset === info.size && cursor.state.nativeId) this.completeNativeIds.add(tool + ':' + cursor.state.nativeId); continue; }
|
|
45
|
+
const handle = await open(path, 'r'); const fresh = Buffer.alloc(length); let bytesRead;
|
|
46
|
+
try { ({ bytesRead } = await handle.read(fresh, 0, length, readOffset)); } finally { await handle.close(); }
|
|
47
|
+
// Keep unfinished bounded records in memory only, never in the cursor file.
|
|
48
|
+
const buffer = pending?.buffer ? Buffer.concat([pending.buffer, fresh.subarray(0, bytesRead)]) : fresh.subarray(0, bytesRead);
|
|
49
|
+
let position = 0, consumed = 0;
|
|
50
|
+
if (cursor.state.skipOversize) {
|
|
51
|
+
const newline = buffer.indexOf(10);
|
|
52
|
+
if (newline >= 0) {
|
|
53
|
+
consumed = newline + 1;
|
|
54
|
+
cursor.state.skipOversize = false;
|
|
55
|
+
position = consumed;
|
|
56
|
+
} else {
|
|
57
|
+
consumed = buffer.length;
|
|
58
|
+
position = buffer.length;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
while (position < buffer.length) {
|
|
62
|
+
const end = buffer.indexOf(10, position); if (end < 0) break;
|
|
63
|
+
if (end - position > this.limits.maxLineBytes) { source.errors.push('line-too-large'); consumed = end + 1; position = end + 1; continue; }
|
|
64
|
+
try { const item = JSON.parse(buffer.subarray(position, end).toString('utf8')); const row = parseUsageLine(tool, item, cursor.state, this.normalize); if (row) this.ledger.put(row); } catch { source.errors.push('invalid-json-record'); }
|
|
65
|
+
consumed = end + 1; position = consumed;
|
|
66
|
+
}
|
|
67
|
+
if (buffer.length - consumed > this.limits.maxLineBytes) {
|
|
68
|
+
source.errors.push('line-too-large');
|
|
69
|
+
cursor.state.skipOversize = true;
|
|
70
|
+
consumed = buffer.length;
|
|
71
|
+
}
|
|
72
|
+
cursor.offset += consumed; this.cursors[path] = cursor; this.pending.delete(path);
|
|
73
|
+
const nextOffset = readOffset + bytesRead;
|
|
74
|
+
if (cursor.offset < nextOffset) {
|
|
75
|
+
if (nextOffset < info.size) this.pending.set(path, { ino: info.ino, offset: cursor.offset, nextOffset, buffer: buffer.subarray(consumed) });
|
|
76
|
+
else this.pending.set(path, { ino: info.ino, offset: cursor.offset, nextOffset, tail: true, size: info.size, mtime: info.mtimeMs });
|
|
77
|
+
}
|
|
78
|
+
if (cursor.offset === info.size && cursor.state.nativeId && !source.errors.length) this.completeNativeIds.add(tool + ':' + cursor.state.nativeId); else this.completeNativeIds.delete(tool + ':' + cursor.state.nativeId);
|
|
79
|
+
if (cursor.offset < info.size) source.truncated = true;
|
|
80
|
+
if (nextOffset < info.size) source.continuable = true;
|
|
81
|
+
budget -= bytesRead; this.coverage.scannedBytes += bytesRead; source.scannedFiles++;
|
|
82
|
+
} catch (e) { source.errors.push(e.code || 'read-history'); }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
source.records = [...this.ledger.rows.values()].filter(row => row.source === 'machine' && row.tool === tool).length;
|
|
86
|
+
source.errors = [...new Set(source.errors)].slice(0, 10); if (source.errors.length || source.truncated) source.status = 'partial';
|
|
87
|
+
}
|
|
88
|
+
this.coverage.state = this.coverage.sources.some(source => source.status === 'partial') ? 'partial' : 'ready'; this.coverage.completedAt = new Date().toISOString();
|
|
89
|
+
await this.ledger.flush();
|
|
90
|
+
const temp = this.path + '.' + randomUUID() + '.tmp'; try { await writeFile(temp, JSON.stringify({ version: 1, cursors: this.cursors }), { mode: 0o600 }); await rename(temp, this.path); } finally { await rm(temp, { force: true }); }
|
|
91
|
+
}
|
|
92
|
+
async sqlite(path, source, budget) {
|
|
93
|
+
let db;
|
|
94
|
+
try {
|
|
95
|
+
const { DatabaseSync } = await import('node:sqlite'); db = new DatabaseSync(path, { readOnly: true });
|
|
96
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('message','part')").all();
|
|
97
|
+
if (!tables.some(row => row.name === 'message')) { source.errors.push('unsupported-opencode-schema'); return 0; }
|
|
98
|
+
const columns = db.prepare('PRAGMA table_info(message)').all().map(row => row.name);
|
|
99
|
+
if (!columns.includes('data')) { source.errors.push('unsupported-opencode-schema'); return 0; }
|
|
100
|
+
const file = await stat(path), prior = this.cursors[path];
|
|
101
|
+
let walTime = 0; try { walTime = (await stat(path + '-wal')).mtimeMs; } catch {}
|
|
102
|
+
// Usage is often written back into an existing assistant row. A changed DB
|
|
103
|
+
// restarts a bounded idempotent scan; stable row ids overwrite prior counts.
|
|
104
|
+
let offset = prior?.mtime === file.mtimeMs && prior?.size === file.size && prior?.walTime === walTime ? prior.rowid ?? 0 : 0, bytes = 0;
|
|
105
|
+
if (prior?.complete && offset > 0) return 0;
|
|
106
|
+
const selected = ['rowid AS cursor', 'data', ...(columns.includes('id') ? ['id'] : []), ...(columns.includes('session_id') ? ['session_id'] : [])];
|
|
107
|
+
const rows = db.prepare('SELECT ' + selected.join(',') + ' FROM message WHERE rowid > ? ORDER BY rowid LIMIT 1000').all(offset);
|
|
108
|
+
let complete = rows.length < 1000;
|
|
109
|
+
for (const item of rows) { const size = Buffer.byteLength(String(item.data)); if (bytes + size > budget) { source.truncated = true; complete = false; break; } bytes += size; const info = typeof item.data === 'string' ? JSON.parse(item.data) : item.data; const row = parseOpenCodeMessage({ id: item.id, sessionID: item.session_id, ...info }, this.normalize); if (row) this.ledger.put(row); offset = item.cursor; }
|
|
110
|
+
this.cursors[path] = { rowid: offset, mtime: file.mtimeMs, walTime, size: file.size, complete }; source.scannedFiles++; if (!complete) { source.truncated = true; source.continuable ||= bytes > 0; } return bytes;
|
|
111
|
+
} catch (e) { source.errors.push(e.code || 'sqlite-unavailable'); return 0; } finally { db?.close(); }
|
|
112
|
+
}
|
|
113
|
+
async close() { this.closed = true; await this.running; }
|
|
114
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const hour = 3_600_000, day = 24 * hour;
|
|
2
|
+
|
|
3
|
+
/** Aggregate the filtered, deduplicated interval before history pagination. */
|
|
4
|
+
export function usageTimeline(rows, { start, end, timeZone = 'UTC' }) {
|
|
5
|
+
const from = Date.parse(start), to = Date.parse(end);
|
|
6
|
+
const formatter = new Intl.DateTimeFormat('sv-SE', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
7
|
+
const localDate = time => formatter.format(new Date(time));
|
|
8
|
+
const firstDate = localDate(from), lastDate = localDate(to - 1);
|
|
9
|
+
const interval = firstDate === lastDate ? 'hour' : to - from <= 90 * day ? 'day' : to - from <= 3660 * day ? 'month' : 'year';
|
|
10
|
+
const periodKey = time => interval === 'hour' ? String(Math.floor((time - from) / hour)) : localDate(time).slice(0, interval === 'day' ? 10 : interval === 'month' ? 7 : 4);
|
|
11
|
+
const buckets = new Map();
|
|
12
|
+
const add = (key, time) => buckets.set(key, { time, calls: 0, measuredCalls: 0, totalTokens: null });
|
|
13
|
+
if (interval === 'hour') {
|
|
14
|
+
for (let time = from; time < to; time += hour) add(periodKey(time), new Date(time).toISOString());
|
|
15
|
+
} else {
|
|
16
|
+
const first = new Date(firstDate + 'T00:00:00Z'), last = new Date(lastDate + 'T00:00:00Z');
|
|
17
|
+
if (interval !== 'day') { first.setUTCDate(1); last.setUTCDate(1); }
|
|
18
|
+
if (interval === 'year') { first.setUTCMonth(0); last.setUTCMonth(0); }
|
|
19
|
+
for (let cursor = first; cursor <= last;) {
|
|
20
|
+
const key = cursor.toISOString().slice(0, interval === 'day' ? 10 : interval === 'month' ? 7 : 4);
|
|
21
|
+
add(key, key);
|
|
22
|
+
if (interval === 'day') cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
23
|
+
else if (interval === 'month') cursor.setUTCMonth(cursor.getUTCMonth() + 1);
|
|
24
|
+
else cursor.setUTCFullYear(cursor.getUTCFullYear() + 1);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
for (const row of rows) {
|
|
28
|
+
const bucket = buckets.get(periodKey(Date.parse(row.time)));
|
|
29
|
+
if (!bucket) continue;
|
|
30
|
+
bucket.calls++;
|
|
31
|
+
if (row.totalTokens !== null && row.totalTokens !== undefined) {
|
|
32
|
+
bucket.measuredCalls++; bucket.totalTokens = (bucket.totalTokens ?? 0) + row.totalTokens;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { interval, timeZone, buckets: [...buckets.values()].map(bucket => bucket.calls ? bucket : { ...bucket, totalTokens: 0 }) };
|
|
36
|
+
}
|
package/src/usage.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { readFile, writeFile, rename, rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { assertSameOrigin } from '../voice/src/web.mjs';
|
|
5
|
+
import { readJson } from '../voice/src/media.mjs';
|
|
6
|
+
import { MachineUsageImporter } from './usage-import.mjs';
|
|
7
|
+
import { usageTimeline } from './usage-timeline.mjs';
|
|
8
|
+
import { assistantStreamFirstTokenTime } from '@deepseek-ai/dsh-llm';
|
|
9
|
+
|
|
10
|
+
const count = value => Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
11
|
+
const sum = values => values.length && values.every(value => value !== null) ? values.reduce((a, b) => a + b, 0) : null;
|
|
12
|
+
export function normalizeUsage(usage = {}, convention = 'disjoint') {
|
|
13
|
+
const input = count(usage.inputTokens ?? usage.input_tokens ?? usage.input);
|
|
14
|
+
const outputTokens = count(usage.outputTokens ?? usage.output_tokens ?? usage.output);
|
|
15
|
+
const cacheReadTokens = count(usage.cacheReadTokens ?? usage.cachedTokens ?? usage.cached_input_tokens ?? usage.cache_read_input_tokens ?? usage.cacheRead ?? usage.cache?.read);
|
|
16
|
+
const cacheWriteTokens = count(usage.cacheWriteTokens ?? usage.cache_write_input_tokens ?? usage.cache_creation_input_tokens ?? usage.cacheWrite ?? usage.cache?.write);
|
|
17
|
+
const reportedTotal = count(usage.totalTokens ?? usage.total_tokens ?? usage.total);
|
|
18
|
+
const promptTokens = convention === 'inclusive' ? input : reportedTotal !== null && outputTokens !== null ? Math.max(0, reportedTotal - outputTokens) : sum([input, cacheReadTokens, cacheWriteTokens]);
|
|
19
|
+
return { inputTokens: convention === 'inclusive' ? input !== null && cacheReadTokens !== null ? Math.max(0, input - cacheReadTokens) : null : input, outputTokens, cacheReadTokens, cacheWriteTokens,
|
|
20
|
+
totalTokens: reportedTotal ?? (promptTokens !== null && outputTokens !== null ? promptTokens + outputTokens : null),
|
|
21
|
+
contextTokens: count(usage.contextTokens) ?? promptTokens, contextWindow: count(usage.contextWindow ?? usage.modelContextWindow), reasoningTokens: count(usage.reasoningTokens ?? usage.reasoning_output_tokens) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class UsageLedger {
|
|
25
|
+
constructor(directory) { this.path = join(directory, 'usage.json'); this.rows = new Map(); this.writes = Promise.resolve(); this.error = null; }
|
|
26
|
+
async open() {
|
|
27
|
+
try { const data = JSON.parse(await readFile(this.path, 'utf8')); if (data.version !== 1 || !Array.isArray(data.rows)) throw Error('Unsupported usage ledger'); for (const row of data.rows) this.rows.set(row.id, row); }
|
|
28
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
put(row) {
|
|
32
|
+
// Strict allowlist: never persist prompts, completions, arbitrary event payloads or credentials.
|
|
33
|
+
const safe = Object.fromEntries(['id', 'tool', 'model', 'time', 'sessionId', 'nativeId', 'callId', 'source', 'turnId', 'status', 'scope', 'durationMs', 'speedOutputTokens', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'totalTokens', 'contextTokens', 'contextWindow', 'reasoningTokens'].map(key => [key, row[key] ?? null]));
|
|
34
|
+
if (!safe.id || !Number.isFinite(Date.parse(safe.time))) return;
|
|
35
|
+
const prior = this.rows.get(safe.id);
|
|
36
|
+
if (prior) {
|
|
37
|
+
// Historical replay may lack the live execution duration or model metadata.
|
|
38
|
+
for (const key of Object.keys(safe)) if (safe[key] === null && prior[key] !== null) safe[key] = prior[key];
|
|
39
|
+
}
|
|
40
|
+
if (JSON.stringify(prior) === JSON.stringify(safe)) return;
|
|
41
|
+
this.rows.set(safe.id, safe); this.dirty = true;
|
|
42
|
+
}
|
|
43
|
+
flush() {
|
|
44
|
+
if (!this.dirty) return this.writes; this.dirty = false;
|
|
45
|
+
const data = JSON.stringify({ version: 1, rows: [...this.rows.values()] });
|
|
46
|
+
const task = this.writes.then(async () => { const temp = this.path + '.' + randomUUID() + '.tmp'; try { await writeFile(temp, data, { mode: 0o600 }); await rename(temp, this.path); } finally { await rm(temp, { force: true }); } });
|
|
47
|
+
this.writes = task.catch(error => { this.error = error.message; }); return task;
|
|
48
|
+
}
|
|
49
|
+
query({ start, end, page = 1, pageSize = 50, timeZone = 'UTC' } = {}) {
|
|
50
|
+
if (!Number.isSafeInteger(page) || page < 1 || ![25, 50, 100].includes(pageSize)) throw Error('无效的分页参数');
|
|
51
|
+
const from = Date.parse(start), to = Date.parse(end);
|
|
52
|
+
if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) throw Error('请选择有效起止日期');
|
|
53
|
+
let excludedDuplicates = 0;
|
|
54
|
+
const managed = this.managed?.() ?? new Map(), imported = new Set([...this.rows.values()].filter(row => row.source === 'machine').map(row => row.tool + ':' + row.nativeId));
|
|
55
|
+
const preferred = new Set([...managed.values()].filter(key => imported.has(key) && this.importer?.coverage.state !== 'scanning' && this.importer?.completeNativeIds.has(key)));
|
|
56
|
+
const rows = [...this.rows.values()].filter(row => {
|
|
57
|
+
const nativeKey = row.source === 'machine' ? row.tool + ':' + row.nativeId : managed.get(row.sessionId);
|
|
58
|
+
const duplicate = row.source === 'machine' ? [...managed.values()].includes(nativeKey) && !preferred.has(nativeKey) : nativeKey && preferred.has(nativeKey);
|
|
59
|
+
if (duplicate) { excludedDuplicates++; return false; }
|
|
60
|
+
const time = Date.parse(row.time); return time >= from && time < to;
|
|
61
|
+
}).sort((a, b) => b.time.localeCompare(a.time));
|
|
62
|
+
const groups = new Map();
|
|
63
|
+
for (const row of rows) { const key = JSON.stringify([row.tool, row.model]); if (!groups.has(key)) groups.set(key, []); groups.get(key).push(row); }
|
|
64
|
+
const summarize = group => {
|
|
65
|
+
const total = field => sum(group.map(row => row[field]));
|
|
66
|
+
const cacheReadTokens = total('cacheReadTokens'), inputTokens = total('inputTokens'), cacheWriteTokens = total('cacheWriteTokens');
|
|
67
|
+
const prompt = sum(group.map(row => row.totalTokens !== null && row.outputTokens !== null ? row.totalTokens - row.outputTokens : sum([row.inputTokens, row.cacheReadTokens, row.cacheWriteTokens])));
|
|
68
|
+
const timed = group.filter(row => row.durationMs > 0 && row.outputTokens !== null);
|
|
69
|
+
const context = group.filter(row => row.contextTokens !== null).sort((a, b) => b.contextTokens - a.contextTokens)[0];
|
|
70
|
+
return { calls: group.length, inputTokens, outputTokens: total('outputTokens'), totalTokens: total('totalTokens'), cacheReadTokens, cacheWriteTokens,
|
|
71
|
+
cacheHitRate: prompt > 0 && cacheReadTokens !== null ? cacheReadTokens / prompt : null, contextTokens: context?.contextTokens ?? null,
|
|
72
|
+
contextWindow: context?.contextWindow ?? null, tokensPerSecond: timed.length ? timed.reduce((n, row) => n + (row.speedOutputTokens ?? row.outputTokens), 0) / timed.reduce((n, row) => n + row.durationMs / 1000, 0) : null, measuredCalls: group.filter(row => row.totalTokens !== null).length };
|
|
73
|
+
};
|
|
74
|
+
const timeline = usageTimeline(rows, { start, end, timeZone });
|
|
75
|
+
const pageCount = Math.max(1, Math.ceil(rows.length / pageSize)), currentPage = Math.min(page, pageCount);
|
|
76
|
+
return { start, end, timeline, page: currentPage, pageSize, pageCount, summary: summarize(rows), groups: [...groups.values()].map(group => ({ tool: group[0].tool, model: group[0].model, ...summarize(group) })), records: rows.slice((currentPage - 1) * pageSize, currentPage * pageSize), recordCount: rows.length, error: this.error, coverage: { ...(this.importer?.coverage ?? { state: 'idle', sources: [] }), excludedDuplicates, deduplication: '完整导入的原生会话以本机历史为准;未完整导入的已管理会话保留实时记录,不叠加历史;未匹配会话按原生调用标识独立统计' } };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function mountUsage(ctx, commander, options = {}) {
|
|
81
|
+
let ledger;
|
|
82
|
+
try { ledger = await new UsageLedger(commander.store.directory).open(); }
|
|
83
|
+
catch (error) { ledger = new UsageLedger(commander.store.directory); ledger.error = '历史用量无法读取,本次仅显示内存采集;原文件保留。'; ledger.flush = () => Promise.resolve(); }
|
|
84
|
+
ledger.managed = () => new Map([...commander.sessions.values()].filter(session => session.nativeId).map(session => [session.id, session.provider + ':' + (session.provider === 'pi' ? session.nativeId.split('/').at(-1)?.replace(/.jsonl$/u, '').split('_').at(-1) : session.nativeId)]));
|
|
85
|
+
ledger.importer = await new MachineUsageImporter(ledger, normalizeUsage, options.enabled === false ? { roots: {} } : options).open();
|
|
86
|
+
let scanTimer, scanRounds = 0;
|
|
87
|
+
const scanMachine = async () => { await ledger.importer.scan(); scanRounds++; if (!ledger.importer.closed && scanRounds < 100 && ledger.importer.coverage.scannedBytes > 0 && ledger.importer.coverage.sources.some(source => source.continuable)) { scanTimer = setTimeout(scanMachine, 1000); scanTimer.unref?.(); } };
|
|
88
|
+
void scanMachine();
|
|
89
|
+
const routes = new Map(), stepFirst = new Map(); let stopped = false, backfill;
|
|
90
|
+
// Earlier versions measured entire native turns. Remove those incompatible
|
|
91
|
+
// intervals rather than mixing them into the official settled-step speed.
|
|
92
|
+
for (const row of ledger.rows.values()) if (row.id.startsWith('native:') && (row.durationMs !== null || row.speedOutputTokens !== null)) { row.durationMs = null; row.speedOutputTokens = null; ledger.dirty = true; }
|
|
93
|
+
const native = event => {
|
|
94
|
+
const session = commander.sessions.get(event.sessionId); if (!session) return;
|
|
95
|
+
const key = session.id + ':' + event.turnId;
|
|
96
|
+
if (event.type === 'usage') {
|
|
97
|
+
const id = 'native:' + key + ':' + (event.data.id ?? 'turn');
|
|
98
|
+
const old = ledger.rows.get(id);
|
|
99
|
+
ledger.put({ ...old, id, tool: session.provider, model: event.data.model ?? session.model ?? null, time: event.time, sessionId: session.id, turnId: event.turnId, status: session.status, scope: event.data.scope, ...normalizeUsage(event.data.usage, event.data.convention), durationMs: count(event.data.durationMs) ?? old?.durationMs ?? null });
|
|
100
|
+
}
|
|
101
|
+
// Legacy Claude message events already carry authoritative per-message usage.
|
|
102
|
+
if (event.type === 'message' && session.provider === 'claude' && event.data.type === 'assistant' && event.data.message?.usage) {
|
|
103
|
+
const message = event.data.message; ledger.put({ id: 'native:' + key + ':' + (message.id ?? event.seq), tool: session.provider, model: message.model ?? session.model ?? null, time: event.time, sessionId: session.id, turnId: event.turnId, status: session.status, durationMs: null, ...normalizeUsage(message.usage) });
|
|
104
|
+
}
|
|
105
|
+
if (event.type === 'turn.completed') {
|
|
106
|
+
const turnRows = [...ledger.rows.values()].filter(row => row.sessionId === session.id && row.turnId === event.turnId);
|
|
107
|
+
for (const row of turnRows) ledger.put({ ...row, status: event.data.status, durationMs: null, speedOutputTokens: null });
|
|
108
|
+
if (![...ledger.rows.values()].some(row => row.sessionId === session.id && row.turnId === event.turnId)) ledger.put({ id: 'native:' + key + ':turn', tool: session.provider, model: session.model, time: event.time, sessionId: session.id, turnId: event.turnId, status: event.data.status });
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
for (const session of commander.sessions.values()) for (const event of session.events) native(event);
|
|
112
|
+
const listener = event => { native(event); void ledger.flush().catch(() => {}); }; commander.on('event', listener);
|
|
113
|
+
const official = (session, event) => {
|
|
114
|
+
if (event.type === 'request/header') { routes.set(session.id, event.data.header?.config ?? {}); return; }
|
|
115
|
+
if (!['assistant/message', 'assistant/attempt'].includes(event.type) || event.seq < (session.inheritedEventCount ?? 0)) return;
|
|
116
|
+
const source = event.data.message?.source ?? routes.get(session.id) ?? {};
|
|
117
|
+
if (source.provider?.startsWith('commander-')) return;
|
|
118
|
+
const stream = event.data.stream ?? []; const usage = event.data.usage ?? stream.filter(record => record.chunk?.type === 'usage').at(-1)?.chunk?.usage;
|
|
119
|
+
const stepKey = session.id + ':' + event.data.turn + ':' + event.data.step;
|
|
120
|
+
const first = assistantStreamFirstTokenTime(stream), pending = stepFirst.get(stepKey);
|
|
121
|
+
const firstTime = Number.isFinite(first) ? Number.isFinite(pending) ? Math.min(first, pending) : first : pending;
|
|
122
|
+
if (event.type === 'assistant/attempt') {
|
|
123
|
+
if (Number.isFinite(firstTime)) stepFirst.set(stepKey, firstTime);
|
|
124
|
+
if (ledger.rows.delete('harness:' + session.id + ':' + event.seq)) ledger.dirty = true;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
stepFirst.delete(stepKey);
|
|
128
|
+
if (!usage && event.type !== 'assistant/message') return;
|
|
129
|
+
const durationMs = Number.isFinite(firstTime) && event.time > firstTime ? event.time - firstTime : null;
|
|
130
|
+
ledger.put({ id: 'harness:' + session.id + ':' + event.seq, tool: source.provider ?? 'harness', model: source.model ?? null, time: new Date(event.time).toISOString(), sessionId: session.id, turnId: String(event.data.turn), status: event.data.interrupted ? 'interrupted' : 'completed', durationMs, ...normalizeUsage(usage ?? {}) });
|
|
131
|
+
};
|
|
132
|
+
ctx.on('session/event', (session, event) => { official(session, event); void ledger.flush().catch(() => {}); });
|
|
133
|
+
ctx.inject(['sessionQuery'], child => {
|
|
134
|
+
backfill = (async () => { for (const record of await child.sessionQuery.listSessions()) { if (stopped) return; try { const snapshot = await child.sessionQuery.readSession(record.header.id); const session = { id: record.header.id, inheritedEventCount: snapshot.inheritedEventCount }; for (const event of snapshot.events) official(session, event); } catch { /* Unreadable old sessions must not prevent current usage collection. */ } } await ledger.flush(); })().catch(error => { ledger.error = error.message; });
|
|
135
|
+
});
|
|
136
|
+
ctx.effect(() => ctx.connection.fetch.register({ path: '/api/commander/usage', methods: ['POST'], requestBody: 'buffered', async fetch(request) {
|
|
137
|
+
try { assertSameOrigin(request); const input = await readJson(request); await backfill; if (input.refresh === true) await ledger.importer.scan(); return Response.json(ledger.query(input), { headers: { 'cache-control': 'no-store' } }); }
|
|
138
|
+
catch (error) { return Response.json({ error: error.message }, { status: 400 }); }
|
|
139
|
+
} }));
|
|
140
|
+
ctx.effect(() => async () => { stopped = true; clearTimeout(scanTimer); commander.off('event', listener); await backfill; await ledger.importer.close(); await ledger.flush(); });
|
|
141
|
+
await ledger.flush(); return ledger;
|
|
142
|
+
}
|