@inflexa-ai/harness 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/conversation-agent.d.ts +8 -2
- package/dist/agents/conversation-agent.js +14 -2
- package/dist/agents/sandbox/shared.d.ts +2 -1
- package/dist/agents/sandbox/shared.js +3 -11
- package/dist/config/environment-stores.d.ts +42 -0
- package/dist/config/environment-stores.js +11 -0
- package/dist/execution/ephemeral-runner.d.ts +2 -1
- package/dist/execution/ephemeral-runner.js +2 -0
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +5 -0
- package/dist/prompts/planner.js +39 -0
- package/dist/prompts/sandbox/data-profiler.d.ts +1 -1
- package/dist/prompts/sandbox/data-profiler.js +5 -4
- package/dist/prompts/sandbox-standards.d.ts +1 -1
- package/dist/prompts/sandbox-standards.js +34 -21
- package/dist/reference-data/catalog.d.ts +33 -3
- package/dist/reference-data/catalog.js +213 -8
- package/dist/tasks/data-profile.d.ts +2 -1
- package/dist/tasks/data-profile.js +2 -0
- package/dist/tools/research/generate-plan.d.ts +2 -1
- package/dist/tools/research/generate-plan.js +15 -3
- package/dist/tools/sandbox/list-available-packages.d.ts +8 -3
- package/dist/tools/sandbox/list-available-packages.js +72 -38
- package/dist/tools/sandbox/list-available-refs.d.ts +30 -77
- package/dist/tools/sandbox/list-available-refs.js +359 -443
- package/package.json +1 -1
|
@@ -1,239 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Reference-store discovery
|
|
3
|
-
* store
|
|
2
|
+
* Reference-store discovery: `createListAvailableRefsTool`, a bounded, no-follow
|
|
3
|
+
* inventory of what is ACTUALLY provisioned in the reference store (managed
|
|
4
|
+
* installs and user-added files alike). Narrow with `path`/`category`, filter
|
|
5
|
+
* with `query`, cap with `limit`; every response carries `returned`/`total`/
|
|
6
|
+
* `hasMore` and the `categories` present, so a listing is never an unbounded
|
|
7
|
+
* dump. Symlinks are never followed. This tool only ever READS the store: the
|
|
8
|
+
* analysis environment cannot fetch reference data, and staging more is the
|
|
9
|
+
* host's job — so an absent dataset is a fact about this moment, not a verdict
|
|
10
|
+
* that it is unobtainable.
|
|
4
11
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* The store is read from the host filesystem at the embedder-supplied
|
|
13
|
+
* `refStorePath` and reported at `/mnt/refs`, the container path sandboxes mount
|
|
14
|
+
* it at — read where it lives, render where the caller will open it. Reading it
|
|
15
|
+
* host-side is what lets a planner ask what reference data exists before any
|
|
16
|
+
* sandbox is created; a step agent asking mid-run sees the same store, because
|
|
17
|
+
* the mount and this path are the same bytes.
|
|
18
|
+
*
|
|
19
|
+
* The scan reports paths; meaning is joined on afterwards from the install
|
|
20
|
+
* receipt and the catalog it names (organism, format, and each file's internal
|
|
21
|
+
* shape). Callers search by what the data IS, so the store's directory layout
|
|
22
|
+
* stays an installer detail rather than an interface anything encodes. Metadata
|
|
23
|
+
* only ever ENRICHES an entry — a file with no receipt and no catalog match is
|
|
24
|
+
* still returned, because a user-provided store is as valid as a managed one.
|
|
14
25
|
*/
|
|
15
|
-
import {
|
|
26
|
+
import { lstat, readFile, readdir } from "node:fs/promises";
|
|
27
|
+
import { join as hostJoin, posix as posixPath } from "node:path";
|
|
16
28
|
import { ok } from "neverthrow";
|
|
17
29
|
import { z } from "zod";
|
|
18
30
|
import { REFERENCE_DATA_CATALOG } from "../../reference-data/catalog.js";
|
|
19
31
|
import { parseReferenceInstallReceipt } from "../../reference-data/receipt.js";
|
|
20
32
|
import { defineTool } from "../define-tool.js";
|
|
21
|
-
|
|
33
|
+
/** Container path the ref store is mounted at; every reported path is rendered under it. */
|
|
22
34
|
const REFS_ROOT = "/mnt/refs";
|
|
23
35
|
const MAX_ENTRIES = 200;
|
|
24
36
|
const MAX_SCANNED_ENTRIES = 2_000;
|
|
25
37
|
const MAX_PATH_BYTES = 40_000;
|
|
26
38
|
const MAX_ENVELOPE_BYTES = 64_000;
|
|
27
39
|
const MAX_INPUT_PATH_BYTES = 4_096;
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
ROOT = sys.argv[1]
|
|
34
|
-
RELATIVE = sys.argv[2]
|
|
35
|
-
TARGET = ROOT if RELATIVE == "." else os.path.join(ROOT, *RELATIVE.split("/"))
|
|
36
|
-
MAX_ENTRIES = int(sys.argv[3])
|
|
37
|
-
MAX_SCANNED = int(sys.argv[4])
|
|
38
|
-
MAX_PATH_BYTES = int(sys.argv[5])
|
|
39
|
-
MAX_ENVELOPE_BYTES = int(sys.argv[6])
|
|
40
|
-
|
|
41
|
-
def emit(value):
|
|
42
|
-
print(json.dumps(value, separators=(",", ":")))
|
|
43
|
-
|
|
44
|
-
def capped_entries(path, limit):
|
|
45
|
-
"""Inspect and sort at most limit names; probe one extra only to mark truncation."""
|
|
46
|
-
values = []
|
|
47
|
-
visited = 0
|
|
48
|
-
cut = False
|
|
49
|
-
try:
|
|
50
|
-
with os.scandir(path) as iterator:
|
|
51
|
-
for entry in iterator:
|
|
52
|
-
if visited >= limit:
|
|
53
|
-
cut = True
|
|
54
|
-
break
|
|
55
|
-
visited += 1
|
|
56
|
-
try:
|
|
57
|
-
if entry.is_symlink():
|
|
58
|
-
kind, size = "symlink", 0
|
|
59
|
-
elif entry.is_file(follow_symlinks=False):
|
|
60
|
-
kind, size = "file", entry.stat(follow_symlinks=False).st_size
|
|
61
|
-
elif entry.is_dir(follow_symlinks=False):
|
|
62
|
-
kind, size = "directory", 0
|
|
63
|
-
else:
|
|
64
|
-
kind, size = "other", 0
|
|
65
|
-
values.append((entry.name, kind, size))
|
|
66
|
-
except OSError:
|
|
67
|
-
continue
|
|
68
|
-
except OSError:
|
|
69
|
-
return [], False
|
|
70
|
-
values.sort(key=lambda value: value[0])
|
|
71
|
-
return values, cut
|
|
72
|
-
|
|
73
|
-
if not os.path.isdir(ROOT) or os.path.islink(ROOT):
|
|
74
|
-
emit({"state": "unavailable", "entries": [], "scannedEntries": 0, "truncated": False})
|
|
75
|
-
raise SystemExit(0)
|
|
76
|
-
|
|
77
|
-
relative = RELATIVE
|
|
78
|
-
cursor = ROOT
|
|
79
|
-
if relative != ".":
|
|
80
|
-
for part in relative.split(os.sep):
|
|
81
|
-
cursor = os.path.join(cursor, part)
|
|
82
|
-
if os.path.islink(cursor):
|
|
83
|
-
emit({"state": "symlink", "entries": [], "scannedEntries": 0, "truncated": False})
|
|
84
|
-
raise SystemExit(0)
|
|
85
|
-
|
|
86
|
-
if not os.path.exists(TARGET):
|
|
87
|
-
emit({"state": "not_found", "entries": [], "scannedEntries": 0, "truncated": False})
|
|
88
|
-
raise SystemExit(0)
|
|
89
|
-
if not os.path.isdir(TARGET):
|
|
90
|
-
emit({"state": "not_a_directory", "entries": [], "scannedEntries": 0, "truncated": False})
|
|
91
|
-
raise SystemExit(0)
|
|
92
|
-
|
|
93
|
-
entries = []
|
|
94
|
-
scanned = 0
|
|
95
|
-
path_bytes = 0
|
|
96
|
-
truncated = False
|
|
97
|
-
|
|
98
|
-
children, children_cut = capped_entries(TARGET, min(MAX_ENTRIES + 2, MAX_SCANNED))
|
|
99
|
-
truncated = children_cut
|
|
100
|
-
|
|
101
|
-
for name, kind, size in children:
|
|
102
|
-
path = os.path.join(TARGET, name)
|
|
103
|
-
rel = os.path.relpath(path, ROOT).replace(os.sep, "/")
|
|
104
|
-
if rel == ".inflexa" or rel.startswith(".inflexa/") or rel == "registry.json":
|
|
105
|
-
continue
|
|
106
|
-
scanned += 1
|
|
107
|
-
rendered = "/mnt/refs/" + rel
|
|
108
|
-
item = None
|
|
109
|
-
|
|
110
|
-
if kind == "symlink":
|
|
111
|
-
item = {"path": rendered, "kind": "symlink", "bytes": 0}
|
|
112
|
-
elif kind == "file":
|
|
113
|
-
item = {"path": rendered, "kind": "file", "bytes": size}
|
|
114
|
-
elif kind == "directory":
|
|
115
|
-
total_bytes = 0
|
|
116
|
-
file_count = 0
|
|
117
|
-
file_types = set()
|
|
118
|
-
directory_truncated = False
|
|
119
|
-
stack = [path]
|
|
120
|
-
while stack and scanned < MAX_SCANNED:
|
|
121
|
-
current = stack.pop()
|
|
122
|
-
descendants, descendants_cut = capped_entries(current, MAX_SCANNED - scanned)
|
|
123
|
-
directory_truncated = directory_truncated or descendants_cut
|
|
124
|
-
child_directories = []
|
|
125
|
-
for child_name, child_kind, child_size in descendants:
|
|
126
|
-
scanned += 1
|
|
127
|
-
if child_kind == "directory":
|
|
128
|
-
child_directories.append(os.path.join(current, child_name))
|
|
129
|
-
elif child_kind == "file":
|
|
130
|
-
file_count += 1
|
|
131
|
-
total_bytes += child_size
|
|
132
|
-
suffix = os.path.splitext(child_name)[1].lower()
|
|
133
|
-
if suffix:
|
|
134
|
-
file_types.add(suffix)
|
|
135
|
-
if scanned >= MAX_SCANNED:
|
|
136
|
-
directory_truncated = True
|
|
137
|
-
break
|
|
138
|
-
stack.extend(reversed(child_directories))
|
|
139
|
-
item = {
|
|
140
|
-
"path": rendered,
|
|
141
|
-
"kind": "directory",
|
|
142
|
-
"bytes": total_bytes,
|
|
143
|
-
"fileCount": file_count,
|
|
144
|
-
"fileTypes": sorted(file_types)[:10],
|
|
145
|
-
"truncated": directory_truncated,
|
|
146
|
-
}
|
|
147
|
-
truncated = truncated or directory_truncated
|
|
148
|
-
|
|
149
|
-
if item is not None:
|
|
150
|
-
if len(entries) < MAX_ENTRIES and path_bytes + len(rendered.encode()) <= MAX_PATH_BYTES:
|
|
151
|
-
entries.append(item)
|
|
152
|
-
path_bytes += len(rendered.encode())
|
|
153
|
-
else:
|
|
154
|
-
truncated = True
|
|
155
|
-
if scanned >= MAX_SCANNED:
|
|
156
|
-
truncated = True
|
|
157
|
-
break
|
|
158
|
-
|
|
159
|
-
receipts = []
|
|
160
|
-
receipt_bytes = 0
|
|
161
|
-
metadata_dir = os.path.join(ROOT, ".inflexa")
|
|
162
|
-
receipts_dir = os.path.join(metadata_dir, "receipts")
|
|
163
|
-
try:
|
|
164
|
-
if os.path.islink(metadata_dir) or os.path.islink(receipts_dir):
|
|
165
|
-
raise ValueError("symlinked metadata directory")
|
|
166
|
-
receipt_entries, _ = capped_entries(receipts_dir, 100)
|
|
167
|
-
for name, kind, _ in receipt_entries:
|
|
168
|
-
path = os.path.join(receipts_dir, name)
|
|
169
|
-
if kind != "file":
|
|
170
|
-
continue
|
|
171
|
-
# Per-file, so one unreadable or malformed receipt costs only its own labels.
|
|
172
|
-
# A shared try would abandon the loop and silently drop every receipt sorting
|
|
173
|
-
# after the bad one, quietly stripping metadata from datasets that are fine.
|
|
174
|
-
try:
|
|
175
|
-
with open(path, "rb") as handle:
|
|
176
|
-
raw = handle.read(16385)
|
|
177
|
-
if len(raw) > 16384 or receipt_bytes + len(raw) > 32768:
|
|
178
|
-
continue
|
|
179
|
-
parsed = json.loads(raw)
|
|
180
|
-
except Exception:
|
|
181
|
-
continue
|
|
182
|
-
receipts.append(parsed)
|
|
183
|
-
receipt_bytes += len(raw)
|
|
184
|
-
except Exception:
|
|
185
|
-
pass
|
|
186
|
-
|
|
187
|
-
legacy_entries = []
|
|
188
|
-
try:
|
|
189
|
-
registry_path = os.path.join(ROOT, "registry.json")
|
|
190
|
-
if os.path.islink(registry_path):
|
|
191
|
-
raise ValueError("symlinked registry")
|
|
192
|
-
with open(registry_path, "rb") as handle:
|
|
193
|
-
registry = json.loads(handle.read(131073))
|
|
194
|
-
if isinstance(registry, dict):
|
|
195
|
-
by_category = registry.get("files", {}).get("by_category", {})
|
|
196
|
-
if isinstance(by_category, dict):
|
|
197
|
-
for category in sorted(by_category):
|
|
198
|
-
values = by_category.get(category)
|
|
199
|
-
if not isinstance(values, list):
|
|
200
|
-
continue
|
|
201
|
-
for item in values:
|
|
202
|
-
if len(legacy_entries) >= 200:
|
|
203
|
-
break
|
|
204
|
-
if isinstance(item, dict) and isinstance(item.get("local_path"), str):
|
|
205
|
-
legacy_entries.append({
|
|
206
|
-
"local_path": item["local_path"][:1000],
|
|
207
|
-
"category": str(category)[:500],
|
|
208
|
-
"dataset": str(item.get("dataset"))[:500] if item.get("dataset") is not None else None,
|
|
209
|
-
"subtype": str(item.get("subtype"))[:500] if item.get("subtype") is not None else None,
|
|
210
|
-
"organism": str(item.get("organism"))[:500] if item.get("organism") is not None else None,
|
|
211
|
-
"rows": item.get("rows"),
|
|
212
|
-
"endpoint": item.get("endpoint"),
|
|
213
|
-
})
|
|
214
|
-
except Exception:
|
|
215
|
-
pass
|
|
216
|
-
|
|
217
|
-
payload = {
|
|
218
|
-
"state": "empty" if len(entries) == 0 else "populated",
|
|
219
|
-
"entries": entries,
|
|
220
|
-
"scannedEntries": scanned,
|
|
221
|
-
"truncated": truncated,
|
|
222
|
-
"receipts": receipts,
|
|
223
|
-
"legacyEntries": legacy_entries,
|
|
224
|
-
}
|
|
225
|
-
while len(json.dumps(payload, separators=(",", ":")).encode()) > MAX_ENVELOPE_BYTES:
|
|
226
|
-
payload["truncated"] = True
|
|
227
|
-
if payload["legacyEntries"]:
|
|
228
|
-
payload["legacyEntries"].pop()
|
|
229
|
-
elif payload["receipts"]:
|
|
230
|
-
payload["receipts"].pop()
|
|
231
|
-
elif payload["entries"]:
|
|
232
|
-
payload["entries"].pop()
|
|
233
|
-
else:
|
|
234
|
-
break
|
|
235
|
-
emit(payload)
|
|
236
|
-
`;
|
|
40
|
+
const MAX_RECEIPT_FILES = 100;
|
|
41
|
+
const MAX_RECEIPT_BYTES = 16_384;
|
|
42
|
+
const MAX_RECEIPT_TOTAL_BYTES = 32_768;
|
|
43
|
+
const MAX_REGISTRY_BYTES = 131_072;
|
|
44
|
+
const MAX_LEGACY_ENTRIES = 200;
|
|
237
45
|
const ListAvailableRefsInputSchema = z.object({
|
|
238
46
|
path: z
|
|
239
47
|
.string()
|
|
@@ -257,25 +65,6 @@ const ListAvailableRefsInputSchema = z.object({
|
|
|
257
65
|
.optional()
|
|
258
66
|
.describe(`Max entries to return (default ${MAX_ENTRIES}); the response carries \`returned\`, \`total\`, and \`hasMore\` so truncation is never silent.`),
|
|
259
67
|
});
|
|
260
|
-
const RawScanSchema = z.object({
|
|
261
|
-
state: z.enum(["unavailable", "empty", "populated", "not_found", "not_a_directory", "symlink"]),
|
|
262
|
-
entries: z.array(z.discriminatedUnion("kind", [
|
|
263
|
-
z.object({ path: z.string().startsWith(`${REFS_ROOT}/`), kind: z.literal("file"), bytes: z.number().nonnegative() }),
|
|
264
|
-
z.object({ path: z.string().startsWith(`${REFS_ROOT}/`), kind: z.literal("symlink"), bytes: z.literal(0) }),
|
|
265
|
-
z.object({
|
|
266
|
-
path: z.string().startsWith(`${REFS_ROOT}/`),
|
|
267
|
-
kind: z.literal("directory"),
|
|
268
|
-
bytes: z.number().nonnegative(),
|
|
269
|
-
fileCount: z.number().int().nonnegative(),
|
|
270
|
-
fileTypes: z.array(z.string()),
|
|
271
|
-
truncated: z.boolean(),
|
|
272
|
-
}),
|
|
273
|
-
])),
|
|
274
|
-
scannedEntries: z.number().int().nonnegative(),
|
|
275
|
-
truncated: z.boolean(),
|
|
276
|
-
receipts: z.array(z.unknown()).optional(),
|
|
277
|
-
legacyEntries: z.array(z.unknown()).optional(),
|
|
278
|
-
});
|
|
279
68
|
function resolveRequestedPath(input) {
|
|
280
69
|
if (input === undefined || input === "" || input === REFS_ROOT)
|
|
281
70
|
return { ok: true, path: REFS_ROOT };
|
|
@@ -298,6 +87,287 @@ function resolveRequestedPath(input) {
|
|
|
298
87
|
return { ok: false, reason: "Path is outside /mnt/refs." };
|
|
299
88
|
return { ok: true, path: normalized };
|
|
300
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Inspect at most `limit` names in one directory, sorted by name.
|
|
92
|
+
*
|
|
93
|
+
* Sorting BEFORE the cap (rather than capping the raw readdir order) keeps
|
|
94
|
+
* truncation deterministic: the same store yields the same prefix on every call,
|
|
95
|
+
* which a durably-cached step result has to be able to promise.
|
|
96
|
+
*/
|
|
97
|
+
async function cappedEntries(directory, limit) {
|
|
98
|
+
let names;
|
|
99
|
+
try {
|
|
100
|
+
names = (await readdir(directory, { withFileTypes: true })).map((entry) => entry.name);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return { values: [], cut: false };
|
|
104
|
+
}
|
|
105
|
+
names.sort();
|
|
106
|
+
const cut = names.length > limit;
|
|
107
|
+
const values = [];
|
|
108
|
+
for (const name of names.slice(0, limit)) {
|
|
109
|
+
try {
|
|
110
|
+
// lstat, never stat: a symlink is reported as a symlink and never resolved,
|
|
111
|
+
// so a link out of the store cannot smuggle in a foreign file's size or type.
|
|
112
|
+
const stats = await lstat(hostJoin(directory, name));
|
|
113
|
+
if (stats.isSymbolicLink())
|
|
114
|
+
values.push({ name, kind: "symlink", bytes: 0 });
|
|
115
|
+
else if (stats.isFile())
|
|
116
|
+
values.push({ name, kind: "file", bytes: stats.size });
|
|
117
|
+
else if (stats.isDirectory())
|
|
118
|
+
values.push({ name, kind: "directory", bytes: 0 });
|
|
119
|
+
else
|
|
120
|
+
values.push({ name, kind: "other", bytes: 0 });
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { values, cut };
|
|
127
|
+
}
|
|
128
|
+
/** Whether any component of `relative` beneath `root` is a symlink — the no-follow gate. */
|
|
129
|
+
async function crossesSymlink(root, relative) {
|
|
130
|
+
let cursor = root;
|
|
131
|
+
for (const segment of relative.split("/")) {
|
|
132
|
+
cursor = hostJoin(cursor, segment);
|
|
133
|
+
try {
|
|
134
|
+
if ((await lstat(cursor)).isSymbolicLink())
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
/** Recursively total a directory's files, bounded by the remaining scan budget. */
|
|
144
|
+
async function summarizeDirectory(directory, scanned) {
|
|
145
|
+
let totalBytes = 0;
|
|
146
|
+
let fileCount = 0;
|
|
147
|
+
let truncated = false;
|
|
148
|
+
const fileTypes = new Set();
|
|
149
|
+
const stack = [directory];
|
|
150
|
+
while (stack.length > 0 && scanned < MAX_SCANNED_ENTRIES) {
|
|
151
|
+
const current = stack.pop();
|
|
152
|
+
const { values, cut } = await cappedEntries(current, MAX_SCANNED_ENTRIES - scanned);
|
|
153
|
+
truncated = truncated || cut;
|
|
154
|
+
const childDirectories = [];
|
|
155
|
+
for (const child of values) {
|
|
156
|
+
scanned += 1;
|
|
157
|
+
if (child.kind === "directory")
|
|
158
|
+
childDirectories.push(hostJoin(current, child.name));
|
|
159
|
+
else if (child.kind === "file") {
|
|
160
|
+
fileCount += 1;
|
|
161
|
+
totalBytes += child.bytes;
|
|
162
|
+
const suffix = posixPath.extname(child.name).toLowerCase();
|
|
163
|
+
if (suffix)
|
|
164
|
+
fileTypes.add(suffix);
|
|
165
|
+
}
|
|
166
|
+
if (scanned >= MAX_SCANNED_ENTRIES) {
|
|
167
|
+
truncated = true;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
stack.push(...childDirectories.reverse());
|
|
172
|
+
}
|
|
173
|
+
return { bytes: totalBytes, fileCount, fileTypes: [...fileTypes].sort().slice(0, 10), truncated, scanned };
|
|
174
|
+
}
|
|
175
|
+
/** Install receipts written by the host installer under `<root>/.inflexa/receipts`. */
|
|
176
|
+
async function readReceipts(root) {
|
|
177
|
+
const receiptsDirectory = hostJoin(root, ".inflexa", "receipts");
|
|
178
|
+
if (await crossesSymlink(root, ".inflexa"))
|
|
179
|
+
return [];
|
|
180
|
+
const { values } = await cappedEntries(receiptsDirectory, MAX_RECEIPT_FILES);
|
|
181
|
+
const receipts = [];
|
|
182
|
+
let totalBytes = 0;
|
|
183
|
+
for (const entry of values) {
|
|
184
|
+
if (entry.kind !== "file" || entry.bytes > MAX_RECEIPT_BYTES || totalBytes + entry.bytes > MAX_RECEIPT_TOTAL_BYTES)
|
|
185
|
+
continue;
|
|
186
|
+
// Per-file, so one unreadable or malformed receipt costs only its own labels.
|
|
187
|
+
// A shared try would abandon the loop and silently drop every receipt sorting
|
|
188
|
+
// after the bad one, quietly stripping metadata from datasets that are fine.
|
|
189
|
+
try {
|
|
190
|
+
receipts.push(JSON.parse(await readFile(hostJoin(receiptsDirectory, entry.name), "utf-8")));
|
|
191
|
+
totalBytes += entry.bytes;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return receipts;
|
|
198
|
+
}
|
|
199
|
+
/** Labels from a `registry.json` an externally-provisioned store may carry. */
|
|
200
|
+
async function readLegacyEntries(root) {
|
|
201
|
+
const legacyEntries = [];
|
|
202
|
+
try {
|
|
203
|
+
if (await crossesSymlink(root, "registry.json"))
|
|
204
|
+
return [];
|
|
205
|
+
const registryPath = hostJoin(root, "registry.json");
|
|
206
|
+
if ((await lstat(registryPath)).size > MAX_REGISTRY_BYTES)
|
|
207
|
+
return [];
|
|
208
|
+
const registry = JSON.parse(await readFile(registryPath, "utf-8"));
|
|
209
|
+
if (!registry || typeof registry !== "object")
|
|
210
|
+
return [];
|
|
211
|
+
const byCategory = registry.files?.by_category;
|
|
212
|
+
if (!byCategory || typeof byCategory !== "object")
|
|
213
|
+
return [];
|
|
214
|
+
for (const category of Object.keys(byCategory).sort()) {
|
|
215
|
+
const values = byCategory[category];
|
|
216
|
+
if (!Array.isArray(values))
|
|
217
|
+
continue;
|
|
218
|
+
for (const item of values) {
|
|
219
|
+
if (legacyEntries.length >= MAX_LEGACY_ENTRIES)
|
|
220
|
+
break;
|
|
221
|
+
if (item && typeof item === "object" && typeof item.local_path === "string") {
|
|
222
|
+
legacyEntries.push({ ...item, category });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return legacyEntries;
|
|
229
|
+
}
|
|
230
|
+
return legacyEntries;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Collect FILES anywhere beneath `directory`, bounded by the scan budget.
|
|
234
|
+
*
|
|
235
|
+
* Searching by meaning only works if it reaches the files that carry meaning. A
|
|
236
|
+
* dataset's labels are joined onto its artifact at
|
|
237
|
+
* `managed/{id}/{version}/{artifact}`, so a listing of the store root — whose only
|
|
238
|
+
* entries are directories, which carry no such labels — can never match "regulon".
|
|
239
|
+
* Browsing wants the directory summary; searching wants the leaves.
|
|
240
|
+
*/
|
|
241
|
+
async function collectFiles(directory, relative, budget) {
|
|
242
|
+
const entries = [];
|
|
243
|
+
let truncated = false;
|
|
244
|
+
const stack = [{ path: directory, relative }];
|
|
245
|
+
while (stack.length > 0 && budget.scanned < MAX_SCANNED_ENTRIES) {
|
|
246
|
+
const current = stack.pop();
|
|
247
|
+
const { values, cut } = await cappedEntries(current.path, MAX_SCANNED_ENTRIES - budget.scanned);
|
|
248
|
+
truncated = truncated || cut;
|
|
249
|
+
for (const child of values) {
|
|
250
|
+
budget.scanned += 1;
|
|
251
|
+
const childRelative = current.relative === "." ? child.name : `${current.relative}/${child.name}`;
|
|
252
|
+
// Installer metadata is not reference data — it never becomes a search hit.
|
|
253
|
+
if (childRelative === ".inflexa" || childRelative.startsWith(".inflexa/") || childRelative === "registry.json")
|
|
254
|
+
continue;
|
|
255
|
+
if (child.kind === "directory") {
|
|
256
|
+
stack.push({ path: hostJoin(current.path, child.name), relative: childRelative });
|
|
257
|
+
}
|
|
258
|
+
else if (child.kind === "file" || child.kind === "symlink") {
|
|
259
|
+
if (entries.length >= MAX_ENTRIES) {
|
|
260
|
+
truncated = true;
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
entries.push(child.kind === "symlink"
|
|
264
|
+
? { path: `${REFS_ROOT}/${childRelative}`, kind: "symlink", bytes: 0 }
|
|
265
|
+
: { path: `${REFS_ROOT}/${childRelative}`, kind: "file", bytes: child.bytes });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (budget.scanned >= MAX_SCANNED_ENTRIES) {
|
|
269
|
+
truncated = true;
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return { entries, truncated };
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Scan the store beneath `root`, reporting every expected state as data.
|
|
278
|
+
*
|
|
279
|
+
* `recursive` selects the two modes: a browse lists immediate children (directories
|
|
280
|
+
* summarized), a search walks to the files, because only a file carries the labels a
|
|
281
|
+
* search matches on.
|
|
282
|
+
*/
|
|
283
|
+
async function scanStore(root, relative, recursive = false) {
|
|
284
|
+
const empty = () => ({ entries: [], scannedEntries: 0, truncated: false });
|
|
285
|
+
if (!root)
|
|
286
|
+
return { state: "unavailable", ...empty() };
|
|
287
|
+
try {
|
|
288
|
+
if (!(await lstat(root)).isDirectory())
|
|
289
|
+
return { state: "unavailable", ...empty() };
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return { state: "unavailable", ...empty() };
|
|
293
|
+
}
|
|
294
|
+
if (relative !== "." && (await crossesSymlink(root, relative)))
|
|
295
|
+
return { state: "symlink", ...empty() };
|
|
296
|
+
const target = relative === "." ? root : hostJoin(root, ...relative.split("/"));
|
|
297
|
+
let targetStats;
|
|
298
|
+
try {
|
|
299
|
+
targetStats = await lstat(target);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return { state: "not_found", ...empty() };
|
|
303
|
+
}
|
|
304
|
+
if (!targetStats.isDirectory())
|
|
305
|
+
return { state: "not_a_directory", ...empty() };
|
|
306
|
+
if (recursive) {
|
|
307
|
+
const budget = { scanned: 0 };
|
|
308
|
+
const walked = await collectFiles(target, relative, budget);
|
|
309
|
+
return {
|
|
310
|
+
state: walked.entries.length === 0 ? "empty" : "populated",
|
|
311
|
+
entries: walked.entries,
|
|
312
|
+
scannedEntries: budget.scanned,
|
|
313
|
+
truncated: walked.truncated,
|
|
314
|
+
receipts: await readReceipts(root),
|
|
315
|
+
legacyEntries: await readLegacyEntries(root),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
const entries = [];
|
|
319
|
+
let scanned = 0;
|
|
320
|
+
let pathBytes = 0;
|
|
321
|
+
let truncated = false;
|
|
322
|
+
const { values: children, cut } = await cappedEntries(target, Math.min(MAX_ENTRIES + 2, MAX_SCANNED_ENTRIES));
|
|
323
|
+
truncated = cut;
|
|
324
|
+
for (const child of children) {
|
|
325
|
+
const childRelative = relative === "." ? child.name : `${relative}/${child.name}`;
|
|
326
|
+
if (childRelative === ".inflexa" || childRelative.startsWith(".inflexa/") || childRelative === "registry.json")
|
|
327
|
+
continue;
|
|
328
|
+
scanned += 1;
|
|
329
|
+
const rendered = `${REFS_ROOT}/${childRelative}`;
|
|
330
|
+
let entry;
|
|
331
|
+
if (child.kind === "symlink")
|
|
332
|
+
entry = { path: rendered, kind: "symlink", bytes: 0 };
|
|
333
|
+
else if (child.kind === "file")
|
|
334
|
+
entry = { path: rendered, kind: "file", bytes: child.bytes };
|
|
335
|
+
else if (child.kind === "directory") {
|
|
336
|
+
const summary = await summarizeDirectory(hostJoin(target, child.name), scanned);
|
|
337
|
+
scanned = summary.scanned;
|
|
338
|
+
truncated = truncated || summary.truncated;
|
|
339
|
+
entry = {
|
|
340
|
+
path: rendered,
|
|
341
|
+
kind: "directory",
|
|
342
|
+
bytes: summary.bytes,
|
|
343
|
+
fileCount: summary.fileCount,
|
|
344
|
+
fileTypes: summary.fileTypes,
|
|
345
|
+
truncated: summary.truncated,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
if (entry) {
|
|
349
|
+
if (entries.length < MAX_ENTRIES && pathBytes + Buffer.byteLength(rendered, "utf8") <= MAX_PATH_BYTES) {
|
|
350
|
+
entries.push(entry);
|
|
351
|
+
pathBytes += Buffer.byteLength(rendered, "utf8");
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
truncated = true;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (scanned >= MAX_SCANNED_ENTRIES) {
|
|
358
|
+
truncated = true;
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
state: entries.length === 0 ? "empty" : "populated",
|
|
364
|
+
entries,
|
|
365
|
+
scannedEntries: scanned,
|
|
366
|
+
truncated,
|
|
367
|
+
receipts: await readReceipts(root),
|
|
368
|
+
legacyEntries: await readLegacyEntries(root),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
301
371
|
function stringValue(value) {
|
|
302
372
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
303
373
|
}
|
|
@@ -312,6 +382,9 @@ function enrichEntries(entries, raw) {
|
|
|
312
382
|
continue;
|
|
313
383
|
const dataset = REFERENCE_DATA_CATALOG.datasets.find((candidate) => candidate.id === receipt.datasetId && candidate.version === receipt.datasetVersion);
|
|
314
384
|
for (const artifact of receipt.artifacts) {
|
|
385
|
+
// Per-artifact, not per-dataset: format and contents differ between files of the
|
|
386
|
+
// same dataset (a Seurat object and its Annoy index are read nothing alike).
|
|
387
|
+
const catalogArtifact = dataset?.artifacts.find((candidate) => candidate.path === artifact.path);
|
|
315
388
|
metadata.set(`${REFS_ROOT}/managed/${receipt.datasetId}/${receipt.datasetVersion}/${artifact.path}`, {
|
|
316
389
|
datasetId: receipt.datasetId,
|
|
317
390
|
version: receipt.datasetVersion,
|
|
@@ -321,8 +394,11 @@ function enrichEntries(entries, raw) {
|
|
|
321
394
|
description: dataset.description,
|
|
322
395
|
sourceUrl: dataset.sourceUrl,
|
|
323
396
|
license: dataset.license.url ?? dataset.license.identifier,
|
|
397
|
+
category: dataset.recommendation.group,
|
|
398
|
+
...(dataset.organism ? { organism: dataset.organism } : {}),
|
|
324
399
|
}
|
|
325
400
|
: {}),
|
|
401
|
+
...(catalogArtifact ? { format: catalogArtifact.format, contents: catalogArtifact.contents } : {}),
|
|
326
402
|
});
|
|
327
403
|
}
|
|
328
404
|
}
|
|
@@ -351,9 +427,23 @@ function enrichEntries(entries, raw) {
|
|
|
351
427
|
return extra ? { ...entry, metadata: extra } : entry;
|
|
352
428
|
});
|
|
353
429
|
}
|
|
354
|
-
/**
|
|
430
|
+
/**
|
|
431
|
+
* Case-insensitive substring match over an inventory entry's path and joined metadata labels.
|
|
432
|
+
* `description` and `contents` are in scope deliberately: a caller searching for what the data
|
|
433
|
+
* IS ("regulon", "tf-target") should hit a file whose name says only "CollecTRI".
|
|
434
|
+
*/
|
|
355
435
|
function entryMatchesQuery(entry, needle) {
|
|
356
|
-
const fields = [
|
|
436
|
+
const fields = [
|
|
437
|
+
entry.path,
|
|
438
|
+
entry.metadata?.datasetId,
|
|
439
|
+
entry.metadata?.title,
|
|
440
|
+
entry.metadata?.description,
|
|
441
|
+
entry.metadata?.category,
|
|
442
|
+
entry.metadata?.subtype,
|
|
443
|
+
entry.metadata?.organism,
|
|
444
|
+
entry.metadata?.format,
|
|
445
|
+
entry.metadata?.contents,
|
|
446
|
+
];
|
|
357
447
|
return fields.some((field) => typeof field === "string" && field.toLowerCase().includes(needle));
|
|
358
448
|
}
|
|
359
449
|
/** The drill-in index: directory groups present in the scan, plus any category labels joined from metadata. */
|
|
@@ -369,7 +459,8 @@ function collectCategories(entries) {
|
|
|
369
459
|
}
|
|
370
460
|
function renderContent(path, scan, entries) {
|
|
371
461
|
if (scan.state === "unavailable")
|
|
372
|
-
return "
|
|
462
|
+
return ("No reference store is provisioned. Nothing in the analysis environment can fetch reference data — the host stages it, " +
|
|
463
|
+
"so a store provisioned through the host's own path will show up on a later call.");
|
|
373
464
|
if (scan.state === "not_found")
|
|
374
465
|
return `Reference path does not exist: ${path}`;
|
|
375
466
|
if (scan.state === "not_a_directory")
|
|
@@ -377,7 +468,7 @@ function renderContent(path, scan, entries) {
|
|
|
377
468
|
if (scan.state === "symlink")
|
|
378
469
|
return `Reference path crosses a symlink and was not scanned: ${path}`;
|
|
379
470
|
if (scan.state === "empty")
|
|
380
|
-
return `Reference store path
|
|
471
|
+
return `Reference store path exists but contains no reference files: ${path}`;
|
|
381
472
|
const lines = [`Reference files visible beneath ${path}:`];
|
|
382
473
|
for (const entry of entries) {
|
|
383
474
|
const labels = entry.metadata
|
|
@@ -391,26 +482,36 @@ function renderContent(path, scan, entries) {
|
|
|
391
482
|
? `file, ${entry.bytes} bytes`
|
|
392
483
|
: "symlink (not followed)";
|
|
393
484
|
lines.push(`- ${entry.path} (${details}${labels ? `; ${labels}` : ""})`);
|
|
485
|
+
// The deciding line: whether this file is the thing you need, and how to open it.
|
|
486
|
+
// Without it a caller is back to inferring both from the filename.
|
|
487
|
+
const shape = [entry.metadata?.format, entry.metadata?.contents].filter(Boolean).join(" — ");
|
|
488
|
+
if (shape)
|
|
489
|
+
lines.push(` ${shape}`);
|
|
394
490
|
}
|
|
395
491
|
if (scan.truncated)
|
|
396
492
|
lines.push(`Inventory truncated after scanning ${scan.scannedEntries} entries. Call list_available_refs with a narrower path to drill down.`);
|
|
397
493
|
return lines.join("\n");
|
|
398
494
|
}
|
|
399
|
-
/** Create
|
|
495
|
+
/** Create reference discovery over the host-visible reference store. */
|
|
400
496
|
export function createListAvailableRefsTool(deps) {
|
|
401
497
|
return defineTool({
|
|
402
498
|
id: "list_available_refs",
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
"
|
|
499
|
+
description: "Resolve reference data you need into a real path. Reports the reference data provisioned for this environment right now — the only reference data an analysis script can open. " +
|
|
500
|
+
"Nothing inside the analysis environment reaches the network, so never write a download into a script. " +
|
|
501
|
+
"Search by what the data IS, not by where you expect it: the store's directory layout is an installer detail and is not a stable interface. " +
|
|
502
|
+
"Catalogued files come back with the organism, the format, and the file's internal shape (key columns, identifier space), so you can pick the right reader and the right species without opening the file first. " +
|
|
503
|
+
"User-added files are returned too, with whatever labels exist — often none. Symlinks are never followed. " +
|
|
504
|
+
"Paths are reported as the analysis environment sees them, so a returned path can be used verbatim in a script. " +
|
|
406
505
|
"Results are ALWAYS bounded — prefer a targeted query over a full dump: " +
|
|
506
|
+
'`query` is the main entry point, a case-insensitive substring filter over each entry\'s path and its dataset/title/category/organism/format/contents labels (e.g. "regulon", "hallmark", "mouse"); ' +
|
|
407
507
|
"`path` inspects one directory beneath /mnt/refs (a returned path, or store-relative) — drill in with it when a listing is truncated; " +
|
|
408
|
-
'`category` is shorthand for a top-level group (e.g. "
|
|
409
|
-
"`query` is a case-insensitive substring filter over each entry's path and its dataset/title/category/organism/subtype labels; " +
|
|
508
|
+
'`category` is shorthand for a top-level group (e.g. "pathways", "regulatory-networks"), and the groups present come back in `categories`; ' +
|
|
410
509
|
"`limit` caps the entries returned. The response carries `returned`, `total`, and `hasMore`, so truncation is never silent. " +
|
|
411
|
-
"
|
|
510
|
+
"An empty result means the dataset is absent right now — not that it is unobtainable. Say so; never guess a path, and never silently substitute a different dataset. " +
|
|
511
|
+
"The store is provisioned by the host and is NOT frozen: datasets can be added through the host's own provisioning path, and whatever is added shows up on a later call. " +
|
|
512
|
+
"So before you report a gap as permanent, check whether any tool you hold reaches that provisioning path — offer that route if one exists, and call the gap permanent only when none does.",
|
|
412
513
|
inputSchema: ListAvailableRefsInputSchema,
|
|
413
|
-
execute: async ({ path, category, query, limit }
|
|
514
|
+
execute: async ({ path, category, query, limit }) => {
|
|
414
515
|
// `category` is shorthand for a top-level path; an explicit `path` wins.
|
|
415
516
|
const requestedInput = path ?? category;
|
|
416
517
|
const requested = resolveRequestedPath(requestedInput);
|
|
@@ -430,33 +531,11 @@ export function createListAvailableRefsTool(deps) {
|
|
|
430
531
|
};
|
|
431
532
|
return ok(response);
|
|
432
533
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
command: [
|
|
439
|
-
"python3",
|
|
440
|
-
"-c",
|
|
441
|
-
SCAN_SCRIPT,
|
|
442
|
-
deps.scanRoot ?? REFS_ROOT,
|
|
443
|
-
posixPath.relative(REFS_ROOT, requested.path) || ".",
|
|
444
|
-
String(MAX_ENTRIES),
|
|
445
|
-
String(MAX_SCANNED_ENTRIES),
|
|
446
|
-
String(MAX_PATH_BYTES),
|
|
447
|
-
String(MAX_ENVELOPE_BYTES),
|
|
448
|
-
],
|
|
449
|
-
timeoutSeconds: 20,
|
|
450
|
-
deadlineMs: deps.deadlineMs(),
|
|
451
|
-
emit: ctx.emit,
|
|
452
|
-
...(deps.markExecActive ? { markExecActive: deps.markExecActive } : {}),
|
|
453
|
-
});
|
|
454
|
-
if (result.exitCode !== 0)
|
|
455
|
-
throw new Error(`Reference inventory command failed (${result.exitCode}): ${result.stderr}`);
|
|
456
|
-
if (Buffer.byteLength(result.stdout, "utf8") > MAX_ENVELOPE_BYTES + 1) {
|
|
457
|
-
throw new Error("Reference inventory response exceeded its output bound");
|
|
458
|
-
}
|
|
459
|
-
const raw = RawScanSchema.parse(JSON.parse(result.stdout));
|
|
534
|
+
// A query searches the leaves; a bare listing browses this level. Without the
|
|
535
|
+
// recursive pass a search from the store root only ever sees directories,
|
|
536
|
+
// which carry no dataset labels, so "find me a regulon network" finds nothing.
|
|
537
|
+
const needleGiven = (query?.trim() ?? "") !== "";
|
|
538
|
+
const raw = await scanStore(deps.refStorePath, posixPath.relative(REFS_ROOT, requested.path) || ".", needleGiven);
|
|
460
539
|
const enriched = enrichEntries(raw.entries, raw);
|
|
461
540
|
const categories = collectCategories(enriched);
|
|
462
541
|
const needle = query?.trim().toLowerCase();
|
|
@@ -495,166 +574,3 @@ export function createListAvailableRefsTool(deps) {
|
|
|
495
574
|
},
|
|
496
575
|
});
|
|
497
576
|
}
|
|
498
|
-
const CATEGORY_LABELS = {
|
|
499
|
-
atlas_singlecell: "Single-cell reference atlases for label transfer (Pan-Cancer T cell — " +
|
|
500
|
-
"Zheng 2021, Pan-Cancer Myeloid — Cheng 2021, Tumor Immune Cell Atlas — " +
|
|
501
|
-
"Nieto 2021, Tabula Sapiens v2). Available as .h5ad (Python: anndata, " +
|
|
502
|
-
"scanpy, scvi-tools, symphonypy) and/or .rds (R: Seurat, ProjecTILs). " +
|
|
503
|
-
"Tabula Sapiens ships full + immune_subset variants. " +
|
|
504
|
-
"Use these for label transfer BEFORE falling back to marker genes.",
|
|
505
|
-
atlas_azimuth: "Azimuth references (Seurat-native): PBMC v1.0.0 and Tonsil v1.0.0. " +
|
|
506
|
-
"Each entry is a (ref.Rds, idx.annoy) pair stored under " +
|
|
507
|
-
"/mnt/refs/atlas_azimuth/{pbmc,tonsil}/. Load with " +
|
|
508
|
-
"Azimuth::LoadReference(path) where path is the directory containing " +
|
|
509
|
-
"ref.Rds + idx.annoy (e.g. '/mnt/refs/atlas_azimuth/pbmc'), then " +
|
|
510
|
-
"Azimuth::RunAzimuth(query, reference = path).",
|
|
511
|
-
atlas_projectils: "ProjecTILs reference atlases (Seurat .rds): human + mouse, CD8 + CD4. " +
|
|
512
|
-
"Use ProjecTILs::ProjecTILs.classifier() to assign every CD8 to one of " +
|
|
513
|
-
"the canonical TCF7+/GZMK+/TEX/TPEX/MAIT substates without re-deriving " +
|
|
514
|
-
"the classifier from scratch.",
|
|
515
|
-
celltypist_models: "Pre-staged CellTypist .pkl classifier models (Immune_All_Low backed by " +
|
|
516
|
-
"the Cross-Tissue Immune Cell Atlas; Immune_All_High; Pan_Fetal_Human; " +
|
|
517
|
-
"COVID19_Immune_Landscape). CELLTYPIST_FOLDER env var is preset, so " +
|
|
518
|
-
"celltypist.annotate_cells(model='Immune_All_Low') resolves with no " +
|
|
519
|
-
"network call.",
|
|
520
|
-
marker_panels: "Marker gene panels: PanglaoDB (community, parquet long-format), " +
|
|
521
|
-
"CellMarker 2.0 (tumor-context, parquet long-format), and the Cortex " +
|
|
522
|
-
"hand-curated panel for Stage 1 substates (TCF7+ memory CD8, exhausted " +
|
|
523
|
-
"CD8, effector-memory CD8, proliferating T, cDC1/cDC2/LAMP3+ DC, SPP1+ " +
|
|
524
|
-
"TAM, C1Q+ TAM, Tregs) — JSON with full provenance.",
|
|
525
|
-
gene_signatures: "Curated gene signatures from key tumor-immune papers: Sade-Feldman " +
|
|
526
|
-
"2018 exhausted CD8 (Table S2), Jerby-Arnon 2018 malignant resistance " +
|
|
527
|
-
"program (Table S1, Stage 3 classifier substrate), Tirosh 2016 AXL/MITF " +
|
|
528
|
-
"programs, Puram 2017 p-EMT (Table S5, HNSCC), Reactome cGAS-STING " +
|
|
529
|
-
"pathway (Stage 2.3 type-I IFN). JSON with PMID/DOI per entry.",
|
|
530
|
-
normal_reference: "Normal-tissue references for safety firing checks: GTEx v8 per-tissue " +
|
|
531
|
-
"median TPM (parquet) + sample attributes (parquet). Use to quantify " +
|
|
532
|
-
"off-target activation of any nominated classifier across healthy tissues.",
|
|
533
|
-
gene_mappings: "Gene ID Conversion Tables: Entrez/Ensembl/RefSeq/UniProt to symbol (NCBI); " +
|
|
534
|
-
"orthologs (Ensembl Compara) — file: orthologs_{tax_id}.parquet, columns: " +
|
|
535
|
-
"tax_id, ensembl_gene_id, entrez_id, symbol, relationship, other_tax_id, " +
|
|
536
|
-
"other_ensembl_gene_id, other_entrez_id, other_symbol; symmetric — to find " +
|
|
537
|
-
"human orthologs of Macaca fascicularis genes, open orthologs_9541.parquet " +
|
|
538
|
-
"and filter other_tax_id == '9606'; cross-division (e.g. yeast↔mouse) " +
|
|
539
|
-
"requires pivoting through human: yeast→human via orthologs_4896.parquet, " +
|
|
540
|
-
"then human→mouse via orthologs_9606.parquet",
|
|
541
|
-
omnipath: "OmniPath (interactions, regulons, annotations)",
|
|
542
|
-
reactome: "Reactome Pathways",
|
|
543
|
-
progeny: "PROGENy Pathway Weights (decoupler: source/target/weight/padj)",
|
|
544
|
-
collectri: "CollecTRI TF-Target Regulons (decoupler: source/target/mor)",
|
|
545
|
-
dorothea: "DoRothEA TF Regulons (decoupler: source/target/weight/confidence)",
|
|
546
|
-
lincs: "LINCS L1000 Drug Perturbation Consensus Signatures (up/down gene sets)",
|
|
547
|
-
hpa: "Human Protein Atlas (tissue RNA expression, target safety/secretome, full 107-column atlas)",
|
|
548
|
-
wikipathways: "WikiPathways Gene Sets",
|
|
549
|
-
msigdb: "MSigDB Gene Set Collections",
|
|
550
|
-
safety_targets: "Curated Off-Target Safety Panel (CSV + JSON: chembl_id, gene_symbol, uniprot, organ_system, severity, clinical_consequence)",
|
|
551
|
-
};
|
|
552
|
-
const CATEGORY_ORDER = [
|
|
553
|
-
"atlas_singlecell",
|
|
554
|
-
"atlas_azimuth",
|
|
555
|
-
"atlas_projectils",
|
|
556
|
-
"celltypist_models",
|
|
557
|
-
"marker_panels",
|
|
558
|
-
"gene_signatures",
|
|
559
|
-
"normal_reference",
|
|
560
|
-
"safety_targets",
|
|
561
|
-
"progeny",
|
|
562
|
-
"collectri",
|
|
563
|
-
"dorothea",
|
|
564
|
-
"omnipath",
|
|
565
|
-
"lincs",
|
|
566
|
-
"msigdb",
|
|
567
|
-
"wikipathways",
|
|
568
|
-
"reactome",
|
|
569
|
-
"hpa",
|
|
570
|
-
"gene_mappings",
|
|
571
|
-
];
|
|
572
|
-
/** The registry's categories in canonical order, unknown ones appended. */
|
|
573
|
-
function orderedCategories(registry) {
|
|
574
|
-
const byCategory = registry.files.by_category;
|
|
575
|
-
const known = new Set(CATEGORY_ORDER);
|
|
576
|
-
return [...CATEGORY_ORDER.filter((c) => byCategory[c]), ...Object.keys(byCategory).filter((c) => !known.has(c))];
|
|
577
|
-
}
|
|
578
|
-
/** Case-insensitive substring match over the descriptive fields the registry carries. */
|
|
579
|
-
function matchesQuery(category, entry, needle) {
|
|
580
|
-
const haystack = [category, entry.local_path, entry.dataset, entry.organism, entry.subtype, entry.tax_id].filter((v) => typeof v === "string" && v.length > 0);
|
|
581
|
-
return haystack.some((field) => field.toLowerCase().includes(needle));
|
|
582
|
-
}
|
|
583
|
-
/** One file line: `name: /mnt/refs/<local_path> (rows, subtype, dataset)`. */
|
|
584
|
-
function renderEntry(entry) {
|
|
585
|
-
const path = join(REFS_ROOT, entry.local_path);
|
|
586
|
-
const name = basename(entry.local_path);
|
|
587
|
-
const info = [entry.rows != null ? `${entry.rows.toLocaleString()} rows` : null, entry.subtype, entry.dataset].filter(Boolean).join(", ");
|
|
588
|
-
return ` ${name}: ${path}${info ? ` (${info})` : ""}`;
|
|
589
|
-
}
|
|
590
|
-
/**
|
|
591
|
-
* Answer a refs query against a parsed registry. Pure — the tool's `execute` is
|
|
592
|
-
* only the file read plus this call.
|
|
593
|
-
*/
|
|
594
|
-
export function queryRefs(registry, { category, query, limit }) {
|
|
595
|
-
const categories = orderedCategories(registry);
|
|
596
|
-
// An unknown category is a caller mistake, not a failure — name the valid set
|
|
597
|
-
// rather than returning an empty listing with no explanation.
|
|
598
|
-
if (category && !categories.includes(category)) {
|
|
599
|
-
return {
|
|
600
|
-
available: true,
|
|
601
|
-
total: 0,
|
|
602
|
-
returned: 0,
|
|
603
|
-
hasMore: false,
|
|
604
|
-
categories,
|
|
605
|
-
content: `Unknown category "${category}". Valid categories: ${categories.join(", ")}.`,
|
|
606
|
-
};
|
|
607
|
-
}
|
|
608
|
-
const needle = query?.trim().toLowerCase();
|
|
609
|
-
const selected = category ? [category] : categories;
|
|
610
|
-
// Filter first so `total` is the honest count of matching files, then bound.
|
|
611
|
-
const matched = selected.map((c) => {
|
|
612
|
-
const entries = registry.files.by_category[c] ?? [];
|
|
613
|
-
return { category: c, entries: needle ? entries.filter((e) => matchesQuery(c, e, needle)) : entries };
|
|
614
|
-
});
|
|
615
|
-
const total = matched.reduce((n, m) => n + m.entries.length, 0);
|
|
616
|
-
if (total === 0) {
|
|
617
|
-
const scope = [category ? `category: ${category}` : null, query ? `query: "${query}"` : null].filter(Boolean).join(", ");
|
|
618
|
-
return {
|
|
619
|
-
available: true,
|
|
620
|
-
total: 0,
|
|
621
|
-
returned: 0,
|
|
622
|
-
hasMore: false,
|
|
623
|
-
categories,
|
|
624
|
-
content: `No reference files match this filter${scope ? ` (${scope})` : ""}. Nothing can be downloaded at runtime.`,
|
|
625
|
-
};
|
|
626
|
-
}
|
|
627
|
-
const cap = limit ?? DEFAULT_LIMIT;
|
|
628
|
-
const lines = [];
|
|
629
|
-
let returned = 0;
|
|
630
|
-
for (const { category: c, entries } of matched) {
|
|
631
|
-
if (entries.length === 0)
|
|
632
|
-
continue;
|
|
633
|
-
lines.push(`\n## ${CATEGORY_LABELS[c] ?? c}`);
|
|
634
|
-
const room = cap - returned;
|
|
635
|
-
if (room <= 0) {
|
|
636
|
-
lines.push(` ${entries.length} file(s) — not shown (limit reached). Call with category: "${c}" to list them.`);
|
|
637
|
-
continue;
|
|
638
|
-
}
|
|
639
|
-
const shown = entries.slice(0, room);
|
|
640
|
-
returned += shown.length;
|
|
641
|
-
for (const entry of shown)
|
|
642
|
-
lines.push(renderEntry(entry));
|
|
643
|
-
const hidden = entries.length - shown.length;
|
|
644
|
-
if (hidden > 0)
|
|
645
|
-
lines.push(` … and ${hidden} more in this category — call with category: "${c}" (and a higher \`limit\`) to list them.`);
|
|
646
|
-
}
|
|
647
|
-
const header = [
|
|
648
|
-
`Reference Store (build ${registry.build_id}, ${registry.generated_at})`,
|
|
649
|
-
`Mount: ${REFS_ROOT}`,
|
|
650
|
-
`Matching files: ${total} (showing ${returned})`,
|
|
651
|
-
].join("\n");
|
|
652
|
-
return {
|
|
653
|
-
available: true,
|
|
654
|
-
total,
|
|
655
|
-
returned,
|
|
656
|
-
hasMore: returned < total,
|
|
657
|
-
categories,
|
|
658
|
-
content: header + "\n" + lines.join("\n"),
|
|
659
|
-
};
|
|
660
|
-
}
|