@cjhyy/code-shell-core 0.5.0-rc.0 → 0.5.0-rc.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 (283) hide show
  1. package/dist/agent/agent-definition-registry.d.ts +29 -0
  2. package/dist/agent/agent-definition-registry.js +74 -0
  3. package/dist/agent/agent-definition.d.ts +37 -0
  4. package/dist/agent/agent-definition.js +59 -0
  5. package/dist/arena/arena.js +11 -12
  6. package/dist/arena/context/context-tools.js +2 -4
  7. package/dist/arena/context/within-root.d.ts +7 -0
  8. package/dist/arena/context/within-root.js +15 -0
  9. package/dist/arena/iterate/phases/argue.js +8 -2
  10. package/dist/arena/iterate/phases/revise.js +1 -1
  11. package/dist/arena/iterate/phases/tournament.js +3 -3
  12. package/dist/arena/phases/adjudication.js +1 -4
  13. package/dist/arena/phases/build-consensus.js +1 -4
  14. package/dist/arena/phases/cross-review.js +2 -8
  15. package/dist/arena/phases/debate-rounds.js +1 -4
  16. package/dist/arena/phases/participant-research.js +1 -4
  17. package/dist/arena/phases/planning-detail-expansion.js +8 -10
  18. package/dist/arena/planner.js +0 -1
  19. package/dist/arena/providers/docs.d.ts +1 -0
  20. package/dist/arena/providers/docs.js +5 -2
  21. package/dist/arena/providers/repo.d.ts +1 -0
  22. package/dist/arena/providers/repo.js +5 -2
  23. package/dist/arena/strategies/utils.js +36 -3
  24. package/dist/arena/transitions.d.ts +7 -2
  25. package/dist/arena/transitions.js +9 -4
  26. package/dist/arena/types.d.ts +8 -2
  27. package/dist/automation/cron-expr.d.ts +31 -0
  28. package/dist/automation/cron-expr.js +151 -0
  29. package/dist/automation/index.d.ts +41 -0
  30. package/dist/automation/index.js +39 -0
  31. package/dist/automation/runner.d.ts +67 -0
  32. package/dist/automation/runner.js +56 -0
  33. package/dist/automation/scheduler.d.ts +156 -0
  34. package/dist/automation/scheduler.js +546 -0
  35. package/dist/automation/store.d.ts +39 -0
  36. package/dist/automation/store.js +119 -0
  37. package/dist/automation/write-policy.d.ts +34 -0
  38. package/dist/automation/write-policy.js +113 -0
  39. package/dist/automation/write-run.d.ts +51 -0
  40. package/dist/automation/write-run.js +38 -0
  41. package/dist/capability-control/index.d.ts +5 -0
  42. package/dist/capability-control/index.js +3 -0
  43. package/dist/capability-control/overlay.d.ts +49 -0
  44. package/dist/capability-control/overlay.js +89 -0
  45. package/dist/capability-control/project.d.ts +65 -0
  46. package/dist/capability-control/project.js +147 -0
  47. package/dist/capability-control/service.d.ts +70 -0
  48. package/dist/capability-control/service.js +147 -0
  49. package/dist/capability-control/types.d.ts +62 -0
  50. package/dist/capability-control/types.js +17 -0
  51. package/dist/cli/agent-server-stdio.d.ts +12 -1
  52. package/dist/cli/agent-server-stdio.js +118 -18
  53. package/dist/cli/agent-server-tcp.d.ts +20 -0
  54. package/dist/cli/agent-server-tcp.js +120 -0
  55. package/dist/cli/graceful-shutdown.d.ts +27 -0
  56. package/dist/cli/graceful-shutdown.js +30 -0
  57. package/dist/context/compaction.js +23 -15
  58. package/dist/context/manager.d.ts +0 -20
  59. package/dist/context/manager.js +0 -37
  60. package/dist/cron/cron-runtime.d.ts +2 -0
  61. package/dist/cron/cron-runtime.js +2 -0
  62. package/dist/cron/cron-store.d.ts +2 -0
  63. package/dist/cron/cron-store.js +2 -0
  64. package/dist/cron/scheduler.d.ts +5 -32
  65. package/dist/cron/scheduler.js +5 -113
  66. package/dist/data/openrouter-sync.js +1 -1
  67. package/dist/engine/engine.d.ts +256 -16
  68. package/dist/engine/engine.js +856 -200
  69. package/dist/engine/goal.d.ts +42 -0
  70. package/dist/engine/goal.js +52 -0
  71. package/dist/engine/image-compression.d.ts +81 -0
  72. package/dist/engine/image-compression.js +186 -0
  73. package/dist/engine/image-policy.d.ts +152 -0
  74. package/dist/engine/image-policy.js +165 -0
  75. package/dist/engine/reactive-threshold.d.ts +13 -0
  76. package/dist/engine/reactive-threshold.js +17 -0
  77. package/dist/engine/runtime.js +7 -0
  78. package/dist/engine/session-title.d.ts +13 -0
  79. package/dist/engine/session-title.js +34 -0
  80. package/dist/engine/turn-loop.d.ts +29 -8
  81. package/dist/engine/turn-loop.js +498 -316
  82. package/dist/git/parse-log.d.ts +13 -0
  83. package/dist/git/parse-log.js +21 -0
  84. package/dist/git/utils.d.ts +8 -6
  85. package/dist/git/utils.js +57 -26
  86. package/dist/git/worktree.js +33 -18
  87. package/dist/hooks/events.d.ts +23 -1
  88. package/dist/hooks/goal-stop-hook.d.ts +30 -0
  89. package/dist/hooks/goal-stop-hook.js +102 -0
  90. package/dist/hooks/hook-output.d.ts +33 -0
  91. package/dist/hooks/hook-output.js +79 -0
  92. package/dist/hooks/registry.d.ts +7 -0
  93. package/dist/hooks/registry.js +38 -1
  94. package/dist/hooks/shell-runner.d.ts +2 -0
  95. package/dist/hooks/shell-runner.js +43 -2
  96. package/dist/index.d.ts +32 -7
  97. package/dist/index.js +30 -3
  98. package/dist/llm/capabilities/reasoning-control.d.ts +24 -0
  99. package/dist/llm/capabilities/reasoning-control.js +38 -0
  100. package/dist/llm/capabilities/rules.js +33 -3
  101. package/dist/llm/capabilities/types.d.ts +22 -2
  102. package/dist/llm/clamp-max-tokens.d.ts +11 -0
  103. package/dist/llm/clamp-max-tokens.js +17 -0
  104. package/dist/llm/client-base.d.ts +41 -4
  105. package/dist/llm/client-base.js +67 -13
  106. package/dist/llm/client-factory.d.ts +3 -3
  107. package/dist/llm/client-factory.js +2 -2
  108. package/dist/llm/model-pool.d.ts +15 -11
  109. package/dist/llm/model-pool.js +23 -20
  110. package/dist/llm/provider-catalog.d.ts +2 -2
  111. package/dist/llm/providers/anthropic.d.ts +32 -2
  112. package/dist/llm/providers/anthropic.js +147 -8
  113. package/dist/llm/providers/openai.d.ts +11 -2
  114. package/dist/llm/providers/openai.js +253 -52
  115. package/dist/llm/reasoning-setting.d.ts +51 -0
  116. package/dist/llm/reasoning-setting.js +32 -0
  117. package/dist/llm/stop-reason.d.ts +13 -0
  118. package/dist/llm/stop-reason.js +21 -0
  119. package/dist/llm/strip-vision.d.ts +32 -0
  120. package/dist/llm/strip-vision.js +94 -0
  121. package/dist/llm/types.d.ts +5 -6
  122. package/dist/logging/logger.js +18 -4
  123. package/dist/logging/sanitize-messages.d.ts +10 -0
  124. package/dist/logging/sanitize-messages.js +90 -0
  125. package/dist/lsp/client.js +28 -8
  126. package/dist/lsp/manager.js +2 -1
  127. package/dist/lsp/root-path.d.ts +9 -0
  128. package/dist/lsp/root-path.js +12 -0
  129. package/dist/onboarding.js +18 -11
  130. package/dist/plugins/gitOps.d.ts +34 -0
  131. package/dist/plugins/gitOps.js +59 -3
  132. package/dist/plugins/installer/codex/convertAgents.d.ts +7 -0
  133. package/dist/plugins/installer/codex/convertAgents.js +55 -0
  134. package/dist/plugins/installer/codex/convertMcp.d.ts +8 -0
  135. package/dist/plugins/installer/codex/convertMcp.js +45 -0
  136. package/dist/plugins/installer/codex/convertSkills.d.ts +12 -0
  137. package/dist/plugins/installer/codex/convertSkills.js +33 -0
  138. package/dist/plugins/installer/detectFormat.d.ts +2 -0
  139. package/dist/plugins/installer/detectFormat.js +6 -0
  140. package/dist/plugins/installer/install.d.ts +7 -0
  141. package/dist/plugins/installer/install.js +99 -0
  142. package/dist/plugins/installer/installFromSource.d.ts +13 -0
  143. package/dist/plugins/installer/installFromSource.js +45 -0
  144. package/dist/plugins/installer/list.d.ts +9 -0
  145. package/dist/plugins/installer/list.js +30 -0
  146. package/dist/plugins/installer/loadPluginAgents.d.ts +3 -0
  147. package/dist/plugins/installer/loadPluginAgents.js +23 -0
  148. package/dist/plugins/installer/loadPluginMcp.d.ts +7 -0
  149. package/dist/plugins/installer/loadPluginMcp.js +74 -0
  150. package/dist/plugins/installer/parseSource.d.ts +20 -0
  151. package/dist/plugins/installer/parseSource.js +74 -0
  152. package/dist/plugins/installer/paths.d.ts +5 -0
  153. package/dist/plugins/installer/paths.js +25 -0
  154. package/dist/plugins/installer/types.d.ts +49 -0
  155. package/dist/plugins/installer/types.js +27 -0
  156. package/dist/plugins/installer/uninstall.d.ts +2 -0
  157. package/dist/plugins/installer/uninstall.js +14 -0
  158. package/dist/plugins/installer/update.d.ts +14 -0
  159. package/dist/plugins/installer/update.js +65 -0
  160. package/dist/plugins/loadPluginHooks.d.ts +8 -1
  161. package/dist/plugins/loadPluginHooks.js +11 -1
  162. package/dist/plugins/marketplaceManager.d.ts +8 -1
  163. package/dist/plugins/marketplaceManager.js +38 -4
  164. package/dist/plugins/pluginCommandHook.js +34 -0
  165. package/dist/plugins/pluginInstaller.d.ts +21 -0
  166. package/dist/plugins/pluginInstaller.js +128 -29
  167. package/dist/plugins/schemas.js +40 -6
  168. package/dist/plugins/types.d.ts +4 -0
  169. package/dist/preset/index.js +16 -0
  170. package/dist/prompt/composer.d.ts +10 -0
  171. package/dist/prompt/composer.js +29 -4
  172. package/dist/prompt/sections/orchestration.md +8 -0
  173. package/dist/protocol/chat-session-manager.d.ts +8 -1
  174. package/dist/protocol/chat-session-manager.js +10 -0
  175. package/dist/protocol/chat-session.d.ts +17 -0
  176. package/dist/protocol/chat-session.js +28 -0
  177. package/dist/protocol/redact.d.ts +50 -0
  178. package/dist/protocol/redact.js +71 -0
  179. package/dist/protocol/server.d.ts +41 -2
  180. package/dist/protocol/server.js +231 -33
  181. package/dist/protocol/tcp-transport.d.ts +45 -0
  182. package/dist/protocol/tcp-transport.js +74 -0
  183. package/dist/protocol/types.d.ts +23 -0
  184. package/dist/protocol/types.js +6 -0
  185. package/dist/remote/bridge.d.ts +11 -2
  186. package/dist/remote/bridge.js +61 -33
  187. package/dist/run/ArtifactTracker.js +5 -4
  188. package/dist/run/EngineRunner.d.ts +27 -1
  189. package/dist/run/EngineRunner.js +46 -8
  190. package/dist/run/FileRunStore.js +32 -8
  191. package/dist/run/RunLock.d.ts +28 -2
  192. package/dist/run/RunLock.js +49 -7
  193. package/dist/run/RunManager.js +51 -3
  194. package/dist/run/RunQueue.d.ts +1 -0
  195. package/dist/run/RunQueue.js +8 -2
  196. package/dist/run/factory.d.ts +7 -0
  197. package/dist/run/factory.js +1 -0
  198. package/dist/run/index.d.ts +2 -2
  199. package/dist/run/index.js +1 -1
  200. package/dist/run/redirect-target.d.ts +7 -0
  201. package/dist/run/redirect-target.js +13 -0
  202. package/dist/run/types.js +1 -1
  203. package/dist/services/browser-open.d.ts +13 -0
  204. package/dist/services/browser-open.js +16 -0
  205. package/dist/services/dream-consolidation.d.ts +57 -0
  206. package/dist/services/dream-consolidation.js +151 -0
  207. package/dist/services/memory-orchestrator.js +18 -0
  208. package/dist/services/notifier.d.ts +8 -0
  209. package/dist/services/notifier.js +39 -25
  210. package/dist/services/oauth.js +5 -7
  211. package/dist/services/session-memory-sort.d.ts +8 -0
  212. package/dist/services/session-memory-sort.js +9 -0
  213. package/dist/services/session-memory.js +8 -9
  214. package/dist/session/file-history.js +10 -3
  215. package/dist/session/session-manager.d.ts +23 -1
  216. package/dist/session/session-manager.js +73 -2
  217. package/dist/settings/disk-defaults.d.ts +35 -0
  218. package/dist/settings/disk-defaults.js +24 -0
  219. package/dist/settings/manager.d.ts +45 -1
  220. package/dist/settings/manager.js +121 -17
  221. package/dist/settings/manager.test.d.ts +1 -0
  222. package/dist/settings/manager.test.js +73 -0
  223. package/dist/settings/personalization.d.ts +19 -0
  224. package/dist/settings/personalization.js +7 -0
  225. package/dist/settings/schema.d.ts +1163 -126
  226. package/dist/settings/schema.js +126 -10
  227. package/dist/skills/scanner.d.ts +13 -6
  228. package/dist/skills/scanner.js +23 -4
  229. package/dist/tool-system/builtin/add-marketplace.d.ts +12 -0
  230. package/dist/tool-system/builtin/add-marketplace.js +76 -0
  231. package/dist/tool-system/builtin/agent-registry.d.ts +15 -0
  232. package/dist/tool-system/builtin/agent-registry.js +14 -4
  233. package/dist/tool-system/builtin/agent.d.ts +55 -0
  234. package/dist/tool-system/builtin/agent.js +165 -17
  235. package/dist/tool-system/builtin/apply-patch/index.js +22 -3
  236. package/dist/tool-system/builtin/arena.js +6 -7
  237. package/dist/tool-system/builtin/complete-goal.d.ts +25 -0
  238. package/dist/tool-system/builtin/complete-goal.js +45 -0
  239. package/dist/tool-system/builtin/cron.js +53 -9
  240. package/dist/tool-system/builtin/edit.d.ts +2 -1
  241. package/dist/tool-system/builtin/edit.js +6 -1
  242. package/dist/tool-system/builtin/generate-image.d.ts +25 -0
  243. package/dist/tool-system/builtin/generate-image.js +146 -0
  244. package/dist/tool-system/builtin/glob.js +7 -0
  245. package/dist/tool-system/builtin/grep.js +6 -0
  246. package/dist/tool-system/builtin/index.d.ts +18 -1
  247. package/dist/tool-system/builtin/index.js +62 -1
  248. package/dist/tool-system/builtin/notebook-edit.d.ts +2 -1
  249. package/dist/tool-system/builtin/notebook-edit.js +8 -1
  250. package/dist/tool-system/builtin/read.d.ts +2 -1
  251. package/dist/tool-system/builtin/read.js +9 -1
  252. package/dist/tool-system/builtin/skill.js +23 -5
  253. package/dist/tool-system/builtin/sleep.js +9 -3
  254. package/dist/tool-system/builtin/task.js +4 -1
  255. package/dist/tool-system/builtin/update-automation-memory.d.ts +24 -0
  256. package/dist/tool-system/builtin/update-automation-memory.js +60 -0
  257. package/dist/tool-system/builtin/view-image.d.ts +18 -0
  258. package/dist/tool-system/builtin/view-image.js +96 -0
  259. package/dist/tool-system/builtin/web-search.d.ts +4 -1
  260. package/dist/tool-system/builtin/web-search.js +36 -3
  261. package/dist/tool-system/builtin/write.d.ts +2 -1
  262. package/dist/tool-system/builtin/write.js +7 -1
  263. package/dist/tool-system/context.d.ts +43 -0
  264. package/dist/tool-system/executor.d.ts +0 -1
  265. package/dist/tool-system/executor.js +34 -40
  266. package/dist/tool-system/investigation-guard.d.ts +4 -1
  267. package/dist/tool-system/investigation-guard.js +19 -3
  268. package/dist/tool-system/mcp-manager.d.ts +36 -12
  269. package/dist/tool-system/mcp-manager.js +173 -10
  270. package/dist/tool-system/path-policy.d.ts +94 -0
  271. package/dist/tool-system/path-policy.js +279 -0
  272. package/dist/tool-system/permission.js +43 -50
  273. package/dist/tool-system/plan-mode-allowlist.d.ts +21 -0
  274. package/dist/tool-system/plan-mode-allowlist.js +43 -0
  275. package/dist/tool-system/registry.js +12 -1
  276. package/dist/tool-system/validation.d.ts +10 -0
  277. package/dist/tool-system/validation.js +16 -3
  278. package/dist/types.d.ts +90 -15
  279. package/dist/utils/format.js +4 -2
  280. package/dist/utils/lockfile.js +9 -2
  281. package/dist/utils/theme.d.ts +6 -0
  282. package/dist/utils/theme.js +4 -2
  283. package/package.json +2 -1
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Goal mode P0 — run-scoped budget guardrails.
3
+ *
4
+ * `GoalConfig` is the normalized internal shape. Public entry points
5
+ * (protocol/server, engine run) accept `string | GoalConfig` for
6
+ * back-compat and call `normalizeGoal()` once at the boundary; everything
7
+ * inward uses `GoalConfig`.
8
+ *
9
+ * The budget tracker is RUN-scoped (not per-turn like token-budget.ts):
10
+ * it accumulates total tokens across turns and stamps a wall-clock start,
11
+ * so an unattended goal run can't burn tokens or wall time without bound.
12
+ */
13
+ export interface GoalConfig {
14
+ /** The objective text shown to the judge / injected into context. */
15
+ objective: string;
16
+ /** Hard cap on total tokens (prompt+completion) for the whole goal run. */
17
+ tokenBudget?: number;
18
+ /** Hard cap on wall-clock duration of the whole goal run, in ms. */
19
+ timeBudgetMs?: number;
20
+ }
21
+ /**
22
+ * Coerce a raw goal input into a normalized GoalConfig, or undefined when
23
+ * there is effectively no goal (empty objective). Drops non-positive
24
+ * budgets (0 / negative → "no limit", same as absent).
25
+ */
26
+ export declare function normalizeGoal(raw: string | GoalConfig | undefined): GoalConfig | undefined;
27
+ export interface GoalBudgetTracker {
28
+ goal: GoalConfig;
29
+ /** Wall-clock start (ms epoch), captured at run start. */
30
+ startedAtMs: number;
31
+ /** Accumulated total tokens (prompt + completion) across all turns. */
32
+ tokensUsed: number;
33
+ }
34
+ /** Create a run-scoped tracker. `nowMs` is injected for testability. */
35
+ export declare function createGoalBudgetTracker(goal: GoalConfig, nowMs: number): GoalBudgetTracker;
36
+ /** Add this turn's token usage to the running total. */
37
+ export declare function recordGoalUsage(tracker: GoalBudgetTracker, turnTokens: number): void;
38
+ /**
39
+ * Has the run exceeded any configured budget? `nowMs` is injected so callers
40
+ * pass the current clock (and tests pass a fixed value).
41
+ */
42
+ export declare function goalBudgetExceeded(tracker: GoalBudgetTracker, nowMs: number): boolean;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Goal mode P0 — run-scoped budget guardrails.
3
+ *
4
+ * `GoalConfig` is the normalized internal shape. Public entry points
5
+ * (protocol/server, engine run) accept `string | GoalConfig` for
6
+ * back-compat and call `normalizeGoal()` once at the boundary; everything
7
+ * inward uses `GoalConfig`.
8
+ *
9
+ * The budget tracker is RUN-scoped (not per-turn like token-budget.ts):
10
+ * it accumulates total tokens across turns and stamps a wall-clock start,
11
+ * so an unattended goal run can't burn tokens or wall time without bound.
12
+ */
13
+ /**
14
+ * Coerce a raw goal input into a normalized GoalConfig, or undefined when
15
+ * there is effectively no goal (empty objective). Drops non-positive
16
+ * budgets (0 / negative → "no limit", same as absent).
17
+ */
18
+ export function normalizeGoal(raw) {
19
+ if (raw == null)
20
+ return undefined;
21
+ const obj = typeof raw === "string" ? { objective: raw } : { ...raw };
22
+ const objective = (obj.objective ?? "").trim();
23
+ if (!objective)
24
+ return undefined;
25
+ const out = { objective };
26
+ if (typeof obj.tokenBudget === "number" && obj.tokenBudget > 0)
27
+ out.tokenBudget = obj.tokenBudget;
28
+ if (typeof obj.timeBudgetMs === "number" && obj.timeBudgetMs > 0)
29
+ out.timeBudgetMs = obj.timeBudgetMs;
30
+ return out;
31
+ }
32
+ /** Create a run-scoped tracker. `nowMs` is injected for testability. */
33
+ export function createGoalBudgetTracker(goal, nowMs) {
34
+ return { goal, startedAtMs: nowMs, tokensUsed: 0 };
35
+ }
36
+ /** Add this turn's token usage to the running total. */
37
+ export function recordGoalUsage(tracker, turnTokens) {
38
+ if (turnTokens > 0)
39
+ tracker.tokensUsed += turnTokens;
40
+ }
41
+ /**
42
+ * Has the run exceeded any configured budget? `nowMs` is injected so callers
43
+ * pass the current clock (and tests pass a fixed value).
44
+ */
45
+ export function goalBudgetExceeded(tracker, nowMs) {
46
+ const { tokenBudget, timeBudgetMs } = tracker.goal;
47
+ if (typeof tokenBudget === "number" && tracker.tokensUsed > tokenBudget)
48
+ return true;
49
+ if (typeof timeBudgetMs === "number" && nowMs - tracker.startedAtMs > timeBudgetMs)
50
+ return true;
51
+ return false;
52
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Engine-side image compression — the fallback for hosts that didn't
3
+ * pre-compress.
4
+ *
5
+ * The desktop renderer already compresses via <canvas> + Blob before
6
+ * sending; the TUI and MCP image-returning tools historically didn't.
7
+ * When the {@link enforceImagePolicy} gate would otherwise refuse a
8
+ * turn for "image too large", we try to bring the offender(s) under
9
+ * the limit first by:
10
+ *
11
+ * 1. Decoding the base64 payload.
12
+ * 2. Resizing the longest edge down to {@link IMAGE_TARGETS.targetMaxDimension}.
13
+ * 3. Re-encoding as JPEG at {@link IMAGE_TARGETS.jpegQuality}.
14
+ *
15
+ * jimp is the obvious pick — pure-JS, no native binaries, runs in
16
+ * both Node and Bun, supports PNG / JPEG / WebP input. But shipping
17
+ * it as a hard `core` dep would balloon the published bundle for
18
+ * every consumer. So we resolve it dynamically: hosts that want
19
+ * automatic compression just `npm install jimp`; everyone else gets
20
+ * the existing "image too large" refusal.
21
+ *
22
+ * Hosts can also inject a custom {@link ImageCompressor} via
23
+ * {@link setEngineImageCompressor} — useful for tests, future
24
+ * sharp-based compressors, or arena agents with different size
25
+ * targets.
26
+ *
27
+ * The defaults must stay pure-JS so this module is safe to bundle for
28
+ * Electron preload / browser worker contexts. Anything that needs
29
+ * native code (e.g. sharp) should be injected explicitly.
30
+ */
31
+ import type { ParsedImage } from "./parse-task.js";
32
+ /**
33
+ * Outcome of one compression attempt. `compressed` is set only when
34
+ * the host actually re-encoded the image — callers can compare bytes
35
+ * before/after to decide whether to log the optimization.
36
+ */
37
+ export interface ImageCompressionResult {
38
+ /** The (possibly) re-encoded image, ready to replace the original. */
39
+ image: ParsedImage;
40
+ /** True if the host actually produced new bytes. */
41
+ compressed: boolean;
42
+ /** Source size in decoded bytes (informational only). */
43
+ originalBytes: number;
44
+ /** Final size in decoded bytes (informational only). */
45
+ finalBytes: number;
46
+ }
47
+ /**
48
+ * Compressor strategy. Implementations should be idempotent — re-
49
+ * running compression on an already-small image should be a no-op,
50
+ * returning `compressed: false`.
51
+ */
52
+ export interface ImageCompressor {
53
+ /**
54
+ * Attempt to bring `image` under `maxBytes`. Implementations may
55
+ * downscale, re-encode, or otherwise transform the payload. Return
56
+ * the original image with `compressed: false` if nothing helped.
57
+ *
58
+ * Implementations MUST NOT mutate `image` — return a new ParsedImage
59
+ * with the new base64.
60
+ */
61
+ compress(image: ParsedImage, maxBytes: number): Promise<ImageCompressionResult>;
62
+ }
63
+ /**
64
+ * Override the engine's active compressor. Pass `null` to clear the
65
+ * override and fall back to lazy jimp resolution. Hosts that want
66
+ * deterministic behavior in tests typically call this with a stub.
67
+ */
68
+ export declare function setEngineImageCompressor(c: ImageCompressor | null): void;
69
+ /** Reset state — for tests. */
70
+ export declare function resetEngineImageCompressor(): void;
71
+ /**
72
+ * Try compressing every image in the batch so it fits under the
73
+ * single-image cap. Returns the (possibly transformed) batch plus a
74
+ * summary of what changed. Callers should hand the returned images
75
+ * back into {@link enforceImagePolicy} to confirm; this function
76
+ * doesn't itself decide whether the result is acceptable.
77
+ */
78
+ export declare function tryCompressImages(images: readonly ParsedImage[]): Promise<{
79
+ images: ParsedImage[];
80
+ anyCompressed: boolean;
81
+ }>;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Engine-side image compression — the fallback for hosts that didn't
3
+ * pre-compress.
4
+ *
5
+ * The desktop renderer already compresses via <canvas> + Blob before
6
+ * sending; the TUI and MCP image-returning tools historically didn't.
7
+ * When the {@link enforceImagePolicy} gate would otherwise refuse a
8
+ * turn for "image too large", we try to bring the offender(s) under
9
+ * the limit first by:
10
+ *
11
+ * 1. Decoding the base64 payload.
12
+ * 2. Resizing the longest edge down to {@link IMAGE_TARGETS.targetMaxDimension}.
13
+ * 3. Re-encoding as JPEG at {@link IMAGE_TARGETS.jpegQuality}.
14
+ *
15
+ * jimp is the obvious pick — pure-JS, no native binaries, runs in
16
+ * both Node and Bun, supports PNG / JPEG / WebP input. But shipping
17
+ * it as a hard `core` dep would balloon the published bundle for
18
+ * every consumer. So we resolve it dynamically: hosts that want
19
+ * automatic compression just `npm install jimp`; everyone else gets
20
+ * the existing "image too large" refusal.
21
+ *
22
+ * Hosts can also inject a custom {@link ImageCompressor} via
23
+ * {@link setEngineImageCompressor} — useful for tests, future
24
+ * sharp-based compressors, or arena agents with different size
25
+ * targets.
26
+ *
27
+ * The defaults must stay pure-JS so this module is safe to bundle for
28
+ * Electron preload / browser worker contexts. Anything that needs
29
+ * native code (e.g. sharp) should be injected explicitly.
30
+ */
31
+ import { IMAGE_LIMITS, IMAGE_TARGETS, byteLengthFromBase64 } from "./image-policy.js";
32
+ /**
33
+ * No-op compressor: returns the input unchanged. Used when the host
34
+ * didn't install jimp and didn't inject anything else, so behavior
35
+ * stays the same as before this module existed.
36
+ */
37
+ const NOOP_COMPRESSOR = {
38
+ async compress(image, _maxBytes) {
39
+ const bytes = byteLengthFromBase64(image.base64);
40
+ return {
41
+ image,
42
+ compressed: false,
43
+ originalBytes: bytes,
44
+ finalBytes: bytes,
45
+ };
46
+ },
47
+ };
48
+ let activeCompressor = null;
49
+ /**
50
+ * Override the engine's active compressor. Pass `null` to clear the
51
+ * override and fall back to lazy jimp resolution. Hosts that want
52
+ * deterministic behavior in tests typically call this with a stub.
53
+ */
54
+ export function setEngineImageCompressor(c) {
55
+ activeCompressor = c;
56
+ }
57
+ /** Reset state — for tests. */
58
+ export function resetEngineImageCompressor() {
59
+ activeCompressor = null;
60
+ cachedJimpCompressor = null;
61
+ }
62
+ let cachedJimpCompressor = null;
63
+ /**
64
+ * Resolve a jimp-backed compressor on first call. If jimp isn't
65
+ * installed, cache the no-op and stop retrying.
66
+ *
67
+ * jimp's API: `await Jimp.read(buffer)` → resize / quality / getBuffer.
68
+ * v0.x used CamelCase constants (e.g. `Jimp.MIME_JPEG`); v1 moved to
69
+ * an explicit string mime arg. We accept both by feature-testing.
70
+ */
71
+ async function resolveJimpCompressor() {
72
+ if (cachedJimpCompressor)
73
+ return cachedJimpCompressor;
74
+ try {
75
+ // Dynamic import so consumers without jimp installed don't pay
76
+ // the bundle cost. The default export shape differs across v0/v1,
77
+ // hence the `any` cast. The literal module specifier is a string
78
+ // we resolve at runtime — TS sees no type info for it and would
79
+ // otherwise error TS2307 when jimp isn't on the host's path.
80
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
81
+ const dynImport = (s) =>
82
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
83
+ Function("s", "return import(s)")(s);
84
+ const mod = (await dynImport("jimp"));
85
+ const Jimp = (mod.default ?? mod.Jimp ?? mod);
86
+ cachedJimpCompressor = {
87
+ async compress(image, maxBytes) {
88
+ const original = Buffer.from(image.base64, "base64");
89
+ const originalBytes = original.byteLength;
90
+ if (originalBytes <= maxBytes) {
91
+ return { image, compressed: false, originalBytes, finalBytes: originalBytes };
92
+ }
93
+ let pic;
94
+ try {
95
+ pic = await Jimp.read(original);
96
+ }
97
+ catch {
98
+ // Unknown / corrupt format. Leave it alone; the policy gate
99
+ // will refuse and surface a meaningful message to the user.
100
+ return { image, compressed: false, originalBytes, finalBytes: originalBytes };
101
+ }
102
+ // Scale longest edge down to the target. We may still be over
103
+ // after one pass for very large source images, so loop with
104
+ // decreasing quality / dimension until under cap or we hit the
105
+ // floor.
106
+ const targets = [
107
+ { dim: IMAGE_TARGETS.targetMaxDimension, q: Math.round(IMAGE_TARGETS.jpegQuality * 100) },
108
+ { dim: 1280, q: 75 },
109
+ { dim: 1024, q: 65 },
110
+ { dim: 800, q: 55 },
111
+ ];
112
+ const mime = Jimp.MIME_JPEG ?? "image/jpeg";
113
+ let bestBuf = null;
114
+ for (const t of targets) {
115
+ const w = pic.bitmap?.width ?? t.dim;
116
+ const h = pic.bitmap?.height ?? t.dim;
117
+ const longest = Math.max(w, h);
118
+ // Only scale down — never up an already-small image.
119
+ if (longest > t.dim) {
120
+ if (w >= h) {
121
+ pic.resize(t.dim, Math.round((h / w) * t.dim));
122
+ }
123
+ else {
124
+ pic.resize(Math.round((w / h) * t.dim), t.dim);
125
+ }
126
+ }
127
+ pic.quality(t.q);
128
+ const buf = await pic.getBufferAsync(mime);
129
+ if (buf.byteLength <= maxBytes) {
130
+ bestBuf = buf;
131
+ break;
132
+ }
133
+ bestBuf = buf;
134
+ }
135
+ if (!bestBuf) {
136
+ return { image, compressed: false, originalBytes, finalBytes: originalBytes };
137
+ }
138
+ const finalBytes = bestBuf.byteLength;
139
+ const out = {
140
+ ...image,
141
+ base64: bestBuf.toString("base64"),
142
+ mime: "image/jpeg",
143
+ };
144
+ return {
145
+ image: out,
146
+ compressed: true,
147
+ originalBytes,
148
+ finalBytes,
149
+ };
150
+ },
151
+ };
152
+ }
153
+ catch {
154
+ cachedJimpCompressor = NOOP_COMPRESSOR;
155
+ }
156
+ return cachedJimpCompressor;
157
+ }
158
+ /**
159
+ * Try compressing every image in the batch so it fits under the
160
+ * single-image cap. Returns the (possibly transformed) batch plus a
161
+ * summary of what changed. Callers should hand the returned images
162
+ * back into {@link enforceImagePolicy} to confirm; this function
163
+ * doesn't itself decide whether the result is acceptable.
164
+ */
165
+ export async function tryCompressImages(images) {
166
+ if (images.length === 0)
167
+ return { images: [], anyCompressed: false };
168
+ const compressor = activeCompressor ?? (await resolveJimpCompressor());
169
+ if (compressor === NOOP_COMPRESSOR) {
170
+ return { images: images.slice(), anyCompressed: false };
171
+ }
172
+ const out = [];
173
+ let anyCompressed = false;
174
+ for (const img of images) {
175
+ const bytes = byteLengthFromBase64(img.base64);
176
+ if (bytes <= IMAGE_LIMITS.maxBytesPerImage) {
177
+ out.push(img);
178
+ continue;
179
+ }
180
+ const result = await compressor.compress(img, IMAGE_LIMITS.maxBytesPerImage);
181
+ out.push(result.image);
182
+ if (result.compressed)
183
+ anyCompressed = true;
184
+ }
185
+ return { images: out, anyCompressed };
186
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Engine-side image policy: a single source of truth for what counts as
3
+ * an "OK to ship to the model" attachment.
4
+ *
5
+ * Why this exists
6
+ * ---------------
7
+ * The desktop renderer happily takes a 4 MB screenshot, base64-encodes it,
8
+ * and ships it to the LLM. OpenAI-compatible providers stream the request
9
+ * body; a payload that fat plus a flaky link manifests as
10
+ * `OpenAI API error: Connection error.` with no useful classification,
11
+ * three retries deep. That bug is in our session log
12
+ * `s-mppk3ot5-02d35ca4`: textLen=4,364,933, promptTokens=1,091,232.
13
+ *
14
+ * The right fix is to keep the bytes small *before* they leave the host.
15
+ * But not every entry point has the same compressor available:
16
+ * - desktop renderer has <canvas> + Blob, no native deps
17
+ * - TUI runs in plain Node and has neither
18
+ * - MCP tools / future arena agents will need it too
19
+ *
20
+ * So this module owns:
21
+ * 1. The shared *thresholds* every host honors before sending to the LLM
22
+ * (`IMAGE_LIMITS`).
23
+ * 2. A `byteLengthFromBase64()` helper everyone uses to measure decoded
24
+ * payload size without actually decoding it.
25
+ * 3. An `enforceImagePolicy()` engine gate that runs *after* parse-task
26
+ * and *before* the message hits the provider. When a single image
27
+ * blows the limit, the gate refuses the turn with a friendly error
28
+ * and a hint telling the user what to do. The engine is the last
29
+ * place we can refuse cheaply — the next step is a $0.10 vision call
30
+ * over a flaky socket.
31
+ *
32
+ * Hosts (desktop, TUI) are encouraged to compress upstream so this gate
33
+ * never fires in practice. When it does fire, the user gets a clear
34
+ * message instead of "Connection error. Connection error. Connection
35
+ * error.".
36
+ *
37
+ * Pure + no I/O — safe to use from any caller.
38
+ */
39
+ import type { ParsedImage } from "./parse-task.js";
40
+ /**
41
+ * Hard limits the engine enforces. Soft hints (target sizes) live in
42
+ * `IMAGE_TARGETS` so hosts know what to aim for when they pre-compress.
43
+ *
44
+ * Rationale for each number:
45
+ * - `maxBytesPerImage`: 2 MB decoded. OpenAI's `gpt-4o` accepts up to
46
+ * 20 MB but charges by tile count; 2 MB ≈ a 2048×2048 PNG which is
47
+ * plenty for every UI screenshot. Above this we've measured the
48
+ * "Connection error" failure mode in practice.
49
+ * - `maxBytesPerTurn`: 6 MB total, regardless of count. The four-image
50
+ * attach is the typical worst case from the desktop UI, and 4× the
51
+ * per-image cap would blow past most providers' rate-limited request
52
+ * body sizes.
53
+ * - `maxImagesPerTurn`: 6 (matches the desktop renderer's UI limit, so
54
+ * a wider engine gate is just dead defense).
55
+ */
56
+ export declare const IMAGE_LIMITS: {
57
+ readonly maxBytesPerImage: number;
58
+ readonly maxBytesPerTurn: number;
59
+ readonly maxImagesPerTurn: 6;
60
+ };
61
+ /**
62
+ * Soft targets hosts use when pre-compressing. `targetMaxDimension` is
63
+ * the longest-edge pixel cap that *almost always* keeps a JPEG under
64
+ * `maxBytesPerImage`. Hosts re-encode at `jpegQuality` and only escalate
65
+ * the compression if the result is still over.
66
+ */
67
+ export declare const IMAGE_TARGETS: {
68
+ readonly targetMaxDimension: 2048;
69
+ readonly jpegQuality: 0.82;
70
+ };
71
+ /**
72
+ * Decode-free size estimate of a base64 string.
73
+ *
74
+ * Returns the count of bytes the base64 string would produce *after*
75
+ * decoding. Used everywhere we need to "weigh" an image without paying
76
+ * the cost of `Buffer.from(s, "base64")` allocation.
77
+ *
78
+ * - 4 base64 chars → 3 decoded bytes.
79
+ * - Trailing `=` padding subtracts from the byte count.
80
+ * - Tolerates whitespace and missing padding by working off `length`
81
+ * modulo 4 — the wire format strips whitespace via parse-task, so
82
+ * this only matters when an exotic caller hands us raw textarea text.
83
+ */
84
+ export declare function byteLengthFromBase64(b64: string): number;
85
+ /**
86
+ * Verdict returned by {@link enforceImagePolicy}. `ok: true` means the
87
+ * caller can proceed with the original images unchanged. `ok: false`
88
+ * carries a user-facing message + machine-readable reason so callers can
89
+ * decide whether to render an error card or refuse the turn outright.
90
+ */
91
+ export type ImagePolicyVerdict = {
92
+ ok: true;
93
+ } | {
94
+ ok: false;
95
+ /**
96
+ * Stable string the engine surfaces as the run's terminal reason.
97
+ * Distinct values so a future UI could pick different icons.
98
+ */
99
+ code: "image_too_large" | "images_total_too_large" | "too_many_images";
100
+ /** User-facing message — Chinese-friendly because both UIs are zh. */
101
+ message: string;
102
+ /** Optional fields the UI may use for finer-grained reporting. */
103
+ offender?: {
104
+ name: string;
105
+ mime: string;
106
+ bytes: number;
107
+ };
108
+ totals: {
109
+ imageCount: number;
110
+ totalBytes: number;
111
+ };
112
+ };
113
+ /**
114
+ * Run the per-turn image policy.
115
+ *
116
+ * Three failure modes, all surfaced with a Chinese-language hint that
117
+ * tells the user *what to do next*:
118
+ *
119
+ * 1. Per-image cap exceeded → name the file + the cap, suggest
120
+ * re-saving / cropping. (`code: "image_too_large"`)
121
+ * 2. Per-turn cumulative cap exceeded → total bytes + cap. The user
122
+ * likely attached too many screenshots; suggest removing some.
123
+ * (`code: "images_total_too_large"`)
124
+ * 3. Count cap exceeded → the desktop UI already caps at 6, so this
125
+ * mostly catches future-MCP / TUI batch paths. (`code: "too_many_images"`)
126
+ *
127
+ * The gate is **strict** — no silent truncation. The "Connection error"
128
+ * failure mode this exists to prevent was effectively a silent truncate
129
+ * (bytes lost mid-stream); a friendly refusal is strictly better.
130
+ */
131
+ /**
132
+ * Drop the images that exceed {@link IMAGE_LIMITS.maxBytesPerImage}
133
+ * and return a textual placeholder describing what was removed. The
134
+ * engine uses this to keep a poisoned image out of conversation
135
+ * history while still letting the rest of the turn proceed — Claude
136
+ * Code's "5MB brick session" failure mode is exactly the bug where
137
+ * a too-large image enters history and every subsequent request
138
+ * re-sends it (see docs/research-cc-vs-codex-image-handling.md §A).
139
+ *
140
+ * Returns the filtered image list and, when anything was dropped, a
141
+ * Chinese-language note suitable for prepending to the user text.
142
+ */
143
+ export interface DropOversizedResult {
144
+ /** Images that fit under the per-image cap (in original order). */
145
+ kept: ParsedImage[];
146
+ /** A human-readable note about what was removed, or "" if nothing was. */
147
+ placeholder: string;
148
+ /** Number of images removed. */
149
+ droppedCount: number;
150
+ }
151
+ export declare function dropOversizedImages(images: readonly ParsedImage[]): DropOversizedResult;
152
+ export declare function enforceImagePolicy(images: readonly ParsedImage[]): ImagePolicyVerdict;
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Engine-side image policy: a single source of truth for what counts as
3
+ * an "OK to ship to the model" attachment.
4
+ *
5
+ * Why this exists
6
+ * ---------------
7
+ * The desktop renderer happily takes a 4 MB screenshot, base64-encodes it,
8
+ * and ships it to the LLM. OpenAI-compatible providers stream the request
9
+ * body; a payload that fat plus a flaky link manifests as
10
+ * `OpenAI API error: Connection error.` with no useful classification,
11
+ * three retries deep. That bug is in our session log
12
+ * `s-mppk3ot5-02d35ca4`: textLen=4,364,933, promptTokens=1,091,232.
13
+ *
14
+ * The right fix is to keep the bytes small *before* they leave the host.
15
+ * But not every entry point has the same compressor available:
16
+ * - desktop renderer has <canvas> + Blob, no native deps
17
+ * - TUI runs in plain Node and has neither
18
+ * - MCP tools / future arena agents will need it too
19
+ *
20
+ * So this module owns:
21
+ * 1. The shared *thresholds* every host honors before sending to the LLM
22
+ * (`IMAGE_LIMITS`).
23
+ * 2. A `byteLengthFromBase64()` helper everyone uses to measure decoded
24
+ * payload size without actually decoding it.
25
+ * 3. An `enforceImagePolicy()` engine gate that runs *after* parse-task
26
+ * and *before* the message hits the provider. When a single image
27
+ * blows the limit, the gate refuses the turn with a friendly error
28
+ * and a hint telling the user what to do. The engine is the last
29
+ * place we can refuse cheaply — the next step is a $0.10 vision call
30
+ * over a flaky socket.
31
+ *
32
+ * Hosts (desktop, TUI) are encouraged to compress upstream so this gate
33
+ * never fires in practice. When it does fire, the user gets a clear
34
+ * message instead of "Connection error. Connection error. Connection
35
+ * error.".
36
+ *
37
+ * Pure + no I/O — safe to use from any caller.
38
+ */
39
+ /**
40
+ * Hard limits the engine enforces. Soft hints (target sizes) live in
41
+ * `IMAGE_TARGETS` so hosts know what to aim for when they pre-compress.
42
+ *
43
+ * Rationale for each number:
44
+ * - `maxBytesPerImage`: 2 MB decoded. OpenAI's `gpt-4o` accepts up to
45
+ * 20 MB but charges by tile count; 2 MB ≈ a 2048×2048 PNG which is
46
+ * plenty for every UI screenshot. Above this we've measured the
47
+ * "Connection error" failure mode in practice.
48
+ * - `maxBytesPerTurn`: 6 MB total, regardless of count. The four-image
49
+ * attach is the typical worst case from the desktop UI, and 4× the
50
+ * per-image cap would blow past most providers' rate-limited request
51
+ * body sizes.
52
+ * - `maxImagesPerTurn`: 6 (matches the desktop renderer's UI limit, so
53
+ * a wider engine gate is just dead defense).
54
+ */
55
+ export const IMAGE_LIMITS = {
56
+ maxBytesPerImage: 2 * 1024 * 1024,
57
+ maxBytesPerTurn: 6 * 1024 * 1024,
58
+ maxImagesPerTurn: 6,
59
+ };
60
+ /**
61
+ * Soft targets hosts use when pre-compressing. `targetMaxDimension` is
62
+ * the longest-edge pixel cap that *almost always* keeps a JPEG under
63
+ * `maxBytesPerImage`. Hosts re-encode at `jpegQuality` and only escalate
64
+ * the compression if the result is still over.
65
+ */
66
+ export const IMAGE_TARGETS = {
67
+ targetMaxDimension: 2048,
68
+ jpegQuality: 0.82,
69
+ };
70
+ /**
71
+ * Decode-free size estimate of a base64 string.
72
+ *
73
+ * Returns the count of bytes the base64 string would produce *after*
74
+ * decoding. Used everywhere we need to "weigh" an image without paying
75
+ * the cost of `Buffer.from(s, "base64")` allocation.
76
+ *
77
+ * - 4 base64 chars → 3 decoded bytes.
78
+ * - Trailing `=` padding subtracts from the byte count.
79
+ * - Tolerates whitespace and missing padding by working off `length`
80
+ * modulo 4 — the wire format strips whitespace via parse-task, so
81
+ * this only matters when an exotic caller hands us raw textarea text.
82
+ */
83
+ export function byteLengthFromBase64(b64) {
84
+ if (!b64)
85
+ return 0;
86
+ // Strip whitespace defensively; the parser already does this, but other
87
+ // callers (logging stubs, MCP image blocks) might not.
88
+ const clean = b64.replace(/\s+/g, "");
89
+ const padding = clean.endsWith("==") ? 2 : clean.endsWith("=") ? 1 : 0;
90
+ return Math.floor((clean.length * 3) / 4) - padding;
91
+ }
92
+ function fmtMB(bytes) {
93
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
94
+ }
95
+ export function dropOversizedImages(images) {
96
+ const kept = [];
97
+ const dropped = [];
98
+ for (const img of images) {
99
+ const bytes = byteLengthFromBase64(img.base64);
100
+ if (bytes > IMAGE_LIMITS.maxBytesPerImage) {
101
+ dropped.push({ name: img.name || "(未命名)", bytes });
102
+ }
103
+ else {
104
+ kept.push(img);
105
+ }
106
+ }
107
+ if (dropped.length === 0) {
108
+ return { kept, placeholder: "", droppedCount: 0 };
109
+ }
110
+ const list = dropped
111
+ .map((d) => `「${d.name}」(~${fmtMB(d.bytes)})`)
112
+ .join("、");
113
+ const placeholder = `[已自动跳过 ${dropped.length} 张超大图片:${list};` +
114
+ `单图上限 ${fmtMB(IMAGE_LIMITS.maxBytesPerImage)}。` +
115
+ `图片未进入对话历史,本轮其余内容继续。]`;
116
+ return { kept, placeholder, droppedCount: dropped.length };
117
+ }
118
+ export function enforceImagePolicy(images) {
119
+ if (images.length === 0)
120
+ return { ok: true };
121
+ if (images.length > IMAGE_LIMITS.maxImagesPerTurn) {
122
+ return {
123
+ ok: false,
124
+ code: "too_many_images",
125
+ message: `一次最多附 ${IMAGE_LIMITS.maxImagesPerTurn} 张图片,` +
126
+ `本次有 ${images.length} 张。请删掉一些再发。`,
127
+ totals: {
128
+ imageCount: images.length,
129
+ totalBytes: images.reduce((s, i) => s + byteLengthFromBase64(i.base64), 0),
130
+ },
131
+ };
132
+ }
133
+ let totalBytes = 0;
134
+ for (const img of images) {
135
+ const bytes = byteLengthFromBase64(img.base64);
136
+ if (bytes > IMAGE_LIMITS.maxBytesPerImage) {
137
+ return {
138
+ ok: false,
139
+ code: "image_too_large",
140
+ message: `图片「${img.name || "(未命名)"}」解码后约 ${fmtMB(bytes)},` +
141
+ `超过单图上限 ${fmtMB(IMAGE_LIMITS.maxBytesPerImage)}。` +
142
+ `请先截图压缩或保存为 JPEG(最长边 ≤ ${IMAGE_TARGETS.targetMaxDimension}px)再发。`,
143
+ offender: { name: img.name, mime: img.mime, bytes },
144
+ totals: {
145
+ imageCount: images.length,
146
+ totalBytes: totalBytes + bytes,
147
+ },
148
+ };
149
+ }
150
+ totalBytes += bytes;
151
+ }
152
+ if (totalBytes > IMAGE_LIMITS.maxBytesPerTurn) {
153
+ return {
154
+ ok: false,
155
+ code: "images_total_too_large",
156
+ message: `本次附图合计约 ${fmtMB(totalBytes)},超过每轮上限 ${fmtMB(IMAGE_LIMITS.maxBytesPerTurn)}。` +
157
+ `请删掉一些图片,或先把图片压缩后再发。`,
158
+ totals: {
159
+ imageCount: images.length,
160
+ totalBytes,
161
+ },
162
+ };
163
+ }
164
+ return { ok: true };
165
+ }