viveworker 0.8.2 → 0.8.4

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/README.md CHANGED
@@ -284,6 +284,8 @@ Typical commands:
284
284
  - `npx viveworker share upload report.pdf --password "hunter2" --expires-days 7`
285
285
  - `npx viveworker share upload deck_standalone.html --no-optimize`
286
286
  - `npx viveworker share list`
287
+ - `npx viveworker share replace <slug> updated-report.html`
288
+ - `npx viveworker share update <slug> --file updated-report.html`
287
289
  - `npx viveworker share update <slug> --password "hunter2"`
288
290
  - `npx viveworker share update <slug> --expires-days 7`
289
291
  - `npx viveworker share link <slug>`
@@ -386,18 +388,18 @@ Because the Claude hook opens browser windows and returns focus to Claude Deskto
386
388
 
387
389
  ### What it does
388
390
 
389
- - **Incoming replies**: detects when other agents comment on your posts and notifies your phone so you can draft a reply
391
+ - **Incoming reply drafts**: detects when other agents comment on your posts, drafts a contextual reply first, and sends it to your phone for approval
390
392
  - **Draft approval on phone**: reply drafts and original post drafts appear in `Tasks` and `Timeline`, where you can approve, deny, or edit them from your phone
391
- - **Auto-scout replies**: every 2 minutes, scans the Moltbook feed, scores posts against your agent's persona (0–100), batches candidates over a 30-minute window, picks the best match, drafts a reply via LLM, and proposes it for your approval
393
+ - **Auto-scout replies**: every 2 minutes, handles pending incoming comments first, then scans the Moltbook feed, scores posts against your agent's persona (0–100), batches candidates over a 30-minute window, picks the best match, drafts a reply via LLM, and proposes it for your approval
392
394
  - **Original post drafts**: based on your daily coding activity, composes new posts in your agent's voice and proposes them at natural intervals — morning (yesterday recap), noon (morning progress), and evening (full-day summary). Up to 3 per day; deny any slot you don't want
393
395
 
394
396
  ### How it works
395
397
 
396
398
  1. Define your agent's persona in `~/.viveworker/moltbook-persona.md` — voice, expertise, interests, topics to avoid
397
399
  2. The system filters all content through this persona: only activities and posts that match your agent's expertise are surfaced
398
- 3. The Moltbook watcher pushes incoming replies and draft proposals into `Tasks` and `Timeline`
400
+ 3. The Moltbook watcher saves incoming replies; auto-scout turns pending comments and discovery candidates into draft proposals
399
401
  4. On your phone, you can approve, deny, or edit the draft body before sending
400
- 5. The Moltbook CLI long-polls for that decision, then posts to Moltbook and solves the verification puzzle automatically
402
+ 5. After approval, the bridge posts to Moltbook and solves the verification puzzle automatically
401
403
 
402
404
  ### Setup
403
405
 
@@ -428,6 +430,7 @@ Open `Settings > Moltbook` in the phone app to see the current auto-scout postin
428
430
  - `npx viveworker moltbook list` — show pending comment notifications
429
431
  - `npx viveworker moltbook poll` — manually refresh Moltbook notifications once
430
432
  - `npx viveworker moltbook reconcile` — resolve inbox items that were already replied to elsewhere
433
+ - `npx viveworker moltbook inbox-pick` — manually pick one pending incoming comment for a reply draft
431
434
  - `npx viveworker moltbook scout` — manually pick a feed candidate
432
435
  - `npx viveworker moltbook propose <postId> --text "..."` — submit a reply draft for phone approval
433
436
  - `npx viveworker moltbook compose` — inspect today's activity for original-post material
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viveworker",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "Open mobile control surface for Codex, Claude, Thread Sharing, File Share, Moltbook, and A2A tasks on your trusted LAN.",
5
5
  "author": "Yuta Hoshino <hoshino.lireneo@gmail.com>",
6
6
  "license": "MIT",
@@ -32,13 +32,13 @@
32
32
  candidate (status: 'candidate'), draft a 2-3 paragraph reply in
33
33
  viveworker voice (informal lowercase, technically substantive,
34
34
  no signature) and run: node scripts/viveworker.mjs moltbook
35
- propose <postId> --text \"<your draft>\" --timeout 900. If
35
+ propose <postId> --text \"<your draft>\" --timeout 86400. If
36
36
  scout prints anything else, exit quietly."
37
37
 
38
38
  Replace `codex exec` with `claude -p` for Claude Code users, or
39
39
  with your own SDK runner.
40
40
  -->
41
- <string>set -a; . "$HOME/.viveworker/moltbook.env"; set +a; cd "$HOME/path/to/viveworker" &amp;&amp; codex exec "Run scripts/moltbook-scout-run.sh and, if it prints a candidate, draft a reply in viveworker voice and call viveworker moltbook propose &lt;postId&gt; --text \"...\" --timeout 900."</string>
41
+ <string>set -a; . "$HOME/.viveworker/moltbook.env"; set +a; cd "$HOME/path/to/viveworker" &amp;&amp; codex exec "Run scripts/moltbook-scout-run.sh and, if it prints a candidate, draft a reply in viveworker voice and call viveworker moltbook propose &lt;postId&gt; --text \"...\" --timeout 86400."</string>
42
42
  </array>
43
43
 
44
44
  <key>StartInterval</key>
@@ -177,11 +177,15 @@ export function defaultScoutState() {
177
177
  return { day: todayKey(), sentToday: 0, seenPostIds: {}, batch: null, lastComposeDay: "", composedToday: 0, composeSlotsAttempted: [], recentComposeTitles: [] };
178
178
  }
179
179
 
180
- export function todayKey() {
180
+ export function todayKey(nowMs = Date.now()) {
181
+ return dayKeyForTimestamp(nowMs);
182
+ }
183
+
184
+ export function dayKeyForTimestamp(timestampMs = Date.now()) {
181
185
  // Use local timezone with AM 5:00 as the day boundary — hours before 5am
182
186
  // count as the previous day so late-night work doesn't consume the next
183
187
  // day's quota.
184
- const d = new Date(Date.now() - 5 * 60 * 60 * 1000);
188
+ const d = new Date(Number(timestampMs || Date.now()) - 5 * 60 * 60 * 1000);
185
189
  const y = d.getFullYear();
186
190
  const m = String(d.getMonth() + 1).padStart(2, "0");
187
191
  const day = String(d.getDate()).padStart(2, "0");
@@ -247,6 +251,178 @@ export async function listInboxItems(dir = DEFAULT_INBOX_DIR) {
247
251
  }
248
252
  }
249
253
 
254
+ function normalizedAuthorKey(value) {
255
+ return String(value || "")
256
+ .trim()
257
+ .replace(/^@/u, "")
258
+ .toLowerCase();
259
+ }
260
+
261
+ export function isOwnMoltbookInboxItem(item, env = {}) {
262
+ const authorId = String(item?.authorId || item?.author_id || "");
263
+ const myAgentId = String(env.MOLTBOOK_AGENT_ID || "");
264
+ if (authorId && myAgentId && authorId === myAgentId) return true;
265
+
266
+ const authorName = normalizedAuthorKey(item?.authorName || item?.author || item?.username);
267
+ const myAgentName = normalizedAuthorKey(env.MOLTBOOK_AGENT_NAME || "");
268
+ return Boolean(authorName && (authorName === "viveworker" || (myAgentName && authorName === myAgentName)));
269
+ }
270
+
271
+ export function hasPendingMoltbookDraftForInboxItem(item) {
272
+ const draftStatus = String(item?.draftStatus || "").toLowerCase();
273
+ if (["failed", "expired", "missing", "stale"].includes(draftStatus)) return false;
274
+ if (draftStatus && draftStatus !== "none") return true;
275
+ return Boolean(item?.draftToken || item?.draftedAt || item?.draftSourceId);
276
+ }
277
+
278
+ export function isActiveMoltbookDraft(draft, nowMs = Date.now()) {
279
+ if (!draft || typeof draft !== "object" || draft.decision) return false;
280
+ const token = String(draft.token || "");
281
+ if (!token) return false;
282
+ const createdAtMs = Number(draft.createdAtMs) || 0;
283
+ if (!createdAtMs) return true;
284
+ const ttlMs = Math.max(60_000, Math.min(Number(draft.ttlMs) || 86_400_000, 86_400_000));
285
+ return nowMs - createdAtMs <= ttlMs;
286
+ }
287
+
288
+ export function countReservedMoltbookReplyDrafts(drafts, {
289
+ day = todayKey(),
290
+ nowMs = Date.now(),
291
+ excludeToken = "",
292
+ } = {}) {
293
+ let count = 0;
294
+ for (const draft of Array.isArray(drafts) ? drafts : []) {
295
+ if (!isActiveMoltbookDraft(draft, nowMs)) continue;
296
+ if (String(draft.token || "") === String(excludeToken || "")) continue;
297
+ if (String(draft.draftType || "reply") !== "reply") continue;
298
+ const createdAtMs = Number(draft.createdAtMs) || nowMs;
299
+ if (dayKeyForTimestamp(createdAtMs) !== day) continue;
300
+ count += 1;
301
+ }
302
+ return count;
303
+ }
304
+
305
+ export async function getMoltbookReplyQuotaState({
306
+ state = null,
307
+ maxDaily = 5,
308
+ draftsDir = DEFAULT_DRAFTS_DIR,
309
+ nowMs = Date.now(),
310
+ excludeToken = "",
311
+ } = {}) {
312
+ const scoutState = rollScoutDayIfNeeded(state || await readScoutState());
313
+ const pendingDrafts = await listPendingDrafts(draftsDir);
314
+ const pendingToday = countReservedMoltbookReplyDrafts(pendingDrafts, {
315
+ day: scoutState.day,
316
+ nowMs,
317
+ excludeToken,
318
+ });
319
+ const sentToday = Number(scoutState.sentToday) || 0;
320
+ const max = Math.max(0, Number(maxDaily) || 5);
321
+ const usedToday = sentToday + pendingToday;
322
+ return {
323
+ day: scoutState.day,
324
+ sentToday,
325
+ pendingToday,
326
+ usedToday,
327
+ maxDaily: max,
328
+ remaining: Math.max(0, max - usedToday),
329
+ quotaReached: usedToday >= max,
330
+ };
331
+ }
332
+
333
+ export async function reconcileInboxDraftMarkers(items, {
334
+ inboxDir = DEFAULT_INBOX_DIR,
335
+ draftsDir = DEFAULT_DRAFTS_DIR,
336
+ nowMs = Date.now(),
337
+ } = {}) {
338
+ const pendingDrafts = await listPendingDrafts(draftsDir);
339
+ const activeTokens = new Set(
340
+ pendingDrafts
341
+ .filter((draft) => isActiveMoltbookDraft(draft, nowMs))
342
+ .map((draft) => String(draft.token || ""))
343
+ .filter(Boolean),
344
+ );
345
+ const reconciled = [];
346
+ let resetCount = 0;
347
+ for (const item of Array.isArray(items) ? items : []) {
348
+ if (!item || typeof item !== "object") {
349
+ reconciled.push(item);
350
+ continue;
351
+ }
352
+ const status = String(item.status || "").toLowerCase();
353
+ const draftStatus = String(item.draftStatus || "").toLowerCase();
354
+ const draftToken = String(item.draftToken || "");
355
+ const shouldReset =
356
+ status === "pending"
357
+ && hasPendingMoltbookDraftForInboxItem(item)
358
+ && (draftStatus === "proposed" || Boolean(draftToken))
359
+ && (!draftToken || !activeTokens.has(draftToken));
360
+ if (!shouldReset) {
361
+ reconciled.push(item);
362
+ continue;
363
+ }
364
+ const updated = await updateInboxStatus(item.commentId, "pending", {
365
+ draftStatus: "expired",
366
+ draftToken: "",
367
+ draftSource: "",
368
+ draftSourceId: "",
369
+ draftError: "previous draft expired or is no longer available; eligible for reproposal",
370
+ draftExpiredAt: new Date(nowMs).toISOString(),
371
+ }, inboxDir);
372
+ reconciled.push(updated || {
373
+ ...item,
374
+ draftStatus: "expired",
375
+ draftToken: "",
376
+ draftSource: "",
377
+ draftSourceId: "",
378
+ draftError: "previous draft expired or is no longer available; eligible for reproposal",
379
+ draftExpiredAt: new Date(nowMs).toISOString(),
380
+ });
381
+ resetCount += 1;
382
+ }
383
+ return { items: reconciled, resetCount };
384
+ }
385
+
386
+ export function pickInboxReplyCandidate(items, env = {}) {
387
+ const skipReasons = {
388
+ malformed: 0,
389
+ notPending: 0,
390
+ selfAuthor: 0,
391
+ alreadyDrafted: 0,
392
+ };
393
+ const candidates = [];
394
+ for (const item of Array.isArray(items) ? items : []) {
395
+ if (!item || typeof item !== "object" || !item.commentId || !item.postId) {
396
+ skipReasons.malformed += 1;
397
+ continue;
398
+ }
399
+ if (String(item.status || "").toLowerCase() !== "pending") {
400
+ skipReasons.notPending += 1;
401
+ continue;
402
+ }
403
+ if (isOwnMoltbookInboxItem(item, env)) {
404
+ skipReasons.selfAuthor += 1;
405
+ continue;
406
+ }
407
+ if (hasPendingMoltbookDraftForInboxItem(item)) {
408
+ skipReasons.alreadyDrafted += 1;
409
+ continue;
410
+ }
411
+ candidates.push(item);
412
+ }
413
+ candidates.sort((a, b) => {
414
+ const bt = Number(Date.parse(b.createdAt || b.updatedAt || "")) || 0;
415
+ const at = Number(Date.parse(a.createdAt || a.updatedAt || "")) || 0;
416
+ return bt - at;
417
+ });
418
+ return {
419
+ status: candidates.length ? "candidate" : "empty",
420
+ item: candidates[0] || null,
421
+ skipReasons,
422
+ consideredCount: candidates.length,
423
+ };
424
+ }
425
+
250
426
  // ---------- Draft persistence ----------
251
427
  //
252
428
  // Pending Moltbook drafts (reply proposals and original-post proposals) are
@@ -33,6 +33,9 @@ import {
33
33
  recordComposeAttempt,
34
34
  solveVerificationPuzzle,
35
35
  solvePuzzleWithLLM,
36
+ pickInboxReplyCandidate,
37
+ reconcileInboxDraftMarkers,
38
+ getMoltbookReplyQuotaState,
36
39
  } from "./moltbook-api.mjs";
37
40
 
38
41
  function fail(message, code = 1) {
@@ -138,7 +141,7 @@ async function cmdThread(commentId) {
138
141
  }
139
142
 
140
143
  async function cmdReply(commentId, flags) {
141
- if (!commentId) fail("usage: viveworker moltbook reply <commentId> --text \"...\"");
144
+ if (!commentId) fail("usage: viveworker moltbook reply <commentId> --text \"...\" [--top-level]");
142
145
  const text = typeof flags.text === "string" ? flags.text : "";
143
146
  if (!text.trim()) fail("--text is required and must be non-empty");
144
147
  let item = await readInboxItem(commentId);
@@ -159,10 +162,20 @@ async function cmdReply(commentId, flags) {
159
162
  };
160
163
  await writeInboxItem(item);
161
164
  }
165
+ // For "mention" notifications, the commentId is a synthetic notification
166
+ // ID — there's no real parent comment to thread under, and posting with
167
+ // parent_id=that value gets rejected as "Parent comment not found". Drop
168
+ // parent_id so the comment lands as a top-level reply on the post.
169
+ // Caller can also force this with --top-level for ad-hoc cases (e.g.,
170
+ // older inbox entries written before the watcher started recording kind).
171
+ const isTopLevel = flags["top-level"] === true || item.kind === "mention";
172
+ const body = isTopLevel
173
+ ? { content: text }
174
+ : { content: text, parent_id: commentId };
162
175
  const { mb, env } = await getClient();
163
176
  const result = await mb(`/posts/${item.postId}/comments`, {
164
177
  method: "POST",
165
- body: JSON.stringify({ content: text, parent_id: commentId }),
178
+ body: JSON.stringify(body),
166
179
  });
167
180
  const verification = result?.comment?.verification || null;
168
181
  await resolveOnBridge(env, commentId);
@@ -272,6 +285,7 @@ async function cmdPoll() {
272
285
  commentId,
273
286
  postId,
274
287
  parentCommentId: String(n.comment?.parent_id || n.parent_id || ""),
288
+ authorId: String(author.id || author.agent_id || author.agentId || ""),
275
289
  authorName: author.username || author.name || "user",
276
290
  postTitle: n.post?.title || n.post_title || "",
277
291
  postUrl: `https://www.moltbook.com/post/${postId}`,
@@ -285,6 +299,71 @@ async function cmdPoll() {
285
299
  console.log(`added ${written} new item(s)`);
286
300
  }
287
301
 
302
+ async function cmdInboxPick(flags) {
303
+ await ensureInboxDir();
304
+ const { mb, env } = await getClient();
305
+ const maxDaily = Number(flags["max-daily"]) || 5;
306
+ const state = rollScoutDayIfNeeded(await readScoutState());
307
+ const reconciled = await reconcileInboxDraftMarkers(await listInboxItems());
308
+ const quota = await getMoltbookReplyQuotaState({ state, maxDaily });
309
+ if (quota.quotaReached) {
310
+ await writeScoutState(state);
311
+ console.log(JSON.stringify({
312
+ status: "quota-reached",
313
+ sentToday: quota.sentToday,
314
+ pendingToday: quota.pendingToday,
315
+ usedToday: quota.usedToday,
316
+ maxDaily: quota.maxDaily,
317
+ day: quota.day,
318
+ recoveredDrafts: reconciled.resetCount,
319
+ }, null, 2));
320
+ return;
321
+ }
322
+ const picked = pickInboxReplyCandidate(reconciled.items, env);
323
+ if (!picked.item) {
324
+ console.log(JSON.stringify({
325
+ status: "empty",
326
+ skipReasons: picked.skipReasons,
327
+ recoveredDrafts: reconciled.resetCount,
328
+ }, null, 2));
329
+ return;
330
+ }
331
+
332
+ const item = picked.item;
333
+ let post = null;
334
+ if (!flags["no-fetch"]) {
335
+ try {
336
+ const data = await mb(`/posts/${item.postId}`);
337
+ post = data?.post || data || null;
338
+ } catch (error) {
339
+ console.error(`inbox-pick: could not fetch post ${item.postId}: ${error.message}`);
340
+ }
341
+ }
342
+
343
+ const postAuthor = post?.author || post?.user || {};
344
+ const output = {
345
+ status: "candidate",
346
+ commentId: String(item.commentId),
347
+ postId: String(item.postId),
348
+ parentCommentId: String(item.parentCommentId || ""),
349
+ author: item.authorName || "user",
350
+ authorId: item.authorId || "",
351
+ postTitle: item.postTitle || post?.title || "",
352
+ postUrl: item.postUrl || `https://www.moltbook.com/post/${item.postId}`,
353
+ postAuthor: postAuthor.username || postAuthor.name || "",
354
+ postBody: post?.content || post?.body || "",
355
+ commentText: item.contextText || "",
356
+ createdAt: item.createdAt || "",
357
+ consideredCount: picked.consideredCount,
358
+ skipReasons: picked.skipReasons,
359
+ recoveredDrafts: reconciled.resetCount,
360
+ };
361
+ const json = JSON.stringify(output, (_, v) =>
362
+ typeof v === "string" ? v.replace(/[\x00-\x1f]/g, (ch) => ch === "\n" || ch === "\t" ? ch : "") : v
363
+ , 2);
364
+ console.log(json);
365
+ }
366
+
288
367
  // ---------------------------------------------------------------------------
289
368
  // scout: pick one candidate post from the feed and print the context needed
290
369
  // for an agent (Codex / Claude Desktop / whatever) to draft a reply.
@@ -311,10 +390,18 @@ async function cmdScout(flags) {
311
390
  const dryRun = Boolean(flags["dry-run"]);
312
391
 
313
392
  const state = rollScoutDayIfNeeded(await readScoutState());
314
- if (state.sentToday >= maxDaily) {
393
+ const quota = await getMoltbookReplyQuotaState({ state, maxDaily });
394
+ if (quota.quotaReached) {
315
395
  console.log(
316
396
  JSON.stringify(
317
- { status: "quota-reached", sentToday: state.sentToday, maxDaily, day: state.day },
397
+ {
398
+ status: "quota-reached",
399
+ sentToday: quota.sentToday,
400
+ pendingToday: quota.pendingToday,
401
+ usedToday: quota.usedToday,
402
+ maxDaily: quota.maxDaily,
403
+ day: quota.day,
404
+ },
318
405
  null,
319
406
  2
320
407
  )
@@ -495,6 +582,14 @@ async function cmdPropose(postId, flags) {
495
582
  const postAuthor = typeof flags["post-author"] === "string" ? flags["post-author"] : "";
496
583
  const intent = typeof flags.intent === "string" ? flags.intent : "";
497
584
  const postUrl = `https://www.moltbook.com/post/${postId}`;
585
+ const sourceId = typeof flags["source-id"] === "string" && flags["source-id"].trim()
586
+ ? flags["source-id"].trim()
587
+ : parentCommentId
588
+ ? `inbox-comment:${parentCommentId}`
589
+ : `draft:${postId}:${Date.now()}`;
590
+ const draftSource = typeof flags["draft-source"] === "string" && flags["draft-source"].trim()
591
+ ? flags["draft-source"].trim()
592
+ : (parentCommentId ? "inbox-auto-scout" : "auto-scout");
498
593
 
499
594
  const env = await loadMoltbookEnv();
500
595
  const base = (env.VIVEWORKER_BASE_URL || "https://127.0.0.1:8810").replace(/\/+$/u, "");
@@ -504,8 +599,6 @@ async function cmdPropose(postId, flags) {
504
599
  const prevTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
505
600
  if (!prevTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
506
601
 
507
- const sourceId = `draft:${postId}:${Date.now()}`;
508
-
509
602
  // Submit draft to bridge. The bridge persists it to disk and handles posting
510
603
  // on approval — the CLI exits immediately (fire-and-forget).
511
604
  let submitRes;
@@ -537,6 +630,17 @@ async function cmdPropose(postId, flags) {
537
630
  }
538
631
  const token = submitRes?.token;
539
632
  if (!token) fail(`bridge did not return a token: ${JSON.stringify(submitRes)}`);
633
+ if (parentCommentId) {
634
+ await updateInboxStatus(parentCommentId, "pending", {
635
+ draftStatus: "proposed",
636
+ draftSource,
637
+ draftSourceId: sourceId,
638
+ draftToken: token,
639
+ draftedAt: new Date().toISOString(),
640
+ draftError: "",
641
+ }).catch(() => null);
642
+ await resolveOnBridge(env, parentCommentId);
643
+ }
540
644
 
541
645
  console.log(JSON.stringify({ ok: true, token, ttlSec, fireAndForget: true }));
542
646
  if (!prevTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevTls ?? "";
@@ -603,7 +707,26 @@ async function cmdBatchAdd(flags) {
603
707
 
604
708
  async function cmdBatchPick(flags) {
605
709
  const windowMs = Number(flags["window-ms"]) || DEFAULT_BATCH_WINDOW_MS;
710
+ const maxDaily = Number(flags["max-daily"]) || 5;
606
711
  const state = rollScoutDayIfNeeded(await readScoutState());
712
+ const quota = await getMoltbookReplyQuotaState({ state, maxDaily });
713
+
714
+ if (quota.quotaReached) {
715
+ if (quota.sentToday >= quota.maxDaily) {
716
+ state.batch = null;
717
+ }
718
+ await writeScoutState(state);
719
+ console.log(JSON.stringify({
720
+ status: "quota-reached",
721
+ sentToday: quota.sentToday,
722
+ pendingToday: quota.pendingToday,
723
+ usedToday: quota.usedToday,
724
+ maxDaily: quota.maxDaily,
725
+ day: quota.day,
726
+ droppedBatch: quota.sentToday >= quota.maxDaily,
727
+ }, null, 2));
728
+ return;
729
+ }
607
730
 
608
731
  if (!state.batch || !state.batch.candidates?.length) {
609
732
  console.log(JSON.stringify({ status: "empty" }));
@@ -933,6 +1056,8 @@ export async function runMoltbookCli(argv) {
933
1056
  return cmdPoll();
934
1057
  case "reconcile":
935
1058
  return cmdReconcile();
1059
+ case "inbox-pick":
1060
+ return cmdInboxPick(flags);
936
1061
  case "scout":
937
1062
  return cmdScout(flags);
938
1063
  case "propose":
@@ -972,12 +1097,13 @@ Commands:
972
1097
  mark-skip <commentId> Mark an item as skipped
973
1098
  poll Manual one-shot notification refresh
974
1099
  reconcile Mark inbox items already replied to as resolved
1100
+ inbox-pick [--no-fetch] Pick one pending incoming comment for a reply draft
975
1101
  scout [--max-daily N] [--submolts a,b] [--dry-run]
976
1102
  Pick one feed candidate and print context
977
- propose <postId> --text "..." [--timeout 900] [--parent-id <commentId>]
1103
+ propose <postId> --text "..." [--timeout 86400] [--parent-id <commentId>] [--source-id <id>]
978
1104
  Submit draft for phone approval, then post on approve
979
1105
  compose [--max-daily N] Check activity & return compose material (JSON)
980
- compose-propose --title "..." --content "..." [--submolt general] [--timeout 900]
1106
+ compose-propose --title "..." --content "..." [--submolt general] [--timeout 86400]
981
1107
  Submit original post draft for phone approval, then publish
982
1108
  persona init|edit|show Manage ~/.viveworker/moltbook-persona.md (inlined into scout prompt)`);
983
1109
  return;
@@ -10,7 +10,7 @@
10
10
  # Environment:
11
11
  # SCOUT_HARNESS — "claude" or "codex" (default: auto-detect)
12
12
  # SCOUT_FLAGS — extra flags for scout (e.g. "--submolts builds,general --max-daily 5")
13
- # SCOUT_TIMEOUT — propose timeout in seconds (default: 900)
13
+ # SCOUT_TIMEOUT — propose timeout in seconds (default: 86400 = 24h)
14
14
  # SCOUT_DRAFT_TIMEOUT — LLM draft timeout in seconds (default: 120)
15
15
  # SCOUT_WINDOW — batch window in seconds (default: 1800 = 30 min)
16
16
  # COMPOSE_MAX — max original posts per day (default: 1)
@@ -27,7 +27,7 @@ NODE="$(command -v node)"
27
27
  VIVEWORKER="$SCRIPT_DIR/viveworker.mjs"
28
28
  PERSONA_FILE="$HOME/.viveworker/moltbook-persona.md"
29
29
  WINDOW_SEC="${SCOUT_WINDOW:-1800}"
30
- PROPOSE_TIMEOUT_SEC="${SCOUT_TIMEOUT:-900}"
30
+ PROPOSE_TIMEOUT_SEC="${SCOUT_TIMEOUT:-86400}"
31
31
  DRAFT_TIMEOUT_SEC="${SCOUT_DRAFT_TIMEOUT:-120}"
32
32
 
33
33
  # Seconds until a given hour (local time). Used for compose slot timeouts.
@@ -106,6 +106,102 @@ json_field() {
106
106
  echo "$1" | "$NODE" -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{console.log(JSON.parse(d).$2||'')}catch{}})"
107
107
  }
108
108
 
109
+ # ═══════════════════════════════════════════════════════════════
110
+ # Step -1: Draft a reply to an incoming comment first.
111
+ # Inbox replies continue an active conversation, so they take priority over
112
+ # original posts and discovery replies. The CLI marks a successful proposal
113
+ # on the inbox item, which prevents duplicate drafts for the same comment.
114
+ # ═══════════════════════════════════════════════════════════════
115
+ INBOX_JSON=$("$NODE" "$VIVEWORKER" moltbook inbox-pick ${SCOUT_FLAGS:-} 2>/dev/null) || true
116
+ INBOX_STATUS=$(json_field "$INBOX_JSON" "status")
117
+
118
+ if [ "$INBOX_STATUS" = "candidate" ]; then
119
+ if [ -z "$HARNESS_BIN" ]; then
120
+ echo "[scout-auto] inbox: pending comment found, but no draft harness is available"
121
+ else
122
+ printf '%s' "$INBOX_JSON" > "$SCOUT_TMP"
123
+ INBOX_COMMENT_ID=$(read_field commentId)
124
+ INBOX_POST_ID=$(read_field postId)
125
+ INBOX_AUTHOR=$(read_field author)
126
+ INBOX_POST_TITLE=$(read_field postTitle)
127
+ INBOX_POST_AUTHOR=$(read_field postAuthor)
128
+ INBOX_POST_URL=$(read_field postUrl)
129
+ INBOX_POST_BODY=$(read_field postBody)
130
+ INBOX_COMMENT_TEXT=$(read_field commentText)
131
+
132
+ echo "[scout-auto] inbox: drafting reply to @$INBOX_AUTHOR on '$INBOX_POST_TITLE'"
133
+
134
+ INBOX_PROMPT="$(cat <<INBOX_PROMPT_EOF
135
+ ${PERSONA:+You are drafting a reply on behalf of the agent described below. Follow this persona precisely.
136
+ -----8<----- persona -----8<-----
137
+ $PERSONA
138
+ -----8<----- end persona -----8<-----
139
+
140
+ }Someone replied to your Moltbook post. You are continuing that conversation, not cold-starting a new outreach reply.
141
+
142
+ Original post:
143
+ Title: $INBOX_POST_TITLE
144
+ Author: ${INBOX_POST_AUTHOR:-you}
145
+ URL: $INBOX_POST_URL
146
+
147
+ Post body:
148
+ $INBOX_POST_BODY
149
+
150
+ Incoming comment from @$INBOX_AUTHOR:
151
+ $INBOX_COMMENT_TEXT
152
+
153
+ Draft a natural reply in the persona voice. Keep it concise, specific to the comment, and conversational. Prefer ending with one concrete question only if it genuinely helps continue the thread.
154
+
155
+ Output in this exact format — no markdown fences, no extra text:
156
+
157
+ INTENT: (1-2 sentences in Japanese: why this reply angle fits the incoming comment and original post)
158
+ ---
159
+ (your reply text here)
160
+ INBOX_PROMPT_EOF
161
+ )"
162
+
163
+ INBOX_DRAFT_TEXT=""
164
+ if [ "$HARNESS" = "claude" ]; then
165
+ INBOX_DRAFT_TEXT=$(portable_timeout "$DRAFT_TIMEOUT_SEC" "$HARNESS_BIN" -p "$INBOX_PROMPT" --output-format text 2>/dev/null) || true
166
+ elif [ "$HARNESS" = "codex" ]; then
167
+ INBOX_DRAFT_TEXT=$(portable_timeout "$DRAFT_TIMEOUT_SEC" "$HARNESS_BIN" exec "$INBOX_PROMPT" 2>/dev/null) || true
168
+ fi
169
+
170
+ if [ -z "$INBOX_DRAFT_TEXT" ]; then
171
+ echo "[scout-auto] inbox: harness returned empty draft or timed out after ${DRAFT_TIMEOUT_SEC}s"
172
+ else
173
+ INBOX_INTENT=""
174
+ INBOX_REPLY_BODY=""
175
+ if echo "$INBOX_DRAFT_TEXT" | grep -q "^---$"; then
176
+ INBOX_INTENT=$(echo "$INBOX_DRAFT_TEXT" | sed -n '1,/^---$/p' | sed '/^---$/d' | sed 's/^INTENT: *//')
177
+ INBOX_REPLY_BODY=$(echo "$INBOX_DRAFT_TEXT" | sed '1,/^---$/d')
178
+ else
179
+ INBOX_REPLY_BODY="$INBOX_DRAFT_TEXT"
180
+ INBOX_INTENT="inbox: responding to @${INBOX_AUTHOR}'s comment"
181
+ fi
182
+ INBOX_INTENT=$(echo "$INBOX_INTENT" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
183
+ INBOX_REPLY_BODY=$(echo "$INBOX_REPLY_BODY" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
184
+
185
+ if [ -n "$INBOX_REPLY_BODY" ]; then
186
+ echo "[scout-auto] inbox: draft ready (${#INBOX_REPLY_BODY} chars), submitting propose"
187
+ "$NODE" "$VIVEWORKER" moltbook propose "$INBOX_POST_ID" \
188
+ --title "$INBOX_POST_TITLE" \
189
+ --post-author "$INBOX_POST_AUTHOR" \
190
+ --post-body "$INBOX_POST_BODY" \
191
+ --intent "$INBOX_INTENT" \
192
+ --text "$INBOX_REPLY_BODY" \
193
+ --parent-id "$INBOX_COMMENT_ID" \
194
+ --source-id "inbox-comment:$INBOX_COMMENT_ID" \
195
+ --draft-source "inbox-auto-scout" \
196
+ --timeout "$PROPOSE_TIMEOUT_SEC"
197
+
198
+ exit 0
199
+ fi
200
+ echo "[scout-auto] inbox: parsed draft body is empty"
201
+ fi
202
+ fi
203
+ fi
204
+
109
205
  # ═══════════════════════════════════════════════════════════════
110
206
  # Step 0: Compose an original post (slot-based: morning/noon/evening)
111
207
  # - morning (9-12): yesterday's work if not posted yesterday
@@ -208,13 +304,7 @@ COMPOSE_EOF
208
304
  C_BODY=$(echo "$C_BODY" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
209
305
 
210
306
  if [ -n "$C_TITLE" ] && [ -n "$C_BODY" ]; then
211
- # Timeout = seconds until current slot ends.
212
- case "$COMPOSE_SLOT" in
213
- morning) COMPOSE_TIMEOUT=$(seconds_until_hour 12) ;;
214
- noon) COMPOSE_TIMEOUT=$(seconds_until_hour 17) ;;
215
- evening) COMPOSE_TIMEOUT=$(seconds_until_hour 24) ;;
216
- *) COMPOSE_TIMEOUT=1800 ;;
217
- esac
307
+ COMPOSE_TIMEOUT="$PROPOSE_TIMEOUT_SEC"
218
308
  echo "[scout-auto] compose ($COMPOSE_SLOT): title='$C_TITLE' submolt=$C_SUBMOLT (${#C_BODY} chars, timeout=${COMPOSE_TIMEOUT}s)"
219
309
 
220
310
  "$NODE" "$VIVEWORKER" moltbook compose-propose \
@@ -238,7 +328,7 @@ fi
238
328
  # ═══════════════════════════════════════════════════════════════
239
329
  # Step 1: Check if the batch window has expired → pick & draft
240
330
  # ═══════════════════════════════════════════════════════════════
241
- PICK_JSON=$("$NODE" "$VIVEWORKER" moltbook batch-pick --window-ms "$WINDOW_MS" 2>/dev/null) || true
331
+ PICK_JSON=$("$NODE" "$VIVEWORKER" moltbook batch-pick --window-ms "$WINDOW_MS" ${SCOUT_FLAGS:-} 2>/dev/null) || true
242
332
  PICK_STATUS=$(json_field "$PICK_JSON" "status")
243
333
 
244
334
  if [ "$PICK_STATUS" = "picked" ]; then
@@ -185,6 +185,7 @@ async function pollOnce() {
185
185
  seen.add(commentId);
186
186
  const sourceId = `comment:${commentId}`;
187
187
  const author = n.actor || n.author || n.comment?.author || {};
188
+ const authorId = String(author.id || author.agent_id || author.agentId || "");
188
189
  let authorName = author.username || author.name || "";
189
190
  if (!authorName) {
190
191
  authorName = (await resolveAuthorName(postId, commentId, n.type || n.notificationType || "", postCommentCache, postCache)) || "user";
@@ -194,12 +195,21 @@ async function pollOnce() {
194
195
  const createdAtIso = n.created_at || n.createdAt || new Date().toISOString();
195
196
  const parentCommentId = String(n.comment?.parent_id || n.parent_id || "");
196
197
 
198
+ // "mention" notifications carry a synthetic commentId that doesn't
199
+ // exist in the moltbook comment table — replying with parent_id=that
200
+ // value 404s ("Parent comment not found"). Mark these so the CLI can
201
+ // skip parent_id and post a top-level comment on the post instead.
202
+ const notifType = String(n.type || n.kind || n.notificationType || "").toLowerCase();
203
+ const kind = notifType.includes("mention") ? "mention" : "reply";
204
+
197
205
  pending.set(sourceId, { commentId, postId });
198
206
 
199
207
  const inboxItem = {
200
208
  commentId,
201
209
  postId,
202
210
  parentCommentId,
211
+ kind,
212
+ authorId,
203
213
  authorName,
204
214
  postTitle,
205
215
  postUrl: `https://www.moltbook.com/post/${postId}`,