let-them-talk 3.6.2 → 3.8.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/README.md +87 -3
- package/cli.js +1 -1
- package/dashboard.html +7480 -7399
- package/dashboard.js +8 -3
- package/office/agents.js +8 -5
- package/office/animation.js +2 -1
- package/office/campus-env.js +41 -19
- package/office/environment.js +60 -67
- package/office/index.js +50 -0
- package/office/monitors.js +12 -3
- package/office/player.js +436 -0
- package/office/spectator-camera.js +30 -21
- package/package.json +1 -1
- package/server.js +1114 -52
package/server.js
CHANGED
|
@@ -18,6 +18,15 @@ const PROFILES_FILE = path.join(DATA_DIR, 'profiles.json');
|
|
|
18
18
|
const WORKFLOWS_FILE = path.join(DATA_DIR, 'workflows.json');
|
|
19
19
|
const WORKSPACES_DIR = path.join(DATA_DIR, 'workspaces');
|
|
20
20
|
const BRANCHES_FILE = path.join(DATA_DIR, 'branches.json');
|
|
21
|
+
const DECISIONS_FILE = path.join(DATA_DIR, 'decisions.json');
|
|
22
|
+
const KB_FILE = path.join(DATA_DIR, 'kb.json');
|
|
23
|
+
const LOCKS_FILE = path.join(DATA_DIR, 'locks.json');
|
|
24
|
+
const PROGRESS_FILE = path.join(DATA_DIR, 'progress.json');
|
|
25
|
+
const VOTES_FILE = path.join(DATA_DIR, 'votes.json');
|
|
26
|
+
const REVIEWS_FILE = path.join(DATA_DIR, 'reviews.json');
|
|
27
|
+
const DEPS_FILE = path.join(DATA_DIR, 'dependencies.json');
|
|
28
|
+
const REPUTATION_FILE = path.join(DATA_DIR, 'reputation.json');
|
|
29
|
+
const COMPRESSED_FILE = path.join(DATA_DIR, 'compressed.json');
|
|
21
30
|
// Plugins removed in v3.4.3 — unnecessary attack surface, CLIs have their own extension systems
|
|
22
31
|
|
|
23
32
|
// In-memory state for this process
|
|
@@ -63,7 +72,13 @@ function isGroupMode() {
|
|
|
63
72
|
}
|
|
64
73
|
|
|
65
74
|
function getGroupCooldown() {
|
|
66
|
-
|
|
75
|
+
// Adaptive cooldown: scales with online agent count — max(500, N * 500)
|
|
76
|
+
// 2 agents = 1s, 3 = 1.5s, 4 = 2s, 6 = 3s, 10 = 5s
|
|
77
|
+
const configured = getConfig().group_cooldown;
|
|
78
|
+
if (configured) return configured; // respect explicit config
|
|
79
|
+
const agents = getAgents();
|
|
80
|
+
const aliveCount = Object.values(agents).filter(a => isPidAlive(a.pid, a.last_activity)).length;
|
|
81
|
+
return Math.max(500, aliveCount * 500);
|
|
67
82
|
}
|
|
68
83
|
|
|
69
84
|
// --- Managed conversation mode ---
|
|
@@ -355,21 +370,33 @@ function autoCompact() {
|
|
|
355
370
|
|
|
356
371
|
const messages = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
357
372
|
|
|
358
|
-
// Collect
|
|
373
|
+
// Collect consumed IDs — for __group__ messages, only check ALIVE agents
|
|
374
|
+
const agents = getAgents();
|
|
375
|
+
const aliveAgentNames = Object.keys(agents).filter(n => isPidAlive(agents[n].pid, agents[n].last_activity));
|
|
359
376
|
const allConsumed = new Set();
|
|
377
|
+
const perAgentConsumed = {};
|
|
360
378
|
if (fs.existsSync(DATA_DIR)) {
|
|
361
379
|
for (const f of fs.readdirSync(DATA_DIR)) {
|
|
362
380
|
if (f.startsWith('consumed-') && f.endsWith('.json')) {
|
|
381
|
+
const agentName = f.replace('consumed-', '').replace('.json', '');
|
|
363
382
|
try {
|
|
364
383
|
const ids = JSON.parse(fs.readFileSync(path.join(DATA_DIR, f), 'utf8'));
|
|
384
|
+
perAgentConsumed[agentName] = new Set(ids);
|
|
365
385
|
ids.forEach(id => allConsumed.add(id));
|
|
366
386
|
} catch {}
|
|
367
387
|
}
|
|
368
388
|
}
|
|
369
389
|
}
|
|
370
390
|
|
|
371
|
-
// Keep
|
|
391
|
+
// Keep messages that are NOT fully consumed
|
|
392
|
+
// For __group__ messages: consumed when ALL ALIVE agents have consumed it (dead agents don't block)
|
|
393
|
+
// For direct messages: consumed when the recipient has consumed it
|
|
372
394
|
const active = messages.filter(m => {
|
|
395
|
+
if (m.to === '__group__') {
|
|
396
|
+
// __group__: check if all alive agents (except sender) have consumed
|
|
397
|
+
return !aliveAgentNames.every(n => n === m.from || (perAgentConsumed[n] && perAgentConsumed[n].has(m.id)));
|
|
398
|
+
}
|
|
399
|
+
// Direct: standard check
|
|
373
400
|
if (!allConsumed.has(m.id)) return true;
|
|
374
401
|
return false;
|
|
375
402
|
});
|
|
@@ -442,7 +469,9 @@ function getUnconsumedMessages(agentName, fromFilter = null) {
|
|
|
442
469
|
const consumed = getConsumedIds(agentName);
|
|
443
470
|
const perms = getPermissions();
|
|
444
471
|
return messages.filter(m => {
|
|
445
|
-
if (m.to !== agentName) return false;
|
|
472
|
+
if (m.to !== agentName && m.to !== '__group__' && m.to !== '__all__') return false;
|
|
473
|
+
// Skip own group messages
|
|
474
|
+
if (m.to === '__group__' && m.from === agentName) return false;
|
|
446
475
|
if (consumed.has(m.id)) return false;
|
|
447
476
|
if (fromFilter && m.from !== fromFilter && !m.system) return false;
|
|
448
477
|
// Permission check: skip messages from senders this agent can't read
|
|
@@ -621,11 +650,69 @@ function toolRegister(name, provider = null) {
|
|
|
621
650
|
}
|
|
622
651
|
}
|
|
623
652
|
}
|
|
653
|
+
// Clean up file locks held by dead agents
|
|
654
|
+
cleanStaleLocks();
|
|
624
655
|
} catch {}
|
|
625
656
|
}, 10000);
|
|
626
657
|
heartbeatInterval.unref(); // Don't prevent process exit
|
|
627
658
|
|
|
628
|
-
|
|
659
|
+
// Fire join event + recovery data for returning agents
|
|
660
|
+
const config = getConfig();
|
|
661
|
+
const mode = config.conversation_mode || 'direct';
|
|
662
|
+
const otherAgents = Object.keys(getAgents()).filter(n => n !== name);
|
|
663
|
+
|
|
664
|
+
const result = {
|
|
665
|
+
success: true,
|
|
666
|
+
message: `Registered as Agent ${name} (PID ${process.pid})`,
|
|
667
|
+
conversation_mode: mode,
|
|
668
|
+
agents_online: otherAgents,
|
|
669
|
+
guide: {
|
|
670
|
+
critical_rules: [
|
|
671
|
+
'AFTER EVERY ACTION YOU TAKE, call listen_group() (group/managed mode) or listen() (direct mode) immediately. This is how you receive messages. If you stop listening, you are invisible to the team.',
|
|
672
|
+
'Never send multiple messages in a row without calling listen_group() between them — you will miss responses.',
|
|
673
|
+
'Keep messages concise. 2-3 paragraphs max. No essays.',
|
|
674
|
+
'When you finish a task, report what you did AND what files you changed, then listen again.',
|
|
675
|
+
],
|
|
676
|
+
first_steps: mode === 'direct'
|
|
677
|
+
? '1. Call list_agents() to see who is online. 2. Send a message or call listen() to wait for one.'
|
|
678
|
+
: '1. Call get_briefing() for full project context. 2. Call listen_group() to join the conversation. 3. When you receive messages, respond and immediately call listen_group() again.',
|
|
679
|
+
tool_categories: {
|
|
680
|
+
'MESSAGING (always use these)': 'send_message, broadcast, listen_group (group/managed), listen (direct), check_messages, get_history, get_summary, handoff, share_file',
|
|
681
|
+
'TEAM COORDINATION': 'get_briefing (project overview), log_decision / get_decisions (prevent re-debating), kb_write / kb_read (shared knowledge), call_vote / cast_vote (team decisions)',
|
|
682
|
+
'TASK MANAGEMENT': 'create_task, update_task, list_tasks, declare_dependency, check_dependencies, suggest_task (what should I do next?)',
|
|
683
|
+
'PROGRESS & QUALITY': 'update_progress / get_progress (feature %), request_review / submit_review (code review), get_reputation (leaderboard)',
|
|
684
|
+
'FILE SAFETY': 'lock_file / unlock_file (prevent conflicts — ALWAYS lock before editing shared files)',
|
|
685
|
+
'PROFILES & WORKSPACES': 'update_profile, workspace_write / workspace_read (personal storage)',
|
|
686
|
+
'MANAGED MODE (if active)': 'claim_manager, yield_floor, set_phase — only the manager uses these',
|
|
687
|
+
},
|
|
688
|
+
patterns: {
|
|
689
|
+
'Starting work': 'get_briefing → check list_tasks → claim a task with update_task(id, "in_progress") → lock_file → do the work → unlock_file → update_task(id, "done") → listen_group',
|
|
690
|
+
'Sharing knowledge': 'kb_write("api-schema", "POST /auth → {token}") — so others can kb_read it without asking you',
|
|
691
|
+
'Making decisions': 'log_decision("Use PostgreSQL", "Better JSON support") — so no one re-debates this later',
|
|
692
|
+
'Disagreements': 'call_vote("Use Redis for caching?", ["yes", "no"]) — let the team decide democratically',
|
|
693
|
+
'Code review': 'request_review("src/auth.ts", "Check token expiry logic") — another agent will review and approve/request changes',
|
|
694
|
+
},
|
|
695
|
+
},
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
// Recovery: if this agent has prior data, include it
|
|
699
|
+
const myTasks = getTasks().filter(t => t.assignee === name && t.status !== 'done');
|
|
700
|
+
const myWorkspace = getWorkspace(name);
|
|
701
|
+
const recentHistory = readJsonl(getHistoryFile(currentBranch));
|
|
702
|
+
const myRecentMsgs = recentHistory.filter(m => m.to === name || m.from === name).slice(-5);
|
|
703
|
+
|
|
704
|
+
if (myTasks.length > 0 || Object.keys(myWorkspace).length > 0 || myRecentMsgs.length > 0) {
|
|
705
|
+
result.recovery = {};
|
|
706
|
+
if (myTasks.length > 0) result.recovery.your_active_tasks = myTasks.map(t => ({ id: t.id, title: t.title, status: t.status }));
|
|
707
|
+
if (Object.keys(myWorkspace).length > 0) result.recovery.your_workspace_keys = Object.keys(myWorkspace);
|
|
708
|
+
if (myRecentMsgs.length > 0) result.recovery.recent_messages = myRecentMsgs.map(m => ({ from: m.from, to: m.to, preview: m.content.substring(0, 100), timestamp: m.timestamp }));
|
|
709
|
+
result.recovery.hint = 'You have prior context from a previous session. Call get_briefing() for a full project summary.';
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Notify other agents
|
|
713
|
+
fireEvent('agent_join', { agent: name });
|
|
714
|
+
|
|
715
|
+
return result;
|
|
629
716
|
} finally {
|
|
630
717
|
unlockAgentsFile();
|
|
631
718
|
}
|
|
@@ -650,6 +737,10 @@ function setListening(isListening) {
|
|
|
650
737
|
const agents = getAgents();
|
|
651
738
|
if (agents[registeredName]) {
|
|
652
739
|
agents[registeredName].listening_since = isListening ? new Date().toISOString() : null;
|
|
740
|
+
// Persist last_listened_at so other agents can detect unresponsive agents
|
|
741
|
+
if (isListening) {
|
|
742
|
+
agents[registeredName].last_listened_at = new Date().toISOString();
|
|
743
|
+
}
|
|
653
744
|
saveAgents(agents);
|
|
654
745
|
}
|
|
655
746
|
} catch {}
|
|
@@ -672,6 +763,7 @@ function toolListAgents() {
|
|
|
672
763
|
status: !alive ? 'dead' : idleSeconds > 60 ? 'sleeping' : 'active',
|
|
673
764
|
listening_since: info.listening_since || null,
|
|
674
765
|
is_listening: !!(info.listening_since && alive),
|
|
766
|
+
last_listened_at: info.last_listened_at || null,
|
|
675
767
|
provider: info.provider || 'unknown',
|
|
676
768
|
branch: info.branch || 'main',
|
|
677
769
|
display_name: profile.display_name || name,
|
|
@@ -796,13 +888,16 @@ async function toolSendMessage(content, to = null, reply_to = null) {
|
|
|
796
888
|
}
|
|
797
889
|
|
|
798
890
|
messageSeq++;
|
|
891
|
+
// In group mode: rewrite to → __group__, original to becomes addressed_to
|
|
892
|
+
const isGroup = isGroupMode() && !isManagedMode();
|
|
799
893
|
const msg = {
|
|
800
894
|
id: generateId(),
|
|
801
895
|
seq: messageSeq,
|
|
802
896
|
from: registeredName,
|
|
803
|
-
to,
|
|
897
|
+
to: isGroup ? '__group__' : to,
|
|
804
898
|
content,
|
|
805
899
|
timestamp: new Date().toISOString(),
|
|
900
|
+
...(isGroup && to && { addressed_to: [to] }),
|
|
806
901
|
...(reply_to && { reply_to }),
|
|
807
902
|
...(thread_id && { thread_id }),
|
|
808
903
|
};
|
|
@@ -813,18 +908,8 @@ async function toolSendMessage(content, to = null, reply_to = null) {
|
|
|
813
908
|
touchActivity();
|
|
814
909
|
lastSentAt = Date.now();
|
|
815
910
|
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
// NEVER auto-broadcast in managed mode — manager controls communication flow
|
|
819
|
-
if (isGroupMode() && !isManagedMode() && !reply_to && !msg.broadcast) {
|
|
820
|
-
const otherRecipients = Object.keys(getAgents()).filter(n => n !== registeredName && n !== to);
|
|
821
|
-
for (const other of otherRecipients) {
|
|
822
|
-
if (!canSendTo(registeredName, other)) continue; // respect permissions
|
|
823
|
-
const broadcastMsg = { ...msg, id: generateId(), to: other, broadcast: true, original_to: to };
|
|
824
|
-
fs.appendFileSync(getMessagesFile(currentBranch), JSON.stringify(broadcastMsg) + '\n');
|
|
825
|
-
fs.appendFileSync(getHistoryFile(currentBranch), JSON.stringify(broadcastMsg) + '\n');
|
|
826
|
-
}
|
|
827
|
-
}
|
|
911
|
+
// Group mode: O(N) auto-broadcast REMOVED. Messages now use __group__ single-write.
|
|
912
|
+
// The to→__group__ rewrite happens above when the message is created.
|
|
828
913
|
|
|
829
914
|
// Managed mode: auto-advance turns after non-manager sends
|
|
830
915
|
if (isManagedMode()) {
|
|
@@ -875,6 +960,12 @@ async function toolSendMessage(content, to = null, reply_to = null) {
|
|
|
875
960
|
result.note = `Agent "${to}" is currently working (not in listen mode). Message queued — they'll see it when they finish their current task and call listen_group().`;
|
|
876
961
|
}
|
|
877
962
|
|
|
963
|
+
// Mode awareness hint: warn if agent seems to be in wrong mode
|
|
964
|
+
const currentMode = getConfig().conversation_mode || 'direct';
|
|
965
|
+
if (currentMode === 'group' || currentMode === 'managed') {
|
|
966
|
+
result.mode_hint = `You're in ${currentMode} mode. Use listen_group() (or listen() — both auto-detect) to stay in the conversation.`;
|
|
967
|
+
}
|
|
968
|
+
|
|
878
969
|
// Nudge: check if THIS agent has unread messages waiting
|
|
879
970
|
const myPending = getUnconsumedMessages(registeredName);
|
|
880
971
|
if (myPending.length > 0) {
|
|
@@ -911,6 +1002,32 @@ function toolBroadcast(content) {
|
|
|
911
1002
|
}
|
|
912
1003
|
|
|
913
1004
|
ensureDataDir();
|
|
1005
|
+
|
|
1006
|
+
// In group mode: single __group__ write instead of per-agent copies
|
|
1007
|
+
if (isGroupMode() && !isManagedMode()) {
|
|
1008
|
+
messageSeq++;
|
|
1009
|
+
const msg = {
|
|
1010
|
+
id: generateId(),
|
|
1011
|
+
seq: messageSeq,
|
|
1012
|
+
from: registeredName,
|
|
1013
|
+
to: '__group__',
|
|
1014
|
+
content,
|
|
1015
|
+
timestamp: new Date().toISOString(),
|
|
1016
|
+
broadcast: true,
|
|
1017
|
+
};
|
|
1018
|
+
fs.appendFileSync(getMessagesFile(currentBranch), JSON.stringify(msg) + '\n');
|
|
1019
|
+
fs.appendFileSync(getHistoryFile(currentBranch), JSON.stringify(msg) + '\n');
|
|
1020
|
+
touchActivity();
|
|
1021
|
+
lastSentAt = Date.now();
|
|
1022
|
+
const aliveOthers = otherAgents.filter(n => { const a = agents[n]; return isPidAlive(a.pid, a.last_activity); });
|
|
1023
|
+
const result = { success: true, messageId: msg.id, recipient_count: aliveOthers.length, sent_to: aliveOthers.map(n => ({ to: n, messageId: msg.id })) };
|
|
1024
|
+
// Nudge for own unread messages
|
|
1025
|
+
const myPending = getUnconsumedMessages(registeredName);
|
|
1026
|
+
if (myPending.length > 0) { result.you_have_messages = myPending.length; result.urgent = `You have ${myPending.length} unread message(s). Call listen_group() soon.`; }
|
|
1027
|
+
return result;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Direct/managed mode: per-agent writes (original behavior)
|
|
914
1031
|
const ids = [];
|
|
915
1032
|
const skipped = [];
|
|
916
1033
|
for (const to of otherAgents) {
|
|
@@ -1058,6 +1175,12 @@ async function toolListen(from = null) {
|
|
|
1058
1175
|
return { error: 'You must call register() first' };
|
|
1059
1176
|
}
|
|
1060
1177
|
|
|
1178
|
+
// Auto-detect group/managed mode and delegate to toolListenGroup
|
|
1179
|
+
// This prevents agents from calling the "wrong" listen function
|
|
1180
|
+
if (isGroupMode() || isManagedMode()) {
|
|
1181
|
+
return toolListenGroup();
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1061
1184
|
setListening(true);
|
|
1062
1185
|
|
|
1063
1186
|
// Check for existing unconsumed messages first
|
|
@@ -1201,6 +1324,11 @@ function toolSetConversationMode(mode) {
|
|
|
1201
1324
|
}
|
|
1202
1325
|
saveConfig(config);
|
|
1203
1326
|
|
|
1327
|
+
// Notify all agents about mode change (managed mode already broadcasts above)
|
|
1328
|
+
if (mode !== 'managed') {
|
|
1329
|
+
broadcastSystemMessage(`[MODE] Conversation switched to ${mode} mode by ${registeredName}. ${mode === 'group' ? 'All messages are now shared with everyone.' : 'Messages are now point-to-point.'}`, registeredName);
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1204
1332
|
const messages = {
|
|
1205
1333
|
group: 'Group mode enabled. Use listen_group() to receive batched messages. All messages are shared with everyone.',
|
|
1206
1334
|
direct: 'Direct mode enabled. Use listen() for point-to-point messaging.',
|
|
@@ -1343,26 +1471,38 @@ function toolSetPhase(phase) {
|
|
|
1343
1471
|
};
|
|
1344
1472
|
}
|
|
1345
1473
|
|
|
1346
|
-
|
|
1474
|
+
// Deterministic stagger delay based on agent name (500-1500ms)
|
|
1475
|
+
// Same agent always gets the same delay, making response ordering predictable
|
|
1476
|
+
function hashStagger(name) {
|
|
1477
|
+
const hash = name.split('').reduce((h, c) => h + c.charCodeAt(0), 0);
|
|
1478
|
+
return 500 + (hash * 137) % 1000; // 0.5-1.5s range
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
async function toolListenGroup() {
|
|
1347
1482
|
if (!registeredName) return { error: 'You must call register() first' };
|
|
1348
|
-
const timeoutMs = Math.min(Math.max(1, timeout_seconds || 300), 3600) * 1000;
|
|
1349
1483
|
|
|
1350
|
-
|
|
1484
|
+
// Auto-detect direct mode and delegate to toolListen (prevents wrong-function bugs)
|
|
1485
|
+
if (!isGroupMode() && !isManagedMode()) {
|
|
1486
|
+
return toolListen();
|
|
1487
|
+
}
|
|
1351
1488
|
|
|
1352
|
-
|
|
1353
|
-
const stagger = 1000 + Math.random() * 2000;
|
|
1354
|
-
await new Promise(r => setTimeout(r, stagger));
|
|
1489
|
+
setListening(true);
|
|
1355
1490
|
|
|
1356
|
-
const deadline = Date.now() + timeoutMs;
|
|
1357
1491
|
const consumed = getConsumedIds(registeredName);
|
|
1358
1492
|
|
|
1359
|
-
|
|
1360
|
-
|
|
1493
|
+
// Poll indefinitely (in 5-min chunks to stay within any MCP limits, same as listen())
|
|
1494
|
+
while (true) {
|
|
1495
|
+
const chunkDeadline = Date.now() + 300000;
|
|
1496
|
+
|
|
1497
|
+
while (Date.now() < chunkDeadline) {
|
|
1498
|
+
// Collect ALL unconsumed messages: direct to us, __group__ (everyone), __all__, or system
|
|
1361
1499
|
const messages = readJsonl(getMessagesFile(currentBranch));
|
|
1362
1500
|
const batch = [];
|
|
1363
1501
|
for (const msg of messages) {
|
|
1364
1502
|
if (consumed.has(msg.id)) continue;
|
|
1365
|
-
|
|
1503
|
+
// Skip own messages in group mode (agent already knows what it sent)
|
|
1504
|
+
if (msg.to === '__group__' && msg.from === registeredName) { consumed.add(msg.id); continue; }
|
|
1505
|
+
if (msg.to !== registeredName && msg.to !== '__all__' && msg.to !== '__group__') continue;
|
|
1366
1506
|
// Permission check
|
|
1367
1507
|
const perms = getPermissions();
|
|
1368
1508
|
if (perms[registeredName] && perms[registeredName].can_read) {
|
|
@@ -1379,6 +1519,42 @@ async function toolListenGroup(timeout_seconds = 300) {
|
|
|
1379
1519
|
touchActivity();
|
|
1380
1520
|
setListening(false);
|
|
1381
1521
|
|
|
1522
|
+
// Post-receive stagger: deterministic delay based on agent name
|
|
1523
|
+
// Prevents all agents from responding simultaneously to the same batch
|
|
1524
|
+
const staggerMs = hashStagger(registeredName);
|
|
1525
|
+
if (staggerMs > 0) {
|
|
1526
|
+
await new Promise(r => setTimeout(r, staggerMs));
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// Sort batch by priority: system > threaded replies > direct > broadcast
|
|
1530
|
+
// Within each category, maintain chronological order
|
|
1531
|
+
function messagePriority(m) {
|
|
1532
|
+
if (m.system || m.from === '__system__') return 0;
|
|
1533
|
+
if (m.reply_to || m.thread_id) return 1;
|
|
1534
|
+
if (!m.broadcast) return 2;
|
|
1535
|
+
return 3;
|
|
1536
|
+
}
|
|
1537
|
+
batch.sort((a, b) => {
|
|
1538
|
+
const pa = messagePriority(a), pb = messagePriority(b);
|
|
1539
|
+
if (pa !== pb) return pa - pb;
|
|
1540
|
+
return new Date(a.timestamp) - new Date(b.timestamp);
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
// Build batch summary for triage
|
|
1544
|
+
const summaryCounts = {};
|
|
1545
|
+
for (const m of batch) {
|
|
1546
|
+
const type = m.system || m.from === '__system__' ? 'system'
|
|
1547
|
+
: m.broadcast ? 'broadcast' : (m.reply_to || m.thread_id) ? 'thread' : 'direct';
|
|
1548
|
+
const key = `${m.from}:${type}`;
|
|
1549
|
+
summaryCounts[key] = (summaryCounts[key] || 0) + 1;
|
|
1550
|
+
}
|
|
1551
|
+
const summaryParts = [];
|
|
1552
|
+
for (const [key, count] of Object.entries(summaryCounts)) {
|
|
1553
|
+
const [from, type] = key.split(':');
|
|
1554
|
+
summaryParts.push(`${count} ${type} from ${from}`);
|
|
1555
|
+
}
|
|
1556
|
+
const batchSummary = `${batch.length} messages: ${summaryParts.join(', ')}`;
|
|
1557
|
+
|
|
1382
1558
|
// Get recent history for context
|
|
1383
1559
|
const history = readJsonl(getHistoryFile(currentBranch));
|
|
1384
1560
|
const recentHistory = history.slice(-20).map(m => ({
|
|
@@ -1404,14 +1580,33 @@ async function toolListenGroup(timeout_seconds = 300) {
|
|
|
1404
1580
|
...(ageSec > 30 && { delayed: true }),
|
|
1405
1581
|
...(m.reply_to && { reply_to: m.reply_to }),
|
|
1406
1582
|
...(m.thread_id && { thread_id: m.thread_id }),
|
|
1583
|
+
// addressed_to hint for group messages
|
|
1584
|
+
...(m.addressed_to && { addressed_to: m.addressed_to }),
|
|
1585
|
+
...(m.to === '__group__' && {
|
|
1586
|
+
addressed_to_you: !m.addressed_to || m.addressed_to.includes(registeredName),
|
|
1587
|
+
should_respond: !m.addressed_to || m.addressed_to.includes(registeredName),
|
|
1588
|
+
}),
|
|
1407
1589
|
};
|
|
1408
1590
|
}),
|
|
1409
1591
|
message_count: batch.length,
|
|
1592
|
+
batch_summary: batchSummary,
|
|
1410
1593
|
context: recentHistory,
|
|
1411
1594
|
agents_online: agentNames.length,
|
|
1412
1595
|
agents_silent: silent,
|
|
1413
1596
|
agents_status: agentNames.reduce(function(acc, n) {
|
|
1414
|
-
|
|
1597
|
+
if (agents[n].listening_since) {
|
|
1598
|
+
acc[n] = 'listening';
|
|
1599
|
+
} else {
|
|
1600
|
+
// Check for unresponsive: not listening, >2min since last listen, has pending messages
|
|
1601
|
+
const lastListened = agents[n].last_listened_at;
|
|
1602
|
+
const sinceLastListen = lastListened ? Date.now() - new Date(lastListened).getTime() : Infinity;
|
|
1603
|
+
const pendingForAgent = getUnconsumedMessages(n);
|
|
1604
|
+
if (sinceLastListen > 120000 && pendingForAgent.length > 0) {
|
|
1605
|
+
acc[n] = 'unresponsive';
|
|
1606
|
+
} else {
|
|
1607
|
+
acc[n] = 'working';
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1415
1610
|
return acc;
|
|
1416
1611
|
}, {}),
|
|
1417
1612
|
hint: silent.length > 0
|
|
@@ -1455,15 +1650,8 @@ async function toolListenGroup(timeout_seconds = 300) {
|
|
|
1455
1650
|
|
|
1456
1651
|
await adaptiveSleep(0);
|
|
1457
1652
|
}
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
return {
|
|
1461
|
-
timeout: true,
|
|
1462
|
-
retry: true,
|
|
1463
|
-
message: 'No messages yet. Call listen_group() again immediately to keep listening. Do NOT stop — you must stay in the conversation.',
|
|
1464
|
-
messages: [],
|
|
1465
|
-
message_count: 0,
|
|
1466
|
-
};
|
|
1653
|
+
// No message in this 5-min chunk — loop again (stay listening forever)
|
|
1654
|
+
}
|
|
1467
1655
|
}
|
|
1468
1656
|
|
|
1469
1657
|
function toolGetHistory(limit = 50, thread_id = null) {
|
|
@@ -1718,6 +1906,23 @@ function toolUpdateTask(taskId, status, notes = null) {
|
|
|
1718
1906
|
saveTasks(tasks);
|
|
1719
1907
|
touchActivity();
|
|
1720
1908
|
|
|
1909
|
+
// Event hooks: task completion
|
|
1910
|
+
if (status === 'done') {
|
|
1911
|
+
fireEvent('task_complete', { title: task.title, created_by: task.created_by });
|
|
1912
|
+
// Check if this resolves any dependencies
|
|
1913
|
+
const deps = getDeps();
|
|
1914
|
+
for (const dep of deps) {
|
|
1915
|
+
if (dep.depends_on === taskId && !dep.resolved) {
|
|
1916
|
+
dep.resolved = true;
|
|
1917
|
+
const blockedTask = tasks.find(t => t.id === dep.task_id);
|
|
1918
|
+
if (blockedTask && blockedTask.assignee) {
|
|
1919
|
+
fireEvent('dependency_met', { task_title: task.title, notify: blockedTask.assignee });
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
writeJsonFile(DEPS_FILE, deps);
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1721
1926
|
return { success: true, task_id: task.id, status: task.status, title: task.title };
|
|
1722
1927
|
}
|
|
1723
1928
|
|
|
@@ -1800,8 +2005,8 @@ function toolReset() {
|
|
|
1800
2005
|
}
|
|
1801
2006
|
}
|
|
1802
2007
|
}
|
|
1803
|
-
// Remove profiles, workflows, branches, permissions, read receipts
|
|
1804
|
-
for (const f of [PROFILES_FILE, WORKFLOWS_FILE, BRANCHES_FILE, PERMISSIONS_FILE, READ_RECEIPTS_FILE, CONFIG_FILE]) {
|
|
2008
|
+
// Remove profiles, workflows, branches, permissions, read receipts, and new ecosystem files
|
|
2009
|
+
for (const f of [PROFILES_FILE, WORKFLOWS_FILE, BRANCHES_FILE, PERMISSIONS_FILE, READ_RECEIPTS_FILE, CONFIG_FILE, DECISIONS_FILE, KB_FILE, LOCKS_FILE, PROGRESS_FILE, VOTES_FILE, REVIEWS_FILE, DEPS_FILE, REPUTATION_FILE, COMPRESSED_FILE]) {
|
|
1805
2010
|
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
1806
2011
|
}
|
|
1807
2012
|
// Remove workspaces dir
|
|
@@ -2131,10 +2336,659 @@ function toolListBranches() {
|
|
|
2131
2336
|
return { branches: result, current: currentBranch };
|
|
2132
2337
|
}
|
|
2133
2338
|
|
|
2339
|
+
// --- Tier 1: Briefing, File Locking, Decisions, Recovery ---
|
|
2340
|
+
|
|
2341
|
+
// Helpers for new data files
|
|
2342
|
+
function readJsonFile(file) { if (!fs.existsSync(file)) return null; try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }
|
|
2343
|
+
function writeJsonFile(file, data) { ensureDataDir(); fs.writeFileSync(file, JSON.stringify(data, null, 2)); }
|
|
2344
|
+
|
|
2345
|
+
function getDecisions() { return readJsonFile(DECISIONS_FILE) || []; }
|
|
2346
|
+
function getKB() { return readJsonFile(KB_FILE) || {}; }
|
|
2347
|
+
function getLocks() { return readJsonFile(LOCKS_FILE) || {}; }
|
|
2348
|
+
function getProgressData() { return readJsonFile(PROGRESS_FILE) || {}; }
|
|
2349
|
+
function getVotes() { return readJsonFile(VOTES_FILE) || []; }
|
|
2350
|
+
function getReviews() { return readJsonFile(REVIEWS_FILE) || []; }
|
|
2351
|
+
function getDeps() { return readJsonFile(DEPS_FILE) || []; }
|
|
2352
|
+
|
|
2353
|
+
// Auto-cleanup dead agent locks (called from heartbeat)
|
|
2354
|
+
function cleanStaleLocks() {
|
|
2355
|
+
const locks = getLocks();
|
|
2356
|
+
const agents = getAgents();
|
|
2357
|
+
let changed = false;
|
|
2358
|
+
for (const [filePath, lock] of Object.entries(locks)) {
|
|
2359
|
+
if (!agents[lock.agent] || !isPidAlive(agents[lock.agent].pid, agents[lock.agent].last_activity)) {
|
|
2360
|
+
delete locks[filePath];
|
|
2361
|
+
changed = true;
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
if (changed) writeJsonFile(LOCKS_FILE, locks);
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
// Event hook: fire system messages based on events
|
|
2368
|
+
function fireEvent(eventName, data) {
|
|
2369
|
+
const agents = getAgents();
|
|
2370
|
+
const aliveAgents = Object.keys(agents).filter(n => isPidAlive(agents[n].pid, agents[n].last_activity));
|
|
2371
|
+
|
|
2372
|
+
switch (eventName) {
|
|
2373
|
+
case 'agent_join': {
|
|
2374
|
+
// Notify existing agents
|
|
2375
|
+
for (const name of aliveAgents) {
|
|
2376
|
+
if (name === data.agent) continue;
|
|
2377
|
+
sendSystemMessage(name, `[EVENT] ${data.agent} has joined the team. They are now online.`);
|
|
2378
|
+
}
|
|
2379
|
+
break;
|
|
2380
|
+
}
|
|
2381
|
+
case 'task_complete': {
|
|
2382
|
+
// Notify task creator
|
|
2383
|
+
if (data.created_by && data.created_by !== registeredName && agents[data.created_by]) {
|
|
2384
|
+
sendSystemMessage(data.created_by, `[EVENT] Task "${data.title}" completed by ${registeredName}.`);
|
|
2385
|
+
}
|
|
2386
|
+
// Check if all tasks done
|
|
2387
|
+
const allTasks = getTasks();
|
|
2388
|
+
const pending = allTasks.filter(t => t.status !== 'done');
|
|
2389
|
+
if (pending.length === 0 && allTasks.length > 0) {
|
|
2390
|
+
broadcastSystemMessage(`[EVENT] All ${allTasks.length} tasks are complete! Consider starting a review phase.`);
|
|
2391
|
+
}
|
|
2392
|
+
break;
|
|
2393
|
+
}
|
|
2394
|
+
case 'dependency_met': {
|
|
2395
|
+
if (data.notify && agents[data.notify]) {
|
|
2396
|
+
sendSystemMessage(data.notify, `[EVENT] Dependency resolved: "${data.task_title}" is done. You can now proceed with your blocked task.`);
|
|
2397
|
+
}
|
|
2398
|
+
break;
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
function toolGetGuide() {
|
|
2404
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
2405
|
+
const config = getConfig();
|
|
2406
|
+
const mode = config.conversation_mode || 'direct';
|
|
2407
|
+
return {
|
|
2408
|
+
your_name: registeredName,
|
|
2409
|
+
conversation_mode: mode,
|
|
2410
|
+
critical_rules: [
|
|
2411
|
+
'AFTER EVERY ACTION, call listen_group() (group/managed) or listen() (direct). This is how you receive messages.',
|
|
2412
|
+
'Never send multiple messages without listening between them.',
|
|
2413
|
+
'Keep messages concise — 2-3 paragraphs max.',
|
|
2414
|
+
'When you finish a task, report what you did + files changed, then listen again.',
|
|
2415
|
+
'ALWAYS lock_file() before editing shared files, unlock_file() when done.',
|
|
2416
|
+
'Use log_decision() for any team decisions so they are not re-debated.',
|
|
2417
|
+
'Use kb_write() to share knowledge (API specs, conventions) so others can read without asking.',
|
|
2418
|
+
],
|
|
2419
|
+
tool_categories: {
|
|
2420
|
+
'MESSAGING': 'send_message, broadcast, listen_group, listen, check_messages, get_history, get_summary, handoff, share_file',
|
|
2421
|
+
'COORDINATION': 'get_briefing, log_decision, get_decisions, kb_write, kb_read, kb_list, call_vote, cast_vote, vote_status',
|
|
2422
|
+
'TASKS': 'create_task, update_task, list_tasks, declare_dependency, check_dependencies, suggest_task',
|
|
2423
|
+
'QUALITY': 'update_progress, get_progress, request_review, submit_review, get_reputation',
|
|
2424
|
+
'SAFETY': 'lock_file, unlock_file',
|
|
2425
|
+
'MANAGED MODE': 'claim_manager, yield_floor, set_phase (manager only)',
|
|
2426
|
+
},
|
|
2427
|
+
workflow: '1. get_briefing → 2. check list_tasks/suggest_task → 3. claim task → 4. lock_file → 5. do work → 6. unlock_file → 7. update_task done → 8. listen_group',
|
|
2428
|
+
};
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
function toolGetBriefing() {
|
|
2432
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
2433
|
+
|
|
2434
|
+
const agents = getAgents();
|
|
2435
|
+
const profiles = getProfiles();
|
|
2436
|
+
const tasks = getTasks();
|
|
2437
|
+
const decisions = getDecisions();
|
|
2438
|
+
const kb = getKB();
|
|
2439
|
+
const progress = getProgressData();
|
|
2440
|
+
const history = readJsonl(getHistoryFile(currentBranch));
|
|
2441
|
+
const locks = getLocks();
|
|
2442
|
+
const config = getConfig();
|
|
2443
|
+
|
|
2444
|
+
// Agent roster
|
|
2445
|
+
const roster = {};
|
|
2446
|
+
for (const [name, info] of Object.entries(agents)) {
|
|
2447
|
+
const alive = isPidAlive(info.pid, info.last_activity);
|
|
2448
|
+
const profile = profiles[name] || {};
|
|
2449
|
+
roster[name] = {
|
|
2450
|
+
status: !alive ? 'offline' : info.listening_since ? 'listening' : 'working',
|
|
2451
|
+
role: profile.role || '',
|
|
2452
|
+
provider: info.provider || 'unknown',
|
|
2453
|
+
};
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
// Recent messages summary (last 15)
|
|
2457
|
+
const recentMsgs = history.slice(-15).map(m => ({
|
|
2458
|
+
from: m.from, to: m.to,
|
|
2459
|
+
preview: m.content.substring(0, 150),
|
|
2460
|
+
timestamp: m.timestamp,
|
|
2461
|
+
}));
|
|
2462
|
+
|
|
2463
|
+
// Active tasks
|
|
2464
|
+
const activeTasks = tasks.filter(t => t.status !== 'done').map(t => ({
|
|
2465
|
+
id: t.id, title: t.title, status: t.status, assignee: t.assignee, created_by: t.created_by,
|
|
2466
|
+
}));
|
|
2467
|
+
const doneTasks = tasks.filter(t => t.status === 'done').length;
|
|
2468
|
+
|
|
2469
|
+
// Locked files
|
|
2470
|
+
const lockedFiles = {};
|
|
2471
|
+
for (const [fp, lock] of Object.entries(locks)) {
|
|
2472
|
+
lockedFiles[fp] = { locked_by: lock.agent, since: lock.since };
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
// Project files summary (scan cwd for key files)
|
|
2476
|
+
const projectFiles = [];
|
|
2477
|
+
try {
|
|
2478
|
+
const cwd = process.cwd();
|
|
2479
|
+
const scan = function(dir, depth) {
|
|
2480
|
+
if (depth > 2) return;
|
|
2481
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
2482
|
+
for (const e of entries) {
|
|
2483
|
+
if (e.name.startsWith('.') || e.name === 'node_modules') continue;
|
|
2484
|
+
const rel = path.relative(cwd, path.join(dir, e.name));
|
|
2485
|
+
if (e.isDirectory()) { projectFiles.push(rel + '/'); scan(path.join(dir, e.name), depth + 1); }
|
|
2486
|
+
else if (e.isFile()) projectFiles.push(rel);
|
|
2487
|
+
}
|
|
2488
|
+
};
|
|
2489
|
+
scan(cwd, 0);
|
|
2490
|
+
} catch {}
|
|
2491
|
+
|
|
2492
|
+
return {
|
|
2493
|
+
briefing: true,
|
|
2494
|
+
conversation_mode: config.conversation_mode || 'direct',
|
|
2495
|
+
agents: roster,
|
|
2496
|
+
your_name: registeredName,
|
|
2497
|
+
total_messages: history.length,
|
|
2498
|
+
recent_messages: recentMsgs,
|
|
2499
|
+
tasks: { active: activeTasks, completed_count: doneTasks, total: tasks.length },
|
|
2500
|
+
decisions: decisions.slice(-10),
|
|
2501
|
+
knowledge_base_keys: Object.keys(kb),
|
|
2502
|
+
locked_files: lockedFiles,
|
|
2503
|
+
progress,
|
|
2504
|
+
project_files: projectFiles.slice(0, 80),
|
|
2505
|
+
hint: 'You are now fully briefed. Check active tasks, read recent messages for context, and start contributing.',
|
|
2506
|
+
};
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
function toolLockFile(filePath) {
|
|
2510
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
2511
|
+
if (typeof filePath !== 'string' || filePath.length < 1 || filePath.length > 200) return { error: 'Invalid file path' };
|
|
2512
|
+
|
|
2513
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
2514
|
+
const locks = getLocks();
|
|
2515
|
+
|
|
2516
|
+
if (locks[normalized]) {
|
|
2517
|
+
const holder = locks[normalized].agent;
|
|
2518
|
+
if (holder === registeredName) return { success: true, message: 'You already hold this lock.', file: normalized };
|
|
2519
|
+
// Check if holder is still alive
|
|
2520
|
+
const agents = getAgents();
|
|
2521
|
+
if (agents[holder] && isPidAlive(agents[holder].pid, agents[holder].last_activity)) {
|
|
2522
|
+
return { error: `File "${normalized}" is locked by ${holder} since ${locks[normalized].since}. Wait for them to unlock it or message them.` };
|
|
2523
|
+
}
|
|
2524
|
+
// Dead holder — take over
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
locks[normalized] = { agent: registeredName, since: new Date().toISOString() };
|
|
2528
|
+
writeJsonFile(LOCKS_FILE, locks);
|
|
2529
|
+
touchActivity();
|
|
2530
|
+
return { success: true, file: normalized, message: `File locked. Other agents cannot edit "${normalized}" until you call unlock_file().` };
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
function toolUnlockFile(filePath) {
|
|
2534
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
2535
|
+
const normalized = (filePath || '').replace(/\\/g, '/');
|
|
2536
|
+
const locks = getLocks();
|
|
2537
|
+
|
|
2538
|
+
if (!filePath) {
|
|
2539
|
+
// Unlock ALL files held by this agent
|
|
2540
|
+
let count = 0;
|
|
2541
|
+
for (const [fp, lock] of Object.entries(locks)) {
|
|
2542
|
+
if (lock.agent === registeredName) { delete locks[fp]; count++; }
|
|
2543
|
+
}
|
|
2544
|
+
writeJsonFile(LOCKS_FILE, locks);
|
|
2545
|
+
return { success: true, unlocked: count, message: `Unlocked ${count} file(s).` };
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
if (!locks[normalized]) return { success: true, message: 'File was not locked.' };
|
|
2549
|
+
if (locks[normalized].agent !== registeredName) return { error: `File is locked by ${locks[normalized].agent}, not you.` };
|
|
2550
|
+
|
|
2551
|
+
delete locks[normalized];
|
|
2552
|
+
writeJsonFile(LOCKS_FILE, locks);
|
|
2553
|
+
return { success: true, file: normalized, message: 'File unlocked.' };
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
function toolLogDecision(decision, reasoning, topic) {
|
|
2557
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
2558
|
+
if (typeof decision !== 'string' || decision.length < 1 || decision.length > 500) return { error: 'Decision must be 1-500 chars' };
|
|
2559
|
+
|
|
2560
|
+
const decisions = getDecisions();
|
|
2561
|
+
const entry = {
|
|
2562
|
+
id: 'dec_' + generateId(),
|
|
2563
|
+
decision,
|
|
2564
|
+
reasoning: (reasoning || '').substring(0, 1000),
|
|
2565
|
+
topic: (topic || 'general').substring(0, 50),
|
|
2566
|
+
decided_by: registeredName,
|
|
2567
|
+
decided_at: new Date().toISOString(),
|
|
2568
|
+
};
|
|
2569
|
+
decisions.push(entry);
|
|
2570
|
+
if (decisions.length > 200) decisions.splice(0, decisions.length - 200); // cap
|
|
2571
|
+
writeJsonFile(DECISIONS_FILE, decisions);
|
|
2572
|
+
touchActivity();
|
|
2573
|
+
return { success: true, decision_id: entry.id, message: 'Decision logged. Other agents can see it via get_decisions() or get_briefing().' };
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
function toolGetDecisions(topic) {
|
|
2577
|
+
let decisions = getDecisions();
|
|
2578
|
+
if (topic) decisions = decisions.filter(d => d.topic === topic);
|
|
2579
|
+
return { count: decisions.length, decisions: decisions.slice(-30) };
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
// --- Tier 2: Knowledge Base, Progress, Event hooks ---
|
|
2583
|
+
|
|
2584
|
+
function toolKBWrite(key, content) {
|
|
2585
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
2586
|
+
if (typeof key !== 'string' || key.length < 1 || key.length > 50) return { error: 'Key must be 1-50 chars' };
|
|
2587
|
+
if (!/^[a-zA-Z0-9_\-\.]+$/.test(key)) return { error: 'Key must be alphanumeric/underscore/hyphen/dot' };
|
|
2588
|
+
if (typeof content !== 'string' || Buffer.byteLength(content, 'utf8') > 102400) return { error: 'Content exceeds 100KB' };
|
|
2589
|
+
|
|
2590
|
+
const kb = getKB();
|
|
2591
|
+
kb[key] = { content, updated_by: registeredName, updated_at: new Date().toISOString() };
|
|
2592
|
+
if (Object.keys(kb).length > 100) return { error: 'Knowledge base full (max 100 keys)' };
|
|
2593
|
+
writeJsonFile(KB_FILE, kb);
|
|
2594
|
+
touchActivity();
|
|
2595
|
+
return { success: true, key, size: content.length, total_keys: Object.keys(kb).length };
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
function toolKBRead(key) {
|
|
2599
|
+
const kb = getKB();
|
|
2600
|
+
if (key) {
|
|
2601
|
+
if (!kb[key]) return { error: `Key "${key}" not found in knowledge base` };
|
|
2602
|
+
return { key, content: kb[key].content, updated_by: kb[key].updated_by, updated_at: kb[key].updated_at };
|
|
2603
|
+
}
|
|
2604
|
+
// Return all entries
|
|
2605
|
+
const entries = {};
|
|
2606
|
+
for (const [k, v] of Object.entries(kb)) {
|
|
2607
|
+
entries[k] = { content: v.content, updated_by: v.updated_by, updated_at: v.updated_at };
|
|
2608
|
+
}
|
|
2609
|
+
return { entries, total_keys: Object.keys(kb).length };
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
function toolKBList() {
|
|
2613
|
+
const kb = getKB();
|
|
2614
|
+
return {
|
|
2615
|
+
keys: Object.keys(kb).map(k => ({ key: k, updated_by: kb[k].updated_by, updated_at: kb[k].updated_at, size: kb[k].content.length })),
|
|
2616
|
+
total: Object.keys(kb).length,
|
|
2617
|
+
};
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
function toolUpdateProgress(feature, percent, notes) {
|
|
2621
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
2622
|
+
if (typeof feature !== 'string' || feature.length < 1 || feature.length > 100) return { error: 'Feature name must be 1-100 chars' };
|
|
2623
|
+
if (typeof percent !== 'number' || percent < 0 || percent > 100) return { error: 'Percent must be 0-100' };
|
|
2624
|
+
|
|
2625
|
+
const progress = getProgressData();
|
|
2626
|
+
progress[feature] = {
|
|
2627
|
+
percent,
|
|
2628
|
+
notes: (notes || '').substring(0, 500),
|
|
2629
|
+
updated_by: registeredName,
|
|
2630
|
+
updated_at: new Date().toISOString(),
|
|
2631
|
+
};
|
|
2632
|
+
writeJsonFile(PROGRESS_FILE, progress);
|
|
2633
|
+
touchActivity();
|
|
2634
|
+
return { success: true, feature, percent, message: `Progress updated: ${feature} is ${percent}% complete.` };
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
function toolGetProgress() {
|
|
2638
|
+
const progress = getProgressData();
|
|
2639
|
+
const features = Object.entries(progress).map(([name, p]) => ({
|
|
2640
|
+
feature: name, percent: p.percent, notes: p.notes, updated_by: p.updated_by, updated_at: p.updated_at,
|
|
2641
|
+
}));
|
|
2642
|
+
const avg = features.length > 0 ? Math.round(features.reduce((s, f) => s + f.percent, 0) / features.length) : 0;
|
|
2643
|
+
return { features, overall_percent: avg, feature_count: features.length };
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
// --- Tier 3: Voting, Code Review, Dependencies ---
|
|
2647
|
+
|
|
2648
|
+
function toolCallVote(question, options) {
|
|
2649
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
2650
|
+
if (typeof question !== 'string' || question.length < 1 || question.length > 200) return { error: 'Question must be 1-200 chars' };
|
|
2651
|
+
if (!Array.isArray(options) || options.length < 2 || options.length > 10) return { error: 'Need 2-10 options' };
|
|
2652
|
+
|
|
2653
|
+
const votes = getVotes();
|
|
2654
|
+
const vote = {
|
|
2655
|
+
id: 'vote_' + generateId(),
|
|
2656
|
+
question,
|
|
2657
|
+
options: options.map(o => String(o).substring(0, 50)),
|
|
2658
|
+
votes: {},
|
|
2659
|
+
status: 'open',
|
|
2660
|
+
created_by: registeredName,
|
|
2661
|
+
created_at: new Date().toISOString(),
|
|
2662
|
+
};
|
|
2663
|
+
votes.push(vote);
|
|
2664
|
+
writeJsonFile(VOTES_FILE, votes);
|
|
2665
|
+
|
|
2666
|
+
// Notify all agents
|
|
2667
|
+
broadcastSystemMessage(`[VOTE] ${registeredName} started a vote: "${question}" — Options: ${vote.options.join(', ')}. Call cast_vote("${vote.id}", "your_choice") to vote.`, registeredName);
|
|
2668
|
+
touchActivity();
|
|
2669
|
+
return { success: true, vote_id: vote.id, question, options: vote.options, message: 'Vote created. All agents have been notified.' };
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
function toolCastVote(voteId, choice) {
|
|
2673
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
2674
|
+
|
|
2675
|
+
const votes = getVotes();
|
|
2676
|
+
const vote = votes.find(v => v.id === voteId);
|
|
2677
|
+
if (!vote) return { error: `Vote not found: ${voteId}` };
|
|
2678
|
+
if (vote.status !== 'open') return { error: 'Vote is already closed.' };
|
|
2679
|
+
if (!vote.options.includes(choice)) return { error: `Invalid choice. Options: ${vote.options.join(', ')}` };
|
|
2680
|
+
|
|
2681
|
+
vote.votes[registeredName] = { choice, voted_at: new Date().toISOString() };
|
|
2682
|
+
|
|
2683
|
+
// Check if all online agents have voted
|
|
2684
|
+
const agents = getAgents();
|
|
2685
|
+
const onlineAgents = Object.keys(agents).filter(n => isPidAlive(agents[n].pid, agents[n].last_activity));
|
|
2686
|
+
const allVoted = onlineAgents.every(n => vote.votes[n]);
|
|
2687
|
+
|
|
2688
|
+
if (allVoted) {
|
|
2689
|
+
vote.status = 'closed';
|
|
2690
|
+
vote.closed_at = new Date().toISOString();
|
|
2691
|
+
// Count results
|
|
2692
|
+
const results = {};
|
|
2693
|
+
for (const opt of vote.options) results[opt] = 0;
|
|
2694
|
+
for (const v of Object.values(vote.votes)) results[v.choice]++;
|
|
2695
|
+
vote.results = results;
|
|
2696
|
+
const winner = Object.entries(results).sort((a, b) => b[1] - a[1])[0];
|
|
2697
|
+
broadcastSystemMessage(`[VOTE RESULT] "${vote.question}" — Winner: ${winner[0]} (${winner[1]} votes). Full results: ${JSON.stringify(results)}`);
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
writeJsonFile(VOTES_FILE, votes);
|
|
2701
|
+
touchActivity();
|
|
2702
|
+
return { success: true, vote_id: voteId, your_vote: choice, status: vote.status, votes_cast: Object.keys(vote.votes).length, agents_online: onlineAgents.length };
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
function toolVoteStatus(voteId) {
|
|
2706
|
+
const votes = getVotes();
|
|
2707
|
+
if (voteId) {
|
|
2708
|
+
const vote = votes.find(v => v.id === voteId);
|
|
2709
|
+
if (!vote) return { error: `Vote not found: ${voteId}` };
|
|
2710
|
+
return { vote };
|
|
2711
|
+
}
|
|
2712
|
+
return { votes: votes.map(v => ({ id: v.id, question: v.question, status: v.status, votes_cast: Object.keys(v.votes).length, results: v.results || null })) };
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
function toolRequestReview(filePath, description) {
|
|
2716
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
2717
|
+
if (typeof filePath !== 'string' || filePath.length < 1) return { error: 'File path required' };
|
|
2718
|
+
|
|
2719
|
+
const reviews = getReviews();
|
|
2720
|
+
const review = {
|
|
2721
|
+
id: 'rev_' + generateId(),
|
|
2722
|
+
file: filePath.replace(/\\/g, '/'),
|
|
2723
|
+
description: (description || '').substring(0, 500),
|
|
2724
|
+
status: 'pending',
|
|
2725
|
+
requested_by: registeredName,
|
|
2726
|
+
requested_at: new Date().toISOString(),
|
|
2727
|
+
reviewer: null,
|
|
2728
|
+
feedback: null,
|
|
2729
|
+
};
|
|
2730
|
+
reviews.push(review);
|
|
2731
|
+
writeJsonFile(REVIEWS_FILE, reviews);
|
|
2732
|
+
|
|
2733
|
+
// Notify all other agents
|
|
2734
|
+
broadcastSystemMessage(`[REVIEW] ${registeredName} requests review of "${review.file}": ${review.description || 'No description'}. Call submit_review("${review.id}", "approved"/"changes_requested", "your feedback") to review.`, registeredName);
|
|
2735
|
+
touchActivity();
|
|
2736
|
+
return { success: true, review_id: review.id, file: review.file, message: 'Review requested. Team has been notified.' };
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
function toolSubmitReview(reviewId, status, feedback) {
|
|
2740
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
2741
|
+
|
|
2742
|
+
const validStatuses = ['approved', 'changes_requested'];
|
|
2743
|
+
if (!validStatuses.includes(status)) return { error: `Status must be: ${validStatuses.join(' or ')}` };
|
|
2744
|
+
|
|
2745
|
+
const reviews = getReviews();
|
|
2746
|
+
const review = reviews.find(r => r.id === reviewId);
|
|
2747
|
+
if (!review) return { error: `Review not found: ${reviewId}` };
|
|
2748
|
+
if (review.requested_by === registeredName) return { error: 'Cannot review your own code.' };
|
|
2749
|
+
|
|
2750
|
+
review.status = status;
|
|
2751
|
+
review.reviewer = registeredName;
|
|
2752
|
+
review.feedback = (feedback || '').substring(0, 2000);
|
|
2753
|
+
review.reviewed_at = new Date().toISOString();
|
|
2754
|
+
writeJsonFile(REVIEWS_FILE, reviews);
|
|
2755
|
+
|
|
2756
|
+
// Notify requester
|
|
2757
|
+
const agents = getAgents();
|
|
2758
|
+
if (agents[review.requested_by]) {
|
|
2759
|
+
sendSystemMessage(review.requested_by, `[REVIEW] ${registeredName} ${status === 'approved' ? 'approved' : 'requested changes on'} "${review.file}": ${review.feedback || 'No feedback'}`);
|
|
2760
|
+
}
|
|
2761
|
+
touchActivity();
|
|
2762
|
+
return { success: true, review_id: reviewId, status, message: `Review submitted: ${status}` };
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
function toolDeclareDependency(taskId, dependsOnTaskId) {
|
|
2766
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
2767
|
+
|
|
2768
|
+
const tasks = getTasks();
|
|
2769
|
+
const task = tasks.find(t => t.id === taskId);
|
|
2770
|
+
const depTask = tasks.find(t => t.id === dependsOnTaskId);
|
|
2771
|
+
if (!task) return { error: `Task not found: ${taskId}` };
|
|
2772
|
+
if (!depTask) return { error: `Dependency task not found: ${dependsOnTaskId}` };
|
|
2773
|
+
|
|
2774
|
+
const deps = getDeps();
|
|
2775
|
+
deps.push({
|
|
2776
|
+
id: 'dep_' + generateId(),
|
|
2777
|
+
task_id: taskId,
|
|
2778
|
+
depends_on: dependsOnTaskId,
|
|
2779
|
+
declared_by: registeredName,
|
|
2780
|
+
declared_at: new Date().toISOString(),
|
|
2781
|
+
resolved: depTask.status === 'done',
|
|
2782
|
+
});
|
|
2783
|
+
writeJsonFile(DEPS_FILE, deps);
|
|
2784
|
+
touchActivity();
|
|
2785
|
+
|
|
2786
|
+
if (depTask.status === 'done') {
|
|
2787
|
+
return { success: true, message: `Dependency declared but already resolved — "${depTask.title}" is done. You can proceed.` };
|
|
2788
|
+
}
|
|
2789
|
+
return { success: true, message: `Dependency declared: "${task.title}" is blocked until "${depTask.title}" is done. You'll be notified when it completes.` };
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
function toolCheckDependencies(taskId) {
|
|
2793
|
+
const deps = getDeps();
|
|
2794
|
+
const tasks = getTasks();
|
|
2795
|
+
|
|
2796
|
+
if (taskId) {
|
|
2797
|
+
const taskDeps = deps.filter(d => d.task_id === taskId);
|
|
2798
|
+
return {
|
|
2799
|
+
task_id: taskId,
|
|
2800
|
+
dependencies: taskDeps.map(d => {
|
|
2801
|
+
const t = tasks.find(t2 => t2.id === d.depends_on);
|
|
2802
|
+
return { depends_on: d.depends_on, title: t ? t.title : 'unknown', status: t ? t.status : 'unknown', resolved: t ? t.status === 'done' : false };
|
|
2803
|
+
}),
|
|
2804
|
+
};
|
|
2805
|
+
}
|
|
2806
|
+
// All unresolved deps
|
|
2807
|
+
const unresolved = deps.filter(d => {
|
|
2808
|
+
const t = tasks.find(t2 => t2.id === d.depends_on);
|
|
2809
|
+
return t && t.status !== 'done';
|
|
2810
|
+
});
|
|
2811
|
+
return { unresolved_count: unresolved.length, unresolved: unresolved.map(d => ({ task_id: d.task_id, blocked_by: d.depends_on })) };
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
// --- Conversation Compression ---
|
|
2815
|
+
|
|
2816
|
+
function getCompressed() { return readJsonFile(COMPRESSED_FILE) || { segments: [], last_compressed_at: null }; }
|
|
2817
|
+
|
|
2818
|
+
// Compress old messages into summary segments
|
|
2819
|
+
// Keeps last 20 verbatim, groups older messages into topic summaries
|
|
2820
|
+
function autoCompress() {
|
|
2821
|
+
const history = readJsonl(getHistoryFile(currentBranch));
|
|
2822
|
+
if (history.length <= 50) return; // only compress when conversation is long
|
|
2823
|
+
|
|
2824
|
+
const compressed = getCompressed();
|
|
2825
|
+
const cutoff = history.length - 20; // keep last 20 verbatim
|
|
2826
|
+
const toCompress = history.slice(compressed.segments.length > 0 ? compressed.segments.reduce((s, seg) => s + seg.message_count, 0) : 0, cutoff);
|
|
2827
|
+
if (toCompress.length < 10) return; // not enough new messages to compress
|
|
2828
|
+
|
|
2829
|
+
// Group messages into chunks of ~10 and create summaries
|
|
2830
|
+
const chunkSize = 10;
|
|
2831
|
+
for (let i = 0; i < toCompress.length; i += chunkSize) {
|
|
2832
|
+
const chunk = toCompress.slice(i, i + chunkSize);
|
|
2833
|
+
const speakers = [...new Set(chunk.map(m => m.from))];
|
|
2834
|
+
const topics = chunk.map(m => {
|
|
2835
|
+
const preview = m.content.substring(0, 80).replace(/\n/g, ' ');
|
|
2836
|
+
return `${m.from}: ${preview}`;
|
|
2837
|
+
});
|
|
2838
|
+
const segment = {
|
|
2839
|
+
id: 'seg_' + generateId(),
|
|
2840
|
+
from_time: chunk[0].timestamp,
|
|
2841
|
+
to_time: chunk[chunk.length - 1].timestamp,
|
|
2842
|
+
message_count: chunk.length,
|
|
2843
|
+
speakers,
|
|
2844
|
+
summary: topics.join(' | '),
|
|
2845
|
+
first_msg_id: chunk[0].id,
|
|
2846
|
+
last_msg_id: chunk[chunk.length - 1].id,
|
|
2847
|
+
};
|
|
2848
|
+
compressed.segments.push(segment);
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
// Cap segments at 100
|
|
2852
|
+
if (compressed.segments.length > 100) compressed.segments = compressed.segments.slice(-100);
|
|
2853
|
+
compressed.last_compressed_at = new Date().toISOString();
|
|
2854
|
+
compressed.total_original_messages = history.length;
|
|
2855
|
+
writeJsonFile(COMPRESSED_FILE, compressed);
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
function toolGetCompressedHistory() {
|
|
2859
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
2860
|
+
|
|
2861
|
+
const compressed = getCompressed();
|
|
2862
|
+
const history = readJsonl(getHistoryFile(currentBranch));
|
|
2863
|
+
const recent = history.slice(-20);
|
|
2864
|
+
|
|
2865
|
+
return {
|
|
2866
|
+
compressed_segments: compressed.segments.slice(-20).map(s => ({
|
|
2867
|
+
time_range: s.from_time + ' to ' + s.to_time,
|
|
2868
|
+
speakers: s.speakers,
|
|
2869
|
+
message_count: s.message_count,
|
|
2870
|
+
summary: s.summary,
|
|
2871
|
+
})),
|
|
2872
|
+
recent_messages: recent.map(m => ({
|
|
2873
|
+
id: m.id, from: m.from, to: m.to,
|
|
2874
|
+
content: m.content.substring(0, 300),
|
|
2875
|
+
timestamp: m.timestamp,
|
|
2876
|
+
})),
|
|
2877
|
+
total_messages: history.length,
|
|
2878
|
+
compressed_count: compressed.segments.reduce((s, seg) => s + seg.message_count, 0),
|
|
2879
|
+
recent_count: recent.length,
|
|
2880
|
+
hint: 'Compressed segments summarize older messages. Recent messages are shown verbatim.',
|
|
2881
|
+
};
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
// --- Agent Reputation ---
|
|
2885
|
+
|
|
2886
|
+
function getReputation() { return readJsonFile(REPUTATION_FILE) || {}; }
|
|
2887
|
+
|
|
2888
|
+
function trackReputation(agent, action) {
|
|
2889
|
+
const rep = getReputation();
|
|
2890
|
+
if (!rep[agent]) {
|
|
2891
|
+
rep[agent] = {
|
|
2892
|
+
tasks_completed: 0, tasks_created: 0, reviews_done: 0, reviews_requested: 0,
|
|
2893
|
+
bugs_found: 0, messages_sent: 0, decisions_made: 0, votes_cast: 0,
|
|
2894
|
+
kb_contributions: 0, files_shared: 0, first_seen: new Date().toISOString(),
|
|
2895
|
+
last_active: new Date().toISOString(), strengths: [],
|
|
2896
|
+
};
|
|
2897
|
+
}
|
|
2898
|
+
const r = rep[agent];
|
|
2899
|
+
r.last_active = new Date().toISOString();
|
|
2900
|
+
|
|
2901
|
+
switch (action) {
|
|
2902
|
+
case 'task_complete': r.tasks_completed++; break;
|
|
2903
|
+
case 'task_create': r.tasks_created++; break;
|
|
2904
|
+
case 'review_submit': r.reviews_done++; break;
|
|
2905
|
+
case 'review_request': r.reviews_requested++; break;
|
|
2906
|
+
case 'message_send': r.messages_sent++; break;
|
|
2907
|
+
case 'decision_log': r.decisions_made++; break;
|
|
2908
|
+
case 'vote_cast': r.votes_cast++; break;
|
|
2909
|
+
case 'kb_write': r.kb_contributions++; break;
|
|
2910
|
+
case 'file_share': r.files_shared++; break;
|
|
2911
|
+
case 'bug_found': r.bugs_found++; break;
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
// Auto-detect strengths based on stats
|
|
2915
|
+
r.strengths = [];
|
|
2916
|
+
if (r.tasks_completed >= 3) r.strengths.push('productive');
|
|
2917
|
+
if (r.reviews_done >= 2) r.strengths.push('reviewer');
|
|
2918
|
+
if (r.decisions_made >= 2) r.strengths.push('decision-maker');
|
|
2919
|
+
if (r.kb_contributions >= 3) r.strengths.push('documenter');
|
|
2920
|
+
if (r.tasks_created >= 3) r.strengths.push('organizer');
|
|
2921
|
+
if (r.bugs_found >= 2) r.strengths.push('bug-hunter');
|
|
2922
|
+
|
|
2923
|
+
writeJsonFile(REPUTATION_FILE, rep);
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
function toolGetReputation(agent) {
|
|
2927
|
+
const rep = getReputation();
|
|
2928
|
+
|
|
2929
|
+
if (agent) {
|
|
2930
|
+
if (!rep[agent]) return { agent, message: 'No reputation data yet for this agent.' };
|
|
2931
|
+
return { agent, reputation: rep[agent] };
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
// All agents with ranking
|
|
2935
|
+
const leaderboard = Object.entries(rep).map(([name, r]) => ({
|
|
2936
|
+
agent: name,
|
|
2937
|
+
score: r.tasks_completed * 10 + r.reviews_done * 5 + r.decisions_made * 3 + r.kb_contributions * 2 + r.bugs_found * 8,
|
|
2938
|
+
tasks_completed: r.tasks_completed,
|
|
2939
|
+
reviews_done: r.reviews_done,
|
|
2940
|
+
strengths: r.strengths,
|
|
2941
|
+
last_active: r.last_active,
|
|
2942
|
+
})).sort((a, b) => b.score - a.score);
|
|
2943
|
+
|
|
2944
|
+
return { leaderboard, total_agents: leaderboard.length };
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
function toolSuggestTask() {
|
|
2948
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
2949
|
+
|
|
2950
|
+
const rep = getReputation();
|
|
2951
|
+
const myRep = rep[registeredName];
|
|
2952
|
+
const tasks = getTasks();
|
|
2953
|
+
const pendingTasks = tasks.filter(t => t.status === 'pending' && !t.assignee);
|
|
2954
|
+
const unassignedTasks = tasks.filter(t => t.status === 'pending');
|
|
2955
|
+
|
|
2956
|
+
if (pendingTasks.length === 0 && unassignedTasks.length === 0) {
|
|
2957
|
+
// Check reviews
|
|
2958
|
+
const reviews = getReviews();
|
|
2959
|
+
const pendingReviews = reviews.filter(r => r.status === 'pending' && r.requested_by !== registeredName);
|
|
2960
|
+
if (pendingReviews.length > 0) {
|
|
2961
|
+
return { suggestion: 'review', review_id: pendingReviews[0].id, file: pendingReviews[0].file, message: `No pending tasks, but there's a code review waiting: "${pendingReviews[0].file}". Call submit_review() to review it.` };
|
|
2962
|
+
}
|
|
2963
|
+
// Check deps
|
|
2964
|
+
const deps = getDeps();
|
|
2965
|
+
const unresolved = deps.filter(d => !d.resolved);
|
|
2966
|
+
if (unresolved.length > 0) {
|
|
2967
|
+
return { suggestion: 'unblock', message: `No tasks available, but ${unresolved.length} task(s) are blocked by dependencies. Check if you can help resolve them.` };
|
|
2968
|
+
}
|
|
2969
|
+
return { suggestion: 'none', message: 'No pending tasks, reviews, or blocked items. Ask the team what needs doing next.' };
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
// Suggest based on reputation strengths
|
|
2973
|
+
let suggested = pendingTasks[0] || unassignedTasks[0];
|
|
2974
|
+
if (myRep && myRep.strengths.includes('reviewer')) {
|
|
2975
|
+
const reviews = getReviews().filter(r => r.status === 'pending' && r.requested_by !== registeredName);
|
|
2976
|
+
if (reviews.length > 0) return { suggestion: 'review', review_id: reviews[0].id, file: reviews[0].file, message: `Based on your strengths (reviewer), review "${reviews[0].file}".` };
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
return {
|
|
2980
|
+
suggestion: 'task',
|
|
2981
|
+
task_id: suggested.id,
|
|
2982
|
+
title: suggested.title,
|
|
2983
|
+
description: suggested.description,
|
|
2984
|
+
message: `Suggested: "${suggested.title}". Call update_task("${suggested.id}", "in_progress") to claim it.`,
|
|
2985
|
+
};
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2134
2988
|
// --- MCP Server setup ---
|
|
2135
2989
|
|
|
2136
2990
|
const server = new Server(
|
|
2137
|
-
{ name: 'agent-bridge', version: '3.
|
|
2991
|
+
{ name: 'agent-bridge', version: '3.8.0' },
|
|
2138
2992
|
{ capabilities: { tools: {} } }
|
|
2139
2993
|
);
|
|
2140
2994
|
|
|
@@ -2143,7 +2997,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
2143
2997
|
tools: [
|
|
2144
2998
|
{
|
|
2145
2999
|
name: 'register',
|
|
2146
|
-
description: 'Register this agent\'s identity
|
|
3000
|
+
description: 'Register this agent\'s identity. Must be called first. Returns a collaboration guide with all tool categories, critical rules, and workflow patterns — READ IT CAREFULLY before doing anything else. Then call get_briefing() for project context, then listen_group() to join the conversation.',
|
|
2147
3001
|
inputSchema: {
|
|
2148
3002
|
type: 'object',
|
|
2149
3003
|
properties: {
|
|
@@ -2222,7 +3076,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
2222
3076
|
},
|
|
2223
3077
|
{
|
|
2224
3078
|
name: 'listen',
|
|
2225
|
-
description: 'Listen for messages indefinitely.
|
|
3079
|
+
description: 'Listen for messages indefinitely. Auto-detects conversation mode: in group/managed mode, behaves like listen_group() (returns batched messages with agent statuses). In direct mode, returns one message at a time. Either listen() or listen_group() works in any mode — they auto-delegate to the correct behavior.',
|
|
2226
3080
|
inputSchema: {
|
|
2227
3081
|
type: 'object',
|
|
2228
3082
|
properties: {
|
|
@@ -2545,14 +3399,127 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
2545
3399
|
},
|
|
2546
3400
|
{
|
|
2547
3401
|
name: 'listen_group',
|
|
2548
|
-
description: 'Listen for messages in group or managed conversation mode. Returns ALL unconsumed messages as a batch
|
|
3402
|
+
description: 'Listen for messages in group or managed conversation mode. Auto-detects mode: in direct mode, behaves like listen(). Returns ALL unconsumed messages as a sorted batch (system > threaded > direct > broadcast), plus batch_summary, agent statuses, and hints. Either listen() or listen_group() works in any mode — they auto-delegate. Call again immediately after responding.',
|
|
2549
3403
|
inputSchema: {
|
|
2550
3404
|
type: 'object',
|
|
2551
|
-
properties: {
|
|
2552
|
-
timeout_seconds: { type: 'number', description: 'Max seconds to wait for messages (default 300)' },
|
|
2553
|
-
},
|
|
3405
|
+
properties: {},
|
|
2554
3406
|
},
|
|
2555
3407
|
},
|
|
3408
|
+
// --- Briefing & Recovery ---
|
|
3409
|
+
{
|
|
3410
|
+
name: 'get_guide',
|
|
3411
|
+
description: 'Get the collaboration guide — all tool categories, critical rules, and workflow patterns. Call this if you are unsure how to use the tools or need a refresher on best practices.',
|
|
3412
|
+
inputSchema: { type: 'object', properties: {} },
|
|
3413
|
+
},
|
|
3414
|
+
{
|
|
3415
|
+
name: 'get_briefing',
|
|
3416
|
+
description: 'Get a full project briefing: who is online, active tasks, recent decisions, knowledge base, locked files, progress, and project files. Call this when joining a project or after being away. One call = fully onboarded.',
|
|
3417
|
+
inputSchema: { type: 'object', properties: {} },
|
|
3418
|
+
},
|
|
3419
|
+
// --- File Locking ---
|
|
3420
|
+
{
|
|
3421
|
+
name: 'lock_file',
|
|
3422
|
+
description: 'Lock a file for exclusive editing. Other agents will be warned if they try to edit it. Call unlock_file() when done. Locks auto-release if you disconnect.',
|
|
3423
|
+
inputSchema: { type: 'object', properties: { file_path: { type: 'string', description: 'Relative path to the file to lock' } }, required: ['file_path'] },
|
|
3424
|
+
},
|
|
3425
|
+
{
|
|
3426
|
+
name: 'unlock_file',
|
|
3427
|
+
description: 'Unlock a file you previously locked. Omit file_path to unlock all your files.',
|
|
3428
|
+
inputSchema: { type: 'object', properties: { file_path: { type: 'string', description: 'File to unlock (optional — omit to unlock all)' } } },
|
|
3429
|
+
},
|
|
3430
|
+
// --- Decision Log ---
|
|
3431
|
+
{
|
|
3432
|
+
name: 'log_decision',
|
|
3433
|
+
description: 'Log a team decision so it persists and other agents can reference it. Prevents re-debating the same choices.',
|
|
3434
|
+
inputSchema: { type: 'object', properties: { decision: { type: 'string', description: 'The decision made (max 500 chars)' }, reasoning: { type: 'string', description: 'Why this was decided (optional, max 1000 chars)' }, topic: { type: 'string', description: 'Category like "architecture", "tech-stack", "design" (optional)' } }, required: ['decision'] },
|
|
3435
|
+
},
|
|
3436
|
+
{
|
|
3437
|
+
name: 'get_decisions',
|
|
3438
|
+
description: 'Get all logged decisions, optionally filtered by topic.',
|
|
3439
|
+
inputSchema: { type: 'object', properties: { topic: { type: 'string', description: 'Filter by topic (optional)' } } },
|
|
3440
|
+
},
|
|
3441
|
+
// --- Knowledge Base ---
|
|
3442
|
+
{
|
|
3443
|
+
name: 'kb_write',
|
|
3444
|
+
description: 'Write to the shared team knowledge base. Any agent can read, any agent can write. Use for API specs, conventions, shared data.',
|
|
3445
|
+
inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'Key name (1-50 alphanumeric chars)' }, content: { type: 'string', description: 'Content (max 100KB)' } }, required: ['key', 'content'] },
|
|
3446
|
+
},
|
|
3447
|
+
{
|
|
3448
|
+
name: 'kb_read',
|
|
3449
|
+
description: 'Read from the shared knowledge base. Omit key to read all entries.',
|
|
3450
|
+
inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'Key to read (optional — omit for all)' } } },
|
|
3451
|
+
},
|
|
3452
|
+
{
|
|
3453
|
+
name: 'kb_list',
|
|
3454
|
+
description: 'List all keys in the shared knowledge base with metadata.',
|
|
3455
|
+
inputSchema: { type: 'object', properties: {} },
|
|
3456
|
+
},
|
|
3457
|
+
// --- Progress Tracking ---
|
|
3458
|
+
{
|
|
3459
|
+
name: 'update_progress',
|
|
3460
|
+
description: 'Update feature-level progress. Higher level than tasks — tracks overall feature completion percentage.',
|
|
3461
|
+
inputSchema: { type: 'object', properties: { feature: { type: 'string', description: 'Feature name (max 100 chars)' }, percent: { type: 'number', description: 'Completion percentage 0-100' }, notes: { type: 'string', description: 'Progress notes (optional)' } }, required: ['feature', 'percent'] },
|
|
3462
|
+
},
|
|
3463
|
+
{
|
|
3464
|
+
name: 'get_progress',
|
|
3465
|
+
description: 'Get progress on all features with completion percentages and overall project progress.',
|
|
3466
|
+
inputSchema: { type: 'object', properties: {} },
|
|
3467
|
+
},
|
|
3468
|
+
// --- Voting ---
|
|
3469
|
+
{
|
|
3470
|
+
name: 'call_vote',
|
|
3471
|
+
description: 'Start a vote for the team to decide something. All online agents are notified and can cast their vote.',
|
|
3472
|
+
inputSchema: { type: 'object', properties: { question: { type: 'string', description: 'The question to vote on' }, options: { type: 'array', items: { type: 'string' }, description: 'Array of 2-10 options to choose from' } }, required: ['question', 'options'] },
|
|
3473
|
+
},
|
|
3474
|
+
{
|
|
3475
|
+
name: 'cast_vote',
|
|
3476
|
+
description: 'Cast your vote on an open vote. Vote auto-resolves when all online agents have voted.',
|
|
3477
|
+
inputSchema: { type: 'object', properties: { vote_id: { type: 'string', description: 'Vote ID' }, choice: { type: 'string', description: 'Your choice (must match one of the options)' } }, required: ['vote_id', 'choice'] },
|
|
3478
|
+
},
|
|
3479
|
+
{
|
|
3480
|
+
name: 'vote_status',
|
|
3481
|
+
description: 'Check status of a specific vote or all votes.',
|
|
3482
|
+
inputSchema: { type: 'object', properties: { vote_id: { type: 'string', description: 'Vote ID (optional — omit for all)' } } },
|
|
3483
|
+
},
|
|
3484
|
+
// --- Code Review ---
|
|
3485
|
+
{
|
|
3486
|
+
name: 'request_review',
|
|
3487
|
+
description: 'Request a code review from the team. Creates a review request and notifies all agents.',
|
|
3488
|
+
inputSchema: { type: 'object', properties: { file_path: { type: 'string', description: 'File to review' }, description: { type: 'string', description: 'What to focus on in the review' } }, required: ['file_path'] },
|
|
3489
|
+
},
|
|
3490
|
+
{
|
|
3491
|
+
name: 'submit_review',
|
|
3492
|
+
description: 'Submit a code review — approve or request changes with feedback.',
|
|
3493
|
+
inputSchema: { type: 'object', properties: { review_id: { type: 'string', description: 'Review ID' }, status: { type: 'string', enum: ['approved', 'changes_requested'], description: 'Review result' }, feedback: { type: 'string', description: 'Your review feedback (max 2000 chars)' } }, required: ['review_id', 'status'] },
|
|
3494
|
+
},
|
|
3495
|
+
// --- Dependencies ---
|
|
3496
|
+
{
|
|
3497
|
+
name: 'declare_dependency',
|
|
3498
|
+
description: 'Declare that a task depends on another task. You will be notified when the dependency is complete.',
|
|
3499
|
+
inputSchema: { type: 'object', properties: { task_id: { type: 'string', description: 'Your task that is blocked' }, depends_on: { type: 'string', description: 'Task ID that must complete first' } }, required: ['task_id', 'depends_on'] },
|
|
3500
|
+
},
|
|
3501
|
+
{
|
|
3502
|
+
name: 'check_dependencies',
|
|
3503
|
+
description: 'Check dependency status for a task or all unresolved dependencies.',
|
|
3504
|
+
inputSchema: { type: 'object', properties: { task_id: { type: 'string', description: 'Task ID to check (optional — omit for all unresolved)' } } },
|
|
3505
|
+
},
|
|
3506
|
+
// --- Conversation Compression ---
|
|
3507
|
+
{
|
|
3508
|
+
name: 'get_compressed_history',
|
|
3509
|
+
description: 'Get conversation history with automatic compression. Old messages are summarized into segments, recent messages shown verbatim. Use this when the conversation is long and you need to catch up without overflowing your context.',
|
|
3510
|
+
inputSchema: { type: 'object', properties: {} },
|
|
3511
|
+
},
|
|
3512
|
+
// --- Reputation ---
|
|
3513
|
+
{
|
|
3514
|
+
name: 'get_reputation',
|
|
3515
|
+
description: 'View agent reputation — tasks completed, reviews done, bugs found, strengths. Shows leaderboard when called without agent name.',
|
|
3516
|
+
inputSchema: { type: 'object', properties: { agent: { type: 'string', description: 'Agent name (optional — omit for leaderboard)' } } },
|
|
3517
|
+
},
|
|
3518
|
+
{
|
|
3519
|
+
name: 'suggest_task',
|
|
3520
|
+
description: 'Get a task suggestion based on your strengths, pending tasks, open reviews, and blocked dependencies. Helps you find the most useful thing to do next.',
|
|
3521
|
+
inputSchema: { type: 'object', properties: {} },
|
|
3522
|
+
},
|
|
2556
3523
|
// --- Managed mode tools ---
|
|
2557
3524
|
{
|
|
2558
3525
|
name: 'claim_manager',
|
|
@@ -2681,7 +3648,70 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2681
3648
|
result = toolSetConversationMode(args.mode);
|
|
2682
3649
|
break;
|
|
2683
3650
|
case 'listen_group':
|
|
2684
|
-
result = await toolListenGroup(
|
|
3651
|
+
result = await toolListenGroup();
|
|
3652
|
+
break;
|
|
3653
|
+
case 'get_guide':
|
|
3654
|
+
result = toolGetGuide();
|
|
3655
|
+
break;
|
|
3656
|
+
case 'get_briefing':
|
|
3657
|
+
result = toolGetBriefing();
|
|
3658
|
+
break;
|
|
3659
|
+
case 'lock_file':
|
|
3660
|
+
result = toolLockFile(args.file_path);
|
|
3661
|
+
break;
|
|
3662
|
+
case 'unlock_file':
|
|
3663
|
+
result = toolUnlockFile(args?.file_path);
|
|
3664
|
+
break;
|
|
3665
|
+
case 'log_decision':
|
|
3666
|
+
result = toolLogDecision(args.decision, args?.reasoning, args?.topic);
|
|
3667
|
+
break;
|
|
3668
|
+
case 'get_decisions':
|
|
3669
|
+
result = toolGetDecisions(args?.topic);
|
|
3670
|
+
break;
|
|
3671
|
+
case 'kb_write':
|
|
3672
|
+
result = toolKBWrite(args.key, args.content);
|
|
3673
|
+
break;
|
|
3674
|
+
case 'kb_read':
|
|
3675
|
+
result = toolKBRead(args?.key);
|
|
3676
|
+
break;
|
|
3677
|
+
case 'kb_list':
|
|
3678
|
+
result = toolKBList();
|
|
3679
|
+
break;
|
|
3680
|
+
case 'update_progress':
|
|
3681
|
+
result = toolUpdateProgress(args.feature, args.percent, args?.notes);
|
|
3682
|
+
break;
|
|
3683
|
+
case 'get_progress':
|
|
3684
|
+
result = toolGetProgress();
|
|
3685
|
+
break;
|
|
3686
|
+
case 'call_vote':
|
|
3687
|
+
result = toolCallVote(args.question, args.options);
|
|
3688
|
+
break;
|
|
3689
|
+
case 'cast_vote':
|
|
3690
|
+
result = toolCastVote(args.vote_id, args.choice);
|
|
3691
|
+
break;
|
|
3692
|
+
case 'vote_status':
|
|
3693
|
+
result = toolVoteStatus(args?.vote_id);
|
|
3694
|
+
break;
|
|
3695
|
+
case 'request_review':
|
|
3696
|
+
result = toolRequestReview(args.file_path, args?.description);
|
|
3697
|
+
break;
|
|
3698
|
+
case 'submit_review':
|
|
3699
|
+
result = toolSubmitReview(args.review_id, args.status, args?.feedback);
|
|
3700
|
+
break;
|
|
3701
|
+
case 'declare_dependency':
|
|
3702
|
+
result = toolDeclareDependency(args.task_id, args.depends_on);
|
|
3703
|
+
break;
|
|
3704
|
+
case 'check_dependencies':
|
|
3705
|
+
result = toolCheckDependencies(args?.task_id);
|
|
3706
|
+
break;
|
|
3707
|
+
case 'get_compressed_history':
|
|
3708
|
+
result = toolGetCompressedHistory();
|
|
3709
|
+
break;
|
|
3710
|
+
case 'get_reputation':
|
|
3711
|
+
result = toolGetReputation(args?.agent);
|
|
3712
|
+
break;
|
|
3713
|
+
case 'suggest_task':
|
|
3714
|
+
result = toolSuggestTask();
|
|
2685
3715
|
break;
|
|
2686
3716
|
case 'claim_manager':
|
|
2687
3717
|
result = toolClaimManager();
|
|
@@ -2706,19 +3736,51 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2706
3736
|
};
|
|
2707
3737
|
}
|
|
2708
3738
|
|
|
2709
|
-
// Global hook: on non-listen tools, check for pending messages and nudge
|
|
2710
|
-
// This catches agents who are mid-work and have messages piling up
|
|
3739
|
+
// Global hook: on non-listen tools, check for pending messages and nudge with escalating urgency
|
|
2711
3740
|
const listenTools = ['listen', 'listen_group', 'listen_codex', 'wait_for_reply', 'check_messages'];
|
|
2712
3741
|
if (registeredName && !listenTools.includes(name) && (isGroupMode() || isManagedMode())) {
|
|
2713
3742
|
try {
|
|
2714
3743
|
const pending = getUnconsumedMessages(registeredName);
|
|
2715
3744
|
if (pending.length > 0 && !result.you_have_messages) {
|
|
2716
3745
|
result._pending_messages = pending.length;
|
|
2717
|
-
|
|
3746
|
+
// Escalate urgency based on oldest pending message age
|
|
3747
|
+
const oldestAge = pending.reduce((max, m) => {
|
|
3748
|
+
const age = Date.now() - new Date(m.timestamp).getTime();
|
|
3749
|
+
return age > max ? age : max;
|
|
3750
|
+
}, 0);
|
|
3751
|
+
const ageSec = Math.round(oldestAge / 1000);
|
|
3752
|
+
if (ageSec > 120) {
|
|
3753
|
+
result._nudge = `CRITICAL: ${pending.length} message(s) waiting ${Math.round(ageSec / 60)}+ min. Team is likely blocked on you. Call listen_group() NOW.`;
|
|
3754
|
+
} else if (ageSec > 30) {
|
|
3755
|
+
result._nudge = `URGENT: ${pending.length} message(s) waiting ${ageSec}s. Team may be blocked. Call listen_group() soon.`;
|
|
3756
|
+
} else {
|
|
3757
|
+
result._nudge = `You have ${pending.length} unread message(s). Call listen_group() after this to read them.`;
|
|
3758
|
+
}
|
|
2718
3759
|
}
|
|
2719
3760
|
} catch {}
|
|
2720
3761
|
}
|
|
2721
3762
|
|
|
3763
|
+
// Global hook: reputation tracking
|
|
3764
|
+
if (registeredName && result.success) {
|
|
3765
|
+
try {
|
|
3766
|
+
const repMap = {
|
|
3767
|
+
'send_message': 'message_send', 'broadcast': 'message_send',
|
|
3768
|
+
'create_task': 'task_create', 'share_file': 'file_share',
|
|
3769
|
+
'log_decision': 'decision_log', 'cast_vote': 'vote_cast',
|
|
3770
|
+
'kb_write': 'kb_write', 'request_review': 'review_request',
|
|
3771
|
+
'submit_review': 'review_submit',
|
|
3772
|
+
};
|
|
3773
|
+
if (repMap[name]) trackReputation(registeredName, repMap[name]);
|
|
3774
|
+
// Track task completion specifically
|
|
3775
|
+
if (name === 'update_task' && args?.status === 'done') trackReputation(registeredName, 'task_complete');
|
|
3776
|
+
} catch {}
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
// Global hook: auto-compress conversation periodically
|
|
3780
|
+
if (name === 'send_message' || name === 'broadcast') {
|
|
3781
|
+
try { autoCompress(); } catch {}
|
|
3782
|
+
}
|
|
3783
|
+
|
|
2722
3784
|
return {
|
|
2723
3785
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
2724
3786
|
};
|
|
@@ -2751,7 +3813,7 @@ async function main() {
|
|
|
2751
3813
|
ensureDataDir();
|
|
2752
3814
|
const transport = new StdioServerTransport();
|
|
2753
3815
|
await server.connect(transport);
|
|
2754
|
-
console.error('Agent Bridge MCP server v3.
|
|
3816
|
+
console.error('Agent Bridge MCP server v3.8.0 running (53 tools)');
|
|
2755
3817
|
}
|
|
2756
3818
|
|
|
2757
3819
|
main().catch(console.error);
|