Files
dsh-slopo/lib/slopo.js
T
dsh-slopo agent e956a8c121 Add the slopo CLI adapter
Three things live here: where the plugin keeps its files, how it renders a
slopo config, and how it finds, installs and invokes the binary.

The adapter is driven by behaviour observed against slopo 0.8.0 rather than by
its documentation:

  * --config is a global option and must precede the subcommand
  * every path is written absolute, because slopo resolves relative paths
    against the process working directory, not against the config file
  * unknown config keys are a hard error, so the renderer writes from a closed
    allow-list
  * the embedding API key is never written at all -- slopo reads
    SLOPO_EMBEDDING_API_KEY from the environment ahead of the file, so the
    secret stays out of every file this plugin touches. Enforced in pruneConfig
    rather than at the call site, so no future caller can leak it by passing one.

explainFailure maps slopo's deliberately terse agent-facing messages onto a
message and a next step, and reads the log tail because stderr alone leaves the
agent with no move: on a real failure stderr said "Embedding failed." while the
log named the exact litellm parameter that was rejected.

installEnv redirects UV_TOOL_DIR, UV_TOOL_BIN_DIR and UV_PYTHON_INSTALL_DIR
into $DSH_HOME. Only two directories in this container are persistent mounts;
~/.local, where uv installs by default, is on the container's writable layer
and does not survive a recreation.
2026-09-22 06:11:12 +01:00

268 lines
9.8 KiB
JavaScript

/**
* Locating, installing, and invoking the slopo CLI.
*
* Everything here is a pure function of its arguments so the decisions can be
* tested without a running harness. The one side effect — running `uv` — is
* expressed as a command line that the caller executes through the shell seam.
*
* Slopo is driven as a separate process, never imported. Its package is
* AGPL-3.0-or-later and the README does not accept patches, so the command-line
* interface is the only contract worth building on.
*/
import { existsSync } from 'node:fs'
import { delimiter, join } from 'node:path'
import { managedPythonDir, managedSlopoPath, managedToolDir, userLocalSlopoPath } from './paths.js'
/** The PyPI distribution name. */
export const SLOPO_PACKAGE = 'slopo'
/** Slopo requires Python >= 3.12; uv fetches a managed interpreter for this. */
export const SLOPO_PYTHON = '3.12'
/**
* The agent-config version this bundle's ported skills were written against.
*
* Slopo's `--config-version` flag fails the command when the agent-facing
* contract has moved on, which is the signal that the skill text in `skills/`
* is stale and needs re-porting. Passing it is therefore a deliberate alarm,
* not boilerplate.
*/
export const SLOPO_AGENT_CONFIG_VERSION = 1
const EXE = process.platform === 'win32' ? 'slopo.exe' : 'slopo'
/**
* Binaries to try, best first.
*
* The managed install is checked before `PATH`, because a stale slopo of a
* different version earlier on `PATH` would otherwise silently win.
*
* @param {string} dshHome
* @param {Record<string, string | undefined>} [env]
* @returns {string[]}
*/
export function slopoCandidates(dshHome, env = process.env) {
const candidates = []
const configured = typeof env.DSH_SLOPO_BINARY === 'string' ? env.DSH_SLOPO_BINARY.trim() : ''
if (configured !== '') candidates.push(configured)
candidates.push(managedSlopoPath(dshHome))
for (const dir of (env.PATH ?? '').split(delimiter)) {
if (dir !== '') candidates.push(join(dir, EXE))
}
candidates.push(userLocalSlopoPath(env))
return candidates
}
/**
* The first candidate that exists, or `undefined`.
*
* @param {string} dshHome
* @param {Record<string, string | undefined>} [env]
* @param {(path: string) => boolean} [exists] injected for tests
* @returns {string | undefined}
*/
export function findSlopo(dshHome, env = process.env, exists = existsSync) {
for (const candidate of slopoCandidates(dshHome, env)) {
if (exists(candidate)) return candidate
}
return undefined
}
/**
* The environment `uv` must run under to install into `$DSH_HOME`.
*
* All three variables are redirected: `UV_TOOL_DIR` for the venv and shim,
* `UV_TOOL_BIN_DIR` for the executable, and `UV_PYTHON_INSTALL_DIR` for the
* CPython that slopo requires. Without the last one uv downloads a second
* interpreter into the container's writable layer and the install does not
* survive a container recreation.
*
* @param {string} dshHome
* @param {Record<string, string | undefined>} [env]
* @returns {Record<string, string | undefined>}
*/
export function installEnv(dshHome, env = process.env) {
return {
...env,
UV_TOOL_DIR: managedToolDir(dshHome),
UV_TOOL_BIN_DIR: join(managedToolDir(dshHome), 'bin'),
UV_PYTHON_INSTALL_DIR: managedPythonDir(dshHome),
}
}
/**
* The `uv` arguments that install or upgrade slopo into `$DSH_HOME`.
*
* @param {{ upgrade?: boolean, version?: string }} [options]
* @returns {string[]}
*/
export function uvInstallArgs({ upgrade = false, version = '' } = {}) {
const spec = version === '' ? SLOPO_PACKAGE : `${SLOPO_PACKAGE}==${version.replace(/^v/, '')}`
return ['tool', 'install', spec, '--python', SLOPO_PYTHON, ...(upgrade ? ['--upgrade'] : [])]
}
/** POSIX single-quote a value. The harness shell is `bash -c` on this platform. */
export function shellQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`
}
/** Join argv into a command line the shell seam can execute. */
export function commandLine(argv) {
return argv.map(shellQuote).join(' ')
}
/**
* Slopo arguments for one action.
*
* `--config` is a global option on slopo's Typer callback, so it must precede
* the subcommand. That placement was verified against slopo 0.8.0 rather than
* assumed.
*
* @param {string} configPath
* @param {string} action
* @param {{ base?: string, cluster?: string }} [params]
* @returns {string[]}
*/
export function slopoArgs(configPath, action, params = {}) {
const global = ['--config', configPath]
const version = ['--config-version', String(SLOPO_AGENT_CONFIG_VERSION)]
const bare = ['--config', configPath]
switch (action) {
case 'review': {
const args = [...global, 'agent-review', ...version]
if (params.base !== undefined && params.base !== '') args.push('--base', params.base)
return args
}
case 'analyze':
return [...global, 'agent-analyze', ...version]
case 'analyze-one': {
const args = [...global, 'agent-analyze', ...version, '--single']
if (params.cluster !== undefined && params.cluster !== '') args.push('--cluster', params.cluster)
return args
}
case 'index':
return [...bare, 'index']
case 'embed':
return [...bare, 'embed']
case 'show-config':
return [...bare, 'show-config']
case 'version':
return ['--version']
default:
throw new Error(`unknown slopo action "${action}"`)
}
}
/**
* Turn slopo's deliberately terse agent-facing failure into something the model
* can act on.
*
* Slopo prints a short non-revealing line on stderr and writes the real detail
* to the agent log, so a bare relay of stderr leaves the agent with no move. Each
* mapping below was produced by actually triggering the failure against slopo
* 0.8.0, not by reading its error table.
*
* @param {{ stderr: string, stdout: string, logDetail?: string }} input
* @returns {{ code: string, message: string, remedy: string }}
*/
export function explainFailure({ stderr, stdout, logDetail = '' }) {
const text = `${stderr}\n${stdout}\n${logDetail}`
if (/No data in database/i.test(text)) {
return {
code: 'DATABASE_MISSING',
message: 'slopo has no index for this workspace yet.',
remedy:
'Run the `slopo` tool with action "prepare" first. It runs `slopo index`, which is local ' +
'parsing and costs nothing, and then embeds. A review cannot bootstrap the database itself.',
}
}
if (/Configuration mismatch/i.test(text)) {
return {
code: 'CONFIGURATION_MISMATCH',
message:
'The embedding model, its dimensions, the source directory, or a node-count threshold has ' +
'changed since this workspace was first indexed.',
remedy:
'Slopo freezes those five values into the database and refuses to mix generations. Delete ' +
'the database file and run "prepare" again; the next embed costs whatever embedding costs ' +
'on the configured backend, because every unit must be re-embedded.',
}
}
if (/Too many code units to embed/i.test(text)) {
return {
code: 'BACKLOG_TOO_LARGE',
message:
'More than 1000 units are unembedded. Slopo refuses to do that inside an agent call to ' +
'protect your latency.',
remedy:
'Run the `slopo` tool with action "prepare" in the background. That is the same work, just ' +
'somewhere you can watch it and cancel it.',
}
}
if (/Configuration file not found/i.test(text)) {
return {
code: 'CONFIG_MISSING',
message: 'The generated slopo config is missing, or the configured source directory is not a directory.',
remedy: 'Run the `slopo` tool with action "status" to see which settings are unset, then "configure".',
}
}
if (/Loading configuration failed/i.test(text)) {
return {
code: 'CONFIG_INVALID',
message: 'Slopo rejected the generated config file.',
remedy:
'This is a bug in this plugin rather than a problem with your settings — the file is written ' +
'from a closed allow-list. The config path and the parser error are in the detail above.',
}
}
if (/Index is out of date/i.test(text)) {
return {
code: 'INDEX_STALE',
message: 'The index predates the current source tree.',
remedy: 'Run the `slopo` tool with action "prepare" to re-index and re-embed.',
}
}
if (/Git command failed/i.test(text)) {
return {
code: 'GIT_FAILED',
message: 'Slopo shells out to git for a review, and git failed or is missing.',
remedy: 'Check that the workspace is a git repository and that git is installed and on PATH.',
}
}
if (/Embedding failed/i.test(text)) {
return {
code: 'EMBEDDING_FAILED',
message: 'The embedding backend rejected the request.',
remedy:
'Check the embedding model, dimensions and api_base in the settings namespace. The provider ' +
'error itself is in the agent log, not on stdout — read it from the log file named above.',
}
}
return {
code: 'SLOPO_FAILED',
message: 'Slopo exited non-zero and its message did not match a known failure.',
remedy: 'Read the agent log file named above; it carries the full error, while stdout is deliberately terse.',
}
}
/**
* Pull the last error line out of a slopo agent log.
*
* Slopo writes the human-readable error to the log and only a short agent-facing
* line to stderr, so the log is where the actionable text lives. Observed on a
* real failure: stderr said `Embedding failed.` while the log named the exact
* litellm parameter that was rejected.
*
* @param {string} contents
* @returns {string}
*/
export function lastLogError(contents) {
const lines = String(contents).split('\n')
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index].trim()
if (line.startsWith('Error:')) return line
}
return ''
}