viveworker 0.3.0 → 0.4.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,394 @@
1
+ /**
2
+ * a2a-relay-client.mjs — Bridge-side polling client for the viveworker A2A relay.
3
+ *
4
+ * Connects the local viveworker bridge to the Cloudflare Worker relay:
5
+ * - Registers the bridge with the relay on first startup
6
+ * - Polls the relay every 20 seconds for pending A2A tasks
7
+ * - Posts task results (completion/failure/rejection) back to the relay
8
+ *
9
+ * Designed to be imported and used from viveworker-bridge.mjs.
10
+ */
11
+
12
+ import crypto from "node:crypto";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { promises as fs, readFileSync } from "node:fs";
16
+ import { upsertEnvText } from "./lib/pairing.mjs";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Constants
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const POLL_INTERVAL_MS = 20_000; // 20 seconds
23
+ const REQUEST_TIMEOUT_MS = 15_000; // 15 seconds per HTTP request
24
+ const A2A_ENV_FILE = path.join(os.homedir(), ".viveworker", "a2a.env");
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Module state
28
+ // ---------------------------------------------------------------------------
29
+
30
+ let pollTimer = null;
31
+ let isPolling = false;
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Registration
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Register this bridge with the A2A relay. On first registration, generates
39
+ * a bridgeSecret and writes it back to ~/.viveworker/a2a.env.
40
+ *
41
+ * @param {object} params
42
+ * @param {object} params.config - Bridge config (must have a2aRelayUrl, a2aRelayUserId, a2aApiKey)
43
+ * @param {Function} params.buildAgentCard - Function to build the local agent card
44
+ * @returns {Promise<{ ok: boolean, relayUrl?: string, error?: string }>}
45
+ */
46
+ export async function registerWithRelay({ config, buildAgentCard }) {
47
+ const relayUrl = config.a2aRelayUrl;
48
+ const userId = config.a2aRelayUserId;
49
+
50
+ if (!relayUrl || !userId) {
51
+ return { ok: false, error: "A2A_RELAY_URL or A2A_RELAY_USER_ID not configured" };
52
+ }
53
+
54
+ // Generate or reuse bridgeSecret
55
+ let bridgeSecret = config.a2aRelaySecret || "";
56
+ const isNewSecret = !bridgeSecret;
57
+ if (isNewSecret) {
58
+ bridgeSecret = crypto.randomBytes(32).toString("hex");
59
+ }
60
+
61
+ const agentCard = typeof buildAgentCard === "function"
62
+ ? buildAgentCard(config)
63
+ : {};
64
+
65
+ const body = {
66
+ userId,
67
+ bridgeSecret,
68
+ a2aApiKey: config.a2aApiKey || "",
69
+ agentCard,
70
+ ...(config.a2aRelayRegisterSecret ? { registerSecret: config.a2aRelayRegisterSecret } : {}),
71
+ };
72
+
73
+ const headers = {
74
+ "content-type": "application/json",
75
+ };
76
+ // Re-registration requires the current bridgeSecret
77
+ if (!isNewSecret) {
78
+ headers["authorization"] = `Bearer ${bridgeSecret}`;
79
+ }
80
+
81
+ try {
82
+ const res = await fetchWithTimeout(`${relayUrl}/internal/register`, {
83
+ method: "POST",
84
+ headers,
85
+ body: JSON.stringify(body),
86
+ });
87
+
88
+ const data = await res.json();
89
+
90
+ if (!res.ok) {
91
+ return { ok: false, error: data.error || `HTTP ${res.status}` };
92
+ }
93
+
94
+ // On success, write bridgeSecret back to a2a.env if new
95
+ if (isNewSecret) {
96
+ await persistRelaySecret(bridgeSecret);
97
+ // Update config in-place so subsequent calls use the secret
98
+ config.a2aRelaySecret = bridgeSecret;
99
+ }
100
+
101
+ console.log(`[a2a-relay] Registered as ${userId} → ${data.relayUrl || relayUrl}`);
102
+ return { ok: true, relayUrl: data.relayUrl };
103
+ } catch (err) {
104
+ return { ok: false, error: err.message };
105
+ }
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Polling
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Start polling the relay for pending A2A tasks. Tasks are ingested into
114
+ * the local runtime just like locally-received A2A tasks.
115
+ *
116
+ * @param {object} params
117
+ * @param {object} params.config - Bridge config
118
+ * @param {object} params.runtime - Bridge runtime state
119
+ * @param {object} params.state - Persistent state
120
+ * @param {object} params.helpers - { recordTimelineEntry, deliverWebPushItem, saveState, historyToken, cleanText }
121
+ */
122
+ export function startRelayPolling({ config, runtime, state, helpers }) {
123
+ if (pollTimer) {
124
+ console.warn("[a2a-relay] Polling already started, skipping duplicate start");
125
+ return;
126
+ }
127
+
128
+ const relayUrl = config.a2aRelayUrl;
129
+ const userId = config.a2aRelayUserId;
130
+ const secret = config.a2aRelaySecret;
131
+
132
+ if (!relayUrl || !userId || !secret) {
133
+ console.warn("[a2a-relay] Cannot start polling — missing relay config");
134
+ return;
135
+ }
136
+
137
+ console.log(`[a2a-relay] Starting poll loop (every ${POLL_INTERVAL_MS / 1000}s) for user ${userId}`);
138
+
139
+ // Do an initial poll immediately, then schedule interval
140
+ pollOnce({ config, runtime, state, helpers }).catch((err) => {
141
+ console.error(`[a2a-relay] Initial poll error: ${err.message}`);
142
+ });
143
+
144
+ pollTimer = setInterval(() => {
145
+ if (isPolling || runtime.stopping) return;
146
+ pollOnce({ config, runtime, state, helpers }).catch((err) => {
147
+ console.error(`[a2a-relay] Poll error: ${err.message}`);
148
+ });
149
+ }, POLL_INTERVAL_MS);
150
+
151
+ // Allow Node to exit even if timer is running
152
+ if (pollTimer.unref) pollTimer.unref();
153
+ }
154
+
155
+ /**
156
+ * Stop relay polling. Call on bridge shutdown.
157
+ */
158
+ export function stopRelayPolling() {
159
+ if (pollTimer) {
160
+ clearInterval(pollTimer);
161
+ pollTimer = null;
162
+ }
163
+ isPolling = false;
164
+ console.log("[a2a-relay] Polling stopped");
165
+ }
166
+
167
+ /**
168
+ * Single poll cycle: fetch pending tasks from the relay and ingest them.
169
+ */
170
+ async function pollOnce({ config, runtime, state, helpers }) {
171
+ if (runtime.stopping) return;
172
+ isPolling = true;
173
+
174
+ try {
175
+ const relayUrl = config.a2aRelayUrl;
176
+ const userId = config.a2aRelayUserId;
177
+ const secret = config.a2aRelaySecret;
178
+
179
+ const res = await fetchWithTimeout(`${relayUrl}/internal/poll/${userId}`, {
180
+ method: "GET",
181
+ headers: {
182
+ authorization: `Bearer ${secret}`,
183
+ },
184
+ });
185
+
186
+ if (!res.ok) {
187
+ const text = await res.text().catch(() => "");
188
+ console.error(`[a2a-relay] Poll failed: HTTP ${res.status} ${text.slice(0, 200)}`);
189
+ return;
190
+ }
191
+
192
+ const data = await res.json();
193
+ const tasks = data.tasks || [];
194
+
195
+ if (tasks.length === 0) return;
196
+
197
+ console.log(`[a2a-relay] Received ${tasks.length} task(s) from relay`);
198
+
199
+ for (const relayTask of tasks) {
200
+ await ingestRelayTask({ relayTask, config, runtime, state, helpers });
201
+ }
202
+ } finally {
203
+ isPolling = false;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Ingest a single relay task into local runtime. Creates the same structure
209
+ * as a locally-received A2A task, tagged with relayTaskId for result posting.
210
+ */
211
+ async function ingestRelayTask({ relayTask, config, runtime, state, helpers }) {
212
+ const { recordTimelineEntry, deliverWebPushItem, saveState, historyToken, cleanText } = helpers;
213
+
214
+ // Skip if we already have this task (deduplicate)
215
+ for (const existing of runtime.a2aTasksByToken.values()) {
216
+ if (existing.relayTaskId === relayTask.id) {
217
+ return; // already ingested
218
+ }
219
+ }
220
+
221
+ const token = historyToken(`a2a_task:${relayTask.id}`);
222
+ const instruction = cleanText(relayTask.instruction || "");
223
+ const now = Date.now();
224
+
225
+ const task = {
226
+ id: relayTask.id,
227
+ token,
228
+ contextId: relayTask.contextId || crypto.randomUUID(),
229
+ status: "submitted",
230
+ statusMessage: "",
231
+ messages: relayTask.messages || [],
232
+ artifacts: [],
233
+ instruction,
234
+ callerInfo: relayTask.callerInfo || {},
235
+ createdAtMs: relayTask.createdAtMs || now,
236
+ updatedAtMs: now,
237
+ decisionWaiters: [],
238
+ decision: null,
239
+ // Mark as relay-originated so we know to post results back
240
+ relayTaskId: relayTask.id,
241
+ relayUserId: config.a2aRelayUserId,
242
+ };
243
+
244
+ runtime.a2aTasksByToken.set(token, task);
245
+
246
+ // Record timeline entry (same as local A2A handler)
247
+ try {
248
+ recordTimelineEntry({
249
+ config,
250
+ runtime,
251
+ state,
252
+ entry: {
253
+ stableId: `a2a_task:${relayTask.id}`,
254
+ token,
255
+ kind: "a2a_task",
256
+ threadId: "a2a",
257
+ threadLabel: "A2A",
258
+ title: `A2A: ${instruction.slice(0, 80)}`,
259
+ summary: instruction.slice(0, 160),
260
+ messageText: instruction,
261
+ createdAtMs: relayTask.createdAtMs || now,
262
+ readOnly: false,
263
+ provider: "a2a",
264
+ },
265
+ });
266
+ await saveState(config.stateFile, state);
267
+ } catch (err) {
268
+ console.error(`[a2a-relay] Timeline save error: ${err.message}`);
269
+ }
270
+
271
+ // Send Web Push notification
272
+ try {
273
+ await deliverWebPushItem({
274
+ config,
275
+ state,
276
+ kind: "a2a_task",
277
+ token,
278
+ stableId: `a2a_task:${relayTask.id}`,
279
+ title: "A2A Task Request",
280
+ body: instruction.slice(0, 160),
281
+ buildLocalizedContent: ({ locale }) => {
282
+ const lang = locale?.startsWith("ja") ? "ja" : "en";
283
+ return {
284
+ title: lang === "ja" ? "\u{1F91D} A2Aリレー経由のタスク依頼" : "\u{1F91D} A2A task via relay",
285
+ body: instruction.slice(0, 160),
286
+ };
287
+ },
288
+ });
289
+ } catch (err) {
290
+ console.error(`[a2a-relay] Push notification error: ${err.message}`);
291
+ }
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Result posting
296
+ // ---------------------------------------------------------------------------
297
+
298
+ /**
299
+ * Post a task result back to the relay. Called after task completion,
300
+ * failure, or rejection.
301
+ *
302
+ * @param {object} params
303
+ * @param {object} params.config - Bridge config (a2aRelayUrl, a2aRelayUserId, a2aRelaySecret)
304
+ * @param {object} params.task - A2A task object with relayTaskId
305
+ * @returns {Promise<{ ok: boolean, error?: string }>}
306
+ */
307
+ export async function postRelayResult({ config, task }) {
308
+ if (!task.relayTaskId) {
309
+ return { ok: false, error: "Not a relay task" };
310
+ }
311
+
312
+ const relayUrl = config.a2aRelayUrl;
313
+ const userId = task.relayUserId || config.a2aRelayUserId;
314
+ const secret = config.a2aRelaySecret;
315
+
316
+ if (!relayUrl || !userId || !secret) {
317
+ return { ok: false, error: "Relay not configured" };
318
+ }
319
+
320
+ const body = {
321
+ status: task.status,
322
+ statusMessage: task.statusMessage || "",
323
+ updatedAtMs: task.updatedAtMs || Date.now(),
324
+ artifacts: task.artifacts || [],
325
+ };
326
+
327
+ try {
328
+ const res = await fetchWithTimeout(
329
+ `${relayUrl}/internal/result/${userId}/${task.relayTaskId}`,
330
+ {
331
+ method: "POST",
332
+ headers: {
333
+ "content-type": "application/json",
334
+ authorization: `Bearer ${secret}`,
335
+ },
336
+ body: JSON.stringify(body),
337
+ }
338
+ );
339
+
340
+ const data = await res.json();
341
+
342
+ if (!res.ok) {
343
+ console.error(`[a2a-relay] Result post failed: HTTP ${res.status} ${data.error || ""}`);
344
+ return { ok: false, error: data.error || `HTTP ${res.status}` };
345
+ }
346
+
347
+ console.log(`[a2a-relay] Result posted for task ${task.relayTaskId} → ${data.status}`);
348
+ return { ok: true };
349
+ } catch (err) {
350
+ console.error(`[a2a-relay] Result post error: ${err.message}`);
351
+ return { ok: false, error: err.message };
352
+ }
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // Helpers
357
+ // ---------------------------------------------------------------------------
358
+
359
+ /**
360
+ * Write A2A_RELAY_SECRET back to ~/.viveworker/a2a.env.
361
+ */
362
+ async function persistRelaySecret(secret) {
363
+ try {
364
+ let current = "";
365
+ try {
366
+ current = await fs.readFile(A2A_ENV_FILE, "utf8");
367
+ } catch {
368
+ // File may not exist yet
369
+ }
370
+
371
+ const updated = upsertEnvText(current, {
372
+ A2A_RELAY_SECRET: secret,
373
+ });
374
+
375
+ await fs.mkdir(path.dirname(A2A_ENV_FILE), { recursive: true, mode: 0o700 });
376
+ await fs.writeFile(A2A_ENV_FILE, updated, { mode: 0o600 });
377
+ console.log(`[a2a-relay] Wrote A2A_RELAY_SECRET to ${A2A_ENV_FILE}`);
378
+ } catch (err) {
379
+ console.error(`[a2a-relay] Failed to write relay secret: ${err.message}`);
380
+ }
381
+ }
382
+
383
+ /**
384
+ * fetch() with a timeout via AbortSignal.
385
+ */
386
+ async function fetchWithTimeout(url, options = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
387
+ const controller = new AbortController();
388
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
389
+ try {
390
+ return await fetch(url, { ...options, signal: controller.signal });
391
+ } finally {
392
+ clearTimeout(timer);
393
+ }
394
+ }
@@ -142,10 +142,13 @@ export function defaultScoutState() {
142
142
  }
143
143
 
144
144
  export function todayKey() {
145
- const d = new Date();
146
- const y = d.getUTCFullYear();
147
- const m = String(d.getUTCMonth() + 1).padStart(2, "0");
148
- const day = String(d.getUTCDate()).padStart(2, "0");
145
+ // Use local timezone with AM 5:00 as the day boundary — hours before 5am
146
+ // count as the previous day so late-night work doesn't consume the next
147
+ // day's quota.
148
+ const d = new Date(Date.now() - 5 * 60 * 60 * 1000);
149
+ const y = d.getFullYear();
150
+ const m = String(d.getMonth() + 1).padStart(2, "0");
151
+ const day = String(d.getDate()).padStart(2, "0");
149
152
  return `${y}-${m}-${day}`;
150
153
  }
151
154
 
@@ -167,11 +170,13 @@ export function rollScoutDayIfNeeded(state) {
167
170
  return state;
168
171
  }
169
172
 
170
- export function recordComposeAttempt(state, title) {
173
+ export function recordComposeAttempt(state, title, postId) {
171
174
  state.composedToday = (state.composedToday || 0) + 1;
172
175
  state.lastComposeDay = todayKey();
173
176
  if (!Array.isArray(state.recentComposeTitles)) state.recentComposeTitles = [];
174
- state.recentComposeTitles.unshift(String(title || ""));
177
+ state.recentComposeTitles.unshift(
178
+ postId ? { title: String(title || ""), postId: String(postId) } : String(title || "")
179
+ );
175
180
  if (state.recentComposeTitles.length > 10) state.recentComposeTitles.length = 10;
176
181
  return state;
177
182
  }
@@ -589,6 +589,14 @@ async function cmdPropose(postId, flags) {
589
589
  )
590
590
  );
591
591
 
592
+ // Bump sentToday counter immediately after posting (before verification),
593
+ // because the comment is already created and rate-limits are consumed
594
+ // regardless of verification outcome.
595
+ const state = rollScoutDayIfNeeded(await readScoutState());
596
+ state.sentToday += 1;
597
+ markPostSeen(state, postId, "published");
598
+ await writeScoutState(state);
599
+
592
600
  if (!verification) {
593
601
  console.log("propose: no verification challenge returned — done");
594
602
  return;
@@ -651,12 +659,6 @@ async function cmdPropose(postId, flags) {
651
659
  }
652
660
  console.log(JSON.stringify({ ok: true, verify: verifyRes, answer }, null, 2));
653
661
 
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
662
  if (!prevTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevTls ?? "";
661
663
  }
662
664
 
@@ -667,10 +669,15 @@ async function solvePuzzleWithLLM(challengeText) {
667
669
  const prompt =
668
670
  `The following text is an obfuscated arithmetic word problem from Moltbook (an AI social network). ` +
669
671
  `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` +
672
+ `CRITICAL: ALL symbols (/, *, ^, ~, [, ], etc.) are NOISE, NOT arithmetic operators. ` +
673
+ `The operation is ALWAYS expressed in natural language words only. ` +
674
+ `Extract the numbers (written as words like "thirty five" = 35), determine the operation from WORDS ONLY ` +
675
+ `(addition: "total", "combined", "and", "plus", "gains", "new velocity"; ` +
676
+ `subtraction: "difference", "minus", "less", "loses"; ` +
677
+ `multiplication: "times", "product", "multiplied"; ` +
678
+ `division: "divided by", "ratio", "per"). ` +
679
+ `If no operation word is found, default to addition. ` +
680
+ `Compute the result and output ONLY the number with exactly 2 decimal places (e.g. "58.00"). No other text.\n\n` +
674
681
  `Puzzle: ${challengeText}`;
675
682
 
676
683
  // Try claude first, then codex.
@@ -714,8 +721,12 @@ async function solvePuzzleWithLLM(challengeText) {
714
721
  // `null` if it can't confidently solve — caller falls back to manual.
715
722
  function solveVerificationPuzzle(challengeText) {
716
723
  if (!challengeText) return null;
724
+ // Strip ALL symbolic characters as noise — operations are expressed in
725
+ // natural language only ("and", "times", "divided by", etc.). Previous
726
+ // regex missed `*` and `@` which caused `/` or `*` to be mistaken for
727
+ // arithmetic operators.
717
728
  const cleaned = String(challengeText)
718
- .replace(/[\[\]|\\\/^\-\.~,;:!?'"(){}]/g, " ")
729
+ .replace(/[^a-zA-Z0-9\s]/g, " ")
719
730
  .toLowerCase();
720
731
  const numberWords = {
721
732
  zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9,
@@ -729,12 +740,42 @@ function solveVerificationPuzzle(challengeText) {
729
740
  // so try matching both the raw word and a version with collapsed runs.
730
741
  // We can't blindly collapse because natural doubles exist ("three" has "ee").
731
742
  const collapseRuns = (w) => w.replace(/([a-z])\1+/g, "$1");
732
- const rawWords = cleaned
743
+ const rawTokens = cleaned
733
744
  .replace(/[^a-z0-9\s]/g, " ")
734
745
  .split(/\s+/)
735
746
  .filter(Boolean);
747
+
748
+ // The obfuscator inserts spaces mid-word (e.g. "th ree" → "three",
749
+ // "to tal" → "total"). Greedily merge adjacent tokens if the combined
750
+ // form (raw or collapsed) matches a known number word or operation keyword.
751
+ const operationWords = new Set([
752
+ "total", "combined", "force", "velocity", "speed", "gains", "plus", "and",
753
+ "subtract", "minus", "less", "difference", "decreased", "loses", "lost", "slower", "slows", "slowed",
754
+ "multiply", "times", "product", "multiplied",
755
+ "divide", "divided", "ratio",
756
+ "how", "much", "what", "exerts", "new",
757
+ ]);
758
+ const isKnown = (w) => numberWords[w] != null || numberWords[collapseRuns(w)] != null || operationWords.has(w) || operationWords.has(collapseRuns(w));
759
+ const merged = [];
760
+ let ti = 0;
761
+ while (ti < rawTokens.length) {
762
+ // Try merging up to 4 consecutive tokens.
763
+ let best = rawTokens[ti];
764
+ let bestLen = 1;
765
+ let candidate = rawTokens[ti];
766
+ for (let span = 2; span <= Math.min(4, rawTokens.length - ti); span++) {
767
+ candidate += rawTokens[ti + span - 1];
768
+ if (isKnown(candidate) || isKnown(collapseRuns(candidate))) {
769
+ best = candidate;
770
+ bestLen = span;
771
+ }
772
+ }
773
+ merged.push(best);
774
+ ti += bestLen;
775
+ }
776
+
736
777
  // For each word, prefer the raw form if it's in the dictionary; otherwise try collapsed.
737
- const words = rawWords.map((w) => {
778
+ const words = merged.map((w) => {
738
779
  if (/^\d+$/.test(w)) return w;
739
780
  if (numberWords[w] != null) return w;
740
781
  const collapsed = collapseRuns(w);
@@ -776,11 +817,11 @@ function solveVerificationPuzzle(challengeText) {
776
817
  const hasWord = (w) => words.includes(w);
777
818
  const hasAny = (...ws) => ws.some(hasWord);
778
819
  let result;
779
- if (hasAny("subtract", "minus", "less", "difference", "decreased", "loses", "lost", "slower")) {
820
+ if (hasAny("subtract", "minus", "less", "difference", "decreased", "loses", "lost", "slower", "slows", "slowed")) {
780
821
  result = a - b;
781
822
  } else if (hasAny("multiply", "times", "product", "multiplied")) {
782
823
  result = a * b;
783
- } else if (hasAny("divide", "divided", "ratio", "per")) {
824
+ } else if (hasAny("divide", "divided", "ratio")) {
784
825
  result = b !== 0 ? a / b : a;
785
826
  } else {
786
827
  // Default to addition — the Moltbook puzzles overwhelmingly ask for
@@ -1111,6 +1152,7 @@ async function cmdComposePropose(flags) {
1111
1152
  const content = typeof flags.content === "string" ? flags.content : "";
1112
1153
  const submolt = typeof flags.submolt === "string" ? flags.submolt : "general";
1113
1154
  const intent = typeof flags.intent === "string" ? flags.intent : "";
1155
+ const slot = typeof flags.slot === "string" ? flags.slot : "";
1114
1156
  const timeoutSec = Number(flags.timeout) || 900;
1115
1157
  if (!title.trim()) fail("--title is required");
1116
1158
  if (!content.trim()) fail("--content is required");
@@ -1139,6 +1181,7 @@ async function cmdComposePropose(flags) {
1139
1181
  draftType: "original_post",
1140
1182
  submoltName: submolt,
1141
1183
  intent,
1184
+ slot,
1142
1185
  contextSummary: truncate(intent || content, 160),
1143
1186
  }),
1144
1187
  });
@@ -1202,10 +1245,19 @@ async function cmdComposePropose(flags) {
1202
1245
  if (!verification) {
1203
1246
  console.log("compose-propose: no verification — done");
1204
1247
  } else {
1205
- // Solve verification puzzle inline.
1248
+ // Solve verification puzzle inline (try naive solver, then LLM fallback, retry once on wrong answer).
1206
1249
  let answer = solveVerificationPuzzle(verification.challenge_text);
1207
- if (answer == null) answer = await solvePuzzleWithLLM(verification.challenge_text);
1208
- if (answer != null) {
1250
+ const source = answer != null ? "solver" : "skip";
1251
+ console.log(`compose-propose: verification puzzle — solver answer: ${answer ?? "(null)"}`);
1252
+
1253
+ if (answer == null) {
1254
+ answer = await solvePuzzleWithLLM(verification.challenge_text);
1255
+ if (answer) console.log(`compose-propose: LLM fallback answer: ${answer}`);
1256
+ }
1257
+
1258
+ if (answer == null) {
1259
+ console.log(`VERIFICATION REQUIRED (solver + LLM both failed):\n verification_code: ${verification.verification_code}\n challenge_text: ${verification.challenge_text}`);
1260
+ } else {
1209
1261
  try {
1210
1262
  const verifyRes = await mb(`/verify`, {
1211
1263
  method: "POST",
@@ -1213,16 +1265,34 @@ async function cmdComposePropose(flags) {
1213
1265
  });
1214
1266
  console.log(JSON.stringify({ ok: true, verify: verifyRes, answer }, null, 2));
1215
1267
  } catch (verifyError) {
1216
- console.log(`compose-propose: verify failed: ${verifyError.message}`);
1268
+ const isWrongAnswer = /incorrect/i.test(verifyError.message);
1269
+ if (isWrongAnswer && source === "solver") {
1270
+ console.log(`compose-propose: solver answer ${answer} was wrong, trying LLM fallback`);
1271
+ const llmAnswer = await solvePuzzleWithLLM(verification.challenge_text);
1272
+ if (llmAnswer && llmAnswer !== answer) {
1273
+ console.log(`compose-propose: LLM retry answer: ${llmAnswer}`);
1274
+ try {
1275
+ const verifyRes2 = await mb(`/verify`, {
1276
+ method: "POST",
1277
+ body: JSON.stringify({ verification_code: verification.verification_code, answer: llmAnswer }),
1278
+ });
1279
+ console.log(JSON.stringify({ ok: true, verify: verifyRes2, answer: llmAnswer }, null, 2));
1280
+ } catch (retryError) {
1281
+ console.log(`compose-propose: LLM retry also failed: ${retryError.message}`);
1282
+ }
1283
+ } else {
1284
+ console.log(`compose-propose: LLM couldn't produce a different answer`);
1285
+ }
1286
+ } else {
1287
+ console.log(`compose-propose: verify failed: ${verifyError.message}`);
1288
+ }
1217
1289
  }
1218
- } else {
1219
- console.log(`VERIFICATION REQUIRED:\n verification_code: ${verification.verification_code}\n challenge_text: ${verification.challenge_text}`);
1220
1290
  }
1221
1291
  }
1222
1292
 
1223
1293
  // 4. Bump compose counter.
1224
1294
  const state = rollScoutDayIfNeeded(await readScoutState());
1225
- recordComposeAttempt(state, finalTitle);
1295
+ recordComposeAttempt(state, finalTitle, post?.id);
1226
1296
  await writeScoutState(state);
1227
1297
 
1228
1298
  if (!prevTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevTls ?? "";