@ishlabs/cli 0.24.0 → 0.25.0

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.
Files changed (40) hide show
  1. package/dist/commands/ask.js +3 -3
  2. package/dist/commands/iteration.js +1 -1
  3. package/dist/commands/study-analyze.js +1 -1
  4. package/dist/commands/study-run.js +83 -15
  5. package/dist/commands/study.js +11 -7
  6. package/dist/lib/alias-store.js +1 -1
  7. package/dist/lib/api-client.d.ts +2 -0
  8. package/dist/lib/billing.d.ts +30 -16
  9. package/dist/lib/billing.js +77 -27
  10. package/dist/lib/docs.js +57 -42
  11. package/dist/lib/local-sim/actions.d.ts +10 -2
  12. package/dist/lib/local-sim/actions.js +16 -11
  13. package/dist/lib/local-sim/adb.d.ts +103 -0
  14. package/dist/lib/local-sim/adb.js +352 -0
  15. package/dist/lib/local-sim/android.d.ts +111 -0
  16. package/dist/lib/local-sim/android.js +499 -0
  17. package/dist/lib/local-sim/apk-manifest.d.ts +22 -0
  18. package/dist/lib/local-sim/apk-manifest.js +210 -0
  19. package/dist/lib/local-sim/browser.d.ts +22 -0
  20. package/dist/lib/local-sim/browser.js +65 -0
  21. package/dist/lib/local-sim/coordinates.d.ts +69 -0
  22. package/dist/lib/local-sim/coordinates.js +59 -0
  23. package/dist/lib/local-sim/device.d.ts +143 -0
  24. package/dist/lib/local-sim/device.js +152 -0
  25. package/dist/lib/local-sim/ios.d.ts +168 -0
  26. package/dist/lib/local-sim/ios.js +546 -0
  27. package/dist/lib/local-sim/loop.d.ts +14 -2
  28. package/dist/lib/local-sim/loop.js +166 -73
  29. package/dist/lib/local-sim/native-a11y.d.ts +97 -0
  30. package/dist/lib/local-sim/native-a11y.js +384 -0
  31. package/dist/lib/local-sim/simctl.d.ts +85 -0
  32. package/dist/lib/local-sim/simctl.js +273 -0
  33. package/dist/lib/local-sim/types.d.ts +37 -2
  34. package/dist/lib/local-sim/upload.d.ts +1 -1
  35. package/dist/lib/local-sim/upload.js +9 -6
  36. package/dist/lib/modality.d.ts +10 -1
  37. package/dist/lib/modality.js +21 -0
  38. package/dist/lib/output.js +58 -12
  39. package/dist/lib/skill-content.js +10 -9
  40. package/package.json +2 -1
@@ -436,15 +436,15 @@ Picks come back with a \`pick_confidence\` (0..1) score per participant when
436
436
  // error rather than a transient failure.
437
437
  ask
438
438
  .command("dispatch")
439
- .description("Dispatch a draft ask — bills credits and starts the round")
439
+ .description("Dispatch a draft ask — draws credits and starts the round")
440
440
  .argument("[id]", "Ask alias or UUID (defaults to active ask)")
441
441
  .option("--ask <id>", "Ask ID; alternative to positional argument")
442
442
  .option("--wait", "Wait until the first round completes (or errors)")
443
443
  .option("--timeout <s>", "Wait timeout in seconds (default 300)")
444
444
  .addHelpText("after", `
445
445
  Use after \`ish ask create --no-dispatch\` to start a draft once the user has
446
- reviewed it. The dispatch is BILLABLE credits are charged when responses
447
- land, the same as a normal create.
446
+ reviewed it. Dispatch draws credits as responses land, the same as a normal
447
+ create. This is the expected way to run an ask — go ahead and dispatch.
448
448
 
449
449
  Examples:
450
450
  # Dispatch the active draft and wait for results:
@@ -383,7 +383,7 @@ Concept pages: ish docs get-page concepts/iteration
383
383
  // Media image
384
384
  .option("--image-urls <urls>", "Comma-separated image URLs or local file paths — image modality")
385
385
  // Shared media
386
- .option("--title <title>", "Content title — media modalities")
386
+ .option("--title <title>", "Content title shown to participants (the leading headline they read) — media modalities")
387
387
  .option("--mime-type <type>", "MIME type (e.g. video/mp4) — media modalities")
388
388
  // Copy/caption
389
389
  .option("--copy-text <text>", "Ad copy or social post caption (or @filepath) — ads & social posts")
@@ -81,7 +81,7 @@ export function attachStudyAnalyzeCommands(study) {
81
81
  study
82
82
  .command("analyze")
83
83
  .description("Trigger an AI summary + key-insights analysis for a study. " +
84
- "First analysis per study is free; subsequent runs cost 10 credits.")
84
+ "First analysis per study is included; subsequent runs draw 10 credits.")
85
85
  .argument("[id]", "Study ID (defaults to active study)")
86
86
  .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
87
87
  .option("--wait", "Poll until the run reaches completed or failed")
@@ -14,7 +14,7 @@ import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
14
14
  import { output, formatSimulationPoll } from "../lib/output.js";
15
15
  import { fetchStudyParticipants } from "../lib/study-participants.js";
16
16
  import { streamStudyEvents } from "../lib/study-events.js";
17
- import { isMediaModality, isChatModality, iterationHasContent, describeRequiredContentFlag, readChatMode, readParticipantPairConfig, summarizeRoleCriteria, } from "../lib/modality.js";
17
+ import { isMediaModality, isChatModality, iterationHasContent, describeRequiredContentFlag, readChatMode, readParticipantPairConfig, summarizeRoleCriteria, toModality, } from "../lib/modality.js";
18
18
  // NOTE: local-sim modules are loaded via dynamic import at the `--local`
19
19
  // branch below, NOT statically here. `local-sim/install.ts` deep-imports
20
20
  // `playwright-core/lib/server/registry/index`, which is not exposed by
@@ -33,8 +33,8 @@ function parseMaxInteractions(value) {
33
33
  /**
34
34
  * Default cap the CLI sends when neither `--max-interactions` nor the
35
35
  * iteration carries its own value. Picked to match the frontend's
36
- * conservative interactive launchers and to prevent runaway spend when an
37
- * iteration runs against a broken or non-responsive surface — without a
36
+ * conservative interactive launchers and to prevent runaway credit draw when
37
+ * an iteration runs against a broken or non-responsive surface — without a
38
38
  * cap, a stuck participant can rack up hundreds of steps before the SDK gives
39
39
  * up.
40
40
  */
@@ -264,6 +264,34 @@ function readIterationDetails(details) {
264
264
  ...(typeof details.title === "string" && { title: details.title }),
265
265
  };
266
266
  }
267
+ /**
268
+ * Normalize a platform string for matching. "web", "browser", and unset all
269
+ * mean the default browser path; "android"/"ios" are native. Lets `--platform
270
+ * web` match a "browser" iteration (and vice-versa) without false mismatches.
271
+ */
272
+ function normalizePlatform(platform) {
273
+ const p = (platform ?? "").toLowerCase();
274
+ if (p === "" || p === "web" || p === "browser")
275
+ return "browser";
276
+ return p;
277
+ }
278
+ /**
279
+ * The local platform the user explicitly requested via flags, before any
280
+ * iteration is picked: --platform, or inferred from --app's extension
281
+ * (.apk → android, .app → ios). Undefined when neither is set (no preference →
282
+ * don't filter iterations by platform). The iteration's stored platform is
283
+ * deliberately NOT consulted here — it's what we're selecting against.
284
+ */
285
+ function requestedLocalPlatform(opts) {
286
+ if (opts.platform)
287
+ return opts.platform;
288
+ const app = opts.app?.toLowerCase();
289
+ if (app?.endsWith(".apk"))
290
+ return "android";
291
+ if (app?.endsWith(".app"))
292
+ return "ios";
293
+ return undefined;
294
+ }
267
295
  export function attachStudyRunCommands(study) {
268
296
  // --- Primary: `study run` ---
269
297
  const studyRun = study
@@ -294,6 +322,8 @@ export function attachStudyRunCommands(study) {
294
322
  .option("--devtools", "Open Chrome DevTools (local mode only)")
295
323
  .option("--debug", "Enable detailed debug logging to stderr and ~/.ish/local-sim.log")
296
324
  .option("--parallel <n>", "Run N participants in parallel (local mode only, default: all)")
325
+ .option("--platform <platform>", "Local target platform: 'web' (Playwright), 'android' (adb emulator), or 'ios' (simctl+idb simulator). Defaults to the iteration's platform.")
326
+ .option("--app <path>", "Native local mode: path to an .apk (android) / .app (ios) to install, or an installed package/bundle id to launch. The extension implies --platform.")
297
327
  .addHelpText("after", `
298
328
  Note: --workspace and --study are optional if you have set active context
299
329
  via \`ish workspace use <alias>\` and \`ish study use <alias>\`.
@@ -326,7 +356,7 @@ Examples:
326
356
  $ ish study run --config c-c3c
327
357
 
328
358
  # Cap interactions per participant (default 20 — pass higher to allow deeper
329
- # exploration, lower to cap spend on a known-broken surface):
359
+ # exploration, lower to cap credit draw on a known-broken surface):
330
360
  $ ish study run --max-interactions 30
331
361
 
332
362
  # Block until all simulations finish (or timeout):
@@ -412,8 +442,13 @@ Examples:
412
442
  if (!study.assignments || study.assignments.length === 0) {
413
443
  throw new Error("Study has no assignments. Add tasks with --assignments when creating the study, or use `ish study generate`.");
414
444
  }
415
- // Step 1: Pick iteration (explicit --iteration, or latest on study)
445
+ // Step 1: Pick iteration (explicit --iteration, or latest on study).
446
+ // When --platform / --app requests a local platform, select the
447
+ // iteration whose details.platform matches — otherwise a multi-platform
448
+ // study (e.g. an android AND an ios iteration) would silently run the
449
+ // latest, which may be the wrong platform.
416
450
  const iterations = study.iterations || [];
451
+ const wantPlatform = opts.local ? requestedLocalPlatform(opts) : undefined;
417
452
  let iteration;
418
453
  if (opts.iteration) {
419
454
  const wantedId = resolveId(opts.iteration);
@@ -421,6 +456,25 @@ Examples:
421
456
  if (!iteration) {
422
457
  throw new Error(`Iteration ${opts.iteration} not found on this study.`);
423
458
  }
459
+ // An explicit --iteration whose platform contradicts --platform is a
460
+ // footgun (you'd drive the wrong device); refuse rather than guess.
461
+ if (wantPlatform) {
462
+ const itPlatform = readIterationDetails(iteration.details).platform;
463
+ if (normalizePlatform(itPlatform) !== normalizePlatform(wantPlatform)) {
464
+ throw new Error(`--platform ${wantPlatform} but iteration ${opts.iteration} is platform ` +
465
+ `'${itPlatform ?? "browser"}'. Pass the matching iteration or drop --platform.`);
466
+ }
467
+ }
468
+ }
469
+ else if (wantPlatform) {
470
+ // Latest iteration whose platform matches the requested one.
471
+ const matching = iterations.filter((it) => normalizePlatform(readIterationDetails(it.details).platform) === normalizePlatform(wantPlatform));
472
+ if (matching.length === 0) {
473
+ const available = [...new Set(iterations.map((it) => normalizePlatform(readIterationDetails(it.details).platform)))].join(", ") || "none";
474
+ throw new Error(`No ${wantPlatform} iteration on this study (platforms present: ${available}). ` +
475
+ `Create one with \`ish iteration create --study ${resolvedStudy} ...\`, or pass --iteration.`);
476
+ }
477
+ iteration = matching[matching.length - 1];
424
478
  }
425
479
  else if (iterations.length > 0) {
426
480
  iteration = iterations[iterations.length - 1];
@@ -663,7 +717,7 @@ Examples:
663
717
  : `CLI default — pass --max-interactions to override`;
664
718
  log(` Max steps: ${stepsForMedia} (${source})`);
665
719
  if (Number.isFinite(stepsForMedia)) {
666
- const est = estimateMediaRun({ participantCount, maxInteractions: stepsForMedia });
720
+ const est = estimateMediaRun({ modality: toModality(modality), participantCount, maxInteractions: stepsForMedia });
667
721
  log(` Credits (est): ≈ ${est.upper_bound} credit(s) upper bound — ${est.breakdown}`);
668
722
  }
669
723
  }
@@ -691,6 +745,21 @@ Examples:
691
745
  // by reusing the iteration's existing Conversation rows or by
692
746
  // calling pair-batch.
693
747
  let pairConversationIds = [];
748
+ // Resolve the local target platform ONCE so participant-create and the
749
+ // local-sim dispatch agree. Precedence: --platform flag > --app
750
+ // extension (.apk → android, .app → ios) > iteration's stored platform
751
+ // > browser. Used to set participant.platform (so the backend's native
752
+ // trigger is driven primarily by the participant, not just the
753
+ // empty-tree fallback) and to pick the device in the local loop.
754
+ const platformFromApp = opts.app?.toLowerCase().endsWith(".apk")
755
+ ? "android"
756
+ : opts.app?.toLowerCase().endsWith(".app")
757
+ ? "ios"
758
+ : undefined;
759
+ const resolvedPlatform = opts.platform
760
+ ?? platformFromApp
761
+ ?? detailsView.platform
762
+ ?? "browser";
694
763
  if (isPair && pairConfig) {
695
764
  // Pair-mode flow mirrors the MCP (`ish-mcp` `_run_pair_mode`):
696
765
  // 1. If the iteration already carries `conversations[]` from a
@@ -782,7 +851,7 @@ Examples:
782
851
  participant_type: "ai",
783
852
  status: "draft",
784
853
  ...(opts.language && { language: opts.language }),
785
- ...(!isMedia && !isChat && { platform: detailsView.platform || "browser" }),
854
+ ...(!isMedia && !isChat && { platform: resolvedPlatform }),
786
855
  }));
787
856
  log(`Creating ${participantInputs.length} participant${participantInputs.length > 1 ? "s" : ""}...`);
788
857
  const batchResult = await client.post(`/iterations/${iterationId}/participants/batch`, { participants: participantInputs }, { timeout: dispatchTimeoutMs });
@@ -814,6 +883,8 @@ Examples:
814
883
  devtools: opts.devtools,
815
884
  debug: opts.debug,
816
885
  parallel: opts.parallel ? parseInt(opts.parallel, 10) : undefined,
886
+ platform: resolvedPlatform,
887
+ ...(opts.app && { appPath: opts.app }),
817
888
  quiet: globals.quiet,
818
889
  json: globals.json,
819
890
  });
@@ -1021,7 +1092,7 @@ Examples:
1021
1092
  const steps = resolveMaxInteractions(opts.maxInteractions, iteration.details);
1022
1093
  if (!Number.isFinite(steps))
1023
1094
  return null;
1024
- return estimateMediaRun({ participantCount, maxInteractions: steps });
1095
+ return estimateMediaRun({ modality: toModality(modality), participantCount, maxInteractions: steps });
1025
1096
  })();
1026
1097
  if (!opts.wait) {
1027
1098
  if (globals.json) {
@@ -1042,14 +1113,11 @@ Examples:
1042
1113
  }, true);
1043
1114
  }
1044
1115
  else {
1045
- for (let i = 0; i < simResults.length; i++) {
1046
- const participant = createdParticipants[i];
1047
- const personName = participant?.person?.name || "Unknown";
1048
- log(` ${personName.padEnd(24)} QUEUED`);
1049
- }
1116
+ const studyAlias = tagAlias(ALIAS_PREFIX.study, resolvedStudy);
1117
+ const n = createdParticipants.length;
1118
+ log(`Dispatched ${n} participant${n > 1 ? "s" : ""} — run \`ish study results ${studyAlias}\` for results (or \`ish study poll --study ${studyAlias}\` / --wait to track progress).`);
1050
1119
  const url = getWebUrl(globals, `/${resolvedWorkspace}/${resolvedStudy}/timeline`);
1051
- log(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
1052
- log(`Run \`ish study poll --study ${resolvedStudy}\` (or --wait next time) to check progress.`);
1120
+ log(` ${terminalLink(url, "Open in browser ↗")}`);
1053
1121
  }
1054
1122
  return;
1055
1123
  }
@@ -113,7 +113,7 @@ Concept pages: ish docs get-page concepts/study
113
113
  .requiredOption("--name <name>", "Study name")
114
114
  .option("--description <description>", "Study description")
115
115
  .option("--modality <modality>", "Study modality (interactive, video, audio, text, image, document, chat)")
116
- .option("--content-type <type>", "Content type (per-modality enum — see 'Content types by modality' below). Not used for interactive / chat.")
116
+ .option("--content-type <type>", "Content type (per-modality enum — see 'Content types by modality' below). Changes how --title is presented to participants (e.g. content-type email renders --title as the Subject: line). Not used for interactive / chat.")
117
117
  .option("--assignment <name:instructions>", "Assignment as 'Name:Instructions' (repeatable)", collectRepeatable, [])
118
118
  .option("--assignments-file <path>", "JSON file with assignments array")
119
119
  .option("--assignments <json>", "Inline JSON array of assignments (escape hatch)")
@@ -124,7 +124,7 @@ Concept pages: ish docs get-page concepts/study
124
124
  .option("--screen-format <format>", "Screen format for interactive iterations: desktop (default) or mobile_portrait (hyphen/underscore variants accepted)")
125
125
  .option("--content-url <url>", "Public URL of the media file. Creates iteration A inline (video, audio, document modalities). For local files, use the 2-step `iteration create` flow.")
126
126
  .option("--image-urls <urls>", "Comma-separated public image URLs. Creates iteration A inline (image modality). For local files, use the 2-step `iteration create` flow.")
127
- .option("--title <title>", "Content title (text + media modalities — image, video, audio, document; optional). Not used for interactive / chat.")
127
+ .option("--title <title>", "Participant-facing content title — the headline participants read before the body (text + media modalities — image, video, audio, document; optional). With --content-type email it becomes the email Subject: line. Not an internal label. Not used for interactive / chat.")
128
128
  .option("--segmentation-json <json>", "Segmentation JSON for the inline iteration A — time_based {intervals_seconds, labels?}, section_based {sections[{name,label,...}]}, or page_based {} (text + media). section_based sections are SEMANTIC: group related paragraphs into a few coherent sections (a long article is usually 3-6 sections, not one per paragraph). Lets one `study create` build a complete segmented iteration — no separate `iteration create` needed.")
129
129
  .option("--content-config-json <json>", "Content-config JSON for the inline iteration A (early_termination, selected_segment_indices) — text + media.")
130
130
  .option("--content-html <html>", "HTML version of the text, or @filepath — text modality (email rendering)")
@@ -239,6 +239,9 @@ Examples:
239
239
 
240
240
  Content types by modality (source: VALID_CONTENT_TYPES in src/lib/types.ts; interactive + chat omitted — they don't take --content-type):
241
241
  ${describeContentTypes()}
242
+ The content type also shapes how --title is rendered to participants: with
243
+ content-type email, --title becomes the email Subject: line; otherwise it
244
+ reads as the leading headline of the content.
242
245
 
243
246
  Tips:
244
247
  Use \`--get <path>\` to capture a single value (e.g. \`--get id\`),
@@ -310,11 +313,12 @@ Next: configure a run with \`ish iteration create --study <id>\`,
310
313
  }
311
314
  normalizedScreenFormat = normalized;
312
315
  }
313
- // Pattern G.2: --title is metadata, not content. The backend
314
- // accepts it on text + media modalities (see
315
- // `buildIterationDetails` in iteration.ts). Reject it only on
316
- // shapes that have no title field — interactive (URL only) and
317
- // chat (endpoint config carries its own metadata).
316
+ // --title is participant-facing content: the backend renders it as a
317
+ // leading headline participants read before the body (and as the email
318
+ // Subject: line when content-type is email). It only exists on text +
319
+ // media modalities (see `buildIterationDetails` in iteration.ts), so
320
+ // reject it on shapes that have no title field — interactive (URL only)
321
+ // and chat (endpoint config carries its own configuration).
318
322
  if (opts.title !== undefined
319
323
  && opts.contentText === undefined
320
324
  && opts.contentUrl === undefined
@@ -129,7 +129,7 @@ const HYDRATE_HINT = {
129
129
  // alias map at ~/.ish/aliases.json carries any sources the CLI has
130
130
  // touched in this session.
131
131
  ps: "ish source upload <file> # or `cat ~/.ish/aliases.json | grep ^ps-` to recover prior aliases",
132
- pt: "ish participant get <participant-id>",
132
+ pt: "ish study participant <participant-id>",
133
133
  c: "ish config list",
134
134
  a: "ish ask list",
135
135
  r: "ish ask get <ask-id>",
@@ -48,6 +48,8 @@ export declare class ApiClient {
48
48
  screenshot_url?: string;
49
49
  location_name: string;
50
50
  screen_format?: string;
51
+ full_page_screenshot_base64?: string;
52
+ platform?: string;
51
53
  }): Promise<{
52
54
  frame_version_id: string;
53
55
  }>;
@@ -2,17 +2,23 @@
2
2
  * Credit cost estimators — mirror the backend's billing formulas so the CLI
3
3
  * can surface a pre-dispatch estimate without a network round-trip.
4
4
  *
5
- * The numbers here MUST match `ish-backend/app/{media,chat}/billing.py`
6
- * and `app/billing/service.py`. If the backend formula changes, update
7
- * this file in the same commit, otherwise the CLI will mislead agents.
5
+ * Source of truth: `ish-backend/app/billing/rates.py` (`MODALITY_RATES`,
6
+ * `compute_step_cost`, `compute_chat_cost`, `ASK_PER_RESPONSE_CREDITS`,
7
+ * `PAIR_CHAT_SIDE_MULTIPLIER`). These numbers MUST match that module when a
8
+ * multiplier changes there, update this file in the same commit, otherwise the
9
+ * CLI will mislead agents. The backend prices through one `price_run` dispatcher
10
+ * shared by `POST /billing/estimate` and the real preflight reservation, so the
11
+ * preview here is calibrated to the actual charge as long as the rates agree.
8
12
  *
9
- * Today every modality uses the same shape: `max(1, round(steps / 10))`
10
- * per principal (per participant for media/interactive, per conversation for
11
- * chat, ×2 for participant-pair). Asks bill flat 1 credit per successful
12
- * participant response. These are intentionally per-run estimates; long-term
13
- * we'll fetch `GET /billing/rates` and parameterise modalitiessee
14
- * `reference/credits` docs page.
13
+ * Each modality has its own per-step rate (interactive costs the most —
14
+ * screenshot + vision per step; text-only the least). The per-principal cost is
15
+ * `max(1, round(steps * per_step_credits))`, per participant for step-based
16
+ * modalities and per conversation (×2 in pair mode) for chat. Asks bill a flat
17
+ * credit per successful participant response (an upper bound refusals/errors
18
+ * don't bill). Clients that need authoritative live rates can fetch them from
19
+ * `GET /billing/rates`; this offline mirror is for the pre-dispatch preview.
15
20
  */
21
+ import type { Modality } from "./modality.js";
16
22
  export interface CreditEstimate {
17
23
  /** Upper bound (no early termination). Never claims exactness. */
18
24
  upper_bound: number;
@@ -23,16 +29,24 @@ export interface CreditEstimate {
23
29
  /** Always "credits" today; reserved for future-proofing (e.g. millicredits). */
24
30
  unit: "credits";
25
31
  }
26
- /** Mirror of `app/media/billing.py::media_credit_cost`. */
27
- export declare function mediaCreditCost(steps: number): number;
28
- /** Mirror of `app/chat/billing.py::chat_credit_cost`. */
29
- export declare function chatCreditCost(turns: number): number;
30
32
  /**
31
- * Media/interactive run: 1 credit-cost-per-participant × participant count. Modality
32
- * doesn't currently affect the rate (interactive == text == video at the
33
- * billing layer) — kept as a parameter for forward compatibility.
33
+ * Per-principal step-based cost for one participant running `steps` interactions
34
+ * on a study of `modality`. Mirror of `app/billing/rates.py::compute_step_cost`.
35
+ */
36
+ export declare function stepCreditCost(modality: Modality, steps: number): number;
37
+ /**
38
+ * Per-conversation chat cost. Mirror of `app/billing/rates.py::compute_chat_cost`
39
+ * — `max(1, round(turns * chat_rate))`, doubled in pair mode (both sides bill
40
+ * per turn).
41
+ */
42
+ export declare function chatCreditCost(turns: number, isPair?: boolean): number;
43
+ /**
44
+ * Step-based run (interactive / text / image / video / audio / document):
45
+ * per-participant cost × participant count. The per-step rate is modality-
46
+ * specific (see `PER_STEP_CREDITS`).
34
47
  */
35
48
  export declare function estimateMediaRun(args: {
49
+ modality: Modality;
36
50
  participantCount: number;
37
51
  maxInteractions: number;
38
52
  }): CreditEstimate;
@@ -2,41 +2,90 @@
2
2
  * Credit cost estimators — mirror the backend's billing formulas so the CLI
3
3
  * can surface a pre-dispatch estimate without a network round-trip.
4
4
  *
5
- * The numbers here MUST match `ish-backend/app/{media,chat}/billing.py`
6
- * and `app/billing/service.py`. If the backend formula changes, update
7
- * this file in the same commit, otherwise the CLI will mislead agents.
5
+ * Source of truth: `ish-backend/app/billing/rates.py` (`MODALITY_RATES`,
6
+ * `compute_step_cost`, `compute_chat_cost`, `ASK_PER_RESPONSE_CREDITS`,
7
+ * `PAIR_CHAT_SIDE_MULTIPLIER`). These numbers MUST match that module when a
8
+ * multiplier changes there, update this file in the same commit, otherwise the
9
+ * CLI will mislead agents. The backend prices through one `price_run` dispatcher
10
+ * shared by `POST /billing/estimate` and the real preflight reservation, so the
11
+ * preview here is calibrated to the actual charge as long as the rates agree.
8
12
  *
9
- * Today every modality uses the same shape: `max(1, round(steps / 10))`
10
- * per principal (per participant for media/interactive, per conversation for
11
- * chat, ×2 for participant-pair). Asks bill flat 1 credit per successful
12
- * participant response. These are intentionally per-run estimates; long-term
13
- * we'll fetch `GET /billing/rates` and parameterise modalitiessee
14
- * `reference/credits` docs page.
13
+ * Each modality has its own per-step rate (interactive costs the most —
14
+ * screenshot + vision per step; text-only the least). The per-principal cost is
15
+ * `max(1, round(steps * per_step_credits))`, per participant for step-based
16
+ * modalities and per conversation (×2 in pair mode) for chat. Asks bill a flat
17
+ * credit per successful participant response (an upper bound refusals/errors
18
+ * don't bill). Clients that need authoritative live rates can fetch them from
19
+ * `GET /billing/rates`; this offline mirror is for the pre-dispatch preview.
15
20
  */
16
- /** Mirror of `app/media/billing.py::media_credit_cost`. */
17
- export function mediaCreditCost(steps) {
21
+ /**
22
+ * Per-step credit multiplier per modality. Mirror of
23
+ * `app/billing/rates.py::MODALITY_RATES` (the `per_step_credits` field).
24
+ */
25
+ const PER_STEP_CREDITS = {
26
+ interactive: 1.0,
27
+ video: 0.5,
28
+ audio: 0.3,
29
+ image: 0.2,
30
+ text: 0.1,
31
+ document: 0.3,
32
+ chat: 0.2,
33
+ };
34
+ /** Mirror of `app/billing/rates.py::ASK_PER_RESPONSE_CREDITS`. */
35
+ const ASK_PER_RESPONSE_CREDITS = 1;
36
+ /** Mirror of `app/billing/rates.py::PAIR_CHAT_SIDE_MULTIPLIER`. */
37
+ const PAIR_CHAT_SIDE_MULTIPLIER = 2;
38
+ /**
39
+ * Round half-to-even ("banker's rounding") to match Python's built-in
40
+ * `round()`, which the backend uses. `Math.round` rounds half *up*, so for
41
+ * costs that land on a .5 boundary (e.g. video at rate 0.5 with an odd step
42
+ * count: 5 × 0.5 = 2.5) it would over-quote by one credit vs the real charge.
43
+ * Replicating banker's rounding keeps the preview byte-for-byte with the
44
+ * backend's `compute_step_cost` / `compute_chat_cost`.
45
+ */
46
+ function bankersRound(value) {
47
+ const floor = Math.floor(value);
48
+ const diff = value - floor;
49
+ if (diff < 0.5)
50
+ return floor;
51
+ if (diff > 0.5)
52
+ return floor + 1;
53
+ // Exactly .5 — round to the nearest even integer.
54
+ return floor % 2 === 0 ? floor : floor + 1;
55
+ }
56
+ /**
57
+ * Per-principal step-based cost for one participant running `steps` interactions
58
+ * on a study of `modality`. Mirror of `app/billing/rates.py::compute_step_cost`.
59
+ */
60
+ export function stepCreditCost(modality, steps) {
18
61
  if (!Number.isFinite(steps) || steps <= 0)
19
62
  return 1;
20
- return Math.max(1, Math.round(steps / 10));
63
+ return Math.max(1, bankersRound(steps * PER_STEP_CREDITS[modality]));
21
64
  }
22
- /** Mirror of `app/chat/billing.py::chat_credit_cost`. */
23
- export function chatCreditCost(turns) {
65
+ /**
66
+ * Per-conversation chat cost. Mirror of `app/billing/rates.py::compute_chat_cost`
67
+ * — `max(1, round(turns * chat_rate))`, doubled in pair mode (both sides bill
68
+ * per turn).
69
+ */
70
+ export function chatCreditCost(turns, isPair = false) {
24
71
  if (!Number.isFinite(turns) || turns <= 0)
25
- return 1;
26
- return Math.max(1, Math.round(turns / 10));
72
+ return isPair ? PAIR_CHAT_SIDE_MULTIPLIER : 1;
73
+ const perSide = Math.max(1, bankersRound(turns * PER_STEP_CREDITS.chat));
74
+ return isPair ? perSide * PAIR_CHAT_SIDE_MULTIPLIER : perSide;
27
75
  }
28
76
  /**
29
- * Media/interactive run: 1 credit-cost-per-participant × participant count. Modality
30
- * doesn't currently affect the rate (interactive == text == video at the
31
- * billing layer) — kept as a parameter for forward compatibility.
77
+ * Step-based run (interactive / text / image / video / audio / document):
78
+ * per-participant cost × participant count. The per-step rate is modality-
79
+ * specific (see `PER_STEP_CREDITS`).
32
80
  */
33
81
  export function estimateMediaRun(args) {
34
- const perParticipant = mediaCreditCost(args.maxInteractions);
82
+ const perParticipant = stepCreditCost(args.modality, args.maxInteractions);
35
83
  const total = Math.max(0, args.participantCount) * perParticipant;
84
+ const rate = PER_STEP_CREDITS[args.modality];
36
85
  return {
37
86
  upper_bound: total,
38
87
  formula: "media_per_participant",
39
- breakdown: `${args.participantCount} participant(s) × max(1, round(${args.maxInteractions} steps / 10)) = ${args.participantCount} × ${perParticipant} = ${total}`,
88
+ breakdown: `${args.participantCount} participant(s) × max(1, round(${args.maxInteractions} steps × ${rate})) = ${args.participantCount} × ${perParticipant} = ${total}`,
40
89
  unit: "credits",
41
90
  };
42
91
  }
@@ -47,18 +96,19 @@ export function estimateChatSolo(args) {
47
96
  return {
48
97
  upper_bound: total,
49
98
  formula: "chat_solo",
50
- breakdown: `${args.participantCount} participant(s) × max(1, round(${args.maxTurns} turns / 10)) = ${args.participantCount} × ${perParticipant} = ${total}`,
99
+ breakdown: `${args.participantCount} participant(s) × max(1, round(${args.maxTurns} turns × ${PER_STEP_CREDITS.chat})) = ${args.participantCount} × ${perParticipant} = ${total}`,
51
100
  unit: "credits",
52
101
  };
53
102
  }
54
103
  /** Participant-pair chat: each turn bills both sides, so cost doubles. */
55
104
  export function estimateChatPair(args) {
56
- const perSide = chatCreditCost(args.maxTurns);
57
- const total = Math.max(0, args.conversationCount) * perSide * 2;
105
+ const perConversation = chatCreditCost(args.maxTurns, true);
106
+ const perSide = perConversation / PAIR_CHAT_SIDE_MULTIPLIER;
107
+ const total = Math.max(0, args.conversationCount) * perConversation;
58
108
  return {
59
109
  upper_bound: total,
60
110
  formula: "chat_pair",
61
- breakdown: `${args.conversationCount} conv × max(1, round(${args.maxTurns} turns / 10)) × 2 sides = ${args.conversationCount} × ${perSide} × 2 = ${total}`,
111
+ breakdown: `${args.conversationCount} conv × max(1, round(${args.maxTurns} turns × ${PER_STEP_CREDITS.chat})) × ${PAIR_CHAT_SIDE_MULTIPLIER} sides = ${args.conversationCount} × ${perSide} × ${PAIR_CHAT_SIDE_MULTIPLIER} = ${total}`,
62
112
  unit: "credits",
63
113
  };
64
114
  }
@@ -67,11 +117,11 @@ export function estimateChatPair(args) {
67
117
  * for completed responses; the upper bound assumes everyone completes).
68
118
  */
69
119
  export function estimateAskRound(args) {
70
- const total = Math.max(0, args.participantCount);
120
+ const total = Math.max(0, args.participantCount) * ASK_PER_RESPONSE_CREDITS;
71
121
  return {
72
122
  upper_bound: total,
73
123
  formula: "ask_per_response",
74
- breakdown: `${args.participantCount} participant(s) × 1 credit/response = ${total} (upper bound; only successful responses bill)`,
124
+ breakdown: `${args.participantCount} participant(s) × ${ASK_PER_RESPONSE_CREDITS} credit/response = ${total} (upper bound; only successful responses bill)`,
75
125
  unit: "credits",
76
126
  };
77
127
  }