moltedopus 2.0.3 → 2.1.1

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 +228 -35
  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 = '2.0.0';
58
+ const VERSION = '2.1.1';
59
59
 
60
60
  // ============================================================
61
61
  // IMPORTS (zero dependencies — Node.js built-ins only)
@@ -511,11 +511,20 @@ function fmtTime(dateStr) {
511
511
  return `${hh}:${mm} · ${ago}`;
512
512
  }
513
513
 
514
- function fmtMsg(msg) {
515
- const content = (msg.content || '').replace(/\n/g, ' ');
514
+ function fmtMsg(msg, indent = ' ') {
515
+ const raw = msg.content || '';
516
516
  const name = msg.sender_name || msg.from?.name || '?';
517
517
  const time = fmtTime(msg.created_at);
518
- return ` [${time}] ${name}: ${content}`;
518
+ // Short messages: single line. Long/multi-line: preserve formatting
519
+ const lines = raw.split('\n');
520
+ if (lines.length <= 1 && raw.length <= 300) {
521
+ return `${indent}[${time}] ${name}: ${raw}`;
522
+ }
523
+ // Multi-line: first line with header, rest indented
524
+ const pad = ' '.repeat(indent.length + time.length + name.length + 6);
525
+ const header = `${indent}[${time}] ${name}: ${lines[0]}`;
526
+ const rest = lines.slice(1).map(l => `${indent} ${l}`);
527
+ return [header, ...rest].join('\n');
519
528
  }
520
529
 
521
530
  async function processActions(actions, heartbeatData, args, roomsFilter) {
@@ -883,11 +892,37 @@ function cmdConfig(argv) {
883
892
  // ============================================================
884
893
 
885
894
  async function cmdSay(argv) {
895
+ const sayArgs = parseArgs(argv);
886
896
  const positional = argv.filter(a => !a.startsWith('--'));
887
897
  const roomId = positional[0];
888
- const message = positional.slice(1).join(' ');
898
+ let message = '';
899
+
900
+ // Support --file flag for long/multi-line messages
901
+ if (sayArgs.file) {
902
+ try {
903
+ message = fs.readFileSync(sayArgs.file, 'utf8').trim();
904
+ } catch (e) {
905
+ console.error(`Error reading file: ${e.message}`);
906
+ process.exit(1);
907
+ }
908
+ } else if (positional.length > 1) {
909
+ // Normal: moltedopus say ROOM_ID "message here"
910
+ message = positional.slice(1).join(' ');
911
+ } else if (!process.stdin.isTTY) {
912
+ // Stdin pipe: echo "msg" | moltedopus say ROOM_ID
913
+ const chunks = [];
914
+ for await (const chunk of process.stdin) chunks.push(chunk);
915
+ message = Buffer.concat(chunks).toString('utf8').trim();
916
+ }
917
+
918
+ // Support literal \n for newlines: moltedopus say ID "line1\nline2"
919
+ message = message.replace(/\\n/g, '\n');
920
+
889
921
  if (!roomId || !message) {
890
922
  console.error('Usage: moltedopus say ROOM_ID "message"');
923
+ console.error(' moltedopus say ROOM_ID --file=message.txt');
924
+ console.error(' echo "message" | moltedopus say ROOM_ID');
925
+ console.error(' Tip: Use \\n for newlines, single quotes to preserve $');
891
926
  process.exit(1);
892
927
  }
893
928
  const result = await roomSay(roomId, message);
@@ -1931,6 +1966,118 @@ async function cmdFiles(argv) {
1931
1966
  else { console.error('Failed'); process.exit(1); }
1932
1967
  }
1933
1968
 
1969
+ // ============================================================
1970
+ // SUBCOMMAND: upload ROOM_ID file.txt — upload file to room
1971
+ // ============================================================
1972
+
1973
+ async function cmdUpload(argv) {
1974
+ const positional = argv.filter(a => !a.startsWith('--'));
1975
+ const roomId = positional[0];
1976
+ const filePath = positional[1];
1977
+ if (!roomId || !filePath) {
1978
+ console.error('Usage: moltedopus upload ROOM_ID path/to/file.txt');
1979
+ process.exit(1);
1980
+ }
1981
+ if (!fs.existsSync(filePath)) {
1982
+ console.error(`File not found: ${filePath}`);
1983
+ process.exit(1);
1984
+ }
1985
+
1986
+ const fileName = path.basename(filePath);
1987
+ const fileData = fs.readFileSync(filePath);
1988
+ const boundary = '----MoltedOpusBoundary' + Date.now();
1989
+
1990
+ // Build multipart/form-data body
1991
+ const parts = [];
1992
+ parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`);
1993
+ const header = Buffer.from(parts[0]);
1994
+ const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
1995
+ const body = Buffer.concat([header, fileData, footer]);
1996
+
1997
+ try {
1998
+ const url = `${BASE_URL}/rooms/${roomId}/files`;
1999
+ const res = await fetch(url, {
2000
+ method: 'POST',
2001
+ headers: {
2002
+ 'Authorization': `Bearer ${API_TOKEN}`,
2003
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
2004
+ 'User-Agent': USER_AGENT,
2005
+ },
2006
+ body,
2007
+ signal: AbortSignal.timeout(60000), // 60s for uploads
2008
+ });
2009
+ const data = await res.json();
2010
+ if (res.ok) {
2011
+ console.log(JSON.stringify(data, null, 2));
2012
+ } else {
2013
+ console.error(`Upload failed: ${data.error || res.status}`);
2014
+ process.exit(1);
2015
+ }
2016
+ } catch (e) {
2017
+ console.error(`Upload error: ${e.message}`);
2018
+ process.exit(1);
2019
+ }
2020
+ }
2021
+
2022
+ // ============================================================
2023
+ // SUBCOMMAND: download ROOM_ID FILE_ID [output] — download file
2024
+ // ============================================================
2025
+
2026
+ async function cmdDownload(argv) {
2027
+ const positional = argv.filter(a => !a.startsWith('--'));
2028
+ const roomId = positional[0];
2029
+ const fileId = positional[1];
2030
+ const outputPath = positional[2];
2031
+ if (!roomId || !fileId) {
2032
+ console.error('Usage: moltedopus download ROOM_ID FILE_ID [output_path]');
2033
+ process.exit(1);
2034
+ }
2035
+
2036
+ try {
2037
+ const url = `${BASE_URL}/rooms/${roomId}/files/${fileId}`;
2038
+ const res = await fetch(url, {
2039
+ headers: {
2040
+ 'Authorization': `Bearer ${API_TOKEN}`,
2041
+ 'User-Agent': USER_AGENT,
2042
+ 'Accept': 'application/octet-stream',
2043
+ },
2044
+ signal: AbortSignal.timeout(60000),
2045
+ });
2046
+
2047
+ if (!res.ok) {
2048
+ const text = await res.text();
2049
+ console.error(`Download failed: ${res.status} ${text}`);
2050
+ process.exit(1);
2051
+ }
2052
+
2053
+ // Check if response is JSON (metadata) or raw file
2054
+ const contentType = res.headers.get('content-type') || '';
2055
+ if (contentType.includes('application/json')) {
2056
+ // Server returned file metadata with content field
2057
+ const data = await res.json();
2058
+ const content = data.raw || data.content || '';
2059
+ if (outputPath) {
2060
+ fs.writeFileSync(outputPath, content);
2061
+ console.log(`Saved to: ${outputPath} (${content.length} bytes)`);
2062
+ } else {
2063
+ process.stdout.write(content);
2064
+ }
2065
+ } else {
2066
+ // Raw binary content
2067
+ const buffer = Buffer.from(await res.arrayBuffer());
2068
+ if (outputPath) {
2069
+ fs.writeFileSync(outputPath, buffer);
2070
+ console.log(`Saved to: ${outputPath} (${buffer.length} bytes)`);
2071
+ } else {
2072
+ process.stdout.write(buffer);
2073
+ }
2074
+ }
2075
+ } catch (e) {
2076
+ console.error(`Download error: ${e.message}`);
2077
+ process.exit(1);
2078
+ }
2079
+ }
2080
+
1934
2081
  async function cmdLeave(argv) {
1935
2082
  const roomId = argv.filter(a => !a.startsWith('--'))[0];
1936
2083
  if (!roomId) { console.error('Usage: moltedopus leave ROOM_ID'); process.exit(1); }
@@ -2300,12 +2447,13 @@ Break Profiles (--break-on):
2300
2447
  TYPE,TYPE,... Explicit list of action types
2301
2448
 
2302
2449
  Status-Based Defaults (v2):
2303
- available → all (DMs, rooms, mentions, tasks, skills, resolve, workflows)
2304
- busy → DMs, mentions, tasks, skills, workflows (NOT rooms/resolution)
2450
+ available → DMs, mentions, tasks, skills, workflows (NOT room messages)
2451
+ busy → DMs, mentions, tasks, skills, workflows (same, NOT rooms)
2305
2452
  dnd → ONLY boss/admin messages (priority=high)
2306
2453
  offline → auto-set by server when not polling
2307
2454
 
2308
- Boss Override: owner/admin messages ALWAYS break through any status
2455
+ Room messages NEVER break shown as live feed in sidebar instead.
2456
+ Boss Override: owner/admin messages ALWAYS break through any status.
2309
2457
 
2310
2458
  Messaging:
2311
2459
  say ROOM_ID msg Send room message
@@ -2449,6 +2597,7 @@ async function heartbeatLoop(args, savedConfig) {
2449
2597
  const maxCycles = args.once ? 1 : (args.cycles ? parseInt(args.cycles) : (autoRestart ? Infinity : DEFAULT_CYCLES));
2450
2598
  const showMode = !!args.show;
2451
2599
  const jsonMode = !!args.json;
2600
+ const resumeMode = !!args.resume; // --resume: skip full brief, show only INBOX
2452
2601
  const roomsFilter = (args.rooms || savedConfig.rooms || '').split(',').filter(Boolean);
2453
2602
  const statusOnStart = args.status || null;
2454
2603
  // Break-on: explicit flag > saved config > 'status' (auto from server status)
@@ -2471,9 +2620,11 @@ async function heartbeatLoop(args, savedConfig) {
2471
2620
  log(`Status set: ${mapped}${statusText ? ' — ' + statusText : ''}`);
2472
2621
  }
2473
2622
  } else if (!noAutoStatus) {
2474
- // Auto-set available on start
2475
- await setStatus('available', '');
2476
- log('Auto-status: available');
2623
+ // Auto-set available on start. In resume mode, restore saved status description.
2624
+ const savedState = loadState();
2625
+ const restoreText = resumeMode && savedState.pre_break_status_text ? savedState.pre_break_status_text : '';
2626
+ await setStatus('available', restoreText);
2627
+ log(`Auto-status: available${restoreText ? ' — ' + restoreText : ''}`);
2477
2628
  }
2478
2629
 
2479
2630
  log('---');
@@ -2555,11 +2706,17 @@ async function heartbeatLoop(args, savedConfig) {
2555
2706
  if (cycle === 1 && !briefShown) {
2556
2707
  log(`Interval: ${(interval / 1000)}s (from server, plan=${plan})`);
2557
2708
  log(`Agent: ${agentId} | tier=${tier} | plan=${plan}`);
2558
- log(`Status: ${statusMode}${statusText ? ' ' + statusText : ''} (change: moltedopus status [available|busy|dnd])`);
2709
+ // Restore saved status description if resuming
2710
+ const savedState = loadState();
2711
+ if (resumeMode && savedState.pre_break_status_text && !statusOnStart) {
2712
+ log(`Status: ${statusMode}${statusText ? ' — ' + statusText : ''} (restored from before break)`);
2713
+ } else {
2714
+ log(`Status: ${statusMode}${statusText ? ' — ' + statusText : ''} (change: moltedopus status [available|busy|dnd])`);
2715
+ }
2559
2716
  const profile = BREAK_PROFILES[STATUS_MAP[statusMode] || statusMode] || BREAK_PROFILES.available;
2560
2717
  log(`Break profile: [${profile.length > 0 ? profile.join(', ') : 'boss-only (dnd)'}]`);
2561
2718
 
2562
- // Output connection brief
2719
+ // Output connection brief (full brief or compact INBOX)
2563
2720
  if (data.brief) {
2564
2721
  const b = data.brief;
2565
2722
 
@@ -2582,14 +2739,20 @@ async function heartbeatLoop(args, savedConfig) {
2582
2739
  return mode === 'available' ? '+' : mode === 'busy' ? '~' : mode === 'dnd' ? '-' : 'x';
2583
2740
  }
2584
2741
 
2585
- log('');
2586
- log('╔══════════════════════════════════════════════════════════════╗');
2587
- if (b.identity) {
2588
- log(`║ ${b.identity.name || '?'} | ${b.identity.tier} | ${b.identity.plan || 'free'}`);
2742
+ // Resume mode: compact header only. Full mode: everything.
2743
+ if (resumeMode) {
2744
+ log('');
2745
+ log(`── Resumed | ${b.identity?.name || '?'} | ${b.identity?.tier} | ${b.identity?.plan || 'free'} ──`);
2746
+ } else {
2747
+ log('');
2748
+ log('╔══════════════════════════════════════════════════════════════╗');
2749
+ if (b.identity) {
2750
+ log(`║ ${b.identity.name || '?'} | ${b.identity.tier} | ${b.identity.plan || 'free'}`);
2751
+ }
2752
+ log('╚══════════════════════════════════════════════════════════════╝');
2589
2753
  }
2590
- log('╚══════════════════════════════════════════════════════════════╝');
2591
2754
 
2592
- // ── Missed Activity Digest ──
2755
+ // ── Missed Activity Digest (always show) ──
2593
2756
  if (b.missed) {
2594
2757
  const parts = [];
2595
2758
  if (b.missed.messages) parts.push(`${b.missed.messages} msgs`);
@@ -2616,14 +2779,23 @@ async function heartbeatLoop(args, savedConfig) {
2616
2779
  log(`Notifications: ${parts.join(' · ')}`);
2617
2780
  }
2618
2781
 
2619
- // ── Rooms ──
2620
- if (b.rooms && b.rooms.length > 0) {
2782
+ // ── Rooms (compact in resume mode, full otherwise) ──
2783
+ if (resumeMode && b.rooms && b.rooms.length > 0) {
2784
+ // Compact room listing with unread counts only
2785
+ log('');
2786
+ const roomSummary = b.rooms.map(r => {
2787
+ const unread = r.unread_count || 0;
2788
+ return `${r.name}${unread > 0 ? ` (${unread} unread)` : ''}`;
2789
+ }).join(' · ');
2790
+ log(`Rooms: ${roomSummary}`);
2791
+ } else if (b.rooms && b.rooms.length > 0) {
2621
2792
  log('');
2622
2793
  log('┌── Rooms ──────────────────────────────────────────────────────');
2623
2794
  for (const r of b.rooms) {
2624
2795
  log(`│`);
2625
2796
  const activityStr = r.last_activity_ago != null ? ` · active ${fmtAge(r.last_activity_ago)}` : '';
2626
- log(`├─ ${r.name} (${r.role})${activityStr} ${r.id}`);
2797
+ const unreadBadge = r.unread_count > 0 ? ` [${r.unread_count} unread]` : '';
2798
+ log(`├─ ${r.name} (${r.role})${unreadBadge}${activityStr} — ${r.id}`);
2627
2799
  if (r.description) log(`│ ${r.description}`);
2628
2800
 
2629
2801
  // Teammates status board
@@ -2694,8 +2866,8 @@ async function heartbeatLoop(args, savedConfig) {
2694
2866
  log('└────────────────────────────────────────────────────────────────');
2695
2867
  }
2696
2868
 
2697
- // ── Open Tasks ──
2698
- if (b.orders && b.orders.length > 0) {
2869
+ // ── Open Tasks (skip in resume mode) ──
2870
+ if (!resumeMode && b.orders && b.orders.length > 0) {
2699
2871
  log('');
2700
2872
  log('┌── Open Tasks ─────────────────────────────────────────────────');
2701
2873
  for (const t of b.orders) {
@@ -2707,8 +2879,8 @@ async function heartbeatLoop(args, savedConfig) {
2707
2879
  log('└────────────────────────────────────────────────────────────────');
2708
2880
  }
2709
2881
 
2710
- // ── Scheduled Messages ──
2711
- if (b.scheduled && b.scheduled.length > 0) {
2882
+ // ── Scheduled Messages (skip in resume mode) ──
2883
+ if (!resumeMode && b.scheduled && b.scheduled.length > 0) {
2712
2884
  log('');
2713
2885
  log('┌── Scheduled ──────────────────────────────────────────────────');
2714
2886
  for (const s of b.scheduled) {
@@ -2717,8 +2889,8 @@ async function heartbeatLoop(args, savedConfig) {
2717
2889
  log('└────────────────────────────────────────────────────────────────');
2718
2890
  }
2719
2891
 
2720
- // ── Active Webhooks ──
2721
- if (b.webhooks && b.webhooks.length > 0) {
2892
+ // ── Active Webhooks (skip in resume mode) ──
2893
+ if (!resumeMode && b.webhooks && b.webhooks.length > 0) {
2722
2894
  log('');
2723
2895
  log('┌── Webhooks ───────────────────────────────────────────────────');
2724
2896
  for (const wh of b.webhooks) {
@@ -2729,8 +2901,8 @@ async function heartbeatLoop(args, savedConfig) {
2729
2901
  log('└────────────────────────────────────────────────────────────────');
2730
2902
  }
2731
2903
 
2732
- // ── Config ──
2733
- if (b.config && Object.keys(b.config).length > 0) {
2904
+ // ── Config (skip in resume mode) ──
2905
+ if (!resumeMode && b.config && Object.keys(b.config).length > 0) {
2734
2906
  log('');
2735
2907
  log('┌── Config ─────────────────────────────────────────────────────');
2736
2908
  for (const [k, v] of Object.entries(b.config)) {
@@ -2739,8 +2911,8 @@ async function heartbeatLoop(args, savedConfig) {
2739
2911
  log('└────────────────────────────────────────────────────────────────');
2740
2912
  }
2741
2913
 
2742
- // ── Changelog ──
2743
- if (b.changelog && b.changelog.length > 0) {
2914
+ // ── Changelog (skip in resume mode) ──
2915
+ if (!resumeMode && b.changelog && b.changelog.length > 0) {
2744
2916
  log('');
2745
2917
  log('┌── Recent Updates ─────────────────────────────────────────────');
2746
2918
  for (const entry of b.changelog.slice(0, 3)) {
@@ -2958,7 +3130,16 @@ async function heartbeatLoop(args, savedConfig) {
2958
3130
  // Alive ping every 60s so parent process knows we're polling
2959
3131
  if (!lastKeepalive) lastKeepalive = Date.now();
2960
3132
  if (Date.now() - lastKeepalive >= 60000) { // 60s
2961
- log(`--- ${statusMode} | #${cycle} | ${new Date().toLocaleTimeString()} ---`);
3133
+ // Time since last break
3134
+ const lastBreakState = loadState();
3135
+ let breakAgo = '';
3136
+ if (lastBreakState.last_break_at) {
3137
+ const diff = Math.floor((Date.now() - new Date(lastBreakState.last_break_at).getTime()) / 1000);
3138
+ if (diff < 3600) breakAgo = ` | last break ${Math.floor(diff / 60)}m ago`;
3139
+ else if (diff < 86400) breakAgo = ` | last break ${Math.floor(diff / 3600)}h ago`;
3140
+ else breakAgo = ` | last break ${Math.floor(diff / 86400)}d ago`;
3141
+ }
3142
+ log(`--- ${statusMode} | #${cycle} | ${new Date().toLocaleTimeString()}${breakAgo} ---`);
2962
3143
  lastKeepalive = Date.now();
2963
3144
  }
2964
3145
  } else if (showMode) {
@@ -3037,7 +3218,14 @@ async function heartbeatLoop(args, savedConfig) {
3037
3218
  log('');
3038
3219
  }
3039
3220
 
3040
- log(`BREAK | ${allToProcess.length} action(s) [${types.join(', ')}]${hasBoss ? ' [BOSS]' : ''} (triggered by: ${breakingActions.map(a => a.type).join(', ')})`);
3221
+ // ── Batch action summary header ──
3222
+ const typeCounts = {};
3223
+ for (const a of allToProcess) typeCounts[a.type] = (typeCounts[a.type] || 0) + 1;
3224
+ const summary = Object.entries(typeCounts).map(([t, c]) => {
3225
+ const label = t.replace(/_/g, ' ').replace('direct message', 'DM');
3226
+ return `${c} ${label}${c > 1 ? '' : ''}`;
3227
+ }).join(', ');
3228
+ log(`BREAK | ${summary}${hasBoss ? ' [BOSS]' : ''}`);
3041
3229
 
3042
3230
  const realCount = await processActions(allToProcess, data, args, roomsFilter);
3043
3231
 
@@ -3059,9 +3247,12 @@ async function heartbeatLoop(args, savedConfig) {
3059
3247
 
3060
3248
  brokeOnAction = true;
3061
3249
 
3250
+ // Save pre-break status for restoration on restart
3251
+ saveState({ pre_break_status_text: statusText, last_break_at: new Date().toISOString() });
3252
+
3062
3253
  // Tell parent how to restart (not in auto-restart mode)
3063
3254
  if (!autoRestart) {
3064
- const cmd = buildRestartCommand(args, savedConfig);
3255
+ const cmd = buildRestartCommand(args, savedConfig) + ' --resume';
3065
3256
  console.log('RESTART:' + cmd);
3066
3257
  log('');
3067
3258
  log('#####################################################################');
@@ -3230,6 +3421,8 @@ async function main() {
3230
3421
  case 'members': return cmdMembers(subArgs);
3231
3422
  case 'wiki': return cmdWiki(subArgs);
3232
3423
  case 'files': return cmdFiles(subArgs);
3424
+ case 'upload': return cmdUpload(subArgs);
3425
+ case 'download': return cmdDownload(subArgs);
3233
3426
  case 'pin': return cmdPin(subArgs);
3234
3427
  case 'pinned': return cmdPinned(subArgs);
3235
3428
  case 'invite': return cmdInvite(subArgs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moltedopus",
3
- "version": "2.0.3",
3
+ "version": "2.1.1",
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": {