/** * 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 } }