@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/README.md +78 -211
- package/dist/index.d.ts +68 -118
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +304 -437
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/dist/example/temporal-demo.js +0 -91
package/dist/index.js
CHANGED
|
@@ -1,269 +1,23 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.Engram =
|
|
3
|
+
exports.Engram = void 0;
|
|
4
4
|
const crypto_1 = require("crypto");
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
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
|
-
*
|
|
253
|
-
*
|
|
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
|
-
*
|
|
262
|
-
* await memory.
|
|
263
|
-
*
|
|
264
|
-
* //
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
302
|
-
|
|
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
|
|
307
|
-
ON memories(
|
|
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
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
|
421
|
-
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
*
|
|
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
|
|
351
|
+
async graph(sessionId, entity) {
|
|
473
352
|
await this.init();
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
//
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
538
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
|
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();
|