@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,1270 @@
1
+ /**
2
+ * HookRunner — Evaluates conditions and executes lifecycle hook actions.
3
+ *
4
+ * Design:
5
+ * - Best-effort: hook failures are logged but never block the main flow
6
+ * - Declared order: hooks execute top-to-bottom per lifecycle event
7
+ * - Guard-first: conditions checked before execution (fail = silent skip)
8
+ * - Mutable context: actions modify results, facts, summaries in-place
9
+ * - Extensible: new actions are functions in the ACTION_REGISTRY
10
+ *
11
+ * Built-in actions (v1):
12
+ * promote — Transfer knowledge from provider A → B (afterSessionEnd, afterSave)
13
+ * boost — Adjust recall scores by recency (afterRecall)
14
+ * context — Pre-load context from a provider (sessionStart)
15
+ * summarize — Save session summary to a provider (sessionEnd)
16
+ *
17
+ * Guards:
18
+ * providersAvailable — Check that named providers are registered (not health-checked for speed)
19
+ * minMessages — Minimum session message count
20
+ */
21
+
22
+ import type { Fact, MemoryProvider, MemoryResult } from '../types/memory'
23
+ import type { HookCondition, HookEntry, HooksConfig, LifecycleEvent } from './config-loader'
24
+ import { log } from './logger'
25
+
26
+ // ════════════════════════════════════════════════════════════
27
+ // Types
28
+ // ════════════════════════════════════════════════════════════
29
+
30
+ /**
31
+ * Function signature for one-shot LLM calls.
32
+ * Used by summarize/promote hooks for intelligent summarization.
33
+ * Provided externally (e.g., CLI wrapper) to keep core agent-agnostic.
34
+ */
35
+ export type LLMFunction = (prompt: string) => Promise<string>
36
+
37
+ /**
38
+ * Mutable context passed through hooks at each lifecycle event.
39
+ * Actions can read and modify any field.
40
+ */
41
+ export interface HookContext {
42
+ /** Current session message count */
43
+ messageCount: number
44
+ /** Session messages (for summarize, promote) */
45
+ messages?: Array<{ role: string; content: string }>
46
+ /** Recall results — mutable (afterRecall boost modifies scores) */
47
+ results?: MemoryResult[]
48
+ /** Search query — mutable (beforeRecall can expand/modify) */
49
+ query?: string
50
+ /** Facts being saved — mutable (beforeSave can classify/route) */
51
+ facts?: Fact[]
52
+ /** Session summary text */
53
+ sessionSummary?: string
54
+ /** Session topics */
55
+ topics?: string[]
56
+ /** Optional LLM function — injected by HookRunner when available */
57
+ llm?: LLMFunction
58
+ /** Episode ID for linking all facts from a session (set by episode hook at sessionStart) */
59
+ episodeId?: string
60
+ /** Session date YYYY-MM-DD (set by episode hook at sessionStart) */
61
+ sessionDate?: string
62
+ /** Channel name (for episode lookup) */
63
+ channel?: string
64
+ /** Fact IDs created during this session (for updating episode at close) */
65
+ createdFactIds?: string[]
66
+ }
67
+
68
+ /**
69
+ * Function signature for action executors.
70
+ * Receives the hook definition, mutable context, and provider map.
71
+ */
72
+ type ActionExecutor = (
73
+ hook: HookEntry,
74
+ context: HookContext,
75
+ providers: ReadonlyMap<string, MemoryProvider>,
76
+ ) => Promise<void>
77
+
78
+ // ════════════════════════════════════════════════════════════
79
+ // Time window parser
80
+ // ════════════════════════════════════════════════════════════
81
+
82
+ const TIME_MULTIPLIERS: Record<string, number> = {
83
+ s: 1_000,
84
+ m: 60_000,
85
+ h: 3_600_000,
86
+ d: 86_400_000,
87
+ w: 604_800_000,
88
+ }
89
+
90
+ /**
91
+ * Parse a human-readable time window to milliseconds.
92
+ * Supports: 30s, 5m, 1h, 24h, 7d, 2w.
93
+ * Returns 24h if format is invalid.
94
+ */
95
+ export function parseTimeWindow(window: string): number {
96
+ const match = window.match(/^(\d+)(s|m|h|d|w)$/)
97
+ if (!match) return TIME_MULTIPLIERS.d // default: 24h
98
+
99
+ const value = parseInt(match[1], 10)
100
+ return value * (TIME_MULTIPLIERS[match[2]] ?? TIME_MULTIPLIERS.d)
101
+ }
102
+
103
+ // ════════════════════════════════════════════════════════════
104
+ // Guard evaluation
105
+ // ════════════════════════════════════════════════════════════
106
+
107
+ /**
108
+ * Evaluate hook guard conditions.
109
+ *
110
+ * Returns true if:
111
+ * - No guard defined (always run)
112
+ * - All conditions are met
113
+ *
114
+ * Returns false if any condition fails (silent skip).
115
+ *
116
+ * Note: `providersAvailable` checks registration only — not health.
117
+ * If a provider is registered but unhealthy, the action itself will fail
118
+ * and be caught by best-effort error handling. This keeps guards fast
119
+ * (no async health calls on the critical path).
120
+ */
121
+ export function evaluateGuard(
122
+ guard: HookCondition | undefined,
123
+ context: HookContext,
124
+ providers: ReadonlyMap<string, MemoryProvider>,
125
+ ): boolean {
126
+ if (!guard) return true
127
+
128
+ // Check providersAvailable — all must be registered
129
+ if (guard.providersAvailable?.length) {
130
+ for (const name of guard.providersAvailable) {
131
+ if (!providers.has(name)) return false
132
+ }
133
+ }
134
+
135
+ // Check minMessages
136
+ if (guard.minMessages !== undefined) {
137
+ if (context.messageCount < guard.minMessages) return false
138
+ }
139
+
140
+ return true
141
+ }
142
+
143
+ // ════════════════════════════════════════════════════════════
144
+ // Constants
145
+ // ════════════════════════════════════════════════════════════
146
+
147
+ /** Maximum transcript characters sent to LLM for summarization (~1000 tokens) */
148
+ const MAX_TRANSCRIPT_CHARS = 4000
149
+
150
+ /**
151
+ * System prompt for LLM-powered session summarization.
152
+ * Designed to produce dense, factual summaries suitable for long-term memory.
153
+ *
154
+ * IMPORTANT: Starts with explicit instruction to NOT use any tools.
155
+ * When run via Claude CLI in print mode, the agent has access to tools
156
+ * (file read, bash, etc.) and will use them for summarization tasks
157
+ * unless explicitly told not to. This wastes turns and produces empty results.
158
+ */
159
+ const SUMMARIZE_PROMPT = `Do not use any tools. Do not read files. Do not run commands. Reply with plain text only.
160
+
161
+ You are a factual session logger. Extract ONLY what happened in the conversation below.
162
+
163
+ Output format: plain text, one fact per line, prefix with category:
164
+ DECISION: <what was decided and why>
165
+ INFO: <key fact, preference, or context shared>
166
+ PROBLEM: <issue encountered> -> <resolution if any>
167
+ PENDING: <unresolved item or next step>
168
+ TECHNICAL: <architecture, code, or config detail>
169
+
170
+ Rules:
171
+ - Report what the participants said and did. Do not add opinions, analysis, or commentary.
172
+ - No markdown formatting (no bold, no italic, no headers, no horizontal rules).
173
+ - Each line must be a complete, standalone fact.
174
+ - Do not include conversational filler (greetings, acknowledgments, rhetorical questions).
175
+ - Do not paraphrase into your own voice. Stick to what was explicitly stated.
176
+ - Match the conversation language.
177
+ - Max 20 lines.
178
+
179
+ Conversation:
180
+ `
181
+
182
+ const JOURNAL_PROMPT = `Do not use any tools. Do not read files. Do not run commands. Reply with plain text only.
183
+
184
+ You are writing a personal journal entry for an AI companion, reflecting on a session with Mars (your human partner).
185
+
186
+ This is YOUR diary — not a report, not data. Write as yourself: what surprised you, what you learned, what you'd do differently, what matters.
187
+
188
+ Rules:
189
+ - First person (I/me). This is your inner voice.
190
+ - Honest and specific. Not generic observations.
191
+ - 3-5 sentences max. Dense, not verbose.
192
+ - No markdown formatting. No headers. No lists.
193
+ - Match the conversation language.
194
+ - If nothing meaningful happened, say that honestly.
195
+
196
+ Conversation:
197
+ `
198
+
199
+ const EXPERIENCE_PROMPT = `Do not use any tools. Do not read files. Do not run commands. Reply with plain text only.
200
+
201
+ You are capturing the emotional tone of a session for an AI companion.
202
+
203
+ Write 1-2 sentences about how this session FELT — the energy, the vibe, the mood. Not what happened (that's the summary), but the color of the experience.
204
+
205
+ Rules:
206
+ - First person.
207
+ - Impressionistic, not analytical. Think "warm and focused" not "productive meeting."
208
+ - No markdown. No lists. 1-2 sentences only.
209
+ - Match the conversation language.
210
+ - If you felt nothing notable, say that.
211
+
212
+ Conversation:
213
+ `
214
+
215
+ // ════════════════════════════════════════════════════════════
216
+ // Built-in actions
217
+ // ════════════════════════════════════════════════════════════
218
+
219
+ /**
220
+ * `promote` — Transfer knowledge from one provider to another.
221
+ *
222
+ * Use case: After session ends, extract facts from Engram (session memory)
223
+ * and save them to Brain (semantic memory).
224
+ *
225
+ * Fields: from, to, via
226
+ * Events: afterSessionEnd, afterSave
227
+ *
228
+ * Strategies (via):
229
+ * 'extraction' — Uses session messages/summary to create facts in target
230
+ * 'full' — Rich summary + individual observations from source (recommended)
231
+ * (default) — Recalls from source and saves to target
232
+ */
233
+ const executePromote: ActionExecutor = async (hook, context, providers) => {
234
+ const { from, to, via } = hook
235
+ if (!from || !to) {
236
+ log.warn({ action: 'promote' }, 'Hook promote: requires "from" and "to" — skipping')
237
+ return
238
+ }
239
+
240
+ const sourceProvider = providers.get(from)
241
+ const targetProvider = providers.get(to)
242
+
243
+ if (!sourceProvider) {
244
+ log.warn({ from, action: 'promote' }, 'Hook promote: source provider not found — skipping')
245
+ return
246
+ }
247
+ if (!targetProvider) {
248
+ log.warn({ to, action: 'promote' }, 'Hook promote: target provider not found — skipping')
249
+ return
250
+ }
251
+
252
+ if (via === 'full') {
253
+ // Full strategy: two sources of individual facts:
254
+ // 1. Parse structured lines from LLM summary (DECISION/INFO/PROBLEM/PENDING/TECHNICAL)
255
+ // 2. Search source provider for observations by topic
256
+ // The session-level summary is NOT saved here (summarize hook handles that).
257
+ const facts: Fact[] = []
258
+ const seenContent = new Set<string>()
259
+
260
+ // Category mapping from LLM prefix to fact category + importance
261
+ const PREFIX_MAP: Record<string, { category: string; importance: number }> = {
262
+ DECISION: { category: 'decision', importance: 7 },
263
+ INFO: { category: 'observation', importance: 5 },
264
+ PROBLEM: { category: 'learning', importance: 6 },
265
+ PENDING: { category: 'pending', importance: 6 },
266
+ TECHNICAL: { category: 'technical', importance: 5 },
267
+ }
268
+
269
+ // 1. Parse LLM summary lines into individual facts
270
+ if (context.sessionSummary) {
271
+ const lines = context.sessionSummary.split('\n').filter((l) => l.trim())
272
+ for (const line of lines) {
273
+ const match = line.match(/^\s*(DECISION|INFO|PROBLEM|PENDING|TECHNICAL):\s*(.+)/i)
274
+ if (!match) continue
275
+
276
+ const prefix = match[1].toUpperCase()
277
+ const factContent = match[2].trim()
278
+ if (!factContent || seenContent.has(factContent)) continue
279
+ seenContent.add(factContent)
280
+
281
+ const mapped = PREFIX_MAP[prefix] ?? { category: 'observation', importance: 5 }
282
+ facts.push({
283
+ content: factContent,
284
+ category: mapped.category,
285
+ domain: 'session',
286
+ source: from,
287
+ tags: ['promoted', `from:${from}`, prefix.toLowerCase()],
288
+ importance: mapped.importance,
289
+ })
290
+ }
291
+ }
292
+
293
+ // 2. Search source provider for additional observations by topic
294
+ const topics = context.topics ?? []
295
+ for (const topic of topics.slice(0, 5)) {
296
+ try {
297
+ const results = await sourceProvider.recall(topic, { limit: 3 })
298
+ for (const r of results) {
299
+ if (seenContent.has(r.content)) continue
300
+ seenContent.add(r.content)
301
+
302
+ facts.push({
303
+ content: r.content,
304
+ category: 'observation',
305
+ domain: 'session',
306
+ source: from,
307
+ tags: ['promoted', `from:${from}`, 'observation'],
308
+ importance: 5,
309
+ })
310
+ }
311
+ } catch (err) {
312
+ const errMsg = err instanceof Error ? err.message : String(err)
313
+ log.debug({ topic, error: errMsg }, 'Hook promote: topic search failed \u2014 continuing')
314
+ }
315
+ }
316
+
317
+ // Cap total promoted facts to avoid flooding target
318
+ const MAX_PROMOTED = 10
319
+ const toSave = facts.slice(0, MAX_PROMOTED)
320
+
321
+ if (toSave.length > 0) {
322
+ await targetProvider.save(toSave)
323
+ }
324
+
325
+ log.info({ from, to, via, facts: toSave.length }, 'Hook promote: full transfer complete')
326
+ } else if (via === 'extraction' && context.messages?.length) {
327
+ // Extraction: use session summary or build from messages
328
+ const summary =
329
+ context.sessionSummary ||
330
+ `Session with ${context.messageCount} messages` +
331
+ (context.topics?.length ? ` covering: ${context.topics.join(', ')}` : '')
332
+
333
+ await targetProvider.save([
334
+ {
335
+ content: summary,
336
+ category: 'session-promotion',
337
+ domain: 'session',
338
+ source: from,
339
+ tags: ['promoted', `from:${from}`],
340
+ importance: 6,
341
+ },
342
+ ])
343
+
344
+ log.info({ from, to, via, contentLength: summary.length }, 'Hook promote: extraction complete')
345
+ } else {
346
+ // Default: recall from source, save to target
347
+ const results = await sourceProvider.recall('session context', { limit: 5 })
348
+ if (results.length > 0) {
349
+ const facts: Fact[] = results.map((r) => ({
350
+ content: r.content,
351
+ category: 'promoted',
352
+ domain: 'session',
353
+ source: from,
354
+ tags: ['promoted', `from:${from}`],
355
+ importance: 5,
356
+ }))
357
+ await targetProvider.save(facts)
358
+ log.info({ from, to, factCount: facts.length }, 'Hook promote: recall-transfer complete')
359
+ } else {
360
+ log.debug({ from, to }, 'Hook promote: no results from source — nothing to transfer')
361
+ }
362
+ }
363
+ }
364
+
365
+ /**
366
+ * `boost` — Adjust recall result scores based on recency.
367
+ *
368
+ * Use case: After recall, boost recent results from a specific provider
369
+ * to prioritize fresh context over old knowledge.
370
+ *
371
+ * Fields: provider (optional filter), recencyWindow (e.g. '24h'), factor (e.g. 1.3)
372
+ * Events: afterRecall
373
+ *
374
+ * Modifies context.results in-place and re-sorts by score.
375
+ */
376
+ const executeBoost: ActionExecutor = async (hook, context, _providers) => {
377
+ if (!context.results?.length) return
378
+
379
+ const { provider, recencyWindow, factor } = hook
380
+ const boostFactor = factor ?? 1.2
381
+ const windowMs = parseTimeWindow(recencyWindow ?? '24h')
382
+ const cutoff = new Date(Date.now() - windowMs)
383
+
384
+ let boosted = 0
385
+ for (const result of context.results) {
386
+ // Filter by provider source if specified
387
+ if (provider && result.source !== provider) continue
388
+
389
+ // Check recency — boost if stored within the window
390
+ if (result.storedAt && result.storedAt > cutoff) {
391
+ result.score = Math.min(1.0, result.score * boostFactor)
392
+ boosted++
393
+ }
394
+ }
395
+
396
+ if (boosted > 0) {
397
+ // Re-sort after boosting (highest first)
398
+ context.results.sort((a, b) => b.score - a.score)
399
+ log.debug(
400
+ { provider: provider ?? 'all', boosted, factor: boostFactor, window: recencyWindow ?? '24h' },
401
+ 'Hook boost: scores adjusted',
402
+ )
403
+ }
404
+ }
405
+
406
+ /**
407
+ * `context` — Pre-load context from a provider at session start.
408
+ *
409
+ * Use case: At sessionStart, recall broad context from a provider
410
+ * to prime the session with relevant background knowledge.
411
+ *
412
+ * Fields: provider
413
+ * Events: sessionStart
414
+ *
415
+ * Results are appended to context.results for the caller to use.
416
+ */
417
+ const executeContext: ActionExecutor = async (hook, context, providers) => {
418
+ const { provider: providerName } = hook
419
+ if (!providerName) {
420
+ log.warn({ action: 'context' }, 'Hook context: requires "provider" — skipping')
421
+ return
422
+ }
423
+
424
+ const provider = providers.get(providerName)
425
+ if (!provider) {
426
+ log.warn(
427
+ { provider: providerName, action: 'context' },
428
+ 'Hook context: provider not found — skipping',
429
+ )
430
+ return
431
+ }
432
+
433
+ const results = await provider.recall('session context recent activity', { limit: 5 })
434
+
435
+ if (results.length > 0) {
436
+ // Append to context results (don't overwrite existing)
437
+ context.results = [...(context.results ?? []), ...results]
438
+ log.info({ provider: providerName, results: results.length }, 'Hook context: pre-loaded')
439
+ } else {
440
+ log.debug({ provider: providerName }, 'Hook context: no results to pre-load')
441
+ }
442
+ }
443
+
444
+ /**
445
+ * `summarize` — Generate and save a session summary.
446
+ *
447
+ * Use case: At sessionEnd, build a summary from the session messages
448
+ * and save it to a specific provider.
449
+ *
450
+ * Fields: provider
451
+ * Events: sessionEnd
452
+ *
453
+ * When context.llm is available, uses LLM for intelligent summarization.
454
+ * Falls back to basic topic extraction when LLM is not available or fails.
455
+ *
456
+ * Also updates context.sessionSummary for downstream hooks (e.g., promote).
457
+ */
458
+ const executeSummarize: ActionExecutor = async (hook, context, providers) => {
459
+ const { provider: providerName } = hook
460
+
461
+ if (!context.messages?.length) {
462
+ log.debug({ action: 'summarize' }, 'Hook summarize: no messages — skipping')
463
+ return
464
+ }
465
+
466
+ let summary = context.sessionSummary
467
+ let usedLLM = false
468
+
469
+ // Try LLM summarization first (if available and no summary exists yet)
470
+ log.debug(
471
+ { hasLLM: !!context.llm, hasSummary: !!summary, messageCount: context.messages?.length },
472
+ 'Hook summarize: checking LLM conditions',
473
+ )
474
+
475
+ if (!summary && context.llm && context.messages.length >= 2) {
476
+ try {
477
+ const transcript = context.messages
478
+ .map((m) => `${m.role}: ${m.content}`)
479
+ .join('\n')
480
+ .slice(0, MAX_TRANSCRIPT_CHARS)
481
+
482
+ log.debug(
483
+ {
484
+ transcriptLength: transcript.length,
485
+ promptLength: SUMMARIZE_PROMPT.length + transcript.length,
486
+ },
487
+ 'Hook summarize: calling LLM',
488
+ )
489
+
490
+ const llmSummary = await context.llm(SUMMARIZE_PROMPT + transcript)
491
+
492
+ log.debug(
493
+ { responseLength: llmSummary?.length, trimmedLength: llmSummary?.trim()?.length },
494
+ 'Hook summarize: LLM response received',
495
+ )
496
+
497
+ if (llmSummary?.trim()) {
498
+ summary = llmSummary.trim()
499
+ usedLLM = true
500
+ log.info(
501
+ { summaryLength: summary.length, messageCount: context.messageCount },
502
+ 'Hook summarize: LLM summary generated',
503
+ )
504
+ } else {
505
+ log.warn('Hook summarize: LLM returned empty response')
506
+ }
507
+ } catch (err) {
508
+ const errMsg = err instanceof Error ? err.message : String(err)
509
+ log.warn({ error: errMsg }, 'Hook summarize: LLM failed, falling back to basic')
510
+ }
511
+ }
512
+
513
+ // Fallback: basic topic summary
514
+ if (!summary) {
515
+ summary =
516
+ `Session with ${context.messageCount} messages` +
517
+ (context.topics?.length ? ` covering: ${context.topics.join(', ')}` : '')
518
+ }
519
+
520
+ // Ensure sessionDate is set (from context or auto-detect)
521
+ const sessionDate = context.sessionDate ?? new Date().toISOString().split('T')[0]
522
+ context.sessionDate = sessionDate
523
+
524
+ // Save to specific provider if named
525
+ if (providerName) {
526
+ const provider = providers.get(providerName)
527
+ if (!provider) {
528
+ log.warn(
529
+ { provider: providerName, action: 'summarize' },
530
+ 'Hook summarize: provider not found — skipping',
531
+ )
532
+ return
533
+ }
534
+
535
+ const fact: Fact = {
536
+ content: summary,
537
+ category: 'session-summary',
538
+ domain: 'session',
539
+ tags: ['summary', usedLLM ? 'llm-generated' : 'auto-generated', `session:${sessionDate}`],
540
+ importance: usedLLM ? 7 : 5,
541
+ }
542
+ if (context.episodeId) (fact as Record<string, unknown>).episodeId = context.episodeId
543
+ ;(fact as Record<string, unknown>).sessionDate = sessionDate
544
+
545
+ const result = await provider.save([fact])
546
+ // Track created fact IDs
547
+ if (Array.isArray(result) && result.length > 0) {
548
+ context.createdFactIds = [...(context.createdFactIds ?? []), ...result]
549
+ }
550
+
551
+ log.info(
552
+ {
553
+ provider: providerName,
554
+ summaryLength: summary.length,
555
+ llm: usedLLM,
556
+ episodeId: context.episodeId,
557
+ },
558
+ 'Hook summarize: saved',
559
+ )
560
+ }
561
+
562
+ // Update context for downstream hooks (e.g., promote can use it)
563
+ context.sessionSummary = summary
564
+ }
565
+
566
+ /**
567
+ * `episode` — Create or find an episode for the current session.
568
+ *
569
+ * Use case: At sessionStart, look up or create an episode in the brain
570
+ * for today's date + channel, and store the episodeId in context.
571
+ *
572
+ * Fields: provider
573
+ * Events: sessionStart
574
+ *
575
+ * Sets context.episodeId and context.sessionDate for downstream hooks.
576
+ */
577
+ const executeEpisode: ActionExecutor = async (hook, context, providers) => {
578
+ const { provider: providerName } = hook
579
+ if (!providerName) {
580
+ log.warn({ action: 'episode' }, 'Hook episode: requires "provider" — skipping')
581
+ return
582
+ }
583
+
584
+ const provider = providers.get(providerName)
585
+ if (!provider) {
586
+ log.warn(
587
+ { provider: providerName, action: 'episode' },
588
+ 'Hook episode: provider not found — skipping',
589
+ )
590
+ return
591
+ }
592
+
593
+ // Check if provider has getOrCreateEpisode (brain-specific)
594
+ const brain = provider as unknown as {
595
+ getOrCreateEpisode?: (
596
+ date: string,
597
+ channel?: string,
598
+ agentId?: string,
599
+ ) => Promise<{ id: string }>
600
+ }
601
+ if (!brain.getOrCreateEpisode) {
602
+ log.warn(
603
+ { provider: providerName, action: 'episode' },
604
+ 'Hook episode: provider does not support episodes — skipping',
605
+ )
606
+ return
607
+ }
608
+
609
+ const today = context.sessionDate ?? new Date().toISOString().split('T')[0]
610
+ const channel = context.channel
611
+
612
+ try {
613
+ const episode = await brain.getOrCreateEpisode(today, channel)
614
+ context.episodeId = episode.id
615
+ context.sessionDate = today
616
+ context.createdFactIds = []
617
+ log.info({ episodeId: episode.id, sessionDate: today, channel }, 'Hook episode: session linked')
618
+ } catch (err) {
619
+ const errMsg = err instanceof Error ? err.message : String(err)
620
+ log.warn({ error: errMsg }, 'Hook episode: failed to create/find episode — continuing without')
621
+ }
622
+ }
623
+
624
+ /**
625
+ * `journal` — Generate a personal journal entry (domain:self, category:journal).
626
+ *
627
+ * Use case: At sessionEnd, generate a first-person reflection about
628
+ * what happened in the session. This is the companion's inner voice, not a report.
629
+ *
630
+ * Fields: provider
631
+ * Events: sessionEnd
632
+ *
633
+ * Requires context.llm for LLM generation. Falls back to nothing (journal
634
+ * without LLM is meaningless — better to skip than fake it).
635
+ */
636
+ const executeJournal: ActionExecutor = async (hook, context, providers) => {
637
+ const { provider: providerName } = hook
638
+
639
+ if (!context.messages?.length || context.messages.length < 2) {
640
+ log.debug({ action: 'journal' }, 'Hook journal: insufficient messages — skipping')
641
+ return
642
+ }
643
+
644
+ if (!context.llm) {
645
+ log.debug({ action: 'journal' }, 'Hook journal: no LLM available — skipping')
646
+ return
647
+ }
648
+
649
+ try {
650
+ const transcript = context.messages
651
+ .map((m) => `${m.role}: ${m.content}`)
652
+ .join('\n')
653
+ .slice(0, MAX_TRANSCRIPT_CHARS)
654
+
655
+ const journal = await context.llm(JOURNAL_PROMPT + transcript)
656
+
657
+ if (!journal?.trim()) {
658
+ log.warn('Hook journal: LLM returned empty response')
659
+ return
660
+ }
661
+
662
+ const journalText = journal.trim()
663
+ const sessionDate = context.sessionDate ?? new Date().toISOString().split('T')[0]
664
+
665
+ // Save to provider
666
+ if (providerName) {
667
+ const provider = providers.get(providerName)
668
+ if (!provider) {
669
+ log.warn(
670
+ { provider: providerName, action: 'journal' },
671
+ 'Hook journal: provider not found — skipping save',
672
+ )
673
+ return
674
+ }
675
+
676
+ const fact: Fact = {
677
+ content: journalText,
678
+ category: 'journal',
679
+ domain: 'self',
680
+ tags: ['journal', 'llm-generated', `session:${sessionDate}`],
681
+ importance: 7,
682
+ }
683
+ if (context.episodeId) (fact as Record<string, unknown>).episodeId = context.episodeId
684
+ ;(fact as Record<string, unknown>).sessionDate = sessionDate
685
+
686
+ const result = await provider.save([fact])
687
+ // Track created fact IDs
688
+ if (Array.isArray(result) && result.length > 0) {
689
+ context.createdFactIds = [...(context.createdFactIds ?? []), ...result]
690
+ }
691
+
692
+ log.info(
693
+ { provider: providerName, length: journalText.length, episodeId: context.episodeId },
694
+ 'Hook journal: saved',
695
+ )
696
+ }
697
+ } catch (err) {
698
+ const errMsg = err instanceof Error ? err.message : String(err)
699
+ log.warn({ error: errMsg }, 'Hook journal: failed — continuing')
700
+ }
701
+ }
702
+
703
+ /**
704
+ * `experience` — Capture the emotional tone of a session (domain:self, category:experience).
705
+ *
706
+ * Use case: At sessionEnd, generate 1-2 impressionistic sentences about
707
+ * how the session felt. Color, not content.
708
+ *
709
+ * Fields: provider
710
+ * Events: sessionEnd
711
+ */
712
+ const executeExperience: ActionExecutor = async (hook, context, providers) => {
713
+ const { provider: providerName } = hook
714
+
715
+ if (!context.messages?.length || context.messages.length < 2) {
716
+ log.debug({ action: 'experience' }, 'Hook experience: insufficient messages — skipping')
717
+ return
718
+ }
719
+
720
+ if (!context.llm) {
721
+ log.debug({ action: 'experience' }, 'Hook experience: no LLM available — skipping')
722
+ return
723
+ }
724
+
725
+ try {
726
+ const transcript = context.messages
727
+ .map((m) => `${m.role}: ${m.content}`)
728
+ .join('\n')
729
+ .slice(0, MAX_TRANSCRIPT_CHARS)
730
+
731
+ const experience = await context.llm(EXPERIENCE_PROMPT + transcript)
732
+
733
+ if (!experience?.trim()) {
734
+ log.warn('Hook experience: LLM returned empty response')
735
+ return
736
+ }
737
+
738
+ const experienceText = experience.trim()
739
+ const sessionDate = context.sessionDate ?? new Date().toISOString().split('T')[0]
740
+
741
+ if (providerName) {
742
+ const provider = providers.get(providerName)
743
+ if (!provider) {
744
+ log.warn(
745
+ { provider: providerName, action: 'experience' },
746
+ 'Hook experience: provider not found — skipping save',
747
+ )
748
+ return
749
+ }
750
+
751
+ const fact: Fact = {
752
+ content: experienceText,
753
+ category: 'experience',
754
+ domain: 'self',
755
+ tags: ['experience', 'llm-generated', `session:${sessionDate}`],
756
+ importance: 6,
757
+ }
758
+ if (context.episodeId) (fact as Record<string, unknown>).episodeId = context.episodeId
759
+ ;(fact as Record<string, unknown>).sessionDate = sessionDate
760
+
761
+ const result = await provider.save([fact])
762
+ if (Array.isArray(result) && result.length > 0) {
763
+ context.createdFactIds = [...(context.createdFactIds ?? []), ...result]
764
+ }
765
+
766
+ log.info(
767
+ { provider: providerName, length: experienceText.length, episodeId: context.episodeId },
768
+ 'Hook experience: saved',
769
+ )
770
+ }
771
+ } catch (err) {
772
+ const errMsg = err instanceof Error ? err.message : String(err)
773
+ log.warn({ error: errMsg }, 'Hook experience: failed — continuing')
774
+ }
775
+ }
776
+
777
+ /**
778
+ * `reconcile` — Dedup-aware transfer from session summary to long-term memory.
779
+ * Replaces `promote` with smarter behavior:
780
+ * 1. Parse structured lines from LLM summary (like promote full)
781
+ * 2. Dedup each candidate against existing facts in the target provider
782
+ * 3. Only save facts that are genuinely new
783
+ *
784
+ * Fields: from, to
785
+ * Events: afterSessionEnd
786
+ */
787
+ const executeReconcile: ActionExecutor = async (hook, context, providers) => {
788
+ const { from, to } = hook
789
+ if (!to) {
790
+ log.warn({ action: 'reconcile' }, 'Hook reconcile: requires "to" — skipping')
791
+ return
792
+ }
793
+
794
+ const targetProvider = providers.get(to)
795
+ if (!targetProvider) {
796
+ log.warn({ to, action: 'reconcile' }, 'Hook reconcile: target provider not found — skipping')
797
+ return
798
+ }
799
+
800
+ // Category mapping from LLM prefix to fact category + importance
801
+ const PREFIX_MAP: Record<string, { category: string; importance: number }> = {
802
+ DECISION: { category: 'decision', importance: 7 },
803
+ INFO: { category: 'observation', importance: 5 },
804
+ PROBLEM: { category: 'learning', importance: 6 },
805
+ PENDING: { category: 'pending', importance: 6 },
806
+ TECHNICAL: { category: 'technical', importance: 5 },
807
+ }
808
+
809
+ const sessionDate = context.sessionDate ?? new Date().toISOString().split('T')[0]
810
+ const candidates: Fact[] = []
811
+ const seenContent = new Set<string>()
812
+
813
+ // 1. Parse LLM summary lines into individual facts
814
+ if (context.sessionSummary) {
815
+ const lines = context.sessionSummary.split('\n').filter((l) => l.trim())
816
+ for (const line of lines) {
817
+ const match = line.match(/^\s*(DECISION|INFO|PROBLEM|PENDING|TECHNICAL):\s*(.+)/i)
818
+ if (!match) continue
819
+
820
+ const prefix = match[1].toUpperCase()
821
+ const factContent = match[2].trim()
822
+ if (!factContent || seenContent.has(factContent)) continue
823
+ seenContent.add(factContent)
824
+
825
+ const mapped = PREFIX_MAP[prefix] ?? { category: 'observation', importance: 5 }
826
+ const fact: Fact = {
827
+ content: factContent,
828
+ category: mapped.category,
829
+ domain: mapped.category === 'decision' ? 'infra' : 'session',
830
+ source: from,
831
+ tags: ['reconciled', prefix.toLowerCase(), `session:${sessionDate}`],
832
+ importance: mapped.importance,
833
+ }
834
+ if (context.episodeId) (fact as Record<string, unknown>).episodeId = context.episodeId
835
+ ;(fact as Record<string, unknown>).sessionDate = sessionDate
836
+
837
+ candidates.push(fact)
838
+ }
839
+ }
840
+
841
+ if (candidates.length === 0) {
842
+ log.debug({ action: 'reconcile' }, 'Hook reconcile: no candidates from summary — skipping')
843
+ return
844
+ }
845
+
846
+ // 2. Dedup against existing facts for this session date
847
+ // Query target provider for facts from today to check for duplicates
848
+ let existingContents: Set<string> = new Set()
849
+ try {
850
+ const existing = await targetProvider.recall(context.topics?.join(' ') || 'session activity', {
851
+ limit: 30,
852
+ })
853
+ existingContents = new Set(existing.map((r) => r.content.toLowerCase().trim()))
854
+ } catch {
855
+ log.debug(
856
+ { action: 'reconcile' },
857
+ 'Hook reconcile: could not query existing — saving all candidates',
858
+ )
859
+ }
860
+
861
+ // 3. Filter out candidates that already exist (simple containment check)
862
+ const newFacts = candidates.filter((f) => {
863
+ const normalized = f.content.toLowerCase().trim()
864
+ // Check if any existing fact is very similar (contains or is contained)
865
+ for (const existing of existingContents) {
866
+ if (existing.includes(normalized) || normalized.includes(existing)) {
867
+ return false
868
+ }
869
+ }
870
+ return true
871
+ })
872
+
873
+ // Cap at 10
874
+ const toSave = newFacts.slice(0, 10)
875
+
876
+ if (toSave.length > 0) {
877
+ const result = await targetProvider.save(toSave)
878
+ if (Array.isArray(result) && result.length > 0) {
879
+ context.createdFactIds = [...(context.createdFactIds ?? []), ...result]
880
+ }
881
+ }
882
+
883
+ log.info(
884
+ {
885
+ to,
886
+ candidates: candidates.length,
887
+ deduplicated: candidates.length - newFacts.length,
888
+ saved: toSave.length,
889
+ episodeId: context.episodeId,
890
+ },
891
+ 'Hook reconcile: complete',
892
+ )
893
+ }
894
+
895
+ /**
896
+ * `episodeClose` — Update episode with all fact IDs at session close.
897
+ *
898
+ * Use case: After all sessionEnd/afterSessionEnd hooks have run,
899
+ * update the episode with the complete list of factIds.
900
+ *
901
+ * Fields: provider
902
+ * Events: afterSessionEnd
903
+ */
904
+ const executeEpisodeClose: ActionExecutor = async (hook, context, providers) => {
905
+ const { provider: providerName } = hook
906
+
907
+ if (!context.episodeId) {
908
+ log.debug({ action: 'episodeClose' }, 'Hook episodeClose: no episodeId — skipping')
909
+ return
910
+ }
911
+
912
+ if (!providerName) {
913
+ log.warn({ action: 'episodeClose' }, 'Hook episodeClose: requires "provider" — skipping')
914
+ return
915
+ }
916
+
917
+ const provider = providers.get(providerName)
918
+ if (!provider) {
919
+ log.warn(
920
+ { provider: providerName, action: 'episodeClose' },
921
+ 'Hook episodeClose: provider not found — skipping',
922
+ )
923
+ return
924
+ }
925
+
926
+ const brain = provider as unknown as {
927
+ updateEpisode?: (id: string, data: Record<string, unknown>) => Promise<unknown>
928
+ }
929
+ if (!brain.updateEpisode) {
930
+ log.warn(
931
+ { action: 'episodeClose' },
932
+ 'Hook episodeClose: provider does not support updateEpisode — skipping',
933
+ )
934
+ return
935
+ }
936
+
937
+ try {
938
+ const updateData: Record<string, unknown> = {}
939
+ if (context.createdFactIds?.length) updateData.factIds = context.createdFactIds
940
+ if (context.sessionSummary) updateData.summary = context.sessionSummary.slice(0, 500)
941
+ if (context.topics?.length) updateData.topics = context.topics
942
+ updateData.messageCount = context.messageCount
943
+
944
+ await brain.updateEpisode(context.episodeId, updateData)
945
+ log.info(
946
+ { episodeId: context.episodeId, factCount: context.createdFactIds?.length ?? 0 },
947
+ 'Hook episodeClose: episode updated',
948
+ )
949
+ } catch (err) {
950
+ const errMsg = err instanceof Error ? err.message : String(err)
951
+ log.warn({ error: errMsg, episodeId: context.episodeId }, 'Hook episodeClose: failed')
952
+ }
953
+ }
954
+
955
+ // ════════════════════════════════════════════════════════════
956
+ // Action registry — extensible via registerAction()
957
+ // ════════════════════════════════════════════════════════════
958
+
959
+ /**
960
+ * `diaryRecall` — Load diary entries (journals, experiences, summaries) by date at session start.
961
+ *
962
+ * Unlike generic recall (semantic similarity), this loads by date:
963
+ * 1. domain:self facts from today (journals + experiences)
964
+ * 2. session-summaries from today
965
+ * 3. If today is empty → try yesterday
966
+ *
967
+ * Results are prepended to context.results so they always appear in Memory Context,
968
+ * regardless of semantic relevance score.
969
+ *
970
+ * Fields: provider
971
+ * Events: sessionStart
972
+ */
973
+ const executeDiaryRecall: ActionExecutor = async (hook, context, providers) => {
974
+ const { provider: providerName } = hook
975
+ if (!providerName) {
976
+ log.warn({ action: 'diaryRecall' }, 'Hook diaryRecall: requires "provider" — skipping')
977
+ return
978
+ }
979
+
980
+ const provider = providers.get(providerName)
981
+ if (!provider) {
982
+ log.warn(
983
+ { provider: providerName, action: 'diaryRecall' },
984
+ 'Hook diaryRecall: provider not found — skipping',
985
+ )
986
+ return
987
+ }
988
+
989
+ // Check if provider has queryFacts (brain-specific)
990
+ const brain = provider as unknown as {
991
+ queryFacts?: (filters: Record<string, unknown>) => Promise<
992
+ Array<{
993
+ id: string
994
+ content: string
995
+ domain: string
996
+ category: string
997
+ episodeId?: string
998
+ sessionDate?: string
999
+ importance: number
1000
+ createdAt: string
1001
+ }>
1002
+ >
1003
+ }
1004
+ if (!brain.queryFacts) {
1005
+ log.warn(
1006
+ { action: 'diaryRecall' },
1007
+ 'Hook diaryRecall: provider does not support queryFacts — skipping',
1008
+ )
1009
+ return
1010
+ }
1011
+
1012
+ const today = context.sessionDate ?? new Date().toISOString().split('T')[0]
1013
+
1014
+ // Helper: get yesterday's date
1015
+ const getYesterday = (date: string): string => {
1016
+ const d = new Date(date + 'T12:00:00Z')
1017
+ d.setDate(d.getDate() - 1)
1018
+ return d.toISOString().split('T')[0]
1019
+ }
1020
+
1021
+ try {
1022
+ // 1. Get domain:self facts from today (journals + experiences)
1023
+ let selfFacts = await brain.queryFacts({ sessionDate: today, domain: 'self', limit: 20 })
1024
+
1025
+ // 2. Get session-summaries from today
1026
+ let summaryFacts = await brain.queryFacts({
1027
+ sessionDate: today,
1028
+ domain: 'session',
1029
+ category: 'session-summary',
1030
+ limit: 10,
1031
+ })
1032
+
1033
+ // 3. If today is empty → try yesterday
1034
+ let usedYesterday = false
1035
+ if (selfFacts.length === 0 && summaryFacts.length === 0) {
1036
+ const yesterday = getYesterday(today)
1037
+ log.debug({ today, yesterday }, 'Hook diaryRecall: today empty, trying yesterday')
1038
+ selfFacts = await brain.queryFacts({ sessionDate: yesterday, domain: 'self', limit: 20 })
1039
+ summaryFacts = await brain.queryFacts({
1040
+ sessionDate: yesterday,
1041
+ domain: 'session',
1042
+ category: 'session-summary',
1043
+ limit: 10,
1044
+ })
1045
+ usedYesterday = selfFacts.length > 0 || summaryFacts.length > 0
1046
+ }
1047
+
1048
+ // ── Score gradient by category and day ──
1049
+ // Today: summaries=1.0, journals=0.95, experiences=0.90
1050
+ // Yesterday: summaries=0.85, journals=0.80, experiences=0.75
1051
+ const dayOffset = usedYesterday ? 0.15 : 0
1052
+
1053
+ // ── Cap per category to prevent context bloat ──
1054
+ const MAX_SUMMARIES = 3
1055
+ const MAX_JOURNALS = 4
1056
+ const MAX_EXPERIENCES = 3
1057
+
1058
+ const journals = selfFacts.filter((f) => f.category === 'journal').slice(-MAX_JOURNALS)
1059
+ const experiences = selfFacts.filter((f) => f.category === 'experience').slice(-MAX_EXPERIENCES)
1060
+ const cappedSummaries = summaryFacts.slice(-MAX_SUMMARIES)
1061
+
1062
+ const diaryResults: MemoryResult[] = []
1063
+
1064
+ const makeDiaryResult = (
1065
+ fact: (typeof selfFacts)[0],
1066
+ label: string,
1067
+ baseScore: number,
1068
+ ): MemoryResult => {
1069
+ const epTag = fact.episodeId ? ` (ep:${fact.episodeId.slice(0, 8)})` : ''
1070
+ return {
1071
+ content: `[${label}]${epTag} ${fact.content}`,
1072
+ score: Math.max(0.5, baseScore - dayOffset),
1073
+ source: 'brain:diary',
1074
+ metadata: {
1075
+ type: 'fact',
1076
+ id: fact.id,
1077
+ domain: fact.domain,
1078
+ category: fact.category,
1079
+ episodeId: fact.episodeId,
1080
+ sessionDate: fact.sessionDate,
1081
+ },
1082
+ storedAt: new Date(fact.createdAt),
1083
+ }
1084
+ }
1085
+
1086
+ // Order: summaries → journals → experiences (descending importance)
1087
+ for (const fact of cappedSummaries) {
1088
+ diaryResults.push(makeDiaryResult(fact, 'summary', 1.0))
1089
+ }
1090
+ for (const fact of journals) {
1091
+ diaryResults.push(makeDiaryResult(fact, 'journal', 0.95))
1092
+ }
1093
+ for (const fact of experiences) {
1094
+ diaryResults.push(makeDiaryResult(fact, 'experience', 0.9))
1095
+ }
1096
+
1097
+ if (diaryResults.length > 0) {
1098
+ // Prepend diary results to context.results (before any generic recall)
1099
+ context.results = [...diaryResults, ...(context.results ?? [])]
1100
+ log.info(
1101
+ {
1102
+ provider: providerName,
1103
+ self: selfFacts.length,
1104
+ summaries: summaryFacts.length,
1105
+ date: today,
1106
+ },
1107
+ 'Hook diaryRecall: loaded',
1108
+ )
1109
+ } else {
1110
+ log.debug({ provider: providerName, date: today }, 'Hook diaryRecall: no entries found')
1111
+ }
1112
+ } catch (err) {
1113
+ const errMsg = err instanceof Error ? err.message : String(err)
1114
+ log.warn({ error: errMsg }, 'Hook diaryRecall: failed — continuing without')
1115
+ }
1116
+ }
1117
+
1118
+ const ACTION_REGISTRY = new Map<string, ActionExecutor>([
1119
+ ['promote', executePromote],
1120
+ ['boost', executeBoost],
1121
+ ['context', executeContext],
1122
+ ['summarize', executeSummarize],
1123
+ ['episode', executeEpisode],
1124
+ ['journal', executeJournal],
1125
+ ['experience', executeExperience],
1126
+ ['reconcile', executeReconcile],
1127
+ ['episodeClose', executeEpisodeClose],
1128
+ ['diaryRecall', executeDiaryRecall],
1129
+ ])
1130
+
1131
+ /**
1132
+ * Register a custom action executor.
1133
+ * Overwrites existing actions with the same name.
1134
+ */
1135
+ export function registerAction(name: string, executor: ActionExecutor): void {
1136
+ ACTION_REGISTRY.set(name, executor)
1137
+ }
1138
+
1139
+ /**
1140
+ * Get a registered action by name (mainly for testing).
1141
+ */
1142
+ export function getAction(name: string): ActionExecutor | undefined {
1143
+ return ACTION_REGISTRY.get(name)
1144
+ }
1145
+
1146
+ /**
1147
+ * List all registered action names.
1148
+ */
1149
+ export function listActions(): string[] {
1150
+ return [...ACTION_REGISTRY.keys()]
1151
+ }
1152
+
1153
+ // ════════════════════════════════════════════════════════════
1154
+ // HookRunner — the main component
1155
+ // ════════════════════════════════════════════════════════════
1156
+
1157
+ /**
1158
+ * HookRunner — Lifecycle hook evaluation and execution engine.
1159
+ *
1160
+ * Plugs into MemoryOrchestrator to run user-defined hooks at each
1161
+ * lifecycle event (sessionStart, afterRecall, sessionEnd, etc.).
1162
+ *
1163
+ * Thread of execution per event:
1164
+ * 1. Get hooks for event (declared order)
1165
+ * 2. For each hook:
1166
+ * a. Evaluate guard conditions → skip if not met
1167
+ * b. Resolve action from registry → skip if unknown
1168
+ * c. Execute action with mutable context → catch errors
1169
+ * 3. Return (possibly modified) context
1170
+ */
1171
+ export class HookRunner {
1172
+ private providers: Map<string, MemoryProvider>
1173
+ private hooks: HooksConfig
1174
+ private llm?: LLMFunction
1175
+
1176
+ constructor(
1177
+ hooks: HooksConfig,
1178
+ providers: Map<string, MemoryProvider> | MemoryProvider[],
1179
+ options?: { llm?: LLMFunction },
1180
+ ) {
1181
+ this.hooks = hooks
1182
+ this.llm = options?.llm
1183
+
1184
+ if (Array.isArray(providers)) {
1185
+ this.providers = new Map(providers.map((p) => [p.id, p]))
1186
+ } else {
1187
+ this.providers = new Map(providers)
1188
+ }
1189
+ }
1190
+
1191
+ /**
1192
+ * Set or update the LLM function at runtime.
1193
+ */
1194
+ setLLM(llm: LLMFunction | undefined): void {
1195
+ this.llm = llm
1196
+ }
1197
+
1198
+ /**
1199
+ * Run all hooks for a lifecycle event.
1200
+ *
1201
+ * @param event - The lifecycle event name
1202
+ * @param context - Mutable context that actions can modify
1203
+ * @returns The (possibly modified) context
1204
+ */
1205
+ async run(event: LifecycleEvent, context: HookContext): Promise<HookContext> {
1206
+ const eventHooks = this.hooks[event]
1207
+ if (!eventHooks?.length) return context
1208
+
1209
+ // Inject LLM function into context if available
1210
+ if (this.llm && !context.llm) {
1211
+ context.llm = this.llm
1212
+ }
1213
+
1214
+ for (const hook of eventHooks) {
1215
+ try {
1216
+ // 1. Evaluate guard
1217
+ const shouldRun = evaluateGuard(hook.when, context, this.providers)
1218
+ if (!shouldRun) {
1219
+ log.debug({ event, action: hook.action, reason: 'guard-not-met' }, 'Hook skipped')
1220
+ continue
1221
+ }
1222
+
1223
+ // 2. Resolve action
1224
+ const executor = ACTION_REGISTRY.get(hook.action)
1225
+ if (!executor) {
1226
+ log.warn({ event, action: hook.action }, 'Unknown hook action — skipping')
1227
+ continue
1228
+ }
1229
+
1230
+ // 3. Execute (best-effort)
1231
+ await executor(hook, context, this.providers)
1232
+ } catch (err) {
1233
+ const errMsg = err instanceof Error ? err.message : String(err)
1234
+ log.warn(
1235
+ { event, action: hook.action, error: errMsg },
1236
+ 'Hook execution failed — continuing',
1237
+ )
1238
+ }
1239
+ }
1240
+
1241
+ return context
1242
+ }
1243
+
1244
+ /**
1245
+ * Check if any hooks are registered for a given event.
1246
+ */
1247
+ hasHooks(event: LifecycleEvent): boolean {
1248
+ return (this.hooks[event]?.length ?? 0) > 0
1249
+ }
1250
+
1251
+ /**
1252
+ * Get the list of events that have at least one hook.
1253
+ */
1254
+ get activeEvents(): LifecycleEvent[] {
1255
+ return (Object.entries(this.hooks) as Array<[LifecycleEvent, HookEntry[] | undefined]>)
1256
+ .filter(([, hooks]) => hooks && hooks.length > 0)
1257
+ .map(([event]) => event)
1258
+ }
1259
+
1260
+ /**
1261
+ * Update the provider map (e.g., when a provider is added/removed at runtime).
1262
+ */
1263
+ updateProviders(providers: Map<string, MemoryProvider> | MemoryProvider[]): void {
1264
+ if (Array.isArray(providers)) {
1265
+ this.providers = new Map(providers.map((p) => [p.id, p]))
1266
+ } else {
1267
+ this.providers = new Map(providers)
1268
+ }
1269
+ }
1270
+ }