agentquad 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/dist-web/assets/index-CMaXwixo.js +1234 -0
- package/dist-web/assets/index-DBHApzV1.css +32 -0
- package/dist-web/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
- package/dist-web/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
- package/dist-web/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
- package/dist-web/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
- package/dist-web/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
- package/dist-web/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
- package/dist-web/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
- package/dist-web/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
- package/dist-web/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
- package/dist-web/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
- package/dist-web/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
- package/dist-web/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
- package/dist-web/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
- package/dist-web/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
- package/dist-web/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
- package/dist-web/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
- package/dist-web/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/dist-web/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/dist-web/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/dist-web/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/dist-web/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/dist-web/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/dist-web/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
- package/dist-web/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
- package/dist-web/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
- package/dist-web/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
- package/dist-web/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
- package/dist-web/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
- package/dist-web/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
- package/dist-web/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
- package/dist-web/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
- package/dist-web/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-BWTpRfYl.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-CEoEElIJ.woff +0 -0
- package/dist-web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
- package/dist-web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-greek-700-normal-C6CZE3T8.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-greek-700-normal-DEigVDxa.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CZipNAKV.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CxPITLHs.woff +0 -0
- package/dist-web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
- package/dist-web/assets/jetbrains-mono-vietnamese-700-normal-BDLVIk2r.woff +0 -0
- package/dist-web/assets/logo-D4DDtU-r.png +0 -0
- package/dist-web/favicon.png +0 -0
- package/dist-web/index.html +14 -0
- package/package.json +88 -0
- package/src/ask-user-buttons.js +142 -0
- package/src/claude-transcript.js +203 -0
- package/src/cli.js +1040 -0
- package/src/codex-event-emitter.js +111 -0
- package/src/codex-prompt-detector.js +53 -0
- package/src/codex-sidecar.js +52 -0
- package/src/codex-transcript.js +74 -0
- package/src/config.js +692 -0
- package/src/data/claude-code-commands.json +52 -0
- package/src/db.js +1503 -0
- package/src/dispatch.js +13 -0
- package/src/export/todoMarkdown.js +246 -0
- package/src/first-run-wizard.js +82 -0
- package/src/git/gitStatus.js +139 -0
- package/src/lark-api-client.js +205 -0
- package/src/lark-bot.js +510 -0
- package/src/lark-card.js +88 -0
- package/src/lark-config-service.js +16 -0
- package/src/lark-event-client.js +107 -0
- package/src/lark-image.js +99 -0
- package/src/lark-markdown.js +51 -0
- package/src/lark-video.js +163 -0
- package/src/mcp/audit.js +34 -0
- package/src/mcp/server.js +83 -0
- package/src/mcp/tools/destructive/index.js +252 -0
- package/src/mcp/tools/openclaw/index.js +405 -0
- package/src/mcp/tools/read/index.js +269 -0
- package/src/mcp/tools/write/index.js +157 -0
- package/src/openclaw-bridge.js +566 -0
- package/src/openclaw-hook-installer.js +338 -0
- package/src/openclaw-hook.js +908 -0
- package/src/openclaw-wizard.js +2442 -0
- package/src/pending-questions.js +297 -0
- package/src/pricing.js +45 -0
- package/src/prompt-render.js +36 -0
- package/src/pty.js +992 -0
- package/src/routes/ai-terminal.js +1228 -0
- package/src/routes/git.js +89 -0
- package/src/routes/openclaw-hook.js +67 -0
- package/src/routes/openclaw-inbound.js +36 -0
- package/src/routes/recurringRules.js +80 -0
- package/src/routes/reports.js +50 -0
- package/src/routes/search.js +46 -0
- package/src/routes/stats.js +31 -0
- package/src/routes/telegram-config.js +152 -0
- package/src/routes/telegram-sync.js +221 -0
- package/src/routes/templates.js +63 -0
- package/src/routes/todos.js +649 -0
- package/src/routes/transcripts.js +75 -0
- package/src/routes/uploads.js +107 -0
- package/src/routes/wiki.js +142 -0
- package/src/search/fts.js +209 -0
- package/src/search/index.js +199 -0
- package/src/search/transcripts.js +148 -0
- package/src/server.js +1791 -0
- package/src/session-input-dispatcher.js +256 -0
- package/src/stats/markdown.js +42 -0
- package/src/stats/report.js +207 -0
- package/src/summarize.js +84 -0
- package/src/system-rules.js +52 -0
- package/src/telegram-bot.js +875 -0
- package/src/telegram-commands.js +149 -0
- package/src/telegram-config-service.js +84 -0
- package/src/telegram-image.js +95 -0
- package/src/telegram-loading-status.js +112 -0
- package/src/telegram-markdown.js +82 -0
- package/src/telegram-reaction-tracker.js +69 -0
- package/src/telegram-video.js +75 -0
- package/src/templates/claude-hooks/notify.js +103 -0
- package/src/transcript.js +305 -0
- package/src/transcripts/blocks.js +56 -0
- package/src/transcripts/index.js +222 -0
- package/src/transcripts/indexer.js +34 -0
- package/src/transcripts/matcher.js +70 -0
- package/src/transcripts/scanner.js +259 -0
- package/src/usage-footer.js +170 -0
- package/src/usage-parser.js +132 -0
- package/src/wiki/guide.js +44 -0
- package/src/wiki/index.js +232 -0
- package/src/wiki/redact.js +34 -0
- package/src/wiki/sources.js +122 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { parseTranscriptFile } from './scanner.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse + upsert a transcript file and its FTS rows.
|
|
5
|
+
* Returns the row (with id) or null on parse fail.
|
|
6
|
+
*/
|
|
7
|
+
export async function indexFile(db, { tool, jsonlPath, size, mtime }) {
|
|
8
|
+
let parsed
|
|
9
|
+
try { parsed = await parseTranscriptFile(tool, jsonlPath) }
|
|
10
|
+
catch (e) { return null }
|
|
11
|
+
const u = parsed.usage || {}
|
|
12
|
+
const row = db.upsertTranscriptFile({
|
|
13
|
+
tool,
|
|
14
|
+
nativeId: parsed.nativeId,
|
|
15
|
+
cwd: parsed.cwd,
|
|
16
|
+
jsonlPath,
|
|
17
|
+
size,
|
|
18
|
+
mtime,
|
|
19
|
+
startedAt: parsed.startedAt,
|
|
20
|
+
endedAt: parsed.endedAt,
|
|
21
|
+
firstUserPrompt: parsed.firstUserPrompt,
|
|
22
|
+
turnCount: parsed.turnCount,
|
|
23
|
+
inputTokens: u.inputTokens ?? null,
|
|
24
|
+
outputTokens: u.outputTokens ?? null,
|
|
25
|
+
cacheReadTokens: u.cacheReadTokens ?? null,
|
|
26
|
+
cacheCreationTokens: u.cacheCreationTokens ?? null,
|
|
27
|
+
primaryModel: u.primaryModel ?? null,
|
|
28
|
+
activeMs: u.activeMs ?? null,
|
|
29
|
+
})
|
|
30
|
+
if (row && parsed.turns?.length) {
|
|
31
|
+
db.writeFtsTurns(row.id, parsed.turns)
|
|
32
|
+
}
|
|
33
|
+
return row
|
|
34
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three-gate auto-bind:
|
|
3
|
+
* - same cwd
|
|
4
|
+
* - startedAt within ±WINDOW_MS of an orphan AiSession.startedAt
|
|
5
|
+
* - first_user_prompt[:100] == orphanSession.prompt[:100]
|
|
6
|
+
*
|
|
7
|
+
* Orphan = an AiSession on a todo that has no native_session_id and no completed_at.
|
|
8
|
+
* Greedy match by |Δt| ascending; ties → skip (avoid mis-bind).
|
|
9
|
+
*/
|
|
10
|
+
export const WINDOW_MS = 60_000
|
|
11
|
+
const PROMPT_PREFIX_LEN = 100
|
|
12
|
+
|
|
13
|
+
function norm(s) { return String(s || '').trim().slice(0, PROMPT_PREFIX_LEN) }
|
|
14
|
+
|
|
15
|
+
export function collectOrphans(todos) {
|
|
16
|
+
const orphans = []
|
|
17
|
+
for (const todo of todos) {
|
|
18
|
+
const sessions = Array.isArray(todo.aiSessions) ? todo.aiSessions : (todo.aiSession ? [todo.aiSession] : [])
|
|
19
|
+
for (const s of sessions) {
|
|
20
|
+
if (!s) continue
|
|
21
|
+
if (s.nativeSessionId) continue
|
|
22
|
+
if (!s.startedAt) continue
|
|
23
|
+
orphans.push({
|
|
24
|
+
todoId: todo.id,
|
|
25
|
+
sessionId: s.sessionId,
|
|
26
|
+
tool: s.tool,
|
|
27
|
+
cwd: s.cwd ?? todo.workDir ?? null,
|
|
28
|
+
startedAt: s.startedAt,
|
|
29
|
+
prompt: norm(s.prompt),
|
|
30
|
+
claimed: false,
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return orphans
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function autoMatch(unboundFiles, orphans) {
|
|
38
|
+
const pairs = []
|
|
39
|
+
const candidates = []
|
|
40
|
+
for (const f of unboundFiles) {
|
|
41
|
+
if (!f.cwd || !f.started_at || !f.first_user_prompt) continue
|
|
42
|
+
for (const o of orphans) {
|
|
43
|
+
if (o.tool !== f.tool) continue
|
|
44
|
+
if (o.cwd !== f.cwd) continue
|
|
45
|
+
if (norm(f.first_user_prompt) !== o.prompt) continue
|
|
46
|
+
const dt = Math.abs(f.started_at - o.startedAt)
|
|
47
|
+
if (dt > WINDOW_MS) continue
|
|
48
|
+
candidates.push({ file: f, orphan: o, dt })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
candidates.sort((a, b) => a.dt - b.dt)
|
|
52
|
+
const usedFiles = new Set()
|
|
53
|
+
const usedOrphans = new Set()
|
|
54
|
+
const tieReject = new Set()
|
|
55
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
56
|
+
const c = candidates[i]
|
|
57
|
+
const nxt = candidates[i + 1]
|
|
58
|
+
if (nxt && nxt.file.id === c.file.id && nxt.dt === c.dt && nxt.orphan.sessionId !== c.orphan.sessionId) {
|
|
59
|
+
tieReject.add(c.file.id)
|
|
60
|
+
while (i + 1 < candidates.length && candidates[i + 1].file.id === c.file.id) i++
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (usedFiles.has(c.file.id) || usedOrphans.has(c.orphan.sessionId)) continue
|
|
64
|
+
if (tieReject.has(c.file.id)) continue
|
|
65
|
+
usedFiles.add(c.file.id)
|
|
66
|
+
usedOrphans.add(c.orphan.sessionId)
|
|
67
|
+
pairs.push({ fileId: c.file.id, todoId: c.orphan.todoId, sessionId: c.orphan.sessionId, nativeId: c.file.native_id })
|
|
68
|
+
}
|
|
69
|
+
return pairs
|
|
70
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import readline from 'node:readline'
|
|
5
|
+
import { extractUsage } from '../usage-parser.js'
|
|
6
|
+
import { normalizeContent, blockToText } from './blocks.js'
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_CLAUDE_DIR = path.join(os.homedir(), '.claude', 'projects')
|
|
9
|
+
export const DEFAULT_CODEX_DIR = path.join(os.homedir(), '.codex', 'sessions')
|
|
10
|
+
export const DEFAULT_CURSOR_DIR = path.join(os.homedir(), '.cursor', 'projects')
|
|
11
|
+
|
|
12
|
+
function walkJsonl(root) {
|
|
13
|
+
const out = []
|
|
14
|
+
function walk(dir) {
|
|
15
|
+
let entries
|
|
16
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
17
|
+
for (const ent of entries) {
|
|
18
|
+
const p = path.join(dir, ent.name)
|
|
19
|
+
if (ent.isDirectory()) walk(p)
|
|
20
|
+
else if (ent.isFile() && ent.name.endsWith('.jsonl')) out.push(p)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
walk(root)
|
|
24
|
+
return out
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function safeStat(p) { try { return fs.statSync(p) } catch { return null } }
|
|
28
|
+
|
|
29
|
+
function decodeClaudeCwdFromDir(dirName) {
|
|
30
|
+
// Claude 目录名用 '-' 替换路径分隔符:'-Users-liuzhenhua-Desktop-code' → '/Users/liuzhenhua/Desktop/code'
|
|
31
|
+
if (!dirName.startsWith('-')) return null
|
|
32
|
+
return dirName.replace(/-/g, '/')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function decodeCursorCwdFromDir(dirName) {
|
|
36
|
+
// cursor: 'Users-liuzhenhua-Desktop-foo' → '/Users/liuzhenhua/Desktop/foo'
|
|
37
|
+
// 'private-tmp-x' → '/private/tmp/x'
|
|
38
|
+
// 跳过特殊命名('empty-window'、纯数字 workspaceId 等)
|
|
39
|
+
if (!dirName) return null
|
|
40
|
+
if (dirName === 'empty-window') return null
|
|
41
|
+
if (/^\d+$/.test(dirName)) return null
|
|
42
|
+
return '/' + dirName.replace(/-/g, '/')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function parseClaudeFile(filePath, opts = {}) {
|
|
46
|
+
const preview = Boolean(opts.preview)
|
|
47
|
+
const rl = readline.createInterface({ input: fs.createReadStream(filePath, 'utf8'), crlfDelay: Infinity })
|
|
48
|
+
let nativeId = null
|
|
49
|
+
let cwd = null
|
|
50
|
+
let startedAt = null
|
|
51
|
+
let endedAt = null
|
|
52
|
+
let firstUserPrompt = null
|
|
53
|
+
let turnCount = 0
|
|
54
|
+
const turns = []
|
|
55
|
+
const rawLines = []
|
|
56
|
+
for await (const line of rl) {
|
|
57
|
+
if (!line.trim()) continue
|
|
58
|
+
rawLines.push(line)
|
|
59
|
+
let j
|
|
60
|
+
try { j = JSON.parse(line) } catch { continue }
|
|
61
|
+
if (!nativeId && j.sessionId) nativeId = j.sessionId
|
|
62
|
+
if (!cwd && j.cwd) cwd = j.cwd
|
|
63
|
+
const ts = j.timestamp ? Date.parse(j.timestamp) : null
|
|
64
|
+
if (ts) {
|
|
65
|
+
if (!startedAt || ts < startedAt) startedAt = ts
|
|
66
|
+
if (!endedAt || ts > endedAt) endedAt = ts
|
|
67
|
+
}
|
|
68
|
+
// preview 模式:剔除 isMeta(local-command-caveat 等噪音)与非 user/assistant 类型。
|
|
69
|
+
// 注意:不能过滤 isSidechain —— subagent transcript 文件全部是 sidechain,
|
|
70
|
+
// 过滤后会变成空预览(与索引时 turn_count 不一致)。
|
|
71
|
+
if (preview) {
|
|
72
|
+
if (j.isMeta) continue
|
|
73
|
+
if (j.type !== 'user' && j.type !== 'assistant') continue
|
|
74
|
+
}
|
|
75
|
+
const role = j.message?.role || j.type || j.role
|
|
76
|
+
const msg = j.message
|
|
77
|
+
let blocks
|
|
78
|
+
if (typeof msg === 'string') blocks = [{ type: 'text', text: msg }]
|
|
79
|
+
else blocks = normalizeContent(msg?.content)
|
|
80
|
+
const parts = []
|
|
81
|
+
for (const blk of blocks) {
|
|
82
|
+
const piece = blockToText(blk, { includeToolUse: preview, includeToolResult: preview, toolResultMaxChars: 300 })
|
|
83
|
+
if (piece) parts.push(piece)
|
|
84
|
+
}
|
|
85
|
+
const content = parts.join('\n').trim()
|
|
86
|
+
if (!content) continue
|
|
87
|
+
turnCount++
|
|
88
|
+
turns.push({ role: role || 'raw', content })
|
|
89
|
+
if (!firstUserPrompt && (role === 'user' || msg?.role === 'user')) {
|
|
90
|
+
firstUserPrompt = content.slice(0, 200)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!cwd) {
|
|
94
|
+
const parent = path.basename(path.dirname(filePath))
|
|
95
|
+
cwd = decodeClaudeCwdFromDir(parent)
|
|
96
|
+
}
|
|
97
|
+
const usage = extractUsage('claude', rawLines, {})
|
|
98
|
+
return { nativeId, cwd, startedAt, endedAt, firstUserPrompt, turnCount, turns, usage }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// eslint-disable-next-line no-unused-vars
|
|
102
|
+
async function parseCursorFile(filePath, _opts = {}) {
|
|
103
|
+
// cursor jsonl 格式:每行 {"role":"user|assistant","message":{"content":[...]}}
|
|
104
|
+
// 没有顶层 timestamp / sessionId / cwd,需要从路径反推。
|
|
105
|
+
// ~/.cursor/projects/<encoded-cwd>/agent-transcripts/<chatId>/<chatId>.jsonl
|
|
106
|
+
const rl = readline.createInterface({ input: fs.createReadStream(filePath, 'utf8'), crlfDelay: Infinity })
|
|
107
|
+
let firstUserPrompt = null
|
|
108
|
+
let turnCount = 0
|
|
109
|
+
const turns = []
|
|
110
|
+
const rawLines = []
|
|
111
|
+
for await (const line of rl) {
|
|
112
|
+
if (!line.trim()) continue
|
|
113
|
+
rawLines.push(line)
|
|
114
|
+
let j
|
|
115
|
+
try { j = JSON.parse(line) } catch { continue }
|
|
116
|
+
const role = j.role || j.message?.role
|
|
117
|
+
const content = j.message?.content
|
|
118
|
+
let text = ''
|
|
119
|
+
if (typeof content === 'string') text = content
|
|
120
|
+
else if (Array.isArray(content)) {
|
|
121
|
+
// 块格式同 claude:{type:'text',text}/{type:'tool_use',name,input}/{type:'tool_result',...}
|
|
122
|
+
const parts = []
|
|
123
|
+
for (const blk of content) {
|
|
124
|
+
if (!blk || typeof blk !== 'object') continue
|
|
125
|
+
if (blk.type === 'text' && blk.text) parts.push(String(blk.text))
|
|
126
|
+
else if (blk.type === 'tool_use') {
|
|
127
|
+
const name = blk.name || 'tool'
|
|
128
|
+
const input = blk.input || {}
|
|
129
|
+
const summary = input.command || input.file_path || input.path || input.url || input.pattern || input.query || input.description
|
|
130
|
+
parts.push(`🔧 ${name}${summary ? ': ' + String(summary).slice(0, 200) : ''}`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
text = parts.join('\n').trim()
|
|
134
|
+
}
|
|
135
|
+
if (!text) continue
|
|
136
|
+
turnCount++
|
|
137
|
+
turns.push({ role: role || 'raw', content: text })
|
|
138
|
+
if (!firstUserPrompt && role === 'user') firstUserPrompt = text.slice(0, 200)
|
|
139
|
+
}
|
|
140
|
+
// chatId = 文件名(去 .jsonl);cwd 从父目录的父目录反编码
|
|
141
|
+
const chatId = path.basename(filePath, '.jsonl')
|
|
142
|
+
const grandparent = path.basename(path.dirname(path.dirname(path.dirname(filePath))))
|
|
143
|
+
const cwd = decodeCursorCwdFromDir(grandparent)
|
|
144
|
+
const st = safeStat(filePath)
|
|
145
|
+
const startedAt = st?.birthtimeMs ? Math.floor(st.birthtimeMs) : (st?.mtimeMs ? Math.floor(st.mtimeMs) : null)
|
|
146
|
+
const endedAt = st?.mtimeMs ? Math.floor(st.mtimeMs) : null
|
|
147
|
+
return { nativeId: chatId, cwd, startedAt, endedAt, firstUserPrompt, turnCount, turns, usage: null }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function safeParseJson(s) {
|
|
151
|
+
if (typeof s !== 'string') return s
|
|
152
|
+
try { return JSON.parse(s) } catch { return null }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function parseCodexFile(filePath, opts = {}) {
|
|
156
|
+
const preview = Boolean(opts.preview)
|
|
157
|
+
const rl = readline.createInterface({ input: fs.createReadStream(filePath, 'utf8'), crlfDelay: Infinity })
|
|
158
|
+
let nativeId = null
|
|
159
|
+
let cwd = null
|
|
160
|
+
let startedAt = null
|
|
161
|
+
let endedAt = null
|
|
162
|
+
let firstUserPrompt = null
|
|
163
|
+
let turnCount = 0
|
|
164
|
+
const turns = []
|
|
165
|
+
const rawLines = []
|
|
166
|
+
for await (const line of rl) {
|
|
167
|
+
if (!line.trim()) continue
|
|
168
|
+
rawLines.push(line)
|
|
169
|
+
let j
|
|
170
|
+
try { j = JSON.parse(line) } catch { continue }
|
|
171
|
+
const ts = j.timestamp ? Date.parse(j.timestamp) : null
|
|
172
|
+
if (ts) {
|
|
173
|
+
if (!startedAt || ts < startedAt) startedAt = ts
|
|
174
|
+
if (!endedAt || ts > endedAt) endedAt = ts
|
|
175
|
+
}
|
|
176
|
+
if (j.type === 'session_meta' && j.payload) {
|
|
177
|
+
if (!nativeId && j.payload.id) nativeId = j.payload.id
|
|
178
|
+
if (!cwd && j.payload.cwd) cwd = j.payload.cwd
|
|
179
|
+
if (j.payload.timestamp) {
|
|
180
|
+
const t = Date.parse(j.payload.timestamp)
|
|
181
|
+
if (t && (!startedAt || t < startedAt)) startedAt = t
|
|
182
|
+
}
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
if (preview && j.type === 'event_msg') continue // task_started/task_complete/error 等噪音
|
|
186
|
+
|
|
187
|
+
const payload = j.payload || j
|
|
188
|
+
let role
|
|
189
|
+
let blocks = []
|
|
190
|
+
|
|
191
|
+
if (payload.type === 'reasoning') {
|
|
192
|
+
// 思考块默认隐藏,和 Claude thinking 行为一致
|
|
193
|
+
continue
|
|
194
|
+
} else if (preview && payload.type === 'function_call') {
|
|
195
|
+
role = 'tool_use'
|
|
196
|
+
blocks = [{ type: 'tool_use', name: payload.name, input: safeParseJson(payload.arguments) }]
|
|
197
|
+
} else if (preview && payload.type === 'function_call_output') {
|
|
198
|
+
role = 'tool_result'
|
|
199
|
+
const outRaw = payload.output ?? payload.content ?? ''
|
|
200
|
+
// codex 的 output 有时是字符串,有时是 {output: '...', metadata: {...}} 的 JSON 串
|
|
201
|
+
let outText = outRaw
|
|
202
|
+
if (typeof outRaw === 'string') {
|
|
203
|
+
const parsed = safeParseJson(outRaw)
|
|
204
|
+
if (parsed && typeof parsed === 'object') outText = parsed.output ?? parsed.content ?? outRaw
|
|
205
|
+
}
|
|
206
|
+
blocks = [{ type: 'tool_result', content: typeof outText === 'string' ? outText : JSON.stringify(outText) }]
|
|
207
|
+
} else if (payload.type === 'message') {
|
|
208
|
+
role = payload.role || 'raw'
|
|
209
|
+
blocks = normalizeContent(payload.content)
|
|
210
|
+
} else {
|
|
211
|
+
// 兜底:旧格式 / 测试 fixtures 的扁平 payload({role, content})
|
|
212
|
+
role = payload.role || j.type
|
|
213
|
+
if (typeof payload.content === 'string') blocks = [{ type: 'text', text: payload.content }]
|
|
214
|
+
else if (Array.isArray(payload.content)) blocks = normalizeContent(payload.content)
|
|
215
|
+
else if (typeof payload.text === 'string') blocks = [{ type: 'text', text: payload.text }]
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const parts = []
|
|
219
|
+
for (const blk of blocks) {
|
|
220
|
+
const piece = blockToText(blk, { includeToolUse: preview, includeToolResult: preview, toolResultMaxChars: 300 })
|
|
221
|
+
if (piece) parts.push(piece)
|
|
222
|
+
}
|
|
223
|
+
const content = parts.join('\n').trim()
|
|
224
|
+
if (!content) continue
|
|
225
|
+
turnCount++
|
|
226
|
+
turns.push({ role: role || 'raw', content })
|
|
227
|
+
if (!firstUserPrompt && role === 'user') firstUserPrompt = content.slice(0, 200)
|
|
228
|
+
}
|
|
229
|
+
const usage = extractUsage('codex', rawLines, {})
|
|
230
|
+
return { nativeId, cwd, startedAt, endedAt, firstUserPrompt, turnCount, turns, usage }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function listTranscriptFiles({ claudeDir = DEFAULT_CLAUDE_DIR, codexDir = DEFAULT_CODEX_DIR, cursorDir = null } = {}) {
|
|
234
|
+
const result = []
|
|
235
|
+
for (const p of walkJsonl(claudeDir)) {
|
|
236
|
+
const st = safeStat(p); if (!st) continue
|
|
237
|
+
result.push({ tool: 'claude', jsonlPath: p, size: st.size, mtime: Math.floor(st.mtimeMs) })
|
|
238
|
+
}
|
|
239
|
+
for (const p of walkJsonl(codexDir)) {
|
|
240
|
+
const st = safeStat(p); if (!st) continue
|
|
241
|
+
result.push({ tool: 'codex', jsonlPath: p, size: st.size, mtime: Math.floor(st.mtimeMs) })
|
|
242
|
+
}
|
|
243
|
+
// cursor: 只扫 agent-transcripts/<chatId>/<chatId>.jsonl,避免把 worker.log/repo.json 的同目录其他文件吃进来
|
|
244
|
+
if (cursorDir) {
|
|
245
|
+
for (const p of walkJsonl(cursorDir)) {
|
|
246
|
+
if (!p.includes(`${path.sep}agent-transcripts${path.sep}`)) continue
|
|
247
|
+
const st = safeStat(p); if (!st) continue
|
|
248
|
+
result.push({ tool: 'cursor', jsonlPath: p, size: st.size, mtime: Math.floor(st.mtimeMs) })
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return result
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function parseTranscriptFile(tool, filePath, opts = {}) {
|
|
255
|
+
if (tool === 'claude') return parseClaudeFile(filePath, opts)
|
|
256
|
+
if (tool === 'codex') return parseCodexFile(filePath, opts)
|
|
257
|
+
if (tool === 'cursor') return parseCursorFile(filePath, opts)
|
|
258
|
+
throw new Error(`unknown tool: ${tool}`)
|
|
259
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram 推送 footer:把"本轮 token 用量 + session 累计费用"拼成两行信息,
|
|
3
|
+
* 让用户每次收到 AI 回复时都能立刻看到这一轮和这个 task 总共烧了多少钱。
|
|
4
|
+
*
|
|
5
|
+
* 数据流:
|
|
6
|
+
* - turn usage: 由 openclaw-hook 从 readLatestAssistantTurn().raw.message 拿
|
|
7
|
+
* (usage / model 直接是 Claude 写到 jsonl 里的字段)
|
|
8
|
+
* - session usage: 扫整个 jsonl 文件,调 usage-parser.extractUsage('claude', lines)
|
|
9
|
+
* 拿到 session 内所有 assistant 消息的累加值
|
|
10
|
+
*
|
|
11
|
+
* 输出格式(紧凑两行,第三方 client 也好渲染):
|
|
12
|
+
*
|
|
13
|
+
* ———— 💸 ————
|
|
14
|
+
* turn: in 1.2k · out 350 · cache 1.0k → $0.012 (¥0.09)
|
|
15
|
+
* session: $0.34 (¥2.46) · 12 turns
|
|
16
|
+
*
|
|
17
|
+
* 边界:
|
|
18
|
+
* - 任何字段缺失 / 全 0 → 那一行直接省略
|
|
19
|
+
* - turn 和 session 都没数据 → 返回空字符串(caller 直接不附 footer)
|
|
20
|
+
* - showCny=false → 省略 ¥…
|
|
21
|
+
* - cnyRate 缺省 → 用 DEFAULT_PRICING.cnyRate (7.2)
|
|
22
|
+
*
|
|
23
|
+
* 纯函数,无 IO;所有依赖(lines / pricing / cnyRate)都从入参传入,方便测试。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { extractUsage } from './usage-parser.js'
|
|
27
|
+
import { estimateCost, DEFAULT_PRICING } from './pricing.js'
|
|
28
|
+
|
|
29
|
+
const FOOTER_DIVIDER = '———— 💸 ————'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 数字格式化:1234 → "1.2k", 999 → "999", 12345 → "12k", 1234567 → "1.2M"
|
|
33
|
+
* 主要给 token 数用,目的是节省 Telegram 显示空间。
|
|
34
|
+
*/
|
|
35
|
+
export function formatTokenCount(n) {
|
|
36
|
+
const v = Number(n) || 0
|
|
37
|
+
if (v < 0) return '0'
|
|
38
|
+
if (v < 1000) return String(v)
|
|
39
|
+
if (v < 10000) return (v / 1000).toFixed(1) + 'k'
|
|
40
|
+
if (v < 1_000_000) return Math.round(v / 1000) + 'k'
|
|
41
|
+
return (v / 1_000_000).toFixed(2) + 'M'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 钱数格式化:自适应位数。
|
|
46
|
+
* < $0.001 → "<$0.001" (太少不值显示具体)
|
|
47
|
+
* < $0.01 → "$0.0042" (4 位小数)
|
|
48
|
+
* < $1 → "$0.123" (3 位小数)
|
|
49
|
+
* ≥ $1 → "$3.45" (2 位小数)
|
|
50
|
+
*
|
|
51
|
+
* 同样规则套到 ¥ 上(CNY 数值通常是 USD * 7.2,量级类似)。
|
|
52
|
+
*/
|
|
53
|
+
function formatMoney(amount, symbol) {
|
|
54
|
+
const v = Math.abs(Number(amount) || 0)
|
|
55
|
+
if (v < 0.001) return `<${symbol}0.001`
|
|
56
|
+
let s
|
|
57
|
+
if (v < 0.01) s = v.toFixed(4)
|
|
58
|
+
else if (v < 1) s = v.toFixed(3)
|
|
59
|
+
else s = v.toFixed(2)
|
|
60
|
+
return `${symbol}${s}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 拼"$0.012 (¥0.09)" 或单 USD/CNY,按 showCny 控制。
|
|
65
|
+
*/
|
|
66
|
+
export function formatCost({ usd, cny, showCny = true } = {}) {
|
|
67
|
+
const usdStr = formatMoney(usd, '$')
|
|
68
|
+
if (!showCny) return usdStr
|
|
69
|
+
const cnyStr = formatMoney(cny, '¥')
|
|
70
|
+
return `${usdStr} (${cnyStr})`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 从 readLatestAssistantTurn().raw 里抽出本轮 usage。
|
|
75
|
+
* 返回 { input, output, cacheRead, cacheCreation, model } —— 字段都是 number / string。
|
|
76
|
+
*
|
|
77
|
+
* 注意:单条 assistant message 的 usage 已经是 Claude 算好的"这次调用消耗"。
|
|
78
|
+
* 一个 turn 可能包含多条 assistant message(tool_use 跟 final response 分开),
|
|
79
|
+
* 但 readLatestAssistantTurn 取的是最后一条(final response),所以本轮 usage 就用这条。
|
|
80
|
+
*
|
|
81
|
+
* 如果 raw 没 usage(极端情况,例如 Claude Code 老版本),返回 null。
|
|
82
|
+
*/
|
|
83
|
+
export function extractTurnUsage(raw) {
|
|
84
|
+
const msg = raw?.message
|
|
85
|
+
if (!msg) return null
|
|
86
|
+
const u = msg.usage
|
|
87
|
+
if (!u) return null
|
|
88
|
+
return {
|
|
89
|
+
input: Number(u.input_tokens) || 0,
|
|
90
|
+
output: Number(u.output_tokens) || 0,
|
|
91
|
+
cacheRead: Number(u.cache_read_input_tokens) || 0,
|
|
92
|
+
cacheCreation: Number(u.cache_creation_input_tokens) || 0,
|
|
93
|
+
model: msg.model || null,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 从 jsonl lines 算 session 累计 usage。
|
|
99
|
+
*
|
|
100
|
+
* @param {string[]} lines JSONL lines
|
|
101
|
+
* @param {'claude'|'codex'} tool tool name; default 'claude' for back-compat
|
|
102
|
+
*/
|
|
103
|
+
export function extractSessionUsageFromLines(lines, tool = 'claude') {
|
|
104
|
+
const summary = extractUsage(tool, lines)
|
|
105
|
+
let turnCount = 0
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
if (!line || !line.trim()) continue
|
|
108
|
+
try {
|
|
109
|
+
const j = JSON.parse(line)
|
|
110
|
+
if (tool === 'claude' && j?.message?.role === 'assistant') turnCount++
|
|
111
|
+
else if (tool === 'codex' && j?.type === 'response_item' && j?.payload?.type === 'message' && j?.payload?.role === 'assistant') turnCount++
|
|
112
|
+
} catch {}
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
input: summary.inputTokens,
|
|
116
|
+
output: summary.outputTokens,
|
|
117
|
+
cacheRead: summary.cacheReadTokens,
|
|
118
|
+
cacheCreation: summary.cacheCreationTokens,
|
|
119
|
+
primaryModel: summary.primaryModel,
|
|
120
|
+
turnCount,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 把 turn / session 拼成 Telegram footer 文本。
|
|
126
|
+
*
|
|
127
|
+
* 入参:
|
|
128
|
+
* - turn: { input, output, cacheRead, cacheCreation, model } 或 null
|
|
129
|
+
* - session: { input, output, cacheRead, cacheCreation, primaryModel, turnCount } 或 null
|
|
130
|
+
* - showCny: 是否显示人民币(默认 true)
|
|
131
|
+
* - pricing: 同 estimateCost;默认 DEFAULT_PRICING
|
|
132
|
+
*
|
|
133
|
+
* 返回:footer 字符串,或 '' 表示不要附加。
|
|
134
|
+
*
|
|
135
|
+
* 单测覆盖各种 0 / null / 缺字段的退化路径。
|
|
136
|
+
*/
|
|
137
|
+
export function formatUsageFooter({ turn = null, session = null, showCny = true, pricing = DEFAULT_PRICING } = {}) {
|
|
138
|
+
const lines = []
|
|
139
|
+
|
|
140
|
+
// ── turn line ─────────────────────────────
|
|
141
|
+
if (turn && (turn.input || turn.output || turn.cacheRead || turn.cacheCreation)) {
|
|
142
|
+
const parts = []
|
|
143
|
+
if (turn.input) parts.push(`in ${formatTokenCount(turn.input)}`)
|
|
144
|
+
if (turn.output) parts.push(`out ${formatTokenCount(turn.output)}`)
|
|
145
|
+
const cache = (turn.cacheRead || 0) + (turn.cacheCreation || 0)
|
|
146
|
+
if (cache > 0) parts.push(`cache ${formatTokenCount(cache)}`)
|
|
147
|
+
const cost = estimateCost(
|
|
148
|
+
{ input: turn.input, output: turn.output, cacheRead: turn.cacheRead, cacheCreation: turn.cacheCreation },
|
|
149
|
+
turn.model,
|
|
150
|
+
pricing,
|
|
151
|
+
)
|
|
152
|
+
lines.push(`turn: ${parts.join(' · ')} → ${formatCost({ usd: cost.usd, cny: cost.cny, showCny })}`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── session line ──────────────────────────
|
|
156
|
+
if (session && (session.input || session.output || session.cacheRead || session.cacheCreation)) {
|
|
157
|
+
const cost = estimateCost(
|
|
158
|
+
{ input: session.input, output: session.output, cacheRead: session.cacheRead, cacheCreation: session.cacheCreation },
|
|
159
|
+
session.primaryModel,
|
|
160
|
+
pricing,
|
|
161
|
+
)
|
|
162
|
+
const turnTag = session.turnCount ? ` · ${session.turnCount} turns` : ''
|
|
163
|
+
lines.push(`session: ${formatCost({ usd: cost.usd, cny: cost.cny, showCny })}${turnTag}`)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (lines.length === 0) return ''
|
|
167
|
+
return `${FOOTER_DIVIDER}\n${lines.join('\n')}`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const __test__ = { FOOTER_DIVIDER, formatMoney }
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Pure helpers: given already-read JSONL lines + tool, return usage summary.
|
|
2
|
+
// No I/O. No throw on bad lines; returns parseErrorCount instead.
|
|
3
|
+
|
|
4
|
+
const MODEL_DATE_SUFFIX = /-\d{8}$/ // e.g. "-20260101"
|
|
5
|
+
|
|
6
|
+
function normalizeModel(name) {
|
|
7
|
+
if (!name) return null
|
|
8
|
+
return String(name).replace(MODEL_DATE_SUFFIX, '')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function pickMode(counter) {
|
|
12
|
+
let best = null, bestN = -1
|
|
13
|
+
for (const [k, n] of counter) if (n > bestN) { best = k; bestN = n }
|
|
14
|
+
return best
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Shared accumulation: takes normalized records { usage, model, ts } and returns summary.
|
|
18
|
+
function accumulateRecords(records, idleThresholdMs) {
|
|
19
|
+
let input = 0, output = 0, cacheR = 0, cacheC = 0
|
|
20
|
+
const modelCounter = new Map()
|
|
21
|
+
const assistantTs = []
|
|
22
|
+
for (const { usage: u = {}, model, ts } of records) {
|
|
23
|
+
input += Number(u.input_tokens) || 0
|
|
24
|
+
output += Number(u.output_tokens) || 0
|
|
25
|
+
cacheR += Number(u.cache_read_input_tokens) || 0
|
|
26
|
+
cacheC += Number(u.cache_creation_input_tokens) || 0
|
|
27
|
+
if (model) modelCounter.set(model, (modelCounter.get(model) || 0) + 1)
|
|
28
|
+
if (!Number.isNaN(ts)) assistantTs.push(ts)
|
|
29
|
+
}
|
|
30
|
+
let activeMs = 0
|
|
31
|
+
assistantTs.sort((a, b) => a - b)
|
|
32
|
+
for (let i = 1; i < assistantTs.length; i++) {
|
|
33
|
+
const dt = assistantTs[i] - assistantTs[i - 1]
|
|
34
|
+
if (dt > 0 && dt <= idleThresholdMs) activeMs += dt
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
inputTokens: input, outputTokens: output,
|
|
38
|
+
cacheReadTokens: cacheR, cacheCreationTokens: cacheC,
|
|
39
|
+
primaryModel: pickMode(modelCounter),
|
|
40
|
+
activeMs,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function extractClaude(lines, { idleThresholdMs }) {
|
|
45
|
+
const records = []
|
|
46
|
+
let errors = 0
|
|
47
|
+
for (const line of lines) {
|
|
48
|
+
if (!line || !line.trim()) continue
|
|
49
|
+
let j
|
|
50
|
+
try { j = JSON.parse(line) } catch { errors++; continue }
|
|
51
|
+
const msg = j.message
|
|
52
|
+
if (msg?.role !== 'assistant') continue
|
|
53
|
+
records.push({
|
|
54
|
+
usage: msg.usage || {},
|
|
55
|
+
model: normalizeModel(msg.model),
|
|
56
|
+
ts: j.timestamp ? Date.parse(j.timestamp) : NaN,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
return { ...accumulateRecords(records, idleThresholdMs), parseErrorCount: errors }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function extractCodex(lines, { idleThresholdMs }) {
|
|
63
|
+
let lastTokenCountInfo = null
|
|
64
|
+
const responseItemRecords = []
|
|
65
|
+
const modelCounter = new Map()
|
|
66
|
+
const assistantTs = []
|
|
67
|
+
let errors = 0
|
|
68
|
+
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
if (!line || !line.trim()) continue
|
|
71
|
+
let j
|
|
72
|
+
try { j = JSON.parse(line) } catch { errors++; continue }
|
|
73
|
+
|
|
74
|
+
if (j.type === 'event_msg' && j.payload?.type === 'token_count') {
|
|
75
|
+
const info = j.payload?.info
|
|
76
|
+
if (info?.total_token_usage) lastTokenCountInfo = info
|
|
77
|
+
} else if (j.type === 'response_item' && j.payload?.type === 'message' && j.payload?.role === 'assistant') {
|
|
78
|
+
const model = normalizeModel(j.payload.model)
|
|
79
|
+
if (model) modelCounter.set(model, (modelCounter.get(model) || 0) + 1)
|
|
80
|
+
const ts = j.timestamp ? Date.parse(j.timestamp) : NaN
|
|
81
|
+
if (!Number.isNaN(ts)) assistantTs.push(ts)
|
|
82
|
+
const u = j.payload.token_usage || j.payload.usage
|
|
83
|
+
if (u) responseItemRecords.push({ usage: u, model, ts })
|
|
84
|
+
} else if (j.type === 'session_meta') {
|
|
85
|
+
const model = normalizeModel(j.payload?.model || j.payload?.model_provider?.model)
|
|
86
|
+
if (model) modelCounter.set(model, (modelCounter.get(model) || 0) + 1)
|
|
87
|
+
} else if (j.type === 'turn_context') {
|
|
88
|
+
const model = normalizeModel(j.payload?.model || j.payload?.collaboration_mode?.settings?.model)
|
|
89
|
+
if (model) modelCounter.set(model, (modelCounter.get(model) || 0) + 1)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let input = 0, output = 0, cacheR = 0, cacheC = 0
|
|
94
|
+
if (lastTokenCountInfo?.total_token_usage) {
|
|
95
|
+
const t = lastTokenCountInfo.total_token_usage
|
|
96
|
+
input = Number(t.input_tokens) || 0
|
|
97
|
+
output = Number(t.output_tokens) || 0
|
|
98
|
+
cacheR = Number(t.cached_input_tokens || t.cache_read_input_tokens) || 0
|
|
99
|
+
cacheC = Number(t.cache_creation_input_tokens) || 0
|
|
100
|
+
} else {
|
|
101
|
+
for (const r of responseItemRecords) {
|
|
102
|
+
input += Number(r.usage.input_tokens) || 0
|
|
103
|
+
output += Number(r.usage.output_tokens) || 0
|
|
104
|
+
cacheR += Number(r.usage.cached_input_tokens || r.usage.cache_read_input_tokens) || 0
|
|
105
|
+
cacheC += Number(r.usage.cache_creation_input_tokens) || 0
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let activeMs = 0
|
|
110
|
+
assistantTs.sort((a, b) => a - b)
|
|
111
|
+
for (let i = 1; i < assistantTs.length; i++) {
|
|
112
|
+
const dt = assistantTs[i] - assistantTs[i - 1]
|
|
113
|
+
if (dt > 0 && dt <= idleThresholdMs) activeMs += dt
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
inputTokens: input, outputTokens: output,
|
|
118
|
+
cacheReadTokens: cacheR, cacheCreationTokens: cacheC,
|
|
119
|
+
primaryModel: pickMode(modelCounter),
|
|
120
|
+
activeMs,
|
|
121
|
+
parseErrorCount: errors,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function extractUsage(tool, lines, opts = {}) {
|
|
126
|
+
const o = { idleThresholdMs: 120_000, ...opts }
|
|
127
|
+
if (tool === 'claude') return extractClaude(lines, o)
|
|
128
|
+
if (tool === 'codex') return extractCodex(lines, o)
|
|
129
|
+
// cursor-agent jsonl 目前不带 token usage 字段(v0.x),返回空 usage 让上游不抛错
|
|
130
|
+
if (tool === 'cursor') return { records: [], totals: {}, parseErrorCount: 0 }
|
|
131
|
+
throw new Error(`unknown tool: ${tool}`)
|
|
132
|
+
}
|