humanish 0.134.0 → 0.136.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 (62) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/actors/computer-use/loop/session.js +7 -2
  3. package/dist/actors/scripted-browser/actor.js +3 -3
  4. package/dist/actors/scripted-browser/steps.d.ts +9 -2
  5. package/dist/actors/scripted-browser/steps.js +17 -12
  6. package/dist/analysis/automatic-config.js +5 -3
  7. package/dist/analysis/evidence-sources.js +4 -7
  8. package/dist/analysis/evidence.js +4 -4
  9. package/dist/analysis/execute.js +2 -1
  10. package/dist/analysis/service.js +2 -1
  11. package/dist/analysis/source.d.ts +7 -4
  12. package/dist/analysis/source.js +22 -4
  13. package/dist/cli/commands/setup.js +7 -10
  14. package/dist/evidence/encoded-text.d.ts +13 -3
  15. package/dist/evidence/encoded-text.js +88 -25
  16. package/dist/evidence/image.d.ts +2 -2
  17. package/dist/evidence/image.js +47 -32
  18. package/dist/observer/data.d.ts +7 -3
  19. package/dist/observer/page-data.d.ts +6 -0
  20. package/dist/observer/page-data.js +34 -7
  21. package/dist/observer/participant-items.d.ts +15 -1
  22. package/dist/observer/participant-items.js +4 -3
  23. package/dist/observer/render.js +2 -4
  24. package/dist/observer/run-routes.js +4 -4
  25. package/dist/observer-app.html +4 -4
  26. package/dist/routes/computer-use/fanout-bundle.js +3 -3
  27. package/dist/routes/computer-use/participant-execution.js +1 -1
  28. package/dist/routes/computer-use/participant-runs.js +2 -2
  29. package/dist/routes/computer-use/single-bundle.js +3 -3
  30. package/dist/routes/scripted/setup.js +62 -17
  31. package/dist/routes/shared-world/bundle.js +3 -3
  32. package/dist/routes/shared-world/participant-specs.js +2 -2
  33. package/dist/routes/terminal/bundle.js +2 -2
  34. package/dist/run/bundle.d.ts +5 -1
  35. package/dist/run/locate.d.ts +13 -11
  36. package/dist/run/locate.js +20 -13
  37. package/dist/run/paths.js +38 -28
  38. package/dist/run/run.js +4 -3
  39. package/dist/run/stream-trace.d.ts +2 -2
  40. package/dist/run/stream-trace.js +24 -11
  41. package/dist/run/streams.d.ts +18 -3
  42. package/dist/run-study.d.ts +3 -3
  43. package/dist/run-study.js +3 -3
  44. package/dist/study/bundle-estimate.d.ts +21 -3
  45. package/dist/study/bundle-estimate.js +65 -45
  46. package/dist/study/config.d.ts +4 -3
  47. package/dist/study/config.js +8 -5
  48. package/dist/study/parse/actors.d.ts +6 -0
  49. package/dist/study/parse/actors.js +14 -0
  50. package/dist/study/parse/subject-state.js +4 -4
  51. package/dist/study/participant-assignment.js +2 -2
  52. package/dist/study/persona.d.ts +7 -1
  53. package/dist/study/persona.js +13 -5
  54. package/dist/study/plan-base.js +4 -3
  55. package/dist/tui-app.js +23 -23
  56. package/dist/verify/screenshot-reads.js +1 -1
  57. package/docs/architecture/observer.md +24 -7
  58. package/docs/architecture/state-driven-executor.md +4 -3
  59. package/docs/contracts/run-bundle.md +3 -0
  60. package/docs/contracts/study-analysis.md +7 -5
  61. package/docs/decisions/0002-observer-is-one-self-contained-file.md +3 -3
  62. package/package.json +1 -1
@@ -5,7 +5,7 @@
5
5
  import { isUtf8 } from "node:buffer";
6
6
  import { createHash } from "node:crypto";
7
7
  import { readPlainText } from "./plain-text.js";
8
- import { containsSensitive } from "./redaction.js";
8
+ import { containsSensitive, REDACTION_MARKERS, redactText } from "./redaction.js";
9
9
  // HTML5 named references that stand for printable ASCII. Letters and digits have only numeric
10
10
  // references, which decodeEscapes handles.
11
11
  const ASCII_ENTITIES = new Map([
@@ -97,37 +97,71 @@ function decodePercentBytes(run) {
97
97
  export function decodeEscapes(text) {
98
98
  return decodeWith(text, decodePercentBytes);
99
99
  }
100
- /** The readings a scan matches, with the two that scanEncodedText also searches for runs. */
100
+ /** The readings a scan matches, with the three that scanEncodedText also searches for runs. */
101
101
  function readings(text) {
102
- const decoded = decodeEscapes(text);
102
+ // Decoding references with the other escapes joins a letter reference to the escaped key after
103
+ // it, `A\u0073k-` to `Ask-`, where a pattern's word boundary fails. With references left as
104
+ // written, the `;` before the key keeps that boundary. A key that ends in a reference before an
105
+ // escaped letter, `l\u0041`, keeps the boundary after it when only references are decoded.
106
+ // The reading with references left as written is also read with transfer escapes expanded, as
107
+ // the decoded one is, for a key that a quoted-printable soft break splits.
108
+ const unescaped = decodeCodeEscapes(text, decodePercentBytes);
109
+ const decoded = decodeReferences(unescaped);
103
110
  // The UTF-8 reading differs only where a percent escape holds a byte of 0x80 or more, which
104
111
  // decodeEscapes writes as a character from U+0080 to U+00FF. Testing the decoded text also
105
112
  // catches a `%` that a `\u0025` wrote.
106
113
  const utf8 = HIGH_BYTE.test(decoded) ? decodeWith(text, decodePercentRun) : decoded;
107
114
  const expanded = decodeTransferEscapes(decoded);
115
+ // Without references in the text, or without code escapes, each of these two readings is one
116
+ // already decoded above, so it reuses that string. On a real 2 MiB Observer page, decoding both
117
+ // again took 12 ms of the 60 that readingsOf took.
118
+ const unescapedExpanded = unescaped === decoded ? expanded : decodeTransferEscapes(unescaped);
119
+ const referencesDecoded = text === unescaped ? decoded : decodeReferences(text);
108
120
  const recovered = [text, decoded].filter((reading) => HIGH_BYTE.test(reading));
109
121
  return {
122
+ all: [
123
+ ...new Set([
124
+ text,
125
+ decoded,
126
+ utf8,
127
+ expanded,
128
+ ...recovered.map(latin1RunsAsUtf8),
129
+ unescaped,
130
+ unescapedExpanded,
131
+ referencesDecoded,
132
+ ]),
133
+ ],
110
134
  decoded,
111
135
  expanded,
112
- all: [...new Set([text, decoded, utf8, expanded, ...recovered.map(latin1RunsAsUtf8)])],
136
+ unescapedExpanded,
113
137
  };
114
138
  }
115
139
  /**
116
140
  * Every reading of the text a scan matches, each once: as written, decodeEscapes, with percent
117
- * escapes read as UTF-8, decodeEscapes with transfer escapes expanded, and the text as written and
141
+ * escapes read as UTF-8, decodeEscapes with transfer escapes expanded, the text as written and
118
142
  * decoded with each run of U+0080 to U+00FF characters read as UTF-8 bytes, which undoes text that
119
- * a reader already decoded one byte per character. Either percent reading can split a value that
120
- * the other keeps whole, as `%E2%80%80` (U+2000, a space, in UTF-8) does.
143
+ * a reader already decoded one byte per character, decodeEscapes with HTML references left as
144
+ * written and that reading with transfer escapes expanded, and the text with only HTML references
145
+ * decoded. Either percent reading can split a value that the other keeps whole, as `%E2%80%80`
146
+ * (U+2000, a space, in UTF-8) does.
121
147
  */
122
148
  export function readingsOf(text) {
123
149
  return readings(text).all;
124
150
  }
125
151
  function decodeWith(text, percent) {
152
+ return decodeReferences(decodeCodeEscapes(text, percent));
153
+ }
154
+ /** JSON and JS string escapes, then percent-encoding. */
155
+ function decodeCodeEscapes(text, percent) {
126
156
  return text
127
157
  .replace(/\\u([0-9a-f]{4})/gi, (match, hex) => codePoint(Number.parseInt(hex, 16), match))
128
158
  .replace(/\\x([0-9a-f]{2})/gi, (match, hex) => codePoint(Number.parseInt(hex, 16), match))
129
159
  .replace(/\\\//g, "/")
130
- .replace(/(?:%[0-9a-f]{2})+/gi, percent)
160
+ .replace(/(?:%[0-9a-f]{2})+/gi, percent);
161
+ }
162
+ /** HTML character references: numeric ones and the ASCII_ENTITIES names. */
163
+ function decodeReferences(text) {
164
+ return text
131
165
  .replace(/&#x([0-9a-f]{1,6});?/gi, (match, hex) => codePoint(Number.parseInt(hex, 16), match))
132
166
  .replace(/&#(\d{1,7});?/g, (match, digits) => codePoint(Number.parseInt(digits, 10), match))
133
167
  .replace(/&([a-z]+);/gi, namedReference);
@@ -144,6 +178,8 @@ const BASE64_RUN = /[A-Za-z0-9+/]{16}[A-Za-z0-9+/]*={0,2}/g;
144
178
  const BASE64URL_RUN = /[A-Za-z0-9_-]{16}[A-Za-z0-9_-]*={0,2}/g;
145
179
  // Hex-encoded text, at least sixteen bytes of it.
146
180
  const HEX_RUN = /[0-9a-f]{32}[0-9a-f]*/gi;
181
+ // The base64, URL-safe base64 or hex run right after a `;`, such as the one that ends a reference.
182
+ const RUN_AFTER_SEMICOLON = /;([A-Za-z0-9+/_-]{16}[A-Za-z0-9+/_-]*={0,2})/g;
147
183
  // A base64 run that decodes to bytes that are neither text nor an archive is unreadable only past
148
184
  // this length. Across 173 real run folders surveyed on 2026-10-01, the longest such run outside
149
185
  // observer/index.html was a 69-character URL path in a terminal log; base64 of a 94-byte payload,
@@ -293,6 +329,18 @@ function inspectDecoded(bytes, run, options, depth) {
293
329
  options.allowOpaqueBase64 !== true,
294
330
  };
295
331
  }
332
+ /** The base64 and URL-safe base64 runs in a reading, each with its bytes. */
333
+ function base64Runs(reading) {
334
+ const runs = [];
335
+ for (const [match] of reading.matchAll(BASE64_RUN))
336
+ // Hex digests and ids decode to noise; scanEncodedText's hex pass reads hex as hex.
337
+ if (!/^[0-9a-f]+$/i.test(match.replace(/=+$/, "")))
338
+ runs.push({ run: match, bytes: Buffer.from(match, "base64") });
339
+ for (const [match] of reading.matchAll(BASE64URL_RUN))
340
+ if (/[-_]/.test(match))
341
+ runs.push({ run: match, bytes: Buffer.from(match, "base64url") });
342
+ return runs;
343
+ }
296
344
  /**
297
345
  * Scans text for secrets in each of its readingsOf, and inside each base64 (standard, URL-safe or
298
346
  * line-wrapped) and hex run. A run that decodes to text is scanned in
@@ -301,31 +349,31 @@ function inspectDecoded(bytes, run, options, depth) {
301
349
  */
302
350
  export function scanEncodedText(text, options = {}, depth = 0) {
303
351
  const matches = matcherOf(options);
304
- const { decoded, expanded, all } = readings(text);
352
+ const { all, decoded, expanded, unescapedExpanded } = readings(text);
305
353
  if (all.some(matches))
306
354
  return SENSITIVE;
307
- const runs = [];
355
+ const runs = base64Runs(expanded);
308
356
  // A piece or a run decoded from a slash only adds findings; its bytes are not judged opaque.
309
357
  const slashStarts = [];
310
- for (const [match] of expanded.matchAll(BASE64_RUN)) {
311
- // Hex digests and ids decode to noise; the hex pass below reads hex as hex.
312
- if (/^[0-9a-f]+$/i.test(match.replace(/=+$/, "")))
313
- continue;
314
- runs.push({ run: match, bytes: Buffer.from(match, "base64") });
315
- if (match.length > MAX_SLASH_START_RUN)
358
+ for (const { run } of runs) {
359
+ if (run.length > MAX_SLASH_START_RUN)
316
360
  continue;
317
- for (const piece of match.split("/"))
318
- if (piece.length >= 16 && piece.length < match.length)
361
+ for (const piece of run.split("/"))
362
+ if (piece.length >= 16 && piece.length < run.length)
319
363
  slashStarts.push(Buffer.from(piece, "base64"));
320
- for (let slash = match.indexOf("/"); slash !== -1 && slash < SLASH_START_SPAN && match.length - slash > 16; slash = match.indexOf("/", slash + 1))
321
- slashStarts.push(Buffer.from(match.slice(slash + 1), "base64"));
322
- }
323
- for (const [match] of expanded.matchAll(BASE64URL_RUN)) {
324
- if (/[-_]/.test(match))
325
- runs.push({ run: match, bytes: Buffer.from(match, "base64url") });
364
+ for (let slash = run.indexOf("/"); slash !== -1 && slash < SLASH_START_SPAN && run.length - slash > 16; slash = run.indexOf("/", slash + 1))
365
+ slashStarts.push(Buffer.from(run.slice(slash + 1), "base64"));
326
366
  }
327
367
  for (const run of wrappedBase64Runs(expanded))
328
368
  runs.push({ run, bytes: Buffer.from(run, "base64") });
369
+ // A decoded reference right before a run joins its character to the run, which shifts every
370
+ // base64 quartet and hex pair in it. The run after each `;` with references left as written
371
+ // adds findings, as a slash start does.
372
+ const runsAfterSemicolons = unescapedExpanded === expanded
373
+ ? []
374
+ : [...unescapedExpanded.matchAll(RUN_AFTER_SEMICOLON)].map(([, run]) => run);
375
+ for (const { bytes } of runsAfterSemicolons.flatMap(base64Runs))
376
+ slashStarts.push(bytes);
329
377
  let opaque = false;
330
378
  for (const { run, bytes } of runs) {
331
379
  const result = inspectDecoded(bytes, run, options, depth);
@@ -341,6 +389,7 @@ export function scanEncodedText(text, options = {}, depth = 0) {
341
389
  const hexRuns = new Set([
342
390
  ...[...expanded.matchAll(HEX_RUN)].map(([match]) => match),
343
391
  ...(expanded === decoded ? [] : [...decoded.matchAll(HEX_RUN)].map(([match]) => match)),
392
+ ...runsAfterSemicolons.flatMap((run) => [...run.matchAll(HEX_RUN)].map(([match]) => match)),
344
393
  ]);
345
394
  for (const match of hexRuns) {
346
395
  const plain = readPlainText(Buffer.from(match, "hex"));
@@ -349,10 +398,24 @@ export function scanEncodedText(text, options = {}, depth = 0) {
349
398
  }
350
399
  return { sensitive: false, opaque };
351
400
  }
401
+ /**
402
+ * redactText, then each whitespace-separated piece that scanEncodedText reads as sensitive replaced
403
+ * by the secret marker, so a key written in base64 or percent-encoded goes as a literal one does.
404
+ * When the pieces read clean one at a time but the text still reads sensitive, as `Bearer` before a
405
+ * percent-encoded token does, the whole text becomes the marker. For study text the run records and
406
+ * the instructions composed from it: on 32 KiB of prose it took 3 ms, and 34 ms with a key in it.
407
+ */
408
+ export function redactEncodedText(text) {
409
+ const literal = redactText(text);
410
+ if (!scanEncodedText(literal).sensitive)
411
+ return literal;
412
+ const pieces = literal.replace(/\S+/g, (piece) => scanEncodedText(piece).sensitive ? REDACTION_MARKERS.secret : piece);
413
+ return scanEncodedText(pieces).sensitive ? REDACTION_MARKERS.secret : pieces;
414
+ }
352
415
  // Bump when scanEncodedText, decodeEscapes or the sensitive patterns change what they return. The
353
416
  // cache lives in one process, so the version guards results across a hot reload or a test that
354
417
  // swaps the scanner.
355
- const ENCODED_SCAN_VERSION = 5;
418
+ const ENCODED_SCAN_VERSION = 6;
356
419
  // Distinct files one process verifies in a burst (a run's files, a serve library's runs).
357
420
  const SCAN_CACHE_LIMIT = 256;
358
421
  const scanCache = new Map();
@@ -20,5 +20,5 @@ export declare function pngDecodeRefusal(bytes: Buffer): string | null;
20
20
  * were downscaled, and any other chunk is a place to carry bytes no one reviews.
21
21
  */
22
22
  export declare function isRedactedFrameShape(bytes: Buffer): boolean;
23
- export declare function screenshotEvidenceError(relativePath: string, bytes: Buffer): string | null;
24
- export declare function assertScreenshotEvidence(relativePath: string, bytes: Buffer): void;
23
+ export declare function screenshotEvidenceError(relativePath: string, bytes: Buffer): Promise<string | null>;
24
+ export declare function assertScreenshotEvidence(relativePath: string, bytes: Buffer): Promise<void>;
@@ -1,4 +1,4 @@
1
- import { crc32, inflateSync } from "node:zlib";
1
+ import { crc32, createInflate } from "node:zlib";
2
2
  import { PNG } from "pngjs";
3
3
  const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
4
4
  const PNG_IHDR_LENGTH = 13;
@@ -128,7 +128,7 @@ export function isRedactedFrameShape(bytes) {
128
128
  last.end === bytes.length &&
129
129
  bytes.readUInt32BE(16) <= SCREENSHOT_MAX_WIDTH_CAP);
130
130
  }
131
- export function screenshotEvidenceError(relativePath, bytes) {
131
+ export async function screenshotEvidenceError(relativePath, bytes) {
132
132
  const extension = relativePath.toLowerCase().split(".").pop() ?? "";
133
133
  if (extension !== "png") {
134
134
  return `unsupported screenshot extension .${extension || "unknown"}; only decoded PNG evidence is supported`;
@@ -146,7 +146,7 @@ export function screenshotEvidenceError(relativePath, bytes) {
146
146
  if (chunks?.some((chunk) => chunk.type === "IEND" && chunk.end - chunk.start !== 12)) {
147
147
  return "PNG IEND chunk must be empty";
148
148
  }
149
- return chunks === null || !pngStructureHolds(bytes, chunks)
149
+ return chunks === null || !(await pngStructureHolds(bytes, chunks))
150
150
  ? "could not decode PNG evidence"
151
151
  : null;
152
152
  }
@@ -164,14 +164,14 @@ const PNG_MIN_CHUNK_BYTES = { gAMA: 4 };
164
164
  /**
165
165
  * Whether a PNG that passed pngDecodeRefusal decodes, read from its structure: the file ends at an
166
166
  * empty IEND, every chunk's CRC matches, IHDR comes once with fields PNG defines, the image data is
167
- * one run of IDAT chunks, and it inflates, with zlib's native code, to exactly the
168
- * bytes its rows need with nothing after the stream, each row starting with a filter type from 0
169
- * to 4. For an image without a palette that is all a decode establishes, at about an eighth of
170
- * the time of pngjs, which unfilters every pixel in JavaScript; a run holds a screenshot for each
171
- * turn. A palette image is also decoded, since each pixel must index a palette that comes before
172
- * the image data; in any other image a palette chunk is only a suggestion, wherever it is.
167
+ * one run of IDAT chunks, and it inflates (inflatesToRows) to exactly the bytes its rows need with
168
+ * nothing after the stream, each row starting with a filter type from 0 to 4. For an image without
169
+ * a palette that is all a decode establishes, at about an eighth of the time of pngjs, which
170
+ * unfilters every pixel in JavaScript; a run holds a screenshot for each turn. A palette image is
171
+ * also decoded, since each pixel must index a palette that comes before the image data; in any
172
+ * other image a palette chunk is only a suggestion, wherever it is.
173
173
  */
174
- function pngStructureHolds(bytes, chunks) {
174
+ async function pngStructureHolds(bytes, chunks) {
175
175
  const last = chunks.at(-1);
176
176
  if (last?.type !== "IEND" || last.end !== bytes.length)
177
177
  return false;
@@ -200,29 +200,44 @@ function pngStructureHolds(bytes, chunks) {
200
200
  return false;
201
201
  const rowBytes = 1 + Math.ceil(rowBits / 8);
202
202
  const data = Buffer.concat(idats.map((chunk) => bytes.subarray(chunk.start + 8, chunk.end - 4)));
203
- let rows;
204
- try {
205
- // A stream that would inflate past the rows the image needs stops there, so a small file
206
- // cannot claim gigabytes.
207
- const options = {
208
- maxOutputLength: rowBytes * height,
209
- info: true,
210
- };
211
- const inflated = inflateSync(data, options);
212
- if (inflated.engine.bytesWritten !== data.length)
213
- return false;
214
- rows = inflated.buffer;
215
- }
216
- catch {
217
- return false;
218
- }
219
- if (rows.length !== rowBytes * height)
203
+ if (!(await inflatesToRows(data, rowBytes, height)))
220
204
  return false;
221
- for (let row = 0; row < rows.length; row += rowBytes)
222
- if (rows[row] > 4)
223
- return false;
224
205
  return colorType !== 3 || pngDecodes(bytes);
225
206
  }
207
+ // The most inflated bytes one check holds at once. Checks overlap on the thread pool, so rows held
208
+ // whole add up across them. A process running 8 checks of 5000x5000 RGBA frames peaked at 832 MB
209
+ // resident with each frame's rows held whole and at 202 MB checked 1 MiB at a time.
210
+ const INFLATE_CHUNK_BYTES = 1024 * 1024;
211
+ /**
212
+ * Whether `data` inflates to exactly `rows` rows of `rowBytes` bytes, each starting with a filter
213
+ * type from 0 to 4, with nothing after the stream. zlib inflates on libuv's thread pool, so checks
214
+ * of several frames overlap. A stream that would inflate past the rows stops there, so a small
215
+ * file cannot claim gigabytes.
216
+ */
217
+ function inflatesToRows(data, rowBytes, rows) {
218
+ const total = rowBytes * rows;
219
+ return new Promise((resolve) => {
220
+ const inflate = createInflate({
221
+ chunkSize: Math.max(64, Math.min(total, INFLATE_CHUNK_BYTES)),
222
+ });
223
+ let seen = 0;
224
+ inflate.on("data", (chunk) => {
225
+ let filtersHold = true;
226
+ // The first row that starts in this chunk, then each row after it.
227
+ for (let at = (rowBytes - (seen % rowBytes)) % rowBytes; at < chunk.length; at += rowBytes)
228
+ if (chunk[at] > 4)
229
+ filtersHold = false;
230
+ seen += chunk.length;
231
+ if (!filtersHold || seen > total) {
232
+ inflate.destroy();
233
+ resolve(false);
234
+ }
235
+ });
236
+ inflate.on("error", () => resolve(false));
237
+ inflate.on("end", () => resolve(seen === total && inflate.bytesWritten === data.length));
238
+ inflate.end(data);
239
+ });
240
+ }
226
241
  function pngDecodes(bytes) {
227
242
  try {
228
243
  PNG.sync.read(bytes, { checkCRC: true });
@@ -232,8 +247,8 @@ function pngDecodes(bytes) {
232
247
  return false;
233
248
  }
234
249
  }
235
- export function assertScreenshotEvidence(relativePath, bytes) {
236
- const error = screenshotEvidenceError(relativePath, bytes);
250
+ export async function assertScreenshotEvidence(relativePath, bytes) {
251
+ const error = await screenshotEvidenceError(relativePath, bytes);
237
252
  if (error) {
238
253
  throw new Error(`Invalid screenshot evidence ${relativePath}: ${error}`);
239
254
  }
@@ -1,7 +1,8 @@
1
+ import type { ActorTrace } from "../actors/contract.js";
1
2
  import { type ActorEnding } from "../actors/stop-cause.js";
2
- import type { RunBundle, RunCostSummary, RunEvent, RunSimulation } from "../run/bundle.js";
3
+ import type { DisplayedRunBundle, RunBundle, RunCostSummary, RunEvent, RunSimulation } from "../run/bundle.js";
3
4
  import { type RunDisplay } from "../run/display.js";
4
- import type { RunStream, RunStreamKind, StoredRunStream } from "../run/streams.js";
5
+ import type { RunStream, RunStreamKind, StoredRunStream, WithoutRequestLists } from "../run/streams.js";
5
6
  /**
6
7
  * The Observer's data. From v2 observer-data.json leaves out each participant's items, and from v3
7
8
  * each participant's per-request lists (REQUEST_LISTS in src/run/stream-trace.ts), which no part
@@ -97,6 +98,9 @@ export interface ObserverData {
97
98
  };
98
99
  }
99
100
  export interface ObserverStream extends StoredRunStream {
101
+ /** The loaded actor, with its items and without its per-request lists, as is the live actor. */
102
+ actor?: WithoutRequestLists<ActorTrace>;
103
+ liveActor?: WithoutRequestLists<NonNullable<RunStream["liveActor"]>>;
100
104
  /** Only the attached server may grant provider-origin access for a live desktop iframe. */
101
105
  embed?: NonNullable<RunStream["embed"]> & {
102
106
  runtimeDesktop?: true;
@@ -127,7 +131,7 @@ interface ObserverLaneGroup {
127
131
  * bundle with no outcome in run.json: a run in progress, interrupted, or recorded before run.json
128
132
  * carried its outcome.
129
133
  */
130
- export declare function buildObserverData(bundle: RunBundle, generatedAt?: string, statusRecord?: unknown): ObserverData;
134
+ export declare function buildObserverData(bundle: DisplayedRunBundle, generatedAt?: string, statusRecord?: unknown): ObserverData;
131
135
  /**
132
136
  * A saved Observer projection as this version reads it, as v3 and unchecked as before. An
133
137
  * observer-data.json written before v2 holds every participant's items, and its per-request lists,
@@ -1,5 +1,11 @@
1
+ import type { LiveItemsRead } from "../run/stream-trace.js";
1
2
  import type { ObserverData } from "./data.js";
2
3
  /** The page's data elements for `data`, in place of the built page's data slot. */
3
4
  export declare function observerDataElements(data: ObserverData): string;
5
+ /**
6
+ * observer-feed.json's body: observer-data.json's content and the items packed as the page packs
7
+ * them, with each running participant's cursor from `live` on its entry.
8
+ */
9
+ export declare function observerFeedBody(data: ObserverData, live?: readonly LiveItemsRead[]): Promise<string>;
4
10
  /** JSON that stays inert inside a script element: no `</script>` breakout, no HTML entities. */
5
11
  export declare function escapeJsonScript(value: unknown): string;
@@ -1,15 +1,42 @@
1
- // How an Observer page carries a run's data: observer-data.json's content as JSON, and every
2
- // participant's trace items gzip-compressed in base64, which a file:// page decodes without a
3
- // fetch. The items are most of a long run's bytes and compress about eight times.
4
- import { gzipSync } from "node:zlib";
5
- import { splitObserverData } from "./participant-items.js";
1
+ // How an Observer page and the served feed carry a run's data: observer-data.json's content as
2
+ // JSON, and each participant's trace items gzip-compressed in base64 on their own, which a file://
3
+ // page decodes without a fetch. The items are most of a long run's bytes and compress about eight
4
+ // times. Packing one participant at a time keeps every string under V8's longest string
5
+ // (536,870,888 characters) while each participant's trace stays under the 32 MiB read limit.
6
+ import { promisify } from "node:util";
7
+ import { constants, gzip, gzipSync } from "node:zlib";
8
+ import { withFeedCursors } from "./feed-cursors.js";
9
+ import { splitObserverData, } from "./participant-items.js";
6
10
  /** The page's data elements for `data`, in place of the built page's data slot. */
7
11
  export function observerDataElements(data) {
8
12
  const { shell, items } = splitObserverData(data);
9
- const packed = gzipSync(JSON.stringify(items)).toString("base64");
10
13
  return (`<script id="observer-data" type="application/json">${escapeJsonScript(shell)}</script>` +
11
- `<script id="observer-items" type="application/octet-stream" data-encoding="gzip">${packed}</script>`);
14
+ `<script id="observer-items" type="application/json">${escapeJsonScript(packObserverItems(items))}</script>`);
12
15
  }
16
+ const gzipOnThreadPool = promisify(gzip);
17
+ /**
18
+ * observer-feed.json's body: observer-data.json's content and the items packed as the page packs
19
+ * them, with each running participant's cursor from `live` on its entry.
20
+ */
21
+ export async function observerFeedBody(data, live = []) {
22
+ const { shell, items } = splitObserverData(data);
23
+ const cursored = withFeedCursors(items, live);
24
+ // Each open page polls every 5 seconds and gets every finished participant's items again, so the
25
+ // server compresses them on the thread pool and answers other requests meanwhile. Level 1 took
26
+ // half the time of the default on 30 MiB of items, for 13 percent more bytes. One participant
27
+ // at a time, so a poll holds one participant's JSON text at once.
28
+ const streams = [];
29
+ for (const [id, entry] of Object.entries(cursored.streams)) {
30
+ const packed = await gzipOnThreadPool(entryText(id, entry), { level: constants.Z_BEST_SPEED });
31
+ streams.push(packed.toString("base64"));
32
+ }
33
+ return JSON.stringify({ data: shell, items: { ...cursored, streams } });
34
+ }
35
+ function packObserverItems(items) {
36
+ const streams = Object.entries(items.streams).map(([id, entry]) => gzipSync(entryText(id, entry)).toString("base64"));
37
+ return { ...items, streams };
38
+ }
39
+ const entryText = (id, entry) => JSON.stringify({ id, ...entry });
13
40
  /** JSON that stays inert inside a script element: no `</script>` breakout, no HTML entities. */
14
41
  export function escapeJsonScript(value) {
15
42
  return JSON.stringify(value)
@@ -1,6 +1,6 @@
1
1
  import type { ActorTraceItem } from "../actors/contract.js";
2
2
  import type { ObserverData, ObserverStream } from "./data.js";
3
- export declare const OBSERVER_ITEMS_SCHEMA = "humanish.observer-items.v1";
3
+ export declare const OBSERVER_ITEMS_SCHEMA = "humanish.observer-items.v2";
4
4
  /** A stream's actor and live actor, each without its items. */
5
5
  type ObserverShellStream = Omit<ObserverStream, "actor" | "liveActor"> & {
6
6
  actor?: Omit<NonNullable<ObserverStream["actor"]>, "items">;
@@ -32,6 +32,20 @@ export interface ObserverItems {
32
32
  liveAfter?: string;
33
33
  }>;
34
34
  }
35
+ /**
36
+ * ObserverItems as the page and the feed carry them: each stream's entry with its stream id, as
37
+ * gzip-compressed JSON in base64, so no string holds two participants' items. The id goes inside
38
+ * the entry because the public-safety scan reads the page's plain text: there, `GITHUB_TOKEN`
39
+ * before a long base64 value reads as a credential, and `https://` with a later id shaped like an
40
+ * address at an E2B host reads as one E2B URL across the base64 between them.
41
+ */
42
+ export interface PackedObserverItems extends Omit<ObserverItems, "streams"> {
43
+ streams: string[];
44
+ }
45
+ /** A packed entry once decoded: the stream's entry with its stream id. */
46
+ export type PackedObserverEntry = ObserverItems["streams"][string] & {
47
+ id: string;
48
+ };
35
49
  /** The data as observer-data.json and the page's items carry it. */
36
50
  export declare function splitObserverData(data: ObserverData): {
37
51
  shell: ObserverShell;
@@ -1,9 +1,10 @@
1
1
  // The Observer's data in two parts: observer-data.json, the run and every participant without
2
2
  // their trace items, and the items themselves. A participant working for hours records thousands
3
3
  // of items, so the items are most of the bytes; apart, the saved observer-data.json stays small
4
- // enough to verify, and the page carries the items compressed. The Observer bundles this module,
5
- // so it imports types only (docs/decisions/0002-observer-is-one-self-contained-file.md).
6
- export const OBSERVER_ITEMS_SCHEMA = "humanish.observer-items.v1";
4
+ // enough to verify, and the page and the feed carry each participant's items compressed on their
5
+ // own. The Observer bundles this module, so it imports types only
6
+ // (docs/decisions/0002-observer-is-one-self-contained-file.md).
7
+ export const OBSERVER_ITEMS_SCHEMA = "humanish.observer-items.v2";
7
8
  /** The data as observer-data.json and the page's items carry it. */
8
9
  export function splitObserverData(data) {
9
10
  // No prototype, so a stream id such as `__proto__` is an entry like any other.
@@ -11,7 +11,7 @@ import { projectShareCheckedAnalysis } from "../analysis/sharing.js";
11
11
  import { loadAnalysis } from "../analysis/load.js";
12
12
  import { physicalCwdOf, runIdOf, bindExistingRunArtifactPaths, isSafeRunIdSegment, resolveLatestRunDirectory, validatePreparedRunArtifactPaths, } from "../run/paths.js";
13
13
  import { writeContainedOutputFile } from "../run/contained-output.js";
14
- import { loadRunBundlePrepared, readRunJsonIfExists, runJsonValue } from "../run/locate.js";
14
+ import { loadDisplayedRunBundle, readRunJsonIfExists, runJsonValue } from "../run/locate.js";
15
15
  import { readRunNotes } from "../run/note-files.js";
16
16
  import { RUN_STATUS_FILE } from "../run/status.js";
17
17
  import { verifyRunPrepared } from "../verify/verify.js";
@@ -91,9 +91,7 @@ export async function renderObserver(cwdInput, runInput, options = {}) {
91
91
  : "HUMANISH_INVALID_RUN_BUNDLE", verified.error?.message ?? "Run bundle failed verification.");
92
92
  }
93
93
  // Verify passes a trace past the read limit, so its participant shows without its items.
94
- const loaded = await loadRunBundlePrepared(selectedPhysicalCwd, preparedRunPaths, {
95
- display: true,
96
- });
94
+ const loaded = await loadDisplayedRunBundle(selectedPhysicalCwd, preparedRunPaths);
97
95
  await validatePreparedRunArtifactPaths(preparedRunPaths);
98
96
  if (!loaded)
99
97
  return runNotFound(cwd, runInput);
@@ -17,7 +17,8 @@ import { MAX_NOTE_FILE_BYTES, readRunNotes, RUN_NOTES_DIR, } from "../run/note-f
17
17
  import { analysisCostOf, runCost, runCostLabel } from "../run/run-cost.js";
18
18
  import { RUN_STATUS_FILE, RUN_STATUS_STALE_MS, isRunStatusRecord, } from "../run/status.js";
19
19
  import { renderObserverHtml } from "./artifact.js";
20
- import { parseFeedCursors, withFeedCursors } from "./feed-cursors.js";
20
+ import { parseFeedCursors } from "./feed-cursors.js";
21
+ import { observerFeedBody } from "./page-data.js";
21
22
  import { splitObserverData } from "./participant-items.js";
22
23
  import { buildObserverData, observerDataFromSaved, recordedStreamEmbed, withObserverEndings, } from "./data.js";
23
24
  import { buildArtifactSecurityHeaders } from "./http.js";
@@ -130,10 +131,9 @@ export async function serveRunPath(runRoot, relativePath, response, runtimeStrea
130
131
  writeResponse(response, 404, `Observer data not found: ${observed.unavailable}`, "text/plain; charset=utf-8");
131
132
  return;
132
133
  }
133
- const { shell, items } = splitObserverData(observed.data);
134
134
  writeResponse(response, 200, cleanedRelativePath === "observer/observer-data.json"
135
- ? JSON.stringify(shell, null, 2)
136
- : JSON.stringify({ data: shell, items: withFeedCursors(items, observed.live) }), "application/json; charset=utf-8");
135
+ ? JSON.stringify(splitObserverData(observed.data).shell, null, 2)
136
+ : await observerFeedBody(observed.data, observed.live), "application/json; charset=utf-8");
137
137
  return;
138
138
  }
139
139
  if (cleanedRelativePath === "observer/study-analysis.json") {