humanish 0.141.0 → 0.142.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 (97) hide show
  1. package/ARCHITECTURE.md +1 -0
  2. package/CHANGELOG.md +11 -0
  3. package/README.md +2 -0
  4. package/dist/analysis/admission.d.ts +8 -0
  5. package/dist/analysis/admission.js +15 -4
  6. package/dist/analysis/automatic-config.d.ts +5 -2
  7. package/dist/analysis/automatic-config.js +23 -8
  8. package/dist/analysis/automatic.js +13 -7
  9. package/dist/analysis/execute.d.ts +17 -6
  10. package/dist/analysis/execute.js +36 -18
  11. package/dist/analysis/gemini-wire.d.ts +8 -0
  12. package/dist/analysis/gemini-wire.js +165 -0
  13. package/dist/analysis/image-tokens.d.ts +4 -2
  14. package/dist/analysis/image-tokens.js +24 -3
  15. package/dist/analysis/job.d.ts +1 -0
  16. package/dist/analysis/job.js +1 -0
  17. package/dist/analysis/openai-wire.d.ts +5 -0
  18. package/dist/analysis/openai-wire.js +101 -0
  19. package/dist/analysis/provider.d.ts +15 -7
  20. package/dist/analysis/provider.js +21 -101
  21. package/dist/analysis/responses.d.ts +3 -0
  22. package/dist/analysis/responses.js +19 -0
  23. package/dist/analysis/service.js +11 -3
  24. package/dist/analysis/types.d.ts +8 -2
  25. package/dist/analysis/validation.d.ts +1 -0
  26. package/dist/analysis/validation.js +13 -12
  27. package/dist/cli/commands/analyze.js +9 -7
  28. package/dist/cli/commands/observe.js +26 -1
  29. package/dist/cli/commands/run-command.d.ts +1 -1
  30. package/dist/cli/commands/run-command.js +12 -0
  31. package/dist/cli/commands/study-remote.d.ts +13 -0
  32. package/dist/cli/commands/study-remote.js +125 -0
  33. package/dist/cli/commands/study-route-computer-use.js +26 -2
  34. package/dist/cli/commands/study-route-scripted.js +1 -0
  35. package/dist/cli/commands/study-route-shared-world.js +1 -0
  36. package/dist/cli/commands/study-route-terminal.js +1 -0
  37. package/dist/cli/commands/study-run.js +34 -0
  38. package/dist/cli/commands/watch.js +5 -0
  39. package/dist/cli/doctor.js +7 -2
  40. package/dist/cli/findings.d.ts +8 -1
  41. package/dist/cli/findings.js +38 -15
  42. package/dist/cli/io.d.ts +8 -0
  43. package/dist/cli/io.js +2 -1
  44. package/dist/keys/key-resolution.d.ts +10 -0
  45. package/dist/keys/key-resolution.js +33 -5
  46. package/dist/observer/listen.d.ts +7 -2
  47. package/dist/observer/listen.js +10 -4
  48. package/dist/observer/render.d.ts +2 -0
  49. package/dist/observer/render.js +1 -1
  50. package/dist/observer-app.html +1 -1
  51. package/dist/remote/launch.d.ts +53 -0
  52. package/dist/remote/launch.js +320 -0
  53. package/dist/remote/observer-proxy.d.ts +11 -0
  54. package/dist/remote/observer-proxy.js +66 -0
  55. package/dist/remote/plan.d.ts +35 -0
  56. package/dist/remote/plan.js +114 -0
  57. package/dist/remote/reattach.d.ts +17 -0
  58. package/dist/remote/reattach.js +81 -0
  59. package/dist/remote/runner.d.ts +68 -0
  60. package/dist/remote/runner.js +216 -0
  61. package/dist/routes/computer-use/e2b-desktop/start.js +1 -1
  62. package/dist/routes/computer-use/types.d.ts +2 -0
  63. package/dist/routes/scripted/types.d.ts +2 -0
  64. package/dist/routes/shared-world/types.d.ts +2 -0
  65. package/dist/routes/terminal/types.d.ts +2 -0
  66. package/dist/run/costs.d.ts +2 -1
  67. package/dist/run/pricing.d.ts +13 -1
  68. package/dist/run/pricing.js +50 -4
  69. package/dist/run/results.d.ts +1 -1
  70. package/dist/run/run-cost.d.ts +2 -1
  71. package/dist/run/run-cost.js +6 -1
  72. package/dist/run/run.d.ts +13 -0
  73. package/dist/run/run.js +18 -7
  74. package/dist/run/sandbox-creates.d.ts +10 -3
  75. package/dist/run/sandbox-creates.js +48 -6
  76. package/dist/run-study.d.ts +5 -0
  77. package/dist/study/doctor.js +14 -1
  78. package/dist/study/requirements.d.ts +1 -1
  79. package/dist/study/requirements.js +8 -6
  80. package/dist/study/route-inputs.js +4 -0
  81. package/dist/study/run-study-events.d.ts +1 -1
  82. package/dist/study/run-study-events.js +9 -2
  83. package/dist/substrates/e2b/base-module.d.ts +3 -0
  84. package/dist/substrates/e2b/base-module.js +40 -0
  85. package/dist/substrates/e2b/desktop-browser.d.ts +1 -1
  86. package/dist/substrates/e2b/desktop-browser.js +24 -6
  87. package/dist/substrates/e2b/sdk.d.ts +24 -0
  88. package/dist/substrates/e2b/sdk.js +5 -1
  89. package/dist/substrates/e2b/shell-only.d.ts +5 -0
  90. package/dist/substrates/e2b/shell-only.js +27 -0
  91. package/dist/substrates/modal/sandbox.js +27 -28
  92. package/dist/substrates/modal/sdk.d.ts +7 -0
  93. package/dist/tui-app.js +46 -46
  94. package/docs/contracts/study-analysis.md +51 -3
  95. package/docs/product/automatic-analysis.md +26 -2
  96. package/package.json +2 -2
  97. package/skills/humanish/SKILL.md +10 -0
@@ -1,22 +1,40 @@
1
+ /** An input image the analysis providers accept; the groups are the image type and its base64 bytes. */
2
+ export const INPUT_IMAGE_DATA_URL = /^data:image\/(png|jpeg|webp);base64,([A-Za-z0-9+/]+={0,2})$/;
1
3
  /**
2
- * Input tokens one image costs at `detail: "high"`, which is what the provider request sends.
4
+ * Input tokens one image costs at the detail the provider request sends: `detail: "high"` on
5
+ * OpenAI, MEDIA_RESOLUTION_HIGH on Gemini.
3
6
  *
4
7
  * OpenAI's vision guide (https://developers.openai.com/api/docs/guides/images-vision, read
5
8
  * 2026-09-30) prices these patch-based models by 32 px patches: ceil(width / 32) × ceil(height /
6
9
  * 32), scaled down to the model's high-detail patch budget, then ceil(patches × multiplier).
7
10
  * Scaling down only removes patches, so min(patches, budget) bounds the billed count from above.
8
11
  * The guide lists a 2,500-patch high budget and a 1.2 multiplier for each model below; its own
9
- * examples are 1024×1024 → 1,229 tokens and 2048×2048 → 3,000.
12
+ * examples are 1024×1024 → 1,229 tokens and 2048×2048 → 3,000. It does not list gpt-6-luna or
13
+ * gpt-6.1-sol (read 2026-10-21). Their entries copy gpt-6-astra's because OpenAI billed the same
14
+ * image tokens on all three on 2026-10-21, within the guide's one-token rounding.
10
15
  */
11
16
  const HIGH_DETAIL_SIZING = Object.freeze({
12
17
  "gpt-6-astra": { patchBudget: 2500, multiplierTenths: 12 },
18
+ "gpt-6.1-sol": { patchBudget: 2500, multiplierTenths: 12 },
19
+ "gpt-6-luna": { patchBudget: 2500, multiplierTenths: 12 },
13
20
  "gpt-5.6-sol": { patchBudget: 2500, multiplierTenths: 12 },
14
21
  "gpt-5.6-terra": { patchBudget: 2500, multiplierTenths: 12 },
15
22
  "gpt-5.6-luna": { patchBudget: 2500, multiplierTenths: 12 },
16
23
  "gpt-5.5": { patchBudget: 2500, multiplierTenths: 12 },
17
24
  });
18
25
  /** 2,500 patches × 1.2: what any image costs on these models when its size is unknown. */
19
- export const HIGH_DETAIL_IMAGE_TOKEN_CEILING = 3000;
26
+ const HIGH_DETAIL_IMAGE_TOKEN_CEILING = 3000;
27
+ /**
28
+ * Gemini bills an image by its media resolution, not its size. The provider sends every capture at
29
+ * MEDIA_RESOLUTION_HIGH, which the media-resolution guide
30
+ * (ai.google.dev/gemini-api/docs/media-resolution, read 2026-10-21) gives as at most 1,120 tokens
31
+ * for an image on Gemini 3 and Gemini 4 models. A 1x1 PNG at that setting was billed 1,089.
32
+ */
33
+ const GEMINI_HIGH_RESOLUTION_IMAGE_TOKENS = Object.freeze({
34
+ "gemini-3.8-flash": 1120,
35
+ });
36
+ /** The most one image can cost on `model`, whatever its size. */
37
+ export const imageTokenCeiling = (model) => GEMINI_HIGH_RESOLUTION_IMAGE_TOKENS[model] ?? HIGH_DETAIL_IMAGE_TOKEN_CEILING;
20
38
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
21
39
  /** Width and height from a PNG data URL's IHDR chunk; undefined for anything else. */
22
40
  function pngDimensions(dataUrl) {
@@ -31,6 +49,9 @@ function pngDimensions(dataUrl) {
31
49
  return width > 0 && height > 0 ? { width, height } : undefined;
32
50
  }
33
51
  export function highDetailImageTokens(model, dataUrl) {
52
+ const fixed = GEMINI_HIGH_RESOLUTION_IMAGE_TOKENS[model];
53
+ if (fixed !== undefined)
54
+ return fixed;
34
55
  const sizing = HIGH_DETAIL_SIZING[model];
35
56
  const size = sizing === undefined ? undefined : pngDimensions(dataUrl);
36
57
  if (sizing === undefined || size === undefined)
@@ -53,6 +53,7 @@ declare const jobSchema: z.ZodObject<{
53
53
  AUTOMATIC_ANALYSIS_CLEANUP_UNCONFIRMED: "AUTOMATIC_ANALYSIS_CLEANUP_UNCONFIRMED";
54
54
  AUTOMATIC_ANALYSIS_CODEX_UNAVAILABLE: "AUTOMATIC_ANALYSIS_CODEX_UNAVAILABLE";
55
55
  AUTOMATIC_ANALYSIS_FAILED: "AUTOMATIC_ANALYSIS_FAILED";
56
+ AUTOMATIC_ANALYSIS_GEMINI_KEY_MISSING: "AUTOMATIC_ANALYSIS_GEMINI_KEY_MISSING";
56
57
  AUTOMATIC_ANALYSIS_KEY_MISSING: "AUTOMATIC_ANALYSIS_KEY_MISSING";
57
58
  AUTOMATIC_ANALYSIS_LIMITATIONS: "AUTOMATIC_ANALYSIS_LIMITATIONS";
58
59
  AUTOMATIC_ANALYSIS_NO_PARTICIPANT_EVIDENCE: "AUTOMATIC_ANALYSIS_NO_PARTICIPANT_EVIDENCE";
@@ -35,6 +35,7 @@ const reasons = [
35
35
  "AUTOMATIC_ANALYSIS_OUTCOME_UNKNOWN",
36
36
  "AUTOMATIC_ANALYSIS_SOURCE_UNAVAILABLE",
37
37
  "AUTOMATIC_ANALYSIS_KEY_MISSING",
38
+ "AUTOMATIC_ANALYSIS_GEMINI_KEY_MISSING",
38
39
  "AUTOMATIC_ANALYSIS_CODEX_UNAVAILABLE",
39
40
  "AUTOMATIC_ANALYSIS_ADMISSION_REFUSED",
40
41
  "AUTOMATIC_ANALYSIS_BUSY",
@@ -0,0 +1,5 @@
1
+ import type { AnalysisProviderResult, AnalysisWire } from "./provider.js";
2
+ /** Text and usage wire shapes reuse the captured closing-report contract; refusal fails closed. */
3
+ export declare function parseAnalysisResponse(raw: unknown): AnalysisProviderResult;
4
+ /** One stateless request: no stored response, no tools, high reasoning effort, strict schema. */
5
+ export declare const openaiWire: AnalysisWire;
@@ -0,0 +1,101 @@
1
+ // The OpenAI Responses API wire format of one analysis request: what the request body says and how
2
+ // a response becomes an AnalysisProviderResult. provider.ts owns the transport around it.
3
+ import { OPENAI_RESPONSES_URL } from "../actors/openai-endpoint.js";
4
+ import { isRecord } from "../run/type-guards.js";
5
+ const record = (value) => (isRecord(value) ? value : {});
6
+ const count = (value) => Number.isSafeInteger(value) && value >= 0 && value <= 1e12;
7
+ function usageOf(raw) {
8
+ const usage = record(record(raw).usage);
9
+ const details = record(usage.input_tokens_details);
10
+ const input = usage.input_tokens;
11
+ const output = usage.output_tokens;
12
+ const cachedInput = details.cached_tokens;
13
+ const cacheWriteInput = details.cache_write_tokens;
14
+ if (!count(input) ||
15
+ !count(output) ||
16
+ (cachedInput !== undefined && !count(cachedInput)) ||
17
+ (cacheWriteInput !== undefined && !count(cacheWriteInput)) ||
18
+ (cachedInput ?? 0) + (cacheWriteInput ?? 0) >
19
+ input)
20
+ return null;
21
+ return {
22
+ input,
23
+ output,
24
+ ...(cachedInput === undefined ? {} : { cachedInput: cachedInput }),
25
+ ...(cacheWriteInput === undefined ? {} : { cacheWriteInput: cacheWriteInput }),
26
+ };
27
+ }
28
+ /** Text and usage wire shapes reuse the captured closing-report contract; refusal fails closed. */
29
+ export function parseAnalysisResponse(raw) {
30
+ const root = record(raw);
31
+ const usage = usageOf(raw);
32
+ const output = Array.isArray(root.output) ? root.output : [];
33
+ const contents = output
34
+ .filter((item) => record(item).type === "message")
35
+ .flatMap((item) => Array.isArray(record(item).content) ? record(item).content : []);
36
+ const base = { output: null, usage, dispatched: true };
37
+ if (contents.some((item) => record(item).type === "refusal")) {
38
+ return { ...base, status: "refused", errorCode: "refusal" };
39
+ }
40
+ if (root.status === "incomplete")
41
+ return { ...base, status: "incomplete", errorCode: "output_incomplete" };
42
+ if (root.status !== "completed" ||
43
+ output.some((item) => !["message", "reasoning"].includes(String(record(item).type)))) {
44
+ return { ...base, status: "failed", errorCode: "invalid_response" };
45
+ }
46
+ // A single JSON answer is expected. Do not concatenate multiple answer objects or expose a
47
+ // refusal, reasoning item, or incomplete message as the report's prose.
48
+ const messages = output.filter((item) => record(item).type === "message");
49
+ const texts = contents.filter((item) => record(item).type === "output_text");
50
+ if (messages.length !== 1 ||
51
+ record(messages[0]).status !== "completed" ||
52
+ record(messages[0]).role !== "assistant" ||
53
+ texts.length !== 1 ||
54
+ contents.length !== 1 ||
55
+ typeof record(texts[0]).text !== "string") {
56
+ return { ...base, status: "failed", errorCode: "invalid_response" };
57
+ }
58
+ try {
59
+ return {
60
+ ...base,
61
+ status: "completed",
62
+ output: JSON.parse(record(texts[0]).text),
63
+ errorCode: null,
64
+ };
65
+ }
66
+ catch {
67
+ return { ...base, status: "failed", errorCode: "invalid_response" };
68
+ }
69
+ }
70
+ /** One stateless request: no stored response, no tools, high reasoning effort, strict schema. */
71
+ export const openaiWire = {
72
+ url: () => OPENAI_RESPONSES_URL,
73
+ headers: (apiKey) => ({ Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }),
74
+ body: (request) => ({
75
+ model: request.model,
76
+ instructions: request.instructions,
77
+ input: [
78
+ {
79
+ role: "user",
80
+ content: [
81
+ { type: "input_text", text: request.evidence },
82
+ ...request.images.flatMap((image) => [
83
+ { type: "input_text", text: JSON.stringify({ captureEvidenceId: image.evidenceId }) },
84
+ { type: "input_image", image_url: image.dataUrl, detail: "high" },
85
+ ]),
86
+ ],
87
+ },
88
+ ],
89
+ store: false,
90
+ tools: [],
91
+ tool_choice: "none",
92
+ truncation: "disabled",
93
+ service_tier: "default",
94
+ reasoning: { effort: "high" },
95
+ max_output_tokens: request.maxOutputTokens,
96
+ text: {
97
+ format: { type: "json_schema", name: "study_analysis", strict: true, schema: request.schema },
98
+ },
99
+ }),
100
+ parse: parseAnalysisResponse,
101
+ };
@@ -13,7 +13,7 @@ export interface AnalysisProviderRequest {
13
13
  timeoutMs: number;
14
14
  signal?: AbortSignal;
15
15
  }
16
- interface AnalysisTokenUsage {
16
+ export interface AnalysisTokenUsage {
17
17
  input: number;
18
18
  output: number;
19
19
  cachedInput?: number;
@@ -40,10 +40,6 @@ export interface AnalysisProviderResult {
40
40
  protocolAdditions?: readonly string[];
41
41
  }
42
42
  export type AnalysisProvider = (request: AnalysisProviderRequest) => Promise<AnalysisProviderResult>;
43
- /** An input image the provider accepts; the groups are the image type and its base64 bytes. */
44
- export declare const INPUT_IMAGE_DATA_URL: RegExp;
45
- /** Text and usage wire shapes reuse the captured closing-report contract; refusal fails closed. */
46
- export declare function parseAnalysisResponse(raw: unknown): AnalysisProviderResult;
47
43
  /** The part of fetch this provider uses; undici's fetch and test doubles both satisfy it. */
48
44
  export type AnalysisFetch = (url: string, init: {
49
45
  method: "POST";
@@ -62,9 +58,21 @@ export type AnalysisFetch = (url: string, init: {
62
58
  status: number;
63
59
  body: ReadableStream<Uint8Array> | null;
64
60
  }>;
65
- /** No alternate endpoint or env-derived base URL: evidence and credentials have one destination. */
61
+ /** The analysts humanish calls over HTTP with an API key. Codex runs through its own CLI. */
62
+ export type AnalysisApiVendor = "openai" | "gemini";
63
+ /** One vendor's wire format. The transport sends `body` to `url` with `headers` and gives `parse`
64
+ * the response JSON only after a 2xx status, within the byte cap and the deadline. */
65
+ export interface AnalysisWire {
66
+ /** A fixed endpoint: never derived from env, so evidence and credentials have one destination. */
67
+ url(model: string): string;
68
+ headers(apiKey: string): Record<string, string>;
69
+ body(request: AnalysisProviderRequest): unknown;
70
+ parse(raw: unknown): AnalysisProviderResult;
71
+ }
72
+ /** One request to the vendor's fixed endpoint, never retried and never sent elsewhere. */
66
73
  export declare function createAnalysisProvider(options: {
67
74
  apiKey: string;
75
+ /** Omitted is OpenAI, the analyst humanish has always called. */
76
+ vendor?: AnalysisApiVendor;
68
77
  fetchFn?: AnalysisFetch;
69
78
  }): AnalysisProvider;
70
- export {};
@@ -1,13 +1,17 @@
1
+ // The analysis provider seam and the HTTP transport its API adapters share. Each API vendor owns
2
+ // only its wire format (gemini-wire.ts, openai-wire.ts): the endpoint, the request body, and how a
3
+ // response becomes a result. The transport owns what must hold for every vendor: one fixed endpoint
4
+ // per vendor, request and response byte caps, the caller's wall-clock deadline, no retry, and a
5
+ // closed error vocabulary that never carries provider error prose. The Codex account analyst is
6
+ // the third adapter at the same seam (restricted-codex.ts).
1
7
  import { Agent, fetch as undiciFetch } from "undici";
2
- import { OPENAI_RESPONSES_URL } from "../actors/openai-endpoint.js";
8
+ import { isRecord } from "../run/type-guards.js";
9
+ import { INPUT_IMAGE_DATA_URL } from "./image-tokens.js";
10
+ import { geminiWire } from "./gemini-wire.js";
11
+ import { openaiWire } from "./openai-wire.js";
3
12
  const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
4
13
  const MAX_REQUEST_BYTES = 32 * 1024 * 1024;
5
- /** An input image the provider accepts; the groups are the image type and its base64 bytes. */
6
- export const INPUT_IMAGE_DATA_URL = /^data:image\/(png|jpeg|webp);base64,([A-Za-z0-9+/]+={0,2})$/;
7
- const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
8
- ? value
9
- : {};
10
- const count = (value) => Number.isSafeInteger(value) && value >= 0 && value <= 1e12;
14
+ const record = (value) => (isRecord(value) ? value : {});
11
15
  /** Undici's fetch has a separate response-header deadline. The caller's wall-clock
12
16
  * deadline must govern this one request, including uploads and body reads, without
13
17
  * changing transport behavior for other requests in the process. The agent is used with
@@ -41,72 +45,14 @@ function networkErrorCode(error) {
41
45
  }
42
46
  return "provider_network_error";
43
47
  }
44
- function usageOf(raw) {
45
- const usage = record(record(raw).usage);
46
- const details = record(usage.input_tokens_details);
47
- const input = usage.input_tokens;
48
- const output = usage.output_tokens;
49
- const cachedInput = details.cached_tokens;
50
- const cacheWriteInput = details.cache_write_tokens;
51
- if (!count(input) ||
52
- !count(output) ||
53
- (cachedInput !== undefined && !count(cachedInput)) ||
54
- (cacheWriteInput !== undefined && !count(cacheWriteInput)) ||
55
- (cachedInput ?? 0) + (cacheWriteInput ?? 0) >
56
- input)
57
- return null;
58
- return {
59
- input,
60
- output,
61
- ...(cachedInput === undefined ? {} : { cachedInput: cachedInput }),
62
- ...(cacheWriteInput === undefined ? {} : { cacheWriteInput: cacheWriteInput }),
63
- };
64
- }
65
- /** Text and usage wire shapes reuse the captured closing-report contract; refusal fails closed. */
66
- export function parseAnalysisResponse(raw) {
67
- const root = record(raw);
68
- const usage = usageOf(raw);
69
- const output = Array.isArray(root.output) ? root.output : [];
70
- const contents = output
71
- .filter((item) => record(item).type === "message")
72
- .flatMap((item) => Array.isArray(record(item).content) ? record(item).content : []);
73
- const base = { output: null, usage, dispatched: true };
74
- if (contents.some((item) => record(item).type === "refusal")) {
75
- return { ...base, status: "refused", errorCode: "refusal" };
76
- }
77
- if (root.status === "incomplete")
78
- return { ...base, status: "incomplete", errorCode: "output_incomplete" };
79
- if (root.status !== "completed" ||
80
- output.some((item) => !["message", "reasoning"].includes(String(record(item).type)))) {
81
- return { ...base, status: "failed", errorCode: "invalid_response" };
82
- }
83
- // A single JSON answer is expected. Do not concatenate multiple answer objects or expose a
84
- // refusal, reasoning item, or incomplete message as the report's prose.
85
- const messages = output.filter((item) => record(item).type === "message");
86
- const texts = contents.filter((item) => record(item).type === "output_text");
87
- if (messages.length !== 1 ||
88
- record(messages[0]).status !== "completed" ||
89
- record(messages[0]).role !== "assistant" ||
90
- texts.length !== 1 ||
91
- contents.length !== 1 ||
92
- typeof record(texts[0]).text !== "string") {
93
- return { ...base, status: "failed", errorCode: "invalid_response" };
94
- }
95
- try {
96
- return {
97
- ...base,
98
- status: "completed",
99
- output: JSON.parse(record(texts[0]).text),
100
- errorCode: null,
101
- };
102
- }
103
- catch {
104
- return { ...base, status: "failed", errorCode: "invalid_response" };
105
- }
106
- }
107
- /** No alternate endpoint or env-derived base URL: evidence and credentials have one destination. */
48
+ const WIRES = {
49
+ openai: openaiWire,
50
+ gemini: geminiWire,
51
+ };
52
+ /** One request to the vendor's fixed endpoint, never retried and never sent elsewhere. */
108
53
  export function createAnalysisProvider(options) {
109
54
  const fetchFn = options.fetchFn ?? undiciFetch;
55
+ const wire = WIRES[options.vendor ?? "openai"];
110
56
  return async (request) => {
111
57
  const failure = (errorCode, dispatched, status = "failed") => ({
112
58
  status,
@@ -130,33 +76,7 @@ export function createAnalysisProvider(options) {
130
76
  request.images.some((image) => !INPUT_IMAGE_DATA_URL.test(image.dataUrl))) {
131
77
  return failure("invalid_request", false);
132
78
  }
133
- const content = [
134
- { type: "input_text", text: request.evidence },
135
- ...request.images.flatMap((image) => [
136
- { type: "input_text", text: JSON.stringify({ captureEvidenceId: image.evidenceId }) },
137
- { type: "input_image", image_url: image.dataUrl, detail: "high" },
138
- ]),
139
- ];
140
- const body = JSON.stringify({
141
- model: request.model,
142
- instructions: request.instructions,
143
- input: [{ role: "user", content }],
144
- store: false,
145
- tools: [],
146
- tool_choice: "none",
147
- truncation: "disabled",
148
- service_tier: "default",
149
- reasoning: { effort: "high" },
150
- max_output_tokens: request.maxOutputTokens,
151
- text: {
152
- format: {
153
- type: "json_schema",
154
- name: "study_analysis",
155
- strict: true,
156
- schema: request.schema,
157
- },
158
- },
159
- });
79
+ const body = JSON.stringify(wire.body(request));
160
80
  if (Buffer.byteLength(body) > MAX_REQUEST_BYTES)
161
81
  return failure("invalid_request", false);
162
82
  const controller = new AbortController();
@@ -173,11 +93,11 @@ export function createAnalysisProvider(options) {
173
93
  if (request.signal?.aborted)
174
94
  return failure("cancelled", false, "cancelled");
175
95
  dispatched = true;
176
- const response = await fetchFn(OPENAI_RESPONSES_URL, {
96
+ const response = await fetchFn(wire.url(request.model), {
177
97
  method: "POST",
178
98
  redirect: "error",
179
99
  signal: controller.signal,
180
- headers: { Authorization: `Bearer ${options.apiKey}`, "Content-Type": "application/json" },
100
+ headers: wire.headers(options.apiKey),
181
101
  body,
182
102
  dispatcher,
183
103
  timeout: false,
@@ -211,7 +131,7 @@ export function createAnalysisProvider(options) {
211
131
  if (controller.signal.aborted)
212
132
  return failure(timedOut ? "timeout" : "cancelled", true, timedOut ? "timed_out" : "cancelled");
213
133
  try {
214
- return parseAnalysisResponse(JSON.parse(Buffer.concat(chunks).toString("utf8")));
134
+ return wire.parse(JSON.parse(Buffer.concat(chunks).toString("utf8")));
215
135
  }
216
136
  catch {
217
137
  return failure("invalid_response", true);
@@ -15,5 +15,8 @@ export declare function checkProviderAnalysis(input: AnalysisInput, value: unkno
15
15
  * each response as its own request, since the long-context tier re-prices one request at a time.
16
16
  */
17
17
  export declare function recordProviderUsage(artifact: AnalysisArtifact, config: AnalysisConfig, responses: readonly AnalysisProviderResult[]): void;
18
+ /** The HTTP status an API analyst answered, for the attempt's warnings. The status code is the only
19
+ * part of the answer kept: the provider's error text may echo evidence or credentials. */
20
+ export declare function httpStatusWarning(config: AnalysisConfig, response: AnalysisProviderResult): string | undefined;
18
21
  /** The warnings a Codex response carries about its CLI release and protocol. */
19
22
  export declare function codexWarnings(config: AnalysisConfig, response: AnalysisProviderResult): string[];
@@ -185,6 +185,25 @@ export function recordProviderUsage(artifact, config, responses) {
185
185
  usage.estimatedCostUsd = priced.estimatedCostUsd;
186
186
  usage.ratesAsOf = priced.ratesAsOf;
187
187
  }
188
+ /** What an HTTP status from an analysis API most likely means, by status. */
189
+ const HTTP_STATUS_MEANINGS = {
190
+ 400: "it refused the request as invalid",
191
+ 401: "it refused the key",
192
+ 403: "the key has no access to this model",
193
+ 404: "it does not know this model",
194
+ 429: "a rate or quota limit of the key's plan was reached",
195
+ 500: "a server error; a later attempt can succeed",
196
+ 503: "the model is overloaded or unavailable; a later attempt can succeed",
197
+ };
198
+ /** The HTTP status an API analyst answered, for the attempt's warnings. The status code is the only
199
+ * part of the answer kept: the provider's error text may echo evidence or credentials. */
200
+ export function httpStatusWarning(config, response) {
201
+ if (response.httpStatus === undefined)
202
+ return undefined;
203
+ const api = config.provider === "gemini" ? "Gemini" : "OpenAI";
204
+ const meaning = HTTP_STATUS_MEANINGS[response.httpStatus];
205
+ return `The ${api} API answered HTTP ${response.httpStatus}${meaning === undefined ? "" : ` (${meaning})`}. humanish does not retry an analysis request; run the command again to retry.`;
206
+ }
188
207
  /** The warnings a Codex response carries about its CLI release and protocol. */
189
208
  export function codexWarnings(config, response) {
190
209
  if (config.provider !== "codex")
@@ -1,5 +1,6 @@
1
1
  import { defaultCodexCliVersion, describeCodexCliAdmission, } from "../actors/codex/codex-admission.js";
2
2
  import { validCodexAnalysisConfig } from "./codex-config.js";
3
+ import { analysisApiKey, analysisKeyNames, missingKeyHint } from "../keys/key-resolution.js";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import { realpath } from "node:fs/promises";
5
6
  import path from "node:path";
@@ -55,7 +56,7 @@ const messages = {
55
56
  ANALYSIS_SOURCE_CHANGED: "The source recording changed during analysis. The result cannot be published against different evidence.",
56
57
  ANALYSIS_BUSY: "Another analysis holds this run's .analysis-lock directory, and its process is still running or humanish cannot check it (another host, or a lock written by humanish 0.139.1 or earlier). Wait for it to finish; if no humanish process is running for this run, remove that directory.",
57
58
  ANALYSIS_HISTORY_UNAVAILABLE: "Analysis history is unavailable or full. No request was sent. Inspect this run's analysis and analysis-attempts storage before retrying.",
58
- ANALYSIS_CONFIG_INVALID: "OpenAI analysis needs a supported model, positive USD admission limit and 256–32768 output tokens. Codex account analysis uses its qualified model and null dollar/output-token caps. Both require timeout 1–600000 ms.",
59
+ ANALYSIS_CONFIG_INVALID: "OpenAI and Gemini analysis need a supported model of that provider, a positive USD admission limit and 256–32768 output tokens. Codex account analysis uses its qualified model and null dollar/output-token caps. All require timeout 1–600000 ms.",
59
60
  ANALYSIS_QUESTION_UNSAFE: "The reviewer question matched a sensitive-text pattern. Remove sensitive details before retrying.",
60
61
  ANALYSIS_API_KEY_MISSING: "Set OPENAI_API_KEY to run analysis. --dry-run checks admission without a key or provider request.",
61
62
  ANALYSIS_CANCELLED: "Analysis was cancelled before dispatch.",
@@ -285,9 +286,16 @@ async function executeAnalysis(attempt) {
285
286
  // An unreadable inventory is not evidence of an absent prior result.
286
287
  // Check readable history capacity before any new paid attempt.
287
288
  await assertAnalysisPublicationCapacity(prepared);
288
- const apiKey = config.provider === "codex" ? "" : (deps.apiKey ?? process.env.OPENAI_API_KEY ?? "");
289
+ const apiKey = config.provider === "codex"
290
+ ? ""
291
+ : (deps.apiKey ?? analysisApiKey(config.provider ?? "openai", process.env));
289
292
  if (config.provider !== "codex" && !apiKey.trim())
290
- return { ...fail(input.runId, false, "ANALYSIS_API_KEY_MISSING"), admission };
293
+ return {
294
+ ...fail(input.runId, false, "ANALYSIS_API_KEY_MISSING", config.provider === "gemini"
295
+ ? `Gemini analysis needs ${analysisKeyNames("gemini").join(" or ")}: ${missingKeyHint(analysisKeyNames("gemini")[0])}. --dry-run checks admission without a key or provider request.`
296
+ : undefined),
297
+ admission,
298
+ };
291
299
  let finalizeExecution;
292
300
  const rejection = {};
293
301
  const analysis = await runAnalysis(input, config, {
@@ -161,6 +161,10 @@ interface OpenAIAnalysisConfig {
161
161
  timeoutMs: number;
162
162
  maxOutputTokens: number;
163
163
  }
164
+ /** The Gemini API analyst: the same billed, capped request as OpenAI's, sent to Google. */
165
+ interface GeminiAnalysisConfig extends Omit<OpenAIAnalysisConfig, "provider"> {
166
+ provider: "gemini";
167
+ }
164
168
  /** Required, qualified execution profile, confirmed by the launcher before a turn.
165
169
  * A failed pre-dispatch artifact does not prove this CLI/model was observed.
166
170
  * These settings participate in cache and immutable attempt identity. */
@@ -184,7 +188,9 @@ export interface CodexAnalysisConfig {
184
188
  identity: CodexAnalysisIdentity;
185
189
  }
186
190
  /** Omitted provider remains the historical OpenAI API contract, including its exact hash. */
187
- export type AnalysisConfig = OpenAIAnalysisConfig | CodexAnalysisConfig;
191
+ export type AnalysisConfig = OpenAIAnalysisConfig | GeminiAnalysisConfig | CodexAnalysisConfig;
192
+ /** Who analyzed: an API billed by key (openai, gemini) or the restricted Codex account. */
193
+ export type AnalysisProviderName = "openai" | "gemini" | "codex";
188
194
  export interface AnalysisUsage {
189
195
  inputTokens: number | null;
190
196
  outputTokens: number | null;
@@ -209,7 +215,7 @@ export interface AnalysisArtifact {
209
215
  configDigest: string;
210
216
  config: AnalysisConfig;
211
217
  promptVersion: string;
212
- provider: "openai" | "codex";
218
+ provider: AnalysisProviderName;
213
219
  usage: AnalysisUsage;
214
220
  participants: AnalysisParticipantInput[];
215
221
  coverage: AnalysisCoverage;
@@ -233,6 +233,7 @@ declare const analysisExecutionReceiptSchema: z.ZodObject<{
233
233
  promptVersion: z.ZodString;
234
234
  provider: z.ZodEnum<{
235
235
  codex: "codex";
236
+ gemini: "gemini";
236
237
  openai: "openai";
237
238
  }>;
238
239
  usage: z.ZodObject<{
@@ -196,6 +196,15 @@ const analysisParticipantProvenanceSchema = z
196
196
  .nullable(),
197
197
  })
198
198
  .strict();
199
+ /** The configuration of an analyst billed by API key. Stored artifacts keep the bounds of every
200
+ * release that wrote them; admission checks the current ones before a request. */
201
+ const apiAnalysisConfigSchema = z.object({
202
+ model: label,
203
+ question: text(4000).nullable(),
204
+ maxCostUsd: z.number().positive().max(1000),
205
+ timeoutMs: z.number().int().positive().max(3600000),
206
+ maxOutputTokens: z.number().int().positive().max(128000),
207
+ });
199
208
  const analysisArtifactSchema = z
200
209
  .object({
201
210
  schema: z.literal(ANALYSIS_SCHEMA),
@@ -209,16 +218,8 @@ const analysisArtifactSchema = z
209
218
  inputDigest: digest,
210
219
  configDigest: digest,
211
220
  config: z.union([
212
- z
213
- .object({
214
- provider: z.literal("openai").optional(),
215
- model: label,
216
- question: text(4000).nullable(),
217
- maxCostUsd: z.number().positive().max(1000),
218
- timeoutMs: z.number().int().positive().max(3600000),
219
- maxOutputTokens: z.number().int().positive().max(128000),
220
- })
221
- .strict(),
221
+ apiAnalysisConfigSchema.extend({ provider: z.literal("openai").optional() }).strict(),
222
+ apiAnalysisConfigSchema.extend({ provider: z.literal("gemini") }).strict(),
222
223
  z
223
224
  .object({
224
225
  provider: z.literal("codex"),
@@ -243,7 +244,7 @@ const analysisArtifactSchema = z
243
244
  .strict(),
244
245
  ]),
245
246
  promptVersion: label,
246
- provider: z.enum(["openai", "codex"]),
247
+ provider: z.enum(["openai", "gemini", "codex"]),
247
248
  usage: z
248
249
  .object({
249
250
  cacheWriteInputTokens: z.number().int().min(0).max(1e12).nullable(),
@@ -547,7 +548,7 @@ export function validateAnalysisExecutionReceipt(value) {
547
548
  const parsed = analysisExecutionReceiptSchema.safeParse(value);
548
549
  if (!parsed.success || Date.parse(parsed.data.completedAt) < Date.parse(parsed.data.createdAt))
549
550
  throw new Error("ANALYSIS_RECEIPT_INVALID");
550
- if ((parsed.data.provider === "openai" && parsed.data.maxCostUsd === null) ||
551
+ if ((parsed.data.provider !== "codex" && parsed.data.maxCostUsd === null) ||
551
552
  (parsed.data.provider === "codex" &&
552
553
  (parsed.data.maxCostUsd !== null ||
553
554
  parsed.data.usage.estimatedCostUsd !== null ||
@@ -1,8 +1,10 @@
1
- import { analysisRequestsText, resolveAutomaticAnalysis, DEFAULT_ANALYSIS_MODEL, DEFAULT_ANALYSIS_TIMEOUT_MS, } from "../../analysis/automatic-config.js";
1
+ import { analysisRequestsText, resolveAutomaticAnalysis, DEFAULT_ANALYSIS_MODEL, DEFAULT_ANALYSIS_TIMEOUT_MS, DEFAULT_GEMINI_ANALYSIS_MODEL, } from "../../analysis/automatic-config.js";
2
2
  import { Command, Option } from "commander";
3
3
  import { forTerminal } from "../../routes/terminal/encoding.js";
4
4
  import { analyzeRun, correctAnalysis, dryRunBundleRefusal, showAnalysis, } from "../../analysis/service.js";
5
5
  import { admissionRule } from "../../analysis/admission.js";
6
+ import { GEMINI_ANALYSIS_MODELS, SUPPORTED_ANALYSIS_MODELS, } from "../../analysis/execute.js";
7
+ import { CODEX_ANALYSIS_MODEL } from "../../analysis/codex-config.js";
6
8
  import { listAnalyses } from "../../analysis/store.js";
7
9
  import { listAnalysisExecutions } from "../../analysis/store-executions.js";
8
10
  import { resolveRunPath } from "../../run/locate.js";
@@ -30,12 +32,12 @@ export function registerAnalyzeCommand(parent, io) {
30
32
  .summary("Analyze a live run into evidence-linked findings.")
31
33
  .option("--run <id>", RUN_OPTION_DESCRIPTION, "latest")
32
34
  .option("--cwd <path>", CWD_OPTION_DESCRIPTION, ".")
33
- .option("--provider <name>", "Analyst: openai (default) or restricted codex account. No provider fallback.")
34
- .option("--max-cost <usd>", `Required for OpenAI, also with --dry-run: an analysis is refused before it sends anything if ${admissionRule("this")}. It does not limit billing. Unsupported for Codex.`)
35
- .option("--model <id>", "Supported vision model. OpenAI uses high effort; qualified Codex account analysis uses low effort.", DEFAULT_ANALYSIS_MODEL)
35
+ .option("--provider <name>", "Analyst: openai (default), gemini (sends the evidence and captures to Google), or restricted codex account. No provider fallback.")
36
+ .option("--max-cost <usd>", `Required for OpenAI and Gemini, also with --dry-run: an analysis is refused before it sends anything if ${admissionRule("this")}. It does not limit billing. Unsupported for Codex.`)
37
+ .option("--model <id>", `OpenAI analysis model, at high effort: ${SUPPORTED_ANALYSIS_MODELS.join(", ")}; ${DEFAULT_ANALYSIS_MODEL} when omitted. Gemini analysis model, at high thinking: ${GEMINI_ANALYSIS_MODELS.join(", ")}; ${DEFAULT_GEMINI_ANALYSIS_MODEL} when omitted. Codex account analysis runs ${CODEX_ANALYSIS_MODEL} at low effort.`)
36
38
  .option("--question <text>", "Additional reviewer question; does not change participant instructions.")
37
39
  .option("--timeout-ms <ms>", "Request timeout, at most 600000 ms.", String(DEFAULT_ANALYSIS_TIMEOUT_MS))
38
- .option("--max-output-tokens <n>", "OpenAI response-token limit including reasoning, 256–32768. Omit for admission-based sizing. Unsupported for Codex.")
40
+ .option("--max-output-tokens <n>", "OpenAI or Gemini response-token limit including reasoning, 256–32768. Omit for admission-based sizing. Unsupported for Codex.")
39
41
  .option("--dry-run", "Capture and validate local input and estimate admission; no request or analysis artifact.")
40
42
  .option("--rerun", "Create a new immutable version even when the same input and configuration were analyzed.")
41
43
  .option("--json", JSON_OPTION_DESCRIPTION)
@@ -73,7 +75,7 @@ export function registerAnalyzeCommand(parent, io) {
73
75
  async function handleAnalyze(io, options, command) {
74
76
  const selected = resolveAutomaticAnalysis({
75
77
  ...(options.provider === undefined ? {} : { provider: options.provider }),
76
- model: options.model,
78
+ ...(options.model === undefined ? {} : { model: options.model }),
77
79
  ...(options.question === undefined ? {} : { question: options.question }),
78
80
  timeoutMs: Number(options.timeoutMs),
79
81
  ...(options.provider === "codex" && options.maxCost === undefined
@@ -106,7 +108,7 @@ async function handleAnalyze(io, options, command) {
106
108
  io.setExitCode(2);
107
109
  return;
108
110
  }
109
- // Only a live OpenAI analysis sends OPENAI_API_KEY. A dry run and the Codex account analyst
111
+ // Only a live OpenAI or Gemini analysis sends an API key. A dry run and the Codex account analyst
110
112
  // read no provider key, so they skip discovery and its `gh auth token` spawn.
111
113
  if (options.dryRun !== true && selected.config.provider !== "codex")
112
114
  await discoverCliKeys({ io, cwd: options.cwd });
@@ -1,9 +1,12 @@
1
+ import { resolve } from "node:path";
1
2
  import { Command, Option } from "commander";
2
3
  import { openTarget, renderObserver, serveObserver } from "../../observer/render.js";
3
4
  import { SERVE_SCHEMA, serveObserverLibrary } from "../../observer/serve.js";
4
5
  import { startExposedObserver, validateExposure } from "../../observer/exposure.js";
5
6
  import { ServeTunnelError } from "../../observer/tunnel.js";
6
- import { collectRepeated, CWD_OPTION_DESCRIPTION, formatRunHuman, freePortOption, JSON_OPTION_DESCRIPTION, parseObserverPort, RUN_OPTION_DESCRIPTION, wantsJson, writeResult, } from "../io.js";
7
+ import { reattachRemoteRun, remoteRunHere } from "../../remote/reattach.js";
8
+ import { reportRemoteOutcome } from "./study-remote.js";
9
+ import { collectRepeated, CWD_OPTION_DESCRIPTION, discoverCliKeys, formatRunHuman, freePortOption, JSON_OPTION_DESCRIPTION, parseObserverPort, RUN_OPTION_DESCRIPTION, wantsJson, writeResult, } from "../io.js";
7
10
  import { exitCodeForSignal, formatObserverHuman, personAtTerminal, unattendedObserverWarning, } from "../observer-follow.js";
8
11
  export function registerObserveCommand(parent, io) {
9
12
  addLibraryOptions(parent
@@ -116,6 +119,12 @@ async function observeRun(io, options, command) {
116
119
  io.setExitCode(2);
117
120
  return;
118
121
  }
122
+ // A remote run that is not copied back yet has no bundle here: follow it on its runner.
123
+ const remote = options.run === "latest" ? undefined : await remoteRunHere(options.cwd, options.run);
124
+ if (remote !== undefined) {
125
+ await observeRemoteRun(io, command, options.cwd, remote, port);
126
+ return;
127
+ }
119
128
  const rendered = await renderObserver(options.cwd, options.run, { open: false });
120
129
  if (!rendered.ok || !rendered.observerPath) {
121
130
  writeResult(command, io, rendered, formatObserverHuman);
@@ -405,3 +414,19 @@ function formatServeHuman(result) {
405
414
  }
406
415
  return lines.join("\n") + "\n";
407
416
  }
417
+ async function observeRemoteRun(io, command, cwd, remote, port) {
418
+ await discoverCliKeys({ io, cwd });
419
+ const say = (line) => io.writeErr(`${line}\n`);
420
+ let outcome;
421
+ try {
422
+ outcome = await reattachRemoteRun({ cwd: resolve(cwd), env: process.env, say }, remote, port);
423
+ }
424
+ catch (error) {
425
+ outcome = {
426
+ runId: remote.runId,
427
+ exitCode: 2,
428
+ error: `could not reach its runner: ${error instanceof Error ? error.message : String(error)}`,
429
+ };
430
+ }
431
+ reportRemoteOutcome(command, io, resolve(cwd), outcome);
432
+ }