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:
1 parent
3490e5aba0
commit
844f12faa3
2 files changed
+281
No files matched your search
+202
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user