dsh-book-reader 0.1.1 → 0.3.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 +47 -0
- package/README.md +58 -68
- package/README.zh-CN.md +91 -0
- package/SKILL.md +22 -4
- package/cordis.patch.yml +5 -3
- package/lib/client.js +1140 -209
- package/lib/core/README.md +47 -22
- package/lib/core/pdf.js +564 -35
- package/lib/index.js +550 -20
- package/package.json +12 -3
package/lib/index.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { basename, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path'
|
|
22
|
+
import { createHash } from 'node:crypto'
|
|
22
23
|
import { appendFileSync } from 'node:fs'
|
|
23
24
|
import { stat } from 'node:fs/promises'
|
|
24
25
|
import z from '@deepseek-ai/schemastery'
|
|
@@ -51,6 +52,8 @@ const PAGE_SELECTION_HARD_LIMIT = 256
|
|
|
51
52
|
export const Config = z.object({
|
|
52
53
|
/** Master switch: false mounts no tool, route, or listener. */
|
|
53
54
|
enabled: z.boolean().default(true),
|
|
55
|
+
/** Remember page/zoom per PDF and restore on reopen. */
|
|
56
|
+
rememberView: z.boolean().default(true),
|
|
54
57
|
/** Inject the current reading page into `agent/pre-step`, at most once per turn. */
|
|
55
58
|
autoShare: z.boolean().default(true),
|
|
56
59
|
/** Resolve the outline section for the injected message (one cached getInfo). */
|
|
@@ -94,6 +97,7 @@ export const Config = z.object({
|
|
|
94
97
|
/** Defaults applied independently of the loader, so direct callers work too. */
|
|
95
98
|
export const DEFAULT_CONFIG = Object.freeze({
|
|
96
99
|
enabled: true,
|
|
100
|
+
rememberView: true,
|
|
97
101
|
autoShare: true,
|
|
98
102
|
autoShareSection: true,
|
|
99
103
|
autoShareMinIntervalMs: 0,
|
|
@@ -166,6 +170,7 @@ export function normalizeConfig(raw) {
|
|
|
166
170
|
if (typeof source !== 'object' || Array.isArray(source)) throw new TypeError('pdf-reader: config must be an object')
|
|
167
171
|
return {
|
|
168
172
|
enabled: configBoolean(source.enabled, DEFAULT_CONFIG.enabled, 'enabled'),
|
|
173
|
+
rememberView: configBoolean(source.rememberView, DEFAULT_CONFIG.rememberView, 'rememberView'),
|
|
169
174
|
autoShare: configBoolean(source.autoShare, DEFAULT_CONFIG.autoShare, 'autoShare'),
|
|
170
175
|
autoShareSection: configBoolean(source.autoShareSection, DEFAULT_CONFIG.autoShareSection, 'autoShareSection'),
|
|
171
176
|
autoShareMinIntervalMs: configInteger(
|
|
@@ -416,6 +421,233 @@ export function createPositionStore({ now = Date.now, maxSessions = 200, maxPath
|
|
|
416
421
|
}
|
|
417
422
|
}
|
|
418
423
|
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// Remembered page/zoom view state
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
/** Durable storage-domain name for remembered book views. */
|
|
429
|
+
export const VIEW_DOMAIN_NAME = 'book_reader_views'
|
|
430
|
+
|
|
431
|
+
/** Format version of {@link VIEW_DOMAIN_NAME}; bump only with a migration. */
|
|
432
|
+
export const VIEW_DOMAIN_VERSION = 1
|
|
433
|
+
|
|
434
|
+
/** Table holding one remembered view per book inside {@link VIEW_DOMAIN_NAME}. */
|
|
435
|
+
export const VIEW_TABLE_NAME = 'views'
|
|
436
|
+
|
|
437
|
+
/** Hard cap on remembered books; the oldest records are pruned past this. */
|
|
438
|
+
export const MAX_VIEW_RECORDS = 500
|
|
439
|
+
|
|
440
|
+
/** Longest accepted book path in a `/view` request. */
|
|
441
|
+
export const MAX_VIEW_PATH_LENGTH = 4096
|
|
442
|
+
|
|
443
|
+
/** Hard ceiling for a remembered PDF page number. */
|
|
444
|
+
export const MAX_VIEW_PAGE = 100_000
|
|
445
|
+
|
|
446
|
+
/** The only accepted zoom modes (mirrors the client's toolbar). */
|
|
447
|
+
export const VIEW_ZOOM_MODES = Object.freeze(['fit-width', 'fixed'])
|
|
448
|
+
|
|
449
|
+
/** Smallest accepted zoom ratio. */
|
|
450
|
+
export const VIEW_ZOOM_SCALE_MIN = 0.25
|
|
451
|
+
|
|
452
|
+
/** Largest accepted zoom ratio. */
|
|
453
|
+
export const VIEW_ZOOM_SCALE_MAX = 4
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Stable storage key for one book: sha256 hex of the absolute path.
|
|
457
|
+
* @param path - normalized absolute `.pdf` path.
|
|
458
|
+
* @returns 64 lowercase hex characters.
|
|
459
|
+
*/
|
|
460
|
+
export function viewKeyForPath(path) {
|
|
461
|
+
return createHash('sha256').update(path, 'utf8').digest('hex')
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Declarative zod schema for one remembered view.
|
|
466
|
+
*
|
|
467
|
+
* The schema is the durable-boundary validator of the storage domain. The
|
|
468
|
+
* HTTP route validates requests itself, so this stays purely declarative and
|
|
469
|
+
* needs no zod call at request time.
|
|
470
|
+
* @param zod - the dynamically imported zod module namespace.
|
|
471
|
+
* @returns a zod object schema matching the `/view` record contract.
|
|
472
|
+
*/
|
|
473
|
+
export function viewRecordSchema(zod) {
|
|
474
|
+
return zod.object({
|
|
475
|
+
path: zod.string().min(1).max(MAX_VIEW_PATH_LENGTH),
|
|
476
|
+
pdfPage: zod.number().int().min(1).max(MAX_VIEW_PAGE),
|
|
477
|
+
zoomMode: zod.enum([...VIEW_ZOOM_MODES]),
|
|
478
|
+
zoomScale: zod.number().min(VIEW_ZOOM_SCALE_MIN).max(VIEW_ZOOM_SCALE_MAX),
|
|
479
|
+
updatedAt: zod.number().int().nonnegative(),
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Build the `book_reader_views` domain declaration.
|
|
485
|
+
*
|
|
486
|
+
* `zod` and the domain helpers are imported dynamically so this module still
|
|
487
|
+
* loads where the storage packages are absent; callers must handle rejection.
|
|
488
|
+
* @returns the domain spec understood by `ctx.storageDomain.open`.
|
|
489
|
+
*/
|
|
490
|
+
export async function createViewDomainSpec() {
|
|
491
|
+
const [{ defineDomain, domainTable }, { z: zod }] = await Promise.all([
|
|
492
|
+
import('@deepseek-ai/dsh-storage-domain'),
|
|
493
|
+
import('zod'),
|
|
494
|
+
])
|
|
495
|
+
return defineDomain({
|
|
496
|
+
name: VIEW_DOMAIN_NAME,
|
|
497
|
+
version: VIEW_DOMAIN_VERSION,
|
|
498
|
+
layout: 'single',
|
|
499
|
+
tables: {
|
|
500
|
+
[VIEW_TABLE_NAME]: domainTable(viewRecordSchema(zod)),
|
|
501
|
+
},
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Open the remembered-view domain through the injected `storageDomain` service.
|
|
507
|
+
* @param storageDomain - the `ctx.storageDomain` facility (DomainFacility).
|
|
508
|
+
* @returns the opened domain handle; the caller owns `close()`.
|
|
509
|
+
* @throws when the service, the modules, or `open` itself are unavailable.
|
|
510
|
+
*/
|
|
511
|
+
export async function openViewStateDomain(storageDomain) {
|
|
512
|
+
if (storageDomain === null || typeof storageDomain !== 'object' || typeof storageDomain.open !== 'function') {
|
|
513
|
+
throw new TypeError('pdf-reader: storageDomain service is unavailable')
|
|
514
|
+
}
|
|
515
|
+
const spec = await createViewDomainSpec()
|
|
516
|
+
const domain = await storageDomain.open(spec)
|
|
517
|
+
if (domain === null || typeof domain !== 'object' || typeof domain.table !== 'function') {
|
|
518
|
+
throw new TypeError('pdf-reader: storageDomain.open returned no domain')
|
|
519
|
+
}
|
|
520
|
+
return domain
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Whether one stored value is a usable view record for the requested path. */
|
|
524
|
+
function isViewRecordFor(record, path) {
|
|
525
|
+
if (typeof path !== 'string' || path.length === 0 || path.length > MAX_VIEW_PATH_LENGTH) return false
|
|
526
|
+
if (record === null || typeof record !== 'object') return false
|
|
527
|
+
if (record.path !== path) return false
|
|
528
|
+
if (!Number.isInteger(record.pdfPage) || record.pdfPage < 1 || record.pdfPage > MAX_VIEW_PAGE) return false
|
|
529
|
+
if (record.zoomMode !== 'fit-width' && record.zoomMode !== 'fixed') return false
|
|
530
|
+
if (typeof record.zoomScale !== 'number' || !Number.isFinite(record.zoomScale)) return false
|
|
531
|
+
if (record.zoomScale < VIEW_ZOOM_SCALE_MIN || record.zoomScale > VIEW_ZOOM_SCALE_MAX) return false
|
|
532
|
+
if (!Number.isInteger(record.updatedAt) || record.updatedAt < 0) return false
|
|
533
|
+
return true
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Project one stored record to the exact client-facing shape. */
|
|
537
|
+
function publicViewRecord(record) {
|
|
538
|
+
return {
|
|
539
|
+
path: record.path,
|
|
540
|
+
pdfPage: record.pdfPage,
|
|
541
|
+
zoomMode: record.zoomMode,
|
|
542
|
+
zoomScale: record.zoomScale,
|
|
543
|
+
updatedAt: record.updatedAt,
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Delete the least recently updated records once the table exceeds the cap.
|
|
549
|
+
* Best-effort: pruning never rejects an already-durable write.
|
|
550
|
+
* @param table - an open domain table handle.
|
|
551
|
+
*/
|
|
552
|
+
async function pruneViewTable(table) {
|
|
553
|
+
try {
|
|
554
|
+
if (table.size <= MAX_VIEW_RECORDS) return
|
|
555
|
+
const excess = table.size - MAX_VIEW_RECORDS
|
|
556
|
+
const oldest = [...table.entries()]
|
|
557
|
+
.sort((left, right) => {
|
|
558
|
+
const leftAt = Number.isFinite(left[1]?.updatedAt) ? left[1].updatedAt : 0
|
|
559
|
+
const rightAt = Number.isFinite(right[1]?.updatedAt) ? right[1].updatedAt : 0
|
|
560
|
+
return leftAt - rightAt
|
|
561
|
+
})
|
|
562
|
+
.slice(0, excess)
|
|
563
|
+
for (const [key] of oldest) await table.delete(key)
|
|
564
|
+
} catch {
|
|
565
|
+
// The record is already durable; a failed prune is not a failed write.
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Route-facing adapter over the opened `book_reader_views` domain.
|
|
571
|
+
*
|
|
572
|
+
* `apply()` attaches the table once storage is open and detaches it on
|
|
573
|
+
* unload. Until then (or forever, when storage is unavailable) `read()`
|
|
574
|
+
* answers `null` and `write()` answers `false`, so routes degrade without
|
|
575
|
+
* throwing. Every failure is contained here and reported to `warn` once.
|
|
576
|
+
* @param options - warning sink; defaults to `console.warn`.
|
|
577
|
+
* @returns a mutable adapter read by the route handler.
|
|
578
|
+
*/
|
|
579
|
+
export function createViewStateAdapter({ warn = console.warn } = {}) {
|
|
580
|
+
let table = null
|
|
581
|
+
let warned = false
|
|
582
|
+
|
|
583
|
+
function warnOnce(message, error) {
|
|
584
|
+
if (warned) return
|
|
585
|
+
warned = true
|
|
586
|
+
try {
|
|
587
|
+
const detail = error instanceof Error ? `: ${error.message}` : ''
|
|
588
|
+
warn(`dsh-pdf-reader: ${message}${detail}`)
|
|
589
|
+
} catch {
|
|
590
|
+
// A broken logger must not break plugin loading or routing.
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
/** Whether an open domain table is currently attached. */
|
|
596
|
+
get available() {
|
|
597
|
+
return table !== null
|
|
598
|
+
},
|
|
599
|
+
/** Attach the table of a successfully opened domain. */
|
|
600
|
+
attach(nextTable) {
|
|
601
|
+
table = nextTable ?? null
|
|
602
|
+
},
|
|
603
|
+
/** Detach on unload so in-flight routes degrade instead of throwing. */
|
|
604
|
+
detach() {
|
|
605
|
+
table = null
|
|
606
|
+
},
|
|
607
|
+
/** Emit at most one warning for this adapter. */
|
|
608
|
+
warnOnce,
|
|
609
|
+
/**
|
|
610
|
+
* Read the remembered view for one normalized absolute path.
|
|
611
|
+
* @returns the public record, or `null` when absent/unavailable/invalid.
|
|
612
|
+
*/
|
|
613
|
+
async read(path) {
|
|
614
|
+
const current = table
|
|
615
|
+
if (current === null) return null
|
|
616
|
+
try {
|
|
617
|
+
const record = current.get(viewKeyForPath(path))
|
|
618
|
+
if (!isViewRecordFor(record, path)) return null
|
|
619
|
+
return publicViewRecord(record)
|
|
620
|
+
} catch (error) {
|
|
621
|
+
warnOnce('could not read the remembered page/zoom', error)
|
|
622
|
+
return null
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
/**
|
|
626
|
+
* Persist one complete view record (key hashed from `record.path`).
|
|
627
|
+
* @returns true when durable, false when storage is unavailable/failed.
|
|
628
|
+
*/
|
|
629
|
+
async write(record) {
|
|
630
|
+
const current = table
|
|
631
|
+
if (current === null) return false
|
|
632
|
+
try {
|
|
633
|
+
const value = {
|
|
634
|
+
path: record.path,
|
|
635
|
+
pdfPage: record.pdfPage,
|
|
636
|
+
zoomMode: record.zoomMode,
|
|
637
|
+
zoomScale: record.zoomScale,
|
|
638
|
+
updatedAt: Number.isInteger(record.updatedAt) ? record.updatedAt : Date.now(),
|
|
639
|
+
}
|
|
640
|
+
await current.put(viewKeyForPath(value.path), value)
|
|
641
|
+
await pruneViewTable(current)
|
|
642
|
+
return true
|
|
643
|
+
} catch (error) {
|
|
644
|
+
warnOnce('could not remember the page/zoom', error)
|
|
645
|
+
return false
|
|
646
|
+
}
|
|
647
|
+
},
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
419
651
|
/** Normalize unicode dashes so a pasted page range parses. */
|
|
420
652
|
function normalizeDashes(value) {
|
|
421
653
|
return value.replace(/[\u2010-\u2015\u2212]/gu, '-')
|
|
@@ -596,8 +828,8 @@ export function printedPageFor(info, pdfPage, reported = null) {
|
|
|
596
828
|
* @param options - selection, explicit mode, `getInfo` result, and page cap.
|
|
597
829
|
* @returns physical pages, the resolved mode, and the offset used.
|
|
598
830
|
*/
|
|
599
|
-
export function resolveRequestedPages({ selection, pageNumbers, info, maxPages = DEFAULT_CONFIG.maxPagesPerCall }) {
|
|
600
|
-
const requested = parsePageSelection(selection, { maxPages })
|
|
831
|
+
export function resolveRequestedPages({ selection, pageNumbers, info, maxPages = DEFAULT_CONFIG.maxPagesPerCall, hardLimit = PAGE_SELECTION_HARD_LIMIT }) {
|
|
832
|
+
const requested = parsePageSelection(selection, { maxPages, hardLimit })
|
|
601
833
|
const detected = detectPageMode(info)
|
|
602
834
|
const mode = pageNumbers ?? (detected.detectable ? 'printed' : 'pdf')
|
|
603
835
|
if (mode !== 'printed' && mode !== 'pdf') {
|
|
@@ -967,6 +1199,19 @@ const PRINTED_PAGE_SCHEMA = {
|
|
|
967
1199
|
oneOf: [{ type: 'integer' }, { type: 'string' }, { type: 'null' }],
|
|
968
1200
|
}
|
|
969
1201
|
|
|
1202
|
+
/**
|
|
1203
|
+
* DSH's JsonSchema dialect marks a property required with `required: true`; a
|
|
1204
|
+
* property that may be absent simply omits `required`. `required: false` is
|
|
1205
|
+
* rejected by defineTool at registration time.
|
|
1206
|
+
*
|
|
1207
|
+
* `IMAGE_VALUE_SCHEMA` is required where it is used for a present image, so an
|
|
1208
|
+
* optional use must drop that marker instead of overriding it to false.
|
|
1209
|
+
*/
|
|
1210
|
+
function optionalPropertySchema(schema) {
|
|
1211
|
+
const { required: _required, ...rest } = schema
|
|
1212
|
+
return rest
|
|
1213
|
+
}
|
|
1214
|
+
|
|
970
1215
|
const SECTION_SCHEMA = {
|
|
971
1216
|
type: 'object',
|
|
972
1217
|
additionalProperties: false,
|
|
@@ -1075,8 +1320,33 @@ function createLazyCore() {
|
|
|
1075
1320
|
}
|
|
1076
1321
|
}
|
|
1077
1322
|
|
|
1323
|
+
/**
|
|
1324
|
+
* Validate a `/view` book path without touching the filesystem.
|
|
1325
|
+
*
|
|
1326
|
+
* The remembered-view route deliberately does not stat the PDF: the client
|
|
1327
|
+
* may report a state for a book whose preview is still opening, and restoring
|
|
1328
|
+
* a missing book's state is harmless. The shape and size checks still mirror
|
|
1329
|
+
* the rest of the route surface.
|
|
1330
|
+
* @param rawPath - the raw query/body `path` value.
|
|
1331
|
+
* @returns the normalized absolute path, or `null` when invalid.
|
|
1332
|
+
*/
|
|
1333
|
+
function validateViewPath(rawPath) {
|
|
1334
|
+
if (typeof rawPath !== 'string' || rawPath.length === 0 || rawPath.length > MAX_VIEW_PATH_LENGTH) return null
|
|
1335
|
+
if (!isAbsolute(rawPath)) return null
|
|
1336
|
+
const path = resolvePath(rawPath)
|
|
1337
|
+
if (!/\.pdf$/iu.test(path)) return null
|
|
1338
|
+
return path
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1078
1341
|
/** Build the route handler. Exported so tests can use fake core and stat. */
|
|
1079
|
-
export function createRouteHandler({
|
|
1342
|
+
export function createRouteHandler({
|
|
1343
|
+
config,
|
|
1344
|
+
pdfCore,
|
|
1345
|
+
infoCache,
|
|
1346
|
+
positions,
|
|
1347
|
+
statFile = stat,
|
|
1348
|
+
views = createViewStateAdapter(),
|
|
1349
|
+
}) {
|
|
1080
1350
|
async function loadPositionFor(sessionId, path) {
|
|
1081
1351
|
if (typeof sessionId === 'string' && sessionId !== '') {
|
|
1082
1352
|
return positions.forSession(sessionId)
|
|
@@ -1175,13 +1445,12 @@ export function createRouteHandler({ config, pdfCore, infoCache, positions, stat
|
|
|
1175
1445
|
sendJson(response, target.status, { ok: false, error: target.error })
|
|
1176
1446
|
return
|
|
1177
1447
|
}
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
sendJson(response, 400, { ok: false, error: 'query-too-long' })
|
|
1448
|
+
let query
|
|
1449
|
+
try {
|
|
1450
|
+
query = cleanPdfSearchQuery(url.searchParams.get('q') ?? '')
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
const tooLong = error instanceof RangeError
|
|
1453
|
+
sendJson(response, 400, { ok: false, error: tooLong ? 'query-too-long' : 'missing-query' })
|
|
1185
1454
|
return
|
|
1186
1455
|
}
|
|
1187
1456
|
const rawLimit = url.searchParams.get('limit')
|
|
@@ -1203,8 +1472,17 @@ export function createRouteHandler({ config, pdfCore, infoCache, positions, stat
|
|
|
1203
1472
|
printed: hit.printed ?? null,
|
|
1204
1473
|
snippet: String(hit.snippet ?? ''),
|
|
1205
1474
|
}))
|
|
1206
|
-
|
|
1475
|
+
const mode = ['phrase', 'compact', 'terms'].includes(result?.mode) ? result.mode : 'phrase'
|
|
1476
|
+
sendJson(response, 200, { ok: true, mode, hits })
|
|
1207
1477
|
} catch (error) {
|
|
1478
|
+
if (
|
|
1479
|
+
error?.code === 'PDF_SEARCH_QUERY_TOO_LONG' ||
|
|
1480
|
+
(error instanceof TypeError && /query must be a non-empty string/u.test(error.message))
|
|
1481
|
+
) {
|
|
1482
|
+
const tooLong = error?.code === 'PDF_SEARCH_QUERY_TOO_LONG'
|
|
1483
|
+
sendJson(response, 400, { ok: false, error: tooLong ? 'query-too-long' : 'missing-query' })
|
|
1484
|
+
return
|
|
1485
|
+
}
|
|
1208
1486
|
sendJson(response, 500, {
|
|
1209
1487
|
ok: false,
|
|
1210
1488
|
error: 'search-failed',
|
|
@@ -1286,6 +1564,97 @@ export function createRouteHandler({ config, pdfCore, infoCache, positions, stat
|
|
|
1286
1564
|
return
|
|
1287
1565
|
}
|
|
1288
1566
|
|
|
1567
|
+
if (url.pathname === `${ROUTE_PATH}/view`) {
|
|
1568
|
+
const rememberView = config.rememberView !== false
|
|
1569
|
+
if (method === 'GET') {
|
|
1570
|
+
const path = validateViewPath(url.searchParams.get('path') ?? '')
|
|
1571
|
+
if (path === null) {
|
|
1572
|
+
sendJson(response, 400, { ok: false, error: 'invalid-path' })
|
|
1573
|
+
return
|
|
1574
|
+
}
|
|
1575
|
+
// Disabled remembering and unavailable storage both answer the same
|
|
1576
|
+
// non-blocking shape, so restoring a book never fails the preview.
|
|
1577
|
+
if (!rememberView) {
|
|
1578
|
+
sendJson(response, 200, { ok: true, view: null })
|
|
1579
|
+
return
|
|
1580
|
+
}
|
|
1581
|
+
let view = null
|
|
1582
|
+
try {
|
|
1583
|
+
view = await views.read(path)
|
|
1584
|
+
} catch {
|
|
1585
|
+
view = null
|
|
1586
|
+
}
|
|
1587
|
+
sendJson(response, 200, { ok: true, view: view ?? null })
|
|
1588
|
+
return
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
if (method === 'POST') {
|
|
1592
|
+
const contentType = String(request.headers['content-type'] ?? '').toLowerCase()
|
|
1593
|
+
if (!contentType.startsWith('application/json')) {
|
|
1594
|
+
sendJson(response, 415, { ok: false, error: 'unsupported-media-type' })
|
|
1595
|
+
return
|
|
1596
|
+
}
|
|
1597
|
+
let body
|
|
1598
|
+
try {
|
|
1599
|
+
body = await readJsonBody(request, config.maxRouteBodyBytes)
|
|
1600
|
+
} catch (error) {
|
|
1601
|
+
const status = Number.isInteger(error?.status) ? error.status : 400
|
|
1602
|
+
sendJson(response, status, { ok: false, error: error?.error ?? 'invalid-body' })
|
|
1603
|
+
return
|
|
1604
|
+
}
|
|
1605
|
+
const path = validateViewPath(body.path)
|
|
1606
|
+
if (path === null) {
|
|
1607
|
+
sendJson(response, 400, { ok: false, error: 'invalid-path' })
|
|
1608
|
+
return
|
|
1609
|
+
}
|
|
1610
|
+
if (!Number.isInteger(body.pdfPage) || body.pdfPage < 1 || body.pdfPage > MAX_VIEW_PAGE) {
|
|
1611
|
+
sendJson(response, 400, { ok: false, error: 'invalid-page' })
|
|
1612
|
+
return
|
|
1613
|
+
}
|
|
1614
|
+
if (body.zoomMode !== 'fit-width' && body.zoomMode !== 'fixed') {
|
|
1615
|
+
sendJson(response, 400, { ok: false, error: 'invalid-zoom-mode' })
|
|
1616
|
+
return
|
|
1617
|
+
}
|
|
1618
|
+
if (
|
|
1619
|
+
typeof body.zoomScale !== 'number' ||
|
|
1620
|
+
!Number.isFinite(body.zoomScale) ||
|
|
1621
|
+
body.zoomScale < VIEW_ZOOM_SCALE_MIN ||
|
|
1622
|
+
body.zoomScale > VIEW_ZOOM_SCALE_MAX
|
|
1623
|
+
) {
|
|
1624
|
+
sendJson(response, 400, { ok: false, error: 'invalid-zoom-scale' })
|
|
1625
|
+
return
|
|
1626
|
+
}
|
|
1627
|
+
// Remembering may be disabled while previews still talk to the route:
|
|
1628
|
+
// validation stays complete, the write becomes a deliberate no-op.
|
|
1629
|
+
if (!rememberView) {
|
|
1630
|
+
sendJson(response, 200, { ok: true })
|
|
1631
|
+
return
|
|
1632
|
+
}
|
|
1633
|
+
let stored = false
|
|
1634
|
+
try {
|
|
1635
|
+
stored =
|
|
1636
|
+
(await views.write({
|
|
1637
|
+
path,
|
|
1638
|
+
pdfPage: body.pdfPage,
|
|
1639
|
+
zoomMode: body.zoomMode,
|
|
1640
|
+
zoomScale: body.zoomScale,
|
|
1641
|
+
updatedAt: Date.now(),
|
|
1642
|
+
})) === true
|
|
1643
|
+
} catch {
|
|
1644
|
+
stored = false
|
|
1645
|
+
}
|
|
1646
|
+
if (!stored) {
|
|
1647
|
+
sendJson(response, 503, { ok: false, error: 'storage-unavailable' })
|
|
1648
|
+
return
|
|
1649
|
+
}
|
|
1650
|
+
sendJson(response, 200, { ok: true })
|
|
1651
|
+
return
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
sendJson(response, 405, { ok: false, error: 'method-not-allowed' })
|
|
1655
|
+
return
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1289
1658
|
sendJson(response, 404, { ok: false, error: 'not-found' })
|
|
1290
1659
|
}
|
|
1291
1660
|
|
|
@@ -1331,6 +1700,52 @@ export function trimPageText(raw, maxChars) {
|
|
|
1331
1700
|
return { text: normalized.slice(0, maxChars), truncated: true, unavailable: false }
|
|
1332
1701
|
}
|
|
1333
1702
|
|
|
1703
|
+
/** Longest search query accepted after wrapper/whitespace cleanup. */
|
|
1704
|
+
const MAX_SEARCH_QUERY_LENGTH = 1000
|
|
1705
|
+
|
|
1706
|
+
/** Whole-query wrappers the model may add around a search query. */
|
|
1707
|
+
const SEARCH_QUERY_WRAPPERS = [
|
|
1708
|
+
['"', '"'],
|
|
1709
|
+
["'", "'"],
|
|
1710
|
+
['“', '”'],
|
|
1711
|
+
['‘', '’'],
|
|
1712
|
+
['`', '`'],
|
|
1713
|
+
['$', '$'],
|
|
1714
|
+
]
|
|
1715
|
+
|
|
1716
|
+
/**
|
|
1717
|
+
* Tool/route-side search-query cleanup: trim, strip a pair of wrapping
|
|
1718
|
+
* quotes/backticks/`$…$`, collapse whitespace, and reject empty/over-long
|
|
1719
|
+
* input. Mirrors `cleanSearchQuery()` in core/pdf.js; kept local because the
|
|
1720
|
+
* PDF core is imported lazily, only when a PDF operation actually runs.
|
|
1721
|
+
*
|
|
1722
|
+
* @param {unknown} raw
|
|
1723
|
+
* @returns {string}
|
|
1724
|
+
*/
|
|
1725
|
+
function cleanPdfSearchQuery(raw) {
|
|
1726
|
+
if (typeof raw !== 'string') throw new TypeError('query must be a non-empty string')
|
|
1727
|
+
|
|
1728
|
+
let query = raw.trim()
|
|
1729
|
+
let stripped = true
|
|
1730
|
+
while (stripped && query.length >= 2) {
|
|
1731
|
+
stripped = false
|
|
1732
|
+
for (const [open, close] of SEARCH_QUERY_WRAPPERS) {
|
|
1733
|
+
if (query.startsWith(open) && query.endsWith(close)) {
|
|
1734
|
+
query = query.slice(open.length, query.length - close.length).trim()
|
|
1735
|
+
stripped = true
|
|
1736
|
+
break
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
query = query.replace(/\s+/gu, ' ').trim()
|
|
1742
|
+
if (query === '') throw new TypeError('query must be a non-empty string')
|
|
1743
|
+
if (query.length > MAX_SEARCH_QUERY_LENGTH) {
|
|
1744
|
+
throw new RangeError(`query is longer than ${MAX_SEARCH_QUERY_LENGTH} characters`)
|
|
1745
|
+
}
|
|
1746
|
+
return query
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1334
1749
|
/** Register `pdf_info` and `pdf_search`. */
|
|
1335
1750
|
export function createCheapTools({ defineTool: makeTool, config, core, infoCache, statFile = stat }) {
|
|
1336
1751
|
return [
|
|
@@ -1440,16 +1855,36 @@ export function createCheapTools({ defineTool: makeTool, config, core, infoCache
|
|
|
1440
1855
|
makeTool({
|
|
1441
1856
|
name: 'pdf_search',
|
|
1442
1857
|
description:
|
|
1443
|
-
'Search a PDF\'s text layer; returns page numbers and
|
|
1858
|
+
'Search a PDF\'s text layer; returns page numbers, snippets, and the matching mode. Literal-ish search over normalized text: case-insensitive by default, tolerant of wrapping quotes/backticks and `$…$` LaTeX markup, hyphen/space-insensitive fallback, then term-AND fallback when no literal match. Body pages rank before front matter. Pass `pages` to restrict the scan to a page range (printed pages by default), e.g. when reading a later chapter. Text only, cheap.',
|
|
1444
1859
|
parameters: {
|
|
1445
1860
|
path: {
|
|
1446
1861
|
type: 'string',
|
|
1447
1862
|
required: true,
|
|
1448
1863
|
description: 'Absolute .pdf path (relative resolves against the session directory).',
|
|
1449
1864
|
},
|
|
1450
|
-
query: {
|
|
1865
|
+
query: {
|
|
1866
|
+
type: 'string',
|
|
1867
|
+
required: true,
|
|
1868
|
+
description:
|
|
1869
|
+
'Text or formula to find. Wrapping quotes/backticks/$…$ are stripped; use a distinctive phrase.',
|
|
1870
|
+
},
|
|
1871
|
+
pages: {
|
|
1872
|
+
description:
|
|
1873
|
+
'Optional 1-based pages to search within: "132-140", "132,135", 132, or [132,133]. Interpreted as printed numbers when the PDF has an offset, unless page_numbers is "pdf". Use it to scope a search to the chapter the user is reading.',
|
|
1874
|
+
oneOf: [
|
|
1875
|
+
{ type: 'integer' },
|
|
1876
|
+
{ type: 'string' },
|
|
1877
|
+
{ type: 'array', items: { oneOf: [{ type: 'integer' }, { type: 'string' }] } },
|
|
1878
|
+
],
|
|
1879
|
+
},
|
|
1880
|
+
page_numbers: {
|
|
1881
|
+
type: 'string',
|
|
1882
|
+
enum: ['printed', 'pdf'],
|
|
1883
|
+
description:
|
|
1884
|
+
'How to interpret `pages`. Defaults to "printed" when the PDF exposes numeric printed-page labels, otherwise "pdf".',
|
|
1885
|
+
},
|
|
1451
1886
|
ignore_case: { type: 'boolean', description: 'Case-insensitive search. Defaults to true.' },
|
|
1452
|
-
limit: { type: 'integer', description: 'Maximum hits to return. Defaults to
|
|
1887
|
+
limit: { type: 'integer', description: 'Maximum hits to return. Defaults to 30; capped by plugin config.' },
|
|
1453
1888
|
},
|
|
1454
1889
|
output: {
|
|
1455
1890
|
schema: {
|
|
@@ -1463,6 +1898,13 @@ export function createCheapTools({ defineTool: makeTool, config, core, infoCache
|
|
|
1463
1898
|
required: true,
|
|
1464
1899
|
description: 'Number of pages with at least one hit; may exceed the returned hit count when limit truncates.',
|
|
1465
1900
|
},
|
|
1901
|
+
mode: {
|
|
1902
|
+
type: 'string',
|
|
1903
|
+
enum: ['phrase', 'compact', 'terms'],
|
|
1904
|
+
required: true,
|
|
1905
|
+
description:
|
|
1906
|
+
'Which pass produced the hits: literal phrase, space-insensitive, or all-terms.',
|
|
1907
|
+
},
|
|
1466
1908
|
hits: {
|
|
1467
1909
|
type: 'array',
|
|
1468
1910
|
required: true,
|
|
@@ -1486,13 +1928,51 @@ export function createCheapTools({ defineTool: makeTool, config, core, infoCache
|
|
|
1486
1928
|
async execute(args, exec) {
|
|
1487
1929
|
const path = resolveToolPath(args.path, exec)
|
|
1488
1930
|
assertInsideWorkspace(path, exec, config)
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1931
|
+
let query
|
|
1932
|
+
try {
|
|
1933
|
+
query = cleanPdfSearchQuery(args.query)
|
|
1934
|
+
} catch (error) {
|
|
1935
|
+
throw codedError(
|
|
1936
|
+
error instanceof Error ? error.message : String(error),
|
|
1937
|
+
'PDF_READER_INVALID_QUERY',
|
|
1938
|
+
)
|
|
1939
|
+
}
|
|
1492
1940
|
await assertPdfFile(path, { statFile, maxFileBytes: config.maxFileBytes })
|
|
1493
|
-
const limit = Math.max(1, Math.min(Number.isInteger(args.limit) ? args.limit :
|
|
1941
|
+
const limit = Math.max(1, Math.min(Number.isInteger(args.limit) ? args.limit : 30, config.maxSearchResults))
|
|
1494
1942
|
const ignoreCase = args.ignore_case !== false
|
|
1495
|
-
|
|
1943
|
+
let pdfPages
|
|
1944
|
+
if (args.pages !== undefined) {
|
|
1945
|
+
const info = (await infoCache.load(path)).info
|
|
1946
|
+
const pageCount = Number.isInteger(info?.pageCount) && info.pageCount > 0
|
|
1947
|
+
? info.pageCount
|
|
1948
|
+
: PAGE_SELECTION_HARD_LIMIT
|
|
1949
|
+
const resolved = resolveRequestedPages({
|
|
1950
|
+
selection: args.pages,
|
|
1951
|
+
pageNumbers: args.page_numbers,
|
|
1952
|
+
info,
|
|
1953
|
+
maxPages: pageCount,
|
|
1954
|
+
hardLimit: Math.max(PAGE_SELECTION_HARD_LIMIT, pageCount),
|
|
1955
|
+
})
|
|
1956
|
+
pdfPages = resolved.pdfPages
|
|
1957
|
+
}
|
|
1958
|
+
let result
|
|
1959
|
+
try {
|
|
1960
|
+
result = await core.searchPdf(path, query, {
|
|
1961
|
+
ignoreCase,
|
|
1962
|
+
limit,
|
|
1963
|
+
...(pdfPages === undefined ? {} : { pdfPages }),
|
|
1964
|
+
})
|
|
1965
|
+
} catch (error) {
|
|
1966
|
+
// Cleanup above already rejects ordinary empties; core also rejects a
|
|
1967
|
+
// query that normalization erases entirely (e.g. "---").
|
|
1968
|
+
if (
|
|
1969
|
+
error?.code === 'PDF_SEARCH_QUERY_TOO_LONG' ||
|
|
1970
|
+
(error instanceof TypeError && /query must be a non-empty string/u.test(error.message))
|
|
1971
|
+
) {
|
|
1972
|
+
throw codedError(error.message, 'PDF_READER_INVALID_QUERY')
|
|
1973
|
+
}
|
|
1974
|
+
throw error
|
|
1975
|
+
}
|
|
1496
1976
|
const rawHits = Array.isArray(result?.hits) ? result.hits : []
|
|
1497
1977
|
const hits = rawHits.slice(0, limit).map((hit) => ({
|
|
1498
1978
|
pdf: Number(hit.pdf),
|
|
@@ -1504,6 +1984,7 @@ export function createCheapTools({ defineTool: makeTool, config, core, infoCache
|
|
|
1504
1984
|
path,
|
|
1505
1985
|
query,
|
|
1506
1986
|
pages: Number.isInteger(result?.pages) ? result.pages : new Set(hits.map((hit) => hit.pdf)).size,
|
|
1987
|
+
mode: ['phrase', 'compact', 'terms'].includes(result?.mode) ? result.mode : 'phrase',
|
|
1507
1988
|
hits,
|
|
1508
1989
|
}
|
|
1509
1990
|
},
|
|
@@ -1604,7 +2085,7 @@ export function createReadPagesTool({ defineTool: makeTool, ctx, config, core, i
|
|
|
1604
2085
|
pdf: { type: 'integer', required: true },
|
|
1605
2086
|
printed: { oneOf: [{ type: 'integer' }, { type: 'string' }], required: true },
|
|
1606
2087
|
tokens: { type: 'integer' },
|
|
1607
|
-
image:
|
|
2088
|
+
image: optionalPropertySchema(IMAGE_VALUE_SCHEMA),
|
|
1608
2089
|
text: { type: 'string' },
|
|
1609
2090
|
textTruncated: { type: 'boolean' },
|
|
1610
2091
|
textUnavailable: { type: 'boolean' },
|
|
@@ -1807,6 +2288,7 @@ export function apply(ctx, rawConfig) {
|
|
|
1807
2288
|
limit: config.infoCacheSize,
|
|
1808
2289
|
})
|
|
1809
2290
|
const positions = createPositionStore()
|
|
2291
|
+
const views = createViewStateAdapter()
|
|
1810
2292
|
|
|
1811
2293
|
const routeHandler = createRouteHandler({
|
|
1812
2294
|
config,
|
|
@@ -1814,6 +2296,7 @@ export function apply(ctx, rawConfig) {
|
|
|
1814
2296
|
infoCache,
|
|
1815
2297
|
positions,
|
|
1816
2298
|
statFile: stat,
|
|
2299
|
+
views,
|
|
1817
2300
|
})
|
|
1818
2301
|
ctx.effect(
|
|
1819
2302
|
() => ctx.webServer.register({ kind: 'prefix', path: ROUTE_PATH, handler: routeHandler }),
|
|
@@ -1843,6 +2326,53 @@ export function apply(ctx, rawConfig) {
|
|
|
1843
2326
|
)
|
|
1844
2327
|
})
|
|
1845
2328
|
|
|
2329
|
+
if (config.rememberView) {
|
|
2330
|
+
ctx.inject(['storageDomain'], (storageCtx) => {
|
|
2331
|
+
let disposed = false
|
|
2332
|
+
let domain = null
|
|
2333
|
+
|
|
2334
|
+
async function closeDomain() {
|
|
2335
|
+
const handle = domain
|
|
2336
|
+
domain = null
|
|
2337
|
+
if (handle === null) return
|
|
2338
|
+
try {
|
|
2339
|
+
await handle.close()
|
|
2340
|
+
} catch (error) {
|
|
2341
|
+
views.warnOnce('remembered page/zoom storage did not close cleanly', error)
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// Opening is asynchronous while the route is already answering: the
|
|
2346
|
+
// adapter stays unavailable until attach(), and reverts to unavailable
|
|
2347
|
+
// on unload. Every failure leaves the feature disabled, never thrown.
|
|
2348
|
+
const opening = (async () => {
|
|
2349
|
+
try {
|
|
2350
|
+
domain = await openViewStateDomain(storageCtx.storageDomain)
|
|
2351
|
+
} catch (error) {
|
|
2352
|
+
views.warnOnce('remembered page/zoom storage is unavailable', error)
|
|
2353
|
+
return
|
|
2354
|
+
}
|
|
2355
|
+
if (disposed) {
|
|
2356
|
+
await closeDomain()
|
|
2357
|
+
return
|
|
2358
|
+
}
|
|
2359
|
+
try {
|
|
2360
|
+
views.attach(domain.table(VIEW_TABLE_NAME))
|
|
2361
|
+
} catch (error) {
|
|
2362
|
+
views.warnOnce('remembered page/zoom storage is unavailable', error)
|
|
2363
|
+
await closeDomain()
|
|
2364
|
+
}
|
|
2365
|
+
})()
|
|
2366
|
+
|
|
2367
|
+
return async () => {
|
|
2368
|
+
disposed = true
|
|
2369
|
+
views.detach()
|
|
2370
|
+
await opening
|
|
2371
|
+
await closeDomain()
|
|
2372
|
+
}
|
|
2373
|
+
})
|
|
2374
|
+
}
|
|
2375
|
+
|
|
1846
2376
|
if (config.autoShare) {
|
|
1847
2377
|
const listener = createAutoShareListener({ config, positions, infoCache })
|
|
1848
2378
|
ctx.on('agent/pre-step', listener, { prepend: true })
|