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,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
|
+
}
|