@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,1689 @@
1
+ /**
2
+ * # HookRunner — Functional Specification
3
+ *
4
+ * Lifecycle hook evaluation and execution engine for the memory system.
5
+ *
6
+ * ## Core responsibilities:
7
+ * - Evaluate guard conditions (providersAvailable, minMessages)
8
+ * - Execute built-in actions: promote, boost, context, summarize
9
+ * - Maintain declared order per lifecycle event
10
+ * - Best-effort execution: failures logged, never block
11
+ * - Mutable context: actions modify results/facts in-place
12
+ * - Extensible action registry for custom actions
13
+ *
14
+ * ## Key design decisions:
15
+ * - Guards check registration only (not health) for speed
16
+ * - Time window parser supports s/m/h/d/w units
17
+ * - Boost modifies scores in-place and re-sorts
18
+ * - Promote supports two strategies: extraction and recall-transfer
19
+ * - Context pre-loads results by appending to existing
20
+ * - Summarize updates context.sessionSummary for downstream hooks
21
+ */
22
+ import { beforeEach, describe, expect, it } from 'bun:test'
23
+ import type { HookCondition, HookEntry, HooksConfig, LifecycleEvent } from '../lib/config-loader'
24
+ import type { HookContext, LLMFunction } from '../lib/hook-runner'
25
+ import {
26
+ evaluateGuard,
27
+ getAction,
28
+ HookRunner,
29
+ listActions,
30
+ parseTimeWindow,
31
+ registerAction,
32
+ } from '../lib/hook-runner'
33
+ import type { Fact, MemoryProvider, MemoryResult, SearchOptions } from '../types/memory'
34
+
35
+ // ═══════════════════════════════════════════════════════════════════
36
+ // Test helpers
37
+ // ═══════════════════════════════════════════════════════════════════
38
+
39
+ function createMockProvider(
40
+ id: string,
41
+ options?: {
42
+ recallResults?: MemoryResult[]
43
+ failRecall?: boolean
44
+ failSave?: boolean
45
+ savedFacts?: Fact[][]
46
+ instructions?: string
47
+ },
48
+ ): MemoryProvider {
49
+ const savedFacts: Fact[][] = options?.savedFacts ?? []
50
+
51
+ return {
52
+ id,
53
+ name: `Mock ${id}`,
54
+ async init() {},
55
+ async destroy() {},
56
+ async recall(_query: string, _opts?: SearchOptions): Promise<MemoryResult[]> {
57
+ if (options?.failRecall) throw new Error(`${id} recall failed`)
58
+ return options?.recallResults ?? []
59
+ },
60
+ async save(facts: Fact[]) {
61
+ if (options?.failSave) throw new Error(`${id} save failed`)
62
+ savedFacts.push(facts)
63
+ },
64
+ async health() {
65
+ return { ok: true }
66
+ },
67
+ agentInstructions() {
68
+ return options?.instructions ?? ''
69
+ },
70
+ }
71
+ }
72
+
73
+ // ═══════════════════════════════════════════════════════════════════
74
+ // parseTimeWindow — time window string to milliseconds
75
+ // ═══════════════════════════════════════════════════════════════════
76
+
77
+ describe('parseTimeWindow', () => {
78
+ it('parses seconds', () => {
79
+ expect(parseTimeWindow('30s')).toBe(30_000)
80
+ })
81
+
82
+ it('parses minutes', () => {
83
+ expect(parseTimeWindow('5m')).toBe(300_000)
84
+ })
85
+
86
+ it('parses hours', () => {
87
+ expect(parseTimeWindow('24h')).toBe(86_400_000)
88
+ })
89
+
90
+ it('parses days', () => {
91
+ expect(parseTimeWindow('7d')).toBe(604_800_000)
92
+ })
93
+
94
+ it('parses weeks', () => {
95
+ expect(parseTimeWindow('2w')).toBe(1_209_600_000)
96
+ })
97
+
98
+ it('defaults to 24h for invalid format', () => {
99
+ expect(parseTimeWindow('invalid')).toBe(86_400_000)
100
+ expect(parseTimeWindow('')).toBe(86_400_000)
101
+ expect(parseTimeWindow('24')).toBe(86_400_000)
102
+ expect(parseTimeWindow('h24')).toBe(86_400_000)
103
+ })
104
+ })
105
+
106
+ // ═══════════════════════════════════════════════════════════════════
107
+ // evaluateGuard — guard condition evaluation
108
+ // ═══════════════════════════════════════════════════════════════════
109
+
110
+ describe('evaluateGuard', () => {
111
+ const providers = new Map<string, MemoryProvider>([
112
+ ['brain', createMockProvider('brain')],
113
+ ['engram', createMockProvider('engram')],
114
+ ])
115
+ const baseContext: HookContext = { messageCount: 5 }
116
+
117
+ it('returns true when no guard is defined', () => {
118
+ expect(evaluateGuard(undefined, baseContext, providers)).toBe(true)
119
+ })
120
+
121
+ it('returns true when all providers are available', () => {
122
+ const guard: HookCondition = { providersAvailable: ['brain', 'engram'] }
123
+ expect(evaluateGuard(guard, baseContext, providers)).toBe(true)
124
+ })
125
+
126
+ it('returns false when a required provider is missing', () => {
127
+ const guard: HookCondition = { providersAvailable: ['brain', 'missing'] }
128
+ expect(evaluateGuard(guard, baseContext, providers)).toBe(false)
129
+ })
130
+
131
+ it('returns true when messageCount meets minMessages', () => {
132
+ const guard: HookCondition = { minMessages: 3 }
133
+ expect(evaluateGuard(guard, baseContext, providers)).toBe(true)
134
+ })
135
+
136
+ it('returns false when messageCount is below minMessages', () => {
137
+ const guard: HookCondition = { minMessages: 10 }
138
+ expect(evaluateGuard(guard, baseContext, providers)).toBe(false)
139
+ })
140
+
141
+ it('requires ALL conditions to be met', () => {
142
+ const guard: HookCondition = {
143
+ providersAvailable: ['brain'],
144
+ minMessages: 10, // context has 5 — should fail
145
+ }
146
+ expect(evaluateGuard(guard, baseContext, providers)).toBe(false)
147
+ })
148
+
149
+ it('returns true when all combined conditions are met', () => {
150
+ const guard: HookCondition = {
151
+ providersAvailable: ['brain'],
152
+ minMessages: 3,
153
+ }
154
+ expect(evaluateGuard(guard, baseContext, providers)).toBe(true)
155
+ })
156
+
157
+ it('returns true for empty providersAvailable array', () => {
158
+ const guard: HookCondition = { providersAvailable: [] }
159
+ expect(evaluateGuard(guard, baseContext, providers)).toBe(true)
160
+ })
161
+ })
162
+
163
+ // ═══════════════════════════════════════════════════════════════════
164
+ // Action registry — extensibility
165
+ // ═══════════════════════════════════════════════════════════════════
166
+
167
+ describe('action registry', () => {
168
+ it('has 4 built-in actions', () => {
169
+ const actions = listActions()
170
+ expect(actions).toContain('promote')
171
+ expect(actions).toContain('boost')
172
+ expect(actions).toContain('context')
173
+ expect(actions).toContain('summarize')
174
+ })
175
+
176
+ it('registers and retrieves custom actions', () => {
177
+ const customAction = async () => {}
178
+ registerAction('custom-test', customAction)
179
+ expect(getAction('custom-test')).toBe(customAction)
180
+ })
181
+
182
+ it('overwrites existing actions', () => {
183
+ const original = getAction('boost')
184
+ const replacement = async () => {}
185
+ registerAction('boost', replacement)
186
+ expect(getAction('boost')).toBe(replacement)
187
+
188
+ // Restore original
189
+ if (original) registerAction('boost', original)
190
+ })
191
+ })
192
+
193
+ // ═══════════════════════════════════════════════════════════════════
194
+ // HookRunner — core lifecycle engine
195
+ // ═══════════════════════════════════════════════════════════════════
196
+
197
+ describe('HookRunner', () => {
198
+ it('returns context unchanged when no hooks for event', async () => {
199
+ const runner = new HookRunner({}, [])
200
+ const context: HookContext = { messageCount: 5 }
201
+ const result = await runner.run('sessionStart', context)
202
+ expect(result).toBe(context)
203
+ })
204
+
205
+ it('reports active events', () => {
206
+ const hooks: HooksConfig = {
207
+ sessionStart: [{ action: 'context', provider: 'engram' }],
208
+ afterRecall: [{ action: 'boost', provider: 'engram' }],
209
+ }
210
+ const runner = new HookRunner(hooks, [])
211
+ expect(runner.activeEvents).toContain('sessionStart')
212
+ expect(runner.activeEvents).toContain('afterRecall')
213
+ expect(runner.activeEvents).not.toContain('sessionEnd')
214
+ })
215
+
216
+ it('checks hasHooks correctly', () => {
217
+ const hooks: HooksConfig = {
218
+ sessionStart: [{ action: 'context', provider: 'engram' }],
219
+ }
220
+ const runner = new HookRunner(hooks, [])
221
+ expect(runner.hasHooks('sessionStart')).toBe(true)
222
+ expect(runner.hasHooks('sessionEnd')).toBe(false)
223
+ })
224
+
225
+ it('accepts provider array (converts to map by id)', async () => {
226
+ const savedFacts: Fact[][] = []
227
+ const providers = [createMockProvider('brain', { savedFacts })]
228
+ const hooks: HooksConfig = {
229
+ sessionEnd: [{ action: 'summarize', provider: 'brain' }],
230
+ }
231
+ const runner = new HookRunner(hooks, providers)
232
+
233
+ await runner.run('sessionEnd', {
234
+ messageCount: 3,
235
+ messages: [{ role: 'user', content: 'test' }],
236
+ })
237
+
238
+ expect(savedFacts.length).toBe(1)
239
+ })
240
+
241
+ it('accepts provider map', async () => {
242
+ const savedFacts: Fact[][] = []
243
+ const map = new Map<string, MemoryProvider>([
244
+ ['brain', createMockProvider('brain', { savedFacts })],
245
+ ])
246
+ const hooks: HooksConfig = {
247
+ sessionEnd: [{ action: 'summarize', provider: 'brain' }],
248
+ }
249
+ const runner = new HookRunner(hooks, map)
250
+
251
+ await runner.run('sessionEnd', {
252
+ messageCount: 3,
253
+ messages: [{ role: 'user', content: 'test' }],
254
+ })
255
+
256
+ expect(savedFacts.length).toBe(1)
257
+ })
258
+
259
+ it('skips hook when guard fails', async () => {
260
+ const savedFacts: Fact[][] = []
261
+ const hooks: HooksConfig = {
262
+ afterSessionEnd: [
263
+ {
264
+ action: 'promote',
265
+ from: 'engram',
266
+ to: 'brain',
267
+ when: { minMessages: 10 }, // context has 2 — guard fails
268
+ },
269
+ ],
270
+ }
271
+ const runner = new HookRunner(hooks, [
272
+ createMockProvider('brain', { savedFacts }),
273
+ createMockProvider('engram'),
274
+ ])
275
+
276
+ await runner.run('afterSessionEnd', { messageCount: 2 })
277
+
278
+ expect(savedFacts.length).toBe(0) // hook was skipped
279
+ })
280
+
281
+ it('skips unknown actions gracefully', async () => {
282
+ const hooks: HooksConfig = {
283
+ sessionStart: [{ action: 'nonexistent-action' }],
284
+ }
285
+ const runner = new HookRunner(hooks, [])
286
+
287
+ // Should not throw
288
+ const result = await runner.run('sessionStart', { messageCount: 0 })
289
+ expect(result.messageCount).toBe(0)
290
+ })
291
+
292
+ it('continues after a hook failure (best-effort)', async () => {
293
+ const savedFacts: Fact[][] = []
294
+ const hooks: HooksConfig = {
295
+ sessionEnd: [
296
+ { action: 'summarize', provider: 'failing' }, // will fail (provider not found)
297
+ { action: 'summarize', provider: 'brain' }, // should still run
298
+ ],
299
+ }
300
+ const runner = new HookRunner(hooks, [createMockProvider('brain', { savedFacts })])
301
+
302
+ await runner.run('sessionEnd', {
303
+ messageCount: 3,
304
+ messages: [{ role: 'user', content: 'test' }],
305
+ })
306
+
307
+ // Second hook should have run despite first failing
308
+ expect(savedFacts.length).toBe(1)
309
+ })
310
+
311
+ it('executes hooks in declared order', async () => {
312
+ const order: string[] = []
313
+
314
+ registerAction('order-a', async () => {
315
+ order.push('a')
316
+ })
317
+ registerAction('order-b', async () => {
318
+ order.push('b')
319
+ })
320
+ registerAction('order-c', async () => {
321
+ order.push('c')
322
+ })
323
+
324
+ const hooks: HooksConfig = {
325
+ sessionStart: [{ action: 'order-a' }, { action: 'order-b' }, { action: 'order-c' }],
326
+ }
327
+ const runner = new HookRunner(hooks, [])
328
+
329
+ await runner.run('sessionStart', { messageCount: 0 })
330
+
331
+ expect(order).toEqual(['a', 'b', 'c'])
332
+ })
333
+
334
+ it('updates providers at runtime', async () => {
335
+ const savedFacts: Fact[][] = []
336
+ const hooks: HooksConfig = {
337
+ sessionEnd: [{ action: 'summarize', provider: 'brain' }],
338
+ }
339
+ const runner = new HookRunner(hooks, [])
340
+
341
+ // First run — no providers, summarize should skip
342
+ await runner.run('sessionEnd', {
343
+ messageCount: 3,
344
+ messages: [{ role: 'user', content: 'test' }],
345
+ })
346
+ expect(savedFacts.length).toBe(0)
347
+
348
+ // Add provider
349
+ runner.updateProviders([createMockProvider('brain', { savedFacts })])
350
+
351
+ // Second run — provider available, should save
352
+ await runner.run('sessionEnd', {
353
+ messageCount: 3,
354
+ messages: [{ role: 'user', content: 'test' }],
355
+ })
356
+ expect(savedFacts.length).toBe(1)
357
+ })
358
+ })
359
+
360
+ // ═══════════════════════════════════════════════════════════════════
361
+ // Action: boost — recency-based score adjustment
362
+ // ═══════════════════════════════════════════════════════════════════
363
+
364
+ describe('action: boost', () => {
365
+ it('boosts recent results', async () => {
366
+ const hooks: HooksConfig = {
367
+ afterRecall: [
368
+ {
369
+ action: 'boost',
370
+ provider: 'engram',
371
+ recencyWindow: '24h',
372
+ factor: 1.5,
373
+ },
374
+ ],
375
+ }
376
+ const runner = new HookRunner(hooks, [createMockProvider('engram')])
377
+
378
+ const recentDate = new Date(Date.now() - 3_600_000) // 1h ago
379
+ const context: HookContext = {
380
+ messageCount: 0,
381
+ results: [
382
+ { content: 'Old fact', score: 0.8, source: 'engram', storedAt: new Date('2020-01-01') },
383
+ { content: 'Recent fact', score: 0.6, source: 'engram', storedAt: recentDate },
384
+ ],
385
+ }
386
+
387
+ await runner.run('afterRecall', context)
388
+
389
+ // Recent fact should be boosted (0.6 * 1.5 = 0.9)
390
+ const recentResult = context.results!.find((r) => r.content === 'Recent fact')!
391
+ expect(recentResult.score).toBeCloseTo(0.9)
392
+
393
+ // Old fact should be unchanged
394
+ const oldResult = context.results!.find((r) => r.content === 'Old fact')!
395
+ expect(oldResult.score).toBe(0.8)
396
+ })
397
+
398
+ it('re-sorts after boosting', async () => {
399
+ const hooks: HooksConfig = {
400
+ afterRecall: [
401
+ {
402
+ action: 'boost',
403
+ factor: 1.5,
404
+ recencyWindow: '24h',
405
+ },
406
+ ],
407
+ }
408
+ const runner = new HookRunner(hooks, [])
409
+
410
+ const context: HookContext = {
411
+ messageCount: 0,
412
+ results: [
413
+ { content: 'High but old', score: 0.9, source: 'brain', storedAt: new Date('2020-01-01') },
414
+ { content: 'Low but recent', score: 0.5, source: 'engram', storedAt: new Date() },
415
+ ],
416
+ }
417
+
418
+ await runner.run('afterRecall', context)
419
+
420
+ // Low-but-recent (0.5 * 1.5 = 0.75) is still below old (0.9)
421
+ // But with a higher factor it could overtake
422
+ expect(context.results![0].content).toBe('High but old')
423
+ })
424
+
425
+ it('caps boosted score at 1.0', async () => {
426
+ const hooks: HooksConfig = {
427
+ afterRecall: [
428
+ {
429
+ action: 'boost',
430
+ factor: 2.0,
431
+ recencyWindow: '24h',
432
+ },
433
+ ],
434
+ }
435
+ const runner = new HookRunner(hooks, [])
436
+
437
+ const context: HookContext = {
438
+ messageCount: 0,
439
+ results: [{ content: 'High score', score: 0.9, source: 'x', storedAt: new Date() }],
440
+ }
441
+
442
+ await runner.run('afterRecall', context)
443
+
444
+ // 0.9 * 2.0 = 1.8 → capped at 1.0
445
+ expect(context.results![0].score).toBe(1.0)
446
+ })
447
+
448
+ it('filters by provider when specified', async () => {
449
+ const hooks: HooksConfig = {
450
+ afterRecall: [
451
+ {
452
+ action: 'boost',
453
+ provider: 'engram',
454
+ factor: 1.5,
455
+ recencyWindow: '24h',
456
+ },
457
+ ],
458
+ }
459
+ const runner = new HookRunner(hooks, [])
460
+
461
+ const context: HookContext = {
462
+ messageCount: 0,
463
+ results: [
464
+ { content: 'Brain result', score: 0.7, source: 'brain', storedAt: new Date() },
465
+ { content: 'Engram result', score: 0.7, source: 'engram', storedAt: new Date() },
466
+ ],
467
+ }
468
+
469
+ await runner.run('afterRecall', context)
470
+
471
+ // Only engram should be boosted
472
+ const brainResult = context.results!.find((r) => r.source === 'brain')!
473
+ const engramResult = context.results!.find((r) => r.source === 'engram')!
474
+ expect(brainResult.score).toBe(0.7) // unchanged
475
+ expect(engramResult.score).toBeCloseTo(1.0) // 0.7 * 1.5 = 1.05 → capped
476
+ })
477
+
478
+ it('does nothing with empty results', async () => {
479
+ const hooks: HooksConfig = {
480
+ afterRecall: [{ action: 'boost', factor: 1.5 }],
481
+ }
482
+ const runner = new HookRunner(hooks, [])
483
+ const context: HookContext = { messageCount: 0, results: [] }
484
+
485
+ await runner.run('afterRecall', context)
486
+ expect(context.results).toEqual([])
487
+ })
488
+
489
+ it('defaults to factor 1.2 and window 24h', async () => {
490
+ const hooks: HooksConfig = {
491
+ afterRecall: [{ action: 'boost' }],
492
+ }
493
+ const runner = new HookRunner(hooks, [])
494
+
495
+ const context: HookContext = {
496
+ messageCount: 0,
497
+ results: [{ content: 'Recent', score: 0.5, source: 'x', storedAt: new Date() }],
498
+ }
499
+
500
+ await runner.run('afterRecall', context)
501
+ expect(context.results![0].score).toBeCloseTo(0.6) // 0.5 * 1.2
502
+ })
503
+ })
504
+
505
+ // ═══════════════════════════════════════════════════════════════════
506
+ // Action: promote — cross-provider knowledge transfer
507
+ // ═══════════════════════════════════════════════════════════════════
508
+
509
+ describe('action: promote', () => {
510
+ it('transfers via extraction using session summary', async () => {
511
+ const savedFacts: Fact[][] = []
512
+ const hooks: HooksConfig = {
513
+ afterSessionEnd: [
514
+ {
515
+ action: 'promote',
516
+ from: 'engram',
517
+ to: 'brain',
518
+ via: 'extraction',
519
+ },
520
+ ],
521
+ }
522
+ const runner = new HookRunner(hooks, [
523
+ createMockProvider('engram'),
524
+ createMockProvider('brain', { savedFacts }),
525
+ ])
526
+
527
+ await runner.run('afterSessionEnd', {
528
+ messageCount: 5,
529
+ messages: [{ role: 'user', content: 'Hello' }],
530
+ sessionSummary: 'Discussed project architecture',
531
+ })
532
+
533
+ expect(savedFacts.length).toBe(1)
534
+ expect(savedFacts[0][0].content).toBe('Discussed project architecture')
535
+ expect(savedFacts[0][0].category).toBe('session-promotion')
536
+ expect(savedFacts[0][0].tags).toContain('from:engram')
537
+ })
538
+
539
+ it('transfers via recall-save (default strategy)', async () => {
540
+ const savedFacts: Fact[][] = []
541
+ const hooks: HooksConfig = {
542
+ afterSessionEnd: [
543
+ {
544
+ action: 'promote',
545
+ from: 'engram',
546
+ to: 'brain',
547
+ },
548
+ ],
549
+ }
550
+ const runner = new HookRunner(hooks, [
551
+ createMockProvider('engram', {
552
+ recallResults: [
553
+ { content: 'Fact from engram session', score: 0.9 },
554
+ { content: 'Another engram fact', score: 0.8 },
555
+ ],
556
+ }),
557
+ createMockProvider('brain', { savedFacts }),
558
+ ])
559
+
560
+ await runner.run('afterSessionEnd', { messageCount: 3 })
561
+
562
+ expect(savedFacts.length).toBe(1)
563
+ expect(savedFacts[0]).toHaveLength(2)
564
+ expect(savedFacts[0][0].content).toBe('Fact from engram session')
565
+ expect(savedFacts[0][0].tags).toContain('promoted')
566
+ })
567
+
568
+ it('skips when from/to not specified', async () => {
569
+ const savedFacts: Fact[][] = []
570
+ const hooks: HooksConfig = {
571
+ afterSessionEnd: [{ action: 'promote', from: 'engram' }], // missing 'to'
572
+ }
573
+ const runner = new HookRunner(hooks, [
574
+ createMockProvider('engram'),
575
+ createMockProvider('brain', { savedFacts }),
576
+ ])
577
+
578
+ await runner.run('afterSessionEnd', { messageCount: 3 })
579
+ expect(savedFacts.length).toBe(0)
580
+ })
581
+
582
+ it('skips when source provider not found', async () => {
583
+ const savedFacts: Fact[][] = []
584
+ const hooks: HooksConfig = {
585
+ afterSessionEnd: [
586
+ {
587
+ action: 'promote',
588
+ from: 'nonexistent',
589
+ to: 'brain',
590
+ },
591
+ ],
592
+ }
593
+ const runner = new HookRunner(hooks, [createMockProvider('brain', { savedFacts })])
594
+
595
+ await runner.run('afterSessionEnd', { messageCount: 3 })
596
+ expect(savedFacts.length).toBe(0)
597
+ })
598
+
599
+ it('builds summary from context when no sessionSummary', async () => {
600
+ const savedFacts: Fact[][] = []
601
+ const hooks: HooksConfig = {
602
+ afterSessionEnd: [
603
+ {
604
+ action: 'promote',
605
+ from: 'engram',
606
+ to: 'brain',
607
+ via: 'extraction',
608
+ },
609
+ ],
610
+ }
611
+ const runner = new HookRunner(hooks, [
612
+ createMockProvider('engram'),
613
+ createMockProvider('brain', { savedFacts }),
614
+ ])
615
+
616
+ await runner.run('afterSessionEnd', {
617
+ messageCount: 7,
618
+ messages: [{ role: 'user', content: 'test' }],
619
+ topics: ['architecture', 'memory'],
620
+ })
621
+
622
+ expect(savedFacts[0][0].content).toContain('7 messages')
623
+ expect(savedFacts[0][0].content).toContain('architecture')
624
+ })
625
+ })
626
+
627
+ // ═══════════════════════════════════════════════════════════════════
628
+ // Action: context — pre-load context at session start
629
+ // ═══════════════════════════════════════════════════════════════════
630
+
631
+ describe('action: context', () => {
632
+ it('pre-loads results from a provider', async () => {
633
+ const hooks: HooksConfig = {
634
+ sessionStart: [
635
+ {
636
+ action: 'context',
637
+ provider: 'engram',
638
+ },
639
+ ],
640
+ }
641
+ const runner = new HookRunner(hooks, [
642
+ createMockProvider('engram', {
643
+ recallResults: [
644
+ { content: 'Previous session context', score: 0.8 },
645
+ { content: 'Recent activity', score: 0.7 },
646
+ ],
647
+ }),
648
+ ])
649
+
650
+ const context: HookContext = { messageCount: 0 }
651
+ await runner.run('sessionStart', context)
652
+
653
+ expect(context.results).toHaveLength(2)
654
+ expect(context.results![0].content).toBe('Previous session context')
655
+ })
656
+
657
+ it('appends to existing results', async () => {
658
+ const hooks: HooksConfig = {
659
+ sessionStart: [
660
+ {
661
+ action: 'context',
662
+ provider: 'engram',
663
+ },
664
+ ],
665
+ }
666
+ const runner = new HookRunner(hooks, [
667
+ createMockProvider('engram', {
668
+ recallResults: [{ content: 'New', score: 0.8 }],
669
+ }),
670
+ ])
671
+
672
+ const context: HookContext = {
673
+ messageCount: 0,
674
+ results: [{ content: 'Existing', score: 0.9 }],
675
+ }
676
+ await runner.run('sessionStart', context)
677
+
678
+ expect(context.results).toHaveLength(2)
679
+ expect(context.results![0].content).toBe('Existing')
680
+ expect(context.results![1].content).toBe('New')
681
+ })
682
+
683
+ it('skips when provider not specified', async () => {
684
+ const hooks: HooksConfig = {
685
+ sessionStart: [{ action: 'context' }], // missing provider
686
+ }
687
+ const runner = new HookRunner(hooks, [createMockProvider('engram')])
688
+
689
+ const context: HookContext = { messageCount: 0 }
690
+ await runner.run('sessionStart', context)
691
+
692
+ expect(context.results).toBeUndefined()
693
+ })
694
+
695
+ it('handles provider with no results', async () => {
696
+ const hooks: HooksConfig = {
697
+ sessionStart: [
698
+ {
699
+ action: 'context',
700
+ provider: 'engram',
701
+ },
702
+ ],
703
+ }
704
+ const runner = new HookRunner(hooks, [createMockProvider('engram', { recallResults: [] })])
705
+
706
+ const context: HookContext = { messageCount: 0 }
707
+ await runner.run('sessionStart', context)
708
+
709
+ expect(context.results).toBeUndefined()
710
+ })
711
+ })
712
+
713
+ // ═══════════════════════════════════════════════════════════════════
714
+ // Action: summarize — session summary generation
715
+ // ═══════════════════════════════════════════════════════════════════
716
+
717
+ describe('action: summarize', () => {
718
+ it('saves session summary to provider', async () => {
719
+ const savedFacts: Fact[][] = []
720
+ const hooks: HooksConfig = {
721
+ sessionEnd: [
722
+ {
723
+ action: 'summarize',
724
+ provider: 'brain',
725
+ },
726
+ ],
727
+ }
728
+ const runner = new HookRunner(hooks, [createMockProvider('brain', { savedFacts })])
729
+
730
+ await runner.run('sessionEnd', {
731
+ messageCount: 10,
732
+ messages: [{ role: 'user', content: 'Hello' }],
733
+ sessionSummary: 'Detailed session analysis',
734
+ })
735
+
736
+ expect(savedFacts.length).toBe(1)
737
+ expect(savedFacts[0][0].content).toBe('Detailed session analysis')
738
+ expect(savedFacts[0][0].category).toBe('session-summary')
739
+ expect(savedFacts[0][0].tags).toContain('summary')
740
+ })
741
+
742
+ it('builds summary from context when no sessionSummary', async () => {
743
+ const savedFacts: Fact[][] = []
744
+ const hooks: HooksConfig = {
745
+ sessionEnd: [
746
+ {
747
+ action: 'summarize',
748
+ provider: 'brain',
749
+ },
750
+ ],
751
+ }
752
+ const runner = new HookRunner(hooks, [createMockProvider('brain', { savedFacts })])
753
+
754
+ await runner.run('sessionEnd', {
755
+ messageCount: 5,
756
+ messages: [{ role: 'user', content: 'test' }],
757
+ topics: ['hooks', 'memory'],
758
+ })
759
+
760
+ expect(savedFacts[0][0].content).toContain('5 messages')
761
+ expect(savedFacts[0][0].content).toContain('hooks')
762
+ })
763
+
764
+ it('updates context.sessionSummary for downstream hooks', async () => {
765
+ const hooks: HooksConfig = {
766
+ sessionEnd: [
767
+ {
768
+ action: 'summarize',
769
+ provider: 'brain',
770
+ },
771
+ ],
772
+ }
773
+ const runner = new HookRunner(hooks, [createMockProvider('brain')])
774
+
775
+ const context: HookContext = {
776
+ messageCount: 3,
777
+ messages: [{ role: 'user', content: 'test' }],
778
+ }
779
+ await runner.run('sessionEnd', context)
780
+
781
+ expect(context.sessionSummary).toBeDefined()
782
+ expect(context.sessionSummary).toContain('3 messages')
783
+ })
784
+
785
+ it('skips when no messages', async () => {
786
+ const savedFacts: Fact[][] = []
787
+ const hooks: HooksConfig = {
788
+ sessionEnd: [{ action: 'summarize', provider: 'brain' }],
789
+ }
790
+ const runner = new HookRunner(hooks, [createMockProvider('brain', { savedFacts })])
791
+
792
+ await runner.run('sessionEnd', { messageCount: 0 })
793
+
794
+ expect(savedFacts.length).toBe(0)
795
+ })
796
+ })
797
+
798
+ // ═════════════════════════════════════════════════════════════════════
799
+ // Action: summarize — LLM-powered summarization
800
+ // ═════════════════════════════════════════════════════════════════════
801
+
802
+ describe('action: summarize (LLM)', () => {
803
+ it('uses LLM when available for rich summary', async () => {
804
+ const savedFacts: Fact[][] = []
805
+ const mockLlm: LLMFunction = async (prompt: string) => {
806
+ expect(prompt).toContain('user: Hello world')
807
+ return '- [Decision] Decided to use hooks for memory lifecycle'
808
+ }
809
+
810
+ const hooks: HooksConfig = {
811
+ sessionEnd: [{ action: 'summarize', provider: 'brain' }],
812
+ }
813
+ const runner = new HookRunner(hooks, [createMockProvider('brain', { savedFacts })], {
814
+ llm: mockLlm,
815
+ })
816
+
817
+ const context: HookContext = {
818
+ messageCount: 4,
819
+ messages: [
820
+ { role: 'user', content: 'Hello world' },
821
+ { role: 'assistant', content: 'Hi there' },
822
+ { role: 'user', content: 'Lets decide on hooks' },
823
+ { role: 'assistant', content: 'Good idea' },
824
+ ],
825
+ }
826
+ await runner.run('sessionEnd', context)
827
+
828
+ expect(savedFacts.length).toBe(1)
829
+ expect(savedFacts[0][0].content).toBe('- [Decision] Decided to use hooks for memory lifecycle')
830
+ expect(savedFacts[0][0].tags).toContain('llm-generated')
831
+ expect(savedFacts[0][0].importance).toBe(7)
832
+ expect(context.sessionSummary).toBe('- [Decision] Decided to use hooks for memory lifecycle')
833
+ })
834
+
835
+ it('falls back to basic summary when LLM fails', async () => {
836
+ const savedFacts: Fact[][] = []
837
+ const failingLlm: LLMFunction = async () => {
838
+ throw new Error('API rate limited')
839
+ }
840
+
841
+ const hooks: HooksConfig = {
842
+ sessionEnd: [{ action: 'summarize', provider: 'brain' }],
843
+ }
844
+ const runner = new HookRunner(hooks, [createMockProvider('brain', { savedFacts })], {
845
+ llm: failingLlm,
846
+ })
847
+
848
+ await runner.run('sessionEnd', {
849
+ messageCount: 3,
850
+ messages: [{ role: 'user', content: 'test' }],
851
+ topics: ['hooks', 'memory'],
852
+ })
853
+
854
+ expect(savedFacts[0][0].content).toContain('3 messages')
855
+ expect(savedFacts[0][0].content).toContain('hooks')
856
+ expect(savedFacts[0][0].tags).toContain('auto-generated')
857
+ expect(savedFacts[0][0].importance).toBe(5)
858
+ })
859
+
860
+ it('falls back to basic summary when LLM returns empty', async () => {
861
+ const savedFacts: Fact[][] = []
862
+ const emptyLlm: LLMFunction = async () => ' '
863
+
864
+ const hooks: HooksConfig = {
865
+ sessionEnd: [{ action: 'summarize', provider: 'brain' }],
866
+ }
867
+ const runner = new HookRunner(hooks, [createMockProvider('brain', { savedFacts })], {
868
+ llm: emptyLlm,
869
+ })
870
+
871
+ await runner.run('sessionEnd', {
872
+ messageCount: 5,
873
+ messages: [{ role: 'user', content: 'test' }],
874
+ })
875
+
876
+ expect(savedFacts[0][0].content).toContain('5 messages')
877
+ })
878
+
879
+ it('skips LLM for very short sessions (< 2 messages)', async () => {
880
+ const savedFacts: Fact[][] = []
881
+ let llmCalled = false
882
+ const mockLlm: LLMFunction = async () => {
883
+ llmCalled = true
884
+ return 'should not be called'
885
+ }
886
+
887
+ const hooks: HooksConfig = {
888
+ sessionEnd: [{ action: 'summarize', provider: 'brain' }],
889
+ }
890
+ const runner = new HookRunner(hooks, [createMockProvider('brain', { savedFacts })], {
891
+ llm: mockLlm,
892
+ })
893
+
894
+ await runner.run('sessionEnd', {
895
+ messageCount: 1,
896
+ messages: [{ role: 'user', content: 'hi' }],
897
+ })
898
+
899
+ expect(llmCalled).toBe(false)
900
+ expect(savedFacts[0][0].content).toContain('1 messages')
901
+ })
902
+
903
+ it('injects LLM into context for all hooks in event', async () => {
904
+ let contextHadLlm = false
905
+ registerAction('check-llm', async (_hook, ctx) => {
906
+ contextHadLlm = !!ctx.llm
907
+ })
908
+
909
+ const hooks: HooksConfig = {
910
+ sessionStart: [{ action: 'check-llm' }],
911
+ }
912
+ const mockLlm: LLMFunction = async () => 'test'
913
+ const runner = new HookRunner(hooks, [], { llm: mockLlm })
914
+
915
+ await runner.run('sessionStart', { messageCount: 0 })
916
+
917
+ expect(contextHadLlm).toBe(true)
918
+ })
919
+
920
+ it('does not override context.llm if already present', async () => {
921
+ const originalLlm: LLMFunction = async () => 'original'
922
+ const runnerLlm: LLMFunction = async () => 'runner'
923
+ let usedLlm: string | undefined
924
+
925
+ registerAction('check-which-llm', async (_hook, ctx) => {
926
+ usedLlm = ctx.llm ? await ctx.llm('test') : undefined
927
+ })
928
+
929
+ const hooks: HooksConfig = {
930
+ sessionStart: [{ action: 'check-which-llm' }],
931
+ }
932
+ const runner = new HookRunner(hooks, [], { llm: runnerLlm })
933
+
934
+ await runner.run('sessionStart', { messageCount: 0, llm: originalLlm })
935
+
936
+ expect(usedLlm).toBe('original')
937
+ })
938
+
939
+ it('setLLM updates the function at runtime', async () => {
940
+ const savedFacts: Fact[][] = []
941
+ const hooks: HooksConfig = {
942
+ sessionEnd: [{ action: 'summarize', provider: 'brain' }],
943
+ }
944
+ const runner = new HookRunner(hooks, [createMockProvider('brain', { savedFacts })])
945
+
946
+ // Without LLM — basic summary
947
+ await runner.run('sessionEnd', {
948
+ messageCount: 3,
949
+ messages: [
950
+ { role: 'user', content: 'test' },
951
+ { role: 'assistant', content: 'ok' },
952
+ { role: 'user', content: 'done' },
953
+ ],
954
+ })
955
+ expect(savedFacts[0][0].content).toContain('3 messages')
956
+
957
+ // Set LLM
958
+ runner.setLLM(async () => 'Rich LLM summary of session')
959
+
960
+ // With LLM — rich summary
961
+ await runner.run('sessionEnd', {
962
+ messageCount: 3,
963
+ messages: [
964
+ { role: 'user', content: 'test' },
965
+ { role: 'assistant', content: 'ok' },
966
+ { role: 'user', content: 'done' },
967
+ ],
968
+ })
969
+ expect(savedFacts[1][0].content).toBe('Rich LLM summary of session')
970
+ })
971
+ })
972
+
973
+ // ═════════════════════════════════════════════════════════════════════
974
+ // Action: promote via 'full' — summary + individual observations
975
+ // ═════════════════════════════════════════════════════════════════════
976
+
977
+ describe('action: promote via full', () => {
978
+ it('parses LLM summary lines into individual facts + source observations', async () => {
979
+ const savedFacts: Fact[][] = []
980
+ const hooks: HooksConfig = {
981
+ afterSessionEnd: [
982
+ {
983
+ action: 'promote',
984
+ from: 'engram',
985
+ to: 'brain',
986
+ via: 'full',
987
+ },
988
+ ],
989
+ }
990
+ const runner = new HookRunner(hooks, [
991
+ createMockProvider('engram', {
992
+ recallResults: [
993
+ { content: 'Observation about hooks design', score: 0.9 },
994
+ { content: 'Bug found in mapper casing', score: 0.8 },
995
+ ],
996
+ }),
997
+ createMockProvider('brain', { savedFacts }),
998
+ ])
999
+
1000
+ await runner.run('afterSessionEnd', {
1001
+ messageCount: 10,
1002
+ messages: [{ role: 'user', content: 'test' }],
1003
+ sessionSummary:
1004
+ 'DECISION: Use LLM for summarization\nINFO: Brain stores long-term facts\nPROBLEM: Empty responses from CLI -> fixed with no-tools instruction',
1005
+ topics: ['hooks'],
1006
+ })
1007
+
1008
+ expect(savedFacts.length).toBe(1)
1009
+ const facts = savedFacts[0]
1010
+
1011
+ // 3 parsed from summary + 2 from source recall (deduped across topic queries)
1012
+ expect(facts.length).toBe(5)
1013
+
1014
+ // Parsed facts have mapped categories
1015
+ expect(facts[0].category).toBe('decision')
1016
+ expect(facts[0].content).toBe('Use LLM for summarization')
1017
+ expect(facts[0].importance).toBe(7)
1018
+ expect(facts[0].tags).toContain('decision')
1019
+
1020
+ expect(facts[1].category).toBe('observation')
1021
+ expect(facts[1].content).toBe('Brain stores long-term facts')
1022
+ expect(facts[1].tags).toContain('info')
1023
+
1024
+ expect(facts[2].category).toBe('learning')
1025
+ expect(facts[2].content).toContain('Empty responses from CLI')
1026
+ expect(facts[2].tags).toContain('problem')
1027
+
1028
+ // Source observations
1029
+ expect(facts[3].content).toBe('Observation about hooks design')
1030
+ expect(facts[4].content).toBe('Bug found in mapper casing')
1031
+ })
1032
+
1033
+ it('deduplicates identical observations across topics', async () => {
1034
+ const savedFacts: Fact[][] = []
1035
+ const hooks: HooksConfig = {
1036
+ afterSessionEnd: [
1037
+ {
1038
+ action: 'promote',
1039
+ from: 'engram',
1040
+ to: 'brain',
1041
+ via: 'full',
1042
+ },
1043
+ ],
1044
+ }
1045
+ const runner = new HookRunner(hooks, [
1046
+ createMockProvider('engram', {
1047
+ recallResults: [{ content: 'Same observation returned for all queries', score: 0.9 }],
1048
+ }),
1049
+ createMockProvider('brain', { savedFacts }),
1050
+ ])
1051
+
1052
+ await runner.run('afterSessionEnd', {
1053
+ messageCount: 5,
1054
+ messages: [{ role: 'user', content: 'test' }],
1055
+ sessionSummary: 'Session summary',
1056
+ topics: ['topic1', 'topic2', 'topic3'],
1057
+ })
1058
+
1059
+ const facts = savedFacts[0]
1060
+ // 1 unique observation (deduped across 3 topic queries)
1061
+ // No parsed lines since sessionSummary is not in PREFIX: format
1062
+ expect(facts.length).toBe(1)
1063
+ })
1064
+
1065
+ it('caps promoted facts at MAX_PROMOTED (10)', async () => {
1066
+ const savedFacts: Fact[][] = []
1067
+ // Create provider that returns many results per query
1068
+ let queryCount = 0
1069
+ const manyResultsProvider: MemoryProvider = {
1070
+ ...createMockProvider('engram'),
1071
+ async recall() {
1072
+ queryCount++
1073
+ return Array.from({ length: 3 }, (_, i) => ({
1074
+ content: `Observation ${queryCount}-${i}`,
1075
+ score: 0.8,
1076
+ }))
1077
+ },
1078
+ }
1079
+
1080
+ const hooks: HooksConfig = {
1081
+ afterSessionEnd: [
1082
+ {
1083
+ action: 'promote',
1084
+ from: 'engram',
1085
+ to: 'brain',
1086
+ via: 'full',
1087
+ },
1088
+ ],
1089
+ }
1090
+ const runner = new HookRunner(hooks, [
1091
+ manyResultsProvider,
1092
+ createMockProvider('brain', { savedFacts }),
1093
+ ])
1094
+
1095
+ await runner.run('afterSessionEnd', {
1096
+ messageCount: 20,
1097
+ messages: [{ role: 'user', content: 'test' }],
1098
+ sessionSummary: 'Summary',
1099
+ topics: ['t1', 't2', 't3', 't4', 't5'],
1100
+ })
1101
+
1102
+ expect(savedFacts[0].length).toBeLessThanOrEqual(10)
1103
+ })
1104
+
1105
+ it('parses summary lines even without topics', async () => {
1106
+ const savedFacts: Fact[][] = []
1107
+ const hooks: HooksConfig = {
1108
+ afterSessionEnd: [
1109
+ {
1110
+ action: 'promote',
1111
+ from: 'engram',
1112
+ to: 'brain',
1113
+ via: 'full',
1114
+ },
1115
+ ],
1116
+ }
1117
+ const runner = new HookRunner(hooks, [
1118
+ createMockProvider('engram'),
1119
+ createMockProvider('brain', { savedFacts }),
1120
+ ])
1121
+
1122
+ await runner.run('afterSessionEnd', {
1123
+ messageCount: 3,
1124
+ messages: [{ role: 'user', content: 'test' }],
1125
+ sessionSummary: 'DECISION: Deploy to production\nINFO: Version 2.0 released',
1126
+ })
1127
+
1128
+ expect(savedFacts.length).toBe(1)
1129
+ expect(savedFacts[0].length).toBe(2)
1130
+ expect(savedFacts[0][0].category).toBe('decision')
1131
+ expect(savedFacts[0][1].category).toBe('observation')
1132
+ })
1133
+
1134
+ it('saves nothing when summary has no parseable lines and no topics', async () => {
1135
+ const savedFacts: Fact[][] = []
1136
+ const hooks: HooksConfig = {
1137
+ afterSessionEnd: [
1138
+ {
1139
+ action: 'promote',
1140
+ from: 'engram',
1141
+ to: 'brain',
1142
+ via: 'full',
1143
+ },
1144
+ ],
1145
+ }
1146
+ const runner = new HookRunner(hooks, [
1147
+ createMockProvider('engram'),
1148
+ createMockProvider('brain', { savedFacts }),
1149
+ ])
1150
+
1151
+ await runner.run('afterSessionEnd', {
1152
+ messageCount: 3,
1153
+ messages: [{ role: 'user', content: 'test' }],
1154
+ sessionSummary: 'Just a plain text summary without prefixes',
1155
+ })
1156
+
1157
+ // No parseable lines, no topics to search
1158
+ expect(savedFacts.length).toBe(0)
1159
+ })
1160
+
1161
+ it('continues when source recall fails for a topic', async () => {
1162
+ const savedFacts: Fact[][] = []
1163
+ let callCount = 0
1164
+ const flakeyProvider: MemoryProvider = {
1165
+ ...createMockProvider('engram'),
1166
+ async recall() {
1167
+ callCount++
1168
+ if (callCount === 1) throw new Error('Connection reset')
1169
+ return [{ content: 'Valid observation', score: 0.8 }]
1170
+ },
1171
+ }
1172
+
1173
+ const hooks: HooksConfig = {
1174
+ afterSessionEnd: [
1175
+ {
1176
+ action: 'promote',
1177
+ from: 'engram',
1178
+ to: 'brain',
1179
+ via: 'full',
1180
+ },
1181
+ ],
1182
+ }
1183
+ const runner = new HookRunner(hooks, [
1184
+ flakeyProvider,
1185
+ createMockProvider('brain', { savedFacts }),
1186
+ ])
1187
+
1188
+ await runner.run('afterSessionEnd', {
1189
+ messageCount: 5,
1190
+ messages: [{ role: 'user', content: 'test' }],
1191
+ sessionSummary: 'Summary',
1192
+ topics: ['fails', 'works'],
1193
+ })
1194
+
1195
+ // 1 summary + observations from successful topic query
1196
+ expect(savedFacts[0].length).toBeGreaterThanOrEqual(1)
1197
+ })
1198
+ })
1199
+
1200
+ // ═══════════════════════════════════════════════════════════════════
1201
+ // Integration: multi-hook chains
1202
+ // ═══════════════════════════════════════════════════════════════════
1203
+
1204
+ describe('multi-hook chains', () => {
1205
+ it('summarize → promote chain at session end', async () => {
1206
+ const brainFacts: Fact[][] = []
1207
+ const hooks: HooksConfig = {
1208
+ sessionEnd: [{ action: 'summarize', provider: 'engram' }],
1209
+ afterSessionEnd: [
1210
+ {
1211
+ action: 'promote',
1212
+ from: 'engram',
1213
+ to: 'brain',
1214
+ via: 'extraction',
1215
+ when: { providersAvailable: ['engram', 'brain'], minMessages: 3 },
1216
+ },
1217
+ ],
1218
+ }
1219
+ const runner = new HookRunner(hooks, [
1220
+ createMockProvider('engram'),
1221
+ createMockProvider('brain', { savedFacts: brainFacts }),
1222
+ ])
1223
+
1224
+ // sessionEnd: summarize creates summary in context
1225
+ const ctx: HookContext = {
1226
+ messageCount: 5,
1227
+ messages: [{ role: 'user', content: 'test' }],
1228
+ }
1229
+ await runner.run('sessionEnd', ctx)
1230
+ expect(ctx.sessionSummary).toBeDefined()
1231
+
1232
+ // afterSessionEnd: promote uses the summary
1233
+ await runner.run('afterSessionEnd', {
1234
+ ...ctx,
1235
+ messages: [{ role: 'user', content: 'test' }],
1236
+ })
1237
+ expect(brainFacts.length).toBe(1)
1238
+ expect(brainFacts[0][0].content).toContain('5 messages')
1239
+ })
1240
+
1241
+ it('context → boost chain (sessionStart + afterRecall)', async () => {
1242
+ const hooks: HooksConfig = {
1243
+ sessionStart: [{ action: 'context', provider: 'engram' }],
1244
+ afterRecall: [{ action: 'boost', provider: 'engram', factor: 1.3, recencyWindow: '1h' }],
1245
+ }
1246
+ const runner = new HookRunner(hooks, [
1247
+ createMockProvider('engram', {
1248
+ recallResults: [{ content: 'Warm context', score: 0.7, storedAt: new Date() }],
1249
+ }),
1250
+ ])
1251
+
1252
+ // sessionStart: context pre-loads
1253
+ const startCtx: HookContext = { messageCount: 0 }
1254
+ await runner.run('sessionStart', startCtx)
1255
+ expect(startCtx.results).toHaveLength(1)
1256
+
1257
+ // afterRecall: boost adjusts scores
1258
+ const recallCtx: HookContext = {
1259
+ messageCount: 0,
1260
+ results: [
1261
+ { content: 'From recall', score: 0.8, source: 'engram', storedAt: new Date() },
1262
+ { content: 'Old recall', score: 0.5, source: 'brain' },
1263
+ ],
1264
+ }
1265
+ await runner.run('afterRecall', recallCtx)
1266
+
1267
+ // Only engram result with recent storedAt should be boosted
1268
+ const engramResult = recallCtx.results!.find((r) => r.source === 'engram')!
1269
+ expect(engramResult.score).toBeGreaterThan(0.8)
1270
+ })
1271
+ })
1272
+
1273
+ // ════════════════════════════════════════════════════════════
1274
+ // New actions: episode, journal, experience, reconcile, episodeClose
1275
+ // ════════════════════════════════════════════════════════════
1276
+
1277
+ describe('action: episode', () => {
1278
+ it('calls getOrCreateEpisode and sets context.episodeId', async () => {
1279
+ const brainProvider = createMockProvider('brain')
1280
+ ;(brainProvider as any).getOrCreateEpisode = async (date: string, channel?: string) => ({
1281
+ id: 'ep-123',
1282
+ sessionDate: date,
1283
+ channel,
1284
+ })
1285
+
1286
+ const hooks: HooksConfig = {
1287
+ sessionStart: [{ action: 'episode', provider: 'brain' }],
1288
+ }
1289
+ const runner = new HookRunner(hooks, [brainProvider])
1290
+
1291
+ const context: HookContext = {
1292
+ messageCount: 0,
1293
+ channel: 'research',
1294
+ sessionDate: '2026-03-01',
1295
+ }
1296
+ const result = await runner.run('sessionStart', context)
1297
+
1298
+ expect(result.episodeId).toBe('ep-123')
1299
+ expect(result.sessionDate).toBe('2026-03-01')
1300
+ expect(result.createdFactIds).toEqual([])
1301
+ })
1302
+
1303
+ it('skips when provider has no getOrCreateEpisode', async () => {
1304
+ const provider = createMockProvider('basic')
1305
+ const hooks: HooksConfig = {
1306
+ sessionStart: [{ action: 'episode', provider: 'basic' }],
1307
+ }
1308
+ const runner = new HookRunner(hooks, [provider])
1309
+
1310
+ const context: HookContext = { messageCount: 0 }
1311
+ const result = await runner.run('sessionStart', context)
1312
+
1313
+ expect(result.episodeId).toBeUndefined()
1314
+ })
1315
+ })
1316
+
1317
+ describe('action: journal', () => {
1318
+ it('generates journal entry via LLM and saves to provider', async () => {
1319
+ const savedFacts: any[] = []
1320
+ const provider = createMockProvider('brain')
1321
+ provider.save = async (facts) => {
1322
+ savedFacts.push(...facts)
1323
+ return ['fact-j-1']
1324
+ }
1325
+
1326
+ const hooks: HooksConfig = {
1327
+ sessionEnd: [{ action: 'journal', provider: 'brain' }],
1328
+ }
1329
+ const llm = async (prompt: string) => 'Hoy aprendí algo importante sobre la arquitectura.'
1330
+ const runner = new HookRunner(hooks, [provider], { llm })
1331
+
1332
+ const context: HookContext = {
1333
+ messageCount: 5,
1334
+ messages: [
1335
+ { role: 'user', content: 'vamos a diseñar la arquitectura' },
1336
+ { role: 'assistant', content: 'vale, empecemos por el core' },
1337
+ ],
1338
+ episodeId: 'ep-123',
1339
+ sessionDate: '2026-03-01',
1340
+ }
1341
+ const result = await runner.run('sessionEnd', context)
1342
+
1343
+ expect(savedFacts).toHaveLength(1)
1344
+ expect(savedFacts[0].domain).toBe('self')
1345
+ expect(savedFacts[0].category).toBe('journal')
1346
+ expect(savedFacts[0].episodeId).toBe('ep-123')
1347
+ expect(savedFacts[0].sessionDate).toBe('2026-03-01')
1348
+ expect(result.createdFactIds).toContain('fact-j-1')
1349
+ })
1350
+
1351
+ it('skips when LLM is not available', async () => {
1352
+ const provider = createMockProvider('brain')
1353
+ const hooks: HooksConfig = {
1354
+ sessionEnd: [{ action: 'journal', provider: 'brain' }],
1355
+ }
1356
+ const runner = new HookRunner(hooks, [provider])
1357
+
1358
+ const context: HookContext = {
1359
+ messageCount: 5,
1360
+ messages: [
1361
+ { role: 'user', content: 'hello' },
1362
+ { role: 'assistant', content: 'hi' },
1363
+ ],
1364
+ }
1365
+ const result = await runner.run('sessionEnd', context)
1366
+ expect(result.createdFactIds).toBeUndefined()
1367
+ })
1368
+ })
1369
+
1370
+ describe('action: experience', () => {
1371
+ it('generates experience entry via LLM and saves to provider', async () => {
1372
+ const savedFacts: any[] = []
1373
+ const provider = createMockProvider('brain')
1374
+ provider.save = async (facts) => {
1375
+ savedFacts.push(...facts)
1376
+ return ['fact-e-1']
1377
+ }
1378
+
1379
+ const hooks: HooksConfig = {
1380
+ sessionEnd: [{ action: 'experience', provider: 'brain' }],
1381
+ }
1382
+ const llm = async (prompt: string) => 'Sesión cálida y enfocada.'
1383
+ const runner = new HookRunner(hooks, [provider], { llm })
1384
+
1385
+ const context: HookContext = {
1386
+ messageCount: 5,
1387
+ messages: [
1388
+ { role: 'user', content: 'vamos a diseñar la arquitectura' },
1389
+ { role: 'assistant', content: 'vale, empecemos por el core' },
1390
+ ],
1391
+ episodeId: 'ep-123',
1392
+ sessionDate: '2026-03-01',
1393
+ }
1394
+ const result = await runner.run('sessionEnd', context)
1395
+
1396
+ expect(savedFacts).toHaveLength(1)
1397
+ expect(savedFacts[0].domain).toBe('self')
1398
+ expect(savedFacts[0].category).toBe('experience')
1399
+ expect(savedFacts[0].episodeId).toBe('ep-123')
1400
+ expect(result.createdFactIds).toContain('fact-e-1')
1401
+ })
1402
+ })
1403
+
1404
+ describe('action: reconcile', () => {
1405
+ it('parses summary lines and deduplicates against existing', async () => {
1406
+ const savedFacts: any[] = []
1407
+ const provider = createMockProvider('brain')
1408
+ provider.save = async (facts) => {
1409
+ savedFacts.push(...facts)
1410
+ return facts.map((_: any, i: number) => `fact-r-${i}`)
1411
+ }
1412
+ // Simulate existing facts returned by recall
1413
+ provider.recall = async () => [{ content: 'Mars decided to use Postgres', score: 0.9 }]
1414
+
1415
+ const hooks: HooksConfig = {
1416
+ afterSessionEnd: [{ action: 'reconcile', to: 'brain' }],
1417
+ }
1418
+ const runner = new HookRunner(hooks, [provider])
1419
+
1420
+ const context: HookContext = {
1421
+ messageCount: 10,
1422
+ sessionSummary: [
1423
+ 'DECISION: Mars decided to use Postgres',
1424
+ 'DECISION: Architecture will have 5 layers',
1425
+ 'INFO: Qwen 2.5 14B selected for local model',
1426
+ 'TECHNICAL: BrainClient needs episode methods',
1427
+ ].join('\n'),
1428
+ episodeId: 'ep-456',
1429
+ sessionDate: '2026-03-01',
1430
+ topics: ['architecture', 'brain'],
1431
+ }
1432
+ const result = await runner.run('afterSessionEnd', context)
1433
+
1434
+ // First decision should be deduped (existing contains it)
1435
+ // Remaining 3 should be saved
1436
+ expect(savedFacts).toHaveLength(3)
1437
+ expect(savedFacts.every((f: any) => f.episodeId === 'ep-456')).toBe(true)
1438
+ })
1439
+
1440
+ it('saves nothing when summary has no parseable lines', async () => {
1441
+ const provider = createMockProvider('brain')
1442
+ let saveCalled = false
1443
+ provider.save = async () => {
1444
+ saveCalled = true
1445
+ return []
1446
+ }
1447
+
1448
+ const hooks: HooksConfig = {
1449
+ afterSessionEnd: [{ action: 'reconcile', to: 'brain' }],
1450
+ }
1451
+ const runner = new HookRunner(hooks, [provider])
1452
+
1453
+ const context: HookContext = {
1454
+ messageCount: 5,
1455
+ sessionSummary: 'Just some text without prefixes',
1456
+ }
1457
+ await runner.run('afterSessionEnd', context)
1458
+
1459
+ expect(saveCalled).toBe(false)
1460
+ })
1461
+ })
1462
+
1463
+ describe('action: episodeClose', () => {
1464
+ it('calls updateEpisode with factIds and summary', async () => {
1465
+ let updateCall: any = null
1466
+ const provider = createMockProvider('brain')
1467
+ ;(provider as any).updateEpisode = async (id: string, data: any) => {
1468
+ updateCall = { id, data }
1469
+ return { id, ...data }
1470
+ }
1471
+
1472
+ const hooks: HooksConfig = {
1473
+ afterSessionEnd: [{ action: 'episodeClose', provider: 'brain' }],
1474
+ }
1475
+ const runner = new HookRunner(hooks, [provider])
1476
+
1477
+ const context: HookContext = {
1478
+ messageCount: 15,
1479
+ episodeId: 'ep-789',
1480
+ createdFactIds: ['fact-1', 'fact-2', 'fact-3'],
1481
+ sessionSummary: 'Great session about architecture',
1482
+ topics: ['architecture', 'design'],
1483
+ }
1484
+ await runner.run('afterSessionEnd', context)
1485
+
1486
+ expect(updateCall).not.toBeNull()
1487
+ expect(updateCall.id).toBe('ep-789')
1488
+ expect(updateCall.data.factIds).toEqual(['fact-1', 'fact-2', 'fact-3'])
1489
+ expect(updateCall.data.topics).toEqual(['architecture', 'design'])
1490
+ expect(updateCall.data.messageCount).toBe(15)
1491
+ })
1492
+
1493
+ it('skips when no episodeId in context', async () => {
1494
+ let updateCalled = false
1495
+ const provider = createMockProvider('brain')
1496
+ ;(provider as any).updateEpisode = async () => {
1497
+ updateCalled = true
1498
+ }
1499
+
1500
+ const hooks: HooksConfig = {
1501
+ afterSessionEnd: [{ action: 'episodeClose', provider: 'brain' }],
1502
+ }
1503
+ const runner = new HookRunner(hooks, [provider])
1504
+
1505
+ const context: HookContext = { messageCount: 5 }
1506
+ await runner.run('afterSessionEnd', context)
1507
+
1508
+ expect(updateCalled).toBe(false)
1509
+ })
1510
+ })
1511
+
1512
+ describe('full diary chain', () => {
1513
+ it('summarize → journal → experience → reconcile → episodeClose', async () => {
1514
+ const savedFacts: any[] = []
1515
+ let factCounter = 0
1516
+ let episodeUpdate: any = null
1517
+
1518
+ const brain = createMockProvider('brain')
1519
+ brain.save = async (facts) => {
1520
+ savedFacts.push(...facts)
1521
+ return facts.map(() => `fact-${++factCounter}`)
1522
+ }
1523
+ brain.recall = async () => []
1524
+ ;(brain as any).updateEpisode = async (id: string, data: any) => {
1525
+ episodeUpdate = { id, data }
1526
+ }
1527
+
1528
+ const hooks: HooksConfig = {
1529
+ sessionEnd: [
1530
+ { action: 'summarize', provider: 'brain' },
1531
+ { action: 'journal', provider: 'brain' },
1532
+ { action: 'experience', provider: 'brain' },
1533
+ ],
1534
+ afterSessionEnd: [
1535
+ { action: 'reconcile', to: 'brain' },
1536
+ { action: 'episodeClose', provider: 'brain' },
1537
+ ],
1538
+ }
1539
+
1540
+ const llm = async (prompt: string) => {
1541
+ if (prompt.includes('factual session logger')) {
1542
+ return 'DECISION: Use 5 cognitive layers\nINFO: Qwen 2.5 14B selected'
1543
+ }
1544
+ if (prompt.includes('personal journal')) {
1545
+ return 'Me sorprendió la profundidad de la conversación.'
1546
+ }
1547
+ if (prompt.includes('emotional tone')) {
1548
+ return 'Sesión intensa y reveladora.'
1549
+ }
1550
+ return ''
1551
+ }
1552
+
1553
+ const runner = new HookRunner(hooks, [brain], { llm })
1554
+
1555
+ // Run sessionEnd
1556
+ const endContext: HookContext = {
1557
+ messageCount: 20,
1558
+ messages: [
1559
+ { role: 'user', content: 'diseñemos las capas cognitivas' },
1560
+ { role: 'assistant', content: 'propongo 5 capas...' },
1561
+ ],
1562
+ episodeId: 'ep-full',
1563
+ sessionDate: '2026-03-01',
1564
+ createdFactIds: [],
1565
+ }
1566
+ const afterEnd = await runner.run('sessionEnd', endContext)
1567
+
1568
+ // Should have: 1 summary + 1 journal + 1 experience = 3 facts
1569
+ expect(savedFacts).toHaveLength(3)
1570
+ expect(savedFacts[0].category).toBe('session-summary')
1571
+ expect(savedFacts[1].category).toBe('journal')
1572
+ expect(savedFacts[2].category).toBe('experience')
1573
+
1574
+ // Run afterSessionEnd with the carried context
1575
+ const afterContext: HookContext = {
1576
+ ...afterEnd,
1577
+ createdFactIds: afterEnd.createdFactIds ?? [],
1578
+ }
1579
+ const finalResult = await runner.run('afterSessionEnd', afterContext)
1580
+
1581
+ // Reconcile should have saved 2 more (DECISION + INFO from summary)
1582
+ expect(savedFacts.length).toBeGreaterThanOrEqual(4)
1583
+
1584
+ // EpisodeClose should have been called
1585
+ expect(episodeUpdate).not.toBeNull()
1586
+ expect(episodeUpdate.id).toBe('ep-full')
1587
+ expect(episodeUpdate.data.factIds.length).toBeGreaterThan(0)
1588
+ })
1589
+ })
1590
+
1591
+ describe('action: diaryRecall', () => {
1592
+ it('loads self facts and summaries by date, prepends to context.results', async () => {
1593
+ const provider = createMockProvider('brain')
1594
+ ;(provider as any).queryFacts = async (filters: any) => {
1595
+ if (filters.domain === 'self') {
1596
+ return [
1597
+ {
1598
+ id: 'j1',
1599
+ content: 'Me sorprendió la profundidad',
1600
+ domain: 'self',
1601
+ category: 'journal',
1602
+ episodeId: 'ep1',
1603
+ createdAt: '2026-03-01T10:00:00Z',
1604
+ importance: 7,
1605
+ },
1606
+ {
1607
+ id: 'e1',
1608
+ content: 'Sesión cálida',
1609
+ domain: 'self',
1610
+ category: 'experience',
1611
+ episodeId: 'ep1',
1612
+ createdAt: '2026-03-01T10:00:00Z',
1613
+ importance: 6,
1614
+ },
1615
+ ]
1616
+ }
1617
+ if (filters.category === 'session-summary') {
1618
+ return [
1619
+ {
1620
+ id: 's1',
1621
+ content: 'Implemented diary spec',
1622
+ domain: 'session',
1623
+ category: 'session-summary',
1624
+ episodeId: 'ep1',
1625
+ createdAt: '2026-03-01T10:00:00Z',
1626
+ importance: 7,
1627
+ },
1628
+ ]
1629
+ }
1630
+ return []
1631
+ }
1632
+
1633
+ const hooks: HooksConfig = {
1634
+ sessionStart: [{ action: 'diaryRecall', provider: 'brain' }],
1635
+ }
1636
+ const runner = new HookRunner(hooks, [provider])
1637
+
1638
+ const context: HookContext = {
1639
+ messageCount: 0,
1640
+ sessionDate: '2026-03-01',
1641
+ results: [{ content: 'existing result', score: 0.5 }],
1642
+ }
1643
+ const result = await runner.run('sessionStart', context)
1644
+
1645
+ // Diary results should be prepended (before existing)
1646
+ expect(result.results!.length).toBe(4) // 2 self + 1 summary + 1 existing
1647
+ expect(result.results![0].source).toBe('brain:diary')
1648
+ expect(result.results![0].score).toBe(1.0) // Max score
1649
+ expect(result.results![2].source).toBe('brain:diary')
1650
+ expect(result.results![2].score).toBe(0.9)
1651
+ expect(result.results![3].content).toBe('existing result') // Original at end
1652
+ })
1653
+
1654
+ it('falls back to yesterday when today has no entries', async () => {
1655
+ const queriedDates: string[] = []
1656
+ const provider = createMockProvider('brain')
1657
+ ;(provider as any).queryFacts = async (filters: any) => {
1658
+ queriedDates.push(filters.sessionDate)
1659
+ if (filters.sessionDate === '2026-03-01') return []
1660
+ if (filters.sessionDate === '2026-02-28' && filters.domain === 'self') {
1661
+ return [
1662
+ {
1663
+ id: 'j-yesterday',
1664
+ content: 'Yesterday journal',
1665
+ domain: 'self',
1666
+ category: 'journal',
1667
+ createdAt: '2026-02-28T20:00:00Z',
1668
+ importance: 7,
1669
+ },
1670
+ ]
1671
+ }
1672
+ return []
1673
+ }
1674
+
1675
+ const hooks: HooksConfig = {
1676
+ sessionStart: [{ action: 'diaryRecall', provider: 'brain' }],
1677
+ }
1678
+ const runner = new HookRunner(hooks, [provider])
1679
+
1680
+ const context: HookContext = { messageCount: 0, sessionDate: '2026-03-01' }
1681
+ const result = await runner.run('sessionStart', context)
1682
+
1683
+ // Should have tried today first, then yesterday
1684
+ expect(queriedDates).toContain('2026-03-01')
1685
+ expect(queriedDates).toContain('2026-02-28')
1686
+ expect(result.results!.length).toBe(1)
1687
+ expect(result.results![0].content).toContain('Yesterday journal')
1688
+ })
1689
+ })