token-studio 4.8.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/.nvmrc +1 -0
- package/CHANGELOG.md +89 -0
- package/Dockerfile +17 -0
- package/LICENSE +22 -0
- package/NOTICE.md +21 -0
- package/PRIVACY.md +68 -0
- package/README.en.md +220 -0
- package/README.md +220 -0
- package/config/collectors.json +54 -0
- package/data/.gitkeep +1 -0
- package/docker-compose.yml +17 -0
- package/docs/assets/.gitkeep +1 -0
- package/docs/assets/token-studio-v44-dashboard.png +0 -0
- package/docs/assets/token-studio-v44-live.png +0 -0
- package/docs/assets/token-studio-v44-review-mobile.png +0 -0
- package/docs/assets/token-studio-v44-review.png +0 -0
- package/docs/assets/token-studio-v45-dashboard.png +0 -0
- package/docs/assets/token-studio-v45-live.png +0 -0
- package/docs/assets/token-studio-v45-review-mobile.png +0 -0
- package/docs/assets/token-studio-v45-review.png +0 -0
- package/docs/blog-case-study.md +34 -0
- package/docs/collector-support-matrix.md +65 -0
- package/docs/competitive-notes.md +87 -0
- package/docs/demo-data/README.md +12 -0
- package/docs/demo-data/token-studio-v2-demo.json +146 -0
- package/docs/demo-flow.md +39 -0
- package/docs/first-run.md +95 -0
- package/docs/local-collectors.md +49 -0
- package/docs/public-launch-checklist.md +45 -0
- package/docs/resume-bullets.md +7 -0
- package/docs/statusline.md +52 -0
- package/index.html +16 -0
- package/package.json +36 -0
- package/render.yaml +17 -0
- package/src/auto-attribution.mjs +396 -0
- package/src/ccusage-bridge.mjs +74 -0
- package/src/ccusage-import.mjs +415 -0
- package/src/cli.mjs +643 -0
- package/src/client/dashboard/App.jsx +1734 -0
- package/src/client/dashboard/annotation-presets.js +138 -0
- package/src/client/dashboard/attribution.js +328 -0
- package/src/client/dashboard/components-charts.jsx +622 -0
- package/src/client/dashboard/components-tables.jsx +1531 -0
- package/src/client/dashboard/components-top.jsx +307 -0
- package/src/client/dashboard/import-budget.js +41 -0
- package/src/client/dashboard/model-usage.js +108 -0
- package/src/client/dashboard/onboarding.js +80 -0
- package/src/client/dashboard/styles.css +2606 -0
- package/src/client/live/LiveApp.jsx +226 -0
- package/src/client/live/styles.css +446 -0
- package/src/client/main.jsx +20 -0
- package/src/client/review/ReviewApp.jsx +507 -0
- package/src/client/review/closure-progress.js +165 -0
- package/src/client/review/markdown-report.js +401 -0
- package/src/client/review/model-strategy.js +273 -0
- package/src/client/review/roi-advisor.js +255 -0
- package/src/client/review/roi-evidence.js +78 -0
- package/src/client/review/savings-simulator.js +252 -0
- package/src/client/review/sections-1.jsx +277 -0
- package/src/client/review/sections-2.jsx +927 -0
- package/src/client/review/styles.css +2321 -0
- package/src/client/review/utils.js +345 -0
- package/src/client/shared/utils.js +236 -0
- package/src/closure-check.mjs +537 -0
- package/src/closure-import.mjs +646 -0
- package/src/collect.mjs +247 -0
- package/src/collector-config.mjs +82 -0
- package/src/collector-registry.mjs +333 -0
- package/src/collectors/claude-code.mjs +355 -0
- package/src/collectors/codex.mjs +418 -0
- package/src/collectors/copilot.mjs +19 -0
- package/src/collectors/cursor.mjs +23 -0
- package/src/collectors/gemini.mjs +530 -0
- package/src/collectors/goose.mjs +15 -0
- package/src/collectors/hermes.mjs +206 -0
- package/src/collectors/kimi.mjs +15 -0
- package/src/collectors/openclaw.mjs +400 -0
- package/src/collectors/opencode.mjs +349 -0
- package/src/collectors/qwen.mjs +15 -0
- package/src/collectors/structured-usage.mjs +437 -0
- package/src/collectors/utils.mjs +93 -0
- package/src/db.mjs +1397 -0
- package/src/demo-seed.mjs +39 -0
- package/src/dev.mjs +43 -0
- package/src/live.mjs +428 -0
- package/src/model-policy.mjs +147 -0
- package/src/pricing.mjs +434 -0
- package/src/privacy-check.mjs +126 -0
- package/src/server.mjs +1240 -0
- package/src/source-health.mjs +195 -0
- package/src/statusline.mjs +156 -0
- package/src/terminal-report.mjs +245 -0
- package/src/update-pricing.mjs +8 -0
- package/test/annotation-presets.test.mjs +137 -0
- package/test/api-annotations.test.mjs +202 -0
- package/test/api-auto-attribution.test.mjs +169 -0
- package/test/api-source-health.test.mjs +109 -0
- package/test/api-v2.test.mjs +278 -0
- package/test/api-v43.test.mjs +151 -0
- package/test/api-v44.test.mjs +128 -0
- package/test/attribution-summary.test.mjs +164 -0
- package/test/auto-attribution.test.mjs +116 -0
- package/test/ccusage-bridge.test.mjs +36 -0
- package/test/ccusage-import.test.mjs +93 -0
- package/test/cli-v43.test.mjs +64 -0
- package/test/cli-v45.test.mjs +34 -0
- package/test/cli-v46.test.mjs +129 -0
- package/test/cli-v47.test.mjs +98 -0
- package/test/closure-check.test.mjs +202 -0
- package/test/closure-import.test.mjs +263 -0
- package/test/collector-config.test.mjs +25 -0
- package/test/collector-registry.test.mjs +56 -0
- package/test/csv.test.mjs +19 -0
- package/test/db-annotations.test.mjs +186 -0
- package/test/db-v2.test.mjs +200 -0
- package/test/db-v4.test.mjs +178 -0
- package/test/experimental-collectors.test.mjs +103 -0
- package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
- package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
- package/test/fixtures/collectors/goose/usage.jsonl +2 -0
- package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
- package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
- package/test/import-budget.test.mjs +40 -0
- package/test/live.test.mjs +256 -0
- package/test/markdown-report.test.mjs +193 -0
- package/test/model-policy.test.mjs +34 -0
- package/test/model-strategy.test.mjs +116 -0
- package/test/model-usage.test.mjs +99 -0
- package/test/official-pricing.test.mjs +70 -0
- package/test/onboarding.test.mjs +55 -0
- package/test/privacy-check.test.mjs +33 -0
- package/test/review-closure-progress.test.mjs +99 -0
- package/test/roi-advisor.test.mjs +188 -0
- package/test/roi-evidence.test.mjs +48 -0
- package/test/roi-summary.test.mjs +101 -0
- package/test/savings-simulator.test.mjs +141 -0
- package/test/source-health.test.mjs +62 -0
- package/test/statusline.test.mjs +148 -0
- package/vite.config.js +23 -0
package/src/db.mjs
ADDED
|
@@ -0,0 +1,1397 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
|
|
5
|
+
export const defaultDbPath = resolve(process.cwd(), 'data', 'usage.sqlite');
|
|
6
|
+
export const TASK_TYPES = ['未分类', '功能开发', '问题修复', '代码审查', '技术调研', '内容创作', '运维配置', '其他'];
|
|
7
|
+
export const OUTPUT_STATUSES = ['未标注', '进行中', '已完成', '已发布', '已废弃'];
|
|
8
|
+
export const WORK_PURPOSES = ['未说明', '需求澄清', '方案设计', '功能开发', '调试修复', '测试验证', '代码审查', '技术调研', '文档内容', '部署运维', '上下文整理', '其他'];
|
|
9
|
+
export const WORK_STAGES = ['未说明', '探索', '实现', '验证', '发布', '维护'];
|
|
10
|
+
export const VALUE_LEVELS = ['未评估', '低', '中', '高', '关键'];
|
|
11
|
+
export const OUTPUT_TYPES = ['未分类', 'PR', 'commit', '文章', '部署', '文档', '截图', '其他'];
|
|
12
|
+
export const PROJECT_ALIAS_MATCH_TYPES = ['prefix', 'contains', 'regex'];
|
|
13
|
+
export const ANNOTATION_SOURCES = ['manual', 'auto', 'imported'];
|
|
14
|
+
export const PRIVACY_LEVELS = ['safe', 'hashed', 'redacted', 'unavailable'];
|
|
15
|
+
export const WORK_ITEM_TYPES = ['未分类', '功能开发', '问题修复', '代码审查', '技术调研', '内容创作', '运维配置', '其他'];
|
|
16
|
+
export const BUDGET_WINDOW_TYPES = ['rolling', 'fixed'];
|
|
17
|
+
export const ADVISOR_ACTION_STATUSES = ['open', 'done', 'dismissed'];
|
|
18
|
+
export const DEFAULT_SESSION_ANNOTATION = {
|
|
19
|
+
projectAlias: null,
|
|
20
|
+
taskType: TASK_TYPES[0],
|
|
21
|
+
outputStatus: OUTPUT_STATUSES[0],
|
|
22
|
+
workPurpose: WORK_PURPOSES[0],
|
|
23
|
+
workStage: WORK_STAGES[0],
|
|
24
|
+
valueLevel: VALUE_LEVELS[0],
|
|
25
|
+
note: null,
|
|
26
|
+
annotationUpdatedAt: null,
|
|
27
|
+
annotationSource: null,
|
|
28
|
+
annotationConfidence: null,
|
|
29
|
+
annotationReason: null,
|
|
30
|
+
autoVersion: null,
|
|
31
|
+
autoRunId: null,
|
|
32
|
+
autoUpdatedAt: null,
|
|
33
|
+
attributionQuality: 'missing',
|
|
34
|
+
manualProjectAlias: null,
|
|
35
|
+
ruleProjectAlias: null,
|
|
36
|
+
outputUrl: null,
|
|
37
|
+
outputLabel: null,
|
|
38
|
+
outputType: OUTPUT_TYPES[0],
|
|
39
|
+
outputUpdatedAt: null
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function openDb(dbPath = defaultDbPath) {
|
|
43
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
44
|
+
const db = new DatabaseSync(dbPath);
|
|
45
|
+
db.exec('PRAGMA busy_timeout = 10000');
|
|
46
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
47
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
48
|
+
initSchema(db);
|
|
49
|
+
return db;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function openReadOnlyDb(dbPath = defaultDbPath) {
|
|
53
|
+
const resolved = resolve(dbPath);
|
|
54
|
+
if (!existsSync(resolved)) {
|
|
55
|
+
throw new Error(`SQLite database not found: ${resolved}`);
|
|
56
|
+
}
|
|
57
|
+
const db = new DatabaseSync(resolved, {
|
|
58
|
+
readOnly: true,
|
|
59
|
+
timeout: 10000
|
|
60
|
+
});
|
|
61
|
+
db.exec('PRAGMA busy_timeout = 10000');
|
|
62
|
+
return db;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createSqliteBackup(db, dbPath = defaultDbPath, { reason = 'manual', backupDir = null } = {}) {
|
|
66
|
+
const resolvedDbPath = resolve(dbPath);
|
|
67
|
+
if (!existsSync(resolvedDbPath)) {
|
|
68
|
+
throw new Error(`SQLite database not found: ${resolvedDbPath}`);
|
|
69
|
+
}
|
|
70
|
+
const createdAt = new Date().toISOString();
|
|
71
|
+
const stamp = createdAt.replace(/[:.]/g, '-');
|
|
72
|
+
const safeReason = String(reason || 'manual').replace(/[^a-z0-9-]+/gi, '-').toLowerCase();
|
|
73
|
+
const targetDir = backupDir || process.env.BACKUP_DIR || join(dirname(resolvedDbPath), 'backups');
|
|
74
|
+
mkdirSync(targetDir, { recursive: true });
|
|
75
|
+
db.exec('PRAGMA wal_checkpoint(FULL)');
|
|
76
|
+
const fileName = `usage-${stamp}-${safeReason}.sqlite`;
|
|
77
|
+
const backupPath = join(targetDir, fileName);
|
|
78
|
+
copyFileSync(resolvedDbPath, backupPath);
|
|
79
|
+
return { createdAt, path: backupPath, fileName };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function initSchema(db) {
|
|
83
|
+
db.exec(`
|
|
84
|
+
CREATE TABLE IF NOT EXISTS collection_runs (
|
|
85
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
86
|
+
device TEXT NOT NULL,
|
|
87
|
+
source TEXT NOT NULL,
|
|
88
|
+
status TEXT NOT NULL,
|
|
89
|
+
message TEXT,
|
|
90
|
+
collected_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
91
|
+
command TEXT
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
CREATE TABLE IF NOT EXISTS daily_usage (
|
|
95
|
+
device TEXT NOT NULL,
|
|
96
|
+
source TEXT NOT NULL,
|
|
97
|
+
usage_date TEXT NOT NULL,
|
|
98
|
+
model TEXT NOT NULL DEFAULT '',
|
|
99
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
100
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
101
|
+
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
|
102
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
103
|
+
cached_input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
104
|
+
reasoning_output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
105
|
+
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
106
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
107
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
108
|
+
PRIMARY KEY (device, source, usage_date, model)
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
CREATE TABLE IF NOT EXISTS session_usage (
|
|
112
|
+
device TEXT NOT NULL,
|
|
113
|
+
source TEXT NOT NULL,
|
|
114
|
+
session_id TEXT NOT NULL,
|
|
115
|
+
last_activity TEXT,
|
|
116
|
+
project_path TEXT,
|
|
117
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
118
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
119
|
+
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
|
120
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
121
|
+
cached_input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
122
|
+
reasoning_output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
123
|
+
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
124
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
125
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
126
|
+
PRIMARY KEY (device, source, session_id)
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
CREATE TABLE IF NOT EXISTS session_annotations (
|
|
130
|
+
device TEXT NOT NULL,
|
|
131
|
+
source TEXT NOT NULL,
|
|
132
|
+
session_id TEXT NOT NULL,
|
|
133
|
+
project_alias TEXT,
|
|
134
|
+
task_type TEXT NOT NULL DEFAULT '未分类',
|
|
135
|
+
output_status TEXT NOT NULL DEFAULT '未标注',
|
|
136
|
+
work_purpose TEXT NOT NULL DEFAULT '未说明',
|
|
137
|
+
work_stage TEXT NOT NULL DEFAULT '未说明',
|
|
138
|
+
value_level TEXT NOT NULL DEFAULT '未评估',
|
|
139
|
+
note TEXT,
|
|
140
|
+
annotation_source TEXT NOT NULL DEFAULT 'manual',
|
|
141
|
+
annotation_confidence INTEGER NOT NULL DEFAULT 100,
|
|
142
|
+
annotation_reason TEXT,
|
|
143
|
+
auto_version TEXT,
|
|
144
|
+
auto_run_id TEXT,
|
|
145
|
+
auto_updated_at TEXT,
|
|
146
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
147
|
+
PRIMARY KEY (device, source, session_id),
|
|
148
|
+
FOREIGN KEY (device, source, session_id)
|
|
149
|
+
REFERENCES session_usage(device, source, session_id)
|
|
150
|
+
ON DELETE CASCADE
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
CREATE TABLE IF NOT EXISTS project_alias_rules (
|
|
154
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
155
|
+
pattern TEXT NOT NULL,
|
|
156
|
+
match_type TEXT NOT NULL DEFAULT 'prefix',
|
|
157
|
+
project_alias TEXT NOT NULL,
|
|
158
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
159
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
CREATE TABLE IF NOT EXISTS session_outputs (
|
|
163
|
+
device TEXT NOT NULL,
|
|
164
|
+
source TEXT NOT NULL,
|
|
165
|
+
session_id TEXT NOT NULL,
|
|
166
|
+
output_url TEXT NOT NULL,
|
|
167
|
+
output_label TEXT,
|
|
168
|
+
output_type TEXT NOT NULL DEFAULT '未分类',
|
|
169
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
170
|
+
PRIMARY KEY (device, source, session_id),
|
|
171
|
+
FOREIGN KEY (device, source, session_id)
|
|
172
|
+
REFERENCES session_usage(device, source, session_id)
|
|
173
|
+
ON DELETE CASCADE
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
CREATE TABLE IF NOT EXISTS token_events (
|
|
177
|
+
event_id TEXT PRIMARY KEY,
|
|
178
|
+
device TEXT NOT NULL,
|
|
179
|
+
source TEXT NOT NULL,
|
|
180
|
+
session_id TEXT NOT NULL,
|
|
181
|
+
timestamp TEXT NOT NULL,
|
|
182
|
+
model TEXT NOT NULL DEFAULT '',
|
|
183
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
184
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
185
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
186
|
+
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
|
187
|
+
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
|
|
188
|
+
tool_category TEXT,
|
|
189
|
+
file_extension TEXT,
|
|
190
|
+
repo_path_hash TEXT,
|
|
191
|
+
privacy_level TEXT NOT NULL DEFAULT 'safe',
|
|
192
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
CREATE TABLE IF NOT EXISTS work_items (
|
|
196
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
197
|
+
title TEXT NOT NULL,
|
|
198
|
+
project_alias TEXT,
|
|
199
|
+
work_type TEXT NOT NULL DEFAULT '未分类',
|
|
200
|
+
status TEXT NOT NULL DEFAULT '未标注',
|
|
201
|
+
value_level TEXT NOT NULL DEFAULT '未评估',
|
|
202
|
+
output_url TEXT,
|
|
203
|
+
output_type TEXT NOT NULL DEFAULT '未分类',
|
|
204
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
205
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
CREATE TABLE IF NOT EXISTS work_item_sessions (
|
|
209
|
+
work_item_id INTEGER NOT NULL,
|
|
210
|
+
device TEXT NOT NULL,
|
|
211
|
+
source TEXT NOT NULL,
|
|
212
|
+
session_id TEXT NOT NULL,
|
|
213
|
+
linked_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
214
|
+
PRIMARY KEY (work_item_id, device, source, session_id),
|
|
215
|
+
FOREIGN KEY (work_item_id) REFERENCES work_items(id) ON DELETE CASCADE,
|
|
216
|
+
FOREIGN KEY (device, source, session_id)
|
|
217
|
+
REFERENCES session_usage(device, source, session_id)
|
|
218
|
+
ON DELETE CASCADE
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
CREATE TABLE IF NOT EXISTS budget_profiles (
|
|
222
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
223
|
+
source TEXT NOT NULL DEFAULT '',
|
|
224
|
+
label TEXT NOT NULL,
|
|
225
|
+
window_type TEXT NOT NULL DEFAULT 'rolling',
|
|
226
|
+
window_minutes INTEGER NOT NULL DEFAULT 300,
|
|
227
|
+
token_budget INTEGER NOT NULL DEFAULT 0,
|
|
228
|
+
cost_budget_usd REAL NOT NULL DEFAULT 0,
|
|
229
|
+
reset_anchor TEXT,
|
|
230
|
+
warning_threshold REAL NOT NULL DEFAULT 0.75,
|
|
231
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
232
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
CREATE TABLE IF NOT EXISTS advisor_actions (
|
|
236
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
237
|
+
period_start TEXT NOT NULL,
|
|
238
|
+
period_end TEXT NOT NULL,
|
|
239
|
+
category TEXT NOT NULL,
|
|
240
|
+
title TEXT NOT NULL,
|
|
241
|
+
action TEXT NOT NULL,
|
|
242
|
+
evidence TEXT,
|
|
243
|
+
source_rule TEXT,
|
|
244
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
245
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
246
|
+
completed_at TEXT,
|
|
247
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
CREATE INDEX IF NOT EXISTS idx_daily_usage_date ON daily_usage(usage_date);
|
|
251
|
+
CREATE INDEX IF NOT EXISTS idx_daily_usage_source ON daily_usage(source);
|
|
252
|
+
CREATE INDEX IF NOT EXISTS idx_session_usage_total ON session_usage(total_tokens DESC);
|
|
253
|
+
CREATE INDEX IF NOT EXISTS idx_session_annotations_task ON session_annotations(task_type);
|
|
254
|
+
CREATE INDEX IF NOT EXISTS idx_session_annotations_status ON session_annotations(output_status);
|
|
255
|
+
CREATE INDEX IF NOT EXISTS idx_project_alias_rules_enabled ON project_alias_rules(enabled, match_type);
|
|
256
|
+
CREATE INDEX IF NOT EXISTS idx_session_outputs_updated ON session_outputs(updated_at DESC);
|
|
257
|
+
CREATE INDEX IF NOT EXISTS idx_token_events_session ON token_events(device, source, session_id);
|
|
258
|
+
CREATE INDEX IF NOT EXISTS idx_token_events_time ON token_events(timestamp DESC);
|
|
259
|
+
CREATE INDEX IF NOT EXISTS idx_work_items_status ON work_items(status, value_level);
|
|
260
|
+
CREATE INDEX IF NOT EXISTS idx_budget_profiles_enabled ON budget_profiles(enabled, source);
|
|
261
|
+
CREATE INDEX IF NOT EXISTS idx_advisor_actions_period ON advisor_actions(period_start, period_end, status);
|
|
262
|
+
CREATE INDEX IF NOT EXISTS idx_advisor_actions_rule ON advisor_actions(period_start, period_end, source_rule);
|
|
263
|
+
`);
|
|
264
|
+
ensureColumn(db, 'session_annotations', 'work_purpose', "TEXT NOT NULL DEFAULT '未说明'");
|
|
265
|
+
ensureColumn(db, 'session_annotations', 'work_stage', "TEXT NOT NULL DEFAULT '未说明'");
|
|
266
|
+
ensureColumn(db, 'session_annotations', 'value_level', "TEXT NOT NULL DEFAULT '未评估'");
|
|
267
|
+
ensureColumn(db, 'session_annotations', 'annotation_source', "TEXT NOT NULL DEFAULT 'manual'");
|
|
268
|
+
ensureColumn(db, 'session_annotations', 'annotation_confidence', 'INTEGER NOT NULL DEFAULT 100');
|
|
269
|
+
ensureColumn(db, 'session_annotations', 'annotation_reason', 'TEXT');
|
|
270
|
+
ensureColumn(db, 'session_annotations', 'auto_version', 'TEXT');
|
|
271
|
+
ensureColumn(db, 'session_annotations', 'auto_run_id', 'TEXT');
|
|
272
|
+
ensureColumn(db, 'session_annotations', 'auto_updated_at', 'TEXT');
|
|
273
|
+
ensureColumn(db, 'budget_profiles', 'reset_anchor', 'TEXT');
|
|
274
|
+
ensureColumn(db, 'budget_profiles', 'warning_threshold', 'REAL NOT NULL DEFAULT 0.75');
|
|
275
|
+
ensureColumn(db, 'session_outputs', 'output_type', "TEXT NOT NULL DEFAULT '未分类'");
|
|
276
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_session_annotations_work ON session_annotations(work_purpose, work_stage, value_level)');
|
|
277
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_session_annotations_provenance ON session_annotations(annotation_source, annotation_confidence, auto_run_id)');
|
|
278
|
+
ensureColumn(db, 'advisor_actions', 'updated_at', "TEXT NOT NULL DEFAULT (datetime('now'))");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function upsertDaily(db, row) {
|
|
282
|
+
db.prepare(`
|
|
283
|
+
INSERT INTO daily_usage (
|
|
284
|
+
device, source, usage_date, model, input_tokens, output_tokens,
|
|
285
|
+
cache_creation_tokens, cache_read_tokens, cached_input_tokens,
|
|
286
|
+
reasoning_output_tokens, total_tokens, cost_usd, updated_at
|
|
287
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
288
|
+
ON CONFLICT(device, source, usage_date, model) DO UPDATE SET
|
|
289
|
+
input_tokens = excluded.input_tokens,
|
|
290
|
+
output_tokens = excluded.output_tokens,
|
|
291
|
+
cache_creation_tokens = excluded.cache_creation_tokens,
|
|
292
|
+
cache_read_tokens = excluded.cache_read_tokens,
|
|
293
|
+
cached_input_tokens = excluded.cached_input_tokens,
|
|
294
|
+
reasoning_output_tokens = excluded.reasoning_output_tokens,
|
|
295
|
+
total_tokens = excluded.total_tokens,
|
|
296
|
+
cost_usd = excluded.cost_usd,
|
|
297
|
+
updated_at = datetime('now')
|
|
298
|
+
`).run(
|
|
299
|
+
row.device,
|
|
300
|
+
row.source,
|
|
301
|
+
row.usageDate,
|
|
302
|
+
row.model || '',
|
|
303
|
+
row.inputTokens || 0,
|
|
304
|
+
row.outputTokens || 0,
|
|
305
|
+
row.cacheCreationTokens || 0,
|
|
306
|
+
row.cacheReadTokens || 0,
|
|
307
|
+
row.cachedInputTokens || 0,
|
|
308
|
+
row.reasoningOutputTokens || 0,
|
|
309
|
+
row.totalTokens || 0,
|
|
310
|
+
row.costUSD || 0
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function upsertSession(db, row) {
|
|
315
|
+
db.prepare(`
|
|
316
|
+
INSERT INTO session_usage (
|
|
317
|
+
device, source, session_id, last_activity, project_path, input_tokens,
|
|
318
|
+
output_tokens, cache_creation_tokens, cache_read_tokens,
|
|
319
|
+
cached_input_tokens, reasoning_output_tokens, total_tokens, cost_usd, updated_at
|
|
320
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
321
|
+
ON CONFLICT(device, source, session_id) DO UPDATE SET
|
|
322
|
+
last_activity = excluded.last_activity,
|
|
323
|
+
project_path = excluded.project_path,
|
|
324
|
+
input_tokens = excluded.input_tokens,
|
|
325
|
+
output_tokens = excluded.output_tokens,
|
|
326
|
+
cache_creation_tokens = excluded.cache_creation_tokens,
|
|
327
|
+
cache_read_tokens = excluded.cache_read_tokens,
|
|
328
|
+
cached_input_tokens = excluded.cached_input_tokens,
|
|
329
|
+
reasoning_output_tokens = excluded.reasoning_output_tokens,
|
|
330
|
+
total_tokens = excluded.total_tokens,
|
|
331
|
+
cost_usd = excluded.cost_usd,
|
|
332
|
+
updated_at = datetime('now')
|
|
333
|
+
`).run(
|
|
334
|
+
row.device,
|
|
335
|
+
row.source,
|
|
336
|
+
row.sessionId,
|
|
337
|
+
row.lastActivity || null,
|
|
338
|
+
row.projectPath || null,
|
|
339
|
+
row.inputTokens || 0,
|
|
340
|
+
row.outputTokens || 0,
|
|
341
|
+
row.cacheCreationTokens || 0,
|
|
342
|
+
row.cacheReadTokens || 0,
|
|
343
|
+
row.cachedInputTokens || 0,
|
|
344
|
+
row.reasoningOutputTokens || 0,
|
|
345
|
+
row.totalTokens || 0,
|
|
346
|
+
row.costUSD || 0
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function recordRun(db, row) {
|
|
351
|
+
db.prepare(`
|
|
352
|
+
INSERT INTO collection_runs(device, source, status, message, collected_at, command)
|
|
353
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
354
|
+
`).run(
|
|
355
|
+
row.device,
|
|
356
|
+
row.source,
|
|
357
|
+
row.status,
|
|
358
|
+
row.message || null,
|
|
359
|
+
row.collectedAt || new Date().toISOString(),
|
|
360
|
+
row.command || null
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function normalizeSessionAnnotation(row = {}, { defaultSource = 'manual' } = {}) {
|
|
365
|
+
const required = {
|
|
366
|
+
device: normalizedRequired(row.device, 'device'),
|
|
367
|
+
source: normalizedRequired(row.source, 'source'),
|
|
368
|
+
sessionId: normalizedRequired(row.sessionId ?? row.session_id, 'sessionId')
|
|
369
|
+
};
|
|
370
|
+
const projectAlias = normalizeOptionalText(row.projectAlias ?? row.project_alias, 'projectAlias', 120);
|
|
371
|
+
const taskType = normalizeEnum(row.taskType ?? row.task_type, TASK_TYPES, TASK_TYPES[0], 'taskType');
|
|
372
|
+
const outputStatus = normalizeEnum(row.outputStatus ?? row.output_status, OUTPUT_STATUSES, OUTPUT_STATUSES[0], 'outputStatus');
|
|
373
|
+
const workPurpose = normalizeEnum(row.workPurpose ?? row.work_purpose, WORK_PURPOSES, WORK_PURPOSES[0], 'workPurpose');
|
|
374
|
+
const workStage = normalizeEnum(row.workStage ?? row.work_stage, WORK_STAGES, WORK_STAGES[0], 'workStage');
|
|
375
|
+
const valueLevel = normalizeEnum(row.valueLevel ?? row.value_level, VALUE_LEVELS, VALUE_LEVELS[0], 'valueLevel');
|
|
376
|
+
const note = normalizeOptionalText(row.note, 'note', 500);
|
|
377
|
+
const annotationSource = normalizeEnum(row.annotationSource ?? row.annotation_source, ANNOTATION_SOURCES, defaultSource, 'annotationSource');
|
|
378
|
+
const annotationConfidence = normalizeConfidence(
|
|
379
|
+
row.annotationConfidence ?? row.annotation_confidence,
|
|
380
|
+
annotationSource === 'manual' || annotationSource === 'imported' ? 100 : 0
|
|
381
|
+
);
|
|
382
|
+
const annotationReason = normalizeOptionalText(row.annotationReason ?? row.annotation_reason, 'annotationReason', 500);
|
|
383
|
+
const autoVersion = normalizeOptionalText(row.autoVersion ?? row.auto_version, 'autoVersion', 40);
|
|
384
|
+
const autoRunId = normalizeOptionalText(row.autoRunId ?? row.auto_run_id, 'autoRunId', 80);
|
|
385
|
+
const autoUpdatedAt = normalizeOptionalText(row.autoUpdatedAt ?? row.auto_updated_at, 'autoUpdatedAt', 60);
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
...required,
|
|
389
|
+
projectAlias,
|
|
390
|
+
taskType,
|
|
391
|
+
outputStatus,
|
|
392
|
+
workPurpose,
|
|
393
|
+
workStage,
|
|
394
|
+
valueLevel,
|
|
395
|
+
note,
|
|
396
|
+
annotationSource,
|
|
397
|
+
annotationConfidence,
|
|
398
|
+
annotationReason,
|
|
399
|
+
autoVersion,
|
|
400
|
+
autoRunId,
|
|
401
|
+
autoUpdatedAt
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function upsertSessionAnnotation(db, row) {
|
|
406
|
+
const annotation = normalizeSessionAnnotation(row);
|
|
407
|
+
db.prepare(`
|
|
408
|
+
INSERT INTO session_annotations (
|
|
409
|
+
device, source, session_id, project_alias, task_type, output_status,
|
|
410
|
+
work_purpose, work_stage, value_level, note,
|
|
411
|
+
annotation_source, annotation_confidence, annotation_reason,
|
|
412
|
+
auto_version, auto_run_id, auto_updated_at, updated_at
|
|
413
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
414
|
+
ON CONFLICT(device, source, session_id) DO UPDATE SET
|
|
415
|
+
project_alias = excluded.project_alias,
|
|
416
|
+
task_type = excluded.task_type,
|
|
417
|
+
output_status = excluded.output_status,
|
|
418
|
+
work_purpose = excluded.work_purpose,
|
|
419
|
+
work_stage = excluded.work_stage,
|
|
420
|
+
value_level = excluded.value_level,
|
|
421
|
+
note = excluded.note,
|
|
422
|
+
annotation_source = excluded.annotation_source,
|
|
423
|
+
annotation_confidence = excluded.annotation_confidence,
|
|
424
|
+
annotation_reason = excluded.annotation_reason,
|
|
425
|
+
auto_version = excluded.auto_version,
|
|
426
|
+
auto_run_id = excluded.auto_run_id,
|
|
427
|
+
auto_updated_at = excluded.auto_updated_at,
|
|
428
|
+
updated_at = datetime('now')
|
|
429
|
+
`).run(
|
|
430
|
+
annotation.device,
|
|
431
|
+
annotation.source,
|
|
432
|
+
annotation.sessionId,
|
|
433
|
+
annotation.projectAlias,
|
|
434
|
+
annotation.taskType,
|
|
435
|
+
annotation.outputStatus,
|
|
436
|
+
annotation.workPurpose,
|
|
437
|
+
annotation.workStage,
|
|
438
|
+
annotation.valueLevel,
|
|
439
|
+
annotation.note,
|
|
440
|
+
annotation.annotationSource,
|
|
441
|
+
annotation.annotationConfidence,
|
|
442
|
+
annotation.annotationReason,
|
|
443
|
+
annotation.autoVersion,
|
|
444
|
+
annotation.autoRunId,
|
|
445
|
+
annotation.autoUpdatedAt
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
return db.prepare(`
|
|
449
|
+
SELECT device, source, session_id AS sessionId,
|
|
450
|
+
project_alias AS projectAlias,
|
|
451
|
+
task_type AS taskType,
|
|
452
|
+
output_status AS outputStatus,
|
|
453
|
+
work_purpose AS workPurpose,
|
|
454
|
+
work_stage AS workStage,
|
|
455
|
+
value_level AS valueLevel,
|
|
456
|
+
note,
|
|
457
|
+
annotation_source AS annotationSource,
|
|
458
|
+
annotation_confidence AS annotationConfidence,
|
|
459
|
+
annotation_reason AS annotationReason,
|
|
460
|
+
auto_version AS autoVersion,
|
|
461
|
+
auto_run_id AS autoRunId,
|
|
462
|
+
auto_updated_at AS autoUpdatedAt,
|
|
463
|
+
updated_at AS annotationUpdatedAt
|
|
464
|
+
FROM session_annotations
|
|
465
|
+
WHERE device = ? AND source = ? AND session_id = ?
|
|
466
|
+
`).get(annotation.device, annotation.source, annotation.sessionId);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function deleteSessionAnnotation(db, row) {
|
|
470
|
+
const device = normalizedRequired(row.device, 'device');
|
|
471
|
+
const source = normalizedRequired(row.source, 'source');
|
|
472
|
+
const sessionId = normalizedRequired(row.sessionId ?? row.session_id, 'sessionId');
|
|
473
|
+
return db.prepare(`
|
|
474
|
+
DELETE FROM session_annotations
|
|
475
|
+
WHERE device = ? AND source = ? AND session_id = ?
|
|
476
|
+
`).run(device, source, sessionId).changes;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function batchUpsertSessionAnnotations(db, payload = {}) {
|
|
480
|
+
const sessions = Array.isArray(payload.sessions) ? payload.sessions : [];
|
|
481
|
+
if (!sessions.length) throw new Error('sessions must include at least one item');
|
|
482
|
+
|
|
483
|
+
const values = payload.values && typeof payload.values === 'object' ? payload.values : {};
|
|
484
|
+
const hasProjectAlias = hasAny(values, ['projectAlias', 'project_alias']);
|
|
485
|
+
const hasTaskType = hasAny(values, ['taskType', 'task_type']);
|
|
486
|
+
const hasOutputStatus = hasAny(values, ['outputStatus', 'output_status']);
|
|
487
|
+
const hasWorkPurpose = hasAny(values, ['workPurpose', 'work_purpose']);
|
|
488
|
+
const hasWorkStage = hasAny(values, ['workStage', 'work_stage']);
|
|
489
|
+
const hasValueLevel = hasAny(values, ['valueLevel', 'value_level']);
|
|
490
|
+
const hasNote = hasAny(values, ['note']);
|
|
491
|
+
if (!hasProjectAlias && !hasTaskType && !hasOutputStatus && !hasWorkPurpose && !hasWorkStage && !hasValueLevel && !hasNote) {
|
|
492
|
+
throw new Error('values must include at least one annotation field');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const select = db.prepare(`
|
|
496
|
+
SELECT project_alias AS projectAlias,
|
|
497
|
+
task_type AS taskType,
|
|
498
|
+
output_status AS outputStatus,
|
|
499
|
+
work_purpose AS workPurpose,
|
|
500
|
+
work_stage AS workStage,
|
|
501
|
+
value_level AS valueLevel,
|
|
502
|
+
note,
|
|
503
|
+
annotation_source AS annotationSource,
|
|
504
|
+
annotation_confidence AS annotationConfidence,
|
|
505
|
+
annotation_reason AS annotationReason,
|
|
506
|
+
auto_version AS autoVersion,
|
|
507
|
+
auto_run_id AS autoRunId,
|
|
508
|
+
auto_updated_at AS autoUpdatedAt
|
|
509
|
+
FROM session_annotations
|
|
510
|
+
WHERE device = ? AND source = ? AND session_id = ?
|
|
511
|
+
`);
|
|
512
|
+
let updated = 0;
|
|
513
|
+
|
|
514
|
+
db.exec('BEGIN');
|
|
515
|
+
try {
|
|
516
|
+
for (const item of sessions) {
|
|
517
|
+
const identity = normalizeSessionIdentity(item);
|
|
518
|
+
const current = select.get(identity.device, identity.source, identity.sessionId) || DEFAULT_SESSION_ANNOTATION;
|
|
519
|
+
upsertSessionAnnotation(db, {
|
|
520
|
+
...identity,
|
|
521
|
+
projectAlias: hasProjectAlias ? (values.projectAlias ?? values.project_alias) : current.projectAlias,
|
|
522
|
+
taskType: hasTaskType ? (values.taskType ?? values.task_type) : current.taskType,
|
|
523
|
+
outputStatus: hasOutputStatus ? (values.outputStatus ?? values.output_status) : current.outputStatus,
|
|
524
|
+
workPurpose: hasWorkPurpose ? (values.workPurpose ?? values.work_purpose) : current.workPurpose,
|
|
525
|
+
workStage: hasWorkStage ? (values.workStage ?? values.work_stage) : current.workStage,
|
|
526
|
+
valueLevel: hasValueLevel ? (values.valueLevel ?? values.value_level) : current.valueLevel,
|
|
527
|
+
note: hasNote ? values.note : current.note,
|
|
528
|
+
annotationSource: 'manual',
|
|
529
|
+
annotationConfidence: 100,
|
|
530
|
+
annotationReason: null,
|
|
531
|
+
autoVersion: null,
|
|
532
|
+
autoRunId: null,
|
|
533
|
+
autoUpdatedAt: null
|
|
534
|
+
});
|
|
535
|
+
updated += 1;
|
|
536
|
+
}
|
|
537
|
+
db.exec('COMMIT');
|
|
538
|
+
} catch (error) {
|
|
539
|
+
db.exec('ROLLBACK');
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return { updated };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function listProjectAliasRules(db, { enabledOnly = false } = {}) {
|
|
547
|
+
const where = enabledOnly ? 'WHERE enabled = 1' : '';
|
|
548
|
+
return db.prepare(`
|
|
549
|
+
SELECT id, pattern, match_type AS matchType, project_alias AS projectAlias,
|
|
550
|
+
enabled, updated_at AS updatedAt
|
|
551
|
+
FROM project_alias_rules
|
|
552
|
+
${where}
|
|
553
|
+
ORDER BY enabled DESC, length(pattern) DESC, id ASC
|
|
554
|
+
`).all().map(rule => ({
|
|
555
|
+
...rule,
|
|
556
|
+
enabled: Boolean(rule.enabled)
|
|
557
|
+
}));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export function normalizeProjectAliasRule(row = {}) {
|
|
561
|
+
const id = normalizeOptionalId(row.id, 'id');
|
|
562
|
+
const pattern = normalizedRequired(row.pattern, 'pattern');
|
|
563
|
+
const matchType = normalizeEnum(row.matchType ?? row.match_type, PROJECT_ALIAS_MATCH_TYPES, 'prefix', 'matchType');
|
|
564
|
+
const projectAlias = normalizedRequiredMax(row.projectAlias ?? row.project_alias, 'projectAlias', 120);
|
|
565
|
+
const enabled = normalizeBoolean(row.enabled, true);
|
|
566
|
+
return { id, pattern, matchType, projectAlias, enabled };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export function upsertProjectAliasRule(db, row) {
|
|
570
|
+
const rule = normalizeProjectAliasRule(row);
|
|
571
|
+
db.prepare(`
|
|
572
|
+
INSERT INTO project_alias_rules (id, pattern, match_type, project_alias, enabled, updated_at)
|
|
573
|
+
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
|
574
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
575
|
+
pattern = excluded.pattern,
|
|
576
|
+
match_type = excluded.match_type,
|
|
577
|
+
project_alias = excluded.project_alias,
|
|
578
|
+
enabled = excluded.enabled,
|
|
579
|
+
updated_at = datetime('now')
|
|
580
|
+
`).run(rule.id, rule.pattern, rule.matchType, rule.projectAlias, rule.enabled ? 1 : 0);
|
|
581
|
+
|
|
582
|
+
const id = rule.id ?? db.prepare('SELECT last_insert_rowid() AS id').get().id;
|
|
583
|
+
const saved = db.prepare(`
|
|
584
|
+
SELECT id, pattern, match_type AS matchType, project_alias AS projectAlias,
|
|
585
|
+
enabled, updated_at AS updatedAt
|
|
586
|
+
FROM project_alias_rules
|
|
587
|
+
WHERE id = ?
|
|
588
|
+
`).get(id);
|
|
589
|
+
return { ...saved, enabled: Boolean(saved.enabled) };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export function deleteProjectAliasRule(db, row = {}) {
|
|
593
|
+
const id = normalizeRequiredId(row.id, 'id');
|
|
594
|
+
return db.prepare('DELETE FROM project_alias_rules WHERE id = ?').run(id).changes;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export function matchProjectAliasRule(projectPath, rules = []) {
|
|
598
|
+
const target = normalizeText(projectPath);
|
|
599
|
+
if (!target) return null;
|
|
600
|
+
const normalizedTarget = target.toLowerCase();
|
|
601
|
+
|
|
602
|
+
for (const rule of rules) {
|
|
603
|
+
if (!rule.enabled) continue;
|
|
604
|
+
const pattern = normalizeText(rule.pattern);
|
|
605
|
+
if (!pattern) continue;
|
|
606
|
+
const normalizedPattern = pattern.toLowerCase();
|
|
607
|
+
if (rule.matchType === 'prefix' && normalizedTarget.startsWith(normalizedPattern)) {
|
|
608
|
+
return rule.projectAlias;
|
|
609
|
+
}
|
|
610
|
+
if (rule.matchType === 'contains' && normalizedTarget.includes(normalizedPattern)) {
|
|
611
|
+
return rule.projectAlias;
|
|
612
|
+
}
|
|
613
|
+
if (rule.matchType === 'regex') {
|
|
614
|
+
try {
|
|
615
|
+
if (new RegExp(pattern, 'i').test(target)) return rule.projectAlias;
|
|
616
|
+
} catch {
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export function normalizeSessionOutput(row = {}) {
|
|
626
|
+
const identity = normalizeSessionIdentity(row);
|
|
627
|
+
const outputUrl = normalizeOutputUrl(row.outputUrl ?? row.output_url);
|
|
628
|
+
const outputLabel = normalizeOptionalText(row.outputLabel ?? row.output_label, 'outputLabel', 120);
|
|
629
|
+
const outputType = normalizeEnum(row.outputType ?? row.output_type, OUTPUT_TYPES, OUTPUT_TYPES[0], 'outputType');
|
|
630
|
+
return { ...identity, outputUrl, outputLabel, outputType };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export function upsertSessionOutput(db, row) {
|
|
634
|
+
const output = normalizeSessionOutput(row);
|
|
635
|
+
db.prepare(`
|
|
636
|
+
INSERT INTO session_outputs (device, source, session_id, output_url, output_label, output_type, updated_at)
|
|
637
|
+
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
|
638
|
+
ON CONFLICT(device, source, session_id) DO UPDATE SET
|
|
639
|
+
output_url = excluded.output_url,
|
|
640
|
+
output_label = excluded.output_label,
|
|
641
|
+
output_type = excluded.output_type,
|
|
642
|
+
updated_at = datetime('now')
|
|
643
|
+
`).run(output.device, output.source, output.sessionId, output.outputUrl, output.outputLabel, output.outputType);
|
|
644
|
+
|
|
645
|
+
return db.prepare(`
|
|
646
|
+
SELECT device, source, session_id AS sessionId,
|
|
647
|
+
output_url AS outputUrl,
|
|
648
|
+
output_label AS outputLabel,
|
|
649
|
+
output_type AS outputType,
|
|
650
|
+
updated_at AS outputUpdatedAt
|
|
651
|
+
FROM session_outputs
|
|
652
|
+
WHERE device = ? AND source = ? AND session_id = ?
|
|
653
|
+
`).get(output.device, output.source, output.sessionId);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export function deleteSessionOutput(db, row) {
|
|
657
|
+
const { device, source, sessionId } = normalizeSessionIdentity(row);
|
|
658
|
+
return db.prepare(`
|
|
659
|
+
DELETE FROM session_outputs
|
|
660
|
+
WHERE device = ? AND source = ? AND session_id = ?
|
|
661
|
+
`).run(device, source, sessionId).changes;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export function exportAnnotationData(db) {
|
|
665
|
+
return {
|
|
666
|
+
version: 3,
|
|
667
|
+
exportedAt: new Date().toISOString(),
|
|
668
|
+
sessionAnnotations: db.prepare(`
|
|
669
|
+
SELECT device, source, session_id AS sessionId,
|
|
670
|
+
project_alias AS projectAlias,
|
|
671
|
+
task_type AS taskType,
|
|
672
|
+
output_status AS outputStatus,
|
|
673
|
+
work_purpose AS workPurpose,
|
|
674
|
+
work_stage AS workStage,
|
|
675
|
+
value_level AS valueLevel,
|
|
676
|
+
note,
|
|
677
|
+
annotation_source AS annotationSource,
|
|
678
|
+
annotation_confidence AS annotationConfidence,
|
|
679
|
+
annotation_reason AS annotationReason,
|
|
680
|
+
auto_version AS autoVersion,
|
|
681
|
+
auto_run_id AS autoRunId,
|
|
682
|
+
auto_updated_at AS autoUpdatedAt,
|
|
683
|
+
updated_at AS annotationUpdatedAt
|
|
684
|
+
FROM session_annotations
|
|
685
|
+
ORDER BY updated_at DESC
|
|
686
|
+
`).all(),
|
|
687
|
+
sessionOutputs: db.prepare(`
|
|
688
|
+
SELECT device, source, session_id AS sessionId,
|
|
689
|
+
output_url AS outputUrl,
|
|
690
|
+
output_label AS outputLabel,
|
|
691
|
+
output_type AS outputType,
|
|
692
|
+
updated_at AS outputUpdatedAt
|
|
693
|
+
FROM session_outputs
|
|
694
|
+
ORDER BY updated_at DESC
|
|
695
|
+
`).all(),
|
|
696
|
+
projectAliasRules: listProjectAliasRules(db)
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export function importAnnotationData(db, payload = {}) {
|
|
701
|
+
const sessionAnnotations = Array.isArray(payload.sessionAnnotations)
|
|
702
|
+
? payload.sessionAnnotations
|
|
703
|
+
: Array.isArray(payload.annotations) ? payload.annotations : [];
|
|
704
|
+
const sessionOutputs = Array.isArray(payload.sessionOutputs) ? payload.sessionOutputs : [];
|
|
705
|
+
const projectAliasRules = Array.isArray(payload.projectAliasRules) ? payload.projectAliasRules : [];
|
|
706
|
+
const counts = {
|
|
707
|
+
sessionAnnotations: 0,
|
|
708
|
+
sessionOutputs: 0,
|
|
709
|
+
projectAliasRules: 0
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
db.exec('BEGIN');
|
|
713
|
+
try {
|
|
714
|
+
for (const row of sessionAnnotations) {
|
|
715
|
+
upsertSessionAnnotation(db, {
|
|
716
|
+
...row,
|
|
717
|
+
annotationSource: row.annotationSource ?? row.annotation_source ?? 'imported',
|
|
718
|
+
annotationConfidence: row.annotationConfidence ?? row.annotation_confidence ?? 100
|
|
719
|
+
});
|
|
720
|
+
counts.sessionAnnotations += 1;
|
|
721
|
+
}
|
|
722
|
+
for (const row of sessionOutputs) {
|
|
723
|
+
upsertSessionOutput(db, row);
|
|
724
|
+
counts.sessionOutputs += 1;
|
|
725
|
+
}
|
|
726
|
+
for (const row of projectAliasRules) {
|
|
727
|
+
upsertProjectAliasRule(db, row);
|
|
728
|
+
counts.projectAliasRules += 1;
|
|
729
|
+
}
|
|
730
|
+
db.exec('COMMIT');
|
|
731
|
+
} catch (error) {
|
|
732
|
+
db.exec('ROLLBACK');
|
|
733
|
+
throw error;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
return counts;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export function applyAutoSessionAnnotations(db, suggestions = [], { runId = autoRunId(), threshold = 80 } = {}) {
|
|
740
|
+
const rows = Array.isArray(suggestions) ? suggestions : [];
|
|
741
|
+
const eligible = rows.filter(row => Number(row.applyConfidence ?? row.annotationConfidence ?? 0) >= threshold);
|
|
742
|
+
const select = db.prepare(`
|
|
743
|
+
SELECT project_alias AS projectAlias,
|
|
744
|
+
task_type AS taskType,
|
|
745
|
+
output_status AS outputStatus,
|
|
746
|
+
work_purpose AS workPurpose,
|
|
747
|
+
work_stage AS workStage,
|
|
748
|
+
value_level AS valueLevel,
|
|
749
|
+
note,
|
|
750
|
+
annotation_source AS annotationSource
|
|
751
|
+
FROM session_annotations
|
|
752
|
+
WHERE device = ? AND source = ? AND session_id = ?
|
|
753
|
+
`);
|
|
754
|
+
const result = {
|
|
755
|
+
runId,
|
|
756
|
+
threshold,
|
|
757
|
+
applied: 0,
|
|
758
|
+
skippedLowConfidence: rows.length - eligible.length,
|
|
759
|
+
skippedProtected: 0
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
db.exec('BEGIN');
|
|
763
|
+
try {
|
|
764
|
+
for (const row of eligible) {
|
|
765
|
+
const identity = normalizeSessionIdentity(row);
|
|
766
|
+
const current = select.get(identity.device, identity.source, identity.sessionId);
|
|
767
|
+
if (current && current.annotationSource !== 'auto') {
|
|
768
|
+
result.skippedProtected += 1;
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
const patchValues = row.applicableValues && typeof row.applicableValues === 'object'
|
|
772
|
+
? row.applicableValues
|
|
773
|
+
: row.values && typeof row.values === 'object' ? row.values : row;
|
|
774
|
+
const values = {
|
|
775
|
+
...DEFAULT_SESSION_ANNOTATION,
|
|
776
|
+
...(current || {}),
|
|
777
|
+
...patchValues
|
|
778
|
+
};
|
|
779
|
+
upsertSessionAnnotation(db, {
|
|
780
|
+
...identity,
|
|
781
|
+
projectAlias: values.projectAlias,
|
|
782
|
+
taskType: values.taskType,
|
|
783
|
+
outputStatus: values.outputStatus,
|
|
784
|
+
workPurpose: values.workPurpose,
|
|
785
|
+
workStage: values.workStage,
|
|
786
|
+
valueLevel: values.valueLevel,
|
|
787
|
+
note: values.note,
|
|
788
|
+
annotationSource: 'auto',
|
|
789
|
+
annotationConfidence: row.applyConfidence ?? row.annotationConfidence,
|
|
790
|
+
annotationReason: row.annotationReason,
|
|
791
|
+
autoVersion: row.autoVersion,
|
|
792
|
+
autoRunId: runId,
|
|
793
|
+
autoUpdatedAt: row.autoUpdatedAt || new Date().toISOString()
|
|
794
|
+
});
|
|
795
|
+
result.applied += 1;
|
|
796
|
+
}
|
|
797
|
+
db.exec('COMMIT');
|
|
798
|
+
} catch (error) {
|
|
799
|
+
db.exec('ROLLBACK');
|
|
800
|
+
throw error;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
return result;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
export function undoAutoSessionAnnotations(db, payload = {}) {
|
|
807
|
+
const runId = normalizedRequiredMax(payload.runId ?? payload.autoRunId ?? payload.auto_run_id, 'runId', 80);
|
|
808
|
+
return db.prepare(`
|
|
809
|
+
DELETE FROM session_annotations
|
|
810
|
+
WHERE annotation_source = 'auto' AND auto_run_id = ?
|
|
811
|
+
`).run(runId).changes;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
export function normalizeTokenEvent(row = {}) {
|
|
815
|
+
const device = normalizedRequired(row.device, 'device');
|
|
816
|
+
const source = normalizedRequired(row.source, 'source');
|
|
817
|
+
const sessionId = normalizedRequired(row.sessionId ?? row.session_id, 'sessionId');
|
|
818
|
+
const timestamp = normalizedRequiredMax(row.timestamp ?? row.createdAt ?? row.created_at, 'timestamp', 80);
|
|
819
|
+
const model = normalizeOptionalText(row.model, 'model', 120) || '';
|
|
820
|
+
const inputTokens = normalizeTokenCount(row.inputTokens ?? row.input_tokens, 'inputTokens');
|
|
821
|
+
const outputTokens = normalizeTokenCount(row.outputTokens ?? row.output_tokens, 'outputTokens');
|
|
822
|
+
const cacheReadTokens = normalizeTokenCount(row.cacheReadTokens ?? row.cache_read_tokens, 'cacheReadTokens');
|
|
823
|
+
const cacheCreationTokens = normalizeTokenCount(row.cacheCreationTokens ?? row.cache_creation_tokens, 'cacheCreationTokens');
|
|
824
|
+
const reasoningTokens = normalizeTokenCount(row.reasoningTokens ?? row.reasoning_tokens, 'reasoningTokens');
|
|
825
|
+
const toolCategory = normalizeOptionalText(row.toolCategory ?? row.tool_category, 'toolCategory', 80);
|
|
826
|
+
const fileExtension = normalizeOptionalText(row.fileExtension ?? row.file_extension, 'fileExtension', 24);
|
|
827
|
+
const repoPathHash = normalizeOptionalText(row.repoPathHash ?? row.repo_path_hash, 'repoPathHash', 128);
|
|
828
|
+
const privacyLevel = normalizeEnum(row.privacyLevel ?? row.privacy_level, PRIVACY_LEVELS, 'safe', 'privacyLevel');
|
|
829
|
+
const eventId = normalizeOptionalText(row.eventId ?? row.event_id, 'eventId', 240)
|
|
830
|
+
|| [
|
|
831
|
+
device,
|
|
832
|
+
source,
|
|
833
|
+
sessionId,
|
|
834
|
+
timestamp,
|
|
835
|
+
model,
|
|
836
|
+
inputTokens,
|
|
837
|
+
outputTokens,
|
|
838
|
+
cacheReadTokens,
|
|
839
|
+
cacheCreationTokens,
|
|
840
|
+
reasoningTokens,
|
|
841
|
+
toolCategory || '',
|
|
842
|
+
fileExtension || '',
|
|
843
|
+
repoPathHash || ''
|
|
844
|
+
].join('::');
|
|
845
|
+
|
|
846
|
+
return {
|
|
847
|
+
eventId,
|
|
848
|
+
device,
|
|
849
|
+
source,
|
|
850
|
+
sessionId,
|
|
851
|
+
timestamp,
|
|
852
|
+
model,
|
|
853
|
+
inputTokens,
|
|
854
|
+
outputTokens,
|
|
855
|
+
cacheReadTokens,
|
|
856
|
+
cacheCreationTokens,
|
|
857
|
+
reasoningTokens,
|
|
858
|
+
toolCategory,
|
|
859
|
+
fileExtension,
|
|
860
|
+
repoPathHash,
|
|
861
|
+
privacyLevel
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
export function upsertTokenEvent(db, row = {}) {
|
|
866
|
+
const event = normalizeTokenEvent(row);
|
|
867
|
+
db.prepare(`
|
|
868
|
+
INSERT INTO token_events (
|
|
869
|
+
event_id, device, source, session_id, timestamp, model,
|
|
870
|
+
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
|
871
|
+
reasoning_tokens, tool_category, file_extension, repo_path_hash,
|
|
872
|
+
privacy_level, updated_at
|
|
873
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
874
|
+
ON CONFLICT(event_id) DO UPDATE SET
|
|
875
|
+
device = excluded.device,
|
|
876
|
+
source = excluded.source,
|
|
877
|
+
session_id = excluded.session_id,
|
|
878
|
+
timestamp = excluded.timestamp,
|
|
879
|
+
model = excluded.model,
|
|
880
|
+
input_tokens = excluded.input_tokens,
|
|
881
|
+
output_tokens = excluded.output_tokens,
|
|
882
|
+
cache_read_tokens = excluded.cache_read_tokens,
|
|
883
|
+
cache_creation_tokens = excluded.cache_creation_tokens,
|
|
884
|
+
reasoning_tokens = excluded.reasoning_tokens,
|
|
885
|
+
tool_category = excluded.tool_category,
|
|
886
|
+
file_extension = excluded.file_extension,
|
|
887
|
+
repo_path_hash = excluded.repo_path_hash,
|
|
888
|
+
privacy_level = excluded.privacy_level,
|
|
889
|
+
updated_at = datetime('now')
|
|
890
|
+
`).run(
|
|
891
|
+
event.eventId,
|
|
892
|
+
event.device,
|
|
893
|
+
event.source,
|
|
894
|
+
event.sessionId,
|
|
895
|
+
event.timestamp,
|
|
896
|
+
event.model,
|
|
897
|
+
event.inputTokens,
|
|
898
|
+
event.outputTokens,
|
|
899
|
+
event.cacheReadTokens,
|
|
900
|
+
event.cacheCreationTokens,
|
|
901
|
+
event.reasoningTokens,
|
|
902
|
+
event.toolCategory,
|
|
903
|
+
event.fileExtension,
|
|
904
|
+
event.repoPathHash,
|
|
905
|
+
event.privacyLevel
|
|
906
|
+
);
|
|
907
|
+
return event;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
export function listTokenEvents(db, { limit = 500 } = {}) {
|
|
911
|
+
const safeLimit = Math.max(1, Math.min(5000, Number(limit) || 500));
|
|
912
|
+
return db.prepare(`
|
|
913
|
+
SELECT event_id AS eventId, device, source, session_id AS sessionId,
|
|
914
|
+
timestamp, model,
|
|
915
|
+
input_tokens AS inputTokens,
|
|
916
|
+
output_tokens AS outputTokens,
|
|
917
|
+
cache_read_tokens AS cacheReadTokens,
|
|
918
|
+
cache_creation_tokens AS cacheCreationTokens,
|
|
919
|
+
reasoning_tokens AS reasoningTokens,
|
|
920
|
+
tool_category AS toolCategory,
|
|
921
|
+
file_extension AS fileExtension,
|
|
922
|
+
repo_path_hash AS repoPathHash,
|
|
923
|
+
privacy_level AS privacyLevel,
|
|
924
|
+
updated_at AS updatedAt
|
|
925
|
+
FROM token_events
|
|
926
|
+
ORDER BY timestamp DESC
|
|
927
|
+
LIMIT ?
|
|
928
|
+
`).all(safeLimit);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
export function normalizeWorkItem(row = {}) {
|
|
932
|
+
const id = normalizeOptionalId(row.id, 'id');
|
|
933
|
+
const title = normalizedRequiredMax(row.title, 'title', 160);
|
|
934
|
+
const projectAlias = normalizeOptionalText(row.projectAlias ?? row.project_alias, 'projectAlias', 120);
|
|
935
|
+
const workType = normalizeEnum(row.workType ?? row.work_type, WORK_ITEM_TYPES, WORK_ITEM_TYPES[0], 'workType');
|
|
936
|
+
const status = normalizeEnum(row.status, OUTPUT_STATUSES, OUTPUT_STATUSES[0], 'status');
|
|
937
|
+
const valueLevel = normalizeEnum(row.valueLevel ?? row.value_level, VALUE_LEVELS, VALUE_LEVELS[0], 'valueLevel');
|
|
938
|
+
const outputUrl = row.outputUrl || row.output_url ? normalizeOutputUrl(row.outputUrl ?? row.output_url) : null;
|
|
939
|
+
const outputType = normalizeEnum(row.outputType ?? row.output_type, OUTPUT_TYPES, OUTPUT_TYPES[0], 'outputType');
|
|
940
|
+
return { id, title, projectAlias, workType, status, valueLevel, outputUrl, outputType };
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
export function upsertWorkItem(db, row = {}) {
|
|
944
|
+
const item = normalizeWorkItem(row);
|
|
945
|
+
db.prepare(`
|
|
946
|
+
INSERT INTO work_items (
|
|
947
|
+
id, title, project_alias, work_type, status, value_level,
|
|
948
|
+
output_url, output_type, updated_at
|
|
949
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
950
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
951
|
+
title = excluded.title,
|
|
952
|
+
project_alias = excluded.project_alias,
|
|
953
|
+
work_type = excluded.work_type,
|
|
954
|
+
status = excluded.status,
|
|
955
|
+
value_level = excluded.value_level,
|
|
956
|
+
output_url = excluded.output_url,
|
|
957
|
+
output_type = excluded.output_type,
|
|
958
|
+
updated_at = datetime('now')
|
|
959
|
+
`).run(
|
|
960
|
+
item.id,
|
|
961
|
+
item.title,
|
|
962
|
+
item.projectAlias,
|
|
963
|
+
item.workType,
|
|
964
|
+
item.status,
|
|
965
|
+
item.valueLevel,
|
|
966
|
+
item.outputUrl,
|
|
967
|
+
item.outputType
|
|
968
|
+
);
|
|
969
|
+
const id = item.id ?? db.prepare('SELECT last_insert_rowid() AS id').get().id;
|
|
970
|
+
return getWorkItem(db, id);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
export function getWorkItem(db, id) {
|
|
974
|
+
const itemId = normalizeRequiredId(id, 'id');
|
|
975
|
+
return db.prepare(`
|
|
976
|
+
SELECT id, title,
|
|
977
|
+
project_alias AS projectAlias,
|
|
978
|
+
work_type AS workType,
|
|
979
|
+
status,
|
|
980
|
+
value_level AS valueLevel,
|
|
981
|
+
output_url AS outputUrl,
|
|
982
|
+
output_type AS outputType,
|
|
983
|
+
created_at AS createdAt,
|
|
984
|
+
updated_at AS updatedAt
|
|
985
|
+
FROM work_items
|
|
986
|
+
WHERE id = ?
|
|
987
|
+
`).get(itemId);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
export function listWorkItems(db) {
|
|
991
|
+
const items = db.prepare(`
|
|
992
|
+
SELECT id, title,
|
|
993
|
+
project_alias AS projectAlias,
|
|
994
|
+
work_type AS workType,
|
|
995
|
+
status,
|
|
996
|
+
value_level AS valueLevel,
|
|
997
|
+
output_url AS outputUrl,
|
|
998
|
+
output_type AS outputType,
|
|
999
|
+
created_at AS createdAt,
|
|
1000
|
+
updated_at AS updatedAt
|
|
1001
|
+
FROM work_items
|
|
1002
|
+
ORDER BY updated_at DESC, id DESC
|
|
1003
|
+
`).all();
|
|
1004
|
+
const sessions = db.prepare(`
|
|
1005
|
+
SELECT work_item_id AS workItemId, device, source, session_id AS sessionId,
|
|
1006
|
+
linked_at AS linkedAt
|
|
1007
|
+
FROM work_item_sessions
|
|
1008
|
+
ORDER BY linked_at DESC
|
|
1009
|
+
`).all();
|
|
1010
|
+
const byItem = new Map();
|
|
1011
|
+
for (const session of sessions) {
|
|
1012
|
+
if (!byItem.has(session.workItemId)) byItem.set(session.workItemId, []);
|
|
1013
|
+
byItem.get(session.workItemId).push(session);
|
|
1014
|
+
}
|
|
1015
|
+
return items.map(item => ({
|
|
1016
|
+
...item,
|
|
1017
|
+
sessions: byItem.get(item.id) || []
|
|
1018
|
+
}));
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
export function linkWorkItemSessions(db, payload = {}) {
|
|
1022
|
+
const workItemId = normalizeRequiredId(payload.workItemId ?? payload.work_item_id ?? payload.id, 'workItemId');
|
|
1023
|
+
const sessions = Array.isArray(payload.sessions) ? payload.sessions : [];
|
|
1024
|
+
if (!sessions.length) throw new Error('sessions must include at least one item');
|
|
1025
|
+
let linked = 0;
|
|
1026
|
+
const insert = db.prepare(`
|
|
1027
|
+
INSERT OR IGNORE INTO work_item_sessions (work_item_id, device, source, session_id, linked_at)
|
|
1028
|
+
VALUES (?, ?, ?, ?, datetime('now'))
|
|
1029
|
+
`);
|
|
1030
|
+
db.exec('BEGIN');
|
|
1031
|
+
try {
|
|
1032
|
+
for (const row of sessions) {
|
|
1033
|
+
const identity = normalizeSessionIdentity(row);
|
|
1034
|
+
linked += insert.run(workItemId, identity.device, identity.source, identity.sessionId).changes;
|
|
1035
|
+
}
|
|
1036
|
+
db.exec('COMMIT');
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
db.exec('ROLLBACK');
|
|
1039
|
+
throw error;
|
|
1040
|
+
}
|
|
1041
|
+
return { workItemId, linked };
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
export function deleteWorkItem(db, row = {}) {
|
|
1045
|
+
const id = normalizeRequiredId(row.id, 'id');
|
|
1046
|
+
return db.prepare('DELETE FROM work_items WHERE id = ?').run(id).changes;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
export function normalizeBudgetProfile(row = {}) {
|
|
1050
|
+
const id = normalizeOptionalId(row.id, 'id');
|
|
1051
|
+
const source = normalizeOptionalText(row.source, 'source', 120) || '';
|
|
1052
|
+
const label = normalizedRequiredMax(row.label, 'label', 140);
|
|
1053
|
+
const windowType = normalizeEnum(row.windowType ?? row.window_type, BUDGET_WINDOW_TYPES, 'rolling', 'windowType');
|
|
1054
|
+
const windowMinutes = normalizePositiveInteger(row.windowMinutes ?? row.window_minutes, 'windowMinutes', 10_080);
|
|
1055
|
+
const tokenBudget = normalizeTokenCount(row.tokenBudget ?? row.token_budget, 'tokenBudget');
|
|
1056
|
+
const costBudgetUSD = normalizeNonNegativeNumber(row.costBudgetUSD ?? row.cost_budget_usd, 'costBudgetUSD');
|
|
1057
|
+
const resetAnchor = normalizeResetAnchor(row.resetAnchor ?? row.reset_anchor, windowType);
|
|
1058
|
+
const warningThreshold = normalizeWarningThreshold(row.warningThreshold ?? row.warning_threshold);
|
|
1059
|
+
const enabled = normalizeBoolean(row.enabled, true);
|
|
1060
|
+
if (tokenBudget === 0 && costBudgetUSD === 0) {
|
|
1061
|
+
throw new Error('tokenBudget or costBudgetUSD must be greater than 0');
|
|
1062
|
+
}
|
|
1063
|
+
return { id, source, label, windowType, windowMinutes, tokenBudget, costBudgetUSD, resetAnchor, warningThreshold, enabled };
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
export function upsertBudgetProfile(db, row = {}) {
|
|
1067
|
+
const profile = normalizeBudgetProfile(row);
|
|
1068
|
+
db.prepare(`
|
|
1069
|
+
INSERT INTO budget_profiles (
|
|
1070
|
+
id, source, label, window_type, window_minutes,
|
|
1071
|
+
token_budget, cost_budget_usd, reset_anchor, warning_threshold,
|
|
1072
|
+
enabled, updated_at
|
|
1073
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
1074
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1075
|
+
source = excluded.source,
|
|
1076
|
+
label = excluded.label,
|
|
1077
|
+
window_type = excluded.window_type,
|
|
1078
|
+
window_minutes = excluded.window_minutes,
|
|
1079
|
+
token_budget = excluded.token_budget,
|
|
1080
|
+
cost_budget_usd = excluded.cost_budget_usd,
|
|
1081
|
+
reset_anchor = excluded.reset_anchor,
|
|
1082
|
+
warning_threshold = excluded.warning_threshold,
|
|
1083
|
+
enabled = excluded.enabled,
|
|
1084
|
+
updated_at = datetime('now')
|
|
1085
|
+
`).run(
|
|
1086
|
+
profile.id,
|
|
1087
|
+
profile.source,
|
|
1088
|
+
profile.label,
|
|
1089
|
+
profile.windowType,
|
|
1090
|
+
profile.windowMinutes,
|
|
1091
|
+
profile.tokenBudget,
|
|
1092
|
+
profile.costBudgetUSD,
|
|
1093
|
+
profile.resetAnchor,
|
|
1094
|
+
profile.warningThreshold,
|
|
1095
|
+
profile.enabled ? 1 : 0
|
|
1096
|
+
);
|
|
1097
|
+
const id = profile.id ?? db.prepare('SELECT last_insert_rowid() AS id').get().id;
|
|
1098
|
+
return getBudgetProfile(db, id);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
export function getBudgetProfile(db, id) {
|
|
1102
|
+
const profileId = normalizeRequiredId(id, 'id');
|
|
1103
|
+
const row = db.prepare(`
|
|
1104
|
+
SELECT id, source, label,
|
|
1105
|
+
window_type AS windowType,
|
|
1106
|
+
window_minutes AS windowMinutes,
|
|
1107
|
+
token_budget AS tokenBudget,
|
|
1108
|
+
cost_budget_usd AS costBudgetUSD,
|
|
1109
|
+
reset_anchor AS resetAnchor,
|
|
1110
|
+
warning_threshold AS warningThreshold,
|
|
1111
|
+
enabled,
|
|
1112
|
+
updated_at AS updatedAt
|
|
1113
|
+
FROM budget_profiles
|
|
1114
|
+
WHERE id = ?
|
|
1115
|
+
`).get(profileId);
|
|
1116
|
+
return row ? { ...row, enabled: Boolean(row.enabled) } : null;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
export function listBudgetProfiles(db) {
|
|
1120
|
+
return db.prepare(`
|
|
1121
|
+
SELECT id, source, label,
|
|
1122
|
+
window_type AS windowType,
|
|
1123
|
+
window_minutes AS windowMinutes,
|
|
1124
|
+
token_budget AS tokenBudget,
|
|
1125
|
+
cost_budget_usd AS costBudgetUSD,
|
|
1126
|
+
reset_anchor AS resetAnchor,
|
|
1127
|
+
warning_threshold AS warningThreshold,
|
|
1128
|
+
enabled,
|
|
1129
|
+
updated_at AS updatedAt
|
|
1130
|
+
FROM budget_profiles
|
|
1131
|
+
ORDER BY enabled DESC, source ASC, updated_at DESC, id DESC
|
|
1132
|
+
`).all().map(row => ({ ...row, enabled: Boolean(row.enabled) }));
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
export function deleteBudgetProfile(db, row = {}) {
|
|
1136
|
+
const id = normalizeRequiredId(row.id, 'id');
|
|
1137
|
+
return db.prepare('DELETE FROM budget_profiles WHERE id = ?').run(id).changes;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
export function normalizeAdvisorAction(row = {}) {
|
|
1141
|
+
const id = normalizeOptionalId(row.id, 'id');
|
|
1142
|
+
const periodStart = normalizedRequiredMax(row.periodStart ?? row.period_start, 'periodStart', 40);
|
|
1143
|
+
const periodEnd = normalizedRequiredMax(row.periodEnd ?? row.period_end, 'periodEnd', 40);
|
|
1144
|
+
const category = normalizedRequiredMax(row.category, 'category', 80);
|
|
1145
|
+
const title = normalizedRequiredMax(row.title, 'title', 180);
|
|
1146
|
+
const action = normalizedRequiredMax(row.action, 'action', 700);
|
|
1147
|
+
const evidence = normalizeOptionalText(row.evidence, 'evidence', 1200);
|
|
1148
|
+
const sourceRule = normalizeOptionalText(row.sourceRule ?? row.source_rule, 'sourceRule', 180);
|
|
1149
|
+
const status = normalizeEnum(row.status, ADVISOR_ACTION_STATUSES, 'open', 'status');
|
|
1150
|
+
const completedAt = status === 'open'
|
|
1151
|
+
? null
|
|
1152
|
+
: normalizeOptionalText(row.completedAt ?? row.completed_at, 'completedAt', 80) || new Date().toISOString();
|
|
1153
|
+
return { id, periodStart, periodEnd, category, title, action, evidence, sourceRule, status, completedAt };
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
export function upsertAdvisorAction(db, row = {}) {
|
|
1157
|
+
const item = normalizeAdvisorAction(row);
|
|
1158
|
+
const existing = item.id ? null : item.sourceRule
|
|
1159
|
+
? db.prepare(`
|
|
1160
|
+
SELECT id FROM advisor_actions
|
|
1161
|
+
WHERE period_start = ? AND period_end = ? AND source_rule = ?
|
|
1162
|
+
ORDER BY id DESC
|
|
1163
|
+
LIMIT 1
|
|
1164
|
+
`).get(item.periodStart, item.periodEnd, item.sourceRule)
|
|
1165
|
+
: null;
|
|
1166
|
+
const id = item.id ?? existing?.id ?? null;
|
|
1167
|
+
|
|
1168
|
+
db.prepare(`
|
|
1169
|
+
INSERT INTO advisor_actions (
|
|
1170
|
+
id, period_start, period_end, category, title, action,
|
|
1171
|
+
evidence, source_rule, status, completed_at, updated_at
|
|
1172
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
1173
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1174
|
+
period_start = excluded.period_start,
|
|
1175
|
+
period_end = excluded.period_end,
|
|
1176
|
+
category = excluded.category,
|
|
1177
|
+
title = excluded.title,
|
|
1178
|
+
action = excluded.action,
|
|
1179
|
+
evidence = excluded.evidence,
|
|
1180
|
+
source_rule = excluded.source_rule,
|
|
1181
|
+
status = excluded.status,
|
|
1182
|
+
completed_at = excluded.completed_at,
|
|
1183
|
+
updated_at = datetime('now')
|
|
1184
|
+
`).run(
|
|
1185
|
+
id,
|
|
1186
|
+
item.periodStart,
|
|
1187
|
+
item.periodEnd,
|
|
1188
|
+
item.category,
|
|
1189
|
+
item.title,
|
|
1190
|
+
item.action,
|
|
1191
|
+
item.evidence,
|
|
1192
|
+
item.sourceRule,
|
|
1193
|
+
item.status,
|
|
1194
|
+
item.completedAt
|
|
1195
|
+
);
|
|
1196
|
+
return getAdvisorAction(db, id ?? db.prepare('SELECT last_insert_rowid() AS id').get().id);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
export function getAdvisorAction(db, id) {
|
|
1200
|
+
const itemId = normalizeRequiredId(id, 'id');
|
|
1201
|
+
return db.prepare(`
|
|
1202
|
+
SELECT id,
|
|
1203
|
+
period_start AS periodStart,
|
|
1204
|
+
period_end AS periodEnd,
|
|
1205
|
+
category, title, action, evidence,
|
|
1206
|
+
source_rule AS sourceRule,
|
|
1207
|
+
status,
|
|
1208
|
+
created_at AS createdAt,
|
|
1209
|
+
completed_at AS completedAt,
|
|
1210
|
+
updated_at AS updatedAt
|
|
1211
|
+
FROM advisor_actions
|
|
1212
|
+
WHERE id = ?
|
|
1213
|
+
`).get(itemId);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
export function listAdvisorActions(db, filters = {}) {
|
|
1217
|
+
const periodStart = normalizeOptionalText(filters.periodStart ?? filters.period_start, 'periodStart', 40);
|
|
1218
|
+
const periodEnd = normalizeOptionalText(filters.periodEnd ?? filters.period_end, 'periodEnd', 40);
|
|
1219
|
+
if (periodStart && periodEnd) {
|
|
1220
|
+
return db.prepare(`
|
|
1221
|
+
SELECT id,
|
|
1222
|
+
period_start AS periodStart,
|
|
1223
|
+
period_end AS periodEnd,
|
|
1224
|
+
category, title, action, evidence,
|
|
1225
|
+
source_rule AS sourceRule,
|
|
1226
|
+
status,
|
|
1227
|
+
created_at AS createdAt,
|
|
1228
|
+
completed_at AS completedAt,
|
|
1229
|
+
updated_at AS updatedAt
|
|
1230
|
+
FROM advisor_actions
|
|
1231
|
+
WHERE period_start = ? AND period_end = ?
|
|
1232
|
+
ORDER BY status = 'open' DESC, updated_at DESC, id DESC
|
|
1233
|
+
`).all(periodStart, periodEnd);
|
|
1234
|
+
}
|
|
1235
|
+
return db.prepare(`
|
|
1236
|
+
SELECT id,
|
|
1237
|
+
period_start AS periodStart,
|
|
1238
|
+
period_end AS periodEnd,
|
|
1239
|
+
category, title, action, evidence,
|
|
1240
|
+
source_rule AS sourceRule,
|
|
1241
|
+
status,
|
|
1242
|
+
created_at AS createdAt,
|
|
1243
|
+
completed_at AS completedAt,
|
|
1244
|
+
updated_at AS updatedAt
|
|
1245
|
+
FROM advisor_actions
|
|
1246
|
+
ORDER BY status = 'open' DESC, updated_at DESC, id DESC
|
|
1247
|
+
LIMIT 200
|
|
1248
|
+
`).all();
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
export function deleteAdvisorAction(db, row = {}) {
|
|
1252
|
+
const id = normalizeRequiredId(row.id, 'id');
|
|
1253
|
+
return db.prepare('DELETE FROM advisor_actions WHERE id = ?').run(id).changes;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function normalizeSessionIdentity(row = {}) {
|
|
1257
|
+
return {
|
|
1258
|
+
device: normalizedRequired(row.device, 'device'),
|
|
1259
|
+
source: normalizedRequired(row.source, 'source'),
|
|
1260
|
+
sessionId: normalizedRequired(row.sessionId ?? row.session_id, 'sessionId')
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function normalizedRequired(value, field) {
|
|
1265
|
+
return normalizedRequiredMax(value, field, 300);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function normalizedRequiredMax(value, field, maxLength) {
|
|
1269
|
+
const text = normalizeText(value);
|
|
1270
|
+
if (!text) throw new Error(`${field} is required`);
|
|
1271
|
+
if (text.length > maxLength) throw new Error(`${field} must be ${maxLength} characters or less`);
|
|
1272
|
+
return text;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function normalizeOptionalText(value, field, maxLength) {
|
|
1276
|
+
const text = normalizeText(value);
|
|
1277
|
+
if (!text) return null;
|
|
1278
|
+
if (text.length > maxLength) throw new Error(`${field} must be ${maxLength} characters or less`);
|
|
1279
|
+
return text;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function normalizeEnum(value, allowed, fallback, field) {
|
|
1283
|
+
const text = normalizeText(value) || fallback;
|
|
1284
|
+
if (!allowed.includes(text)) {
|
|
1285
|
+
throw new Error(`${field} must be one of: ${allowed.join(', ')}`);
|
|
1286
|
+
}
|
|
1287
|
+
return text;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function normalizeBoolean(value, fallback) {
|
|
1291
|
+
if (value == null || value === '') return Boolean(fallback);
|
|
1292
|
+
if (typeof value === 'boolean') return value;
|
|
1293
|
+
if (typeof value === 'number') return value !== 0;
|
|
1294
|
+
const text = String(value).trim().toLowerCase();
|
|
1295
|
+
if (['1', 'true', 'yes', 'on'].includes(text)) return true;
|
|
1296
|
+
if (['0', 'false', 'no', 'off'].includes(text)) return false;
|
|
1297
|
+
throw new Error('enabled must be a boolean');
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function normalizeConfidence(value, fallback) {
|
|
1301
|
+
if (value == null || value === '') return fallback;
|
|
1302
|
+
const number = Number(value);
|
|
1303
|
+
if (!Number.isInteger(number) || number < 0 || number > 100) {
|
|
1304
|
+
throw new Error('annotationConfidence must be an integer from 0 to 100');
|
|
1305
|
+
}
|
|
1306
|
+
return number;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function normalizeTokenCount(value, field) {
|
|
1310
|
+
const number = Number(value || 0);
|
|
1311
|
+
if (!Number.isInteger(number) || number < 0) {
|
|
1312
|
+
throw new Error(`${field} must be a non-negative integer`);
|
|
1313
|
+
}
|
|
1314
|
+
return number;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function normalizePositiveInteger(value, field, max = Number.MAX_SAFE_INTEGER) {
|
|
1318
|
+
const number = Number(value);
|
|
1319
|
+
if (!Number.isInteger(number) || number <= 0 || number > max) {
|
|
1320
|
+
throw new Error(`${field} must be a positive integer no greater than ${max}`);
|
|
1321
|
+
}
|
|
1322
|
+
return number;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function normalizeNonNegativeNumber(value, field) {
|
|
1326
|
+
const number = Number(value || 0);
|
|
1327
|
+
if (!Number.isFinite(number) || number < 0) {
|
|
1328
|
+
throw new Error(`${field} must be a non-negative number`);
|
|
1329
|
+
}
|
|
1330
|
+
return number;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
function normalizeWarningThreshold(value) {
|
|
1334
|
+
if (value == null || value === '') return 0.75;
|
|
1335
|
+
const number = Number(value);
|
|
1336
|
+
if (!Number.isFinite(number) || number <= 0 || number > 1) {
|
|
1337
|
+
throw new Error('warningThreshold must be greater than 0 and no greater than 1');
|
|
1338
|
+
}
|
|
1339
|
+
return number;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function normalizeResetAnchor(value, windowType) {
|
|
1343
|
+
const text = normalizeOptionalText(value, 'resetAnchor', 80);
|
|
1344
|
+
if (!text) return null;
|
|
1345
|
+
if (windowType !== 'fixed') return null;
|
|
1346
|
+
const ms = new Date(text).getTime();
|
|
1347
|
+
if (!Number.isFinite(ms)) {
|
|
1348
|
+
throw new Error('resetAnchor must be a valid date/time for fixed budget windows');
|
|
1349
|
+
}
|
|
1350
|
+
return new Date(ms).toISOString();
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function autoRunId() {
|
|
1354
|
+
return `auto-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
function normalizeOptionalId(value, field) {
|
|
1358
|
+
if (value == null || value === '') return null;
|
|
1359
|
+
return normalizeRequiredId(value, field);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function normalizeRequiredId(value, field) {
|
|
1363
|
+
const id = Number(value);
|
|
1364
|
+
if (!Number.isInteger(id) || id <= 0) throw new Error(`${field} must be a positive integer`);
|
|
1365
|
+
return id;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function normalizeOutputUrl(value) {
|
|
1369
|
+
const text = normalizeOptionalText(value, 'outputUrl', 500);
|
|
1370
|
+
if (!text) throw new Error('outputUrl is required');
|
|
1371
|
+
let parsed;
|
|
1372
|
+
try {
|
|
1373
|
+
parsed = new URL(text);
|
|
1374
|
+
} catch {
|
|
1375
|
+
throw new Error('outputUrl must be a valid URL');
|
|
1376
|
+
}
|
|
1377
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
1378
|
+
throw new Error('outputUrl must use http or https');
|
|
1379
|
+
}
|
|
1380
|
+
return parsed.href;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
function hasAny(row, keys) {
|
|
1384
|
+
return keys.some(key => Object.prototype.hasOwnProperty.call(row, key));
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
function normalizeText(value) {
|
|
1388
|
+
return String(value ?? '').trim().replace(/\s+/g, ' ');
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
function ensureColumn(db, table, column, definition) {
|
|
1392
|
+
const exists = db.prepare(`PRAGMA table_info(${table})`).all()
|
|
1393
|
+
.some(info => info.name === column);
|
|
1394
|
+
if (!exists) {
|
|
1395
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
1396
|
+
}
|
|
1397
|
+
}
|