@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,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
|
+
}
|