@auxiora/memory 1.0.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.
Files changed (56) hide show
  1. package/LICENSE +191 -0
  2. package/dist/extractor.d.ts +40 -0
  3. package/dist/extractor.d.ts.map +1 -0
  4. package/dist/extractor.js +130 -0
  5. package/dist/extractor.js.map +1 -0
  6. package/dist/index.d.ts +11 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +8 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/partition.d.ts +25 -0
  11. package/dist/partition.d.ts.map +1 -0
  12. package/dist/partition.js +104 -0
  13. package/dist/partition.js.map +1 -0
  14. package/dist/pattern-detector.d.ts +27 -0
  15. package/dist/pattern-detector.d.ts.map +1 -0
  16. package/dist/pattern-detector.js +260 -0
  17. package/dist/pattern-detector.js.map +1 -0
  18. package/dist/personality-adapter.d.ts +10 -0
  19. package/dist/personality-adapter.d.ts.map +1 -0
  20. package/dist/personality-adapter.js +72 -0
  21. package/dist/personality-adapter.js.map +1 -0
  22. package/dist/retriever.d.ts +15 -0
  23. package/dist/retriever.d.ts.map +1 -0
  24. package/dist/retriever.js +179 -0
  25. package/dist/retriever.js.map +1 -0
  26. package/dist/sentiment.d.ts +7 -0
  27. package/dist/sentiment.d.ts.map +1 -0
  28. package/dist/sentiment.js +118 -0
  29. package/dist/sentiment.js.map +1 -0
  30. package/dist/store.d.ts +41 -0
  31. package/dist/store.d.ts.map +1 -0
  32. package/dist/store.js +352 -0
  33. package/dist/store.js.map +1 -0
  34. package/dist/types.d.ts +80 -0
  35. package/dist/types.d.ts.map +1 -0
  36. package/dist/types.js +2 -0
  37. package/dist/types.js.map +1 -0
  38. package/package.json +26 -0
  39. package/src/extractor.ts +212 -0
  40. package/src/index.ts +22 -0
  41. package/src/partition.ts +123 -0
  42. package/src/pattern-detector.ts +304 -0
  43. package/src/personality-adapter.ts +82 -0
  44. package/src/retriever.ts +213 -0
  45. package/src/sentiment.ts +129 -0
  46. package/src/store.ts +384 -0
  47. package/src/types.ts +92 -0
  48. package/tests/extractor.test.ts +247 -0
  49. package/tests/partition.test.ts +168 -0
  50. package/tests/pattern-detector.test.ts +150 -0
  51. package/tests/personality-adapter.test.ts +155 -0
  52. package/tests/retriever.test.ts +240 -0
  53. package/tests/sentiment.test.ts +207 -0
  54. package/tests/store.test.ts +390 -0
  55. package/tsconfig.json +13 -0
  56. package/tsconfig.tsbuildinfo +1 -0
package/src/store.ts ADDED
@@ -0,0 +1,384 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import * as crypto from 'node:crypto';
4
+ import { getLogger } from '@auxiora/logger';
5
+ import { audit } from '@auxiora/audit';
6
+ import { getMemoryDir } from '@auxiora/core';
7
+ import type { MemoryEntry, MemoryCategory, LivingMemoryState, MemoryPartition } from './types.js';
8
+
9
+ const logger = getLogger('memory:store');
10
+
11
+ export class MemoryStore {
12
+ private filePath: string;
13
+ private maxEntries: number;
14
+
15
+ constructor(options?: { dir?: string; maxEntries?: number }) {
16
+ const dir = options?.dir ?? getMemoryDir();
17
+ this.filePath = path.join(dir, 'memories.json');
18
+ this.maxEntries = options?.maxEntries ?? 1000;
19
+ }
20
+
21
+ async add(
22
+ content: string,
23
+ category: MemoryCategory,
24
+ source: MemoryEntry['source'],
25
+ extra?: Partial<Pick<MemoryEntry, 'importance' | 'confidence' | 'sentiment' | 'expiresAt' | 'encrypted' | 'relatedMemories' | 'partitionId' | 'sourceUserId'>>,
26
+ ): Promise<MemoryEntry> {
27
+ const memories = await this.readFile();
28
+ const tags = this.extractTags(content);
29
+
30
+ // Dedup: check for >50% tag overlap with existing entries
31
+ const existing = this.findOverlap(memories, tags);
32
+ if (existing) {
33
+ existing.content = content;
34
+ existing.updatedAt = Date.now();
35
+ existing.tags = tags;
36
+ if (extra?.importance !== undefined) existing.importance = extra.importance;
37
+ if (extra?.confidence !== undefined) existing.confidence = extra.confidence;
38
+ if (extra?.sentiment !== undefined) existing.sentiment = extra.sentiment;
39
+ await this.writeFile(memories);
40
+ logger.debug('Updated existing memory (dedup)', { id: existing.id });
41
+ return existing;
42
+ }
43
+
44
+ const entry: MemoryEntry = {
45
+ id: `mem-${crypto.randomUUID().slice(0, 8)}`,
46
+ content,
47
+ category,
48
+ source,
49
+ createdAt: Date.now(),
50
+ updatedAt: Date.now(),
51
+ accessCount: 0,
52
+ tags,
53
+ importance: extra?.importance ?? 0.5,
54
+ confidence: extra?.confidence ?? 0.8,
55
+ sentiment: extra?.sentiment ?? 'neutral',
56
+ encrypted: extra?.encrypted ?? false,
57
+ partitionId: extra?.partitionId ?? 'global',
58
+ ...(extra?.sourceUserId !== undefined ? { sourceUserId: extra.sourceUserId } : {}),
59
+ ...(extra?.expiresAt !== undefined ? { expiresAt: extra.expiresAt } : {}),
60
+ ...(extra?.relatedMemories !== undefined ? { relatedMemories: extra.relatedMemories } : {}),
61
+ };
62
+
63
+ memories.push(entry);
64
+
65
+ // Enforce max entries: remove oldest by updatedAt
66
+ if (memories.length > this.maxEntries) {
67
+ memories.sort((a, b) => b.updatedAt - a.updatedAt);
68
+ memories.length = this.maxEntries;
69
+ }
70
+
71
+ await this.writeFile(memories);
72
+ void audit('memory.saved', { id: entry.id, category, source });
73
+ logger.debug('Saved memory', { id: entry.id, category });
74
+ return entry;
75
+ }
76
+
77
+ async remove(id: string): Promise<boolean> {
78
+ const memories = await this.readFile();
79
+ const filtered = memories.filter(m => m.id !== id);
80
+ if (filtered.length === memories.length) return false;
81
+ await this.writeFile(filtered);
82
+ void audit('memory.deleted', { id });
83
+ logger.debug('Removed memory', { id });
84
+ return true;
85
+ }
86
+
87
+ async update(id: string, updates: Partial<Pick<MemoryEntry, 'content' | 'category' | 'importance' | 'confidence' | 'sentiment' | 'expiresAt' | 'encrypted'>>): Promise<MemoryEntry | undefined> {
88
+ const memories = await this.readFile();
89
+ const entry = memories.find(m => m.id === id);
90
+ if (!entry) return undefined;
91
+
92
+ if (updates.content !== undefined) {
93
+ entry.content = updates.content;
94
+ entry.tags = this.extractTags(updates.content);
95
+ }
96
+ if (updates.category !== undefined) entry.category = updates.category;
97
+ if (updates.importance !== undefined) entry.importance = updates.importance;
98
+ if (updates.confidence !== undefined) entry.confidence = updates.confidence;
99
+ if (updates.sentiment !== undefined) entry.sentiment = updates.sentiment;
100
+ if (updates.expiresAt !== undefined) entry.expiresAt = updates.expiresAt;
101
+ if (updates.encrypted !== undefined) entry.encrypted = updates.encrypted;
102
+ entry.updatedAt = Date.now();
103
+
104
+ await this.writeFile(memories);
105
+ return entry;
106
+ }
107
+
108
+ async getAll(): Promise<MemoryEntry[]> {
109
+ return this.readFile();
110
+ }
111
+
112
+ async search(query: string): Promise<MemoryEntry[]> {
113
+ const memories = await this.readFile();
114
+ const queryTags = this.extractTags(query);
115
+ if (queryTags.length === 0) return memories;
116
+
117
+ // Score by tag overlap
118
+ const scored = memories.map(m => {
119
+ const overlap = m.tags.filter(t => queryTags.includes(t)).length;
120
+ return { entry: m, score: overlap };
121
+ });
122
+
123
+ const results = scored
124
+ .filter(s => s.score > 0)
125
+ .sort((a, b) => b.score - a.score)
126
+ .map(s => {
127
+ s.entry.accessCount++;
128
+ return s.entry;
129
+ });
130
+
131
+ // Fix: persist accessCount increments
132
+ if (results.length > 0) {
133
+ await this.writeFile(memories);
134
+ }
135
+
136
+ return results;
137
+ }
138
+
139
+ async getByCategory(category: MemoryCategory, partitionId?: string): Promise<MemoryEntry[]> {
140
+ const memories = await this.readFile();
141
+ return memories.filter(m =>
142
+ m.category === category &&
143
+ (partitionId === undefined || (m.partitionId ?? 'global') === partitionId),
144
+ );
145
+ }
146
+
147
+ async getByPartition(partitionId: string): Promise<MemoryEntry[]> {
148
+ const memories = await this.readFile();
149
+ return memories.filter(m => (m.partitionId ?? 'global') === partitionId);
150
+ }
151
+
152
+ async getByPartitions(partitionIds: string[]): Promise<MemoryEntry[]> {
153
+ const memories = await this.readFile();
154
+ const idSet = new Set(partitionIds);
155
+ return memories.filter(m => idSet.has(m.partitionId ?? 'global'));
156
+ }
157
+
158
+ async getExpired(): Promise<MemoryEntry[]> {
159
+ const memories = await this.readFile();
160
+ const now = Date.now();
161
+ return memories.filter(m => m.expiresAt !== undefined && m.expiresAt <= now);
162
+ }
163
+
164
+ async cleanExpired(): Promise<number> {
165
+ const memories = await this.readFile();
166
+ const now = Date.now();
167
+ const before = memories.length;
168
+ const kept = memories.filter(m => m.expiresAt === undefined || m.expiresAt > now);
169
+ const removed = before - kept.length;
170
+ if (removed > 0) {
171
+ await this.writeFile(kept);
172
+ logger.debug('Cleaned expired memories', { removed });
173
+ }
174
+ return removed;
175
+ }
176
+
177
+ async merge(id1: string, id2: string, mergedContent: string): Promise<MemoryEntry> {
178
+ const memories = await this.readFile();
179
+ const entry1 = memories.find(m => m.id === id1);
180
+ const entry2 = memories.find(m => m.id === id2);
181
+ if (!entry1) throw new Error(`Memory not found: ${id1}`);
182
+ if (!entry2) throw new Error(`Memory not found: ${id2}`);
183
+
184
+ // Keep the older entry's id, merge fields
185
+ const merged: MemoryEntry = {
186
+ id: entry1.id,
187
+ content: mergedContent,
188
+ category: entry1.category,
189
+ source: entry1.source,
190
+ createdAt: Math.min(entry1.createdAt, entry2.createdAt),
191
+ updatedAt: Date.now(),
192
+ accessCount: entry1.accessCount + entry2.accessCount,
193
+ tags: this.extractTags(mergedContent),
194
+ importance: Math.max(entry1.importance, entry2.importance),
195
+ confidence: Math.max(entry1.confidence, entry2.confidence),
196
+ sentiment: entry1.sentiment,
197
+ encrypted: entry1.encrypted || entry2.encrypted,
198
+ relatedMemories: [
199
+ ...new Set([
200
+ ...(entry1.relatedMemories ?? []),
201
+ ...(entry2.relatedMemories ?? []),
202
+ ]),
203
+ ],
204
+ };
205
+
206
+ // Remove both originals and add merged
207
+ const filtered = memories.filter(m => m.id !== id1 && m.id !== id2);
208
+ filtered.push(merged);
209
+ await this.writeFile(filtered);
210
+
211
+ logger.debug('Merged memories', { id1, id2, mergedId: merged.id });
212
+ return merged;
213
+ }
214
+
215
+ async getStats(): Promise<LivingMemoryState['stats']> {
216
+ const memories = await this.readFile();
217
+
218
+ if (memories.length === 0) {
219
+ return {
220
+ totalMemories: 0,
221
+ oldestMemory: 0,
222
+ newestMemory: 0,
223
+ averageImportance: 0,
224
+ topTags: [],
225
+ };
226
+ }
227
+
228
+ const tagCounts = new Map<string, number>();
229
+ let totalImportance = 0;
230
+ let oldest = Infinity;
231
+ let newest = 0;
232
+
233
+ for (const m of memories) {
234
+ totalImportance += m.importance;
235
+ if (m.createdAt < oldest) oldest = m.createdAt;
236
+ if (m.createdAt > newest) newest = m.createdAt;
237
+ for (const tag of m.tags) {
238
+ tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
239
+ }
240
+ }
241
+
242
+ const topTags = Array.from(tagCounts.entries())
243
+ .sort((a, b) => b[1] - a[1])
244
+ .slice(0, 10)
245
+ .map(([tag, count]) => ({ tag, count }));
246
+
247
+ return {
248
+ totalMemories: memories.length,
249
+ oldestMemory: oldest,
250
+ newestMemory: newest,
251
+ averageImportance: totalImportance / memories.length,
252
+ topTags,
253
+ };
254
+ }
255
+
256
+ async exportAll(): Promise<{ version: string; memories: MemoryEntry[]; exportedAt: number }> {
257
+ const memories = await this.readFile();
258
+ return {
259
+ version: '1.0',
260
+ memories,
261
+ exportedAt: Date.now(),
262
+ };
263
+ }
264
+
265
+ async importAll(data: { memories: MemoryEntry[] }): Promise<{ imported: number; skipped: number }> {
266
+ const existing = await this.readFile();
267
+ const existingIds = new Set(existing.map(m => m.id));
268
+ let imported = 0;
269
+ let skipped = 0;
270
+
271
+ for (const entry of data.memories) {
272
+ if (existingIds.has(entry.id)) {
273
+ skipped++;
274
+ continue;
275
+ }
276
+ // Ensure new fields have defaults for legacy imports
277
+ existing.push(this.applyDefaults(entry));
278
+ existingIds.add(entry.id);
279
+ imported++;
280
+ }
281
+
282
+ // Enforce max entries
283
+ if (existing.length > this.maxEntries) {
284
+ existing.sort((a, b) => b.updatedAt - a.updatedAt);
285
+ existing.length = this.maxEntries;
286
+ }
287
+
288
+ await this.writeFile(existing);
289
+ logger.debug('Imported memories', { imported, skipped });
290
+ return { imported, skipped };
291
+ }
292
+
293
+ async setImportance(id: string, importance: number): Promise<void> {
294
+ if (importance < 0 || importance > 1) {
295
+ throw new Error('Importance must be between 0 and 1');
296
+ }
297
+ const result = await this.update(id, { importance });
298
+ if (!result) throw new Error(`Memory not found: ${id}`);
299
+ }
300
+
301
+ async linkMemories(id1: string, id2: string): Promise<void> {
302
+ const memories = await this.readFile();
303
+ const entry1 = memories.find(m => m.id === id1);
304
+ const entry2 = memories.find(m => m.id === id2);
305
+ if (!entry1) throw new Error(`Memory not found: ${id1}`);
306
+ if (!entry2) throw new Error(`Memory not found: ${id2}`);
307
+
308
+ if (!entry1.relatedMemories) entry1.relatedMemories = [];
309
+ if (!entry2.relatedMemories) entry2.relatedMemories = [];
310
+
311
+ if (!entry1.relatedMemories.includes(id2)) entry1.relatedMemories.push(id2);
312
+ if (!entry2.relatedMemories.includes(id1)) entry2.relatedMemories.push(id1);
313
+
314
+ await this.writeFile(memories);
315
+ logger.debug('Linked memories', { id1, id2 });
316
+ }
317
+
318
+ extractTags(text: string): string[] {
319
+ const stopWords = new Set([
320
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
321
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
322
+ 'should', 'may', 'might', 'can', 'shall', 'to', 'of', 'in', 'for',
323
+ 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through', 'during',
324
+ 'before', 'after', 'above', 'below', 'between', 'and', 'but', 'or',
325
+ 'nor', 'not', 'so', 'yet', 'both', 'either', 'neither', 'each',
326
+ 'every', 'all', 'any', 'few', 'more', 'most', 'other', 'some',
327
+ 'such', 'no', 'only', 'own', 'same', 'than', 'too', 'very',
328
+ 'just', 'about', 'also', 'that', 'this', 'it', 'its', 'i', 'my',
329
+ 'me', 'we', 'our', 'you', 'your', 'he', 'she', 'they', 'them',
330
+ 'their', 'what', 'which', 'who', 'when', 'where', 'how', 'like',
331
+ 'user', 'prefers', 'uses', 'wants', 'likes',
332
+ ]);
333
+
334
+ return text
335
+ .toLowerCase()
336
+ .replace(/[^a-z0-9\s-]/g, '')
337
+ .split(/\s+/)
338
+ .filter(w => w.length > 2 && !stopWords.has(w))
339
+ .filter((w, i, arr) => arr.indexOf(w) === i); // unique
340
+ }
341
+
342
+ private findOverlap(memories: MemoryEntry[], tags: string[]): MemoryEntry | undefined {
343
+ if (tags.length === 0) return undefined;
344
+
345
+ for (const m of memories) {
346
+ const overlap = m.tags.filter(t => tags.includes(t)).length;
347
+ const ratio = overlap / Math.max(m.tags.length, tags.length);
348
+ if (ratio > 0.5) return m;
349
+ }
350
+ return undefined;
351
+ }
352
+
353
+ /** Apply defaults for new fields when loading legacy entries */
354
+ private applyDefaults(entry: MemoryEntry): MemoryEntry {
355
+ return {
356
+ ...entry,
357
+ importance: entry.importance ?? 0.5,
358
+ confidence: entry.confidence ?? 0.8,
359
+ sentiment: entry.sentiment ?? 'neutral',
360
+ encrypted: entry.encrypted ?? false,
361
+ partitionId: entry.partitionId ?? 'global',
362
+ };
363
+ }
364
+
365
+ private async readFile(): Promise<MemoryEntry[]> {
366
+ try {
367
+ const content = await fs.readFile(this.filePath, 'utf-8');
368
+ const raw = JSON.parse(content) as MemoryEntry[];
369
+ // Apply defaults for legacy entries that lack new fields
370
+ return raw.map(e => this.applyDefaults(e));
371
+ } catch (error) {
372
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
373
+ return [];
374
+ }
375
+ throw error;
376
+ }
377
+ }
378
+
379
+ private async writeFile(memories: MemoryEntry[]): Promise<void> {
380
+ const dir = path.dirname(this.filePath);
381
+ await fs.mkdir(dir, { recursive: true });
382
+ await fs.writeFile(this.filePath, JSON.stringify(memories, null, 2), 'utf-8');
383
+ }
384
+ }
package/src/types.ts ADDED
@@ -0,0 +1,92 @@
1
+ export type MemoryCategory =
2
+ | 'preference' // user preferences
3
+ | 'fact' // factual knowledge about user
4
+ | 'context' // contextual information
5
+ | 'relationship' // shared history, inside jokes
6
+ | 'pattern' // observed communication patterns
7
+ | 'personality'; // personality adaptation signals
8
+
9
+ export type MemorySource = 'extracted' | 'explicit' | 'observed';
10
+
11
+ export interface MemoryEntry {
12
+ id: string;
13
+ content: string;
14
+ category: MemoryCategory;
15
+ source: MemorySource;
16
+ createdAt: number;
17
+ updatedAt: number;
18
+ accessCount: number;
19
+ tags: string[];
20
+ importance: number;
21
+ confidence: number;
22
+ expiresAt?: number;
23
+ relatedMemories?: string[];
24
+ sentiment?: 'positive' | 'negative' | 'neutral';
25
+ encrypted?: boolean;
26
+ /** Partition this memory belongs to. Defaults to 'global'. */
27
+ partitionId?: string;
28
+ /** User ID that created this memory. */
29
+ sourceUserId?: string;
30
+ }
31
+
32
+ /** A memory partition for per-user or shared storage. */
33
+ export interface MemoryPartition {
34
+ id: string;
35
+ name: string;
36
+ type: 'private' | 'shared' | 'global';
37
+ ownerId?: string;
38
+ memberIds?: string[];
39
+ createdAt: number;
40
+ }
41
+
42
+ export interface RelationshipMemory {
43
+ type: 'inside_joke' | 'shared_experience' | 'milestone' | 'callback';
44
+ originalContext?: string;
45
+ useCount: number;
46
+ lastUsed: number;
47
+ }
48
+
49
+ export interface PatternMemory {
50
+ type: 'communication' | 'schedule' | 'topic' | 'mood';
51
+ pattern: string;
52
+ observations: number;
53
+ confidence: number;
54
+ examples?: string[];
55
+ }
56
+
57
+ export interface PersonalityAdaptation {
58
+ trait: string;
59
+ adjustment: number;
60
+ reason: string;
61
+ signalCount: number;
62
+ }
63
+
64
+ export type SentimentLabel = 'positive' | 'negative' | 'neutral';
65
+
66
+ export interface SentimentResult {
67
+ sentiment: SentimentLabel;
68
+ confidence: number;
69
+ keywords: string[];
70
+ }
71
+
72
+ export interface SentimentSnapshot {
73
+ sentiment: SentimentLabel;
74
+ confidence: number;
75
+ timestamp: number;
76
+ hour: number;
77
+ dayOfWeek: number;
78
+ }
79
+
80
+ export interface LivingMemoryState {
81
+ facts: MemoryEntry[];
82
+ relationships: MemoryEntry[];
83
+ patterns: MemoryEntry[];
84
+ adaptations: PersonalityAdaptation[];
85
+ stats: {
86
+ totalMemories: number;
87
+ oldestMemory: number;
88
+ newestMemory: number;
89
+ averageImportance: number;
90
+ topTags: Array<{ tag: string; count: number }>;
91
+ };
92
+ }