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/CHANGELOG.md +36 -0
- package/LICENSE +21 -0
- package/README.md +494 -2
- package/SKILL.md +97 -0
- package/cordis.patch.yml +31 -0
- package/icon.svg +13 -0
- package/lib/client.js +1794 -0
- package/lib/core/README.md +299 -0
- package/lib/core/pdf.js +962 -0
- package/lib/core/render.js +351 -0
- package/lib/core/tokens.js +116 -0
- package/lib/index.js +1936 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +83 -4
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, '&').replace(/</gu, '<').replace(/>/gu, '>')
|
|
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
|
+
}
|