@inflexa-ai/harness 0.24.0 → 0.25.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 (59) 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/app/data-profile-policy.d.ts +33 -7
  7. package/dist/app/data-profile-policy.js +31 -9
  8. package/dist/contracts/data-profile.d.ts +166 -0
  9. package/dist/contracts/index.d.ts +1 -0
  10. package/dist/execution/input-signature.d.ts +33 -0
  11. package/dist/execution/input-signature.js +0 -0
  12. package/dist/index.d.ts +4 -1
  13. package/dist/index.js +5 -0
  14. package/dist/input-scan/coverage.d.ts +22 -0
  15. package/dist/input-scan/coverage.js +85 -0
  16. package/dist/input-scan/enrich.d.ts +41 -0
  17. package/dist/input-scan/enrich.js +227 -0
  18. package/dist/input-scan/formats.d.ts +41 -0
  19. package/dist/input-scan/formats.js +279 -0
  20. package/dist/input-scan/index.d.ts +12 -0
  21. package/dist/input-scan/index.js +11 -0
  22. package/dist/input-scan/scan.d.ts +50 -0
  23. package/dist/input-scan/scan.js +182 -0
  24. package/dist/input-scan/shapes.d.ts +46 -0
  25. package/dist/input-scan/shapes.js +0 -0
  26. package/dist/input-scan/types.d.ts +172 -0
  27. package/dist/input-scan/types.js +12 -0
  28. package/dist/prompts/sandbox/data-profiler.d.ts +1 -1
  29. package/dist/prompts/sandbox/data-profiler.js +159 -275
  30. package/dist/provenance/exec-frame.js +9 -3
  31. package/dist/sandbox/client.d.ts +7 -0
  32. package/dist/sandbox/create-sandbox.js +1 -0
  33. package/dist/sandbox/docker-client.d.ts +1 -0
  34. package/dist/sandbox/docker-client.js +20 -15
  35. package/dist/sandbox/k8s-client.d.ts +6 -5
  36. package/dist/sandbox/k8s-client.js +55 -39
  37. package/dist/sandbox/reaper.d.ts +24 -13
  38. package/dist/sandbox/reaper.js +47 -14
  39. package/dist/sandbox/types.d.ts +6 -5
  40. package/dist/schemas/data-profile-schemas.d.ts +62 -0
  41. package/dist/schemas/data-profile-schemas.js +68 -2
  42. package/dist/state/data-profile.d.ts +7 -88
  43. package/dist/state/index.d.ts +1 -1
  44. package/dist/tasks/data-profile-activity.d.ts +8 -0
  45. package/dist/tasks/data-profile-activity.js +1 -0
  46. package/dist/tasks/data-profile-index.d.ts +45 -0
  47. package/dist/tasks/data-profile-index.js +134 -0
  48. package/dist/tasks/data-profile.d.ts +8 -23
  49. package/dist/tasks/data-profile.js +150 -117
  50. package/dist/tools/research/generate-plan.js +1 -1
  51. package/dist/tools/research/inspect-data-profile.d.ts +30 -4
  52. package/dist/tools/research/inspect-data-profile.js +66 -10
  53. package/dist/tools/workspace/scan-inputs.d.ts +38 -0
  54. package/dist/tools/workspace/scan-inputs.js +61 -0
  55. package/dist/tools/workspace/workspace-search.d.ts +1 -1
  56. package/dist/tools/workspace/workspace-search.js +12 -9
  57. package/dist/workspace/filesystem.d.ts +27 -0
  58. package/dist/workspace/filesystem.js +27 -0
  59. package/package.json +1 -1
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Per-shape header readout — the one part of the scan that needs the sandbox.
3
+ *
4
+ * A bounded prefix read compared against a magic table runs on the host; a DECODER
5
+ * over user-supplied bytes does not. zlib, HDF5, and PDF parsers in the long-lived
6
+ * multi-tenant harness process are exactly the exposure the ephemeral sandbox exists
7
+ * to contain, so every decode below runs there.
8
+ *
9
+ * Cost is O(shapes), not O(files): shapes are observed from names and sizes alone, so
10
+ * enrichment decodes a bounded number of members per shape — roughly four files for
11
+ * the 3513-file tree that motivated this capability. One exec covers every shape, so
12
+ * even the round trip does not scale with the tree.
13
+ *
14
+ * Nothing ships in `sandbox-base` for this: the decoder is a Python program passed on
15
+ * the command line through the existing `runSandboxExec` path, so the capability
16
+ * releases on the harness's own path rather than on the image's manual one.
17
+ */
18
+ import { runSandboxExec } from "../tools/workspace/run-exec.js";
19
+ /** Members decoded per shape. One is enough to characterise a set whose names already match. */
20
+ export const MEMBERS_DECODED_PER_SHAPE = 1;
21
+ /** Wall-clock bound on the whole readout exec — it reads prefixes, nothing more. */
22
+ const ENRICH_TIMEOUT_SECONDS = 120;
23
+ /**
24
+ * The decoder, as a self-contained program. Reads a bounded prefix of each path and
25
+ * prints one JSON object per line: `{"path": …, "fields": {…}}` or `{"path": …,
26
+ * "unavailable": …}`. It never reads a file in full except a size-capped document,
27
+ * whose page count lives in a trailer no prefix can reach.
28
+ */
29
+ const DECODER = String.raw `
30
+ import sys, json, zlib, io, os, csv
31
+
32
+ PREFIX = 262144
33
+ MEMBER = 1048576
34
+ DOC_MAX = 33554432
35
+
36
+ def prefix_bytes(path, n=PREFIX):
37
+ with open(path, "rb") as fh:
38
+ return fh.read(n)
39
+
40
+ def gunzip_prefix(raw):
41
+ d = zlib.decompressobj(16 + zlib.MAX_WBITS)
42
+ try:
43
+ return d.decompress(raw, MEMBER)
44
+ except zlib.error:
45
+ return b""
46
+
47
+ def sniff_delimiter(line):
48
+ counts = {d: line.count(d) for d in [",", "\t", ";", "|"]}
49
+ best = max(counts, key=lambda d: counts[d])
50
+ return best if counts[best] > 0 else None
51
+
52
+ def text_fields(text):
53
+ lines = [l for l in text.splitlines() if l.strip() != ""]
54
+ if not lines:
55
+ return {}
56
+ head = lines[0]
57
+ if head.startswith("##fileformat=VCF") or head.startswith("##"):
58
+ fields = {}
59
+ for line in lines[:200]:
60
+ if line.startswith("##fileformat="):
61
+ fields["fileformat"] = line.split("=", 1)[1][:40]
62
+ elif line.startswith("##reference="):
63
+ fields["reference"] = line.split("=", 1)[1][:80]
64
+ elif line.startswith("#CHROM"):
65
+ cols = line.split("\t")
66
+ samples = cols[9:]
67
+ fields["sampleCount"] = len(samples)
68
+ fields["samples"] = ", ".join(samples[:5])[:120]
69
+ break
70
+ fields["metaLines"] = sum(1 for l in lines if l.startswith("##"))
71
+ return fields
72
+ if head.startswith(">"):
73
+ return {"firstRecord": head[1:80], "recordsInPrefix": sum(1 for l in lines if l.startswith(">"))}
74
+ if head.startswith("@") and len(lines) > 1 and not head.startswith("@HD"):
75
+ return {"firstRecord": head[1:80], "readLength": len(lines[1])}
76
+ if head.startswith("@HD") or head.startswith("@SQ"):
77
+ return {"referenceSequences": sum(1 for l in lines if l.startswith("@SQ")), "readGroups": sum(1 for l in lines if l.startswith("@RG"))}
78
+ delim = sniff_delimiter(head)
79
+ if delim:
80
+ columns = next(csv.reader([head], delimiter=delim))
81
+ return {
82
+ "delimiter": {",": "comma", "\t": "tab", ";": "semicolon", "|": "pipe"}[delim],
83
+ "columnCount": len(columns),
84
+ "columns": ", ".join(c.strip()[:24] for c in columns[:12])[:200],
85
+ "linesInPrefix": len(lines),
86
+ }
87
+ return {"firstLine": head[:120]}
88
+
89
+ def hdf5_fields(path):
90
+ try:
91
+ import h5py
92
+ except Exception:
93
+ return {"unavailable": "h5py not installed in this sandbox"}
94
+ try:
95
+ with h5py.File(path, "r") as f:
96
+ keys = list(f.keys())[:20]
97
+ out = {"rootKeys": ", ".join(keys)[:200], "rootKeyCount": len(f.keys())}
98
+ for probe in ("X", "obs", "var", "matrix"):
99
+ if probe in f:
100
+ node = f[probe]
101
+ shape = getattr(node, "shape", None)
102
+ if shape:
103
+ out[probe + "Shape"] = "x".join(str(d) for d in shape)
104
+ return out
105
+ except Exception as exc:
106
+ return {"unavailable": "hdf5 open failed: " + str(exc)[:80]}
107
+
108
+ def pdf_fields(path):
109
+ if os.path.getsize(path) > DOC_MAX:
110
+ return {"unavailable": "document too large to page-count within the scan's bound"}
111
+ try:
112
+ from pypdf import PdfReader
113
+ reader = PdfReader(path)
114
+ text = (reader.pages[0].extract_text() or "")[:200] if reader.pages else ""
115
+ return {"pageCount": len(reader.pages), "firstPageText": " ".join(text.split())[:200]}
116
+ except Exception as exc:
117
+ return {"unavailable": "pdf read failed: " + str(exc)[:80]}
118
+
119
+ def docx_fields(path):
120
+ if os.path.getsize(path) > DOC_MAX:
121
+ return {"unavailable": "document too large to inspect within the scan's bound"}
122
+ try:
123
+ import zipfile, re
124
+ with zipfile.ZipFile(path) as z:
125
+ names = z.namelist()
126
+ out = {"parts": len(names)}
127
+ if "docProps/app.xml" in names:
128
+ app = z.read("docProps/app.xml").decode("utf8", "replace")
129
+ for tag in ("Pages", "Words"):
130
+ m = re.search("<" + tag + ">(\\d+)</" + tag + ">", app)
131
+ if m:
132
+ out[tag.lower()] = int(m.group(1))
133
+ return out
134
+ except Exception as exc:
135
+ return {"unavailable": "docx read failed: " + str(exc)[:80]}
136
+
137
+ def describe(path):
138
+ ext = path.lower()
139
+ if ext.endswith(".pdf"):
140
+ return pdf_fields(path)
141
+ if ext.endswith(".docx"):
142
+ return docx_fields(path)
143
+ if ext.endswith((".h5", ".hdf5", ".h5ad", ".loom")):
144
+ return hdf5_fields(path)
145
+ raw = prefix_bytes(path)
146
+ if raw[:2] == b"\x1f\x8b":
147
+ wrapper = "bgzip" if len(raw) > 13 and raw[3] & 4 and raw[12:14] == b"BC" else "gzip"
148
+ inner = gunzip_prefix(raw)
149
+ if inner[:4] == b"BAM\x01":
150
+ l_text = int.from_bytes(inner[4:8], "little")
151
+ header = inner[8:8 + min(l_text, 65536)].decode("utf8", "replace")
152
+ fields = text_fields(header)
153
+ fields["wrapper"] = wrapper
154
+ fields["container"] = "bam"
155
+ return fields
156
+ if inner[:4] == b"BCF\x02" or inner[:3] == b"BCF":
157
+ return {"wrapper": wrapper, "container": "bcf"}
158
+ fields = text_fields(inner.decode("utf8", "replace"))
159
+ fields["wrapper"] = wrapper
160
+ return fields
161
+ if raw[:8] == b"\x89HDF\r\n\x1a\n":
162
+ return hdf5_fields(path)
163
+ return text_fields(raw.decode("utf8", "replace"))
164
+
165
+ for path in sys.argv[1:]:
166
+ try:
167
+ fields = describe(path)
168
+ except Exception as exc:
169
+ fields = {"unavailable": type(exc).__name__ + ": " + str(exc)[:80]}
170
+ note = fields.pop("unavailable", None)
171
+ record = {"path": path, "fields": fields}
172
+ if note:
173
+ record["unavailable"] = note
174
+ sys.stdout.write(json.dumps(record) + "\n")
175
+ `;
176
+ /**
177
+ * Decode one member per shape and attach the readout.
178
+ *
179
+ * A shape whose decode failed keeps its `unavailable` note rather than losing the
180
+ * shape: the readout is enrichment, and a manifest without it still carries every
181
+ * structural observation the agent's grouping rests on.
182
+ */
183
+ export async function enrichShapes(args) {
184
+ const { shapes, mountRoot } = args;
185
+ const targets = shapes.map((shape) => ({ shape, paths: shape.examplePaths.slice(0, MEMBERS_DECODED_PER_SHAPE) })).filter((t) => t.paths.length > 0);
186
+ if (targets.length === 0)
187
+ return [...shapes];
188
+ const absolute = new Map();
189
+ for (const { shape, paths } of targets) {
190
+ for (const path of paths)
191
+ absolute.set(`${mountRoot}/${path}`, shape.id);
192
+ }
193
+ const result = await runSandboxExec({
194
+ sandboxClient: args.sandboxClient,
195
+ sandbox: args.sandbox,
196
+ execId: args.execId,
197
+ command: ["python3", "-c", DECODER, ...absolute.keys()],
198
+ timeoutSeconds: ENRICH_TIMEOUT_SECONDS,
199
+ deadlineMs: args.deadlineMs,
200
+ emit: args.emit,
201
+ });
202
+ const readouts = new Map();
203
+ for (const line of result.stdout.split("\n")) {
204
+ const trimmed = line.trim();
205
+ if (!trimmed.startsWith("{"))
206
+ continue;
207
+ let parsed;
208
+ try {
209
+ parsed = JSON.parse(trimmed);
210
+ }
211
+ catch {
212
+ continue;
213
+ }
214
+ const shapeId = parsed.path ? absolute.get(parsed.path) : undefined;
215
+ if (!shapeId || !parsed.path)
216
+ continue;
217
+ readouts.set(shapeId, {
218
+ path: parsed.path.startsWith(`${mountRoot}/`) ? parsed.path.slice(mountRoot.length + 1) : parsed.path,
219
+ fields: parsed.fields ?? {},
220
+ ...(parsed.unavailable ? { unavailable: parsed.unavailable } : {}),
221
+ });
222
+ }
223
+ return shapes.map((shape) => {
224
+ const header = readouts.get(shape.id);
225
+ return header ? { ...shape, header } : shape;
226
+ });
227
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Format detection over a bounded byte prefix.
3
+ *
4
+ * A prefix read compared against a magic-byte table is NOT a decode: no parser
5
+ * runs over the bytes, so this belongs in the harness process. Every actual
6
+ * decoder — zlib, HDF5, PDF — stays in the sandbox (see `enrich.ts`), which is
7
+ * the exposure the sandbox exists to contain.
8
+ *
9
+ * Compression wrappers are reported ALONGSIDE the inner format, never in place of
10
+ * it: `.vcf.gz` is a VCF, and a shape observed on the wrapper would present
11
+ * unrelated data as mechanically alike. The wrapper comes from the bytes; the inner
12
+ * format of a wrapped file comes from its extension chain, which the sandbox
13
+ * header readout then confirms against the decompressed stream.
14
+ *
15
+ * The recognised list is a floor, not a closed set. An unrecognised format reports
16
+ * `unknown` with its extension chain preserved, and still joins a shape — shape
17
+ * observation depends on names and sizes, which are always available.
18
+ */
19
+ /** Bytes read per file for detection. Enough for every magic below plus a text sniff line. */
20
+ export declare const MAGIC_PREFIX_BYTES = 512;
21
+ /** The extension chain of a basename, outermost last. `sample.vcf.gz` → `["vcf", "gz"]`. */
22
+ export declare function extensionChain(fileName: string): string[];
23
+ /** The extension chain with any compression wrappers stripped from the tail. */
24
+ export declare function innerExtensions(extensions: readonly string[]): string[];
25
+ export interface DetectedFormat {
26
+ readonly format: string;
27
+ readonly wrapper?: string;
28
+ }
29
+ /**
30
+ * Identify a file's format from a bounded prefix of its bytes, falling back to the
31
+ * extension chain — which is all a wrapped file can offer the host, since reading
32
+ * the inner format's magic would mean decompressing.
33
+ *
34
+ * A path segment ending `.zarr` names a chunked store whose members are individually
35
+ * meaningless, so every file beneath one reports `zarr`.
36
+ */
37
+ export declare function detectFormat(args: {
38
+ readonly path: string;
39
+ readonly extensions: readonly string[];
40
+ readonly prefix: Buffer | null;
41
+ }): DetectedFormat;
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Format detection over a bounded byte prefix.
3
+ *
4
+ * A prefix read compared against a magic-byte table is NOT a decode: no parser
5
+ * runs over the bytes, so this belongs in the harness process. Every actual
6
+ * decoder — zlib, HDF5, PDF — stays in the sandbox (see `enrich.ts`), which is
7
+ * the exposure the sandbox exists to contain.
8
+ *
9
+ * Compression wrappers are reported ALONGSIDE the inner format, never in place of
10
+ * it: `.vcf.gz` is a VCF, and a shape observed on the wrapper would present
11
+ * unrelated data as mechanically alike. The wrapper comes from the bytes; the inner
12
+ * format of a wrapped file comes from its extension chain, which the sandbox
13
+ * header readout then confirms against the decompressed stream.
14
+ *
15
+ * The recognised list is a floor, not a closed set. An unrecognised format reports
16
+ * `unknown` with its extension chain preserved, and still joins a shape — shape
17
+ * observation depends on names and sizes, which are always available.
18
+ */
19
+ /** Bytes read per file for detection. Enough for every magic below plus a text sniff line. */
20
+ export const MAGIC_PREFIX_BYTES = 512;
21
+ /** Compression wrappers, keyed by the extension that names them. */
22
+ const WRAPPER_EXTENSIONS = new Set(["gz", "bgz", "bgzf", "zst", "zstd", "bz2", "xz"]);
23
+ /**
24
+ * Extension → format. This table IS the platform's format floor (see the
25
+ * input-scan-manifest spec); an extension absent from it detects as `unknown`
26
+ * unless its bytes are recognised.
27
+ */
28
+ const EXTENSION_FORMATS = {
29
+ // Variants
30
+ vcf: "vcf",
31
+ bcf: "bcf",
32
+ tbi: "tabix-index",
33
+ csi: "csi-index",
34
+ // Alignments
35
+ sam: "sam",
36
+ bam: "bam",
37
+ cram: "cram",
38
+ bai: "bam-index",
39
+ crai: "cram-index",
40
+ // Sequence
41
+ fa: "fasta",
42
+ fasta: "fasta",
43
+ fna: "fasta",
44
+ faa: "fasta",
45
+ fq: "fastq",
46
+ fastq: "fastq",
47
+ // Intervals and annotation
48
+ bed: "bed",
49
+ gff: "gff",
50
+ gff3: "gff3",
51
+ gtf: "gtf",
52
+ wig: "wig",
53
+ bw: "bigwig",
54
+ bigwig: "bigwig",
55
+ bb: "bigbed",
56
+ bigbed: "bigbed",
57
+ chain: "chain",
58
+ // Matrices and containers
59
+ h5: "hdf5",
60
+ hdf5: "hdf5",
61
+ h5ad: "h5ad",
62
+ loom: "loom",
63
+ mtx: "matrix-market",
64
+ zarr: "zarr",
65
+ rds: "rds",
66
+ rdata: "rdata",
67
+ // Tabular
68
+ csv: "csv",
69
+ tsv: "tsv",
70
+ txt: "text",
71
+ tab: "tsv",
72
+ parquet: "parquet",
73
+ xlsx: "excel",
74
+ xls: "excel",
75
+ // Genotype
76
+ bim: "plink-bim",
77
+ fam: "plink-fam",
78
+ pgen: "plink-pgen",
79
+ pvar: "plink-pvar",
80
+ psam: "plink-psam",
81
+ // Chemistry and structure
82
+ sdf: "sdf",
83
+ mol: "mol",
84
+ mol2: "mol2",
85
+ smi: "smiles",
86
+ smiles: "smiles",
87
+ pdb: "pdb",
88
+ cif: "mmcif",
89
+ mmcif: "mmcif",
90
+ // Mass spectrometry
91
+ mzml: "mzml",
92
+ mzxml: "mzxml",
93
+ mzid: "mzidentml",
94
+ mgf: "mgf",
95
+ // Arrays
96
+ idat: "idat",
97
+ cel: "cel",
98
+ // Imaging
99
+ dcm: "dicom",
100
+ nii: "nifti",
101
+ tif: "tiff",
102
+ tiff: "tiff",
103
+ // Documents and config
104
+ pdf: "pdf",
105
+ docx: "docx",
106
+ md: "markdown",
107
+ markdown: "markdown",
108
+ json: "json",
109
+ yaml: "yaml",
110
+ yml: "yaml",
111
+ log: "text",
112
+ };
113
+ /**
114
+ * `.bed` is PLINK's binary genotype table AND the interval format, and only the
115
+ * bytes tell them apart (PLINK writes a three-byte magic).
116
+ */
117
+ const PLINK_BED_MAGIC = Buffer.from([0x6c, 0x1b, 0x01]);
118
+ function startsWith(buf, magic) {
119
+ const bytes = Buffer.isBuffer(magic) ? magic : Buffer.from(magic);
120
+ return buf.length >= bytes.length && buf.subarray(0, bytes.length).equals(bytes);
121
+ }
122
+ function startsWithText(buf, text) {
123
+ return startsWith(buf, Buffer.from(text, "latin1"));
124
+ }
125
+ /**
126
+ * A gzip member whose FEXTRA carries the `BC` subfield is BGZF — the block-gzip
127
+ * form every indexed bioinformatics file uses. Reported distinctly because it is
128
+ * what makes a random-access decode of one member possible.
129
+ */
130
+ function gzipWrapper(buf) {
131
+ if (buf.length < 12)
132
+ return "gzip";
133
+ const flags = buf[3];
134
+ const hasExtra = (flags & 0x04) !== 0;
135
+ if (!hasExtra)
136
+ return "gzip";
137
+ return buf[12] === 0x42 && buf[13] === 0x43 ? "bgzip" : "gzip";
138
+ }
139
+ /** The wrapper a prefix's magic bytes name, or `undefined` for uncompressed content. */
140
+ function detectWrapper(buf) {
141
+ if (startsWith(buf, [0x1f, 0x8b]))
142
+ return gzipWrapper(buf);
143
+ if (startsWith(buf, [0x28, 0xb5, 0x2f, 0xfd]))
144
+ return "zstd";
145
+ if (startsWithText(buf, "BZh"))
146
+ return "bzip2";
147
+ if (startsWith(buf, [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00]))
148
+ return "xz";
149
+ return undefined;
150
+ }
151
+ /** Format identified from binary magic alone, independent of any extension. */
152
+ function binaryFormat(buf) {
153
+ if (startsWith(buf, [0x89, 0x48, 0x44, 0x46, 0x0d, 0x0a, 0x1a, 0x0a]))
154
+ return "hdf5";
155
+ if (startsWithText(buf, "PAR1"))
156
+ return "parquet";
157
+ if (startsWithText(buf, "CRAM"))
158
+ return "cram";
159
+ if (startsWithText(buf, "%PDF-"))
160
+ return "pdf";
161
+ if (startsWith(buf, [0x26, 0xfc, 0x8f, 0x88]))
162
+ return "bigwig";
163
+ if (startsWith(buf, [0xeb, 0xf2, 0x89, 0x87]))
164
+ return "bigbed";
165
+ if (startsWithText(buf, "RDX2") || startsWithText(buf, "RDX3"))
166
+ return "rds";
167
+ if (startsWithText(buf, "II*\0") || startsWithText(buf, "MM\0*"))
168
+ return "tiff";
169
+ if (startsWith(buf, PLINK_BED_MAGIC))
170
+ return "plink-bed";
171
+ if (buf.length >= 132 && buf.subarray(128, 132).toString("latin1") === "DICM")
172
+ return "dicom";
173
+ if (startsWithText(buf, "IDAT"))
174
+ return "idat";
175
+ return undefined;
176
+ }
177
+ /** Format identified from a text prefix — the header line most text formats declare. */
178
+ function textFormat(buf) {
179
+ const head = buf.toString("utf8", 0, Math.min(buf.length, 256));
180
+ if (head.startsWith("##fileformat=VCF"))
181
+ return "vcf";
182
+ if (head.startsWith("##gff-version 3"))
183
+ return "gff3";
184
+ if (head.startsWith("##gff-version"))
185
+ return "gff";
186
+ if (head.startsWith("@HD\t") || head.startsWith("@SQ\t"))
187
+ return "sam";
188
+ if (head.startsWith("%%MatrixMarket"))
189
+ return "matrix-market";
190
+ if (head.startsWith("BEGIN IONS"))
191
+ return "mgf";
192
+ if (head.startsWith("@<TRIPOS>"))
193
+ return "mol2";
194
+ if (head.startsWith("chain "))
195
+ return "chain";
196
+ if (head.startsWith("HEADER ") || head.startsWith("ATOM "))
197
+ return "pdb";
198
+ if (head.startsWith("data_"))
199
+ return "mmcif";
200
+ if (head.startsWith("[CEL]"))
201
+ return "cel";
202
+ if (head.startsWith("track ") || head.startsWith("fixedStep") || head.startsWith("variableStep"))
203
+ return "wig";
204
+ if (/^<\?xml/.test(head)) {
205
+ if (head.includes("<indexedmzML") || head.includes("<mzML"))
206
+ return "mzml";
207
+ if (head.includes("<mzXML"))
208
+ return "mzxml";
209
+ if (head.includes("MzIdentML"))
210
+ return "mzidentml";
211
+ return "xml";
212
+ }
213
+ if (head.startsWith(">"))
214
+ return "fasta";
215
+ if (head.startsWith("@"))
216
+ return "fastq";
217
+ return undefined;
218
+ }
219
+ /** The extension chain of a basename, outermost last. `sample.vcf.gz` → `["vcf", "gz"]`. */
220
+ export function extensionChain(fileName) {
221
+ const dot = fileName.indexOf(".");
222
+ // A leading dot names a hidden file, not an extension.
223
+ if (dot <= 0)
224
+ return [];
225
+ return fileName
226
+ .slice(dot + 1)
227
+ .split(".")
228
+ .filter((part) => part.length > 0 && part.length <= 12)
229
+ .map((part) => part.toLowerCase());
230
+ }
231
+ /** The extension chain with any compression wrappers stripped from the tail. */
232
+ export function innerExtensions(extensions) {
233
+ const inner = [...extensions];
234
+ while (inner.length > 0 && WRAPPER_EXTENSIONS.has(inner[inner.length - 1]))
235
+ inner.pop();
236
+ return inner;
237
+ }
238
+ /**
239
+ * Identify a file's format from a bounded prefix of its bytes, falling back to the
240
+ * extension chain — which is all a wrapped file can offer the host, since reading
241
+ * the inner format's magic would mean decompressing.
242
+ *
243
+ * A path segment ending `.zarr` names a chunked store whose members are individually
244
+ * meaningless, so every file beneath one reports `zarr`.
245
+ */
246
+ export function detectFormat(args) {
247
+ const { path, extensions, prefix } = args;
248
+ if (path.split("/").some((segment) => segment.toLowerCase().endsWith(".zarr")))
249
+ return { format: "zarr" };
250
+ const wrapper = prefix ? detectWrapper(prefix) : undefined;
251
+ const inner = innerExtensions(extensions);
252
+ const innerExt = inner[inner.length - 1];
253
+ const byExtension = innerExt ? EXTENSION_FORMATS[innerExt] : undefined;
254
+ if (wrapper) {
255
+ // The inner bytes are behind a decoder; the extension chain is the only inner
256
+ // evidence available here, and the sandbox header readout confirms it.
257
+ return byExtension ? { format: byExtension, wrapper } : { format: "unknown", wrapper };
258
+ }
259
+ if (prefix && prefix.length > 0) {
260
+ const binary = binaryFormat(prefix);
261
+ // A container's specialisation is an extension fact (`.h5ad` and `.loom` are
262
+ // both HDF5), so a more specific extension reading wins over the container magic.
263
+ if (binary === "hdf5" && byExtension && byExtension !== "hdf5")
264
+ return { format: byExtension };
265
+ if (binary)
266
+ return { format: binary };
267
+ const text = textFormat(prefix);
268
+ // `@` opens both FASTQ and SAM headers, and `>` opens FASTA; where the extension
269
+ // is explicit it is the better evidence for WHICH text format this is.
270
+ if (text && byExtension && text !== byExtension) {
271
+ const ambiguous = text === "fastq" || text === "fasta" || text === "xml";
272
+ if (ambiguous)
273
+ return { format: byExtension };
274
+ }
275
+ if (text)
276
+ return { format: text };
277
+ }
278
+ return { format: byExtension ?? "unknown" };
279
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Input scan — the deterministic pass that replaced per-file agent profiling.
3
+ *
4
+ * The scan observes; the profiler agent groups. Nothing exported here names a kind
5
+ * or an axis (see the input-scan-manifest spec).
6
+ */
7
+ export { MAGIC_PREFIX_BYTES, detectFormat, extensionChain, innerExtensions, type DetectedFormat } from "./formats.js";
8
+ export { MAX_SAMPLE_VALUES, MAX_SHAPES, nameStem, observeShapes, tokenizeStem, type ObservedShapes } from "./shapes.js";
9
+ export { MAX_SCANNED_FILES, buildManifest, renderInputScanManifest, scanInputTree, type ScanInputTreeArgs } from "./scan.js";
10
+ export { MEMBERS_DECODED_PER_SHAPE, enrichShapes, type EnrichShapesArgs } from "./enrich.js";
11
+ export { computeCoverage, type ProfileCoverage } from "./coverage.js";
12
+ export type { FileShape, FormatCount, HeaderReadout, InputScan, InputScanManifest, PositionCooccurrence, ScannedFile, ShapeValueOverlap, UnstructuredEntry, UnstructuredFiles, VariablePosition, } from "./types.js";
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Input scan — the deterministic pass that replaced per-file agent profiling.
3
+ *
4
+ * The scan observes; the profiler agent groups. Nothing exported here names a kind
5
+ * or an axis (see the input-scan-manifest spec).
6
+ */
7
+ export { MAGIC_PREFIX_BYTES, detectFormat, extensionChain, innerExtensions } from "./formats.js";
8
+ export { MAX_SAMPLE_VALUES, MAX_SHAPES, nameStem, observeShapes, tokenizeStem } from "./shapes.js";
9
+ export { MAX_SCANNED_FILES, buildManifest, renderInputScanManifest, scanInputTree } from "./scan.js";
10
+ export { MEMBERS_DECODED_PER_SHAPE, enrichShapes } from "./enrich.js";
11
+ export { computeCoverage } from "./coverage.js";
@@ -0,0 +1,50 @@
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 type { AgentSession } from "../auth/types.js";
15
+ import type { WorkspaceFilesystem } from "../workspace/filesystem.js";
16
+ import type { InputScan, InputScanManifest, ScannedFile } from "./types.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 declare const MAX_SCANNED_FILES = 100000;
29
+ export interface ScanInputTreeArgs {
30
+ readonly session: AgentSession;
31
+ readonly fs: WorkspaceFilesystem;
32
+ /** Directory to scan, relative to the analysis root (e.g. `data/inputs`). */
33
+ readonly root: string;
34
+ /** Walk ceiling; defaults to {@link MAX_SCANNED_FILES}. */
35
+ readonly limit?: number;
36
+ readonly signal?: AbortSignal;
37
+ }
38
+ /** Assemble a manifest from an already-walked file set. Pure — the seam of the unit tests. */
39
+ export declare function buildManifest(root: string, files: readonly ScannedFile[], truncated: boolean, limit?: number): InputScan;
40
+ /** Walk a staged input tree and observe its shapes. No model, no container. */
41
+ export declare function scanInputTree(args: ScanInputTreeArgs): Promise<InputScan>;
42
+ /**
43
+ * Render a manifest for a briefing or a tool result.
44
+ *
45
+ * Bounded by construction — every list it draws from is already capped — so a tree of
46
+ * three thousand files costs the same context as a tree of thirty. The wording states
47
+ * observations only: it never calls a shape a kind, and it says outright that the
48
+ * grouping decision is the reader's.
49
+ */
50
+ export declare function renderInputScanManifest(manifest: InputScanManifest): string;