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.
750 lines
31 KiB
JavaScript
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)
|
|
}
|