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.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +318 -0
  3. package/dist-web/assets/index-CMaXwixo.js +1234 -0
  4. package/dist-web/assets/index-DBHApzV1.css +32 -0
  5. package/dist-web/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
  6. package/dist-web/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
  7. package/dist-web/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
  8. package/dist-web/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
  9. package/dist-web/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
  10. package/dist-web/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
  11. package/dist-web/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
  12. package/dist-web/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
  13. package/dist-web/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
  14. package/dist-web/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
  15. package/dist-web/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
  16. package/dist-web/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
  17. package/dist-web/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
  18. package/dist-web/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
  19. package/dist-web/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
  20. package/dist-web/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
  21. package/dist-web/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
  22. package/dist-web/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
  23. package/dist-web/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
  24. package/dist-web/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
  25. package/dist-web/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
  26. package/dist-web/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
  27. package/dist-web/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
  28. package/dist-web/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
  29. package/dist-web/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
  30. package/dist-web/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
  31. package/dist-web/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
  32. package/dist-web/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
  33. package/dist-web/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
  34. package/dist-web/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
  35. package/dist-web/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
  36. package/dist-web/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
  37. package/dist-web/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  38. package/dist-web/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  39. package/dist-web/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  40. package/dist-web/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  41. package/dist-web/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  42. package/dist-web/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  43. package/dist-web/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
  44. package/dist-web/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  45. package/dist-web/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
  46. package/dist-web/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
  47. package/dist-web/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
  48. package/dist-web/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
  49. package/dist-web/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
  50. package/dist-web/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
  51. package/dist-web/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
  52. package/dist-web/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
  53. package/dist-web/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
  54. package/dist-web/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
  55. package/dist-web/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
  56. package/dist-web/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
  57. package/dist-web/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
  58. package/dist-web/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
  59. package/dist-web/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
  60. package/dist-web/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
  61. package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  62. package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  63. package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-BWTpRfYl.woff2 +0 -0
  64. package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-CEoEElIJ.woff +0 -0
  65. package/dist-web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  66. package/dist-web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  67. package/dist-web/assets/jetbrains-mono-greek-700-normal-C6CZE3T8.woff2 +0 -0
  68. package/dist-web/assets/jetbrains-mono-greek-700-normal-DEigVDxa.woff +0 -0
  69. package/dist-web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  70. package/dist-web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  71. package/dist-web/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
  72. package/dist-web/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
  73. package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  74. package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  75. package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CZipNAKV.woff2 +0 -0
  76. package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CxPITLHs.woff +0 -0
  77. package/dist-web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  78. package/dist-web/assets/jetbrains-mono-vietnamese-700-normal-BDLVIk2r.woff +0 -0
  79. package/dist-web/assets/logo-D4DDtU-r.png +0 -0
  80. package/dist-web/favicon.png +0 -0
  81. package/dist-web/index.html +14 -0
  82. package/package.json +88 -0
  83. package/src/ask-user-buttons.js +142 -0
  84. package/src/claude-transcript.js +203 -0
  85. package/src/cli.js +1040 -0
  86. package/src/codex-event-emitter.js +111 -0
  87. package/src/codex-prompt-detector.js +53 -0
  88. package/src/codex-sidecar.js +52 -0
  89. package/src/codex-transcript.js +74 -0
  90. package/src/config.js +692 -0
  91. package/src/data/claude-code-commands.json +52 -0
  92. package/src/db.js +1503 -0
  93. package/src/dispatch.js +13 -0
  94. package/src/export/todoMarkdown.js +246 -0
  95. package/src/first-run-wizard.js +82 -0
  96. package/src/git/gitStatus.js +139 -0
  97. package/src/lark-api-client.js +205 -0
  98. package/src/lark-bot.js +510 -0
  99. package/src/lark-card.js +88 -0
  100. package/src/lark-config-service.js +16 -0
  101. package/src/lark-event-client.js +107 -0
  102. package/src/lark-image.js +99 -0
  103. package/src/lark-markdown.js +51 -0
  104. package/src/lark-video.js +163 -0
  105. package/src/mcp/audit.js +34 -0
  106. package/src/mcp/server.js +83 -0
  107. package/src/mcp/tools/destructive/index.js +252 -0
  108. package/src/mcp/tools/openclaw/index.js +405 -0
  109. package/src/mcp/tools/read/index.js +269 -0
  110. package/src/mcp/tools/write/index.js +157 -0
  111. package/src/openclaw-bridge.js +566 -0
  112. package/src/openclaw-hook-installer.js +338 -0
  113. package/src/openclaw-hook.js +908 -0
  114. package/src/openclaw-wizard.js +2442 -0
  115. package/src/pending-questions.js +297 -0
  116. package/src/pricing.js +45 -0
  117. package/src/prompt-render.js +36 -0
  118. package/src/pty.js +992 -0
  119. package/src/routes/ai-terminal.js +1228 -0
  120. package/src/routes/git.js +89 -0
  121. package/src/routes/openclaw-hook.js +67 -0
  122. package/src/routes/openclaw-inbound.js +36 -0
  123. package/src/routes/recurringRules.js +80 -0
  124. package/src/routes/reports.js +50 -0
  125. package/src/routes/search.js +46 -0
  126. package/src/routes/stats.js +31 -0
  127. package/src/routes/telegram-config.js +152 -0
  128. package/src/routes/telegram-sync.js +221 -0
  129. package/src/routes/templates.js +63 -0
  130. package/src/routes/todos.js +649 -0
  131. package/src/routes/transcripts.js +75 -0
  132. package/src/routes/uploads.js +107 -0
  133. package/src/routes/wiki.js +142 -0
  134. package/src/search/fts.js +209 -0
  135. package/src/search/index.js +199 -0
  136. package/src/search/transcripts.js +148 -0
  137. package/src/server.js +1791 -0
  138. package/src/session-input-dispatcher.js +256 -0
  139. package/src/stats/markdown.js +42 -0
  140. package/src/stats/report.js +207 -0
  141. package/src/summarize.js +84 -0
  142. package/src/system-rules.js +52 -0
  143. package/src/telegram-bot.js +875 -0
  144. package/src/telegram-commands.js +149 -0
  145. package/src/telegram-config-service.js +84 -0
  146. package/src/telegram-image.js +95 -0
  147. package/src/telegram-loading-status.js +112 -0
  148. package/src/telegram-markdown.js +82 -0
  149. package/src/telegram-reaction-tracker.js +69 -0
  150. package/src/telegram-video.js +75 -0
  151. package/src/templates/claude-hooks/notify.js +103 -0
  152. package/src/transcript.js +305 -0
  153. package/src/transcripts/blocks.js +56 -0
  154. package/src/transcripts/index.js +222 -0
  155. package/src/transcripts/indexer.js +34 -0
  156. package/src/transcripts/matcher.js +70 -0
  157. package/src/transcripts/scanner.js +259 -0
  158. package/src/usage-footer.js +170 -0
  159. package/src/usage-parser.js +132 -0
  160. package/src/wiki/guide.js +44 -0
  161. package/src/wiki/index.js +232 -0
  162. package/src/wiki/redact.js +34 -0
  163. 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
+ }