dsh-py-codeact 0.0.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/lib/kernel.js ADDED
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Host side of the persistent Python kernel: spawn, JSON-lines framing on fd 3, and the outbound tool-call bridge.
3
+ *
4
+ * Model code has full access to fd 3, so every inbound frame is shape-validated and REBUILT before anything reads it — a forged extra field never rides along, and junk drops instead of throwing in the message handler.
5
+ *
6
+ * @module dsh-py-codeact/kernel
7
+ */
8
+
9
+ import { execFile, spawn } from 'node:child_process'
10
+ import { promisify } from 'node:util'
11
+ import { dirname, join } from 'node:path'
12
+ import { mkdtemp, readdir, rm } from 'node:fs/promises'
13
+ import { tmpdir } from 'node:os'
14
+ import { fileURLToPath } from 'node:url'
15
+
16
+ const execFileAsync = promisify(execFile)
17
+
18
+ /** Wire default for the `shell` field. Python has its own copy of this literal — the seam is the one place the two languages must agree by hand. */
19
+ export const DEFAULT_SHELL = 'main'
20
+
21
+ export const KERNEL_PY = join(dirname(fileURLToPath(import.meta.url)), '..', 'py', 'kernel.py')
22
+
23
+ /** The shell a frame addresses. Empty string falls back too, matching the Python side's `or` rather than diverging from it. */
24
+ const shellOf = (raw) => (typeof raw.shell === 'string' && raw.shell !== '' ? raw.shell : DEFAULT_SHELL)
25
+
26
+ /** Rebuild one inbound frame, or `undefined` when it is not a shape we accept. */
27
+ function validateFrame(raw) {
28
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined
29
+ const t = raw.t
30
+ if (t === 'ready') return { t, shell: shellOf(raw), env: raw.env === null || typeof raw.env !== 'object' ? undefined : raw.env }
31
+ if (t === 'call') {
32
+ if (!Number.isSafeInteger(raw.id) || typeof raw.name !== 'string') return undefined
33
+ return { t, id: raw.id, shell: shellOf(raw), name: raw.name, args: raw.args }
34
+ }
35
+ if (t === 'done') {
36
+ if (!Number.isSafeInteger(raw.id) || typeof raw.ok !== 'boolean') return undefined
37
+ const text = (value) => (typeof value === 'string' ? value : undefined)
38
+ return {
39
+ t, id: raw.id, ok: raw.ok, shell: shellOf(raw),
40
+ stdout: text(raw.stdout) ?? '',
41
+ stderr: text(raw.stderr) ?? '',
42
+ repr: text(raw.repr),
43
+ error: text(raw.error),
44
+ note: text(raw.note),
45
+ }
46
+ }
47
+ return undefined
48
+ }
49
+
50
+ /**
51
+ * Environment the kernel gets when `inheritEnv` is off.
52
+ *
53
+ * NOT the empty env the worker-thread runtime uses: CPython running IPython needs a few of these to work at all (a profile dir under HOME, a PATH for shell escapes, a TMPDIR). The point is excluding ambient credentials.
54
+ *
55
+ * Deliberately an allowlist rather than dsh's shared `scrubbedParentEnv()`, which is a denylist over `/KEY|PASSWORD|SECRET|TOKEN/i` — `GH_PAT`, `*_AUTH` and a `DATABASE_URL` with an inline password all pass that filter. The README promises no ambient credentials reach model code, and only an allowlist keeps that promise.
56
+ */
57
+ const ENV_ALLOWLIST = [
58
+ 'PATH', 'HOME', 'TMPDIR', 'LANG', 'LC_ALL', 'TERM', 'SYSTEMROOT', 'APPDATA',
59
+ // A cell installs packages and fetches URLs. Without these, `!uv pip install` behind a corporate proxy fails with a network error that points nowhere near the missing variable. None of them is credential-shaped. Lowercase forms included deliberately: curl and requests read those, not the uppercase ones.
60
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy',
61
+ 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE',
62
+ ]
63
+
64
+ function baseEnv() {
65
+ return Object.fromEntries(
66
+ ENV_ALLOWLIST.map((key) => [key, process.env[key]]).filter(([, value]) => value !== undefined),
67
+ )
68
+ }
69
+
70
+ /**
71
+ * Delete throwaway venvs left by harness processes that are gone.
72
+ *
73
+ * Teardown is best-effort by nature: the `rm` on exit is asynchronous, and a SIGKILL'd harness never reaches it at all. Sweeping on the way IN makes the leak self-healing instead of unbounded — and the pid tag means a directory in use by a live harness is never touched.
74
+ */
75
+ async function sweepAbandonedEnvs() {
76
+ const alive = (pid) => {
77
+ try { process.kill(pid, 0); return true } catch (error) { return error.code === 'EPERM' }
78
+ }
79
+ let entries
80
+ try { entries = await readdir(tmpdir()) } catch { return }
81
+ await Promise.all(entries.map(async (entry) => {
82
+ const pid = Number(/^dsh-py-codeact-(\d+)-/.exec(entry)?.[1])
83
+ if (!Number.isInteger(pid) || alive(pid)) return
84
+ await rm(join(tmpdir(), entry), { recursive: true, force: true }).catch(() => {})
85
+ }))
86
+ }
87
+
88
+ export class KernelDeadError extends Error {
89
+ constructor(message) {
90
+ super(message)
91
+ this.name = 'KernelDeadError'
92
+ }
93
+ }
94
+
95
+ export class PythonKernel {
96
+ /**
97
+ * @param options.command - argv to spawn. By default the PEP 723 environment is resolved once with `uv python find --script` and that interpreter is spawned DIRECTLY. Pass `[<python>, <kernel.py>]` to use your own.
98
+ * @param options.cwd - working directory for the kernel process.
99
+ * @param options.env - full environment override; omit for the allowlist above.
100
+ * @param options.onCall - `(name, args) => Promise<{ok, value?, message?}>`, the host's tool dispatch. The kernel may have several outstanding under `asyncio.gather`.
101
+ * @param options.hardInterruptMs - grace period before SIGKILL when an aborted cell does not yield to the in-band interrupt.
102
+ */
103
+ constructor({ command, cwd, env, onCall, hardInterruptMs = 5000, ephemeralEnv = true } = {}) {
104
+ this.command = command
105
+ this.ephemeralEnv = ephemeralEnv
106
+ this.ephemeralRoot = undefined
107
+ this.dead = false
108
+ this.cwd = cwd
109
+ this.env = { ...(env ?? baseEnv()), PYTHONUNBUFFERED: '1' }
110
+ this.onCall = onCall
111
+ this.hardInterruptMs = hardInterruptMs
112
+ this.proc = undefined
113
+ this.buffer = ''
114
+ this.nextExecId = 0
115
+ this.pending = new Map() // execId -> {resolve, reject}
116
+ this.shells = new Set() // agents with a live shell in this process
117
+ this.ready = undefined
118
+ }
119
+
120
+ get alive() {
121
+ // Tracked from the exit event rather than read off `proc.killed`, which Node sets on any `kill()` call — including a signal the process survived.
122
+ return this.proc !== undefined && !this.dead
123
+ }
124
+
125
+ /**
126
+ * The interpreter to spawn, plus anything that has to be torn down with it.
127
+ *
128
+ * NOT `uv run --script`: that stays in the process tree as a PARENT of the real interpreter. When it exits, the interpreter is reparented to init, the handle we hold reports an exit, and a perfectly live kernel looks dead — so the next cell respawns and the session's state is lost for no reason. `uv python find --script` materializes the same PEP 723 environment and hands back the interpreter, which we then own directly.
129
+ *
130
+ * That environment is SHARED, though: uv keys it by the script's dependency list, so every session and every future run resolves the same directory. A cell doing `!uv pip install` would leak into all of them — and pointing `config.python` at a project venv is worse, since the install lands in the user's own project. So each kernel gets a throwaway venv of its own that INHERITS the base environment's packages: imports still resolve, installs stay local, and the directory dies with the kernel. Same shape as `ipython-mcp.py`'s ephemeral-venv fork.
131
+ */
132
+ async #resolveCommand() {
133
+ // `uv python find --script` only LOOKS the environment up — on a machine where it has never been built it happily returns the bare interpreter, which has no IPython, and the kernel dies on its first import. `sync` builds it (a no-op once it exists), and only then does `find` name the environment rather than the interpreter uv would have used to make it. Every developer who already ran the kernel once has this cached, which is exactly why it never showed up here.
134
+ if (this.command === undefined) await execFileAsync('uv', ['sync', '--script', KERNEL_PY], { env: this.env })
135
+ const base = this.command ?? [(await execFileAsync('uv', ['python', 'find', '--script', KERNEL_PY], { env: this.env })).stdout.trim(), KERNEL_PY]
136
+ if (this.ephemeralEnv === false || this.command !== undefined) return base // an explicit argv is the user's business
137
+
138
+ const [python] = base
139
+ // Independent: the sweep only needs tmpdir, the site-packages read only needs the interpreter. Overlapping them hides a directory walk behind a subprocess spawn on every cold start.
140
+ const [{ stdout: sitePaths }] = await Promise.all([
141
+ execFileAsync(python, ['-c', 'import site, os; print(os.pathsep.join(site.getsitepackages()))'], { env: this.env }),
142
+ sweepAbandonedEnvs(),
143
+ ])
144
+ // Tagged with our pid so the sweep above can tell an abandoned directory from one a live harness is still using.
145
+ const root = await mkdtemp(join(tmpdir(), `dsh-py-codeact-${process.pid}-`))
146
+ // symlink so this costs milliseconds rather than a copy of every package
147
+ await execFileAsync('uv', ['venv', '--python', python, '--link-mode', 'symlink', root], { env: this.env })
148
+ this.ephemeralRoot = root
149
+ this.env = {
150
+ ...this.env,
151
+ VIRTUAL_ENV: root,
152
+ // Read by kernel.py BEFORE it imports IPython. Appended, so the throwaway venv's own site-packages keep precedence and an install shadows the inherited copy rather than being shadowed by it.
153
+ DSH_CODEACT_INHERIT_SITE: sitePaths.trim(),
154
+ }
155
+ return [join(root, process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python'), KERNEL_PY]
156
+ }
157
+
158
+ /**
159
+ * Spawn the interpreter and open one shell. Idempotent per shell.
160
+ *
161
+ * The first call brings the process up; later shells (a subagent's) reuse it, so a fan-out pays one `init` frame rather than another interpreter.
162
+ */
163
+ async start(toolSpecs, shell = DEFAULT_SHELL) {
164
+ if (this.ready === undefined) {
165
+ this.ready = this.#spawn(toolSpecs, shell)
166
+ this.shells.add(shell)
167
+ return this.ready
168
+ }
169
+ await this.ready
170
+ if (!this.shells.has(shell)) {
171
+ this.shells.add(shell)
172
+ this.#send({ t: 'init', shell, tools: toolSpecs })
173
+ }
174
+ return this
175
+ }
176
+
177
+ /** Drop one agent's shell so its globals can be collected. The process stays. */
178
+ closeShell(shell) {
179
+ if (!this.shells.delete(shell) || !this.alive) return
180
+ try { this.#send({ t: 'dispose', shell }) } catch { /* going away anyway */ }
181
+ }
182
+
183
+ async #spawn(toolSpecs, shell) {
184
+ const [bin, ...argv] = await this.#resolveCommand()
185
+ return new Promise((resolve, reject) => {
186
+ const proc = spawn(bin, argv, {
187
+ cwd: this.cwd,
188
+ env: this.env,
189
+ stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
190
+ })
191
+ this.proc = proc
192
+ // The kernel captures Python-level stdout/stderr per cell, so anything arriving on fd 1/2 came from a native write (a subprocess the model spawned) or from uv provisioning. Not attributable to a cell — retained only for the crash message.
193
+ this.nativeOutput = ''
194
+ const drain = (chunk) => { this.nativeOutput = (this.nativeOutput + chunk).slice(-8192) }
195
+ proc.stdout.setEncoding('utf8').on('data', drain)
196
+ proc.stderr.setEncoding('utf8').on('data', drain)
197
+
198
+ proc.stdio[3].setEncoding('utf8').on('data', (chunk) => this.#receive(chunk))
199
+
200
+ // Every way the kernel can go away funnels through here. `reject` on an already-settled promise is a no-op, so late deaths cost nothing.
201
+ const die = (why) => {
202
+ this.dead = true
203
+ if (this.ephemeralRoot !== undefined) {
204
+ rm(this.ephemeralRoot, { recursive: true, force: true }).catch(() => {})
205
+ this.ephemeralRoot = undefined
206
+ }
207
+ // Not every death is an exit. Model code can `os.close(3)`, and then the pipe errors here while the interpreter is still running the cell — but `dead` is now true, so `dispose()`'s `if (!this.alive) return` skips the teardown and the process outlives the harness as an orphan holding ~60-100MB. Signalling here is idempotent: after a real exit the pid is gone and `kill` is a no-op we swallow.
208
+ if (proc.exitCode === null && proc.signalCode === null) {
209
+ try { proc.kill('SIGKILL') } catch { /* already reaped */ }
210
+ }
211
+ const error = new KernelDeadError(why)
212
+ // Cleared BEFORE rejecting: `finish` releases the event-loop hold only when it sees an empty map, and rejecting first left every one of them looking at a non-empty one — so the handles stayed reffed after the kernel was already gone.
213
+ const waiting = [...this.pending.values()]
214
+ this.pending.clear()
215
+ this.#hold(false)
216
+ for (const { reject: rejectPending } of waiting) rejectPending(error)
217
+ reject(error)
218
+ }
219
+ // 'error' fires INSTEAD of 'exit' when the spawn itself fails (ENOENT). Without marking it dead here, `alive` stays true forever and every later cell is handed the corpse instead of respawning.
220
+ proc.once('error', (error) => die(`python kernel failed to start: ${error.message}`))
221
+ proc.once('exit', (code, signal) =>
222
+ die(`python kernel exited (code ${code}, signal ${signal})${this.nativeOutput ? `\n${this.nativeOutput}` : ''}`))
223
+ // A broken pipe surfaces on the stream, not on the process — and model code can `os.close(3)` at any time. Unhandled, it takes down the whole harness process, not just this kernel.
224
+ for (const stream of [proc.stdout, proc.stderr, proc.stdio[3]]) {
225
+ stream.on('error', (error) => die(`python kernel pipe failed: ${error.message}`))
226
+ }
227
+
228
+ this.onReady = resolve
229
+ this.#hold(true) // the init handshake must not be cut short by an idle exit
230
+ this.#send({ t: 'init', shell, tools: toolSpecs })
231
+ })
232
+ }
233
+
234
+ #send(frame) {
235
+ if (!this.alive) throw new KernelDeadError('python kernel is not running')
236
+ this.proc.stdio[3].write(`${JSON.stringify(frame)}\n`)
237
+ }
238
+
239
+ #receive(chunk) {
240
+ this.buffer += chunk
241
+ let index
242
+ while ((index = this.buffer.indexOf('\n')) >= 0) {
243
+ const line = this.buffer.slice(0, index)
244
+ this.buffer = this.buffer.slice(index + 1)
245
+ if (line.length === 0) continue
246
+ let parsed
247
+ try { parsed = JSON.parse(line) } catch { continue }
248
+ const frame = validateFrame(parsed)
249
+ if (frame === undefined) continue
250
+ this.#handle(frame)
251
+ }
252
+ }
253
+
254
+ #handle(frame) {
255
+ if (frame.t === 'ready') {
256
+ if (this.pending.size === 0) this.#hold(false)
257
+ // Which interpreter, which version, where `uv pip install` lands. Reported by the kernel rather than guessed here: the host only knows the argv it spawned, and with the default PEP 723 route it does not even know that until `uv` has resolved it.
258
+ // The kernel can only report `sys.prefix != sys.base_prefix`, which is equally true of the user's own project venv. Whether the installs are actually disposable is something only this side knows, and the prompt states it as fact.
259
+ this.pythonEnv = { ...frame.env, disposable: this.ephemeralRoot !== undefined }
260
+ this.onReady?.(this)
261
+ return
262
+ }
263
+ if (frame.t === 'done') {
264
+ const entry = this.pending.get(frame.id)
265
+ if (entry === undefined) return // forged or late — the host answers once
266
+ this.pending.delete(frame.id)
267
+ entry.resolve(frame)
268
+ return
269
+ }
270
+ // frame.t === 'call' — dispatch into the harness, answer exactly once.
271
+ this.onCall(frame.name, frame.args, frame.shell).then(
272
+ (outcome) => this.#reply(frame, outcome),
273
+ (error) => this.#reply(frame, { ok: false, message: error?.message ?? String(error) }),
274
+ )
275
+ }
276
+
277
+ #reply(frame, outcome) {
278
+ if (!this.alive) return
279
+ try {
280
+ this.#send(outcome.ok
281
+ ? { t: 'result', id: frame.id, ok: true, value: outcome.value ?? null }
282
+ : { t: 'result', id: frame.id, ok: false, tool: frame.name, message: outcome.message ?? 'tool call failed' })
283
+ } catch (error) {
284
+ // This is the ONLY `#send` reached from an unguarded promise chain, and the one that serialises a tool's canonical value — model-reachable data, so a circular graph or a BigInt is enough to make `JSON.stringify` throw. Unhandled it took down the whole harness process; the cell's `await` also never settled. Answering with the failure keeps both alive.
285
+ try {
286
+ this.#send({ t: 'result', id: frame.id, ok: false, tool: frame.name, message: `tool result could not be serialised: ${error?.message ?? error}` })
287
+ } catch { /* the pipe itself is gone; `die` has already rejected everything */ }
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Hold / release the event loop. An idle kernel must NOT keep the harness alive (it would finish its turn and hang, and outlive it as an orphan), but a cell in flight must, or the process could exit mid-execution. So the handles are unreffed at spawn and reffed only for the duration of a cell.
293
+ */
294
+ #hold(active) {
295
+ if (this.proc === undefined) return
296
+ for (const handle of [this.proc, this.proc.stdout, this.proc.stderr, this.proc.stdio[3]]) {
297
+ if (active) handle?.ref?.()
298
+ else handle?.unref?.()
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Run one cell. Rejects only when the kernel dies; a program exception comes back as `{ok: false, error}` so the model can self-correct from it.
304
+ */
305
+ async exec(code, signal, toolSpecs, shell = DEFAULT_SHELL) {
306
+ await this.ready
307
+ // `addEventListener('abort', …)` NEVER fires on a signal that is already aborted. Without this check, a turn cancelled while the kernel was still starting — a cold `uv` resolve takes seconds — would run its cell all the way through, side effects and all, and report success.
308
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted before the cell was dispatched')
309
+ const id = ++this.nextExecId
310
+ return new Promise((resolve, reject) => {
311
+ let escalation
312
+ const finish = (ok, arg) => {
313
+ clearTimeout(escalation)
314
+ signal?.removeEventListener('abort', onAbort)
315
+ if (this.pending.size === 0) this.#hold(false)
316
+ if (ok) resolve(arg)
317
+ else reject(arg)
318
+ }
319
+ const onAbort = () => {
320
+ this.interrupt(shell)
321
+ // A cell parked on `await` cancels in-band. A pure CPU loop never reaches the signal handler, so kill the interpreter as the backstop; the caller respawns and the model is told state was lost.
322
+ escalation = setTimeout(() => this.proc?.kill('SIGKILL'), this.hardInterruptMs)
323
+ }
324
+ signal?.addEventListener('abort', onAbort, { once: true })
325
+ this.pending.set(id, { resolve: (value) => finish(true, value), reject: (error) => finish(false, error) })
326
+ this.#hold(true)
327
+ try {
328
+ this.#send({ t: 'exec', id, shell, code, tools: toolSpecs })
329
+ } catch (error) {
330
+ this.pending.delete(id)
331
+ finish(false, error)
332
+ }
333
+ })
334
+ }
335
+
336
+ /** Cancel the running cell in-band; the kernel stays alive and keeps its state. */
337
+ interrupt(shell = DEFAULT_SHELL) {
338
+ if (!this.alive) return
339
+ try { this.#send({ t: 'interrupt', shell }) } catch { /* dying anyway */ }
340
+ }
341
+
342
+ dispose() {
343
+ if (!this.alive) return
344
+ try { this.#send({ t: 'shutdown' }) } catch { /* dying anyway */ }
345
+ // Mark it dead NOW, not when 'exit' eventually lands: `#send` only checks `alive`, so until then another exec or interrupt could be dispatched into a kernel that is mid-teardown, racing the SIGKILL below.
346
+ this.dead = true
347
+ const proc = this.proc
348
+ const timer = setTimeout(() => proc.kill('SIGKILL'), 2000)
349
+ proc.once('exit', () => clearTimeout(timer))
350
+ }
351
+ }
package/package.json CHANGED
@@ -1,11 +1,76 @@
1
1
  {
2
2
  "name": "dsh-py-codeact",
3
- "version": "0.0.0",
4
- "description": "Placeholder for dsh-py-codeact",
5
- "main": "index.js",
6
- "scripts": {},
7
- "keywords": [],
8
- "author": "muspi-merol (https://www.npmjs.com/~muspi-merol)",
9
- "license": "UNLICENSED",
10
- "private": false
11
- }
3
+ "version": "0.1.1",
4
+ "description": "CodeAct agent loop for the DeepSeek Harness: a persistent IPython session as the model's action space, with harness tools bridged in as a virtual `__dsh__.tools` module.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/CNSeniorious000/dsh-py-codeact.git"
9
+ },
10
+ "keywords": [
11
+ "dsh",
12
+ "deepseek-harness",
13
+ "codeact",
14
+ "ipython",
15
+ "repl",
16
+ "agent",
17
+ "cordis-plugin"
18
+ ],
19
+ "type": "module",
20
+ "main": "lib/index.js",
21
+ "exports": {
22
+ ".": "./lib/index.js",
23
+ "./client": "./lib/client.js",
24
+ "./package.json": "./package.json"
25
+ },
26
+ "files": [
27
+ "lib",
28
+ "py",
29
+ "example",
30
+ "cordis.patch.yml",
31
+ "!**/__pycache__"
32
+ ],
33
+ "scripts": {
34
+ "test": "node test/smoke.js"
35
+ },
36
+ "dsh": {
37
+ "bundle": {
38
+ "patch": "./cordis.patch.yml"
39
+ },
40
+ "client": {
41
+ "platform": "web",
42
+ "inject": [
43
+ "@deepseek-ai/dsh-client-ui-tool"
44
+ ]
45
+ }
46
+ },
47
+ "peerDependencies": {
48
+ "@deepseek-ai/cordis": ">=4.0.1",
49
+ "@deepseek-ai/dsh-llm": ">=0.1.1-rc.2 || >=0.1.2-0",
50
+ "@deepseek-ai/dsh-tools": ">=0.1.1-rc.2 || >=0.1.2-0",
51
+ "@deepseek-ai/dsh-session": ">=0.1.1-rc.2 || >=0.1.2-0",
52
+ "@deepseek-ai/dsh-system-prompt": ">=0.1.1-rc.2 || >=0.1.2-0",
53
+ "@deepseek-ai/dsh-client-ui-primitives": ">=0.1.1-rc.2 || >=0.1.2-0",
54
+ "@deepseek-ai/dsh-client-ui-slots": ">=0.1.1-rc.2 || >=0.1.2-0",
55
+ "@deepseek-ai/dsh-client-ui-tool": ">=0.1.1-rc.2 || >=0.1.2-0",
56
+ "react": ">=18"
57
+ },
58
+ "peerDependenciesMeta": {
59
+ "@deepseek-ai/dsh-client-ui-primitives": {
60
+ "optional": true
61
+ },
62
+ "@deepseek-ai/dsh-client-ui-slots": {
63
+ "optional": true
64
+ },
65
+ "@deepseek-ai/dsh-client-ui-tool": {
66
+ "optional": true
67
+ },
68
+ "react": {
69
+ "optional": true
70
+ }
71
+ },
72
+ "devDependencies": {
73
+ "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
74
+ "@deepseek-ai/dsh-tools": "^0.1.1-rc.2"
75
+ }
76
+ }