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/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({ config, pdfCore, infoCache, positions, statFile = stat }) {
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
- const query = (url.searchParams.get('q') ?? '').trim()
1179
- if (query === '') {
1180
- sendJson(response, 400, { ok: false, error: 'missing-query' })
1181
- return
1182
- }
1183
- if (query.length > 1000) {
1184
- sendJson(response, 400, { ok: false, error: 'query-too-long' })
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
- sendJson(response, 200, { ok: true, hits })
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 snippets. Text only, cheap.',
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: { type: 'string', required: true, description: 'Literal text to search for.' },
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 20; capped by plugin config.' },
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
- const query = typeof args.query === 'string' ? args.query.trim() : ''
1490
- if (query === '') throw codedError('query must be a non-empty string', 'PDF_READER_INVALID_QUERY')
1491
- if (query.length > 1000) throw codedError('query is longer than 1000 characters', 'PDF_READER_INVALID_QUERY')
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 : 20, config.maxSearchResults))
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
- const result = await core.searchPdf(path, query, { ignoreCase, limit })
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: { ...IMAGE_VALUE_SCHEMA, required: false },
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 })