@officexapp/vidfarm-devcli 0.21.54 → 0.21.56

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.
@@ -24,6 +24,7 @@ const BOLD = "\x1b[1m";
24
24
  const DIM = "\x1b[2m";
25
25
  const GREEN = "\x1b[32m";
26
26
  const YELLOW = "\x1b[33m";
27
+ const RED = "\x1b[31m";
27
28
  const RESET = "\x1b[0m";
28
29
  /** The shared vidfarm feed: the registry + notification stream every client's machines are listed in. */
29
30
  const VIDFARM_FEED_ID = "FEED_01M0BCTVTKSDFZQVEY1RXA4NJK";
@@ -58,25 +59,56 @@ CLIENT (paid vidfarm plan — the key is read from your vidfarm account)
58
59
  gigs ring-bell --title "<t>" [--subtext "<s>"] [--machine inbound_offers]
59
60
  Broadcast to every agent on the vidfarm feed
60
61
 
61
- GIGWORKER (free — bring your own key: export DOLLARPLATOON_API_KEY=…)
62
- gigs work Work waiting in every machine you joined → GET /work/available
63
- gigs feed Who is asking for videos right now (notifications)
64
- --registry Every machine listed in the feed, joinable
65
- --limit <n>
66
- gigs join-feed [--name "<you>"] Join the shared vidfarm feed, read-only (needed to read it)
67
- gigs join <gig-id> [--invite <token>] [--name "<you>"] [--wallet 0x…]
68
- gigs claim <gig-id> [--count 2] Claim task(s) off a FIFO queue machine
62
+ GIGWORKER — "help me earn on vidfarm" (free — bring your own key: export DOLLARPLATOON_API_KEY=…)
63
+ gigs earn START HERE. The whole first session in one command:
64
+ joins the feed if needed, reads who is asking for videos
65
+ RIGHT NOW, checks every one of those machines for MONEY,
66
+ and prints them best-payer-first with the join command.
67
+ --limit <n> --name "<you>" (alias: gigs start)
68
+
69
+ the same four moves, one at a time:
70
+ gigs feed ① Who is asking for videos right now (notifications)
71
+ --funded …and whether each one can actually pay
72
+ --registry Every machine listed in the feed, not just today's bells
73
+ gigs check <gig-id|join-url> ② CAN THIS MACHINE PAY ME? available_funds + a verdict.
74
+ --price <usd> Score against the TASK price, not the gig default
75
+ Do this BEFORE you commit an hour. (alias: gigs funds)
76
+ gigs join <gig-id|invite-url> ③ Join it. --wallet 0x… --invite <token> --name "<you>"
77
+ SET --wallet ON THE FIRST JOIN: a rollup pays the address
78
+ snapshotted when it was created.
79
+ gigs work ④ Work waiting across every machine you joined (paged)
80
+ gigs claim <gig-id> [--count 1] Claim task(s) off a FIFO queue machine. ONE by default.
81
+ Inbound Offers hands out NO tasks — you pitch, unprompted.
82
+
83
+ delivering, and getting paid:
84
+ gigs upload <file> [--prefix p] Put a file on DollarPlatoon's own s3, get a durable url
69
85
  gigs submit <gig-id> --task <task-id> --proof <url> [--proof <url>…]
70
- --mailbox <id> --tags a,b --note "<text>"
86
+ --clean-master <file> Upload it and seal it in the private note. THE WATERMARKED
87
+ cut goes in --proof; the clean master goes here, and the
88
+ buyer cannot read it until a rollup has actually paid you.
89
+ --private-note "<text>" Anything else that follows the master (ProRes, project
90
+ files, music licence id). 8000 chars, cannot be added later.
91
+ --mailbox <id> --tags a,b --note "<text>" (--note is the PUBLIC feedback field)
92
+ Watch the cut and approve it BEFORE this call — a rejection scores on your
93
+ wallet and clients read it as a trust score on your public profile.
94
+ gigs proof <gig-id> <proof-id> Read your own proof back: paid_out_at + the private note
95
+ gigs earnings Your rollups + totals. (aliases: rollups, paid)
71
96
  gigs mine Your mailboxes across every gig
97
+ gigs join-feed [--name "<you>"] Join the shared vidfarm feed (gigs earn does this for you)
72
98
 
73
- Common: --dp-key <key> · --json · --host <vidfarm url> (for the key lookup)
99
+ Common: --dp-key <key> · --json · --limit <n> · --host <vidfarm url> (for the key lookup)
74
100
 
75
- e.g. vidfarm gigs add-task --task "60s explainer for acme.com" --price 0.25 \\
101
+ e.g. DOLLARPLATOON_API_KEY=… vidfarm gigs earn
102
+ vidfarm gigs check "https://dollarplatoon.com/gig/GIG_01H…/join?invite=abc"
103
+ vidfarm gigs submit GIG_01H… --task TASK_01H… \\
104
+ --proof https://…/final-watermarked.mp4 --clean-master ./final-clean.mp4
105
+ vidfarm gigs add-task --task "60s explainer for acme.com" --price 0.25 \\
76
106
  --upload-link "https://vidfarm.cc/directory/preview/dsh_…/files/acme"
77
107
  vidfarm gigs proofs --status pending
78
108
  vidfarm gigs approve PRF_01H… --feedback "Great hook, keeping it."
79
- DOLLARPLATOON_API_KEY=… vidfarm gigs work`;
109
+
110
+ Building the video is a separate job: https://vidfarm.cc/agentic-clipper.md is the loop,
111
+ and https://vidfarm.cc/flash-harness.md is how to build it on a weak model.`;
80
112
  /**
81
113
  * "You have no key yet, here is how to get one" — the single most common first
82
114
  * run for a gigworker, and a deliberate instruction rather than a bug. cli.ts
@@ -193,6 +225,130 @@ async function dp(auth, path, init = {}) {
193
225
  }
194
226
  return json;
195
227
  }
228
+ /**
229
+ * Every list route here is cursored, and a FILTERED page can come back empty
230
+ * while later pages still hold work — `/work/available?only_with_work=true` is
231
+ * the one that bites. Reading page one only is how a clipper gets told "nothing
232
+ * waiting" over a feed full of paid work. Page to the end, with a hard stop so a
233
+ * server that never nulls its cursor cannot spin the loop forever.
234
+ */
235
+ async function pageAll(auth, path, pick, query = {}, maxPages = 20) {
236
+ const rows = [];
237
+ let cursor;
238
+ for (let page = 0; page < maxPages; page++) {
239
+ const body = await dp(auth, path, { query: { ...query, cursor } });
240
+ rows.push(...pick(body));
241
+ const next = body.next_cursor;
242
+ cursor = typeof next === "string" && next ? next : undefined;
243
+ if (!cursor)
244
+ return { rows, truncated: false };
245
+ }
246
+ return { rows, truncated: true };
247
+ }
248
+ /** Run `work` with at most `limit` in flight — these are N gig reads per loop. */
249
+ async function mapLimit(items, limit, work) {
250
+ const results = new Array(items.length);
251
+ let next = 0;
252
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
253
+ for (let i = next++; i < items.length; i = next++)
254
+ results[i] = await work(items[i]);
255
+ });
256
+ await Promise.all(runners);
257
+ return results;
258
+ }
259
+ // ── funding — the check that decides whether work gets paid ──────────────────
260
+ /**
261
+ * `GET /gigs/:id` has been seen answering both `{ gig: {...} }` and a flat
262
+ * object — cmdAddTask already reads `webhook` both ways. Unwrap defensively,
263
+ * because a funds check that silently reads `null` is worse than none: it turns
264
+ * an EMPTY machine into an "unknown" one, and a clipper works unknowns.
265
+ */
266
+ function unwrapGig(body) {
267
+ return body?.gig && typeof body.gig === "object" ? body.gig : (body ?? {});
268
+ }
269
+ function num(value) {
270
+ const parsed = typeof value === "string" ? Number(value) : typeof value === "number" ? value : NaN;
271
+ return Number.isFinite(parsed) ? parsed : null;
272
+ }
273
+ async function readGigFunds(auth, gigId) {
274
+ const gig = unwrapGig(await dp(auth, `/gigs/${encodeURIComponent(gigId)}`));
275
+ return {
276
+ gigId,
277
+ title: String(gig.title ?? gigId),
278
+ available_funds: num(gig.available_funds),
279
+ reserved_funds: num(gig.reserved_funds),
280
+ price: num(gig.price),
281
+ review_timeout: gig.review_timeout ?? null,
282
+ distribution: gig.distribution ? String(gig.distribution) : null
283
+ };
284
+ }
285
+ /**
286
+ * A $1.00 payout draws $1.10 out of the machine — the 10% platform fee is
287
+ * charged on top of the gig balance, not deducted from the worker. So a gig
288
+ * holding $5.00 pays four $1.00 videos, not five, and "can it pay me" has to be
289
+ * asked against price × 1.1.
290
+ */
291
+ const PLATFORM_FEE_MULTIPLIER = 1.1;
292
+ const VERDICT_LABEL = {
293
+ empty: "SKIP",
294
+ partial: "PARTIAL",
295
+ thin: "THIN",
296
+ healthy: "HEALTHY",
297
+ price_tbd: "PRICE TBD",
298
+ unknown: "UNKNOWN"
299
+ };
300
+ /** Same thresholds as https://vidfarm.cc/agentic-clipper.md § ④. Keep them in sync. */
301
+ function fundsVerdict(funds, taskPrice) {
302
+ const available = funds.available_funds;
303
+ const price = taskPrice ?? funds.price;
304
+ if (available === null) {
305
+ return { verdict: "unknown", pays: null, note: "the gig did not report available_funds — check it on dollarplatoon.com before you commit an hour" };
306
+ }
307
+ if (available <= 0) {
308
+ return { verdict: "empty", pays: 0, note: "empty coin box. Approval does not create money — do not work this gig yet" };
309
+ }
310
+ if (price === null || price <= 0) {
311
+ return { verdict: "price_tbd", pays: null, note: `$${available.toFixed(2)} in the machine, but the price is TBD — the client names the amount at approval` };
312
+ }
313
+ const pays = Math.floor(available / (price * PLATFORM_FEE_MULTIPLIER));
314
+ if (pays < 1) {
315
+ return { verdict: "partial", pays, note: `$${available.toFixed(2)} cannot cover one $${price.toFixed(2)} payout + the 10% fee. Submitting still succeeds — that is not a promise of payment` };
316
+ }
317
+ if (pays >= 3) {
318
+ return { verdict: "healthy", pays, note: `$${available.toFixed(2)} available — about ${pays} payouts at $${price.toFixed(2)} after the 10% fee` };
319
+ }
320
+ return { verdict: "thin", pays, note: `$${available.toFixed(2)} available — only about ${pays} payout(s) at $${price.toFixed(2)}. Someone else may take it first` };
321
+ }
322
+ function paintVerdict(verdict) {
323
+ const label = VERDICT_LABEL[verdict];
324
+ if (verdict === "healthy")
325
+ return `${GREEN}${label}${RESET}`;
326
+ if (verdict === "empty" || verdict === "partial")
327
+ return `${RED}${label}${RESET}`;
328
+ return `${YELLOW}${label}${RESET}`;
329
+ }
330
+ /** Rank for the shortlist: the machine most likely to actually pay comes first. */
331
+ const VERDICT_RANK = {
332
+ healthy: 0, thin: 1, price_tbd: 2, unknown: 3, partial: 4, empty: 5
333
+ };
334
+ /**
335
+ * A gig id, or a join / invite URL pasted straight out of a notification's
336
+ * `destination_url`. Both halves come out of the URL, which is the whole point:
337
+ * a clipper should never have to hand-parse a link to check a machine.
338
+ */
339
+ function parseGigRef(raw, fallbackInvite = "") {
340
+ const value = (raw ?? "").trim();
341
+ if (!/^https?:\/\//i.test(value))
342
+ return { gigId: value, invite: fallbackInvite };
343
+ try {
344
+ const url = new URL(value);
345
+ const found = url.pathname.split("/").find((segment) => /^GIG_/i.test(segment));
346
+ return { gigId: found ?? value, invite: fallbackInvite || url.searchParams.get("invite") || "" };
347
+ }
348
+ catch {
349
+ return { gigId: value, invite: fallbackInvite };
350
+ }
351
+ }
196
352
  // ── machine resolution ───────────────────────────────────────────────────────
197
353
  const MACHINE_ALIASES = {
198
354
  custom: "custom_requests",
@@ -479,21 +635,262 @@ async function cmdRingBell(auth, values) {
479
635
  }
480
636
  // ── gigworker commands ───────────────────────────────────────────────────────
481
637
  async function cmdWork(auth, values) {
482
- const body = await dp(auth, "/work/available", { query: { only_with_work: "true", limit: values.limit } });
483
638
  // `/work/available` answers `{ items: [...] }` — reading `mailboxes` first
484
- // printed "nothing waiting" over a page full of paid work.
485
- const rows = body.items ?? body.mailboxes ?? body.work ?? body.data ?? [];
486
- out(Boolean(values.json), body, () => {
639
+ // printed "nothing waiting" over a page full of paid work. And it MUST be
640
+ // paged: with only_with_work=true the filter runs inside a page, so several
641
+ // pages in a row can be empty while later ones hold paid work.
642
+ const { rows, truncated } = await pageAll(auth, "/work/available", (body) => body.items ?? body.mailboxes ?? body.work ?? body.data ?? [], { only_with_work: "true", limit: values.limit ?? "100" });
643
+ out(Boolean(values.json), { items: rows, truncated }, () => {
487
644
  if (!rows.length) {
488
- console.log(`${DIM}Nothing waiting in the machines you joined. Find new clients: vidfarm gigs feed${RESET}`);
645
+ console.log(`${DIM}Nothing waiting in the machines you joined. Find new clients: vidfarm gigs earn${RESET}`);
489
646
  return;
490
647
  }
491
648
  for (const row of rows) {
492
- const count = row.available ?? row.pending_count ?? row.count ?? "";
493
- console.log(` ${BOLD}${short(row.gig_title ?? row.gig_id, 60)}${RESET} ${DIM}${row.gig_id ?? ""}${count !== "" ? ` · ${count} waiting` : ""}${RESET}`);
649
+ const price = num(row.price);
650
+ // `tasks_in_mailbox` = already delivered to you. `poll_in_gig` = unclaimed
651
+ // work sitting in the shared queue. `poll_in_gig: false` is definitive.
652
+ const state = row.tasks_in_mailbox ? "in your mailbox" : row.poll_in_gig ? "claimable — poll it" : "nothing to poll";
653
+ console.log(` ${BOLD}${short(row.gig_title ?? row.gig_id, 55)}${RESET} ${DIM}${row.gig_id ?? ""}${RESET}`);
654
+ console.log(` ${DIM}${state}${price === null ? " · price TBD" : ` · $${price.toFixed(2)}/video`}${RESET}`);
655
+ }
656
+ console.log("");
657
+ if (truncated)
658
+ console.log(`${YELLOW}More pages exist${RESET}${DIM} — raise --limit or page the API directly.${RESET}`);
659
+ console.log(`${DIM}The gig price is only a DEFAULT — each task can carry its own, and price TBD means the client names it at approval.${RESET}`);
660
+ console.log(`${DIM}Check it can pay: vidfarm gigs check <gig-id> · Claim one: vidfarm gigs claim <gig-id>${RESET}`);
661
+ });
662
+ }
663
+ /**
664
+ * MOVE 2 of the earning loop — "can this machine actually pay me?"
665
+ *
666
+ * This is the check that decides whether an hour of editing turns into money,
667
+ * and it was the one step of https://vidfarm.cc/marketplace.md with no CLI verb
668
+ * behind it. Takes a gig id OR a join link pasted straight out of a
669
+ * notification, so nothing has to be hand-parsed.
670
+ */
671
+ async function cmdCheck(auth, values, positionals) {
672
+ const raw = positionals[0];
673
+ if (!raw)
674
+ throw new Error("gigs check needs a gig id or a join link: vidfarm gigs check GIG_01H… (or paste the notification's URL)");
675
+ const { gigId, invite } = parseGigRef(raw);
676
+ const funds = await readGigFunds(auth, gigId);
677
+ const taskPrice = values.price === undefined ? null : num(values.price);
678
+ const verdict = fundsVerdict(funds, taskPrice);
679
+ out(Boolean(values.json), { ...funds, verdict: verdict.verdict, pays_about: verdict.pays, note: verdict.note }, () => {
680
+ console.log(` ${BOLD}${funds.title}${RESET} ${DIM}${funds.gigId}${funds.distribution ? " · " + funds.distribution : ""}${RESET}`);
681
+ console.log(` ${paintVerdict(verdict.verdict)} ${verdict.note}`);
682
+ const reserved = funds.reserved_funds;
683
+ if (reserved !== null && reserved > 0) {
684
+ console.log(` ${DIM}$${reserved.toFixed(2)} is RESERVED for proofs ahead of you. You are paid from available_funds, not the sum.${RESET}`);
685
+ }
686
+ // A negative timeout is "never" — Inbound Offers machines do not auto-approve,
687
+ // so printing the raw -1 reads like a broken field.
688
+ const timeout = num(funds.review_timeout);
689
+ if (timeout !== null) {
690
+ console.log(` ${DIM}review: ${timeout < 0 ? "no auto-approval — the buyer must act" : `auto-approves after ${timeout}${RESET}`}${RESET}`);
691
+ }
692
+ console.log("");
693
+ if (verdict.verdict === "empty" || verdict.verdict === "partial") {
694
+ console.log(`${DIM}Funds are per gig and cannot move between gigs — a client's other machine tells you nothing about this one.${RESET}`);
695
+ }
696
+ else if (invite) {
697
+ console.log(`${DIM}Join it: vidfarm gigs join "${raw}" --wallet 0xYOUR_BASE_ADDRESS${RESET}`);
698
+ }
699
+ else {
700
+ console.log(`${DIM}Join it: vidfarm gigs join ${funds.gigId} --invite <token> --wallet 0xYOUR_BASE_ADDRESS${RESET}`);
701
+ console.log(`${DIM}No token to hand? An invite-only gig refuses a tokenless join — find the tokened link: vidfarm gigs feed --registry${RESET}`);
702
+ }
703
+ });
704
+ }
705
+ /**
706
+ * "help me earn on vidfarm" in one command — moves 1 and 2 of
707
+ * https://vidfarm.cc/marketplace.md, then a ranked shortlist.
708
+ *
709
+ * Reads the notification feed for who is asking for videos RIGHT NOW, digs the
710
+ * gig id out of each bell's join link, checks every one of them for money, and
711
+ * prints them best-payer-first with the exact join command. Joins the feed for
712
+ * you if you are not on it — a non-member 404s on every feed route, which reads
713
+ * like an empty feed rather than a missing membership.
714
+ */
715
+ async function cmdEarn(auth, values) {
716
+ const limit = values.limit ?? "50";
717
+ const json = Boolean(values.json);
718
+ const notificationsPath = `/feeds/${VIDFARM_FEED_ID}/notifications`;
719
+ const pickNotifications = (body) => body.notifications ?? body.items ?? body.data ?? [];
720
+ let notifications;
721
+ try {
722
+ notifications = (await pageAll(auth, notificationsPath, pickNotifications, { limit })).rows;
723
+ }
724
+ catch (error) {
725
+ if (!(error instanceof Error) || !/40[134]/.test(error.message))
726
+ throw error;
727
+ if (!json)
728
+ console.log(`${DIM}Not on the vidfarm feed yet — joining it…${RESET}`);
729
+ await dp(auth, `/feeds/${VIDFARM_FEED_ID}/join`, {
730
+ method: "POST",
731
+ body: { invite: VIDFARM_FEED_READ_INVITE, ...(values.name ? { display_name: String(values.name) } : {}) }
732
+ });
733
+ notifications = (await pageAll(auth, notificationsPath, pickNotifications, { limit })).rows;
734
+ }
735
+ // One row per MACHINE, newest bell wins. A client who rang three times this
736
+ // week is one opportunity, not three.
737
+ const byGig = new Map();
738
+ const unjoinable = [];
739
+ for (const row of notifications) {
740
+ const url = String(row.destination_url ?? row.invite_url ?? "");
741
+ const { gigId, invite } = parseGigRef(url);
742
+ if (!/^GIG_/i.test(gigId))
743
+ continue;
744
+ if (!invite)
745
+ unjoinable.push({ gigId, title: row.title });
746
+ if (byGig.has(gigId))
747
+ continue;
748
+ byGig.set(gigId, {
749
+ gigId,
750
+ invite,
751
+ title: String(row.title ?? gigId),
752
+ subtext: String(row.subtext ?? ""),
753
+ created_at: String(row.created_at ?? ""),
754
+ url
755
+ });
756
+ }
757
+ const candidates = [...byGig.values()];
758
+ const scored = await mapLimit(candidates, 5, async (candidate) => {
759
+ try {
760
+ const funds = await readGigFunds(auth, candidate.gigId);
761
+ const verdict = fundsVerdict(funds);
762
+ return { ...candidate, funds, ...verdict };
763
+ }
764
+ catch {
765
+ // A gig you cannot read yet is not a dead gig — most commonly you are not
766
+ // a member. Keep it on the list as unknown rather than dropping it.
767
+ const funds = { gigId: candidate.gigId, title: candidate.title, available_funds: null, reserved_funds: null, price: null, review_timeout: null, distribution: null };
768
+ return { ...candidate, funds, verdict: "unknown", pays: null, note: "could not read this gig — you may need to join it first" };
769
+ }
770
+ });
771
+ scored.sort((a, b) => VERDICT_RANK[a.verdict] - VERDICT_RANK[b.verdict]);
772
+ const joined = await pageAll(auth, "/work/available", (body) => body.items ?? body.mailboxes ?? body.work ?? body.data ?? [], { only_with_work: "true", limit: "100" }).catch(() => ({ rows: [], truncated: false }));
773
+ out(json, { opportunities: scored, work_waiting: joined.rows }, () => {
774
+ console.log(`${BOLD}① Who is asking for videos right now${RESET} ${DIM}(${notifications.length} bells, ${scored.length} machines)${RESET}`);
775
+ if (!scored.length) {
776
+ console.log(` ${DIM}No bells on the feed yet. The registry lists every machine that exists: vidfarm gigs feed --registry${RESET}`);
777
+ }
778
+ console.log("");
779
+ console.log(`${BOLD}② Which of them can actually pay${RESET}`);
780
+ for (const row of scored) {
781
+ console.log(` ${paintVerdict(row.verdict)} ${BOLD}${short(row.title, 58)}${RESET} ${DIM}${row.gigId}${RESET}`);
782
+ console.log(` ${DIM}${row.note}${RESET}`);
783
+ if (row.subtext)
784
+ console.log(` ${DIM}${short(row.subtext, 100)}${RESET}`);
785
+ if (row.verdict !== "empty" && row.verdict !== "partial") {
786
+ // No token means the bare join URL is a dead end — an invite-only gig
787
+ // refuses a tokenless join — so never print a command that cannot work.
788
+ console.log(row.invite
789
+ ? ` ${DIM}→ vidfarm gigs join ${row.url} --wallet 0xYOUR_BASE_ADDRESS${RESET}`
790
+ : ` ${YELLOW}→ no ?invite= token on that bell${RESET}${DIM} — get the tokened link: vidfarm gigs feed --registry${RESET}`);
791
+ }
792
+ }
793
+ if (unjoinable.length) {
794
+ console.log("");
795
+ console.log(`${YELLOW}${unjoinable.length} bell(s) carry a link with no ?invite= token${RESET}${DIM} — an old build rang those. Find the gig in the registry, where the invite_url IS tokened: vidfarm gigs feed --registry${RESET}`);
796
+ }
797
+ console.log("");
798
+ console.log(`${BOLD}③ Work already waiting in machines you joined${RESET}`);
799
+ if (!joined.rows.length) {
800
+ console.log(` ${DIM}None. Join a funded machine above first.${RESET}`);
801
+ }
802
+ else {
803
+ for (const row of joined.rows) {
804
+ const price = num(row.price);
805
+ console.log(` ${BOLD}${short(row.gig_title ?? row.gig_id, 55)}${RESET} ${DIM}${row.gig_id ?? ""}${price === null ? " · price TBD" : ` · $${price.toFixed(2)}`}${row.poll_in_gig ? " · claimable" : ""}${RESET}`);
806
+ }
807
+ }
808
+ console.log("");
809
+ console.log(`${DIM}Then: vidfarm gigs claim <gig-id> → build it → vidfarm gigs submit <gig-id> --task <task-id> --proof <url>${RESET}`);
810
+ console.log(`${DIM}Inbound Offers machines hand out NO tasks — you pitch a finished video unprompted, and a pass costs you no reputation.${RESET}`);
811
+ console.log(`${DIM}Weak model? Build from a decomposed template: https://vidfarm.cc/flash-harness.md${RESET}`);
812
+ });
813
+ }
814
+ /**
815
+ * "have I been paid?" — `approved` is not `paid`. A rollup that reads `failed`
816
+ * means NOT SETTLED YET, never lost: the cron retries the same rollup, checking
817
+ * the chain first, until it settles. Never ask for one to be re-sent.
818
+ */
819
+ async function cmdEarnings(auth, values) {
820
+ const { rows } = await pageAll(auth, "/rollups/mine", (body) => body.rollups ?? body.items ?? body.data ?? [], { limit: values.limit ?? "100" });
821
+ const totals = new Map();
822
+ for (const row of rows) {
823
+ const status = String(row.status ?? "unknown");
824
+ const entry = totals.get(status) ?? { count: 0, amount: 0 };
825
+ entry.count += 1;
826
+ entry.amount += num(row.net_amount) ?? 0;
827
+ totals.set(status, entry);
828
+ }
829
+ out(Boolean(values.json), { rollups: rows, totals: Object.fromEntries(totals) }, () => {
830
+ if (!rows.length) {
831
+ console.log(`${DIM}No rollups yet. Submit a proof first: vidfarm gigs earn${RESET}`);
832
+ return;
833
+ }
834
+ for (const row of rows.slice(0, 25)) {
835
+ const amount = num(row.net_amount);
836
+ const settled = row.status === "settled" || row.status === "paid" || Boolean(row.tx_hash);
837
+ console.log(` ${settled ? GREEN + "✓" : YELLOW + "·"}${RESET} ${BOLD}${amount === null ? "?" : "$" + amount.toFixed(2)}${RESET} ${DIM}${row.status ?? ""}${row.tx_hash ? " · " + short(row.tx_hash, 22) : ""}${RESET}`);
838
+ }
839
+ console.log("");
840
+ for (const [status, entry] of totals) {
841
+ console.log(` ${BOLD}${status}${RESET} ${DIM}${entry.count} rollup(s) · $${entry.amount.toFixed(2)}${RESET}`);
494
842
  }
495
843
  console.log("");
496
- console.log(`${DIM}Claim one: vidfarm gigs claim <gig-id> then vidfarm gigs submit <gig-id> --task <task-id> --proof <mp4 url>${RESET}`);
844
+ console.log(`${DIM}A 'failed' rollup means NOT SETTLED YET, never lost the cron retries the same one until it settles. Never ask for a re-send.${RESET}`);
845
+ console.log(`${DIM}Per proof, the only field that means the USDC moved is paid_out_at — approved is not paid.${RESET}`);
846
+ });
847
+ }
848
+ /** Guess a content type so the presigned PUT is not stored as octet-stream. */
849
+ function guessContentType(filename) {
850
+ const ext = filename.toLowerCase().split(".").pop() ?? "";
851
+ const map = {
852
+ mp4: "video/mp4", mov: "video/quicktime", webm: "video/webm", m4v: "video/x-m4v",
853
+ mp3: "audio/mpeg", wav: "audio/wav", m4a: "audio/mp4",
854
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif",
855
+ zip: "application/zip", json: "application/json", md: "text/markdown", txt: "text/plain"
856
+ };
857
+ return map[ext] ?? "application/octet-stream";
858
+ }
859
+ /**
860
+ * Put a file on Dollar Platoon's OWN s3 and return the durable url.
861
+ *
862
+ * This is where the clean master goes. It has to be their bucket, not a link you
863
+ * host: the private note presigns that url at read time and the key it hands you
864
+ * is random, which is exactly what keeps the master unreachable while the note is
865
+ * still locked. The presigned PUT expires in an hour, so presign immediately
866
+ * before pushing the bytes.
867
+ */
868
+ async function uploadToDollarPlatoon(auth, file, prefix = "proofs") {
869
+ const bytes = readFileSync(file);
870
+ const filename = file.split(/[\\/]/).pop() || "upload.bin";
871
+ const contentType = guessContentType(filename);
872
+ const presign = await dp(auth, "/upload/presign", {
873
+ method: "POST",
874
+ body: { filename, content_type: contentType, prefix }
875
+ });
876
+ const putUrl = presign.presigned_url ?? presign.url_put ?? "";
877
+ const publicUrl = presign.url ?? presign.public_url ?? "";
878
+ if (!putUrl || !publicUrl)
879
+ throw new Error("upload presign did not return both presigned_url and url — check the key and the host.");
880
+ const put = await fetch(putUrl, { method: "PUT", headers: { "content-type": contentType }, body: new Uint8Array(bytes) });
881
+ if (!put.ok)
882
+ throw new Error(`upload PUT failed: HTTP ${put.status}`);
883
+ return String(publicUrl);
884
+ }
885
+ async function cmdUpload(auth, values, positionals) {
886
+ const file = positionals[0];
887
+ if (!file)
888
+ throw new Error("gigs upload needs a file: vidfarm gigs upload ./final-clean.mp4");
889
+ const url = await uploadToDollarPlatoon(auth, file, values.prefix ?? "proofs");
890
+ out(Boolean(values.json), { url }, () => {
891
+ console.log(`${GREEN}✓${RESET} Uploaded ${BOLD}${file}${RESET}`);
892
+ console.log(` ${url}`);
893
+ console.log(` ${DIM}Put THIS in the proof's private note, not in proofs[]: vidfarm gigs submit <gig> --task <id> --proof <watermarked url> --private-note "Clean master: ${short(url, 40)}"${RESET}`);
497
894
  });
498
895
  }
499
896
  async function cmdFeed(auth, values) {
@@ -513,21 +910,44 @@ async function cmdFeed(auth, values) {
513
910
  // only one key gives a silent zero on the other route — the exact bug that had
514
911
  // `--registry` printing "Nothing on the feed right now" over a full page.
515
912
  const rows = body.notifications ?? body.items ?? body.entries ?? body.registry ?? body.data ?? [];
913
+ // `--funded` answers the question the feed alone cannot: which of these
914
+ // machines has money in it. One gig read per row, capped at 5 in flight.
915
+ const funded = new Map();
916
+ if (values.funded) {
917
+ const gigIds = [...new Set(rows
918
+ .map((row) => parseGigRef(String(row.destination_url ?? row.invite_url ?? "")).gigId)
919
+ .filter((id) => /^GIG_/i.test(id)))];
920
+ await mapLimit(gigIds, 5, async (gigId) => {
921
+ const gig = await readGigFunds(auth, gigId).catch(() => null);
922
+ if (gig)
923
+ funded.set(gigId, fundsVerdict(gig));
924
+ });
925
+ }
516
926
  out(Boolean(values.json), body, () => {
517
927
  if (!rows.length) {
518
928
  console.log(`${DIM}Nothing on the feed right now.${RESET}`);
519
929
  return;
520
930
  }
521
931
  for (const row of rows) {
522
- console.log(` ${BOLD}${short(row.title ?? row.gig_title ?? row.note, 70)}${RESET}`);
932
+ const link = row.destination_url ?? row.invite_url ?? "";
933
+ const check = funded.get(parseGigRef(String(link)).gigId);
934
+ console.log(` ${check ? paintVerdict(check.verdict) + " " : ""}${BOLD}${short(row.title ?? row.gig_title ?? row.note, 70)}${RESET}`);
935
+ if (check)
936
+ console.log(` ${DIM}${check.note}${RESET}`);
523
937
  if (row.subtext)
524
938
  console.log(` ${DIM}${short(row.subtext, 100)}${RESET}`);
525
- const link = row.destination_url ?? row.invite_url ?? "";
526
- if (link)
527
- console.log(` ${link}`);
939
+ if (link) {
940
+ // An invite-only gig refuses a tokenless join, so a link with no
941
+ // `?invite=` is a dead end, not an opportunity. Say so on the row.
942
+ const joinable = hasInviteToken(link);
943
+ console.log(` ${link}${joinable ? "" : ` ${YELLOW}← no ?invite= token: cannot join this link${RESET}`}`);
944
+ }
528
945
  }
529
946
  console.log("");
530
- console.log(`${DIM}${registry ? "Join one with its invite link, then: vidfarm gigs work" : "Every entry links at a machine you can join right now."}${RESET}`);
947
+ if (!values.funded) {
948
+ console.log(`${DIM}A bell tells you who is asking, not who can pay. Add --funded, or run: vidfarm gigs earn${RESET}`);
949
+ }
950
+ console.log(`${DIM}${registry ? "The registry is every machine that EXISTS; the notifications are the ones whose owner is awake. Pick today's work from the bells." : "Check one before you commit an hour: vidfarm gigs check <the link above>"}${RESET}`);
531
951
  });
532
952
  }
533
953
  async function cmdJoinFeed(auth, values) {
@@ -544,15 +964,12 @@ async function cmdJoin(auth, values, positionals) {
544
964
  if (!raw)
545
965
  throw new Error("gigs join needs a gig id or an invite URL: vidfarm gigs join GIG_01H… --invite <token>");
546
966
  // An invite URL carries both halves: .../gig/<gigId>/join?invite=<token>
547
- let gigId = raw;
548
- let invite = values.invite ? String(values.invite) : "";
549
- if (/^https?:\/\//i.test(raw)) {
550
- const url = new URL(raw);
551
- const found = url.pathname.split("/").find((s) => /^GIG_/i.test(s));
552
- if (found)
553
- gigId = found;
554
- invite = invite || url.searchParams.get("invite") || "";
555
- }
967
+ const { gigId, invite } = parseGigRef(raw, values.invite ? String(values.invite) : "");
968
+ // Joining is free and reversible, so this never blocks — but a clipper who
969
+ // joins an empty machine and only discovers it after an hour of editing has
970
+ // already lost the afternoon. Surface the coin box at the moment of joining.
971
+ const funds = await readGigFunds(auth, gigId).catch(() => null);
972
+ const verdict = funds ? fundsVerdict(funds) : null;
556
973
  const result = await dp(auth, `/gigs/${encodeURIComponent(gigId)}/mailboxes`, {
557
974
  method: "POST",
558
975
  body: {
@@ -561,9 +978,17 @@ async function cmdJoin(auth, values, positionals) {
561
978
  ...(values.wallet ? { wallet_address: String(values.wallet) } : {})
562
979
  }
563
980
  });
564
- out(Boolean(values.json), result, () => {
981
+ out(Boolean(values.json), { ...result, funds, verdict: verdict?.verdict ?? null }, () => {
565
982
  const mailbox = result.mailbox?.id ?? result.id ?? "";
566
983
  console.log(`${GREEN}✓${RESET} Joined ${BOLD}${gigId}${RESET}${mailbox ? ` ${DIM}mailbox ${mailbox}${RESET}` : ""}`);
984
+ if (verdict)
985
+ console.log(` ${paintVerdict(verdict.verdict)} ${DIM}${verdict.note}${RESET}`);
986
+ if (!values.wallet) {
987
+ // A rollup pays the address snapshotted when it was created, so a wallet
988
+ // added after the first submit does not retroactively redirect anything.
989
+ console.log(` ${YELLOW}No --wallet given${RESET}${DIM} — a managed hot wallet is auto-provisioned and you move the money yourself later.${RESET}`);
990
+ console.log(` ${DIM}Set it BEFORE your first proof: a rollup pays the address snapshotted when it was created.${RESET}`);
991
+ }
567
992
  console.log(` ${DIM}Now: vidfarm gigs work · vidfarm gigs claim ${gigId}${RESET}`);
568
993
  });
569
994
  }
@@ -571,7 +996,12 @@ async function cmdClaim(auth, values, positionals) {
571
996
  const gigId = positionals[0];
572
997
  if (!gigId)
573
998
  throw new Error("gigs claim needs a gig id: vidfarm gigs claim GIG_01H… [--count 2]");
574
- const count = values.count === undefined ? 2 : Math.trunc(Number(values.count));
999
+ // ONE by default. Claiming takes a task off everybody else's queue and the
1000
+ // client is now waiting on you specifically, so a default of 2 quietly put a
1001
+ // clipper two tasks deep before it had finished one — and `unresponsive` is a
1002
+ // 2x reputation hit. The clipper harness's anti-runaway rail is one task in
1003
+ // flight; ask for more only when you mean it.
1004
+ const count = values.count === undefined ? 1 : Math.trunc(Number(values.count));
575
1005
  const result = await dp(auth, `/gigs/${encodeURIComponent(gigId)}/queue/poll`, { method: "POST", body: { count } });
576
1006
  const tasks = result.tasks ?? [];
577
1007
  out(Boolean(values.json), result, () => {
@@ -601,6 +1031,21 @@ async function cmdSubmit(auth, values, positionals) {
601
1031
  throw new Error("gigs submit needs --task <task-id>. On a queue machine that is the POLLED task's id (never the subject line) — it is what claims the task to you.");
602
1032
  }
603
1033
  const tags = values.tags ? String(values.tags).split(",").map((t) => t.trim()).filter(Boolean) : [];
1034
+ // The escrowed half of the delivery. `proofs[]` is a PUBLIC playable url — the
1035
+ // buyer can download it and so can anyone they forward it to — so the
1036
+ // watermarked cut goes there and the clean master goes in `private_note`,
1037
+ // which the platform unseals only once a rollup has stamped paid_out_at.
1038
+ // --clean-master uploads the file to DollarPlatoon's own s3 first, because the
1039
+ // note presigns that url at read time off a random key; a link you host is
1040
+ // reachable the moment anyone guesses it.
1041
+ const noteParts = [];
1042
+ if (values["clean-master"]) {
1043
+ const url = await uploadToDollarPlatoon(auth, String(values["clean-master"]));
1044
+ noteParts.push(`Clean master, no watermark: ${url}`);
1045
+ }
1046
+ if (values["private-note"])
1047
+ noteParts.push(String(values["private-note"]));
1048
+ const privateNote = noteParts.join("\n");
604
1049
  const result = await dp(auth, `/gigs/${encodeURIComponent(gigId)}/proofs`, {
605
1050
  method: "POST",
606
1051
  body: {
@@ -608,12 +1053,60 @@ async function cmdSubmit(auth, values, positionals) {
608
1053
  proofs,
609
1054
  ...(values.mailbox ? { mailbox_id: String(values.mailbox) } : {}),
610
1055
  ...(tags.length ? { tags } : {}),
611
- ...(values.note ? { feedback: String(values.note) } : {})
1056
+ ...(values.note ? { feedback: String(values.note) } : {}),
1057
+ ...(privateNote ? { private_note: privateNote } : {})
612
1058
  }
613
1059
  });
1060
+ const proof = result.proof ?? result;
614
1061
  out(Boolean(values.json), result, () => {
615
- console.log(`${GREEN}✓${RESET} Submitted to ${BOLD}${gigId}${RESET} ${DIM}${result.proof_id ?? result.id ?? ""}${RESET}`);
1062
+ console.log(`${GREEN}✓${RESET} Submitted to ${BOLD}${gigId}${RESET} ${DIM}${proof.id ?? result.proof_id ?? ""}${RESET}`);
1063
+ if (privateNote) {
1064
+ // The response field is the ONLY confirmation the note was stored, and a
1065
+ // note cannot be patched onto a proof afterwards — so a silent miss here
1066
+ // means the master was never handed over at all.
1067
+ if (proof.private_note_locked) {
1068
+ console.log(` ${GREEN}✓${RESET} ${DIM}private note stored and sealed — it opens when a rollup stamps paid_out_at, not on approval.${RESET}`);
1069
+ }
1070
+ else {
1071
+ console.log(` ${RED}!${RESET} ${BOLD}private_note_locked was not returned — the note may not have saved.${RESET}`);
1072
+ console.log(` ${DIM}You cannot patch a note onto a proof afterwards. Check it now: vidfarm gigs proof ${gigId} ${proof.id ?? "<proof-id>"}${RESET}`);
1073
+ }
1074
+ }
1075
+ else {
1076
+ console.log(` ${DIM}No private note sent. If that url is your CLEAN master, the buyer now has it before paying — see --clean-master.${RESET}`);
1077
+ }
616
1078
  console.log(` ${DIM}The buyer swipes this in a deck — a link that needs a login is a proof they cannot watch.${RESET}`);
1079
+ console.log(` ${DIM}A proof cannot be unsent. Agents: only submit a cut the gigworker approved, unless they asked for auto-submit.${RESET}`);
1080
+ });
1081
+ }
1082
+ /**
1083
+ * Read one of YOUR OWN proofs back — the "have I been paid, and did my note
1084
+ * save" check. The submitting worker can always read their own note, at any
1085
+ * status. Note this GET returns the proof FLAT: `.paid_out_at`, never
1086
+ * `.proof.paid_out_at`.
1087
+ */
1088
+ async function cmdProof(auth, values, positionals) {
1089
+ const [gigRef, proofId] = positionals;
1090
+ if (!gigRef || !proofId)
1091
+ throw new Error("gigs proof needs a gig id and a proof id: vidfarm gigs proof GIG_01H… PROOF_01H…");
1092
+ const { gigId } = parseGigRef(gigRef);
1093
+ const body = await dp(auth, `/gigs/${encodeURIComponent(gigId)}/proofs/${encodeURIComponent(proofId)}`);
1094
+ const proof = body.proof ?? body;
1095
+ out(Boolean(values.json), body, () => {
1096
+ const paid = Boolean(proof.paid_out_at);
1097
+ console.log(` ${BOLD}${proof.id ?? proofId}${RESET} ${DIM}${proof.status ?? ""}${RESET}`);
1098
+ console.log(` ${paid ? GREEN + "PAID" : YELLOW + "NOT PAID"}${RESET} ${DIM}${paid ? String(proof.paid_out_at) : "approved is not paid — paid_out_at is the only field that means the USDC moved"}${RESET}`);
1099
+ if (proof.private_note_locked) {
1100
+ console.log(` ${DIM}private note: stored and sealed (opens on payout)${RESET}`);
1101
+ }
1102
+ else if (typeof proof.private_note === "string" && proof.private_note) {
1103
+ console.log(` ${DIM}private note:${RESET} ${short(proof.private_note, 160)}`);
1104
+ }
1105
+ else {
1106
+ console.log(` ${DIM}private note: none was attached${RESET}`);
1107
+ }
1108
+ console.log("");
1109
+ console.log(`${DIM}A link inside the note is presigned at read time and expires in an hour — re-fetch each time you want the file.${RESET}`);
617
1110
  });
618
1111
  }
619
1112
  async function cmdMine(auth, values) {
@@ -658,12 +1151,16 @@ function options() {
658
1151
  title: { type: "string" },
659
1152
  subtext: { type: "string" },
660
1153
  registry: { type: "boolean" },
1154
+ funded: { type: "boolean" },
661
1155
  name: { type: "string" },
662
1156
  invite: { type: "string" },
663
1157
  wallet: { type: "string" },
664
1158
  count: { type: "string" },
665
1159
  mailbox: { type: "string" },
666
- note: { type: "string" }
1160
+ note: { type: "string" },
1161
+ "private-note": { type: "string" },
1162
+ "clean-master": { type: "string" },
1163
+ prefix: { type: "string" }
667
1164
  };
668
1165
  }
669
1166
  export async function runGigsCommand(argv) {
@@ -691,14 +1188,23 @@ export async function runGigsCommand(argv) {
691
1188
  case "pass": return cmdReview(auth, values, rest, "reject");
692
1189
  case "ring-bell":
693
1190
  case "bell": return cmdRingBell(auth, values);
1191
+ case "earn":
1192
+ case "start": return cmdEarn(auth, values);
694
1193
  case "work":
695
1194
  case "available": return cmdWork(auth, values);
1195
+ case "check":
1196
+ case "funds": return cmdCheck(auth, values, rest);
696
1197
  case "feed": return cmdFeed(auth, values);
697
1198
  case "join-feed": return cmdJoinFeed(auth, values);
698
1199
  case "join": return cmdJoin(auth, values, rest);
699
1200
  case "claim":
700
1201
  case "poll": return cmdClaim(auth, values, rest);
701
1202
  case "submit": return cmdSubmit(auth, values, rest);
1203
+ case "proof": return cmdProof(auth, values, rest);
1204
+ case "upload": return cmdUpload(auth, values, rest);
1205
+ case "earnings":
1206
+ case "rollups":
1207
+ case "paid": return cmdEarnings(auth, values);
702
1208
  case "mine":
703
1209
  case "mailboxes": return cmdMine(auth, values);
704
1210
  default: