@heretek-ai/epistemic-swarm 0.7.18 → 0.7.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/antigravity/plugin.json +1 -1
- package/plugins/opencode/config-io.js +1084 -0
- package/plugins/opencode/index.js +735 -10
- package/plugins/opencode/tui.js +1028 -1
- package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
- package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
- package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
- package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
- package/runner/__pycache__/errors.cpython-311.pyc +0 -0
- package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
- package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
- package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
- package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
- package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
- package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
- package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
- package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
- package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
- package/runner/mcp_server.py +37 -4
- package/runner/research_swarm.py +12 -2
- package/runner/schemas.py +18 -0
- package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
- package/runner/tests/test_config_schema.py +41 -0
- package/runner/tests/test_opencode_ux.py +1098 -9
- package/runner/tests/test_opencode_v2_registrars.py +1314 -0
- package/runner/tests/test_swarm_config.py +21 -0
- package/schemas/config.schema.json +11 -0
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
- package/skills/swarm_config/configure.py +5 -2
|
@@ -0,0 +1,1084 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IUMBTEMS Epistemic Swarm — dependency-free config read/validate/format/write.
|
|
3
|
+
*
|
|
4
|
+
* Direct-fs fallback for the settings surface (the phase-03 RPC bridge later
|
|
5
|
+
* keeps this path as its offline fallback). No dependencies beyond
|
|
6
|
+
* node:fs/node:path/node:crypto/node:os.
|
|
7
|
+
*
|
|
8
|
+
* Semantics mirror `skills/swarm_config/configure.py` (phase 01 contract):
|
|
9
|
+
* - Validation is derived from `schemas/config.schema.json` (the generated
|
|
10
|
+
* artifact): the dependency-free subset type/enum/minimum/maximum/minLength/
|
|
11
|
+
* pattern (+ required/properties/items/additionalProperties) is interpreted
|
|
12
|
+
* by a line-for-line port of `runner/schema_validate.py`. Unknown keys are
|
|
13
|
+
* allowed and preserved; null is accepted only for nullable keys.
|
|
14
|
+
* - `saveConfig` merges over whatever is on disk, validates, and publishes via
|
|
15
|
+
* a unique temp file + rename. A crash between temp-write and rename leaves
|
|
16
|
+
* the original byte-identical (the temp is unlinked on failure).
|
|
17
|
+
* - Single-writer discipline over `.research/.config.lock`: O_EXCL create with
|
|
18
|
+
* a pid+timestamp payload; a live lock is respected (CONFIG_LOCKED, nothing
|
|
19
|
+
* written); a stale lock (> 5s) is taken over. The lock is released (unlinked
|
|
20
|
+
* when still ours) after every save attempt, so the steady state is lockless.
|
|
21
|
+
*
|
|
22
|
+
* RESIDUAL WINDOW (documented, negligible): the Python writer holds its lock
|
|
23
|
+
* via fcntl.flock on the same path but never writes a pid+timestamp payload
|
|
24
|
+
* and never unlinks. A lockfile without our payload therefore reads as
|
|
25
|
+
* foreign/stale and is ADOPTED BY OVERWRITING (never by unlinking) — so a
|
|
26
|
+
* live Python holder can still be adopted if its write spans the adoption.
|
|
27
|
+
* Python writes are millisecond-scale read-merge-write cycles under its own
|
|
28
|
+
* flock (which still serializes Python-side writers), so the window is
|
|
29
|
+
* negligible; human-paced TUI saves will essentially never land inside it.
|
|
30
|
+
* `release()` unlinks ONLY when the on-disk payload's pid is our own pid —
|
|
31
|
+
* a foreign live lock is never removed, and a lock adopted away from us by
|
|
32
|
+
* a concurrent takeover is left intact.
|
|
33
|
+
* - Optional expected-hash guard (both `expected_hash` and `expectedHash`
|
|
34
|
+
* spellings; both present must agree or the call is rejected with
|
|
35
|
+
* CONFLICTING_EXPECTED_HASH). Full SHA-256 or a prefix of at least 8 hex
|
|
36
|
+
* chars; a mismatch raises CONFIG_STALE with a fresh snapshot and writes
|
|
37
|
+
* nothing. A malformed guard raises CONFIG_INVALID_EXPECTED_HASH.
|
|
38
|
+
* - Every function is pure/defensive: an absent or malformed workspace never
|
|
39
|
+
* throws — readers return defaults/null and writers surface structured
|
|
40
|
+
* errors instead of exceptions from fs reads.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
44
|
+
import {
|
|
45
|
+
existsSync,
|
|
46
|
+
mkdirSync,
|
|
47
|
+
readFileSync,
|
|
48
|
+
renameSync,
|
|
49
|
+
unlinkSync,
|
|
50
|
+
writeFileSync,
|
|
51
|
+
} from 'node:fs';
|
|
52
|
+
import path from 'node:path';
|
|
53
|
+
import { fileURLToPath } from 'node:url';
|
|
54
|
+
|
|
55
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
56
|
+
const __dirname = path.dirname(__filename);
|
|
57
|
+
const PKG_ROOT = path.resolve(__dirname, '../..');
|
|
58
|
+
|
|
59
|
+
export const DEFAULT_RESEARCH_DIR = '.research';
|
|
60
|
+
export const CONFIG_FILE_NAME = 'config.json';
|
|
61
|
+
export const CONFIG_LOCK_NAME = '.config.lock';
|
|
62
|
+
/** A lock older than this is taken over (mirrors the documented 5s rule). */
|
|
63
|
+
export const LOCK_STALE_MS = 5000;
|
|
64
|
+
|
|
65
|
+
/** Legacy per-role backend pins (pre-0.7.6 default persisted verbatim). */
|
|
66
|
+
export const LEGACY_AGENT_BACKEND_PINS = [['claude', '-p']];
|
|
67
|
+
export const AGENT_ROLES = ['alpha', 'beta'];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Env vars that override a file value at runtime (effective source becomes
|
|
71
|
+
* `env-override`). Verified against runner/research_swarm.py + preflight.py:
|
|
72
|
+
* SEARXNG_URL, IUMBTEMS_FETCH_TTL_DAYS, IUMBTEMS_SEARCH_TIMEOUT_S,
|
|
73
|
+
* IUMBTEMS_CACHE_RAW_MARKDOWN, IUMBTEMS_OPENCODE_AUTO,
|
|
74
|
+
* IUMBTEMS_BACKEND_<ROLE>, IUMBTEMS_MODEL_<ROLE>.
|
|
75
|
+
*/
|
|
76
|
+
export const ENV_OVERRIDES = {
|
|
77
|
+
searxng_url: ['SEARXNG_URL'],
|
|
78
|
+
cache_ttl_days: ['IUMBTEMS_FETCH_TTL_DAYS'],
|
|
79
|
+
search_timeout_s: ['IUMBTEMS_SEARCH_TIMEOUT_S'],
|
|
80
|
+
cache_raw_markdown: ['IUMBTEMS_CACHE_RAW_MARKDOWN'],
|
|
81
|
+
opencode_auto: ['IUMBTEMS_OPENCODE_AUTO'],
|
|
82
|
+
agents: [
|
|
83
|
+
'IUMBTEMS_BACKEND_ALPHA',
|
|
84
|
+
'IUMBTEMS_BACKEND_BETA',
|
|
85
|
+
'IUMBTEMS_MODEL_ALPHA',
|
|
86
|
+
'IUMBTEMS_MODEL_BETA',
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// structured errors (mirror configure.py codes/messages)
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
export class ConfigError extends Error {
|
|
95
|
+
constructor(message, details = {}) {
|
|
96
|
+
super(message);
|
|
97
|
+
this.name = 'ConfigError';
|
|
98
|
+
const { code, ...rest } = details || {};
|
|
99
|
+
this.code = code || 'CONFIG_ERROR';
|
|
100
|
+
this.message = message;
|
|
101
|
+
this.details = { ...(rest || {}) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
toDict() {
|
|
105
|
+
const out = { code: this.code, message: this.message };
|
|
106
|
+
if (this.details && Object.keys(this.details).length > 0) {
|
|
107
|
+
out.details = this.details;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
toString() {
|
|
113
|
+
const lines = [`[${this.code}] ${this.message}`];
|
|
114
|
+
for (const problem of this.details.errors || []) {
|
|
115
|
+
lines.push(` - ${problem}`);
|
|
116
|
+
}
|
|
117
|
+
if (this.details.current_hash) {
|
|
118
|
+
lines.push(` current_hash: ${this.details.current_hash}`);
|
|
119
|
+
}
|
|
120
|
+
return lines.join('\n');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export class ConfigValidationError extends ConfigError {
|
|
125
|
+
constructor(problems) {
|
|
126
|
+
const errors = (problems || []).map(String);
|
|
127
|
+
super(
|
|
128
|
+
`Config validation failed with ${errors.length} violation(s); nothing written.`,
|
|
129
|
+
{ errors }
|
|
130
|
+
);
|
|
131
|
+
this.name = 'ConfigValidationError';
|
|
132
|
+
this.code = 'CONFIG_VALIDATION_FAILED';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
get errors() {
|
|
136
|
+
return [...(this.details.errors || [])];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class ConfigStaleError extends ConfigError {
|
|
141
|
+
constructor(expectedHash, currentHash, snapshot = {}) {
|
|
142
|
+
const details = {
|
|
143
|
+
expected_hash: expectedHash,
|
|
144
|
+
current_hash: currentHash,
|
|
145
|
+
written: false,
|
|
146
|
+
...(snapshot || {}),
|
|
147
|
+
};
|
|
148
|
+
super(
|
|
149
|
+
'Config changed on disk since it was read; refusing the stale write. ' +
|
|
150
|
+
'Re-read the fresh snapshot and retry.',
|
|
151
|
+
details
|
|
152
|
+
);
|
|
153
|
+
this.name = 'ConfigStaleError';
|
|
154
|
+
this.code = 'CONFIG_STALE';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export class ConfigHashError extends ConfigError {
|
|
159
|
+
constructor(expectedHash) {
|
|
160
|
+
super(
|
|
161
|
+
'expected_hash must be 8-64 hex characters (a full SHA-256 or a ' +
|
|
162
|
+
'unique prefix of at least 8 hex chars).',
|
|
163
|
+
{ expected_hash: String(expectedHash) }
|
|
164
|
+
);
|
|
165
|
+
this.name = 'ConfigHashError';
|
|
166
|
+
this.code = 'CONFIG_INVALID_EXPECTED_HASH';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A live `.config.lock` is held by another writer; nothing was written. */
|
|
171
|
+
export class ConfigLockedError extends ConfigError {
|
|
172
|
+
constructor(details = {}) {
|
|
173
|
+
super(
|
|
174
|
+
'Another writer holds .research/.config.lock; refusing to clobber a ' +
|
|
175
|
+
'live write. Retry once it is released (stale locks are taken over ' +
|
|
176
|
+
'automatically after 5s).',
|
|
177
|
+
{ written: false, ...(details || {}) }
|
|
178
|
+
);
|
|
179
|
+
this.name = 'ConfigLockedError';
|
|
180
|
+
this.code = 'CONFIG_LOCKED';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// schema loading (derived from the generated artifact, never hand-copied)
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
let cachedSchema = null;
|
|
189
|
+
let schemaAttempted = false;
|
|
190
|
+
|
|
191
|
+
function schemaArtifactPath() {
|
|
192
|
+
return path.join(PKG_ROOT, 'schemas', 'config.schema.json');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The canonical CONFIG schema (generated artifact). Returns null when
|
|
197
|
+
* unreadable — validation then degrades to [] with a warning, mirroring the
|
|
198
|
+
* standalone-skill path in configure.py. Never throws.
|
|
199
|
+
*/
|
|
200
|
+
export function loadConfigSchema() {
|
|
201
|
+
if (schemaAttempted) return cachedSchema;
|
|
202
|
+
schemaAttempted = true;
|
|
203
|
+
try {
|
|
204
|
+
const raw = readFileSync(schemaArtifactPath(), 'utf-8');
|
|
205
|
+
const parsed = JSON.parse(raw);
|
|
206
|
+
if (parsed && typeof parsed === 'object') cachedSchema = parsed;
|
|
207
|
+
} catch {
|
|
208
|
+
try {
|
|
209
|
+
console.error(
|
|
210
|
+
'[iumbtems config-io] Warning: schemas/config.schema.json unreadable; ' +
|
|
211
|
+
'skipping canonical validation.'
|
|
212
|
+
);
|
|
213
|
+
} catch {
|
|
214
|
+
/* logging must never throw */
|
|
215
|
+
}
|
|
216
|
+
cachedSchema = null;
|
|
217
|
+
}
|
|
218
|
+
return cachedSchema;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Test hook: forget the cached schema so the next load re-reads disk. */
|
|
222
|
+
export function _resetSchemaCache() {
|
|
223
|
+
cachedSchema = null;
|
|
224
|
+
schemaAttempted = false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// dependency-free validator subset (port of runner/schema_validate.py)
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
function typeMatches(value, expected) {
|
|
232
|
+
switch (expected) {
|
|
233
|
+
case 'integer':
|
|
234
|
+
return typeof value === 'number' && Number.isInteger(value);
|
|
235
|
+
case 'number':
|
|
236
|
+
// NaN passes the type check and is rejected by the finiteness check,
|
|
237
|
+
// exactly like the Python port (bool can never reach here in JS).
|
|
238
|
+
return typeof value === 'number';
|
|
239
|
+
case 'boolean':
|
|
240
|
+
return typeof value === 'boolean';
|
|
241
|
+
case 'string':
|
|
242
|
+
return typeof value === 'string';
|
|
243
|
+
case 'array':
|
|
244
|
+
return Array.isArray(value);
|
|
245
|
+
case 'object':
|
|
246
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
247
|
+
case 'null':
|
|
248
|
+
return value === null;
|
|
249
|
+
default:
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Python type() names, so messages match schema_validate.py wording. */
|
|
255
|
+
function typeName(value) {
|
|
256
|
+
if (value === null || value === undefined) return 'NoneType';
|
|
257
|
+
if (Array.isArray(value)) return 'list';
|
|
258
|
+
if (typeof value === 'number') {
|
|
259
|
+
return Number.isInteger(value) ? 'int' : 'float';
|
|
260
|
+
}
|
|
261
|
+
if (typeof value === 'string') return 'str';
|
|
262
|
+
if (typeof value === 'boolean') return 'bool';
|
|
263
|
+
if (typeof value === 'object') return 'dict';
|
|
264
|
+
return typeof value;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Python repr() for scalars/collections (single quotes, True/None). */
|
|
268
|
+
function pyRepr(value) {
|
|
269
|
+
if (typeof value === 'string') {
|
|
270
|
+
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
271
|
+
}
|
|
272
|
+
if (value === null || value === undefined) return 'None';
|
|
273
|
+
if (value === true) return 'True';
|
|
274
|
+
if (value === false) return 'False';
|
|
275
|
+
if (Array.isArray(value)) return `[${value.map(pyRepr).join(', ')}]`;
|
|
276
|
+
if (typeof value === 'object') {
|
|
277
|
+
const parts = Object.entries(value).map(
|
|
278
|
+
([k, v]) => `${pyRepr(k)}: ${pyRepr(v)}`
|
|
279
|
+
);
|
|
280
|
+
return `{${parts.join(', ')}}`;
|
|
281
|
+
}
|
|
282
|
+
if (typeof value === 'number') {
|
|
283
|
+
if (Number.isNaN(value)) return 'nan';
|
|
284
|
+
if (value === Infinity) return 'inf';
|
|
285
|
+
if (value === -Infinity) return '-inf';
|
|
286
|
+
}
|
|
287
|
+
return String(value);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function pyReprEnum(values) {
|
|
291
|
+
return `[${(values || []).map(pyRepr).join(', ')}]`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Human-readable violations (dotted paths); [] == valid. Ports
|
|
296
|
+
* `runner/schema_validate.py:validate` keyword for keyword.
|
|
297
|
+
*/
|
|
298
|
+
export function validateAgainstSchema(data, schema, currentPath = '') {
|
|
299
|
+
if (!schema || typeof schema !== 'object') return [];
|
|
300
|
+
const problems = [];
|
|
301
|
+
const at = currentPath || '<root>';
|
|
302
|
+
|
|
303
|
+
const expected = schema.type;
|
|
304
|
+
if (expected !== undefined) {
|
|
305
|
+
const types = Array.isArray(expected) ? expected : [expected];
|
|
306
|
+
if (!types.some((t) => typeMatches(data, t))) {
|
|
307
|
+
return [`${at}: expected ${types.join('|')}, got ${typeName(data)}`];
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (data !== null && typeof data === 'object' && !Array.isArray(data)) {
|
|
312
|
+
for (const key of schema.required || []) {
|
|
313
|
+
if (!Object.prototype.hasOwnProperty.call(data, key)) {
|
|
314
|
+
problems.push(`${at}: missing required key '${key}'`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const props = schema.properties || {};
|
|
318
|
+
for (const key of Object.keys(props)) {
|
|
319
|
+
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
320
|
+
const child = currentPath ? `${currentPath}.${key}` : key;
|
|
321
|
+
problems.push(...validateAgainstSchema(data[key], props[key], child));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
|
|
325
|
+
const known = new Set(Object.keys(props));
|
|
326
|
+
for (const key of Object.keys(data)) {
|
|
327
|
+
if (!known.has(key)) {
|
|
328
|
+
const child = currentPath ? `${currentPath}.${key}` : key;
|
|
329
|
+
problems.push(
|
|
330
|
+
...validateAgainstSchema(data[key], schema.additionalProperties, child)
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (schema.enum !== undefined && !(schema.enum || []).includes(data)) {
|
|
336
|
+
problems.push(`${at}: value ${pyRepr(data)} not in enum ${pyReprEnum(schema.enum)}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (Array.isArray(data) && schema.items && typeof schema.items === 'object') {
|
|
341
|
+
for (let i = 0; i < data.length; i += 1) {
|
|
342
|
+
problems.push(...validateAgainstSchema(data[i], schema.items, `${at}[${i}]`));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (data === null || typeof data !== 'object') {
|
|
347
|
+
if (schema.enum !== undefined && !(schema.enum || []).includes(data)) {
|
|
348
|
+
problems.push(`${at}: value ${pyRepr(data)} not in enum ${pyReprEnum(schema.enum)}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (typeof data === 'number') {
|
|
353
|
+
if (!Number.isFinite(data)) {
|
|
354
|
+
problems.push(`${at}: value ${pyRepr(data)} is not finite`);
|
|
355
|
+
} else {
|
|
356
|
+
if (schema.minimum !== undefined && data < schema.minimum) {
|
|
357
|
+
problems.push(`${at}: value ${pyRepr(data)} is below minimum ${pyRepr(schema.minimum)}`);
|
|
358
|
+
}
|
|
359
|
+
if (schema.maximum !== undefined && data > schema.maximum) {
|
|
360
|
+
problems.push(`${at}: value ${pyRepr(data)} is above maximum ${pyRepr(schema.maximum)}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (typeof data === 'string') {
|
|
366
|
+
if (schema.minLength !== undefined && data.length < schema.minLength) {
|
|
367
|
+
problems.push(`${at}: string shorter than minLength ${pyRepr(schema.minLength)}`);
|
|
368
|
+
}
|
|
369
|
+
if (typeof schema.pattern === 'string') {
|
|
370
|
+
let matched = false;
|
|
371
|
+
try {
|
|
372
|
+
matched = new RegExp(schema.pattern).test(data);
|
|
373
|
+
} catch {
|
|
374
|
+
matched = true; // an invalid pattern must never fail a valid value
|
|
375
|
+
}
|
|
376
|
+
if (!matched) {
|
|
377
|
+
problems.push(
|
|
378
|
+
`${at}: value ${pyRepr(data)} does not match pattern ${pyRepr(schema.pattern)}`
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return problems;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Violations of the canonical CONFIG schema; [] == valid. Never throws. */
|
|
388
|
+
export function validateConfig(cfg) {
|
|
389
|
+
try {
|
|
390
|
+
const schema = loadConfigSchema();
|
|
391
|
+
if (!schema) return [];
|
|
392
|
+
return validateAgainstSchema(cfg, schema);
|
|
393
|
+
} catch {
|
|
394
|
+
return [];
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
// defaults / merging / legacy-pin migration (ports of configure.py)
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
function deepCopy(value) {
|
|
403
|
+
if (value === undefined) return undefined;
|
|
404
|
+
return JSON.parse(JSON.stringify(value));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Fallback defaults used ONLY when the schema artifact is unreadable.
|
|
409
|
+
* Pinned equal to runner.schemas.CONFIG_DEFAULTS by the parity suite.
|
|
410
|
+
*/
|
|
411
|
+
export const FALLBACK_DEFAULTS = {
|
|
412
|
+
search_engine: 'duckduckgo',
|
|
413
|
+
max_iterations: 2,
|
|
414
|
+
divergence_threshold: 0.75,
|
|
415
|
+
mode: 'research',
|
|
416
|
+
backend: 'auto',
|
|
417
|
+
cache_raw_markdown: true,
|
|
418
|
+
cache_ttl_days: null,
|
|
419
|
+
search_timeout_s: null,
|
|
420
|
+
searxng_url: null,
|
|
421
|
+
license_whitelist: ['MIT', 'Apache-2.0', 'BSD-3-Clause', 'ISC'],
|
|
422
|
+
output_dir: '.research',
|
|
423
|
+
allocation: 'dag',
|
|
424
|
+
domain_pack: null,
|
|
425
|
+
verify: { min_fuzzy_confidence: 0.88 },
|
|
426
|
+
agents: {
|
|
427
|
+
alpha: { backend: null, model: null, opencode_agent: null },
|
|
428
|
+
beta: { backend: null, model: null, opencode_agent: null },
|
|
429
|
+
},
|
|
430
|
+
opencode_auto: null,
|
|
431
|
+
opencode_agent: null,
|
|
432
|
+
mcp_servers: {},
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
/** Effective defaults, derived from the schema `default` keywords. */
|
|
436
|
+
export function configDefaults() {
|
|
437
|
+
try {
|
|
438
|
+
const schema = loadConfigSchema();
|
|
439
|
+
if (schema && schema.properties && typeof schema.properties === 'object') {
|
|
440
|
+
const out = {};
|
|
441
|
+
for (const [key, sub] of Object.entries(schema.properties)) {
|
|
442
|
+
if (sub && typeof sub === 'object' && 'default' in sub) {
|
|
443
|
+
out[key] = deepCopy(sub.default);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (Object.keys(out).length > 0) return out;
|
|
447
|
+
}
|
|
448
|
+
} catch {
|
|
449
|
+
/* fall through to the pinned fallback */
|
|
450
|
+
}
|
|
451
|
+
return deepCopy(FALLBACK_DEFAULTS);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Recursively merge updates over base (updates win; lists replace). */
|
|
455
|
+
export function mergeConfig(base, updates) {
|
|
456
|
+
if (
|
|
457
|
+
!base || typeof base !== 'object' || Array.isArray(base) ||
|
|
458
|
+
!updates || typeof updates !== 'object' || Array.isArray(updates)
|
|
459
|
+
) {
|
|
460
|
+
return deepCopy(updates);
|
|
461
|
+
}
|
|
462
|
+
const merged = deepCopy(base);
|
|
463
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
464
|
+
if (
|
|
465
|
+
value !== null && typeof value === 'object' && !Array.isArray(value) &&
|
|
466
|
+
merged[key] !== null && typeof merged[key] === 'object' && !Array.isArray(merged[key])
|
|
467
|
+
) {
|
|
468
|
+
merged[key] = mergeConfig(merged[key], value);
|
|
469
|
+
} else {
|
|
470
|
+
merged[key] = deepCopy(value);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return merged;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function pinsEqual(a, b) {
|
|
477
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Drop stale pre-0.7.6 `["claude", "-p"]` pins in place; returns True when
|
|
482
|
+
* changed. Only fires when the top-level backend is not an explicit "claude".
|
|
483
|
+
*/
|
|
484
|
+
export function migrateLegacyAgentBackends(cfg) {
|
|
485
|
+
try {
|
|
486
|
+
if (String((cfg && cfg.backend) || 'auto').trim().toLowerCase() === 'claude') {
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
const agents = cfg && cfg.agents;
|
|
490
|
+
if (!agents || typeof agents !== 'object' || Array.isArray(agents)) return false;
|
|
491
|
+
let changed = false;
|
|
492
|
+
for (const role of AGENT_ROLES) {
|
|
493
|
+
const roleCfg = agents[role];
|
|
494
|
+
if (
|
|
495
|
+
roleCfg && typeof roleCfg === 'object' && !Array.isArray(roleCfg) &&
|
|
496
|
+
LEGACY_AGENT_BACKEND_PINS.some((pin) => pinsEqual(roleCfg.backend, pin))
|
|
497
|
+
) {
|
|
498
|
+
roleCfg.backend = null;
|
|
499
|
+
changed = true;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return changed;
|
|
503
|
+
} catch {
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function mergeAgents(defaultAgents, fileAgents) {
|
|
509
|
+
const merged =
|
|
510
|
+
defaultAgents && typeof defaultAgents === 'object' && !Array.isArray(defaultAgents)
|
|
511
|
+
? deepCopy(defaultAgents)
|
|
512
|
+
: {};
|
|
513
|
+
if (!fileAgents || typeof fileAgents !== 'object' || Array.isArray(fileAgents)) {
|
|
514
|
+
return merged;
|
|
515
|
+
}
|
|
516
|
+
for (const [role, roleCfg] of Object.entries(fileAgents)) {
|
|
517
|
+
if (!roleCfg || typeof roleCfg !== 'object' || Array.isArray(roleCfg)) {
|
|
518
|
+
merged[role] = deepCopy(roleCfg);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
const base = merged[role];
|
|
522
|
+
if (base && typeof base === 'object' && !Array.isArray(base)) {
|
|
523
|
+
Object.assign(base, deepCopy(roleCfg));
|
|
524
|
+
} else {
|
|
525
|
+
merged[role] = deepCopy(roleCfg);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return merged;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
// paths + reads (never throw)
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
export function getConfigPath(baseDir = DEFAULT_RESEARCH_DIR) {
|
|
536
|
+
return path.join(String(baseDir || DEFAULT_RESEARCH_DIR), CONFIG_FILE_NAME);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export function getLockPath(baseDir = DEFAULT_RESEARCH_DIR) {
|
|
540
|
+
return path.join(String(baseDir || DEFAULT_RESEARCH_DIR), CONFIG_LOCK_NAME);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function readBytes(file) {
|
|
544
|
+
try {
|
|
545
|
+
if (!existsSync(file)) return null;
|
|
546
|
+
return readFileSync(file);
|
|
547
|
+
} catch {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** SHA-256 of the config file's exact bytes, or null when absent. */
|
|
553
|
+
export function configHash(baseDir = DEFAULT_RESEARCH_DIR) {
|
|
554
|
+
try {
|
|
555
|
+
const bytes = readBytes(getConfigPath(baseDir));
|
|
556
|
+
if (!bytes) return null;
|
|
557
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
558
|
+
} catch {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function parseJsonObject(bytes) {
|
|
564
|
+
if (!bytes) return null;
|
|
565
|
+
try {
|
|
566
|
+
const data = JSON.parse(bytes.toString('utf-8'));
|
|
567
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : null;
|
|
568
|
+
} catch {
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Best-effort raw JSON object on disk (null when absent/malformed). */
|
|
574
|
+
export function readRawConfig(baseDir = DEFAULT_RESEARCH_DIR) {
|
|
575
|
+
try {
|
|
576
|
+
return parseJsonObject(readBytes(getConfigPath(baseDir)));
|
|
577
|
+
} catch {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** True when a config file exists but is not a parseable JSON object. */
|
|
583
|
+
export function isConfigMalformed(baseDir = DEFAULT_RESEARCH_DIR) {
|
|
584
|
+
try {
|
|
585
|
+
const bytes = readBytes(getConfigPath(baseDir));
|
|
586
|
+
if (!bytes) return false;
|
|
587
|
+
return parseJsonObject(bytes) === null;
|
|
588
|
+
} catch {
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Single-read snapshot of the config file (phase-03 R5): the raw bytes are
|
|
595
|
+
* read EXACTLY ONCE; `hash` is computed over those bytes and `raw` is parsed
|
|
596
|
+
* from those bytes, so a config parsed from one era can never pair with a
|
|
597
|
+
* hash of another. `malformed` is true when bytes exist but are not a JSON
|
|
598
|
+
* object. Never throws, never writes, never creates directories.
|
|
599
|
+
*/
|
|
600
|
+
export function readConfigSnapshot(baseDir = DEFAULT_RESEARCH_DIR) {
|
|
601
|
+
try {
|
|
602
|
+
const bytes = readBytes(getConfigPath(baseDir));
|
|
603
|
+
if (!bytes) return { hash: null, raw: null, malformed: false };
|
|
604
|
+
let hash = null;
|
|
605
|
+
try {
|
|
606
|
+
hash = createHash('sha256').update(bytes).digest('hex');
|
|
607
|
+
} catch {
|
|
608
|
+
hash = null;
|
|
609
|
+
}
|
|
610
|
+
const raw = parseJsonObject(bytes);
|
|
611
|
+
return { hash, raw, malformed: raw === null };
|
|
612
|
+
} catch {
|
|
613
|
+
return { hash: null, raw: null, malformed: false };
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Effective config merged over defaults from an already-parsed raw object —
|
|
619
|
+
* the no-reread half of `loadConfig` (identical merge, migration, and
|
|
620
|
+
* fallback semantics; the caller owns the bytes). Never throws.
|
|
621
|
+
*/
|
|
622
|
+
export function loadConfigFromRaw(raw) {
|
|
623
|
+
try {
|
|
624
|
+
const base = configDefaults();
|
|
625
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return base;
|
|
626
|
+
const merged = { ...base, ...deepCopy(raw) };
|
|
627
|
+
merged.agents = mergeAgents(base.agents, raw.agents);
|
|
628
|
+
if (
|
|
629
|
+
raw.verify && typeof raw.verify === 'object' && !Array.isArray(raw.verify) &&
|
|
630
|
+
base.verify && typeof base.verify === 'object' && !Array.isArray(base.verify)
|
|
631
|
+
) {
|
|
632
|
+
merged.verify = { ...base.verify, ...deepCopy(raw.verify) };
|
|
633
|
+
}
|
|
634
|
+
migrateLegacyAgentBackends(merged);
|
|
635
|
+
return merged;
|
|
636
|
+
} catch {
|
|
637
|
+
try {
|
|
638
|
+
return deepCopy(FALLBACK_DEFAULTS);
|
|
639
|
+
} catch {
|
|
640
|
+
return {};
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Load config merged over defaults. Pure — never writes. Applies the
|
|
647
|
+
* legacy-pin migration in memory; unknown keys are preserved verbatim.
|
|
648
|
+
* Never throws (malformed workspace falls back to defaults with a warning).
|
|
649
|
+
*/
|
|
650
|
+
export function loadConfig(baseDir = DEFAULT_RESEARCH_DIR) {
|
|
651
|
+
try {
|
|
652
|
+
const raw = readRawConfig(baseDir);
|
|
653
|
+
if (!raw) {
|
|
654
|
+
if (isConfigMalformed(baseDir)) {
|
|
655
|
+
try {
|
|
656
|
+
console.error(
|
|
657
|
+
`[iumbtems config-io] Warning: ${getConfigPath(baseDir)} is not a ` +
|
|
658
|
+
'JSON object. Using defaults.'
|
|
659
|
+
);
|
|
660
|
+
} catch {
|
|
661
|
+
/* logging must never throw */
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return configDefaults();
|
|
665
|
+
}
|
|
666
|
+
return loadConfigFromRaw(raw);
|
|
667
|
+
} catch {
|
|
668
|
+
try {
|
|
669
|
+
return deepCopy(FALLBACK_DEFAULTS);
|
|
670
|
+
} catch {
|
|
671
|
+
return {};
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ---------------------------------------------------------------------------
|
|
677
|
+
// effective sources + settings rows (drive the wizard and the panel)
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
679
|
+
|
|
680
|
+
function envIsSet(env, name) {
|
|
681
|
+
try {
|
|
682
|
+
return String((env || {})[name] ?? '').trim() !== '';
|
|
683
|
+
} catch {
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Per-key provenance: `env-override` when a mapped env var is set, `file`
|
|
690
|
+
* when the key is present in the raw file, else `default`. Nested role keys
|
|
691
|
+
* fold into their top-level row (`agents` reports env-override when any
|
|
692
|
+
* IUMBTEMS_BACKEND_/MODEL_<ROLE> var is set).
|
|
693
|
+
*/
|
|
694
|
+
export function effectiveSources(
|
|
695
|
+
baseDir = DEFAULT_RESEARCH_DIR,
|
|
696
|
+
env = process.env
|
|
697
|
+
) {
|
|
698
|
+
const sources = {};
|
|
699
|
+
try {
|
|
700
|
+
const schema = loadConfigSchema();
|
|
701
|
+
const keys = schema && schema.properties
|
|
702
|
+
? Object.keys(schema.properties)
|
|
703
|
+
: Object.keys(FALLBACK_DEFAULTS);
|
|
704
|
+
const raw = readRawConfig(baseDir) || {};
|
|
705
|
+
for (const key of keys) {
|
|
706
|
+
const mapped = ENV_OVERRIDES[key] || [];
|
|
707
|
+
if (mapped.some((name) => envIsSet(env, name))) {
|
|
708
|
+
sources[key] = 'env-override';
|
|
709
|
+
} else if (Object.prototype.hasOwnProperty.call(raw, key)) {
|
|
710
|
+
sources[key] = 'file';
|
|
711
|
+
} else {
|
|
712
|
+
sources[key] = 'default';
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
} catch {
|
|
716
|
+
/* sources stay empty rather than throwing */
|
|
717
|
+
}
|
|
718
|
+
return sources;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Schema-ordered settings rows: {key, label, value, source, restart,
|
|
723
|
+
* description, deprecated, schema}. Pure; reads disk defensively.
|
|
724
|
+
*/
|
|
725
|
+
export function describeSettings(
|
|
726
|
+
baseDir = DEFAULT_RESEARCH_DIR,
|
|
727
|
+
env = process.env
|
|
728
|
+
) {
|
|
729
|
+
try {
|
|
730
|
+
const schema = loadConfigSchema();
|
|
731
|
+
const props =
|
|
732
|
+
(schema && schema.properties) || {};
|
|
733
|
+
const values = loadConfig(baseDir);
|
|
734
|
+
const raw = readRawConfig(baseDir) || {};
|
|
735
|
+
return buildSettingRows(values, raw, env, props);
|
|
736
|
+
} catch {
|
|
737
|
+
return [];
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** Pure row builder over already-loaded values (used by the live wizard). */
|
|
742
|
+
export function buildSettingRows(values, raw, env = process.env, props = null) {
|
|
743
|
+
try {
|
|
744
|
+
const properties =
|
|
745
|
+
props ||
|
|
746
|
+
(loadConfigSchema() && loadConfigSchema().properties) ||
|
|
747
|
+
{};
|
|
748
|
+
const safe = values && typeof values === 'object' ? values : {};
|
|
749
|
+
const safeRaw = raw && typeof raw === 'object' ? raw : {};
|
|
750
|
+
const rows = [];
|
|
751
|
+
for (const [key, sub] of Object.entries(properties)) {
|
|
752
|
+
const mapped = ENV_OVERRIDES[key] || [];
|
|
753
|
+
const source = mapped.some((name) => envIsSet(env, name))
|
|
754
|
+
? 'env-override'
|
|
755
|
+
: Object.prototype.hasOwnProperty.call(safeRaw, key)
|
|
756
|
+
? 'file'
|
|
757
|
+
: 'default';
|
|
758
|
+
rows.push({
|
|
759
|
+
key,
|
|
760
|
+
label: (sub && sub['x-label']) || key,
|
|
761
|
+
value: Object.prototype.hasOwnProperty.call(safe, key)
|
|
762
|
+
? deepCopy(safe[key])
|
|
763
|
+
: null,
|
|
764
|
+
source,
|
|
765
|
+
restart: (sub && sub['x-restart']) || 'next-run',
|
|
766
|
+
description: (sub && sub.description) || '',
|
|
767
|
+
deprecated: Boolean(sub && sub.deprecated),
|
|
768
|
+
schema: sub || {},
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
return rows;
|
|
772
|
+
} catch {
|
|
773
|
+
return [];
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function prettyValue(value) {
|
|
778
|
+
if (value === undefined) return 'unset';
|
|
779
|
+
try {
|
|
780
|
+
const text = JSON.stringify(value);
|
|
781
|
+
return text === undefined ? String(value) : text;
|
|
782
|
+
} catch {
|
|
783
|
+
return String(value);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Mask an environment-shadowed cell for display: the file value is struck
|
|
789
|
+
* through and marked `(set via environment)` — it is NEVER presented bare as
|
|
790
|
+
* the effective value, and the env value itself is NEVER rendered (secrets
|
|
791
|
+
* stay in env). Takes already-formatted text so panel (full) and wizard
|
|
792
|
+
* (truncated) titles share one rule.
|
|
793
|
+
*/
|
|
794
|
+
export function maskEnvCell(text) {
|
|
795
|
+
return `~~${text}~~ (set via environment)`;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** Display text for a settings row: masked when env-shadowed, plain otherwise. */
|
|
799
|
+
export function displayCell(row, format = prettyValue) {
|
|
800
|
+
let text;
|
|
801
|
+
try {
|
|
802
|
+
text = format(row ? row.value : undefined);
|
|
803
|
+
} catch {
|
|
804
|
+
text = String(row ? row.value : undefined);
|
|
805
|
+
}
|
|
806
|
+
return row && row.source === 'env-override' ? maskEnvCell(text) : text;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** Human-readable settings text for the read-only panel / confirm screen. */
|
|
810
|
+
export function formatSettingsText(baseDir = DEFAULT_RESEARCH_DIR, env = process.env) {
|
|
811
|
+
try {
|
|
812
|
+
const rows = describeSettings(baseDir, env);
|
|
813
|
+
if (rows.length === 0) return 'IUMBTEMS settings: no schema available.';
|
|
814
|
+
const lines = rows.map((row) => {
|
|
815
|
+
const restartNote =
|
|
816
|
+
row.restart === 'next-run'
|
|
817
|
+
? 'applies to next swarm run'
|
|
818
|
+
: `restart: ${row.restart}`;
|
|
819
|
+
const stale = row.deprecated ? ' (deprecated, display-only)' : '';
|
|
820
|
+
return `- ${row.label} (${row.key}): ${displayCell(row)} [${row.source}] — ${restartNote}${stale}`;
|
|
821
|
+
});
|
|
822
|
+
return ['IUMBTEMS settings (effective values):', ...lines].join('\n');
|
|
823
|
+
} catch {
|
|
824
|
+
return 'IUMBTEMS settings: unavailable (workspace unreadable).';
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Human-readable config block (mirrors configure.py display_config). */
|
|
829
|
+
export function formatConfig(cfg) {
|
|
830
|
+
try {
|
|
831
|
+
const c = cfg && typeof cfg === 'object' ? cfg : {};
|
|
832
|
+
const verify = c.verify && typeof c.verify === 'object' ? c.verify : {};
|
|
833
|
+
const lines = [
|
|
834
|
+
'',
|
|
835
|
+
'⚙️ EPISTEMIC SWARM ACTIVE CONFIGURATION',
|
|
836
|
+
'========================================',
|
|
837
|
+
` 🔍 Primary Search Engine: ${String(c.search_engine ?? 'duckduckgo').toUpperCase()}`,
|
|
838
|
+
` 🔄 Max Iterations (Depth): ${c.max_iterations ?? 2}`,
|
|
839
|
+
` ⚖️ Divergence Threshold: ${c.divergence_threshold ?? 0.75} (advisory)`,
|
|
840
|
+
` 🎯 Operating Mode: ${String(c.mode ?? 'research').toUpperCase()}`,
|
|
841
|
+
` 🖥️ Agent Backend: ${String(c.backend ?? 'auto').toUpperCase()} (auto = host-native)`,
|
|
842
|
+
` 💾 Raw Markdown Caching: ${(c.cache_raw_markdown ?? true) ? 'ENABLED' : 'DISABLED'}`,
|
|
843
|
+
` 📜 License Whitelist: ${Array.isArray(c.license_whitelist) ? c.license_whitelist.join(', ') : ''}`,
|
|
844
|
+
` 🔬 Min Fuzzy Confidence: ${verify.min_fuzzy_confidence ?? 0.88}`,
|
|
845
|
+
` 📁 Output Directory: ${c.output_dir ?? '.research'} (deprecated)`,
|
|
846
|
+
'========================================',
|
|
847
|
+
'',
|
|
848
|
+
];
|
|
849
|
+
return lines.join('\n');
|
|
850
|
+
} catch {
|
|
851
|
+
return 'Epistemic Swarm configuration: unavailable.';
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// ---------------------------------------------------------------------------
|
|
856
|
+
// expected-hash guard (both spellings, phase-01 semantics)
|
|
857
|
+
// ---------------------------------------------------------------------------
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Validate/normalize an expected-hash guard; null means "no guard". Accepts a
|
|
861
|
+
* full 64-hex SHA-256 or a prefix of at least 8 hex chars. Anything else
|
|
862
|
+
* malformed raises ConfigHashError. Never throws on null/empty.
|
|
863
|
+
*/
|
|
864
|
+
export function normalizeExpectedHash(expectedHash) {
|
|
865
|
+
if (expectedHash === null || expectedHash === undefined) return null;
|
|
866
|
+
const value = String(expectedHash).trim().toLowerCase();
|
|
867
|
+
if (!value) return null;
|
|
868
|
+
if (!/^[0-9a-f]{8,64}$/.test(value)) {
|
|
869
|
+
throw new ConfigHashError(expectedHash);
|
|
870
|
+
}
|
|
871
|
+
return value;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Resolve the guard from `expected_hash`/`expectedHash` spellings. Both
|
|
876
|
+
* present must agree (mirror the MCP R4 rule) or a CONFLICTING_EXPECTED_HASH
|
|
877
|
+
* ConfigError is raised and nothing is written.
|
|
878
|
+
*/
|
|
879
|
+
export function resolveExpectedHash(opts = {}) {
|
|
880
|
+
const snake = opts ? opts.expected_hash : undefined;
|
|
881
|
+
const camel = opts ? opts.expectedHash : undefined;
|
|
882
|
+
const hasSnake = snake !== null && snake !== undefined;
|
|
883
|
+
const hasCamel = camel !== null && camel !== undefined;
|
|
884
|
+
if (hasSnake && hasCamel && String(snake) !== String(camel)) {
|
|
885
|
+
throw new ConfigError(
|
|
886
|
+
'expected_hash and expectedHash were both provided with different ' +
|
|
887
|
+
'values; provide one spelling (or two equal values).',
|
|
888
|
+
{
|
|
889
|
+
code: 'CONFLICTING_EXPECTED_HASH',
|
|
890
|
+
written: false,
|
|
891
|
+
expected_hash: String(snake),
|
|
892
|
+
expectedHash: String(camel),
|
|
893
|
+
}
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
const chosen = hasSnake ? snake : camel;
|
|
897
|
+
return normalizeExpectedHash(chosen ?? null);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function hashMatches(expectedHash, currentHash) {
|
|
901
|
+
if (!expectedHash || !currentHash) return false;
|
|
902
|
+
return String(currentHash).startsWith(expectedHash);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// ---------------------------------------------------------------------------
|
|
906
|
+
// lockfile discipline (O_EXCL create, pid+timestamp, stale takeover > 5s)
|
|
907
|
+
// ---------------------------------------------------------------------------
|
|
908
|
+
|
|
909
|
+
function readLockPayload(lockPath) {
|
|
910
|
+
try {
|
|
911
|
+
const raw = readFileSync(lockPath, 'utf-8');
|
|
912
|
+
const parsed = JSON.parse(raw);
|
|
913
|
+
if (parsed && typeof parsed === 'object' && Number.isFinite(Number(parsed.ts))) {
|
|
914
|
+
return { pid: parsed.pid, ts: Number(parsed.ts), token: parsed.token };
|
|
915
|
+
}
|
|
916
|
+
return null;
|
|
917
|
+
} catch {
|
|
918
|
+
return null;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Acquire the workspace lock. Creates `.config.lock` with O_EXCL; a live lock
|
|
924
|
+
* raises ConfigLockedError; a stale lock (> LOCK_STALE_MS) or a foreign file
|
|
925
|
+
* without our pid+timestamp payload is taken over. Returns {release}.
|
|
926
|
+
*/
|
|
927
|
+
export function acquireLock(baseDir = DEFAULT_RESEARCH_DIR) {
|
|
928
|
+
const dir = String(baseDir || DEFAULT_RESEARCH_DIR);
|
|
929
|
+
mkdirSync(dir, { recursive: true });
|
|
930
|
+
const lockPath = getLockPath(dir);
|
|
931
|
+
const token = `${process.pid}:${Date.now()}:${randomBytes(4).toString('hex')}`;
|
|
932
|
+
const payload = JSON.stringify({ pid: process.pid, ts: Date.now(), token });
|
|
933
|
+
|
|
934
|
+
const takeOver = () => {
|
|
935
|
+
// Adopt by OVERWRITING the payload — never by unlinking. Unlinking a
|
|
936
|
+
// foreign lockfile would delete another writer's liveness signal (and a
|
|
937
|
+
// live Python/flock holder's file, which carries no timestamp at all);
|
|
938
|
+
// overwriting leaves a valid pid+timestamp payload behind that our own
|
|
939
|
+
// release() can later verify as ours before unlinking.
|
|
940
|
+
writeFileSync(lockPath, payload, 'utf-8');
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
try {
|
|
944
|
+
writeFileSync(lockPath, payload, { flag: 'wx', encoding: 'utf-8' });
|
|
945
|
+
} catch (err) {
|
|
946
|
+
if (!err || err.code !== 'EEXIST') throw err;
|
|
947
|
+
const existing = readLockPayload(lockPath);
|
|
948
|
+
if (existing) {
|
|
949
|
+
const age = Date.now() - existing.ts;
|
|
950
|
+
if (age <= LOCK_STALE_MS) {
|
|
951
|
+
throw new ConfigLockedError({ lock_age_ms: age, lock_pid: existing.pid });
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
// Stale (> 5s) or foreign (no pid+timestamp payload, e.g. a leftover
|
|
955
|
+
// advisory lockfile from the Python writer, which never unlinks): take
|
|
956
|
+
// over rather than deadlocking. Residual window documented above: a live
|
|
957
|
+
// Python holder can still be adopted here; its writes are
|
|
958
|
+
// millisecond-scale so the window is negligible.
|
|
959
|
+
takeOver();
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
let released = false;
|
|
963
|
+
const release = () => {
|
|
964
|
+
if (released) return;
|
|
965
|
+
released = true;
|
|
966
|
+
try {
|
|
967
|
+
// Unlink ONLY when the on-disk payload is verifiably ours (pid match):
|
|
968
|
+
// a foreign live lock is never removed, and if a concurrent writer
|
|
969
|
+
// adopted the lock away from us after we acquired it, their payload
|
|
970
|
+
// (different pid, and in-process a different token) keeps the file.
|
|
971
|
+
const current = readLockPayload(lockPath);
|
|
972
|
+
if (
|
|
973
|
+
current &&
|
|
974
|
+
current.pid === process.pid &&
|
|
975
|
+
current.token === token
|
|
976
|
+
) {
|
|
977
|
+
unlinkSync(lockPath);
|
|
978
|
+
}
|
|
979
|
+
} catch {
|
|
980
|
+
/* release is best-effort */
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
return { release, token, lockPath };
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
let tempCounter = 0;
|
|
987
|
+
|
|
988
|
+
// ---------------------------------------------------------------------------
|
|
989
|
+
// atomic save
|
|
990
|
+
// ---------------------------------------------------------------------------
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Atomically persist cfg into `<baseDir>/config.json`.
|
|
994
|
+
*
|
|
995
|
+
* - Merges over whatever is on disk (unknown keys survive); cfg wins.
|
|
996
|
+
* - Validates the merged dict (disable only with validate:false when the
|
|
997
|
+
* exact dict was already validated); failures raise
|
|
998
|
+
* ConfigValidationError and write nothing.
|
|
999
|
+
* - Honors the expected-hash guard (both spellings); mismatches raise
|
|
1000
|
+
* ConfigStaleError with a fresh snapshot (no absolute path, mirroring the
|
|
1001
|
+
* Python R5 rule) and write nothing.
|
|
1002
|
+
* - Publishes via a unique temp file + rename; `injectFailure:
|
|
1003
|
+
* 'after-temp-write'` is a test-only hook that throws between the two so
|
|
1004
|
+
* failure-injection tests can prove the original stays intact.
|
|
1005
|
+
*/
|
|
1006
|
+
export function saveConfig(cfg, baseDir = DEFAULT_RESEARCH_DIR, opts = {}) {
|
|
1007
|
+
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
|
|
1008
|
+
throw new ConfigValidationError([
|
|
1009
|
+
`<root>: expected object, got ${typeName(cfg)} (config must be a JSON object)`,
|
|
1010
|
+
]);
|
|
1011
|
+
}
|
|
1012
|
+
const expected = resolveExpectedHash(opts || {});
|
|
1013
|
+
const dir = String(baseDir || DEFAULT_RESEARCH_DIR);
|
|
1014
|
+
mkdirSync(dir, { recursive: true });
|
|
1015
|
+
const cfgFile = getConfigPath(dir);
|
|
1016
|
+
const lock = acquireLock(dir);
|
|
1017
|
+
let tmp = null;
|
|
1018
|
+
try {
|
|
1019
|
+
const existing = readBytes(cfgFile);
|
|
1020
|
+
const currentHash = existing
|
|
1021
|
+
? createHash('sha256').update(existing).digest('hex')
|
|
1022
|
+
: null;
|
|
1023
|
+
if (expected !== null && !hashMatches(expected, currentHash)) {
|
|
1024
|
+
throw new ConfigStaleError(expected, currentHash, {
|
|
1025
|
+
exists: existing !== null,
|
|
1026
|
+
hash: currentHash,
|
|
1027
|
+
config: loadConfig(dir),
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
const onDisk = parseJsonObject(existing);
|
|
1031
|
+
const merged = mergeConfig(onDisk || {}, cfg);
|
|
1032
|
+
if (!opts || opts.validate !== false) {
|
|
1033
|
+
const problems = validateConfig(merged);
|
|
1034
|
+
if (problems.length > 0) {
|
|
1035
|
+
throw new ConfigValidationError(problems);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
tempCounter += 1;
|
|
1039
|
+
tmp = path.join(
|
|
1040
|
+
dir,
|
|
1041
|
+
`${CONFIG_FILE_NAME}.${process.pid}-${tempCounter}-${randomBytes(4).toString('hex')}.tmp`
|
|
1042
|
+
);
|
|
1043
|
+
writeFileSync(tmp, JSON.stringify(merged, null, 2), 'utf-8');
|
|
1044
|
+
if (opts && opts.injectFailure === 'after-temp-write') {
|
|
1045
|
+
throw new Error(
|
|
1046
|
+
'injected failure between temp write and rename (test hook)'
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
renameSync(tmp, cfgFile);
|
|
1050
|
+
tmp = null;
|
|
1051
|
+
const written = readBytes(cfgFile);
|
|
1052
|
+
return {
|
|
1053
|
+
path: cfgFile,
|
|
1054
|
+
hash: written ? createHash('sha256').update(written).digest('hex') : null,
|
|
1055
|
+
written: true,
|
|
1056
|
+
};
|
|
1057
|
+
} catch (err) {
|
|
1058
|
+
if (tmp) {
|
|
1059
|
+
try {
|
|
1060
|
+
unlinkSync(tmp);
|
|
1061
|
+
} catch {
|
|
1062
|
+
/* temp cleanup is best-effort */
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
throw err;
|
|
1066
|
+
} finally {
|
|
1067
|
+
lock.release();
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** Fresh read-only snapshot: existence, file hash, effective config. */
|
|
1072
|
+
export function snapshot(baseDir = DEFAULT_RESEARCH_DIR) {
|
|
1073
|
+
try {
|
|
1074
|
+
const cfgFile = getConfigPath(baseDir);
|
|
1075
|
+
return {
|
|
1076
|
+
path: cfgFile,
|
|
1077
|
+
exists: existsSync(cfgFile),
|
|
1078
|
+
hash: configHash(baseDir),
|
|
1079
|
+
config: loadConfig(baseDir),
|
|
1080
|
+
};
|
|
1081
|
+
} catch {
|
|
1082
|
+
return { path: getConfigPath(baseDir), exists: false, hash: null, config: {} };
|
|
1083
|
+
}
|
|
1084
|
+
}
|