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
@@ -204,7 +204,12 @@ function spreadOrder(entries) {
204
204
  }
205
205
  return result;
206
206
  }
207
- export function sourceOrder(entries, capturesOnly) {
207
+ /** The trace messages that carry a participant's closing impressions. */
208
+ export function impressionEventIds(stream) {
209
+ const impressions = stream.actor?.impressions;
210
+ return new Set(impressions?.status === "collected" ? impressions.items.map((item) => item.messageId) : []);
211
+ }
212
+ export function sourceOrder(entries, capturesOnly, impressions = new Set()) {
208
213
  const candidates = capturesOnly ? entries.filter((entry) => entry.capturePath !== null) : entries;
209
214
  const ordered = new Set();
210
215
  const admit = (entry) => {
@@ -214,6 +219,10 @@ export function sourceOrder(entries, capturesOnly) {
214
219
  // Actor endings precede appended run bookkeeping when text slots are scarce.
215
220
  if (!capturesOnly)
216
221
  admit(entries.findLast((entry) => !entry.kind.startsWith("run_event:")));
222
+ // The participant's closing impressions are its only opinions of the product; keep them all.
223
+ for (const entry of entries)
224
+ if (impressions.has(entry.eventId))
225
+ admit(entry);
217
226
  admit(candidates.at(-1));
218
227
  admit(candidates[0]);
219
228
  const afterCapture = [];
@@ -6,7 +6,7 @@ import { isRecord } from "../run/type-guards.js";
6
6
  import { RUN_BUNDLE_FILE } from "../run/bundle.js";
7
7
  import { ACTION_CAPTURE_VERSION, } from "./types.js";
8
8
  import { digestAnalysisInput, hashAnalysisValue, validateAnalysisInputMetadata, } from "./validation.js";
9
- import { boundedText, fairShares, hasUnmappedCaptures, participantAssignment, participantSource, sourceEntries, sourceOrder, } from "./evidence-sources.js";
9
+ import { boundedText, fairShares, hasUnmappedCaptures, participantAssignment, participantSource, sourceEntries, sourceOrder, impressionEventIds, } from "./evidence-sources.js";
10
10
  import { readBoundedFile, readBoundedFileResult } from "../run/evidence-files.js";
11
11
  export const EVIDENCE_LIMITS = Object.freeze({
12
12
  participants: 16,
@@ -312,7 +312,7 @@ function recordCaptureOmissions(packings, sel, omissions) {
312
312
  omissions.add("Some declared captures have no normalized trace reference.");
313
313
  for (const source of packing.captures.keys())
314
314
  packing.admitted.add(source);
315
- for (const source of sourceOrder(packing.entries, false)) {
315
+ for (const source of sourceOrder(packing.entries, false, impressionEventIds(packing.stream))) {
316
316
  if (packing.admitted.size >= sel.evidenceShares[index])
317
317
  break;
318
318
  packing.admitted.add(source);
@@ -1,7 +1,7 @@
1
1
  import { type AnalysisArtifact, type AnalysisConfig, type AnalysisInput } from "./types.js";
2
2
  import { type AnalysisFetch, type AnalysisProvider } from "./provider.js";
3
3
  import type { RejectedAnalysisOutput } from "./diagnostics.js";
4
- export declare const ANALYSIS_PROMPT_VERSION = "study-evidence-7";
4
+ export declare const ANALYSIS_PROMPT_VERSION = "study-evidence-8";
5
5
  export declare const isSupportedAnalysisModel: (model: string) => boolean;
6
6
  /** The OpenAI output limit when a study omits maxOutputTokens, and the most it may ask for. */
7
7
  export declare const DEFAULT_ANALYSIS_MAX_OUTPUT_TOKENS = 16384;
@@ -9,9 +9,14 @@ 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
+ maxCostUsd: number | null;
15
20
  ratesAsOf: string | null;
16
21
  }
17
22
  export interface AnalysisProgress {
@@ -23,15 +28,24 @@ export interface AnalysisProgress {
23
28
  }
24
29
  /**
25
30
  * 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.
31
+ * call, credential, or network access. admission.ts holds the cost model: text at a calibrated
32
+ * bytes-per-token ratio, the expected output, and the worst case that spends the whole output
33
+ * allowance. Each high-detail image is priced from its PNG size by the vision guide's patch
34
+ * formula (see image-tokens.ts), or at the 3,000-token ceiling when its size or the model's sizing
35
+ * is unknown. Unknown models/rates fail closed rather than inheriting those assumptions.
31
36
  * Known-sensitive decoded text denies admission with analysis_input_sensitive or
32
37
  * analysis_question_sensitive. Neither error includes rejected input values.
33
38
  */
34
39
  export declare function estimateAnalysisAdmission(input: AnalysisInput, config: AnalysisConfig): AnalysisAdmission;
40
+ /**
41
+ * The expected cost of an analysis for this many participants before any evidence exists: from a
42
+ * packet with no evidence to one at the evidence limits with every capture at the image-token
43
+ * ceiling. Undefined for Codex and for a model without rates.
44
+ */
45
+ export declare function analysisCostRange(config: AnalysisConfig, participants: number): {
46
+ low: number;
47
+ high: number;
48
+ } | undefined;
35
49
  /** Only for an omitted output limit. Preserve the established allowance when
36
50
  * more reasoning/report space would refuse a study its declared budget admits.
37
51
  * 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";
@@ -10,7 +11,7 @@ import { checkAnalysisResult, hashAnalysisValue, analysisResponseSchema, analysi
10
11
  import { EVIDENCE_LIMITS } from "./evidence.js";
11
12
  import { truncatedFrameWarning, unknownNotificationsWarning, } from "../actors/codex/restricted-notifications.js";
12
13
  import { protocolAdditionsWarning, protocolIncompatibilityMessage, } from "../actors/codex/protocol-compat.js";
13
- export const ANALYSIS_PROMPT_VERSION = "study-evidence-7";
14
+ export const ANALYSIS_PROMPT_VERSION = "study-evidence-8";
14
15
  const SUPPORTED_ANALYSIS_MODELS = Object.freeze([
15
16
  "gpt-6-astra",
16
17
  "gpt-5.5",
@@ -61,6 +62,8 @@ Write for the person who will act on the report: a designer, product manager or
61
62
 
62
63
  Separately, review the supplied captures as an experienced product designer would and report designFindings: problems a designer would notice in what the screens show, whether or not any participant mentioned them. Look at the size of text and controls (too small to read or to hit comfortably at this window size), layout and density (spread too wide or packed too tight for the task), visual hierarchy, legibility and contrast, wording and labels, consistency between screens, feedback after actions, and anything that makes the product look unfinished or untrustworthy. Judge the screens as the participants' personas would meet them: who they are and what they came to do. Each design finding names the screen in plain words, says what a designer notices, why it matters to a person using the product, and one concrete suggestion. Each must cite at least one supplied capture that shows the problem, and seenByStreamIds lists only participants whose cited captures show it. Rate severity by the effect on a person using the product: major when it misleads or blocks, moderate when it slows or confuses, minor when it is polish. Report only what the captures show; no capture, no design finding. Use D1, D2, and so on as IDs. An empty designFindings array is correct when the captures show no design problem worth a designer's time. Write design findings in the same plain register as headlines.
63
64
 
65
+ Messages that begin "Impression (kind):" are a participant's closing impressions of the product, where kind is unclear, unfinished, untrustworthy, liked, missing or unlike my work. They are that participant's own opinions: use them as participant statements and quote the words after the label. An unlike my work impression says where the screen differs from how the persona does the same task in its own work or life; with a capture of that screen it is a strong design finding, which cites both. Without such a capture, an impression supports only a finding about what the participant said.
66
+
64
67
  Order findings by observed task impact, replication among exposed participants, and recovery. Preserve severity and confidence as separate fields. Do not compute a numeric frustration score or universal priority score. Use F1, F2, and so on as local finding IDs. Explain ordering with concrete evidence. Provide a short, testable next check for each finding. Say when recovery was not observed rather than claiming it was impossible. Keep titles and summaries concise and specific. The overall summary must be grounded in participant reviews and observations, including successful outcomes and evidence limits.`;
65
68
  function instructions(config) {
66
69
  return `${INSTRUCTIONS}\n\nResearcher question (null means use the standard review): ${JSON.stringify(config.question)}`;
@@ -126,11 +129,11 @@ function inputError(input) {
126
129
  }
127
130
  /**
128
131
  * Local admission estimate, not a provider-enforced billed-spend guarantee. No token-count API
129
- * call, credential, or network access. One UTF-8 byte/token for all text/schema plus framing is
130
- * intentionally conservative. Each high-detail image is priced from its PNG size by the vision
131
- * guide's patch formula (see image-tokens.ts), or at the 3,000-token ceiling when its size or the
132
- * model's sizing is unknown. Unknown models/rates fail closed rather than inheriting those
133
- * 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.
134
137
  * Known-sensitive decoded text denies admission with analysis_input_sensitive or
135
138
  * analysis_question_sensitive. Neither error includes rejected input values.
136
139
  */
@@ -141,6 +144,8 @@ export function estimateAnalysisAdmission(input, config) {
141
144
  inputTokenAllowance: 0,
142
145
  outputTokenAllowance: 0,
143
146
  estimatedCostUsd: null,
147
+ worstCaseCostUsd: null,
148
+ maxCostUsd: null,
144
149
  ratesAsOf: null,
145
150
  });
146
151
  if ((config.provider === "codex"
@@ -172,6 +177,8 @@ export function estimateAnalysisAdmission(input, config) {
172
177
  inputTokenAllowance: null,
173
178
  outputTokenAllowance: null,
174
179
  estimatedCostUsd: null,
180
+ worstCaseCostUsd: null,
181
+ maxCostUsd: null,
175
182
  ratesAsOf: null,
176
183
  };
177
184
  const rate = MODEL_RATES[config.model];
@@ -182,32 +189,56 @@ export function estimateAnalysisAdmission(input, config) {
182
189
  !Number.isFinite(rate.outputUsdPerToken) ||
183
190
  rate.outputUsdPerToken < 0)
184
191
  return denied("analysis_rate_unknown");
185
- const inputTokenAllowance = Buffer.byteLength(JSON.stringify({
186
- instructions: instructions(config),
187
- evidence: evidenceText(input),
188
- schema: analysisResultJsonSchema,
189
- })) +
190
- 2048 +
191
- input.images.reduce((sum, image) => sum + highDetailImageTokens(config.model, image.dataUrl), 0);
192
- const long = rate.longContext !== undefined && inputTokenAllowance > rate.longContext.thresholdInputTokens;
193
- const inputRate = Math.max(rate.inputUsdPerToken, rate.cacheWriteUsdPerToken ?? 0, rate.cachedInputUsdPerToken ?? 0);
194
- const estimate = inputTokenAllowance * inputRate * (long ? rate.longContext.inputMultiplier : 1) +
195
- config.maxOutputTokens *
196
- rate.outputUsdPerToken *
197
- (long ? rate.longContext.outputMultiplier : 1);
198
- // Round upward for admission; rounding a small positive boundary down could admit an overrun.
199
- const estimatedCostUsd = Math.ceil(estimate * 1e6) / 1e6;
200
- if (!Number.isFinite(estimatedCostUsd))
192
+ const cost = estimateAnalysisCost(rate, {
193
+ textBytes: Buffer.byteLength(instructions(config)) +
194
+ Buffer.byteLength(evidenceText(input)) +
195
+ Buffer.byteLength(JSON.stringify(analysisResultJsonSchema)),
196
+ imageTokens: input.images.reduce((sum, image) => sum + highDetailImageTokens(config.model, image.dataUrl), 0),
197
+ participants: input.participants.length,
198
+ outputAllowance: config.maxOutputTokens,
199
+ });
200
+ if (!Number.isFinite(cost.worstCaseCostUsd))
201
201
  return denied("analysis_rate_unknown");
202
+ const allowed = cost.admittedCostUsd <= config.maxCostUsd;
202
203
  return {
203
- allowed: estimatedCostUsd <= config.maxCostUsd,
204
- error: estimatedCostUsd <= config.maxCostUsd ? null : "analysis_budget_exceeded",
205
- inputTokenAllowance,
204
+ allowed,
205
+ error: allowed ? null : "analysis_budget_exceeded",
206
+ inputTokenAllowance: cost.inputTokens,
206
207
  outputTokenAllowance: config.maxOutputTokens,
207
- estimatedCostUsd,
208
+ estimatedCostUsd: cost.expectedCostUsd,
209
+ worstCaseCostUsd: cost.worstCaseCostUsd,
210
+ maxCostUsd: config.maxCostUsd,
208
211
  ratesAsOf: rate.asOf,
209
212
  };
210
213
  }
214
+ /** Structure each evidence entry adds to the packet: ids, kind, timestamps and flags. The largest
215
+ * retained packet averaged 200 bytes. */
216
+ const ENTRY_STRUCTURE_BYTES = 256;
217
+ /**
218
+ * The expected cost of an analysis for this many participants before any evidence exists: from a
219
+ * packet with no evidence to one at the evidence limits with every capture at the image-token
220
+ * ceiling. Undefined for Codex and for a model without rates.
221
+ */
222
+ export function analysisCostRange(config, participants) {
223
+ if (config.provider === "codex")
224
+ return undefined;
225
+ const rate = MODEL_RATES[config.model];
226
+ if (!rate || rate.placeholder)
227
+ return undefined;
228
+ const prompt = Buffer.byteLength(instructions(config)) +
229
+ Buffer.byteLength(JSON.stringify(analysisResultJsonSchema));
230
+ const size = {
231
+ participants: Math.min(participants, EVIDENCE_LIMITS.participants),
232
+ outputAllowance: config.maxOutputTokens,
233
+ };
234
+ const low = estimateAnalysisCost(rate, { ...size, textBytes: prompt, imageTokens: 0 });
235
+ const high = estimateAnalysisCost(rate, {
236
+ ...size,
237
+ textBytes: prompt + EVIDENCE_LIMITS.textBytes + EVIDENCE_LIMITS.evidence * ENTRY_STRUCTURE_BYTES,
238
+ imageTokens: EVIDENCE_LIMITS.captures * HIGH_DETAIL_IMAGE_TOKEN_CEILING,
239
+ });
240
+ return { low: low.expectedCostUsd, high: high.expectedCostUsd };
241
+ }
211
242
  /** Only for an omitted output limit. Preserve the established allowance when
212
243
  * more reasoning/report space would refuse a study its declared budget admits.
213
244
  * This is one pre-dispatch choice, never a fallback request or a budget increase. */
@@ -462,9 +493,11 @@ function settleCompletedOutput(artifact, input, config, admission, response, onR
462
493
  artifact.result = checked.result;
463
494
  artifact.status = input.coverage.complete ? "complete" : "partial";
464
495
  artifact.error = null;
496
+ // A bill above the expected cost is a normal outcome. Only one above the worst case or the cap
497
+ // shows the estimate was wrong.
465
498
  if (config.provider !== "codex" &&
466
499
  ((response.usage?.output ?? 0) > config.maxOutputTokens ||
467
- (artifact.usage.estimatedCostUsd ?? 0) > (admission.estimatedCostUsd ?? config.maxCostUsd) ||
500
+ (artifact.usage.estimatedCostUsd ?? 0) > (admission.worstCaseCostUsd ?? config.maxCostUsd) ||
468
501
  (artifact.usage.estimatedCostUsd ?? 0) > config.maxCostUsd)) {
469
502
  artifact.status = "partial";
470
503
  artifact.error = "analysis_admission_estimate_exceeded";
@@ -1 +1,3 @@
1
+ /** 2,500 patches × 1.2: what any image costs on these models when its size is unknown. */
2
+ export declare const HIGH_DETAIL_IMAGE_TOKEN_CEILING = 3000;
1
3
  export declare function highDetailImageTokens(model: string, dataUrl: string): number;
@@ -16,7 +16,7 @@ const HIGH_DETAIL_SIZING = Object.freeze({
16
16
  "gpt-5.5": { patchBudget: 2500, multiplierTenths: 12 },
17
17
  });
18
18
  /** 2,500 patches × 1.2: what any image costs on these models when its size is unknown. */
19
- const HIGH_DETAIL_IMAGE_TOKEN_CEILING = 3000;
19
+ export const HIGH_DETAIL_IMAGE_TOKEN_CEILING = 3000;
20
20
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
21
21
  /** Width and height from a PNG data URL's IHDR chunk; undefined for anything else. */
22
22
  function pngDimensions(dataUrl) {
@@ -1,6 +1,7 @@
1
1
  import type { AnalyzeResult } from "./service.js";
2
2
  import { z } from "zod";
3
3
  import { type PreparedRunArtifactPaths } from "../run/paths.js";
4
+ import type { RefusedAnalysisCost } from "./admission.js";
4
5
  export declare const AUTOMATIC_ANALYSIS_DIRECTORY = "analysis-automatic";
5
6
  export declare const AUTOMATIC_ANALYSIS_STALE_MS = 15000;
6
7
  /** Execution metadata only. Never a participant outcome or permission to dispatch. */
@@ -11,6 +12,8 @@ export interface AutomaticAnalysisView {
11
12
  /** Safe stable code, not provider text. */
12
13
  reason: string | null;
13
14
  updatedAt: string;
15
+ /** What the analysis would have cost, when admission refused it for its cost. */
16
+ admission?: RefusedAnalysisCost;
14
17
  }
15
18
  export interface AutomaticAnalysisOutcome {
16
19
  state: "complete" | "partial" | "failed" | "cancelled" | "skipped" | "unknown";
@@ -69,6 +72,11 @@ declare const jobSchema: z.ZodObject<{
69
72
  receiptSha256: z.ZodNullable<z.ZodString>;
70
73
  startedAt: z.ZodNullable<z.ZodISODateTime>;
71
74
  finishedAt: z.ZodNullable<z.ZodISODateTime>;
75
+ admission: z.ZodOptional<z.ZodObject<{
76
+ expectedCostUsd: z.ZodNumber;
77
+ worstCaseCostUsd: z.ZodNumber;
78
+ maxCostUsd: z.ZodNumber;
79
+ }, z.core.$strict>>;
72
80
  }, z.core.$strict>;
73
81
  type JobRecord = z.infer<typeof jobSchema>;
74
82
  /** Also guards direct HTML projection callers, not only the contained-file reader. */
@@ -86,7 +94,7 @@ export declare function readAutomaticAnalysisPrepared(prepared: PreparedRunArtif
86
94
  export interface AutomaticAnalysisJob {
87
95
  readonly attemptId: string;
88
96
  /** Serialized, awaited durability boundary. A failed write must prevent dispatch. */
89
- update(update: Partial<Pick<JobRecord, "state" | "analysisId" | "reason" | "sourceRunSha256" | "inputDigest" | "startedAt" | "analysisSha256" | "receiptSha256">>): Promise<void>;
97
+ update(update: Partial<Pick<JobRecord, "state" | "analysisId" | "reason" | "sourceRunSha256" | "inputDigest" | "startedAt" | "analysisSha256" | "receiptSha256" | "admission">>): Promise<void>;
90
98
  touch(): Promise<void>;
91
99
  cancellationRequested(): Promise<boolean>;
92
100
  }
@@ -52,6 +52,12 @@ const reasons = [
52
52
  const id = z.string().regex(ANALYSIS_ID_PATTERN);
53
53
  const date = z.iso.datetime();
54
54
  const digest = z.string().regex(SHA256_HEX_PATTERN);
55
+ const usd = z.number().min(0).max(1e9);
56
+ const refusedCostSchema = z.strictObject({
57
+ expectedCostUsd: usd,
58
+ worstCaseCostUsd: usd,
59
+ maxCostUsd: usd,
60
+ });
55
61
  const jobSchema = z.strictObject({
56
62
  schema: z.literal(AUTOMATIC_ANALYSIS_SCHEMA),
57
63
  runId: id,
@@ -70,6 +76,7 @@ const jobSchema = z.strictObject({
70
76
  receiptSha256: digest.nullable(),
71
77
  startedAt: date.nullable(),
72
78
  finishedAt: date.nullable(),
79
+ admission: refusedCostSchema.optional(),
73
80
  });
74
81
  const cancelSchema = z.strictObject({
75
82
  schema: z.literal(CANCEL_SCHEMA),
@@ -111,11 +118,12 @@ export function projectAutomaticAnalysisView(value) {
111
118
  if (value === undefined)
112
119
  return undefined;
113
120
  const parsed = jobSchema
114
- .pick({ state: true, analysisId: true, reason: true, updatedAt: true })
121
+ .pick({ state: true, analysisId: true, reason: true, updatedAt: true, admission: true })
115
122
  .safeParse(value);
116
123
  if (!parsed.success || containsSensitive(JSON.stringify(parsed.data)))
117
124
  return unknown();
118
- return parsed.data;
125
+ const { admission, ...view } = parsed.data;
126
+ return admission === undefined ? view : { ...view, admission };
119
127
  }
120
128
  async function bindJob(prepared) {
121
129
  await validatePreparedRunRootIdentity(prepared);
@@ -162,6 +170,7 @@ export async function readAutomaticAnalysisPrepared(prepared, now = Date.now())
162
170
  analysisId: record.analysisId,
163
171
  reason: record.reason,
164
172
  updatedAt: record.updatedAt,
173
+ ...(record.admission === undefined ? {} : { admission: record.admission }),
165
174
  };
166
175
  const updated = Date.parse(record.updatedAt);
167
176
  if (updated > now || (pending(record.state) && now - updated > AUTOMATIC_ANALYSIS_STALE_MS)) {
@@ -17,6 +17,7 @@ import { appendAnalysisCorrection, assertAnalysisPublicationCapacity, listAnalys
17
17
  import { beginAnalysisExecution, writeAnalysisExecutionReceipt } from "./store-executions.js";
18
18
  import { keepRejectedAnalysisOutput } from "./diagnostics.js";
19
19
  import { loadAnalysis } from "./load.js";
20
+ import { admittingMaxCost, costRefusalText, refusedCost } from "./admission.js";
20
21
  import { hashAnalysisValue } from "./validation.js";
21
22
  import { ANALYSIS_CORRECTION_SCHEMA, } from "./types.js";
22
23
  import { RUN_BUNDLE_FILE } from "../run/bundle.js";
@@ -188,17 +189,19 @@ function analyzeConfigRefusal(config) {
188
189
  }
189
190
  /** The result when admission refuses the attempt, or when a dry run stops after admission. */
190
191
  function admissionOnlyResult(base, config) {
191
- if (!base.admission.allowed)
192
+ if (!base.admission.allowed) {
193
+ const cost = refusedCost(base.admission);
192
194
  return {
193
195
  ...base,
194
196
  ok: false,
195
197
  error: {
196
198
  code: base.admission.error ?? "analysis_admission_denied",
197
- message: base.admission.error === "analysis_budget_exceeded"
198
- ? "The conservative admission estimate exceeds --max-cost. No provider request was sent."
199
+ message: cost
200
+ ? `${costRefusalText(cost)} To run it, raise the cap: humanish analyze --run ${base.run} --max-cost ${admittingMaxCost(cost)}`
199
201
  : "The analysis input or configuration did not pass admission. No provider request was sent.",
200
202
  },
201
203
  };
204
+ }
202
205
  return {
203
206
  ...base,
204
207
  ok: true,
@@ -29,7 +29,7 @@ export function registerAnalyzeCommand(parent, io) {
29
29
  .option("--run <id>", RUN_OPTION_DESCRIPTION, "latest")
30
30
  .option("--cwd <path>", CWD_OPTION_DESCRIPTION, ".")
31
31
  .option("--provider <name>", "Analyst: openai (default) or restricted codex account. No provider fallback.")
32
- .option("--max-cost <usd>", "Required for OpenAI, also with --dry-run: send nothing when the estimated cost in USD is above this. The estimate is not a billing cap. Unsupported for Codex.")
32
+ .option("--max-cost <usd>", "Required for OpenAI, also with --dry-run: send nothing when the expected cost in USD, plus a 10% margin, is above this. It does not limit billing. Unsupported for Codex.")
33
33
  .option("--model <id>", "Supported vision model. OpenAI uses high effort; qualified Codex account analysis uses low effort.", DEFAULT_ANALYSIS_MODEL)
34
34
  .option("--question <text>", "Additional reviewer question; does not change participant instructions.")
35
35
  .option("--timeout-ms <ms>", "Request timeout, at most 600000 ms.", String(DEFAULT_ANALYSIS_TIMEOUT_MS))
@@ -125,7 +125,7 @@ async function handleAnalyze(io, options, command) {
125
125
  if (value.ok && value.dryRun && selected.config?.provider === "codex")
126
126
  return "Local evidence and configuration passed admission. Codex CLI, login and model access were not checked. Dollar cost and output-token ceiling are unknown. No provider request sent.\n";
127
127
  if (value.ok && value.dryRun)
128
- return `Admission estimate: $${value.admission?.estimatedCostUsd ?? "unknown"}; output allowance: ${value.admission?.outputTokenAllowance ?? "unknown"} tokens including reasoning. No request sent.\n`;
128
+ return admittedDryRunText(value.admission);
129
129
  const lines = [];
130
130
  if (value.artifactPath)
131
131
  lines.push(`${value.reused ? "Reused" : "Saved"} ${value.status} analysis: ${value.artifactPath}`);
@@ -231,3 +231,8 @@ async function handleAnalyzeCorrect(io, options, command) {
231
231
  io.setExitCode(2);
232
232
  }
233
233
  }
234
+ /** An admitted dry run's costs: the expected cost, the worst case and the cap it was admitted under. */
235
+ function admittedDryRunText(admission) {
236
+ const usd = (value) => value === null || value === undefined ? "unknown" : `$${value.toFixed(2)}`;
237
+ return `Expected cost: ${usd(admission?.estimatedCostUsd)}. Worst case: ${usd(admission?.worstCaseCostUsd)}, if the analyst writes its whole ${admission?.outputTokenAllowance ?? "unknown"}-token output allowance, reasoning included. Admitted under the $${admission?.maxCostUsd ?? "unknown"} cap. No request sent.\n`;
238
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ import { type CliIo } from "../io.js";
3
+ export declare function registerNotesCommand(parent: Command, io: CliIo): void;
@@ -0,0 +1,144 @@
1
+ import { renderObserver } from "../../observer/render.js";
2
+ import { loadRunBundlePrepared, resolveRunPath } from "../../run/locate.js";
3
+ import { addRunNote, formatRunTime, readRunNotes, runParticipantCaptions, } from "../../run/notes.js";
4
+ import { streamParticipantIdOf } from "../../run/participant-records.js";
5
+ import { resolvePhysicalCwd, runIdOf } from "../../run/paths.js";
6
+ import { runNotFoundMessage } from "../../run/run-not-found.js";
7
+ import { cli } from "../invocation.js";
8
+ import { CWD_OPTION_DESCRIPTION, humanError, JSON_OPTION_DESCRIPTION, writeResult, } from "../io.js";
9
+ const NOTES_RESULT_SCHEMA = "humanish.notes-result.v1";
10
+ export function registerNotesCommand(parent, io) {
11
+ parent
12
+ .command("notes")
13
+ .description("List the reviewer notes on a run, or add one at a moment of its recording. A note's time counts from the run's first timed capture, as the Observer's study timeline shows it.")
14
+ .summary("List or add reviewer notes on a run.")
15
+ // The text is read from the words after the run, not declared, so the root command list
16
+ // stays as narrow as its other rows and an unquoted note still arrives whole.
17
+ .usage("[options] <run> [text...]")
18
+ .argument("<run>", "Run id, or latest.")
19
+ .allowExcessArguments(true)
20
+ .option("--add", "Add a note. Its text is the words after the run; --at gives the moment.")
21
+ .option("--at <mm:ss>", "With --add: the moment, in minutes and seconds, such as 02:31.")
22
+ .option("--participant <id>", "With --add: the participant's id from the study, or its stream id. Leave it out for a note on the whole run.")
23
+ .option("--cwd <path>", CWD_OPTION_DESCRIPTION, ".")
24
+ .option("--json", JSON_OPTION_DESCRIPTION)
25
+ .addHelpText("after", [
26
+ "",
27
+ "Examples:",
28
+ " humanish notes latest",
29
+ ' humanish notes latest --add --at 02:31 "The save button was hidden here."',
30
+ ' humanish notes <runId> --add --at 00:45 --participant <id> "They gave up."',
31
+ "",
32
+ "Each note is saved in the run directory as notes/<id>.json. Text that looks like a secret",
33
+ "or a local path is replaced before it is saved, and verify scans notes like other run text.",
34
+ ].join("\n"))
35
+ .action((run, options, command) => {
36
+ const words = command.args.slice(1);
37
+ return handleNotes(io, run, words.length > 0 ? words.join(" ") : undefined, options, command);
38
+ });
39
+ }
40
+ /** Minutes and seconds as the Observer shows them, `02:31`, in milliseconds; null otherwise. */
41
+ function parseRunTime(value) {
42
+ const match = /^(\d{1,4}):([0-5]\d)$/.exec(value.trim());
43
+ return match ? (Number(match[1]) * 60 + Number(match[2])) * 1000 : null;
44
+ }
45
+ const byRunTime = (notes) => [...notes].sort((left, right) => left.atMs - right.atMs || left.createdAt.localeCompare(right.createdAt));
46
+ async function handleNotes(io, run, text, options, command) {
47
+ const base = {
48
+ schema: NOTES_RESULT_SCHEMA,
49
+ cwd: options.cwd,
50
+ run,
51
+ notes: [],
52
+ participants: [],
53
+ warnings: [],
54
+ };
55
+ const fail = (code, message) => {
56
+ writeResult(command, io, { ...base, ok: false, error: { code, message } }, formatNotesHuman);
57
+ io.setExitCode(2);
58
+ };
59
+ const adding = options.add === true;
60
+ if (!adding && (text !== undefined || options.at !== undefined))
61
+ return fail("HUMANISH_NOTES_OPTION_CONFLICT", `Adding a note needs --add: ${cli(`notes ${run} --add --at 02:31 "text"`)}.`);
62
+ if (adding && (options.at === undefined || text === undefined))
63
+ return fail("HUMANISH_NOTES_OPTION_CONFLICT", `--add needs --at and the note's text: ${cli(`notes ${run} --add --at 02:31 "text"`)}.`);
64
+ const atMs = options.at === undefined ? null : parseRunTime(options.at);
65
+ if (adding && atMs === null)
66
+ return fail("HUMANISH_NOTE_INVALID_TIME", `--at ${options.at ?? ""} is not a time on the run clock. Give minutes and seconds from the run's first capture, as the Observer shows them, such as 02:31.`);
67
+ const cwd = await resolvePhysicalCwd(options.cwd);
68
+ const prepared = await resolveRunPath(cwd, run).catch(() => null);
69
+ if (!prepared)
70
+ return fail("HUMANISH_RUN_NOT_FOUND", await runNotFoundMessage(cwd, run));
71
+ const runId = runIdOf(prepared);
72
+ let added;
73
+ const warnings = [];
74
+ if (adding && atMs !== null && text !== undefined) {
75
+ const result = await addRunNote(prepared, {
76
+ atMs,
77
+ participant: options.participant ?? null,
78
+ text,
79
+ });
80
+ if (!result.ok)
81
+ return fail(result.error.code, result.error.message);
82
+ added = result.note;
83
+ if (result.scrubbed)
84
+ warnings.push("Part of the note looked like a secret or a local path, so it was replaced before the note was saved.");
85
+ // The saved Observer page shows notes read-only, so it is rendered again with the new one.
86
+ const rendered = await renderObserver(cwd, runId, { open: false, expectedRun: prepared })
87
+ .then((observer) => observer.ok)
88
+ .catch(() => false);
89
+ if (!rendered)
90
+ warnings.push(`The note is saved, but the run's saved Observer page could not be rendered again. ${cli(`observe --run ${runId}`)} shows it.`);
91
+ }
92
+ const notes = await readRunNotes(prepared);
93
+ // A note file that cannot be read is named and left as it is; the others are listed.
94
+ warnings.push(...notes.skipped);
95
+ const result = {
96
+ ...base,
97
+ ok: true,
98
+ run: runId,
99
+ notes: byRunTime(notes.notes),
100
+ participants: await participantsOf(cwd, prepared),
101
+ ...(added === undefined ? {} : { added }),
102
+ warnings,
103
+ };
104
+ writeResult(command, io, result, formatNotesHuman);
105
+ io.setExitCode(0);
106
+ }
107
+ /** The run's participants with their captions; none when its run.json cannot be read. */
108
+ async function participantsOf(cwd, prepared) {
109
+ const loaded = await loadRunBundlePrepared(cwd, prepared).catch(() => null);
110
+ if (!loaded)
111
+ return [];
112
+ const captions = runParticipantCaptions(loaded.bundle);
113
+ return loaded.bundle.streams.map((stream) => {
114
+ const participantId = streamParticipantIdOf(stream);
115
+ return {
116
+ id: stream.id,
117
+ ...(participantId === undefined ? {} : { participantId }),
118
+ caption: captions.get(stream.id) ?? stream.id,
119
+ };
120
+ });
121
+ }
122
+ /** Who a note is about, as a person reads it. */
123
+ function participantName(result, participant) {
124
+ if (participant === null)
125
+ return "whole run";
126
+ return result.participants.find((entry) => entry.id === participant)?.caption ?? participant;
127
+ }
128
+ function noteLine(note, name, width) {
129
+ const head = ` ${formatRunTime(note.atMs)} ${name.padEnd(width)} `;
130
+ const [first = "", ...rest] = note.text.split("\n");
131
+ return [`${head}${note.author}: ${first}`, ...rest.map((line) => ` ${line}`)].join("\n");
132
+ }
133
+ function formatNotesHuman(result) {
134
+ if (!result.ok)
135
+ return humanError(result.error);
136
+ const warnings = result.warnings.map((warning) => `warning: ${warning}\n`).join("");
137
+ if (result.added)
138
+ return `Added a note at ${formatRunTime(result.added.atMs)} ${result.added.participant === null ? "on the whole run" : `for ${participantName(result, result.added.participant)}`} to run ${result.run}. The Observer marks it on the study timeline, and ${cli(`notes ${result.run}`)} lists every note.\n${warnings}`;
139
+ if (result.notes.length === 0)
140
+ return `Run ${result.run} has no reviewer notes. Add one: ${cli(`notes ${result.run} --add --at 00:30 "text"`)}\n${warnings}`;
141
+ const names = result.notes.map((note) => participantName(result, note.participant));
142
+ const width = Math.max(...names.map((name) => name.length));
143
+ return `${[`Reviewer notes on run ${result.run}: ${result.notes.length}`, ...result.notes.map((note, index) => noteLine(note, names[index], width))].join("\n")}\n${warnings}`;
144
+ }
@@ -152,7 +152,7 @@ async function observeRun(io, options, command) {
152
152
  ...(openResult.command ? { openCommand: openResult.command } : {}),
153
153
  warnings: [
154
154
  ...rendered.warnings,
155
- "Observer is served read-only over loopback http on 127.0.0.1; only this run's bundle directory is exposed.",
155
+ "Observer is served over loopback http on 127.0.0.1; only this run's bundle directory is exposed, and the one change the page can make is adding a reviewer note.",
156
156
  ...(openResult.warning ? [openResult.warning] : []),
157
157
  ],
158
158
  };