diff --git a/lib/config.js b/lib/config.js index dfbc28e..2208608 100644 --- a/lib/config.js +++ b/lib/config.js @@ -81,9 +81,21 @@ const NUMERIC_KEYS = new Set([ * @param {Record} input * @returns {Record} only the keys with a usable value */ +/** + * Keys this plugin will never write into the config file, whatever it is handed. + * + * `embedding_api_key` is a legitimate slopo key, but slopo reads + * `SLOPO_EMBEDDING_API_KEY` from the environment *ahead* of the file, so the + * environment is both higher-precedence and the only place a secret belongs. + * Enforcing that here rather than at the call site means no future caller can + * leak the key into a file on disk by passing it through. + */ +const NEVER_WRITTEN_KEYS = new Set(['embedding_api_key']) + export function pruneConfig(input) { const output = {} for (const key of SLOPO_CONFIG_KEYS) { + if (NEVER_WRITTEN_KEYS.has(key)) continue if (!Object.hasOwn(input, key)) continue const value = input[key] if (value === undefined || value === null || value === '') continue @@ -104,7 +116,11 @@ export function pruneConfig(input) { export function missingRequired(config) { return SLOPO_REQUIRED_KEYS.filter((key) => { const value = config[key] - return value === undefined || value === null || value === '' + if (value === undefined || value === null || value === '') return true + // A dimension of zero is how an unset number arrives from a settings form, + // and slopo requires a positive integer, so it is missing, not configured. + if (key === 'embedding_dimensions') return !Number.isFinite(Number(value)) || Number(value) <= 0 + return false }) } diff --git a/lib/paths.js b/lib/paths.js index 8cbbb05..bcede6b 100644 --- a/lib/paths.js +++ b/lib/paths.js @@ -17,7 +17,7 @@ import { createHash } from 'node:crypto' import { homedir } from 'node:os' import { basename, join, resolve } from 'node:path' -/** Plugin name, used for the `$DSH_HOME/plugin-data/` directory. */ +/** Plugin name. Also the `$DSH_HOME/` directory, matching graph-memory's shape. */ export const PLUGIN_NAME = 'dsh-slopo' /** The dsh home directory: `$DSH_HOME`, else `~/.dsh`. */ @@ -44,12 +44,12 @@ export function workspaceKey(cwd) { /** Per-workspace state: the slopo config, index database, ignore file and log. */ export function workspaceStateDir(dshHome, cwd) { - return join(dshHome, 'plugin-data', PLUGIN_NAME, 'workspaces', workspaceKey(cwd)) + return join(pluginDataDir(dshHome), 'workspaces', workspaceKey(cwd)) } /** Root of this plugin's own data, independent of any workspace. */ export function pluginDataDir(dshHome) { - return join(dshHome, 'plugin-data', PLUGIN_NAME) + return join(dshHome, PLUGIN_NAME) } /** diff --git a/lib/slopo.js b/lib/slopo.js new file mode 100644 index 0000000..3de4463 --- /dev/null +++ b/lib/slopo.js @@ -0,0 +1,267 @@ +/** + * 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 '' +}