humanish 0.112.0 → 0.114.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 (140) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/CONTEXT.md +1 -0
  3. package/README.md +12 -1
  4. package/TELEMETRY.md +21 -0
  5. package/dist/actors/codex/restricted-participant-policy.d.ts +21 -1
  6. package/dist/actors/codex/restricted-participant-policy.js +20 -2
  7. package/dist/actors/codex/restricted-participant.d.ts +1 -1
  8. package/dist/actors/codex/restricted-participant.js +2 -1
  9. package/dist/actors/computer-use/loop/debrief.d.ts +12 -4
  10. package/dist/actors/computer-use/loop/debrief.js +57 -20
  11. package/dist/actors/computer-use/loop/ending.d.ts +5 -2
  12. package/dist/actors/computer-use/loop/impressions.d.ts +14 -0
  13. package/dist/actors/computer-use/loop/impressions.js +40 -0
  14. package/dist/actors/computer-use/loop/session.d.ts +3 -1
  15. package/dist/actors/computer-use/loop/session.js +2 -0
  16. package/dist/actors/computer-use/loop/trace.js +2 -0
  17. package/dist/actors/computer-use/loop/types.d.ts +8 -1
  18. package/dist/actors/computer-use/loop.d.ts +2 -1
  19. package/dist/actors/computer-use/loop.js +15 -3
  20. package/dist/actors/computer-use/openai-conversation.d.ts +9 -4
  21. package/dist/actors/computer-use/openai-conversation.js +60 -29
  22. package/dist/actors/computer-use/openai-provider.js +18 -12
  23. package/dist/actors/contract.d.ts +47 -5
  24. package/dist/actors/contract.js +13 -0
  25. package/dist/analysis/admission.d.ts +37 -0
  26. package/dist/analysis/admission.js +60 -0
  27. package/dist/analysis/automatic-completion.d.ts +0 -6
  28. package/dist/analysis/automatic-completion.js +3 -3
  29. package/dist/analysis/automatic-config.d.ts +10 -2
  30. package/dist/analysis/automatic-config.js +29 -14
  31. package/dist/analysis/automatic.js +7 -0
  32. package/dist/analysis/evidence-sources.d.ts +3 -1
  33. package/dist/analysis/evidence-sources.js +10 -1
  34. package/dist/analysis/evidence.js +2 -2
  35. package/dist/analysis/execute.d.ts +20 -6
  36. package/dist/analysis/execute.js +61 -28
  37. package/dist/analysis/image-tokens.d.ts +2 -0
  38. package/dist/analysis/image-tokens.js +1 -1
  39. package/dist/analysis/job.d.ts +9 -1
  40. package/dist/analysis/job.js +11 -2
  41. package/dist/analysis/service.js +6 -3
  42. package/dist/cli/commands/analyze.js +7 -2
  43. package/dist/cli/commands/notes.d.ts +3 -0
  44. package/dist/cli/commands/notes.js +144 -0
  45. package/dist/cli/commands/observe.js +1 -1
  46. package/dist/cli/commands/runs.js +33 -10
  47. package/dist/cli/commands/setup.js +95 -6
  48. package/dist/cli/commands/study-format.js +7 -4
  49. package/dist/cli/commands/study-run.js +2 -1
  50. package/dist/cli/commands/study.js +1 -1
  51. package/dist/cli/commands/tui.js +1 -1
  52. package/dist/cli/doctor.d.ts +2 -0
  53. package/dist/cli/doctor.js +18 -0
  54. package/dist/cli/findings.js +4 -1
  55. package/dist/cli/io.d.ts +3 -0
  56. package/dist/cli/io.js +11 -12
  57. package/dist/cli/program.js +37 -1
  58. package/dist/cli/secret-prompt.d.ts +5 -1
  59. package/dist/cli/secret-prompt.js +6 -2
  60. package/dist/cli/update-check-worker.d.ts +1 -0
  61. package/dist/cli/update-check-worker.js +5 -0
  62. package/dist/cli/update-check.d.ts +46 -0
  63. package/dist/cli/update-check.js +203 -0
  64. package/dist/evidence/encoded-text.d.ts +15 -2
  65. package/dist/evidence/encoded-text.js +68 -4
  66. package/dist/evidence/secret-scrub.d.ts +1 -1
  67. package/dist/evidence/secret-scrub.js +23 -2
  68. package/dist/feedback/draft.d.ts +19 -0
  69. package/dist/feedback/draft.js +69 -1
  70. package/dist/feedback/export-bundle.js +2 -2
  71. package/dist/feedback/export.js +47 -3
  72. package/dist/feedback/feedback.js +42 -2
  73. package/dist/keys/key-resolution.js +2 -0
  74. package/dist/keys/key-status.d.ts +37 -0
  75. package/dist/keys/key-status.js +68 -0
  76. package/dist/observer/artifact.d.ts +5 -0
  77. package/dist/observer/artifact.js +18 -2
  78. package/dist/observer/notes-route.d.ts +21 -0
  79. package/dist/observer/notes-route.js +140 -0
  80. package/dist/observer/pinned-files.d.ts +8 -1
  81. package/dist/observer/pinned-files.js +32 -3
  82. package/dist/observer/render.js +38 -14
  83. package/dist/observer/run-routes.d.ts +8 -2
  84. package/dist/observer/run-routes.js +47 -6
  85. package/dist/observer/serve.d.ts +3 -0
  86. package/dist/observer/serve.js +28 -3
  87. package/dist/observer-app.html +7 -6
  88. package/dist/routes/computer-use/local-vm.js +9 -2
  89. package/dist/routes/computer-use/participant-desktop.d.ts +6 -0
  90. package/dist/routes/computer-use/participant-execution.js +9 -1
  91. package/dist/routes/computer-use/rerun-selection.js +23 -21
  92. package/dist/routes/computer-use/result.js +3 -2
  93. package/dist/routes/computer-use/self-report.js +7 -1
  94. package/dist/routes/computer-use/setup.js +14 -0
  95. package/dist/routes/computer-use/types.d.ts +4 -1
  96. package/dist/routes/shared-world/bundle-records.js +8 -7
  97. package/dist/routes/shared-world/bundle.js +1 -1
  98. package/dist/run/contained-output.d.ts +7 -0
  99. package/dist/run/contained-output.js +35 -1
  100. package/dist/run/detail.js +4 -4
  101. package/dist/run/note-files.d.ts +73 -0
  102. package/dist/run/note-files.js +184 -0
  103. package/dist/run/notes.d.ts +37 -0
  104. package/dist/run/notes.js +198 -0
  105. package/dist/run/participant-caption.d.ts +11 -1
  106. package/dist/run/participant-caption.js +30 -1
  107. package/dist/run/participant-records.d.ts +3 -1
  108. package/dist/run/participant-records.js +6 -0
  109. package/dist/run-study.js +17 -1
  110. package/dist/study/discover.js +6 -1
  111. package/dist/study/doctor.d.ts +2 -0
  112. package/dist/study/doctor.js +46 -8
  113. package/dist/study/init-templates.js +10 -5
  114. package/dist/study/plan.js +3 -2
  115. package/dist/study/preflight-result.d.ts +1 -0
  116. package/dist/study/preflight-result.js +3 -2
  117. package/dist/study/preflight.d.ts +2 -0
  118. package/dist/study/preflight.js +54 -14
  119. package/dist/study/summary.js +2 -2
  120. package/dist/study/warnings.d.ts +5 -0
  121. package/dist/study/warnings.js +79 -2
  122. package/dist/substrates/local/capacity.d.ts +75 -0
  123. package/dist/substrates/local/capacity.js +143 -0
  124. package/dist/substrates/local/firecracker-desktop.d.ts +8 -0
  125. package/dist/substrates/local/firecracker-desktop.js +34 -3
  126. package/dist/substrates/local/runtime-host.d.ts +16 -3
  127. package/dist/substrates/local/runtime-host.js +49 -4
  128. package/dist/substrates/local/runtime.d.ts +10 -0
  129. package/dist/substrates/local/runtime.js +44 -5
  130. package/dist/tui-app.js +58 -58
  131. package/docs/architecture/actor-contract.md +20 -6
  132. package/docs/contracts/errors.md +2 -2
  133. package/docs/contracts/feedback.md +8 -1
  134. package/docs/contracts/run-bundle.md +57 -0
  135. package/docs/contracts/schemas.md +62 -36
  136. package/docs/contracts/study-analysis.md +41 -8
  137. package/docs/principles/invariants-and-defaults.md +2 -2
  138. package/docs/product/automatic-analysis.md +22 -16
  139. package/package.json +2 -2
  140. package/skills/humanish/SKILL.md +26 -10
@@ -1,9 +1,10 @@
1
1
  import { ComputerUseProviderError } from "./provider-error.js";
2
2
  import { runActionBatch } from "./loop/actions.js";
3
3
  import { advanceBackstop, startBackstop } from "./loop/backstop.js";
4
- import { requestDebrief } from "./loop/debrief.js";
4
+ import { requestDebrief, validClosingReport } from "./loop/debrief.js";
5
5
  import * as stops from "./loop/ending.js";
6
6
  import { declaredOutcomeOf } from "./loop/ending.js";
7
+ import { notCollected, recordImpressions } from "./loop/impressions.js";
7
8
  import { DesktopObserver } from "./loop/observation.js";
8
9
  import { retryAfterOutputLimit } from "./loop/output-limit.js";
9
10
  import { requestTurn } from "./loop/provider-call.js";
@@ -13,7 +14,8 @@ import { accountBillingConflicts } from "./loop/usage.js";
13
14
  import { loopResult, notice } from "./loop/trace.js";
14
15
  export { describeCuaAction } from "./loop/actions.js";
15
16
  export { stableProgressKey } from "./loop/backstop.js";
16
- export { validClosingReport } from "./loop/debrief.js";
17
+ export { validClosingReport, validImpressionsReply } from "./loop/debrief.js";
18
+ export { IMPRESSIONS_ASK } from "./loop/impressions.js";
17
19
  /**
18
20
  * Drive the loop to one explicit completion and return its CuaLoopResult: status, completion
19
21
  * reason, public reason and ActorTrace. Model-authored text passes through redactNarration before
@@ -118,8 +120,18 @@ async function runTurns(session, conversation) {
118
120
  // The declared outcome is kept even if redacting the participant's summary fails.
119
121
  session.declaredOutcome = declaredOutcomeOf(turn);
120
122
  const ended = stops.participantEnded(turn, session.declaredOutcome, (text) => session.redactNarration(text));
123
+ const report = validClosingReport(turn.closingReport) ? turn.closingReport : undefined;
124
+ // A provider that can ask for impressions alone does so after the session, like a debrief.
125
+ if (report?.impressions !== undefined || session.provider.requestImpressions === undefined) {
126
+ session.impressions =
127
+ report === undefined
128
+ ? notCollected("the participant ended the session without a structured closing account")
129
+ : recordImpressions(session, report.impressions);
130
+ }
121
131
  await observer.observeFinalTasks(turnNumber);
122
- return ended;
132
+ return session.impressions === undefined
133
+ ? { ...ended, debriefTrigger: { kind: "participant_end", observation } }
134
+ : ended;
123
135
  }
124
136
  const batch = await runActionBatch(session, turn.actions, turn.shortenedWaits);
125
137
  conversation.previousExecution = batch.execution;
@@ -1,6 +1,6 @@
1
1
  import type { CuaTurn, CuaTurnRequest } from "./loop.js";
2
2
  import type { ReasoningEffort } from "../reasoning-effort.js";
3
- import type { ActorConversation } from "../contract.js";
3
+ import { type ActorConversation } from "../contract.js";
4
4
  import { type OpenAiReasoningSummary } from "./openai-wire.js";
5
5
  /** How a conversation starts. The model settings stay fixed for the whole session. */
6
6
  export interface OpenAiConversationSettings {
@@ -30,6 +30,11 @@ export interface ConversationRequest {
30
30
  }
31
31
  /** Which answer told the provider that the organization keeps no server-side conversation. */
32
32
  export type ZdrRejection = NonNullable<ActorConversation["rejection"]>;
33
+ /**
34
+ * What a closing request asks for: the closing report after a harness-owned stop, or only
35
+ * impressions after the participant ended the session itself.
36
+ */
37
+ export type ClosingRequest = "report" | "impressions";
33
38
  export declare class OpenAiConversation {
34
39
  private readonly settings;
35
40
  private lastResponseId;
@@ -46,9 +51,9 @@ export declare class OpenAiConversation {
46
51
  constructor(settings: OpenAiConversationSettings);
47
52
  /**
48
53
  * The next request, carrying `req`'s screen: the opening request, or the outputs the latest
49
- * reply's computer calls are owed. A closing report asks for a strict JSON report and no tools.
54
+ * reply's computer calls are owed. A closing request asks for strict JSON and no tools.
50
55
  */
51
- request(req: CuaTurnRequest, closing?: boolean): ConversationRequest;
56
+ request(req: CuaTurnRequest, closing?: ClosingRequest): ConversationRequest;
52
57
  /**
53
58
  * The organization rejected server-side state: carry the conversation on the client from the
54
59
  * request being sent, and record which answer made the switch. False when the conversation is
@@ -80,7 +85,7 @@ export declare class OpenAiConversation {
80
85
  * ahead of `sent`.
81
86
  */
82
87
  private turnBody;
83
- /** The read-only closing report: no tools, a small output limit, a strict schema. */
88
+ /** A read-only closing request: no tools, its own output limit, a strict schema. */
84
89
  private closingBody;
85
90
  /**
86
91
  * Move the conversation on to a reply to a request that added `sent`. A participant reply cut
@@ -1,7 +1,51 @@
1
+ import { PARTICIPANT_IMPRESSION_KINDS } from "../contract.js";
1
2
  import { CarriedConversation, estimateTokens } from "./openai-context.js";
2
3
  import { buildCallOutput, buildContinuationRequest, buildInitialRequest, hintItems, openingMessage, parseOpenAiResponse, } from "./openai-wire.js";
3
- /** The closing report's output limit: its own cap, or the declared limit when that is lower. */
4
- const CLOSING_OUTPUT_LIMIT = 1024;
4
+ /**
5
+ * The closing request's output limit: its own cap, or the declared limit when that is lower. It
6
+ * counts reasoning too, and leaves room for six impressions after the summary and friction reports.
7
+ */
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
+ const CLOSING_FORMATS = {
22
+ report: {
23
+ type: "json_schema",
24
+ name: "participant_closing_report",
25
+ 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
+ },
36
+ },
37
+ impressions: {
38
+ type: "json_schema",
39
+ name: "participant_impressions",
40
+ strict: true,
41
+ schema: {
42
+ type: "object",
43
+ additionalProperties: false,
44
+ required: ["impressions"],
45
+ properties: { impressions: IMPRESSIONS_SCHEMA },
46
+ },
47
+ },
48
+ };
5
49
  export class OpenAiConversation {
6
50
  settings;
7
51
  lastResponseId;
@@ -23,12 +67,12 @@ export class OpenAiConversation {
23
67
  }
24
68
  /**
25
69
  * The next request, carrying `req`'s screen: the opening request, or the outputs the latest
26
- * reply's computer calls are owed. A closing report asks for a strict JSON report and no tools.
70
+ * reply's computer calls are owed. A closing request asks for strict JSON and no tools.
27
71
  */
28
- request(req, closing = false) {
72
+ request(req, closing) {
29
73
  const sent = this.newItems(req);
30
74
  return {
31
- body: () => (closing ? this.closingBody(req, sent) : this.turnBody(req, sent)),
75
+ body: () => closing === undefined ? this.turnBody(req, sent) : this.closingBody(req, sent, closing),
32
76
  accept: (reply) => this.accept(reply, closing, sent),
33
77
  };
34
78
  }
@@ -110,28 +154,13 @@ export class OpenAiConversation {
110
154
  ...(this.mode === "explicit_context" ? { explicitContextItems: this.carried.carried() } : {}),
111
155
  });
112
156
  }
113
- /** The read-only closing report: no tools, a small output limit, a strict schema. */
114
- closingBody(req, sent) {
157
+ /** A read-only closing request: no tools, its own output limit, a strict schema. */
158
+ closingBody(req, sent, closing) {
115
159
  return {
116
160
  ...this.turnBody(req, sent),
117
161
  tool_choice: "none",
118
162
  max_output_tokens: Math.min(this.settings.maxOutputTokens ?? CLOSING_OUTPUT_LIMIT, CLOSING_OUTPUT_LIMIT),
119
- text: {
120
- format: {
121
- type: "json_schema",
122
- name: "participant_closing_report",
123
- strict: true,
124
- schema: {
125
- type: "object",
126
- additionalProperties: false,
127
- required: ["summary", "frictionReports"],
128
- properties: {
129
- summary: { type: "string" },
130
- frictionReports: { type: "array", items: { type: "string" } },
131
- },
132
- },
133
- },
134
- },
163
+ text: { format: CLOSING_FORMATS[closing] },
135
164
  };
136
165
  }
137
166
  /**
@@ -142,10 +171,10 @@ export class OpenAiConversation {
142
171
  accept(reply, closing, sent) {
143
172
  // Recorded before the reply is accepted: accepting can cut the carried history, and the record
144
173
  // counts what this request carried.
145
- if (!closing)
146
- this.evidence.requested(this.mode, this.carried, sent);
174
+ if (closing !== "report")
175
+ this.evidence.requested(this.mode, this.carried, sent, closing);
147
176
  const parsed = parseOpenAiResponse(reply);
148
- if (!closing && parsed.turn.interruption === "output_limit")
177
+ if (closing === undefined && parsed.turn.interruption === "output_limit")
149
178
  return parsed.turn;
150
179
  if (parsed.turn.responseId !== undefined)
151
180
  this.lastResponseId = parsed.turn.responseId;
@@ -177,10 +206,11 @@ class ConversationRecord {
177
206
  // Counted like requests[], which includes replies set aside at their output limit.
178
207
  this.switchedAtRequest = this.requests.length + 1;
179
208
  }
180
- /** A participant request went out with `sent` after `conversation`. */
181
- requested(mode, conversation, sent) {
209
+ /** A participant turn or impressions request went out with `sent` after `conversation`. */
210
+ requested(mode, conversation, sent, kind) {
211
+ const marker = kind === undefined ? {} : { kind };
182
212
  if (mode !== "explicit_context") {
183
- this.requests.push({ mode: "threaded" });
213
+ this.requests.push({ mode: "threaded", ...marker });
184
214
  return;
185
215
  }
186
216
  const carried = conversation.size();
@@ -188,6 +218,7 @@ class ConversationRecord {
188
218
  this.summarizedTurns = carried.notedTurns;
189
219
  this.requests.push({
190
220
  mode: "explicit_context",
221
+ ...marker,
191
222
  carriedExchanges: carried.exchanges,
192
223
  carriedScreenshots: carried.screenshots,
193
224
  estimatedInputTokens: carried.estimatedTokens + estimateTokens(sent),
@@ -1,4 +1,4 @@
1
- import { validClosingReport } from "./loop.js";
1
+ import { validClosingReport, validImpressionsReply } from "./loop.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";
@@ -455,21 +455,24 @@ export function createOpenAiResponsesProvider(options) {
455
455
  }
456
456
  }
457
457
  };
458
- const requestTurn = async (req, signal, closing = false, spend) => {
458
+ const requestTurn = async (req, signal, closing, spend) => {
459
459
  await capture.prepareNext();
460
460
  const request = conversation.request(req, closing);
461
- // A closing report makes exactly one request: no HTTP or policy-latch retries.
462
- const raw = closing
463
- ? await post(request.body(), signal, 0, false)
464
- : await postTurn(request, signal, spend);
461
+ // A closing request makes exactly one request: no HTTP or policy-latch retries.
462
+ const raw = closing === undefined
463
+ ? await postTurn(request, signal, spend)
464
+ : await post(request.body(), signal, 0, false);
465
465
  const turn = request.accept(raw);
466
- if (closing) {
466
+ if (closing !== undefined) {
467
467
  // Refusals, incomplete output, malformed JSON, and invalid shapes remain no-report results.
468
468
  // Never promote raw JSON or a fallback paragraph into a structured finding.
469
469
  try {
470
- const report = JSON.parse(turn.message ?? "");
471
- if (asRecord(raw).status === "completed" && validClosingReport(report)) {
472
- return { ...turn, closingReport: report };
470
+ const reply = JSON.parse(turn.message ?? "");
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 };
473
476
  }
474
477
  }
475
478
  catch {
@@ -496,15 +499,18 @@ export function createOpenAiResponsesProvider(options) {
496
499
  get interactionUsageIncomplete() {
497
500
  return interactionUsageIncomplete;
498
501
  },
499
- nextTurn: (req, signal, spend) => requestTurn(req, signal, false, spend),
502
+ nextTurn: (req, signal, spend) => requestTurn(req, signal, undefined, spend),
500
503
  get conversation() {
501
504
  return conversation.record();
502
505
  },
503
506
  // A retrospective report reads the session the server holds; see serverHoldsSession.
504
507
  get debrief() {
505
508
  return conversation.serverHoldsSession
506
- ? (req, signal) => requestTurn(req, signal, true)
509
+ ? (req, signal) => requestTurn(req, signal, "report")
507
510
  : undefined;
508
511
  },
512
+ // Offered in both modes. An explicit_context conversation carries its newest screens whole and
513
+ // earlier turns as text, which serves impressions; a closing report needs the whole session.
514
+ requestImpressions: (req, signal) => requestTurn(req, signal, "impressions"),
509
515
  };
510
516
  }
@@ -186,7 +186,37 @@ export declare function validActorProviderRequests(value: unknown): value is Act
186
186
  export interface ParticipantClosingReport {
187
187
  summary: string;
188
188
  frictionReports: string[];
189
+ /** Absent when the provider did not ask for impressions; empty when the participant had none. */
190
+ impressions?: ParticipantImpression[];
189
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
+ /** An impression as the trace keeps it, with the message that quotes it. */
205
+ interface RecordedImpression extends ParticipantImpression {
206
+ /** The trace message carrying the impression, which the analysis quotes and cites. */
207
+ messageId: string;
208
+ }
209
+ /**
210
+ * The impressions a session collected, or why it collected none. An empty `items` list means the
211
+ * participant had none to give.
212
+ */
213
+ export type ParticipantImpressions = {
214
+ status: "collected";
215
+ items: RecordedImpression[];
216
+ } | {
217
+ status: "not_collected";
218
+ reason: string;
219
+ };
190
220
  /** Runtime declarations and executable-version observations; not provider request attestation. */
191
221
  export interface ActorRuntimeProvenance {
192
222
  schema: "humanish.actor-runtime.v1";
@@ -231,11 +261,13 @@ export interface ActorConversation {
231
261
  */
232
262
  summarizedTurns: number;
233
263
  /**
234
- * Per participant request, in order: its mode and, in explicit_context, what it carried and its
235
- * estimated input. `tokenUsage.turns` has each request's billed input.
264
+ * Per participant turn or impressions request, in order: its mode and, in explicit_context,
265
+ * what it carried and its estimated input. `tokenUsage.turns` has each request's billed input.
236
266
  */
237
267
  requests: Array<{
238
268
  mode: "threaded" | "explicit_context";
269
+ /** Present for the impressions-only request; absent for participant turns and older bundles. */
270
+ kind?: "impressions";
239
271
  carriedExchanges?: number;
240
272
  carriedScreenshots?: number;
241
273
  estimatedInputTokens?: number;
@@ -315,17 +347,27 @@ export interface ActorTrace {
315
347
  * each fixed a false refusal and each left the next shape unhandled.
316
348
  */
317
349
  declaredOutcome?: ParticipantDeclaredOutcome;
318
- /** Closing report after a harness-owned stop. Does not change task outcomes or permit actions. */
350
+ /**
351
+ * The read-only closing request: a closing report after a harness-owned stop (`stop_when`,
352
+ * `dwell`), or impressions only after the participant ended the session itself
353
+ * (`participant_end`, never with `report`). Does not change task outcomes or permit actions.
354
+ */
319
355
  debrief?: {
320
- trigger: "stop_when" | "dwell";
356
+ trigger: "stop_when" | "dwell" | "participant_end";
321
357
  status: "completed" | "skipped" | "failed";
322
358
  reason: string;
323
359
  /** Absent if no request was made; false means token accounting is incomplete. */
324
360
  usageReported?: boolean;
325
- report?: ParticipantClosingReport;
361
+ /** The report's impressions are kept in `impressions`, with the messages that quote them. */
362
+ report?: Omit<ParticipantClosingReport, "impressions">;
326
363
  /** Links the readable projection so it is not heuristically classified a second time. */
327
364
  messageId?: string;
328
365
  };
366
+ /**
367
+ * What the participant said about the product at the end of a computer-use session, or why
368
+ * nothing was collected. Absent on other routes and on older traces.
369
+ */
370
+ impressions?: ParticipantImpressions;
329
371
  items: ActorTraceItem[];
330
372
  tokenUsage?: ActorTokenUsage;
331
373
  /** A stalled or adapter-reported ambiguous interaction may have additional unreported usage.
@@ -168,6 +168,19 @@ 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
+ ];
171
184
  export const CODEX_APP_SERVER_CAPABILITIES = {
172
185
  headless: true,
173
186
  structuredTrace: true,
@@ -0,0 +1,37 @@
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
+ /** The --max-cost that admits a refused analysis: its worst case, rounded up to a whole dollar. */
28
+ export declare function admittingMaxCost(cost: RefusedAnalysisCost): number;
29
+ /** Why admission refused an analysis, in plain words. */
30
+ export declare function costRefusalText(cost: RefusedAnalysisCost): string;
31
+ /** The admission's refused cost, when it refused the analysis for its cost. */
32
+ export declare function refusedCost(admission: {
33
+ error: string | null;
34
+ estimatedCostUsd: number | null;
35
+ worstCaseCostUsd: number | null;
36
+ maxCostUsd: number | null;
37
+ }): RefusedAnalysisCost | undefined;
@@ -0,0 +1,60 @@
1
+ // The admission cost model for one study analysis request: the expected cost, the worst case, and
2
+ // the figure admission compares with the cap.
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
+ /** The --max-cost that admits a refused analysis: its worst case, rounded up to a whole dollar. */
44
+ export function admittingMaxCost(cost) {
45
+ return Math.max(1, Math.ceil(cost.worstCaseCostUsd));
46
+ }
47
+ /** Why admission refused an analysis, in plain words. */
48
+ export function costRefusalText(cost) {
49
+ return `The expected cost is $${cost.expectedCostUsd.toFixed(2)} and the worst case is $${cost.worstCaseCostUsd.toFixed(2)}. With a ${Math.round((ADMISSION_MARGIN - 1) * 100)}% margin the expected cost is over the $${cost.maxCostUsd} cap, so no request was sent.`;
50
+ }
51
+ /** The admission's refused cost, when it refused the analysis for its cost. */
52
+ export function refusedCost(admission) {
53
+ const { error, estimatedCostUsd, worstCaseCostUsd, maxCostUsd } = admission;
54
+ return error === "analysis_budget_exceeded" &&
55
+ estimatedCostUsd !== null &&
56
+ worstCaseCostUsd !== null &&
57
+ maxCostUsd !== null
58
+ ? { expectedCostUsd: estimatedCostUsd, worstCaseCostUsd, maxCostUsd }
59
+ : undefined;
60
+ }
@@ -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"
@@ -20,4 +20,6 @@ export interface SourceEntry {
20
20
  export declare function sourceEntries(bundle: RunBundle, stream: RunStream, captureVersion?: CaptureVersion): SourceEntry[];
21
21
  /** Max-min allocation; stable ID order breaks a remainder tie by at most one slot. */
22
22
  export declare function fairShares(budget: number, capacities: number[]): number[];
23
- export declare function sourceOrder(entries: SourceEntry[], capturesOnly: boolean): SourceEntry[];
23
+ /** The trace messages that carry a participant's closing impressions. */
24
+ export declare function impressionEventIds(stream: RunStream): ReadonlySet<string>;
25
+ export declare function sourceOrder(entries: SourceEntry[], capturesOnly: boolean, impressions?: ReadonlySet<string>): SourceEntry[];