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,103 @@
1
+ #!/usr/bin/env node
2
+ // quadtodo-hook-version: 2
3
+ /**
4
+ * AgentQuad Claude Code hook —— 把 PTY 内 Claude Code 的状态事件转推到微信。
5
+ *
6
+ * 调用约定:
7
+ * - argv[2] = 事件名: stop | notification | session-end
8
+ * - stdin = Claude Code 注入的 hook payload(JSON 文本,可空)
9
+ * - env: QUADTODO_SESSION_ID (空 = 非 AgentQuad 启动的 Claude Code,立刻 exit 0)
10
+ * QUADTODO_TARGET_USER (微信 peer id)
11
+ * QUADTODO_TODO_ID
12
+ * QUADTODO_TODO_TITLE
13
+ *
14
+ * 故障策略:失败一律静默。这个脚本绝不能阻塞 Claude Code。
15
+ * - 没注 env → 仍然记日志("no env"),exit 0
16
+ * - AgentQuad 没起 / 网络失败 → catch 后记日志,exit 0
17
+ * - JSON 解析失败 → 当作空 payload 继续
18
+ *
19
+ * Debug log: 写到 ~/.agentquad/claude-hooks/hook.log,记每次 fire。
20
+ * 这样能 100% 区分"hook 没 fire" vs "fire 了但 AgentQuad 没收到"。
21
+ *
22
+ * 这个文件是模板源;安装器会拷贝到 ~/.agentquad/claude-hooks/notify.js。
23
+ * 顶部 `quadtodo-hook-version` 行用于版本比对,升级 AgentQuad 时会自动覆盖旧脚本(带备份)。
24
+ * 注意:脚本独立运行(不能 import config.js)。LOG_PATH 用 import.meta.url 派生,
25
+ * 跟随脚本所在目录,自动适配 ~/.agentquad / ~/.quadtodo(legacy)。
26
+ */
27
+ import { appendFileSync } from 'node:fs'
28
+ import { dirname, join } from 'node:path'
29
+ import { fileURLToPath } from 'node:url'
30
+
31
+ const LOG_PATH = join(dirname(fileURLToPath(import.meta.url)), 'hook.log')
32
+
33
+ function logLine(obj) {
34
+ try {
35
+ appendFileSync(LOG_PATH, JSON.stringify({ ts: new Date().toISOString(), ...obj }) + '\n', 'utf8')
36
+ } catch { /* ignore — log 失败也不能阻塞 */ }
37
+ }
38
+
39
+ const event = (process.argv[2] || 'unknown').toLowerCase()
40
+ const SESSION_ID = process.env.QUADTODO_SESSION_ID
41
+ if (!SESSION_ID) {
42
+ logLine({ event, status: 'skipped_no_env', argv: process.argv.slice(2) })
43
+ process.exit(0)
44
+ }
45
+
46
+ const QUADTODO_URL = process.env.QUADTODO_URL || 'http://127.0.0.1:5677'
47
+ const ENDPOINT = `${QUADTODO_URL}/api/openclaw/hook`
48
+ logLine({ event, status: 'fired', sessionId: SESSION_ID, todoTitle: process.env.QUADTODO_TODO_TITLE })
49
+
50
+ let raw = ''
51
+ process.stdin.setEncoding('utf8')
52
+ process.stdin.on('data', (chunk) => {
53
+ raw += chunk
54
+ // 防止超大 payload 把这个进程占内存
55
+ if (raw.length > 64 * 1024) raw = raw.slice(0, 64 * 1024)
56
+ })
57
+ process.stdin.on('end', send)
58
+ // 没有 stdin 也要发(例如 SessionEnd 可能不带 payload)
59
+ setTimeout(() => { if (!sent) send() }, 1500).unref?.()
60
+
61
+ let sent = false
62
+ async function send() {
63
+ if (sent) return
64
+ sent = true
65
+
66
+ let hookPayload = null
67
+ if (raw.trim()) {
68
+ try { hookPayload = JSON.parse(raw) } catch { hookPayload = { _raw: raw.slice(0, 240) } }
69
+ }
70
+
71
+ const body = JSON.stringify({
72
+ event,
73
+ sessionId: SESSION_ID,
74
+ targetUserId: process.env.QUADTODO_TARGET_USER || null,
75
+ todoId: process.env.QUADTODO_TODO_ID || null,
76
+ todoTitle: process.env.QUADTODO_TODO_TITLE || null,
77
+ hookPayload,
78
+ })
79
+
80
+ try {
81
+ // 30s timeout:openclaw CLI shell-out 实测 4-6s,留足余量;
82
+ // Claude Code 默认等 hook 60s,所以 30s 安全。
83
+ const ctrl = new AbortController()
84
+ const timer = setTimeout(() => ctrl.abort(), 30_000)
85
+ timer.unref?.()
86
+ const res = await fetch(ENDPOINT, {
87
+ method: 'POST',
88
+ headers: { 'Content-Type': 'application/json' },
89
+ body,
90
+ signal: ctrl.signal,
91
+ })
92
+ clearTimeout(timer)
93
+ if (res.ok) {
94
+ const data = await res.json().catch(() => null)
95
+ logLine({ event, status: 'sent', sessionId: SESSION_ID, action: data?.action, reason: data?.reason })
96
+ } else {
97
+ const text = await res.text().catch(() => '')
98
+ logLine({ event, status: 'http_error', code: res.status, body: text.slice(0, 200) })
99
+ }
100
+ } catch (e) {
101
+ logLine({ event, status: 'fetch_error', error: e?.message || String(e) })
102
+ }
103
+ }
@@ -0,0 +1,305 @@
1
+ import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+ import xtermHeadless from '@xterm/headless'
5
+ import { cursorTranscriptPath } from './pty.js'
6
+
7
+ const { Terminal } = xtermHeadless
8
+
9
+ const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects')
10
+ const CODEX_SESSIONS_DIR = join(homedir(), '.codex', 'sessions')
11
+ function claudeProjectHash(absPath) {
12
+ // Claude Code 在写 JSONL 路径时会先规范化 cwd(去掉尾斜杠)
13
+ // 若不做同样处理,带尾斜杠的 cwd 会被 hash 成比实际路径多一个 '-' 的目录名,导致找不到 JSONL
14
+ // 回退到 ptylog("日志降级")的 UX 退化
15
+ const normalized = String(absPath).replace(/\/+$/, '') || '/'
16
+ return normalized.replace(/\//g, '-')
17
+ }
18
+
19
+ // Replay ANSI byte stream through a headless xterm so cursor motions (CUF/CUP/CHA…)
20
+ // become real spaces rather than being deleted — TUIs like Claude Code render status
21
+ // bars by moving the cursor between words instead of writing ASCII spaces.
22
+ export function renderPtyLogText(raw) {
23
+ return new Promise((resolve) => {
24
+ const term = new Terminal({
25
+ cols: 200,
26
+ rows: 50,
27
+ scrollback: 50000,
28
+ allowProposedApi: true,
29
+ convertEol: true,
30
+ })
31
+ term.write(String(raw), () => {
32
+ const buf = term.buffer.active
33
+ const lines = []
34
+ for (let i = 0; i < buf.length; i++) {
35
+ const line = buf.getLine(i)
36
+ lines.push(line ? line.translateToString(true) : '')
37
+ }
38
+ while (lines.length && lines[lines.length - 1] === '') lines.pop()
39
+ term.dispose()
40
+ resolve(lines.join('\n'))
41
+ })
42
+ })
43
+ }
44
+
45
+ function parseClaudeJsonl(filePath) {
46
+ const raw = readFileSync(filePath, 'utf8')
47
+ const turns = []
48
+ for (const line of raw.split('\n')) {
49
+ if (!line) continue
50
+ let obj
51
+ try { obj = JSON.parse(line) } catch { continue }
52
+ if (obj.type !== 'user' && obj.message?.role !== 'assistant' && obj.type !== 'assistant') continue
53
+ const ts = obj.timestamp ? Date.parse(obj.timestamp) : undefined
54
+ const msg = obj.message
55
+ if (!msg) continue
56
+
57
+ if (obj.type === 'user') {
58
+ if (typeof msg.content === 'string') {
59
+ turns.push({ role: 'user', content: msg.content, timestamp: ts })
60
+ } else if (Array.isArray(msg.content)) {
61
+ for (const c of msg.content) {
62
+ if (c.type === 'tool_result') {
63
+ const text = Array.isArray(c.content)
64
+ ? c.content.map(x => x.text || '').join('\n')
65
+ : (typeof c.content === 'string' ? c.content : JSON.stringify(c.content))
66
+ turns.push({ role: 'tool_result', content: text, toolUseId: c.tool_use_id, timestamp: ts })
67
+ } else if (c.type === 'text') {
68
+ turns.push({ role: 'user', content: c.text, timestamp: ts })
69
+ }
70
+ }
71
+ }
72
+ } else if (msg.role === 'assistant' && Array.isArray(msg.content)) {
73
+ for (const c of msg.content) {
74
+ if (c.type === 'text' && c.text) {
75
+ turns.push({ role: 'assistant', content: c.text, timestamp: ts })
76
+ } else if (c.type === 'thinking' && c.thinking) {
77
+ turns.push({ role: 'thinking', content: c.thinking, timestamp: ts })
78
+ } else if (c.type === 'tool_use') {
79
+ turns.push({
80
+ role: 'tool_use',
81
+ toolName: c.name,
82
+ toolUseId: c.id,
83
+ content: typeof c.input === 'string' ? c.input : JSON.stringify(c.input, null, 2),
84
+ timestamp: ts,
85
+ })
86
+ }
87
+ }
88
+ }
89
+ }
90
+ return turns
91
+ }
92
+
93
+ function findClaudeFile(cwd, nativeSessionId) {
94
+ if (!cwd || !nativeSessionId) return null
95
+ const projDir = join(CLAUDE_PROJECTS_DIR, claudeProjectHash(cwd))
96
+ const file = join(projDir, `${nativeSessionId}.jsonl`)
97
+ return existsSync(file) ? file : null
98
+ }
99
+
100
+ function findCodexFile(nativeSessionId) {
101
+ if (!nativeSessionId || !existsSync(CODEX_SESSIONS_DIR)) return null
102
+ // Walk yyyy/mm/dd subdirs
103
+ const years = readdirSync(CODEX_SESSIONS_DIR).filter(y => /^\d{4}$/.test(y))
104
+ for (const y of years) {
105
+ const yDir = join(CODEX_SESSIONS_DIR, y)
106
+ for (const m of readdirSync(yDir).filter(x => /^\d{2}$/.test(x))) {
107
+ const mDir = join(yDir, m)
108
+ for (const d of readdirSync(mDir).filter(x => /^\d{2}$/.test(x))) {
109
+ const dDir = join(mDir, d)
110
+ for (const f of readdirSync(dDir)) {
111
+ if (f.includes(nativeSessionId) && f.endsWith('.jsonl')) {
112
+ return join(dDir, f)
113
+ }
114
+ }
115
+ }
116
+ }
117
+ }
118
+ return null
119
+ }
120
+
121
+ function parseCodexJsonl(filePath) {
122
+ const raw = readFileSync(filePath, 'utf8')
123
+ const turns = []
124
+ for (const line of raw.split('\n')) {
125
+ if (!line) continue
126
+ let obj
127
+ try { obj = JSON.parse(line) } catch { continue }
128
+ if (obj.type !== 'response_item') continue
129
+ const p = obj.payload
130
+ if (!p) continue
131
+ const ts = obj.timestamp ? Date.parse(obj.timestamp) : undefined
132
+
133
+ if (p.type === 'message') {
134
+ if (p.role === 'developer' || p.role === 'system') continue
135
+ const text = Array.isArray(p.content)
136
+ ? p.content.map(c => c.text || '').join('\n').trim()
137
+ : ''
138
+ if (!text) continue
139
+ // Filter out environment_context auto-injection
140
+ if (p.role === 'user' && /^<environment_context>/.test(text)) continue
141
+ turns.push({
142
+ role: p.role === 'assistant' ? 'assistant' : 'user',
143
+ content: text,
144
+ timestamp: ts,
145
+ })
146
+ } else if (p.type === 'function_call') {
147
+ let inputStr = p.arguments
148
+ try { inputStr = JSON.stringify(JSON.parse(p.arguments), null, 2) } catch {}
149
+ turns.push({
150
+ role: 'tool_use',
151
+ toolName: p.name,
152
+ toolUseId: p.call_id,
153
+ content: inputStr,
154
+ timestamp: ts,
155
+ })
156
+ } else if (p.type === 'function_call_output') {
157
+ turns.push({
158
+ role: 'tool_result',
159
+ toolUseId: p.call_id,
160
+ content: typeof p.output === 'string' ? p.output : JSON.stringify(p.output),
161
+ timestamp: ts,
162
+ })
163
+ } else if (p.type === 'reasoning') {
164
+ // Codex reasoning is encrypted by default — skip unless there's visible summary
165
+ const summary = Array.isArray(p.summary) ? p.summary.map(s => s.text || s).join('\n').trim() : ''
166
+ if (summary) turns.push({ role: 'thinking', content: summary, timestamp: ts })
167
+ }
168
+ }
169
+ return turns
170
+ }
171
+
172
+ function findCursorFile(cwd, nativeSessionId) {
173
+ if (!cwd) return null
174
+ if (nativeSessionId) {
175
+ const file = cursorTranscriptPath(cwd, nativeSessionId)
176
+ if (file && existsSync(file)) return file
177
+ // nativeSessionId 已知但文件尚不存在(cursor 还没写第一行)→ 返回 null,
178
+ // 不要 fallback 到 mtime 搜索,否则会展示上一个 cursor 会话的历史内容。
179
+ return null
180
+ }
181
+ // 拿一个临时 chatId 推目录路径,再 dirname 两次 → agent-transcripts 根。
182
+ // 这样不用复制 encodeCursorCwd 实现,避免和 pty.js 漂移。
183
+ const probe = cursorTranscriptPath(cwd, '__probe__')
184
+ if (!probe) return null
185
+ const transcriptsDir = join(probe, '..', '..')
186
+ // 兜底:cursor-agent create-chat 偶发失败导致 nativeSessionId 没被存进 DB。
187
+ // 这种情况下按 mtime 选 cwd 下最近的 chatId 目录,能让正在跑的会话也展示出对话。
188
+ if (!existsSync(transcriptsDir)) return null
189
+ let best = null
190
+ let bestMtime = 0
191
+ let entries
192
+ try { entries = readdirSync(transcriptsDir, { withFileTypes: true }) } catch { return null }
193
+ for (const ent of entries) {
194
+ if (!ent.isDirectory()) continue
195
+ const chatId = ent.name
196
+ const file = join(transcriptsDir, chatId, `${chatId}.jsonl`)
197
+ if (!existsSync(file)) continue
198
+ let st
199
+ try { st = statSync(file) } catch { continue }
200
+ if (st.mtimeMs > bestMtime) { bestMtime = st.mtimeMs; best = file }
201
+ }
202
+ return best
203
+ }
204
+
205
+ // cursor jsonl 格式:每行 {"role":"user|assistant","message":{"content":[{type:'text'|'tool_use'|'tool_result', ...}]}}
206
+ // 没有顶层 timestamp,沿用文件 mtime 作为兜底。
207
+ function parseCursorJsonl(filePath) {
208
+ const raw = readFileSync(filePath, 'utf8')
209
+ const ts = (() => { try { return statSync(filePath).mtimeMs } catch { return undefined } })()
210
+ const turns = []
211
+ for (const line of raw.split('\n')) {
212
+ if (!line.trim()) continue
213
+ let obj
214
+ try { obj = JSON.parse(line) } catch { continue }
215
+ const role = obj.role || obj.message?.role
216
+ if (!role) continue
217
+ const content = obj.message?.content
218
+ if (typeof content === 'string') {
219
+ if (content.trim()) turns.push({ role, content, timestamp: ts })
220
+ continue
221
+ }
222
+ if (!Array.isArray(content)) continue
223
+ if (role === 'assistant') {
224
+ for (const c of content) {
225
+ if (!c || typeof c !== 'object') continue
226
+ if (c.type === 'text' && c.text) {
227
+ turns.push({ role: 'assistant', content: c.text, timestamp: ts })
228
+ } else if (c.type === 'thinking' && (c.thinking || c.text)) {
229
+ turns.push({ role: 'thinking', content: c.thinking || c.text, timestamp: ts })
230
+ } else if (c.type === 'tool_use') {
231
+ turns.push({
232
+ role: 'tool_use',
233
+ toolName: c.name || 'tool',
234
+ toolUseId: c.id,
235
+ content: typeof c.input === 'string' ? c.input : JSON.stringify(c.input ?? {}, null, 2),
236
+ timestamp: ts,
237
+ })
238
+ }
239
+ }
240
+ } else if (role === 'user') {
241
+ for (const c of content) {
242
+ if (!c || typeof c !== 'object') continue
243
+ if (c.type === 'text' && c.text) {
244
+ turns.push({ role: 'user', content: c.text, timestamp: ts })
245
+ } else if (c.type === 'tool_result') {
246
+ const text = Array.isArray(c.content)
247
+ ? c.content.map(x => x.text || '').join('\n')
248
+ : (typeof c.content === 'string' ? c.content : JSON.stringify(c.content ?? ''))
249
+ turns.push({ role: 'tool_result', content: text, toolUseId: c.tool_use_id, timestamp: ts })
250
+ }
251
+ }
252
+ }
253
+ }
254
+ return turns
255
+ }
256
+
257
+ async function loadFromPtyLog(logDir, sessionId) {
258
+ if (!logDir || !sessionId) return null
259
+ const file = join(logDir, `${sessionId}.log`)
260
+ if (!existsSync(file)) return null
261
+ const raw = readFileSync(file, 'utf8')
262
+ const content = await renderPtyLogText(raw)
263
+ return [{ role: 'raw', content, timestamp: statSync(file).mtimeMs }]
264
+ }
265
+
266
+ async function loadFromLiveOutputHistory(outputHistory, timestamp) {
267
+ if (!Array.isArray(outputHistory) || outputHistory.length === 0) return null
268
+ const raw = outputHistory.join('')
269
+ if (!raw) return null
270
+ const content = await renderPtyLogText(raw)
271
+ return [{ role: 'raw', content, timestamp: timestamp || Date.now() }]
272
+ }
273
+
274
+ /**
275
+ * @param {{ tool: 'claude'|'codex', nativeSessionId?: string|null, cwd?: string|null, sessionId: string, logDir?: string|null, liveOutputHistory?: string[]|null, liveTimestamp?: number|null }} opts
276
+ * @returns {Promise<{ source: 'jsonl'|'ptylog'|'empty', turns: Array<object>, filePath: string|null }>}
277
+ */
278
+ export async function loadTranscript({ tool, nativeSessionId, cwd, sessionId, logDir, liveOutputHistory, liveTimestamp }) {
279
+ try {
280
+ let filePath = null
281
+ if (tool === 'claude' && nativeSessionId && cwd) {
282
+ filePath = findClaudeFile(cwd, nativeSessionId)
283
+ if (filePath) {
284
+ return { source: 'jsonl', turns: parseClaudeJsonl(filePath), filePath }
285
+ }
286
+ } else if (tool === 'codex' && nativeSessionId) {
287
+ filePath = findCodexFile(nativeSessionId)
288
+ if (filePath) {
289
+ return { source: 'jsonl', turns: parseCodexJsonl(filePath), filePath }
290
+ }
291
+ } else if (tool === 'cursor' && cwd) {
292
+ filePath = findCursorFile(cwd, nativeSessionId)
293
+ if (filePath) {
294
+ return { source: 'jsonl', turns: parseCursorJsonl(filePath), filePath }
295
+ }
296
+ }
297
+ } catch (e) {
298
+ console.warn('[transcript] parse failed:', e.message)
299
+ }
300
+ const ptyTurns = await loadFromPtyLog(logDir, sessionId)
301
+ if (ptyTurns) return { source: 'ptylog', turns: ptyTurns, filePath: null }
302
+ const liveTurns = await loadFromLiveOutputHistory(liveOutputHistory, liveTimestamp)
303
+ if (liveTurns) return { source: 'ptylog', turns: liveTurns, filePath: null }
304
+ return { source: 'empty', turns: [], filePath: null }
305
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Shared content-block helpers for Claude / Codex transcript renderers.
3
+ *
4
+ * 历史上 src/claude-transcript.js (Stop hook 用) 和 src/transcripts/scanner.js (历史会话找回的索引/预览用)
5
+ * 各写了一份 block→text 逻辑,scanner 那一份只挑 text 字段,遇到 tool_use / tool_result 就静默丢失整轮,
6
+ * 导致预览里看不到夹在工具调用之间的 user 输入。这里把渲染逻辑统一到一处,两边共用。
7
+ */
8
+
9
+ /** 把 message.content 拍平成 array<block>,无论它是 string、array 还是缺省。 */
10
+ export function normalizeContent(content) {
11
+ if (!content) return []
12
+ if (typeof content === 'string') return [{ type: 'text', text: content }]
13
+ if (Array.isArray(content)) return content
14
+ return []
15
+ }
16
+
17
+ /** 把单个 content block 渲染成人类可读文本。 */
18
+ export function blockToText(block, opts = {}) {
19
+ if (!block || typeof block !== 'object') return ''
20
+ if (block.type === 'text') return String(block.text || '')
21
+ // codex 的 message.content 块可能是 {type:'input_text'|'output_text', text} 或裸 {text}
22
+ if (block.type === 'input_text' || block.type === 'output_text') return String(block.text || '')
23
+ if (!block.type && typeof block.text === 'string') return String(block.text)
24
+ if (block.type === 'tool_use') {
25
+ if (opts.includeToolUse === false) return ''
26
+ const name = block.name || 'tool'
27
+ const input = block.input
28
+ let summary = ''
29
+ if (input && typeof input === 'object') {
30
+ const cmd = input.command || input.cmd
31
+ const fp = input.file_path || input.path || input.filePath
32
+ const url = input.url
33
+ const pat = input.pattern || input.query
34
+ const desc = input.description
35
+ if (cmd) summary = String(cmd).slice(0, 200)
36
+ else if (fp) summary = String(fp).slice(0, 200)
37
+ else if (url) summary = String(url).slice(0, 200)
38
+ else if (pat) summary = String(pat).slice(0, 200)
39
+ else if (desc) summary = String(desc).slice(0, 120)
40
+ else summary = JSON.stringify(input).slice(0, 200)
41
+ }
42
+ return `🔧 ${name}${summary ? ': ' + summary : ''}`
43
+ }
44
+ if (block.type === 'tool_result') {
45
+ if (!opts.includeToolResult) return ''
46
+ const c = block.content
47
+ let text = ''
48
+ if (typeof c === 'string') text = c
49
+ else if (Array.isArray(c)) text = c.map((b) => b?.text || JSON.stringify(b)).join('\n')
50
+ const max = opts.toolResultMaxChars || 300
51
+ if (text.length > max) text = text.slice(0, max) + ` …(${text.length - max} more chars)`
52
+ return `📋 result: ${text}`
53
+ }
54
+ if (block.type === 'thinking') return ''
55
+ return ''
56
+ }
@@ -0,0 +1,222 @@
1
+ import fs from 'node:fs'
2
+ import { listTranscriptFiles, parseTranscriptFile, DEFAULT_CLAUDE_DIR, DEFAULT_CODEX_DIR, DEFAULT_CURSOR_DIR } from './scanner.js'
3
+ import { indexFile } from './indexer.js'
4
+ import { collectOrphans, autoMatch } from './matcher.js'
5
+
6
+ export { DEFAULT_CLAUDE_DIR, DEFAULT_CODEX_DIR, DEFAULT_CURSOR_DIR }
7
+
8
+ export function createTranscriptsService({ db, listTodos, updateTodo, dirs = {} } = {}) {
9
+ const claudeDir = dirs.claude || DEFAULT_CLAUDE_DIR
10
+ const codexDir = dirs.codex || DEFAULT_CODEX_DIR
11
+ // 默认只在生产路径下(caller 没有 override claude/codex)才扫 ~/.cursor/projects;
12
+ // 测试里都传了 fixture 目录,cursor 自动跟随 disabled,避免把用户真实数据吃进来。
13
+ const cursorDir = dirs.cursor !== undefined
14
+ ? dirs.cursor
15
+ : (dirs.claude || dirs.codex ? null : DEFAULT_CURSOR_DIR)
16
+
17
+ function applyBindingToTodo(todoId, { nativeId, tool, startedAt, endedAt }, sessionIdHint) {
18
+ const todo = listTodos().find(t => t.id === todoId)
19
+ if (!todo) return null
20
+ const sessions = Array.isArray(todo.aiSessions) ? [...todo.aiSessions] : []
21
+
22
+ // Remove any existing session on this todo that already holds this native id (dedup)
23
+ const filtered = sessions.filter(s => !(s?.nativeSessionId === nativeId && s?.tool === tool))
24
+
25
+ let targetIdx = -1
26
+ if (sessionIdHint) targetIdx = filtered.findIndex(s => s?.sessionId === sessionIdHint)
27
+ if (targetIdx === -1) targetIdx = filtered.findIndex(s => !s?.nativeSessionId && s?.tool === tool)
28
+
29
+ const baseTs = startedAt || Date.now()
30
+ const newSession = targetIdx >= 0 ? { ...filtered[targetIdx] } : {
31
+ sessionId: `imported-${nativeId}`,
32
+ tool,
33
+ status: 'done',
34
+ startedAt: baseTs,
35
+ prompt: '',
36
+ label: '',
37
+ }
38
+ newSession.nativeSessionId = nativeId
39
+ newSession.tool = tool
40
+ newSession.source = 'imported'
41
+ if (!newSession.startedAt) newSession.startedAt = baseTs
42
+ if (!newSession.completedAt) newSession.completedAt = endedAt || baseTs
43
+ if (!newSession.status || newSession.status === 'running' || newSession.status === 'idle' || newSession.status === 'pending_confirm') {
44
+ newSession.status = 'done'
45
+ }
46
+
47
+ if (targetIdx >= 0) filtered[targetIdx] = newSession
48
+ else filtered.push(newSession)
49
+
50
+ updateTodo(todoId, { aiSessions: filtered })
51
+ return newSession.sessionId
52
+ }
53
+
54
+ function removeBindingFromTodo(todoId, nativeId, tool) {
55
+ const todo = listTodos().find(t => t.id === todoId)
56
+ if (!todo) return
57
+ const sessions = Array.isArray(todo.aiSessions) ? todo.aiSessions : []
58
+ const next = sessions.map(s => {
59
+ if (s?.nativeSessionId === nativeId && s?.tool === tool) {
60
+ if (s.source === 'imported') return null
61
+ return { ...s, nativeSessionId: null }
62
+ }
63
+ return s
64
+ }).filter(Boolean)
65
+ updateTodo(todoId, { aiSessions: next })
66
+ }
67
+
68
+ async function scanFull() {
69
+ return scan({ mode: 'full' })
70
+ }
71
+
72
+ async function scanIncremental() {
73
+ return scan({ mode: 'incremental' })
74
+ }
75
+
76
+ async function scan({ mode }) {
77
+ if (db.raw && db.raw.open === false) return { newFiles: 0, indexed: 0, autoBound: 0, unbound: 0 }
78
+ const disk = listTranscriptFiles({ claudeDir, codexDir, cursorDir: cursorDir || undefined })
79
+ const diskByPath = new Map(disk.map(f => [f.jsonlPath, f]))
80
+ const dbFiles = db.listTranscriptFilesMeta()
81
+ const dbByPath = new Map(dbFiles.map(r => [r.jsonl_path, r]))
82
+
83
+ // delete missing files
84
+ for (const r of dbFiles) {
85
+ if (!diskByPath.has(r.jsonl_path)) db.deleteTranscriptFile(r.jsonl_path)
86
+ }
87
+
88
+ let indexed = 0
89
+ let newFiles = 0
90
+ for (const f of disk) {
91
+ const existing = dbByPath.get(f.jsonlPath)
92
+ const missingUsage = existing && existing.input_tokens == null && existing.output_tokens == null
93
+ const dirty = mode === 'full' || !existing || existing.size !== f.size || existing.mtime !== f.mtime || missingUsage
94
+ if (!dirty) continue
95
+ if (!existing) newFiles++
96
+ const row = await indexFile(db, f)
97
+ if (row) indexed++
98
+ }
99
+
100
+ const autoBound = await autoBindUnbound()
101
+ const unbound = db.countUnboundTranscripts()
102
+ return { newFiles, indexed, autoBound, unbound }
103
+ }
104
+
105
+ async function autoBindUnbound() {
106
+ const unbound = db.listUnboundTranscriptFiles()
107
+ if (!unbound.length) return 0
108
+ const todos = listTodos()
109
+
110
+ // Pass 1(直连):transcript_files.native_id 直接命中 todo.aiSessions[].nativeSessionId
111
+ // AgentQuad 启动的会话都走这条,避免依赖 cwd+time+prompt 的模糊匹配
112
+ const nativeToTodo = new Map()
113
+ for (const t of todos) {
114
+ for (const s of (t.aiSessions || [])) {
115
+ if (s?.nativeSessionId && s?.tool) {
116
+ nativeToTodo.set(`${s.tool}:${s.nativeSessionId}`, t.id)
117
+ }
118
+ }
119
+ }
120
+ const remaining = []
121
+ let directBound = 0
122
+ for (const f of unbound) {
123
+ const hit = f.native_id ? nativeToTodo.get(`${f.tool}:${f.native_id}`) : null
124
+ if (hit) {
125
+ db.setTranscriptBound(f.id, hit)
126
+ directBound++
127
+ } else {
128
+ remaining.push(f)
129
+ }
130
+ }
131
+
132
+ // Pass 2(fuzzy):历史遗留、外部工具启动的会话没有 nativeSessionId 记到 todo 上,才走 cwd+time+prompt
133
+ const orphans = collectOrphans(todos)
134
+ let fuzzyBound = 0
135
+ if (orphans.length && remaining.length) {
136
+ const pairs = autoMatch(remaining, orphans)
137
+ for (const p of pairs) {
138
+ const file = db.getTranscriptFile(p.fileId)
139
+ if (!file) continue
140
+ applyBindingToTodo(p.todoId, {
141
+ nativeId: p.nativeId,
142
+ tool: file.tool,
143
+ startedAt: file.started_at,
144
+ endedAt: file.ended_at,
145
+ }, p.sessionId)
146
+ db.setTranscriptBound(p.fileId, p.todoId)
147
+ fuzzyBound++
148
+ }
149
+ }
150
+ return directBound + fuzzyBound
151
+ }
152
+
153
+ function search(opts) {
154
+ return db.searchTranscripts(opts || {})
155
+ }
156
+
157
+ async function preview(fileId, { offset = 0, limit = 200 } = {}) {
158
+ const f = db.getTranscriptFile(fileId)
159
+ if (!f) return null
160
+ // preview 模式:包含 tool_use / tool_result 摘要,过滤 isMeta(保留 isSidechain,
161
+ // 否则 subagent transcript 文件会变成空预览)。
162
+ // index 模式(默认)保持纯文本以避免污染 FTS。
163
+ const parsed = await parseTranscriptFile(f.tool, f.jsonl_path, { preview: true })
164
+ return {
165
+ file: f,
166
+ turns: parsed.turns.slice(offset, offset + limit),
167
+ totalTurns: parsed.turns.length,
168
+ }
169
+ }
170
+
171
+ function bind(fileId, todoId, { force = false } = {}) {
172
+ const file = db.getTranscriptFile(fileId)
173
+ if (!file) return { ok: false, code: 'NOT_FOUND' }
174
+ if (!file.native_id) return { ok: false, code: 'NO_NATIVE_ID' }
175
+
176
+ // uniqueness: check if another transcript_files row has the same native_id+tool already bound elsewhere
177
+ const twin = db.findTranscriptByNative(file.native_id, file.tool)
178
+ // also check todo-side: any other todo already hosts this nativeId
179
+ const todos = listTodos()
180
+ const existingHost = todos.find(t => t.id !== todoId && (t.aiSessions || []).some(s => s?.tool === file.tool && s?.nativeSessionId === file.native_id))
181
+
182
+ if ((file.bound_todo_id && file.bound_todo_id !== todoId) || existingHost) {
183
+ if (!force) {
184
+ return { ok: false, code: 'ALREADY_BOUND', currentTodoId: file.bound_todo_id || existingHost?.id || null }
185
+ }
186
+ const prevTodoId = file.bound_todo_id || existingHost?.id
187
+ if (prevTodoId) removeBindingFromTodo(prevTodoId, file.native_id, file.tool)
188
+ }
189
+
190
+ applyBindingToTodo(todoId, {
191
+ nativeId: file.native_id,
192
+ tool: file.tool,
193
+ startedAt: file.started_at,
194
+ endedAt: file.ended_at,
195
+ })
196
+ db.setTranscriptBound(fileId, todoId)
197
+ return { ok: true }
198
+ }
199
+
200
+ function unbind(fileId) {
201
+ const file = db.getTranscriptFile(fileId)
202
+ if (!file) return { ok: false, code: 'NOT_FOUND' }
203
+ if (file.bound_todo_id) {
204
+ removeBindingFromTodo(file.bound_todo_id, file.native_id, file.tool)
205
+ db.setTranscriptBound(fileId, null)
206
+ }
207
+ return { ok: true }
208
+ }
209
+
210
+ return {
211
+ scanFull,
212
+ scanIncremental,
213
+ search,
214
+ preview,
215
+ bind,
216
+ unbind,
217
+ getStats: () => ({ unboundCount: db.countUnboundTranscripts() }),
218
+ getFile: (id) => db.getTranscriptFile(id),
219
+ // exposed for tests
220
+ _applyBindingToTodo: applyBindingToTodo,
221
+ }
222
+ }