Files
dsh-slopo/index.js
T
dsh-slopo agent 8f68ef4e37 fix: read the settings values, not the references they arrive as
Every field is `volatile()`, which is what puts the settings card on the
Plugins page — and what makes the resolved config carry a reference per field
rather than a value. Every read was a plain-value test (`typeof x === 'string'`,
`Number(x)`, `x !== true`), so all of them were false and the plugin behaved as
if nothing had been configured.

The card displayed and persisted correctly the whole time, because the settings
wire unwraps references for display; only the plugin's own reads were broken.
Reads now go through `configValue()`, and the tool takes a fresh snapshot per
call, which is what lets an edit reach a running instance.
2026-09-26 07:13:36 +01:00

974 lines
43 KiB
JavaScript

/**
* 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.
*
* `volatile()` has a second consequence, and it is the one that bites: the
* resolved config does not carry VALUES for these fields. Each arrives as a
* reference object with a `get()`, because that is what lets a settings write
* land in the running instance without re-applying the plugin. So a plain-value
* read (`typeof config.embeddingModel === 'string'`) is false for every field,
* and the plugin silently behaves as if nothing were configured. Every read
* goes through {@link configValue} or {@link settingsSnapshot}.
*
* `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/<workspace>. */
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(),
})
/**
* Read one configuration field.
*
* A volatile field is a reference in the running config, not a plain value; a
* non-volatile one (or a config object a test handed straight to `apply`) is
* the value itself. Both are accepted so the same path serves a real profile
* and a direct call. Duck-typed on `get` rather than importing cosmokit's
* `isVolatile`, because the only thing this plugin needs to know about a
* reference is how to read it.
*
* @param {unknown} value a field of the resolved config
* @returns {unknown} the current value
*/
export function configValue(value) {
return value !== null && typeof value === 'object' && typeof value.get === 'function' ? value.get() : value
}
/**
* A plain snapshot of the whole settings section.
*
* Taken per tool call rather than cached, because that is exactly what a
* volatile field buys: the running instance's references are written in place
* when the document changes, so the next snapshot sees the new value without
* the plugin being remounted.
*
* @param {Record<string, any>} config the resolved composition config
* @returns {Record<string, any>} every declared field, unwrapped
*/
export function settingsSnapshot(config) {
const snapshot = {}
for (const key of Object.keys(config ?? {})) snapshot[key] = configValue(config[key])
return snapshot
}
/** 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.
*
* `config` may be the live composition config — whose fields are all volatile
* references — or the plain snapshot a tool call already took of it. Both are
* accepted, because this is exported and the next caller cannot be assumed to
* know the difference; a plain value passes through `settingsSnapshot` intact.
*/
export function workspacePaths(dshHome, cwd, config) {
const settings = settingsSnapshot(config)
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.
*
* `config` is accepted in either shape for the same reason `workspacePaths`
* is: exported, so called with a live volatile config or with a plain one.
*/
export function toSlopoConfig(config, paths, problems = []) {
const settings = settingsSnapshot(config)
const mapped = {
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(mapped)
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, whose
* fields are volatile references rather than values
*/
export function apply(ctx, config) {
const logger = ctx.logger('dsh-slopo')
const dshHome = dshHomeOf(ctx)
// The authoritative settings source: a plain snapshot of the running config,
// taken per call.
//
// `config` holds LIVE REFERENCES, not values. Every field is `volatile()`, so
// a settings write does NOT re-apply this plugin: the Loader writes the new
// value into the reference this instance already holds (`_commitVolatile`),
// and that is the whole point of the marker. It is also why nothing here is
// cached — a snapshot taken once at apply time would be frozen at the
// composition's values and would never see an edit.
const readSettings = () => settingsSnapshot(config)
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()
// Fresh per call, so a settings edit made while this tool is loaded is
// picked up by the very next call — see `readSettings` above.
const settings = readSettings()
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.
//
// It runs whenever the endpoint can be NAMED — a base URL and a model
// — not only once every required setting is present. Gating it on
// completeness hid it in the one state that needs it most: nobody can
// look up how wide an arbitrary embedding model is, and the probe is
// what reports the width it observed.
const apiBase = typeof settings.embeddingApiBase === 'string' ? settings.embeddingApiBase : ''
const model = String(slopoConfig.embedding_model ?? '')
const endpoint =
apiBase.trim() !== '' && model.trim() !== ''
? await probeEmbeddingEndpoint({
apiBase,
model,
dimensions: Number(slopoConfig.embedding_dimensions ?? 0),
apiKey: typeof settings.embeddingApiKey === 'string' ? settings.embeddingApiKey : '',
})
: {
checked: false,
skipped: 'set embeddingModel and embeddingApiBase to probe the endpoint and read its dimensions',
}
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".'
// The one thing a new user cannot look up anywhere: how wide this
// model's vectors are. Say it plainly when that is what is missing.
if (
endpoint.checked === true &&
typeof endpoint.dimensions === 'number' &&
missing.includes('embedding_dimensions')
) {
message +=
` The endpoint is reachable and returned ${endpoint.dimensions} dimensions, so ` +
`set embeddingDimensions to ${endpoint.dimensions}.`
}
} 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/<model>: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)
}