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,45 @@
1
+ # Public Launch Checklist
2
+
3
+ Use this checklist before pushing a public GitHub release or publishing an npm package.
4
+
5
+ ## Required Commands
6
+
7
+ ```bash
8
+ npm test
9
+ npm run build
10
+ npm run privacy:check
11
+ npm audit --audit-level=low
12
+ npm pack --dry-run
13
+ git diff --check
14
+ ```
15
+
16
+ ## Screenshots
17
+
18
+ - Use `npx token-studio demo` after npm publication, or `npm run demo` from a cloned repository.
19
+ - Confirm the UI shows Demo Mode.
20
+ - Do not use real `data/usage.sqlite`.
21
+ - Do not include real project paths, local usernames, exported reports, or private output URLs.
22
+ - Current public screenshot assets:
23
+ - `docs/assets/token-studio-v45-dashboard.png`
24
+ - `docs/assets/token-studio-v45-review.png`
25
+ - `docs/assets/token-studio-v45-live.png`
26
+ - `docs/assets/token-studio-v45-review-mobile.png`
27
+
28
+ ## GitHub Release
29
+
30
+ - Repository name: `token-studio-roi`.
31
+ - Current public tag: `v4.7.0`.
32
+ - Current local next version: `v4.8.0`.
33
+ - Historical standalone baseline: `v4.0.0`.
34
+ - Suggested topics: `ai-coding`, `token-usage`, `cost-tracking`, `local-first`, `privacy-first`, `roi`, `codex-cli`, `claude-code`.
35
+ - Release notes should say cost is official public token-price conversion, not a provider invoice.
36
+ - Keep `NOTICE.md` in the repository.
37
+
38
+ ## npm
39
+
40
+ - Primary package name: `token-studio`.
41
+ - Fallback package name if unavailable: `tokenroi`.
42
+ - Primary one-command demo: `npx token-studio demo`.
43
+ - Do not publish until `npm pack --dry-run` shows no SQLite databases, logs, `.env`, `.claude`, `.codex`, `dist`, or `node_modules`.
44
+ - If the package name is unavailable, publish the fallback only after updating README, package metadata, and release notes consistently.
45
+ - `npm whoami` must succeed before running `npm publish --access public`.
@@ -0,0 +1,7 @@
1
+ # Resume Bullets
2
+
3
+ - Built Token Studio ROI, a local-first AI coding ROI review system using Node.js `node:sqlite`, React, Vite, and a privacy-preserving collector registry.
4
+ - Designed a structured attribution layer that connects AI coding token usage to projects, task type, work stage, output status, value level, and output links.
5
+ - Implemented a local ROI Evidence Score and rule-based Advisor to turn usage history into model-switching, context-compression, and attribution cleanup actions without calling an LLM.
6
+ - Added a `token-studio` CLI for demo mode, local start, explicit collection, collector diagnostics, and public-readiness privacy checks.
7
+ - Hardened local write APIs with loopback, local-Origin, and JSON-only checks, while keeping cost conversion tied to official public token prices.
@@ -0,0 +1,52 @@
1
+ # Statusline Guardrails
2
+
3
+ `token-studio statusline` is a read-only SQLite summary for terminal prompts, tmux, scripts, and Claude Code statusline integrations. It does not scan local AI logs, run ccusage, start a daemon, or read conversation content.
4
+
5
+ ## Basic Command
6
+
7
+ ```bash
8
+ npx token-studio statusline --format=text --window-minutes=15 --max-width=100
9
+ ```
10
+
11
+ For script usage:
12
+
13
+ ```bash
14
+ npx token-studio statusline --format=json --window-minutes=15
15
+ ```
16
+
17
+ ## Claude Code Statusline
18
+
19
+ Use the same text command as your statusline command:
20
+
21
+ ```bash
22
+ npx token-studio statusline --format=text --window-minutes=15 --max-width=100
23
+ ```
24
+
25
+ ## tmux
26
+
27
+ ```tmux
28
+ set -g status-right "#(npx token-studio statusline --format=text --window-minutes=15 --max-width=80)"
29
+ ```
30
+
31
+ ## PowerShell Prompt
32
+
33
+ ```powershell
34
+ function prompt {
35
+ $ts = npx token-studio statusline --format=text --window-minutes=15 --max-width=80
36
+ "$ts PS $($PWD)> "
37
+ }
38
+ ```
39
+
40
+ From a cloned repository, replace `npx token-studio` with `node src/cli.mjs`.
41
+
42
+ ## Output Meaning
43
+
44
+ - `tok`: tokens in the recent local window.
45
+ - `burn`: tokens per hour if the recent pace continues.
46
+ - `cache`: cache hit percentage from structured token events.
47
+ - `actions`: open Advisor Actions.
48
+ - `budget`: custom token/cost guardrail status and usage share.
49
+ - `reset`: next fixed-window reset countdown, or rolling-window duration.
50
+ - `warn`: highest current guardrail warning.
51
+
52
+ Budget profiles are custom guardrails. They are not provider subscription quota claims.
package/index.html ADDED
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Token Studio ROI</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23262520'/%3E%3Ctext x='32' y='39' text-anchor='middle' font-family='Arial,sans-serif' font-size='22' font-weight='700' fill='%23faf7ef'%3ETS%3C/text%3E%3C/svg%3E" />
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ <script type="module" src="/src/client/main.jsx"></script>
15
+ </body>
16
+ </html>
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "token-studio",
3
+ "version": "4.8.0",
4
+ "description": "Local AI coding ROI studio for private token, output, and model strategy review.",
5
+ "type": "module",
6
+ "bin": {
7
+ "token-studio": "src/cli.mjs"
8
+ },
9
+ "scripts": {
10
+ "start": "node src/cli.mjs start",
11
+ "demo": "node src/cli.mjs demo",
12
+ "collect": "node src/collect.mjs",
13
+ "doctor": "node src/cli.mjs doctor",
14
+ "privacy:check": "node src/cli.mjs privacy-check",
15
+ "closure:check": "node src/closure-check.mjs",
16
+ "closure:import": "node src/closure-import.mjs",
17
+ "pricing:update": "node src/update-pricing.mjs",
18
+ "dev": "node src/dev.mjs",
19
+ "dev:client": "vite --host 127.0.0.1 --port 5173",
20
+ "dev:server": "node src/server.mjs",
21
+ "build": "vite build",
22
+ "preview": "npm run build && node src/server.mjs",
23
+ "serve": "node src/server.mjs",
24
+ "test": "node --test"
25
+ },
26
+ "engines": {
27
+ "node": ">=22.12.0"
28
+ },
29
+ "dependencies": {
30
+ "@vitejs/plugin-react": "^6.0.2",
31
+ "echarts": "^6.0.0",
32
+ "react": "^18.3.1",
33
+ "react-dom": "^18.3.1",
34
+ "vite": "^8.0.16"
35
+ }
36
+ }
package/render.yaml ADDED
@@ -0,0 +1,17 @@
1
+ services:
2
+ - type: web
3
+ name: token-studio-roi
4
+ env: docker
5
+ plan: starter
6
+ autoDeploy: true
7
+ envVars:
8
+ - key: PORT
9
+ value: 4173
10
+ - key: HOST
11
+ value: 0.0.0.0
12
+ - key: INGEST_TOKEN
13
+ generateValue: true
14
+ disk:
15
+ name: token-studio-roi-data
16
+ mountPath: /app/data
17
+ sizeGB: 1
@@ -0,0 +1,396 @@
1
+ export const AUTO_ATTRIBUTION_VERSION = 'v4.0.0';
2
+ export const AUTO_ATTRIBUTION_THRESHOLD = 80;
3
+
4
+ const DEFAULTS = {
5
+ taskType: '未分类',
6
+ outputStatus: '未标注',
7
+ workPurpose: '未说明',
8
+ workStage: '未说明',
9
+ valueLevel: '未评估'
10
+ };
11
+
12
+ const OUTPUT_RULES = {
13
+ PR: {
14
+ taskType: '功能开发',
15
+ outputStatus: '已完成',
16
+ workPurpose: '功能开发',
17
+ workStage: '实现',
18
+ valueLevel: '中',
19
+ confidence: 85,
20
+ reason: '已有 PR 产出链接,结构化证据足以判断为已完成的功能开发。'
21
+ },
22
+ commit: {
23
+ taskType: '功能开发',
24
+ outputStatus: '已完成',
25
+ workPurpose: '功能开发',
26
+ workStage: '实现',
27
+ valueLevel: '中',
28
+ confidence: 85,
29
+ reason: '已有 commit 产出链接,结构化证据足以判断为已完成的功能开发。'
30
+ },
31
+ 部署: {
32
+ taskType: '运维配置',
33
+ outputStatus: '已发布',
34
+ workPurpose: '部署运维',
35
+ workStage: '发布',
36
+ valueLevel: '高',
37
+ confidence: 90,
38
+ reason: '已有部署产出链接,结构化证据足以判断为已发布。'
39
+ },
40
+ 文章: {
41
+ taskType: '内容创作',
42
+ outputStatus: '已完成',
43
+ workPurpose: '文档内容',
44
+ workStage: '发布',
45
+ valueLevel: '中',
46
+ confidence: 85,
47
+ reason: '已有文章产出链接,结构化证据足以判断为已完成的内容产出。'
48
+ },
49
+ 文档: {
50
+ taskType: '内容创作',
51
+ outputStatus: '已完成',
52
+ workPurpose: '文档内容',
53
+ workStage: '发布',
54
+ valueLevel: '中',
55
+ confidence: 85,
56
+ reason: '已有文档产出链接,结构化证据足以判断为已完成的文档内容。'
57
+ },
58
+ 截图: {
59
+ taskType: '其他',
60
+ outputStatus: '已完成',
61
+ workPurpose: '文档内容',
62
+ workStage: '验证',
63
+ valueLevel: '中',
64
+ confidence: 82,
65
+ reason: '已有截图产出链接,结构化证据足以判断为已完成的可展示产出。'
66
+ }
67
+ };
68
+
69
+ export function buildAutoAttributionPlan({
70
+ sessions = [],
71
+ projectAliasRules = [],
72
+ now = new Date(),
73
+ threshold = AUTO_ATTRIBUTION_THRESHOLD
74
+ } = {}) {
75
+ const generatedAt = toIso(now);
76
+ const suggestions = sessions
77
+ .map(session => buildAutoAttributionSuggestion(session, { projectAliasRules, now, threshold, generatedAt }))
78
+ .filter(Boolean)
79
+ .sort((a, b) => Number(b.canApply) - Number(a.canApply)
80
+ || b.annotationConfidence - a.annotationConfidence
81
+ || (b.totalTokens || 0) - (a.totalTokens || 0));
82
+
83
+ const highConfidence = suggestions.filter(item => item.canApply);
84
+ const lowConfidence = suggestions.filter(item =>
85
+ item.annotationConfidence >= 60 && item.annotationConfidence < threshold
86
+ );
87
+ const skipped = sessions.length - suggestions.length;
88
+ const initiallyUnattributed = sessions.filter(isReviewIncomplete).length;
89
+ const remainingAfterApply = sessions.filter(session => {
90
+ const suggestion = highConfidence.find(item => sameSession(item, session));
91
+ return isReviewIncomplete(suggestion ? { ...session, ...suggestion.applicableValues } : session);
92
+ }).length;
93
+
94
+ return {
95
+ version: AUTO_ATTRIBUTION_VERSION,
96
+ generatedAt,
97
+ threshold,
98
+ totalSessions: sessions.length,
99
+ suggestionCount: suggestions.length,
100
+ highConfidenceCount: highConfidence.length,
101
+ lowConfidenceCount: lowConfidence.length,
102
+ skippedCount: skipped,
103
+ initiallyUnattributed,
104
+ estimatedRemainingUnattributed: remainingAfterApply,
105
+ estimatedReductionShare: initiallyUnattributed
106
+ ? (initiallyUnattributed - remainingAfterApply) / initiallyUnattributed
107
+ : 0,
108
+ suggestions
109
+ };
110
+ }
111
+
112
+ export function buildAutoAttributionSuggestion(session = {}, options = {}) {
113
+ if (!canAutoWrite(session)) return null;
114
+
115
+ const now = options.now instanceof Date ? options.now : new Date(options.now || Date.now());
116
+ const generatedAt = options.generatedAt || toIso(now);
117
+ const threshold = Number(options.threshold || AUTO_ATTRIBUTION_THRESHOLD);
118
+ const reasons = [];
119
+ const values = {
120
+ projectAlias: normalizeText(session.manualProjectAlias || session.projectAlias) || null,
121
+ taskType: session.taskType || DEFAULTS.taskType,
122
+ outputStatus: session.outputStatus || DEFAULTS.outputStatus,
123
+ workPurpose: session.workPurpose || DEFAULTS.workPurpose,
124
+ workStage: session.workStage || DEFAULTS.workStage,
125
+ valueLevel: session.valueLevel || DEFAULTS.valueLevel,
126
+ note: normalizeText(session.note) || null
127
+ };
128
+ const fieldConfidence = {};
129
+
130
+ const alias = inferProjectAlias(session, options.projectAliasRules || []);
131
+ if (!values.projectAlias && alias.value) {
132
+ values.projectAlias = alias.value;
133
+ fieldConfidence.projectAlias = alias.confidence;
134
+ reasons.push(alias.reason);
135
+ }
136
+
137
+ const outputRule = inferFromOutput(session);
138
+ if (outputRule) {
139
+ applyIfDefault(values, fieldConfidence, 'taskType', outputRule.taskType, outputRule.confidence);
140
+ applyIfDefault(values, fieldConfidence, 'outputStatus', outputRule.outputStatus, outputRule.confidence);
141
+ applyIfDefault(values, fieldConfidence, 'workPurpose', outputRule.workPurpose, outputRule.confidence);
142
+ applyIfDefault(values, fieldConfidence, 'workStage', outputRule.workStage, outputRule.confidence);
143
+ applyIfDefault(values, fieldConfidence, 'valueLevel', outputRule.valueLevel, outputRule.confidence);
144
+ reasons.push(outputRule.reason);
145
+ } else {
146
+ const active = inferActiveStatus(session, now);
147
+ if (active && isDefault(values.outputStatus, 'outputStatus')) {
148
+ values.outputStatus = active.outputStatus;
149
+ fieldConfidence.outputStatus = active.confidence;
150
+ reasons.push(active.reason);
151
+ }
152
+ const shape = inferFromTokenShape(session);
153
+ if (shape) {
154
+ applyIfDefault(values, fieldConfidence, 'taskType', shape.taskType, shape.confidence);
155
+ applyIfDefault(values, fieldConfidence, 'workPurpose', shape.workPurpose, shape.confidence);
156
+ applyIfDefault(values, fieldConfidence, 'workStage', shape.workStage, shape.confidence);
157
+ reasons.push(shape.reason);
158
+ }
159
+ }
160
+
161
+ const changedFields = Object.entries(values)
162
+ .filter(([field, value]) => field !== 'note' && !sameValue(value, session[field]) && !isUnhelpfulDefault(field, value))
163
+ .map(([field]) => field);
164
+ if (!changedFields.length) return null;
165
+
166
+ const confidenceValues = Object.values(fieldConfidence).filter(value => Number.isFinite(value));
167
+ const annotationConfidence = confidenceValues.length
168
+ ? Math.min(...confidenceValues)
169
+ : 60;
170
+ const applicableFields = changedFields.filter(field => Number(fieldConfidence[field] || 0) >= threshold);
171
+ const applicableValues = Object.fromEntries(applicableFields.map(field => [field, values[field]]));
172
+ const applyConfidenceValues = applicableFields.map(field => fieldConfidence[field]);
173
+ const applyConfidence = applyConfidenceValues.length ? Math.min(...applyConfidenceValues) : 0;
174
+ const annotationReason = reasons.join(';').slice(0, 500);
175
+ const canApply = applicableFields.length > 0;
176
+
177
+ return {
178
+ device: session.device,
179
+ source: session.source,
180
+ sessionId: session.sessionId,
181
+ projectPath: session.projectPath || null,
182
+ totalTokens: session.totalTokens || 0,
183
+ costUSD: session.costUSD || 0,
184
+ model: session.model || session.pricingModel || null,
185
+ values,
186
+ changedFields,
187
+ applicableFields,
188
+ applicableValues,
189
+ fieldConfidence,
190
+ annotationSource: 'auto',
191
+ annotationConfidence,
192
+ applyConfidence,
193
+ annotationReason,
194
+ autoVersion: AUTO_ATTRIBUTION_VERSION,
195
+ autoRunId: null,
196
+ autoUpdatedAt: generatedAt,
197
+ canApply,
198
+ evidence: summarizeEvidence(session, changedFields, annotationConfidence)
199
+ };
200
+ }
201
+
202
+ export function attachAutoSuggestions(sessions = [], suggestions = []) {
203
+ const byKey = new Map(suggestions.map(item => [sessionKey(item), item]));
204
+ return sessions.map(session => ({
205
+ ...session,
206
+ autoSuggestion: byKey.get(sessionKey(session)) || null
207
+ }));
208
+ }
209
+
210
+ export function autoAttributionIdentity(suggestion = {}) {
211
+ return {
212
+ device: suggestion.device,
213
+ source: suggestion.source,
214
+ sessionId: suggestion.sessionId
215
+ };
216
+ }
217
+
218
+ function canAutoWrite(session) {
219
+ const source = normalizeText(session.annotationSource);
220
+ return !source || source === 'auto';
221
+ }
222
+
223
+ function inferProjectAlias(session, rules = []) {
224
+ if (session.ruleProjectAlias) {
225
+ return {
226
+ value: session.ruleProjectAlias,
227
+ confidence: 92,
228
+ reason: `命中项目别名规则:${session.ruleProjectAlias}`
229
+ };
230
+ }
231
+ const projectPath = normalizeText(session.projectPath);
232
+ const matchedRuleAlias = matchProjectAliasRule(projectPath, rules);
233
+ if (matchedRuleAlias) {
234
+ return {
235
+ value: matchedRuleAlias,
236
+ confidence: 92,
237
+ reason: `命中项目别名规则:${matchedRuleAlias}`
238
+ };
239
+ }
240
+ const fromPath = basename(session.projectPath);
241
+ if (fromPath) {
242
+ return {
243
+ value: fromPath,
244
+ confidence: 80,
245
+ reason: `根据项目路径末级目录推断项目别名:${fromPath}`
246
+ };
247
+ }
248
+ const fromSession = basename(projectPathFromSessionId(session.sessionId));
249
+ if (fromSession) {
250
+ return {
251
+ value: fromSession,
252
+ confidence: 65,
253
+ reason: `根据 session_id 中的本地路径片段粗略推断项目别名:${fromSession}`
254
+ };
255
+ }
256
+ return { value: null, confidence: 0, reason: '' };
257
+ }
258
+
259
+ function matchProjectAliasRule(projectPath, rules = []) {
260
+ const target = normalizeText(projectPath);
261
+ if (!target) return null;
262
+ const normalizedTarget = target.toLowerCase();
263
+ for (const rule of rules) {
264
+ if (!rule?.enabled) continue;
265
+ const pattern = normalizeText(rule.pattern);
266
+ if (!pattern) continue;
267
+ const normalizedPattern = pattern.toLowerCase();
268
+ if (rule.matchType === 'prefix' && normalizedTarget.startsWith(normalizedPattern)) return rule.projectAlias;
269
+ if (rule.matchType === 'contains' && normalizedTarget.includes(normalizedPattern)) return rule.projectAlias;
270
+ if (rule.matchType === 'regex') {
271
+ try {
272
+ if (new RegExp(pattern, 'i').test(target)) return rule.projectAlias;
273
+ } catch {
274
+ continue;
275
+ }
276
+ }
277
+ }
278
+ return null;
279
+ }
280
+
281
+ function inferFromOutput(session) {
282
+ if (!session.outputUrl) return null;
283
+ const type = normalizeText(session.outputType) || '未分类';
284
+ if (OUTPUT_RULES[type]) return OUTPUT_RULES[type];
285
+ return {
286
+ taskType: '其他',
287
+ outputStatus: '已完成',
288
+ workPurpose: '其他',
289
+ workStage: '验证',
290
+ valueLevel: '中',
291
+ confidence: 80,
292
+ reason: '已有产出链接,但类型未分类,因此仅按已完成产出做保守归因。'
293
+ };
294
+ }
295
+
296
+ function inferActiveStatus(session, now) {
297
+ const last = parseDate(session.lastActivity);
298
+ if (!last) return null;
299
+ const ageHours = (now.getTime() - last.getTime()) / 36e5;
300
+ if (ageHours < 0 || ageHours > 48) return null;
301
+ return {
302
+ outputStatus: '进行中',
303
+ confidence: 70,
304
+ reason: '最近 48 小时内仍有活动且暂无产出链接,保守建议为进行中。'
305
+ };
306
+ }
307
+
308
+ function inferFromTokenShape(session) {
309
+ const input = Number(session.inputTokens || 0);
310
+ const output = Number(session.outputTokens || 0);
311
+ const total = Number(session.totalTokens || 0);
312
+ const ratio = output > 0 ? input / output : input ? Infinity : 0;
313
+ if (input >= 100_000 && total >= 120_000 && ratio >= 8) {
314
+ return {
315
+ taskType: '技术调研',
316
+ workPurpose: '上下文整理',
317
+ workStage: '探索',
318
+ confidence: 65,
319
+ reason: '输入显著高于输出,且没有产出链接,仅能低置信建议为技术调研或上下文整理。'
320
+ };
321
+ }
322
+ return null;
323
+ }
324
+
325
+ function applyIfDefault(values, fieldConfidence, field, value, confidence) {
326
+ if (!isDefault(values[field], field)) return;
327
+ values[field] = value;
328
+ fieldConfidence[field] = confidence;
329
+ }
330
+
331
+ function isDefault(value, field) {
332
+ return (value || DEFAULTS[field]) === DEFAULTS[field];
333
+ }
334
+
335
+ function isUnhelpfulDefault(field, value) {
336
+ return field in DEFAULTS && (value || DEFAULTS[field]) === DEFAULTS[field];
337
+ }
338
+
339
+ function isReviewIncomplete(session = {}) {
340
+ return Object.entries(DEFAULTS).some(([field, fallback]) => (session[field] || fallback) === fallback);
341
+ }
342
+
343
+ function summarizeEvidence(session, fields, confidence) {
344
+ const bits = [
345
+ `${fields.length} 个字段`,
346
+ `置信度 ${confidence}%`
347
+ ];
348
+ if (session.outputType && session.outputUrl) bits.push(`产出类型 ${session.outputType}`);
349
+ if (session.ruleProjectAlias) bits.push('命中别名规则');
350
+ if (session.projectPath) bits.push('本地项目路径');
351
+ return bits.join(' · ');
352
+ }
353
+
354
+ function sameSession(left, right) {
355
+ return sessionKey(left) === sessionKey(right);
356
+ }
357
+
358
+ function sessionKey(row = {}) {
359
+ return `${row.device || ''}::${row.source || ''}::${row.sessionId || ''}`;
360
+ }
361
+
362
+ function sameValue(left, right) {
363
+ return normalizeText(left) === normalizeText(right);
364
+ }
365
+
366
+ function basename(value) {
367
+ const text = normalizeText(value);
368
+ if (!text || text === 'Unknown Project') return null;
369
+ const cleaned = text.replace(/[\\/]+$/, '');
370
+ const parts = cleaned.split(/[\\/]/).filter(Boolean);
371
+ return parts.at(-1) || null;
372
+ }
373
+
374
+ function projectPathFromSessionId(sessionId) {
375
+ const text = normalizeText(sessionId);
376
+ if (!text.startsWith('local:')) return '';
377
+ const lastColon = text.lastIndexOf(':');
378
+ if (lastColon <= 'local:'.length) return '';
379
+ const withoutModel = text.slice(0, lastColon);
380
+ return withoutModel.replace(/^local:[^:]+:/, '');
381
+ }
382
+
383
+ function parseDate(value) {
384
+ if (!value) return null;
385
+ const date = new Date(value);
386
+ return Number.isNaN(date.getTime()) ? null : date;
387
+ }
388
+
389
+ function toIso(value) {
390
+ const date = value instanceof Date ? value : new Date(value);
391
+ return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString();
392
+ }
393
+
394
+ function normalizeText(value) {
395
+ return String(value ?? '').trim().replace(/\s+/g, ' ');
396
+ }
@@ -0,0 +1,74 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { basename } from 'node:path';
3
+ import { parseCcusageJsonText, planCcusageImport } from './ccusage-import.mjs';
4
+
5
+ export const CCUSAGE_CLI_REPORTS = ['daily', 'weekly', 'monthly', 'session', 'blocks'];
6
+
7
+ export async function runCcusageCliImportPlan({
8
+ report = 'session',
9
+ ccusageBin = null,
10
+ device,
11
+ now = new Date()
12
+ } = {}) {
13
+ const invocation = ccusageInvocation({ report, ccusageBin });
14
+ const { stdout } = await runCommand(invocation);
15
+ const payload = parseCcusageJsonText(stdout);
16
+ const plan = planCcusageImport(payload, {
17
+ device,
18
+ now,
19
+ importSource: 'import:ccusage-cli',
20
+ toolCategory: 'import:ccusage-cli',
21
+ command: invocation.commandLabel
22
+ });
23
+ return { plan, invocation };
24
+ }
25
+
26
+ export function ccusageInvocation({ report = 'session', ccusageBin = null } = {}) {
27
+ const normalizedReport = String(report || 'session').toLowerCase();
28
+ if (!CCUSAGE_CLI_REPORTS.includes(normalizedReport)) {
29
+ throw new Error(`--report must be one of: ${CCUSAGE_CLI_REPORTS.join(', ')}`);
30
+ }
31
+ if (ccusageBin) {
32
+ const command = String(ccusageBin);
33
+ return {
34
+ command,
35
+ args: [normalizedReport, '--json', '--no-cost'],
36
+ commandLabel: `${basename(command)} ${normalizedReport} --json --no-cost`
37
+ };
38
+ }
39
+ return {
40
+ command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
41
+ args: ['ccusage@latest', normalizedReport, '--json', '--no-cost'],
42
+ commandLabel: `npx ccusage@latest ${normalizedReport} --json --no-cost`
43
+ };
44
+ }
45
+
46
+ function runCommand(invocation) {
47
+ return new Promise((resolve, reject) => {
48
+ const shell = process.platform === 'win32' && /\.(cmd|bat)$/i.test(invocation.command);
49
+ const child = spawn(invocation.command, invocation.args, {
50
+ cwd: process.cwd(),
51
+ env: process.env,
52
+ stdio: ['ignore', 'pipe', 'pipe'],
53
+ windowsHide: true,
54
+ shell
55
+ });
56
+ let stdout = '';
57
+ let stderr = '';
58
+ child.stdout.setEncoding('utf8');
59
+ child.stderr.setEncoding('utf8');
60
+ child.stdout.on('data', chunk => { stdout += chunk; });
61
+ child.stderr.on('data', chunk => { stderr += chunk; });
62
+ child.on('error', error => {
63
+ reject(new Error(`ccusage CLI failed to start: ${error.message}`));
64
+ });
65
+ child.on('close', code => {
66
+ if (code === 0) {
67
+ resolve({ stdout, stderr });
68
+ return;
69
+ }
70
+ const detail = stderr.trim() || stdout.trim() || `exit code ${code}`;
71
+ reject(new Error(`ccusage CLI failed: ${detail.slice(0, 800)}`));
72
+ });
73
+ });
74
+ }