openclaw-node-harness 2.0.4 → 2.1.1
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/README.md +646 -3
- package/bin/hyperagent.mjs +419 -0
- package/bin/lane-watchdog.js +23 -2
- package/bin/mesh-agent.js +439 -28
- package/bin/mesh-bridge.js +69 -3
- package/bin/mesh-health-publisher.js +41 -1
- package/bin/mesh-task-daemon.js +821 -26
- package/bin/mesh.js +411 -20
- package/config/claude-settings.json +95 -0
- package/config/daemon.json.template +2 -1
- package/config/git-hooks/pre-commit +13 -0
- package/config/git-hooks/pre-push +12 -0
- package/config/harness-rules.json +174 -0
- package/config/plan-templates/team-bugfix.yaml +52 -0
- package/config/plan-templates/team-deploy.yaml +50 -0
- package/config/plan-templates/team-feature.yaml +71 -0
- package/config/roles/qa-engineer.yaml +36 -0
- package/config/roles/solidity-dev.yaml +51 -0
- package/config/roles/tech-architect.yaml +36 -0
- package/config/rules/framework/solidity.md +22 -0
- package/config/rules/framework/typescript.md +21 -0
- package/config/rules/framework/unity.md +21 -0
- package/config/rules/universal/design-docs.md +18 -0
- package/config/rules/universal/git-hygiene.md +18 -0
- package/config/rules/universal/security.md +19 -0
- package/config/rules/universal/test-standards.md +19 -0
- package/identity/DELEGATION.md +6 -6
- package/install.sh +296 -10
- package/lib/agent-activity.js +2 -2
- package/lib/circling-parser.js +119 -0
- package/lib/exec-safety.js +105 -0
- package/lib/hyperagent-store.mjs +652 -0
- package/lib/kanban-io.js +24 -31
- package/lib/llm-providers.js +16 -0
- package/lib/mcp-knowledge/bench.mjs +118 -0
- package/lib/mcp-knowledge/core.mjs +530 -0
- package/lib/mcp-knowledge/package.json +25 -0
- package/lib/mcp-knowledge/server.mjs +252 -0
- package/lib/mcp-knowledge/test.mjs +802 -0
- package/lib/memory-budget.mjs +261 -0
- package/lib/mesh-collab.js +483 -165
- package/lib/mesh-harness.js +427 -0
- package/lib/mesh-plans.js +79 -50
- package/lib/mesh-tasks.js +132 -49
- package/lib/nats-resolve.js +4 -4
- package/lib/plan-templates.js +226 -0
- package/lib/pre-compression-flush.mjs +322 -0
- package/lib/role-loader.js +292 -0
- package/lib/rule-loader.js +358 -0
- package/lib/session-store.mjs +461 -0
- package/lib/transcript-parser.mjs +292 -0
- package/mission-control/drizzle/soul_schema_update.sql +29 -0
- package/mission-control/drizzle.config.ts +1 -4
- package/mission-control/package-lock.json +1571 -83
- package/mission-control/package.json +6 -2
- package/mission-control/scripts/gen-chronology.js +3 -3
- package/mission-control/scripts/import-pipeline-v2.js +0 -16
- package/mission-control/scripts/import-pipeline.js +0 -15
- package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
- package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
- package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
- package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
- package/mission-control/src/app/api/cowork/events/route.ts +65 -0
- package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
- package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
- package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
- package/mission-control/src/app/api/diagnostics/route.ts +97 -0
- package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
- package/mission-control/src/app/api/memory/search/route.ts +6 -3
- package/mission-control/src/app/api/mesh/events/route.ts +95 -19
- package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
- package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
- package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
- package/mission-control/src/app/api/souls/[id]/evolution/route.ts +21 -5
- package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
- package/mission-control/src/app/api/souls/[id]/propagate/route.ts +14 -2
- package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +8 -2
- package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
- package/mission-control/src/app/api/tasks/route.ts +21 -30
- package/mission-control/src/app/api/workspace/read/route.ts +11 -0
- package/mission-control/src/app/cowork/page.tsx +261 -0
- package/mission-control/src/app/diagnostics/page.tsx +385 -0
- package/mission-control/src/app/graph/page.tsx +26 -0
- package/mission-control/src/app/memory/page.tsx +1 -1
- package/mission-control/src/app/obsidian/page.tsx +36 -6
- package/mission-control/src/app/roadmap/page.tsx +24 -0
- package/mission-control/src/app/souls/page.tsx +2 -2
- package/mission-control/src/components/board/execution-config.tsx +431 -0
- package/mission-control/src/components/board/kanban-board.tsx +75 -9
- package/mission-control/src/components/board/kanban-column.tsx +135 -19
- package/mission-control/src/components/board/task-card.tsx +55 -2
- package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
- package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
- package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
- package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
- package/mission-control/src/components/cowork/role-picker.tsx +102 -0
- package/mission-control/src/components/cowork/session-card.tsx +284 -0
- package/mission-control/src/components/layout/sidebar.tsx +39 -2
- package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
- package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
- package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
- package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
- package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
- package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
- package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
- package/mission-control/src/lib/config.ts +67 -0
- package/mission-control/src/lib/db/index.ts +85 -1
- package/mission-control/src/lib/db/schema.ts +61 -3
- package/mission-control/src/lib/hooks.ts +309 -0
- package/mission-control/src/lib/memory/entities.ts +3 -2
- package/mission-control/src/lib/memory/extract.ts +2 -1
- package/mission-control/src/lib/memory/retrieval.ts +3 -2
- package/mission-control/src/lib/nats.ts +66 -1
- package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
- package/mission-control/src/lib/parsers/transcript.ts +4 -4
- package/mission-control/src/lib/scheduler.ts +12 -11
- package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
- package/mission-control/src/lib/sync/tasks.ts +23 -1
- package/mission-control/src/lib/task-id.ts +32 -0
- package/mission-control/src/lib/tts/index.ts +33 -9
- package/mission-control/src/middleware.ts +82 -0
- package/mission-control/tsconfig.json +2 -1
- package/mission-control/vitest.config.ts +14 -0
- package/package.json +15 -2
- package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
- package/services/launchd/ai.openclaw.mesh-deploy-listener.plist +4 -0
- package/services/launchd/ai.openclaw.mesh-health-publisher.plist +4 -0
- package/services/launchd/ai.openclaw.mission-control.plist +1 -1
- package/services/service-manifest.json +1 -1
- package/skills/cc-godmode/references/agents.md +8 -8
- package/uninstall.sh +37 -9
- package/workspace-bin/memory-daemon.mjs +199 -5
- package/workspace-bin/session-search.mjs +204 -0
- package/workspace-bin/web-fetch.mjs +65 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-store.mjs — SQLite Session Archive with FTS5
|
|
3
|
+
*
|
|
4
|
+
* Standalone episodic recall database for OpenClaw.
|
|
5
|
+
* Replaces grepping daily markdown files with ranked, structured search.
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* - SQLite with WAL mode (concurrent read/write)
|
|
9
|
+
* - FTS5 on messages.content for full-text search
|
|
10
|
+
* - Session-grouped results ranked by (match_count × recency_weight)
|
|
11
|
+
* - Context windows around matches with merged overlapping ranges
|
|
12
|
+
*
|
|
13
|
+
* Database location: ~/.openclaw/state.db
|
|
14
|
+
* External dependency: better-sqlite3
|
|
15
|
+
*
|
|
16
|
+
* Tables:
|
|
17
|
+
* - sessions: id, source, start_time, end_time, summary, message_count
|
|
18
|
+
* - messages: session_id, role, content, timestamp, turn_index
|
|
19
|
+
* - messages_fts: FTS5 virtual table for full-text search
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import Database from 'better-sqlite3';
|
|
23
|
+
import fs from 'fs';
|
|
24
|
+
import path from 'path';
|
|
25
|
+
import os from 'os';
|
|
26
|
+
import { parseJsonlFile, detectFormat } from './transcript-parser.mjs';
|
|
27
|
+
|
|
28
|
+
const DEFAULT_DB_PATH = path.join(os.homedir(), '.openclaw/state.db');
|
|
29
|
+
|
|
30
|
+
export class SessionStore {
|
|
31
|
+
#db;
|
|
32
|
+
#dbPath;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {Object} opts
|
|
36
|
+
* @param {string} opts.dbPath - Database path (default: ~/.openclaw/state.db)
|
|
37
|
+
*/
|
|
38
|
+
constructor(opts = {}) {
|
|
39
|
+
this.#dbPath = opts.dbPath || DEFAULT_DB_PATH;
|
|
40
|
+
|
|
41
|
+
// Ensure parent directory exists
|
|
42
|
+
const dir = path.dirname(this.#dbPath);
|
|
43
|
+
if (!fs.existsSync(dir)) {
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
this.#db = new Database(this.#dbPath);
|
|
48
|
+
this.#db.pragma('journal_mode = WAL');
|
|
49
|
+
this.#db.pragma('foreign_keys = ON');
|
|
50
|
+
|
|
51
|
+
this.#runMigrations();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get dbPath() { return this.#dbPath; }
|
|
55
|
+
|
|
56
|
+
// ── Schema ────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
#runMigrations() {
|
|
59
|
+
this.#db.exec(`
|
|
60
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
61
|
+
id TEXT PRIMARY KEY,
|
|
62
|
+
source TEXT NOT NULL, -- source identifier: 'gateway', 'discord', 'claude-code', etc.
|
|
63
|
+
start_time TEXT NOT NULL, -- ISO 8601
|
|
64
|
+
end_time TEXT, -- ISO 8601 (null = still active)
|
|
65
|
+
summary TEXT, -- optional one-line summary
|
|
66
|
+
message_count INTEGER DEFAULT 0,
|
|
67
|
+
parent_session_id TEXT, -- optional link to parent session
|
|
68
|
+
metadata TEXT, -- JSON blob for extra data
|
|
69
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
73
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
74
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
75
|
+
role TEXT NOT NULL, -- 'user', 'assistant', 'system'
|
|
76
|
+
content TEXT NOT NULL,
|
|
77
|
+
timestamp TEXT, -- ISO 8601
|
|
78
|
+
turn_index INTEGER NOT NULL, -- 0-based position in conversation
|
|
79
|
+
metadata TEXT -- JSON: tool_calls, token_count, etc.
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp);
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source);
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_start ON sessions(start_time);
|
|
87
|
+
`);
|
|
88
|
+
|
|
89
|
+
// FTS5 virtual table for full-text search on message content
|
|
90
|
+
this.#db.exec(`
|
|
91
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
92
|
+
content,
|
|
93
|
+
role,
|
|
94
|
+
session_id UNINDEXED,
|
|
95
|
+
content='messages',
|
|
96
|
+
content_rowid='id'
|
|
97
|
+
);
|
|
98
|
+
`);
|
|
99
|
+
|
|
100
|
+
// FTS sync triggers
|
|
101
|
+
const triggerExists = this.#db
|
|
102
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='trigger' AND name='messages_ai'")
|
|
103
|
+
.get();
|
|
104
|
+
|
|
105
|
+
if (!triggerExists) {
|
|
106
|
+
this.#db.exec(`
|
|
107
|
+
CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN
|
|
108
|
+
INSERT INTO messages_fts(rowid, content, role, session_id)
|
|
109
|
+
VALUES (new.id, new.content, new.role, new.session_id);
|
|
110
|
+
END;
|
|
111
|
+
|
|
112
|
+
CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN
|
|
113
|
+
INSERT INTO messages_fts(messages_fts, rowid, content, role, session_id)
|
|
114
|
+
VALUES ('delete', old.id, old.content, old.role, old.session_id);
|
|
115
|
+
END;
|
|
116
|
+
|
|
117
|
+
CREATE TRIGGER messages_au AFTER UPDATE ON messages BEGIN
|
|
118
|
+
INSERT INTO messages_fts(messages_fts, rowid, content, role, session_id)
|
|
119
|
+
VALUES ('delete', old.id, old.content, old.role, old.session_id);
|
|
120
|
+
INSERT INTO messages_fts(rowid, content, role, session_id)
|
|
121
|
+
VALUES (new.id, new.content, new.role, new.session_id);
|
|
122
|
+
END;
|
|
123
|
+
`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Import ────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Import a session from any JSONL transcript file.
|
|
131
|
+
* Format-agnostic — auto-detects any registered transcript format.
|
|
132
|
+
* Wraps bulk inserts in a transaction for atomicity.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} jsonlPath - Path to the JSONL session file
|
|
135
|
+
* @param {Object} opts
|
|
136
|
+
* @param {string} opts.source - Source identifier (e.g. 'gateway', 'claude-code'). Default: 'unknown'
|
|
137
|
+
* @param {string} opts.format - Transcript format (auto-detected if omitted)
|
|
138
|
+
* @param {boolean} opts.skipIfExists - Skip if session already imported (default: true)
|
|
139
|
+
* @returns {Promise<{ sessionId: string, messageCount: number, imported: boolean }>}
|
|
140
|
+
*/
|
|
141
|
+
async importSession(jsonlPath, opts = {}) {
|
|
142
|
+
const { source = 'unknown', format, skipIfExists = true } = opts;
|
|
143
|
+
const sessionId = path.basename(jsonlPath, '.jsonl');
|
|
144
|
+
|
|
145
|
+
// Check if already imported
|
|
146
|
+
if (skipIfExists) {
|
|
147
|
+
const existing = this.#db.prepare('SELECT id FROM sessions WHERE id = ?').get(sessionId);
|
|
148
|
+
if (existing) {
|
|
149
|
+
return { sessionId, messageCount: 0, imported: false };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Parse JSONL using format-agnostic transcript parser
|
|
154
|
+
const parsed = await parseJsonlFile(jsonlPath, { format });
|
|
155
|
+
|
|
156
|
+
if (parsed.length === 0) {
|
|
157
|
+
return { sessionId, messageCount: 0, imported: false };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Build message list with turn indices
|
|
161
|
+
const messages = parsed.map((msg, i) => ({
|
|
162
|
+
role: msg.role,
|
|
163
|
+
content: msg.content,
|
|
164
|
+
timestamp: msg.timestamp || null,
|
|
165
|
+
turnIndex: i,
|
|
166
|
+
}));
|
|
167
|
+
|
|
168
|
+
const startTime = messages[0].timestamp || new Date().toISOString();
|
|
169
|
+
const endTime = messages[messages.length - 1].timestamp;
|
|
170
|
+
|
|
171
|
+
// Bulk insert in transaction
|
|
172
|
+
const insertSession = this.#db.prepare(`
|
|
173
|
+
INSERT OR REPLACE INTO sessions (id, source, start_time, end_time, message_count)
|
|
174
|
+
VALUES (?, ?, ?, ?, ?)
|
|
175
|
+
`);
|
|
176
|
+
|
|
177
|
+
const insertMessage = this.#db.prepare(`
|
|
178
|
+
INSERT INTO messages (session_id, role, content, timestamp, turn_index)
|
|
179
|
+
VALUES (?, ?, ?, ?, ?)
|
|
180
|
+
`);
|
|
181
|
+
|
|
182
|
+
const transaction = this.#db.transaction(() => {
|
|
183
|
+
insertSession.run(sessionId, source, startTime || new Date().toISOString(), endTime, messages.length);
|
|
184
|
+
for (const msg of messages) {
|
|
185
|
+
insertMessage.run(sessionId, msg.role, msg.content, msg.timestamp, msg.turnIndex);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
transaction();
|
|
190
|
+
|
|
191
|
+
return { sessionId, messageCount: messages.length, imported: true };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Import multiple sessions from a directory of JSONL files.
|
|
196
|
+
* @param {string} dirPath - Directory containing .jsonl files
|
|
197
|
+
* @param {Object} opts - Options forwarded to importSession
|
|
198
|
+
* @param {string} opts.source - Source identifier (e.g. 'gateway', 'claude-code')
|
|
199
|
+
* @param {string} opts.format - Transcript format (auto-detected if omitted)
|
|
200
|
+
* @returns {Promise<{ imported: number, skipped: number, total: number }>}
|
|
201
|
+
*/
|
|
202
|
+
async importDirectory(dirPath, opts = {}) {
|
|
203
|
+
if (!fs.existsSync(dirPath)) return { imported: 0, skipped: 0, total: 0 };
|
|
204
|
+
|
|
205
|
+
const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.jsonl'));
|
|
206
|
+
let imported = 0, skipped = 0;
|
|
207
|
+
|
|
208
|
+
for (const f of files) {
|
|
209
|
+
const result = await this.importSession(path.join(dirPath, f), opts);
|
|
210
|
+
if (result.imported) imported++;
|
|
211
|
+
else skipped++;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { imported, skipped, total: files.length };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Search ────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Search session transcripts using FTS5.
|
|
221
|
+
*
|
|
222
|
+
* Returns results grouped by session, ranked by:
|
|
223
|
+
* score = match_count × recency_weight
|
|
224
|
+
*
|
|
225
|
+
* Each result includes context windows around matches.
|
|
226
|
+
*
|
|
227
|
+
* @param {string} query - Search query (supports FTS5 syntax)
|
|
228
|
+
* @param {Object} opts
|
|
229
|
+
* @param {number} opts.limit - Max sessions to return (default: 10)
|
|
230
|
+
* @param {string} opts.role - Filter by role: 'user', 'assistant', or null (default: null)
|
|
231
|
+
* @param {number} opts.contextTurns - Number of surrounding turns for context (default: 2)
|
|
232
|
+
* @param {number} opts.recencyDays - Recency decay period in days (default: 30)
|
|
233
|
+
* @returns {Array<{ sessionId, source, startTime, matchCount, score, excerpts }>}
|
|
234
|
+
*/
|
|
235
|
+
search(query, opts = {}) {
|
|
236
|
+
const { limit = 10, role = null, contextTurns = 2, recencyDays = 30 } = opts;
|
|
237
|
+
|
|
238
|
+
// Build FTS5 query
|
|
239
|
+
const ftsQuery = this.#buildFtsQuery(query);
|
|
240
|
+
if (!ftsQuery) return [];
|
|
241
|
+
|
|
242
|
+
// Search with FTS5
|
|
243
|
+
let sql = `
|
|
244
|
+
SELECT
|
|
245
|
+
m.id,
|
|
246
|
+
m.session_id,
|
|
247
|
+
m.role,
|
|
248
|
+
m.content,
|
|
249
|
+
m.turn_index,
|
|
250
|
+
m.timestamp,
|
|
251
|
+
rank
|
|
252
|
+
FROM messages_fts
|
|
253
|
+
JOIN messages m ON messages_fts.rowid = m.id
|
|
254
|
+
WHERE messages_fts MATCH ?
|
|
255
|
+
`;
|
|
256
|
+
const params = [ftsQuery];
|
|
257
|
+
|
|
258
|
+
if (role) {
|
|
259
|
+
sql += ' AND m.role = ?';
|
|
260
|
+
params.push(role);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
sql += ' ORDER BY rank LIMIT 200'; // cap raw matches
|
|
264
|
+
|
|
265
|
+
const matches = this.#db.prepare(sql).all(...params);
|
|
266
|
+
|
|
267
|
+
if (matches.length === 0) return [];
|
|
268
|
+
|
|
269
|
+
// Group by session
|
|
270
|
+
const bySession = new Map();
|
|
271
|
+
for (const match of matches) {
|
|
272
|
+
if (!bySession.has(match.session_id)) {
|
|
273
|
+
bySession.set(match.session_id, []);
|
|
274
|
+
}
|
|
275
|
+
bySession.get(match.session_id).push(match);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Score and rank sessions
|
|
279
|
+
const now = Date.now();
|
|
280
|
+
const results = [];
|
|
281
|
+
|
|
282
|
+
for (const [sessionId, sessionMatches] of bySession) {
|
|
283
|
+
const session = this.#db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId);
|
|
284
|
+
if (!session) continue;
|
|
285
|
+
|
|
286
|
+
const matchCount = sessionMatches.length;
|
|
287
|
+
|
|
288
|
+
// Recency weight: exp(-daysOld / recencyDays)
|
|
289
|
+
const sessionDate = session.start_time ? new Date(session.start_time).getTime() : 0;
|
|
290
|
+
const daysOld = (now - sessionDate) / (1000 * 60 * 60 * 24);
|
|
291
|
+
const recencyWeight = Math.exp(-daysOld / recencyDays);
|
|
292
|
+
|
|
293
|
+
const score = matchCount * recencyWeight;
|
|
294
|
+
|
|
295
|
+
// Build context excerpts
|
|
296
|
+
const turnIndices = sessionMatches.map(m => m.turn_index);
|
|
297
|
+
const contextRanges = this.#mergeTurnRanges(turnIndices, contextTurns);
|
|
298
|
+
|
|
299
|
+
const excerpts = [];
|
|
300
|
+
for (const [start, end] of contextRanges) {
|
|
301
|
+
const turns = this.#db.prepare(`
|
|
302
|
+
SELECT role, content, turn_index, timestamp
|
|
303
|
+
FROM messages
|
|
304
|
+
WHERE session_id = ? AND turn_index BETWEEN ? AND ?
|
|
305
|
+
ORDER BY turn_index
|
|
306
|
+
`).all(sessionId, start, end);
|
|
307
|
+
|
|
308
|
+
const matchIndices = new Set(turnIndices.filter(i => i >= start && i <= end));
|
|
309
|
+
|
|
310
|
+
excerpts.push({
|
|
311
|
+
turns: turns.map(t => ({
|
|
312
|
+
role: t.role,
|
|
313
|
+
content: t.content.slice(0, 300), // truncate for context
|
|
314
|
+
turnIndex: t.turn_index,
|
|
315
|
+
isMatch: matchIndices.has(t.turn_index),
|
|
316
|
+
})),
|
|
317
|
+
startTurn: start,
|
|
318
|
+
endTurn: end,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
results.push({
|
|
323
|
+
sessionId,
|
|
324
|
+
source: session.source,
|
|
325
|
+
startTime: session.start_time,
|
|
326
|
+
matchCount,
|
|
327
|
+
score: Math.round(score * 1000) / 1000,
|
|
328
|
+
excerpts,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Sort by score descending
|
|
333
|
+
results.sort((a, b) => b.score - a.score);
|
|
334
|
+
return results.slice(0, limit);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── Session Management ────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Update a session's summary.
|
|
341
|
+
*/
|
|
342
|
+
updateSummary(sessionId, summary) {
|
|
343
|
+
this.#db.prepare('UPDATE sessions SET summary = ? WHERE id = ?').run(summary, sessionId);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Get session by ID with message count.
|
|
348
|
+
*/
|
|
349
|
+
getSession(sessionId) {
|
|
350
|
+
return this.#db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* List recent sessions.
|
|
355
|
+
*/
|
|
356
|
+
listSessions(opts = {}) {
|
|
357
|
+
const { limit = 20, source = null } = opts;
|
|
358
|
+
let sql = 'SELECT * FROM sessions';
|
|
359
|
+
const params = [];
|
|
360
|
+
if (source) {
|
|
361
|
+
sql += ' WHERE source = ?';
|
|
362
|
+
params.push(source);
|
|
363
|
+
}
|
|
364
|
+
sql += ' ORDER BY start_time DESC LIMIT ?';
|
|
365
|
+
params.push(limit);
|
|
366
|
+
return this.#db.prepare(sql).all(...params);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Get database stats.
|
|
371
|
+
*/
|
|
372
|
+
getStats() {
|
|
373
|
+
const sessions = this.#db.prepare('SELECT COUNT(*) as count FROM sessions').get();
|
|
374
|
+
const messages = this.#db.prepare('SELECT COUNT(*) as count FROM messages').get();
|
|
375
|
+
const dbSize = fs.statSync(this.#dbPath).size;
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
sessionCount: sessions.count,
|
|
379
|
+
messageCount: messages.count,
|
|
380
|
+
dbSizeBytes: dbSize,
|
|
381
|
+
dbSizeMb: Math.round(dbSize / 1024 / 1024 * 100) / 100,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Close the database connection.
|
|
387
|
+
*/
|
|
388
|
+
close() {
|
|
389
|
+
this.#db.close();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ── Private Helpers ────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Build an FTS5 query from a natural language search string.
|
|
396
|
+
* - Single word → "word"* (prefix match)
|
|
397
|
+
* - Multi-word → phrase + individual terms
|
|
398
|
+
* - Escapes quotes
|
|
399
|
+
*/
|
|
400
|
+
#buildFtsQuery(query) {
|
|
401
|
+
if (!query || !query.trim()) return null;
|
|
402
|
+
// Escape double quotes, then strip FTS5 operators to prevent query injection
|
|
403
|
+
const escaped = query.replace(/"/g, '""').trim();
|
|
404
|
+
const safe = escaped.replace(/[*(){}^]/g, '');
|
|
405
|
+
if (!safe) return null;
|
|
406
|
+
|
|
407
|
+
const words = safe.split(/\s+/).filter(w => w.length > 0);
|
|
408
|
+
if (words.length === 0) return null;
|
|
409
|
+
|
|
410
|
+
if (words.length === 1) {
|
|
411
|
+
return `"${words[0]}"*`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Multi-word: phrase match OR individual prefix matches
|
|
415
|
+
const phrase = `"${words.join(' ')}"`;
|
|
416
|
+
const prefixes = words.map(w => `"${w}"*`).join(' OR ');
|
|
417
|
+
return `(${phrase}) OR (${prefixes})`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Merge overlapping turn ranges to prevent duplicate excerpts.
|
|
422
|
+
*
|
|
423
|
+
* Given match turn indices [3, 5, 12, 14] with contextTurns=2:
|
|
424
|
+
* → ranges before merge: [1-5], [3-7], [10-14], [12-16]
|
|
425
|
+
* → after merge: [1-7], [10-16]
|
|
426
|
+
*
|
|
427
|
+
* @param {number[]} turnIndices - Array of matching turn indices
|
|
428
|
+
* @param {number} context - Number of context turns on each side
|
|
429
|
+
* @returns {Array<[number, number]>} Merged [start, end] ranges
|
|
430
|
+
*/
|
|
431
|
+
#mergeTurnRanges(turnIndices, context) {
|
|
432
|
+
if (turnIndices.length === 0) return [];
|
|
433
|
+
|
|
434
|
+
// Build ranges
|
|
435
|
+
const ranges = turnIndices
|
|
436
|
+
.map(i => [Math.max(0, i - context), i + context])
|
|
437
|
+
.sort((a, b) => a[0] - b[0]);
|
|
438
|
+
|
|
439
|
+
// Merge overlapping
|
|
440
|
+
const merged = [ranges[0]];
|
|
441
|
+
for (let i = 1; i < ranges.length; i++) {
|
|
442
|
+
const prev = merged[merged.length - 1];
|
|
443
|
+
const curr = ranges[i];
|
|
444
|
+
if (curr[0] <= prev[1] + 1) {
|
|
445
|
+
prev[1] = Math.max(prev[1], curr[1]);
|
|
446
|
+
} else {
|
|
447
|
+
merged.push(curr);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return merged;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Create a SessionStore instance with default path.
|
|
457
|
+
* @param {Object} opts - Options forwarded to SessionStore
|
|
458
|
+
*/
|
|
459
|
+
export function createSessionStore(opts = {}) {
|
|
460
|
+
return new SessionStore(opts);
|
|
461
|
+
}
|