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 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.0",
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
  },
@@ -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. Handles the obfuscated two-number
317
- // arithmetic Moltbook currently uses (add / subtract / multiply). Returns
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
- const cleaned = String(challengeText)
322
- .replace(/[^a-zA-Z0-9\s]/g, " ")
323
- .toLowerCase();
324
- const numberWords = {
325
- zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9,
326
- ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16,
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
- const operationWords = new Set([
336
- "total", "combined", "force", "velocity", "speed", "gains", "plus", "and",
337
- "subtract", "minus", "less", "difference", "decreased", "loses", "lost", "slower", "slows", "slowed",
338
- "multiply", "times", "product", "multiplied",
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 (isKnown(candidate) || isKnown(collapseRuns(candidate))) {
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
- const words = merged.map((w) => {
360
- if (/^\d+$/.test(w)) return w;
361
- if (numberWords[w] != null) return w;
362
- const collapsed = collapseRuns(w);
363
- if (numberWords[collapsed] != null) return collapsed;
364
- return collapsed;
365
- });
366
- const numbers = [];
367
- let i = 0;
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
- numbers.push(Number(w));
372
- i += 1;
440
+ tokens.push({ type: "num", value: Number(w) });
441
+ j += 1;
373
442
  continue;
374
443
  }
375
- if (numberWords[w] != null) {
376
- let total = numberWords[w];
377
- i += 1;
378
- while (i < words.length && numberWords[words[i]] != null) {
379
- const next = numberWords[words[i]];
380
- if (next === 100) total *= 100;
381
- else if (next < 100 && total < 100) total += next;
382
- else break;
383
- i += 1;
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
- numbers.push(total);
480
+ if (consumed) tokens.push({ type: "num", value: sum + chunk });
386
481
  continue;
387
482
  }
388
- i += 1;
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
- if (numbers.length < 2) return null;
391
- const a = numbers[0];
392
- const b = numbers[1];
393
- const hasWord = (w) => words.includes(w);
394
- const hasAny = (...ws) => ws.some(hasWord);
395
- let result;
396
- if (hasAny("subtract", "minus", "less", "difference", "decreased", "loses", "lost", "slower", "slows", "slowed")) {
397
- result = a - b;
398
- } else if (hasAny("multiply", "times", "product", "multiplied")) {
399
- result = a * b;
400
- } else if (hasAny("divide", "divided", "ratio")) {
401
- result = b !== 0 ? a / b : a;
402
- } else {
403
- result = a + b;
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), determine the operation from WORDS ONLY ` +
418
- `(addition: "total", "combined", "and", "plus", "gains", "new velocity"; ` +
419
- `subtraction: "difference", "minus", "less", "loses"; ` +
420
- `multiplication: "times", "product", "multiplied"; ` +
421
- `division: "divided by", "ratio", "per"). ` +
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"). No other text.\n\n` +
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 match = result.match(/(\d+\.\d{2})/);
447
- if (match) return match[1];
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
+ }