@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.
- package/LICENSE +21 -0
- package/README.md +81 -9
- package/dist/agent/context-manager.d.ts +2 -1
- package/dist/agent/context-manager.js +16 -2
- package/dist/agent/context-manager.js.map +1 -1
- package/dist/agent/loop-guard.d.ts +18 -0
- package/dist/agent/loop-guard.js +50 -2
- package/dist/agent/loop-guard.js.map +1 -1
- package/dist/agent/must-continue.js +4 -8
- package/dist/agent/must-continue.js.map +1 -1
- package/dist/agent/prompt-composer.js +8 -8
- package/dist/agent/prompt-composer.js.map +1 -1
- package/dist/agent/runner.d.ts +1 -0
- package/dist/agent/runner.js +238 -74
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/session-policy.d.ts +1 -2
- package/dist/agent/session-policy.js +3 -2
- package/dist/agent/session-policy.js.map +1 -1
- package/dist/agent/tool-call-parser.js +6 -6
- package/dist/agent/tool-call-parser.js.map +1 -1
- package/dist/app/adapters/current-agent-adapter.js +1 -0
- package/dist/app/adapters/current-agent-adapter.js.map +1 -1
- package/dist/app/controllers/session-controller.js +2 -0
- package/dist/app/controllers/session-controller.js.map +1 -1
- package/dist/app/controllers/session-turn-request.d.ts +1 -0
- package/dist/app/controllers/session-turn-request.js +4 -1
- package/dist/app/controllers/session-turn-request.js.map +1 -1
- package/dist/app/ports/agent-port.d.ts +1 -0
- package/dist/attachments/clipboard-image.js +5 -4
- package/dist/attachments/clipboard-image.js.map +1 -1
- package/dist/attachments/image-content.d.ts +20 -2
- package/dist/attachments/image-content.js +201 -1
- package/dist/attachments/image-content.js.map +1 -1
- package/dist/attachments/image-prepare.d.ts +25 -0
- package/dist/attachments/image-prepare.js +390 -0
- package/dist/attachments/image-prepare.js.map +1 -0
- package/dist/attachments/service.d.ts +4 -2
- package/dist/attachments/service.js +51 -21
- package/dist/attachments/service.js.map +1 -1
- package/dist/commands/providers.d.ts +5 -2
- package/dist/commands/providers.js +121 -7
- package/dist/commands/providers.js.map +1 -1
- package/dist/index.js +12 -4
- package/dist/index.js.map +1 -1
- package/dist/llm/agentrouter.js +2 -2
- package/dist/llm/agentrouter.js.map +1 -1
- package/dist/llm/anthropic.d.ts +5 -4
- package/dist/llm/anthropic.js +28 -25
- package/dist/llm/anthropic.js.map +1 -1
- package/dist/llm/aws-mantle.js +19 -20
- package/dist/llm/aws-mantle.js.map +1 -1
- package/dist/llm/bynara.js +2 -2
- package/dist/llm/bynara.js.map +1 -1
- package/dist/llm/capabilities.d.ts +19 -0
- package/dist/llm/capabilities.js +273 -16
- package/dist/llm/capabilities.js.map +1 -1
- package/dist/llm/gemini.js +16 -8
- package/dist/llm/gemini.js.map +1 -1
- package/dist/llm/groq.js +2 -2
- package/dist/llm/groq.js.map +1 -1
- package/dist/llm/http.d.ts +7 -1
- package/dist/llm/http.js +210 -8
- package/dist/llm/http.js.map +1 -1
- package/dist/llm/kimchi.js +2 -2
- package/dist/llm/kimchi.js.map +1 -1
- package/dist/llm/lightning.d.ts +11 -0
- package/dist/llm/lightning.js +108 -0
- package/dist/llm/lightning.js.map +1 -0
- package/dist/llm/modal.d.ts +16 -0
- package/dist/llm/modal.js +182 -0
- package/dist/llm/modal.js.map +1 -0
- package/dist/llm/nvidia.js +2 -2
- package/dist/llm/nvidia.js.map +1 -1
- package/dist/llm/ollama.js +3 -3
- package/dist/llm/ollama.js.map +1 -1
- package/dist/llm/openai.js +2 -2
- package/dist/llm/openai.js.map +1 -1
- package/dist/llm/openrouter.js +2 -2
- package/dist/llm/openrouter.js.map +1 -1
- package/dist/llm/provider.d.ts +11 -0
- package/dist/llm/provider.js +316 -1
- package/dist/llm/provider.js.map +1 -1
- package/dist/llm/qwen-cloud.js +2 -5
- package/dist/llm/qwen-cloud.js.map +1 -1
- package/dist/llm/router.js +127 -7
- package/dist/llm/router.js.map +1 -1
- package/dist/llm/system-messages.d.ts +5 -0
- package/dist/llm/system-messages.js +14 -0
- package/dist/llm/system-messages.js.map +1 -1
- package/dist/llm/token-usage.d.ts +13 -0
- package/dist/llm/token-usage.js +31 -0
- package/dist/llm/token-usage.js.map +1 -1
- package/dist/llm/tokenrouter.d.ts +15 -0
- package/dist/llm/tokenrouter.js +106 -0
- package/dist/llm/tokenrouter.js.map +1 -0
- package/dist/modes/agent.d.ts +1 -0
- package/dist/modes/agent.js.map +1 -1
- package/dist/modes/ask.js +15 -4
- package/dist/modes/ask.js.map +1 -1
- package/dist/prompts/embedded.js +2 -2
- package/dist/prompts/embedded.js.map +1 -1
- package/dist/prompts/index.d.ts +6 -0
- package/dist/prompts/index.js +39 -21
- package/dist/prompts/index.js.map +1 -1
- package/dist/prompts/system.agent.md +15 -27
- package/dist/prompts/system.ask.md +1 -1
- package/dist/repl/slash-commands.js +53 -0
- package/dist/repl/slash-commands.js.map +1 -1
- package/dist/repl.js +16 -9
- package/dist/repl.js.map +1 -1
- package/dist/store/config.d.ts +55 -0
- package/dist/store/config.js +127 -0
- package/dist/store/config.js.map +1 -1
- package/dist/store/history.d.ts +2 -1
- package/dist/store/history.js +15 -16
- package/dist/store/history.js.map +1 -1
- package/dist/store/keys.js +41 -2
- package/dist/store/keys.js.map +1 -1
- package/dist/tools/definitions.js +31 -6
- package/dist/tools/definitions.js.map +1 -1
- package/dist/tools/image.d.ts +0 -7
- package/dist/tools/image.js +76 -22
- package/dist/tools/image.js.map +1 -1
- package/dist/tools/jobs.d.ts +11 -1
- package/dist/tools/jobs.js +67 -0
- package/dist/tools/jobs.js.map +1 -1
- package/dist/tools/ocr.d.ts +28 -0
- package/dist/tools/ocr.js +316 -0
- package/dist/tools/ocr.js.map +1 -0
- package/dist/tools/pdf.d.ts +0 -12
- package/dist/tools/pdf.js +436 -150
- package/dist/tools/pdf.js.map +1 -1
- package/dist/tools/registry.js +5 -0
- package/dist/tools/registry.js.map +1 -1
- package/dist/tui/format-keys.js +10 -0
- package/dist/tui/format-keys.js.map +1 -1
- package/dist/tui-v2/actions/action-id.d.ts +1 -1
- package/dist/tui-v2/actions/action-id.js +2 -0
- package/dist/tui-v2/actions/action-id.js.map +1 -1
- package/dist/tui-v2/actions/format-shortcuts.js +3 -1
- package/dist/tui-v2/actions/format-shortcuts.js.map +1 -1
- package/dist/tui-v2/actions/keymap.js +4 -1
- package/dist/tui-v2/actions/keymap.js.map +1 -1
- package/dist/tui-v2/app/commands/key-commands.js +117 -15
- package/dist/tui-v2/app/commands/key-commands.js.map +1 -1
- package/dist/tui-v2/app/commands/picker-commands.js +80 -11
- package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
- package/dist/tui-v2/components/jobs/jobs-panel.js +25 -1
- package/dist/tui-v2/components/jobs/jobs-panel.js.map +1 -1
- package/dist/tui-v2/components/modal/keys-modal.js +15 -8
- package/dist/tui-v2/components/modal/keys-modal.js.map +1 -1
- package/dist/tui-v2/components/modal/secret-modal.js +13 -4
- package/dist/tui-v2/components/modal/secret-modal.js.map +1 -1
- package/dist/tui-v2/components/pager/pager.js +103 -12
- package/dist/tui-v2/components/pager/pager.js.map +1 -1
- package/dist/tui-v2/components/transcript/thinking-block.d.ts +11 -3
- package/dist/tui-v2/components/transcript/thinking-block.js +5 -4
- package/dist/tui-v2/components/transcript/thinking-block.js.map +1 -1
- package/dist/tui-v2/components/transcript/transcript-row.d.ts +2 -0
- package/dist/tui-v2/components/transcript/transcript-row.js +2 -2
- package/dist/tui-v2/components/transcript/transcript-row.js.map +1 -1
- package/dist/tui-v2/components/transcript/transcript-view.js +8 -1
- package/dist/tui-v2/components/transcript/transcript-view.js.map +1 -1
- package/dist/tui-v2/controllers/overlay-controller.d.ts +13 -0
- package/dist/tui-v2/controllers/overlay-controller.js +5 -2
- package/dist/tui-v2/controllers/overlay-controller.js.map +1 -1
- package/dist/tui-v2/rendering/artifact-pager-source.d.ts +10 -0
- package/dist/tui-v2/rendering/artifact-pager-source.js +6 -0
- package/dist/tui-v2/rendering/artifact-pager-source.js.map +1 -1
- package/dist/tui-v2/rendering/job-tail-source.d.ts +21 -0
- package/dist/tui-v2/rendering/job-tail-source.js +87 -0
- package/dist/tui-v2/rendering/job-tail-source.js.map +1 -0
- package/dist/types.d.ts +7 -1
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -1
- package/dist/ui/mentions.d.ts +7 -2
- package/dist/ui/mentions.js +52 -117
- package/dist/ui/mentions.js.map +1 -1
- package/dist/version.generated.d.ts +2 -2
- package/dist/version.generated.js +2 -2
- package/dist/version.generated.js.map +1 -1
- 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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return
|
|
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 (
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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:
|
|
284
|
+
output: 'pdf.read: ocr must be "auto", "never" or "always"',
|
|
72
285
|
exitCode: 1,
|
|
73
286
|
};
|
|
74
287
|
}
|
|
75
|
-
const
|
|
76
|
-
|
|
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:
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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:
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (
|
|
179
|
-
|
|
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
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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:
|
|
232
|
-
output: `
|
|
233
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|