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,199 @@
1
+ import {
2
+ ensureFtsConsistency,
3
+ initFtsTables,
4
+ rebuildWikiFts,
5
+ } from './fts.js'
6
+
7
+ const ALL_SCOPES = ['todos', 'comments', 'wiki', 'ai_sessions']
8
+
9
+ /**
10
+ * 把用户输入的自然语言 query 转成 FTS5 MATCH 友好的表达式。
11
+ *
12
+ * 策略:
13
+ * - 单词按空白切分;
14
+ * - 每个 token 加通配 `*` 做前缀匹配;
15
+ * - tokens 之间用 AND;
16
+ * - 若 token 含 FTS5 特殊字符(引号/操作符),用 `"..."` 引起来;
17
+ * - 过短(1 字符)和全标点 token 丢弃。
18
+ */
19
+ function sanitizeFtsQuery(raw) {
20
+ const trimmed = String(raw || '').trim()
21
+ if (!trimmed) return ''
22
+ // FTS5 支持的语法字符:AND/OR/NOT/NEAR + 双引号 + 括号 + *
23
+ // 我们简化:先去掉所有引号和括号,保留其它
24
+ const stripped = trimmed.replace(/["()]/g, ' ').replace(/\s+/g, ' ').trim()
25
+ const parts = stripped.split(/\s+/)
26
+ const terms = []
27
+ for (const part of parts) {
28
+ const clean = part.replace(/[*]+$/g, '') // 先剥尾部星号避免 **
29
+ if (!clean) continue
30
+ if (clean.length === 0) continue
31
+ // 安全起见,所有 token 都套双引号 + 尾部加 *(前缀搜索)
32
+ // 双引号里双引号转义
33
+ const quoted = `"${clean.replace(/"/g, '""')}"`
34
+ terms.push(`${quoted}*`)
35
+ }
36
+ return terms.join(' AND ')
37
+ }
38
+
39
+ /**
40
+ * 工厂:创建 search service。
41
+ *
42
+ * 职责:
43
+ * 1. 启动时 initFtsTables + ensureFtsConsistency
44
+ * 2. 对外暴露 search({ query, scopes, includeArchived, limit })
45
+ * 3. 暴露 reindexWiki() 供 wiki run 完成时调用
46
+ */
47
+ export function createSearchService({ db, wikiDir } = {}) {
48
+ if (!db) throw new Error('db_required')
49
+ const dbHandle = db.raw
50
+
51
+ function init() {
52
+ initFtsTables(dbHandle)
53
+ return ensureFtsConsistency(dbHandle, { wikiDir })
54
+ }
55
+
56
+ function reindexWiki() {
57
+ return rebuildWikiFts(dbHandle, { wikiDir })
58
+ }
59
+
60
+ function search({ query, scopes, includeArchived = false, limit = 20 } = {}) {
61
+ if (!query || !String(query).trim()) throw new Error('query_required')
62
+ const cappedLimit = Math.max(1, Math.min(Number(limit) || 20, 100))
63
+ const useScopes = Array.isArray(scopes) && scopes.length
64
+ ? scopes.filter((s) => ALL_SCOPES.includes(s))
65
+ : ALL_SCOPES.slice()
66
+ if (useScopes.length === 0) return { total: 0, results: [] }
67
+ const match = sanitizeFtsQuery(query)
68
+ if (!match) return { total: 0, results: [] }
69
+
70
+ const perScopeResults = []
71
+
72
+ if (useScopes.includes('todos')) {
73
+ // 注意 bm25 越小越相关,所以我们转成 1/(1+bm25) 归一化
74
+ const rows = dbHandle.prepare(`
75
+ SELECT f.todo_id AS todoId,
76
+ snippet(todos_fts, 1, '<mark>', '</mark>', '…', 12) AS titleSnippet,
77
+ snippet(todos_fts, 2, '<mark>', '</mark>', '…', 16) AS descSnippet,
78
+ bm25(todos_fts) AS bm25
79
+ FROM todos_fts f
80
+ JOIN todos t ON t.id = f.todo_id
81
+ WHERE todos_fts MATCH ? ${includeArchived ? '' : 'AND t.archived_at IS NULL'}
82
+ ORDER BY bm25 ASC
83
+ LIMIT ?
84
+ `).all(match, cappedLimit * 2)
85
+ for (const r of rows) {
86
+ perScopeResults.push({
87
+ scope: 'todos',
88
+ todoId: r.todoId,
89
+ snippet: r.descSnippet || r.titleSnippet || '',
90
+ title: null, // 前端 / MCP 再 JOIN 拿完整 title
91
+ score: 1 / (1 + Number(r.bm25)),
92
+ })
93
+ }
94
+ }
95
+
96
+ if (useScopes.includes('comments')) {
97
+ const rows = dbHandle.prepare(`
98
+ SELECT f.comment_id AS commentId,
99
+ f.todo_id AS todoId,
100
+ snippet(comments_fts, 2, '<mark>', '</mark>', '…', 16) AS snippet,
101
+ bm25(comments_fts) AS bm25
102
+ FROM comments_fts f
103
+ JOIN todos t ON t.id = f.todo_id
104
+ WHERE comments_fts MATCH ? ${includeArchived ? '' : 'AND t.archived_at IS NULL'}
105
+ ORDER BY bm25 ASC
106
+ LIMIT ?
107
+ `).all(match, cappedLimit * 2)
108
+ for (const r of rows) {
109
+ perScopeResults.push({
110
+ scope: 'comments',
111
+ todoId: r.todoId,
112
+ commentId: r.commentId,
113
+ snippet: r.snippet || '',
114
+ score: 1 / (1 + Number(r.bm25)),
115
+ })
116
+ }
117
+ }
118
+
119
+ if (useScopes.includes('wiki')) {
120
+ const rows = dbHandle.prepare(`
121
+ SELECT f.todo_id AS todoId,
122
+ snippet(wiki_fts, 1, '<mark>', '</mark>', '…', 16) AS snippet,
123
+ bm25(wiki_fts) AS bm25
124
+ FROM wiki_fts f
125
+ JOIN todos t ON t.id = f.todo_id
126
+ WHERE wiki_fts MATCH ? ${includeArchived ? '' : 'AND t.archived_at IS NULL'}
127
+ ORDER BY bm25 ASC
128
+ LIMIT ?
129
+ `).all(match, cappedLimit * 2)
130
+ for (const r of rows) {
131
+ perScopeResults.push({
132
+ scope: 'wiki',
133
+ todoId: r.todoId,
134
+ snippet: r.snippet || '',
135
+ score: 1 / (1 + Number(r.bm25)),
136
+ })
137
+ }
138
+ }
139
+
140
+ if (useScopes.includes('ai_sessions')) {
141
+ const rows = dbHandle.prepare(`
142
+ SELECT f.todo_id AS todoId,
143
+ f.session_id AS sessionId,
144
+ snippet(ai_sessions_fts, 2, '<mark>', '</mark>', '…', 12) AS labelSnip,
145
+ snippet(ai_sessions_fts, 3, '<mark>', '</mark>', '…', 16) AS commandSnip,
146
+ bm25(ai_sessions_fts) AS bm25
147
+ FROM ai_sessions_fts f
148
+ JOIN todos t ON t.id = f.todo_id
149
+ WHERE ai_sessions_fts MATCH ? ${includeArchived ? '' : 'AND t.archived_at IS NULL'}
150
+ ORDER BY bm25 ASC
151
+ LIMIT ?
152
+ `).all(match, cappedLimit * 2)
153
+ for (const r of rows) {
154
+ perScopeResults.push({
155
+ scope: 'ai_sessions',
156
+ todoId: r.todoId,
157
+ sessionId: r.sessionId,
158
+ snippet: r.commandSnip || r.labelSnip || '',
159
+ score: 1 / (1 + Number(r.bm25)),
160
+ })
161
+ }
162
+ }
163
+
164
+ // 全局排序 + 截断
165
+ perScopeResults.sort((a, b) => b.score - a.score)
166
+ const top = perScopeResults.slice(0, cappedLimit)
167
+
168
+ // 把 todoId → title 查一次,避免 N+1
169
+ const todoIds = [...new Set(top.map((r) => r.todoId))]
170
+ const titleMap = new Map()
171
+ if (todoIds.length) {
172
+ const placeholders = todoIds.map(() => '?').join(',')
173
+ const rows = dbHandle.prepare(`
174
+ SELECT id, title, archived_at FROM todos WHERE id IN (${placeholders})
175
+ `).all(...todoIds)
176
+ for (const r of rows) {
177
+ titleMap.set(r.id, { title: r.title, archived: r.archived_at != null })
178
+ }
179
+ }
180
+ for (const r of top) {
181
+ const info = titleMap.get(r.todoId)
182
+ if (info) {
183
+ r.todoTitle = info.title
184
+ r.archived = info.archived
185
+ }
186
+ }
187
+
188
+ return {
189
+ total: perScopeResults.length,
190
+ results: top,
191
+ }
192
+ }
193
+
194
+ return {
195
+ init,
196
+ reindexWiki,
197
+ search,
198
+ }
199
+ }
@@ -0,0 +1,148 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /**
5
+ * 在 AI 会话日志文件里做逐行关键词扫描。不使用 FTS,纯 Node fs。
6
+ *
7
+ * 查 transcripts 时的常见约束:
8
+ * - 单个 log 文件可能几十 MB(node-pty 输出量大);
9
+ * - 查询返回太多命中会把 LLM 上下文拉爆。
10
+ * 所以这里用严格的 maxMatches / perFileLimit 保护。
11
+ */
12
+ export function createTranscriptScanner({ db, logDir } = {}) {
13
+ if (!db) throw new Error('db_required')
14
+ if (!logDir) throw new Error('logDir_required')
15
+
16
+ /**
17
+ * 从 todo.ai_session 拿出所有 sessionId + 起止时间。
18
+ * 返回 [{ sessionId, startedAt, completedAt, tool, todoId, todoTitle }]
19
+ */
20
+ function collectCandidates({ todoId, afterTs, beforeTs }) {
21
+ const todos = todoId
22
+ ? [db.getTodo(todoId)].filter(Boolean)
23
+ : db.listTodos({ archived: 'all' })
24
+ const out = []
25
+ for (const t of todos) {
26
+ if (!Array.isArray(t.aiSessions)) continue
27
+ for (const s of t.aiSessions) {
28
+ if (!s?.sessionId) continue
29
+ const start = s.startedAt || 0
30
+ if (afterTs && start < afterTs) continue
31
+ if (beforeTs && start > beforeTs) continue
32
+ out.push({
33
+ sessionId: s.sessionId,
34
+ todoId: t.id,
35
+ todoTitle: t.title,
36
+ startedAt: s.startedAt || null,
37
+ completedAt: s.completedAt || null,
38
+ tool: s.tool || null,
39
+ })
40
+ }
41
+ }
42
+ return out
43
+ }
44
+
45
+ /**
46
+ * 对单个 log 文件扫 query。返回至多 perFileLimit 条命中。
47
+ * 命中条目:{ lineNumber, beforeLines[], matchLine, afterLines[] }
48
+ */
49
+ function scanFile(absPath, needle, { perFileLimit = 5, contextBefore = 1, contextAfter = 1 } = {}) {
50
+ if (!existsSync(absPath)) return []
51
+ let buf = ''
52
+ try {
53
+ const st = statSync(absPath)
54
+ // 文件超大(>8MB)只读尾部 4MB,避免 OOM
55
+ if (st.size > 8 * 1024 * 1024) {
56
+ const fd = readFileSync(absPath)
57
+ buf = fd.slice(fd.length - 4 * 1024 * 1024).toString('utf8')
58
+ } else {
59
+ buf = readFileSync(absPath, 'utf8')
60
+ }
61
+ } catch {
62
+ return []
63
+ }
64
+ const lines = buf.split('\n')
65
+ const lowerNeedle = needle.toLowerCase()
66
+ const hits = []
67
+ for (let i = 0; i < lines.length && hits.length < perFileLimit; i++) {
68
+ if (lines[i].toLowerCase().includes(lowerNeedle)) {
69
+ hits.push({
70
+ lineNumber: i + 1,
71
+ beforeLines: lines.slice(Math.max(0, i - contextBefore), i),
72
+ matchLine: lines[i],
73
+ afterLines: lines.slice(i + 1, Math.min(lines.length, i + 1 + contextAfter)),
74
+ })
75
+ }
76
+ }
77
+ return hits
78
+ }
79
+
80
+ /**
81
+ * 主入口。
82
+ */
83
+ function search({
84
+ query,
85
+ todoId,
86
+ afterTs,
87
+ beforeTs,
88
+ maxMatches = 30,
89
+ perFileLimit = 5,
90
+ contextBefore = 1,
91
+ contextAfter = 1,
92
+ } = {}) {
93
+ if (!query || !String(query).trim()) throw new Error('query_required')
94
+ const q = String(query).trim()
95
+ const candidates = collectCandidates({ todoId, afterTs, beforeTs })
96
+ const matches = []
97
+ let scanned = 0
98
+ for (const c of candidates) {
99
+ if (matches.length >= maxMatches) break
100
+ const file = join(logDir, `${c.sessionId}.log`)
101
+ scanned += 1
102
+ const hits = scanFile(file, q, {
103
+ perFileLimit: Math.min(perFileLimit, Math.max(1, maxMatches - matches.length)),
104
+ contextBefore,
105
+ contextAfter,
106
+ })
107
+ for (const h of hits) {
108
+ matches.push({ ...h, sessionId: c.sessionId, todoId: c.todoId, todoTitle: c.todoTitle, tool: c.tool })
109
+ if (matches.length >= maxMatches) break
110
+ }
111
+ }
112
+ return {
113
+ query: q,
114
+ scannedFiles: scanned,
115
+ totalMatches: matches.length,
116
+ matches,
117
+ }
118
+ }
119
+
120
+ /**
121
+ * 读取单个会话 transcript 的完整内容(可截断)。
122
+ * maxChars:返回字符数上限(粗略近似 token,默认 32000 ≈ 8k tokens)
123
+ */
124
+ function readSession({ sessionId, maxChars = 32_000 } = {}) {
125
+ if (!sessionId) throw new Error('sessionId_required')
126
+ const file = join(logDir, `${sessionId}.log`)
127
+ if (!existsSync(file)) {
128
+ return { exists: false, body: null }
129
+ }
130
+ const st = statSync(file)
131
+ const full = readFileSync(file, 'utf8')
132
+ if (full.length <= maxChars) {
133
+ return { exists: true, body: full, bytes: st.size, truncated: false, path: file }
134
+ }
135
+ const tail = full.slice(full.length - maxChars)
136
+ const truncatedChars = full.length - maxChars
137
+ return {
138
+ exists: true,
139
+ body: `…[truncated ${truncatedChars} chars from the start]\n${tail}`,
140
+ bytes: st.size,
141
+ truncated: true,
142
+ droppedChars: truncatedChars,
143
+ path: file,
144
+ }
145
+ }
146
+
147
+ return { search, readSession }
148
+ }