@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.
- package/dist/agent/agent-definition-registry.d.ts +29 -0
- package/dist/agent/agent-definition-registry.js +74 -0
- package/dist/agent/agent-definition.d.ts +37 -0
- package/dist/agent/agent-definition.js +59 -0
- package/dist/arena/arena.js +11 -12
- package/dist/arena/context/context-tools.js +2 -4
- package/dist/arena/context/within-root.d.ts +7 -0
- package/dist/arena/context/within-root.js +15 -0
- package/dist/arena/iterate/phases/argue.js +8 -2
- package/dist/arena/iterate/phases/revise.js +1 -1
- package/dist/arena/iterate/phases/tournament.js +3 -3
- package/dist/arena/phases/adjudication.js +1 -4
- package/dist/arena/phases/build-consensus.js +1 -4
- package/dist/arena/phases/cross-review.js +2 -8
- package/dist/arena/phases/debate-rounds.js +1 -4
- package/dist/arena/phases/participant-research.js +1 -4
- package/dist/arena/phases/planning-detail-expansion.js +8 -10
- package/dist/arena/planner.js +0 -1
- package/dist/arena/providers/docs.d.ts +1 -0
- package/dist/arena/providers/docs.js +5 -2
- package/dist/arena/providers/repo.d.ts +1 -0
- package/dist/arena/providers/repo.js +5 -2
- package/dist/arena/strategies/utils.js +36 -3
- package/dist/arena/transitions.d.ts +7 -2
- package/dist/arena/transitions.js +9 -4
- package/dist/arena/types.d.ts +8 -2
- package/dist/automation/cron-expr.d.ts +31 -0
- package/dist/automation/cron-expr.js +151 -0
- package/dist/automation/index.d.ts +41 -0
- package/dist/automation/index.js +39 -0
- package/dist/automation/runner.d.ts +67 -0
- package/dist/automation/runner.js +56 -0
- package/dist/automation/scheduler.d.ts +156 -0
- package/dist/automation/scheduler.js +546 -0
- package/dist/automation/store.d.ts +39 -0
- package/dist/automation/store.js +119 -0
- package/dist/automation/write-policy.d.ts +34 -0
- package/dist/automation/write-policy.js +113 -0
- package/dist/automation/write-run.d.ts +51 -0
- package/dist/automation/write-run.js +38 -0
- package/dist/capability-control/index.d.ts +5 -0
- package/dist/capability-control/index.js +3 -0
- package/dist/capability-control/overlay.d.ts +49 -0
- package/dist/capability-control/overlay.js +89 -0
- package/dist/capability-control/project.d.ts +65 -0
- package/dist/capability-control/project.js +147 -0
- package/dist/capability-control/service.d.ts +70 -0
- package/dist/capability-control/service.js +147 -0
- package/dist/capability-control/types.d.ts +62 -0
- package/dist/capability-control/types.js +17 -0
- package/dist/cli/agent-server-stdio.d.ts +12 -1
- package/dist/cli/agent-server-stdio.js +118 -18
- package/dist/cli/agent-server-tcp.d.ts +20 -0
- package/dist/cli/agent-server-tcp.js +120 -0
- package/dist/cli/graceful-shutdown.d.ts +27 -0
- package/dist/cli/graceful-shutdown.js +30 -0
- package/dist/context/compaction.js +23 -15
- package/dist/context/manager.d.ts +0 -20
- package/dist/context/manager.js +0 -37
- package/dist/cron/cron-runtime.d.ts +2 -0
- package/dist/cron/cron-runtime.js +2 -0
- package/dist/cron/cron-store.d.ts +2 -0
- package/dist/cron/cron-store.js +2 -0
- package/dist/cron/scheduler.d.ts +5 -32
- package/dist/cron/scheduler.js +5 -113
- package/dist/data/openrouter-sync.js +1 -1
- package/dist/engine/engine.d.ts +256 -16
- package/dist/engine/engine.js +856 -200
- package/dist/engine/goal.d.ts +42 -0
- package/dist/engine/goal.js +52 -0
- package/dist/engine/image-compression.d.ts +81 -0
- package/dist/engine/image-compression.js +186 -0
- package/dist/engine/image-policy.d.ts +152 -0
- package/dist/engine/image-policy.js +165 -0
- package/dist/engine/reactive-threshold.d.ts +13 -0
- package/dist/engine/reactive-threshold.js +17 -0
- package/dist/engine/runtime.js +7 -0
- package/dist/engine/session-title.d.ts +13 -0
- package/dist/engine/session-title.js +34 -0
- package/dist/engine/turn-loop.d.ts +29 -8
- package/dist/engine/turn-loop.js +498 -316
- package/dist/git/parse-log.d.ts +13 -0
- package/dist/git/parse-log.js +21 -0
- package/dist/git/utils.d.ts +8 -6
- package/dist/git/utils.js +57 -26
- package/dist/git/worktree.js +33 -18
- package/dist/hooks/events.d.ts +23 -1
- package/dist/hooks/goal-stop-hook.d.ts +30 -0
- package/dist/hooks/goal-stop-hook.js +102 -0
- package/dist/hooks/hook-output.d.ts +33 -0
- package/dist/hooks/hook-output.js +79 -0
- package/dist/hooks/registry.d.ts +7 -0
- package/dist/hooks/registry.js +38 -1
- package/dist/hooks/shell-runner.d.ts +2 -0
- package/dist/hooks/shell-runner.js +43 -2
- package/dist/index.d.ts +32 -7
- package/dist/index.js +30 -3
- package/dist/llm/capabilities/reasoning-control.d.ts +24 -0
- package/dist/llm/capabilities/reasoning-control.js +38 -0
- package/dist/llm/capabilities/rules.js +33 -3
- package/dist/llm/capabilities/types.d.ts +22 -2
- package/dist/llm/clamp-max-tokens.d.ts +11 -0
- package/dist/llm/clamp-max-tokens.js +17 -0
- package/dist/llm/client-base.d.ts +41 -4
- package/dist/llm/client-base.js +67 -13
- package/dist/llm/client-factory.d.ts +3 -3
- package/dist/llm/client-factory.js +2 -2
- package/dist/llm/model-pool.d.ts +15 -11
- package/dist/llm/model-pool.js +23 -20
- package/dist/llm/provider-catalog.d.ts +2 -2
- package/dist/llm/providers/anthropic.d.ts +32 -2
- package/dist/llm/providers/anthropic.js +147 -8
- package/dist/llm/providers/openai.d.ts +11 -2
- package/dist/llm/providers/openai.js +253 -52
- package/dist/llm/reasoning-setting.d.ts +51 -0
- package/dist/llm/reasoning-setting.js +32 -0
- package/dist/llm/stop-reason.d.ts +13 -0
- package/dist/llm/stop-reason.js +21 -0
- package/dist/llm/strip-vision.d.ts +32 -0
- package/dist/llm/strip-vision.js +94 -0
- package/dist/llm/types.d.ts +5 -6
- package/dist/logging/logger.js +18 -4
- package/dist/logging/sanitize-messages.d.ts +10 -0
- package/dist/logging/sanitize-messages.js +90 -0
- package/dist/lsp/client.js +28 -8
- package/dist/lsp/manager.js +2 -1
- package/dist/lsp/root-path.d.ts +9 -0
- package/dist/lsp/root-path.js +12 -0
- package/dist/onboarding.js +18 -11
- package/dist/plugins/gitOps.d.ts +34 -0
- package/dist/plugins/gitOps.js +59 -3
- package/dist/plugins/installer/codex/convertAgents.d.ts +7 -0
- package/dist/plugins/installer/codex/convertAgents.js +55 -0
- package/dist/plugins/installer/codex/convertMcp.d.ts +8 -0
- package/dist/plugins/installer/codex/convertMcp.js +45 -0
- package/dist/plugins/installer/codex/convertSkills.d.ts +12 -0
- package/dist/plugins/installer/codex/convertSkills.js +33 -0
- package/dist/plugins/installer/detectFormat.d.ts +2 -0
- package/dist/plugins/installer/detectFormat.js +6 -0
- package/dist/plugins/installer/install.d.ts +7 -0
- package/dist/plugins/installer/install.js +99 -0
- package/dist/plugins/installer/installFromSource.d.ts +13 -0
- package/dist/plugins/installer/installFromSource.js +45 -0
- package/dist/plugins/installer/list.d.ts +9 -0
- package/dist/plugins/installer/list.js +30 -0
- package/dist/plugins/installer/loadPluginAgents.d.ts +3 -0
- package/dist/plugins/installer/loadPluginAgents.js +23 -0
- package/dist/plugins/installer/loadPluginMcp.d.ts +7 -0
- package/dist/plugins/installer/loadPluginMcp.js +74 -0
- package/dist/plugins/installer/parseSource.d.ts +20 -0
- package/dist/plugins/installer/parseSource.js +74 -0
- package/dist/plugins/installer/paths.d.ts +5 -0
- package/dist/plugins/installer/paths.js +25 -0
- package/dist/plugins/installer/types.d.ts +49 -0
- package/dist/plugins/installer/types.js +27 -0
- package/dist/plugins/installer/uninstall.d.ts +2 -0
- package/dist/plugins/installer/uninstall.js +14 -0
- package/dist/plugins/installer/update.d.ts +14 -0
- package/dist/plugins/installer/update.js +65 -0
- package/dist/plugins/loadPluginHooks.d.ts +8 -1
- package/dist/plugins/loadPluginHooks.js +11 -1
- package/dist/plugins/marketplaceManager.d.ts +8 -1
- package/dist/plugins/marketplaceManager.js +38 -4
- package/dist/plugins/pluginCommandHook.js +34 -0
- package/dist/plugins/pluginInstaller.d.ts +21 -0
- package/dist/plugins/pluginInstaller.js +128 -29
- package/dist/plugins/schemas.js +40 -6
- package/dist/plugins/types.d.ts +4 -0
- package/dist/preset/index.js +16 -0
- package/dist/prompt/composer.d.ts +10 -0
- package/dist/prompt/composer.js +29 -4
- package/dist/prompt/sections/orchestration.md +8 -0
- package/dist/protocol/chat-session-manager.d.ts +8 -1
- package/dist/protocol/chat-session-manager.js +10 -0
- package/dist/protocol/chat-session.d.ts +17 -0
- package/dist/protocol/chat-session.js +28 -0
- package/dist/protocol/redact.d.ts +50 -0
- package/dist/protocol/redact.js +71 -0
- package/dist/protocol/server.d.ts +41 -2
- package/dist/protocol/server.js +231 -33
- package/dist/protocol/tcp-transport.d.ts +45 -0
- package/dist/protocol/tcp-transport.js +74 -0
- package/dist/protocol/types.d.ts +23 -0
- package/dist/protocol/types.js +6 -0
- package/dist/remote/bridge.d.ts +11 -2
- package/dist/remote/bridge.js +61 -33
- package/dist/run/ArtifactTracker.js +5 -4
- package/dist/run/EngineRunner.d.ts +27 -1
- package/dist/run/EngineRunner.js +46 -8
- package/dist/run/FileRunStore.js +32 -8
- package/dist/run/RunLock.d.ts +28 -2
- package/dist/run/RunLock.js +49 -7
- package/dist/run/RunManager.js +51 -3
- package/dist/run/RunQueue.d.ts +1 -0
- package/dist/run/RunQueue.js +8 -2
- package/dist/run/factory.d.ts +7 -0
- package/dist/run/factory.js +1 -0
- package/dist/run/index.d.ts +2 -2
- package/dist/run/index.js +1 -1
- package/dist/run/redirect-target.d.ts +7 -0
- package/dist/run/redirect-target.js +13 -0
- package/dist/run/types.js +1 -1
- package/dist/services/browser-open.d.ts +13 -0
- package/dist/services/browser-open.js +16 -0
- package/dist/services/dream-consolidation.d.ts +57 -0
- package/dist/services/dream-consolidation.js +151 -0
- package/dist/services/memory-orchestrator.js +18 -0
- package/dist/services/notifier.d.ts +8 -0
- package/dist/services/notifier.js +39 -25
- package/dist/services/oauth.js +5 -7
- package/dist/services/session-memory-sort.d.ts +8 -0
- package/dist/services/session-memory-sort.js +9 -0
- package/dist/services/session-memory.js +8 -9
- package/dist/session/file-history.js +10 -3
- package/dist/session/session-manager.d.ts +23 -1
- package/dist/session/session-manager.js +73 -2
- package/dist/settings/disk-defaults.d.ts +35 -0
- package/dist/settings/disk-defaults.js +24 -0
- package/dist/settings/manager.d.ts +45 -1
- package/dist/settings/manager.js +121 -17
- package/dist/settings/manager.test.d.ts +1 -0
- package/dist/settings/manager.test.js +73 -0
- package/dist/settings/personalization.d.ts +19 -0
- package/dist/settings/personalization.js +7 -0
- package/dist/settings/schema.d.ts +1163 -126
- package/dist/settings/schema.js +126 -10
- package/dist/skills/scanner.d.ts +13 -6
- package/dist/skills/scanner.js +23 -4
- package/dist/tool-system/builtin/add-marketplace.d.ts +12 -0
- package/dist/tool-system/builtin/add-marketplace.js +76 -0
- package/dist/tool-system/builtin/agent-registry.d.ts +15 -0
- package/dist/tool-system/builtin/agent-registry.js +14 -4
- package/dist/tool-system/builtin/agent.d.ts +55 -0
- package/dist/tool-system/builtin/agent.js +165 -17
- package/dist/tool-system/builtin/apply-patch/index.js +22 -3
- package/dist/tool-system/builtin/arena.js +6 -7
- package/dist/tool-system/builtin/complete-goal.d.ts +25 -0
- package/dist/tool-system/builtin/complete-goal.js +45 -0
- package/dist/tool-system/builtin/cron.js +53 -9
- package/dist/tool-system/builtin/edit.d.ts +2 -1
- package/dist/tool-system/builtin/edit.js +6 -1
- package/dist/tool-system/builtin/generate-image.d.ts +25 -0
- package/dist/tool-system/builtin/generate-image.js +146 -0
- package/dist/tool-system/builtin/glob.js +7 -0
- package/dist/tool-system/builtin/grep.js +6 -0
- package/dist/tool-system/builtin/index.d.ts +18 -1
- package/dist/tool-system/builtin/index.js +62 -1
- package/dist/tool-system/builtin/notebook-edit.d.ts +2 -1
- package/dist/tool-system/builtin/notebook-edit.js +8 -1
- package/dist/tool-system/builtin/read.d.ts +2 -1
- package/dist/tool-system/builtin/read.js +9 -1
- package/dist/tool-system/builtin/skill.js +23 -5
- package/dist/tool-system/builtin/sleep.js +9 -3
- package/dist/tool-system/builtin/task.js +4 -1
- package/dist/tool-system/builtin/update-automation-memory.d.ts +24 -0
- package/dist/tool-system/builtin/update-automation-memory.js +60 -0
- package/dist/tool-system/builtin/view-image.d.ts +18 -0
- package/dist/tool-system/builtin/view-image.js +96 -0
- package/dist/tool-system/builtin/web-search.d.ts +4 -1
- package/dist/tool-system/builtin/web-search.js +36 -3
- package/dist/tool-system/builtin/write.d.ts +2 -1
- package/dist/tool-system/builtin/write.js +7 -1
- package/dist/tool-system/context.d.ts +43 -0
- package/dist/tool-system/executor.d.ts +0 -1
- package/dist/tool-system/executor.js +34 -40
- package/dist/tool-system/investigation-guard.d.ts +4 -1
- package/dist/tool-system/investigation-guard.js +19 -3
- package/dist/tool-system/mcp-manager.d.ts +36 -12
- package/dist/tool-system/mcp-manager.js +173 -10
- package/dist/tool-system/path-policy.d.ts +94 -0
- package/dist/tool-system/path-policy.js +279 -0
- package/dist/tool-system/permission.js +43 -50
- package/dist/tool-system/plan-mode-allowlist.d.ts +21 -0
- package/dist/tool-system/plan-mode-allowlist.js +43 -0
- package/dist/tool-system/registry.js +12 -1
- package/dist/tool-system/validation.d.ts +10 -0
- package/dist/tool-system/validation.js +16 -3
- package/dist/types.d.ts +90 -15
- package/dist/utils/format.js +4 -2
- package/dist/utils/lockfile.js +9 -2
- package/dist/utils/theme.d.ts +6 -0
- package/dist/utils/theme.js +4 -2
- 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
|
+
}
|