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.
203 lines
6.9 KiB
JavaScript
203 lines
6.9 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
|
|
*/
|
|
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 }
|
|
}
|