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
package/src/db.js ADDED
@@ -0,0 +1,1503 @@
1
+ import Database from 'better-sqlite3'
2
+ import { randomUUID } from 'node:crypto'
3
+
4
+ const SCHEMA = `
5
+ CREATE TABLE IF NOT EXISTS todos (
6
+ id TEXT PRIMARY KEY,
7
+ parent_id TEXT,
8
+ title TEXT NOT NULL,
9
+ description TEXT NOT NULL DEFAULT '',
10
+ quadrant INTEGER NOT NULL CHECK(quadrant IN (1,2,3,4)),
11
+ status TEXT NOT NULL DEFAULT 'todo',
12
+ due_date INTEGER,
13
+ work_dir TEXT,
14
+ sort_order REAL NOT NULL,
15
+ ai_session TEXT,
16
+ completed_at INTEGER,
17
+ created_at INTEGER NOT NULL,
18
+ updated_at INTEGER NOT NULL
19
+ );
20
+ CREATE INDEX IF NOT EXISTS idx_todos_quadrant_sort ON todos(quadrant, sort_order);
21
+ CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(status);
22
+
23
+ CREATE TABLE IF NOT EXISTS comments (
24
+ id TEXT PRIMARY KEY,
25
+ todo_id TEXT NOT NULL,
26
+ content TEXT NOT NULL,
27
+ created_at INTEGER NOT NULL,
28
+ FOREIGN KEY (todo_id) REFERENCES todos(id) ON DELETE CASCADE
29
+ );
30
+ CREATE INDEX IF NOT EXISTS idx_comments_todo ON comments(todo_id, created_at);
31
+
32
+ CREATE TABLE IF NOT EXISTS ai_session_log (
33
+ id TEXT PRIMARY KEY,
34
+ todo_id TEXT NOT NULL,
35
+ tool TEXT NOT NULL,
36
+ quadrant INTEGER NOT NULL,
37
+ status TEXT NOT NULL,
38
+ exit_code INTEGER,
39
+ started_at INTEGER NOT NULL,
40
+ completed_at INTEGER NOT NULL,
41
+ duration_ms INTEGER NOT NULL
42
+ );
43
+ CREATE INDEX IF NOT EXISTS idx_ail_completed_at ON ai_session_log(completed_at);
44
+ CREATE INDEX IF NOT EXISTS idx_ail_tool ON ai_session_log(tool);
45
+ CREATE INDEX IF NOT EXISTS idx_ail_quadrant ON ai_session_log(quadrant);
46
+
47
+ CREATE TABLE IF NOT EXISTS transcript_files (
48
+ id INTEGER PRIMARY KEY,
49
+ tool TEXT NOT NULL,
50
+ native_id TEXT,
51
+ cwd TEXT,
52
+ jsonl_path TEXT NOT NULL UNIQUE,
53
+ size INTEGER NOT NULL,
54
+ mtime INTEGER NOT NULL,
55
+ started_at INTEGER,
56
+ ended_at INTEGER,
57
+ first_user_prompt TEXT,
58
+ turn_count INTEGER NOT NULL DEFAULT 0,
59
+ input_tokens INTEGER,
60
+ output_tokens INTEGER,
61
+ cache_read_tokens INTEGER,
62
+ cache_creation_tokens INTEGER,
63
+ primary_model TEXT,
64
+ active_ms INTEGER,
65
+ bound_todo_id TEXT,
66
+ indexed_at INTEGER NOT NULL
67
+ );
68
+ CREATE INDEX IF NOT EXISTS idx_tf_native ON transcript_files(native_id);
69
+ CREATE INDEX IF NOT EXISTS idx_tf_bound ON transcript_files(bound_todo_id);
70
+ CREATE INDEX IF NOT EXISTS idx_tf_tool_cwd_started ON transcript_files(tool, cwd, started_at);
71
+
72
+ CREATE TABLE IF NOT EXISTS recurring_rules (
73
+ id TEXT PRIMARY KEY,
74
+ title TEXT NOT NULL,
75
+ description TEXT NOT NULL DEFAULT '',
76
+ quadrant INTEGER NOT NULL CHECK(quadrant IN (1,2,3,4)),
77
+ work_dir TEXT,
78
+ brainstorm INTEGER NOT NULL DEFAULT 0,
79
+ applied_template_ids TEXT,
80
+ subtodos TEXT,
81
+ frequency TEXT NOT NULL CHECK(frequency IN ('daily','weekly','monthly')),
82
+ weekdays TEXT,
83
+ month_days TEXT,
84
+ active INTEGER NOT NULL DEFAULT 1,
85
+ last_generated_date TEXT,
86
+ created_at INTEGER NOT NULL,
87
+ updated_at INTEGER NOT NULL
88
+ );
89
+ CREATE INDEX IF NOT EXISTS idx_rr_active ON recurring_rules(active);
90
+
91
+ CREATE TABLE IF NOT EXISTS prompt_templates (
92
+ id TEXT PRIMARY KEY,
93
+ name TEXT NOT NULL,
94
+ description TEXT NOT NULL DEFAULT '',
95
+ content TEXT NOT NULL,
96
+ builtin INTEGER NOT NULL DEFAULT 0,
97
+ sort_order REAL NOT NULL DEFAULT 0,
98
+ created_at INTEGER NOT NULL,
99
+ updated_at INTEGER NOT NULL
100
+ );
101
+ CREATE INDEX IF NOT EXISTS idx_pt_sort ON prompt_templates(sort_order);
102
+
103
+ CREATE TABLE IF NOT EXISTS wiki_runs (
104
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
105
+ started_at INTEGER NOT NULL,
106
+ completed_at INTEGER,
107
+ todo_count INTEGER NOT NULL DEFAULT 0,
108
+ dry_run INTEGER NOT NULL DEFAULT 0,
109
+ exit_code INTEGER,
110
+ error TEXT,
111
+ note TEXT
112
+ );
113
+ CREATE INDEX IF NOT EXISTS idx_wiki_runs_started ON wiki_runs(started_at DESC);
114
+
115
+ CREATE TABLE IF NOT EXISTS wiki_todo_coverage (
116
+ wiki_run_id INTEGER NOT NULL,
117
+ todo_id TEXT NOT NULL,
118
+ source_path TEXT,
119
+ llm_applied INTEGER NOT NULL DEFAULT 0,
120
+ PRIMARY KEY (wiki_run_id, todo_id)
121
+ );
122
+ CREATE INDEX IF NOT EXISTS idx_wiki_cov_todo ON wiki_todo_coverage(todo_id, llm_applied);
123
+
124
+ CREATE TABLE IF NOT EXISTS pending_questions (
125
+ ticket TEXT PRIMARY KEY,
126
+ session_id TEXT NOT NULL,
127
+ todo_id TEXT,
128
+ question TEXT NOT NULL,
129
+ options_json TEXT NOT NULL,
130
+ status TEXT NOT NULL DEFAULT 'pending',
131
+ answer_text TEXT,
132
+ chosen_index INTEGER,
133
+ created_at INTEGER NOT NULL,
134
+ answered_at INTEGER,
135
+ timeout_ms INTEGER NOT NULL
136
+ );
137
+ CREATE INDEX IF NOT EXISTS idx_pq_status_created ON pending_questions(status, created_at DESC);
138
+ CREATE INDEX IF NOT EXISTS idx_pq_session ON pending_questions(session_id);
139
+ `
140
+
141
+ const UNFINISHED = ['todo', 'ai_running', 'ai_pending', 'ai_done']
142
+
143
+ function normalizeAiSessions(value) {
144
+ if (!value) return []
145
+ if (Array.isArray(value)) return value.filter(Boolean)
146
+ return [value]
147
+ }
148
+
149
+ function currentAiSession(aiSessions) {
150
+ if (!aiSessions.length) return null
151
+ return aiSessions.find(s => s?.status === 'running' || s?.status === 'idle' || s?.status === 'pending_confirm') || aiSessions[0]
152
+ }
153
+
154
+ function rowToTodo(row) {
155
+ if (!row) return null
156
+ const aiSessions = normalizeAiSessions(row.ai_session ? JSON.parse(row.ai_session) : null)
157
+ return {
158
+ id: row.id,
159
+ parentId: row.parent_id ?? null,
160
+ title: row.title,
161
+ description: row.description,
162
+ quadrant: row.quadrant,
163
+ status: row.status,
164
+ dueDate: row.due_date,
165
+ workDir: row.work_dir ?? null,
166
+ brainstorm: !!row.brainstorm,
167
+ appliedTemplateIds: row.applied_template_ids ? (() => { try { return JSON.parse(row.applied_template_ids) } catch { return [] } })() : [],
168
+ sortOrder: row.sort_order,
169
+ aiSession: currentAiSession(aiSessions),
170
+ aiSessions,
171
+ recurringRuleId: row.recurring_rule_id ?? null,
172
+ instanceDate: row.instance_date ?? null,
173
+ completedAt: row.completed_at ?? null,
174
+ stageTag: row.stage_tag ?? null,
175
+ archivedAt: row.archived_at ?? null,
176
+ createdAt: row.created_at,
177
+ updatedAt: row.updated_at,
178
+ }
179
+ }
180
+
181
+ function todayStr(now = Date.now()) {
182
+ const d = new Date(now)
183
+ const y = d.getFullYear()
184
+ const m = String(d.getMonth() + 1).padStart(2, '0')
185
+ const day = String(d.getDate()).padStart(2, '0')
186
+ return `${y}-${m}-${day}`
187
+ }
188
+
189
+ function endOfDayMs(now = Date.now()) {
190
+ const d = new Date(now)
191
+ d.setHours(23, 59, 59, 999)
192
+ return d.getTime()
193
+ }
194
+
195
+ function parseJsonArray(value) {
196
+ if (!value) return null
197
+ try {
198
+ const parsed = JSON.parse(value)
199
+ return Array.isArray(parsed) ? parsed : null
200
+ } catch { return null }
201
+ }
202
+
203
+ function rowToRule(row) {
204
+ if (!row) return null
205
+ return {
206
+ id: row.id,
207
+ title: row.title,
208
+ description: row.description,
209
+ quadrant: row.quadrant,
210
+ workDir: row.work_dir ?? null,
211
+ brainstorm: !!row.brainstorm,
212
+ appliedTemplateIds: parseJsonArray(row.applied_template_ids) || [],
213
+ subtodos: parseJsonArray(row.subtodos) || [],
214
+ frequency: row.frequency,
215
+ weekdays: parseJsonArray(row.weekdays) || [],
216
+ monthDays: parseJsonArray(row.month_days) || [],
217
+ active: !!row.active,
218
+ lastGeneratedDate: row.last_generated_date ?? null,
219
+ createdAt: row.created_at,
220
+ updatedAt: row.updated_at,
221
+ }
222
+ }
223
+
224
+ function ruleShouldProduceOn(rule, dateStr) {
225
+ const d = new Date(`${dateStr}T12:00:00`)
226
+ if (rule.frequency === 'daily') return true
227
+ if (rule.frequency === 'weekly') {
228
+ const wd = d.getDay()
229
+ return (rule.weekdays || []).includes(wd)
230
+ }
231
+ if (rule.frequency === 'monthly') {
232
+ const dom = d.getDate()
233
+ return (rule.monthDays || []).includes(dom)
234
+ }
235
+ return false
236
+ }
237
+
238
+ export function openDb(file = ':memory:') {
239
+ const db = new Database(file)
240
+ db.pragma('journal_mode = WAL')
241
+ db.exec(SCHEMA)
242
+
243
+ let ftsAvailable = false
244
+ try {
245
+ const existing = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='transcript_fts'`).get()
246
+ const usesTrigram = existing && /tokenize\s*=\s*['"]?trigram/i.test(existing.sql || '')
247
+ if (existing && !usesTrigram) {
248
+ db.exec(`DROP TABLE transcript_fts`)
249
+ // 旧 tokenizer 下的 FTS 已清空;把 transcript_files.size 置 -1 让下一次 scan 视为脏,触发重建
250
+ try { db.exec(`UPDATE transcript_files SET size = -1`) } catch {}
251
+ }
252
+ db.exec(`
253
+ CREATE VIRTUAL TABLE IF NOT EXISTS transcript_fts USING fts5(
254
+ content,
255
+ role UNINDEXED,
256
+ file_id UNINDEXED,
257
+ tokenize = "trigram"
258
+ );
259
+ `)
260
+ ftsAvailable = true
261
+ } catch (e) {
262
+ ftsAvailable = false
263
+ }
264
+
265
+ const columns = db.prepare(`PRAGMA table_info(todos)`).all()
266
+ if (!columns.some(col => col.name === 'parent_id')) {
267
+ db.exec(`ALTER TABLE todos ADD COLUMN parent_id TEXT`)
268
+ }
269
+ if (!columns.some(col => col.name === 'work_dir')) {
270
+ db.exec(`ALTER TABLE todos ADD COLUMN work_dir TEXT`)
271
+ }
272
+ if (!columns.some(col => col.name === 'brainstorm')) {
273
+ db.exec(`ALTER TABLE todos ADD COLUMN brainstorm INTEGER NOT NULL DEFAULT 0`)
274
+ }
275
+ if (!columns.some(col => col.name === 'applied_template_ids')) {
276
+ db.exec(`ALTER TABLE todos ADD COLUMN applied_template_ids TEXT`)
277
+ }
278
+ if (!columns.some(col => col.name === 'recurring_rule_id')) {
279
+ db.exec(`ALTER TABLE todos ADD COLUMN recurring_rule_id TEXT`)
280
+ }
281
+ if (!columns.some(col => col.name === 'instance_date')) {
282
+ db.exec(`ALTER TABLE todos ADD COLUMN instance_date TEXT`)
283
+ }
284
+ if (!columns.some(col => col.name === 'completed_at')) {
285
+ db.exec(`ALTER TABLE todos ADD COLUMN completed_at INTEGER`)
286
+ // 一次性回填:已完成的旧行用 updated_at 作为近似完成时间
287
+ db.exec(`UPDATE todos SET completed_at = updated_at WHERE status = 'done' AND completed_at IS NULL`)
288
+ }
289
+ if (!columns.some(col => col.name === 'archived_at')) {
290
+ db.exec(`ALTER TABLE todos ADD COLUMN archived_at INTEGER`)
291
+ }
292
+ if (!columns.some(col => col.name === 'stage_tag')) {
293
+ db.exec(`ALTER TABLE todos ADD COLUMN stage_tag TEXT`)
294
+ }
295
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_todos_recurring ON todos(recurring_rule_id, instance_date)`)
296
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_todos_parent_sort ON todos(parent_id, sort_order)`)
297
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_todos_quad_parent_sort ON todos(quadrant, parent_id, sort_order)`)
298
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_todos_completed_at ON todos(completed_at)`)
299
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_todos_archived_at ON todos(archived_at)`)
300
+
301
+ // Phase-out: pipeline feature removed in 2026-05-13 cleanup.
302
+ // Drop the two tables if they exist (idempotent — no-op for fresh installs).
303
+ db.exec(`DROP TABLE IF EXISTS pipeline_runs;`)
304
+ db.exec(`DROP TABLE IF EXISTS pipeline_templates;`)
305
+
306
+ const tfCols = db.prepare(`PRAGMA table_info(transcript_files)`).all().map(c => c.name)
307
+ for (const [name, type] of [
308
+ ['input_tokens', 'INTEGER'],
309
+ ['output_tokens', 'INTEGER'],
310
+ ['cache_read_tokens', 'INTEGER'],
311
+ ['cache_creation_tokens', 'INTEGER'],
312
+ ['primary_model', 'TEXT'],
313
+ ['active_ms', 'INTEGER'],
314
+ ]) {
315
+ if (!tfCols.includes(name)) db.exec(`ALTER TABLE transcript_files ADD COLUMN ${name} ${type}`)
316
+ }
317
+
318
+ const stmts = {
319
+ insert: db.prepare(`
320
+ INSERT INTO todos (id, parent_id, title, description, quadrant, status, due_date, work_dir, brainstorm, applied_template_ids, sort_order, ai_session, recurring_rule_id, instance_date, completed_at, created_at, updated_at)
321
+ VALUES (@id, @parent_id, @title, @description, @quadrant, @status, @due_date, @work_dir, @brainstorm, @applied_template_ids, @sort_order, @ai_session, @recurring_rule_id, @instance_date, @completed_at, @created_at, @updated_at)
322
+ `),
323
+ getById: db.prepare(`SELECT * FROM todos WHERE id = ?`),
324
+ listChildrenByParent: db.prepare(`SELECT id FROM todos WHERE parent_id = ? ORDER BY sort_order ASC, created_at ASC`),
325
+ maxSortInQuadrant: db.prepare(`SELECT MAX(sort_order) AS m FROM todos WHERE quadrant = ? AND parent_id IS NULL`),
326
+ maxSortInParent: db.prepare(`SELECT MAX(sort_order) AS m FROM todos WHERE parent_id = ?`),
327
+ deleteById: db.prepare(`DELETE FROM todos WHERE id = ?`),
328
+ }
329
+
330
+ function nextSortOrder(quadrant, parentId = null) {
331
+ const row = parentId
332
+ ? stmts.maxSortInParent.get(parentId)
333
+ : stmts.maxSortInQuadrant.get(quadrant)
334
+ const m = row?.m
335
+ return (m == null ? 0 : m) + 1024
336
+ }
337
+
338
+ function resolveParent(parentId) {
339
+ if (parentId == null) return null
340
+ const parent = rowToTodo(stmts.getById.get(parentId))
341
+ if (!parent) throw new Error('parent_not_found')
342
+ if (parent.parentId) throw new Error('nested_subtodo_not_allowed')
343
+ return parent
344
+ }
345
+
346
+ function createTodo(data) {
347
+ const now = Date.now()
348
+ const parent = resolveParent(data.parentId ?? null)
349
+ const quadrant = parent ? parent.quadrant : (Number(data.quadrant) || 4)
350
+ const status = data.status || 'todo'
351
+ const row = {
352
+ id: randomUUID(),
353
+ parent_id: parent?.id ?? null,
354
+ title: data.title,
355
+ description: data.description || '',
356
+ quadrant,
357
+ status,
358
+ due_date: data.dueDate ?? null,
359
+ work_dir: data.workDir ?? null,
360
+ brainstorm: data.brainstorm ? 1 : 0,
361
+ applied_template_ids: Array.isArray(data.appliedTemplateIds) ? JSON.stringify(data.appliedTemplateIds) : null,
362
+ sort_order: data.sortOrder != null ? data.sortOrder : nextSortOrder(quadrant, parent?.id ?? null),
363
+ ai_session: JSON.stringify(normalizeAiSessions(data.aiSessions ?? data.aiSession)),
364
+ recurring_rule_id: data.recurringRuleId ?? null,
365
+ instance_date: data.instanceDate ?? null,
366
+ completed_at: status === 'done' ? now : null,
367
+ created_at: now,
368
+ updated_at: now,
369
+ }
370
+ stmts.insert.run(row)
371
+ return rowToTodo(stmts.getById.get(row.id))
372
+ }
373
+
374
+ function getTodo(id) {
375
+ return rowToTodo(stmts.getById.get(id))
376
+ }
377
+
378
+ function updateTodo(id, patch) {
379
+ const existing = rowToTodo(stmts.getById.get(id))
380
+ if (!existing) return null
381
+ const fields = []
382
+ const bind = { id }
383
+ const map = {
384
+ title: 'title',
385
+ description: 'description',
386
+ quadrant: 'quadrant',
387
+ status: 'status',
388
+ dueDate: 'due_date',
389
+ workDir: 'work_dir',
390
+ brainstorm: 'brainstorm',
391
+ sortOrder: 'sort_order',
392
+ stageTag: 'stage_tag',
393
+ }
394
+
395
+ let nextParentId = existing.parentId
396
+ if (patch.parentId !== undefined) {
397
+ nextParentId = patch.parentId
398
+ }
399
+ const parent = resolveParent(nextParentId)
400
+ if (parent && parent.id === id) throw new Error('parent_cycle')
401
+ const nextQuadrant = parent ? parent.quadrant : (patch.quadrant !== undefined ? Number(patch.quadrant) || 4 : existing.quadrant)
402
+ if (parent && patch.quadrant !== undefined && parent.quadrant !== nextQuadrant) {
403
+ throw new Error('parent_quadrant_mismatch')
404
+ }
405
+
406
+ for (const [k, col] of Object.entries(map)) {
407
+ if (patch[k] !== undefined) {
408
+ fields.push(`${col} = @${col}`)
409
+ bind[col] = k === 'brainstorm' ? (patch[k] ? 1 : 0) : patch[k]
410
+ }
411
+ }
412
+ if (parent && patch.quadrant === undefined && existing.quadrant !== parent.quadrant) {
413
+ fields.push(`quadrant = @quadrant`)
414
+ bind.quadrant = parent.quadrant
415
+ }
416
+ if (patch.parentId !== undefined) {
417
+ fields.push(`parent_id = @parent_id`)
418
+ bind.parent_id = parent?.id ?? null
419
+ }
420
+ if (patch.appliedTemplateIds !== undefined) {
421
+ fields.push(`applied_template_ids = @applied_template_ids`)
422
+ bind.applied_template_ids = Array.isArray(patch.appliedTemplateIds) ? JSON.stringify(patch.appliedTemplateIds) : null
423
+ }
424
+ if (patch.aiSession !== undefined) {
425
+ const sessions = patch.aiSession === null ? [] : normalizeAiSessions(patch.aiSession)
426
+ fields.push(`ai_session = @ai_session`)
427
+ bind.ai_session = JSON.stringify(sessions)
428
+ }
429
+ if (patch.aiSessions !== undefined) {
430
+ fields.push(`ai_session = @ai_session`)
431
+ bind.ai_session = JSON.stringify(normalizeAiSessions(patch.aiSessions))
432
+ }
433
+ const now = Date.now()
434
+ if (patch.status !== undefined && patch.status !== existing.status) {
435
+ if (patch.status === 'done') {
436
+ fields.push(`completed_at = @completed_at`)
437
+ bind.completed_at = now
438
+ } else if (existing.status === 'done') {
439
+ fields.push(`completed_at = @completed_at`)
440
+ bind.completed_at = null
441
+ }
442
+ }
443
+ if (!fields.length) return existing
444
+ fields.push(`updated_at = @updated_at`)
445
+ bind.updated_at = now
446
+ const sql = `UPDATE todos SET ${fields.join(', ')} WHERE id = @id`
447
+ db.prepare(sql).run(bind)
448
+ if (!existing.parentId && nextQuadrant !== existing.quadrant) {
449
+ db.prepare(`UPDATE todos SET quadrant = ?, updated_at = ? WHERE parent_id = ?`).run(nextQuadrant, now, id)
450
+ }
451
+ return rowToTodo(stmts.getById.get(id))
452
+ }
453
+
454
+ function deleteTodo(id) {
455
+ const children = stmts.listChildrenByParent.all(id)
456
+ for (const child of children) {
457
+ deleteTodo(child.id)
458
+ }
459
+ stmts.deleteById.run(id)
460
+ }
461
+
462
+ function listTodos({ quadrant, status, keyword, archived } = {}) {
463
+ const where = []
464
+ const params = []
465
+ if (quadrant != null) {
466
+ where.push('quadrant = ?')
467
+ params.push(Number(quadrant))
468
+ }
469
+ if (status === 'todo') {
470
+ where.push(`status IN (${UNFINISHED.map(() => '?').join(',')})`)
471
+ params.push(...UNFINISHED)
472
+ } else if (status === 'done') {
473
+ where.push('status = ?')
474
+ params.push('done')
475
+ } else {
476
+ where.push(`status != 'missed'`)
477
+ }
478
+ // archived: undefined|false → 只看未归档;true → 只看已归档;'all' → 都要
479
+ if (archived === true) {
480
+ where.push('archived_at IS NOT NULL')
481
+ } else if (archived === 'all') {
482
+ // no-op, 全部
483
+ } else {
484
+ where.push('archived_at IS NULL')
485
+ }
486
+ const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : ''
487
+ const rows = db.prepare(`
488
+ SELECT * FROM todos
489
+ ${whereSql}
490
+ ORDER BY quadrant ASC, COALESCE(parent_id, id) ASC, CASE WHEN parent_id IS NULL THEN 0 ELSE 1 END ASC, sort_order ASC, created_at ASC
491
+ `).all(...params)
492
+ const todos = rows.map(rowToTodo)
493
+ if (!keyword) return todos
494
+
495
+ const needle = keyword.toLowerCase()
496
+ const byId = new Map(todos.map(todo => [todo.id, todo]))
497
+ const matched = todos.filter(todo => todo.title.toLowerCase().includes(needle))
498
+ const includeIds = new Set(matched.map(todo => todo.id))
499
+ for (const todo of matched) {
500
+ if (todo.parentId) {
501
+ includeIds.add(todo.parentId)
502
+ } else {
503
+ for (const child of todos) {
504
+ if (child.parentId === todo.id) includeIds.add(child.id)
505
+ }
506
+ }
507
+ }
508
+ return todos.filter(todo => includeIds.has(todo.id) || (todo.parentId && includeIds.has(todo.parentId)))
509
+ }
510
+
511
+ function listSubtodosByParent(parentId) {
512
+ if (!parentId) return []
513
+ const rows = db.prepare(
514
+ `SELECT * FROM todos WHERE parent_id = ? ORDER BY sort_order ASC, created_at ASC`
515
+ ).all(parentId)
516
+ return rows.map(rowToTodo)
517
+ }
518
+
519
+ function archiveTodo(id) {
520
+ const existing = stmts.getById.get(id)
521
+ if (!existing) throw new Error('todo_not_found')
522
+ if (existing.archived_at != null) return rowToTodo(existing)
523
+ const now = Date.now()
524
+ db.prepare(`UPDATE todos SET archived_at = ?, updated_at = ? WHERE id = ?`).run(now, now, id)
525
+ return rowToTodo(stmts.getById.get(id))
526
+ }
527
+
528
+ function unarchiveTodo(id) {
529
+ const existing = stmts.getById.get(id)
530
+ if (!existing) throw new Error('todo_not_found')
531
+ if (existing.archived_at == null) return rowToTodo(existing)
532
+ const now = Date.now()
533
+ db.prepare(`UPDATE todos SET archived_at = NULL, updated_at = ? WHERE id = ?`).run(now, id)
534
+ return rowToTodo(stmts.getById.get(id))
535
+ }
536
+
537
+ /**
538
+ * Preview/describe the impact of merging. 不做任何修改。
539
+ * 返回 { targetId, sources[], movedChildren, movedComments, movedSessions, movedSessionLogs,
540
+ * movedCoverage, movedTranscripts, proposedTitle }
541
+ */
542
+ function describeMergeTodos({ targetId, sourceIds, titleStrategy = 'keep_target', manualTitle } = {}) {
543
+ if (!targetId) throw new Error('target_required')
544
+ if (!Array.isArray(sourceIds) || sourceIds.length === 0) throw new Error('sources_required')
545
+ const target = stmts.getById.get(targetId)
546
+ if (!target) throw new Error('target_not_found')
547
+ const uniqueSources = [...new Set(sourceIds)].filter((s) => s && s !== targetId)
548
+ if (uniqueSources.length === 0) throw new Error('sources_required')
549
+ const sources = []
550
+ for (const sid of uniqueSources) {
551
+ const row = stmts.getById.get(sid)
552
+ if (!row) throw new Error(`source_not_found:${sid}`)
553
+ sources.push(row)
554
+ }
555
+ const countOne = (sql, id) => db.prepare(sql).get(id)?.n || 0
556
+ let movedChildren = 0
557
+ let movedComments = 0
558
+ let movedSessions = 0
559
+ let movedSessionLogs = 0
560
+ let movedCoverage = 0
561
+ let movedTranscripts = 0
562
+ for (const src of sources) {
563
+ movedChildren += countOne(`SELECT COUNT(*) AS n FROM todos WHERE parent_id = ?`, src.id)
564
+ movedComments += countOne(`SELECT COUNT(*) AS n FROM comments WHERE todo_id = ?`, src.id)
565
+ movedSessions += normalizeAiSessions(src.ai_session ? JSON.parse(src.ai_session) : null).length
566
+ movedSessionLogs += countOne(`SELECT COUNT(*) AS n FROM ai_session_log WHERE todo_id = ?`, src.id)
567
+ movedCoverage += countOne(`SELECT COUNT(*) AS n FROM wiki_todo_coverage WHERE todo_id = ?`, src.id)
568
+ movedTranscripts += countOne(`SELECT COUNT(*) AS n FROM transcript_files WHERE bound_todo_id = ?`, src.id)
569
+ }
570
+ let proposedTitle = target.title
571
+ if (titleStrategy === 'concat') {
572
+ proposedTitle = [target.title, ...sources.map((s) => s.title)].join(' + ')
573
+ } else if (titleStrategy === 'manual') {
574
+ if (!manualTitle || !String(manualTitle).trim()) throw new Error('manual_title_required')
575
+ proposedTitle = String(manualTitle).trim()
576
+ }
577
+ return {
578
+ targetId,
579
+ target: { id: target.id, title: target.title },
580
+ sources: sources.map((s) => ({ id: s.id, title: s.title })),
581
+ movedChildren,
582
+ movedComments,
583
+ movedSessions,
584
+ movedSessionLogs,
585
+ movedCoverage,
586
+ movedTranscripts,
587
+ proposedTitle,
588
+ }
589
+ }
590
+
591
+ /**
592
+ * 事务化合并:把 sourceIds 的子任务、评论、ai_session JSON、ai_session_log、wiki_coverage、
593
+ * transcript_files 全部迁移到 targetId,然后删除 source todo。
594
+ * titleStrategy: 'keep_target' | 'concat' | 'manual'(manual 需 manualTitle)
595
+ */
596
+ function mergeTodos({ targetId, sourceIds, titleStrategy = 'keep_target', manualTitle } = {}) {
597
+ // 先复用 describe 做校验
598
+ const preview = describeMergeTodos({ targetId, sourceIds, titleStrategy, manualTitle })
599
+ const now = Date.now()
600
+ const run = db.transaction(() => {
601
+ const targetRow = stmts.getById.get(targetId)
602
+ let mergedSessions = normalizeAiSessions(targetRow.ai_session ? JSON.parse(targetRow.ai_session) : null)
603
+ for (const src of preview.sources) {
604
+ db.prepare(`UPDATE todos SET parent_id = ?, updated_at = ? WHERE parent_id = ?`).run(targetId, now, src.id)
605
+ db.prepare(`UPDATE comments SET todo_id = ? WHERE todo_id = ?`).run(targetId, src.id)
606
+ db.prepare(`UPDATE ai_session_log SET todo_id = ? WHERE todo_id = ?`).run(targetId, src.id)
607
+ db.prepare(`UPDATE wiki_todo_coverage SET todo_id = ? WHERE todo_id = ?`).run(targetId, src.id)
608
+ db.prepare(`UPDATE transcript_files SET bound_todo_id = ? WHERE bound_todo_id = ?`).run(targetId, src.id)
609
+ // ai_session JSON 合并
610
+ const srcRow = stmts.getById.get(src.id)
611
+ const srcSessions = normalizeAiSessions(srcRow.ai_session ? JSON.parse(srcRow.ai_session) : null)
612
+ mergedSessions = [...mergedSessions, ...srcSessions]
613
+ stmts.deleteById.run(src.id)
614
+ }
615
+ // 写回合并后的 ai_session + 可能的新标题
616
+ const fields = ['ai_session = ?', 'updated_at = ?']
617
+ const params = [mergedSessions.length ? JSON.stringify(mergedSessions) : null, now]
618
+ if (preview.proposedTitle !== targetRow.title) {
619
+ fields.push('title = ?')
620
+ params.push(preview.proposedTitle)
621
+ }
622
+ params.push(targetId)
623
+ db.prepare(`UPDATE todos SET ${fields.join(', ')} WHERE id = ?`).run(...params)
624
+ })
625
+ run()
626
+ return {
627
+ ...preview,
628
+ ok: true,
629
+ resultingTodo: rowToTodo(stmts.getById.get(targetId)),
630
+ }
631
+ }
632
+
633
+ /**
634
+ * 对一组 todo id 批量 patch。Patch 字段限白名单(quadrant/status/archived/dueDate)。
635
+ * archived: true → 设置 archived_at=now;false → 清空;不传则不改。
636
+ */
637
+ function bulkUpdateTodos({ ids, patch } = {}) {
638
+ if (!Array.isArray(ids) || ids.length === 0) throw new Error('ids_required')
639
+ if (!patch || typeof patch !== 'object') throw new Error('patch_required')
640
+ const allowed = ['quadrant', 'status', 'archived', 'dueDate']
641
+ const keys = Object.keys(patch).filter((k) => allowed.includes(k))
642
+ if (keys.length === 0) throw new Error('patch_empty')
643
+ const now = Date.now()
644
+ const changed = []
645
+ const run = db.transaction(() => {
646
+ for (const id of ids) {
647
+ const existing = stmts.getById.get(id)
648
+ if (!existing) continue
649
+ const sets = []
650
+ const params = []
651
+ if ('quadrant' in patch) {
652
+ sets.push('quadrant = ?')
653
+ params.push(Number(patch.quadrant))
654
+ }
655
+ if ('status' in patch) {
656
+ sets.push('status = ?')
657
+ params.push(String(patch.status))
658
+ if (patch.status === 'done') {
659
+ sets.push('completed_at = ?')
660
+ params.push(now)
661
+ }
662
+ }
663
+ if ('dueDate' in patch) {
664
+ sets.push('due_date = ?')
665
+ params.push(patch.dueDate == null ? null : Number(patch.dueDate))
666
+ }
667
+ if ('archived' in patch) {
668
+ sets.push('archived_at = ?')
669
+ params.push(patch.archived ? now : null)
670
+ }
671
+ sets.push('updated_at = ?')
672
+ params.push(now)
673
+ params.push(id)
674
+ db.prepare(`UPDATE todos SET ${sets.join(', ')} WHERE id = ?`).run(...params)
675
+ changed.push(id)
676
+ }
677
+ })
678
+ run()
679
+ return { changedIds: changed, count: changed.length }
680
+ }
681
+
682
+ function listCompletedTodos({ since, until }) {
683
+ const rows = db.prepare(`
684
+ SELECT * FROM todos
685
+ WHERE status = 'done'
686
+ AND completed_at IS NOT NULL
687
+ AND completed_at >= ?
688
+ AND completed_at < ?
689
+ ORDER BY completed_at DESC
690
+ `).all(Number(since), Number(until))
691
+ return rows.map(rowToTodo)
692
+ }
693
+
694
+ function countMissedInRange({ since, until }) {
695
+ // 循环任务过期:status='missed',在 sweepRecurring 里用 updated_at 标记时间
696
+ const row = db.prepare(`
697
+ SELECT COUNT(*) AS n FROM todos
698
+ WHERE status = 'missed'
699
+ AND updated_at >= ?
700
+ AND updated_at < ?
701
+ `).get(Number(since), Number(until))
702
+ return row?.n || 0
703
+ }
704
+
705
+ const commentStmts = {
706
+ insert: db.prepare(`INSERT INTO comments (id, todo_id, content, created_at) VALUES (@id, @todo_id, @content, @created_at)`),
707
+ listByTodo: db.prepare(`SELECT * FROM comments WHERE todo_id = ? ORDER BY created_at ASC`),
708
+ deleteById: db.prepare(`DELETE FROM comments WHERE id = ?`),
709
+ getById: db.prepare(`SELECT * FROM comments WHERE id = ?`),
710
+ }
711
+
712
+ function addComment(todoId, content) {
713
+ const row = {
714
+ id: randomUUID(),
715
+ todo_id: todoId,
716
+ content,
717
+ created_at: Date.now(),
718
+ }
719
+ commentStmts.insert.run(row)
720
+ return { id: row.id, todoId: row.todo_id, content: row.content, createdAt: row.created_at }
721
+ }
722
+
723
+ function listComments(todoId) {
724
+ return commentStmts.listByTodo.all(todoId).map(r => ({
725
+ id: r.id,
726
+ todoId: r.todo_id,
727
+ content: r.content,
728
+ createdAt: r.created_at,
729
+ }))
730
+ }
731
+
732
+ function deleteComment(id) {
733
+ commentStmts.deleteById.run(id)
734
+ }
735
+
736
+ function getComment(id) {
737
+ const r = commentStmts.getById.get(id)
738
+ if (!r) return null
739
+ return { id: r.id, todoId: r.todo_id, content: r.content, createdAt: r.created_at }
740
+ }
741
+
742
+ const aiLogStmts = {
743
+ insert: db.prepare(`
744
+ INSERT OR REPLACE INTO ai_session_log
745
+ (id, todo_id, tool, quadrant, status, exit_code, started_at, completed_at, duration_ms)
746
+ VALUES
747
+ (@id, @todo_id, @tool, @quadrant, @status, @exit_code, @started_at, @completed_at, @duration_ms)
748
+ `),
749
+ listSince: db.prepare(`SELECT * FROM ai_session_log WHERE completed_at >= ? AND completed_at < ? ORDER BY completed_at DESC`),
750
+ listInWindow: db.prepare(`
751
+ SELECT id, todo_id, tool, started_at, completed_at, duration_ms
752
+ FROM ai_session_log
753
+ WHERE tool = ? AND started_at BETWEEN ? AND ?
754
+ `),
755
+ }
756
+
757
+ function insertSessionLog(row) {
758
+ aiLogStmts.insert.run({
759
+ id: row.id,
760
+ todo_id: row.todoId,
761
+ tool: row.tool,
762
+ quadrant: Number(row.quadrant) || 0,
763
+ status: row.status,
764
+ exit_code: row.exitCode ?? null,
765
+ started_at: row.startedAt,
766
+ completed_at: row.completedAt,
767
+ duration_ms: Math.max(0, row.completedAt - row.startedAt),
768
+ })
769
+ }
770
+
771
+ function querySessionStats({ since, until = Date.now() } = {}) {
772
+ const rows = aiLogStmts.listSince.all(since, until)
773
+ const stats = {
774
+ total: rows.length,
775
+ byStatus: { done: 0, failed: 0, stopped: 0 },
776
+ byTool: { claude: 0, codex: 0, cursor: 0 },
777
+ byQuadrant: { 1: 0, 2: 0, 3: 0, 4: 0 },
778
+ totalDurationMs: 0,
779
+ avgDurationMs: 0,
780
+ timeline: [],
781
+ }
782
+ if (!rows.length) return stats
783
+ const buckets = new Map()
784
+ const bucketSize = (until - since) > 7 * 86400_000 ? 86400_000 : 3600_000
785
+ for (const r of rows) {
786
+ stats.byStatus[r.status] = (stats.byStatus[r.status] || 0) + 1
787
+ stats.byTool[r.tool] = (stats.byTool[r.tool] || 0) + 1
788
+ stats.byQuadrant[r.quadrant] = (stats.byQuadrant[r.quadrant] || 0) + 1
789
+ stats.totalDurationMs += r.duration_ms
790
+ const bucket = Math.floor(r.completed_at / bucketSize) * bucketSize
791
+ buckets.set(bucket, (buckets.get(bucket) || 0) + 1)
792
+ }
793
+ stats.avgDurationMs = Math.round(stats.totalDurationMs / rows.length)
794
+ stats.timeline = [...buckets.entries()]
795
+ .sort((a, b) => a[0] - b[0])
796
+ .map(([t, count]) => ({ t, count }))
797
+ return stats
798
+ }
799
+
800
+ const tfStmts = {
801
+ getByPath: db.prepare(`SELECT * FROM transcript_files WHERE jsonl_path = ?`),
802
+ listAllPaths: db.prepare(`SELECT id, jsonl_path, size, mtime FROM transcript_files`),
803
+ upsert: db.prepare(`
804
+ INSERT INTO transcript_files (tool, native_id, cwd, jsonl_path, size, mtime, started_at, ended_at, first_user_prompt, turn_count, bound_todo_id, indexed_at, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, primary_model, active_ms)
805
+ VALUES (@tool, @native_id, @cwd, @jsonl_path, @size, @mtime, @started_at, @ended_at, @first_user_prompt, @turn_count, @bound_todo_id, @indexed_at, @input_tokens, @output_tokens, @cache_read_tokens, @cache_creation_tokens, @primary_model, @active_ms)
806
+ ON CONFLICT(jsonl_path) DO UPDATE SET
807
+ tool=excluded.tool,
808
+ native_id=excluded.native_id,
809
+ cwd=excluded.cwd,
810
+ size=excluded.size,
811
+ mtime=excluded.mtime,
812
+ started_at=excluded.started_at,
813
+ ended_at=excluded.ended_at,
814
+ first_user_prompt=excluded.first_user_prompt,
815
+ turn_count=excluded.turn_count,
816
+ indexed_at=excluded.indexed_at,
817
+ input_tokens=excluded.input_tokens,
818
+ output_tokens=excluded.output_tokens,
819
+ cache_read_tokens=excluded.cache_read_tokens,
820
+ cache_creation_tokens=excluded.cache_creation_tokens,
821
+ primary_model=excluded.primary_model,
822
+ active_ms=excluded.active_ms
823
+ `),
824
+ deleteByPath: db.prepare(`DELETE FROM transcript_files WHERE jsonl_path = ?`),
825
+ getById: db.prepare(`SELECT * FROM transcript_files WHERE id = ?`),
826
+ setBound: db.prepare(`UPDATE transcript_files SET bound_todo_id = ? WHERE id = ?`),
827
+ findByNative: db.prepare(`SELECT * FROM transcript_files WHERE native_id = ? AND tool = ?`),
828
+ countUnbound: db.prepare(`SELECT COUNT(*) AS n FROM transcript_files WHERE bound_todo_id IS NULL`),
829
+ listUnboundForMatching: db.prepare(`SELECT * FROM transcript_files WHERE bound_todo_id IS NULL`),
830
+ }
831
+ const ftsStmts = ftsAvailable ? {
832
+ deleteByFile: db.prepare(`DELETE FROM transcript_fts WHERE file_id = ?`),
833
+ insert: db.prepare(`INSERT INTO transcript_fts (content, role, file_id) VALUES (?, ?, ?)`),
834
+ } : null
835
+
836
+ function upsertTranscriptFile(row) {
837
+ tfStmts.upsert.run({
838
+ tool: row.tool,
839
+ native_id: row.nativeId ?? null,
840
+ cwd: row.cwd ?? null,
841
+ jsonl_path: row.jsonlPath,
842
+ size: row.size,
843
+ mtime: row.mtime,
844
+ started_at: row.startedAt ?? null,
845
+ ended_at: row.endedAt ?? null,
846
+ first_user_prompt: row.firstUserPrompt ?? null,
847
+ turn_count: row.turnCount ?? 0,
848
+ bound_todo_id: row.boundTodoId ?? null,
849
+ indexed_at: Date.now(),
850
+ input_tokens: row.inputTokens ?? null,
851
+ output_tokens: row.outputTokens ?? null,
852
+ cache_read_tokens: row.cacheReadTokens ?? null,
853
+ cache_creation_tokens: row.cacheCreationTokens ?? null,
854
+ primary_model: row.primaryModel ?? null,
855
+ active_ms: row.activeMs ?? null,
856
+ })
857
+ return tfStmts.getByPath.get(row.jsonlPath)
858
+ }
859
+
860
+ function deleteTranscriptFile(jsonlPath) {
861
+ const existing = tfStmts.getByPath.get(jsonlPath)
862
+ if (!existing) return
863
+ if (ftsStmts) ftsStmts.deleteByFile.run(existing.id)
864
+ tfStmts.deleteByPath.run(jsonlPath)
865
+ }
866
+
867
+ function writeFtsTurns(fileId, turns) {
868
+ if (!ftsStmts) return
869
+ const tx = db.transaction(() => {
870
+ ftsStmts.deleteByFile.run(fileId)
871
+ for (const t of turns) {
872
+ if (!t?.content) continue
873
+ ftsStmts.insert.run(String(t.content), String(t.role || ''), fileId)
874
+ }
875
+ })
876
+ tx()
877
+ }
878
+
879
+ function searchTranscripts({ q, tool, cwd, since, unboundOnly, limit = 50, offset = 0 } = {}) {
880
+ const where = []
881
+ const params = []
882
+ if (tool) { where.push('tf.tool = ?'); params.push(tool) }
883
+ if (cwd) { where.push('tf.cwd = ?'); params.push(cwd) }
884
+ if (since) { where.push('tf.started_at >= ?'); params.push(since) }
885
+ if (unboundOnly) where.push('tf.bound_todo_id IS NULL')
886
+
887
+ if (q && ftsAvailable) {
888
+ // trigram tokenizer 要求 ≥3 字才能走 MATCH;<3 字用 LIKE 兜底扫 FTS 的 content 列
889
+ if (q.length < 3) {
890
+ const like = `%${q.replace(/[\\%_]/g, s => '\\' + s)}%`
891
+ where.push(`tf.id IN (SELECT file_id FROM transcript_fts WHERE content LIKE ? ESCAPE '\\')`)
892
+ params.push(like)
893
+ const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : ''
894
+ const total = db.prepare(`SELECT COUNT(*) AS n FROM transcript_files tf ${whereSql}`).get(...params).n
895
+ const rows = db.prepare(`
896
+ SELECT tf.*, (
897
+ SELECT SUBSTR(content, MAX(1, INSTR(content, ?) - 16), 64)
898
+ FROM transcript_fts WHERE file_id = tf.id AND content LIKE ? ESCAPE '\\' LIMIT 1
899
+ ) AS snippet
900
+ FROM transcript_files tf
901
+ ${whereSql}
902
+ ORDER BY tf.started_at DESC
903
+ LIMIT ? OFFSET ?
904
+ `).all(q, like, ...params, limit, offset)
905
+ return { total, items: rows }
906
+ }
907
+ const ftsQuery = q.replace(/"/g, '""')
908
+ where.push('tf.id IN (SELECT file_id FROM transcript_fts WHERE transcript_fts MATCH ?)')
909
+ params.push(`"${ftsQuery}"`)
910
+ const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : ''
911
+ const total = db.prepare(`SELECT COUNT(*) AS n FROM transcript_files tf ${whereSql}`).get(...params).n
912
+ const rows = db.prepare(`
913
+ SELECT tf.*, (
914
+ SELECT snippet(transcript_fts, 0, '<mark>', '</mark>', '…', 16)
915
+ FROM transcript_fts WHERE file_id = tf.id AND transcript_fts MATCH ? LIMIT 1
916
+ ) AS snippet
917
+ FROM transcript_files tf
918
+ ${whereSql}
919
+ ORDER BY tf.started_at DESC NULLS LAST
920
+ LIMIT ? OFFSET ?
921
+ `).all(`"${ftsQuery}"`, ...params, limit, offset)
922
+ return { total, items: rows }
923
+ }
924
+
925
+ if (q && !ftsAvailable) {
926
+ where.push('LOWER(tf.first_user_prompt) LIKE ?')
927
+ params.push(`%${q.toLowerCase()}%`)
928
+ }
929
+ const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : ''
930
+ const total = db.prepare(`SELECT COUNT(*) AS n FROM transcript_files tf ${whereSql}`).get(...params).n
931
+ const rows = db.prepare(`
932
+ SELECT tf.*, NULL AS snippet
933
+ FROM transcript_files tf
934
+ ${whereSql}
935
+ ORDER BY tf.started_at DESC
936
+ LIMIT ? OFFSET ?
937
+ `).all(...params, limit, offset)
938
+ return { total, items: rows }
939
+ }
940
+
941
+ const ptStmts = {
942
+ list: db.prepare(`SELECT * FROM prompt_templates ORDER BY builtin DESC, sort_order ASC, created_at ASC`),
943
+ get: db.prepare(`SELECT * FROM prompt_templates WHERE id = ?`),
944
+ insert: db.prepare(`INSERT INTO prompt_templates (id, name, description, content, builtin, sort_order, created_at, updated_at) VALUES (@id, @name, @description, @content, @builtin, @sort_order, @created_at, @updated_at)`),
945
+ update: db.prepare(`UPDATE prompt_templates SET name = @name, description = @description, content = @content, sort_order = @sort_order, updated_at = @updated_at WHERE id = @id`),
946
+ delete: db.prepare(`DELETE FROM prompt_templates WHERE id = ?`),
947
+ countAll: db.prepare(`SELECT COUNT(*) AS n FROM prompt_templates`),
948
+ }
949
+
950
+ function rowToTemplate(r) {
951
+ if (!r) return null
952
+ return {
953
+ id: r.id,
954
+ name: r.name,
955
+ description: r.description,
956
+ content: r.content,
957
+ builtin: !!r.builtin,
958
+ sortOrder: r.sort_order,
959
+ createdAt: r.created_at,
960
+ updatedAt: r.updated_at,
961
+ }
962
+ }
963
+
964
+ function listTemplates() { return ptStmts.list.all().map(rowToTemplate) }
965
+ function getTemplate(id) { return rowToTemplate(ptStmts.get.get(id)) }
966
+ function createTemplate(data) {
967
+ const now = Date.now()
968
+ const row = {
969
+ id: randomUUID(),
970
+ name: data.name || '未命名模板',
971
+ description: data.description || '',
972
+ content: data.content || '',
973
+ builtin: data.builtin ? 1 : 0,
974
+ sort_order: Number.isFinite(data.sortOrder) ? data.sortOrder : now,
975
+ created_at: now,
976
+ updated_at: now,
977
+ }
978
+ ptStmts.insert.run(row)
979
+ return rowToTemplate(ptStmts.get.get(row.id))
980
+ }
981
+ function updateTemplate(id, patch) {
982
+ const existing = ptStmts.get.get(id)
983
+ if (!existing) return null
984
+ if (existing.builtin) {
985
+ throw new Error('builtin_template_readonly')
986
+ }
987
+ ptStmts.update.run({
988
+ id,
989
+ name: patch.name ?? existing.name,
990
+ description: patch.description ?? existing.description,
991
+ content: patch.content ?? existing.content,
992
+ sort_order: Number.isFinite(patch.sortOrder) ? patch.sortOrder : existing.sort_order,
993
+ updated_at: Date.now(),
994
+ })
995
+ return rowToTemplate(ptStmts.get.get(id))
996
+ }
997
+ function deleteTemplate(id) {
998
+ const existing = ptStmts.get.get(id)
999
+ if (!existing) return
1000
+ if (existing.builtin) throw new Error('builtin_template_readonly')
1001
+ ptStmts.delete.run(id)
1002
+ }
1003
+
1004
+ // ─── pending_questions ──────────────────────────────────────────
1005
+ // 配合 ask_user MCP 工具:AI 在 PTY 里发起问题时,写一行 pending;
1006
+ // 用户在 OpenClaw(微信)回复后,通过 submit_user_reply 回填。
1007
+ const pqStmts = {
1008
+ insert: db.prepare(`
1009
+ INSERT INTO pending_questions (
1010
+ ticket, session_id, todo_id, question, options_json,
1011
+ status, created_at, timeout_ms
1012
+ ) VALUES (
1013
+ @ticket, @session_id, @todo_id, @question, @options_json,
1014
+ 'pending', @created_at, @timeout_ms
1015
+ )
1016
+ `),
1017
+ getByTicket: db.prepare(`SELECT * FROM pending_questions WHERE ticket = ?`),
1018
+ listPending: db.prepare(`
1019
+ SELECT * FROM pending_questions
1020
+ WHERE status = 'pending'
1021
+ ORDER BY created_at DESC
1022
+ `),
1023
+ listLatestPending: db.prepare(`
1024
+ SELECT * FROM pending_questions
1025
+ WHERE status = 'pending'
1026
+ ORDER BY created_at DESC
1027
+ LIMIT 1
1028
+ `),
1029
+ setAnswered: db.prepare(`
1030
+ UPDATE pending_questions
1031
+ SET status = 'answered', answer_text = ?, chosen_index = ?, answered_at = ?
1032
+ WHERE ticket = ? AND status = 'pending'
1033
+ `),
1034
+ setStatus: db.prepare(`
1035
+ UPDATE pending_questions
1036
+ SET status = ?, answered_at = ?
1037
+ WHERE ticket = ? AND status = 'pending'
1038
+ `),
1039
+ sweepExpired: db.prepare(`
1040
+ UPDATE pending_questions
1041
+ SET status = 'timeout', answered_at = ?
1042
+ WHERE status = 'pending' AND (created_at + timeout_ms) < ?
1043
+ `),
1044
+ }
1045
+
1046
+ function rowToPending(r) {
1047
+ if (!r) return null
1048
+ let options = []
1049
+ try { options = JSON.parse(r.options_json) } catch {}
1050
+ return {
1051
+ ticket: r.ticket,
1052
+ sessionId: r.session_id,
1053
+ todoId: r.todo_id,
1054
+ question: r.question,
1055
+ options,
1056
+ status: r.status,
1057
+ answerText: r.answer_text,
1058
+ chosenIndex: r.chosen_index,
1059
+ createdAt: r.created_at,
1060
+ answeredAt: r.answered_at,
1061
+ timeoutMs: r.timeout_ms,
1062
+ }
1063
+ }
1064
+
1065
+ function createPendingQuestion({ ticket, sessionId, todoId, question, options, timeoutMs }) {
1066
+ if (!ticket) throw new Error('ticket_required')
1067
+ if (!sessionId) throw new Error('session_id_required')
1068
+ if (!question) throw new Error('question_required')
1069
+ if (!Array.isArray(options) || options.length === 0) throw new Error('options_required')
1070
+ pqStmts.insert.run({
1071
+ ticket,
1072
+ session_id: sessionId,
1073
+ todo_id: todoId || null,
1074
+ question,
1075
+ options_json: JSON.stringify(options),
1076
+ created_at: Date.now(),
1077
+ timeout_ms: Number(timeoutMs) || 600000,
1078
+ })
1079
+ return rowToPending(pqStmts.getByTicket.get(ticket))
1080
+ }
1081
+
1082
+ function getPendingQuestion(ticket) {
1083
+ return rowToPending(pqStmts.getByTicket.get(ticket))
1084
+ }
1085
+
1086
+ function listPendingQuestions() {
1087
+ return pqStmts.listPending.all().map(rowToPending)
1088
+ }
1089
+
1090
+ function getLatestPendingQuestion() {
1091
+ return rowToPending(pqStmts.listLatestPending.get())
1092
+ }
1093
+
1094
+ function answerPendingQuestion(ticket, { answerText, chosenIndex }) {
1095
+ const r = pqStmts.setAnswered.run(
1096
+ answerText ?? null,
1097
+ Number.isInteger(chosenIndex) ? chosenIndex : null,
1098
+ Date.now(),
1099
+ ticket,
1100
+ )
1101
+ if (r.changes === 0) return null
1102
+ return rowToPending(pqStmts.getByTicket.get(ticket))
1103
+ }
1104
+
1105
+ function setPendingStatus(ticket, status) {
1106
+ if (!['timeout', 'cancelled'].includes(status)) throw new Error('invalid_status')
1107
+ pqStmts.setStatus.run(status, Date.now(), ticket)
1108
+ return rowToPending(pqStmts.getByTicket.get(ticket))
1109
+ }
1110
+
1111
+ function sweepExpiredPendingQuestions() {
1112
+ const r = pqStmts.sweepExpired.run(Date.now(), Date.now())
1113
+ return r.changes
1114
+ }
1115
+
1116
+ function seedBuiltinTemplatesIfEmpty() {
1117
+ if (ptStmts.countAll.get().n > 0) return
1118
+ const now = Date.now()
1119
+ const seeds = [
1120
+ {
1121
+ name: 'Brainstorm(脑爆)',
1122
+ description: '先脑爆方向,不急着动手',
1123
+ content: '请先不要直接动手实现。先针对下面的任务 brainstorm:\n- 列出 2-3 种可选方案,说明优缺点\n- 指出风险点与需要用户拍板的关键决策\n- 明确验收标准\n\n在我确认方案后再进入实现。',
1124
+ },
1125
+ {
1126
+ name: 'Bug 修复',
1127
+ description: '复现 → 定位 → 最小用例 → 修复 → 回归',
1128
+ content: '按 bug 修复流程处理下面的问题:\n1. 先复现(给出复现步骤和实际 vs 预期)\n2. 定位根因(不要过早修改代码)\n3. 写一个能复现该 bug 的最小用例(如果有测试框架)\n4. 修复根因,不是修现象\n5. 回归:跑相关测试;考虑同类 bug 是否还存在',
1129
+ },
1130
+ {
1131
+ name: '重构',
1132
+ description: '先读懂 → 列出影响面 → 小步重构',
1133
+ content: '按照小步重构原则处理下面的任务:\n1. 先通读相关代码,复述你的理解\n2. 列出此次重构的影响面(调用方 / 测试 / 类型)\n3. 每一步只改一件事,保持可运行\n4. 每步后跑一次测试(如果有)\n5. 不要顺手加功能、不要引入新抽象,除非当前任务要求',
1134
+ },
1135
+ {
1136
+ name: '写测试',
1137
+ description: 'TDD:红 → 绿 → 重构',
1138
+ content: '用 TDD 的方式处理下面的任务:\n1. 先列出测试矩阵(输入 × 场景)\n2. 先写一个最简失败用例(红)\n3. 用最小改动让它通过(绿)\n4. 重构(保持绿)\n5. 重复 2-4 直到覆盖矩阵\n不 mock 真实依赖(除非跨网络/支付等)。',
1139
+ },
1140
+ {
1141
+ name: '代码评审',
1142
+ description: '只评审,不改代码',
1143
+ content: '请只做代码评审,不要修改代码。按下面的维度给出具体反馈:\n- 可读性:命名、结构、注释\n- 正确性:边界、错误处理、并发\n- 安全性:注入、鉴权、敏感数据\n- 性能:明显的 N+1 / 无谓复制\n- 简洁性:是否有过度设计 / 可删除的冗余\n每条反馈给出文件:行号 + 建议。',
1144
+ },
1145
+ ]
1146
+ seeds.forEach((s, i) => {
1147
+ ptStmts.insert.run({
1148
+ id: randomUUID(),
1149
+ name: s.name,
1150
+ description: s.description,
1151
+ content: s.content,
1152
+ builtin: 1,
1153
+ sort_order: i,
1154
+ created_at: now,
1155
+ updated_at: now,
1156
+ })
1157
+ })
1158
+ }
1159
+ seedBuiltinTemplatesIfEmpty()
1160
+
1161
+ const wikiStmts = {
1162
+ insertRun: db.prepare(`
1163
+ INSERT INTO wiki_runs (started_at, todo_count, dry_run)
1164
+ VALUES (?, ?, ?)
1165
+ `),
1166
+ completeRun: db.prepare(`
1167
+ UPDATE wiki_runs SET completed_at = ?, exit_code = ?, note = ?
1168
+ WHERE id = ?
1169
+ `),
1170
+ failRun: db.prepare(`
1171
+ UPDATE wiki_runs SET completed_at = ?, exit_code = ?, error = ?
1172
+ WHERE id = ?
1173
+ `),
1174
+ listRuns: db.prepare(`
1175
+ SELECT * FROM wiki_runs ORDER BY started_at DESC LIMIT ?
1176
+ `),
1177
+ orphanRuns: db.prepare(`
1178
+ SELECT * FROM wiki_runs WHERE completed_at IS NULL
1179
+ `),
1180
+ upsertCoverage: db.prepare(`
1181
+ INSERT INTO wiki_todo_coverage (wiki_run_id, todo_id, source_path, llm_applied)
1182
+ VALUES (?, ?, ?, ?)
1183
+ ON CONFLICT(wiki_run_id, todo_id) DO UPDATE SET
1184
+ source_path = excluded.source_path,
1185
+ llm_applied = excluded.llm_applied
1186
+ `),
1187
+ markApplied: db.prepare(`
1188
+ UPDATE wiki_todo_coverage SET llm_applied = 1 WHERE wiki_run_id = ?
1189
+ `),
1190
+ coverageForTodo: db.prepare(`
1191
+ SELECT * FROM wiki_todo_coverage WHERE todo_id = ? ORDER BY wiki_run_id DESC
1192
+ `),
1193
+ unappliedDoneTodos: db.prepare(`
1194
+ SELECT t.* FROM todos t
1195
+ WHERE t.status = 'done'
1196
+ AND NOT EXISTS (
1197
+ SELECT 1 FROM wiki_todo_coverage c
1198
+ WHERE c.todo_id = t.id AND c.llm_applied = 1
1199
+ )
1200
+ ORDER BY t.updated_at DESC
1201
+ `),
1202
+ }
1203
+
1204
+ function createWikiRun({ todoCount = 0, dryRun = 0 } = {}) {
1205
+ const now = Date.now()
1206
+ const info = wikiStmts.insertRun.run(now, Number(todoCount) || 0, dryRun ? 1 : 0)
1207
+ return { id: info.lastInsertRowid, started_at: now, completed_at: null }
1208
+ }
1209
+ function completeWikiRun(id, { exitCode = 0, note = '' } = {}) {
1210
+ wikiStmts.completeRun.run(Date.now(), exitCode, note || '', id)
1211
+ }
1212
+ function failWikiRun(id, errorMsg) {
1213
+ wikiStmts.failRun.run(Date.now(), -1, String(errorMsg || 'unknown'), id)
1214
+ }
1215
+ function listWikiRuns({ limit = 20 } = {}) {
1216
+ return wikiStmts.listRuns.all(Math.max(1, Math.min(200, limit)))
1217
+ }
1218
+ function findOrphanWikiRuns() {
1219
+ return wikiStmts.orphanRuns.all()
1220
+ }
1221
+ function upsertWikiCoverage(runId, todoId, sourcePath, llmApplied) {
1222
+ wikiStmts.upsertCoverage.run(runId, todoId, sourcePath || null, llmApplied ? 1 : 0)
1223
+ }
1224
+ function markCoverageApplied(runId) {
1225
+ wikiStmts.markApplied.run(runId)
1226
+ }
1227
+ function listCoverageForTodo(todoId) {
1228
+ return wikiStmts.coverageForTodo.all(todoId)
1229
+ }
1230
+ function listUnappliedDoneTodos() {
1231
+ return wikiStmts.unappliedDoneTodos.all().map(rowToTodo)
1232
+ }
1233
+
1234
+ const ruleStmts = {
1235
+ insert: db.prepare(`
1236
+ INSERT INTO recurring_rules
1237
+ (id, title, description, quadrant, work_dir, brainstorm, applied_template_ids, subtodos, frequency, weekdays, month_days, active, last_generated_date, created_at, updated_at)
1238
+ VALUES
1239
+ (@id, @title, @description, @quadrant, @work_dir, @brainstorm, @applied_template_ids, @subtodos, @frequency, @weekdays, @month_days, @active, @last_generated_date, @created_at, @updated_at)
1240
+ `),
1241
+ get: db.prepare(`SELECT * FROM recurring_rules WHERE id = ?`),
1242
+ list: db.prepare(`SELECT * FROM recurring_rules ORDER BY created_at DESC`),
1243
+ listActive: db.prepare(`SELECT * FROM recurring_rules WHERE active = 1`),
1244
+ update: db.prepare(`
1245
+ UPDATE recurring_rules SET
1246
+ title = @title,
1247
+ description = @description,
1248
+ quadrant = @quadrant,
1249
+ work_dir = @work_dir,
1250
+ brainstorm = @brainstorm,
1251
+ applied_template_ids = @applied_template_ids,
1252
+ subtodos = @subtodos,
1253
+ frequency = @frequency,
1254
+ weekdays = @weekdays,
1255
+ month_days = @month_days,
1256
+ updated_at = @updated_at
1257
+ WHERE id = @id
1258
+ `),
1259
+ setActive: db.prepare(`UPDATE recurring_rules SET active = ?, updated_at = ? WHERE id = ?`),
1260
+ setLastGenerated: db.prepare(`UPDATE recurring_rules SET last_generated_date = ?, updated_at = ? WHERE id = ?`),
1261
+ delete: db.prepare(`DELETE FROM recurring_rules WHERE id = ?`),
1262
+ unlinkInstances: db.prepare(`UPDATE todos SET recurring_rule_id = NULL WHERE recurring_rule_id = ?`),
1263
+ }
1264
+
1265
+ function normalizeRuleInput(data) {
1266
+ const frequency = data.frequency
1267
+ if (!['daily', 'weekly', 'monthly'].includes(frequency)) {
1268
+ throw new Error('invalid_frequency')
1269
+ }
1270
+ let weekdays = null
1271
+ let monthDays = null
1272
+ if (frequency === 'weekly') {
1273
+ const arr = Array.isArray(data.weekdays) ? data.weekdays.filter(n => Number.isInteger(n) && n >= 0 && n <= 6) : []
1274
+ if (!arr.length) throw new Error('weekdays_required')
1275
+ weekdays = [...new Set(arr)].sort((a, b) => a - b)
1276
+ }
1277
+ if (frequency === 'monthly') {
1278
+ const arr = Array.isArray(data.monthDays) ? data.monthDays.filter(n => Number.isInteger(n) && n >= 1 && n <= 31) : []
1279
+ if (!arr.length) throw new Error('month_days_required')
1280
+ monthDays = [...new Set(arr)].sort((a, b) => a - b)
1281
+ }
1282
+ const q = Number(data.quadrant)
1283
+ if (![1, 2, 3, 4].includes(q)) throw new Error('invalid_quadrant')
1284
+ return { frequency, weekdays, monthDays, quadrant: q }
1285
+ }
1286
+
1287
+ function instantiateRule(rule, now) {
1288
+ const today = todayStr(now)
1289
+ const due = endOfDayMs(now)
1290
+ const parent = createTodo({
1291
+ title: rule.title,
1292
+ description: rule.description,
1293
+ quadrant: rule.quadrant,
1294
+ status: 'todo',
1295
+ dueDate: due,
1296
+ workDir: rule.workDir ?? null,
1297
+ brainstorm: !!rule.brainstorm,
1298
+ appliedTemplateIds: rule.appliedTemplateIds || [],
1299
+ recurringRuleId: rule.id,
1300
+ instanceDate: today,
1301
+ })
1302
+ for (const st of (rule.subtodos || [])) {
1303
+ if (!st || !st.title) continue
1304
+ createTodo({
1305
+ title: st.title,
1306
+ description: st.description || '',
1307
+ status: 'todo',
1308
+ dueDate: due,
1309
+ parentId: parent.id,
1310
+ recurringRuleId: rule.id,
1311
+ instanceDate: today,
1312
+ })
1313
+ }
1314
+ return parent
1315
+ }
1316
+
1317
+ function createRecurringRule(data) {
1318
+ const { frequency, weekdays, monthDays, quadrant } = normalizeRuleInput(data)
1319
+ if (!data.title || typeof data.title !== 'string') throw new Error('title_required')
1320
+ const now = Date.now()
1321
+ const today = todayStr(now)
1322
+ const rule = {
1323
+ id: randomUUID(),
1324
+ title: data.title.trim(),
1325
+ description: data.description || '',
1326
+ quadrant,
1327
+ work_dir: data.workDir || null,
1328
+ brainstorm: data.brainstorm ? 1 : 0,
1329
+ applied_template_ids: JSON.stringify(Array.isArray(data.appliedTemplateIds) ? data.appliedTemplateIds : []),
1330
+ subtodos: JSON.stringify(Array.isArray(data.subtodos) ? data.subtodos.map(s => ({
1331
+ title: String(s.title || '').trim(),
1332
+ description: String(s.description || ''),
1333
+ })).filter(s => s.title) : []),
1334
+ frequency,
1335
+ weekdays: weekdays ? JSON.stringify(weekdays) : null,
1336
+ month_days: monthDays ? JSON.stringify(monthDays) : null,
1337
+ active: 1,
1338
+ last_generated_date: today,
1339
+ created_at: now,
1340
+ updated_at: now,
1341
+ }
1342
+ ruleStmts.insert.run(rule)
1343
+ const ruleObj = rowToRule(ruleStmts.get.get(rule.id))
1344
+ let firstInstance = null
1345
+ if (ruleShouldProduceOn(ruleObj, today)) {
1346
+ firstInstance = instantiateRule(ruleObj, now)
1347
+ }
1348
+ return { rule: ruleObj, firstInstance }
1349
+ }
1350
+
1351
+ function updateRecurringRule(id, patch) {
1352
+ const existing = rowToRule(ruleStmts.get.get(id))
1353
+ if (!existing) return null
1354
+ const merged = {
1355
+ title: patch.title ?? existing.title,
1356
+ description: patch.description ?? existing.description,
1357
+ quadrant: patch.quadrant ?? existing.quadrant,
1358
+ workDir: patch.workDir !== undefined ? patch.workDir : existing.workDir,
1359
+ brainstorm: patch.brainstorm !== undefined ? !!patch.brainstorm : existing.brainstorm,
1360
+ appliedTemplateIds: patch.appliedTemplateIds !== undefined ? patch.appliedTemplateIds : existing.appliedTemplateIds,
1361
+ subtodos: patch.subtodos !== undefined ? patch.subtodos : existing.subtodos,
1362
+ frequency: patch.frequency ?? existing.frequency,
1363
+ weekdays: patch.frequency === 'weekly' || (patch.frequency === undefined && existing.frequency === 'weekly')
1364
+ ? (patch.weekdays ?? existing.weekdays)
1365
+ : undefined,
1366
+ monthDays: patch.frequency === 'monthly' || (patch.frequency === undefined && existing.frequency === 'monthly')
1367
+ ? (patch.monthDays ?? existing.monthDays)
1368
+ : undefined,
1369
+ }
1370
+ const { frequency, weekdays, monthDays, quadrant } = normalizeRuleInput(merged)
1371
+ ruleStmts.update.run({
1372
+ id,
1373
+ title: merged.title,
1374
+ description: merged.description,
1375
+ quadrant,
1376
+ work_dir: merged.workDir || null,
1377
+ brainstorm: merged.brainstorm ? 1 : 0,
1378
+ applied_template_ids: JSON.stringify(Array.isArray(merged.appliedTemplateIds) ? merged.appliedTemplateIds : []),
1379
+ subtodos: JSON.stringify(Array.isArray(merged.subtodos) ? merged.subtodos.map(s => ({
1380
+ title: String(s.title || '').trim(),
1381
+ description: String(s.description || ''),
1382
+ })).filter(s => s.title) : []),
1383
+ frequency,
1384
+ weekdays: weekdays ? JSON.stringify(weekdays) : null,
1385
+ month_days: monthDays ? JSON.stringify(monthDays) : null,
1386
+ updated_at: Date.now(),
1387
+ })
1388
+ return rowToRule(ruleStmts.get.get(id))
1389
+ }
1390
+
1391
+ function getRecurringRule(id) {
1392
+ return rowToRule(ruleStmts.get.get(id))
1393
+ }
1394
+
1395
+ function setRecurringRuleActive(id, active) {
1396
+ ruleStmts.setActive.run(active ? 1 : 0, Date.now(), id)
1397
+ return rowToRule(ruleStmts.get.get(id))
1398
+ }
1399
+
1400
+ function deleteRecurringRule(id) {
1401
+ ruleStmts.unlinkInstances.run(id)
1402
+ ruleStmts.delete.run(id)
1403
+ }
1404
+
1405
+ function sweepRecurring(now = Date.now()) {
1406
+ const today = todayStr(now)
1407
+ const startOfToday = new Date(now)
1408
+ startOfToday.setHours(0, 0, 0, 0)
1409
+ const startOfTodayMs = startOfToday.getTime()
1410
+
1411
+ db.prepare(`
1412
+ UPDATE todos
1413
+ SET status = 'missed', updated_at = ?
1414
+ WHERE recurring_rule_id IS NOT NULL
1415
+ AND instance_date IS NOT NULL
1416
+ AND instance_date < ?
1417
+ AND status IN ('todo', 'ai_done')
1418
+ `).run(now, today)
1419
+
1420
+ const rules = ruleStmts.listActive.all().map(rowToRule)
1421
+ const tx = db.transaction(() => {
1422
+ for (const rule of rules) {
1423
+ if (rule.lastGeneratedDate === today) continue
1424
+ if (ruleShouldProduceOn(rule, today)) {
1425
+ instantiateRule(rule, now)
1426
+ }
1427
+ ruleStmts.setLastGenerated.run(today, now, rule.id)
1428
+ }
1429
+ })
1430
+ tx()
1431
+ return { today, startOfTodayMs }
1432
+ }
1433
+
1434
+ return {
1435
+ raw: db,
1436
+ listTemplates,
1437
+ getTemplate,
1438
+ createTemplate,
1439
+ updateTemplate,
1440
+ deleteTemplate,
1441
+ createTodo,
1442
+ getTodo,
1443
+ updateTodo,
1444
+ deleteTodo,
1445
+ listTodos,
1446
+ listSubtodosByParent,
1447
+ listCompletedTodos,
1448
+ countMissedInRange,
1449
+ archiveTodo,
1450
+ unarchiveTodo,
1451
+ describeMergeTodos,
1452
+ mergeTodos,
1453
+ bulkUpdateTodos,
1454
+ nextSortOrder,
1455
+ addComment,
1456
+ listComments,
1457
+ deleteComment,
1458
+ getComment,
1459
+ insertSessionLog,
1460
+ querySessionStats,
1461
+ listSessionLogsInWindow: (tool, startedAt, windowMs) => {
1462
+ const lo = startedAt - windowMs
1463
+ const hi = startedAt + windowMs
1464
+ return aiLogStmts.listInWindow.all(tool, lo, hi)
1465
+ },
1466
+ ftsAvailable,
1467
+ transcriptFilesStmts: tfStmts,
1468
+ upsertTranscriptFile,
1469
+ deleteTranscriptFile,
1470
+ writeFtsTurns,
1471
+ searchTranscripts,
1472
+ getTranscriptFile: (id) => tfStmts.getById.get(id),
1473
+ listTranscriptFilesMeta: () => tfStmts.listAllPaths.all(),
1474
+ listUnboundTranscriptFiles: () => tfStmts.listUnboundForMatching.all(),
1475
+ findTranscriptByNative: (nativeId, tool) => tfStmts.findByNative.get(nativeId, tool),
1476
+ setTranscriptBound: (id, todoId) => tfStmts.setBound.run(todoId, id),
1477
+ countUnboundTranscripts: () => tfStmts.countUnbound.get().n,
1478
+ createRecurringRule,
1479
+ updateRecurringRule,
1480
+ getRecurringRule,
1481
+ setRecurringRuleActive,
1482
+ deleteRecurringRule,
1483
+ sweepRecurring,
1484
+ createWikiRun,
1485
+ completeWikiRun,
1486
+ failWikiRun,
1487
+ listWikiRuns,
1488
+ findOrphanWikiRuns,
1489
+ upsertWikiCoverage,
1490
+ markCoverageApplied,
1491
+ listCoverageForTodo,
1492
+ listUnappliedDoneTodos,
1493
+ // pending_questions (open-claw 桥接)
1494
+ createPendingQuestion,
1495
+ getPendingQuestion,
1496
+ listPendingQuestions,
1497
+ getLatestPendingQuestion,
1498
+ answerPendingQuestion,
1499
+ setPendingStatus,
1500
+ sweepExpiredPendingQuestions,
1501
+ close: () => db.close(),
1502
+ }
1503
+ }