@nandansai08/personal-ai 0.8.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 (61) hide show
  1. package/.env.example +62 -0
  2. package/LICENSE +21 -0
  3. package/README.md +431 -0
  4. package/bin/personal-ai.js +4 -0
  5. package/config/mcp.json +3 -0
  6. package/config/models.yaml +23 -0
  7. package/config/persona.yaml +24 -0
  8. package/config/profiles.yaml +61 -0
  9. package/config/providers.yaml +22 -0
  10. package/dist/bootstrap.js +41 -0
  11. package/dist/core/assistant.js +170 -0
  12. package/dist/core/context.js +35 -0
  13. package/dist/core/events.js +45 -0
  14. package/dist/core/logger.js +67 -0
  15. package/dist/core/model-manager.js +101 -0
  16. package/dist/index.js +98 -0
  17. package/dist/mcp/client.js +3 -0
  18. package/dist/mcp/loader.js +3 -0
  19. package/dist/memory/embeddings.js +53 -0
  20. package/dist/memory/intent.js +113 -0
  21. package/dist/memory/long-term.js +312 -0
  22. package/dist/memory/short-term.js +63 -0
  23. package/dist/memory/types.js +5 -0
  24. package/dist/memory/vector-store.js +57 -0
  25. package/dist/persona/loader.js +56 -0
  26. package/dist/persona/profiles.js +51 -0
  27. package/dist/persona/system-prompt.js +99 -0
  28. package/dist/persona/types.js +22 -0
  29. package/dist/plugins/interface.js +1 -0
  30. package/dist/plugins/loader.js +3 -0
  31. package/dist/providers/anthropic.js +112 -0
  32. package/dist/providers/factory.js +40 -0
  33. package/dist/providers/gemini.js +86 -0
  34. package/dist/providers/groq.js +14 -0
  35. package/dist/providers/interface.js +2 -0
  36. package/dist/providers/lmstudio.js +13 -0
  37. package/dist/providers/metadata.js +96 -0
  38. package/dist/providers/mistral.js +133 -0
  39. package/dist/providers/ollama.js +265 -0
  40. package/dist/providers/openai-compatible.js +110 -0
  41. package/dist/providers/openai.js +14 -0
  42. package/dist/providers/together.js +14 -0
  43. package/dist/providers/utils.js +57 -0
  44. package/dist/tools/calculator.js +44 -0
  45. package/dist/tools/file-reader.js +101 -0
  46. package/dist/tools/memory-tool.js +58 -0
  47. package/dist/tools/notes.js +121 -0
  48. package/dist/tools/parser.js +119 -0
  49. package/dist/tools/registry.js +88 -0
  50. package/dist/tools/tasks.js +134 -0
  51. package/dist/tools/types.js +3 -0
  52. package/dist/tools/web-search.js +108 -0
  53. package/dist/ui/cli-helpers.js +153 -0
  54. package/dist/ui/cli.js +647 -0
  55. package/dist/ui/setup.js +196 -0
  56. package/dist/ui/web/client/index.html +2081 -0
  57. package/dist/ui/web/server.js +310 -0
  58. package/dist/voice/stt.js +3 -0
  59. package/dist/voice/tts.js +3 -0
  60. package/dist/web.js +63 -0
  61. package/package.json +68 -0
@@ -0,0 +1,113 @@
1
+ // MIT License — personal-ai
2
+ // Explicit memory-intent detection and fact normalization.
3
+ // "remember i am studying CSE at IIT Dhanbad" → structured, normalized memory.
4
+ // Trigger phrases — anchored to the start of the message (after pleasantries)
5
+ const TRIGGER_RE = /^(?:hey|hi|ok|okay|please|also|and)?[,!\s]*(remember(?:\s+(?:that|this))?|don'?t\s+forget(?:\s+that)?|save\s+this|keep\s+in\s+mind(?:\s+that)?|you\s+should\s+know(?:\s+that)?|note\s+that)\b[:,\s]*/i;
6
+ /**
7
+ * Detect explicit memory intent. Returns null for normal chat messages.
8
+ */
9
+ export function detectMemoryIntent(message) {
10
+ const match = TRIGGER_RE.exec(message.trim());
11
+ if (!match)
12
+ return null;
13
+ const raw = message.trim().slice(match[0].length).trim();
14
+ if (raw.length < 3)
15
+ return null;
16
+ const fact = normalizeFact(raw);
17
+ const type = categorizeFact(fact);
18
+ const tags = extractTags(fact, type);
19
+ const importance = type === 'personal' || /name is/i.test(fact) ? 9 : 8;
20
+ return {
21
+ fact, type, tags, importance,
22
+ confirmation: `✓ I've remembered that ${toConfirmation(fact)}.`,
23
+ };
24
+ }
25
+ /**
26
+ * Rewrite first-person statements into third-person facts.
27
+ * "my name is Nandan" → "User's name is Nandan"
28
+ * "i am studying CSE at IIT" → "User studies CSE at IIT"
29
+ * "my favorite language is TS" → "User's favorite language is TS"
30
+ */
31
+ export function normalizeFact(raw) {
32
+ let s = raw.trim().replace(/\s+/g, ' ').replace(/[.!?]+$/, '');
33
+ const rules = [
34
+ [/^my name is\s+/i, 'User\'s name is '],
35
+ [/^my favorite\s+/i, 'User\'s favorite '],
36
+ [/^my\s+/i, 'User\'s '],
37
+ [/^i am studying\s+|^i'?m studying\s+|^i study\s+/i, 'User studies '],
38
+ [/^i am working (?:at|on)\s+/i, 'User works at '],
39
+ [/^i work (?:at|for)\s+/i, 'User works at '],
40
+ [/^i am a\s+|^i'?m a\s+/i, 'User is a '],
41
+ [/^i am an\s+|^i'?m an\s+/i, 'User is an '],
42
+ [/^i am\s+|^i'?m\s+/i, 'User is '],
43
+ [/^i prefer\s+/i, 'User prefers '],
44
+ [/^i like\s+/i, 'User likes '],
45
+ [/^i hate\s+|^i dislike\s+/i, 'User dislikes '],
46
+ [/^i live in\s+/i, 'User lives in '],
47
+ [/^i have\s+|^i'?ve got\s+/i, 'User has '],
48
+ [/^i use\s+/i, 'User uses '],
49
+ [/^i want\s+/i, 'User wants '],
50
+ ];
51
+ for (const [re, replacement] of rules) {
52
+ if (re.test(s)) {
53
+ s = s.replace(re, replacement);
54
+ break;
55
+ }
56
+ }
57
+ // Mid-sentence first-person cleanup: "…and i am…" → "…and is…"
58
+ s = s
59
+ .replace(/\bmy\b/gi, 'their')
60
+ .replace(/\bi am\b|\bi'?m\b/gi, 'is')
61
+ .replace(/(?<!User's name )\bis Nandan\b/g, 'is Nandan') // keep names intact
62
+ .replace(/\bi\b/g, 'they');
63
+ if (!/^User/.test(s))
64
+ s = `User: ${s}`;
65
+ return s.charAt(0).toUpperCase() + s.slice(1);
66
+ }
67
+ const EDUCATION_RE = /\b(stud(?:y|ies|ying)|college|university|iit|nit|b\.?tech|m\.?tech|degree|semester|year student|2nd year|3rd year|school|cse|ece|engineering)\b/i;
68
+ const CAREER_RE = /\b(works? at|job|career|intern(?:ship)?|company|salary|hired|employer)\b/i;
69
+ const PROJECT_RE = /\b(project|building|repo|app i|side.?project|startup)\b/i;
70
+ const PREF_RE = /\b(favorite|prefers?|likes?|dislikes?|hates?|loves?)\b/i;
71
+ const PERSONAL_RE = /\b(name is|birthday|lives? in|age|family|brother|sister|married)\b/i;
72
+ export function categorizeFact(fact) {
73
+ if (PERSONAL_RE.test(fact))
74
+ return 'personal';
75
+ if (EDUCATION_RE.test(fact))
76
+ return 'education';
77
+ if (CAREER_RE.test(fact))
78
+ return 'career';
79
+ if (PROJECT_RE.test(fact))
80
+ return 'project';
81
+ if (PREF_RE.test(fact))
82
+ return 'preference';
83
+ return 'fact';
84
+ }
85
+ const TAG_PATTERNS = [
86
+ [/\biit\s+(\w+)/i, 'iit-$1'],
87
+ [/\bcse\b/i, 'cse'],
88
+ [/\bece\b/i, 'ece'],
89
+ [/\bcollege|university\b/i, 'college'],
90
+ [/\btypescript\b/i, 'typescript'],
91
+ [/\bjavascript\b/i, 'javascript'],
92
+ [/\bpython\b/i, 'python'],
93
+ [/\bcricket\b/i, 'cricket'],
94
+ ];
95
+ export function extractTags(fact, type) {
96
+ const tags = new Set([type]);
97
+ for (const [re, tag] of TAG_PATTERNS) {
98
+ const m = re.exec(fact);
99
+ if (m)
100
+ tags.add(tag.replace('$1', (m[1] ?? '').toLowerCase()));
101
+ }
102
+ return [...tags];
103
+ }
104
+ /** "User's name is Nandan" → "your name is Nandan" for the confirmation line. */
105
+ function toConfirmation(fact) {
106
+ return fact
107
+ .replace(/^User's\s+/i, 'your ')
108
+ .replace(/^User is\s+/i, "you're ")
109
+ .replace(/^User (\w+s)\s+/i, (_m, verb) => `you ${verb.replace(/s$/, '')} `)
110
+ .replace(/^User:\s*/i, '')
111
+ .replace(/\btheir\b/g, 'your')
112
+ .replace(/\bthey\b/g, 'you');
113
+ }
@@ -0,0 +1,312 @@
1
+ // MIT License — personal-ai
2
+ import Database from 'better-sqlite3';
3
+ import { randomUUID } from 'node:crypto';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import fs from 'node:fs';
7
+ import { eventBus } from '../core/events.js';
8
+ import { logger } from '../core/logger.js';
9
+ import { MEMORY_TYPES } from './types.js';
10
+ import { VectorStore } from './vector-store.js';
11
+ /** Cosine similarity above which two memories count as near-duplicates. */
12
+ const NEAR_DUP_THRESHOLD = 0.92;
13
+ /** Hybrid retrieval weights: semantic 70%, importance 20%, recency 10%. */
14
+ const W_SEMANTIC = 0.7;
15
+ const W_IMPORTANCE = 0.2;
16
+ const W_RECENCY = 0.1;
17
+ const DB_DIR = path.join(os.homedir(), '.personal-ai');
18
+ const DB_PATH = path.join(DB_DIR, 'memory.db');
19
+ function rowToMemory(row) {
20
+ return {
21
+ id: row.id,
22
+ content: row.content,
23
+ type: row.type,
24
+ tags: JSON.parse(row.tags),
25
+ importance: row.importance,
26
+ access_count: row.access_count,
27
+ created_at: row.created_at,
28
+ last_accessed: row.last_accessed,
29
+ archived: row.archived === 1,
30
+ };
31
+ }
32
+ export class LongTermMemory {
33
+ db;
34
+ vectors;
35
+ embedder;
36
+ constructor(dbPath = DB_PATH) {
37
+ if (!fs.existsSync(DB_DIR))
38
+ fs.mkdirSync(DB_DIR, { recursive: true });
39
+ this.db = new Database(dbPath);
40
+ this.db.pragma('journal_mode = WAL');
41
+ this.migrate();
42
+ // VectorStore creates its side table on demand — existing DBs migrate
43
+ // transparently and keep working without vectors.
44
+ this.vectors = new VectorStore(this.db);
45
+ logger.debug('memory', `SQLite opened: ${dbPath}`);
46
+ }
47
+ /** Enable semantic indexing/retrieval. Without an embedder, keyword search is used. */
48
+ setEmbedder(embedder) {
49
+ this.embedder = embedder;
50
+ }
51
+ migrate() {
52
+ this.db.exec(`
53
+ CREATE TABLE IF NOT EXISTS memories (
54
+ id TEXT PRIMARY KEY,
55
+ content TEXT NOT NULL,
56
+ type TEXT NOT NULL DEFAULT 'fact',
57
+ tags TEXT NOT NULL DEFAULT '[]',
58
+ importance INTEGER NOT NULL DEFAULT 5,
59
+ access_count INTEGER NOT NULL DEFAULT 0,
60
+ created_at TEXT NOT NULL,
61
+ last_accessed TEXT NOT NULL,
62
+ archived INTEGER NOT NULL DEFAULT 0
63
+ );
64
+ CREATE INDEX IF NOT EXISTS idx_type ON memories(type);
65
+ CREATE INDEX IF NOT EXISTS idx_archived ON memories(archived);
66
+ CREATE INDEX IF NOT EXISTS idx_importance ON memories(importance);
67
+ `);
68
+ }
69
+ /** Save new memory. Normalizes whitespace; never duplicates (case-insensitive). */
70
+ save(input) {
71
+ const now = new Date().toISOString();
72
+ const id = randomUUID();
73
+ const tags = JSON.stringify(input.tags ?? []);
74
+ const importance = input.importance ?? 5;
75
+ // Normalize: trim + collapse internal whitespace so "a b" === "a b"
76
+ const content = input.content.trim().replace(/\s+/g, ' ');
77
+ // Upsert by content (case-insensitive) — update importance if exists
78
+ const existing = this.db
79
+ .prepare('SELECT * FROM memories WHERE LOWER(content) = LOWER(?) AND archived = 0')
80
+ .get(content);
81
+ if (existing) {
82
+ this.db.prepare(`
83
+ UPDATE memories SET importance = MAX(importance, ?), last_accessed = ? WHERE id = ?
84
+ `).run(importance, now, existing.id);
85
+ logger.debug('memory', `updated existing: ${existing.id}`);
86
+ return rowToMemory({ ...existing, importance: Math.max(existing.importance, importance), last_accessed: now });
87
+ }
88
+ this.db.prepare(`
89
+ INSERT INTO memories (id, content, type, tags, importance, access_count, created_at, last_accessed)
90
+ VALUES (?, ?, ?, ?, ?, 0, ?, ?)
91
+ `).run(id, content, input.type, tags, importance, now, now);
92
+ eventBus.emit('memory_saved', { type: input.type, importance });
93
+ logger.debug('memory', `saved [${input.type}] importance=${importance}: ${input.content.slice(0, 60)}`);
94
+ const row = this.db.prepare('SELECT * FROM memories WHERE id = ?').get(id);
95
+ return rowToMemory(row);
96
+ }
97
+ /**
98
+ * Tokenized LIKE search: splits the query into words and ranks rows by how
99
+ * many words match. A whole-sentence LIKE almost never matches, so this is
100
+ * what makes retrieval actually work for conversational queries.
101
+ */
102
+ search(query, limit = 8) {
103
+ const words = query
104
+ .toLowerCase()
105
+ .split(/[^a-z0-9]+/i)
106
+ .filter(w => w.length >= 3) // skip stopword-length noise
107
+ .slice(0, 12); // cap clause count
108
+ if (words.length === 0)
109
+ return this.getRecent(limit);
110
+ const clauses = words.map(() => '(CASE WHEN content LIKE ? THEN 1 ELSE 0 END)').join(' + ');
111
+ const params = words.map(w => `%${w}%`);
112
+ const rows = this.db.prepare(`
113
+ SELECT * FROM (
114
+ SELECT *, (${clauses}) AS hits FROM memories WHERE archived = 0
115
+ ) WHERE hits > 0
116
+ ORDER BY hits DESC, importance DESC, last_accessed DESC
117
+ LIMIT ?
118
+ `).all(...params, limit);
119
+ const results = rows.map(rowToMemory);
120
+ if (results.length > 0)
121
+ this.incrementAccessBatch(results.map(m => m.id));
122
+ eventBus.emit('memory_retrieved', { query, count: results.length });
123
+ logger.debug('memory', `search "${query}" → ${results.length} results`);
124
+ return results;
125
+ }
126
+ getByType(type, limit = 20) {
127
+ const rows = this.db.prepare(`
128
+ SELECT * FROM memories WHERE type = ? AND archived = 0
129
+ ORDER BY importance DESC, last_accessed DESC LIMIT ?
130
+ `).all(type, limit);
131
+ logger.debug('memory', `getByType ${type} → ${rows.length}`);
132
+ return rows.map(rowToMemory);
133
+ }
134
+ getRecent(limit = 10) {
135
+ const rows = this.db.prepare(`
136
+ SELECT * FROM memories WHERE archived = 0
137
+ ORDER BY created_at DESC, ROWID DESC LIMIT ?
138
+ `).all(limit);
139
+ logger.debug('memory', `getRecent → ${rows.length}`);
140
+ return rows.map(rowToMemory);
141
+ }
142
+ getAll(limit = 100) {
143
+ const rows = this.db.prepare(`
144
+ SELECT * FROM memories WHERE archived = 0
145
+ ORDER BY importance DESC, last_accessed DESC LIMIT ?
146
+ `).all(limit);
147
+ return rows.map(rowToMemory);
148
+ }
149
+ /** Soft-delete — never hard delete per spec. */
150
+ archive(id) {
151
+ this.db.prepare('UPDATE memories SET archived = 1 WHERE id = ?').run(id);
152
+ logger.debug('memory', `archived: ${id}`);
153
+ }
154
+ incrementAccess(id) {
155
+ const now = new Date().toISOString();
156
+ this.db.prepare(`
157
+ UPDATE memories SET access_count = access_count + 1, last_accessed = ? WHERE id = ?
158
+ `).run(now, id);
159
+ }
160
+ incrementAccessBatch(ids) {
161
+ const now = new Date().toISOString();
162
+ const update = this.db.prepare('UPDATE memories SET access_count = access_count + 1, last_accessed = ? WHERE id = ?');
163
+ const tx = this.db.transaction((list) => {
164
+ for (const id of list)
165
+ update.run(now, id);
166
+ });
167
+ tx(ids);
168
+ }
169
+ getStats() {
170
+ const total = this.db.prepare('SELECT COUNT(*) as n FROM memories WHERE archived = 0').get().n;
171
+ const byTypeRows = this.db.prepare(`
172
+ SELECT type, COUNT(*) as n FROM memories WHERE archived = 0 GROUP BY type
173
+ `).all();
174
+ const byType = Object.fromEntries(MEMORY_TYPES.map(t => [t, 0]));
175
+ for (const r of byTypeRows)
176
+ byType[r.type] = r.n;
177
+ const avgRow = this.db.prepare('SELECT AVG(importance) as avg FROM memories WHERE archived = 0').get();
178
+ const topRow = this.db.prepare(`
179
+ SELECT * FROM memories WHERE archived = 0 ORDER BY access_count DESC LIMIT 1
180
+ `).get();
181
+ return {
182
+ total,
183
+ byType,
184
+ avgImportance: Math.round((avgRow.avg ?? 0) * 10) / 10,
185
+ mostAccessed: topRow ? rowToMemory(topRow) : null,
186
+ };
187
+ }
188
+ getById(id) {
189
+ const row = this.db.prepare('SELECT * FROM memories WHERE id = ?').get(id);
190
+ return row ? rowToMemory(row) : null;
191
+ }
192
+ // ── Semantic layer (M-embeddings) ───────────────────────────────────────
193
+ /**
194
+ * Save with semantic dedup: a new memory whose embedding is ≥ 0.92 cosine
195
+ * similar to an existing one merges into it (importance max-merged) instead
196
+ * of creating a near-identical row. Falls back to plain save() when no
197
+ * embedder is available.
198
+ */
199
+ async saveSmart(input) {
200
+ if (!this.embedder)
201
+ return { memory: this.save(input), deduped: false };
202
+ const vector = await this.embedder.embed(input.content);
203
+ if (!vector)
204
+ return { memory: this.save(input), deduped: false };
205
+ const [nearest] = this.vectors.search(vector, 1);
206
+ if (nearest && nearest.similarity >= NEAR_DUP_THRESHOLD) {
207
+ const existing = this.getById(nearest.memoryId);
208
+ if (existing) {
209
+ const importance = Math.max(existing.importance, input.importance ?? 5);
210
+ this.db.prepare('UPDATE memories SET importance = ?, last_accessed = ? WHERE id = ?')
211
+ .run(importance, new Date().toISOString(), existing.id);
212
+ logger.debug('memory', `near-dup (${nearest.similarity.toFixed(3)}) merged into ${existing.id}`);
213
+ return { memory: { ...existing, importance }, deduped: true };
214
+ }
215
+ }
216
+ const memory = this.save(input);
217
+ this.vectors.put(memory.id, vector, this.embedder.name);
218
+ return { memory, deduped: false };
219
+ }
220
+ /**
221
+ * Hybrid semantic search: 70% cosine similarity, 20% importance, 10% recency.
222
+ * Falls back to tokenized keyword search when embeddings are unavailable.
223
+ */
224
+ async searchSemantic(query, limit = 8) {
225
+ if (!this.embedder || this.vectors.count() === 0)
226
+ return this.search(query, limit);
227
+ const queryVec = await this.embedder.embed(query);
228
+ if (!queryVec)
229
+ return this.search(query, limit);
230
+ const candidates = this.vectors.search(queryVec, limit * 3);
231
+ const now = Date.now();
232
+ const scored = candidates
233
+ .map(c => {
234
+ const memory = this.getById(c.memoryId);
235
+ if (!memory || memory.archived)
236
+ return null;
237
+ const ageDays = (now - new Date(memory.last_accessed).getTime()) / 86_400_000;
238
+ const recency = Math.exp(-ageDays / 30);
239
+ const score = W_SEMANTIC * c.similarity
240
+ + W_IMPORTANCE * (memory.importance / 10)
241
+ + W_RECENCY * recency;
242
+ return { memory, score };
243
+ })
244
+ .filter((x) => x !== null)
245
+ .sort((a, b) => b.score - a.score)
246
+ .slice(0, limit);
247
+ const results = scored.map(s => s.memory);
248
+ if (results.length > 0)
249
+ this.incrementAccessBatch(results.map(m => m.id));
250
+ eventBus.emit('memory_retrieved', { query, count: results.length });
251
+ logger.debug('memory', `semantic search "${query.slice(0, 40)}" → ${results.length}`);
252
+ return results;
253
+ }
254
+ /** Semantic when available, keyword otherwise — the engine's entry point. */
255
+ async searchSmart(query, limit = 8) {
256
+ if (this.embedder && this.vectors.count() > 0)
257
+ return this.searchSemantic(query, limit);
258
+ return this.search(query, limit);
259
+ }
260
+ /** Re-embed every active memory. Returns how many were indexed. */
261
+ async rebuildIndex(onProgress) {
262
+ if (!this.embedder)
263
+ return 0;
264
+ const all = this.getAll(10_000);
265
+ let done = 0;
266
+ for (const m of all) {
267
+ const vec = await this.embedder.embed(m.content);
268
+ if (vec) {
269
+ this.vectors.put(m.id, vec, this.embedder.name);
270
+ done++;
271
+ }
272
+ onProgress?.(done, all.length);
273
+ }
274
+ logger.debug('memory', `rebuilt vector index: ${done}/${all.length}`);
275
+ return done;
276
+ }
277
+ /** Vector index stats for /memory stats. */
278
+ getIndexStats() {
279
+ return { indexed: this.vectors.count(), embedder: this.embedder?.name ?? null };
280
+ }
281
+ /**
282
+ * Summarize low-value memories (importance ≤ 3, never accessed) into a
283
+ * single summary memory and archive the originals. `generate` is any
284
+ * text-in/text-out function — provider-blind by design.
285
+ */
286
+ async summarizeLowValue(generate, opts = {}) {
287
+ const minCount = opts.minCount ?? 10;
288
+ const maxImportance = opts.maxImportance ?? 3;
289
+ const rows = this.db.prepare(`
290
+ SELECT * FROM memories
291
+ WHERE archived = 0 AND importance <= ? AND access_count = 0
292
+ ORDER BY created_at ASC
293
+ `).all(maxImportance);
294
+ if (rows.length < minCount)
295
+ return { summarized: 0, summary: null };
296
+ const list = rows.map(r => `- ${r.content}`).join('\n');
297
+ const prompt = `Summarize these minor facts about the user into one short paragraph. Keep every distinct fact:\n${list}`;
298
+ const text = (await generate(prompt)).trim();
299
+ if (!text)
300
+ return { summarized: 0, summary: null };
301
+ const summary = this.save({ content: text, type: 'context', importance: 5, tags: ['summary'] });
302
+ const archive = this.db.prepare('UPDATE memories SET archived = 1 WHERE id = ?');
303
+ const tx = this.db.transaction((ids) => { for (const id of ids)
304
+ archive.run(id); });
305
+ tx(rows.map(r => r.id));
306
+ logger.debug('memory', `summarized ${rows.length} low-value memories into ${summary.id}`);
307
+ return { summarized: rows.length, summary };
308
+ }
309
+ close() {
310
+ this.db.close();
311
+ }
312
+ }
@@ -0,0 +1,63 @@
1
+ import { normalizeFact, categorizeFact } from './intent.js';
2
+ const TRIGGERS = [
3
+ { pattern: /my name is .+/i, type: 'fact', importance: 9 },
4
+ { pattern: /i work at .+/i, type: 'fact', importance: 8 },
5
+ { pattern: /i live in .+/i, type: 'fact', importance: 8 },
6
+ { pattern: /i(?:'m| am) a .+/i, type: 'fact', importance: 7 },
7
+ { pattern: /i prefer .+/i, type: 'preference', importance: 7 },
8
+ { pattern: /i always .+/i, type: 'preference', importance: 6 },
9
+ { pattern: /i usually .+/i, type: 'preference', importance: 6 },
10
+ { pattern: /my favorite .+/i, type: 'preference', importance: 7 },
11
+ { pattern: /i like .+/i, type: 'preference', importance: 5 },
12
+ { pattern: /i hate .+/i, type: 'preference', importance: 6 },
13
+ { pattern: /remember that .+/i, type: 'context', importance: 8 },
14
+ { pattern: /you should know .+/i, type: 'context', importance: 8 },
15
+ ];
16
+ /** Ring buffer for short-term context window. */
17
+ export class CircularBuffer {
18
+ maxSize;
19
+ buf = [];
20
+ pos = 0;
21
+ constructor(maxSize = 30) {
22
+ this.maxSize = maxSize;
23
+ }
24
+ push(item) {
25
+ if (this.buf.length < this.maxSize) {
26
+ this.buf.push(item);
27
+ }
28
+ else {
29
+ this.buf[this.pos] = item;
30
+ this.pos = (this.pos + 1) % this.maxSize;
31
+ }
32
+ }
33
+ getAll() {
34
+ if (this.buf.length < this.maxSize)
35
+ return [...this.buf];
36
+ return [...this.buf.slice(this.pos), ...this.buf.slice(0, this.pos)];
37
+ }
38
+ clear() { this.buf = []; this.pos = 0; }
39
+ get size() { return this.buf.length; }
40
+ }
41
+ /**
42
+ * Scan text for memory-worthy sentences based on trigger patterns.
43
+ * Returns candidates sorted by importance desc.
44
+ */
45
+ export function extractMemoryCandidates(text) {
46
+ const sentences = text
47
+ .split(/[.!?\n]+/)
48
+ .map(s => s.trim())
49
+ .filter(s => s.length > 5);
50
+ const candidates = [];
51
+ for (const sentence of sentences) {
52
+ for (const { pattern, type, importance } of TRIGGERS) {
53
+ if (pattern.test(sentence)) {
54
+ // Store normalized third-person facts, not raw first-person sentences
55
+ const content = normalizeFact(sentence);
56
+ const refined = categorizeFact(content);
57
+ candidates.push({ content, type: refined === 'fact' ? type : refined, importance });
58
+ break; // one trigger per sentence
59
+ }
60
+ }
61
+ }
62
+ return candidates.sort((a, b) => b.importance - a.importance);
63
+ }
@@ -0,0 +1,5 @@
1
+ // MIT License — personal-ai
2
+ export const MEMORY_TYPES = [
3
+ 'fact', 'preference', 'context', 'episodic',
4
+ 'education', 'career', 'project', 'personal',
5
+ ];
@@ -0,0 +1,57 @@
1
+ // MIT License — personal-ai
2
+ // SQLite-backed vector store. Vectors live in a side table keyed by memory id
3
+ // — existing databases keep working; the table is created on demand.
4
+ import { cosineSimilarity } from './embeddings.js';
5
+ export class VectorStore {
6
+ db;
7
+ constructor(db) {
8
+ this.db = db;
9
+ this.db.exec(`
10
+ CREATE TABLE IF NOT EXISTS memory_vectors (
11
+ memory_id TEXT PRIMARY KEY,
12
+ model TEXT NOT NULL,
13
+ dims INTEGER NOT NULL,
14
+ vector BLOB NOT NULL
15
+ );
16
+ `);
17
+ }
18
+ put(memoryId, vector, model) {
19
+ const buf = Buffer.from(new Float32Array(vector).buffer);
20
+ this.db.prepare(`
21
+ INSERT INTO memory_vectors (memory_id, model, dims, vector)
22
+ VALUES (?, ?, ?, ?)
23
+ ON CONFLICT(memory_id) DO UPDATE SET model = excluded.model, dims = excluded.dims, vector = excluded.vector
24
+ `).run(memoryId, model, vector.length, buf);
25
+ }
26
+ get(memoryId) {
27
+ const row = this.db.prepare('SELECT vector FROM memory_vectors WHERE memory_id = ?')
28
+ .get(memoryId);
29
+ if (!row)
30
+ return null;
31
+ return new Float32Array(row.vector.buffer, row.vector.byteOffset, row.vector.byteLength / 4);
32
+ }
33
+ delete(memoryId) {
34
+ this.db.prepare('DELETE FROM memory_vectors WHERE memory_id = ?').run(memoryId);
35
+ }
36
+ count() {
37
+ return this.db.prepare('SELECT COUNT(*) AS n FROM memory_vectors').get().n;
38
+ }
39
+ /**
40
+ * Brute-force cosine search over all stored vectors. Fine for a personal
41
+ * assistant's scale (thousands of memories); swap for sqlite-vec if it
42
+ * ever becomes a bottleneck.
43
+ */
44
+ search(queryVector, limit = 16) {
45
+ const rows = this.db.prepare(`
46
+ SELECT v.memory_id, v.vector
47
+ FROM memory_vectors v
48
+ JOIN memories m ON m.id = v.memory_id AND m.archived = 0
49
+ `).all();
50
+ const scored = rows.map(r => ({
51
+ memoryId: r.memory_id,
52
+ similarity: cosineSimilarity(queryVector, new Float32Array(r.vector.buffer, r.vector.byteOffset, r.vector.byteLength / 4)),
53
+ }));
54
+ scored.sort((a, b) => b.similarity - a.similarity);
55
+ return scored.slice(0, limit);
56
+ }
57
+ }
@@ -0,0 +1,56 @@
1
+ // MIT License — personal-ai
2
+ import fs from 'node:fs';
3
+ import yaml from 'js-yaml';
4
+ import chokidar from 'chokidar';
5
+ import { logger } from '../core/logger.js';
6
+ import { PersonaConfigSchema, ProfilesConfigSchema, } from './types.js';
7
+ function readYaml(filePath) {
8
+ const raw = fs.readFileSync(filePath, 'utf8');
9
+ return yaml.load(raw);
10
+ }
11
+ /** Load and validate persona.yaml. */
12
+ export function loadPersona(filePath) {
13
+ const raw = readYaml(filePath);
14
+ const result = PersonaConfigSchema.safeParse(raw);
15
+ if (!result.success) {
16
+ logger.warn('persona', `Invalid persona config: ${result.error.message}`);
17
+ return PersonaConfigSchema.parse({ name: 'AI', expertise: [], avoid: [] });
18
+ }
19
+ logger.debug('persona', `Loaded persona: ${result.data.name}`);
20
+ return result.data;
21
+ }
22
+ /** Load and validate profiles.yaml. */
23
+ export function loadProfiles(filePath) {
24
+ const raw = readYaml(filePath);
25
+ const result = ProfilesConfigSchema.safeParse(raw);
26
+ if (!result.success) {
27
+ throw new Error(`Invalid profiles config: ${result.error.message}`);
28
+ }
29
+ logger.debug('persona', `Loaded ${Object.keys(result.data.profiles).length} profiles`);
30
+ return result.data;
31
+ }
32
+ /** Watch file for changes, call onChange with reparsed data. Returns cleanup fn. */
33
+ export function watchPersona(filePath, onChange) {
34
+ const watcher = chokidar.watch(filePath, { ignoreInitial: true });
35
+ watcher.on('change', () => {
36
+ try {
37
+ onChange(loadPersona(filePath));
38
+ }
39
+ catch (e) {
40
+ logger.warn('persona', `watch reload failed: ${String(e)}`);
41
+ }
42
+ });
43
+ return () => { void watcher.close(); };
44
+ }
45
+ export function watchProfiles(filePath, onChange) {
46
+ const watcher = chokidar.watch(filePath, { ignoreInitial: true });
47
+ watcher.on('change', () => {
48
+ try {
49
+ onChange(loadProfiles(filePath));
50
+ }
51
+ catch (e) {
52
+ logger.warn('persona', `watch reload failed: ${String(e)}`);
53
+ }
54
+ });
55
+ return () => { void watcher.close(); };
56
+ }
@@ -0,0 +1,51 @@
1
+ // MIT License — personal-ai
2
+ import { logger } from '../core/logger.js';
3
+ export class ProfileManager {
4
+ config;
5
+ active;
6
+ profiles;
7
+ constructor(config) {
8
+ this.config = config;
9
+ this.active = config.active;
10
+ this.profiles = config.profiles;
11
+ // Fallback to 'assistant' if active doesn't exist
12
+ if (!this.profiles[this.active]) {
13
+ const first = Object.keys(this.profiles)[0];
14
+ this.active = first ?? 'assistant';
15
+ logger.debug('profile', `Active profile "${config.active}" not found, using "${this.active}"`);
16
+ }
17
+ }
18
+ getActive() {
19
+ return this.profiles[this.active];
20
+ }
21
+ getActiveName() {
22
+ return this.active;
23
+ }
24
+ setActive(name) {
25
+ if (!this.profiles[name]) {
26
+ throw new Error(`Profile "${name}" not found. Available: ${Object.keys(this.profiles).join(', ')}`);
27
+ }
28
+ this.active = name;
29
+ logger.debug('profile', `switched to: ${name}`);
30
+ }
31
+ getAll() {
32
+ return this.profiles;
33
+ }
34
+ getPromptAddon() {
35
+ return this.getActive().system_addon ?? '';
36
+ }
37
+ getPreferredModel() {
38
+ return this.getActive().preferred_model;
39
+ }
40
+ getTemperature() {
41
+ return this.getActive().temperature;
42
+ }
43
+ /** Hot-reload when config file changes. */
44
+ reload(config) {
45
+ this.profiles = config.profiles;
46
+ if (!this.profiles[this.active]) {
47
+ this.active = config.active;
48
+ }
49
+ logger.debug('profile', `reloaded — active: ${this.active}`);
50
+ }
51
+ }