humanish 0.113.0 → 0.115.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 (109) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/CONTEXT.md +1 -0
  3. package/README.md +1 -1
  4. package/dist/actors/closing-report-limits.d.ts +11 -0
  5. package/dist/actors/closing-report-limits.js +13 -0
  6. package/dist/actors/closing-report.d.ts +68 -0
  7. package/dist/actors/closing-report.js +55 -0
  8. package/dist/actors/codex/restricted-participant-policy.d.ts +4 -47
  9. package/dist/actors/codex/restricted-participant-policy.js +12 -40
  10. package/dist/actors/computer-use/loop/debrief.d.ts +1 -10
  11. package/dist/actors/computer-use/loop/debrief.js +10 -46
  12. package/dist/actors/computer-use/loop/impressions.d.ts +2 -2
  13. package/dist/actors/computer-use/loop/impressions.js +6 -1
  14. package/dist/actors/computer-use/loop.d.ts +0 -1
  15. package/dist/actors/computer-use/loop.js +3 -3
  16. package/dist/actors/computer-use/openai-conversation.d.ts +1 -1
  17. package/dist/actors/computer-use/openai-conversation.js +12 -36
  18. package/dist/actors/computer-use/openai-provider.js +11 -5
  19. package/dist/actors/contract.d.ts +6 -22
  20. package/dist/actors/contract.js +0 -13
  21. package/dist/analysis/admission.d.ts +47 -0
  22. package/dist/analysis/admission.js +65 -0
  23. package/dist/analysis/automatic-completion.d.ts +0 -6
  24. package/dist/analysis/automatic-completion.js +3 -3
  25. package/dist/analysis/automatic-config.d.ts +10 -2
  26. package/dist/analysis/automatic-config.js +29 -14
  27. package/dist/analysis/automatic.js +7 -0
  28. package/dist/analysis/execute.d.ts +24 -5
  29. package/dist/analysis/execute.js +61 -27
  30. package/dist/analysis/finding-lead.d.ts +27 -0
  31. package/dist/analysis/finding-lead.js +23 -0
  32. package/dist/analysis/image-tokens.d.ts +2 -0
  33. package/dist/analysis/image-tokens.js +1 -1
  34. package/dist/analysis/job.d.ts +9 -1
  35. package/dist/analysis/job.js +11 -2
  36. package/dist/analysis/service.js +8 -3
  37. package/dist/cli/commands/analyze.js +7 -2
  38. package/dist/cli/commands/notes.d.ts +3 -0
  39. package/dist/cli/commands/notes.js +145 -0
  40. package/dist/cli/commands/observe.js +1 -1
  41. package/dist/cli/commands/study-run.js +2 -1
  42. package/dist/cli/findings.js +11 -30
  43. package/dist/cli/io.d.ts +1 -0
  44. package/dist/cli/io.js +8 -12
  45. package/dist/cli/program.js +2 -0
  46. package/dist/cli/telemetry.js +10 -22
  47. package/dist/cli/update-check.js +6 -18
  48. package/dist/cli/user-config.d.ts +11 -0
  49. package/dist/cli/user-config.js +25 -0
  50. package/dist/comms/email-catch.js +7 -31
  51. package/dist/evidence/encoded-text.d.ts +17 -3
  52. package/dist/evidence/encoded-text.js +121 -46
  53. package/dist/evidence/redaction.d.ts +11 -0
  54. package/dist/evidence/redaction.js +21 -10
  55. package/dist/evidence/secret-scrub.d.ts +14 -1
  56. package/dist/evidence/secret-scrub.js +125 -28
  57. package/dist/feedback/draft.d.ts +19 -0
  58. package/dist/feedback/draft.js +62 -15
  59. package/dist/feedback/export-bundle.js +3 -3
  60. package/dist/feedback/export.js +50 -20
  61. package/dist/feedback/feedback.js +29 -2
  62. package/dist/keys/key-resolution.js +12 -17
  63. package/dist/observer/artifact.d.ts +5 -0
  64. package/dist/observer/artifact.js +18 -2
  65. package/dist/observer/http.d.ts +7 -0
  66. package/dist/observer/http.js +19 -1
  67. package/dist/observer/notes-route.d.ts +20 -0
  68. package/dist/observer/notes-route.js +120 -0
  69. package/dist/observer/pinned-files.d.ts +8 -1
  70. package/dist/observer/pinned-files.js +17 -3
  71. package/dist/observer/render.js +39 -14
  72. package/dist/observer/run-routes.d.ts +8 -2
  73. package/dist/observer/run-routes.js +32 -6
  74. package/dist/observer/serve.d.ts +3 -0
  75. package/dist/observer/serve.js +29 -3
  76. package/dist/observer-app.html +7 -6
  77. package/dist/run/contained-output.d.ts +8 -0
  78. package/dist/run/contained-output.js +27 -0
  79. package/dist/run/evidence-files.d.ts +7 -0
  80. package/dist/run/evidence-files.js +24 -14
  81. package/dist/run/note-files.d.ts +54 -0
  82. package/dist/run/note-files.js +212 -0
  83. package/dist/run/note-shape.d.ts +35 -0
  84. package/dist/run/note-shape.js +20 -0
  85. package/dist/run/notes.d.ts +34 -0
  86. package/dist/run/notes.js +192 -0
  87. package/dist/run/participant-caption.d.ts +9 -0
  88. package/dist/run/participant-caption.js +19 -0
  89. package/dist/run/participant-records.d.ts +2 -0
  90. package/dist/run/participant-records.js +17 -2
  91. package/dist/run/transient-comms-secrets.d.ts +6 -5
  92. package/dist/run/transient-comms-secrets.js +19 -24
  93. package/dist/study/doctor.js +1 -1
  94. package/dist/study/preflight-result.js +2 -1
  95. package/dist/study/summary.d.ts +4 -5
  96. package/dist/study/summary.js +6 -4
  97. package/dist/study/warnings.js +2 -1
  98. package/dist/tui-app.js +1 -1
  99. package/docs/architecture/actor-contract.md +3 -3
  100. package/docs/architecture/restricted-codex-analysis.md +1 -1
  101. package/docs/architecture/state-driven-executor.md +1 -1
  102. package/docs/contracts/errors.md +2 -2
  103. package/docs/contracts/run-bundle.md +61 -0
  104. package/docs/contracts/schemas.md +17 -14
  105. package/docs/contracts/study-analysis.md +35 -9
  106. package/docs/principles/invariants-and-defaults.md +2 -2
  107. package/docs/product/automatic-analysis.md +22 -16
  108. package/package.json +8 -7
  109. package/skills/humanish/SKILL.md +6 -2
@@ -1,4 +1,4 @@
1
- import { PARTICIPANT_IMPRESSION_KINDS } from "../contract.js";
1
+ import { closingReportSchema, impressionsReplySchema, strictOutputSchema, } from "../closing-report.js";
2
2
  import { CarriedConversation, estimateTokens } from "./openai-context.js";
3
3
  import { buildCallOutput, buildContinuationRequest, buildInitialRequest, hintItems, openingMessage, parseOpenAiResponse, } from "./openai-wire.js";
4
4
  /**
@@ -6,44 +6,18 @@ import { buildCallOutput, buildContinuationRequest, buildInitialRequest, hintIte
6
6
  * counts reasoning too, and leaves room for six impressions after the summary and friction reports.
7
7
  */
8
8
  const CLOSING_OUTPUT_LIMIT = 3072;
9
- const IMPRESSIONS_SCHEMA = {
10
- type: "array",
11
- items: {
12
- type: "object",
13
- additionalProperties: false,
14
- required: ["kind", "text"],
15
- properties: {
16
- kind: { type: "string", enum: [...PARTICIPANT_IMPRESSION_KINDS] },
17
- text: { type: "string" },
18
- },
19
- },
20
- };
21
9
  const CLOSING_FORMATS = {
22
10
  report: {
23
11
  type: "json_schema",
24
12
  name: "participant_closing_report",
25
13
  strict: true,
26
- schema: {
27
- type: "object",
28
- additionalProperties: false,
29
- required: ["summary", "frictionReports", "impressions"],
30
- properties: {
31
- summary: { type: "string" },
32
- frictionReports: { type: "array", items: { type: "string" } },
33
- impressions: IMPRESSIONS_SCHEMA,
34
- },
35
- },
14
+ schema: strictOutputSchema(closingReportSchema),
36
15
  },
37
16
  impressions: {
38
17
  type: "json_schema",
39
18
  name: "participant_impressions",
40
19
  strict: true,
41
- schema: {
42
- type: "object",
43
- additionalProperties: false,
44
- required: ["impressions"],
45
- properties: { impressions: IMPRESSIONS_SCHEMA },
46
- },
20
+ schema: strictOutputSchema(impressionsReplySchema),
47
21
  },
48
22
  };
49
23
  export class OpenAiConversation {
@@ -73,7 +47,7 @@ export class OpenAiConversation {
73
47
  const sent = this.newItems(req);
74
48
  return {
75
49
  body: () => closing === undefined ? this.turnBody(req, sent) : this.closingBody(req, sent, closing),
76
- accept: (reply) => this.accept(reply, closing !== undefined, sent),
50
+ accept: (reply) => this.accept(reply, closing, sent),
77
51
  };
78
52
  }
79
53
  /**
@@ -171,10 +145,10 @@ export class OpenAiConversation {
171
145
  accept(reply, closing, sent) {
172
146
  // Recorded before the reply is accepted: accepting can cut the carried history, and the record
173
147
  // counts what this request carried.
174
- if (!closing)
175
- this.evidence.requested(this.mode, this.carried, sent);
148
+ if (closing !== "report")
149
+ this.evidence.requested(this.mode, this.carried, sent, closing);
176
150
  const parsed = parseOpenAiResponse(reply);
177
- if (!closing && parsed.turn.interruption === "output_limit")
151
+ if (closing === undefined && parsed.turn.interruption === "output_limit")
178
152
  return parsed.turn;
179
153
  if (parsed.turn.responseId !== undefined)
180
154
  this.lastResponseId = parsed.turn.responseId;
@@ -206,10 +180,11 @@ class ConversationRecord {
206
180
  // Counted like requests[], which includes replies set aside at their output limit.
207
181
  this.switchedAtRequest = this.requests.length + 1;
208
182
  }
209
- /** A participant request went out with `sent` after `conversation`. */
210
- requested(mode, conversation, sent) {
183
+ /** A participant turn or impressions request went out with `sent` after `conversation`. */
184
+ requested(mode, conversation, sent, kind) {
185
+ const marker = kind === undefined ? {} : { kind };
211
186
  if (mode !== "explicit_context") {
212
- this.requests.push({ mode: "threaded" });
187
+ this.requests.push({ mode: "threaded", ...marker });
213
188
  return;
214
189
  }
215
190
  const carried = conversation.size();
@@ -217,6 +192,7 @@ class ConversationRecord {
217
192
  this.summarizedTurns = carried.notedTurns;
218
193
  this.requests.push({
219
194
  mode: "explicit_context",
195
+ ...marker,
220
196
  carriedExchanges: carried.exchanges,
221
197
  carriedScreenshots: carried.screenshots,
222
198
  estimatedInputTokens: carried.estimatedTokens + estimateTokens(sent),
@@ -1,4 +1,4 @@
1
- import { validClosingReport, validImpressionsReply } from "./loop.js";
1
+ import { closingReportSchema, impressionsReplySchema } from "../closing-report.js";
2
2
  import { ComputerUseAdmissionLimitError, isComputerUseAdmissionLimitError, } from "./admission-limit.js";
3
3
  import { ComputerUsePromptRefusedError } from "./provider-error.js";
4
4
  import { OpenAiConversation, } from "./openai-conversation.js";
@@ -469,10 +469,16 @@ export function createOpenAiResponsesProvider(options) {
469
469
  try {
470
470
  const reply = JSON.parse(turn.message ?? "");
471
471
  if (asRecord(raw).status === "completed") {
472
- if (closing === "report" && validClosingReport(reply))
473
- return { ...turn, closingReport: reply };
474
- if (closing === "impressions" && validImpressionsReply(reply))
475
- return { ...turn, impressions: reply.impressions };
472
+ if (closing === "report") {
473
+ const report = closingReportSchema.safeParse(reply);
474
+ if (report.success)
475
+ return { ...turn, closingReport: report.data };
476
+ }
477
+ else {
478
+ const impressions = impressionsReplySchema.safeParse(reply);
479
+ if (impressions.success)
480
+ return { ...turn, impressions: impressions.data.impressions };
481
+ }
476
482
  }
477
483
  }
478
484
  catch {
@@ -1,8 +1,10 @@
1
1
  import type { AffordanceUse } from "./affordance.js";
2
2
  import type { ActorEstimatedCost } from "../run/pricing.js";
3
3
  import type { TaskFunnel } from "../study/tasks.js";
4
+ import type { ParticipantClosingReport, ParticipantImpression } from "./closing-report.js";
4
5
  import { type CuaProviderFailurePhase } from "./computer-use/provider-error.js";
5
6
  export declare const ACTOR_TRACE_SCHEMA = "humanish.actor-trace.v1";
7
+ export type { ParticipantClosingReport, ParticipantImpression } from "./closing-report.js";
6
8
  /**
7
9
  * How a session ended, from the point of view of the study rather than the harness.
8
10
  *
@@ -182,25 +184,6 @@ export interface ActorProviderRequest extends ProviderRequestReceipt {
182
184
  export declare function validActorExecutionProfile(value: unknown): value is ActorExecutionProfile;
183
185
  /** Closed per-attempt evidence; dollar amounts are never part of account usage. */
184
186
  export declare function validActorProviderRequests(value: unknown): value is ActorProviderRequest[];
185
- /** The participant's account, not an independently confirmed product diagnosis. */
186
- export interface ParticipantClosingReport {
187
- summary: string;
188
- frictionReports: string[];
189
- /** Absent when the provider did not ask for impressions; empty when the participant had none. */
190
- impressions?: ParticipantImpression[];
191
- }
192
- /**
193
- * What a participant can say about the product at the end of a session, beyond friction.
194
- * `unlike_my_work` compares the screen with how the persona does the same task in its own work or
195
- * life.
196
- */
197
- export declare const PARTICIPANT_IMPRESSION_KINDS: readonly ["unclear", "unfinished", "untrustworthy", "liked", "missing", "unlike_my_work"];
198
- type ParticipantImpressionKind = (typeof PARTICIPANT_IMPRESSION_KINDS)[number];
199
- /** One first-person impression about something the participant saw in the session. */
200
- export interface ParticipantImpression {
201
- kind: ParticipantImpressionKind;
202
- text: string;
203
- }
204
187
  /** An impression as the trace keeps it, with the message that quotes it. */
205
188
  interface RecordedImpression extends ParticipantImpression {
206
189
  /** The trace message carrying the impression, which the analysis quotes and cites. */
@@ -261,11 +244,13 @@ export interface ActorConversation {
261
244
  */
262
245
  summarizedTurns: number;
263
246
  /**
264
- * Per participant request, in order: its mode and, in explicit_context, what it carried and its
265
- * estimated input. `tokenUsage.turns` has each request's billed input.
247
+ * Per participant turn or impressions request, in order: its mode and, in explicit_context,
248
+ * what it carried and its estimated input. `tokenUsage.turns` has each request's billed input.
266
249
  */
267
250
  requests: Array<{
268
251
  mode: "threaded" | "explicit_context";
252
+ /** Present for the impressions-only request; absent for participant turns and older bundles. */
253
+ kind?: "impressions";
269
254
  carriedExchanges?: number;
270
255
  carriedScreenshots?: number;
271
256
  estimatedInputTokens?: number;
@@ -397,4 +382,3 @@ export interface ActorSessionResult {
397
382
  export declare const CODEX_APP_SERVER_CAPABILITIES: ActorCapabilities;
398
383
  export declare const SCRIPTED_BROWSER_CAPABILITIES: ActorCapabilities;
399
384
  export declare const TERMINAL_AGENT_CAPABILITIES: ActorCapabilities;
400
- export {};
@@ -168,19 +168,6 @@ export function validActorProviderRequests(value) {
168
168
  usage.input));
169
169
  });
170
170
  }
171
- /**
172
- * What a participant can say about the product at the end of a session, beyond friction.
173
- * `unlike_my_work` compares the screen with how the persona does the same task in its own work or
174
- * life.
175
- */
176
- export const PARTICIPANT_IMPRESSION_KINDS = [
177
- "unclear",
178
- "unfinished",
179
- "untrustworthy",
180
- "liked",
181
- "missing",
182
- "unlike_my_work",
183
- ];
184
171
  export const CODEX_APP_SERVER_CAPABILITIES = {
185
172
  headless: true,
186
173
  structuredTrace: true,
@@ -0,0 +1,47 @@
1
+ import type { ModelRate } from "../run/pricing.js";
2
+ /** Admission compares the expected cost times this margin with the cap. */
3
+ export declare const ADMISSION_MARGIN = 1.1;
4
+ export interface AnalysisRequestSize {
5
+ /** UTF-8 bytes of the instructions, the evidence packet and the result schema. */
6
+ textBytes: number;
7
+ imageTokens: number;
8
+ participants: number;
9
+ /** The request's output limit. The worst case spends all of it. */
10
+ outputAllowance: number;
11
+ }
12
+ export interface AnalysisCostEstimate {
13
+ inputTokens: number;
14
+ expectedOutputTokens: number;
15
+ expectedCostUsd: number;
16
+ worstCaseCostUsd: number;
17
+ /** The expected cost with the margin, never above the worst case: what admission compares. */
18
+ admittedCostUsd: number;
19
+ }
20
+ export declare function estimateAnalysisCost(rate: ModelRate, size: AnalysisRequestSize): AnalysisCostEstimate;
21
+ /** The numbers a cost refusal reports. */
22
+ export interface RefusedAnalysisCost {
23
+ expectedCostUsd: number;
24
+ worstCaseCostUsd: number;
25
+ maxCostUsd: number;
26
+ }
27
+ /** A cost refusal in plain words, and the command that runs the analysis under a cap that admits it. */
28
+ export interface CostRefusal {
29
+ /** The costs, the cap and that no request was sent, ending in the lead-in to `command`. */
30
+ text: string;
31
+ /** `analyze` with `--max-cost` at the worst case rounded up to a whole dollar, at least 1. */
32
+ command: string;
33
+ }
34
+ /**
35
+ * Words a cost refusal. `runFlags` are the `analyze` flags that select the run, such as
36
+ * `--run <id>`. `cli` prefixes a humanish command: the CLI passes `cli` from
37
+ * src/cli/invocation.ts, and the Observer, which cannot read the install, a bare `humanish`.
38
+ * This module keeps only type imports, so the Observer can bundle it.
39
+ */
40
+ export declare function costRefusal(cost: RefusedAnalysisCost, runFlags: string, cli: (rest: string) => string): CostRefusal;
41
+ /** The admission's refused cost, when it refused the analysis for its cost. */
42
+ export declare function refusedCost(admission: {
43
+ error: string | null;
44
+ estimatedCostUsd: number | null;
45
+ worstCaseCostUsd: number | null;
46
+ maxCostUsd: number | null;
47
+ }): RefusedAnalysisCost | undefined;
@@ -0,0 +1,65 @@
1
+ // The admission cost model for one study analysis request: the expected cost, the worst case, the
2
+ // figure admission compares with the cap, and the words and command a cost refusal gives.
3
+ /**
4
+ * UTF-8 bytes per input token for the instructions, evidence packet and schema. On 148 billed
5
+ * gpt-6-astra analyses the evidence packet ran 3.1 to 3.3 bytes per token and the instructions
6
+ * more, so 3 stays above every billed input. Chinese, Japanese and Korean text is 3 bytes per
7
+ * character in UTF-8, so it also counts at least a token per character.
8
+ */
9
+ const BYTES_PER_INPUT_TOKEN = 3;
10
+ /** Request framing on top of the text and the images. */
11
+ const FRAMING_TOKENS = 2048;
12
+ /**
13
+ * The output an analysis is expected to write, reasoning included. The most any billed analysis
14
+ * used was 12,511 tokens for one participant and 16,722 for eight.
15
+ */
16
+ const EXPECTED_OUTPUT_TOKENS = 12_000;
17
+ const EXPECTED_OUTPUT_TOKENS_PER_PARTICIPANT = 1_000;
18
+ /** Admission compares the expected cost times this margin with the cap. */
19
+ export const ADMISSION_MARGIN = 1.1;
20
+ /** Rounded up to the micro-dollar, so a small positive boundary never rounds into admission. */
21
+ const roundUp = (usd) => Math.ceil(usd * 1e6) / 1e6;
22
+ /** One request's cost. Input is priced at the highest input rate the model bills, cache writes
23
+ * included, and the long-context tier re-prices the whole request past its threshold. */
24
+ function requestCost(rate, inputTokens, outputTokens) {
25
+ const long = rate.longContext !== undefined && inputTokens > rate.longContext.thresholdInputTokens;
26
+ const inputRate = Math.max(rate.inputUsdPerToken, rate.cacheWriteUsdPerToken ?? 0, rate.cachedInputUsdPerToken ?? 0);
27
+ return roundUp(inputTokens * inputRate * (long ? rate.longContext.inputMultiplier : 1) +
28
+ outputTokens * rate.outputUsdPerToken * (long ? rate.longContext.outputMultiplier : 1));
29
+ }
30
+ export function estimateAnalysisCost(rate, size) {
31
+ const inputTokens = Math.ceil(size.textBytes / BYTES_PER_INPUT_TOKEN) + FRAMING_TOKENS + size.imageTokens;
32
+ const expectedOutputTokens = Math.min(size.outputAllowance, EXPECTED_OUTPUT_TOKENS + EXPECTED_OUTPUT_TOKENS_PER_PARTICIPANT * size.participants);
33
+ const expectedCostUsd = requestCost(rate, inputTokens, expectedOutputTokens);
34
+ const worstCaseCostUsd = requestCost(rate, inputTokens, size.outputAllowance);
35
+ return {
36
+ inputTokens,
37
+ expectedOutputTokens,
38
+ expectedCostUsd,
39
+ worstCaseCostUsd,
40
+ admittedCostUsd: Math.min(worstCaseCostUsd, roundUp(expectedCostUsd * ADMISSION_MARGIN)),
41
+ };
42
+ }
43
+ /**
44
+ * Words a cost refusal. `runFlags` are the `analyze` flags that select the run, such as
45
+ * `--run <id>`. `cli` prefixes a humanish command: the CLI passes `cli` from
46
+ * src/cli/invocation.ts, and the Observer, which cannot read the install, a bare `humanish`.
47
+ * This module keeps only type imports, so the Observer can bundle it.
48
+ */
49
+ export function costRefusal(cost, runFlags, cli) {
50
+ const margin = Math.round((ADMISSION_MARGIN - 1) * 100);
51
+ return {
52
+ text: `The expected cost is $${cost.expectedCostUsd.toFixed(2)} and the worst case is $${cost.worstCaseCostUsd.toFixed(2)}. With a ${margin}% margin the expected cost is over the $${cost.maxCostUsd} cap, so no request was sent. To run it, raise the cap:`,
53
+ command: cli(`analyze ${runFlags} --max-cost ${Math.max(1, Math.ceil(cost.worstCaseCostUsd))}`),
54
+ };
55
+ }
56
+ /** The admission's refused cost, when it refused the analysis for its cost. */
57
+ export function refusedCost(admission) {
58
+ const { error, estimatedCostUsd, worstCaseCostUsd, maxCostUsd } = admission;
59
+ return error === "analysis_budget_exceeded" &&
60
+ estimatedCostUsd !== null &&
61
+ worstCaseCostUsd !== null &&
62
+ maxCostUsd !== null
63
+ ? { expectedCostUsd: estimatedCostUsd, worstCaseCostUsd, maxCostUsd }
64
+ : undefined;
65
+ }
@@ -35,10 +35,4 @@ export declare function completeAutomaticAnalysis<T extends {
35
35
  /** The route's reason not to analyze this run; see AutomaticAnalysisDeps.refusal. */
36
36
  refusal?: AutomaticAnalysisDeps["refusal"];
37
37
  }): Promise<T & AutomaticAnalysisResult>;
38
- /**
39
- * True when an analysis the study never declared was refused because its conservative estimate is
40
- * over the default cap. The author did not ask for it, so the refusal does not fail the run;
41
- * `humanish analyze --max-cost` can still run it.
42
- */
43
- export declare function defaultAnalysisOverBudget(result: AutomaticAnalysisResult): boolean;
44
38
  export declare function automaticAnalysisSucceeded(result: AutomaticAnalysisResult): boolean;
@@ -85,11 +85,11 @@ export async function completeAutomaticAnalysis(result, finished, config, input,
85
85
  }
86
86
  }
87
87
  /**
88
- * True when an analysis the study never declared was refused because its conservative estimate is
89
- * over the default cap. The author did not ask for it, so the refusal does not fail the run;
88
+ * True when an analysis the study never declared was refused because its expected cost, with the
89
+ * admission margin, is over the default cap. The author did not ask for it, so the refusal does not fail the run;
90
90
  * `humanish analyze --max-cost` can still run it.
91
91
  */
92
- export function defaultAnalysisOverBudget(result) {
92
+ function defaultAnalysisOverBudget(result) {
93
93
  const value = result.automaticAnalysis;
94
94
  return (result.automaticAnalysisTrigger === "default" &&
95
95
  value?.state === "skipped" &&
@@ -34,8 +34,16 @@ export interface AutomaticAnalysisBudget {
34
34
  model: string;
35
35
  maxCostUsd: number | null;
36
36
  trigger: "default" | "explicit";
37
+ /** The participants the expected cost range is for. */
38
+ participants?: number;
39
+ /** The expected cost, from a run that keeps no evidence to one at the evidence limits. */
40
+ expectedCostUsd?: {
41
+ low: number;
42
+ high: number;
43
+ };
37
44
  }
38
- /** Metadata only: resolving the future live-run budget never reads keys or dispatches. */
39
- export declare function automaticAnalysisBudget(raw: unknown, route: StudyRoute): AutomaticAnalysisBudget | undefined;
45
+ /** Metadata only: resolving the future live-run budget never reads keys or dispatches. With a
46
+ * participant count it also gives the expected cost range for that many participants. */
47
+ export declare function automaticAnalysisBudget(raw: unknown, route: StudyRoute, participants?: number): AutomaticAnalysisBudget | undefined;
40
48
  export declare function formatAutomaticAnalysisBudget(budget: AutomaticAnalysisBudget): string;
41
49
  export {};
@@ -1,5 +1,7 @@
1
1
  import { CODEX_ANALYSIS_MODEL, codexAnalysisIdentity } from "./codex-config.js";
2
- import { DEFAULT_ANALYSIS_MAX_OUTPUT_TOKENS, MAX_ANALYSIS_OUTPUT_TOKENS, isSupportedAnalysisModel, } from "./execute.js";
2
+ import { DEFAULT_ANALYSIS_MAX_OUTPUT_TOKENS, MAX_ANALYSIS_OUTPUT_TOKENS, analysisCostRange, isSupportedAnalysisModel, } from "./execute.js";
3
+ import { ADMISSION_MARGIN } from "./admission.js";
4
+ import { plural } from "../run/text.js";
3
5
  import { containsSensitive } from "../evidence/redaction.js";
4
6
  export const DEFAULT_ANALYSIS_TIMEOUT_MS = 600_000;
5
7
  const MAX_ANALYSIS_TIMEOUT_MS = 600_000;
@@ -111,25 +113,38 @@ export function resolveAutomaticAnalysis(raw) {
111
113
  ...(value.maxOutputTokens === undefined ? { preferLargerOutput: true } : {}),
112
114
  };
113
115
  }
114
- /** Metadata only: resolving the future live-run budget never reads keys or dispatches. */
115
- export function automaticAnalysisBudget(raw, route) {
116
+ /** Metadata only: resolving the future live-run budget never reads keys or dispatches. With a
117
+ * participant count it also gives the expected cost range for that many participants. */
118
+ export function automaticAnalysisBudget(raw, route, participants) {
116
119
  // The synthetic preview has no participants, so nothing is analyzed after it.
117
120
  if (route === "preview")
118
121
  return undefined;
119
122
  const resolved = resolveAutomaticAnalysis(raw);
120
- return resolved.ok && resolved.config
121
- ? {
122
- ...(resolved.config.provider === "codex"
123
- ? { provider: "codex", billing: "account-unknown" }
124
- : {}),
125
- model: resolved.config.model,
126
- maxCostUsd: resolved.config.maxCostUsd,
127
- trigger: raw === undefined ? "default" : "explicit",
128
- }
129
- : undefined;
123
+ if (!resolved.ok || !resolved.config)
124
+ return undefined;
125
+ const { config } = resolved;
126
+ const range = participants === undefined || config.provider === "codex"
127
+ ? undefined
128
+ : analysisCostRange(resolved.preferLargerOutput
129
+ ? { ...config, maxOutputTokens: MAX_ANALYSIS_OUTPUT_TOKENS }
130
+ : config, participants);
131
+ return {
132
+ ...(config.provider === "codex"
133
+ ? { provider: "codex", billing: "account-unknown" }
134
+ : {}),
135
+ model: config.model,
136
+ maxCostUsd: config.maxCostUsd,
137
+ trigger: raw === undefined ? "default" : "explicit",
138
+ ...(range === undefined || participants === undefined
139
+ ? {}
140
+ : { participants, expectedCostUsd: range }),
141
+ };
130
142
  }
131
143
  export function formatAutomaticAnalysisBudget(budget) {
132
144
  if (budget.provider === "codex")
133
145
  return `After live runs: Codex account analysis · ${budget.model} · separate restricted analyst with remote inference. Account limits apply; dollar cost and output-token ceiling are unknown. Set review.analysis: false to disable.`;
134
- return `After live runs: ${budget.trigger} analysis · ${budget.model} · refused before it starts if its estimate is over $${budget.maxCostUsd}; this is not a billing cap. Set review.analysis: false to disable.`;
146
+ const range = budget.expectedCostUsd === undefined || budget.participants === undefined
147
+ ? ""
148
+ : ` · expected $${budget.expectedCostUsd.low.toFixed(2)} to $${budget.expectedCostUsd.high.toFixed(2)} for ${plural(budget.participants, "participant")}, depending on how much evidence the run keeps`;
149
+ return `After live runs: ${budget.trigger} analysis · ${budget.model}${range} · refused before it starts if the expected cost plus a ${Math.round((ADMISSION_MARGIN - 1) * 100)}% margin is over $${budget.maxCostUsd}; this is not a billing cap. Set review.analysis: false to disable.`;
135
150
  }
@@ -5,6 +5,7 @@ import { refreshObserver, analyzeRun, readCompletedAnalysisSource, resolveAnalys
5
5
  import { preferLargerAnalysisOutput, ANALYSIS_PROMPT_VERSION } from "./execute.js";
6
6
  import { captureEvidence } from "./evidence.js";
7
7
  import { hashAnalysisValue } from "./validation.js";
8
+ import { refusedCost } from "./admission.js";
8
9
  import { claimAutomaticAnalysis, readAutomaticAnalysisPrepared, requestAutomaticAnalysisCancellationPrepared, } from "./job.js";
9
10
  import { ANALYSIS_ID_PATTERN } from "./types.js";
10
11
  import { readAnalysisVersion } from "./store.js";
@@ -105,6 +106,11 @@ async function readAutomaticSource(cwd, prepared, configInput, deps, hasKey) {
105
106
  return skipped("AUTOMATIC_ANALYSIS_SOURCE_UNAVAILABLE");
106
107
  }
107
108
  }
109
+ /** The cost of an analysis admission refused for its cost, kept so review and Observer can say it. */
110
+ function withRefusedCost(result) {
111
+ const cost = result?.admission && refusedCost(result.admission);
112
+ return cost ? { admission: cost } : {};
113
+ }
108
114
  /**
109
115
  * Bind the job's terminal metadata to the exact safely published execution. Reads never promote a
110
116
  * completed-looking sidecar without rechecking these bindings. Null when storage failed.
@@ -130,6 +136,7 @@ async function persistOutcome(job, prepared, outcome) {
130
136
  receiptSha256: hashAnalysisValue(receipt),
131
137
  }),
132
138
  ...(entry?.analysis ? { analysisSha256: hashAnalysisValue(entry.analysis) } : {}),
139
+ ...withRefusedCost(outcome.result),
133
140
  });
134
141
  const persisted = await readAutomaticAnalysisPrepared(prepared);
135
142
  return persisted?.state === "unknown"
@@ -9,9 +9,19 @@ export declare const MAX_ANALYSIS_OUTPUT_TOKENS = 32768;
9
9
  export interface AnalysisAdmission {
10
10
  allowed: boolean;
11
11
  error: string | null;
12
+ /** The input tokens admission priced. */
12
13
  inputTokenAllowance: number | null;
13
14
  outputTokenAllowance: number | null;
15
+ /** The expected cost. Admission compares it, with a margin, with maxCostUsd. */
14
16
  estimatedCostUsd: number | null;
17
+ /** The cost if the analyst spends its whole output allowance. */
18
+ worstCaseCostUsd: number | null;
19
+ /**
20
+ * The expected cost plus the margin, or the worst case when that is lower: the figure admission
21
+ * compares with maxCostUsd, so the smallest cap that admits this analysis.
22
+ */
23
+ admittedCostUsd: number | null;
24
+ maxCostUsd: number | null;
15
25
  ratesAsOf: string | null;
16
26
  }
17
27
  export interface AnalysisProgress {
@@ -23,15 +33,24 @@ export interface AnalysisProgress {
23
33
  }
24
34
  /**
25
35
  * Local admission estimate, not a provider-enforced billed-spend guarantee. No token-count API
26
- * call, credential, or network access. One UTF-8 byte/token for all text/schema plus framing is
27
- * intentionally conservative. Each high-detail image is priced from its PNG size by the vision
28
- * guide's patch formula (see image-tokens.ts), or at the 3,000-token ceiling when its size or the
29
- * model's sizing is unknown. Unknown models/rates fail closed rather than inheriting those
30
- * assumptions.
36
+ * call, credential, or network access. admission.ts holds the cost model: text at a calibrated
37
+ * bytes-per-token ratio, the expected output, and the worst case that spends the whole output
38
+ * allowance. Each high-detail image is priced from its PNG size by the vision guide's patch
39
+ * formula (see image-tokens.ts), or at the 3,000-token ceiling when its size or the model's sizing
40
+ * is unknown. Unknown models/rates fail closed rather than inheriting those assumptions.
31
41
  * Known-sensitive decoded text denies admission with analysis_input_sensitive or
32
42
  * analysis_question_sensitive. Neither error includes rejected input values.
33
43
  */
34
44
  export declare function estimateAnalysisAdmission(input: AnalysisInput, config: AnalysisConfig): AnalysisAdmission;
45
+ /**
46
+ * The expected cost of an analysis for this many participants before any evidence exists: from a
47
+ * packet with no evidence to one at the evidence limits with every capture at the image-token
48
+ * ceiling. Undefined for Codex and for a model without rates.
49
+ */
50
+ export declare function analysisCostRange(config: AnalysisConfig, participants: number): {
51
+ low: number;
52
+ high: number;
53
+ } | undefined;
35
54
  /** Only for an omitted output limit. Preserve the established allowance when
36
55
  * more reasoning/report space would refuse a study its declared budget admits.
37
56
  * This is one pre-dispatch choice, never a fallback request or a budget increase. */
@@ -1,5 +1,6 @@
1
1
  import { validCodexAnalysisConfig } from "./codex-config.js";
2
- import { highDetailImageTokens } from "./image-tokens.js";
2
+ import { HIGH_DETAIL_IMAGE_TOKEN_CEILING, highDetailImageTokens } from "./image-tokens.js";
3
+ import { estimateAnalysisCost } from "./admission.js";
3
4
  import { createHash, randomUUID } from "node:crypto";
4
5
  import { estimateActorCost, MODEL_RATES } from "../run/pricing.js";
5
6
  import { containsSensitive } from "../evidence/redaction.js";
@@ -128,11 +129,11 @@ function inputError(input) {
128
129
  }
129
130
  /**
130
131
  * Local admission estimate, not a provider-enforced billed-spend guarantee. No token-count API
131
- * call, credential, or network access. One UTF-8 byte/token for all text/schema plus framing is
132
- * intentionally conservative. Each high-detail image is priced from its PNG size by the vision
133
- * guide's patch formula (see image-tokens.ts), or at the 3,000-token ceiling when its size or the
134
- * model's sizing is unknown. Unknown models/rates fail closed rather than inheriting those
135
- * assumptions.
132
+ * call, credential, or network access. admission.ts holds the cost model: text at a calibrated
133
+ * bytes-per-token ratio, the expected output, and the worst case that spends the whole output
134
+ * allowance. Each high-detail image is priced from its PNG size by the vision guide's patch
135
+ * formula (see image-tokens.ts), or at the 3,000-token ceiling when its size or the model's sizing
136
+ * is unknown. Unknown models/rates fail closed rather than inheriting those assumptions.
136
137
  * Known-sensitive decoded text denies admission with analysis_input_sensitive or
137
138
  * analysis_question_sensitive. Neither error includes rejected input values.
138
139
  */
@@ -143,6 +144,9 @@ export function estimateAnalysisAdmission(input, config) {
143
144
  inputTokenAllowance: 0,
144
145
  outputTokenAllowance: 0,
145
146
  estimatedCostUsd: null,
147
+ worstCaseCostUsd: null,
148
+ admittedCostUsd: null,
149
+ maxCostUsd: null,
146
150
  ratesAsOf: null,
147
151
  });
148
152
  if ((config.provider === "codex"
@@ -174,6 +178,9 @@ export function estimateAnalysisAdmission(input, config) {
174
178
  inputTokenAllowance: null,
175
179
  outputTokenAllowance: null,
176
180
  estimatedCostUsd: null,
181
+ worstCaseCostUsd: null,
182
+ admittedCostUsd: null,
183
+ maxCostUsd: null,
177
184
  ratesAsOf: null,
178
185
  };
179
186
  const rate = MODEL_RATES[config.model];
@@ -184,32 +191,57 @@ export function estimateAnalysisAdmission(input, config) {
184
191
  !Number.isFinite(rate.outputUsdPerToken) ||
185
192
  rate.outputUsdPerToken < 0)
186
193
  return denied("analysis_rate_unknown");
187
- const inputTokenAllowance = Buffer.byteLength(JSON.stringify({
188
- instructions: instructions(config),
189
- evidence: evidenceText(input),
190
- schema: analysisResultJsonSchema,
191
- })) +
192
- 2048 +
193
- input.images.reduce((sum, image) => sum + highDetailImageTokens(config.model, image.dataUrl), 0);
194
- const long = rate.longContext !== undefined && inputTokenAllowance > rate.longContext.thresholdInputTokens;
195
- const inputRate = Math.max(rate.inputUsdPerToken, rate.cacheWriteUsdPerToken ?? 0, rate.cachedInputUsdPerToken ?? 0);
196
- const estimate = inputTokenAllowance * inputRate * (long ? rate.longContext.inputMultiplier : 1) +
197
- config.maxOutputTokens *
198
- rate.outputUsdPerToken *
199
- (long ? rate.longContext.outputMultiplier : 1);
200
- // Round upward for admission; rounding a small positive boundary down could admit an overrun.
201
- const estimatedCostUsd = Math.ceil(estimate * 1e6) / 1e6;
202
- if (!Number.isFinite(estimatedCostUsd))
194
+ const cost = estimateAnalysisCost(rate, {
195
+ textBytes: Buffer.byteLength(instructions(config)) +
196
+ Buffer.byteLength(evidenceText(input)) +
197
+ Buffer.byteLength(JSON.stringify(analysisResultJsonSchema)),
198
+ imageTokens: input.images.reduce((sum, image) => sum + highDetailImageTokens(config.model, image.dataUrl), 0),
199
+ participants: input.participants.length,
200
+ outputAllowance: config.maxOutputTokens,
201
+ });
202
+ if (!Number.isFinite(cost.worstCaseCostUsd))
203
203
  return denied("analysis_rate_unknown");
204
+ const allowed = cost.admittedCostUsd <= config.maxCostUsd;
204
205
  return {
205
- allowed: estimatedCostUsd <= config.maxCostUsd,
206
- error: estimatedCostUsd <= config.maxCostUsd ? null : "analysis_budget_exceeded",
207
- inputTokenAllowance,
206
+ allowed,
207
+ error: allowed ? null : "analysis_budget_exceeded",
208
+ inputTokenAllowance: cost.inputTokens,
208
209
  outputTokenAllowance: config.maxOutputTokens,
209
- estimatedCostUsd,
210
+ estimatedCostUsd: cost.expectedCostUsd,
211
+ worstCaseCostUsd: cost.worstCaseCostUsd,
212
+ admittedCostUsd: cost.admittedCostUsd,
213
+ maxCostUsd: config.maxCostUsd,
210
214
  ratesAsOf: rate.asOf,
211
215
  };
212
216
  }
217
+ /** Structure each evidence entry adds to the packet: ids, kind, timestamps and flags. The largest
218
+ * retained packet averaged 200 bytes. */
219
+ const ENTRY_STRUCTURE_BYTES = 256;
220
+ /**
221
+ * The expected cost of an analysis for this many participants before any evidence exists: from a
222
+ * packet with no evidence to one at the evidence limits with every capture at the image-token
223
+ * ceiling. Undefined for Codex and for a model without rates.
224
+ */
225
+ export function analysisCostRange(config, participants) {
226
+ if (config.provider === "codex")
227
+ return undefined;
228
+ const rate = MODEL_RATES[config.model];
229
+ if (!rate || rate.placeholder)
230
+ return undefined;
231
+ const prompt = Buffer.byteLength(instructions(config)) +
232
+ Buffer.byteLength(JSON.stringify(analysisResultJsonSchema));
233
+ const size = {
234
+ participants: Math.min(participants, EVIDENCE_LIMITS.participants),
235
+ outputAllowance: config.maxOutputTokens,
236
+ };
237
+ const low = estimateAnalysisCost(rate, { ...size, textBytes: prompt, imageTokens: 0 });
238
+ const high = estimateAnalysisCost(rate, {
239
+ ...size,
240
+ textBytes: prompt + EVIDENCE_LIMITS.textBytes + EVIDENCE_LIMITS.evidence * ENTRY_STRUCTURE_BYTES,
241
+ imageTokens: EVIDENCE_LIMITS.captures * HIGH_DETAIL_IMAGE_TOKEN_CEILING,
242
+ });
243
+ return { low: low.expectedCostUsd, high: high.expectedCostUsd };
244
+ }
213
245
  /** Only for an omitted output limit. Preserve the established allowance when
214
246
  * more reasoning/report space would refuse a study its declared budget admits.
215
247
  * This is one pre-dispatch choice, never a fallback request or a budget increase. */
@@ -464,9 +496,11 @@ function settleCompletedOutput(artifact, input, config, admission, response, onR
464
496
  artifact.result = checked.result;
465
497
  artifact.status = input.coverage.complete ? "complete" : "partial";
466
498
  artifact.error = null;
499
+ // A bill above the expected cost is a normal outcome. Only one above the worst case or the cap
500
+ // shows the estimate was wrong.
467
501
  if (config.provider !== "codex" &&
468
502
  ((response.usage?.output ?? 0) > config.maxOutputTokens ||
469
- (artifact.usage.estimatedCostUsd ?? 0) > (admission.estimatedCostUsd ?? config.maxCostUsd) ||
503
+ (artifact.usage.estimatedCostUsd ?? 0) > (admission.worstCaseCostUsd ?? config.maxCostUsd) ||
470
504
  (artifact.usage.estimatedCostUsd ?? 0) > config.maxCostUsd)) {
471
505
  artifact.status = "partial";
472
506
  artifact.error = "analysis_admission_estimate_exceeded";