config.mjs
388 lines 16.9 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Config loader: config/local.yaml + env overrides. SPEC §4.4.
3 * Env overrides apply after file. vault_path is required.
4 * Multi-vault (Phase 15): when hub_vaults.yaml is absent, single vault "default" from vault_path.
5 */
6
7 import fs from 'fs';
8 import path from 'path';
9 import yaml from 'js-yaml';
10 import { readHubVaults } from './hub-vaults.mjs';
11 import { getRepoRoot } from './repo-root.mjs';
12
13 const projectRoot = getRepoRoot();
14
15 const ENV_VAULT = 'KNOWTATION_VAULT_PATH';
16 const ENV_QDRANT = 'QDRANT_URL';
17 const ENV_DATA_DIR = 'KNOWTATION_DATA_DIR';
18 const ENV_VECTOR_STORE = 'KNOWTATION_VECTOR_STORE';
19 const ENV_MEMORY_URL = 'KNOWTATION_MEMORY_URL';
20 const ENV_MEMORY_ENABLED = 'KNOWTATION_MEMORY_ENABLED';
21 const ENV_MEMORY_PROVIDER = 'KNOWTATION_MEMORY_PROVIDER';
22 const ENV_AIR_ENDPOINT = 'KNOWTATION_AIR_ENDPOINT';
23 const ENV_OLLAMA_URL = 'OLLAMA_URL';
24 const ENV_EMBEDDING_PROVIDER = 'EMBEDDING_PROVIDER';
25 const ENV_EMBEDDING_MODEL = 'EMBEDDING_MODEL';
26 const ENV_TRANSCODE_OVERSIZED = 'KNOWTATION_TRANSCODE_OVERSIZED';
27 const ENV_MUSE_URL = 'MUSE_URL';
28
29 /** Default embed model name when YAML/env omits `model` (must match provider). */
30 function defaultEmbeddingModelForProvider(provider) {
31 const p = String(provider || 'ollama').toLowerCase();
32 if (p === 'openai') return 'text-embedding-3-small';
33 if (p === 'voyage') return 'voyage-4-lite';
34 return 'nomic-embed-text';
35 }
36
37 const DEFAULT_IGNORE = ['templates', 'meta', 'node_modules', '.git'];
38
39 /**
40 * Read transcription.* from config/local.yaml only (no vault_path required).
41 * Used by lib/transcribe.mjs on hosted bridge where full loadConfig may be skipped.
42 * @param {string} [cwd]
43 * @returns {{ provider: string, model: string, transcode_oversized: boolean }}
44 */
45 export function readTranscriptionYaml(cwd = projectRoot) {
46 const configPath = path.join(cwd, 'config', 'local.yaml');
47 let t = {};
48 if (fs.existsSync(configPath)) {
49 try {
50 const raw = yaml.load(fs.readFileSync(configPath, 'utf8')) || {};
51 if (raw.transcription && typeof raw.transcription === 'object') {
52 t = raw.transcription;
53 }
54 } catch (_) {
55 /* ignore invalid yaml for this optional slice */
56 }
57 }
58 return {
59 provider: t.provider || 'openai',
60 model: t.model || 'whisper-1',
61 transcode_oversized: t.transcode_oversized !== false,
62 };
63 }
64
65 /**
66 * Load config from config/local.yaml (if present) then apply env overrides.
67 *
68 * **AIR config** (`air` key in local.yaml):
69 * ```yaml
70 * air:
71 * enabled: true # master switch; default false
72 * required: true # hard-fail: throw AttestationRequiredError when endpoint fails; default false
73 * endpoint: https://... # attestation endpoint URL; falls back to KNOWTATION_AIR_ENDPOINT env var
74 * ```
75 * When `air.required=true` a write or export is rejected if the attestation endpoint is
76 * unreachable or returns a non-OK response. Default (`false`) is non-blocking: a placeholder
77 * id is logged and the operation proceeds (backward-compatible).
78 *
79 * @param {string} [cwd] - Working directory (default: project root)
80 * @returns {{ vault_path: string, qdrant_url?: string, vector_store?: string, data_dir: string, embedding?: object, memory?: object, air?: { enabled: boolean, required: boolean, endpoint: string|undefined }, ignore?: string[] }} embedding.ollama_url from YAML or OLLAMA_URL env when set
81 * @throws if vault_path is missing after load
82 */
83 export function loadConfig(cwd = projectRoot) {
84 const configPath = path.join(cwd, 'config', 'local.yaml');
85 let config = {};
86
87 if (fs.existsSync(configPath)) {
88 try {
89 const raw = fs.readFileSync(configPath, 'utf8');
90 config = yaml.load(raw) || {};
91 } catch (e) {
92 throw new Error(`Invalid config at ${configPath}: ${e.message}`);
93 }
94 }
95
96 // Env overrides (SPEC: env overrides, then config)
97 if (process.env[ENV_VAULT]) config.vault_path = process.env[ENV_VAULT];
98 if (process.env[ENV_QDRANT]) config.qdrant_url = process.env[ENV_QDRANT];
99 if (process.env[ENV_DATA_DIR]) config.data_dir = process.env[ENV_DATA_DIR];
100 if (process.env[ENV_VECTOR_STORE]) config.vector_store = process.env[ENV_VECTOR_STORE];
101 if (process.env[ENV_MEMORY_URL]) config.memory = { ...(config.memory || {}), url: process.env[ENV_MEMORY_URL] };
102 if (process.env[ENV_MEMORY_ENABLED] === 'true') config.memory = { ...(config.memory || {}), enabled: true };
103 if (process.env[ENV_MEMORY_ENABLED] === 'false') config.memory = { ...(config.memory || {}), enabled: false };
104 if (process.env[ENV_MEMORY_PROVIDER]) config.memory = { ...(config.memory || {}), provider: process.env[ENV_MEMORY_PROVIDER] };
105 if (process.env[ENV_AIR_ENDPOINT]) config.air = { ...config.air, endpoint: process.env[ENV_AIR_ENDPOINT] };
106 if (process.env[ENV_TRANSCODE_OVERSIZED] === '0' || process.env[ENV_TRANSCODE_OVERSIZED] === 'false') {
107 config.transcription = { ...(config.transcription || {}), transcode_oversized: false };
108 }
109 if (process.env[ENV_TRANSCODE_OVERSIZED] === '1' || process.env[ENV_TRANSCODE_OVERSIZED] === 'true') {
110 config.transcription = { ...(config.transcription || {}), transcode_oversized: true };
111 }
112
113 // Hub Setup overrides (optional): data_dir/hub_setup.yaml can set vault_path and vault.git
114 const dataDirPath = path.resolve(cwd, config.data_dir || 'data');
115 const hubSetupPath = path.join(dataDirPath, 'hub_setup.yaml');
116 if (fs.existsSync(hubSetupPath)) {
117 try {
118 const setupRaw = fs.readFileSync(hubSetupPath, 'utf8');
119 const setup = yaml.load(setupRaw) || {};
120 // Hub writes vault_path here; operator/tests use KNOWTATION_VAULT_PATH — that must win (SPEC: env overrides).
121 if (setup.vault_path != null && !process.env[ENV_VAULT]) {
122 config.vault_path = setup.vault_path;
123 }
124 if (setup.vault?.git && typeof setup.vault.git === 'object') {
125 config.vault = config.vault || {};
126 config.vault.git = { ...(config.vault.git || {}), ...setup.vault.git };
127 }
128 } catch (_) { /* ignore invalid hub_setup */ }
129 }
130
131 /** Muse thin bridge: optional `muse.url` in local.yaml; `MUSE_URL` env wins when set. */
132 const museRaw = config.muse && typeof config.muse === 'object' ? config.muse : {};
133 let museUrlMerged =
134 typeof museRaw.url === 'string' ? museRaw.url.trim().replace(/\/+$/, '') : '';
135 if (process.env[ENV_MUSE_URL] != null && String(process.env[ENV_MUSE_URL]).trim() !== '') {
136 museUrlMerged = String(process.env[ENV_MUSE_URL]).trim().replace(/\/+$/, '');
137 }
138 config.muse = museUrlMerged ? { url: museUrlMerged } : {};
139
140 const vaultPath = config.vault_path;
141 if (!vaultPath || typeof vaultPath !== 'string') {
142 throw new Error('vault_path is required. Set in config/local.yaml or env KNOWTATION_VAULT_PATH.');
143 }
144
145 const resolvedVault = path.isAbsolute(vaultPath) ? vaultPath : path.resolve(cwd, vaultPath);
146 if (!fs.existsSync(resolvedVault) || !fs.statSync(resolvedVault).isDirectory()) {
147 throw new Error(`Vault path does not exist or is not a directory: ${resolvedVault}`);
148 }
149
150 let vaultList = readHubVaults(dataDirPath, cwd);
151 if (vaultList.length === 0) {
152 vaultList = [{ id: 'default', path: resolvedVault, label: undefined }];
153 }
154
155 /**
156 * Resolve vault id to absolute path. Returns undefined if vault id not in list.
157 * @param {string} vaultId
158 * @returns {string | undefined}
159 */
160 function resolveVaultPath(vaultId) {
161 const v = vaultList.find((e) => e.id === vaultId);
162 return v ? v.path : undefined;
163 }
164
165 const embeddingYaml = config.embedding && typeof config.embedding === 'object' ? config.embedding : null;
166 let embeddingProvider = embeddingYaml?.provider || 'ollama';
167 if (process.env[ENV_EMBEDDING_PROVIDER] != null && String(process.env[ENV_EMBEDDING_PROVIDER]).trim() !== '') {
168 embeddingProvider = String(process.env[ENV_EMBEDDING_PROVIDER]).trim().toLowerCase();
169 }
170 let embeddingModel = embeddingYaml?.model;
171 if (process.env[ENV_EMBEDDING_MODEL] != null && String(process.env[ENV_EMBEDDING_MODEL]).trim() !== '') {
172 embeddingModel = String(process.env[ENV_EMBEDDING_MODEL]).trim();
173 }
174 if (embeddingModel == null || String(embeddingModel).trim() === '') {
175 embeddingModel = defaultEmbeddingModelForProvider(embeddingProvider);
176 }
177 const embedding = {
178 provider: embeddingProvider,
179 model: embeddingModel,
180 ollama_url: embeddingYaml?.ollama_url,
181 };
182 if (process.env[ENV_OLLAMA_URL]) {
183 embedding.ollama_url = process.env[ENV_OLLAMA_URL];
184 }
185
186 return {
187 vault_path: resolvedVault,
188 vaultList,
189 resolveVaultPath,
190 qdrant_url: config.qdrant_url,
191 vector_store: config.vector_store || 'qdrant',
192 data_dir: path.resolve(cwd, config.data_dir || 'data'),
193 embedding,
194 indexer: config.indexer && typeof config.indexer === 'object'
195 ? {
196 chunk_size: config.indexer.chunk_size ?? 2048,
197 chunk_overlap: config.indexer.chunk_overlap ?? 256,
198 }
199 : { chunk_size: 2048, chunk_overlap: 256 },
200 transcription: config.transcription && typeof config.transcription === 'object'
201 ? {
202 provider: config.transcription.provider || 'openai',
203 model: config.transcription.model || 'whisper-1',
204 transcode_oversized: config.transcription.transcode_oversized !== false,
205 }
206 : { provider: 'openai', model: 'whisper-1', transcode_oversized: true },
207 memory: config.memory && typeof config.memory === 'object'
208 ? {
209 enabled: config.memory.enabled === true,
210 provider: config.memory.provider || 'file',
211 url: config.memory.url || process.env.KNOWTATION_MEMORY_URL,
212 retention_days: config.memory.retention_days ?? null,
213 capture: Array.isArray(config.memory.capture) ? config.memory.capture : undefined,
214 scope: config.memory.scope === 'global' ? 'global' : 'vault',
215 encrypt: config.memory.encrypt === true,
216 secret: config.memory.secret || undefined,
217 supabase_url: config.memory.supabase_url || process.env.KNOWTATION_SUPABASE_URL || undefined,
218 supabase_key: config.memory.supabase_key || process.env.KNOWTATION_SUPABASE_KEY || undefined,
219 }
220 : { enabled: false, provider: 'file', url: undefined, retention_days: null, capture: undefined, scope: 'vault', encrypt: false, secret: undefined, supabase_url: undefined, supabase_key: undefined },
221 air: config.air && typeof config.air === 'object'
222 ? {
223 enabled: config.air.enabled === true,
224 required: config.air.required === true,
225 endpoint: config.air.endpoint || process.env.KNOWTATION_AIR_ENDPOINT,
226 }
227 : { enabled: false, required: false, endpoint: undefined },
228 vault_git: config.vault?.git && typeof config.vault.git === 'object'
229 ? {
230 enabled: config.vault.git.enabled === true,
231 remote: config.vault.git.remote || undefined,
232 auto_commit: config.vault.git.auto_commit === true,
233 auto_push: config.vault.git.auto_push === true,
234 }
235 : { enabled: false, remote: undefined, auto_commit: false, auto_push: false },
236 mcp: (() => {
237 const mcpRaw = config.mcp && typeof config.mcp === 'object' ? config.mcp : {};
238 const envPort = process.env.KNOWTATION_MCP_HTTP_PORT;
239 const http_port =
240 envPort != null && String(envPort).trim() !== ''
241 ? parseInt(String(envPort), 10) || 3334
242 : mcpRaw.http_port ?? 3334;
243 const http_host = mcpRaw.http_host || '127.0.0.1';
244 return { http_port, http_host };
245 })(),
246 muse: config.muse && typeof config.muse === 'object' && config.muse.url
247 ? { url: String(config.muse.url).trim().replace(/\/+$/, '') }
248 : {},
249 daemon: loadDaemonConfig(config.daemon),
250 llm: loadLlmConfig(config.llm),
251 ignore: config.ignore || DEFAULT_IGNORE,
252 };
253 }
254
255 /** Chat providers selectable for the `completeChat` path (Hub Settings UI + config llm.provider). */
256 export const CHAT_PROVIDERS = Object.freeze(['deepinfra', 'openrouter', 'openai', 'anthropic', 'ollama']);
257
258 /**
259 * Validate a chat-provider value submitted from the Hub Settings UI before it is persisted.
260 *
261 * Security boundary: the persisted provider drives where note text is sent (privacy) and which
262 * account is billed. Only an empty string ("" → auto-detect) or an exact whitelisted provider is
263 * accepted; anything else is rejected so a malformed/hostile value can never be written to
264 * config/local.yaml or reach completeChat.
265 *
266 * @param {unknown} value
267 * @returns {{ ok: true, provider: string } | { ok: false, error: string }}
268 */
269 export function normalizeChatProviderInput(value) {
270 if (value == null || value === '') return { ok: true, provider: '' };
271 if (typeof value !== 'string') {
272 return { ok: false, error: 'provider must be a string' };
273 }
274 const v = value.trim().toLowerCase();
275 if (v === '') return { ok: true, provider: '' };
276 if (!CHAT_PROVIDERS.includes(v)) {
277 return {
278 ok: false,
279 error: `provider must be one of: ${CHAT_PROVIDERS.join(', ')} (or empty for auto-detect)`,
280 };
281 }
282 return { ok: true, provider: v };
283 }
284
285 /**
286 * Resolve the top-level `llm` block (the `completeChat` path) from local.yaml.
287 *
288 * Distinct from `daemon.llm` (consolidation/Discovery). `provider` selects the chat lane used by
289 * MCP summarize and Hub proposal LLM jobs; invalid values are dropped (treated as auto-detect) so a
290 * malformed config can never force an unknown provider. The env var `KNOWTATION_CHAT_PROVIDER` still
291 * takes precedence at resolution time (see lib/llm-complete.mjs).
292 *
293 * @param {object|undefined} raw — the `llm` key from the YAML file
294 * @returns {{ provider: string, openai_chat_model: string|undefined, anthropic_chat_model: string|undefined, deepinfra_chat_model: string|undefined, openrouter_chat_model: string|undefined, ollama_chat_model: string|undefined }}
295 */
296 export function loadLlmConfig(raw) {
297 const l = raw && typeof raw === 'object' ? raw : {};
298 const providerRaw = String(l.provider || '').trim().toLowerCase();
299 const provider = CHAT_PROVIDERS.includes(providerRaw) ? providerRaw : '';
300 return {
301 provider,
302 openai_chat_model: l.openai_chat_model || undefined,
303 anthropic_chat_model: l.anthropic_chat_model || undefined,
304 deepinfra_chat_model: l.deepinfra_chat_model || undefined,
305 openrouter_chat_model: l.openrouter_chat_model || undefined,
306 ollama_chat_model: l.ollama_chat_model || undefined,
307 };
308 }
309
310 const DAEMON_DEFAULTS = Object.freeze({
311 enabled: false,
312 interval_minutes: 120,
313 idle_only: true,
314 idle_threshold_minutes: 15,
315 run_on_start: false,
316 lookback_hours: 24,
317 max_events_per_pass: 200,
318 max_topics_per_pass: 10,
319 passes: { consolidate: true, verify: true, discover: false, rebuild_index: true },
320 llm: {
321 provider: null,
322 model: null,
323 api_key_env: null,
324 base_url: null,
325 max_tokens: 1024,
326 temperature: 0.2,
327 },
328 dry_run: false,
329 log_file: null,
330 max_cost_per_day_usd: null,
331 });
332
333 /**
334 * Parse daemon configuration from the raw `daemon` section of local.yaml,
335 * applying defaults and environment variable overrides.
336 *
337 * @param {object|undefined} raw — the `daemon` key from the YAML file
338 * @returns {object} fully resolved daemon config with all defaults applied
339 */
340 export function loadDaemonConfig(raw) {
341 const d = raw && typeof raw === 'object' ? raw : {};
342
343 const enabled = process.env.KNOWTATION_DAEMON_ENABLED != null
344 ? process.env.KNOWTATION_DAEMON_ENABLED === 'true'
345 : d.enabled === true;
346
347 const interval_minutes = process.env.KNOWTATION_DAEMON_INTERVAL != null
348 ? parseInt(process.env.KNOWTATION_DAEMON_INTERVAL, 10) || DAEMON_DEFAULTS.interval_minutes
349 : d.interval_minutes ?? DAEMON_DEFAULTS.interval_minutes;
350
351 const dry_run = process.env.KNOWTATION_DAEMON_DRY_RUN != null
352 ? process.env.KNOWTATION_DAEMON_DRY_RUN === 'true'
353 : d.dry_run === true;
354
355 const rawPasses = d.passes && typeof d.passes === 'object' ? d.passes : {};
356 const passes = {
357 consolidate: rawPasses.consolidate !== false,
358 verify: rawPasses.verify !== false,
359 discover: rawPasses.discover === true,
360 rebuild_index: rawPasses.rebuild_index !== false,
361 };
362
363 const rawLlm = d.llm && typeof d.llm === 'object' ? d.llm : {};
364 const llm = {
365 provider: process.env.KNOWTATION_DAEMON_LLM_PROVIDER || rawLlm.provider || null,
366 model: process.env.KNOWTATION_DAEMON_LLM_MODEL || rawLlm.model || null,
367 api_key_env: rawLlm.api_key_env || null,
368 base_url: process.env.KNOWTATION_DAEMON_LLM_BASE_URL || rawLlm.base_url || null,
369 max_tokens: rawLlm.max_tokens ?? DAEMON_DEFAULTS.llm.max_tokens,
370 temperature: rawLlm.temperature ?? DAEMON_DEFAULTS.llm.temperature,
371 };
372
373 return {
374 enabled,
375 interval_minutes,
376 idle_only: d.idle_only !== false,
377 idle_threshold_minutes: d.idle_threshold_minutes ?? DAEMON_DEFAULTS.idle_threshold_minutes,
378 run_on_start: d.run_on_start === true,
379 lookback_hours: d.lookback_hours ?? DAEMON_DEFAULTS.lookback_hours,
380 max_events_per_pass: d.max_events_per_pass ?? DAEMON_DEFAULTS.max_events_per_pass,
381 max_topics_per_pass: d.max_topics_per_pass ?? DAEMON_DEFAULTS.max_topics_per_pass,
382 passes,
383 llm,
384 dry_run,
385 log_file: d.log_file || null,
386 max_cost_per_day_usd: d.max_cost_per_day_usd ?? null,
387 };
388 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago