agentquad 0.4.5 → 0.4.8
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/README.md +4 -3
- package/dist-web/assets/index-DdqC2CwH.css +32 -0
- package/dist-web/assets/{index-By--XlP3.js → index-DkI6ZJx_.js} +411 -399
- package/dist-web/assets/logo-Cxw7XzHl.png +0 -0
- package/dist-web/favicon.png +0 -0
- package/dist-web/index.html +2 -2
- package/package.json +7 -1
- package/src/claude-prompt-detector.js +72 -0
- package/src/cli.js +1 -1
- package/src/codex-hook-installer.js +1 -1
- package/src/codex-prompt-detector.js +104 -13
- package/src/config.js +33 -5
- package/src/db.js +77 -31
- package/src/export/todoMarkdown.js +1 -9
- package/src/lark-bot.js +44 -5
- package/src/mcp/tools/destructive/index.js +22 -16
- package/src/mcp/tools/openclaw/index.js +12 -16
- package/src/mcp/tools/read/index.js +7 -7
- package/src/mcp/tools/write/index.js +9 -6
- package/src/openclaw-bridge.js +176 -28
- package/src/openclaw-hook-installer.js +2 -1
- package/src/openclaw-hook.js +127 -9
- package/src/openclaw-wizard.js +168 -191
- package/src/permission-prompt.js +113 -31
- package/src/prompt-render.js +0 -8
- package/src/pty.js +183 -49
- package/src/routes/ai-terminal.js +90 -26
- package/src/routes/telegram-sync.js +7 -5
- package/src/routes/todos.js +8 -14
- package/src/server.js +90 -12
- package/src/session-input-dispatcher.js +48 -4
- package/src/stats/report.js +1 -6
- package/src/telegram-bot.js +82 -15
- package/src/telegram-loading-status.js +1 -1
- package/src/templates/claude-hooks/notify.js +1 -1
- package/src/templates/codex-hooks/notify.js +1 -1
- package/src/wiki/index.js +1 -1
- package/src/wiki/sources.js +0 -1
- package/dist-web/assets/index-8A0oLLcX.css +0 -32
- package/dist-web/assets/logo-D4DDtU-r.png +0 -0
|
Binary file
|
package/dist-web/favicon.png
CHANGED
|
Binary file
|
package/dist-web/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
|
7
7
|
<title>AgentQuad</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-DkI6ZJx_.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DdqC2CwH.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentquad",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"description": "AgentQuad — local four-quadrant AI task scheduler with embedded Claude Code / Codex terminals",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "LIUZHENHUA521",
|
|
@@ -53,6 +53,10 @@
|
|
|
53
53
|
"build:all": "npm run setup && npm run build",
|
|
54
54
|
"clean": "rm -rf node_modules web/node_modules dist-web web/dist",
|
|
55
55
|
"ensure-web-deps": "node scripts/ensure-web-deps.js",
|
|
56
|
+
"release": "bash scripts/release.sh patch",
|
|
57
|
+
"release:patch": "bash scripts/release.sh patch",
|
|
58
|
+
"release:minor": "bash scripts/release.sh minor",
|
|
59
|
+
"release:major": "bash scripts/release.sh major",
|
|
56
60
|
"prepack": "npm run ensure-web-deps && npm run build:web",
|
|
57
61
|
"postinstall": "chmod +x node_modules/node-pty/prebuilds/*/spawn-helper 2>/dev/null || true"
|
|
58
62
|
},
|
|
@@ -76,6 +80,8 @@
|
|
|
76
80
|
"ws": "^8.18.0"
|
|
77
81
|
},
|
|
78
82
|
"devDependencies": {
|
|
83
|
+
"@xterm/xterm": "^5.5.0",
|
|
84
|
+
"jsdom": "^29.1.1",
|
|
79
85
|
"supertest": "^7.0.0",
|
|
80
86
|
"vitest": "^2.1.1"
|
|
81
87
|
},
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code stdout 提示词检测器:兜底 Notification hook 不 fire 的场景。
|
|
3
|
+
*
|
|
4
|
+
* 背景:Claude Code 2.x 引入 settings.json `permissions.defaultMode='auto'` 后,
|
|
5
|
+
* 由 model classifier 决定是否要弹权限提示。实测命中这条路径时 Notification hook
|
|
6
|
+
* 不 fire(社区 issue 也复现),导致 AgentQuad 完全收不到信号——状态条仍显示"运行中",
|
|
7
|
+
* IM 也不会推权限卡片。
|
|
8
|
+
*
|
|
9
|
+
* 实现:与 codex-prompt-detector 同构(环形缓冲区 + 防抖),命中
|
|
10
|
+
* extractPermissionPrompt 的锚点 + 至少 2 个数字选项 + Claude 独有页脚时上报。
|
|
11
|
+
*
|
|
12
|
+
* 误触保护:
|
|
13
|
+
* - 同一段文本只 emit 一次(lastEmittedText 去重)
|
|
14
|
+
* - 选项数 < 2 不算 Claude 标准 1/2/3 权限框,跳过(光锚点会被 assistant 自述误触)
|
|
15
|
+
* - 必须命中 CLAUDE_PERMISSION_FOOTER("Esc to cancel · Tab to amend" 或同源
|
|
16
|
+
* "Tab to select")——AI 自由回复里可能恰好出现"Do you want to" + 数字列表,
|
|
17
|
+
* 但绝不会带这些 TUI 控件提示。这条页脚是把"真权限框"跟"普通 markdown 列表"
|
|
18
|
+
* 区分开的唯一可靠信号
|
|
19
|
+
* - 调用方(server.js / openclaw-hook 走 cooldown)负责跟真 Notification hook 的去重
|
|
20
|
+
*/
|
|
21
|
+
import { extractPermissionPrompt } from './permission-prompt.js'
|
|
22
|
+
|
|
23
|
+
const DEFAULT_DEBOUNCE_MS = 1500
|
|
24
|
+
const RING_MAX = 32
|
|
25
|
+
|
|
26
|
+
// Claude TUI 真权限框/选择器底部固定 footer(cleanPtyTail 不会过滤掉这行)。
|
|
27
|
+
// "Esc to cancel"(permission prompt)、"Tab to amend"(permission prompt)、
|
|
28
|
+
// "Tab to select"(slash command picker / model picker)。
|
|
29
|
+
// AI 自由回复里出现这种字面文本的概率近乎 0。
|
|
30
|
+
const CLAUDE_PERMISSION_FOOTER = /Esc\s+to\s+cancel|Tab\s+to\s+amend|Tab\s+to\s+select/i
|
|
31
|
+
|
|
32
|
+
export function createClaudePromptDetector({ pty, onMatch, debounceMs = DEFAULT_DEBOUNCE_MS } = {}) {
|
|
33
|
+
if (!pty || !onMatch) throw new Error('pty, onMatch required')
|
|
34
|
+
const ring = []
|
|
35
|
+
let timer = null
|
|
36
|
+
let stopped = false
|
|
37
|
+
let lastEmittedText = null
|
|
38
|
+
|
|
39
|
+
function onData(chunk) {
|
|
40
|
+
if (stopped) return
|
|
41
|
+
ring.push({ ts: Date.now(), text: String(chunk) })
|
|
42
|
+
while (ring.length > RING_MAX) ring.shift()
|
|
43
|
+
if (timer) clearTimeout(timer)
|
|
44
|
+
timer = setTimeout(maybeMatch, debounceMs)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function maybeMatch() {
|
|
48
|
+
timer = null
|
|
49
|
+
if (stopped) return
|
|
50
|
+
const tail = ring.map(c => c.text).join('')
|
|
51
|
+
const { text, options } = extractPermissionPrompt(tail)
|
|
52
|
+
if (!text || options.length < 2) return
|
|
53
|
+
// 防 AI 自由回复假阳性:必须带真权限框的 footer,否则不动手
|
|
54
|
+
if (!CLAUDE_PERMISSION_FOOTER.test(text)) return
|
|
55
|
+
// 用尾部 200 字符做去重 key:连续 TUI redraw 会让 extractor 的 window 上下文不一样,
|
|
56
|
+
// 但 prompt 末尾("Do you want to proceed?\n1. Yes\n...")总是稳定的——用它判同。
|
|
57
|
+
const sig = text.slice(-200)
|
|
58
|
+
if (sig === lastEmittedText) return
|
|
59
|
+
lastEmittedText = sig
|
|
60
|
+
onMatch({ promptText: text, options })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 一轮结束(Stop hook / jsonl turn-done)后调,让下一轮新的 prompt 不被 lastEmittedText 卡掉
|
|
64
|
+
function reset() {
|
|
65
|
+
lastEmittedText = null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function start() { pty.onData(onData) }
|
|
69
|
+
function stop() { stopped = true; if (timer) { clearTimeout(timer); timer = null } }
|
|
70
|
+
|
|
71
|
+
return { start, stop, reset, _maybeMatch: maybeMatch, _ring: ring }
|
|
72
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -667,7 +667,7 @@ export async function runStart(cmdOpts = {}) {
|
|
|
667
667
|
const program = new Command()
|
|
668
668
|
program
|
|
669
669
|
.name('agentquad')
|
|
670
|
-
.description('Local
|
|
670
|
+
.description('Local status-board todo CLI with embedded Claude Code / Codex / Cursor terminal')
|
|
671
671
|
.version(loadPkgVersion())
|
|
672
672
|
|
|
673
673
|
program.command('start')
|
|
@@ -59,7 +59,7 @@ function parseHookVersion(content) {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
function buildHookEntry(event, hookScriptPath) {
|
|
62
|
-
const eventLower = event === 'UserPromptSubmit' ? '
|
|
62
|
+
const eventLower = event === 'UserPromptSubmit' ? 'user-prompt-submit' : 'stop'
|
|
63
63
|
return {
|
|
64
64
|
matcher: '',
|
|
65
65
|
hooks: [
|
|
@@ -1,7 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex CLI stdout 提示词检测器:把 Codex 在 PTY 里弹出来的"是否允许运行命令?"
|
|
3
|
+
* 兜底转成 markPendingConfirm + IM 卡片,跟 Claude detector 一对的。
|
|
4
|
+
*
|
|
5
|
+
* 新版 Codex (gpt-5-codex / codex-cli 0.2+) 的 prompt 形态:
|
|
6
|
+
* Would you like to run the following command?
|
|
7
|
+
* Reason: ...
|
|
8
|
+
*
|
|
9
|
+
* $ <command>
|
|
10
|
+
*
|
|
11
|
+
* 1. Yes, proceed (y)
|
|
12
|
+
* 2. Yes, and don't ask again for commands that start with `<cmd>` (p)
|
|
13
|
+
* 3. No, and tell Codex what to do differently (esc)
|
|
14
|
+
*
|
|
15
|
+
* 旧 Codex 的 `[y/N]` / `apply patch?` 单行问句已经被淘汰;老的 PATTERNS 一个都不命中
|
|
16
|
+
* 真权限框,结果是 PTY 卡住但 IM 收不到卡片。
|
|
17
|
+
*
|
|
18
|
+
* 新检测规则(与 claude-prompt-detector 同思路):
|
|
19
|
+
* 1) anchor: "Would you like to" + "?" 出现在尾部
|
|
20
|
+
* 2) 末尾 5 行内有 `(esc)` 选项(Codex 真权限框第 3 个选项一定是 "...(esc)")
|
|
21
|
+
* 3) anchor → 末尾之间有 ≥2 个 "N. xxx (y/p/esc/n)" 形式的数字选项
|
|
22
|
+
*
|
|
23
|
+
* 三个信号一起到位才认;AI 自由回复里偶尔出现 "Would you like to" + 数字列表
|
|
24
|
+
* 不会有 `(esc)` 那种 hotkey 后缀,照常不命中。
|
|
25
|
+
*
|
|
26
|
+
* 兼容老 Codex:保留旧的 single-line `[y/N]` / `apply patch?` 形式作为 fallback。
|
|
27
|
+
*/
|
|
1
28
|
const DEFAULT_DEBOUNCE_MS = 1500
|
|
2
29
|
const RING_MAX = 32
|
|
3
30
|
|
|
4
|
-
|
|
31
|
+
// 老 Codex 的单行问句(保留兜底,1.x 老版本还能用)
|
|
32
|
+
const LEGACY_SINGLE_LINE_PATTERNS = [
|
|
5
33
|
/(approve|allow|continue|proceed)\??\s*\(\s*y\/n\s*\)\s*$/i,
|
|
6
34
|
/\?\s*\[\s*y\/N\s*\]\s*$/i,
|
|
7
35
|
/\?\s*\[\s*Y\/n\s*\]\s*$/i,
|
|
@@ -10,15 +38,56 @@ const PATTERNS = [
|
|
|
10
38
|
/apply patch\?\s*\[[^\]]*\]\s*$/i,
|
|
11
39
|
]
|
|
12
40
|
|
|
41
|
+
// 新版 Codex 多行权限框的三个识别信号
|
|
42
|
+
const CODEX_NEW_ANCHOR = /Would\s+you\s+like\s+to\s+(?:run|apply|approve|continue)/i
|
|
43
|
+
const CODEX_NEW_OPTION = /^\s*([1-9])\.\s+(\S.{0,120}?)\s+\(\s*(y|p|n|esc|N|Y)\s*\)\s*$/i
|
|
44
|
+
const CODEX_NEW_ESC_OPTION = /\(\s*esc\s*\)\s*$/i
|
|
45
|
+
|
|
13
46
|
function stripAnsi(s) {
|
|
14
47
|
return String(s || '').replace(/\x1b\[[0-9;?]*[A-Za-z~]/g, '').replace(/\x1b\][^\x07]*\x07/g, '')
|
|
15
48
|
}
|
|
16
49
|
|
|
50
|
+
/**
|
|
51
|
+
* 新版 Codex prompt 严格匹配。
|
|
52
|
+
* - 末尾 5 行里有 `(esc)` 结尾的选项行
|
|
53
|
+
* - 它上面 15 行内有 anchor ("Would you like to ...")
|
|
54
|
+
* - anchor → esc-option 之间 ≥2 个带 hotkey 后缀的数字选项
|
|
55
|
+
*/
|
|
56
|
+
function matchCodexNewPrompt(cleaned) {
|
|
57
|
+
const lines = cleaned.split('\n')
|
|
58
|
+
let escIdx = -1
|
|
59
|
+
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 5); i--) {
|
|
60
|
+
if (CODEX_NEW_ESC_OPTION.test(lines[i])) { escIdx = i; break }
|
|
61
|
+
}
|
|
62
|
+
if (escIdx < 0) return null
|
|
63
|
+
const start = Math.max(0, escIdx - 15)
|
|
64
|
+
let anchorIdx = -1
|
|
65
|
+
for (let i = escIdx - 1; i >= start; i--) {
|
|
66
|
+
if (CODEX_NEW_ANCHOR.test(lines[i])) { anchorIdx = i; break }
|
|
67
|
+
}
|
|
68
|
+
if (anchorIdx < 0) return null
|
|
69
|
+
// 收集 anchor → esc 之间的数字选项行(用作 dedup signature 的稳定核心)
|
|
70
|
+
const optionLines = []
|
|
71
|
+
for (let i = anchorIdx + 1; i <= escIdx; i++) {
|
|
72
|
+
if (CODEX_NEW_OPTION.test(lines[i])) optionLines.push(lines[i].trim())
|
|
73
|
+
}
|
|
74
|
+
if (optionLines.length < 2) return null
|
|
75
|
+
return {
|
|
76
|
+
startIdx: anchorIdx,
|
|
77
|
+
endIdx: escIdx,
|
|
78
|
+
text: lines.slice(anchorIdx, escIdx + 1).join('\n'),
|
|
79
|
+
// signature:所有选项行(含 hotkey 后缀)拼起来——同一个 prompt 复用同一份
|
|
80
|
+
// 选项;连续 TUI redraw / ring buffer 重复都映射到同一 sig,dedup 稳。
|
|
81
|
+
sig: optionLines.join('|'),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
17
85
|
export function createCodexPromptDetector({ pty, onMatch, debounceMs = DEFAULT_DEBOUNCE_MS, emitter = null } = {}) {
|
|
18
86
|
if (!pty || !onMatch) throw new Error('pty, onMatch required')
|
|
19
87
|
const ring = []
|
|
20
88
|
let timer = null
|
|
21
89
|
let stopped = false
|
|
90
|
+
let lastEmittedSig = null
|
|
22
91
|
|
|
23
92
|
function onData(chunk) {
|
|
24
93
|
if (stopped) return
|
|
@@ -28,26 +97,48 @@ export function createCodexPromptDetector({ pty, onMatch, debounceMs = DEFAULT_D
|
|
|
28
97
|
timer = setTimeout(maybeMatch, debounceMs)
|
|
29
98
|
}
|
|
30
99
|
|
|
100
|
+
function ifNotSelfQuotedByAi(text) {
|
|
101
|
+
const resolvedEmitter = typeof emitter === 'function' ? emitter() : emitter
|
|
102
|
+
if (!resolvedEmitter?.getLatestAssistantContent) return text
|
|
103
|
+
const ai = resolvedEmitter.getLatestAssistantContent() || ''
|
|
104
|
+
const trimmed = text.slice(-200).trim()
|
|
105
|
+
if (trimmed && (ai.includes(trimmed) || ai.endsWith(trimmed))) return null
|
|
106
|
+
return text
|
|
107
|
+
}
|
|
108
|
+
|
|
31
109
|
function maybeMatch() {
|
|
32
|
-
|
|
110
|
+
timer = null
|
|
111
|
+
if (stopped) return
|
|
112
|
+
const tail = ring.map(c => c.text).join('')
|
|
113
|
+
|
|
114
|
+
// 1) 新版多行权限框优先(gpt-5-codex / codex-cli 0.2+)
|
|
115
|
+
const m = matchCodexNewPrompt(tail)
|
|
116
|
+
if (m) {
|
|
117
|
+
const checked = ifNotSelfQuotedByAi(m.text)
|
|
118
|
+
if (!checked) return
|
|
119
|
+
if (m.sig === lastEmittedSig) return
|
|
120
|
+
lastEmittedSig = m.sig
|
|
121
|
+
onMatch({ promptText: m.text, matchedPattern: 'codex_new_multiline' })
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 2) 老版单行问句兜底
|
|
33
126
|
let matchedPattern = null
|
|
34
|
-
for (const re of
|
|
127
|
+
for (const re of LEGACY_SINGLE_LINE_PATTERNS) {
|
|
35
128
|
if (re.test(tail)) { matchedPattern = re.source; break }
|
|
36
129
|
}
|
|
37
130
|
if (!matchedPattern) return
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return // AI self-quoted prompt; not a real Codex permission ask
|
|
44
|
-
}
|
|
45
|
-
}
|
|
131
|
+
const checked = ifNotSelfQuotedByAi(tail)
|
|
132
|
+
if (!checked) return
|
|
133
|
+
const sig = tail.slice(-200)
|
|
134
|
+
if (sig === lastEmittedSig) return
|
|
135
|
+
lastEmittedSig = sig
|
|
46
136
|
onMatch({ promptText: tail.slice(-200), matchedPattern })
|
|
47
137
|
}
|
|
48
138
|
|
|
139
|
+
function reset() { lastEmittedSig = null }
|
|
49
140
|
function start() { pty.onData(onData) }
|
|
50
|
-
function stop() { stopped = true; if (timer) clearTimeout(timer) }
|
|
141
|
+
function stop() { stopped = true; if (timer) { clearTimeout(timer); timer = null } }
|
|
51
142
|
|
|
52
|
-
return { start, stop }
|
|
143
|
+
return { start, stop, reset }
|
|
53
144
|
}
|
package/src/config.js
CHANGED
|
@@ -126,6 +126,7 @@ const DEFAULT_LARK_CONFIG = {
|
|
|
126
126
|
requireThreadGroup: true,
|
|
127
127
|
eventSubscribeEnabled: true,
|
|
128
128
|
autoCreateTopic: true,
|
|
129
|
+
autoCreateTodo: true,
|
|
129
130
|
defaultPermissionMode: "bypass",
|
|
130
131
|
notificationCooldownMs: 600_000,
|
|
131
132
|
};
|
|
@@ -313,6 +314,9 @@ function defaultConfig() {
|
|
|
313
314
|
// 新建待办时是否默认勾选「创建后自动启动 AI 终端」。
|
|
314
315
|
// Drawer 上的开关仍可单次覆盖;这里只是默认值。
|
|
315
316
|
defaultAutoStartAi: false,
|
|
317
|
+
// 新建待办时默认套用的 Prompt 模板 ID 列表(多选)。空数组 = 不预选。
|
|
318
|
+
// 用户在 SettingsDrawer 里维护;创建任务时 TodoManage 读取此值作为表单初值。
|
|
319
|
+
defaultAppliedTemplateIds: [],
|
|
316
320
|
// 自动启动 / dispatch / 顶栏 ⌘K 等场景下使用的默认 AI 工具。
|
|
317
321
|
defaultAiTool: "claude",
|
|
318
322
|
tools: resolveToolsConfig(),
|
|
@@ -381,6 +385,9 @@ export function normalizeConfig(cfg = {}) {
|
|
|
381
385
|
...cfgRest,
|
|
382
386
|
defaultPermissionMode: normalizePermissionMode(cfg.defaultPermissionMode, "default"),
|
|
383
387
|
defaultAutoStartAi: !!cfg.defaultAutoStartAi,
|
|
388
|
+
defaultAppliedTemplateIds: Array.isArray(cfg.defaultAppliedTemplateIds)
|
|
389
|
+
? cfg.defaultAppliedTemplateIds.map((x) => String(x).trim()).filter(Boolean)
|
|
390
|
+
: [],
|
|
384
391
|
defaultAiTool,
|
|
385
392
|
tools: {
|
|
386
393
|
...mergedTools,
|
|
@@ -470,15 +477,38 @@ function backupCorruptConfig(file) {
|
|
|
470
477
|
}
|
|
471
478
|
}
|
|
472
479
|
|
|
480
|
+
// Atomic write: write to a sibling tmp file then rename over the target.
|
|
481
|
+
// POSIX rename is atomic on the same filesystem — readers always see either
|
|
482
|
+
// the old or the new file, never a half-written one. Eliminates the
|
|
483
|
+
// truncated-write → JSON.parse-fail → reset-to-defaults loop.
|
|
484
|
+
function atomicWriteFile(file, contents) {
|
|
485
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
486
|
+
writeFileSync(tmp, contents);
|
|
487
|
+
renameSync(tmp, file);
|
|
488
|
+
}
|
|
489
|
+
|
|
473
490
|
function tryWriteConfig(file, cfg) {
|
|
474
491
|
try {
|
|
475
|
-
|
|
492
|
+
atomicWriteFile(file, JSON.stringify(cfg, null, 2));
|
|
476
493
|
return true;
|
|
477
494
|
} catch {
|
|
478
495
|
return false;
|
|
479
496
|
}
|
|
480
497
|
}
|
|
481
498
|
|
|
499
|
+
// In-process write serialization. Concurrent PUT /api/config calls (and any
|
|
500
|
+
// other server-side read-modify-write sequence) used to interleave their
|
|
501
|
+
// load/save and lose each other's changes. withConfigLock chains operations
|
|
502
|
+
// onto a single Promise queue so reads + writes within `fn` are atomic
|
|
503
|
+
// against other queued operations. Out-of-process writers (CLI, hooks) are
|
|
504
|
+
// NOT protected — see design doc R2/F4 deferred scope.
|
|
505
|
+
let configWriteQueue = Promise.resolve();
|
|
506
|
+
export function withConfigLock(fn) {
|
|
507
|
+
const run = configWriteQueue.then(() => fn(), () => fn());
|
|
508
|
+
configWriteQueue = run.catch(() => {});
|
|
509
|
+
return run;
|
|
510
|
+
}
|
|
511
|
+
|
|
482
512
|
export function loadConfig({ rootDir = DEFAULT_ROOT_DIR } = {}) {
|
|
483
513
|
ensureRoot(rootDir);
|
|
484
514
|
const file = join(rootDir, "config.json");
|
|
@@ -488,9 +518,7 @@ export function loadConfig({ rootDir = DEFAULT_ROOT_DIR } = {}) {
|
|
|
488
518
|
return cfg;
|
|
489
519
|
}
|
|
490
520
|
try {
|
|
491
|
-
|
|
492
|
-
tryWriteConfig(file, cfg);
|
|
493
|
-
return cfg;
|
|
521
|
+
return normalizeConfig(JSON.parse(readFileSync(file, "utf8")));
|
|
494
522
|
} catch {
|
|
495
523
|
backupCorruptConfig(file);
|
|
496
524
|
const cfg = normalizeConfig();
|
|
@@ -501,7 +529,7 @@ export function loadConfig({ rootDir = DEFAULT_ROOT_DIR } = {}) {
|
|
|
501
529
|
|
|
502
530
|
export function saveConfig(cfg, { rootDir = DEFAULT_ROOT_DIR } = {}) {
|
|
503
531
|
ensureRoot(rootDir);
|
|
504
|
-
|
|
532
|
+
atomicWriteFile(
|
|
505
533
|
join(rootDir, "config.json"),
|
|
506
534
|
JSON.stringify(normalizeConfig(cfg), null, 2),
|
|
507
535
|
);
|
package/src/db.js
CHANGED
|
@@ -1110,37 +1110,82 @@ export function openDb(file = ':memory:') {
|
|
|
1110
1110
|
return r.changes
|
|
1111
1111
|
}
|
|
1112
1112
|
|
|
1113
|
-
|
|
1114
|
-
|
|
1113
|
+
// Canonical list of builtin templates. On startup we ensure each one exists
|
|
1114
|
+
// in the DB (matched by exact name + builtin=1). Missing ones get inserted;
|
|
1115
|
+
// user-edited copies are left alone. Adding a new entry here will surface
|
|
1116
|
+
// for existing users on next restart.
|
|
1117
|
+
const BUILTIN_TEMPLATE_SEEDS = [
|
|
1118
|
+
{
|
|
1119
|
+
name: '方案顾问(脑爆)',
|
|
1120
|
+
description: '先脑爆方向,不急着动手',
|
|
1121
|
+
content: '请先不要直接动手实现。先针对下面的任务 brainstorm:\n- 列出 2-3 种可选方案,说明优缺点\n- 指出风险点与需要用户拍板的关键决策\n- 明确验收标准\n\n在我确认方案后再进入实现。',
|
|
1122
|
+
},
|
|
1123
|
+
{
|
|
1124
|
+
name: '全自动工程师(自动驾驶)',
|
|
1125
|
+
description: '内心脑爆 → 自选最优 → 跑完 → 最后报告',
|
|
1126
|
+
content: '按"自动驾驶"模式处理下面的任务,不要停下来问我。\n\n1. 先在心里 brainstorm 2-3 种实现思路,挑出最合理的一种,但不需要列出来征求我的同意。\n2. 直接执行:理解 → 实现 → 自测(跑相关测试 / 编译 / lint) → 提交。\n3. 遇到不可避免必须我决策的歧义点(例:要不要删数据、要不要对外发版),才停下来问;普通选型不要问。\n4. 跑完后输出一份三段式报告:\n - 变更摘要:改了什么、为什么这么改\n - 验证结果:跑了哪些自测、是否通过、有没有遗留\n - 仍需我确认的事项:列出来 / 没有就写"无"\n5. 我考虑过哪些方案、为什么选这个,请在"变更摘要"里用一两句说明。',
|
|
1127
|
+
},
|
|
1128
|
+
{
|
|
1129
|
+
name: '守稳派开发(稳定优先)',
|
|
1130
|
+
description: '改动面最小、不引新依赖、不动公共 API',
|
|
1131
|
+
content: '本任务执行时请优先保证"稳定",含义:\n- 改动面尽量小,不顺手重构、不删看似无用的代码\n- 不引入新依赖、不升级现有依赖\n- 不改公共 API / 接口签名 / 数据库 schema(除非任务本身就是改这个)\n- 优先补测试覆盖现有行为;改动 hot path 时尽量保留旧路径作为兜底\n- 选型偏保守:用已经在项目里用过的库 / 写法\n如果"稳定"和任务目标冲突,告诉我,让我决定。',
|
|
1132
|
+
},
|
|
1133
|
+
{
|
|
1134
|
+
name: '前瞻派架构师(未来发展优先)',
|
|
1135
|
+
description: '允许重构 / 引入抽象 / 留扩展点',
|
|
1136
|
+
content: '本任务执行时请优先考虑"长期可扩展性",含义:\n- 允许并鼓励顺手重构邻近代码,让结构更清晰\n- 可以引入新抽象 / 接口,为可预见的下一步需求留扩展点\n- 公共 API / 类型 / 数据结构允许调整,但要在报告里列出影响面(调用方 / 测试 / 文档)\n- 选型可以挑当前社区主流而非项目已有的旧写法,但要说明替换原因\n- 不要为完全假想的需求过度设计 —— 只服务"已经看到苗头"的下一步\n完成后在报告里说明:哪些是为长期留的扩展点,分别服务什么场景。',
|
|
1137
|
+
},
|
|
1138
|
+
{
|
|
1139
|
+
name: 'Bug 侦探',
|
|
1140
|
+
description: '复现 → 定位 → 最小用例 → 修复 → 回归',
|
|
1141
|
+
content: '按 bug 修复流程处理下面的问题:\n1. 先复现(给出复现步骤和实际 vs 预期)\n2. 定位根因(不要过早修改代码)\n3. 写一个能复现该 bug 的最小用例(如果有测试框架)\n4. 修复根因,不是修现象\n5. 回归:跑相关测试;考虑同类 bug 是否还存在',
|
|
1142
|
+
},
|
|
1143
|
+
{
|
|
1144
|
+
name: '重构师',
|
|
1145
|
+
description: '先读懂 → 列出影响面 → 小步重构',
|
|
1146
|
+
content: '按照小步重构原则处理下面的任务:\n1. 先通读相关代码,复述你的理解\n2. 列出此次重构的影响面(调用方 / 测试 / 类型)\n3. 每一步只改一件事,保持可运行\n4. 每步后跑一次测试(如果有)\n5. 不要顺手加功能、不要引入新抽象,除非当前任务要求',
|
|
1147
|
+
},
|
|
1148
|
+
{
|
|
1149
|
+
name: '测试工程师',
|
|
1150
|
+
description: 'TDD:红 → 绿 → 重构',
|
|
1151
|
+
content: '用 TDD 的方式处理下面的任务:\n1. 先列出测试矩阵(输入 × 场景)\n2. 先写一个最简失败用例(红)\n3. 用最小改动让它通过(绿)\n4. 重构(保持绿)\n5. 重复 2-4 直到覆盖矩阵\n不 mock 真实依赖(除非跨网络/支付等)。',
|
|
1152
|
+
},
|
|
1153
|
+
{
|
|
1154
|
+
name: '代码评审员',
|
|
1155
|
+
description: '只评审,不改代码',
|
|
1156
|
+
content: '请只做代码评审,不要修改代码。按下面的维度给出具体反馈:\n- 可读性:命名、结构、注释\n- 正确性:边界、错误处理、并发\n- 安全性:注入、鉴权、敏感数据\n- 性能:明显的 N+1 / 无谓复制\n- 简洁性:是否有过度设计 / 可删除的冗余\n每条反馈给出文件:行号 + 建议。',
|
|
1157
|
+
},
|
|
1158
|
+
]
|
|
1159
|
+
// 把老用户库里旧名字的 builtin 行就地改名成新名字,避免新种子重复插入。
|
|
1160
|
+
// 仅在新名尚未占位时改名;用户复制版(builtin=0)不动。
|
|
1161
|
+
const BUILTIN_RENAMES = [
|
|
1162
|
+
['Brainstorm(脑爆)', '方案顾问(脑爆)'],
|
|
1163
|
+
['自动驾驶(Autopilot)', '全自动工程师(自动驾驶)'],
|
|
1164
|
+
['稳定优先(Stability First)', '守稳派开发(稳定优先)'],
|
|
1165
|
+
['未来发展优先(Future First)', '前瞻派架构师(未来发展优先)'],
|
|
1166
|
+
['Bug 修复', 'Bug 侦探'],
|
|
1167
|
+
['重构', '重构师'],
|
|
1168
|
+
['写测试', '测试工程师'],
|
|
1169
|
+
['代码评审', '代码评审员'],
|
|
1170
|
+
]
|
|
1171
|
+
const findBuiltinByName = db.prepare(
|
|
1172
|
+
`SELECT id FROM prompt_templates WHERE builtin = 1 AND name = ? LIMIT 1`,
|
|
1173
|
+
)
|
|
1174
|
+
const renameBuiltin = db.prepare(
|
|
1175
|
+
`UPDATE prompt_templates SET name = ?, updated_at = ? WHERE builtin = 1 AND name = ?`,
|
|
1176
|
+
)
|
|
1177
|
+
function migrateBuiltinNames() {
|
|
1178
|
+
const now = Date.now()
|
|
1179
|
+
for (const [oldName, newName] of BUILTIN_RENAMES) {
|
|
1180
|
+
if (findBuiltinByName.get(newName)) continue // 新名已存在则跳过
|
|
1181
|
+
if (!findBuiltinByName.get(oldName)) continue // 旧名也不在就没事
|
|
1182
|
+
renameBuiltin.run(newName, now, oldName)
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
function ensureBuiltinTemplates() {
|
|
1115
1186
|
const now = Date.now()
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
name: 'Brainstorm(脑爆)',
|
|
1119
|
-
description: '先脑爆方向,不急着动手',
|
|
1120
|
-
content: '请先不要直接动手实现。先针对下面的任务 brainstorm:\n- 列出 2-3 种可选方案,说明优缺点\n- 指出风险点与需要用户拍板的关键决策\n- 明确验收标准\n\n在我确认方案后再进入实现。',
|
|
1121
|
-
},
|
|
1122
|
-
{
|
|
1123
|
-
name: 'Bug 修复',
|
|
1124
|
-
description: '复现 → 定位 → 最小用例 → 修复 → 回归',
|
|
1125
|
-
content: '按 bug 修复流程处理下面的问题:\n1. 先复现(给出复现步骤和实际 vs 预期)\n2. 定位根因(不要过早修改代码)\n3. 写一个能复现该 bug 的最小用例(如果有测试框架)\n4. 修复根因,不是修现象\n5. 回归:跑相关测试;考虑同类 bug 是否还存在',
|
|
1126
|
-
},
|
|
1127
|
-
{
|
|
1128
|
-
name: '重构',
|
|
1129
|
-
description: '先读懂 → 列出影响面 → 小步重构',
|
|
1130
|
-
content: '按照小步重构原则处理下面的任务:\n1. 先通读相关代码,复述你的理解\n2. 列出此次重构的影响面(调用方 / 测试 / 类型)\n3. 每一步只改一件事,保持可运行\n4. 每步后跑一次测试(如果有)\n5. 不要顺手加功能、不要引入新抽象,除非当前任务要求',
|
|
1131
|
-
},
|
|
1132
|
-
{
|
|
1133
|
-
name: '写测试',
|
|
1134
|
-
description: 'TDD:红 → 绿 → 重构',
|
|
1135
|
-
content: '用 TDD 的方式处理下面的任务:\n1. 先列出测试矩阵(输入 × 场景)\n2. 先写一个最简失败用例(红)\n3. 用最小改动让它通过(绿)\n4. 重构(保持绿)\n5. 重复 2-4 直到覆盖矩阵\n不 mock 真实依赖(除非跨网络/支付等)。',
|
|
1136
|
-
},
|
|
1137
|
-
{
|
|
1138
|
-
name: '代码评审',
|
|
1139
|
-
description: '只评审,不改代码',
|
|
1140
|
-
content: '请只做代码评审,不要修改代码。按下面的维度给出具体反馈:\n- 可读性:命名、结构、注释\n- 正确性:边界、错误处理、并发\n- 安全性:注入、鉴权、敏感数据\n- 性能:明显的 N+1 / 无谓复制\n- 简洁性:是否有过度设计 / 可删除的冗余\n每条反馈给出文件:行号 + 建议。',
|
|
1141
|
-
},
|
|
1142
|
-
]
|
|
1143
|
-
seeds.forEach((s, i) => {
|
|
1187
|
+
BUILTIN_TEMPLATE_SEEDS.forEach((s, i) => {
|
|
1188
|
+
if (findBuiltinByName.get(s.name)) return
|
|
1144
1189
|
ptStmts.insert.run({
|
|
1145
1190
|
id: randomUUID(),
|
|
1146
1191
|
name: s.name,
|
|
@@ -1153,7 +1198,8 @@ export function openDb(file = ':memory:') {
|
|
|
1153
1198
|
})
|
|
1154
1199
|
})
|
|
1155
1200
|
}
|
|
1156
|
-
|
|
1201
|
+
migrateBuiltinNames()
|
|
1202
|
+
ensureBuiltinTemplates()
|
|
1157
1203
|
|
|
1158
1204
|
const wikiStmts = {
|
|
1159
1205
|
insertRun: db.prepare(`
|
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
import { parseTranscriptFile } from '../transcripts/scanner.js'
|
|
2
2
|
import { estimateCost, DEFAULT_PRICING } from '../pricing.js'
|
|
3
3
|
|
|
4
|
-
const QUADRANT_LABEL = {
|
|
5
|
-
1: 'Q1 紧急且重要',
|
|
6
|
-
2: 'Q2 重要不紧急',
|
|
7
|
-
3: 'Q3 紧急不重要',
|
|
8
|
-
4: 'Q4 不紧急不重要',
|
|
9
|
-
}
|
|
10
|
-
|
|
11
4
|
const STATUS_LABEL = {
|
|
12
5
|
todo: '待办',
|
|
13
6
|
ai_pending: 'AI 待确认',
|
|
@@ -30,7 +23,7 @@ export async function buildTodoExport(db, todoId, { turns = 'summary', turnLimit
|
|
|
30
23
|
if (!todo) return null
|
|
31
24
|
const comments = db.listComments(todoId)
|
|
32
25
|
const aiSessions = Array.isArray(todo.aiSessions) ? todo.aiSessions : []
|
|
33
|
-
const subtodos = todo.parentId ? [] : db.listTodos(
|
|
26
|
+
const subtodos = todo.parentId ? [] : db.listTodos().filter(item => item.parentId === todo.id)
|
|
34
27
|
|
|
35
28
|
const sessionRows = []
|
|
36
29
|
for (const s of aiSessions) {
|
|
@@ -122,7 +115,6 @@ export function renderTodoMarkdown(report) {
|
|
|
122
115
|
lines.push(`# ${todo.title}`)
|
|
123
116
|
lines.push('')
|
|
124
117
|
const meta = [
|
|
125
|
-
`**象限**:${QUADRANT_LABEL[todo.quadrant] || todo.quadrant}`,
|
|
126
118
|
`**状态**:${STATUS_LABEL[todo.status] || todo.status}`,
|
|
127
119
|
]
|
|
128
120
|
if (todo.dueDate) meta.push(`**截止**:${fmtDateTime(todo.dueDate)}`)
|
package/src/lark-bot.js
CHANGED
|
@@ -126,6 +126,38 @@ export function normalizeEvent(raw = {}) {
|
|
|
126
126
|
* action.value 是按钮的 value(构卡片时塞的 JSON),约定字段:
|
|
127
127
|
* { callback_data: 'qt:perm:abcd:allow' } // 跟 telegram 的 callback_data 同字符串格式
|
|
128
128
|
*/
|
|
129
|
+
/**
|
|
130
|
+
* 把 wizard 风格的 callback 返回值({toast: '字符串', chosenLabel, action, editOriginal})
|
|
131
|
+
* 适配成 Lark 卡片回调期望的 schema({toast: {type, content}})。
|
|
132
|
+
*
|
|
133
|
+
* 背景:Lark SDK 的 WSClient.handleEventData 把 handler 的返回值 base64 编码后回写到
|
|
134
|
+
* WebSocket frame;Lark 服务端反序列化时按文档校验 toast 必须是 `{type, content}` 对象,
|
|
135
|
+
* wizard 直传 string 会失败 → 飞书 UI 弹「出错了,请稍后重试 code: 200340」。
|
|
136
|
+
* 文档:https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/feishu-cards/card-callback/card-callback-communication
|
|
137
|
+
*
|
|
138
|
+
* 已经是 Lark 格式的({toast: {type, content}})直接放过;undefined → 不显 toast。
|
|
139
|
+
*/
|
|
140
|
+
export function adaptWizardResponseToLark(r) {
|
|
141
|
+
if (!r || typeof r !== 'object') return undefined
|
|
142
|
+
|
|
143
|
+
// 已是 Lark 形态({toast: {type?, content}}) → 透传,保留 caller 想自己控的 type
|
|
144
|
+
if (r.toast && typeof r.toast === 'object' && (typeof r.toast.content === 'string' || r.toast.i18n)) {
|
|
145
|
+
const out = { toast: { ...r.toast } }
|
|
146
|
+
if (!out.toast.type) out.toast.type = 'info'
|
|
147
|
+
return out
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// wizard 形态(toast: string)→ 按 action 字段推断 type
|
|
151
|
+
const content = typeof r.toast === 'string' ? r.toast.trim() : ''
|
|
152
|
+
if (!content) return undefined // 没有 toast 文本就别 emit,Lark UI 默认无提示
|
|
153
|
+
let type = 'info'
|
|
154
|
+
const action = String(r.action || '')
|
|
155
|
+
if (action.includes('allow') || action.includes('sent')) type = 'success'
|
|
156
|
+
else if (action.includes('stale') || action.includes('invalid')) type = 'warning'
|
|
157
|
+
else if (action.includes('failed') || action.includes('error') || action.includes('unavailable')) type = 'error'
|
|
158
|
+
return { toast: { type, content } }
|
|
159
|
+
}
|
|
160
|
+
|
|
129
161
|
export function normalizeCardAction(raw = {}) {
|
|
130
162
|
const event = raw.event || raw
|
|
131
163
|
const action = event.action || {}
|
|
@@ -418,20 +450,24 @@ export function createLarkBot({
|
|
|
418
450
|
|
|
419
451
|
async function handleCardAction(raw) {
|
|
420
452
|
const ev = normalizeCardAction(raw)
|
|
453
|
+
logger.info?.(`[lark-bot] card action received: callbackData=${ev.callbackData || 'null'} chatId=${ev.chatId || 'null'} from=${ev.fromUserId || 'null'}`)
|
|
421
454
|
if (!ev.chatId || !ev.callbackData) {
|
|
422
|
-
|
|
455
|
+
const r = adaptWizardResponseToLark({ toast: '⚠️ 无效的卡片回传', action: 'invalid' })
|
|
456
|
+
logger.info?.(`[lark-bot] card action → Lark resp: ${JSON.stringify(r)}`)
|
|
457
|
+
return r
|
|
423
458
|
}
|
|
424
459
|
const configuredChatId = getConfig()?.lark?.chatId
|
|
425
460
|
if (configuredChatId && ev.chatId !== String(configuredChatId)) {
|
|
426
461
|
logger.warn?.(`[lark-bot] ignored card_action from other chat: ${ev.chatId}`)
|
|
427
|
-
|
|
462
|
+
// 跨群点的卡片:返回最小合法响应(带个 info toast),避免 Lark UI 弹 200340 generic 错误
|
|
463
|
+
return { toast: { type: 'info', content: '已忽略(非本群)' } }
|
|
428
464
|
}
|
|
429
465
|
if (typeof wizard.handleCallback !== 'function') {
|
|
430
466
|
logger.warn?.(`[lark-bot] wizard.handleCallback unavailable; dropping lark card action`)
|
|
431
|
-
return {
|
|
467
|
+
return adaptWizardResponseToLark({ toast: '⚠️ 服务未就绪', action: 'failed' })
|
|
432
468
|
}
|
|
433
469
|
try {
|
|
434
|
-
|
|
470
|
+
const wizardR = await wizard.handleCallback({
|
|
435
471
|
channel: 'lark',
|
|
436
472
|
chatId: ev.chatId,
|
|
437
473
|
threadId: ev.threadId,
|
|
@@ -439,9 +475,12 @@ export function createLarkBot({
|
|
|
439
475
|
callbackData: ev.callbackData,
|
|
440
476
|
fromUserId: ev.fromUserId,
|
|
441
477
|
})
|
|
478
|
+
const adapted = adaptWizardResponseToLark(wizardR)
|
|
479
|
+
logger.info?.(`[lark-bot] card action → wizard returned ${JSON.stringify(wizardR)} → Lark resp: ${JSON.stringify(adapted)}`)
|
|
480
|
+
return adapted
|
|
442
481
|
} catch (e) {
|
|
443
482
|
logger.warn?.(`[lark-bot] card action handler failed: ${e.message}`)
|
|
444
|
-
return {
|
|
483
|
+
return adaptWizardResponseToLark({ toast: `⚠️ 处理失败:${e.message}`, action: 'failed' })
|
|
445
484
|
}
|
|
446
485
|
}
|
|
447
486
|
|