dsh-read-animation 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/read.js CHANGED
@@ -7,26 +7,59 @@
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
  }
27
54
 
28
55
  const attachments = ctx.get('attachments')
29
56
  if (attachments === undefined) throw new Error('read_animation requires a mounted attachment store')
57
+ if (typeof attachments.saveImages !== 'function') {
58
+ throw new Error(
59
+ 'read_animation needs attachments.saveImages() to store the frames it decodes, and this DSH build does not ' +
60
+ 'provide it; read_animation supports DSH 0.1.3-alpha.2 and later',
61
+ )
62
+ }
30
63
  await assertImageCapableRoute(ctx, exec, args.file_path)
31
64
 
32
65
  const target = await ctx.fs.resolve(args.file_path, resolveOptions(exec))
@@ -40,7 +73,13 @@ export async function readAnimation(ctx, settings, args, exec) {
40
73
  }
41
74
 
42
75
  const byteCap = Math.min(settings.maxInputBytes, attachments.imageLimits.maxMessageImageBytes)
43
- const data = await ctx.fs.readBytes(target, exec.signal, byteCap)
76
+
77
+ // Follow an advertised normalized copy back to the animation this plugin kept.
78
+ // Without this, reading the path the model was shown reports that the picture
79
+ // has one frame, which is true of that file and useless as an answer.
80
+ const kept = await keptOriginal(ctx, attachments, pasted, target)
81
+ const data = kept === undefined ? await ctx.fs.readBytes(target, exec.signal, byteCap) : kept.data
82
+ const path = kept === undefined ? target.displayPath : kept.path
44
83
 
45
84
  // The deployment's own decoded-pixel ceiling, applied to both the probe and the
46
85
  // frame extraction. Bounding the encoded bytes does not bound decoding: a 60 KB
@@ -52,7 +91,7 @@ export async function readAnimation(ctx, settings, args, exec) {
52
91
 
53
92
  if (!SUPPORTED_FORMATS.has(animation.format)) {
54
93
  throw new Error(
55
- `cannot read "${target.displayPath}" as an animation: it is ${animation.format}, and ` +
94
+ `cannot read "${path}" as an animation: it is ${animation.format}, and ` +
56
95
  'read_animation only handles GIF and animated WebP. Use read_image for this file.',
57
96
  )
58
97
  }
@@ -75,7 +114,7 @@ export async function readAnimation(ctx, settings, args, exec) {
75
114
  rendered.map((frame) => ({
76
115
  data: frame.data,
77
116
  mediaType: 'image/png',
78
- name: `${basename(target.displayPath)}#frame${frame.index}`,
117
+ name: `${basename(path)}#frame${frame.index}`,
79
118
  })),
80
119
  )
81
120
 
@@ -83,7 +122,7 @@ export async function readAnimation(ctx, settings, args, exec) {
83
122
 
84
123
  const delays = animation.delaysMs
85
124
  return {
86
- path: target.displayPath,
125
+ path,
87
126
  animation: {
88
127
  mediaType: animation.mediaType,
89
128
  width: animation.width,
@@ -124,6 +163,38 @@ export async function readAnimation(ctx, settings, args, exec) {
124
163
  }
125
164
  }
126
165
 
166
+ /**
167
+ * The animation this plugin kept, for a path that names its normalized copy.
168
+ *
169
+ * Returns the original bytes and the path they actually came from, so the caller
170
+ * can report which file was read rather than which one was asked for. The returned
171
+ * path is the model's route back to the same file for a denser read.
172
+ *
173
+ * @param ctx - the plugin context.
174
+ * @param attachments - the mounted attachment service.
175
+ * @param pasted - the pasted-animation store, or undefined when disabled.
176
+ * @param target - the resolved requested path.
177
+ * @returns `{ data, path }`, or undefined when this is not a captured animation.
178
+ */
179
+ async function keptOriginal(ctx, attachments, pasted, target) {
180
+ if (pasted === undefined) return undefined
181
+ const id = storedAttachmentId(target.displayPath)
182
+ if (id === undefined) return undefined
183
+ const entry = pasted.lookup(id)
184
+ if (entry?.original === undefined) return undefined
185
+
186
+ try {
187
+ const data = await collectBytes(attachments.readFileStream(entry.original.file))
188
+ const host =
189
+ typeof attachments.fileHostPath === 'function' ? attachments.fileHostPath(entry.original.file) : undefined
190
+ return { data, path: typeof host === 'string' && host.length > 0 ? host : target.displayPath }
191
+ } catch {
192
+ // An unreadable original is not a reason to fail: the requested path may still
193
+ // be a usable file in its own right.
194
+ return undefined
195
+ }
196
+ }
197
+
127
198
  /**
128
199
  * Refuse the call when the resolved model route does not declare image input.
129
200
  *
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.2",
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",
@@ -13,13 +13,16 @@
13
13
  "files": [
14
14
  "lib",
15
15
  "scripts",
16
+ "assets",
16
17
  "cordis.patch.yml",
17
18
  "README.md",
18
19
  "README.zh.md",
19
20
  "CHANGELOG.md",
21
+ "COMPATIBILITY.md",
20
22
  "THIRD_PARTY.md",
21
23
  "LICENSE"
22
24
  ],
25
+ "icon": "assets/icon.svg",
23
26
  "license": "MIT",
24
27
  "author": "NeoHotaru (https://github.com/NeoHotaru)",
25
28
  "repository": {
@@ -35,7 +38,7 @@
35
38
  },
36
39
  "engines": {
37
40
  "node": "^22.19.0 || >=24.0.0",
38
- "dsh": ">=0.1.7-alpha.1"
41
+ "dsh": ">=0.1.3-alpha.2"
39
42
  },
40
43
  "keywords": [
41
44
  "dsh",
@@ -55,7 +58,7 @@
55
58
  }
56
59
  },
57
60
  "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",
61
+ "test": "node --test test/animation.test.js test/decode.test.js test/envelope.test.js test/filmstrip.test.js test/model.test.js test/pacing.test.js test/pasted.test.js test/patch.test.js test/read.test.js test/shipped.test.js test/tool.test.js",
59
62
  "test:integration": "node test/integration/harness.mjs",
60
63
  "test:all": "npm test && npm run test:integration",
61
64
  "prepublishOnly": "npm test",
@@ -0,0 +1,121 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Which DSH versions expose every attachment-store member this plugin uses.
3
+
4
+ DSH publishes its whole monorepo at one version, so dsh@X pins
5
+ dsh-attachment-local@X. That makes the probe a straight walk over DSH versions:
6
+ download the two packages that declare the attachment service, concatenate their
7
+ type declarations, and ask which of the members this plugin calls are present.
8
+
9
+ A version whose declarations could not be fetched is reported as NO DATA, never as
10
+ a pass. The first version of this probe returned "OK" for every release because it
11
+ looked in the wrong place and treated an empty haystack as an empty list of
12
+ missing needles.
13
+ """
14
+ import io
15
+ import json
16
+ import sys
17
+ import tarfile
18
+ import urllib.request
19
+ from concurrent.futures import ThreadPoolExecutor
20
+
21
+ REG = 'https://registry.npmjs.org'
22
+ CACHE = {}
23
+
24
+ REQUIRED = [
25
+ 'saveImages',
26
+ 'saveFile',
27
+ 'readImage',
28
+ 'readImageRequest',
29
+ 'readFileStream',
30
+ 'fileHostPath',
31
+ 'imageLimits',
32
+ 'maxImagePixels',
33
+ 'root',
34
+ ]
35
+
36
+ PACKAGES = ['@deepseek-ai/dsh-attachment', '@deepseek-ai/dsh-attachment-local']
37
+
38
+
39
+ def fetch(url, binary=False):
40
+ req = urllib.request.Request(url, headers={'cache-control': 'no-cache',
41
+ 'user-agent': 'dsh-compat-probe'})
42
+ with urllib.request.urlopen(req, timeout=90) as r:
43
+ return r.read() if binary else r.read().decode('utf-8', 'replace')
44
+
45
+
46
+ def packument(name):
47
+ key = 'meta:' + name
48
+ if key not in CACHE:
49
+ CACHE[key] = json.loads(fetch('%s/%s' % (REG, name.replace('/', '%2F'))))
50
+ return CACHE[key]
51
+
52
+
53
+ def declarations(name, version):
54
+ """Every .d.ts in one published package, or None when there is nothing to read."""
55
+ key = '%s@%s' % (name, version)
56
+ if key in CACHE:
57
+ return CACHE[key]
58
+ CACHE[key] = None
59
+ try:
60
+ entry = packument(name)['versions'].get(version)
61
+ if entry is None:
62
+ return None
63
+ blob = fetch(entry['dist']['tarball'], binary=True)
64
+ text = []
65
+ with tarfile.open(fileobj=io.BytesIO(blob), mode='r:gz') as tar:
66
+ for member in tar.getmembers():
67
+ if member.name.endswith('.d.ts') and member.isfile():
68
+ text.append(tar.extractfile(member).read().decode('utf-8', 'replace'))
69
+ CACHE[key] = '\n'.join(text) if text else None
70
+ except Exception as error:
71
+ print(' ! %s: %s' % (key, error), file=sys.stderr)
72
+ return CACHE[key]
73
+
74
+
75
+ def main():
76
+ meta = packument('@deepseek-ai/dsh')
77
+ versions = sorted(meta['versions'], key=lambda v: meta['time'][v])
78
+
79
+ def probe(version):
80
+ texts = []
81
+ sources = []
82
+ for name in PACKAGES:
83
+ text = declarations(name, version)
84
+ if text:
85
+ texts.append(text)
86
+ sources.append(name.rsplit('/', 1)[-1])
87
+ if not texts:
88
+ return version, None, None
89
+ joined = '\n'.join(texts)
90
+ missing = [member for member in REQUIRED if member not in joined]
91
+ return version, missing, '+'.join(sources)
92
+
93
+ with ThreadPoolExecutor(max_workers=6) as pool:
94
+ results = list(pool.map(probe, versions))
95
+
96
+ print('%-18s %-9s %s' % ('dsh', 'read from', 'missing members'))
97
+ print('-' * 84)
98
+ state = {}
99
+ for version, missing, sources in results:
100
+ if missing is None:
101
+ label = 'NO DATA'
102
+ elif missing:
103
+ label = ', '.join(missing)
104
+ else:
105
+ label = 'OK'
106
+ state[version] = label
107
+ print('%-18s %-9s %s' % (version, sources or '-', label))
108
+
109
+ ok = [v for v in versions if state[v] == 'OK']
110
+ nodata = [v for v in versions if state[v] == 'NO DATA']
111
+ print('\nevery member present : %s' % (', '.join(ok) if ok else 'none'))
112
+ print('no data : %s' % (', '.join(nodata) if nodata else 'none'))
113
+ if ok:
114
+ print('\n=> continuous support from %s through %s' % (ok[0], ok[-1]))
115
+ first_bad = [v for v in versions[:versions.index(ok[0])]]
116
+ if first_bad:
117
+ print(' versions before it that lack something: %s' % ', '.join(first_bad))
118
+
119
+
120
+ if __name__ == '__main__':
121
+ main()
@@ -24,10 +24,28 @@ 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 — and the first version of it spelled the layout wrong, printing
41
+ * `files/<name>` where the store uses `files/<2>/<digest>/<name>`, which put a
42
+ * path that does not exist onto the preview. The integration harness asserts the
43
+ * real accessor returns a real file, so only this script could drift.
44
+ */
45
+ function keptFilePath(ref) {
46
+ const digest = String(ref.attachmentId).replace(/^sha256:/, '')
47
+ return join(files, digest.slice(0, 2), digest, ref.name)
48
+ }
31
49
 
32
50
  /** Where a content-addressed attachment object lives. */
33
51
  function objectPath(attachmentId) {
@@ -62,18 +80,8 @@ for (const [position, frame] of frames.entries()) {
62
80
  }
63
81
 
64
82
  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.`
83
+ const kept = record.entry.original?.file
84
+ const header = sheetHeader(animation, tiles.length, kept?.name === undefined ? undefined : keptFilePath(kept))
77
85
 
78
86
  const sheet = await buildFilmstrip(sharp, { tiles, header, maxBytes: 4 * 1024 * 1024, maxDimension: 4096 })
79
87
  await writeFile(out, sheet.data)