viveworker 0.8.2 → 0.8.3
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 +5 -4
- package/package.json +1 -1
- package/scripts/com.viveworker.moltbook-scout.plist.sample +2 -2
- package/scripts/moltbook-api.mjs +178 -2
- package/scripts/moltbook-cli.mjs +134 -8
- package/scripts/moltbook-scout-auto.sh +100 -10
- package/scripts/moltbook-watcher.mjs +10 -0
- package/scripts/viveworker-bridge.mjs +716 -38
- package/web/app.css +33 -0
- package/web/app.js +78 -18
- package/web/build-id.js +1 -1
- package/web/i18n.js +24 -2
- package/web/remote-pairing/api-router.js +168 -15
package/README.md
CHANGED
|
@@ -386,18 +386,18 @@ Because the Claude hook opens browser windows and returns focus to Claude Deskto
|
|
|
386
386
|
|
|
387
387
|
### What it does
|
|
388
388
|
|
|
389
|
-
- **Incoming
|
|
389
|
+
- **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
390
|
- **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
|
|
391
|
+
- **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
392
|
- **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
393
|
|
|
394
394
|
### How it works
|
|
395
395
|
|
|
396
396
|
1. Define your agent's persona in `~/.viveworker/moltbook-persona.md` — voice, expertise, interests, topics to avoid
|
|
397
397
|
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
|
|
398
|
+
3. The Moltbook watcher saves incoming replies; auto-scout turns pending comments and discovery candidates into draft proposals
|
|
399
399
|
4. On your phone, you can approve, deny, or edit the draft body before sending
|
|
400
|
-
5.
|
|
400
|
+
5. After approval, the bridge posts to Moltbook and solves the verification puzzle automatically
|
|
401
401
|
|
|
402
402
|
### Setup
|
|
403
403
|
|
|
@@ -428,6 +428,7 @@ Open `Settings > Moltbook` in the phone app to see the current auto-scout postin
|
|
|
428
428
|
- `npx viveworker moltbook list` — show pending comment notifications
|
|
429
429
|
- `npx viveworker moltbook poll` — manually refresh Moltbook notifications once
|
|
430
430
|
- `npx viveworker moltbook reconcile` — resolve inbox items that were already replied to elsewhere
|
|
431
|
+
- `npx viveworker moltbook inbox-pick` — manually pick one pending incoming comment for a reply draft
|
|
431
432
|
- `npx viveworker moltbook scout` — manually pick a feed candidate
|
|
432
433
|
- `npx viveworker moltbook propose <postId> --text "..."` — submit a reply draft for phone approval
|
|
433
434
|
- `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.
|
|
3
|
+
"version": "0.8.3",
|
|
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
|
|
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" && 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 <postId> --text \"...\" --timeout
|
|
41
|
+
<string>set -a; . "$HOME/.viveworker/moltbook.env"; set +a; cd "$HOME/path/to/viveworker" && 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 <postId> --text \"...\" --timeout 86400."</string>
|
|
42
42
|
</array>
|
|
43
43
|
|
|
44
44
|
<key>StartInterval</key>
|
package/scripts/moltbook-api.mjs
CHANGED
|
@@ -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
|
package/scripts/moltbook-cli.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
393
|
+
const quota = await getMoltbookReplyQuotaState({ state, maxDaily });
|
|
394
|
+
if (quota.quotaReached) {
|
|
315
395
|
console.log(
|
|
316
396
|
JSON.stringify(
|
|
317
|
-
{
|
|
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
|
|
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
|
|
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:
|
|
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:-
|
|
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
|
-
|
|
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}`,
|