diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..dfbc28e --- /dev/null +++ b/lib/config.js @@ -0,0 +1,202 @@ +/** + * Building the `slopo.conf.yaml` that slopo is actually invoked with. + * + * Two facts about slopo's config handling shape everything here. + * + * 1. Unknown keys are a *hard error*, not a warning (`unrecognized config key + * 'x'`, exit 1). So this module writes from a closed allow-list and refuses + * to emit anything outside it. + * 2. Relative paths resolve against the process working directory, never + * against the config file's own directory. So every path written here is + * absolute, computed by the caller. + * + * The embedding API key is deliberately *not* written to this file. Slopo reads + * `SLOPO_EMBEDDING_API_KEY` from the environment *before* the YAML value, so + * passing it in the child process's environment is both higher-precedence and + * keeps the secret out of a file on disk. + */ + +/** + * Every key slopo 0.8.0 accepts, copied from its `_KNOWN_CONFIG_KEYS`. + * + * Kept as a literal rather than derived so that a slopo upgrade which adds a + * key is a deliberate edit here instead of a silent pass-through that only + * fails on the machine that has the newer slopo. + */ +export const SLOPO_CONFIG_KEYS = Object.freeze([ + 'source_dir', + 'source_dir_exclude', + 'db_file', + 'report_dir', + 'ignore_file', + 'agent_log_file', + 'embedding_model', + 'embedding_dimensions', + 'embedding_api_key', + 'embedding_params', + 'embedding_batch_size', + 'embedding_batch_chars', + 'embedding_request_delay', + 'analyze_similarity_threshold', + 'analyze_rerank_threshold', + 'review_similarity_threshold', + 'review_rerank_threshold', + 'body_node_count_threshold', + 'block_node_count_threshold', +]) + +/** The three keys slopo refuses to start without. */ +export const SLOPO_REQUIRED_KEYS = Object.freeze([ + 'source_dir', + 'embedding_model', + 'embedding_dimensions', +]) + +/** Five keys slopo freezes into the database at first `index`. */ +export const SLOPO_FROZEN_KEYS = Object.freeze([ + 'source_dir', + 'embedding_model', + 'embedding_dimensions', + 'body_node_count_threshold', + 'block_node_count_threshold', +]) + +const NUMERIC_KEYS = new Set([ + 'embedding_dimensions', + 'embedding_batch_size', + 'embedding_batch_chars', + 'embedding_request_delay', + 'analyze_similarity_threshold', + 'analyze_rerank_threshold', + 'review_similarity_threshold', + 'review_rerank_threshold', + 'body_node_count_threshold', + 'block_node_count_threshold', +]) + +/** + * Drop empty values so an unset field falls back to slopo's own default rather + * than being written as an empty string, which slopo rejects. + * + * @param {Record} input + * @returns {Record} only the keys with a usable value + */ +export function pruneConfig(input) { + const output = {} + for (const key of SLOPO_CONFIG_KEYS) { + if (!Object.hasOwn(input, key)) continue + const value = input[key] + if (value === undefined || value === null || value === '') continue + if (Array.isArray(value) && value.length === 0) continue + if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) continue + output[key] = value + } + return output +} + +/** + * Which required settings are still missing, so the tool can say what to do + * rather than relaying slopo's terser message. + * + * @param {Record} config + * @returns {string[]} the missing key names, in slopo's own order + */ +export function missingRequired(config) { + return SLOPO_REQUIRED_KEYS.filter((key) => { + const value = config[key] + return value === undefined || value === null || value === '' + }) +} + +function yamlScalar(value) { + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new TypeError(`cannot write non-finite number to config: ${value}`) + return String(value) + } + if (typeof value === 'boolean') return value ? 'true' : 'false' + if (value === null) return 'null' + if (typeof value !== 'string') throw new TypeError(`unsupported config value type: ${typeof value}`) + // Always double-quoted. A path can contain spaces, colons, '#' or a leading + // '-'/'*', each of which changes meaning in a plain scalar. Escaping is the + // two sequences YAML's double-quoted style defines. + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` +} + +function yamlBlock(key, value, lines) { + if (Array.isArray(value)) { + lines.push(`${key}:`) + for (const item of value) lines.push(` - ${yamlScalar(item)}`) + return + } + if (typeof value === 'object') { + lines.push(`${key}:`) + for (const [nested, nestedValue] of Object.entries(value)) { + if (nestedValue === undefined || nestedValue === null || nestedValue === '') continue + lines.push(` ${nested}: ${yamlScalar(nestedValue)}`) + } + return + } + lines.push(`${key}: ${yamlScalar(value)}`) +} + +/** + * Render a slopo config file. + * + * Emits `key: value` with a space after the colon throughout, because slopo + * lints for exactly that and reports `missing space after ':'` otherwise. + * + * @param {Record} config + * @returns {string} the file contents + */ +export function renderSlopoConfig(config) { + const pruned = pruneConfig(config) + const lines = [ + '# Generated by dsh-slopo. Edits here are overwritten on the next run.', + '# Change the values in the dsh-slopo settings namespace instead.', + '#', + '# Paths are absolute on purpose: slopo resolves relative paths against the', + '# process working directory, not against this file.', + '#', + '# The embedding API key is not stored here. It is passed to slopo as the', + '# SLOPO_EMBEDDING_API_KEY environment variable, which takes precedence over', + '# this file anyway.', + '', + ] + for (const key of SLOPO_CONFIG_KEYS) { + if (!Object.hasOwn(pruned, key)) continue + yamlBlock(key, pruned[key], lines) + } + return `${lines.join('\n')}\n` +} + +/** + * Coerce and validate the handful of values that come from a settings form, so + * a string `"768"` typed into a text field becomes the number slopo needs. + * + * @param {Record} input + * @returns {{ config: Record, problems: string[] }} + */ +export function normalizeConfig(input) { + const config = pruneConfig(input) + const problems = [] + for (const [key, value] of Object.entries(config)) { + if (!NUMERIC_KEYS.has(key)) continue + const numeric = typeof value === 'string' ? Number(value) : value + if (typeof numeric !== 'number' || !Number.isFinite(numeric)) { + problems.push(`${key} must be a number (got ${JSON.stringify(value)})`) + delete config[key] + continue + } + if (!Number.isInteger(numeric) && Number.isInteger(Number(value)) === false && key !== 'analyze_similarity_threshold') { + // thresholds are floats; the rest are counts and a float here almost + // always means a unit mistake + if (!key.endsWith('_threshold')) { + problems.push(`${key} must be a whole number (got ${numeric})`) + delete config[key] + continue + } + } + config[key] = numeric + } + return { config, problems } +} diff --git a/lib/paths.js b/lib/paths.js new file mode 100644 index 0000000..8cbbb05 --- /dev/null +++ b/lib/paths.js @@ -0,0 +1,79 @@ +/** + * Where this plugin keeps things on disk. + * + * Every path here is deliberately under `$DSH_HOME`, because `$DSH_HOME` is the + * one directory that survives the container this harness usually runs in. The + * uv-managed slopo install and the CPython it needs would otherwise live in + * `~/.local`, which is on the container's writable layer and is lost whenever + * the container is recreated rather than merely restarted. + * + * Relative paths are never used: slopo resolves every relative path against the + * *process working directory*, not against the config file that named it, so a + * relative path in a config invoked as `--config /elsewhere/x.yaml` still lands + * in whatever directory the caller happened to be in. + */ + +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. */ +export const PLUGIN_NAME = 'dsh-slopo' + +/** The dsh home directory: `$DSH_HOME`, else `~/.dsh`. */ +export function resolveDshHome(env = process.env) { + const configured = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : '' + return configured === '' ? join(homedir(), '.dsh') : resolve(configured) +} + +/** + * A stable, filesystem-safe key for one workspace. + * + * The basename is for a human reading the directory listing; the hash is what + * actually identifies it, so two checkouts both named `app` cannot collide. + */ +export function workspaceKey(cwd) { + const absolute = resolve(cwd) + const name = + basename(absolute) + .replace(/[^A-Za-z0-9._-]+/g, '-') + .replace(/^[-.]+|-+$/g, '') || 'workspace' + const hash = createHash('sha256').update(absolute).digest('hex').slice(0, 8) + return `${name}-${hash}` +} + +/** 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)) +} + +/** Root of this plugin's own data, independent of any workspace. */ +export function pluginDataDir(dshHome) { + return join(dshHome, 'plugin-data', PLUGIN_NAME) +} + +/** + * The directory `uv tool install` writes the slopo venv and shim into. + * + * Redirected here from its `~/.local/share/uv/tools` default for the durability + * reason in the file header. + */ +export function managedToolDir(dshHome) { + return join(pluginDataDir(dshHome), 'uv-tools') +} + +/** The directory uv keeps its downloaded CPython builds in. */ +export function managedPythonDir(dshHome) { + return join(pluginDataDir(dshHome), 'uv-python') +} + +/** The slopo executable inside {@link managedToolDir}. */ +export function managedSlopoPath(dshHome) { + return join(managedToolDir(dshHome), 'bin', process.platform === 'win32' ? 'slopo.exe' : 'slopo') +} + +/** Slopo's own default install location, for machines that already have one. */ +export function userLocalSlopoPath(env = process.env) { + const home = typeof env.HOME === 'string' && env.HOME !== '' ? env.HOME : homedir() + return join(home, '.local', 'bin', process.platform === 'win32' ? 'slopo.exe' : 'slopo') +}