viveworker 0.6.0 → 0.6.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`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viveworker",
3
- "version": "0.6.0",
3
+ "version": "0.6.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",
@@ -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
+ }
@@ -7,7 +7,7 @@
7
7
  * .html .htm .pdf .png .jpg .jpeg .gif .webp .csv
8
8
  *
9
9
  * Commands:
10
- * viveworker share upload <file> [--password <pw>] [--expires-days <n>] [--json]
10
+ * viveworker share upload <file> [--password <pw>] [--expires-days <n>] [--no-optimize] [--json]
11
11
  * viveworker share list [--json]
12
12
  * viveworker share update <slug> [--password <pw>] [--no-password] [--expires-days <n>] [--json]
13
13
  * viveworker share link <slug> --password <pw> [--ttl-hours <n>] [--json]
@@ -71,7 +71,7 @@ export async function runShareCli(args) {
71
71
 
72
72
  function printHelp() {
73
73
  console.log("Commands:");
74
- console.log(" viveworker share upload <file> [--password <pw>] [--expires-days <n>] [--json]");
74
+ console.log(" viveworker share upload <file> [--password <pw>] [--expires-days <n>] [--no-optimize] [--json]");
75
75
  console.log(" viveworker share list [--json]");
76
76
  console.log(" viveworker share update <slug> [--password <pw>] [--no-password] [--expires-days <n>] [--json]");
77
77
  console.log(" viveworker share link <slug> --password <pw> [--ttl-hours <n>] [--json]");
@@ -79,6 +79,7 @@ function printHelp() {
79
79
  console.log("");
80
80
  console.log(`Accepted file types: ${ALLOWED_EXTENSIONS.join(" / ")}`);
81
81
  console.log("CSV files are rendered as an HTML table on view; append ?raw=1 for bytes.");
82
+ console.log("HTML uploads are optimized by default when possible (use --no-optimize to disable).");
82
83
  console.log("");
83
84
  console.log("Credentials are read from ~/.viveworker/a2a.env (same as `viveworker a2a`).");
84
85
  }
@@ -107,9 +108,6 @@ async function handleUpload(args) {
107
108
  if (stat.size === 0) {
108
109
  throw new Error(`File is empty: ${filePath}`);
109
110
  }
110
- if (stat.size > MAX_FILE_SIZE) {
111
- throw new Error(`File too large (${stat.size} bytes, max ${MAX_FILE_SIZE})`);
112
- }
113
111
  const ext = path.extname(absolute).toLowerCase();
114
112
  if (!ALLOWED_EXTENSIONS.includes(ext)) {
115
113
  throw new Error(
@@ -134,7 +132,18 @@ async function handleUpload(args) {
134
132
 
135
133
  const { apiKey, userId, shareUrl } = await resolveCredentials();
136
134
 
137
- const bytes = await fs.readFile(absolute);
135
+ const originalBytes = await fs.readFile(absolute);
136
+ const optimization =
137
+ flags["no-optimize"] || flags["noOptimize"]
138
+ ? { bytes: originalBytes, info: null }
139
+ : maybeOptimizeUpload({ absolute, ext, bytes: originalBytes });
140
+ const bytes = optimization.bytes;
141
+ if (bytes.length > MAX_FILE_SIZE) {
142
+ const detail = optimization.info
143
+ ? ` after optimization to ${formatSize(bytes.length)}`
144
+ : "";
145
+ throw new Error(`File too large (${originalBytes.length} bytes, max ${MAX_FILE_SIZE})${detail}`);
146
+ }
138
147
  const form = new FormData();
139
148
  const blob = new Blob([bytes], { type: mime });
140
149
  const file = new File([blob], path.basename(absolute), { type: mime });
@@ -164,6 +173,15 @@ async function handleUpload(args) {
164
173
  console.log("");
165
174
  console.log(`✅ Uploaded ${body.originalName || path.basename(absolute)} (${formatSize(body.size)})`);
166
175
  console.log("");
176
+ if (optimization.info) {
177
+ console.log(
178
+ ` Optimized HTML: ${formatSize(optimization.info.originalBytes)} → ${formatSize(optimization.info.optimizedBytes)}`
179
+ );
180
+ console.log(
181
+ ` Removed embedded fonts: ${optimization.info.removedFonts}`
182
+ );
183
+ console.log("");
184
+ }
167
185
  console.log(` ${body.url}`);
168
186
  console.log("");
169
187
  if (body.hasPassword) console.log(` 🔒 Password-protected`);
@@ -179,6 +197,97 @@ async function handleUpload(args) {
179
197
  console.log("");
180
198
  }
181
199
 
200
+ function maybeOptimizeUpload({ absolute, ext, bytes }) {
201
+ if (ext !== ".html" && ext !== ".htm") {
202
+ return { bytes, info: null };
203
+ }
204
+
205
+ const source = bytes.toString("utf8");
206
+ const optimized = optimizeBundledStandaloneHtml(source);
207
+ if (!optimized) {
208
+ return { bytes, info: null };
209
+ }
210
+
211
+ const optimizedBytes = Buffer.from(optimized.html, "utf8");
212
+ if (optimizedBytes.length >= bytes.length) {
213
+ return { bytes, info: null };
214
+ }
215
+
216
+ return {
217
+ bytes: optimizedBytes,
218
+ info: {
219
+ kind: "bundled-standalone-html",
220
+ removedFonts: optimized.removedFonts,
221
+ originalBytes: bytes.length,
222
+ optimizedBytes: optimizedBytes.length,
223
+ file: absolute,
224
+ },
225
+ };
226
+ }
227
+
228
+ function optimizeBundledStandaloneHtml(source) {
229
+ const manifestTagPattern = /(<script\b[^>]*type="__bundler\/manifest"[^>]*>)([\s\S]*?)(<\/script>)/i;
230
+ const templateTagPattern = /(<script\b[^>]*type="__bundler\/template"[^>]*>)([\s\S]*?)(<\/script>)/i;
231
+ const manifestMatch = source.match(manifestTagPattern);
232
+ const templateMatch = source.match(templateTagPattern);
233
+ if (!manifestMatch || !templateMatch) {
234
+ return null;
235
+ }
236
+
237
+ let manifest;
238
+ let template;
239
+ try {
240
+ manifest = JSON.parse(manifestMatch[2]);
241
+ template = JSON.parse(templateMatch[2]);
242
+ } catch {
243
+ return null;
244
+ }
245
+
246
+ const fontUuids = Object.entries(manifest)
247
+ .filter(([, entry]) => entry?.mime === "font/woff2")
248
+ .map(([uuid]) => uuid);
249
+ if (fontUuids.length === 0) {
250
+ return null;
251
+ }
252
+
253
+ for (const uuid of fontUuids) {
254
+ delete manifest[uuid];
255
+ const escapedUuid = escapeRegExp(uuid);
256
+ template = template.replace(
257
+ new RegExp(`@font-face\\s*\\{[^{}]*${escapedUuid}[^{}]*\\}`, "g"),
258
+ "",
259
+ );
260
+ template = template.split(uuid).join("");
261
+ }
262
+
263
+ let html = source.replace(
264
+ manifestTagPattern,
265
+ (_, openTag, _json, closeTag) => `${openTag}${stringifyForHtmlScriptTag(manifest)}${closeTag}`,
266
+ );
267
+ html = html.replace(
268
+ templateTagPattern,
269
+ (_, openTag, _json, closeTag) => `${openTag}${stringifyForHtmlScriptTag(template)}${closeTag}`,
270
+ );
271
+
272
+ return {
273
+ html,
274
+ removedFonts: fontUuids.length,
275
+ };
276
+ }
277
+
278
+ function escapeRegExp(value) {
279
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
280
+ }
281
+
282
+ function stringifyForHtmlScriptTag(value) {
283
+ return JSON.stringify(value)
284
+ .replace(/</g, "\\u003C")
285
+ .replace(/>/g, "\\u003E")
286
+ .replace(/&/g, "\\u0026")
287
+ .replace(/\u2028/g, "\\u2028")
288
+ .replace(/\u2029/g, "\\u2029");
289
+ }
290
+
182
291
  // ---------------------------------------------------------------------------
183
292
  // list
184
293
  // ---------------------------------------------------------------------------
@@ -18,7 +18,7 @@ import { generatePairingCredentials, shouldRotatePairing, upsertEnvText } from "
18
18
  import { renderMarkdownHtml } from "./lib/markdown-render.mjs";
19
19
  import { buildAgentCard, handleA2ARequest, resolveA2ATaskDecision, completeA2ATask, failA2ATask } from "./a2a-handler.mjs";
20
20
  import { registerWithRelay, startRelayPolling, stopRelayPolling, postRelayResult, getRelayStatus, updatePublicTasksFlag } from "./a2a-relay-client.mjs";
21
- import { createMoltbookClient, readScoutState, writeScoutState, rollScoutDayIfNeeded, markPostSeen, recordComposeAttempt, writeDraft, readDraft, deleteDraft, listPendingDrafts, solveVerificationPuzzle, solvePuzzleWithLLM, listInboxItems } from "./moltbook-api.mjs";
21
+ import { createMoltbookClient, readScoutState, writeScoutState, rollScoutDayIfNeeded, markPostSeen, recordComposeAttempt, writeDraft, readDraft, deleteDraft, listPendingDrafts, solveVerificationPuzzle, solvePuzzleWithLLM, recordPuzzleAttempt, listInboxItems } from "./moltbook-api.mjs";
22
22
 
23
23
  const __filename = fileURLToPath(import.meta.url);
24
24
  const __dirname = path.dirname(__filename);
@@ -15038,6 +15038,12 @@ async function executeMoltbookDraftPost(draft, config, runtime, state) {
15038
15038
  const finalTitle = draft.decision.title || draft.postTitle;
15039
15039
 
15040
15040
  let verification;
15041
+ // Captured so the post-verify history record knows which post/comment the
15042
+ // challenge_text was attached to. `draft.postId` only exists for replies;
15043
+ // for `original_post` drafts the id only shows up in the POST /posts
15044
+ // response.
15045
+ let createdPostId = null;
15046
+ let createdCommentId = null;
15041
15047
 
15042
15048
  if (draft.draftType === "original_post") {
15043
15049
  const result = await mb("/posts", {
@@ -15051,6 +15057,7 @@ async function executeMoltbookDraftPost(draft, config, runtime, state) {
15051
15057
  });
15052
15058
  const post = result?.post || null;
15053
15059
  verification = post?.verification || null;
15060
+ createdPostId = post?.id || null;
15054
15061
  console.log(`[moltbook-draft-post] Posted original post (id=${post?.id})`);
15055
15062
 
15056
15063
  const scoutState = rollScoutDayIfNeeded(await readScoutState());
@@ -15066,6 +15073,8 @@ async function executeMoltbookDraftPost(draft, config, runtime, state) {
15066
15073
  });
15067
15074
  const comment = result?.comment || null;
15068
15075
  verification = comment?.verification || null;
15076
+ createdPostId = draft.postId || null;
15077
+ createdCommentId = comment?.id || null;
15069
15078
  console.log(`[moltbook-draft-post] Posted reply (commentId=${comment?.id}) to post ${draft.postId}`);
15070
15079
 
15071
15080
  const scoutState = rollScoutDayIfNeeded(await readScoutState());
@@ -15080,41 +15089,79 @@ async function executeMoltbookDraftPost(draft, config, runtime, state) {
15080
15089
  }
15081
15090
 
15082
15091
  // Solve verification puzzle if present.
15092
+ //
15093
+ // Every attempt — success, server rejection, or complete failure — gets
15094
+ // recorded via `recordPuzzleAttempt` so we can build a corpus of
15095
+ // challenge_text at `~/.viveworker/moltbook-verify-history.jsonl`. This is
15096
+ // the only way to reconstruct failing puzzles; Moltbook only returns
15097
+ // `challenge_text` in the initial POST response.
15083
15098
  if (verification) {
15084
- let answer = solveVerificationPuzzle(verification.challenge_text);
15085
- const source = answer != null ? "solver" : null;
15086
- if (answer == null) {
15087
- answer = await solvePuzzleWithLLM(verification.challenge_text);
15099
+ const solverAnswer = solveVerificationPuzzle(verification.challenge_text);
15100
+ let submittedAnswer = solverAnswer;
15101
+ let llmAnswer = null;
15102
+ let outcome = "unknown";
15103
+ let verifyError = null;
15104
+
15105
+ if (submittedAnswer == null) {
15106
+ llmAnswer = await solvePuzzleWithLLM(verification.challenge_text);
15107
+ submittedAnswer = llmAnswer;
15088
15108
  }
15089
- if (answer != null) {
15109
+
15110
+ if (submittedAnswer != null) {
15090
15111
  try {
15091
15112
  await mb("/verify", {
15092
15113
  method: "POST",
15093
- body: JSON.stringify({ verification_code: verification.verification_code, answer }),
15114
+ body: JSON.stringify({ verification_code: verification.verification_code, answer: submittedAnswer }),
15094
15115
  });
15095
- console.log(`[moltbook-draft-verify] Verified with answer ${answer}`);
15096
- } catch (verifyError) {
15116
+ console.log(`[moltbook-draft-verify] Verified with answer ${submittedAnswer}`);
15117
+ outcome = solverAnswer != null && llmAnswer == null ? "solver-verified" : "llm-verified";
15118
+ } catch (err) {
15119
+ verifyError = err.message;
15097
15120
  // Wrong answer from solver — retry with LLM.
15098
- if (/incorrect/i.test(verifyError.message) && source === "solver") {
15099
- const llmAnswer = await solvePuzzleWithLLM(verification.challenge_text);
15100
- if (llmAnswer && llmAnswer !== answer) {
15121
+ if (/incorrect/i.test(err.message) && solverAnswer != null && llmAnswer == null) {
15122
+ llmAnswer = await solvePuzzleWithLLM(verification.challenge_text);
15123
+ if (llmAnswer && llmAnswer !== solverAnswer) {
15101
15124
  try {
15102
15125
  await mb("/verify", {
15103
15126
  method: "POST",
15104
15127
  body: JSON.stringify({ verification_code: verification.verification_code, answer: llmAnswer }),
15105
15128
  });
15106
15129
  console.log(`[moltbook-draft-verify] Verified with LLM retry answer ${llmAnswer}`);
15130
+ submittedAnswer = llmAnswer;
15131
+ verifyError = null;
15132
+ outcome = "solver-incorrect-llm-verified";
15107
15133
  } catch (retryError) {
15108
15134
  console.error(`[moltbook-draft-verify] LLM retry failed: ${retryError.message}`);
15135
+ verifyError = retryError.message;
15136
+ outcome = "both-incorrect";
15109
15137
  }
15138
+ } else {
15139
+ outcome = "solver-incorrect-llm-agreed-or-null";
15110
15140
  }
15111
15141
  } else {
15112
- console.error(`[moltbook-draft-verify] Failed: ${verifyError.message}`);
15142
+ console.error(`[moltbook-draft-verify] Failed: ${err.message}`);
15143
+ outcome = solverAnswer != null ? "solver-rejected" : "llm-rejected";
15113
15144
  }
15114
15145
  }
15115
15146
  } else {
15116
15147
  console.error(`[moltbook-draft-verify] Could not solve puzzle for draft ${draft.token}`);
15117
- }
15148
+ outcome = "both-failed-to-solve";
15149
+ }
15150
+
15151
+ // Best-effort corpus write. Never throws.
15152
+ await recordPuzzleAttempt({
15153
+ draftToken: draft.token,
15154
+ draftType: draft.draftType,
15155
+ postId: createdPostId,
15156
+ commentId: createdCommentId,
15157
+ challenge_text: verification.challenge_text,
15158
+ verification_code: verification.verification_code,
15159
+ solverAnswer,
15160
+ llmAnswer,
15161
+ submittedAnswer,
15162
+ outcome,
15163
+ verifyError,
15164
+ });
15118
15165
  }
15119
15166
 
15120
15167
  // Push notification for successful post.
@@ -64,6 +64,17 @@ if (rawArgs[0] === "share") {
64
64
  }
65
65
  }
66
66
 
67
+ if (rawArgs[0] === "stats") {
68
+ const { runStatsCli } = await import("./stats-cli.mjs");
69
+ try {
70
+ await runStatsCli(rawArgs.slice(1));
71
+ process.exit(0);
72
+ } catch (error) {
73
+ console.error(error.message || String(error));
74
+ process.exit(1);
75
+ }
76
+ }
77
+
67
78
  const cli = parseArgs(rawArgs);
68
79
 
69
80
  try {