let-them-talk 4.0.1 → 4.2.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 +44 -0
- package/cli.js +1 -1
- package/dashboard.html +306 -15
- package/dashboard.js +163 -4
- package/package.json +3 -2
- package/server.js +512 -83
package/server.js
CHANGED
|
@@ -41,6 +41,7 @@ let sendsSinceLastListen = 0; // enforced: must listen between sends in group mo
|
|
|
41
41
|
let sendLimit = 1; // default: 1 send per listen cycle (2 if addressed)
|
|
42
42
|
let unaddressedSends = 0; // response budget: unaddressed sends counter
|
|
43
43
|
let budgetResetTime = Date.now(); // resets every 60s
|
|
44
|
+
let _channelSendTimes = {}; // per-channel rate limit sliding window
|
|
44
45
|
|
|
45
46
|
// --- Read cache (eliminates 70%+ redundant disk I/O) ---
|
|
46
47
|
const _cache = {};
|
|
@@ -181,6 +182,32 @@ function ensureDataDir() {
|
|
|
181
182
|
}
|
|
182
183
|
}
|
|
183
184
|
|
|
185
|
+
// Data version tracking — enables safe migrations between releases
|
|
186
|
+
const DATA_VERSION_FILE = path.join(DATA_DIR, '.version');
|
|
187
|
+
const CURRENT_DATA_VERSION = 1; // bump when data format changes require migration
|
|
188
|
+
let _migrationDone = false;
|
|
189
|
+
|
|
190
|
+
function migrateIfNeeded() {
|
|
191
|
+
if (_migrationDone) return;
|
|
192
|
+
_migrationDone = true;
|
|
193
|
+
ensureDataDir();
|
|
194
|
+
let dataVersion = 0;
|
|
195
|
+
try {
|
|
196
|
+
if (fs.existsSync(DATA_VERSION_FILE)) {
|
|
197
|
+
dataVersion = parseInt(fs.readFileSync(DATA_VERSION_FILE, 'utf8').trim()) || 0;
|
|
198
|
+
}
|
|
199
|
+
} catch {}
|
|
200
|
+
if (dataVersion >= CURRENT_DATA_VERSION) return;
|
|
201
|
+
|
|
202
|
+
// Run migrations in order
|
|
203
|
+
// v0 → v1: stamp initial version (no data changes needed, all fields are additive)
|
|
204
|
+
// Future migrations go here:
|
|
205
|
+
// if (dataVersion < 2) { /* migrate v1 → v2 */ }
|
|
206
|
+
|
|
207
|
+
// Stamp current version
|
|
208
|
+
try { fs.writeFileSync(DATA_VERSION_FILE, String(CURRENT_DATA_VERSION)); } catch {}
|
|
209
|
+
}
|
|
210
|
+
|
|
184
211
|
const RESERVED_NAMES = ['__system__', '__all__', '__open__', '__close__', 'system'];
|
|
185
212
|
|
|
186
213
|
function sanitizeName(name) {
|
|
@@ -423,7 +450,14 @@ function autoCompact() {
|
|
|
423
450
|
const newContent = active.map(m => JSON.stringify(m)).join('\n') + (active.length ? '\n' : '');
|
|
424
451
|
const tmpFile = msgFile + '.tmp';
|
|
425
452
|
fs.writeFileSync(tmpFile, newContent);
|
|
426
|
-
|
|
453
|
+
try {
|
|
454
|
+
fs.renameSync(tmpFile, msgFile);
|
|
455
|
+
} catch {
|
|
456
|
+
// Rename can fail on Windows if another process has the file open
|
|
457
|
+
// Clean up temp file and abort compaction — will retry next cycle
|
|
458
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
427
461
|
lastReadOffset = Buffer.byteLength(newContent, 'utf8');
|
|
428
462
|
|
|
429
463
|
// Trim consumed ID files — keep only IDs still in active messages
|
|
@@ -589,20 +623,36 @@ function getHistoryFile(branch) {
|
|
|
589
623
|
|
|
590
624
|
// --- Dynamic Guide (progressive disclosure) ---
|
|
591
625
|
|
|
592
|
-
function buildGuide() {
|
|
626
|
+
function buildGuide(level = 'standard') {
|
|
593
627
|
const agents = getAgents();
|
|
594
628
|
const aliveCount = Object.values(agents).filter(a => isPidAlive(a.pid, a.last_activity)).length;
|
|
595
629
|
const mode = getConfig().conversation_mode || 'direct';
|
|
596
630
|
const channels = getChannelsData();
|
|
597
631
|
const hasChannels = Object.keys(channels).length > 1; // more than just #general
|
|
598
|
-
const hasTasks = getTasks().length > 0;
|
|
599
632
|
|
|
600
633
|
const rules = [];
|
|
601
634
|
|
|
602
|
-
// Tier 0 — THE one rule (always)
|
|
635
|
+
// Tier 0 — THE one rule (always included at every level)
|
|
603
636
|
rules.push('AFTER EVERY ACTION, call listen_group(). This is how you receive messages. Never skip this.');
|
|
604
637
|
|
|
605
|
-
// Tier
|
|
638
|
+
// Minimal level: Tier 0 only — for experienced agents refreshing rules
|
|
639
|
+
if (level === 'minimal') {
|
|
640
|
+
rules.push('Call get_briefing() when joining a project or after being away.');
|
|
641
|
+
rules.push('Lock files before editing shared code (lock_file / unlock_file).');
|
|
642
|
+
if (mode === 'group' || mode === 'managed') {
|
|
643
|
+
rules.push('Use reply_to when responding — you get faster cooldown (500ms vs default).');
|
|
644
|
+
rules.push('Messages not addressed to you show should_respond: false. Only respond if you have something new to add.');
|
|
645
|
+
}
|
|
646
|
+
return {
|
|
647
|
+
rules,
|
|
648
|
+
tier_info: `${rules.length} rules (minimal level, ${aliveCount} agents)`,
|
|
649
|
+
first_steps: mode === 'direct'
|
|
650
|
+
? '1. Call list_agents() to see who is online. 2. Send a message or call listen() to wait.'
|
|
651
|
+
: '1. Call get_briefing() for project context. 2. Call listen_group() to join. 3. Respond and listen_group() again.',
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Tier 1 — core behavior (standard + full)
|
|
606
656
|
rules.push('Call get_briefing() when joining a project or after being away.');
|
|
607
657
|
rules.push('Keep messages to 2-3 paragraphs max.');
|
|
608
658
|
rules.push('When you finish work, report what you did and what files you changed.');
|
|
@@ -623,7 +673,7 @@ function buildGuide() {
|
|
|
623
673
|
|
|
624
674
|
// Tier 3 — large teams (shown when 5+ agents)
|
|
625
675
|
if (aliveCount >= 5) {
|
|
626
|
-
rules.push('
|
|
676
|
+
rules.push('listen_group blocks until messages arrive. Do not stop listening.');
|
|
627
677
|
rules.push('Tasks auto-create channels (#task-xxx). Use them for focused discussion instead of #general.');
|
|
628
678
|
rules.push('Use channels to split into sub-teams. Do not discuss everything in #general.');
|
|
629
679
|
}
|
|
@@ -638,7 +688,7 @@ function buildGuide() {
|
|
|
638
688
|
} catch {}
|
|
639
689
|
}
|
|
640
690
|
|
|
641
|
-
|
|
691
|
+
const result = {
|
|
642
692
|
rules,
|
|
643
693
|
project_rules: projectRules.length > 0 ? projectRules : undefined,
|
|
644
694
|
tier_info: `${rules.length} rules (${aliveCount} agents, ${mode} mode${hasChannels ? ', channels active' : ''})`,
|
|
@@ -646,7 +696,7 @@ function buildGuide() {
|
|
|
646
696
|
? '1. Call list_agents() to see who is online. 2. Send a message or call listen() to wait.'
|
|
647
697
|
: '1. Call get_briefing() for project context. 2. Call listen_group() to join. 3. Respond and listen_group() again.',
|
|
648
698
|
tool_categories: {
|
|
649
|
-
'MESSAGING': 'send_message, broadcast, listen_group, listen, check_messages, get_history, get_summary, handoff, share_file',
|
|
699
|
+
'MESSAGING': 'send_message, broadcast, listen_group, listen, check_messages, get_history, get_summary, search_messages, handoff, share_file',
|
|
650
700
|
'COORDINATION': 'get_briefing, log_decision, get_decisions, kb_write, kb_read, kb_list, call_vote, cast_vote, vote_status',
|
|
651
701
|
'TASKS': 'create_task, update_task, list_tasks, declare_dependency, check_dependencies, suggest_task',
|
|
652
702
|
'QUALITY': 'update_progress, get_progress, request_review, submit_review, get_reputation',
|
|
@@ -655,12 +705,31 @@ function buildGuide() {
|
|
|
655
705
|
...(mode === 'managed' ? { 'MANAGED MODE': 'claim_manager, yield_floor, set_phase' } : {}),
|
|
656
706
|
},
|
|
657
707
|
};
|
|
708
|
+
|
|
709
|
+
// Full level: add tool descriptions for complete reference
|
|
710
|
+
if (level === 'full') {
|
|
711
|
+
result.tool_details = {
|
|
712
|
+
'listen_group': 'Blocks until messages arrive. Returns batch with priorities, context, agent statuses.',
|
|
713
|
+
'send_message': 'Send to agent (to param). reply_to for threading. channel for sub-channels.',
|
|
714
|
+
'lock_file / unlock_file': 'Exclusive file locking. Auto-releases on disconnect.',
|
|
715
|
+
'log_decision': 'Persist decisions to prevent re-debating. Visible in get_briefing().',
|
|
716
|
+
'create_task / update_task': 'Structured task management. Auto-creates channels at 5+ agents.',
|
|
717
|
+
'kb_write / kb_read': 'Shared knowledge base. Any agent can read/write.',
|
|
718
|
+
'suggest_task': 'AI-suggested next task based on your strengths and pending work.',
|
|
719
|
+
'request_review / submit_review': 'Structured code review workflow with notifications.',
|
|
720
|
+
'declare_dependency': 'Block a task until another completes. Auto-notifies on resolution.',
|
|
721
|
+
'get_compressed_history': 'Summarized history for catching up without context overflow.',
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
return result;
|
|
658
726
|
}
|
|
659
727
|
|
|
660
728
|
// --- Tool implementations ---
|
|
661
729
|
|
|
662
730
|
function toolRegister(name, provider = null) {
|
|
663
731
|
ensureDataDir();
|
|
732
|
+
migrateIfNeeded(); // run data migrations on first register
|
|
664
733
|
sanitizeName(name);
|
|
665
734
|
lockAgentsFile();
|
|
666
735
|
|
|
@@ -698,13 +767,21 @@ function toolRegister(name, provider = null) {
|
|
|
698
767
|
}
|
|
699
768
|
|
|
700
769
|
// Start heartbeat — updates last_activity every 10s so dashboard knows we're alive
|
|
770
|
+
// Deterministic jitter per agent to spread writes across the interval (prevents lock storms at 10 agents)
|
|
771
|
+
const heartbeatJitter = name.split('').reduce((h, c) => h + c.charCodeAt(0), 0) % 2000;
|
|
701
772
|
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
|
702
773
|
heartbeatInterval = setInterval(() => {
|
|
703
774
|
try {
|
|
704
775
|
const agents = getAgents();
|
|
705
776
|
if (agents[registeredName]) {
|
|
706
|
-
|
|
707
|
-
|
|
777
|
+
lockAgentsFile();
|
|
778
|
+
try {
|
|
779
|
+
const freshAgents = getAgents(); // re-read inside lock
|
|
780
|
+
if (freshAgents[registeredName]) {
|
|
781
|
+
freshAgents[registeredName].last_activity = new Date().toISOString();
|
|
782
|
+
saveAgents(freshAgents);
|
|
783
|
+
}
|
|
784
|
+
} finally { unlockAgentsFile(); }
|
|
708
785
|
}
|
|
709
786
|
// Managed mode: detect dead manager and dead turn holder
|
|
710
787
|
if (isManagedMode()) {
|
|
@@ -738,11 +815,17 @@ function toolRegister(name, provider = null) {
|
|
|
738
815
|
}
|
|
739
816
|
}
|
|
740
817
|
}
|
|
818
|
+
// Snapshot dead agents BEFORE cleanup (for auto-recovery)
|
|
819
|
+
snapshotDeadAgents(agents);
|
|
741
820
|
// Clean up file locks held by dead agents
|
|
742
821
|
cleanStaleLocks();
|
|
743
822
|
cleanStaleChannelMembers();
|
|
823
|
+
// Auto-escalation: notify team about long-blocked tasks
|
|
824
|
+
escalateBlockedTasks();
|
|
825
|
+
// Stand-up meetings: periodic team check-ins
|
|
826
|
+
triggerStandupIfDue();
|
|
744
827
|
} catch {}
|
|
745
|
-
}, 10000);
|
|
828
|
+
}, 10000 + heartbeatJitter);
|
|
746
829
|
heartbeatInterval.unref(); // Don't prevent process exit
|
|
747
830
|
|
|
748
831
|
// Fire join event + recovery data for returning agents
|
|
@@ -772,6 +855,41 @@ function toolRegister(name, provider = null) {
|
|
|
772
855
|
result.recovery.hint = 'You have prior context from a previous session. Call get_briefing() for a full project summary.';
|
|
773
856
|
}
|
|
774
857
|
|
|
858
|
+
// Auto-recovery: load crash snapshot if it exists (TTL: 1 hour)
|
|
859
|
+
const recoveryFile = path.join(DATA_DIR, `recovery-${name}.json`);
|
|
860
|
+
if (fs.existsSync(recoveryFile)) {
|
|
861
|
+
try {
|
|
862
|
+
const snapshot = JSON.parse(fs.readFileSync(recoveryFile, 'utf8'));
|
|
863
|
+
const snapshotAge = Date.now() - new Date(snapshot.died_at).getTime();
|
|
864
|
+
if (snapshotAge > 3600000) {
|
|
865
|
+
// Stale snapshot (>1 hour) — discard
|
|
866
|
+
try { fs.unlinkSync(recoveryFile); } catch {}
|
|
867
|
+
} else {
|
|
868
|
+
if (!result.recovery) result.recovery = {};
|
|
869
|
+
result.recovery.previous_session = true;
|
|
870
|
+
result.recovery.died_at = snapshot.died_at;
|
|
871
|
+
result.recovery.crashed_ago = Math.round(snapshotAge / 1000) + 's';
|
|
872
|
+
if (snapshot.active_tasks && snapshot.active_tasks.length > 0) result.recovery.your_active_tasks = snapshot.active_tasks;
|
|
873
|
+
if (snapshot.locked_files && snapshot.locked_files.length > 0) {
|
|
874
|
+
result.recovery.locked_files_released = snapshot.locked_files;
|
|
875
|
+
result.recovery.lock_note = 'These files were locked by your previous session. Locks have been auto-released. Re-lock them with lock_file() before editing.';
|
|
876
|
+
}
|
|
877
|
+
if (snapshot.channels && snapshot.channels.length > 0) result.recovery.your_channels = snapshot.channels;
|
|
878
|
+
if (snapshot.last_messages_sent) result.recovery.last_messages_sent = snapshot.last_messages_sent;
|
|
879
|
+
// Agent memory fields
|
|
880
|
+
if (snapshot.decisions_made && snapshot.decisions_made.length > 0) result.recovery.decisions_made = snapshot.decisions_made;
|
|
881
|
+
if (snapshot.tasks_completed && snapshot.tasks_completed.length > 0) result.recovery.tasks_completed = snapshot.tasks_completed;
|
|
882
|
+
if (snapshot.kb_entries_written && snapshot.kb_entries_written.length > 0) result.recovery.kb_entries_written = snapshot.kb_entries_written;
|
|
883
|
+
if (snapshot.graceful) result.recovery.was_graceful = true;
|
|
884
|
+
result.recovery.hint = snapshot.graceful
|
|
885
|
+
? 'You are RESUMING from a previous session that exited gracefully. Your memory (decisions, completed tasks, KB entries) is below. Continue where you left off.'
|
|
886
|
+
: 'You are RESUMING a previous session that crashed. Review your active tasks and locked files below, then continue where you left off. Do NOT restart work from scratch.';
|
|
887
|
+
// Clean up snapshot after loading
|
|
888
|
+
try { fs.unlinkSync(recoveryFile); } catch {}
|
|
889
|
+
}
|
|
890
|
+
} catch {}
|
|
891
|
+
}
|
|
892
|
+
|
|
775
893
|
// Notify other agents
|
|
776
894
|
fireEvent('agent_join', { agent: name });
|
|
777
895
|
|
|
@@ -782,14 +900,18 @@ function toolRegister(name, provider = null) {
|
|
|
782
900
|
}
|
|
783
901
|
|
|
784
902
|
// Update last_activity timestamp for this agent
|
|
903
|
+
// Uses file lock to prevent race with heartbeat writes
|
|
785
904
|
function touchActivity() {
|
|
786
905
|
if (!registeredName) return;
|
|
787
906
|
try {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
agents
|
|
791
|
-
|
|
792
|
-
|
|
907
|
+
lockAgentsFile();
|
|
908
|
+
try {
|
|
909
|
+
const agents = JSON.parse(fs.readFileSync(AGENTS_FILE, 'utf8'));
|
|
910
|
+
if (agents[registeredName]) {
|
|
911
|
+
agents[registeredName].last_activity = new Date().toISOString();
|
|
912
|
+
saveAgents(agents);
|
|
913
|
+
}
|
|
914
|
+
} finally { unlockAgentsFile(); }
|
|
793
915
|
} catch {}
|
|
794
916
|
}
|
|
795
917
|
|
|
@@ -834,6 +956,11 @@ function toolListAgents() {
|
|
|
834
956
|
role: profile.role || '',
|
|
835
957
|
bio: profile.bio || '',
|
|
836
958
|
};
|
|
959
|
+
// Include workspace status if set (agent intent board)
|
|
960
|
+
try {
|
|
961
|
+
const ws = getWorkspace(name);
|
|
962
|
+
if (ws._status) result[name].current_status = ws._status;
|
|
963
|
+
} catch {}
|
|
837
964
|
}
|
|
838
965
|
return { agents: result };
|
|
839
966
|
}
|
|
@@ -864,8 +991,24 @@ async function toolSendMessage(content, to = null, reply_to = null, channel = nu
|
|
|
864
991
|
// Group mode cooldown — per-channel aware + split by addressing (fast/slow lane)
|
|
865
992
|
let _cooldownApplied = 0;
|
|
866
993
|
if (isGroupMode()) {
|
|
867
|
-
// Per-channel
|
|
994
|
+
// Per-channel rate limit: check if channel has custom rate_limit config
|
|
868
995
|
const agentsNow = getAgents();
|
|
996
|
+
if (channel && channel !== 'general') {
|
|
997
|
+
const channels = getChannelsData();
|
|
998
|
+
const ch = channels[channel];
|
|
999
|
+
if (ch && ch.rate_limit && ch.rate_limit.max_sends_per_minute) {
|
|
1000
|
+
// Custom per-channel rate limit — check sliding window
|
|
1001
|
+
if (!_channelSendTimes[channel]) _channelSendTimes[channel] = [];
|
|
1002
|
+
const now = Date.now();
|
|
1003
|
+
_channelSendTimes[channel] = _channelSendTimes[channel].filter(t => now - t < 60000);
|
|
1004
|
+
if (_channelSendTimes[channel].length >= ch.rate_limit.max_sends_per_minute) {
|
|
1005
|
+
return { error: `Rate limit for #${channel}: max ${ch.rate_limit.max_sends_per_minute} messages/minute. Wait before sending.` };
|
|
1006
|
+
}
|
|
1007
|
+
_channelSendTimes[channel].push(now);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Per-channel cooldown: use channel member count, not total agents
|
|
869
1012
|
let memberCount;
|
|
870
1013
|
if (channel && channel !== 'general') {
|
|
871
1014
|
const channels = getChannelsData();
|
|
@@ -975,11 +1118,19 @@ async function toolSendMessage(content, to = null, reply_to = null, channel = nu
|
|
|
975
1118
|
// Check if recipient is alive — warn if dead
|
|
976
1119
|
const recipientAlive = isPidAlive(agents[to].pid, agents[to].last_activity);
|
|
977
1120
|
|
|
978
|
-
// Resolve threading
|
|
1121
|
+
// Resolve threading — search main messages + channel files
|
|
979
1122
|
let thread_id = null;
|
|
980
1123
|
if (reply_to) {
|
|
981
|
-
|
|
982
|
-
|
|
1124
|
+
let referencedMsg = null;
|
|
1125
|
+
// Search channel file first if channel specified, then main messages
|
|
1126
|
+
if (channel && channel !== 'general') {
|
|
1127
|
+
const chMsgs = readJsonl(getChannelMessagesFile(channel));
|
|
1128
|
+
referencedMsg = chMsgs.find(m => m.id === reply_to);
|
|
1129
|
+
}
|
|
1130
|
+
if (!referencedMsg) {
|
|
1131
|
+
const allMsgs = readJsonl(getMessagesFile(currentBranch));
|
|
1132
|
+
referencedMsg = allMsgs.find(m => m.id === reply_to);
|
|
1133
|
+
}
|
|
983
1134
|
if (referencedMsg) {
|
|
984
1135
|
thread_id = referencedMsg.thread_id || referencedMsg.id;
|
|
985
1136
|
} else {
|
|
@@ -1069,6 +1220,24 @@ async function toolSendMessage(content, to = null, reply_to = null, channel = nu
|
|
|
1069
1220
|
if (isGroupMode() && !msg.addressed_to) { unaddressedSends++; }
|
|
1070
1221
|
|
|
1071
1222
|
const result = { success: true, messageId: msg.id, from: msg.from, to: msg.to };
|
|
1223
|
+
|
|
1224
|
+
// Decision overlap hint: warn if message content overlaps with existing decisions
|
|
1225
|
+
if (isGroupMode()) {
|
|
1226
|
+
try {
|
|
1227
|
+
const decisions = readJsonFile(path.join(DATA_DIR, 'decisions.json')) || [];
|
|
1228
|
+
if (decisions.length > 0) {
|
|
1229
|
+
const contentLower = content.toLowerCase();
|
|
1230
|
+
const overlap = decisions.find(d => {
|
|
1231
|
+
const topic = (d.topic || '').toLowerCase();
|
|
1232
|
+
const decision = (d.decision || '').toLowerCase();
|
|
1233
|
+
return topic && contentLower.includes(topic) || decision.split(' ').filter(w => w.length > 4).some(w => contentLower.includes(w));
|
|
1234
|
+
});
|
|
1235
|
+
if (overlap) {
|
|
1236
|
+
result._decision_hint = `Related decision exists: "${overlap.decision}" (topic: ${overlap.topic || 'general'}). Check get_decisions() before re-debating.`;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
} catch {}
|
|
1240
|
+
}
|
|
1072
1241
|
if (_cooldownApplied > 0) result.cooldown_applied_ms = _cooldownApplied;
|
|
1073
1242
|
if (channel) result.channel = channel;
|
|
1074
1243
|
if (currentBranch !== 'main') result.branch = currentBranch;
|
|
@@ -1639,8 +1808,6 @@ async function toolListenGroup() {
|
|
|
1639
1808
|
setListening(true);
|
|
1640
1809
|
|
|
1641
1810
|
const consumed = getConsumedIds(registeredName);
|
|
1642
|
-
const idleThreshold = 60000; // 60s of no messages → return idle suggestions
|
|
1643
|
-
const listenStarted = Date.now();
|
|
1644
1811
|
|
|
1645
1812
|
// Poll indefinitely (in 5-min chunks to stay within any MCP limits, same as listen())
|
|
1646
1813
|
while (true) {
|
|
@@ -1761,6 +1928,17 @@ async function toolListenGroup() {
|
|
|
1761
1928
|
const recentSpeakers = new Set(history.slice(-10).map(m => m.from));
|
|
1762
1929
|
const silent = agentNames.filter(n => !recentSpeakers.has(n) && n !== registeredName);
|
|
1763
1930
|
|
|
1931
|
+
// KB hints: check if batch messages mention KB topics
|
|
1932
|
+
let kbHints = [];
|
|
1933
|
+
try {
|
|
1934
|
+
const kb = getKB();
|
|
1935
|
+
const kbKeys = Object.keys(kb);
|
|
1936
|
+
if (kbKeys.length > 0) {
|
|
1937
|
+
const batchText = batch.map(m => m.content).join(' ').toLowerCase();
|
|
1938
|
+
kbHints = kbKeys.filter(k => batchText.includes(k.toLowerCase().replace(/[-_.]/g, ' '))).slice(0, 3);
|
|
1939
|
+
}
|
|
1940
|
+
} catch {}
|
|
1941
|
+
|
|
1764
1942
|
const now = Date.now();
|
|
1765
1943
|
const result = {
|
|
1766
1944
|
messages: batch.map(m => {
|
|
@@ -1771,7 +1949,14 @@ async function toolListenGroup() {
|
|
|
1771
1949
|
timestamp: m.timestamp,
|
|
1772
1950
|
age_seconds: ageSec,
|
|
1773
1951
|
...(ageSec > 30 && { delayed: true }),
|
|
1774
|
-
...(m.reply_to && {
|
|
1952
|
+
...(m.reply_to && {
|
|
1953
|
+
reply_to: m.reply_to,
|
|
1954
|
+
// Thread context: include parent message preview so recipients have context
|
|
1955
|
+
_reply_context: (() => {
|
|
1956
|
+
const parent = history.find(h => h.id === m.reply_to);
|
|
1957
|
+
return parent ? `${parent.from}: "${parent.content.substring(0, 100)}..."` : null;
|
|
1958
|
+
})(),
|
|
1959
|
+
}),
|
|
1775
1960
|
...(m.thread_id && { thread_id: m.thread_id }),
|
|
1776
1961
|
// addressed_to hint for group messages
|
|
1777
1962
|
...(m.addressed_to && { addressed_to: m.addressed_to }),
|
|
@@ -1836,47 +2021,15 @@ async function toolListenGroup() {
|
|
|
1836
2021
|
}
|
|
1837
2022
|
}
|
|
1838
2023
|
|
|
2024
|
+
if (kbHints.length > 0) {
|
|
2025
|
+
result.kb_hints = kbHints.map(k => `Relevant KB entry: "${k}" — call kb_read("${k}") for context`);
|
|
2026
|
+
}
|
|
1839
2027
|
result.next_action = 'After processing these messages and sending your response, call listen_group() again immediately. Never stop listening.';
|
|
1840
2028
|
return result;
|
|
1841
2029
|
}
|
|
1842
2030
|
|
|
1843
|
-
//
|
|
1844
|
-
|
|
1845
|
-
setListening(false);
|
|
1846
|
-
touchActivity();
|
|
1847
|
-
|
|
1848
|
-
// Reset send counters (listening counts as a listen cycle)
|
|
1849
|
-
sendsSinceLastListen = 0;
|
|
1850
|
-
sendLimit = 1;
|
|
1851
|
-
|
|
1852
|
-
const agents = getAgents();
|
|
1853
|
-
const agentNames = Object.keys(agents).filter(n => isPidAlive(agents[n].pid, agents[n].last_activity));
|
|
1854
|
-
|
|
1855
|
-
// Proactive work suggestions
|
|
1856
|
-
const suggestion = toolSuggestTask();
|
|
1857
|
-
const myTasks = getTasks().filter(t => t.assignee === registeredName && t.status === 'in_progress');
|
|
1858
|
-
const pendingReviews = getReviews().filter(r => r.status === 'pending' && r.requested_by !== registeredName);
|
|
1859
|
-
const unresolved = getDeps().filter(d => !d.resolved);
|
|
1860
|
-
|
|
1861
|
-
const workItems = [];
|
|
1862
|
-
if (myTasks.length > 0) workItems.push(`You have ${myTasks.length} task(s) in progress: ${myTasks.map(t => `"${t.title}" (${t.id})`).join(', ')}`);
|
|
1863
|
-
if (pendingReviews.length > 0) workItems.push(`${pendingReviews.length} code review(s) waiting`);
|
|
1864
|
-
if (unresolved.length > 0) workItems.push(`${unresolved.length} blocked dependency(s) to resolve`);
|
|
1865
|
-
|
|
1866
|
-
return {
|
|
1867
|
-
idle: true,
|
|
1868
|
-
idle_seconds: Math.round((Date.now() - listenStarted) / 1000),
|
|
1869
|
-
messages: [],
|
|
1870
|
-
message_count: 0,
|
|
1871
|
-
agents_online: agentNames.length,
|
|
1872
|
-
work_suggestions: workItems.length > 0 ? workItems : ['No pending work. Ask the team what needs doing, or propose a new task.'],
|
|
1873
|
-
suggestion: suggestion.suggestion !== 'none' ? suggestion : undefined,
|
|
1874
|
-
instructions: myTasks.length > 0
|
|
1875
|
-
? `No new messages for ${Math.round((Date.now() - listenStarted) / 1000)}s. Continue working on your in-progress task(s), then call listen_group() again.`
|
|
1876
|
-
: `No new messages for ${Math.round((Date.now() - listenStarted) / 1000)}s. ${suggestion.message || 'Ask the team what needs doing next.'}`,
|
|
1877
|
-
next_action: 'Do some work (suggest_task, continue tasks, review code), then call listen_group() again.',
|
|
1878
|
-
};
|
|
1879
|
-
}
|
|
2031
|
+
// Note: NO idle timeout here. listen_group blocks indefinitely until messages arrive.
|
|
2032
|
+
// Work suggestions are delivered via enhanced nudge on other tool calls, not by breaking listen.
|
|
1880
2033
|
|
|
1881
2034
|
await adaptiveSleep(0);
|
|
1882
2035
|
}
|
|
@@ -2167,6 +2320,8 @@ function toolUpdateTask(taskId, status, notes = null) {
|
|
|
2167
2320
|
|
|
2168
2321
|
task.status = status;
|
|
2169
2322
|
task.updated_at = new Date().toISOString();
|
|
2323
|
+
// Clear escalation flag when task is unblocked
|
|
2324
|
+
if (status !== 'blocked' && task.escalated_at) delete task.escalated_at;
|
|
2170
2325
|
if (notes) {
|
|
2171
2326
|
task.notes.push({ by: registeredName, text: notes, at: new Date().toISOString() });
|
|
2172
2327
|
}
|
|
@@ -2174,6 +2329,17 @@ function toolUpdateTask(taskId, status, notes = null) {
|
|
|
2174
2329
|
saveTasks(tasks);
|
|
2175
2330
|
touchActivity();
|
|
2176
2331
|
|
|
2332
|
+
// Auto-status: update agent's workspace status on task state changes
|
|
2333
|
+
try {
|
|
2334
|
+
if (status === 'in_progress') {
|
|
2335
|
+
saveWorkspace(registeredName, Object.assign(getWorkspace(registeredName), { _status: `Working on: ${task.title}`, _status_since: new Date().toISOString() }));
|
|
2336
|
+
} else if (status === 'done') {
|
|
2337
|
+
saveWorkspace(registeredName, Object.assign(getWorkspace(registeredName), { _status: `Completed: ${task.title}`, _status_since: new Date().toISOString() }));
|
|
2338
|
+
} else if (status === 'blocked') {
|
|
2339
|
+
saveWorkspace(registeredName, Object.assign(getWorkspace(registeredName), { _status: `BLOCKED on: ${task.title}`, _status_since: new Date().toISOString() }));
|
|
2340
|
+
}
|
|
2341
|
+
} catch {}
|
|
2342
|
+
|
|
2177
2343
|
// Task-channel auto-join: when claiming a task that has a channel, auto-join it
|
|
2178
2344
|
if (status === 'in_progress' && task.channel) {
|
|
2179
2345
|
const channels = getChannelsData();
|
|
@@ -2207,6 +2373,13 @@ function toolUpdateTask(taskId, status, notes = null) {
|
|
|
2207
2373
|
saveChannelsData(channels);
|
|
2208
2374
|
}
|
|
2209
2375
|
}
|
|
2376
|
+
|
|
2377
|
+
// Quality gate: auto-request review when task is completed
|
|
2378
|
+
const agents = getAgents();
|
|
2379
|
+
const aliveOthers = Object.keys(agents).filter(n => n !== registeredName && isPidAlive(agents[n].pid, agents[n].last_activity));
|
|
2380
|
+
if (aliveOthers.length > 0) {
|
|
2381
|
+
broadcastSystemMessage(`[REVIEW NEEDED] ${registeredName} completed task "${task.title}". Team: please review the work and call submit_review() if applicable.`, registeredName);
|
|
2382
|
+
}
|
|
2210
2383
|
}
|
|
2211
2384
|
|
|
2212
2385
|
return { success: true, task_id: task.id, status: task.status, title: task.title };
|
|
@@ -2261,6 +2434,45 @@ function toolGetSummary(lastN = 20) {
|
|
|
2261
2434
|
};
|
|
2262
2435
|
}
|
|
2263
2436
|
|
|
2437
|
+
function toolSearchMessages(query, from = null, limit = 20) {
|
|
2438
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
2439
|
+
if (typeof query !== 'string' || query.length < 2) return { error: 'Query must be at least 2 characters' };
|
|
2440
|
+
if (query.length > 100) return { error: 'Query too long (max 100 chars)' };
|
|
2441
|
+
limit = Math.min(Math.max(1, limit || 20), 50);
|
|
2442
|
+
|
|
2443
|
+
// Search general history + all channel history files
|
|
2444
|
+
let allMessages = readJsonl(getHistoryFile(currentBranch));
|
|
2445
|
+
try {
|
|
2446
|
+
const myChannels = getAgentChannels(registeredName);
|
|
2447
|
+
for (const ch of myChannels) {
|
|
2448
|
+
if (ch === 'general') continue;
|
|
2449
|
+
const chFile = getChannelHistoryFile(ch);
|
|
2450
|
+
if (fs.existsSync(chFile)) {
|
|
2451
|
+
const chMsgs = readJsonl(chFile);
|
|
2452
|
+
allMessages = allMessages.concat(chMsgs);
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
} catch {}
|
|
2456
|
+
// Sort by timestamp descending for newest-first results
|
|
2457
|
+
allMessages.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
|
2458
|
+
|
|
2459
|
+
const queryLower = query.toLowerCase();
|
|
2460
|
+
const results = [];
|
|
2461
|
+
for (let i = 0; i < allMessages.length && results.length < limit; i++) {
|
|
2462
|
+
const m = allMessages[i];
|
|
2463
|
+
if (from && m.from !== from) continue;
|
|
2464
|
+
if (m.content && m.content.toLowerCase().includes(queryLower)) {
|
|
2465
|
+
results.push({
|
|
2466
|
+
id: m.id, from: m.from, to: m.to,
|
|
2467
|
+
preview: m.content.substring(0, 200),
|
|
2468
|
+
timestamp: m.timestamp,
|
|
2469
|
+
...(m.channel && { channel: m.channel }),
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
return { query, results_count: results.length, results, searched: allMessages.length };
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2264
2476
|
function toolReset() {
|
|
2265
2477
|
if (!registeredName) {
|
|
2266
2478
|
return { error: 'You must call register() first' };
|
|
@@ -2686,7 +2898,7 @@ function cleanStaleChannelMembers() {
|
|
|
2686
2898
|
if (changed) saveChannelsData(channels);
|
|
2687
2899
|
}
|
|
2688
2900
|
|
|
2689
|
-
function toolJoinChannel(channelName, description) {
|
|
2901
|
+
function toolJoinChannel(channelName, description, rateLimit) {
|
|
2690
2902
|
if (!registeredName) return { error: 'You must call register() first' };
|
|
2691
2903
|
if (typeof channelName !== 'string' || channelName.length < 1 || channelName.length > 20) return { error: 'Channel name must be 1-20 chars' };
|
|
2692
2904
|
sanitizeName(channelName);
|
|
@@ -2703,12 +2915,19 @@ function toolJoinChannel(channelName, description) {
|
|
|
2703
2915
|
};
|
|
2704
2916
|
} else if (!isChannelMember(channelName, registeredName)) {
|
|
2705
2917
|
channels[channelName].members.push(registeredName);
|
|
2706
|
-
} else {
|
|
2918
|
+
} else if (!rateLimit) {
|
|
2707
2919
|
return { success: true, channel: channelName, message: 'Already a member of #' + channelName };
|
|
2708
2920
|
}
|
|
2921
|
+
// Per-channel rate limit config — any member can set/update
|
|
2922
|
+
if (rateLimit && typeof rateLimit === 'object' && rateLimit.max_sends_per_minute) {
|
|
2923
|
+
const max = Math.min(Math.max(1, parseInt(rateLimit.max_sends_per_minute) || 10), 60);
|
|
2924
|
+
channels[channelName].rate_limit = { max_sends_per_minute: max };
|
|
2925
|
+
}
|
|
2709
2926
|
saveChannelsData(channels);
|
|
2710
2927
|
touchActivity();
|
|
2711
|
-
|
|
2928
|
+
const result = { success: true, channel: channelName, members: channels[channelName].members, message: 'Joined #' + channelName };
|
|
2929
|
+
if (channels[channelName].rate_limit) result.rate_limit = channels[channelName].rate_limit;
|
|
2930
|
+
return result;
|
|
2712
2931
|
}
|
|
2713
2932
|
|
|
2714
2933
|
function toolLeaveChannel(channelName) {
|
|
@@ -2747,6 +2966,113 @@ function toolListChannels() {
|
|
|
2747
2966
|
return { channels: result, your_channels: getAgentChannels(registeredName) };
|
|
2748
2967
|
}
|
|
2749
2968
|
|
|
2969
|
+
// Auto-escalation: notify team about tasks blocked for >5 minutes
|
|
2970
|
+
// Uses task.escalated_at field for cross-process dedup (file-based, not in-memory)
|
|
2971
|
+
function escalateBlockedTasks() {
|
|
2972
|
+
try {
|
|
2973
|
+
const tasks = getTasks();
|
|
2974
|
+
const now = Date.now();
|
|
2975
|
+
let changed = false;
|
|
2976
|
+
for (const task of tasks) {
|
|
2977
|
+
if (task.status !== 'blocked') continue;
|
|
2978
|
+
if (task.escalated_at) continue; // already escalated (cross-process safe)
|
|
2979
|
+
const blockedSince = new Date(task.updated_at).getTime();
|
|
2980
|
+
if (now - blockedSince > 300000) { // 5 minutes
|
|
2981
|
+
task.escalated_at = new Date().toISOString();
|
|
2982
|
+
changed = true;
|
|
2983
|
+
broadcastSystemMessage(
|
|
2984
|
+
`[ESCALATION] Task "${task.title}" (assigned to ${task.assignee || 'unassigned'}) has been blocked for ${Math.round((now - blockedSince) / 60000)} minutes. Team: can anyone help unblock it?`,
|
|
2985
|
+
registeredName
|
|
2986
|
+
);
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
if (changed) saveTasks(tasks);
|
|
2990
|
+
} catch {}
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
// Stand-up meetings: periodic team check-ins triggered by heartbeat
|
|
2994
|
+
let _lastStandupTime = 0;
|
|
2995
|
+
function triggerStandupIfDue() {
|
|
2996
|
+
try {
|
|
2997
|
+
const config = getConfig();
|
|
2998
|
+
const intervalHours = config.standup_interval_hours || 0; // 0 = disabled
|
|
2999
|
+
if (intervalHours <= 0) return;
|
|
3000
|
+
const intervalMs = intervalHours * 3600000;
|
|
3001
|
+
const now = Date.now();
|
|
3002
|
+
|
|
3003
|
+
// Only one process should trigger (the first to notice it's due)
|
|
3004
|
+
const standupFile = path.join(DATA_DIR, '.last-standup');
|
|
3005
|
+
let lastStandup = 0;
|
|
3006
|
+
if (fs.existsSync(standupFile)) {
|
|
3007
|
+
try { lastStandup = parseInt(fs.readFileSync(standupFile, 'utf8').trim()) || 0; } catch {}
|
|
3008
|
+
}
|
|
3009
|
+
if (now - lastStandup < intervalMs) return;
|
|
3010
|
+
|
|
3011
|
+
// Write timestamp first to prevent other processes from also triggering
|
|
3012
|
+
fs.writeFileSync(standupFile, String(now));
|
|
3013
|
+
|
|
3014
|
+
const agents = getAgents();
|
|
3015
|
+
const aliveAgents = Object.keys(agents).filter(n => isPidAlive(agents[n].pid, agents[n].last_activity));
|
|
3016
|
+
if (aliveAgents.length < 5) return; // stand-ups only for large teams (5+)
|
|
3017
|
+
|
|
3018
|
+
// Build standup context: tasks in progress, blocked, recently completed
|
|
3019
|
+
const tasks = getTasks();
|
|
3020
|
+
const inProgress = tasks.filter(t => t.status === 'in_progress');
|
|
3021
|
+
const blocked = tasks.filter(t => t.status === 'blocked');
|
|
3022
|
+
const recentDone = tasks.filter(t => t.status === 'done' && (now - new Date(t.updated_at).getTime()) < intervalMs);
|
|
3023
|
+
|
|
3024
|
+
let summary = `[STANDUP] Team check-in (${aliveAgents.length} agents online).`;
|
|
3025
|
+
if (inProgress.length > 0) summary += ` In progress: ${inProgress.map(t => `"${t.title}" (${t.assignee || '?'})`).join(', ')}.`;
|
|
3026
|
+
if (blocked.length > 0) summary += ` BLOCKED: ${blocked.map(t => `"${t.title}" (${t.assignee || '?'})`).join(', ')}.`;
|
|
3027
|
+
if (recentDone.length > 0) summary += ` Recently done: ${recentDone.length} task(s).`;
|
|
3028
|
+
summary += ' Each agent: report what you did, what\'s blocked, what\'s next. Then call listen_group().';
|
|
3029
|
+
|
|
3030
|
+
broadcastSystemMessage(summary, registeredName);
|
|
3031
|
+
} catch {}
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
// Auto-recovery: snapshot dead agent state before cleanup
|
|
3035
|
+
// Creates recovery-{name}.json so replacement agent can resume
|
|
3036
|
+
function snapshotDeadAgents(agents) {
|
|
3037
|
+
for (const [name, info] of Object.entries(agents)) {
|
|
3038
|
+
if (name === registeredName) continue; // skip self
|
|
3039
|
+
if (isPidAlive(info.pid, info.last_activity)) continue; // skip alive
|
|
3040
|
+
const recoveryFile = path.join(DATA_DIR, `recovery-${name}.json`);
|
|
3041
|
+
if (fs.existsSync(recoveryFile)) continue; // already snapshotted
|
|
3042
|
+
try {
|
|
3043
|
+
const allTasks = getTasks();
|
|
3044
|
+
const tasks = allTasks.filter(t => t.assignee === name && (t.status === 'in_progress' || t.status === 'pending'));
|
|
3045
|
+
const locks = getLocks();
|
|
3046
|
+
const lockedFiles = Object.entries(locks).filter(([, l]) => l.agent === name).map(([f]) => f);
|
|
3047
|
+
const channels = getAgentChannels(name);
|
|
3048
|
+
const workspace = getWorkspace(name);
|
|
3049
|
+
const history = readJsonl(getHistoryFile(currentBranch));
|
|
3050
|
+
const lastSent = history.filter(m => m.from === name).slice(-5).map(m => ({ to: m.to, content: m.content.substring(0, 200), timestamp: m.timestamp }));
|
|
3051
|
+
// Agent memory: decisions made, tasks completed, KB keys written
|
|
3052
|
+
const decisions = readJsonFile(DECISIONS_FILE) || [];
|
|
3053
|
+
const myDecisions = decisions.filter(d => d.decided_by === name).slice(-10).map(d => ({ decision: d.decision, reasoning: (d.reasoning || '').substring(0, 150), decided_at: d.decided_at }));
|
|
3054
|
+
const completedTasks = allTasks.filter(t => t.assignee === name && t.status === 'done').slice(-10).map(t => ({ id: t.id, title: t.title }));
|
|
3055
|
+
const kb = readJsonFile(KB_FILE) || {};
|
|
3056
|
+
const kbKeysWritten = Object.keys(kb).filter(k => kb[k] && kb[k].updated_by === name);
|
|
3057
|
+
// Only snapshot if there's meaningful state to recover
|
|
3058
|
+
if (tasks.length > 0 || lockedFiles.length > 0 || Object.keys(workspace).length > 0 || myDecisions.length > 0 || completedTasks.length > 0) {
|
|
3059
|
+
writeJsonFile(recoveryFile, {
|
|
3060
|
+
agent: name,
|
|
3061
|
+
died_at: new Date().toISOString(),
|
|
3062
|
+
active_tasks: tasks.map(t => ({ id: t.id, title: t.title, status: t.status, description: (t.description || '').substring(0, 300) })),
|
|
3063
|
+
locked_files: lockedFiles,
|
|
3064
|
+
channels: channels.filter(c => c !== 'general'),
|
|
3065
|
+
workspace_keys: Object.keys(workspace),
|
|
3066
|
+
last_messages_sent: lastSent,
|
|
3067
|
+
decisions_made: myDecisions,
|
|
3068
|
+
tasks_completed: completedTasks,
|
|
3069
|
+
kb_entries_written: kbKeysWritten,
|
|
3070
|
+
});
|
|
3071
|
+
}
|
|
3072
|
+
} catch {}
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
|
|
2750
3076
|
// Auto-cleanup dead agent locks (called from heartbeat)
|
|
2751
3077
|
function cleanStaleLocks() {
|
|
2752
3078
|
const locks = getLocks();
|
|
@@ -2797,11 +3123,14 @@ function fireEvent(eventName, data) {
|
|
|
2797
3123
|
}
|
|
2798
3124
|
}
|
|
2799
3125
|
|
|
2800
|
-
function toolGetGuide() {
|
|
3126
|
+
function toolGetGuide(level = 'standard') {
|
|
2801
3127
|
if (!registeredName) return { error: 'You must call register() first' };
|
|
2802
|
-
|
|
3128
|
+
if (!['minimal', 'standard', 'full'].includes(level)) return { error: 'Level must be "minimal", "standard", or "full"' };
|
|
3129
|
+
const guide = buildGuide(level);
|
|
2803
3130
|
guide.your_name = registeredName;
|
|
2804
|
-
|
|
3131
|
+
if (level !== 'minimal') {
|
|
3132
|
+
guide.workflow = '1. get_briefing → 2. list_tasks/suggest_task → 3. claim task → 4. lock_file → 5. work → 6. unlock_file → 7. update_task done → 8. listen_group';
|
|
3133
|
+
}
|
|
2805
3134
|
return guide;
|
|
2806
3135
|
}
|
|
2807
3136
|
|
|
@@ -3273,6 +3602,8 @@ function trackReputation(agent, action) {
|
|
|
3273
3602
|
bugs_found: 0, messages_sent: 0, decisions_made: 0, votes_cast: 0,
|
|
3274
3603
|
kb_contributions: 0, files_shared: 0, first_seen: new Date().toISOString(),
|
|
3275
3604
|
last_active: new Date().toISOString(), strengths: [],
|
|
3605
|
+
task_times: [], // completion times in seconds for avg calculation
|
|
3606
|
+
response_times: [], // time between being addressed and responding
|
|
3276
3607
|
};
|
|
3277
3608
|
}
|
|
3278
3609
|
const r = rep[agent];
|
|
@@ -3291,6 +3622,14 @@ function trackReputation(agent, action) {
|
|
|
3291
3622
|
case 'bug_found': r.bugs_found++; break;
|
|
3292
3623
|
}
|
|
3293
3624
|
|
|
3625
|
+
// Track task completion time if metadata provided
|
|
3626
|
+
if (action === 'task_complete' && arguments[2]) {
|
|
3627
|
+
const taskTime = arguments[2]; // seconds
|
|
3628
|
+
if (!r.task_times) r.task_times = [];
|
|
3629
|
+
r.task_times.push(taskTime);
|
|
3630
|
+
if (r.task_times.length > 50) r.task_times = r.task_times.slice(-50); // keep last 50
|
|
3631
|
+
}
|
|
3632
|
+
|
|
3294
3633
|
// Auto-detect strengths based on stats
|
|
3295
3634
|
r.strengths = [];
|
|
3296
3635
|
if (r.tasks_completed >= 3) r.strengths.push('productive');
|
|
@@ -3312,14 +3651,20 @@ function toolGetReputation(agent) {
|
|
|
3312
3651
|
}
|
|
3313
3652
|
|
|
3314
3653
|
// All agents with ranking
|
|
3315
|
-
const leaderboard = Object.entries(rep).map(([name, r]) =>
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3654
|
+
const leaderboard = Object.entries(rep).map(([name, r]) => {
|
|
3655
|
+
const avgTaskTime = r.task_times && r.task_times.length > 0
|
|
3656
|
+
? Math.round(r.task_times.reduce((a, b) => a + b, 0) / r.task_times.length) : null;
|
|
3657
|
+
return {
|
|
3658
|
+
agent: name,
|
|
3659
|
+
score: r.tasks_completed * 10 + r.reviews_done * 5 + r.decisions_made * 3 + r.kb_contributions * 2 + r.bugs_found * 8,
|
|
3660
|
+
tasks_completed: r.tasks_completed,
|
|
3661
|
+
reviews_done: r.reviews_done,
|
|
3662
|
+
strengths: r.strengths,
|
|
3663
|
+
avg_task_time_sec: avgTaskTime,
|
|
3664
|
+
messages_sent: r.messages_sent,
|
|
3665
|
+
last_active: r.last_active,
|
|
3666
|
+
};
|
|
3667
|
+
}).sort((a, b) => b.score - a.score);
|
|
3323
3668
|
|
|
3324
3669
|
return { leaderboard, total_agents: leaderboard.length };
|
|
3325
3670
|
}
|
|
@@ -3349,26 +3694,57 @@ function toolSuggestTask() {
|
|
|
3349
3694
|
return { suggestion: 'none', message: 'No pending tasks, reviews, or blocked items. Ask the team what needs doing next.' };
|
|
3350
3695
|
}
|
|
3351
3696
|
|
|
3697
|
+
// Check current workload — don't suggest new tasks if already overloaded
|
|
3698
|
+
const myActiveTasks = tasks.filter(t => t.assignee === registeredName && t.status === 'in_progress');
|
|
3699
|
+
if (myActiveTasks.length >= 3) {
|
|
3700
|
+
return { suggestion: 'finish_first', your_active_tasks: myActiveTasks.map(t => ({ id: t.id, title: t.title })), message: `You already have ${myActiveTasks.length} tasks in progress. Finish one before taking more.` };
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3352
3703
|
// Suggest based on reputation strengths
|
|
3353
|
-
let suggested = pendingTasks[0] || unassignedTasks[0];
|
|
3354
3704
|
if (myRep && myRep.strengths.includes('reviewer')) {
|
|
3355
3705
|
const reviews = getReviews().filter(r => r.status === 'pending' && r.requested_by !== registeredName);
|
|
3356
3706
|
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}".` };
|
|
3357
3707
|
}
|
|
3358
3708
|
|
|
3709
|
+
// Smart matching: score tasks by keyword overlap with agent's completed task history
|
|
3710
|
+
const myDoneTasks = tasks.filter(t => t.assignee === registeredName && t.status === 'done');
|
|
3711
|
+
const myKeywords = new Set();
|
|
3712
|
+
for (const t of myDoneTasks) {
|
|
3713
|
+
const words = (t.title + ' ' + (t.description || '')).toLowerCase().split(/\W+/).filter(w => w.length > 3);
|
|
3714
|
+
words.forEach(w => myKeywords.add(w));
|
|
3715
|
+
}
|
|
3716
|
+
|
|
3717
|
+
let suggested = pendingTasks[0] || unassignedTasks[0];
|
|
3718
|
+
if (myKeywords.size > 0 && pendingTasks.length > 1) {
|
|
3719
|
+
// Score each pending task by keyword overlap
|
|
3720
|
+
let bestScore = 0;
|
|
3721
|
+
for (const task of pendingTasks) {
|
|
3722
|
+
const taskWords = (task.title + ' ' + (task.description || '')).toLowerCase().split(/\W+/).filter(w => w.length > 3);
|
|
3723
|
+
const score = taskWords.filter(w => myKeywords.has(w)).length;
|
|
3724
|
+
if (score > bestScore) { bestScore = score; suggested = task; }
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
|
|
3728
|
+
// Check for blocked tasks that might be unblockable
|
|
3729
|
+
const blockedTasks = tasks.filter(t => t.status === 'blocked');
|
|
3730
|
+
if (blockedTasks.length > 0 && pendingTasks.length === 0) {
|
|
3731
|
+
return { suggestion: 'unblock_task', task: { id: blockedTasks[0].id, title: blockedTasks[0].title }, message: `No pending tasks, but "${blockedTasks[0].title}" is blocked. Can you help unblock it?` };
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3359
3734
|
return {
|
|
3360
3735
|
suggestion: 'task',
|
|
3361
3736
|
task_id: suggested.id,
|
|
3362
3737
|
title: suggested.title,
|
|
3363
3738
|
description: suggested.description,
|
|
3364
3739
|
message: `Suggested: "${suggested.title}". Call update_task("${suggested.id}", "in_progress") to claim it.`,
|
|
3740
|
+
...(myKeywords.size > 0 && { match_reason: 'Based on your completed task history' }),
|
|
3365
3741
|
};
|
|
3366
3742
|
}
|
|
3367
3743
|
|
|
3368
3744
|
// --- MCP Server setup ---
|
|
3369
3745
|
|
|
3370
3746
|
const server = new Server(
|
|
3371
|
-
{ name: 'agent-bridge', version: '4.0
|
|
3747
|
+
{ name: 'agent-bridge', version: '4.2.0' },
|
|
3372
3748
|
{ capabilities: { tools: {} } }
|
|
3373
3749
|
);
|
|
3374
3750
|
|
|
@@ -3618,6 +3994,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
3618
3994
|
},
|
|
3619
3995
|
},
|
|
3620
3996
|
},
|
|
3997
|
+
{
|
|
3998
|
+
name: 'search_messages',
|
|
3999
|
+
description: 'Search conversation history by keyword. Returns matching messages with previews. Useful for finding past discussions, decisions, or code references.',
|
|
4000
|
+
inputSchema: {
|
|
4001
|
+
type: 'object',
|
|
4002
|
+
properties: {
|
|
4003
|
+
query: { type: 'string', description: 'Search term (min 2 chars)' },
|
|
4004
|
+
from: { type: 'string', description: 'Filter by sender agent name (optional)' },
|
|
4005
|
+
limit: { type: 'number', description: 'Max results (default: 20, max: 50)' },
|
|
4006
|
+
},
|
|
4007
|
+
required: ['query'],
|
|
4008
|
+
},
|
|
4009
|
+
},
|
|
3621
4010
|
{
|
|
3622
4011
|
name: 'reset',
|
|
3623
4012
|
description: 'Clear all data files and start fresh. Automatically archives the conversation before clearing.',
|
|
@@ -3793,7 +4182,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
3793
4182
|
{
|
|
3794
4183
|
name: 'join_channel',
|
|
3795
4184
|
description: 'Join or create a channel. Channels let sub-teams communicate without flooding the main conversation. Auto-joined to #general on register. Use channels when team size > 4.',
|
|
3796
|
-
inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Channel name (1-
|
|
4185
|
+
inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Channel name (1-20 chars, e.g. "backend", "testing")' }, description: { type: 'string', description: 'Channel description (optional, max 200 chars)' }, rate_limit: { type: 'object', description: 'Optional rate limit config: { max_sends_per_minute: 10 }. Any member can update.', properties: { max_sends_per_minute: { type: 'number' } } } }, required: ['name'] },
|
|
3797
4186
|
},
|
|
3798
4187
|
{
|
|
3799
4188
|
name: 'leave_channel',
|
|
@@ -3808,8 +4197,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
3808
4197
|
// --- Briefing & Recovery ---
|
|
3809
4198
|
{
|
|
3810
4199
|
name: 'get_guide',
|
|
3811
|
-
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.',
|
|
3812
|
-
inputSchema: { type: 'object', properties: {} },
|
|
4200
|
+
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. Use level="minimal" for a compact refresher (saves context tokens), "full" for complete reference with tool details.',
|
|
4201
|
+
inputSchema: { type: 'object', properties: { level: { type: 'string', enum: ['minimal', 'standard', 'full'], description: 'Guide detail level: "minimal" (~5 rules, saves tokens), "standard" (default, progressive disclosure), "full" (all rules + tool details)' } } },
|
|
3813
4202
|
},
|
|
3814
4203
|
{
|
|
3815
4204
|
name: 'get_briefing',
|
|
@@ -4011,6 +4400,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4011
4400
|
case 'get_summary':
|
|
4012
4401
|
result = toolGetSummary(args?.last_n);
|
|
4013
4402
|
break;
|
|
4403
|
+
case 'search_messages':
|
|
4404
|
+
result = toolSearchMessages(args.query, args?.from, args?.limit);
|
|
4405
|
+
break;
|
|
4014
4406
|
case 'reset':
|
|
4015
4407
|
result = toolReset();
|
|
4016
4408
|
break;
|
|
@@ -4051,7 +4443,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4051
4443
|
result = await toolListenGroup();
|
|
4052
4444
|
break;
|
|
4053
4445
|
case 'join_channel':
|
|
4054
|
-
result = toolJoinChannel(args.name, args?.description);
|
|
4446
|
+
result = toolJoinChannel(args.name, args?.description, args?.rate_limit);
|
|
4055
4447
|
break;
|
|
4056
4448
|
case 'leave_channel':
|
|
4057
4449
|
result = toolLeaveChannel(args.name);
|
|
@@ -4060,7 +4452,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4060
4452
|
result = toolListChannels();
|
|
4061
4453
|
break;
|
|
4062
4454
|
case 'get_guide':
|
|
4063
|
-
result = toolGetGuide();
|
|
4455
|
+
result = toolGetGuide(args?.level);
|
|
4064
4456
|
break;
|
|
4065
4457
|
case 'get_briefing':
|
|
4066
4458
|
result = toolGetBriefing();
|
|
@@ -4209,7 +4601,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4209
4601
|
};
|
|
4210
4602
|
if (repMap[name]) trackReputation(registeredName, repMap[name]);
|
|
4211
4603
|
// Track task completion specifically
|
|
4212
|
-
if (name === 'update_task' && args?.status === 'done')
|
|
4604
|
+
if (name === 'update_task' && args?.status === 'done') {
|
|
4605
|
+
// Calculate task completion time
|
|
4606
|
+
const tasks = getTasks();
|
|
4607
|
+
const doneTask = tasks.find(t => t.id === args.task_id);
|
|
4608
|
+
const taskTimeSec = doneTask ? Math.round((Date.now() - new Date(doneTask.created_at).getTime()) / 1000) : 0;
|
|
4609
|
+
trackReputation(registeredName, 'task_complete', taskTimeSec);
|
|
4610
|
+
}
|
|
4213
4611
|
} catch {}
|
|
4214
4612
|
}
|
|
4215
4613
|
|
|
@@ -4234,6 +4632,37 @@ process.on('exit', () => {
|
|
|
4234
4632
|
unlockAgentsFile(); // Clean up any held lock
|
|
4235
4633
|
unlockConfigFile();
|
|
4236
4634
|
if (registeredName) {
|
|
4635
|
+
try {
|
|
4636
|
+
// Save final status to workspace before exit
|
|
4637
|
+
const ws = getWorkspace(registeredName);
|
|
4638
|
+
ws._status = 'Offline (graceful exit)';
|
|
4639
|
+
ws._status_since = new Date().toISOString();
|
|
4640
|
+
saveWorkspace(registeredName, ws);
|
|
4641
|
+
} catch {}
|
|
4642
|
+
try {
|
|
4643
|
+
// Agent memory: save recovery snapshot with decisions/tasks/KB on graceful exit
|
|
4644
|
+
const recoveryFile = path.join(DATA_DIR, `recovery-${registeredName}.json`);
|
|
4645
|
+
const allTasks = getTasks();
|
|
4646
|
+
const activeTasks = allTasks.filter(t => t.assignee === registeredName && (t.status === 'in_progress' || t.status === 'pending'));
|
|
4647
|
+
const completedTasks = allTasks.filter(t => t.assignee === registeredName && t.status === 'done').slice(-10).map(t => ({ id: t.id, title: t.title }));
|
|
4648
|
+
const decisions = readJsonFile(DECISIONS_FILE) || [];
|
|
4649
|
+
const myDecisions = decisions.filter(d => d.decided_by === registeredName).slice(-10).map(d => ({ decision: d.decision, reasoning: (d.reasoning || '').substring(0, 150), decided_at: d.decided_at }));
|
|
4650
|
+
const kb = readJsonFile(KB_FILE) || {};
|
|
4651
|
+
const kbKeysWritten = Object.keys(kb).filter(k => kb[k] && kb[k].updated_by === registeredName);
|
|
4652
|
+
const history = readJsonl(getHistoryFile(currentBranch));
|
|
4653
|
+
const lastSent = history.filter(m => m.from === registeredName).slice(-5).map(m => ({ to: m.to, content: m.content.substring(0, 200), timestamp: m.timestamp }));
|
|
4654
|
+
fs.writeFileSync(recoveryFile, JSON.stringify({
|
|
4655
|
+
agent: registeredName,
|
|
4656
|
+
died_at: new Date().toISOString(),
|
|
4657
|
+
graceful: true,
|
|
4658
|
+
active_tasks: activeTasks.map(t => ({ id: t.id, title: t.title, status: t.status, description: (t.description || '').substring(0, 300) })),
|
|
4659
|
+
channels: getAgentChannels(registeredName).filter(c => c !== 'general'),
|
|
4660
|
+
last_messages_sent: lastSent,
|
|
4661
|
+
decisions_made: myDecisions,
|
|
4662
|
+
tasks_completed: completedTasks,
|
|
4663
|
+
kb_entries_written: kbKeysWritten,
|
|
4664
|
+
}));
|
|
4665
|
+
} catch {}
|
|
4237
4666
|
try {
|
|
4238
4667
|
const agents = getAgents();
|
|
4239
4668
|
if (agents[registeredName]) {
|
|
@@ -4250,7 +4679,7 @@ async function main() {
|
|
|
4250
4679
|
ensureDataDir();
|
|
4251
4680
|
const transport = new StdioServerTransport();
|
|
4252
4681
|
await server.connect(transport);
|
|
4253
|
-
console.error('Agent Bridge MCP server
|
|
4682
|
+
console.error('Agent Bridge MCP server v4.1.0 running (57 tools)');
|
|
4254
4683
|
}
|
|
4255
4684
|
|
|
4256
4685
|
main().catch(console.error);
|