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,142 @@
1
+ /**
2
+ * ask_user 推送的 inline keyboard 拼装 + callback 路由解析。
3
+ *
4
+ * Telegram 的 callback_data 上限是 64 字节(含),所以编码必须紧凑:
5
+ * qt:ans:<ticket>:<idx> ← 用户点选项
6
+ * qt:ext:<ticket>:<idx> ← 用户点 ✏️ 补充
7
+ *
8
+ * ticket 是 3 字符 base32(pending-questions.js 生成),idx 是 0-7 单字符 → 总长 ≤ 16 字节,安全。
9
+ *
10
+ * 布局策略:
11
+ * - 选项数 ≤ 4 且每条选项文本长度(含 emoji 计 1)≤ 8 → 2 列
12
+ * - 否则 1 列
13
+ * - 每个选项下面单独一行 ✏️ 补充按钮(行内空间留给主选项)
14
+ */
15
+
16
+ export const CB_PREFIX = 'qt'
17
+ export const CB_KIND_ANSWER = 'ans'
18
+ export const CB_KIND_EXTEND = 'ext'
19
+
20
+ const SHORT_TEXT_THRESHOLD = 8 // 用 ≤8 字符判断"短"
21
+ const TWO_COL_MAX_OPTIONS = 4 // 超过 4 个选项强制单列(视觉太挤)
22
+
23
+ /**
24
+ * 截断按钮文字。Telegram 按钮文本最大 64 字节,超过会发不出去。
25
+ * 我们更激进地砍到 24,保持可读 + 不撑屏。
26
+ */
27
+ function truncateLabel(s, max = 24) {
28
+ const t = String(s || '').replace(/\s+/g, ' ').trim()
29
+ if (t.length <= max) return t
30
+ return t.slice(0, max - 1) + '…'
31
+ }
32
+
33
+ /**
34
+ * 用 [...str] 算"显示宽度"(Unicode code points),保证中文字符也按 1 计。
35
+ * 不区分东亚宽度,只为粗略判断"按钮文字短/长"。
36
+ */
37
+ function displayWidth(s) {
38
+ return [...String(s || '')].length
39
+ }
40
+
41
+ /**
42
+ * 构造 ask_user 的 inline keyboard。
43
+ *
44
+ * 入参:
45
+ * - ticket: 3 字符 ticket
46
+ * - options: 选项字符串数组(≥ 2,建议 ≤ 8)
47
+ *
48
+ * 返回:{ inline_keyboard: Button[][] },可直接作为 reply_markup 传 sendMessage。
49
+ *
50
+ * 行布局:
51
+ * - 选项行:每行 1 个按钮(长选项)或 2 个按钮(短选项 + 选项数 ≤ 4)
52
+ * - 紧跟在选项行后面会跟一个"✏️" 按钮行(独占)
53
+ * 注意:每个选项独立配一个 ✏️,让用户可以在选完之后单独补充。
54
+ * 为了不让按钮翻倍变成"主+辅"竖排太长,✏️ 跟主按钮放同一行最右(如果主按钮也独占行)。
55
+ *
56
+ * 实际版本:选用 "选项 + ✏️ 共占一行" 布局,让总行数等于选项数 —— 视觉最紧凑。
57
+ */
58
+ export function buildAskUserReplyMarkup(ticket, options) {
59
+ if (!ticket || typeof ticket !== 'string') {
60
+ throw new Error('ticket required')
61
+ }
62
+ if (!Array.isArray(options) || options.length === 0) {
63
+ throw new Error('options required')
64
+ }
65
+
66
+ const allShort = options.every((o) => displayWidth(o) <= SHORT_TEXT_THRESHOLD)
67
+ const useTwoCol = allShort && options.length <= TWO_COL_MAX_OPTIONS && options.length > 1
68
+
69
+ const rows = []
70
+ if (useTwoCol) {
71
+ // 2 列:每行 [opt_i, ✏️_i, opt_{i+1}, ✏️_{i+1}] —— 4 列太挤,所以
72
+ // 折中:选项一行(2 个),✏️ 一行(2 个),用前缀 ✏️ 1./2. 标识对应关系
73
+ for (let i = 0; i < options.length; i += 2) {
74
+ const optRow = [
75
+ { text: truncateLabel(`${i + 1}. ${options[i]}`), callback_data: `${CB_PREFIX}:${CB_KIND_ANSWER}:${ticket}:${i}` },
76
+ ]
77
+ if (i + 1 < options.length) {
78
+ optRow.push({ text: truncateLabel(`${i + 2}. ${options[i + 1]}`), callback_data: `${CB_PREFIX}:${CB_KIND_ANSWER}:${ticket}:${i + 1}` })
79
+ }
80
+ rows.push(optRow)
81
+ const extRow = [
82
+ { text: `✏️ 补充${i + 1}`, callback_data: `${CB_PREFIX}:${CB_KIND_EXTEND}:${ticket}:${i}` },
83
+ ]
84
+ if (i + 1 < options.length) {
85
+ extRow.push({ text: `✏️ 补充${i + 2}`, callback_data: `${CB_PREFIX}:${CB_KIND_EXTEND}:${ticket}:${i + 1}` })
86
+ }
87
+ rows.push(extRow)
88
+ }
89
+ } else {
90
+ // 1 列:每行 [opt_i, ✏️] —— 主按钮独占主体,✏️ 缩成右侧小按钮
91
+ for (let i = 0; i < options.length; i++) {
92
+ rows.push([
93
+ { text: truncateLabel(`${i + 1}. ${options[i]}`), callback_data: `${CB_PREFIX}:${CB_KIND_ANSWER}:${ticket}:${i}` },
94
+ { text: '✏️', callback_data: `${CB_PREFIX}:${CB_KIND_EXTEND}:${ticket}:${i}` },
95
+ ])
96
+ }
97
+ }
98
+
99
+ return { inline_keyboard: rows }
100
+ }
101
+
102
+ /**
103
+ * 解析 callback_data。
104
+ * 返回 { kind, ticket, idx } 或 null(不是 AgentQuad 的 callback)。
105
+ *
106
+ * 对未知 prefix(包括老的 qt:wd / qt:q / qt:t wizard prefix)返回 null —— caller 决定 fallback。
107
+ */
108
+ export function parseCallbackData(data) {
109
+ const s = String(data || '')
110
+ if (!s.startsWith(`${CB_PREFIX}:`)) return null
111
+ const parts = s.split(':')
112
+ if (parts.length !== 4) return null
113
+ const [, kind, ticket, idxStr] = parts
114
+ if (kind !== CB_KIND_ANSWER && kind !== CB_KIND_EXTEND) return null
115
+ if (!ticket || ticket.length < 1) return null
116
+ const idx = parseInt(idxStr, 10)
117
+ if (!Number.isInteger(idx) || idx < 0 || idx > 99) return null
118
+ return { kind, ticket, idx }
119
+ }
120
+
121
+ /**
122
+ * 选项数字回填给 pending.submitReply 的纯文本("1" / "2"…)
123
+ * 老路径完全兼容(pending-questions.parseReply 的"1..N"分支)。
124
+ */
125
+ export function buildAnswerReplyText(idx) {
126
+ return String(idx + 1)
127
+ }
128
+
129
+ /**
130
+ * 拼"选项 + 用户补充"的最终 answerText(透传给 AI)。
131
+ * 例:选项是 "北京时间",用户补 "用 +0800 不要 +08:00" → "北京时间 · 用 +0800 不要 +08:00"
132
+ *
133
+ * 用 ` · ` 分隔(中圆点)—— 视觉清晰,AI 上下文也能看出主+附结构。
134
+ */
135
+ export function buildExtendedReplyText(optionLabel, extra) {
136
+ const opt = String(optionLabel || '').trim()
137
+ const ex = String(extra || '').trim()
138
+ if (!opt && !ex) return ''
139
+ if (!ex) return opt
140
+ if (!opt) return ex
141
+ return `${opt} · ${ex}`
142
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Claude Code 会话日志解析器:把 ~/.claude/projects/<encoded>/<uuid>.jsonl 解析成
3
+ * 干净的可读文本。
4
+ *
5
+ * 用途:
6
+ * - readLatestAssistantTurn(jsonlPath) → AI 最近一轮的完整文本(用于 Stop hook 推送)
7
+ * - buildFullTranscript(jsonlPath) → 整段对话 markdown(用于 SessionEnd 附件)
8
+ *
9
+ * jsonl 格式(v2.1.x):
10
+ * 每行一个 JSON 对象,type ∈ {user, assistant, queue-operation, ...}
11
+ * message.role: user / assistant
12
+ * message.content:
13
+ * - string(user 偶尔)
14
+ * - array of:
15
+ * {type: 'text', text: '...'}
16
+ * {type: 'tool_use', name: 'Bash', input: {...}, id: '...'}
17
+ * {type: 'tool_result', tool_use_id: '...', content: '...' or array}
18
+ *
19
+ * 设计:
20
+ * - 读全文件(v1 假设 < 5MB;后续超大可改成 readline 流式)
21
+ * - 跳过 meta / queue / system 行
22
+ * - 工具调用用简短摘要("🔧 Bash: ls /tmp")替代完整 input
23
+ * - 工具结果默认折叠(前 200 字 + "(N more chars)")
24
+ */
25
+ import { existsSync, readFileSync } from 'node:fs'
26
+ import { normalizeContent, blockToText } from './transcripts/blocks.js'
27
+
28
+ const MAX_FILE_BYTES = 10 * 1024 * 1024 // 10MB 上限保护
29
+
30
+ export function readJsonlLines(path) {
31
+ if (!existsSync(path)) return []
32
+ const stat = existsSync(path) ? readFileSync(path) : null
33
+ if (!stat) return []
34
+ if (stat.length > MAX_FILE_BYTES) {
35
+ // 超大文件只读末尾 5MB
36
+ const buf = stat.subarray(stat.length - 5 * 1024 * 1024)
37
+ return buf.toString('utf8').split('\n')
38
+ }
39
+ return stat.toString('utf8').split('\n')
40
+ }
41
+
42
+ function parseJsonlLine(line) {
43
+ if (!line) return null
44
+ const trimmed = line.trim()
45
+ if (!trimmed || !trimmed.startsWith('{')) return null
46
+ try { return JSON.parse(trimmed) } catch { return null }
47
+ }
48
+
49
+ /**
50
+ * 取最后一条 assistant 消息的完整文本。
51
+ * 返回 { text, hasToolUse, timestamp, raw } 或 null。
52
+ */
53
+ export function readLatestAssistantTurn(jsonlPath) {
54
+ const lines = readJsonlLines(jsonlPath)
55
+ if (lines.length === 0) return null
56
+
57
+ // 反向找最近的 assistant
58
+ for (let i = lines.length - 1; i >= 0; i--) {
59
+ const obj = parseJsonlLine(lines[i])
60
+ if (!obj) continue
61
+ if (obj.type !== 'assistant') continue
62
+ const content = normalizeContent(obj.message?.content)
63
+ if (content.length === 0) continue
64
+
65
+ const parts = []
66
+ let hasToolUse = false
67
+ for (const block of content) {
68
+ if (block?.type === 'tool_use') hasToolUse = true
69
+ const piece = blockToText(block)
70
+ if (piece) parts.push(piece)
71
+ }
72
+ const text = parts.join('\n\n').trim()
73
+ if (!text) continue
74
+ return {
75
+ text,
76
+ hasToolUse,
77
+ timestamp: obj.timestamp || null,
78
+ raw: obj,
79
+ }
80
+ }
81
+ return null
82
+ }
83
+
84
+ /**
85
+ * 取最后一条 assistant 消息的 stop_reason('end_turn' / 'tool_use' / 'max_tokens' / null)。
86
+ *
87
+ * 用途:Stop hook 校验门。Claude 自家的 Stop hook 在中间停顿、子代理、内部 transition 等场景下
88
+ * 也会 fire,但只有 stop_reason === 'end_turn' 才是"真的本轮结束"。openclaw-hook.js 用这个
89
+ * 函数判断要不要把会话状态翻成 idle。
90
+ *
91
+ * 读不到文件或解析失败时返回 null,调用方应把 null 视作"不可判定"(一般兜底为 true 不阻塞)。
92
+ */
93
+ export function readLatestAssistantStopReason(jsonlPath) {
94
+ const lines = readJsonlLines(jsonlPath)
95
+ if (lines.length === 0) return null
96
+ for (let i = lines.length - 1; i >= 0; i--) {
97
+ const obj = parseJsonlLine(lines[i])
98
+ if (!obj) continue
99
+ if (obj.type !== 'assistant') continue
100
+ if (obj.isMeta || obj.isSidechain) continue
101
+ const sr = obj.message?.stop_reason
102
+ return typeof sr === 'string' ? sr : null
103
+ }
104
+ return null
105
+ }
106
+
107
+ /**
108
+ * 取最后一条 user 消息的时间戳(不含 isMeta / sidechain)。
109
+ * 用于 Stop hook 识别 "AI 是否已经回应到最新 user 输入"。
110
+ */
111
+ export function readLatestUserTimestamp(jsonlPath) {
112
+ const lines = readJsonlLines(jsonlPath)
113
+ for (let i = lines.length - 1; i >= 0; i--) {
114
+ const obj = parseJsonlLine(lines[i])
115
+ if (!obj) continue
116
+ if (obj.type !== 'user') continue
117
+ if (obj.isMeta || obj.isSidechain) continue
118
+ if (obj.timestamp) return obj.timestamp
119
+ }
120
+ return null
121
+ }
122
+
123
+ /**
124
+ * 拿"针对最新 user 输入的 assistant 回应"。
125
+ *
126
+ * 解决的问题:Stop hook 触发瞬间,Claude Code 可能还没写完 jsonl,导致
127
+ * readLatestAssistantTurn 拿到上一轮的内容(用户感觉"每条回复都是上一次的")。
128
+ *
129
+ * 策略:retry 等待,直到 latest assistant.timestamp > latest user.timestamp。
130
+ * 默认 retry 5 次 × 250ms = 1.25s 上限。
131
+ */
132
+ export async function readLatestAssistantTurnFresh(jsonlPath, opts = {}) {
133
+ const maxRetries = opts.maxRetries ?? 5
134
+ const delayMs = opts.delayMs ?? 250
135
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
136
+ const userTs = readLatestUserTimestamp(jsonlPath)
137
+ const turn = readLatestAssistantTurn(jsonlPath)
138
+ if (!turn) {
139
+ // 没 assistant 消息 → 只能等
140
+ if (attempt === maxRetries) return null
141
+ } else if (!userTs || !turn.timestamp || turn.timestamp > userTs) {
142
+ // assistant 在 user 之后(已回应 latest input)—— 干净,返回
143
+ return { ...turn, fresh: true, attempts: attempt }
144
+ } else {
145
+ // assistant 在 user 之前 —— stale,等等再读
146
+ if (attempt === maxRetries) {
147
+ // 仍然 stale → 返回 stale 数据让上游决定
148
+ return { ...turn, fresh: false, attempts: attempt }
149
+ }
150
+ }
151
+ await new Promise((r) => setTimeout(r, delayMs))
152
+ }
153
+ return null
154
+ }
155
+
156
+ /**
157
+ * 整段对话渲染成 markdown。供 SessionEnd 附件用。
158
+ * 返回 { markdown, turnCount }。
159
+ */
160
+ export function buildFullTranscript(jsonlPath, opts = {}) {
161
+ const lines = readJsonlLines(jsonlPath)
162
+ if (lines.length === 0) return { markdown: '', turnCount: 0 }
163
+
164
+ const out = []
165
+ let turnCount = 0
166
+
167
+ for (const line of lines) {
168
+ const obj = parseJsonlLine(line)
169
+ if (!obj) continue
170
+ if (obj.type !== 'user' && obj.type !== 'assistant') continue
171
+ if (obj.isMeta) continue // 跳过 meta(local-command-caveat 等)
172
+ if (obj.isSidechain) continue
173
+
174
+ const role = obj.message?.role || obj.type
175
+ const content = normalizeContent(obj.message?.content)
176
+ if (content.length === 0) continue
177
+
178
+ const parts = []
179
+ for (const block of content) {
180
+ const piece = blockToText(block, { includeToolResult: true, toolResultMaxChars: opts.toolResultMaxChars || 1000 })
181
+ if (piece) parts.push(piece)
182
+ }
183
+ const text = parts.join('\n\n').trim()
184
+ if (!text) continue
185
+
186
+ const ts = obj.timestamp ? new Date(obj.timestamp).toISOString().slice(0, 19).replace('T', ' ') : ''
187
+ if (role === 'user') {
188
+ out.push(`### 👤 User${ts ? ` _${ts}_` : ''}\n\n${text}\n`)
189
+ } else {
190
+ out.push(`### 🤖 Assistant${ts ? ` _${ts}_` : ''}\n\n${text}\n`)
191
+ }
192
+ turnCount++
193
+ }
194
+
195
+ if (turnCount === 0) return { markdown: '', turnCount: 0 }
196
+ const header = `# Claude Code Session Transcript\n\n_Generated: ${new Date().toISOString()}_\n_Source: ${jsonlPath}_\n_Turns: ${turnCount}_\n\n---\n\n`
197
+ return {
198
+ markdown: header + out.join('\n'),
199
+ turnCount,
200
+ }
201
+ }
202
+
203
+ export const __test__ = { normalizeContent, blockToText, parseJsonlLine }