viveworker 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,447 @@
1
+ #!/usr/bin/env bash
2
+ # Automated Moltbook scout with batch scoring window + daily compose.
3
+ #
4
+ # Each 2-min cycle:
5
+ # Step 0: Check if we should compose an original post today (once/day, needs >= 3 activities).
6
+ # Step 1: Check if the batch window has expired. If yes → pick best → draft → propose → exit.
7
+ # Step 2: Find a candidate post; skip if it matches avoid list.
8
+ # Step 3: Score the candidate (0-100) against persona, add to batch.
9
+ #
10
+ # Environment:
11
+ # SCOUT_HARNESS — "claude" or "codex" (default: auto-detect)
12
+ # SCOUT_FLAGS — extra flags for scout (e.g. "--submolts builds,general --max-daily 5")
13
+ # SCOUT_TIMEOUT — propose timeout in seconds (default: 900)
14
+ # SCOUT_WINDOW — batch window in seconds (default: 1800 = 30 min)
15
+ # COMPOSE_MAX — max original posts per day (default: 1)
16
+ set -euo pipefail
17
+
18
+ # Portable timeout wrapper (macOS lacks GNU timeout)
19
+ portable_timeout() {
20
+ local secs="$1"; shift
21
+ perl -e 'alarm shift; exec @ARGV' "$secs" "$@"
22
+ }
23
+
24
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
25
+ NODE="$(command -v node)"
26
+ VIVEWORKER="$SCRIPT_DIR/viveworker.mjs"
27
+ PERSONA_FILE="$HOME/.viveworker/moltbook-persona.md"
28
+ WINDOW_SEC="${SCOUT_WINDOW:-1800}"
29
+
30
+ # Seconds until a given hour (local time). Used for compose slot timeouts.
31
+ seconds_until_hour() {
32
+ local target_hour="$1"
33
+ "$NODE" -e "const n=new Date(),t=new Date(n);t.setHours($target_hour,0,0,0);if(t<=n)t.setDate(t.getDate()+1);console.log(Math.floor((t-n)/1000))"
34
+ }
35
+ WINDOW_MS=$((WINDOW_SEC * 1000))
36
+ MAX_SKIP=5
37
+
38
+ # ── Detect harness ────────────────────────────────────────────
39
+ HARNESS="${SCOUT_HARNESS:-}"
40
+ HARNESS_BIN=""
41
+ if [ -z "$HARNESS" ]; then
42
+ if command -v claude >/dev/null 2>&1; then
43
+ HARNESS="claude"
44
+ elif command -v codex >/dev/null 2>&1; then
45
+ HARNESS="codex"
46
+ fi
47
+ fi
48
+ if [ -n "$HARNESS" ]; then
49
+ HARNESS_BIN=$(command -v "$HARNESS" 2>/dev/null || true)
50
+ fi
51
+
52
+ # ── Load persona & avoid keywords ─────────────────────────────
53
+ PERSONA=""
54
+ AVOID_PATTERN=""
55
+ if [ -f "$PERSONA_FILE" ]; then
56
+ PERSONA=$(cat "$PERSONA_FILE")
57
+ AVOID_PATTERN=$(echo "$PERSONA" | sed -n '/^## avoid/,/^## /{ /^## /d; p; }' \
58
+ | sed 's/^- *//' \
59
+ | sed '/^$/d' \
60
+ | "$NODE" -e "
61
+ let d=''; process.stdin.on('data',c=>d+=c);
62
+ process.stdin.on('end',()=>{
63
+ const lines=d.trim().split('\n').filter(Boolean);
64
+ const kws=[];
65
+ for(const line of lines){
66
+ for(const part of line.split(/[,\/]/)){
67
+ const w=part.trim().toLowerCase()
68
+ .replace(/[''\"]/g,'')
69
+ .replace(/\s+tone$/,'')
70
+ .replace(/\s+threads?$/,'');
71
+ if(w && w.length>2) kws.push(w);
72
+ }
73
+ }
74
+ if(kws.length) console.log(kws.join('|'));
75
+ });
76
+ " 2>/dev/null)
77
+ fi
78
+
79
+ [ -n "$AVOID_PATTERN" ] && echo "[scout-auto] avoid pattern: $AVOID_PATTERN"
80
+
81
+ candidate_should_skip() {
82
+ [ -z "$AVOID_PATTERN" ] && return 1
83
+ printf '%s\n%s' "$1" "$2" | grep -iqE "$AVOID_PATTERN"
84
+ }
85
+
86
+ # Temp file for JSON interchange
87
+ SCOUT_TMP=$(mktemp /tmp/viveworker-scout-XXXXXX.json)
88
+ trap 'rm -f "$SCOUT_TMP"' EXIT
89
+
90
+ read_field() {
91
+ "$NODE" -e "
92
+ const fs=require('fs');
93
+ try{const o=JSON.parse(fs.readFileSync('$SCOUT_TMP','utf8'));console.log(o['$1']||'')}catch{}
94
+ "
95
+ }
96
+
97
+ json_field() {
98
+ 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{}})"
99
+ }
100
+
101
+ # ═══════════════════════════════════════════════════════════════
102
+ # Step 0: Compose an original post (slot-based: morning/noon/evening)
103
+ # - morning (9-12): yesterday's work if not posted yesterday
104
+ # - noon (12-17): morning's work
105
+ # - evening(17+): full day's work
106
+ # Max 3 approved posts/day. Each slot proposed at most once.
107
+ # ═══════════════════════════════════════════════════════════════
108
+ COMPOSE_MAX="${COMPOSE_MAX:-3}"
109
+ COMPOSE_JSON=$("$NODE" "$VIVEWORKER" moltbook compose --max-daily "$COMPOSE_MAX" 2>/dev/null) || true
110
+ COMPOSE_STATUS=$(json_field "$COMPOSE_JSON" "status")
111
+
112
+ if [ "$COMPOSE_STATUS" = "material" ] && [ -n "$HARNESS_BIN" ]; then
113
+ COMPOSE_SLOT=$(json_field "$COMPOSE_JSON" "slot")
114
+ ACTIVITY_COUNT=$(json_field "$COMPOSE_JSON" "activityCount")
115
+ ACTIVITY_SUMMARY=$(echo "$COMPOSE_JSON" | "$NODE" -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const o=JSON.parse(d);const e=o.activitySummary||[];console.log(e.map(x=>'- ['+x.kind+'] '+x.title+(x.summary?' — '+x.summary:'')).join('\n'))}catch{}})")
116
+ RECENT_TITLES=$(echo "$COMPOSE_JSON" | "$NODE" -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const o=JSON.parse(d);console.log((o.recentTitles||[]).join(', '))}catch{}})")
117
+
118
+ echo "[scout-auto] compose ($COMPOSE_SLOT): ${ACTIVITY_COUNT} activities found"
119
+
120
+ # Slot-specific tone instructions.
121
+ case "$COMPOSE_SLOT" in
122
+ morning)
123
+ SLOT_TONE="This is a morning update about yesterday's work. Frame it as a recap: what you accomplished, what you learned, any open questions left over."
124
+ ;;
125
+ noon)
126
+ SLOT_TONE="This is a midday progress update. Share what you've been working on this morning — focus on the most interesting or challenging part so far."
127
+ ;;
128
+ evening)
129
+ SLOT_TONE="This is an end-of-day report. Summarize the full day's work, highlight the key achievement or insight, and mention what's next."
130
+ ;;
131
+ *)
132
+ SLOT_TONE="Share what you've been working on recently."
133
+ ;;
134
+ esac
135
+
136
+ COMPOSE_PROMPT="$(cat <<COMPOSE_EOF
137
+ ${PERSONA:+You are composing an original post on Moltbook (an AI agent social network) on behalf of the agent described below.
138
+
139
+ CRITICAL RULES:
140
+ 1. Follow this persona EXACTLY — use the same voice, tone, and interests described below.
141
+ 2. Only write about activities that overlap with the persona expertise ("i can talk substantively about"). Skip unrelated work entirely.
142
+ 3. Respect the "avoid" list — do not mention any avoided topics even if they appear in the activity log.
143
+ 4. The post must sound like the persona wrote it naturally, not like a generated summary.
144
+
145
+ -----8<----- persona -----8<-----
146
+ $PERSONA
147
+ -----8<----- end persona -----8<-----
148
+
149
+ }$SLOT_TONE
150
+
151
+ Work activity:
152
+ $ACTIVITY_SUMMARY
153
+
154
+ ${RECENT_TITLES:+Recent post titles (avoid repeating similar topics): $RECENT_TITLES
155
+
156
+ }Available submolts: general, builds, tooling, agents, infrastructure
157
+
158
+ Instructions:
159
+ - Filter the activity list through the persona lens — only include work that the persona would genuinely find interesting or worth sharing.
160
+ - If none of the activities match the persona expertise, output only: NO_MATCH
161
+ - Write in the persona authentic voice. Do not summarize mechanically.
162
+ - 2-4 paragraphs. End with a question or open thought to invite discussion.
163
+
164
+ Output format (no markdown fences, no extra text):
165
+
166
+ SUBMOLT: (one of the available submolts)
167
+ TITLE: (concise title, max 300 chars)
168
+ INTENT: (1-2 sentences in Japanese: why this is worth posting from this persona perspective)
169
+ ---
170
+ (post body in persona voice)
171
+ COMPOSE_EOF
172
+ )"
173
+
174
+ COMPOSE_DRAFT=""
175
+ if [ "$HARNESS" = "claude" ]; then
176
+ COMPOSE_DRAFT=$(portable_timeout 60 "$HARNESS_BIN" -p "$COMPOSE_PROMPT" --output-format text 2>/dev/null) || true
177
+ elif [ "$HARNESS" = "codex" ]; then
178
+ COMPOSE_DRAFT=$(portable_timeout 60 "$HARNESS_BIN" exec "$COMPOSE_PROMPT" 2>/dev/null) || true
179
+ fi
180
+
181
+ if [ -n "$COMPOSE_DRAFT" ]; then
182
+ # Check for NO_MATCH signal (persona doesn't align with today's work).
183
+ if echo "$COMPOSE_DRAFT" | grep -q "NO_MATCH"; then
184
+ echo "[scout-auto] compose ($COMPOSE_SLOT): persona has no match with current activities — skipping"
185
+ else
186
+ # Parse SUBMOLT, TITLE, INTENT, and body.
187
+ C_SUBMOLT=$(echo "$COMPOSE_DRAFT" | sed -n 's/^SUBMOLT: *//p' | head -1)
188
+ C_TITLE=$(echo "$COMPOSE_DRAFT" | sed -n 's/^TITLE: *//p' | head -1)
189
+ C_INTENT=""
190
+ C_BODY=""
191
+ if echo "$COMPOSE_DRAFT" | grep -q "^---$"; then
192
+ C_INTENT=$(echo "$COMPOSE_DRAFT" | sed -n 's/^INTENT: *//p' | head -1)
193
+ C_BODY=$(echo "$COMPOSE_DRAFT" | sed '1,/^---$/d')
194
+ else
195
+ C_BODY=$(echo "$COMPOSE_DRAFT" | sed '/^SUBMOLT:/d;/^TITLE:/d;/^INTENT:/d')
196
+ fi
197
+ C_SUBMOLT=${C_SUBMOLT:-general}
198
+ C_TITLE=$(echo "$C_TITLE" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
199
+ C_INTENT=$(echo "$C_INTENT" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
200
+ C_BODY=$(echo "$C_BODY" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
201
+
202
+ if [ -n "$C_TITLE" ] && [ -n "$C_BODY" ]; then
203
+ # Timeout = seconds until current slot ends.
204
+ case "$COMPOSE_SLOT" in
205
+ morning) COMPOSE_TIMEOUT=$(seconds_until_hour 12) ;;
206
+ noon) COMPOSE_TIMEOUT=$(seconds_until_hour 17) ;;
207
+ evening) COMPOSE_TIMEOUT=$(seconds_until_hour 24) ;;
208
+ *) COMPOSE_TIMEOUT=1800 ;;
209
+ esac
210
+ echo "[scout-auto] compose ($COMPOSE_SLOT): title='$C_TITLE' submolt=$C_SUBMOLT (${#C_BODY} chars, timeout=${COMPOSE_TIMEOUT}s)"
211
+
212
+ "$NODE" "$VIVEWORKER" moltbook compose-propose \
213
+ --title "$C_TITLE" \
214
+ --content "$C_BODY" \
215
+ --submolt "$C_SUBMOLT" \
216
+ --intent "${C_INTENT:-auto-compose: $COMPOSE_SLOT update}" \
217
+ --slot "$COMPOSE_SLOT" \
218
+ --timeout "$COMPOSE_TIMEOUT"
219
+
220
+ exit 0
221
+ else
222
+ echo "[scout-auto] compose ($COMPOSE_SLOT): draft parsing failed (title='$C_TITLE', body=${#C_BODY} chars)"
223
+ fi
224
+ fi
225
+ else
226
+ echo "[scout-auto] compose ($COMPOSE_SLOT): harness returned empty draft"
227
+ fi
228
+ fi
229
+
230
+ # ═══════════════════════════════════════════════════════════════
231
+ # Step 1: Check if the batch window has expired → pick & draft
232
+ # ═══════════════════════════════════════════════════════════════
233
+ PICK_JSON=$("$NODE" "$VIVEWORKER" moltbook batch-pick --window-ms "$WINDOW_MS" 2>/dev/null) || true
234
+ PICK_STATUS=$(json_field "$PICK_JSON" "status")
235
+
236
+ if [ "$PICK_STATUS" = "picked" ]; then
237
+ BEST_POST_ID=$(json_field "$PICK_JSON" "postId")
238
+ BEST_TITLE=$(json_field "$PICK_JSON" "title")
239
+ BEST_AUTHOR=$(json_field "$PICK_JSON" "author")
240
+ BEST_SCORE=$(json_field "$PICK_JSON" "score")
241
+ CONSIDERED=$(json_field "$PICK_JSON" "consideredCount")
242
+
243
+ echo "[scout-auto] batch complete: picked '$BEST_TITLE' by @$BEST_AUTHOR (score=$BEST_SCORE, considered=$CONSIDERED)"
244
+
245
+ # Fetch post content from Moltbook API
246
+ POST_DATA=$("$NODE" --input-type=module -e "
247
+ import { readFileSync } from 'fs';
248
+ const env = readFileSync(process.env.HOME + '/.viveworker/moltbook.env', 'utf8');
249
+ for(const line of env.split('\n')){
250
+ const m = line.match(/^(\w+)=(.*)\$/);
251
+ if(m) process.env[m[1]] = m[2];
252
+ }
253
+ const { createMoltbookClient } = await import('./scripts/moltbook-api.mjs');
254
+ const { client: mb } = await createMoltbookClient();
255
+ try {
256
+ const post = await mb('/posts/${BEST_POST_ID}');
257
+ const p = post?.post || post;
258
+ console.log(JSON.stringify({ content: p?.content || p?.body || '' }));
259
+ } catch(e) {
260
+ console.log(JSON.stringify({ content: '' }));
261
+ }
262
+ " 2>/dev/null) || echo '{"content":""}'
263
+ POST_CONTENT=$(json_field "$POST_DATA" "content")
264
+ POST_URL="https://www.moltbook.com/post/$BEST_POST_ID"
265
+
266
+ # ── Draft via LLM ────────────────────────────────────────
267
+ if [ -z "$HARNESS_BIN" ]; then
268
+ echo "[scout-auto] error: neither 'claude' nor 'codex' CLI found"
269
+ exit 1
270
+ fi
271
+
272
+ DRAFT_PROMPT="$(cat <<PROMPT_EOF
273
+ ${PERSONA:+You are drafting a reply on behalf of the agent described below. Follow this persona precisely.
274
+ -----8<----- persona -----8<-----
275
+ $PERSONA
276
+ -----8<----- end persona -----8<-----
277
+
278
+ }You are replying to the following post on Moltbook (an AI agent social network).
279
+
280
+ Title: $BEST_TITLE
281
+ Author: @$BEST_AUTHOR
282
+ URL: $POST_URL
283
+
284
+ Post body:
285
+ $POST_CONTENT
286
+
287
+ Draft a reply in the persona voice (or, if no persona is set, informal lowercase, 2-3 paragraphs, technically substantive, no signature). Prefer ending on one concrete question or a conceded open problem.
288
+
289
+ Output in this exact format — no markdown fences, no extra text:
290
+
291
+ INTENT: (1-2 sentences in Japanese: why this post caught your attention and what angle your reply takes — this is shown to the human operator for approval context)
292
+ ---
293
+ (your reply text here)
294
+ PROMPT_EOF
295
+ )"
296
+
297
+ echo "[scout-auto] drafting via $HARNESS for '$BEST_TITLE'"
298
+ DRAFT_TEXT=""
299
+ if [ "$HARNESS" = "claude" ]; then
300
+ DRAFT_TEXT=$("$HARNESS_BIN" -p "$DRAFT_PROMPT" --output-format text) || true
301
+ elif [ "$HARNESS" = "codex" ]; then
302
+ DRAFT_TEXT=$("$HARNESS_BIN" exec "$DRAFT_PROMPT") || true
303
+ fi
304
+
305
+ if [ -z "$DRAFT_TEXT" ]; then
306
+ echo "[scout-auto] error: harness returned empty draft"
307
+ exit 1
308
+ fi
309
+
310
+ # Parse INTENT and reply body
311
+ INTENT=""
312
+ REPLY_BODY=""
313
+ if echo "$DRAFT_TEXT" | grep -q "^---$"; then
314
+ INTENT=$(echo "$DRAFT_TEXT" | sed -n '1,/^---$/p' | sed '/^---$/d' | sed 's/^INTENT: *//')
315
+ REPLY_BODY=$(echo "$DRAFT_TEXT" | sed '1,/^---$/d')
316
+ else
317
+ REPLY_BODY="$DRAFT_TEXT"
318
+ INTENT="auto-scout: responding to @${BEST_AUTHOR}'s post (score=$BEST_SCORE)"
319
+ fi
320
+ INTENT=$(echo "$INTENT" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
321
+ REPLY_BODY=$(echo "$REPLY_BODY" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
322
+
323
+ if [ -z "$REPLY_BODY" ]; then
324
+ echo "[scout-auto] error: parsed draft body is empty"
325
+ exit 1
326
+ fi
327
+
328
+ echo "[scout-auto] intent: $INTENT"
329
+ echo "[scout-auto] draft ready (${#REPLY_BODY} chars), submitting propose"
330
+
331
+ # ── Propose ──────────────────────────────────────────────
332
+ "$NODE" "$VIVEWORKER" moltbook propose "$BEST_POST_ID" \
333
+ --title "$BEST_TITLE" \
334
+ --post-author "$BEST_AUTHOR" \
335
+ --post-body "$POST_CONTENT" \
336
+ --intent "$INTENT" \
337
+ --text "$REPLY_BODY" \
338
+ --timeout "$WINDOW_SEC"
339
+
340
+ exit 0
341
+ fi
342
+
343
+ # ═══════════════════════════════════════════════════════════════
344
+ # Step 2 & 3: Find a candidate, score, add to batch
345
+ # ═══════════════════════════════════════════════════════════════
346
+ echo "[scout-auto] collecting candidates (window: ${WINDOW_SEC}s)"
347
+ SKIP_COUNT=0
348
+
349
+ while [ $SKIP_COUNT -lt $MAX_SKIP ]; do
350
+ "$NODE" "$VIVEWORKER" moltbook scout ${SCOUT_FLAGS:-} > "$SCOUT_TMP" 2>/dev/null || true
351
+
352
+ STATUS=$("$NODE" -e "
353
+ const fs=require('fs');
354
+ try{console.log(JSON.parse(fs.readFileSync('$SCOUT_TMP','utf8')).status)}catch{console.log('error')}
355
+ ")
356
+
357
+ if [ "$STATUS" = "already-engaged" ]; then
358
+ SKIP_COUNT=$((SKIP_COUNT + 1))
359
+ echo "[scout-auto] already engaged, retrying [$SKIP_COUNT/$MAX_SKIP]"
360
+ continue
361
+ fi
362
+
363
+ if [ "$STATUS" != "candidate" ]; then
364
+ echo "[scout-auto] no candidate (status=$STATUS)"
365
+ exit 0
366
+ fi
367
+
368
+ POST_ID=$(read_field postId)
369
+ POST_TITLE=$(read_field title)
370
+ POST_AUTHOR=$(read_field author)
371
+ POST_CONTENT=$(read_field content)
372
+ POST_URL=$(read_field postUrl)
373
+ POST_SUBMOLT=$(read_field submolt)
374
+
375
+ # Keyword filter
376
+ if candidate_should_skip "$POST_TITLE" "$POST_CONTENT"; then
377
+ SKIP_COUNT=$((SKIP_COUNT + 1))
378
+ echo "[scout-auto] skipping (avoid keyword): $POST_TITLE (by @$POST_AUTHOR) [$SKIP_COUNT/$MAX_SKIP]"
379
+ "$NODE" "$VIVEWORKER" moltbook mark-scout-seen "$POST_ID" 2>/dev/null || true
380
+ continue
381
+ fi
382
+
383
+ # LLM scoring (0-100)
384
+ SCORE=0
385
+ if [ -n "$PERSONA" ] && [ -n "$HARNESS_BIN" ]; then
386
+ SCORE_PROMPT="You are scoring Moltbook posts for an AI agent. Rate how well this post matches the agent's expertise and interests on a scale of 0-100.
387
+
388
+ Score 0: post matches the avoid list, or is completely outside the agent's domain.
389
+ Score 1-30: tangentially related but the agent has little to add.
390
+ Score 31-60: relevant topic, the agent could contribute meaningfully.
391
+ Score 61-80: strong match with the agent's core expertise.
392
+ Score 81-100: excellent match — the agent has direct experience and a unique angle.
393
+
394
+ Persona:
395
+ $PERSONA
396
+
397
+ Post title: $POST_TITLE
398
+ Post author: @$POST_AUTHOR
399
+ Post body (first 500 chars): ${POST_CONTENT:0:500}
400
+
401
+ Reply with ONLY a number from 0 to 100. Nothing else."
402
+
403
+ SCORE_RAW=""
404
+ if [ "$HARNESS" = "claude" ]; then
405
+ SCORE_RAW=$(portable_timeout 30 "$HARNESS_BIN" -p "$SCORE_PROMPT" --output-format text 2>/dev/null) || true
406
+ elif [ "$HARNESS" = "codex" ]; then
407
+ SCORE_RAW=$(portable_timeout 30 "$HARNESS_BIN" exec "$SCORE_PROMPT" 2>/dev/null) || true
408
+ fi
409
+
410
+ SCORE=$(echo "$SCORE_RAW" | tr -dc '0-9\n' | head -1)
411
+ SCORE=${SCORE:-0}
412
+
413
+ if [ "$SCORE" -eq 0 ] 2>/dev/null; then
414
+ SKIP_COUNT=$((SKIP_COUNT + 1))
415
+ echo "[scout-auto] skipping (score=0): $POST_TITLE (by @$POST_AUTHOR) [$SKIP_COUNT/$MAX_SKIP]"
416
+ "$NODE" "$VIVEWORKER" moltbook mark-scout-seen "$POST_ID" 2>/dev/null || true
417
+ continue
418
+ fi
419
+
420
+ if [ -z "$SCORE_RAW" ]; then
421
+ SKIP_COUNT=$((SKIP_COUNT + 1))
422
+ echo "[scout-auto] skipping (score failed): $POST_TITLE (by @$POST_AUTHOR) [$SKIP_COUNT/$MAX_SKIP]"
423
+ continue
424
+ fi
425
+ else
426
+ SCORE=50
427
+ fi
428
+
429
+ echo "[scout-auto] scored $SCORE: $POST_TITLE (by @$POST_AUTHOR)"
430
+
431
+ # Add to batch
432
+ "$NODE" "$VIVEWORKER" moltbook batch-add "$POST_ID" \
433
+ --score "$SCORE" \
434
+ --title "$POST_TITLE" \
435
+ --author "$POST_AUTHOR" \
436
+ --post-url "$POST_URL" \
437
+ --submolt "$POST_SUBMOLT" \
438
+ --window-ms "$WINDOW_MS" 2>/dev/null || true
439
+
440
+ break
441
+ done
442
+
443
+ if [ $SKIP_COUNT -ge $MAX_SKIP ]; then
444
+ echo "[scout-auto] all $MAX_SKIP candidates skipped this cycle"
445
+ fi
446
+
447
+ echo "[scout-auto] cycle done — waiting for next invocation"