Files
dsh-slopo/index.js
T
dsh-slopo agent 46124b11aa Fix: status could not be serialised when the probe SUCCEEDED
The probe returned `error: undefined` on a dimension match. `undefined` is not a
JSON value -- JSON.stringify silently drops it from an object -- so the shape
looked harmless and was not: the harness enforces the declared output schema
against the canonical value before materialising it, and an explicit undefined
property fails that check. A perfectly healthy status call could not be returned
at all.

Fixed at the boundary rather than at each site. Every return now funnels through
`toJsonValue`, which drops undefined properties exactly as JSON.stringify would,
maps non-finite numbers to null (same reasoning: JSON.stringify(NaN) is "null"),
turns an undefined array slot into null rather than shifting indices, and refuses
a circular value instead of recursing forever. `update` was carrying
`errorCode: undefined` and `ensureSlopo` a `detail: undefined` on the same path,
so a per-site fix would have left two more of these waiting.

The probe also now omits `error` rather than assigning undefined, since it is a
plausible thing for another consumer to read.

Four regression tests, and they were checked against a negative control: with the
sanitiser reverted and the undefined restored, exactly the "probe SUCCEEDS" test
fails with "does not survive a JSON round trip" and the others still pass, which
is what makes it a test of this bug rather than of the JSON round trip in
general.
2026-09-22 13:08:22 +01:00

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