dsh-read-animation 0.0.0-stage → 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 ADDED
@@ -0,0 +1,124 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1 — 2026-10-14
4
+
5
+ Fixed after the first release, from what a live session reported back.
6
+
7
+ - **`read_animation` follows the path it advertises.** Every image a model receives
8
+ is shown as a normalized copy at `attachments/v1/objects/<2>/<digest>`, and that
9
+ is the path a model reaches for when it wants to look at the picture again. For a
10
+ pasted animation that copy is a single frame, so the tool answered "this picture
11
+ has one frame": true of that file, and useless as an answer. The tool now resolves
12
+ that path to the animation this plugin kept, and reports the file the bytes
13
+ actually came from. A bare `sha256:<digest>` is accepted too.
14
+ - **The sheet says where the animation lives.** Its header ends with the path the
15
+ store keeps the original at, so a model that wants more than the eight samples can
16
+ read that path with explicit frame indices instead of searching the attachment
17
+ store for the file behind the picture it was sent.
18
+
19
+ ## 0.1.0 — 2026-10-14
20
+
21
+ First release.
22
+
23
+ Note for anyone publishing a later version of this package: npm routes the first
24
+ release of a brand-new package name through staged publishing, and the registry
25
+ carries a public `0.0.0-stage` placeholder while the real version is processed. The
26
+ real version appears on its own a couple of minutes later; nothing needs approving.
27
+
28
+ ### Fixed after an adversarial audit
29
+
30
+ Four independent reviews (blast radius, plugin code, packaging and lifecycle, then
31
+ an adversarial pass over every high-severity finding) ran before this was
32
+ published. What they found, and what changed:
33
+
34
+ - **The frame sheet's `variantId` was not a content digest.** It was a hand-rolled
35
+ `sha256:filmanim…` string, and the DeepSeek adapter validates persisted ids
36
+ against `/^sha256:[0-9a-f]{64}$/` when it loads its upload index — then swallows
37
+ the failure into an *empty* index. The effect was not a visible error: it
38
+ discarded every other session's cached file id and re-uploaded every image.
39
+ Reproduced against the real exported index class (plugin id: written 1, reloaded
40
+ 0; conforming id: 1/1). It is now the SHA-256 of the bytes being returned, which
41
+ also stops one on-disk cache entry from aliasing two different byte streams.
42
+ - **The degraded path was worse than the missing feature.** The store reaches the
43
+ frame it re-encodes through its own `readImage` call, which this plugin wraps, so
44
+ falling back to the store's request read could hand the model the *whole
45
+ animation* where it expects one frame. The request path now suspends the
46
+ animation substitution around that call, and reads its own tiles through the
47
+ unwrapped read. Pinned by an integration check that breaks a stored frame and
48
+ asserts the fallback is exactly one frame.
49
+ - **No decoded-pixel ceiling on the `read_animation` path.** `limitInputPixels`
50
+ was disabled with nothing in its place: a 60 KB file declaring a 9000x9000
51
+ canvas was refused by the deployment's own attachment store and decoded here
52
+ anyway, at about 310 MiB. Both the probe and the frame extraction now carry the
53
+ deployment's `imageLimits.maxImagePixels`.
54
+ - **The sheet cache ignored the size the caller asked for**, so a caller with an
55
+ 8 KiB budget could be served a 28 KB sheet built for a 4 MiB one. The target is
56
+ now part of the cache key, and a tile is never larger than a single frame would
57
+ have been for that caller.
58
+ - **The measured header wrap could still be defeated.** A floor of 16 characters
59
+ kept the loop from reaching a budget that fit, so on a narrow canvas it gave up
60
+ and returned ink wider than the sheet. The floor is now low enough to be
61
+ satisfiable, and a canvas too narrow for legible text gets no header at all.
62
+ - **The canvas was composed before it was shrunk**, building a 6156x6306 (about
63
+ 111 MiB) image to throw away. Tile size and grid are settled first.
64
+ - **Torn state files could poison the next record.** A crash part way through an
65
+ append left a line with no newline, so the following record concatenated onto it
66
+ and became unreadable too. A torn tail is now detected and compacted away.
67
+ - **The store's bound applied to memory but not to what was read back**, and the
68
+ log was never compacted. Both fixed; the file is rewritten when it grows past
69
+ twice the entries worth keeping.
70
+ - **Teardown was last-writer-wins.** Each install captured whatever was on the
71
+ method, so disposing one left another's patch removed and could reinstall a
72
+ disposed closure as a permanent own property. Three wrappers now share one
73
+ installer that resolves its delegate per call, removes cleanly (deleting an
74
+ inherited property rather than leaving a shadow behind), and reports whether it
75
+ actually took effect — the previous identity check compared a value that proxy
76
+ reads always re-wrap, so it could never detect a failed install.
77
+ - **`npm test` did not run the invariant checks.** The no-`@deepseek-ai`-import
78
+ rule and the bundle/manifest consistency check lived only in the integration
79
+ harness, which prints SKIP and exits 0 without `DSH_HARNESS_MODULES`. Both now
80
+ run in `npm test`, which is also the `prepublishOnly` gate.
81
+ - **`files[]` omitted `scripts/`** while `package.json` advertised two npm scripts
82
+ that live there, so the published package's own scripts were broken.
83
+
84
+ - **A pasted animation keeps playing in the UI, and the message is left alone.**
85
+ The browser already uploads an animated GIF/WebP byte-for-byte and labelled as
86
+ such; the Host flattens it to a single
87
+ frame during prompt admission and keeps no copy of the source. The plugin wraps
88
+ `ctx.attachments.admitPromptContent` to capture the frames and the original file
89
+ at that moment, and `ctx.attachments.readImage` so the UI is served the animation
90
+ itself (the browser animates whatever bytes that one read returns). What you send
91
+ is what you send: the chat message is unchanged.
92
+ - **The model now receives a labelled sheet of the frames.** The delivery step is
93
+ `ctx.attachments.readImageRequest(ref, target, signal)`, the method each adapter
94
+ calls to build its own copy of one image. It answers with exactly one image, so
95
+ the frames are composed into a grid chosen to be as square as the sheet budget
96
+ allows — every tile at its native size, with the frame index, duration and start
97
+ time printed under each, and the animation's own facts in a wrapped header. The
98
+ sheet is built once per animation and cached.
99
+ - Rewriting the request from an `llm/stream` listener is **not** used and would not
100
+ work: a loop-built request arrives deep-frozen ("listeners read it, never rewrite
101
+ it"), so that earlier attempt silently did nothing.
102
+ - Captured frames and the original are persisted per `$DSH_HOME` in
103
+ `read-animation/v1/pasted.jsonl`, keyed by the message's attachment id, so a
104
+ session resumed in a new process still displays — and still delivers — an
105
+ animation pasted before the restart.
106
+ - A frame's index and start time ride in its attachment display name
107
+ (`x.gif#frame16@1960ms`), which the harness renders beside the picture once the
108
+ frames are delivered — so the timing never has to be repeated as message text.
109
+ (An earlier version of this feature put a header plus one caption per frame into
110
+ the user's own message; consecutive text parts are concatenated by the UI with
111
+ no separator, so a one-image message became an unreadable wall.)
112
+ - `read_animation` tool: decodes an animated GIF or animated WebP, samples frames
113
+ across the timeline (or by explicit index / instant), commits each frame to the
114
+ attachment store, and returns a header plus one captioned image per frame.
115
+ - Refuses animated PNG by name instead of silently returning its first frame,
116
+ which is what the host image library does with one.
117
+ - Refuses static images and points at `read_image`; answers a single-frame GIF or
118
+ WebP with a note that it is not animated.
119
+ - Discloses decode-time substitutions in the model-facing text: zero frame
120
+ delays treated as 100 ms, missing delays, transparency flattened onto white.
121
+ - The package imports no `@deepseek-ai/*` module at runtime, so it loads both as
122
+ an installed package and as a `link:`ed checkout. The compiled schemas ship as
123
+ literals beside their authoring specs, and the integration harness keeps the
124
+ two in step.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NeoHotaru
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,322 @@
1
- # Temporary Holding Version
1
+ # dsh-read-animation
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A DeepSeek Harness plugin that lets the agent **actually see an animated GIF**.
4
+
5
+ `read_image` on a GIF gives the model one picture — the first frame — and no
6
+ signal that the other frames existed. This plugin decodes every frame and hands
7
+ the model a labelled sequence of real frames sampled across the animation's
8
+ timeline.
9
+
10
+ ```sh
11
+ dsh plugin --profile web add dsh-read-animation
12
+ ```
13
+
14
+ ---
15
+
16
+ ## The problem
17
+
18
+ DeepSeek Harness normalizes every stored image to a single-frame 8-bit sRGB
19
+ raster before it reaches a model, and it does this to animations too.
20
+ `@deepseek-ai/dsh-attachment-local` excludes `image/gif` from its pass-through
21
+ path and re-encodes through a pipeline that never sets `animated` or `page`, so
22
+ `sharp` returns page 0 and the rest are dropped on the floor.
23
+
24
+ Measured, on this machine, against an 8-frame GIF:
25
+
26
+ ```
27
+ metadata: {"format":"gif","width":240,"height":160,"pages":8,
28
+ "delay":[80,80,80,80,80,80,80,80],"loop":0}
29
+ default -> 240x160 pages=1 ← what read_image delivers
30
+ animated -> 240x1280 pages=1 ← all eight frames, when asked for
31
+ ```
32
+
33
+ No layer above the read step can repair this. By the time a vision plugin, an
34
+ image router, or the LLM adapter sees the image it is already a single frame —
35
+ which is why the growing ecosystem of DSH vision plugins
36
+ ([`dsh-multimodal`](https://github.com/SCT192221/dsh-multimodal),
37
+ [`dsh-mmroute`](https://github.com/jmxsxwyzjdwl/dsh-mmroute),
38
+ [`dsh-vision-mix`](https://github.com/haiziyao/dsh-vision-mix),
39
+ [`dsh-tool-plus`](https://github.com/xiaoso456/dsh-tool-plus), …) all say they
40
+ "support PNG/JPEG/WebP/GIF" without any of them showing you motion. The
41
+ animation is still intact in exactly one place: the file. So the tool lives
42
+ there.
43
+
44
+ Animated PNG has the same disease with a worse prognosis: libvips 8.18.7 does
45
+ not read `acTL`/`fcTL`/`fdAT` at all, reports an APNG as a one-page PNG, and
46
+ silently discards every frame after the first. This plugin detects that case from
47
+ the chunk stream and refuses it by name instead of returning frame 0.
48
+
49
+ ## Two ways in, both handled
50
+
51
+ **Pasting into the chat box.** That path is already clean on the browser side: the
52
+ paste handler hands the clipboard's own `File` straight to the upload path, and
53
+ that path encodes `image/gif` and `image/webp` unchanged (`imageMediaType()`
54
+ accepts both and returns them as-is) — no canvas, no `drawImage`, no `toBlob`.
55
+ **The bytes reach the Host whole.** It is the Host that destroys them:
56
+ `admitPromptContent` runs every image through the single-frame normalizer and
57
+ keeps no copy of the source.
58
+
59
+ `admitPromptContent` is therefore the last moment the original bytes exist. The
60
+ plugin wraps it, decodes the animation and stores the frames — then **returns the
61
+ original result untouched**:
62
+
63
+ ```
64
+ you paste 1 animation
65
+ ├─ the message: unchanged — the picture you sent, not one word added
66
+ ├─ the UI: the animation itself, played from the original bytes
67
+ └─ the model: one sheet of its frames, each labelled with index and time
68
+ ```
69
+
70
+ **What you send is what you send.** Nothing is added to the message you wrote,
71
+ and it does not turn into eight stills.
72
+
73
+ **And it keeps playing in the UI.** The Web UI has exactly one way to display a
74
+ durable image: `session.readAttachment(id)` → the Host's
75
+ `ctx.attachments.readImage(ref)` →
76
+ `URL.createObjectURL(new Blob([bytes], { type: attachment.mediaType }))`. Both the
77
+ bytes and the media type come from that one host call, and the browser animates
78
+ whatever it is handed. So the plugin serves the original at that **display read** —
79
+ no client plugin, no media route, no second authorization path. The single frame
80
+ in the store is only a lossy artifact of a normalizer that cannot represent
81
+ motion; handing back the file the person actually pasted is the more truthful
82
+ answer to "read this stored image", and the other two callers of that layer (ACP
83
+ image blocks, session-log export) are better with an animation too.
84
+
85
+ Each captured frame carries its index and start time in its attachment display
86
+ name (`x.gif#frame16@1960ms`), which the harness renders to the model as
87
+ `Image "<name>" …` — so the timing is already attached to the frames, waiting
88
+ for the delivery step below.
89
+
90
+ **The display path and the model's are independent**: the UI is served the
91
+ original bytes by `readImage`, while the model's copy comes from
92
+ `readImageRequest`, which composes the sheet.
93
+
94
+ > **Where the frames are delivered**: `readImageRequest(ref, target, signal)` is
95
+ > the method each adapter calls to build its own copy of one image
96
+ > (`dsh-llm-deepseek` and `dsh-llm-pi-ai` both do), so it is the seam that reaches
97
+ > the model. It answers with exactly one image, so the frames are composed into one
98
+ > labelled sheet: a 4x2 grid for eight samples of a phone-shaped animation, every
99
+ > tile at its native size, with the frame index, duration and start time printed
100
+ > under each. Nothing is re-derived per request — the same stored attachment always
101
+ > produces the same sheet — and the sheet is built once and cached.
102
+ >
103
+ > The route that looks obvious, rewriting the request from an `llm/stream`
104
+ > listener, is closed by design: a loop-built request "arrives deep-frozen
105
+ > (mutation throws): its content is a pure function of the session log", so
106
+ > listeners may read it but never rewrite it. An earlier version of this plugin
107
+ > tried exactly that and silently did nothing, because the probe behind it used a
108
+ > hand-built request, which is the one case that documentation exempts.
109
+
110
+ **Files.** Animations in the workspace, or attached as files, still exist on
111
+ disk byte-for-byte; the `read_animation` tool below reads those.
112
+
113
+ ## What it does
114
+
115
+ `read_animation(path, …)` returns one text header plus one captioned image per
116
+ sampled frame:
117
+
118
+ ```
119
+ <path>D:\work\dance.gif</path>
120
+ <type>animation</type>
121
+ <content>
122
+ image/gif animation, 480x270 px, 24 frames, 1200 ms total, ~20 fps, loops forever.
123
+ Attached below are 6 frames sampled evenly in time from this animation. They are
124
+ consecutive moments of the SAME animation, in time order, not separate images;
125
+ each is preceded by its frame index and start time.
126
+ Only frames 0, 4, 9, 13, 18, 23 of 24 were sampled, so any motion between two
127
+ attached frames is not visible. Re-read with explicit "frames" indices to inspect
128
+ a specific moment, or a higher "max_frames" to sample more densely.
129
+ </content>
130
+ ```
131
+
132
+ …then, for each frame:
133
+
134
+ ```
135
+ frame 9 of 24 (sample 3) · starts at 450 ms · displayed for 50 ms
136
+ [ image ]
137
+ ```
138
+
139
+ Everything the model needs to reason about *motion* is in that text: how long the
140
+ animation runs, how fast it plays, whether it loops, which frames it is looking
141
+ at, and — stated outright, because it is the easiest thing to get wrong — that
142
+ the gaps are gaps.
143
+
144
+ ## Install
145
+
146
+ ```sh
147
+ dsh plugin --profile web add dsh-read-animation
148
+ ```
149
+
150
+ Replace `web` with your profile name. Both routes are verified in a real profile:
151
+ the install lands, `dsh plugin add` registers the bundle, and the `sharp` native
152
+ binary resolves from the package's own `node_modules`.
153
+
154
+ ```sh
155
+ dsh plugin --profile web add dsh-read-animation # npm
156
+ dsh plugin --profile web add github:NeoHotaru/dsh-read-animation#v0.1.0 # a tagged commit
157
+ ```
158
+
159
+ The git route copies the whole repository, tests and fixtures included, which is
160
+ why npm is the nicer install. (A brand-new package name goes through npm's staged
161
+ publishing on its first release, so the version you install may briefly not exist
162
+ yet while the registry processes it.)
163
+
164
+ Or from a checkout:
165
+
166
+ ```sh
167
+ cd dsh-read-animation
168
+ npm install # fetches sharp
169
+ dsh plugin --profile web add link:$PWD
170
+ ```
171
+
172
+ The same spec works in the Web sidebar's Plugins page, and in Settings the plugin
173
+ list is read-only. Restart the running instance afterwards: plugin rows take
174
+ effect on the next process, and a `link:`ed checkout is loaded once at startup.
175
+
176
+ ## Usage
177
+
178
+ The model reaches for it on its own for a `.gif`, because the tool description
179
+ says to. You can also ask directly:
180
+
181
+ > what happens in this GIF? — `animations/loading-spinner.gif`
182
+
183
+ Arguments:
184
+
185
+ | argument | meaning |
186
+ |---|---|
187
+ | `file_path` | the animation to read (required) |
188
+ | `frames` | explicit 0-based indices, in the order given — up to 16 |
189
+ | `at` | millisecond instants to sample instead of an even spread |
190
+ | `max_frames` | how many frames to sample (default 8, maximum 16) |
191
+ | `max_edge` | long-edge size of each returned frame in px (default 640) |
192
+
193
+ A useful loop, when the first read is not enough detail:
194
+
195
+ ```
196
+ read_animation { file_path: "dance.gif" } → 8 frames across the timeline
197
+ read_animation { file_path: "dance.gif",
198
+ frames: [11, 12, 13], max_edge: 1024 } → those three, full size
199
+ ```
200
+
201
+ ## Configuration
202
+
203
+ Optional, in the plugin row of your profile's `cordis.patch.yml`:
204
+
205
+ ```yaml
206
+ - id: read-animation
207
+ name: 'dsh-read-animation'
208
+ config:
209
+ maxFrames: 8 # default frames per call, 1..16
210
+ maxEdge: 640 # default long-edge cap, 64..2048
211
+ background: white # "white" flattens transparency, "keep" preserves it
212
+ maxInputBytes: 67108864 # refuse animations larger than this
213
+ expandPastedAnimations: true # a pasted animation keeps playing in the UI and reaches the model as a frame sheet
214
+ ```
215
+
216
+ ## What it will not do
217
+
218
+ - **Video.** MP4/WebM need ffmpeg and a different decode path. Use
219
+ [`dsh-video-tools`](https://github.com/PiedPiper911/dsh-video-tools).
220
+ - **APNG.** Not decodable by the host image library; refused with an explanation
221
+ (see above).
222
+ - **Read a static image.** It says so and points at `read_image`. A single-frame
223
+ GIF or WebP is answered, with a note that it is not animated.
224
+ - **Guess.** When the sampler returns fewer frames than you asked for, the header
225
+ says which ones and why.
226
+
227
+ ## How it behaves under the hood
228
+
229
+ Frames are committed through `ctx.attachments.saveImages`, the same store every
230
+ other harness image goes through. So frames are content-addressed (two identical
231
+ frames in one GIF cost one attachment; re-reading a file reuses what is already
232
+ stored), durable across a session replay, and subject to the deployment's own
233
+ image limits. Frames are resized to the target edge and re-encoded as PNG before
234
+ storage, so the harness's normalizer has nothing left to do to them.
235
+
236
+ Reading goes through `ctx.fs.readBytes` and emits `fs/observed`, so the sandbox
237
+ and the read-before-write policy apply exactly as they do to `read_image`. The
238
+ tool refuses to run when the resolved model route does not declare image input,
239
+ for the same reason `read_image` does.
240
+
241
+ **A pasted animation** is handled in three steps, none of which touch your
242
+ message: **capture** at admission (`ctx.attachments.admitPromptContent` is the last
243
+ moment the original bytes exist), **display** at `ctx.attachments.readImage` (the
244
+ one read the UI makes, served the original so it plays), and **delivery** at
245
+ `ctx.attachments.readImageRequest` (the model's copy, composed into one labelled
246
+ sheet). All three are **monkey-patches of core service methods**, so they do
247
+ nothing at all the moment anything is not as expected — a missing method, a failed
248
+ assignment, a decode that throws, an original that cannot be read back, a batch
249
+ with no room. The caller always receives exactly what it would have received
250
+ anyway. Captured frames and the original file are recorded in
251
+ `$DSH_HOME/read-animation/v1/pasted.jsonl`, keyed by the attachment id the message
252
+ carries, so a session resumed in a new process still displays — and still expands
253
+ — an animation pasted before the restart. Delivery swaps references only — at that
254
+ layer an image is still `{type: 'image', attachment}` — and never touches bytes.
255
+
256
+ ### Design notes for contributors
257
+
258
+ Two decisions are worth knowing before you change anything.
259
+
260
+ **Frames are extracted one at a time with `sharp(buf, { page: i })`, not by
261
+ decoding the animation as one tall strip and slicing it.** Both were measured on
262
+ a 600×400, 60-frame GIF: the strip path costs 54.9 MiB resident and is *slower*
263
+ here (228 ms for 8 frames at a 512 px target, because it resizes all 60 frames)
264
+ than per-frame extraction (182 ms, one frame resident at a time). The correctness
265
+ claim this rests on — that `{ page: i }` is byte-identical to frame `i` of the
266
+ composited strip — is pinned by a test.
267
+
268
+ **The package imports no `@deepseek-ai/*` at runtime.** The harness's own tools
269
+ build their definitions with `defineTool`, which compiles a schema spec and wraps
270
+ the body with argument validation. That is right for a plugin installed *as a
271
+ package*, where pnpm places it under `$DSH_HOME/profiles/<name>/node_modules/`
272
+ and `@deepseek-ai/*` resolves by walking up to the profile's hoisted tree. It is
273
+ wrong for a plugin *linked from a working copy*: a `link:`ed package loads
274
+ through its real path, so its imports resolve upward from the checkout, where no
275
+ `@deepseek-ai/*` exists — measured, by importing the entry with `node_modules`
276
+ absent (`ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/dsh-tools'`).
277
+ `lib/tool.js` therefore ships the compiled JSON Schema as literals beside the
278
+ authoring spec, and the integration harness asserts the real `defineTool` still
279
+ derives exactly those literals from that spec. Edit the spec, run
280
+ `npm run derive-schema`, paste the result.
281
+
282
+ ## Development
283
+
284
+ ```sh
285
+ npm install
286
+ npm test # decoder, sampling, envelope, wrapping, store, layout, arguments
287
+ npm run test:integration # real dsh-fs, dsh-attachments, and dsh-tools, in-process
288
+ npm run fixtures # regenerate test/fixtures (needs Python 3 + Pillow)
289
+ ```
290
+
291
+ `npm test` needs nothing but Node and the installed `sharp`, and it is the gate
292
+ `npm publish` runs. It also holds the two invariants that used to live only in the
293
+ integration harness — the no-`@deepseek-ai`-import rule and the bundle's
294
+ consistency with `package.json` — because that harness prints SKIP and exits 0
295
+ when `DSH_HARNESS_MODULES` is unset, so "the integration suite is green" could
296
+ otherwise mean nothing had been checked.
297
+
298
+ `npm run test:integration` boots the genuine `dsh-fs-local`,
299
+ `dsh-attachment-local`, `dsh-system-prompt`, and `dsh-tools` plugins in-process,
300
+ registers the tool, and executes it. It exists to prove the one thing no unit
301
+ test can reach and the whole design depends on: that a single tool result
302
+ carrying *several* image blocks survives the real registry and the real
303
+ attachment store. It also drives the pasted-animation path end to end — a real
304
+ `llm.stream` dispatch, a deep-frozen request, and the request image the adapter
305
+ receives — because three separate bugs in this plugin were invisible to
306
+ everything that did not go through that path. Only the `llm` service and the
307
+ agent object are stubbed, both because they need a live model session. Point it
308
+ at your harness install:
309
+
310
+ ```sh
311
+ DSH_HARNESS_MODULES="$(npm root -g)/@deepseek-ai/dsh/node_modules" npm run test:integration
312
+ ```
313
+
314
+ On a machine that has no `sharp` reachable from the checkout,
315
+ `DSH_READ_ANIMATION_SHARP=<path to a loadable sharp>` overrides resolution.
316
+
317
+ The fixtures are committed, so the suite runs without Python; `make-fixtures.py`
318
+ regenerates them and documents what each one is for.
319
+
320
+ ## License
321
+
322
+ MIT. See [THIRD_PARTY.md](THIRD_PARTY.md) for sharp and libvips.