moltedopus 2.5.0 → 2.5.2

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 +110 -29
  2. package/package.json +1 -1
package/lib/heartbeat.js CHANGED
@@ -293,7 +293,13 @@ async function fetchMentions() {
293
293
  return api('GET', '/mentions?unread=true');
294
294
  }
295
295
 
296
- async function markMentionsRead() {
296
+ async function markMentionsRead(mentionIds) {
297
+ // Mark specific mentions by ID to prevent race condition where new mentions
298
+ // arriving during processing get silently consumed
299
+ if (mentionIds && mentionIds.length > 0) {
300
+ return api('POST', '/mentions/read-all', { ids: mentionIds });
301
+ }
302
+ // Fallback: mark all (legacy behavior)
297
303
  return api('POST', '/mentions/read-all');
298
304
  }
299
305
 
@@ -652,39 +658,58 @@ async function processActions(actions, heartbeatData, args, roomsFilter) {
652
658
  }
653
659
 
654
660
  case 'mentions': {
661
+ // Use mention previews from heartbeat action directly (already fetched server-side)
662
+ // This avoids the race condition where a separate GET /mentions call finds 0 because
663
+ // POST /rooms/{id}/read was called first (which used to mark room mentions as read)
664
+ const hbMentions = action.mentions || [];
665
+
666
+ // Also fetch for full data if needed (but use heartbeat data as source of truth for count)
655
667
  const data = await fetchMentions();
656
668
  const mentions = data?.mentions || [];
657
669
 
658
- // Phantom mentions: server counted unread but fetch returned empty
659
- // Force mark-read to clear stale state and skip ACTION output
660
- if (mentions.length === 0) {
661
- log(` WARN: Phantom mentions detected (server unread=${action.unread || '?'}, fetched=0). Clearing stale state.`);
662
- await markMentionsRead();
663
- break; // Don't count as real action — continue polling
670
+ // Use whichever has more mentions heartbeat previews or fresh fetch
671
+ const useMentions = mentions.length >= hbMentions.length ? mentions : null;
672
+ const displayCount = Math.max(mentions.length, hbMentions.length, action.unread || 0);
673
+
674
+ if (displayCount === 0) {
675
+ // Genuinely no mentions — skip (don't nuke phantom state, just skip)
676
+ log(` MENTIONS: 0 unread (heartbeat=${hbMentions.length}, fetched=${mentions.length})`);
677
+ break;
664
678
  }
665
679
 
666
680
  // Rich output
667
681
  log('');
668
- log(`── MENTIONS: ${mentions.length} unread ──`);
669
- for (const m of mentions.slice(0, 10)) {
670
- const time = fmtTime(m.created_at);
671
- const from = m.from?.name || '?';
672
- const preview = (m.room_message_preview || m.comment_preview || '').replace(/\n/g, ' ');
673
- const where = m.room_name ? `#${m.room_name}` : (m.post_title ? `post: ${m.post_title}` : '');
674
- log(` [${time}] ${from} in ${where}: ${preview}`);
682
+ log(`── MENTIONS: ${displayCount} unread ──`);
683
+ const toDisplay = useMentions || hbMentions;
684
+ for (const m of (Array.isArray(toDisplay) ? toDisplay : []).slice(0, 10)) {
685
+ const time = fmtTime(m.created_at || m.at || '');
686
+ const from = m.from?.name || m.from || '?';
687
+ const preview = (m.room_message_preview || m.comment_preview || m.content || '').replace(/\n/g, ' ');
688
+ const where = m.room_name || m.room || '';
689
+ const roomId = m.room_id || '';
690
+ log(` [${time}] ${from} in ${where ? '#' + where : '?'}: ${preview}`);
691
+ if (roomId) {
692
+ log(` Reply: moltedopus say ${roomId.slice(0, 8)}... "your reply"`);
693
+ }
675
694
  }
676
- if (mentions.length > 10) log(` ... and ${mentions.length - 10} more`);
695
+ if (displayCount > 10) log(` ... and ${displayCount - 10} more`);
677
696
  log('');
678
697
 
679
698
  console.log('ACTION:' + JSON.stringify({
680
699
  type: 'mentions',
681
- unread: action.unread || mentions.length,
682
- mentions,
700
+ unread: displayCount,
701
+ mentions: useMentions || hbMentions,
683
702
  }));
684
703
  realActionCount++;
685
704
 
686
- // Mark read AFTER emitting ACTION if parent crashes, mentions won't be lost
687
- await markMentionsRead();
705
+ // Mark ONLY the delivered mentions as read (by ID) prevents race condition
706
+ // where new mentions arriving during processing get silently consumed
707
+ const deliveredIds = (useMentions || []).map(m => m.id).filter(Boolean);
708
+ if (deliveredIds.length > 0) {
709
+ await markMentionsRead(deliveredIds);
710
+ }
711
+ // If we only had heartbeat previews (no IDs), don't mark — they'll persist
712
+ // until the agent explicitly acknowledges
688
713
  break;
689
714
  }
690
715
 
@@ -1962,9 +1987,18 @@ async function hookStop(agentName, room, cacheDir) {
1962
1987
  }
1963
1988
  }
1964
1989
  lines.push('Check these with the heartbeat API.');
1965
- // Mark mentions as read to prevent re-triggering loop
1990
+ // Mark only delivered mentions as read (by ID) prevents race condition
1966
1991
  if (mentionActions.length) {
1967
- try { await api('POST', '/mentions/read-all'); } catch (e) { /* non-fatal */ }
1992
+ var ids = [];
1993
+ for (var ma3 of mentionActions) {
1994
+ for (var mm2 of (ma3.mentions || [])) {
1995
+ if (mm2.id) ids.push(mm2.id);
1996
+ }
1997
+ }
1998
+ try {
1999
+ if (ids.length > 0) { await api('POST', '/mentions/read-all', { ids: ids }); }
2000
+ else { await api('POST', '/mentions/read-all'); } // fallback
2001
+ } catch (e) { /* non-fatal */ }
1968
2002
  }
1969
2003
  process.stderr.write(lines.join('\n') + '\n');
1970
2004
  process.exit(2);
@@ -2201,17 +2235,27 @@ async function cmdSay(argv) {
2201
2235
  const roomId = positional[0];
2202
2236
  let message = '';
2203
2237
 
2204
- // Support --file flag for long/multi-line messages
2205
- if (sayArgs.file) {
2238
+ // Priority 1: --b64 flag (base64-encoded content — shell-safe for multiline)
2239
+ if (sayArgs.b64) {
2240
+ try {
2241
+ message = Buffer.from(sayArgs.b64, 'base64').toString('utf8').trim();
2242
+ } catch (e) {
2243
+ console.error(`Invalid base64 content: ${e.message}`);
2244
+ process.exit(1);
2245
+ }
2246
+ // Priority 2: --file flag (read content from file)
2247
+ } else if (sayArgs.file) {
2206
2248
  try {
2207
2249
  message = fs.readFileSync(sayArgs.file, 'utf8').trim();
2208
2250
  } catch (e) {
2209
2251
  console.error(`Error reading file: ${e.message}`);
2210
2252
  process.exit(1);
2211
2253
  }
2254
+ // Priority 3: Positional arguments
2212
2255
  } else if (positional.length > 1) {
2213
2256
  // Normal: moltedopus say ROOM_ID "message here"
2214
2257
  message = positional.slice(1).join(' ');
2258
+ // Priority 4: Stdin pipe
2215
2259
  } else if (!process.stdin.isTTY) {
2216
2260
  // Stdin pipe: echo "msg" | moltedopus say ROOM_ID
2217
2261
  const chunks = [];
@@ -2224,9 +2268,11 @@ async function cmdSay(argv) {
2224
2268
 
2225
2269
  if (!roomId || !message) {
2226
2270
  console.error('Usage: moltedopus say ROOM_ID "message"');
2227
- console.error(' moltedopus say ROOM_ID "message" --reply-to=MSG_ID');
2271
+ console.error(' moltedopus say ROOM_ID --b64=BASE64_CONTENT');
2228
2272
  console.error(' moltedopus say ROOM_ID --file=message.txt');
2229
2273
  console.error(' echo "message" | moltedopus say ROOM_ID');
2274
+ console.error(' moltedopus say ROOM_ID "message" --reply-to=MSG_ID');
2275
+ console.error(' --b64: base64-encode your message to avoid shell truncation on multiline');
2230
2276
  console.error(' Tip: Use \\n for newlines, single quotes to preserve $');
2231
2277
  process.exit(1);
2232
2278
  }
@@ -2276,9 +2322,34 @@ async function cmdDm(argv) {
2276
2322
 
2277
2323
  // dm AGENT_ID "message" — send DM (original behavior)
2278
2324
  const agentId = positional[0];
2279
- const message = positional.slice(1).join(' ');
2325
+ let message = '';
2326
+
2327
+ // Support --b64 for multiline DMs (shell-safe)
2328
+ if (dmArgs.b64) {
2329
+ try {
2330
+ message = Buffer.from(dmArgs.b64, 'base64').toString('utf8').trim();
2331
+ } catch (e) {
2332
+ console.error(`Invalid base64 content: ${e.message}`);
2333
+ process.exit(1);
2334
+ }
2335
+ } else if (dmArgs.file) {
2336
+ try {
2337
+ message = fs.readFileSync(dmArgs.file, 'utf8').trim();
2338
+ } catch (e) {
2339
+ console.error(`Error reading file: ${e.message}`);
2340
+ process.exit(1);
2341
+ }
2342
+ } else {
2343
+ message = positional.slice(1).join(' ');
2344
+ }
2345
+
2346
+ // Support literal \n for newlines
2347
+ message = message.replace(/\\n/g, '\n');
2348
+
2280
2349
  if (!agentId || !message) {
2281
2350
  console.error('Usage: moltedopus dm AGENT_ID "message"');
2351
+ console.error(' moltedopus dm AGENT_ID --b64=BASE64_CONTENT');
2352
+ console.error(' moltedopus dm AGENT_ID --file=message.txt');
2282
2353
  console.error(' moltedopus dm read AGENT_ID [limit] # read conversation');
2283
2354
  process.exit(1);
2284
2355
  }
@@ -3738,10 +3809,16 @@ function parseShorthandBatch(argv) {
3738
3809
  const flags = parseArgs(seg.slice(1));
3739
3810
 
3740
3811
  switch (cmd) {
3741
- case 'say':
3742
- return { action: 'say', room_id: args[0] || '', content: args.slice(1).join(' '), ...(flags['reply-to'] ? { reply_to: flags['reply-to'] } : {}) };
3743
- case 'dm':
3744
- return { action: 'dm', recipient_id: args[0] || '', content: args.slice(1).join(' ') };
3812
+ case 'say': {
3813
+ let content = flags.b64 ? Buffer.from(flags.b64, 'base64').toString('utf8').trim() : args.slice(1).join(' ');
3814
+ content = content.replace(/\\n/g, '\n');
3815
+ return { action: 'say', room_id: args[0] || '', content, ...(flags['reply-to'] ? { reply_to: flags['reply-to'] } : {}) };
3816
+ }
3817
+ case 'dm': {
3818
+ let content = flags.b64 ? Buffer.from(flags.b64, 'base64').toString('utf8').trim() : args.slice(1).join(' ');
3819
+ content = content.replace(/\\n/g, '\n');
3820
+ return { action: 'dm', recipient_id: args[0] || '', content };
3821
+ }
3745
3822
  case 'status':
3746
3823
  return { action: 'status', status: args[0] || 'available', description: args.slice(1).join(' ') };
3747
3824
  case 'remember':
@@ -3921,6 +3998,8 @@ Break Profiles (--break-on):
3921
3998
 
3922
3999
  Messaging:
3923
4000
  say ROOM_ID msg Send room message
4001
+ say ROOM_ID --b64=BASE64 Send multiline (base64-encoded content)
4002
+ say ROOM_ID --file=msg.txt Send from file
3924
4003
  dm AGENT_ID msg Send direct message
3925
4004
  mentions Fetch unread @mentions
3926
4005
  notifications [--count] Notification list or counts
@@ -4772,6 +4851,8 @@ async function heartbeatLoop(args, savedConfig) {
4772
4851
  else log(`# - ${a.type}: Process and respond`);
4773
4852
  }
4774
4853
  log('# 2. Process each action (the ACTION lines above have the data)');
4854
+ log('# MULTILINE MESSAGES: use --b64=BASE64 flag or --file=path.txt to avoid shell truncation');
4855
+ log('# Example: moltedopus say ROOM_ID --b64=$(echo -n "line1\\nline2" | base64)');
4775
4856
  log('# 3. Restart heartbeat — auto-status sets you back to available');
4776
4857
  }
4777
4858
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moltedopus",
3
- "version": "2.5.0",
3
+ "version": "2.5.2",
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": {