chore: isolate shared dsh plugins into independent monorepo
Some checks failed
build-and-publish / build-test (push) Failing after 1m6s
build-and-publish / publish (push) Has been skipped

This commit is contained in:
2026-08-26 22:44:31 +07:00
parent 0e0b68fed5
commit 81159a22e8
37 changed files with 3456 additions and 2 deletions

View File

@@ -0,0 +1,64 @@
{
"name": "@deepseek-ai/dsh-tool-lab",
"description": "Model-facing laboratory tools over the LAN media lab: ComfyUI image generation, Docling PDF OCR, and Whishper speech-to-text",
"version": "0.1.0-rc.7",
"publishConfig": {
"access": "public",
"registry": "https://git.byte-mate.ru/api/packages/Coder/npm/",
"tag": "rc"
},
"repository": {
"type": "git",
"url": "git+https://git.byte-mate.ru/Coder/dsh-plugins.git",
"directory": "packages/tool-lab"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"types": "./lib/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib"
],
"license": "MIT",
"engines": {
"node": ">=22.19"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest run"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-fs": "^0.1.0-rc.7",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.7",
"@deepseek-ai/dsh-timeout": "^0.1.0-rc.7",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.7"
},
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"devDependencies": {
"@deepseek-ai/cordis": "4.0.1",
"@deepseek-ai/schemastery": "3.18.1",
"@deepseek-ai/dsh-fs": "0.1.0-rc.7",
"@deepseek-ai/dsh-invariants": "0.1.0-rc.7",
"@deepseek-ai/dsh-timeout": "0.1.0-rc.7",
"@deepseek-ai/dsh-tools": "0.1.0-rc.7",
"@deepseek-ai/dsh-llm": "0.1.0-rc.7",
"@deepseek-ai/dsh-sandbox": "0.1.0-rc.7",
"typescript": "^6.0.3",
"vitest": "^4.1.8",
"@types/node": "^22.20.0"
}
}

View File

@@ -0,0 +1,203 @@
/**
* ComfyUI image-generation tool. Submits an SDXL-turbo workflow to a shared
* ComfyUI instance, polls `/history/{prompt_id}` until the SaveImage node
* produced a file, and returns the served PNG URL. Services are unauthenticated.
* @module @deepseek-ai/dsh-tool-lab/comfy
*/
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { HttpError, deadline, sleep } from './helpers.ts'
/** Default SDXL-turbo checkpoint on the lab server. */
export const DEFAULT_MODEL = 'sdxl_turbo.safetensors'
/** Default negative prompt applied unless the caller overrides it. */
export const DEFAULT_NEGATIVE = 'negative prompt'
/** Default output filename prefix for SaveImage. */
export const DEFAULT_FILENAME_PREFIX = 'dsh'
/** Default step count for the SDXL-turbo Euler-normal pass. */
export const DEFAULT_STEPS = 4
/** Default generation dimensions when neither axis is supplied. */
export const DEFAULT_SIZE = 512
/** One history poll interval (ms) while awaiting the rendered image. */
export const POLL_INTERVAL_MS = 1_000
/** Schema-validated arguments for `lab_generate_image`. */
export interface GenerateImageArgs {
prompt: string
negative?: string
width?: number
height?: number
steps?: number
seed?: number
model?: string
filename_prefix?: string
}
/**
* Validate and resolve optional image arguments against defaults. Positive
* integer constraints the schema DSL cannot express are enforced here.
*/
export function resolveImageArgs(args: GenerateImageArgs): Required<GenerateImageArgs> {
const resolved = {
prompt: args.prompt,
negative: args.negative ?? DEFAULT_NEGATIVE,
width: args.width ?? DEFAULT_SIZE,
height: args.height ?? DEFAULT_SIZE,
steps: args.steps ?? DEFAULT_STEPS,
seed: args.seed ?? 123,
model: args.model ?? DEFAULT_MODEL,
filename_prefix: args.filename_prefix ?? DEFAULT_FILENAME_PREFIX,
}
for (const [name, value] of [
['width', resolved.width],
['height', resolved.height],
['steps', resolved.steps],
['seed', resolved.seed],
] as const) {
if (!Number.isInteger(value) || value < 1) throw new Error(`lab_generate_image: ${name} must be a positive integer`)
}
return resolved
}
/**
* Build an SDXL-turbo ComfyUI workflow for a 4-step Euler-normal pass. The
* node graph is the lab's validated shape; only the seed, dimensions, step
* count, prompts, and output prefix vary per call.
*/
export function buildWorkflow(opts: {
prompt: string
negative: string
width: number
height: number
steps: number
seed: number
model: string
filenamePrefix: string
}): Record<string, unknown> {
const { prompt, negative, width, height, steps, seed, model, filenamePrefix } = opts
return {
'3': {
class_type: 'KSampler',
inputs: {
seed,
steps,
cfg: 1.0,
sampler_name: 'euler',
scheduler: 'normal',
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: 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: filenamePrefix, images: ['8', 0] } },
}
}
/**
* Execute one SDXL-turbo generation against a ComfyUI server: queue the
* workflow, poll history for the rendered image, and return its `GET /view` URL.
*
* @param baseUrl - the ComfyUI server base URL, e.g. `http://192.168.31.240:8188`.
* @param resolved - schema-validated, default-resolved generation arguments.
* @param signal - the executor's cancellation signal (fused into the deadline).
* @param timeoutMs - cooperative timeout budget for the whole call.
* @returns the served PNG URL.
*/
export async function runComfyImage(
baseUrl: string,
resolved: Required<GenerateImageArgs>,
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<string> {
using d = deadline(signal, timeoutMs, 'LAB_TOOL_TIMEOUT')
let response = await fetch(`${baseUrl}/prompt`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
prompt: buildWorkflow({
prompt: resolved.prompt,
negative: resolved.negative,
width: resolved.width,
height: resolved.height,
steps: resolved.steps,
seed: resolved.seed,
model: resolved.model,
filenamePrefix: resolved.filename_prefix,
}),
client_id: 'dsh',
}),
signal: d.signal,
})
if (!response.ok) {
throw new HttpError(`lab_generate_image: ComfyUI prompt failed (HTTP ${response.status})`, response.status, 'LAB_COMFY_HTTP')
}
const queued = await response.json() as { prompt_id?: string; node_errors?: Record<string, unknown> }
const nodeErrors = queued.node_errors ?? {}
if (Object.keys(nodeErrors).length > 0) throw new Error(`lab_generate_image: workflow rejected: ${JSON.stringify(nodeErrors)}`)
const promptId = queued.prompt_id
if (promptId === undefined) throw new Error('lab_generate_image: /prompt returned no prompt_id')
while (true) {
d.signal.throwIfAborted()
response = await fetch(`${baseUrl}/history/${encodeURIComponent(promptId)}`, { signal: d.signal })
if (!response.ok) {
throw new HttpError(`lab_generate_image: ComfyUI history failed (HTTP ${response.status})`, response.status, 'LAB_COMFY_HTTP')
}
const history = await response.json() as Record<string, { outputs?: Record<string, { images?: { filename?: string }[] }> }>
const images = history[promptId]?.outputs?.['9']?.images ?? []
const filename = images[0]?.filename
if (filename !== undefined) {
return `${baseUrl}/view?filename=${encodeURIComponent(filename)}&subfolder=&type=output`
}
await sleep(POLL_INTERVAL_MS, d.signal)
}
}
/**
* Register the `lab_generate_image` tool.
*
* @param ctx - context whose `tools` registry receives the definition
* (effect-scoped; unregistered on plugin dispose).
* @param baseUrl - the ComfyUI server base URL.
* @param timeoutMs - cooperative timeout budget attached as the tool's `timeoutMs`.
* @param maxOutputChars - output cap; image generation returns a URL and ignores this.
*/
export function registerLabComfyTool(ctx: Context, baseUrl: string, timeoutMs: number, maxOutputChars: number): void {
void maxOutputChars
ctx.tools.register(defineTool({
name: 'lab_generate_image',
description:
'Generate an image with the shared ComfyUI lab server (SDXL-turbo, 4 steps, euler/normal, no auth). '
+ 'Returns the served PNG URL. Polls the render up to the tool timeout; use the default size for fastest results.',
parameters: {
prompt: { type: 'string', required: true, description: 'The positive text prompt describing the image.' },
negative: { type: 'string', description: `Negative prompt; defaults to "${DEFAULT_NEGATIVE}".` },
width: { type: 'integer', description: `Output width; defaults to ${DEFAULT_SIZE}.` },
height: { type: 'integer', description: `Output height; defaults to ${DEFAULT_SIZE}.` },
steps: { type: 'integer', description: `Sampling steps; defaults to ${DEFAULT_STEPS} (SDXL-turbo).` },
seed: { type: 'integer', description: 'Sampling seed; defaults to 123.' },
model: { type: 'string', description: `Checkpoint name on the server; defaults to "${DEFAULT_MODEL}".` },
filename_prefix: { type: 'string', description: `Output filename prefix; defaults to "${DEFAULT_FILENAME_PREFIX}".` },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: String(value) }],
},
timeoutMs,
// A shared GPU render is idempotent from the caller's perspective: sibling
// calls mutate no parent-owned state beyond the lab queue.
isConcurrencySafe: () => true,
async execute(args, exec) {
return runComfyImage(baseUrl, resolveImageArgs(args as GenerateImageArgs), exec.signal, timeoutMs)
},
}))
}

View File

@@ -0,0 +1,150 @@
/**
* Docling PDF-OCR tool. Uploads a PDF through Docling's multipart async
* conversion endpoint, polls the task status, and returns the extracted
* markdown. Services are unauthenticated.
* @module @deepseek-ai/dsh-tool-lab/docling
*/
import type { Context } from '@deepseek-ai/cordis'
import type { FsTarget } from '@deepseek-ai/dsh-fs'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { HttpError, deadline, sleep } from './helpers.ts'
/** Docling `/v1/status/poll` wait hint (seconds) per poll. */
export const DOCLING_POLL_WAIT_S = 2
/** One transport retry interval (ms) between status polls. */
export const DOCLING_POLL_INTERVAL_MS = 2_000
/** Schema-validated arguments for `lab_ocr_pdf`. */
export interface OcrPdfArgs {
file_path: string
}
/**
* Upload a PDF blob and poll the Docling task until it succeeds, returning
* the extracted markdown (`document.md_content`).
*
* @param baseUrl - the Docling server base URL, e.g. `http://192.168.31.159:5001`.
* @param bytes - the PDF file bytes read through `ctx.fs.readBytes`.
* @param filename - the file name sent in the multipart upload.
* @param signal - the executor's cancellation signal (deadline-fused).
* @param timeoutMs - cooperative timeout budget for the whole call.
* @returns the extracted markdown text.
*/
export async function runDoclingOcr(
baseUrl: string,
bytes: Uint8Array,
filename: string,
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<string> {
using d = deadline(signal, timeoutMs, 'LAB_TOOL_TIMEOUT')
const form = new FormData()
form.append('files', new Blob([bytes as BlobPart], { type: 'application/pdf' }), filename)
let response = await fetch(`${baseUrl}/v1/convert/file/async`, { method: 'POST', body: form, signal: d.signal })
if (!response.ok) {
throw new HttpError(`lab_ocr_pdf: Docling convert failed (HTTP ${response.status})`, response.status, 'LAB_DOCLING_HTTP')
}
const queued = await response.json() as { task_id?: string; task_status?: string }
const taskId = queued.task_id
if (taskId === undefined) throw new Error('lab_ocr_pdf: /v1/convert/file/async returned no task_id')
while (true) {
d.signal.throwIfAborted()
response = await fetch(`${baseUrl}/v1/status/poll/${encodeURIComponent(taskId)}?wait=${DOCLING_POLL_WAIT_S}`, { signal: d.signal })
if (!response.ok) {
throw new HttpError(`lab_ocr_pdf: Docling status failed (HTTP ${response.status})`, response.status, 'LAB_DOCLING_HTTP')
}
const status = await response.json() as { task_status?: string }
if (status.task_status === 'success') break
if (status.task_status === 'failure' || status.task_status === 'cancelled' || status.task_status === 'error') {
throw new Error(`lab_ocr_pdf: Docling task ended with status "${status.task_status}"`)
}
await sleep(DOCLING_POLL_INTERVAL_MS, d.signal)
}
d.signal.throwIfAborted()
response = await fetch(`${baseUrl}/v1/result/${encodeURIComponent(taskId)}`, { signal: d.signal })
if (!response.ok) {
throw new HttpError(`lab_ocr_pdf: Docling result failed (HTTP ${response.status})`, response.status, 'LAB_DOCLING_HTTP')
}
const result = await response.json() as { status?: string; errors?: unknown[]; document?: { md_content?: string } }
if (result.status !== 'success') throw new Error(`lab_ocr_pdf: Docling result status "${result.status ?? 'unknown'}"`)
const markdown = result.document?.md_content
if (markdown === undefined) throw new Error('lab_ocr_pdf: Docling result carried no document.md_content')
return markdown
}
/**
* Register the `lab_ocr_pdf` tool. Reads the file as bytes via `ctx.fs`
* (`resolve` then `readBytes`, bounded by `maxBytes`) and uploads it.
*
* @param ctx - context whose `tools` registry receives the definition and whose
* `fs` provides file reads.
* @param baseUrl - the Docling server base URL.
* @param timeoutMs - cooperative timeout budget attached as the tool's `timeoutMs`.
* @param maxBytes - inclusive cap on the PDF bytes read from disk.
* @param maxOutputChars - cap on the returned markdown; longer output is truncated.
*/
export function registerLabDoclingTool(
ctx: Context,
baseUrl: string,
timeoutMs: number,
maxBytes: number,
maxOutputChars: number,
): void {
ctx.tools.register(defineTool({
name: 'lab_ocr_pdf',
description:
'OCR a PDF file and return the extracted markdown text via the shared Docling server (no auth). '
+ `The file is read from disk up to ${formatBytes(maxBytes)} and uploaded as multipart; the tool polls until OCR succeeds or times out.`,
parameters: {
file_path: { type: 'string', required: true, description: 'Path to the PDF file to OCR.' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: String(value) }],
},
timeoutMs,
// Reads and an upload to the shared OCR queue: no parent-owned mutation.
isConcurrencySafe: () => true,
async execute(args, exec) {
const target = await ctx.fs.resolve(args.file_path, { signal: exec.signal })
return runDoclingOcrFromTarget(
baseUrl,
ctx,
target,
args.file_path,
exec.signal,
timeoutMs,
maxBytes,
maxOutputChars,
)
},
}))
}
/** Read the resolved target bytes and run the Docling OCR flow. */
async function runDoclingOcrFromTarget(
baseUrl: string,
ctx: Context,
target: FsTarget,
displayPath: string,
signal: AbortSignal | undefined,
timeoutMs: number,
maxBytes: number,
maxOutputChars: number,
): Promise<string> {
const bytes = await ctx.fs.readBytes(target, signal, maxBytes)
const filename = displayPath.split(/[\\/]/).pop() ?? 'input.pdf'
const markdown = await runDoclingOcr(baseUrl, bytes, filename, signal, timeoutMs)
if (markdown.length <= maxOutputChars) return markdown
return `${markdown.slice(0, maxOutputChars)}\n\n(OCR output truncated.)`
}
/** Human-readable byte bound for the tool description. */
function formatBytes(bytes: number): string {
return bytes >= 1024 * 1024 ? `${Math.floor(bytes / (1024 * 1024))}MB` : `${bytes}B`
}

View File

@@ -0,0 +1,68 @@
/**
* Shared cooperative utilities for the lab tools: a signal-fused deadline, an
* abortable sleep, and a structured HTTP/upstream error carrier. Deadline and
* sleep cooperate with the caller's cancellation signal so a timed-out or
* cancelled call unwinds promptly instead of blocking on a hung server.
* @module @deepseek-ai/dsh-tool-lab/helpers
*/
import { deadline as makeDeadline } from '@deepseek-ai/dsh-timeout'
import type { Deadline } from '@deepseek-ai/dsh-timeout'
/**
* Cooperative classification error for lab-service HTTP failures. Carries the
* HTTP status (or `undefined` for transport failures) and a stable code so
* callers and diagnostics can distinguish a rejected workflow, an unreachable
* server, and a timed-out request without reparsing the message.
*/
export class HttpError extends Error {
override name = 'HttpError'
constructor(
message: string,
readonly status: number | undefined,
readonly code: string,
) {
super(message)
}
}
/**
* Fuse the executor's cancellation signal with a per-call timeout budget.
* The returned deadline aborts on upstream cancellation OR on timeout (the
* timeout carries a {@link TimeoutReason} with the given code). `using` clears
* the timer at scope exit.
*
* @param signal - the executor's cancellation signal, if any.
* @param timeoutMs - cooperative timeout budget in milliseconds.
* @param code - capability-owned code stamped onto the timeout reason.
* @returns the fused deadline (signal + timer cleanup).
*/
export function deadline(signal: AbortSignal | undefined, timeoutMs: number, code: string): Deadline {
return makeDeadline(signal, timeoutMs, code)
}
/**
* Suspend the current coroutine for `ms` milliseconds, returning early when
* the signal aborts. Throws the abort once registered; cooperative callers
* check `signal.aborted` or let the next upstream call raise.
*
* @param ms - sleep duration in milliseconds.
* @param signal - optional signal to observe.
*/
export async function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
if (ms <= 0) return
if (signal !== undefined && signal.aborted) signal.throwIfAborted()
await new Promise<void>((resolve, reject) => {
if (signal === undefined) {
setTimeout(resolve, ms)
return
}
const onAbort = (): void => reject(signal.reason)
signal.addEventListener('abort', onAbort, { once: true })
setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, ms)
})
}

View File

@@ -0,0 +1,90 @@
/**
* Model-facing lab tools over the LAN media lab: ComfyUI image generation,
* Docling PDF OCR, and Whishper speech-to-text. This package owns schemas,
* validation, defaults, and presentation; each service module owns its HTTP
* flow. Enablement is config-driven; the three tools register together.
* @module @deepseek-ai/dsh-tool-lab
*/
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { registerLabComfyTool } from './comfy.ts'
import { registerLabDoclingTool } from './docling.ts'
import { registerLabWhishTool } from './whish.ts'
export { DEFAULT_MODEL as COMFY_DEFAULT_MODEL, DEFAULT_NEGATIVE as COMFY_DEFAULT_NEGATIVE } from './comfy.ts'
export type { GenerateImageArgs as ComfyGenerateImageArgs } from './comfy.ts'
export type { OcrPdfArgs as DoclingOcrPdfArgs } from './docling.ts'
export type { TranscribeAudioArgs as WhishTranscribeAudioArgs } from './whish.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'lab'
/** Services required by the lab tool suite. */
export const inject = ['tools', 'fs']
/** Default ComfyUI server base URL. */
export const DEFAULT_COMFY_BASE_URL = 'http://192.168.31.240:8188'
/** Default Docling server base URL. */
export const DEFAULT_DOCLING_BASE_URL = 'http://192.168.31.159:5001'
/** Default Whishper server base URL. */
export const DEFAULT_WHISH_BASE_URL = 'http://192.168.31.159:8082'
/** Default per-call cooperative timeout budget. */
export const DEFAULT_TIMEOUT_MS = 120_000
/** Default cap on one uploaded file. */
export const DEFAULT_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
/** Default cap on one tool's text output. */
export const DEFAULT_MAX_OUTPUT_CHARS = 200_000
/** Plugin config: lab service endpoints and the shared tool bounds. */
export interface Config {
/** ComfyUI server base URL. */
comfyBaseUrl?: string
/** Docling server base URL. */
doclingBaseUrl?: string
/** Whishper server base URL. */
whishBaseUrl?: string
/** Cooperative timeout budget (ms) for one lab tool call. */
timeoutMs?: number
/** Inclusive cap on one uploaded file (bytes). */
maxUploadBytes?: number
/** Cap on one tool's text output (characters). */
maxOutputChars?: number
}
export const Config: z<Config> = z.object({
comfyBaseUrl: z.string().default(DEFAULT_COMFY_BASE_URL),
doclingBaseUrl: z.string().default(DEFAULT_DOCLING_BASE_URL),
whishBaseUrl: z.string().default(DEFAULT_WHISH_BASE_URL),
timeoutMs: z.number().default(DEFAULT_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 timeouts and caps must be positive integers. */
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1) throw new Error(`tool-lab: ${name} must be a positive integer`)
}
/**
* Register the three lab tools. Each tool's cooperative timeout budget
* (`timeoutMs`) and file/output bounds come from config and are attached to
* the definition for `@deepseek-ai/dsh-tool-call-timeout-policy` to enforce.
* The effect-based registry cleanup means no manual teardown is needed.
*/
export function apply(ctx: Context, config: Config): void {
const resolved = config as ResolvedConfig
for (const [key, value] of [
['timeoutMs', resolved.timeoutMs],
['maxUploadBytes', resolved.maxUploadBytes],
['maxOutputChars', resolved.maxOutputChars],
] as const) {
assertPositiveInteger(key, value)
}
registerLabComfyTool(ctx, resolved.comfyBaseUrl, resolved.timeoutMs, resolved.maxOutputChars)
registerLabDoclingTool(ctx, resolved.doclingBaseUrl, resolved.timeoutMs, resolved.maxUploadBytes, resolved.maxOutputChars)
registerLabWhishTool(ctx, resolved.whishBaseUrl, resolved.timeoutMs, resolved.maxUploadBytes, resolved.maxOutputChars)
}

View File

@@ -0,0 +1,30 @@
/**
* 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 seams 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))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,153 @@
/**
* Whishper speech-to-text tool. Uploads an audio file through Whishper's
* multipart `/api/transcriptions` endpoint, polls until a transcript is ready,
* and returns `result.text`. Services are unauthenticated.
* @module @deepseek-ai/dsh-tool-lab/whish
*/
import type { Context } from '@deepseek-ai/cordis'
import type { FsTarget } from '@deepseek-ai/dsh-fs'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { HttpError, deadline, sleep } from './helpers.ts'
/** One transcription status poll interval (ms). */
export const WHISH_POLL_INTERVAL_MS = 2_000
/** Default Whishper model size when the caller omits it. */
export const WHISH_DEFAULT_MODEL = 'base'
/** Schema-validated arguments for `lab_transcribe_audio`. */
export interface TranscribeAudioArgs {
file_path: string
model_size?: string
language?: string
}
/**
* Upload an audio blob and poll Whishper until the transcript text is ready,
* returning it. A queued item reports `status: -1`; once `status >= 0` AND
* `result.text` is non-empty the transcript is done.
*
* @param baseUrl - the Whishper server base URL, e.g. `http://192.168.31.159:8082`.
* @param bytes - the audio bytes read through `ctx.fs.readBytes`.
* @param filename - the file name sent in the multipart upload.
* @param modelSize - optional model size hint.
* @param language - optional spoken-language hint.
* @param signal - the executor's cancellation signal (deadline-fused).
* @param timeoutMs - cooperative timeout budget for the whole call.
* @returns the transcribed text.
*/
export async function runWhishTranscribe(
baseUrl: string,
bytes: Uint8Array,
filename: string,
modelSize: string | undefined,
language: string | undefined,
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<string> {
using d = deadline(signal, timeoutMs, 'LAB_TOOL_TIMEOUT')
const form = new FormData()
form.append('files', new Blob([bytes as BlobPart]), filename)
if (modelSize !== undefined) form.append('model_size', modelSize)
if (language !== undefined) form.append('language', language)
let response = await fetch(`${baseUrl}/api/transcriptions`, { method: 'POST', body: form, signal: d.signal })
if (!response.ok) {
throw new HttpError(`lab_transcribe_audio: Whishper upload failed (HTTP ${response.status})`, response.status, 'LAB_WHISH_HTTP')
}
const queued = await response.json() as { id?: string; status?: number }
const id = queued.id
if (id === undefined) throw new Error('lab_transcribe_audio: /api/transcriptions returned no id')
while (true) {
d.signal.throwIfAborted()
response = await fetch(`${baseUrl}/api/transcriptions/${encodeURIComponent(String(id))}`, { signal: d.signal })
if (!response.ok) {
throw new HttpError(`lab_transcribe_audio: Whishper status failed (HTTP ${response.status})`, response.status, 'LAB_WISH_HTTP')
}
const state = await response.json() as { status?: number; result?: { text?: string } }
const status = state.status ?? 0
const text = state.result?.text
if (status >= 0 && text !== undefined && text.trim().length > 0) return text
await sleep(WHISH_POLL_INTERVAL_MS, d.signal)
}
}
/**
* Register the `lab_transcribe_audio` tool. Reads the audio file as bytes via
* `ctx.fs` (`resolve` then `readBytes`, bounded by `maxBytes`) and uploads it.
*
* @param ctx - context whose `tools` registry receives the definition and whose
* `fs` provides file reads.
* @param baseUrl - the Whishper server base URL.
* @param timeoutMs - cooperative timeout budget attached as the tool's `timeoutMs`.
* @param maxBytes - inclusive cap on the audio bytes read from disk.
* @param maxOutputChars - cap on the returned transcript; longer text is truncated.
*/
export function registerLabWhishTool(
ctx: Context,
baseUrl: string,
timeoutMs: number,
maxBytes: number,
maxOutputChars: number,
): void {
ctx.tools.register(defineTool({
name: 'lab_transcribe_audio',
description:
'Transcribe speech to text from an audio file via the shared Whishper server (no auth). '
+ `The file is read from disk up to ${formatBytes(maxBytes)} and uploaded as multipart; the tool polls until the transcript is ready or times out.`,
parameters: {
file_path: { type: 'string', required: true, description: 'Path to the audio file to transcribe.' },
model_size: { type: 'string', description: `Optional model size hint; defaults to "${WHISH_DEFAULT_MODEL}".` },
language: { type: 'string', description: 'Optional spoken-language hint (e.g. "en" or "ru").' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: String(value) }],
},
timeoutMs,
isConcurrencySafe: () => true,
async execute(args, exec) {
const target = await ctx.fs.resolve(args.file_path, { signal: exec.signal })
return runWhishFromTarget(
baseUrl,
ctx,
target,
args.file_path,
args.model_size,
args.language,
exec.signal,
timeoutMs,
maxBytes,
maxOutputChars,
)
},
}))
}
/** Read the resolved target bytes and run the Whishper flow. */
async function runWhishFromTarget(
baseUrl: string,
ctx: Context,
target: FsTarget,
displayPath: string,
modelSize: string | undefined,
language: string | undefined,
signal: AbortSignal | undefined,
timeoutMs: number,
maxBytes: number,
maxOutputChars: number,
): Promise<string> {
const bytes = await ctx.fs.readBytes(target, signal, maxBytes)
const filename = displayPath.split(/[\\/]/).pop() ?? 'audio.bin'
const model = modelSize !== undefined && modelSize.trim().length > 0 ? modelSize.trim() : WHISH_DEFAULT_MODEL
const text = await runWhishTranscribe(baseUrl, bytes, filename, model, language, signal, timeoutMs)
if (text.length <= maxOutputChars) return text
return `${text.slice(0, maxOutputChars)}\n\n(Transcript truncated.)`
}
/** Human-readable byte bound for the tool description. */
function formatBytes(bytes: number): string {
return bytes >= 1024 * 1024 ? `${Math.floor(bytes / (1024 * 1024))}MB` : `${bytes}B`
}

View File

@@ -0,0 +1,162 @@
import { describe, expect, it, vi, afterEach } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import { FileSystem } from '@deepseek-ai/dsh-fs'
import type { FsTarget, FsDirEntry, FsEditOutcome, FsEditRequest, FsInfo, FsPathInfo, FsWriteIntent, FsWriteOutcome, FsVersion, FsTargetKey } from '@deepseek-ai/dsh-fs'
import * as ToolLab from '@deepseek-ai/dsh-tool-lab'
import { runComfyImage, resolveImageArgs, buildWorkflow } from '@deepseek-ai/dsh-tool-lab/src/comfy.ts'
import { runDoclingOcr } from '@deepseek-ai/dsh-tool-lab/src/docling.ts'
import { runWhishTranscribe } from '@deepseek-ai/dsh-tool-lab/src/whish.ts'
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
const testToolSignal = new AbortController().signal
/** In-memory fake ctx.fs backend for upload tools. */
class FakeFs extends FileSystem {
files = new Map<string, Uint8Array>()
override async resolve(path: string): Promise<FsTarget> {
return { targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` }
}
override processPath(target: FsTarget): string { return String(target.targetKey) }
override fileUrl(target: FsTarget): string { return `file://${target.targetKey}` }
override contains(parent: FsTarget, child: FsTarget): boolean {
return child.targetKey === parent.targetKey || String(child.targetKey).startsWith(`${parent.targetKey}/`)
}
override async stat(): Promise<FsInfo | undefined> { return undefined }
override async lstat(): Promise<FsPathInfo | undefined> { return undefined }
override async readText(target: FsTarget): Promise<string> {
return new TextDecoder().decode(this.files.get(target.targetKey) ?? new Uint8Array())
}
override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
const text = await this.readText(target)
return (async function* () { yield text })()
}
override async readBytes(target: FsTarget, _signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
const bytes = this.files.get(target.targetKey) ?? new Uint8Array()
if (bytes.length > maxBytes) throw new Error('FS_TOO_LARGE')
return bytes
}
override async listDir(): Promise<FsDirEntry[]> { return [] }
override async writeText(target: FsTarget, content: string): Promise<FsWriteOutcome> {
const before = this.files.get(target.targetKey) ?? null
this.files.set(target.targetKey, new TextEncoder().encode(content))
return { operation: before !== null ? 'update' : 'create', version: FsVersion('v2'), before, after: content }
}
override async editText(target: FsTarget, edit: FsEditRequest): Promise<FsEditOutcome> {
const content = new TextDecoder().decode(this.files.get(target.targetKey) ?? new Uint8Array())
const after = content.split(edit.oldString).join(edit.newString)
this.files.set(target.targetKey, new TextEncoder().encode(after))
return { version: FsVersion('v3'), before: content, after }
}
}
async function mount(opts: { files?: Record<string, Uint8Array> } = {}) {
const ctx = new Context()
await ctx.plugin(ToolRuntime)
const fs = new FakeFs()
if (opts.files) for (const [k, v] of Object.entries(opts.files)) fs.files.set(`key:${k}`, v)
await ctx.plugin(fs, {})
await ctx.plugin(ToolLab, {})
let counter = 0
const call = (name: string, args: unknown) => ctx.tools.execute({
signal: testToolSignal,
callId: CallId(`call-${++counter}`),
name,
arguments: args,
})
return { ctx, fs, call }
}
function jsonResponse(body: unknown, { status = 200, headers }: { status?: number; headers?: Record<string, string> } = {}): Response {
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } })
}
const pngBytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13])
afterEach(() => { vi.unstubAllGlobals() })
describe('lab_generate_image (ComfyUI)', () => {
it('queues the workflow and returns the served PNG URL after polling history', async () => {
const calls: string[] = []
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input)
calls.push(url)
if (url.endsWith('/prompt')) return jsonResponse({ prompt_id: 'p-1', node_errors: {} })
if (url.includes('/history/p-1')) return jsonResponse({ 'p-1': { outputs: { '9': { images: [{ filename: 'dsh_00001_.png' }] } } } })
throw new Error(`unexpected fetch ${url}`)
}))
const { fs } = await mountTarget({ files: { 'x.png': pngBytes } })
void fs
const out = await runComfyImage('http://192.168.31.240:8188', resolveImageArgs({ prompt: 'a cat' }), undefined, 30_000)
expect(out).toBe('http://192.168.31.240:8188/view?filename=dsh_00001_.png&subfolder=&type=output')
expect(calls.some(u => u.endsWith('/prompt'))).toBe(true)
})
it('rejects a workflow with non-empty node_errors', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ prompt_id: 'p-2', node_errors: { '3': ['bad'] } })))
await expect(runComfyImage('http://x', resolveImageArgs({ prompt: 'x' }), undefined, 30_000))
.rejects.toThrow(/workflow rejected/)
})
it('rejects non-positive integer dimensions and steps', () => {
expect(() => resolveImageArgs({ prompt: 'x', width: 0 })).toThrow(/width must be a positive integer/)
expect(() => resolveImageArgs({ prompt: 'x', steps: -1 })).toThrow(/steps must be a positive integer/)
})
it('uses the documented SDXL-turbo defaults in the workflow', () => {
const wf = buildWorkflow({ prompt: 'p', negative: 'negative prompt', width: 512, height: 512, steps: 4, seed: 123, model: 'sdxl_turbo.safetensors', filenamePrefix: 'dsh' })
const sampler = wf['3'] as { inputs: Record<string, unknown> }
expect(sampler.inputs.cfg).toBe(1.0)
expect(sampler.inputs.sampler_name).toBe('euler')
expect(sampler.inputs.scheduler).toBe('normal')
expect(sampler.inputs.steps).toBe(4)
})
})
describe('lab_ocr_pdf (Docling)', () => {
it('uploads a PDF and returns document.md_content after polling', async () => {
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/v1/convert/file/async')) return jsonResponse({ task_id: 't-1', task_status: 'pending' })
if (url.includes('/v1/status/poll/t-1')) return jsonResponse({ task_status: 'success' })
if (url.includes('/v1/result/t-1')) return jsonResponse({ status: 'success', errors: [], document: { filename: 'a.pdf', md_content: '# OCRed' } })
throw new Error(`unexpected ${url}`)
}))
const out = await runDoclingOcr('http://192.168.31.159:5001', new TextEncoder().encode('%PDF-1.4'), 'a.pdf', undefined, 30_000)
expect(out).toBe('# OCRed')
expect(globalThis.fetch).toHaveBeenCalled()
})
it('rejects an empty task id', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ task_id: undefined })))
await expect(runDoclingOcr('http://x', new Uint8Array(), 'a.pdf', undefined, 30_000)).rejects.toThrow(/no task_id/)
})
})
describe('lab_transcribe_audio (Whishper)', () => {
it('polls until result.text is non-empty', async () => {
let polls = 0
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.endsWith('/api/transcriptions')) return jsonResponse({ id: 'w-1', status: -1 })
polls += 1
if (polls === 1) return jsonResponse({ id: 'w-1', status: -1, result: { text: '' } })
return jsonResponse({ id: 'w-1', status: 0, result: { text: 'hello world', language: 'en', duration: 1.2 } })
}))
const out = await runWhishTranscribe('http://192.168.31.159:8082', new TextEncoder().encode('wav'), 'a.wav', undefined, undefined, undefined, 30_000)
expect(out).toBe('hello world')
})
it('rejects an upload with no id', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ id: undefined })))
await expect(runWhishTranscribe('http://x', new Uint8Array(), 'a.wav', undefined, undefined, undefined, 30_000))
.rejects.toThrow(/no id/)
})
})
// keep imports referenced for the fake implementation; these types come from
// @deepseek-ai/dsh-fs and are used by FakeFs above.
type _ = SandboxExecutionPolicy | SandboxMode
export { FakeFs }

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
]
}