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