@aitytech/agentkits-memory 1.0.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +54 -5
  2. package/dist/better-sqlite3-backend.d.ts +192 -0
  3. package/dist/better-sqlite3-backend.d.ts.map +1 -0
  4. package/dist/better-sqlite3-backend.js +801 -0
  5. package/dist/better-sqlite3-backend.js.map +1 -0
  6. package/dist/cli/save.js +0 -0
  7. package/dist/cli/setup.d.ts +6 -2
  8. package/dist/cli/setup.d.ts.map +1 -1
  9. package/dist/cli/setup.js +289 -42
  10. package/dist/cli/setup.js.map +1 -1
  11. package/dist/cli/viewer.js +25 -56
  12. package/dist/cli/viewer.js.map +1 -1
  13. package/dist/cli/web-viewer.d.ts +2 -1
  14. package/dist/cli/web-viewer.d.ts.map +1 -1
  15. package/dist/cli/web-viewer.js +791 -141
  16. package/dist/cli/web-viewer.js.map +1 -1
  17. package/dist/embeddings/embedding-cache.d.ts +131 -0
  18. package/dist/embeddings/embedding-cache.d.ts.map +1 -0
  19. package/dist/embeddings/embedding-cache.js +217 -0
  20. package/dist/embeddings/embedding-cache.js.map +1 -0
  21. package/dist/embeddings/index.d.ts +11 -0
  22. package/dist/embeddings/index.d.ts.map +1 -0
  23. package/dist/embeddings/index.js +11 -0
  24. package/dist/embeddings/index.js.map +1 -0
  25. package/dist/embeddings/local-embeddings.d.ts +140 -0
  26. package/dist/embeddings/local-embeddings.d.ts.map +1 -0
  27. package/dist/embeddings/local-embeddings.js +293 -0
  28. package/dist/embeddings/local-embeddings.js.map +1 -0
  29. package/dist/hooks/context.d.ts +6 -1
  30. package/dist/hooks/context.d.ts.map +1 -1
  31. package/dist/hooks/context.js +12 -2
  32. package/dist/hooks/context.js.map +1 -1
  33. package/dist/hooks/observation.d.ts +6 -1
  34. package/dist/hooks/observation.d.ts.map +1 -1
  35. package/dist/hooks/observation.js +12 -2
  36. package/dist/hooks/observation.js.map +1 -1
  37. package/dist/hooks/service.d.ts +1 -6
  38. package/dist/hooks/service.d.ts.map +1 -1
  39. package/dist/hooks/service.js +33 -85
  40. package/dist/hooks/service.js.map +1 -1
  41. package/dist/hooks/session-init.d.ts +6 -1
  42. package/dist/hooks/session-init.d.ts.map +1 -1
  43. package/dist/hooks/session-init.js +12 -2
  44. package/dist/hooks/session-init.js.map +1 -1
  45. package/dist/hooks/summarize.d.ts +6 -1
  46. package/dist/hooks/summarize.d.ts.map +1 -1
  47. package/dist/hooks/summarize.js +12 -2
  48. package/dist/hooks/summarize.js.map +1 -1
  49. package/dist/index.d.ts +10 -17
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +172 -94
  52. package/dist/index.js.map +1 -1
  53. package/dist/mcp/server.js +17 -3
  54. package/dist/mcp/server.js.map +1 -1
  55. package/dist/migration.js +3 -3
  56. package/dist/migration.js.map +1 -1
  57. package/dist/search/hybrid-search.d.ts +262 -0
  58. package/dist/search/hybrid-search.d.ts.map +1 -0
  59. package/dist/search/hybrid-search.js +688 -0
  60. package/dist/search/hybrid-search.js.map +1 -0
  61. package/dist/search/index.d.ts +13 -0
  62. package/dist/search/index.d.ts.map +1 -0
  63. package/dist/search/index.js +13 -0
  64. package/dist/search/index.js.map +1 -0
  65. package/dist/search/token-economics.d.ts +161 -0
  66. package/dist/search/token-economics.d.ts.map +1 -0
  67. package/dist/search/token-economics.js +239 -0
  68. package/dist/search/token-economics.js.map +1 -0
  69. package/dist/types.d.ts +0 -68
  70. package/dist/types.d.ts.map +1 -1
  71. package/dist/types.js.map +1 -1
  72. package/package.json +6 -4
  73. package/src/__tests__/better-sqlite3-backend.test.ts +1466 -0
  74. package/src/__tests__/cache-manager.test.ts +499 -0
  75. package/src/__tests__/embedding-integration.test.ts +481 -0
  76. package/src/__tests__/hnsw-index.test.ts +727 -0
  77. package/src/__tests__/index.test.ts +432 -0
  78. package/src/better-sqlite3-backend.ts +1000 -0
  79. package/src/cli/setup.ts +358 -47
  80. package/src/cli/viewer.ts +28 -63
  81. package/src/cli/web-viewer.ts +936 -182
  82. package/src/embeddings/__tests__/embedding-cache.test.ts +269 -0
  83. package/src/embeddings/__tests__/local-embeddings.test.ts +495 -0
  84. package/src/embeddings/embedding-cache.ts +318 -0
  85. package/src/embeddings/index.ts +20 -0
  86. package/src/embeddings/local-embeddings.ts +419 -0
  87. package/src/hooks/__tests__/handlers.test.ts +58 -17
  88. package/src/hooks/__tests__/integration.test.ts +77 -26
  89. package/src/hooks/context.ts +13 -2
  90. package/src/hooks/observation.ts +13 -2
  91. package/src/hooks/service.ts +39 -100
  92. package/src/hooks/session-init.ts +13 -2
  93. package/src/hooks/summarize.ts +13 -2
  94. package/src/index.ts +210 -116
  95. package/src/mcp/server.ts +20 -3
  96. package/src/search/__tests__/hybrid-search.test.ts +669 -0
  97. package/src/search/__tests__/token-economics.test.ts +276 -0
  98. package/src/search/hybrid-search.ts +968 -0
  99. package/src/search/index.ts +29 -0
  100. package/src/search/token-economics.ts +367 -0
  101. package/src/types.ts +0 -96
  102. package/src/__tests__/sqljs-backend.test.ts +0 -410
  103. package/src/migration.ts +0 -574
  104. package/src/sql.js.d.ts +0 -70
  105. package/src/sqljs-backend.ts +0 -789
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Local Offline Embeddings Service
3
+ *
4
+ * Provides 100% offline text embeddings using Transformers.js (WASM-based).
5
+ * No external API calls. Model is downloaded once and cached locally.
6
+ *
7
+ * @module @aitytech/agentkits-memory/embeddings
8
+ */
9
+
10
+ import type { EmbeddingGenerator } from '../types.js';
11
+
12
+ /**
13
+ * Embedding provider type
14
+ */
15
+ export type EmbeddingProvider = 'transformers' | 'mock';
16
+
17
+ /**
18
+ * Local embeddings configuration
19
+ */
20
+ export interface LocalEmbeddingsConfig {
21
+ /** Provider to use (default: 'transformers') */
22
+ provider?: EmbeddingProvider;
23
+
24
+ /**
25
+ * Model ID for Transformers.js
26
+ * Default: 'Xenova/multilingual-e5-small' (100+ languages, optimized for retrieval)
27
+ * Alternative: 'Xenova/paraphrase-multilingual-MiniLM-L12-v2' (50+ languages)
28
+ * Alternative: 'Xenova/all-MiniLM-L6-v2' (English only, faster)
29
+ */
30
+ modelId?: string;
31
+
32
+ /** Vector dimensions (default: 384) */
33
+ dimensions?: number;
34
+
35
+ /** Enable in-memory cache for repeated texts */
36
+ cacheEnabled?: boolean;
37
+
38
+ /** Maximum cache size (default: 1000) */
39
+ maxCacheSize?: number;
40
+
41
+ /** Show progress during model download */
42
+ showProgress?: boolean;
43
+
44
+ /** Custom cache directory for models */
45
+ cacheDir?: string;
46
+ }
47
+
48
+ /**
49
+ * Embedding result with metadata
50
+ */
51
+ export interface EmbeddingResult {
52
+ /** The embedding vector */
53
+ embedding: Float32Array;
54
+
55
+ /** Time taken in milliseconds */
56
+ timeMs: number;
57
+
58
+ /** Whether result was from cache */
59
+ cached: boolean;
60
+
61
+ /** Token count (approximate) */
62
+ tokenCount?: number;
63
+ }
64
+
65
+ /**
66
+ * Local embeddings service statistics
67
+ */
68
+ export interface EmbeddingsStats {
69
+ /** Total embeddings generated */
70
+ totalEmbeddings: number;
71
+
72
+ /** Cache hits */
73
+ cacheHits: number;
74
+
75
+ /** Cache misses */
76
+ cacheMisses: number;
77
+
78
+ /** Average time per embedding (ms) */
79
+ avgTimeMs: number;
80
+
81
+ /** Total time spent (ms) */
82
+ totalTimeMs: number;
83
+
84
+ /** Model loaded */
85
+ modelLoaded: boolean;
86
+
87
+ /** Provider being used */
88
+ provider: EmbeddingProvider;
89
+ }
90
+
91
+ /**
92
+ * In-memory LRU Cache for embeddings
93
+ */
94
+ class InMemoryEmbeddingCache {
95
+ private cache = new Map<string, Float32Array>();
96
+ private accessOrder: string[] = [];
97
+ private maxSize: number;
98
+
99
+ constructor(maxSize: number) {
100
+ this.maxSize = maxSize;
101
+ }
102
+
103
+ get(key: string): Float32Array | undefined {
104
+ const value = this.cache.get(key);
105
+ if (value) {
106
+ // Move to end (most recently used)
107
+ const index = this.accessOrder.indexOf(key);
108
+ if (index > -1) {
109
+ this.accessOrder.splice(index, 1);
110
+ }
111
+ this.accessOrder.push(key);
112
+ }
113
+ return value;
114
+ }
115
+
116
+ set(key: string, value: Float32Array): void {
117
+ if (this.cache.has(key)) {
118
+ this.cache.set(key, value);
119
+ return;
120
+ }
121
+
122
+ // Evict if at capacity
123
+ while (this.cache.size >= this.maxSize && this.accessOrder.length > 0) {
124
+ const oldest = this.accessOrder.shift();
125
+ if (oldest) {
126
+ this.cache.delete(oldest);
127
+ }
128
+ }
129
+
130
+ this.cache.set(key, value);
131
+ this.accessOrder.push(key);
132
+ }
133
+
134
+ clear(): void {
135
+ this.cache.clear();
136
+ this.accessOrder = [];
137
+ }
138
+
139
+ get size(): number {
140
+ return this.cache.size;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Mock embedding provider for testing
146
+ */
147
+ function createMockEmbedding(text: string, dimensions: number): Float32Array {
148
+ const embedding = new Float32Array(dimensions);
149
+ // Generate deterministic pseudo-random values based on text hash
150
+ let hash = 0;
151
+ for (let i = 0; i < text.length; i++) {
152
+ hash = ((hash << 5) - hash) + text.charCodeAt(i);
153
+ hash = hash & hash;
154
+ }
155
+
156
+ for (let i = 0; i < dimensions; i++) {
157
+ // Use hash to seed pseudo-random generation
158
+ hash = ((hash << 5) - hash) + i;
159
+ hash = hash & hash;
160
+ embedding[i] = (hash % 1000) / 1000 - 0.5;
161
+ }
162
+
163
+ // Normalize
164
+ let norm = 0;
165
+ for (let i = 0; i < dimensions; i++) {
166
+ norm += embedding[i] * embedding[i];
167
+ }
168
+ norm = Math.sqrt(norm);
169
+ if (norm > 0) {
170
+ for (let i = 0; i < dimensions; i++) {
171
+ embedding[i] /= norm;
172
+ }
173
+ }
174
+
175
+ return embedding;
176
+ }
177
+
178
+ /**
179
+ * Local Embeddings Service
180
+ *
181
+ * Provides offline text embeddings using Transformers.js.
182
+ * Models are downloaded once and cached locally in ~/.cache/huggingface.
183
+ */
184
+ export class LocalEmbeddingsService {
185
+ private config: Required<LocalEmbeddingsConfig>;
186
+ private cache: InMemoryEmbeddingCache | null = null;
187
+ private pipeline: any = null;
188
+ private modelLoading: Promise<void> | null = null;
189
+ private stats = {
190
+ totalEmbeddings: 0,
191
+ cacheHits: 0,
192
+ cacheMisses: 0,
193
+ totalTimeMs: 0,
194
+ };
195
+
196
+ constructor(config: LocalEmbeddingsConfig = {}) {
197
+ this.config = {
198
+ provider: config.provider || 'transformers',
199
+ // Use multilingual-e5-small for best CJK support and retrieval accuracy
200
+ modelId: config.modelId || 'Xenova/multilingual-e5-small',
201
+ dimensions: config.dimensions || 384,
202
+ cacheEnabled: config.cacheEnabled ?? true,
203
+ maxCacheSize: config.maxCacheSize || 1000,
204
+ showProgress: config.showProgress ?? false,
205
+ cacheDir: config.cacheDir || '',
206
+ };
207
+
208
+ if (this.config.cacheEnabled) {
209
+ this.cache = new InMemoryEmbeddingCache(this.config.maxCacheSize);
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Initialize the embeddings service (loads model)
215
+ */
216
+ async initialize(): Promise<void> {
217
+ if (this.config.provider === 'mock') {
218
+ return;
219
+ }
220
+
221
+ if (this.pipeline) {
222
+ return;
223
+ }
224
+
225
+ if (this.modelLoading) {
226
+ await this.modelLoading;
227
+ return;
228
+ }
229
+
230
+ this.modelLoading = this.loadModel();
231
+ await this.modelLoading;
232
+ }
233
+
234
+ private async loadModel(): Promise<void> {
235
+ try {
236
+ // Dynamic import for Transformers.js
237
+ const { pipeline } = await import('@xenova/transformers');
238
+
239
+ const progressCallback = this.config.showProgress
240
+ ? (progress: { status: string; progress?: number }) => {
241
+ if (progress.status === 'progress' && progress.progress !== undefined) {
242
+ process.stderr.write(
243
+ `\rLoading model: ${Math.round(progress.progress)}%`
244
+ );
245
+ } else if (progress.status === 'done') {
246
+ process.stderr.write('\rModel loaded successfully. \n');
247
+ }
248
+ }
249
+ : undefined;
250
+
251
+ this.pipeline = await pipeline('feature-extraction', this.config.modelId, {
252
+ progress_callback: progressCallback,
253
+ });
254
+ } catch (error) {
255
+ // If Transformers.js is not available, fall back to mock
256
+ console.warn(
257
+ 'Transformers.js not available, falling back to mock embeddings.',
258
+ 'Install with: npm install @xenova/transformers'
259
+ );
260
+ this.config.provider = 'mock';
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Generate embedding for text
266
+ */
267
+ async embed(text: string): Promise<EmbeddingResult> {
268
+ const startTime = performance.now();
269
+
270
+ // Check cache
271
+ if (this.cache) {
272
+ const cached = this.cache.get(text);
273
+ if (cached) {
274
+ this.stats.cacheHits++;
275
+ return {
276
+ embedding: cached,
277
+ timeMs: performance.now() - startTime,
278
+ cached: true,
279
+ };
280
+ }
281
+ this.stats.cacheMisses++;
282
+ }
283
+
284
+ // Generate embedding
285
+ let embedding: Float32Array;
286
+
287
+ if (this.config.provider === 'mock') {
288
+ embedding = createMockEmbedding(text, this.config.dimensions);
289
+ } else {
290
+ await this.initialize();
291
+
292
+ if (!this.pipeline) {
293
+ // Fall back to mock if model failed to load
294
+ embedding = createMockEmbedding(text, this.config.dimensions);
295
+ } else {
296
+ const output = await this.pipeline(text, {
297
+ pooling: 'mean',
298
+ normalize: true,
299
+ });
300
+ embedding = new Float32Array(output.data);
301
+ }
302
+ }
303
+
304
+ const timeMs = performance.now() - startTime;
305
+
306
+ // Update stats
307
+ this.stats.totalEmbeddings++;
308
+ this.stats.totalTimeMs += timeMs;
309
+
310
+ // Cache result
311
+ if (this.cache) {
312
+ this.cache.set(text, embedding);
313
+ }
314
+
315
+ return {
316
+ embedding,
317
+ timeMs,
318
+ cached: false,
319
+ };
320
+ }
321
+
322
+ /**
323
+ * Generate embeddings for multiple texts (batch)
324
+ */
325
+ async embedBatch(texts: string[]): Promise<EmbeddingResult[]> {
326
+ // Process in parallel for better performance
327
+ return Promise.all(texts.map((text) => this.embed(text)));
328
+ }
329
+
330
+ /**
331
+ * Get embedding generator function compatible with ProjectMemoryService
332
+ */
333
+ getGenerator(): EmbeddingGenerator {
334
+ return async (content: string): Promise<Float32Array> => {
335
+ const result = await this.embed(content);
336
+ return result.embedding;
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Get service statistics
342
+ */
343
+ getStats(): EmbeddingsStats {
344
+ return {
345
+ totalEmbeddings: this.stats.totalEmbeddings,
346
+ cacheHits: this.stats.cacheHits,
347
+ cacheMisses: this.stats.cacheMisses,
348
+ avgTimeMs:
349
+ this.stats.totalEmbeddings > 0
350
+ ? this.stats.totalTimeMs / this.stats.totalEmbeddings
351
+ : 0,
352
+ totalTimeMs: this.stats.totalTimeMs,
353
+ modelLoaded: this.pipeline !== null || this.config.provider === 'mock',
354
+ provider: this.config.provider,
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Clear the embedding cache
360
+ */
361
+ clearCache(): void {
362
+ if (this.cache) {
363
+ this.cache.clear();
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Get vector dimensions
369
+ */
370
+ getDimensions(): number {
371
+ return this.config.dimensions;
372
+ }
373
+
374
+ /**
375
+ * Shutdown and cleanup
376
+ */
377
+ async shutdown(): Promise<void> {
378
+ this.clearCache();
379
+ this.pipeline = null;
380
+ this.modelLoading = null;
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Create a local embeddings service with default configuration
386
+ */
387
+ export function createLocalEmbeddings(
388
+ config?: LocalEmbeddingsConfig
389
+ ): LocalEmbeddingsService {
390
+ return new LocalEmbeddingsService(config);
391
+ }
392
+
393
+ /**
394
+ * Create an embedding generator function for use with ProjectMemoryService
395
+ *
396
+ * @example
397
+ * ```typescript
398
+ * import { createEmbeddingGenerator } from '@aitytech/agentkits-memory/embeddings';
399
+ * import { ProjectMemoryService } from '@aitytech/agentkits-memory';
400
+ *
401
+ * const embeddingGenerator = await createEmbeddingGenerator();
402
+ *
403
+ * const memory = new ProjectMemoryService({
404
+ * projectPath: '/path/to/project',
405
+ * enableVectorIndex: true,
406
+ * embeddingGenerator,
407
+ * });
408
+ * ```
409
+ */
410
+ export async function createEmbeddingGenerator(
411
+ config?: LocalEmbeddingsConfig
412
+ ): Promise<EmbeddingGenerator> {
413
+ const service = new LocalEmbeddingsService(config);
414
+ await service.initialize();
415
+ return service.getGenerator();
416
+ }
417
+
418
+ // Default export
419
+ export default LocalEmbeddingsService;
@@ -16,6 +16,9 @@ import { SummarizeHook, createSummarizeHook } from '../summarize.js';
16
16
 
17
17
  const TEST_DIR = path.join(process.cwd(), '.test-hook-handlers');
18
18
 
19
+ // Track hooks for cleanup
20
+ let activeHooks: Array<{ shutdown: () => Promise<void> }> = [];
21
+
19
22
  function createTestInput(overrides: Partial<NormalizedHookInput> = {}): NormalizedHookInput {
20
23
  return {
21
24
  sessionId: 'test-session-123',
@@ -26,25 +29,53 @@ function createTestInput(overrides: Partial<NormalizedHookInput> = {}): Normaliz
26
29
  };
27
30
  }
28
31
 
32
+ // Helper to track hooks for cleanup
33
+ function trackHook<T extends { shutdown: () => Promise<void> }>(hook: T): T {
34
+ activeHooks.push(hook);
35
+ return hook;
36
+ }
37
+
29
38
  describe('Hook Handlers', () => {
30
39
  beforeEach(() => {
40
+ activeHooks = [];
31
41
  // Clean up test directory
32
42
  if (existsSync(TEST_DIR)) {
33
- rmSync(TEST_DIR, { recursive: true });
43
+ try {
44
+ rmSync(TEST_DIR, { recursive: true });
45
+ } catch {
46
+ // Ignore errors on Windows
47
+ }
34
48
  }
35
49
  mkdirSync(TEST_DIR, { recursive: true });
36
50
  });
37
51
 
38
- afterEach(() => {
52
+ afterEach(async () => {
53
+ // Shutdown all hooks first (releases database locks)
54
+ for (const hook of activeHooks) {
55
+ try {
56
+ await hook.shutdown();
57
+ } catch {
58
+ // Ignore shutdown errors
59
+ }
60
+ }
61
+ activeHooks = [];
62
+
63
+ // Small delay for Windows file system
64
+ await new Promise((r) => setTimeout(r, 100));
65
+
39
66
  // Clean up test directory
40
67
  if (existsSync(TEST_DIR)) {
41
- rmSync(TEST_DIR, { recursive: true });
68
+ try {
69
+ rmSync(TEST_DIR, { recursive: true });
70
+ } catch {
71
+ // Ignore errors on Windows - files may still be locked
72
+ }
42
73
  }
43
74
  });
44
75
 
45
76
  describe('ContextHook', () => {
46
77
  it('should return no context for new project', async () => {
47
- const hook = createContextHook(TEST_DIR);
78
+ const hook = trackHook(createContextHook(TEST_DIR));
48
79
  const input = createTestInput();
49
80
 
50
81
  const result = await hook.execute(input);
@@ -63,7 +94,7 @@ describe('Hook Handlers', () => {
63
94
  await service.shutdown();
64
95
 
65
96
  // Run context hook
66
- const hook = createContextHook(TEST_DIR);
97
+ const hook = trackHook(createContextHook(TEST_DIR));
67
98
  const input = createTestInput({ sessionId: 'new-session' });
68
99
 
69
100
  const result = await hook.execute(input);
@@ -92,7 +123,7 @@ describe('Hook Handlers', () => {
92
123
 
93
124
  describe('SessionInitHook', () => {
94
125
  it('should initialize a new session', async () => {
95
- const hook = createSessionInitHook(TEST_DIR);
126
+ const hook = trackHook(createSessionInitHook(TEST_DIR));
96
127
  const input = createTestInput({ prompt: 'Hello Claude' });
97
128
 
98
129
  const result = await hook.execute(input);
@@ -100,6 +131,9 @@ describe('Hook Handlers', () => {
100
131
  expect(result.continue).toBe(true);
101
132
  expect(result.suppressOutput).toBe(true);
102
133
 
134
+ // Shutdown hook before verifying
135
+ await hook.shutdown();
136
+
103
137
  // Verify session was created
104
138
  const service = new MemoryHookService(TEST_DIR);
105
139
  await service.initialize();
@@ -112,12 +146,14 @@ describe('Hook Handlers', () => {
112
146
 
113
147
  it('should not overwrite existing session', async () => {
114
148
  // Create initial session
115
- const hook1 = createSessionInitHook(TEST_DIR);
149
+ const hook1 = trackHook(createSessionInitHook(TEST_DIR));
116
150
  await hook1.execute(createTestInput({ prompt: 'First prompt' }));
151
+ await hook1.shutdown();
117
152
 
118
153
  // Try to re-init with different prompt
119
- const hook2 = createSessionInitHook(TEST_DIR);
154
+ const hook2 = trackHook(createSessionInitHook(TEST_DIR));
120
155
  await hook2.execute(createTestInput({ prompt: 'Second prompt' }));
156
+ await hook2.shutdown();
121
157
 
122
158
  // Verify original prompt preserved
123
159
  const service = new MemoryHookService(TEST_DIR);
@@ -145,11 +181,12 @@ describe('Hook Handlers', () => {
145
181
  describe('ObservationHook', () => {
146
182
  it('should store observation', async () => {
147
183
  // Initialize session first
148
- const initHook = createSessionInitHook(TEST_DIR);
184
+ const initHook = trackHook(createSessionInitHook(TEST_DIR));
149
185
  await initHook.execute(createTestInput());
186
+ await initHook.shutdown();
150
187
 
151
188
  // Store observation
152
- const hook = createObservationHook(TEST_DIR);
189
+ const hook = trackHook(createObservationHook(TEST_DIR));
153
190
  const input = createTestInput({
154
191
  toolName: 'Read',
155
192
  toolInput: { file_path: '/path/to/file.ts' },
@@ -157,6 +194,7 @@ describe('Hook Handlers', () => {
157
194
  });
158
195
 
159
196
  const result = await hook.execute(input);
197
+ await hook.shutdown();
160
198
 
161
199
  expect(result.continue).toBe(true);
162
200
  expect(result.suppressOutput).toBe(true);
@@ -172,7 +210,7 @@ describe('Hook Handlers', () => {
172
210
  });
173
211
 
174
212
  it('should skip if no tool name', async () => {
175
- const hook = createObservationHook(TEST_DIR);
213
+ const hook = trackHook(createObservationHook(TEST_DIR));
176
214
  const input = createTestInput({ toolName: undefined });
177
215
 
178
216
  const result = await hook.execute(input);
@@ -183,10 +221,11 @@ describe('Hook Handlers', () => {
183
221
 
184
222
  it('should skip internal tools', async () => {
185
223
  // Initialize session first
186
- const initHook = createSessionInitHook(TEST_DIR);
224
+ const initHook = trackHook(createSessionInitHook(TEST_DIR));
187
225
  await initHook.execute(createTestInput());
226
+ await initHook.shutdown();
188
227
 
189
- const hook = createObservationHook(TEST_DIR);
228
+ const hook = trackHook(createObservationHook(TEST_DIR));
190
229
 
191
230
  // Test skipped tools
192
231
  for (const tool of ['TodoWrite', 'TodoRead', 'AskFollowupQuestion', 'AttemptCompletion']) {
@@ -196,6 +235,7 @@ describe('Hook Handlers', () => {
196
235
  expect(result.continue).toBe(true);
197
236
  expect(result.suppressOutput).toBe(true);
198
237
  }
238
+ await hook.shutdown();
199
239
 
200
240
  // Verify no observations stored
201
241
  const service = new MemoryHookService(TEST_DIR);
@@ -207,7 +247,7 @@ describe('Hook Handlers', () => {
207
247
  });
208
248
 
209
249
  it('should create session if not exists', async () => {
210
- const hook = createObservationHook(TEST_DIR);
250
+ const hook = trackHook(createObservationHook(TEST_DIR));
211
251
  const input = createTestInput({
212
252
  sessionId: 'new-session',
213
253
  toolName: 'Read',
@@ -216,6 +256,7 @@ describe('Hook Handlers', () => {
216
256
  });
217
257
 
218
258
  const result = await hook.execute(input);
259
+ await hook.shutdown();
219
260
 
220
261
  expect(result.continue).toBe(true);
221
262
 
@@ -251,8 +292,8 @@ describe('Hook Handlers', () => {
251
292
  await service.storeObservation('test-session-123', 'test-project', 'Write', { file_path: 'b.ts' }, {}, TEST_DIR);
252
293
  await service.shutdown();
253
294
 
254
- // Run summarize hook
255
- const hook = createSummarizeHook(TEST_DIR);
295
+ // Run summarize hook (it already calls shutdown internally)
296
+ const hook = trackHook(createSummarizeHook(TEST_DIR));
256
297
  const input = createTestInput();
257
298
 
258
299
  const result = await hook.execute(input);
@@ -272,7 +313,7 @@ describe('Hook Handlers', () => {
272
313
  });
273
314
 
274
315
  it('should handle non-existent session', async () => {
275
- const hook = createSummarizeHook(TEST_DIR);
316
+ const hook = trackHook(createSummarizeHook(TEST_DIR));
276
317
  const input = createTestInput({ sessionId: 'non-existent' });
277
318
 
278
319
  const result = await hook.execute(input);