agentquad 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/dist-web/assets/index-CMaXwixo.js +1234 -0
- package/dist-web/assets/index-DBHApzV1.css +32 -0
- package/dist-web/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
- package/dist-web/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
- package/dist-web/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
- package/dist-web/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
- package/dist-web/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
- package/dist-web/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
- package/dist-web/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
- package/dist-web/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
- package/dist-web/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
- package/dist-web/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
- package/dist-web/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
- package/dist-web/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
- package/dist-web/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
- package/dist-web/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
- package/dist-web/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
- package/dist-web/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
- package/dist-web/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/dist-web/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/dist-web/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/dist-web/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/dist-web/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/dist-web/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/dist-web/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
- package/dist-web/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
- package/dist-web/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
- package/dist-web/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
- package/dist-web/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
- package/dist-web/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
- package/dist-web/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
- package/dist-web/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
- package/dist-web/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
- package/dist-web/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-BWTpRfYl.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-CEoEElIJ.woff +0 -0
- package/dist-web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
- package/dist-web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-greek-700-normal-C6CZE3T8.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-greek-700-normal-DEigVDxa.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CZipNAKV.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CxPITLHs.woff +0 -0
- package/dist-web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
- package/dist-web/assets/jetbrains-mono-vietnamese-700-normal-BDLVIk2r.woff +0 -0
- package/dist-web/assets/logo-D4DDtU-r.png +0 -0
- package/dist-web/favicon.png +0 -0
- package/dist-web/index.html +14 -0
- package/package.json +88 -0
- package/src/ask-user-buttons.js +142 -0
- package/src/claude-transcript.js +203 -0
- package/src/cli.js +1040 -0
- package/src/codex-event-emitter.js +111 -0
- package/src/codex-prompt-detector.js +53 -0
- package/src/codex-sidecar.js +52 -0
- package/src/codex-transcript.js +74 -0
- package/src/config.js +692 -0
- package/src/data/claude-code-commands.json +52 -0
- package/src/db.js +1503 -0
- package/src/dispatch.js +13 -0
- package/src/export/todoMarkdown.js +246 -0
- package/src/first-run-wizard.js +82 -0
- package/src/git/gitStatus.js +139 -0
- package/src/lark-api-client.js +205 -0
- package/src/lark-bot.js +510 -0
- package/src/lark-card.js +88 -0
- package/src/lark-config-service.js +16 -0
- package/src/lark-event-client.js +107 -0
- package/src/lark-image.js +99 -0
- package/src/lark-markdown.js +51 -0
- package/src/lark-video.js +163 -0
- package/src/mcp/audit.js +34 -0
- package/src/mcp/server.js +83 -0
- package/src/mcp/tools/destructive/index.js +252 -0
- package/src/mcp/tools/openclaw/index.js +405 -0
- package/src/mcp/tools/read/index.js +269 -0
- package/src/mcp/tools/write/index.js +157 -0
- package/src/openclaw-bridge.js +566 -0
- package/src/openclaw-hook-installer.js +338 -0
- package/src/openclaw-hook.js +908 -0
- package/src/openclaw-wizard.js +2442 -0
- package/src/pending-questions.js +297 -0
- package/src/pricing.js +45 -0
- package/src/prompt-render.js +36 -0
- package/src/pty.js +992 -0
- package/src/routes/ai-terminal.js +1228 -0
- package/src/routes/git.js +89 -0
- package/src/routes/openclaw-hook.js +67 -0
- package/src/routes/openclaw-inbound.js +36 -0
- package/src/routes/recurringRules.js +80 -0
- package/src/routes/reports.js +50 -0
- package/src/routes/search.js +46 -0
- package/src/routes/stats.js +31 -0
- package/src/routes/telegram-config.js +152 -0
- package/src/routes/telegram-sync.js +221 -0
- package/src/routes/templates.js +63 -0
- package/src/routes/todos.js +649 -0
- package/src/routes/transcripts.js +75 -0
- package/src/routes/uploads.js +107 -0
- package/src/routes/wiki.js +142 -0
- package/src/search/fts.js +209 -0
- package/src/search/index.js +199 -0
- package/src/search/transcripts.js +148 -0
- package/src/server.js +1791 -0
- package/src/session-input-dispatcher.js +256 -0
- package/src/stats/markdown.js +42 -0
- package/src/stats/report.js +207 -0
- package/src/summarize.js +84 -0
- package/src/system-rules.js +52 -0
- package/src/telegram-bot.js +875 -0
- package/src/telegram-commands.js +149 -0
- package/src/telegram-config-service.js +84 -0
- package/src/telegram-image.js +95 -0
- package/src/telegram-loading-status.js +112 -0
- package/src/telegram-markdown.js +82 -0
- package/src/telegram-reaction-tracker.js +69 -0
- package/src/telegram-video.js +75 -0
- package/src/templates/claude-hooks/notify.js +103 -0
- package/src/transcript.js +305 -0
- package/src/transcripts/blocks.js +56 -0
- package/src/transcripts/index.js +222 -0
- package/src/transcripts/indexer.js +34 -0
- package/src/transcripts/matcher.js +70 -0
- package/src/transcripts/scanner.js +259 -0
- package/src/usage-footer.js +170 -0
- package/src/usage-parser.js +132 -0
- package/src/wiki/guide.js +44 -0
- package/src/wiki/index.js +232 -0
- package/src/wiki/redact.js +34 -0
- package/src/wiki/sources.js +122 -0
|
@@ -0,0 +1,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
|
+
}
|