@inflexa-ai/harness 0.24.0 → 0.26.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 (85) hide show
  1. package/dist/agents/sandbox/__fixtures__/deps.js +6 -0
  2. package/dist/agents/sandbox/data-profiler.js +5 -4
  3. package/dist/agents/sandbox/shared.js +11 -0
  4. package/dist/agents/sandbox/types.d.ts +1 -1
  5. package/dist/app/data-profile-orientation.js +30 -1
  6. package/dist/contracts/data-profile.d.ts +166 -0
  7. package/dist/contracts/index.d.ts +1 -0
  8. package/dist/execution/input-signature.d.ts +33 -0
  9. package/dist/execution/input-signature.js +0 -0
  10. package/dist/index.d.ts +5 -2
  11. package/dist/index.js +10 -3
  12. package/dist/input-scan/coverage.d.ts +22 -0
  13. package/dist/input-scan/coverage.js +85 -0
  14. package/dist/input-scan/enrich.d.ts +41 -0
  15. package/dist/input-scan/enrich.js +227 -0
  16. package/dist/input-scan/formats.d.ts +41 -0
  17. package/dist/input-scan/formats.js +279 -0
  18. package/dist/input-scan/index.d.ts +12 -0
  19. package/dist/input-scan/index.js +11 -0
  20. package/dist/input-scan/scan.d.ts +50 -0
  21. package/dist/input-scan/scan.js +182 -0
  22. package/dist/input-scan/shapes.d.ts +46 -0
  23. package/dist/input-scan/shapes.js +0 -0
  24. package/dist/input-scan/types.d.ts +172 -0
  25. package/dist/input-scan/types.js +12 -0
  26. package/dist/lib/page-capture.d.ts +44 -15
  27. package/dist/lib/page-capture.js +78 -19
  28. package/dist/loop/run-agent.js +36 -19
  29. package/dist/memory/card-builders.d.ts +4 -6
  30. package/dist/memory/card-builders.js +4 -6
  31. package/dist/memory/conversation-display-replay.d.ts +7 -6
  32. package/dist/memory/conversation-display-replay.js +7 -6
  33. package/dist/memory/conversation-display-storage.d.ts +2 -10
  34. package/dist/memory/conversation-display-storage.js +79 -32
  35. package/dist/memory/thread-history.d.ts +31 -20
  36. package/dist/memory/thread-history.js +45 -45
  37. package/dist/prompts/report-session.d.ts +1 -1
  38. package/dist/prompts/report-session.js +22 -13
  39. package/dist/prompts/sandbox/data-profiler.d.ts +1 -1
  40. package/dist/prompts/sandbox/data-profiler.js +159 -275
  41. package/dist/provenance/exec-frame.js +9 -3
  42. package/dist/runtime/boot.d.ts +7 -8
  43. package/dist/runtime/boot.js +7 -19
  44. package/dist/sandbox/client.d.ts +7 -0
  45. package/dist/sandbox/create-sandbox.js +1 -0
  46. package/dist/sandbox/docker-client.d.ts +1 -0
  47. package/dist/sandbox/docker-client.js +20 -15
  48. package/dist/sandbox/k8s-client.d.ts +6 -5
  49. package/dist/sandbox/k8s-client.js +55 -39
  50. package/dist/sandbox/reaper.d.ts +24 -13
  51. package/dist/sandbox/reaper.js +47 -14
  52. package/dist/sandbox/types.d.ts +6 -5
  53. package/dist/schemas/data-profile-schemas.d.ts +62 -0
  54. package/dist/schemas/data-profile-schemas.js +68 -2
  55. package/dist/state/data-profile.d.ts +7 -88
  56. package/dist/state/index.d.ts +1 -1
  57. package/dist/tasks/data-profile-activity.d.ts +8 -0
  58. package/dist/tasks/data-profile-activity.js +1 -0
  59. package/dist/tasks/data-profile-index.d.ts +45 -0
  60. package/dist/tasks/data-profile-index.js +134 -0
  61. package/dist/tasks/data-profile.d.ts +8 -23
  62. package/dist/tasks/data-profile.js +150 -117
  63. package/dist/tools/define-tool.d.ts +19 -8
  64. package/dist/tools/define-tool.js +28 -12
  65. package/dist/tools/report-session/examine-page.d.ts +22 -13
  66. package/dist/tools/report-session/examine-page.js +15 -10
  67. package/dist/tools/report-session/index.d.ts +1 -1
  68. package/dist/tools/research/generate-plan.js +3 -8
  69. package/dist/tools/research/inspect-data-profile.d.ts +30 -4
  70. package/dist/tools/research/inspect-data-profile.js +75 -20
  71. package/dist/tools/workspace/scan-inputs.d.ts +38 -0
  72. package/dist/tools/workspace/scan-inputs.js +61 -0
  73. package/dist/tools/workspace/workspace-search.d.ts +1 -1
  74. package/dist/tools/workspace/workspace-search.js +12 -9
  75. package/dist/workspace/filesystem.d.ts +27 -0
  76. package/dist/workspace/filesystem.js +27 -0
  77. package/package.json +1 -1
  78. package/dist/app/data-profile-policy.d.ts +0 -36
  79. package/dist/app/data-profile-policy.js +0 -37
  80. package/dist/memory/content-to-cortex.d.ts +0 -75
  81. package/dist/memory/content-to-cortex.js +0 -201
  82. package/dist/memory/conversation-display-backfill.d.ts +0 -23
  83. package/dist/memory/conversation-display-backfill.js +0 -91
  84. package/dist/memory/reconstruct-cards.d.ts +0 -27
  85. package/dist/memory/reconstruct-cards.js +0 -57
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The input scan — one deterministic pass over a staged input tree.
3
+ *
4
+ * Runs in the harness process over the workspace read seam, which is
5
+ * sandbox-independent: the walk touches names and metadata, and format detection
6
+ * compares a bounded byte prefix against a table. Neither is a decode, so neither
7
+ * needs a container. Only the per-shape header readout (`enrich.ts`) runs a decoder,
8
+ * and it runs in the sandbox.
9
+ *
10
+ * The scan is the SOLE enumeration pass. Before it, the profiler agent discovered
11
+ * structure by issuing one command per input file, so its cost scaled with file
12
+ * count and its step budget did not.
13
+ */
14
+ import { unwrapOrThrow } from "../lib/result.js";
15
+ import { MAGIC_PREFIX_BYTES, detectFormat, extensionChain } from "./formats.js";
16
+ import { observeShapes } from "./shapes.js";
17
+ /**
18
+ * Files walked before the scan reports truncation instead of completing.
19
+ *
20
+ * Header decode is O(shapes), but the walk and its per-file prefix read are O(files),
21
+ * and that arm needs a stated ceiling rather than an implicit one. 100k files is ~30s
22
+ * of stat-and-prefix work against a 20-minute profile deadline, and two orders of
23
+ * magnitude above the largest tree the platform has seen (3513). Past it the scan
24
+ * stops and SAYS it stopped: a silently sampled manifest would describe a fraction of
25
+ * the tree while reading as a description of all of it, which is the failure mode this
26
+ * capability exists to remove.
27
+ */
28
+ export const MAX_SCANNED_FILES = 100_000;
29
+ /** Walk the tree breadth-first, reading a bounded prefix of each file for format detection. */
30
+ async function walk(args) {
31
+ const { session, fs, root, signal } = args;
32
+ const limit = args.limit ?? MAX_SCANNED_FILES;
33
+ const files = [];
34
+ const queue = [root];
35
+ let truncated = false;
36
+ while (queue.length > 0) {
37
+ signal?.throwIfAborted();
38
+ const dir = queue.shift();
39
+ const listing = unwrapOrThrow(await fs.list({ session, path: dir }));
40
+ // A directory that vanished mid-walk, or one the seam refuses, is not a scan
41
+ // failure — the tree is being described, not validated.
42
+ if (listing.kind !== "ok")
43
+ continue;
44
+ for (const entry of listing.entries) {
45
+ const path = dir === "" ? entry.name : `${dir}/${entry.name}`;
46
+ if (entry.type === "directory") {
47
+ queue.push(path);
48
+ continue;
49
+ }
50
+ if (files.length >= limit) {
51
+ truncated = true;
52
+ return { files, truncated };
53
+ }
54
+ const extensions = extensionChain(entry.name);
55
+ const read = unwrapOrThrow(await fs.readBytes({ session, path, length: MAGIC_PREFIX_BYTES }));
56
+ const prefix = read.kind === "ok" ? read.bytes : null;
57
+ const { format, wrapper } = detectFormat({ path, extensions, prefix });
58
+ files.push({
59
+ path,
60
+ size: entry.size ?? 0,
61
+ extensions,
62
+ format,
63
+ ...(wrapper ? { wrapper } : {}),
64
+ });
65
+ }
66
+ }
67
+ return { files, truncated };
68
+ }
69
+ function formatCensus(files) {
70
+ const counts = new Map();
71
+ for (const file of files)
72
+ counts.set(file.format, (counts.get(file.format) ?? 0) + 1);
73
+ return [...counts.entries()].map(([format, count]) => ({ format, count })).sort((a, b) => b.count - a.count || a.format.localeCompare(b.format, "en"));
74
+ }
75
+ /** Assemble a manifest from an already-walked file set. Pure — the seam of the unit tests. */
76
+ export function buildManifest(root, files, truncated, limit = MAX_SCANNED_FILES) {
77
+ const observed = observeShapes(files);
78
+ const manifest = {
79
+ root,
80
+ fileCount: files.length,
81
+ totalBytes: files.reduce((sum, f) => sum + f.size, 0),
82
+ truncated,
83
+ ...(truncated ? { scanLimit: limit } : {}),
84
+ shapesTruncated: observed.shapesTruncated,
85
+ formats: formatCensus(files),
86
+ shapes: observed.shapes,
87
+ valueOverlaps: observed.valueOverlaps,
88
+ unstructured: observed.unstructured,
89
+ };
90
+ return { files, manifest, positionValues: observed.positionValues };
91
+ }
92
+ /** Walk a staged input tree and observe its shapes. No model, no container. */
93
+ export async function scanInputTree(args) {
94
+ const limit = args.limit ?? MAX_SCANNED_FILES;
95
+ const { files, truncated } = await walk(args);
96
+ return buildManifest(args.root, files, truncated, limit);
97
+ }
98
+ function formatBytes(bytes) {
99
+ if (bytes < 1024)
100
+ return `${bytes} B`;
101
+ const units = ["KB", "MB", "GB", "TB"];
102
+ let value = bytes / 1024;
103
+ let unit = 0;
104
+ while (value >= 1024 && unit < units.length - 1) {
105
+ value /= 1024;
106
+ unit++;
107
+ }
108
+ return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
109
+ }
110
+ /**
111
+ * Render a manifest for a briefing or a tool result.
112
+ *
113
+ * Bounded by construction — every list it draws from is already capped — so a tree of
114
+ * three thousand files costs the same context as a tree of thirty. The wording states
115
+ * observations only: it never calls a shape a kind, and it says outright that the
116
+ * grouping decision is the reader's.
117
+ */
118
+ export function renderInputScanManifest(manifest) {
119
+ const lines = [];
120
+ lines.push(`Input scan of ${manifest.root} — ${manifest.fileCount} files, ${formatBytes(manifest.totalBytes)}.`);
121
+ if (manifest.truncated) {
122
+ lines.push(`INCOMPLETE: the walk stopped at its ${manifest.scanLimit ?? MAX_SCANNED_FILES}-file ceiling, so this describes part of the tree.`);
123
+ }
124
+ lines.push(`Formats: ${manifest.formats.map((f) => `${f.format} (${f.count})`).join(", ") || "none"}`);
125
+ lines.push("");
126
+ lines.push("These are OBSERVATIONS of filename structure, not a grouping of the dataset.");
127
+ lines.push("A shape is a set of files whose names differ only at the marked positions; deciding");
128
+ lines.push("what is one kind of data, and what the varying positions mean, is your judgement.");
129
+ if (manifest.shapes.length > 0) {
130
+ lines.push("");
131
+ lines.push(`Shapes (${manifest.shapes.length}${manifest.shapesTruncated ? ", capped — the remainder is in the aggregate below" : ""}):`);
132
+ for (const shape of manifest.shapes) {
133
+ const location = shape.directory ? `${shape.directory}/` : "";
134
+ const wrapper = shape.wrapper ? `, ${shape.wrapper}-compressed` : "";
135
+ lines.push(`- ${shape.id}: ${shape.fileCount} files at ${location}${shape.pattern} (${shape.format}${wrapper}, ${formatBytes(shape.totalBytes)})`);
136
+ for (const position of shape.variablePositions) {
137
+ const prefix = position.prefix ? ` after "${position.prefix}"` : "";
138
+ const shown = position.sampleValues.join(", ");
139
+ const more = position.distinctValues > position.sampleValues.length ? ", …" : "";
140
+ lines.push(` - position <${position.index}>${prefix}: ${position.distinctValues} distinct values — ${shown}${more}`);
141
+ }
142
+ for (const pair of shape.cooccurrence) {
143
+ const crossing = pair.observedPairs === pair.possiblePairs ? "fully crossed" : `partially crossed (${pair.possiblePairs} possible)`;
144
+ lines.push(` - positions <${pair.positions[0]}>×<${pair.positions[1]}>: ${pair.observedPairs} observed combinations, ${crossing}`);
145
+ }
146
+ if (shape.header) {
147
+ const fields = Object.entries(shape.header.fields)
148
+ .map(([key, value]) => `${key}=${value}`)
149
+ .join(", ");
150
+ lines.push(` - header (${shape.header.path}): ${fields || shape.header.unavailable || "no fields read"}`);
151
+ }
152
+ else {
153
+ lines.push(` - example: ${shape.examplePaths[0] ?? "—"}`);
154
+ }
155
+ }
156
+ }
157
+ if (manifest.valueOverlaps.length > 0) {
158
+ lines.push("");
159
+ lines.push("Value overlap between shapes (evidence, NOT an assertion that they share an axis):");
160
+ for (const overlap of manifest.valueOverlaps) {
161
+ const gaps = [];
162
+ if (overlap.onlyInFirst > 0)
163
+ gaps.push(`${overlap.onlyInFirst} only in ${overlap.shapes[0]} (${overlap.onlyInFirstSample.join(", ")})`);
164
+ if (overlap.onlyInSecond > 0)
165
+ gaps.push(`${overlap.onlyInSecond} only in ${overlap.shapes[1]} (${overlap.onlyInSecondSample.join(", ")})`);
166
+ const gapText = gaps.length > 0 ? `; ${gaps.join("; ")}` : "; no gaps";
167
+ lines.push(`- ${overlap.shapes[0]}<${overlap.positions[0]}> vs ${overlap.shapes[1]}<${overlap.positions[1]}>: ` +
168
+ `${overlap.sharedValues} shared values${gapText}`);
169
+ }
170
+ }
171
+ if (manifest.unstructured.count > 0) {
172
+ lines.push("");
173
+ lines.push(`Files sharing no name structure with any other (${manifest.unstructured.count}, ${formatBytes(manifest.unstructured.totalBytes)}):`);
174
+ for (const entry of manifest.unstructured.sample) {
175
+ lines.push(`- ${entry.path} (${entry.format}, ${formatBytes(entry.size)})`);
176
+ }
177
+ if (manifest.unstructured.count > manifest.unstructured.sample.length) {
178
+ lines.push(`- … ${manifest.unstructured.count - manifest.unstructured.sample.length} more not listed`);
179
+ }
180
+ }
181
+ return lines.join("\n");
182
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Shape observation — pure functions over filenames and sizes, no I/O.
3
+ *
4
+ * A shape is a mechanical fact: these filenames differ only here. Establishing it
5
+ * needs nothing but names, sizes, and the format already detected, which is why
6
+ * shapes are observed BEFORE any decode and why header enrichment costs O(shapes)
7
+ * rather than O(files).
8
+ *
9
+ * Grouping proceeds in two passes. The first masks digit runs, so `PATIENT_001` and
10
+ * `PATIENT_002` land together while `tumor_001` and `normal_001` do not. The second
11
+ * merges groups that differ in exactly one alphabetic position, which is what makes
12
+ * `tumor`/`normal` one shape with a categorical position rather than two shapes an
13
+ * agent has to notice are related. A file whose structure matches no other survives
14
+ * both passes alone and is reported in the aggregate, never as a shape of one.
15
+ */
16
+ import type { FileShape, ScannedFile, ShapeValueOverlap, UnstructuredFiles } from "./types.js";
17
+ /** Shapes reported before the tail is folded into the aggregate. */
18
+ export declare const MAX_SHAPES = 30;
19
+ /** Values sampled per variable position. The sample is the agent's decision material. */
20
+ export declare const MAX_SAMPLE_VALUES = 20;
21
+ type AtomKind = "alpha" | "digit" | "delim";
22
+ interface Atom {
23
+ readonly kind: AtomKind;
24
+ readonly value: string;
25
+ }
26
+ /**
27
+ * Split a filename stem into runs of letters, runs of digits, and the delimiters
28
+ * between them. Splitting INSIDE a token is what makes `PT001_D0_rep1` three
29
+ * positions rather than three opaque tokens — the nested design is only visible
30
+ * once `PT` and `001` are separate.
31
+ */
32
+ export declare function tokenizeStem(stem: string): readonly Atom[];
33
+ /** The name stem — the basename with its extension chain removed. */
34
+ export declare function nameStem(fileName: string): string;
35
+ export interface ObservedShapes {
36
+ readonly shapes: readonly FileShape[];
37
+ readonly unstructured: UnstructuredFiles;
38
+ readonly valueOverlaps: readonly ShapeValueOverlap[];
39
+ /** Complete per-position value sets, keyed by shape id — the manifest carries only a sample. */
40
+ readonly positionValues: ReadonlyMap<string, readonly (readonly string[])[]>;
41
+ /** True when the shape list was capped and the remainder folded into the aggregate. */
42
+ readonly shapesTruncated: boolean;
43
+ }
44
+ /** Observe the shapes of a scanned file set. Pure — no I/O, no container, no model. */
45
+ export declare function observeShapes(files: readonly ScannedFile[]): ObservedShapes;
46
+ export {};
Binary file
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Input-scan vocabulary.
3
+ *
4
+ * The scan reports what is OBSERVABLE about a staged input tree; it never decides
5
+ * how the tree is grouped (see the input-scan-manifest spec). That separation is
6
+ * carried by the vocabulary itself: a shape is "these filenames differ only here",
7
+ * a kind is "these files are the same sort of thing", and only the profiler agent
8
+ * can establish the second. Nothing here is named `kinds` or `axes`, and no type
9
+ * here is assignable to the profiler's output — a manifest an agent could copy
10
+ * into its submission would make its central judgement invisible.
11
+ */
12
+ /** One file the walk found. `path` is relative to the analysis root. */
13
+ export interface ScannedFile {
14
+ readonly path: string;
15
+ readonly size: number;
16
+ /** Trailing extension components, outermost last: `sample.vcf.gz` → `["vcf", "gz"]`. */
17
+ readonly extensions: readonly string[];
18
+ /** Format identified from magic bytes, falling back to the extension chain, else `"unknown"`. */
19
+ readonly format: string;
20
+ /** Compression wrapper reported ALONGSIDE the inner format, never in place of it. */
21
+ readonly wrapper?: string;
22
+ }
23
+ /**
24
+ * A filename position whose token differs across a shape's members.
25
+ *
26
+ * `sampleValues` is normative, not decoration: a count alone cannot distinguish an
27
+ * identifier (`0001`…`1171`) from a categorical label (`tumor`/`normal`) from a shard
28
+ * index (`chr1`…`chr22`), and those readings imply entirely different kinds.
29
+ */
30
+ export interface VariablePosition {
31
+ /** Token index within the shape's name pattern — the `<n>` placeholder it fills. */
32
+ readonly index: number;
33
+ /** Literal text immediately preceding the token, when any (`PT` in `PT001`). */
34
+ readonly prefix?: string;
35
+ readonly distinctValues: number;
36
+ /** Bounded sample of the values observed at this position. */
37
+ readonly sampleValues: readonly string[];
38
+ }
39
+ /**
40
+ * How two variable positions vary together. A fully-crossed pair is a nested design
41
+ * (every subject × every timepoint); a partially-crossed one is not, and the
42
+ * difference is design evidence a flat file count destroys.
43
+ */
44
+ export interface PositionCooccurrence {
45
+ readonly positions: readonly [number, number];
46
+ /** Distinct value pairs observed. */
47
+ readonly observedPairs: number;
48
+ /** Pairs the two value sets could form. `observedPairs === possiblePairs` is a full crossing. */
49
+ readonly possiblePairs: number;
50
+ }
51
+ /** Header fields read from a bounded prefix of one member of a shape. */
52
+ export interface HeaderReadout {
53
+ /** The member the readout came from. */
54
+ readonly path: string;
55
+ /** Decoder-reported fields, flat and bounded (`delimiter`, `columns`, `sampleCount`, …). */
56
+ readonly fields: Readonly<Record<string, string | number | boolean>>;
57
+ /** Why no readout is present, when the decode did not produce one. */
58
+ readonly unavailable?: string;
59
+ }
60
+ /**
61
+ * A set of files that are mechanically indistinguishable by name structure, format,
62
+ * and location. A shape is an observation, never a grouping of the dataset: the
63
+ * agent may split one shape into several kinds, cover several shapes with one kind,
64
+ * or ignore the shape entirely.
65
+ */
66
+ export interface FileShape {
67
+ /** Stable within one manifest (`shape-1`, `shape-2`, …) so overlaps can name shapes. */
68
+ readonly id: string;
69
+ /** Directory the members share, relative to the analysis root. */
70
+ readonly directory: string;
71
+ /** Name pattern with each variable position rendered as `<n>`. */
72
+ readonly pattern: string;
73
+ readonly format: string;
74
+ readonly wrapper?: string;
75
+ readonly extensions: readonly string[];
76
+ readonly fileCount: number;
77
+ readonly totalBytes: number;
78
+ readonly variablePositions: readonly VariablePosition[];
79
+ readonly cooccurrence: readonly PositionCooccurrence[];
80
+ /** Bounded sample of member paths. */
81
+ readonly examplePaths: readonly string[];
82
+ /** Present once sandbox enrichment has run for this shape. */
83
+ readonly header?: HeaderReadout;
84
+ }
85
+ /**
86
+ * Value-set overlap between two shapes' variable positions, reported WITH its gaps.
87
+ *
88
+ * Correspondence across shapes is heuristic — it depends on stripping shape-specific
89
+ * text before comparing, and near-miss namings defeat it. Reported as evidence it is
90
+ * useful; asserted as a shared axis it is a guess a consumer cannot tell was guessed.
91
+ */
92
+ export interface ShapeValueOverlap {
93
+ readonly shapes: readonly [string, string];
94
+ readonly positions: readonly [number, number];
95
+ readonly sharedValues: number;
96
+ /** Values present in the first shape and absent from the second. */
97
+ readonly onlyInFirst: number;
98
+ readonly onlyInSecond: number;
99
+ /** Bounded samples of the two gaps, so a near-complete correspondence names its misses. */
100
+ readonly onlyInFirstSample: readonly string[];
101
+ readonly onlyInSecondSample: readonly string[];
102
+ }
103
+ /** One member of the no-shared-structure aggregate. */
104
+ export interface UnstructuredEntry {
105
+ readonly path: string;
106
+ readonly size: number;
107
+ readonly format: string;
108
+ }
109
+ /**
110
+ * Files whose name structure matches no other file, in aggregate.
111
+ *
112
+ * Never one shape per file: a tree of arbitrarily named files would otherwise produce
113
+ * a shape count proportional to the file count, which is the unbounded output this
114
+ * capability exists to prevent. Whether these are notable singletons worth prose or
115
+ * an unclassified remainder is the agent's determination.
116
+ */
117
+ export interface UnstructuredFiles {
118
+ readonly count: number;
119
+ readonly totalBytes: number;
120
+ /**
121
+ * Bounded sample covering distinct formats first, then the largest files. A
122
+ * size-ordered sample would bury the metadata sheet, the README, and the paper —
123
+ * one file each — under whatever large files failed to group.
124
+ */
125
+ readonly sample: readonly UnstructuredEntry[];
126
+ }
127
+ /** How many files carry each detected format, across the whole scanned tree. */
128
+ export interface FormatCount {
129
+ readonly format: string;
130
+ readonly count: number;
131
+ }
132
+ /**
133
+ * The agent-facing manifest: bounded by construction, so injecting it into a briefing
134
+ * or returning it from `scan_inputs` costs a fixed amount of context whatever the tree
135
+ * size. The complete per-file list lives on {@link InputScan}, host-side.
136
+ */
137
+ export interface InputScanManifest {
138
+ /** Path scanned, relative to the analysis root. */
139
+ readonly root: string;
140
+ readonly fileCount: number;
141
+ readonly totalBytes: number;
142
+ /**
143
+ * True when the walk hit its file ceiling and stopped. The shapes below then
144
+ * describe a prefix of the tree, not all of it — stated rather than silently
145
+ * sampled.
146
+ */
147
+ readonly truncated: boolean;
148
+ /** The ceiling that bound, when `truncated`. */
149
+ readonly scanLimit?: number;
150
+ /** True when the shape list itself was capped; the remainder is folded into `unstructured`. */
151
+ readonly shapesTruncated: boolean;
152
+ readonly formats: readonly FormatCount[];
153
+ readonly shapes: readonly FileShape[];
154
+ readonly valueOverlaps: readonly ShapeValueOverlap[];
155
+ readonly unstructured: UnstructuredFiles;
156
+ }
157
+ /**
158
+ * The scan's full result. `files` is the complete enumeration — it stays host-side and
159
+ * backs coverage and the index projection; `manifest` is the bounded projection the
160
+ * agent reads.
161
+ */
162
+ export interface InputScan {
163
+ readonly files: readonly ScannedFile[];
164
+ readonly manifest: InputScanManifest;
165
+ /**
166
+ * Per shape, the COMPLETE value set of each variable position, keyed by shape id.
167
+ * Bounded output belongs to the manifest; the entity tier of the index needs every
168
+ * value, and re-deriving them from filenames downstream would duplicate the
169
+ * tokenisation.
170
+ */
171
+ readonly positionValues: ReadonlyMap<string, readonly (readonly string[])[]>;
172
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Input-scan vocabulary.
3
+ *
4
+ * The scan reports what is OBSERVABLE about a staged input tree; it never decides
5
+ * how the tree is grouped (see the input-scan-manifest spec). That separation is
6
+ * carried by the vocabulary itself: a shape is "these filenames differ only here",
7
+ * a kind is "these files are the same sort of thing", and only the profiler agent
8
+ * can establish the second. Nothing here is named `kinds` or `axes`, and no type
9
+ * here is assignable to the profiler's output — a manifest an agent could copy
10
+ * into its submission would make its central judgement invisible.
11
+ */
12
+ export {};
@@ -2,8 +2,15 @@
2
2
  * The one page capture over the Chrome sidecar.
3
3
  *
4
4
  * A capture navigates to a page URL, collects the console errors and the failed requests, waits for the
5
- * readiness signal of the page, and gives back a base64 screenshot. Two tools capture a report page: the
6
- * preview snapshot of the old report path and the eyes tool of a report session. Both read this module.
5
+ * readiness signal of the page, and gives back base64 screenshots. The eyes tool of a report session reads
6
+ * this module.
7
+ *
8
+ * A short page captures as one full-page shot. A tall page captures as consecutive vertical slices, because
9
+ * one tall picture dies twice on the provider path: a picture past the hard dimension cap rejects the whole
10
+ * request, and a legal tall picture downscales to about 1568 pixels on the long side, which compresses the
11
+ * text past legibility. A slice of about two window heights survives both. The slice budget bounds what one
12
+ * look costs, and a page past the budget truncates with the captured and the total pixels on the coverage,
13
+ * thus the truncation is never silent.
7
14
  *
8
15
  * The readiness contract is the reason for one shared body. The renderer emits the event name and the
9
16
  * sentinel name, and `report-render/page.ts` owns both constants together with the two budgets. This module
@@ -23,20 +30,41 @@ export interface FailedRequest {
23
30
  url: string;
24
31
  reason: string;
25
32
  }
26
- /** The picture and the faults of one page capture. */
33
+ /** One picture of a capture: the base64 bytes, and the document rows that a slice holds. */
34
+ export interface CapturedShot {
35
+ readonly base64: string;
36
+ /** The vertical document range of a slice, in CSS pixels. Absent on a whole-document or window picture. */
37
+ readonly range?: {
38
+ readonly fromY: number;
39
+ readonly toY: number;
40
+ };
41
+ }
42
+ /**
43
+ * What the pictures hold together. `full` is one shot of the whole document. `tiled` is consecutive vertical
44
+ * slices in document order; `capturedPx` is the height the slices cover and `totalPx` is the document height,
45
+ * thus `capturedPx < totalPx` says that the slice budget ran out and the tail of the page is absent. `viewport`
46
+ * appears when a screenshot threw and the retry at the window passed, thus the one picture shows the top
47
+ * window alone and a section below the fold is absent from it.
48
+ */
49
+ export type CaptureCoverage = {
50
+ readonly kind: "full";
51
+ } | {
52
+ readonly kind: "tiled";
53
+ readonly capturedPx: number;
54
+ readonly totalPx: number;
55
+ } | {
56
+ readonly kind: "viewport";
57
+ };
58
+ /** The pictures and the faults of one page capture. */
27
59
  export interface PageCapture {
28
- screenshotBase64: string;
29
- /**
30
- * What the picture holds. `full` is the whole document, and it is the shape of a capture that passed at
31
- * the first attempt. `viewport` appears when the full-page screenshot threw and the retry at the window
32
- * passed, thus the picture shows the top window alone and a section below the fold is absent from it.
33
- */
34
- coverage: "full" | "viewport";
60
+ /** The pictures in document order: one shot under `full` and `viewport`, the slices under `tiled`. */
61
+ screenshots: CapturedShot[];
62
+ coverage: CaptureCoverage;
35
63
  consoleErrors: string[];
36
64
  failedRequests: FailedRequest[];
37
65
  }
38
66
  /**
39
- * The capture seam. It navigates to a page URL, and it gives back the screenshot and the faults. The seam
67
+ * The capture seam. It navigates to a page URL, and it gives back the screenshots and the faults. The seam
40
68
  * speaks the throw protocol, because the chrome connection does. A test injects a seam that reads no
41
69
  * browser, thus a tool orchestration runs with no chrome sidecar.
42
70
  */
@@ -49,11 +77,12 @@ export interface CaptureOptions {
49
77
  readonly waitMs?: number;
50
78
  }
51
79
  /**
52
- * Capture one page: the screenshot, the console errors, and the failed requests.
80
+ * Capture one page: the screenshots, the console errors, and the failed requests.
53
81
  *
54
- * The picture holds the whole document, and not the window alone. Thus a caller can judge a section that a
55
- * reader reaches by a scroll. A refused full-page bitmap degrades to the window, and the coverage of the
56
- * result names which of the two pictures arrived.
82
+ * The pictures hold the document, and not the window alone. Thus a caller can judge a section that a reader
83
+ * reaches by a scroll. A short page arrives as one full-page shot, and a tall page arrives as consecutive
84
+ * slices in document order. A refused bitmap degrades to the window, and the coverage of the result names
85
+ * what the pictures hold.
57
86
  *
58
87
  * The readiness wait is best-effort. A page that never signals still captures at the readiness budget, thus
59
88
  * a broken page gives a picture that shows what broke.
@@ -2,8 +2,15 @@
2
2
  * The one page capture over the Chrome sidecar.
3
3
  *
4
4
  * A capture navigates to a page URL, collects the console errors and the failed requests, waits for the
5
- * readiness signal of the page, and gives back a base64 screenshot. Two tools capture a report page: the
6
- * preview snapshot of the old report path and the eyes tool of a report session. Both read this module.
5
+ * readiness signal of the page, and gives back base64 screenshots. The eyes tool of a report session reads
6
+ * this module.
7
+ *
8
+ * A short page captures as one full-page shot. A tall page captures as consecutive vertical slices, because
9
+ * one tall picture dies twice on the provider path: a picture past the hard dimension cap rejects the whole
10
+ * request, and a legal tall picture downscales to about 1568 pixels on the long side, which compresses the
11
+ * text past legibility. A slice of about two window heights survives both. The slice budget bounds what one
12
+ * look costs, and a page past the budget truncates with the captured and the total pixels on the coverage,
13
+ * thus the truncation is never silent.
7
14
  *
8
15
  * The readiness contract is the reason for one shared body. The renderer emits the event name and the
9
16
  * sentinel name, and `report-render/page.ts` owns both constants together with the two budgets. This module
@@ -29,6 +36,23 @@ const SELECTOR_TIMEOUT_MS = 5_000;
29
36
  */
30
37
  const VIEWPORT_WIDTH = 1440;
31
38
  const VIEWPORT_HEIGHT = 900;
39
+ /**
40
+ * The height of one capture slice, in CSS pixels, and the bound of a single-shot page.
41
+ *
42
+ * The provider downscales every picture to about 1568 pixels on the long side before the model reads it. Two
43
+ * window heights survive that downscale with readable text, and a taller picture reaches the model compressed
44
+ * past that. Thus a page at this height or under captures as one full-page shot, and a taller page captures
45
+ * as slices of this height.
46
+ */
47
+ const TILE_HEIGHT_PX = VIEWPORT_HEIGHT * 2;
48
+ /**
49
+ * The most slices of one capture.
50
+ *
51
+ * Each slice is one picture on the tool result, and the budget bounds what one look costs the context. A page
52
+ * taller than the budget covers truncates, and the coverage carries the captured and the total pixels, thus
53
+ * the truncation is never silent.
54
+ */
55
+ const MAX_TILES = 6;
32
56
  /**
33
57
  * The body of the readiness wait, in the browser context. The sentinel arm resolves a page that dispatched
34
58
  * the event before this wait registered, thus a plain listener never blocks forever. The timer arm bounds a
@@ -57,37 +81,72 @@ function waitForThemeReady(sentinel, event, timeout) {
57
81
  function toBase64(shot) {
58
82
  return typeof shot === "string" ? shot : Buffer.from(shot).toString("base64");
59
83
  }
84
+ /** The body of the height measure, in the browser context. */
85
+ function measureScrollHeight() {
86
+ return document.documentElement.scrollHeight;
87
+ }
60
88
  /**
61
- * Take the picture of one settled page, at the whole document or at the window alone.
89
+ * Measure the document height, in CSS pixels. A page that refuses the measure reads as a short one, thus the
90
+ * capture takes the one full-page shot and the refusal costs no look.
91
+ */
92
+ async function measureTotalHeight(page) {
93
+ const measured = await page.evaluate(measureScrollHeight).catch(() => undefined);
94
+ return typeof measured === "number" && Number.isFinite(measured) ? measured : 0;
95
+ }
96
+ /**
97
+ * Capture the slices of one tall page, in document order. Each slice clips {@link TILE_HEIGHT_PX} rows at the
98
+ * reader width, and the last slice ends at the document height or at the budget, whichever comes first.
99
+ */
100
+ async function captureTiles(page, totalPx) {
101
+ const tileCount = Math.min(Math.ceil(totalPx / TILE_HEIGHT_PX), MAX_TILES);
102
+ const screenshots = [];
103
+ for (let index = 0; index < tileCount; index++) {
104
+ const fromY = index * TILE_HEIGHT_PX;
105
+ const toY = Math.min(fromY + TILE_HEIGHT_PX, totalPx);
106
+ const clip = { x: 0, y: fromY, width: VIEWPORT_WIDTH, height: toY - fromY };
107
+ screenshots.push({ base64: toBase64(await page.screenshot({ encoding: "base64", clip })), range: { fromY, toY } });
108
+ }
109
+ const capturedPx = Math.min(tileCount * TILE_HEIGHT_PX, totalPx);
110
+ return { screenshots, coverage: { kind: "tiled", capturedPx, totalPx } };
111
+ }
112
+ /**
113
+ * Take the pictures of one settled page.
114
+ *
115
+ * A page at the single-shot bound or under gives one full-page shot. A taller page gives consecutive slices,
116
+ * because the provider path compresses or rejects one tall picture; the module comment carries the account.
62
117
  *
63
- * The compositor can refuse a full-page bitmap of a tall page while the bitmap of the window is fine. A
64
- * degraded picture beats a dead look, thus a refused full page retries one time at the window. A second throw
65
- * names a broken browser and not a tall page, thus it propagates to the caller.
118
+ * The compositor can refuse a bitmap of either shape while the bitmap of the window is fine. A degraded
119
+ * picture beats a dead look, thus a refusal retries one time at the window. A second throw names a broken
120
+ * browser and not a tall page, thus it propagates to the caller.
66
121
  */
67
- async function captureShot(page) {
122
+ async function captureShots(page) {
123
+ const totalPx = await measureTotalHeight(page);
68
124
  try {
69
- return { screenshotBase64: toBase64(await page.screenshot({ encoding: "base64", fullPage: true })), coverage: "full" };
125
+ if (totalPx > TILE_HEIGHT_PX)
126
+ return await captureTiles(page, totalPx);
127
+ return { screenshots: [{ base64: toBase64(await page.screenshot({ encoding: "base64", fullPage: true })) }], coverage: { kind: "full" } };
70
128
  }
71
- catch (refusedFullPage) {
129
+ catch (refused) {
72
130
  try {
73
- return { screenshotBase64: toBase64(await page.screenshot({ encoding: "base64" })), coverage: "viewport" };
131
+ return { screenshots: [{ base64: toBase64(await page.screenshot({ encoding: "base64" })) }], coverage: { kind: "viewport" } };
74
132
  }
75
133
  catch (refusedViewport) {
76
134
  // The two refusals together are the account of a dead look, and the caller reports the second one
77
135
  // alone. Thus the first one rides as the cause, and no reader of that report loses half of it.
78
136
  if (refusedViewport instanceof Error && refusedViewport.cause === undefined) {
79
- refusedViewport.cause = refusedFullPage;
137
+ refusedViewport.cause = refused;
80
138
  }
81
139
  throw refusedViewport;
82
140
  }
83
141
  }
84
142
  }
85
143
  /**
86
- * Capture one page: the screenshot, the console errors, and the failed requests.
144
+ * Capture one page: the screenshots, the console errors, and the failed requests.
87
145
  *
88
- * The picture holds the whole document, and not the window alone. Thus a caller can judge a section that a
89
- * reader reaches by a scroll. A refused full-page bitmap degrades to the window, and the coverage of the
90
- * result names which of the two pictures arrived.
146
+ * The pictures hold the document, and not the window alone. Thus a caller can judge a section that a reader
147
+ * reaches by a scroll. A short page arrives as one full-page shot, and a tall page arrives as consecutive
148
+ * slices in document order. A refused bitmap degrades to the window, and the coverage of the result names
149
+ * what the pictures hold.
91
150
  *
92
151
  * The readiness wait is best-effort. A page that never signals still captures at the readiness budget, thus
93
152
  * a broken page gives a picture that shows what broke.
@@ -124,12 +183,12 @@ export function capturePage(chrome, url, options = {}) {
124
183
  const settle = options.waitMs;
125
184
  await new Promise((resolve) => setTimeout(resolve, settle));
126
185
  }
127
- // The picture must show the whole document at the layout that a reader gets. Thus a defect below the
186
+ // The pictures must show the whole document at the layout that a reader gets. Thus a defect below the
128
187
  // fold is visible, and a question about content that never appeared has an answer.
129
- const shot = await captureShot(page);
188
+ const shots = await captureShots(page);
130
189
  return {
131
- screenshotBase64: shot.screenshotBase64,
132
- coverage: shot.coverage,
190
+ screenshots: shots.screenshots,
191
+ coverage: shots.coverage,
133
192
  consoleErrors,
134
193
  failedRequests,
135
194
  };