diff --git a/index.js b/index.js new file mode 100644 index 0000000..a02343e --- /dev/null +++ b/index.js @@ -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/. */ + 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} 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/: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 '' + } +}