@dot-ai/ext-sqlite-memory 0.9.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/LICENSE +21 -0
- package/dist/extension.d.ts +3 -0
- package/dist/extension.d.ts.map +1 -0
- package/dist/extension.js +102 -0
- package/dist/extension.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/index.spec.d.ts +2 -0
- package/dist/index.spec.d.ts.map +1 -0
- package/dist/index.spec.js +108 -0
- package/dist/index.spec.js.map +1 -0
- package/dist/migrate-files.d.ts +3 -0
- package/dist/migrate-files.d.ts.map +1 -0
- package/dist/migrate-files.js +177 -0
- package/dist/migrate-files.js.map +1 -0
- package/dist/sqlite-memory.d.ts +15 -0
- package/dist/sqlite-memory.d.ts.map +1 -0
- package/dist/sqlite-memory.js +184 -0
- package/dist/sqlite-memory.js.map +1 -0
- package/package.json +35 -0
- package/src/__tests__/lifecycle.test.ts +209 -0
- package/src/__tests__/sqlite-memory.test.ts +99 -0
- package/src/extension.ts +102 -0
- package/src/index.spec.ts +138 -0
- package/src/index.ts +2 -0
- package/src/migrate-files.ts +193 -0
- package/src/sqlite-memory.ts +242 -0
- package/tsconfig.json +17 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { SqliteMemoryProvider } from './sqlite-memory.js';
|
|
3
|
+
|
|
4
|
+
describe('SqliteMemoryProvider.describe()', () => {
|
|
5
|
+
let provider: SqliteMemoryProvider;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
provider = new SqliteMemoryProvider({ path: ':memory:' });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
provider.close();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns a string containing "SQLite"', () => {
|
|
16
|
+
const result = provider.describe();
|
|
17
|
+
expect(typeof result).toBe('string');
|
|
18
|
+
expect(result).toContain('SQLite');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns a string containing "FTS5"', () => {
|
|
22
|
+
const result = provider.describe();
|
|
23
|
+
expect(result).toContain('FTS5');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('shows "0 entries" for an empty database', () => {
|
|
27
|
+
const result = provider.describe();
|
|
28
|
+
expect(result).toContain('0 entries');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('shows correct entry count after storing 3 entries', async () => {
|
|
32
|
+
await provider.store({ content: 'First memory entry about auth', type: 'log' });
|
|
33
|
+
await provider.store({ content: 'Second memory entry about routing', type: 'log' });
|
|
34
|
+
await provider.store({ content: 'Third memory entry about testing', type: 'log' });
|
|
35
|
+
|
|
36
|
+
const result = provider.describe();
|
|
37
|
+
expect(result).toContain('3 entries');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('SqliteMemoryProvider.search() — source field', () => {
|
|
42
|
+
let provider: SqliteMemoryProvider;
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
provider = new SqliteMemoryProvider({ path: ':memory:' });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
provider.close();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns entries with source "sqlite-memory"', async () => {
|
|
53
|
+
await provider.store({ content: 'The authentication middleware was refactored', type: 'log' });
|
|
54
|
+
const results = await provider.search('authentication');
|
|
55
|
+
|
|
56
|
+
expect(results.length).toBeGreaterThan(0);
|
|
57
|
+
for (const entry of results) {
|
|
58
|
+
expect(entry.source).toBe('sqlite-memory');
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('SqliteMemoryProvider store/search roundtrip', () => {
|
|
64
|
+
let provider: SqliteMemoryProvider;
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
provider = new SqliteMemoryProvider({ path: ':memory:' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
provider.close();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('stores an entry and retrieves it via search', async () => {
|
|
75
|
+
await provider.store({
|
|
76
|
+
content: 'Fixed the N+1 query in the task loader',
|
|
77
|
+
type: 'log',
|
|
78
|
+
date: '2026-03-04',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const results = await provider.search('task loader');
|
|
82
|
+
|
|
83
|
+
expect(results).toHaveLength(1);
|
|
84
|
+
expect(results[0].content).toContain('task loader');
|
|
85
|
+
expect(results[0].source).toBe('sqlite-memory');
|
|
86
|
+
expect(results[0].date).toBe('2026-03-04');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('stores multiple entries and retrieves them all', async () => {
|
|
90
|
+
await provider.store({ content: 'Auth middleware fix applied', type: 'log' });
|
|
91
|
+
await provider.store({ content: 'Auth token expiry extended to 7 days', type: 'decision' });
|
|
92
|
+
await provider.store({ content: 'Database migration ran without issues', type: 'log' });
|
|
93
|
+
|
|
94
|
+
const results = await provider.search('auth');
|
|
95
|
+
|
|
96
|
+
expect(results.length).toBeGreaterThanOrEqual(2);
|
|
97
|
+
const contents = results.map(r => r.content);
|
|
98
|
+
expect(contents.some(c => c.includes('middleware fix'))).toBe(true);
|
|
99
|
+
expect(contents.some(c => c.includes('token expiry'))).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('FTS5 search matches partial words', async () => {
|
|
103
|
+
await provider.store({ content: 'Authentication system redesigned', type: 'log' });
|
|
104
|
+
|
|
105
|
+
// FTS5 prefix search with trailing *
|
|
106
|
+
// The provider uses OR semantics — the word "authen" won't match without *
|
|
107
|
+
// But a full word "authentication" in the query should match
|
|
108
|
+
const results = await provider.search('authentication');
|
|
109
|
+
|
|
110
|
+
expect(results).toHaveLength(1);
|
|
111
|
+
expect(results[0].content).toContain('Authentication');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('does not return unrelated entries', async () => {
|
|
115
|
+
await provider.store({ content: 'React component lifecycle overview', type: 'fact' });
|
|
116
|
+
await provider.store({ content: 'Vue router configuration patterns', type: 'fact' });
|
|
117
|
+
await provider.store({ content: 'Database indexing strategy', type: 'fact' });
|
|
118
|
+
|
|
119
|
+
const results = await provider.search('authentication bearer token');
|
|
120
|
+
|
|
121
|
+
expect(results).toHaveLength(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('stores with labels and retrieves by label', async () => {
|
|
125
|
+
await provider.store({
|
|
126
|
+
content: 'Added input validation to all endpoints',
|
|
127
|
+
type: 'decision',
|
|
128
|
+
date: '2026-03-01',
|
|
129
|
+
labels: ['security', 'api'],
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const results = await provider.search('unrelated query', ['security']);
|
|
133
|
+
|
|
134
|
+
expect(results).toHaveLength(1);
|
|
135
|
+
expect(results[0].labels).toContain('security');
|
|
136
|
+
expect(results[0].date).toBe('2026-03-01');
|
|
137
|
+
});
|
|
138
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Migrate file-based memory to SQLite.
|
|
4
|
+
* Usage: npx tsx migrate-files.ts --root /path/to/workspace --db /path/to/memory.db
|
|
5
|
+
*/
|
|
6
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { SqliteMemoryProvider } from './sqlite-memory.js';
|
|
9
|
+
|
|
10
|
+
// Parse a single memory markdown file into entries
|
|
11
|
+
function parseMemoryFile(content: string, filename: string, node: string): Array<{
|
|
12
|
+
content: string;
|
|
13
|
+
type: string;
|
|
14
|
+
date: string | undefined;
|
|
15
|
+
labels: string[];
|
|
16
|
+
node: string;
|
|
17
|
+
}> {
|
|
18
|
+
// Extract date from filename if it matches YYYY-MM-DD pattern
|
|
19
|
+
const dateMatch = filename.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
20
|
+
const date = dateMatch ? dateMatch[1] : undefined;
|
|
21
|
+
|
|
22
|
+
// Split by ## headings into sections
|
|
23
|
+
const sections = content.split(/^## /m).filter(Boolean);
|
|
24
|
+
|
|
25
|
+
if (sections.length <= 1) {
|
|
26
|
+
// Single entry — whole file is one memory
|
|
27
|
+
return [{
|
|
28
|
+
content: content.trim().slice(0, 2000),
|
|
29
|
+
type: inferType(filename),
|
|
30
|
+
date,
|
|
31
|
+
labels: inferLabels(filename, content),
|
|
32
|
+
node,
|
|
33
|
+
}];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Multiple sections — each ## heading becomes an entry
|
|
37
|
+
return sections.map(section => {
|
|
38
|
+
const lines = section.split('\n');
|
|
39
|
+
const heading = lines[0]?.trim() ?? '';
|
|
40
|
+
const body = lines.slice(1).join('\n').trim();
|
|
41
|
+
const entryContent = heading ? `## ${heading}\n${body}` : body;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
content: entryContent.slice(0, 2000),
|
|
45
|
+
type: inferType(filename),
|
|
46
|
+
date,
|
|
47
|
+
labels: inferLabels(filename, entryContent),
|
|
48
|
+
node,
|
|
49
|
+
};
|
|
50
|
+
}).filter(e => e.content.length > 10);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function inferType(filename: string): string {
|
|
54
|
+
if (filename.includes('lesson')) return 'lesson';
|
|
55
|
+
if (filename.match(/^\d{4}-\d{2}-\d{2}/)) return 'log';
|
|
56
|
+
if (filename.includes('research') || filename.includes('analysis')) return 'research';
|
|
57
|
+
return 'note';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function inferLabels(filename: string, _content: string): string[] {
|
|
61
|
+
const labels: string[] = [];
|
|
62
|
+
const parts = filename.replace('.md', '').split('-');
|
|
63
|
+
const meaningful = new Set(['cockpit', 'pro', 'roule', 'caillou', 'van', 'todo', 'home', 'assistant', 'dot', 'ai', 'nx', 'blog', 'property', 'poi', 'digest', 'email', 'api']);
|
|
64
|
+
for (const part of parts) {
|
|
65
|
+
if (meaningful.has(part.toLowerCase())) {
|
|
66
|
+
labels.push(part.toLowerCase());
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return [...new Set(labels)];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function scanMemoryDir(dir: string, node: string): Promise<Array<{ content: string; type: string; date?: string; labels: string[]; node: string }>> {
|
|
73
|
+
const entries: Array<{ content: string; type: string; date?: string; labels: string[]; node: string }> = [];
|
|
74
|
+
|
|
75
|
+
let files: string[];
|
|
76
|
+
try {
|
|
77
|
+
files = await readdir(dir);
|
|
78
|
+
} catch {
|
|
79
|
+
return entries;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const file of files) {
|
|
83
|
+
if (!file.endsWith('.md')) continue;
|
|
84
|
+
if (file === 'projects-index.md') continue;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const content = await readFile(join(dir, file), 'utf-8');
|
|
88
|
+
const parsed = parseMemoryFile(content, file, node);
|
|
89
|
+
entries.push(...parsed);
|
|
90
|
+
} catch {
|
|
91
|
+
// Skip unreadable files
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Also scan _archive/ subdirectory
|
|
96
|
+
try {
|
|
97
|
+
const archiveFiles = await readdir(join(dir, '_archive'));
|
|
98
|
+
for (const file of archiveFiles) {
|
|
99
|
+
if (!file.endsWith('.md')) continue;
|
|
100
|
+
try {
|
|
101
|
+
const content = await readFile(join(dir, '_archive', file), 'utf-8');
|
|
102
|
+
const parsed = parseMemoryFile(content, file, node);
|
|
103
|
+
entries.push(...parsed);
|
|
104
|
+
} catch {
|
|
105
|
+
// Skip
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// No archive dir
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Also scan lessons/ subdirectory
|
|
113
|
+
try {
|
|
114
|
+
const lessonFiles = await readdir(join(dir, 'lessons'));
|
|
115
|
+
for (const file of lessonFiles) {
|
|
116
|
+
if (!file.endsWith('.md')) continue;
|
|
117
|
+
try {
|
|
118
|
+
const content = await readFile(join(dir, 'lessons', file), 'utf-8');
|
|
119
|
+
const parsed = parseMemoryFile(content, file, node);
|
|
120
|
+
for (const entry of parsed) {
|
|
121
|
+
entry.type = 'lesson';
|
|
122
|
+
}
|
|
123
|
+
entries.push(...parsed);
|
|
124
|
+
} catch {
|
|
125
|
+
// Skip
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
// No lessons dir
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return entries;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function main() {
|
|
136
|
+
const args = process.argv.slice(2);
|
|
137
|
+
const rootIdx = args.indexOf('--root');
|
|
138
|
+
const dbIdx = args.indexOf('--db');
|
|
139
|
+
|
|
140
|
+
const root = rootIdx >= 0 ? args[rootIdx + 1] : process.cwd();
|
|
141
|
+
const dbPath = dbIdx >= 0 ? args[dbIdx + 1] : join(root, '.ai', 'memory.db');
|
|
142
|
+
|
|
143
|
+
console.log(`Migrating memory files to SQLite`);
|
|
144
|
+
console.log(` Root: ${root}`);
|
|
145
|
+
console.log(` DB: ${dbPath}`);
|
|
146
|
+
|
|
147
|
+
// Create provider (creates DB and tables)
|
|
148
|
+
const provider = new SqliteMemoryProvider({ path: dbPath });
|
|
149
|
+
|
|
150
|
+
// Scan root memory
|
|
151
|
+
const rootEntries = await scanMemoryDir(join(root, '.ai', 'memory'), 'root');
|
|
152
|
+
console.log(` Root memory: ${rootEntries.length} entries`);
|
|
153
|
+
|
|
154
|
+
// Scan project memories
|
|
155
|
+
let projectEntries = 0;
|
|
156
|
+
try {
|
|
157
|
+
const projects = await readdir(join(root, 'projects'));
|
|
158
|
+
for (const project of projects) {
|
|
159
|
+
const memDir = join(root, 'projects', project, '.ai', 'memory');
|
|
160
|
+
const entries = await scanMemoryDir(memDir, project);
|
|
161
|
+
if (entries.length > 0) {
|
|
162
|
+
console.log(` ${project}: ${entries.length} entries`);
|
|
163
|
+
for (const entry of entries) {
|
|
164
|
+
await provider.store(entry);
|
|
165
|
+
}
|
|
166
|
+
projectEntries += entries.length;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// No projects dir
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Store root entries
|
|
174
|
+
for (const entry of rootEntries) {
|
|
175
|
+
await provider.store(entry);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const total = rootEntries.length + projectEntries;
|
|
179
|
+
console.log(`\nMigrated ${total} entries total`);
|
|
180
|
+
|
|
181
|
+
// Test search
|
|
182
|
+
console.log(`\nTest search for "cockpit":`);
|
|
183
|
+
const results = await provider.search('cockpit');
|
|
184
|
+
console.log(` Found ${results.length} results`);
|
|
185
|
+
if (results.length > 0) {
|
|
186
|
+
console.log(` First: ${results[0].content.slice(0, 100)}...`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
provider.close();
|
|
190
|
+
console.log(`\nDone. DB saved to ${dbPath}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { MemoryEntry } from '@dot-ai/core';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Compute Jaccard similarity between two strings (word-level).
|
|
7
|
+
* similarity = |intersection| / |union|
|
|
8
|
+
*/
|
|
9
|
+
function jaccardSimilarity(a: string, b: string): number {
|
|
10
|
+
const wordsA = new Set(
|
|
11
|
+
a.toLowerCase().replace(/[^\w\s]/g, ' ').split(/\s+/).filter(w => w.length > 1)
|
|
12
|
+
);
|
|
13
|
+
const wordsB = new Set(
|
|
14
|
+
b.toLowerCase().replace(/[^\w\s]/g, ' ').split(/\s+/).filter(w => w.length > 1)
|
|
15
|
+
);
|
|
16
|
+
if (wordsA.size === 0 && wordsB.size === 0) return 1;
|
|
17
|
+
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
|
18
|
+
|
|
19
|
+
let intersection = 0;
|
|
20
|
+
for (const w of wordsA) {
|
|
21
|
+
if (wordsB.has(w)) intersection++;
|
|
22
|
+
}
|
|
23
|
+
const union = wordsA.size + wordsB.size - intersection;
|
|
24
|
+
return intersection / union;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class SqliteMemoryProvider {
|
|
28
|
+
private db: Database.Database;
|
|
29
|
+
|
|
30
|
+
constructor(options: Record<string, unknown> = {}) {
|
|
31
|
+
const root = (options.root as string) ?? process.cwd();
|
|
32
|
+
const rawPath = (options.path as string) ?? ':memory:';
|
|
33
|
+
const dbPath = rawPath === ':memory:' ? rawPath : (rawPath.startsWith('/') ? rawPath : join(root, rawPath));
|
|
34
|
+
this.db = new Database(dbPath);
|
|
35
|
+
this.db.pragma('journal_mode = WAL');
|
|
36
|
+
this.init();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private init(): void {
|
|
40
|
+
this.db.exec(`
|
|
41
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
42
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
43
|
+
content TEXT NOT NULL,
|
|
44
|
+
type TEXT NOT NULL DEFAULT 'log',
|
|
45
|
+
date TEXT,
|
|
46
|
+
labels TEXT DEFAULT '[]',
|
|
47
|
+
node TEXT,
|
|
48
|
+
source TEXT DEFAULT 'sqlite-memory',
|
|
49
|
+
created INTEGER DEFAULT (unixepoch())
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
|
53
|
+
content,
|
|
54
|
+
labels,
|
|
55
|
+
node,
|
|
56
|
+
content='memories',
|
|
57
|
+
content_rowid='id'
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
|
61
|
+
INSERT INTO memories_fts(rowid, content, labels, node)
|
|
62
|
+
VALUES (new.id, new.content, new.labels, new.node);
|
|
63
|
+
END;
|
|
64
|
+
|
|
65
|
+
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
|
66
|
+
INSERT INTO memories_fts(memories_fts, rowid, content, labels, node)
|
|
67
|
+
VALUES ('delete', old.id, old.content, old.labels, old.node);
|
|
68
|
+
END;
|
|
69
|
+
`);
|
|
70
|
+
|
|
71
|
+
// Graceful migration: add lifecycle columns if they don't exist
|
|
72
|
+
const existingCols = (this.db.prepare(`PRAGMA table_info(memories)`).all() as Array<{ name: string }>)
|
|
73
|
+
.map(c => c.name);
|
|
74
|
+
|
|
75
|
+
if (!existingCols.includes('score')) {
|
|
76
|
+
this.db.exec(`ALTER TABLE memories ADD COLUMN score REAL DEFAULT 1.0`);
|
|
77
|
+
}
|
|
78
|
+
if (!existingCols.includes('last_recalled')) {
|
|
79
|
+
this.db.exec(`ALTER TABLE memories ADD COLUMN last_recalled TEXT`);
|
|
80
|
+
}
|
|
81
|
+
if (!existingCols.includes('recall_count')) {
|
|
82
|
+
this.db.exec(`ALTER TABLE memories ADD COLUMN recall_count INTEGER DEFAULT 0`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async store(entry: Omit<MemoryEntry, 'source'>): Promise<void> {
|
|
87
|
+
const content = entry.content;
|
|
88
|
+
|
|
89
|
+
// Extract key terms for FTS dedup check
|
|
90
|
+
const queryWords = content
|
|
91
|
+
.replace(/[^\w\s]/g, ' ')
|
|
92
|
+
.split(/\s+/)
|
|
93
|
+
.filter(w => w.length > 1);
|
|
94
|
+
|
|
95
|
+
if (queryWords.length > 0) {
|
|
96
|
+
const ftsQuery = queryWords.join(' OR ');
|
|
97
|
+
let candidates: Array<{ id: number; content: string }> = [];
|
|
98
|
+
try {
|
|
99
|
+
candidates = this.db.prepare(`
|
|
100
|
+
SELECT m.id, m.content
|
|
101
|
+
FROM memories_fts
|
|
102
|
+
JOIN memories m ON m.id = memories_fts.rowid
|
|
103
|
+
WHERE memories_fts MATCH ?
|
|
104
|
+
LIMIT 10
|
|
105
|
+
`).all(ftsQuery) as typeof candidates;
|
|
106
|
+
} catch {
|
|
107
|
+
// FTS error — fall through to insert
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const candidate of candidates) {
|
|
111
|
+
const similarity = jaccardSimilarity(content, candidate.content);
|
|
112
|
+
if (similarity > 0.85) {
|
|
113
|
+
// Duplicate found — update existing entry instead of inserting
|
|
114
|
+
this.db.prepare(
|
|
115
|
+
`UPDATE memories SET content = ?, date = ?, score = MIN(score + 0.1, 5.0) WHERE id = ?`
|
|
116
|
+
).run(
|
|
117
|
+
content,
|
|
118
|
+
entry.date ?? new Date().toISOString().slice(0, 10),
|
|
119
|
+
candidate.id,
|
|
120
|
+
);
|
|
121
|
+
// Update FTS index for the modified row
|
|
122
|
+
this.db.prepare(
|
|
123
|
+
`INSERT INTO memories_fts(memories_fts, rowid, content, labels, node) VALUES ('delete', ?, ?, ?, ?)`
|
|
124
|
+
).run(candidate.id, candidate.content, '[]', null);
|
|
125
|
+
const updated = this.db.prepare(`SELECT content, labels, node FROM memories WHERE id = ?`).get(candidate.id) as { content: string; labels: string; node: string | null };
|
|
126
|
+
this.db.prepare(
|
|
127
|
+
`INSERT INTO memories_fts(rowid, content, labels, node) VALUES (?, ?, ?, ?)`
|
|
128
|
+
).run(candidate.id, updated.content, updated.labels, updated.node);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// No duplicate found — insert new entry
|
|
135
|
+
const stmt = this.db.prepare(
|
|
136
|
+
'INSERT INTO memories (content, type, date, labels, node, score, recall_count) VALUES (?, ?, ?, ?, ?, 1.0, 0)'
|
|
137
|
+
);
|
|
138
|
+
stmt.run(
|
|
139
|
+
entry.content,
|
|
140
|
+
entry.type ?? 'log',
|
|
141
|
+
entry.date ?? new Date().toISOString().slice(0, 10),
|
|
142
|
+
JSON.stringify(entry.labels ?? []),
|
|
143
|
+
entry.node ?? null,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async search(query: string, labels?: string[]): Promise<MemoryEntry[]> {
|
|
148
|
+
const queryWords = query
|
|
149
|
+
.replace(/[^\w\s]/g, ' ')
|
|
150
|
+
.split(/\s+/)
|
|
151
|
+
.filter(w => w.length > 1);
|
|
152
|
+
|
|
153
|
+
const labelWords = (labels ?? [])
|
|
154
|
+
.map(l => l.replace(/[^\w\s]/g, '').trim())
|
|
155
|
+
.filter(w => w.length > 1);
|
|
156
|
+
|
|
157
|
+
const allTerms = [...new Set([...queryWords, ...labelWords])];
|
|
158
|
+
const cleanQuery = allTerms.join(' OR ');
|
|
159
|
+
|
|
160
|
+
if (!cleanQuery) return [];
|
|
161
|
+
|
|
162
|
+
let rows: Array<{
|
|
163
|
+
id: number;
|
|
164
|
+
content: string;
|
|
165
|
+
type: string;
|
|
166
|
+
date: string | null;
|
|
167
|
+
labels: string;
|
|
168
|
+
node: string | null;
|
|
169
|
+
score: number;
|
|
170
|
+
rank: number;
|
|
171
|
+
}>;
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
rows = this.db.prepare(`
|
|
175
|
+
SELECT m.id, m.content, m.type, m.date, m.labels, m.node, m.score,
|
|
176
|
+
bm25(memories_fts) AS rank
|
|
177
|
+
FROM memories_fts
|
|
178
|
+
JOIN memories m ON m.id = memories_fts.rowid
|
|
179
|
+
WHERE memories_fts MATCH ?
|
|
180
|
+
ORDER BY rank
|
|
181
|
+
LIMIT 20
|
|
182
|
+
`).all(cleanQuery) as typeof rows;
|
|
183
|
+
} catch {
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Score bump: increment score, update last_recalled, increment recall_count
|
|
188
|
+
if (rows.length > 0) {
|
|
189
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
190
|
+
const updateStmt = this.db.prepare(`
|
|
191
|
+
UPDATE memories
|
|
192
|
+
SET score = MIN(score + 0.1, 5.0),
|
|
193
|
+
last_recalled = ?,
|
|
194
|
+
recall_count = recall_count + 1
|
|
195
|
+
WHERE id = ?
|
|
196
|
+
`);
|
|
197
|
+
for (const row of rows) {
|
|
198
|
+
updateStmt.run(today, row.id);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return rows.map(row => ({
|
|
203
|
+
content: row.content,
|
|
204
|
+
type: row.type,
|
|
205
|
+
source: 'sqlite-memory' as const,
|
|
206
|
+
date: row.date ?? undefined,
|
|
207
|
+
labels: JSON.parse(row.labels) as string[],
|
|
208
|
+
node: row.node ?? undefined,
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async consolidate(): Promise<{ archived: number; deleted: number }> {
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
const day = 86400000;
|
|
215
|
+
|
|
216
|
+
const deletedLogs = this.db.prepare(
|
|
217
|
+
`DELETE FROM memories WHERE type = 'log' AND date < ? AND score < 0.3`
|
|
218
|
+
).run(new Date(now - 30 * day).toISOString().slice(0, 10));
|
|
219
|
+
|
|
220
|
+
const deletedNotes = this.db.prepare(
|
|
221
|
+
`DELETE FROM memories WHERE type = 'note' AND date < ? AND score < 0.3`
|
|
222
|
+
).run(new Date(now - 60 * day).toISOString().slice(0, 10));
|
|
223
|
+
|
|
224
|
+
const deletedOld = this.db.prepare(
|
|
225
|
+
`DELETE FROM memories WHERE type NOT IN ('lesson', 'decision', 'fact') AND date < ? AND score < 0.1`
|
|
226
|
+
).run(new Date(now - 90 * day).toISOString().slice(0, 10));
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
archived: 0,
|
|
230
|
+
deleted: (deletedLogs.changes as number) + (deletedNotes.changes as number) + (deletedOld.changes as number),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
describe(): string {
|
|
235
|
+
const count = (this.db.prepare('SELECT COUNT(*) as count FROM memories').get() as { count: number }).count;
|
|
236
|
+
return `Memory: SQLite with FTS5 full-text search (${count} entries). Use memory_recall to search, memory_store to save. This is the ONLY memory system — do not read or write memory/*.md files.`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
close(): void {
|
|
240
|
+
this.db.close();
|
|
241
|
+
}
|
|
242
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"declarationMap": true,
|
|
10
|
+
"sourceMap": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"composite": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*.ts"],
|
|
16
|
+
"exclude": ["src/**/*.test.ts"]
|
|
17
|
+
}
|