let-them-talk 3.3.3 → 3.4.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/dashboard.js CHANGED
@@ -228,6 +228,74 @@ function apiStatus(query) {
228
228
  };
229
229
  }
230
230
 
231
+ function apiStats(query) {
232
+ const projectPath = query.get('project') || null;
233
+ const history = readJsonl(filePath('history.jsonl', projectPath));
234
+ const agents = readJson(filePath('agents.json', projectPath));
235
+
236
+ // Per-agent stats
237
+ const perAgent = {};
238
+ let totalMessages = history.length;
239
+ const hourBuckets = new Array(24).fill(0);
240
+
241
+ for (let i = 0; i < history.length; i++) {
242
+ const m = history[i];
243
+ const from = m.from || 'unknown';
244
+ if (!perAgent[from]) {
245
+ perAgent[from] = { messages: 0, responseTimes: [], hours: new Array(24).fill(0) };
246
+ }
247
+ perAgent[from].messages++;
248
+ const ts = new Date(m.timestamp);
249
+ const hour = ts.getHours();
250
+ perAgent[from].hours[hour]++;
251
+ hourBuckets[hour]++;
252
+
253
+ // Compute response time if this is a reply
254
+ if (m.reply_to) {
255
+ for (let j = i - 1; j >= Math.max(0, i - 50); j--) {
256
+ if (history[j].id === m.reply_to) {
257
+ const delta = ts.getTime() - new Date(history[j].timestamp).getTime();
258
+ if (delta > 0 && delta < 3600000) perAgent[from].responseTimes.push(delta);
259
+ break;
260
+ }
261
+ }
262
+ }
263
+ }
264
+
265
+ // Build per-agent summary
266
+ const agentStats = {};
267
+ let busiestAgent = null;
268
+ let busiestCount = 0;
269
+ for (const [name, data] of Object.entries(perAgent)) {
270
+ const avgResponseMs = data.responseTimes.length
271
+ ? Math.round(data.responseTimes.reduce((a, b) => a + b, 0) / data.responseTimes.length)
272
+ : null;
273
+ const peakHour = data.hours.indexOf(Math.max(...data.hours));
274
+ agentStats[name] = {
275
+ messages: data.messages,
276
+ avg_response_ms: avgResponseMs,
277
+ peak_hour: peakHour,
278
+ };
279
+ if (data.messages > busiestCount) {
280
+ busiestCount = data.messages;
281
+ busiestAgent = name;
282
+ }
283
+ }
284
+
285
+ // Conversation velocity (messages per minute over last 10 minutes)
286
+ const tenMinAgo = Date.now() - 600000;
287
+ const recentCount = history.filter(m => new Date(m.timestamp).getTime() > tenMinAgo).length;
288
+ const velocity = Math.round((recentCount / 10) * 10) / 10;
289
+
290
+ return {
291
+ total_messages: totalMessages,
292
+ busiest_agent: busiestAgent,
293
+ velocity_per_min: velocity,
294
+ hour_distribution: hourBuckets,
295
+ agents: agentStats,
296
+ };
297
+ }
298
+
231
299
  function apiReset(query) {
232
300
  const projectPath = query.get('project') || null;
233
301
  const dataDir = resolveDataDir(projectPath);
@@ -528,9 +596,12 @@ function apiUpdateTask(body, query) {
528
596
  const task = tasks.find(t => t.id === body.task_id);
529
597
  if (!task) return { error: 'Task not found' };
530
598
 
599
+ const validStatuses = ['pending', 'in_progress', 'done', 'blocked'];
600
+ if (!validStatuses.includes(body.status)) return { error: 'Invalid status. Must be: ' + validStatuses.join(', ') };
531
601
  task.status = body.status;
532
602
  task.updated_at = new Date().toISOString();
533
603
  if (body.notes) {
604
+ if (!Array.isArray(task.notes)) task.notes = [];
534
605
  task.notes.push({ by: 'Dashboard', text: body.notes, at: new Date().toISOString() });
535
606
  }
536
607
 
@@ -662,6 +733,233 @@ function apiLaunchAgent(body) {
662
733
  };
663
734
  }
664
735
 
736
+ // --- v3.4: Message Edit ---
737
+ function apiEditMessage(body, query) {
738
+ const projectPath = query.get('project') || null;
739
+ const { id, content } = body;
740
+ if (!id || !content) return { error: 'Missing "id" and/or "content" fields' };
741
+ if (content.length > 50000) return { error: 'Content too long (max 50000 chars)' };
742
+
743
+ const dataDir = resolveDataDir(projectPath);
744
+ const historyFile = path.join(dataDir, 'history.jsonl');
745
+ const messagesFile = path.join(dataDir, 'messages.jsonl');
746
+
747
+ let found = false;
748
+ const now = new Date().toISOString();
749
+
750
+ // Update in history.jsonl
751
+ if (fs.existsSync(historyFile)) {
752
+ const lines = fs.readFileSync(historyFile, 'utf8').trim().split('\n').filter(Boolean);
753
+ const updated = lines.map(line => {
754
+ try {
755
+ const msg = JSON.parse(line);
756
+ if (msg.id === id) {
757
+ found = true;
758
+ if (!msg.edit_history) msg.edit_history = [];
759
+ msg.edit_history.push({ content: msg.content, edited_at: now });
760
+ msg.content = content;
761
+ msg.edited = true;
762
+ msg.edited_at = now;
763
+ return JSON.stringify(msg);
764
+ }
765
+ return line;
766
+ } catch { return line; }
767
+ });
768
+ if (found) fs.writeFileSync(historyFile, updated.join('\n') + '\n');
769
+ }
770
+
771
+ // Also update in messages.jsonl (for agents that haven't consumed yet)
772
+ if (found && fs.existsSync(messagesFile)) {
773
+ const raw = fs.readFileSync(messagesFile, 'utf8').trim();
774
+ if (raw) {
775
+ const lines = raw.split('\n');
776
+ const updated = lines.map(line => {
777
+ try {
778
+ const msg = JSON.parse(line);
779
+ if (msg.id === id) {
780
+ msg.content = content;
781
+ msg.edited = true;
782
+ msg.edited_at = now;
783
+ return JSON.stringify(msg);
784
+ }
785
+ return line;
786
+ } catch { return line; }
787
+ });
788
+ fs.writeFileSync(messagesFile, updated.join('\n') + '\n');
789
+ }
790
+ }
791
+
792
+ if (!found) return { error: 'Message not found' };
793
+ return { success: true, id, edited_at: now };
794
+ }
795
+
796
+ // --- v3.4: Message Delete ---
797
+ function apiDeleteMessage(body, query) {
798
+ const projectPath = query.get('project') || null;
799
+ const { id } = body;
800
+ if (!id) return { error: 'Missing "id" field' };
801
+
802
+ const dataDir = resolveDataDir(projectPath);
803
+ const historyFile = path.join(dataDir, 'history.jsonl');
804
+ const messagesFile = path.join(dataDir, 'messages.jsonl');
805
+
806
+ let found = false;
807
+ let msgFrom = null;
808
+
809
+ // Find the message first to check permissions
810
+ if (fs.existsSync(historyFile)) {
811
+ const lines = fs.readFileSync(historyFile, 'utf8').trim().split('\n');
812
+ for (const line of lines) {
813
+ try {
814
+ const msg = JSON.parse(line);
815
+ if (msg.id === id) { found = true; msgFrom = msg.from; break; }
816
+ } catch {}
817
+ }
818
+ }
819
+
820
+ if (!found) return { error: 'Message not found' };
821
+
822
+ // Only allow deleting dashboard-injected or system messages
823
+ const allowed = ['Dashboard', 'dashboard', 'system', '__system__'];
824
+ if (!allowed.includes(msgFrom)) {
825
+ return { error: 'Can only delete messages sent from Dashboard or system' };
826
+ }
827
+
828
+ // Remove from history.jsonl
829
+ if (fs.existsSync(historyFile)) {
830
+ const lines = fs.readFileSync(historyFile, 'utf8').trim().split('\n');
831
+ const filtered = lines.filter(line => {
832
+ try { return JSON.parse(line).id !== id; } catch { return true; }
833
+ });
834
+ fs.writeFileSync(historyFile, filtered.join('\n') + (filtered.length ? '\n' : ''));
835
+ }
836
+
837
+ // Remove from messages.jsonl
838
+ if (fs.existsSync(messagesFile)) {
839
+ const lines = fs.readFileSync(messagesFile, 'utf8').trim().split('\n');
840
+ const filtered = lines.filter(line => {
841
+ try { return JSON.parse(line).id !== id; } catch { return true; }
842
+ });
843
+ fs.writeFileSync(messagesFile, filtered.join('\n') + (filtered.length ? '\n' : ''));
844
+ }
845
+
846
+ return { success: true, id };
847
+ }
848
+
849
+ // --- v3.4: Conversation Templates ---
850
+ function apiGetConversationTemplates() {
851
+ const templatesDir = path.join(__dirname, 'conversation-templates');
852
+ if (!fs.existsSync(templatesDir)) {
853
+ // Return built-in templates
854
+ return getBuiltInConversationTemplates();
855
+ }
856
+ const custom = fs.readdirSync(templatesDir)
857
+ .filter(f => f.endsWith('.json'))
858
+ .map(f => { try { return JSON.parse(fs.readFileSync(path.join(templatesDir, f), 'utf8')); } catch { return null; } })
859
+ .filter(Boolean);
860
+ return [...getBuiltInConversationTemplates(), ...custom];
861
+ }
862
+
863
+ function getBuiltInConversationTemplates() {
864
+ return [
865
+ {
866
+ id: 'code-review',
867
+ name: 'Code Review Pipeline',
868
+ description: 'Developer writes code, Reviewer checks it, Tester validates',
869
+ agents: [
870
+ { name: 'Developer', role: 'Developer', prompt: 'You are a developer. Write code as instructed. After completing, send your code to Reviewer for review.' },
871
+ { name: 'Reviewer', role: 'Code Reviewer', prompt: 'You are a code reviewer. Wait for code from Developer. Review it for bugs, style, and best practices. Send feedback back to Developer or approve and forward to Tester.' },
872
+ { name: 'Tester', role: 'QA Tester', prompt: 'You are a QA tester. Wait for approved code from Reviewer. Write and run tests. Report results back to the team.' }
873
+ ],
874
+ workflow: { name: 'Code Review', steps: ['Write Code', 'Review', 'Test', 'Approve'] }
875
+ },
876
+ {
877
+ id: 'debug-squad',
878
+ name: 'Debug Squad',
879
+ description: 'Investigator finds the bug, Fixer patches it, Verifier confirms the fix',
880
+ agents: [
881
+ { name: 'Investigator', role: 'Bug Investigator', prompt: 'You investigate bugs. Analyze error logs, trace code paths, and identify root causes. Send findings to Fixer.' },
882
+ { name: 'Fixer', role: 'Bug Fixer', prompt: 'You fix bugs. Wait for findings from Investigator. Implement fixes and send to Verifier for confirmation.' },
883
+ { name: 'Verifier', role: 'Fix Verifier', prompt: 'You verify bug fixes. Wait for patches from Fixer. Test the fix and confirm resolution or send back for more work.' }
884
+ ],
885
+ workflow: { name: 'Bug Fix', steps: ['Investigate', 'Fix', 'Verify', 'Close'] }
886
+ },
887
+ {
888
+ id: 'feature-build',
889
+ name: 'Feature Development',
890
+ description: 'Architect designs, Builder implements, Reviewer approves',
891
+ agents: [
892
+ { name: 'Architect', role: 'Software Architect', prompt: 'You are a software architect. Design the feature architecture, define interfaces, and create the implementation plan. Send the plan to Builder.' },
893
+ { name: 'Builder', role: 'Developer', prompt: 'You are a developer. Wait for architecture plans from Architect. Implement the feature following the design. Send completed code to Reviewer.' },
894
+ { name: 'Reviewer', role: 'Senior Reviewer', prompt: 'You are a senior reviewer. Review implementations from Builder against the architecture from Architect. Approve or request changes.' }
895
+ ],
896
+ workflow: { name: 'Feature Dev', steps: ['Design', 'Implement', 'Review', 'Ship'] }
897
+ },
898
+ {
899
+ id: 'research-write',
900
+ name: 'Research & Write',
901
+ description: 'Researcher gathers info, Writer creates content, Editor polishes',
902
+ agents: [
903
+ { name: 'Researcher', role: 'Researcher', prompt: 'You are a researcher. Gather information on the given topic. Organize findings and send a research brief to Writer.' },
904
+ { name: 'Writer', role: 'Writer', prompt: 'You are a writer. Wait for research from Researcher. Write clear, well-structured content based on the findings. Send to Editor.' },
905
+ { name: 'Editor', role: 'Editor', prompt: 'You are an editor. Review and polish content from Writer. Check for clarity, accuracy, and style. Send back final version or request revisions.' }
906
+ ],
907
+ workflow: { name: 'Content Pipeline', steps: ['Research', 'Draft', 'Edit', 'Publish'] }
908
+ }
909
+ ];
910
+ }
911
+
912
+ function apiLaunchConversationTemplate(body, query) {
913
+ const projectPath = query.get('project') || null;
914
+ const { template_id } = body;
915
+ if (!template_id) return { error: 'Missing template_id' };
916
+
917
+ const templates = apiGetConversationTemplates();
918
+ const template = templates.find(t => t.id === template_id);
919
+ if (!template) return { error: 'Template not found: ' + template_id };
920
+
921
+ // Return the template config for the frontend to display launch instructions
922
+ return {
923
+ success: true,
924
+ template,
925
+ instructions: template.agents.map(a => ({
926
+ agent_name: a.name,
927
+ role: a.role,
928
+ prompt: `You are "${a.name}" with role "${a.role}". ${a.prompt}\n\nFirst register yourself with: register(name="${a.name}"), then update_profile(role="${a.role}"). Then enter listen mode.`
929
+ }))
930
+ };
931
+ }
932
+
933
+ // --- v3.4: Agent Permissions ---
934
+ function apiUpdatePermissions(body, query) {
935
+ const projectPath = query.get('project') || null;
936
+ const dataDir = resolveDataDir(projectPath);
937
+ const permFile = path.join(dataDir, 'permissions.json');
938
+
939
+ const { agent, permissions } = body;
940
+ if (!agent || !permissions) return { error: 'Missing "agent" and/or "permissions" fields' };
941
+
942
+ let perms = {};
943
+ if (fs.existsSync(permFile)) {
944
+ try { perms = JSON.parse(fs.readFileSync(permFile, 'utf8')); } catch {}
945
+ }
946
+
947
+ // permissions: { can_read: [agents...] or "*", can_write_to: [agents...] or "*", is_admin: bool }
948
+ const allowed = {};
949
+ if (permissions.can_read !== undefined) allowed.can_read = permissions.can_read;
950
+ if (permissions.can_write_to !== undefined) allowed.can_write_to = permissions.can_write_to;
951
+ if (permissions.is_admin !== undefined) allowed.is_admin = !!permissions.is_admin;
952
+ perms[agent] = {
953
+ ...perms[agent],
954
+ ...allowed,
955
+ updated_at: new Date().toISOString()
956
+ };
957
+
958
+ if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
959
+ fs.writeFileSync(permFile, JSON.stringify(perms, null, 2));
960
+ return { success: true, agent, permissions: perms[agent] };
961
+ }
962
+
665
963
  // --- HTTP Server ---
666
964
 
667
965
  // Load HTML at startup (re-read on each request in dev for hot-reload)
@@ -696,7 +994,7 @@ const server = http.createServer(async (req, res) => {
696
994
  } else if (reqOrigin === allowedOrigin || reqOrigin === `http://127.0.0.1:${PORT}`) {
697
995
  res.setHeader('Access-Control-Allow-Origin', reqOrigin);
698
996
  }
699
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
997
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
700
998
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
701
999
 
702
1000
  if (req.method === 'OPTIONS') {
@@ -706,7 +1004,7 @@ const server = http.createServer(async (req, res) => {
706
1004
  }
707
1005
 
708
1006
  // CSRF + DNS rebinding protection: validate Host and Origin on mutating requests
709
- if (req.method === 'POST' || req.method === 'DELETE') {
1007
+ if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') {
710
1008
  // Check Host header to block DNS rebinding attacks
711
1009
  const host = (req.headers.host || '').replace(/:\d+$/, '');
712
1010
  const validHosts = ['localhost', '127.0.0.1'];
@@ -775,6 +1073,10 @@ const server = http.createServer(async (req, res) => {
775
1073
  res.writeHead(200, { 'Content-Type': 'application/json' });
776
1074
  res.end(JSON.stringify(apiStatus(url.searchParams)));
777
1075
  }
1076
+ else if (url.pathname === '/api/stats' && req.method === 'GET') {
1077
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1078
+ res.end(JSON.stringify(apiStats(url.searchParams)));
1079
+ }
778
1080
  else if (url.pathname === '/api/reset' && req.method === 'POST') {
779
1081
  res.writeHead(200, { 'Content-Type': 'application/json' });
780
1082
  res.end(JSON.stringify(apiReset(url.searchParams)));
@@ -953,6 +1255,43 @@ const server = http.createServer(async (req, res) => {
953
1255
  res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
954
1256
  res.end(JSON.stringify(result));
955
1257
  }
1258
+ // --- v3.4: Message Edit ---
1259
+ else if (url.pathname === '/api/message' && req.method === 'PUT') {
1260
+ const body = await parseBody(req);
1261
+ const result = apiEditMessage(body, url.searchParams);
1262
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
1263
+ res.end(JSON.stringify(result));
1264
+ }
1265
+ // --- v3.4: Message Delete ---
1266
+ else if (url.pathname === '/api/message' && req.method === 'DELETE') {
1267
+ const body = await parseBody(req);
1268
+ const result = apiDeleteMessage(body, url.searchParams);
1269
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
1270
+ res.end(JSON.stringify(result));
1271
+ }
1272
+ // --- v3.4: Conversation Templates ---
1273
+ else if (url.pathname === '/api/conversation-templates' && req.method === 'GET') {
1274
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1275
+ res.end(JSON.stringify(apiGetConversationTemplates()));
1276
+ }
1277
+ else if (url.pathname === '/api/conversation-templates/launch' && req.method === 'POST') {
1278
+ const body = await parseBody(req);
1279
+ const result = apiLaunchConversationTemplate(body, url.searchParams);
1280
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
1281
+ res.end(JSON.stringify(result));
1282
+ }
1283
+ // --- v3.4: Agent Permissions ---
1284
+ else if (url.pathname === '/api/permissions' && req.method === 'GET') {
1285
+ const projectPath = url.searchParams.get('project') || null;
1286
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1287
+ res.end(JSON.stringify(readJson(filePath('permissions.json', projectPath))));
1288
+ }
1289
+ else if (url.pathname === '/api/permissions' && req.method === 'POST') {
1290
+ const body = await parseBody(req);
1291
+ const result = apiUpdatePermissions(body, url.searchParams);
1292
+ res.writeHead(result.error ? 400 : 200, { 'Content-Type': 'application/json' });
1293
+ res.end(JSON.stringify(result));
1294
+ }
956
1295
  // Server info (LAN mode detection for frontend)
957
1296
  else if (url.pathname === '/api/server-info' && req.method === 'GET') {
958
1297
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1081,7 +1420,7 @@ server.listen(PORT, LAN_MODE ? '0.0.0.0' : '127.0.0.1', () => {
1081
1420
  const dataDir = resolveDataDir();
1082
1421
  const lanIP = getLanIP();
1083
1422
  console.log('');
1084
- console.log(' Let Them Talk - Agent Bridge Dashboard v3.3.3');
1423
+ console.log(' Let Them Talk - Agent Bridge Dashboard v3.4.0');
1085
1424
  console.log(' ============================================');
1086
1425
  console.log(' Dashboard: http://localhost:' + PORT);
1087
1426
  if (LAN_MODE && lanIP) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "let-them-talk",
3
- "version": "3.3.3",
3
+ "version": "3.4.0",
4
4
  "description": "MCP message broker + web dashboard for inter-agent communication. Let AI CLI agents talk to each other.",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "dashboard.html",
22
22
  "cli.js",
23
23
  "templates/",
24
+ "conversation-templates/",
24
25
  "logo.png",
25
26
  "LICENSE",
26
27
  "SECURITY.md",
package/server.js CHANGED
@@ -2021,7 +2021,7 @@ async function main() {
2021
2021
  loadPlugins();
2022
2022
  const transport = new StdioServerTransport();
2023
2023
  await server.connect(transport);
2024
- console.error('Agent Bridge MCP server v3.3.3 running (' + (27 + loadedPlugins.length) + ' tools)');
2024
+ console.error('Agent Bridge MCP server v3.4.0 running (' + (27 + loadedPlugins.length) + ' tools)');
2025
2025
  }
2026
2026
 
2027
2027
  main().catch(console.error);