dsh-dl-server 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/tools.js ADDED
@@ -0,0 +1,216 @@
1
+ import { CLEANUP_SCOPES, DEFAULT_SCOPE, QueueError } from './queue.js'
2
+ import { mark, markFailure } from './bootlog.js'
3
+
4
+ /**
5
+ * Turn a domain-level failure into a canonical value instead of an exception.
6
+ * Only unexpected failures propagate as errors.
7
+ * @param {unknown} error
8
+ */
9
+ function fail(error) {
10
+ if (error instanceof QueueError) {
11
+ return { ok: false, code: error.code, message: error.message }
12
+ }
13
+ throw error
14
+ }
15
+
16
+ /**
17
+ * Every DLS tool returns one canonical JSON object.
18
+ * `ToolDefinition.output` is mandatory and its `render` is mandatory too, so the
19
+ * shape lives here instead of being repeated seven times.
20
+ */
21
+ const CANONICAL_OUTPUT = {
22
+ schema: { type: 'object', additionalProperties: true },
23
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
24
+ }
25
+
26
+ /**
27
+ * Best-effort caller identity. DSH's tool execution context is the only source;
28
+ * when a field is missing we fall back to a stable placeholder so the queue
29
+ * still behaves sanely in a single-session profile.
30
+ * @param {any} exec
31
+ */
32
+ export function resolveCaller(exec) {
33
+ const agent = exec?.agent
34
+ const session =
35
+ agent?.sessionId ?? agent?.session?.id ?? exec?.session?.id ?? exec?.sessionId ?? 'local'
36
+ const workspace =
37
+ agent?.workspaceId ?? agent?.workspace?.id ?? exec?.workspaceId ?? 'local'
38
+ return { workspace: String(workspace), session: String(session) }
39
+ }
40
+
41
+ /**
42
+ * Register the seven DLS tools.
43
+ *
44
+ * @param {object} deps
45
+ * @param {any} deps.ctx
46
+ * @param {import('./queue.js').Queue} deps.queue
47
+ * @param {object} deps.config
48
+ */
49
+ export function registerTools({ ctx, queue, config }) {
50
+ mark('registerTools enter')
51
+
52
+ ctx.inject(['tools'], (scope) => {
53
+ try {
54
+ mark('tools inject fired', `hasTools=${typeof scope?.tools?.register === 'function'}`)
55
+ const tools = scope.tools
56
+ const withCaller = (exec) => resolveCaller(exec)
57
+
58
+ tools.register({
59
+ name: 'download_enqueue',
60
+ output: CANONICAL_OUTPUT,
61
+ description:
62
+ 'Queue a file for download without blocking. Returns a job id such as J#1042 immediately; ' +
63
+ 'use download_wait or download_status to follow it. The job survives the turn, the session and ' +
64
+ 'a DSH restart. Every session can see every job, but only the owning session can pause, resume ' +
65
+ 'or cancel an in-flight one.',
66
+ parameters: {
67
+ type: 'object',
68
+ properties: {
69
+ urls: {
70
+ type: 'array',
71
+ items: { type: 'string' },
72
+ minItems: 1,
73
+ description: 'Candidate HTTP(S) URLs, tried in order. The first that answers wins.',
74
+ },
75
+ dest: {
76
+ type: 'string',
77
+ description: 'Absolute destination path including the file name.',
78
+ },
79
+ sha256: {
80
+ type: 'string',
81
+ description: 'Optional expected SHA-256. The job fails if the finished file does not match.',
82
+ },
83
+ },
84
+ required: ['urls', 'dest'],
85
+ additionalProperties: false,
86
+ },
87
+ async execute(args, exec) {
88
+ try {
89
+ const job = await queue.enqueue(
90
+ { urls: args.urls, destPath: args.dest, expectSha256: args.sha256 },
91
+ withCaller(exec),
92
+ )
93
+ return { ok: true, jobId: job.id, state: job.state, dest: job.dest }
94
+ } catch (error) {
95
+ return fail(error)
96
+ }
97
+ },
98
+ })
99
+
100
+ tools.register({
101
+ name: 'download_status',
102
+ output: CANONICAL_OUTPUT,
103
+ description:
104
+ 'List DLS jobs. Every session sees every job; the owner field shows which session owns each one. ' +
105
+ 'Omit id to list all jobs, pass an id such as J#1042 for a single job.',
106
+ parameters: {
107
+ type: 'object',
108
+ properties: {
109
+ id: { type: 'string', description: 'Optional job id. Omit to list every job.' },
110
+ },
111
+ additionalProperties: false,
112
+ },
113
+ async execute(args) {
114
+ if (args?.id) {
115
+ const job = queue.get(args.id)
116
+ return job
117
+ ? { ok: true, count: 1, jobs: [job] }
118
+ : { ok: false, code: 'not-found', message: `${args.id} does not exist` }
119
+ }
120
+ const jobs = queue.list()
121
+ return { ok: true, count: jobs.length, jobs }
122
+ },
123
+ })
124
+
125
+ tools.register({
126
+ name: 'download_wait',
127
+ output: CANONICAL_OUTPUT,
128
+ description:
129
+ 'Block until a job reaches a terminal state (completed / failed / canceled) or the timeout expires. ' +
130
+ 'Returns the current job view either way; check state to tell which happened.',
131
+ parameters: {
132
+ type: 'object',
133
+ properties: {
134
+ id: { type: 'string', description: 'Job id such as J#1042.' },
135
+ timeoutSeconds: {
136
+ type: 'number',
137
+ description: 'How long to wait. Default 60, maximum 600.',
138
+ },
139
+ },
140
+ required: ['id'],
141
+ additionalProperties: false,
142
+ },
143
+ async execute(args) {
144
+ const seconds = Math.min(Math.max(Number(args.timeoutSeconds ?? 60), 1), 600)
145
+ try {
146
+ const job = await queue.wait(args.id, seconds * 1000)
147
+ return { ok: true, job }
148
+ } catch (error) {
149
+ return fail(error)
150
+ }
151
+ },
152
+ })
153
+
154
+ for (const [toolName, method, verb] of [
155
+ ['download_pause', 'pause', 'Pause'],
156
+ ['download_resume', 'resume', 'Resume'],
157
+ ['download_cancel', 'cancel', 'Cancel'],
158
+ ]) {
159
+ tools.register({
160
+ name: toolName,
161
+ output: CANONICAL_OUTPUT,
162
+ description:
163
+ `${verb} a DLS job. Only the session that enqueued the job may do this while it is in flight; ` +
164
+ 'finished jobs are open to everyone. A paused job keeps its partial file and can resume later.',
165
+ parameters: {
166
+ type: 'object',
167
+ properties: { id: { type: 'string', description: 'Job id such as J#1042.' } },
168
+ required: ['id'],
169
+ additionalProperties: false,
170
+ },
171
+ async execute(args, exec) {
172
+ try {
173
+ const job = await queue[method](args.id, withCaller(exec))
174
+ return { ok: true, job }
175
+ } catch (error) {
176
+ return fail(error)
177
+ }
178
+ },
179
+ })
180
+ }
181
+
182
+ tools.register({
183
+ name: 'download_cleanup',
184
+ output: CANONICAL_OUTPUT,
185
+ description:
186
+ 'Clean up the calling session\'s DLS records and leftovers. Default scope "allWithoutFiles" removes ' +
187
+ 'finished records and partial files but never touches the downloaded files themselves. Passing "all" ' +
188
+ 'also deletes those files and cannot be undone.',
189
+ parameters: {
190
+ type: 'object',
191
+ properties: {
192
+ scope: {
193
+ type: 'string',
194
+ enum: CLEANUP_SCOPES,
195
+ description: `Default ${DEFAULT_SCOPE}. "all" is the only value that deletes downloaded files.`,
196
+ },
197
+ },
198
+ additionalProperties: false,
199
+ },
200
+ async execute(args, exec) {
201
+ try {
202
+ const result = await queue.cleanup(args?.scope ?? DEFAULT_SCOPE, withCaller(exec))
203
+ return { ok: true, ...result }
204
+ } catch (error) {
205
+ return fail(error)
206
+ }
207
+ },
208
+ })
209
+
210
+ mark('tools registered', '7/7')
211
+ } catch (error) {
212
+ markFailure('registerTools', error)
213
+ throw error
214
+ }
215
+ })
216
+ }
@@ -0,0 +1,357 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { createWriteStream } from 'node:fs'
3
+ import fs from 'node:fs/promises'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { Readable } from 'node:stream'
7
+ import { pipeline } from 'node:stream/promises'
8
+ import { randomBytes } from 'node:crypto'
9
+
10
+ /**
11
+ * Transport layer. Two channels: an aria2c JSON-RPC daemon when one is
12
+ * available, and a plain Node fetch stream otherwise.
13
+ *
14
+ * The queue never talks to aria2 directly; it calls {@link Transport#transfer}.
15
+ *
16
+ * @typedef {object} TransferOptions
17
+ * @property {string[]} urls candidate sources, tried in order
18
+ * @property {string} destPath absolute final file path
19
+ * @property {number} [connections] aria2 split, default 16
20
+ * @property {number} [speedLimitKbps] 0 or absent means unlimited
21
+ * @property {string} [proxy] e.g. http://127.0.0.1:65532
22
+ * @property {number} [bytesDone] resume hint for the direct channel
23
+ * @property {(done: number, total: number) => void} [onProgress]
24
+ * @property {AbortSignal} [signal]
25
+ *
26
+ * @typedef {object} TransferResult
27
+ * @property {number} bytes
28
+ * @property {number} total -1 when the server never reported one
29
+ */
30
+
31
+ const IS_WIN = process.platform === 'win32'
32
+ const ARIA2_BIN = IS_WIN ? 'aria2c.exe' : 'aria2c'
33
+
34
+ /** Absolute locations worth checking beyond PATH. */
35
+ function extraCandidates() {
36
+ if (IS_WIN) {
37
+ const local = process.env.LOCALAPPDATA ?? ''
38
+ const pf = process.env.ProgramFiles ?? 'C:\\Program Files'
39
+ const pf86 = process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)'
40
+ return [
41
+ path.join(pf, 'aria2', ARIA2_BIN),
42
+ path.join(pf86, 'aria2', ARIA2_BIN),
43
+ path.join(local, 'Programs', 'Motrix', 'resources', 'aria2', ARIA2_BIN),
44
+ path.join(local, 'Motrix', 'resources', 'aria2', ARIA2_BIN),
45
+ ]
46
+ }
47
+ return ['/usr/bin/aria2c', '/usr/local/bin/aria2c', '/opt/homebrew/bin/aria2c']
48
+ }
49
+
50
+ async function isExecutable(file) {
51
+ try {
52
+ await fs.access(file, fs.constants.X_OK)
53
+ return true
54
+ } catch {
55
+ return false
56
+ }
57
+ }
58
+
59
+ /** @param {string} name */
60
+ async function findInPath(name) {
61
+ const dirs = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean)
62
+ for (const dir of dirs) {
63
+ const candidate = path.join(dir, name)
64
+ if (await isExecutable(candidate)) return candidate
65
+ }
66
+ return undefined
67
+ }
68
+
69
+ /**
70
+ * Locate an aria2c binary.
71
+ * Order: explicit config, environment, PATH, well-known install locations.
72
+ *
73
+ * @param {string} [configured]
74
+ * @returns {Promise<string | undefined>}
75
+ */
76
+ export async function findAria2(configured) {
77
+ if (configured) {
78
+ try {
79
+ await fs.access(configured, fs.constants.X_OK)
80
+ return configured
81
+ } catch {
82
+ /* fall through */
83
+ }
84
+ }
85
+
86
+ for (const key of ['DLS_ARIA2C_PATH', 'MT_ARIA2C_PATH', 'ARIA2C_PATH']) {
87
+ const value = process.env[key]
88
+ if (value) {
89
+ try {
90
+ await fs.access(value, fs.constants.X_OK)
91
+ return value
92
+ } catch {
93
+ /* fall through */
94
+ }
95
+ }
96
+ }
97
+
98
+ const fromPath = await findInPath(ARIA2_BIN)
99
+ if (fromPath) return fromPath
100
+
101
+ for (const candidate of extraCandidates()) {
102
+ if (await isExecutable(candidate)) return candidate
103
+ }
104
+ return undefined
105
+ }
106
+
107
+ /** Minimal JSON-RPC client for the aria2 daemon. */
108
+ class Aria2Rpc {
109
+ /** @param {number} port @param {string} secret */
110
+ constructor(port, secret) {
111
+ this.port = port
112
+ this.secret = secret
113
+ this.url = `http://127.0.0.1:${port}/jsonrpc`
114
+ this.nextId = 1
115
+ }
116
+
117
+ /** @param {string} method @param {unknown[]} params */
118
+ async call(method, params = []) {
119
+ const body = JSON.stringify({
120
+ jsonrpc: '2.0',
121
+ id: this.nextId++,
122
+ method,
123
+ params: [`token:${this.secret}`, ...params],
124
+ })
125
+ const response = await fetch(this.url, {
126
+ method: 'POST',
127
+ headers: { 'content-type': 'application/json' },
128
+ body,
129
+ })
130
+ const payload = await response.json()
131
+ if (payload.error) {
132
+ throw new Error(`aria2 ${method}: ${payload.error.message}`)
133
+ }
134
+ return payload.result
135
+ }
136
+
137
+ /** Poll until the daemon answers, or give up. */
138
+ async waitReady(timeoutMs = 8000) {
139
+ const deadline = Date.now() + timeoutMs
140
+ while (Date.now() < deadline) {
141
+ try {
142
+ await this.call('aria2.getVersion')
143
+ return true
144
+ } catch {
145
+ await sleep(120)
146
+ }
147
+ }
148
+ return false
149
+ }
150
+ }
151
+
152
+ function sleep(ms) {
153
+ return new Promise((resolve) => setTimeout(resolve, ms))
154
+ }
155
+
156
+ /**
157
+ * The transport facade. One instance per plugin; the daemon is shared by every
158
+ * concurrent job and torn down on dispose.
159
+ */
160
+ export class Transport {
161
+ /**
162
+ * @param {{ aria2Path?: string, aria2Port?: number, connectionsPerJob?: number,
163
+ * speedLimitKbps?: number, proxy?: string }} config
164
+ * @param {(level: string, message: string) => void} [log]
165
+ */
166
+ constructor(config = {}, log = () => {}) {
167
+ this.config = config
168
+ this.log = log
169
+ this.mode = 'direct'
170
+ this.aria2 = undefined
171
+ this.rpc = undefined
172
+ }
173
+
174
+ /** Detect aria2 and start the daemon if possible. Never throws. */
175
+ async init() {
176
+ const binary = await findAria2(this.config.aria2Path)
177
+ if (!binary) {
178
+ this.log('info', 'aria2c not found, using the single-stream fallback')
179
+ return
180
+ }
181
+
182
+ const port = this.config.aria2Port ?? 16801
183
+ const secret = randomBytes(12).toString('hex')
184
+ const args = [
185
+ '--enable-rpc',
186
+ `--rpc-listen-port=${port}`,
187
+ '--rpc-listen-all=false',
188
+ `--rpc-secret=${secret}`,
189
+ '--continue=true',
190
+ '--auto-file-renaming=false',
191
+ '--allow-overwrite=true',
192
+ '--log-level=warn',
193
+ '--console-log-level=warn',
194
+ '--summary-interval=0',
195
+ '--disable-ipv6=true',
196
+ ]
197
+ if (this.config.proxy) args.push(`--all-proxy=${this.config.proxy}`)
198
+
199
+ try {
200
+ this.aria2 = spawn(binary, args, { windowsHide: true, stdio: 'ignore' })
201
+ this.aria2.unref()
202
+ this.aria2.on('exit', () => {
203
+ this.aria2 = undefined
204
+ this.mode = 'direct'
205
+ })
206
+
207
+ const rpc = new Aria2Rpc(port, secret)
208
+ if (await rpc.waitReady()) {
209
+ this.rpc = rpc
210
+ this.mode = 'aria2'
211
+ this.log('info', `aria2c ready on port ${port} (${binary})`)
212
+ } else {
213
+ this.log('warn', 'aria2c started but never answered; falling back')
214
+ this.aria2?.kill()
215
+ this.aria2 = undefined
216
+ }
217
+ } catch (error) {
218
+ this.log('warn', `aria2c failed to start: ${error.message}`)
219
+ this.mode = 'direct'
220
+ }
221
+ }
222
+
223
+ /** @param {TransferOptions} options @returns {Promise<TransferResult>} */
224
+ async transfer(options) {
225
+ if (this.mode === 'aria2' && this.rpc) {
226
+ try {
227
+ return await this.#transferAria2(options)
228
+ } catch (error) {
229
+ if (options.signal?.aborted) throw error
230
+ this.log('warn', `aria2 transfer failed, retrying single-stream: ${error.message}`)
231
+ }
232
+ }
233
+ return this.#transferDirect(options)
234
+ }
235
+
236
+ /** @param {TransferOptions} options */
237
+ async #transferAria2({ urls, destPath, connections, speedLimitKbps, onProgress, signal }) {
238
+ const rpc = this.rpc
239
+ await fs.mkdir(path.dirname(destPath), { recursive: true })
240
+
241
+ const options = {
242
+ dir: path.dirname(destPath),
243
+ out: path.basename(destPath),
244
+ split: String(connections ?? this.config.connectionsPerJob ?? 16),
245
+ 'max-connection-per-server': String(connections ?? this.config.connectionsPerJob ?? 16),
246
+ 'min-split-size': '1M',
247
+ continue: 'true',
248
+ 'auto-file-renaming': 'false',
249
+ 'allow-overwrite': 'true',
250
+ }
251
+ const limit = speedLimitKbps ?? this.config.speedLimitKbps ?? 0
252
+ if (limit > 0) options['max-download-limit'] = `${limit}K`
253
+
254
+ const gid = await rpc.call('aria2.addUri', [urls, options])
255
+
256
+ let aborted = false
257
+ const onAbort = () => {
258
+ aborted = true
259
+ void rpc.call('aria2.remove', [gid]).catch(() => {})
260
+ }
261
+ signal?.addEventListener('abort', onAbort, { once: true })
262
+
263
+ try {
264
+ for (;;) {
265
+ if (aborted) throw new Error('canceled')
266
+ const status = await rpc.call('aria2.tellStatus', [
267
+ gid,
268
+ ['status', 'totalLength', 'completedLength', 'errorMessage'],
269
+ ])
270
+ const done = Number(status.completedLength ?? 0)
271
+ const total = status.totalLength === '0' ? -1 : Number(status.totalLength)
272
+ onProgress?.(done, total)
273
+
274
+ if (status.status === 'complete') return { bytes: done, total }
275
+ if (status.status === 'error' || status.status === 'removed') {
276
+ throw new Error(status.errorMessage || `aria2 status ${status.status}`)
277
+ }
278
+ if (status.status === 'paused') return { bytes: done, total }
279
+ await sleep(400)
280
+ }
281
+ } finally {
282
+ signal?.removeEventListener('abort', onAbort)
283
+ }
284
+ }
285
+
286
+ /** @param {TransferOptions} options */
287
+ async #transferDirect({ urls, destPath, bytesDone = 0, onProgress, signal }) {
288
+ await fs.mkdir(path.dirname(destPath), { recursive: true })
289
+ const partPath = `${destPath}.part`
290
+ let lastError
291
+
292
+ for (const url of urls) {
293
+ try {
294
+ let offset = bytesDone
295
+ try {
296
+ offset = (await fs.stat(partPath)).size
297
+ } catch {
298
+ offset = bytesDone
299
+ }
300
+
301
+ const headers = {}
302
+ if (offset > 0) headers.range = `bytes=${offset}-`
303
+
304
+ const response = await fetch(url, { headers, signal })
305
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
306
+
307
+ const ranged = response.status === 206
308
+ const start = ranged ? offset : 0
309
+ const declared = Number(response.headers.get('content-length') ?? 0)
310
+ const total = declared > 0 ? declared + start : -1
311
+
312
+ const sink = createWriteStream(partPath, { flags: ranged ? 'a' : 'w' })
313
+ let written = start
314
+ let lastTick = 0
315
+
316
+ const source = Readable.fromWeb(response.body)
317
+ source.on('data', (chunk) => {
318
+ written += chunk.length
319
+ const now = Date.now()
320
+ if (now - lastTick > 400) {
321
+ lastTick = now
322
+ onProgress?.(written, total)
323
+ }
324
+ })
325
+
326
+ await pipeline(source, sink)
327
+ onProgress?.(written, total)
328
+ await fs.rename(partPath, destPath)
329
+ return { bytes: written, total }
330
+ } catch (error) {
331
+ if (error?.name === 'AbortError' || signal?.aborted) throw error
332
+ lastError = error
333
+ }
334
+ }
335
+ throw lastError ?? new Error('no source available')
336
+ }
337
+
338
+ /** Stop the daemon. Safe to call twice. */
339
+ async dispose() {
340
+ try {
341
+ await this.rpc?.call('aria2.shutdown')
342
+ } catch {
343
+ /* daemon already gone */
344
+ }
345
+ this.rpc = undefined
346
+ this.mode = 'direct'
347
+ try {
348
+ this.aria2?.kill()
349
+ } catch {
350
+ /* ignore */
351
+ }
352
+ this.aria2 = undefined
353
+ }
354
+ }
355
+
356
+ /** Exposed for tests: resolve a candidate without starting anything. */
357
+ export const _internal = { findInPath, extraCandidates, Aria2Rpc, sleep, homeDir: os.homedir }
package/lib/types.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * DLS data model. Zero dependencies, plain JS.
3
+ *
4
+ * @typedef {'queued'|'running'|'paused'|'completed'|'failed'|'canceled'} JobState
5
+ * @typedef {'user'|'session_closed'|'host_restart'} PauseReason
6
+ *
7
+ * @typedef {object} JobRecord
8
+ * @property {string} id `J#1042`
9
+ * @property {number} seq monotonic sequence, matches the number in id
10
+ * @property {string} ownerWorkspace WorkspaceId (uuid)
11
+ * @property {string} ownerSession SessionId (uuid) of the parent session
12
+ * @property {string} [origin] subagent name, display and audit only
13
+ * @property {string[]} urls
14
+ * @property {string} destPath
15
+ * @property {JobState} state
16
+ * @property {PauseReason} [pauseReason]
17
+ * @property {number} createdAt
18
+ * @property {number} updatedAt
19
+ * @property {number} bytesTotal -1 when unknown
20
+ * @property {number} bytesCheckpoint checkpoint bytes, not live progress
21
+ * @property {number} attempts
22
+ * @property {string} [lastError]
23
+ * @property {string} [expectSha256]
24
+ * @property {number} [finalBytes]
25
+ */
26
+
27
+ export const JOB_STATES = Object.freeze([
28
+ 'queued',
29
+ 'running',
30
+ 'paused',
31
+ 'completed',
32
+ 'failed',
33
+ 'canceled',
34
+ ])
35
+
36
+ const TERMINAL = new Set(['completed', 'failed', 'canceled'])
37
+
38
+ /** @param {JobState} state */
39
+ export function isTerminal(state) {
40
+ return TERMINAL.has(state)
41
+ }
42
+
43
+ /** @param {number} seq */
44
+ export function formatJobId(seq) {
45
+ return `J#${seq}`
46
+ }
47
+
48
+ const JOB_ID_RE = /^J#(\d+)$/
49
+
50
+ /** @param {string} id @returns {number | undefined} */
51
+ export function parseJobId(id) {
52
+ const m = JOB_ID_RE.exec(id)
53
+ return m ? Number(m[1]) : undefined
54
+ }
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "dsh-dl-server",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "DLS — DSH Download Server: a global, session-aware, persistent download queue for DeepSeek Harness.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./cordis.patch.yml": "./cordis.patch.yml",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "cordis.patch.yml",
15
+ "LICENSE",
16
+ "README.md",
17
+ "README.en.md"
18
+ ],
19
+ "keywords": [
20
+ "deepseek-harness",
21
+ "dsh",
22
+ "dsh-plugin",
23
+ "download",
24
+ "downloader",
25
+ "queue",
26
+ "aria2",
27
+ "dls"
28
+ ],
29
+ "author": "Jestions",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/Jestions-dev/dsh-download-server.git"
34
+ },
35
+ "homepage": "https://github.com/Jestions-dev/dsh-download-server#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/Jestions-dev/dsh-download-server/issues"
38
+ },
39
+ "engines": {
40
+ "node": ">=20"
41
+ },
42
+ "scripts": {
43
+ "test": "node --test",
44
+ "check": "node --check lib/index.js && node --check lib/domain.js && node --check lib/types.js"
45
+ },
46
+ "dependencies": {
47
+ "@deepseek-ai/cordis": "~4.1.0",
48
+ "@deepseek-ai/dsh-storage": "^0.2.2-rc.1",
49
+ "@deepseek-ai/dsh-storage-domain": "^0.2.2-rc.1",
50
+ "zod": "^4.4.3"
51
+ },
52
+ "dsh": {
53
+ "bundle": {
54
+ "patch": "./cordis.patch.yml"
55
+ }
56
+ }
57
+ }