dsh-book-reader 0.1.0 → 0.1.1

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 CHANGED
@@ -4,6 +4,29 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.1.1] — 2026-10-18
8
+
9
+ ### Changed
10
+
11
+ - Simplified, feature-focused README (English + 中文) that leads with what the
12
+ plugin does instead of full internals documentation.
13
+ - The automatic reading-position hint is now sent **at most once per turn**, and
14
+ only when the page changed. The per-step guard and the 3-minute default
15
+ throttle are gone: `autoShareMinSteps` was removed and
16
+ `autoShareMinIntervalMs` now defaults to `0` (optional extra throttle).
17
+ - Repository, homepage, and issue links now point to
18
+ `github.com/Strepsiades2/dsh-book-reader`.
19
+
20
+ ### Removed
21
+
22
+ - The `pdf_current_page` tool. The per-turn position hint (or a page reference
23
+ you insert yourself) now covers “which page am I on”, leaving three tools:
24
+ `pdf_info`, `pdf_search`, and `pdf_read_pages`.
25
+ - The toolbar's manual “insert current page reference” (↗) button. The
26
+ automatic per-turn position hint already tells the model the current page, so
27
+ the button was redundant. The search panel's per-result “→ AI” insertion
28
+ remains.
29
+
7
30
  ## [0.1.0] — 2026-10-18
8
31
 
9
32
  First public release.
@@ -33,4 +56,5 @@ First public release.
33
56
  - `pdfjs-dist` is pinned to `6.3.289` to match the host process.
34
57
  - MIT licensed.
35
58
 
36
- [0.1.0]: https://github.com/strepsiades/dsh-book-reader/releases/tag/v0.1.0
59
+ [0.1.1]: https://github.com/Strepsiades2/dsh-book-reader/releases/tag/v0.1.1
60
+ [0.1.0]: https://github.com/Strepsiades2/dsh-book-reader/releases/tag/v0.1.0
package/README.md CHANGED
@@ -1,495 +1,101 @@
1
1
  # dsh-book-reader
2
2
 
3
- Read books and papers in DSH: page-aware controls on the official PDF preview, plus cheap, text-first PDF tools for the model.
3
+ Read books and papers in DSH: a reading toolbar on the official PDF preview, plus three PDF tools for the model.
4
4
 
5
5
  **English** | [中文](#中文说明)
6
6
 
7
- ## Overview
7
+ ## Features
8
8
 
9
- `dsh-book-reader` decorates the official PDF preview in DSH's right sidebar with a small reading toolbar and registers four tools the model can call to read a PDF **text first**. It does not replace the previewer: zoom, fit-to-width, text selection, reload, and auto-refresh keep working, and removing the plugin restores the stock preview.
9
+ **Reading toolbar** — appears only on `.pdf` previews in the right sidebar; the stock previewer (zoom, selection, reload) is untouched.
10
10
 
11
- Controls are attached only to previews whose `absolutePath` ends in `.pdf`. PDFs are parsed and rendered in-process with `pdfjs-dist` + `@napi-rs/canvas`; the browser half adds its controls to the preview's document-header slot.
11
+ - Previous/next page, a page readout that follows scrolling, and a jump box that accepts printed page numbers (or `p152` for PDF page 152).
12
+ - Full-text search panel (`Cmd/Ctrl+Shift+F`): click a result to jump there, or insert its page reference into the chat.
13
+ - **AI position hint** (on by default): before a turn starts, the model may receive one short line with the page you are reading — at most once per turn, and only when the page changed since the last hint. Turn it off if you do not want the model to know your page automatically.
14
+ - Every control has a hover/focus explanation in English or Chinese, following the DSH UI language.
12
15
 
13
- ```
14
- ┌─ official PDF preview (PDF.js) ──────────────────────────┐
15
- │ reload zoom … │ ‹ › [132] / 410 ⌕ ↗ AI● │ ← added by this plugin
16
- ├──────────────────────────────────────────────────────────┤
17
- │ page content … │
18
- └──────────────────────────────────────────────────────────┘
19
- ```
16
+ **Model tools** — the model can look up the page map, search the text, or read chosen pages (as text or as page images) when you ask about a PDF.
20
17
 
21
- Design rule: **spend text tokens by default. Images are a deliberate expense, not the first move.**
22
-
23
- ### Features
24
-
25
- | Control | Behavior |
18
+ | Tool | What it does |
26
19
  | --- | --- |
27
- | `‹` / `›` | Previous / next **physical** PDF page (consecutive pages, regardless of printed labels). |
28
- | `[132] / 410` | Current-page readout that follows scrolling; the box is also a jump box (type a page and press `Enter`). Shows the printed page when an offset is known, otherwise the physical page. |
29
- | `⌕` | Opens the full-text search panel. Results read `p.132 — snippet`; click one to jump. Each row also has **`→ AI`**, which inserts `PDF p.132: <snippet>` at the composer caret. |
30
- | `↗` | Inserts `[PDF p.132]` at the composer caret as one undoable edit; it does not overwrite the draft. |
31
- | `AI` + dot | Reading-position reporting toggle (on by default). Dot: green = Host acknowledged, amber = sending, red = Host route failed, faded = off. Shared by all mounted PDF previews and not persisted across reloads. |
32
- | `Cmd/Ctrl + Shift + F` | Opens the search panel for the preview the pointer last claimed, and focuses/selects the query. `Cmd/Ctrl + F` is deliberately not intercepted. |
33
-
34
- `Esc` closes the panel; `Enter` in the page box jumps; `Enter` in the search box jumps to the first result. Tooltips explain every control on hover or keyboard focus, in the active DSH UI language (English or Chinese).
35
-
36
- After the readout resolves, the browser reports page changes to the Host with a 400 ms trailing debounce. When auto-share is enabled, the Host may inject at most one short, source-attributed position line per step (and, with the default interval, at most one every 3 minutes):
37
-
38
- ```text
39
- [dsh-pdf-reader] 正在阅读《book》printed 132 (PDF 152) · Chapter 5 › 5B
40
- ```
41
-
42
- That line is a **position marker only**; it does not tell the model to read anything. The model decides whether, what, and how to read, and can ask again with `pdf_current_page`. Injection is throttled by page change, a minimum interval (3 minutes by default), and step distance.
43
-
44
- ### Install
45
-
46
- #### a) Published package
47
-
48
- Install from the DSH **Plugins** page: **Add plugin** → `dsh-book-reader` (or pin the version with `dsh-book-reader@0.1.0`). The CLI equivalent is:
49
-
50
- ```sh
51
- dsh plugin --profile <name> add dsh-book-reader
52
- ```
53
-
54
- Restart DSH afterwards; the host half is loaded at startup. With the published package, consumers need **no manual package symlink and no npm command** — the optional skill registration in (c) is the only manual step.
55
-
56
- #### b) Local development checkout
57
-
58
- ```sh
59
- dsh plugin add "/abs/path/to/dsh-book-reader"
60
- # add --profile <name> after "plugin" when targeting a non-default profile
61
- ```
62
-
63
- Restart DSH after installing. The profile stores a `link:` to the checkout, so keep the checkout **and its `node_modules/`** in place: at runtime the Host imports `pdfjs-dist` and `@napi-rs/canvas` from that directory. The `node_modules/` in this checkout is macOS arm64; on another architecture run `npm install` in the checkout first. `engines.node` requires Node >= 20.
64
-
65
- Uninstall either way with `dsh plugin --profile <name> remove dsh-book-reader`, then restart DSH. Prefer enabling the plugin through the GUI toggle over hand-editing `dsh.profile.bundles`; if a profile fails to start after installing, remove the plugin row from the command line and restart.
66
-
67
- #### c) Skill registration
68
-
69
- ```sh
70
- mkdir -p "<workspace>/.dsh/skills"
71
- ln -sfn "/abs/path/to/dsh-book-reader" "<workspace>/.dsh/skills/pdf-reader"
72
- ```
73
-
74
- The package ships `SKILL.md`, but DSH packaging does **not** auto-register skills. The link name must be `pdf-reader` (the `name` in `SKILL.md`); for an npm-installed package, point it at the installed package directory. The tools work without the skill, but the skill gives the model the text-first loop, the token costs, and the pitfalls. Register it once per workspace.
75
-
76
- After publishing, installing the plugin by package name needs no manual symlink and no npm command for consumers; only this optional skill registration remains manual.
77
-
78
- ### Tools
79
-
80
- Four tools, three cost tiers. The default is text.
81
-
82
- | Tool | Cost | Use for |
83
- | --- | --- | --- |
84
- | `pdf_info` | ~0 (text) | Page count, printed↔physical offset, outline/TOC; also title, author, file size, page size, and `textCoverage.isLikelyScanned`. |
85
- | `pdf_search` | ~0 (text) | Locating a phrase, theorem number, or figure caption. |
86
- | `pdf_read_pages` + `include_image: false` | ~0 (text, per page) | Prose, theorem statements, exercise text. |
87
- | `pdf_read_pages` (images on) | **~350–1024 tokens/page** | Formulas, diagrams, tables, layout. |
88
- | `pdf_current_page` | ~0 | Which page and section the user is looking at. |
89
-
90
- `pdf_read_pages` is the only image-returning tool. It is registered only while `ctx.attachments` is mounted (so PNGs can become durable image attachments), and every call fails if the routed model does not declare image input — even with `include_image: false`.
91
-
92
- Page numbers are **1-based**, and relative paths resolve against the session working directory. `pages` accepts `"132-134"`, `"132,135"`, `132`, `[132,133,134]`, or an array of range strings. Other useful arguments:
93
-
94
- - `page_numbers`: `"printed"` or `"pdf"`; defaults to printed when the PDF exposes numeric labels/offset.
95
- - `scale`: `1`–`3`, default `2.5` (about 180 DPI).
96
- - `crop`: fractions with a top-left origin — `"x0,y0,x1,y1"`, `[x0,y0,x1,y1]`, or `{x,y,w,h}`.
97
- - `image_quality`: `"cheap"` (1.5×, about 350 tokens/page) or `"standard"` (`defaultScale`).
98
- - `include_image`: default `true`; `false` returns page text only and adds no image tokens.
99
- - `include_text`: default `true`, capped by `maxTextCharsPerPage`.
100
-
101
- A call is capped at `maxPagesPerCall` (default **4**, hard maximum **8**) **before** rendering. Exceeding it is an error; pages are never silently dropped. `pdf_read_pages` returns per-page `tokens` and a total `imageTokens`, plus `text`, `textTruncated`, and `textUnavailable` per page. `pdf_info` caps its outline at 200 entries, and `pdf_search` defaults to 20 hits (clamped to `maxSearchResults`).
102
-
103
- #### Text-first loop
104
-
105
- 1. `pdf_info` once per document. Note `printedOffset`, `pageNumberMode`, and the outline. Never hardcode the offset.
106
- 2. `pdf_search` for a distinctive phrase to get physical page numbers.
107
- 3. `pdf_read_pages` with **`include_image: false`** to read those pages as text.
108
- 4. Only when the text is ambiguous or the question is about typography (a formula, diagram, table, or theorem box) → call again with images, ideally with `crop` and `image_quality: "cheap"`.
109
-
110
- For exact formulas or dense subscripts, `scale: 3` plus `crop` is the exception, not the default.
111
-
112
- ### Configuration
113
-
114
- All keys are optional. Set them in the plugin row's `config` in the profile, using the same names as the comments in `cordis.patch.yml`.
115
-
116
- | Key | Default | Meaning |
117
- | --- | --- | --- |
118
- | `enabled` | `true` | Master switch; `false` mounts no tools, routes, or listener. |
119
- | `autoShare` | `true` | Inject the current reading position into `agent/pre-step`; `false` keeps the tools and routes but stops injection. |
120
- | `autoShareSection` | `true` | Resolve the outline section for the injected line. |
121
- | `autoShareMinIntervalMs` | `180000` | Minimum milliseconds between two injections (3 minutes). |
122
- | `autoSharePageDelta` | `1` | Minimum absolute page change before another injection. |
123
- | `autoShareMinSteps` | `1` | Minimum step distance between injections in the same turn. |
124
- | `autoShareMaxAgeMs` | `0` | Ignore reported positions older than this; `0` = no age limit. |
125
- | `allowOutsideWorkspace` | `false` | Let tools read PDFs outside the session working directory. |
126
- | `routeAllowedRoots` | `[]` | Extra directory allowlist for the HTTP routes; `[]` = no extra restriction. |
127
- | `maxPagesPerCall` | `4` | Hard page cap per `pdf_read_pages` call (`1`–`8`), checked before rendering. |
128
- | `defaultScale` | `2.5` | Default render scale (`1`–`3`, about 180 DPI); no token gain above about `2.7`. |
129
- | `maxTextCharsPerPage` | `1200` | Extracted text kept per page; `0` disables text. |
130
- | `maxSearchResults` | `100` | Cap for the tool's and route's search limits (`1`–`500`). |
131
- | `maxOutlineEntries` | `500` | Route outline cap (`1`–`5000`); `pdf_info` additionally caps at 200. |
132
- | `maxLabels` | `0` | Page labels included by `pdf_info`; `0` omits them (cheapest). |
133
- | `outlineDepth` | `1` | `0` = chapters, `1` = sections (default), `2` = subsections. |
134
- | `debugCost` | `false` | Append one JSON cost line per image-returning `pdf_read_pages` call to `.debug-cost.log` beside the package. |
135
- | `debug` | `false` | Include internal error detail in HTTP 500 bodies (development only). |
136
- | `maxFileBytes` | `268435456` | Largest PDF touched by the tools or routes; 256 MiB. |
137
-
138
- The code also accepts two advanced keys that are not shown in the sample comments in `cordis.patch.yml`: `maxRouteBodyBytes` (default `65536`, the `POST /current` body cap) and `infoCacheSize` (default `8`, parsed `getInfo` results cached by path + mtime + size).
139
-
140
- ### Page numbering
141
-
142
- Tools and the UI use 1-based pages. `pdf_info` returns `printedOffset`, defined as physical PDF page minus printed page:
143
-
144
- ```text
145
- physical = printed + printedOffset
146
- ```
147
-
148
- The test book (`Linear Algebra Done Right`) has `printedOffset = 20`, so printed 132 = PDF 152. The offset is detected from numeric page labels (the mode of the per-page deltas), not hardcoded.
149
-
150
- `pdf_info.pageNumberMode` is `"printed"` when numeric labels/offset exist and `"pdf"` otherwise. Tools default to printed numbering in that case; pass `page_numbers: "pdf"` for roman-numeral front matter, PDFs without numeric labels, or whenever you mean physical pages.
151
-
152
- The browser readout uses printed numbering when an offset is known and falls back to physical numbering in front matter. In the jump box, a bare `132` means printed 132 when an offset is known; `p152`, `p.152`, `PDF 152`, and `#152` always mean physical PDF 152. Out-of-range input is rejected rather than clamped, so a mistyped printed page cannot silently land on the last page.
153
-
154
- ### Token economics
155
-
156
- DeepSeek vision billing projects an image onto a 14 px patch grid, downsamples 3:1, and caps it at **1024 tokens**:
157
-
158
- ```text
159
- tokens = gridHeight × (gridWidth + 1) + 2
160
- ```
161
-
162
- Images smaller than 544×544 are scaled up first. Measured on a 6×9 in book page:
163
-
164
- | Render | Pixels | Tokens/page | Notes |
165
- | --- | --- | --- | --- |
166
- | 1.5× (`image_quality: "cheap"`) | 563×954 | **347** | Enough for prose. |
167
- | **2.5× (default)** | 938×1590 | **914** | Highest resolution before downsampling. |
168
- | 3× | 1125×1908 | **1002** | Capped; no gain above about 2.7×. |
169
- | 4× | 1500×2544 | **1002** | Still capped — pure waste. |
170
-
171
- - `maxPagesPerCall` (default 4) is enforced before rendering.
172
- - `pdf_read_pages` reports `tokens` per page and `imageTokens` in total; check them.
173
- - Images stay in the conversation and are re-billed on later turns. **Read once, then answer in text.**
174
- - Prefer `crop` and `image_quality: "cheap"` for a quick look; use `scale: 3` with `crop` only for dense subscripts.
175
- - If the context grows too large, DSH's `image-offload` may replace old images with a file name and path; `read_image` can reload them.
176
- - Set `debugCost: true` to append a JSON line per image call to `.debug-cost.log` and measure your own distribution.
177
-
178
- ### Security
179
-
180
- - **Tools are workspace-scoped by default.** The resolved path must stay inside the session working directory, or the call fails with `PDF_OUTSIDE_WORKSPACE`. Set `allowOutsideWorkspace: true` only when reading outside is intentional. Relative tool paths use the session's original `header.cwd`; a later `working_directory` change is not consulted, so prefer absolute paths.
181
- - **HTTP routes are loopback-only but unauthenticated.** They accept only loopback `Host` values (`127.0.0.1`, `localhost`, `::1`), absolute paths, `.pdf` suffixes, regular files, and files within `maxFileBytes`; responses carry no `Access-Control-Allow-Origin` header. Any local process can still use them to read local PDFs — the same tradeoff as `dsh-pdf-search` and other local preview plugins. Set `routeAllowedRoots: ["/path/to/books"]` to restrict the routes (this does **not** restrict model tools).
182
- - **Installing a plugin runs local code with the Host process's privileges.** Install only packages you trust, and review a local checkout before adding it.
183
- - **Renderer hardening:** 60 s document-load timeout, 60 s per-page render timeout, a 50,000,000-pixel canvas cap, at most 2 concurrent renders, at most 4 cached open documents, and the 256 MiB default file cap.
184
- - **`debug: false` by default**, so HTTP 500 bodies do not expose internal detail or paths.
185
-
186
- ### Known limitations
187
-
188
- - **Official PDF preview only.** The controls key off a `.pdf` `absolutePath`; Word/PowerPoint previews (even when internally converted to PDF), images, Markdown, and code previews get no toolbar. The tools can still read `.pdf` files directly.
189
- - **Private DOM markers.** The browser half depends on `data-pdf-page`, `data-textpreview-body`, and `data-document-zoom-scrollport`. A DSH upgrade that renames them can make the controls inert; the preview itself keeps working.
190
- - **Scanned PDFs have no text layer.** `pdf_search` finds nothing and text reads report `textUnavailable`; only page images help. Check `pdf_info.textCoverage.isLikelyScanned` before searching. There is **no OCR**.
191
- - **Search is literal.** The UI uses case-insensitive substring matching: no regex, no whole-word mode, no hyphenation unfolding, and no match-to-match navigation inside a page.
192
- - **The page offset is detected.** PDFs without numeric page labels fall back to physical numbering, and roman front-matter labels cannot be passed to `pdf_read_pages` as printed pages; use `page_numbers: "pdf"` (the browser jump box also accepts `pN`).
193
- - **`pdfjs-dist` must match the Host process.** The package pins `6.3.289`. Other PDF plugins in the same process (for example `dsh-pdf-search`) must use the same version; mixing versions can fail with `API version does not match Worker version`. Upgrade them together.
194
- - **Visual overlap with other preview add-ons.** This search panel is fixed at the top right, as is `dsh-pdf-search`'s find bar; opening both makes them overlap. Their shortcuts are deliberately different (`Cmd/Ctrl+Shift+F` here, `Cmd/Ctrl+F` there).
195
- - **Auto-share state is process-local.** The throttle is not written to the session log, and staying on the same page does not produce a second message; ask `pdf_current_page` when the position is needed later. The reporting toggle is also not persisted across reloads.
196
- - **Page reads require an image-capable model and an attachment store.** Without `ctx.attachments`, `pdf_read_pages` is not registered. Its route check runs before every call — including `include_image: false` — so a text-only model can still use `pdf_info` and `pdf_search` but not page reads.
197
- - **Read-only.** There is no annotation, notes, editing, or OCR. The plugin reads and renders only.
198
-
199
- ### Development
200
-
201
- ```
202
- dsh-book-reader/
203
- ├─ lib/index.js host half: four tools, /dsh-pdf-reader routes, auto-share
204
- ├─ lib/core/pdf.js document LRU, page labels/offset, outline, page text, search
205
- ├─ lib/core/render.js pdfjs + @napi-rs/canvas → RGB PNG, with timeouts and a pixel cap
206
- ├─ lib/core/tokens.js DeepSeek vision token accounting
207
- ├─ lib/client.js browser half: header controls, jump, search, share, reporting
208
- ├─ lib/host-README.md host internals: arguments, return shapes, routes
209
- ├─ lib/client-README.md browser internals: UI, shortcuts, DOM markers
210
- ├─ SKILL.md model-facing usage guide
211
- ├─ cordis.patch.yml profile mount + commented defaults
212
- └─ test/*.mjs headless core / host / client tests
213
- ```
214
-
215
- `package.json` points `main` at `lib/index.js`, exposes `./client` as `lib/client.js`, sets `dsh.bundle.patch` to `./cordis.patch.yml`, and marks `dsh.client.platform` as `web`. `cordis.patch.yml` inserts plugin id `pdf-reader` under package name `dsh-book-reader`.
216
-
217
- There is **no bundler step**: the host half is hand-written ESM, and `lib/client.js` is the browser bundle in the `window.__ModuleLoader__.load` shape, with React as the only external. Runtime dependencies are `pdfjs-dist` pinned to `6.3.289` and `@napi-rs/canvas` `^1.0.11`; `engines.node` is `>=20`. Restart DSH to reload host code, and reload the page for browser-side changes.
218
-
219
- #### Host routes
220
-
221
- All routes share the historical prefix `/dsh-pdf-reader`, even though the package is now `dsh-book-reader`; the `data-dsh-pdf-reader` DOM markers keep the old name too. Use these exact paths:
222
-
223
- - `GET /dsh-pdf-reader/info?path=<absolute .pdf>`
224
- - `GET /dsh-pdf-reader/search?path=<absolute .pdf>&q=<text>&ignoreCase=1&limit=100`
225
- - `POST /dsh-pdf-reader/current` with `{ "path": "...", "pdfPage": 152, "printedPage": 132, "sessionId": "..." }`
226
- - `GET /dsh-pdf-reader/current?sessionId=<id>` (or `?path=<absolute .pdf>` as a fallback)
20
+ | `pdf_info` | Page count, printed↔physical page offset, outline/TOC. Text only. |
21
+ | `pdf_search` | Literal full-text search; returns page numbers and snippets. |
22
+ | `pdf_read_pages` | Text of chosen pages, optionally as images (`include_image: false` for text only; images cost ~350–1024 tokens/page). |
227
23
 
228
- Responses are JSON with `cache-control: no-store` and `x-content-type-options: nosniff`, and never an `Access-Control-Allow-Origin` header. The browser sends `sessionId`; auto-share only reads positions keyed by the calling session. A `POST /current` without `sessionId` is path-keyed and is not used for auto-share.
24
+ Typical loop: `pdf_info` → `pdf_search` → `pdf_read_pages` (text) → images only for formulas, diagrams, or tables.
229
25
 
230
- #### Tests
26
+ ## Install
231
27
 
232
- ```sh
233
- node test/core.test.mjs
234
- node test/host.test.mjs
235
- node test/client.test.mjs
236
- ```
28
+ - **From npm**: DSH **Plugins → Add plugin** → `dsh-book-reader` (or pin `dsh-book-reader@0.1.1`), or `dsh plugin add dsh-book-reader`.
29
+ - A fresh install applies live (this profile has HMR); **upgrading a plugin that is already loaded needs a DSH restart** to load the new code.
30
+ - **Optional skill** (gives the model the built-in PDF reading guide): symlink the package into the workspace:
31
+ ```sh
32
+ ln -sfn "<installed package dir>" "<workspace>/.dsh/skills/pdf-reader"
33
+ ```
34
+ - **Local checkout**: `dsh plugin add "/abs/path/to/dsh-book-reader"`; keep the checkout and its `node_modules/` in place.
237
35
 
238
- Plain Node, no test framework. The core test renders the real LADR PDFs, so those files must be present in the workspace; the client test reuses the `jsdom`/React install under a sibling `dsh-pdf-search/node_modules`. The host test stubs its host services, and jsdom has no layout engine, so the suite does not replace a live GUI smoke test for slot layout, shortcut arbitration, and real PDF.js lazy rendering.
36
+ ## Configuration
239
37
 
240
- #### Publishing
38
+ Every option is optional and the defaults are safe. Common ones: `maxPagesPerCall` (default 4), `defaultScale` (2.5), `autoShare` (automatic position hint), `allowOutsideWorkspace` (tools stay inside the session workspace by default), `routeAllowedRoots` (restrict the local HTTP routes). The full list with defaults is commented in [`cordis.patch.yml`](cordis.patch.yml).
241
39
 
242
- `npm publish` publishes the npm package (bump `version` first; `publishConfig.access` is already `public`). The `files` whitelist controls what ships; tests, `reference/`, and `node_modules/` are never in the tarball. Push the repository to GitHub separately (`git push`) — the npm package and the GitHub repo are independent artifacts.
40
+ ## Notes
243
41
 
244
- After publishing, consumers install by package name as in (a): no manual package symlink and no npm command. The optional skill still needs the symlink in (c). Users must reinstall or update the plugin and restart DSH to pick up a new version; the host half is loaded at startup.
42
+ - HTTP routes are loopback-only and unauthenticated; the tools are workspace-scoped by default. Other local preview plugins make the same tradeoff.
43
+ - Works only with the official PDF preview: it relies on that preview's DOM markers, which a DSH upgrade may change.
44
+ - Scanned PDFs have no text layer — there is no OCR, so only page images help.
45
+ - `pdfjs-dist` is pinned to `6.3.289` to match the host process.
245
46
 
246
- ### License
47
+ ## More
247
48
 
248
- MIT. See [LICENSE](LICENSE).
49
+ - [`SKILL.md`](SKILL.md) — model-facing guide: costs, page numbering, pitfalls.
50
+ - [`CHANGELOG.md`](CHANGELOG.md) · [`LICENSE`](LICENSE) (MIT)
249
51
 
250
52
  ---
251
53
 
252
54
  ## 中文说明
253
55
 
254
- ### 概述
255
-
256
- `dsh-book-reader` 在 DSH 右侧栏的官方 PDF 预览上加一组读书控件,同时注册四个模型可调用的工具,让模型**先读文本**。它不替换官方预览器:缩放、适应宽度、选中文字、刷新、自动刷新都保持原样;删掉插件行,预览恢复原状。
257
-
258
- 控件只挂在 `absolutePath` 以 `.pdf` 结尾的预览上。PDF 在宿主进程内用 `pdfjs-dist` + `@napi-rs/canvas` 解析和渲染;浏览器端把控件加进预览的 document-header 插槽。
259
-
260
- ```
261
- ┌─ 官方 PDF 预览(PDF.js)──────────────────────────────────┐
262
- │ 刷新 缩放 … │ ‹ › [132] / 410 ⌕ ↗ AI● │ ← 本插件注入
263
- ├──────────────────────────────────────────────────────────┤
264
- │ 页面内容 … │
265
- └──────────────────────────────────────────────────────────┘
266
- ```
267
-
268
- 设计原则:**默认只花文本的 token;图片是需要时才付的钱,不是第一步。**
56
+ 在 DSH 里读 PDF 书和论文:官方 PDF 预览上加一条读书工具栏,外加三个 PDF 工具给模型。
269
57
 
270
58
  ### 功能
271
59
 
272
- | 控件 | 行为 |
273
- | --- | --- |
274
- | `‹` / `›` | 上一页 / 下一页(按**物理** PDF 页连续翻,与印刷页码无关)。 |
275
- | `[132] / 410` | 当前页读数,随滚动更新;也是跳页框(输入页码后按 `Enter`)。已知偏移时显示印刷页码,否则显示物理页码。 |
276
- | `⌕` | 打开全文搜索面板。结果显示为 `p.132 — 摘要`,点击跳转;每行还有 **`→ AI`**,把 `PDF p.132: <摘要>` 插到对话输入框光标处。 |
277
- | `↗` | 把 `[PDF p.132]` 插到输入框光标处,是一次可撤销的编辑,不覆盖草稿。 |
278
- | `AI` + 状态点 | 阅读位置上报开关(默认开启)。状态点:绿色 = 宿主已确认,琥珀 = 发送中,红色 = 宿主路由失败,变暗 = 关闭。所有已挂载的 PDF 预览共用这个开关,刷新后不保留。 |
279
- | `Cmd/Ctrl + Shift + F` | 对指针最后停留/聚焦的预览打开搜索面板,并聚焦、全选查询词。`Cmd/Ctrl + F` 故意不拦截。 |
60
+ **阅读工具栏** —— 只在右侧栏的 `.pdf` 预览上出现;不替换官方预览器(缩放、选中、刷新都保持原样)。
280
61
 
281
- `Esc` 关闭面板;页码框里 `Enter` 跳页;搜索框里 `Enter` 跳到第一条结果。鼠标悬停或键盘聚焦时,每个控件都会显示提示,语言跟随 DSH 当前界面语言(中文或英文)。
62
+ - 上一页/下一页、随滚动更新的页码读数、跳页框(输入印刷页码,或 `p152` 表示 PDF 第 152 页)。
63
+ - 全文搜索面板(`Cmd/Ctrl+Shift+F`):点结果跳页,或把该页引用插入对话框。
64
+ - **AI 位置提示**(默认开):每轮对话开始时,模型可能收到一行“你正在看第 N 页”的短提示 —— 每轮最多一次,且页码变了才发。关掉后,模型不会自动知道你在哪页。
65
+ - 每个控件都有 hover / 键盘聚焦提示,跟随 DSH 界面语言(中文或英文)。
282
66
 
283
- 读数第一次解析成功后,浏览器会在滚动停止 400 ms 后上报页码变化。开启自动上报时,宿主在满足节流条件时每一步最多注入一条很短的来源标记(按默认间隔,最多每 3 分钟一条):
67
+ **模型工具** —— 默认只花文本 token,图片是按需付的钱。
284
68
 
285
- ```text
286
- [dsh-pdf-reader] 正在阅读《book》printed 132 (PDF 152) · Chapter 5 › 5B
287
- ```
69
+ | 工具 | 作用 |
70
+ | --- | --- |
71
+ | `pdf_info` | 页数、印刷页↔物理页偏移、目录/TOC;纯文本。 |
72
+ | `pdf_search` | 字面全文搜索;返回页码和摘要。 |
73
+ | `pdf_read_pages` | 选中页的文本,也可返回页面图片(`include_image: false` 只取文本;图片约 350–1024 token/页)。 |
288
74
 
289
- 它**只是位置标记**,不会命令模型读页。读不读、读哪页、要不要图,由模型自己判断;需要时也可以用 `pdf_current_page` 再取一次。注入受页变化、最小间隔(默认 3 分钟)和步距约束。
75
+ 典型流程:`pdf_info` → `pdf_search` → `pdf_read_pages`(文本)→ 只有公式、图表、表格才看图。
290
76
 
291
77
  ### 安装
292
78
 
293
- #### a) 发布包
294
-
295
- 在 DSH 的 **Plugins** 页里点 **Add plugin**,输入 `dsh-book-reader`(或锁定版本 `dsh-book-reader@0.1.0`)。等价的命令行:
296
-
297
- ```sh
298
- dsh plugin --profile <name> add dsh-book-reader
299
- ```
300
-
301
- 装完后完全重启 DSH;宿主端随进程启动加载。用发布包安装时,消费者**不需要手动给包建软链,也不需要 npm 命令**;如果还需要 skill,唯一的手动步骤是下面的可选注册。
302
-
303
- #### b) 本地开发目录
304
-
305
- ```sh
306
- dsh plugin add "/abs/path/to/dsh-book-reader"
307
- # 需要指定非默认 profile 时,在 plugin 后加 --profile <name>
308
- ```
309
-
310
- 然后重启 DSH。profile 记录的是指向源码目录的 `link:`,所以源码目录**和它的 `node_modules/`** 都必须留在原处:宿主运行时要从那里 `import 'pdfjs-dist'` 和 `@napi-rs/canvas`。当前目录里的 `node_modules/` 是 macOS arm64 的;其他架构请先在目录里执行 `npm install`。`engines.node` 要求 Node >= 20。
311
-
312
- 卸载:`dsh plugin --profile <name> remove dsh-book-reader`,再重启 DSH。首次启用建议走 GUI 开关,而不是手改 `dsh.profile.bundles`;如果安装后 profile 启动失败,可先用命令行移除插件行再重启。
313
-
314
- #### c) 注册 skill
315
-
316
- ```sh
317
- mkdir -p "<workspace>/.dsh/skills"
318
- ln -sfn "/abs/path/to/dsh-book-reader" "<workspace>/.dsh/skills/pdf-reader"
319
- ```
320
-
321
- 包里带了 `SKILL.md`,但 DSH 的打包**不会自动注册 skill**。软链名必须是 `pdf-reader`(即 `SKILL.md` 里的 `name`);如果是 npm 安装的包,就指向已安装的包目录。不注册 skill 也能用这些工具,但注册后模型才知道文本优先的流程、成本表和常见陷阱。每个工作区注册一次即可。
322
-
323
- 发布之后,消费者用包名安装插件不需要手动软链,也不需要 npm 命令;只有这个可选的 skill 注册仍需手动。
324
-
325
- ### 工具
326
-
327
- 四个工具,三档成本。默认走文本。
328
-
329
- | 工具 | 成本 | 用途 |
330
- | --- | --- | --- |
331
- | `pdf_info` | ~0(纯文本) | 页数、印刷页↔物理页偏移、目录/TOC;也返回标题、作者、文件大小、页面尺寸和 `textCoverage.isLikelyScanned`。 |
332
- | `pdf_search` | ~0(纯文本) | 定位短语、定理编号、图表标题。 |
333
- | `pdf_read_pages` + `include_image: false` | ~0(纯文本,按页) | 正文、定理陈述、习题文字。 |
334
- | `pdf_read_pages`(带图) | **~350–1024 token/页** | 公式、图表、表格、版式。 |
335
- | `pdf_current_page` | ~0 | 用户当前在看哪一页、哪一节。 |
336
-
337
- `pdf_read_pages` 是唯一返回图片的工具。它只在宿主挂载了 `ctx.attachments` 时注册(PNG 才能成为持久的图片附件);如果当前路由的模型不接受图片输入,任何调用都会失败,`include_image: false` 也不例外。
338
-
339
- 页码是 **1-based**,相对路径按会话工作目录解析。`pages` 支持 `"132-134"`、`"132,135"`、`132`、`[132,133,134]`,也支持范围字符串组成的数组。其他常用参数:
340
-
341
- - `page_numbers`:`"printed"` 或 `"pdf"`;PDF 有数字页标签/偏移时默认 `"printed"`。
342
- - `scale`:`1`–`3`,默认 `2.5`(约 180 DPI)。
343
- - `crop`:以左上角为原点的分数——`"x0,y0,x1,y1"`、`[x0,y0,x1,y1]` 或 `{x,y,w,h}`。
344
- - `image_quality`:`"cheap"`(1.5×,约 350 token/页)或 `"standard"`(用 `defaultScale`)。
345
- - `include_image`:默认 `true`;`false` 只返回页文本,不产生图片 token。
346
- - `include_text`:默认 `true`,受 `maxTextCharsPerPage` 限制。
347
-
348
- 单次调用在**渲染之前**就按 `maxPagesPerCall`(默认 **4**,硬上限 **8**)校验;超了直接报错,不会悄悄丢页。`pdf_read_pages` 返回每页的 `tokens` 和合计 `imageTokens`,以及每页的 `text`、`textTruncated`、`textUnavailable`。`pdf_info` 的目录最多 200 条;`pdf_search` 默认 20 条(受 `maxSearchResults` 限制)。
349
-
350
- #### 文本优先流程
351
-
352
- 1. 每份文档先调一次 `pdf_info`,记下 `printedOffset`、`pageNumberMode` 和目录。不要把偏移量写死。
353
- 2. 用 `pdf_search` 搜一个有辨识度的短语,拿到物理页码。
354
- 3. 用 `pdf_read_pages` 配 **`include_image: false`**,把这几页当文本读。
355
- 4. 只有文本有歧义,或问题本身与排版有关(公式、图、表、定理框)时,才带图再调一次,优先用 `crop` 和 `image_quality: "cheap"`。
356
-
357
- 精确公式或密集下标是例外:用 `scale: 3` 配 `crop`,但不要当成默认。
79
+ - **从 npm 安装**:DSH **Plugins → Add plugin** → 输入 `dsh-book-reader`(或锁定版本 `dsh-book-reader@0.1.1`),或命令行 `dsh plugin add dsh-book-reader`。
80
+ - 全新安装即时生效(本 profile 已启用 HMR);**升级已加载的插件需要完整重启 DSH** 才能加载新代码。
81
+ - **可选 skill**(给模型一份内置的 PDF 阅读指南):把包目录软链到工作区:
82
+ ```sh
83
+ ln -sfn "<已安装的包目录>" "<workspace>/.dsh/skills/pdf-reader"
84
+ ```
85
+ - **本地开发**:`dsh plugin add "/abs/path/to/dsh-book-reader"`;源码目录和 `node_modules/` 必须留在原处。
358
86
 
359
87
  ### 配置
360
88
 
361
- 全部可选。通过 profile 中插件行的 `config` 设置,键名与 `cordis.patch.yml` 注释一致。
362
-
363
- | 键 | 默认 | 含义 |
364
- | --- | --- | --- |
365
- | `enabled` | `true` | 总开关;`false` 时不挂载任何工具、路由或监听器。 |
366
- | `autoShare` | `true` | 把当前阅读位置注入 `agent/pre-step`;`false` 保留工具和路由,只停注入。 |
367
- | `autoShareSection` | `true` | 为注入行解析目录中的小节。 |
368
- | `autoShareMinIntervalMs` | `180000` | 两次注入的最小间隔(3 分钟)。 |
369
- | `autoSharePageDelta` | `1` | 再次注入前要求的最小绝对页变化。 |
370
- | `autoShareMinSteps` | `1` | 同一轮内两次注入的最小步距。 |
371
- | `autoShareMaxAgeMs` | `0` | 忽略早于此时长的位置;`0` = 不限。 |
372
- | `allowOutsideWorkspace` | `false` | 允许工具读取会话工作区外的 PDF。 |
373
- | `routeAllowedRoots` | `[]` | HTTP 路由的额外目录白名单;`[]` = 不额外限制。 |
374
- | `maxPagesPerCall` | `4` | 单次 `pdf_read_pages` 的硬页数上限(`1`–`8`),渲染前校验。 |
375
- | `defaultScale` | `2.5` | 默认渲染倍率(`1`–`3`,约 180 DPI);超过约 `2.7` 没有 token 收益。 |
376
- | `maxTextCharsPerPage` | `1200` | 每页保留的抽取文本字符数;`0` 关闭文本。 |
377
- | `maxSearchResults` | `100` | 工具和路由搜索条数的上限(`1`–`500`)。 |
378
- | `maxOutlineEntries` | `500` | 路由目录条数上限(`1`–`5000`);`pdf_info` 另有 200 条上限。 |
379
- | `maxLabels` | `0` | `pdf_info` 返回的页标签数量;`0` = 不返回(最省)。 |
380
- | `outlineDepth` | `1` | `0` = 章,`1` = 节(默认),`2` = 小节。 |
381
- | `debugCost` | `false` | 每次带图的 `pdf_read_pages` 往包目录下的 `.debug-cost.log` 追加一行 JSON 成本记录。 |
382
- | `debug` | `false` | 在 HTTP 500 响应里带内部错误细节(仅开发用)。 |
383
- | `maxFileBytes` | `268435456` | 工具或路由能碰的最大 PDF;256 MiB。 |
384
-
385
- 代码还接受两个键,只是 `cordis.patch.yml` 的示例注释里没有列出:`maxRouteBodyBytes`(默认 `65536`,`POST /current` 的请求体上限)和 `infoCacheSize`(默认 `8`,按路径 + mtime + size 缓存的 `getInfo` 结果数)。
386
-
387
- ### 页码
388
-
389
- 工具和界面都用 1-based 页码。`pdf_info` 返回 `printedOffset`,定义是「物理 PDF 页 − 印刷页」:
390
-
391
- ```text
392
- 物理页 = 印刷页 + printedOffset
393
- ```
394
-
395
- 测试用的《Linear Algebra Done Right》`printedOffset = 20`,所以 printed 132 = PDF 152。偏移量是从数字页标签的逐页差值中探测出来的,不要写死。
396
-
397
- `pdf_info.pageNumberMode` 在有数字标签/偏移时为 `"printed"`,否则为 `"pdf"`。可探测时工具默认用印刷页码;前言页、罗马数字标签、没有数字标签的 PDF,或者你就是想说物理页时,传 `page_numbers: "pdf"`。
398
-
399
- 浏览器读数在已知偏移时显示印刷页码,前言部分退回物理页码。跳页框里,裸数字 `132` 在已知偏移时按 printed 132 处理;`p152`、`p.152`、`PDF 152`、`#152` 强制物理页 152。超出 `1..pageCount` 的输入会被拒绝,不会截断到最后一页,所以打错的印刷页不会悄悄跳到全书末尾。
400
-
401
- ### Token 成本
402
-
403
- DeepSeek 的视觉计费:图片先对齐到 14 px patch 网格,按 3:1 降采样,单图上限 **1024 token**:
404
-
405
- ```text
406
- tokens = 网格高 × (网格宽 + 1) + 2
407
- ```
408
-
409
- 小于 544×544 的图会先放大。6×9 英寸书页实测:
410
-
411
- | 渲染 | 像素 | token/页 | 说明 |
412
- | --- | --- | --- | --- |
413
- | 1.5×(`image_quality: "cheap"`) | 563×954 | **347** | 只读正文够用。 |
414
- | **2.5×(默认)** | 938×1590 | **914** | 未被降采样的最高分辨率。 |
415
- | 3× | 1125×1908 | **1002** | 已触顶;超过约 2.7× 没有收益。 |
416
- | 4× | 1500×2544 | **1002** | 仍然触顶,纯属浪费。 |
417
-
418
- - `maxPagesPerCall`(默认 4)在渲染前校验。
419
- - `pdf_read_pages` 返回每页 `tokens` 和合计 `imageTokens`,记得看。
420
- - 图片会留在对话里,后续轮次仍会计费。**读一次,然后用文本回答。**
421
- - 快速看一眼用 `crop` + `image_quality: "cheap"`;只有密集下标才需要 `scale: 3` 配 `crop`。
422
- - 上下文太大时,DSH 的 `image-offload` 可能把旧图替换成文件名+路径;仍可用 `read_image` 重读。
423
- - 设 `debugCost: true` 后,每次带图调用会往 `.debug-cost.log` 追加一行 JSON,便于统计自己的真实成本分布。
424
-
425
- ### 安全
426
-
427
- - **工具默认只在工作区内。** 解析后的路径必须落在当前会话工作目录内,否则返回 `PDF_OUTSIDE_WORKSPACE`。确实要读工作区外的 PDF 时,才显式设 `allowOutsideWorkspace: true`。相对路径按会话最初的 `header.cwd` 解析,之后改 `working_directory` 不会影响它,所以建议用绝对路径。
428
- - **HTTP 路由只认回环 Host,但没有鉴权。** 只接受 `127.0.0.1`、`localhost`、`::1` 这类回环 `Host`,路径必须是绝对路径、`.pdf` 后缀、普通文件,且不超过 `maxFileBytes`;响应不带 `Access-Control-Allow-Origin`。本机任何进程仍可借它读取本机 PDF,这与 `dsh-pdf-search` 等本地预览插件是同一权衡。要收紧就设 `routeAllowedRoots: ["/path/to/books"]`;它只管 HTTP 路由,**不管**模型工具。
429
- - **安装插件等于在宿主进程里运行本地代码。** 只装你信任的包;从本地目录安装前先看代码。
430
- - **渲染保护:** 文档加载 60 s 超时,单页渲染 60 s 超时,画布上限 5000 万像素,同时最多 2 个渲染任务,最多缓存 4 个已打开文档,默认文件上限 256 MiB。
431
- - **默认 `debug: false`**,HTTP 500 不返回内部错误细节和路径。
432
-
433
- ### 已知限制
434
-
435
- - **只支持官方 PDF 预览。** 控件按 `.pdf` 的 `absolutePath` 匹配;Word/PowerPoint(即使预览时内部转成了 PDF)、图片、Markdown、代码预览都不会出现工具栏。工具仍可直接读 `.pdf` 文件。
436
- - **依赖官方预览的私有 DOM 标记。** 浏览器端依赖 `data-pdf-page`、`data-textpreview-body`、`data-document-zoom-scrollport`;DSH 升级若改名,控件会失效,但预览本身不受影响。
437
- - **扫描件没有文本层。** `pdf_search` 搜不到,文本读页返回 `textUnavailable`,只能看图;**没有 OCR**。搜之前先看 `pdf_info.textCoverage.isLikelyScanned`。
438
- - **搜索是字面匹配。** 界面用大小写不敏感的子串匹配:没有正则、整词模式,不会展开连字符断行,也不能在一页内逐个跳匹配。
439
- - **页码偏移是探测的。** 没有数字页标签的 PDF 退化为物理页模式;前言页的罗马数字标签不能直接传给 `pdf_read_pages`,请用 `page_numbers: "pdf"`(浏览器跳页框还可以用 `pN`)。
440
- - **`pdfjs-dist` 版本必须和宿主进程一致。** 本包锁定 `6.3.289`;同一进程里的其他 PDF 插件(例如 `dsh-pdf-search`)必须用同一版本,混用会报 `API version does not match Worker version`。升级时一起升。
441
- - **可能和其他预览插件视觉重叠。** 搜索面板固定在右上角,和 `dsh-pdf-search` 的查找条同时打开会重叠;快捷键故意错开(这里是 `Cmd/Ctrl+Shift+F`,那边是 `Cmd/Ctrl+F`)。
442
- - **自动上报状态在进程内存里。** 节流状态不写入会话日志;用户停在同一页时不会重复注入,需要位置时模型应调用 `pdf_current_page`。上报开关也不跨刷新保留。
443
- - **读页需要附件服务和支持图片的模型。** 没有 `ctx.attachments` 时 `pdf_read_pages` 不注册。每次调用都会先做路由检查,`include_image: false` 也不例外;所以纯文本模型仍可用 `pdf_info` 和 `pdf_search`,但不能读页。
444
- - **只读。** 没有批注、笔记、编辑、OCR;插件只负责读取和渲染。
445
-
446
- ### 开发
447
-
448
- ```
449
- dsh-book-reader/
450
- ├─ lib/index.js 宿主端:4 个工具、/dsh-pdf-reader 路由、自动位置注入
451
- ├─ lib/core/pdf.js 文档 LRU、页标签/偏移、目录、页文本、搜索
452
- ├─ lib/core/render.js pdfjs + @napi-rs/canvas → RGB PNG,带超时与像素上限
453
- ├─ lib/core/tokens.js DeepSeek 视觉 token 计算
454
- ├─ lib/client.js 浏览器端:header 控件、跳页、搜索、分享、上报
455
- ├─ lib/host-README.md 宿主内部文档:参数、返回结构、路由
456
- ├─ lib/client-README.md 浏览器内部文档:界面、快捷键、DOM 标记
457
- ├─ SKILL.md 模型使用手册
458
- ├─ cordis.patch.yml profile 挂载 + 注释掉的默认值
459
- └─ test/*.mjs core / host / client 无头测试
460
- ```
461
-
462
- `package.json` 的 `main` 是 `lib/index.js`,`exports["./client"]` 指向 `lib/client.js`,`dsh.bundle.patch` 指向 `./cordis.patch.yml`,`dsh.client.platform` 是 `web`。`cordis.patch.yml` 以插件 id `pdf-reader`、包名 `dsh-book-reader` 插入。
463
-
464
- **没有打包步骤**:宿主端是手写 ESM,浏览器端 `lib/client.js` 直接是 `window.__ModuleLoader__.load` 形式的 bundle,React 是唯一外部依赖。运行时依赖:`pdfjs-dist` 锁定 `6.3.289`,`@napi-rs/canvas` `^1.0.11`;`engines.node >= 20`。改宿主代码后重启 DSH 才生效;改浏览器端后刷新页面。
465
-
466
- #### 宿主路由
467
-
468
- 所有路由共用一个历史前缀 `/dsh-pdf-reader`:包名已经是 `dsh-book-reader`,但线上路径和 `data-dsh-pdf-reader` DOM 标记仍保留旧名。调用时用下面的精确路径:
469
-
470
- - `GET /dsh-pdf-reader/info?path=<absolute .pdf>`
471
- - `GET /dsh-pdf-reader/search?path=<absolute .pdf>&q=<text>&ignoreCase=1&limit=100`
472
- - `POST /dsh-pdf-reader/current`,请求体 `{ "path": "...", "pdfPage": 152, "printedPage": 132, "sessionId": "..." }`
473
- - `GET /dsh-pdf-reader/current?sessionId=<id>`(没有会话 id 时可用 `?path=<absolute .pdf>` 兜底)
474
-
475
- 响应都是 JSON,带 `cache-control: no-store` 和 `x-content-type-options: nosniff`,不带 `Access-Control-Allow-Origin`。浏览器端会传 `sessionId`;自动上报只读调用会话的位置。`POST /current` 不带 `sessionId` 时只按路径记位置,不会被自动上报使用。
476
-
477
- #### 测试
478
-
479
- ```sh
480
- node test/core.test.mjs
481
- node test/host.test.mjs
482
- node test/client.test.mjs
483
- ```
484
-
485
- 纯 Node,不用测试框架。core 测试会渲染真实的 LADR PDF,所以工作区里要有那两个文件;client 测试复用同级 `dsh-pdf-search/node_modules` 里的 `jsdom`/React。host 测试会替换宿主服务,jsdom 也没有布局引擎,因此整套测试不能代替真实 GUI 里对插槽布局、快捷键冲突和 PDF.js 懒渲染的冒烟验证。
486
-
487
- #### 发布
89
+ 全部可选,默认值即安全值。常用:`maxPagesPerCall`(默认 4)、`defaultScale`(2.5)、`autoShare`(自动位置提示)、`allowOutsideWorkspace`(工具默认只读会话工作区)、`routeAllowedRoots`(限制本地 HTTP 路由)。完整键和默认值见 [`cordis.patch.yml`](cordis.patch.yml) 注释。
488
90
 
489
- 先升 `version`,再执行 `npm publish` 发布 npm 包(`publishConfig.access` 已经是 `public`)。`files` 白名单决定哪些文件进包;测试、`reference/`、`node_modules/` 都不会进 tarball。仓库另外推到 GitHub(`git push`);npm 包和 GitHub 仓库是两个独立产物。
91
+ ### 说明
490
92
 
491
- 发布后,消费者按 (a) 用包名安装即可,不需要手动给包建软链,也不需要 npm 命令;只有可选的 skill 仍需按 (c) 建软链。用户升级时要重新安装/更新插件并重启 DSH,宿主端在启动时加载,不能原地热升级。
93
+ - HTTP 路由只认回环地址且无鉴权;工具默认只在工作区内。其他本地预览插件也是同样的权衡。
94
+ - 只支持官方 PDF 预览(依赖它的私有 DOM 标记,DSH 升级可能失效)。
95
+ - 扫描件没有文本层,**没有 OCR**,只能看图。
96
+ - `pdfjs-dist` 锁定 `6.3.289`,以和宿主进程保持一致。
492
97
 
493
- ### 许可证
98
+ ### 更多
494
99
 
495
- 许可证:MIT。
100
+ - [`SKILL.md`](SKILL.md) —— 模型使用手册:成本、页码、常见陷阱。
101
+ - [`CHANGELOG.md`](CHANGELOG.md) · [`LICENSE`](LICENSE)(MIT)
package/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: 'Read a PDF (book, paper, manual) cheaply and accurately with the d
5
5
 
6
6
  # Reading a PDF with `dsh-book-reader`
7
7
 
8
- Four tools, three cost tiers. **The default is text.** Images are a deliberate
8
+ Three tools, two cost tiers. **The default is text.** Images are a deliberate
9
9
  expense, not the first move.
10
10
 
11
11
  | Tool | Cost | Use for |
@@ -14,7 +14,6 @@ expense, not the first move.
14
14
  | `pdf_search` | ~0 (text) | locating a phrase, theorem number, or figure caption |
15
15
  | `pdf_read_pages` + `include_image:false` | ~0 (text, per page) | prose, theorem statements, exercise text |
16
16
  | `pdf_read_pages` (images on) | **~350–1024 tokens/page** | formulas, diagrams, tables, layout |
17
- | `pdf_current_page` | ~0 | which page the user is looking at |
18
17
 
19
18
  ## Default loop (text-first)
20
19
 
package/cordis.patch.yml CHANGED
@@ -1,5 +1,5 @@
1
1
  # dsh-book-reader bundle patch: mount the plugin into the active profile.
2
- # Host half: pdf_info / pdf_search / pdf_read_pages / pdf_current_page tools,
2
+ # Host half: pdf_info / pdf_search / pdf_read_pages tools,
3
3
  # /dsh-book-reader/* routes, and a minimal per-turn reading-position
4
4
  # line injected through agent/pre-step.
5
5
  # Browser half: page readout, jump, search panel, and share-to-chat on top of
@@ -11,11 +11,10 @@
11
11
  name: 'dsh-book-reader'
12
12
  # config:
13
13
  # enabled: true
14
- # autoShare: true # false stops position injection
14
+ # autoShare: true # false stops position hints
15
15
  # autoShareSection: true # include the outline section in the line
16
- # autoShareMinIntervalMs: 180000 # at most once every 3 minutes
17
- # autoSharePageDelta: 1 # require a page change
18
- # autoShareMinSteps: 1 # at most once per step
16
+ # autoShareMinIntervalMs: 0 # optional: min ms between hints (0 = no time throttle)
17
+ # autoSharePageDelta: 1 # require a page change before another hint
19
18
  # autoShareMaxAgeMs: 0 # 0 = no age limit on a reported position
20
19
  # allowOutsideWorkspace: false # tools may only read inside the session cwd
21
20
  # routeAllowedRoots: [] # extra dirs the HTTP routes may serve; [] = any
package/lib/client.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Decorates DSH's official PDF preview; it never replaces the renderer:
5
5
  * - current-page readout, prev/next page, and a page-jump box;
6
6
  * - a search panel (Cmd/Ctrl+Shift+F) backed by the Host text route;
7
- * - "→ AI" and "share this page" insertions into the chat composer;
7
+ * - "→ AI" insertion of a search hit into the chat composer;
8
8
  * - debounced current-page reporting for AI context, with an on/off toggle.
9
9
  *
10
10
  * The contribution mounts into the preview's own header slot
@@ -134,11 +134,6 @@ window.__ModuleLoader__.load({
134
134
  return page === undefined ? 'p.?' : `p.${page}`
135
135
  }
136
136
 
137
- /** @returns `[PDF p.132]` for the share button. */
138
- function buildShareReference(pdfPage, printedOffset) {
139
- return `[PDF p.${displayPage(pdfPage, printedOffset)}]`
140
- }
141
-
142
137
  /** @returns `PDF p.132: <snippet>` (or `[PDF p.132]` when there is no snippet). */
143
138
  function buildSearchReference(hit) {
144
139
  const page = searchResultPage(hit)
@@ -679,10 +674,6 @@ window.__ModuleLoader__.load({
679
674
  zh: ['搜索', '搜索这个 PDF 的文字层(Cmd/Ctrl+Shift+F)'],
680
675
  en: ['Search', "Search this PDF's text layer (Cmd/Ctrl+Shift+F)"],
681
676
  },
682
- share: {
683
- zh: ['引用当前页', '把当前位置引用插入输入框'],
684
- en: ['Insert reference', 'Insert this page reference into the chat'],
685
- },
686
677
  follow: {
687
678
  zh: ['AI 上下文', '把当前阅读页报告给模型'],
688
679
  en: ['AI context', 'Report the page you are reading to the model'],
@@ -1536,17 +1527,6 @@ window.__ModuleLoader__.load({
1536
1527
  },
1537
1528
  style: BUTTON_STYLE,
1538
1529
  }, SEARCH_ICON),
1539
- h('button', {
1540
- key: 'share',
1541
- type: 'button',
1542
- 'aria-label': 'Insert this page reference into the chat',
1543
- 'data-tip': 'share',
1544
- 'data-dsh-pdf-reader': 'share',
1545
- onClick: () => {
1546
- deliverReference(buildShareReference(page, printedOffset), 'Inserted')
1547
- },
1548
- style: BUTTON_STYLE,
1549
- }, h('span', { 'aria-hidden': 'true', style: { fontSize: '14px' } }, '↗')),
1550
1530
  h('button', {
1551
1531
  key: 'follow',
1552
1532
  type: 'button',
@@ -1764,7 +1744,6 @@ window.__ModuleLoader__.load({
1764
1744
  AI_BUTTON_STYLE,
1765
1745
  PANEL_STYLE,
1766
1746
  buildSearchReference,
1767
- buildShareReference,
1768
1747
  cachedInfo,
1769
1748
  copyText,
1770
1749
  createDebouncer,
package/lib/index.js CHANGED
@@ -12,8 +12,8 @@
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
  */
@@ -51,16 +51,14 @@ const PAGE_SELECTION_HARD_LIMIT = 256
51
51
  export const Config = z.object({
52
52
  /** Master switch: false mounts no tool, route, or listener. */
53
53
  enabled: z.boolean().default(true),
54
- /** Inject the current reading page into `agent/pre-step`. */
54
+ /** Inject the current reading page into `agent/pre-step`, at most once per turn. */
55
55
  autoShare: z.boolean().default(true),
56
56
  /** Resolve the outline section for the injected message (one cached getInfo). */
57
57
  autoShareSection: z.boolean().default(true),
58
- /** Minimum milliseconds between two injected reading positions. */
59
- autoShareMinIntervalMs: z.number().default(180_000),
60
- /** Minimum absolute page change required before another injection. */
58
+ /** Optional minimum milliseconds between hints; 0 = no time throttle. */
59
+ autoShareMinIntervalMs: z.number().default(0),
60
+ /** Minimum absolute page change required before another hint. */
61
61
  autoSharePageDelta: z.number().default(1),
62
- /** Minimum step distance between injections in the same turn. */
63
- autoShareMinSteps: z.number().default(1),
64
62
  /** Ignore a reported position older than this; 0 means no age limit. */
65
63
  autoShareMaxAgeMs: z.number().default(0),
66
64
  /** Hard cap on pages rendered by one `pdf_read_pages` call. */
@@ -98,9 +96,8 @@ export const DEFAULT_CONFIG = Object.freeze({
98
96
  enabled: true,
99
97
  autoShare: true,
100
98
  autoShareSection: true,
101
- autoShareMinIntervalMs: 180_000,
99
+ autoShareMinIntervalMs: 0,
102
100
  autoSharePageDelta: 1,
103
- autoShareMinSteps: 1,
104
101
  autoShareMaxAgeMs: 0,
105
102
  maxPagesPerCall: 4,
106
103
  defaultScale: 2.5,
@@ -124,7 +121,6 @@ export const INITIAL_AUTO_SHARE_STATE = Object.freeze({
124
121
  lastInjectedPdfPage: null,
125
122
  lastInjectedAt: null,
126
123
  lastInjectedTurn: null,
127
- lastInjectedStep: null,
128
124
  })
129
125
 
130
126
  /** Create an Error carrying a stable code, for tests and route mapping. */
@@ -180,7 +176,6 @@ export function normalizeConfig(raw) {
180
176
  86_400_000,
181
177
  ),
182
178
  autoSharePageDelta: configInteger(source.autoSharePageDelta, DEFAULT_CONFIG.autoSharePageDelta, 'autoSharePageDelta', 1, 1000),
183
- autoShareMinSteps: configInteger(source.autoShareMinSteps, DEFAULT_CONFIG.autoShareMinSteps, 'autoShareMinSteps', 1, 1000),
184
179
  autoShareMaxAgeMs: configInteger(source.autoShareMaxAgeMs, DEFAULT_CONFIG.autoShareMaxAgeMs, 'autoShareMaxAgeMs', 0, 31_536_000_000),
185
180
  maxPagesPerCall: configInteger(
186
181
  source.maxPagesPerCall,
@@ -738,12 +733,15 @@ function sameInjectedPage(state, position) {
738
733
  }
739
734
 
740
735
  /**
741
- * Decide whether this step should receive a reading-position message.
736
+ * Decide whether this turn should receive a reading-position message.
737
+ *
738
+ * One hint per turn at most: the guard is the turn number, not the step, and
739
+ * the hint is only sent when the reported page changed since the last one.
742
740
  * Pure: callers own the state map and the clock.
743
- * @param options - position, prior state, turn/step, clock, and config.
741
+ * @param options - position, prior state, turn, clock, and config.
744
742
  * @returns `{ inject, reason, nextState }`; `nextState` is unchanged when false.
745
743
  */
746
- export function decideAutoShare({ position, state = INITIAL_AUTO_SHARE_STATE, now, turn, step, config = DEFAULT_CONFIG }) {
744
+ export function decideAutoShare({ position, state = INITIAL_AUTO_SHARE_STATE, now, turn, config = DEFAULT_CONFIG }) {
747
745
  const keep = { inject: false, reason: '', nextState: state }
748
746
  if (!config.autoShare) return { ...keep, reason: 'disabled' }
749
747
  if (position === null || position === undefined) return { ...keep, reason: 'no-position' }
@@ -752,15 +750,11 @@ export function decideAutoShare({ position, state = INITIAL_AUTO_SHARE_STATE, no
752
750
  if (typeof position.updatedAt !== 'number') return { ...keep, reason: 'unknown-age' }
753
751
  if (now - position.updatedAt > config.autoShareMaxAgeMs) return { ...keep, reason: 'stale' }
754
752
  }
755
- if (state.lastInjectedAt !== null && state.lastInjectedAt !== undefined) {
753
+ if (Number.isInteger(turn) && state.lastInjectedTurn === turn) return { ...keep, reason: 'same-turn' }
754
+ if (config.autoShareMinIntervalMs > 0 && state.lastInjectedAt !== null && state.lastInjectedAt !== undefined) {
756
755
  const elapsed = now - state.lastInjectedAt
757
756
  if (!(elapsed >= config.autoShareMinIntervalMs)) return { ...keep, reason: 'throttled' }
758
757
  }
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
758
  if (
765
759
  config.autoSharePageDelta > 1 &&
766
760
  state.lastInjectedPdfPage !== null &&
@@ -777,7 +771,6 @@ export function decideAutoShare({ position, state = INITIAL_AUTO_SHARE_STATE, no
777
771
  lastInjectedPdfPage: position.pdfPage,
778
772
  lastInjectedAt: now,
779
773
  lastInjectedTurn: Number.isInteger(turn) ? turn : null,
780
- lastInjectedStep: Number.isInteger(step) ? step : null,
781
774
  },
782
775
  }
783
776
  }
@@ -823,7 +816,6 @@ export function createAutoShareListener({
823
816
  state,
824
817
  now: now(),
825
818
  turn: payload.turn,
826
- step: payload.step,
827
819
  config,
828
820
  })
829
821
  if (!verdict.inject) return decision
@@ -1339,8 +1331,8 @@ export function trimPageText(raw, maxChars) {
1339
1331
  return { text: normalized.slice(0, maxChars), truncated: true, unavailable: false }
1340
1332
  }
1341
1333
 
1342
- /** Register `pdf_info`, `pdf_search`, and `pdf_current_page`. */
1343
- export function createCheapTools({ defineTool: makeTool, config, core, infoCache, positions, statFile = stat }) {
1334
+ /** Register `pdf_info` and `pdf_search`. */
1335
+ export function createCheapTools({ defineTool: makeTool, config, core, infoCache, statFile = stat }) {
1344
1336
  return [
1345
1337
  makeTool({
1346
1338
  name: 'pdf_info',
@@ -1522,83 +1514,6 @@ export function createCheapTools({ defineTool: makeTool, config, core, infoCache
1522
1514
  locations: [{ path: args.path }],
1523
1515
  }),
1524
1516
  }),
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
1517
  ]
1603
1518
  }
1604
1519
 
@@ -1910,7 +1825,6 @@ export function apply(ctx, rawConfig) {
1910
1825
  config,
1911
1826
  core,
1912
1827
  infoCache,
1913
- positions,
1914
1828
  statFile: stat,
1915
1829
  })) {
1916
1830
  ctx.tools.register(tool)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-book-reader",
3
- "version": "0.1.0",
4
- "description": "Read books and papers in DSH: page-aware controls on the official PDF preview, plus cheap text-first PDF context for the model (TOC, search, selective page images).",
3
+ "version": "0.1.1",
4
+ "description": "Read books and papers in DSH: a reading toolbar on the official PDF preview, plus PDF tools for the model (page map, full-text search, page text and images).",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "dsh-plugin",
@@ -17,11 +17,11 @@
17
17
  "author": "strepsiades",
18
18
  "repository": {
19
19
  "type": "git",
20
- "url": "git+https://github.com/strepsiades/dsh-book-reader.git"
20
+ "url": "git+https://github.com/Strepsiades2/dsh-book-reader.git"
21
21
  },
22
- "homepage": "https://github.com/strepsiades/dsh-book-reader#readme",
22
+ "homepage": "https://github.com/Strepsiades2/dsh-book-reader#readme",
23
23
  "bugs": {
24
- "url": "https://github.com/strepsiades/dsh-book-reader/issues"
24
+ "url": "https://github.com/Strepsiades2/dsh-book-reader/issues"
25
25
  },
26
26
  "engines": {
27
27
  "node": ">=20"