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,107 @@
1
+ import * as Lark from '@larksuiteoapi/node-sdk'
2
+
3
+ function isBlank(value) {
4
+ return value == null || String(value) === ''
5
+ }
6
+
7
+ function normalizeError(error) {
8
+ return error?.message || error?.description || String(error)
9
+ }
10
+
11
+ function defaultDispatcherFactory() {
12
+ return new Lark.EventDispatcher({})
13
+ }
14
+
15
+ function defaultWsClientFactory({ appId, appSecret }) {
16
+ return new Lark.WSClient({
17
+ appId,
18
+ appSecret,
19
+ appType: Lark.AppType.SelfBuild,
20
+ loggerLevel: Lark.LoggerLevel?.info,
21
+ })
22
+ }
23
+
24
+ export function createLarkEventClient({
25
+ appId,
26
+ appSecret,
27
+ onEvent,
28
+ onCardAction = null,
29
+ dispatcherFactory = defaultDispatcherFactory,
30
+ wsClientFactory = defaultWsClientFactory,
31
+ logger = console,
32
+ } = {}) {
33
+ if (typeof onEvent !== 'function') throw new Error('onEvent_required')
34
+
35
+ let wsClient = null
36
+ let running = false
37
+ let lastReason = null
38
+ let lastDetail = null
39
+
40
+ function hasCredentials() {
41
+ return !isBlank(appId) && !isBlank(appSecret)
42
+ }
43
+
44
+ async function start() {
45
+ if (!hasCredentials()) {
46
+ lastReason = 'lark_credentials_missing'
47
+ lastDetail = null
48
+ return { ok: false, reason: 'lark_credentials_missing' }
49
+ }
50
+ if (running) return { ok: true, action: 'already_running' }
51
+
52
+ try {
53
+ const handlers = {
54
+ 'im.message.receive_v1': async (data) => {
55
+ try {
56
+ await onEvent(data)
57
+ } catch (e) {
58
+ const detail = normalizeError(e)
59
+ logger.warn?.(`[lark-event] lark_event_handler_failed: ${detail}`)
60
+ }
61
+ },
62
+ }
63
+ if (typeof onCardAction === 'function') {
64
+ handlers['card.action.trigger'] = async (data) => {
65
+ try {
66
+ return await onCardAction(data)
67
+ } catch (e) {
68
+ const detail = normalizeError(e)
69
+ logger.warn?.(`[lark-event] lark_card_action_handler_failed: ${detail}`)
70
+ }
71
+ }
72
+ }
73
+ const eventDispatcher = dispatcherFactory().register(handlers)
74
+ wsClient = wsClientFactory({ appId, appSecret })
75
+ wsClient.start({ eventDispatcher })
76
+ running = true
77
+ lastReason = null
78
+ lastDetail = null
79
+ return { ok: true, action: 'started' }
80
+ } catch (e) {
81
+ running = false
82
+ wsClient = null
83
+ lastReason = 'lark_ws_start_failed'
84
+ lastDetail = normalizeError(e)
85
+ logger.warn?.(`[lark-event] websocket start failed: ${lastDetail}`)
86
+ return { ok: false, reason: lastReason, detail: lastDetail }
87
+ }
88
+ }
89
+
90
+ async function stop() {
91
+ running = false
92
+ const current = wsClient
93
+ wsClient = null
94
+ if (current?.stop) await current.stop()
95
+ return { ok: true }
96
+ }
97
+
98
+ function describe() {
99
+ return {
100
+ running,
101
+ reason: lastReason,
102
+ detail: lastDetail,
103
+ }
104
+ }
105
+
106
+ return { start, stop, describe }
107
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * 把飞书入站图片下载到本地,让 PTY stdin 写 `@<path>` 喂给 Claude Code attach。
3
+ *
4
+ * 流程:
5
+ * 1) lark-api-client.getMessageResource({messageId, fileKey, type:'image'}) → SDK
6
+ * 返回 { writeFile, getReadableStream, headers }
7
+ * 2) 根据 headers['content-type'] 推扩展名(image/png → png, image/jpeg → jpg, ...)
8
+ * 3) writeFile 落到 ~/.agentquad/lark-uploads/<ts>-<rand>.<ext>
9
+ * 4) 返回本地绝对路径
10
+ *
11
+ * 不主动清理:磁盘占用可忽略(图片量级一般几百 KB)。
12
+ */
13
+ import { mkdirSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import { DEFAULT_ROOT_DIR } from './config.js'
16
+
17
+ const DEFAULT_DIR = join(DEFAULT_ROOT_DIR, 'lark-uploads')
18
+
19
+ const CONTENT_TYPE_TO_EXT = {
20
+ 'image/png': 'png',
21
+ 'image/jpeg': 'jpg',
22
+ 'image/jpg': 'jpg',
23
+ 'image/gif': 'gif',
24
+ 'image/webp': 'webp',
25
+ 'image/svg+xml': 'svg',
26
+ 'image/bmp': 'bmp',
27
+ }
28
+
29
+ export function extFromContentType(headers) {
30
+ const ct = String(
31
+ headers?.['content-type']
32
+ || headers?.['Content-Type']
33
+ || ''
34
+ ).toLowerCase()
35
+ for (const [type, ext] of Object.entries(CONTENT_TYPE_TO_EXT)) {
36
+ if (ct.includes(type)) return ext
37
+ }
38
+ return 'bin'
39
+ }
40
+
41
+ /**
42
+ * @param opts.apiClient lark-api-client 实例(或类似 shape:必须有 getMessageResource)
43
+ * @param opts.messageId 飞书 message_id(图片所在的消息)
44
+ * @param opts.imageKey content.image_key(普通 image 消息)或 post 里 img 节点的 image_key
45
+ * @param opts.destDir 目标目录(默认 ~/.agentquad/lark-uploads)
46
+ * @returns {Promise<{ ok: true, localPath } | { ok: false, reason, detail? }>}
47
+ */
48
+ export async function downloadLarkImage({ apiClient, messageId, imageKey, destDir = DEFAULT_DIR } = {}) {
49
+ if (!apiClient?.getMessageResource) return { ok: false, reason: 'apiClient_required' }
50
+ if (!messageId) return { ok: false, reason: 'messageId_required' }
51
+ if (!imageKey) return { ok: false, reason: 'imageKey_required' }
52
+
53
+ const r = await apiClient.getMessageResource({ messageId, fileKey: imageKey, type: 'image' })
54
+ if (!r?.ok) return { ok: false, reason: r?.reason || 'lark_resource_failed', detail: r?.detail }
55
+ if (typeof r.writeFile !== 'function') return { ok: false, reason: 'no_writefile' }
56
+
57
+ try {
58
+ mkdirSync(destDir, { recursive: true })
59
+ } catch (e) {
60
+ return { ok: false, reason: 'mkdir_failed', detail: e.message }
61
+ }
62
+ const ext = extFromContentType(r.headers || {})
63
+ const localName = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`
64
+ const localPath = join(destDir, localName)
65
+ try {
66
+ await r.writeFile(localPath)
67
+ } catch (e) {
68
+ return { ok: false, reason: 'write_failed', detail: e.message }
69
+ }
70
+ return { ok: true, localPath }
71
+ }
72
+
73
+ /**
74
+ * 从飞书 message 提取所有 image_key。
75
+ * 普通 image 消息:content.image_key。
76
+ * post 富文本:content.content[][].tag === 'img' 节点的 image_key。
77
+ */
78
+ export function extractImageKeys(message = {}) {
79
+ const keys = []
80
+ let content = message.content
81
+ if (typeof content === 'string') {
82
+ try { content = JSON.parse(content) } catch { content = null }
83
+ }
84
+ if (!content || typeof content !== 'object') return keys
85
+ if (typeof content.image_key === 'string' && content.image_key) {
86
+ keys.push(content.image_key)
87
+ }
88
+ if (Array.isArray(content.content)) {
89
+ for (const line of content.content) {
90
+ if (!Array.isArray(line)) continue
91
+ for (const node of line) {
92
+ if (node?.tag === 'img' && typeof node.image_key === 'string' && node.image_key) {
93
+ keys.push(node.image_key)
94
+ }
95
+ }
96
+ }
97
+ }
98
+ return keys
99
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 飞书 text 消息不渲染 markdown,把常见 markdown 语法降级为可读纯文本。
3
+ * 主线场景:把 LLM/AgentQuad 输出的 markdown 长文本干净地推到飞书 thread。
4
+ *
5
+ * 处理范围(按顺序):
6
+ * - 代码块 ```lang ... ``` → 去掉栅栏,保留内容
7
+ * - 图片 ![alt](url) → 删掉
8
+ * - 链接 [text](url) → "text (url)",text==url 时只保留 url
9
+ * - 标题 #..###### → 去掉 # 前缀
10
+ * - 引用 > → 去掉 > 前缀
11
+ * - 粗体 **x** / __x__ → x
12
+ * - 斜体 *x* / _x_ → x(用前后界判断避免吃 list bullet)
13
+ * - 删除线 ~~x~~ → x
14
+ * - 水平线 --- / *** / ___ → ——————
15
+ * - 转义 \* \_ \` 等 → * _ `
16
+ *
17
+ * 不处理:
18
+ * - inline code `code` 保留 backticks(视觉提示),飞书原样显示
19
+ * - 表格 | a | b | 保留原样(飞书不渲染但能读)
20
+ * - 列表标记 - / * / 1. 保留
21
+ */
22
+ export function toLarkText(text) {
23
+ if (text == null) return ''
24
+ if (typeof text !== 'string') return String(text)
25
+ let out = text
26
+ // 代码块(带或不带语言)
27
+ out = out.replace(/```[a-zA-Z0-9_+-]*\n?([\s\S]*?)```/g, '$1')
28
+ // 图片
29
+ out = out.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
30
+ // 链接
31
+ out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, url) =>
32
+ label === url ? url : `${label} (${url})`
33
+ )
34
+ // 标题(行首 1-6 个 #)
35
+ out = out.replace(/^#{1,6}\s+/gm, '')
36
+ // 引用
37
+ out = out.replace(/^>\s?/gm, '')
38
+ // 粗体 **x** / __x__
39
+ out = out.replace(/\*\*([^*\n]+?)\*\*/g, '$1')
40
+ out = out.replace(/__([^_\n]+?)__/g, '$1')
41
+ // 斜体 *x* / _x_:用 lookbehind 避免吃 list bullet 和反斜杠转义的 *
42
+ out = out.replace(/(?<![\\*\w])\*([^*\n]+?)(?<!\\)\*(?!\w)/g, '$1')
43
+ out = out.replace(/(?<![\\_\w])_([^_\n]+?)(?<!\\)_(?!\w)/g, '$1')
44
+ // 删除线
45
+ out = out.replace(/~~([^~\n]+?)~~/g, '$1')
46
+ // 水平线(整行只有 --- / *** / ___ 至少 3 个)
47
+ out = out.replace(/^\s*([-*_])\1{2,}\s*$/gm, '——————————')
48
+ // backslash 转义
49
+ out = out.replace(/\\([\\*_`~\[\]()#+\-.!|>])/g, '$1')
50
+ return out
51
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * 飞书视频入站:跟 lark-image 类似,但用 getMessageResource(type:'file')。
3
+ *
4
+ * 实测飞书发视频时,事件 shape 是:
5
+ * msg_type: 'post'
6
+ * content: { title:'', content: [[{ tag:'media', file_key:'file_v3_xxx', image_key:'img_v3_xxx' }]] }
7
+ *
8
+ * 也可能(理论上 / 其他版本)出现:
9
+ * msg_type: 'media' / 'video', 顶层 content.file_key
10
+ * msg_type: 'file', content.file_key + file_name 后缀是视频
11
+ *
12
+ * extractVideoFileKey 把以上几种 shape 都覆盖。
13
+ */
14
+ import { mkdirSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+ import { DEFAULT_ROOT_DIR } from './config.js'
17
+
18
+ const DEFAULT_DIR = join(DEFAULT_ROOT_DIR, 'lark-uploads')
19
+
20
+ const CONTENT_TYPE_TO_EXT = {
21
+ 'video/mp4': 'mp4',
22
+ 'video/quicktime': 'mov',
23
+ 'video/x-msvideo': 'avi',
24
+ 'video/x-matroska': 'mkv',
25
+ 'video/webm': 'webm',
26
+ 'video/3gpp': '3gp',
27
+ 'video/mpeg': 'mpeg',
28
+ }
29
+
30
+ export function videoExtFromContentType(headers) {
31
+ const ct = String(
32
+ headers?.['content-type']
33
+ || headers?.['Content-Type']
34
+ || ''
35
+ ).toLowerCase()
36
+ for (const [type, ext] of Object.entries(CONTENT_TYPE_TO_EXT)) {
37
+ if (ct.includes(type)) return ext
38
+ }
39
+ return 'mp4' // 飞书视频默认 mp4,未知 mime 兜底成 mp4 比 bin 更安全
40
+ }
41
+
42
+ const VIDEO_MSG_TYPES = new Set(['media', 'video'])
43
+ const VIDEO_FILE_NAME_RE = /\.(mp4|mov|m4v|webm|mkv|avi|3gp|mpeg|mpg|wmv|flv)$/i
44
+
45
+ /**
46
+ * 从飞书 message 提取视频 file_key。
47
+ *
48
+ * 识别优先级:
49
+ * ① post 富文本里的 media 节点 —— 实测飞书发视频走的就是这条
50
+ * ② msg_type ∈ {media, video} → content.file_key(顶层)
51
+ * ③ msg_type='file' 且 file_name 是视频后缀 → content.file_key
52
+ * ④ 兜底嵌套:content.video.file_key / content.media.file_key
53
+ * ⑤ 兜底:未知 msg_type 但 content 里有 file_key + 文件名是视频后缀
54
+ *
55
+ * @returns {{ fileKey: string, fileName: string|null, duration: number|null, msgType: string|null } | null}
56
+ */
57
+ export function extractVideoFileKey(message = {}) {
58
+ if (!message || typeof message !== 'object') return null
59
+ const msgType = message.msg_type || message.message_type || null
60
+
61
+ let content = message.content
62
+ if (typeof content === 'string') {
63
+ try { content = JSON.parse(content) } catch { content = null }
64
+ }
65
+ if (!content || typeof content !== 'object') return null
66
+
67
+ // ① post 富文本里的 media 节点
68
+ if (Array.isArray(content.content)) {
69
+ for (const line of content.content) {
70
+ if (!Array.isArray(line)) continue
71
+ for (const node of line) {
72
+ if (node && node.tag === 'media' && typeof node.file_key === 'string' && node.file_key) {
73
+ return {
74
+ fileKey: node.file_key,
75
+ fileName: typeof node.file_name === 'string' ? node.file_name : null,
76
+ duration: typeof node.duration === 'number' ? node.duration : null,
77
+ msgType,
78
+ }
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ // ②③④⑤ 顶层 / 嵌套 file_key
85
+ const fileKey = pickFirstString([
86
+ content.file_key,
87
+ content.video && content.video.file_key,
88
+ content.media && content.media.file_key,
89
+ ])
90
+ if (!fileKey) return null
91
+
92
+ const fileName = pickFirstString([
93
+ content.file_name,
94
+ content.video && content.video.file_name,
95
+ content.media && content.media.file_name,
96
+ ]) || null
97
+
98
+ let claim = false
99
+ if (msgType && VIDEO_MSG_TYPES.has(String(msgType).toLowerCase())) {
100
+ claim = true
101
+ } else if (fileName && VIDEO_FILE_NAME_RE.test(fileName)) {
102
+ claim = true
103
+ }
104
+ if (!claim) return null
105
+
106
+ const duration = typeof content.duration === 'number' ? content.duration : null
107
+
108
+ return { fileKey, fileName, duration, msgType }
109
+ }
110
+
111
+ function pickFirstString(candidates) {
112
+ for (const c of candidates) {
113
+ if (typeof c === 'string' && c) return c
114
+ }
115
+ return null
116
+ }
117
+
118
+ /**
119
+ * @param opts.apiClient lark-api-client 实例(必须有 getMessageResource)
120
+ * @param opts.messageId 飞书 message_id
121
+ * @param opts.fileKey content.file_key 或 post media 节点的 file_key
122
+ * @param opts.fileName 用于推扩展名(可选;优先级低于 content-type)
123
+ * @param opts.destDir 目标目录
124
+ * @returns {Promise<{ ok: true, localPath } | { ok: false, reason, detail? }>}
125
+ */
126
+ export async function downloadLarkVideo({
127
+ apiClient,
128
+ messageId,
129
+ fileKey,
130
+ fileName = null,
131
+ destDir = DEFAULT_DIR,
132
+ } = {}) {
133
+ if (!apiClient?.getMessageResource) return { ok: false, reason: 'apiClient_required' }
134
+ if (!messageId) return { ok: false, reason: 'messageId_required' }
135
+ if (!fileKey) return { ok: false, reason: 'fileKey_required' }
136
+
137
+ const r = await apiClient.getMessageResource({ messageId, fileKey, type: 'file' })
138
+ if (!r?.ok) return { ok: false, reason: r?.reason || 'lark_resource_failed', detail: r?.detail }
139
+ if (typeof r.writeFile !== 'function') return { ok: false, reason: 'no_writefile' }
140
+
141
+ try {
142
+ mkdirSync(destDir, { recursive: true })
143
+ } catch (e) {
144
+ return { ok: false, reason: 'mkdir_failed', detail: e.message }
145
+ }
146
+
147
+ let ext = videoExtFromContentType(r.headers || {})
148
+ if (ext === 'mp4' && fileName) {
149
+ const dot = fileName.lastIndexOf('.')
150
+ if (dot > 0 && dot < fileName.length - 1) {
151
+ const guess = fileName.slice(dot + 1).toLowerCase()
152
+ if (/^[a-z0-9]{2,5}$/.test(guess)) ext = guess
153
+ }
154
+ }
155
+ const localName = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`
156
+ const localPath = join(destDir, localName)
157
+ try {
158
+ await r.writeFile(localPath)
159
+ } catch (e) {
160
+ return { ok: false, reason: 'write_failed', detail: e.message }
161
+ }
162
+ return { ok: true, localPath }
163
+ }
@@ -0,0 +1,34 @@
1
+ import { appendFileSync, existsSync, mkdirSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ /**
5
+ * 简单的 NDJSON 审计日志:每次破坏性 MCP 工具真执行后追加一行。
6
+ *
7
+ * rootDir 通常是 ~/.agentquad;文件名固定为 mcp-audit.log。
8
+ * 调用失败(磁盘只读等)不影响主流程——静默降级。
9
+ */
10
+ export function createAuditLog({ rootDir, filename = 'mcp-audit.log' } = {}) {
11
+ if (!rootDir) throw new Error('rootDir_required')
12
+ const path = join(rootDir, filename)
13
+
14
+ function ensureDir() {
15
+ const dir = dirname(path)
16
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
17
+ }
18
+
19
+ function append(entry) {
20
+ try {
21
+ ensureDir()
22
+ const line = JSON.stringify({
23
+ ts: new Date().toISOString(),
24
+ ...entry,
25
+ }) + '\n'
26
+ appendFileSync(path, line, 'utf8')
27
+ return { ok: true }
28
+ } catch (e) {
29
+ return { ok: false, error: e?.message }
30
+ }
31
+ }
32
+
33
+ return { append, path }
34
+ }
@@ -0,0 +1,83 @@
1
+ import express from 'express'
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
3
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
4
+ import { registerReadTools } from './tools/read/index.js'
5
+ import { registerWriteTools } from './tools/write/index.js'
6
+ import { registerDestructiveTools } from './tools/destructive/index.js'
7
+ import { registerOpenClawTools } from './tools/openclaw/index.js'
8
+ import { createAuditLog } from './audit.js'
9
+ import { createTranscriptScanner } from '../search/transcripts.js'
10
+
11
+ const SERVER_NAME = 'agentquad'
12
+
13
+ /**
14
+ * 创建一个挂在 Express 下的 MCP Streamable HTTP 路由。
15
+ *
16
+ * 工作方式:一个全局 McpServer + 一个全局 StreamableHTTPServerTransport,stateless 模式。
17
+ * 每个 HTTP 请求都由 transport.handleRequest 完整处理。
18
+ *
19
+ * 依赖:
20
+ * - db:openDb(...) 返回的句柄
21
+ * - searchService:createSearchService 返回
22
+ * - wikiDir:wiki .md 文件所在目录(用于 read_wiki)
23
+ * - getVersion():可选,注入当前 AgentQuad 版本
24
+ * - aiTerminal:可选,{ spawnSession },用于 start_ai_session
25
+ * - openclaw:可选,OpenClaw bridge 句柄
26
+ * - pending:可选,pending-question coordinator 句柄
27
+ * - getConfig:可选,() => 当前配置快照
28
+ */
29
+ export function createMcpRouter({
30
+ db, searchService, wikiDir, rootDir, logDir, getVersion,
31
+ aiTerminal = null, openclaw = null, pending = null, getConfig = null,
32
+ } = {}) {
33
+ if (!db) throw new Error('db_required')
34
+ if (!searchService) throw new Error('searchService_required')
35
+
36
+ const server = new McpServer({
37
+ name: SERVER_NAME,
38
+ version: (typeof getVersion === 'function' && getVersion()) || '0.1.0',
39
+ })
40
+
41
+ const audit = rootDir ? createAuditLog({ rootDir }) : null
42
+ const transcriptScanner = logDir ? createTranscriptScanner({ db, logDir }) : null
43
+
44
+ registerReadTools(server, { db, searchService, wikiDir, transcriptScanner })
45
+ registerWriteTools(server, { db })
46
+ registerDestructiveTools(server, { db, audit })
47
+ if (pending) {
48
+ registerOpenClawTools(server, { db, aiTerminal, openclaw, pending, getConfig })
49
+ }
50
+
51
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
52
+ // 异步 connect;路由处理器会等这个 promise resolve 之后再调 handleRequest。
53
+ const ready = server.connect(transport)
54
+
55
+ const router = express.Router()
56
+ // MCP Streamable HTTP 约定:客户端用 POST /mcp 下发 JSON-RPC;
57
+ // 对于 SSE 变体或重连,GET 会触发会话初始化。
58
+ // 我们是 stateless mode,所以两种方法都交给 transport.handleRequest。
59
+ const handle = async (req, res) => {
60
+ try {
61
+ await ready
62
+ await transport.handleRequest(req, res, req.body)
63
+ } catch (e) {
64
+ if (!res.headersSent) {
65
+ res.status(500).json({
66
+ jsonrpc: '2.0',
67
+ error: { code: -32603, message: e?.message || 'internal_error' },
68
+ id: null,
69
+ })
70
+ }
71
+ }
72
+ }
73
+ router.post('/', handle)
74
+ router.get('/', handle)
75
+ router.delete('/', handle)
76
+
77
+ // 健康检查(MCP 客户端一般不走这个,但方便 `agentquad mcp status` 和运维)
78
+ router.get('/health', (_req, res) => {
79
+ res.json({ ok: true, server: SERVER_NAME, tools: server._registeredTools ? Object.keys(server._registeredTools).length : undefined })
80
+ })
81
+
82
+ return { router, server, transport }
83
+ }