@sayknow-cli/coding-agent 0.5.6 → 0.5.8

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/CHANGELOG.md CHANGED
@@ -2,7 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [0.5.6] - 2026-08-28
5
+ ## [0.5.8] - 2026-09-10
6
+
7
+ ## [0.5.7] - 2026-09-10
8
+
9
+ ### Added
10
+
11
+ - Added opt-in `reasoningLanguage: english`. Model reasoning quality on technical work is generally better in English, but that previously meant answering in English too. With the setting on, the system prompt gains a `<reasoning-language>` block asking the agent to reason in English while keeping user-facing answers in the language the user used; correctness, safety, and communication requirements are explicitly unchanged. The default `off` adds nothing to the prompt.
12
+ - Added opt-in `compaction.adaptive.*` settings — `enabled`, `baseThresholdPercent`, `aggression`, `turnWindow`, and `minThresholdPercent` — and wired a per-session call-rate tracker into the auto-compaction threshold decision. Adaptive mode is disabled by default and the existing fixed `compaction.thresholdTokens` / `compaction.thresholdPercent` precedence is preserved. `getGroup("compaction")` flattens dotted keys, so the nested options object is assembled explicitly at the decision site rather than inferred, and a completed compaction resets the tracker in exactly one place.
13
+
14
+ ### Fixed
15
+
16
+ - Restored PDF conversion in standalone binaries (ported from upstream #5433). Compiled releases kept mupdf `--external`, but compiled-Bun module resolution cannot satisfy markit's bare `import("mupdf")`, and mupdf's Emscripten loader cannot find `mupdf-wasm.wasm` inside the bunfs — so every standalone PDF read fell back to raw bytes behind a generic "PDF support requires 'mupdf'" note while the real cause was discarded. mupdf is now bundled: the wasm is embedded via `with { type: "file" }` and routed to the loader through a `locateFile` hook seeded before the first mupdf import, markit-ai's bundler-hostile `require("mupdf")` image-render call is patched to a dynamic import (`patches/markit-ai@0.5.3.patch`), and conversion failures re-run the import so diagnostics carry the actual error. The fetch tool also accepts any non-empty markit conversion instead of discarding short documents (e.g. the 14-character W3C dummy.pdf) into a raw-bytes fallback.
6
17
 
7
18
  ## [0.5.4] - 2026-08-28
8
19
 
@@ -523,6 +523,25 @@ export declare const SETTINGS_SCHEMA: {
523
523
  }];
524
524
  };
525
525
  };
526
+ readonly reasoningLanguage: {
527
+ readonly type: "enum";
528
+ readonly values: readonly ["off", "english"];
529
+ readonly default: "off";
530
+ readonly ui: {
531
+ readonly tab: "appearance";
532
+ readonly label: "Reasoning Language";
533
+ readonly description: "Reason through technical work in English while answering in your own language";
534
+ readonly options: readonly [{
535
+ readonly value: "off";
536
+ readonly label: "Off";
537
+ readonly description: "Do not add reasoning-language guidance";
538
+ }, {
539
+ readonly value: "english";
540
+ readonly label: "English";
541
+ readonly description: "Reason in English and answer in the language you used";
542
+ }];
543
+ };
544
+ };
526
545
  readonly colorBlindMode: {
527
546
  readonly type: "boolean";
528
547
  readonly default: false;
@@ -1958,6 +1977,51 @@ export declare const SETTINGS_SCHEMA: {
1958
1977
  }];
1959
1978
  };
1960
1979
  };
1980
+ readonly "compaction.adaptive.enabled": {
1981
+ readonly type: "boolean";
1982
+ readonly default: false;
1983
+ readonly ui: {
1984
+ readonly tab: "context";
1985
+ readonly label: "Adaptive Compaction";
1986
+ readonly description: "Adjust compaction frequency by context size and call rate";
1987
+ };
1988
+ };
1989
+ readonly "compaction.adaptive.baseThresholdPercent": {
1990
+ readonly type: "number";
1991
+ readonly default: 85;
1992
+ readonly ui: {
1993
+ readonly tab: "context";
1994
+ readonly label: "Adaptive Base Threshold %";
1995
+ readonly description: "Base context percentage used when adaptive compaction is enabled";
1996
+ };
1997
+ };
1998
+ readonly "compaction.adaptive.aggression": {
1999
+ readonly type: "number";
2000
+ readonly default: 0.15;
2001
+ readonly ui: {
2002
+ readonly tab: "context";
2003
+ readonly label: "Adaptive Aggression";
2004
+ readonly description: "How strongly call rate lowers the compaction threshold; use 0 to 1";
2005
+ };
2006
+ };
2007
+ readonly "compaction.adaptive.turnWindow": {
2008
+ readonly type: "number";
2009
+ readonly default: 15;
2010
+ readonly ui: {
2011
+ readonly tab: "context";
2012
+ readonly label: "Call-Rate Window";
2013
+ readonly description: "Minutes of recent calls considered by adaptive compaction";
2014
+ };
2015
+ };
2016
+ readonly "compaction.adaptive.minThresholdPercent": {
2017
+ readonly type: "number";
2018
+ readonly default: 50;
2019
+ readonly ui: {
2020
+ readonly tab: "context";
2021
+ readonly label: "Adaptive Floor %";
2022
+ readonly description: "Lowest context percentage adaptive compaction may lower the threshold to";
2023
+ };
2024
+ };
1961
2025
  readonly "compaction.handoffSaveToDisk": {
1962
2026
  readonly type: "boolean";
1963
2027
  readonly default: false;
@@ -83,6 +83,11 @@ export interface BuildSystemPromptOptions {
83
83
  alwaysApplyRules?: AlwaysApplyRule[];
84
84
  /** Whether secret obfuscation is active. When true, explains the redaction format in the prompt. */
85
85
  secretsEnabled?: boolean;
86
+ /**
87
+ * Reason through technical work in English while answering in the language the
88
+ * user used. Affects prompt guidance only. Default: "off".
89
+ */
90
+ reasoningLanguage?: "off" | "english";
86
91
  /** Pre-loaded workspace tree (skips discovery if provided). May be a Promise to allow early kick-off. */
87
92
  workspaceTree?: WorkspaceTree | Promise<WorkspaceTree>;
88
93
  /**
@@ -0,0 +1 @@
1
+ export declare function ensureMupdfWasmResolution(): void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/coding-agent",
4
- "version": "0.5.6",
4
+ "version": "0.5.8",
5
5
  "description": "Sayknow-CLI CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://sayknow-cli.com",
7
7
  "author": "jaybeyond",
@@ -54,12 +54,12 @@
54
54
  "@agentclientprotocol/sdk": "1.3.0",
55
55
  "@babel/parser": "^7.29.3",
56
56
  "@mozilla/readability": "^0.6.0",
57
- "@sayknow-cli/stats": "0.5.6",
58
- "@sayknow-cli/agent-core": "0.5.6",
59
- "@sayknow-cli/ai": "0.5.6",
60
- "@sayknow-cli/natives": "0.5.6",
61
- "@sayknow-cli/tui": "0.5.6",
62
- "@sayknow-cli/utils": "0.5.6",
57
+ "@sayknow-cli/stats": "0.5.8",
58
+ "@sayknow-cli/agent-core": "0.5.8",
59
+ "@sayknow-cli/ai": "0.5.8",
60
+ "@sayknow-cli/natives": "0.5.8",
61
+ "@sayknow-cli/tui": "0.5.8",
62
+ "@sayknow-cli/utils": "0.5.8",
63
63
  "@puppeteer/browsers": "^2.13.0",
64
64
  "@types/turndown": "5.0.6",
65
65
  "@xterm/headless": "^6.0.0",
@@ -18,7 +18,14 @@ const compiledDefineFlags = ['process.env.PI_COMPILED="true"'];
18
18
  const releaseDefineFlags = [...compiledDefineFlags, 'process.env.SKC_BUILD_CHANNEL="release"'];
19
19
  const devDefineFlags = [...compiledDefineFlags, 'process.env.SKC_BUILD_CHANNEL="dev"'];
20
20
 
21
- export const compiledExternalPackages = ["mupdf"];
21
+ // NOTE: mupdf must NOT be marked --external here. Compiled binaries cannot
22
+ // resolve bare "mupdf" imports (and its Emscripten loader cannot find the
23
+ // wasm asset inside the bunfs), which broke markit PDF conversion in every
24
+ // standalone release (upstream #5433). mupdf is bundled instead: its wasm is
25
+ // embedded via `with { type: "file" }` and routed to the loader through the
26
+ // hook in packages/coding-agent/src/utils/mupdf-wasm.ts, and the bundled
27
+ // `require("mupdf")` inside markit-ai is replaced by the patch in
28
+ // patches/markit-ai@0.5.3.patch.
22
29
 
23
30
  export const releaseEntrypoints = [
24
31
  "./packages/coding-agent/src/cli.ts",
@@ -57,8 +64,6 @@ export function buildReleaseCompileArgs(target: string, outfile: string): string
57
64
  outfile,
58
65
  target,
59
66
  defines: releaseDefineFlags,
60
-
61
- externals: compiledExternalPackages,
62
67
  });
63
68
  }
64
69
 
@@ -68,7 +73,6 @@ export function buildDevCompileArgs(outfile = "dist/skc"): string[] {
68
73
  entrypoints: devEntrypoints,
69
74
  outfile,
70
75
  defines: devDefineFlags,
71
- externals: compiledExternalPackages,
72
76
  });
73
77
  }
74
78
 
@@ -149,25 +149,25 @@ export const BUILTIN_MODEL_PROFILES: readonly ModelProfileDefinition[] = [
149
149
  architect: "anthropic/claude-fable-5:xhigh",
150
150
  }),
151
151
  profile("glm-eco", ["zai"], {
152
- default: "zai/glm-5.2:low",
153
- executor: "zai/glm-5.2:minimal",
154
- planner: "zai/glm-5.2:low",
155
- critic: "zai/glm-5.2:medium",
156
- architect: "zai/glm-5.2:high",
152
+ default: "zai/glm-5.3:low",
153
+ executor: "zai/glm-5.3:minimal",
154
+ planner: "zai/glm-5.3:low",
155
+ critic: "zai/glm-5.3:medium",
156
+ architect: "zai/glm-5.3:high",
157
157
  }),
158
158
  profile("glm-medium", ["zai"], {
159
- default: "zai/glm-5.2:medium",
160
- executor: "zai/glm-5.2:low",
161
- planner: "zai/glm-5.2:medium",
162
- critic: "zai/glm-5.2:high",
163
- architect: "zai/glm-5.2:xhigh",
159
+ default: "zai/glm-5.3:medium",
160
+ executor: "zai/glm-5.3:low",
161
+ planner: "zai/glm-5.3:medium",
162
+ critic: "zai/glm-5.3:high",
163
+ architect: "zai/glm-5.3:xhigh",
164
164
  }),
165
165
  profile("glm-pro", ["zai"], {
166
- default: "zai/glm-5.2:xhigh",
167
- executor: "zai/glm-5.2:medium",
168
- planner: "zai/glm-5.2:high",
169
- critic: "zai/glm-5.2:xhigh",
170
- architect: "zai/glm-5.2:xhigh",
166
+ default: "zai/glm-5.3:xhigh",
167
+ executor: "zai/glm-5.3:medium",
168
+ planner: "zai/glm-5.3:high",
169
+ critic: "zai/glm-5.3:xhigh",
170
+ architect: "zai/glm-5.3:xhigh",
171
171
  }),
172
172
  profile("kimi-coding-plan-eco", ["kimi-code"], {
173
173
  default: "kimi-code/k3:low",
@@ -581,6 +581,25 @@ export const SETTINGS_SCHEMA = {
581
581
  },
582
582
  },
583
583
 
584
+ reasoningLanguage: {
585
+ type: "enum",
586
+ values: ["off", "english"] as const,
587
+ default: "off",
588
+ ui: {
589
+ tab: "appearance",
590
+ label: "Reasoning Language",
591
+ description: "Reason through technical work in English while answering in your own language",
592
+ options: [
593
+ { value: "off", label: "Off", description: "Do not add reasoning-language guidance" },
594
+ {
595
+ value: "english",
596
+ label: "English",
597
+ description: "Reason in English and answer in the language you used",
598
+ },
599
+ ],
600
+ },
601
+ },
602
+
584
603
  colorBlindMode: {
585
604
  type: "boolean",
586
605
  default: false,
@@ -1625,6 +1644,57 @@ export const SETTINGS_SCHEMA = {
1625
1644
  },
1626
1645
  },
1627
1646
 
1647
+ // Adaptive compaction. Opt-in; the fixed thresholds above keep precedence.
1648
+ "compaction.adaptive.enabled": {
1649
+ type: "boolean",
1650
+ default: false,
1651
+ ui: {
1652
+ tab: "context",
1653
+ label: "Adaptive Compaction",
1654
+ description: "Adjust compaction frequency by context size and call rate",
1655
+ },
1656
+ },
1657
+
1658
+ "compaction.adaptive.baseThresholdPercent": {
1659
+ type: "number",
1660
+ default: 85,
1661
+ ui: {
1662
+ tab: "context",
1663
+ label: "Adaptive Base Threshold %",
1664
+ description: "Base context percentage used when adaptive compaction is enabled",
1665
+ },
1666
+ },
1667
+
1668
+ "compaction.adaptive.aggression": {
1669
+ type: "number",
1670
+ default: 0.15,
1671
+ ui: {
1672
+ tab: "context",
1673
+ label: "Adaptive Aggression",
1674
+ description: "How strongly call rate lowers the compaction threshold; use 0 to 1",
1675
+ },
1676
+ },
1677
+
1678
+ "compaction.adaptive.turnWindow": {
1679
+ type: "number",
1680
+ default: 15,
1681
+ ui: {
1682
+ tab: "context",
1683
+ label: "Call-Rate Window",
1684
+ description: "Minutes of recent calls considered by adaptive compaction",
1685
+ },
1686
+ },
1687
+
1688
+ "compaction.adaptive.minThresholdPercent": {
1689
+ type: "number",
1690
+ default: 50,
1691
+ ui: {
1692
+ tab: "context",
1693
+ label: "Adaptive Floor %",
1694
+ description: "Lowest context percentage adaptive compaction may lower the threshold to",
1695
+ },
1696
+ },
1697
+
1628
1698
  "compaction.handoffSaveToDisk": {
1629
1699
  type: "boolean",
1630
1700
  default: false,
@@ -42,6 +42,14 @@ Optimize for correctness first, maintainability second, and brevity third. Prefe
42
42
  - Do not defer actionable work. Underpromise and overdeliver: report only what is done or in progress, never announce remaining work instead of doing it.
43
43
  </communication>
44
44
 
45
+ {{#if reasoningLanguageEnglish}}
46
+ <reasoning-language>
47
+ - Reason through development and technical problem-solving in English.
48
+ - Keep user-facing answers in the language the user requested or used.
49
+ - This changes reasoning language only; it does not relax correctness, safety, or communication requirements.
50
+ </reasoning-language>
51
+ {{/if}}
52
+
45
53
  <completion-contract>
46
54
  - Never present partial work as complete.
47
55
  - Never suppress tests or warnings to make code pass.
@@ -2197,6 +2197,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2197
2197
 
2198
2198
  const repeatToolDescriptions = settings.get("repeatToolDescriptions");
2199
2199
  const eagerTasks = settings.get("task.eager");
2200
+ const reasoningLanguage = settings.get("reasoningLanguage");
2200
2201
  const intentTracingEnabled = resolveIntentTracingEnabled(
2201
2202
  settings.get("tools.intentTracing"),
2202
2203
  options.hasUI ?? false,
@@ -2257,6 +2258,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2257
2258
  toolDiscoveryActive: effectiveDiscoveryMode === "all" || mcpDiscoveryEnabled,
2258
2259
  eagerTasks,
2259
2260
  secretsEnabled,
2261
+ reasoningLanguage,
2260
2262
  workspaceTree: workspaceTreePromise,
2261
2263
  subagent: options.parentTaskPrefix !== undefined,
2262
2264
  });
@@ -42,10 +42,13 @@ import {
42
42
  } from "@sayknow-cli/agent-core";
43
43
  import { normalizeMessagesForProvider } from "@sayknow-cli/agent-core/agent-loop";
44
44
  import {
45
+ type AdaptiveCompactionOptions,
46
+ AdaptiveCompactionTracker,
45
47
  AUTO_HANDOFF_THRESHOLD_FOCUS,
46
48
  CompactionCancelledError,
47
49
  type CompactionPreparation,
48
50
  type CompactionResult,
51
+ type CompactionSettings as CoreCompactionSettings,
49
52
  calculateContextTokens,
50
53
  calculatePromptTokens,
51
54
  collectEntriesForBranchSummary,
@@ -10770,6 +10773,43 @@ export class AgentSession {
10770
10773
  return 0;
10771
10774
  }
10772
10775
 
10776
+ /**
10777
+ * Call-rate tracker backing opt-in adaptive compaction. Always maintained so
10778
+ * enabling the setting mid-session has immediate, honest state; the recorded
10779
+ * rate only reaches a threshold decision while `compaction.adaptive.enabled`
10780
+ * is true.
10781
+ */
10782
+ #adaptiveCompaction = new AdaptiveCompactionTracker();
10783
+
10784
+ #adaptiveCompactionOptions(): AdaptiveCompactionOptions {
10785
+ return {
10786
+ enabled: this.settings.get("compaction.adaptive.enabled"),
10787
+ baseThresholdPercent: this.settings.get("compaction.adaptive.baseThresholdPercent"),
10788
+ aggression: this.settings.get("compaction.adaptive.aggression"),
10789
+ turnWindow: this.settings.get("compaction.adaptive.turnWindow"),
10790
+ minThresholdPercent: this.settings.get("compaction.adaptive.minThresholdPercent"),
10791
+ };
10792
+ }
10793
+
10794
+ /**
10795
+ * Compaction settings for a threshold decision.
10796
+ *
10797
+ * `getGroup("compaction")` flattens `compaction.adaptive.*` into dotted keys,
10798
+ * so the nested options object is assembled explicitly here. The tracker
10799
+ * window follows the configured minutes so a settings change takes effect
10800
+ * without restarting the session.
10801
+ */
10802
+ #compactionSettingsWithAdaptive(): CoreCompactionSettings {
10803
+ const adaptive = this.#adaptiveCompactionOptions();
10804
+ const windowMinutes = Number.isFinite(adaptive.turnWindow) ? Math.max(1, adaptive.turnWindow) : 15;
10805
+ this.#adaptiveCompaction.setWindowMs(windowMinutes * 60_000);
10806
+ return {
10807
+ ...this.settings.getGroup("compaction"),
10808
+ adaptive,
10809
+ adaptiveState: this.#adaptiveCompaction.decisionState(),
10810
+ };
10811
+ }
10812
+
10773
10813
  /**
10774
10814
  * Evidence-gated below-threshold maintenance pruning (Finding 13). Runs
10775
10815
  * #pruneToolOutputs ONCE, below the compaction threshold, only when it is
@@ -11436,13 +11476,15 @@ export class AgentSession {
11436
11476
  }
11437
11477
  return this.#scheduleOverflowRetryContinuation(generation);
11438
11478
  }
11439
- const compactionSettings = this.settings.getGroup("compaction");
11479
+ const compactionSettings = this.#compactionSettingsWithAdaptive();
11440
11480
  if (!compactionSettings.enabled || compactionSettings.strategy === "off") return false;
11441
11481
 
11442
11482
  // Case 2: Threshold - turn succeeded but context is getting large
11443
11483
  // Skip if this was an error (non-overflow errors don't have usage data)
11444
11484
  if (assistantMessage.stopReason === "error") return false;
11445
11485
  let contextTokens = calculateContextTokens(assistantMessage.usage);
11486
+ this.#adaptiveCompaction.recordCall(contextTokens);
11487
+ compactionSettings.adaptiveState = this.#adaptiveCompaction.decisionState();
11446
11488
  // Model maxTokens is a capability ceiling, not a per-turn reservation.
11447
11489
  // Auto maintenance should track actual context fullness.
11448
11490
  const autoCompactionOutputReserveTokens = 0;
@@ -12747,6 +12789,11 @@ export class AgentSession {
12747
12789
 
12748
12790
  /**
12749
12791
  * Internal: Run auto-compaction with events.
12792
+ *
12793
+ * Wraps the run so a completed compaction resets the adaptive call-rate
12794
+ * tracker in exactly one place. The post-compaction context size is reported
12795
+ * as 0 because the next recorded call carries the real provider number; the
12796
+ * tracker only uses this value as a fallback for callers that pass none.
12750
12797
  */
12751
12798
  async #runAutoCompaction(
12752
12799
  reason: "overflow" | "threshold" | "idle",
@@ -12760,6 +12807,24 @@ export class AgentSession {
12760
12807
  beforeTerminalOverflowNoop?: () => void;
12761
12808
  suppressNextPostTurnMaintenanceAfterHandoff?: boolean;
12762
12809
  },
12810
+ ): Promise<AutoCompactionTerminalStatus> {
12811
+ const status = await this.#runAutoCompactionInner(reason, willRetry, deferred, options);
12812
+ if (status.kind === "compacted") this.#adaptiveCompaction.recordCompact(0);
12813
+ return status;
12814
+ }
12815
+
12816
+ async #runAutoCompactionInner(
12817
+ reason: "overflow" | "threshold" | "idle",
12818
+ willRetry: boolean,
12819
+ deferred = false,
12820
+ options?: {
12821
+ continueAfterMaintenance?: boolean;
12822
+ deferHandoffMaintenance?: boolean;
12823
+ force?: boolean;
12824
+ signal?: AbortSignal;
12825
+ beforeTerminalOverflowNoop?: () => void;
12826
+ suppressNextPostTurnMaintenanceAfterHandoff?: boolean;
12827
+ },
12763
12828
  ): Promise<AutoCompactionTerminalStatus> {
12764
12829
  const compactionSettings = this.settings.getGroup("compaction");
12765
12830
  // `force` is the non-disableable emergency floor (F6): it bypasses the user's
@@ -375,6 +375,11 @@ export interface BuildSystemPromptOptions {
375
375
  alwaysApplyRules?: AlwaysApplyRule[];
376
376
  /** Whether secret obfuscation is active. When true, explains the redaction format in the prompt. */
377
377
  secretsEnabled?: boolean;
378
+ /**
379
+ * Reason through technical work in English while answering in the language the
380
+ * user used. Affects prompt guidance only. Default: "off".
381
+ */
382
+ reasoningLanguage?: "off" | "english";
378
383
  /** Pre-loaded workspace tree (skips discovery if provided). May be a Promise to allow early kick-off. */
379
384
  workspaceTree?: WorkspaceTree | Promise<WorkspaceTree>;
380
385
  /**
@@ -439,6 +444,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
439
444
  toolDiscoveryActive = false,
440
445
  eagerTasks = false,
441
446
  secretsEnabled = false,
447
+ reasoningLanguage = "off",
442
448
  workspaceTree: providedWorkspaceTree,
443
449
  subagent = false,
444
450
  } = options;
@@ -613,6 +619,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
613
619
  toolDiscoveryActive: toolDiscoveryActive && hasHiddenToolDiscoveryTool,
614
620
  eagerTasks,
615
621
  secretsEnabled,
622
+ reasoningLanguageEnglish: reasoningLanguage === "english",
616
623
  subagent,
617
624
  };
618
625
  const rendered = prompt.render(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data);
@@ -751,7 +751,9 @@ async function renderUrl(
751
751
  let convertedText: string | null = null;
752
752
  const converted = await convertWithMarkit(binary.buffer, conversionExtension, timeout, signal);
753
753
  if (converted.ok) {
754
- if (converted.content.trim().length > 50) {
754
+ // See the non-empty conversion note near the document path
755
+ // below (upstream #5433): short conversions still beat raw fallback.
756
+ if (converted.content.trim().length > 0) {
755
757
  notes.push("Converted with markit");
756
758
  convertedText = converted.content;
757
759
  } else {
@@ -859,7 +861,10 @@ async function renderUrl(
859
861
  const ext = getExtensionHint(finalUrl, binary.contentDisposition) || extHint;
860
862
  const converted = await convertWithMarkit(binary.buffer, ext, timeout, signal);
861
863
  if (converted.ok) {
862
- if (converted.content.trim().length > 50) {
864
+ // Any non-empty markit conversion is preferable to a raw-bytes
865
+ // fallback (upstream #5433): short documents like the W3C dummy.pdf
866
+ // ("Dummy PDF file", 14 chars) are legitimately converted text.
867
+ if (converted.content.trim().length > 0) {
863
868
  notes.push("Converted with markit");
864
869
  const output = finalizeOutput(converted.content);
865
870
  return {
@@ -1,6 +1,7 @@
1
1
  import { untilAborted } from "@sayknow-cli/utils";
2
2
  import type { Markit, StreamInfo } from "markit-ai";
3
3
  import { ToolAbortError } from "../tools/tool-errors";
4
+ import { ensureMupdfWasmResolution } from "./mupdf-wasm";
4
5
 
5
6
  export interface MarkitConversionResult {
6
7
  content: string;
@@ -18,6 +19,11 @@ let markit: () => Markit | Promise<Markit> = async () => {
18
19
  return promise;
19
20
  };
20
21
 
22
+ // Upstream #5433: mupdf's wasm asset is not reachable at the Emscripten
23
+ // loader's default bunfs path in compiled binaries. Seed the embedded-asset
24
+ // hook before any markit conversion (and therefore before any mupdf import).
25
+ ensureMupdfWasmResolution();
26
+
21
27
  function normalizeExtension(extension: string): string {
22
28
  const trimmed = extension.trim().toLowerCase();
23
29
  if (!trimmed) return ".bin";
@@ -31,6 +37,22 @@ function normalizeError(error: unknown): string {
31
37
  return "Conversion failed";
32
38
  }
33
39
 
40
+ /**
41
+ * markit-ai's PDF converter swallows the real mupdf import failure behind a
42
+ * generic "Install it" message (upstream #5433). Re-run the import here so
43
+ * release diagnostics carry the actual module/asset/initialization error.
44
+ */
45
+ async function describeConversionError(error: unknown): Promise<string> {
46
+ const base = normalizeError(error);
47
+ if (!base.includes("PDF support requires 'mupdf'")) return base;
48
+ try {
49
+ await import("mupdf");
50
+ } catch (cause) {
51
+ return `${base} (mupdf import failed: ${normalizeError(cause)})`;
52
+ }
53
+ return base;
54
+ }
55
+
34
56
  async function runMarkitConversion<T>(task: (markit: Markit) => Promise<T>, signal?: AbortSignal): Promise<T> {
35
57
  try {
36
58
  const instance = await markit();
@@ -62,7 +84,7 @@ export async function convertFileWithMarkit(filePath: string, signal?: AbortSign
62
84
  if (error instanceof ToolAbortError) {
63
85
  throw error;
64
86
  }
65
- return { content: "", ok: false, error: normalizeError(error) };
87
+ return { content: "", ok: false, error: await describeConversionError(error) };
66
88
  }
67
89
  }
68
90
 
@@ -84,6 +106,6 @@ export async function convertBufferWithMarkit(
84
106
  if (error instanceof ToolAbortError) {
85
107
  throw error;
86
108
  }
87
- return { content: "", ok: false, error: normalizeError(error) };
109
+ return { content: "", ok: false, error: await describeConversionError(error) };
88
110
  }
89
111
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * MuPDF wasm asset resolution inside compiled binaries (upstream #5433).
3
+ *
4
+ * `mupdf`'s Emscripten loader resolves `mupdf-wasm.wasm` relative to
5
+ * `import.meta.url` (or `scriptDirectory`). Inside a `bun build --compile`
6
+ * bunfs that path points at the bunfs root where the asset does not exist,
7
+ * so every mupdf import aborted with
8
+ * `ENOENT: ... /$bunfs/root/mupdf-wasm.wasm`.
9
+ *
10
+ * The wasm is embedded via `with { type: "file" }`, which lands it at a
11
+ * hashed bunfs path. MuPDF's top-level factory call reads
12
+ * `globalThis["$libmupdf_wasm_Module"]`, so seeding that config with a
13
+ * `locateFile` hook that returns the embedded asset path makes the loader
14
+ * read the wasm from the bunfs directly — no disk sidecar needed.
15
+ *
16
+ * This must run before the first `import("mupdf")` anywhere in the process.
17
+ */
18
+ import mupdfWasmPath from "../../../../node_modules/mupdf/dist/mupdf-wasm.wasm" with { type: "file" };
19
+
20
+ const MODULE_CONFIG_KEY = "$libmupdf_wasm_Module";
21
+
22
+ export function ensureMupdfWasmResolution(): void {
23
+ const globalScope = globalThis as typeof globalThis & Record<string, unknown>;
24
+ if (globalScope[MODULE_CONFIG_KEY] !== undefined) return;
25
+ globalScope[MODULE_CONFIG_KEY] = { locateFile: () => String(mupdfWasmPath) };
26
+ }