/** * 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} [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} [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} [env] * @returns {Record} */ 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 '' }