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.
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/dist-web/assets/index-CMaXwixo.js +1234 -0
- package/dist-web/assets/index-DBHApzV1.css +32 -0
- package/dist-web/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
- package/dist-web/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
- package/dist-web/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
- package/dist-web/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
- package/dist-web/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
- package/dist-web/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
- package/dist-web/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
- package/dist-web/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
- package/dist-web/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
- package/dist-web/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
- package/dist-web/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
- package/dist-web/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
- package/dist-web/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
- package/dist-web/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
- package/dist-web/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
- package/dist-web/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
- package/dist-web/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
- package/dist-web/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
- package/dist-web/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
- package/dist-web/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/dist-web/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/dist-web/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/dist-web/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/dist-web/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/dist-web/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/dist-web/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
- package/dist-web/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
- package/dist-web/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
- package/dist-web/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
- package/dist-web/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
- package/dist-web/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
- package/dist-web/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
- package/dist-web/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
- package/dist-web/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
- package/dist-web/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
- package/dist-web/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
- package/dist-web/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-BWTpRfYl.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-CEoEElIJ.woff +0 -0
- package/dist-web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
- package/dist-web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-greek-700-normal-C6CZE3T8.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-greek-700-normal-DEigVDxa.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CZipNAKV.woff2 +0 -0
- package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CxPITLHs.woff +0 -0
- package/dist-web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
- package/dist-web/assets/jetbrains-mono-vietnamese-700-normal-BDLVIk2r.woff +0 -0
- package/dist-web/assets/logo-D4DDtU-r.png +0 -0
- package/dist-web/favicon.png +0 -0
- package/dist-web/index.html +14 -0
- package/package.json +88 -0
- package/src/ask-user-buttons.js +142 -0
- package/src/claude-transcript.js +203 -0
- package/src/cli.js +1040 -0
- package/src/codex-event-emitter.js +111 -0
- package/src/codex-prompt-detector.js +53 -0
- package/src/codex-sidecar.js +52 -0
- package/src/codex-transcript.js +74 -0
- package/src/config.js +692 -0
- package/src/data/claude-code-commands.json +52 -0
- package/src/db.js +1503 -0
- package/src/dispatch.js +13 -0
- package/src/export/todoMarkdown.js +246 -0
- package/src/first-run-wizard.js +82 -0
- package/src/git/gitStatus.js +139 -0
- package/src/lark-api-client.js +205 -0
- package/src/lark-bot.js +510 -0
- package/src/lark-card.js +88 -0
- package/src/lark-config-service.js +16 -0
- package/src/lark-event-client.js +107 -0
- package/src/lark-image.js +99 -0
- package/src/lark-markdown.js +51 -0
- package/src/lark-video.js +163 -0
- package/src/mcp/audit.js +34 -0
- package/src/mcp/server.js +83 -0
- package/src/mcp/tools/destructive/index.js +252 -0
- package/src/mcp/tools/openclaw/index.js +405 -0
- package/src/mcp/tools/read/index.js +269 -0
- package/src/mcp/tools/write/index.js +157 -0
- package/src/openclaw-bridge.js +566 -0
- package/src/openclaw-hook-installer.js +338 -0
- package/src/openclaw-hook.js +908 -0
- package/src/openclaw-wizard.js +2442 -0
- package/src/pending-questions.js +297 -0
- package/src/pricing.js +45 -0
- package/src/prompt-render.js +36 -0
- package/src/pty.js +992 -0
- package/src/routes/ai-terminal.js +1228 -0
- package/src/routes/git.js +89 -0
- package/src/routes/openclaw-hook.js +67 -0
- package/src/routes/openclaw-inbound.js +36 -0
- package/src/routes/recurringRules.js +80 -0
- package/src/routes/reports.js +50 -0
- package/src/routes/search.js +46 -0
- package/src/routes/stats.js +31 -0
- package/src/routes/telegram-config.js +152 -0
- package/src/routes/telegram-sync.js +221 -0
- package/src/routes/templates.js +63 -0
- package/src/routes/todos.js +649 -0
- package/src/routes/transcripts.js +75 -0
- package/src/routes/uploads.js +107 -0
- package/src/routes/wiki.js +142 -0
- package/src/search/fts.js +209 -0
- package/src/search/index.js +199 -0
- package/src/search/transcripts.js +148 -0
- package/src/server.js +1791 -0
- package/src/session-input-dispatcher.js +256 -0
- package/src/stats/markdown.js +42 -0
- package/src/stats/report.js +207 -0
- package/src/summarize.js +84 -0
- package/src/system-rules.js +52 -0
- package/src/telegram-bot.js +875 -0
- package/src/telegram-commands.js +149 -0
- package/src/telegram-config-service.js +84 -0
- package/src/telegram-image.js +95 -0
- package/src/telegram-loading-status.js +112 -0
- package/src/telegram-markdown.js +82 -0
- package/src/telegram-reaction-tracker.js +69 -0
- package/src/telegram-video.js +75 -0
- package/src/templates/claude-hooks/notify.js +103 -0
- package/src/transcript.js +305 -0
- package/src/transcripts/blocks.js +56 -0
- package/src/transcripts/index.js +222 -0
- package/src/transcripts/indexer.js +34 -0
- package/src/transcripts/matcher.js +70 -0
- package/src/transcripts/scanner.js +259 -0
- package/src/usage-footer.js +170 -0
- package/src/usage-parser.js +132 -0
- package/src/wiki/guide.js +44 -0
- package/src/wiki/index.js +232 -0
- package/src/wiki/redact.js +34 -0
- 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
|
+
}
|