openclaw-node-harness 2.0.4 → 2.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.
Files changed (115) hide show
  1. package/README.md +646 -3
  2. package/bin/hyperagent.mjs +419 -0
  3. package/bin/mesh-agent.js +401 -12
  4. package/bin/mesh-bridge.js +66 -1
  5. package/bin/mesh-task-daemon.js +816 -26
  6. package/bin/mesh.js +403 -1
  7. package/config/claude-settings.json +95 -0
  8. package/config/daemon.json.template +2 -1
  9. package/config/git-hooks/pre-commit +13 -0
  10. package/config/git-hooks/pre-push +12 -0
  11. package/config/harness-rules.json +174 -0
  12. package/config/plan-templates/team-bugfix.yaml +52 -0
  13. package/config/plan-templates/team-deploy.yaml +50 -0
  14. package/config/plan-templates/team-feature.yaml +71 -0
  15. package/config/roles/qa-engineer.yaml +36 -0
  16. package/config/roles/solidity-dev.yaml +51 -0
  17. package/config/roles/tech-architect.yaml +36 -0
  18. package/config/rules/framework/solidity.md +22 -0
  19. package/config/rules/framework/typescript.md +21 -0
  20. package/config/rules/framework/unity.md +21 -0
  21. package/config/rules/universal/design-docs.md +18 -0
  22. package/config/rules/universal/git-hygiene.md +18 -0
  23. package/config/rules/universal/security.md +19 -0
  24. package/config/rules/universal/test-standards.md +19 -0
  25. package/identity/DELEGATION.md +6 -6
  26. package/install.sh +293 -8
  27. package/lib/circling-parser.js +119 -0
  28. package/lib/hyperagent-store.mjs +652 -0
  29. package/lib/kanban-io.js +9 -0
  30. package/lib/mcp-knowledge/bench.mjs +118 -0
  31. package/lib/mcp-knowledge/core.mjs +528 -0
  32. package/lib/mcp-knowledge/package.json +25 -0
  33. package/lib/mcp-knowledge/server.mjs +245 -0
  34. package/lib/mcp-knowledge/test.mjs +802 -0
  35. package/lib/memory-budget.mjs +261 -0
  36. package/lib/mesh-collab.js +301 -1
  37. package/lib/mesh-harness.js +427 -0
  38. package/lib/mesh-plans.js +13 -5
  39. package/lib/mesh-tasks.js +67 -0
  40. package/lib/plan-templates.js +226 -0
  41. package/lib/pre-compression-flush.mjs +320 -0
  42. package/lib/role-loader.js +292 -0
  43. package/lib/rule-loader.js +358 -0
  44. package/lib/session-store.mjs +458 -0
  45. package/lib/transcript-parser.mjs +292 -0
  46. package/mission-control/drizzle/soul_schema_update.sql +29 -0
  47. package/mission-control/drizzle.config.ts +1 -4
  48. package/mission-control/package-lock.json +1571 -83
  49. package/mission-control/package.json +6 -2
  50. package/mission-control/scripts/gen-chronology.js +3 -3
  51. package/mission-control/scripts/import-pipeline-v2.js +0 -16
  52. package/mission-control/scripts/import-pipeline.js +0 -15
  53. package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
  54. package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
  55. package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
  56. package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
  57. package/mission-control/src/app/api/cowork/events/route.ts +65 -0
  58. package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
  59. package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
  60. package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
  61. package/mission-control/src/app/api/diagnostics/route.ts +97 -0
  62. package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
  63. package/mission-control/src/app/api/mesh/events/route.ts +95 -19
  64. package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
  65. package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
  66. package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
  67. package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +1 -1
  68. package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
  69. package/mission-control/src/app/api/tasks/route.ts +21 -30
  70. package/mission-control/src/app/cowork/page.tsx +261 -0
  71. package/mission-control/src/app/diagnostics/page.tsx +385 -0
  72. package/mission-control/src/app/graph/page.tsx +26 -0
  73. package/mission-control/src/app/memory/page.tsx +1 -1
  74. package/mission-control/src/app/obsidian/page.tsx +36 -6
  75. package/mission-control/src/app/roadmap/page.tsx +24 -0
  76. package/mission-control/src/app/souls/page.tsx +2 -2
  77. package/mission-control/src/components/board/execution-config.tsx +431 -0
  78. package/mission-control/src/components/board/kanban-board.tsx +75 -9
  79. package/mission-control/src/components/board/kanban-column.tsx +135 -19
  80. package/mission-control/src/components/board/task-card.tsx +55 -2
  81. package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
  82. package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
  83. package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
  84. package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
  85. package/mission-control/src/components/cowork/role-picker.tsx +102 -0
  86. package/mission-control/src/components/cowork/session-card.tsx +284 -0
  87. package/mission-control/src/components/layout/sidebar.tsx +39 -2
  88. package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
  89. package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
  90. package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
  91. package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
  92. package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
  93. package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
  94. package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
  95. package/mission-control/src/lib/config.ts +58 -0
  96. package/mission-control/src/lib/db/index.ts +69 -0
  97. package/mission-control/src/lib/db/schema.ts +61 -3
  98. package/mission-control/src/lib/hooks.ts +309 -0
  99. package/mission-control/src/lib/memory/entities.ts +3 -2
  100. package/mission-control/src/lib/nats.ts +66 -1
  101. package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
  102. package/mission-control/src/lib/parsers/transcript.ts +4 -4
  103. package/mission-control/src/lib/scheduler.ts +12 -11
  104. package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
  105. package/mission-control/src/lib/sync/tasks.ts +23 -1
  106. package/mission-control/src/lib/task-id.ts +32 -0
  107. package/mission-control/src/lib/tts/index.ts +33 -9
  108. package/mission-control/tsconfig.json +2 -1
  109. package/mission-control/vitest.config.ts +14 -0
  110. package/package.json +15 -2
  111. package/services/service-manifest.json +1 -1
  112. package/skills/cc-godmode/references/agents.md +8 -8
  113. package/workspace-bin/memory-daemon.mjs +199 -5
  114. package/workspace-bin/session-search.mjs +204 -0
  115. package/workspace-bin/web-fetch.mjs +65 -0
@@ -0,0 +1,458 @@
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
+ const cleaned = query.replace(/"/g, '""').trim();
402
+ if (!cleaned) return null;
403
+
404
+ const words = cleaned.split(/\s+/).filter(w => w.length > 0);
405
+ if (words.length === 0) return null;
406
+
407
+ if (words.length === 1) {
408
+ return `"${words[0]}"*`;
409
+ }
410
+
411
+ // Multi-word: phrase match OR individual prefix matches
412
+ const phrase = `"${words.join(' ')}"`;
413
+ const prefixes = words.map(w => `"${w}"*`).join(' OR ');
414
+ return `(${phrase}) OR (${prefixes})`;
415
+ }
416
+
417
+ /**
418
+ * Merge overlapping turn ranges to prevent duplicate excerpts.
419
+ *
420
+ * Given match turn indices [3, 5, 12, 14] with contextTurns=2:
421
+ * → ranges before merge: [1-5], [3-7], [10-14], [12-16]
422
+ * → after merge: [1-7], [10-16]
423
+ *
424
+ * @param {number[]} turnIndices - Array of matching turn indices
425
+ * @param {number} context - Number of context turns on each side
426
+ * @returns {Array<[number, number]>} Merged [start, end] ranges
427
+ */
428
+ #mergeTurnRanges(turnIndices, context) {
429
+ if (turnIndices.length === 0) return [];
430
+
431
+ // Build ranges
432
+ const ranges = turnIndices
433
+ .map(i => [Math.max(0, i - context), i + context])
434
+ .sort((a, b) => a[0] - b[0]);
435
+
436
+ // Merge overlapping
437
+ const merged = [ranges[0]];
438
+ for (let i = 1; i < ranges.length; i++) {
439
+ const prev = merged[merged.length - 1];
440
+ const curr = ranges[i];
441
+ if (curr[0] <= prev[1] + 1) {
442
+ prev[1] = Math.max(prev[1], curr[1]);
443
+ } else {
444
+ merged.push(curr);
445
+ }
446
+ }
447
+
448
+ return merged;
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Create a SessionStore instance with default path.
454
+ * @param {Object} opts - Options forwarded to SessionStore
455
+ */
456
+ export function createSessionStore(opts = {}) {
457
+ return new SessionStore(opts);
458
+ }