dsh-read-animation 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
@@ -1,8 +1,29 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0 — unreleased
3
+ ## 0.1.1 — 2026-10-14
4
4
 
5
- Initial version.
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.
6
27
 
7
28
  ### Fixed after an adversarial audit
8
29
 
package/README.md CHANGED
@@ -147,18 +147,19 @@ the gaps are gaps.
147
147
  dsh plugin --profile web add dsh-read-animation
148
148
  ```
149
149
 
150
- Replace `web` with your profile name. **Not on npm yet** — until it is, install
151
- straight from the repository:
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`.
152
153
 
153
154
  ```sh
154
- dsh plugin --profile web add github:NeoHotaru/dsh-read-animation
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
155
157
  ```
156
158
 
157
- That route is verified end to end: it installs, `dsh plugin add` registers the
158
- bundle, and the `sharp` native binary resolves from the package's own
159
- `node_modules`. (A git spec copies the whole repository, tests and fixtures
160
- included, which is why the npm package above is the nicer install once it
161
- exists.)
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.)
162
163
 
163
164
  Or from a checkout:
164
165
 
package/README.zh.md CHANGED
@@ -121,15 +121,16 @@ frame 9 of 24 (sample 3) · starts at 450 ms · displayed for 50 ms
121
121
  dsh plugin --profile web add dsh-read-animation
122
122
  ```
123
123
 
124
- `web` 换成你自己的 profile 名。**目前还没发到 npm** —— 在那之前可以直接从仓库装:
124
+ `web` 换成你自己的 profile 名。**两条路都在真实 profile 里验证过**:装得上、`dsh plugin add` 会把它
125
+ 注册成 bundle、`sharp` 的原生二进制也能从包自己的 `node_modules` 里解析出来。
125
126
 
126
127
  ```sh
127
- dsh plugin --profile web add github:NeoHotaru/dsh-read-animation
128
+ dsh plugin --profile web add dsh-read-animation # npm
129
+ dsh plugin --profile web add github:NeoHotaru/dsh-read-animation#v0.1.0 # 钉住某个 tag
128
130
  ```
129
131
 
130
- 这条路是**实测过的**:能装上、`dsh plugin add` 会把它注册成 bundle、`sharp` 的原生二进制也能
131
- 从包自己的 `node_modules` 里解析出来。(git spec 会把整个仓库连测试和 fixture 一起拷下来,
132
- 所以等上面那个 npm 包存在之后,npm 那条路更干净。)
132
+ git 那条会把整个仓库(含测试和 fixture)拷下来,所以 npm 更干净。(全新包名的**首次发布**会走
133
+ npm 的 staged publishing,所以刚发完那一小段时间里,registry 上可能还查不到那个版本号。)
133
134
 
134
135
  或者从本地检出:
135
136
 
package/lib/bytes.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Collect a bounded byte stream into one array.
3
+ *
4
+ * `readFileStream` hands back an async iterable, and two callers here need the
5
+ * whole thing in one piece: the display path, which serves an original animation
6
+ * over the wire, and the tool, which decodes what the store kept verbatim.
7
+ *
8
+ * @module dsh-read-animation/bytes
9
+ */
10
+
11
+ /**
12
+ * Drain an async iterable of chunks.
13
+ *
14
+ * @param stream - the chunks, in order.
15
+ * @returns one `Uint8Array` holding all of them.
16
+ */
17
+ export async function collectBytes(stream) {
18
+ const chunks = []
19
+ let total = 0
20
+ for await (const chunk of stream) {
21
+ chunks.push(chunk)
22
+ total += chunk.byteLength
23
+ }
24
+ const out = new Uint8Array(total)
25
+ let at = 0
26
+ for (const chunk of chunks) {
27
+ out.set(chunk, at)
28
+ at += chunk.byteLength
29
+ }
30
+ return out
31
+ }
package/lib/display.js CHANGED
@@ -31,6 +31,7 @@
31
31
  * @module dsh-read-animation/display
32
32
  */
33
33
 
34
+ import { collectBytes } from './bytes.js'
34
35
  import { describe, warn, wrapServiceMethod } from './patch.js'
35
36
 
36
37
  /**
@@ -64,7 +65,7 @@ export function installOriginalDisplay(ctx, pasted) {
64
65
  const entry = pasted.lookup(ref?.attachmentId)
65
66
  if (entry?.original === undefined) return base(ref, signal)
66
67
  try {
67
- const data = await collect(attachments.readFileStream(entry.original.file, signal))
68
+ const data = await collectBytes(attachments.readFileStream(entry.original.file, signal))
68
69
  return { ref: displayedRef(ref, entry.original, data.byteLength), data }
69
70
  } catch (error) {
70
71
  // A missing or unreadable original must not cost the user their picture.
@@ -122,19 +123,3 @@ function displayedRef(ref, original, bytes) {
122
123
  }
123
124
  }
124
125
 
125
- /** Drain a bounded byte stream into one array. */
126
- async function collect(stream) {
127
- const chunks = []
128
- let total = 0
129
- for await (const chunk of stream) {
130
- chunks.push(chunk)
131
- total += chunk.byteLength
132
- }
133
- const out = new Uint8Array(total)
134
- let at = 0
135
- for (const chunk of chunks) {
136
- out.set(chunk, at)
137
- at += chunk.byteLength
138
- }
139
- return out
140
- }
package/lib/index.js CHANGED
@@ -62,15 +62,19 @@ const DEFAULT_MAX_INPUT_BYTES = 64 * 1024 * 1024
62
62
  export function apply(ctx, config = {}) {
63
63
  const settings = resolveConfig(config)
64
64
  ctx.inject(['attachments'], (imageCtx) => {
65
+ let pasted
65
66
  if (settings.expandPastedAnimations) {
66
67
  // Capture at admission (the last moment the animation's bytes exist) and
67
68
  // hand the frames to the model at request time. The message is not touched.
68
- const pasted = createPastedStore(imageCtx)
69
+ pasted = createPastedStore(imageCtx)
69
70
  installAdmissionCapture(imageCtx, settings, pasted)
70
71
  const display = installOriginalDisplay(imageCtx, pasted)
71
72
  installModelFrames(imageCtx, pasted, display)
72
73
  }
73
- registerTool(imageCtx, settings)
74
+ // The store is handed to the tool as well: a model shown a normalized copy of
75
+ // a pasted animation reaches for that path first, and the tool can answer with
76
+ // the animation instead of reporting that the picture has one frame.
77
+ registerTool(imageCtx, settings, pasted)
74
78
  })
75
79
  }
76
80
 
@@ -102,7 +106,7 @@ function resolveConfig(config) {
102
106
  }
103
107
 
104
108
  /** Build and register the `read_animation` tool definition. */
105
- function registerTool(ctx, settings) {
109
+ function registerTool(ctx, settings, pasted) {
106
110
  ctx.tools.register({
107
111
  name: TOOL_NAME,
108
112
  description: describeTool(settings),
@@ -116,7 +120,7 @@ function registerTool(ctx, settings) {
116
120
  async execute(args, exec) {
117
121
  const violations = validateArguments(args)
118
122
  if (violations.length > 0) throw new Error(`invalid arguments: ${violations.join('; ')}`)
119
- return readAnimation(ctx, settings, args, exec)
123
+ return readAnimation(ctx, settings, args, exec, pasted)
120
124
  },
121
125
  presentCall(args) {
122
126
  return {
package/lib/model.js CHANGED
@@ -163,7 +163,7 @@ async function sheetFor(ctx, entry, ref, target, signal, display) {
163
163
 
164
164
  const sheet = await buildFilmstrip(sharp, {
165
165
  tiles,
166
- header: headerFor(entry.note?.animation, tiles.length),
166
+ header: sheetHeader(entry.note?.animation, tiles.length, hostPathOf(ctx, entry)),
167
167
  // One tile may be as large as a single frame would have been for this caller:
168
168
  // the sheet costs N tiles, not one picture multiplied by N.
169
169
  tileMaxWidth: target?.width,
@@ -191,24 +191,52 @@ async function sheetFor(ctx, entry, ref, target, signal, display) {
191
191
  }
192
192
  }
193
193
 
194
+ /**
195
+ * Where the animation itself is kept, when the store can say.
196
+ *
197
+ * The sheet names it so the model has a route to a denser read: eight samples is
198
+ * what fits in one image, and `read_animation(path, frames: […])` is how it asks
199
+ * for a specific moment afterwards. Without the path it has to go looking through
200
+ * the attachment store for the file behind the picture it was sent.
201
+ */
202
+ function hostPathOf(ctx, entry) {
203
+ const attachments = ctx.get('attachments')
204
+ if (attachments === undefined || typeof attachments.fileHostPath !== 'function') return undefined
205
+ const path = attachments.fileHostPath(entry.original.file)
206
+ return typeof path === 'string' && path.length > 0 ? path : undefined
207
+ }
208
+
194
209
  /**
195
210
  * The sheet's own header: what this animation is, and how it was sampled.
196
211
  *
197
- * Kept short and split in two on purpose. A single long line is not wrapped by
198
- * anything downstream — the sheet's own layout wraps each line to the canvas, and
199
- * that works better from meaningful breaks than from one 200-character sentence.
212
+ * Kept short and split on purpose. A single long line is not wrapped by anything
213
+ * downstream — the sheet's own layout wraps each line to the canvas, and that
214
+ * works better from meaningful breaks than from one 200-character sentence.
215
+ *
216
+ * Exported so the preview script renders the same text the model is handed,
217
+ * instead of a second copy that can drift from this one.
218
+ *
219
+ * @param animation - the captured animation's facts, when available.
220
+ * @param sampled - how many tiles the sheet holds.
221
+ * @param originalPath - where the animation itself is kept, when known.
222
+ * @returns the header, lines separated by newlines.
200
223
  */
201
- function headerFor(animation, sampled) {
224
+ export function sheetHeader(animation, sampled, originalPath) {
202
225
  if (animation === undefined) return `${sampled} sampled frames of one animation, in reading order`
203
226
  const total = animation.frameCount
204
227
  const fps =
205
228
  total > 1 && animation.durationMs > 0 ? ` · ~${Math.round((total * 1000) / animation.durationMs)} fps` : ''
206
229
  const timing = animation.minDelayMs === animation.maxDelayMs ? '' : ' · uneven timing'
207
230
  const loops = animation.loopsForever === true ? ' · loops forever' : ''
231
+ const source =
232
+ originalPath === undefined
233
+ ? `All ${total} frames are in the file this was pasted from`
234
+ : `All ${total} frames are at ${originalPath}`
208
235
  return (
209
236
  `${animation.mediaType} · ${animation.width}x${animation.height} px · ${total} frames · ` +
210
237
  `${animation.durationMs} ms${fps}${timing}${loops}\n` +
211
238
  `The ${sampled} tiles below are evenly spaced samples in reading order; ` +
212
- `each frame's index, duration and start time is printed under it.`
239
+ `each frame's index, duration and start time is printed under it.\n` +
240
+ `${source} — read_animation on that path with explicit frame indices looks closer at any moment.`
213
241
  )
214
242
  }
package/lib/read.js CHANGED
@@ -7,20 +7,47 @@
7
7
  */
8
8
 
9
9
  import { planFrames, probeAnimation, renderFrames } from './animation.js'
10
+ import { collectBytes } from './bytes.js'
10
11
  import { loadSharp } from './sharp.js'
11
12
 
12
13
  /** Media types this tool accepts, independent of what the deployment accepts for storage. */
13
14
  const SUPPORTED_FORMATS = new Set(['gif', 'webp'])
14
15
 
16
+ /**
17
+ * The stored attachment a path names, when it names one.
18
+ *
19
+ * Every image the harness stores has a normalized copy at
20
+ * `attachments/v1/objects/<2>/<digest>`, and that is the path a model is shown for
21
+ * an attachment it received. For an animation this plugin captured, that copy is
22
+ * the lossy single frame and the file the person actually sent is held verbatim
23
+ * beside it, so a model that reaches for the advertised path is asking about the
24
+ * animation through the wrong door.
25
+ *
26
+ * The digest alone is enough to identify the object, so this matches on the leaf
27
+ * name rather than on a directory layout, which also accepts a bare
28
+ * `sha256:<digest>` — how the same object is named everywhere else in the harness.
29
+ *
30
+ * @param pathLike - the requested path, as written.
31
+ * @returns `sha256:<digest>`, or undefined when this is an ordinary file.
32
+ */
33
+ export function storedAttachmentId(pathLike) {
34
+ if (typeof pathLike !== 'string') return undefined
35
+ const leaf = basename(pathLike.trim())
36
+ const bare = /^sha256:([0-9a-f]{64})$/i.exec(leaf)
37
+ const digest = bare?.[1] ?? (/^[0-9a-f]{64}$/i.test(leaf) ? leaf : undefined)
38
+ return digest === undefined ? undefined : `sha256:${digest.toLowerCase()}`
39
+ }
40
+
15
41
  /**
16
42
  * Read one animation and turn it into frames plus a description.
17
43
  * @param ctx - the plugin context carrying `fs`, `attachments`, and optionally `llm`.
18
44
  * @param settings - the resolved plugin configuration.
19
45
  * @param args - validated tool arguments.
20
46
  * @param exec - the tool execution context.
47
+ * @param pasted - the pasted-animation store, when that feature is enabled.
21
48
  * @returns the canonical output value.
22
49
  */
23
- export async function readAnimation(ctx, settings, args, exec) {
50
+ export async function readAnimation(ctx, settings, args, exec, pasted) {
24
51
  if (typeof args.file_path !== 'string' || args.file_path.trim().length === 0) {
25
52
  throw new Error('file_path must be a non-empty string')
26
53
  }
@@ -40,7 +67,13 @@ export async function readAnimation(ctx, settings, args, exec) {
40
67
  }
41
68
 
42
69
  const byteCap = Math.min(settings.maxInputBytes, attachments.imageLimits.maxMessageImageBytes)
43
- const data = await ctx.fs.readBytes(target, exec.signal, byteCap)
70
+
71
+ // Follow an advertised normalized copy back to the animation this plugin kept.
72
+ // Without this, reading the path the model was shown reports that the picture
73
+ // has one frame, which is true of that file and useless as an answer.
74
+ const kept = await keptOriginal(ctx, attachments, pasted, target)
75
+ const data = kept === undefined ? await ctx.fs.readBytes(target, exec.signal, byteCap) : kept.data
76
+ const path = kept === undefined ? target.displayPath : kept.path
44
77
 
45
78
  // The deployment's own decoded-pixel ceiling, applied to both the probe and the
46
79
  // frame extraction. Bounding the encoded bytes does not bound decoding: a 60 KB
@@ -52,7 +85,7 @@ export async function readAnimation(ctx, settings, args, exec) {
52
85
 
53
86
  if (!SUPPORTED_FORMATS.has(animation.format)) {
54
87
  throw new Error(
55
- `cannot read "${target.displayPath}" as an animation: it is ${animation.format}, and ` +
88
+ `cannot read "${path}" as an animation: it is ${animation.format}, and ` +
56
89
  'read_animation only handles GIF and animated WebP. Use read_image for this file.',
57
90
  )
58
91
  }
@@ -75,7 +108,7 @@ export async function readAnimation(ctx, settings, args, exec) {
75
108
  rendered.map((frame) => ({
76
109
  data: frame.data,
77
110
  mediaType: 'image/png',
78
- name: `${basename(target.displayPath)}#frame${frame.index}`,
111
+ name: `${basename(path)}#frame${frame.index}`,
79
112
  })),
80
113
  )
81
114
 
@@ -83,7 +116,7 @@ export async function readAnimation(ctx, settings, args, exec) {
83
116
 
84
117
  const delays = animation.delaysMs
85
118
  return {
86
- path: target.displayPath,
119
+ path,
87
120
  animation: {
88
121
  mediaType: animation.mediaType,
89
122
  width: animation.width,
@@ -124,6 +157,38 @@ export async function readAnimation(ctx, settings, args, exec) {
124
157
  }
125
158
  }
126
159
 
160
+ /**
161
+ * The animation this plugin kept, for a path that names its normalized copy.
162
+ *
163
+ * Returns the original bytes and the path they actually came from, so the caller
164
+ * can report which file was read rather than which one was asked for. The returned
165
+ * path is the model's route back to the same file for a denser read.
166
+ *
167
+ * @param ctx - the plugin context.
168
+ * @param attachments - the mounted attachment service.
169
+ * @param pasted - the pasted-animation store, or undefined when disabled.
170
+ * @param target - the resolved requested path.
171
+ * @returns `{ data, path }`, or undefined when this is not a captured animation.
172
+ */
173
+ async function keptOriginal(ctx, attachments, pasted, target) {
174
+ if (pasted === undefined) return undefined
175
+ const id = storedAttachmentId(target.displayPath)
176
+ if (id === undefined) return undefined
177
+ const entry = pasted.lookup(id)
178
+ if (entry?.original === undefined) return undefined
179
+
180
+ try {
181
+ const data = await collectBytes(attachments.readFileStream(entry.original.file))
182
+ const host =
183
+ typeof attachments.fileHostPath === 'function' ? attachments.fileHostPath(entry.original.file) : undefined
184
+ return { data, path: typeof host === 'string' && host.length > 0 ? host : target.displayPath }
185
+ } catch {
186
+ // An unreadable original is not a reason to fail: the requested path may still
187
+ // be a usable file in its own right.
188
+ return undefined
189
+ }
190
+ }
191
+
127
192
  /**
128
193
  * Refuse the call when the resolved model route does not declare image input.
129
194
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-read-animation",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Let a DeepSeek Harness agent actually see an animated GIF or animated WebP: decode every frame, sample the animation across time, and hand the model a labelled sequence of real frames instead of the single first frame the harness otherwise delivers.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -55,7 +55,7 @@
55
55
  }
56
56
  },
57
57
  "scripts": {
58
- "test": "node --test test/animation.test.js test/decode.test.js test/envelope.test.js test/filmstrip.test.js test/model.test.js test/pasted.test.js test/patch.test.js test/shipped.test.js test/tool.test.js",
58
+ "test": "node --test test/animation.test.js test/decode.test.js test/envelope.test.js test/filmstrip.test.js test/model.test.js test/pasted.test.js test/patch.test.js test/read.test.js test/shipped.test.js test/tool.test.js",
59
59
  "test:integration": "node test/integration/harness.mjs",
60
60
  "test:all": "npm test && npm run test:integration",
61
61
  "prepublishOnly": "npm test",
@@ -24,10 +24,24 @@ import { fileURLToPath } from 'node:url'
24
24
  const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..')
25
25
  const { loadSharp } = await import(pathToFileURL(join(REPO, 'lib', 'sharp.js')).href)
26
26
  const { buildFilmstrip } = await import(pathToFileURL(join(REPO, 'lib', 'filmstrip.js')).href)
27
+ // The same header text the model is handed, so this preview cannot drift from it.
28
+ const { sheetHeader } = await import(pathToFileURL(join(REPO, 'lib', 'model.js')).href)
27
29
 
28
30
  const dshHome = process.env.DSH_HOME ?? resolve(REPO, '..')
29
31
  const stateFile = join(dshHome, 'read-animation', 'v1', 'pasted.jsonl')
30
32
  const objects = join(dshHome, 'attachments', 'v1', 'objects')
33
+ const files = join(dshHome, 'attachments', 'v1', 'files')
34
+
35
+ /**
36
+ * Where the store keeps a file's verbatim bytes.
37
+ *
38
+ * Mirrors `ctx.attachments.fileHostPath`, which the plugin uses for the header's
39
+ * last line. This script has no mounted service, so the layout is spelled out
40
+ * here; the integration harness asserts the real accessor returns a real path.
41
+ */
42
+ function keptFilePath(ref) {
43
+ return join(files, ref.name)
44
+ }
31
45
 
32
46
  /** Where a content-addressed attachment object lives. */
33
47
  function objectPath(attachmentId) {
@@ -62,18 +76,8 @@ for (const [position, frame] of frames.entries()) {
62
76
  }
63
77
 
64
78
  const animation = note?.animation
65
- const total = animation?.frameCount ?? tiles.length
66
- const fps =
67
- animation && total > 1 && animation.durationMs > 0
68
- ? ` · ~${Math.round((total * 1000) / animation.durationMs)} fps`
69
- : ''
70
- const header = animation === undefined
71
- ? `${tiles.length} sampled frames of one animation, in reading order`
72
- : `${animation.mediaType} · ${animation.width}x${animation.height} px · ${total} frames · ` +
73
- `${animation.durationMs} ms${fps}${animation.minDelayMs === animation.maxDelayMs ? '' : ' · uneven timing'}` +
74
- `${animation.loopsForever ? ' · loops forever' : ''}\n` +
75
- `The ${tiles.length} tiles below are evenly spaced samples in reading order; ` +
76
- `each frame's index, duration and start time is printed under it.`
79
+ const kept = record.entry.original?.file
80
+ const header = sheetHeader(animation, tiles.length, kept?.name === undefined ? undefined : keptFilePath(kept))
77
81
 
78
82
  const sheet = await buildFilmstrip(sharp, { tiles, header, maxBytes: 4 * 1024 * 1024, maxDimension: 4096 })
79
83
  await writeFile(out, sheet.data)