dsh-book-reader 0.1.0 → 0.2.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
@@ -12,13 +12,14 @@
12
12
  * `.pdf` suffix, regular file, size cap, JSON only, no CORS header.
13
13
  * - Page images use the same attachment route as `read_image`: a durable
14
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.
15
+ * - Auto-share is per-session and deliberately conservative: at most one
16
+ * message per turn, and only when the page changed since the last hint.
17
17
  *
18
18
  * @module dsh-pdf-reader
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,16 +52,16 @@ 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),
54
- /** Inject the current reading page into `agent/pre-step`. */
55
+ /** Remember page/zoom per PDF and restore on reopen. */
56
+ rememberView: z.boolean().default(true),
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). */
57
60
  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
+ /** Optional minimum milliseconds between hints; 0 = no time throttle. */
62
+ autoShareMinIntervalMs: z.number().default(0),
63
+ /** Minimum absolute page change required before another hint. */
61
64
  autoSharePageDelta: z.number().default(1),
62
- /** Minimum step distance between injections in the same turn. */
63
- autoShareMinSteps: z.number().default(1),
64
65
  /** Ignore a reported position older than this; 0 means no age limit. */
65
66
  autoShareMaxAgeMs: z.number().default(0),
66
67
  /** Hard cap on pages rendered by one `pdf_read_pages` call. */
@@ -96,11 +97,11 @@ export const Config = z.object({
96
97
  /** Defaults applied independently of the loader, so direct callers work too. */
97
98
  export const DEFAULT_CONFIG = Object.freeze({
98
99
  enabled: true,
100
+ rememberView: true,
99
101
  autoShare: true,
100
102
  autoShareSection: true,
101
- autoShareMinIntervalMs: 180_000,
103
+ autoShareMinIntervalMs: 0,
102
104
  autoSharePageDelta: 1,
103
- autoShareMinSteps: 1,
104
105
  autoShareMaxAgeMs: 0,
105
106
  maxPagesPerCall: 4,
106
107
  defaultScale: 2.5,
@@ -124,7 +125,6 @@ export const INITIAL_AUTO_SHARE_STATE = Object.freeze({
124
125
  lastInjectedPdfPage: null,
125
126
  lastInjectedAt: null,
126
127
  lastInjectedTurn: null,
127
- lastInjectedStep: null,
128
128
  })
129
129
 
130
130
  /** Create an Error carrying a stable code, for tests and route mapping. */
@@ -170,6 +170,7 @@ export function normalizeConfig(raw) {
170
170
  if (typeof source !== 'object' || Array.isArray(source)) throw new TypeError('pdf-reader: config must be an object')
171
171
  return {
172
172
  enabled: configBoolean(source.enabled, DEFAULT_CONFIG.enabled, 'enabled'),
173
+ rememberView: configBoolean(source.rememberView, DEFAULT_CONFIG.rememberView, 'rememberView'),
173
174
  autoShare: configBoolean(source.autoShare, DEFAULT_CONFIG.autoShare, 'autoShare'),
174
175
  autoShareSection: configBoolean(source.autoShareSection, DEFAULT_CONFIG.autoShareSection, 'autoShareSection'),
175
176
  autoShareMinIntervalMs: configInteger(
@@ -180,7 +181,6 @@ export function normalizeConfig(raw) {
180
181
  86_400_000,
181
182
  ),
182
183
  autoSharePageDelta: configInteger(source.autoSharePageDelta, DEFAULT_CONFIG.autoSharePageDelta, 'autoSharePageDelta', 1, 1000),
183
- autoShareMinSteps: configInteger(source.autoShareMinSteps, DEFAULT_CONFIG.autoShareMinSteps, 'autoShareMinSteps', 1, 1000),
184
184
  autoShareMaxAgeMs: configInteger(source.autoShareMaxAgeMs, DEFAULT_CONFIG.autoShareMaxAgeMs, 'autoShareMaxAgeMs', 0, 31_536_000_000),
185
185
  maxPagesPerCall: configInteger(
186
186
  source.maxPagesPerCall,
@@ -421,6 +421,233 @@ export function createPositionStore({ now = Date.now, maxSessions = 200, maxPath
421
421
  }
422
422
  }
423
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
+
424
651
  /** Normalize unicode dashes so a pasted page range parses. */
425
652
  function normalizeDashes(value) {
426
653
  return value.replace(/[\u2010-\u2015\u2212]/gu, '-')
@@ -738,12 +965,15 @@ function sameInjectedPage(state, position) {
738
965
  }
739
966
 
740
967
  /**
741
- * Decide whether this step should receive a reading-position message.
968
+ * Decide whether this turn should receive a reading-position message.
969
+ *
970
+ * One hint per turn at most: the guard is the turn number, not the step, and
971
+ * the hint is only sent when the reported page changed since the last one.
742
972
  * Pure: callers own the state map and the clock.
743
- * @param options - position, prior state, turn/step, clock, and config.
973
+ * @param options - position, prior state, turn, clock, and config.
744
974
  * @returns `{ inject, reason, nextState }`; `nextState` is unchanged when false.
745
975
  */
746
- export function decideAutoShare({ position, state = INITIAL_AUTO_SHARE_STATE, now, turn, step, config = DEFAULT_CONFIG }) {
976
+ export function decideAutoShare({ position, state = INITIAL_AUTO_SHARE_STATE, now, turn, config = DEFAULT_CONFIG }) {
747
977
  const keep = { inject: false, reason: '', nextState: state }
748
978
  if (!config.autoShare) return { ...keep, reason: 'disabled' }
749
979
  if (position === null || position === undefined) return { ...keep, reason: 'no-position' }
@@ -752,15 +982,11 @@ export function decideAutoShare({ position, state = INITIAL_AUTO_SHARE_STATE, no
752
982
  if (typeof position.updatedAt !== 'number') return { ...keep, reason: 'unknown-age' }
753
983
  if (now - position.updatedAt > config.autoShareMaxAgeMs) return { ...keep, reason: 'stale' }
754
984
  }
755
- if (state.lastInjectedAt !== null && state.lastInjectedAt !== undefined) {
985
+ if (Number.isInteger(turn) && state.lastInjectedTurn === turn) return { ...keep, reason: 'same-turn' }
986
+ if (config.autoShareMinIntervalMs > 0 && state.lastInjectedAt !== null && state.lastInjectedAt !== undefined) {
756
987
  const elapsed = now - state.lastInjectedAt
757
988
  if (!(elapsed >= config.autoShareMinIntervalMs)) return { ...keep, reason: 'throttled' }
758
989
  }
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
990
  if (
765
991
  config.autoSharePageDelta > 1 &&
766
992
  state.lastInjectedPdfPage !== null &&
@@ -777,7 +1003,6 @@ export function decideAutoShare({ position, state = INITIAL_AUTO_SHARE_STATE, no
777
1003
  lastInjectedPdfPage: position.pdfPage,
778
1004
  lastInjectedAt: now,
779
1005
  lastInjectedTurn: Number.isInteger(turn) ? turn : null,
780
- lastInjectedStep: Number.isInteger(step) ? step : null,
781
1006
  },
782
1007
  }
783
1008
  }
@@ -823,7 +1048,6 @@ export function createAutoShareListener({
823
1048
  state,
824
1049
  now: now(),
825
1050
  turn: payload.turn,
826
- step: payload.step,
827
1051
  config,
828
1052
  })
829
1053
  if (!verdict.inject) return decision
@@ -1083,8 +1307,33 @@ function createLazyCore() {
1083
1307
  }
1084
1308
  }
1085
1309
 
1310
+ /**
1311
+ * Validate a `/view` book path without touching the filesystem.
1312
+ *
1313
+ * The remembered-view route deliberately does not stat the PDF: the client
1314
+ * may report a state for a book whose preview is still opening, and restoring
1315
+ * a missing book's state is harmless. The shape and size checks still mirror
1316
+ * the rest of the route surface.
1317
+ * @param rawPath - the raw query/body `path` value.
1318
+ * @returns the normalized absolute path, or `null` when invalid.
1319
+ */
1320
+ function validateViewPath(rawPath) {
1321
+ if (typeof rawPath !== 'string' || rawPath.length === 0 || rawPath.length > MAX_VIEW_PATH_LENGTH) return null
1322
+ if (!isAbsolute(rawPath)) return null
1323
+ const path = resolvePath(rawPath)
1324
+ if (!/\.pdf$/iu.test(path)) return null
1325
+ return path
1326
+ }
1327
+
1086
1328
  /** Build the route handler. Exported so tests can use fake core and stat. */
1087
- export function createRouteHandler({ config, pdfCore, infoCache, positions, statFile = stat }) {
1329
+ export function createRouteHandler({
1330
+ config,
1331
+ pdfCore,
1332
+ infoCache,
1333
+ positions,
1334
+ statFile = stat,
1335
+ views = createViewStateAdapter(),
1336
+ }) {
1088
1337
  async function loadPositionFor(sessionId, path) {
1089
1338
  if (typeof sessionId === 'string' && sessionId !== '') {
1090
1339
  return positions.forSession(sessionId)
@@ -1294,6 +1543,97 @@ export function createRouteHandler({ config, pdfCore, infoCache, positions, stat
1294
1543
  return
1295
1544
  }
1296
1545
 
1546
+ if (url.pathname === `${ROUTE_PATH}/view`) {
1547
+ const rememberView = config.rememberView !== false
1548
+ if (method === 'GET') {
1549
+ const path = validateViewPath(url.searchParams.get('path') ?? '')
1550
+ if (path === null) {
1551
+ sendJson(response, 400, { ok: false, error: 'invalid-path' })
1552
+ return
1553
+ }
1554
+ // Disabled remembering and unavailable storage both answer the same
1555
+ // non-blocking shape, so restoring a book never fails the preview.
1556
+ if (!rememberView) {
1557
+ sendJson(response, 200, { ok: true, view: null })
1558
+ return
1559
+ }
1560
+ let view = null
1561
+ try {
1562
+ view = await views.read(path)
1563
+ } catch {
1564
+ view = null
1565
+ }
1566
+ sendJson(response, 200, { ok: true, view: view ?? null })
1567
+ return
1568
+ }
1569
+
1570
+ if (method === 'POST') {
1571
+ const contentType = String(request.headers['content-type'] ?? '').toLowerCase()
1572
+ if (!contentType.startsWith('application/json')) {
1573
+ sendJson(response, 415, { ok: false, error: 'unsupported-media-type' })
1574
+ return
1575
+ }
1576
+ let body
1577
+ try {
1578
+ body = await readJsonBody(request, config.maxRouteBodyBytes)
1579
+ } catch (error) {
1580
+ const status = Number.isInteger(error?.status) ? error.status : 400
1581
+ sendJson(response, status, { ok: false, error: error?.error ?? 'invalid-body' })
1582
+ return
1583
+ }
1584
+ const path = validateViewPath(body.path)
1585
+ if (path === null) {
1586
+ sendJson(response, 400, { ok: false, error: 'invalid-path' })
1587
+ return
1588
+ }
1589
+ if (!Number.isInteger(body.pdfPage) || body.pdfPage < 1 || body.pdfPage > MAX_VIEW_PAGE) {
1590
+ sendJson(response, 400, { ok: false, error: 'invalid-page' })
1591
+ return
1592
+ }
1593
+ if (body.zoomMode !== 'fit-width' && body.zoomMode !== 'fixed') {
1594
+ sendJson(response, 400, { ok: false, error: 'invalid-zoom-mode' })
1595
+ return
1596
+ }
1597
+ if (
1598
+ typeof body.zoomScale !== 'number' ||
1599
+ !Number.isFinite(body.zoomScale) ||
1600
+ body.zoomScale < VIEW_ZOOM_SCALE_MIN ||
1601
+ body.zoomScale > VIEW_ZOOM_SCALE_MAX
1602
+ ) {
1603
+ sendJson(response, 400, { ok: false, error: 'invalid-zoom-scale' })
1604
+ return
1605
+ }
1606
+ // Remembering may be disabled while previews still talk to the route:
1607
+ // validation stays complete, the write becomes a deliberate no-op.
1608
+ if (!rememberView) {
1609
+ sendJson(response, 200, { ok: true })
1610
+ return
1611
+ }
1612
+ let stored = false
1613
+ try {
1614
+ stored =
1615
+ (await views.write({
1616
+ path,
1617
+ pdfPage: body.pdfPage,
1618
+ zoomMode: body.zoomMode,
1619
+ zoomScale: body.zoomScale,
1620
+ updatedAt: Date.now(),
1621
+ })) === true
1622
+ } catch {
1623
+ stored = false
1624
+ }
1625
+ if (!stored) {
1626
+ sendJson(response, 503, { ok: false, error: 'storage-unavailable' })
1627
+ return
1628
+ }
1629
+ sendJson(response, 200, { ok: true })
1630
+ return
1631
+ }
1632
+
1633
+ sendJson(response, 405, { ok: false, error: 'method-not-allowed' })
1634
+ return
1635
+ }
1636
+
1297
1637
  sendJson(response, 404, { ok: false, error: 'not-found' })
1298
1638
  }
1299
1639
 
@@ -1339,8 +1679,8 @@ export function trimPageText(raw, maxChars) {
1339
1679
  return { text: normalized.slice(0, maxChars), truncated: true, unavailable: false }
1340
1680
  }
1341
1681
 
1342
- /** Register `pdf_info`, `pdf_search`, and `pdf_current_page`. */
1343
- export function createCheapTools({ defineTool: makeTool, config, core, infoCache, positions, statFile = stat }) {
1682
+ /** Register `pdf_info` and `pdf_search`. */
1683
+ export function createCheapTools({ defineTool: makeTool, config, core, infoCache, statFile = stat }) {
1344
1684
  return [
1345
1685
  makeTool({
1346
1686
  name: 'pdf_info',
@@ -1522,83 +1862,6 @@ export function createCheapTools({ defineTool: makeTool, config, core, infoCache
1522
1862
  locations: [{ path: args.path }],
1523
1863
  }),
1524
1864
  }),
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
1865
  ]
1603
1866
  }
1604
1867
 
@@ -1892,6 +2155,7 @@ export function apply(ctx, rawConfig) {
1892
2155
  limit: config.infoCacheSize,
1893
2156
  })
1894
2157
  const positions = createPositionStore()
2158
+ const views = createViewStateAdapter()
1895
2159
 
1896
2160
  const routeHandler = createRouteHandler({
1897
2161
  config,
@@ -1899,6 +2163,7 @@ export function apply(ctx, rawConfig) {
1899
2163
  infoCache,
1900
2164
  positions,
1901
2165
  statFile: stat,
2166
+ views,
1902
2167
  })
1903
2168
  ctx.effect(
1904
2169
  () => ctx.webServer.register({ kind: 'prefix', path: ROUTE_PATH, handler: routeHandler }),
@@ -1910,7 +2175,6 @@ export function apply(ctx, rawConfig) {
1910
2175
  config,
1911
2176
  core,
1912
2177
  infoCache,
1913
- positions,
1914
2178
  statFile: stat,
1915
2179
  })) {
1916
2180
  ctx.tools.register(tool)
@@ -1929,6 +2193,53 @@ export function apply(ctx, rawConfig) {
1929
2193
  )
1930
2194
  })
1931
2195
 
2196
+ if (config.rememberView) {
2197
+ ctx.inject(['storageDomain'], (storageCtx) => {
2198
+ let disposed = false
2199
+ let domain = null
2200
+
2201
+ async function closeDomain() {
2202
+ const handle = domain
2203
+ domain = null
2204
+ if (handle === null) return
2205
+ try {
2206
+ await handle.close()
2207
+ } catch (error) {
2208
+ views.warnOnce('remembered page/zoom storage did not close cleanly', error)
2209
+ }
2210
+ }
2211
+
2212
+ // Opening is asynchronous while the route is already answering: the
2213
+ // adapter stays unavailable until attach(), and reverts to unavailable
2214
+ // on unload. Every failure leaves the feature disabled, never thrown.
2215
+ const opening = (async () => {
2216
+ try {
2217
+ domain = await openViewStateDomain(storageCtx.storageDomain)
2218
+ } catch (error) {
2219
+ views.warnOnce('remembered page/zoom storage is unavailable', error)
2220
+ return
2221
+ }
2222
+ if (disposed) {
2223
+ await closeDomain()
2224
+ return
2225
+ }
2226
+ try {
2227
+ views.attach(domain.table(VIEW_TABLE_NAME))
2228
+ } catch (error) {
2229
+ views.warnOnce('remembered page/zoom storage is unavailable', error)
2230
+ await closeDomain()
2231
+ }
2232
+ })()
2233
+
2234
+ return async () => {
2235
+ disposed = true
2236
+ views.detach()
2237
+ await opening
2238
+ await closeDomain()
2239
+ }
2240
+ })
2241
+ }
2242
+
1932
2243
  if (config.autoShare) {
1933
2244
  const listener = createAutoShareListener({ config, positions, infoCache })
1934
2245
  ctx.on('agent/pre-step', listener, { prepend: true })