A review on a real repository indexed 113,768 code units and began an embed that would have run for about eight hours. Two independent causes, both mine: 1. Nothing excluded anything. Slopo does NOT read .gitignore, so a repo with node_modules in its ignore file still had all 21,104 files under it parsed - 13,091 of them source. The generated config carried no source_dir_exclude at all, so the tool reviewed the dependencies instead of the code. 2. Nothing bounded the work. Slopo's own >1000 guard is useless here: it measures the PRE-EXISTING backlog before indexing, and a fresh database has no units at all. The guard that exists to protect an agent's latency is silent in the one case that most needs it. Measured on that repository: no excludes 113,768 units ~7.9 h default excludes 8,607 units ~36 min default + a server-only scope 1,519 units ~6 min So: default excludes for vendored and generated directories, ADDED to the user's own list rather than chosen between (a settings patch replaces a whole value, so a single sourceDirExclude pattern would otherwise re-enable the 13x blow-up). And every action now indexes first, checks the backlog against maxIndexUnits (default 5,000), and refuses with the count, the estimate and the lever rather than starting the work. The count comes from slopo's own database via node:sqlite - no new dependency, and it degrades to the summary line on an older runtime or a locked file. That matters because `Indexed N` reports only what was NEWLY indexed: on a warm database it reads zero while the backlog is still full, which is exactly the state a refusal leaves behind. Caught that hole by running the thing against the real repository rather than trusting the first version. Also fixes `status` reporting when the endpoint probe succeeded - the fix from the previous commit is what made this run reachable at all.
814 lines
34 KiB
JavaScript
814 lines
34 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 {
|
|
budgetRemedy,
|
|
checkBudget,
|
|
effectiveExcludes,
|
|
parseIndexOutput,
|
|
readIndexCounts,
|
|
} from './lib/scope.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',
|
|
'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.
|
|
*/
|
|
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(''),
|
|
/** Extra gitignore-style paths to skip, on top of the defaults. */
|
|
sourceDirExclude: Schema.array(String).default([]),
|
|
/** Exclude vendored dependencies and build output. Off means slopo sees everything. */
|
|
useDefaultExcludes: Schema.boolean().default(true),
|
|
/** Refuse to embed more than this many units without being asked. 0 removes the limit. */
|
|
maxIndexUnits: Schema.number().default(5000),
|
|
/** 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: effectiveExcludes(settings),
|
|
db_file: paths.dbFile,
|
|
report_dir: paths.reportDir,
|
|
ignore_file: paths.ignoreFile,
|
|
agent_log_file: paths.logFile,
|
|
embedding_model: typeof settings.embeddingModel === 'string' ? settings.embeddingModel.trim() : '',
|
|
// Omitted rather than written as 0 when unset: slopo requires a positive
|
|
// integer, and pruneConfig drops an undefined value entirely.
|
|
embedding_dimensions: Number(settings.embeddingDimensions) > 0 ? Number(settings.embeddingDimensions) : undefined,
|
|
embedding_params: embeddingParams(settings, problems),
|
|
embedding_request_delay: Number(settings.embeddingRequestDelay) || 0,
|
|
}
|
|
const { config: normalized, problems: numericProblems } = normalizeConfig(config)
|
|
problems.push(...numericProblems)
|
|
delete normalized.embedding_api_key
|
|
return normalized
|
|
}
|
|
|
|
/** Read the `description:` line out of a SKILL.md frontmatter block. */
|
|
export function readDescription(body) {
|
|
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(body)
|
|
if (frontmatter === null) return undefined
|
|
const line = /^description:[ \t]*(.+)$/m.exec(frontmatter[1])
|
|
if (line === null) return undefined
|
|
const raw = line[1].trim()
|
|
if (raw.startsWith('"') && raw.endsWith('"') && raw.length > 1) {
|
|
return raw.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
|
|
}
|
|
if (raw.startsWith("'") && raw.endsWith("'") && raw.length > 1) return raw.slice(1, -1).replace(/''/g, "'")
|
|
return raw
|
|
}
|
|
|
|
const SKILL_INVOCATION = Object.freeze({ modelInvocable: false, userInvocable: true })
|
|
const RESOURCE_BASE = Object.freeze({ kind: 'directory', path: SKILLS_DIR })
|
|
|
|
/** Load one packaged skill, or `undefined` when it cannot be read. */
|
|
async function loadSkill(name, signal) {
|
|
const url = new URL(`${name}/SKILL.md`, new URL('./skills/', import.meta.url))
|
|
try {
|
|
const content = await readFile(url, { encoding: 'utf8', signal })
|
|
const description = readDescription(content)
|
|
if (description === undefined) return undefined
|
|
return { description, content, url }
|
|
} catch (error) {
|
|
if (error?.name === 'AbortError') throw error
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
const skillsProvider = {
|
|
name: PROVIDER_NAME,
|
|
async list(options = {}) {
|
|
const found = []
|
|
for (const name of SKILL_NAMES) {
|
|
const skill = await loadSkill(name, options.signal)
|
|
if (skill === undefined) continue
|
|
found.push({
|
|
name,
|
|
description: skill.description,
|
|
invocation: SKILL_INVOCATION,
|
|
provider: PROVIDER_NAME,
|
|
source: 'bundled',
|
|
resourceBase: RESOURCE_BASE,
|
|
rank: BUNDLED_SKILL_RANK,
|
|
locator: name,
|
|
})
|
|
}
|
|
return found
|
|
},
|
|
async get(candidate, options = {}) {
|
|
const name = typeof candidate.locator === 'string' ? candidate.locator : candidate.name
|
|
const skill = await loadSkill(name, options.signal)
|
|
if (skill === undefined) return undefined
|
|
return {
|
|
name,
|
|
description: skill.description,
|
|
invocation: SKILL_INVOCATION,
|
|
provider: PROVIDER_NAME,
|
|
source: 'bundled',
|
|
resourceBase: RESOURCE_BASE,
|
|
content: skill.content,
|
|
}
|
|
},
|
|
}
|
|
|
|
const BACKGROUND_OUTPUT = {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
kind: { type: 'string', required: true, const: 'background' },
|
|
action: { type: 'string', required: true },
|
|
jobId: { type: 'string', required: true },
|
|
logPath: { type: 'string' },
|
|
configPath: { type: 'string' },
|
|
},
|
|
}
|
|
|
|
const FOREGROUND_OUTPUT = {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
kind: { type: 'string', required: true, const: 'foreground' },
|
|
action: { type: 'string', required: true },
|
|
ok: { type: 'boolean', required: true },
|
|
exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
report: { type: 'string' },
|
|
message: { type: 'string' },
|
|
errorCode: { type: 'string' },
|
|
remedy: { type: 'string' },
|
|
logError: { type: 'string' },
|
|
configPath: { type: 'string' },
|
|
logPath: { type: 'string' },
|
|
details: { type: 'json' },
|
|
},
|
|
}
|
|
|
|
export const name = 'dsh-slopo'
|
|
export const inject = ['tools', 'skills']
|
|
|
|
/**
|
|
* Register the skills, the settings namespace, and the tool.
|
|
*
|
|
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
* @param {Record<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,
|
|
// 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. */
|
|
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
|
|
|
|
// 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) },
|
|
})
|
|
|
|
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 { refused, counted, outcome } = await runScoped(controller.signal)
|
|
const logDetail = await readLogTail(paths.logFile)
|
|
if (refused !== undefined) {
|
|
return {
|
|
status: 'failed',
|
|
detail: 'SCOPE_TOO_LARGE',
|
|
output: `${refused}\n\n${budgetRemedy(Number(settings.maxIndexUnits), excludes)}`,
|
|
}
|
|
}
|
|
if (outcome.exitCode === 0) {
|
|
return { status: 'completed', detail: 'exit code: 0', output: outcome.stdout.trim() || 'No duplicates found' }
|
|
}
|
|
const explained = explainFailure({ stderr: outcome.stderr, stdout: outcome.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 { 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)
|
|
}
|