@xiaobuyu/nodesign 0.0.1 → 0.0.2

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 (282) hide show
  1. package/README.md +156 -106
  2. package/bin/nodesign.js +0 -0
  3. package/package.json +1 -1
  4. package/server/api/README.md +45 -45
  5. package/server/api/_guard.js +75 -75
  6. package/server/api/admin.js +187 -187
  7. package/server/api/assets/docx-page.js +103 -103
  8. package/server/api/assets/helpers.js +22 -22
  9. package/server/api/assets/notes.js +144 -144
  10. package/server/api/assets.js +896 -896
  11. package/server/api/board.js +57 -57
  12. package/server/api/browse.js +138 -138
  13. package/server/api/canvas.js +444 -444
  14. package/server/api/chatai.js +324 -324
  15. package/server/api/exports/build-standalone.js +980 -980
  16. package/server/api/exports/cards.js +118 -118
  17. package/server/api/exports/docx-pdf.js +46 -46
  18. package/server/api/exports/export-page.js +142 -142
  19. package/server/api/exports/handoff.js +147 -147
  20. package/server/api/exports.js +690 -690
  21. package/server/api/instruction.js +75 -75
  22. package/server/api/me.js +116 -116
  23. package/server/api/memory.js +185 -185
  24. package/server/api/pending-changes.js +366 -366
  25. package/server/api/plugins.js +164 -164
  26. package/server/api/projects.js +220 -220
  27. package/server/api/publish.js +64 -64
  28. package/server/api/recent.js +73 -73
  29. package/server/api/sessions.js +589 -589
  30. package/server/api/skills.js +77 -77
  31. package/server/api/standalone-fit.js +168 -168
  32. package/server/api/turn-compose.js +180 -180
  33. package/server/api/turn-inflight.js +44 -44
  34. package/server/auth/README.md +17 -17
  35. package/server/auth/middleware.js +138 -138
  36. package/server/auth/origin-guard.js +71 -71
  37. package/server/auth/session.js +108 -108
  38. package/server/auth/tier.js +113 -113
  39. package/server/auth/users-store.js +348 -348
  40. package/server/edit/README.md +29 -29
  41. package/server/engine/README.md +113 -113
  42. package/server/engine/agent/agent-shared.js +550 -550
  43. package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -4
  44. package/server/engine/agent/auto-mode-rules.js +78 -78
  45. package/server/engine/agent/context.js +418 -418
  46. package/server/engine/agent/events.js +464 -464
  47. package/server/engine/agent/hooks/canvas-validate.js +199 -199
  48. package/server/engine/agent/hooks/failure.js +120 -120
  49. package/server/engine/agent/hooks/file-events.js +75 -75
  50. package/server/engine/agent/hooks/lifecycle.js +233 -233
  51. package/server/engine/agent/hooks/post-canvas-focus.js +80 -80
  52. package/server/engine/agent/hooks/post-guidance.js +206 -206
  53. package/server/engine/agent/hooks/post-subagent-report.js +104 -104
  54. package/server/engine/agent/hooks/post-trim.js +58 -58
  55. package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -34
  56. package/server/engine/agent/hooks/pre-defaults.js +84 -84
  57. package/server/engine/agent/hooks/pre-injectors.js +277 -277
  58. package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -78
  59. package/server/engine/agent/hooks/pre-starter-files.js +61 -61
  60. package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -78
  61. package/server/engine/agent/hooks/site-validate.js +94 -94
  62. package/server/engine/agent/hooks/tool-prompts.js +31 -31
  63. package/server/engine/agent/hooks/turn-state-memory.js +56 -56
  64. package/server/engine/agent/hooks/user-prompt-submit.js +215 -215
  65. package/server/engine/agent/hooks.js +354 -354
  66. package/server/engine/agent/init-contract.js +69 -69
  67. package/server/engine/agent/isolation.js +159 -159
  68. package/server/engine/agent/model-table.js +292 -292
  69. package/server/engine/agent/plugin-loader.js +266 -266
  70. package/server/engine/agent/prompts/nodesign-prelude.md +336 -336
  71. package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -71
  72. package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -98
  73. package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -583
  74. package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -483
  75. package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -100
  76. package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -270
  77. package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -70
  78. package/server/engine/agent/prompts/tools/site-reference.md +106 -106
  79. package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -125
  80. package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -48
  81. package/server/engine/agent/session-loop.js +1024 -1024
  82. package/server/engine/agent/session-model.js +164 -164
  83. package/server/engine/agent/skill.js +204 -204
  84. package/server/engine/agent/task-events.js +78 -78
  85. package/server/engine/agents/ds-extractor.md +132 -132
  86. package/server/engine/agents/explorer.md +190 -190
  87. package/server/engine/agents/index.js +230 -230
  88. package/server/engine/agents/schemas/design-system.json +156 -156
  89. package/server/engine/agents/schemas/tweak-schema.json +113 -113
  90. package/server/engine/agents/tweak-proposer.md +127 -127
  91. package/server/engine/agents/vision-checker.md +254 -254
  92. package/server/engine/browse/capture.js +422 -422
  93. package/server/engine/browse/card.js +137 -137
  94. package/server/engine/browse/handover.js +59 -59
  95. package/server/engine/browse/page-digest.js +119 -119
  96. package/server/engine/browse/refs.js +196 -196
  97. package/server/engine/browse/registry.js +307 -307
  98. package/server/engine/browse/screencast.js +170 -170
  99. package/server/engine/browse/state.js +121 -121
  100. package/server/engine/chatai/chat-log.js +79 -79
  101. package/server/engine/chatai/index.js +193 -193
  102. package/server/engine/chatai/openai-compat.js +152 -152
  103. package/server/engine/chatai/orchestrate.js +309 -309
  104. package/server/engine/chatai/perform.js +40 -40
  105. package/server/engine/chatai/summarize.js +79 -79
  106. package/server/engine/mcp/capability-gate.js +52 -52
  107. package/server/engine/mcp/index.js +339 -339
  108. package/server/engine/mcp/param-sanitizer.js +142 -142
  109. package/server/engine/mcp/tools/arrange-on-board.js +124 -124
  110. package/server/engine/mcp/tools/artifact-session.js +278 -278
  111. package/server/engine/mcp/tools/browse-computer.js +421 -421
  112. package/server/engine/mcp/tools/browse-find-batch.js +183 -183
  113. package/server/engine/mcp/tools/browse-screenshot.js +157 -157
  114. package/server/engine/mcp/tools/browse.js +480 -480
  115. package/server/engine/mcp/tools/build-docx.js +101 -101
  116. package/server/engine/mcp/tools/clear-pending-changes.js +99 -99
  117. package/server/engine/mcp/tools/create-on-board.js +155 -155
  118. package/server/engine/mcp/tools/crystallize-skill.js +168 -168
  119. package/server/engine/mcp/tools/explain-style.js +237 -237
  120. package/server/engine/mcp/tools/export-handoff.js +133 -133
  121. package/server/engine/mcp/tools/expose-tweaks.js +149 -149
  122. package/server/engine/mcp/tools/generate-image.js +842 -842
  123. package/server/engine/mcp/tools/get-computed-styles.js +164 -164
  124. package/server/engine/mcp/tools/get-pending-changes.js +205 -205
  125. package/server/engine/mcp/tools/h3box-ssh.js +84 -84
  126. package/server/engine/mcp/tools/helpers/acquire-page.js +82 -82
  127. package/server/engine/mcp/tools/helpers/motion-lab.js +554 -554
  128. package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -112
  129. package/server/engine/mcp/tools/helpers/perception-page.js +215 -215
  130. package/server/engine/mcp/tools/helpers/reference-download.js +103 -103
  131. package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -80
  132. package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -280
  133. package/server/engine/mcp/tools/highlight.js +68 -68
  134. package/server/engine/mcp/tools/list-pages.js +222 -222
  135. package/server/engine/mcp/tools/lookup-tags.js +59 -59
  136. package/server/engine/mcp/tools/navigate-to-page.js +60 -60
  137. package/server/engine/mcp/tools/organize-board.js +64 -64
  138. package/server/engine/mcp/tools/paint-still.js +434 -434
  139. package/server/engine/mcp/tools/pin-to-board.js +130 -130
  140. package/server/engine/mcp/tools/preview-deck.js +130 -130
  141. package/server/engine/mcp/tools/profile-scroll.js +305 -305
  142. package/server/engine/mcp/tools/publish-site.js +102 -102
  143. package/server/engine/mcp/tools/query-elements.js +195 -195
  144. package/server/engine/mcp/tools/read-board.js +103 -103
  145. package/server/engine/mcp/tools/read-document.js +93 -93
  146. package/server/engine/mcp/tools/read-page.js +264 -264
  147. package/server/engine/mcp/tools/read-tavern-json.js +143 -143
  148. package/server/engine/mcp/tools/record-decision.js +140 -140
  149. package/server/engine/mcp/tools/relate-on-board.js +110 -110
  150. package/server/engine/mcp/tools/report-issue.js +115 -115
  151. package/server/engine/mcp/tools/roll-film.js +266 -266
  152. package/server/engine/mcp/tools/screenshot-docx.js +138 -138
  153. package/server/engine/mcp/tools/screenshot-url.js +195 -195
  154. package/server/engine/mcp/tools/screenshot.js +583 -583
  155. package/server/engine/mcp/tools/tier-gate.js +96 -96
  156. package/server/engine/mcp/tools/trace-motion.js +215 -215
  157. package/server/engine/mcp/tools/web-search.js +548 -548
  158. package/server/engine/motion/inventory.js +349 -349
  159. package/server/engine/perception/session.js +235 -235
  160. package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -5
  161. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -192
  162. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -667
  163. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -22
  164. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -25
  165. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -23
  166. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -21
  167. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -23
  168. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -21
  169. package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -162
  170. package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -407
  171. package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -80
  172. package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -228
  173. package/server/engine/plugins/nodesign/skills/rp-craft/patterns//346/274/224/345/207/272/351/241/265-/346/234/200/345/260/217/345/256/236/347/216/260.html +78 -78
  174. package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -268
  175. package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -323
  176. package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -134
  177. package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -61
  178. package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -163
  179. package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -86
  180. package/server/engine/runs/active-runs.js +698 -698
  181. package/server/engine/runs/live-turn.js +252 -252
  182. package/server/engine/runs/store.js +364 -364
  183. package/server/engine/runs/turn-relay.js +217 -217
  184. package/server/engine/runtime/workspace.js +164 -164
  185. package/server/lib/artifact-file-path.js +80 -80
  186. package/server/lib/artifact-target.js +284 -284
  187. package/server/lib/asset-refs.js +239 -239
  188. package/server/lib/async-queue.js +104 -104
  189. package/server/lib/auto-relations.js +130 -130
  190. package/server/lib/binding-types.js +60 -60
  191. package/server/lib/board-kind-sizes.js +62 -62
  192. package/server/lib/board-relations.js +133 -133
  193. package/server/lib/browse-proxy.js +180 -180
  194. package/server/lib/canvas-id.js +36 -36
  195. package/server/lib/cover.js +227 -227
  196. package/server/lib/danbooru-tags.js +291 -291
  197. package/server/lib/doc-extract.js +156 -156
  198. package/server/lib/docx/build-from-source.js +260 -260
  199. package/server/lib/docx/build.js +490 -490
  200. package/server/lib/docx/dump-styles.js +287 -287
  201. package/server/lib/docx/fonts/nodesign-cjk.conf +164 -164
  202. package/server/lib/docx/merge-runs.js +129 -129
  203. package/server/lib/docx/numbering.js +218 -218
  204. package/server/lib/docx/order.js +152 -152
  205. package/server/lib/docx/rawzip.js +172 -172
  206. package/server/lib/docx/render.js +92 -92
  207. package/server/lib/docx/text-lint.js +225 -225
  208. package/server/lib/docx/tokens.js +335 -335
  209. package/server/lib/docx/units.js +29 -29
  210. package/server/lib/docx/xml.js +291 -291
  211. package/server/lib/docx-pages.js +187 -187
  212. package/server/lib/export-collect.js +289 -289
  213. package/server/lib/export-package.js +210 -210
  214. package/server/lib/html-srcset.js +145 -145
  215. package/server/lib/image-variant.js +460 -460
  216. package/server/lib/ingress/forward-openai-chat.js +305 -305
  217. package/server/lib/ingress/openai-chat.js +491 -491
  218. package/server/lib/ingress/session-notice.js +66 -66
  219. package/server/lib/ingress/session-routes.js +68 -68
  220. package/server/lib/ingress/slot-probe.js +130 -130
  221. package/server/lib/ingress/upstream-billing.js +52 -52
  222. package/server/lib/ingress/upstream-fail-streak.js +72 -72
  223. package/server/lib/ingress/upstream-truncation.js +48 -48
  224. package/server/lib/issues-store.js +182 -182
  225. package/server/lib/kinds/deck.js +72 -72
  226. package/server/lib/kinds/docx.js +283 -283
  227. package/server/lib/kinds/file-kinds.js +113 -113
  228. package/server/lib/kinds/index.js +202 -202
  229. package/server/lib/kinds/site.js +161 -161
  230. package/server/lib/model-ingress.js +543 -543
  231. package/server/lib/moderation.js +255 -255
  232. package/server/lib/notice-store.js +103 -103
  233. package/server/lib/plugin-install.js +104 -104
  234. package/server/lib/plugin-validator.js +721 -721
  235. package/server/lib/publish-store.js +115 -115
  236. package/server/lib/quick-summary.js +52 -52
  237. package/server/lib/quota.js +270 -270
  238. package/server/lib/rate-window.js +29 -29
  239. package/server/lib/reference-assets.js +92 -92
  240. package/server/lib/region-shot.js +135 -135
  241. package/server/lib/safe-path.js +73 -73
  242. package/server/lib/sdk-session.js +33 -33
  243. package/server/lib/showcase-store.js +92 -92
  244. package/server/lib/site-publish.js +465 -465
  245. package/server/lib/ssrf-guard.js +432 -432
  246. package/server/lib/task-scan.js +158 -158
  247. package/server/lib/tavern-json.js +154 -154
  248. package/server/lib/video-variant.js +237 -237
  249. package/server/lib/workspace-path.js +33 -33
  250. package/server/ops/fix-sdk-musl.mjs +40 -40
  251. package/server/ops/install-macos-fonts.sh +114 -114
  252. package/server/ops/macos-fonts.conf +336 -336
  253. package/server/ops/sandbox-shim/bwrap +57 -57
  254. package/server/projects/assets-summary.js +119 -119
  255. package/server/projects/auto-name.js +53 -53
  256. package/server/projects/board-store.js +640 -640
  257. package/server/projects/move-entry.js +103 -103
  258. package/server/projects/store.js +270 -270
  259. package/server/projects/ui-config.js +54 -54
  260. package/server/projects/workspace-templates.js +53 -53
  261. package/server/projects/workspace.js +1025 -1025
  262. package/server/runtime/local-config.js +177 -177
  263. package/server/runtime/profile.js +72 -72
  264. package/server/services/rembg-service.py +334 -334
  265. package/server/shared/README.md +25 -25
  266. package/server/shared/deck.js +54 -54
  267. package/server/shared/time.js +49 -49
  268. package/server/style-pipeline/README.md +53 -53
  269. package/server/ws/broker.js +36 -36
  270. package/server/ws/browse-channel.js +177 -177
  271. package/server/ws/index.js +386 -386
  272. package/web/README.md +89 -89
  273. package/web/dist/assets/BrowserWindow-BYCgizHw.js +11 -0
  274. package/web/dist/assets/{DeckWindow-4G0xX7P3.js → DeckWindow-pNydEM4L.js} +1 -1
  275. package/web/dist/assets/{DocxWindow-BXQsoPkh.js → DocxWindow-PPgzh3Gq.js} +1 -1
  276. package/web/dist/assets/{SiteWindow-D6eS-2gM.js → SiteWindow-DM0LQPCO.js} +1 -1
  277. package/web/dist/assets/{index-Cbl3ATvp.js → index-BHTBJlpY.js} +32 -32
  278. package/web/dist/assets/{pending-edit-apply-DTjo2geW.js → pending-edit-apply-C0I13CbD.js} +1 -1
  279. package/web/dist/assets/{rotate-cw-LMT3x1Zj.js → rotate-cw-B1UQ8Hld.js} +1 -1
  280. package/web/dist/assets/{square-dashed-mouse-pointer-CBoAkfhi.js → square-dashed-mouse-pointer-8lrgqmfv.js} +1 -1
  281. package/web/dist/index.html +17 -17
  282. package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +0 -11
@@ -1,418 +1,418 @@
1
- /**
2
- * AgentContext — 一次 run 的运行时上下文(SDK 包装版)
3
- *
4
- * 形态变更(2026-04-29 战略转向):
5
- * - 之前:自写 agent-loop + tool-registry,ctx 含 todoState / forTool() 给 tool execute 用
6
- * - 现在:包 Claude Agent SDK 的 query(),工具由 SDK 内置 + 子进程跑,ctx 不再管 tool 执行
7
- *
8
- * 职责:
9
- * - 串起 runId / skillId / EventBus / AbortController
10
- * - 提供 workspace.* 包装供 skill loader / 业务逻辑读取沙盒
11
- * - 暴露 emit() 把业务事件推到 EventBus(给 WS 桥接)
12
- * - 跟踪 SDK 返回的 sessionId(从首条 message 提取,存 metadata)
13
- * - 维护 counters(rounds / tokens / cost / errors)落 run.metadata
14
- *
15
- * 不再管:
16
- * - todoState(SDK TodoWrite 工具自管)
17
- * - forTool()(SDK 自己管 tool execute 上下文)
18
- */
19
-
20
- import { promises as fs } from 'fs';
21
- import path from 'path';
22
- import { ensureWorkspace, getWorkspaceRoot, readFile, writeFile, listDir, exists, safeResolve } from '../runtime/workspace.js';
23
- import { EventBus } from './events.js';
24
- import { repriceUsageDeltas } from './model-context.js';
25
-
26
- /**
27
- * 一个回合的计数器初值。**唯一形状真相**:AgentContext 构造函数与 session-loop 的
28
- * startTurn(每个回合重置一次)都用它。
29
- *
30
- * ⛔ 08-21 夜血案:startTurn 里原来抄了一份字面量,08-21 深夜给构造函数加 `toolCharges`
31
- * 时没同步过去 → startTurn 必定跑在任何工具之前 → `addToolCharge` 读 undefined 直接
32
- * TypeError:generate_image 的 $0.20 一分没记,而且**成功出的图还会变成一次工具报错**。
33
- * 收成一份就没有下一次。
34
- */
35
- export function freshTurnCounters() {
36
- return {
37
- turns: 0, // SDK num_turns
38
- toolCalls: 0,
39
- toolFailures: 0,
40
- compactBoundaries: 0,
41
- apiRetries: 0,
42
- durationMs: 0,
43
- durationApiMs: 0,
44
- totalCostUsd: 0,
45
- inputTokens: 0,
46
- outputTokens: 0,
47
- cacheReadTokens: 0,
48
- cacheCreateTokens: 0,
49
- modelUsage: null, // 分模型本 turn 增量(absorbResult 差分产物)
50
- toolCharges: {}, // 按件计价的工具花费(generate_image $0.20/张),absorbResult 末尾并进 modelUsage
51
- };
52
- }
53
-
54
- export class AgentContext {
55
- /**
56
- * @param {object} opts
57
- * @param {string} opts.runId
58
- * @param {string} opts.skillId
59
- * @param {EventBus} [opts.eventBus]
60
- * @param {AbortController} [opts.abortController]
61
- * @param {object} [opts.metadata={}]
62
- * @param {string} [opts.workspaceRoot] - 外部 workspace(如 per-project 目录);
63
- * 传了就走它,否则用 runId 推路径(旧 smoke 兼容)
64
- * @param {string} [opts.sessionId] - 当前 SDK session id;emit 时自动 enrich 到事件,
65
- * 让 WS 端按 sid 过滤防跨 session 串扰
66
- * @param {string} [opts.appModel] - NoDesign 上层真实 model(如 kimi-k2.6)。
67
- * 区别于 sdkOptions.model(spoofing alias,让 SDK
68
- * 内部 rawMaxTokens 算对)。hooks/events 算真实
69
- * context window 时按 appModel 查映射表。
70
- */
71
- constructor({ runId, skillId, eventBus, abortController, metadata = {}, workspaceRoot = null, sessionId = null, appModel = null }) {
72
- if (!runId) throw new Error('AgentContext: runId required');
73
- if (!skillId) throw new Error('AgentContext: skillId required');
74
-
75
- this.runId = runId;
76
- this.skillId = skillId;
77
- this.sessionId = sessionId;
78
- this.appModel = appModel;
79
- this.eventBus = eventBus || new EventBus();
80
- this.abortController = abortController || new AbortController();
81
- this.metadata = metadata;
82
- this._externalWorkspaceRoot = workspaceRoot;
83
-
84
- // SDK 在 message 流里返回 session_id,首次见到时记下
85
- this.sdkSessionId = null;
86
-
87
- // result.modelUsage 的差分基准(2026-07-31)。modelUsage 是**会话累计值**
88
- // (探针实测:turn2 的值 = turn1 + turn2),要拿到本 turn 增量必须对上一条
89
- // result 做差。基准跟 ctx 同生命周期 = 跟 SDK query stream 同生命周期
90
- // (runSession 每次 new AgentContext + new stream),stream 重启累计值归零、
91
- // 基准也归零,差分天然对齐。**不随 turn 边界重置**。
92
- this._modelUsageBase = null;
93
-
94
- // 可观测计数器(落 run.metadata 用)—— 形状只有 freshTurnCounters 一份
95
- this.counters = freshTurnCounters();
96
-
97
- this.startedAt = Date.now();
98
- }
99
-
100
- // ── 取消 ──
101
-
102
- get signal() {
103
- return this.abortController.signal;
104
- }
105
-
106
- /**
107
- * 取消 run。Phase 3c 加幂等保证 —— cancel 多次只触发一次 abort + 一次
108
- * run.cancelled emit。两条调用路径都依赖此幂等:
109
- * 1. cancelRun(active-runs.js)race window / interrupt 兜底时直接调
110
- * 2. session-loop.js result 处理识别 terminal_reason: 'aborted_*' 时调(interrupt 路径)
111
- *
112
- * 没有这个 flag,interrupt 路径 + abort 兜底会双 emit run.cancelled,
113
- * 前端 toast '已取消' 弹两次。
114
- */
115
- cancel(reason = 'user_cancel') {
116
- if (this._cancelled) return;
117
- this._cancelled = true;
118
- this.abortController.abort(reason);
119
- this.emit({ type: 'run.cancelled', reason });
120
- }
121
-
122
- ensureNotAborted() {
123
- if (this.signal.aborted) {
124
- const err = new Error(`Run ${this.runId} aborted: ${this.signal.reason || 'unknown'}`);
125
- err.code = 'AGENT_ABORTED';
126
- throw err;
127
- }
128
- }
129
-
130
- // ── 事件 ──
131
-
132
- emit(event) {
133
- if (!event || !event.type) throw new Error('emit: event.type required');
134
- // sessionId 自动 enrich 让 WS 端按 sid 过滤事件,防多 session / 多 tab 跨 session
135
- // 串扰(同 project bus 共享)。`...event` 在后保证调用方显式传的字段不被覆盖。
136
- const enriched = {
137
- runId: this.runId,
138
- sessionId: this.sessionId,
139
- ts: new Date().toISOString(),
140
- ...event,
141
- };
142
- this.eventBus.publish(enriched);
143
- return enriched;
144
- }
145
-
146
- // ── workspace 包装 ──
147
- // 外部 workspaceRoot 模式(P0 per-project 目录):直接走绝对路径,
148
- // 由调用方负责 mkdir + git init(见 projects/workspace.js)。
149
- // 旧 runId 模式(保留给 smoke 测试 / 单 run 沙盒):走 runtime/workspace.js 那套。
150
-
151
- workspace = {
152
- ensure: async () => {
153
- if (this._externalWorkspaceRoot) {
154
- await fs.mkdir(this._externalWorkspaceRoot, { recursive: true });
155
- return this._externalWorkspaceRoot;
156
- }
157
- return ensureWorkspace(this.runId);
158
- },
159
- root: () => this._externalWorkspaceRoot || getWorkspaceRoot(this.runId),
160
- exists: async (rel) => {
161
- if (this._externalWorkspaceRoot) {
162
- try {
163
- await fs.access(path.resolve(this._externalWorkspaceRoot, rel));
164
- return true;
165
- } catch {
166
- return false;
167
- }
168
- }
169
- return exists(this.runId, rel);
170
- },
171
- read: (rel) => readFile(this.runId, rel),
172
- write: (rel, content) => writeFile(this.runId, rel, content),
173
- list: (rel) => listDir(this.runId, rel),
174
- resolve: (rel) => safeResolve(this.runId, rel),
175
- };
176
-
177
- // ── 跟踪 SDK 数据 ──
178
-
179
- /** SDK 第一条 message 带 session_id,记下供 metadata / debug 用 */
180
- recordSdkSession(sessionId) {
181
- if (!this.sdkSessionId && sessionId) {
182
- this.sdkSessionId = sessionId;
183
- this.emit({ type: 'run.sdk.session', sessionId });
184
- }
185
- }
186
-
187
- /**
188
- * SDK SDKResultMessage 含全套统计;一次性吸收。
189
- *
190
- * 计量口径(2026-07-31 起):权威数据源是 result.modelUsage —— SDK 的分模型
191
- * 总账,含子代理消耗,中断的 turn 也不丢(累计值持续增长,下一条 result 的
192
- * 差分会捞回来)。差分出的本 turn 增量:
193
- * - 按模型汇总进 counters.inputTokens 等主字段(配额真列的来源)
194
- * - 分模型明细留在 counters.modelUsage,finishTurn 落 run_model_usage 表
195
- * - totalCostUsd 同样取差分(result.total_cost_usd 是会话累计值,以前
196
- * 原样落库导致 cost 列不能 sum —— 那是虚价,但至少语义要对)
197
- * result.usage 只做 modelUsage 缺失时的兜底(usage 是本轮真增量,探针实测)。
198
- */
199
- absorbResult(result) {
200
- if (!result) return;
201
- this.counters.turns = result.num_turns ?? this.counters.turns;
202
- this.counters.durationMs = result.duration_ms ?? this.counters.durationMs;
203
- this.counters.durationApiMs = result.duration_api_ms ?? this.counters.durationApiMs;
204
-
205
- // reprice:仅 API 会话 —— key 从 SDK spoof alias 还原成 appModel、按表价
206
- // 重算 costUsd(SDK 按 alias 的 Claude 价目表算,Kimi 时代虚高 30×)。
207
- // 订阅会话原样过(alias 与真订阅模型名同形,必须以会话通路为准,见
208
- // model-context.js repriceUsageDeltas 注释)。
209
- const deltas = repriceUsageDeltas(this._diffModelUsage(result.modelUsage), this.appModel);
210
- if (deltas) {
211
- let inp = 0; let out = 0; let cr = 0; let cc = 0; let cost = 0;
212
- for (const d of Object.values(deltas)) {
213
- inp += d.inputTokens; out += d.outputTokens;
214
- cr += d.cacheReadTokens; cc += d.cacheCreateTokens;
215
- cost += d.costUsd;
216
- }
217
- this.counters.inputTokens = inp;
218
- this.counters.outputTokens = out;
219
- this.counters.cacheReadTokens = cr;
220
- this.counters.cacheCreateTokens = cc;
221
- this.counters.totalCostUsd = cost;
222
- this.counters.modelUsage = deltas;
223
- } else if (result.usage) {
224
- this.counters.inputTokens = result.usage.input_tokens || 0;
225
- this.counters.outputTokens = result.usage.output_tokens || 0;
226
- this.counters.cacheReadTokens = result.usage.cache_read_input_tokens || 0;
227
- this.counters.cacheCreateTokens = result.usage.cache_creation_input_tokens || 0;
228
- this.counters.totalCostUsd = result.total_cost_usd ?? this.counters.totalCostUsd;
229
- }
230
- this._foldToolCharges();
231
- }
232
-
233
- /**
234
- * 半截续接的账要**结转**(08-21 夜,fable 评审 P0)。
235
- *
236
- * 病:absorbResult 对 counters 是**赋值不是累加**(差分基准 _modelUsageBase 在第一个
237
- * result 就前移了),而半截续接会让同一个 run 出现第二个 result → 第一轮的整份账
238
- * (token、表价 cost、已经折进去的 generate_image 按件费、上游自报的真 cost)被第二轮
239
- * 的增量整个覆盖 → 那一轮等于白跑,日限闸门(quota.usedCostToday 读 run_model_usage)少收。
240
- * 真路径实测:续接的回合上游真打 2 发、落库只有 1 发的量。
241
- *
242
- * 用法:推续接消息**之前** takeCarry() 存一份,下一个 result 的 absorbResult /
243
- * applyUpstreamBilling 跑完后 addCarry(carry) 加回去。多次续接会一路滚下去。
244
- *
245
- * ⚠️ 只加"可累加"的量:token / cost / 时长 / 工具计数。`turns` 不加 —— 它是 SDK 的
246
- * num_turns,本身带累计语义,加了会双数。
247
- */
248
- takeCarry() {
249
- const c = this.counters;
250
- return {
251
- inputTokens: c.inputTokens || 0,
252
- outputTokens: c.outputTokens || 0,
253
- cacheReadTokens: c.cacheReadTokens || 0,
254
- cacheCreateTokens: c.cacheCreateTokens || 0,
255
- totalCostUsd: c.totalCostUsd || 0,
256
- durationMs: c.durationMs || 0,
257
- durationApiMs: c.durationApiMs || 0,
258
- toolCalls: c.toolCalls || 0,
259
- toolFailures: c.toolFailures || 0,
260
- compactBoundaries: c.compactBoundaries || 0,
261
- apiRetries: c.apiRetries || 0,
262
- modelUsage: c.modelUsage ? JSON.parse(JSON.stringify(c.modelUsage)) : null,
263
- };
264
- }
265
-
266
- addCarry(carry) {
267
- if (!carry) return;
268
- const c = this.counters;
269
- for (const k of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheCreateTokens', 'totalCostUsd', 'durationMs', 'durationApiMs']) {
270
- c[k] = (c[k] || 0) + (carry[k] || 0);
271
- }
272
- // toolCalls 等计数器**不是**每回合重置后单调累加的差分量,而是 handleSDKMessage 一路 ++ 的,
273
- // 续接期间它们没被重置(同一个 run 同一个 ctx)→ 已经含了第一轮,别再加一遍。
274
- if (carry.modelUsage) {
275
- const usage = c.modelUsage && typeof c.modelUsage === 'object' ? c.modelUsage : {};
276
- for (const [name, d] of Object.entries(carry.modelUsage)) {
277
- const prev = usage[name] || { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, costUsd: 0 };
278
- usage[name] = {
279
- ...prev,
280
- inputTokens: (prev.inputTokens || 0) + (d.inputTokens || 0),
281
- outputTokens: (prev.outputTokens || 0) + (d.outputTokens || 0),
282
- cacheReadTokens: (prev.cacheReadTokens || 0) + (d.cacheReadTokens || 0),
283
- cacheCreateTokens: (prev.cacheCreateTokens || 0) + (d.cacheCreateTokens || 0),
284
- costUsd: (prev.costUsd || 0) + (d.costUsd || 0),
285
- };
286
- }
287
- c.modelUsage = usage;
288
- }
289
- }
290
-
291
- /** 按件计价的工具花费(generate_image 每张 $0.20):工具成功后记一笔,结账时并进 modelUsage 同一本账 */
292
- addToolCharge(name, usd) {
293
- const v = Number(usd);
294
- if (!name || !Number.isFinite(v) || v <= 0) return;
295
- this.counters.toolCharges[name] = (this.counters.toolCharges[name] || 0) + v;
296
- }
297
- _foldToolCharges() {
298
- const charges = this.counters.toolCharges || {};
299
- const names = Object.keys(charges);
300
- if (!names.length) return;
301
- const usage = this.counters.modelUsage && typeof this.counters.modelUsage === 'object' ? this.counters.modelUsage : {};
302
- for (const name of names) {
303
- const prev = usage[name] || { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, costUsd: 0 };
304
- usage[name] = { ...prev, costUsd: (prev.costUsd || 0) + charges[name] };
305
- }
306
- this.counters.modelUsage = usage;
307
- this.counters.totalCostUsd = Object.values(usage).reduce((s, d) => s + (d.costUsd || 0), 0);
308
- this.counters.toolCharges = {};
309
- }
310
-
311
- /**
312
- * 上游自报的费用覆盖(08-21 晚,lib/ingress/upstream-billing.js)。
313
- * billing = { appModel → { costUsd, responses, promptTokens, completionTokens, cachedTokens } },
314
- * 是 ingress 在本轮累加、session-loop 结账前取走的。规则:
315
- * - 上游报的 cost **> 0**(真扣了余额)→ 覆盖 modelUsage[appModel].costUsd;token 数仍信 SDK 差分(口径不同)
316
- * - 上游报 0 / 没报 → 保留表价算出来的数。OpenCode Go 订阅行(08-21 深夜实测)对订阅额度内的请求报 cost=0,
317
- * 但它消耗的是全站共享的 Go 池子($12/5h),按表价记才能让每用户日限跟着受控;免费行表价本就是 0
318
- * - SDK 没有该模型条目(CLI 失败 / 没覆盖到的 helper)→ 用上游 token 数补一条,按表价算(repriceUsageDeltas)
319
- * 覆盖后重算 totalCostUsd。counters.costSource:'upstream'(上游真扣费覆盖过)| 'table'(只按表价)
320
- */
321
- applyUpstreamBilling(billing) {
322
- if (!billing || typeof billing !== 'object') return;
323
- let touched = false; let overrode = false;
324
- const usage = this.counters.modelUsage && typeof this.counters.modelUsage === 'object' ? this.counters.modelUsage : {};
325
- for (const [appModel, acc] of Object.entries(billing)) {
326
- if (!acc || !acc.responses) continue;
327
- const reported = acc.costUsd != null && Number(acc.costUsd) > 0 ? Number(acc.costUsd) : null;
328
- const prev = usage[appModel];
329
- if (prev) {
330
- if (reported != null) { usage[appModel] = { ...prev, costUsd: reported }; overrode = true; touched = true; }
331
- continue;
332
- }
333
- // SDK 没这条(CLI 失败那类):用上游 token 数补一条,先按表价,再看上游有没有真扣费
334
- const cached = acc.cachedTokens || 0;
335
- const raw = { [appModel]: { inputTokens: Math.max(0, (acc.promptTokens || 0) - cached), outputTokens: acc.completionTokens || 0, cacheReadTokens: cached, cacheCreateTokens: 0, costUsd: 0 } };
336
- const priced = repriceUsageDeltas(raw, this.appModel)?.[appModel] || raw[appModel];
337
- usage[appModel] = reported != null ? { ...priced, costUsd: reported } : priced;
338
- if (reported != null) overrode = true;
339
- touched = true;
340
- }
341
- if (!touched) return;
342
- this.counters.modelUsage = usage;
343
- this.counters.totalCostUsd = Object.values(usage).reduce((s, d) => s + (d.costUsd || 0), 0);
344
- this.counters.costSource = overrode ? 'upstream' : 'table';
345
- }
346
-
347
- /**
348
- * 会话累计的 modelUsage → 本 turn 增量。返回 null 表示"没有可用的 modelUsage"
349
- * (调用方退回 usage 兜底);返回 {}(空对象)表示"有 modelUsage 但本 turn
350
- * 没有任何新消耗",此时主字段照常清零是正确语义。
351
- * Math.max(0, ...) 防浮点 / 上游异常出负数;基准无论如何都更新成最新快照。
352
- */
353
- _diffModelUsage(modelUsage) {
354
- if (!modelUsage || typeof modelUsage !== 'object') return null;
355
- const models = Object.keys(modelUsage);
356
- if (models.length === 0) return null;
357
- const base = this._modelUsageBase || {};
358
- const deltas = {};
359
- const snapshot = {};
360
- for (const model of models) {
361
- const u = modelUsage[model] || {};
362
- const b = base[model] || {};
363
- snapshot[model] = {
364
- inputTokens: u.inputTokens || 0,
365
- outputTokens: u.outputTokens || 0,
366
- cacheReadInputTokens: u.cacheReadInputTokens || 0,
367
- cacheCreationInputTokens: u.cacheCreationInputTokens || 0,
368
- costUSD: u.costUSD || 0,
369
- };
370
- const d = {
371
- inputTokens: Math.max(0, snapshot[model].inputTokens - (b.inputTokens || 0)),
372
- outputTokens: Math.max(0, snapshot[model].outputTokens - (b.outputTokens || 0)),
373
- cacheReadTokens: Math.max(0, snapshot[model].cacheReadInputTokens - (b.cacheReadInputTokens || 0)),
374
- cacheCreateTokens: Math.max(0, snapshot[model].cacheCreationInputTokens - (b.cacheCreationInputTokens || 0)),
375
- costUsd: Math.max(0, snapshot[model].costUSD - (b.costUSD || 0)),
376
- };
377
- if (d.inputTokens || d.outputTokens || d.cacheReadTokens || d.cacheCreateTokens || d.costUsd) {
378
- deltas[model] = d;
379
- }
380
- }
381
- // 基准里已有但本次 modelUsage 没出现的模型:保留旧值(SDK 不应该丢 key,
382
- // 防御性合并,免得它真丢时差分把历史消耗又算一遍)
383
- this._modelUsageBase = { ...base, ...snapshot };
384
- return deltas;
385
- }
386
-
387
- incrementTool(failed = false) {
388
- this.counters.toolCalls += 1;
389
- if (failed) this.counters.toolFailures += 1;
390
- }
391
-
392
- /**
393
- * 子代理收尾用量(SDK task_notification.usage)累积(2026-07-30)。
394
- * **单独记账,不加进主 inputTokens/outputTokens** —— 主字段现在来自
395
- * modelUsage 差分(SDK 的分模型总账,已含 sidechain),相加必然双重计数。
396
- * 这两个字段只进 metadata 做可观测("这轮子代理烧了多少"),也是交叉验证
397
- * 钩子:某轮 subagent 用量明显大于 modelUsage 差分总和 = 总账没含 sidechain
398
- * 的信号(目前判断是含的,出现反例再查)。
399
- */
400
- absorbSubagentUsage(usage) {
401
- if (!usage || typeof usage !== 'object') return;
402
- this.counters.subagentInputTokens = (this.counters.subagentInputTokens || 0) + (usage.input_tokens || 0);
403
- this.counters.subagentOutputTokens = (this.counters.subagentOutputTokens || 0) + (usage.output_tokens || 0);
404
- }
405
-
406
- // ── observability ──
407
-
408
- snapshot() {
409
- return {
410
- runId: this.runId,
411
- skillId: this.skillId,
412
- sdkSessionId: this.sdkSessionId,
413
- counters: { ...this.counters },
414
- elapsedMs: Date.now() - this.startedAt,
415
- aborted: this.signal.aborted,
416
- };
417
- }
418
- }
1
+ /**
2
+ * AgentContext — 一次 run 的运行时上下文(SDK 包装版)
3
+ *
4
+ * 形态变更(2026-04-29 战略转向):
5
+ * - 之前:自写 agent-loop + tool-registry,ctx 含 todoState / forTool() 给 tool execute 用
6
+ * - 现在:包 Claude Agent SDK 的 query(),工具由 SDK 内置 + 子进程跑,ctx 不再管 tool 执行
7
+ *
8
+ * 职责:
9
+ * - 串起 runId / skillId / EventBus / AbortController
10
+ * - 提供 workspace.* 包装供 skill loader / 业务逻辑读取沙盒
11
+ * - 暴露 emit() 把业务事件推到 EventBus(给 WS 桥接)
12
+ * - 跟踪 SDK 返回的 sessionId(从首条 message 提取,存 metadata)
13
+ * - 维护 counters(rounds / tokens / cost / errors)落 run.metadata
14
+ *
15
+ * 不再管:
16
+ * - todoState(SDK TodoWrite 工具自管)
17
+ * - forTool()(SDK 自己管 tool execute 上下文)
18
+ */
19
+
20
+ import { promises as fs } from 'fs';
21
+ import path from 'path';
22
+ import { ensureWorkspace, getWorkspaceRoot, readFile, writeFile, listDir, exists, safeResolve } from '../runtime/workspace.js';
23
+ import { EventBus } from './events.js';
24
+ import { repriceUsageDeltas } from './model-context.js';
25
+
26
+ /**
27
+ * 一个回合的计数器初值。**唯一形状真相**:AgentContext 构造函数与 session-loop 的
28
+ * startTurn(每个回合重置一次)都用它。
29
+ *
30
+ * ⛔ 08-21 夜血案:startTurn 里原来抄了一份字面量,08-21 深夜给构造函数加 `toolCharges`
31
+ * 时没同步过去 → startTurn 必定跑在任何工具之前 → `addToolCharge` 读 undefined 直接
32
+ * TypeError:generate_image 的 $0.20 一分没记,而且**成功出的图还会变成一次工具报错**。
33
+ * 收成一份就没有下一次。
34
+ */
35
+ export function freshTurnCounters() {
36
+ return {
37
+ turns: 0, // SDK num_turns
38
+ toolCalls: 0,
39
+ toolFailures: 0,
40
+ compactBoundaries: 0,
41
+ apiRetries: 0,
42
+ durationMs: 0,
43
+ durationApiMs: 0,
44
+ totalCostUsd: 0,
45
+ inputTokens: 0,
46
+ outputTokens: 0,
47
+ cacheReadTokens: 0,
48
+ cacheCreateTokens: 0,
49
+ modelUsage: null, // 分模型本 turn 增量(absorbResult 差分产物)
50
+ toolCharges: {}, // 按件计价的工具花费(generate_image $0.20/张),absorbResult 末尾并进 modelUsage
51
+ };
52
+ }
53
+
54
+ export class AgentContext {
55
+ /**
56
+ * @param {object} opts
57
+ * @param {string} opts.runId
58
+ * @param {string} opts.skillId
59
+ * @param {EventBus} [opts.eventBus]
60
+ * @param {AbortController} [opts.abortController]
61
+ * @param {object} [opts.metadata={}]
62
+ * @param {string} [opts.workspaceRoot] - 外部 workspace(如 per-project 目录);
63
+ * 传了就走它,否则用 runId 推路径(旧 smoke 兼容)
64
+ * @param {string} [opts.sessionId] - 当前 SDK session id;emit 时自动 enrich 到事件,
65
+ * 让 WS 端按 sid 过滤防跨 session 串扰
66
+ * @param {string} [opts.appModel] - NoDesign 上层真实 model(如 kimi-k2.6)。
67
+ * 区别于 sdkOptions.model(spoofing alias,让 SDK
68
+ * 内部 rawMaxTokens 算对)。hooks/events 算真实
69
+ * context window 时按 appModel 查映射表。
70
+ */
71
+ constructor({ runId, skillId, eventBus, abortController, metadata = {}, workspaceRoot = null, sessionId = null, appModel = null }) {
72
+ if (!runId) throw new Error('AgentContext: runId required');
73
+ if (!skillId) throw new Error('AgentContext: skillId required');
74
+
75
+ this.runId = runId;
76
+ this.skillId = skillId;
77
+ this.sessionId = sessionId;
78
+ this.appModel = appModel;
79
+ this.eventBus = eventBus || new EventBus();
80
+ this.abortController = abortController || new AbortController();
81
+ this.metadata = metadata;
82
+ this._externalWorkspaceRoot = workspaceRoot;
83
+
84
+ // SDK 在 message 流里返回 session_id,首次见到时记下
85
+ this.sdkSessionId = null;
86
+
87
+ // result.modelUsage 的差分基准(2026-07-31)。modelUsage 是**会话累计值**
88
+ // (探针实测:turn2 的值 = turn1 + turn2),要拿到本 turn 增量必须对上一条
89
+ // result 做差。基准跟 ctx 同生命周期 = 跟 SDK query stream 同生命周期
90
+ // (runSession 每次 new AgentContext + new stream),stream 重启累计值归零、
91
+ // 基准也归零,差分天然对齐。**不随 turn 边界重置**。
92
+ this._modelUsageBase = null;
93
+
94
+ // 可观测计数器(落 run.metadata 用)—— 形状只有 freshTurnCounters 一份
95
+ this.counters = freshTurnCounters();
96
+
97
+ this.startedAt = Date.now();
98
+ }
99
+
100
+ // ── 取消 ──
101
+
102
+ get signal() {
103
+ return this.abortController.signal;
104
+ }
105
+
106
+ /**
107
+ * 取消 run。Phase 3c 加幂等保证 —— cancel 多次只触发一次 abort + 一次
108
+ * run.cancelled emit。两条调用路径都依赖此幂等:
109
+ * 1. cancelRun(active-runs.js)race window / interrupt 兜底时直接调
110
+ * 2. session-loop.js result 处理识别 terminal_reason: 'aborted_*' 时调(interrupt 路径)
111
+ *
112
+ * 没有这个 flag,interrupt 路径 + abort 兜底会双 emit run.cancelled,
113
+ * 前端 toast '已取消' 弹两次。
114
+ */
115
+ cancel(reason = 'user_cancel') {
116
+ if (this._cancelled) return;
117
+ this._cancelled = true;
118
+ this.abortController.abort(reason);
119
+ this.emit({ type: 'run.cancelled', reason });
120
+ }
121
+
122
+ ensureNotAborted() {
123
+ if (this.signal.aborted) {
124
+ const err = new Error(`Run ${this.runId} aborted: ${this.signal.reason || 'unknown'}`);
125
+ err.code = 'AGENT_ABORTED';
126
+ throw err;
127
+ }
128
+ }
129
+
130
+ // ── 事件 ──
131
+
132
+ emit(event) {
133
+ if (!event || !event.type) throw new Error('emit: event.type required');
134
+ // sessionId 自动 enrich 让 WS 端按 sid 过滤事件,防多 session / 多 tab 跨 session
135
+ // 串扰(同 project bus 共享)。`...event` 在后保证调用方显式传的字段不被覆盖。
136
+ const enriched = {
137
+ runId: this.runId,
138
+ sessionId: this.sessionId,
139
+ ts: new Date().toISOString(),
140
+ ...event,
141
+ };
142
+ this.eventBus.publish(enriched);
143
+ return enriched;
144
+ }
145
+
146
+ // ── workspace 包装 ──
147
+ // 外部 workspaceRoot 模式(P0 per-project 目录):直接走绝对路径,
148
+ // 由调用方负责 mkdir + git init(见 projects/workspace.js)。
149
+ // 旧 runId 模式(保留给 smoke 测试 / 单 run 沙盒):走 runtime/workspace.js 那套。
150
+
151
+ workspace = {
152
+ ensure: async () => {
153
+ if (this._externalWorkspaceRoot) {
154
+ await fs.mkdir(this._externalWorkspaceRoot, { recursive: true });
155
+ return this._externalWorkspaceRoot;
156
+ }
157
+ return ensureWorkspace(this.runId);
158
+ },
159
+ root: () => this._externalWorkspaceRoot || getWorkspaceRoot(this.runId),
160
+ exists: async (rel) => {
161
+ if (this._externalWorkspaceRoot) {
162
+ try {
163
+ await fs.access(path.resolve(this._externalWorkspaceRoot, rel));
164
+ return true;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+ return exists(this.runId, rel);
170
+ },
171
+ read: (rel) => readFile(this.runId, rel),
172
+ write: (rel, content) => writeFile(this.runId, rel, content),
173
+ list: (rel) => listDir(this.runId, rel),
174
+ resolve: (rel) => safeResolve(this.runId, rel),
175
+ };
176
+
177
+ // ── 跟踪 SDK 数据 ──
178
+
179
+ /** SDK 第一条 message 带 session_id,记下供 metadata / debug 用 */
180
+ recordSdkSession(sessionId) {
181
+ if (!this.sdkSessionId && sessionId) {
182
+ this.sdkSessionId = sessionId;
183
+ this.emit({ type: 'run.sdk.session', sessionId });
184
+ }
185
+ }
186
+
187
+ /**
188
+ * SDK SDKResultMessage 含全套统计;一次性吸收。
189
+ *
190
+ * 计量口径(2026-07-31 起):权威数据源是 result.modelUsage —— SDK 的分模型
191
+ * 总账,含子代理消耗,中断的 turn 也不丢(累计值持续增长,下一条 result 的
192
+ * 差分会捞回来)。差分出的本 turn 增量:
193
+ * - 按模型汇总进 counters.inputTokens 等主字段(配额真列的来源)
194
+ * - 分模型明细留在 counters.modelUsage,finishTurn 落 run_model_usage 表
195
+ * - totalCostUsd 同样取差分(result.total_cost_usd 是会话累计值,以前
196
+ * 原样落库导致 cost 列不能 sum —— 那是虚价,但至少语义要对)
197
+ * result.usage 只做 modelUsage 缺失时的兜底(usage 是本轮真增量,探针实测)。
198
+ */
199
+ absorbResult(result) {
200
+ if (!result) return;
201
+ this.counters.turns = result.num_turns ?? this.counters.turns;
202
+ this.counters.durationMs = result.duration_ms ?? this.counters.durationMs;
203
+ this.counters.durationApiMs = result.duration_api_ms ?? this.counters.durationApiMs;
204
+
205
+ // reprice:仅 API 会话 —— key 从 SDK spoof alias 还原成 appModel、按表价
206
+ // 重算 costUsd(SDK 按 alias 的 Claude 价目表算,Kimi 时代虚高 30×)。
207
+ // 订阅会话原样过(alias 与真订阅模型名同形,必须以会话通路为准,见
208
+ // model-context.js repriceUsageDeltas 注释)。
209
+ const deltas = repriceUsageDeltas(this._diffModelUsage(result.modelUsage), this.appModel);
210
+ if (deltas) {
211
+ let inp = 0; let out = 0; let cr = 0; let cc = 0; let cost = 0;
212
+ for (const d of Object.values(deltas)) {
213
+ inp += d.inputTokens; out += d.outputTokens;
214
+ cr += d.cacheReadTokens; cc += d.cacheCreateTokens;
215
+ cost += d.costUsd;
216
+ }
217
+ this.counters.inputTokens = inp;
218
+ this.counters.outputTokens = out;
219
+ this.counters.cacheReadTokens = cr;
220
+ this.counters.cacheCreateTokens = cc;
221
+ this.counters.totalCostUsd = cost;
222
+ this.counters.modelUsage = deltas;
223
+ } else if (result.usage) {
224
+ this.counters.inputTokens = result.usage.input_tokens || 0;
225
+ this.counters.outputTokens = result.usage.output_tokens || 0;
226
+ this.counters.cacheReadTokens = result.usage.cache_read_input_tokens || 0;
227
+ this.counters.cacheCreateTokens = result.usage.cache_creation_input_tokens || 0;
228
+ this.counters.totalCostUsd = result.total_cost_usd ?? this.counters.totalCostUsd;
229
+ }
230
+ this._foldToolCharges();
231
+ }
232
+
233
+ /**
234
+ * 半截续接的账要**结转**(08-21 夜,fable 评审 P0)。
235
+ *
236
+ * 病:absorbResult 对 counters 是**赋值不是累加**(差分基准 _modelUsageBase 在第一个
237
+ * result 就前移了),而半截续接会让同一个 run 出现第二个 result → 第一轮的整份账
238
+ * (token、表价 cost、已经折进去的 generate_image 按件费、上游自报的真 cost)被第二轮
239
+ * 的增量整个覆盖 → 那一轮等于白跑,日限闸门(quota.usedCostToday 读 run_model_usage)少收。
240
+ * 真路径实测:续接的回合上游真打 2 发、落库只有 1 发的量。
241
+ *
242
+ * 用法:推续接消息**之前** takeCarry() 存一份,下一个 result 的 absorbResult /
243
+ * applyUpstreamBilling 跑完后 addCarry(carry) 加回去。多次续接会一路滚下去。
244
+ *
245
+ * ⚠️ 只加"可累加"的量:token / cost / 时长 / 工具计数。`turns` 不加 —— 它是 SDK 的
246
+ * num_turns,本身带累计语义,加了会双数。
247
+ */
248
+ takeCarry() {
249
+ const c = this.counters;
250
+ return {
251
+ inputTokens: c.inputTokens || 0,
252
+ outputTokens: c.outputTokens || 0,
253
+ cacheReadTokens: c.cacheReadTokens || 0,
254
+ cacheCreateTokens: c.cacheCreateTokens || 0,
255
+ totalCostUsd: c.totalCostUsd || 0,
256
+ durationMs: c.durationMs || 0,
257
+ durationApiMs: c.durationApiMs || 0,
258
+ toolCalls: c.toolCalls || 0,
259
+ toolFailures: c.toolFailures || 0,
260
+ compactBoundaries: c.compactBoundaries || 0,
261
+ apiRetries: c.apiRetries || 0,
262
+ modelUsage: c.modelUsage ? JSON.parse(JSON.stringify(c.modelUsage)) : null,
263
+ };
264
+ }
265
+
266
+ addCarry(carry) {
267
+ if (!carry) return;
268
+ const c = this.counters;
269
+ for (const k of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheCreateTokens', 'totalCostUsd', 'durationMs', 'durationApiMs']) {
270
+ c[k] = (c[k] || 0) + (carry[k] || 0);
271
+ }
272
+ // toolCalls 等计数器**不是**每回合重置后单调累加的差分量,而是 handleSDKMessage 一路 ++ 的,
273
+ // 续接期间它们没被重置(同一个 run 同一个 ctx)→ 已经含了第一轮,别再加一遍。
274
+ if (carry.modelUsage) {
275
+ const usage = c.modelUsage && typeof c.modelUsage === 'object' ? c.modelUsage : {};
276
+ for (const [name, d] of Object.entries(carry.modelUsage)) {
277
+ const prev = usage[name] || { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, costUsd: 0 };
278
+ usage[name] = {
279
+ ...prev,
280
+ inputTokens: (prev.inputTokens || 0) + (d.inputTokens || 0),
281
+ outputTokens: (prev.outputTokens || 0) + (d.outputTokens || 0),
282
+ cacheReadTokens: (prev.cacheReadTokens || 0) + (d.cacheReadTokens || 0),
283
+ cacheCreateTokens: (prev.cacheCreateTokens || 0) + (d.cacheCreateTokens || 0),
284
+ costUsd: (prev.costUsd || 0) + (d.costUsd || 0),
285
+ };
286
+ }
287
+ c.modelUsage = usage;
288
+ }
289
+ }
290
+
291
+ /** 按件计价的工具花费(generate_image 每张 $0.20):工具成功后记一笔,结账时并进 modelUsage 同一本账 */
292
+ addToolCharge(name, usd) {
293
+ const v = Number(usd);
294
+ if (!name || !Number.isFinite(v) || v <= 0) return;
295
+ this.counters.toolCharges[name] = (this.counters.toolCharges[name] || 0) + v;
296
+ }
297
+ _foldToolCharges() {
298
+ const charges = this.counters.toolCharges || {};
299
+ const names = Object.keys(charges);
300
+ if (!names.length) return;
301
+ const usage = this.counters.modelUsage && typeof this.counters.modelUsage === 'object' ? this.counters.modelUsage : {};
302
+ for (const name of names) {
303
+ const prev = usage[name] || { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, costUsd: 0 };
304
+ usage[name] = { ...prev, costUsd: (prev.costUsd || 0) + charges[name] };
305
+ }
306
+ this.counters.modelUsage = usage;
307
+ this.counters.totalCostUsd = Object.values(usage).reduce((s, d) => s + (d.costUsd || 0), 0);
308
+ this.counters.toolCharges = {};
309
+ }
310
+
311
+ /**
312
+ * 上游自报的费用覆盖(08-21 晚,lib/ingress/upstream-billing.js)。
313
+ * billing = { appModel → { costUsd, responses, promptTokens, completionTokens, cachedTokens } },
314
+ * 是 ingress 在本轮累加、session-loop 结账前取走的。规则:
315
+ * - 上游报的 cost **> 0**(真扣了余额)→ 覆盖 modelUsage[appModel].costUsd;token 数仍信 SDK 差分(口径不同)
316
+ * - 上游报 0 / 没报 → 保留表价算出来的数。OpenCode Go 订阅行(08-21 深夜实测)对订阅额度内的请求报 cost=0,
317
+ * 但它消耗的是全站共享的 Go 池子($12/5h),按表价记才能让每用户日限跟着受控;免费行表价本就是 0
318
+ * - SDK 没有该模型条目(CLI 失败 / 没覆盖到的 helper)→ 用上游 token 数补一条,按表价算(repriceUsageDeltas)
319
+ * 覆盖后重算 totalCostUsd。counters.costSource:'upstream'(上游真扣费覆盖过)| 'table'(只按表价)
320
+ */
321
+ applyUpstreamBilling(billing) {
322
+ if (!billing || typeof billing !== 'object') return;
323
+ let touched = false; let overrode = false;
324
+ const usage = this.counters.modelUsage && typeof this.counters.modelUsage === 'object' ? this.counters.modelUsage : {};
325
+ for (const [appModel, acc] of Object.entries(billing)) {
326
+ if (!acc || !acc.responses) continue;
327
+ const reported = acc.costUsd != null && Number(acc.costUsd) > 0 ? Number(acc.costUsd) : null;
328
+ const prev = usage[appModel];
329
+ if (prev) {
330
+ if (reported != null) { usage[appModel] = { ...prev, costUsd: reported }; overrode = true; touched = true; }
331
+ continue;
332
+ }
333
+ // SDK 没这条(CLI 失败那类):用上游 token 数补一条,先按表价,再看上游有没有真扣费
334
+ const cached = acc.cachedTokens || 0;
335
+ const raw = { [appModel]: { inputTokens: Math.max(0, (acc.promptTokens || 0) - cached), outputTokens: acc.completionTokens || 0, cacheReadTokens: cached, cacheCreateTokens: 0, costUsd: 0 } };
336
+ const priced = repriceUsageDeltas(raw, this.appModel)?.[appModel] || raw[appModel];
337
+ usage[appModel] = reported != null ? { ...priced, costUsd: reported } : priced;
338
+ if (reported != null) overrode = true;
339
+ touched = true;
340
+ }
341
+ if (!touched) return;
342
+ this.counters.modelUsage = usage;
343
+ this.counters.totalCostUsd = Object.values(usage).reduce((s, d) => s + (d.costUsd || 0), 0);
344
+ this.counters.costSource = overrode ? 'upstream' : 'table';
345
+ }
346
+
347
+ /**
348
+ * 会话累计的 modelUsage → 本 turn 增量。返回 null 表示"没有可用的 modelUsage"
349
+ * (调用方退回 usage 兜底);返回 {}(空对象)表示"有 modelUsage 但本 turn
350
+ * 没有任何新消耗",此时主字段照常清零是正确语义。
351
+ * Math.max(0, ...) 防浮点 / 上游异常出负数;基准无论如何都更新成最新快照。
352
+ */
353
+ _diffModelUsage(modelUsage) {
354
+ if (!modelUsage || typeof modelUsage !== 'object') return null;
355
+ const models = Object.keys(modelUsage);
356
+ if (models.length === 0) return null;
357
+ const base = this._modelUsageBase || {};
358
+ const deltas = {};
359
+ const snapshot = {};
360
+ for (const model of models) {
361
+ const u = modelUsage[model] || {};
362
+ const b = base[model] || {};
363
+ snapshot[model] = {
364
+ inputTokens: u.inputTokens || 0,
365
+ outputTokens: u.outputTokens || 0,
366
+ cacheReadInputTokens: u.cacheReadInputTokens || 0,
367
+ cacheCreationInputTokens: u.cacheCreationInputTokens || 0,
368
+ costUSD: u.costUSD || 0,
369
+ };
370
+ const d = {
371
+ inputTokens: Math.max(0, snapshot[model].inputTokens - (b.inputTokens || 0)),
372
+ outputTokens: Math.max(0, snapshot[model].outputTokens - (b.outputTokens || 0)),
373
+ cacheReadTokens: Math.max(0, snapshot[model].cacheReadInputTokens - (b.cacheReadInputTokens || 0)),
374
+ cacheCreateTokens: Math.max(0, snapshot[model].cacheCreationInputTokens - (b.cacheCreationInputTokens || 0)),
375
+ costUsd: Math.max(0, snapshot[model].costUSD - (b.costUSD || 0)),
376
+ };
377
+ if (d.inputTokens || d.outputTokens || d.cacheReadTokens || d.cacheCreateTokens || d.costUsd) {
378
+ deltas[model] = d;
379
+ }
380
+ }
381
+ // 基准里已有但本次 modelUsage 没出现的模型:保留旧值(SDK 不应该丢 key,
382
+ // 防御性合并,免得它真丢时差分把历史消耗又算一遍)
383
+ this._modelUsageBase = { ...base, ...snapshot };
384
+ return deltas;
385
+ }
386
+
387
+ incrementTool(failed = false) {
388
+ this.counters.toolCalls += 1;
389
+ if (failed) this.counters.toolFailures += 1;
390
+ }
391
+
392
+ /**
393
+ * 子代理收尾用量(SDK task_notification.usage)累积(2026-07-30)。
394
+ * **单独记账,不加进主 inputTokens/outputTokens** —— 主字段现在来自
395
+ * modelUsage 差分(SDK 的分模型总账,已含 sidechain),相加必然双重计数。
396
+ * 这两个字段只进 metadata 做可观测("这轮子代理烧了多少"),也是交叉验证
397
+ * 钩子:某轮 subagent 用量明显大于 modelUsage 差分总和 = 总账没含 sidechain
398
+ * 的信号(目前判断是含的,出现反例再查)。
399
+ */
400
+ absorbSubagentUsage(usage) {
401
+ if (!usage || typeof usage !== 'object') return;
402
+ this.counters.subagentInputTokens = (this.counters.subagentInputTokens || 0) + (usage.input_tokens || 0);
403
+ this.counters.subagentOutputTokens = (this.counters.subagentOutputTokens || 0) + (usage.output_tokens || 0);
404
+ }
405
+
406
+ // ── observability ──
407
+
408
+ snapshot() {
409
+ return {
410
+ runId: this.runId,
411
+ skillId: this.skillId,
412
+ sdkSessionId: this.sdkSessionId,
413
+ counters: { ...this.counters },
414
+ elapsedMs: Date.now() - this.startedAt,
415
+ aborted: this.signal.aborted,
416
+ };
417
+ }
418
+ }