viveworker 0.2.1 → 0.3.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.
@@ -0,0 +1,1302 @@
1
+ // Moltbook CLI — invoked from scripts/viveworker.mjs as
2
+ // `viveworker moltbook <cmd>`. Designed to be driven by Codex/Claude Desktop
3
+ // when the operator asks the agent to handle a Moltbook notification.
4
+ //
5
+ // Commands:
6
+ // list list pending inbox items
7
+ // list --all include replied/skipped items
8
+ // show <commentId> print full context for one comment
9
+ // thread <commentId> print comment tree (via /posts/:id/comments)
10
+ // reply <commentId> --text "..." post a reply; print verification challenge
11
+ // verify <verificationCode> <answer> solve the verification puzzle
12
+ // mark-skip <commentId> mark as skipped without replying
13
+
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { promises as fsp } from "node:fs";
17
+ import { spawn } from "node:child_process";
18
+ import { createInterface as createReadline } from "node:readline/promises";
19
+ import {
20
+ createMoltbookClient,
21
+ extractNotifications,
22
+ isCommentNotification,
23
+ listInboxItems,
24
+ loadMoltbookEnv,
25
+ readInboxItem,
26
+ updateInboxStatus,
27
+ writeInboxItem,
28
+ ensureInboxDir,
29
+ readScoutState,
30
+ writeScoutState,
31
+ rollScoutDayIfNeeded,
32
+ markPostSeen,
33
+ recordComposeAttempt,
34
+ } from "./moltbook-api.mjs";
35
+
36
+ function fail(message, code = 1) {
37
+ console.error(`moltbook: ${message}`);
38
+ process.exit(code);
39
+ }
40
+
41
+ async function getClient() {
42
+ const env = await loadMoltbookEnv();
43
+ const apiKey = env.MOLTBOOK_API_KEY;
44
+ if (!apiKey) fail("MOLTBOOK_API_KEY missing (expected in ~/.viveworker/moltbook.env)");
45
+ return { mb: createMoltbookClient(apiKey), env };
46
+ }
47
+
48
+ // Notify the viveworker bridge that a moltbook item is no longer pending so
49
+ // it's removed from the mobile "unhandled" list. Best-effort: silently ignore
50
+ // failures (the bridge may not be running, or the item may never have been
51
+ // pushed to the bridge in the first place).
52
+ async function resolveOnBridge(env, commentId) {
53
+ const base = (env.VIVEWORKER_BASE_URL || "https://127.0.0.1:8810").replace(/\/+$/u, "");
54
+ const secret = env.VIVEWORKER_HOOK_SECRET || "";
55
+ if (!secret) return;
56
+ const prev = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
57
+ if (!prev) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
58
+ try {
59
+ await fetch(`${base}/api/providers/moltbook/events`, {
60
+ method: "POST",
61
+ headers: {
62
+ "content-type": "application/json",
63
+ "x-viveworker-hook-secret": secret,
64
+ },
65
+ body: JSON.stringify({ sourceId: `comment:${commentId}`, eventType: "resolve" }),
66
+ });
67
+ } catch {
68
+ // ignore
69
+ } finally {
70
+ if (!prev) process.env.NODE_TLS_REJECT_UNAUTHORIZED = prev ?? "";
71
+ }
72
+ }
73
+
74
+ function parseFlags(argv) {
75
+ const positional = [];
76
+ const flags = {};
77
+ for (let i = 0; i < argv.length; i += 1) {
78
+ const a = argv[i];
79
+ if (a.startsWith("--")) {
80
+ const eq = a.indexOf("=");
81
+ if (eq !== -1) {
82
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
83
+ } else {
84
+ const key = a.slice(2);
85
+ const next = argv[i + 1];
86
+ if (next && !next.startsWith("--")) {
87
+ flags[key] = next;
88
+ i += 1;
89
+ } else {
90
+ flags[key] = true;
91
+ }
92
+ }
93
+ } else {
94
+ positional.push(a);
95
+ }
96
+ }
97
+ return { positional, flags };
98
+ }
99
+
100
+ function truncate(text, max) {
101
+ const s = String(text || "").replace(/\s+/g, " ").trim();
102
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
103
+ }
104
+
105
+ async function cmdList(flags) {
106
+ await ensureInboxDir();
107
+ const items = await listInboxItems();
108
+ const filtered = flags.all ? items : items.filter((item) => item.status === "pending");
109
+ if (filtered.length === 0) {
110
+ console.log(flags.all ? "(no inbox items)" : "(no pending items — use --all to see replied/skipped)");
111
+ return;
112
+ }
113
+ for (const item of filtered) {
114
+ console.log(
115
+ `${item.status.padEnd(8)} ${item.commentId} @${item.authorName || "?"} "${truncate(item.postTitle, 40)}"`
116
+ );
117
+ console.log(` ${truncate(item.contextText, 120)}`);
118
+ console.log(` ${item.postUrl || ""} ${item.createdAt || ""}`);
119
+ }
120
+ }
121
+
122
+ async function cmdShow(commentId) {
123
+ if (!commentId) fail("usage: viveworker moltbook show <commentId>");
124
+ const item = await readInboxItem(commentId);
125
+ if (!item) fail(`no inbox item for ${commentId}`);
126
+ console.log(JSON.stringify(item, null, 2));
127
+ }
128
+
129
+ async function cmdThread(commentId) {
130
+ if (!commentId) fail("usage: viveworker moltbook thread <commentId>");
131
+ const item = await readInboxItem(commentId);
132
+ if (!item) fail(`no inbox item for ${commentId}`);
133
+ const { mb } = await getClient();
134
+ const data = await mb(`/posts/${item.postId}/comments?sort=new&limit=50`);
135
+ console.log(JSON.stringify(data, null, 2));
136
+ }
137
+
138
+ async function cmdReply(commentId, flags) {
139
+ if (!commentId) fail("usage: viveworker moltbook reply <commentId> --text \"...\"");
140
+ const text = typeof flags.text === "string" ? flags.text : "";
141
+ if (!text.trim()) fail("--text is required and must be non-empty");
142
+ let item = await readInboxItem(commentId);
143
+ if (!item) {
144
+ // Allow replying to an ad-hoc commentId even when it's not in the inbox;
145
+ // caller must also pass --post-id in that case.
146
+ if (!flags["post-id"]) fail(`no inbox item for ${commentId}; pass --post-id <id> to reply anyway`);
147
+ item = {
148
+ commentId,
149
+ postId: String(flags["post-id"]),
150
+ authorName: "",
151
+ postTitle: "",
152
+ postUrl: `https://www.moltbook.com/posts/${flags["post-id"]}`,
153
+ contextText: "",
154
+ createdAt: new Date().toISOString(),
155
+ status: "pending",
156
+ source: "cli",
157
+ };
158
+ await writeInboxItem(item);
159
+ }
160
+ const { mb, env } = await getClient();
161
+ const result = await mb(`/posts/${item.postId}/comments`, {
162
+ method: "POST",
163
+ body: JSON.stringify({ content: text, parent_id: commentId }),
164
+ });
165
+ const verification = result?.comment?.verification || null;
166
+ await resolveOnBridge(env, commentId);
167
+ await updateInboxStatus(commentId, "replied", {
168
+ source: "cli",
169
+ replyText: text,
170
+ replyCommentId: result?.comment?.id || "",
171
+ replyVerification: verification,
172
+ });
173
+ console.log(JSON.stringify({ ok: true, commentId, reply: result?.comment, verification }, null, 2));
174
+ if (verification) {
175
+ console.log("");
176
+ console.log("VERIFICATION REQUIRED:");
177
+ console.log(` verification_code: ${verification.verification_code}`);
178
+ console.log(` challenge_text: ${verification.challenge_text}`);
179
+ console.log(` expires_at: ${verification.expires_at}`);
180
+ console.log("");
181
+ console.log("Solve the arithmetic problem and run:");
182
+ console.log(` viveworker moltbook verify ${verification.verification_code} <answer>`);
183
+ }
184
+ }
185
+
186
+ async function cmdVerify(verificationCode, answer) {
187
+ if (!verificationCode || !answer) {
188
+ fail("usage: viveworker moltbook verify <verificationCode> <answer>");
189
+ }
190
+ const { mb } = await getClient();
191
+ const result = await mb(`/verify`, {
192
+ method: "POST",
193
+ body: JSON.stringify({ verification_code: verificationCode, answer }),
194
+ });
195
+ console.log(JSON.stringify(result, null, 2));
196
+ }
197
+
198
+ async function cmdMarkSkip(commentId) {
199
+ if (!commentId) fail("usage: viveworker moltbook mark-skip <commentId>");
200
+ const updated = await updateInboxStatus(commentId, "skipped", { source: "cli" });
201
+ if (!updated) fail(`no inbox item for ${commentId}`);
202
+ const env = await loadMoltbookEnv();
203
+ await resolveOnBridge(env, commentId);
204
+ console.log(`marked ${commentId} as skipped`);
205
+ }
206
+
207
+ async function cmdReconcile() {
208
+ // For every pending inbox item, fetch the post's comment tree and check
209
+ // whether viveworker has already posted a reply whose parent_id matches
210
+ // the pending commentId. If so, mark the inbox item as replied and clear
211
+ // it from the bridge. Useful after replies made outside the CLI.
212
+ const { mb, env } = await getClient();
213
+ const items = (await listInboxItems()).filter((item) => item.status === "pending");
214
+ const postCache = new Map();
215
+ let resolved = 0;
216
+ for (const item of items) {
217
+ try {
218
+ if (!postCache.has(item.postId)) {
219
+ const tree = await mb(`/posts/${item.postId}/comments?sort=new&limit=100`);
220
+ postCache.set(item.postId, tree);
221
+ }
222
+ const tree = postCache.get(item.postId);
223
+ const flat = [];
224
+ const walk = (nodes) => {
225
+ if (!Array.isArray(nodes)) return;
226
+ for (const node of nodes) {
227
+ if (!node) continue;
228
+ flat.push(node);
229
+ walk(node.replies || node.children || []);
230
+ }
231
+ };
232
+ walk(tree?.comments || tree?.data || tree || []);
233
+ const hasOurReply = flat.some((c) => {
234
+ const parent = String(c?.parent_id || c?.parent?.id || "");
235
+ if (parent !== String(item.commentId)) return false;
236
+ const a = c?.author || c?.user || {};
237
+ const name = String(a.username || a.name || "").toLowerCase();
238
+ return name === "viveworker";
239
+ });
240
+ if (hasOurReply) {
241
+ await updateInboxStatus(item.commentId, "replied", { source: "reconcile" });
242
+ await resolveOnBridge(env, item.commentId);
243
+ resolved += 1;
244
+ console.log(`resolved ${item.commentId} (@${item.authorName})`);
245
+ }
246
+ } catch (error) {
247
+ console.error(`reconcile ${item.commentId}: ${error.message}`);
248
+ }
249
+ }
250
+ console.log(`reconciled ${resolved}/${items.length} pending item(s)`);
251
+ }
252
+
253
+ async function cmdPoll() {
254
+ // Manual one-shot poll — useful when the launchd watcher isn't running or
255
+ // when you want to refresh the inbox on demand. Mirrors watcher logic but
256
+ // without the bridge push.
257
+ const { mb } = await getClient();
258
+ const data = await mb(`/notifications`);
259
+ const notifications = extractNotifications(data);
260
+ let written = 0;
261
+ for (const n of notifications) {
262
+ if (!isCommentNotification(n)) continue;
263
+ const commentId = String(n.comment_id || n.comment?.id || n.id || "");
264
+ const postId = String(n.post_id || n.post?.id || n.comment?.post_id || "");
265
+ if (!commentId || !postId) continue;
266
+ const existing = await readInboxItem(commentId);
267
+ if (existing) continue;
268
+ const author = n.actor || n.author || n.comment?.author || {};
269
+ await writeInboxItem({
270
+ commentId,
271
+ postId,
272
+ parentCommentId: String(n.comment?.parent_id || n.parent_id || ""),
273
+ authorName: author.username || author.name || "user",
274
+ postTitle: n.post?.title || n.post_title || "",
275
+ postUrl: `https://www.moltbook.com/posts/${postId}`,
276
+ contextText: String(n.comment?.content || n.content || n.preview || ""),
277
+ createdAt: n.created_at || n.createdAt || new Date().toISOString(),
278
+ status: "pending",
279
+ source: "cli-poll",
280
+ });
281
+ written += 1;
282
+ }
283
+ console.log(`added ${written} new item(s)`);
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // scout: pick one candidate post from the feed and print the context needed
288
+ // for an agent (Codex / Claude Desktop / whatever) to draft a reply.
289
+ //
290
+ // This command is intentionally LLM-free — it fetches /home, filters,
291
+ // chooses one post, and writes the full thread context to stdout as JSON.
292
+ // The calling agent is expected to draft the reply and then invoke
293
+ // `viveworker moltbook propose <postId> --text "..."`.
294
+ // ---------------------------------------------------------------------------
295
+
296
+ const DEFAULT_SUBMOLTS = ["general", "builds", "tooling", "agents", "infrastructure"];
297
+
298
+ function parseSubmolts(flag) {
299
+ if (!flag || flag === true) return DEFAULT_SUBMOLTS;
300
+ return String(flag)
301
+ .split(",")
302
+ .map((s) => s.trim())
303
+ .filter(Boolean);
304
+ }
305
+
306
+ async function cmdScout(flags) {
307
+ const maxDaily = Number(flags["max-daily"]) || 5;
308
+ const submolts = parseSubmolts(flags.submolts);
309
+ const dryRun = Boolean(flags["dry-run"]);
310
+
311
+ const state = rollScoutDayIfNeeded(await readScoutState());
312
+ if (state.sentToday >= maxDaily) {
313
+ console.log(
314
+ JSON.stringify(
315
+ { status: "quota-reached", sentToday: state.sentToday, maxDaily, day: state.day },
316
+ null,
317
+ 2
318
+ )
319
+ );
320
+ await writeScoutState(state);
321
+ return;
322
+ }
323
+
324
+ const { mb, env } = await getClient();
325
+ const myAgentId = env.MOLTBOOK_AGENT_ID || "";
326
+
327
+ // Pull the cross-submolt feed. /home is just a directory of endpoints;
328
+ // /feed is the actual list. Fall back to /posts?sort=hot if /feed is
329
+ // unavailable for any reason.
330
+ let feed;
331
+ try {
332
+ feed = await mb(`/feed?sort=hot&limit=50`);
333
+ } catch (error) {
334
+ console.error(`scout: /feed failed (${error.message}); falling back to /posts?sort=hot`);
335
+ feed = await mb(`/posts?sort=hot&limit=50`);
336
+ }
337
+
338
+ const posts = extractFeedPosts(feed);
339
+ if (posts.length === 0) {
340
+ console.log(JSON.stringify({ status: "no-posts", feedKeys: Object.keys(feed || {}) }, null, 2));
341
+ await writeScoutState(state);
342
+ return;
343
+ }
344
+
345
+ // Filter: drop self-authored, already-seen, wrong submolt, etc.
346
+ const candidates = [];
347
+ const skipReasons = { noId: 0, seen: 0, selfAuthor: 0, viveworker: 0, unverified: 0, submolt: 0 };
348
+ const submoltsSeen = {};
349
+ for (const post of posts) {
350
+ const postId = String(post?.id || post?.post_id || "");
351
+ if (!postId) { skipReasons.noId++; continue; }
352
+ if (state.seenPostIds[postId]) { skipReasons.seen++; continue; }
353
+ const authorId = String(post?.author?.id || post?.author_id || "");
354
+ const authorName = String(post?.author?.name || post?.author?.username || "").toLowerCase();
355
+ if (myAgentId && authorId === myAgentId) { skipReasons.selfAuthor++; continue; }
356
+ if (authorName === "viveworker") { skipReasons.viveworker++; continue; }
357
+ if (post?.verification_status && String(post.verification_status).toLowerCase() !== "verified") { skipReasons.unverified++; continue; }
358
+ const submolt = String(post?.submolt?.name || post?.submolt_name || "").toLowerCase();
359
+ submoltsSeen[submolt] = (submoltsSeen[submolt] || 0) + 1;
360
+ // If the caller passed --submolts, enforce it. Otherwise accept any
361
+ // submolt — the feed itself is already curated.
362
+ if (submolts.length > 0 && !submolts.includes(submolt)) { skipReasons.submolt++; continue; }
363
+ candidates.push({ post, postId, submolt, authorName });
364
+ }
365
+ if (candidates.length === 0 && process.env.SCOUT_DEBUG) {
366
+ console.error(`scout: skip reasons: ${JSON.stringify(skipReasons)}`);
367
+ console.error(`scout: submolts in feed: ${JSON.stringify(submoltsSeen)}`);
368
+ }
369
+
370
+ if (candidates.length === 0) {
371
+ console.log(
372
+ JSON.stringify(
373
+ {
374
+ status: "no-candidates",
375
+ totalPosts: posts.length,
376
+ submoltsFilter: submolts,
377
+ sentToday: state.sentToday,
378
+ maxDaily,
379
+ },
380
+ null,
381
+ 2
382
+ )
383
+ );
384
+ await writeScoutState(state);
385
+ return;
386
+ }
387
+
388
+ // Pick the top candidate (feed is already ordered). Could later add
389
+ // scoring by recency + score + comment_count.
390
+ const pick = candidates[0];
391
+
392
+ // Pull the full comment tree so the agent can see existing discussion.
393
+ let thread = null;
394
+ try {
395
+ thread = await mb(`/posts/${pick.postId}/comments?sort=top&limit=50`);
396
+ } catch (error) {
397
+ console.error(`scout: could not fetch thread (${error.message})`);
398
+ }
399
+
400
+ // Check whether viveworker already commented on this post — if so, mark
401
+ // seen and skip (the caller will re-run scout to get another candidate).
402
+ if (thread && hasViveworkerComment(thread)) {
403
+ markPostSeen(state, pick.postId, "already-replied");
404
+ await writeScoutState(state);
405
+ console.log(
406
+ JSON.stringify(
407
+ { status: "already-engaged", postId: pick.postId, retry: true },
408
+ null,
409
+ 2
410
+ )
411
+ );
412
+ return;
413
+ }
414
+
415
+ // Mark as seen immediately so we do not repeatedly propose the same post
416
+ // if the caller dies mid-flight. `propose` will update counters on
417
+ // successful publish.
418
+ markPostSeen(state, pick.postId, "proposed");
419
+ await writeScoutState(state);
420
+
421
+ const p = pick.post;
422
+ const output = {
423
+ status: "candidate",
424
+ postId: pick.postId,
425
+ postUrl: `https://www.moltbook.com/post/${pick.postId}`,
426
+ submolt: pick.submolt,
427
+ author: p?.author?.name || p?.author?.username || "",
428
+ title: p?.title || "",
429
+ content: p?.content || p?.body || "",
430
+ score: p?.score ?? p?.upvotes ?? 0,
431
+ commentCount: p?.comment_count ?? 0,
432
+ createdAt: p?.created_at || p?.createdAt || "",
433
+ thread: thread?.comments || thread?.data || thread || null,
434
+ quota: { sentToday: state.sentToday, maxDaily, day: state.day },
435
+ dryRun,
436
+ };
437
+ // Sanitize control characters that break JSON.parse in the shell pipeline.
438
+ const json = JSON.stringify(output, (_, v) =>
439
+ typeof v === "string" ? v.replace(/[\x00-\x1f]/g, (ch) => ch === "\n" || ch === "\t" ? ch : "") : v
440
+ , 2);
441
+ console.log(json);
442
+ }
443
+
444
+ function extractFeedPosts(feed) {
445
+ if (!feed) return [];
446
+ const buckets = [
447
+ feed.posts,
448
+ feed.home?.posts,
449
+ feed.data?.posts,
450
+ feed.feed,
451
+ feed.data,
452
+ feed.hot,
453
+ feed.trending,
454
+ ];
455
+ for (const b of buckets) {
456
+ if (Array.isArray(b) && b.length > 0) return b;
457
+ }
458
+ if (Array.isArray(feed)) return feed;
459
+ return [];
460
+ }
461
+
462
+ function hasViveworkerComment(tree) {
463
+ const flat = [];
464
+ const walk = (nodes) => {
465
+ if (!Array.isArray(nodes)) return;
466
+ for (const node of nodes) {
467
+ if (!node) continue;
468
+ flat.push(node);
469
+ walk(node.replies || node.children || []);
470
+ }
471
+ };
472
+ walk(tree?.comments || tree?.data || tree || []);
473
+ return flat.some((c) => {
474
+ const a = c?.author || c?.user || {};
475
+ return String(a.username || a.name || "").toLowerCase() === "viveworker";
476
+ });
477
+ }
478
+
479
+ // ---------------------------------------------------------------------------
480
+ // propose: submit a draft reply to the bridge as a moltbook_draft approval
481
+ // item, long-poll for the phone's decision, and on approve actually post +
482
+ // verify the comment.
483
+ // ---------------------------------------------------------------------------
484
+
485
+ async function cmdPropose(postId, flags) {
486
+ if (!postId) fail("usage: viveworker moltbook propose <postId> --text \"...\"");
487
+ const text = typeof flags.text === "string" ? flags.text : "";
488
+ if (!text.trim()) fail("--text is required and must be non-empty");
489
+ const timeoutSec = Number(flags.timeout) || 900;
490
+ const parentCommentId = typeof flags["parent-id"] === "string" ? flags["parent-id"] : "";
491
+ const postTitle = typeof flags.title === "string" ? flags.title : "";
492
+ const postBody = typeof flags["post-body"] === "string" ? flags["post-body"] : "";
493
+ const postAuthor = typeof flags["post-author"] === "string" ? flags["post-author"] : "";
494
+ const intent = typeof flags.intent === "string" ? flags.intent : "";
495
+ const postUrl = `https://www.moltbook.com/post/${postId}`;
496
+
497
+ const env = await loadMoltbookEnv();
498
+ const base = (env.VIVEWORKER_BASE_URL || "https://127.0.0.1:8810").replace(/\/+$/u, "");
499
+ const secret = env.VIVEWORKER_HOOK_SECRET || "";
500
+ if (!secret) fail("VIVEWORKER_HOOK_SECRET missing (expected in ~/.viveworker/moltbook.env)");
501
+
502
+ const prevTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
503
+ if (!prevTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
504
+
505
+ const sourceId = `draft:${postId}:${Date.now()}`;
506
+
507
+ // 1. Submit draft to bridge.
508
+ let submitRes;
509
+ try {
510
+ const r = await fetch(`${base}/api/providers/moltbook/draft`, {
511
+ method: "POST",
512
+ headers: { "content-type": "application/json", "x-viveworker-hook-secret": secret },
513
+ body: JSON.stringify({
514
+ sourceId,
515
+ postId,
516
+ postTitle,
517
+ postAuthor,
518
+ postBody,
519
+ postUrl,
520
+ parentCommentId,
521
+ intent,
522
+ draftText: text,
523
+ contextSummary: truncate(intent || text, 160),
524
+ }),
525
+ });
526
+ if (!r.ok) {
527
+ const body = await r.text().catch(() => "");
528
+ fail(`bridge /api/providers/moltbook/draft failed: ${r.status} ${body}`);
529
+ }
530
+ submitRes = await r.json();
531
+ } catch (error) {
532
+ fail(`bridge submit failed: ${error.message}`);
533
+ }
534
+ const token = submitRes?.token;
535
+ if (!token) fail(`bridge did not return a token: ${JSON.stringify(submitRes)}`);
536
+
537
+ console.log(`propose: draft submitted (token=${token}); waiting for phone decision (timeout=${timeoutSec}s)`);
538
+
539
+ // 2. Long-poll decision.
540
+ const deadline = Date.now() + timeoutSec * 1000;
541
+ let decision = null;
542
+ while (Date.now() < deadline && !decision) {
543
+ const remain = Math.min(60, Math.ceil((deadline - Date.now()) / 1000));
544
+ try {
545
+ const r = await fetch(
546
+ `${base}/api/providers/moltbook/draft/${encodeURIComponent(token)}/decision?wait=${remain}`,
547
+ {
548
+ method: "GET",
549
+ headers: { "x-viveworker-hook-secret": secret },
550
+ }
551
+ );
552
+ if (r.ok) {
553
+ const body = await r.json();
554
+ if (body && body.status === "decided") decision = body;
555
+ }
556
+ } catch {
557
+ // transient — retry
558
+ }
559
+ }
560
+
561
+ if (!decision) {
562
+ console.log("propose: timed out waiting for phone decision — treating as deny");
563
+ process.exit(2);
564
+ }
565
+
566
+ if (decision.action === "deny") {
567
+ console.log(`propose: denied by phone${decision.reason ? ` (${decision.reason})` : ""}`);
568
+ process.exit(1);
569
+ }
570
+
571
+ // 3. Approve path: use the (possibly edited) text to actually post.
572
+ const finalText = decision.text || text;
573
+ const { mb } = await getClient();
574
+ const result = await mb(`/posts/${postId}/comments`, {
575
+ method: "POST",
576
+ body: JSON.stringify({
577
+ content: finalText,
578
+ ...(parentCommentId ? { parent_id: parentCommentId } : {}),
579
+ }),
580
+ });
581
+ const comment = result?.comment || null;
582
+ const verification = comment?.verification || null;
583
+
584
+ console.log(
585
+ JSON.stringify(
586
+ { ok: true, postId, action: "posted", commentId: comment?.id, verification },
587
+ null,
588
+ 2
589
+ )
590
+ );
591
+
592
+ if (!verification) {
593
+ console.log("propose: no verification challenge returned — done");
594
+ return;
595
+ }
596
+
597
+ // 4. Solve verification puzzle inline (try naive solver, then LLM fallback, retry once on wrong answer).
598
+ let answer = solveVerificationPuzzle(verification.challenge_text);
599
+ const source = answer != null ? "solver" : "skip";
600
+ console.log(`propose: verification puzzle — solver answer: ${answer ?? "(null)"}`);
601
+
602
+ // If solver couldn't parse, try LLM fallback.
603
+ if (answer == null) {
604
+ answer = await solvePuzzleWithLLM(verification.challenge_text);
605
+ if (answer) console.log(`propose: LLM fallback answer: ${answer}`);
606
+ }
607
+
608
+ if (answer == null) {
609
+ console.log("");
610
+ console.log("VERIFICATION REQUIRED (solver + LLM both failed):");
611
+ console.log(` verification_code: ${verification.verification_code}`);
612
+ console.log(` challenge_text: ${verification.challenge_text}`);
613
+ console.log("");
614
+ console.log("Solve manually and run:");
615
+ console.log(` viveworker moltbook verify ${verification.verification_code} <answer>`);
616
+ return;
617
+ }
618
+
619
+ let verifyRes;
620
+ try {
621
+ verifyRes = await mb(`/verify`, {
622
+ method: "POST",
623
+ body: JSON.stringify({ verification_code: verification.verification_code, answer }),
624
+ });
625
+ } catch (verifyError) {
626
+ // Wrong answer — retry with LLM if the first attempt was from solver.
627
+ const isWrongAnswer = /incorrect/i.test(verifyError.message);
628
+ if (isWrongAnswer && source === "solver") {
629
+ console.log(`propose: solver answer ${answer} was wrong, trying LLM fallback`);
630
+ const llmAnswer = await solvePuzzleWithLLM(verification.challenge_text);
631
+ if (llmAnswer && llmAnswer !== answer) {
632
+ console.log(`propose: LLM retry answer: ${llmAnswer}`);
633
+ try {
634
+ verifyRes = await mb(`/verify`, {
635
+ method: "POST",
636
+ body: JSON.stringify({ verification_code: verification.verification_code, answer: llmAnswer }),
637
+ });
638
+ answer = llmAnswer;
639
+ } catch (retryError) {
640
+ console.log(`propose: LLM retry also failed: ${retryError.message}`);
641
+ return;
642
+ }
643
+ } else {
644
+ console.log(`propose: LLM couldn't produce a different answer`);
645
+ return;
646
+ }
647
+ } else {
648
+ console.log(`propose: verify failed: ${verifyError.message}`);
649
+ return;
650
+ }
651
+ }
652
+ console.log(JSON.stringify({ ok: true, verify: verifyRes, answer }, null, 2));
653
+
654
+ // 5. Bump sentToday counter on success.
655
+ const state = rollScoutDayIfNeeded(await readScoutState());
656
+ state.sentToday += 1;
657
+ markPostSeen(state, postId, "published");
658
+ await writeScoutState(state);
659
+
660
+ if (!prevTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevTls ?? "";
661
+ }
662
+
663
+ // LLM-based verification puzzle solver. Shells out to claude or codex CLI.
664
+ // Returns the answer as "XX.XX" string, or null if unavailable.
665
+ async function solvePuzzleWithLLM(challengeText) {
666
+ if (!challengeText) return null;
667
+ const prompt =
668
+ `The following text is an obfuscated arithmetic word problem from Moltbook (an AI social network). ` +
669
+ `The text has random capitalization, doubled letters, and stray punctuation — ignore all of that. ` +
670
+ `Extract the numbers (written as words like "thirty five" = 35), determine the operation ` +
671
+ `(usually addition for "total", "combined", "new velocity"; subtraction for "difference", "minus"; ` +
672
+ `multiplication for "times", "product"), compute the result, and output ONLY the number with ` +
673
+ `exactly 2 decimal places (e.g. "58.00"). No other text.\n\n` +
674
+ `Puzzle: ${challengeText}`;
675
+
676
+ // Try claude first, then codex.
677
+ for (const cmd of ["claude", "codex"]) {
678
+ let bin;
679
+ try {
680
+ bin = await new Promise((resolve) => {
681
+ const p = spawn("command", ["-v", cmd], { shell: "/bin/bash", stdio: ["ignore", "pipe", "ignore"] });
682
+ let out = "";
683
+ p.stdout.on("data", (d) => (out += d.toString()));
684
+ p.on("exit", (code) => resolve(code === 0 ? out.trim() : ""));
685
+ p.on("error", () => resolve(""));
686
+ });
687
+ } catch { bin = ""; }
688
+ if (!bin) continue;
689
+
690
+ const args = cmd === "claude" ? ["-p", prompt, "--output-format", "text"] : ["exec", prompt];
691
+ try {
692
+ const result = await new Promise((resolve, reject) => {
693
+ const p = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"], timeout: 30000 });
694
+ let out = "";
695
+ p.stdout.on("data", (d) => (out += d.toString()));
696
+ p.on("exit", (code) => (code === 0 ? resolve(out.trim()) : reject(new Error(`exit ${code}`))));
697
+ p.on("error", reject);
698
+ });
699
+ // Extract the number from output (LLM might add extra text).
700
+ const match = result.match(/(\d+\.\d{2})/);
701
+ if (match) return match[1];
702
+ // Try integer and append .00
703
+ const intMatch = result.match(/^(\d+)$/m);
704
+ if (intMatch) return `${intMatch[1]}.00`;
705
+ } catch {
706
+ // try next
707
+ }
708
+ }
709
+ return null;
710
+ }
711
+
712
+ // Naive verification-puzzle solver. Handles the obfuscated two-number
713
+ // arithmetic Moltbook currently uses (add / subtract / multiply). Returns
714
+ // `null` if it can't confidently solve — caller falls back to manual.
715
+ function solveVerificationPuzzle(challengeText) {
716
+ if (!challengeText) return null;
717
+ const cleaned = String(challengeText)
718
+ .replace(/[\[\]|\\\/^\-\.~,;:!?'"(){}]/g, " ")
719
+ .toLowerCase();
720
+ const numberWords = {
721
+ zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9,
722
+ ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16,
723
+ seventeen: 17, eighteen: 18, nineteen: 19, twenty: 20, thirty: 30, forty: 40, fifty: 50,
724
+ sixty: 60, seventy: 70, eighty: 80, ninety: 90, hundred: 100,
725
+ };
726
+ // Tokenize loosely by collapsing whitespace. Words sometimes have stray
727
+ // characters (e.g. "tw eLvE") so strip non-letters between word fragments.
728
+ // Moltbook's obfuscator randomly doubles letters ("tWeNnTy" → "twennty"),
729
+ // so try matching both the raw word and a version with collapsed runs.
730
+ // We can't blindly collapse because natural doubles exist ("three" has "ee").
731
+ const collapseRuns = (w) => w.replace(/([a-z])\1+/g, "$1");
732
+ const rawWords = cleaned
733
+ .replace(/[^a-z0-9\s]/g, " ")
734
+ .split(/\s+/)
735
+ .filter(Boolean);
736
+ // For each word, prefer the raw form if it's in the dictionary; otherwise try collapsed.
737
+ const words = rawWords.map((w) => {
738
+ if (/^\d+$/.test(w)) return w;
739
+ if (numberWords[w] != null) return w;
740
+ const collapsed = collapseRuns(w);
741
+ if (numberWords[collapsed] != null) return collapsed;
742
+ return collapsed; // default to collapsed for non-number words (operation keywords etc.)
743
+ });
744
+
745
+ // Reconstruct compound numbers like "twenty three" → 23, "one hundred
746
+ // twenty" → 120.
747
+ const numbers = [];
748
+ let i = 0;
749
+ while (i < words.length) {
750
+ const w = words[i];
751
+ if (/^\d+$/.test(w)) {
752
+ numbers.push(Number(w));
753
+ i += 1;
754
+ continue;
755
+ }
756
+ if (numberWords[w] != null) {
757
+ let total = numberWords[w];
758
+ i += 1;
759
+ while (i < words.length && numberWords[words[i]] != null) {
760
+ const next = numberWords[words[i]];
761
+ if (next === 100) total *= 100;
762
+ else if (next < 100 && total < 100) total += next;
763
+ else break;
764
+ i += 1;
765
+ }
766
+ numbers.push(total);
767
+ continue;
768
+ }
769
+ i += 1;
770
+ }
771
+
772
+ if (numbers.length < 2) return null;
773
+ const a = numbers[0];
774
+ const b = numbers[1];
775
+
776
+ const hasWord = (w) => words.includes(w);
777
+ const hasAny = (...ws) => ws.some(hasWord);
778
+ let result;
779
+ if (hasAny("subtract", "minus", "less", "difference", "decreased", "loses", "lost", "slower")) {
780
+ result = a - b;
781
+ } else if (hasAny("multiply", "times", "product", "multiplied")) {
782
+ result = a * b;
783
+ } else if (hasAny("divide", "divided", "ratio", "per")) {
784
+ result = b !== 0 ? a / b : a;
785
+ } else {
786
+ // Default to addition — the Moltbook puzzles overwhelmingly ask for
787
+ // "total force", "new speed", "combined", "gains", etc.
788
+ result = a + b;
789
+ }
790
+ return result.toFixed(2);
791
+ }
792
+
793
+ async function cmdMarkScoutSeen(postId) {
794
+ if (!postId) fail("usage: viveworker moltbook mark-scout-seen <postId>");
795
+ const state = rollScoutDayIfNeeded(await readScoutState());
796
+ markPostSeen(state, postId, "avoid-skipped");
797
+ await writeScoutState(state);
798
+ console.log(`marked ${postId} as seen (avoid-skipped)`);
799
+ }
800
+
801
+ // ── Batch scoring ────────────────────────────────────────────
802
+ //
803
+ // Instead of drafting the first acceptable candidate, the auto-scout
804
+ // accumulates scored candidates in state.batch over a configurable
805
+ // window (default 30 min). When the window expires the best candidate
806
+ // is picked and drafted.
807
+
808
+ const DEFAULT_BATCH_WINDOW_MS = 30 * 60 * 1000; // 30 minutes
809
+
810
+ async function cmdBatchAdd(flags) {
811
+ const postId = flags._?.[0] || flags.postId || "";
812
+ const score = Number(flags.score) || 0;
813
+ const title = flags.title || "";
814
+ const author = flags.author || "";
815
+ const postUrl = flags.postUrl || flags["post-url"] || "";
816
+ const submolt = flags.submolt || "";
817
+ const windowMs = Number(flags["window-ms"]) || DEFAULT_BATCH_WINDOW_MS;
818
+
819
+ if (!postId) fail("usage: viveworker moltbook batch-add <postId> --score N --title ... --author ...");
820
+
821
+ const state = rollScoutDayIfNeeded(await readScoutState());
822
+
823
+ // Initialize or validate batch window
824
+ const now = Date.now();
825
+ if (!state.batch || (now - state.batch.startedAt) >= windowMs) {
826
+ // Start a new window
827
+ state.batch = { startedAt: now, windowMs, candidates: [] };
828
+ }
829
+
830
+ // Don't add duplicates
831
+ if (state.batch.candidates.some((c) => c.postId === postId)) {
832
+ console.log(JSON.stringify({ status: "duplicate", postId }));
833
+ await writeScoutState(state);
834
+ return;
835
+ }
836
+
837
+ state.batch.candidates.push({ postId, score, title, author, postUrl, submolt, addedAt: now });
838
+ await writeScoutState(state);
839
+
840
+ const elapsed = Math.round((now - state.batch.startedAt) / 1000);
841
+ const remaining = Math.max(0, Math.round((state.batch.startedAt + windowMs - now) / 1000));
842
+ console.log(JSON.stringify({
843
+ status: "added",
844
+ postId,
845
+ score,
846
+ batchSize: state.batch.candidates.length,
847
+ elapsedSeconds: elapsed,
848
+ remainingSeconds: remaining,
849
+ }, null, 2));
850
+ }
851
+
852
+ async function cmdBatchPick(flags) {
853
+ const windowMs = Number(flags["window-ms"]) || DEFAULT_BATCH_WINDOW_MS;
854
+ const state = rollScoutDayIfNeeded(await readScoutState());
855
+
856
+ if (!state.batch || !state.batch.candidates?.length) {
857
+ console.log(JSON.stringify({ status: "empty" }));
858
+ return;
859
+ }
860
+
861
+ const now = Date.now();
862
+ const elapsed = now - state.batch.startedAt;
863
+
864
+ if (elapsed < windowMs) {
865
+ const remaining = Math.max(0, Math.round((state.batch.startedAt + windowMs - now) / 1000));
866
+ console.log(JSON.stringify({
867
+ status: "waiting",
868
+ batchSize: state.batch.candidates.length,
869
+ elapsedSeconds: Math.round(elapsed / 1000),
870
+ remainingSeconds: remaining,
871
+ topScore: Math.max(...state.batch.candidates.map((c) => c.score)),
872
+ }, null, 2));
873
+ return;
874
+ }
875
+
876
+ // Window expired — pick the best candidate
877
+ const sorted = [...state.batch.candidates].sort((a, b) => b.score - a.score);
878
+ const best = sorted[0];
879
+
880
+ // Clear the batch
881
+ state.batch = null;
882
+ await writeScoutState(state);
883
+
884
+ console.log(JSON.stringify({
885
+ status: "picked",
886
+ postId: best.postId,
887
+ score: best.score,
888
+ title: best.title,
889
+ author: best.author,
890
+ postUrl: best.postUrl,
891
+ submolt: best.submolt,
892
+ consideredCount: sorted.length,
893
+ scores: sorted.map((c) => ({ postId: c.postId, score: c.score, title: c.title })),
894
+ }, null, 2));
895
+ }
896
+
897
+ async function cmdBatchStatus() {
898
+ const state = rollScoutDayIfNeeded(await readScoutState());
899
+ if (!state.batch || !state.batch.candidates?.length) {
900
+ console.log(JSON.stringify({ status: "empty" }));
901
+ return;
902
+ }
903
+ const now = Date.now();
904
+ const elapsed = now - state.batch.startedAt;
905
+ const windowMs = state.batch.windowMs || DEFAULT_BATCH_WINDOW_MS;
906
+ const remaining = Math.max(0, Math.round((state.batch.startedAt + windowMs - now) / 1000));
907
+ const sorted = [...state.batch.candidates].sort((a, b) => b.score - a.score);
908
+ console.log(JSON.stringify({
909
+ status: elapsed >= windowMs ? "ready" : "collecting",
910
+ batchSize: state.batch.candidates.length,
911
+ elapsedSeconds: Math.round(elapsed / 1000),
912
+ remainingSeconds: remaining,
913
+ candidates: sorted.map((c) => ({ postId: c.postId, score: c.score, title: c.title, author: c.author })),
914
+ }, null, 2));
915
+ }
916
+
917
+ const PERSONA_FILE = path.join(os.homedir(), ".viveworker", "moltbook-persona.md");
918
+
919
+ async function askPrompt(rl, question) {
920
+ const answer = await rl.question(`${question}\n> `);
921
+ return answer.trim();
922
+ }
923
+
924
+ async function cmdPersonaInit() {
925
+ const rl = createReadline({ input: process.stdin, output: process.stdout });
926
+ console.log("");
927
+ console.log("Moltbook persona setup — answer each question or press Enter to skip.");
928
+ console.log("Results will be saved to:", PERSONA_FILE);
929
+ console.log("");
930
+
931
+ try {
932
+ const name = await askPrompt(rl, "1) Agent name and first-person pronoun (e.g. 'viveworker, i'):");
933
+ const bio = await askPrompt(rl, "2) 1–2 sentence bio — who is this agent?");
934
+ const expertRaw = await askPrompt(
935
+ rl,
936
+ "3) Topics you can speak on with authority (comma or newline separated, 3–5 items):"
937
+ );
938
+ const curiousRaw = await askPrompt(
939
+ rl,
940
+ "4) Topics you're curious about but not an expert in (optional):"
941
+ );
942
+ const avoidRaw = await askPrompt(rl, "5) Topics/styles to avoid (optional, e.g. 'emojis, marketing tone'):");
943
+ const voice = await askPrompt(
944
+ rl,
945
+ "6) Voice rules (default: 'informal lowercase, 2–3 paragraphs, no signature, prefer ending on one concrete question'):"
946
+ );
947
+ const sample = await askPrompt(rl, "7) One sample reply in your ideal voice (optional — paste a short paragraph):");
948
+
949
+ const toList = (raw) =>
950
+ raw
951
+ .split(/[,\n]/)
952
+ .map((s) => s.trim())
953
+ .filter(Boolean)
954
+ .map((s) => `- ${s}`)
955
+ .join("\n");
956
+
957
+ const body = [
958
+ "# moltbook persona",
959
+ "",
960
+ name ? `## who i am\n${name}\n${bio || ""}`.trim() : bio ? `## who i am\n${bio}` : "",
961
+ expertRaw ? `## i can talk substantively about\n${toList(expertRaw)}` : "",
962
+ curiousRaw ? `## i'm curious about but not an expert in\n${toList(curiousRaw)}` : "",
963
+ avoidRaw ? `## avoid\n${toList(avoidRaw)}` : "",
964
+ `## voice\n${
965
+ voice ||
966
+ "informal lowercase, 2–3 paragraphs, no signature, prefer ending on one concrete question or conceded open problem"
967
+ }`,
968
+ sample ? `## sample reply\n${sample}` : "",
969
+ ]
970
+ .filter(Boolean)
971
+ .join("\n\n")
972
+ .trim() + "\n";
973
+
974
+ await fsp.mkdir(path.dirname(PERSONA_FILE), { recursive: true });
975
+ await fsp.writeFile(PERSONA_FILE, body, { mode: 0o600 });
976
+ await fsp.chmod(PERSONA_FILE, 0o600);
977
+ console.log("");
978
+ console.log(`Persona saved: ${PERSONA_FILE}`);
979
+ console.log("It will be inlined into the scout prompt on the next auto-scout run.");
980
+ console.log("Edit later with: viveworker moltbook persona edit");
981
+ } finally {
982
+ rl.close();
983
+ }
984
+ }
985
+
986
+ async function cmdPersonaShow() {
987
+ try {
988
+ const body = await fsp.readFile(PERSONA_FILE, "utf8");
989
+ console.log(body);
990
+ } catch {
991
+ fail(`no persona file at ${PERSONA_FILE}. Run: viveworker moltbook persona init`);
992
+ }
993
+ }
994
+
995
+ async function cmdPersonaEdit() {
996
+ try {
997
+ await fsp.access(PERSONA_FILE);
998
+ } catch {
999
+ await fsp.mkdir(path.dirname(PERSONA_FILE), { recursive: true });
1000
+ await fsp.writeFile(PERSONA_FILE, "# moltbook persona\n\n", { mode: 0o600 });
1001
+ }
1002
+ const editor = process.env.EDITOR || process.env.VISUAL || "vi";
1003
+ await new Promise((resolve, reject) => {
1004
+ const p = spawn(editor, [PERSONA_FILE], { stdio: "inherit" });
1005
+ p.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`${editor} exited ${code}`))));
1006
+ p.on("error", reject);
1007
+ });
1008
+ console.log(`Persona saved: ${PERSONA_FILE}`);
1009
+ }
1010
+
1011
+ // ---------- Compose (original post) ----------
1012
+
1013
+ // Determine the current compose slot based on local hour.
1014
+ function currentComposeSlot() {
1015
+ const hour = new Date().getHours();
1016
+ if (hour >= 9 && hour < 12) return "morning";
1017
+ if (hour >= 12 && hour < 17) return "noon";
1018
+ if (hour >= 17) return "evening";
1019
+ return null; // too early
1020
+ }
1021
+
1022
+ // Yesterday as YYYY-MM-DD (local time).
1023
+ function yesterdayKey() {
1024
+ const d = new Date();
1025
+ d.setDate(d.getDate() - 1);
1026
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
1027
+ }
1028
+
1029
+ async function cmdCompose(flags) {
1030
+ const env = await loadMoltbookEnv();
1031
+ const base = (env.VIVEWORKER_BASE_URL || "https://127.0.0.1:8810").replace(/\/+$/u, "");
1032
+ const secret = env.VIVEWORKER_HOOK_SECRET || "";
1033
+ if (!secret) fail("VIVEWORKER_HOOK_SECRET missing");
1034
+
1035
+ const prevTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
1036
+ if (!prevTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
1037
+
1038
+ const state = rollScoutDayIfNeeded(await readScoutState());
1039
+ const maxCompose = Number(flags["max-daily"]) || 3;
1040
+ if (state.composedToday >= maxCompose) {
1041
+ console.log(JSON.stringify({ status: "quota-reached", composedToday: state.composedToday, maxComposeDaily: maxCompose }));
1042
+ return;
1043
+ }
1044
+
1045
+ // Determine current slot.
1046
+ const slot = currentComposeSlot();
1047
+ if (!slot) {
1048
+ console.log(JSON.stringify({ status: "too-early" }));
1049
+ return;
1050
+ }
1051
+
1052
+ const attempted = Array.isArray(state.composeSlotsAttempted) ? state.composeSlotsAttempted : [];
1053
+ if (attempted.includes(slot)) {
1054
+ console.log(JSON.stringify({ status: "slot-attempted", slot }));
1055
+ return;
1056
+ }
1057
+
1058
+ // Build query params for activity-summary.
1059
+ const params = new URLSearchParams({ slot });
1060
+ if (slot === "morning") params.set("date", yesterdayKey());
1061
+ // For morning, also check that there were no posts yesterday.
1062
+ if (slot === "morning" && state.recentComposeTitles?.length && state.lastComposeDay === yesterdayKey()) {
1063
+ console.log(JSON.stringify({ status: "morning-already-posted-yesterday" }));
1064
+ return;
1065
+ }
1066
+
1067
+ // Fetch activity summary from bridge.
1068
+ let summary;
1069
+ try {
1070
+ const r = await fetch(`${base}/api/providers/moltbook/activity-summary?${params}`, {
1071
+ headers: { "x-viveworker-hook-secret": secret },
1072
+ });
1073
+ if (!r.ok) fail(`activity-summary: ${r.status}`);
1074
+ summary = await r.json();
1075
+ } catch (error) {
1076
+ fail(`activity-summary fetch failed: ${error.message}`);
1077
+ }
1078
+
1079
+ if (!summary.entries || summary.entries.length < 3) {
1080
+ console.log(JSON.stringify({ status: "no-material", slot, activityCount: summary.entries?.length || 0 }));
1081
+ return;
1082
+ }
1083
+
1084
+ // Mark slot as attempted (even before drafting, to avoid re-proposal on deny).
1085
+ state.composeSlotsAttempted = [...attempted, slot];
1086
+ await writeScoutState(state);
1087
+
1088
+ // Load persona.
1089
+ let persona = "";
1090
+ try {
1091
+ persona = await fsp.readFile(PERSONA_FILE, "utf8");
1092
+ } catch { /* no persona */ }
1093
+
1094
+ console.log(JSON.stringify({
1095
+ status: "material",
1096
+ slot,
1097
+ date: summary.date,
1098
+ activityCount: summary.entries.length,
1099
+ activitySummary: summary.entries,
1100
+ persona: persona ? "(loaded)" : "(none)",
1101
+ recentTitles: state.recentComposeTitles || [],
1102
+ composedToday: state.composedToday,
1103
+ maxComposeDaily: maxCompose,
1104
+ }));
1105
+
1106
+ if (!prevTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevTls ?? "";
1107
+ }
1108
+
1109
+ async function cmdComposePropose(flags) {
1110
+ const title = typeof flags.title === "string" ? flags.title : "";
1111
+ const content = typeof flags.content === "string" ? flags.content : "";
1112
+ const submolt = typeof flags.submolt === "string" ? flags.submolt : "general";
1113
+ const intent = typeof flags.intent === "string" ? flags.intent : "";
1114
+ const timeoutSec = Number(flags.timeout) || 900;
1115
+ if (!title.trim()) fail("--title is required");
1116
+ if (!content.trim()) fail("--content is required");
1117
+
1118
+ const env = await loadMoltbookEnv();
1119
+ const base = (env.VIVEWORKER_BASE_URL || "https://127.0.0.1:8810").replace(/\/+$/u, "");
1120
+ const secret = env.VIVEWORKER_HOOK_SECRET || "";
1121
+ if (!secret) fail("VIVEWORKER_HOOK_SECRET missing");
1122
+
1123
+ const prevTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
1124
+ if (!prevTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
1125
+
1126
+ const sourceId = `compose:${Date.now()}`;
1127
+
1128
+ // 1. Submit draft to bridge.
1129
+ let submitRes;
1130
+ try {
1131
+ const r = await fetch(`${base}/api/providers/moltbook/draft`, {
1132
+ method: "POST",
1133
+ headers: { "content-type": "application/json", "x-viveworker-hook-secret": secret },
1134
+ body: JSON.stringify({
1135
+ sourceId,
1136
+ postId: "",
1137
+ postTitle: title,
1138
+ draftText: content,
1139
+ draftType: "original_post",
1140
+ submoltName: submolt,
1141
+ intent,
1142
+ contextSummary: truncate(intent || content, 160),
1143
+ }),
1144
+ });
1145
+ if (!r.ok) {
1146
+ const body = await r.text().catch(() => "");
1147
+ fail(`bridge draft submit failed: ${r.status} ${body}`);
1148
+ }
1149
+ submitRes = await r.json();
1150
+ } catch (error) {
1151
+ fail(`bridge submit failed: ${error.message}`);
1152
+ }
1153
+ const token = submitRes?.token;
1154
+ if (!token) fail(`bridge did not return a token: ${JSON.stringify(submitRes)}`);
1155
+
1156
+ console.log(`compose-propose: draft submitted (token=${token}); waiting for decision (timeout=${timeoutSec}s)`);
1157
+
1158
+ // 2. Long-poll decision.
1159
+ const deadline = Date.now() + timeoutSec * 1000;
1160
+ let decision = null;
1161
+ while (Date.now() < deadline && !decision) {
1162
+ const remain = Math.min(60, Math.ceil((deadline - Date.now()) / 1000));
1163
+ try {
1164
+ const r = await fetch(
1165
+ `${base}/api/providers/moltbook/draft/${encodeURIComponent(token)}/decision?wait=${remain}`,
1166
+ { method: "GET", headers: { "x-viveworker-hook-secret": secret } }
1167
+ );
1168
+ if (r.ok) {
1169
+ const body = await r.json();
1170
+ if (body && body.status === "decided") decision = body;
1171
+ }
1172
+ } catch { /* transient — retry */ }
1173
+ }
1174
+
1175
+ if (!decision) {
1176
+ console.log("compose-propose: timed out — treating as deny");
1177
+ process.exit(2);
1178
+ }
1179
+ if (decision.action === "deny") {
1180
+ console.log("compose-propose: denied by phone");
1181
+ process.exit(1);
1182
+ }
1183
+
1184
+ // 3. Approve path: create original post.
1185
+ const finalTitle = decision.title || title;
1186
+ const finalContent = decision.text || content;
1187
+ const { mb } = await getClient();
1188
+ const result = await mb(`/posts`, {
1189
+ method: "POST",
1190
+ body: JSON.stringify({
1191
+ submolt_name: submolt,
1192
+ submolt,
1193
+ title: finalTitle,
1194
+ content: finalContent,
1195
+ }),
1196
+ });
1197
+ const post = result?.post || null;
1198
+ const verification = post?.verification || null;
1199
+
1200
+ console.log(JSON.stringify({ ok: true, action: "posted", postId: post?.id, verification }, null, 2));
1201
+
1202
+ if (!verification) {
1203
+ console.log("compose-propose: no verification — done");
1204
+ } else {
1205
+ // Solve verification puzzle inline.
1206
+ let answer = solveVerificationPuzzle(verification.challenge_text);
1207
+ if (answer == null) answer = await solvePuzzleWithLLM(verification.challenge_text);
1208
+ if (answer != null) {
1209
+ try {
1210
+ const verifyRes = await mb(`/verify`, {
1211
+ method: "POST",
1212
+ body: JSON.stringify({ verification_code: verification.verification_code, answer }),
1213
+ });
1214
+ console.log(JSON.stringify({ ok: true, verify: verifyRes, answer }, null, 2));
1215
+ } catch (verifyError) {
1216
+ console.log(`compose-propose: verify failed: ${verifyError.message}`);
1217
+ }
1218
+ } else {
1219
+ console.log(`VERIFICATION REQUIRED:\n verification_code: ${verification.verification_code}\n challenge_text: ${verification.challenge_text}`);
1220
+ }
1221
+ }
1222
+
1223
+ // 4. Bump compose counter.
1224
+ const state = rollScoutDayIfNeeded(await readScoutState());
1225
+ recordComposeAttempt(state, finalTitle);
1226
+ await writeScoutState(state);
1227
+
1228
+ if (!prevTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevTls ?? "";
1229
+ }
1230
+
1231
+ export async function runMoltbookCli(argv) {
1232
+ const { positional, flags } = parseFlags(argv);
1233
+ const [cmd, ...rest] = positional;
1234
+ switch (cmd) {
1235
+ case "list":
1236
+ return cmdList(flags);
1237
+ case "show":
1238
+ return cmdShow(rest[0]);
1239
+ case "thread":
1240
+ return cmdThread(rest[0]);
1241
+ case "reply":
1242
+ return cmdReply(rest[0], flags);
1243
+ case "verify":
1244
+ return cmdVerify(rest[0], rest[1]);
1245
+ case "mark-skip":
1246
+ return cmdMarkSkip(rest[0]);
1247
+ case "poll":
1248
+ return cmdPoll();
1249
+ case "reconcile":
1250
+ return cmdReconcile();
1251
+ case "scout":
1252
+ return cmdScout(flags);
1253
+ case "propose":
1254
+ return cmdPropose(rest[0], flags);
1255
+ case "mark-scout-seen":
1256
+ return cmdMarkScoutSeen(rest[0]);
1257
+ case "batch-add":
1258
+ return cmdBatchAdd({ ...flags, _: rest });
1259
+ case "batch-pick":
1260
+ return cmdBatchPick(flags);
1261
+ case "batch-status":
1262
+ return cmdBatchStatus();
1263
+ case "compose":
1264
+ return cmdCompose(flags);
1265
+ case "compose-propose":
1266
+ return cmdComposePropose(flags);
1267
+ case "persona": {
1268
+ const sub = rest[0] || "show";
1269
+ if (sub === "init") return cmdPersonaInit();
1270
+ if (sub === "edit") return cmdPersonaEdit();
1271
+ if (sub === "show") return cmdPersonaShow();
1272
+ fail(`unknown persona subcommand: ${sub} (expected init|edit|show)`);
1273
+ return;
1274
+ }
1275
+ case undefined:
1276
+ case "help":
1277
+ case "--help":
1278
+ case "-h":
1279
+ console.log(`Usage: viveworker moltbook <command>
1280
+
1281
+ Commands:
1282
+ list [--all] Pending items (or all with --all)
1283
+ show <commentId> Full inbox JSON for one comment
1284
+ thread <commentId> Moltbook comment tree for the post
1285
+ reply <commentId> --text "..." Post a reply; print verification challenge
1286
+ verify <verificationCode> <answer> Solve verification puzzle and publish
1287
+ mark-skip <commentId> Mark an item as skipped
1288
+ poll Manual one-shot notification refresh
1289
+ reconcile Mark inbox items already replied to as resolved
1290
+ scout [--max-daily N] [--submolts a,b] [--dry-run]
1291
+ Pick one feed candidate and print context
1292
+ propose <postId> --text "..." [--timeout 900] [--parent-id <commentId>]
1293
+ Submit draft for phone approval, then post on approve
1294
+ compose [--max-daily N] Check activity & return compose material (JSON)
1295
+ compose-propose --title "..." --content "..." [--submolt general] [--timeout 900]
1296
+ Submit original post draft for phone approval, then publish
1297
+ persona init|edit|show Manage ~/.viveworker/moltbook-persona.md (inlined into scout prompt)`);
1298
+ return;
1299
+ default:
1300
+ fail(`unknown command: ${cmd}`);
1301
+ }
1302
+ }