clearctx 3.0.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,615 @@
1
+ /**
2
+ * TeamHub - Layer 1 Chat System for clearctx
3
+ *
4
+ * This class manages team-based messaging between Claude sessions.
5
+ * It handles:
6
+ * - Team roster (who's in the team)
7
+ * - Direct messaging between members
8
+ * - Broadcast messages to all members
9
+ * - Ask/reply system for synchronous questions
10
+ * - Inbox management and compaction
11
+ *
12
+ * @class TeamHub
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const os = require('os');
18
+ const crypto = require('crypto');
19
+
20
+ // Import our atomic file operations
21
+ const { atomicWriteJson, readJsonSafe, appendJsonl } = require('./atomic-io');
22
+ const { acquireLock, releaseLock } = require('./file-lock');
23
+
24
+ /**
25
+ * TeamHub class - manages team communication and roster
26
+ */
27
+ class TeamHub {
28
+ /**
29
+ * Creates a new TeamHub instance
30
+ *
31
+ * @param {string} teamName - Name of the team (default: 'default')
32
+ */
33
+ constructor(teamName = 'default') {
34
+ // Set the team name
35
+ this.teamName = teamName;
36
+
37
+ // Create the base directory path: ~/.clearctx
38
+ const baseDir = path.join(os.homedir(), '.clearctx');
39
+
40
+ // Create the team directory path: ~/.clearctx/team/{teamName}
41
+ this.teamDir = path.join(baseDir, 'team', teamName);
42
+
43
+ // Create subdirectories for inbox and asks if they don't exist
44
+ const inboxDir = path.join(this.teamDir, 'inbox');
45
+ const asksDir = path.join(this.teamDir, 'asks');
46
+
47
+ // Create locks directory for roster locking
48
+ this.locksDir = path.join(baseDir, 'team', teamName, 'locks');
49
+
50
+ // Make sure all directories exist
51
+ fs.mkdirSync(inboxDir, { recursive: true });
52
+ fs.mkdirSync(asksDir, { recursive: true });
53
+ fs.mkdirSync(this.locksDir, { recursive: true });
54
+
55
+ // Rate limiting: track message counts per sender
56
+ // Map structure: { senderName: { count: number, windowStart: timestamp } }
57
+ this.rateLimitMap = new Map();
58
+
59
+ // Loop detection: track exchanges between pairs
60
+ // Map structure: { 'sender:receiver': [ timestamps ] }
61
+ this.loopDetectionMap = new Map();
62
+ }
63
+
64
+ // ========== ROSTER METHODS ==========
65
+
66
+ /**
67
+ * Get the current team roster
68
+ *
69
+ * @returns {Array} Array of team member objects
70
+ */
71
+ getRoster() {
72
+ const rosterPath = path.join(this.teamDir, 'roster.json');
73
+ // readJsonSafe returns empty array if file doesn't exist
74
+ const data = readJsonSafe(rosterPath, []);
75
+ // Defensive: ensure we always return an array, even if file contains {}
76
+ return Array.isArray(data) ? data : [];
77
+ }
78
+
79
+ /**
80
+ * Add a new member to the team
81
+ *
82
+ * @param {string} name - Member name (unique identifier)
83
+ * @param {Object} options - Member details
84
+ * @param {string} options.role - Member's role in the team
85
+ * @param {string} options.task - Member's current task
86
+ * @param {string} options.model - AI model being used (e.g., 'sonnet', 'opus')
87
+ */
88
+ joinTeam(name, { role, task, model }) {
89
+ // Acquire lock for roster modification
90
+ acquireLock(this.locksDir, 'roster');
91
+
92
+ try {
93
+ // Get current roster
94
+ const roster = this.getRoster();
95
+
96
+ // Create new member entry with all required fields
97
+ const newMember = {
98
+ name,
99
+ role,
100
+ task,
101
+ model,
102
+ status: 'active',
103
+ joinedAt: new Date().toISOString(),
104
+ lastSeen: new Date().toISOString()
105
+ };
106
+
107
+ // Remove existing entry if member is rejoining
108
+ const filtered = roster.filter(m => m.name !== name);
109
+
110
+ // Add the new member
111
+ filtered.push(newMember);
112
+
113
+ // Save the updated roster
114
+ const rosterPath = path.join(this.teamDir, 'roster.json');
115
+ atomicWriteJson(rosterPath, filtered);
116
+ } finally {
117
+ // Always release the lock
118
+ releaseLock(this.locksDir, 'roster');
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Remove a member from the team
124
+ *
125
+ * @param {string} name - Name of member to remove
126
+ */
127
+ leaveTeam(name) {
128
+ // Acquire lock for roster modification
129
+ acquireLock(this.locksDir, 'roster');
130
+
131
+ try {
132
+ // Get current roster
133
+ const roster = this.getRoster();
134
+
135
+ // Filter out the member
136
+ const filtered = roster.filter(m => m.name !== name);
137
+
138
+ // Save the updated roster
139
+ const rosterPath = path.join(this.teamDir, 'roster.json');
140
+ atomicWriteJson(rosterPath, filtered);
141
+ } finally {
142
+ // Always release the lock
143
+ releaseLock(this.locksDir, 'roster');
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Update a member's information
149
+ *
150
+ * @param {string} name - Name of member to update
151
+ * @param {Object} updates - Fields to update (status, task, role, etc.)
152
+ */
153
+ updateMember(name, updates) {
154
+ // Acquire lock for roster modification
155
+ acquireLock(this.locksDir, 'roster');
156
+
157
+ try {
158
+ // Get current roster
159
+ const roster = this.getRoster();
160
+
161
+ // Find and update the member
162
+ const updated = roster.map(member => {
163
+ if (member.name === name) {
164
+ // Merge updates into existing member object
165
+ return { ...member, ...updates };
166
+ }
167
+ return member;
168
+ });
169
+
170
+ // Save the updated roster
171
+ const rosterPath = path.join(this.teamDir, 'roster.json');
172
+ atomicWriteJson(rosterPath, updated);
173
+ } finally {
174
+ // Always release the lock
175
+ releaseLock(this.locksDir, 'roster');
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Update a member's lastSeen timestamp to now
181
+ *
182
+ * @param {string} name - Name of member
183
+ */
184
+ touchLastSeen(name) {
185
+ this.updateMember(name, { lastSeen: new Date().toISOString() });
186
+ }
187
+
188
+ // ========== MESSAGING METHODS ==========
189
+
190
+ /**
191
+ * Check rate limits for a sender
192
+ * Max 10 messages per minute
193
+ *
194
+ * @param {string} from - Sender name
195
+ * @throws {Error} If rate limit exceeded
196
+ */
197
+ _checkRateLimit(from) {
198
+ const now = Date.now();
199
+ const windowSize = 60000; // 60 seconds in milliseconds
200
+ const maxMessages = 10;
201
+
202
+ // Get or create rate limit entry for this sender
203
+ let rateLimitEntry = this.rateLimitMap.get(from);
204
+
205
+ if (!rateLimitEntry) {
206
+ // First message from this sender
207
+ rateLimitEntry = { count: 0, windowStart: now };
208
+ this.rateLimitMap.set(from, rateLimitEntry);
209
+ }
210
+
211
+ // Check if we need to reset the window
212
+ if (now - rateLimitEntry.windowStart > windowSize) {
213
+ // Window expired, reset
214
+ rateLimitEntry.count = 0;
215
+ rateLimitEntry.windowStart = now;
216
+ }
217
+
218
+ // Check if limit exceeded
219
+ if (rateLimitEntry.count >= maxMessages) {
220
+ throw new Error(`Rate limit exceeded for ${from}: max ${maxMessages} messages per minute`);
221
+ }
222
+
223
+ // Increment counter
224
+ rateLimitEntry.count++;
225
+ }
226
+
227
+ /**
228
+ * Check for messaging loops between two parties
229
+ * Max 5 messages between same pair in 60 seconds
230
+ *
231
+ * @param {string} from - Sender name
232
+ * @param {string} to - Receiver name
233
+ * @throws {Error} If loop detected
234
+ */
235
+ _checkLoopDetection(from, to) {
236
+ const now = Date.now();
237
+ const windowSize = 60000; // 60 seconds
238
+ const maxExchanges = 5;
239
+
240
+ // Create a consistent key for this pair (alphabetically sorted)
241
+ const pair = [from, to].sort().join(':');
242
+
243
+ // Get or create exchange history for this pair
244
+ let exchanges = this.loopDetectionMap.get(pair);
245
+
246
+ if (!exchanges) {
247
+ exchanges = [];
248
+ this.loopDetectionMap.set(pair, exchanges);
249
+ }
250
+
251
+ // Remove old timestamps outside the window
252
+ exchanges = exchanges.filter(timestamp => now - timestamp < windowSize);
253
+ this.loopDetectionMap.set(pair, exchanges);
254
+
255
+ // Check if too many exchanges
256
+ if (exchanges.length >= maxExchanges) {
257
+ throw new Error(`Loop detected: too many exchanges between ${from} and ${to} (max ${maxExchanges} per minute)`);
258
+ }
259
+
260
+ // Add this exchange
261
+ exchanges.push(now);
262
+ }
263
+
264
+ /**
265
+ * Send a direct message to a specific team member
266
+ *
267
+ * @param {string} from - Sender name
268
+ * @param {string} to - Recipient name
269
+ * @param {string} content - Message content
270
+ * @param {Object} options - Message options
271
+ * @param {string} options.priority - Priority level ('normal' or 'urgent')
272
+ * @returns {Object} The created message object
273
+ */
274
+ sendDirect(from, to, content, { priority = 'normal' } = {}) {
275
+ // Check rate limits and loop detection
276
+ this._checkRateLimit(from);
277
+ this._checkLoopDetection(from, to);
278
+
279
+ // Create the message object
280
+ const message = {
281
+ id: crypto.randomUUID(),
282
+ from,
283
+ to,
284
+ type: 'direct',
285
+ content,
286
+ timestamp: new Date().toISOString(),
287
+ status: 'pending',
288
+ priority
289
+ };
290
+
291
+ // Append to recipient's inbox
292
+ const inboxPath = path.join(this.teamDir, 'inbox', `${to}.jsonl`);
293
+ appendJsonl(inboxPath, message);
294
+
295
+ return message;
296
+ }
297
+
298
+ /**
299
+ * Send a broadcast message to all team members
300
+ *
301
+ * @param {string} from - Sender name
302
+ * @param {string} content - Message content
303
+ * @param {Object} options - Message options
304
+ * @param {string} options.priority - Priority level ('normal' or 'urgent')
305
+ * @returns {Object} The created message object
306
+ */
307
+ sendBroadcast(from, content, { priority = 'normal' } = {}) {
308
+ // Check rate limits (loop detection doesn't apply to broadcasts)
309
+ this._checkRateLimit(from);
310
+
311
+ // Create the message object
312
+ const message = {
313
+ id: crypto.randomUUID(),
314
+ from,
315
+ to: 'all',
316
+ type: 'broadcast',
317
+ content,
318
+ timestamp: new Date().toISOString(),
319
+ status: 'pending',
320
+ priority
321
+ };
322
+
323
+ // Get all team members
324
+ const roster = this.getRoster();
325
+
326
+ // Append to every member's inbox (including sender for record-keeping)
327
+ for (const member of roster) {
328
+ const inboxPath = path.join(this.teamDir, 'inbox', `${member.name}.jsonl`);
329
+ appendJsonl(inboxPath, message);
330
+ }
331
+
332
+ return message;
333
+ }
334
+
335
+ // ========== INBOX METHODS ==========
336
+
337
+ /**
338
+ * Get messages from a member's inbox
339
+ *
340
+ * @param {string} name - Member name
341
+ * @param {Object} options - Inbox options
342
+ * @param {boolean} options.markRead - Whether to mark messages as read (default: false)
343
+ * @param {number} options.limit - Max number of messages to return (default: 20)
344
+ * @returns {Array} Array of message objects
345
+ */
346
+ getInbox(name, { markRead = false, limit = 20 } = {}) {
347
+ const inboxPath = path.join(this.teamDir, 'inbox', `${name}.jsonl`);
348
+
349
+ // Read the inbox file
350
+ let allMessages = [];
351
+
352
+ // Check if file exists
353
+ if (fs.existsSync(inboxPath)) {
354
+ // Read file and parse each line as JSON
355
+ const content = fs.readFileSync(inboxPath, 'utf-8');
356
+ const lines = content.trim().split('\n').filter(line => line.length > 0);
357
+
358
+ allMessages = lines.map(line => JSON.parse(line));
359
+
360
+ // Auto-compaction if total messages exceed 1000
361
+ if (allMessages.length > 1000) {
362
+ this.compactInbox(name);
363
+ // Re-read after compaction
364
+ const newContent = fs.readFileSync(inboxPath, 'utf-8');
365
+ const newLines = newContent.trim().split('\n').filter(line => line.length > 0);
366
+ allMessages = newLines.map(line => JSON.parse(line));
367
+ }
368
+ }
369
+
370
+ // Filter to only pending (unread) messages
371
+ const unreadMessages = allMessages.filter(msg => msg.status === 'pending');
372
+
373
+ // Sort by priority (urgent first), then timestamp
374
+ unreadMessages.sort((a, b) => {
375
+ // Priority comparison (urgent comes before normal)
376
+ if (a.priority === 'urgent' && b.priority !== 'urgent') return -1;
377
+ if (a.priority !== 'urgent' && b.priority === 'urgent') return 1;
378
+
379
+ // If same priority, sort by timestamp (oldest first)
380
+ return new Date(a.timestamp) - new Date(b.timestamp);
381
+ });
382
+
383
+ // Limit the results
384
+ const limitedMessages = unreadMessages.slice(0, limit);
385
+
386
+ // Mark as read if requested
387
+ if (markRead && limitedMessages.length > 0) {
388
+ // Create a set of IDs to mark as read
389
+ const idsToMark = new Set(limitedMessages.map(msg => msg.id));
390
+
391
+ // Update all messages
392
+ const updatedMessages = allMessages.map(msg => {
393
+ if (idsToMark.has(msg.id)) {
394
+ return { ...msg, status: 'read' };
395
+ }
396
+ return msg;
397
+ });
398
+
399
+ // Rewrite the file
400
+ const newContent = updatedMessages.map(msg => JSON.stringify(msg)).join('\n') + '\n';
401
+ fs.writeFileSync(inboxPath, newContent, 'utf-8');
402
+ }
403
+
404
+ return limitedMessages;
405
+ }
406
+
407
+ /**
408
+ * Get count of unread messages in inbox
409
+ *
410
+ * @param {string} name - Member name
411
+ * @returns {number} Count of unread messages
412
+ */
413
+ getUnreadCount(name) {
414
+ const inboxPath = path.join(this.teamDir, 'inbox', `${name}.jsonl`);
415
+
416
+ // If file doesn't exist, no unread messages
417
+ if (!fs.existsSync(inboxPath)) {
418
+ return 0;
419
+ }
420
+
421
+ // Read and count pending messages
422
+ const content = fs.readFileSync(inboxPath, 'utf-8');
423
+ const lines = content.trim().split('\n').filter(line => line.length > 0);
424
+
425
+ let count = 0;
426
+ for (const line of lines) {
427
+ const msg = JSON.parse(line);
428
+ if (msg.status === 'pending') {
429
+ count++;
430
+ }
431
+ }
432
+
433
+ return count;
434
+ }
435
+
436
+ /**
437
+ * Compact an inbox by removing old read messages
438
+ * Keeps all unread messages and the last N read messages
439
+ *
440
+ * @param {string} name - Member name
441
+ * @param {Object} options - Compaction options
442
+ * @param {number} options.keepLast - Number of read messages to keep (default: 200)
443
+ * @returns {Object} Object with removed and kept counts
444
+ */
445
+ compactInbox(name, { keepLast = 200 } = {}) {
446
+ const inboxPath = path.join(this.teamDir, 'inbox', `${name}.jsonl`);
447
+
448
+ // If file doesn't exist, nothing to compact
449
+ if (!fs.existsSync(inboxPath)) {
450
+ return { removed: 0, kept: 0 };
451
+ }
452
+
453
+ // Read all messages
454
+ const content = fs.readFileSync(inboxPath, 'utf-8');
455
+ const lines = content.trim().split('\n').filter(line => line.length > 0);
456
+ const allMessages = lines.map(line => JSON.parse(line));
457
+
458
+ // Separate unread and read messages
459
+ const unreadMessages = allMessages.filter(msg => msg.status === 'pending');
460
+ const readMessages = allMessages.filter(msg => msg.status === 'read');
461
+
462
+ // Keep only the last N read messages
463
+ const keptReadMessages = readMessages.slice(-keepLast);
464
+
465
+ // Combine: all unread + last N read
466
+ const keptMessages = [...unreadMessages, ...keptReadMessages];
467
+
468
+ // Write back to file
469
+ const newContent = keptMessages.map(msg => JSON.stringify(msg)).join('\n') + '\n';
470
+ fs.writeFileSync(inboxPath, newContent, 'utf-8');
471
+
472
+ return {
473
+ removed: allMessages.length - keptMessages.length,
474
+ kept: keptMessages.length
475
+ };
476
+ }
477
+
478
+ // ========== ASK/REPLY METHODS ==========
479
+
480
+ /**
481
+ * Create an ask (synchronous question) to another member
482
+ *
483
+ * @param {string} from - Asker name
484
+ * @param {string} to - Recipient name
485
+ * @param {string} question - The question text
486
+ * @param {number} timeout - Timeout in milliseconds (default: 30000)
487
+ * @returns {Object} Object with askId
488
+ */
489
+ createAsk(from, to, question, timeout = 30000) {
490
+ // Generate unique ID
491
+ const id = crypto.randomUUID();
492
+
493
+ // Create ask object
494
+ const ask = {
495
+ id,
496
+ from,
497
+ to,
498
+ question,
499
+ status: 'pending',
500
+ createdAt: new Date().toISOString(),
501
+ timeout
502
+ };
503
+
504
+ // Write ask file
505
+ const askPath = path.join(this.teamDir, 'asks', `ask_${id}.json`);
506
+ atomicWriteJson(askPath, ask);
507
+
508
+ // Also send as a message to recipient's inbox
509
+ const message = {
510
+ id: crypto.randomUUID(),
511
+ from,
512
+ to,
513
+ type: 'ask',
514
+ content: question,
515
+ askId: id,
516
+ timestamp: new Date().toISOString(),
517
+ status: 'pending',
518
+ priority: 'urgent' // Asks are urgent
519
+ };
520
+
521
+ const inboxPath = path.join(this.teamDir, 'inbox', `${to}.jsonl`);
522
+ appendJsonl(inboxPath, message);
523
+
524
+ return { askId: id };
525
+ }
526
+
527
+ /**
528
+ * Poll for a reply to an ask
529
+ * Uses exponential backoff: 1s, 2s, 4s, 8s, then 10s cap
530
+ *
531
+ * @param {string} askId - The ask ID to poll for
532
+ * @param {number} timeout - Total timeout in milliseconds (default: 30000)
533
+ * @returns {Promise<Object>} Promise that resolves with reply or timeout
534
+ */
535
+ pollForReply(askId, timeout = 30000) {
536
+ return new Promise((resolve) => {
537
+ const askPath = path.join(this.teamDir, 'asks', `ask_${askId}.json`);
538
+ const startTime = Date.now();
539
+
540
+ // Exponential backoff intervals: 1s, 2s, 4s, 8s, 10s cap
541
+ let backoffDelay = 1000; // Start with 1 second
542
+
543
+ const poll = () => {
544
+ // Check if timeout exceeded
545
+ if (Date.now() - startTime > timeout) {
546
+ resolve({
547
+ timeout: true,
548
+ suggestion: 'Session may be busy. Check inbox later.'
549
+ });
550
+ return;
551
+ }
552
+
553
+ // Try to read the ask file
554
+ const ask = readJsonSafe(askPath, null);
555
+
556
+ if (!ask) {
557
+ // File doesn't exist or can't be read
558
+ resolve({
559
+ timeout: true,
560
+ suggestion: 'Ask not found or was deleted.'
561
+ });
562
+ return;
563
+ }
564
+
565
+ // Check if replied
566
+ if (ask.status === 'replied') {
567
+ resolve({
568
+ reply: ask.answer,
569
+ from: ask.repliedBy
570
+ });
571
+ return;
572
+ }
573
+
574
+ // Schedule next poll with exponential backoff
575
+ setTimeout(poll, backoffDelay);
576
+
577
+ // Increase backoff delay (double it, max 10 seconds)
578
+ backoffDelay = Math.min(backoffDelay * 2, 10000);
579
+ };
580
+
581
+ // Start polling
582
+ poll();
583
+ });
584
+ }
585
+
586
+ /**
587
+ * Submit a reply to an ask
588
+ *
589
+ * @param {string} from - Replier name
590
+ * @param {string} askId - The ask ID to reply to
591
+ * @param {string} answer - The answer/reply
592
+ */
593
+ submitReply(from, askId, answer) {
594
+ const askPath = path.join(this.teamDir, 'asks', `ask_${askId}.json`);
595
+
596
+ // Read the ask
597
+ const ask = readJsonSafe(askPath, null);
598
+
599
+ if (!ask) {
600
+ throw new Error(`Ask ${askId} not found`);
601
+ }
602
+
603
+ // Update ask with reply
604
+ ask.status = 'replied';
605
+ ask.answer = answer;
606
+ ask.repliedAt = new Date().toISOString();
607
+ ask.repliedBy = from;
608
+
609
+ // Save updated ask
610
+ atomicWriteJson(askPath, ask);
611
+ }
612
+ }
613
+
614
+ // Export the TeamHub class
615
+ module.exports = TeamHub;