agentquad 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +318 -0
  3. package/dist-web/assets/index-CMaXwixo.js +1234 -0
  4. package/dist-web/assets/index-DBHApzV1.css +32 -0
  5. package/dist-web/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
  6. package/dist-web/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
  7. package/dist-web/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
  8. package/dist-web/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
  9. package/dist-web/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
  10. package/dist-web/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
  11. package/dist-web/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
  12. package/dist-web/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
  13. package/dist-web/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
  14. package/dist-web/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
  15. package/dist-web/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
  16. package/dist-web/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
  17. package/dist-web/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
  18. package/dist-web/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
  19. package/dist-web/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
  20. package/dist-web/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
  21. package/dist-web/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
  22. package/dist-web/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
  23. package/dist-web/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
  24. package/dist-web/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
  25. package/dist-web/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
  26. package/dist-web/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
  27. package/dist-web/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
  28. package/dist-web/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
  29. package/dist-web/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
  30. package/dist-web/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
  31. package/dist-web/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
  32. package/dist-web/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
  33. package/dist-web/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
  34. package/dist-web/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
  35. package/dist-web/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
  36. package/dist-web/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
  37. package/dist-web/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  38. package/dist-web/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  39. package/dist-web/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  40. package/dist-web/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  41. package/dist-web/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  42. package/dist-web/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  43. package/dist-web/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
  44. package/dist-web/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  45. package/dist-web/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
  46. package/dist-web/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
  47. package/dist-web/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
  48. package/dist-web/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
  49. package/dist-web/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
  50. package/dist-web/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
  51. package/dist-web/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
  52. package/dist-web/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
  53. package/dist-web/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
  54. package/dist-web/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
  55. package/dist-web/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
  56. package/dist-web/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
  57. package/dist-web/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
  58. package/dist-web/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
  59. package/dist-web/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
  60. package/dist-web/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
  61. package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  62. package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  63. package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-BWTpRfYl.woff2 +0 -0
  64. package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-CEoEElIJ.woff +0 -0
  65. package/dist-web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  66. package/dist-web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  67. package/dist-web/assets/jetbrains-mono-greek-700-normal-C6CZE3T8.woff2 +0 -0
  68. package/dist-web/assets/jetbrains-mono-greek-700-normal-DEigVDxa.woff +0 -0
  69. package/dist-web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  70. package/dist-web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  71. package/dist-web/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
  72. package/dist-web/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
  73. package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  74. package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  75. package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CZipNAKV.woff2 +0 -0
  76. package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CxPITLHs.woff +0 -0
  77. package/dist-web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  78. package/dist-web/assets/jetbrains-mono-vietnamese-700-normal-BDLVIk2r.woff +0 -0
  79. package/dist-web/assets/logo-D4DDtU-r.png +0 -0
  80. package/dist-web/favicon.png +0 -0
  81. package/dist-web/index.html +14 -0
  82. package/package.json +88 -0
  83. package/src/ask-user-buttons.js +142 -0
  84. package/src/claude-transcript.js +203 -0
  85. package/src/cli.js +1040 -0
  86. package/src/codex-event-emitter.js +111 -0
  87. package/src/codex-prompt-detector.js +53 -0
  88. package/src/codex-sidecar.js +52 -0
  89. package/src/codex-transcript.js +74 -0
  90. package/src/config.js +692 -0
  91. package/src/data/claude-code-commands.json +52 -0
  92. package/src/db.js +1503 -0
  93. package/src/dispatch.js +13 -0
  94. package/src/export/todoMarkdown.js +246 -0
  95. package/src/first-run-wizard.js +82 -0
  96. package/src/git/gitStatus.js +139 -0
  97. package/src/lark-api-client.js +205 -0
  98. package/src/lark-bot.js +510 -0
  99. package/src/lark-card.js +88 -0
  100. package/src/lark-config-service.js +16 -0
  101. package/src/lark-event-client.js +107 -0
  102. package/src/lark-image.js +99 -0
  103. package/src/lark-markdown.js +51 -0
  104. package/src/lark-video.js +163 -0
  105. package/src/mcp/audit.js +34 -0
  106. package/src/mcp/server.js +83 -0
  107. package/src/mcp/tools/destructive/index.js +252 -0
  108. package/src/mcp/tools/openclaw/index.js +405 -0
  109. package/src/mcp/tools/read/index.js +269 -0
  110. package/src/mcp/tools/write/index.js +157 -0
  111. package/src/openclaw-bridge.js +566 -0
  112. package/src/openclaw-hook-installer.js +338 -0
  113. package/src/openclaw-hook.js +908 -0
  114. package/src/openclaw-wizard.js +2442 -0
  115. package/src/pending-questions.js +297 -0
  116. package/src/pricing.js +45 -0
  117. package/src/prompt-render.js +36 -0
  118. package/src/pty.js +992 -0
  119. package/src/routes/ai-terminal.js +1228 -0
  120. package/src/routes/git.js +89 -0
  121. package/src/routes/openclaw-hook.js +67 -0
  122. package/src/routes/openclaw-inbound.js +36 -0
  123. package/src/routes/recurringRules.js +80 -0
  124. package/src/routes/reports.js +50 -0
  125. package/src/routes/search.js +46 -0
  126. package/src/routes/stats.js +31 -0
  127. package/src/routes/telegram-config.js +152 -0
  128. package/src/routes/telegram-sync.js +221 -0
  129. package/src/routes/templates.js +63 -0
  130. package/src/routes/todos.js +649 -0
  131. package/src/routes/transcripts.js +75 -0
  132. package/src/routes/uploads.js +107 -0
  133. package/src/routes/wiki.js +142 -0
  134. package/src/search/fts.js +209 -0
  135. package/src/search/index.js +199 -0
  136. package/src/search/transcripts.js +148 -0
  137. package/src/server.js +1791 -0
  138. package/src/session-input-dispatcher.js +256 -0
  139. package/src/stats/markdown.js +42 -0
  140. package/src/stats/report.js +207 -0
  141. package/src/summarize.js +84 -0
  142. package/src/system-rules.js +52 -0
  143. package/src/telegram-bot.js +875 -0
  144. package/src/telegram-commands.js +149 -0
  145. package/src/telegram-config-service.js +84 -0
  146. package/src/telegram-image.js +95 -0
  147. package/src/telegram-loading-status.js +112 -0
  148. package/src/telegram-markdown.js +82 -0
  149. package/src/telegram-reaction-tracker.js +69 -0
  150. package/src/telegram-video.js +75 -0
  151. package/src/templates/claude-hooks/notify.js +103 -0
  152. package/src/transcript.js +305 -0
  153. package/src/transcripts/blocks.js +56 -0
  154. package/src/transcripts/index.js +222 -0
  155. package/src/transcripts/indexer.js +34 -0
  156. package/src/transcripts/matcher.js +70 -0
  157. package/src/transcripts/scanner.js +259 -0
  158. package/src/usage-footer.js +170 -0
  159. package/src/usage-parser.js +132 -0
  160. package/src/wiki/guide.js +44 -0
  161. package/src/wiki/index.js +232 -0
  162. package/src/wiki/redact.js +34 -0
  163. package/src/wiki/sources.js +122 -0
@@ -0,0 +1,252 @@
1
+ import { z } from 'zod'
2
+
3
+ function asText(value) {
4
+ return {
5
+ content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }],
6
+ }
7
+ }
8
+
9
+ function asError(message) {
10
+ return {
11
+ isError: true,
12
+ content: [{ type: 'text', text: `error: ${message}` }],
13
+ }
14
+ }
15
+
16
+ function previewResponse({ toolName, summary, impact, args }) {
17
+ const { confirm, confirmNote, ...safeArgs } = args
18
+ return asText({
19
+ preview: true,
20
+ tool: toolName,
21
+ summary,
22
+ impact,
23
+ howToConfirm:
24
+ `调用一次同样的 ${toolName},但把 "confirm" 设为 true(同时可选地附上 "confirmNote" 记录用户同意的理由)。`,
25
+ confirmedArgs: { ...safeArgs, confirm: true, confirmNote: '<optional note>' },
26
+ })
27
+ }
28
+
29
+ /**
30
+ * 注册 4 个破坏性工具:delete_todo / archive_todo / merge_todos / bulk_update。
31
+ * 默认 confirm:false 只返回 preview JSON;带 confirm:true 才真执行,并写 audit log。
32
+ */
33
+ export function registerDestructiveTools(server, { db, audit }) {
34
+ // ─── 1. delete_todo ───────────────────────────────────────────
35
+ server.registerTool(
36
+ 'delete_todo',
37
+ {
38
+ description:
39
+ '硬删除一个 todo(级联删除子任务、评论、AI 会话日志)。不可逆。默认 confirm:false 返回预览,不修改 DB;confirm:true 才真删。',
40
+ inputSchema: {
41
+ id: z.string().min(1),
42
+ confirm: z.boolean().optional(),
43
+ confirmNote: z.string().optional().describe('转述用户同意理由,写入审计日志'),
44
+ },
45
+ },
46
+ async (args) => {
47
+ try {
48
+ const todo = db.getTodo(args.id)
49
+ if (!todo) return asError('todo_not_found')
50
+ const countCascade = (sql, id) => db.raw.prepare(sql).get(id)?.n || 0
51
+ const impact = {
52
+ todoId: todo.id,
53
+ title: todo.title,
54
+ subtodos: countCascade(`SELECT COUNT(*) AS n FROM todos WHERE parent_id = ?`, todo.id),
55
+ comments: countCascade(`SELECT COUNT(*) AS n FROM comments WHERE todo_id = ?`, todo.id),
56
+ sessionLogs: countCascade(`SELECT COUNT(*) AS n FROM ai_session_log WHERE todo_id = ?`, todo.id),
57
+ aiSessions: Array.isArray(todo.aiSessions) ? todo.aiSessions.length : 0,
58
+ }
59
+ if (!args.confirm) {
60
+ return previewResponse({
61
+ toolName: 'delete_todo',
62
+ summary: `将硬删 todo id=${todo.id}(标题「${todo.title}」),级联删除 ${impact.subtodos} 个子任务 / ${impact.comments} 条评论 / ${impact.sessionLogs} 条 AI 会话日志。此操作不可逆。`,
63
+ impact,
64
+ args,
65
+ })
66
+ }
67
+ db.deleteTodo(todo.id)
68
+ audit?.append({
69
+ tool: 'delete_todo',
70
+ ok: true,
71
+ args: { id: args.id },
72
+ result: impact,
73
+ confirmNote: args.confirmNote || null,
74
+ })
75
+ return asText({ ok: true, deleted: impact })
76
+ } catch (e) {
77
+ audit?.append({ tool: 'delete_todo', ok: false, args: { id: args.id }, error: e?.message })
78
+ return asError(e?.message || 'delete_failed')
79
+ }
80
+ },
81
+ )
82
+
83
+ // ─── 2. archive_todo ──────────────────────────────────────────
84
+ server.registerTool(
85
+ 'archive_todo',
86
+ {
87
+ description:
88
+ '把 todo 从默认列表里隐藏(设置 archived_at=now)。可用 unarchive_todo 撤销。默认 confirm:false 返回预览。',
89
+ inputSchema: {
90
+ id: z.string().min(1),
91
+ confirm: z.boolean().optional(),
92
+ confirmNote: z.string().optional(),
93
+ },
94
+ },
95
+ async (args) => {
96
+ try {
97
+ const todo = db.getTodo(args.id)
98
+ if (!todo) return asError('todo_not_found')
99
+ if (todo.archivedAt) {
100
+ return asText({ ok: true, alreadyArchived: true, todo })
101
+ }
102
+ const impact = { todoId: todo.id, title: todo.title }
103
+ if (!args.confirm) {
104
+ return previewResponse({
105
+ toolName: 'archive_todo',
106
+ summary: `将归档 todo id=${todo.id}(标题「${todo.title}」)。它将不再出现在默认列表里;可用 unarchive_todo 撤销。`,
107
+ impact,
108
+ args,
109
+ })
110
+ }
111
+ const result = db.archiveTodo(todo.id)
112
+ audit?.append({
113
+ tool: 'archive_todo',
114
+ ok: true,
115
+ args: { id: args.id },
116
+ result: impact,
117
+ confirmNote: args.confirmNote || null,
118
+ })
119
+ return asText({ ok: true, todo: result })
120
+ } catch (e) {
121
+ audit?.append({ tool: 'archive_todo', ok: false, args: { id: args.id }, error: e?.message })
122
+ return asError(e?.message || 'archive_failed')
123
+ }
124
+ },
125
+ )
126
+
127
+ // ─── 3. merge_todos ───────────────────────────────────────────
128
+ server.registerTool(
129
+ 'merge_todos',
130
+ {
131
+ description:
132
+ '把 sourceIds 里的多条 todo 合并进 targetId:它们的子任务、评论、AI 会话、wiki 等关联记录全部迁移到 target,随后源 todo 被删除。不可逆。默认 confirm:false 返回 preview。titleStrategy:keep_target / concat / manual(manual 要 manualTitle)。',
133
+ inputSchema: {
134
+ targetId: z.string().min(1),
135
+ sourceIds: z.array(z.string().min(1)).min(1),
136
+ titleStrategy: z.enum(['keep_target', 'concat', 'manual']).optional(),
137
+ manualTitle: z.string().optional(),
138
+ confirm: z.boolean().optional(),
139
+ confirmNote: z.string().optional(),
140
+ },
141
+ },
142
+ async (args) => {
143
+ try {
144
+ const preview = db.describeMergeTodos({
145
+ targetId: args.targetId,
146
+ sourceIds: args.sourceIds,
147
+ titleStrategy: args.titleStrategy || 'keep_target',
148
+ manualTitle: args.manualTitle,
149
+ })
150
+ if (!args.confirm) {
151
+ const summary =
152
+ `将把 ${preview.sources.length} 条源 todo 合并进 target id=${preview.target.id}(「${preview.target.title}」)。` +
153
+ `迁移:${preview.movedChildren} 子任务 / ${preview.movedComments} 评论 / ${preview.movedSessions} AI 会话 / ${preview.movedSessionLogs} 会话日志。` +
154
+ `合并后标题:「${preview.proposedTitle}」。源 todo 将被删除(不可逆)。`
155
+ return previewResponse({
156
+ toolName: 'merge_todos',
157
+ summary,
158
+ impact: preview,
159
+ args,
160
+ })
161
+ }
162
+ const result = db.mergeTodos({
163
+ targetId: args.targetId,
164
+ sourceIds: args.sourceIds,
165
+ titleStrategy: args.titleStrategy || 'keep_target',
166
+ manualTitle: args.manualTitle,
167
+ })
168
+ audit?.append({
169
+ tool: 'merge_todos',
170
+ ok: true,
171
+ args: {
172
+ targetId: args.targetId,
173
+ sourceIds: args.sourceIds,
174
+ titleStrategy: args.titleStrategy,
175
+ },
176
+ result: {
177
+ movedChildren: result.movedChildren,
178
+ movedComments: result.movedComments,
179
+ movedSessions: result.movedSessions,
180
+ movedSessionLogs: result.movedSessionLogs,
181
+ deletedIds: result.sources.map((s) => s.id),
182
+ },
183
+ confirmNote: args.confirmNote || null,
184
+ })
185
+ return asText({ ok: true, result })
186
+ } catch (e) {
187
+ audit?.append({ tool: 'merge_todos', ok: false, args: { targetId: args.targetId, sourceIds: args.sourceIds }, error: e?.message })
188
+ return asError(e?.message || 'merge_failed')
189
+ }
190
+ },
191
+ )
192
+
193
+ // ─── 4. bulk_update ───────────────────────────────────────────
194
+ server.registerTool(
195
+ 'bulk_update',
196
+ {
197
+ description:
198
+ '对一组 todo id 批量 patch。允许字段:quadrant / status / archived / dueDate。默认 confirm:false 返回 preview(列出将要修改的 todos,最多 20 条)。',
199
+ inputSchema: {
200
+ ids: z.array(z.string().min(1)).min(1),
201
+ patch: z
202
+ .object({
203
+ quadrant: z.number().int().min(1).max(4).optional(),
204
+ status: z.enum(['todo', 'done']).optional(),
205
+ archived: z.boolean().optional(),
206
+ dueDate: z.number().int().nullable().optional(),
207
+ })
208
+ .refine((obj) => Object.keys(obj).length > 0, 'patch cannot be empty'),
209
+ confirm: z.boolean().optional(),
210
+ confirmNote: z.string().optional(),
211
+ },
212
+ },
213
+ async (args) => {
214
+ try {
215
+ const preview = {
216
+ ids: args.ids,
217
+ patch: args.patch,
218
+ affected: [],
219
+ missing: [],
220
+ }
221
+ for (const id of args.ids.slice(0, 20)) {
222
+ const t = db.getTodo(id)
223
+ if (t) preview.affected.push({ id: t.id, title: t.title, quadrant: t.quadrant, status: t.status })
224
+ else preview.missing.push(id)
225
+ }
226
+ preview.totalTargeted = args.ids.length
227
+ preview.previewTruncated = args.ids.length > 20
228
+ if (!args.confirm) {
229
+ const patchKeys = Object.keys(args.patch).join(', ')
230
+ return previewResponse({
231
+ toolName: 'bulk_update',
232
+ summary: `将对 ${args.ids.length} 条 todo 批量 patch 以下字段:${patchKeys}。命中 ${preview.affected.length} 条、未找到 ${preview.missing.length} 条。`,
233
+ impact: preview,
234
+ args,
235
+ })
236
+ }
237
+ const result = db.bulkUpdateTodos({ ids: args.ids, patch: args.patch })
238
+ audit?.append({
239
+ tool: 'bulk_update',
240
+ ok: true,
241
+ args: { ids: args.ids, patch: args.patch },
242
+ result: { changedCount: result.count, changedIds: result.changedIds },
243
+ confirmNote: args.confirmNote || null,
244
+ })
245
+ return asText({ ok: true, result })
246
+ } catch (e) {
247
+ audit?.append({ tool: 'bulk_update', ok: false, args: { ids: args.ids, patch: args.patch }, error: e?.message })
248
+ return asError(e?.message || 'bulk_update_failed')
249
+ }
250
+ },
251
+ )
252
+ }
@@ -0,0 +1,405 @@
1
+ /**
2
+ * OpenClaw 双向桥接的 MCP 工具集(8 个):
3
+ *
4
+ * 创建任务向导(OpenClaw skill 调):
5
+ * - list_workdir_options
6
+ * - list_quadrants
7
+ * - list_templates
8
+ *
9
+ * 启动 PTY(OpenClaw skill 调):
10
+ * - start_ai_session
11
+ *
12
+ * 双向交互:
13
+ * - ask_user (PTY 内 AI 调,阻塞)
14
+ * - submit_user_reply (OpenClaw skill 调)
15
+ * - list_pending_questions
16
+ * - cancel_pending_question
17
+ */
18
+ import { z } from 'zod'
19
+ import { existsSync } from 'node:fs'
20
+ import { homedir } from 'node:os'
21
+ import { join, resolve as resolvePath } from 'node:path'
22
+ import { buildAskUserReplyMarkup } from '../../../ask-user-buttons.js'
23
+ import { applySystemRules } from '../../../system-rules.js'
24
+ import { resolveTool } from '../../../dispatch.js'
25
+
26
+ function asText(value) {
27
+ return {
28
+ content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }],
29
+ }
30
+ }
31
+
32
+ function asError(message, extra = null) {
33
+ const text = extra ? `error: ${message}\n${JSON.stringify(extra, null, 2)}` : `error: ${message}`
34
+ return {
35
+ isError: true,
36
+ content: [{ type: 'text', text }],
37
+ }
38
+ }
39
+
40
+ const QUADRANTS = [
41
+ { id: 1, label: '重要紧急', shortLabel: 'Q1', isDefault: false },
42
+ { id: 2, label: '重要不紧急', shortLabel: 'Q2', isDefault: true },
43
+ { id: 3, label: '紧急不重要', shortLabel: 'Q3', isDefault: false },
44
+ { id: 4, label: '不重要不紧急', shortLabel: 'Q4', isDefault: false },
45
+ ]
46
+
47
+ function expandHome(p) {
48
+ if (!p) return p
49
+ if (p === '~' || p.startsWith('~/')) return p === '~' ? homedir() : join(homedir(), p.slice(2))
50
+ return p
51
+ }
52
+
53
+ function recentWorkDirs(db, limit = 5) {
54
+ // 取最近 200 条 todo,按 work_dir 出现频次排序
55
+ try {
56
+ const rows = db.raw.prepare(`
57
+ SELECT work_dir, COUNT(*) AS n, MAX(updated_at) AS last_used
58
+ FROM todos
59
+ WHERE work_dir IS NOT NULL AND work_dir != ''
60
+ GROUP BY work_dir
61
+ ORDER BY n DESC, last_used DESC
62
+ LIMIT ?
63
+ `).all(limit)
64
+ return rows.map((r) => ({ path: r.work_dir, count: r.n, lastUsedAt: r.last_used }))
65
+ } catch {
66
+ return []
67
+ }
68
+ }
69
+
70
+ export function registerOpenClawTools(server, deps) {
71
+ const { db, aiTerminal, openclaw, pending, getConfig } = deps
72
+ if (!db) throw new Error('openclaw_tools: db required')
73
+ if (!pending) throw new Error('openclaw_tools: pending coordinator required')
74
+
75
+ // ─── 1. list_workdir_options ────────────────────────────────────
76
+ server.registerTool(
77
+ 'list_workdir_options',
78
+ {
79
+ description:
80
+ '列出建议的工作目录候选,用于「创建任务多轮向导」第一步。来源:' +
81
+ '(a) 已有 todo 中频次最高的 work_dir;' +
82
+ '(b) 配置项 defaultCwd;' +
83
+ '(c) 当前用户主目录。每项带 source/path/count/lastUsedAt。',
84
+ inputSchema: {},
85
+ },
86
+ async () => {
87
+ try {
88
+ const cfg = (typeof getConfig === 'function' && getConfig()) || {}
89
+ const recent = recentWorkDirs(db, 5)
90
+ const out = recent.map((r) => ({ ...r, source: 'recent' }))
91
+ const seen = new Set(out.map((x) => x.path))
92
+ const defCwd = cfg.defaultCwd || homedir()
93
+ if (defCwd && !seen.has(defCwd)) {
94
+ out.push({ path: defCwd, source: 'default' })
95
+ seen.add(defCwd)
96
+ }
97
+ const home = homedir()
98
+ if (!seen.has(home)) {
99
+ out.push({ path: home, source: 'home' })
100
+ }
101
+ return asText({ options: out })
102
+ } catch (e) {
103
+ return asError(e?.message || 'list_workdir_options_failed')
104
+ }
105
+ },
106
+ )
107
+
108
+ // ─── 2. list_quadrants ──────────────────────────────────────────
109
+ server.registerTool(
110
+ 'list_quadrants',
111
+ {
112
+ description: '返回 4 个象限的元数据(id/label/isDefault),创建向导第二步用。',
113
+ inputSchema: {},
114
+ },
115
+ async () => asText({ quadrants: QUADRANTS }),
116
+ )
117
+
118
+ // ─── 3. list_templates ──────────────────────────────────────────
119
+ server.registerTool(
120
+ 'list_templates',
121
+ {
122
+ description:
123
+ '返回所有提示词模板(id/name/description/builtin/contentPreview),创建向导第三步用。' +
124
+ '完整 content 不返回(避免上下文爆);启动会话时按 templateId 自动注入。',
125
+ inputSchema: {},
126
+ },
127
+ async () => {
128
+ try {
129
+ const list = db.listTemplates().map((t) => ({
130
+ id: t.id,
131
+ name: t.name,
132
+ description: t.description,
133
+ builtin: t.builtin,
134
+ contentPreview: (t.content || '').slice(0, 80),
135
+ }))
136
+ return asText({ templates: list })
137
+ } catch (e) {
138
+ return asError(e?.message || 'list_templates_failed')
139
+ }
140
+ },
141
+ )
142
+
143
+ // ─── 4. start_ai_session ────────────────────────────────────────
144
+ server.registerTool(
145
+ 'start_ai_session',
146
+ {
147
+ description:
148
+ '为指定 todo 启动一个 AI 终端会话(Claude Code / Codex)。' +
149
+ '默认 permissionMode=bypass —— 用户从微信远程驱动时无法响应交互式权限。' +
150
+ '可选 templateId:从 prompt_templates 拉模板内容作为首句注入。' +
151
+ '可选 prompt:直接传 prompt 字符串覆盖 templateId(template 不变)。' +
152
+ '可选 routeUserId:把这个微信对端绑定到这个 sessionId,' +
153
+ 'AI 后续调 ask_user / Stop hook 都自动推到该用户。' +
154
+ '同时会向 PTY 注入 QUADTODO_SESSION_ID/TARGET_USER/TODO_ID/TODO_TITLE 环境变量,' +
155
+ '让嵌套 Claude Code 的 hook 脚本能识别这是个 AgentQuad 启动的会话。',
156
+ inputSchema: {
157
+ todoId: z.string().min(1),
158
+ tool: z.enum(['claude', 'codex']).optional().describe('默认 claude'),
159
+ cwd: z.string().optional().describe('工作目录;不填用 todo.workDir 或 defaultCwd'),
160
+ templateId: z.string().optional(),
161
+ prompt: z.string().optional().describe('显式 prompt;优先级高于 templateId'),
162
+ permissionMode: z.enum(['default', 'acceptEdits', 'bypass']).optional()
163
+ .describe('默认 bypass'),
164
+ routeUserId: z.string().optional().describe('OpenClaw 微信对端 user_id,用于回推'),
165
+ routeAccount: z.string().optional(),
166
+ routeChannel: z.string().optional(),
167
+ },
168
+ },
169
+ async (args) => {
170
+ try {
171
+ if (!aiTerminal?.spawnSession) return asError('ai_terminal_unavailable')
172
+ const todo = db.getTodo(args.todoId)
173
+ if (!todo) return asError('todo_not_found')
174
+
175
+ let prompt = args.prompt
176
+ let templateName = null
177
+ if (!prompt && args.templateId) {
178
+ const tpl = db.getTemplate(args.templateId)
179
+ if (!tpl) return asError('template_not_found')
180
+ prompt = `${tpl.content}\n\n---\n任务: ${todo.title}\n${todo.description ? `\n描述:\n${todo.description}` : ''}`
181
+ templateName = tpl.name
182
+ }
183
+ if (!prompt) {
184
+ prompt = `任务: ${todo.title}${todo.description ? `\n\n${todo.description}` : ''}`
185
+ }
186
+ // 默认不自动 prepend ask_user 规则,避免诱发 Claude Code 交互式 TUI。
187
+ // 需要强制走 Telegram 按钮时,可显式配置 config.aiSession.enforceAskUserRule = true。
188
+ const cfgEnforce = (getConfig?.()?.aiSession?.enforceAskUserRule === true)
189
+ prompt = applySystemRules(prompt, { enforce: cfgEnforce })
190
+
191
+ const cwd = expandHome(args.cwd) || todo.workDir || (getConfig?.()?.defaultCwd) || homedir()
192
+ if (cwd && !existsSync(cwd)) {
193
+ return asError('cwd_not_exists', { cwd })
194
+ }
195
+
196
+ const cfg = getConfig?.() || {}
197
+ const tool = resolveTool({ channel: args.channel || 'openclaw', userId: args.targetUserId, override: args.tool }, cfg)
198
+ // 默认 bypass —— 微信远程驱动场景必备(无法响应权限弹窗)
199
+ const permissionMode = args.permissionMode || 'bypass'
200
+
201
+ // 预生成 sessionId,让 env 能写真值进 PTY 子进程,
202
+ // 嵌套 Claude Code 的 hook 脚本就能在 stdin 里拿到正确 sessionId 路由
203
+ const sessionId = `ai-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
204
+ const port = getConfig?.()?.port || 5677
205
+ const extraEnv = {
206
+ QUADTODO_SESSION_ID: sessionId,
207
+ QUADTODO_TODO_ID: String(args.todoId),
208
+ QUADTODO_TODO_TITLE: String(todo.title || ''),
209
+ QUADTODO_URL: `http://127.0.0.1:${port}`,
210
+ }
211
+ if (args.routeUserId) extraEnv.QUADTODO_TARGET_USER = String(args.routeUserId)
212
+
213
+ // 先注册路由(用预生成的 sessionId)—— 否则 PTY 启动后第一秒内的 hook
214
+ // 若发到 AgentQuad,可能因路由没注册而 fallback 到 config.targetUserId
215
+ if (openclaw?.registerSessionRoute && args.routeUserId) {
216
+ openclaw.registerSessionRoute(sessionId, {
217
+ targetUserId: args.routeUserId,
218
+ account: args.routeAccount || null,
219
+ channel: args.routeChannel || null,
220
+ })
221
+ }
222
+
223
+ const result = aiTerminal.spawnSession({
224
+ sessionId,
225
+ todoId: args.todoId,
226
+ prompt,
227
+ tool,
228
+ cwd,
229
+ permissionMode,
230
+ label: templateName ? `template:${templateName}` : null,
231
+ extraEnv,
232
+ })
233
+
234
+ return asText({
235
+ ok: true,
236
+ sessionId: result.sessionId,
237
+ reused: result.reused,
238
+ tool,
239
+ cwd: resolvePath(cwd),
240
+ templateName,
241
+ permissionMode,
242
+ })
243
+ } catch (e) {
244
+ return asError(e?.message || 'start_ai_session_failed')
245
+ }
246
+ },
247
+ )
248
+
249
+ // ─── 5. ask_user (阻塞) ─────────────────────────────────────────
250
+ server.registerTool(
251
+ 'ask_user',
252
+ {
253
+ description:
254
+ '【AI 在 PTY 里调】把决策点抛给真人用户。这是一次阻塞调用:' +
255
+ '工具会等用户在 OpenClaw(微信)回复后才返回 chosen 选项。' +
256
+ '什么时候用:方案分歧 / 重大破坏性操作前 / 多个并列实现路径。' +
257
+ '不要为每个小决策都调 —— 只在真正需要人来拍板时用。',
258
+ inputSchema: {
259
+ question: z.string().min(1).describe('问题(精炼,给真人看)'),
260
+ options: z.array(z.string().min(1)).min(2).max(8).describe('2-8 个互斥选项'),
261
+ sessionId: z.string().optional().describe('当前 PTY 会话 ID(不填则尽力推断)'),
262
+ todoId: z.string().optional(),
263
+ timeoutMs: z.number().int().positive().max(3_600_000).optional(),
264
+ urgency: z.enum(['low', 'normal', 'high']).optional(),
265
+ },
266
+ },
267
+ async (args) => {
268
+ try {
269
+ if (!openclaw) return asError('openclaw_bridge_unavailable')
270
+
271
+ const oc = getConfig?.()?.openclaw || {}
272
+ if (!openclaw.isEnabled()) return asError('openclaw_disabled', {
273
+ hint: 'set openclaw.enabled = true in config',
274
+ })
275
+
276
+ const sessionId = args.sessionId || `ad-hoc-${Date.now()}`
277
+ const todoId = args.todoId || null
278
+ const timeoutMs = args.timeoutMs || oc?.askUser?.defaultTimeoutMs || 600_000
279
+
280
+ const { ticket, promise } = pending.ask({
281
+ sessionId,
282
+ todoId,
283
+ question: args.question,
284
+ options: args.options,
285
+ timeoutMs,
286
+ })
287
+
288
+ // 拼出微信文本:[#a3f] 任务 X 卡到决策点:\n问题\n\n1. ...\n2. ...
289
+ const todoTitle = todoId ? (db.getTodo(todoId)?.title || todoId) : null
290
+ const lines = []
291
+ const header = todoTitle
292
+ ? `[#${ticket}] 任务「${todoTitle}」需要你拍板:`
293
+ : `[#${ticket}] 决策需求:`
294
+ lines.push(header)
295
+ lines.push('')
296
+ lines.push(args.question)
297
+ lines.push('')
298
+ args.options.forEach((opt, i) => lines.push(`${i + 1}. ${opt}`))
299
+ lines.push('')
300
+ // 提示同时支持文本回复(老路径)和按钮(新路径),两者完全等价
301
+ lines.push(`点上面按钮 / 回 1-${args.options.length} / 回 #${ticket} N`)
302
+
303
+ // Telegram 路径:附 inline keyboard;其它 channel 自动忽略 replyMarkup
304
+ // bridge.postText 在 telegram fast-path 才透传,CLI fallback 走纯文本(无按钮)
305
+ let askUserMarkup = null
306
+ try {
307
+ askUserMarkup = buildAskUserReplyMarkup(ticket, args.options)
308
+ } catch (e) {
309
+ // 拼按钮失败不阻塞;纯文本兜底(用户照样能数字回复)
310
+ }
311
+
312
+ const sendResult = await openclaw.postText({
313
+ sessionId,
314
+ message: lines.join('\n'),
315
+ replyMarkup: askUserMarkup,
316
+ })
317
+ if (!sendResult.ok) {
318
+ // 不直接 reject 这条 pending;让用户能从 web UI fallback 答复
319
+ // 但要明确告知 AI 推送失败
320
+ return asText({
321
+ ticket,
322
+ status: 'pending',
323
+ warning: `outbound_failed:${sendResult.reason}`,
324
+ note: 'message could not be pushed to OpenClaw; user may answer via web UI. await result anyway.',
325
+ elapsedMs: 0,
326
+ })
327
+ }
328
+
329
+ // 阻塞 await,pending coordinator 内部已处理 timeout/cancel
330
+ const settled = await promise
331
+ return asText({
332
+ ticket,
333
+ status: settled.status,
334
+ chosen: settled.chosen,
335
+ chosenIndex: settled.chosenIndex,
336
+ answerText: settled.answerText,
337
+ elapsedMs: settled.elapsedMs,
338
+ })
339
+ } catch (e) {
340
+ return asError(e?.message || 'ask_user_failed')
341
+ }
342
+ },
343
+ )
344
+
345
+ // ─── 6. submit_user_reply ───────────────────────────────────────
346
+ server.registerTool(
347
+ 'submit_user_reply',
348
+ {
349
+ description:
350
+ '【OpenClaw skill 调】把用户在微信的纯文本回复送进来,路由到对应 pending question。' +
351
+ '路由规则:#xxx 强制 → bare xxx 尝试 → 最新 pending(fallback)。' +
352
+ '会自动模糊匹配选项(数字 1/2/3 / 选项原文 startswith / contains)。',
353
+ inputSchema: {
354
+ text: z.string().min(1),
355
+ },
356
+ },
357
+ async ({ text }) => {
358
+ try {
359
+ const r = pending.submitReply(text)
360
+ return asText(r)
361
+ } catch (e) {
362
+ return asError(e?.message || 'submit_reply_failed')
363
+ }
364
+ },
365
+ )
366
+
367
+ // ─── 7. list_pending_questions ──────────────────────────────────
368
+ server.registerTool(
369
+ 'list_pending_questions',
370
+ {
371
+ description: '列出当前所有 pending 的 ask_user 问题(含 ticket / 倒计时)。',
372
+ inputSchema: {},
373
+ },
374
+ async () => {
375
+ try {
376
+ const list = pending.listPending()
377
+ return asText({ pending: list, count: list.length })
378
+ } catch (e) {
379
+ return asError(e?.message || 'list_pending_failed')
380
+ }
381
+ },
382
+ )
383
+
384
+ // ─── 8. cancel_pending_question ─────────────────────────────────
385
+ server.registerTool(
386
+ 'cancel_pending_question',
387
+ {
388
+ description: '取消一条还在等用户回复的 pending question。' +
389
+ 'AI 端会立刻收到 status=cancelled 并继续。',
390
+ inputSchema: {
391
+ ticket: z.string().min(1).max(8),
392
+ reason: z.string().optional(),
393
+ },
394
+ },
395
+ async ({ ticket, reason }) => {
396
+ try {
397
+ const r = pending.cancel(ticket, reason)
398
+ if (!r.ok) return asError(r.reason, r)
399
+ return asText(r)
400
+ } catch (e) {
401
+ return asError(e?.message || 'cancel_failed')
402
+ }
403
+ },
404
+ )
405
+ }