viveworker 0.7.0-beta.0 → 0.7.0-beta.2
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 +38 -0
- package/package.json +2 -1
- package/scripts/moltbook-api.mjs +319 -72
- package/scripts/share-cli.mjs +116 -6
- package/scripts/viveworker-bridge.mjs +2637 -160
- package/scripts/viveworker.mjs +11 -0
- package/viveworker.env.example +4 -0
- package/web/app.css +787 -9
- package/web/app.js +1664 -61
- package/web/hazbase-passkey.js +107 -0
- package/web/i18n.js +254 -0
- package/web/sw.js +33 -15
package/README.md
CHANGED
|
@@ -168,12 +168,21 @@ What it supports:
|
|
|
168
168
|
- optional password protection
|
|
169
169
|
- optional expiry
|
|
170
170
|
|
|
171
|
+
HTML uploads are optimized by default when possible.
|
|
172
|
+
This is especially useful for bundled standalone HTML exports that carry large embedded font payloads.
|
|
173
|
+
`viveworker share upload` will try to shrink those files locally before upload, which often makes otherwise-too-large deck or prototype exports shareable without extra prep.
|
|
174
|
+
|
|
175
|
+
If you want the original HTML bytes untouched, use `--no-optimize`.
|
|
176
|
+
When optimization strips embedded fonts, layout and typography may change slightly, but the goal is to preserve a usable standalone share URL.
|
|
177
|
+
|
|
171
178
|
It reuses the same A2A credentials as the rest of `viveworker`, so there is no separate auth or setup step.
|
|
172
179
|
|
|
173
180
|
Typical commands:
|
|
174
181
|
|
|
175
182
|
- `npx viveworker share upload report.html`
|
|
183
|
+
- `npx viveworker share upload deck_standalone.html`
|
|
176
184
|
- `npx viveworker share upload report.pdf --password "hunter2" --expires-days 7`
|
|
185
|
+
- `npx viveworker share upload deck_standalone.html --no-optimize`
|
|
177
186
|
- `npx viveworker share list`
|
|
178
187
|
- `npx viveworker share update <slug> --password "hunter2"`
|
|
179
188
|
- `npx viveworker share update <slug> --expires-days 7`
|
|
@@ -192,6 +201,12 @@ The idea is simple:
|
|
|
192
201
|
- the result is handed back through File Share
|
|
193
202
|
- the requester unlocks the deliverable on testnet
|
|
194
203
|
|
|
204
|
+
When the seller wants payouts to go to a hazbase-managed wallet instead of a raw EOA, the recommended flow is:
|
|
205
|
+
|
|
206
|
+
- the human completes OTP / passkey / wallet issuance in `Settings -> Integrations -> Wallet`
|
|
207
|
+
- the agent resolves the local payout address from `/api/hazbase/payout-address`
|
|
208
|
+
- the agent passes that resolved address to `share upload` / `share update --pay-to`
|
|
209
|
+
|
|
195
210
|
This is not meant as a generic "payments feature."
|
|
196
211
|
The interesting part is the agent workflow: request, delivery, handoff, and unlock stay cleanly separated.
|
|
197
212
|
|
|
@@ -211,6 +226,29 @@ Run `npx viveworker enable claude` if you want to repair the hooks later or targ
|
|
|
211
226
|
|
|
212
227
|
Advanced: pass `--settings-file <path>` (or `--claude-settings-file <path>`) to target a non-default Claude settings file.
|
|
213
228
|
|
|
229
|
+
## Auto Pilot
|
|
230
|
+
|
|
231
|
+
`viveworker` also includes **Auto Pilot**, a conservative auto-approval layer for the current workspace.
|
|
232
|
+
|
|
233
|
+
You can enable it from `Settings > Auto Pilot`.
|
|
234
|
+
|
|
235
|
+
Today it exposes two families of policy:
|
|
236
|
+
|
|
237
|
+
- **Safe reads**: auto-approve common workspace-only read commands such as `rg`, `find`, `git diff`, `git show`, `sed -n`, `head`, `tail`, and `wc`
|
|
238
|
+
- **Low-risk writes**: auto-approve small file changes only when they fit one of these lanes:
|
|
239
|
+
- `Content & copy`
|
|
240
|
+
- `UI & tests`
|
|
241
|
+
- `Small code patches (beta)`
|
|
242
|
+
|
|
243
|
+
Important boundaries:
|
|
244
|
+
|
|
245
|
+
- Auto Pilot is scoped to the **current workspace only**
|
|
246
|
+
- secrets, config, deploy, auth, payment, networked changes, and anything outside the current workspace stay manual
|
|
247
|
+
- `Small code patches (beta)` is stricter than the other write lanes and expects a recent read of the same file in the same thread
|
|
248
|
+
|
|
249
|
+
When Auto Pilot does not approve something, the request stays in the normal phone approval flow.
|
|
250
|
+
The approval detail view also explains why it stayed manual, so it is easier to tune and trust over time.
|
|
251
|
+
|
|
214
252
|
### Sync Mode (for Claude plans and questions)
|
|
215
253
|
|
|
216
254
|
Claude Desktop exposes approval hooks but has no native IPC for answering `ExitPlanMode` / `AskUserQuestion` prompts remotely. To let you answer plans and questions from your paired device, `viveworker` offers **Sync mode** (toggle in `Settings`, formerly "Away mode"):
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viveworker",
|
|
3
|
-
"version": "0.7.0-beta.
|
|
3
|
+
"version": "0.7.0-beta.2",
|
|
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",
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"ntfy/docker-compose.yml.example"
|
|
62
62
|
],
|
|
63
63
|
"dependencies": {
|
|
64
|
+
"@hazbase/auth": "^0.5.0",
|
|
64
65
|
"qrcode-terminal": "^0.12.0",
|
|
65
66
|
"web-push": "^3.6.7"
|
|
66
67
|
},
|
package/scripts/moltbook-api.mjs
CHANGED
|
@@ -312,43 +312,114 @@ export async function listPendingDrafts(dir = DEFAULT_DRAFTS_DIR) {
|
|
|
312
312
|
//
|
|
313
313
|
// Shared by the CLI (for manual `reply` flow) and the bridge (for fire-and-
|
|
314
314
|
// forget draft posting on approval).
|
|
315
|
+
//
|
|
316
|
+
// Moltbook's verification puzzles are obfuscated word problems — e.g.
|
|
317
|
+
// `lOoB-stErR ClAw FoRcE iS tHiRtY fIvE NoOtOnS aNd iT s OtHeR ClAw Is tWeNtY
|
|
318
|
+
// tHrEe NooToNs, tOtAl/ FoRcE?` → 35 + 23 = 58.00. The solver strips
|
|
319
|
+
// non-letter noise, identifies number words (including compounds like
|
|
320
|
+
// "one hundred forty two" = 142), identifies operator words around each
|
|
321
|
+
// number, and evaluates left-to-right. See `scripts/test-puzzle-solver.mjs`
|
|
322
|
+
// for the regression corpus; add new cases there whenever we observe a
|
|
323
|
+
// failure in the wild via the `~/.viveworker/moltbook-verify-history.jsonl`
|
|
324
|
+
// log.
|
|
325
|
+
|
|
326
|
+
// Operator encoding: 0=add, 1=sub, 2=mul, 3=div.
|
|
327
|
+
const OP_ADD = 0;
|
|
328
|
+
const OP_SUB = 1;
|
|
329
|
+
const OP_MUL = 2;
|
|
330
|
+
const OP_DIV = 3;
|
|
331
|
+
|
|
332
|
+
const NUMBER_WORDS = Object.freeze({
|
|
333
|
+
zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9,
|
|
334
|
+
ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16,
|
|
335
|
+
seventeen: 17, eighteen: 18, nineteen: 19,
|
|
336
|
+
twenty: 20, thirty: 30, forty: 40, fifty: 50,
|
|
337
|
+
sixty: 60, seventy: 70, eighty: 80, ninety: 90,
|
|
338
|
+
hundred: 100, thousand: 1000,
|
|
339
|
+
// Cultural shortcuts that occasionally appear in the generator.
|
|
340
|
+
dozen: 12, score: 20,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Operator words. Keep contextual/framing words (force, velocity, total, etc.)
|
|
344
|
+
// mapped to OP_ADD so their presence doesn't crash the parser — the binary-
|
|
345
|
+
// sequence builder drops ops that have no number to operate against.
|
|
346
|
+
const OP_WORDS = Object.freeze({
|
|
347
|
+
// Addition / neutral-context.
|
|
348
|
+
add: OP_ADD, added: OP_ADD, adds: OP_ADD, plus: OP_ADD, and: OP_ADD,
|
|
349
|
+
sum: OP_ADD, summed: OP_ADD, combined: OP_ADD, combine: OP_ADD, together: OP_ADD,
|
|
350
|
+
total: OP_ADD, totaled: OP_ADD, totals: OP_ADD, both: OP_ADD,
|
|
351
|
+
gain: OP_ADD, gains: OP_ADD, gained: OP_ADD,
|
|
352
|
+
increase: OP_ADD, increased: OP_ADD, increases: OP_ADD,
|
|
353
|
+
boost: OP_ADD, boosted: OP_ADD, boosts: OP_ADD,
|
|
354
|
+
faster: OP_ADD, accelerates: OP_ADD, accelerated: OP_ADD, more: OP_ADD,
|
|
355
|
+
force: OP_ADD, velocity: OP_ADD, speed: OP_ADD, exerts: OP_ADD, new: OP_ADD,
|
|
356
|
+
// Subtraction.
|
|
357
|
+
minus: OP_SUB, less: OP_SUB, difference: OP_SUB, subtract: OP_SUB,
|
|
358
|
+
subtracts: OP_SUB, subtracted: OP_SUB,
|
|
359
|
+
decrease: OP_SUB, decreased: OP_SUB, decreases: OP_SUB,
|
|
360
|
+
loses: OP_SUB, lost: OP_SUB, lose: OP_SUB,
|
|
361
|
+
reduce: OP_SUB, reduced: OP_SUB, reduces: OP_SUB, reducing: OP_SUB, reduction: OP_SUB,
|
|
362
|
+
drop: OP_SUB, drops: OP_SUB, dropped: OP_SUB,
|
|
363
|
+
slower: OP_SUB, slows: OP_SUB, slowed: OP_SUB,
|
|
364
|
+
remove: OP_SUB, removes: OP_SUB, removed: OP_SUB,
|
|
365
|
+
// Multiplication.
|
|
366
|
+
multiply: OP_MUL, multiplied: OP_MUL, multiplies: OP_MUL,
|
|
367
|
+
times: OP_MUL, product: OP_MUL,
|
|
368
|
+
twice: OP_MUL, double: OP_MUL, doubled: OP_MUL,
|
|
369
|
+
triple: OP_MUL, tripled: OP_MUL,
|
|
370
|
+
squared: OP_MUL, square: OP_MUL,
|
|
371
|
+
// Division.
|
|
372
|
+
divide: OP_DIV, divided: OP_DIV, divides: OP_DIV, division: OP_DIV,
|
|
373
|
+
ratio: OP_DIV,
|
|
374
|
+
// NOTE: "per" intentionally NOT mapped here — "per second" / "per hour"
|
|
375
|
+
// are units, not division operators. When a puzzle really means division
|
|
376
|
+
// it uses "divided by" or "ratio of" or "split".
|
|
377
|
+
half: OP_DIV, halved: OP_DIV, halves: OP_DIV,
|
|
378
|
+
split: OP_DIV,
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Collapse runs of the same letter: the generator often doubles consonants
|
|
382
|
+
// (e.g. "NoOtOnS" → "nootons"; "lOoB-stErR" → "loobsterr"). Collapsing means
|
|
383
|
+
// known-word detection still fires on the de-doubled form.
|
|
384
|
+
const collapseRuns = (w) => w.replace(/([a-z])\1+/g, "$1");
|
|
385
|
+
|
|
386
|
+
function lookupWord(word) {
|
|
387
|
+
if (NUMBER_WORDS[word] != null) return { type: "num", value: NUMBER_WORDS[word] };
|
|
388
|
+
if (OP_WORDS[word] != null) return { type: "op", value: OP_WORDS[word] };
|
|
389
|
+
const collapsed = collapseRuns(word);
|
|
390
|
+
if (collapsed !== word) {
|
|
391
|
+
if (NUMBER_WORDS[collapsed] != null) return { type: "num", value: NUMBER_WORDS[collapsed] };
|
|
392
|
+
if (OP_WORDS[collapsed] != null) return { type: "op", value: OP_WORDS[collapsed] };
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
315
396
|
|
|
316
|
-
// Naive verification-puzzle solver.
|
|
317
|
-
//
|
|
318
|
-
// `null` if it can't confidently solve — caller falls back to LLM or manual.
|
|
397
|
+
// Naive verification-puzzle solver. Returns answer as "XX.XX" or null if we
|
|
398
|
+
// can't confidently recover two numbers. Caller falls back to LLM.
|
|
319
399
|
export function solveVerificationPuzzle(challengeText) {
|
|
320
400
|
if (!challengeText) return null;
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
seventeen: 17, eighteen: 18, nineteen: 19, twenty: 20, thirty: 30, forty: 40, fifty: 50,
|
|
328
|
-
sixty: 60, seventy: 70, eighty: 80, ninety: 90, hundred: 100,
|
|
329
|
-
};
|
|
330
|
-
const collapseRuns = (w) => w.replace(/([a-z])\1+/g, "$1");
|
|
331
|
-
const rawTokens = cleaned
|
|
401
|
+
|
|
402
|
+
// Strip everything that isn't a letter or digit. Symbols (/, *, ^, ~, [, ])
|
|
403
|
+
// are deliberately NOT operators — the generator sprinkles them as pure
|
|
404
|
+
// noise. All real operators are spelled out in words.
|
|
405
|
+
const rawTokens = String(challengeText)
|
|
406
|
+
.toLowerCase()
|
|
332
407
|
.replace(/[^a-z0-9\s]/g, " ")
|
|
333
408
|
.split(/\s+/)
|
|
334
409
|
.filter(Boolean);
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
"divide", "divided", "ratio",
|
|
340
|
-
"how", "much", "what", "exerts", "new",
|
|
341
|
-
]);
|
|
342
|
-
const isKnown = (w) => numberWords[w] != null || numberWords[collapseRuns(w)] != null || operationWords.has(w) || operationWords.has(collapseRuns(w));
|
|
410
|
+
|
|
411
|
+
// The generator sometimes splits a known word into pieces separated by
|
|
412
|
+
// stripped punctuation (e.g. "tHiR-tY" → "thir" "ty"). Try to re-merge
|
|
413
|
+
// adjacent tokens if their concatenation is a known number or operator.
|
|
343
414
|
const merged = [];
|
|
344
415
|
let ti = 0;
|
|
345
416
|
while (ti < rawTokens.length) {
|
|
346
|
-
let best = rawTokens[ti];
|
|
347
417
|
let bestLen = 1;
|
|
418
|
+
let best = rawTokens[ti];
|
|
348
419
|
let candidate = rawTokens[ti];
|
|
349
420
|
for (let span = 2; span <= Math.min(4, rawTokens.length - ti); span++) {
|
|
350
421
|
candidate += rawTokens[ti + span - 1];
|
|
351
|
-
if (
|
|
422
|
+
if (lookupWord(candidate)) {
|
|
352
423
|
best = candidate;
|
|
353
424
|
bestLen = span;
|
|
354
425
|
}
|
|
@@ -356,57 +427,199 @@ export function solveVerificationPuzzle(challengeText) {
|
|
|
356
427
|
merged.push(best);
|
|
357
428
|
ti += bestLen;
|
|
358
429
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
while (i < words.length) {
|
|
369
|
-
const w = words[i];
|
|
430
|
+
|
|
431
|
+
// Convert the token stream into a sequence of {type:"num"|"op", value}.
|
|
432
|
+
// Compound numbers ("one hundred forty two" = 142) are collected greedily.
|
|
433
|
+
const tokens = [];
|
|
434
|
+
let j = 0;
|
|
435
|
+
while (j < merged.length) {
|
|
436
|
+
const w = merged[j];
|
|
437
|
+
|
|
438
|
+
// Bare digit.
|
|
370
439
|
if (/^\d+$/.test(w)) {
|
|
371
|
-
|
|
372
|
-
|
|
440
|
+
tokens.push({ type: "num", value: Number(w) });
|
|
441
|
+
j += 1;
|
|
373
442
|
continue;
|
|
374
443
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
444
|
+
|
|
445
|
+
const lookup = lookupWord(w);
|
|
446
|
+
if (!lookup) { j += 1; continue; }
|
|
447
|
+
|
|
448
|
+
if (lookup.type === "num") {
|
|
449
|
+
// Greedy compound-number consumption. Examples:
|
|
450
|
+
// "thirty five" = 35 (chunk += 30 then chunk += 5)
|
|
451
|
+
// "one hundred forty two" = 142 (chunk=1 → *100 → +40 → +2)
|
|
452
|
+
// "three thousand four hundred fifty" = 3450
|
|
453
|
+
let sum = 0;
|
|
454
|
+
let chunk = 0;
|
|
455
|
+
let consumed = false;
|
|
456
|
+
while (j < merged.length) {
|
|
457
|
+
const cur = merged[j];
|
|
458
|
+
if (/^\d+$/.test(cur)) {
|
|
459
|
+
if (consumed) break; // mixing word-number + digit-number, stop
|
|
460
|
+
sum = Number(cur);
|
|
461
|
+
consumed = true;
|
|
462
|
+
j += 1;
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
const inner = lookupWord(cur);
|
|
466
|
+
if (!inner || inner.type !== "num") break;
|
|
467
|
+
const n = inner.value;
|
|
468
|
+
if (n === 1000) {
|
|
469
|
+
sum += (chunk || 1) * 1000;
|
|
470
|
+
chunk = 0;
|
|
471
|
+
} else if (n === 100) {
|
|
472
|
+
chunk = (chunk || 1) * 100;
|
|
473
|
+
} else {
|
|
474
|
+
// n < 100: tens + ones accumulate into the current chunk.
|
|
475
|
+
chunk += n;
|
|
476
|
+
}
|
|
477
|
+
consumed = true;
|
|
478
|
+
j += 1;
|
|
384
479
|
}
|
|
385
|
-
|
|
480
|
+
if (consumed) tokens.push({ type: "num", value: sum + chunk });
|
|
386
481
|
continue;
|
|
387
482
|
}
|
|
388
|
-
|
|
483
|
+
|
|
484
|
+
// Operator.
|
|
485
|
+
tokens.push({ type: "op", value: lookup.value });
|
|
486
|
+
j += 1;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Capture the first non-default op that appears BEFORE the first number.
|
|
490
|
+
// Phrases like "product of X and Y", "difference between X and Y", and
|
|
491
|
+
// "ratio of X to Y" put the real operation at the front and leave only a
|
|
492
|
+
// weak connector ("and", "to", "of") between the numbers. Without this,
|
|
493
|
+
// the solver would drop the leading op and default to addition.
|
|
494
|
+
let leadingStrongOp = null;
|
|
495
|
+
for (const t of tokens) {
|
|
496
|
+
if (t.type === "num") break;
|
|
497
|
+
if (t.type === "op" && t.value !== OP_ADD) { leadingStrongOp = t.value; break; }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Normalise into a strict alternation [num, op, num, op, num, ...].
|
|
501
|
+
// - Drop leading ops (framing words like "total force is ...").
|
|
502
|
+
// - If two ops appear back-to-back, prefer the STRONGER (non-ADD) one
|
|
503
|
+
// over the default ADD. This is because contextual framing verbs
|
|
504
|
+
// (force / velocity / speed / exerts) are mapped to OP_ADD so their
|
|
505
|
+
// presence doesn't crash the parser, but they should NOT clobber a
|
|
506
|
+
// real operator (OP_SUB / OP_MUL / OP_DIV) that appears right next
|
|
507
|
+
// to them. Example: "reducing speed by four" → tokens end up as
|
|
508
|
+
// [OP_SUB(reducing), OP_ADD(speed)] — SUB must survive.
|
|
509
|
+
// When both ops are the same strength, the latter wins (closest to
|
|
510
|
+
// the following number).
|
|
511
|
+
// - If two nums appear with no op between them, insert a default add.
|
|
512
|
+
// - Drop trailing ops.
|
|
513
|
+
const seq = [];
|
|
514
|
+
for (const t of tokens) {
|
|
515
|
+
if (seq.length === 0) {
|
|
516
|
+
if (t.type === "num") seq.push(t);
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
const last = seq[seq.length - 1];
|
|
520
|
+
if (t.type === "num") {
|
|
521
|
+
if (last.type === "num") {
|
|
522
|
+
seq.push({ type: "op", value: OP_ADD });
|
|
523
|
+
}
|
|
524
|
+
seq.push(t);
|
|
525
|
+
} else {
|
|
526
|
+
if (last.type === "op") {
|
|
527
|
+
// Prefer strong over default (ADD). Otherwise latter wins.
|
|
528
|
+
if (last.value === OP_ADD && t.value !== OP_ADD) {
|
|
529
|
+
seq[seq.length - 1] = t;
|
|
530
|
+
} else if (last.value !== OP_ADD && t.value === OP_ADD) {
|
|
531
|
+
// Keep last — strong op survives over a trailing framing verb.
|
|
532
|
+
} else {
|
|
533
|
+
seq[seq.length - 1] = t;
|
|
534
|
+
}
|
|
535
|
+
} else {
|
|
536
|
+
seq.push(t);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
389
539
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
540
|
+
while (seq.length > 0 && seq[seq.length - 1].type === "op") seq.pop();
|
|
541
|
+
|
|
542
|
+
if (seq.length < 3) return null;
|
|
543
|
+
if (seq[0].type !== "num") return null;
|
|
544
|
+
|
|
545
|
+
// Promote the leading op if no strong op appeared between the numbers. We
|
|
546
|
+
// only do this when every internal op is the default OP_ADD — otherwise
|
|
547
|
+
// the internal ops were the real signal (e.g. "a+b-c" should stay mixed,
|
|
548
|
+
// even if the sentence started with "total").
|
|
549
|
+
if (leadingStrongOp != null) {
|
|
550
|
+
const hasStrongInternal = seq.some((t) => t.type === "op" && t.value !== OP_ADD);
|
|
551
|
+
if (!hasStrongInternal) {
|
|
552
|
+
for (const t of seq) {
|
|
553
|
+
if (t.type === "op") t.value = leadingStrongOp;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Evaluate strictly left-to-right (no precedence — Moltbook puzzles are
|
|
559
|
+
// binary in practice, so this only matters for the rare 3-number case).
|
|
560
|
+
let result = seq[0].value;
|
|
561
|
+
for (let k = 1; k < seq.length; k += 2) {
|
|
562
|
+
const op = seq[k];
|
|
563
|
+
const rhs = seq[k + 1];
|
|
564
|
+
if (!op || !rhs || op.type !== "op" || rhs.type !== "num") break;
|
|
565
|
+
switch (op.value) {
|
|
566
|
+
case OP_ADD: result += rhs.value; break;
|
|
567
|
+
case OP_SUB: result -= rhs.value; break;
|
|
568
|
+
case OP_MUL: result *= rhs.value; break;
|
|
569
|
+
case OP_DIV: result = rhs.value !== 0 ? result / rhs.value : result; break;
|
|
570
|
+
default: break;
|
|
571
|
+
}
|
|
404
572
|
}
|
|
573
|
+
|
|
574
|
+
if (!Number.isFinite(result)) return null;
|
|
575
|
+
// Refuse answers the puzzle generator shouldn't produce. A negative answer
|
|
576
|
+
// usually means we mis-identified the operator direction; returning null
|
|
577
|
+
// lets the LLM take a clean shot rather than submitting a definitely-wrong
|
|
578
|
+
// value.
|
|
579
|
+
if (result < 0) return null;
|
|
580
|
+
|
|
405
581
|
return result.toFixed(2);
|
|
406
582
|
}
|
|
407
583
|
|
|
408
584
|
// LLM-based verification puzzle solver. Shells out to claude or codex CLI.
|
|
409
585
|
// Returns the answer as "XX.XX" string, or null if unavailable.
|
|
586
|
+
//
|
|
587
|
+
// The naive solver misses ~13% of Moltbook's puzzles (mostly 3-number or
|
|
588
|
+
// unusual-vocabulary cases). The LLM fallback has been the same ~13% blind
|
|
589
|
+
// spot because parsing was too strict — it only accepted "XX.XX" on its own
|
|
590
|
+
// or a pure-integer line, and many CLI wrappers prepend banners or reason
|
|
591
|
+
// step-by-step before emitting the final number. `extractPuzzleAnswerFromText`
|
|
592
|
+
// is now shared with `solvePuzzleWithLLM` and is much more forgiving:
|
|
593
|
+
// prefer explicit decimals, fall back to the last standalone integer, then
|
|
594
|
+
// the last integer anywhere in the response. Exported for tests.
|
|
595
|
+
export function extractPuzzleAnswerFromText(text) {
|
|
596
|
+
if (!text) return null;
|
|
597
|
+
const trimmed = String(text).trim();
|
|
598
|
+
if (!trimmed) return null;
|
|
599
|
+
|
|
600
|
+
// 1. Prefer an exact "NN.NN" (two decimals) token. This is what the prompt
|
|
601
|
+
// explicitly asks for and what every happy-path response contains.
|
|
602
|
+
const twoDecimal = trimmed.match(/(?:^|[^\d])(\d+\.\d{2})(?:[^\d]|$)/);
|
|
603
|
+
if (twoDecimal) return twoDecimal[1];
|
|
604
|
+
|
|
605
|
+
// 2. Accept a one-decimal form and pad — e.g. "58.5" → "58.50".
|
|
606
|
+
const oneDecimal = trimmed.match(/(?:^|[^\d])(\d+\.\d)(?:[^\d]|$)/);
|
|
607
|
+
if (oneDecimal) return `${oneDecimal[1]}0`;
|
|
608
|
+
|
|
609
|
+
// 3. If the response contains any integer at all, use the LAST one. The LLM
|
|
610
|
+
// often reasons step-by-step ("thirty-five = 35, twenty-three = 23,
|
|
611
|
+
// sum is 58"), and the final integer is the answer. This is less
|
|
612
|
+
// precise than step (1) but beats returning null on otherwise-correct
|
|
613
|
+
// reasoning.
|
|
614
|
+
const ints = [...trimmed.matchAll(/\b(\d+)\b/g)].map((m) => m[1]);
|
|
615
|
+
if (ints.length > 0) {
|
|
616
|
+
const pick = Number(ints[ints.length - 1]);
|
|
617
|
+
if (Number.isFinite(pick) && pick >= 0) return `${pick}.00`;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
|
|
410
623
|
export async function solvePuzzleWithLLM(challengeText) {
|
|
411
624
|
if (!challengeText) return null;
|
|
412
625
|
const prompt =
|
|
@@ -414,13 +627,16 @@ export async function solvePuzzleWithLLM(challengeText) {
|
|
|
414
627
|
`The text has random capitalization, doubled letters, and stray punctuation — ignore all of that. ` +
|
|
415
628
|
`CRITICAL: ALL symbols (/, *, ^, ~, [, ], etc.) are NOISE, NOT arithmetic operators. ` +
|
|
416
629
|
`The operation is ALWAYS expressed in natural language words only. ` +
|
|
417
|
-
`Extract the numbers (written as words like "thirty five" = 35
|
|
418
|
-
`
|
|
419
|
-
`
|
|
420
|
-
`
|
|
421
|
-
`
|
|
630
|
+
`Extract the numbers (written as words like "thirty five" = 35, or "one hundred forty" = 140), ` +
|
|
631
|
+
`determine the operation from WORDS ONLY ` +
|
|
632
|
+
`(addition: "total", "combined", "and", "plus", "gains", "sum", "together", "increased", "new velocity"; ` +
|
|
633
|
+
`subtraction: "difference", "minus", "less", "loses", "decreased", "reduced"; ` +
|
|
634
|
+
`multiplication: "times", "product", "multiplied", "doubled", "tripled", "squared"; ` +
|
|
635
|
+
`division: "divided by", "ratio", "per", "halved"). ` +
|
|
636
|
+
`If two or more operation words appear, apply them left-to-right in order. ` +
|
|
422
637
|
`If no operation word is found, default to addition. ` +
|
|
423
|
-
`Compute the result and output ONLY the number with exactly 2 decimal places (e.g. "58.00").
|
|
638
|
+
`Compute the result and output ONLY the number with exactly 2 decimal places (e.g. "58.00"). ` +
|
|
639
|
+
`No reasoning, no other text — JUST the number.\n\n` +
|
|
424
640
|
`Puzzle: ${challengeText}`;
|
|
425
641
|
for (const cmd of ["claude", "codex"]) {
|
|
426
642
|
let bin;
|
|
@@ -443,13 +659,44 @@ export async function solvePuzzleWithLLM(challengeText) {
|
|
|
443
659
|
p.on("exit", (code) => (code === 0 ? resolve(out.trim()) : reject(new Error(`exit ${code}`))));
|
|
444
660
|
p.on("error", reject);
|
|
445
661
|
});
|
|
446
|
-
const
|
|
447
|
-
if (
|
|
448
|
-
const intMatch = result.match(/^(\d+)$/m);
|
|
449
|
-
if (intMatch) return `${intMatch[1]}.00`;
|
|
662
|
+
const extracted = extractPuzzleAnswerFromText(result);
|
|
663
|
+
if (extracted) return extracted;
|
|
450
664
|
} catch {
|
|
451
665
|
// try next
|
|
452
666
|
}
|
|
453
667
|
}
|
|
454
668
|
return null;
|
|
455
669
|
}
|
|
670
|
+
|
|
671
|
+
// ---------- Verification history (for offline analysis / retraining) ----------
|
|
672
|
+
//
|
|
673
|
+
// Every verify attempt — whether it succeeded, was rejected by the server,
|
|
674
|
+
// or couldn't be solved at all — gets a single JSONL line appended to
|
|
675
|
+
// `~/.viveworker/moltbook-verify-history.jsonl`. The file is the only way to
|
|
676
|
+
// reconstruct a failing `challenge_text` after the fact, because the puzzle
|
|
677
|
+
// is ephemeral on the Moltbook side (returned once in the POST /posts
|
|
678
|
+
// response and never exposed again).
|
|
679
|
+
//
|
|
680
|
+
// Having the corpus lets us:
|
|
681
|
+
// 1. Diagnose concrete failures with `scripts/test-puzzle-solver.mjs`.
|
|
682
|
+
// 2. Add new regression cases to the test harness when a new template
|
|
683
|
+
// starts appearing.
|
|
684
|
+
// 3. Measure solver vs LLM hit rate over time.
|
|
685
|
+
export const DEFAULT_VERIFY_HISTORY_FILE = path.join(
|
|
686
|
+
os.homedir(), ".viveworker", "moltbook-verify-history.jsonl"
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
export async function recordPuzzleAttempt(entry, file = DEFAULT_VERIFY_HISTORY_FILE) {
|
|
690
|
+
try {
|
|
691
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
692
|
+
const record = {
|
|
693
|
+
ts: new Date().toISOString(),
|
|
694
|
+
...entry,
|
|
695
|
+
};
|
|
696
|
+
await fs.appendFile(file, JSON.stringify(record) + "\n", "utf8");
|
|
697
|
+
} catch (err) {
|
|
698
|
+
// Best-effort logging — never fail a post just because the history file
|
|
699
|
+
// is unwritable.
|
|
700
|
+
try { console.error(`[moltbook-verify-history] Failed to record: ${err.message}`); } catch { /* ignore */ }
|
|
701
|
+
}
|
|
702
|
+
}
|