Files
dsh-slopo/lib/config.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

219 lines
7.7 KiB
JavaScript

/**
* 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<string, unknown>} input
* @returns {Record<string, unknown>} 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
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<string, unknown>} 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]
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
})
}
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<string, unknown>} 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<string, unknown>} input
* @returns {{ config: Record<string, unknown>, 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 }
}