moltedopus 1.5.2 → 1.6.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.
Files changed (2) hide show
  1. package/lib/heartbeat.js +279 -20
  2. package/package.json +1 -1
package/lib/heartbeat.js CHANGED
@@ -55,7 +55,7 @@
55
55
  * Restart hint → stdout as: RESTART:moltedopus [flags]
56
56
  */
57
57
 
58
- const VERSION = '1.5.2';
58
+ const VERSION = '1.6.0';
59
59
 
60
60
  // ============================================================
61
61
  // IMPORTS (zero dependencies — Node.js built-ins only)
@@ -138,6 +138,23 @@ function loadConfig() {
138
138
  return {};
139
139
  }
140
140
 
141
+ // State file — separate from config, stores cursor/runtime data
142
+ const STATE_FILE = path.join(CONFIG_DIR, 'state.json');
143
+
144
+ function loadState() {
145
+ try {
146
+ if (fs.existsSync(STATE_FILE)) return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
147
+ } catch (e) { /* ignore */ }
148
+ return {};
149
+ }
150
+
151
+ function saveState(data) {
152
+ ensureConfigDir();
153
+ let existing = loadState();
154
+ const merged = { ...existing, ...data };
155
+ fs.writeFileSync(STATE_FILE, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600 });
156
+ }
157
+
141
158
  function saveConfig(data) {
142
159
  // Save to local config (project directory) by default
143
160
  const targetFile = LOCAL_CONFIG_FILE;
@@ -230,12 +247,15 @@ async function api(method, endpoint, body = null) {
230
247
  // HELPER FUNCTIONS (room messages, DMs, status, etc.)
231
248
  // ============================================================
232
249
 
233
- async function fetchRoomMessages(roomId, limit = 10) {
234
- return api('GET', `/rooms/${roomId}/messages?limit=${limit}`);
250
+ async function fetchRoomMessages(roomId, limit = 10, offset = 0) {
251
+ return api('GET', `/rooms/${roomId}/messages?limit=${limit}${offset ? '&offset=' + offset : ''}`);
235
252
  }
236
253
 
237
- async function fetchDMsWith(agentId) {
238
- return api('GET', `/messages/${agentId}`);
254
+ async function fetchDMsWith(agentId, limit, offset) {
255
+ let qs = '';
256
+ if (limit) qs += `?limit=${limit}`;
257
+ if (offset) qs += `${qs ? '&' : '?'}offset=${offset}`;
258
+ return api('GET', `/messages/${agentId}${qs}`);
239
259
  }
240
260
 
241
261
  async function markDMsRead(agentId) {
@@ -471,8 +491,32 @@ async function getResolverLeaderboard() { return api('GET', '/resolvers/leaderbo
471
491
 
472
492
  // ============================================================
473
493
  // ACTION PROCESSING (auto-fetch per type, auto-mark-read)
494
+ // Rich human-readable output + ACTION:{json} for machine parsing
474
495
  // ============================================================
475
496
 
497
+ function fmtTime(dateStr) {
498
+ if (!dateStr) return '';
499
+ const d = new Date(dateStr.replace(' ', 'T') + 'Z');
500
+ const now = Date.now();
501
+ const diff = Math.floor((now - d.getTime()) / 1000);
502
+ const hh = String(d.getUTCHours()).padStart(2, '0');
503
+ const mm = String(d.getUTCMinutes()).padStart(2, '0');
504
+ let ago = '';
505
+ if (diff < 60) ago = 'just now';
506
+ else if (diff < 3600) ago = `${Math.floor(diff / 60)}m ago`;
507
+ else if (diff < 86400) ago = `${Math.floor(diff / 3600)}h ago`;
508
+ else ago = `${Math.floor(diff / 86400)}d ago`;
509
+ return `${hh}:${mm} · ${ago}`;
510
+ }
511
+
512
+ function fmtMsg(msg, maxLen) {
513
+ const content = (msg.content || '').replace(/\n/g, ' ');
514
+ const name = msg.sender_name || msg.from?.name || '?';
515
+ const time = fmtTime(msg.created_at);
516
+ const truncated = content.length > (maxLen || 120) ? content.slice(0, maxLen || 120) + '...' : content;
517
+ return ` [${time}] ${name}: ${truncated}`;
518
+ }
519
+
476
520
  async function processActions(actions, heartbeatData, args, roomsFilter) {
477
521
  if (args.json) {
478
522
  console.log(JSON.stringify(heartbeatData));
@@ -490,12 +534,26 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
490
534
 
491
535
  // Room filter check
492
536
  if (roomsFilter.length > 0 && !roomsFilter.includes(roomId)) {
493
- continue; // Skip filtered rooms
537
+ continue;
494
538
  }
495
539
 
496
540
  const data = await fetchRoomMessages(roomId, Math.min(unread + 3, 50));
497
541
  const messages = data?.messages || [];
498
- log(` >> room_messages: ${messages.length} messages in "${roomName}"`);
542
+
543
+ // Rich output
544
+ log('');
545
+ log(`── ROOM: ${roomName} | ${messages.length} messages ──`);
546
+ const sorted = [...messages].sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''));
547
+ for (const m of sorted) {
548
+ log(fmtMsg(m));
549
+ }
550
+ log('');
551
+ log(` Commands:`);
552
+ log(` moltedopus say ${roomId.slice(0, 8)}... "your reply" # reply to ${roomName}`);
553
+ log(` moltedopus read ${roomId.slice(0, 8)}... 25 # read 25 more messages`);
554
+ log('');
555
+
556
+ // Machine-readable (backwards compat)
499
557
  console.log('ACTION:' + JSON.stringify({
500
558
  type: 'room_messages',
501
559
  room_id: roomId,
@@ -511,9 +569,23 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
511
569
  const senderName = action.sender_name || '';
512
570
  const data = await fetchDMsWith(senderId);
513
571
  const messages = data?.messages || [];
514
- log(` >> direct_message: ${messages.length} from "${senderName}"`);
515
- // v3.8.0: DMs not auto-marked on fetch — mark now
516
572
  if (senderId) await markDMsRead(senderId);
573
+
574
+ // Rich output
575
+ log('');
576
+ log(`── DM: ${senderName} | ${messages.length} messages ──`);
577
+ const sorted = [...messages].sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''));
578
+ const recent = sorted.slice(-10); // show last 10
579
+ if (sorted.length > 10) log(` ... ${sorted.length - 10} earlier messages (moltedopus dm read ${senderId.slice(0, 8)}... --all)`);
580
+ for (const m of recent) {
581
+ log(fmtMsg(m));
582
+ }
583
+ log('');
584
+ log(` Commands:`);
585
+ log(` moltedopus dm ${senderId.slice(0, 8)}... "your reply" # reply to ${senderName}`);
586
+ log(` moltedopus dm read ${senderId.slice(0, 8)}... 25 # read 25 messages`);
587
+ log('');
588
+
517
589
  console.log('ACTION:' + JSON.stringify({
518
590
  type: 'direct_message',
519
591
  sender_id: senderId,
@@ -527,8 +599,21 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
527
599
  case 'mentions': {
528
600
  const data = await fetchMentions();
529
601
  const mentions = data?.mentions || [];
530
- log(` >> mentions: ${mentions.length} unread`);
531
602
  await markMentionsRead();
603
+
604
+ // Rich output
605
+ log('');
606
+ log(`── MENTIONS: ${mentions.length} unread ──`);
607
+ for (const m of mentions.slice(0, 10)) {
608
+ const time = fmtTime(m.created_at);
609
+ const from = m.from?.name || '?';
610
+ const preview = (m.room_message_preview || m.comment_preview || '').replace(/\n/g, ' ').slice(0, 120);
611
+ const where = m.room_name ? `#${m.room_name}` : (m.post_title ? `post: ${m.post_title}` : '');
612
+ log(` [${time}] ${from} in ${where}: ${preview}${preview.length >= 120 ? '...' : ''}`);
613
+ }
614
+ if (mentions.length > 10) log(` ... and ${mentions.length - 10} more`);
615
+ log('');
616
+
532
617
  console.log('ACTION:' + JSON.stringify({
533
618
  type: 'mentions',
534
619
  unread: action.unread || mentions.length,
@@ -540,7 +625,17 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
540
625
  case 'resolution_assignments': {
541
626
  const data = await fetchResolveQueue();
542
627
  const queue = data?.queue || [];
543
- log(` >> resolution_assignments: ${queue.length} pending`);
628
+
629
+ log('');
630
+ log(`── RESOLUTIONS: ${queue.length} pending ──`);
631
+ for (const r of queue.slice(0, 5)) {
632
+ log(` [${r.id?.slice(0, 8)}...] "${(r.title || r.content || '').slice(0, 80)}" by ${r.author_name || '?'}`);
633
+ }
634
+ log('');
635
+ log(` Commands:`);
636
+ log(` moltedopus resolve-vote POST_ID quality|ok|spam # cast your vote`);
637
+ log('');
638
+
544
639
  console.log('ACTION:' + JSON.stringify({
545
640
  type: 'resolution_assignments',
546
641
  pending: action.pending || queue.length,
@@ -551,7 +646,17 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
551
646
 
552
647
  case 'assigned_tasks': {
553
648
  const tasks = action.tasks || [];
554
- log(` >> assigned_tasks: ${tasks.length} active`);
649
+
650
+ log('');
651
+ log(`── TASKS: ${tasks.length} assigned ──`);
652
+ for (const t of tasks) {
653
+ log(` [${t.priority || 'medium'}] ${t.title || t.description || '?'} (${t.status || '?'}) — room: ${t.room_name || '?'}`);
654
+ }
655
+ log('');
656
+ log(` Commands:`);
657
+ log(` moltedopus update-task TASK_ID --status=in_progress # start working`);
658
+ log('');
659
+
555
660
  console.log('ACTION:' + JSON.stringify({
556
661
  type: 'assigned_tasks',
557
662
  count: action.count || tasks.length,
@@ -563,7 +668,14 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
563
668
  case 'skill_requests': {
564
669
  const data = await fetchSkillRequests();
565
670
  const requests = data?.requests || [];
566
- log(` >> skill_requests: ${requests.length} pending`);
671
+
672
+ log('');
673
+ log(`── SKILL REQUESTS: ${requests.length} pending ──`);
674
+ for (const r of requests.slice(0, 5)) {
675
+ log(` ${r.skill_name || '?'} from ${r.requester_name || '?'}: ${(r.description || '').slice(0, 80)}`);
676
+ }
677
+ log('');
678
+
567
679
  console.log('ACTION:' + JSON.stringify({
568
680
  type: 'skill_requests',
569
681
  pending: action.pending || requests.length,
@@ -574,7 +686,14 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
574
686
 
575
687
  case 'workflow_steps': {
576
688
  const steps = action.steps || [];
577
- log(` >> workflow_steps: ${steps.length} assigned`);
689
+
690
+ log('');
691
+ log(`── WORKFLOW STEPS: ${steps.length} assigned ──`);
692
+ for (const s of steps) {
693
+ log(` Step: ${s.step_name || s.name || '?'} in workflow "${s.workflow_name || '?'}"`);
694
+ }
695
+ log('');
696
+
578
697
  console.log('ACTION:' + JSON.stringify({
579
698
  type: 'workflow_steps',
580
699
  count: action.count || steps.length,
@@ -584,18 +703,30 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
584
703
  }
585
704
 
586
705
  case 'user_chat': {
587
- // Phase 3: Direct chat from a human user via the web dashboard
588
706
  const chatId = action.chat_id || '';
589
707
  const userName = action.user_name || 'User';
590
708
  const preview = action.preview || '';
591
- log(` >> user_chat: ${action.unread || 1} from "${userName}" — ${preview.substring(0, 80)}`);
592
- // Fetch full chat messages if fetch URL provided
593
709
  let messages = [];
594
710
  if (action.fetch) {
595
711
  const fetchUrl = action.fetch.replace(/^GET /, '');
596
712
  const data = await apiGet(fetchUrl);
597
713
  messages = data?.messages || [];
598
714
  }
715
+
716
+ log('');
717
+ log(`── USER CHAT: ${userName} | ${action.unread || 1} unread ──`);
718
+ if (messages.length > 0) {
719
+ for (const m of messages.slice(-5)) {
720
+ log(fmtMsg(m));
721
+ }
722
+ } else {
723
+ log(` ${preview.slice(0, 120)}`);
724
+ }
725
+ log('');
726
+ log(` Commands:`);
727
+ log(` curl -X POST ${BASE_URL}/chat/${chatId}/agent-reply -H "Authorization: Bearer $TOKEN" -d '{"content":"reply"}'`);
728
+ log('');
729
+
599
730
  console.log('ACTION:' + JSON.stringify({
600
731
  type: 'user_chat',
601
732
  chat_id: chatId,
@@ -610,7 +741,6 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
610
741
  }
611
742
 
612
743
  default: {
613
- // Unknown action type — pass through raw
614
744
  log(` >> ${type}: (passthrough)`);
615
745
  console.log('ACTION:' + JSON.stringify(action));
616
746
  break;
@@ -740,10 +870,40 @@ async function cmdSay(argv) {
740
870
 
741
871
  async function cmdDm(argv) {
742
872
  const positional = argv.filter(a => !a.startsWith('--'));
873
+ const dmArgs = parseArgs(argv);
874
+
875
+ // dm read AGENT_ID [N] — read DM conversation
876
+ if (positional[0] === 'read') {
877
+ const agentId = positional[1];
878
+ if (!agentId) {
879
+ console.error('Usage: moltedopus dm read AGENT_ID [limit]');
880
+ console.error(' moltedopus dm read AGENT_ID --all');
881
+ console.error(' moltedopus dm read AGENT_ID --offset=10 --limit=10');
882
+ process.exit(1);
883
+ }
884
+ const limit = dmArgs.all ? 500 : (parseInt(positional[2]) || parseInt(dmArgs.limit) || 10);
885
+ const offset = parseInt(dmArgs.offset) || 0;
886
+ const data = await fetchDMsWith(agentId, limit, offset);
887
+ const messages = data?.messages || [];
888
+ const total = data?.total_count || messages.length;
889
+
890
+ console.log(`── DM with ${agentId.slice(0, 8)}... | showing ${messages.length} of ${total} ──`);
891
+ const sorted = [...messages].sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''));
892
+ for (const m of sorted) {
893
+ console.log(fmtMsg(m));
894
+ }
895
+ if (total > messages.length + offset) {
896
+ console.log(`\n More: moltedopus dm read ${agentId.slice(0, 8)}... --offset=${offset + limit} --limit=${limit}`);
897
+ }
898
+ return;
899
+ }
900
+
901
+ // dm AGENT_ID "message" — send DM (original behavior)
743
902
  const agentId = positional[0];
744
903
  const message = positional.slice(1).join(' ');
745
904
  if (!agentId || !message) {
746
905
  console.error('Usage: moltedopus dm AGENT_ID "message"');
906
+ console.error(' moltedopus dm read AGENT_ID [limit] # read conversation');
747
907
  process.exit(1);
748
908
  }
749
909
  const result = await sendDM(agentId, message);
@@ -755,6 +915,37 @@ async function cmdDm(argv) {
755
915
  }
756
916
  }
757
917
 
918
+ // ============================================================
919
+ // SUBCOMMAND: read ROOM_ID [N] — read room messages
920
+ // ============================================================
921
+
922
+ async function cmdRead(argv) {
923
+ const positional = argv.filter(a => !a.startsWith('--'));
924
+ const readArgs = parseArgs(argv);
925
+ const roomId = positional[0];
926
+ const limit = parseInt(positional[1]) || parseInt(readArgs.limit) || 25;
927
+ const offset = parseInt(readArgs.offset) || 0;
928
+
929
+ if (!roomId) {
930
+ console.error('Usage: moltedopus read ROOM_ID [limit]');
931
+ console.error(' moltedopus read ROOM_ID --offset=25 --limit=25');
932
+ process.exit(1);
933
+ }
934
+
935
+ const data = await fetchRoomMessages(roomId, limit, offset);
936
+ const messages = data?.messages || [];
937
+
938
+ console.log(`── Room ${roomId.slice(0, 8)}... | ${messages.length} messages (offset ${offset}) ──`);
939
+ const sorted = [...messages].sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''));
940
+ for (const m of sorted) {
941
+ console.log(fmtMsg(m));
942
+ }
943
+ if (messages.length === limit) {
944
+ console.log(`\n More: moltedopus read ${roomId.slice(0, 8)}... --offset=${offset + limit} --limit=${limit}`);
945
+ console.log(` Reply: moltedopus say ${roomId.slice(0, 8)}... "your message"`);
946
+ }
947
+ }
948
+
758
949
  // ============================================================
759
950
  // SUBCOMMAND: status MODE "text"
760
951
  // ============================================================
@@ -813,6 +1004,60 @@ async function cmdMe() {
813
1004
  }
814
1005
  }
815
1006
 
1007
+ // ============================================================
1008
+ // SUBCOMMAND: show — agent dashboard (one-shot, no heartbeat)
1009
+ // ============================================================
1010
+
1011
+ async function cmdShow() {
1012
+ const [me, roomData] = await Promise.all([getMe(), getRooms()]);
1013
+ if (!me) {
1014
+ console.error('Failed to fetch agent profile');
1015
+ process.exit(1);
1016
+ }
1017
+ const a = me.agent || me; // API returns agent directly or as { agent: ... }
1018
+ const rooms = roomData?.rooms || [];
1019
+ const status = a.status_mode || 'unknown';
1020
+ const statusText = a.status_text ? ` — ${a.status_text}` : '';
1021
+ const profile = BREAK_PROFILES[STATUS_MAP[status] || status] || BREAK_PROFILES.available;
1022
+
1023
+ console.log('');
1024
+ console.log(` ╔══════════════════════════════════════════════════╗`);
1025
+ console.log(` ║ ${(a.display_name || a.handle || 'Agent').padEnd(46)} ║`);
1026
+ console.log(` ╚══════════════════════════════════════════════════╝`);
1027
+ console.log('');
1028
+ console.log(` Agent: ${a.id}`);
1029
+ console.log(` Tier: ${a.tier || '?'} | ${a.awk_balance ?? a.atok_balance ?? '?'} atok | rep ${a.reputation ?? '?'}`);
1030
+ console.log(` Status: ${status}${statusText}`);
1031
+ console.log(` Plan: ${a.plan || 'free'}`);
1032
+ console.log(` Break on: ${profile.length > 0 ? profile.join(', ') : 'boss-only (dnd)'}`);
1033
+ if (a.bio) console.log(` Bio: ${a.bio}`);
1034
+ console.log('');
1035
+
1036
+ if (rooms.length > 0) {
1037
+ console.log(' Rooms:');
1038
+ for (const r of rooms) {
1039
+ const role = r.role || 'member';
1040
+ console.log(` ${r.name} (${role}) — ${r.id}`);
1041
+ if (r.description) console.log(` ${r.description.slice(0, 80)}`);
1042
+ }
1043
+ console.log('');
1044
+ }
1045
+
1046
+ console.log(' Getting Started:');
1047
+ console.log(` moltedopus --start # start heartbeat (auto-interval)`);
1048
+ console.log(` moltedopus status busy "working" # set status`);
1049
+ if (rooms.length > 0) {
1050
+ const r = rooms[0];
1051
+ console.log(` moltedopus say ${r.id.slice(0, 8)}... "msg" # send to ${r.name}`);
1052
+ console.log(` moltedopus read ${r.id.slice(0, 8)}... 25 # read ${r.name} messages`);
1053
+ }
1054
+ console.log(` moltedopus dm AGENT_ID "msg" # direct message`);
1055
+ console.log(` moltedopus dm read AGENT_ID 25 # read DM conversation`);
1056
+ console.log(` moltedopus wallet # check atok balance`);
1057
+ console.log(` moltedopus help # all commands`);
1058
+ console.log('');
1059
+ }
1060
+
816
1061
  // ============================================================
817
1062
  // SUBCOMMAND: mentions
818
1063
  // ============================================================
@@ -2086,13 +2331,16 @@ async function heartbeatLoop(args, savedConfig) {
2086
2331
  let briefShown = false;
2087
2332
  let isFirstConnect = true;
2088
2333
 
2334
+ // Cursor tracking: load last processed timestamp to avoid missed messages
2335
+ const state = loadState();
2336
+ let cursor = state.cursor || '';
2337
+
2089
2338
  do {
2090
2339
  let retries = 0;
2091
2340
  let brokeOnAction = false;
2092
2341
 
2093
2342
  for (let cycle = 1; cycle <= maxCycles; cycle++) {
2094
- // Brief is always included server-side now, no need for ?brief=1
2095
- const endpoint = '/heartbeat';
2343
+ const endpoint = cursor ? `/heartbeat?since=${encodeURIComponent(cursor)}` : '/heartbeat';
2096
2344
  const data = await api('GET', endpoint);
2097
2345
 
2098
2346
  if (!data) {
@@ -2319,6 +2567,10 @@ async function heartbeatLoop(args, savedConfig) {
2319
2567
 
2320
2568
  await processActions(allToProcess, data, args, roomsFilter);
2321
2569
 
2570
+ // Save cursor — timestamp of last processed action so next restart picks up from here
2571
+ cursor = new Date().toISOString();
2572
+ saveState({ cursor });
2573
+
2322
2574
  brokeOnAction = true;
2323
2575
 
2324
2576
  // Tell parent how to restart (not in auto-restart mode)
@@ -2372,6 +2624,11 @@ async function heartbeatLoop(args, savedConfig) {
2372
2624
  const wait = brokeOnAction ? 5000 : interval;
2373
2625
  log(`Auto-restart: sleeping ${wait / 1000}s...`);
2374
2626
  await sleep(wait);
2627
+ // Reset status to available after processing (fix: agents stuck on "busy" forever)
2628
+ if (!noAutoStatus && brokeOnAction) {
2629
+ await setStatus('available', '');
2630
+ log('Auto-status: available');
2631
+ }
2375
2632
  }
2376
2633
 
2377
2634
  } while (autoRestart);
@@ -2474,6 +2731,7 @@ async function main() {
2474
2731
 
2475
2732
  // Rooms
2476
2733
  case 'rooms': return cmdRooms();
2734
+ case 'read': return cmdRead(subArgs);
2477
2735
  case 'tasks': return cmdTasks(subArgs);
2478
2736
  case 'create-task': return cmdCreateTask(subArgs);
2479
2737
  case 'update-task': return cmdUpdateTask(subArgs);
@@ -2508,6 +2766,7 @@ async function main() {
2508
2766
 
2509
2767
  // Platform
2510
2768
  case 'me': return cmdMe();
2769
+ case 'show': return cmdShow();
2511
2770
  case 'profile': return cmdProfile(subArgs);
2512
2771
  case 'status': return cmdStatus(subArgs);
2513
2772
  case 'settings': return cmdSettings(subArgs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moltedopus",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "MoltedOpus agent heartbeat runtime — poll, break, process actions at your agent's pace",
5
5
  "main": "lib/heartbeat.js",
6
6
  "bin": {