Release / release (push) Successful in 6s
Two things a user setting this up hit immediately. The card drew `embeddingApiKey` twice. The render loop walked every entry in FIELDS, and the secret — which is drawn by hand, as a write-only password control — was one of them, so a second control appeared beside it: an ordinary text input, echoing the key in clear as you typed. One key, two boxes, one of them visible. The loop skips secrets now, and the tests count controls rather than checking that each expected label is present, because a duplicate beside the real control passed the old assertions. `status` could not answer the one question a new user cannot look up: how wide is this model? The probe that reports it was gated on every required setting being present, so in exactly the state that needs the answer — dimensions unset — it reported nothing and said "Missing: embedding_dimensions". It now runs whenever the endpoint can be named (a model and a base URL), and the status message names the width it observed: Missing: embedding_dimensions. The endpoint is reachable and returned 768 dimensions, so set embeddingDimensions to 768. The probe also tells "not set yet" apart from "set wrong", because the two have different remedies. Also documents both ways to read the width by hand, since a wrong value is frozen into the index at first use and costs a full re-embed to correct.
912 lines
40 KiB
JavaScript
912 lines
40 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.
|
|
//
|
|
// It runs whenever the endpoint can be NAMED — a base URL and a model
|
|
// — not only once every required setting is present. Gating it on
|
|
// completeness hid it in the one state that needs it most: nobody can
|
|
// look up how wide an arbitrary embedding model is, and the probe is
|
|
// what reports the width it observed.
|
|
const apiBase = typeof settings.embeddingApiBase === 'string' ? settings.embeddingApiBase : ''
|
|
const model = String(slopoConfig.embedding_model ?? '')
|
|
const endpoint =
|
|
apiBase.trim() !== '' && model.trim() !== ''
|
|
? await probeEmbeddingEndpoint({
|
|
apiBase,
|
|
model,
|
|
dimensions: Number(slopoConfig.embedding_dimensions ?? 0),
|
|
apiKey: typeof settings.embeddingApiKey === 'string' ? settings.embeddingApiKey : '',
|
|
})
|
|
: {
|
|
checked: false,
|
|
skipped: 'set embeddingModel and embeddingApiBase to probe the endpoint and read its dimensions',
|
|
}
|
|
|
|
let message
|
|
if (missing.length > 0) {
|
|
message =
|
|
`Not configured yet. Missing: ${missing.join(', ')}. Run this tool with action "configure" ` +
|
|
'to set embeddingModel and embeddingDimensions, then "prepare".'
|
|
// The one thing a new user cannot look up anywhere: how wide this
|
|
// model's vectors are. Say it plainly when that is what is missing.
|
|
if (
|
|
endpoint.checked === true &&
|
|
typeof endpoint.dimensions === 'number' &&
|
|
missing.includes('embedding_dimensions')
|
|
) {
|
|
message +=
|
|
` The endpoint is reachable and returned ${endpoint.dimensions} dimensions, so ` +
|
|
`set embeddingDimensions to ${endpoint.dimensions}.`
|
|
}
|
|
} else if (endpoint.ok === false) {
|
|
message =
|
|
`Configured, but the embedding backend failed its check: ${endpoint.error}. ` +
|
|
`(probed ${endpoint.url} with model "${endpoint.sentModel}")`
|
|
} else if (endpoint.checked === true) {
|
|
message =
|
|
`Ready. ${binary ?? 'slopo'} will use "${slopoConfig.embedding_model}" at ` +
|
|
`${Number(slopoConfig.embedding_dimensions)} dimensions, confirmed against ${endpoint.url}.`
|
|
} else {
|
|
message =
|
|
`Configured against "${slopoConfig.embedding_model}" at ` +
|
|
`${Number(slopoConfig.embedding_dimensions)} dimensions. Nothing was probed: ${endpoint.skipped}.`
|
|
}
|
|
|
|
return foreground({
|
|
ok: missing.length === 0 && endpoint.ok !== false,
|
|
exitCode: null,
|
|
details: {
|
|
dshHome,
|
|
cwd,
|
|
stateDir: paths.stateDir,
|
|
configPath: paths.configPath,
|
|
database: existsSync(paths.dbFile) ? paths.dbFile : null,
|
|
ignoreFile: existsSync(paths.ignoreFile) ? paths.ignoreFile : null,
|
|
agentLog: existsSync(paths.logFile) ? paths.logFile : null,
|
|
slopoBinary: binary ?? null,
|
|
slopoVersion: slopoVersion ?? null,
|
|
slopoInstalledInto: binary === undefined ? null : dshHome,
|
|
settingsNamespace: writeSettings === undefined ? null : SETTINGS_NAMESPACE,
|
|
embeddingModel: slopoConfig.embedding_model ?? null,
|
|
embeddingDimensions: slopoConfig.embedding_dimensions ?? null,
|
|
apiKeyConfigured: typeof settings.embeddingApiKey === 'string' && settings.embeddingApiKey !== '',
|
|
embeddingEndpoint: endpoint,
|
|
// What slopo will actually look at, and the limit on how much of
|
|
// it may be embedded without being asked. Both are the levers a
|
|
// runaway scope is fixed with, so status has to name them.
|
|
sourceDirExclude: effectiveExcludes(settings),
|
|
useDefaultExcludes: settings.useDefaultExcludes !== false,
|
|
maxIndexUnits: Number(settings.maxIndexUnits),
|
|
missingSettings: missing,
|
|
problems,
|
|
},
|
|
message,
|
|
})
|
|
}
|
|
|
|
if (args.action === 'configure') {
|
|
const patch = args.config
|
|
if (patch === undefined || patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
return foreground({ ok: false, exitCode: null, message: 'The `config` parameter must be an object.', errorCode: 'BAD_PATCH' })
|
|
}
|
|
const unknown = Object.keys(patch).filter((key) => !SETTING_KEYS.includes(key))
|
|
if (unknown.length > 0) {
|
|
return foreground({
|
|
ok: false,
|
|
exitCode: null,
|
|
errorCode: 'UNKNOWN_SETTING',
|
|
message: `Unknown setting(s): ${unknown.join(', ')}.`,
|
|
remedy: `Known settings: ${SETTING_KEYS.join(', ')}.`,
|
|
})
|
|
}
|
|
if (writeSettings === undefined) {
|
|
return foreground({
|
|
ok: false,
|
|
exitCode: null,
|
|
errorCode: 'NO_SETTINGS_PROVIDER',
|
|
message: 'No settings provider is mounted, so configuration cannot be persisted at runtime.',
|
|
remedy: `Set these values in the dsh-slopo row of your cordis layer instead: ${JSON.stringify(patch)}`,
|
|
})
|
|
}
|
|
await writeSettings(patch)
|
|
return foreground({ ok: true, exitCode: 0, message: `Updated ${Object.keys(patch).join(', ')}.` })
|
|
}
|
|
|
|
if (shell === undefined) {
|
|
return foreground({
|
|
ok: false,
|
|
exitCode: null,
|
|
errorCode: 'NO_SHELL',
|
|
message: 'No shell executor is available, so slopo cannot be run.',
|
|
remedy: 'Load @deepseek-ai/dsh-bash-local (or another shell provider) into this profile.',
|
|
})
|
|
}
|
|
|
|
// Resolve the CALLING SESSION's sandbox policy, the way the bash tool
|
|
// does. `dsh-bash-sandbox.resolve()` stamps
|
|
// `request.sandboxPolicy ?? ctx.sandboxPolicy.resolve()`, so omitting it
|
|
// does not mean "unconfined" — it silently substitutes the DEPLOYMENT
|
|
// default (`DSH_PERMISSION_MODE ?? 'workspace-write'`) for the session's
|
|
// preset. That confines a call the session asked to run unconfined, and
|
|
// on a host with no sandbox runner it fails with SandboxUnavailableError
|
|
// before slopo is ever reached.
|
|
if (sandboxDefault !== undefined && policyService === undefined) {
|
|
return foreground({
|
|
ok: false,
|
|
exitCode: null,
|
|
errorCode: 'NO_SANDBOX_POLICY',
|
|
message: 'The mounted shell executor confines commands, but ctx.sandboxPolicy is absent.',
|
|
remedy: 'Compose @deepseek-ai/dsh-sandbox-policy, or mount an unconfining shell executor.',
|
|
})
|
|
}
|
|
|
|
// Slopo's state is deliberately outside the workspace, which a
|
|
// workspace-write sandbox forbids writing to. Say so before a run and
|
|
// name both ways out, rather than letting a write fail mid-embed.
|
|
if (
|
|
sandboxPolicy !== undefined &&
|
|
sandboxPolicy.mode !== 'danger-full-access' &&
|
|
!isInside(sandboxPolicy.workspaceRoot, paths.stateDir)
|
|
) {
|
|
return foreground({
|
|
ok: false,
|
|
exitCode: null,
|
|
errorCode: 'STATE_OUTSIDE_SANDBOX',
|
|
message:
|
|
`This session runs under the "${sandboxPolicy.mode}" sandbox, which permits writes only under ` +
|
|
`${sandboxPolicy.workspaceRoot} — but slopo's index, config and log live in ${paths.stateDir}.`,
|
|
remedy:
|
|
'Either run this session under the danger-full-access preset, or set `stateDir` to a directory ' +
|
|
'inside the workspace using action "configure".',
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Run one command through the shell seam.
|
|
*
|
|
* `runShell` returns the seam's own result; everything below wants the
|
|
* flattened strings, so the adaptation happens once, here.
|
|
*
|
|
* `workdir` is overridable because installing slopo has nothing to do
|
|
* with the workspace: the workspace may not even exist yet on a fresh
|
|
* session, and a shell that cannot `cd` there fails before `uv` is
|
|
* reached. The plugin's own state directory is always a real directory
|
|
* by the time an install runs.
|
|
*/
|
|
const run = async (command, { env, timeoutMs, signal, workdir } = {}) => {
|
|
const result = await runShell(shell, {
|
|
command,
|
|
workdir: workdir ?? paths.cwd,
|
|
timeoutMs: timeoutMs ?? (Number(settings.timeoutMs) || 600_000),
|
|
...(signal === undefined ? {} : { signal }),
|
|
// Merges over a parent-derived base, so PATH and HOME survive.
|
|
...(env === undefined ? {} : { env }),
|
|
// Omitting this substitutes the deployment default; see above.
|
|
...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
|
|
})
|
|
return {
|
|
exitCode: result.exitCode,
|
|
stdout: result.stdout.text,
|
|
stderr: result.stderr.text,
|
|
truncated: result.stdout.truncated || result.stderr.truncated,
|
|
timedOut: Boolean(result.timedOut),
|
|
}
|
|
}
|
|
|
|
if (args.action === 'update') {
|
|
const result = await run(commandLine(['uv', ...uvInstallArgs({ upgrade: true })]), {
|
|
env: installEnv(dshHome),
|
|
timeoutMs: 900_000,
|
|
signal: exec.signal,
|
|
})
|
|
return foreground({
|
|
ok: result.exitCode === 0,
|
|
exitCode: result.exitCode,
|
|
report: result.stdout.trim(),
|
|
errorCode: result.exitCode === 0 ? undefined : 'UPDATE_FAILED',
|
|
remedy: result.exitCode === 0 ? undefined : result.stderr.trim(),
|
|
})
|
|
}
|
|
|
|
// Everything below actually needs slopo.
|
|
const problems = []
|
|
const slopoConfig = toSlopoConfig(settings, paths, problems)
|
|
const missing = missingRequired(slopoConfig)
|
|
if (missing.length > 0) {
|
|
return foreground({
|
|
ok: false,
|
|
exitCode: null,
|
|
errorCode: 'NOT_CONFIGURED',
|
|
message: `Slopo is not configured for this workspace yet. Missing: ${missing.join(', ')}.`,
|
|
remedy:
|
|
'Call this tool with action "configure" first, for example ' +
|
|
'{"embeddingModel":"ollama/<model>:latest","embeddingDimensions":768,"embeddingApiBase":"http://127.0.0.1:11434"}.',
|
|
details: { missing, problems },
|
|
})
|
|
}
|
|
|
|
// The state directory and the config file come FIRST, before the
|
|
// executable is resolved. The config is a pure function of the
|
|
// settings — nothing about it needs slopo to exist — and writing it
|
|
// early means a run that stops at "not installed" still leaves the
|
|
// resolved configuration on disk to look at. It also gives the install
|
|
// below a real working directory to run in.
|
|
await mkdir(paths.stateDir, { recursive: true })
|
|
await writeFile(paths.configPath, renderSlopoConfig(slopoConfig), 'utf8')
|
|
|
|
const binary = await ensureSlopo(settings, run, paths.stateDir)
|
|
if (typeof binary !== 'string') {
|
|
return foreground({ ok: false, exitCode: null, errorCode: binary.error, message: binary.remedy, remedy: binary.detail })
|
|
}
|
|
|
|
const env =
|
|
typeof settings.embeddingApiKey === 'string' && settings.embeddingApiKey !== ''
|
|
? { SLOPO_EMBEDDING_API_KEY: settings.embeddingApiKey }
|
|
: undefined
|
|
|
|
// Every action indexes FIRST, so the size gate sees the real scope
|
|
// before any embedding is paid for. Slopo's own >1000 guard cannot do
|
|
// this: it measures the pre-existing backlog, and a fresh database has
|
|
// no units at all — which is exactly when a runaway costs the most.
|
|
const excludes = effectiveExcludes(settings)
|
|
const indexArgs = slopoArgs(paths.configPath, 'index')
|
|
const restArgs =
|
|
args.action === 'prepare'
|
|
? [slopoArgs(paths.configPath, 'embed')]
|
|
: [slopoArgs(paths.configPath, args.action, { base: args.base, cluster: args.cluster })]
|
|
|
|
/**
|
|
* Index, check the budget, then run the action.
|
|
* Returns a refusal instead of running when the scope is too large.
|
|
*/
|
|
const runScoped = async (signal) => {
|
|
const indexed = await run(commandLine([binary, ...indexArgs]), { env, signal })
|
|
const parsed = parseIndexOutput(indexed.stdout)
|
|
// Prefer the database. `Indexed N` counts only what was NEWLY indexed,
|
|
// so it reads zero on a warm database whose backlog is still full —
|
|
// exactly the state a refused run leaves behind.
|
|
const fromDb = readIndexCounts(paths.dbFile)
|
|
const counted =
|
|
fromDb === undefined
|
|
? parsed
|
|
: { units: fromDb.unembedded, files: fromDb.files, inScope: fromDb.units, embedded: fromDb.embedded }
|
|
if (indexed.exitCode !== 0) return { refused: undefined, counted, outcome: indexed }
|
|
const decision = checkBudget(counted, Number(settings.maxIndexUnits), excludes)
|
|
if (!decision.allowed) return { refused: decision.reason, counted, outcome: undefined }
|
|
const restCommand = restArgs.map((argv) => commandLine([binary, ...argv])).join(' && ')
|
|
return { refused: undefined, counted, outcome: await run(restCommand, { env, signal }) }
|
|
}
|
|
|
|
const scopeRefusal = (reason, counted) =>
|
|
foreground({
|
|
ok: false,
|
|
exitCode: null,
|
|
errorCode: 'SCOPE_TOO_LARGE',
|
|
message: reason,
|
|
remedy: budgetRemedy(Number(settings.maxIndexUnits), excludes),
|
|
details: { counted, excludes, maxIndexUnits: Number(settings.maxIndexUnits) },
|
|
})
|
|
|
|
// Background is for the long warm-up, not for every action. A completion
|
|
// notice is best-effort: a KILLED job deliberately produces none, and an
|
|
// agent whose turn has ended may have the message injected rather than be
|
|
// woken for it. A review that returns its report directly is worth more
|
|
// than a job id the caller has to remember to collect.
|
|
const wantsBackground = args.run_in_background ?? BACKGROUND_BY_DEFAULT.has(args.action)
|
|
const jobs = ctx.get('jobs')
|
|
|
|
if (wantsBackground && jobs !== undefined) {
|
|
const jobId = jobs.start({
|
|
kind: 'slopo',
|
|
label: `slopo ${args.action}`,
|
|
// Ownership is fenced by SESSION ID, not by the agent object: the
|
|
// registry cancels and awaits the job when that session's agent is
|
|
// disposed, and it looks the id up itself. Passing `exec.agent`
|
|
// here registered a job no session could collect or stop.
|
|
...(exec.agent === undefined ? {} : { owner: exec.agent.id }),
|
|
run: () => {
|
|
const controller = new AbortController()
|
|
// `done` resolves to a JobOutcome: `{ status, detail, result }`.
|
|
// The report rides `result`, which the registry hands to the
|
|
// model on its first read after settlement — read it back with
|
|
// job_output. (The field is `result`, not `output`: the ring
|
|
// carries a job's stream, and `result` carries the return value
|
|
// of one whose product is a value rather than a stream.)
|
|
const done = (async () => {
|
|
const { refused, counted, outcome } = await runScoped(controller.signal)
|
|
const logDetail = await readLogTail(paths.logFile)
|
|
if (refused !== undefined) {
|
|
return {
|
|
status: 'failed',
|
|
detail: 'SCOPE_TOO_LARGE',
|
|
result: `${refused}\n\n${budgetRemedy(Number(settings.maxIndexUnits), excludes)}`,
|
|
}
|
|
}
|
|
if (outcome.exitCode === 0) {
|
|
return { status: 'completed', detail: 'exit code: 0', result: outcome.stdout.trim() || 'No duplicates found' }
|
|
}
|
|
const explained = explainFailure({ stderr: outcome.stderr, stdout: outcome.stdout, logDetail })
|
|
return {
|
|
status: 'failed',
|
|
detail: explained.code,
|
|
result: [explained.message, explained.remedy, logDetail.trim()].filter((line) => line !== '').join('\n\n'),
|
|
}
|
|
})()
|
|
return { cancel: () => controller.abort(), done }
|
|
},
|
|
})
|
|
return toJsonValue({ kind: 'background', action: args.action, jobId, logPath: paths.logFile, configPath: paths.configPath })
|
|
}
|
|
|
|
const { refused, counted, outcome } = await runScoped(exec.signal)
|
|
const logDetail = await readLogTail(paths.logFile)
|
|
if (refused !== undefined) return scopeRefusal(refused, counted)
|
|
if (outcome.exitCode === 0) {
|
|
return foreground({ ok: true, exitCode: 0, report: outcome.stdout.trim() || 'No duplicates found' })
|
|
}
|
|
const explained = explainFailure({ stderr: outcome.stderr, stdout: outcome.stdout, logDetail })
|
|
return foreground({
|
|
ok: false,
|
|
exitCode: outcome.exitCode,
|
|
errorCode: explained.code,
|
|
message: explained.message,
|
|
remedy: explained.remedy,
|
|
logError: lastLogError(logDetail),
|
|
})
|
|
},
|
|
}),
|
|
)
|
|
}
|
|
|
|
/** Read the tail of slopo's agent log, tolerating a missing file. */
|
|
async function readLogTail(path) {
|
|
try {
|
|
const contents = await readFile(path, 'utf8')
|
|
return contents.length > 16_000 ? contents.slice(-16_000) : contents
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
/** True when `path` is `root` itself or sits underneath it. */
|
|
function isInside(root, path) {
|
|
const prefix = root.endsWith(sep) ? root : root + sep
|
|
return path === root || path.startsWith(prefix)
|
|
}
|