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,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Input Dispatcher
|
|
3
|
+
*
|
|
4
|
+
* 所有 "把用户文本投递到一个 Claude Code session" 的路径都走这里。
|
|
5
|
+
* 三档语义:
|
|
6
|
+
* - queue_or_send :普通文本,busy 时入队,idle 时直发
|
|
7
|
+
* - soft_interrupt :`!` 前缀,busy 时 Esc → 250ms 后投递新文本,丢弃旧队列
|
|
8
|
+
* - hard_cancel :`!!` 前缀 或精确 `/stop`,busy 时 Ctrl+C,不投递文本
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const QUEUE_LIMIT = 20
|
|
12
|
+
const STALE_MS = 5 * 60 * 1000
|
|
13
|
+
const SOFT_INTERRUPT_DELAY_MS = 250
|
|
14
|
+
// 安全网:awaitingReply=false 但 PTY 已经 N 毫秒没 output → 视为 idle,直接写。
|
|
15
|
+
// 历史 bug:浏览器 / REST 上无关的 input 把 awaitingReply 推回 false 后,dispatcher
|
|
16
|
+
// 把 IM 消息全部 queue,必须等下次 Stop hook 才会 flush;如果 Claude 没新输入就一直卡。
|
|
17
|
+
// 这里给 dispatcher 一个 "Claude 大概率在 idle prompt" 的兜底判断(PTY busy 时是连续
|
|
18
|
+
// 输出 spinner / token,3s 静默基本不可能是真 busy)。
|
|
19
|
+
const IDLE_GRACE_MS = 3000
|
|
20
|
+
|
|
21
|
+
export function parseTrigger(rawText) {
|
|
22
|
+
const text = String(rawText || '').trim()
|
|
23
|
+
if (text === '/stop') return { mode: 'hard_cancel', stripped: '' }
|
|
24
|
+
if (text.startsWith('!!')) return { mode: 'hard_cancel', stripped: '' }
|
|
25
|
+
if (text.startsWith('!')) return { mode: 'soft_interrupt', stripped: text.slice(1).trim() }
|
|
26
|
+
return { mode: 'queue_or_send', stripped: text }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildPayload(text, imagePaths) {
|
|
30
|
+
if (!imagePaths || imagePaths.length === 0) return text
|
|
31
|
+
const ats = imagePaths.map((p) => `@${p}`).join(' ')
|
|
32
|
+
return text ? `${ats} ${text}` : ats
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeToPty(pty, sessionId, payload, logger) {
|
|
36
|
+
pty.write(sessionId, payload)
|
|
37
|
+
setTimeout(() => {
|
|
38
|
+
try { pty.write(sessionId, '\r') } catch (e) {
|
|
39
|
+
logger?.warn?.(`[dispatcher] submit \\r failed sid=${sessionId}: ${e.message}`)
|
|
40
|
+
}
|
|
41
|
+
}, 80)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 写真实文本到 PTY 后,更新 ai-terminal awaitingReply=false("已经把输入交给 Claude,现在它在干活")。
|
|
45
|
+
// 写 Esc / Ctrl+C 这类控制字符不调用此函数,因为它们让 Claude 回到 idle prompt 而不是开始新 turn。
|
|
46
|
+
function markBusyAfterWrite(aiTerminal, sessionId) {
|
|
47
|
+
try { aiTerminal.markSessionAwaitingReply?.(sessionId, false) } catch { /* ignore */ }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createSessionInputDispatcher({ pty, aiTerminal, callbacks = {}, logger = console } = {}) {
|
|
51
|
+
if (!pty) throw new Error('pty_required')
|
|
52
|
+
if (!aiTerminal) throw new Error('aiTerminal_required')
|
|
53
|
+
|
|
54
|
+
// sessionId → QueueState { items, firstEchoMessageId, staleTimer }
|
|
55
|
+
const queues = new Map()
|
|
56
|
+
// sessionId set: 软中断 250ms 窗口内
|
|
57
|
+
const softInterrupting = new Set()
|
|
58
|
+
|
|
59
|
+
function getOrCreateQueue(sessionId) {
|
|
60
|
+
let q = queues.get(sessionId)
|
|
61
|
+
if (!q) {
|
|
62
|
+
q = { items: [], staleTimer: null, firstEchoMessageId: null }
|
|
63
|
+
queues.set(sessionId, q)
|
|
64
|
+
}
|
|
65
|
+
return q
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function enqueue({ sessionId, stripped, imagePaths, channel, echoTarget }) {
|
|
69
|
+
const q = getOrCreateQueue(sessionId)
|
|
70
|
+
if (q.items.length >= QUEUE_LIMIT) {
|
|
71
|
+
return { full: true, queueSize: q.items.length }
|
|
72
|
+
}
|
|
73
|
+
q.items.push({ text: stripped, imagePaths, enqueuedAt: Date.now() })
|
|
74
|
+
if (q.staleTimer) clearTimeout(q.staleTimer)
|
|
75
|
+
q.staleTimer = setTimeout(() => {
|
|
76
|
+
if (callbacks.onStale) {
|
|
77
|
+
Promise.resolve(callbacks.onStale({ sessionId, channel, echoTarget, queueSize: q.items.length }))
|
|
78
|
+
.catch((e) => logger?.warn?.(`[dispatcher] onStale failed: ${e.message}`))
|
|
79
|
+
}
|
|
80
|
+
}, STALE_MS)
|
|
81
|
+
const isFirst = q.items.length === 1
|
|
82
|
+
const cb = isFirst ? callbacks.onQueueFirstEnqueue : callbacks.onQueueAdditionalEnqueue
|
|
83
|
+
if (cb) {
|
|
84
|
+
try {
|
|
85
|
+
const echo = await cb({ sessionId, channel, echoTarget, queueSize: q.items.length })
|
|
86
|
+
if (isFirst && echo?.messageId) q.firstEchoMessageId = echo.messageId
|
|
87
|
+
} catch (e) {
|
|
88
|
+
logger?.warn?.(`[dispatcher] echo callback failed: ${e.message}`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { full: false, queueSize: q.items.length }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function send({ sessionId, text, imagePaths = [], channel, echoTarget } = {}) {
|
|
95
|
+
if (!pty.has(sessionId)) {
|
|
96
|
+
return { action: 'session_ended', sessionId }
|
|
97
|
+
}
|
|
98
|
+
const { mode, stripped } = parseTrigger(text)
|
|
99
|
+
let idle = aiTerminal.isSessionAwaitingReply(sessionId)
|
|
100
|
+
|
|
101
|
+
// 兜底:awaitingReply=false 但 PTY 已经静默 ≥ IDLE_GRACE_MS → 视为 idle 直发。
|
|
102
|
+
// 不动 hard_cancel —— 那一档不依赖 idle 状态(无条件发 \x03 中断),
|
|
103
|
+
// 这里加 grace 反而会让 idle 走 noop_idle 分支吞掉用户的 /stop。
|
|
104
|
+
if (!idle && mode !== 'hard_cancel') {
|
|
105
|
+
try {
|
|
106
|
+
const sess = aiTerminal?.sessions?.get?.(sessionId)
|
|
107
|
+
const lastOut = sess?.lastOutputAt || 0
|
|
108
|
+
if (lastOut > 0 && (Date.now() - lastOut) >= IDLE_GRACE_MS) {
|
|
109
|
+
logger?.info?.(`[dispatcher] idle-grace promote sid=${sessionId} silent_for_ms=${Date.now() - lastOut} (awaitingReply=${sess?.awaitingReply})`)
|
|
110
|
+
idle = true
|
|
111
|
+
}
|
|
112
|
+
} catch { /* ignore */ }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (idle) {
|
|
116
|
+
if (mode === 'hard_cancel') {
|
|
117
|
+
return { action: 'noop_idle', sessionId }
|
|
118
|
+
}
|
|
119
|
+
// queue_or_send / soft_interrupt 在 idle 下都等同直发 stripped
|
|
120
|
+
const payload = buildPayload(stripped, imagePaths)
|
|
121
|
+
writeToPty(pty, sessionId, payload, logger)
|
|
122
|
+
markBusyAfterWrite(aiTerminal, sessionId)
|
|
123
|
+
return { action: 'sent', sessionId }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (mode === 'queue_or_send') {
|
|
127
|
+
// 诊断:busy 判定 = aiTerminal.isSessionAwaitingReply(sid) 返回 false。Stop hook
|
|
128
|
+
// 应该已经把 awaitingReply 置 true 了,跑到这里说明 (a) hook 没 fire (b) markS... no-op
|
|
129
|
+
// (c) 中间被 web UI / REST input / PTY exit 重置回去了。把当前快照打出来,便于排查
|
|
130
|
+
// "飞书发消息一直被排队"这类卡死。
|
|
131
|
+
try {
|
|
132
|
+
const sess = aiTerminal?.sessions?.get?.(sessionId)
|
|
133
|
+
logger?.warn?.(`[dispatcher] queueing (idle=false) sid=${sessionId} sessionExists=${!!sess} status=${sess?.status || 'null'} awaitingReply=${sess?.awaitingReply} text=${String(stripped || '').slice(0, 40)}`)
|
|
134
|
+
} catch { /* ignore diag */ }
|
|
135
|
+
const r = await enqueue({ sessionId, stripped, imagePaths, channel, echoTarget })
|
|
136
|
+
if (r.full) return { action: 'queue_full', queueSize: r.queueSize, sessionId }
|
|
137
|
+
return { action: 'queued', queueSize: r.queueSize, sessionId }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (mode === 'soft_interrupt') {
|
|
141
|
+
if (softInterrupting.has(sessionId)) {
|
|
142
|
+
// 250ms 窗口内的第 2 个 ! → 降级为入队
|
|
143
|
+
const r = await enqueue({ sessionId, stripped, imagePaths, channel, echoTarget })
|
|
144
|
+
if (r.full) return { action: 'queue_full', queueSize: r.queueSize, sessionId }
|
|
145
|
+
return { action: 'queued', queueSize: r.queueSize, reason: 'soft_interrupt_in_progress', sessionId }
|
|
146
|
+
}
|
|
147
|
+
return await performSoftInterrupt({ sessionId, stripped, imagePaths })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (mode === 'hard_cancel') {
|
|
151
|
+
return await performHardCancel({ sessionId, channel, echoTarget })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { action: 'noop', reason: 'unknown_mode', sessionId }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function performHardCancel({ sessionId, channel, echoTarget }) {
|
|
158
|
+
const q = queues.get(sessionId)
|
|
159
|
+
if (q) {
|
|
160
|
+
if (q.staleTimer) clearTimeout(q.staleTimer)
|
|
161
|
+
queues.delete(sessionId)
|
|
162
|
+
}
|
|
163
|
+
pty.write(sessionId, '\x03')
|
|
164
|
+
if (callbacks.onHardCancel) {
|
|
165
|
+
try { await callbacks.onHardCancel({ sessionId, channel, echoTarget }) }
|
|
166
|
+
catch (e) { logger?.warn?.(`[dispatcher] onHardCancel callback failed: ${e.message}`) }
|
|
167
|
+
}
|
|
168
|
+
return { action: 'hard_cancelled', sessionId }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function performSoftInterrupt({ sessionId, stripped, imagePaths }) {
|
|
172
|
+
// 丢弃旧队列
|
|
173
|
+
const q = queues.get(sessionId)
|
|
174
|
+
if (q) {
|
|
175
|
+
if (q.staleTimer) clearTimeout(q.staleTimer)
|
|
176
|
+
queues.delete(sessionId)
|
|
177
|
+
}
|
|
178
|
+
// 立刻发 Esc
|
|
179
|
+
pty.write(sessionId, '\x1b')
|
|
180
|
+
softInterrupting.add(sessionId)
|
|
181
|
+
// 等 TUI 回到 prompt
|
|
182
|
+
await new Promise((resolve) => setTimeout(resolve, SOFT_INTERRUPT_DELAY_MS))
|
|
183
|
+
softInterrupting.delete(sessionId)
|
|
184
|
+
// 投递新文本(如果有)
|
|
185
|
+
if (stripped || (imagePaths && imagePaths.length)) {
|
|
186
|
+
const payload = buildPayload(stripped, imagePaths)
|
|
187
|
+
writeToPty(pty, sessionId, payload, logger)
|
|
188
|
+
markBusyAfterWrite(aiTerminal, sessionId)
|
|
189
|
+
}
|
|
190
|
+
return { action: 'soft_interrupted', sessionId }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function flushQueue(sessionId) {
|
|
194
|
+
const q = queues.get(sessionId)
|
|
195
|
+
if (!q || q.items.length === 0) return { flushed: 0 }
|
|
196
|
+
const allImages = []
|
|
197
|
+
const texts = []
|
|
198
|
+
for (const item of q.items) {
|
|
199
|
+
if (item.imagePaths && item.imagePaths.length) allImages.push(...item.imagePaths)
|
|
200
|
+
if (item.text) texts.push(item.text)
|
|
201
|
+
}
|
|
202
|
+
const count = q.items.length
|
|
203
|
+
const combinedText = texts.join('\n')
|
|
204
|
+
const payload = buildPayload(combinedText, allImages)
|
|
205
|
+
if (q.staleTimer) { clearTimeout(q.staleTimer); q.staleTimer = null }
|
|
206
|
+
queues.delete(sessionId)
|
|
207
|
+
try {
|
|
208
|
+
writeToPty(pty, sessionId, payload, logger)
|
|
209
|
+
markBusyAfterWrite(aiTerminal, sessionId)
|
|
210
|
+
} catch (e) {
|
|
211
|
+
logger?.warn?.(`[dispatcher] flush write failed sid=${sessionId}: ${e.message}`)
|
|
212
|
+
return { flushed: 0, error: e.message }
|
|
213
|
+
}
|
|
214
|
+
if (callbacks.onFlush) {
|
|
215
|
+
try { await callbacks.onFlush({ sessionId, count }) }
|
|
216
|
+
catch (e) { logger?.warn?.(`[dispatcher] onFlush callback failed: ${e.message}`) }
|
|
217
|
+
}
|
|
218
|
+
return { flushed: count }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function onSessionIdle(sessionId) {
|
|
222
|
+
return flushQueue(sessionId)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function onSessionEnd(sessionId) {
|
|
226
|
+
const q = queues.get(sessionId)
|
|
227
|
+
if (!q) return
|
|
228
|
+
if (q.staleTimer) clearTimeout(q.staleTimer)
|
|
229
|
+
const undelivered = q.items.slice()
|
|
230
|
+
queues.delete(sessionId)
|
|
231
|
+
if (callbacks.onSessionEnd) {
|
|
232
|
+
try {
|
|
233
|
+
await callbacks.onSessionEnd({
|
|
234
|
+
sessionId,
|
|
235
|
+
undeliveredCount: undelivered.length,
|
|
236
|
+
undeliveredTexts: undelivered.map((it) => it.text),
|
|
237
|
+
})
|
|
238
|
+
} catch (e) {
|
|
239
|
+
logger?.warn?.(`[dispatcher] onSessionEnd callback failed: ${e.message}`)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function describe() {
|
|
245
|
+
const byId = {}
|
|
246
|
+
for (const [sid, q] of queues.entries()) {
|
|
247
|
+
byId[sid] = {
|
|
248
|
+
queueSize: q.items.length,
|
|
249
|
+
oldestEnqueuedAt: q.items[0]?.enqueuedAt ?? null,
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return { sessions: queues.size, byId }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { send, onSessionIdle, onSessionEnd, describe, __test__: { queues, parseTrigger } }
|
|
256
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
function fmtHours(ms) {
|
|
2
|
+
return (ms / 3600_000).toFixed(1) + 'h'
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function fmtTokens(n) {
|
|
6
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
|
|
7
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
|
|
8
|
+
return String(n)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fmtDate(ms) {
|
|
12
|
+
const d = new Date(ms)
|
|
13
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fmtCost(c) {
|
|
17
|
+
return `$${c.usd.toFixed(2)} / ¥${c.cny.toFixed(1)}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function renderMarkdown(r) {
|
|
21
|
+
const { range, summary, topTodos, byModel } = r
|
|
22
|
+
const lines = []
|
|
23
|
+
const title = range.label === '本月' ? '月报' : '周报'
|
|
24
|
+
lines.push(`# AgentQuad ${title} · ${fmtDate(range.since)} ~ ${fmtDate(range.until)}`)
|
|
25
|
+
lines.push('')
|
|
26
|
+
lines.push(`AI 活跃 ${fmtHours(summary.activeMs)}(墙钟 ${fmtHours(summary.wallClockMs)})· ${summary.sessionCount} 场会话 · 覆盖 ${summary.todoCount} 个任务`)
|
|
27
|
+
lines.push(`Token ${fmtTokens(summary.tokens.total)}(cache 命中 ${fmtTokens(summary.tokens.cacheRead)})· 成本 ${fmtCost(summary.cost)}`)
|
|
28
|
+
if (summary.unboundSessionCount > 0) {
|
|
29
|
+
lines.push(`> 其中 ${summary.unboundSessionCount} 场未关联任务`)
|
|
30
|
+
}
|
|
31
|
+
lines.push('')
|
|
32
|
+
lines.push('## Top 10 任务')
|
|
33
|
+
topTodos.forEach((t, i) => {
|
|
34
|
+
lines.push(`${i + 1}. ${t.title} — 活跃 ${fmtHours(t.activeMs)} · ${fmtCost(t.cost)} · ${t.sessionCount} 场`)
|
|
35
|
+
})
|
|
36
|
+
lines.push('')
|
|
37
|
+
lines.push('## 按模型')
|
|
38
|
+
for (const m of byModel) {
|
|
39
|
+
lines.push(`- ${m.key}: ${m.sessions} 场 · ${fmtTokens(m.tokens.input + m.tokens.output)} tok · ${fmtCost(m.cost)}`)
|
|
40
|
+
}
|
|
41
|
+
return lines.join('\n')
|
|
42
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { estimateCost } from '../pricing.js'
|
|
2
|
+
|
|
3
|
+
function addTokens(a, b) {
|
|
4
|
+
return {
|
|
5
|
+
input: a.input + b.input,
|
|
6
|
+
output: a.output + b.output,
|
|
7
|
+
cacheRead: a.cacheRead + b.cacheRead,
|
|
8
|
+
cacheCreation: a.cacheCreation + b.cacheCreation,
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ZERO = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 }
|
|
13
|
+
const ZERO_COST = { usd: 0, cny: 0 }
|
|
14
|
+
|
|
15
|
+
function fileTokens(f) {
|
|
16
|
+
return {
|
|
17
|
+
input: f.input_tokens || 0,
|
|
18
|
+
output: f.output_tokens || 0,
|
|
19
|
+
cacheRead: f.cache_read_tokens || 0,
|
|
20
|
+
cacheCreation: f.cache_creation_tokens || 0,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function addCost(a, b) {
|
|
25
|
+
return { usd: a.usd + b.usd, cny: a.cny + b.cny }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 每个 transcript_file 按其自身 primary_model 计价。混合模型的桶(summary /
|
|
29
|
+
// byTool / byQuadrant / timeline / 某个 todo 下同时跑了 Opus 和 Sonnet)必须先
|
|
30
|
+
// 逐文件算成本再求和,否则"合并 token 后按单一费率算"会把 Opus 的钱按
|
|
31
|
+
// Sonnet 价打折(低估 5x),或把 Haiku 按 Sonnet 价虚高(高估 3x)。
|
|
32
|
+
function fileCost(f, pricing) {
|
|
33
|
+
return estimateCost(fileTokens(f), f.primary_model, pricing)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function pickBucketSize(since, until) {
|
|
37
|
+
return (until - since) > 7 * 86400_000 ? 86400_000 : 3600_000
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildReport(db, { since, until = Date.now(), pricing, topN = 10 }) {
|
|
41
|
+
const raw = db.raw
|
|
42
|
+
const files = raw.prepare(`
|
|
43
|
+
SELECT * FROM transcript_files
|
|
44
|
+
WHERE started_at IS NOT NULL AND started_at >= ? AND started_at < ?
|
|
45
|
+
`).all(since, until)
|
|
46
|
+
|
|
47
|
+
const logs = raw.prepare(`
|
|
48
|
+
SELECT * FROM ai_session_log
|
|
49
|
+
WHERE completed_at >= ? AND completed_at < ?
|
|
50
|
+
`).all(since, until)
|
|
51
|
+
|
|
52
|
+
// Build nativeId → todoId fallback map so stats 不用等下一次 scan 补 bound_todo_id
|
|
53
|
+
// 覆盖:transcript_files.bound_todo_id 为空、但 transcript.native_id 能在任何 todo.aiSessions[] 里找到
|
|
54
|
+
const todos = db.listTodos()
|
|
55
|
+
const nativeToTodo = new Map()
|
|
56
|
+
for (const t of todos) {
|
|
57
|
+
for (const s of (t.aiSessions || [])) {
|
|
58
|
+
if (s?.nativeSessionId && s?.tool) {
|
|
59
|
+
nativeToTodo.set(`${s.tool}:${s.nativeSessionId}`, t.id)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const effectiveTodoId = (f) => f.bound_todo_id
|
|
64
|
+
|| (f.native_id ? nativeToTodo.get(`${f.tool}:${f.native_id}`) : null)
|
|
65
|
+
|| null
|
|
66
|
+
|
|
67
|
+
// summary
|
|
68
|
+
let totalTokens = { ...ZERO }
|
|
69
|
+
let totalCost = { ...ZERO_COST }
|
|
70
|
+
let totalActive = 0
|
|
71
|
+
let totalWall = 0
|
|
72
|
+
const coveredTodos = new Set()
|
|
73
|
+
let unbound = 0
|
|
74
|
+
for (const f of files) {
|
|
75
|
+
totalTokens = addTokens(totalTokens, fileTokens(f))
|
|
76
|
+
totalCost = addCost(totalCost, fileCost(f, pricing))
|
|
77
|
+
totalActive += f.active_ms || 0
|
|
78
|
+
const tid = effectiveTodoId(f)
|
|
79
|
+
if (tid) coveredTodos.add(tid)
|
|
80
|
+
else unbound++
|
|
81
|
+
}
|
|
82
|
+
for (const l of logs) totalWall += l.duration_ms || 0
|
|
83
|
+
|
|
84
|
+
// topTodos: group files by effective todoId(bound 或 通过 nativeId 回填)
|
|
85
|
+
const todoAgg = new Map()
|
|
86
|
+
for (const f of files) {
|
|
87
|
+
const tid = effectiveTodoId(f)
|
|
88
|
+
if (!tid) continue
|
|
89
|
+
const bucket = todoAgg.get(tid) || {
|
|
90
|
+
todoId: tid, activeMs: 0, tokens: { ...ZERO }, cost: { ...ZERO_COST }, sessions: 0, models: new Map(),
|
|
91
|
+
}
|
|
92
|
+
bucket.activeMs += f.active_ms || 0
|
|
93
|
+
bucket.tokens = addTokens(bucket.tokens, fileTokens(f))
|
|
94
|
+
bucket.cost = addCost(bucket.cost, fileCost(f, pricing))
|
|
95
|
+
bucket.sessions += 1
|
|
96
|
+
if (f.primary_model) bucket.models.set(f.primary_model, (bucket.models.get(f.primary_model) || 0) + 1)
|
|
97
|
+
todoAgg.set(tid, bucket)
|
|
98
|
+
}
|
|
99
|
+
// wall clock per todo from ai_session_log
|
|
100
|
+
const todoWall = new Map()
|
|
101
|
+
for (const l of logs) {
|
|
102
|
+
todoWall.set(l.todo_id, (todoWall.get(l.todo_id) || 0) + (l.duration_ms || 0))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const todoById = new Map(todos.map(t => [t.id, t]))
|
|
106
|
+
|
|
107
|
+
const topTodos = [...todoAgg.values()]
|
|
108
|
+
.map(b => {
|
|
109
|
+
const t = todoById.get(b.todoId)
|
|
110
|
+
const topModel = [...b.models.entries()].sort((a, c) => c[1] - a[1])[0]?.[0] || null
|
|
111
|
+
return {
|
|
112
|
+
todoId: b.todoId,
|
|
113
|
+
title: t?.title || '(已删除)',
|
|
114
|
+
quadrant: t?.quadrant || 0,
|
|
115
|
+
activeMs: b.activeMs,
|
|
116
|
+
wallClockMs: todoWall.get(b.todoId) || 0,
|
|
117
|
+
tokens: b.tokens,
|
|
118
|
+
cost: b.cost,
|
|
119
|
+
sessionCount: b.sessions,
|
|
120
|
+
primaryModel: topModel,
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
.sort((a, b) => {
|
|
124
|
+
if (b.activeMs !== a.activeMs) return b.activeMs - a.activeMs
|
|
125
|
+
const aTok = a.tokens.input + a.tokens.output + a.tokens.cacheRead + a.tokens.cacheCreation
|
|
126
|
+
const bTok = b.tokens.input + b.tokens.output + b.tokens.cacheRead + b.tokens.cacheCreation
|
|
127
|
+
return bTok - aTok
|
|
128
|
+
})
|
|
129
|
+
.slice(0, topN)
|
|
130
|
+
|
|
131
|
+
// byTool / byQuadrant / byModel
|
|
132
|
+
const byTool = aggregateBy(files, logs, f => f.tool, l => l.tool, pricing)
|
|
133
|
+
const byQuadrant = aggregateBy(files, logs,
|
|
134
|
+
f => { const t = todoById.get(effectiveTodoId(f)); return t ? t.quadrant : null },
|
|
135
|
+
l => l.quadrant, pricing)
|
|
136
|
+
const byModel = aggregateBy(files, [], f => f.primary_model || '(unknown)', () => null, pricing, { includeWall: false })
|
|
137
|
+
|
|
138
|
+
// timeline
|
|
139
|
+
const bucketSize = pickBucketSize(since, until)
|
|
140
|
+
const timelineMap = new Map()
|
|
141
|
+
for (const f of files) {
|
|
142
|
+
const b = Math.floor((f.started_at || since) / bucketSize) * bucketSize
|
|
143
|
+
const cur = timelineMap.get(b) || { t: b, wallClockMs: 0, activeMs: 0, tokens: { ...ZERO }, cost: { ...ZERO_COST } }
|
|
144
|
+
cur.activeMs += f.active_ms || 0
|
|
145
|
+
cur.tokens = addTokens(cur.tokens, fileTokens(f))
|
|
146
|
+
cur.cost = addCost(cur.cost, fileCost(f, pricing))
|
|
147
|
+
timelineMap.set(b, cur)
|
|
148
|
+
}
|
|
149
|
+
for (const l of logs) {
|
|
150
|
+
const b = Math.floor(l.completed_at / bucketSize) * bucketSize
|
|
151
|
+
const cur = timelineMap.get(b) || { t: b, wallClockMs: 0, activeMs: 0, tokens: { ...ZERO }, cost: { ...ZERO_COST } }
|
|
152
|
+
cur.wallClockMs += l.duration_ms || 0
|
|
153
|
+
timelineMap.set(b, cur)
|
|
154
|
+
}
|
|
155
|
+
const timeline = [...timelineMap.values()].sort((a, b) => a.t - b.t)
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
range: { since, until, label: rangeLabel(since, until) },
|
|
159
|
+
summary: {
|
|
160
|
+
wallClockMs: totalWall,
|
|
161
|
+
activeMs: totalActive,
|
|
162
|
+
tokens: { ...totalTokens, total: totalTokens.input + totalTokens.output + totalTokens.cacheRead + totalTokens.cacheCreation },
|
|
163
|
+
cost: totalCost,
|
|
164
|
+
// transcripts 是 AI 活动的权威来源;ai_session_log 仅覆盖经过 PTY runner 的会话
|
|
165
|
+
sessionCount: files.length,
|
|
166
|
+
todoCount: coveredTodos.size,
|
|
167
|
+
unboundSessionCount: unbound,
|
|
168
|
+
},
|
|
169
|
+
topTodos,
|
|
170
|
+
byTool,
|
|
171
|
+
byQuadrant,
|
|
172
|
+
byModel,
|
|
173
|
+
timeline,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function aggregateBy(files, logs, keyF, keyL, pricing, { includeWall = true } = {}) {
|
|
178
|
+
const m = new Map()
|
|
179
|
+
for (const f of files) {
|
|
180
|
+
const k = keyF(f)
|
|
181
|
+
if (k == null) continue
|
|
182
|
+
const cur = m.get(k) || { key: k, sessions: 0, activeMs: 0, wallClockMs: 0, tokens: { ...ZERO }, cost: { ...ZERO_COST } }
|
|
183
|
+
cur.sessions += 1
|
|
184
|
+
cur.activeMs += f.active_ms || 0
|
|
185
|
+
cur.tokens = addTokens(cur.tokens, fileTokens(f))
|
|
186
|
+
cur.cost = addCost(cur.cost, fileCost(f, pricing))
|
|
187
|
+
m.set(k, cur)
|
|
188
|
+
}
|
|
189
|
+
if (includeWall) {
|
|
190
|
+
for (const l of logs) {
|
|
191
|
+
const k = keyL(l)
|
|
192
|
+
if (k == null) continue
|
|
193
|
+
const cur = m.get(k) || { key: k, sessions: 0, activeMs: 0, wallClockMs: 0, tokens: { ...ZERO }, cost: { ...ZERO_COST } }
|
|
194
|
+
cur.wallClockMs += l.duration_ms || 0
|
|
195
|
+
m.set(k, cur)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return [...m.values()]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function rangeLabel(since, until) {
|
|
202
|
+
const days = Math.round((until - since) / 86400_000)
|
|
203
|
+
if (days === 7) return '本周'
|
|
204
|
+
if (days === 30) return '近 30 天'
|
|
205
|
+
if (days >= 28 && days <= 31) return '本月'
|
|
206
|
+
return '自定义'
|
|
207
|
+
}
|
package/src/summarize.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
const MAX_INPUT_CHARS = 24000
|
|
4
|
+
|
|
5
|
+
function turnsToText(turns) {
|
|
6
|
+
const lines = []
|
|
7
|
+
for (const t of turns) {
|
|
8
|
+
const role = t.role === 'user' ? '用户'
|
|
9
|
+
: t.role === 'assistant' ? 'AI'
|
|
10
|
+
: t.role === 'thinking' ? '思考'
|
|
11
|
+
: t.role === 'tool_use' ? `工具调用(${t.toolName || ''})`
|
|
12
|
+
: t.role === 'tool_result' ? '工具输出'
|
|
13
|
+
: t.role
|
|
14
|
+
const content = String(t.content || '').slice(0, 2000)
|
|
15
|
+
lines.push(`【${role}】${content}`)
|
|
16
|
+
}
|
|
17
|
+
let text = lines.join('\n\n')
|
|
18
|
+
if (text.length > MAX_INPUT_CHARS) {
|
|
19
|
+
const head = text.slice(0, MAX_INPUT_CHARS / 2)
|
|
20
|
+
const tail = text.slice(-MAX_INPUT_CHARS / 2)
|
|
21
|
+
text = `${head}\n\n...(中间已省略)...\n\n${tail}`
|
|
22
|
+
}
|
|
23
|
+
return text
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function runCli(cmd, args, input, timeoutMs = 60000) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const proc = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
29
|
+
let out = ''
|
|
30
|
+
let err = ''
|
|
31
|
+
const timer = setTimeout(() => {
|
|
32
|
+
try { proc.kill('SIGTERM') } catch { /* ignore */ }
|
|
33
|
+
reject(new Error(`${cmd} summarize timeout`))
|
|
34
|
+
}, timeoutMs)
|
|
35
|
+
proc.stdout.on('data', d => { out += d.toString() })
|
|
36
|
+
proc.stderr.on('data', d => { err += d.toString() })
|
|
37
|
+
proc.on('error', e => { clearTimeout(timer); reject(e) })
|
|
38
|
+
proc.on('close', code => {
|
|
39
|
+
clearTimeout(timer)
|
|
40
|
+
if (code === 0) resolve(out.trim())
|
|
41
|
+
else reject(new Error(`${cmd} exited ${code}: ${err.slice(0, 500)}`))
|
|
42
|
+
})
|
|
43
|
+
if (input != null) {
|
|
44
|
+
proc.stdin.write(input)
|
|
45
|
+
proc.stdin.end()
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveToolInvocation(tool, tools = {}) {
|
|
51
|
+
const toolConfig = tools?.[tool] || {}
|
|
52
|
+
const cmd = toolConfig.bin || toolConfig.command || tool
|
|
53
|
+
const baseArgs = Array.isArray(toolConfig.args) ? toolConfig.args.map((item) => String(item)) : []
|
|
54
|
+
return { cmd, baseArgs }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const SYSTEM_PROMPT = `你是一个对话摘要助手。请把下面的 AI 开发会话历史压缩为一段结构化的中文摘要,供继续对话使用。要求:
|
|
58
|
+
1) 核心目标与任务背景(1-2 句)
|
|
59
|
+
2) 已确认的关键决策 / 结论(要点列表)
|
|
60
|
+
3) 仍未解决的问题 / 下一步 TODO(要点列表)
|
|
61
|
+
4) 重要的代码路径、函数名、命令(如有)
|
|
62
|
+
不要输出客套话,只输出摘要。`
|
|
63
|
+
|
|
64
|
+
export async function summarizeTurns(turns, { tool = 'claude', tools } = {}) {
|
|
65
|
+
if (!Array.isArray(turns) || turns.length === 0) return ''
|
|
66
|
+
const body = turnsToText(turns)
|
|
67
|
+
const input = `${SYSTEM_PROMPT}\n\n=== 会话历史 ===\n${body}\n\n=== 请输出摘要 ===\n`
|
|
68
|
+
|
|
69
|
+
if (tool === 'codex') {
|
|
70
|
+
const { cmd, baseArgs } = resolveToolInvocation('codex', tools)
|
|
71
|
+
try {
|
|
72
|
+
return await runCli(cmd, [...baseArgs, 'exec', '--skip-git-repo-check', '-'], input)
|
|
73
|
+
} catch (e) {
|
|
74
|
+
console.warn('[summarize] codex failed, fallback to claude:', e.message)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const { cmd, baseArgs } = resolveToolInvocation('claude', tools)
|
|
78
|
+
try {
|
|
79
|
+
return await runCli(cmd, [...baseArgs, '-p', '--output-format', 'text'], input)
|
|
80
|
+
} catch (e) {
|
|
81
|
+
console.warn('[summarize] claude failed:', e.message)
|
|
82
|
+
return `(自动摘要失败:${e.message})\n\n原始会话片段:\n${body.slice(0, 2000)}`
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 启动 AI session 时自动 prepend 到 prompt 的"工程纪律"。
|
|
3
|
+
*
|
|
4
|
+
* 这一段会出现在每条 task 的 prompt 顶部,强化 AI 行为:
|
|
5
|
+
* - 拍板必走 ask_user MCP 工具(保证 telegram 端是按钮交互)
|
|
6
|
+
* - 不要在 chat 文本里堆"我有 N 个问题"列表(这种格式在 telegram 没按钮,体验差)
|
|
7
|
+
*
|
|
8
|
+
* 默认不注入,避免把 Claude Code 推进交互式 TUI。
|
|
9
|
+
* 想启用:config.aiSession.enforceAskUserRule = true
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const ASK_USER_RULE = `# 工程纪律 · 拍板必须用 ask_user MCP 工具
|
|
13
|
+
|
|
14
|
+
任何需要用户拍板、确认方向、做选择的场景(包括但不限于:
|
|
15
|
+
"请确认"、"用 X 还是 Y"、"OK 吗"、"哪个方案对"、"我有 N 个问题想问你"),
|
|
16
|
+
必须调用 ask_user MCP 工具,而不是在 chat 文本里写
|
|
17
|
+
✅/✏️、(a)/(b)/(c)、1./2./3. 让用户在 chat 里数字回复。
|
|
18
|
+
|
|
19
|
+
理由:用户的 chat 客户端(Telegram)会把 ask_user 自动渲染成 inline 按钮,
|
|
20
|
+
一键点选 + 可选 ✏️ 补充细节;而你直接写在文本里的"问题列表"是纯文本,
|
|
21
|
+
用户得手敲数字回,体验差且容易记错。
|
|
22
|
+
|
|
23
|
+
ask_user 用法:
|
|
24
|
+
question: 一句话精炼问题(不要堆原因/上下文,那些放在 PTY 输出即可)
|
|
25
|
+
options: 2-8 个互斥选项(每个 ≤ 30 字,越短越好)
|
|
26
|
+
|
|
27
|
+
多个独立决策 → 多次调 ask_user(每次一个问题,AI 拿到答案后再问下一个)。
|
|
28
|
+
不要把多个问题塞进一次 ask_user 的 question 里。
|
|
29
|
+
`
|
|
30
|
+
|
|
31
|
+
export function getAskUserSystemRule() {
|
|
32
|
+
return ASK_USER_RULE
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 拼最终 prompt:
|
|
37
|
+
* [system rule]
|
|
38
|
+
* ---
|
|
39
|
+
* [original prompt:来自 template / user 的内容]
|
|
40
|
+
*
|
|
41
|
+
* 入参:
|
|
42
|
+
* - originalPrompt: caller 已经拼好的 prompt(template + 任务描述)
|
|
43
|
+
* - enforce: 是否启用规则;缺省 false
|
|
44
|
+
*
|
|
45
|
+
* 返回:拼装后的 prompt 字符串。enforce=false 时原样返回 originalPrompt。
|
|
46
|
+
*/
|
|
47
|
+
export function applySystemRules(originalPrompt, { enforce = false } = {}) {
|
|
48
|
+
const orig = String(originalPrompt || '').trim()
|
|
49
|
+
if (!enforce) return orig
|
|
50
|
+
if (!orig) return ASK_USER_RULE.trim()
|
|
51
|
+
return `${ASK_USER_RULE.trim()}\n\n---\n\n${orig}`
|
|
52
|
+
}
|