mixdog 0.9.96 → 0.9.99

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 (193) hide show
  1. package/README.md +0 -17
  2. package/package.json +16 -8
  3. package/scripts/build-tui.mjs +6 -6
  4. package/scripts/{channel-daemon-stub.mjs → daemon-stub.mjs} +18 -12
  5. package/src/defaults/skills/setup/SKILL.md +1 -1
  6. package/src/lib/keychain-cjs.cjs +1 -1
  7. package/src/lib/mixdog-debug.cjs +2 -3
  8. package/src/rules/agent/30-explorer.md +43 -32
  9. package/src/rules/lead/01-general.md +1 -2
  10. package/src/rules/lead/lead-tool.md +0 -3
  11. package/src/rules/shared/01-tool.md +11 -10
  12. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -13
  13. package/src/runtime/agent/orchestrator/agent-trace.mjs +19 -5
  14. package/src/runtime/agent/orchestrator/mcp/client.mjs +81 -33
  15. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +7 -0
  16. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +30 -3
  17. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +5 -3
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +3 -6
  19. package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +23 -1
  20. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -2
  21. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -6
  22. package/src/runtime/agent/orchestrator/session/compact.mjs +1 -1
  23. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +22 -4
  24. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +20 -2
  25. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +4 -3
  26. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +42 -26
  27. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -3
  28. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +44 -6
  29. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +12 -6
  30. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -1
  31. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +38 -2
  32. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +3 -6
  33. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +10 -3
  34. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
  35. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +59 -42
  36. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -2
  37. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +12 -22
  38. package/src/runtime/agent/orchestrator/tools/builtin/binary-file.mjs +92 -19
  39. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +2 -0
  41. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +103 -12
  42. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +4 -4
  43. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +80 -30
  44. package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +10 -30
  45. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +41 -22
  46. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +6 -2
  47. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +55 -11
  48. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +12 -1
  49. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +55 -18
  50. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -0
  51. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +1 -2
  52. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +2 -2
  53. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +1 -1
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +94 -105
  55. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +2 -1
  56. package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
  57. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +7 -1
  58. package/src/runtime/attachments/pdf-extract.mjs +83 -0
  59. package/src/runtime/attachments/store.mjs +515 -0
  60. package/src/runtime/attachments/store.test.mjs +188 -0
  61. package/src/runtime/channels/lib/config.mjs +33 -33
  62. package/src/runtime/channels/lib/inbound-handler.mjs +7 -7
  63. package/src/runtime/channels/lib/interaction-handlers.mjs +4 -194
  64. package/src/runtime/channels/lib/memory-client.mjs +31 -157
  65. package/src/runtime/channels/lib/output-forwarder.mjs +16 -16
  66. package/src/runtime/channels/lib/owned-runtime.mjs +60 -60
  67. package/src/runtime/channels/lib/owner-heartbeat.mjs +1 -1
  68. package/src/runtime/channels/lib/{backend-dispatch.mjs → provider-dispatch.mjs} +6 -6
  69. package/src/runtime/channels/lib/runtime-paths.mjs +5 -34
  70. package/src/runtime/channels/lib/status-snapshot.mjs +3 -3
  71. package/src/runtime/channels/lib/tool-dispatch.mjs +4 -4
  72. package/src/runtime/channels/lib/worker-bootstrap.mjs +1 -1
  73. package/src/runtime/channels/lib/worker-ipc.mjs +4 -70
  74. package/src/runtime/channels/lib/worker-main.mjs +39 -58
  75. package/src/runtime/channels/{backends → providers}/discord-access.mjs +1 -1
  76. package/src/runtime/channels/{backends → providers}/discord-gateway.mjs +1 -1
  77. package/src/runtime/channels/{backends → providers}/discord.mjs +3 -3
  78. package/src/runtime/channels/{backends → providers}/telegram.mjs +12 -12
  79. package/src/runtime/channels/tool-defs.mjs +1 -1
  80. package/src/runtime/memory/index.mjs +72 -211
  81. package/src/runtime/memory/lib/agent-ipc.mjs +9 -8
  82. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +2 -2
  83. package/src/runtime/memory/lib/http-router.mjs +1 -6
  84. package/src/runtime/memory/lib/http-wire.mjs +39 -4
  85. package/src/runtime/memory/lib/memory-config-flags.mjs +1 -1
  86. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +4 -1
  87. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +5 -0
  88. package/src/runtime/memory/lib/memory-log.mjs +1 -1
  89. package/src/runtime/memory/lib/memory-process-lock.mjs +5 -60
  90. package/src/runtime/memory/lib/memory.mjs +1 -1
  91. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  92. package/src/runtime/search/index.mjs +2 -2
  93. package/src/runtime/shared/abort-race.mjs +70 -0
  94. package/src/runtime/shared/child-spawn-gate.mjs +101 -63
  95. package/src/runtime/shared/config.mjs +11 -24
  96. package/src/runtime/shared/json-metrics.mjs +94 -0
  97. package/src/runtime/shared/llm/index.mjs +1 -1
  98. package/src/runtime/shared/owner-fair-gate.mjs +134 -0
  99. package/src/runtime/shared/resource-admission.mjs +6 -5
  100. package/src/runtime/shared/tool-execution-owner.mjs +12 -0
  101. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  102. package/src/runtime/shared/tool-surface.mjs +91 -22
  103. package/src/runtime/shared/tool-workload-gates.mjs +29 -0
  104. package/src/runtime/shared/turn-snapshot.mjs +30 -2
  105. package/src/runtime/shared/turn-worktree-snapshot.mjs +19 -0
  106. package/src/session-runtime/channel-config-api.mjs +5 -5
  107. package/src/session-runtime/config-lifecycle.mjs +27 -27
  108. package/src/session-runtime/lifecycle-api.mjs +22 -3
  109. package/src/session-runtime/memory-runtime-prewarm.mjs +15 -0
  110. package/src/session-runtime/prewarm.mjs +1 -1
  111. package/src/session-runtime/provider-models.mjs +12 -2
  112. package/src/session-runtime/quick-model-rows.mjs +38 -7
  113. package/src/session-runtime/quick-search-models.mjs +15 -17
  114. package/src/session-runtime/remote-control.mjs +6 -7
  115. package/src/session-runtime/runtime-core.mjs +61 -32
  116. package/src/session-runtime/runtime-tunables.mjs +9 -6
  117. package/src/session-runtime/self-update.mjs +1 -1
  118. package/src/session-runtime/session-lifecycle.mjs +36 -15
  119. package/src/session-runtime/session-title.mjs +28 -4
  120. package/src/session-runtime/session-turn-api.mjs +55 -15
  121. package/src/session-runtime/settings-api.mjs +7 -7
  122. package/src/standalone/agent-dispatch-broker.mjs +1 -1
  123. package/src/standalone/channel-admin.mjs +42 -53
  124. package/src/standalone/{channel-daemon-client.mjs → channel-client.mjs} +16 -18
  125. package/src/standalone/{channel-daemon-transport.mjs → channel-transport.mjs} +73 -58
  126. package/src/standalone/channel-worker.mjs +124 -647
  127. package/src/standalone/{backend-daemon.mjs → daemon.mjs} +190 -132
  128. package/src/standalone/memory-runtime-proxy.mjs +58 -1
  129. package/src/standalone/{engine-daemon-client.mjs → session-client.mjs} +151 -86
  130. package/src/standalone/session-protocol.mjs +9 -4
  131. package/src/standalone/session-runtime-pool.mjs +393 -0
  132. package/src/standalone/session-runtime-record.mjs +20 -0
  133. package/src/standalone/session-runtime-worker.mjs +203 -0
  134. package/src/standalone/{engine-daemon-service.mjs → session-service.mjs} +192 -157
  135. package/src/standalone/{engine-daemon-transport.mjs → session-transport.mjs} +142 -63
  136. package/src/standalone/session-wire.mjs +55 -0
  137. package/src/tui/App.jsx +4 -4
  138. package/src/tui/app/app-format.mjs +1 -1
  139. package/src/tui/app/channel-pickers.mjs +41 -41
  140. package/src/tui/app/input-parsers.mjs +2 -2
  141. package/src/tui/app/onboarding-steps.mjs +2 -2
  142. package/src/tui/app/project-picker.mjs +8 -8
  143. package/src/tui/app/prompt-submit.mjs +15 -17
  144. package/src/tui/app/settings-picker.mjs +25 -25
  145. package/src/tui/app/use-prompt-handlers.mjs +7 -3
  146. package/src/tui/app/use-prompt-queue-history.mjs +1 -1
  147. package/src/tui/app/use-transcript-scroll.mjs +1 -1
  148. package/src/tui/components/ToolExecution.jsx +1 -1
  149. package/src/tui/components/prompt-input/restore-policy.mjs +1 -1
  150. package/src/tui/components/tool-execution/surface-detail.mjs +1 -1
  151. package/src/tui/dist/index.mjs +403 -170
  152. package/src/tui/hooks/useSession.mjs +16 -0
  153. package/src/tui/index.jsx +11 -11
  154. package/src/tui/paste-attachments.mjs +13 -7
  155. package/src/tui/prompt-history-store.mjs +52 -4
  156. package/src/tui/{engine → session}/agent-envelope.mjs +3 -3
  157. package/src/tui/{engine → session}/agent-job-feed.mjs +9 -5
  158. package/src/tui/{engine → session}/boot-profile.mjs +2 -2
  159. package/src/tui/{engine → session}/context-state.mjs +3 -3
  160. package/src/tui/{engine → session}/labels.mjs +2 -2
  161. package/src/tui/{engine → session}/live-share.mjs +28 -14
  162. package/src/tui/{engine → session}/notice-text.mjs +2 -2
  163. package/src/tui/{engine → session}/notification-plan.mjs +3 -3
  164. package/src/tui/session/oauth-flows.mjs +140 -0
  165. package/src/tui/{engine → session}/prompt-history.mjs +3 -3
  166. package/src/tui/{engine → session}/queue-helpers.mjs +8 -8
  167. package/src/tui/{engine → session}/render-timing.mjs +2 -2
  168. package/src/tui/{engine → session}/session-api-ext.mjs +42 -21
  169. package/src/tui/{engine → session}/session-api.mjs +17 -61
  170. package/src/tui/{engine → session}/session-flow.mjs +5 -3
  171. package/src/tui/{engine → session}/session-stats.mjs +3 -3
  172. package/src/tui/{engine → session}/tool-approval.mjs +3 -3
  173. package/src/tui/{engine → session}/tool-call-fields.mjs +2 -2
  174. package/src/tui/{engine → session}/tool-card-results.mjs +4 -4
  175. package/src/tui/{engine → session}/tool-result-status.mjs +5 -5
  176. package/src/tui/{engine → session}/tool-result-text.mjs +3 -3
  177. package/src/tui/{engine → session}/transcript-spill.mjs +34 -34
  178. package/src/tui/{engine → session}/turn.mjs +29 -107
  179. package/src/tui/{engine-local-session.mjs → session-local.mjs} +72 -78
  180. package/src/tui/session.mjs +22 -0
  181. package/src/ui/statusline-segments.mjs +1 -1
  182. package/src/ui/statusline.mjs +1 -1
  183. package/vendor/ink/build/components/App.js +10 -1
  184. package/src/session-runtime/memory-daemon-probe.mjs +0 -61
  185. package/src/standalone/engine-daemon-protocol.mjs +0 -32
  186. package/src/tui/engine/turn-watchdog.mjs +0 -92
  187. package/src/tui/engine.mjs +0 -22
  188. package/src/tui/hooks/useEngine.mjs +0 -16
  189. /package/src/runtime/agent/orchestrator/session/compact/{engine.mjs → runner.mjs} +0 -0
  190. /package/src/runtime/channels/{backends → providers}/discord-attachments.mjs +0 -0
  191. /package/src/tui/{engine → session}/agent-response-tail.mjs +0 -0
  192. /package/src/tui/{engine → session}/frame-batched-store.mjs +0 -0
  193. /package/src/tui/{engine → session}/tui-steering-persist.mjs +0 -0
@@ -1,7 +1,8 @@
1
1
  // Symbol search / callers / callees / references / impact query layer over a
2
2
  // built graph. Pure over {graph,cwd,args}; owns no cache state. Extracted
3
3
  // verbatim from code-graph.mjs.
4
- import { readFileSync } from 'node:fs';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { codeGraphSourceIoAdmission } from '../../../../shared/tool-workload-gates.mjs';
5
6
  import { _isJsLike } from './lang-predicates.mjs';
6
7
  import { _maskNonCodeText } from './text-mask.mjs';
7
8
  import {
@@ -30,7 +31,7 @@ import {
30
31
  _keywordMatchesSymbolName,
31
32
  } from './keyword-match.mjs';
32
33
 
33
- export { _formatRelated, _formatImpact, _findSymbolAcrossGraph, _resolveReferenceLanguageNode, _formatCallerReferences, _formatTransitiveCallers } from './search-references.mjs';
34
+ export { _formatRelated, _formatImpact, _impactSourceNodes, _findSymbolAcrossGraph, _resolveReferenceLanguageNode, _formatCallerReferences, _formatTransitiveCallers } from './search-references.mjs';
34
35
  export function _extractCallees(graph, declHit, _cwd, { cap = 200, callerSymbol = null, language = null } = {}) {
35
36
  if (!declHit || !_CALLEES_BRACE_LANGS.has(declHit.lang)) return [];
36
37
  const declNode = graph.nodes.get(declHit.rel);
@@ -253,28 +254,64 @@ export function _formatCalleeRow(row) {
253
254
  const enclosing = row.enclosing ? `(in ${row.enclosing})` : '(in ?)';
254
255
  return `${row.name}\t${callsite}\t${decl}\t${enclosing}`;
255
256
  }
256
- export async function _prewarmReferenceSourceText(graph, symbol, language) {
257
- const candidateNodes = _lookupCandidateNodes(graph, symbol, language);
258
- // Return the resolved candidate set so the immediately-following
259
- // _cheapReferenceSearch (references/callers dispatch) can reuse it instead
260
- // of recomputing _lookupCandidateNodes for the same (symbol, language) —
261
- // which on a token-index miss is a full-graph scan run twice per symbol.
262
- if (!candidateNodes.length) return candidateNodes;
257
+ const CODE_GRAPH_SOURCE_READ_CONCURRENCY = Math.max(
258
+ 1,
259
+ Math.min(32, Math.floor(Number(process.env.MIXDOG_CODE_GRAPH_SOURCE_READ_CONCURRENCY) || 8)),
260
+ );
261
+
262
+ export async function _prewarmSourceTextNodes(graph, nodes, {
263
+ concurrency = CODE_GRAPH_SOURCE_READ_CONCURRENCY,
264
+ readFileImpl = readFile,
265
+ signal = null,
266
+ ownerKey = null,
267
+ } = {}) {
268
+ const sourceNodes = [];
269
+ const seen = new Set();
270
+ for (const node of Array.isArray(nodes) ? nodes : []) {
271
+ if (!node?.rel || !node?.abs || seen.has(node.rel)) continue;
272
+ seen.add(node.rel);
273
+ sourceNodes.push(node);
274
+ }
263
275
  const uncached = [];
264
- for (const node of candidateNodes) {
276
+ for (const node of sourceNodes) {
265
277
  const cached = graph._sourceTextCache?.get(node.rel);
266
278
  if (!cached || cached.fingerprint !== (node.fingerprint || '')) {
267
279
  uncached.push(node);
268
280
  }
269
281
  }
270
- if (uncached.length === 0) return candidateNodes;
271
- const { readFile } = await import('fs/promises');
272
- await Promise.all(uncached.map(async (node) => {
273
- try {
274
- const text = await readFile(node.abs, 'utf8');
275
- graph._sourceTextCache?.set(node.rel, { fingerprint: node.fingerprint || '', text });
276
- } catch { /* skip unreadable file */ }
277
- }));
282
+ let next = 0;
283
+ const worker = async () => {
284
+ while (!signal?.aborted) {
285
+ const index = next++;
286
+ if (index >= uncached.length) return;
287
+ const node = uncached[index];
288
+ try {
289
+ const text = await codeGraphSourceIoAdmission.run(
290
+ ownerKey,
291
+ () => readFileImpl(node.abs, 'utf8'),
292
+ { signal },
293
+ );
294
+ graph._sourceTextCache?.set(node.rel, { fingerprint: node.fingerprint || '', text });
295
+ } catch { /* skip unreadable/aborted file */ }
296
+ }
297
+ };
298
+ const workerCount = Math.min(
299
+ Math.max(1, Math.floor(Number(concurrency) || CODE_GRAPH_SOURCE_READ_CONCURRENCY)),
300
+ Math.max(1, uncached.length),
301
+ );
302
+ if (uncached.length > 0) {
303
+ await Promise.all(Array.from({ length: workerCount }, worker));
304
+ }
305
+ return sourceNodes;
306
+ }
307
+
308
+ export async function _prewarmReferenceSourceText(graph, symbol, language, options = {}) {
309
+ const candidateNodes = _lookupCandidateNodes(graph, symbol, language);
310
+ // Return the resolved candidate set so the immediately-following
311
+ // _cheapReferenceSearch (references/callers dispatch) can reuse it instead
312
+ // of recomputing _lookupCandidateNodes for the same (symbol, language) —
313
+ // which on a token-index miss is a full-graph scan run twice per symbol.
314
+ await _prewarmSourceTextNodes(graph, candidateNodes, options);
278
315
  return candidateNodes;
279
316
  }
280
317
 
@@ -14,6 +14,7 @@ export const CODE_GRAPH_TOOL_DEFS = [
14
14
  limit: { type: 'number', minimum: 1, description: 'Max results.' },
15
15
  depth: { type: 'number', minimum: 1, maximum: 5, description: 'Caller depth.' },
16
16
  page: { type: 'number', minimum: 1, description: 'Caller page.' },
17
+ cwd: { type: 'string', description: 'Explicit root.' },
17
18
  },
18
19
  required: ['mode'],
19
20
  additionalProperties: false,
@@ -120,8 +120,7 @@ export function scrubRuntimeRootVars(env) {
120
120
  // Daemon IDENTITY leak: user-facing children must not inherit host/worker
121
121
  // ownership or the daemon's terminal-lead PID. Internal daemon/worker
122
122
  // spawns set every identity value explicitly after this boundary.
123
- delete env.MIXDOG_ENGINE_DAEMON_HOST;
124
- delete env.MIXDOG_CHANNEL_DAEMON;
123
+ delete env.MIXDOG_DAEMON_HOST;
125
124
  delete env.MIXDOG_WORKER_MODE;
126
125
  delete env.MIXDOG_DAEMON_SPAWNED_FOR;
127
126
  delete env.MIXDOG_SUPERVISOR_PID;
@@ -43,11 +43,11 @@ export function _resolvePwshPoolTarget({
43
43
  if (Number.isFinite(n) && n >= 0) return Math.min(8, n);
44
44
  }
45
45
  if (platform !== 'win32') return 0;
46
- // Tools now execute in the machine-global backend daemon. Keep client/test
46
+ // Tools now execute in the machine-global daemon. Keep client/test
47
47
  // processes at zero by default, but let that ONE host maintain a short-lived
48
48
  // warm burst sized to the machine instead of making every shell call pay the
49
49
  // ~300ms pwsh startup cost.
50
- if (env?.MIXDOG_ENGINE_DAEMON_HOST !== '1' && env?.MIXDOG_CHANNEL_DAEMON !== '1') return 0;
50
+ if (env?.MIXDOG_DAEMON_HOST !== '1') return 0;
51
51
  let cores = Number(parallelism);
52
52
  if (!Number.isFinite(cores) || cores < 1) {
53
53
  try { cores = availableParallelism(); } catch { cores = 1; }
@@ -2,7 +2,7 @@
2
2
  // mode gating, binary resolution, prewarm/idle lifecycle, and the char-indexed
3
3
  // EDIT client. Split out of patch.mjs; behavior is identical.
4
4
  //
5
- // Backend: NATIVE-ONLY. Every supported apply/edit case is dispatched to the
5
+ // Executor: NATIVE-ONLY. Every supported apply/edit case is dispatched to the
6
6
  // mixdog-patch Rust engine via the persistent stdio server. There is NO JS
7
7
  // apply fallback: unsupported / unsafe input returns a clean Error string.
8
8
 
@@ -144,8 +144,8 @@ function restorePatchRollbackState(snapshots, readStateScope) {
144
144
  }
145
145
 
146
146
  // Apply one "wave" (a set of unique-target parsed entries) via the native
147
- // (+ JS out-of-base) split. Returns { backend, text } on success or
148
- // { backend, error } so the caller decides whether earlier waves already
147
+ // (+ JS out-of-base) split. Returns { executor, text } on success or
148
+ // { executor, error } so the caller decides whether earlier waves already
149
149
  // committed to disk. Extracted verbatim from the inline applyWave closure so
150
150
  // both the default wave loop and sequence mode share identical apply
151
151
  // semantics.
@@ -156,7 +156,7 @@ async function applyParsedWave({ parsed: wparsed, entries: wentries, headerRewri
156
156
  const parsedInside = (wparsed || []).filter(
157
157
  (entry) => !isResolvedPathOutsideBase(parsedEntryResolvedPath(entry, basePath), basePath),
158
158
  );
159
- const backend = outsideEntries.length > 0
159
+ const executor = outsideEntries.length > 0
160
160
  ? (insideEntries.length > 0 ? 'native+js-patch' : 'js-patch')
161
161
  : 'native-patch';
162
162
  const resultParts = [];
@@ -173,7 +173,7 @@ async function applyParsedWave({ parsed: wparsed, entries: wentries, headerRewri
173
173
  signal: abortSignal,
174
174
  parsed: parsedInside,
175
175
  });
176
- if (isPatchErrorText(nativeResult)) return { backend, error: nativeResult };
176
+ if (isPatchErrorText(nativeResult)) return { executor, error: nativeResult };
177
177
  resultParts.push(nativeResult);
178
178
  }
179
179
  if (outsideEntries.length > 0) {
@@ -187,10 +187,10 @@ async function applyParsedWave({ parsed: wparsed, entries: wentries, headerRewri
187
187
  fuzzy,
188
188
  readStateScope,
189
189
  });
190
- if (isPatchErrorText(jsResult)) return { backend, error: jsResult };
190
+ if (isPatchErrorText(jsResult)) return { executor, error: jsResult };
191
191
  resultParts.push(jsResult);
192
192
  }
193
- return { backend, text: resultParts.join('\n') };
193
+ return { executor, text: resultParts.join('\n') };
194
194
  }
195
195
 
196
196
  // Default ordered section mode. Apply each file section in listed order,
@@ -200,7 +200,7 @@ async function applyParsedWave({ parsed: wparsed, entries: wentries, headerRewri
200
200
  async function applyPatchSequence(patchStr, requestedFormat, basePath, ctx) {
201
201
  const {
202
202
  v4aConvertOpts, dryRun, fuzz, fuzzy, rejectPartial,
203
- readStateScope, abortSignal, mutationPlan, rollbackOnFailure,
203
+ readStateScope, abortSignal, mutationPlan,
204
204
  toolCallId, sessionId,
205
205
  } = ctx;
206
206
 
@@ -328,52 +328,8 @@ async function applyPatchSequence(patchStr, requestedFormat, basePath, ctx) {
328
328
 
329
329
  return withBuiltinPathLocks(lockPaths, () =>
330
330
  withAdvisoryLocks(lockPaths, async () => {
331
- // Most multi-file failures are stale context in a later, independent
332
- // file. For unique targets, validate every section against current bytes
333
- // before the first write. Same-target sequences retain the existing
334
- // ordered conversion/rollback path because later sections may
335
- // intentionally depend on earlier edits.
336
- const preparedUnits = new Map();
337
- const uniqueTargets = new Set(lockPaths.map(patchPathKey)).size === units.length;
338
- if (!dryRun && rollbackOnFailure && uniqueTargets) {
339
- for (let i = 0; i < units.length; i++) {
340
- const unit = units[i];
341
- if (abortSignal?.aborted) return 'Error: apply_patch aborted during preflight; no files were written';
342
- let parsed;
343
- try {
344
- parsed = await unit.buildParsed();
345
- } catch (err) {
346
- return `Error: apply_patch preflight rejected section ${i + 1}/${units.length} (${unit.displayPath}); no files were written.\n${err?.message || String(err)}`;
347
- }
348
- if (!Array.isArray(parsed) || parsed.length === 0) {
349
- preparedUnits.set(i, { parsed, wave: null });
350
- continue;
351
- }
352
- let wave;
353
- try {
354
- const { entries, headerRewrites } = await preValidateNativeBatch(parsed, basePath);
355
- wave = { parsed, entries, headerRewrites };
356
- } catch (err) {
357
- return `Error: apply_patch preflight rejected section ${i + 1}/${units.length} (${unit.displayPath}); no files were written.\n${err?.message || String(err)}`;
358
- }
359
- const checked = await applyParsedWave(wave, basePath, { ...waveOpts, dryRun: true });
360
- if (checked.error) {
361
- const detail = checked.error.replace(/^Error:\s*/, '');
362
- return `Error: apply_patch preflight rejected section ${i + 1}/${units.length} (${unit.displayPath}); no files were written.\n${detail}`;
363
- }
364
- preparedUnits.set(i, { parsed, wave });
365
- }
366
- }
367
- let rollbackSnapshots = [];
368
331
  let uiBeforeSnapshots = [];
369
- if (!dryRun && rollbackOnFailure) {
370
- try {
371
- rollbackSnapshots = capturePatchRollbackState(lockPaths);
372
- uiBeforeSnapshots = rollbackSnapshots;
373
- } catch (err) {
374
- return `Error: ${err?.message || String(err)}`;
375
- }
376
- } else if (!dryRun && toolCallId && sessionId) {
332
+ if (!dryRun && toolCallId && sessionId) {
377
333
  try {
378
334
  uiBeforeSnapshots = capturePatchRollbackState(lockPaths);
379
335
  } catch {
@@ -384,7 +340,7 @@ async function applyPatchSequence(patchStr, requestedFormat, basePath, ctx) {
384
340
  const skipped = [];
385
341
  let failed = null;
386
342
  let failedIndex = -1;
387
- let backend = 'native-patch';
343
+ let executor = 'native-patch';
388
344
  for (let i = 0; i < units.length; i++) {
389
345
  const unit = units[i];
390
346
  if (failed) { skipped.push(unit.displayPath); continue; }
@@ -393,15 +349,13 @@ async function applyPatchSequence(patchStr, requestedFormat, basePath, ctx) {
393
349
  failedIndex = i;
394
350
  continue;
395
351
  }
396
- let parsed = preparedUnits.get(i)?.parsed;
397
- if (!preparedUnits.has(i)) {
398
- try {
399
- parsed = await unit.buildParsed();
400
- } catch (err) {
401
- failed = { displayPath: unit.displayPath, error: `Error: ${err?.message || String(err)}` };
402
- failedIndex = i;
403
- continue;
404
- }
352
+ let parsed;
353
+ try {
354
+ parsed = await unit.buildParsed();
355
+ } catch (err) {
356
+ failed = { displayPath: unit.displayPath, error: `Error: ${err?.message || String(err)}` };
357
+ failedIndex = i;
358
+ continue;
405
359
  }
406
360
  if (!Array.isArray(parsed) || parsed.length === 0) {
407
361
  // Section produced no applicable hunks (all skipped / no-op). Nothing
@@ -409,19 +363,17 @@ async function applyPatchSequence(patchStr, requestedFormat, basePath, ctx) {
409
363
  applied.push({ displayPath: unit.displayPath, text: `(no changes) ${unit.displayPath}` });
410
364
  continue;
411
365
  }
412
- let wave = preparedUnits.get(i)?.wave;
413
- if (!wave) {
414
- try {
415
- const { entries, headerRewrites } = await preValidateNativeBatch(parsed, basePath);
416
- wave = { parsed, entries, headerRewrites };
417
- } catch (err) {
418
- failed = { displayPath: unit.displayPath, error: `Error: ${err?.message || String(err)}` };
419
- failedIndex = i;
420
- continue;
421
- }
366
+ let wave;
367
+ try {
368
+ const { entries, headerRewrites } = await preValidateNativeBatch(parsed, basePath);
369
+ wave = { parsed, entries, headerRewrites };
370
+ } catch (err) {
371
+ failed = { displayPath: unit.displayPath, error: `Error: ${err?.message || String(err)}` };
372
+ failedIndex = i;
373
+ continue;
422
374
  }
423
375
  const res = await applyParsedWave(wave, basePath, waveOpts);
424
- backend = res.backend;
376
+ executor = res.executor;
425
377
  if (res.error) {
426
378
  failed = { displayPath: unit.displayPath, error: res.error };
427
379
  failedIndex = i;
@@ -459,16 +411,13 @@ async function applyPatchSequence(patchStr, requestedFormat, basePath, ctx) {
459
411
  }
460
412
  const head = `apply_patch: ${verb} ${units.length} section(s)`;
461
413
  const body = (appliedTexts ? `${head}\n${appliedTexts}` : head) + dryNote + rejectedTail;
462
- return wrapPatchMutationOutput(body, mutationPlan, { backend });
414
+ return wrapPatchMutationOutput(body, mutationPlan, { executor });
463
415
  }
464
416
  const failMsg = failed.error.replace(/^Error:\s*/, '');
465
- const rollbackErrors = (!dryRun && rollbackOnFailure)
466
- ? restorePatchRollbackState(rollbackSnapshots, readStateScope)
467
- : [];
468
417
  if (
469
418
  !dryRun
470
419
  && uiBeforeSnapshots.length > 0
471
- && ((!rollbackOnFailure && applied.length > 0) || rollbackErrors.length > 0)
420
+ && applied.length > 0
472
421
  ) {
473
422
  registerCommittedPatchUiDiff({
474
423
  callId: toolCallId,
@@ -480,26 +429,22 @@ async function applyPatchSequence(patchStr, requestedFormat, basePath, ctx) {
480
429
  }
481
430
  const committedPhrase = dryRun
482
431
  ? `${applied.length} earlier section(s) were validated`
483
- : (rollbackOnFailure
484
- ? (rollbackErrors.length === 0
485
- ? `${applied.length} earlier section(s) were applied, then all touched paths were rolled back to their pre-patch state`
486
- : `${applied.length} earlier section(s) were applied, but rollback was incomplete`)
487
- : `${applied.length} earlier section(s) were applied to disk (committed) and left in place`);
432
+ : `${applied.length} earlier section(s) were applied to disk (committed) and left in place`;
488
433
  const lines = [
489
434
  `Error: apply_patch sequence stopped at section ${failedIndex + 1}/${units.length} (${failed.displayPath}); `
490
435
  + `${committedPhrase}; ${skipped.length} later section(s) were skipped (not attempted).`,
491
436
  ];
437
+ if (!dryRun) {
438
+ lines.push('Retry only the failed and skipped sections; do not resend committed sections.');
439
+ }
492
440
  if (appliedTexts) {
493
- lines.push(`--- ${dryRun ? 'validated' : (rollbackOnFailure ? 'applied before rollback' : 'applied (committed to disk)')} ---`, appliedTexts);
441
+ lines.push(`--- ${dryRun ? 'validated' : 'applied (committed to disk)'} ---`, appliedTexts);
494
442
  }
495
443
  lines.push(`--- failed section: ${failed.displayPath} ---`, failMsg);
496
- if (rollbackErrors.length > 0) {
497
- lines.push('--- rollback incomplete ---', ...rollbackErrors);
498
- }
499
444
  if (skipped.length > 0) {
500
445
  lines.push(`--- skipped (not attempted): ${skipped.join(', ')} ---`);
501
446
  }
502
- return wrapPatchMutationOutput(lines.join('\n') + dryNote + rejectedTail, mutationPlan, { backend });
447
+ return wrapPatchMutationOutput(lines.join('\n') + dryNote + rejectedTail, mutationPlan, { executor });
503
448
  }));
504
449
  }
505
450
 
@@ -723,9 +668,41 @@ function salvageShatteredV4APatchArgs(args) {
723
668
  return cleaned;
724
669
  }
725
670
 
671
+ function extractInlinePatchRoot(patchText) {
672
+ const text = String(patchText || '');
673
+ const match = /^(\*\*\* Begin Patch\r?\n)\*\*\* Root:[ \t]*(.*?)\r?\n/.exec(text);
674
+ if (!match) return { patch: text, root: null };
675
+ const root = String(match[2] || '').trim();
676
+ if (!root) throw new Error('apply_patch: "*** Root:" requires a containing directory');
677
+ return {
678
+ patch: `${match[1]}${text.slice(match[0].length)}`,
679
+ root,
680
+ };
681
+ }
682
+
683
+ function isFilesystemRootSpecifier(value, cwd) {
684
+ const raw = String(value || '').trim();
685
+ if (!raw) return false;
686
+ // Keep Windows semantics testable on every host and reject the ambiguous
687
+ // drive-relative spelling (`C:`) alongside an actual drive root.
688
+ if (/^[A-Za-z]:(?:[\\/]?)$/.test(raw)) return true;
689
+ const resolved = resolveBasePath(cwd, raw);
690
+ return pathDirname(resolved) === resolved;
691
+ }
692
+
726
693
  async function apply_patch(args, cwd, options = {}) {
727
694
  args = salvageShatteredV4APatchArgs(args);
728
- const patchStr = salvageV4AOpening((typeof args?.patch === 'string' ? args.patch : '').replace(/^\uFEFF/, ''));
695
+ let patchStr = salvageV4AOpening((typeof args?.patch === 'string' ? args.patch : '').replace(/^\uFEFF/, ''));
696
+ const inlineRoot = extractInlinePatchRoot(patchStr);
697
+ patchStr = inlineRoot.patch;
698
+ if (inlineRoot.root) {
699
+ const argumentRoot = typeof args?.root === 'string' ? args.root.trim() : '';
700
+ if (argumentRoot
701
+ && pathResolve(resolveBasePath(cwd, argumentRoot)) !== pathResolve(resolveBasePath(cwd, inlineRoot.root))) {
702
+ throw new Error('apply_patch: root argument conflicts with the "*** Root:" directive');
703
+ }
704
+ args = { ...(args || {}), root: inlineRoot.root };
705
+ }
729
706
  if (!patchStr.trim()) {
730
707
  throw new Error('apply_patch: "patch" is required (unified diff or V4A patch string)');
731
708
  }
@@ -761,7 +738,21 @@ async function apply_patch(args, cwd, options = {}) {
761
738
  // a mis-scoped working directory can never silently rewrite another tree.
762
739
  // Editing outside stays possible, but only as a deliberate act: name the
763
740
  // root that contains those targets in the same call.
764
- const writeRoot = resolveBasePath(cwd, args?.root ?? args?.base_path ?? null);
741
+ const explicitRoot = typeof args?.root === 'string' && args.root.trim() ? args.root.trim() : null;
742
+ if (explicitRoot && isFilesystemRootSpecifier(explicitRoot, cwd)) {
743
+ throw new Error(`apply_patch: refusing filesystem root as write root: ${normalizeOutputPath(explicitRoot)}`);
744
+ }
745
+ const writeRoot = resolveBasePath(cwd, explicitRoot ?? args?.base_path ?? null);
746
+ if (explicitRoot) {
747
+ try {
748
+ await assertPathReachable(writeRoot);
749
+ if (!statSync(writeRoot).isDirectory()) {
750
+ throw new Error(`not a directory — ${normalizeOutputPath(writeRoot)}`);
751
+ }
752
+ } catch (err) {
753
+ throw new Error(`apply_patch: invalid write root ${normalizeOutputPath(writeRoot)}: ${err?.message || String(err)}`);
754
+ }
755
+ }
765
756
  const outsideTargets = [];
766
757
  for (const rel of patchTargetPaths(patchStr, basePath)) {
767
758
  const abs = isAbsolute(rel) ? pathResolve(rel) : pathResolve(basePath, rel);
@@ -773,7 +764,7 @@ async function apply_patch(args, cwd, options = {}) {
773
764
  const more = shown.length > 3 ? ` (+${shown.length - 3} more)` : '';
774
765
  throw new Error(
775
766
  `apply_patch: ${shown.length} target(s) fall outside the write root ${normalizeOutputPath(writeRoot)}: ${head}${more}. `
776
- + 'Check the paths first; if they are intended, re-issue the same patch with root set to the directory that contains them.',
767
+ + 'Check the paths first; if intended, set root (JSON) or add "*** Root: <containing directory>" after "*** Begin Patch" (freeform).',
777
768
  );
778
769
  }
779
770
  const rejectPartial = args?.reject_partial !== false;
@@ -784,11 +775,10 @@ async function apply_patch(args, cwd, options = {}) {
784
775
  let inputPatchStr = patchStr;
785
776
  const rejectedV4AHunks = [];
786
777
  const v4aConvertOpts = { rejectPartial, rejectedHunks: rejectedV4AHunks, fuzzy, dryRun, readStateScope };
787
- // Default internal ordered mode: apply sections in listed order, stop at the
788
- // first failure, and roll every touched path back to its pre-patch state.
789
- // mode:"partial" retains the old committed-prefix behavior for internal
790
- // callers that explicitly need it.
791
- // The legacy bulk/atomic path remains available as an explicit escape hatch.
778
+ // Default ordered mode mirrors Codex's lower executor: apply sections in
779
+ // listed order, stop at the first failure, and keep the committed prefix.
780
+ // The model-visible schema stays unchanged; legacy bulk/atomic rollback
781
+ // remains an internal escape hatch.
792
782
  const patchMode = String(args?.mode || '').toLowerCase();
793
783
  const legacyBulkMode = args?.sequence === false
794
784
  || ['atomic', 'bulk'].includes(patchMode);
@@ -796,7 +786,6 @@ async function apply_patch(args, cwd, options = {}) {
796
786
  const seqOut = await applyPatchSequence(patchStr, requestedFormat, basePath, {
797
787
  v4aConvertOpts, dryRun, fuzz, fuzzy, rejectPartial,
798
788
  readStateScope, abortSignal, mutationPlan,
799
- rollbackOnFailure: patchMode !== 'partial',
800
789
  toolCallId: options?.toolCallId || null,
801
790
  sessionId: options?.sessionId || null,
802
791
  });
@@ -917,17 +906,17 @@ async function apply_patch(args, cwd, options = {}) {
917
906
  if (v4aRenameOnly) {
918
907
  const lines = formatV4ARenameSuccessLines(v4aRenameResults);
919
908
  if (lines.length === 0) return 'Error: patch contained no applicable file sections';
920
- return wrapPatchMutationOutput(`${lines.join('\n')}\n`, mutationPlan, { backend: 'v4a-rename' });
909
+ return wrapPatchMutationOutput(`${lines.join('\n')}\n`, mutationPlan, { executor: 'v4a-rename' });
921
910
  }
922
911
  // Apply one wave (a set of unique targets) via applyParsedWave (native +
923
- // JS split). Returns { backend, text } on success or { backend, error }.
912
+ // JS split). Returns { executor, text } on success or { executor, error }.
924
913
  const applyWave = (wave) => applyParsedWave(wave, basePath, { fuzz, rejectPartial, dryRun, fuzzy, readStateScope, abortSignal });
925
914
 
926
915
  // Duplicate-target blocks were split into contiguous sequential groups
927
916
  // (listed order preserved); apply them in order, each against the prior
928
917
  // group's on-disk result.
929
918
  const waveTexts = [];
930
- let backend = 'native-patch';
919
+ let executor = 'native-patch';
931
920
  // dry_run never writes, so a later group would validate against unchanged
932
921
  // disk and false-fail on any block that depends on an earlier edit. Only
933
922
  // the first group is validated under dry_run; the rest are reported as
@@ -935,9 +924,9 @@ async function apply_patch(args, cwd, options = {}) {
935
924
  const groupCount = (dryRun && waveDispatch.length > 1) ? 1 : waveDispatch.length;
936
925
  for (let w = 0; w < groupCount; w++) {
937
926
  const res = await applyWave(waveDispatch[w]);
938
- backend = res.backend;
927
+ executor = res.executor;
939
928
  if (res.error) {
940
- if (w === 0) return wrapPatchMutationOutput(res.error, mutationPlan, { backend });
929
+ if (w === 0) return wrapPatchMutationOutput(res.error, mutationPlan, { executor });
941
930
  // A later group failed. rejectPartial makes each group all-or-nothing,
942
931
  // so every block in groups 1..w is fully committed to disk and left in
943
932
  // place. List them all so the caller knows the true on-disk state.
@@ -948,7 +937,7 @@ async function apply_patch(args, cwd, options = {}) {
948
937
  '--- failing block ---',
949
938
  failMsg,
950
939
  ].join('\n');
951
- return wrapPatchMutationOutput(note, mutationPlan, { backend });
940
+ return wrapPatchMutationOutput(note, mutationPlan, { executor });
952
941
  }
953
942
  waveTexts.push(res.text);
954
943
  }
@@ -970,9 +959,9 @@ async function apply_patch(args, cwd, options = {}) {
970
959
  `hunk-level rejected (rejectPartial=false, V4A): ${rejectedV4AHunks.length}`,
971
960
  ...rejectedV4AHunks.map((r) => ` REJECT ${r.file || '(unknown)'} — ${String(r.reason || '').split(';')[0].trim()}`),
972
961
  ];
973
- return wrapPatchMutationOutput(`${combined}\n${tail.join('\n')}`, mutationPlan, { backend });
962
+ return wrapPatchMutationOutput(`${combined}\n${tail.join('\n')}`, mutationPlan, { executor });
974
963
  }
975
- return wrapPatchMutationOutput(combined, mutationPlan, { backend });
964
+ return wrapPatchMutationOutput(combined, mutationPlan, { executor });
976
965
  };
977
966
 
978
967
  return withBuiltinPathLocks(_lockPaths, () =>
@@ -1,5 +1,6 @@
1
- const APPLY_PATCH_LARK_GRAMMAR = `start: begin_patch hunk+ end_patch
1
+ const APPLY_PATCH_LARK_GRAMMAR = `start: begin_patch root_line? hunk+ end_patch
2
2
  begin_patch: "*** Begin Patch" LF
3
+ root_line: "*** Root: " filename LF
3
4
  end_patch: "*** End Patch" LF?
4
5
 
5
6
  hunk: add_hunk | delete_hunk | update_hunk
@@ -20,7 +20,7 @@
20
20
  // ./patch/orchestrator.mjs — apply_patch orchestration + executePatchTool
21
21
  // + replay capture + UI-diff side-channel.
22
22
  //
23
- // Backend is unchanged: NATIVE-ONLY dispatch to the mixdog-patch Rust engine,
23
+ // Executor is unchanged: NATIVE-ONLY dispatch to the mixdog-patch Rust engine,
24
24
  // no JS apply fallback. Public exports below match the pre-split surface.
25
25
 
26
26
  // Side-effect import: schedules the native-patch prewarm on module load, as
@@ -258,6 +258,7 @@ export function execShellCommand({
258
258
  ownerSessionId,
259
259
  backgroundOnTimeout,
260
260
  promotedTimeoutMs = 0,
261
+ backgroundDeadlineMs = 0,
261
262
  admission = resourceAdmission,
262
263
  }) {
263
264
  return new Promise(async (resolve) => {
@@ -791,7 +792,12 @@ export function execShellCommand({
791
792
  const stdoutPath = taskOutput.spilled ? taskOutput.stdoutPath : null;
792
793
  const stderrPath = taskOutput.spilled ? taskOutput.stderrPath : null;
793
794
  let job = null;
794
- const adoptedTimeoutMs = reason === 'timeout' ? promotedTimeoutMs : 0;
795
+ const elapsedMs = Math.max(0, Date.now() - _startMs);
796
+ const adoptedTimeoutMs = reason === 'timeout'
797
+ ? promotedTimeoutMs
798
+ : (backgroundDeadlineMs > 0
799
+ ? Math.max(1, backgroundDeadlineMs - elapsedMs)
800
+ : 0);
795
801
  try {
796
802
  job = adoptForegroundShellJob({
797
803
  command,
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Bounded PDF inspection/text extraction shared by prompt intake and `read`.
3
+ *
4
+ * Native PDF-capable providers receive the original content-addressed file.
5
+ * OpenAI-compatible providers without a document contract receive page-ordered
6
+ * text extracted here, so they never see an unsupported inline Base64 block.
7
+ */
8
+ const DEFAULT_MAX_PAGES = 100;
9
+ const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
10
+
11
+ function boundedUtf8(value, maxBytes) {
12
+ const buffer = Buffer.from(String(value || ''), 'utf8');
13
+ if (buffer.length <= maxBytes) return { text: buffer.toString('utf8'), bytes: buffer.length, truncated: false };
14
+ if (maxBytes <= 0) return { text: '', bytes: 0, truncated: true };
15
+ const text = buffer.subarray(0, maxBytes).toString('utf8').replace(/\uFFFD+$/g, '');
16
+ return { text, bytes: Buffer.byteLength(text, 'utf8'), truncated: true };
17
+ }
18
+
19
+ export async function inspectPdfBuffer(buffer, {
20
+ extractText = false,
21
+ maxPages = DEFAULT_MAX_PAGES,
22
+ maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES,
23
+ pageRange = null,
24
+ } = {}) {
25
+ if (!Buffer.isBuffer(buffer) || buffer.length === 0) throw new TypeError('PDF payload is empty');
26
+ const { getDocumentProxy } = await import('unpdf');
27
+ const pdf = await getDocumentProxy(new Uint8Array(buffer));
28
+ try {
29
+ const pageCount = Math.max(0, Number(pdf?.numPages) || 0);
30
+ const pageLimit = Number.isFinite(Number(maxPages)) ? Math.max(1, Math.floor(Number(maxPages))) : Infinity;
31
+ if (pageCount > pageLimit) {
32
+ throw new RangeError(`PDF has ${pageCount} pages; maximum supported attachment is ${pageLimit} pages`);
33
+ }
34
+ if (!extractText) return { pageCount, text: '', truncated: false };
35
+
36
+ const from = Math.max(1, Number(pageRange?.from) || 1);
37
+ const to = Math.min(pageCount, Math.max(from, Number(pageRange?.to) || pageCount));
38
+ const byteLimit = Math.max(1, Number(maxOutputBytes) || DEFAULT_MAX_OUTPUT_BYTES);
39
+ const chunks = [];
40
+ let bytes = 0;
41
+ let truncated = false;
42
+ for (let pageNumber = from; pageNumber <= to; pageNumber += 1) {
43
+ const page = await pdf.getPage(pageNumber);
44
+ try {
45
+ const content = await page.getTextContent();
46
+ const body = (content?.items || [])
47
+ .map((item) => typeof item?.str === 'string' ? item.str : '')
48
+ .filter(Boolean)
49
+ .join(' ')
50
+ .trim();
51
+ const block = `--- Page ${pageNumber} ---\n${body || '(no extractable text on this page)'}`;
52
+ const separatorBytes = chunks.length ? 2 : 0;
53
+ const remaining = byteLimit - bytes - separatorBytes;
54
+ if (remaining <= 0) {
55
+ truncated = true;
56
+ break;
57
+ }
58
+ const bounded = boundedUtf8(block, remaining);
59
+ if (chunks.length) {
60
+ chunks.push('\n\n');
61
+ bytes += 2;
62
+ }
63
+ chunks.push(bounded.text);
64
+ bytes += bounded.bytes;
65
+ if (bounded.truncated) {
66
+ truncated = true;
67
+ break;
68
+ }
69
+ } finally {
70
+ try { page.cleanup?.(); } catch {}
71
+ }
72
+ }
73
+ if (truncated) {
74
+ const suffix = '\n\n... [PDF text truncated to the prompt input budget]';
75
+ const room = Math.max(0, byteLimit - Buffer.byteLength(suffix, 'utf8'));
76
+ const bounded = boundedUtf8(chunks.join(''), room);
77
+ return { pageCount, text: `${bounded.text}${suffix}`, truncated: true };
78
+ }
79
+ return { pageCount, text: chunks.join(''), truncated: false };
80
+ } finally {
81
+ try { await pdf.destroy?.(); } catch {}
82
+ }
83
+ }