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,75 @@
1
+ import express from 'express'
2
+
3
+ export function createTranscriptsRouter({ service }) {
4
+ const router = express.Router()
5
+
6
+ router.post('/scan', async (_req, res) => {
7
+ try {
8
+ const r = await service.scanIncremental()
9
+ res.json({ ok: true, ...r })
10
+ } catch (e) {
11
+ res.status(500).json({ ok: false, error: e.message })
12
+ }
13
+ })
14
+
15
+ router.get('/stats', (_req, res) => {
16
+ res.json({ ok: true, ...service.getStats() })
17
+ })
18
+
19
+ router.get('/search', (req, res) => {
20
+ try {
21
+ const { q, tool, cwd, since, unboundOnly, limit, offset } = req.query
22
+ const r = service.search({
23
+ q: q ? String(q) : undefined,
24
+ tool: tool ? String(tool) : undefined,
25
+ cwd: cwd ? String(cwd) : undefined,
26
+ since: since ? Number(since) : undefined,
27
+ unboundOnly: unboundOnly === '1' || unboundOnly === 'true',
28
+ limit: limit ? Math.min(Number(limit), 200) : 50,
29
+ offset: offset ? Number(offset) : 0,
30
+ })
31
+ res.json({ ok: true, ...r })
32
+ } catch (e) {
33
+ res.status(500).json({ ok: false, error: e.message })
34
+ }
35
+ })
36
+
37
+ router.get('/:fileId', (req, res) => {
38
+ const f = service.getFile(Number(req.params.fileId))
39
+ if (!f) return res.status(404).json({ ok: false, error: 'not found' })
40
+ res.json({ ok: true, file: f })
41
+ })
42
+
43
+ router.get('/:fileId/preview', async (req, res) => {
44
+ try {
45
+ const r = await service.preview(Number(req.params.fileId), {
46
+ offset: req.query.offset ? Number(req.query.offset) : 0,
47
+ limit: req.query.limit ? Math.min(Number(req.query.limit), 500) : 200,
48
+ })
49
+ if (!r) return res.status(404).json({ ok: false, error: 'not found' })
50
+ res.json({ ok: true, ...r })
51
+ } catch (e) {
52
+ res.status(500).json({ ok: false, error: e.message })
53
+ }
54
+ })
55
+
56
+ router.post('/:fileId/bind', (req, res) => {
57
+ const { todoId, force } = req.body || {}
58
+ if (!todoId) return res.status(400).json({ ok: false, error: 'todoId required' })
59
+ const r = service.bind(Number(req.params.fileId), String(todoId), { force: Boolean(force) })
60
+ if (!r.ok) {
61
+ if (r.code === 'ALREADY_BOUND') return res.status(409).json({ ok: false, code: r.code, currentTodoId: r.currentTodoId })
62
+ if (r.code === 'NOT_FOUND') return res.status(404).json({ ok: false, error: 'not found' })
63
+ return res.status(400).json({ ok: false, code: r.code })
64
+ }
65
+ res.json({ ok: true })
66
+ })
67
+
68
+ router.post('/:fileId/unbind', (req, res) => {
69
+ const r = service.unbind(Number(req.params.fileId))
70
+ if (!r.ok) return res.status(404).json({ ok: false, error: 'not found' })
71
+ res.json({ ok: true })
72
+ })
73
+
74
+ return router
75
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Web 端粘贴/拖拽图片上传:
3
+ * POST /api/uploads/image
4
+ * body: { filename: 'paste.png', mime: 'image/png', dataBase64: '...' }
5
+ * 返回:{ ok: true, path: '/Users/.../...png', fileSize: number }
6
+ *
7
+ * 用 base64 JSON 而不是 multipart:
8
+ * - 不引新依赖(multer / busboy)
9
+ * - 粘贴图通常 <2MB,base64 33% 开销可忍
10
+ * - 大文件 / 多文件场景以后真有需求再换 multipart
11
+ *
12
+ * 文件落到 ~/.agentquad/web-uploads/<ts>-<rand>.<ext>,跟 telegram tg-uploads 同模式。
13
+ */
14
+ import { Router } from 'express'
15
+ import { mkdirSync, writeFileSync, statSync, realpathSync } from 'node:fs'
16
+ import { join, resolve as resolvePath, sep } from 'node:path'
17
+ import { Buffer } from 'node:buffer'
18
+ import { DEFAULT_ROOT_DIR } from '../config.js'
19
+
20
+ const DEFAULT_UPLOAD_DIR = join(DEFAULT_ROOT_DIR, 'web-uploads')
21
+ const MAX_BYTES = 20 * 1024 * 1024 // 20MB,跟 telegram 一致
22
+
23
+ const SAFE_EXTS = new Set([
24
+ 'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'heic', 'heif',
25
+ 'pdf', 'txt', 'md', 'json', 'log', 'csv',
26
+ ])
27
+
28
+ function sanitizeExt(filename, mime) {
29
+ // 优先 filename 后缀;缺失或不安全 → 退到 mime 推断
30
+ const m = String(filename || '').match(/\.([a-zA-Z0-9]{1,8})$/)
31
+ let ext = m ? m[1].toLowerCase() : null
32
+ if (!ext || !SAFE_EXTS.has(ext)) {
33
+ if (/^image\/(png|jpeg|jpg|gif|webp|bmp|svg)/.test(mime || '')) {
34
+ ext = mime.split('/')[1].replace('+xml', '').toLowerCase()
35
+ } else if (/^application\/pdf/.test(mime || '')) {
36
+ ext = 'pdf'
37
+ } else {
38
+ ext = 'bin'
39
+ }
40
+ }
41
+ return ext
42
+ }
43
+
44
+ export function createUploadsRouter({ uploadDir = DEFAULT_UPLOAD_DIR, logger = console } = {}) {
45
+ const router = Router()
46
+
47
+ router.post('/image', (req, res) => {
48
+ try {
49
+ const { filename, mime, dataBase64 } = req.body || {}
50
+ if (!dataBase64 || typeof dataBase64 !== 'string') {
51
+ return res.status(400).json({ ok: false, error: 'dataBase64_required' })
52
+ }
53
+ // 大致预估 base64 解码后大小:bytes ≈ b64len * 3/4
54
+ if (dataBase64.length * 3 / 4 > MAX_BYTES) {
55
+ return res.status(413).json({ ok: false, error: 'file_too_large', limitMB: 20 })
56
+ }
57
+ const buf = Buffer.from(dataBase64, 'base64')
58
+ if (buf.length > MAX_BYTES) {
59
+ return res.status(413).json({ ok: false, error: 'file_too_large', limitMB: 20 })
60
+ }
61
+ mkdirSync(uploadDir, { recursive: true })
62
+ const ext = sanitizeExt(filename, mime)
63
+ const localName = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`
64
+ const localPath = join(uploadDir, localName)
65
+ writeFileSync(localPath, buf)
66
+ logger.info?.(`[uploads] saved ${(buf.length / 1024).toFixed(1)}kB → ${localPath}`)
67
+ res.json({ ok: true, path: localPath, fileSize: buf.length, ext })
68
+ } catch (e) {
69
+ logger.warn?.(`[uploads] save failed: ${e.message}`)
70
+ res.status(500).json({ ok: false, error: e.message || 'upload_failed' })
71
+ }
72
+ })
73
+
74
+ router.get('/file', (req, res) => {
75
+ try {
76
+ const raw = String(req.query.path || '')
77
+ if (!raw) return res.status(400).json({ ok: false, error: 'path_required' })
78
+ // Resolve symlinks for both root and file so a symlink inside the upload
79
+ // dir can't escape the sandbox.
80
+ let rootAbs
81
+ try { rootAbs = realpathSync(resolvePath(uploadDir)) + sep } catch {
82
+ // upload dir doesn't exist yet → nothing inside it can possibly be served
83
+ return res.status(404).json({ ok: false, error: 'not_found' })
84
+ }
85
+ let fileAbs
86
+ try { fileAbs = realpathSync(resolvePath(raw)) } catch {
87
+ return res.status(404).json({ ok: false, error: 'not_found' })
88
+ }
89
+ if (!(fileAbs + sep).startsWith(rootAbs)) {
90
+ return res.status(403).json({ ok: false, error: 'forbidden_path' })
91
+ }
92
+ const st = statSync(fileAbs)
93
+ if (!st.isFile()) return res.status(404).json({ ok: false, error: 'not_a_file' })
94
+ // Defensive headers: prevent SVG/HTML uploads from executing scripts when
95
+ // served back. Content-Type is still inferred by sendFile from extension.
96
+ res.set('Content-Disposition', 'inline')
97
+ res.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox")
98
+ res.set('X-Content-Type-Options', 'nosniff')
99
+ return res.sendFile(fileAbs)
100
+ } catch (e) {
101
+ logger.warn?.(`[uploads] serve failed: ${e.message}`)
102
+ res.status(500).json({ ok: false, error: e.message || 'serve_failed' })
103
+ }
104
+ })
105
+
106
+ return router
107
+ }
@@ -0,0 +1,142 @@
1
+ import { Router } from 'express'
2
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
3
+ import { join, resolve, relative } from 'node:path'
4
+
5
+ function walkDir(root, current = root, out = [], maxDepth = 5, depth = 0) {
6
+ if (depth > maxDepth) return out
7
+ let entries = []
8
+ try { entries = readdirSync(current, { withFileTypes: true }) } catch { return out }
9
+ for (const e of entries) {
10
+ if (e.name === '.git') continue
11
+ const abs = join(current, e.name)
12
+ const rel = relative(root, abs)
13
+ if (e.isDirectory()) {
14
+ out.push({ path: rel, type: 'dir' })
15
+ walkDir(root, abs, out, maxDepth, depth + 1)
16
+ } else if (e.isFile()) {
17
+ let size = 0
18
+ try { size = statSync(abs).size } catch {}
19
+ out.push({ path: rel, type: 'file', size })
20
+ }
21
+ }
22
+ return out
23
+ }
24
+
25
+ function isPathSafe(wikiDir, relPath) {
26
+ if (typeof relPath !== 'string' || !relPath) return false
27
+ if (relPath.startsWith('/')) return false
28
+ const abs = resolve(wikiDir, relPath)
29
+ const wikiResolved = resolve(wikiDir)
30
+ return abs === wikiResolved || abs.startsWith(wikiResolved + '/')
31
+ }
32
+
33
+ export function createWikiRouter({ service }) {
34
+ const router = Router()
35
+
36
+ router.get('/status', (_req, res) => {
37
+ try {
38
+ res.json({ ok: true, status: service.status() })
39
+ } catch (e) {
40
+ res.status(500).json({ ok: false, error: e.message })
41
+ }
42
+ })
43
+
44
+ router.get('/pending', (_req, res) => {
45
+ try {
46
+ res.json({ ok: true, list: service.pending() })
47
+ } catch (e) {
48
+ res.status(500).json({ ok: false, error: e.message })
49
+ }
50
+ })
51
+
52
+ router.get('/tree', (_req, res) => {
53
+ try {
54
+ const s = service.status()
55
+ if (!existsSync(s.wikiDir)) {
56
+ res.json({ ok: true, files: [] })
57
+ return
58
+ }
59
+ res.json({ ok: true, files: walkDir(s.wikiDir) })
60
+ } catch (e) {
61
+ res.status(500).json({ ok: false, error: e.message })
62
+ }
63
+ })
64
+
65
+ router.get('/file', (req, res) => {
66
+ try {
67
+ const s = service.status()
68
+ const p = typeof req.query.path === 'string' ? req.query.path : ''
69
+ if (!isPathSafe(s.wikiDir, p)) {
70
+ res.status(400).json({ ok: false, error: 'invalid_path' })
71
+ return
72
+ }
73
+ const abs = resolve(s.wikiDir, p)
74
+ if (!existsSync(abs)) {
75
+ res.status(404).json({ ok: false, error: 'not_found' })
76
+ return
77
+ }
78
+ const st = statSync(abs)
79
+ if (st.isDirectory()) {
80
+ res.status(400).json({ ok: false, error: 'is_directory' })
81
+ return
82
+ }
83
+ if (st.size > 2 * 1024 * 1024) {
84
+ res.status(400).json({ ok: false, error: 'file_too_large' })
85
+ return
86
+ }
87
+ res.json({ ok: true, path: p, content: readFileSync(abs, 'utf8') })
88
+ } catch (e) {
89
+ res.status(500).json({ ok: false, error: e.message })
90
+ }
91
+ })
92
+
93
+ router.post('/run', async (req, res) => {
94
+ try {
95
+ const { todoIds, dryRun = false } = req.body || {}
96
+ if (!Array.isArray(todoIds) || todoIds.length === 0) {
97
+ res.status(400).json({ ok: false, error: 'todoIds must be non-empty array' })
98
+ return
99
+ }
100
+ const result = await service.runOnce({ todoIds, dryRun: !!dryRun })
101
+ res.json({ ok: true, ...result })
102
+ } catch (e) {
103
+ const code = /already running/i.test(e.message) ? 409 : 500
104
+ res.status(code).json({ ok: false, error: e.message })
105
+ }
106
+ })
107
+
108
+ router.post('/init', async (_req, res) => {
109
+ try {
110
+ const r = await service.init()
111
+ res.json({ ok: true, ...r })
112
+ } catch (e) {
113
+ res.status(500).json({ ok: false, error: e.message })
114
+ }
115
+ })
116
+
117
+ router.post('/open', async (_req, res) => {
118
+ try {
119
+ const s = service.status()
120
+ if (!s.wikiDir || !existsSync(s.wikiDir)) {
121
+ res.status(404).json({ ok: false, error: 'wiki_dir_missing' })
122
+ return
123
+ }
124
+ const { default: open } = await import('open')
125
+ await open(s.wikiDir)
126
+ res.json({ ok: true, wikiDir: s.wikiDir })
127
+ } catch (e) {
128
+ res.status(500).json({ ok: false, error: e.message })
129
+ }
130
+ })
131
+
132
+ router.get('/runs', (req, res) => {
133
+ try {
134
+ const limit = Math.max(1, Math.min(200, Number(req.query.limit) || 20))
135
+ res.json({ ok: true, list: service.listRuns(limit) })
136
+ } catch (e) {
137
+ res.status(500).json({ ok: false, error: e.message })
138
+ }
139
+ })
140
+
141
+ return router
142
+ }
@@ -0,0 +1,209 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /**
5
+ * 为 todos / comments / ai_sessions / wiki 建 4 张 FTS5 虚拟表并装同步触发器。
6
+ *
7
+ * 设计:
8
+ * - todos.id / comments.id 是 TEXT UUID,FTS5 要求 INTEGER rowid,所以我们用
9
+ * **standalone FTS5**(不绑 content='<base>'),用 UNINDEXED 的 todo_id / comment_id
10
+ * 字段把 UUID 存进去,触发器手动同步。
11
+ * - ai_sessions 存在 todos.ai_session JSON 列里,用 UPDATE trigger + JSON_EACH 把
12
+ * 数组展开成多行 index。
13
+ * - wiki_fts 没有触发器:由应用层在 wiki run 结束 / 服务启动时用 rebuildWikiFts 刷新。
14
+ *
15
+ * 所有表/触发器都是 `IF NOT EXISTS`,重复调用安全。
16
+ */
17
+ export function initFtsTables(dbHandle) {
18
+ dbHandle.exec(`
19
+ CREATE VIRTUAL TABLE IF NOT EXISTS todos_fts USING fts5(
20
+ todo_id UNINDEXED,
21
+ title, description,
22
+ tokenize='unicode61 remove_diacritics 2'
23
+ );
24
+
25
+ CREATE VIRTUAL TABLE IF NOT EXISTS comments_fts USING fts5(
26
+ comment_id UNINDEXED,
27
+ todo_id UNINDEXED,
28
+ body,
29
+ tokenize='unicode61 remove_diacritics 2'
30
+ );
31
+
32
+ CREATE VIRTUAL TABLE IF NOT EXISTS ai_sessions_fts USING fts5(
33
+ todo_id UNINDEXED,
34
+ session_id UNINDEXED,
35
+ label, command, native_session_id,
36
+ tokenize='unicode61 remove_diacritics 2'
37
+ );
38
+
39
+ CREATE VIRTUAL TABLE IF NOT EXISTS wiki_fts USING fts5(
40
+ todo_id UNINDEXED,
41
+ body,
42
+ tokenize='unicode61 remove_diacritics 2'
43
+ );
44
+ `)
45
+
46
+ // ── todos_fts 触发器 ──
47
+ dbHandle.exec(`
48
+ CREATE TRIGGER IF NOT EXISTS todos_fts_ai AFTER INSERT ON todos BEGIN
49
+ INSERT INTO todos_fts(todo_id, title, description)
50
+ VALUES (new.id, COALESCE(new.title, ''), COALESCE(new.description, ''));
51
+ END;
52
+
53
+ CREATE TRIGGER IF NOT EXISTS todos_fts_ad AFTER DELETE ON todos BEGIN
54
+ DELETE FROM todos_fts WHERE todo_id = old.id;
55
+ DELETE FROM ai_sessions_fts WHERE todo_id = old.id;
56
+ END;
57
+
58
+ CREATE TRIGGER IF NOT EXISTS todos_fts_au AFTER UPDATE OF title, description ON todos BEGIN
59
+ DELETE FROM todos_fts WHERE todo_id = old.id;
60
+ INSERT INTO todos_fts(todo_id, title, description)
61
+ VALUES (new.id, COALESCE(new.title, ''), COALESCE(new.description, ''));
62
+ END;
63
+
64
+ CREATE TRIGGER IF NOT EXISTS ai_sessions_fts_au AFTER UPDATE OF ai_session ON todos BEGIN
65
+ DELETE FROM ai_sessions_fts WHERE todo_id = old.id;
66
+ INSERT INTO ai_sessions_fts(todo_id, session_id, label, command, native_session_id)
67
+ SELECT old.id,
68
+ COALESCE(json_extract(value, '$.sessionId'), ''),
69
+ COALESCE(json_extract(value, '$.label'), ''),
70
+ COALESCE(json_extract(value, '$.command'), ''),
71
+ COALESCE(json_extract(value, '$.nativeSessionId'), '')
72
+ FROM json_each(COALESCE(new.ai_session, '[]'));
73
+ END;
74
+
75
+ CREATE TRIGGER IF NOT EXISTS ai_sessions_fts_ai AFTER INSERT ON todos BEGIN
76
+ INSERT INTO ai_sessions_fts(todo_id, session_id, label, command, native_session_id)
77
+ SELECT new.id,
78
+ COALESCE(json_extract(value, '$.sessionId'), ''),
79
+ COALESCE(json_extract(value, '$.label'), ''),
80
+ COALESCE(json_extract(value, '$.command'), ''),
81
+ COALESCE(json_extract(value, '$.nativeSessionId'), '')
82
+ FROM json_each(COALESCE(new.ai_session, '[]'));
83
+ END;
84
+ `)
85
+
86
+ // ── comments_fts 触发器 ──
87
+ dbHandle.exec(`
88
+ CREATE TRIGGER IF NOT EXISTS comments_fts_ai AFTER INSERT ON comments BEGIN
89
+ INSERT INTO comments_fts(comment_id, todo_id, body)
90
+ VALUES (new.id, new.todo_id, COALESCE(new.content, ''));
91
+ END;
92
+
93
+ CREATE TRIGGER IF NOT EXISTS comments_fts_ad AFTER DELETE ON comments BEGIN
94
+ DELETE FROM comments_fts WHERE comment_id = old.id;
95
+ END;
96
+
97
+ CREATE TRIGGER IF NOT EXISTS comments_fts_au AFTER UPDATE ON comments BEGIN
98
+ DELETE FROM comments_fts WHERE comment_id = old.id;
99
+ INSERT INTO comments_fts(comment_id, todo_id, body)
100
+ VALUES (new.id, new.todo_id, COALESCE(new.content, ''));
101
+ END;
102
+ `)
103
+ }
104
+
105
+ /**
106
+ * 全量重建 todos_fts / comments_fts / ai_sessions_fts。
107
+ * 用于启动一致性自检或手动 reindex。
108
+ */
109
+ export function rebuildTodosFts(dbHandle) {
110
+ dbHandle.exec(`DELETE FROM todos_fts`)
111
+ dbHandle.exec(`
112
+ INSERT INTO todos_fts(todo_id, title, description)
113
+ SELECT id, COALESCE(title, ''), COALESCE(description, '') FROM todos
114
+ `)
115
+ }
116
+
117
+ export function rebuildCommentsFts(dbHandle) {
118
+ dbHandle.exec(`DELETE FROM comments_fts`)
119
+ dbHandle.exec(`
120
+ INSERT INTO comments_fts(comment_id, todo_id, body)
121
+ SELECT id, todo_id, COALESCE(content, '') FROM comments
122
+ `)
123
+ }
124
+
125
+ export function rebuildAiSessionsFts(dbHandle) {
126
+ dbHandle.exec(`DELETE FROM ai_sessions_fts`)
127
+ dbHandle.exec(`
128
+ INSERT INTO ai_sessions_fts(todo_id, session_id, label, command, native_session_id)
129
+ SELECT t.id,
130
+ COALESCE(json_extract(j.value, '$.sessionId'), ''),
131
+ COALESCE(json_extract(j.value, '$.label'), ''),
132
+ COALESCE(json_extract(j.value, '$.command'), ''),
133
+ COALESCE(json_extract(j.value, '$.nativeSessionId'), '')
134
+ FROM todos t,
135
+ json_each(COALESCE(t.ai_session, '[]')) j
136
+ `)
137
+ }
138
+
139
+ /**
140
+ * 重新读取 wikiDir 下所有 .md 文件,按文件名里的 todo_id 建索引。
141
+ * 命名约定:文件名格式 todo-<id>.md(或 wikiService 决定的任何格式)。
142
+ * 这里用宽松匹配:todo_id = 去掉前缀/后缀之后的部分,若 wiki 还没存在就跳过。
143
+ *
144
+ * 如果 wikiDir 不存在 / 读取失败,静默跳过(没有 wiki 不影响搜索其他 scope)。
145
+ */
146
+ export function rebuildWikiFts(dbHandle, { wikiDir } = {}) {
147
+ dbHandle.exec(`DELETE FROM wiki_fts`)
148
+ if (!wikiDir || !existsSync(wikiDir)) return { indexed: 0 }
149
+ let indexed = 0
150
+ const stmt = dbHandle.prepare(`INSERT INTO wiki_fts(todo_id, body) VALUES (?, ?)`)
151
+ const insertMany = dbHandle.transaction((entries) => {
152
+ for (const { todoId, body } of entries) {
153
+ stmt.run(todoId, body)
154
+ indexed += 1
155
+ }
156
+ })
157
+ const entries = []
158
+ try {
159
+ for (const entry of readdirSync(wikiDir, { withFileTypes: true })) {
160
+ if (!entry.isFile()) continue
161
+ if (!/^(todo-)?(.+)\.md$/i.test(entry.name)) continue
162
+ const match = entry.name.match(/^(?:todo-)?(.+?)\.md$/i)
163
+ const todoId = match?.[1]
164
+ if (!todoId) continue
165
+ const fullPath = join(wikiDir, entry.name)
166
+ try {
167
+ const st = statSync(fullPath)
168
+ if (!st.isFile()) continue
169
+ const body = readFileSync(fullPath, 'utf8')
170
+ if (body.trim()) entries.push({ todoId, body })
171
+ } catch {
172
+ // ignore unreadable file
173
+ }
174
+ }
175
+ } catch {
176
+ return { indexed: 0 }
177
+ }
178
+ if (entries.length) insertMany(entries)
179
+ return { indexed }
180
+ }
181
+
182
+ /**
183
+ * 启动时检查 FTS 行数与源表一致(粗略),不一致就全量重建。
184
+ */
185
+ export function ensureFtsConsistency(dbHandle, { wikiDir } = {}) {
186
+ const out = { rebuilt: [] }
187
+ const count = (sql) => dbHandle.prepare(sql).get()?.n ?? 0
188
+ if (count(`SELECT COUNT(*) AS n FROM todos_fts`) !== count(`SELECT COUNT(*) AS n FROM todos`)) {
189
+ rebuildTodosFts(dbHandle)
190
+ out.rebuilt.push('todos_fts')
191
+ }
192
+ if (count(`SELECT COUNT(*) AS n FROM comments_fts`) !== count(`SELECT COUNT(*) AS n FROM comments`)) {
193
+ rebuildCommentsFts(dbHandle)
194
+ out.rebuilt.push('comments_fts')
195
+ }
196
+ // ai_sessions 是 JSON 展开后的条数;如果数量不一致(可能上次没跑到触发器)就重建
197
+ const aiSessionsExpected = dbHandle.prepare(`
198
+ SELECT COUNT(*) AS n
199
+ FROM todos t, json_each(COALESCE(t.ai_session, '[]')) j
200
+ `).get()?.n ?? 0
201
+ if (count(`SELECT COUNT(*) AS n FROM ai_sessions_fts`) !== aiSessionsExpected) {
202
+ rebuildAiSessionsFts(dbHandle)
203
+ out.rebuilt.push('ai_sessions_fts')
204
+ }
205
+ // wiki 每次启动都重建(对比文件数成本高、直接 rebuild 便宜)
206
+ rebuildWikiFts(dbHandle, { wikiDir })
207
+ out.rebuilt.push('wiki_fts')
208
+ return out
209
+ }