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.
- package/LICENSE +121 -0
- package/README.ko.md +197 -0
- package/README.md +197 -0
- package/index.js +30 -0
- package/lib/agent-registry.js +60 -0
- package/lib/config.default.js +68 -0
- package/lib/config.example.js +28 -0
- package/lib/config.js +28 -0
- package/lib/context.js +34 -0
- package/lib/intercom-log.js +187 -0
- package/lib/kv-cache/agent-adapter.js +192 -0
- package/lib/kv-cache/backup.js +357 -0
- package/lib/kv-cache/compressor.js +130 -0
- package/lib/kv-cache/embedding.js +205 -0
- package/lib/kv-cache/index.js +446 -0
- package/lib/kv-cache/schema.js +108 -0
- package/lib/kv-cache/snapshot.js +213 -0
- package/lib/kv-cache/sqlite-store.js +402 -0
- package/lib/kv-cache/tier-manager.js +239 -0
- package/lib/kv-cache/token-saver.js +153 -0
- package/lib/paths.js +20 -0
- package/lib/soul-engine.js +189 -0
- package/lib/utils.js +97 -0
- package/package.json +31 -0
- package/sequences/boot.js +81 -0
- package/sequences/end.js +132 -0
- package/sequences/work.js +257 -0
- package/tools/brain.js +45 -0
- package/tools/kv-cache.js +246 -0
|
@@ -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 };
|