@onmars/lunar-core 0.1.0

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.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -0
  3. package/package.json +32 -0
  4. package/src/__tests__/clear-command.test.ts +214 -0
  5. package/src/__tests__/command-handler.test.ts +169 -0
  6. package/src/__tests__/compact-command.test.ts +80 -0
  7. package/src/__tests__/config-command.test.ts +240 -0
  8. package/src/__tests__/config-loader.test.ts +1512 -0
  9. package/src/__tests__/config.test.ts +429 -0
  10. package/src/__tests__/cron-command.test.ts +418 -0
  11. package/src/__tests__/cron-parser.test.ts +259 -0
  12. package/src/__tests__/daemon.test.ts +346 -0
  13. package/src/__tests__/dedup.test.ts +404 -0
  14. package/src/__tests__/e2e-sanitization.ts +168 -0
  15. package/src/__tests__/e2e-skill-loader.test.ts +176 -0
  16. package/src/__tests__/fixtures/AGENTS.md +4 -0
  17. package/src/__tests__/fixtures/IDENTITY.md +2 -0
  18. package/src/__tests__/fixtures/SOUL.md +3 -0
  19. package/src/__tests__/fixtures/moons/athena/IDENTITY.md +2 -0
  20. package/src/__tests__/fixtures/moons/athena/SOUL.md +3 -0
  21. package/src/__tests__/fixtures/moons/hermes/SOUL.md +3 -0
  22. package/src/__tests__/fixtures/skills/brain/SKILL.md +6 -0
  23. package/src/__tests__/fixtures/skills/empty/SKILL.md +3 -0
  24. package/src/__tests__/fixtures/skills/multiline/SKILL.md +7 -0
  25. package/src/__tests__/fixtures/skills/no-desc/SKILL.md +5 -0
  26. package/src/__tests__/fixtures/skills/notion/SKILL.md +6 -0
  27. package/src/__tests__/fixtures/skills/quoted/SKILL.md +6 -0
  28. package/src/__tests__/hook-runner.test.ts +1689 -0
  29. package/src/__tests__/input-sanitization.test.ts +367 -0
  30. package/src/__tests__/logger.test.ts +163 -0
  31. package/src/__tests__/memory-orchestrator.test.ts +552 -0
  32. package/src/__tests__/model-catalog.test.ts +215 -0
  33. package/src/__tests__/model-command.test.ts +185 -0
  34. package/src/__tests__/moon-loader.test.ts +398 -0
  35. package/src/__tests__/ping-command.test.ts +85 -0
  36. package/src/__tests__/plugin.test.ts +258 -0
  37. package/src/__tests__/remind-command.test.ts +368 -0
  38. package/src/__tests__/reset-command.test.ts +92 -0
  39. package/src/__tests__/router.test.ts +1246 -0
  40. package/src/__tests__/scheduler.test.ts +469 -0
  41. package/src/__tests__/security.test.ts +214 -0
  42. package/src/__tests__/session-meta.test.ts +101 -0
  43. package/src/__tests__/session-tracker.test.ts +389 -0
  44. package/src/__tests__/session.test.ts +241 -0
  45. package/src/__tests__/skill-loader.test.ts +153 -0
  46. package/src/__tests__/status-command.test.ts +153 -0
  47. package/src/__tests__/stop-command.test.ts +60 -0
  48. package/src/__tests__/think-command.test.ts +146 -0
  49. package/src/__tests__/usage-api.test.ts +222 -0
  50. package/src/__tests__/usage-command-api-fail.test.ts +48 -0
  51. package/src/__tests__/usage-command-no-oauth.test.ts +48 -0
  52. package/src/__tests__/usage-command.test.ts +173 -0
  53. package/src/__tests__/whoami-command.test.ts +124 -0
  54. package/src/index.ts +122 -0
  55. package/src/lib/command-handler.ts +135 -0
  56. package/src/lib/commands/clear.ts +69 -0
  57. package/src/lib/commands/compact.ts +14 -0
  58. package/src/lib/commands/config-show.ts +49 -0
  59. package/src/lib/commands/cron.ts +118 -0
  60. package/src/lib/commands/help.ts +26 -0
  61. package/src/lib/commands/model.ts +71 -0
  62. package/src/lib/commands/ping.ts +24 -0
  63. package/src/lib/commands/remind.ts +75 -0
  64. package/src/lib/commands/status.ts +118 -0
  65. package/src/lib/commands/stop.ts +18 -0
  66. package/src/lib/commands/think.ts +42 -0
  67. package/src/lib/commands/usage.ts +56 -0
  68. package/src/lib/commands/whoami.ts +23 -0
  69. package/src/lib/config-loader.ts +1449 -0
  70. package/src/lib/config.ts +202 -0
  71. package/src/lib/cron-parser.ts +388 -0
  72. package/src/lib/daemon.ts +216 -0
  73. package/src/lib/dedup.ts +414 -0
  74. package/src/lib/hook-runner.ts +1270 -0
  75. package/src/lib/logger.ts +55 -0
  76. package/src/lib/memory-orchestrator.ts +415 -0
  77. package/src/lib/model-catalog.ts +240 -0
  78. package/src/lib/moon-loader.ts +291 -0
  79. package/src/lib/plugin.ts +148 -0
  80. package/src/lib/router.ts +1135 -0
  81. package/src/lib/scheduler.ts +422 -0
  82. package/src/lib/security.ts +259 -0
  83. package/src/lib/session-tracker.ts +222 -0
  84. package/src/lib/session.ts +158 -0
  85. package/src/lib/skill-loader.ts +166 -0
  86. package/src/lib/usage-api.ts +145 -0
  87. package/src/types/agent.ts +86 -0
  88. package/src/types/channel.ts +93 -0
  89. package/src/types/index.ts +32 -0
  90. package/src/types/memory.ts +92 -0
  91. package/src/types/moon.ts +56 -0
  92. package/src/types/voice.ts +74 -0
@@ -0,0 +1,1449 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import type { ChannelPersona } from '../types/moon'
4
+ import type { DedupConfig } from './dedup'
5
+ import { log } from './logger'
6
+
7
+ // Re-export for downstream consumers
8
+ export type { DedupConfig } from './dedup'
9
+
10
+ // ════════════════════════════════════════════════════════════
11
+ // Types — Config Spec v3 (2026-03-01)
12
+ // ════════════════════════════════════════════════════════════
13
+
14
+ /**
15
+ * Security configuration from config.yaml.
16
+ * Controls what the agent subprocess can access.
17
+ */
18
+ export interface SecurityConfig {
19
+ /** Isolation level */
20
+ isolation: 'process' | 'user' | 'container'
21
+ /** OS-level env vars that pass through. HOME and PATH always included. */
22
+ envDefaults: string[]
23
+ /** Additional user-specified env vars to pass through */
24
+ envPassthrough: string[]
25
+ /** Pass all env vars (dangerous, opt-in) */
26
+ envPassthroughAll: boolean
27
+ /** Regex patterns to redact from output */
28
+ outputRedactPatterns: string[]
29
+ /** OS user for agent subprocess (Level 2) */
30
+ agentUser?: string
31
+ /** Input sanitization configuration */
32
+ inputSanitization: InputSanitizationConfig
33
+ }
34
+
35
+ /**
36
+ * Input sanitization configuration — defense against prompt injection.
37
+ * Lives inside the security section of config.yaml.
38
+ *
39
+ * Default: enabled with all protections active. Zero config needed.
40
+ */
41
+ export interface InputSanitizationConfig {
42
+ /** Master switch (default: true) */
43
+ enabled: boolean
44
+ /** Strip system-prompt-like markers from input (default: true) */
45
+ stripMarkers: boolean
46
+ /** Log suspicious patterns to pino logger (default: true) */
47
+ logSuspicious: boolean
48
+ /** Prepend sanitization note to system prompt so agent knows (default: true) */
49
+ notifyAgent: boolean
50
+ /** Additional user-defined strip patterns (regex strings) */
51
+ customPatterns: string[]
52
+ }
53
+
54
+ /**
55
+ * Moon entry — parsed from moons section.
56
+ * Value can be string shorthand (= path) or object with path + default.
57
+ */
58
+ export interface MoonEntry {
59
+ /** Filesystem path (resolved relative to workspace) */
60
+ path: string
61
+ /** Whether this is the default moon */
62
+ isDefault: boolean
63
+ /** Model override for this moon (e.g., 'opus', 'sonnet', 'claude-opus-4-6') */
64
+ model?: string
65
+ /** Enable 1M context window for this moon's model */
66
+ context1m?: boolean
67
+ }
68
+
69
+ /**
70
+ * Recall configuration — controls what gets injected into the agent prompt.
71
+ * Merges the old autoRecall + recall sections into a single config.
72
+ */
73
+ export interface RecallConfig {
74
+ /** Whether auto-recall is enabled (default: true if providers exist) */
75
+ enabled: boolean
76
+ /** Maximum number of results to inject (default: 5) */
77
+ limit: number
78
+ /** Minimum relevance score 0-1 (default: 0.3) */
79
+ minScore: number
80
+ /** Max token budget for memory context (default: 800) */
81
+ budget: number
82
+ }
83
+
84
+ /**
85
+ * Memory provider configuration — one entry per provider.
86
+ *
87
+ * Type detection:
88
+ * - url present → HTTP provider (Brain, custom REST)
89
+ * - command present → MCP stdio provider (Engram, custom MCP server)
90
+ * - module present → Local JS/TS module (future)
91
+ *
92
+ * url and command are mutually exclusive.
93
+ */
94
+ export interface ProviderConfig {
95
+ // ─── Connection (mutually exclusive) ───
96
+ /** HTTP endpoint URL → creates HTTP client adapter */
97
+ url?: string
98
+ /** Command to spawn MCP server via stdio → creates MCP adapter */
99
+ command?: string
100
+ /** Local module path (future) */
101
+ module?: string
102
+
103
+ // ─── MCP-specific ───
104
+ /** Command arguments */
105
+ args?: string[]
106
+ /** Environment variables for the MCP subprocess */
107
+ env?: Record<string, string>
108
+ /** Restart MCP server on crash (default: true) */
109
+ autoRestart?: boolean
110
+
111
+ // ─── HTTP-specific ───
112
+ /** Credential env var names — values are resolved from .env / process.env */
113
+ credentials?: Record<string, string>
114
+
115
+ // ─── Shared ───
116
+ /** Expose provider URL/info to agent subprocess env */
117
+ agentAccess?: boolean
118
+ /** Path to .md file with agent instructions (relative to workspace) */
119
+ instructions?: string
120
+ /** Adapter-specific configuration (project, limits, etc.) */
121
+ config?: Record<string, unknown>
122
+ }
123
+
124
+ /**
125
+ * Hook condition — guards that determine if a hook should execute.
126
+ */
127
+ export interface HookCondition {
128
+ /** Only run if these providers are currently active and healthy */
129
+ providersAvailable?: string[]
130
+ /** Only run if the session had at least this many messages */
131
+ minMessages?: number
132
+ }
133
+
134
+ /**
135
+ * Hook definition — an action to execute at a lifecycle event.
136
+ */
137
+ export interface HookEntry {
138
+ /** Built-in action name: promote, boost, context, summarize */
139
+ action: string
140
+ /** Provider to act on (action-specific) */
141
+ provider?: string
142
+ /** Source provider (for promote) */
143
+ from?: string
144
+ /** Target provider (for promote) */
145
+ to?: string
146
+ /** Promotion method (for promote, e.g., 'extraction') */
147
+ via?: string
148
+ /** Recency window (for boost, e.g., '24h') */
149
+ recencyWindow?: string
150
+ /** Boost factor (for boost, e.g., 1.3) */
151
+ factor?: number
152
+ /** Optional guard */
153
+ when?: HookCondition
154
+ }
155
+
156
+ /**
157
+ * Lifecycle event names for memory hooks.
158
+ */
159
+ export type LifecycleEvent =
160
+ | 'sessionStart'
161
+ | 'beforeRecall'
162
+ | 'afterRecall'
163
+ | 'beforeSave'
164
+ | 'afterSave'
165
+ | 'sessionEnd'
166
+ | 'afterSessionEnd'
167
+ | 'compaction'
168
+
169
+ /**
170
+ * Hooks configuration — keyed by lifecycle event name.
171
+ */
172
+ export type HooksConfig = Partial<Record<LifecycleEvent, HookEntry[]>>
173
+
174
+ /**
175
+ * Memory system configuration — providers + orchestration + hooks.
176
+ */
177
+ export interface MemoryConfig {
178
+ /** Named providers — all are equal peers */
179
+ providers: Record<string, ProviderConfig>
180
+ /** Recall: how memory is injected into agent prompt */
181
+ recall: RecallConfig
182
+ /** Dedup: how results from multiple providers are merged */
183
+ dedup: DedupConfig
184
+ /** Lifecycle hooks for provider coordination (optional) */
185
+ hooks?: HooksConfig
186
+ }
187
+
188
+ /**
189
+ * TTS provider configuration from config.yaml voice.tts section.
190
+ */
191
+ export interface VoiceTTSConfig {
192
+ /** Provider name: "elevenlabs" | "openai" */
193
+ provider: string
194
+ /** Voice ID or name (supports ${ENV_VAR} resolution) */
195
+ voice?: string
196
+ /** Model ID (e.g., "eleven_multilingual_v2") */
197
+ model?: string
198
+ /** Language code (e.g., "es", "en") */
199
+ language?: string
200
+ /** Speed multiplier (1.0 = normal) */
201
+ speed?: number
202
+ /** When to generate audio: auto (voice-in→voice-out), always, never */
203
+ mode: 'auto' | 'always' | 'never'
204
+ /** Credential env var names (values resolved from .env) */
205
+ credentials?: Record<string, string>
206
+ }
207
+
208
+ /**
209
+ * STT provider configuration from config.yaml voice.stt section.
210
+ */
211
+ export interface VoiceSTTConfig {
212
+ /** Provider name: "whisper" */
213
+ provider: string
214
+ /** Model ID (e.g., "whisper-1") */
215
+ model?: string
216
+ /** Language hint for transcription */
217
+ language?: string
218
+ /** When to transcribe: auto (always transcribe audio), never */
219
+ mode: 'auto' | 'never'
220
+ /** Credential env var names */
221
+ credentials?: Record<string, string>
222
+ }
223
+
224
+ /**
225
+ * Voice configuration — TTS and/or STT.
226
+ * Both are optional — you can configure just one direction.
227
+ */
228
+ export interface VoiceConfig {
229
+ tts?: VoiceTTSConfig
230
+ stt?: VoiceSTTConfig
231
+ }
232
+
233
+ /**
234
+ * Agent backend configuration.
235
+ */
236
+ export interface AgentBackendConfig {
237
+ /** Model override (e.g., 'opus', 'sonnet', 'claude-opus-4-6') */
238
+ model?: string
239
+ /**
240
+ * Enable 1M context window. Appends [1m] to the model flag.
241
+ * Same pricing up to 200K tokens; 2x input / 1.5x output above 200K.
242
+ */
243
+ context1m?: boolean
244
+ }
245
+
246
+ /**
247
+ * Agent entry — parsed from the top-level `agents` section in config.yaml.
248
+ * Each entry declares an agent instance to register with the plugin registry.
249
+ */
250
+ export interface AgentEntry {
251
+ /** Agent ID used for registration and channel routing */
252
+ id: string
253
+ /** Workspace path for this agent (used as cwd for Claude CLI) */
254
+ workspace?: string
255
+ /** Model override for this specific agent */
256
+ model?: string
257
+ /** Enable 1M context window */
258
+ context1m?: boolean
259
+ /** Claude CLI binary path (defaults to global setting) */
260
+ binary?: string
261
+ /** Whether to mark as default agent (first = default if not specified) */
262
+ default?: boolean
263
+ }
264
+
265
+ /**
266
+ * Platform configuration — one entry per messaging platform (Discord, Telegram, etc.).
267
+ * Contains credentials (resolved from env vars) and platform-specific settings.
268
+ *
269
+ * Credentials use ${ENV_VAR} syntax in config.yaml, resolved at load time.
270
+ */
271
+ export interface PlatformConfig {
272
+ /** Bot/API token (resolved from ${ENV_VAR}) */
273
+ token: string
274
+ /** Platform-specific: Discord guild ID */
275
+ guild?: string
276
+ /** Allowed user IDs */
277
+ allowedUsers?: string[]
278
+ /** Allowed channel IDs (auto-populated from channels if not set) */
279
+ allowedChannels?: string[]
280
+ /** Channels within this platform */
281
+ channels: ChannelPersona[]
282
+ }
283
+
284
+ /**
285
+ * Full workspace config loaded from config.yaml (v2).
286
+ */
287
+ export interface WorkspaceConfig {
288
+ /** Moon declarations — first entry (or default:true) is the default */
289
+ moons: Record<string, MoonEntry>
290
+ /** Platform configurations (discord, telegram, etc.) — v3 */
291
+ platforms: Record<string, PlatformConfig>
292
+ /** Channel → persona bindings (flattened from platforms for backward compat) */
293
+ channels: ChannelPersona[]
294
+ /** Security sandbox for agent subprocess */
295
+ security: SecurityConfig
296
+ /** Memory system configuration (optional — no memory if absent) */
297
+ memory?: MemoryConfig
298
+ /** Agent backend configuration (optional) */
299
+ agent?: AgentBackendConfig
300
+ /** Scheduler configuration (optional) */
301
+ scheduler?: SchedulerConfigParsed
302
+ /** Voice configuration (TTS + STT, optional) */
303
+ voice?: VoiceConfig
304
+ /** Agent instances to register — parsed from top-level `agents` section.
305
+ * Defaults to a single `[{ id: 'claude', default: true }]` when absent. */
306
+ agents: AgentEntry[]
307
+ }
308
+
309
+ // ════════════════════════════════════════════════════════════
310
+ // YAML Parser (unchanged — minimal custom parser, no deps)
311
+ // ════════════════════════════════════════════════════════════
312
+
313
+ /** Hardcoded minimum — these always pass through regardless of user config */
314
+ const HARDCODED_ENV = ['HOME', 'PATH']
315
+
316
+ /** Default env vars if user doesn't override envDefaults */
317
+ const DEFAULT_ENV_DEFAULTS = ['HOME', 'PATH', 'USER', 'LANG', 'TERM', 'TZ', 'SHELL', 'TMPDIR']
318
+
319
+ /** Valid lifecycle event names */
320
+ const VALID_LIFECYCLE_EVENTS = new Set<string>([
321
+ 'sessionStart',
322
+ 'beforeRecall',
323
+ 'afterRecall',
324
+ 'beforeSave',
325
+ 'afterSave',
326
+ 'sessionEnd',
327
+ 'afterSessionEnd',
328
+ 'compaction',
329
+ ])
330
+
331
+ /**
332
+ * Parse a simple YAML file into a nested object.
333
+ * Handles: scalars, lists (- item), nested objects via indentation.
334
+ * Does NOT handle: multi-line strings, anchors, flow style, complex keys.
335
+ */
336
+ function parseSimpleYaml(text: string): Record<string, unknown> {
337
+ const lines = text.split('\n')
338
+ const root: Record<string, unknown> = {}
339
+ const stack: Array<{ obj: Record<string, unknown>; indent: number; key?: string }> = [
340
+ { obj: root, indent: -1 },
341
+ ]
342
+
343
+ // Array tracking — lazy: only activated when first '- ' is encountered
344
+ let currentArrayItems: unknown[] | null = null
345
+ let currentArrayKey: string | null = null
346
+ let currentArrayParent: Record<string, unknown> | null = null
347
+ let currentArrayIndent = -1
348
+ let currentObjItem: Record<string, unknown> | null = null
349
+ let currentObjItemIndent = -1
350
+
351
+ const flushObjItem = () => {
352
+ if (currentObjItem && currentArrayItems) {
353
+ currentArrayItems.push(currentObjItem)
354
+ currentObjItem = null
355
+ currentObjItemIndent = -1
356
+ }
357
+ }
358
+
359
+ const flushArray = () => {
360
+ flushObjItem()
361
+ if (currentArrayItems && currentArrayKey && currentArrayParent) {
362
+ if (currentArrayItems.length > 0) {
363
+ currentArrayParent[currentArrayKey] = currentArrayItems
364
+ }
365
+ currentArrayItems = null
366
+ currentArrayKey = null
367
+ currentArrayParent = null
368
+ currentArrayIndent = -1
369
+ }
370
+ }
371
+
372
+ const parseValue = (raw: string): unknown => {
373
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
374
+ return raw.slice(1, -1)
375
+ }
376
+ if (raw === 'true') return true
377
+ if (raw === 'false') return false
378
+ if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw)
379
+ return raw
380
+ }
381
+
382
+ for (const rawLine of lines) {
383
+ const commentIdx = rawLine.indexOf('#')
384
+ const line = commentIdx >= 0 ? rawLine.slice(0, commentIdx) : rawLine
385
+ if (!line.trim()) continue
386
+
387
+ const indent = line.search(/\S/)
388
+ const trimmed = line.trim()
389
+
390
+ // Array item: "- value" or "- key: value"
391
+ if (trimmed.startsWith('- ')) {
392
+ const value = trimmed.slice(2).trim()
393
+
394
+ // Lazy array init: if no array is active, find the parent key from the stack
395
+ if (!currentArrayItems) {
396
+ // The parent should be the nearest stack entry whose indent < this indent
397
+ // and which created a child object for this indent level
398
+ let found = false
399
+ for (let i = stack.length - 1; i >= 0; i--) {
400
+ if (stack[i].indent < indent && stack[i].key) {
401
+ // Convert this key from an object to an array
402
+ currentArrayKey = stack[i].key!
403
+ currentArrayParent = i > 0 ? stack[i - 1].obj : root
404
+ currentArrayItems = []
405
+ currentArrayIndent = stack[i].indent
406
+ // Remove the empty object that was placed there
407
+ // (since this is actually an array, not an object)
408
+ delete currentArrayParent[currentArrayKey]
409
+ // Pop the stack entry since it's now an array, not an object
410
+ stack.splice(i, 1)
411
+ found = true
412
+ break
413
+ }
414
+ }
415
+ if (!found) {
416
+ log.warn({ line: trimmed }, 'Array item without preceding key')
417
+ continue
418
+ }
419
+ }
420
+
421
+ // Check if this is an object item (contains ':')
422
+ const colonIdx = value.indexOf(':')
423
+ if (colonIdx > 0) {
424
+ // Object array item: "- key: value"
425
+ flushObjItem()
426
+ const itemKey = value.slice(0, colonIdx).trim()
427
+ const itemRawVal = value.slice(colonIdx + 1).trim()
428
+ currentObjItem = { [itemKey]: parseValue(itemRawVal) }
429
+ currentObjItemIndent = indent
430
+ } else {
431
+ // Simple string array item: "- value"
432
+ flushObjItem()
433
+ currentArrayItems!.push(parseValue(value))
434
+ }
435
+ continue
436
+ }
437
+
438
+ // Continuation of object array item (indented key-value after "- key: val")
439
+ if (currentObjItem && indent > currentObjItemIndent) {
440
+ const colonIdx = trimmed.indexOf(':')
441
+ if (colonIdx > 0) {
442
+ const key = trimmed.slice(0, colonIdx).trim()
443
+ const rawValue = trimmed.slice(colonIdx + 1).trim()
444
+ currentObjItem[key] = parseValue(rawValue)
445
+ continue
446
+ }
447
+ }
448
+
449
+ // If we're in an array but hit a line at or before the array's indent level, flush
450
+ if (currentArrayItems && indent <= currentArrayIndent) {
451
+ flushArray()
452
+ }
453
+
454
+ // Non-array line: must be "key: value" or "key:"
455
+ const colonIdx = trimmed.indexOf(':')
456
+ if (colonIdx < 0) continue
457
+
458
+ const key = trimmed.slice(0, colonIdx).trim()
459
+ const rawValue = trimmed.slice(colonIdx + 1).trim()
460
+
461
+ while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
462
+ stack.pop()
463
+ }
464
+ const parent = stack[stack.length - 1].obj
465
+
466
+ if (rawValue === '' || rawValue === '[]') {
467
+ if (rawValue === '[]') {
468
+ parent[key] = []
469
+ } else {
470
+ // Create child object — DON'T set up array tracking (lazy init)
471
+ const child: Record<string, unknown> = {}
472
+ parent[key] = child
473
+ stack.push({ obj: child, indent, key })
474
+ }
475
+ continue
476
+ }
477
+
478
+ parent[key] = parseValue(rawValue)
479
+ }
480
+
481
+ flushArray()
482
+ return root
483
+ }
484
+
485
+ // ════════════════════════════════════════════════════════════
486
+ // Environment Variable Resolution
487
+ // ════════════════════════════════════════════════════════════
488
+
489
+ /**
490
+ * Resolve ${ENV_VAR} references in a string value.
491
+ * Returns the resolved string, or undefined if the env var is not set.
492
+ *
493
+ * Examples:
494
+ * resolveEnvVars("${DISCORD_TOKEN}") → "xyztoken123"
495
+ * resolveEnvVars("prefix-${VAR}-suffix") → "prefix-value-suffix"
496
+ * resolveEnvVars("no-vars-here") → "no-vars-here"
497
+ * resolveEnvVars("${MISSING_VAR}") → undefined
498
+ */
499
+ function resolveEnvVars(value: string): string | undefined {
500
+ const envPattern = /\$\{([^}]+)\}/g
501
+ let hasUnresolved = false
502
+
503
+ const resolved = value.replace(envPattern, (_match, varName: string) => {
504
+ const envValue = process.env[varName.trim()]
505
+ if (envValue === undefined || envValue === '') {
506
+ hasUnresolved = true
507
+ log.warn({ var: varName.trim() }, 'Unresolved env var in config.yaml')
508
+ return ''
509
+ }
510
+ return envValue
511
+ })
512
+
513
+ // If the entire value was a single ${VAR} that couldn't be resolved, return undefined
514
+ if (hasUnresolved && resolved.trim() === '') return undefined
515
+
516
+ return resolved
517
+ }
518
+
519
+ /**
520
+ * Check if a string contains ${...} env var references.
521
+ */
522
+ function hasEnvVarRef(value: string): boolean {
523
+ return /\$\{[^}]+\}/.test(value)
524
+ }
525
+
526
+ // ════════════════════════════════════════════════════════════
527
+ // Section Parsers
528
+ // ════════════════════════════════════════════════════════════
529
+
530
+ /**
531
+ * Parse moons section — unified format (replaces old moon + moons).
532
+ *
533
+ * Supports both shorthand (string = path) and full (object with path + default).
534
+ * First entry is default unless one has `default: true`.
535
+ */
536
+ function parseMoons(raw: unknown): Record<string, MoonEntry> {
537
+ if (!raw || typeof raw !== 'object') return {}
538
+
539
+ const moons: Record<string, MoonEntry> = {}
540
+ let hasExplicitDefault = false
541
+ let isFirst = true
542
+
543
+ for (const [name, value] of Object.entries(raw as Record<string, unknown>)) {
544
+ if (typeof value === 'string') {
545
+ // Shorthand: name: "path"
546
+ moons[name] = {
547
+ path: value,
548
+ isDefault: false, // set below
549
+ }
550
+ } else if (value && typeof value === 'object') {
551
+ const obj = value as Record<string, unknown>
552
+ const path = typeof obj.path === 'string' ? obj.path : `moons/${name}`
553
+ const isDefault = obj.default === true
554
+ const model = typeof obj.model === 'string' ? obj.model : undefined
555
+ const context1m = obj.context1m === true
556
+
557
+ if (isDefault) hasExplicitDefault = true
558
+
559
+ moons[name] = { path, isDefault, model, context1m: context1m || undefined }
560
+ } else {
561
+ log.warn({ moon: name }, 'Invalid moon config — skipping')
562
+ continue
563
+ }
564
+
565
+ // Mark first entry as default candidate
566
+ if (isFirst) {
567
+ isFirst = false
568
+ if (!hasExplicitDefault) {
569
+ moons[name].isDefault = true
570
+ }
571
+ }
572
+ }
573
+
574
+ // If an explicit default was found, ensure only it is marked
575
+ if (hasExplicitDefault) {
576
+ for (const [name, entry] of Object.entries(moons)) {
577
+ if (!entry.isDefault) continue
578
+ // Check if this one has explicit default
579
+ const rawObj = (raw as Record<string, unknown>)[name]
580
+ if (
581
+ rawObj &&
582
+ typeof rawObj === 'object' &&
583
+ (rawObj as Record<string, unknown>).default === true
584
+ ) {
585
+ // Keep it
586
+ } else {
587
+ entry.isDefault = false
588
+ }
589
+ }
590
+ }
591
+
592
+ return moons
593
+ }
594
+
595
+ /**
596
+ * Parse channel entries from a raw channels object into ChannelPersona[].
597
+ * Shared between single-account and multi-account platform parsing.
598
+ */
599
+ function parsePlatformChannels(
600
+ channelsRaw: unknown,
601
+ adapterId: string,
602
+ channelNames: Set<string>,
603
+ ): ChannelPersona[] {
604
+ if (!channelsRaw || typeof channelsRaw !== 'object') return []
605
+
606
+ const channels: ChannelPersona[] = []
607
+
608
+ for (const [channelName, channelRaw] of Object.entries(channelsRaw as Record<string, unknown>)) {
609
+ if (!channelRaw || typeof channelRaw !== 'object') continue
610
+
611
+ // Check for globally unique channel names
612
+ if (channelNames.has(channelName)) {
613
+ log.warn(
614
+ { channel: channelName, platform: adapterId },
615
+ 'Duplicate channel name across platforms — skipping',
616
+ )
617
+ continue
618
+ }
619
+ channelNames.add(channelName)
620
+
621
+ const ch = channelRaw as Record<string, unknown>
622
+ const id = ch.id
623
+
624
+ if (id === undefined || id === null || id === '') {
625
+ log.warn(
626
+ { platform: adapterId, channel: channelName },
627
+ 'Channel missing "id" field — skipping',
628
+ )
629
+ continue
630
+ }
631
+
632
+ // Warn about numeric IDs losing precision (Discord IDs > 2^53)
633
+ if (typeof id === 'number' && id > Number.MAX_SAFE_INTEGER) {
634
+ log.warn(
635
+ { platform: adapterId, channel: channelName, id },
636
+ 'Channel ID exceeds safe integer range — use quotes in YAML to prevent precision loss',
637
+ )
638
+ }
639
+
640
+ channels.push({
641
+ id: String(id),
642
+ name: channelName,
643
+ platform: adapterId,
644
+ moon: typeof ch.moon === 'string' ? ch.moon : undefined,
645
+ instructions: typeof ch.instructions === 'string' ? ch.instructions : undefined,
646
+ tone: typeof ch.tone === 'string' ? ch.tone : undefined,
647
+ focus: typeof ch.focus === 'string' ? ch.focus : undefined,
648
+ model: typeof ch.model === 'string' ? ch.model : undefined,
649
+ skills: Array.isArray(ch.skills) ? (ch.skills as unknown[]).map(String) : undefined,
650
+ agentId: typeof ch.agent === 'string' ? ch.agent : undefined,
651
+ })
652
+ }
653
+
654
+ return channels
655
+ }
656
+
657
+ /**
658
+ * Resolve token from a raw value (string with optional ${ENV_VAR} reference).
659
+ * Returns undefined if the value is not a string or the env var is unresolved.
660
+ */
661
+ function resolveTokenValue(raw: unknown): string | undefined {
662
+ if (typeof raw !== 'string') return undefined
663
+ return hasEnvVarRef(raw) ? resolveEnvVars(raw) : raw
664
+ }
665
+
666
+ /**
667
+ * Resolve guild ID from a raw value (string with optional ${ENV_VAR}, or number).
668
+ */
669
+ function resolveGuildValue(raw: unknown, context: string): string | undefined {
670
+ if (typeof raw === 'string') {
671
+ return hasEnvVarRef(raw) ? resolveEnvVars(raw) : raw
672
+ }
673
+ if (typeof raw === 'number') {
674
+ if (raw > Number.MAX_SAFE_INTEGER) {
675
+ log.warn(
676
+ { platform: context, guild: raw },
677
+ 'Guild ID exceeds safe integer range — use quotes in YAML',
678
+ )
679
+ }
680
+ return String(raw)
681
+ }
682
+ return undefined
683
+ }
684
+
685
+ /**
686
+ * Parse a single platform account into a PlatformConfig entry.
687
+ * Used for both single-account and multi-account platform configs.
688
+ */
689
+ function parseSingleAccount(
690
+ accountObj: Record<string, unknown>,
691
+ adapterId: string,
692
+ channelNames: Set<string>,
693
+ ): PlatformConfig | undefined {
694
+ const token = resolveTokenValue(accountObj.token)
695
+ if (!token) {
696
+ log.warn({ platform: adapterId }, 'Platform missing or unresolvable token — skipping')
697
+ return undefined
698
+ }
699
+
700
+ const guild = resolveGuildValue(accountObj.guild, adapterId)
701
+
702
+ let allowedUsers: string[] | undefined
703
+ if (Array.isArray(accountObj.allowedUsers)) {
704
+ allowedUsers = (accountObj.allowedUsers as unknown[]).map((u) => String(u)).filter(Boolean)
705
+ }
706
+
707
+ const channels = parsePlatformChannels(accountObj.channels, adapterId, channelNames)
708
+
709
+ return {
710
+ token,
711
+ guild,
712
+ allowedUsers,
713
+ allowedChannels: channels.map((c) => c.id),
714
+ channels,
715
+ }
716
+ }
717
+
718
+ /**
719
+ * Parse platforms section from config.yaml.
720
+ * Each platform entry contains credentials + platform-specific config + channels.
721
+ *
722
+ * Credentials use ${ENV_VAR} syntax, resolved from process.env at load time.
723
+ * Channel names must be globally unique across all platforms.
724
+ *
725
+ * Supports multi-account via `accounts` sub-section (Phase C):
726
+ * platforms.discord.accounts.default -> adapterId = 'discord'
727
+ * platforms.discord.accounts.hermes -> adapterId = 'discord:hermes'
728
+ *
729
+ * Returns: Record<adapterId, PlatformConfig> + flattened ChannelPersona[]
730
+ */
731
+ function parsePlatforms(raw: unknown): {
732
+ platforms: Record<string, PlatformConfig>
733
+ channels: ChannelPersona[]
734
+ } {
735
+ if (!raw || typeof raw !== 'object') return { platforms: {}, channels: [] }
736
+
737
+ const platforms: Record<string, PlatformConfig> = {}
738
+ const allChannels: ChannelPersona[] = []
739
+ const channelNames = new Set<string>()
740
+
741
+ for (const [platformName, platformRaw] of Object.entries(raw as Record<string, unknown>)) {
742
+ if (!platformRaw || typeof platformRaw !== 'object') continue
743
+
744
+ const platformObj = platformRaw as Record<string, unknown>
745
+
746
+ // ── Multi-account support (Phase C) ──
747
+ // If `accounts` sub-section exists, parse each account separately
748
+ if (platformObj.accounts && typeof platformObj.accounts === 'object') {
749
+ for (const [accountName, accountRaw] of Object.entries(
750
+ platformObj.accounts as Record<string, unknown>,
751
+ )) {
752
+ if (!accountRaw || typeof accountRaw !== 'object') continue
753
+
754
+ const adapterId =
755
+ accountName === 'default' ? platformName : `${platformName}:${accountName}`
756
+ const accountObj = accountRaw as Record<string, unknown>
757
+
758
+ const config = parseSingleAccount(accountObj, adapterId, channelNames)
759
+ if (config) {
760
+ platforms[adapterId] = config
761
+ allChannels.push(...config.channels)
762
+ }
763
+ }
764
+ continue
765
+ }
766
+
767
+ // ── Single-account (backward compat) ──
768
+ const config = parseSingleAccount(platformObj, platformName, channelNames)
769
+ if (config) {
770
+ platforms[platformName] = config
771
+ allChannels.push(...config.channels)
772
+ }
773
+ }
774
+
775
+ return { platforms, channels: allChannels }
776
+ }
777
+
778
+ /**
779
+ * Parse channels section from config.yaml into ChannelPersona array.
780
+ * Unchanged from v1.
781
+ */
782
+ function parseChannels(raw: unknown): ChannelPersona[] {
783
+ if (!raw || typeof raw !== 'object') return []
784
+
785
+ const channels: ChannelPersona[] = []
786
+
787
+ for (const [platform, platformChannels] of Object.entries(raw as Record<string, unknown>)) {
788
+ if (!platformChannels || typeof platformChannels !== 'object') continue
789
+
790
+ for (const [name, value] of Object.entries(platformChannels as Record<string, unknown>)) {
791
+ if (!value || typeof value !== 'object') continue
792
+
793
+ const ch = value as Record<string, unknown>
794
+ const id = ch.id
795
+
796
+ if (id === undefined || id === null || id === '') {
797
+ log.warn({ platform, channel: name }, 'Channel missing "id" field — skipping')
798
+ continue
799
+ }
800
+
801
+ // Warn about numeric IDs losing precision (Discord IDs > 2^53)
802
+ if (typeof id === 'number' && id > Number.MAX_SAFE_INTEGER) {
803
+ log.warn(
804
+ { platform, channel: name, id },
805
+ 'Channel ID exceeds safe integer range — use quotes in YAML to prevent precision loss',
806
+ )
807
+ }
808
+
809
+ channels.push({
810
+ id: String(id),
811
+ name,
812
+ platform,
813
+ moon: typeof ch.moon === 'string' ? ch.moon : undefined,
814
+ instructions: typeof ch.instructions === 'string' ? ch.instructions : undefined,
815
+ tone: typeof ch.tone === 'string' ? ch.tone : undefined,
816
+ focus: typeof ch.focus === 'string' ? ch.focus : undefined,
817
+ model: typeof ch.model === 'string' ? ch.model : undefined,
818
+ skills: Array.isArray(ch.skills) ? (ch.skills as unknown[]).map(String) : undefined,
819
+ agentId: typeof ch.agent === 'string' ? ch.agent : undefined,
820
+ })
821
+ }
822
+ }
823
+
824
+ return channels
825
+ }
826
+
827
+ /**
828
+ * Parse a single provider entry from memory.providers.<name>.
829
+ */
830
+ function parseProvider(name: string, raw: unknown): ProviderConfig | undefined {
831
+ if (!raw || typeof raw !== 'object') return undefined
832
+
833
+ const entry = raw as Record<string, unknown>
834
+ const url = typeof entry.url === 'string' ? entry.url : undefined
835
+ const command = typeof entry.command === 'string' ? entry.command : undefined
836
+ const module = typeof entry.module === 'string' ? entry.module : undefined
837
+
838
+ // Validate: at least one connection method
839
+ if (!url && !command && !module) {
840
+ log.warn({ provider: name }, 'Provider needs url, command, or module — skipping')
841
+ return undefined
842
+ }
843
+
844
+ // Validate: mutually exclusive
845
+ const connectionCount = [url, command, module].filter(Boolean).length
846
+ if (connectionCount > 1) {
847
+ log.warn(
848
+ { provider: name },
849
+ 'Provider has multiple connection types (url/command/module) — use only one. Skipping.',
850
+ )
851
+ return undefined
852
+ }
853
+
854
+ // Parse credentials
855
+ const credentials: Record<string, string> = {}
856
+ if (entry.credentials && typeof entry.credentials === 'object') {
857
+ for (const [key, value] of Object.entries(entry.credentials as Record<string, unknown>)) {
858
+ if (typeof value === 'string') credentials[key] = value
859
+ }
860
+ }
861
+
862
+ // Parse env
863
+ const env: Record<string, string> = {}
864
+ if (entry.env && typeof entry.env === 'object') {
865
+ for (const [key, value] of Object.entries(entry.env as Record<string, unknown>)) {
866
+ if (typeof value === 'string') env[key] = value
867
+ }
868
+ }
869
+
870
+ // Parse args
871
+ const args = Array.isArray(entry.args)
872
+ ? ((entry.args as unknown[]).filter((a) => typeof a === 'string') as string[])
873
+ : undefined
874
+
875
+ return {
876
+ url,
877
+ command,
878
+ module,
879
+ args: args?.length ? args : undefined,
880
+ env: Object.keys(env).length > 0 ? env : undefined,
881
+ autoRestart: entry.autoRestart !== false,
882
+ credentials: Object.keys(credentials).length > 0 ? credentials : undefined,
883
+ agentAccess: entry.agentAccess === true,
884
+ instructions: typeof entry.instructions === 'string' ? entry.instructions : undefined,
885
+ config:
886
+ entry.config && typeof entry.config === 'object'
887
+ ? (entry.config as Record<string, unknown>)
888
+ : undefined,
889
+ }
890
+ }
891
+
892
+ /**
893
+ * Parse hooks section from memory.hooks.
894
+ */
895
+ function parseHooks(raw: unknown): HooksConfig | undefined {
896
+ if (!raw || typeof raw !== 'object') return undefined
897
+
898
+ const hooks: HooksConfig = {}
899
+ let hasAnyHook = false
900
+
901
+ for (const [eventName, entries] of Object.entries(raw as Record<string, unknown>)) {
902
+ if (!VALID_LIFECYCLE_EVENTS.has(eventName)) {
903
+ log.warn({ event: eventName }, 'Unknown lifecycle event in hooks — skipping')
904
+ continue
905
+ }
906
+
907
+ const event = eventName as LifecycleEvent
908
+
909
+ // entries can be an array of hook objects or a single object
910
+ // Our YAML parser creates objects for nested structures
911
+ if (!entries || typeof entries !== 'object') continue
912
+
913
+ // Handle array-like (our parser doesn't create arrays of objects,
914
+ // but if manually constructed it might)
915
+ const hookEntries: HookEntry[] = []
916
+
917
+ if (Array.isArray(entries)) {
918
+ for (const entry of entries) {
919
+ const hook = parseHookEntry(entry)
920
+ if (hook) hookEntries.push(hook)
921
+ }
922
+ } else {
923
+ // Single hook object under the event
924
+ const hook = parseHookEntry(entries)
925
+ if (hook) hookEntries.push(hook)
926
+ }
927
+
928
+ if (hookEntries.length > 0) {
929
+ hooks[event] = hookEntries
930
+ hasAnyHook = true
931
+ }
932
+ }
933
+
934
+ return hasAnyHook ? hooks : undefined
935
+ }
936
+
937
+ /**
938
+ * Parse a single hook entry.
939
+ */
940
+ function parseHookEntry(raw: unknown): HookEntry | undefined {
941
+ if (!raw || typeof raw !== 'object') return undefined
942
+
943
+ const entry = raw as Record<string, unknown>
944
+ const action = entry.action
945
+ if (typeof action !== 'string' || !action) {
946
+ log.warn('Hook entry missing "action" field — skipping')
947
+ return undefined
948
+ }
949
+
950
+ // Parse when condition
951
+ let when: HookCondition | undefined
952
+ if (entry.when && typeof entry.when === 'object') {
953
+ const w = entry.when as Record<string, unknown>
954
+ when = {}
955
+ if (Array.isArray(w.providersAvailable)) {
956
+ when.providersAvailable = w.providersAvailable.filter(
957
+ (v) => typeof v === 'string',
958
+ ) as string[]
959
+ }
960
+ if (typeof w.minMessages === 'number') {
961
+ when.minMessages = w.minMessages
962
+ }
963
+ // Only keep if there's at least one condition
964
+ if (!when.providersAvailable?.length && when.minMessages === undefined) {
965
+ when = undefined
966
+ }
967
+ }
968
+
969
+ return {
970
+ action,
971
+ provider: typeof entry.provider === 'string' ? entry.provider : undefined,
972
+ from: typeof entry.from === 'string' ? entry.from : undefined,
973
+ to: typeof entry.to === 'string' ? entry.to : undefined,
974
+ via: typeof entry.via === 'string' ? entry.via : undefined,
975
+ recencyWindow: typeof entry.recencyWindow === 'string' ? entry.recencyWindow : undefined,
976
+ factor: typeof entry.factor === 'number' ? entry.factor : undefined,
977
+ when,
978
+ }
979
+ }
980
+
981
+ /**
982
+ * Parse memory section — v2 format with providers map + orchestration.
983
+ */
984
+ function parseMemory(raw: unknown): MemoryConfig | undefined {
985
+ if (!raw || typeof raw !== 'object') return undefined
986
+
987
+ const mem = raw as Record<string, unknown>
988
+
989
+ // Parse providers
990
+ const providersRaw = mem.providers
991
+ if (!providersRaw || typeof providersRaw !== 'object') {
992
+ log.warn('Memory section found but missing "providers" — skipping')
993
+ return undefined
994
+ }
995
+
996
+ const providers: Record<string, ProviderConfig> = {}
997
+ for (const [name, value] of Object.entries(providersRaw as Record<string, unknown>)) {
998
+ const provider = parseProvider(name, value)
999
+ if (provider) {
1000
+ providers[name] = provider
1001
+ }
1002
+ }
1003
+
1004
+ if (Object.keys(providers).length === 0) {
1005
+ log.warn('Memory providers configured but none are valid — skipping memory')
1006
+ return undefined
1007
+ }
1008
+
1009
+ // Parse recall config (merges old autoRecall + recall)
1010
+ const recallRaw = (mem.recall ?? {}) as Record<string, unknown>
1011
+ const recall: RecallConfig = {
1012
+ enabled: recallRaw.enabled !== false,
1013
+ limit: typeof recallRaw.limit === 'number' ? recallRaw.limit : 5,
1014
+ minScore: typeof recallRaw.minScore === 'number' ? recallRaw.minScore : 0.3,
1015
+ budget: typeof recallRaw.budget === 'number' ? recallRaw.budget : 800,
1016
+ }
1017
+
1018
+ // Parse dedup config
1019
+ const dedupRaw = (mem.dedup ?? {}) as Record<string, unknown>
1020
+ const validStrategies = ['dice', 'minhash', 'adaptive']
1021
+ const dedup: DedupConfig = {
1022
+ strategy:
1023
+ typeof dedupRaw.strategy === 'string' && validStrategies.includes(dedupRaw.strategy)
1024
+ ? (dedupRaw.strategy as DedupConfig['strategy'])
1025
+ : 'adaptive',
1026
+ threshold: typeof dedupRaw.threshold === 'number' ? dedupRaw.threshold : 0.85,
1027
+ numPermutations: typeof dedupRaw.numPermutations === 'number' ? dedupRaw.numPermutations : 128,
1028
+ shingleSize: typeof dedupRaw.shingleSize === 'number' ? dedupRaw.shingleSize : 3,
1029
+ crossoverLength: typeof dedupRaw.crossoverLength === 'number' ? dedupRaw.crossoverLength : 300,
1030
+ }
1031
+
1032
+ // Parse hooks
1033
+ const hooks = parseHooks(mem.hooks)
1034
+
1035
+ return { providers, recall, dedup, hooks }
1036
+ }
1037
+
1038
+ /**
1039
+ * Parse voice section from config.yaml.
1040
+ * Supports ${ENV_VAR} resolution for voice ID and credentials.
1041
+ */
1042
+ function parseVoice(raw: unknown): VoiceConfig | undefined {
1043
+ if (!raw || typeof raw !== 'object') return undefined
1044
+
1045
+ const voice = raw as Record<string, unknown>
1046
+ const result: VoiceConfig = {}
1047
+
1048
+ // Parse TTS
1049
+ if (voice.tts && typeof voice.tts === 'object') {
1050
+ const tts = voice.tts as Record<string, unknown>
1051
+ const provider = typeof tts.provider === 'string' ? tts.provider : undefined
1052
+
1053
+ if (!provider) {
1054
+ log.warn('voice.tts missing "provider" field — skipping TTS')
1055
+ } else {
1056
+ // Resolve voice ID from ${ENV_VAR} if needed
1057
+ let voiceId: string | undefined
1058
+ if (typeof tts.voice === 'string') {
1059
+ voiceId = hasEnvVarRef(tts.voice) ? resolveEnvVars(tts.voice) : tts.voice
1060
+ }
1061
+
1062
+ // Parse credentials
1063
+ const credentials: Record<string, string> = {}
1064
+ if (tts.credentials && typeof tts.credentials === 'object') {
1065
+ for (const [key, value] of Object.entries(tts.credentials as Record<string, unknown>)) {
1066
+ if (typeof value === 'string') credentials[key] = value
1067
+ }
1068
+ }
1069
+
1070
+ // Validate mode
1071
+ const validTTSModes = ['auto', 'always', 'never']
1072
+ const mode =
1073
+ typeof tts.mode === 'string' && validTTSModes.includes(tts.mode)
1074
+ ? (tts.mode as VoiceTTSConfig['mode'])
1075
+ : 'auto'
1076
+
1077
+ result.tts = {
1078
+ provider,
1079
+ voice: voiceId,
1080
+ model: typeof tts.model === 'string' ? tts.model : undefined,
1081
+ language: typeof tts.language === 'string' ? tts.language : undefined,
1082
+ speed: typeof tts.speed === 'number' ? tts.speed : undefined,
1083
+ mode,
1084
+ credentials: Object.keys(credentials).length > 0 ? credentials : undefined,
1085
+ }
1086
+ }
1087
+ }
1088
+
1089
+ // Parse STT
1090
+ if (voice.stt && typeof voice.stt === 'object') {
1091
+ const stt = voice.stt as Record<string, unknown>
1092
+ const provider = typeof stt.provider === 'string' ? stt.provider : undefined
1093
+
1094
+ if (!provider) {
1095
+ log.warn('voice.stt missing "provider" field — skipping STT')
1096
+ } else {
1097
+ const credentials: Record<string, string> = {}
1098
+ if (stt.credentials && typeof stt.credentials === 'object') {
1099
+ for (const [key, value] of Object.entries(stt.credentials as Record<string, unknown>)) {
1100
+ if (typeof value === 'string') credentials[key] = value
1101
+ }
1102
+ }
1103
+
1104
+ const validSTTModes = ['auto', 'never']
1105
+ const mode =
1106
+ typeof stt.mode === 'string' && validSTTModes.includes(stt.mode)
1107
+ ? (stt.mode as VoiceSTTConfig['mode'])
1108
+ : 'auto'
1109
+
1110
+ result.stt = {
1111
+ provider,
1112
+ model: typeof stt.model === 'string' ? stt.model : undefined,
1113
+ language: typeof stt.language === 'string' ? stt.language : undefined,
1114
+ mode,
1115
+ credentials: Object.keys(credentials).length > 0 ? credentials : undefined,
1116
+ }
1117
+ }
1118
+ }
1119
+
1120
+ // Return undefined if neither TTS nor STT configured
1121
+ if (!result.tts && !result.stt) return undefined
1122
+
1123
+ return result
1124
+ }
1125
+
1126
+ /**
1127
+ * Parse input sanitization config with safe defaults.
1128
+ * Everything is enabled by default — user opts OUT, not in.
1129
+ */
1130
+ function parseInputSanitization(raw: unknown): InputSanitizationConfig {
1131
+ if (!raw || typeof raw !== 'object') {
1132
+ // Default: everything enabled
1133
+ return {
1134
+ enabled: true,
1135
+ stripMarkers: true,
1136
+ logSuspicious: true,
1137
+ notifyAgent: true,
1138
+ customPatterns: [],
1139
+ }
1140
+ }
1141
+
1142
+ const obj = raw as Record<string, unknown>
1143
+ return {
1144
+ enabled: obj.enabled !== false,
1145
+ stripMarkers: obj.stripMarkers !== false,
1146
+ logSuspicious: obj.logSuspicious !== false,
1147
+ notifyAgent: obj.notifyAgent !== false,
1148
+ customPatterns: Array.isArray(obj.customPatterns)
1149
+ ? ((obj.customPatterns as unknown[]).filter((p) => typeof p === 'string') as string[])
1150
+ : [],
1151
+ }
1152
+ }
1153
+
1154
+ /**
1155
+ * Parse the top-level `agents` section from config.yaml.
1156
+ *
1157
+ * YAML format:
1158
+ * agents:
1159
+ * deimos:
1160
+ * workspace: "."
1161
+ * model: sonnet
1162
+ * default: true
1163
+ * hermes:
1164
+ * workspace: "moons/hermes"
1165
+ * model: haiku
1166
+ *
1167
+ * Returns a single default entry `[{ id: 'claude', default: true }]`
1168
+ * when the section is absent — backward compatible.
1169
+ */
1170
+ function parseAgents(raw: unknown): AgentEntry[] {
1171
+ const defaultEntry: AgentEntry[] = [{ id: 'claude', default: true }]
1172
+
1173
+ if (!raw || typeof raw !== 'object') return defaultEntry
1174
+
1175
+ const agents: AgentEntry[] = []
1176
+ let hasExplicitDefault = false
1177
+ let isFirst = true
1178
+
1179
+ for (const [id, value] of Object.entries(raw as Record<string, unknown>)) {
1180
+ if (!value || typeof value !== 'object') {
1181
+ // Skip invalid entries
1182
+ log.warn({ agent: id }, 'Invalid agent config — skipping')
1183
+ continue
1184
+ }
1185
+
1186
+ const obj = value as Record<string, unknown>
1187
+ const isDefault = obj.default === true
1188
+ if (isDefault) hasExplicitDefault = true
1189
+
1190
+ const entry: AgentEntry = {
1191
+ id,
1192
+ workspace: typeof obj.workspace === 'string' ? obj.workspace : undefined,
1193
+ model: typeof obj.model === 'string' ? obj.model : undefined,
1194
+ context1m: obj.context1m === true ? true : undefined,
1195
+ binary: typeof obj.binary === 'string' ? obj.binary : undefined,
1196
+ default: isFirst && !hasExplicitDefault ? true : isDefault,
1197
+ }
1198
+
1199
+ agents.push(entry)
1200
+ isFirst = false
1201
+ }
1202
+
1203
+ // If an explicit default was found, clear auto-default from first entry
1204
+ if (
1205
+ hasExplicitDefault &&
1206
+ agents.length > 0 &&
1207
+ agents[0].default &&
1208
+ !isExplicitDefault(raw, agents[0].id)
1209
+ ) {
1210
+ agents[0].default = false
1211
+ }
1212
+
1213
+ return agents.length > 0 ? agents : defaultEntry
1214
+ }
1215
+
1216
+ /** Check if an agent has explicit `default: true` in the raw config. */
1217
+ function isExplicitDefault(raw: unknown, id: string): boolean {
1218
+ if (!raw || typeof raw !== 'object') return false
1219
+ const entry = (raw as Record<string, unknown>)[id]
1220
+ if (!entry || typeof entry !== 'object') return false
1221
+ return (entry as Record<string, unknown>).default === true
1222
+ }
1223
+
1224
+ // ════════════════════════════════════════════════════════════
1225
+ // Main Loader
1226
+ // ════════════════════════════════════════════════════════════
1227
+
1228
+ /**
1229
+ * Load workspace config from config.yaml (v2 format).
1230
+ * Returns defaults if file doesn't exist.
1231
+ */
1232
+ export function loadWorkspaceConfig(workspacePath: string): WorkspaceConfig {
1233
+ const configPath = resolve(workspacePath, 'config.yaml')
1234
+
1235
+ const defaults: WorkspaceConfig = {
1236
+ security: {
1237
+ isolation: 'process',
1238
+ envDefaults: DEFAULT_ENV_DEFAULTS,
1239
+ envPassthrough: [],
1240
+ envPassthroughAll: false,
1241
+ outputRedactPatterns: [],
1242
+ inputSanitization: {
1243
+ enabled: true,
1244
+ stripMarkers: true,
1245
+ logSuspicious: true,
1246
+ notifyAgent: true,
1247
+ customPatterns: [],
1248
+ },
1249
+ },
1250
+ platforms: {},
1251
+ channels: [],
1252
+ moons: {},
1253
+ agents: [{ id: 'claude', default: true }],
1254
+ }
1255
+
1256
+ if (!existsSync(configPath)) {
1257
+ log.debug({ path: configPath }, 'No config.yaml found, using defaults')
1258
+ return defaults
1259
+ }
1260
+
1261
+ try {
1262
+ const raw = require('node:fs').readFileSync(configPath, 'utf-8') as string
1263
+ const parsed = parseSimpleYaml(raw)
1264
+
1265
+ // Security (unchanged from v1)
1266
+ const secRaw = (parsed.security ?? {}) as Record<string, unknown>
1267
+ const security: SecurityConfig = {
1268
+ isolation: (['process', 'user', 'container'].includes(secRaw.isolation as string)
1269
+ ? secRaw.isolation
1270
+ : 'process') as SecurityConfig['isolation'],
1271
+
1272
+ envDefaults: Array.isArray(secRaw.envDefaults)
1273
+ ? ensureHardcoded(secRaw.envDefaults as string[])
1274
+ : DEFAULT_ENV_DEFAULTS,
1275
+
1276
+ envPassthrough: Array.isArray(secRaw.envPassthrough)
1277
+ ? (secRaw.envPassthrough as string[])
1278
+ : [],
1279
+
1280
+ envPassthroughAll: secRaw.envPassthroughAll === true,
1281
+
1282
+ outputRedactPatterns: Array.isArray(secRaw.outputRedactPatterns)
1283
+ ? (secRaw.outputRedactPatterns as string[])
1284
+ : [],
1285
+
1286
+ agentUser: typeof secRaw.agentUser === 'string' ? secRaw.agentUser : undefined,
1287
+ inputSanitization: parseInputSanitization(secRaw.inputSanitization),
1288
+ }
1289
+
1290
+ // Moons (v2 — unified)
1291
+ const moons = parseMoons(parsed.moons)
1292
+
1293
+ // Platforms (v3 — credentials + channels unified)
1294
+ const { platforms, channels: platformChannels } = parsePlatforms(parsed.platforms)
1295
+
1296
+ // Legacy fallback: if no platforms section, try old channels section
1297
+ const channels = platformChannels.length > 0 ? platformChannels : parseChannels(parsed.channels)
1298
+
1299
+ // Memory (v2 — providers map + orchestration + hooks)
1300
+ const memory = parseMemory(parsed.memory)
1301
+
1302
+ log.info(
1303
+ {
1304
+ isolation: security.isolation,
1305
+ moons: Object.keys(moons).length,
1306
+ defaultMoon: Object.entries(moons).find(([, m]) => m.isDefault)?.[0] ?? 'none',
1307
+ platforms: Object.keys(platforms).length,
1308
+ channels: channels.length,
1309
+ memoryProviders: memory ? Object.keys(memory.providers).length : 0,
1310
+ hooks: memory?.hooks ? Object.keys(memory.hooks).length : 0,
1311
+ },
1312
+ 'Workspace config loaded (v3)',
1313
+ )
1314
+
1315
+ // Agent backend config (optional)
1316
+ let agent: AgentBackendConfig | undefined
1317
+ if (parsed.agent && typeof parsed.agent === 'object') {
1318
+ const raw = parsed.agent as Record<string, unknown>
1319
+ agent = {
1320
+ model: typeof raw.model === 'string' ? raw.model : undefined,
1321
+ context1m: raw.context1m === true,
1322
+ }
1323
+ }
1324
+
1325
+ // Scheduler (v0.3)
1326
+ const scheduler = parseSchedulerConfig(parsed.scheduler as Record<string, unknown> | undefined)
1327
+ if (scheduler) {
1328
+ log.info(
1329
+ {
1330
+ enabled: scheduler.enabled,
1331
+ jobs: Object.keys(scheduler.jobs).length,
1332
+ tz: scheduler.timezone,
1333
+ },
1334
+ 'Scheduler config loaded',
1335
+ )
1336
+ }
1337
+
1338
+ // Voice (TTS + STT)
1339
+ const voice = parseVoice(parsed.voice)
1340
+ if (voice) {
1341
+ log.info(
1342
+ {
1343
+ tts: voice.tts ? `${voice.tts.provider} (${voice.tts.mode})` : 'none',
1344
+ stt: voice.stt ? `${voice.stt.provider} (${voice.stt.mode})` : 'none',
1345
+ },
1346
+ 'Voice config loaded',
1347
+ )
1348
+ }
1349
+
1350
+ // Agents (Phase B — multi-agent routing)
1351
+ const agents = parseAgents(parsed.agents)
1352
+
1353
+ return {
1354
+ security,
1355
+ moons,
1356
+ platforms,
1357
+ channels,
1358
+ memory,
1359
+ agent,
1360
+ scheduler: scheduler ?? undefined,
1361
+ voice,
1362
+ agents,
1363
+ }
1364
+ } catch (error) {
1365
+ log.error({ err: error, path: configPath }, 'Failed to parse config.yaml, using defaults')
1366
+ return defaults
1367
+ }
1368
+ }
1369
+
1370
+ /** Ensure HOME and PATH are always in the list */
1371
+ function ensureHardcoded(vars: string[]): string[] {
1372
+ const result = [...vars]
1373
+ for (const required of HARDCODED_ENV) {
1374
+ if (!result.includes(required)) {
1375
+ result.unshift(required)
1376
+ }
1377
+ }
1378
+ return result
1379
+ }
1380
+
1381
+ // ════════════════════════════════════════════════════════════
1382
+ // Scheduler Config — v0.3
1383
+ // ════════════════════════════════════════════════════════════
1384
+
1385
+ export interface SchedulerJobConfig {
1386
+ schedule: { kind: string; expr?: string; at?: string; intervalMs?: number; tz?: string }
1387
+ channel: string
1388
+ payload: { kind: string; prompt?: string; text?: string }
1389
+ moon?: string
1390
+ enabled?: boolean
1391
+ oneShot?: boolean
1392
+ }
1393
+
1394
+ export interface SchedulerConfigParsed {
1395
+ enabled: boolean
1396
+ pollIntervalMs: number
1397
+ timezone: string
1398
+ jobs: Record<string, SchedulerJobConfig>
1399
+ }
1400
+
1401
+ /**
1402
+ * Parse the scheduler section from config.yaml.
1403
+ * Returns null if no scheduler section exists.
1404
+ */
1405
+ export function parseSchedulerConfig(
1406
+ raw: Record<string, unknown> | undefined,
1407
+ ): SchedulerConfigParsed | null {
1408
+ if (!raw || typeof raw !== 'object') return null
1409
+
1410
+ const enabled = raw.enabled !== false
1411
+ const pollIntervalMs = typeof raw.pollIntervalMs === 'number' ? raw.pollIntervalMs : 60000
1412
+ const timezone = typeof raw.timezone === 'string' ? raw.timezone : 'UTC'
1413
+
1414
+ const jobs: Record<string, SchedulerJobConfig> = {}
1415
+ const rawJobs = (raw.jobs ?? {}) as Record<string, Record<string, unknown>>
1416
+
1417
+ for (const [name, jobRaw] of Object.entries(rawJobs)) {
1418
+ if (!jobRaw || typeof jobRaw !== 'object') continue
1419
+
1420
+ const schedRaw = jobRaw.schedule as Record<string, unknown> | undefined
1421
+ const payloadRaw = jobRaw.payload as Record<string, unknown> | undefined
1422
+
1423
+ if (!schedRaw || !payloadRaw) {
1424
+ log.warn({ job: name }, 'Scheduler job missing schedule or payload — skipping')
1425
+ continue
1426
+ }
1427
+
1428
+ jobs[name] = {
1429
+ schedule: {
1430
+ kind: String(schedRaw.kind ?? 'cron'),
1431
+ expr: typeof schedRaw.expr === 'string' ? schedRaw.expr : undefined,
1432
+ at: typeof schedRaw.at === 'string' ? schedRaw.at : undefined,
1433
+ intervalMs: typeof schedRaw.intervalMs === 'number' ? schedRaw.intervalMs : undefined,
1434
+ tz: typeof schedRaw.tz === 'string' ? schedRaw.tz : undefined,
1435
+ },
1436
+ channel: String(jobRaw.channel ?? ''),
1437
+ payload: {
1438
+ kind: String(payloadRaw.kind ?? 'message'),
1439
+ prompt: typeof payloadRaw.prompt === 'string' ? payloadRaw.prompt : undefined,
1440
+ text: typeof payloadRaw.text === 'string' ? payloadRaw.text : undefined,
1441
+ },
1442
+ moon: typeof jobRaw.moon === 'string' ? jobRaw.moon : undefined,
1443
+ enabled: jobRaw.enabled !== false,
1444
+ oneShot: jobRaw.oneShot === true,
1445
+ }
1446
+ }
1447
+
1448
+ return { enabled, pollIntervalMs, timezone, jobs }
1449
+ }