@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,552 @@
1
+ /**
2
+ * # MemoryOrchestrator — Functional Specification
3
+ *
4
+ * Coordinates multiple MemoryProviders behind a single MemoryProvider interface.
5
+ *
6
+ * ## Core responsibilities:
7
+ * - Parallel recall across all providers
8
+ * - Deduplication of merged results (Dice/MinHash/Adaptive)
9
+ * - Score-based ranking (highest first)
10
+ * - maxResults enforcement post-dedup
11
+ * - Non-fatal: individual provider failures don't block others
12
+ * - Session lifecycle broadcast (onSessionEnd → all providers)
13
+ * - Health aggregation (all OK = OK, any fail = fail with details)
14
+ * - Agent instructions merging from all providers
15
+ * - Lifecycle hooks via HookRunner at every stage
16
+ *
17
+ * ## Key design decisions:
18
+ * - Implements MemoryProvider → plugs into Router as a single provider
19
+ * - Single provider → use directly (no orchestrator overhead)
20
+ * - Zero providers → valid state (returns empty results)
21
+ * - Dedup threshold 0.85 (conservative: prefer false negatives)
22
+ * - Hooks are best-effort, never block core flow
23
+ */
24
+ import { describe, expect, it } from 'bun:test'
25
+ import type { HooksConfig } from '../lib/config-loader'
26
+ import { MemoryOrchestrator } from '../lib/memory-orchestrator'
27
+ import type { Fact, MemoryProvider, MemoryResult, SearchOptions } from '../types/memory'
28
+
29
+ // ═══════════════════════════════════════════════════════════════════
30
+ // Test helpers
31
+ // ═══════════════════════════════════════════════════════════════════
32
+
33
+ function createMockProvider(
34
+ id: string,
35
+ results: MemoryResult[],
36
+ options?: {
37
+ failRecall?: boolean
38
+ failSave?: boolean
39
+ failHealth?: boolean
40
+ instructions?: string
41
+ onSessionEnd?: (ctx: unknown) => void
42
+ savedFacts?: Fact[][]
43
+ },
44
+ ): MemoryProvider {
45
+ const savedFacts: Fact[][] = options?.savedFacts ?? []
46
+
47
+ return {
48
+ id,
49
+ name: `Mock ${id}`,
50
+ async init() {},
51
+ async destroy() {},
52
+ async recall(_query: string, _opts?: SearchOptions): Promise<MemoryResult[]> {
53
+ if (options?.failRecall) throw new Error(`${id} recall failed`)
54
+ return results
55
+ },
56
+ async save(facts: Fact[]) {
57
+ if (options?.failSave) throw new Error(`${id} save failed`)
58
+ savedFacts.push(facts)
59
+ },
60
+ async onSessionEnd(ctx: {
61
+ messages: Array<{ role: string; content: string }>
62
+ sessionSummary?: string
63
+ topics?: string[]
64
+ }) {
65
+ if (options?.onSessionEnd) options.onSessionEnd(ctx)
66
+ },
67
+ async health() {
68
+ if (options?.failHealth) return { ok: false, error: `${id} is down` }
69
+ return { ok: true }
70
+ },
71
+ agentInstructions() {
72
+ return options?.instructions || ''
73
+ },
74
+ }
75
+ }
76
+
77
+ // ═══════════════════════════════════════════════════════════════════
78
+ // Provider management
79
+ // ═══════════════════════════════════════════════════════════════════
80
+
81
+ describe('provider management', () => {
82
+ it('starts with zero providers', () => {
83
+ const orch = new MemoryOrchestrator()
84
+ expect(orch.providerCount).toBe(0)
85
+ expect(orch.providerIds).toEqual([])
86
+ })
87
+
88
+ it('adds providers and tracks count', () => {
89
+ const orch = new MemoryOrchestrator()
90
+ orch.addProvider(createMockProvider('brain', []))
91
+ orch.addProvider(createMockProvider('engram', []))
92
+
93
+ expect(orch.providerCount).toBe(2)
94
+ expect(orch.providerIds).toEqual(['brain', 'engram'])
95
+ })
96
+
97
+ it('has id "orchestrator" and name "Memory Orchestrator"', () => {
98
+ const orch = new MemoryOrchestrator()
99
+ expect(orch.id).toBe('orchestrator')
100
+ expect(orch.name).toBe('Memory Orchestrator')
101
+ })
102
+
103
+ it('supports custom config name for providers', () => {
104
+ const orch = new MemoryOrchestrator()
105
+ const provider = createMockProvider('my-brain-v2', [])
106
+ orch.addProvider(provider, 'brain')
107
+
108
+ expect(orch.providerIds).toEqual(['my-brain-v2'])
109
+ })
110
+ })
111
+
112
+ // ═══════════════════════════════════════════════════════════════════
113
+ // Recall — parallel fetch + merge + dedup
114
+ // ═══════════════════════════════════════════════════════════════════
115
+
116
+ describe('recall', () => {
117
+ it('returns empty array with zero providers', async () => {
118
+ const orch = new MemoryOrchestrator()
119
+ const results = await orch.recall('anything')
120
+ expect(results).toEqual([])
121
+ })
122
+
123
+ it('returns results from a single provider', async () => {
124
+ const orch = new MemoryOrchestrator()
125
+ orch.addProvider(
126
+ createMockProvider('brain', [{ content: 'Mars is a software engineer', score: 0.9 }]),
127
+ )
128
+
129
+ const results = await orch.recall('Mars')
130
+ expect(results).toHaveLength(1)
131
+ expect(results[0].content).toBe('Mars is a software engineer')
132
+ })
133
+
134
+ it('merges results from multiple providers', async () => {
135
+ const orch = new MemoryOrchestrator()
136
+ orch.addProvider(
137
+ createMockProvider('brain', [{ content: 'Mars works at Sngular', score: 0.9 }]),
138
+ )
139
+ orch.addProvider(
140
+ createMockProvider('engram', [{ content: 'Recent session: Mars was debugging', score: 0.7 }]),
141
+ )
142
+
143
+ const results = await orch.recall('Mars')
144
+ expect(results).toHaveLength(2)
145
+ })
146
+
147
+ it('sorts results by score (highest first)', async () => {
148
+ const orch = new MemoryOrchestrator()
149
+ orch.addProvider(createMockProvider('brain', [{ content: 'Low score fact', score: 0.3 }]))
150
+ orch.addProvider(createMockProvider('engram', [{ content: 'High score fact', score: 0.95 }]))
151
+
152
+ const results = await orch.recall('test')
153
+ expect(results[0].score).toBe(0.95)
154
+ expect(results[1].score).toBe(0.3)
155
+ })
156
+
157
+ it('deduplicates near-identical content from different providers', async () => {
158
+ const orch = new MemoryOrchestrator({ dedup: { threshold: 0.85 } })
159
+ orch.addProvider(
160
+ createMockProvider('brain', [
161
+ { content: 'Mars is a software engineer at Sngular', score: 0.9 },
162
+ ]),
163
+ )
164
+ orch.addProvider(
165
+ createMockProvider('engram', [
166
+ { content: 'Mars is a software engineer at Sngular', score: 0.8 },
167
+ ]),
168
+ )
169
+
170
+ const results = await orch.recall('Mars')
171
+ expect(results).toHaveLength(1) // deduped!
172
+ expect(results[0].score).toBe(0.9) // keeps higher score
173
+ })
174
+
175
+ it('keeps distinct content even when similar topic', async () => {
176
+ const orch = new MemoryOrchestrator({ dedup: { threshold: 0.85 } })
177
+ orch.addProvider(
178
+ createMockProvider('brain', [
179
+ { content: 'Mars works at Sngular as a Software Tech Lead', score: 0.9 },
180
+ ]),
181
+ )
182
+ orch.addProvider(
183
+ createMockProvider('engram', [
184
+ { content: 'Mars is studying computer engineering at UOC', score: 0.8 },
185
+ ]),
186
+ )
187
+
188
+ const results = await orch.recall('Mars')
189
+ expect(results).toHaveLength(2) // different content, both kept
190
+ })
191
+
192
+ it('respects maxResults after dedup', async () => {
193
+ const orch = new MemoryOrchestrator({ maxResults: 2 })
194
+ orch.addProvider(
195
+ createMockProvider('brain', [
196
+ { content: 'Fact A from brain', score: 0.9 },
197
+ { content: 'Fact B from brain', score: 0.8 },
198
+ ]),
199
+ )
200
+ orch.addProvider(
201
+ createMockProvider('engram', [
202
+ { content: 'Fact C from engram', score: 0.85 },
203
+ { content: 'Fact D from engram', score: 0.7 },
204
+ ]),
205
+ )
206
+
207
+ const results = await orch.recall('test')
208
+ expect(results.length).toBeLessThanOrEqual(2)
209
+ })
210
+
211
+ it('tags results with provider source', async () => {
212
+ const orch = new MemoryOrchestrator()
213
+ orch.addProvider(createMockProvider('brain', [{ content: 'From brain', score: 0.9 }]))
214
+ orch.addProvider(createMockProvider('engram', [{ content: 'From engram', score: 0.8 }]))
215
+
216
+ const results = await orch.recall('test')
217
+ expect(results.find((r) => r.source === 'brain')).toBeDefined()
218
+ expect(results.find((r) => r.source === 'engram')).toBeDefined()
219
+ })
220
+
221
+ it('filters by minScore', async () => {
222
+ const orch = new MemoryOrchestrator()
223
+ orch.addProvider(
224
+ createMockProvider('brain', [
225
+ { content: 'High relevance', score: 0.9 },
226
+ { content: 'Low relevance', score: 0.1 },
227
+ ]),
228
+ )
229
+
230
+ const results = await orch.recall('test', { minScore: 0.5 })
231
+ expect(results).toHaveLength(1)
232
+ expect(results[0].content).toBe('High relevance')
233
+ })
234
+ })
235
+
236
+ // ═══════════════════════════════════════════════════════════════════
237
+ // Non-fatal failures — provider crashes don't block others
238
+ // ═══════════════════════════════════════════════════════════════════
239
+
240
+ describe('non-fatal failures', () => {
241
+ it('returns results from healthy providers when one fails recall', async () => {
242
+ const orch = new MemoryOrchestrator()
243
+ orch.addProvider(createMockProvider('brain', [{ content: 'Brain is fine', score: 0.9 }]))
244
+ orch.addProvider(createMockProvider('engram', [], { failRecall: true }))
245
+
246
+ const results = await orch.recall('test')
247
+ expect(results).toHaveLength(1)
248
+ expect(results[0].content).toBe('Brain is fine')
249
+ })
250
+
251
+ it('returns empty when all providers fail recall', async () => {
252
+ const orch = new MemoryOrchestrator()
253
+ orch.addProvider(createMockProvider('brain', [], { failRecall: true }))
254
+ orch.addProvider(createMockProvider('engram', [], { failRecall: true }))
255
+
256
+ const results = await orch.recall('test')
257
+ expect(results).toEqual([])
258
+ })
259
+
260
+ it('save continues even when one provider fails', async () => {
261
+ let brainSaved = false
262
+ const orch = new MemoryOrchestrator()
263
+
264
+ const brainProvider = createMockProvider('brain', [])
265
+ const origSave = brainProvider.save.bind(brainProvider)
266
+ brainProvider.save = async (facts) => {
267
+ brainSaved = true
268
+ return origSave(facts)
269
+ }
270
+
271
+ orch.addProvider(brainProvider)
272
+ orch.addProvider(createMockProvider('engram', [], { failSave: true }))
273
+
274
+ // Should not throw
275
+ await orch.save([{ content: 'Test fact' }])
276
+ expect(brainSaved).toBe(true)
277
+ })
278
+ })
279
+
280
+ // ═══════════════════════════════════════════════════════════════════
281
+ // Session lifecycle — broadcast to all providers
282
+ // ═══════════════════════════════════════════════════════════════════
283
+
284
+ describe('session lifecycle', () => {
285
+ it('broadcasts onSessionEnd to all providers', async () => {
286
+ const calls: string[] = []
287
+ const orch = new MemoryOrchestrator()
288
+
289
+ orch.addProvider(
290
+ createMockProvider('brain', [], {
291
+ onSessionEnd: () => calls.push('brain'),
292
+ }),
293
+ )
294
+ orch.addProvider(
295
+ createMockProvider('engram', [], {
296
+ onSessionEnd: () => calls.push('engram'),
297
+ }),
298
+ )
299
+
300
+ await orch.onSessionEnd({
301
+ messages: [{ role: 'user', content: 'test' }],
302
+ sessionSummary: 'Test session',
303
+ })
304
+
305
+ expect(calls).toContain('brain')
306
+ expect(calls).toContain('engram')
307
+ })
308
+ })
309
+
310
+ // ═══════════════════════════════════════════════════════════════════
311
+ // Health — aggregate status from all providers
312
+ // ═══════════════════════════════════════════════════════════════════
313
+
314
+ describe('health', () => {
315
+ it('returns ok when all providers are healthy', async () => {
316
+ const orch = new MemoryOrchestrator()
317
+ orch.addProvider(createMockProvider('brain', []))
318
+ orch.addProvider(createMockProvider('engram', []))
319
+
320
+ const health = await orch.health()
321
+ expect(health.ok).toBe(true)
322
+ expect(health.error).toBeUndefined()
323
+ })
324
+
325
+ it('returns not ok when any provider is unhealthy', async () => {
326
+ const orch = new MemoryOrchestrator()
327
+ orch.addProvider(createMockProvider('brain', []))
328
+ orch.addProvider(createMockProvider('engram', [], { failHealth: true }))
329
+
330
+ const health = await orch.health()
331
+ expect(health.ok).toBe(false)
332
+ expect(health.error).toContain('engram')
333
+ })
334
+
335
+ it('returns ok with zero providers', async () => {
336
+ const orch = new MemoryOrchestrator()
337
+ const health = await orch.health()
338
+ expect(health.ok).toBe(true)
339
+ })
340
+ })
341
+
342
+ // ═══════════════════════════════════════════════════════════════════
343
+ // Agent instructions — merged from all providers
344
+ // ═══════════════════════════════════════════════════════════════════
345
+
346
+ describe('agent instructions', () => {
347
+ it('merges instructions from all providers', () => {
348
+ const orch = new MemoryOrchestrator()
349
+ orch.addProvider(createMockProvider('brain', [], { instructions: 'Use /recall for brain.' }))
350
+ orch.addProvider(createMockProvider('engram', [], { instructions: 'Engram tracks sessions.' }))
351
+
352
+ const instructions = orch.agentInstructions()
353
+ expect(instructions).toContain('Use /recall for brain.')
354
+ expect(instructions).toContain('Engram tracks sessions.')
355
+ })
356
+
357
+ it('skips empty instructions', () => {
358
+ const orch = new MemoryOrchestrator()
359
+ orch.addProvider(createMockProvider('brain', [], { instructions: 'Brain instructions' }))
360
+ orch.addProvider(createMockProvider('engram', []))
361
+
362
+ const instructions = orch.agentInstructions()
363
+ expect(instructions).toBe('Brain instructions')
364
+ expect(instructions).not.toContain('\n\n\n') // no empty gaps
365
+ })
366
+ })
367
+
368
+ // ═══════════════════════════════════════════════════════════════════
369
+ // Hooks integration — HookRunner wired into lifecycle
370
+ // ═══════════════════════════════════════════════════════════════════
371
+
372
+ describe('hooks integration', () => {
373
+ it('reports hasHooks false when no hooks configured', () => {
374
+ const orch = new MemoryOrchestrator()
375
+ expect(orch.hasHooks).toBe(false)
376
+ })
377
+
378
+ it('reports hasHooks true when hooks configured', () => {
379
+ const hooks: HooksConfig = {
380
+ afterRecall: [{ action: 'boost', factor: 1.3 }],
381
+ }
382
+ const orch = new MemoryOrchestrator({ hooks })
383
+ expect(orch.hasHooks).toBe(true)
384
+ })
385
+
386
+ it('afterRecall boost modifies scores in recall results', async () => {
387
+ const hooks: HooksConfig = {
388
+ afterRecall: [
389
+ {
390
+ action: 'boost',
391
+ provider: 'engram',
392
+ factor: 1.5,
393
+ recencyWindow: '24h',
394
+ },
395
+ ],
396
+ }
397
+ const orch = new MemoryOrchestrator({ hooks })
398
+
399
+ const recentDate = new Date(Date.now() - 3_600_000)
400
+ orch.addProvider(
401
+ createMockProvider('engram', [
402
+ { content: 'Recent engram fact', score: 0.6, source: 'engram', storedAt: recentDate },
403
+ ]),
404
+ )
405
+ orch.addProvider(
406
+ createMockProvider('brain', [{ content: 'Old brain fact', score: 0.7, source: 'brain' }]),
407
+ )
408
+
409
+ const results = await orch.recall('test')
410
+
411
+ // Engram result should be boosted (0.6 * 1.5 = 0.9)
412
+ const engramResult = results.find((r) => r.source === 'engram')
413
+ expect(engramResult).toBeDefined()
414
+ expect(engramResult!.score).toBeCloseTo(0.9)
415
+ })
416
+
417
+ it('sessionEnd hooks run before provider onSessionEnd', async () => {
418
+ const order: string[] = []
419
+ const savedFacts: Fact[][] = []
420
+
421
+ const hooks: HooksConfig = {
422
+ sessionEnd: [
423
+ {
424
+ action: 'summarize',
425
+ provider: 'brain',
426
+ },
427
+ ],
428
+ }
429
+ const orch = new MemoryOrchestrator({ hooks })
430
+
431
+ orch.addProvider(
432
+ createMockProvider('brain', [], {
433
+ savedFacts,
434
+ onSessionEnd: () => order.push('provider-end'),
435
+ }),
436
+ )
437
+
438
+ await orch.onSessionEnd({
439
+ messages: [{ role: 'user', content: 'test' }],
440
+ })
441
+
442
+ // summarize should have saved before provider's onSessionEnd
443
+ expect(savedFacts.length).toBe(1)
444
+ expect(savedFacts[0][0].category).toBe('session-summary')
445
+ })
446
+
447
+ it('afterSessionEnd hooks run after provider onSessionEnd', async () => {
448
+ const order: string[] = []
449
+ const brainFacts: Fact[][] = []
450
+
451
+ const hooks: HooksConfig = {
452
+ afterSessionEnd: [
453
+ {
454
+ action: 'promote',
455
+ from: 'engram',
456
+ to: 'brain',
457
+ via: 'extraction',
458
+ when: { providersAvailable: ['engram', 'brain'] },
459
+ },
460
+ ],
461
+ }
462
+ const orch = new MemoryOrchestrator({ hooks })
463
+
464
+ orch.addProvider(
465
+ createMockProvider('engram', [], {
466
+ onSessionEnd: () => order.push('engram-end'),
467
+ }),
468
+ )
469
+ orch.addProvider(
470
+ createMockProvider('brain', [], {
471
+ savedFacts: brainFacts,
472
+ onSessionEnd: () => order.push('brain-end'),
473
+ }),
474
+ )
475
+
476
+ await orch.onSessionEnd({
477
+ messages: [{ role: 'user', content: 'test' }],
478
+ sessionSummary: 'Session summary for promotion',
479
+ })
480
+
481
+ // Promote should have saved the summary to brain
482
+ expect(brainFacts.length).toBe(1)
483
+ expect(brainFacts[0][0].content).toBe('Session summary for promotion')
484
+ expect(brainFacts[0][0].tags).toContain('from:engram')
485
+ })
486
+
487
+ it('hooks with failing guards are silently skipped', async () => {
488
+ const brainFacts: Fact[][] = []
489
+ const hooks: HooksConfig = {
490
+ afterSessionEnd: [
491
+ {
492
+ action: 'promote',
493
+ from: 'engram',
494
+ to: 'brain',
495
+ via: 'extraction',
496
+ when: { minMessages: 100 }, // won't be met
497
+ },
498
+ ],
499
+ }
500
+ const orch = new MemoryOrchestrator({ hooks })
501
+ orch.addProvider(createMockProvider('engram', []))
502
+ orch.addProvider(createMockProvider('brain', [], { savedFacts: brainFacts }))
503
+
504
+ await orch.onSessionEnd({
505
+ messages: [{ role: 'user', content: 'test' }],
506
+ })
507
+
508
+ expect(brainFacts.length).toBe(0)
509
+ })
510
+
511
+ it('beforeSave hooks can modify facts', async () => {
512
+ // Use a custom action to test beforeSave
513
+ const { registerAction } = await import('../lib/hook-runner')
514
+ registerAction('tag-facts', async (_hook, context, _providers) => {
515
+ if (context.facts) {
516
+ for (const fact of context.facts) {
517
+ fact.tags = [...(fact.tags ?? []), 'auto-tagged']
518
+ }
519
+ }
520
+ })
521
+
522
+ const savedFacts: Fact[][] = []
523
+ const hooks: HooksConfig = {
524
+ beforeSave: [{ action: 'tag-facts' }],
525
+ }
526
+ const orch = new MemoryOrchestrator({ hooks })
527
+ orch.addProvider(createMockProvider('brain', [], { savedFacts }))
528
+
529
+ await orch.save([{ content: 'Test fact' }])
530
+
531
+ expect(savedFacts.length).toBe(1)
532
+ expect(savedFacts[0][0].tags).toContain('auto-tagged')
533
+ })
534
+
535
+ it('compaction hooks trigger via onCompaction', async () => {
536
+ let compactionRan = false
537
+ const { registerAction } = await import('../lib/hook-runner')
538
+ registerAction('compaction-handler', async () => {
539
+ compactionRan = true
540
+ })
541
+
542
+ const hooks: HooksConfig = {
543
+ compaction: [{ action: 'compaction-handler' }],
544
+ }
545
+ const orch = new MemoryOrchestrator({ hooks })
546
+ orch.addProvider(createMockProvider('brain', []))
547
+
548
+ await orch.onCompaction({ messageCount: 50 })
549
+
550
+ expect(compactionRan).toBe(true)
551
+ })
552
+ })