@onmars/lunar-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +13 -0
- package/package.json +32 -0
- package/src/__tests__/clear-command.test.ts +214 -0
- package/src/__tests__/command-handler.test.ts +169 -0
- package/src/__tests__/compact-command.test.ts +80 -0
- package/src/__tests__/config-command.test.ts +240 -0
- package/src/__tests__/config-loader.test.ts +1512 -0
- package/src/__tests__/config.test.ts +429 -0
- package/src/__tests__/cron-command.test.ts +418 -0
- package/src/__tests__/cron-parser.test.ts +259 -0
- package/src/__tests__/daemon.test.ts +346 -0
- package/src/__tests__/dedup.test.ts +404 -0
- package/src/__tests__/e2e-sanitization.ts +168 -0
- package/src/__tests__/e2e-skill-loader.test.ts +176 -0
- package/src/__tests__/fixtures/AGENTS.md +4 -0
- package/src/__tests__/fixtures/IDENTITY.md +2 -0
- package/src/__tests__/fixtures/SOUL.md +3 -0
- package/src/__tests__/fixtures/moons/athena/IDENTITY.md +2 -0
- package/src/__tests__/fixtures/moons/athena/SOUL.md +3 -0
- package/src/__tests__/fixtures/moons/hermes/SOUL.md +3 -0
- package/src/__tests__/fixtures/skills/brain/SKILL.md +6 -0
- package/src/__tests__/fixtures/skills/empty/SKILL.md +3 -0
- package/src/__tests__/fixtures/skills/multiline/SKILL.md +7 -0
- package/src/__tests__/fixtures/skills/no-desc/SKILL.md +5 -0
- package/src/__tests__/fixtures/skills/notion/SKILL.md +6 -0
- package/src/__tests__/fixtures/skills/quoted/SKILL.md +6 -0
- package/src/__tests__/hook-runner.test.ts +1689 -0
- package/src/__tests__/input-sanitization.test.ts +367 -0
- package/src/__tests__/logger.test.ts +163 -0
- package/src/__tests__/memory-orchestrator.test.ts +552 -0
- package/src/__tests__/model-catalog.test.ts +215 -0
- package/src/__tests__/model-command.test.ts +185 -0
- package/src/__tests__/moon-loader.test.ts +398 -0
- package/src/__tests__/ping-command.test.ts +85 -0
- package/src/__tests__/plugin.test.ts +258 -0
- package/src/__tests__/remind-command.test.ts +368 -0
- package/src/__tests__/reset-command.test.ts +92 -0
- package/src/__tests__/router.test.ts +1246 -0
- package/src/__tests__/scheduler.test.ts +469 -0
- package/src/__tests__/security.test.ts +214 -0
- package/src/__tests__/session-meta.test.ts +101 -0
- package/src/__tests__/session-tracker.test.ts +389 -0
- package/src/__tests__/session.test.ts +241 -0
- package/src/__tests__/skill-loader.test.ts +153 -0
- package/src/__tests__/status-command.test.ts +153 -0
- package/src/__tests__/stop-command.test.ts +60 -0
- package/src/__tests__/think-command.test.ts +146 -0
- package/src/__tests__/usage-api.test.ts +222 -0
- package/src/__tests__/usage-command-api-fail.test.ts +48 -0
- package/src/__tests__/usage-command-no-oauth.test.ts +48 -0
- package/src/__tests__/usage-command.test.ts +173 -0
- package/src/__tests__/whoami-command.test.ts +124 -0
- package/src/index.ts +122 -0
- package/src/lib/command-handler.ts +135 -0
- package/src/lib/commands/clear.ts +69 -0
- package/src/lib/commands/compact.ts +14 -0
- package/src/lib/commands/config-show.ts +49 -0
- package/src/lib/commands/cron.ts +118 -0
- package/src/lib/commands/help.ts +26 -0
- package/src/lib/commands/model.ts +71 -0
- package/src/lib/commands/ping.ts +24 -0
- package/src/lib/commands/remind.ts +75 -0
- package/src/lib/commands/status.ts +118 -0
- package/src/lib/commands/stop.ts +18 -0
- package/src/lib/commands/think.ts +42 -0
- package/src/lib/commands/usage.ts +56 -0
- package/src/lib/commands/whoami.ts +23 -0
- package/src/lib/config-loader.ts +1449 -0
- package/src/lib/config.ts +202 -0
- package/src/lib/cron-parser.ts +388 -0
- package/src/lib/daemon.ts +216 -0
- package/src/lib/dedup.ts +414 -0
- package/src/lib/hook-runner.ts +1270 -0
- package/src/lib/logger.ts +55 -0
- package/src/lib/memory-orchestrator.ts +415 -0
- package/src/lib/model-catalog.ts +240 -0
- package/src/lib/moon-loader.ts +291 -0
- package/src/lib/plugin.ts +148 -0
- package/src/lib/router.ts +1135 -0
- package/src/lib/scheduler.ts +422 -0
- package/src/lib/security.ts +259 -0
- package/src/lib/session-tracker.ts +222 -0
- package/src/lib/session.ts +158 -0
- package/src/lib/skill-loader.ts +166 -0
- package/src/lib/usage-api.ts +145 -0
- package/src/types/agent.ts +86 -0
- package/src/types/channel.ts +93 -0
- package/src/types/index.ts +32 -0
- package/src/types/memory.ts +92 -0
- package/src/types/moon.ts +56 -0
- package/src/types/voice.ts +74 -0
|
@@ -0,0 +1,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
|
+
})
|