@cartisien/engram 0.2.0 → 0.3.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/dist/index.js CHANGED
@@ -1,269 +1,23 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Engram = exports.TemporalQuery = void 0;
3
+ exports.Engram = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  /**
6
- * Temporal query parser for natural language time expressions
7
- */
8
- class TemporalQuery {
9
- referenceDate;
10
- timezoneOffset;
11
- constructor(referenceDate = new Date(), timezoneOffset = -referenceDate.getTimezoneOffset()) {
12
- this.referenceDate = new Date(referenceDate);
13
- this.timezoneOffset = timezoneOffset;
14
- }
15
- /**
16
- * Parse a natural language time expression into a concrete time range
17
- *
18
- * Supports:
19
- * - Relative: 'today', 'yesterday', 'tomorrow'
20
- * - Days ago: '3 days ago', 'a week ago', '2 weeks ago'
21
- * - Last: 'last monday', 'last week', 'last month'
22
- * - This: 'this week', 'this month', 'this year'
23
- * - Recent: 'recent', 'lately', 'recently' (last 7 days)
24
- * - Between: 'january 15 to january 20', '3/1 to 3/15'
25
- */
26
- parse(expression) {
27
- const normalized = expression.toLowerCase().trim();
28
- const now = new Date(this.referenceDate);
29
- // Handle 'now', 'recent', 'lately', 'recently' → last 7 days
30
- if (/^(now|recent|lately|recently)$/.test(normalized)) {
31
- const end = new Date(now);
32
- const start = new Date(now);
33
- start.setDate(start.getDate() - 7);
34
- return { start, end, description: 'last 7 days' };
35
- }
36
- // Handle 'today'
37
- if (normalized === 'today') {
38
- const start = this.startOfDay(now);
39
- const end = new Date(now);
40
- return { start, end, description: 'today' };
41
- }
42
- // Handle 'yesterday'
43
- if (normalized === 'yesterday') {
44
- const start = this.startOfDay(now);
45
- start.setDate(start.getDate() - 1);
46
- const end = this.endOfDay(start);
47
- return { start, end, description: 'yesterday' };
48
- }
49
- // Handle 'tomorrow' (future, but useful for completeness)
50
- if (normalized === 'tomorrow') {
51
- const start = this.startOfDay(now);
52
- start.setDate(start.getDate() + 1);
53
- const end = this.endOfDay(start);
54
- return { start, end, description: 'tomorrow' };
55
- }
56
- // Handle 'N days ago' / 'a week ago' / 'N weeks ago'
57
- const daysAgoMatch = normalized.match(/^(?:(\d+)|a|one)\s+(day|week|month)s?\s+ago$/);
58
- if (daysAgoMatch) {
59
- const num = daysAgoMatch[1] ? parseInt(daysAgoMatch[1]) : 1;
60
- const unit = daysAgoMatch[2];
61
- const start = this.startOfDay(now);
62
- const end = this.endOfDay(now);
63
- if (unit === 'day') {
64
- start.setDate(start.getDate() - num);
65
- end.setDate(end.getDate() - num);
66
- }
67
- else if (unit === 'week') {
68
- start.setDate(start.getDate() - (num * 7));
69
- end.setDate(end.getDate() - ((num - 1) * 7) - 1);
70
- }
71
- else if (unit === 'month') {
72
- start.setMonth(start.getMonth() - num);
73
- start.setDate(1);
74
- end.setMonth(end.getMonth() - num + 1);
75
- end.setDate(0);
76
- }
77
- return { start, end, description: `${num} ${unit}${num > 1 ? 's' : ''} ago` };
78
- }
79
- // Handle 'last N days/weeks/months' (range ending now)
80
- const lastNMatch = normalized.match(/^last\s+(?:(\d+)|a|one)\s+(day|week|month)s?$/);
81
- if (lastNMatch) {
82
- const num = lastNMatch[1] ? parseInt(lastNMatch[1]) : 1;
83
- const unit = lastNMatch[2];
84
- const start = new Date(now);
85
- const end = new Date(now);
86
- if (unit === 'day') {
87
- start.setDate(start.getDate() - num);
88
- }
89
- else if (unit === 'week') {
90
- start.setDate(start.getDate() - (num * 7));
91
- }
92
- else if (unit === 'month') {
93
- start.setMonth(start.getMonth() - num);
94
- }
95
- return { start, end, description: `last ${num} ${unit}${num > 1 ? 's' : ''}` };
96
- }
97
- // Handle 'this week/month/year'
98
- const thisMatch = normalized.match(/^this\s+(week|month|year)$/);
99
- if (thisMatch) {
100
- const unit = thisMatch[1];
101
- const start = new Date(now);
102
- const end = new Date(now);
103
- if (unit === 'week') {
104
- const dayOfWeek = start.getDay();
105
- start.setDate(start.getDate() - dayOfWeek);
106
- start.setHours(0, 0, 0, 0);
107
- end.setDate(start.getDate() + 6);
108
- end.setHours(23, 59, 59, 999);
109
- }
110
- else if (unit === 'month') {
111
- start.setDate(1);
112
- start.setHours(0, 0, 0, 0);
113
- end.setMonth(end.getMonth() + 1);
114
- end.setDate(0);
115
- end.setHours(23, 59, 59, 999);
116
- }
117
- else if (unit === 'year') {
118
- start.setMonth(0, 1);
119
- start.setHours(0, 0, 0, 0);
120
- end.setMonth(11, 31);
121
- end.setHours(23, 59, 59, 999);
122
- }
123
- return { start, end, description: `this ${unit}` };
124
- }
125
- // Handle 'last week/month/year' (previous full period)
126
- const lastPeriodMatch = normalized.match(/^last\s+(week|month|year)$/);
127
- if (lastPeriodMatch) {
128
- const unit = lastPeriodMatch[1];
129
- const start = new Date(now);
130
- const end = new Date(now);
131
- if (unit === 'week') {
132
- const dayOfWeek = start.getDay();
133
- start.setDate(start.getDate() - dayOfWeek - 7);
134
- start.setHours(0, 0, 0, 0);
135
- end.setDate(start.getDate() + 6);
136
- end.setHours(23, 59, 59, 999);
137
- }
138
- else if (unit === 'month') {
139
- start.setMonth(start.getMonth() - 1);
140
- start.setDate(1);
141
- start.setHours(0, 0, 0, 0);
142
- end.setDate(0);
143
- end.setHours(23, 59, 59, 999);
144
- }
145
- else if (unit === 'year') {
146
- start.setFullYear(start.getFullYear() - 1);
147
- start.setMonth(0, 1);
148
- start.setHours(0, 0, 0, 0);
149
- end.setMonth(0, 0);
150
- end.setHours(23, 59, 59, 999);
151
- }
152
- return { start, end, description: `last ${unit}` };
153
- }
154
- // Handle day names: 'monday', 'last monday', 'tuesday', etc.
155
- const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
156
- const dayMatch = normalized.match(/^(?:last\s+)?(sunday|monday|tuesday|wednesday|thursday|friday|saturday)$/);
157
- if (dayMatch) {
158
- const targetDay = dayNames.indexOf(dayMatch[1]);
159
- const isLast = normalized.startsWith('last ');
160
- const start = this.startOfDay(now);
161
- let daysDiff = start.getDay() - targetDay;
162
- if (daysDiff <= 0) {
163
- daysDiff += 7;
164
- }
165
- if (isLast && daysDiff === 0) {
166
- daysDiff = 7;
167
- }
168
- start.setDate(start.getDate() - daysDiff);
169
- const end = this.endOfDay(start);
170
- return { start, end, description: isLast ? `last ${dayMatch[1]}` : dayMatch[1] };
171
- }
172
- // Handle date ranges: 'jan 15 to jan 20', '3/1 to 3/15', '2024-01-15 to 2024-01-20'
173
- const rangeMatch = normalized.match(/^(.+?)\s+(?:to|through|until|-)\s+(.+)$/);
174
- if (rangeMatch) {
175
- const startDate = this.parseDate(rangeMatch[1]);
176
- const endDate = this.parseDate(rangeMatch[2]);
177
- if (startDate && endDate) {
178
- return {
179
- start: this.startOfDay(startDate),
180
- end: this.endOfDay(endDate),
181
- description: `${rangeMatch[1]} to ${rangeMatch[2]}`
182
- };
183
- }
184
- }
185
- // Try to parse as single date
186
- const singleDate = this.parseDate(normalized);
187
- if (singleDate) {
188
- return {
189
- start: this.startOfDay(singleDate),
190
- end: this.endOfDay(singleDate),
191
- description: normalized
192
- };
193
- }
194
- return null;
195
- }
196
- parseDate(expr) {
197
- const normalized = expr.trim().toLowerCase();
198
- const now = new Date(this.referenceDate);
199
- // Try various date formats
200
- const formats = [
201
- // MM/DD or MM/DD/YY or MM/DD/YYYY
202
- { regex: /^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?$/, fn: (m) => {
203
- const month = parseInt(m[1]) - 1;
204
- const day = parseInt(m[2]);
205
- let year = now.getFullYear();
206
- if (m[3]) {
207
- const y = parseInt(m[3]);
208
- year = y < 100 ? (y < 50 ? 2000 + y : 1900 + y) : y;
209
- }
210
- return new Date(year, month, day);
211
- } },
212
- // Month name + day (e.g., "january 15" or "jan 15")
213
- { regex: /^(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(\d{1,2})(?:,?\s+(\d{4}))?$/i, fn: (m) => {
214
- const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
215
- const month = months.indexOf(m[1].toLowerCase().slice(0, 3));
216
- const day = parseInt(m[2]);
217
- let year = now.getFullYear();
218
- if (m[3])
219
- year = parseInt(m[3]);
220
- return new Date(year, month, day);
221
- } },
222
- // ISO date YYYY-MM-DD
223
- { regex: /^(\d{4})-(\d{2})-(\d{2})$/, fn: (m) => {
224
- return new Date(parseInt(m[1]), parseInt(m[2]) - 1, parseInt(m[3]));
225
- } }
226
- ];
227
- for (const format of formats) {
228
- const match = normalized.match(format.regex);
229
- if (match) {
230
- const date = format.fn(match);
231
- if (!isNaN(date.getTime()))
232
- return date;
233
- }
234
- }
235
- return null;
236
- }
237
- startOfDay(date) {
238
- const d = new Date(date);
239
- d.setHours(0, 0, 0, 0);
240
- return d;
241
- }
242
- endOfDay(date) {
243
- const d = new Date(date);
244
- d.setHours(23, 59, 59, 999);
245
- return d;
246
- }
247
- }
248
- exports.TemporalQuery = TemporalQuery;
249
- /**
250
- * Engram - Persistent memory for AI assistants
6
+ * Engram - Persistent semantic memory for AI agents
251
7
  *
252
- * A lightweight, SQLite-backed memory system that gives your AI assistants
253
- * the ability to remember conversations across sessions.
8
+ * v0.3 adds graph memory entity relationships extracted from memories
9
+ * using a local LLM, enabling richer contextual recall.
254
10
  *
255
11
  * @example
256
12
  * ```typescript
257
13
  * import { Engram } from '@cartisien/engram';
258
14
  *
259
- * const memory = new Engram({ dbPath: './memory.db' });
15
+ * const memory = new Engram({ dbPath: './memory.db', graphMemory: true });
260
16
  *
261
- * // Store a memory
262
- * await memory.remember('user_123', 'Jeff loves Triumph motorcycles', 'user');
263
- *
264
- * // Retrieve with temporal query
265
- * const yesterday = await memory.recallByTime('user_123', 'yesterday');
266
- * const lastWeek = await memory.recallByTime('user_123', 'last week');
17
+ * await memory.remember('session_1', 'Jeff is building GovScout in React 19', 'user');
18
+ * const context = await memory.recall('session_1', 'what is Jeff building?', 5);
19
+ * const graph = await memory.graph('session_1', 'GovScout');
20
+ * // { entity: 'GovScout', relationships: [{ relation: 'built_with', target: 'React 19' }], ... }
267
21
  * ```
268
22
  */
269
23
  class Engram {
@@ -271,9 +25,19 @@ class Engram {
271
25
  maxContextLength;
272
26
  dbPath;
273
27
  initialized = false;
28
+ embeddingUrl;
29
+ embeddingModel;
30
+ semanticSearch;
31
+ graphMemory;
32
+ graphModel;
274
33
  constructor(config = {}) {
275
34
  this.dbPath = config.dbPath || ':memory:';
276
35
  this.maxContextLength = config.maxContextLength || 4000;
36
+ this.embeddingUrl = config.embeddingUrl || 'http://192.168.68.73:11434';
37
+ this.embeddingModel = config.embeddingModel || 'nomic-embed-text';
38
+ this.semanticSearch = config.semanticSearch !== false;
39
+ this.graphMemory = config.graphMemory === true;
40
+ this.graphModel = config.graphModel || 'qwen2.5:32b';
277
41
  }
278
42
  async init() {
279
43
  if (this.initialized)
@@ -284,7 +48,7 @@ class Engram {
284
48
  filename: this.dbPath,
285
49
  driver: sqlite3.Database
286
50
  });
287
- // Create memories table
51
+ // Memories table
288
52
  await this.db.exec(`
289
53
  CREATE TABLE IF NOT EXISTS memories (
290
54
  id TEXT PRIMARY KEY,
@@ -293,21 +57,146 @@ class Engram {
293
57
  role TEXT CHECK(role IN ('user', 'assistant', 'system')),
294
58
  timestamp INTEGER NOT NULL,
295
59
  metadata TEXT,
296
- content_hash TEXT NOT NULL
60
+ content_hash TEXT NOT NULL,
61
+ embedding TEXT
297
62
  );
298
63
  `);
299
- // Create index for fast session lookups
64
+ // Add embedding column if upgrading from v0.1
65
+ try {
66
+ await this.db.exec(`ALTER TABLE memories ADD COLUMN embedding TEXT`);
67
+ }
68
+ catch { /* already exists */ }
69
+ // v0.3: Graph tables
300
70
  await this.db.exec(`
301
- CREATE INDEX IF NOT EXISTS idx_session_timestamp
302
- ON memories(session_id, timestamp DESC);
71
+ CREATE TABLE IF NOT EXISTS graph_nodes (
72
+ id TEXT PRIMARY KEY,
73
+ session_id TEXT NOT NULL,
74
+ entity TEXT NOT NULL,
75
+ type TEXT,
76
+ created_at INTEGER NOT NULL
77
+ );
78
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_node_entity
79
+ ON graph_nodes(session_id, entity);
80
+ `);
81
+ await this.db.exec(`
82
+ CREATE TABLE IF NOT EXISTS graph_edges (
83
+ id TEXT PRIMARY KEY,
84
+ session_id TEXT NOT NULL,
85
+ from_entity TEXT NOT NULL,
86
+ relation TEXT NOT NULL,
87
+ to_entity TEXT NOT NULL,
88
+ confidence REAL DEFAULT 1.0,
89
+ memory_id TEXT,
90
+ created_at INTEGER NOT NULL
91
+ );
92
+ CREATE INDEX IF NOT EXISTS idx_edge_from
93
+ ON graph_edges(session_id, from_entity);
94
+ CREATE INDEX IF NOT EXISTS idx_edge_to
95
+ ON graph_edges(session_id, to_entity);
303
96
  `);
304
- // Create index for content search
305
97
  await this.db.exec(`
306
- CREATE INDEX IF NOT EXISTS idx_content
307
- ON memories(content);
98
+ CREATE INDEX IF NOT EXISTS idx_session_timestamp
99
+ ON memories(session_id, timestamp DESC);
308
100
  `);
309
101
  this.initialized = true;
310
102
  }
103
+ /**
104
+ * Fetch embedding vector from Ollama
105
+ */
106
+ async embed(text) {
107
+ try {
108
+ const response = await fetch(`${this.embeddingUrl}/api/embeddings`, {
109
+ method: 'POST',
110
+ headers: { 'Content-Type': 'application/json' },
111
+ body: JSON.stringify({ model: this.embeddingModel, prompt: text }),
112
+ signal: AbortSignal.timeout(5000)
113
+ });
114
+ if (!response.ok)
115
+ return null;
116
+ const data = await response.json();
117
+ return data.embedding ?? null;
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
123
+ /**
124
+ * Extract entity-relationship triples from text using a local LLM
125
+ */
126
+ async extractGraph(text) {
127
+ const prompt = `Extract entity-relationship triples from this text. Return ONLY a JSON array of objects with keys: "from", "relation", "to". Be concise. Max 5 triples. If nothing to extract, return [].
128
+
129
+ Text: "${text}"
130
+
131
+ JSON array:`;
132
+ try {
133
+ const response = await fetch(`${this.embeddingUrl}/api/generate`, {
134
+ method: 'POST',
135
+ headers: { 'Content-Type': 'application/json' },
136
+ body: JSON.stringify({
137
+ model: this.graphModel,
138
+ prompt,
139
+ stream: false,
140
+ options: { temperature: 0, num_predict: 200 }
141
+ }),
142
+ signal: AbortSignal.timeout(15000)
143
+ });
144
+ if (!response.ok)
145
+ return [];
146
+ const data = await response.json();
147
+ const raw = data.response.trim();
148
+ // Extract JSON array from response
149
+ const match = raw.match(/\[[\s\S]*\]/);
150
+ if (!match)
151
+ return [];
152
+ const triples = JSON.parse(match[0]);
153
+ return triples
154
+ .filter(t => t.from && t.relation && t.to)
155
+ .map(t => ({
156
+ from: t.from.toLowerCase().trim(),
157
+ relation: t.relation.toLowerCase().trim(),
158
+ to: t.to.toLowerCase().trim(),
159
+ confidence: 0.9
160
+ }));
161
+ }
162
+ catch {
163
+ return [];
164
+ }
165
+ }
166
+ /**
167
+ * Upsert a graph node
168
+ */
169
+ async upsertNode(sessionId, entity, type) {
170
+ const id = (0, crypto_1.createHash)('sha256').update(`${sessionId}:${entity}`).digest('hex').slice(0, 16);
171
+ await this.db.run(`INSERT OR IGNORE INTO graph_nodes (id, session_id, entity, type, created_at)
172
+ VALUES (?, ?, ?, ?, ?)`, [id, sessionId, entity, type || null, Date.now()]);
173
+ }
174
+ /**
175
+ * Store a graph edge
176
+ */
177
+ async storeEdge(sessionId, edge, memoryId) {
178
+ const id = (0, crypto_1.createHash)('sha256')
179
+ .update(`${sessionId}:${edge.from}:${edge.relation}:${edge.to}`)
180
+ .digest('hex').slice(0, 16);
181
+ await this.upsertNode(sessionId, edge.from);
182
+ await this.upsertNode(sessionId, edge.to);
183
+ await this.db.run(`INSERT OR REPLACE INTO graph_edges
184
+ (id, session_id, from_entity, relation, to_entity, confidence, memory_id, created_at)
185
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [id, sessionId, edge.from, edge.relation, edge.to, edge.confidence ?? 1.0, memoryId, Date.now()]);
186
+ }
187
+ /**
188
+ * Cosine similarity between two vectors
189
+ */
190
+ cosineSimilarity(a, b) {
191
+ let dot = 0, magA = 0, magB = 0;
192
+ for (let i = 0; i < a.length; i++) {
193
+ dot += a[i] * b[i];
194
+ magA += a[i] * a[i];
195
+ magB += b[i] * b[i];
196
+ }
197
+ const denom = Math.sqrt(magA) * Math.sqrt(magB);
198
+ return denom === 0 ? 0 : dot / denom;
199
+ }
311
200
  /**
312
201
  * Store a memory entry
313
202
  */
@@ -315,59 +204,40 @@ class Engram {
315
204
  await this.init();
316
205
  const id = (0, crypto_1.createHash)('sha256')
317
206
  .update(`${sessionId}:${content}:${Date.now()}`)
318
- .digest('hex')
319
- .slice(0, 16);
320
- const contentHash = (0, crypto_1.createHash)('sha256')
321
- .update(content)
322
- .digest('hex')
323
- .slice(0, 16);
207
+ .digest('hex').slice(0, 16);
208
+ const contentHash = (0, crypto_1.createHash)('sha256').update(content).digest('hex').slice(0, 16);
209
+ const truncated = content.slice(0, this.maxContextLength);
210
+ // Fetch embedding
211
+ let embeddingJson = null;
212
+ if (this.semanticSearch) {
213
+ const vector = await this.embed(truncated);
214
+ if (vector)
215
+ embeddingJson = JSON.stringify(vector);
216
+ }
324
217
  const entry = {
325
- id,
326
- sessionId,
327
- content: content.slice(0, this.maxContextLength),
328
- role,
329
- timestamp: new Date(),
330
- metadata
218
+ id, sessionId, content: truncated, role,
219
+ timestamp: new Date(), metadata
331
220
  };
332
- await this.db.run(`INSERT INTO memories (id, session_id, content, role, timestamp, metadata, content_hash)
333
- VALUES (?, ?, ?, ?, ?, ?, ?)`, [
334
- entry.id,
335
- entry.sessionId,
336
- entry.content,
337
- entry.role,
338
- entry.timestamp.getTime(),
339
- metadata ? JSON.stringify(metadata) : null,
340
- contentHash
341
- ]);
221
+ await this.db.run(`INSERT INTO memories (id, session_id, content, role, timestamp, metadata, content_hash, embedding)
222
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [id, sessionId, truncated, role, entry.timestamp.getTime(),
223
+ metadata ? JSON.stringify(metadata) : null, contentHash, embeddingJson]);
224
+ // v0.3: Extract graph relationships
225
+ if (this.graphMemory) {
226
+ const edges = await this.extractGraph(truncated);
227
+ for (const edge of edges) {
228
+ await this.storeEdge(sessionId, edge, id);
229
+ }
230
+ }
342
231
  return entry;
343
232
  }
344
233
  /**
345
- * Recall memories for a session
346
- *
347
- * Supports temporal queries via options.temporalQuery:
348
- * - 'yesterday', 'today', 'tomorrow'
349
- * - '3 days ago', 'a week ago', '2 weeks ago'
350
- * - 'last monday', 'last week', 'last month'
351
- * - 'this week', 'this month'
352
- * - 'last 3 days', 'last week'
353
- * - 'january 15', '3/15', '2024-01-15'
354
- * - 'jan 15 to jan 20', '3/1 to 3/15'
234
+ * Recall memories with optional graph traversal
355
235
  */
356
236
  async recall(sessionId, query, limit = 10, options = {}) {
357
237
  await this.init();
358
- // Handle temporal query if provided
359
- if (options.temporalQuery) {
360
- const temporal = new TemporalQuery(new Date(), options.timezoneOffset);
361
- const range = temporal.parse(options.temporalQuery);
362
- if (range) {
363
- options.after = range.start;
364
- options.before = range.end;
365
- }
366
- }
367
238
  let sql = `
368
- SELECT id, session_id, content, role, timestamp, metadata
369
- FROM memories
370
- WHERE session_id = ?
239
+ SELECT id, session_id, content, role, timestamp, metadata, embedding
240
+ FROM memories WHERE session_id = ?
371
241
  `;
372
242
  const params = [sessionId];
373
243
  if (options.role) {
@@ -382,7 +252,43 @@ class Engram {
382
252
  sql += ` AND timestamp <= ?`;
383
253
  params.push(options.before.getTime());
384
254
  }
385
- // Simple keyword matching if query provided
255
+ // Semantic search
256
+ if (query && query.trim() && this.semanticSearch) {
257
+ const queryVector = await this.embed(query);
258
+ if (queryVector) {
259
+ sql += ` ORDER BY timestamp DESC`;
260
+ const rows = await this.db.all(sql, params);
261
+ const scored = rows
262
+ .map((row) => {
263
+ let similarity = 0;
264
+ if (row.embedding) {
265
+ try {
266
+ const vec = JSON.parse(row.embedding);
267
+ similarity = this.cosineSimilarity(queryVector, vec);
268
+ }
269
+ catch { /* skip */ }
270
+ }
271
+ return { row, similarity };
272
+ })
273
+ .sort((a, b) => b.similarity - a.similarity)
274
+ .slice(0, limit);
275
+ const results = scored.map(({ row, similarity }) => ({
276
+ id: row.id,
277
+ sessionId: row.session_id,
278
+ content: row.content,
279
+ role: row.role,
280
+ timestamp: new Date(row.timestamp),
281
+ metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
282
+ similarity
283
+ }));
284
+ // v0.3: Augment with graph-connected memories
285
+ if (this.graphMemory && options.includeGraph !== false) {
286
+ return this.augmentWithGraph(sessionId, results, limit);
287
+ }
288
+ return results;
289
+ }
290
+ }
291
+ // Keyword fallback
386
292
  if (query && query.trim()) {
387
293
  const keywords = query.toLowerCase().split(/\s+/).filter(k => k.length > 2);
388
294
  if (keywords.length > 0) {
@@ -403,104 +309,97 @@ class Engram {
403
309
  }));
404
310
  }
405
311
  /**
406
- * Recall memories by natural language time expression
407
- *
408
- * @example
409
- * ```typescript
410
- * // Get yesterday's memories
411
- * const yesterday = await memory.recallByTime('session_123', 'yesterday');
412
- *
413
- * // Get last week's memories
414
- * const lastWeek = await memory.recallByTime('session_123', 'last week');
415
- *
416
- * // Get memories from 3 days ago
417
- * const threeDaysAgo = await memory.recallByTime('session_123', '3 days ago');
418
- * ```
312
+ * Augment recall results with graph-connected memories
419
313
  */
420
- async recallByTime(sessionId, temporalQuery, query, limit = 50, options = {}) {
421
- const temporal = new TemporalQuery(new Date(), options.timezoneOffset);
422
- const range = temporal.parse(temporalQuery);
423
- if (!range) {
424
- throw new Error(`Unable to parse temporal query: "${temporalQuery}"`);
314
+ async augmentWithGraph(sessionId, results, limit) {
315
+ // Collect memory IDs that appear in graph edges
316
+ const seenIds = new Set(results.map(r => r.id));
317
+ const graphMemoryIds = new Set();
318
+ for (const result of results.slice(0, 3)) { // Only expand top 3
319
+ const edges = await this.db.all(`SELECT memory_id FROM graph_edges WHERE session_id = ? AND memory_id IS NOT NULL
320
+ AND (from_entity IN (
321
+ SELECT from_entity FROM graph_edges WHERE memory_id = ?
322
+ UNION SELECT to_entity FROM graph_edges WHERE memory_id = ?
323
+ ))
324
+ LIMIT 5`, [sessionId, result.id, result.id]);
325
+ for (const edge of edges) {
326
+ if (edge.memory_id && !seenIds.has(edge.memory_id)) {
327
+ graphMemoryIds.add(edge.memory_id);
328
+ }
329
+ }
425
330
  }
426
- const entries = await this.recall(sessionId, query, limit, {
427
- ...options,
428
- after: range.start,
429
- before: range.end
430
- });
431
- return { entries, range };
432
- }
433
- /**
434
- * Get memories from the last N days
435
- */
436
- async recallRecent(sessionId, days = 7, query, limit = 50, options = {}) {
437
- const since = new Date();
438
- since.setDate(since.getDate() - days);
439
- since.setHours(0, 0, 0, 0);
440
- const entries = await this.recall(sessionId, query, limit, {
441
- ...options,
442
- after: since
443
- });
444
- return { entries, days, since };
445
- }
446
- /**
447
- * Get memories since a specific date
448
- */
449
- async recallSince(sessionId, since, query, limit = 50, options = {}) {
450
- const entries = await this.recall(sessionId, query, limit, {
451
- ...options,
452
- after: since
453
- });
454
- return { entries, since, count: entries.length };
455
- }
456
- /**
457
- * Get memories between two dates
458
- */
459
- async recallBetween(sessionId, start, end, query, limit = 50, options = {}) {
460
- const entries = await this.recall(sessionId, query, limit, {
461
- ...options,
462
- after: start,
463
- before: end
464
- });
465
- return { entries, start, end, count: entries.length };
331
+ if (graphMemoryIds.size === 0)
332
+ return results;
333
+ // Fetch connected memories
334
+ const placeholders = Array.from(graphMemoryIds).map(() => '?').join(',');
335
+ const connectedRows = await this.db.all(`SELECT id, session_id, content, role, timestamp, metadata FROM memories
336
+ WHERE id IN (${placeholders})`, Array.from(graphMemoryIds));
337
+ const connected = connectedRows.map((row) => ({
338
+ id: row.id,
339
+ sessionId: row.session_id,
340
+ content: row.content,
341
+ role: row.role,
342
+ timestamp: new Date(row.timestamp),
343
+ metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
344
+ similarity: 0 // Graph-connected, not vector-matched
345
+ }));
346
+ return [...results, ...connected].slice(0, limit);
466
347
  }
467
348
  /**
468
- * Get a daily summary of memories
469
- *
470
- * Returns memories grouped by day, useful for "what happened each day" views
349
+ * v0.3: Query the knowledge graph for an entity
471
350
  */
472
- async dailySummary(sessionId, days = 7) {
351
+ async graph(sessionId, entity) {
473
352
  await this.init();
474
- const since = new Date();
475
- since.setDate(since.getDate() - days);
476
- since.setHours(0, 0, 0, 0);
477
- const entries = await this.recall(sessionId, undefined, 1000, { after: since });
478
- // Group by day
479
- const grouped = new Map();
480
- for (const entry of entries) {
481
- const dateKey = entry.timestamp.toISOString().split('T')[0];
482
- if (!grouped.has(dateKey)) {
483
- grouped.set(dateKey, []);
484
- }
485
- grouped.get(dateKey).push(entry);
353
+ const ent = entity.toLowerCase().trim();
354
+ // Outgoing edges
355
+ const outgoing = await this.db.all(`SELECT relation, to_entity, confidence, memory_id FROM graph_edges
356
+ WHERE session_id = ? AND from_entity = ?`, [sessionId, ent]);
357
+ // Incoming edges
358
+ const incoming = await this.db.all(`SELECT relation, from_entity, confidence, memory_id FROM graph_edges
359
+ WHERE session_id = ? AND to_entity = ?`, [sessionId, ent]);
360
+ const relationships = [
361
+ ...outgoing.map((e) => ({
362
+ type: 'outgoing',
363
+ relation: e.relation,
364
+ target: e.to_entity,
365
+ confidence: e.confidence
366
+ })),
367
+ ...incoming.map((e) => ({
368
+ type: 'incoming',
369
+ relation: e.relation,
370
+ target: e.from_entity,
371
+ confidence: e.confidence
372
+ }))
373
+ ];
374
+ // Get source memories
375
+ const memoryIds = [
376
+ ...outgoing.map((e) => e.memory_id),
377
+ ...incoming.map((e) => e.memory_id)
378
+ ].filter(Boolean);
379
+ let relatedMemories = [];
380
+ if (memoryIds.length > 0) {
381
+ const placeholders = memoryIds.map(() => '?').join(',');
382
+ const rows = await this.db.all(`SELECT id, session_id, content, role, timestamp, metadata
383
+ FROM memories WHERE id IN (${placeholders})`, memoryIds);
384
+ relatedMemories = rows.map((row) => ({
385
+ id: row.id,
386
+ sessionId: row.session_id,
387
+ content: row.content,
388
+ role: row.role,
389
+ timestamp: new Date(row.timestamp),
390
+ metadata: row.metadata ? JSON.parse(row.metadata) : undefined
391
+ }));
486
392
  }
487
- // Convert to sorted array
488
- return Array.from(grouped.entries())
489
- .sort((a, b) => b[0].localeCompare(a[0])) // Descending date order
490
- .map(([dateKey, dayEntries]) => ({
491
- date: new Date(dateKey),
492
- entries: dayEntries.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()),
493
- count: dayEntries.length
494
- }));
393
+ return { entity: ent, relationships, relatedMemories };
495
394
  }
496
395
  /**
497
- * Get recent conversation history for a session
396
+ * Get recent conversation history
498
397
  */
499
398
  async history(sessionId, limit = 20) {
500
399
  return this.recall(sessionId, undefined, limit, {});
501
400
  }
502
401
  /**
503
- * Forget (delete) memories
402
+ * Delete memories
504
403
  */
505
404
  async forget(sessionId, options) {
506
405
  await this.init();
@@ -510,10 +409,6 @@ class Engram {
510
409
  }
511
410
  let sql = 'DELETE FROM memories WHERE session_id = ?';
512
411
  const params = [sessionId];
513
- if (options?.after) {
514
- sql += ' AND timestamp >= ?';
515
- params.push(options.after.getTime());
516
- }
517
412
  if (options?.before) {
518
413
  sql += ' AND timestamp < ?';
519
414
  params.push(options.before.getTime());
@@ -522,59 +417,31 @@ class Engram {
522
417
  return result.changes || 0;
523
418
  }
524
419
  /**
525
- * Get memory statistics for a session
420
+ * Memory statistics
526
421
  */
527
422
  async stats(sessionId) {
528
423
  await this.init();
529
424
  const totalRow = await this.db.get('SELECT COUNT(*) as count FROM memories WHERE session_id = ?', [sessionId]);
530
- const total = totalRow?.count || 0;
531
425
  const roleRows = await this.db.all('SELECT role, COUNT(*) as count FROM memories WHERE session_id = ? GROUP BY role', [sessionId]);
532
426
  const byRole = {};
533
- roleRows.forEach((row) => {
534
- byRole[row.role] = row.count;
535
- });
427
+ roleRows.forEach((row) => { byRole[row.role] = row.count; });
536
428
  const range = await this.db.get('SELECT MIN(timestamp) as oldest, MAX(timestamp) as newest FROM memories WHERE session_id = ?', [sessionId]);
537
- return {
538
- total,
429
+ const embRow = await this.db.get('SELECT COUNT(*) as count FROM memories WHERE session_id = ? AND embedding IS NOT NULL', [sessionId]);
430
+ const stats = {
431
+ total: totalRow?.count || 0,
539
432
  byRole,
540
433
  oldest: range?.oldest ? new Date(range.oldest) : null,
541
- newest: range?.newest ? new Date(range.newest) : null
434
+ newest: range?.newest ? new Date(range.newest) : null,
435
+ withEmbeddings: embRow?.count || 0
542
436
  };
543
- }
544
- /**
545
- * Get temporal statistics for a session
546
- *
547
- * Returns memory counts grouped by day, useful for activity visualization
548
- */
549
- async temporalStats(sessionId, days = 30) {
550
- await this.init();
551
- const since = new Date();
552
- since.setDate(since.getDate() - days);
553
- const rows = await this.db.all(`SELECT
554
- date(timestamp / 1000, 'unixepoch', 'localtime') as date,
555
- role,
556
- COUNT(*) as count
557
- FROM memories
558
- WHERE session_id = ? AND timestamp >= ?
559
- GROUP BY date, role
560
- ORDER BY date DESC`, [sessionId, since.getTime()]);
561
- // Aggregate by date
562
- const byDate = new Map();
563
- for (const row of rows) {
564
- if (!byDate.has(row.date)) {
565
- byDate.set(row.date, { count: 0, byRole: {} });
566
- }
567
- const day = byDate.get(row.date);
568
- day.count += row.count;
569
- day.byRole[row.role] = row.count;
437
+ if (this.graphMemory) {
438
+ const nodeRow = await this.db.get('SELECT COUNT(*) as count FROM graph_nodes WHERE session_id = ?', [sessionId]);
439
+ const edgeRow = await this.db.get('SELECT COUNT(*) as count FROM graph_edges WHERE session_id = ?', [sessionId]);
440
+ stats.graphNodes = nodeRow?.count || 0;
441
+ stats.graphEdges = edgeRow?.count || 0;
570
442
  }
571
- return Array.from(byDate.entries())
572
- .sort((a, b) => b[0].localeCompare(a[0]))
573
- .map(([date, stats]) => ({ date, ...stats }));
443
+ return stats;
574
444
  }
575
- /**
576
- * Close the database connection
577
- */
578
445
  async close() {
579
446
  if (this.db) {
580
447
  await this.db.close();