@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,1135 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import type { AgentEvent, AgentInput } from '../types/agent'
4
+ import type { IncomingMessage, OutgoingMessage } from '../types/channel'
5
+ import type { MemoryProvider } from '../types/memory'
6
+ import type { ChannelPersona } from '../types/moon'
7
+ import { type ClearResult, type CommandContext, CommandHandler } from './command-handler'
8
+ import { clearCommand } from './commands/clear'
9
+ import { compactCommand } from './commands/compact'
10
+ import { configCommand } from './commands/config-show'
11
+ import { cronCommand } from './commands/cron'
12
+ import { createHelpCommand } from './commands/help'
13
+ import { modelCommand } from './commands/model'
14
+ import { pingCommand } from './commands/ping'
15
+ import { remindCommand } from './commands/remind'
16
+ import { statusCommand } from './commands/status'
17
+ import { stopCommand } from './commands/stop'
18
+ import { thinkCommand } from './commands/think'
19
+ import { usageCommand } from './commands/usage'
20
+ import { whoamiCommand } from './commands/whoami'
21
+ import type { LunarConfig } from './config'
22
+ import type { MemoryConfig, RecallConfig, SecurityConfig, VoiceConfig } from './config-loader'
23
+ import { log } from './logger'
24
+ import type { MemoryOrchestrator } from './memory-orchestrator'
25
+ import type { MoonLoader } from './moon-loader'
26
+ import type { PluginRegistry } from './plugin'
27
+ import type { JobPayload, Scheduler } from './scheduler'
28
+ import type { SanitizationResult } from './security'
29
+ import { createRedactor, createSanitizer } from './security'
30
+ import type { SessionStore } from './session'
31
+ import { SessionTracker } from './session-tracker'
32
+
33
+ export interface RouteOptions {
34
+ /** Override agent ID */
35
+ agentId?: string
36
+ }
37
+
38
+ /**
39
+ * Router — Handles the flow: incoming message → agent query → outgoing response.
40
+ *
41
+ * Responsibilities:
42
+ * - Session lookup/creation
43
+ * - Auth check (allowed users)
44
+ * - Moon resolution per channel (via MoonLoader)
45
+ * - Memory auto-recall (inject relevant context before query)
46
+ * - Streaming agent response → channel message
47
+ * - Typing indicator management
48
+ * - Output redaction (security)
49
+ */
50
+ export class Router {
51
+ private redact: (text: string) => string
52
+ private sanitize: (text: string) => SanitizationResult
53
+ private channelIndex: Map<string, ChannelPersona>
54
+ private memory?: MemoryProvider
55
+ private recallConfig?: RecallConfig
56
+ private memoryInstructions?: string
57
+
58
+ /**
59
+ * In-memory message history per session — used for graceful session close.
60
+ * Capped at MAX_SESSION_HISTORY messages per session to bound memory usage.
61
+ */
62
+ private sessionMessages = new Map<string, Array<{ role: string; content: string }>>()
63
+ private static readonly MAX_SESSION_HISTORY = 200
64
+
65
+ /** Tracks cumulative token usage per session */
66
+ private sessionTracker = new SessionTracker()
67
+
68
+ private voiceConfig?: VoiceConfig
69
+
70
+ /** Slash command handler */
71
+ private commandHandler: CommandHandler
72
+
73
+ /** Daemon start time — set by Daemon after construction */
74
+ daemonStartedAt = 0
75
+
76
+ constructor(
77
+ private registry: PluginRegistry,
78
+ private sessions: SessionStore,
79
+ private config: LunarConfig,
80
+ private moonLoader: MoonLoader,
81
+ channels: ChannelPersona[],
82
+ security?: SecurityConfig,
83
+ memory?: MemoryProvider,
84
+ recallConfig?: RecallConfig,
85
+ private workspacePath?: string,
86
+ private memoryConfig?: MemoryConfig,
87
+ private scheduler?: Scheduler,
88
+ voiceConfig?: VoiceConfig,
89
+ ) {
90
+ this.redact = security ? createRedactor(security) : (t: string) => t
91
+ this.sanitize = security
92
+ ? createSanitizer(security.inputSanitization)
93
+ : (t: string) => ({
94
+ text: t,
95
+ stripped: 0,
96
+ suspicious: [],
97
+ wasSanitized: false,
98
+ })
99
+ this.memory = memory
100
+ this.recallConfig = recallConfig
101
+ this.memoryInstructions = this.loadMemoryInstructions()
102
+ this.voiceConfig = voiceConfig
103
+
104
+ // Build index: platform:channelId → ChannelPersona
105
+ // Also index by bare ID for backward compat (single-platform setups)
106
+ this.channelIndex = new Map()
107
+ for (const ch of channels) {
108
+ this.channelIndex.set(`${ch.platform}:${ch.id}`, ch)
109
+ // Bare ID as fallback (avoids breaking single-platform setups)
110
+ if (!this.channelIndex.has(ch.id)) {
111
+ this.channelIndex.set(ch.id, ch)
112
+ }
113
+ }
114
+
115
+ if (this.channelIndex.size > 0) {
116
+ log.info(
117
+ { channels: channels.map((c) => `${c.platform}:${c.name}→${c.moon ?? 'default'}`) },
118
+ 'Channel → Moon routing configured',
119
+ )
120
+ }
121
+
122
+ if (memory && recallConfig?.enabled) {
123
+ log.info(
124
+ { provider: memory.id, limit: recallConfig.limit, minScore: recallConfig.minScore },
125
+ 'Auto-recall enabled',
126
+ )
127
+ }
128
+
129
+ // Register slash commands
130
+ this.commandHandler = new CommandHandler()
131
+ this.commandHandler.register(
132
+ 'status',
133
+ 'Show session status, tokens, context, and cost',
134
+ statusCommand,
135
+ )
136
+ this.commandHandler.register(
137
+ 'help',
138
+ 'List available commands',
139
+ createHelpCommand(this.commandHandler),
140
+ )
141
+ this.commandHandler.register(
142
+ 'clear',
143
+ 'Clear session: reset tokens, context, and start fresh',
144
+ clearCommand,
145
+ )
146
+ this.commandHandler.register(
147
+ 'remind',
148
+ 'Set a one-shot reminder: /remind in 15m Check oven',
149
+ remindCommand,
150
+ )
151
+ this.commandHandler.register(
152
+ 'cron',
153
+ 'Manage recurring jobs: /cron list|add|remove|enable|disable',
154
+ cronCommand,
155
+ )
156
+ this.commandHandler.register('reset', 'Reset session (alias for /clear)', clearCommand)
157
+ this.commandHandler.register(
158
+ 'whoami',
159
+ 'Show your identity and current channel info',
160
+ whoamiCommand,
161
+ )
162
+ this.commandHandler.register('ping', 'Latency check and daemon uptime', pingCommand)
163
+ this.commandHandler.register(
164
+ 'compact',
165
+ 'Force context compaction (clears session)',
166
+ compactCommand,
167
+ )
168
+ this.commandHandler.register('config', 'Show active configuration: /config [show]', configCommand)
169
+ this.commandHandler.register('usage', 'Show session usage and account quota', usageCommand)
170
+ this.commandHandler.register('model', 'View or change model: /model [alias|id|clear]', modelCommand)
171
+ this.commandHandler.register('think', 'Set thinking level: /think [off|low|medium|high]', thinkCommand)
172
+ this.commandHandler.register('stop', 'Cancel the currently running query', stopCommand)
173
+ }
174
+
175
+ /** Route an incoming message to the appropriate agent and send the response */
176
+ async route(channelId: string, message: IncomingMessage, options?: RouteOptions): Promise<void> {
177
+ // Auth check
178
+ if (!this.isAllowed(message.sender.id)) {
179
+ log.warn({ sender: message.sender.id }, 'Unauthorized message, ignoring')
180
+ return
181
+ }
182
+
183
+ const channel = this.registry.getChannel(channelId)
184
+ if (!channel) {
185
+ log.error({ channelId }, 'Channel not found in registry')
186
+ return
187
+ }
188
+
189
+ // Resolve channel persona and moon
190
+ // Try platform-qualified key first, then bare ID as fallback
191
+ const channelPersona =
192
+ this.channelIndex.get(`${channelId}:${message.channelId}`) ??
193
+ this.channelIndex.get(message.channelId)
194
+
195
+ // Priority: explicit RouteOptions.agentId > channel persona agentId > default
196
+ const resolvedAgentId = options?.agentId ?? channelPersona?.agentId
197
+ const agent = this.registry.getAgent(resolvedAgentId)
198
+ if (!agent) {
199
+ log.error({ agentId: resolvedAgentId, channel: channelPersona?.name }, 'No agent available')
200
+ return
201
+ }
202
+ const moon = this.moonLoader.resolve(channelPersona)
203
+
204
+ // Include agent ID in session key to prevent cross-agent session pollution.
205
+ // When Deimos and Hermes both handle different channels, their sessions
206
+ // must not interfere even if they share a channel adapter.
207
+ const sessionKey = `${agent.id}:${channelId}:${message.channelId}`
208
+ const session = this.sessions.get(sessionKey)
209
+
210
+ // ─── Command interception (before system prompt — zero token cost) ───
211
+ if (this.commandHandler.isCommand(message.text)) {
212
+ const ctx: CommandContext = {
213
+ sessionKey,
214
+ message,
215
+ tracker: this.sessionTracker,
216
+ moon,
217
+ channelPersona,
218
+ memory: this.memory,
219
+ daemonStartedAt: this.daemonStartedAt,
220
+ agent: { id: agent.id, name: agent.name },
221
+ config: this.config,
222
+ updateSessionMeta: (updates) => {
223
+ const existing = this.sessions.getMetadata(sessionKey) ?? {}
224
+ const merged = { ...existing, ...updates }
225
+ this.sessions.updateMetadata(sessionKey, JSON.stringify(merged))
226
+ },
227
+ getSessionMeta: () => this.sessions.getMetadata(sessionKey),
228
+ abortAgent: () => agent.abort?.(),
229
+ clearSession: (opts) => this.performClearSession(sessionKey, opts?.skipHooks),
230
+ scheduler: this.scheduler,
231
+ }
232
+
233
+ const response = await this.commandHandler.handle(message.text, ctx)
234
+ if (response) {
235
+ const outgoing: OutgoingMessage = { text: this.redact(response), replyTo: message.id }
236
+ await channel.send(message.channelId, outgoing)
237
+ log.info({ command: message.text.split(/\s+/)[0] }, 'Command handled')
238
+ return
239
+ }
240
+ }
241
+
242
+ // ─── Input sanitization ────────────────────────────────────────────
243
+ const sanitized = this.sanitize(message.text)
244
+ if (sanitized.wasSanitized) {
245
+ log.warn(
246
+ {
247
+ stripped: sanitized.stripped,
248
+ suspicious: sanitized.suspicious,
249
+ sender: message.sender.id,
250
+ channel: message.channelId,
251
+ },
252
+ 'Input sanitized — potential prompt injection detected',
253
+ )
254
+ }
255
+
256
+ // ─── STT: transcribe incoming audio ────────────────────────────────
257
+ let effectiveText = sanitized.text
258
+ let hasIncomingAudio = false
259
+
260
+ const sttProvider = this.registry.getSTT()
261
+ if (sttProvider && this.voiceConfig?.stt?.mode !== 'never') {
262
+ const audioAttachments = message.attachments?.filter((a) => a.type === 'audio') ?? []
263
+
264
+ if (audioAttachments.length > 0) {
265
+ hasIncomingAudio = true
266
+
267
+ for (const audio of audioAttachments) {
268
+ try {
269
+ const audioBuffer = await this.downloadAttachment(audio.url)
270
+ const result = await sttProvider.transcribe(audioBuffer, {
271
+ language: this.voiceConfig?.stt?.language,
272
+ })
273
+
274
+ if (result.text.trim()) {
275
+ // Sanitize transcribed text too (voice injection is possible)
276
+ const sttSanitized = this.sanitize(result.text)
277
+ if (sttSanitized.wasSanitized) {
278
+ log.warn(
279
+ { stripped: sttSanitized.stripped, suspicious: sttSanitized.suspicious },
280
+ 'STT transcription sanitized',
281
+ )
282
+ }
283
+
284
+ const cleanTranscription = sttSanitized.text
285
+ effectiveText = effectiveText
286
+ ? `[Voice transcription]: ${cleanTranscription}\n\n${effectiveText}`
287
+ : cleanTranscription
288
+
289
+ log.info(
290
+ {
291
+ chars: result.text.length,
292
+ language: result.language,
293
+ durationMs: result.durationMs,
294
+ },
295
+ 'Audio transcribed via STT',
296
+ )
297
+ }
298
+ } catch (err) {
299
+ const errMsg = err instanceof Error ? err.message : String(err)
300
+ log.warn({ error: errMsg }, 'STT transcription failed — continuing with text only')
301
+ }
302
+ }
303
+ }
304
+ }
305
+
306
+ // Build system prompt from moon + channel persona
307
+ let systemPrompt = moon ? this.moonLoader.buildSystemPrompt(moon, channelPersona) : undefined
308
+
309
+ // Memory: inject instructions (user file or provider default)
310
+ if (this.memoryInstructions) {
311
+ systemPrompt = systemPrompt
312
+ ? `${systemPrompt}\n\n---\n\n${this.memoryInstructions}`
313
+ : this.memoryInstructions
314
+ }
315
+
316
+ // Auto-recall: inject relevant memories into system prompt
317
+ if (this.memory && this.recallConfig?.enabled) {
318
+ const memoryContext = await this.recallContext(message.text)
319
+ if (memoryContext) {
320
+ systemPrompt = systemPrompt ? `${systemPrompt}\n\n${memoryContext}` : memoryContext
321
+ }
322
+ }
323
+
324
+ // Read session metadata for per-session overrides (/model, /think commands)
325
+ const sessionMeta = this.sessions.getMetadata(sessionKey)
326
+ const sessionModelOverride = sessionMeta?.modelOverride as string | undefined
327
+ const sessionThinking = sessionMeta?.thinkingLevel as string | undefined
328
+
329
+ // Build agent input — session override > channel-level model > moon-level model > global agent config
330
+ const input: AgentInput = {
331
+ prompt: effectiveText,
332
+ sessionId: session?.agentSessionId,
333
+ systemPrompt,
334
+ model: sessionModelOverride ?? channelPersona?.model ?? moon?.model,
335
+ context1m: moon?.context1m,
336
+ thinking: sessionThinking,
337
+ metadata: {
338
+ channelId: message.channelId,
339
+ channelName: channelPersona?.name,
340
+ moonName: moon?.name,
341
+ sender: message.sender,
342
+ },
343
+ }
344
+
345
+ log.debug(
346
+ {
347
+ sessionKey,
348
+ hasSession: !!session,
349
+ moon: moon?.name,
350
+ channel: channelPersona?.name,
351
+ },
352
+ 'Routing message',
353
+ )
354
+
355
+ // Start typing
356
+ const typingInterval = this.startTyping(channel, message.channelId)
357
+
358
+ try {
359
+ // Collect response from agent
360
+ let fullText = ''
361
+ let newSessionId: string | undefined
362
+
363
+ for await (const event of agent.query(input)) {
364
+ switch (event.type) {
365
+ case 'text':
366
+ fullText += event.content
367
+ break
368
+ case 'done':
369
+ newSessionId = event.sessionId
370
+ if (event.usage) {
371
+ this.sessionTracker.recordQuery(sessionKey, event.usage)
372
+ log.info(
373
+ {
374
+ model: event.usage.model,
375
+ tokens: event.usage.inputTokens + event.usage.outputTokens,
376
+ cacheRead: event.usage.cacheReadTokens,
377
+ cacheWrite: event.usage.cacheWriteTokens,
378
+ durationMs: event.usage.durationMs,
379
+ moon: moon?.name,
380
+ },
381
+ 'Agent query complete',
382
+ )
383
+ }
384
+ break
385
+ case 'error':
386
+ log.error({ error: event.error, recoverable: event.recoverable }, 'Agent error')
387
+ if (!event.recoverable) {
388
+ fullText = '⚠️ An error occurred. Please try again.'
389
+ }
390
+ break
391
+ }
392
+ }
393
+
394
+ // Stop typing
395
+ clearInterval(typingInterval)
396
+
397
+ // Send response (with output redaction + optional TTS)
398
+ if (fullText.trim()) {
399
+ const redactedText = this.redact(fullText.trim())
400
+
401
+ // ─── TTS: synthesize response if conditions met ──────────────────
402
+ let audioAttachment: import('../types/channel').OutgoingAttachment | undefined
403
+ const ttsProvider = this.registry.getTTS()
404
+ const ttsMode = this.voiceConfig?.tts?.mode ?? 'auto'
405
+ const shouldSpeak =
406
+ ttsProvider && (ttsMode === 'always' || (ttsMode === 'auto' && hasIncomingAudio))
407
+
408
+ if (shouldSpeak) {
409
+ try {
410
+ // Cap text length to avoid excessive API cost / timeouts
411
+ const maxChars = 4000
412
+ const textToSpeak =
413
+ redactedText.length > maxChars ? redactedText.slice(0, maxChars) : redactedText
414
+
415
+ const ttsResult = await ttsProvider.synthesize(textToSpeak, {
416
+ language: this.voiceConfig?.tts?.language,
417
+ speed: this.voiceConfig?.tts?.speed,
418
+ })
419
+
420
+ audioAttachment = {
421
+ type: 'audio' as const,
422
+ data: ttsResult.audio,
423
+ filename: `voice.${ttsResult.format}`,
424
+ mimeType: `audio/${ttsResult.format}`,
425
+ }
426
+
427
+ log.info(
428
+ {
429
+ format: ttsResult.format,
430
+ bytes: ttsResult.audio.length,
431
+ durationMs: ttsResult.durationMs,
432
+ },
433
+ 'TTS audio synthesized',
434
+ )
435
+ } catch (err) {
436
+ const errMsg = err instanceof Error ? err.message : String(err)
437
+ log.warn({ error: errMsg }, 'TTS synthesis failed — sending text only')
438
+ }
439
+ }
440
+
441
+ const outgoing: OutgoingMessage = {
442
+ text: redactedText,
443
+ replyTo: message.id,
444
+ attachments: audioAttachment ? [audioAttachment] : undefined,
445
+ }
446
+ await channel.send(message.channelId, outgoing)
447
+ }
448
+
449
+ // Track message history for graceful session close
450
+ this.sessionTracker.recordMessage(sessionKey)
451
+ this.trackMessage(sessionKey, 'user', message.text)
452
+ if (fullText.trim()) {
453
+ this.trackMessage(sessionKey, 'assistant', fullText.trim())
454
+ }
455
+
456
+ // Update session
457
+ if (newSessionId) {
458
+ this.sessions.set(sessionKey, newSessionId)
459
+ } else {
460
+ this.sessions.touch(sessionKey)
461
+ }
462
+ } catch (error) {
463
+ clearInterval(typingInterval)
464
+ log.error({ err: error }, 'Router error')
465
+
466
+ await channel.send(message.channelId, {
467
+ text: '⚠️ Something went wrong. Please try again.',
468
+ replyTo: message.id,
469
+ })
470
+ }
471
+ }
472
+
473
+ // ─── Session lifecycle ─────────────────────────────────────────────
474
+
475
+ /**
476
+ * Clear a single session: run hooks (optional), delete Claude session,
477
+ * reset tracker, clear message history.
478
+ *
479
+ * Called by /clear command. The next agent query will start a fresh
480
+ * Claude Code session (no --resume flag).
481
+ */
482
+ private async performClearSession(sessionKey: string, skipHooks = false): Promise<ClearResult> {
483
+ let hooksRan = false
484
+
485
+ // Run memory lifecycle hooks before clearing (graceful clear)
486
+ if (!skipHooks && this.memory?.onSessionEnd) {
487
+ const messages = this.sessionMessages.get(sessionKey) ?? []
488
+ if (messages.length > 0) {
489
+ try {
490
+ const orchestrator = this.memory as unknown as MemoryOrchestrator
491
+ const clearContext: Record<string, unknown> = {
492
+ messages,
493
+ topics: this.extractTopics(messages),
494
+ }
495
+ if (orchestrator?.episodeId) clearContext.episodeId = orchestrator.episodeId
496
+ if (orchestrator?.sessionDate) clearContext.sessionDate = orchestrator.sessionDate
497
+
498
+ await this.memory.onSessionEnd(
499
+ clearContext as Parameters<NonNullable<MemoryProvider['onSessionEnd']>>[0],
500
+ )
501
+ hooksRan = true
502
+ log.info({ sessionKey, messageCount: messages.length }, 'Memory hooks ran before clear')
503
+ } catch (err) {
504
+ const errMsg = err instanceof Error ? err.message : String(err)
505
+ log.warn({ sessionKey, error: errMsg }, 'Memory hooks failed during /clear')
506
+ }
507
+ }
508
+ }
509
+
510
+ // Delete Claude session from store → next query won't use --resume
511
+ this.sessions.delete(sessionKey)
512
+
513
+ // Reset usage counters
514
+ this.sessionTracker.clear(sessionKey)
515
+
516
+ // Clear message history
517
+ this.sessionMessages.delete(sessionKey)
518
+
519
+ log.info({ sessionKey, hooksRan, skipHooks }, 'Session cleared via /clear')
520
+
521
+ return { sessionCleared: true, hooksRan }
522
+ }
523
+
524
+ // ─── Scheduler dispatch ─────────────────────────────────────────────
525
+
526
+ /**
527
+ * Dispatch a scheduled job — bypasses auth, uses target channel context.
528
+ *
529
+ * For 'query' payloads: resolves moon + channel persona, builds system prompt,
530
+ * does memory auto-recall, queries the agent, and sends the response.
531
+ *
532
+ * For 'message' payloads: sends the text directly to the channel (no agent).
533
+ *
534
+ * @param channelName - Channel name from config.yaml (e.g., "orbit")
535
+ * @param payload - Job payload (query or message)
536
+ * @param options - Optional moon override, job metadata
537
+ */
538
+ async dispatchScheduled(
539
+ channelName: string,
540
+ payload: JobPayload,
541
+ options?: { moon?: string; jobId?: string; jobName?: string },
542
+ ): Promise<void> {
543
+ // Find the channel persona by name
544
+ const channelPersona = [...this.channelIndex.values()].find((c) => c.name === channelName)
545
+
546
+ if (!channelPersona) {
547
+ const available = [...this.channelIndex.values()].map((c) => c.name)
548
+ log.error({ channelName, available }, 'Scheduler: channel not found in config')
549
+ return
550
+ }
551
+
552
+ // Find the channel adapter
553
+ const channel = this.registry.getChannel(channelPersona.platform ?? 'discord')
554
+ if (!channel) {
555
+ log.error({ platform: channelPersona.platform }, 'Scheduler: channel adapter not found')
556
+ return
557
+ }
558
+
559
+ // Handle static message — no agent query needed
560
+ if (payload.kind === 'message') {
561
+ await channel.send(channelPersona.id, { text: payload.text })
562
+ log.info(
563
+ { channel: channelName, jobId: options?.jobId, jobName: options?.jobName },
564
+ 'Scheduler: static message sent',
565
+ )
566
+ return
567
+ }
568
+
569
+ // Handle agent query
570
+ if (payload.kind === 'query') {
571
+ // Priority: channelPersona.agentId > default agent
572
+ const resolvedAgentId = channelPersona?.agentId
573
+ const agent = this.registry.getAgent(resolvedAgentId)
574
+ if (!agent) {
575
+ log.error(
576
+ { agentId: resolvedAgentId, channel: channelName },
577
+ 'Scheduler: no agent available for query dispatch',
578
+ )
579
+ return
580
+ }
581
+
582
+ // Resolve moon (override from job, or channel default)
583
+ const moon = options?.moon
584
+ ? (this.moonLoader.get(options.moon) ?? this.moonLoader.resolve(channelPersona))
585
+ : this.moonLoader.resolve(channelPersona)
586
+
587
+ // Build system prompt (same as normal route flow)
588
+ let systemPrompt = moon ? this.moonLoader.buildSystemPrompt(moon, channelPersona) : undefined
589
+
590
+ if (this.memoryInstructions) {
591
+ systemPrompt = systemPrompt
592
+ ? `${systemPrompt}\n\n---\n\n${this.memoryInstructions}`
593
+ : this.memoryInstructions
594
+ }
595
+
596
+ // Auto-recall for the scheduled prompt
597
+ if (this.memory && this.recallConfig?.enabled) {
598
+ const memoryContext = await this.recallContext(payload.prompt)
599
+ if (memoryContext) {
600
+ systemPrompt = systemPrompt ? `${systemPrompt}\n\n${memoryContext}` : memoryContext
601
+ }
602
+ }
603
+
604
+ // Session key for scheduled queries — includes agent ID for namespace isolation
605
+ const sessionKey = `${agent.id}:discord:${channelPersona.id}`
606
+ const session = this.sessions.get(sessionKey)
607
+
608
+ const input: AgentInput = {
609
+ prompt: payload.prompt,
610
+ sessionId: session?.agentSessionId,
611
+ systemPrompt,
612
+ model: moon?.model,
613
+ context1m: moon?.context1m,
614
+ metadata: {
615
+ channelId: channelPersona.id,
616
+ channelName: channelPersona.name,
617
+ moonName: moon?.name,
618
+ sender: { id: 'scheduler', name: 'Scheduler' },
619
+ scheduled: true,
620
+ jobId: options?.jobId,
621
+ jobName: options?.jobName,
622
+ },
623
+ }
624
+
625
+ // Start typing indicator
626
+ const typingInterval = this.startTyping(channel, channelPersona.id)
627
+
628
+ try {
629
+ let fullText = ''
630
+ let newSessionId: string | undefined
631
+
632
+ for await (const event of agent.query(input)) {
633
+ switch (event.type) {
634
+ case 'text':
635
+ fullText += event.content
636
+ break
637
+ case 'done':
638
+ newSessionId = event.sessionId
639
+ if (event.usage) {
640
+ this.sessionTracker.recordQuery(sessionKey, event.usage)
641
+ }
642
+ break
643
+ case 'error':
644
+ log.error({ error: event.error }, 'Scheduler: agent error')
645
+ if (!event.recoverable) {
646
+ fullText = ''
647
+ }
648
+ break
649
+ }
650
+ }
651
+
652
+ clearInterval(typingInterval)
653
+
654
+ if (fullText.trim()) {
655
+ const redactedText = this.redact(fullText.trim())
656
+ await channel.send(channelPersona.id, { text: redactedText })
657
+
658
+ // Track for session continuity
659
+ this.trackMessage(sessionKey, 'user', payload.prompt)
660
+ this.trackMessage(sessionKey, 'assistant', fullText.trim())
661
+ }
662
+
663
+ if (newSessionId) {
664
+ this.sessions.set(sessionKey, newSessionId)
665
+ }
666
+
667
+ log.info(
668
+ { channel: channelName, jobId: options?.jobId, jobName: options?.jobName },
669
+ 'Scheduler: query dispatched and response sent',
670
+ )
671
+ } catch (error) {
672
+ clearInterval(typingInterval)
673
+ log.error({ err: error, jobId: options?.jobId }, 'Scheduler: dispatch error')
674
+ }
675
+ }
676
+ }
677
+
678
+ /**
679
+ * Gracefully close all active sessions — triggers memory lifecycle hooks.
680
+ *
681
+ * Called by Daemon.stop() on SIGINT/SIGTERM. For each session that has
682
+ * message history, calls memory.onSessionEnd() which triggers:
683
+ * - sessionEnd hooks (e.g., summarize)
684
+ * - Provider-specific cleanup (Engram: session summary, Brain: extraction)
685
+ * - afterSessionEnd hooks (e.g., promote Engram → Brain)
686
+ *
687
+ * After closing, message history is cleared.
688
+ */
689
+ async closeAllSessions(): Promise<void> {
690
+ if (!this.memory || this.sessionMessages.size === 0) return
691
+
692
+ const sessionCount = this.sessionMessages.size
693
+ log.info({ sessions: sessionCount }, 'Closing active sessions (graceful shutdown)')
694
+
695
+ const closePromises: Promise<void>[] = []
696
+
697
+ for (const [sessionKey, messages] of this.sessionMessages) {
698
+ if (messages.length === 0) continue
699
+
700
+ closePromises.push(
701
+ (async () => {
702
+ try {
703
+ // Pass episodeId from orchestrator if available
704
+ const orchestrator = this.memory as unknown as MemoryOrchestrator
705
+ const sessionContext: Record<string, unknown> = {
706
+ messages,
707
+ topics: this.extractTopics(messages),
708
+ }
709
+ if (orchestrator?.episodeId) sessionContext.episodeId = orchestrator.episodeId
710
+ if (orchestrator?.sessionDate) sessionContext.sessionDate = orchestrator.sessionDate
711
+
712
+ await this.memory!.onSessionEnd!(
713
+ sessionContext as Parameters<NonNullable<MemoryProvider['onSessionEnd']>>[0],
714
+ )
715
+ log.info({ sessionKey, messageCount: messages.length }, 'Session closed gracefully')
716
+ } catch (err) {
717
+ const errMsg = err instanceof Error ? err.message : String(err)
718
+ log.warn(
719
+ { sessionKey, error: errMsg },
720
+ 'Failed to close session — hooks may not have run',
721
+ )
722
+ }
723
+ })(),
724
+ )
725
+ }
726
+
727
+ await Promise.allSettled(closePromises)
728
+ this.sessionMessages.clear()
729
+ log.info({ sessions: sessionCount }, 'All sessions closed')
730
+ }
731
+
732
+ /**
733
+ * Get message count for a session (for testing/diagnostics).
734
+ */
735
+ getSessionMessageCount(sessionKey: string): number {
736
+ return this.sessionMessages.get(sessionKey)?.length ?? 0
737
+ }
738
+
739
+ /**
740
+ * Get total number of active sessions with message history.
741
+ */
742
+ get activeSessionCount(): number {
743
+ return this.sessionMessages.size
744
+ }
745
+
746
+ // ─── Private ───────────────────────────────────────────────────────
747
+
748
+ /**
749
+ * Track a message in session history (capped at MAX_SESSION_HISTORY).
750
+ */
751
+ private trackMessage(sessionKey: string, role: string, content: string): void {
752
+ let history = this.sessionMessages.get(sessionKey)
753
+ if (!history) {
754
+ history = []
755
+ this.sessionMessages.set(sessionKey, history)
756
+ }
757
+
758
+ history.push({ role, content })
759
+
760
+ // Cap to prevent unbounded memory growth
761
+ if (history.length > Router.MAX_SESSION_HISTORY) {
762
+ // Remove oldest messages (keep the most recent)
763
+ const excess = history.length - Router.MAX_SESSION_HISTORY
764
+ history.splice(0, excess)
765
+ }
766
+ }
767
+
768
+ /**
769
+ * Extract simple topic keywords from messages.
770
+ * Lightweight extraction — no LLM needed, just frequent nouns/phrases.
771
+ */
772
+ private extractTopics(messages: Array<{ role: string; content: string }>): string[] {
773
+ // Combine all user messages
774
+ const text = messages
775
+ .filter((m) => m.role === 'user')
776
+ .map((m) => m.content)
777
+ .join(' ')
778
+ .toLowerCase()
779
+
780
+ // Simple word frequency (skip common words)
781
+ const stopWords = new Set([
782
+ 'the',
783
+ 'a',
784
+ 'an',
785
+ 'is',
786
+ 'are',
787
+ 'was',
788
+ 'were',
789
+ 'be',
790
+ 'been',
791
+ 'being',
792
+ 'have',
793
+ 'has',
794
+ 'had',
795
+ 'do',
796
+ 'does',
797
+ 'did',
798
+ 'will',
799
+ 'would',
800
+ 'could',
801
+ 'should',
802
+ 'may',
803
+ 'might',
804
+ 'shall',
805
+ 'can',
806
+ 'need',
807
+ 'dare',
808
+ 'ought',
809
+ 'used',
810
+ 'to',
811
+ 'of',
812
+ 'in',
813
+ 'for',
814
+ 'on',
815
+ 'with',
816
+ 'at',
817
+ 'by',
818
+ 'from',
819
+ 'as',
820
+ 'into',
821
+ 'through',
822
+ 'during',
823
+ 'before',
824
+ 'after',
825
+ 'above',
826
+ 'below',
827
+ 'between',
828
+ 'out',
829
+ 'off',
830
+ 'over',
831
+ 'under',
832
+ 'again',
833
+ 'further',
834
+ 'then',
835
+ 'once',
836
+ 'and',
837
+ 'but',
838
+ 'or',
839
+ 'nor',
840
+ 'not',
841
+ 'so',
842
+ 'yet',
843
+ 'both',
844
+ 'either',
845
+ 'neither',
846
+ 'each',
847
+ 'every',
848
+ 'all',
849
+ 'any',
850
+ 'few',
851
+ 'more',
852
+ 'most',
853
+ 'other',
854
+ 'some',
855
+ 'such',
856
+ 'no',
857
+ 'only',
858
+ 'own',
859
+ 'same',
860
+ 'than',
861
+ 'too',
862
+ 'very',
863
+ 'just',
864
+ 'because',
865
+ 'if',
866
+ 'when',
867
+ 'where',
868
+ 'how',
869
+ 'what',
870
+ 'which',
871
+ 'who',
872
+ 'whom',
873
+ 'this',
874
+ 'that',
875
+ 'these',
876
+ 'those',
877
+ 'i',
878
+ 'me',
879
+ 'my',
880
+ 'myself',
881
+ 'we',
882
+ 'our',
883
+ 'you',
884
+ 'your',
885
+ 'he',
886
+ 'him',
887
+ 'she',
888
+ 'her',
889
+ 'it',
890
+ 'its',
891
+ 'they',
892
+ 'them',
893
+ 'their',
894
+ 'que',
895
+ 'de',
896
+ 'la',
897
+ 'el',
898
+ 'en',
899
+ 'y',
900
+ 'un',
901
+ 'una',
902
+ 'los',
903
+ 'las',
904
+ 'es',
905
+ 'se',
906
+ 'del',
907
+ 'lo',
908
+ 'con',
909
+ 'no',
910
+ 'por',
911
+ 'para',
912
+ 'al',
913
+ 'como',
914
+ 'más',
915
+ 'pero',
916
+ 'sus',
917
+ 'le',
918
+ 'ya',
919
+ 'o',
920
+ 'este',
921
+ 'si',
922
+ 'porque',
923
+ 'esta',
924
+ 'entre',
925
+ 'cuando',
926
+ 'muy',
927
+ 'sin',
928
+ 'sobre',
929
+ 'también',
930
+ 'me',
931
+ 'hasta',
932
+ 'hay',
933
+ 'donde',
934
+ 'quien',
935
+ ])
936
+
937
+ const words = text
938
+ .replace(/[^a-záéíóúñü\s]/g, '')
939
+ .split(/\s+/)
940
+ .filter((w) => w.length > 3)
941
+ const freq = new Map<string, number>()
942
+ for (const word of words) {
943
+ if (stopWords.has(word)) continue
944
+ freq.set(word, (freq.get(word) ?? 0) + 1)
945
+ }
946
+
947
+ // Top 5 most frequent non-stop words
948
+ return [...freq.entries()]
949
+ .sort((a, b) => b[1] - a[1])
950
+ .slice(0, 5)
951
+ .map(([word]) => word)
952
+ }
953
+
954
+ /**
955
+ * Load memory instructions for the agent.
956
+ *
957
+ * Priority:
958
+ * 1. config.yaml memory.instructionsFile (explicit path)
959
+ * 2. prompts/{provider}.md in workspace (convention)
960
+ * 3. Provider's built-in agentInstructions() (fallback)
961
+ */
962
+ private loadMemoryInstructions(): string | undefined {
963
+ if (!this.memory) return undefined
964
+
965
+ const instructions: string[] = []
966
+
967
+ // v2: Check each provider's instructions field
968
+ if (this.memoryConfig?.providers && this.workspacePath) {
969
+ for (const [name, prov] of Object.entries(this.memoryConfig.providers)) {
970
+ // 1. Explicit instructions file from provider config
971
+ if (prov.instructions) {
972
+ const explicitPath = resolve(this.workspacePath, prov.instructions)
973
+ if (existsSync(explicitPath)) {
974
+ const content = readFileSync(explicitPath, 'utf-8').trim()
975
+ if (content) {
976
+ log.info(
977
+ { provider: name, path: explicitPath },
978
+ 'Memory instructions loaded from provider config',
979
+ )
980
+ instructions.push(content)
981
+ continue
982
+ }
983
+ } else {
984
+ log.warn({ provider: name, path: explicitPath }, 'Provider instructions file not found')
985
+ }
986
+ }
987
+
988
+ // 2. Convention: prompts/{name}.md
989
+ const conventionPath = resolve(this.workspacePath, 'prompts', `${name}.md`)
990
+ if (existsSync(conventionPath)) {
991
+ const content = readFileSync(conventionPath, 'utf-8').trim()
992
+ if (content) {
993
+ log.info(
994
+ { provider: name, path: conventionPath },
995
+ 'Memory instructions loaded from prompts/',
996
+ )
997
+ instructions.push(content)
998
+ }
999
+ }
1000
+ }
1001
+ }
1002
+
1003
+ if (instructions.length > 0) {
1004
+ return instructions.join('\n\n---\n\n')
1005
+ }
1006
+
1007
+ // 3. Provider built-in default
1008
+ if (this.memory.agentInstructions) {
1009
+ log.debug('Using provider built-in memory instructions')
1010
+ return this.memory.agentInstructions()
1011
+ }
1012
+
1013
+ return undefined
1014
+ }
1015
+
1016
+ /**
1017
+ * Recall relevant memories for the user's message.
1018
+ * Returns formatted context string or undefined if no relevant results.
1019
+ */
1020
+ private async recallContext(query: string): Promise<string | undefined> {
1021
+ if (!this.memory || !this.recallConfig) return undefined
1022
+
1023
+ const minScore = this.recallConfig.minScore
1024
+ const budget = this.recallConfig.budget
1025
+
1026
+ try {
1027
+ const results = await this.memory.recall(query, {
1028
+ limit: this.recallConfig.limit,
1029
+ minScore,
1030
+ })
1031
+
1032
+ if (results.length === 0) return undefined
1033
+
1034
+ // Split: diary entries (always included) vs semantic results (budget-limited)
1035
+ const diaryResults = results.filter((r) => r.source === 'brain:diary')
1036
+ const semanticResults = results.filter((r) => r.source !== 'brain:diary')
1037
+
1038
+ // 1. Always include all diary entries (they're already capped at source)
1039
+ const diaryItems: string[] = []
1040
+ let diaryChars = 0
1041
+ for (const r of diaryResults) {
1042
+ const meta = r.metadata
1043
+ const prefix = meta?.category ? `[${meta.category}]` : '[diary]'
1044
+ const line = `- ${prefix} ${r.content}`
1045
+ diaryItems.push(line)
1046
+ diaryChars += line.length
1047
+ }
1048
+
1049
+ // 2. Fill remaining budget with semantic results
1050
+ const budgetChars = budget * 4
1051
+ const remainingBudget =
1052
+ budget === Infinity ? Infinity : Math.max(budgetChars - diaryChars, budgetChars * 0.3)
1053
+ const semanticItems: string[] = []
1054
+ let semanticChars = 0
1055
+
1056
+ for (const r of semanticResults) {
1057
+ const score = `${(r.score * 100).toFixed(0)}%`
1058
+ const meta = r.metadata
1059
+ const prefix = meta?.type ? `[${meta.type}]` : ''
1060
+ const line = `- ${prefix} ${r.content} (relevance: ${score})`
1061
+
1062
+ if (remainingBudget !== Infinity && semanticChars + line.length > remainingBudget) {
1063
+ log.debug(
1064
+ {
1065
+ budget,
1066
+ diaryChars,
1067
+ semanticChars,
1068
+ dropped: semanticResults.length - semanticItems.length,
1069
+ },
1070
+ 'Semantic context truncated by budget (diary preserved)',
1071
+ )
1072
+ break
1073
+ }
1074
+
1075
+ semanticItems.push(line)
1076
+ semanticChars += line.length
1077
+ }
1078
+
1079
+ // Combine: diary section + semantic section
1080
+ const sections: string[] = []
1081
+ if (diaryItems.length > 0) {
1082
+ sections.push(`### Session Diary (today)\n${diaryItems.join('\n')}`)
1083
+ }
1084
+ if (semanticItems.length > 0) {
1085
+ sections.push(`### Relevant Memories\n${semanticItems.join('\n')}`)
1086
+ }
1087
+
1088
+ if (sections.length === 0) return undefined
1089
+
1090
+ const totalChars = diaryChars + semanticChars
1091
+ log.debug(
1092
+ {
1093
+ diary: diaryItems.length,
1094
+ semantic: semanticItems.length,
1095
+ totalResults: results.length,
1096
+ budgetUsed: totalChars,
1097
+ },
1098
+ 'Auto-recall injected',
1099
+ )
1100
+
1101
+ return `## Memory Context (auto-recalled)\n${sections.join('\n\n')}`
1102
+ } catch (err) {
1103
+ log.warn({ err }, 'Auto-recall failed, continuing without memory context')
1104
+ return undefined
1105
+ }
1106
+ }
1107
+
1108
+ private isAllowed(senderId: string): boolean {
1109
+ if (!this.config.discordAllowedUsers?.length) return true
1110
+ return this.config.discordAllowedUsers.includes(senderId)
1111
+ }
1112
+
1113
+ private startTyping(
1114
+ channel: { sendTyping(target: string): Promise<void> },
1115
+ target: string,
1116
+ ): ReturnType<typeof setInterval> {
1117
+ // Send typing immediately, then refresh every 8s (Discord expires at 10s)
1118
+ channel.sendTyping(target).catch(() => {})
1119
+ return setInterval(() => {
1120
+ channel.sendTyping(target).catch(() => {})
1121
+ }, 8000)
1122
+ }
1123
+ /**
1124
+ * Download an audio attachment from URL into a Buffer.
1125
+ * Used by STT pipeline to fetch voice messages before transcription.
1126
+ */
1127
+ private async downloadAttachment(url: string): Promise<Buffer> {
1128
+ const response = await fetch(url)
1129
+ if (!response.ok) {
1130
+ throw new Error(`Failed to download attachment: HTTP ${response.status}`)
1131
+ }
1132
+ const arrayBuffer = await response.arrayBuffer()
1133
+ return Buffer.from(arrayBuffer)
1134
+ }
1135
+ }