@inflexa-ai/harness 0.20.0 → 0.21.1

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 (133) 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 +35 -9
  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/loop/run-agent.js +58 -23
  27. package/dist/loop/tool-detail.d.ts +19 -2
  28. package/dist/loop/tool-detail.js +35 -2
  29. package/dist/memory/content-to-cortex.d.ts +3 -2
  30. package/dist/memory/content-to-cortex.js +21 -12
  31. package/dist/memory/conversation-display-replay.js +14 -7
  32. package/dist/memory/thread-history.d.ts +60 -3
  33. package/dist/memory/thread-history.js +55 -16
  34. package/dist/memory/thread-store.d.ts +11 -1
  35. package/dist/memory/thread-store.js +12 -2
  36. package/dist/prompts/conversation.d.ts +1 -1
  37. package/dist/prompts/conversation.js +23 -6
  38. package/dist/prompts/report-session.d.ts +1 -1
  39. package/dist/prompts/report-session.js +190 -9
  40. package/dist/report-model/authoring-grammar.d.ts +463 -0
  41. package/dist/report-model/authoring-grammar.js +77 -0
  42. package/dist/report-model/block-walk.d.ts +57 -14
  43. package/dist/report-model/block-walk.js +116 -11
  44. package/dist/report-model/draft-finish.d.ts +22 -4
  45. package/dist/report-model/draft-finish.js +39 -7
  46. package/dist/report-model/draft-operations.d.ts +3 -0
  47. package/dist/report-model/draft-operations.js +19 -5
  48. package/dist/report-model/draft-read.d.ts +8 -5
  49. package/dist/report-model/draft-read.js +21 -5
  50. package/dist/report-model/fixture-resolver.js +15 -4
  51. package/dist/report-model/pin-snapshot.d.ts +24 -6
  52. package/dist/report-model/pin-snapshot.js +204 -6
  53. package/dist/report-model/production-resolver.js +15 -4
  54. package/dist/report-model/reference-resolver.d.ts +50 -1
  55. package/dist/report-model/reference-resolver.js +94 -1
  56. package/dist/report-model/reference-stamp.d.ts +45 -0
  57. package/dist/report-model/reference-stamp.js +173 -0
  58. package/dist/report-model/structural-validation.d.ts +4 -1
  59. package/dist/report-model/structural-validation.js +48 -3
  60. package/dist/report-model/validate.d.ts +1 -1
  61. package/dist/report-model/validate.js +1 -1
  62. package/dist/report-render/asset-lookup.d.ts +19 -0
  63. package/dist/report-render/asset-lookup.js +37 -0
  64. package/dist/report-render/assets.d.ts +45 -1
  65. package/dist/report-render/assets.js +65 -6
  66. package/dist/report-render/chart-presets.d.ts +102 -0
  67. package/dist/report-render/chart-presets.js +205 -0
  68. package/dist/report-render/chart.d.ts +112 -10
  69. package/dist/report-render/chart.js +1164 -41
  70. package/dist/report-render/design.d.ts +99 -0
  71. package/dist/report-render/design.js +289 -107
  72. package/dist/report-render/number-format.d.ts +117 -0
  73. package/dist/report-render/number-format.js +479 -0
  74. package/dist/report-render/page.d.ts +121 -5
  75. package/dist/report-render/page.js +689 -7
  76. package/dist/report-render/references.d.ts +70 -16
  77. package/dist/report-render/references.js +54 -22
  78. package/dist/report-render/render.d.ts +23 -5
  79. package/dist/report-render/render.js +81 -22
  80. package/dist/report-render/table-data.d.ts +92 -0
  81. package/dist/report-render/table-data.js +129 -0
  82. package/dist/report-render/types.d.ts +15 -0
  83. package/dist/report-render/value-bridge.js +6 -2
  84. package/dist/report-render/views/chart-view.d.ts +12 -5
  85. package/dist/report-render/views/chart-view.js +14 -8
  86. package/dist/report-render/views/page-view.d.ts +36 -15
  87. package/dist/report-render/views/page-view.js +45 -23
  88. package/dist/report-render/views/prose.d.ts +12 -2
  89. package/dist/report-render/views/prose.js +29 -7
  90. package/dist/report-render/views/references-view.d.ts +22 -8
  91. package/dist/report-render/views/references-view.js +101 -13
  92. package/dist/report-render/views/values.d.ts +128 -13
  93. package/dist/report-render/views/values.js +199 -18
  94. package/dist/runtime/assemble.d.ts +39 -5
  95. package/dist/runtime/assemble.js +47 -6
  96. package/dist/sandbox/client.d.ts +10 -1
  97. package/dist/sandbox/create-sandbox.d.ts +9 -4
  98. package/dist/sandbox/create-sandbox.js +22 -16
  99. package/dist/sandbox/docker-client.js +10 -7
  100. package/dist/sandbox/mount-plan.d.ts +45 -7
  101. package/dist/sandbox/mount-plan.js +43 -5
  102. package/dist/sandbox/types.d.ts +6 -0
  103. package/dist/state/init.js +38 -9
  104. package/dist/state/report-session-state.d.ts +42 -1
  105. package/dist/state/report-session-state.js +70 -2
  106. package/dist/state/report-versions.d.ts +22 -17
  107. package/dist/state/report-versions.js +48 -35
  108. package/dist/state/snapshot-parse.js +16 -1
  109. package/dist/tools/define-tool.d.ts +32 -0
  110. package/dist/tools/define-tool.js +4 -0
  111. package/dist/tools/detail-resolver.d.ts +7 -2
  112. package/dist/tools/detail-resolver.js +7 -2
  113. package/dist/tools/iterate-report.d.ts +1 -1
  114. package/dist/tools/report/preview-snapshot.js +1 -0
  115. package/dist/tools/report-authoring/authoring-tools.d.ts +15 -6
  116. package/dist/tools/report-authoring/authoring-tools.js +99 -18
  117. package/dist/tools/report-session/derive-table.d.ts +162 -0
  118. package/dist/tools/report-session/derive-table.js +459 -0
  119. package/dist/tools/report-session/examine-page.d.ts +48 -18
  120. package/dist/tools/report-session/examine-page.js +204 -24
  121. package/dist/tools/report-session/index.d.ts +2 -0
  122. package/dist/tools/report-session/index.js +2 -0
  123. package/dist/tools/report-session/list-artifacts.d.ts +97 -0
  124. package/dist/tools/report-session/list-artifacts.js +197 -0
  125. package/dist/tools/report-session/preview-report.d.ts +6 -0
  126. package/dist/tools/report-session/preview-report.js +180 -28
  127. package/dist/tools/report-session/record-version.d.ts +22 -10
  128. package/dist/tools/report-session/record-version.js +99 -14
  129. package/dist/tools/start-report-session.d.ts +115 -0
  130. package/dist/tools/start-report-session.js +179 -0
  131. package/dist/workspace/paths.d.ts +50 -0
  132. package/dist/workspace/paths.js +63 -0
  133. package/package.json +2 -1
@@ -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.
@@ -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.
@@ -25,19 +31,21 @@
25
31
  * control-flow exception propagate.
26
32
  */
27
33
  import { err, ok } from "neverthrow";
28
- import { copyFile, mkdir, writeFile } from "node:fs/promises";
29
- import { createRequire } from "node:module";
34
+ import { copyFile, mkdir, readdir, rm, writeFile } from "node:fs/promises";
30
35
  import { extname, join } from "node:path";
36
+ import { relative as relativePosix } from "node:path/posix";
31
37
  import { z } from "zod";
32
- import { resolveWorkspacePath } from "../../workspace/paths.js";
38
+ import { reportSessionDir, resolveWorkspacePath } from "../../workspace/paths.js";
33
39
  import { createNoopLogger } from "../../lib/console-logger.js";
34
40
  import { describeFsError, tryFsWrite } from "../../lib/fs-result.js";
35
41
  import { defaultErrorFields } from "../../lib/logger.js";
42
+ import { referencedPaths, walkBlocks } from "../../report-model/block-walk.js";
36
43
  import { computeDraftHash } from "../../report-model/draft-hash.js";
37
44
  import { finishDraft } from "../../report-model/draft-finish.js";
38
45
  import { resolveDocumentReferences } from "../../report-model/validate.js";
39
46
  import { bridgeValues } from "../../report-render/value-bridge.js";
40
- import { PAGE_ASSETS } from "../../report-render/assets.js";
47
+ import { resolvePageAssetFromInstallation } from "../../report-render/asset-lookup.js";
48
+ import { ASSETS_DIR, DEPS_DIR, derivationScriptName, PAGE_ASSETS, stagedSource, tableSidecarName } from "../../report-render/assets.js";
41
49
  import { renderReportPage } from "../../report-render/render.js";
42
50
  import { defineTool } from "../define-tool.js";
43
51
  import { openReportThread } from "../report-authoring/authoring-tools.js";
@@ -58,13 +66,6 @@ function assetFileName(file) {
58
66
  function figureSourcePolicy(file) {
59
67
  return `assets/${assetFileName(file)}`;
60
68
  }
61
- /**
62
- * The resolver of a package source. A manifest entry names a module specifier, and the resolution reads
63
- * the installation of the harness. Thus the staged bytes are the bytes of the pinned version.
64
- */
65
- const moduleResolver = createRequire(import.meta.url);
66
- /** The default asset lookup: the module resolution of the installation of the harness. */
67
- const resolvePageAssetFromInstallation = (specifier) => moduleResolver.resolve(specifier);
68
69
  /**
69
70
  * Pair each block with the resolved value of its binding, in document order.
70
71
  *
@@ -110,6 +111,77 @@ function collectResolutions(blocks, resolvedByBlock) {
110
111
  }
111
112
  return resolutions;
112
113
  }
114
+ /**
115
+ * The sidecar of each table block of the document, in document order.
116
+ *
117
+ * A table card offers the whole table as a download, and the download is the pinned file itself and never a
118
+ * re-serialization of it. The card spells the staged name through `tableSidecarName`, thus the stage reads
119
+ * that same function and the link and the file cannot disagree.
120
+ *
121
+ * A chart binds a table too, and it holds no card. Thus the walk reads the table blocks alone.
122
+ */
123
+ function collectTableSidecars(blocks) {
124
+ const sidecars = [];
125
+ const visit = (block) => {
126
+ if (block.kind === "section") {
127
+ for (const child of block.blocks) {
128
+ visit(child);
129
+ }
130
+ return;
131
+ }
132
+ if (block.kind === "table") {
133
+ sidecars.push({ blockId: block.id, name: tableSidecarName(block.binding.hash, block.binding.path), path: block.binding.path });
134
+ }
135
+ };
136
+ for (const block of blocks) {
137
+ visit(block);
138
+ }
139
+ return sidecars;
140
+ }
141
+ /**
142
+ * The derivation records that the document references, in the order that they landed.
143
+ *
144
+ * A binding names an output path, thus the used set is the intersection of the recorded paths and the paths
145
+ * of the document. The record of a dropped block stays in the durable state, and this walk leaves it out.
146
+ * Thus the page states the chain of what it shows, and the stage writes the script of what it states.
147
+ */
148
+ function referencedDerivations(document, derivations) {
149
+ if (derivations.length === 0) {
150
+ return [];
151
+ }
152
+ const named = referencedPaths(walkBlocks(document.sections).references);
153
+ return derivations.filter((record) => named.has(record.outputPath));
154
+ }
155
+ /**
156
+ * The chain of one record, with the two relative links that its appendix entry carries.
157
+ *
158
+ * The script link names the staged asset through the shared name function, thus the link and the file that
159
+ * the stage writes cannot disagree. The output link is the path of the derived file against the directory of
160
+ * the page. The derived table already sits inside the session directory, thus the link needs no copy.
161
+ */
162
+ function chainOf(record, sessionDir) {
163
+ return {
164
+ outputPath: record.outputPath,
165
+ sources: record.sources,
166
+ scriptHash: record.scriptHash,
167
+ scriptSource: stagedSource(derivationScriptName(record.scriptHash)),
168
+ outputSource: relativePosix(sessionDir, record.outputPath),
169
+ };
170
+ }
171
+ /**
172
+ * The script of each referenced derivation, keyed by its staged name.
173
+ *
174
+ * Two records of one script text carry one hash, thus they take one name and the map holds one entry. The
175
+ * name is content-addressed, thus an amended script stages beside no stale copy of itself and the sweep
176
+ * removes the name that the new page does not reference.
177
+ */
178
+ function scriptAssets(records) {
179
+ const scripts = new Map();
180
+ for (const record of records) {
181
+ scripts.set(derivationScriptName(record.scriptHash), record.script);
182
+ }
183
+ return scripts;
184
+ }
113
185
  /**
114
186
  * Resolve each reference of the document, and split the resolutions from the unresolved references.
115
187
  *
@@ -136,9 +208,14 @@ async function resolveDocument(document, resolver, snapshot) {
136
208
  * thus the containment test runs before any copy and reuses the one workspace-path resolver. A source
137
209
  * outside the root refuses and names the block, and no copy runs.
138
210
  *
139
- * The page references the chart runtime and the fonts under the same `assets/` directory, thus one copy
140
- * loop stages the figures and the manifest entries together. The manifest is never empty, thus the assets
141
- * directory exists beside every page.
211
+ * The page references the chart runtime and the fonts under `assets/deps/`, and each report-side file at
212
+ * the root of `assets/`. Each manifest entry names its own subpath, thus the copy loop reads the manifest
213
+ * and it spells no layout of its own. The manifest is never empty, thus both directories exist beside every
214
+ * page.
215
+ *
216
+ * The data assets and the derivation scripts are the parts that this preview produces rather than the disk.
217
+ * They write after the copies and before the page. The sweep then runs, and the root of the directory holds
218
+ * the closure of this page alone.
142
219
  */
143
220
  async function renderToWorkspace(args) {
144
221
  let root;
@@ -148,31 +225,47 @@ async function renderToWorkspace(args) {
148
225
  catch (cause) {
149
226
  return err({ kind: "fs", error: { type: "read_failed", op: "preview.resolveWorkspaceRoot", path: args.analysisId, cause } });
150
227
  }
151
- const sessionDir = join(root, "report-sessions", args.threadId);
152
- const assetsDir = join(sessionDir, "assets");
228
+ const sessionDir = join(root, reportSessionDir(args.threadId));
229
+ // The page addresses each staged file through `assetSource`, which spells the same segment. Thus the
230
+ // directory that receives the copies comes from that one constant, and never from a literal here.
231
+ const assetsDir = join(sessionDir, ASSETS_DIR);
232
+ const depsDir = join(assetsDir, DEPS_DIR);
153
233
  const pagePath = join(sessionDir, "index.html");
154
234
  // Each bound figure, contained and deduplicated by its staged name, mapped to its host source.
155
- const sources = new Map();
235
+ const reportFiles = new Map();
156
236
  for (const resolution of args.resolutions) {
157
237
  if (resolution.kind !== "figure" || resolution.resolved.type !== "file") {
158
238
  continue;
159
239
  }
160
240
  const file = resolution.resolved;
161
241
  const name = assetFileName(file);
162
- if (sources.has(name)) {
242
+ if (reportFiles.has(name)) {
163
243
  continue;
164
244
  }
165
245
  const resolved = resolveWorkspacePath({ workspaceRoot: root, analysisId: args.analysisId, path: file.path });
166
246
  if (resolved.kind !== "ok") {
167
247
  return err({ kind: "figure-out-of-scope", blockId: resolution.blockId, path: file.path });
168
248
  }
169
- sources.set(name, resolved.absolute);
249
+ reportFiles.set(name, resolved.absolute);
170
250
  }
171
- // Each manifest entry, mapped from its staged name to the file that the asset lookup gives. A specifier
172
- // that does not resolve is a fault of the installation, thus it rides the `fs` kind.
251
+ // Each table sidecar: the pinned bytes themselves, under the name that the download link of the card
252
+ // spells. The path of a snapshot entry is untrusted, thus the containment test runs here too.
253
+ for (const sidecar of args.sidecars) {
254
+ if (reportFiles.has(sidecar.name)) {
255
+ continue;
256
+ }
257
+ const resolved = resolveWorkspacePath({ workspaceRoot: root, analysisId: args.analysisId, path: sidecar.path });
258
+ if (resolved.kind !== "ok") {
259
+ return err({ kind: "figure-out-of-scope", blockId: sidecar.blockId, path: sidecar.path });
260
+ }
261
+ reportFiles.set(sidecar.name, resolved.absolute);
262
+ }
263
+ // Each manifest entry, mapped from its staged subpath to the file that the asset lookup gives. A
264
+ // specifier that does not resolve is a fault of the installation, thus it rides the `fs` kind.
265
+ const depsFiles = new Map();
173
266
  for (const asset of PAGE_ASSETS) {
174
267
  try {
175
- sources.set(asset.file, args.resolvePageAsset(asset.specifier));
268
+ depsFiles.set(asset.file, args.resolvePageAsset(asset.specifier));
176
269
  }
177
270
  catch (cause) {
178
271
  return err({ kind: "fs", error: { type: "read_failed", op: "preview.resolveAsset", path: asset.specifier, cause } });
@@ -182,22 +275,66 @@ async function renderToWorkspace(args) {
182
275
  if (madeSession.isErr()) {
183
276
  return err({ kind: "fs", error: madeSession.error });
184
277
  }
185
- const madeAssets = await tryFsWrite("preview.mkdir", () => mkdir(assetsDir, { recursive: true }), { path: assetsDir });
278
+ // The recursive make covers the assets directory itself, thus one call answers for both levels.
279
+ const madeAssets = await tryFsWrite("preview.mkdir", () => mkdir(depsDir, { recursive: true }), { path: depsDir });
186
280
  if (madeAssets.isErr()) {
187
281
  return err({ kind: "fs", error: madeAssets.error });
188
282
  }
189
- for (const [name, source] of sources) {
283
+ for (const [name, source] of [...reportFiles, ...depsFiles]) {
190
284
  const copied = await tryFsWrite("preview.copyFile", () => copyFile(source, join(assetsDir, name)), { path: source });
191
285
  if (copied.isErr()) {
192
286
  return err({ kind: "fs", error: copied.error });
193
287
  }
194
288
  }
289
+ // A data asset is source text that the renderer derived, and a script is the text of a durable record.
290
+ // Neither one sits on disk under its staged name, thus both write and neither one copies.
291
+ const texts = [...args.dataAssets.map((asset) => [asset.name, asset.bytes]), ...args.scripts];
292
+ for (const [name, text] of texts) {
293
+ const assetPath = join(assetsDir, name);
294
+ const wroteAsset = await tryFsWrite("preview.writeFile", () => writeFile(assetPath, text, "utf8"), { path: assetPath });
295
+ if (wroteAsset.isErr()) {
296
+ return err({ kind: "fs", error: wroteAsset.error });
297
+ }
298
+ }
195
299
  const wrote = await tryFsWrite("preview.writeFile", () => writeFile(pagePath, args.page, "utf8"), { path: pagePath });
196
300
  if (wrote.isErr()) {
197
301
  return err({ kind: "fs", error: wrote.error });
198
302
  }
303
+ const staged = new Set([...reportFiles.keys(), ...texts.map(([name]) => name)]);
304
+ await sweepAssets(assetsDir, staged, args.logger);
199
305
  return ok(pagePath);
200
306
  }
307
+ /**
308
+ * Remove each report-side file of the assets directory that this preview did not stage.
309
+ *
310
+ * The stage is authoritative: the root of the directory holds the closure of the page and nothing else. A
311
+ * block that goes leaves a stale data asset, a stale sidecar, a stale figure, and the script of a derivation
312
+ * that nothing binds. Each one costs disk and misleads a reader who opens the directory. The staged set is
313
+ * what this run wrote, thus the sweep needs no read of the page.
314
+ *
315
+ * The `deps/` directory holds the shipped libraries and fonts. The manifest governs that set, and the stage
316
+ * writes each entry of it on every run. Thus the sweep passes over the directory whole.
317
+ *
318
+ * A sweep fault costs the cleanup alone. The page and its assets are on disk and complete, thus a failed
319
+ * listing and a failed removal each log and the preview still reports the page.
320
+ */
321
+ async function sweepAssets(assetsDir, staged, logger) {
322
+ const listed = await tryFsWrite("preview.readdir", () => readdir(assetsDir), { path: assetsDir });
323
+ if (listed.isErr()) {
324
+ logger.warn("the assets directory did not list", { path: assetsDir, detail: describeFsError(listed.error) });
325
+ return;
326
+ }
327
+ for (const entry of listed.value) {
328
+ if (entry === DEPS_DIR || staged.has(entry)) {
329
+ continue;
330
+ }
331
+ const stale = join(assetsDir, entry);
332
+ const removed = await tryFsWrite("preview.rm", () => rm(stale, { force: true, recursive: true }), { path: stale });
333
+ if (removed.isErr()) {
334
+ logger.warn("a stale asset did not go", { path: stale, detail: describeFsError(removed.error) });
335
+ }
336
+ }
337
+ }
201
338
  /**
202
339
  * Make the render-and-preview tool over the session-state gateway and the render seams.
203
340
  *
@@ -217,14 +354,19 @@ export function createPreviewReportTool(deps) {
217
354
  inputSchema: previewReportInput,
218
355
  executionMode: "inline",
219
356
  describeCall: "none",
357
+ // The page path is the one fact of a preview that a watcher wants, and it exists only in the
358
+ // result. Each other arm is a degraded condition whose kind already names it, thus the kind
359
+ // stands as the line. A stamp failure is such an arm: the page landed, but the marker did not,
360
+ // and a line that named the page would read as a clean pass.
361
+ describeResult: (_input, result) => (result.outcome === "rendered" ? `page ${result.pagePath}` : result.outcome),
220
362
  execute: async (_input, ctx) => {
221
363
  const opened = await openReportThread(deps.gateway, ctx.session.scope);
222
364
  if (opened.isErr()) {
223
365
  return ok({ outcome: "refused", refusal: opened.error });
224
366
  }
225
- const { threadId, analysisId, state } = opened.value;
367
+ const { threadId, analysisId, state, derivations } = opened.value;
226
368
  const { document: draft, snapshot } = state;
227
- const finished = finishDraft(draft, snapshot);
369
+ const finished = finishDraft(draft, snapshot, derivations);
228
370
  if (!finished.valid) {
229
371
  return ok({ outcome: "gaps", gaps: finished.gaps });
230
372
  }
@@ -251,7 +393,13 @@ export function createPreviewReportTool(deps) {
251
393
  if (bridged.isErr()) {
252
394
  return ok({ outcome: "bridge-mismatch", mismatches: bridged.error });
253
395
  }
254
- const rendered = renderReportPage(document, bridged.value);
396
+ // The citation records are the bibliography of the pin, and the derivation records are the chain
397
+ // of each derived path. Both ride the render call and never the value map, because the appendix
398
+ // reads them beside the cards and the value map is keyed by block. The chain carries the two
399
+ // relative links of its entry, thus the renderer states them and it stages nothing.
400
+ const used = referencedDerivations(document, derivations);
401
+ const sessionDir = reportSessionDir(threadId);
402
+ const rendered = renderReportPage(document, bridged.value, snapshot.citationRecords, used.map((record) => chainOf(record, sessionDir)));
255
403
  if (rendered.isErr()) {
256
404
  return ok({ outcome: "render-problems", problems: rendered.error });
257
405
  }
@@ -261,7 +409,11 @@ export function createPreviewReportTool(deps) {
261
409
  analysisId,
262
410
  threadId,
263
411
  resolutions,
264
- page: rendered.value,
412
+ sidecars: collectTableSidecars(document.sections),
413
+ dataAssets: rendered.value.dataAssets,
414
+ scripts: scriptAssets(used),
415
+ page: rendered.value.html,
416
+ logger,
265
417
  });
266
418
  if (written.isErr()) {
267
419
  const failure = written.error;
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * The record tool of a report session.
3
3
  *
4
- * The tool records one version of the report. The store is append-only with no delete, thus a version that
5
- * the gate did not accept must never exist. The tool runs the whole gate first, and only a pass reaches the
6
- * store.
4
+ * The tool records one version of the report. A version that the gate did not accept must never stand, thus
5
+ * the tool runs the whole gate first, and only a pass reaches the store. The gate and the look rule run on
6
+ * every record, the first and each later one alike.
7
7
  *
8
8
  * The gate has three parts, in order of cost. The finish gates the schema, the unique ids, and the
9
9
  * structural tier, and a gap list returns as data. The look-before-record rule then compares the seen hash
@@ -18,8 +18,15 @@
18
18
  * before the resolver constructs.
19
19
  *
20
20
  * On a pass the store records the document that the gate validated, the pinned snapshot, and the anchor from
21
- * the thread row. The one-per-thread rule of the store bounds a concurrent race, and that refusal returns as
22
- * data. The outcome of a pass carries the version id.
21
+ * the thread row. A record on a thread that holds a version replaces that version whole, under the same
22
+ * version id. Thus the loop is unbounded: the agent amends, looks again, and records again, and the stored
23
+ * version equals the page that the last look accepted. A refused record leaves the stored version as it was.
24
+ * The outcome of a pass carries the version id, and it names a replacement.
25
+ *
26
+ * The prune runs after the version lands. It removes the output file of each derivation that the recorded
27
+ * document does not reference, under the `derived/` directory of the session alone. The records stay
28
+ * append-only, because the bytes are reproducible from the script and the sources. A failed removal logs and
29
+ * changes no outcome.
23
30
  */
24
31
  import { z } from "zod";
25
32
  import type { AuthContext } from "../../auth/types.js";
@@ -29,6 +36,7 @@ import { type FinishGap } from "../../report-model/draft-finish.js";
29
36
  import type { ReferenceResolver } from "../../report-model/reference-resolver.js";
30
37
  import { type ResolutionFailure, type SchemaIssue } from "../../report-model/validate.js";
31
38
  import type { ReportVersionStore } from "../../state/report-versions.js";
39
+ import { type ResolveWorkspaceRoot } from "../../workspace/paths.js";
32
40
  import { type Tool } from "../define-tool.js";
33
41
  import { type ReportSessionStateGateway, type SessionRefusal } from "../report-authoring/authoring-tools.js";
34
42
  /** The empty input. The tool records the current draft of the thread, thus it needs no field. */
@@ -41,8 +49,8 @@ export type RecordVersionInput = z.infer<typeof recordVersionInput>;
41
49
  * `gaps` names each completeness gap of the draft. `never-seen` means that no eyes ran on the current draft.
42
50
  * `stale-look` means that the eyes ran, and the agent then changed the draft. `root-unresolvable` means that
43
51
  * the resolver construction cannot resolve the workspace root. `invalid` names each gate failure, and a
44
- * resolution failure carries the block that holds it. `already-recorded` means that the thread already holds
45
- * a version. `recorded` carries the version id.
52
+ * resolution failure carries the block that holds it. `recorded` carries the version id, and `replaced` is
53
+ * true when the record wrote over the earlier version of the thread.
46
54
  */
47
55
  export type RecordVersionResult = {
48
56
  outcome: "refused";
@@ -64,27 +72,31 @@ export type RecordVersionResult = {
64
72
  schemaIssues?: SchemaIssue[];
65
73
  duplicateIds?: string[];
66
74
  resolutionFailures?: ResolutionFailure[];
67
- } | {
68
- outcome: "already-recorded";
69
75
  } | {
70
76
  outcome: "record-failed";
71
77
  detail: string;
72
78
  } | {
73
79
  outcome: "recorded";
74
80
  versionId: string;
81
+ replaced: boolean;
75
82
  };
76
83
  /**
77
84
  * The construction deps of the record tool.
78
85
  *
79
- * `store` is the append-only version store. `threads` reads the anchor of the report thread, thus the
86
+ * `store` is the version store. `threads` reads the anchor of the report thread, thus the
80
87
  * version carries the parent conversation and the transcript position. `makeResolver` is optional, because a
81
88
  * resolver realization can be absent, and the gate needs one. It binds one analysis, thus the tool makes the
82
89
  * resolver over the scope of the call.
90
+ *
91
+ * `resolveWorkspaceRoot` maps the analysis of the call onto its workspace root. The prune reaches the
92
+ * derived directory of the session under that root, thus the seam is mandatory: a composition that bound
93
+ * none would record a version and leave every unused derivation on disk.
83
94
  */
84
95
  export interface RecordVersionToolDeps {
85
96
  readonly gateway: ReportSessionStateGateway;
86
97
  readonly store: ReportVersionStore;
87
98
  readonly threads: Pick<ThreadStore, "getThread">;
99
+ readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
88
100
  readonly makeResolver?: (scope: {
89
101
  analysisId: string;
90
102
  auth: AuthContext;