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.
- package/CHANGELOG.md +71 -0
- package/LICENSE +21 -0
- package/README.md +1006 -0
- package/STRATEGY.md +485 -0
- package/bin/cli.js +1756 -0
- package/bin/continuity-hook.js +118 -0
- package/bin/mcp.js +27 -0
- package/bin/setup.js +929 -0
- package/package.json +56 -0
- package/src/artifact-store.js +710 -0
- package/src/atomic-io.js +99 -0
- package/src/briefing-generator.js +451 -0
- package/src/continuity-hooks.js +253 -0
- package/src/contract-store.js +525 -0
- package/src/decision-journal.js +229 -0
- package/src/delegate.js +348 -0
- package/src/dependency-resolver.js +453 -0
- package/src/diff-engine.js +473 -0
- package/src/file-lock.js +161 -0
- package/src/index.js +61 -0
- package/src/lineage-graph.js +402 -0
- package/src/manager.js +510 -0
- package/src/mcp-server.js +3501 -0
- package/src/pattern-registry.js +221 -0
- package/src/pipeline-engine.js +618 -0
- package/src/prompts.js +1217 -0
- package/src/safety-net.js +170 -0
- package/src/session-snapshot.js +508 -0
- package/src/snapshot-engine.js +490 -0
- package/src/stale-detector.js +169 -0
- package/src/store.js +131 -0
- package/src/stream-session.js +463 -0
- package/src/team-hub.js +615 -0
package/src/team-hub.js
ADDED
|
@@ -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;
|