@grec0/memory-bank-mcp 0.1.21 → 0.1.23

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,486 @@
1
+ /**
2
+ * @fileoverview SQLite-based Agent Board Implementation
3
+ * Replaces Markdown-based agentBoard with proper database operations.
4
+ * Provides project-centric task management and multi-agent coordination.
5
+ */
6
+ import { databaseManager } from './database.js';
7
+ import * as crypto from 'crypto';
8
+ // ============================================================================
9
+ // Agent Board SQLite Implementation
10
+ // ============================================================================
11
+ export class AgentBoardSqlite {
12
+ projectId;
13
+ constructor(projectId) {
14
+ this.projectId = projectId;
15
+ }
16
+ // ========================================================================
17
+ // Agent Management
18
+ // ========================================================================
19
+ /**
20
+ * Register a new agent for this project.
21
+ * Automatically deactivates any previous active agent.
22
+ * The MCP generates the hash suffix - client only provides base ID.
23
+ */
24
+ registerAgent(baseAgentId, sessionId) {
25
+ const db = databaseManager.getConnection();
26
+ // Generate unique agent ID with hash suffix
27
+ const suffix = crypto.randomUUID().slice(0, 8);
28
+ const fullAgentId = `${baseAgentId}-${suffix}`;
29
+ // Generate session ID if not provided
30
+ const effectiveSessionId = sessionId || crypto.randomUUID();
31
+ const now = new Date().toISOString();
32
+ return databaseManager.transaction(() => {
33
+ // Deactivate any currently active agents for this project
34
+ db.prepare(`
35
+ UPDATE agents
36
+ SET status = 'INACTIVE', last_heartbeat = ?
37
+ WHERE project_id = ? AND status = 'ACTIVE'
38
+ `).run(now, this.projectId);
39
+ // Insert new agent as ACTIVE
40
+ db.prepare(`
41
+ INSERT INTO agents (id, project_id, session_id, status, focus, last_heartbeat)
42
+ VALUES (?, ?, ?, 'ACTIVE', '-', ?)
43
+ `).run(fullAgentId, this.projectId, effectiveSessionId, now);
44
+ // Log the registration
45
+ this.logMessage(fullAgentId, `Agent registered and activated`);
46
+ return { agentId: fullAgentId, sessionId: effectiveSessionId };
47
+ });
48
+ }
49
+ /**
50
+ * Get the currently active agent for this project
51
+ */
52
+ getActiveAgent() {
53
+ const db = databaseManager.getConnection();
54
+ const row = db.prepare(`
55
+ SELECT id, project_id, session_id, status, focus, last_heartbeat, created_at
56
+ FROM agents
57
+ WHERE project_id = ? AND status = 'ACTIVE'
58
+ ORDER BY last_heartbeat DESC
59
+ LIMIT 1
60
+ `).get(this.projectId);
61
+ if (!row)
62
+ return null;
63
+ return {
64
+ id: row.id,
65
+ projectId: row.project_id,
66
+ sessionId: row.session_id,
67
+ status: row.status,
68
+ focus: row.focus,
69
+ lastHeartbeat: row.last_heartbeat,
70
+ createdAt: row.created_at
71
+ };
72
+ }
73
+ /**
74
+ * Get session ID for a specific agent
75
+ */
76
+ getSessionId(agentId) {
77
+ const db = databaseManager.getConnection();
78
+ const row = db.prepare(`
79
+ SELECT session_id FROM agents
80
+ WHERE id = ? AND project_id = ?
81
+ `).get(agentId, this.projectId);
82
+ return row?.session_id || null;
83
+ }
84
+ /**
85
+ * Resolve an agent ID - find the active agent matching a base ID prefix
86
+ */
87
+ resolveActiveAgentId(baseId) {
88
+ const db = databaseManager.getConnection();
89
+ // First try exact match
90
+ const exact = db.prepare(`
91
+ SELECT id FROM agents
92
+ WHERE id = ? AND project_id = ? AND status = 'ACTIVE'
93
+ `).get(baseId, this.projectId);
94
+ if (exact)
95
+ return exact.id;
96
+ // Try prefix match - find most recent active agent starting with baseId
97
+ const prefixMatch = db.prepare(`
98
+ SELECT id FROM agents
99
+ WHERE project_id = ? AND status = 'ACTIVE' AND id LIKE ?
100
+ ORDER BY last_heartbeat DESC
101
+ LIMIT 1
102
+ `).get(this.projectId, `${baseId}-%`);
103
+ return prefixMatch?.id || baseId;
104
+ }
105
+ /**
106
+ * Update agent status and focus
107
+ */
108
+ updateStatus(agentId, status, focus) {
109
+ const db = databaseManager.getConnection();
110
+ const now = new Date().toISOString();
111
+ // Resolve to actual agent ID if base ID provided
112
+ const resolvedId = this.resolveActiveAgentId(agentId);
113
+ db.prepare(`
114
+ UPDATE agents
115
+ SET status = ?, focus = ?, last_heartbeat = ?
116
+ WHERE id = ? AND project_id = ?
117
+ `).run(status, focus, now, resolvedId, this.projectId);
118
+ }
119
+ /**
120
+ * Update heartbeat for an agent
121
+ */
122
+ heartbeat(agentId) {
123
+ const db = databaseManager.getConnection();
124
+ const now = new Date().toISOString();
125
+ db.prepare(`
126
+ UPDATE agents
127
+ SET last_heartbeat = ?
128
+ WHERE id = ? AND project_id = ?
129
+ `).run(now, agentId, this.projectId);
130
+ }
131
+ /**
132
+ * Get all agents for this project (for session history view)
133
+ */
134
+ getAllAgents() {
135
+ const db = databaseManager.getConnection();
136
+ const rows = db.prepare(`
137
+ SELECT id, project_id, session_id, status, focus, last_heartbeat, created_at
138
+ FROM agents
139
+ WHERE project_id = ?
140
+ ORDER BY created_at DESC
141
+ `).all(this.projectId);
142
+ return rows.map(row => ({
143
+ id: row.id,
144
+ projectId: row.project_id,
145
+ sessionId: row.session_id,
146
+ status: row.status,
147
+ focus: row.focus,
148
+ lastHeartbeat: row.last_heartbeat,
149
+ createdAt: row.created_at
150
+ }));
151
+ }
152
+ // ========================================================================
153
+ // Task Management (Project-centric)
154
+ // ========================================================================
155
+ /**
156
+ * Create a task for this project
157
+ */
158
+ createTask(title, description, fromAgent) {
159
+ const db = databaseManager.getConnection();
160
+ const taskId = `TASK-${Date.now().toString().slice(-6)}`;
161
+ const now = new Date().toISOString();
162
+ db.prepare(`
163
+ INSERT INTO tasks (id, project_id, title, description, from_agent, status, created_at)
164
+ VALUES (?, ?, ?, ?, ?, 'PENDING', ?)
165
+ `).run(taskId, this.projectId, title, description || null, fromAgent || null, now);
166
+ this.logMessage(fromAgent || 'SYSTEM', `Created task ${taskId}: ${title}`);
167
+ return taskId;
168
+ }
169
+ /**
170
+ * Create an external task (cross-project delegation)
171
+ */
172
+ createExternalTask(title, fromProjectId, context) {
173
+ const db = databaseManager.getConnection();
174
+ const taskId = `EXT-${Date.now().toString().slice(-6)}`;
175
+ const now = new Date().toISOString();
176
+ db.prepare(`
177
+ INSERT INTO tasks (id, project_id, title, description, from_project, status, created_at)
178
+ VALUES (?, ?, ?, ?, ?, 'PENDING', ?)
179
+ `).run(taskId, this.projectId, title, context || null, fromProjectId, now);
180
+ this.logMessage('SYSTEM', `External task ${taskId} from ${fromProjectId}: ${title}`);
181
+ return taskId;
182
+ }
183
+ /**
184
+ * Get pending tasks for this project
185
+ */
186
+ getPendingTasks() {
187
+ const db = databaseManager.getConnection();
188
+ const rows = db.prepare(`
189
+ SELECT * FROM tasks
190
+ WHERE project_id = ? AND status IN ('PENDING', 'IN_PROGRESS')
191
+ ORDER BY created_at ASC
192
+ `).all(this.projectId);
193
+ return rows.map(this.mapTaskRow);
194
+ }
195
+ /**
196
+ * Claim a task (agent takes ownership)
197
+ */
198
+ claimTask(taskId, agentId) {
199
+ const db = databaseManager.getConnection();
200
+ const now = new Date().toISOString();
201
+ const result = db.prepare(`
202
+ UPDATE tasks
203
+ SET status = 'IN_PROGRESS', claimed_by = ?, claimed_at = ?
204
+ WHERE id = ? AND project_id = ? AND status = 'PENDING'
205
+ `).run(agentId, now, taskId, this.projectId);
206
+ if (result.changes > 0) {
207
+ this.logMessage(agentId, `Claimed task ${taskId}`);
208
+ return true;
209
+ }
210
+ return false;
211
+ }
212
+ /**
213
+ * Complete a task
214
+ */
215
+ completeTask(taskId, agentId) {
216
+ const db = databaseManager.getConnection();
217
+ const now = new Date().toISOString();
218
+ const result = db.prepare(`
219
+ UPDATE tasks
220
+ SET status = 'COMPLETED', completed_at = ?
221
+ WHERE id = ? AND project_id = ?
222
+ `).run(now, taskId, this.projectId);
223
+ if (result.changes > 0) {
224
+ this.logMessage(agentId, `Completed task ${taskId}`);
225
+ return true;
226
+ }
227
+ return false;
228
+ }
229
+ mapTaskRow(row) {
230
+ return {
231
+ id: row.id,
232
+ projectId: row.project_id,
233
+ title: row.title,
234
+ description: row.description,
235
+ fromProject: row.from_project,
236
+ fromAgent: row.from_agent,
237
+ status: row.status,
238
+ claimedBy: row.claimed_by,
239
+ createdAt: row.created_at,
240
+ claimedAt: row.claimed_at,
241
+ completedAt: row.completed_at
242
+ };
243
+ }
244
+ // ========================================================================
245
+ // Resource Locks
246
+ // ========================================================================
247
+ /**
248
+ * Claim a resource lock
249
+ */
250
+ claimResource(agentId, resource) {
251
+ const db = databaseManager.getConnection();
252
+ const now = new Date().toISOString();
253
+ try {
254
+ return databaseManager.transaction(() => {
255
+ // Check if already locked by someone else
256
+ const existing = db.prepare(`
257
+ SELECT agent_id FROM locks
258
+ WHERE resource = ? AND project_id = ?
259
+ `).get(resource, this.projectId);
260
+ if (existing && existing.agent_id !== agentId) {
261
+ return false; // Already locked by another agent
262
+ }
263
+ // Insert or update lock
264
+ db.prepare(`
265
+ INSERT OR REPLACE INTO locks (resource, project_id, agent_id, acquired_at)
266
+ VALUES (?, ?, ?, ?)
267
+ `).run(resource, this.projectId, agentId, now);
268
+ this.logMessage(agentId, `Claimed lock on ${resource}`);
269
+ return true;
270
+ });
271
+ }
272
+ catch {
273
+ return false;
274
+ }
275
+ }
276
+ /**
277
+ * Release a resource lock
278
+ */
279
+ releaseResource(agentId, resource) {
280
+ const db = databaseManager.getConnection();
281
+ db.prepare(`
282
+ DELETE FROM locks
283
+ WHERE resource = ? AND project_id = ? AND agent_id = ?
284
+ `).run(resource, this.projectId, agentId);
285
+ this.logMessage(agentId, `Released lock on ${resource}`);
286
+ }
287
+ /**
288
+ * Get all locks for this project
289
+ */
290
+ getLocks() {
291
+ const db = databaseManager.getConnection();
292
+ const rows = db.prepare(`
293
+ SELECT resource, project_id, agent_id, acquired_at
294
+ FROM locks
295
+ WHERE project_id = ?
296
+ `).all(this.projectId);
297
+ return rows.map(row => ({
298
+ resource: row.resource,
299
+ projectId: row.project_id,
300
+ agentId: row.agent_id,
301
+ acquiredAt: row.acquired_at
302
+ }));
303
+ }
304
+ /**
305
+ * Release all locks held by an agent (cleanup)
306
+ */
307
+ releaseAllLocks(agentId) {
308
+ const db = databaseManager.getConnection();
309
+ const result = db.prepare(`
310
+ DELETE FROM locks
311
+ WHERE agent_id = ? AND project_id = ?
312
+ `).run(agentId, this.projectId);
313
+ return result.changes;
314
+ }
315
+ /**
316
+ * Cleanup orphaned locks (locks from inactive agents)
317
+ */
318
+ cleanupOrphanedLocks() {
319
+ const db = databaseManager.getConnection();
320
+ const result = db.prepare(`
321
+ DELETE FROM locks
322
+ WHERE project_id = ? AND agent_id NOT IN (
323
+ SELECT id FROM agents WHERE project_id = ? AND status = 'ACTIVE'
324
+ )
325
+ `).run(this.projectId, this.projectId);
326
+ return result.changes;
327
+ }
328
+ // ========================================================================
329
+ // Session Events
330
+ // ========================================================================
331
+ /**
332
+ * Log a session event
333
+ */
334
+ logSessionEvent(sessionId, eventType, eventData, agentId) {
335
+ const db = databaseManager.getConnection();
336
+ const now = new Date().toISOString();
337
+ db.prepare(`
338
+ INSERT INTO session_events (project_id, session_id, agent_id, event_type, event_data, timestamp)
339
+ VALUES (?, ?, ?, ?, ?, ?)
340
+ `).run(this.projectId, sessionId, agentId || null, eventType, JSON.stringify(eventData), now);
341
+ }
342
+ /**
343
+ * Get session history for a specific session
344
+ */
345
+ getSessionHistory(sessionId) {
346
+ const db = databaseManager.getConnection();
347
+ const rows = db.prepare(`
348
+ SELECT id, project_id, session_id, agent_id, event_type, event_data, timestamp
349
+ FROM session_events
350
+ WHERE session_id = ?
351
+ ORDER BY timestamp ASC
352
+ `).all(sessionId);
353
+ return rows.map(row => ({
354
+ id: row.id,
355
+ projectId: row.project_id,
356
+ sessionId: row.session_id,
357
+ agentId: row.agent_id,
358
+ eventType: row.event_type,
359
+ eventData: JSON.parse(row.event_data),
360
+ timestamp: row.timestamp
361
+ }));
362
+ }
363
+ /**
364
+ * Get all sessions for this project (for UI display)
365
+ */
366
+ getProjectSessions() {
367
+ const db = databaseManager.getConnection();
368
+ const rows = db.prepare(`
369
+ SELECT
370
+ session_id,
371
+ MAX(agent_id) as agent_id,
372
+ COUNT(*) as event_count,
373
+ MIN(timestamp) as first_event,
374
+ MAX(timestamp) as last_event
375
+ FROM session_events
376
+ WHERE project_id = ?
377
+ GROUP BY session_id
378
+ ORDER BY last_event DESC
379
+ `).all(this.projectId);
380
+ return rows.map(row => ({
381
+ sessionId: row.session_id,
382
+ agentId: row.agent_id,
383
+ eventCount: row.event_count,
384
+ firstEvent: row.first_event,
385
+ lastEvent: row.last_event
386
+ }));
387
+ }
388
+ // ========================================================================
389
+ // Messages (Agent Log)
390
+ // ========================================================================
391
+ /**
392
+ * Log a message
393
+ */
394
+ logMessage(agentId, message) {
395
+ const db = databaseManager.getConnection();
396
+ const now = new Date().toISOString();
397
+ db.prepare(`
398
+ INSERT INTO messages (project_id, agent_id, message, timestamp)
399
+ VALUES (?, ?, ?, ?)
400
+ `).run(this.projectId, agentId, message, now);
401
+ }
402
+ /**
403
+ * Get recent messages
404
+ */
405
+ getMessages(limit = 20) {
406
+ const db = databaseManager.getConnection();
407
+ const rows = db.prepare(`
408
+ SELECT id, project_id, agent_id, message, timestamp
409
+ FROM messages
410
+ WHERE project_id = ?
411
+ ORDER BY timestamp DESC
412
+ LIMIT ?
413
+ `).all(this.projectId, limit);
414
+ return rows.map(row => ({
415
+ id: row.id,
416
+ projectId: row.project_id,
417
+ agentId: row.agent_id,
418
+ message: row.message,
419
+ timestamp: row.timestamp
420
+ }));
421
+ }
422
+ // ========================================================================
423
+ // Export to Markdown (for compatibility/display)
424
+ // ========================================================================
425
+ /**
426
+ * Generate Markdown representation of the board (for get_board action)
427
+ */
428
+ exportToMarkdown() {
429
+ const agents = this.getAllAgents();
430
+ const tasks = this.getPendingTasks();
431
+ const locks = this.getLocks();
432
+ const messages = this.getMessages(20);
433
+ const lines = [
434
+ '# Multi-Agent Board',
435
+ '',
436
+ '## Active Agents',
437
+ '| Agent ID | Status | Current Focus | Session ID | Last Heartbeat |',
438
+ '|---|---|---|---|---|',
439
+ ...agents.map(a => `| ${a.id} | ${a.status} | ${a.focus} | ${a.sessionId} | ${a.lastHeartbeat} |`),
440
+ '',
441
+ '## Pending Tasks',
442
+ '| ID | Title | From | Status | Created At |',
443
+ '|---|---|---|---|---|',
444
+ ...tasks.map(t => `| ${t.id} | ${t.title} | ${t.fromProject || t.fromAgent || '-'} | ${t.status} | ${t.createdAt} |`),
445
+ '',
446
+ '## File Locks',
447
+ '| File Pattern | Claimed By | Since |',
448
+ '|---|---|---|',
449
+ ...locks.map(l => `| ${l.resource} | ${l.agentId} | ${l.acquiredAt} |`),
450
+ '',
451
+ '## Agent Messages',
452
+ ...messages.reverse().map(m => `- [${m.timestamp.split('T')[1]?.split('.')[0] || m.timestamp}] **${m.agentId}**: ${m.message}`),
453
+ ''
454
+ ];
455
+ return lines.join('\n');
456
+ }
457
+ }
458
+ // ============================================================================
459
+ // Cleanup utilities
460
+ // ============================================================================
461
+ /**
462
+ * Cleanup stale agents (no heartbeat for specified minutes)
463
+ */
464
+ export function cleanupStaleAgents(staleMinutes = 30) {
465
+ const db = databaseManager.getConnection();
466
+ const result = db.prepare(`
467
+ UPDATE agents
468
+ SET status = 'INACTIVE'
469
+ WHERE status = 'ACTIVE'
470
+ AND datetime(last_heartbeat) < datetime('now', '-' || ? || ' minutes')
471
+ `).run(staleMinutes);
472
+ return result.changes;
473
+ }
474
+ /**
475
+ * Cleanup all orphaned locks across all projects
476
+ */
477
+ export function cleanupAllOrphanedLocks() {
478
+ const db = databaseManager.getConnection();
479
+ const result = db.prepare(`
480
+ DELETE FROM locks
481
+ WHERE agent_id NOT IN (
482
+ SELECT id FROM agents WHERE status = 'ACTIVE'
483
+ )
484
+ `).run();
485
+ return result.changes;
486
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * @fileoverview SQLite Database Manager for Memory Bank
3
+ * Centralized database for agent coordination, sessions, tasks, and locks.
4
+ * Located at ~/.memorybank/agentboard.db (same directory as global_registry.json)
5
+ */
6
+ import Database from 'better-sqlite3';
7
+ import * as path from 'path';
8
+ import * as os from 'os';
9
+ import * as fs from 'fs';
10
+ const SCHEMA_VERSION = 1;
11
+ /**
12
+ * SQL Schema for Agent Board
13
+ */
14
+ const SCHEMA_SQL = `
15
+ -- Agents: All agent sessions across all projects
16
+ -- Only one agent can be ACTIVE per project at a time
17
+ CREATE TABLE IF NOT EXISTS agents (
18
+ id TEXT NOT NULL, -- Full agent ID with hash suffix (e.g., 'Dev-VSCode-Gemini-abc12345')
19
+ project_id TEXT NOT NULL, -- Project this agent is working on
20
+ session_id TEXT NOT NULL, -- UUID for this session
21
+ status TEXT NOT NULL DEFAULT 'ACTIVE', -- ACTIVE, INACTIVE
22
+ focus TEXT DEFAULT '-', -- Current task/file being worked on
23
+ last_heartbeat TEXT NOT NULL, -- ISO timestamp of last activity
24
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
25
+ PRIMARY KEY (id, project_id)
26
+ );
27
+
28
+ CREATE INDEX IF NOT EXISTS idx_agents_project ON agents(project_id);
29
+ CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(project_id, status);
30
+ CREATE INDEX IF NOT EXISTS idx_agents_session ON agents(session_id);
31
+
32
+ -- Tasks: Project-centric tasks (not assigned to specific agents)
33
+ -- The active agent of a project processes its pending tasks
34
+ CREATE TABLE IF NOT EXISTS tasks (
35
+ id TEXT PRIMARY KEY, -- e.g., 'TASK-123456'
36
+ project_id TEXT NOT NULL, -- Target project to handle this task
37
+ title TEXT NOT NULL,
38
+ description TEXT, -- Extended task description
39
+ from_project TEXT, -- Source project (for cross-project delegation)
40
+ from_agent TEXT, -- Agent that created the task
41
+ status TEXT NOT NULL DEFAULT 'PENDING', -- PENDING, IN_PROGRESS, COMPLETED, CANCELLED
42
+ claimed_by TEXT, -- Agent ID that claimed the task
43
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
44
+ claimed_at TEXT,
45
+ completed_at TEXT
46
+ );
47
+
48
+ CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
49
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(project_id, status);
50
+ CREATE INDEX IF NOT EXISTS idx_tasks_from ON tasks(from_project);
51
+
52
+ -- Locks: Resource/file locks held by agents
53
+ CREATE TABLE IF NOT EXISTS locks (
54
+ resource TEXT NOT NULL, -- Resource pattern (e.g., 'src/auth/')
55
+ project_id TEXT NOT NULL, -- Project scope
56
+ agent_id TEXT NOT NULL, -- Agent holding the lock
57
+ acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
58
+ PRIMARY KEY (resource, project_id)
59
+ );
60
+
61
+ CREATE INDEX IF NOT EXISTS idx_locks_agent ON locks(agent_id);
62
+ CREATE INDEX IF NOT EXISTS idx_locks_project ON locks(project_id);
63
+
64
+ -- Session Events: All actions performed during agent sessions
65
+ -- Replaces JSONL files for better querying
66
+ CREATE TABLE IF NOT EXISTS session_events (
67
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68
+ project_id TEXT NOT NULL,
69
+ session_id TEXT NOT NULL,
70
+ agent_id TEXT, -- Agent that performed the action
71
+ event_type TEXT NOT NULL, -- search, read_doc, read_file, index, decision, write_file, etc.
72
+ event_data TEXT NOT NULL, -- JSON blob with event details
73
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
74
+ );
75
+
76
+ CREATE INDEX IF NOT EXISTS idx_events_session ON session_events(session_id);
77
+ CREATE INDEX IF NOT EXISTS idx_events_project ON session_events(project_id);
78
+ CREATE INDEX IF NOT EXISTS idx_events_type ON session_events(event_type);
79
+ CREATE INDEX IF NOT EXISTS idx_events_time ON session_events(timestamp);
80
+
81
+ -- Messages: Agent communication log
82
+ CREATE TABLE IF NOT EXISTS messages (
83
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
84
+ project_id TEXT NOT NULL,
85
+ agent_id TEXT NOT NULL,
86
+ message TEXT NOT NULL,
87
+ timestamp TEXT NOT NULL DEFAULT (datetime('now'))
88
+ );
89
+
90
+ CREATE INDEX IF NOT EXISTS idx_messages_project ON messages(project_id);
91
+ CREATE INDEX IF NOT EXISTS idx_messages_time ON messages(timestamp);
92
+
93
+ -- Schema versioning for future migrations
94
+ CREATE TABLE IF NOT EXISTS schema_version (
95
+ version INTEGER PRIMARY KEY,
96
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
97
+ );
98
+ `;
99
+ /**
100
+ * Database Manager Singleton
101
+ * Manages a single SQLite connection for the entire MCP server
102
+ */
103
+ class DatabaseManager {
104
+ static instance;
105
+ db = null;
106
+ dbPath;
107
+ constructor() {
108
+ // Same directory as global_registry.json
109
+ this.dbPath = path.join(os.homedir(), '.memorybank', 'agentboard.db');
110
+ }
111
+ static getInstance() {
112
+ if (!DatabaseManager.instance) {
113
+ DatabaseManager.instance = new DatabaseManager();
114
+ }
115
+ return DatabaseManager.instance;
116
+ }
117
+ /**
118
+ * Get or create the database connection
119
+ */
120
+ getConnection() {
121
+ if (this.db) {
122
+ return this.db;
123
+ }
124
+ // Ensure directory exists
125
+ const dir = path.dirname(this.dbPath);
126
+ if (!fs.existsSync(dir)) {
127
+ fs.mkdirSync(dir, { recursive: true });
128
+ }
129
+ // Open database with WAL mode for better concurrency
130
+ this.db = new Database(this.dbPath, {
131
+ timeout: 5000, // Wait up to 5s for locks
132
+ });
133
+ // Enable WAL mode for concurrent reads during writes
134
+ this.db.pragma('journal_mode = WAL');
135
+ // Foreign keys enforcement
136
+ this.db.pragma('foreign_keys = ON');
137
+ // Initialize schema
138
+ this.initializeSchema();
139
+ console.error(`[Database] Connected to ${this.dbPath}`);
140
+ return this.db;
141
+ }
142
+ /**
143
+ * Initialize database schema
144
+ */
145
+ initializeSchema() {
146
+ if (!this.db)
147
+ return;
148
+ // Check current schema version
149
+ let currentVersion = 0;
150
+ try {
151
+ const row = this.db.prepare('SELECT MAX(version) as version FROM schema_version').get();
152
+ currentVersion = row?.version || 0;
153
+ }
154
+ catch {
155
+ // Table doesn't exist yet, version is 0
156
+ }
157
+ if (currentVersion < SCHEMA_VERSION) {
158
+ console.error(`[Database] Migrating schema from v${currentVersion} to v${SCHEMA_VERSION}`);
159
+ // Run schema creation (IF NOT EXISTS makes it safe)
160
+ this.db.exec(SCHEMA_SQL);
161
+ // Record schema version
162
+ if (currentVersion === 0) {
163
+ this.db.prepare('INSERT OR REPLACE INTO schema_version (version) VALUES (?)').run(SCHEMA_VERSION);
164
+ }
165
+ console.error(`[Database] Schema initialized at v${SCHEMA_VERSION}`);
166
+ }
167
+ }
168
+ /**
169
+ * Close database connection
170
+ */
171
+ close() {
172
+ if (this.db) {
173
+ this.db.close();
174
+ this.db = null;
175
+ console.error('[Database] Connection closed');
176
+ }
177
+ }
178
+ /**
179
+ * Get the database file path
180
+ */
181
+ getDbPath() {
182
+ return this.dbPath;
183
+ }
184
+ /**
185
+ * Check if database file exists
186
+ */
187
+ exists() {
188
+ return fs.existsSync(this.dbPath);
189
+ }
190
+ /**
191
+ * Run a transaction with automatic rollback on error
192
+ */
193
+ transaction(fn) {
194
+ const db = this.getConnection();
195
+ return db.transaction(fn)();
196
+ }
197
+ }
198
+ // Export singleton instance
199
+ export const databaseManager = DatabaseManager.getInstance();