humanish 0.122.0 → 0.124.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 (61) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/CONTEXT.md +2 -2
  3. package/README.md +33 -6
  4. package/dist/actors/computer-use/loop/trace.js +6 -1
  5. package/dist/actors/computer-use/openai-conversation.d.ts +0 -6
  6. package/dist/actors/computer-use/openai-conversation.js +0 -8
  7. package/dist/actors/computer-use/openai-provider.js +5 -8
  8. package/dist/analysis/automatic.js +2 -1
  9. package/dist/analysis/evidence.js +41 -21
  10. package/dist/cli/commands/study-run.js +9 -3
  11. package/dist/evidence/image.js +82 -3
  12. package/dist/feedback/derivative-text.d.ts +12 -0
  13. package/dist/feedback/derivative-text.js +130 -0
  14. package/dist/feedback/export-bundle.js +39 -136
  15. package/dist/feedback/export.js +36 -9
  16. package/dist/observer/artifact.js +2 -9
  17. package/dist/observer/data.d.ts +9 -2
  18. package/dist/observer/data.js +17 -2
  19. package/dist/observer/page-data.d.ts +5 -0
  20. package/dist/observer/page-data.js +21 -0
  21. package/dist/observer/participant-items.d.ts +45 -0
  22. package/dist/observer/participant-items.js +85 -0
  23. package/dist/observer/render.js +2 -1
  24. package/dist/observer/run-routes.js +75 -22
  25. package/dist/observer-app.html +4 -4
  26. package/dist/run/bundle-shape.js +2 -2
  27. package/dist/run/bundle.d.ts +5 -2
  28. package/dist/run/bundle.js +4 -1
  29. package/dist/run/detail.d.ts +6 -0
  30. package/dist/run/detail.js +14 -1
  31. package/dist/run/locate.js +3 -1
  32. package/dist/run/projection.d.ts +7 -0
  33. package/dist/run/projection.js +10 -0
  34. package/dist/run/run.js +9 -2
  35. package/dist/run/stored-runs.js +10 -4
  36. package/dist/run/stream-shape.d.ts +11 -1
  37. package/dist/run/stream-shape.js +26 -2
  38. package/dist/run/stream-trace.d.ts +68 -0
  39. package/dist/run/stream-trace.js +260 -0
  40. package/dist/run/streams.d.ts +20 -0
  41. package/dist/study/bundle-estimate.d.ts +48 -0
  42. package/dist/study/bundle-estimate.js +159 -0
  43. package/dist/study/doctor.js +4 -0
  44. package/dist/study/plan-types.d.ts +3 -0
  45. package/dist/study/plan.js +42 -2
  46. package/dist/study/preflight.js +8 -0
  47. package/dist/tui-app.js +87 -87
  48. package/dist/verify/artifacts.d.ts +17 -10
  49. package/dist/verify/artifacts.js +22 -13
  50. package/dist/verify/scan-limit.d.ts +6 -0
  51. package/dist/verify/scan-limit.js +25 -0
  52. package/dist/verify/verify.d.ts +1 -1
  53. package/dist/verify/verify.js +68 -18
  54. package/docs/architecture/github-feedback-loop.md +1 -1
  55. package/docs/architecture/observer.md +24 -5
  56. package/docs/contracts/README.md +1 -1
  57. package/docs/contracts/run-bundle.md +89 -6
  58. package/docs/contracts/schemas.md +14 -7
  59. package/docs/contracts/study-analysis.md +1 -1
  60. package/docs/decisions/0002-observer-is-one-self-contained-file.md +2 -1
  61. package/package.json +7 -4
@@ -4,18 +4,17 @@ import { createHash } from "node:crypto";
4
4
  import { lstat, mkdir, mkdtemp, readdir, realpath, rename, rm, rmdir } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { tmpdir } from "node:os";
7
- import { parse as parseYaml } from "yaml";
8
- import { ACTOR_TRACE_SCHEMA } from "../actors/contract.js";
9
7
  import { renderObserver } from "../observer/render.js";
10
- import { buildObserverData } from "../observer/data.js";
11
- import { inventorySandboxIds, scrubSandboxIdBytes } from "../run/sandbox-ids.js";
12
- import { containsSensitive, redactSandboxIds, redactScreenshot, redactText, } from "../evidence/redaction.js";
13
- import { decodeEscapes, readingsOf } from "../evidence/encoded-text.js";
14
- import { readPlainText } from "../evidence/plain-text.js";
15
- import { MAX_RUN_ENTRIES, streamScreenshotPaths } from "../verify/artifacts.js";
8
+ import { inventorySandboxIds, publicSandboxView, scrubSandboxIdBytes } from "../run/sandbox-ids.js";
9
+ import { splitStreamTraces } from "../run/stream-trace.js";
10
+ import { redactScreenshot, redactText } from "../evidence/redaction.js";
11
+ import { assertRewritableNumbers, derivedBundle, jsonBytes, transformText, } from "./derivative-text.js";
12
+ import { streamScreenshotPaths } from "../verify/artifacts.js";
13
+ import { runEntryLimit } from "../verify/scan-limit.js";
16
14
  import { verifyRunPrepared } from "../verify/verify.js";
17
- import { loadRunBundlePrepared, resolveRunPath } from "../run/locate.js";
18
- import {} from "../run/bundle.js";
15
+ import { loadRunBundlePrepared, readRunJsonIfExists, resolveRunPath, runJsonValue, } from "../run/locate.js";
16
+ import { RUN_BUNDLE_FILE } from "../run/bundle.js";
17
+ import { isRecord } from "../run/type-guards.js";
19
18
  import { runIdOf, isPathInside, prepareRunArtifactPaths, validatePreparedRunArtifactPaths, } from "../run/paths.js";
20
19
  import { isAnalysisRecordPath } from "../analysis/sharing.js";
21
20
  import { LOCAL_ONLY_RUN_FILES } from "../run/local-only-files.js";
@@ -43,9 +42,6 @@ const PNG_MAGIC = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
43
42
  function hash(bytes) {
44
43
  return createHash("sha256").update(bytes).digest("hex");
45
44
  }
46
- function jsonBytes(value) {
47
- return Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
48
- }
49
45
  function omittedReason(relative) {
50
46
  if (relative.startsWith("analysis-automatic/") && isAnalysisRecordPath(relative))
51
47
  return "automatic execution intent and liveness do not transfer to a derivative";
@@ -63,14 +59,17 @@ async function inventory(root, maxBytes) {
63
59
  const files = [];
64
60
  let total = 0;
65
61
  let entriesSeen = 0;
62
+ // The scan's limit for the participants run.json records, so a run verify reads in full exports.
63
+ const stored = runJsonValue(await readRunJsonIfExists(root, RUN_BUNDLE_FILE));
64
+ const entryLimit = runEntryLimit(isRecord(stored) && Array.isArray(stored.simulations) ? stored.simulations.length : 0);
66
65
  const walk = async (relative) => {
67
66
  const directory = path.join(root.physicalRunRoot, relative);
68
67
  const before = await lstat(directory, { bigint: true });
69
68
  if (!before.isDirectory() || before.isSymbolicLink())
70
69
  throw new Error("Source contains an unsafe directory.");
71
70
  for (const name of (await readdir(directory)).sort()) {
72
- if (++entriesSeen > MAX_RUN_ENTRIES)
73
- throw new Error(`Source exceeds ${MAX_RUN_ENTRIES} inventory entries.`);
71
+ if (++entriesSeen > entryLimit)
72
+ throw new Error(`Source exceeds ${entryLimit} inventory entries.`);
74
73
  if (name.includes("\\") || name.includes("\0"))
75
74
  throw new Error("Source contains an unsafe path segment.");
76
75
  const rel = relative ? `${relative}/${name}` : name;
@@ -147,65 +146,6 @@ async function prospectivePhysicalPath(requested) {
147
146
  return path.join(await prospectivePhysicalPath(parent), path.basename(requested));
148
147
  }
149
148
  }
150
- function assertNoInlineRaster(text) {
151
- // Decode the escapes verify decodes (decodeEscapes) before checking. Unknown embedded bytes are
152
- // refused: copying a text extension must not smuggle untransformed pixels.
153
- if (readingsOf(text).some(containsSensitive))
154
- throw new Error("Decoded text contains a secret-shaped value or private path; export refused.");
155
- const decoded = decodeEscapes(text);
156
- if (/data\s*:\s*(?:image\/|application\/|text\/|[^,\s]*;base64,|,)/i.test(decoded) ||
157
- /iVBORw0KGgo|\/9j\/[A-Za-z0-9+/]|R0lGOD|UklGR[A-Za-z0-9+/]{4}|<svg\b/i.test(decoded)) {
158
- throw new Error("Text contains an inline image/data payload; embedded raster redaction is unsupported.");
159
- }
160
- }
161
- function decodeText(file) {
162
- const decoded = readPlainText(file.bytes);
163
- if (!decoded.ok)
164
- throw new Error(decoded.reason === "invalid-utf8"
165
- ? "Source text contains invalid UTF-8 or binary bytes."
166
- : "Source text contains binary control bytes.");
167
- assertNoInlineRaster(decoded.text);
168
- return decoded.text;
169
- }
170
- function updateScreenshotDeclarations(value, imagePaths) {
171
- if (Array.isArray(value))
172
- return value.map((entry) => updateScreenshotDeclarations(entry, imagePaths));
173
- if (value === null || typeof value !== "object")
174
- return value;
175
- const object = value;
176
- const result = Object.create(null);
177
- for (const [key, entry] of Object.entries(object))
178
- result[key] = updateScreenshotDeclarations(entry, imagePaths);
179
- if (object.schema === ACTOR_TRACE_SCHEMA &&
180
- object.redaction !== null &&
181
- typeof object.redaction === "object") {
182
- const original = object.redaction;
183
- if (original.screenshots === "raw" || original.screenshots === "blurred") {
184
- result.redaction = {
185
- ...original,
186
- screenshots: "blurred",
187
- notes: `${typeof original.notes === "string" ? original.notes : ""} Exported copy: screenshots blurred during export; original capture posture: ${original.screenshots}. See derivation.json.`,
188
- };
189
- }
190
- if (Array.isArray(result.items)) {
191
- result.items = result.items.map((item) => {
192
- if (item === null || typeof item !== "object")
193
- return item;
194
- const entry = item;
195
- if (entry.screenshotRef === undefined)
196
- return item;
197
- if (entry.screenshotRef === null || typeof entry.screenshotRef !== "object")
198
- throw new Error("Actor screenshot reference is malformed.");
199
- const ref = entry.screenshotRef;
200
- if (typeof ref.path !== "string" || !imagePaths.has(ref.path)) {
201
- throw new Error("Actor screenshot reference must resolve to a PNG transformed from this run's inventory.");
202
- }
203
- return { ...entry, screenshotRef: { ...ref, redaction: "blurred" } };
204
- });
205
- }
206
- }
207
- return result;
208
- }
209
149
  function assertFeedbackReferences(bundle, source) {
210
150
  const retained = new Set(source.files.filter((file) => omittedReason(file.path) === undefined).map((file) => file.path));
211
151
  retained.add("observer/index.html");
@@ -220,51 +160,6 @@ function assertFeedbackReferences(bundle, source) {
220
160
  }
221
161
  }
222
162
  }
223
- function transformText(file, imagePaths) {
224
- const text = decodeText(file);
225
- const extension = path.extname(file.path).toLowerCase();
226
- if (extension === ".json") {
227
- const parsed = JSON.parse(text);
228
- // Parsing also resolves escape sequences in string values before inspection.
229
- assertNoInlineRaster(JSON.stringify(parsed));
230
- // The copy names no sandbox: an id is local operational evidence, and the copy is shared.
231
- const transformed = redactSandboxIds(updateScreenshotDeclarations(parsed, imagePaths));
232
- if (JSON.stringify(transformed) === JSON.stringify(parsed))
233
- return file.bytes;
234
- assertRewritableNumbers(text);
235
- return jsonBytes(transformed);
236
- }
237
- if (extension === ".ndjson" || extension === ".jsonl") {
238
- const lines = text.split("\n");
239
- const transformed = lines.map((line) => {
240
- if (line.trim().length === 0)
241
- return line;
242
- const parsed = JSON.parse(line);
243
- assertNoInlineRaster(JSON.stringify(parsed));
244
- const updated = JSON.stringify(redactSandboxIds(updateScreenshotDeclarations(parsed, imagePaths)));
245
- if (updated === JSON.stringify(parsed))
246
- return line;
247
- assertRewritableNumbers(line);
248
- return updated;
249
- });
250
- return Buffer.from(transformed.join("\n"));
251
- }
252
- if (extension === ".yaml" || extension === ".yml") {
253
- assertNoInlineRaster(JSON.stringify(parseYaml(text, { maxAliasCount: 100, logLevel: "silent" })) ?? "");
254
- }
255
- return file.bytes;
256
- }
257
- function assertRewritableNumbers(json) {
258
- // Opaque adapter JSON stays byte-identical. A known trace that actually needs
259
- // rewriting must not silently round an identifier outside JS's integer range.
260
- const unquoted = json.replace(/"(?:\\.|[^"\\])*"/g, '""');
261
- for (const match of unquoted.matchAll(/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g)) {
262
- const number = Number(match[0]);
263
- if (!Number.isFinite(number) || (Number.isInteger(number) && !Number.isSafeInteger(number))) {
264
- throw new Error("A trace requiring redaction contains an unsafe numeric value; represent large identifiers as strings before capture.");
265
- }
266
- }
267
- }
268
163
  /** PNG paths that run.json cites outside actor traces: feedback, adapter and stream artifacts. */
269
164
  function citedPngPaths(bundle) {
270
165
  const cited = [
@@ -304,6 +199,13 @@ async function writeDerivative(source, stagePaths, bundle) {
304
199
  const entries = [];
305
200
  const imagePaths = keptPngPaths(source, bundle);
306
201
  const sandboxIds = inventorySandboxIds(source.files);
202
+ // run.json and each trace file come from one rewritten bundle, so each trace's index binds the
203
+ // bytes written here. A v1 source is written as v2.
204
+ const derived = splitStreamTraces(publicSandboxView(derivedBundle(bundle, imagePaths), sandboxIds));
205
+ const derivedFiles = new Map([
206
+ ["run.json", jsonBytes(derived.bundle)],
207
+ ...derived.files.map((file) => [file.path, file.bytes]),
208
+ ]);
307
209
  let images = 0;
308
210
  for (const file of source.files) {
309
211
  const isPng = path.extname(file.path).toLowerCase() === ".png";
@@ -332,20 +234,11 @@ async function writeDerivative(source, stagePaths, bundle) {
332
234
  if (!TEXT_EXTENSIONS.has(extension))
333
235
  throw new Error(`Unsupported artifact format ${extension || "(no extension)"}; bundle export supports PNG and UTF-8 text only.`);
334
236
  bytes = transformText(file, imagePaths);
335
- if (file.path === "run.json") {
237
+ const rewritten = derivedFiles.get(file.path);
238
+ if (rewritten !== undefined) {
336
239
  assertRewritableNumbers(file.bytes.toString("utf8"));
337
- const transformed = redactSandboxIds(updateScreenshotDeclarations(bundle, imagePaths));
338
- transformed.redaction = {
339
- status: "passed",
340
- notes: `${bundle.redaction.notes} Exported copy: screenshots were blurred during export; the original capture is retained separately. See derivation.json.`,
341
- };
342
- for (const stream of transformed.streams) {
343
- for (const artifact of stream.artifacts) {
344
- if (artifact.kind === "screenshot")
345
- artifact.label = artifact.label.replace(/\((raw|blurred)\)/g, "(blurred at export)");
346
- }
347
- }
348
- bytes = jsonBytes(transformed);
240
+ bytes = rewritten;
241
+ derivedFiles.delete(file.path);
349
242
  }
350
243
  bytes = scrubSandboxIdBytes(file.path, bytes, sandboxIds);
351
244
  action = bytes.equals(file.bytes) ? "copied" : "updated";
@@ -353,6 +246,19 @@ async function writeDerivative(source, stagePaths, bundle) {
353
246
  await writeContainedOutputFile(stagePaths, file.path, bytes);
354
247
  entries.push({ path: file.path, sourceSha256: file.sha256, outputSha256: hash(bytes), action });
355
248
  }
249
+ // A v1 source stream with no actor trace file gets one; its content came from run.json.
250
+ const runJson = source.files.find((file) => file.path === "run.json");
251
+ for (const [relative, bytes] of derivedFiles) {
252
+ if (relative === "run.json" || runJson === undefined)
253
+ continue;
254
+ await writeContainedOutputFile(stagePaths, relative, bytes);
255
+ entries.push({
256
+ path: relative,
257
+ sourceSha256: runJson.sha256,
258
+ outputSha256: hash(bytes),
259
+ action: "updated",
260
+ });
261
+ }
356
262
  return { images, entries };
357
263
  }
358
264
  async function removeOwnedDirectory(root, strict = false) {
@@ -534,10 +440,7 @@ async function buildDerivative(stage, source, runId, bundle, maxBytes) {
534
440
  note: "A redacted copy of the same study, not a new attempt. Original pixels are not present. Text and findings still require human review before sharing.",
535
441
  files: transformed.entries,
536
442
  };
537
- const transformedBundle = await loadRunBundlePrepared(stage.physicalPath, stagePaths);
538
- if (!transformedBundle)
539
- throw new Error("Derivative bundle could not be loaded.");
540
- await writeContainedOutputFile(stagePaths, "observer/observer-data.json", jsonBytes(buildObserverData(transformedBundle.bundle)));
443
+ // Rendering writes observer-data.json and index.html from the derivative's run.json.
541
444
  const rendered = await renderObserver(stage.physicalPath, runId, { open: false });
542
445
  if (!rendered.ok)
543
446
  throw new Error("Derivative Observer could not be rebuilt.");
@@ -13,8 +13,9 @@ import { createHash } from "node:crypto";
13
13
  import path from "node:path";
14
14
  import { renderObserver } from "../observer/render.js";
15
15
  import { renderObserverHtml } from "../observer/artifact.js";
16
- import { buildObserverData } from "../observer/data.js";
16
+ import { buildObserverData, OBSERVER_DATA_SCHEMA, observerDataFromSaved, } from "../observer/data.js";
17
17
  import { resolveRunPath } from "../run/locate.js";
18
+ import { loadRunTraces, StreamTraceRefusedError } from "../run/stream-trace.js";
18
19
  import { verifyRun } from "../verify/verify.js";
19
20
  import {} from "../run/bundle.js";
20
21
  import { exportRedactedBundle } from "./export-bundle.js";
@@ -24,6 +25,7 @@ import { cli } from "../cli/invocation.js";
24
25
  import { analysisSharingProblems } from "../analysis/sharing.js";
25
26
  import { EVIDENCE_LIMITS, validateAnalysisEvidence } from "../analysis/evidence.js";
26
27
  import { readBoundedFile } from "../run/evidence-files.js";
28
+ import { isRecord } from "../run/type-guards.js";
27
29
  import { shellQuote } from "../substrates/shell.js";
28
30
  import { plural } from "../run/text.js";
29
31
  const EXPORT_SCHEMA = "humanish.export-result.v1";
@@ -79,7 +81,7 @@ export async function exportRun(cwdInput, runInput, options = {}, deps = {}) {
79
81
  const analysis = await loadAnalysis(runPaths);
80
82
  const data = await currentObserverData(runPaths, analysis, slot);
81
83
  if (data === undefined)
82
- return exportFailure(cwd, runInput, "HUMANISH_EXPORT_VERIFY_FAILED", "Source evidence changed while preparing the analysis export.");
84
+ return exportFailure(cwd, runInput, "HUMANISH_EXPORT_VERIFY_FAILED", "The run's evidence changed, or a participant's trace could not be read, while preparing the export.");
83
85
  const omittedRecording = omitRecordings(data.value);
84
86
  if (omittedRecording)
85
87
  warnings.push(MEDIA_OMISSION);
@@ -176,17 +178,42 @@ async function readObserverSlot(cwd, runInput, runRoot, runId, verified, deps, w
176
178
  /**
177
179
  * The Observer data to export. A current report must travel with the current source projection,
178
180
  * even if an older saved HTML was never refreshed; old recording-only exports keep the saved slot.
179
- * Undefined when run.json no longer matches the analysis.
181
+ * A page saved since observer-data v2 holds no participant's items in its slot, and verify does not
182
+ * read the page's compressed copy, so its export is the current projection of the run and its
183
+ * traces. Undefined when run.json no longer matches the analysis, or a v2 page's run cannot be read.
180
184
  */
181
185
  async function currentObserverData(runPaths, analysis, slot) {
182
186
  const saved = JSON.parse(slot);
183
- if (analysis.state !== "ready" || !analysis.analysis)
184
- return { value: saved };
185
- const source = await readBoundedFile(runPaths, "run.json", EVIDENCE_LIMITS.sourceBytes);
186
- if (!source ||
187
- createHash("sha256").update(source).digest("hex") !== analysis.analysis.sourceRunSha256)
187
+ const source = async () => readBoundedFile(runPaths, "run.json", EVIDENCE_LIMITS.sourceBytes);
188
+ if (analysis.state !== "ready" || !analysis.analysis) {
189
+ const schema = saved?.schema;
190
+ if (schema === "humanish.observer-data.v1")
191
+ return { value: observerDataFromSaved(saved) };
192
+ if (schema !== OBSERVER_DATA_SCHEMA)
193
+ return { value: saved };
194
+ const bundle = await sourceTraces(runPaths, await source());
195
+ return bundle === undefined ? undefined : { value: buildObserverData(bundle) };
196
+ }
197
+ const bytes = await source();
198
+ if (!bytes ||
199
+ createHash("sha256").update(bytes).digest("hex") !== analysis.analysis.sourceRunSha256)
200
+ return undefined;
201
+ const bundle = await sourceTraces(runPaths, bytes);
202
+ return bundle === undefined ? undefined : { value: buildObserverData(bundle) };
203
+ }
204
+ /** The run with its traces read, or undefined when run.json is missing or a trace is refused. */
205
+ async function sourceTraces(runPaths, bytes) {
206
+ if (!bytes)
188
207
  return undefined;
189
- return { value: buildObserverData(JSON.parse(source.toString("utf8"))) };
208
+ const stored = JSON.parse(bytes.toString("utf8"));
209
+ if (!isRecord(stored) || !Array.isArray(stored.streams))
210
+ return undefined;
211
+ // A trace that no longer matches run.json's index is a source that no longer matches.
212
+ return loadRunTraces(runPaths, stored).catch((error) => {
213
+ if (error instanceof StreamTraceRefusedError)
214
+ return undefined;
215
+ throw error;
216
+ });
190
217
  }
191
218
  /** Portable HTML keeps the lightweight evidence; media remains in the source run. */
192
219
  function omitRecordings(data) {
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { projectShareCheckedAnalysis, analysisSharingProblems } from "../analysis/sharing.js";
9
9
  import { withObserverEndings } from "./data.js";
10
+ import { escapeJsonScript, observerDataElements } from "./page-data.js";
10
11
  const OBSERVER_DATA_PLACEHOLDER = "__HUMANISH_OBSERVER_DATA__";
11
12
  const OBSERVER_DATA_SLOT = `<script id="observer-data" type="application/json">${OBSERVER_DATA_PLACEHOLDER}</script>`;
12
13
  let cachedObserverArtifact = null;
@@ -166,7 +167,7 @@ function renderObserverAppHtml(data, snapshot, analysis, notes, assets) {
166
167
  return (snapshot
167
168
  ? artifact.replace("</head>", '<meta name="humanish-observer-mode" content="snapshot"></head>')
168
169
  : artifact)
169
- .replace(OBSERVER_DATA_SLOT, () => `<script id="observer-data" type="application/json">${escapeJsonScript(data)}</script>`)
170
+ .replace(OBSERVER_DATA_SLOT, () => observerDataElements(data))
170
171
  .replace(/<script id="study-analysis" type="application\/json">[\s\S]*?<\/script>/, () => `<script id="study-analysis" type="application/json">${escapeJsonScript(analysis)}</script>`)
171
172
  .replace(/<script id="run-notes" type="application\/json">[\s\S]*?<\/script>/, () => `<script id="run-notes" type="application/json">${escapeJsonScript(notes)}</script>`)
172
173
  .replace("</body>", () => `${renderExportAssets(assets)}</body>`)
@@ -241,11 +242,3 @@ function escapeHtml(value) {
241
242
  }
242
243
  });
243
244
  }
244
- function escapeJsonScript(value) {
245
- return JSON.stringify(value)
246
- .replace(/</g, "\\u003c")
247
- .replace(/>/g, "\\u003e")
248
- .replace(/&/g, "\\u0026")
249
- .replace(/\u2028/g, "\\u2028")
250
- .replace(/\u2029/g, "\\u2029");
251
- }
@@ -2,7 +2,7 @@ import { type ActorEnding } from "../actors/stop-cause.js";
2
2
  import type { RunBundle, RunCostSummary, RunEvent, RunSimulation } from "../run/bundle.js";
3
3
  import { type RunDisplay } from "../run/display.js";
4
4
  import type { RunStream, RunStreamKind } from "../run/streams.js";
5
- export declare const OBSERVER_DATA_SCHEMA = "humanish.observer-data.v1";
5
+ export declare const OBSERVER_DATA_SCHEMA = "humanish.observer-data.v2";
6
6
  interface ObserverArtifactLink {
7
7
  label: string;
8
8
  href: string;
@@ -10,7 +10,7 @@ interface ObserverArtifactLink {
10
10
  }
11
11
  export interface ObserverData {
12
12
  schema: typeof OBSERVER_DATA_SCHEMA;
13
- schemaVersion: 1;
13
+ schemaVersion: 2;
14
14
  generatedAt: string;
15
15
  /** Served-only liveness observation. Never a replacement for the recorded evidence verdict. */
16
16
  runtime?: {
@@ -123,6 +123,13 @@ interface ObserverLaneGroup {
123
123
  * carried its outcome.
124
124
  */
125
125
  export declare function buildObserverData(bundle: RunBundle, generatedAt?: string, statusRecord?: unknown): ObserverData;
126
+ /**
127
+ * A saved Observer projection as this version reads it, unchecked as before: an observer-data.json
128
+ * written before v2 holds every participant's items. A v2 one holds none, so it is read only when
129
+ * no participant has any: showing participants with no steps would say they did nothing, and the
130
+ * items are in the run's traces. Null for a v2 projection that leaves items out.
131
+ */
132
+ export declare function observerDataFromSaved(value: unknown): ObserverData | null;
126
133
  /** Refresh presentation from recorded actor evidence, including older exported snapshots. */
127
134
  export declare function withObserverEndings(data: ObserverData): ObserverData;
128
135
  export {};
@@ -3,7 +3,8 @@ import { isCommsReceivingEvidence, receivingAnalysisContext } from "../comms/rec
3
3
  import { formatParticipantOutcomes, formatRunTaskFunnel, participantOutcomeDetails, withCuaReviewProvenance, } from "../run/outcomes.js";
4
4
  import { cuaGoalSource, CUA_COMPLETION_NOTE } from "../actors/goal-source.js";
5
5
  import { bundleDisplayFacts, runDisplay } from "../run/display.js";
6
- export const OBSERVER_DATA_SCHEMA = "humanish.observer-data.v1";
6
+ import { joinObserverData, shellNeedsItems } from "./participant-items.js";
7
+ export const OBSERVER_DATA_SCHEMA = "humanish.observer-data.v2";
7
8
  /** Discard a forged or stale runtime grant before projecting persisted evidence. */
8
9
  export function recordedStreamEmbed(embed) {
9
10
  if (!embed)
@@ -80,7 +81,7 @@ export function buildObserverData(bundle, generatedAt = new Date().toISOString()
80
81
  const active = streams.filter((stream) => stream.status === "running" || stream.status === "preparing").length;
81
82
  return withObserverEndings({
82
83
  schema: OBSERVER_DATA_SCHEMA,
83
- schemaVersion: 1,
84
+ schemaVersion: 2,
84
85
  generatedAt,
85
86
  run: {
86
87
  runId: bundle.runId,
@@ -141,6 +142,20 @@ export function buildObserverData(bundle, generatedAt = new Date().toISOString()
141
142
  },
142
143
  });
143
144
  }
145
+ /**
146
+ * A saved Observer projection as this version reads it, unchecked as before: an observer-data.json
147
+ * written before v2 holds every participant's items. A v2 one holds none, so it is read only when
148
+ * no participant has any: showing participants with no steps would say they did nothing, and the
149
+ * items are in the run's traces. Null for a v2 projection that leaves items out.
150
+ */
151
+ export function observerDataFromSaved(value) {
152
+ const saved = value;
153
+ if (saved.schema === "humanish.observer-data.v1")
154
+ return { ...saved, schema: OBSERVER_DATA_SCHEMA, schemaVersion: 2 };
155
+ return shellNeedsItems(saved)
156
+ ? null
157
+ : joinObserverData(saved, null);
158
+ }
144
159
  /** Refresh presentation from recorded actor evidence, including older exported snapshots. */
145
160
  export function withObserverEndings(data) {
146
161
  const streams = (data.streams ?? []).map(({ ending: _previousEnding, ...stream }) => {
@@ -0,0 +1,5 @@
1
+ import type { ObserverData } from "./data.js";
2
+ /** The page's data elements for `data`, in place of the built page's data slot. */
3
+ export declare function observerDataElements(data: ObserverData): string;
4
+ /** JSON that stays inert inside a script element: no `</script>` breakout, no HTML entities. */
5
+ export declare function escapeJsonScript(value: unknown): string;
@@ -0,0 +1,21 @@
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";
6
+ /** The page's data elements for `data`, in place of the built page's data slot. */
7
+ export function observerDataElements(data) {
8
+ const { shell, items } = splitObserverData(data);
9
+ const packed = gzipSync(JSON.stringify(items)).toString("base64");
10
+ 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>`);
12
+ }
13
+ /** JSON that stays inert inside a script element: no `</script>` breakout, no HTML entities. */
14
+ export function escapeJsonScript(value) {
15
+ return JSON.stringify(value)
16
+ .replace(/</g, "\\u003c")
17
+ .replace(/>/g, "\\u003e")
18
+ .replace(/&/g, "\\u0026")
19
+ .replace(/\u2028/g, "\\u2028")
20
+ .replace(/\u2029/g, "\\u2029");
21
+ }
@@ -0,0 +1,45 @@
1
+ import type { ActorTraceItem } from "../actors/contract.js";
2
+ import type { ObserverData, ObserverStream } from "./data.js";
3
+ export declare const OBSERVER_ITEMS_SCHEMA = "humanish.observer-items.v1";
4
+ /** A stream's actor and live actor, each without its items. */
5
+ type ObserverShellStream = Omit<ObserverStream, "actor" | "liveActor"> & {
6
+ actor?: Omit<NonNullable<ObserverStream["actor"]>, "items">;
7
+ liveActor?: Omit<NonNullable<ObserverStream["liveActor"]>, "items">;
8
+ /** How many screenshot entries of `artifacts` travel with the items, when any do. */
9
+ screenshotArtifacts?: number;
10
+ };
11
+ /** observer-data.json: the Observer's data with no participant's trace items. */
12
+ export type ObserverShell = Omit<ObserverData, "streams"> & {
13
+ streams: ObserverShellStream[];
14
+ };
15
+ /**
16
+ * Each participant's trace items by stream id, with the stream's artifact list when it names
17
+ * screenshots: one per turn, so it grows with the items.
18
+ */
19
+ export interface ObserverItems {
20
+ schema: typeof OBSERVER_ITEMS_SCHEMA;
21
+ runId: string;
22
+ streams: Record<string, {
23
+ actor?: ActorTraceItem[];
24
+ liveActor?: ActorTraceItem[];
25
+ artifacts?: ObserverStream["artifacts"];
26
+ }>;
27
+ }
28
+ /** The data as observer-data.json and the page's items carry it. */
29
+ export declare function splitObserverData(data: ObserverData): {
30
+ shell: ObserverShell;
31
+ items: ObserverItems;
32
+ };
33
+ /**
34
+ * Whether `items` are this run's and hold every participant's items that the data leaves out. The
35
+ * page reads nothing short of that: participants shown with no steps would say they did nothing.
36
+ */
37
+ export declare function itemsCoverShell(shell: ObserverShell, items: ObserverItems): boolean;
38
+ /** Whether the data leaves out items or screenshot entries that only an items document holds. */
39
+ export declare function shellNeedsItems(shell: ObserverShell): boolean;
40
+ /**
41
+ * The data with each participant's items back in place. A stream the items do not cover keeps an
42
+ * empty list, so the page shows the participant with no recorded steps.
43
+ */
44
+ export declare function joinObserverData(shell: ObserverShell, items: ObserverItems | null): ObserverData;
45
+ export {};
@@ -0,0 +1,85 @@
1
+ // The Observer's data in two parts: observer-data.json, the run and every participant without
2
+ // their trace items, and the items themselves. A participant working for hours records thousands
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";
7
+ /** The data as observer-data.json and the page's items carry it. */
8
+ export function splitObserverData(data) {
9
+ // No prototype, so a stream id such as `__proto__` is an entry like any other.
10
+ const items = Object.create(null);
11
+ const streams = data.streams.map((stream) => {
12
+ const { actor, liveActor, ...rest } = stream;
13
+ const entry = {};
14
+ const shell = rest;
15
+ // Saved projections are read unchecked, and one written by an older version may have no list.
16
+ if (Array.isArray(stream.artifacts) &&
17
+ stream.artifacts.some((artifact) => artifact.kind === "screenshot")) {
18
+ entry.artifacts = stream.artifacts;
19
+ shell.artifacts = stream.artifacts.filter((artifact) => artifact.kind !== "screenshot");
20
+ shell.screenshotArtifacts = stream.artifacts.length - shell.artifacts.length;
21
+ }
22
+ if (actor !== undefined) {
23
+ const { items: actorItems, ...summary } = actor;
24
+ entry.actor = actorItems;
25
+ shell.actor = summary;
26
+ }
27
+ if (liveActor !== undefined) {
28
+ const { items: liveItems, ...summary } = liveActor;
29
+ entry.liveActor = liveItems;
30
+ shell.liveActor = summary;
31
+ }
32
+ if (Object.keys(entry).length > 0)
33
+ items[stream.id] = entry;
34
+ return shell;
35
+ });
36
+ return {
37
+ shell: { ...data, streams },
38
+ items: { schema: OBSERVER_ITEMS_SCHEMA, runId: data.run.runId, streams: items },
39
+ };
40
+ }
41
+ /**
42
+ * Whether `items` are this run's and hold every participant's items that the data leaves out. The
43
+ * page reads nothing short of that: participants shown with no steps would say they did nothing.
44
+ */
45
+ export function itemsCoverShell(shell, items) {
46
+ if (items.runId !== shell.run.runId)
47
+ return false;
48
+ return shell.streams.every((stream) => {
49
+ const entry = Object.hasOwn(items.streams, stream.id) ? items.streams[stream.id] : undefined;
50
+ return ((stream.actor === undefined || Array.isArray(entry?.actor)) &&
51
+ (stream.liveActor === undefined || Array.isArray(entry?.liveActor)) &&
52
+ (stream.screenshotArtifacts === undefined ||
53
+ (Array.isArray(entry?.artifacts) &&
54
+ entry.artifacts.filter((artifact) => artifact.kind === "screenshot").length ===
55
+ stream.screenshotArtifacts)));
56
+ });
57
+ }
58
+ /** Whether the data leaves out items or screenshot entries that only an items document holds. */
59
+ export function shellNeedsItems(shell) {
60
+ return shell.streams.some((stream) => stream.actor !== undefined ||
61
+ stream.liveActor !== undefined ||
62
+ stream.screenshotArtifacts !== undefined);
63
+ }
64
+ /**
65
+ * The data with each participant's items back in place. A stream the items do not cover keeps an
66
+ * empty list, so the page shows the participant with no recorded steps.
67
+ */
68
+ export function joinObserverData(shell, items) {
69
+ const byStream = items !== null && items.runId === shell.run.runId ? items.streams : {};
70
+ return {
71
+ ...shell,
72
+ streams: shell.streams.map((stream) => {
73
+ const entry = Object.hasOwn(byStream, stream.id) ? byStream[stream.id] : undefined;
74
+ const { actor, liveActor, screenshotArtifacts: _moved, ...rest } = stream;
75
+ return {
76
+ ...rest,
77
+ artifacts: entry?.artifacts ?? stream.artifacts,
78
+ ...(actor === undefined ? {} : { actor: { ...actor, items: entry?.actor ?? [] } }),
79
+ ...(liveActor === undefined
80
+ ? {}
81
+ : { liveActor: { ...liveActor, items: entry?.liveActor ?? [] } }),
82
+ };
83
+ }),
84
+ };
85
+ }
@@ -17,6 +17,7 @@ import { RUN_STATUS_FILE } from "../run/status.js";
17
17
  import { verifyRunPrepared } from "../verify/verify.js";
18
18
  import { renderObserverHtml } from "./artifact.js";
19
19
  import { buildObserverData } from "./data.js";
20
+ import { splitObserverData } from "./participant-items.js";
20
21
  import { buildServeSecurityHeaders, hostAllowed, parsePublicOrigin } from "./http.js";
21
22
  import { listenOnLoopback } from "./listen.js";
22
23
  import { NOTES_PATH } from "../run/note-shape.js";
@@ -116,7 +117,7 @@ export async function renderObserver(cwdInput, runInput, options = {}) {
116
117
  verifiedAt: new Date().toISOString(),
117
118
  reasons: verified.shareSafety.reasons.map((reason) => reason.code),
118
119
  };
119
- await writeContainedOutputFile(preparedRunPaths, path.join("observer", "observer-data.json"), `${JSON.stringify(observerData, null, 2)}\n`, "utf8");
120
+ await writeContainedOutputFile(preparedRunPaths, path.join("observer", "observer-data.json"), `${JSON.stringify(splitObserverData(observerData).shell, null, 2)}\n`, "utf8");
120
121
  await writeContainedOutputFile(preparedRunPaths, path.join("observer", "study-analysis.json"), `${JSON.stringify(analysis, null, 2)}\n`, "utf8");
121
122
  await writeContainedOutputFile(preparedRunPaths, path.join("observer", "index.html"), renderObserverHtml(observerData, { analysis, notes: await readRunNotes(preparedRunPaths) }), "utf8");
122
123
  await validatePreparedRunArtifactPaths(preparedRunPaths);