@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.
- package/LICENSE +21 -0
- package/README.md +13 -0
- package/package.json +32 -0
- package/src/__tests__/clear-command.test.ts +214 -0
- package/src/__tests__/command-handler.test.ts +169 -0
- package/src/__tests__/compact-command.test.ts +80 -0
- package/src/__tests__/config-command.test.ts +240 -0
- package/src/__tests__/config-loader.test.ts +1512 -0
- package/src/__tests__/config.test.ts +429 -0
- package/src/__tests__/cron-command.test.ts +418 -0
- package/src/__tests__/cron-parser.test.ts +259 -0
- package/src/__tests__/daemon.test.ts +346 -0
- package/src/__tests__/dedup.test.ts +404 -0
- package/src/__tests__/e2e-sanitization.ts +168 -0
- package/src/__tests__/e2e-skill-loader.test.ts +176 -0
- package/src/__tests__/fixtures/AGENTS.md +4 -0
- package/src/__tests__/fixtures/IDENTITY.md +2 -0
- package/src/__tests__/fixtures/SOUL.md +3 -0
- package/src/__tests__/fixtures/moons/athena/IDENTITY.md +2 -0
- package/src/__tests__/fixtures/moons/athena/SOUL.md +3 -0
- package/src/__tests__/fixtures/moons/hermes/SOUL.md +3 -0
- package/src/__tests__/fixtures/skills/brain/SKILL.md +6 -0
- package/src/__tests__/fixtures/skills/empty/SKILL.md +3 -0
- package/src/__tests__/fixtures/skills/multiline/SKILL.md +7 -0
- package/src/__tests__/fixtures/skills/no-desc/SKILL.md +5 -0
- package/src/__tests__/fixtures/skills/notion/SKILL.md +6 -0
- package/src/__tests__/fixtures/skills/quoted/SKILL.md +6 -0
- package/src/__tests__/hook-runner.test.ts +1689 -0
- package/src/__tests__/input-sanitization.test.ts +367 -0
- package/src/__tests__/logger.test.ts +163 -0
- package/src/__tests__/memory-orchestrator.test.ts +552 -0
- package/src/__tests__/model-catalog.test.ts +215 -0
- package/src/__tests__/model-command.test.ts +185 -0
- package/src/__tests__/moon-loader.test.ts +398 -0
- package/src/__tests__/ping-command.test.ts +85 -0
- package/src/__tests__/plugin.test.ts +258 -0
- package/src/__tests__/remind-command.test.ts +368 -0
- package/src/__tests__/reset-command.test.ts +92 -0
- package/src/__tests__/router.test.ts +1246 -0
- package/src/__tests__/scheduler.test.ts +469 -0
- package/src/__tests__/security.test.ts +214 -0
- package/src/__tests__/session-meta.test.ts +101 -0
- package/src/__tests__/session-tracker.test.ts +389 -0
- package/src/__tests__/session.test.ts +241 -0
- package/src/__tests__/skill-loader.test.ts +153 -0
- package/src/__tests__/status-command.test.ts +153 -0
- package/src/__tests__/stop-command.test.ts +60 -0
- package/src/__tests__/think-command.test.ts +146 -0
- package/src/__tests__/usage-api.test.ts +222 -0
- package/src/__tests__/usage-command-api-fail.test.ts +48 -0
- package/src/__tests__/usage-command-no-oauth.test.ts +48 -0
- package/src/__tests__/usage-command.test.ts +173 -0
- package/src/__tests__/whoami-command.test.ts +124 -0
- package/src/index.ts +122 -0
- package/src/lib/command-handler.ts +135 -0
- package/src/lib/commands/clear.ts +69 -0
- package/src/lib/commands/compact.ts +14 -0
- package/src/lib/commands/config-show.ts +49 -0
- package/src/lib/commands/cron.ts +118 -0
- package/src/lib/commands/help.ts +26 -0
- package/src/lib/commands/model.ts +71 -0
- package/src/lib/commands/ping.ts +24 -0
- package/src/lib/commands/remind.ts +75 -0
- package/src/lib/commands/status.ts +118 -0
- package/src/lib/commands/stop.ts +18 -0
- package/src/lib/commands/think.ts +42 -0
- package/src/lib/commands/usage.ts +56 -0
- package/src/lib/commands/whoami.ts +23 -0
- package/src/lib/config-loader.ts +1449 -0
- package/src/lib/config.ts +202 -0
- package/src/lib/cron-parser.ts +388 -0
- package/src/lib/daemon.ts +216 -0
- package/src/lib/dedup.ts +414 -0
- package/src/lib/hook-runner.ts +1270 -0
- package/src/lib/logger.ts +55 -0
- package/src/lib/memory-orchestrator.ts +415 -0
- package/src/lib/model-catalog.ts +240 -0
- package/src/lib/moon-loader.ts +291 -0
- package/src/lib/plugin.ts +148 -0
- package/src/lib/router.ts +1135 -0
- package/src/lib/scheduler.ts +422 -0
- package/src/lib/security.ts +259 -0
- package/src/lib/session-tracker.ts +222 -0
- package/src/lib/session.ts +158 -0
- package/src/lib/skill-loader.ts +166 -0
- package/src/lib/usage-api.ts +145 -0
- package/src/types/agent.ts +86 -0
- package/src/types/channel.ts +93 -0
- package/src/types/index.ts +32 -0
- package/src/types/memory.ts +92 -0
- package/src/types/moon.ts +56 -0
- 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
|
+
}
|