@soederpop/luca 0.1.3 → 0.2.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.
@@ -0,0 +1,694 @@
1
+ import { z } from 'zod'
2
+ import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
3
+ import { Feature } from '../feature.js'
4
+ import type { Helper } from '../../helper.js'
5
+
6
+ declare module '@soederpop/luca/feature' {
7
+ interface AvailableFeatures {
8
+ memory: typeof Memory
9
+ }
10
+ }
11
+
12
+ // --- Schemas ---
13
+
14
+ export const MemoryStateSchema = FeatureStateSchema.extend({
15
+ dbReady: z.boolean().default(false).describe('Whether the SQLite database is initialized'),
16
+ totalMemories: z.number().default(0).describe('Total memories across all categories'),
17
+ epoch: z.number().default(1).describe('Current epoch for event grouping'),
18
+ })
19
+ export type MemoryState = z.infer<typeof MemoryStateSchema>
20
+
21
+ export const MemoryOptionsSchema = FeatureOptionsSchema.extend({
22
+ dbPath: z.string().optional().describe('Path to SQLite database file. Defaults to .luca/agent-memory/<hash>.db in home dir'),
23
+ embeddingModel: z.string().default('text-embedding-3-large').describe('OpenAI embedding model to use'),
24
+ namespace: z.string().default('default').describe('Namespace to isolate memory sets (e.g. per-assistant)'),
25
+ })
26
+ export type MemoryOptions = z.infer<typeof MemoryOptionsSchema>
27
+
28
+ export const MemoryEventsSchema = FeatureEventsSchema.extend({
29
+ memoryCreated: z.tuple([z.object({ id: z.number(), category: z.string(), document: z.string() }).describe('The created memory')]).describe('Emitted when a memory is created'),
30
+ memoryDeleted: z.tuple([z.object({ id: z.number(), category: z.string() }).describe('The deleted memory ref')]).describe('Emitted when a memory is deleted'),
31
+ epochChanged: z.tuple([z.number().describe('New epoch value')]).describe('Emitted when the epoch changes'),
32
+ dbInitialized: z.tuple([]).describe('Emitted when the database is ready'),
33
+ })
34
+
35
+ // --- Types ---
36
+
37
+ export interface MemoryRecord {
38
+ id: number
39
+ category: string
40
+ document: string
41
+ metadata: Record<string, any>
42
+ created_at: string
43
+ updated_at: string
44
+ }
45
+
46
+ export interface MemorySearchResult extends MemoryRecord {
47
+ distance: number
48
+ }
49
+
50
+ /**
51
+ * Semantic memory storage and retrieval for AI agents.
52
+ *
53
+ * Provides categorized memory with embedding-based search, metadata filtering,
54
+ * epoch tracking, and assistant tool integration. Built natively on Luca's
55
+ * SQLite and semanticSearch features.
56
+ *
57
+ * @example
58
+ * ```typescript
59
+ * const mem = container.feature('memory')
60
+ * await mem.create('user-prefs', 'Prefers dark mode', { source: 'onboarding' })
61
+ * const results = await mem.search('user-prefs', 'UI preferences')
62
+ * ```
63
+ *
64
+ * @extends Feature
65
+ */
66
+ export class Memory extends Feature<MemoryState, MemoryOptions> {
67
+ static override shortcut = 'features.memory' as const
68
+ static override stateSchema = MemoryStateSchema
69
+ static override optionsSchema = MemoryOptionsSchema
70
+ static override eventsSchema = MemoryEventsSchema
71
+
72
+ static { Feature.register(this, 'memory') }
73
+
74
+ // --- Tools for assistant integration via assistant.use(memory) ---
75
+
76
+ static override tools: Record<string, { schema: z.ZodType; description?: string }> = {
77
+ remember: {
78
+ description: 'Persist a fact, preference, or piece of context to long-term memory so it can be recalled in future conversations. Safe to call liberally — duplicates are automatically detected and skipped.',
79
+ schema: z.object({
80
+ category: z.string().describe('A short, consistent label for grouping related memories. Use lowercase-kebab-case. Common categories: "facts" (things that are true about the user or world), "preferences" (how the user likes things done), "context" (project state, decisions, plans). When in doubt, use "facts". Always reuse existing categories — call listCategories first if unsure.'),
81
+ text: z.string().describe('A single, self-contained statement of what to remember. Write it as a fact, not a conversation excerpt. Good: "User prefers dark mode". Bad: "The user said they like dark mode in our chat".'),
82
+ metadata: z.record(z.string(), z.string()).optional().describe('Optional key-value tags for filtering later (e.g. {"source": "onboarding", "confidence": "high"})'),
83
+ }).describe('Persist a fact, preference, or piece of context to long-term memory so it can be recalled in future conversations. Safe to call liberally — duplicates are automatically detected and skipped.'),
84
+ },
85
+ recall: {
86
+ description: 'Search long-term memory using natural language. Returns the most semantically similar memories ranked by relevance. Call this BEFORE answering questions — you may already know something from a previous conversation.',
87
+ schema: z.object({
88
+ category: z.string().describe('The category to search in. If unsure which category holds what you need, call listCategories first, then search the most likely one. Use "facts" as a default.'),
89
+ query: z.string().describe('A natural-language description of what you are looking for. Phrase it as a question or topic, not keywords. Good: "what programming languages does the user prefer". Bad: "languages".'),
90
+ n_results: z.number().default(5).describe('How many results to return. Use 3-5 for focused lookups, up to 10 for broad exploration.'),
91
+ }).describe('Search long-term memory using natural language. Returns the most semantically similar memories ranked by relevance. Call this BEFORE answering questions — you may already know something from a previous conversation.'),
92
+ },
93
+ forgetCategory: {
94
+ description: 'Permanently delete all memories in a category. Use only when the user explicitly asks to forget something or when a category has become stale.',
95
+ schema: z.object({
96
+ category: z.string().describe('The category to wipe. This is irreversible — all memories in this category will be permanently deleted.'),
97
+ }).describe('Permanently delete all memories in a category. Use only when the user explicitly asks to forget something or when a category has become stale.'),
98
+ },
99
+ listCategories: {
100
+ description: 'List all memory categories and how many memories each contains. Call this at the start of a conversation to understand what you already know, and before recall if unsure which category to search.',
101
+ schema: z.object({}).describe('List all memory categories and how many memories each contains. Call this at the start of a conversation to understand what you already know, and before recall if unsure which category to search.'),
102
+ },
103
+ }
104
+
105
+ private _db: any = null
106
+ private _searcher: any = null
107
+
108
+ /** @internal */
109
+ private get db() {
110
+ if (!this._db) throw new Error('Memory not initialized. Call initDb() first.')
111
+ return this._db
112
+ }
113
+
114
+ /** @internal */
115
+ private get searcher() {
116
+ if (!this._searcher) {
117
+ this._searcher = this.container.feature('semanticSearch', {
118
+ embeddingModel: this.options.embeddingModel,
119
+ })
120
+ }
121
+ return this._searcher
122
+ }
123
+
124
+ /**
125
+ * Initialize the SQLite database and create tables.
126
+ * Called automatically on first use, but can be called explicitly.
127
+ *
128
+ * @example
129
+ * ```typescript
130
+ * const mem = container.feature('memory')
131
+ * await mem.initDb()
132
+ * ```
133
+ */
134
+ async initDb() {
135
+ if (this.state.get('dbReady')) return
136
+
137
+ const homedir = this.container.feature('os').homedir
138
+ const cwdHash = this.container.utils.hashObject(this.container.cwd)
139
+ const dbPath = this.options.dbPath || this.container.paths.join(homedir, '.luca', 'agent-memory', `${cwdHash}.db`)
140
+ const dir = dbPath.replace(/\/[^/]+$/, '')
141
+
142
+ const fs = this.container.feature('fs')
143
+ await fs.mkdirp(dir)
144
+
145
+ this._db = this.container.feature('sqlite', { path: dbPath })
146
+
147
+ await this.db.execute(`
148
+ CREATE TABLE IF NOT EXISTS memories (
149
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
150
+ namespace TEXT NOT NULL DEFAULT 'default',
151
+ category TEXT NOT NULL,
152
+ document TEXT NOT NULL,
153
+ metadata TEXT NOT NULL DEFAULT '{}',
154
+ embedding BLOB,
155
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
156
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
157
+ )
158
+ `)
159
+
160
+ await this.db.execute(`
161
+ CREATE INDEX IF NOT EXISTS idx_memories_ns_cat ON memories(namespace, category)
162
+ `)
163
+
164
+ await this.db.execute(`
165
+ CREATE TABLE IF NOT EXISTS epochs (
166
+ namespace TEXT NOT NULL,
167
+ value INTEGER NOT NULL DEFAULT 1,
168
+ PRIMARY KEY (namespace)
169
+ )
170
+ `)
171
+
172
+ const rows = await this.db.query<{ value: number }>(
173
+ 'SELECT value FROM epochs WHERE namespace = ?',
174
+ [this.options.namespace]
175
+ )
176
+
177
+ if (rows.length) {
178
+ this.state.set('epoch', rows[0].value)
179
+ } else {
180
+ await this.db.execute('INSERT INTO epochs (namespace, value) VALUES (?, 1)', [this.options.namespace])
181
+ }
182
+
183
+ this.state.set('dbReady', true)
184
+ this.emit('dbInitialized')
185
+ }
186
+
187
+ /** @internal Ensure db is ready before any operation */
188
+ private async ensureDb() {
189
+ if (!this.state.get('dbReady')) await this.initDb()
190
+ }
191
+
192
+ // --- Tool handler methods (auto-wired by toTools via matching names) ---
193
+
194
+ /** Tool handler: store a memory, deduplicating by similarity. */
195
+ async remember(args: { category: string; text: string; metadata?: Record<string, any> }) {
196
+ const mem = await this.createUnique(args.category, args.text, args.metadata || {})
197
+ if (mem) return { stored: true, id: mem.id, category: mem.category }
198
+ return { stored: false, reason: 'A similar memory already exists' }
199
+ }
200
+
201
+ /** Tool handler: search memories by semantic similarity. */
202
+ async recall(args: { category: string; query: string; n_results?: number }) {
203
+ const results = await this.search(args.category, args.query, args.n_results ?? 5)
204
+ return results.map(r => ({
205
+ document: r.document,
206
+ metadata: r.metadata,
207
+ distance: Math.round(r.distance * 1000) / 1000,
208
+ created_at: r.created_at,
209
+ }))
210
+ }
211
+
212
+ /** Tool handler: wipe all memories in a category. */
213
+ async forgetCategory(args: { category: string }) {
214
+ const deleted = await this.wipeCategory(args.category)
215
+ return { deleted, category: args.category }
216
+ }
217
+
218
+ /** Tool handler: list all categories with counts. */
219
+ async listCategories() {
220
+ const cats = await this.categories()
221
+ const counts: Record<string, number> = {}
222
+ for (const cat of cats) {
223
+ counts[cat] = await this.count(cat)
224
+ }
225
+ return { categories: counts }
226
+ }
227
+
228
+ /**
229
+ * When an assistant uses memory, inject system prompt guidance.
230
+ */
231
+ override setupToolsConsumer(consumer: Helper) {
232
+ if (typeof (consumer as any).addSystemPromptExtension === 'function') {
233
+ (consumer as any).addSystemPromptExtension('memory', [
234
+ '## Long-Term Memory',
235
+ '',
236
+ 'You have persistent memory that survives across conversations. Use it proactively:',
237
+ '',
238
+ '**Start of conversation:** Call `listCategories` to see what you already know. If categories exist, call `recall` with a broad query related to the user\'s first message. Do this before responding — context from prior sessions makes your answers dramatically better.',
239
+ '',
240
+ '**During conversation:** When the user shares facts about themselves, their preferences, decisions, or project context, call `remember` immediately. Don\'t wait — if it\'s worth noting, store it now. Duplicates are auto-detected so over-remembering is safe, under-remembering is not.',
241
+ '',
242
+ '**Before answering questions:** Call `recall` to check if you already have relevant knowledge. A user asking "what\'s my deploy target?" may have told you last week. Always check before saying "I don\'t know".',
243
+ '',
244
+ '**Categories:** Use consistent, descriptive kebab-case categories. Prefer a few broad categories ("facts", "preferences", "context") over many narrow ones. Always reuse existing categories rather than creating similar new ones.',
245
+ ].join('\n'))
246
+ }
247
+ }
248
+
249
+ // --- Core CRUD ---
250
+
251
+ /**
252
+ * Create a new memory in the given category.
253
+ *
254
+ * @param {string} category - The category to store the memory in
255
+ * @param {string} text - The text content of the memory
256
+ * @param {Record<string, any>} metadata - Optional metadata key-value pairs
257
+ * @returns {Promise<MemoryRecord>} The created memory
258
+ *
259
+ * @example
260
+ * ```typescript
261
+ * const mem = container.feature('memory')
262
+ * await mem.create('facts', 'The user lives in Austin', { confidence: 0.9 })
263
+ * ```
264
+ */
265
+ async create(category: string, text: string, metadata: Record<string, any> = {}): Promise<MemoryRecord> {
266
+ await this.ensureDb()
267
+
268
+ const embedding = await this.embed(text)
269
+ const embeddingBlob = this.float64ToBlob(embedding)
270
+ const metaJson = JSON.stringify({ ...metadata, epoch: this.state.get('epoch') })
271
+
272
+ const { lastInsertRowid } = await this.db.execute(
273
+ 'INSERT INTO memories (namespace, category, document, metadata, embedding) VALUES (?, ?, ?, ?, ?)',
274
+ [this.options.namespace, category, text, metaJson, embeddingBlob]
275
+ )
276
+
277
+ const id = Number(lastInsertRowid)
278
+ const memory = await this.get(category, id)
279
+ this.emit('memoryCreated', { id, category, document: text })
280
+ return memory!
281
+ }
282
+
283
+ /**
284
+ * Create a memory only if no sufficiently similar memory exists.
285
+ *
286
+ * @param {string} category - The category to store the memory in
287
+ * @param {string} text - The text content of the memory
288
+ * @param {Record<string, any>} metadata - Optional metadata
289
+ * @param {number} similarityThreshold - Minimum cosine similarity to consider a duplicate (0-1, default 0.95)
290
+ * @returns {Promise<MemoryRecord | null>} The created memory, or null if a similar one exists
291
+ *
292
+ * @example
293
+ * ```typescript
294
+ * const mem = container.feature('memory')
295
+ * await mem.createUnique('facts', 'User prefers dark mode', {}, 0.9)
296
+ * ```
297
+ */
298
+ async createUnique(category: string, text: string, metadata: Record<string, any> = {}, similarityThreshold = 0.95): Promise<MemoryRecord | null> {
299
+ await this.ensureDb()
300
+
301
+ const results = await this.search(category, text, 1)
302
+ if (results.length > 0 && (1 - results[0].distance) >= similarityThreshold) {
303
+ return null
304
+ }
305
+
306
+ return this.create(category, text, metadata)
307
+ }
308
+
309
+ /**
310
+ * Get a memory by ID.
311
+ *
312
+ * @param {string} category - The category the memory belongs to
313
+ * @param {number} id - The memory ID
314
+ * @returns {Promise<MemoryRecord | null>} The memory, or null if not found
315
+ */
316
+ async get(category: string, id: number): Promise<MemoryRecord | null> {
317
+ await this.ensureDb()
318
+
319
+ const rows = await this.db.query<any>(
320
+ 'SELECT id, category, document, metadata, created_at, updated_at FROM memories WHERE namespace = ? AND category = ? AND id = ?',
321
+ [this.options.namespace, category, id]
322
+ )
323
+
324
+ if (!rows.length) return null
325
+ return this.rowToMemory(rows[0])
326
+ }
327
+
328
+ /**
329
+ * Get all memories in a category, with optional metadata filtering.
330
+ *
331
+ * @param {string} category - The category to query
332
+ * @param {object} options - Query options
333
+ * @param {number} options.limit - Max results (default 20)
334
+ * @param {string} options.sortOrder - 'asc' or 'desc' by created_at (default 'desc')
335
+ * @param {Record<string, any>} options.filterMetadata - Filter by metadata key-value pairs
336
+ * @returns {Promise<MemoryRecord[]>} Array of memories
337
+ */
338
+ async getAll(category: string, options: { limit?: number; sortOrder?: 'asc' | 'desc'; filterMetadata?: Record<string, any> } = {}): Promise<MemoryRecord[]> {
339
+ await this.ensureDb()
340
+
341
+ const { limit = 20, sortOrder = 'desc', filterMetadata } = options
342
+
343
+ let rows = await this.db.query<any>(
344
+ `SELECT id, category, document, metadata, created_at, updated_at FROM memories WHERE namespace = ? AND category = ? ORDER BY created_at ${sortOrder === 'asc' ? 'ASC' : 'DESC'} LIMIT ?`,
345
+ [this.options.namespace, category, limit]
346
+ )
347
+
348
+ if (filterMetadata) {
349
+ rows = rows.filter((row: any) => {
350
+ const meta = JSON.parse(row.metadata)
351
+ return Object.entries(filterMetadata).every(([k, v]) => meta[k] === v)
352
+ })
353
+ }
354
+
355
+ return rows.map((r: any) => this.rowToMemory(r))
356
+ }
357
+
358
+ /**
359
+ * Update a memory's text and/or metadata.
360
+ *
361
+ * @param {string} category - The category the memory belongs to
362
+ * @param {number} id - The memory ID
363
+ * @param {object} updates - Fields to update
364
+ * @param {string} updates.text - New text content (re-embeds automatically)
365
+ * @param {Record<string, any>} updates.metadata - Metadata to merge
366
+ * @returns {Promise<MemoryRecord | null>} The updated memory
367
+ */
368
+ async update(category: string, id: number, updates: { text?: string; metadata?: Record<string, any> }): Promise<MemoryRecord | null> {
369
+ await this.ensureDb()
370
+
371
+ const existing = await this.get(category, id)
372
+ if (!existing) return null
373
+
374
+ const newText = updates.text ?? existing.document
375
+ const newMeta = updates.metadata ? { ...existing.metadata, ...updates.metadata } : existing.metadata
376
+
377
+ let embeddingBlob: Buffer | null = null
378
+ if (updates.text) {
379
+ const embedding = await this.embed(newText)
380
+ embeddingBlob = this.float64ToBlob(embedding)
381
+ }
382
+
383
+ if (embeddingBlob) {
384
+ await this.db.execute(
385
+ "UPDATE memories SET document = ?, metadata = ?, embedding = ?, updated_at = datetime('now') WHERE id = ? AND namespace = ?",
386
+ [newText, JSON.stringify(newMeta), embeddingBlob, id, this.options.namespace]
387
+ )
388
+ } else {
389
+ await this.db.execute(
390
+ "UPDATE memories SET document = ?, metadata = ?, updated_at = datetime('now') WHERE id = ? AND namespace = ?",
391
+ [newText, JSON.stringify(newMeta), id, this.options.namespace]
392
+ )
393
+ }
394
+
395
+ return this.get(category, id)
396
+ }
397
+
398
+ /**
399
+ * Delete a specific memory.
400
+ *
401
+ * @param {string} category - The category
402
+ * @param {number} id - The memory ID
403
+ * @returns {Promise<boolean>} True if deleted
404
+ */
405
+ async delete(category: string, id: number): Promise<boolean> {
406
+ await this.ensureDb()
407
+
408
+ const { changes } = await this.db.execute(
409
+ 'DELETE FROM memories WHERE namespace = ? AND category = ? AND id = ?',
410
+ [this.options.namespace, category, id]
411
+ )
412
+
413
+ if (changes > 0) {
414
+ this.emit('memoryDeleted', { id, category })
415
+ }
416
+
417
+ return changes > 0
418
+ }
419
+
420
+ /**
421
+ * Delete all memories in a category.
422
+ *
423
+ * @param {string} category - The category to wipe
424
+ * @returns {Promise<number>} Number of deleted memories
425
+ */
426
+ async wipeCategory(category: string): Promise<number> {
427
+ await this.ensureDb()
428
+
429
+ const { changes } = await this.db.execute(
430
+ 'DELETE FROM memories WHERE namespace = ? AND category = ?',
431
+ [this.options.namespace, category]
432
+ )
433
+
434
+ return changes
435
+ }
436
+
437
+ /**
438
+ * Delete all memories across all categories in this namespace.
439
+ *
440
+ * @returns {Promise<number>} Number of deleted memories
441
+ */
442
+ async wipeAll(): Promise<number> {
443
+ await this.ensureDb()
444
+
445
+ const { changes } = await this.db.execute(
446
+ 'DELETE FROM memories WHERE namespace = ?',
447
+ [this.options.namespace]
448
+ )
449
+
450
+ await this.setEpoch(1)
451
+
452
+ return changes
453
+ }
454
+
455
+ /**
456
+ * Count memories in a category (or all categories if omitted).
457
+ *
458
+ * @param {string} category - Optional category to count
459
+ * @returns {Promise<number>} The count
460
+ */
461
+ async count(category?: string): Promise<number> {
462
+ await this.ensureDb()
463
+
464
+ if (category) {
465
+ const rows = await this.db.query<{ cnt: number }>(
466
+ 'SELECT COUNT(*) as cnt FROM memories WHERE namespace = ? AND category = ?',
467
+ [this.options.namespace, category]
468
+ )
469
+ return rows[0].cnt
470
+ }
471
+
472
+ const rows = await this.db.query<{ cnt: number }>(
473
+ 'SELECT COUNT(*) as cnt FROM memories WHERE namespace = ?',
474
+ [this.options.namespace]
475
+ )
476
+ return rows[0].cnt
477
+ }
478
+
479
+ /**
480
+ * List all categories that have memories.
481
+ *
482
+ * @returns {Promise<string[]>} Array of category names
483
+ */
484
+ async categories(): Promise<string[]> {
485
+ await this.ensureDb()
486
+
487
+ const rows = await this.db.query<{ category: string }>(
488
+ 'SELECT DISTINCT category FROM memories WHERE namespace = ?',
489
+ [this.options.namespace]
490
+ )
491
+
492
+ return rows.map((r: { category: string }) => r.category)
493
+ }
494
+
495
+ // --- Semantic Search ---
496
+
497
+ /**
498
+ * Search memories by semantic similarity.
499
+ *
500
+ * @param {string} category - The category to search in
501
+ * @param {string} query - The search query (will be embedded)
502
+ * @param {number} nResults - Maximum number of results (default 5)
503
+ * @param {object} options - Additional search options
504
+ * @param {number} options.maxDistance - Maximum cosine distance threshold (0-2, default none)
505
+ * @param {Record<string, any>} options.filterMetadata - Filter by metadata key-value pairs
506
+ * @returns {Promise<MemorySearchResult[]>} Memories sorted by similarity (closest first)
507
+ */
508
+ async search(category: string, query: string, nResults = 5, options: { maxDistance?: number; filterMetadata?: Record<string, any> } = {}): Promise<MemorySearchResult[]> {
509
+ await this.ensureDb()
510
+
511
+ const queryEmbedding = await this.embed(query)
512
+
513
+ const rows = await this.db.query<any>(
514
+ 'SELECT id, category, document, metadata, embedding, created_at, updated_at FROM memories WHERE namespace = ? AND category = ? AND embedding IS NOT NULL',
515
+ [this.options.namespace, category]
516
+ )
517
+
518
+ let scored = rows.map((row: any) => {
519
+ const stored = this.blobToFloat64(row.embedding)
520
+ const distance = this.cosineDistance(queryEmbedding, stored)
521
+ return { ...this.rowToMemory(row), distance }
522
+ })
523
+
524
+ if (options.filterMetadata) {
525
+ scored = scored.filter((m: MemorySearchResult) =>
526
+ Object.entries(options.filterMetadata!).every(([k, v]) => m.metadata[k] === v)
527
+ )
528
+ }
529
+
530
+ if (options.maxDistance !== undefined) {
531
+ scored = scored.filter((m: MemorySearchResult) => m.distance <= options.maxDistance!)
532
+ }
533
+
534
+ scored.sort((a: MemorySearchResult, b: MemorySearchResult) => a.distance - b.distance)
535
+
536
+ return scored.slice(0, nResults)
537
+ }
538
+
539
+ // --- Epoch / Events ---
540
+
541
+ /**
542
+ * Get the current epoch value.
543
+ * @returns {number} The current epoch
544
+ */
545
+ getEpoch(): number {
546
+ return this.state.get('epoch') ?? 1
547
+ }
548
+
549
+ /**
550
+ * Set the epoch to a specific value.
551
+ * @param {number} value - The new epoch value
552
+ */
553
+ async setEpoch(value: number) {
554
+ await this.ensureDb()
555
+ await this.db.execute('UPDATE epochs SET value = ? WHERE namespace = ?', [value, this.options.namespace])
556
+ this.state.set('epoch', value)
557
+ this.emit('epochChanged', value)
558
+ }
559
+
560
+ /**
561
+ * Increment the epoch by 1.
562
+ * @returns {Promise<number>} The new epoch value
563
+ */
564
+ async incrementEpoch(): Promise<number> {
565
+ const next = this.getEpoch() + 1
566
+ await this.setEpoch(next)
567
+ return next
568
+ }
569
+
570
+ /**
571
+ * Create a timestamped event memory in the 'events' category,
572
+ * automatically tagged with the current epoch.
573
+ *
574
+ * @param {string} text - The event description
575
+ * @param {Record<string, any>} metadata - Optional additional metadata
576
+ * @returns {Promise<MemoryRecord>} The created event memory
577
+ */
578
+ async createEvent(text: string, metadata: Record<string, any> = {}): Promise<MemoryRecord> {
579
+ return this.create('events', text, { ...metadata, type: 'event', epoch: this.getEpoch() })
580
+ }
581
+
582
+ /**
583
+ * Get events, optionally filtered by epoch.
584
+ *
585
+ * @param {object} options - Query options
586
+ * @param {number} options.epoch - Filter to a specific epoch
587
+ * @param {number} options.limit - Max results (default 10)
588
+ * @returns {Promise<MemoryRecord[]>} Array of event memories
589
+ */
590
+ async getEvents(options: { epoch?: number; limit?: number } = {}): Promise<MemoryRecord[]> {
591
+ const filterMetadata = options.epoch !== undefined ? { type: 'event', epoch: options.epoch } : { type: 'event' }
592
+ return this.getAll('events', { limit: options.limit ?? 10, filterMetadata })
593
+ }
594
+
595
+ // --- Import / Export ---
596
+
597
+ /**
598
+ * Export all memories in this namespace to a JSON-serializable object.
599
+ */
600
+ async exportToJson(): Promise<{ namespace: string; epoch: number; memories: MemoryRecord[] }> {
601
+ await this.ensureDb()
602
+
603
+ const rows = await this.db.query<any>(
604
+ 'SELECT id, category, document, metadata, created_at, updated_at FROM memories WHERE namespace = ? ORDER BY category, id',
605
+ [this.options.namespace]
606
+ )
607
+
608
+ return {
609
+ namespace: this.options.namespace,
610
+ epoch: this.getEpoch(),
611
+ memories: rows.map((r: any) => this.rowToMemory(r)),
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Import memories from a JSON export. Optionally replaces all existing memories.
617
+ *
618
+ * @param {object} data - The exported data object
619
+ * @param {boolean} replace - If true, wipe existing memories before importing (default true)
620
+ * @returns {Promise<number>} Number of memories imported
621
+ */
622
+ async importFromJson(data: { namespace?: string; epoch?: number; memories: Array<{ category: string; document: string; metadata?: Record<string, any> }> }, replace = true): Promise<number> {
623
+ await this.ensureDb()
624
+
625
+ if (replace) {
626
+ await this.wipeAll()
627
+ }
628
+
629
+ let count = 0
630
+ for (const mem of data.memories) {
631
+ await this.create(mem.category, mem.document, mem.metadata || {})
632
+ count++
633
+ }
634
+
635
+ if (data.epoch !== undefined) {
636
+ await this.setEpoch(data.epoch)
637
+ }
638
+
639
+ return count
640
+ }
641
+
642
+ // --- Internal Helpers ---
643
+
644
+ /** @internal Embed a single text string, returns flat number array */
645
+ private async embed(text: string): Promise<number[]> {
646
+ const results = await this.searcher.embed([text])
647
+ return results[0]
648
+ }
649
+
650
+ /** @internal Convert number[] to a Buffer for BLOB storage */
651
+ private float64ToBlob(arr: number[]): Buffer {
652
+ const buf = Buffer.alloc(arr.length * 8)
653
+ for (let i = 0; i < arr.length; i++) {
654
+ buf.writeDoubleLE(arr[i], i * 8)
655
+ }
656
+ return buf
657
+ }
658
+
659
+ /** @internal Convert a BLOB back to number[] */
660
+ private blobToFloat64(blob: Buffer | Uint8Array): number[] {
661
+ const buf = Buffer.from(blob)
662
+ const arr = new Array(buf.length / 8)
663
+ for (let i = 0; i < arr.length; i++) {
664
+ arr[i] = buf.readDoubleLE(i * 8)
665
+ }
666
+ return arr
667
+ }
668
+
669
+ /** @internal Cosine distance between two vectors (0 = identical, 2 = opposite) */
670
+ private cosineDistance(a: number[], b: number[]): number {
671
+ let dot = 0, magA = 0, magB = 0
672
+ for (let i = 0; i < a.length; i++) {
673
+ dot += a[i] * b[i]
674
+ magA += a[i] * a[i]
675
+ magB += b[i] * b[i]
676
+ }
677
+ const similarity = dot / (Math.sqrt(magA) * Math.sqrt(magB))
678
+ return 1 - similarity
679
+ }
680
+
681
+ /** @internal Convert a SQLite row to a MemoryRecord object */
682
+ private rowToMemory(row: any): MemoryRecord {
683
+ return {
684
+ id: row.id,
685
+ category: row.category,
686
+ document: row.document,
687
+ metadata: typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata,
688
+ created_at: row.created_at,
689
+ updated_at: row.updated_at,
690
+ }
691
+ }
692
+ }
693
+
694
+ export default Memory
@@ -261,7 +261,7 @@ export class Assistant extends Feature<AssistantState, AssistantOptions> {
261
261
  // Bind hooks to events BEFORE emitting created so the created hook fires
262
262
  this.bindHooksToEvents()
263
263
 
264
- this.emit('created')
264
+ setTimeout(() => this.emit('created'), 1)
265
265
  }
266
266
 
267
267
  get conversation(): Conversation {