dsh-book-reader 0.0.0-stage → 0.1.1

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/lib/index.js ADDED
@@ -0,0 +1,1850 @@
1
+ /**
2
+ * Host half of dsh-pdf-reader.
3
+ *
4
+ * Registers four model-callable PDF tools, the browser-facing HTTP routes, and
5
+ * one `agent/pre-step` injection that tells the model which page the user is
6
+ * looking at. The PDF engine itself lives in `lib/core/` and is imported
7
+ * lazily, so this module can be loaded (and its pure helpers tested) even
8
+ * while that engine is absent.
9
+ *
10
+ * Design constraints:
11
+ * - Route validation mirrors `dsh-pdf-search`: loopback Host, absolute path,
12
+ * `.pdf` suffix, regular file, size cap, JSON only, no CORS header.
13
+ * - Page images use the same attachment route as `read_image`: a durable
14
+ * `attachments.saveImage` reference plus an `ImageBlock` from `render()`.
15
+ * - Auto-share is per-session and deliberately conservative: at most one
16
+ * message per turn, and only when the page changed since the last hint.
17
+ *
18
+ * @module dsh-pdf-reader
19
+ */
20
+
21
+ import { basename, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path'
22
+ import { appendFileSync } from 'node:fs'
23
+ import { stat } from 'node:fs/promises'
24
+ import z from '@deepseek-ai/schemastery'
25
+ import { defineTool } from '@deepseek-ai/dsh-tools'
26
+ import { estimateImageTokens, estimateTextTokens } from './core/tokens.js'
27
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
28
+
29
+ /** Cordis plugin name used by loader diagnostics. */
30
+ export const name = 'pdf-reader'
31
+
32
+ /** Host services this plugin answers from and hooks into. */
33
+ export const inject = ['tools', 'webServer', 'agents']
34
+
35
+ /** Prefix route owned by this plugin; every sub-route is matched exactly. */
36
+ export const ROUTE_PATH = '/dsh-pdf-reader'
37
+
38
+ /** Largest PDF this plugin will open, in bytes. */
39
+ const DEFAULT_MAX_FILE_BYTES = 256 * 1024 * 1024
40
+
41
+ /** Hard safety ceiling on a request body accepted by POST /current. */
42
+ const DEFAULT_MAX_ROUTE_BODY_BYTES = 64 * 1024
43
+
44
+ /** Hard ceiling for one `pdf_read_pages` call, regardless of configuration. */
45
+ export const ABSOLUTE_MAX_PAGES_PER_CALL = 8
46
+
47
+ /** Ceiling used while expanding a page selection, so `1-100000` fails fast. */
48
+ const PAGE_SELECTION_HARD_LIMIT = 256
49
+
50
+ /** Config schema. Every key is optional in YAML; defaults live here. */
51
+ export const Config = z.object({
52
+ /** Master switch: false mounts no tool, route, or listener. */
53
+ enabled: z.boolean().default(true),
54
+ /** Inject the current reading page into `agent/pre-step`, at most once per turn. */
55
+ autoShare: z.boolean().default(true),
56
+ /** Resolve the outline section for the injected message (one cached getInfo). */
57
+ autoShareSection: z.boolean().default(true),
58
+ /** Optional minimum milliseconds between hints; 0 = no time throttle. */
59
+ autoShareMinIntervalMs: z.number().default(0),
60
+ /** Minimum absolute page change required before another hint. */
61
+ autoSharePageDelta: z.number().default(1),
62
+ /** Ignore a reported position older than this; 0 means no age limit. */
63
+ autoShareMaxAgeMs: z.number().default(0),
64
+ /** Hard cap on pages rendered by one `pdf_read_pages` call. */
65
+ maxPagesPerCall: z.number().default(4),
66
+ /** Default render scale for `pdf_read_pages` (2.5 is ~180 DPI). */
67
+ defaultScale: z.number().default(2.5),
68
+ /** Characters of extracted text kept per page; 0 disables text. */
69
+ maxTextCharsPerPage: z.number().default(1200),
70
+ /** Maximum search hits returned by the tool and the route. */
71
+ maxSearchResults: z.number().default(100),
72
+ /** Maximum outline entries returned by the route. */
73
+ maxOutlineEntries: z.number().default(500),
74
+ /** Maximum page labels included by `pdf_info`; 0 omits them (cheapest). */
75
+ maxLabels: z.number().default(0),
76
+ /** Deepest outline level `pdf_info` returns; 0=chapters, 1=sections (default), 2=subsections. */
77
+ outlineDepth: z.number().default(1),
78
+ /** Append one JSON cost line per expensive tool call to <plugin>/.debug-cost.log. */
79
+ debugCost: z.boolean().default(false),
80
+ /** Include internal error detail in HTTP 500 bodies (development only). */
81
+ debug: z.boolean().default(false),
82
+ /** Allow tools to open PDFs outside the session workspace (default: no). */
83
+ allowOutsideWorkspace: z.boolean().default(false),
84
+ /** Extra route-level allowlist of directories; empty = allow any (UI compatibility). */
85
+ routeAllowedRoots: z.array(z.string()).default([]),
86
+ /** Largest PDF this plugin will stat, parse, or render. */
87
+ maxFileBytes: z.number().default(DEFAULT_MAX_FILE_BYTES),
88
+ /** Largest JSON body accepted by POST /current. */
89
+ maxRouteBodyBytes: z.number().default(DEFAULT_MAX_ROUTE_BODY_BYTES),
90
+ /** Parsed `getInfo` results kept per process (path + mtime + size). */
91
+ infoCacheSize: z.number().default(8),
92
+ })
93
+
94
+ /** Defaults applied independently of the loader, so direct callers work too. */
95
+ export const DEFAULT_CONFIG = Object.freeze({
96
+ enabled: true,
97
+ autoShare: true,
98
+ autoShareSection: true,
99
+ autoShareMinIntervalMs: 0,
100
+ autoSharePageDelta: 1,
101
+ autoShareMaxAgeMs: 0,
102
+ maxPagesPerCall: 4,
103
+ defaultScale: 2.5,
104
+ maxTextCharsPerPage: 1200,
105
+ maxSearchResults: 100,
106
+ maxOutlineEntries: 500,
107
+ maxLabels: 0,
108
+ outlineDepth: 1,
109
+ debugCost: false,
110
+ debug: false,
111
+ allowOutsideWorkspace: false,
112
+ routeAllowedRoots: [],
113
+ maxFileBytes: DEFAULT_MAX_FILE_BYTES,
114
+ maxRouteBodyBytes: DEFAULT_MAX_ROUTE_BODY_BYTES,
115
+ infoCacheSize: 8,
116
+ })
117
+
118
+ /** Shape of the per-session auto-share bookkeeping. */
119
+ export const INITIAL_AUTO_SHARE_STATE = Object.freeze({
120
+ lastInjectedPath: null,
121
+ lastInjectedPdfPage: null,
122
+ lastInjectedAt: null,
123
+ lastInjectedTurn: null,
124
+ })
125
+
126
+ /** Create an Error carrying a stable code, for tests and route mapping. */
127
+ function codedError(message, code) {
128
+ const error = new Error(message)
129
+ error.code = code
130
+ return error
131
+ }
132
+
133
+ /** Read a boolean config value with a clear failure. */
134
+ function configBoolean(value, fallback, key) {
135
+ if (value === undefined || value === null) return fallback
136
+ if (typeof value !== 'boolean') throw new TypeError(`pdf-reader: ${key} must be a boolean`)
137
+ return value
138
+ }
139
+
140
+ /** Read an integer config value within an inclusive range. */
141
+ function configInteger(value, fallback, key, min, max) {
142
+ if (value === undefined || value === null) return fallback
143
+ if (!Number.isInteger(value) || value < min || value > max) {
144
+ throw new TypeError(`pdf-reader: ${key} must be an integer between ${min} and ${max}, got ${String(value)}`)
145
+ }
146
+ return value
147
+ }
148
+
149
+ /** Read a numeric config value within an inclusive range. */
150
+ function configNumber(value, fallback, key, min, max) {
151
+ if (value === undefined || value === null) return fallback
152
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {
153
+ throw new TypeError(`pdf-reader: ${key} must be a number between ${min} and ${max}, got ${String(value)}`)
154
+ }
155
+ return value
156
+ }
157
+
158
+ /**
159
+ * Apply defaults and range-check one raw plugin config.
160
+ * @param raw - the loader-validated config, a partial object, or undefined.
161
+ * @returns a complete config object.
162
+ * @throws TypeError on a value the plugin cannot honor.
163
+ */
164
+ export function normalizeConfig(raw) {
165
+ const source = raw ?? {}
166
+ if (typeof source !== 'object' || Array.isArray(source)) throw new TypeError('pdf-reader: config must be an object')
167
+ return {
168
+ enabled: configBoolean(source.enabled, DEFAULT_CONFIG.enabled, 'enabled'),
169
+ autoShare: configBoolean(source.autoShare, DEFAULT_CONFIG.autoShare, 'autoShare'),
170
+ autoShareSection: configBoolean(source.autoShareSection, DEFAULT_CONFIG.autoShareSection, 'autoShareSection'),
171
+ autoShareMinIntervalMs: configInteger(
172
+ source.autoShareMinIntervalMs,
173
+ DEFAULT_CONFIG.autoShareMinIntervalMs,
174
+ 'autoShareMinIntervalMs',
175
+ 0,
176
+ 86_400_000,
177
+ ),
178
+ autoSharePageDelta: configInteger(source.autoSharePageDelta, DEFAULT_CONFIG.autoSharePageDelta, 'autoSharePageDelta', 1, 1000),
179
+ autoShareMaxAgeMs: configInteger(source.autoShareMaxAgeMs, DEFAULT_CONFIG.autoShareMaxAgeMs, 'autoShareMaxAgeMs', 0, 31_536_000_000),
180
+ maxPagesPerCall: configInteger(
181
+ source.maxPagesPerCall,
182
+ DEFAULT_CONFIG.maxPagesPerCall,
183
+ 'maxPagesPerCall',
184
+ 1,
185
+ ABSOLUTE_MAX_PAGES_PER_CALL,
186
+ ),
187
+ defaultScale: configNumber(source.defaultScale, DEFAULT_CONFIG.defaultScale, 'defaultScale', 1, 3),
188
+ maxTextCharsPerPage: configInteger(
189
+ source.maxTextCharsPerPage,
190
+ DEFAULT_CONFIG.maxTextCharsPerPage,
191
+ 'maxTextCharsPerPage',
192
+ 0,
193
+ 100_000,
194
+ ),
195
+ maxSearchResults: configInteger(source.maxSearchResults, DEFAULT_CONFIG.maxSearchResults, 'maxSearchResults', 1, 500),
196
+ maxOutlineEntries: configInteger(source.maxOutlineEntries, DEFAULT_CONFIG.maxOutlineEntries, 'maxOutlineEntries', 1, 5000),
197
+ maxLabels: configInteger(source.maxLabels, DEFAULT_CONFIG.maxLabels, 'maxLabels', 0, 1000),
198
+ outlineDepth: configInteger(source.outlineDepth, DEFAULT_CONFIG.outlineDepth, 'outlineDepth', 0, 5),
199
+ debugCost: source.debugCost === undefined ? DEFAULT_CONFIG.debugCost : source.debugCost === true,
200
+ debug: source.debug === undefined ? DEFAULT_CONFIG.debug : source.debug === true,
201
+ allowOutsideWorkspace: source.allowOutsideWorkspace === true,
202
+ routeAllowedRoots: Array.isArray(source.routeAllowedRoots)
203
+ ? source.routeAllowedRoots.filter((entry) => typeof entry === 'string' && isAbsolute(entry))
204
+ : [],
205
+ maxFileBytes: configInteger(source.maxFileBytes, DEFAULT_CONFIG.maxFileBytes, 'maxFileBytes', 1, 8 * 1024 * 1024 * 1024),
206
+ maxRouteBodyBytes: configInteger(
207
+ source.maxRouteBodyBytes,
208
+ DEFAULT_CONFIG.maxRouteBodyBytes,
209
+ 'maxRouteBodyBytes',
210
+ 1024,
211
+ 4 * 1024 * 1024,
212
+ ),
213
+ infoCacheSize: configInteger(source.infoCacheSize, DEFAULT_CONFIG.infoCacheSize, 'infoCacheSize', 1, 64),
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Whether a request's Host header names the loopback interface.
219
+ *
220
+ * The route serves the local application only, and the webserver itself carries
221
+ * no authentication, so a request addressed to another name is refused.
222
+ * @param host - the raw Host header.
223
+ * @returns true for `127.0.0.1`, `localhost`, and `::1`, with any port.
224
+ */
225
+ export function loopbackHost(host) {
226
+ if (typeof host !== 'string' || host === '') return false
227
+ let raw
228
+ if (host.startsWith('[')) {
229
+ const end = host.indexOf(']')
230
+ if (end < 0) return false
231
+ raw = host.slice(1, end)
232
+ } else if (host === '::1') {
233
+ // Tolerate an unbracketed bare IPv6 loopback; a bracketed form is the
234
+ // RFC-correct Host header and is handled above.
235
+ raw = host
236
+ } else {
237
+ raw = host.split(':')[0]
238
+ }
239
+ const hostname = raw.toLowerCase()
240
+ return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1'
241
+ }
242
+
243
+ /** Answer one request with JSON and no CORS header. */
244
+ function sendJson(response, status, body) {
245
+ const text = JSON.stringify(body)
246
+ response.writeHead(status, {
247
+ 'content-type': 'application/json; charset=utf-8',
248
+ 'content-length': String(Buffer.byteLength(text)),
249
+ 'cache-control': 'no-store',
250
+ 'x-content-type-options': 'nosniff',
251
+ })
252
+ response.end(text)
253
+ }
254
+
255
+ /** One HTTP failure with the status and stable error token the route returns. */
256
+ function httpError(status, error, message = error) {
257
+ const failure = new Error(message)
258
+ failure.status = status
259
+ failure.error = error
260
+ return failure
261
+ }
262
+
263
+ /**
264
+ * Validate a route target exactly like `dsh-pdf-search` does.
265
+ * @param rawPath - the raw `path` query/body value.
266
+ * @param options - file-stat function and size cap.
267
+ * @returns `{ ok: true, path, stat }` or `{ ok: false, status, error }`.
268
+ */
269
+ export async function validateRouteTarget(rawPath, { statFile = stat, maxFileBytes = DEFAULT_CONFIG.maxFileBytes, allowedRoots = [] } = {}) {
270
+ if (typeof rawPath !== 'string' || rawPath.length === 0) {
271
+ return { ok: false, status: 400, error: 'invalid-path' }
272
+ }
273
+ if (!isAbsolute(rawPath)) return { ok: false, status: 400, error: 'invalid-path' }
274
+ const path = resolvePath(rawPath)
275
+ if (Array.isArray(allowedRoots) && allowedRoots.length > 0) {
276
+ const allowed = allowedRoots.some((root) => {
277
+ const rel = relative(resolvePath(root), path)
278
+ return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel))
279
+ })
280
+ if (!allowed) return { ok: false, status: 403, error: 'path-outside-allowed-roots' }
281
+ }
282
+ if (!/\.pdf$/iu.test(path)) return { ok: false, status: 400, error: 'invalid-path' }
283
+ let info
284
+ try {
285
+ info = await statFile(path)
286
+ } catch {
287
+ return { ok: false, status: 404, error: 'missing' }
288
+ }
289
+ if (typeof info?.isFile !== 'function' || !info.isFile()) {
290
+ return { ok: false, status: 400, error: 'not-a-file' }
291
+ }
292
+ if (typeof info.size !== 'number' || info.size > maxFileBytes) {
293
+ return { ok: false, status: 413, error: 'file-too-large' }
294
+ }
295
+ return { ok: true, path, stat: info }
296
+ }
297
+
298
+ /** Read a bounded JSON request body. */
299
+ async function readJsonBody(request, limit) {
300
+ if (typeof request?.[Symbol.asyncIterator] !== 'function') throw httpError(400, 'invalid-body')
301
+ const chunks = []
302
+ let bytes = 0
303
+ for await (const chunk of request) {
304
+ const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
305
+ bytes += buffer.byteLength
306
+ if (bytes > limit) throw httpError(413, 'body-too-large')
307
+ chunks.push(buffer)
308
+ }
309
+ if (bytes === 0) throw httpError(400, 'invalid-json', 'empty request body')
310
+ let value
311
+ try {
312
+ value = JSON.parse(Buffer.concat(chunks).toString('utf8'))
313
+ } catch {
314
+ throw httpError(400, 'invalid-json')
315
+ }
316
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
317
+ throw httpError(400, 'invalid-json', 'request body must be a JSON object')
318
+ }
319
+ return value
320
+ }
321
+
322
+ /**
323
+ * Cache `getInfo` by path, mtime, and size. Both the routes and the tools use
324
+ * one instance so a page change never re-parses the outline for every caller.
325
+ * @param options - `getInfo` loader, stat function, and LRU bound.
326
+ */
327
+ export function createInfoCache({ getInfo, statFile = stat, limit = DEFAULT_CONFIG.infoCacheSize } = {}) {
328
+ if (typeof getInfo !== 'function') throw new TypeError('createInfoCache: getInfo must be a function')
329
+ const entries = new Map()
330
+
331
+ async function load(path, knownStat) {
332
+ const info = knownStat ?? (await statFile(path))
333
+ const key = `${path}\u0000${String(info.mtimeMs ?? '')}\u0000${String(info.size ?? '')}`
334
+ const cached = entries.get(key)
335
+ if (cached !== undefined) {
336
+ entries.delete(key)
337
+ entries.set(key, cached)
338
+ return cached
339
+ }
340
+ const value = await getInfo(path)
341
+ if (value === null || typeof value !== 'object') {
342
+ throw new Error(`pdf-reader: getInfo returned no info for "${path}"`)
343
+ }
344
+ const entry = { info: value, stat: info }
345
+ entries.set(key, entry)
346
+ while (entries.size > limit) {
347
+ const oldest = entries.keys().next()
348
+ if (oldest.done === true) break
349
+ entries.delete(oldest.value)
350
+ }
351
+ return entry
352
+ }
353
+
354
+ return {
355
+ load,
356
+ clear() {
357
+ entries.clear()
358
+ },
359
+ get size() {
360
+ return entries.size
361
+ },
362
+ }
363
+ }
364
+
365
+ /**
366
+ * In-memory reading positions: one per session and one latest per path.
367
+ * Sessions are the only source used for auto-share; path positions exist for
368
+ * the un-scoped GET /current form and for diagnostics.
369
+ * @param options - clock and LRU bounds.
370
+ */
371
+ export function createPositionStore({ now = Date.now, maxSessions = 200, maxPaths = 100 } = {}) {
372
+ const sessions = new Map()
373
+ const paths = new Map()
374
+
375
+ function touch(map, key, value, limit) {
376
+ map.delete(key)
377
+ map.set(key, value)
378
+ while (map.size > limit) {
379
+ const oldest = map.keys().next()
380
+ if (oldest.done === true) break
381
+ map.delete(oldest.value)
382
+ }
383
+ }
384
+
385
+ return {
386
+ record({ path, pdfPage, printedPage = null, sessionId = null, printedOffset = null }) {
387
+ const position = {
388
+ path,
389
+ pdfPage,
390
+ printedPage,
391
+ printedOffset,
392
+ sessionId: typeof sessionId === 'string' ? sessionId : null,
393
+ updatedAt: now(),
394
+ }
395
+ if (position.sessionId !== null) touch(sessions, position.sessionId, position, maxSessions)
396
+ touch(paths, path, position, maxPaths)
397
+ return position
398
+ },
399
+ forSession(sessionId) {
400
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined
401
+ return sessions.get(sessionId)
402
+ },
403
+ forPath(path) {
404
+ return paths.get(path)
405
+ },
406
+ clear() {
407
+ sessions.clear()
408
+ paths.clear()
409
+ },
410
+ get sessionCount() {
411
+ return sessions.size
412
+ },
413
+ get pathCount() {
414
+ return paths.size
415
+ },
416
+ }
417
+ }
418
+
419
+ /** Normalize unicode dashes so a pasted page range parses. */
420
+ function normalizeDashes(value) {
421
+ return value.replace(/[\u2010-\u2015\u2212]/gu, '-')
422
+ }
423
+
424
+ /**
425
+ * Parse `"132-134"`, `"132,135"`, `[132,133,134]`, or `"132-134,137"`.
426
+ * Results are deduplicated and ascending.
427
+ * @param input - the raw `pages` argument.
428
+ * @param options - maximum accepted pages and an expansion guard.
429
+ * @returns ascending, deduplicated page numbers as supplied.
430
+ * @throws Error with code PDF_READER_TOO_MANY_PAGES when the cap is exceeded.
431
+ */
432
+ export function parsePageSelection(input, { maxPages = DEFAULT_CONFIG.maxPagesPerCall, hardLimit = PAGE_SELECTION_HARD_LIMIT } = {}) {
433
+ const expanded = []
434
+ let overflow = false
435
+
436
+ function pushPage(page) {
437
+ if (expanded.length >= hardLimit) {
438
+ overflow = true
439
+ return
440
+ }
441
+ expanded.push(page)
442
+ }
443
+
444
+ function addToken(token) {
445
+ if (typeof token === 'number') {
446
+ if (!Number.isInteger(token) || token < 1 || token > 1_000_000) {
447
+ throw codedError(`page numbers must be positive integers, got ${String(token)}`, 'PDF_READER_INVALID_PAGE')
448
+ }
449
+ pushPage(token)
450
+ return
451
+ }
452
+ if (typeof token !== 'string') {
453
+ throw codedError(`page selections must be strings or integers, got ${typeof token}`, 'PDF_READER_INVALID_PAGE')
454
+ }
455
+ const text = normalizeDashes(token).trim()
456
+ if (text === '') return
457
+ const range = /^(\d+)\s*-\s*(\d+)$/u.exec(text)
458
+ if (range !== null) {
459
+ const start = Number(range[1])
460
+ const end = Number(range[2])
461
+ if (start < 1 || end < 1 || start > 1_000_000 || end > 1_000_000) {
462
+ throw codedError(`page numbers must be positive integers, got "${text}"`, 'PDF_READER_INVALID_PAGE')
463
+ }
464
+ if (start > end) {
465
+ throw codedError(`page range "${text}" starts after it ends`, 'PDF_READER_INVALID_PAGE')
466
+ }
467
+ if (end - start + 1 > hardLimit) {
468
+ throw codedError(`page range "${text}" is too large to expand safely`, 'PDF_READER_TOO_MANY_PAGES')
469
+ }
470
+ for (let page = start; page <= end; page += 1) pushPage(page)
471
+ return
472
+ }
473
+ if (/^\d+$/u.test(text)) {
474
+ const page = Number(text)
475
+ if (page < 1 || page > 1_000_000) {
476
+ throw codedError(`page numbers must be positive integers, got "${text}"`, 'PDF_READER_INVALID_PAGE')
477
+ }
478
+ pushPage(page)
479
+ return
480
+ }
481
+ throw codedError(`cannot parse page selection "${text}"`, 'PDF_READER_INVALID_PAGE')
482
+ }
483
+
484
+ if (Array.isArray(input)) {
485
+ for (const item of input) addToken(item)
486
+ } else if (typeof input === 'string') {
487
+ for (const token of input.split(',')) addToken(token)
488
+ } else {
489
+ addToken(input)
490
+ }
491
+
492
+ if (overflow) {
493
+ throw codedError(`page selection expands past ${hardLimit} pages`, 'PDF_READER_TOO_MANY_PAGES')
494
+ }
495
+ if (expanded.length === 0) {
496
+ throw codedError('pages must contain at least one page number', 'PDF_READER_INVALID_PAGE')
497
+ }
498
+ const unique = [...new Set(expanded)].sort((a, b) => a - b)
499
+ if (unique.length > maxPages) {
500
+ throw codedError(
501
+ `requested ${unique.length} pages, but one call may read at most ${maxPages}; split the request into smaller calls`,
502
+ 'PDF_READER_TOO_MANY_PAGES',
503
+ )
504
+ }
505
+ return unique
506
+ }
507
+
508
+ /**
509
+ * Parse a crop rectangle: four fractions with a top-left origin.
510
+ * @param input - `"0,0,1,0.55"`, `[0,0,1,0.55]`, `{x,y,w,h}`, or undefined.
511
+ * @returns `{ x, y, w, h }` fractions, or undefined when no crop was requested.
512
+ */
513
+ export function parseCrop(input) {
514
+ if (input === undefined || input === null) return undefined
515
+
516
+ // The skill documents the core's native shape, {x,y,w,h}; accept it directly.
517
+ if (typeof input === 'object' && !Array.isArray(input)) {
518
+ const { x, y, w, h } = input
519
+ const numbers = [x, y, w, h]
520
+ if (numbers.some((value) => typeof value !== 'number' || !Number.isFinite(value))) {
521
+ throw codedError('crop object must contain finite numeric x, y, w, and h', 'PDF_READER_INVALID_CROP')
522
+ }
523
+ if (x < 0 || y < 0 || w <= 0 || h <= 0 || x + w > 1 || y + h > 1) {
524
+ throw codedError(
525
+ 'crop must satisfy x,y >= 0, w,h > 0, x+w <= 1, and y+h <= 1',
526
+ 'PDF_READER_INVALID_CROP',
527
+ )
528
+ }
529
+ return { x, y, w, h }
530
+ }
531
+
532
+ let values
533
+ if (Array.isArray(input)) {
534
+ values = input
535
+ } else if (typeof input === 'string') {
536
+ values = input.split(',').map((part) => part.trim())
537
+ } else {
538
+ throw codedError(
539
+ 'crop must be "x0,y0,x1,y1", [x0,y0,x1,y1], or {x,y,w,h}',
540
+ 'PDF_READER_INVALID_CROP',
541
+ )
542
+ }
543
+ if (values.length !== 4) {
544
+ throw codedError('crop must contain exactly four values', 'PDF_READER_INVALID_CROP')
545
+ }
546
+ const numbers = values.map((value) => (typeof value === 'number' ? value : Number(value)))
547
+ if (numbers.some((value) => !Number.isFinite(value))) {
548
+ throw codedError('crop values must be finite numbers', 'PDF_READER_INVALID_CROP')
549
+ }
550
+ const [x0, y0, x1, y1] = numbers
551
+ if (x0 < 0 || y0 < 0 || x1 > 1 || y1 > 1 || x0 >= x1 || y0 >= y1) {
552
+ throw codedError('crop must satisfy 0 <= x0 < x1 <= 1 and 0 <= y0 < y1 <= 1', 'PDF_READER_INVALID_CROP')
553
+ }
554
+ return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }
555
+ }
556
+
557
+ /**
558
+ * Decide whether a PDF exposes numeric printed page numbers.
559
+ * @param info - a `getInfo` result.
560
+ * @returns whether printed numbers are usable and the PDF→printed offset.
561
+ */
562
+ export function detectPageMode(info) {
563
+ const labels = Array.isArray(info?.labels) ? info.labels : []
564
+ const hasNumericLabel = labels.some((label) => typeof label?.printed === 'number' && Number.isFinite(label.printed))
565
+ const offset = Number.isInteger(info?.printedOffset) ? info.printedOffset : 0
566
+ return { detectable: hasNumericLabel || offset !== 0, offset }
567
+ }
568
+
569
+ /**
570
+ * Resolve the printed label for one physical page.
571
+ *
572
+ * Prefers the browser-reported label, then the page-label map (which covers
573
+ * roman front matter), then `pdfPage - offset` when the offset is meaningful.
574
+ * Never returns a non-positive body-page number.
575
+ *
576
+ * @param info - a `getInfo` result, or null when metadata is unavailable.
577
+ * @param pdfPage - the 1-based physical page.
578
+ * @param reported - optional label reported by the browser.
579
+ * @returns a number, a label string, or null when no printed page is known.
580
+ */
581
+ export function printedPageFor(info, pdfPage, reported = null) {
582
+ if (reported !== null && reported !== undefined && reported !== '') return reported
583
+ const labels = Array.isArray(info?.labels) ? info.labels : []
584
+ const label = labels.find((entry) => Number(entry?.pdf) === pdfPage)
585
+ if (label !== undefined && label.printed !== null && label.printed !== undefined) return label.printed
586
+ const detected = detectPageMode(info)
587
+ if (detected.detectable) {
588
+ const printable = pdfPage - detected.offset
589
+ if (printable >= 1) return printable
590
+ }
591
+ return null
592
+ }
593
+
594
+ /**
595
+ * Resolve a page selection to physical PDF pages.
596
+ * @param options - selection, explicit mode, `getInfo` result, and page cap.
597
+ * @returns physical pages, the resolved mode, and the offset used.
598
+ */
599
+ export function resolveRequestedPages({ selection, pageNumbers, info, maxPages = DEFAULT_CONFIG.maxPagesPerCall }) {
600
+ const requested = parsePageSelection(selection, { maxPages })
601
+ const detected = detectPageMode(info)
602
+ const mode = pageNumbers ?? (detected.detectable ? 'printed' : 'pdf')
603
+ if (mode !== 'printed' && mode !== 'pdf') {
604
+ throw codedError(`page_numbers must be "printed" or "pdf", got ${JSON.stringify(mode)}`, 'PDF_READER_INVALID_PAGE_MODE')
605
+ }
606
+ if (mode === 'printed' && !detected.detectable) {
607
+ throw codedError(
608
+ 'this PDF has no numeric printed-page labels; pass page_numbers:"pdf" or use physical PDF page numbers',
609
+ 'PDF_READER_NO_PRINTED_PAGES',
610
+ )
611
+ }
612
+ const pageCount = Number.isInteger(info?.pageCount) && info.pageCount > 0 ? info.pageCount : null
613
+ const pdfPages = requested.map((page) => (mode === 'printed' ? page + detected.offset : page))
614
+ for (let index = 0; index < pdfPages.length; index += 1) {
615
+ const pdf = pdfPages[index]
616
+ if (pdf < 1) {
617
+ throw codedError(
618
+ `printed page ${requested[index]} maps to PDF page ${pdf}, before the first page (offset ${detected.offset})`,
619
+ 'PDF_READER_INVALID_PAGE',
620
+ )
621
+ }
622
+ if (pageCount !== null && pdf > pageCount) {
623
+ const context = mode === 'printed' ? ` (printed page ${requested[index]}, offset ${detected.offset})` : ''
624
+ throw codedError(`PDF page ${pdf} is outside this document (${pageCount} pages)${context}`, 'PDF_READER_INVALID_PAGE')
625
+ }
626
+ }
627
+ return { requested, pdfPages, mode, printedOffset: detected.detectable ? detected.offset : 0 }
628
+ }
629
+
630
+ /** Coerce page labels to the JSON shape the output schemas allow. */
631
+ function sanitizeLabels(labels, limit) {
632
+ if (!Array.isArray(labels)) return undefined
633
+ return labels.slice(0, limit).map((label) => ({
634
+ pdf: Number(label?.pdf),
635
+ printed: label?.printed ?? null,
636
+ }))
637
+ }
638
+
639
+ /** Coerce an outline entry to the JSON shape this plugin exposes. */
640
+ function outlineEntry(entry) {
641
+ return {
642
+ title: String(entry?.title ?? ''),
643
+ pdf: Number(entry?.pdf),
644
+ printed: entry?.printed ?? null,
645
+ depth: Number.isInteger(entry?.depth) ? entry.depth : 0,
646
+ }
647
+ }
648
+
649
+ /**
650
+ * Keep the first `limit` outline entries.
651
+ * @returns `{ outline, truncated }`.
652
+ */
653
+ export function sanitizeOutline(outline, limit = DEFAULT_CONFIG.maxOutlineEntries, maxDepth = Infinity) {
654
+ if (!Array.isArray(outline)) return { outline: [], truncated: false }
655
+ const eligible = outline.filter(
656
+ (entry) => entry !== null && typeof entry === 'object' && Number.isFinite(Number(entry.pdf)),
657
+ )
658
+ const entries = eligible
659
+ .filter((entry) => (Number.isInteger(entry.depth) ? entry.depth : 0) <= maxDepth)
660
+ .slice(0, limit)
661
+ .map(outlineEntry)
662
+ return { outline: entries, truncated: entries.length < eligible.length }
663
+ }
664
+
665
+ /**
666
+ * Find the ancestor chain of outline entries containing one PDF page.
667
+ * Entries must be in document order, as `getInfo` returns them.
668
+ * @param outline - outline entries with `pdf` and `depth`.
669
+ * @param pdfPage - the physical page to locate.
670
+ * @param options - maximum chain length.
671
+ * @returns ancestors from chapter to the deepest section; empty before the first entry.
672
+ */
673
+ export function sectionTrailForPage(outline, pdfPage, { maxDepth = 6 } = {}) {
674
+ if (!Array.isArray(outline) || !Number.isFinite(pdfPage)) return []
675
+ const eligible = outline.filter((entry) => entry !== null && typeof entry === 'object' && Number.isFinite(entry.pdf) && entry.pdf <= pdfPage)
676
+ if (eligible.length === 0) return []
677
+ const chain = []
678
+ let depth = Number.POSITIVE_INFINITY
679
+ for (let index = eligible.length - 1; index >= 0; index -= 1) {
680
+ const entry = eligible[index]
681
+ const entryDepth = Number.isInteger(entry.depth) ? entry.depth : 0
682
+ if (entryDepth < depth) {
683
+ chain.unshift(outlineEntry(entry))
684
+ depth = entryDepth
685
+ if (chain.length >= maxDepth) break
686
+ }
687
+ }
688
+ return chain
689
+ }
690
+
691
+ /** Book title used in prose: the PDF basename without its extension. */
692
+ export function titleFromPath(path) {
693
+ return basename(String(path)).replace(/\.pdf$/iu, '')
694
+ }
695
+
696
+ /**
697
+ * Render the auto-share message. Source attribution is part of the text and
698
+ * the message source, so compaction and replay can recognize it.
699
+ * @param options - position, title, section chain, and page-number mode.
700
+ * @returns one short user-role message body.
701
+ */
702
+ export function renderReadingContext({
703
+ path,
704
+ pdfPage,
705
+ printedPage = null,
706
+ title,
707
+ sectionTrail = [],
708
+ printedOffset = 0,
709
+ mode,
710
+ }) {
711
+ const book = title ?? titleFromPath(path)
712
+ const hasPrinted = printedPage !== null && printedPage !== undefined && printedPage !== ''
713
+ const printable = hasPrinted ? printedPage : null
714
+ const resolvedMode = mode ?? (printable === null ? 'pdf' : 'printed')
715
+ const trail = sectionTrail
716
+ .map((entry) => (typeof entry === 'string' ? entry : entry?.title))
717
+ .filter((entry) => typeof entry === 'string' && entry.length > 0)
718
+ const sectionText = trail.join(' \u203a ')
719
+ // Deliberately minimal: a position marker only. Whether and what to read is
720
+ // the model's decision (see SKILL.md); no tool-call hint is injected, which
721
+ // keeps every turn cheap and avoids steering the model.
722
+ const label =
723
+ printable === null
724
+ ? `PDF ${pdfPage}`
725
+ : `printed ${printable} (PDF ${pdfPage})`
726
+ const suffix = sectionText === '' ? '' : ` \u00b7 ${sectionText}`
727
+ return `[dsh-pdf-reader] \u6b63\u5728\u9605\u8bfb\u300a${book}\u300b${label}${suffix}`
728
+ }
729
+
730
+ /** Whether two injection state records refer to the same page. */
731
+ function sameInjectedPage(state, position) {
732
+ return state.lastInjectedPath === position.path && state.lastInjectedPdfPage === position.pdfPage
733
+ }
734
+
735
+ /**
736
+ * Decide whether this turn should receive a reading-position message.
737
+ *
738
+ * One hint per turn at most: the guard is the turn number, not the step, and
739
+ * the hint is only sent when the reported page changed since the last one.
740
+ * Pure: callers own the state map and the clock.
741
+ * @param options - position, prior state, turn, clock, and config.
742
+ * @returns `{ inject, reason, nextState }`; `nextState` is unchanged when false.
743
+ */
744
+ export function decideAutoShare({ position, state = INITIAL_AUTO_SHARE_STATE, now, turn, config = DEFAULT_CONFIG }) {
745
+ const keep = { inject: false, reason: '', nextState: state }
746
+ if (!config.autoShare) return { ...keep, reason: 'disabled' }
747
+ if (position === null || position === undefined) return { ...keep, reason: 'no-position' }
748
+ if (sameInjectedPage(state, position)) return { ...keep, reason: 'unchanged' }
749
+ if (config.autoShareMaxAgeMs > 0) {
750
+ if (typeof position.updatedAt !== 'number') return { ...keep, reason: 'unknown-age' }
751
+ if (now - position.updatedAt > config.autoShareMaxAgeMs) return { ...keep, reason: 'stale' }
752
+ }
753
+ if (Number.isInteger(turn) && state.lastInjectedTurn === turn) return { ...keep, reason: 'same-turn' }
754
+ if (config.autoShareMinIntervalMs > 0 && state.lastInjectedAt !== null && state.lastInjectedAt !== undefined) {
755
+ const elapsed = now - state.lastInjectedAt
756
+ if (!(elapsed >= config.autoShareMinIntervalMs)) return { ...keep, reason: 'throttled' }
757
+ }
758
+ if (
759
+ config.autoSharePageDelta > 1 &&
760
+ state.lastInjectedPdfPage !== null &&
761
+ state.lastInjectedPath === position.path &&
762
+ Math.abs(position.pdfPage - state.lastInjectedPdfPage) < config.autoSharePageDelta
763
+ ) {
764
+ return { ...keep, reason: 'page-delta' }
765
+ }
766
+ return {
767
+ inject: true,
768
+ reason: 'due',
769
+ nextState: {
770
+ lastInjectedPath: position.path,
771
+ lastInjectedPdfPage: position.pdfPage,
772
+ lastInjectedAt: now,
773
+ lastInjectedTurn: Number.isInteger(turn) ? turn : null,
774
+ },
775
+ }
776
+ }
777
+
778
+ /**
779
+ * Build the `agent/pre-step` listener. It delegates first, appends at most one
780
+ * source-attributed user message, and records state only after appending.
781
+ * @param options - config, position store, optional info cache, clock, message factory.
782
+ * @returns a Cordis waterfall listener for `agent/pre-step`.
783
+ */
784
+ export function createAutoShareListener({
785
+ config,
786
+ positions,
787
+ infoCache = null,
788
+ now = Date.now,
789
+ createMessage = createUserMessage,
790
+ maxStates = 500,
791
+ }) {
792
+ const states = new Map()
793
+
794
+ function remember(sessionId, state) {
795
+ states.delete(sessionId)
796
+ states.set(sessionId, state)
797
+ while (states.size > maxStates) {
798
+ const oldest = states.keys().next()
799
+ if (oldest.done === true) break
800
+ states.delete(oldest.value)
801
+ }
802
+ }
803
+
804
+ return async function pdfReaderPreStep(payload, next) {
805
+ const decision = await next()
806
+ if (!config.autoShare) return decision
807
+ if (decision === null || typeof decision !== 'object' || decision.kind === 'reject') return decision
808
+ if (payload?.signal?.aborted === true) return decision
809
+ const sessionId = payload?.agent?.session?.id
810
+ if (typeof sessionId !== 'string' || sessionId === '') return decision
811
+ const position = positions.forSession(sessionId)
812
+ if (position === undefined) return decision
813
+ const state = states.get(sessionId) ?? INITIAL_AUTO_SHARE_STATE
814
+ const verdict = decideAutoShare({
815
+ position,
816
+ state,
817
+ now: now(),
818
+ turn: payload.turn,
819
+ config,
820
+ })
821
+ if (!verdict.inject) return decision
822
+
823
+ let trail = []
824
+ let info = null
825
+ if (config.autoShareSection && infoCache !== null) {
826
+ try {
827
+ info = (await infoCache.load(position.path)).info
828
+ trail = sectionTrailForPage(info.outline, position.pdfPage)
829
+ } catch {
830
+ trail = []
831
+ }
832
+ }
833
+ const detected = detectPageMode(info)
834
+ const printable = printedPageFor(info, position.pdfPage, position.printedPage)
835
+ const mode = typeof printable === 'number' ? 'printed' : 'pdf'
836
+ const text = renderReadingContext({
837
+ path: position.path,
838
+ pdfPage: position.pdfPage,
839
+ printedPage: printable,
840
+ title: titleFromPath(position.path),
841
+ sectionTrail: trail,
842
+ printedOffset: detected.offset,
843
+ mode,
844
+ })
845
+ const message = createMessage({
846
+ content: [{ type: 'text', text }],
847
+ source: {
848
+ kind: name,
849
+ form: 'snapshot',
850
+ sections: [{ name, text }],
851
+ },
852
+ })
853
+ remember(sessionId, verdict.nextState)
854
+ const priorMessages = Array.isArray(decision.messages) ? decision.messages : []
855
+ return { ...decision, messages: [...priorMessages, message] }
856
+ }
857
+ }
858
+
859
+ /** Normalize a printed page supplied by the browser: number or short label. */
860
+ export function normalizePrintedPage(value) {
861
+ if (value === undefined || value === null) return null
862
+ if (typeof value === 'number') {
863
+ if (!Number.isInteger(value) || value < 0 || value > 1_000_000) {
864
+ throw codedError('printedPage must be a non-negative integer', 'PDF_READER_INVALID_PRINTED_PAGE')
865
+ }
866
+ return value
867
+ }
868
+ if (typeof value === 'string') {
869
+ const text = value.trim()
870
+ if (text === '') return null
871
+ if (text.length > 24 || /[\u0000-\u001f\u007f]/u.test(text)) {
872
+ throw codedError('printedPage is not a usable page label', 'PDF_READER_INVALID_PRINTED_PAGE')
873
+ }
874
+ if (/^\d+$/u.test(text)) return Number(text)
875
+ return text
876
+ }
877
+ throw codedError('printedPage must be a number or a page label string', 'PDF_READER_INVALID_PRINTED_PAGE')
878
+ }
879
+
880
+ /** Resolve a model-supplied path against the session cwd. */
881
+ export function resolveToolPath(rawPath, exec) {
882
+ if (typeof rawPath !== 'string' || rawPath.trim() === '') {
883
+ throw codedError('path must be a non-empty string', 'PDF_READER_INVALID_PATH')
884
+ }
885
+ if (isAbsolute(rawPath)) return resolvePath(rawPath)
886
+ const cwd = exec?.agent?.session?.header?.cwd
887
+ if (typeof cwd === 'string' && isAbsolute(cwd)) return resolvePath(cwd, rawPath)
888
+ throw codedError(`path must be absolute, got "${rawPath}"`, 'PDF_READER_INVALID_PATH')
889
+ }
890
+
891
+ /** Refuse a tool path outside the session workspace unless config allows it. */
892
+ export function assertInsideWorkspace(path, exec, config = DEFAULT_CONFIG) {
893
+ if (config.allowOutsideWorkspace === true) return path
894
+ const cwd = exec?.agent?.session?.header?.cwd
895
+ if (typeof cwd !== 'string' || !isAbsolute(cwd)) return path
896
+ const root = resolvePath(cwd)
897
+ const rel = relative(root, path)
898
+ if (rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel))) return path
899
+ throw codedError(
900
+ `"${path}" is outside the session workspace (${root}); set allowOutsideWorkspace:true to read it`,
901
+ 'PDF_OUTSIDE_WORKSPACE',
902
+ )
903
+ }
904
+
905
+ /** Stat and validate a tool target, with model-facing failures. */
906
+ export async function assertPdfFile(path, { statFile = stat, maxFileBytes = DEFAULT_CONFIG.maxFileBytes } = {}) {
907
+ if (!/\.pdf$/iu.test(path)) throw codedError(`cannot read "${path}": not a .pdf file`, 'PDF_NOT_FILE')
908
+ let info
909
+ try {
910
+ info = await statFile(path)
911
+ } catch (error) {
912
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
913
+ throw codedError(`cannot read "${path}": not found`, 'PDF_NOT_FOUND')
914
+ }
915
+ throw codedError(`cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 'PDF_UNREADABLE')
916
+ }
917
+ if (typeof info?.isFile !== 'function' || !info.isFile()) {
918
+ throw codedError(`cannot read "${path}": not a regular file`, 'PDF_NOT_FILE')
919
+ }
920
+ if (typeof info.size !== 'number' || info.size > maxFileBytes) {
921
+ throw codedError(`cannot read "${path}": file is larger than the ${maxFileBytes}-byte limit`, 'PDF_READER_FILE_TOO_LARGE')
922
+ }
923
+ return info
924
+ }
925
+
926
+ /** Re-brand a structured image outcome into an `ImageBlock` attachment. */
927
+ export function imageRefFromValue(image) {
928
+ return {
929
+ attachmentId: image.attachmentId,
930
+ mediaType: image.mediaType,
931
+ bytes: image.bytes,
932
+ width: image.width,
933
+ height: image.height,
934
+ ...(image.name === undefined ? {} : { name: image.name }),
935
+ ...(image.originalDimensions === undefined ? {} : { originalDimensions: { ...image.originalDimensions } }),
936
+ }
937
+ }
938
+
939
+ /** Escape text placed inside the small XML-ish envelopes in tool content. */
940
+ function escapeEnvelope(text) {
941
+ return String(text).replace(/&/gu, '&amp;').replace(/</gu, '&lt;').replace(/>/gu, '&gt;')
942
+ }
943
+
944
+ const IMAGE_VALUE_SCHEMA = {
945
+ type: 'object',
946
+ additionalProperties: false,
947
+ required: true,
948
+ properties: {
949
+ attachmentId: { type: 'string', required: true },
950
+ mediaType: { type: 'string', enum: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], required: true },
951
+ bytes: { type: 'integer', required: true },
952
+ width: { type: 'integer', required: true },
953
+ height: { type: 'integer', required: true },
954
+ name: { type: 'string' },
955
+ originalDimensions: {
956
+ type: 'object',
957
+ additionalProperties: false,
958
+ properties: {
959
+ width: { type: 'integer', required: true },
960
+ height: { type: 'integer', required: true },
961
+ },
962
+ },
963
+ },
964
+ }
965
+
966
+ const PRINTED_PAGE_SCHEMA = {
967
+ oneOf: [{ type: 'integer' }, { type: 'string' }, { type: 'null' }],
968
+ }
969
+
970
+ const SECTION_SCHEMA = {
971
+ type: 'object',
972
+ additionalProperties: false,
973
+ properties: {
974
+ title: { type: 'string', required: true },
975
+ pdf: { type: 'integer', required: true },
976
+ printed: { ...PRINTED_PAGE_SCHEMA },
977
+ depth: { type: 'integer', required: true },
978
+ },
979
+ }
980
+
981
+ /** Render one `pdf_read_pages` outcome as text envelopes plus image blocks. */
982
+ export function renderPageReadContent(value) {
983
+ const summary = value.pages
984
+ .map((page) => `PDF ${page.pdf}${page.printed === page.pdf ? '' : ` / printed ${page.printed}`}`)
985
+ .join(', ')
986
+ const blocks = [
987
+ {
988
+ type: 'text',
989
+ text: `<path>${escapeEnvelope(value.path)}</path>\n<book>${escapeEnvelope(value.book)}</book>\n<pages mode="${value.pageNumbers}" printed-offset="${value.printedOffset}">${summary}</pages>`,
990
+ },
991
+ ]
992
+ for (const page of value.pages) {
993
+ const body =
994
+ page.text !== undefined
995
+ ? page.text
996
+ : page.textUnavailable === true
997
+ ? '(no text layer on this page; read the image)'
998
+ : '(text not requested; read the image)'
999
+ const truncation = page.textTruncated === true ? '\n(text truncated)' : ''
1000
+ blocks.push({
1001
+ type: 'text',
1002
+ text: `<page pdf="${page.pdf}" printed="${page.printed}">\n${body}${truncation}\n</page>`,
1003
+ })
1004
+ blocks.push({ type: 'image', attachment: imageRefFromValue(page.image) })
1005
+ }
1006
+ return blocks
1007
+ }
1008
+
1009
+ /** Check the calling route declares image input before rendering anything. */
1010
+ async function assertImageCapableRoute(ctx, exec, requestedPath) {
1011
+ const routed = exec.agent?.session?.requestHeader?.()?.config
1012
+ const provider = routed?.provider ?? exec.agent?.options?.provider
1013
+ const model = routed?.model ?? exec.agent?.options?.model
1014
+ const llm = ctx.get('llm')
1015
+ if (provider === undefined || model === undefined || llm === undefined) {
1016
+ throw codedError(
1017
+ `cannot read "${requestedPath}" as images: the current model route could not be resolved`,
1018
+ 'PDF_READER_NO_IMAGE_ROUTE',
1019
+ )
1020
+ }
1021
+ let active
1022
+ try {
1023
+ active = await llm.resolveModelInfo(provider, model, exec.signal)
1024
+ } catch (error) {
1025
+ return
1026
+ }
1027
+ if (!Array.isArray(active?.inputModalities) || !active.inputModalities.includes('image')) {
1028
+ throw codedError(
1029
+ `cannot read "${requestedPath}" as images: model "${model}" does not declare image input; switch to an image-capable model`,
1030
+ 'PDF_READER_NO_IMAGE_INPUT',
1031
+ )
1032
+ }
1033
+ }
1034
+
1035
+ /** Lazy import of the PDF text/info core with a clear missing-engine error. */
1036
+ function createLazyCore() {
1037
+ let pdfPromise
1038
+ let renderPromise
1039
+
1040
+ function loadPdf() {
1041
+ pdfPromise ??= import('./core/pdf.js').catch((error) => {
1042
+ pdfPromise = undefined
1043
+ throw new Error(
1044
+ `dsh-pdf-reader: PDF core is unavailable (lib/core/pdf.js missing or failed to load): ${error instanceof Error ? error.message : String(error)}`,
1045
+ { cause: error },
1046
+ )
1047
+ })
1048
+ return pdfPromise
1049
+ }
1050
+
1051
+ function loadRender() {
1052
+ renderPromise ??= import('./core/render.js').catch((error) => {
1053
+ renderPromise = undefined
1054
+ throw new Error(
1055
+ `dsh-pdf-reader: PDF renderer is unavailable (lib/core/render.js missing or failed to load): ${error instanceof Error ? error.message : String(error)}`,
1056
+ { cause: error },
1057
+ )
1058
+ })
1059
+ return renderPromise
1060
+ }
1061
+
1062
+ return {
1063
+ async getInfo(path) {
1064
+ return (await loadPdf()).getInfo(path)
1065
+ },
1066
+ async getPageText(path, pdfPage) {
1067
+ return (await loadPdf()).getPageText(path, pdfPage)
1068
+ },
1069
+ async searchPdf(path, query, options) {
1070
+ return (await loadPdf()).searchPdf(path, query, options)
1071
+ },
1072
+ async renderPagesToPng(path, pages, options) {
1073
+ return (await loadRender()).renderPagesToPng(path, pages, options)
1074
+ },
1075
+ }
1076
+ }
1077
+
1078
+ /** Build the route handler. Exported so tests can use fake core and stat. */
1079
+ export function createRouteHandler({ config, pdfCore, infoCache, positions, statFile = stat }) {
1080
+ async function loadPositionFor(sessionId, path) {
1081
+ if (typeof sessionId === 'string' && sessionId !== '') {
1082
+ return positions.forSession(sessionId)
1083
+ }
1084
+ if (typeof path === 'string' && path !== '') {
1085
+ if (!isAbsolute(path) || !/\.pdf$/iu.test(path)) throw httpError(400, 'invalid-path')
1086
+ return positions.forPath(resolvePath(path))
1087
+ }
1088
+ return undefined
1089
+ }
1090
+
1091
+ async function currentResponse(position) {
1092
+ if (position === undefined) return { ok: true, current: null }
1093
+ let info = null
1094
+ try {
1095
+ info = (await infoCache.load(position.path)).info
1096
+ } catch {
1097
+ info = null
1098
+ }
1099
+ const printedPage = printedPageFor(info, position.pdfPage, position.printedPage)
1100
+ const trail = info === null ? [] : sectionTrailForPage(info.outline, position.pdfPage)
1101
+ return {
1102
+ ok: true,
1103
+ path: position.path,
1104
+ pdfPage: position.pdfPage,
1105
+ printedPage,
1106
+ ...(trail.length === 0
1107
+ ? {}
1108
+ : {
1109
+ section: trail[trail.length - 1],
1110
+ sectionTrail: trail.map((entry) => entry.title),
1111
+ }),
1112
+ }
1113
+ }
1114
+
1115
+ async function handle(request, response) {
1116
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1')
1117
+ if (!url.pathname.startsWith(`${ROUTE_PATH}/`)) {
1118
+ sendJson(response, 404, { ok: false, error: 'not-found' })
1119
+ return
1120
+ }
1121
+ if (!loopbackHost(request.headers.host)) {
1122
+ sendJson(response, 403, { ok: false, error: 'forbidden-host' })
1123
+ return
1124
+ }
1125
+ const method = (request.method ?? 'GET').toUpperCase()
1126
+
1127
+ if (url.pathname === `${ROUTE_PATH}/info`) {
1128
+ if (method !== 'GET') {
1129
+ sendJson(response, 405, { ok: false, error: 'method-not-allowed' })
1130
+ return
1131
+ }
1132
+ const target = await validateRouteTarget(url.searchParams.get('path') ?? '', {
1133
+ statFile,
1134
+ maxFileBytes: config.maxFileBytes,
1135
+ allowedRoots: config.routeAllowedRoots,
1136
+ })
1137
+ if (!target.ok) {
1138
+ sendJson(response, target.status, { ok: false, error: target.error })
1139
+ return
1140
+ }
1141
+ try {
1142
+ const info = (await infoCache.load(target.path, target.stat)).info
1143
+ const sanitized = sanitizeOutline(info.outline, config.maxOutlineEntries)
1144
+ const labels = sanitizeLabels(info.labels, 1000)
1145
+ sendJson(response, 200, {
1146
+ ok: true,
1147
+ path: target.path,
1148
+ pageCount: Number.isInteger(info.pageCount) ? info.pageCount : 0,
1149
+ printedOffset: detectPageMode(info).offset,
1150
+ outline: sanitized.outline,
1151
+ ...(sanitized.truncated ? { outlineTruncated: true } : {}),
1152
+ ...(labels === undefined ? {} : { labels }),
1153
+ })
1154
+ } catch (error) {
1155
+ sendJson(response, 500, {
1156
+ ok: false,
1157
+ error: 'info-failed',
1158
+ detail: error instanceof Error ? error.message : String(error),
1159
+ })
1160
+ }
1161
+ return
1162
+ }
1163
+
1164
+ if (url.pathname === `${ROUTE_PATH}/search`) {
1165
+ if (method !== 'GET') {
1166
+ sendJson(response, 405, { ok: false, error: 'method-not-allowed' })
1167
+ return
1168
+ }
1169
+ const target = await validateRouteTarget(url.searchParams.get('path') ?? '', {
1170
+ statFile,
1171
+ maxFileBytes: config.maxFileBytes,
1172
+ allowedRoots: config.routeAllowedRoots,
1173
+ })
1174
+ if (!target.ok) {
1175
+ sendJson(response, target.status, { ok: false, error: target.error })
1176
+ return
1177
+ }
1178
+ const query = (url.searchParams.get('q') ?? '').trim()
1179
+ if (query === '') {
1180
+ sendJson(response, 400, { ok: false, error: 'missing-query' })
1181
+ return
1182
+ }
1183
+ if (query.length > 1000) {
1184
+ sendJson(response, 400, { ok: false, error: 'query-too-long' })
1185
+ return
1186
+ }
1187
+ const rawLimit = url.searchParams.get('limit')
1188
+ let limit = config.maxSearchResults
1189
+ if (rawLimit !== null) {
1190
+ const parsed = Number(rawLimit)
1191
+ if (!Number.isInteger(parsed) || parsed < 1) {
1192
+ sendJson(response, 400, { ok: false, error: 'invalid-limit' })
1193
+ return
1194
+ }
1195
+ limit = Math.min(parsed, config.maxSearchResults)
1196
+ }
1197
+ const ignoreFlag = url.searchParams.get('ignoreCase')
1198
+ const ignoreCase = ignoreFlag === '1' || ignoreFlag === 'true'
1199
+ try {
1200
+ const result = await pdfCore.searchPdf(target.path, query, { ignoreCase, limit })
1201
+ const hits = (Array.isArray(result?.hits) ? result.hits : []).slice(0, limit).map((hit) => ({
1202
+ pdf: Number(hit.pdf),
1203
+ printed: hit.printed ?? null,
1204
+ snippet: String(hit.snippet ?? ''),
1205
+ }))
1206
+ sendJson(response, 200, { ok: true, hits })
1207
+ } catch (error) {
1208
+ sendJson(response, 500, {
1209
+ ok: false,
1210
+ error: 'search-failed',
1211
+ detail: error instanceof Error ? error.message : String(error),
1212
+ })
1213
+ }
1214
+ return
1215
+ }
1216
+
1217
+ if (url.pathname === `${ROUTE_PATH}/current`) {
1218
+ if (method === 'GET') {
1219
+ const sessionId = url.searchParams.get('sessionId')
1220
+ if (sessionId !== null && (sessionId.length === 0 || sessionId.length > 200)) {
1221
+ sendJson(response, 400, { ok: false, error: 'invalid-session-id' })
1222
+ return
1223
+ }
1224
+ let position
1225
+ try {
1226
+ position = await loadPositionFor(sessionId, url.searchParams.get('path'))
1227
+ } catch (error) {
1228
+ const status = Number.isInteger(error?.status) ? error.status : 400
1229
+ sendJson(response, status, { ok: false, error: error?.error ?? 'invalid-request' })
1230
+ return
1231
+ }
1232
+ sendJson(response, 200, await currentResponse(position))
1233
+ return
1234
+ }
1235
+
1236
+ if (method === 'POST') {
1237
+ const contentType = String(request.headers['content-type'] ?? '').toLowerCase()
1238
+ if (!contentType.startsWith('application/json')) {
1239
+ sendJson(response, 415, { ok: false, error: 'unsupported-media-type' })
1240
+ return
1241
+ }
1242
+ let body
1243
+ try {
1244
+ body = await readJsonBody(request, config.maxRouteBodyBytes)
1245
+ } catch (error) {
1246
+ const status = Number.isInteger(error?.status) ? error.status : 400
1247
+ sendJson(response, status, { ok: false, error: error?.error ?? 'invalid-body' })
1248
+ return
1249
+ }
1250
+ const target = await validateRouteTarget(body.path ?? '', {
1251
+ statFile,
1252
+ maxFileBytes: config.maxFileBytes,
1253
+ allowedRoots: config.routeAllowedRoots,
1254
+ })
1255
+ if (!target.ok) {
1256
+ sendJson(response, target.status, { ok: false, error: target.error })
1257
+ return
1258
+ }
1259
+ const rawPage = body.pdfPage
1260
+ const pdfPage = typeof rawPage === 'string' && /^\d+$/u.test(rawPage) ? Number(rawPage) : rawPage
1261
+ if (!Number.isInteger(pdfPage) || pdfPage < 1 || pdfPage > 1_000_000) {
1262
+ sendJson(response, 400, { ok: false, error: 'invalid-page' })
1263
+ return
1264
+ }
1265
+ let sessionId = null
1266
+ if (body.sessionId !== undefined && body.sessionId !== null) {
1267
+ if (typeof body.sessionId !== 'string' || body.sessionId.length === 0 || body.sessionId.length > 200) {
1268
+ sendJson(response, 400, { ok: false, error: 'invalid-session-id' })
1269
+ return
1270
+ }
1271
+ sessionId = body.sessionId
1272
+ }
1273
+ let printedPage
1274
+ try {
1275
+ printedPage = normalizePrintedPage(body.printedPage)
1276
+ } catch {
1277
+ sendJson(response, 400, { ok: false, error: 'invalid-printed-page' })
1278
+ return
1279
+ }
1280
+ positions.record({ path: target.path, pdfPage, printedPage, sessionId })
1281
+ sendJson(response, 200, { ok: true })
1282
+ return
1283
+ }
1284
+
1285
+ sendJson(response, 405, { ok: false, error: 'method-not-allowed' })
1286
+ return
1287
+ }
1288
+
1289
+ sendJson(response, 404, { ok: false, error: 'not-found' })
1290
+ }
1291
+
1292
+ return async function pdfReaderRoute(request, response) {
1293
+ try {
1294
+ await handle(request, response)
1295
+ } catch (error) {
1296
+ if (response.headersSent === true) {
1297
+ if (typeof response.end === 'function') response.end()
1298
+ return
1299
+ }
1300
+ sendJson(response, 500, {
1301
+ ok: false,
1302
+ error: 'route-failed',
1303
+ ...(config.debug === true
1304
+ ? { detail: error instanceof Error ? error.message : String(error) }
1305
+ : {}),
1306
+ })
1307
+ }
1308
+ }
1309
+ }
1310
+
1311
+ /** Validate a model-supplied render scale. */
1312
+ function normalizeScale(value, fallback) {
1313
+ if (value === undefined || value === null) return fallback
1314
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > 3) {
1315
+ throw codedError('scale must be a number between 1 and 3', 'PDF_READER_INVALID_SCALE')
1316
+ }
1317
+ return value
1318
+ }
1319
+
1320
+ /** Collapse extraction noise and cap one page's text. */
1321
+ export function trimPageText(raw, maxChars) {
1322
+ if (typeof raw !== 'string') return { text: undefined, truncated: false, unavailable: true }
1323
+ const normalized = raw
1324
+ .replace(/\r\n?/gu, '\n')
1325
+ .replace(/[ \t]+\n/gu, '\n')
1326
+ .replace(/\n{3,}/gu, '\n\n')
1327
+ .trim()
1328
+ if (normalized === '') return { text: undefined, truncated: false, unavailable: true }
1329
+ if (maxChars === 0) return { text: undefined, truncated: true, unavailable: false }
1330
+ if (normalized.length <= maxChars) return { text: normalized, truncated: false, unavailable: false }
1331
+ return { text: normalized.slice(0, maxChars), truncated: true, unavailable: false }
1332
+ }
1333
+
1334
+ /** Register `pdf_info` and `pdf_search`. */
1335
+ export function createCheapTools({ defineTool: makeTool, config, core, infoCache, statFile = stat }) {
1336
+ return [
1337
+ makeTool({
1338
+ name: 'pdf_info',
1339
+ description:
1340
+ 'PDF map: page count, printed↔physical page offset, and the outline/TOC. Text only, cheap.',
1341
+ parameters: {
1342
+ path: {
1343
+ type: 'string',
1344
+ required: true,
1345
+ description: 'Absolute .pdf path (relative resolves against the session directory).',
1346
+ },
1347
+ },
1348
+ output: {
1349
+ schema: {
1350
+ type: 'object',
1351
+ additionalProperties: false,
1352
+ properties: {
1353
+ path: { type: 'string', required: true },
1354
+ title: { type: 'string' },
1355
+ author: { type: 'string' },
1356
+ fileSizeBytes: { type: 'integer' },
1357
+ pageCount: { type: 'integer', required: true },
1358
+ printedOffset: { type: 'integer', required: true },
1359
+ pageSize: {
1360
+ type: 'object',
1361
+ additionalProperties: false,
1362
+ properties: {
1363
+ width: { type: 'number', required: true },
1364
+ height: { type: 'number', required: true },
1365
+ },
1366
+ },
1367
+ textCoverage: {
1368
+ type: 'object',
1369
+ additionalProperties: false,
1370
+ properties: {
1371
+ isLikelyScanned: { type: 'boolean', required: true },
1372
+ ratio: { type: 'number', required: true },
1373
+ sampledPages: { type: 'integer', required: true },
1374
+ },
1375
+ },
1376
+ pageNumberMode: { type: 'string', enum: ['printed', 'pdf'], required: true },
1377
+ outline: {
1378
+ type: 'array',
1379
+ required: true,
1380
+ items: SECTION_SCHEMA,
1381
+ },
1382
+ labels: {
1383
+ type: 'array',
1384
+ items: {
1385
+ type: 'object',
1386
+ additionalProperties: false,
1387
+ properties: {
1388
+ pdf: { type: 'integer', required: true },
1389
+ printed: { ...PRINTED_PAGE_SCHEMA },
1390
+ },
1391
+ },
1392
+ },
1393
+ outlineTruncated: { type: 'boolean' },
1394
+ labelsTruncated: { type: 'boolean' },
1395
+ },
1396
+ },
1397
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
1398
+ presentationMeta: (_args, value) => ({ path: value.path }),
1399
+ },
1400
+ isConcurrencySafe: () => true,
1401
+ async execute(args, exec) {
1402
+ const path = resolveToolPath(args.path, exec)
1403
+ assertInsideWorkspace(path, exec, config)
1404
+ const targetStat = await assertPdfFile(path, { statFile, maxFileBytes: config.maxFileBytes })
1405
+ const info = (await infoCache.load(path, targetStat)).info
1406
+ const detected = detectPageMode(info)
1407
+ const outlineLimit = Math.min(200, config.maxOutlineEntries)
1408
+ const sanitized = sanitizeOutline(info.outline, outlineLimit, config.outlineDepth)
1409
+ const labels = sanitizeLabels(info.labels, config.maxLabels)
1410
+ const docMeta = info.documentMetadata ?? {}
1411
+ const coverage = info.textCoverage ?? {}
1412
+ return {
1413
+ path,
1414
+ title: typeof docMeta.title === 'string' ? docMeta.title : titleFromPath(path),
1415
+ ...(typeof docMeta.author === 'string' ? { author: docMeta.author } : {}),
1416
+ fileSizeBytes: Number.isFinite(targetStat?.size) ? targetStat.size : undefined,
1417
+ pageCount: Number.isInteger(info.pageCount) ? info.pageCount : 0,
1418
+ printedOffset: detected.offset,
1419
+ pageNumberMode: detected.detectable ? 'printed' : 'pdf',
1420
+ ...(info.pageSize === null || info.pageSize === undefined ? {} : { pageSize: { ...info.pageSize } }),
1421
+ ...(typeof coverage.ratio === 'number'
1422
+ ? { textCoverage: { isLikelyScanned: coverage.isLikelyScanned === true, ratio: coverage.ratio, sampledPages: coverage.sampledPages } }
1423
+ : {}),
1424
+ outline: sanitized.outline,
1425
+ ...(labels === undefined || config.maxLabels === 0 ? {} : { labels }),
1426
+ ...(sanitized.truncated ? { outlineTruncated: true } : {}),
1427
+ ...(Array.isArray(info.labels) && info.labels.length > config.maxLabels && config.maxLabels > 0
1428
+ ? { labelsTruncated: true }
1429
+ : {}),
1430
+ }
1431
+ },
1432
+ presentCall: (args) => ({
1433
+ card: 'generic',
1434
+ title: `PDF info ${args.path}`,
1435
+ kind: 'read',
1436
+ locations: [{ path: args.path }],
1437
+ }),
1438
+ }),
1439
+
1440
+ makeTool({
1441
+ name: 'pdf_search',
1442
+ description:
1443
+ 'Search a PDF\'s text layer; returns page numbers and snippets. Text only, cheap.',
1444
+ parameters: {
1445
+ path: {
1446
+ type: 'string',
1447
+ required: true,
1448
+ description: 'Absolute .pdf path (relative resolves against the session directory).',
1449
+ },
1450
+ query: { type: 'string', required: true, description: 'Literal text to search for.' },
1451
+ ignore_case: { type: 'boolean', description: 'Case-insensitive search. Defaults to true.' },
1452
+ limit: { type: 'integer', description: 'Maximum hits to return. Defaults to 20; capped by plugin config.' },
1453
+ },
1454
+ output: {
1455
+ schema: {
1456
+ type: 'object',
1457
+ additionalProperties: false,
1458
+ properties: {
1459
+ path: { type: 'string', required: true },
1460
+ query: { type: 'string', required: true },
1461
+ pages: {
1462
+ type: 'integer',
1463
+ required: true,
1464
+ description: 'Number of pages with at least one hit; may exceed the returned hit count when limit truncates.',
1465
+ },
1466
+ hits: {
1467
+ type: 'array',
1468
+ required: true,
1469
+ items: {
1470
+ type: 'object',
1471
+ additionalProperties: false,
1472
+ properties: {
1473
+ pdf: { type: 'integer', required: true },
1474
+ printed: { ...PRINTED_PAGE_SCHEMA },
1475
+ indexOnPage: { type: 'integer' },
1476
+ snippet: { type: 'string', required: true },
1477
+ },
1478
+ },
1479
+ },
1480
+ },
1481
+ },
1482
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
1483
+ presentationMeta: (_args, value) => ({ path: value.path }),
1484
+ },
1485
+ isConcurrencySafe: () => true,
1486
+ async execute(args, exec) {
1487
+ const path = resolveToolPath(args.path, exec)
1488
+ assertInsideWorkspace(path, exec, config)
1489
+ const query = typeof args.query === 'string' ? args.query.trim() : ''
1490
+ if (query === '') throw codedError('query must be a non-empty string', 'PDF_READER_INVALID_QUERY')
1491
+ if (query.length > 1000) throw codedError('query is longer than 1000 characters', 'PDF_READER_INVALID_QUERY')
1492
+ await assertPdfFile(path, { statFile, maxFileBytes: config.maxFileBytes })
1493
+ const limit = Math.max(1, Math.min(Number.isInteger(args.limit) ? args.limit : 20, config.maxSearchResults))
1494
+ const ignoreCase = args.ignore_case !== false
1495
+ const result = await core.searchPdf(path, query, { ignoreCase, limit })
1496
+ const rawHits = Array.isArray(result?.hits) ? result.hits : []
1497
+ const hits = rawHits.slice(0, limit).map((hit) => ({
1498
+ pdf: Number(hit.pdf),
1499
+ printed: hit.printed ?? null,
1500
+ ...(Number.isInteger(hit.indexOnPage) ? { indexOnPage: hit.indexOnPage } : {}),
1501
+ snippet: String(hit.snippet ?? ''),
1502
+ }))
1503
+ return {
1504
+ path,
1505
+ query,
1506
+ pages: Number.isInteger(result?.pages) ? result.pages : new Set(hits.map((hit) => hit.pdf)).size,
1507
+ hits,
1508
+ }
1509
+ },
1510
+ presentCall: (args) => ({
1511
+ card: 'generic',
1512
+ title: `Search PDF ${args.query}`,
1513
+ kind: 'read',
1514
+ locations: [{ path: args.path }],
1515
+ }),
1516
+ }),
1517
+ ]
1518
+ }
1519
+
1520
+ /** Register `pdf_read_pages` into a context that has the attachments service. */
1521
+ export function createReadPagesTool({ defineTool: makeTool, ctx, config, core, infoCache, statFile = stat }) {
1522
+ return makeTool({
1523
+ name: 'pdf_read_pages',
1524
+ description:
1525
+ 'Read chosen PDF pages. Returns extracted text, optionally the rendered images (include_image:false for text only). Both page_numberings accepted.',
1526
+ parameters: {
1527
+ path: {
1528
+ type: 'string',
1529
+ required: true,
1530
+ description: 'Absolute .pdf path (relative resolves against the session directory).',
1531
+ },
1532
+ pages: {
1533
+ required: true,
1534
+ description:
1535
+ '1-based pages to render: "132-134", "132,135", 132, or [132,133,134]. Interpreted as printed numbers when the PDF has a printed-page offset, unless page_numbers is "pdf".',
1536
+ oneOf: [
1537
+ { type: 'integer' },
1538
+ { type: 'string' },
1539
+ { type: 'array', items: { oneOf: [{ type: 'integer' }, { type: 'string' }] } },
1540
+ ],
1541
+ },
1542
+ page_numbers: {
1543
+ type: 'string',
1544
+ enum: ['printed', 'pdf'],
1545
+ description:
1546
+ 'How to interpret `pages`. Defaults to "printed" when the PDF exposes numeric printed-page labels, otherwise "pdf".',
1547
+ },
1548
+ scale: {
1549
+ type: 'number',
1550
+ description: 'Render scale from 1 to 3. Defaults to 2.5. Use 3 with crop for dense subscripts; never above 3.',
1551
+ },
1552
+ crop: {
1553
+ oneOf: [
1554
+ { type: 'string' },
1555
+ { type: 'array', items: { type: 'number' } },
1556
+ {
1557
+ type: 'object',
1558
+ additionalProperties: false,
1559
+ properties: {
1560
+ x: { type: 'number', required: true },
1561
+ y: { type: 'number', required: true },
1562
+ w: { type: 'number', required: true },
1563
+ h: { type: 'number', required: true },
1564
+ },
1565
+ },
1566
+ ],
1567
+ description:
1568
+ 'Optional crop as "x0,y0,x1,y1" fractions, [x0,y0,x1,y1], or {x,y,w,h} with a top-left origin, e.g. "0,0,1,0.55" or {x:0,y:0,w:1,h:0.55} for the top 55%.',
1569
+ },
1570
+ image_quality: {
1571
+ type: 'string',
1572
+ enum: ['cheap', 'standard'],
1573
+ description:
1574
+ 'Cost/readability preset: "cheap" renders at 1.5x (about 350 tokens/page, prose only); "standard" uses the default 2.5x (about 900 tokens/page). An explicit scale wins.',
1575
+ },
1576
+ include_text: {
1577
+ type: 'boolean',
1578
+ description:
1579
+ 'Include a capped text extract beside each image. Defaults to true. The image is the evidence; text is an index.',
1580
+ },
1581
+ include_image: {
1582
+ type: 'boolean',
1583
+ description: 'Render page images. Defaults to true. false returns text only, with no image tokens.',
1584
+ },
1585
+ },
1586
+ output: {
1587
+ schema: {
1588
+ type: 'object',
1589
+ additionalProperties: false,
1590
+ properties: {
1591
+ path: { type: 'string', required: true },
1592
+ book: { type: 'string', required: true },
1593
+ pageNumbers: { type: 'string', enum: ['printed', 'pdf'], required: true },
1594
+ printedOffset: { type: 'integer', required: true },
1595
+ textIncluded: { type: 'boolean', required: true },
1596
+ imageTokens: { type: 'integer' },
1597
+ pages: {
1598
+ type: 'array',
1599
+ required: true,
1600
+ items: {
1601
+ type: 'object',
1602
+ additionalProperties: false,
1603
+ properties: {
1604
+ pdf: { type: 'integer', required: true },
1605
+ printed: { oneOf: [{ type: 'integer' }, { type: 'string' }], required: true },
1606
+ tokens: { type: 'integer' },
1607
+ image: { ...IMAGE_VALUE_SCHEMA, required: false },
1608
+ text: { type: 'string' },
1609
+ textTruncated: { type: 'boolean' },
1610
+ textUnavailable: { type: 'boolean' },
1611
+ },
1612
+ },
1613
+ },
1614
+ },
1615
+ },
1616
+ render: (_args, value) => renderPageReadContent(value),
1617
+ presentationMeta: (_args, value) => ({ path: value.path, pages: value.pages.map((page) => page.pdf) }),
1618
+ },
1619
+ isConcurrencySafe: () => true,
1620
+ async execute(args, exec) {
1621
+ try {
1622
+ const path = resolveToolPath(args.path, exec)
1623
+ assertInsideWorkspace(path, exec, config)
1624
+ const targetStat = await assertPdfFile(path, { statFile, maxFileBytes: config.maxFileBytes })
1625
+ const attachments = ctx.get('attachments')
1626
+ if (attachments === undefined) {
1627
+ throw codedError('cannot read PDF pages as images: no attachment service is mounted', 'PDF_READER_NO_ATTACHMENTS')
1628
+ }
1629
+ await assertImageCapableRoute(ctx, exec, path)
1630
+ const info = (await infoCache.load(path, targetStat)).info
1631
+ const resolved = resolveRequestedPages({
1632
+ selection: args.pages,
1633
+ pageNumbers: args.page_numbers,
1634
+ info,
1635
+ maxPages: config.maxPagesPerCall,
1636
+ })
1637
+ const includeImage = args.include_image !== false
1638
+ if (!includeImage) {
1639
+ const book = titleFromPath(path)
1640
+ const includeText = args.include_text !== false && config.maxTextCharsPerPage > 0
1641
+ const pages = []
1642
+ for (const pdf of resolved.pdfPages) {
1643
+ let textResult = { text: undefined, truncated: false, unavailable: false }
1644
+ if (includeText) {
1645
+ try {
1646
+ textResult = trimPageText(await core.getPageText(path, pdf), config.maxTextCharsPerPage)
1647
+ } catch {
1648
+ textResult = { text: undefined, truncated: false, unavailable: true }
1649
+ }
1650
+ }
1651
+ pages.push({
1652
+ pdf,
1653
+ printed: resolved.mode === 'printed' ? pdf - resolved.printedOffset : pdf,
1654
+ tokens: 0,
1655
+ ...(textResult.text === undefined ? {} : { text: textResult.text }),
1656
+ ...(textResult.truncated ? { textTruncated: true } : {}),
1657
+ ...(textResult.unavailable ? { textUnavailable: true } : {}),
1658
+ })
1659
+ }
1660
+ return {
1661
+ path,
1662
+ book,
1663
+ pageNumbers: resolved.mode,
1664
+ printedOffset: resolved.printedOffset,
1665
+ textIncluded: includeText,
1666
+ imageTokens: 0,
1667
+ pages,
1668
+ }
1669
+ }
1670
+ const requestedScale =
1671
+ args.scale ?? (args.image_quality === 'cheap' ? 1.5 : config.defaultScale)
1672
+ const scale = normalizeScale(requestedScale, config.defaultScale)
1673
+ const crop = parseCrop(args.crop)
1674
+ let rendered
1675
+ try {
1676
+ rendered = await core.renderPagesToPng(path, resolved.pdfPages, {
1677
+ scale,
1678
+ ...(crop === undefined ? {} : { crop }),
1679
+ })
1680
+ } catch (error) {
1681
+ if (error?.code === 'PDF_PAGE_OUT_OF_RANGE') {
1682
+ throw codedError(error.message, 'PDF_READER_INVALID_PAGE')
1683
+ }
1684
+ throw error
1685
+ }
1686
+ const byPdf = new Map()
1687
+ for (const page of Array.isArray(rendered) ? rendered : []) {
1688
+ const pdf = Number(page?.pdf)
1689
+ if (Number.isFinite(pdf)) byPdf.set(pdf, page)
1690
+ }
1691
+ const includeText = args.include_text !== false && config.maxTextCharsPerPage > 0
1692
+ const book = titleFromPath(path)
1693
+ const safeName = book.replace(/[^A-Za-z0-9._-]+/gu, '_').slice(0, 60) || 'page'
1694
+ const pages = []
1695
+ let totalImageTokens = 0
1696
+ for (const pdf of resolved.pdfPages) {
1697
+ const page = byPdf.get(pdf)
1698
+ if (page === undefined || page.buffer === undefined || page.buffer.byteLength === 0) {
1699
+ throw codedError(`renderer returned no PNG for PDF page ${pdf}`, 'PDF_READER_RENDER_FAILED')
1700
+ }
1701
+ let textResult = { text: undefined, truncated: false, unavailable: false }
1702
+ if (includeText) {
1703
+ try {
1704
+ textResult = trimPageText(await core.getPageText(path, pdf), config.maxTextCharsPerPage)
1705
+ } catch {
1706
+ textResult = { text: undefined, truncated: false, unavailable: true }
1707
+ }
1708
+ }
1709
+ let ref
1710
+ try {
1711
+ ref = await attachments.saveImage({
1712
+ data: page.buffer,
1713
+ mediaType: 'image/png',
1714
+ name: `${safeName}-p${pdf}.png`,
1715
+ })
1716
+ } catch (error) {
1717
+ const code = error?.code
1718
+ if (code === 'IMAGE_DIMENSION_TOO_LARGE' || code === 'IMAGE_TOO_MANY_PIXELS' || code === 'IMAGE_TOO_LARGE') {
1719
+ throw codedError(
1720
+ `cannot store PDF page ${pdf} as an image: ${error.message}; lower scale or crop the page and retry`,
1721
+ 'PDF_READER_IMAGE_TOO_LARGE',
1722
+ )
1723
+ }
1724
+ throw error
1725
+ }
1726
+ if (typeof ref?.attachmentId !== 'string') {
1727
+ throw codedError(`attachment store returned no reference for PDF page ${pdf}`, 'PDF_READER_ATTACHMENT_FAILED')
1728
+ }
1729
+ const pageTokens = estimateImageTokens(
1730
+ ref?.width ?? page.width ?? 0,
1731
+ ref?.height ?? page.height ?? 0,
1732
+ ).numTokens
1733
+ totalImageTokens += pageTokens
1734
+ pages.push({
1735
+ pdf,
1736
+ tokens: pageTokens,
1737
+ printed:
1738
+ resolved.mode === 'printed'
1739
+ ? pdf - resolved.printedOffset
1740
+ : (page.printed ?? pdf),
1741
+ image: {
1742
+ attachmentId: ref.attachmentId,
1743
+ mediaType: ref.mediaType ?? 'image/png',
1744
+ bytes: ref.bytes ?? page.buffer.byteLength,
1745
+ width: ref.width ?? page.width,
1746
+ height: ref.height ?? page.height,
1747
+ ...(ref.name === undefined ? {} : { name: ref.name }),
1748
+ ...(ref.originalDimensions === undefined ? {} : { originalDimensions: { ...ref.originalDimensions } }),
1749
+ },
1750
+ ...(textResult.text === undefined ? {} : { text: textResult.text }),
1751
+ ...(textResult.truncated ? { textTruncated: true } : {}),
1752
+ ...(textResult.unavailable ? { textUnavailable: true } : {}),
1753
+ })
1754
+ }
1755
+ if (config.debugCost === true) {
1756
+ try {
1757
+ appendFileSync(
1758
+ new URL('../.debug-cost.log', import.meta.url).pathname,
1759
+ JSON.stringify({
1760
+ at: new Date().toISOString(),
1761
+ tool: 'pdf_read_pages',
1762
+ book: titleFromPath(path),
1763
+ pages: pages.map((page) => ({ pdf: page.pdf, printed: page.printed, tokens: page.tokens })),
1764
+ imageTokens: totalImageTokens,
1765
+ textTokens: estimateTextTokens(pages.map((page) => page.text ?? '').join('\n')),
1766
+ scale: Number.isFinite(scale) ? scale : undefined,
1767
+ }) + '\n',
1768
+ )
1769
+ } catch {}
1770
+ }
1771
+ return {
1772
+ path,
1773
+ book,
1774
+ pageNumbers: resolved.mode,
1775
+ printedOffset: resolved.printedOffset,
1776
+ textIncluded: includeText,
1777
+ imageTokens: totalImageTokens,
1778
+ pages,
1779
+ }
1780
+ } catch (error) {
1781
+ throw error
1782
+ }
1783
+ },
1784
+ presentCall: (args) => ({
1785
+ card: 'generic',
1786
+ title: `Read PDF pages ${Array.isArray(args.pages) ? args.pages.join(',') : args.pages}`,
1787
+ kind: 'read',
1788
+ locations: [{ path: args.path }],
1789
+ }),
1790
+ })
1791
+ }
1792
+
1793
+ /**
1794
+ * Mount the host half: one prefix route, three cheap tools, the image tool
1795
+ * while attachments are mounted, and one prepended pre-step listener.
1796
+ * @param ctx - Cordis plugin context.
1797
+ * @param rawConfig - plugin config; defaults are applied here as well as by the loader.
1798
+ */
1799
+ export function apply(ctx, rawConfig) {
1800
+ const config = normalizeConfig(rawConfig)
1801
+ if (!config.enabled) return
1802
+
1803
+ const core = createLazyCore()
1804
+ const infoCache = createInfoCache({
1805
+ getInfo: core.getInfo,
1806
+ statFile: stat,
1807
+ limit: config.infoCacheSize,
1808
+ })
1809
+ const positions = createPositionStore()
1810
+
1811
+ const routeHandler = createRouteHandler({
1812
+ config,
1813
+ pdfCore: core,
1814
+ infoCache,
1815
+ positions,
1816
+ statFile: stat,
1817
+ })
1818
+ ctx.effect(
1819
+ () => ctx.webServer.register({ kind: 'prefix', path: ROUTE_PATH, handler: routeHandler }),
1820
+ 'dsh-pdf-reader: HTTP routes',
1821
+ )
1822
+
1823
+ for (const tool of createCheapTools({
1824
+ defineTool,
1825
+ config,
1826
+ core,
1827
+ infoCache,
1828
+ statFile: stat,
1829
+ })) {
1830
+ ctx.tools.register(tool)
1831
+ }
1832
+
1833
+ ctx.inject(['attachments'], (attachmentCtx) => {
1834
+ attachmentCtx.tools.register(
1835
+ createReadPagesTool({
1836
+ defineTool,
1837
+ ctx: attachmentCtx,
1838
+ config,
1839
+ core,
1840
+ infoCache,
1841
+ statFile: stat,
1842
+ }),
1843
+ )
1844
+ })
1845
+
1846
+ if (config.autoShare) {
1847
+ const listener = createAutoShareListener({ config, positions, infoCache })
1848
+ ctx.on('agent/pre-step', listener, { prepend: true })
1849
+ }
1850
+ }