Release / release (push) Successful in 6s
0.1.7-rc.2 moved four things this bundle was built on, and every one of them
failed quietly rather than loudly, so the plugin looked installed and did
nothing:
* `ctx.settings.installSection(...)` is gone. The call threw into a
`try/catch` that only logged a warning, so the settings namespace was never
registered and `configure` had nowhere to persist. An entry's exported
Config IS the form now; the namespace is the Loader entry id the bundle's
patch declares, and `ctx.settings.configure({ auto: false })` is what claims
that entry's own page.
* `settingsScope` and the `settings.plugin.item` slot are gone. The card
registered into both, so it never rendered — the tab draws the intersection
of served namespaces and registered cards, and that intersection was empty.
It now registers into `plugins.bundle.config` keyed by the bundle's package
name, over the `configForms` service, and uses the framework's own form
chrome (`SettingsFormModel` / `SettingsForm` / `SettingsValueField` /
`SettingsSecretField`) instead of the hand-rolled equivalent. A save is one
revision-fenced write.
* `ctx.shell.run(spec)` is gone. Every action that runs a command threw
`shell.run is not a function`; the seam is now
`await ctx.shell.execute(ctx.shell.resolve(request)).result()`.
* A job's `owner` is a session id, not the agent object. The wrong owner
registered a job no session could collect or cancel, and the producer's
`done` resolved `output` where the registry reads `result`, so a finished
job handed back nothing.
Two conformance gaps went with them: a Config field that is not marked
`volatile()` is invisible to every settings UI — the form projection keeps only
volatile fields, and an entry with no form is dropped from `settings.describe`
entirely — and `embeddingApiKey` was a plain field, so the key rode every
settings read back to the browser. It is `role('secret')` now: the browser is
told whether a key is stored and never receives the key.
Also declares the `@deepseek-ai/dsh-*` peerDependencies the compatibility gate
reads, so an incompatible runtime refuses this plugin at startup instead of
half-loading it.
Verified against a real 0.1.7-rc.2 boot: `settings.describe` serves the entry
with `autoGenerate: false`, the key redacted and reported only as `set: true`,
and no startup diagnostics. The host mock in the tests now has no `run` and no
`installSection`, so those seams are contract-tested rather than assumed, and
the browser tests read the installed harness's own slot catalog and package
exports — a rename fails a test instead of quietly emptying a page.
890 lines
39 KiB
JavaScript
890 lines
39 KiB
JavaScript
/**
|
|
* dsh-slopo — slopo as a DeepSeek Harness bundle.
|
|
*
|
|
* Three contributions:
|
|
*
|
|
* 1. Three skills on `ctx.skills`, ported from slopo's own agent configs.
|
|
* All three are user-invocable and deliberately NOT model-invocable,
|
|
* matching upstream: every one of these commands can spend minutes
|
|
* embedding, so the human decides when.
|
|
* 2. The `dsh-slopo` configuration: this module's own exported `Config`, which
|
|
* the framework projects onto the Loader entry that composed it and
|
|
* addresses by that entry's id. Nothing is registered for it — a section
|
|
* assembled from a schema cannot disagree with the schema — and the page
|
|
* that edits it lives in `client/client.js`.
|
|
* 3. One `slopo` tool that resolves the CLI, materialises the config, and
|
|
* runs the long work as a background job.
|
|
*
|
|
* Why `ctx.inject(['settings'], ...)` rather than `inject: ['settings']`: a
|
|
* Cordis plugin with an unsatisfied required dependency never runs `apply` at
|
|
* all. Putting `settings` in the top-level inject would make the tool and the
|
|
* skills disappear on any profile without a settings provider, which is a
|
|
* silent whole-plugin failure for an optional capability. The optional form
|
|
* simply has nowhere to persist a `configure` and says so.
|
|
*
|
|
* Plain ESM, no build step — deliberately. `dsh plugin add git+...` fetches the
|
|
* source tree, not build output, and a `prepare` script would make every user
|
|
* allowlist a build before the first install succeeds.
|
|
*/
|
|
|
|
import { existsSync } from 'node:fs'
|
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
import { join, sep } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
import Schema from '@deepseek-ai/schemastery'
|
|
|
|
import { missingRequired, normalizeConfig, renderSlopoConfig } from './lib/config.js'
|
|
import { probeEmbeddingEndpoint } from './lib/probe.js'
|
|
import { toJsonValue } from './lib/json.js'
|
|
import {
|
|
budgetRemedy,
|
|
checkBudget,
|
|
effectiveExcludes,
|
|
parseIndexOutput,
|
|
readIndexCounts,
|
|
} from './lib/scope.js'
|
|
import {
|
|
resolveDshHome,
|
|
workspaceStateDir,
|
|
} from './lib/paths.js'
|
|
import {
|
|
explainFailure,
|
|
findSlopo,
|
|
installEnv,
|
|
lastLogError,
|
|
commandLine,
|
|
runShell,
|
|
slopoArgs,
|
|
uvInstallArgs,
|
|
} from './lib/slopo.js'
|
|
|
|
/** Rank of a packaged bundle's skills. Hardcoded, not imported. */
|
|
const BUNDLED_SKILL_RANK = 600
|
|
|
|
/** Reserved provider name inside dsh; registering it throws. */
|
|
const PROVIDER_NAME = 'dsh-slopo'
|
|
|
|
/** Settings namespace. Must match /^[a-z][a-z0-9-]*$/. */
|
|
const SETTINGS_NAMESPACE = 'dsh-slopo'
|
|
|
|
const SKILL_NAMES = ['slopo-review', 'slopo-analyze-ignore', 'slopo-analyze-one']
|
|
|
|
const SKILLS_DIR = fileURLToPath(new URL('./skills/', import.meta.url))
|
|
|
|
/** Long-running actions, in the order the tool description advertises them. */
|
|
/**
|
|
* Actions that default to a background job.
|
|
*
|
|
* Only the warm-up. `index` plus `embed` is the long, watchable step — the one
|
|
* the user asked to be able to watch — while a review finds a warm index and
|
|
* returns its report directly. Named after dsh-tool-bash's `run_in_background`,
|
|
* because a model's prior is that backgrounding is something it ASKS for rather
|
|
* than the default.
|
|
*/
|
|
const BACKGROUND_BY_DEFAULT = new Set(['prepare'])
|
|
|
|
/** Every configuration field this plugin exposes. */
|
|
const SETTING_KEYS = [
|
|
'binary',
|
|
'autoInstall',
|
|
'embeddingModel',
|
|
'embeddingDimensions',
|
|
'embeddingApiBase',
|
|
'embeddingApiKey',
|
|
'embeddingParamsJson',
|
|
'sourceDir',
|
|
'sourceDirExclude',
|
|
'useDefaultExcludes',
|
|
'maxIndexUnits',
|
|
'ignoreFile',
|
|
'stateDir',
|
|
'embeddingRequestDelay',
|
|
'timeoutMs',
|
|
]
|
|
|
|
/**
|
|
* The plugin's composition schema.
|
|
*
|
|
* Nothing meaningful is defaulted: slopo itself requires `source_dir`,
|
|
* `embedding_model` and `embedding_dimensions`, and guessing an embedding
|
|
* backend would mean guessing where money is spent. Unset means unset, and the
|
|
* tool says so.
|
|
*
|
|
* Every field is marked `volatile()`, which is what puts it on the settings
|
|
* page: the framework's form projection keeps only the fields whose nearest
|
|
* volatile ancestor makes them editable without remounting the plugin, and an
|
|
* entry with no such field is dropped from `settings.describe` entirely. An
|
|
* unmarked field would still be *configurable* through the profile patch — it
|
|
* would simply be invisible to every settings UI.
|
|
*
|
|
* `embeddingApiKey` additionally carries `role('secret')`, so the settings
|
|
* wire never carries it: every remote read is redacted (`secrets` reports only
|
|
* whether a value is set) and the browser sends the key one way, on write.
|
|
*/
|
|
export const Config = Schema.object({
|
|
/** Explicit slopo executable. Empty means autodiscover. */
|
|
binary: Schema.string().default('').volatile(),
|
|
/** Install slopo into $DSH_HOME with uv when it is missing. */
|
|
autoInstall: Schema.boolean().default(true).volatile(),
|
|
/** LiteLLM model string, e.g. `ollama/jina-embeddings-v2-base-code:latest`. */
|
|
embeddingModel: Schema.string().default('').volatile(),
|
|
/** Output dimensions, which must match the model. */
|
|
embeddingDimensions: Schema.number().default(0).volatile(),
|
|
/** Provider base URL, e.g. `http://127.0.0.1:11434` for Ollama. */
|
|
embeddingApiBase: Schema.string().default('').volatile(),
|
|
/** Provider key. Passed to slopo in its environment, never written to disk. */
|
|
embeddingApiKey: Schema.string().default('').role('secret').volatile(),
|
|
/** Extra LiteLLM embedding params, as a JSON object string. */
|
|
embeddingParamsJson: Schema.string().default('{}').volatile(),
|
|
/** Code to index. Empty means the session's working directory. */
|
|
sourceDir: Schema.string().default('').volatile(),
|
|
/** Extra gitignore-style paths to skip, on top of the defaults. */
|
|
sourceDirExclude: Schema.array(String).default([]).volatile(),
|
|
/** Exclude vendored dependencies and build output. Off means slopo sees everything. */
|
|
useDefaultExcludes: Schema.boolean().default(true).volatile(),
|
|
/** Refuse to embed more than this many units without being asked. 0 removes the limit. */
|
|
maxIndexUnits: Schema.number().default(5000).volatile(),
|
|
/** Ignore file. Empty means inside the per-workspace state directory. */
|
|
ignoreFile: Schema.string().default('').volatile(),
|
|
/** State directory. Empty means $DSH_HOME/dsh-slopo/workspaces/<workspace>. */
|
|
stateDir: Schema.string().default('').volatile(),
|
|
/** Milliseconds between embedding requests, for rate-limited providers. */
|
|
embeddingRequestDelay: Schema.number().default(0).volatile(),
|
|
/** Cooperative budget for a foreground slopo run. */
|
|
timeoutMs: Schema.number().default(600_000).volatile(),
|
|
})
|
|
|
|
/** 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)
|
|
|
|
// `config` is the live configuration, not a snapshot of the composition
|
|
// layer: a settings write rewrites this entry's patch and the Loader
|
|
// re-applies the plugin with the new values, so every read below sees the
|
|
// deployment's current answer. There is deliberately no cached copy — a cache
|
|
// is exactly what a reload invalidates.
|
|
let writeSettings
|
|
|
|
// The settings namespace IS this plugin's Config. The framework projects the
|
|
// exported schema onto the Loader entry that composed it and addresses it by
|
|
// that entry's id, which the bundle's patch declares as `dsh-slopo` — the
|
|
// same string the browser card binds to. Nothing is registered here any more:
|
|
// 0.1.7-rc.2 removed `ctx.settings.installSection`, because a section
|
|
// assembled from a schema cannot disagree with the schema.
|
|
//
|
|
// `configure({ auto: false })` records that this entry ships its own page
|
|
// (`client/client.js` registers one onto the Plugins page), so the deployment
|
|
// must not also generate one for the same entry.
|
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
try {
|
|
settingsCtx.effect(
|
|
() => settingsCtx.settings.configure({ auto: false }, ctx.fiber),
|
|
'dsh-slopo: settings page policy',
|
|
)
|
|
writeSettings = (patch) => settingsCtx.settings.update(SETTINGS_NAMESPACE, patch)
|
|
} catch (error) {
|
|
logger.warn('dsh-slopo: settings namespace unavailable: %s', error?.message ?? error)
|
|
}
|
|
})
|
|
|
|
ctx.skills.registerProvider(() => skillsProvider)
|
|
|
|
/**
|
|
* The slopo executable, installing it first when allowed.
|
|
*
|
|
* `installInto` is the directory the install runs in. It is deliberately NOT
|
|
* the workspace: `uv tool install` has nothing to do with the project, and a
|
|
* shell that must `cd` into a workspace that does not exist yet fails before
|
|
* `uv` is ever reached.
|
|
*/
|
|
async function ensureSlopo(settings, run, installInto) {
|
|
const configured = typeof settings.binary === 'string' ? settings.binary.trim() : ''
|
|
if (configured !== '' && existsSync(configured)) return configured
|
|
const found = findSlopo(dshHome)
|
|
if (found !== undefined) return found
|
|
if (settings.autoInstall !== true) {
|
|
return { error: 'SLOPO_NOT_INSTALLED', remedy: 'Set `autoInstall`, or install slopo yourself and set `binary`.' }
|
|
}
|
|
const install = await run(commandLine(['uv', ...uvInstallArgs()]), {
|
|
env: installEnv(dshHome),
|
|
timeoutMs: 900_000,
|
|
workdir: installInto,
|
|
})
|
|
if (install.exitCode !== 0) {
|
|
return {
|
|
error: 'SLOPO_INSTALL_FAILED',
|
|
remedy:
|
|
'Install slopo manually with `uv tool install slopo --python 3.12`, or set the `binary` setting to ' +
|
|
'an existing slopo executable.',
|
|
detail: install.stderr || install.stdout,
|
|
}
|
|
}
|
|
const installed = findSlopo(dshHome)
|
|
if (installed === undefined) {
|
|
return { error: 'SLOPO_INSTALL_MISSING', remedy: 'uv reported success but no executable appeared.' }
|
|
}
|
|
return installed
|
|
}
|
|
|
|
ctx.tools.register(
|
|
defineTool({
|
|
name: 'slopo',
|
|
description:
|
|
'Run slopo, an embedding-based duplicate-code detector, against the current workspace. ' +
|
|
'Actions: "status" reports the resolved configuration and index state; "configure" updates the ' +
|
|
'dsh-slopo settings; "prepare" runs the local index and then embeds (the slow, possibly costly ' +
|
|
'step); "review" finds similar code involving Git changes; "analyze" triages the whole codebase; ' +
|
|
'"analyze-one" reports a single cluster by hash; "update" upgrades the slopo CLI. Slopo is ' +
|
|
'installed and driven as a separate process. "prepare" starts a background job by default, because ' +
|
|
'indexing and embedding a repository takes minutes and slopo writes its progress to a log file rather ' +
|
|
'than to stdout; everything else returns its report directly.',
|
|
parameters: {
|
|
action: {
|
|
type: 'string',
|
|
required: true,
|
|
enum: ['status', 'configure', 'prepare', 'review', 'analyze', 'analyze-one', 'update'],
|
|
description: 'What to do.',
|
|
},
|
|
base: { type: 'string', description: 'Git ref to compare against for "review". Defaults to HEAD.' },
|
|
cluster: { type: 'string', description: 'Cluster hash for "analyze-one". Omit for the next cluster.' },
|
|
run_in_background: {
|
|
type: 'boolean',
|
|
description:
|
|
'Return a job id instead of the result, for a run long enough to watch. Defaults to true for ' +
|
|
'"prepare" (the warm-up) and false for everything else, which is quick once the index is warm.',
|
|
},
|
|
config: {
|
|
type: 'json',
|
|
description:
|
|
'Settings patch for "configure", using this plugin\'s own camelCase field names, for example ' +
|
|
'{"embeddingModel":"ollama/nomic-embed-text:latest","embeddingDimensions":768}.',
|
|
},
|
|
},
|
|
output: {
|
|
schema: { oneOf: [BACKGROUND_OUTPUT, FOREGROUND_OUTPUT] },
|
|
render: (_args, value) => {
|
|
if (value.kind === 'background') {
|
|
const log = value.logPath === undefined ? '' : ` Progress is written to ${value.logPath}.`
|
|
return [
|
|
{
|
|
type: 'text',
|
|
text:
|
|
`Started slopo ${value.action} as background job ${value.jobId}.${log} You will be notified when ` +
|
|
`it settles; collect it with job_output for "${value.jobId}" (add wait: true if you are blocked on it).`,
|
|
},
|
|
]
|
|
}
|
|
const parts = []
|
|
if (typeof value.report === 'string' && value.report !== '') parts.push(value.report)
|
|
if (typeof value.message === 'string' && value.message !== '') parts.push(value.message)
|
|
if (typeof value.logError === 'string' && value.logError !== '') parts.push(`Slopo log: ${value.logError}`)
|
|
if (typeof value.remedy === 'string' && value.remedy !== '') parts.push(`Next: ${value.remedy}`)
|
|
if (typeof value.logPath === 'string' && value.logPath !== '') parts.push(`(agent log: ${value.logPath})`)
|
|
if (parts.length === 0) parts.push(`slopo ${value.action} finished with exit code ${value.exitCode}.`)
|
|
return [{ type: 'text', text: parts.join('\n\n') }]
|
|
},
|
|
},
|
|
async execute(args, exec) {
|
|
const cwd = exec.agent?.session.header.cwd ?? process.cwd()
|
|
const settings = config
|
|
const paths = workspacePaths(dshHome, cwd, settings)
|
|
const shell = ctx.get('shell')
|
|
// Resolved once, up front, so `status` can probe without a second
|
|
// resolution. The NO_SANDBOX_POLICY refusal stays with the actions that
|
|
// actually execute something.
|
|
const sandboxDefault = shell?.sandboxMode
|
|
const policyService = sandboxDefault === undefined ? undefined : ctx.get('sandboxPolicy')
|
|
const sandboxPolicy = policyService?.resolve(
|
|
exec.agent === undefined ? {} : { session: exec.agent.session },
|
|
)
|
|
|
|
// Every return funnels through here. The harness enforces the declared
|
|
// output schema against this value BEFORE materialising it, and an
|
|
// explicit `undefined` property is not lossless JSON — which is exactly
|
|
// how a probe result carrying `error: undefined` broke the status call.
|
|
const foreground = (value) =>
|
|
toJsonValue({
|
|
kind: 'foreground',
|
|
action: args.action,
|
|
configPath: paths.configPath,
|
|
logPath: paths.logFile,
|
|
...value,
|
|
})
|
|
|
|
if (args.action === 'status') {
|
|
const binary = findSlopo(dshHome)
|
|
const problems = []
|
|
const slopoConfig = toSlopoConfig(settings, paths, problems)
|
|
const missing = missingRequired(slopoConfig)
|
|
|
|
// Two pre-flight checks, because slopo reports a bad embedding
|
|
// configuration as the two words "Embedding failed." on stdout with
|
|
// the real reason buried in its agent log. That is a slow way to
|
|
// discover a wrong provider prefix or a dimension mismatch.
|
|
let slopoVersion
|
|
if (binary !== undefined && shell !== undefined) {
|
|
try {
|
|
const versionResult = await runShell(shell, {
|
|
command: commandLine([binary, '--version']),
|
|
workdir: paths.cwd,
|
|
timeoutMs: 30_000,
|
|
...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
|
|
})
|
|
slopoVersion =
|
|
versionResult.exitCode === 0
|
|
? versionResult.stdout.text.trim().split('\n')[0]
|
|
: `exit code ${versionResult.exitCode}: ${versionResult.stderr.text.trim().slice(0, 200)}`
|
|
} catch (error) {
|
|
slopoVersion = `could not run: ${error?.message ?? error}`
|
|
}
|
|
}
|
|
|
|
// A direct fetch rather than the shell seam: the probe is one small
|
|
// HTTP request and does not belong behind a sandbox boundary that
|
|
// exists to confine filesystem writes.
|
|
const endpoint =
|
|
missing.length === 0
|
|
? await probeEmbeddingEndpoint({
|
|
apiBase: typeof settings.embeddingApiBase === 'string' ? settings.embeddingApiBase : '',
|
|
model: String(slopoConfig.embedding_model ?? ''),
|
|
dimensions: Number(slopoConfig.embedding_dimensions ?? 0),
|
|
apiKey: typeof settings.embeddingApiKey === 'string' ? settings.embeddingApiKey : '',
|
|
})
|
|
: { checked: false, skipped: 'nothing is configured yet' }
|
|
|
|
let message
|
|
if (missing.length > 0) {
|
|
message =
|
|
`Not configured yet. Missing: ${missing.join(', ')}. Run this tool with action "configure" ` +
|
|
'to set embeddingModel and embeddingDimensions, then "prepare".'
|
|
} else if (endpoint.ok === false) {
|
|
message =
|
|
`Configured, but the embedding backend failed its check: ${endpoint.error}. ` +
|
|
`(probed ${endpoint.url} with model "${endpoint.sentModel}")`
|
|
} else if (endpoint.checked === true) {
|
|
message =
|
|
`Ready. ${binary ?? 'slopo'} will use "${slopoConfig.embedding_model}" at ` +
|
|
`${Number(slopoConfig.embedding_dimensions)} dimensions, confirmed against ${endpoint.url}.`
|
|
} else {
|
|
message =
|
|
`Configured against "${slopoConfig.embedding_model}" at ` +
|
|
`${Number(slopoConfig.embedding_dimensions)} dimensions. Nothing was probed: ${endpoint.skipped}.`
|
|
}
|
|
|
|
return foreground({
|
|
ok: missing.length === 0 && endpoint.ok !== false,
|
|
exitCode: null,
|
|
details: {
|
|
dshHome,
|
|
cwd,
|
|
stateDir: paths.stateDir,
|
|
configPath: paths.configPath,
|
|
database: existsSync(paths.dbFile) ? paths.dbFile : null,
|
|
ignoreFile: existsSync(paths.ignoreFile) ? paths.ignoreFile : null,
|
|
agentLog: existsSync(paths.logFile) ? paths.logFile : null,
|
|
slopoBinary: binary ?? null,
|
|
slopoVersion: slopoVersion ?? null,
|
|
slopoInstalledInto: binary === undefined ? null : dshHome,
|
|
settingsNamespace: writeSettings === undefined ? null : SETTINGS_NAMESPACE,
|
|
embeddingModel: slopoConfig.embedding_model ?? null,
|
|
embeddingDimensions: slopoConfig.embedding_dimensions ?? null,
|
|
apiKeyConfigured: typeof settings.embeddingApiKey === 'string' && settings.embeddingApiKey !== '',
|
|
embeddingEndpoint: endpoint,
|
|
// What slopo will actually look at, and the limit on how much of
|
|
// it may be embedded without being asked. Both are the levers a
|
|
// runaway scope is fixed with, so status has to name them.
|
|
sourceDirExclude: effectiveExcludes(settings),
|
|
useDefaultExcludes: settings.useDefaultExcludes !== false,
|
|
maxIndexUnits: Number(settings.maxIndexUnits),
|
|
missingSettings: missing,
|
|
problems,
|
|
},
|
|
message,
|
|
})
|
|
}
|
|
|
|
if (args.action === 'configure') {
|
|
const patch = args.config
|
|
if (patch === undefined || patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
return foreground({ ok: false, exitCode: null, message: 'The `config` parameter must be an object.', errorCode: 'BAD_PATCH' })
|
|
}
|
|
const unknown = Object.keys(patch).filter((key) => !SETTING_KEYS.includes(key))
|
|
if (unknown.length > 0) {
|
|
return foreground({
|
|
ok: false,
|
|
exitCode: null,
|
|
errorCode: 'UNKNOWN_SETTING',
|
|
message: `Unknown setting(s): ${unknown.join(', ')}.`,
|
|
remedy: `Known settings: ${SETTING_KEYS.join(', ')}.`,
|
|
})
|
|
}
|
|
if (writeSettings === undefined) {
|
|
return foreground({
|
|
ok: false,
|
|
exitCode: null,
|
|
errorCode: 'NO_SETTINGS_PROVIDER',
|
|
message: 'No settings provider is mounted, so configuration cannot be persisted at runtime.',
|
|
remedy: `Set these values in the dsh-slopo row of your cordis layer instead: ${JSON.stringify(patch)}`,
|
|
})
|
|
}
|
|
await writeSettings(patch)
|
|
return foreground({ ok: true, exitCode: 0, message: `Updated ${Object.keys(patch).join(', ')}.` })
|
|
}
|
|
|
|
if (shell === undefined) {
|
|
return foreground({
|
|
ok: false,
|
|
exitCode: null,
|
|
errorCode: 'NO_SHELL',
|
|
message: 'No shell executor is available, so slopo cannot be run.',
|
|
remedy: 'Load @deepseek-ai/dsh-bash-local (or another shell provider) into this profile.',
|
|
})
|
|
}
|
|
|
|
// Resolve the CALLING SESSION's sandbox policy, the way the bash tool
|
|
// does. `dsh-bash-sandbox.resolve()` stamps
|
|
// `request.sandboxPolicy ?? ctx.sandboxPolicy.resolve()`, so omitting it
|
|
// does not mean "unconfined" — it silently substitutes the DEPLOYMENT
|
|
// default (`DSH_PERMISSION_MODE ?? 'workspace-write'`) for the session's
|
|
// preset. That confines a call the session asked to run unconfined, and
|
|
// on a host with no sandbox runner it fails with SandboxUnavailableError
|
|
// before slopo is ever reached.
|
|
if (sandboxDefault !== undefined && policyService === undefined) {
|
|
return foreground({
|
|
ok: false,
|
|
exitCode: null,
|
|
errorCode: 'NO_SANDBOX_POLICY',
|
|
message: 'The mounted shell executor confines commands, but ctx.sandboxPolicy is absent.',
|
|
remedy: 'Compose @deepseek-ai/dsh-sandbox-policy, or mount an unconfining shell executor.',
|
|
})
|
|
}
|
|
|
|
// Slopo's state is deliberately outside the workspace, which a
|
|
// workspace-write sandbox forbids writing to. Say so before a run and
|
|
// name both ways out, rather than letting a write fail mid-embed.
|
|
if (
|
|
sandboxPolicy !== undefined &&
|
|
sandboxPolicy.mode !== 'danger-full-access' &&
|
|
!isInside(sandboxPolicy.workspaceRoot, paths.stateDir)
|
|
) {
|
|
return foreground({
|
|
ok: false,
|
|
exitCode: null,
|
|
errorCode: 'STATE_OUTSIDE_SANDBOX',
|
|
message:
|
|
`This session runs under the "${sandboxPolicy.mode}" sandbox, which permits writes only under ` +
|
|
`${sandboxPolicy.workspaceRoot} — but slopo's index, config and log live in ${paths.stateDir}.`,
|
|
remedy:
|
|
'Either run this session under the danger-full-access preset, or set `stateDir` to a directory ' +
|
|
'inside the workspace using action "configure".',
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Run one command through the shell seam.
|
|
*
|
|
* `runShell` returns the seam's own result; everything below wants the
|
|
* flattened strings, so the adaptation happens once, here.
|
|
*
|
|
* `workdir` is overridable because installing slopo has nothing to do
|
|
* with the workspace: the workspace may not even exist yet on a fresh
|
|
* session, and a shell that cannot `cd` there fails before `uv` is
|
|
* reached. The plugin's own state directory is always a real directory
|
|
* by the time an install runs.
|
|
*/
|
|
const run = async (command, { env, timeoutMs, signal, workdir } = {}) => {
|
|
const result = await runShell(shell, {
|
|
command,
|
|
workdir: workdir ?? paths.cwd,
|
|
timeoutMs: timeoutMs ?? (Number(settings.timeoutMs) || 600_000),
|
|
...(signal === undefined ? {} : { signal }),
|
|
// Merges over a parent-derived base, so PATH and HOME survive.
|
|
...(env === undefined ? {} : { env }),
|
|
// Omitting this substitutes the deployment default; see above.
|
|
...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
|
|
})
|
|
return {
|
|
exitCode: result.exitCode,
|
|
stdout: result.stdout.text,
|
|
stderr: result.stderr.text,
|
|
truncated: result.stdout.truncated || result.stderr.truncated,
|
|
timedOut: Boolean(result.timedOut),
|
|
}
|
|
}
|
|
|
|
if (args.action === 'update') {
|
|
const result = await run(commandLine(['uv', ...uvInstallArgs({ upgrade: true })]), {
|
|
env: installEnv(dshHome),
|
|
timeoutMs: 900_000,
|
|
signal: exec.signal,
|
|
})
|
|
return foreground({
|
|
ok: result.exitCode === 0,
|
|
exitCode: result.exitCode,
|
|
report: result.stdout.trim(),
|
|
errorCode: result.exitCode === 0 ? undefined : 'UPDATE_FAILED',
|
|
remedy: result.exitCode === 0 ? undefined : result.stderr.trim(),
|
|
})
|
|
}
|
|
|
|
// Everything below actually needs slopo.
|
|
const problems = []
|
|
const slopoConfig = toSlopoConfig(settings, paths, problems)
|
|
const missing = missingRequired(slopoConfig)
|
|
if (missing.length > 0) {
|
|
return foreground({
|
|
ok: false,
|
|
exitCode: null,
|
|
errorCode: 'NOT_CONFIGURED',
|
|
message: `Slopo is not configured for this workspace yet. Missing: ${missing.join(', ')}.`,
|
|
remedy:
|
|
'Call this tool with action "configure" first, for example ' +
|
|
'{"embeddingModel":"ollama/<model>:latest","embeddingDimensions":768,"embeddingApiBase":"http://127.0.0.1:11434"}.',
|
|
details: { missing, problems },
|
|
})
|
|
}
|
|
|
|
// The state directory and the config file come FIRST, before the
|
|
// executable is resolved. The config is a pure function of the
|
|
// settings — nothing about it needs slopo to exist — and writing it
|
|
// early means a run that stops at "not installed" still leaves the
|
|
// resolved configuration on disk to look at. It also gives the install
|
|
// below a real working directory to run in.
|
|
await mkdir(paths.stateDir, { recursive: true })
|
|
await writeFile(paths.configPath, renderSlopoConfig(slopoConfig), 'utf8')
|
|
|
|
const binary = await ensureSlopo(settings, run, paths.stateDir)
|
|
if (typeof binary !== 'string') {
|
|
return foreground({ ok: false, exitCode: null, errorCode: binary.error, message: binary.remedy, remedy: binary.detail })
|
|
}
|
|
|
|
const env =
|
|
typeof settings.embeddingApiKey === 'string' && settings.embeddingApiKey !== ''
|
|
? { SLOPO_EMBEDDING_API_KEY: settings.embeddingApiKey }
|
|
: undefined
|
|
|
|
// Every action indexes FIRST, so the size gate sees the real scope
|
|
// before any embedding is paid for. Slopo's own >1000 guard cannot do
|
|
// this: it measures the pre-existing backlog, and a fresh database has
|
|
// no units at all — which is exactly when a runaway costs the most.
|
|
const excludes = effectiveExcludes(settings)
|
|
const indexArgs = slopoArgs(paths.configPath, 'index')
|
|
const restArgs =
|
|
args.action === 'prepare'
|
|
? [slopoArgs(paths.configPath, 'embed')]
|
|
: [slopoArgs(paths.configPath, args.action, { base: args.base, cluster: args.cluster })]
|
|
|
|
/**
|
|
* Index, check the budget, then run the action.
|
|
* Returns a refusal instead of running when the scope is too large.
|
|
*/
|
|
const runScoped = async (signal) => {
|
|
const indexed = await run(commandLine([binary, ...indexArgs]), { env, signal })
|
|
const parsed = parseIndexOutput(indexed.stdout)
|
|
// Prefer the database. `Indexed N` counts only what was NEWLY indexed,
|
|
// so it reads zero on a warm database whose backlog is still full —
|
|
// exactly the state a refused run leaves behind.
|
|
const fromDb = readIndexCounts(paths.dbFile)
|
|
const counted =
|
|
fromDb === undefined
|
|
? parsed
|
|
: { units: fromDb.unembedded, files: fromDb.files, inScope: fromDb.units, embedded: fromDb.embedded }
|
|
if (indexed.exitCode !== 0) return { refused: undefined, counted, outcome: indexed }
|
|
const decision = checkBudget(counted, Number(settings.maxIndexUnits), excludes)
|
|
if (!decision.allowed) return { refused: decision.reason, counted, outcome: undefined }
|
|
const restCommand = restArgs.map((argv) => commandLine([binary, ...argv])).join(' && ')
|
|
return { refused: undefined, counted, outcome: await run(restCommand, { env, signal }) }
|
|
}
|
|
|
|
const scopeRefusal = (reason, counted) =>
|
|
foreground({
|
|
ok: false,
|
|
exitCode: null,
|
|
errorCode: 'SCOPE_TOO_LARGE',
|
|
message: reason,
|
|
remedy: budgetRemedy(Number(settings.maxIndexUnits), excludes),
|
|
details: { counted, excludes, maxIndexUnits: Number(settings.maxIndexUnits) },
|
|
})
|
|
|
|
// Background is for the long warm-up, not for every action. A completion
|
|
// notice is best-effort: a KILLED job deliberately produces none, and an
|
|
// agent whose turn has ended may have the message injected rather than be
|
|
// woken for it. A review that returns its report directly is worth more
|
|
// than a job id the caller has to remember to collect.
|
|
const wantsBackground = args.run_in_background ?? BACKGROUND_BY_DEFAULT.has(args.action)
|
|
const jobs = ctx.get('jobs')
|
|
|
|
if (wantsBackground && jobs !== undefined) {
|
|
const jobId = jobs.start({
|
|
kind: 'slopo',
|
|
label: `slopo ${args.action}`,
|
|
// Ownership is fenced by SESSION ID, not by the agent object: the
|
|
// registry cancels and awaits the job when that session's agent is
|
|
// disposed, and it looks the id up itself. Passing `exec.agent`
|
|
// here registered a job no session could collect or stop.
|
|
...(exec.agent === undefined ? {} : { owner: exec.agent.id }),
|
|
run: () => {
|
|
const controller = new AbortController()
|
|
// `done` resolves to a JobOutcome: `{ status, detail, result }`.
|
|
// The report rides `result`, which the registry hands to the
|
|
// model on its first read after settlement — read it back with
|
|
// job_output. (The field is `result`, not `output`: the ring
|
|
// carries a job's stream, and `result` carries the return value
|
|
// of one whose product is a value rather than a stream.)
|
|
const done = (async () => {
|
|
const { refused, counted, outcome } = await runScoped(controller.signal)
|
|
const logDetail = await readLogTail(paths.logFile)
|
|
if (refused !== undefined) {
|
|
return {
|
|
status: 'failed',
|
|
detail: 'SCOPE_TOO_LARGE',
|
|
result: `${refused}\n\n${budgetRemedy(Number(settings.maxIndexUnits), excludes)}`,
|
|
}
|
|
}
|
|
if (outcome.exitCode === 0) {
|
|
return { status: 'completed', detail: 'exit code: 0', result: outcome.stdout.trim() || 'No duplicates found' }
|
|
}
|
|
const explained = explainFailure({ stderr: outcome.stderr, stdout: outcome.stdout, logDetail })
|
|
return {
|
|
status: 'failed',
|
|
detail: explained.code,
|
|
result: [explained.message, explained.remedy, logDetail.trim()].filter((line) => line !== '').join('\n\n'),
|
|
}
|
|
})()
|
|
return { cancel: () => controller.abort(), done }
|
|
},
|
|
})
|
|
return toJsonValue({ kind: 'background', action: args.action, jobId, logPath: paths.logFile, configPath: paths.configPath })
|
|
}
|
|
|
|
const { refused, counted, outcome } = await runScoped(exec.signal)
|
|
const logDetail = await readLogTail(paths.logFile)
|
|
if (refused !== undefined) return scopeRefusal(refused, counted)
|
|
if (outcome.exitCode === 0) {
|
|
return foreground({ ok: true, exitCode: 0, report: outcome.stdout.trim() || 'No duplicates found' })
|
|
}
|
|
const explained = explainFailure({ stderr: outcome.stderr, stdout: outcome.stdout, logDetail })
|
|
return foreground({
|
|
ok: false,
|
|
exitCode: outcome.exitCode,
|
|
errorCode: explained.code,
|
|
message: explained.message,
|
|
remedy: explained.remedy,
|
|
logError: lastLogError(logDetail),
|
|
})
|
|
},
|
|
}),
|
|
)
|
|
}
|
|
|
|
/** Read the tail of slopo's agent log, tolerating a missing file. */
|
|
async function readLogTail(path) {
|
|
try {
|
|
const contents = await readFile(path, 'utf8')
|
|
return contents.length > 16_000 ? contents.slice(-16_000) : contents
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
/** True when `path` is `root` itself or sits underneath it. */
|
|
function isInside(root, path) {
|
|
const prefix = root.endsWith(sep) ? root : root + sep
|
|
return path === root || path.startsWith(prefix)
|
|
}
|