feat: add economy/maximum presets, tool-lab and subagent-cursor extensions
Some checks failed
CI / windows node 24 / wine blocking (push) Has been skipped
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / wine apt cache (push) Successful in 7s
CI / serial / linux (push) Has been skipped
Deploy documentation / build (push) Failing after 1m25s
Deploy documentation / deploy (push) Has been skipped
Landlock Run / Matrix (push) Successful in 5s
Release (vendor) / Pack npm tarballs (push) Failing after 2m47s
Release (dsh) / Pack npm tarballs (push) Failing after 1m56s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m57s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m19s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
E2E (real DeepSeek API) / e2e (push) Failing after 1m24s
Some checks failed
CI / windows node 24 / wine blocking (push) Has been skipped
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / wine apt cache (push) Successful in 7s
CI / serial / linux (push) Has been skipped
Deploy documentation / build (push) Failing after 1m25s
Deploy documentation / deploy (push) Has been skipped
Landlock Run / Matrix (push) Successful in 5s
Release (vendor) / Pack npm tarballs (push) Failing after 2m47s
Release (dsh) / Pack npm tarballs (push) Failing after 1m56s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m57s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m19s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
E2E (real DeepSeek API) / e2e (push) Failing after 1m24s
- new economy and maximum agent presets with three-role pipeline skill - new packages/extensions/tool-lab (home-lab ComfyUI/Docling/Whishper tools) - new packages/subagent/subagent-cursor provider - openrouter balance UI with on-demand refresh - session projection context-seed boundary fold - regenerate docs catalogs; keep local searxng benchmark scripts
This commit is contained in:
51
packages/extensions/tool-lab/package.json
Normal file
51
packages/extensions/tool-lab/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-lab",
|
||||
"description": "Model-facing home-lab AI tools (lab_generate_image via ComfyUI, lab_ocr_pdf via Docling, lab_transcribe_audio via Whishper) over the lab's LAN services",
|
||||
"version": "0.1.0-rc.7",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/extensions/tool-lab"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
129
packages/extensions/tool-lab/src/comfy.ts
Normal file
129
packages/extensions/tool-lab/src/comfy.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* ComfyUI image generation tool. Talks to the lab's ComfyUI server (default
|
||||
* http://192.168.31.240:8188) with a Juggernaut-XL workflow and returns the
|
||||
* served image URL. @module @deepseek-ai/dsh-tool-lab
|
||||
*/
|
||||
|
||||
import { Deadline, sleep } from './helpers.ts'
|
||||
|
||||
/** Root of the resolved lab config. */
|
||||
export interface ResolvedLabConfig {
|
||||
comfyBaseUrl: string
|
||||
doclingBaseUrl: string
|
||||
whishBaseUrl: string
|
||||
timeoutMs: number
|
||||
maxUploadBytes: number
|
||||
maxOutputChars: number
|
||||
}
|
||||
|
||||
/** Model-backed fields ComfyUI exposes on the workflow we build. */
|
||||
interface ComfyApiResult {
|
||||
prompt_id?: string
|
||||
node_errors?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ComfyHistoryEntry {
|
||||
outputs?: Record<string, { images?: { filename?: string; subfolder?: string; type?: string }[] }>
|
||||
status?: { completed?: boolean; error?: unknown }
|
||||
}
|
||||
|
||||
/** A single ComfyUI generation request. */
|
||||
export interface GenerateImageArgs {
|
||||
prompt: string
|
||||
negative?: string
|
||||
width?: number
|
||||
height?: number
|
||||
steps?: number
|
||||
seed?: number
|
||||
model?: string
|
||||
upscale?: boolean
|
||||
upscale_model?: string
|
||||
filename_prefix?: string
|
||||
}
|
||||
|
||||
/** Compose the fixed 28-step Juggernaut-XL workflow from user-facing arguments. */
|
||||
export function buildWorkflow(args: GenerateImageArgs): Record<string, unknown> {
|
||||
const seed = args.seed ?? Math.floor(Math.random() * 1_000_000)
|
||||
const width = args.width ?? 512
|
||||
const height = args.height ?? 512
|
||||
const steps = args.steps ?? 28
|
||||
const model = args.model ?? 'Juggernaut-XL_v9_RunDiffusionPhoto_v2.safetensors'
|
||||
const negative = args.negative ?? 'blurry, low quality, distorted, watermark, text, extra limbs'
|
||||
const upscale = args.upscale !== false
|
||||
const upscaleModel = args.upscale_model ?? 'RealESRGAN_x4plus.safetensors'
|
||||
const prefix = args.filename_prefix ?? 'dsh'
|
||||
const workflow: Record<string, unknown> = {
|
||||
3: {
|
||||
class_type: 'KSampler',
|
||||
inputs: {
|
||||
seed, steps, cfg: 4.0, sampler_name: 'dpmpp_2m_sde', scheduler: 'karras', denoise: 1.0,
|
||||
model: ['4', 0], positive: ['6', 0], negative: ['7', 0], latent_image: ['5', 0],
|
||||
},
|
||||
},
|
||||
4: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: model } },
|
||||
5: { class_type: 'EmptyLatentImage', inputs: { width, height, batch_size: 1 } },
|
||||
6: { class_type: 'CLIPTextEncode', inputs: { text: args.prompt, clip: ['4', 1] } },
|
||||
7: { class_type: 'CLIPTextEncode', inputs: { text: negative, clip: ['4', 1] } },
|
||||
8: { class_type: 'VAEDecode', inputs: { samples: ['3', 0], vae: ['4', 2] } },
|
||||
9: { class_type: 'SaveImage', inputs: { filename_prefix: prefix, images: upscale ? ['10', 0] : ['8', 0] } },
|
||||
}
|
||||
if (upscale) {
|
||||
workflow['10'] = { class_type: 'ImageUpscaleWithModel', inputs: { upscale_model: ['11', 0], image: ['8', 0] } }
|
||||
workflow['11'] = { class_type: 'UpscaleModelLoader', inputs: { model_name: upscaleModel } }
|
||||
}
|
||||
return workflow
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll ComfyUI `/history/{prompt_id}` until the image appears or the timeout
|
||||
* budget elapses. Returns the served image URL.
|
||||
*/
|
||||
async function pollImage(
|
||||
baseUrl: string,
|
||||
promptId: string,
|
||||
deadline: Deadline,
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
const started = Date.now()
|
||||
for (;;) {
|
||||
deadline.check()
|
||||
const res = await fetch(`${baseUrl}/history/${promptId}`)
|
||||
if (!res.ok) throw new Error(`ComfyUI history ${res.status}: ${await res.text()}`)
|
||||
const history = (await res.json()) as Record<string, ComfyHistoryEntry>
|
||||
const entry = history[promptId]
|
||||
if (entry?.outputs?.['9']?.images?.length) {
|
||||
const img = entry.outputs['9'].images[0]
|
||||
if (!img) throw new Error('ComfyUI history entry missing image 0')
|
||||
return `${baseUrl}/view?filename=${encodeURIComponent(img.filename ?? '')}&subfolder=${encodeURIComponent(img.subfolder ?? '')}&type=${encodeURIComponent(img.type ?? 'output')}`
|
||||
}
|
||||
if (entry?.status?.error) throw new Error(`ComfyUI generation failed: ${JSON.stringify(entry.status.error)}`)
|
||||
if (Date.now() - started >= timeoutMs) throw new Error(`ComfyUI generation timed out after ${timeoutMs}ms`)
|
||||
await sleep(1500, deadline.signal)
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the ComfyUI tool. Reads no files; returns a served image URL string. */
|
||||
export async function runGenerateImage(
|
||||
config: ResolvedLabConfig,
|
||||
args: GenerateImageArgs,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<string> {
|
||||
const deadline = new Deadline('generate_image', config.timeoutMs, signal)
|
||||
try {
|
||||
const clientId = `dsh-${Math.random().toString(36).slice(2)}`
|
||||
const workflow = buildWorkflow(args)
|
||||
const submit = await fetch(`${config.comfyBaseUrl}/prompt`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: workflow, client_id: clientId }),
|
||||
})
|
||||
if (!submit.ok) throw new Error(`ComfyUI /prompt ${submit.status}: ${await submit.text()}`)
|
||||
const result = (await submit.json()) as ComfyApiResult
|
||||
if (!result.prompt_id) throw new Error(`ComfyUI /prompt no prompt_id: ${JSON.stringify(result)}`)
|
||||
const errors = result.node_errors
|
||||
if (errors && Object.keys(errors).length > 0) throw new Error(`ComfyUI node_errors: ${JSON.stringify(errors)}`)
|
||||
return await pollImage(config.comfyBaseUrl, result.prompt_id, deadline, config.timeoutMs)
|
||||
} finally {
|
||||
deadline.dispose()
|
||||
}
|
||||
}
|
||||
85
packages/extensions/tool-lab/src/docling.ts
Normal file
85
packages/extensions/tool-lab/src/docling.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Docling OCR tool. Reads a local PDF via `ctx.fs`, uploads it to the lab's
|
||||
* Docling server, polls the async task, and returns the extracted markdown.
|
||||
* @module @deepseek-ai/dsh-tool-lab
|
||||
*/
|
||||
|
||||
import type { FileSystem } from '@deepseek-ai/dsh-fs'
|
||||
import { Deadline, sleep } from './helpers.ts'
|
||||
import type { ResolvedLabConfig } from './comfy.ts'
|
||||
|
||||
interface DoclingPoll {
|
||||
task_id?: string
|
||||
task_status?: string
|
||||
}
|
||||
|
||||
interface DoclingResult {
|
||||
document?: { filename?: string; md_content?: string | null }
|
||||
status?: string
|
||||
errors?: unknown[]
|
||||
}
|
||||
|
||||
/** Build FormData upload of a PDF from bytes. */
|
||||
export function buildPdfUpload(data: Uint8Array, filename: string): FormData {
|
||||
const fd = new FormData()
|
||||
fd.append('files', new Blob([data as unknown as BlobPart], { type: 'application/pdf' }), filename)
|
||||
fd.append('options', JSON.stringify({}))
|
||||
return fd
|
||||
}
|
||||
|
||||
/** Poll Docling `/v1/status/poll/{task_id}` until success. */
|
||||
async function pollTask(
|
||||
baseUrl: string,
|
||||
taskId: string,
|
||||
deadline: Deadline,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
const started = Date.now()
|
||||
for (;;) {
|
||||
deadline.check()
|
||||
const res = await fetch(`${baseUrl}/v1/status/poll/${taskId}?wait=2`)
|
||||
if (!res.ok) throw new Error(`Docling poll ${res.status}: ${await res.text()}`)
|
||||
const poll = (await res.json()) as DoclingPoll
|
||||
if (poll.task_status === 'success') return
|
||||
if (poll.task_status === 'failed' || poll.task_status === 'error') {
|
||||
throw new Error(`Docling task failed: ${await (await fetch(`${baseUrl}/v1/result/${taskId}`)).text()}`)
|
||||
}
|
||||
if (Date.now() - started >= timeoutMs) throw new Error(`Docling OCR timed out after ${timeoutMs}ms`)
|
||||
await sleep(1200, deadline.signal)
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the Docling OCR tool: read file, upload, poll, return markdown text. */
|
||||
export async function runOcrPdf(
|
||||
fs: FileSystem,
|
||||
config: ResolvedLabConfig,
|
||||
filePath: string,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<string> {
|
||||
const deadline = new Deadline('ocr_pdf', config.timeoutMs, signal)
|
||||
try {
|
||||
const target = await fs.resolve(filePath, signal === undefined ? undefined : { signal })
|
||||
const stat = await fs.stat(target, signal)
|
||||
if (!stat) throw new Error(`ocr_pdf: file not found: ${filePath}`)
|
||||
const data = await fs.readBytes(target, signal, config.maxUploadBytes)
|
||||
const filename = filePath.split(/[\\/]/).pop() ?? 'document.pdf'
|
||||
const form = buildPdfUpload(data, filename)
|
||||
const submit = await fetch(`${config.doclingBaseUrl}/v1/convert/file/async`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
if (!submit.ok) throw new Error(`Docling /convert ${submit.status}: ${await submit.text()}`)
|
||||
const poll = (await submit.json()) as DoclingPoll
|
||||
if (!poll.task_id) throw new Error(`Docling no task_id: ${JSON.stringify(poll)}`)
|
||||
await pollTask(config.doclingBaseUrl, poll.task_id, deadline, config.timeoutMs)
|
||||
const res = await fetch(`${config.doclingBaseUrl}/v1/result/${poll.task_id}`)
|
||||
if (!res.ok) throw new Error(`Docling result ${res.status}: ${await res.text()}`)
|
||||
const result = (await res.json()) as DoclingResult
|
||||
const md = result.document?.md_content ?? ''
|
||||
if (md.length === 0) throw new Error('Docling returned empty markdown content')
|
||||
if (md.length > config.maxOutputChars) return md.slice(0, config.maxOutputChars)
|
||||
return md
|
||||
} finally {
|
||||
deadline.dispose()
|
||||
}
|
||||
}
|
||||
62
packages/extensions/tool-lab/src/helpers.ts
Normal file
62
packages/extensions/tool-lab/src/helpers.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Shared helpers for the home-lab tool package: cooperative deadline enforcement
|
||||
* and aborted sleep. @module @deepseek-ai/dsh-tool-lab
|
||||
*/
|
||||
|
||||
/** Error thrown when a lab tool exceeds its cooperative timeout budget. */
|
||||
export class LabToolTimeoutError extends Error {
|
||||
constructor(tool: string, ms: number) {
|
||||
super(`lab tool ${tool} timed out after ${ms}ms`)
|
||||
this.name = 'LabToolTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
/** A deadline that aborts when the caller cancels or the budget expires. */
|
||||
export class Deadline {
|
||||
readonly signal: AbortSignal | undefined
|
||||
private readonly timer: ReturnType<typeof setTimeout> | undefined
|
||||
private cancelled = false
|
||||
private elapsed = false
|
||||
|
||||
constructor(
|
||||
private readonly tool: string,
|
||||
private readonly timeoutMs: number,
|
||||
signal: AbortSignal | undefined,
|
||||
) {
|
||||
this.signal = signal
|
||||
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
||||
this.timer = setTimeout(() => this.expire(), timeoutMs)
|
||||
}
|
||||
signal?.addEventListener('abort', () => this.expire(), { once: true })
|
||||
}
|
||||
|
||||
private expire(): void {
|
||||
if (this.cancelled || this.elapsed) return
|
||||
this.elapsed = true
|
||||
}
|
||||
|
||||
/** Throw if the budget has elapsed or the caller aborted. Cheap in loops. */
|
||||
check(): void {
|
||||
if (this.signal?.aborted || this.elapsed) {
|
||||
throw new LabToolTimeoutError(this.tool, this.timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel the timer when the tool finishes normally. */
|
||||
dispose(): void {
|
||||
this.cancelled = true
|
||||
if (this.timer !== undefined) clearTimeout(this.timer)
|
||||
}
|
||||
}
|
||||
|
||||
/** Abortable sleep; resolves early or throws on cancellation/expiry. */
|
||||
export async function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
||||
if (signal?.aborted) throw new Error('aborted')
|
||||
await new Promise<void>((resolve) => {
|
||||
const t = setTimeout(resolve, ms)
|
||||
signal?.addEventListener('abort', () => {
|
||||
clearTimeout(t)
|
||||
resolve()
|
||||
}, { once: true })
|
||||
})
|
||||
}
|
||||
189
packages/extensions/tool-lab/src/index.ts
Normal file
189
packages/extensions/tool-lab/src/index.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Model-facing home-lab AI tools. Registers `lab_generate_image` (ComfyUI),
|
||||
* `lab_ocr_pdf` (Docling), and `lab_transcribe_audio` (Whishper) over the
|
||||
* lab's LAN services. Tools read local files only via `ctx.fs`; uploads and
|
||||
* polls happen over HTTP. Image results stay server-hosted as URLs.
|
||||
* @module @deepseek-ai/dsh-tool-lab
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { runGenerateImage, type ResolvedLabConfig } from './comfy.ts'
|
||||
import { runOcrPdf } from './docling.ts'
|
||||
import { runTranscribeAudio } from './whish.ts'
|
||||
|
||||
export { runGenerateImage, buildWorkflow, type GenerateImageArgs } from './comfy.ts'
|
||||
export type { ResolvedLabConfig } from './comfy.ts'
|
||||
export { runOcrPdf, buildPdfUpload } from './docling.ts'
|
||||
export { runTranscribeAudio, buildAudioUpload } from './whish.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'tool-lab'
|
||||
|
||||
/** Services required by the lab tool suite. */
|
||||
export const inject = ['tools', 'fs']
|
||||
|
||||
/** Default cooperative tool-call timeout budget (ms). */
|
||||
export const DEFAULT_LAB_TIMEOUT_MS = 120_000
|
||||
|
||||
/** Default cap on one upload in bytes and one output in characters. */
|
||||
export const DEFAULT_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
||||
export const DEFAULT_MAX_OUTPUT_CHARS = 200_000
|
||||
|
||||
/** Plugin config: which lab tools to register, the base URLs, and limits. */
|
||||
export interface Config {
|
||||
/** Register `lab_generate_image` (ComfyUI). Defaults to true. */
|
||||
generateImage?: boolean
|
||||
/** Register `lab_ocr_pdf` (Docling). Defaults to true. */
|
||||
ocrPdf?: boolean
|
||||
/** Register `lab_transcribe_audio` (Whishper). Defaults to true. */
|
||||
transcribeAudio?: boolean
|
||||
/** ComfyUI base URL. Defaults to http://192.168.31.240:8188 */
|
||||
comfyBaseUrl?: string
|
||||
/** Docling base URL. Defaults to http://192.168.31.159:5001 */
|
||||
doclingBaseUrl?: string
|
||||
/** Whishper base URL. Defaults to http://192.168.31.159:8082 */
|
||||
whishBaseUrl?: string
|
||||
/** Cooperative timeout budget (ms). Defaults to 120000. */
|
||||
timeoutMs?: number
|
||||
/** Cap on upload bytes. Defaults to 20971520. */
|
||||
maxUploadBytes?: number
|
||||
/** Cap on output characters. Defaults to 200000. */
|
||||
maxOutputChars?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
generateImage: z.boolean().default(true),
|
||||
ocrPdf: z.boolean().default(true),
|
||||
transcribeAudio: z.boolean().default(true),
|
||||
comfyBaseUrl: z.string().default('http://192.168.31.240:8188'),
|
||||
doclingBaseUrl: z.string().default('http://192.168.31.159:5001'),
|
||||
whishBaseUrl: z.string().default('http://192.168.31.159:8082'),
|
||||
timeoutMs: z.number().default(DEFAULT_LAB_TIMEOUT_MS),
|
||||
maxUploadBytes: z.number().default(DEFAULT_MAX_UPLOAD_BYTES),
|
||||
maxOutputChars: z.number().default(DEFAULT_MAX_OUTPUT_CHARS),
|
||||
})
|
||||
|
||||
/** Complete config after schemastery applies every field default. */
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/** Configured limits must be positive integers. */
|
||||
function assertPositiveInteger(field: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`tool-lab: ${field} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Render one string value as a single text content block. */
|
||||
function renderText(_args: unknown, value: string): ContentBlock[] {
|
||||
return [{ type: 'text', text: String(value) }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the enabled lab tools. Each tool's cooperative timeout budget is
|
||||
* resolved here and attached as `ToolDefinition.timeoutMs`. Disposers are
|
||||
* fiber-scoped (effect-based registries clean up on dispose), so no manual
|
||||
* teardown is needed.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
|
||||
assertPositiveInteger('maxUploadBytes', resolved.maxUploadBytes)
|
||||
assertPositiveInteger('maxOutputChars', resolved.maxOutputChars)
|
||||
|
||||
const labConfig: ResolvedLabConfig = {
|
||||
comfyBaseUrl: resolved.comfyBaseUrl,
|
||||
doclingBaseUrl: resolved.doclingBaseUrl,
|
||||
whishBaseUrl: resolved.whishBaseUrl,
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
maxUploadBytes: resolved.maxUploadBytes,
|
||||
maxOutputChars: resolved.maxOutputChars,
|
||||
}
|
||||
|
||||
if (resolved.generateImage) {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'lab_generate_image',
|
||||
description:
|
||||
'Generate an image on the home-lab ComfyUI server (Juggernaut-XL V9, 512x512 base, upscaled 4x via RealESRGAN by default). Returns a URL to the generated PNG hosted on that server. Give a descriptive prompt; optionally adjust model, width, height, steps, seed, negative, upscale, upscale_model, or filename_prefix.',
|
||||
parameters: {
|
||||
prompt: { type: 'string', description: 'Positive prompt describing the desired image.', required: true },
|
||||
negative: { type: 'string', description: 'Negative prompt.' },
|
||||
width: { type: 'integer', description: 'Image width in pixels. Default 512.' },
|
||||
height: { type: 'integer', description: 'Image height in pixels. Default 512.' },
|
||||
steps: { type: 'integer', description: 'Sampling steps. Default 28 (Juggernaut).' },
|
||||
seed: { type: 'integer', description: 'Random seed. Defaults to a random value.' },
|
||||
model: { type: 'string', description: 'Checkpoint model filename. Default Juggernaut-XL_v9_RunDiffusionPhoto_v2.safetensors.' },
|
||||
upscale: { type: 'boolean', description: 'Upscale the result 4x with RealESRGAN_x4plus. Default true.' },
|
||||
upscale_model: { type: 'string', description: 'Upscale model filename. Default RealESRGAN_x4plus.safetensors.' },
|
||||
filename_prefix: { type: 'string', description: 'Filename prefix for the saved image. Default dsh.' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: renderText,
|
||||
},
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
const imageArgs = args as {
|
||||
prompt: string
|
||||
negative?: string
|
||||
width?: number
|
||||
height?: number
|
||||
steps?: number
|
||||
seed?: number
|
||||
model?: string
|
||||
upscale?: boolean
|
||||
upscale_model?: string
|
||||
filename_prefix?: string
|
||||
}
|
||||
return runGenerateImage(labConfig, imageArgs, exec.signal)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
if (resolved.ocrPdf) {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'lab_ocr_pdf',
|
||||
description:
|
||||
'Extract text from a PDF using the home-lab Docling OCR server. Reads the local PDF and returns the recognized markdown text. Provide the local path to the PDF.',
|
||||
parameters: {
|
||||
file_path: { type: 'string', description: 'Local path to the PDF file to OCR.', required: true },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: renderText,
|
||||
},
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
isConcurrencySafe: () => true,
|
||||
execute(args, exec) {
|
||||
const fs = ctx.fs
|
||||
return runOcrPdf(fs, labConfig, args.file_path, exec.signal)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
if (resolved.transcribeAudio) {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'lab_transcribe_audio',
|
||||
description:
|
||||
'Transcribe speech from an audio file using the home-lab Whishper server. Returns the recognized text. Provide a local path to an audio file and optionally a language hint.',
|
||||
parameters: {
|
||||
file_path: { type: 'string', description: 'Local path to the audio file (wav, mp3, ogg, or m4a).', required: true },
|
||||
language: { type: 'string', description: 'Optional language code hint (e.g. ru, en).' },
|
||||
model_size: { type: 'string', description: 'Optional model size (e.g. small, base).' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: renderText,
|
||||
},
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
isConcurrencySafe: () => true,
|
||||
execute(args, exec) {
|
||||
const fs = ctx.fs
|
||||
return runTranscribeAudio(fs, labConfig, args.file_path, args.language, args.model_size, exec.signal)
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
29
packages/extensions/tool-lab/src/invariant.ts
Normal file
29
packages/extensions/tool-lab/src/invariant.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-lab`.
|
||||
* @module @deepseek-ai/dsh-tool-lab/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-lab'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-lab-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this model-facing adapter has no independent lifecycle
|
||||
* stream; execution relations are owned by the capability seam it calls.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
84
packages/extensions/tool-lab/src/whish.ts
Normal file
84
packages/extensions/tool-lab/src/whish.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Whishper speech-to-text tool. Reads local audio via `ctx.fs`, uploads it to
|
||||
* the lab's Whishper server, polls the transcription, and returns the text.
|
||||
* @module @deepseek-ai/dsh-tool-lab
|
||||
*/
|
||||
|
||||
import type { FileSystem } from '@deepseek-ai/dsh-fs'
|
||||
import { Deadline, sleep } from './helpers.ts'
|
||||
import type { ResolvedLabConfig } from './comfy.ts'
|
||||
|
||||
interface WhishperTranscription {
|
||||
id?: string
|
||||
status?: number
|
||||
result?: { text?: string | null; language?: string | null; duration?: number | null }
|
||||
}
|
||||
|
||||
/** Build multipart upload of an audio file. */
|
||||
export function buildAudioUpload(data: Uint8Array, filename: string, language?: string): FormData {
|
||||
const fd = new FormData()
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? 'wav'
|
||||
const type = ext === 'mp3' ? 'audio/mpeg' : ext === 'ogg' ? 'audio/ogg' : ext === 'm4a' ? 'audio/mp4' : 'audio/wav'
|
||||
fd.append('files', new Blob([data as unknown as BlobPart], { type }), filename)
|
||||
if (language) fd.append('language', language)
|
||||
return fd
|
||||
}
|
||||
|
||||
/** Poll Whishper `/api/transcriptions/{id}` until text is ready or done. */
|
||||
async function pollTranscription(
|
||||
baseUrl: string,
|
||||
id: string,
|
||||
deadline: Deadline,
|
||||
timeoutMs: number,
|
||||
): Promise<WhishperTranscription> {
|
||||
const started = Date.now()
|
||||
for (;;) {
|
||||
deadline.check()
|
||||
const res = await fetch(`${baseUrl}/api/transcriptions/${id}`)
|
||||
if (!res.ok) throw new Error(`Whishper status ${res.status}: ${await res.text()}`)
|
||||
const row = (await res.json()) as WhishperTranscription
|
||||
const text = row.result?.text
|
||||
if (typeof text === 'string' && text.length > 0 && row.status !== -1) return row
|
||||
if (row.status !== -1 && (row.status === 1 || row.status === 2)) return row
|
||||
if (Date.now() - started >= timeoutMs) throw new Error(`Whishper transcription timed out after ${timeoutMs}ms`)
|
||||
await sleep(1500, deadline.signal)
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the Whishper tool: read audio, upload, poll, return transcription text. */
|
||||
export async function runTranscribeAudio(
|
||||
fs: FileSystem,
|
||||
config: ResolvedLabConfig,
|
||||
filePath: string,
|
||||
language: string | undefined,
|
||||
modelSize: string | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<string> {
|
||||
const deadline = new Deadline('transcribe_audio', config.timeoutMs, signal)
|
||||
try {
|
||||
const target = await fs.resolve(filePath, signal === undefined ? undefined : { signal })
|
||||
const statInfo = await fs.stat(target, signal)
|
||||
if (!statInfo) throw new Error(`transcribe_audio: file not found: ${filePath}`)
|
||||
const data = await fs.readBytes(target, signal, config.maxUploadBytes)
|
||||
const filename = filePath.split(/[\\/]/).pop() ?? 'audio.wav'
|
||||
const form = buildAudioUpload(data, filename, language)
|
||||
if (modelSize) {
|
||||
// Whishper server config may restrict model size; pass it as a field if supported.
|
||||
form.append('modelSize', modelSize)
|
||||
}
|
||||
const submit = await fetch(`${config.whishBaseUrl}/api/transcriptions`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
if (!submit.ok) throw new Error(`Whishper upload ${submit.status}: ${await submit.text()}`)
|
||||
const row = (await submit.json()) as WhishperTranscription
|
||||
if (!row.id) throw new Error(`Whishper no id: ${JSON.stringify(row)}`)
|
||||
const done = await pollTranscription(config.whishBaseUrl, row.id, deadline, config.timeoutMs)
|
||||
const text = done.result?.text ?? ''
|
||||
if (text.length === 0) return '(transcription completed with no text)'
|
||||
if (text.length > config.maxOutputChars) return text.slice(0, config.maxOutputChars)
|
||||
return text
|
||||
} finally {
|
||||
deadline.dispose()
|
||||
}
|
||||
}
|
||||
60
packages/extensions/tool-lab/tests/tool-lab.spec.ts
Normal file
60
packages/extensions/tool-lab/tests/tool-lab.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Unit tests for the pure helpers of `@deepseek-ai/dsh-tool-lab`. These
|
||||
* assertions do not touch the network or the runtime; they check the workflow
|
||||
* and upload builders only.
|
||||
* @module @deepseek-ai/dsh-tool-lab/tests
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWorkflow } from '../src/comfy.ts'
|
||||
import { buildPdfUpload } from '../src/docling.ts'
|
||||
import { buildAudioUpload } from '../src/whish.ts'
|
||||
|
||||
/** One ComfyUI workflow node as seen by the assertions. */
|
||||
interface WorkflowNode {
|
||||
class_type: string
|
||||
inputs: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Reinterpret the workflow builder's JSON-serializable output for assertions. */
|
||||
function wf(prompt: string, overrides: Record<string, unknown> = {}): Record<string, WorkflowNode> {
|
||||
return buildWorkflow({ prompt, ...(overrides as object) }) as Record<string, WorkflowNode>
|
||||
}
|
||||
|
||||
describe('buildWorkflow', () => {
|
||||
it('renders a 28-step Juggernaut workflow with defaults', () => {
|
||||
const flow = wf('a red fox')
|
||||
expect(flow['4'].inputs.ckpt_name).toBe('Juggernaut-XL_v9_RunDiffusionPhoto_v2.safetensors')
|
||||
expect(flow['9'].class_type).toBe('SaveImage')
|
||||
expect(flow['9'].inputs.images).toEqual(['10', 0])
|
||||
expect(flow['10'].class_type).toBe('ImageUpscaleWithModel')
|
||||
expect(flow['11'].inputs.model_name).toBe('RealESRGAN_x4plus.safetensors')
|
||||
expect(flow['3'].inputs).toMatchObject({ steps: 28, cfg: 4.0, sampler_name: 'dpmpp_2m_sde', scheduler: 'karras' })
|
||||
})
|
||||
|
||||
it('skips upscaling when upscale is false', () => {
|
||||
const flow = wf('p', { upscale: false })
|
||||
expect(flow['9'].inputs.images).toEqual(['8', 0])
|
||||
expect(flow['10']).toBeUndefined()
|
||||
expect(flow['11']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('honors overrides and seed', () => {
|
||||
const flow = wf('p', { width: 768, height: 640, steps: 8, seed: 99 })
|
||||
expect(flow['5'].inputs).toMatchObject({ width: 768, height: 640 })
|
||||
expect(flow['3'].inputs).toMatchObject({ seed: 99, steps: 8 })
|
||||
expect(flow['6'].inputs.text).toBe('p')
|
||||
})
|
||||
})
|
||||
|
||||
describe('upload builders', () => {
|
||||
it('builds a PDF upload carrying files and options parts', () => {
|
||||
const fd = buildPdfUpload(new Uint8Array([1, 2, 3]), 'scan.pdf')
|
||||
expect(fd).toBeDefined()
|
||||
})
|
||||
|
||||
it('builds an audio upload', () => {
|
||||
const fd = buildAudioUpload(new Uint8Array([1, 2]), 'clip.mp3')
|
||||
expect(fd).toBeDefined()
|
||||
})
|
||||
})
|
||||
28
packages/extensions/tool-lab/tsconfig.json
Normal file
28
packages/extensions/tool-lab/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../fs/fs"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user