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
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_SESSION_ANNOTATION,
|
|
7
|
+
OUTPUT_STATUSES,
|
|
8
|
+
OUTPUT_TYPES,
|
|
9
|
+
TASK_TYPES,
|
|
10
|
+
VALUE_LEVELS,
|
|
11
|
+
WORK_PURPOSES,
|
|
12
|
+
WORK_STAGES,
|
|
13
|
+
defaultDbPath,
|
|
14
|
+
listProjectAliasRules,
|
|
15
|
+
matchProjectAliasRule
|
|
16
|
+
} from './db.mjs';
|
|
17
|
+
import { attachOfficialPricing } from './pricing.mjs';
|
|
18
|
+
import { buildReviewClosureProgress } from './client/review/closure-progress.js';
|
|
19
|
+
import { buildRoiAdvisor } from './client/review/roi-advisor.js';
|
|
20
|
+
|
|
21
|
+
export function openReadOnlyDb(dbPath = defaultDbPath) {
|
|
22
|
+
const resolved = resolve(dbPath);
|
|
23
|
+
if (!existsSync(resolved)) {
|
|
24
|
+
throw new Error(`SQLite database not found: ${resolved}`);
|
|
25
|
+
}
|
|
26
|
+
return new DatabaseSync(resolved, {
|
|
27
|
+
readOnly: true,
|
|
28
|
+
timeout: 10000
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildClosureAuditFromDb(db, options = {}) {
|
|
33
|
+
assertRequiredTables(db);
|
|
34
|
+
const daily = loadDailyRows(db);
|
|
35
|
+
const sessions = loadSessionRows(db);
|
|
36
|
+
const roiAdvice = buildRoiAdvisor({ sessions, daily });
|
|
37
|
+
const progress = buildReviewClosureProgress({
|
|
38
|
+
sessions,
|
|
39
|
+
roiAdvice,
|
|
40
|
+
targetAttributedSessions: options.targetAttributedSessions,
|
|
41
|
+
targetOutputLinks: options.targetOutputLinks,
|
|
42
|
+
targetNonLabelAdvice: options.targetNonLabelAdvice,
|
|
43
|
+
topGapLimit: options.topGapLimit
|
|
44
|
+
});
|
|
45
|
+
const publicProgress = sanitizeProgress(progress);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
generatedAt: new Date().toISOString(),
|
|
49
|
+
dbPath: options.dbPath || null,
|
|
50
|
+
status: publicProgress.status,
|
|
51
|
+
counts: {
|
|
52
|
+
sessions: sessions.length,
|
|
53
|
+
daily: daily.length,
|
|
54
|
+
annotations: countRows(db, 'session_annotations'),
|
|
55
|
+
outputs: countRows(db, 'session_outputs')
|
|
56
|
+
},
|
|
57
|
+
progress: publicProgress,
|
|
58
|
+
roiAdvice
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatClosureAudit(audit) {
|
|
63
|
+
const lines = [
|
|
64
|
+
'Token Studio P0 Closure Check',
|
|
65
|
+
'',
|
|
66
|
+
`Status: ${audit.status === 'complete' ? 'complete' : 'needs-work'}`,
|
|
67
|
+
audit.dbPath ? `DB: ${audit.dbPath}` : null,
|
|
68
|
+
`Sessions: ${audit.counts.sessions}`,
|
|
69
|
+
`Daily rows: ${audit.counts.daily}`,
|
|
70
|
+
`Annotations: ${audit.counts.annotations}`,
|
|
71
|
+
`Output links: ${audit.counts.outputs}`,
|
|
72
|
+
'',
|
|
73
|
+
'Checks:'
|
|
74
|
+
].filter(line => line != null);
|
|
75
|
+
|
|
76
|
+
for (const check of audit.progress.checks) {
|
|
77
|
+
lines.push(`- ${check.complete ? '[x]' : '[ ]'} ${check.label}: ${check.current}/${check.target} ${check.unit}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (audit.progress.topGaps.length) {
|
|
81
|
+
lines.push('', 'Top gaps:');
|
|
82
|
+
for (const [index, row] of audit.progress.topGaps.slice(0, 5).entries()) {
|
|
83
|
+
lines.push(`${index + 1}. ${row.project} | ${row.sessionId || 'unknown'} | missing ${row.missingFields.join(', ')} | ${formatInt(row.totalTokens)} tokens | ${money(row.costUSD)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (audit.progress.nextActions.length) {
|
|
88
|
+
lines.push('', 'Next actions:');
|
|
89
|
+
audit.progress.nextActions.forEach((action, index) => {
|
|
90
|
+
lines.push(`${index + 1}. ${action}`);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
lines.push(
|
|
95
|
+
'',
|
|
96
|
+
'Privacy: read-only SQLite audit; no collect command, no conversation content, no output-link fetching.'
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return lines.join('\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function formatClosureWorklist(audit, { limit = 10 } = {}) {
|
|
103
|
+
const rows = audit.progress.topGaps.slice(0, Math.max(1, limit));
|
|
104
|
+
const lines = [
|
|
105
|
+
'# Token Studio P0 Attribution Worklist',
|
|
106
|
+
'',
|
|
107
|
+
`- Generated at: ${audit.generatedAt}`,
|
|
108
|
+
audit.dbPath ? `- SQLite: ${audit.dbPath}` : null,
|
|
109
|
+
'- Scope: local structured usage and existing annotations only.',
|
|
110
|
+
'- Privacy: no conversation content, no collect command, no SQLite writes, no output-link fetching.',
|
|
111
|
+
'- Instructions: fill the blank columns manually after checking your real work, then save labels in the dashboard.',
|
|
112
|
+
'',
|
|
113
|
+
'Allowed values:',
|
|
114
|
+
'- Task type: 未分类 / 功能开发 / 问题修复 / 代码审查 / 技术调研 / 内容创作 / 运维配置 / 其他',
|
|
115
|
+
'- Output status: 未标注 / 进行中 / 已完成 / 已发布 / 已废弃',
|
|
116
|
+
'- Work purpose: 未说明 / 需求澄清 / 方案设计 / 功能开发 / 调试修复 / 测试验证 / 代码审查 / 技术调研 / 文档内容 / 部署运维 / 上下文整理 / 其他',
|
|
117
|
+
'- Work stage: 未说明 / 探索 / 实现 / 验证 / 发布 / 维护',
|
|
118
|
+
'- Value level: 未评估 / 低 / 中 / 高 / 关键',
|
|
119
|
+
''
|
|
120
|
+
].filter(line => line != null);
|
|
121
|
+
|
|
122
|
+
if (!rows.length) {
|
|
123
|
+
return [
|
|
124
|
+
...lines,
|
|
125
|
+
'No remaining P0 attribution gaps in the current SQLite database.'
|
|
126
|
+
].join('\n');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
lines.push(markdownTable(
|
|
130
|
+
[
|
|
131
|
+
'#',
|
|
132
|
+
'Project',
|
|
133
|
+
'Session',
|
|
134
|
+
'Missing fields',
|
|
135
|
+
'Tokens',
|
|
136
|
+
'Official price',
|
|
137
|
+
'Project alias',
|
|
138
|
+
'Task type',
|
|
139
|
+
'Output status',
|
|
140
|
+
'Work purpose',
|
|
141
|
+
'Work stage',
|
|
142
|
+
'Value level',
|
|
143
|
+
'Output URL',
|
|
144
|
+
'Output type'
|
|
145
|
+
],
|
|
146
|
+
rows.map((row, index) => [
|
|
147
|
+
index + 1,
|
|
148
|
+
row.project,
|
|
149
|
+
row.sessionId,
|
|
150
|
+
row.missingFields.join(', '),
|
|
151
|
+
formatInt(row.totalTokens),
|
|
152
|
+
money(row.costUSD),
|
|
153
|
+
'',
|
|
154
|
+
'',
|
|
155
|
+
'',
|
|
156
|
+
'',
|
|
157
|
+
'',
|
|
158
|
+
'',
|
|
159
|
+
'',
|
|
160
|
+
''
|
|
161
|
+
])
|
|
162
|
+
));
|
|
163
|
+
|
|
164
|
+
lines.push(
|
|
165
|
+
'',
|
|
166
|
+
'Completion gate:',
|
|
167
|
+
`- Fully attributed sessions: ${audit.progress.checks.find(check => check.id === 'real-attribution')?.current || 0} / ${audit.progress.checks.find(check => check.id === 'real-attribution')?.target || 10}`,
|
|
168
|
+
`- Output links: ${audit.progress.checks.find(check => check.id === 'output-links')?.current || 0} / ${audit.progress.checks.find(check => check.id === 'output-links')?.target || 3}`,
|
|
169
|
+
`- Non-labeling Advisor items: ${audit.progress.checks.find(check => check.id === 'non-label-advice')?.current || 0} / ${audit.progress.checks.find(check => check.id === 'non-label-advice')?.target || 1}`
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
return lines.join('\n');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function buildClosureImportTemplate(audit, { limit = 10 } = {}) {
|
|
176
|
+
const rows = audit.progress.topGaps.slice(0, Math.max(1, limit));
|
|
177
|
+
return {
|
|
178
|
+
generatedAt: audit.generatedAt,
|
|
179
|
+
dbPath: audit.dbPath,
|
|
180
|
+
privacy: [
|
|
181
|
+
'Generated from structured session_usage rows only.',
|
|
182
|
+
'No collect command was run.',
|
|
183
|
+
'No conversation content was read.',
|
|
184
|
+
'No SQLite writes were performed.',
|
|
185
|
+
'Fill labels manually from your real work before importing.'
|
|
186
|
+
],
|
|
187
|
+
allowedValues: {
|
|
188
|
+
taskType: TASK_TYPES,
|
|
189
|
+
outputStatus: OUTPUT_STATUSES,
|
|
190
|
+
workPurpose: WORK_PURPOSES,
|
|
191
|
+
workStage: WORK_STAGES,
|
|
192
|
+
valueLevel: VALUE_LEVELS,
|
|
193
|
+
outputType: OUTPUT_TYPES
|
|
194
|
+
},
|
|
195
|
+
sessions: rows.map(row => ({
|
|
196
|
+
device: row.device || '',
|
|
197
|
+
source: row.source || '',
|
|
198
|
+
sessionId: row.sessionId || '',
|
|
199
|
+
projectPath: row.projectPath || '',
|
|
200
|
+
projectHint: row.project || '',
|
|
201
|
+
lastActivity: row.lastActivity || '',
|
|
202
|
+
model: row.model || '',
|
|
203
|
+
totalTokens: Number(row.totalTokens || 0),
|
|
204
|
+
officialCostUSD: Number(row.costUSD || 0),
|
|
205
|
+
missingFields: row.missingFields || [],
|
|
206
|
+
projectAlias: '',
|
|
207
|
+
taskType: '',
|
|
208
|
+
outputStatus: '',
|
|
209
|
+
workPurpose: '',
|
|
210
|
+
workStage: '',
|
|
211
|
+
valueLevel: '',
|
|
212
|
+
note: '',
|
|
213
|
+
outputUrl: '',
|
|
214
|
+
outputLabel: '',
|
|
215
|
+
outputType: ''
|
|
216
|
+
}))
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function formatClosureImportTemplate(audit, { limit = 10 } = {}) {
|
|
221
|
+
return `${JSON.stringify(buildClosureImportTemplate(audit, { limit }), null, 2)}\n`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function parseArgs(argv = process.argv.slice(2)) {
|
|
225
|
+
const options = {
|
|
226
|
+
dbPath: process.env.DB_PATH || defaultDbPath,
|
|
227
|
+
json: false,
|
|
228
|
+
failOnIncomplete: false,
|
|
229
|
+
worklist: false,
|
|
230
|
+
templateJson: false,
|
|
231
|
+
outPath: null,
|
|
232
|
+
worklistLimit: 10
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
236
|
+
const arg = argv[i];
|
|
237
|
+
if (arg === '--json') {
|
|
238
|
+
options.json = true;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (arg === '--fail-on-incomplete') {
|
|
242
|
+
options.failOnIncomplete = true;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (arg === '--worklist') {
|
|
246
|
+
options.worklist = true;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (arg === '--template-json') {
|
|
250
|
+
options.templateJson = true;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (arg === '--help' || arg === '-h') {
|
|
254
|
+
options.help = true;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (arg === '--db') {
|
|
258
|
+
options.dbPath = argv[++i];
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (arg.startsWith('--db=')) {
|
|
262
|
+
options.dbPath = arg.slice('--db='.length);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (arg === '--out') {
|
|
266
|
+
options.outPath = argv[++i];
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (arg.startsWith('--out=')) {
|
|
270
|
+
options.outPath = arg.slice('--out='.length);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (arg.startsWith('--target-sessions=')) {
|
|
274
|
+
options.targetAttributedSessions = parsePositiveInt(arg.slice('--target-sessions='.length), 'target-sessions');
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (arg.startsWith('--target-outputs=')) {
|
|
278
|
+
options.targetOutputLinks = parsePositiveInt(arg.slice('--target-outputs='.length), 'target-outputs');
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (arg === '--limit') {
|
|
282
|
+
options.worklistLimit = parsePositiveInt(argv[++i], 'limit');
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (arg.startsWith('--limit=')) {
|
|
286
|
+
options.worklistLimit = parsePositiveInt(arg.slice('--limit='.length), 'limit');
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (options.worklist || options.templateJson) {
|
|
293
|
+
options.topGapLimit = options.worklistLimit;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return options;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function runCli() {
|
|
300
|
+
try {
|
|
301
|
+
const options = parseArgs();
|
|
302
|
+
if (options.help) {
|
|
303
|
+
console.log(helpText());
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const dbPath = resolve(options.dbPath || defaultDbPath);
|
|
308
|
+
const db = openReadOnlyDb(dbPath);
|
|
309
|
+
try {
|
|
310
|
+
const audit = buildClosureAuditFromDb(db, { ...options, dbPath });
|
|
311
|
+
const output = options.templateJson
|
|
312
|
+
? formatClosureImportTemplate(audit, { limit: options.worklistLimit })
|
|
313
|
+
: options.worklist
|
|
314
|
+
? formatClosureWorklist(audit, { limit: options.worklistLimit })
|
|
315
|
+
: options.json ? JSON.stringify(audit, null, 2) : formatClosureAudit(audit);
|
|
316
|
+
if (options.outPath) {
|
|
317
|
+
const outPath = resolve(options.outPath);
|
|
318
|
+
writeFileSync(outPath, output, 'utf8');
|
|
319
|
+
console.log(`Wrote ${outPath}`);
|
|
320
|
+
} else {
|
|
321
|
+
console.log(output);
|
|
322
|
+
}
|
|
323
|
+
if (options.failOnIncomplete && audit.status !== 'complete') {
|
|
324
|
+
process.exitCode = 1;
|
|
325
|
+
}
|
|
326
|
+
} finally {
|
|
327
|
+
db.close();
|
|
328
|
+
}
|
|
329
|
+
} catch (error) {
|
|
330
|
+
console.error(`closure:check failed: ${error.message}`);
|
|
331
|
+
process.exitCode = 2;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function assertRequiredTables(db) {
|
|
336
|
+
const required = ['daily_usage', 'session_usage', 'session_annotations', 'session_outputs', 'project_alias_rules'];
|
|
337
|
+
const existing = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map(row => row.name));
|
|
338
|
+
const missing = required.filter(table => !existing.has(table));
|
|
339
|
+
if (missing.length) {
|
|
340
|
+
throw new Error(`SQLite schema is missing required table(s): ${missing.join(', ')}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function loadSessionRows(db) {
|
|
345
|
+
const aliasRules = listProjectAliasRules(db, { enabledOnly: true });
|
|
346
|
+
return db.prepare(`
|
|
347
|
+
SELECT s.device, s.source,
|
|
348
|
+
s.session_id AS sessionId,
|
|
349
|
+
s.last_activity AS lastActivity,
|
|
350
|
+
s.project_path AS projectPath,
|
|
351
|
+
s.input_tokens AS inputTokens,
|
|
352
|
+
s.output_tokens AS outputTokens,
|
|
353
|
+
s.cache_creation_tokens AS cacheCreationTokens,
|
|
354
|
+
s.cache_read_tokens AS cacheReadTokens,
|
|
355
|
+
s.cached_input_tokens AS cachedInputTokens,
|
|
356
|
+
s.reasoning_output_tokens AS reasoningOutputTokens,
|
|
357
|
+
s.total_tokens AS totalTokens,
|
|
358
|
+
s.cost_usd AS costUSD,
|
|
359
|
+
a.project_alias AS manualProjectAlias,
|
|
360
|
+
COALESCE(a.task_type, '未分类') AS taskType,
|
|
361
|
+
COALESCE(a.output_status, '未标注') AS outputStatus,
|
|
362
|
+
COALESCE(a.work_purpose, '未说明') AS workPurpose,
|
|
363
|
+
COALESCE(a.work_stage, '未说明') AS workStage,
|
|
364
|
+
COALESCE(a.value_level, '未评估') AS valueLevel,
|
|
365
|
+
a.note,
|
|
366
|
+
o.output_url AS outputUrl,
|
|
367
|
+
o.output_label AS outputLabel,
|
|
368
|
+
COALESCE(o.output_type, '未分类') AS outputType
|
|
369
|
+
FROM session_usage s
|
|
370
|
+
LEFT JOIN session_annotations a
|
|
371
|
+
ON a.device = s.device
|
|
372
|
+
AND a.source = s.source
|
|
373
|
+
AND a.session_id = s.session_id
|
|
374
|
+
LEFT JOIN session_outputs o
|
|
375
|
+
ON o.device = s.device
|
|
376
|
+
AND o.source = s.source
|
|
377
|
+
AND o.session_id = s.session_id
|
|
378
|
+
ORDER BY s.total_tokens DESC
|
|
379
|
+
`).all().map(row => {
|
|
380
|
+
const projectPath = (row.projectPath && row.projectPath !== 'Unknown Project')
|
|
381
|
+
? row.projectPath
|
|
382
|
+
: (row.sessionId ? row.sessionId.split('/').slice(-1)[0] || row.sessionId : null);
|
|
383
|
+
const ruleProjectAlias = matchProjectAliasRule(projectPath, aliasRules);
|
|
384
|
+
const model = modelFromSessionId(row.sessionId);
|
|
385
|
+
return attachOfficialPricing({
|
|
386
|
+
...row,
|
|
387
|
+
...DEFAULT_SESSION_ANNOTATION,
|
|
388
|
+
model,
|
|
389
|
+
lastActivity: row.lastActivity ? String(row.lastActivity).slice(0, 10) : null,
|
|
390
|
+
projectPath,
|
|
391
|
+
projectAlias: row.manualProjectAlias || ruleProjectAlias || null,
|
|
392
|
+
manualProjectAlias: row.manualProjectAlias || null,
|
|
393
|
+
ruleProjectAlias,
|
|
394
|
+
taskType: row.taskType || DEFAULT_SESSION_ANNOTATION.taskType,
|
|
395
|
+
outputStatus: row.outputStatus || DEFAULT_SESSION_ANNOTATION.outputStatus,
|
|
396
|
+
workPurpose: row.workPurpose || DEFAULT_SESSION_ANNOTATION.workPurpose,
|
|
397
|
+
workStage: row.workStage || DEFAULT_SESSION_ANNOTATION.workStage,
|
|
398
|
+
valueLevel: row.valueLevel || DEFAULT_SESSION_ANNOTATION.valueLevel,
|
|
399
|
+
note: row.note || null,
|
|
400
|
+
outputUrl: row.outputUrl || null,
|
|
401
|
+
outputLabel: row.outputLabel || null,
|
|
402
|
+
outputType: row.outputType || DEFAULT_SESSION_ANNOTATION.outputType
|
|
403
|
+
}, model, providerFromSource(row.source));
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function loadDailyRows(db) {
|
|
408
|
+
return db.prepare(`
|
|
409
|
+
SELECT device, source,
|
|
410
|
+
usage_date AS usageDate,
|
|
411
|
+
model,
|
|
412
|
+
input_tokens AS inputTokens,
|
|
413
|
+
output_tokens AS outputTokens,
|
|
414
|
+
cache_creation_tokens AS cacheCreationTokens,
|
|
415
|
+
cache_read_tokens AS cacheReadTokens,
|
|
416
|
+
cached_input_tokens AS cachedInputTokens,
|
|
417
|
+
reasoning_output_tokens AS reasoningOutputTokens,
|
|
418
|
+
total_tokens AS totalTokens,
|
|
419
|
+
cost_usd AS costUSD
|
|
420
|
+
FROM daily_usage
|
|
421
|
+
ORDER BY usage_date DESC
|
|
422
|
+
`).all().map(row => attachOfficialPricing(
|
|
423
|
+
row,
|
|
424
|
+
row.model,
|
|
425
|
+
providerFromSource(row.source)
|
|
426
|
+
));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function countRows(db, table) {
|
|
430
|
+
return db.prepare(`SELECT COUNT(*) AS total FROM ${table}`).get().total;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function sanitizeProgress(progress) {
|
|
434
|
+
return {
|
|
435
|
+
...progress,
|
|
436
|
+
annotatedSessions: progress.annotatedSessions.map(summarizeSession),
|
|
437
|
+
outputSessions: progress.outputSessions.map(summarizeSession),
|
|
438
|
+
topGaps: progress.topGaps.map(({ session, ...row }) => row)
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function summarizeSession(session = {}) {
|
|
443
|
+
return {
|
|
444
|
+
device: session.device || '',
|
|
445
|
+
source: session.source || '',
|
|
446
|
+
sessionId: session.sessionId || '',
|
|
447
|
+
project: session.projectAlias || session.projectPath || '',
|
|
448
|
+
taskType: session.taskType || '',
|
|
449
|
+
outputStatus: session.outputStatus || '',
|
|
450
|
+
workPurpose: session.workPurpose || '',
|
|
451
|
+
workStage: session.workStage || '',
|
|
452
|
+
valueLevel: session.valueLevel || '',
|
|
453
|
+
outputUrl: session.outputUrl || null,
|
|
454
|
+
outputLabel: session.outputLabel || null,
|
|
455
|
+
outputType: session.outputType || null,
|
|
456
|
+
totalTokens: session.totalTokens || 0,
|
|
457
|
+
costUSD: session.costUSD || 0
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function providerFromSource(source) {
|
|
462
|
+
const value = String(source || '').toLowerCase();
|
|
463
|
+
if (value.includes('codex') || value.includes('openai')) return 'openai';
|
|
464
|
+
if (value.includes('claude') || value.includes('anthropic')) return 'anthropic';
|
|
465
|
+
if (value.includes('deepseek')) return 'deepseek';
|
|
466
|
+
if (value.includes('mimo') || value.includes('xiaomi')) return 'xiaomi';
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function modelFromSessionId(sessionId) {
|
|
471
|
+
const text = String(sessionId || '').trim();
|
|
472
|
+
if (!text) return null;
|
|
473
|
+
if (text.startsWith('local:')) return text.split(':').at(-1) || null;
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function parsePositiveInt(value, label) {
|
|
478
|
+
const number = Number(value);
|
|
479
|
+
if (!Number.isInteger(number) || number <= 0) {
|
|
480
|
+
throw new Error(`${label} must be a positive integer`);
|
|
481
|
+
}
|
|
482
|
+
return number;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function helpText() {
|
|
486
|
+
return [
|
|
487
|
+
'Usage: npm run closure:check -- [options]',
|
|
488
|
+
'',
|
|
489
|
+
'Options:',
|
|
490
|
+
' --db <path> SQLite database path. Defaults to DB_PATH or data/usage.sqlite.',
|
|
491
|
+
' --json Print machine-readable JSON.',
|
|
492
|
+
' --worklist Print a Markdown worksheet for the highest-cost attribution gaps.',
|
|
493
|
+
' --template-json Print a fillable closure:import JSON template for the highest-cost attribution gaps.',
|
|
494
|
+
' --out <path> Write command output as a UTF-8 file.',
|
|
495
|
+
' --limit=<n> Worklist/template row limit. Default: 10.',
|
|
496
|
+
' --fail-on-incomplete Exit 1 when the P0 closure gate is incomplete.',
|
|
497
|
+
' --target-sessions=<n> Required fully attributed session count. Default: 10.',
|
|
498
|
+
' --target-outputs=<n> Required completed/published output link count. Default: 3.',
|
|
499
|
+
' -h, --help Show this help.',
|
|
500
|
+
'',
|
|
501
|
+
'This command is read-only: it does not run collect, write SQLite, read conversation content, or fetch output links.'
|
|
502
|
+
].join('\n');
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function formatInt(value) {
|
|
506
|
+
return new Intl.NumberFormat('zh-CN').format(Math.round(Number(value || 0)));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function money(value) {
|
|
510
|
+
return new Intl.NumberFormat('en-US', {
|
|
511
|
+
style: 'currency',
|
|
512
|
+
currency: 'USD',
|
|
513
|
+
minimumFractionDigits: 2,
|
|
514
|
+
maximumFractionDigits: 2
|
|
515
|
+
}).format(Number(value || 0));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function markdownTable(headers, rows) {
|
|
519
|
+
return [
|
|
520
|
+
`| ${headers.map(markdownCell).join(' | ')} |`,
|
|
521
|
+
`| ${headers.map(() => '---').join(' | ')} |`,
|
|
522
|
+
...rows.map(row => `| ${row.map(markdownCell).join(' | ')} |`)
|
|
523
|
+
].join('\n');
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function markdownCell(value) {
|
|
527
|
+
const text = String(value ?? '')
|
|
528
|
+
.replace(/\r?\n/g, ' ')
|
|
529
|
+
.replace(/\s+/g, ' ')
|
|
530
|
+
.trim();
|
|
531
|
+
const formulaSafe = /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
|
|
532
|
+
return formulaSafe.replace(/\|/g, '\\|');
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
|
536
|
+
runCli();
|
|
537
|
+
}
|