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