n2-soul 4.1.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.
@@ -0,0 +1,213 @@
1
+ // Soul KV-Cache — Snapshot engine. Creates/restores session snapshots from disk.
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { createSession, migrateSession } = require('./schema');
5
+ const { logError } = require('../utils');
6
+
7
+ /**
8
+ * Snapshot engine for session persistence.
9
+ * Stores compressed session snapshots to disk with date-based organization.
10
+ */
11
+ class SnapshotEngine {
12
+ /**
13
+ * @param {string} baseDir - Base directory for snapshots (e.g., data/kv-cache/snapshots)
14
+ */
15
+ constructor(baseDir) {
16
+ this.baseDir = baseDir;
17
+ }
18
+
19
+ /**
20
+ * Save a session snapshot to disk.
21
+ *
22
+ * @param {object} session - Normalized session object from schema.js
23
+ * @returns {string} Snapshot ID
24
+ */
25
+ save(session) {
26
+ const s = createSession(session);
27
+ s.endedAt = s.endedAt || new Date().toISOString();
28
+
29
+ const dateStr = s.endedAt.split('T')[0];
30
+ const dir = path.join(this.baseDir, s.projectName, dateStr);
31
+
32
+ if (!fs.existsSync(dir)) {
33
+ fs.mkdirSync(dir, { recursive: true });
34
+ }
35
+
36
+ const fileName = `${s.id}.json`;
37
+ const filePath = path.join(dir, fileName);
38
+
39
+ fs.writeFileSync(filePath, JSON.stringify(s, null, 2), 'utf-8');
40
+ return s.id;
41
+ }
42
+
43
+ /**
44
+ * Load the most recent snapshot for a project.
45
+ *
46
+ * @param {string} projectName
47
+ * @returns {object|null} Session object or null
48
+ */
49
+ loadLatest(projectName) {
50
+ const snapshots = this.list(projectName, 1);
51
+ return snapshots.length > 0 ? snapshots[0] : null;
52
+ }
53
+
54
+ /**
55
+ * Load a specific snapshot by ID.
56
+ *
57
+ * @param {string} projectName
58
+ * @param {string} snapshotId
59
+ * @returns {object|null}
60
+ */
61
+ loadById(projectName, snapshotId) {
62
+ const projectDir = path.join(this.baseDir, projectName);
63
+ if (!fs.existsSync(projectDir)) return null;
64
+
65
+ // Search through date directories
66
+ const dateDirs = this._getDateDirs(projectDir);
67
+ for (const dateDir of dateDirs) {
68
+ const filePath = path.join(dateDir, `${snapshotId}.json`);
69
+ if (fs.existsSync(filePath)) {
70
+ return this._readSnapshot(filePath);
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * List snapshots for a project, sorted by recency.
78
+ *
79
+ * @param {string} projectName
80
+ * @param {number} limit - Max results
81
+ * @returns {object[]} Array of session objects
82
+ */
83
+ list(projectName, limit = 10) {
84
+ const projectDir = path.join(this.baseDir, projectName);
85
+ if (!fs.existsSync(projectDir)) return [];
86
+
87
+ const dateDirs = this._getDateDirs(projectDir);
88
+ const all = [];
89
+
90
+ for (const dateDir of dateDirs) {
91
+ try {
92
+ const files = fs.readdirSync(dateDir).filter(f => f.endsWith('.json'));
93
+ for (const file of files) {
94
+ const snap = this._readSnapshot(path.join(dateDir, file));
95
+ if (snap) all.push(snap);
96
+ }
97
+ } catch (e) { logError('snapshot:list', e); }
98
+ }
99
+
100
+ // Sort by endedAt descending (most recent first)
101
+ all.sort((a, b) => {
102
+ const ta = new Date(b.endedAt || b.startedAt).getTime();
103
+ const tb = new Date(a.endedAt || a.startedAt).getTime();
104
+ return ta - tb;
105
+ });
106
+
107
+ return all.slice(0, limit);
108
+ }
109
+
110
+ /**
111
+ * Search snapshots by keyword.
112
+ *
113
+ * @param {string} query - Space-separated keywords
114
+ * @param {string} projectName
115
+ * @param {number} limit
116
+ * @returns {object[]} Matching snapshots with scores
117
+ */
118
+ search(query, projectName, limit = 10) {
119
+ const keywords = query.toLowerCase().split(/\s+/).filter(k => k.length >= 2);
120
+ const snapshots = this.list(projectName, 100);
121
+
122
+ const scored = snapshots.map(snap => {
123
+ let score = 0;
124
+ const text = [
125
+ snap.context?.summary || '',
126
+ ...(snap.keys || []),
127
+ ...(snap.context?.decisions || []),
128
+ ].join(' ').toLowerCase();
129
+
130
+ for (const kw of keywords) {
131
+ const escaped = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
132
+ const matches = (text.match(new RegExp(escaped, 'g')) || []).length;
133
+ score += matches;
134
+ }
135
+
136
+ return { ...snap, _score: score };
137
+ });
138
+
139
+ return scored
140
+ .filter(s => s._score > 0)
141
+ .sort((a, b) => b._score - a._score)
142
+ .slice(0, limit);
143
+ }
144
+
145
+ /**
146
+ * Garbage collect old snapshots.
147
+ *
148
+ * @param {string} projectName
149
+ * @param {number} maxAgeDays - Delete snapshots older than N days
150
+ * @param {number} maxCount - Keep at most N snapshots
151
+ * @returns {{ deleted: number }}
152
+ */
153
+ gc(projectName, maxAgeDays = 30, maxCount = 50) {
154
+ const snapshots = this.list(projectName, 9999);
155
+ let deleted = 0;
156
+ const cutoff = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000);
157
+
158
+ // Delete by age
159
+ for (const snap of snapshots) {
160
+ const ts = new Date(snap.endedAt || snap.startedAt).getTime();
161
+ if (ts < cutoff) {
162
+ this._deleteSnapshot(snap);
163
+ deleted++;
164
+ }
165
+ }
166
+
167
+ // Delete excess (keep most recent maxCount)
168
+ const remaining = this.list(projectName, 9999);
169
+ if (remaining.length > maxCount) {
170
+ const excess = remaining.slice(maxCount);
171
+ for (const snap of excess) {
172
+ this._deleteSnapshot(snap);
173
+ deleted++;
174
+ }
175
+ }
176
+
177
+ return { deleted };
178
+ }
179
+
180
+ // -- Private helpers --
181
+
182
+ _readSnapshot(filePath) {
183
+ try {
184
+ const raw = fs.readFileSync(filePath, 'utf-8');
185
+ return migrateSession(JSON.parse(raw));
186
+ } catch (e) {
187
+ logError('snapshot:read', `${path.basename(filePath)}: ${e.message}`);
188
+ return null;
189
+ }
190
+ }
191
+
192
+ _deleteSnapshot(snap) {
193
+ const dateStr = (snap.endedAt || snap.startedAt || '').split('T')[0];
194
+ if (!dateStr) return;
195
+ const filePath = path.join(this.baseDir, snap.projectName, dateStr, `${snap.id}.json`);
196
+ try { fs.unlinkSync(filePath); } catch (e) { logError('snapshot:delete', e); }
197
+ }
198
+
199
+ _getDateDirs(projectDir) {
200
+ try {
201
+ return fs.readdirSync(projectDir, { withFileTypes: true })
202
+ .filter(d => d.isDirectory())
203
+ .map(d => path.join(projectDir, d.name))
204
+ .sort()
205
+ .reverse(); // most recent first
206
+ } catch (e) {
207
+ logError('snapshot:getDateDirs', e);
208
+ return [];
209
+ }
210
+ }
211
+ }
212
+
213
+ module.exports = { SnapshotEngine };
@@ -0,0 +1,402 @@
1
+ // Soul KV-Cache — SQLite storage engine (pure JS via sql.js). No native dependencies.
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { createSession } = require('./schema');
5
+
6
+ // Module-level singleton: sql.js SQL module (loaded once via async init)
7
+ let _SQL = null;
8
+ let _sqlInitPromise = null;
9
+
10
+ /**
11
+ * Initialize sql.js module once.
12
+ * @returns {Promise<object>} sql.js module with Database constructor
13
+ */
14
+ async function initSqlJs() {
15
+ if (_SQL) return _SQL;
16
+ if (_sqlInitPromise) return _sqlInitPromise;
17
+
18
+ _sqlInitPromise = (async () => {
19
+ const initFn = require('sql.js');
20
+ _SQL = await initFn();
21
+ return _SQL;
22
+ })();
23
+
24
+ return _sqlInitPromise;
25
+ }
26
+
27
+ /**
28
+ * Get sql.js module synchronously (must call initSqlJs() first).
29
+ * @returns {object|null}
30
+ */
31
+ function getSqlSync() {
32
+ return _SQL;
33
+ }
34
+
35
+ /**
36
+ * SQLite-backed snapshot storage using sql.js (pure JavaScript WASM).
37
+ * No native compilation — works with any Node.js version.
38
+ *
39
+ * Architecture:
40
+ * - Module-level async init for sql.js WASM (once per process)
41
+ * - Per-project SQLite files at {baseDir}/{project}.sqlite
42
+ * - LIKE-based search (FTS5 not available in default WASM build)
43
+ * - Auto-persist on every write operation
44
+ */
45
+ class SqliteStore {
46
+ /**
47
+ * @param {string} baseDir - Base directory for SQLite databases
48
+ */
49
+ constructor(baseDir) {
50
+ this.baseDir = baseDir;
51
+ this._dbs = {}; // { projectName: { db, dirty, path } }
52
+ this._ready = false;
53
+ }
54
+
55
+ /**
56
+ * Async initialization. Must be called before any operations.
57
+ * Safe to call multiple times.
58
+ */
59
+ async init() {
60
+ if (this._ready) return;
61
+ await initSqlJs();
62
+ this._ready = true;
63
+ }
64
+
65
+ /**
66
+ * Ensure the module is initialized. Throws if not ready.
67
+ */
68
+ _assertReady() {
69
+ if (!_SQL) {
70
+ // Try sync fallback — will work after first async init
71
+ throw new Error('SqliteStore not initialized. Call init() first or use ensureReady().');
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Ensure ready and get/create DB for project.
77
+ * @param {string} projectName
78
+ * @returns {object} sql.js Database
79
+ */
80
+ _getDb(projectName) {
81
+ if (this._dbs[projectName]) return this._dbs[projectName].db;
82
+
83
+ this._assertReady();
84
+ const dbPath = path.join(this.baseDir, `${projectName}.sqlite`);
85
+
86
+ if (!fs.existsSync(this.baseDir)) {
87
+ fs.mkdirSync(this.baseDir, { recursive: true });
88
+ }
89
+
90
+ let db;
91
+ if (fs.existsSync(dbPath)) {
92
+ const buffer = fs.readFileSync(dbPath);
93
+ db = new _SQL.Database(buffer);
94
+ } else {
95
+ db = new _SQL.Database();
96
+ }
97
+
98
+ // Schema: separation of concerns — tool-catalog gets tools only, others get snapshots only
99
+ const isToolCatalog = projectName === '_tool-catalog';
100
+
101
+ if (!isToolCatalog) {
102
+ db.run(`
103
+ CREATE TABLE IF NOT EXISTS snapshots (
104
+ id TEXT PRIMARY KEY,
105
+ agent_name TEXT NOT NULL,
106
+ agent_type TEXT DEFAULT 'external',
107
+ model TEXT,
108
+ started_at TEXT,
109
+ ended_at TEXT,
110
+ turn_count INTEGER DEFAULT 0,
111
+ token_estimate INTEGER DEFAULT 0,
112
+ keys TEXT DEFAULT '[]',
113
+ context TEXT DEFAULT '{}',
114
+ parent_session_id TEXT,
115
+ project_name TEXT NOT NULL,
116
+ created_at TEXT DEFAULT (datetime('now'))
117
+ )
118
+ `);
119
+ db.run(`
120
+ CREATE INDEX IF NOT EXISTS idx_snapshots_project
121
+ ON snapshots(project_name, ended_at DESC)
122
+ `);
123
+ }
124
+
125
+ if (isToolCatalog) {
126
+ // Tools table — only for dedicated tool catalog DB
127
+ db.run(`
128
+ CREATE TABLE IF NOT EXISTS tools (
129
+ name TEXT PRIMARY KEY,
130
+ description TEXT DEFAULT '',
131
+ source TEXT DEFAULT 'unknown',
132
+ category TEXT DEFAULT 'misc',
133
+ plugin_name TEXT DEFAULT '',
134
+ input_schema TEXT DEFAULT '{}',
135
+ triggers TEXT DEFAULT '[]',
136
+ tags TEXT DEFAULT '[]',
137
+ search_text TEXT DEFAULT '',
138
+ embedding TEXT DEFAULT '',
139
+ usage_count INTEGER DEFAULT 0,
140
+ success_rate REAL DEFAULT 1.0,
141
+ registered_at TEXT DEFAULT (datetime('now')),
142
+ updated_at TEXT DEFAULT (datetime('now'))
143
+ )
144
+ `);
145
+ db.run(`CREATE INDEX IF NOT EXISTS idx_tools_source ON tools(source)`);
146
+ db.run(`CREATE INDEX IF NOT EXISTS idx_tools_category ON tools(category)`);
147
+ }
148
+
149
+ this._dbs[projectName] = { db, dirty: false, path: dbPath };
150
+ return db;
151
+ }
152
+
153
+ /**
154
+ * Persist database to disk.
155
+ * @param {string} projectName
156
+ */
157
+ _persist(projectName) {
158
+ const entry = this._dbs[projectName];
159
+ if (!entry) return;
160
+ const data = entry.db.export();
161
+ fs.writeFileSync(entry.path, Buffer.from(data));
162
+ entry.dirty = false;
163
+ }
164
+
165
+ /**
166
+ * Save a session snapshot.
167
+ * @param {object} session - Normalized session from schema.js
168
+ * @returns {string} Snapshot ID
169
+ */
170
+ save(session) {
171
+ const s = createSession(session);
172
+ s.endedAt = s.endedAt || new Date().toISOString();
173
+
174
+ const db = this._getDb(s.projectName);
175
+ const keys = JSON.stringify(s.keys);
176
+ const context = JSON.stringify(s.context);
177
+
178
+ db.run(`
179
+ INSERT OR REPLACE INTO snapshots
180
+ (id, agent_name, agent_type, model, started_at, ended_at,
181
+ turn_count, token_estimate, keys, context, parent_session_id, project_name)
182
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
183
+ `, [
184
+ s.id, s.agentName, s.agentType, s.model,
185
+ s.startedAt, s.endedAt,
186
+ s.turnCount, s.tokenEstimate,
187
+ keys, context,
188
+ s.parentSessionId, s.projectName,
189
+ ]);
190
+
191
+ this._persist(s.projectName);
192
+ return s.id;
193
+ }
194
+
195
+ /**
196
+ * Load the most recent snapshot.
197
+ * @param {string} projectName
198
+ * @returns {object|null}
199
+ */
200
+ loadLatest(projectName) {
201
+ const results = this.list(projectName, 1);
202
+ return results.length > 0 ? results[0] : null;
203
+ }
204
+
205
+ /**
206
+ * Load a specific snapshot by ID.
207
+ * @param {string} projectName
208
+ * @param {string} snapshotId
209
+ * @returns {object|null}
210
+ */
211
+ loadById(projectName, snapshotId) {
212
+ const db = this._getDb(projectName);
213
+ const results = db.exec('SELECT * FROM snapshots WHERE id = ?', [snapshotId]);
214
+ if (results.length === 0 || results[0].values.length === 0) return null;
215
+ return this._resultToSession(results[0].columns, results[0].values[0]);
216
+ }
217
+
218
+ /**
219
+ * List snapshots sorted by recency.
220
+ * @param {string} projectName
221
+ * @param {number} limit
222
+ * @returns {object[]}
223
+ */
224
+ list(projectName, limit = 10) {
225
+ const db = this._getDb(projectName);
226
+ const results = db.exec(`
227
+ SELECT * FROM snapshots
228
+ WHERE project_name = ?
229
+ ORDER BY COALESCE(ended_at, started_at) DESC
230
+ LIMIT ?
231
+ `, [projectName, limit]);
232
+
233
+ if (results.length === 0) return [];
234
+ return results[0].values.map(row =>
235
+ this._resultToSession(results[0].columns, row)
236
+ );
237
+ }
238
+
239
+ /**
240
+ * Search snapshots by keyword (LIKE-based).
241
+ * @param {string} query - Space-separated keywords
242
+ * @param {string} projectName
243
+ * @param {number} limit
244
+ * @returns {object[]}
245
+ */
246
+ search(query, projectName, limit = 10) {
247
+ const db = this._getDb(projectName);
248
+ const keywords = query.toLowerCase().split(/\s+/).filter(k => k.length >= 2);
249
+ if (keywords.length === 0) return [];
250
+
251
+ const conditions = keywords.map(() =>
252
+ `(LOWER(keys) LIKE ? OR LOWER(context) LIKE ?)`
253
+ ).join(' AND ');
254
+
255
+ const params = [];
256
+ for (const kw of keywords) {
257
+ params.push(`%${kw}%`, `%${kw}%`);
258
+ }
259
+ params.push(projectName, limit);
260
+
261
+ const results = db.exec(`
262
+ SELECT * FROM snapshots
263
+ WHERE ${conditions}
264
+ AND project_name = ?
265
+ ORDER BY COALESCE(ended_at, started_at) DESC
266
+ LIMIT ?
267
+ `, params);
268
+
269
+ if (results.length === 0) return [];
270
+ return results[0].values.map(row => {
271
+ const session = this._resultToSession(results[0].columns, row);
272
+ // Score by keyword match count
273
+ const text = JSON.stringify(session).toLowerCase();
274
+ session._score = keywords.reduce((sum, kw) => {
275
+ const escaped = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
276
+ return sum + (text.match(new RegExp(escaped, 'g')) || []).length;
277
+ }, 0);
278
+ return session;
279
+ }).sort((a, b) => b._score - a._score);
280
+ }
281
+
282
+ /**
283
+ * Garbage collect old snapshots.
284
+ * @param {string} projectName
285
+ * @param {number} maxAgeDays
286
+ * @param {number} maxCount
287
+ * @returns {{ deleted: number }}
288
+ */
289
+ gc(projectName, maxAgeDays = 30, maxCount = 50) {
290
+ const db = this._getDb(projectName);
291
+
292
+ // Count before
293
+ const beforeResult = db.exec(
294
+ 'SELECT COUNT(*) FROM snapshots WHERE project_name = ?',
295
+ [projectName]
296
+ );
297
+ const before = beforeResult.length > 0 ? beforeResult[0].values[0][0] : 0;
298
+
299
+ // Delete by age
300
+ const cutoffDate = new Date(Date.now() - maxAgeDays * 86400000).toISOString();
301
+ db.run(`
302
+ DELETE FROM snapshots
303
+ WHERE project_name = ?
304
+ AND COALESCE(ended_at, started_at) < ?
305
+ `, [projectName, cutoffDate]);
306
+
307
+ // Delete excess
308
+ db.run(`
309
+ DELETE FROM snapshots
310
+ WHERE project_name = ?
311
+ AND id NOT IN (
312
+ SELECT id FROM snapshots
313
+ WHERE project_name = ?
314
+ ORDER BY COALESCE(ended_at, started_at) DESC
315
+ LIMIT ?
316
+ )
317
+ `, [projectName, projectName, maxCount]);
318
+
319
+ // Count after
320
+ const afterResult = db.exec(
321
+ 'SELECT COUNT(*) FROM snapshots WHERE project_name = ?',
322
+ [projectName]
323
+ );
324
+ const after = afterResult.length > 0 ? afterResult[0].values[0][0] : 0;
325
+
326
+ this._persist(projectName);
327
+ return { deleted: before - after };
328
+ }
329
+
330
+ /**
331
+ * Convert db.exec result row to session object.
332
+ * @param {string[]} columns
333
+ * @param {any[]} values
334
+ * @returns {object}
335
+ */
336
+ _resultToSession(columns, values) {
337
+ const row = {};
338
+ columns.forEach((col, i) => { row[col] = values[i]; });
339
+
340
+ return {
341
+ id: row.id,
342
+ agentName: row.agent_name,
343
+ agentType: row.agent_type,
344
+ model: row.model,
345
+ startedAt: row.started_at,
346
+ endedAt: row.ended_at,
347
+ turnCount: row.turn_count,
348
+ tokenEstimate: row.token_estimate,
349
+ keys: JSON.parse(row.keys || '[]'),
350
+ context: JSON.parse(row.context || '{}'),
351
+ parentSessionId: row.parent_session_id,
352
+ projectName: row.project_name,
353
+ };
354
+ }
355
+
356
+ /**
357
+ * Close all connections and persist.
358
+ */
359
+ dispose() {
360
+ for (const [name, entry] of Object.entries(this._dbs)) {
361
+ this._persist(name);
362
+ try { entry.db.close(); } catch (e) { /* ignore */ }
363
+ }
364
+ this._dbs = {};
365
+ }
366
+
367
+ /**
368
+ * Migrate JSON snapshots to SQLite.
369
+ * @param {string} jsonBaseDir
370
+ * @param {string} projectName
371
+ * @returns {{ migrated: number, errors: number }}
372
+ */
373
+ migrateFromJson(jsonBaseDir, projectName) {
374
+ const projectDir = path.join(jsonBaseDir, projectName);
375
+ if (!fs.existsSync(projectDir)) return { migrated: 0, errors: 0 };
376
+
377
+ let migrated = 0;
378
+ let errors = 0;
379
+
380
+ const dateDirs = fs.readdirSync(projectDir, { withFileTypes: true })
381
+ .filter(d => d.isDirectory())
382
+ .map(d => path.join(projectDir, d.name));
383
+
384
+ for (const dateDir of dateDirs) {
385
+ const files = fs.readdirSync(dateDir).filter(f => f.endsWith('.json'));
386
+ for (const file of files) {
387
+ try {
388
+ const raw = fs.readFileSync(path.join(dateDir, file), 'utf-8');
389
+ const session = JSON.parse(raw);
390
+ this.save(session);
391
+ migrated++;
392
+ } catch (e) {
393
+ errors++;
394
+ }
395
+ }
396
+ }
397
+
398
+ return { migrated, errors };
399
+ }
400
+ }
401
+
402
+ module.exports = { SqliteStore, initSqlJs };