/** * 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. The `dsh-slopo` configuration: this module's own exported `Config`, which * the framework projects onto the Loader entry that composed it and * addresses by that entry's id. Nothing is registered for it — a section * assembled from a schema cannot disagree with the schema — and the page * that edits it lives in `client/client.js`. * 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 * simply has nowhere to persist a `configure` and says so. * * 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, sep } 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 { probeEmbeddingEndpoint } from './lib/probe.js' import { toJsonValue } from './lib/json.js' import { budgetRemedy, checkBudget, effectiveExcludes, parseIndexOutput, readIndexCounts, } from './lib/scope.js' import { resolveDshHome, workspaceStateDir, } from './lib/paths.js' import { explainFailure, findSlopo, installEnv, lastLogError, commandLine, runShell, 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. */ /** * Actions that default to a background job. * * Only the warm-up. `index` plus `embed` is the long, watchable step — the one * the user asked to be able to watch — while a review finds a warm index and * returns its report directly. Named after dsh-tool-bash's `run_in_background`, * because a model's prior is that backgrounding is something it ASKS for rather * than the default. */ const BACKGROUND_BY_DEFAULT = new Set(['prepare']) /** Every configuration field this plugin exposes. */ const SETTING_KEYS = [ 'binary', 'autoInstall', 'embeddingModel', 'embeddingDimensions', 'embeddingApiBase', 'embeddingApiKey', 'embeddingParamsJson', 'sourceDir', 'sourceDirExclude', 'useDefaultExcludes', 'maxIndexUnits', '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. * * Every field is marked `volatile()`, which is what puts it on the settings * page: the framework's form projection keeps only the fields whose nearest * volatile ancestor makes them editable without remounting the plugin, and an * entry with no such field is dropped from `settings.describe` entirely. An * unmarked field would still be *configurable* through the profile patch — it * would simply be invisible to every settings UI. * * `embeddingApiKey` additionally carries `role('secret')`, so the settings * wire never carries it: every remote read is redacted (`secrets` reports only * whether a value is set) and the browser sends the key one way, on write. */ export const Config = Schema.object({ /** Explicit slopo executable. Empty means autodiscover. */ binary: Schema.string().default('').volatile(), /** Install slopo into $DSH_HOME with uv when it is missing. */ autoInstall: Schema.boolean().default(true).volatile(), /** LiteLLM model string, e.g. `ollama/jina-embeddings-v2-base-code:latest`. */ embeddingModel: Schema.string().default('').volatile(), /** Output dimensions, which must match the model. */ embeddingDimensions: Schema.number().default(0).volatile(), /** Provider base URL, e.g. `http://127.0.0.1:11434` for Ollama. */ embeddingApiBase: Schema.string().default('').volatile(), /** Provider key. Passed to slopo in its environment, never written to disk. */ embeddingApiKey: Schema.string().default('').role('secret').volatile(), /** Extra LiteLLM embedding params, as a JSON object string. */ embeddingParamsJson: Schema.string().default('{}').volatile(), /** Code to index. Empty means the session's working directory. */ sourceDir: Schema.string().default('').volatile(), /** Extra gitignore-style paths to skip, on top of the defaults. */ sourceDirExclude: Schema.array(String).default([]).volatile(), /** Exclude vendored dependencies and build output. Off means slopo sees everything. */ useDefaultExcludes: Schema.boolean().default(true).volatile(), /** Refuse to embed more than this many units without being asked. 0 removes the limit. */ maxIndexUnits: Schema.number().default(5000).volatile(), /** Ignore file. Empty means inside the per-workspace state directory. */ ignoreFile: Schema.string().default('').volatile(), /** State directory. Empty means $DSH_HOME/dsh-slopo/workspaces/. */ stateDir: Schema.string().default('').volatile(), /** Milliseconds between embedding requests, for rate-limited providers. */ embeddingRequestDelay: Schema.number().default(0).volatile(), /** Cooperative budget for a foreground slopo run. */ timeoutMs: Schema.number().default(600_000).volatile(), }) /** 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: effectiveExcludes(settings), 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) // `config` is the live configuration, not a snapshot of the composition // layer: a settings write rewrites this entry's patch and the Loader // re-applies the plugin with the new values, so every read below sees the // deployment's current answer. There is deliberately no cached copy — a cache // is exactly what a reload invalidates. let writeSettings // The settings namespace IS this plugin's Config. The framework projects the // exported schema onto the Loader entry that composed it and addresses it by // that entry's id, which the bundle's patch declares as `dsh-slopo` — the // same string the browser card binds to. Nothing is registered here any more: // 0.1.7-rc.2 removed `ctx.settings.installSection`, because a section // assembled from a schema cannot disagree with the schema. // // `configure({ auto: false })` records that this entry ships its own page // (`client/client.js` registers one onto the Plugins page), so the deployment // must not also generate one for the same entry. ctx.inject(['settings'], (settingsCtx) => { try { settingsCtx.effect( () => settingsCtx.settings.configure({ auto: false }, ctx.fiber), 'dsh-slopo: settings page policy', ) 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. * * `installInto` is the directory the install runs in. It is deliberately NOT * the workspace: `uv tool install` has nothing to do with the project, and a * shell that must `cd` into a workspace that does not exist yet fails before * `uv` is ever reached. */ async function ensureSlopo(settings, run, installInto) { 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, workdir: installInto, }) 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. "prepare" starts a background job by default, because ' + 'indexing and embedding a repository takes minutes and slopo writes its progress to a log file rather ' + 'than to stdout; everything else returns its report directly.', 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.' }, run_in_background: { type: 'boolean', description: 'Return a job id instead of the result, for a run long enough to watch. Defaults to true for ' + '"prepare" (the warm-up) and false for everything else, which is quick once the index is warm.', }, 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} You will be notified when ` + `it settles; collect it with job_output for "${value.jobId}" (add wait: true if you are blocked on it).`, }, ] } 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 = config const paths = workspacePaths(dshHome, cwd, settings) const shell = ctx.get('shell') // Resolved once, up front, so `status` can probe without a second // resolution. The NO_SANDBOX_POLICY refusal stays with the actions that // actually execute something. const sandboxDefault = shell?.sandboxMode const policyService = sandboxDefault === undefined ? undefined : ctx.get('sandboxPolicy') const sandboxPolicy = policyService?.resolve( exec.agent === undefined ? {} : { session: exec.agent.session }, ) // Every return funnels through here. The harness enforces the declared // output schema against this value BEFORE materialising it, and an // explicit `undefined` property is not lossless JSON — which is exactly // how a probe result carrying `error: undefined` broke the status call. const foreground = (value) => toJsonValue({ 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) // Two pre-flight checks, because slopo reports a bad embedding // configuration as the two words "Embedding failed." on stdout with // the real reason buried in its agent log. That is a slow way to // discover a wrong provider prefix or a dimension mismatch. let slopoVersion if (binary !== undefined && shell !== undefined) { try { const versionResult = await runShell(shell, { command: commandLine([binary, '--version']), workdir: paths.cwd, timeoutMs: 30_000, ...(sandboxPolicy === undefined ? {} : { sandboxPolicy }), }) slopoVersion = versionResult.exitCode === 0 ? versionResult.stdout.text.trim().split('\n')[0] : `exit code ${versionResult.exitCode}: ${versionResult.stderr.text.trim().slice(0, 200)}` } catch (error) { slopoVersion = `could not run: ${error?.message ?? error}` } } // A direct fetch rather than the shell seam: the probe is one small // HTTP request and does not belong behind a sandbox boundary that // exists to confine filesystem writes. const endpoint = missing.length === 0 ? await probeEmbeddingEndpoint({ apiBase: typeof settings.embeddingApiBase === 'string' ? settings.embeddingApiBase : '', model: String(slopoConfig.embedding_model ?? ''), dimensions: Number(slopoConfig.embedding_dimensions ?? 0), apiKey: typeof settings.embeddingApiKey === 'string' ? settings.embeddingApiKey : '', }) : { checked: false, skipped: 'nothing is configured yet' } let message if (missing.length > 0) { message = `Not configured yet. Missing: ${missing.join(', ')}. Run this tool with action "configure" ` + 'to set embeddingModel and embeddingDimensions, then "prepare".' } else if (endpoint.ok === false) { message = `Configured, but the embedding backend failed its check: ${endpoint.error}. ` + `(probed ${endpoint.url} with model "${endpoint.sentModel}")` } else if (endpoint.checked === true) { message = `Ready. ${binary ?? 'slopo'} will use "${slopoConfig.embedding_model}" at ` + `${Number(slopoConfig.embedding_dimensions)} dimensions, confirmed against ${endpoint.url}.` } else { message = `Configured against "${slopoConfig.embedding_model}" at ` + `${Number(slopoConfig.embedding_dimensions)} dimensions. Nothing was probed: ${endpoint.skipped}.` } return foreground({ ok: missing.length === 0 && endpoint.ok !== false, 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, slopoVersion: slopoVersion ?? 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 !== '', embeddingEndpoint: endpoint, // What slopo will actually look at, and the limit on how much of // it may be embedded without being asked. Both are the levers a // runaway scope is fixed with, so status has to name them. sourceDirExclude: effectiveExcludes(settings), useDefaultExcludes: settings.useDefaultExcludes !== false, maxIndexUnits: Number(settings.maxIndexUnits), missingSettings: missing, problems, }, message, }) } 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.', }) } // Resolve the CALLING SESSION's sandbox policy, the way the bash tool // does. `dsh-bash-sandbox.resolve()` stamps // `request.sandboxPolicy ?? ctx.sandboxPolicy.resolve()`, so omitting it // does not mean "unconfined" — it silently substitutes the DEPLOYMENT // default (`DSH_PERMISSION_MODE ?? 'workspace-write'`) for the session's // preset. That confines a call the session asked to run unconfined, and // on a host with no sandbox runner it fails with SandboxUnavailableError // before slopo is ever reached. if (sandboxDefault !== undefined && policyService === undefined) { return foreground({ ok: false, exitCode: null, errorCode: 'NO_SANDBOX_POLICY', message: 'The mounted shell executor confines commands, but ctx.sandboxPolicy is absent.', remedy: 'Compose @deepseek-ai/dsh-sandbox-policy, or mount an unconfining shell executor.', }) } // Slopo's state is deliberately outside the workspace, which a // workspace-write sandbox forbids writing to. Say so before a run and // name both ways out, rather than letting a write fail mid-embed. if ( sandboxPolicy !== undefined && sandboxPolicy.mode !== 'danger-full-access' && !isInside(sandboxPolicy.workspaceRoot, paths.stateDir) ) { return foreground({ ok: false, exitCode: null, errorCode: 'STATE_OUTSIDE_SANDBOX', message: `This session runs under the "${sandboxPolicy.mode}" sandbox, which permits writes only under ` + `${sandboxPolicy.workspaceRoot} — but slopo's index, config and log live in ${paths.stateDir}.`, remedy: 'Either run this session under the danger-full-access preset, or set `stateDir` to a directory ' + 'inside the workspace using action "configure".', }) } /** * Run one command through the shell seam. * * `runShell` returns the seam's own result; everything below wants the * flattened strings, so the adaptation happens once, here. * * `workdir` is overridable because installing slopo has nothing to do * with the workspace: the workspace may not even exist yet on a fresh * session, and a shell that cannot `cd` there fails before `uv` is * reached. The plugin's own state directory is always a real directory * by the time an install runs. */ const run = async (command, { env, timeoutMs, signal, workdir } = {}) => { const result = await runShell(shell, { command, workdir: 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 }), // Omitting this substitutes the deployment default; see above. ...(sandboxPolicy === undefined ? {} : { sandboxPolicy }), }) return { exitCode: result.exitCode, stdout: result.stdout.text, stderr: result.stderr.text, truncated: 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 }, }) } // The state directory and the config file come FIRST, before the // executable is resolved. The config is a pure function of the // settings — nothing about it needs slopo to exist — and writing it // early means a run that stops at "not installed" still leaves the // resolved configuration on disk to look at. It also gives the install // below a real working directory to run in. await mkdir(paths.stateDir, { recursive: true }) await writeFile(paths.configPath, renderSlopoConfig(slopoConfig), 'utf8') const binary = await ensureSlopo(settings, run, paths.stateDir) if (typeof binary !== 'string') { return foreground({ ok: false, exitCode: null, errorCode: binary.error, message: binary.remedy, remedy: binary.detail }) } const env = typeof settings.embeddingApiKey === 'string' && settings.embeddingApiKey !== '' ? { SLOPO_EMBEDDING_API_KEY: settings.embeddingApiKey } : undefined // Every action indexes FIRST, so the size gate sees the real scope // before any embedding is paid for. Slopo's own >1000 guard cannot do // this: it measures the pre-existing backlog, and a fresh database has // no units at all — which is exactly when a runaway costs the most. const excludes = effectiveExcludes(settings) const indexArgs = slopoArgs(paths.configPath, 'index') const restArgs = args.action === 'prepare' ? [slopoArgs(paths.configPath, 'embed')] : [slopoArgs(paths.configPath, args.action, { base: args.base, cluster: args.cluster })] /** * Index, check the budget, then run the action. * Returns a refusal instead of running when the scope is too large. */ const runScoped = async (signal) => { const indexed = await run(commandLine([binary, ...indexArgs]), { env, signal }) const parsed = parseIndexOutput(indexed.stdout) // Prefer the database. `Indexed N` counts only what was NEWLY indexed, // so it reads zero on a warm database whose backlog is still full — // exactly the state a refused run leaves behind. const fromDb = readIndexCounts(paths.dbFile) const counted = fromDb === undefined ? parsed : { units: fromDb.unembedded, files: fromDb.files, inScope: fromDb.units, embedded: fromDb.embedded } if (indexed.exitCode !== 0) return { refused: undefined, counted, outcome: indexed } const decision = checkBudget(counted, Number(settings.maxIndexUnits), excludes) if (!decision.allowed) return { refused: decision.reason, counted, outcome: undefined } const restCommand = restArgs.map((argv) => commandLine([binary, ...argv])).join(' && ') return { refused: undefined, counted, outcome: await run(restCommand, { env, signal }) } } const scopeRefusal = (reason, counted) => foreground({ ok: false, exitCode: null, errorCode: 'SCOPE_TOO_LARGE', message: reason, remedy: budgetRemedy(Number(settings.maxIndexUnits), excludes), details: { counted, excludes, maxIndexUnits: Number(settings.maxIndexUnits) }, }) // Background is for the long warm-up, not for every action. A completion // notice is best-effort: a KILLED job deliberately produces none, and an // agent whose turn has ended may have the message injected rather than be // woken for it. A review that returns its report directly is worth more // than a job id the caller has to remember to collect. const wantsBackground = args.run_in_background ?? BACKGROUND_BY_DEFAULT.has(args.action) const jobs = ctx.get('jobs') if (wantsBackground && jobs !== undefined) { const jobId = jobs.start({ kind: 'slopo', label: `slopo ${args.action}`, // Ownership is fenced by SESSION ID, not by the agent object: the // registry cancels and awaits the job when that session's agent is // disposed, and it looks the id up itself. Passing `exec.agent` // here registered a job no session could collect or stop. ...(exec.agent === undefined ? {} : { owner: exec.agent.id }), run: () => { const controller = new AbortController() // `done` resolves to a JobOutcome: `{ status, detail, result }`. // The report rides `result`, which the registry hands to the // model on its first read after settlement — read it back with // job_output. (The field is `result`, not `output`: the ring // carries a job's stream, and `result` carries the return value // of one whose product is a value rather than a stream.) const done = (async () => { const { refused, counted, outcome } = await runScoped(controller.signal) const logDetail = await readLogTail(paths.logFile) if (refused !== undefined) { return { status: 'failed', detail: 'SCOPE_TOO_LARGE', result: `${refused}\n\n${budgetRemedy(Number(settings.maxIndexUnits), excludes)}`, } } if (outcome.exitCode === 0) { return { status: 'completed', detail: 'exit code: 0', result: outcome.stdout.trim() || 'No duplicates found' } } const explained = explainFailure({ stderr: outcome.stderr, stdout: outcome.stdout, logDetail }) return { status: 'failed', detail: explained.code, result: [explained.message, explained.remedy, logDetail.trim()].filter((line) => line !== '').join('\n\n'), } })() return { cancel: () => controller.abort(), done } }, }) return toJsonValue({ kind: 'background', action: args.action, jobId, logPath: paths.logFile, configPath: paths.configPath }) } const { refused, counted, outcome } = await runScoped(exec.signal) const logDetail = await readLogTail(paths.logFile) if (refused !== undefined) return scopeRefusal(refused, counted) if (outcome.exitCode === 0) { return foreground({ ok: true, exitCode: 0, report: outcome.stdout.trim() || 'No duplicates found' }) } const explained = explainFailure({ stderr: outcome.stderr, stdout: outcome.stdout, logDetail }) return foreground({ ok: false, exitCode: outcome.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 '' } } /** True when `path` is `root` itself or sits underneath it. */ function isInside(root, path) { const prefix = root.endsWith(sep) ? root : root + sep return path === root || path.startsWith(prefix) }