@pentoshi/clai 3.9.7 → 3.10.0

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 (182) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -9
  3. package/dist/agent/context-manager.d.ts +2 -1
  4. package/dist/agent/context-manager.js +16 -2
  5. package/dist/agent/context-manager.js.map +1 -1
  6. package/dist/agent/loop-guard.d.ts +18 -0
  7. package/dist/agent/loop-guard.js +50 -2
  8. package/dist/agent/loop-guard.js.map +1 -1
  9. package/dist/agent/must-continue.js +4 -8
  10. package/dist/agent/must-continue.js.map +1 -1
  11. package/dist/agent/prompt-composer.js +8 -8
  12. package/dist/agent/prompt-composer.js.map +1 -1
  13. package/dist/agent/runner.d.ts +1 -0
  14. package/dist/agent/runner.js +238 -74
  15. package/dist/agent/runner.js.map +1 -1
  16. package/dist/agent/session-policy.d.ts +1 -2
  17. package/dist/agent/session-policy.js +3 -2
  18. package/dist/agent/session-policy.js.map +1 -1
  19. package/dist/agent/tool-call-parser.js +6 -6
  20. package/dist/agent/tool-call-parser.js.map +1 -1
  21. package/dist/app/adapters/current-agent-adapter.js +1 -0
  22. package/dist/app/adapters/current-agent-adapter.js.map +1 -1
  23. package/dist/app/controllers/session-controller.js +2 -0
  24. package/dist/app/controllers/session-controller.js.map +1 -1
  25. package/dist/app/controllers/session-turn-request.d.ts +1 -0
  26. package/dist/app/controllers/session-turn-request.js +4 -1
  27. package/dist/app/controllers/session-turn-request.js.map +1 -1
  28. package/dist/app/ports/agent-port.d.ts +1 -0
  29. package/dist/attachments/clipboard-image.js +5 -4
  30. package/dist/attachments/clipboard-image.js.map +1 -1
  31. package/dist/attachments/image-content.d.ts +20 -2
  32. package/dist/attachments/image-content.js +201 -1
  33. package/dist/attachments/image-content.js.map +1 -1
  34. package/dist/attachments/image-prepare.d.ts +25 -0
  35. package/dist/attachments/image-prepare.js +390 -0
  36. package/dist/attachments/image-prepare.js.map +1 -0
  37. package/dist/attachments/service.d.ts +4 -2
  38. package/dist/attachments/service.js +51 -21
  39. package/dist/attachments/service.js.map +1 -1
  40. package/dist/commands/providers.d.ts +5 -2
  41. package/dist/commands/providers.js +121 -7
  42. package/dist/commands/providers.js.map +1 -1
  43. package/dist/index.js +12 -4
  44. package/dist/index.js.map +1 -1
  45. package/dist/llm/agentrouter.js +2 -2
  46. package/dist/llm/agentrouter.js.map +1 -1
  47. package/dist/llm/anthropic.d.ts +5 -4
  48. package/dist/llm/anthropic.js +28 -25
  49. package/dist/llm/anthropic.js.map +1 -1
  50. package/dist/llm/aws-mantle.js +19 -20
  51. package/dist/llm/aws-mantle.js.map +1 -1
  52. package/dist/llm/bynara.js +2 -2
  53. package/dist/llm/bynara.js.map +1 -1
  54. package/dist/llm/capabilities.d.ts +19 -0
  55. package/dist/llm/capabilities.js +273 -16
  56. package/dist/llm/capabilities.js.map +1 -1
  57. package/dist/llm/gemini.js +16 -8
  58. package/dist/llm/gemini.js.map +1 -1
  59. package/dist/llm/groq.js +2 -2
  60. package/dist/llm/groq.js.map +1 -1
  61. package/dist/llm/http.d.ts +7 -1
  62. package/dist/llm/http.js +210 -8
  63. package/dist/llm/http.js.map +1 -1
  64. package/dist/llm/kimchi.js +2 -2
  65. package/dist/llm/kimchi.js.map +1 -1
  66. package/dist/llm/lightning.d.ts +11 -0
  67. package/dist/llm/lightning.js +108 -0
  68. package/dist/llm/lightning.js.map +1 -0
  69. package/dist/llm/modal.d.ts +16 -0
  70. package/dist/llm/modal.js +182 -0
  71. package/dist/llm/modal.js.map +1 -0
  72. package/dist/llm/nvidia.js +2 -2
  73. package/dist/llm/nvidia.js.map +1 -1
  74. package/dist/llm/ollama.js +3 -3
  75. package/dist/llm/ollama.js.map +1 -1
  76. package/dist/llm/openai.js +2 -2
  77. package/dist/llm/openai.js.map +1 -1
  78. package/dist/llm/openrouter.js +2 -2
  79. package/dist/llm/openrouter.js.map +1 -1
  80. package/dist/llm/provider.d.ts +11 -0
  81. package/dist/llm/provider.js +316 -1
  82. package/dist/llm/provider.js.map +1 -1
  83. package/dist/llm/qwen-cloud.js +2 -5
  84. package/dist/llm/qwen-cloud.js.map +1 -1
  85. package/dist/llm/router.js +127 -7
  86. package/dist/llm/router.js.map +1 -1
  87. package/dist/llm/system-messages.d.ts +5 -0
  88. package/dist/llm/system-messages.js +14 -0
  89. package/dist/llm/system-messages.js.map +1 -1
  90. package/dist/llm/token-usage.d.ts +13 -0
  91. package/dist/llm/token-usage.js +31 -0
  92. package/dist/llm/token-usage.js.map +1 -1
  93. package/dist/llm/tokenrouter.d.ts +15 -0
  94. package/dist/llm/tokenrouter.js +106 -0
  95. package/dist/llm/tokenrouter.js.map +1 -0
  96. package/dist/modes/agent.d.ts +1 -0
  97. package/dist/modes/agent.js.map +1 -1
  98. package/dist/modes/ask.js +15 -4
  99. package/dist/modes/ask.js.map +1 -1
  100. package/dist/prompts/embedded.js +2 -2
  101. package/dist/prompts/embedded.js.map +1 -1
  102. package/dist/prompts/index.d.ts +6 -0
  103. package/dist/prompts/index.js +39 -21
  104. package/dist/prompts/index.js.map +1 -1
  105. package/dist/prompts/system.agent.md +15 -27
  106. package/dist/prompts/system.ask.md +1 -1
  107. package/dist/repl/slash-commands.js +53 -0
  108. package/dist/repl/slash-commands.js.map +1 -1
  109. package/dist/repl.js +16 -9
  110. package/dist/repl.js.map +1 -1
  111. package/dist/store/config.d.ts +55 -0
  112. package/dist/store/config.js +127 -0
  113. package/dist/store/config.js.map +1 -1
  114. package/dist/store/history.d.ts +2 -1
  115. package/dist/store/history.js +15 -16
  116. package/dist/store/history.js.map +1 -1
  117. package/dist/store/keys.js +41 -2
  118. package/dist/store/keys.js.map +1 -1
  119. package/dist/tools/definitions.js +31 -6
  120. package/dist/tools/definitions.js.map +1 -1
  121. package/dist/tools/image.d.ts +0 -7
  122. package/dist/tools/image.js +76 -22
  123. package/dist/tools/image.js.map +1 -1
  124. package/dist/tools/jobs.d.ts +11 -1
  125. package/dist/tools/jobs.js +67 -0
  126. package/dist/tools/jobs.js.map +1 -1
  127. package/dist/tools/ocr.d.ts +28 -0
  128. package/dist/tools/ocr.js +316 -0
  129. package/dist/tools/ocr.js.map +1 -0
  130. package/dist/tools/pdf.d.ts +0 -12
  131. package/dist/tools/pdf.js +436 -150
  132. package/dist/tools/pdf.js.map +1 -1
  133. package/dist/tools/registry.js +5 -0
  134. package/dist/tools/registry.js.map +1 -1
  135. package/dist/tui/format-keys.js +10 -0
  136. package/dist/tui/format-keys.js.map +1 -1
  137. package/dist/tui-v2/actions/action-id.d.ts +1 -1
  138. package/dist/tui-v2/actions/action-id.js +2 -0
  139. package/dist/tui-v2/actions/action-id.js.map +1 -1
  140. package/dist/tui-v2/actions/format-shortcuts.js +3 -1
  141. package/dist/tui-v2/actions/format-shortcuts.js.map +1 -1
  142. package/dist/tui-v2/actions/keymap.js +4 -1
  143. package/dist/tui-v2/actions/keymap.js.map +1 -1
  144. package/dist/tui-v2/app/commands/key-commands.js +117 -15
  145. package/dist/tui-v2/app/commands/key-commands.js.map +1 -1
  146. package/dist/tui-v2/app/commands/picker-commands.js +80 -11
  147. package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
  148. package/dist/tui-v2/components/jobs/jobs-panel.js +25 -1
  149. package/dist/tui-v2/components/jobs/jobs-panel.js.map +1 -1
  150. package/dist/tui-v2/components/modal/keys-modal.js +15 -8
  151. package/dist/tui-v2/components/modal/keys-modal.js.map +1 -1
  152. package/dist/tui-v2/components/modal/secret-modal.js +13 -4
  153. package/dist/tui-v2/components/modal/secret-modal.js.map +1 -1
  154. package/dist/tui-v2/components/pager/pager.js +103 -12
  155. package/dist/tui-v2/components/pager/pager.js.map +1 -1
  156. package/dist/tui-v2/components/transcript/thinking-block.d.ts +11 -3
  157. package/dist/tui-v2/components/transcript/thinking-block.js +5 -4
  158. package/dist/tui-v2/components/transcript/thinking-block.js.map +1 -1
  159. package/dist/tui-v2/components/transcript/transcript-row.d.ts +2 -0
  160. package/dist/tui-v2/components/transcript/transcript-row.js +2 -2
  161. package/dist/tui-v2/components/transcript/transcript-row.js.map +1 -1
  162. package/dist/tui-v2/components/transcript/transcript-view.js +8 -1
  163. package/dist/tui-v2/components/transcript/transcript-view.js.map +1 -1
  164. package/dist/tui-v2/controllers/overlay-controller.d.ts +13 -0
  165. package/dist/tui-v2/controllers/overlay-controller.js +5 -2
  166. package/dist/tui-v2/controllers/overlay-controller.js.map +1 -1
  167. package/dist/tui-v2/rendering/artifact-pager-source.d.ts +10 -0
  168. package/dist/tui-v2/rendering/artifact-pager-source.js +6 -0
  169. package/dist/tui-v2/rendering/artifact-pager-source.js.map +1 -1
  170. package/dist/tui-v2/rendering/job-tail-source.d.ts +21 -0
  171. package/dist/tui-v2/rendering/job-tail-source.js +87 -0
  172. package/dist/tui-v2/rendering/job-tail-source.js.map +1 -0
  173. package/dist/types.d.ts +7 -1
  174. package/dist/types.js +3 -0
  175. package/dist/types.js.map +1 -1
  176. package/dist/ui/mentions.d.ts +7 -2
  177. package/dist/ui/mentions.js +52 -117
  178. package/dist/ui/mentions.js.map +1 -1
  179. package/dist/version.generated.d.ts +2 -2
  180. package/dist/version.generated.js +2 -2
  181. package/dist/version.generated.js.map +1 -1
  182. package/package.json +1 -1
package/dist/tools/pdf.js CHANGED
@@ -1,8 +1,17 @@
1
- import { mkdtemp, readdir, rm, stat } from "node:fs/promises";
1
+ import { mkdir, mkdtemp, open, readdir, rm, stat } from "node:fs/promises";
2
2
  import { homedir, tmpdir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import { commandAvailable } from "../os/pkgmgr.js";
5
5
  import { spawnArgv } from "./shell.js";
6
+ import { LANG_PATTERN, meaningfulCharCount, runOcr, stripCommandEcho, tesseractUnavailableReason, } from "./ocr.js";
7
+ const MIN_PAGE_MEANINGFUL_CHARS = 12;
8
+ const DEFAULT_DPI = 300;
9
+ const DEFAULT_PSM = 3;
10
+ const DEFAULT_MAX_CHARS = 200_000;
11
+ const MAX_MAX_CHARS = 1_000_000;
12
+ const TEXT_LAYER_TIMEOUT_MS = 120_000;
13
+ const RENDER_TIMEOUT_MS = 300_000;
14
+ const PAGE_OCR_TIMEOUT_MS = 120_000;
6
15
  function expandHome(path) {
7
16
  if (path === "~")
8
17
  return homedir();
@@ -19,33 +28,213 @@ function optionalNumber(args, key) {
19
28
  const value = args[key];
20
29
  return typeof value === "number" ? value : undefined;
21
30
  }
22
- /**
23
- * Count the "meaningful" characters in extracted text — letters and digits
24
- * only. A text PDF returns hundreds; a scanned (image-only) PDF returns a
25
- * handful of stray glyphs or nothing. We use this to decide whether to fall
26
- * back to rendering pages and OCR-ing them.
27
- */
28
- function meaningfulCharCount(text) {
29
- const matches = text.match(/[A-Za-z0-9]/g);
30
- return matches ? matches.length : 0;
31
+ function integerArg(args, key, min, max) {
32
+ const raw = optionalNumber(args, key);
33
+ if (raw === undefined)
34
+ return {};
35
+ const value = Math.floor(raw);
36
+ if (!Number.isFinite(raw) || value < min || value > max) {
37
+ return { error: `pdf.read: ${key} must be an integer from ${min} to ${max}` };
38
+ }
39
+ return { value };
40
+ }
41
+ async function readPdfMetadata(path, timeoutMs, signal) {
42
+ if (!(await commandAvailable("pdfinfo")))
43
+ return { encrypted: false };
44
+ const result = await spawnArgv({
45
+ command: "pdfinfo",
46
+ argv: [path],
47
+ timeoutMs,
48
+ signal,
49
+ noArtifact: true,
50
+ maxModelBytes: 16_000,
51
+ });
52
+ const body = stripCommandEcho(result.output);
53
+ const pages = /^Pages:\s+(\d+)/m.exec(body)?.[1];
54
+ const encrypted = /^Encrypted:\s+yes/im.test(body);
55
+ const title = /^Title:\s+(.+)$/m.exec(body)?.[1]?.trim();
56
+ return {
57
+ ...(pages ? { pageCount: Number(pages) } : {}),
58
+ encrypted,
59
+ ...(title ? { title } : {}),
60
+ };
61
+ }
62
+ function encryptionError(path) {
63
+ return {
64
+ ok: false,
65
+ output: `pdf.read: ${path} is password-protected, so its text cannot be extracted. ` +
66
+ "Decrypt it first (qpdf --decrypt --password=<pw> in.pdf out.pdf) and read the decrypted copy.",
67
+ exitCode: 1,
68
+ };
69
+ }
70
+ async function looksLikePdf(path) {
71
+ const handle = await open(path, "r");
72
+ try {
73
+ const header = Buffer.alloc(1024);
74
+ const { bytesRead } = await handle.read(header, 0, header.length, 0);
75
+ return header.subarray(0, bytesRead).includes("%PDF-");
76
+ }
77
+ finally {
78
+ await handle.close();
79
+ }
80
+ }
81
+ async function extractTextLayer(path, first, last, timeoutMs, signal) {
82
+ if (await commandAvailable("pdftotext")) {
83
+ const argv = ["-layout", "-enc", "UTF-8", "-f", String(first)];
84
+ if (last !== undefined)
85
+ argv.push("-l", String(last));
86
+ argv.push(path, "-");
87
+ const result = await spawnArgv({
88
+ command: "pdftotext",
89
+ argv,
90
+ timeoutMs,
91
+ signal,
92
+ noArtifact: true,
93
+ maxModelBytes: 2_000_000,
94
+ });
95
+ const body = stripCommandEcho(result.output);
96
+ if (/incorrect password|encrypted/i.test(body)) {
97
+ return { pages: [], extractor: "pdftotext", encrypted: true };
98
+ }
99
+ if (!result.ok && meaningfulCharCount(body) === 0) {
100
+ return {
101
+ pages: [],
102
+ extractor: "pdftotext",
103
+ failure: body.trim() || `pdftotext exited with code ${result.exitCode ?? 1}`,
104
+ };
105
+ }
106
+ return { pages: splitPages(body), extractor: "pdftotext" };
107
+ }
108
+ if (await commandAvailable("mutool")) {
109
+ const argv = ["draw", "-F", "txt", "-o", "-", path];
110
+ if (last !== undefined)
111
+ argv.push(`${first}-${last}`);
112
+ else
113
+ argv.push(`${first}-`);
114
+ const result = await spawnArgv({
115
+ command: "mutool",
116
+ argv,
117
+ timeoutMs,
118
+ signal,
119
+ noArtifact: true,
120
+ maxModelBytes: 2_000_000,
121
+ });
122
+ const body = stripCommandEcho(result.output);
123
+ if (/password/i.test(body)) {
124
+ return { pages: [], extractor: "mutool", encrypted: true };
125
+ }
126
+ if (!result.ok && meaningfulCharCount(body) === 0) {
127
+ return {
128
+ pages: [],
129
+ extractor: "mutool",
130
+ failure: body.trim() || `mutool exited with code ${result.exitCode ?? 1}`,
131
+ };
132
+ }
133
+ return { pages: splitPages(body), extractor: "mutool" };
134
+ }
135
+ return { pages: [], extractor: undefined };
136
+ }
137
+ function splitPages(body) {
138
+ const pages = body.split("\f");
139
+ if (pages.length > 1 && pages[pages.length - 1] === "")
140
+ pages.pop();
141
+ return pages.map((page) => page.replace(/[ \t]+$/gm, "").trim());
142
+ }
143
+ function groupConsecutive(pages) {
144
+ const groups = [];
145
+ for (const page of pages) {
146
+ const current = groups[groups.length - 1];
147
+ if (current && page === current[1] + 1) {
148
+ current[1] = page;
149
+ continue;
150
+ }
151
+ groups.push([page, page]);
152
+ }
153
+ return groups;
154
+ }
155
+ async function renderPageRange(path, from, to, dpi, outputDir, timeoutMs, signal) {
156
+ await mkdir(outputDir, { recursive: true });
157
+ if (await commandAvailable("pdftoppm")) {
158
+ const result = await spawnArgv({
159
+ command: "pdftoppm",
160
+ argv: [
161
+ "-png",
162
+ "-gray",
163
+ "-r",
164
+ String(dpi),
165
+ "-f",
166
+ String(from),
167
+ "-l",
168
+ String(to),
169
+ path,
170
+ join(outputDir, "page"),
171
+ ],
172
+ timeoutMs,
173
+ signal,
174
+ noArtifact: true,
175
+ maxModelBytes: 8_000,
176
+ });
177
+ const rendered = await listRenderedPages(outputDir, from, to, true);
178
+ if (rendered.length > 0)
179
+ return rendered;
180
+ if (!result.ok)
181
+ return [];
182
+ }
183
+ if (await commandAvailable("mutool")) {
184
+ await spawnArgv({
185
+ command: "mutool",
186
+ argv: [
187
+ "draw",
188
+ "-F",
189
+ "png",
190
+ "-r",
191
+ String(dpi),
192
+ "-o",
193
+ join(outputDir, "page-%d.png"),
194
+ path,
195
+ `${from}-${to}`,
196
+ ],
197
+ timeoutMs,
198
+ signal,
199
+ noArtifact: true,
200
+ maxModelBytes: 8_000,
201
+ });
202
+ return listRenderedPages(outputDir, from, to, false);
203
+ }
204
+ return [];
205
+ }
206
+ async function listRenderedPages(directory, from, to, namesArePageNumbers) {
207
+ let names;
208
+ try {
209
+ names = (await readdir(directory))
210
+ .filter((name) => name.toLowerCase().endsWith(".png"))
211
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
212
+ }
213
+ catch {
214
+ return [];
215
+ }
216
+ return names.flatMap((name, index) => {
217
+ const parsed = namesArePageNumbers
218
+ ? Number(/(\d+)\.png$/i.exec(name)?.[1])
219
+ : Number.NaN;
220
+ const pageNumber = Number.isFinite(parsed) && parsed >= from && parsed <= to
221
+ ? parsed
222
+ : from + index;
223
+ if (pageNumber > to)
224
+ return [];
225
+ return [{ pageNumber, imagePath: join(directory, name) }];
226
+ });
227
+ }
228
+ async function missingOcrTooling() {
229
+ const missing = [];
230
+ const hasRenderer = (await commandAvailable("pdftoppm")) || (await commandAvailable("mutool"));
231
+ if (!hasRenderer)
232
+ missing.push("pdftoppm (poppler) or mutool (mupdf)");
233
+ const tesseract = await tesseractUnavailableReason();
234
+ if (tesseract)
235
+ missing.push("tesseract");
236
+ return missing;
31
237
  }
32
- // Below this many meaningful characters we treat pdftotext's output as
33
- // "empty" and switch to the render-then-OCR fallback. Tuned low so a PDF
34
- // with a tiny bit of real text (a cover page) still triggers OCR when the
35
- // body is scanned.
36
- const MIN_MEANINGFUL_CHARS = 16;
37
- /**
38
- * Read a PDF as text.
39
- *
40
- * Strategy (mirrors what a human would do):
41
- * 1. Run `pdftotext -layout <pdf> -` — fast and exact for born-digital PDFs.
42
- * 2. If that yields little/no real text the PDF is scanned (image-only), so
43
- * render every page to a PNG with `pdftoppm` and OCR each one with
44
- * `tesseract`, concatenating the results page by page.
45
- *
46
- * Auto-executes (read-only). Returns a helpful error naming the missing
47
- * binary so the agent can pkg.install it and retry.
48
- */
49
238
  export async function pdfRead(args, options = {}) {
50
239
  const rawPath = optionalString(args, "path");
51
240
  if (!rawPath) {
@@ -56,45 +245,54 @@ export async function pdfRead(args, options = {}) {
56
245
  };
57
246
  }
58
247
  const lang = optionalString(args, "lang") ?? "eng";
59
- if (!/^[A-Za-z0-9_+-]+$/.test(lang)) {
248
+ if (!LANG_PATTERN.test(lang)) {
60
249
  return {
61
250
  ok: false,
62
251
  output: "pdf.read: lang may contain only letters, digits, _, +, or -",
63
252
  exitCode: 1,
64
253
  };
65
254
  }
66
- const dpiRaw = optionalNumber(args, "dpi") ?? 200;
67
- const dpi = Math.floor(dpiRaw);
68
- if (!Number.isFinite(dpiRaw) || dpi < 72 || dpi > 600) {
255
+ const dpiArg = integerArg(args, "dpi", 72, 600);
256
+ if (dpiArg.error)
257
+ return { ok: false, output: dpiArg.error, exitCode: 1 };
258
+ const dpi = dpiArg.value ?? DEFAULT_DPI;
259
+ const psmArg = integerArg(args, "psm", 0, 13);
260
+ if (psmArg.error)
261
+ return { ok: false, output: psmArg.error, exitCode: 1 };
262
+ const psm = psmArg.value ?? DEFAULT_PSM;
263
+ const maxPagesArg = integerArg(args, "maxPages", 1, 500);
264
+ if (maxPagesArg.error) {
265
+ return { ok: false, output: maxPagesArg.error, exitCode: 1 };
266
+ }
267
+ const firstPageArg = integerArg(args, "firstPage", 1, 100_000);
268
+ if (firstPageArg.error) {
269
+ return { ok: false, output: firstPageArg.error, exitCode: 1 };
270
+ }
271
+ const lastPageArg = integerArg(args, "lastPage", 1, 100_000);
272
+ if (lastPageArg.error) {
273
+ return { ok: false, output: lastPageArg.error, exitCode: 1 };
274
+ }
275
+ const maxCharsArg = integerArg(args, "maxChars", 1_000, MAX_MAX_CHARS);
276
+ if (maxCharsArg.error) {
277
+ return { ok: false, output: maxCharsArg.error, exitCode: 1 };
278
+ }
279
+ const maxChars = maxCharsArg.value ?? DEFAULT_MAX_CHARS;
280
+ const ocrMode = optionalString(args, "ocr") ?? "auto";
281
+ if (!["auto", "never", "always"].includes(ocrMode)) {
69
282
  return {
70
283
  ok: false,
71
- output: "pdf.read: dpi must be an integer from 72 to 600",
284
+ output: 'pdf.read: ocr must be "auto", "never" or "always"',
72
285
  exitCode: 1,
73
286
  };
74
287
  }
75
- const psmRaw = optionalNumber(args, "psm") ?? 6;
76
- const psm = Math.floor(psmRaw);
77
- if (!Number.isFinite(psmRaw) || psm < 0 || psm > 13) {
288
+ const firstPage = firstPageArg.value ?? 1;
289
+ if (lastPageArg.value !== undefined && lastPageArg.value < firstPage) {
78
290
  return {
79
291
  ok: false,
80
- output: "pdf.read: psm must be an integer from 0 to 13",
292
+ output: "pdf.read: lastPage must be greater than or equal to firstPage",
81
293
  exitCode: 1,
82
294
  };
83
295
  }
84
- const maxPagesRaw = optionalNumber(args, "maxPages");
85
- let maxPages;
86
- if (maxPagesRaw !== undefined) {
87
- maxPages = Math.floor(maxPagesRaw);
88
- if (!Number.isFinite(maxPagesRaw) || maxPages < 1 || maxPages > 500) {
89
- return {
90
- ok: false,
91
- output: "pdf.read: maxPages must be an integer from 1 to 500",
92
- exitCode: 1,
93
- };
94
- }
95
- }
96
- // The per-call budget bounds the whole operation, including OCR of every
97
- // rendered page, so the model can actually cap a scanned-PDF read.
98
296
  const totalTimeoutMs = optionalNumber(args, "timeoutMs");
99
297
  const deadline = totalTimeoutMs !== undefined ? Date.now() + totalTimeoutMs : undefined;
100
298
  const remainingMs = (fallback) => {
@@ -113,6 +311,16 @@ export async function pdfRead(args, options = {}) {
113
311
  exitCode: 1,
114
312
  };
115
313
  }
314
+ if (info.size === 0) {
315
+ return { ok: false, output: `pdf.read: ${path} is empty`, exitCode: 1 };
316
+ }
317
+ if (!(await looksLikePdf(path))) {
318
+ return {
319
+ ok: false,
320
+ output: `pdf.read: ${path} does not start with a %PDF- header, so it is not a PDF. For images use image.ocr; for office documents convert them first (e.g. libreoffice --convert-to pdf).`,
321
+ exitCode: 1,
322
+ };
323
+ }
116
324
  }
117
325
  catch (error) {
118
326
  return {
@@ -121,123 +329,201 @@ export async function pdfRead(args, options = {}) {
121
329
  exitCode: 1,
122
330
  };
123
331
  }
124
- // Step 1: fast text extraction for born-digital PDFs
125
- let textLayerOutput = "";
126
- if (await commandAvailable("pdftotext")) {
127
- const direct = await spawnArgv({
128
- command: "pdftotext",
129
- argv: ["-layout", path, "-"],
130
- timeoutMs: remainingMs(120_000),
131
- signal: options.signal,
132
- onOutput: options.onOutput,
133
- noArtifact: true,
134
- maxModelBytes: 200_000,
135
- });
136
- // spawnArgv prefixes the output with a "$ pdftotext …" command echo; strip
137
- // that first line so the meaningful-char heuristic sees only PDF content.
138
- textLayerOutput = direct.output.replace(/^\$ pdftotext[^\n]*\n?/, "");
139
- if (meaningfulCharCount(textLayerOutput) >= MIN_MEANINGFUL_CHARS) {
140
- return {
141
- ok: true,
142
- output: textLayerOutput.trim(),
143
- };
144
- }
145
- }
146
- if (options.signal?.aborted) {
147
- return { ok: false, output: "pdf.read aborted.", exitCode: 130 };
332
+ const metadata = await readPdfMetadata(path, remainingMs(15_000), options.signal);
333
+ if (metadata.encrypted)
334
+ return encryptionError(path);
335
+ let rangeEnd = lastPageArg.value ?? metadata.pageCount;
336
+ if (maxPagesArg.value !== undefined) {
337
+ const capped = firstPage + maxPagesArg.value - 1;
338
+ rangeEnd = rangeEnd === undefined ? capped : Math.min(rangeEnd, capped);
148
339
  }
149
- // Step 2: scanned PDF → render pages then OCR each one
150
- const missing = [];
151
- if (!(await commandAvailable("pdftoppm")))
152
- missing.push("pdftoppm (poppler)");
153
- if (!(await commandAvailable("tesseract")))
154
- missing.push("tesseract");
155
- if (missing.length > 0) {
156
- const hint = textLayerOutput.trim().length > 0
157
- ? `\n\nPartial text-layer extraction:\n${textLayerOutput.trim()}`
158
- : "";
340
+ if (metadata.pageCount !== undefined &&
341
+ firstPage > metadata.pageCount) {
159
342
  return {
160
343
  ok: false,
161
- output: `pdf.read: the PDF has no extractable text layer (it is scanned), and OCR fallback needs: ${missing.join(", ")}. ` +
162
- `Install the missing tool(s) (e.g. poppler for pdftoppm, tesseract for OCR) and retry.${hint}`,
344
+ output: `pdf.read: firstPage ${firstPage} is beyond the document, which has ${metadata.pageCount} page(s).`,
163
345
  exitCode: 1,
164
346
  };
165
347
  }
166
- const workDir = await mkdtemp(join(tmpdir(), "clai-pdfocr-"));
167
- try {
168
- const prefix = join(workDir, "page");
169
- options.onOutput?.("\n text layer empty — rendering pages for OCR…\n", "stdout");
170
- const render = await spawnArgv({
171
- command: "pdftoppm",
172
- argv: ["-png", "-r", String(dpi), path, prefix],
173
- timeoutMs: remainingMs(300_000),
174
- signal: options.signal,
175
- onOutput: options.onOutput,
176
- noArtifact: true,
177
- });
178
- if (!render.ok && !options.signal?.aborted) {
179
- return {
180
- ok: false,
181
- output: `pdf.read: failed to render PDF pages with pdftoppm.\n${render.output}`,
182
- exitCode: render.exitCode ?? 1,
183
- };
184
- }
185
- const allEntries = (await readdir(workDir))
186
- .filter((name) => name.toLowerCase().endsWith(".png"))
187
- .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
188
- if (allEntries.length === 0) {
348
+ const textLayer = await extractTextLayer(path, firstPage, rangeEnd, remainingMs(TEXT_LAYER_TIMEOUT_MS), options.signal);
349
+ if (textLayer.encrypted)
350
+ return encryptionError(path);
351
+ if (options.signal?.aborted) {
352
+ return { ok: false, output: "pdf.read aborted.", exitCode: 130 };
353
+ }
354
+ let pages = textLayer.pages;
355
+ if (rangeEnd !== undefined) {
356
+ pages = pages.slice(0, Math.max(0, rangeEnd - firstPage + 1));
357
+ }
358
+ const knownPageCount = metadata.pageCount;
359
+ if (pages.length === 0 && rangeEnd === undefined && knownPageCount === undefined) {
360
+ if (textLayer.extractor === undefined) {
361
+ const missing = await missingOcrTooling();
189
362
  return {
190
363
  ok: false,
191
- output: "pdf.read: no pages were rendered from the PDFit may be empty or corrupt.",
364
+ output: "pdf.read: no PDF text extractor is installed. Install poppler (pdftotext/pdftoppm) or mupdf (mutool) e.g. `brew install poppler`, `apt install poppler-utils`" +
365
+ (missing.length > 0
366
+ ? `. OCR of scanned pages additionally needs: ${missing.join(", ")}.`
367
+ : "."),
192
368
  exitCode: 1,
193
369
  };
194
370
  }
195
- // maxPages is advertised in the schema and must actually bound the work:
196
- // OCR costs up to 120s per page, so a 100-page scan is hours otherwise.
197
- const entries = maxPages !== undefined ? allEntries.slice(0, maxPages) : allEntries;
198
- const skippedPages = allEntries.length - entries.length;
199
- const pageTexts = [];
200
- for (let i = 0; i < entries.length; i += 1) {
201
- if (options.signal?.aborted) {
202
- return { ok: false, output: "pdf.read aborted.", exitCode: 130 };
371
+ return {
372
+ ok: false,
373
+ output: `pdf.read: could not determine the page count of ${path}${textLayer.failure ? ` (${textLayer.failure})` : ""}. Install poppler so pdfinfo can report it, or pass maxPages.`,
374
+ exitCode: 1,
375
+ };
376
+ }
377
+ const authoritativeEnd = rangeEnd ?? knownPageCount;
378
+ const textLayerEnd = pages.length > 0 ? firstPage + pages.length - 1 : undefined;
379
+ const resolvedEnd = authoritativeEnd !== undefined
380
+ ? textLayerEnd !== undefined
381
+ ? Math.max(authoritativeEnd, textLayerEnd)
382
+ : authoritativeEnd
383
+ : textLayerEnd;
384
+ const pageCountForRange = resolvedEnd !== undefined ? resolvedEnd - firstPage + 1 : 0;
385
+ if (pageCountForRange <= 0) {
386
+ return {
387
+ ok: false,
388
+ output: `pdf.read: the requested page range is empty for ${path}.`,
389
+ exitCode: 1,
390
+ };
391
+ }
392
+ const boundedPageCount = Math.min(pageCountForRange, 500);
393
+ const pageNumbers = Array.from({ length: boundedPageCount }, (_, index) => firstPage + index);
394
+ const pageText = new Map();
395
+ for (const [index, number] of pageNumbers.entries()) {
396
+ pageText.set(number, pages[index] ?? "");
397
+ }
398
+ const needsOcr = ocrMode === "never"
399
+ ? []
400
+ : pageNumbers.filter((number) => ocrMode === "always" ||
401
+ meaningfulCharCount(pageText.get(number) ?? "") <
402
+ MIN_PAGE_MEANINGFUL_CHARS);
403
+ const ocredPages = [];
404
+ const lowConfidencePages = [];
405
+ let ocrNotice;
406
+ let timedOut = false;
407
+ if (needsOcr.length > 0) {
408
+ const missing = await missingOcrTooling();
409
+ if (missing.length > 0) {
410
+ ocrNotice =
411
+ `${needsOcr.length} page(s) have no text layer and could not be OCR-ed because these tools are missing: ${missing.join(", ")}. ` +
412
+ "Install them (e.g. `brew install poppler tesseract` or `apt install poppler-utils tesseract-ocr`) and retry.";
413
+ }
414
+ else {
415
+ const workDir = await mkdtemp(join(tmpdir(), "clai-pdfocr-"));
416
+ try {
417
+ options.onOutput?.(`\n ${needsOcr.length} page(s) without a text layer — rendering at ${dpi} dpi for OCR…\n`, "stdout");
418
+ for (const [from, to] of groupConsecutive(needsOcr)) {
419
+ if (options.signal?.aborted) {
420
+ return { ok: false, output: "pdf.read aborted.", exitCode: 130 };
421
+ }
422
+ if (outOfTime()) {
423
+ timedOut = true;
424
+ break;
425
+ }
426
+ const groupDir = join(workDir, `range-${from}-${to}`);
427
+ const rendered = await renderPageRange(path, from, to, dpi, groupDir, remainingMs(RENDER_TIMEOUT_MS), options.signal);
428
+ if (rendered.length === 0) {
429
+ ocrNotice =
430
+ ocrNotice ??
431
+ `pages ${from}-${to} could not be rendered for OCR — the PDF may be damaged or use an unsupported filter.`;
432
+ continue;
433
+ }
434
+ for (const { pageNumber, imagePath } of rendered) {
435
+ if (options.signal?.aborted) {
436
+ return { ok: false, output: "pdf.read aborted.", exitCode: 130 };
437
+ }
438
+ if (outOfTime()) {
439
+ timedOut = true;
440
+ break;
441
+ }
442
+ options.onOutput?.(` OCR page ${pageNumber}…\n`, "stdout");
443
+ const ocr = await runOcr({
444
+ path: imagePath,
445
+ lang,
446
+ psmCandidates: psmArg.value !== undefined ? [psm] : [psm, 6, 11],
447
+ timeoutMs: remainingMs(PAGE_OCR_TIMEOUT_MS),
448
+ dpi,
449
+ preprocess: false,
450
+ signal: options.signal,
451
+ });
452
+ if (!ocr.ok) {
453
+ ocrNotice = ocrNotice ?? `OCR failed on page ${pageNumber}: ${ocr.error ?? "unknown error"}`;
454
+ continue;
455
+ }
456
+ if (meaningfulCharCount(ocr.text) === 0)
457
+ continue;
458
+ if (!ocr.reliable) {
459
+ lowConfidencePages.push(pageNumber);
460
+ continue;
461
+ }
462
+ pageText.set(pageNumber, ocr.text);
463
+ ocredPages.push(pageNumber);
464
+ }
465
+ if (timedOut)
466
+ break;
467
+ }
203
468
  }
204
- if (outOfTime()) {
205
- pageTexts.push(`----- page ${i + 1} -----\n(skipped: timeoutMs budget exhausted after ${i} page(s); raise timeoutMs or lower maxPages)`);
206
- break;
469
+ finally {
470
+ await rm(workDir, { recursive: true, force: true }).catch(() => undefined);
207
471
  }
208
- const imagePath = join(workDir, entries[i]);
209
- options.onOutput?.(` OCR page ${i + 1}/${entries.length}…\n`, "stdout");
210
- const ocr = await spawnArgv({
211
- command: "tesseract",
212
- argv: [imagePath, "stdout", "-l", lang, "--psm", String(psm)],
213
- timeoutMs: remainingMs(120_000),
214
- signal: options.signal,
215
- noArtifact: true,
216
- maxModelBytes: 200_000,
217
- });
218
- // Strip spawnArgv's "$ tesseract …" command echo from each page.
219
- const body = ocr.output.replace(/^\$ tesseract[^\n]*\n?/, "").trim();
220
- pageTexts.push(`----- page ${i + 1} -----\n${body || "(no text recognized on this page)"}`);
221
- }
222
- const combined = pageTexts.join("\n\n").trim();
223
- if (meaningfulCharCount(combined) === 0) {
224
- return {
225
- ok: false,
226
- output: "pdf.read: OCR ran on all pages but recognized no text. The scan may be too low quality — try a higher dpi.",
227
- exitCode: 1,
228
- };
229
472
  }
473
+ }
474
+ const sections = [];
475
+ let extractedChars = 0;
476
+ for (const number of pageNumbers) {
477
+ const body = pageText.get(number)?.trim() ?? "";
478
+ extractedChars += meaningfulCharCount(body);
479
+ sections.push(`----- page ${number} -----\n${body || "(no text on this page)"}`);
480
+ }
481
+ let combined = sections.join("\n\n").trim();
482
+ if (extractedChars === 0) {
483
+ const reason = ocrNotice ??
484
+ (ocrMode === "never"
485
+ ? "the PDF has no text layer and ocr was set to \"never\""
486
+ : "OCR ran but recognized no text — the scan may be too low quality, so retry with a higher dpi (e.g. 400) or a different psm");
230
487
  return {
231
- ok: true,
232
- output: `[scanned PDF text recovered via OCR of ${entries.length} page(s)` +
233
- (skippedPages > 0
234
- ? `; ${skippedPages} further page(s) skipped by maxPages=${maxPages}`
235
- : "") +
236
- `]\n\n${combined}`,
488
+ ok: false,
489
+ output: `pdf.read: no text could be extracted from ${path}. ${reason}`,
490
+ exitCode: 1,
237
491
  };
238
492
  }
239
- finally {
240
- await rm(workDir, { recursive: true, force: true }).catch(() => undefined);
493
+ let truncated = false;
494
+ if (combined.length > maxChars) {
495
+ combined = combined.slice(0, maxChars);
496
+ truncated = true;
497
+ }
498
+ const rangeLabel = pageNumbers.length === 1
499
+ ? `page ${pageNumbers[0]}`
500
+ : `pages ${pageNumbers[0]}-${pageNumbers[pageNumbers.length - 1]}`;
501
+ const headerParts = [
502
+ `${rangeLabel}${knownPageCount ? ` of ${knownPageCount}` : ""}`,
503
+ ];
504
+ if (ocredPages.length > 0) {
505
+ const shown = ocredPages.slice(0, 12).join(", ");
506
+ headerParts.push(`OCR used on ${ocredPages.length} page(s)${ocredPages.length <= 12 ? ` (${shown})` : ""} at ${dpi} dpi`);
507
+ }
508
+ else if (needsOcr.length === 0) {
509
+ headerParts.push("embedded text layer");
510
+ }
511
+ if (lowConfidencePages.length > 0) {
512
+ headerParts.push(`${lowConfidencePages.length} page(s) produced only OCR noise and were left blank rather than guessed (${lowConfidencePages.slice(0, 12).join(", ")}) — retry with a higher dpi if those pages matter`);
241
513
  }
514
+ if (metadata.title)
515
+ headerParts.push(`title: ${metadata.title}`);
516
+ if (timedOut) {
517
+ headerParts.push("stopped early: timeoutMs budget exhausted — raise timeoutMs or lower maxPages");
518
+ }
519
+ if (truncated) {
520
+ headerParts.push(`truncated at maxChars=${maxChars} — read a narrower firstPage/lastPage range for the rest`);
521
+ }
522
+ if (ocrNotice)
523
+ headerParts.push(ocrNotice);
524
+ return {
525
+ ok: true,
526
+ output: `[pdf.read ${path} — ${headerParts.join(" · ")}]\n\n${combined}`,
527
+ };
242
528
  }
243
529
  //# sourceMappingURL=pdf.js.map