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.
- package/README.md +25 -11
- package/package.json +11 -2
- package/scripts/a2a-cli.mjs +189 -0
- package/scripts/a2a-executor.mjs +126 -0
- package/scripts/a2a-handler.mjs +439 -0
- package/scripts/a2a-relay-client.mjs +394 -0
- package/scripts/moltbook-api.mjs +11 -6
- package/scripts/moltbook-cli.mjs +92 -22
- package/scripts/moltbook-scout-auto.sh +447 -0
- package/scripts/viveworker-bridge.mjs +270 -8
- package/scripts/viveworker.mjs +11 -0
- package/web/app.css +29 -2
- package/web/app.js +152 -8
- package/web/i18n.js +45 -9
- package/web/sw.js +1 -1
|
@@ -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
|
+
}
|
package/scripts/moltbook-api.mjs
CHANGED
|
@@ -142,10 +142,13 @@ export function defaultScoutState() {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
export function todayKey() {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
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(
|
|
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
|
}
|
package/scripts/moltbook-cli.mjs
CHANGED
|
@@ -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
|
-
`
|
|
671
|
-
`
|
|
672
|
-
`
|
|
673
|
-
`
|
|
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(/[\
|
|
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
|
|
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 =
|
|
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"
|
|
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
|
-
|
|
1208
|
-
|
|
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
|
-
|
|
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 ?? "";
|