humanish 0.128.0 → 0.130.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 (42) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/actors/stop-cause.d.ts +1 -1
  3. package/dist/analysis/automatic.js +0 -1
  4. package/dist/analysis/evidence-sources.d.ts +44 -7
  5. package/dist/analysis/evidence-sources.js +40 -6
  6. package/dist/analysis/evidence.js +96 -125
  7. package/dist/analysis/service.js +12 -6
  8. package/dist/analysis/source.d.ts +28 -0
  9. package/dist/analysis/source.js +118 -0
  10. package/dist/analysis/validation.js +1 -1
  11. package/dist/cli/commands/study-format.js +2 -1
  12. package/dist/feedback/export.js +0 -1
  13. package/dist/observer/data.d.ts +2 -2
  14. package/dist/observer/feed-cursors.d.ts +3 -1
  15. package/dist/observer/feed-cursors.js +5 -2
  16. package/dist/observer/run-routes.js +40 -7
  17. package/dist/observer-app.html +1 -1
  18. package/dist/routes/computer-use/fanout-bundle.js +4 -7
  19. package/dist/routes/shared-world/bundle-records.js +6 -1
  20. package/dist/routes/shared-world/bundle.d.ts +8 -0
  21. package/dist/routes/shared-world/bundle.js +33 -13
  22. package/dist/routes/shared-world/participant-specs.d.ts +2 -1
  23. package/dist/routes/shared-world/participant-specs.js +5 -2
  24. package/dist/routes/shared-world/result.js +13 -7
  25. package/dist/routes/shared-world/types.d.ts +2 -0
  26. package/dist/run/bundle-shape.d.ts +2 -2
  27. package/dist/run/bundle.d.ts +8 -1
  28. package/dist/run/detail.js +4 -1
  29. package/dist/run/outcomes.d.ts +7 -0
  30. package/dist/run/outcomes.js +14 -0
  31. package/dist/run/participant-records.d.ts +1 -1
  32. package/dist/run/stream-shape.d.ts +2 -2
  33. package/dist/run/stream-trace.d.ts +46 -15
  34. package/dist/run/stream-trace.js +101 -20
  35. package/dist/run/streams.d.ts +18 -0
  36. package/dist/study/bundle-estimate.d.ts +4 -1
  37. package/dist/study/bundle-estimate.js +40 -15
  38. package/dist/verify/verify.js +28 -21
  39. package/docs/architecture/observer.md +13 -0
  40. package/docs/contracts/run-bundle.md +11 -5
  41. package/docs/contracts/study-analysis.md +13 -1
  42. package/package.json +1 -1
@@ -13,15 +13,28 @@ import { isLiveItemsIndex, LIVE_ACTOR_SCHEMA, LIVE_ACTOR_V1_SCHEMA, liveItemsAft
13
13
  import { estimateActorCostForExecution } from "./pricing.js";
14
14
  import { isRunStreamArtifact, isStreamTraceIndex, isStreamTracePath } from "./stream-shape.js";
15
15
  import { isRecord } from "./type-guards.js";
16
- /** A trace that does not match its index, or cannot be read. The message names the stream. */
16
+ /**
17
+ * A trace that does not match its index, or cannot be read. The message names the stream, and ends
18
+ * with `advice`: by default, restore the run folder from a copy.
19
+ */
17
20
  export class StreamTraceRefusedError extends Error {
18
21
  streamId;
19
- constructor(streamId, detail) {
20
- super(`Participant ${streamId}'s trace was not read: ${detail}. A trace file must stay as the run wrote it; restore the run folder from a copy.`);
22
+ constructor(streamId, detail, advice = "A trace file must stay as the run wrote it; restore the run folder from a copy.") {
23
+ super(`Participant ${streamId}'s trace was not read: ${detail}. ${advice}`);
21
24
  this.streamId = streamId;
22
25
  this.name = "StreamTraceRefusedError";
23
26
  }
24
27
  }
28
+ /** Why the loader reads no trace or live file whose index records more than `maxBytes`. */
29
+ const pastLimitDetail = (index, maxBytes) => `run.json records ${index.path} at ${index.bytes} bytes, more than the ${maxBytes} bytes humanish reads of one file`;
30
+ /**
31
+ * A trace or live file whose index records more than the loader reads of one file. The loader reads
32
+ * no file past the limit, so a copy of the file is refused too.
33
+ */
34
+ export class StreamTraceTooLargeError extends StreamTraceRefusedError {
35
+ }
36
+ /** The refusal of a trace or live file whose index records more than `maxBytes`. */
37
+ const tooLarge = (streamId, index, maxBytes) => new StreamTraceTooLargeError(streamId, pastLimitDetail(index, maxBytes), "Restoring a copy does not help; open the file with another tool, such as jq, to read its items.");
25
38
  const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
26
39
  const isScreenshot = (artifact) => artifact.kind === "screenshot";
27
40
  /**
@@ -226,6 +239,13 @@ function assertIndex(stream) {
226
239
  if (stream.actor.items !== undefined)
227
240
  throw new StreamTraceRefusedError(stream.id, "run.json holds items beside its trace index");
228
241
  }
242
+ /**
243
+ * A stream of run.json typed as a loaded one, for the streams the loader joins no trace into: one
244
+ * without an index, which v1 wrote with its actor's lists, and a refused one, kept as run.json
245
+ * holds it. From v4 a refused stream's actor has none of the lists, and its `conversation` lacks
246
+ * the `requests` ActorTrace declares; the loader's `refused` names each such stream.
247
+ */
248
+ const unjoined = (stream) => stream;
229
249
  /** A live actor whose items run.json holds inline: humanish.live-actor.v1, or one with no schema. */
230
250
  const isInlineLiveActor = (live) => live.schema === LIVE_ACTOR_V1_SCHEMA || live.schema === undefined;
231
251
  /** A live file that could not be read, which a writer that moved on may have removed. */
@@ -250,10 +270,22 @@ async function readLiveItems(read, stream, maxBytes, liveAfter) {
250
270
  return refuse("run.json records a malformed live items index");
251
271
  if (live.items !== undefined)
252
272
  refuse("run.json holds in-progress items beside their live file");
253
- if (index.bytes > maxBytes)
254
- refuse(`${index.path} is indexed at ${index.bytes} bytes, more than the ${maxBytes} read`);
255
273
  const { itemsFile: _index, ...summary } = live;
256
274
  const after = liveAfter?.(index);
275
+ if (index.bytes > maxBytes) {
276
+ if (after === undefined || !cursorFits(index, after))
277
+ throw tooLarge(stream.id, index, maxBytes);
278
+ // The caller keeps the prefix it holds, read when the file was under the limit.
279
+ return {
280
+ stream: { ...stream, liveActor: { ...summary, items: [] } },
281
+ read: {
282
+ streamId: stream.id,
283
+ index,
284
+ after,
285
+ stopped: `Participant ${stream.id}'s items past ${after.items} were not read: ${pastLimitDetail(index, maxBytes)}. The items read before stay shown; open the file with another tool, such as jq, to read the rest.`,
286
+ },
287
+ };
288
+ }
257
289
  const tail = after === undefined ? undefined : await readLiveTail(read, stream, index, after);
258
290
  if (tail !== undefined)
259
291
  return {
@@ -274,6 +306,15 @@ async function readLiveItems(read, stream, maxBytes, liveAfter) {
274
306
  read: { streamId: stream.id, index },
275
307
  };
276
308
  }
309
+ /**
310
+ * Whether `after` can be a prefix of the file `index` describes: some bytes, no more items or bytes
311
+ * than the index, and at its end in both counts or in neither. Whether it ends on a line end is
312
+ * checked only when the tail is read.
313
+ */
314
+ const cursorFits = (index, after) => after.bytes > 0 &&
315
+ after.items <= index.items &&
316
+ after.bytes <= index.bytes &&
317
+ (after.items === index.items) === (after.bytes === index.bytes);
277
318
  /**
278
319
  * The items of the live file `index` describes past `after`, and the bytes they were read from;
279
320
  * undefined when the cursor does not fit the index or the file's lines, so the caller reads the
@@ -281,10 +322,7 @@ async function readLiveItems(read, stream, maxBytes, liveAfter) {
281
322
  * LiveFileUnreadableError, as a whole read does.
282
323
  */
283
324
  async function readLiveTail(read, stream, index, after) {
284
- if (after.bytes === 0 ||
285
- after.items > index.items ||
286
- after.bytes > index.bytes ||
287
- (after.items === index.items) !== (after.bytes === index.bytes))
325
+ if (!cursorFits(index, after))
288
326
  return undefined;
289
327
  if (after.bytes === index.bytes)
290
328
  return { items: [], bytes: Buffer.alloc(0) };
@@ -301,9 +339,11 @@ async function readLiveTail(read, stream, index, after) {
301
339
  */
302
340
  async function readStreamTrace(read, stream, maxBytes, liveAfter) {
303
341
  const files = [];
304
- let joined = stream;
342
+ let joined = unjoined(stream);
305
343
  if (stream.trace !== undefined) {
306
344
  assertIndex(stream);
345
+ if (stream.trace.bytes > maxBytes)
346
+ throw tooLarge(stream.id, stream.trace, maxBytes);
307
347
  const file = await read(stream.trace.path, maxBytes);
308
348
  if ("refused" in file)
309
349
  throw new StreamTraceRefusedError(stream.id, file.refused);
@@ -320,7 +360,8 @@ async function readStreamTrace(read, stream, maxBytes, liveAfter) {
320
360
  * stream without an index is returned as it is (a v1 stream, or one with no actor). An indexed
321
361
  * stream's file must have a trace file name (isStreamTracePath); it is read under `maxBytes` and
322
362
  * checked against the index's byte count, sha256, item count and screenshot count, and any
323
- * difference throws StreamTraceRefusedError. The rules that need the whole bundle (an unindexed v2
363
+ * difference throws StreamTraceRefusedError. An index that records more than `maxBytes` throws
364
+ * before the file is read. The rules that need the whole bundle (an unindexed v2
324
365
  * actor, two participants indexing one file) are loadStreamTraces'.
325
366
  */
326
367
  export async function loadStreamTrace(runPaths, stream, maxBytes = RUN_ARTIFACT_MAX_BYTES) {
@@ -387,7 +428,9 @@ function bundleRefusal(bundle, stream, owners) {
387
428
  * `liveAfter` names, for a live file's index, the prefix the caller holds already (the Observer
388
429
  * feed's cursor). The loader then reads and joins only the items past it, checked by liveItemsAfter
389
430
  * without the indexed sha256, and `live` records the cursor; a cursor past the index or off the
390
- * file's lines gets the whole prefix, checked as always.
431
+ * file's lines gets the whole prefix, checked as always. A live file whose index records more than
432
+ * `maxBytes` is not read: for a cursor that fits the index the stream gets no items past it and
433
+ * `live` records `stopped`, and without one the stream is refused.
391
434
  *
392
435
  * A live file that cannot be read may have been removed by the writer after `bundle` was read: it
393
436
  * removes a superseded generation once run.json names the next one, and every live file once the
@@ -420,20 +463,25 @@ async function rereadBundle(read, runId) {
420
463
  return undefined;
421
464
  }
422
465
  }
423
- async function loadEveryStream(read, bundle, maxBytes, liveAfter) {
466
+ /** bundleRefusal for each stream of `bundle`, with the streams that index each file counted once. */
467
+ function bundleRefusals(bundle) {
424
468
  const owners = new Map();
425
469
  for (const stream of bundle.streams)
426
470
  if (isRecord(stream.trace)) {
427
471
  const key = traceFileKey(String(stream.trace.path));
428
472
  owners.set(key, [...(owners.get(key) ?? []), stream.id]);
429
473
  }
474
+ return (stream) => bundleRefusal(bundle, stream, owners);
475
+ }
476
+ async function loadEveryStream(read, bundle, maxBytes, liveAfter) {
477
+ const refusal = bundleRefusals(bundle);
430
478
  const streams = [];
431
479
  const refused = [];
432
480
  const files = [];
433
481
  const live = [];
434
482
  for (const stream of bundle.streams) {
435
483
  try {
436
- const reason = bundleRefusal(bundle, stream, owners);
484
+ const reason = refusal(stream);
437
485
  if (reason !== undefined)
438
486
  throw new StreamTraceRefusedError(stream.id, reason);
439
487
  const loaded = await readStreamTrace(read, stream, maxBytes, liveAfter);
@@ -446,7 +494,7 @@ async function loadEveryStream(read, bundle, maxBytes, liveAfter) {
446
494
  if (!(error instanceof StreamTraceRefusedError))
447
495
  throw error;
448
496
  refused.push(error);
449
- streams.push(stream);
497
+ streams.push(unjoined(stream));
450
498
  }
451
499
  }
452
500
  return { bundle: { ...bundle, streams }, refused, files, live };
@@ -456,23 +504,25 @@ async function loadEveryStream(read, bundle, maxBytes, liveAfter) {
456
504
  * summary, shows none of the items or per-request lists run.json or a file supplied for it (its
457
505
  * actor's items and lists, its in-progress items, its screenshot entries), and carries
458
506
  * `traceUnavailable` with the reason; a warn event on its timeline says the same. A reader that
459
- * shows the run then never prices or checks rows the loader refused.
507
+ * shows the run then never prices or checks rows the loader refused. A live file the loader
508
+ * stopped reading (LiveItemsRead's `stopped`) gives its stream a `live-items.not-read` warn event.
460
509
  */
461
510
  export function displayedTraces(loaded) {
462
511
  const reasons = new Map(loaded.refused.map((error) => [error.streamId, error.message]));
463
- if (reasons.size === 0)
512
+ const stopped = (loaded.live ?? []).filter((read) => read.stopped !== undefined);
513
+ if (reasons.size === 0 && stopped.length === 0)
464
514
  return loaded.bundle;
465
515
  const streams = loaded.bundle.streams.map((stream) => {
466
516
  const reason = reasons.get(stream.id);
467
517
  if (reason === undefined)
468
518
  return stream;
469
519
  const { liveActor: _live, trace: _index, actor, ...rest } = stream;
470
- return {
520
+ return unjoined({
471
521
  ...rest,
472
522
  ...(actor === undefined ? {} : { actor: { ...withoutRequestLists(actor), items: [] } }),
473
523
  artifacts: stream.artifacts.filter((artifact) => !isScreenshot(artifact)),
474
524
  traceUnavailable: reason,
475
- };
525
+ });
476
526
  });
477
527
  const events = loaded.bundle.streams
478
528
  .filter((stream) => reasons.has(stream.id))
@@ -484,7 +534,38 @@ export function displayedTraces(loaded) {
484
534
  message: reasons.get(stream.id),
485
535
  streamId: stream.id,
486
536
  }));
487
- return { ...loaded.bundle, streams, events: [...loaded.bundle.events, ...events] };
537
+ const notRead = stopped.map((read) => ({
538
+ id: `event-live-items-not-read-${read.streamId}`,
539
+ at: loaded.bundle.streams.find((stream) => stream.id === read.streamId)?.updatedAt ?? "",
540
+ level: "warn",
541
+ type: "live-items.not-read",
542
+ message: read.stopped,
543
+ streamId: read.streamId,
544
+ }));
545
+ return {
546
+ ...loaded.bundle,
547
+ streams,
548
+ events: [...loaded.bundle.events, ...events, ...notRead],
549
+ };
550
+ }
551
+ /**
552
+ * What `reduce` returns for each stream of the bundle, loaded as loadRunTraces loads it, in stream
553
+ * order; the first refusal throws. Streams are loaded one at a time and nothing of a stream's files
554
+ * is kept past its `reduce` call, so a caller that keeps part of each trace holds one trace at a
555
+ * time where loadRunTraces holds them all. Unlike loadStreamTraces it does not read run.json again
556
+ * when a live file cannot be read, so it suits a run whose participants have finished.
557
+ */
558
+ export async function reduceRunTraces(runPaths, bundle, reduce) {
559
+ const read = containedReader(runPaths);
560
+ const refusal = bundleRefusals(bundle);
561
+ const kept = [];
562
+ for (const [index, stream] of bundle.streams.entries()) {
563
+ const reason = refusal(stream);
564
+ if (reason !== undefined)
565
+ throw new StreamTraceRefusedError(stream.id, reason);
566
+ kept.push(reduce((await readStreamTrace(read, stream, RUN_ARTIFACT_MAX_BYTES)).stream, index));
567
+ }
568
+ return kept;
488
569
  }
489
570
  /** The bundle with every stream loaded; the first refusal throws. */
490
571
  export async function loadRunTraces(source, bundle, maxBytes) {
@@ -316,4 +316,22 @@ export interface RunStream {
316
316
  kind: "bundle" | "review" | "observer" | "events" | "screenshot" | "trace" | "log" | "filesystem" | "recording";
317
317
  }>;
318
318
  }
319
+ /**
320
+ * `T` without a participant's per-request lists (REQUEST_LISTS in src/run/stream-trace.ts):
321
+ * `tokenUsage.turns`, `conversation.requests` and `providerRequests`. From run bundle v4 only the
322
+ * trace file holds them, so a reader of run.json or of the Observer's data has none to read.
323
+ */
324
+ export type WithoutRequestLists<T> = Omit<T, "providerRequests" | "tokenUsage" | "conversation"> & {
325
+ [K in "tokenUsage" & keyof T]?: Omit<NonNullable<T[K]>, "turns"> | undefined;
326
+ } & {
327
+ [K in "conversation" & keyof T]?: Omit<NonNullable<T[K]>, "requests"> | undefined;
328
+ };
329
+ /**
330
+ * A stream as run.json holds it, in any version: its actor and live actor WithoutRequestLists.
331
+ * loadStreamTraces joins the lists from the trace file.
332
+ */
333
+ export type StoredRunStream = Omit<RunStream, "actor" | "liveActor"> & {
334
+ actor?: WithoutRequestLists<ActorTrace>;
335
+ liveActor?: WithoutRequestLists<NonNullable<RunStream["liveActor"]>>;
336
+ };
319
337
  export {};
@@ -1,11 +1,14 @@
1
1
  import type { DwellWindow } from "../actors/stop-conditions.js";
2
2
  import type { ParticipantSchedule } from "./arrivals.js";
3
- /** One participant as the plan has it: its schedule and its dwell window. */
3
+ /** One participant as the plan has it: its schedule, its dwell window and its mission. */
4
4
  export interface EstimatedParticipant {
5
5
  readonly schedule: ParticipantSchedule;
6
6
  readonly limits: {
7
7
  readonly dwell?: DwellWindow;
8
8
  };
9
+ readonly assignment: {
10
+ readonly mission?: string;
11
+ };
9
12
  }
10
13
  /** A run's estimated bundle, and the scan's limit for its participants. */
11
14
  export interface BundleEstimate {
@@ -1,14 +1,18 @@
1
1
  // How large a computer-use run's bundle grows, estimated from its plan before anything runs, and
2
- // whether it would pass a limit a later command holds it to: analysis reads run.json and each
3
- // participant's trace up to EVIDENCE_LIMITS.sourceBytes each, verify reads no file larger than
4
- // RUN_ARTIFACT_MAX_BYTES, and the public-safety scan visits at most runEntryLimit(participants)
5
- // files and folders.
2
+ // whether it would pass a limit a later command holds it to: analysis reads run.json up to
3
+ // EVIDENCE_LIMITS.sourceBytes, analysis, verify and the Observer read each participant's trace up to
4
+ // RUN_ARTIFACT_MAX_BYTES, verify reads no file larger than RUN_ARTIFACT_MAX_BYTES, and the
5
+ // public-safety scan visits at most runEntryLimit(participants) files and folders.
6
6
  //
7
7
  // observer/index.html carries every participant's items gzipped, so with busy participants it
8
8
  // passes verify's 32 MiB long before run.json passes analysis's 16 MiB: at these rates, 20
9
9
  // participants working 8 hours. From run bundle v4 run.json grows by participant, not by model
10
- // request: each participant's per-request lists are only in its trace. run.json can still pass
11
- // 16 MiB, since a study's mission is not capped and each participant's stream copies it.
10
+ // request: each participant's per-request lists are only in its trace. run.json holds the mission
11
+ // once for the run, once in each participant's stream and once more in the feedback candidate of
12
+ // each participant that reports friction or stops. The mission is not capped, and the estimate
13
+ // counts that last copy for every participant, so it warns at 100 participants with a mission of
14
+ // 75,238 bytes. On 2026-10-13 the longest mission on the live computer-use runs kept on the
15
+ // maintainer's machine was 807 characters.
12
16
  // observer-data.json leaves the lists out too and was 0.94 to 1.12 of run.json's size on the live
13
17
  // runs measured, so it passes 32 MiB only after run.json passes 16 MiB, and the estimate does not
14
18
  // check it.
@@ -63,8 +67,12 @@ const RATES = Object.freeze({
63
67
  /** observer/index.html's app before any run data: 1,199,831 bytes on the dry runs. */
64
68
  observerAppBytes: 1_200_000,
65
69
  /**
66
- * The page's compact copy of observer-data.json, as a share of run.json: 0.71 to 0.84 on the
67
- * live runs written as v4, where neither carries the per-request lists.
70
+ * The page's compact copy of observer-data.json, as a share of run.json before the mission: 0.71
71
+ * to 0.84 on the live runs written as v4, where neither carries the per-request lists. The page
72
+ * holds the mission at its full size once for the run and once for each participant: dry runs of
73
+ * 1 and 4 participants grew the page by 2 and 5 copies, as run.json grew, and a 0.128.0 run of 2
74
+ * participants with 2 feedback candidates held 5 copies in run.json and 3 on the page. An
75
+ * analysis adds each participant's assignment, cut to 8,000 bytes.
68
76
  */
69
77
  observerPageDataShare: 0.85,
70
78
  /**
@@ -97,11 +105,13 @@ export function estimateBundle(participants, sessionMs) {
97
105
  let frames = 0;
98
106
  let traces = 0;
99
107
  let largestTrace = 0;
108
+ let missionBytes = 0;
109
+ let longestMission = 0;
100
110
  let longestWindowMs = 0;
101
111
  // The longest any participant without an end is counted, for the messages.
102
112
  let openMs = Math.min(sessionMs, RATES.openSessionMs);
103
113
  let scheduled = 0;
104
- for (const { schedule, limits } of participants) {
114
+ for (const { schedule, limits, assignment } of participants) {
105
115
  const { end } = schedule;
106
116
  const budgetMs = end?.windowMs ?? sessionMs;
107
117
  let countedMs;
@@ -122,12 +132,26 @@ export function estimateBundle(participants, sessionMs) {
122
132
  frames += ownFrames;
123
133
  traces += trace;
124
134
  largestTrace = Math.max(largestTrace, trace);
135
+ // The mission's bytes as run.json stores them, a JSON string without its quotes.
136
+ const mission = assignment.mission === undefined
137
+ ? 0
138
+ : Buffer.byteLength(JSON.stringify(assignment.mission)) - 2;
139
+ missionBytes += mission;
140
+ longestMission = Math.max(longestMission, mission);
125
141
  }
126
142
  const count = participants.length;
127
- const runFileBytes = RATES.runBytes + count * RATES.participantBytes;
143
+ const dataBytes = RATES.runBytes + count * RATES.participantBytes;
144
+ // run.json holds the mission in the run's scenario goal, in each participant's stream and in the
145
+ // feedback candidate of each participant that reports friction or stops, counted for every
146
+ // participant. The rates were fit on runs with short missions, so a short mission is counted
147
+ // twice.
148
+ const runFileBytes = dataBytes + longestMission + 2 * missionBytes;
128
149
  const screenshots = count + turns + frames;
150
+ // The page holds no feedback candidate's copy.
129
151
  const observerPageBytes = Math.round(RATES.observerAppBytes +
130
- RATES.observerPageDataShare * runFileBytes +
152
+ RATES.observerPageDataShare * dataBytes +
153
+ longestMission +
154
+ missionBytes +
131
155
  RATES.observerPageItemsShare * traces);
132
156
  return {
133
157
  runFileBytes,
@@ -158,12 +182,12 @@ export function bundleLimitReason(estimate) {
158
182
  const passed = [
159
183
  ...(runFile
160
184
  ? [
161
- `This study's run.json may grow to about ${mib(estimate.runFileBytes)}, past the ${mib(EVIDENCE_LIMITS.sourceBytes)} analysis reads.`,
185
+ `This study's run.json may grow to about ${mib(estimate.runFileBytes)}, past the ${mib(EVIDENCE_LIMITS.sourceBytes)} analysis reads. It holds the mission once for the run, once in each participant's stream and once more for each participant that reports friction or stops, and the estimate counts the last copy for every participant.`,
162
186
  ]
163
187
  : []),
164
- ...(estimate.largestTraceBytes > EVIDENCE_LIMITS.sourceBytes
188
+ ...(estimate.largestTraceBytes > RUN_ARTIFACT_MAX_BYTES
165
189
  ? [
166
- `One participant's trace may grow to about ${mib(estimate.largestTraceBytes)}, past the ${mib(EVIDENCE_LIMITS.sourceBytes)} analysis reads of each trace.`,
190
+ `One participant's trace may grow to about ${mib(estimate.largestTraceBytes)}, past the ${mib(RUN_ARTIFACT_MAX_BYTES)} humanish reads of each trace.`,
167
191
  ]
168
192
  : []),
169
193
  ...(page
@@ -195,6 +219,7 @@ export function bundleLimitReason(estimate) {
195
219
  const changes = [
196
220
  ...(scheduled > 0 ? ["shorten the longest windows (participants[].endAfterMs)"] : []),
197
221
  ...(dwell ? ["shorten the dwell window (dwell.ms) or capture less often (dwell.everyMs)"] : []),
222
+ ...(runFile ? ["shorten the mission (actor.mission)"] : []),
198
223
  ];
199
224
  // A trace is one participant's, so splitting the study helps only the run's own files.
200
225
  if (runFile || page || entries || changes.length === 0)
@@ -211,7 +236,7 @@ export function bundleLimitReason(estimate) {
211
236
  }
212
237
  /** The estimate beside the limits, for `study check`. */
213
238
  export function describeBundle(estimate) {
214
- return `run.json about ${mib(estimate.runFileBytes)} of the ${mib(EVIDENCE_LIMITS.sourceBytes)} analysis reads; largest trace about ${mib(estimate.largestTraceBytes)} of the ${mib(EVIDENCE_LIMITS.sourceBytes)} analysis reads of each; Observer page about ${mib(estimate.observerPageBytes)} of the ${mib(RUN_ARTIFACT_MAX_BYTES)} verify reads; about ${count(estimate.entries)} files and folders of the ${count(estimate.entryLimit)} the public-safety scan reads; about ${mib(estimate.totalBytes, 0)} in all. Participants with a declared end are counted for their whole window and the others for up to ${formatDuration(estimate.basis.openMs)}, at a model turn every ${formatDuration(RATES.secondsPerTurn * 1000)}.`;
239
+ return `run.json about ${mib(estimate.runFileBytes)} of the ${mib(EVIDENCE_LIMITS.sourceBytes)} analysis reads; largest trace about ${mib(estimate.largestTraceBytes)} of the ${mib(RUN_ARTIFACT_MAX_BYTES)} humanish reads of each; Observer page about ${mib(estimate.observerPageBytes)} of the ${mib(RUN_ARTIFACT_MAX_BYTES)} verify reads; about ${count(estimate.entries)} files and folders of the ${count(estimate.entryLimit)} the public-safety scan reads; about ${mib(estimate.totalBytes, 0)} in all. Participants with a declared end are counted for their whole window and the others for up to ${formatDuration(estimate.basis.openMs)}, at a model turn every ${formatDuration(RATES.secondsPerTurn * 1000)}.`;
215
240
  }
216
241
  /** Bytes in MiB to `digits` places, or two under 0.1 MiB, so a short study's trace does not read 0.0. */
217
242
  function mib(bytes, digits = 1) {
@@ -80,7 +80,7 @@ export async function verifyResolvedRun(cwd, runInput, runPaths) {
80
80
  // Every check below reads the streams with their traces loaded; a trace that does not match its
81
81
  // index fails the evidence-artifacts check and its stream is checked without its items.
82
82
  const traces = await loadTraces(runPaths, runJsonValue(bundleRead));
83
- const { bundle, refusedTraces } = traces;
83
+ const { bundle, recorded, refusedTraces } = traces;
84
84
  const cleanupRead = await readRunJsonIfExists(runPaths, "cleanup.json");
85
85
  const review = {
86
86
  json: await readRunJsonIfExists(runPaths, "review.json"),
@@ -101,20 +101,20 @@ export async function verifyResolvedRun(cwd, runInput, runPaths) {
101
101
  unscannedArtifacts.files.push(SANDBOX_RECEIPTS_ARTIFACT);
102
102
  }
103
103
  const publicSafetyFindings = await scanRunPublicSafetyArtifacts(runPaths, derivedPublicSafetyFindings, {
104
- recordingPaths: new Set(isRunBundle(bundle)
104
+ recordingPaths: new Set(bundle
105
105
  ? bundle.streams.flatMap((stream) => (stream.recording ? [stream.recording.path] : []))
106
106
  : []),
107
- screenshotPaths: isRunBundle(bundle) ? streamScreenshotPaths(bundle) : new Set(),
107
+ screenshotPaths: bundle ? streamScreenshotPaths(bundle) : new Set(),
108
108
  // A run from before 0.110 with no receipts still names its ids in run.json.
109
- sandboxIds: [...new Set([...journaledSandboxIds, ...keyedSandboxIds(bundle)])],
109
+ sandboxIds: [...new Set([...journaledSandboxIds, ...keyedSandboxIds(recorded)])],
110
110
  sandboxIdFiles,
111
- }, unscannedArtifacts, isRunBundle(bundle) ? bundle.simulations.length : 0);
111
+ }, unscannedArtifacts, bundle ? bundle.simulations.length : 0);
112
112
  addTraceScan(traces, publicSafetyFindings, unscannedArtifacts);
113
113
  // JSON escapes can hide a sensitive value from the byte scan while the
114
114
  // decoded recording exposes it to Observer, feedback, or analysis input.
115
115
  if (publicSafetyFindings.length < MAX_REPORTED_FINDINGS &&
116
- bundle !== null &&
117
- containsSensitive(JSON.stringify(bundle))) {
116
+ recorded !== null &&
117
+ containsSensitive(JSON.stringify(recorded))) {
118
118
  publicSafetyFindings.push("sensitive decoded run.json");
119
119
  }
120
120
  checks.push({
@@ -124,14 +124,14 @@ export async function verifyResolvedRun(cwd, runInput, runPaths) {
124
124
  ? "no run text artifact or public-proof path matches a known secret or browser-profile pattern"
125
125
  : `public-safety findings: ${publicSafetyFindings.slice(0, 5).join(", ")}`,
126
126
  });
127
- const missingEvidenceArtifacts = isRunBundle(bundle)
127
+ const missingEvidenceArtifacts = bundle
128
128
  ? await missingLocalEvidenceArtifacts(runPaths, bundle)
129
129
  : [];
130
- const redactedShapeFrames = isRunBundle(bundle)
130
+ const redactedShapeFrames = bundle
131
131
  ? await redactedShapeFramePaths(runPaths, bundle)
132
132
  : new Set();
133
- const invalidEvidenceReferences = isRunBundle(bundle) ? invalidRunEvidenceReferences(bundle) : [];
134
- checks.push(evidenceArtifactsCheck(isRunBundle(bundle), [
133
+ const invalidEvidenceReferences = bundle ? invalidRunEvidenceReferences(bundle) : [];
134
+ checks.push(evidenceArtifactsCheck(bundle !== undefined, [
135
135
  ...refusedTraces,
136
136
  ...(invalidEvidenceReferences.length > 0
137
137
  ? [`invalid evidence artifact references: ${invalidEvidenceReferences.join(", ")}`]
@@ -148,10 +148,10 @@ export async function verifyResolvedRun(cwd, runInput, runPaths) {
148
148
  message: "Derived analysis contains sensitive text or unsafe artifact paths; sharing is blocked, original recording remains independently verifiable.",
149
149
  });
150
150
  const ok = checks.every((check) => check.ok);
151
- const notFinished = isRunBundle(bundle)
151
+ const notFinished = bundle
152
152
  ? await runNotFinished(runPaths, bundle)
153
153
  : { warnings: [], unfinished: undefined };
154
- const warnings = isRunBundle(bundle)
154
+ const warnings = bundle
155
155
  ? [
156
156
  ...rawScreenshotPostureWarnings(bundle, redactedShapeFrames),
157
157
  ...undeclaredSubjectStateWarnings(bundle),
@@ -160,7 +160,7 @@ export async function verifyResolvedRun(cwd, runInput, runPaths) {
160
160
  ...notFinished.warnings,
161
161
  ]
162
162
  : [];
163
- const shareSafety = isRunBundle(bundle)
163
+ const shareSafety = bundle
164
164
  ? buildShareSafety({
165
165
  ok,
166
166
  bundle,
@@ -226,12 +226,19 @@ function evidenceArtifactsCheck(shaped, problems) {
226
226
  /**
227
227
  * run.json with each stream's trace loaded, one file at a time, and the refusal of each trace that
228
228
  * does not match its index, with the ids of those streams. A run.json that fails the shape guard
229
- * is returned as it is.
229
+ * gives no bundle. `recorded` is the loaded bundle, or run.json as parsed when it has none.
230
230
  */
231
231
  async function loadTraces(runPaths, stored) {
232
232
  const scanned = { sensitive: [], opaque: [] };
233
233
  if (!isRunBundle(stored))
234
- return { bundle: stored, refusedTraces: [], refusedStreams: new Set(), ...scanned, pages: [] };
234
+ return {
235
+ bundle: undefined,
236
+ recorded: stored,
237
+ refusedTraces: [],
238
+ refusedStreams: new Set(),
239
+ ...scanned,
240
+ pages: [],
241
+ };
235
242
  // Each trace is source evidence: scanned by the bytes it was joined from, apart from the
236
243
  // directory walk and any path exemption the walk makes.
237
244
  const { bundle, refused } = await loadStreamTraces(runPaths, stored, {
@@ -243,6 +250,7 @@ async function loadTraces(runPaths, stored) {
243
250
  });
244
251
  return {
245
252
  bundle,
253
+ recorded: bundle,
246
254
  refusedTraces: refused.map((error) => error.message),
247
255
  refusedStreams: new Set(refused.map((error) => error.streamId)),
248
256
  ...scanned,
@@ -381,13 +389,12 @@ function findingsCheck(name, findings, okMessage, failLabel) {
381
389
  */
382
390
  const SHAPE_UNCHECKED = "not checked, because run.json failed the shape check";
383
391
  /**
384
- * The bundle-content checks, in report order. A bundle that fails its shape guard has no findings.
385
- * `refusedStreams` names the streams whose traces the loader refused, which the evidence-artifacts
386
- * check reports.
392
+ * The bundle-content checks of the loaded bundle `valid`, in report order. Without one (run.json
393
+ * failed its shape guard) no check has findings. `refusedStreams` names the streams whose traces
394
+ * the loader refused, which the evidence-artifacts check reports.
387
395
  */
388
- async function evidenceChecks(runPaths, bundle, cleanupRead, refusedStreams) {
396
+ async function evidenceChecks(runPaths, valid, cleanupRead, refusedStreams) {
389
397
  const cleanupJson = runJsonValue(cleanupRead);
390
- const valid = isRunBundle(bundle) ? bundle : null;
391
398
  const pass = (message) => (valid ? message : SHAPE_UNCHECKED);
392
399
  const checks = [
393
400
  findingsCheck("terminal-product evidence", valid ? await validateTerminalProductEvidence(runPaths, valid) : [], pass("live terminal-product evidence is complete (ledgers, redacted output, proven teardown, known spend within the cap), or the run has none"), "terminal-product findings"),
@@ -97,6 +97,19 @@ A page that cannot join a body sends no cursors with its next poll. Static
97
97
  operator path. Agents and CI should use `humanish watch --json --no-open` for
98
98
  the same fresh evidence without browser open or a long-running process.
99
99
 
100
+ The feed reads no live file whose index passes the 32 MiB read limit
101
+ (`RUN_ARTIFACT_MAX_BYTES`); a request for the file by its path still gets the whole file. A page
102
+ that sends a cursor for it keeps the items it holds: the
103
+ server answers with no items past the cursor, gives the page's cursor back as `live`, and adds a
104
+ `live-items.not-read` warning to the participant's timeline, so later polls send the same cursor
105
+ and get the same answer. A poll that sends no cursor for that file gets the participant's trace
106
+ as unavailable, with the file's size and the limit, and the page drops any items it held. Such a
107
+ poll comes from a page opened after the file passed the limit or after the writer started a new
108
+ generation, from the poll after a body the page could not join, and from a page whose cursors
109
+ pass 8,000 characters (`MAX_AFTER_CHARS` in `observer/lib/live.ts`), which sends none. When the
110
+ run finishes, a trace file past the limit is refused too, and every page shows that trace as
111
+ unavailable.
112
+
100
113
  ### Reopening a run
101
114
 
102
115
  The TUI's **Open in Observer** action opens an HTTP view of the selected run's saved
@@ -52,9 +52,14 @@ a run id or `latest`: it returns run.json with every trace file and live file jo
52
52
  as below, for v1, v2, v3 and v4 bundles, with each participant's per-request lists, and throws an Error named `StreamTraceRefusedError` for a
53
53
  file that does not match its index. Inside humanish, readers load a stream through
54
54
  `loadStreamTrace(runPaths, stream)`. A v1 stream returns as it is.
55
- An indexed stream's file is read under the 32 MiB per-file limit (analysis uses its 16 MiB
56
- source limit for each trace) and refused unless its size, sha256, item count and screenshot count
57
- match the index. A trace file belongs to one participant: every stream whose index names a file
55
+ An indexed stream's file is read under the 32 MiB per-file limit, by analysis as by every other
56
+ reader, and refused unless its size, sha256, item count and screenshot count
57
+ match the index. A trace or live file whose index records more than the limit is refused before it
58
+ is read, and the refusal says the file is larger than humanish reads of one file and that
59
+ restoring a copy does not help. A trace file whose index is under the limit and whose bytes are
60
+ over it changed after the run wrote it, and its refusal says to restore the run folder from a copy.
61
+ A live file may hold bytes past its index while the writer appends; the loader reads only the
62
+ indexed bytes. A trace file belongs to one participant: every stream whose index names a file
58
63
  another stream's index names, compared without case as a case-insensitive file system compares
59
64
  names, is refused before any file is read. The writer claims names the same way. In a v2 run.json every `actor`
60
65
  must have an index: an actor with no index, with
@@ -133,8 +138,9 @@ Readers that show a run (the Observer server, run detail and the TUI, the review
133
138
  provenance) load traces through the same loader and rules. A participant whose trace the loader
134
139
  refused shows none of the items run.json or a file supplied for it: its stream carries
135
140
  `traceUnavailable` with the reason, its Observer timeline a `trace.unavailable` warning, and its
136
- run-detail entry `evidenceUnavailable`. The Observer server reads the saved observer-data.json
137
- only while a run has no run.json at all; a run.json that is there and cannot be read, parsed or
141
+ run-detail entry `evidenceUnavailable`. The Observer server reads run.json, and the saved
142
+ observer-data.json, under the same 32 MiB limit; past it the server answers 404 with a reason
143
+ naming the limit. It reads the saved observer-data.json only while a run has no run.json at all; a run.json that is there and cannot be read, parsed or
138
144
  used as a bundle answers 404 with the reason, never the saved projection. In safe serving
139
145
  (`observe --all --safe`) every read is checked against the files admitted with the run's
140
146
  share_ready verification, so a run.json changed after admission is refused and the admitted
@@ -183,7 +183,19 @@ stopped, an operator can remove the empty lock directory and retry.
183
183
 
184
184
  Each request's packet admits up to 16 participants, 800 evidence items, 40 PNG
185
185
  captures, 160 KiB of text and 20 MiB of images. Individual source files, image
186
- dimensions and result sizes have separate limits. Count and text budgets are
186
+ dimensions and result sizes have separate limits. Analysis reads run.json up to
187
+ 16 MiB and each participant's trace up to 32 MiB, as verify and the Observer do.
188
+ It reads the traces one at a time and keeps of each only what selection can
189
+ reach: the participant's share of its request's 800 evidence items, worked out
190
+ from run.json before any trace is read, and the first 80 captures selection may
191
+ read. Beside the one trace it is reading, analysis holds at most 800 entries for
192
+ each request and 80 for each participant; their size grows with each trace
193
+ item's text. A run.json past 16 MiB is refused as `ANALYSIS_SOURCE_TOO_LARGE`, and
194
+ a run.json or trace without the fields analysis reads as `ANALYSIS_SOURCE_INVALID`;
195
+ each message names the file, says why, and says no request was sent. Analysis
196
+ verifies the run before it reads a trace, so a trace that does not match its
197
+ index in run.json, or whose index records more than 32 MiB, fails verify and
198
+ `humanish analyze` reports `ANALYSIS_VERIFY_FAILED`. Count and text budgets are
187
199
  distributed across included participants, with unused capacity from short
188
200
  sessions available to longer ones. Reclaimed capture slots go first to eligible
189
201
  participants with fewer admitted images, including those deferred by their
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanish",
3
- "version": "0.128.0",
3
+ "version": "0.130.0",
4
4
  "description": "Synthetic user research for apps, CLIs, and agent-facing product flows.",
5
5
  "keywords": [
6
6
  "cli",