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/CHANGELOG.md +41 -1
- package/README.md +35 -475
- package/README.zh-CN.md +55 -0
- package/SKILL.md +1 -2
- package/cordis.patch.yml +9 -8
- package/lib/client.js +513 -22
- package/lib/index.js +415 -104
- package/package.json +15 -6
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:
|
|
16
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
59
|
-
autoShareMinIntervalMs: z.number().default(
|
|
60
|
-
/** Minimum absolute page change required before another
|
|
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:
|
|
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
|
|
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
|
|
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,
|
|
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 (
|
|
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({
|
|
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
|
|
1343
|
-
export function createCheapTools({ defineTool: makeTool, config, core, infoCache,
|
|
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 })
|