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.
Files changed (139) hide show
  1. package/.nvmrc +1 -0
  2. package/CHANGELOG.md +89 -0
  3. package/Dockerfile +17 -0
  4. package/LICENSE +22 -0
  5. package/NOTICE.md +21 -0
  6. package/PRIVACY.md +68 -0
  7. package/README.en.md +220 -0
  8. package/README.md +220 -0
  9. package/config/collectors.json +54 -0
  10. package/data/.gitkeep +1 -0
  11. package/docker-compose.yml +17 -0
  12. package/docs/assets/.gitkeep +1 -0
  13. package/docs/assets/token-studio-v44-dashboard.png +0 -0
  14. package/docs/assets/token-studio-v44-live.png +0 -0
  15. package/docs/assets/token-studio-v44-review-mobile.png +0 -0
  16. package/docs/assets/token-studio-v44-review.png +0 -0
  17. package/docs/assets/token-studio-v45-dashboard.png +0 -0
  18. package/docs/assets/token-studio-v45-live.png +0 -0
  19. package/docs/assets/token-studio-v45-review-mobile.png +0 -0
  20. package/docs/assets/token-studio-v45-review.png +0 -0
  21. package/docs/blog-case-study.md +34 -0
  22. package/docs/collector-support-matrix.md +65 -0
  23. package/docs/competitive-notes.md +87 -0
  24. package/docs/demo-data/README.md +12 -0
  25. package/docs/demo-data/token-studio-v2-demo.json +146 -0
  26. package/docs/demo-flow.md +39 -0
  27. package/docs/first-run.md +95 -0
  28. package/docs/local-collectors.md +49 -0
  29. package/docs/public-launch-checklist.md +45 -0
  30. package/docs/resume-bullets.md +7 -0
  31. package/docs/statusline.md +52 -0
  32. package/index.html +16 -0
  33. package/package.json +36 -0
  34. package/render.yaml +17 -0
  35. package/src/auto-attribution.mjs +396 -0
  36. package/src/ccusage-bridge.mjs +74 -0
  37. package/src/ccusage-import.mjs +415 -0
  38. package/src/cli.mjs +643 -0
  39. package/src/client/dashboard/App.jsx +1734 -0
  40. package/src/client/dashboard/annotation-presets.js +138 -0
  41. package/src/client/dashboard/attribution.js +328 -0
  42. package/src/client/dashboard/components-charts.jsx +622 -0
  43. package/src/client/dashboard/components-tables.jsx +1531 -0
  44. package/src/client/dashboard/components-top.jsx +307 -0
  45. package/src/client/dashboard/import-budget.js +41 -0
  46. package/src/client/dashboard/model-usage.js +108 -0
  47. package/src/client/dashboard/onboarding.js +80 -0
  48. package/src/client/dashboard/styles.css +2606 -0
  49. package/src/client/live/LiveApp.jsx +226 -0
  50. package/src/client/live/styles.css +446 -0
  51. package/src/client/main.jsx +20 -0
  52. package/src/client/review/ReviewApp.jsx +507 -0
  53. package/src/client/review/closure-progress.js +165 -0
  54. package/src/client/review/markdown-report.js +401 -0
  55. package/src/client/review/model-strategy.js +273 -0
  56. package/src/client/review/roi-advisor.js +255 -0
  57. package/src/client/review/roi-evidence.js +78 -0
  58. package/src/client/review/savings-simulator.js +252 -0
  59. package/src/client/review/sections-1.jsx +277 -0
  60. package/src/client/review/sections-2.jsx +927 -0
  61. package/src/client/review/styles.css +2321 -0
  62. package/src/client/review/utils.js +345 -0
  63. package/src/client/shared/utils.js +236 -0
  64. package/src/closure-check.mjs +537 -0
  65. package/src/closure-import.mjs +646 -0
  66. package/src/collect.mjs +247 -0
  67. package/src/collector-config.mjs +82 -0
  68. package/src/collector-registry.mjs +333 -0
  69. package/src/collectors/claude-code.mjs +355 -0
  70. package/src/collectors/codex.mjs +418 -0
  71. package/src/collectors/copilot.mjs +19 -0
  72. package/src/collectors/cursor.mjs +23 -0
  73. package/src/collectors/gemini.mjs +530 -0
  74. package/src/collectors/goose.mjs +15 -0
  75. package/src/collectors/hermes.mjs +206 -0
  76. package/src/collectors/kimi.mjs +15 -0
  77. package/src/collectors/openclaw.mjs +400 -0
  78. package/src/collectors/opencode.mjs +349 -0
  79. package/src/collectors/qwen.mjs +15 -0
  80. package/src/collectors/structured-usage.mjs +437 -0
  81. package/src/collectors/utils.mjs +93 -0
  82. package/src/db.mjs +1397 -0
  83. package/src/demo-seed.mjs +39 -0
  84. package/src/dev.mjs +43 -0
  85. package/src/live.mjs +428 -0
  86. package/src/model-policy.mjs +147 -0
  87. package/src/pricing.mjs +434 -0
  88. package/src/privacy-check.mjs +126 -0
  89. package/src/server.mjs +1240 -0
  90. package/src/source-health.mjs +195 -0
  91. package/src/statusline.mjs +156 -0
  92. package/src/terminal-report.mjs +245 -0
  93. package/src/update-pricing.mjs +8 -0
  94. package/test/annotation-presets.test.mjs +137 -0
  95. package/test/api-annotations.test.mjs +202 -0
  96. package/test/api-auto-attribution.test.mjs +169 -0
  97. package/test/api-source-health.test.mjs +109 -0
  98. package/test/api-v2.test.mjs +278 -0
  99. package/test/api-v43.test.mjs +151 -0
  100. package/test/api-v44.test.mjs +128 -0
  101. package/test/attribution-summary.test.mjs +164 -0
  102. package/test/auto-attribution.test.mjs +116 -0
  103. package/test/ccusage-bridge.test.mjs +36 -0
  104. package/test/ccusage-import.test.mjs +93 -0
  105. package/test/cli-v43.test.mjs +64 -0
  106. package/test/cli-v45.test.mjs +34 -0
  107. package/test/cli-v46.test.mjs +129 -0
  108. package/test/cli-v47.test.mjs +98 -0
  109. package/test/closure-check.test.mjs +202 -0
  110. package/test/closure-import.test.mjs +263 -0
  111. package/test/collector-config.test.mjs +25 -0
  112. package/test/collector-registry.test.mjs +56 -0
  113. package/test/csv.test.mjs +19 -0
  114. package/test/db-annotations.test.mjs +186 -0
  115. package/test/db-v2.test.mjs +200 -0
  116. package/test/db-v4.test.mjs +178 -0
  117. package/test/experimental-collectors.test.mjs +103 -0
  118. package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
  119. package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
  120. package/test/fixtures/collectors/goose/usage.jsonl +2 -0
  121. package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
  122. package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
  123. package/test/import-budget.test.mjs +40 -0
  124. package/test/live.test.mjs +256 -0
  125. package/test/markdown-report.test.mjs +193 -0
  126. package/test/model-policy.test.mjs +34 -0
  127. package/test/model-strategy.test.mjs +116 -0
  128. package/test/model-usage.test.mjs +99 -0
  129. package/test/official-pricing.test.mjs +70 -0
  130. package/test/onboarding.test.mjs +55 -0
  131. package/test/privacy-check.test.mjs +33 -0
  132. package/test/review-closure-progress.test.mjs +99 -0
  133. package/test/roi-advisor.test.mjs +188 -0
  134. package/test/roi-evidence.test.mjs +48 -0
  135. package/test/roi-summary.test.mjs +101 -0
  136. package/test/savings-simulator.test.mjs +141 -0
  137. package/test/source-health.test.mjs +62 -0
  138. package/test/statusline.test.mjs +148 -0
  139. package/vite.config.js +23 -0
@@ -0,0 +1,646 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { DatabaseSync } from 'node:sqlite';
5
+ import {
6
+ OUTPUT_STATUSES,
7
+ OUTPUT_TYPES,
8
+ TASK_TYPES,
9
+ VALUE_LEVELS,
10
+ WORK_PURPOSES,
11
+ WORK_STAGES,
12
+ defaultDbPath,
13
+ normalizeSessionAnnotation,
14
+ normalizeSessionOutput,
15
+ openDb,
16
+ upsertSessionAnnotation,
17
+ upsertSessionOutput
18
+ } from './db.mjs';
19
+
20
+ const PRODUCTIVE_STATUSES = new Set(['已完成', '已发布']);
21
+
22
+ export function parseImportArgs(argv = process.argv.slice(2)) {
23
+ const options = {
24
+ dbPath: process.env.DB_PATH || defaultDbPath,
25
+ file: null,
26
+ apply: false,
27
+ applyValid: false,
28
+ fillGuide: false,
29
+ report: false,
30
+ limit: 10,
31
+ json: false
32
+ };
33
+
34
+ for (let i = 0; i < argv.length; i += 1) {
35
+ const arg = argv[i];
36
+ if (arg === '--help' || arg === '-h') {
37
+ options.help = true;
38
+ continue;
39
+ }
40
+ if (arg === '--apply') {
41
+ options.apply = true;
42
+ continue;
43
+ }
44
+ if (arg === '--apply-valid') {
45
+ options.applyValid = true;
46
+ continue;
47
+ }
48
+ if (arg === '--report') {
49
+ options.report = true;
50
+ continue;
51
+ }
52
+ if (arg === '--fill-guide') {
53
+ options.fillGuide = true;
54
+ continue;
55
+ }
56
+ if (arg === '--json') {
57
+ options.json = true;
58
+ continue;
59
+ }
60
+ if (arg === '--db') {
61
+ options.dbPath = argv[++i];
62
+ continue;
63
+ }
64
+ if (arg.startsWith('--db=')) {
65
+ options.dbPath = arg.slice('--db='.length);
66
+ continue;
67
+ }
68
+ if (arg === '--file') {
69
+ options.file = argv[++i];
70
+ continue;
71
+ }
72
+ if (arg.startsWith('--file=')) {
73
+ options.file = arg.slice('--file='.length);
74
+ continue;
75
+ }
76
+ if (arg === '--limit') {
77
+ options.limit = parsePositiveInt(argv[++i], 'limit');
78
+ continue;
79
+ }
80
+ if (arg.startsWith('--limit=')) {
81
+ options.limit = parsePositiveInt(arg.slice('--limit='.length), 'limit');
82
+ continue;
83
+ }
84
+ throw new Error(`Unknown option: ${arg}`);
85
+ }
86
+
87
+ const writeModes = [options.apply, options.applyValid, options.report, options.fillGuide].filter(Boolean).length;
88
+ if (writeModes > 1) {
89
+ throw new Error('--apply, --apply-valid, --report, and --fill-guide cannot be combined');
90
+ }
91
+
92
+ return options;
93
+ }
94
+
95
+ export function loadClosureImportFile(filePath) {
96
+ if (!filePath) throw new Error('--file is required');
97
+ const resolved = resolve(filePath);
98
+ if (!existsSync(resolved)) throw new Error(`Import file not found: ${resolved}`);
99
+ const payload = JSON.parse(readFileSync(resolved, 'utf8'));
100
+ const rows = Array.isArray(payload) ? payload : payload.sessions;
101
+ if (!Array.isArray(rows) || rows.length === 0) {
102
+ throw new Error('Import JSON must be an array or an object with a non-empty sessions array');
103
+ }
104
+ return { filePath: resolved, rows };
105
+ }
106
+
107
+ export function planClosureImport(db, rows = []) {
108
+ const planned = rows.map((row, index) => normalizeImportRow(db, row, index));
109
+ return {
110
+ mode: 'dry-run',
111
+ rowCount: planned.length,
112
+ annotationCount: planned.length,
113
+ outputCount: planned.filter(item => item.output).length,
114
+ sessions: planned.map(item => ({
115
+ index: item.index,
116
+ device: item.annotation.device,
117
+ source: item.annotation.source,
118
+ sessionId: item.annotation.sessionId,
119
+ projectAlias: item.annotation.projectAlias,
120
+ taskType: item.annotation.taskType,
121
+ outputStatus: item.annotation.outputStatus,
122
+ workPurpose: item.annotation.workPurpose,
123
+ workStage: item.annotation.workStage,
124
+ valueLevel: item.annotation.valueLevel,
125
+ hasOutput: Boolean(item.output)
126
+ })),
127
+ planned
128
+ };
129
+ }
130
+
131
+ export function buildClosureImportReport(db, rows = []) {
132
+ const { results, planned } = collectImportRows(db, rows);
133
+ const invalidRows = results.filter(row => !row.valid);
134
+ return {
135
+ mode: 'report',
136
+ valid: invalidRows.length === 0,
137
+ rowCount: rows.length,
138
+ validCount: results.length - invalidRows.length,
139
+ errorCount: invalidRows.length,
140
+ outputCount: planned.filter(item => item.output).length,
141
+ sessions: results,
142
+ errors: invalidRows.map(row => ({
143
+ index: row.index,
144
+ device: row.device,
145
+ source: row.source,
146
+ sessionId: row.sessionId,
147
+ error: row.error
148
+ }))
149
+ };
150
+ }
151
+
152
+ export function planValidClosureImport(db, rows = []) {
153
+ const { results, planned } = collectImportRows(db, rows);
154
+ const invalidRows = results.filter(row => !row.valid);
155
+ return {
156
+ mode: 'apply-valid',
157
+ rowCount: rows.length,
158
+ annotationCount: planned.length,
159
+ outputCount: planned.filter(item => item.output).length,
160
+ skippedCount: invalidRows.length,
161
+ sessions: planned.map(summarizePlannedItem),
162
+ skipped: invalidRows.map(row => ({
163
+ index: row.index,
164
+ device: row.device,
165
+ source: row.source,
166
+ sessionId: row.sessionId,
167
+ error: row.error
168
+ })),
169
+ planned
170
+ };
171
+ }
172
+
173
+ export function buildClosureFillGuide(db, rows = [], { limit = 10 } = {}) {
174
+ const report = buildClosureImportReport(db, rows);
175
+ const rowLimit = Math.max(1, limit);
176
+ const rowsToFill = report.sessions
177
+ .filter(row => !row.valid)
178
+ .slice(0, rowLimit)
179
+ .map(row => ({
180
+ ...row,
181
+ missingFields: missingRawClosureFields(rows[row.index]),
182
+ projectHint: normalizeText(rows[row.index]?.projectHint ?? rows[row.index]?.project_path ?? rows[row.index]?.projectPath),
183
+ totalTokens: Number(rows[row.index]?.totalTokens ?? rows[row.index]?.total_tokens ?? 0),
184
+ officialCostUSD: Number(rows[row.index]?.officialCostUSD ?? rows[row.index]?.official_cost_usd ?? rows[row.index]?.costUSD ?? rows[row.index]?.cost_usd ?? 0)
185
+ }));
186
+
187
+ return {
188
+ mode: 'fill-guide',
189
+ rowCount: rows.length,
190
+ readyCount: report.validCount,
191
+ needsInputCount: report.errorCount,
192
+ shownCount: rowsToFill.length,
193
+ allowedValues: allowedImportValues(),
194
+ rows: rowsToFill,
195
+ privacy: 'Read-only guide for user-supplied structured labels; no collect, no SQLite writes, no conversation content.'
196
+ };
197
+ }
198
+
199
+ export function applyClosureImport(db, plan, { dbPath = defaultDbPath, backupDir = null, mode = 'apply', reason = 'closure-import' } = {}) {
200
+ if (!plan.planned.length) {
201
+ return {
202
+ mode,
203
+ applied: false,
204
+ backup: null,
205
+ annotationCount: 0,
206
+ outputCount: 0,
207
+ rowCount: plan.rowCount
208
+ };
209
+ }
210
+
211
+ const backup = createDbBackup(db, dbPath, { reason, backupDir });
212
+ db.exec('BEGIN');
213
+ try {
214
+ for (const item of plan.planned) {
215
+ upsertSessionAnnotation(db, item.annotation);
216
+ if (item.output) upsertSessionOutput(db, item.output);
217
+ }
218
+ db.exec('COMMIT');
219
+ } catch (error) {
220
+ db.exec('ROLLBACK');
221
+ throw error;
222
+ }
223
+ return {
224
+ mode,
225
+ applied: true,
226
+ backup,
227
+ annotationCount: plan.annotationCount,
228
+ outputCount: plan.outputCount,
229
+ rowCount: plan.rowCount
230
+ };
231
+ }
232
+
233
+ export function formatImportPlan(plan, result = null) {
234
+ const lines = [
235
+ 'Token Studio Closure Import',
236
+ '',
237
+ `Mode: ${result?.mode || 'dry-run'}`,
238
+ `Rows: ${plan.rowCount}`,
239
+ `Annotations: ${plan.annotationCount}`,
240
+ `Output links: ${plan.outputCount}`
241
+ ];
242
+
243
+ if (result?.backup) {
244
+ lines.push(`Backup: ${result.backup.path}`);
245
+ }
246
+
247
+ lines.push('', 'Sessions:');
248
+ for (const row of plan.sessions) {
249
+ lines.push(`- #${row.index + 1} ${row.projectAlias} | ${row.sessionId} | ${row.taskType} / ${row.outputStatus} / ${row.workPurpose} / ${row.workStage} / ${row.valueLevel}${row.hasOutput ? ' | output' : ''}`);
250
+ }
251
+
252
+ if (!result) {
253
+ lines.push('', 'Dry run only. Re-run with --apply to write labels after reviewing this plan.');
254
+ }
255
+
256
+ lines.push('', 'Privacy: this command imports only user-supplied structured labels; it does not run collect or read conversation content.');
257
+ return lines.join('\n');
258
+ }
259
+
260
+ export function formatApplyValidResult(plan, result) {
261
+ const lines = [
262
+ 'Token Studio Closure Apply Valid',
263
+ '',
264
+ `Mode: ${result?.mode || 'apply-valid'}`,
265
+ `Rows: ${plan.rowCount}`,
266
+ `Applied annotations: ${plan.annotationCount}`,
267
+ `Applied output links: ${plan.outputCount}`,
268
+ `Skipped invalid rows: ${plan.skippedCount}`
269
+ ];
270
+
271
+ if (result?.backup) {
272
+ lines.push(`Backup: ${result.backup.path}`);
273
+ }
274
+
275
+ if (plan.sessions.length) {
276
+ lines.push('', 'Applied sessions:');
277
+ for (const row of plan.sessions) {
278
+ lines.push(`- #${row.index + 1} ${row.projectAlias} | ${row.sessionId} | ${row.taskType} / ${row.outputStatus} / ${row.workPurpose} / ${row.workStage} / ${row.valueLevel}${row.hasOutput ? ' | output' : ''}`);
279
+ }
280
+ } else {
281
+ lines.push('', 'No valid rows were found. Nothing was written.');
282
+ }
283
+
284
+ if (plan.skipped.length) {
285
+ lines.push('', 'Skipped rows:');
286
+ for (const row of plan.skipped) {
287
+ lines.push(`- #${row.index + 1} ${row.sessionId || 'unknown session'}${row.source ? ` (${row.source})` : ''}: ${row.error}`);
288
+ }
289
+ }
290
+
291
+ lines.push('', 'Privacy: this command writes only fully validated user-supplied structured labels; it does not run collect or read conversation content.');
292
+ return lines.join('\n');
293
+ }
294
+
295
+ export function formatClosureFillGuide(guide) {
296
+ const lines = [
297
+ 'Token Studio Closure Fill Guide',
298
+ '',
299
+ `Rows: ${guide.rowCount}`,
300
+ `Ready to import: ${guide.readyCount}`,
301
+ `Need input: ${guide.needsInputCount}`,
302
+ `Shown: ${guide.shownCount}`,
303
+ '',
304
+ 'Allowed values:',
305
+ `- taskType: ${guide.allowedValues.taskType.join(' / ')}`,
306
+ `- outputStatus: ${guide.allowedValues.outputStatus.join(' / ')}`,
307
+ `- workPurpose: ${guide.allowedValues.workPurpose.join(' / ')}`,
308
+ `- workStage: ${guide.allowedValues.workStage.join(' / ')}`,
309
+ `- valueLevel: ${guide.allowedValues.valueLevel.join(' / ')}`,
310
+ `- outputType: ${guide.allowedValues.outputType.join(' / ')}`
311
+ ];
312
+
313
+ if (!guide.rows.length) {
314
+ lines.push('', 'No rows need input. Run --report, then dry-run or apply.');
315
+ } else {
316
+ lines.push('', 'Rows to fill:');
317
+ for (const row of guide.rows) {
318
+ lines.push(
319
+ '',
320
+ `#${row.index + 1} ${row.projectHint || row.sessionId || 'unknown session'}`,
321
+ `- sessionId: ${row.sessionId || '(missing)'}`,
322
+ row.source ? `- source: ${row.source}` : null,
323
+ row.totalTokens ? `- tokens: ${formatInt(row.totalTokens)}` : null,
324
+ row.officialCostUSD ? `- officialCostUSD: ${money(row.officialCostUSD)}` : null,
325
+ `- missing: ${row.missingFields.length ? row.missingFields.join(', ') : 'see validation error'}`,
326
+ `- validation: ${row.error}`,
327
+ '- fill required fields: projectAlias, taskType, outputStatus, workPurpose, workStage, valueLevel',
328
+ '- optional output: outputUrl/outputLabel/outputType only for completed or published real outputs'
329
+ );
330
+ }
331
+ }
332
+
333
+ lines.push('', `Privacy: ${guide.privacy}`);
334
+ return lines.filter(line => line != null).join('\n');
335
+ }
336
+
337
+ export function formatImportReport(report) {
338
+ const lines = [
339
+ 'Token Studio Closure Import Report',
340
+ '',
341
+ `Rows: ${report.rowCount}`,
342
+ `Valid: ${report.validCount}`,
343
+ `Invalid: ${report.errorCount}`,
344
+ `Output links: ${report.outputCount}`
345
+ ];
346
+
347
+ if (report.errorCount) {
348
+ lines.push('', 'Invalid rows:');
349
+ for (const row of report.sessions.filter(item => !item.valid)) {
350
+ lines.push(`- #${row.index + 1} ${row.sessionId || 'unknown session'}${row.source ? ` (${row.source})` : ''}: ${row.error}`);
351
+ }
352
+ } else {
353
+ lines.push('', 'All rows are valid. Re-run without --report for dry-run plan, then add --apply to write after review.');
354
+ }
355
+
356
+ lines.push('', 'Privacy: this report validates user-supplied structured labels only; it does not run collect, write SQLite, or read conversation content.');
357
+ return lines.join('\n');
358
+ }
359
+
360
+ function collectImportRows(db, rows) {
361
+ const results = [];
362
+ const planned = [];
363
+
364
+ for (const [index, row] of rows.entries()) {
365
+ try {
366
+ const item = normalizeImportRow(db, row, index);
367
+ planned.push(item);
368
+ results.push(summarizePlannedItem(item));
369
+ } catch (error) {
370
+ results.push({
371
+ ...summarizeRawImportRow(row, index),
372
+ valid: false,
373
+ error: error.message
374
+ });
375
+ }
376
+ }
377
+
378
+ return { results, planned };
379
+ }
380
+
381
+ function missingRawClosureFields(row = {}) {
382
+ const fields = [];
383
+ if (!normalizeText(row.projectAlias ?? row.project_alias)) fields.push('projectAlias');
384
+ if (!normalizeText(row.taskType ?? row.task_type)) fields.push('taskType');
385
+ if (!normalizeText(row.outputStatus ?? row.output_status)) fields.push('outputStatus');
386
+ if (!normalizeText(row.workPurpose ?? row.work_purpose)) fields.push('workPurpose');
387
+ if (!normalizeText(row.workStage ?? row.work_stage)) fields.push('workStage');
388
+ if (!normalizeText(row.valueLevel ?? row.value_level)) fields.push('valueLevel');
389
+ return fields;
390
+ }
391
+
392
+ function allowedImportValues() {
393
+ return {
394
+ taskType: TASK_TYPES,
395
+ outputStatus: OUTPUT_STATUSES,
396
+ workPurpose: WORK_PURPOSES,
397
+ workStage: WORK_STAGES,
398
+ valueLevel: VALUE_LEVELS,
399
+ outputType: OUTPUT_TYPES
400
+ };
401
+ }
402
+
403
+ function summarizePlannedItem(item) {
404
+ return {
405
+ index: item.index,
406
+ valid: true,
407
+ device: item.annotation.device,
408
+ source: item.annotation.source,
409
+ sessionId: item.annotation.sessionId,
410
+ projectAlias: item.annotation.projectAlias,
411
+ taskType: item.annotation.taskType,
412
+ outputStatus: item.annotation.outputStatus,
413
+ workPurpose: item.annotation.workPurpose,
414
+ workStage: item.annotation.workStage,
415
+ valueLevel: item.annotation.valueLevel,
416
+ hasOutput: Boolean(item.output)
417
+ };
418
+ }
419
+
420
+ function normalizeImportRow(db, row, index) {
421
+ if (!row || typeof row !== 'object' || Array.isArray(row)) {
422
+ throw new Error(`sessions[${index}] must be an object`);
423
+ }
424
+ const identity = resolveSessionIdentity(db, row, index);
425
+ const annotation = normalizeSessionAnnotation({
426
+ ...row,
427
+ ...identity
428
+ });
429
+ requireFullClosureAnnotation(annotation, index);
430
+
431
+ const outputUrl = String(row.outputUrl ?? row.output_url ?? '').trim();
432
+ let output = null;
433
+ if (outputUrl) {
434
+ if (!PRODUCTIVE_STATUSES.has(annotation.outputStatus)) {
435
+ throw new Error(`sessions[${index}].outputUrl requires outputStatus 已完成 or 已发布`);
436
+ }
437
+ output = normalizeSessionOutput({
438
+ ...row,
439
+ ...identity,
440
+ outputUrl
441
+ });
442
+ }
443
+
444
+ return { index, annotation, output };
445
+ }
446
+
447
+ function summarizeRawImportRow(row, index) {
448
+ const value = row && typeof row === 'object' && !Array.isArray(row) ? row : {};
449
+ return {
450
+ index,
451
+ device: normalizeText(value.device),
452
+ source: normalizeText(value.source),
453
+ sessionId: normalizeText(value.sessionId ?? value.session_id),
454
+ projectAlias: normalizeText(value.projectAlias ?? value.project_alias)
455
+ };
456
+ }
457
+
458
+ function requireFullClosureAnnotation(annotation, index) {
459
+ const required = [
460
+ ['projectAlias', annotation.projectAlias],
461
+ ['taskType', annotation.taskType !== '未分类' ? annotation.taskType : null],
462
+ ['outputStatus', annotation.outputStatus !== '未标注' ? annotation.outputStatus : null],
463
+ ['workPurpose', annotation.workPurpose !== '未说明' ? annotation.workPurpose : null],
464
+ ['workStage', annotation.workStage !== '未说明' ? annotation.workStage : null],
465
+ ['valueLevel', annotation.valueLevel !== '未评估' ? annotation.valueLevel : null]
466
+ ];
467
+ const missing = required.filter(([, value]) => !value).map(([field]) => field);
468
+ if (missing.length) {
469
+ throw new Error(`sessions[${index}] is missing required closure field(s): ${missing.join(', ')}`);
470
+ }
471
+ }
472
+
473
+ function resolveSessionIdentity(db, row, index) {
474
+ const sessionId = normalizeText(row.sessionId ?? row.session_id);
475
+ if (!sessionId) throw new Error(`sessions[${index}].sessionId is required`);
476
+ const device = normalizeText(row.device);
477
+ const source = normalizeText(row.source);
478
+
479
+ if (device && source) {
480
+ const found = db.prepare(`
481
+ SELECT device, source, session_id AS sessionId
482
+ FROM session_usage
483
+ WHERE device = ? AND source = ? AND session_id = ?
484
+ `).get(device, source, sessionId);
485
+ if (!found) throw new Error(`sessions[${index}] does not match an existing session`);
486
+ return found;
487
+ }
488
+
489
+ const matches = db.prepare(`
490
+ SELECT device, source, session_id AS sessionId
491
+ FROM session_usage
492
+ WHERE session_id = ?
493
+ ORDER BY device, source
494
+ `).all(sessionId);
495
+ if (matches.length === 0) throw new Error(`sessions[${index}].sessionId does not exist in session_usage`);
496
+ if (matches.length > 1) throw new Error(`sessions[${index}].sessionId is ambiguous; include device and source`);
497
+ return matches[0];
498
+ }
499
+
500
+ function createDbBackup(db, dbPath, { reason = 'closure-import', backupDir = null } = {}) {
501
+ const resolvedDbPath = resolve(dbPath);
502
+ const createdAt = new Date().toISOString();
503
+ const stamp = createdAt.replace(/[:.]/g, '-');
504
+ const safeReason = String(reason || 'manual').replace(/[^a-z0-9-]+/gi, '-').toLowerCase();
505
+ const dir = backupDir || process.env.BACKUP_DIR || join(dirname(resolvedDbPath), 'backups');
506
+ mkdirSync(dir, { recursive: true });
507
+ db.exec('PRAGMA wal_checkpoint(FULL)');
508
+ const fileName = `usage-${stamp}-${safeReason}.sqlite`;
509
+ const path = join(dir, fileName);
510
+ copyFileSync(resolvedDbPath, path);
511
+ return { createdAt, path, fileName };
512
+ }
513
+
514
+ function normalizeText(value) {
515
+ return String(value ?? '').trim().replace(/\s+/g, ' ');
516
+ }
517
+
518
+ function parsePositiveInt(value, label) {
519
+ const number = Number(value);
520
+ if (!Number.isInteger(number) || number <= 0) {
521
+ throw new Error(`${label} must be a positive integer`);
522
+ }
523
+ return number;
524
+ }
525
+
526
+ function formatInt(value) {
527
+ return new Intl.NumberFormat('zh-CN').format(Math.round(Number(value || 0)));
528
+ }
529
+
530
+ function money(value) {
531
+ return new Intl.NumberFormat('en-US', {
532
+ style: 'currency',
533
+ currency: 'USD',
534
+ minimumFractionDigits: 2,
535
+ maximumFractionDigits: 2
536
+ }).format(Number(value || 0));
537
+ }
538
+
539
+ function openDryRunDb(dbPath) {
540
+ const resolved = resolve(dbPath);
541
+ if (!existsSync(resolved)) throw new Error(`SQLite database not found: ${resolved}`);
542
+ return new DatabaseSync(resolved, { readOnly: true, timeout: 10000 });
543
+ }
544
+
545
+ function runCli() {
546
+ try {
547
+ const options = parseImportArgs();
548
+ if (options.help) {
549
+ console.log(helpText());
550
+ return;
551
+ }
552
+
553
+ const dbPath = resolve(options.dbPath || defaultDbPath);
554
+ const { rows, filePath } = loadClosureImportFile(options.file);
555
+ if (!existsSync(dbPath)) throw new Error(`SQLite database not found: ${dbPath}`);
556
+ const db = (options.apply || options.applyValid) ? openDb(dbPath) : openDryRunDb(dbPath);
557
+ try {
558
+ if (options.fillGuide) {
559
+ const guide = buildClosureFillGuide(db, rows, { limit: options.limit });
560
+ console.log(options.json ? JSON.stringify({
561
+ filePath,
562
+ dbPath,
563
+ ...guide
564
+ }, null, 2) : formatClosureFillGuide(guide));
565
+ return;
566
+ }
567
+
568
+ if (options.report) {
569
+ const report = buildClosureImportReport(db, rows);
570
+ console.log(options.json ? JSON.stringify({
571
+ filePath,
572
+ dbPath,
573
+ ...report
574
+ }, null, 2) : formatImportReport(report));
575
+ if (!report.valid) process.exitCode = 1;
576
+ return;
577
+ }
578
+
579
+ if (options.applyValid) {
580
+ const plan = planValidClosureImport(db, rows);
581
+ const result = applyClosureImport(db, plan, {
582
+ dbPath,
583
+ mode: 'apply-valid',
584
+ reason: 'closure-import-valid'
585
+ });
586
+ const output = {
587
+ filePath,
588
+ dbPath,
589
+ ...result,
590
+ rowCount: plan.rowCount,
591
+ annotationCount: plan.annotationCount,
592
+ outputCount: plan.outputCount,
593
+ skippedCount: plan.skippedCount,
594
+ sessions: plan.sessions,
595
+ skipped: plan.skipped
596
+ };
597
+ console.log(options.json ? JSON.stringify(output, null, 2) : formatApplyValidResult(plan, result));
598
+ if (!result.applied) process.exitCode = 1;
599
+ return;
600
+ }
601
+
602
+ const plan = planClosureImport(db, rows);
603
+ const result = options.apply ? applyClosureImport(db, plan, { dbPath }) : null;
604
+ const output = {
605
+ filePath,
606
+ dbPath,
607
+ ...(result || { mode: 'dry-run', applied: false }),
608
+ rowCount: plan.rowCount,
609
+ annotationCount: plan.annotationCount,
610
+ outputCount: plan.outputCount,
611
+ sessions: plan.sessions
612
+ };
613
+ console.log(options.json ? JSON.stringify(output, null, 2) : formatImportPlan(plan, result));
614
+ } finally {
615
+ db.close();
616
+ }
617
+ } catch (error) {
618
+ console.error(`closure:import failed: ${error.message}`);
619
+ process.exitCode = 2;
620
+ }
621
+ }
622
+
623
+ function helpText() {
624
+ return [
625
+ 'Usage: npm run closure:import -- --file labels.json [options]',
626
+ '',
627
+ 'Options:',
628
+ ' --file <path> JSON array or { "sessions": [...] } with filled labels.',
629
+ ' --db <path> SQLite database path. Defaults to DB_PATH or data/usage.sqlite.',
630
+ ' --json Print machine-readable output.',
631
+ ' --fill-guide Print a read-only field-by-field guide for filling real labels.',
632
+ ' --report Validate all rows and report every invalid row without writing.',
633
+ ' --apply-valid Write only fully valid rows and skip invalid rows after creating a backup.',
634
+ ' --apply Write labels after validation. Without this, the command is dry-run only.',
635
+ ' --limit=<n> Fill guide row limit. Default: 10.',
636
+ ' -h, --help Show this help.',
637
+ '',
638
+ 'Required per row: sessionId, projectAlias, taskType, outputStatus, workPurpose, workStage, valueLevel.',
639
+ 'Optional per row: device, source, note, outputUrl, outputLabel, outputType.',
640
+ 'This command never runs collect or reads conversation content.'
641
+ ].join('\n');
642
+ }
643
+
644
+ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
645
+ runCli();
646
+ }