Add the plugin host: three skills, a settings namespace, and one tool

The three skills are ports of slopo's own agent configs, published
user-invocable and NOT model-invocable. Slopo makes that choice upstream and the
reason holds here: every one of these commands can spend minutes embedding, so a
model that decides to "check for duplication" mid-task can do it repeatedly.

settings goes through ctx.inject(['settings'], ...) rather than the top-level
inject, so a profile without a settings provider still gets the tool and the
skills and degrades to the composition config. A required dependency would have
suppressed apply entirely -- a silent whole-plugin failure for an optional
capability.

The tool resolves the CLI, materialises the config, and runs the long actions as
background jobs. Background is the right default because embedding takes minutes
and slopo writes progress to a log file rather than stdout, so there is nothing
to stream; the job handle carries the log path instead.

Nothing is defaulted. Guessing an embedding backend would mean guessing where
money is spent, so status says what is missing and configure sets it.
This commit is contained in:
dsh-slopo agent committed 2026-09-22 06:11:17 +01:00
1 parent e956a8c121
commit 31991143e4
1 file changed
+628
+628
View File
@@ -0,0 +1,628 @@
/**
* dsh-slopo — slopo as a DeepSeek Harness bundle.
*
* Three contributions:
*
* 1. Three skills on `ctx.skills`, ported from slopo's own agent configs.
* All three are user-invocable and deliberately NOT model-invocable,
* matching upstream: every one of these commands can spend minutes
* embedding, so the human decides when.
* 2. A `dsh-slopo` settings namespace on `ctx.settings`, so the embedding
* backend is editable at runtime instead of baked into a YAML layer.
* 3. One `slopo` tool that resolves the CLI, materialises the config, and
* runs the long work as a background job.
*
* Why `ctx.inject(['settings'], ...)` rather than `inject: ['settings']`: a
* Cordis plugin with an unsatisfied required dependency never runs `apply` at
* all. Putting `settings` in the top-level inject would make the tool and the
* skills disappear on any profile without a settings provider, which is a
* silent whole-plugin failure for an optional capability. The optional form
* degrades to the composition config instead.
*
* Plain ESM, no build step — deliberately. `dsh plugin add git+...` fetches the
* source tree, not build output, and a `prepare` script would make every user
* allowlist a build before the first install succeeds.
*/
import { existsSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineTool } from '@deepseek-ai/dsh-tools'
import Schema from '@deepseek-ai/schemastery'
import { missingRequired, normalizeConfig, renderSlopoConfig } from './lib/config.js'
import {
PLUGIN_NAME,
resolveDshHome,
workspaceKey,
workspaceStateDir,
} from './lib/paths.js'
import {
explainFailure,
findSlopo,
installEnv,
lastLogError,
commandLine,
slopoArgs,
uvInstallArgs,
} from './lib/slopo.js'
/** Rank of a packaged bundle's skills. Hardcoded, not imported. */
const BUNDLED_SKILL_RANK = 600
/** Reserved provider name inside dsh; registering it throws. */
const PROVIDER_NAME = 'dsh-slopo'
/** Settings namespace. Must match /^[a-z][a-z0-9-]*$/. */
const SETTINGS_NAMESPACE = 'dsh-slopo'
const SKILL_NAMES = ['slopo-review', 'slopo-analyze-ignore', 'slopo-analyze-one']
const SKILLS_DIR = fileURLToPath(new URL('./skills/', import.meta.url))
/** Long-running actions, in the order the tool description advertises them. */
const BACKGROUND_ACTIONS = new Set(['prepare', 'review', 'analyze', 'analyze-one'])
/** Every configuration field this plugin exposes. */
const SETTING_KEYS = [
'binary',
'autoInstall',
'embeddingModel',
'embeddingDimensions',
'embeddingApiBase',
'embeddingApiKey',
'embeddingParamsJson',
'sourceDir',
'sourceDirExclude',
'ignoreFile',
'stateDir',
'embeddingRequestDelay',
'timeoutMs',
]
/**
* The plugin's composition schema.
*
* Nothing meaningful is defaulted: slopo itself requires `source_dir`,
* `embedding_model` and `embedding_dimensions`, and guessing an embedding
* backend would mean guessing where money is spent. Unset means unset, and the
* tool says so.
*/
export const Config = Schema.object({
/** Explicit slopo executable. Empty means autodiscover. */
binary: Schema.string().default(''),
/** Install slopo into $DSH_HOME with uv when it is missing. */
autoInstall: Schema.boolean().default(true),
/** LiteLLM model string, e.g. `ollama/jina-embeddings-v2-base-code:latest`. */
embeddingModel: Schema.string().default(''),
/** Output dimensions, which must match the model. */
embeddingDimensions: Schema.number().default(0),
/** Provider base URL, e.g. `http://127.0.0.1:11434` for Ollama. */
embeddingApiBase: Schema.string().default(''),
/** Provider key. Passed to slopo in its environment, never written to disk. */
embeddingApiKey: Schema.string().default(''),
/** Extra LiteLLM embedding params, as a JSON object string. */
embeddingParamsJson: Schema.string().default('{}'),
/** Code to index. Empty means the session's working directory. */
sourceDir: Schema.string().default(''),
/** gitignore-style paths to skip. */
sourceDirExclude: Schema.array(String).default([]),
/** Ignore file. Empty means inside the per-workspace state directory. */
ignoreFile: Schema.string().default(''),
/** State directory. Empty means $DSH_HOME/dsh-slopo/workspaces/<workspace>. */
stateDir: Schema.string().default(''),
/** Milliseconds between embedding requests, for rate-limited providers. */
embeddingRequestDelay: Schema.number().default(0),
/** Cooperative budget for a foreground slopo run. */
timeoutMs: Schema.number().default(600_000),
})
/** Resolve the harness home, preferring the harness's own resolver. */
function dshHomeOf(ctx) {
try {
const homePath = ctx.get('dshHomePath')
if (typeof homePath === 'function') {
const resolved = homePath()
if (typeof resolved === 'string' && resolved !== '') return resolved
}
} catch {
// The service is declared optional; absence is expected outside dsh-app-boot.
}
return resolveDshHome()
}
/** Every path one workspace's slopo state occupies. */
export function workspacePaths(dshHome, cwd, settings) {
const stateDir =
typeof settings.stateDir === 'string' && settings.stateDir.trim() !== ''
? settings.stateDir.trim()
: workspaceStateDir(dshHome, cwd)
return {
stateDir,
cwd,
sourceDir: typeof settings.sourceDir === 'string' && settings.sourceDir.trim() !== ''
? settings.sourceDir.trim()
: cwd,
configPath: join(stateDir, 'slopo.conf.yaml'),
dbFile: join(stateDir, 'slopo.db'),
reportDir: join(stateDir, 'report'),
ignoreFile:
typeof settings.ignoreFile === 'string' && settings.ignoreFile.trim() !== ''
? settings.ignoreFile.trim()
: join(stateDir, 'slopo.ignore.txt'),
logFile: join(stateDir, 'slopo.agent.log'),
}
}
/** Parse the JSON params field, tolerating an unset or malformed value. */
function embeddingParams(settings, problems) {
const raw = typeof settings.embeddingParamsJson === 'string' ? settings.embeddingParamsJson.trim() : ''
const params = {}
if (raw !== '' && raw !== '{}') {
try {
const parsed = JSON.parse(raw)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
problems.push('embeddingParamsJson must be a JSON object')
} else {
Object.assign(params, parsed)
}
} catch (error) {
problems.push(`embeddingParamsJson is not valid JSON: ${error.message}`)
}
}
const apiBase = typeof settings.embeddingApiBase === 'string' ? settings.embeddingApiBase.trim() : ''
if (apiBase !== '') params.api_base = apiBase
return params
}
/**
* Map this plugin's settings onto slopo's own key names.
*
* The names differ on purpose: dsh settings are camelCase by convention while
* slopo's config file is snake_case, and slopo treats an unknown key as a hard
* error, so this mapping is the only place a name can go wrong.
*/
export function toSlopoConfig(settings, paths, problems = []) {
const config = {
source_dir: paths.sourceDir,
source_dir_exclude: Array.isArray(settings.sourceDirExclude) ? settings.sourceDirExclude : [],
db_file: paths.dbFile,
report_dir: paths.reportDir,
ignore_file: paths.ignoreFile,
agent_log_file: paths.logFile,
embedding_model: typeof settings.embeddingModel === 'string' ? settings.embeddingModel.trim() : '',
// Omitted rather than written as 0 when unset: slopo requires a positive
// integer, and pruneConfig drops an undefined value entirely.
embedding_dimensions: Number(settings.embeddingDimensions) > 0 ? Number(settings.embeddingDimensions) : undefined,
embedding_params: embeddingParams(settings, problems),
embedding_request_delay: Number(settings.embeddingRequestDelay) || 0,
}
const { config: normalized, problems: numericProblems } = normalizeConfig(config)
problems.push(...numericProblems)
delete normalized.embedding_api_key
return normalized
}
/** Read the `description:` line out of a SKILL.md frontmatter block. */
export function readDescription(body) {
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(body)
if (frontmatter === null) return undefined
const line = /^description:[ \t]*(.+)$/m.exec(frontmatter[1])
if (line === null) return undefined
const raw = line[1].trim()
if (raw.startsWith('"') && raw.endsWith('"') && raw.length > 1) {
return raw.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
}
if (raw.startsWith("'") && raw.endsWith("'") && raw.length > 1) return raw.slice(1, -1).replace(/''/g, "'")
return raw
}
const SKILL_INVOCATION = Object.freeze({ modelInvocable: false, userInvocable: true })
const RESOURCE_BASE = Object.freeze({ kind: 'directory', path: SKILLS_DIR })
/** Load one packaged skill, or `undefined` when it cannot be read. */
async function loadSkill(name, signal) {
const url = new URL(`${name}/SKILL.md`, new URL('./skills/', import.meta.url))
try {
const content = await readFile(url, { encoding: 'utf8', signal })
const description = readDescription(content)
if (description === undefined) return undefined
return { description, content, url }
} catch (error) {
if (error?.name === 'AbortError') throw error
return undefined
}
}
const skillsProvider = {
name: PROVIDER_NAME,
async list(options = {}) {
const found = []
for (const name of SKILL_NAMES) {
const skill = await loadSkill(name, options.signal)
if (skill === undefined) continue
found.push({
name,
description: skill.description,
invocation: SKILL_INVOCATION,
provider: PROVIDER_NAME,
source: 'bundled',
resourceBase: RESOURCE_BASE,
rank: BUNDLED_SKILL_RANK,
locator: name,
})
}
return found
},
async get(candidate, options = {}) {
const name = typeof candidate.locator === 'string' ? candidate.locator : candidate.name
const skill = await loadSkill(name, options.signal)
if (skill === undefined) return undefined
return {
name,
description: skill.description,
invocation: SKILL_INVOCATION,
provider: PROVIDER_NAME,
source: 'bundled',
resourceBase: RESOURCE_BASE,
content: skill.content,
}
},
}
const BACKGROUND_OUTPUT = {
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'background' },
action: { type: 'string', required: true },
jobId: { type: 'string', required: true },
logPath: { type: 'string' },
configPath: { type: 'string' },
},
}
const FOREGROUND_OUTPUT = {
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'foreground' },
action: { type: 'string', required: true },
ok: { type: 'boolean', required: true },
exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
report: { type: 'string' },
message: { type: 'string' },
errorCode: { type: 'string' },
remedy: { type: 'string' },
logError: { type: 'string' },
configPath: { type: 'string' },
logPath: { type: 'string' },
details: { type: 'json' },
},
}
export const name = 'dsh-slopo'
export const inject = ['tools', 'skills']
/**
* Register the skills, the settings namespace, and the tool.
*
* @param {import('@deepseek-ai/cordis').Context} ctx
* @param {Record<string, unknown>} config validated composition config
*/
export function apply(ctx, config) {
const logger = ctx.logger('dsh-slopo')
const dshHome = dshHomeOf(ctx)
// The authoritative settings source. Starts as the composition config and is
// replaced by the settings scope's thunk once a provider attaches; if none
// ever does, everything below still works off the composition layer.
let readSettings = () => config
let writeSettings
ctx.inject(['settings'], (settingsCtx) => {
try {
settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config, {
setSource: (current) => {
readSettings = current
},
onChange: () => {},
})
writeSettings = (patch) => settingsCtx.settings.update(SETTINGS_NAMESPACE, patch)
} catch (error) {
logger.warn('dsh-slopo: settings namespace unavailable: %s', error?.message ?? error)
}
})
ctx.skills.registerProvider(() => skillsProvider)
/** The slopo executable, installing it first when allowed. */
async function ensureSlopo(settings, run) {
const configured = typeof settings.binary === 'string' ? settings.binary.trim() : ''
if (configured !== '' && existsSync(configured)) return configured
const found = findSlopo(dshHome)
if (found !== undefined) return found
if (settings.autoInstall !== true) {
return { error: 'SLOPO_NOT_INSTALLED', remedy: 'Set `autoInstall`, or install slopo yourself and set `binary`.' }
}
const install = await run(commandLine(['uv', ...uvInstallArgs()]), { env: installEnv(dshHome), timeoutMs: 900_000 })
if (install.exitCode !== 0) {
return {
error: 'SLOPO_INSTALL_FAILED',
remedy:
'Install slopo manually with `uv tool install slopo --python 3.12`, or set the `binary` setting to ' +
'an existing slopo executable.',
detail: install.stderr || install.stdout,
}
}
const installed = findSlopo(dshHome)
if (installed === undefined) {
return { error: 'SLOPO_INSTALL_MISSING', remedy: 'uv reported success but no executable appeared.' }
}
return installed
}
ctx.tools.register(
defineTool({
name: 'slopo',
description:
'Run slopo, an embedding-based duplicate-code detector, against the current workspace. ' +
'Actions: "status" reports the resolved configuration and index state; "configure" updates the ' +
'dsh-slopo settings; "prepare" runs the local index and then embeds (the slow, possibly costly ' +
'step); "review" finds similar code involving Git changes; "analyze" triages the whole codebase; ' +
'"analyze-one" reports a single cluster by hash; "update" upgrades the slopo CLI. Slopo is ' +
'installed and driven as a separate process. Long actions run as background jobs because ' +
'embedding a repository takes minutes and slopo writes its progress to a log file rather than ' +
'to stdout.',
parameters: {
action: {
type: 'string',
required: true,
enum: ['status', 'configure', 'prepare', 'review', 'analyze', 'analyze-one', 'update'],
description: 'What to do.',
},
base: { type: 'string', description: 'Git ref to compare against for "review". Defaults to HEAD.' },
cluster: { type: 'string', description: 'Cluster hash for "analyze-one". Omit for the next cluster.' },
foreground: {
type: 'boolean',
description: 'Run a long action inline instead of as a background job. Defaults to false.',
},
config: {
type: 'json',
description:
'Settings patch for "configure", using this plugin\'s own camelCase field names, for example ' +
'{"embeddingModel":"ollama/nomic-embed-text:latest","embeddingDimensions":768}.',
},
},
output: {
schema: { oneOf: [BACKGROUND_OUTPUT, FOREGROUND_OUTPUT] },
render: (_args, value) => {
if (value.kind === 'background') {
const log = value.logPath === undefined ? '' : ` Progress is written to ${value.logPath}.`
return [
{
type: 'text',
text: `Started slopo ${value.action} as background job ${value.jobId}.${log} Read the job output when it settles.`,
},
]
}
const parts = []
if (typeof value.report === 'string' && value.report !== '') parts.push(value.report)
if (typeof value.message === 'string' && value.message !== '') parts.push(value.message)
if (typeof value.logError === 'string' && value.logError !== '') parts.push(`Slopo log: ${value.logError}`)
if (typeof value.remedy === 'string' && value.remedy !== '') parts.push(`Next: ${value.remedy}`)
if (typeof value.logPath === 'string' && value.logPath !== '') parts.push(`(agent log: ${value.logPath})`)
if (parts.length === 0) parts.push(`slopo ${value.action} finished with exit code ${value.exitCode}.`)
return [{ type: 'text', text: parts.join('\n\n') }]
},
},
async execute(args, exec) {
const cwd = exec.agent?.session.header.cwd ?? process.cwd()
const settings = readSettings() ?? config
const paths = workspacePaths(dshHome, cwd, settings)
const shell = ctx.get('shell')
const foreground = (value) => ({ kind: 'foreground', action: args.action, configPath: paths.configPath, logPath: paths.logFile, ...value })
if (args.action === 'status') {
const binary = findSlopo(dshHome)
const problems = []
const slopoConfig = toSlopoConfig(settings, paths, problems)
const missing = missingRequired(slopoConfig)
return foreground({
ok: missing.length === 0,
exitCode: null,
details: {
dshHome,
cwd,
stateDir: paths.stateDir,
configPath: paths.configPath,
database: existsSync(paths.dbFile) ? paths.dbFile : null,
ignoreFile: existsSync(paths.ignoreFile) ? paths.ignoreFile : null,
agentLog: existsSync(paths.logFile) ? paths.logFile : null,
slopoBinary: binary ?? null,
slopoInstalledInto: binary === undefined ? null : dshHome,
settingsNamespace: writeSettings === undefined ? null : SETTINGS_NAMESPACE,
embeddingModel: slopoConfig.embedding_model ?? null,
embeddingDimensions: slopoConfig.embedding_dimensions ?? null,
apiKeyConfigured: typeof settings.embeddingApiKey === 'string' && settings.embeddingApiKey !== '',
missingSettings: missing,
problems,
},
message:
missing.length === 0
? `${binary === undefined ? 'slopo is not installed yet; it will be installed on first use.' : `slopo: ${binary}`}`
: `Not configured yet. Missing: ${missing.join(', ')}. Run this tool with action "configure" ` +
'to set embeddingModel and embeddingDimensions, then "prepare".',
})
}
if (args.action === 'configure') {
const patch = args.config
if (patch === undefined || patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
return foreground({ ok: false, exitCode: null, message: 'The `config` parameter must be an object.', errorCode: 'BAD_PATCH' })
}
const unknown = Object.keys(patch).filter((key) => !SETTING_KEYS.includes(key))
if (unknown.length > 0) {
return foreground({
ok: false,
exitCode: null,
errorCode: 'UNKNOWN_SETTING',
message: `Unknown setting(s): ${unknown.join(', ')}.`,
remedy: `Known settings: ${SETTING_KEYS.join(', ')}.`,
})
}
if (writeSettings === undefined) {
return foreground({
ok: false,
exitCode: null,
errorCode: 'NO_SETTINGS_PROVIDER',
message: 'No settings provider is mounted, so configuration cannot be persisted at runtime.',
remedy: `Set these values in the dsh-slopo row of your cordis layer instead: ${JSON.stringify(patch)}`,
})
}
await writeSettings(patch)
return foreground({ ok: true, exitCode: 0, message: `Updated ${Object.keys(patch).join(', ')}.` })
}
if (shell === undefined) {
return foreground({
ok: false,
exitCode: null,
errorCode: 'NO_SHELL',
message: 'No shell executor is available, so slopo cannot be run.',
remedy: 'Load @deepseek-ai/dsh-bash-local (or another shell provider) into this profile.',
})
}
/** Run one command through the shell seam. */
const run = async (command, { env, timeoutMs, signal } = {}) => {
const result = await shell.run(
shell.resolve({
command,
workdir: paths.cwd,
timeoutMs: timeoutMs ?? (Number(settings.timeoutMs) || 600_000),
...(signal === undefined ? {} : { signal }),
// Merges over a parent-derived base, so PATH and HOME survive.
...(env === undefined ? {} : { env }),
}),
)
return {
exitCode: result.exitCode,
stdout: result.stdout?.text ?? '',
stderr: result.stderr?.text ?? '',
truncated: Boolean(result.stdout?.truncated || result.stderr?.truncated),
timedOut: Boolean(result.timedOut),
}
}
if (args.action === 'update') {
const result = await run(commandLine(['uv', ...uvInstallArgs({ upgrade: true })]), {
env: installEnv(dshHome),
timeoutMs: 900_000,
signal: exec.signal,
})
return foreground({
ok: result.exitCode === 0,
exitCode: result.exitCode,
report: result.stdout.trim(),
errorCode: result.exitCode === 0 ? undefined : 'UPDATE_FAILED',
remedy: result.exitCode === 0 ? undefined : result.stderr.trim(),
})
}
// Everything below actually needs slopo.
const problems = []
const slopoConfig = toSlopoConfig(settings, paths, problems)
const missing = missingRequired(slopoConfig)
if (missing.length > 0) {
return foreground({
ok: false,
exitCode: null,
errorCode: 'NOT_CONFIGURED',
message: `Slopo is not configured for this workspace yet. Missing: ${missing.join(', ')}.`,
remedy:
'Call this tool with action "configure" first, for example ' +
'{"embeddingModel":"ollama/<model>:latest","embeddingDimensions":768,"embeddingApiBase":"http://127.0.0.1:11434"}.',
details: { missing, problems },
})
}
const binary = await ensureSlopo(settings, run)
if (typeof binary !== 'string') {
return foreground({ ok: false, exitCode: null, errorCode: binary.error, message: binary.remedy, remedy: binary.detail })
}
await mkdir(paths.stateDir, { recursive: true })
await writeFile(paths.configPath, renderSlopoConfig(slopoConfig), 'utf8')
const env =
typeof settings.embeddingApiKey === 'string' && settings.embeddingApiKey !== ''
? { SLOPO_EMBEDDING_API_KEY: settings.embeddingApiKey }
: undefined
const steps =
args.action === 'prepare'
? [slopoArgs(paths.configPath, 'index'), slopoArgs(paths.configPath, 'embed')]
: [slopoArgs(paths.configPath, args.action, { base: args.base, cluster: args.cluster })]
const command = steps.map((argv) => commandLine([binary, ...argv])).join(' && ')
const wantsBackground = args.foreground !== true && BACKGROUND_ACTIONS.has(args.action)
const jobs = ctx.get('jobs')
if (wantsBackground && jobs !== undefined) {
const jobId = jobs.start({
kind: 'slopo',
label: `slopo ${args.action}`,
...(exec.agent === undefined ? {} : { owner: exec.agent }),
run: () => {
const controller = new AbortController()
const done = (async () => {
const result = await run(command, { env, signal: controller.signal })
const logDetail = await readLogTail(paths.logFile)
if (result.exitCode === 0) {
return { status: 'completed', detail: `exit code: 0`, output: result.stdout.trim() || 'No duplicates found' }
}
const explained = explainFailure({ stderr: result.stderr, stdout: result.stdout, logDetail })
return {
status: 'failed',
detail: explained.code,
output: [explained.message, explained.remedy, logDetail.trim()].filter((line) => line !== '').join('\n\n'),
}
})()
return { cancel: () => controller.abort(), done }
},
})
return { kind: 'background', action: args.action, jobId, logPath: paths.logFile, configPath: paths.configPath }
}
const result = await run(command, { env, signal: exec.signal })
const logDetail = await readLogTail(paths.logFile)
if (result.exitCode === 0) {
return foreground({ ok: true, exitCode: 0, report: result.stdout.trim() || 'No duplicates found' })
}
const explained = explainFailure({ stderr: result.stderr, stdout: result.stdout, logDetail })
return foreground({
ok: false,
exitCode: result.exitCode,
errorCode: explained.code,
message: explained.message,
remedy: explained.remedy,
logError: lastLogError(logDetail),
})
},
}),
)
}
/** Read the tail of slopo's agent log, tolerating a missing file. */
async function readLogTail(path) {
try {
const contents = await readFile(path, 'utf8')
return contents.length > 16_000 ? contents.slice(-16_000) : contents
} catch {
return ''
}
}