@officexapp/vidfarm-devcli 0.21.59 → 0.21.61

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.
@@ -44,6 +44,18 @@ CLIENT (paid vidfarm plan — the key is read from your vidfarm account)
44
44
  gigs machines Your two standing machines + gig ids + invite links
45
45
  gigs tasks [machine] What is waiting in the Custom Requests queue
46
46
  gigs add-task --task "<brief>" Post a task into Custom Requests
47
+
48
+ BID AUCTION — let clippers name the price instead of taking yours
49
+ gigs request-bids --task "<brief>" Post a task NOBODY can claim; they bid in the
50
+ comments. --sealed hides bids from each other,
51
+ --bell broadcasts it, --opening "$0.10".
52
+ Prints a share link that carries a gig invite,
53
+ so a clipper who never joined can join AND bid
54
+ on that one page. Price stays TBD.
55
+ gigs bids <task-id> The bids on it, newest thread first
56
+ gigs award <task-id> --bid <id> Reserve that SAME task for the winner and tell
57
+ them privately. Losing bidders see nothing.
58
+ Then: gigs approve <proof-id> --amount <usd>
47
59
  --price <usd> Price per delivered video (default 0.25; "tbd" allowed)
48
60
  --tags a,b --priority <n> Routing hints (lower priority polls sooner)
49
61
  --angle/--hook/--url/--format The usual brief fields
@@ -54,7 +66,10 @@ CLIENT (paid vidfarm plan — the key is read from your vidfarm account)
54
66
  --body <file.json> Send this JSON as the whole task instead
55
67
  gigs proofs [machine] Submitted videos, newest first
56
68
  --status <pending|approved|rejected> --limit <n>
57
- gigs approve <proof-id> [machine] [--feedback "<text>"]
69
+ gigs approve <proof-id> [machine] [--amount <usd>] [--feedback "<text>"]
70
+ --amount names the price for a TBD task (what
71
+ you agreed in the bid thread). Omit it on a
72
+ TBD proof and it pays the machine's base rate.
58
73
  gigs reject <proof-id> [machine] [--tag not_selected] [--feedback "<text>"]
59
74
  not_selected = a pass; it costs the worker NO reputation
60
75
  gigs ring-bell --title "<t>" [--subtext "<s>"] [--machine inbound_offers]
@@ -584,6 +599,178 @@ async function cmdAddTask(auth, values) {
584
599
  console.log(` ${DIM}Nobody knows yet — broadcast it: vidfarm gigs ring-bell --title "…" --machine custom_requests${RESET}`);
585
600
  });
586
601
  }
602
+ // ── The bid auction ─────────────────────────────────────────────────────────
603
+ // A normal Custom Requests task is claimed by whoever polls first, at the
604
+ // machine's price. A BID task inverts that: it is published `view_only` so
605
+ // nobody can claim it, clippers post their price in the comment thread, and the
606
+ // buyer awards one by flipping that SAME task to `reserved`. One record carries
607
+ // the whole lifecycle, so the winner reads the entire negotiation on the task
608
+ // they are about to do.
609
+ //
610
+ // The price stays `tbd` throughout. The deal is plain text in the thread, and
611
+ // the real number is named once, at approval (`gigs approve --amount`), because
612
+ // arrangements ("$5 now $5 on completion", "$12 for three") do not fit a float.
613
+ /**
614
+ * The share link for a bid task. `/task/:gig/:id` is MEMBERS ONLY — Dollar
615
+ * Platoon shows a stranger the gig's join link instead of the task, and it
616
+ * ignores ?invite=. Only `/claim/:gig/:id?invite=<token>` admits a non-member:
617
+ * the "you have not joined" panel becomes "you are invited" and joining returns
618
+ * them to this task. A view-only task simply renders without the Accept button
619
+ * — the brief plus the comment thread — which is exactly the bidding page.
620
+ */
621
+ function bidTaskUrl(gigId, taskId, inviteUrl) {
622
+ let token = "";
623
+ try {
624
+ token = new URL(inviteUrl).searchParams.get("invite") ?? "";
625
+ }
626
+ catch {
627
+ token = "";
628
+ }
629
+ const base = `https://dollarplatoon.com/${token ? "claim" : "task"}`
630
+ + `/${encodeURIComponent(gigId)}/${encodeURIComponent(taskId)}`;
631
+ return token ? `${base}?invite=${encodeURIComponent(token)}` : base;
632
+ }
633
+ async function cmdRequestBids(auth, values) {
634
+ const task = values.task?.trim();
635
+ if (!task)
636
+ throw new Error('gigs request-bids needs --task "<what you want made>".');
637
+ const gigId = await resolveGigId(auth, values.machine, "custom_requests");
638
+ const gig = await dp(auth, `/gigs/${encodeURIComponent(gigId)}`);
639
+ const webhook = gig.gig?.webhook ?? gig.webhook ?? "";
640
+ if (!webhook)
641
+ throw new Error("That machine has no publisher webhook — open it on dollarplatoon.com.");
642
+ // --sealed => each clipper reads only their own thread (no undercutting).
643
+ // Default is open: bidders see each other, which is fine because discovery
644
+ // runs UPWARD from the opening bid, unlike a procurement reverse auction.
645
+ const policy = values.sealed ? "private" : "public";
646
+ const url = new URL(webhook);
647
+ url.searchParams.set("availability", "view_only");
648
+ url.searchParams.set("price", "tbd");
649
+ url.searchParams.set("tags", String(values.tags ?? "vidfarm,bid"));
650
+ const published = await fetch(url, {
651
+ method: "POST",
652
+ headers: { "content-type": "application/json" },
653
+ body: JSON.stringify({
654
+ task,
655
+ ...(values.url ? { url: String(values.url) } : {}),
656
+ ...(values.reference ? { reference_title: String(values.reference) } : {}),
657
+ starting_bid: String(values.opening ?? "$0.10"),
658
+ bidding: policy === "public" ? "open — reply with your price" : "sealed — your reply is private"
659
+ })
660
+ });
661
+ const publishText = await published.text();
662
+ let publishBody = {};
663
+ try {
664
+ publishBody = publishText ? JSON.parse(publishText) : {};
665
+ }
666
+ catch {
667
+ publishBody = {};
668
+ }
669
+ if (!published.ok)
670
+ throw new Error(`request-bids failed: ${publishBody?.error ?? `HTTP ${published.status}`}`);
671
+ // ONE task answers `message_id` (a string); only a batch answers `message_ids`.
672
+ const taskId = publishBody.message_id ?? publishBody.message_ids?.[0] ?? "";
673
+ if (!taskId)
674
+ throw new Error("Dollar Platoon accepted the task but returned no task id.");
675
+ await dp(auth, `/gigs/${encodeURIComponent(gigId)}/tasks/${encodeURIComponent(taskId)}/comments-policy`, {
676
+ method: "PATCH",
677
+ body: { policy }
678
+ });
679
+ const shareUrl = bidTaskUrl(gigId, taskId, await resolveInviteUrl(auth, gigId).catch(() => ""));
680
+ if (values.bell) {
681
+ await dp(auth, `/feeds/${VIDFARM_FEED_ID}/notifications`, {
682
+ method: "POST",
683
+ body: {
684
+ title: `Video wanted: ${task.slice(0, 120)}`,
685
+ subtext: `Bidding is ${policy === "public" ? "open" : "sealed"}. Opening bid ${values.opening ?? "$0.10"}.`,
686
+ destination_url: shareUrl,
687
+ tags: ["vidfarm", "video", "bid"]
688
+ }
689
+ }).catch((error) => {
690
+ console.log(`${YELLOW}!${RESET} Task posted, but the bell did not ring: ${error instanceof Error ? error.message : error}`);
691
+ });
692
+ }
693
+ out(Boolean(values.json), { gig_id: gigId, task_id: taskId, share_url: shareUrl, comments_policy: policy }, () => {
694
+ console.log(`${GREEN}✓${RESET} Bid request posted — ${BOLD}nobody can claim it${RESET}, they bid in the comments.`);
695
+ console.log(` ${BOLD}${taskId}${RESET} ${DIM}${policy === "public" ? "open bidding" : "sealed bids"} · price TBD${RESET}`);
696
+ console.log(` ${BOLD}Share this:${RESET} ${shareUrl}`);
697
+ console.log(` ${DIM}That link carries a gig invite — a clipper who never joined can join AND bid on that one page.${RESET}`);
698
+ if (!values.bell)
699
+ console.log(` ${DIM}Nobody was notified. Add --bell to broadcast it to the vidfarm feed.${RESET}`);
700
+ console.log(` ${DIM}Next: vidfarm gigs bids ${taskId}${RESET}`);
701
+ });
702
+ }
703
+ async function cmdBids(auth, values, positionals) {
704
+ const taskId = positionals[0];
705
+ if (!taskId)
706
+ throw new Error("gigs bids needs a task id: vidfarm gigs bids TASK_01H…");
707
+ const gigId = await resolveGigId(auth, values.machine, "custom_requests");
708
+ const body = await dp(auth, `/gigs/${encodeURIComponent(gigId)}/tasks/${encodeURIComponent(taskId)}/comments`);
709
+ const comments = body.comments ?? body.items ?? [];
710
+ // Root comments are the bids; replies underneath are the negotiation.
711
+ const bids = comments.filter((c) => !c.parent_id);
712
+ out(Boolean(values.json), { gig_id: gigId, task_id: taskId, bids }, () => {
713
+ if (!bids.length) {
714
+ console.log(`${DIM}No bids yet on ${taskId}.${RESET}`);
715
+ console.log(` ${DIM}Nobody may know it exists — vidfarm gigs request-bids … --bell, or send them the share link.${RESET}`);
716
+ return;
717
+ }
718
+ for (const bid of bids) {
719
+ // author_mailbox_id comes back ONLY to the gig owner, and only on a
720
+ // WORKER's comment — it is what `gigs award` reserves the task for.
721
+ const who = bid.author_name ?? "gigworker";
722
+ console.log(` ${BOLD}${bid.id}${RESET} ${DIM}${who}${bid.author_mailbox_id ? "" : " (no mailbox — cannot be awarded)"}${RESET}`);
723
+ console.log(` ${short(String(bid.body ?? ""), 110)}`);
724
+ }
725
+ console.log("");
726
+ console.log(`${DIM}Award one: vidfarm gigs award ${taskId} --bid <comment-id>${RESET}`);
727
+ });
728
+ }
729
+ async function cmdAward(auth, values, positionals) {
730
+ const taskId = positionals[0];
731
+ const bidId = values.bid?.trim();
732
+ if (!taskId)
733
+ throw new Error("gigs award needs a task id: vidfarm gigs award TASK_01H… --bid TCOMMENT_01H…");
734
+ if (!bidId)
735
+ throw new Error('gigs award needs --bid <comment-id>. See them with: vidfarm gigs bids <task-id>');
736
+ const gigId = await resolveGigId(auth, values.machine, "custom_requests");
737
+ const body = await dp(auth, `/gigs/${encodeURIComponent(gigId)}/tasks/${encodeURIComponent(taskId)}/comments`);
738
+ const comments = body.comments ?? body.items ?? [];
739
+ const winner = comments.find((c) => c.id === bidId);
740
+ if (!winner)
741
+ throw new Error(`No bid ${bidId} on ${taskId}. List them: vidfarm gigs bids ${taskId}`);
742
+ const mailbox = winner.author_mailbox_id ?? "";
743
+ if (!mailbox) {
744
+ throw new Error("Dollar Platoon did not identify that bidder. Only the gig OWNER sees a commenter's mailbox — check you are using the buyer's key, not a gigworker key.");
745
+ }
746
+ // The SAME task flips from view_only to reserved. No new task, so the winner
747
+ // reads the whole negotiation on the record they are about to work.
748
+ await dp(auth, `/gigs/${encodeURIComponent(gigId)}/tasks/${encodeURIComponent(taskId)}/availability`, {
749
+ method: "PATCH",
750
+ body: { availability: "reserved", reserved_for: mailbox }
751
+ });
752
+ const shareUrl = bidTaskUrl(gigId, taskId, await resolveInviteUrl(auth, gigId).catch(() => ""));
753
+ // A private reply has exactly two readers — its author and the person it
754
+ // answers — so the losing bidders never see the agreed terms.
755
+ await dp(auth, `/gigs/${encodeURIComponent(gigId)}/tasks/${encodeURIComponent(taskId)}/comments`, {
756
+ method: "POST",
757
+ body: {
758
+ body: values.message?.trim()
759
+ || `You won this one. It is reserved for you — accept it here: ${shareUrl}`,
760
+ parent_id: bidId,
761
+ private: true
762
+ }
763
+ });
764
+ out(Boolean(values.json), { gig_id: gigId, task_id: taskId, reserved_for: mailbox, share_url: shareUrl }, () => {
765
+ console.log(`${GREEN}✓${RESET} ${BOLD}${taskId}${RESET} reserved for ${BOLD}${winner.author_name ?? mailbox}${RESET}.`);
766
+ console.log(` ${DIM}They were told privately. The other bidders saw nothing.${RESET}`);
767
+ console.log(` ${DIM}${shareUrl}${RESET}`);
768
+ console.log("");
769
+ console.log(`${YELLOW}The task is still priced TBD${RESET}${DIM} — name the agreed amount when you approve their proof:${RESET}`);
770
+ console.log(` ${DIM}vidfarm gigs approve <proof-id> --amount 8.00${RESET}`);
771
+ console.log(` ${DIM}Approve without --amount and it silently pays the machine's base rate, not what you agreed.${RESET}`);
772
+ });
773
+ }
587
774
  async function cmdProofs(auth, values, positional) {
588
775
  const gigId = await resolveGigId(auth, positional, "inbound_offers");
589
776
  const body = await dp(auth, `/gigs/${encodeURIComponent(gigId)}/proofs`, {
@@ -617,10 +804,34 @@ async function cmdReview(auth, values, positionals, action) {
617
804
  body.feedback = String(values.feedback);
618
805
  if (action === "reject")
619
806
  body.rejection_tag = values.tag ? String(values.tag) : "not_selected";
807
+ // --amount names the price at APPROVAL. This is the only moment a TBD task
808
+ // gets its real number, and a bid task is TBD by design: the deal was agreed
809
+ // in plain text in the comment thread, not on the record. Dollar Platoon
810
+ // accepts an amount only while `locked_price` is null (a TBD proof, or one
811
+ // carrying the worker's own asking_price) — anything else answers 409, since
812
+ // a price a worker already agreed to can never be lowered at review.
813
+ //
814
+ // "TBD is not $0": approving a TBD proof with NO amount silently falls back
815
+ // to the gig's base rate, so an $8 bid pays $0.25 and the price locks on the
816
+ // spot with no way back. Warn loudly rather than let that pass unnoticed.
817
+ let amount = null;
818
+ if (action === "approve" && values.amount !== undefined && String(values.amount).trim() !== "") {
819
+ const parsed = Number(String(values.amount).trim().replace(/^\$/, ""));
820
+ if (!Number.isFinite(parsed) || parsed < 0) {
821
+ throw new Error(`--amount must be a positive number of dollars, got "${values.amount}".`);
822
+ }
823
+ amount = Math.round(parsed * 100) / 100;
824
+ body.amount = amount;
825
+ }
620
826
  const result = await dp(auth, `/gigs/${encodeURIComponent(gigId)}/proofs/${encodeURIComponent(proofId)}`, { method: "PATCH", body });
621
827
  out(Boolean(values.json), result, () => {
622
828
  if (action === "approve") {
623
- console.log(`${GREEN}✓${RESET} Approved ${BOLD}${proofId}${RESET} it pays out on the next rollup.`);
829
+ const locked = typeof result?.locked_price === "number" ? result.locked_price : null;
830
+ console.log(`${GREEN}✓${RESET} Approved ${BOLD}${proofId}${RESET}${locked != null ? ` at ${BOLD}$${locked.toFixed(2)}${RESET}` : ""} — it pays out on the next rollup.`);
831
+ if (amount == null && locked != null) {
832
+ console.log(` ${DIM}No --amount given, so it locked at the machine's own price.${RESET}`);
833
+ console.log(` ${YELLOW}If this came from a bid, that is NOT what you agreed${RESET}${DIM} — bid tasks are priced TBD and the number lives in the comment thread. Use --amount <usd>.${RESET}`);
834
+ }
624
835
  }
625
836
  else {
626
837
  console.log(`${GREEN}✓${RESET} Rejected ${BOLD}${proofId}${RESET} as ${BOLD}${body.rejection_tag}${RESET}.`);
@@ -1430,6 +1641,14 @@ function options() {
1430
1641
  tag: { type: "string" },
1431
1642
  title: { type: "string" },
1432
1643
  subtext: { type: "string" },
1644
+ // Bid auction (request-bids / bids / award).
1645
+ sealed: { type: "boolean" },
1646
+ bell: { type: "boolean" },
1647
+ opening: { type: "string" },
1648
+ reference: { type: "string" },
1649
+ bid: { type: "string" },
1650
+ message: { type: "string" },
1651
+ amount: { type: "string" },
1433
1652
  registry: { type: "boolean" },
1434
1653
  funded: { type: "boolean" },
1435
1654
  name: { type: "string" },
@@ -1471,6 +1690,11 @@ export async function runGigsCommand(argv) {
1471
1690
  case "queue": return cmdTasks(auth, values, rest[0]);
1472
1691
  case "add-task":
1473
1692
  case "task": return cmdAddTask(auth, values);
1693
+ case "request-bids":
1694
+ case "request":
1695
+ case "invite-bids": return cmdRequestBids(auth, values);
1696
+ case "bids": return cmdBids(auth, values, rest);
1697
+ case "award": return cmdAward(auth, values, rest);
1474
1698
  case "proofs":
1475
1699
  case "submissions": return cmdProofs(auth, values, rest[0]);
1476
1700
  case "approve":
@@ -117,6 +117,8 @@ export const PACK_TOPICS = [
117
117
  blurb: "The 5-stage ladder — what the viewer knows, what the video must do, and what it may ask for" },
118
118
  { topic: "problem-angles", aliases: ["angle", "lenses", "problem-angle"], doc: "references/content-ideas.md", heading: "The problem angles",
119
119
  blurb: "44 angles on the problem — hold the frame, change the angle when a topic is \"already covered\"" },
120
+ { topic: "ad-formats", aliases: ["ad-format", "formats", "format", "executions"], doc: "references/content-ideas.md", heading: "The ad formats",
121
+ blurb: "40 ad formats — what the finished video IS (iphone notes, tier list, trustpilot reviews, warning), and the honesty rule for the ones that imitate a record" },
120
122
  { topic: "meme-recaption", // `meme_recaption` needs no alias: resolvePackTopic folds `_` to `-` first.
121
123
  aliases: ["meme", "recaption", "meme-caption"], doc: "references/editor-workflows.md", heading: "Writing a meme recaption",
122
124
  blurb: "Recaption a meme at a pain or a win the niche knows — the cold-viewer test. Building one from scratch? the full format is vidfarm.cc/experimental/meme-recaption.md" },
@@ -248,6 +250,18 @@ export function loadAngleBank(name = DEFAULT_PACK) {
248
250
  const { frames, families } = parseBulletBank(contents, "The problem angles", true);
249
251
  return { angles: frames, families };
250
252
  }
253
+ /**
254
+ * The ad-format bank — the FOURTH axis. A frame says what the video is the
255
+ * story of and an angle says which side of the problem it comes from; a format
256
+ * says what the finished thing IS (an iPhone note, a tier list, a warning
257
+ * label). Left unnamed, a director shoots the same talking head all month, so
258
+ * it belongs next to the other banks rather than in the prose nobody reads.
259
+ */
260
+ export function loadAdFormatBank(name = DEFAULT_PACK) {
261
+ const { contents } = readPackDoc("references/content-ideas.md", name);
262
+ const { frames, families } = parseBulletBank(contents, "The ad formats", true);
263
+ return { formats: frames, families };
264
+ }
251
265
  export function loadAwarenessLadder(name = DEFAULT_PACK) {
252
266
  const { contents } = readPackDoc("references/content-ideas.md", name);
253
267
  const section = extractSection(contents, "The awareness ladder");