humanish 0.123.0 → 0.125.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 +28 -0
  2. package/CONTEXT.md +2 -2
  3. package/README.md +2 -2
  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/evidence.js +2 -2
  9. package/dist/evidence/image.js +82 -3
  10. package/dist/feedback/export-bundle.js +1 -5
  11. package/dist/feedback/export.js +30 -11
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +1 -0
  14. package/dist/observer/artifact.js +2 -9
  15. package/dist/observer/data.d.ts +9 -2
  16. package/dist/observer/data.js +17 -2
  17. package/dist/observer/page-data.d.ts +5 -0
  18. package/dist/observer/page-data.js +21 -0
  19. package/dist/observer/participant-items.d.ts +45 -0
  20. package/dist/observer/participant-items.js +85 -0
  21. package/dist/observer/pinned-files.d.ts +3 -1
  22. package/dist/observer/pinned-files.js +10 -7
  23. package/dist/observer/render.js +2 -1
  24. package/dist/observer/run-routes.js +16 -5
  25. package/dist/observer-app.html +4 -4
  26. package/dist/routes/computer-use/live-flush.d.ts +9 -4
  27. package/dist/routes/computer-use/live-flush.js +16 -3
  28. package/dist/run/bundle-shape.js +2 -2
  29. package/dist/run/bundle.d.ts +10 -3
  30. package/dist/run/bundle.js +11 -2
  31. package/dist/run/contained-output.d.ts +22 -0
  32. package/dist/run/contained-output.js +97 -1
  33. package/dist/run/evidence-files.d.ts +12 -1
  34. package/dist/run/evidence-files.js +70 -40
  35. package/dist/run/live-items.d.ts +48 -0
  36. package/dist/run/live-items.js +242 -0
  37. package/dist/run/locate.d.ts +10 -0
  38. package/dist/run/locate.js +10 -0
  39. package/dist/run/run.d.ts +3 -2
  40. package/dist/run/run.js +31 -9
  41. package/dist/run/sandbox-ids.d.ts +0 -5
  42. package/dist/run/sandbox-ids.js +0 -7
  43. package/dist/run/stream-trace.d.ts +18 -9
  44. package/dist/run/stream-trace.js +113 -22
  45. package/dist/run/streams.d.ts +17 -2
  46. package/dist/study/parse/actors.js +23 -6
  47. package/dist/study/parse/execution.js +6 -0
  48. package/dist/study/plan-base.js +4 -1
  49. package/dist/study/plan.js +20 -0
  50. package/dist/verify/artifacts.js +39 -51
  51. package/dist/verify/screenshot-reads.d.ts +21 -0
  52. package/dist/verify/screenshot-reads.js +46 -0
  53. package/dist/verify/verify.js +7 -6
  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 +78 -14
  58. package/docs/contracts/schemas.md +38 -37
  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 +4 -2
@@ -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,10 +17,12 @@ export interface PinnedDirectory {
17
17
  /**
18
18
  * The file's bytes, or null when it cannot be read under the root's checks. With `maxBytes`, a
19
19
  * file larger than that is refused from its opened size and the read stops there, so a large file
20
- * is never held in memory.
20
+ * is never held in memory. With `length`, only the file's first `length` bytes are read, or all of
21
+ * a shorter file.
21
22
  */
22
23
  export declare function readContainedFile(root: PinnedDirectory, filePathInput: string, options?: {
23
24
  maxBytes?: number;
25
+ length?: number;
24
26
  }): Promise<Buffer | null>;
25
27
  export declare function openContainedFile(root: PinnedDirectory, filePathInput: string): Promise<{
26
28
  handle: FileHandle;
@@ -5,23 +5,26 @@ import { createHash } from "node:crypto";
5
5
  import { constants as fsConstants } from "node:fs";
6
6
  import { lstat, open, realpath } from "node:fs/promises";
7
7
  import path from "node:path";
8
- import { readOpenedAtMost } from "../run/contained-output.js";
8
+ import { readOpenedAtMost, readOpenedPrefix } from "../run/contained-output.js";
9
9
  import { isPathInside, isSafeRunIdSegment } from "../run/paths.js";
10
10
  /**
11
11
  * The file's bytes, or null when it cannot be read under the root's checks. With `maxBytes`, a
12
12
  * file larger than that is refused from its opened size and the read stops there, so a large file
13
- * is never held in memory.
13
+ * is never held in memory. With `length`, only the file's first `length` bytes are read, or all of
14
+ * a shorter file.
14
15
  */
15
16
  export async function readContainedFile(root, filePathInput, options = {}) {
16
17
  const opened = await openContainedFile(root, filePathInput);
17
18
  if (!opened)
18
19
  return null;
19
20
  try {
20
- const body = options.maxBytes === undefined
21
- ? await opened.handle.readFile()
22
- : opened.size > options.maxBytes
23
- ? null
24
- : await readOpenedAtMost(opened.handle, options.maxBytes);
21
+ const body = options.length !== undefined
22
+ ? await readOpenedPrefix(opened.handle, options.length)
23
+ : options.maxBytes === undefined
24
+ ? await opened.handle.readFile()
25
+ : opened.size > options.maxBytes
26
+ ? null
27
+ : await readOpenedAtMost(opened.handle, options.maxBytes);
25
28
  if (body === null)
26
29
  return null;
27
30
  // A write that landed during the read changed the file's mtime and ctime.
@@ -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);
@@ -16,7 +16,8 @@ import { MAX_NOTE_FILE_BYTES, readRunNotes, RUN_NOTES_DIR, } from "../run/note-f
16
16
  import { analysisCostOf, runCost, runCostLabel } from "../run/run-cost.js";
17
17
  import { RUN_STATUS_FILE, RUN_STATUS_STALE_MS, isRunStatusRecord, } from "../run/status.js";
18
18
  import { renderObserverHtml } from "./artifact.js";
19
- import { buildObserverData, recordedStreamEmbed, withObserverEndings, } from "./data.js";
19
+ import { splitObserverData } from "./participant-items.js";
20
+ import { buildObserverData, observerDataFromSaved, recordedStreamEmbed, withObserverEndings, } from "./data.js";
20
21
  import { buildArtifactSecurityHeaders } from "./http.js";
21
22
  import { assertPinnedDirectory, openContainedFile, pinDirectChildDirectory, readContainedFile, relativeToRoot, sha256OfOpenedFile, } from "./pinned-files.js";
22
23
  /** The run's artifact paths, only when they are still the pinned directory being served. */
@@ -116,13 +117,19 @@ export async function serveRunPath(runRoot, relativePath, response, runtimeStrea
116
117
  }), "text/html; charset=utf-8");
117
118
  return;
118
119
  }
119
- if (cleanedRelativePath === "observer/observer-data.json") {
120
+ // observer-data.json is the run without its participants' items, as the run saves it. The page
121
+ // polls observer-feed.json, the same data with the items, so one projection serves each poll.
122
+ if (cleanedRelativePath === "observer/observer-data.json" ||
123
+ cleanedRelativePath === "observer/observer-feed.json") {
120
124
  const observerData = await observerDataOrReason(runRoot, runtimeStreamUrls);
121
125
  if ("unavailable" in observerData) {
122
126
  writeResponse(response, 404, `Observer data not found: ${observerData.unavailable}`, "text/plain; charset=utf-8");
123
127
  return;
124
128
  }
125
- writeResponse(response, 200, JSON.stringify(observerData, null, 2), "application/json; charset=utf-8");
129
+ const { shell, items } = splitObserverData(observerData);
130
+ writeResponse(response, 200, cleanedRelativePath === "observer/observer-data.json"
131
+ ? JSON.stringify(shell, null, 2)
132
+ : JSON.stringify({ data: shell, items }), "application/json; charset=utf-8");
126
133
  return;
127
134
  }
128
135
  if (cleanedRelativePath === "observer/study-analysis.json") {
@@ -158,9 +165,10 @@ export async function serveRunPath(runRoot, relativePath, response, runtimeStrea
158
165
  }
159
166
  /** The run's trace files, read through its pinned folder. */
160
167
  function pinnedTraceReader(runRoot) {
161
- return async (relativePath, maxBytes) => {
168
+ return async (relativePath, maxBytes, length) => {
162
169
  const bytes = await readContainedFile(runRoot, path.join(runRoot.physicalPath, relativePath), {
163
170
  maxBytes,
171
+ ...(length === undefined ? {} : { length }),
164
172
  }).catch(() => null);
165
173
  return bytes === null ? { refused: `${relativePath} could not be read` } : { bytes };
166
174
  };
@@ -214,7 +222,10 @@ async function savedObserverData(runRoot, record, runtimeStreamUrls) {
214
222
  const observerBytes = await readContainedFile(runRoot, path.join(runRoot.physicalPath, "observer", "observer-data.json"));
215
223
  if (!observerBytes)
216
224
  throw new Error("observer-data.json unavailable");
217
- return withRuntimeStreamUrls(withLocalRunStatus(record, withObserverEndings(JSON.parse(observerBytes.toString("utf8")))), runtimeStreamUrls);
225
+ const saved = observerDataFromSaved(JSON.parse(observerBytes.toString("utf8")));
226
+ if (saved === null)
227
+ throw new Error("observer-data.json holds no participant's items");
228
+ return withRuntimeStreamUrls(withLocalRunStatus(record, withObserverEndings(saved)), runtimeStreamUrls);
218
229
  }
219
230
  catch {
220
231
  return { unavailable: "The run has written no run.json or Observer data yet." };