@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.
- package/dist/agents/sandbox/__fixtures__/deps.js +6 -0
- package/dist/agents/sandbox/data-profiler.js +5 -4
- package/dist/agents/sandbox/shared.js +11 -0
- package/dist/agents/sandbox/types.d.ts +1 -1
- package/dist/app/data-profile-orientation.js +30 -1
- package/dist/app/data-profile-policy.d.ts +33 -7
- package/dist/app/data-profile-policy.js +31 -9
- package/dist/contracts/data-profile.d.ts +166 -0
- package/dist/contracts/index.d.ts +1 -0
- package/dist/execution/input-signature.d.ts +33 -0
- package/dist/execution/input-signature.js +0 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +5 -0
- package/dist/input-scan/coverage.d.ts +22 -0
- package/dist/input-scan/coverage.js +85 -0
- package/dist/input-scan/enrich.d.ts +41 -0
- package/dist/input-scan/enrich.js +227 -0
- package/dist/input-scan/formats.d.ts +41 -0
- package/dist/input-scan/formats.js +279 -0
- package/dist/input-scan/index.d.ts +12 -0
- package/dist/input-scan/index.js +11 -0
- package/dist/input-scan/scan.d.ts +50 -0
- package/dist/input-scan/scan.js +182 -0
- package/dist/input-scan/shapes.d.ts +46 -0
- package/dist/input-scan/shapes.js +0 -0
- package/dist/input-scan/types.d.ts +172 -0
- package/dist/input-scan/types.js +12 -0
- package/dist/prompts/sandbox/data-profiler.d.ts +1 -1
- package/dist/prompts/sandbox/data-profiler.js +159 -275
- package/dist/provenance/exec-frame.js +9 -3
- package/dist/sandbox/client.d.ts +7 -0
- package/dist/sandbox/create-sandbox.js +1 -0
- package/dist/sandbox/docker-client.d.ts +1 -0
- package/dist/sandbox/docker-client.js +20 -15
- package/dist/sandbox/k8s-client.d.ts +6 -5
- package/dist/sandbox/k8s-client.js +55 -39
- package/dist/sandbox/reaper.d.ts +24 -13
- package/dist/sandbox/reaper.js +47 -14
- package/dist/sandbox/types.d.ts +6 -5
- package/dist/schemas/data-profile-schemas.d.ts +62 -0
- package/dist/schemas/data-profile-schemas.js +68 -2
- package/dist/state/data-profile.d.ts +7 -88
- package/dist/state/index.d.ts +1 -1
- package/dist/tasks/data-profile-activity.d.ts +8 -0
- package/dist/tasks/data-profile-activity.js +1 -0
- package/dist/tasks/data-profile-index.d.ts +45 -0
- package/dist/tasks/data-profile-index.js +134 -0
- package/dist/tasks/data-profile.d.ts +8 -23
- package/dist/tasks/data-profile.js +150 -117
- package/dist/tools/research/generate-plan.js +1 -1
- package/dist/tools/research/inspect-data-profile.d.ts +30 -4
- package/dist/tools/research/inspect-data-profile.js +66 -10
- package/dist/tools/workspace/scan-inputs.d.ts +38 -0
- package/dist/tools/workspace/scan-inputs.js +61 -0
- package/dist/tools/workspace/workspace-search.d.ts +1 -1
- package/dist/tools/workspace/workspace-search.js +12 -9
- package/dist/workspace/filesystem.d.ts +27 -0
- package/dist/workspace/filesystem.js +27 -0
- 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;
|