session-collab-mcp 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,562 @@
1
+ // Database queries for Session Collaboration MCP
2
+
3
+ import type { D1Database } from '@cloudflare/workers-types';
4
+ import type {
5
+ Session,
6
+ Claim,
7
+ ClaimStatus,
8
+ ClaimScope,
9
+ ClaimWithFiles,
10
+ ConflictInfo,
11
+ Message,
12
+ Decision,
13
+ DecisionCategory,
14
+ TodoItem,
15
+ SessionProgress,
16
+ } from './types';
17
+
18
+ // Helper to generate UUID v4
19
+ function generateId(): string {
20
+ return crypto.randomUUID();
21
+ }
22
+
23
+ // ============ Session Queries ============
24
+
25
+ export async function createSession(
26
+ db: D1Database,
27
+ params: {
28
+ name?: string;
29
+ project_root: string;
30
+ machine_id?: string;
31
+ user_id?: string;
32
+ }
33
+ ): Promise<Session> {
34
+ const id = generateId();
35
+ const now = new Date().toISOString();
36
+
37
+ await db
38
+ .prepare(
39
+ `INSERT INTO sessions (id, name, project_root, machine_id, user_id, created_at, last_heartbeat, status)
40
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'active')`
41
+ )
42
+ .bind(id, params.name ?? null, params.project_root, params.machine_id ?? null, params.user_id ?? null, now, now)
43
+ .run();
44
+
45
+ return {
46
+ id,
47
+ name: params.name ?? null,
48
+ project_root: params.project_root,
49
+ machine_id: params.machine_id ?? null,
50
+ user_id: params.user_id ?? null,
51
+ created_at: now,
52
+ last_heartbeat: now,
53
+ status: 'active',
54
+ current_task: null,
55
+ progress: null,
56
+ todos: null,
57
+ };
58
+ }
59
+
60
+ export async function getSession(db: D1Database, id: string): Promise<Session | null> {
61
+ const result = await db.prepare('SELECT * FROM sessions WHERE id = ?').bind(id).first<Session>();
62
+ return result ?? null;
63
+ }
64
+
65
+ export async function listSessions(
66
+ db: D1Database,
67
+ params: {
68
+ include_inactive?: boolean;
69
+ project_root?: string;
70
+ user_id?: string;
71
+ } = {}
72
+ ): Promise<Session[]> {
73
+ let query = 'SELECT * FROM sessions WHERE 1=1';
74
+ const bindings: (string | number)[] = [];
75
+
76
+ if (!params.include_inactive) {
77
+ query += " AND status = 'active'";
78
+ }
79
+
80
+ if (params.project_root) {
81
+ query += ' AND project_root = ?';
82
+ bindings.push(params.project_root);
83
+ }
84
+
85
+ if (params.user_id) {
86
+ query += ' AND user_id = ?';
87
+ bindings.push(params.user_id);
88
+ }
89
+
90
+ query += ' ORDER BY last_heartbeat DESC';
91
+
92
+ const result = await db
93
+ .prepare(query)
94
+ .bind(...bindings)
95
+ .all<Session>();
96
+ return result.results;
97
+ }
98
+
99
+ export async function updateSessionHeartbeat(
100
+ db: D1Database,
101
+ id: string,
102
+ statusUpdate?: {
103
+ current_task?: string | null;
104
+ todos?: TodoItem[];
105
+ }
106
+ ): Promise<boolean> {
107
+ const now = new Date().toISOString();
108
+
109
+ // Calculate progress from todos if provided
110
+ let progress: SessionProgress | null = null;
111
+ let todosJson: string | null = null;
112
+
113
+ if (statusUpdate?.todos) {
114
+ const total = statusUpdate.todos.length;
115
+ const completed = statusUpdate.todos.filter((t) => t.status === 'completed').length;
116
+ progress = {
117
+ completed,
118
+ total,
119
+ percentage: total > 0 ? Math.round((completed / total) * 100) : 0,
120
+ };
121
+ todosJson = JSON.stringify(statusUpdate.todos);
122
+ }
123
+
124
+ let query = "UPDATE sessions SET last_heartbeat = ?";
125
+ const bindings: (string | null)[] = [now];
126
+
127
+ if (statusUpdate?.current_task !== undefined) {
128
+ query += ", current_task = ?";
129
+ bindings.push(statusUpdate.current_task);
130
+ }
131
+
132
+ if (progress) {
133
+ query += ", progress = ?";
134
+ bindings.push(JSON.stringify(progress));
135
+ }
136
+
137
+ if (todosJson) {
138
+ query += ", todos = ?";
139
+ bindings.push(todosJson);
140
+ }
141
+
142
+ query += " WHERE id = ? AND status = 'active'";
143
+ bindings.push(id);
144
+
145
+ const result = await db
146
+ .prepare(query)
147
+ .bind(...bindings)
148
+ .run();
149
+ return result.meta.changes > 0;
150
+ }
151
+
152
+ export async function updateSessionStatus(
153
+ db: D1Database,
154
+ id: string,
155
+ params: {
156
+ current_task?: string | null;
157
+ todos?: TodoItem[];
158
+ }
159
+ ): Promise<boolean> {
160
+ const now = new Date().toISOString();
161
+
162
+ // Calculate progress from todos
163
+ let progress: SessionProgress | null = null;
164
+ let todosJson: string | null = null;
165
+
166
+ if (params.todos) {
167
+ const total = params.todos.length;
168
+ const completed = params.todos.filter((t) => t.status === 'completed').length;
169
+ progress = {
170
+ completed,
171
+ total,
172
+ percentage: total > 0 ? Math.round((completed / total) * 100) : 0,
173
+ };
174
+ todosJson = JSON.stringify(params.todos);
175
+ }
176
+
177
+ const result = await db
178
+ .prepare(
179
+ `UPDATE sessions
180
+ SET current_task = ?, progress = ?, todos = ?, last_heartbeat = ?
181
+ WHERE id = ? AND status = 'active'`
182
+ )
183
+ .bind(
184
+ params.current_task ?? null,
185
+ progress ? JSON.stringify(progress) : null,
186
+ todosJson,
187
+ now,
188
+ id
189
+ )
190
+ .run();
191
+
192
+ return result.meta.changes > 0;
193
+ }
194
+
195
+ export async function endSession(
196
+ db: D1Database,
197
+ id: string,
198
+ release_claims: 'complete' | 'abandon' = 'abandon'
199
+ ): Promise<boolean> {
200
+ const claimStatus: ClaimStatus = release_claims === 'complete' ? 'completed' : 'abandoned';
201
+
202
+ // Update all active claims for this session
203
+ await db
204
+ .prepare("UPDATE claims SET status = ?, updated_at = ? WHERE session_id = ? AND status = 'active'")
205
+ .bind(claimStatus, new Date().toISOString(), id)
206
+ .run();
207
+
208
+ // Mark session as terminated
209
+ const result = await db.prepare("UPDATE sessions SET status = 'terminated' WHERE id = ?").bind(id).run();
210
+
211
+ return result.meta.changes > 0;
212
+ }
213
+
214
+ export async function cleanupStaleSessions(db: D1Database, staleMinutes: number = 30): Promise<{ stale_sessions: number; orphaned_claims: number }> {
215
+ const cutoff = new Date(Date.now() - staleMinutes * 60 * 1000).toISOString();
216
+ const now = new Date().toISOString();
217
+
218
+ // Mark stale sessions as inactive
219
+ const result = await db
220
+ .prepare("UPDATE sessions SET status = 'inactive' WHERE status = 'active' AND last_heartbeat < ?")
221
+ .bind(cutoff)
222
+ .run();
223
+
224
+ // Abandon claims from inactive/terminated sessions
225
+ const orphanedResult = await db
226
+ .prepare(
227
+ `UPDATE claims SET status = 'abandoned', updated_at = ?
228
+ WHERE status = 'active' AND session_id IN (
229
+ SELECT id FROM sessions WHERE status IN ('inactive', 'terminated')
230
+ )`
231
+ )
232
+ .bind(now)
233
+ .run();
234
+
235
+ return {
236
+ stale_sessions: result.meta.changes,
237
+ orphaned_claims: orphanedResult.meta.changes,
238
+ };
239
+ }
240
+
241
+ // ============ Claim Queries ============
242
+
243
+ export async function createClaim(
244
+ db: D1Database,
245
+ params: {
246
+ session_id: string;
247
+ files: string[];
248
+ intent: string;
249
+ scope?: ClaimScope;
250
+ }
251
+ ): Promise<{ claim: Claim; files: string[] }> {
252
+ const id = generateId();
253
+ const now = new Date().toISOString();
254
+ const scope = params.scope ?? 'medium';
255
+
256
+ // Batch insert: claim + all file paths in single transaction
257
+ const claimStatement = db
258
+ .prepare(
259
+ `INSERT INTO claims (id, session_id, intent, scope, status, created_at, updated_at)
260
+ VALUES (?, ?, ?, ?, 'active', ?, ?)`
261
+ )
262
+ .bind(id, params.session_id, params.intent, scope, now, now);
263
+
264
+ const fileStatements = params.files.map((filePath) => {
265
+ const isPattern = filePath.includes('*') ? 1 : 0;
266
+ return db
267
+ .prepare('INSERT INTO claim_files (claim_id, file_path, is_pattern) VALUES (?, ?, ?)')
268
+ .bind(id, filePath, isPattern);
269
+ });
270
+
271
+ await db.batch([claimStatement, ...fileStatements]);
272
+
273
+ return {
274
+ claim: {
275
+ id,
276
+ session_id: params.session_id,
277
+ intent: params.intent,
278
+ scope,
279
+ status: 'active',
280
+ created_at: now,
281
+ updated_at: now,
282
+ completed_summary: null,
283
+ },
284
+ files: params.files,
285
+ };
286
+ }
287
+
288
+ export async function getClaim(db: D1Database, id: string): Promise<ClaimWithFiles | null> {
289
+ const claim = await db.prepare('SELECT * FROM claims WHERE id = ?').bind(id).first<Claim>();
290
+
291
+ if (!claim) return null;
292
+
293
+ const files = await db.prepare('SELECT file_path FROM claim_files WHERE claim_id = ?').bind(id).all<{ file_path: string }>();
294
+
295
+ const session = await db.prepare('SELECT name FROM sessions WHERE id = ?').bind(claim.session_id).first<{ name: string | null }>();
296
+
297
+ return {
298
+ ...claim,
299
+ files: files.results.map((f) => f.file_path),
300
+ session_name: session?.name ?? null,
301
+ };
302
+ }
303
+
304
+ export async function listClaims(
305
+ db: D1Database,
306
+ params: {
307
+ session_id?: string;
308
+ status?: ClaimStatus | 'all';
309
+ project_root?: string;
310
+ } = {}
311
+ ): Promise<ClaimWithFiles[]> {
312
+ let query = `
313
+ SELECT c.*, s.name as session_name
314
+ FROM claims c
315
+ JOIN sessions s ON c.session_id = s.id
316
+ WHERE 1=1
317
+ `;
318
+ const bindings: string[] = [];
319
+
320
+ if (params.session_id) {
321
+ query += ' AND c.session_id = ?';
322
+ bindings.push(params.session_id);
323
+ }
324
+
325
+ if (params.status && params.status !== 'all') {
326
+ query += ' AND c.status = ?';
327
+ bindings.push(params.status);
328
+ }
329
+
330
+ if (params.project_root) {
331
+ query += ' AND s.project_root = ?';
332
+ bindings.push(params.project_root);
333
+ }
334
+
335
+ query += ' ORDER BY c.created_at DESC';
336
+
337
+ const claims = await db
338
+ .prepare(query)
339
+ .bind(...bindings)
340
+ .all<Claim & { session_name: string | null }>();
341
+
342
+ if (claims.results.length === 0) {
343
+ return [];
344
+ }
345
+
346
+ // Batch fetch all files for claims (avoid N+1 query)
347
+ const claimIds = claims.results.map((c) => c.id);
348
+ const placeholders = claimIds.map(() => '?').join(',');
349
+ const allFiles = await db
350
+ .prepare(`SELECT claim_id, file_path FROM claim_files WHERE claim_id IN (${placeholders})`)
351
+ .bind(...claimIds)
352
+ .all<{ claim_id: string; file_path: string }>();
353
+
354
+ // Group files by claim_id
355
+ const filesByClaimId = new Map<string, string[]>();
356
+ for (const f of allFiles.results) {
357
+ const arr = filesByClaimId.get(f.claim_id) ?? [];
358
+ arr.push(f.file_path);
359
+ filesByClaimId.set(f.claim_id, arr);
360
+ }
361
+
362
+ // Assemble results
363
+ return claims.results.map((claim) => ({
364
+ ...claim,
365
+ files: filesByClaimId.get(claim.id) ?? [],
366
+ }));
367
+ }
368
+
369
+ export async function checkConflicts(
370
+ db: D1Database,
371
+ files: string[],
372
+ excludeSessionId?: string
373
+ ): Promise<ConflictInfo[]> {
374
+ const conflicts: ConflictInfo[] = [];
375
+
376
+ for (const filePath of files) {
377
+ // Check for exact matches or pattern overlaps
378
+ let query = `
379
+ SELECT
380
+ c.id as claim_id,
381
+ c.session_id,
382
+ s.name as session_name,
383
+ cf.file_path,
384
+ c.intent,
385
+ c.scope,
386
+ c.created_at
387
+ FROM claim_files cf
388
+ JOIN claims c ON cf.claim_id = c.id
389
+ JOIN sessions s ON c.session_id = s.id
390
+ WHERE c.status = 'active'
391
+ AND s.status = 'active'
392
+ AND (cf.file_path = ? OR (cf.is_pattern = 1 AND ? GLOB cf.file_path))
393
+ `;
394
+ const bindings: string[] = [filePath, filePath];
395
+
396
+ if (excludeSessionId) {
397
+ query += ' AND c.session_id != ?';
398
+ bindings.push(excludeSessionId);
399
+ }
400
+
401
+ const result = await db
402
+ .prepare(query)
403
+ .bind(...bindings)
404
+ .all<ConflictInfo>();
405
+
406
+ conflicts.push(...result.results);
407
+ }
408
+
409
+ // Deduplicate by claim_id + file_path
410
+ const seen = new Set<string>();
411
+ return conflicts.filter((c) => {
412
+ const key = `${c.claim_id}:${c.file_path}`;
413
+ if (seen.has(key)) return false;
414
+ seen.add(key);
415
+ return true;
416
+ });
417
+ }
418
+
419
+ export async function releaseClaim(
420
+ db: D1Database,
421
+ id: string,
422
+ params: {
423
+ status: 'completed' | 'abandoned';
424
+ summary?: string;
425
+ }
426
+ ): Promise<boolean> {
427
+ const now = new Date().toISOString();
428
+
429
+ const result = await db
430
+ .prepare('UPDATE claims SET status = ?, updated_at = ?, completed_summary = ? WHERE id = ?')
431
+ .bind(params.status, now, params.summary ?? null, id)
432
+ .run();
433
+
434
+ return result.meta.changes > 0;
435
+ }
436
+
437
+ // ============ Message Queries ============
438
+
439
+ export async function sendMessage(
440
+ db: D1Database,
441
+ params: {
442
+ from_session_id: string;
443
+ to_session_id?: string;
444
+ content: string;
445
+ }
446
+ ): Promise<Message> {
447
+ const id = generateId();
448
+ const now = new Date().toISOString();
449
+
450
+ await db
451
+ .prepare(
452
+ `INSERT INTO messages (id, from_session_id, to_session_id, content, created_at)
453
+ VALUES (?, ?, ?, ?, ?)`
454
+ )
455
+ .bind(id, params.from_session_id, params.to_session_id ?? null, params.content, now)
456
+ .run();
457
+
458
+ return {
459
+ id,
460
+ from_session_id: params.from_session_id,
461
+ to_session_id: params.to_session_id ?? null,
462
+ content: params.content,
463
+ read_at: null,
464
+ created_at: now,
465
+ };
466
+ }
467
+
468
+ export async function listMessages(
469
+ db: D1Database,
470
+ params: {
471
+ session_id: string;
472
+ unread_only?: boolean;
473
+ mark_as_read?: boolean;
474
+ }
475
+ ): Promise<Message[]> {
476
+ let query = `
477
+ SELECT * FROM messages
478
+ WHERE (to_session_id = ? OR to_session_id IS NULL)
479
+ `;
480
+ const bindings: string[] = [params.session_id];
481
+
482
+ if (params.unread_only) {
483
+ query += ' AND read_at IS NULL';
484
+ }
485
+
486
+ query += ' ORDER BY created_at DESC';
487
+
488
+ const messages = await db
489
+ .prepare(query)
490
+ .bind(...bindings)
491
+ .all<Message>();
492
+
493
+ // Mark as read if requested
494
+ if (params.mark_as_read && messages.results.length > 0) {
495
+ const now = new Date().toISOString();
496
+ const ids = messages.results.map((m) => m.id);
497
+
498
+ for (const id of ids) {
499
+ await db.prepare('UPDATE messages SET read_at = ? WHERE id = ? AND read_at IS NULL').bind(now, id).run();
500
+ }
501
+ }
502
+
503
+ return messages.results;
504
+ }
505
+
506
+ // ============ Decision Queries ============
507
+
508
+ export async function addDecision(
509
+ db: D1Database,
510
+ params: {
511
+ session_id: string;
512
+ category?: DecisionCategory;
513
+ title: string;
514
+ description: string;
515
+ }
516
+ ): Promise<Decision> {
517
+ const id = generateId();
518
+ const now = new Date().toISOString();
519
+
520
+ await db
521
+ .prepare(
522
+ `INSERT INTO decisions (id, session_id, category, title, description, created_at)
523
+ VALUES (?, ?, ?, ?, ?, ?)`
524
+ )
525
+ .bind(id, params.session_id, params.category ?? null, params.title, params.description, now)
526
+ .run();
527
+
528
+ return {
529
+ id,
530
+ session_id: params.session_id,
531
+ category: params.category ?? null,
532
+ title: params.title,
533
+ description: params.description,
534
+ created_at: now,
535
+ };
536
+ }
537
+
538
+ export async function listDecisions(
539
+ db: D1Database,
540
+ params: {
541
+ category?: DecisionCategory;
542
+ limit?: number;
543
+ } = {}
544
+ ): Promise<Decision[]> {
545
+ let query = 'SELECT * FROM decisions WHERE 1=1';
546
+ const bindings: (string | number)[] = [];
547
+
548
+ if (params.category) {
549
+ query += ' AND category = ?';
550
+ bindings.push(params.category);
551
+ }
552
+
553
+ query += ' ORDER BY created_at DESC LIMIT ?';
554
+ bindings.push(params.limit ?? 20);
555
+
556
+ const result = await db
557
+ .prepare(query)
558
+ .bind(...bindings)
559
+ .all<Decision>();
560
+
561
+ return result.results;
562
+ }
@@ -0,0 +1,137 @@
1
+ // SQLite adapter: wraps better-sqlite3 to match D1-like API
2
+ // This allows the same queries.ts to work with both D1 and local SQLite
3
+
4
+ import Database from 'better-sqlite3';
5
+ import { randomUUID } from 'crypto';
6
+ import { existsSync, mkdirSync } from 'fs';
7
+ import { dirname, join } from 'path';
8
+ import { homedir } from 'os';
9
+
10
+ // Polyfill crypto.randomUUID for Node.js
11
+ if (typeof globalThis.crypto === 'undefined') {
12
+ (globalThis as unknown as { crypto: { randomUUID: () => string } }).crypto = {
13
+ randomUUID,
14
+ };
15
+ }
16
+
17
+ export interface D1Result<T> {
18
+ results: T[];
19
+ meta: {
20
+ changes: number;
21
+ last_row_id: number;
22
+ };
23
+ }
24
+
25
+ export interface D1PreparedStatement {
26
+ bind(...values: unknown[]): D1PreparedStatement;
27
+ first<T>(): Promise<T | null>;
28
+ all<T>(): Promise<D1Result<T>>;
29
+ run(): Promise<{ meta: { changes: number } }>;
30
+ }
31
+
32
+ export interface D1Database {
33
+ prepare(sql: string): D1PreparedStatement;
34
+ batch(statements: D1PreparedStatement[]): Promise<D1Result<unknown>[]>;
35
+ }
36
+
37
+ class SqlitePreparedStatement implements D1PreparedStatement {
38
+ private bindings: unknown[] = [];
39
+
40
+ constructor(
41
+ private db: Database.Database,
42
+ private sql: string
43
+ ) {}
44
+
45
+ bind(...values: unknown[]): D1PreparedStatement {
46
+ this.bindings = values;
47
+ return this;
48
+ }
49
+
50
+ async first<T>(): Promise<T | null> {
51
+ const stmt = this.db.prepare(this.sql);
52
+ const result = stmt.get(...this.bindings) as T | undefined;
53
+ return result ?? null;
54
+ }
55
+
56
+ async all<T>(): Promise<D1Result<T>> {
57
+ const stmt = this.db.prepare(this.sql);
58
+ const results = stmt.all(...this.bindings) as T[];
59
+ return {
60
+ results,
61
+ meta: { changes: 0, last_row_id: 0 },
62
+ };
63
+ }
64
+
65
+ async run(): Promise<{ meta: { changes: number } }> {
66
+ const stmt = this.db.prepare(this.sql);
67
+ const result = stmt.run(...this.bindings);
68
+ return {
69
+ meta: { changes: result.changes },
70
+ };
71
+ }
72
+
73
+ // Internal method for batch execution
74
+ _run(): Database.RunResult {
75
+ const stmt = this.db.prepare(this.sql);
76
+ return stmt.run(...this.bindings);
77
+ }
78
+ }
79
+
80
+ class SqliteDatabase implements D1Database {
81
+ private db: Database.Database;
82
+
83
+ constructor(dbPath: string) {
84
+ // Ensure directory exists
85
+ const dir = dirname(dbPath);
86
+ if (!existsSync(dir)) {
87
+ mkdirSync(dir, { recursive: true });
88
+ }
89
+
90
+ this.db = new Database(dbPath);
91
+ this.db.pragma('journal_mode = WAL');
92
+ this.db.pragma('foreign_keys = ON');
93
+ }
94
+
95
+ prepare(sql: string): D1PreparedStatement {
96
+ return new SqlitePreparedStatement(this.db, sql);
97
+ }
98
+
99
+ async batch(statements: D1PreparedStatement[]): Promise<D1Result<unknown>[]> {
100
+ const transaction = this.db.transaction(() => {
101
+ return statements.map((stmt) => {
102
+ const sqliteStmt = stmt as SqlitePreparedStatement;
103
+ const result = sqliteStmt._run();
104
+ return {
105
+ results: [],
106
+ meta: { changes: result.changes, last_row_id: Number(result.lastInsertRowid) },
107
+ };
108
+ });
109
+ });
110
+ return transaction();
111
+ }
112
+
113
+ // Initialize database schema
114
+ initSchema(migrations: string[]): void {
115
+ for (const migration of migrations) {
116
+ this.db.exec(migration);
117
+ }
118
+ }
119
+
120
+ close(): void {
121
+ this.db.close();
122
+ }
123
+ }
124
+
125
+ // Get default database path
126
+ export function getDefaultDbPath(): string {
127
+ const dataDir = join(homedir(), '.claude', 'session-collab');
128
+ return join(dataDir, 'collab.db');
129
+ }
130
+
131
+ // Create a local SQLite database
132
+ export function createLocalDatabase(dbPath?: string): SqliteDatabase {
133
+ const path = dbPath ?? getDefaultDbPath();
134
+ return new SqliteDatabase(path);
135
+ }
136
+
137
+ export { SqliteDatabase };