Add path resolution and slopo config rendering

Every path the plugin owns is under $DSH_HOME. Only two directories in the
container this runs in are persistent volumes ($DSH_HOME and the workspace
root); $HOME/.local -- where 'uv tool install' puts slopo and its CPython by
default -- is on the container's writable layer and dies on recreation, not
merely on restart.

renderSlopoConfig writes from a closed allow-list because slopo treats an
unknown config key as a hard error, and quotes every scalar because a path
can contain a colon or a leading dash. Verified against slopo 0.8.0 itself:
it reads the generated file back with no complaint and round-trips all 19
keys. The embedding API key is deliberately absent -- slopo reads
SLOPO_EMBEDDING_API_KEY from the environment ahead of the YAML value, so the
secret never lands on disk.
This commit is contained in:
dsh-slopo agent committed 2026-09-22 06:06:44 +01:00
1 parent 3490e5aba0
commit 844f12faa3
2 files changed
+281

No files matched your search

+202
View File
@@ -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<string, unknown>} input
* @returns {Record<string, unknown>} 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<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]
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<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 }
}