@inflexa-ai/harness 0.19.2 → 0.21.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 (139) hide show
  1. package/dist/agents/conversation-agent.d.ts +26 -14
  2. package/dist/agents/conversation-agent.js +12 -23
  3. package/dist/agents/report-session-agent.d.ts +31 -5
  4. package/dist/agents/report-session-agent.js +31 -6
  5. package/dist/app/chat-turn.js +3 -1
  6. package/dist/app/message-assembly.d.ts +15 -1
  7. package/dist/app/message-assembly.js +22 -8
  8. package/dist/app/report-session-runtime.d.ts +21 -4
  9. package/dist/app/report-session-runtime.js +53 -6
  10. package/dist/app/spawn-report-session.d.ts +174 -17
  11. package/dist/app/spawn-report-session.js +233 -21
  12. package/dist/contracts/chat-events.d.ts +7 -2
  13. package/dist/contracts/message.d.ts +10 -0
  14. package/dist/contracts/report-blocks.d.ts +743 -19
  15. package/dist/contracts/report-blocks.js +186 -11
  16. package/dist/contracts/report-reference.d.ts +88 -0
  17. package/dist/contracts/report-reference.js +48 -0
  18. package/dist/index.d.ts +9 -0
  19. package/dist/index.js +20 -0
  20. package/dist/lib/chrome.d.ts +26 -6
  21. package/dist/lib/chrome.js +99 -32
  22. package/dist/lib/eyes.d.ts +57 -0
  23. package/dist/lib/eyes.js +45 -0
  24. package/dist/lib/page-capture.d.ts +14 -0
  25. package/dist/lib/page-capture.js +56 -2
  26. package/dist/literature/sources/pubmed.js +2 -1
  27. package/dist/loop/run-agent.js +58 -23
  28. package/dist/loop/tool-detail.d.ts +19 -2
  29. package/dist/loop/tool-detail.js +35 -2
  30. package/dist/memory/content-to-cortex.d.ts +3 -2
  31. package/dist/memory/content-to-cortex.js +21 -12
  32. package/dist/memory/conversation-display-replay.js +14 -7
  33. package/dist/memory/thread-history.d.ts +60 -3
  34. package/dist/memory/thread-history.js +55 -16
  35. package/dist/memory/thread-store.d.ts +11 -1
  36. package/dist/memory/thread-store.js +12 -2
  37. package/dist/prompts/conversation.d.ts +1 -1
  38. package/dist/prompts/conversation.js +23 -6
  39. package/dist/prompts/report-session.d.ts +1 -1
  40. package/dist/prompts/report-session.js +183 -9
  41. package/dist/report-model/authoring-grammar.d.ts +463 -0
  42. package/dist/report-model/authoring-grammar.js +77 -0
  43. package/dist/report-model/block-walk.d.ts +57 -14
  44. package/dist/report-model/block-walk.js +116 -11
  45. package/dist/report-model/draft-finish.d.ts +22 -4
  46. package/dist/report-model/draft-finish.js +39 -7
  47. package/dist/report-model/draft-operations.d.ts +3 -0
  48. package/dist/report-model/draft-operations.js +19 -5
  49. package/dist/report-model/draft-read.d.ts +8 -5
  50. package/dist/report-model/draft-read.js +21 -5
  51. package/dist/report-model/fixture-resolver.js +15 -4
  52. package/dist/report-model/pin-snapshot.d.ts +24 -6
  53. package/dist/report-model/pin-snapshot.js +204 -6
  54. package/dist/report-model/production-resolver.js +15 -4
  55. package/dist/report-model/reference-resolver.d.ts +50 -1
  56. package/dist/report-model/reference-resolver.js +94 -1
  57. package/dist/report-model/reference-stamp.d.ts +45 -0
  58. package/dist/report-model/reference-stamp.js +173 -0
  59. package/dist/report-model/structural-validation.d.ts +4 -1
  60. package/dist/report-model/structural-validation.js +48 -3
  61. package/dist/report-model/validate.d.ts +1 -1
  62. package/dist/report-model/validate.js +1 -1
  63. package/dist/report-render/asset-lookup.d.ts +19 -0
  64. package/dist/report-render/asset-lookup.js +37 -0
  65. package/dist/report-render/assets.d.ts +45 -1
  66. package/dist/report-render/assets.js +65 -6
  67. package/dist/report-render/chart-presets.d.ts +102 -0
  68. package/dist/report-render/chart-presets.js +205 -0
  69. package/dist/report-render/chart.d.ts +112 -10
  70. package/dist/report-render/chart.js +1164 -41
  71. package/dist/report-render/design.d.ts +99 -0
  72. package/dist/report-render/design.js +289 -107
  73. package/dist/report-render/number-format.d.ts +117 -0
  74. package/dist/report-render/number-format.js +479 -0
  75. package/dist/report-render/page.d.ts +121 -5
  76. package/dist/report-render/page.js +689 -7
  77. package/dist/report-render/references.d.ts +70 -16
  78. package/dist/report-render/references.js +54 -22
  79. package/dist/report-render/render.d.ts +23 -5
  80. package/dist/report-render/render.js +81 -22
  81. package/dist/report-render/table-data.d.ts +92 -0
  82. package/dist/report-render/table-data.js +129 -0
  83. package/dist/report-render/types.d.ts +15 -0
  84. package/dist/report-render/value-bridge.js +6 -2
  85. package/dist/report-render/views/chart-view.d.ts +12 -5
  86. package/dist/report-render/views/chart-view.js +14 -8
  87. package/dist/report-render/views/page-view.d.ts +36 -15
  88. package/dist/report-render/views/page-view.js +45 -23
  89. package/dist/report-render/views/prose.d.ts +12 -2
  90. package/dist/report-render/views/prose.js +29 -7
  91. package/dist/report-render/views/references-view.d.ts +22 -8
  92. package/dist/report-render/views/references-view.js +101 -13
  93. package/dist/report-render/views/values.d.ts +128 -13
  94. package/dist/report-render/views/values.js +199 -18
  95. package/dist/runtime/assemble.d.ts +39 -5
  96. package/dist/runtime/assemble.js +47 -6
  97. package/dist/sandbox/client.d.ts +10 -1
  98. package/dist/sandbox/create-sandbox.d.ts +9 -4
  99. package/dist/sandbox/create-sandbox.js +22 -16
  100. package/dist/sandbox/docker-client.js +10 -7
  101. package/dist/sandbox/k8s-client.js +11 -18
  102. package/dist/sandbox/mount-plan.d.ts +45 -7
  103. package/dist/sandbox/mount-plan.js +43 -5
  104. package/dist/sandbox/types.d.ts +10 -6
  105. package/dist/state/init.js +38 -9
  106. package/dist/state/report-session-state.d.ts +42 -1
  107. package/dist/state/report-session-state.js +70 -2
  108. package/dist/state/report-versions.d.ts +22 -17
  109. package/dist/state/report-versions.js +48 -35
  110. package/dist/state/snapshot-parse.js +16 -1
  111. package/dist/tasks/data-profile.d.ts +7 -4
  112. package/dist/tasks/data-profile.js +9 -9
  113. package/dist/tools/define-tool.d.ts +32 -0
  114. package/dist/tools/define-tool.js +4 -0
  115. package/dist/tools/detail-resolver.d.ts +7 -2
  116. package/dist/tools/detail-resolver.js +7 -2
  117. package/dist/tools/iterate-report.d.ts +1 -1
  118. package/dist/tools/report/preview-snapshot.js +1 -0
  119. package/dist/tools/report-authoring/authoring-tools.d.ts +15 -6
  120. package/dist/tools/report-authoring/authoring-tools.js +99 -18
  121. package/dist/tools/report-session/derive-table.d.ts +162 -0
  122. package/dist/tools/report-session/derive-table.js +459 -0
  123. package/dist/tools/report-session/examine-page.d.ts +48 -18
  124. package/dist/tools/report-session/examine-page.js +204 -24
  125. package/dist/tools/report-session/index.d.ts +2 -0
  126. package/dist/tools/report-session/index.js +2 -0
  127. package/dist/tools/report-session/list-artifacts.d.ts +97 -0
  128. package/dist/tools/report-session/list-artifacts.js +197 -0
  129. package/dist/tools/report-session/preview-report.d.ts +6 -0
  130. package/dist/tools/report-session/preview-report.js +180 -28
  131. package/dist/tools/report-session/record-version.d.ts +22 -10
  132. package/dist/tools/report-session/record-version.js +99 -14
  133. package/dist/tools/start-report-session.d.ts +115 -0
  134. package/dist/tools/start-report-session.js +179 -0
  135. package/dist/workflows/sandbox-step.d.ts +7 -4
  136. package/dist/workflows/sandbox-step.js +10 -12
  137. package/dist/workspace/paths.d.ts +50 -0
  138. package/dist/workspace/paths.js +63 -0
  139. package/package.json +2 -1
@@ -9,68 +9,238 @@
9
9
  * page lives at `report-sessions/{threadId}/index.html` under the workspace root, which the preview writes.
10
10
  * A missed page means that no preview ran, and it is a typed outcome, not a throw.
11
11
  *
12
- * The `file://` URL resolves on the filesystem of the Chrome sidecar, because the connection is out of
13
- * process. Thus the sidecar must mount the workspace tree of the harness host at the same path. A sidecar
12
+ * The composition says where a browser comes from, and the eyes seam is that answer. One look acquires one
13
+ * lease, and the tool captures against the endpoint of that lease.
14
+ *
15
+ * The `file://` URL resolves on the filesystem of the browser, because the connection is out of process.
16
+ * Thus the browser of a lease must hold the workspace tree of the harness host at the same path. A browser
14
17
  * with no such mount reports the page as an unreachable request, and the tool gives back that fault.
15
18
  *
16
- * A composition that names no browser and injects no capture seam has no eyes at all. The tool reports that
17
- * condition once, up front, and it stamps nothing. A per-attempt capture failure would instead read as a
18
- * transient fault and invite a repeat of a call that can never pass.
19
+ * The tool releases the lease after the look. The release runs on a pass and on a failed capture alike. That
20
+ * release is hygiene, and it is not the guarantee against a leak. A process can die between the acquire and
21
+ * the release, thus the realization bounds the life of what it provisions. As a result a failed release
22
+ * changes no outcome, and the log is its whole record.
23
+ *
24
+ * A composition with no capture seam, no eyes seam, and no configured endpoint has no eyes at all. The tool
25
+ * reports that condition one time, up front, and it stamps nothing. A per-attempt capture failure would
26
+ * instead read as a transient fault, and it would invite a repeat of a call that can never pass.
19
27
  *
20
28
  * On a capture the tool copies the rendered hash onto the seen hash through the gateway. Thus the look
21
29
  * counts, and the record tool lets the current draft record. The copy takes the rendered hash and never the
22
- * current one, thus a later edit makes the look stale and the record refuses.
30
+ * current one, thus a later edit makes the look stale and the record refuses. A picture of the window alone
31
+ * is a true picture of the current document, thus it stamps the same and the coverage of the result is what
32
+ * tells the agent what it saw.
23
33
  *
24
34
  * The gateway reports whether a rendered hash existed to copy. When the row holds none, no preview stamped
25
35
  * one and the look cannot count. The tool then gives a missed-stamp outcome that directs a new preview,
26
36
  * because a repeated look never stamps a marker that no preview wrote.
27
37
  *
28
- * The chrome navigation and the workspace-root seam each speak the throw protocol. The tool runs the capture
29
- * inside a guard, thus a genuine fault becomes a typed outcome and a control-flow exception propagates.
38
+ * The eyes seam, the chrome navigation, and the workspace-root seam each speak the throw protocol. The tool
39
+ * guards each of them, thus a fault of a look becomes a typed outcome and the loop never sees a throw.
40
+ *
41
+ * A hang is not a throw, and a guard alone never ends one. Thus the acquire and the release each carry a
42
+ * deadline. A realization that hangs while it starts a browser would otherwise hold the whole agent turn,
43
+ * and no outcome would ever arrive.
30
44
  */
31
- import { ok } from "neverthrow";
45
+ import { err, ok } from "neverthrow";
32
46
  import { access } from "node:fs/promises";
33
47
  import { join } from "node:path";
34
48
  import { pathToFileURL } from "node:url";
35
49
  import { z } from "zod";
36
50
  import { hasBrowserUrl } from "../../lib/chrome.js";
37
51
  import { createNoopLogger } from "../../lib/console-logger.js";
52
+ import { createStaticEyes } from "../../lib/eyes.js";
38
53
  import { defaultErrorFields } from "../../lib/logger.js";
39
54
  import { capturePage } from "../../lib/page-capture.js";
55
+ import { reportSessionDir } from "../../workspace/paths.js";
40
56
  import { defineTool, withToolResultImage } from "../define-tool.js";
41
57
  import { openReportThread } from "../report-authoring/authoring-tools.js";
42
58
  /** The empty input. The tool examines the current page of the thread, thus it needs no field. */
43
59
  const examinePageInput = z.object({});
60
+ /**
61
+ * Resolve the transport of the tool from its deps, one time.
62
+ *
63
+ * The precedence is `capture`, then `eyes`, then the chrome config as static eyes. An injected capture wins
64
+ * over the two others, because it replaces the whole transport and a test injects it.
65
+ *
66
+ * The chrome arm serves a direct construction alone, for example the tests of this file. The assembled
67
+ * runtime wraps the configured endpoint into the static realization at its composition root. Thus one wrap
68
+ * serves that runtime, and this arm never fires there.
69
+ */
70
+ function resolveEyes(deps) {
71
+ if (deps.capture !== undefined) {
72
+ return { kind: "capture", capture: deps.capture };
73
+ }
74
+ if (deps.eyes !== undefined) {
75
+ return { kind: "lease", acquire: deps.eyes };
76
+ }
77
+ if (hasBrowserUrl(deps.chrome)) {
78
+ return { kind: "lease", acquire: createStaticEyes(deps.chrome) };
79
+ }
80
+ return { kind: "none" };
81
+ }
82
+ /** The log line of each stage. The outcome of the tool is one arm, thus the stage rides in the log alone. */
83
+ const LOOK_FAULT_MESSAGE = {
84
+ acquire: "the eyes gave no browser for the look",
85
+ capture: "the page capture failed",
86
+ };
87
+ /**
88
+ * The deadline of one acquire, in milliseconds.
89
+ *
90
+ * A realization can start a container and boot a browser inside it, and that cold start costs seconds. The
91
+ * budget covers a slow cold start with a wide margin. It also ends a realization that hangs: one look sits
92
+ * inside one agent turn, and a hung acquire holds that whole turn with no outcome at all.
93
+ */
94
+ const ACQUIRE_DEADLINE_MS = 60_000;
95
+ /**
96
+ * The deadline of one release, in milliseconds.
97
+ *
98
+ * A release stops what one acquire started, and the look already holds its picture. Thus the budget is short.
99
+ * A release that runs past the budget costs nothing, because the realization bounds the life of what it
100
+ * provisions.
101
+ */
102
+ const RELEASE_DEADLINE_MS = 10_000;
103
+ /** The win of the timer arm. A symbol cannot collide with a value that the operation gives. */
104
+ const EXPIRED = Symbol("expired");
105
+ /**
106
+ * Run one operation against a deadline, and give the value, the cause, or the expiry.
107
+ *
108
+ * The eyes seam takes no abort signal, thus a race is the whole bound. The loser of the race stays in
109
+ * flight, and the caller of an expired operation owns whatever arrives late.
110
+ *
111
+ * Each path clears the timer. `sleep` of `async-utils.ts` holds a timer that nothing can clear. Thus an
112
+ * operation that wins its race would still keep the process alive for the rest of the budget.
113
+ */
114
+ async function withDeadline(work, ms) {
115
+ let timer;
116
+ // The settled arm never rejects. Thus a fault that arrives after the expiry stays contained here, and it
117
+ // never reaches the process as an unhandled rejection.
118
+ const settled = work.then((value) => ok(value), (cause) => err({ kind: "threw", cause }));
119
+ const expiry = new Promise((resolve) => {
120
+ timer = setTimeout(() => resolve(EXPIRED), ms);
121
+ });
122
+ const winner = await Promise.race([settled, expiry]);
123
+ clearTimeout(timer);
124
+ return winner === EXPIRED ? err({ kind: "expired" }) : winner;
125
+ }
126
+ /** The cause of one such fault. An expiry names its budget, thus the detail of the outcome names it too. */
127
+ function faultCause(fault, expiry, ms) {
128
+ return fault.kind === "threw" ? fault.cause : new Error(`${expiry} within ${ms} ms`);
129
+ }
130
+ /**
131
+ * Start one seam call, and give the promise of its outcome.
132
+ *
133
+ * A realization comes from the embedder, and one that is hand-written can throw before it gives its promise.
134
+ * The catch turns that throw into a rejection. Thus the deadline below reads one shape, and a look keeps the
135
+ * no-throw contract of the tool.
136
+ */
137
+ function startSeamCall(call) {
138
+ try {
139
+ return call();
140
+ }
141
+ catch (cause) {
142
+ return Promise.reject(cause);
143
+ }
144
+ }
145
+ /**
146
+ * Release a lease that arrived after the deadline of its acquire.
147
+ *
148
+ * The acquire stays in flight past that deadline, thus a browser can still arrive with no owner. The
149
+ * realization bounds the life of that browser, and this release ends it sooner. The look already gave its
150
+ * outcome, thus a fault here reaches the log alone.
151
+ */
152
+ function releaseLateLease(pending, logger) {
153
+ void pending
154
+ .then((lease) => lease.release())
155
+ .catch((cause) => {
156
+ logger.warn("the eyes lease did not release", logger.errorFields(cause));
157
+ });
158
+ }
159
+ /**
160
+ * Run one look over the resolved transport, and give the picture or the typed fault.
161
+ *
162
+ * The lease arm acquires one browser, captures against the endpoint of that lease, and releases in a
163
+ * `finally`. Thus a failed capture releases too. The shared capture takes a chrome config, thus the endpoint
164
+ * of the lease replaces the configured one and each other field of the composition stays.
165
+ *
166
+ * A failed release changes no outcome of the look. The capture already ran, and the realization bounds the
167
+ * life of what it provisions. Thus the log is the whole record of a failed release.
168
+ *
169
+ * The two seam calls each run against a deadline. An expired acquire reads as an acquire fault, thus a hung
170
+ * realization gives the same outcome as a refused one. An expired release reads as a failed release, thus it
171
+ * reaches the log and it changes no outcome.
172
+ */
173
+ async function runLook(args) {
174
+ const { transport } = args;
175
+ if (transport.kind === "capture") {
176
+ try {
177
+ return ok(await transport.capture(args.url));
178
+ }
179
+ catch (cause) {
180
+ return err({ stage: "capture", cause });
181
+ }
182
+ }
183
+ // The pending acquire stays apart from its deadline. An expiry leaves the acquire in flight, and a lease
184
+ // that arrives late still gets a release.
185
+ const pending = startSeamCall(() => transport.acquire(args.scope));
186
+ const acquired = await withDeadline(pending, args.acquireMs);
187
+ if (acquired.isErr()) {
188
+ if (acquired.error.kind === "expired")
189
+ releaseLateLease(pending, args.logger);
190
+ return err({ stage: "acquire", cause: faultCause(acquired.error, "the eyes gave no browser", args.acquireMs) });
191
+ }
192
+ const lease = acquired.value;
193
+ try {
194
+ return ok(await capturePage({ ...args.chrome, browserUrl: lease.browserUrl }, args.url));
195
+ }
196
+ catch (cause) {
197
+ return err({ stage: "capture", cause });
198
+ }
199
+ finally {
200
+ const released = await withDeadline(startSeamCall(() => lease.release()), args.releaseMs);
201
+ if (released.isErr()) {
202
+ const cause = faultCause(released.error, "the eyes lease did not release", args.releaseMs);
203
+ args.logger.warn("the eyes lease did not release", args.logger.errorFields(cause));
204
+ }
205
+ }
206
+ }
44
207
  /** The IANA media type of the screenshot. `page.screenshot` gives PNG bytes. */
45
208
  const SCREENSHOT_MEDIA_TYPE = "image/png";
46
209
  /** The line that the agent reads when the composition gives no browser. */
47
210
  const NO_BROWSER_DETAIL = "the composition gives no browser, thus this session cannot look at its page";
48
211
  /**
49
- * Make the eyes tool over the session-state gateway, the workspace-root seam, and the chrome config.
212
+ * Make the eyes tool over the session-state gateway, the workspace-root seam, and the eyes of the composition.
50
213
  *
51
214
  * The tool reads the thread id from the scope of the call, and it resolves the page path under the workspace
52
215
  * root. Thus one factory serves every thread. The tool holds no per-session value.
53
216
  */
54
217
  export function createExaminePageTool(deps) {
55
218
  const logger = (deps.logger ?? createNoopLogger()).named("examine-page");
56
- // An injected seam is the eyes of a test. A named browser is the eyes of a deployment. With neither, the
57
- // composition has no eyes, and that answer is fixed at construction.
58
- const eyesAvailable = deps.capture !== undefined || hasBrowserUrl(deps.chrome);
59
- const capture = deps.capture ?? ((url) => capturePage(deps.chrome, url));
219
+ // The eyes of the composition are fixed here, thus one look reads one resolved transport and never the
220
+ // precedence again.
221
+ const transport = resolveEyes(deps);
222
+ const acquireMs = deps.deadlines?.acquireMs ?? ACQUIRE_DEADLINE_MS;
223
+ const releaseMs = deps.deadlines?.releaseMs ?? RELEASE_DEADLINE_MS;
60
224
  return defineTool({
61
225
  id: "examine_page",
62
226
  description: "Open the rendered report page in a real headless browser, and report what you see. " +
63
- "Give back a screenshot, the console errors, and the failed requests. " +
227
+ "Give back a screenshot, the coverage of that screenshot, the console errors, and the failed requests. " +
228
+ "The coverage says whether the picture holds the whole page, or the top window alone. " +
64
229
  "Run it after the preview to look at the current page, and to confirm that the layout and the charts read clean. " +
65
230
  "The report tool records a version only after you look at the current page. " +
66
231
  "If the page has no confirmed render, run the preview again first.",
67
232
  inputSchema: examinePageInput,
68
233
  executionMode: "inline",
69
234
  describeCall: "none",
235
+ // A look gives a picture, and a picture has no one-line form. Thus the outcome of the look is the
236
+ // whole line: a watcher reads that the eyes saw the page, or that the page was absent, or that the
237
+ // capture failed.
238
+ describeResult: (_input, result) => result.outcome,
70
239
  execute: async (_input, ctx) => {
71
240
  // The check runs before every read, thus one clear signal replaces a capture failure for each
72
- // page. The arm stamps no seen hash, because no eyes saw the page.
73
- if (!eyesAvailable) {
241
+ // page. The arm stamps no seen hash, because no eyes saw the page. It also narrows the transport
242
+ // for the look below, thus the acquire needs no assertion.
243
+ if (transport.kind === "none") {
74
244
  logger.warn("the composition gives no browser, thus no look can run");
75
245
  return ok({ outcome: "no-browser", detail: NO_BROWSER_DETAIL });
76
246
  }
@@ -87,7 +257,7 @@ export function createExaminePageTool(deps) {
87
257
  logger.warn("the workspace root did not resolve", { threadId, analysisId, ...defaultErrorFields(cause) });
88
258
  return ok({ outcome: "capture-failed", detail: "the workspace root did not resolve" });
89
259
  }
90
- const relativePagePath = join("report-sessions", threadId, "index.html");
260
+ const relativePagePath = join(reportSessionDir(threadId), "index.html");
91
261
  const pagePath = join(root, relativePagePath);
92
262
  try {
93
263
  await access(pagePath);
@@ -100,14 +270,23 @@ export function createExaminePageTool(deps) {
100
270
  logger.warn("the session page could not be read", { threadId, analysisId, ...defaultErrorFields(cause) });
101
271
  return ok({ outcome: "capture-failed", detail: "the session page could not be read" });
102
272
  }
103
- let captured;
104
- try {
105
- captured = await capture(pathToFileURL(pagePath).href);
106
- }
107
- catch (cause) {
108
- logger.warn("the page capture failed", { threadId, analysisId, ...defaultErrorFields(cause) });
273
+ // The scope of the acquire carries the analysis and the root that this call resolved. Thus a
274
+ // realization that starts a browser mounts the same tree, and it holds no second resolver.
275
+ const looked = await runLook({
276
+ transport,
277
+ chrome: deps.chrome,
278
+ scope: { analysisId, workspaceRoot: root },
279
+ url: pathToFileURL(pagePath).href,
280
+ acquireMs,
281
+ releaseMs,
282
+ logger,
283
+ });
284
+ if (looked.isErr()) {
285
+ const { stage, cause } = looked.error;
286
+ logger.warn(LOOK_FAULT_MESSAGE[stage], { threadId, analysisId, ...defaultErrorFields(cause) });
109
287
  return ok({ outcome: "capture-failed", detail: cause instanceof Error ? cause.message : String(cause) });
110
288
  }
289
+ const captured = looked.value;
111
290
  const stamped = await deps.gateway.stampSeen(threadId);
112
291
  if (stamped.outcome === "no-rendered") {
113
292
  // The row holds no rendered hash, thus no preview stamped one and the look cannot count. A
@@ -127,6 +306,7 @@ export function createExaminePageTool(deps) {
127
306
  // JSON keeps the faults and the page path only, thus the JSON text holds no bytes.
128
307
  return ok(withToolResultImage({
129
308
  outcome: "examined",
309
+ coverage: captured.coverage,
130
310
  consoleErrors: captured.consoleErrors,
131
311
  failedRequests: captured.failedRequests,
132
312
  pagePath: relativePagePath,
@@ -8,3 +8,5 @@
8
8
  export { createPreviewReportTool, type PreviewReportInput, type PreviewReportResult, type PreviewReportToolDeps, type ResolvePageAsset, } from "./preview-report.js";
9
9
  export { createExaminePageTool, type CapturePage, type ExaminePageInput, type ExaminePageResult, type ExaminePageToolDeps, type FailedRequest, type PageCapture, } from "./examine-page.js";
10
10
  export { createRecordVersionTool, type RecordVersionInput, type RecordVersionResult, type RecordVersionToolDeps } from "./record-version.js";
11
+ export { createDeriveTableTool, type DeriveTableInput, type DeriveTableResult, type DeriveTableToolDeps } from "./derive-table.js";
12
+ export { createListPinnedArtifactsTool, type ListPinnedArtifactsInput, type ListPinnedArtifactsResult, type ListPinnedArtifactsToolDeps, type PinnedArtifact, type PinnedCitation, } from "./list-artifacts.js";
@@ -8,3 +8,5 @@
8
8
  export { createPreviewReportTool, } from "./preview-report.js";
9
9
  export { createExaminePageTool, } from "./examine-page.js";
10
10
  export { createRecordVersionTool } from "./record-version.js";
11
+ export { createDeriveTableTool } from "./derive-table.js";
12
+ export { createListPinnedArtifactsTool, } from "./list-artifacts.js";
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The pinned-artifact listing tool of a report session.
3
+ *
4
+ * The tool reads the frozen snapshot of the thread, and it gives the pinned set: the path, the content
5
+ * hash, the file type, and the header columns of a tabular artifact, plus the pinned citations with the
6
+ * short citation of each. Thus one call orients the agent, and a reference binds to a path or to a
7
+ * citation key of that set.
8
+ *
9
+ * The order is the code-unit order of the path. Two calls over one snapshot give one listing, thus the
10
+ * agent reads a stable set. A snapshot can pin many thousands of staged inputs, thus the listing stops at
11
+ * a cap and it carries the total count and a truncation marker.
12
+ *
13
+ * The columns come from a bounded header read of a listed entry. The path of a snapshot entry is
14
+ * untrusted, thus the read contains it with the one workspace-path resolver first. The extension gates
15
+ * the read, because a file type states a role and not a data format.
16
+ *
17
+ * An absent file, an unreadable file, and an unresolvable workspace root each give no columns and no
18
+ * error. Absence is a normal condition here: the listing is orientation, and the hash is the evidence.
19
+ */
20
+ import { z } from "zod";
21
+ import { type ResolveWorkspaceRoot } from "../../workspace/paths.js";
22
+ import { type Logger } from "../../lib/logger.js";
23
+ import { type Tool } from "../define-tool.js";
24
+ import { type ReportSessionStateGateway, type SessionRefusal } from "../report-authoring/authoring-tools.js";
25
+ /** The empty input. The tool lists the pinned set of the thread, thus it needs no field. */
26
+ declare const listPinnedArtifactsInput: z.ZodObject<{}, z.core.$strip>;
27
+ export type ListPinnedArtifactsInput = z.infer<typeof listPinnedArtifactsInput>;
28
+ /**
29
+ * One pinned artifact of the listing.
30
+ *
31
+ * `fileType` states a role, and it does not state a data format. `columns` is present only for a header
32
+ * that the read recovered, thus an absent `columns` says nothing about the content of the file.
33
+ */
34
+ export interface PinnedArtifact {
35
+ path: string;
36
+ hash: string;
37
+ fileType?: string;
38
+ columns?: string[];
39
+ }
40
+ /**
41
+ * One pinned citation of the listing: the key, and the short citation of its pinned record.
42
+ *
43
+ * `citation` names which paper the key is, thus the agent binds a citation block with no guess. A key that
44
+ * the pin recorded no citation for carries none, because absence is a normal condition. The description of
45
+ * the record stays out, because the listing is an orientation surface.
46
+ */
47
+ export interface PinnedCitation {
48
+ key: string;
49
+ citation?: string;
50
+ }
51
+ /**
52
+ * The typed outcome of the listing tool. Each arm is ok-channel data, thus the tool never throws for one
53
+ * of them.
54
+ *
55
+ * `total` counts the whole pinned set, and `truncated` says that the set holds more entries than
56
+ * `artifacts` names. Thus the agent reads a partial listing as a partial listing.
57
+ *
58
+ * `citations` gives the pinned citations in the code-unit order of the keys that the pin stored. A citation
59
+ * reference binds to one of those keys, thus the agent reads a key here and never out of a refusal.
60
+ */
61
+ export type ListPinnedArtifactsResult = {
62
+ outcome: "refused";
63
+ refusal: SessionRefusal;
64
+ } | {
65
+ outcome: "listed";
66
+ artifacts: PinnedArtifact[];
67
+ total: number;
68
+ truncated: boolean;
69
+ citations: PinnedCitation[];
70
+ };
71
+ /**
72
+ * The construction deps of the listing tool.
73
+ *
74
+ * `resolveWorkspaceRoot` maps the analysis of the call onto its workspace root, thus one singleton tool
75
+ * serves every analysis and it resolves the root per call from the scope.
76
+ */
77
+ export interface ListPinnedArtifactsToolDeps {
78
+ readonly gateway: ReportSessionStateGateway;
79
+ readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
80
+ readonly logger?: Logger;
81
+ }
82
+ /**
83
+ * The header columns of a file on disk, or `undefined` when the file gives none.
84
+ *
85
+ * `path` decides whether the file carries a header at all, and `absolute` is where the bytes sit. The
86
+ * derivation tool reads the columns of its own output the same way, thus the two surfaces describe a table
87
+ * with one rule.
88
+ */
89
+ export declare function readHeaderColumns(path: string, absolute: string): Promise<string[] | undefined>;
90
+ /**
91
+ * Make the pinned-artifact listing tool over the session-state gateway.
92
+ *
93
+ * The tool reads the thread id from the scope of the call, and it loads the snapshot through the gateway.
94
+ * Thus one factory serves every thread, and the tool holds no per-session value.
95
+ */
96
+ export declare function createListPinnedArtifactsTool(deps: ListPinnedArtifactsToolDeps): Tool<ListPinnedArtifactsInput, ListPinnedArtifactsResult>;
97
+ export {};
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The pinned-artifact listing tool of a report session.
3
+ *
4
+ * The tool reads the frozen snapshot of the thread, and it gives the pinned set: the path, the content
5
+ * hash, the file type, and the header columns of a tabular artifact, plus the pinned citations with the
6
+ * short citation of each. Thus one call orients the agent, and a reference binds to a path or to a
7
+ * citation key of that set.
8
+ *
9
+ * The order is the code-unit order of the path. Two calls over one snapshot give one listing, thus the
10
+ * agent reads a stable set. A snapshot can pin many thousands of staged inputs, thus the listing stops at
11
+ * a cap and it carries the total count and a truncation marker.
12
+ *
13
+ * The columns come from a bounded header read of a listed entry. The path of a snapshot entry is
14
+ * untrusted, thus the read contains it with the one workspace-path resolver first. The extension gates
15
+ * the read, because a file type states a role and not a data format.
16
+ *
17
+ * An absent file, an unreadable file, and an unresolvable workspace root each give no columns and no
18
+ * error. Absence is a normal condition here: the listing is orientation, and the hash is the evidence.
19
+ */
20
+ import { ok } from "neverthrow";
21
+ import { open } from "node:fs/promises";
22
+ import { extname } from "node:path";
23
+ import { z } from "zod";
24
+ import { resolveWorkspacePath } from "../../workspace/paths.js";
25
+ import { createNoopLogger } from "../../lib/console-logger.js";
26
+ import { tryFs } from "../../lib/fs-result.js";
27
+ import { defaultErrorFields } from "../../lib/logger.js";
28
+ import { citationRecordOf, fileTypeHoldsNoCell } from "../../report-model/reference-resolver.js";
29
+ import { defineTool } from "../define-tool.js";
30
+ import { openReportThread } from "../report-authoring/authoring-tools.js";
31
+ /** The empty input. The tool lists the pinned set of the thread, thus it needs no field. */
32
+ const listPinnedArtifactsInput = z.object({});
33
+ /**
34
+ * The cap of the header read. A header line of an analysis output sits far under it, and a file with no
35
+ * newline at all costs this many bytes and no more.
36
+ */
37
+ const HEADER_CAP_BYTES = 16 * 1024;
38
+ /**
39
+ * The cap of the listing. A snapshot pins each staged input of the analysis, and such a set can hold
40
+ * many thousands of entries. One entry costs one file read, thus the cap bounds both the reply and the
41
+ * work of the call.
42
+ */
43
+ const LISTING_CAP = 200;
44
+ /**
45
+ * The two extensions that carry a header line, each with its delimiter.
46
+ *
47
+ * The extension decides, because the bytes carry no declaration and a file type states a role alone. A
48
+ * minified JSON under `output/` splits into hundreds of names that address nothing, and a binary splits
49
+ * into unreadable text. Thus an entry with another extension reads no header.
50
+ */
51
+ const HEADER_DELIMITERS = new Map([
52
+ [".csv", ","],
53
+ [".tsv", "\t"],
54
+ ]);
55
+ /** The header delimiter of a path, or `undefined` when the extension carries no header. */
56
+ function delimiterOf(path) {
57
+ return HEADER_DELIMITERS.get(extname(path).toLowerCase());
58
+ }
59
+ /**
60
+ * Read the first line of a file under the cap, or give `undefined` when the bytes do not come back.
61
+ *
62
+ * The read takes a bounded byte window, thus a file of any size costs the cap. An absent file and a
63
+ * genuine read fault both give `undefined`, because the listing reports no error for a header.
64
+ */
65
+ async function readHeaderLine(absolute) {
66
+ const head = await tryFs("listPinnedArtifacts.readHeader", async () => {
67
+ const handle = await open(absolute, "r");
68
+ try {
69
+ const buffer = Buffer.alloc(HEADER_CAP_BYTES);
70
+ const { bytesRead } = await handle.read(buffer, 0, HEADER_CAP_BYTES, 0);
71
+ return { text: buffer.subarray(0, bytesRead).toString("utf8"), filled: bytesRead === HEADER_CAP_BYTES };
72
+ }
73
+ finally {
74
+ await handle.close();
75
+ }
76
+ }, { path: absolute, onAbsent: () => undefined }).unwrapOr(undefined);
77
+ if (head === undefined) {
78
+ return undefined;
79
+ }
80
+ const line = head.text.split(/\r?\n/, 1)[0];
81
+ if (line.length === 0) {
82
+ return undefined;
83
+ }
84
+ return { line, cut: head.filled && !head.text.includes("\n") };
85
+ }
86
+ /**
87
+ * Split a header line into column names on the delimiter, or give `undefined` when no whole name comes
88
+ * out of it.
89
+ *
90
+ * A double quote makes a field that can hold the delimiter itself, thus the naive split gives wrong
91
+ * names. A cut line ends inside its last name, and an agent writes a column name into a locator. Both
92
+ * conditions give no columns, because a wrong name is worse than an absent one.
93
+ */
94
+ function columnsOf(read, delimiter) {
95
+ if (read.line.includes('"')) {
96
+ return undefined;
97
+ }
98
+ const names = read.line.split(delimiter).map((name) => name.trim());
99
+ const whole = read.cut ? names.slice(0, -1) : names;
100
+ return whole.length > 0 ? whole : undefined;
101
+ }
102
+ /**
103
+ * The header columns of a file on disk, or `undefined` when the file gives none.
104
+ *
105
+ * `path` decides whether the file carries a header at all, and `absolute` is where the bytes sit. The
106
+ * derivation tool reads the columns of its own output the same way, thus the two surfaces describe a table
107
+ * with one rule.
108
+ */
109
+ export async function readHeaderColumns(path, absolute) {
110
+ const delimiter = delimiterOf(path);
111
+ if (delimiter === undefined) {
112
+ return undefined;
113
+ }
114
+ const read = await readHeaderLine(absolute);
115
+ return read === undefined ? undefined : columnsOf(read, delimiter);
116
+ }
117
+ /**
118
+ * Make the pinned-artifact listing tool over the session-state gateway.
119
+ *
120
+ * The tool reads the thread id from the scope of the call, and it loads the snapshot through the gateway.
121
+ * Thus one factory serves every thread, and the tool holds no per-session value.
122
+ */
123
+ export function createListPinnedArtifactsTool(deps) {
124
+ const logger = (deps.logger ?? createNoopLogger()).named("list-pinned-artifacts");
125
+ return defineTool({
126
+ id: "list_pinned_artifacts",
127
+ description: "List the pinned evidence of this session. Each entry gives the path of an artifact, its content hash, its file type, " +
128
+ "and the header columns of a .csv or a .tsv file. Another extension, a header line that holds a double quote, and a " +
129
+ "file whose bytes do not read each give no columns. " +
130
+ `The listing gives a maximum of ${LISTING_CAP} entries: "total" gives the size of the pinned set, and "truncated" says that the ` +
131
+ "set holds more artifacts than this listing names. " +
132
+ '"citations" gives the pinned literature of this session. Each entry carries "key", the citation id in the "idKind:id" form, and ' +
133
+ '"citation", the short citation of the paper. A key that the pin recorded no citation for carries no "citation". A citation block ' +
134
+ "binds to a key of this list, thus take a key from here and never from a refusal. " +
135
+ "The pin freezes at the start of the session, thus a reference binds to an artifact of this pinned set. " +
136
+ "Read it to orient before you bind a block, and to choose the column that a locator names. " +
137
+ "A reference names the path alone: the session stamps the hash from this evidence.",
138
+ inputSchema: listPinnedArtifactsInput,
139
+ executionMode: "inline",
140
+ describeCall: "none",
141
+ // The size of the listing is what a watcher reads, and the cap makes that size differ from the
142
+ // pinned set. Thus a truncated listing names the total beside the count, and a whole listing names
143
+ // the count alone, because a total that equals the count says the same thing two times.
144
+ describeResult: (_input, result) => {
145
+ if (result.outcome !== "listed") {
146
+ return result.outcome;
147
+ }
148
+ const count = result.artifacts.length;
149
+ const listed = `${count} ${count === 1 ? "artifact" : "artifacts"}`;
150
+ return result.truncated ? `${listed} of ${result.total}` : listed;
151
+ },
152
+ execute: async (_input, ctx) => {
153
+ const opened = await openReportThread(deps.gateway, ctx.session.scope);
154
+ if (opened.isErr()) {
155
+ return ok({ outcome: "refused", refusal: opened.error });
156
+ }
157
+ const { threadId, analysisId, state } = opened.value;
158
+ const paths = Object.keys(state.snapshot.artifacts).sort();
159
+ // The cap cuts the tail of the same order, thus a truncated listing is a prefix of the whole
160
+ // listing and a second call over one snapshot names the same entries.
161
+ const listed = paths.slice(0, LISTING_CAP);
162
+ // The seam signals an unresolvable resource by a throw. The listing still serves the path and
163
+ // the hash of each entry, thus the fault costs the columns alone and never the whole call.
164
+ let root;
165
+ try {
166
+ root = deps.resolveWorkspaceRoot(analysisId);
167
+ }
168
+ catch (cause) {
169
+ logger.warn("the workspace root did not resolve", { threadId, analysisId, ...defaultErrorFields(cause) });
170
+ }
171
+ const artifacts = [];
172
+ for (const path of listed) {
173
+ const entry = state.snapshot.artifacts[path];
174
+ const fileType = entry.fileType;
175
+ const artifact = { path, hash: entry.hash, ...(typeof fileType === "string" ? { fileType } : {}) };
176
+ if (root !== undefined && !fileTypeHoldsNoCell(fileType)) {
177
+ const resolved = resolveWorkspacePath({ workspaceRoot: root, analysisId, path });
178
+ if (resolved.kind === "ok") {
179
+ const columns = await readHeaderColumns(path, resolved.absolute);
180
+ if (columns !== undefined) {
181
+ artifact.columns = columns;
182
+ }
183
+ }
184
+ }
185
+ artifacts.push(artifact);
186
+ }
187
+ // The pin stores the citation keys sorted, thus the listing keeps that order. A snapshot that
188
+ // pinned no citation gives an empty list, and an empty list is a complete answer. A key with no
189
+ // pinned record lists bare, thus a stored pin that holds no record map still orients the agent.
190
+ const citations = (state.snapshot.citations ?? []).map((key) => {
191
+ const record = citationRecordOf(state.snapshot.citationRecords, key);
192
+ return record === undefined ? { key } : { key, citation: record.citation };
193
+ });
194
+ return ok({ outcome: "listed", artifacts, total: paths.length, truncated: paths.length > listed.length, citations });
195
+ },
196
+ });
197
+ }
@@ -6,6 +6,12 @@
6
6
  * resolves each reference through the injected resolver, bridges the resolved values into the render model,
7
7
  * and renders the page.
8
8
  *
9
+ * The renderer writes no file. It gives the page and the data asset of each table, and this tool stages
10
+ * them beside the figures. It also writes the script of each derivation that the document references, from
11
+ * the text of the durable record. The shipped libraries and fonts stage under `assets/deps/`, and each
12
+ * report-side file at the root of `assets/`. The stage is authoritative over that root: what this preview
13
+ * wrote stays, and each other file goes.
14
+ *
9
15
  * The page and its staged assets land in `report-sessions/{threadId}/` under the workspace root. That
10
16
  * namespace belongs to this path alone, thus the tool never writes under the old `previews/` or `reports/`
11
17
  * trees. The result carries the absolute page path, thus a local host shows the page with no seam.