@inflexa-ai/harness 0.6.0 → 0.7.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/conversation-agent.d.ts +7 -0
- package/dist/agents/conversation-agent.js +9 -2
- package/dist/agents/sandbox/shared.d.ts +12 -0
- package/dist/agents/sandbox/shared.js +3 -11
- package/dist/execution/ephemeral-runner.d.ts +12 -0
- package/dist/execution/ephemeral-runner.js +2 -0
- package/dist/prompts/planner.js +26 -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 +12 -0
- package/dist/tasks/data-profile.js +2 -0
- package/dist/tools/research/generate-plan.d.ts +7 -0
- package/dist/tools/research/generate-plan.js +8 -3
- package/dist/tools/sandbox/list-available-packages.d.ts +16 -3
- package/dist/tools/sandbox/list-available-packages.js +72 -38
- package/dist/tools/sandbox/list-available-refs.d.ts +31 -75
- package/dist/tools/sandbox/list-available-refs.js +352 -443
- package/package.json +1 -1
|
@@ -1,239 +1,44 @@
|
|
|
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; nothing can be downloaded at runtime.
|
|
4
8
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* The store is read from the host filesystem at the embedder-supplied
|
|
10
|
+
* `refStorePath` and reported at `/mnt/refs`, the container path sandboxes mount
|
|
11
|
+
* it at — read where it lives, render where the caller will open it. Reading it
|
|
12
|
+
* host-side is what lets a planner ask what reference data exists before any
|
|
13
|
+
* sandbox is created; a step agent asking mid-run sees the same store, because
|
|
14
|
+
* the mount and this path are the same bytes.
|
|
15
|
+
*
|
|
16
|
+
* The scan reports paths; meaning is joined on afterwards from the install
|
|
17
|
+
* receipt and the catalog it names (organism, format, and each file's internal
|
|
18
|
+
* shape). Callers search by what the data IS, so the store's directory layout
|
|
19
|
+
* stays an installer detail rather than an interface anything encodes. Metadata
|
|
20
|
+
* only ever ENRICHES an entry — a file with no receipt and no catalog match is
|
|
21
|
+
* still returned, because a user-provided store is as valid as a managed one.
|
|
14
22
|
*/
|
|
15
|
-
import {
|
|
23
|
+
import { lstat, readFile, readdir } from "node:fs/promises";
|
|
24
|
+
import { join as hostJoin, posix as posixPath } from "node:path";
|
|
16
25
|
import { ok } from "neverthrow";
|
|
17
26
|
import { z } from "zod";
|
|
18
27
|
import { REFERENCE_DATA_CATALOG } from "../../reference-data/catalog.js";
|
|
19
28
|
import { parseReferenceInstallReceipt } from "../../reference-data/receipt.js";
|
|
20
29
|
import { defineTool } from "../define-tool.js";
|
|
21
|
-
|
|
30
|
+
/** Container path the ref store is mounted at; every reported path is rendered under it. */
|
|
22
31
|
const REFS_ROOT = "/mnt/refs";
|
|
23
32
|
const MAX_ENTRIES = 200;
|
|
24
33
|
const MAX_SCANNED_ENTRIES = 2_000;
|
|
25
34
|
const MAX_PATH_BYTES = 40_000;
|
|
26
35
|
const MAX_ENVELOPE_BYTES = 64_000;
|
|
27
36
|
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
|
-
`;
|
|
37
|
+
const MAX_RECEIPT_FILES = 100;
|
|
38
|
+
const MAX_RECEIPT_BYTES = 16_384;
|
|
39
|
+
const MAX_RECEIPT_TOTAL_BYTES = 32_768;
|
|
40
|
+
const MAX_REGISTRY_BYTES = 131_072;
|
|
41
|
+
const MAX_LEGACY_ENTRIES = 200;
|
|
237
42
|
const ListAvailableRefsInputSchema = z.object({
|
|
238
43
|
path: z
|
|
239
44
|
.string()
|
|
@@ -257,25 +62,6 @@ const ListAvailableRefsInputSchema = z.object({
|
|
|
257
62
|
.optional()
|
|
258
63
|
.describe(`Max entries to return (default ${MAX_ENTRIES}); the response carries \`returned\`, \`total\`, and \`hasMore\` so truncation is never silent.`),
|
|
259
64
|
});
|
|
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
65
|
function resolveRequestedPath(input) {
|
|
280
66
|
if (input === undefined || input === "" || input === REFS_ROOT)
|
|
281
67
|
return { ok: true, path: REFS_ROOT };
|
|
@@ -298,6 +84,287 @@ function resolveRequestedPath(input) {
|
|
|
298
84
|
return { ok: false, reason: "Path is outside /mnt/refs." };
|
|
299
85
|
return { ok: true, path: normalized };
|
|
300
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Inspect at most `limit` names in one directory, sorted by name.
|
|
89
|
+
*
|
|
90
|
+
* Sorting BEFORE the cap (rather than capping the raw readdir order) keeps
|
|
91
|
+
* truncation deterministic: the same store yields the same prefix on every call,
|
|
92
|
+
* which a durably-cached step result has to be able to promise.
|
|
93
|
+
*/
|
|
94
|
+
async function cappedEntries(directory, limit) {
|
|
95
|
+
let names;
|
|
96
|
+
try {
|
|
97
|
+
names = (await readdir(directory, { withFileTypes: true })).map((entry) => entry.name);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { values: [], cut: false };
|
|
101
|
+
}
|
|
102
|
+
names.sort();
|
|
103
|
+
const cut = names.length > limit;
|
|
104
|
+
const values = [];
|
|
105
|
+
for (const name of names.slice(0, limit)) {
|
|
106
|
+
try {
|
|
107
|
+
// lstat, never stat: a symlink is reported as a symlink and never resolved,
|
|
108
|
+
// so a link out of the store cannot smuggle in a foreign file's size or type.
|
|
109
|
+
const stats = await lstat(hostJoin(directory, name));
|
|
110
|
+
if (stats.isSymbolicLink())
|
|
111
|
+
values.push({ name, kind: "symlink", bytes: 0 });
|
|
112
|
+
else if (stats.isFile())
|
|
113
|
+
values.push({ name, kind: "file", bytes: stats.size });
|
|
114
|
+
else if (stats.isDirectory())
|
|
115
|
+
values.push({ name, kind: "directory", bytes: 0 });
|
|
116
|
+
else
|
|
117
|
+
values.push({ name, kind: "other", bytes: 0 });
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { values, cut };
|
|
124
|
+
}
|
|
125
|
+
/** Whether any component of `relative` beneath `root` is a symlink — the no-follow gate. */
|
|
126
|
+
async function crossesSymlink(root, relative) {
|
|
127
|
+
let cursor = root;
|
|
128
|
+
for (const segment of relative.split("/")) {
|
|
129
|
+
cursor = hostJoin(cursor, segment);
|
|
130
|
+
try {
|
|
131
|
+
if ((await lstat(cursor)).isSymbolicLink())
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
/** Recursively total a directory's files, bounded by the remaining scan budget. */
|
|
141
|
+
async function summarizeDirectory(directory, scanned) {
|
|
142
|
+
let totalBytes = 0;
|
|
143
|
+
let fileCount = 0;
|
|
144
|
+
let truncated = false;
|
|
145
|
+
const fileTypes = new Set();
|
|
146
|
+
const stack = [directory];
|
|
147
|
+
while (stack.length > 0 && scanned < MAX_SCANNED_ENTRIES) {
|
|
148
|
+
const current = stack.pop();
|
|
149
|
+
const { values, cut } = await cappedEntries(current, MAX_SCANNED_ENTRIES - scanned);
|
|
150
|
+
truncated = truncated || cut;
|
|
151
|
+
const childDirectories = [];
|
|
152
|
+
for (const child of values) {
|
|
153
|
+
scanned += 1;
|
|
154
|
+
if (child.kind === "directory")
|
|
155
|
+
childDirectories.push(hostJoin(current, child.name));
|
|
156
|
+
else if (child.kind === "file") {
|
|
157
|
+
fileCount += 1;
|
|
158
|
+
totalBytes += child.bytes;
|
|
159
|
+
const suffix = posixPath.extname(child.name).toLowerCase();
|
|
160
|
+
if (suffix)
|
|
161
|
+
fileTypes.add(suffix);
|
|
162
|
+
}
|
|
163
|
+
if (scanned >= MAX_SCANNED_ENTRIES) {
|
|
164
|
+
truncated = true;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
stack.push(...childDirectories.reverse());
|
|
169
|
+
}
|
|
170
|
+
return { bytes: totalBytes, fileCount, fileTypes: [...fileTypes].sort().slice(0, 10), truncated, scanned };
|
|
171
|
+
}
|
|
172
|
+
/** Install receipts written by the host installer under `<root>/.inflexa/receipts`. */
|
|
173
|
+
async function readReceipts(root) {
|
|
174
|
+
const receiptsDirectory = hostJoin(root, ".inflexa", "receipts");
|
|
175
|
+
if (await crossesSymlink(root, ".inflexa"))
|
|
176
|
+
return [];
|
|
177
|
+
const { values } = await cappedEntries(receiptsDirectory, MAX_RECEIPT_FILES);
|
|
178
|
+
const receipts = [];
|
|
179
|
+
let totalBytes = 0;
|
|
180
|
+
for (const entry of values) {
|
|
181
|
+
if (entry.kind !== "file" || entry.bytes > MAX_RECEIPT_BYTES || totalBytes + entry.bytes > MAX_RECEIPT_TOTAL_BYTES)
|
|
182
|
+
continue;
|
|
183
|
+
// Per-file, so one unreadable or malformed receipt costs only its own labels.
|
|
184
|
+
// A shared try would abandon the loop and silently drop every receipt sorting
|
|
185
|
+
// after the bad one, quietly stripping metadata from datasets that are fine.
|
|
186
|
+
try {
|
|
187
|
+
receipts.push(JSON.parse(await readFile(hostJoin(receiptsDirectory, entry.name), "utf-8")));
|
|
188
|
+
totalBytes += entry.bytes;
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return receipts;
|
|
195
|
+
}
|
|
196
|
+
/** Labels from a `registry.json` an externally-provisioned store may carry. */
|
|
197
|
+
async function readLegacyEntries(root) {
|
|
198
|
+
const legacyEntries = [];
|
|
199
|
+
try {
|
|
200
|
+
if (await crossesSymlink(root, "registry.json"))
|
|
201
|
+
return [];
|
|
202
|
+
const registryPath = hostJoin(root, "registry.json");
|
|
203
|
+
if ((await lstat(registryPath)).size > MAX_REGISTRY_BYTES)
|
|
204
|
+
return [];
|
|
205
|
+
const registry = JSON.parse(await readFile(registryPath, "utf-8"));
|
|
206
|
+
if (!registry || typeof registry !== "object")
|
|
207
|
+
return [];
|
|
208
|
+
const byCategory = registry.files?.by_category;
|
|
209
|
+
if (!byCategory || typeof byCategory !== "object")
|
|
210
|
+
return [];
|
|
211
|
+
for (const category of Object.keys(byCategory).sort()) {
|
|
212
|
+
const values = byCategory[category];
|
|
213
|
+
if (!Array.isArray(values))
|
|
214
|
+
continue;
|
|
215
|
+
for (const item of values) {
|
|
216
|
+
if (legacyEntries.length >= MAX_LEGACY_ENTRIES)
|
|
217
|
+
break;
|
|
218
|
+
if (item && typeof item === "object" && typeof item.local_path === "string") {
|
|
219
|
+
legacyEntries.push({ ...item, category });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return legacyEntries;
|
|
226
|
+
}
|
|
227
|
+
return legacyEntries;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Collect FILES anywhere beneath `directory`, bounded by the scan budget.
|
|
231
|
+
*
|
|
232
|
+
* Searching by meaning only works if it reaches the files that carry meaning. A
|
|
233
|
+
* dataset's labels are joined onto its artifact at
|
|
234
|
+
* `managed/{id}/{version}/{artifact}`, so a listing of the store root — whose only
|
|
235
|
+
* entries are directories, which carry no such labels — can never match "regulon".
|
|
236
|
+
* Browsing wants the directory summary; searching wants the leaves.
|
|
237
|
+
*/
|
|
238
|
+
async function collectFiles(directory, relative, budget) {
|
|
239
|
+
const entries = [];
|
|
240
|
+
let truncated = false;
|
|
241
|
+
const stack = [{ path: directory, relative }];
|
|
242
|
+
while (stack.length > 0 && budget.scanned < MAX_SCANNED_ENTRIES) {
|
|
243
|
+
const current = stack.pop();
|
|
244
|
+
const { values, cut } = await cappedEntries(current.path, MAX_SCANNED_ENTRIES - budget.scanned);
|
|
245
|
+
truncated = truncated || cut;
|
|
246
|
+
for (const child of values) {
|
|
247
|
+
budget.scanned += 1;
|
|
248
|
+
const childRelative = current.relative === "." ? child.name : `${current.relative}/${child.name}`;
|
|
249
|
+
// Installer metadata is not reference data — it never becomes a search hit.
|
|
250
|
+
if (childRelative === ".inflexa" || childRelative.startsWith(".inflexa/") || childRelative === "registry.json")
|
|
251
|
+
continue;
|
|
252
|
+
if (child.kind === "directory") {
|
|
253
|
+
stack.push({ path: hostJoin(current.path, child.name), relative: childRelative });
|
|
254
|
+
}
|
|
255
|
+
else if (child.kind === "file" || child.kind === "symlink") {
|
|
256
|
+
if (entries.length >= MAX_ENTRIES) {
|
|
257
|
+
truncated = true;
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
entries.push(child.kind === "symlink"
|
|
261
|
+
? { path: `${REFS_ROOT}/${childRelative}`, kind: "symlink", bytes: 0 }
|
|
262
|
+
: { path: `${REFS_ROOT}/${childRelative}`, kind: "file", bytes: child.bytes });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (budget.scanned >= MAX_SCANNED_ENTRIES) {
|
|
266
|
+
truncated = true;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return { entries, truncated };
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Scan the store beneath `root`, reporting every expected state as data.
|
|
275
|
+
*
|
|
276
|
+
* `recursive` selects the two modes: a browse lists immediate children (directories
|
|
277
|
+
* summarized), a search walks to the files, because only a file carries the labels a
|
|
278
|
+
* search matches on.
|
|
279
|
+
*/
|
|
280
|
+
async function scanStore(root, relative, recursive = false) {
|
|
281
|
+
const empty = () => ({ entries: [], scannedEntries: 0, truncated: false });
|
|
282
|
+
if (!root)
|
|
283
|
+
return { state: "unavailable", ...empty() };
|
|
284
|
+
try {
|
|
285
|
+
if (!(await lstat(root)).isDirectory())
|
|
286
|
+
return { state: "unavailable", ...empty() };
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return { state: "unavailable", ...empty() };
|
|
290
|
+
}
|
|
291
|
+
if (relative !== "." && (await crossesSymlink(root, relative)))
|
|
292
|
+
return { state: "symlink", ...empty() };
|
|
293
|
+
const target = relative === "." ? root : hostJoin(root, ...relative.split("/"));
|
|
294
|
+
let targetStats;
|
|
295
|
+
try {
|
|
296
|
+
targetStats = await lstat(target);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return { state: "not_found", ...empty() };
|
|
300
|
+
}
|
|
301
|
+
if (!targetStats.isDirectory())
|
|
302
|
+
return { state: "not_a_directory", ...empty() };
|
|
303
|
+
if (recursive) {
|
|
304
|
+
const budget = { scanned: 0 };
|
|
305
|
+
const walked = await collectFiles(target, relative, budget);
|
|
306
|
+
return {
|
|
307
|
+
state: walked.entries.length === 0 ? "empty" : "populated",
|
|
308
|
+
entries: walked.entries,
|
|
309
|
+
scannedEntries: budget.scanned,
|
|
310
|
+
truncated: walked.truncated,
|
|
311
|
+
receipts: await readReceipts(root),
|
|
312
|
+
legacyEntries: await readLegacyEntries(root),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const entries = [];
|
|
316
|
+
let scanned = 0;
|
|
317
|
+
let pathBytes = 0;
|
|
318
|
+
let truncated = false;
|
|
319
|
+
const { values: children, cut } = await cappedEntries(target, Math.min(MAX_ENTRIES + 2, MAX_SCANNED_ENTRIES));
|
|
320
|
+
truncated = cut;
|
|
321
|
+
for (const child of children) {
|
|
322
|
+
const childRelative = relative === "." ? child.name : `${relative}/${child.name}`;
|
|
323
|
+
if (childRelative === ".inflexa" || childRelative.startsWith(".inflexa/") || childRelative === "registry.json")
|
|
324
|
+
continue;
|
|
325
|
+
scanned += 1;
|
|
326
|
+
const rendered = `${REFS_ROOT}/${childRelative}`;
|
|
327
|
+
let entry;
|
|
328
|
+
if (child.kind === "symlink")
|
|
329
|
+
entry = { path: rendered, kind: "symlink", bytes: 0 };
|
|
330
|
+
else if (child.kind === "file")
|
|
331
|
+
entry = { path: rendered, kind: "file", bytes: child.bytes };
|
|
332
|
+
else if (child.kind === "directory") {
|
|
333
|
+
const summary = await summarizeDirectory(hostJoin(target, child.name), scanned);
|
|
334
|
+
scanned = summary.scanned;
|
|
335
|
+
truncated = truncated || summary.truncated;
|
|
336
|
+
entry = {
|
|
337
|
+
path: rendered,
|
|
338
|
+
kind: "directory",
|
|
339
|
+
bytes: summary.bytes,
|
|
340
|
+
fileCount: summary.fileCount,
|
|
341
|
+
fileTypes: summary.fileTypes,
|
|
342
|
+
truncated: summary.truncated,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
if (entry) {
|
|
346
|
+
if (entries.length < MAX_ENTRIES && pathBytes + Buffer.byteLength(rendered, "utf8") <= MAX_PATH_BYTES) {
|
|
347
|
+
entries.push(entry);
|
|
348
|
+
pathBytes += Buffer.byteLength(rendered, "utf8");
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
truncated = true;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (scanned >= MAX_SCANNED_ENTRIES) {
|
|
355
|
+
truncated = true;
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
state: entries.length === 0 ? "empty" : "populated",
|
|
361
|
+
entries,
|
|
362
|
+
scannedEntries: scanned,
|
|
363
|
+
truncated,
|
|
364
|
+
receipts: await readReceipts(root),
|
|
365
|
+
legacyEntries: await readLegacyEntries(root),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
301
368
|
function stringValue(value) {
|
|
302
369
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
303
370
|
}
|
|
@@ -312,6 +379,9 @@ function enrichEntries(entries, raw) {
|
|
|
312
379
|
continue;
|
|
313
380
|
const dataset = REFERENCE_DATA_CATALOG.datasets.find((candidate) => candidate.id === receipt.datasetId && candidate.version === receipt.datasetVersion);
|
|
314
381
|
for (const artifact of receipt.artifacts) {
|
|
382
|
+
// Per-artifact, not per-dataset: format and contents differ between files of the
|
|
383
|
+
// same dataset (a Seurat object and its Annoy index are read nothing alike).
|
|
384
|
+
const catalogArtifact = dataset?.artifacts.find((candidate) => candidate.path === artifact.path);
|
|
315
385
|
metadata.set(`${REFS_ROOT}/managed/${receipt.datasetId}/${receipt.datasetVersion}/${artifact.path}`, {
|
|
316
386
|
datasetId: receipt.datasetId,
|
|
317
387
|
version: receipt.datasetVersion,
|
|
@@ -321,8 +391,11 @@ function enrichEntries(entries, raw) {
|
|
|
321
391
|
description: dataset.description,
|
|
322
392
|
sourceUrl: dataset.sourceUrl,
|
|
323
393
|
license: dataset.license.url ?? dataset.license.identifier,
|
|
394
|
+
category: dataset.recommendation.group,
|
|
395
|
+
...(dataset.organism ? { organism: dataset.organism } : {}),
|
|
324
396
|
}
|
|
325
397
|
: {}),
|
|
398
|
+
...(catalogArtifact ? { format: catalogArtifact.format, contents: catalogArtifact.contents } : {}),
|
|
326
399
|
});
|
|
327
400
|
}
|
|
328
401
|
}
|
|
@@ -351,9 +424,23 @@ function enrichEntries(entries, raw) {
|
|
|
351
424
|
return extra ? { ...entry, metadata: extra } : entry;
|
|
352
425
|
});
|
|
353
426
|
}
|
|
354
|
-
/**
|
|
427
|
+
/**
|
|
428
|
+
* Case-insensitive substring match over an inventory entry's path and joined metadata labels.
|
|
429
|
+
* `description` and `contents` are in scope deliberately: a caller searching for what the data
|
|
430
|
+
* IS ("regulon", "tf-target") should hit a file whose name says only "CollecTRI".
|
|
431
|
+
*/
|
|
355
432
|
function entryMatchesQuery(entry, needle) {
|
|
356
|
-
const fields = [
|
|
433
|
+
const fields = [
|
|
434
|
+
entry.path,
|
|
435
|
+
entry.metadata?.datasetId,
|
|
436
|
+
entry.metadata?.title,
|
|
437
|
+
entry.metadata?.description,
|
|
438
|
+
entry.metadata?.category,
|
|
439
|
+
entry.metadata?.subtype,
|
|
440
|
+
entry.metadata?.organism,
|
|
441
|
+
entry.metadata?.format,
|
|
442
|
+
entry.metadata?.contents,
|
|
443
|
+
];
|
|
357
444
|
return fields.some((field) => typeof field === "string" && field.toLowerCase().includes(needle));
|
|
358
445
|
}
|
|
359
446
|
/** The drill-in index: directory groups present in the scan, plus any category labels joined from metadata. */
|
|
@@ -369,7 +456,7 @@ function collectCategories(entries) {
|
|
|
369
456
|
}
|
|
370
457
|
function renderContent(path, scan, entries) {
|
|
371
458
|
if (scan.state === "unavailable")
|
|
372
|
-
return "
|
|
459
|
+
return "No reference store is provisioned. Reference data cannot be downloaded at runtime; provision it through the host setup flow.";
|
|
373
460
|
if (scan.state === "not_found")
|
|
374
461
|
return `Reference path does not exist: ${path}`;
|
|
375
462
|
if (scan.state === "not_a_directory")
|
|
@@ -377,7 +464,7 @@ function renderContent(path, scan, entries) {
|
|
|
377
464
|
if (scan.state === "symlink")
|
|
378
465
|
return `Reference path crosses a symlink and was not scanned: ${path}`;
|
|
379
466
|
if (scan.state === "empty")
|
|
380
|
-
return `Reference store path
|
|
467
|
+
return `Reference store path exists but contains no reference files: ${path}`;
|
|
381
468
|
const lines = [`Reference files visible beneath ${path}:`];
|
|
382
469
|
for (const entry of entries) {
|
|
383
470
|
const labels = entry.metadata
|
|
@@ -391,26 +478,33 @@ function renderContent(path, scan, entries) {
|
|
|
391
478
|
? `file, ${entry.bytes} bytes`
|
|
392
479
|
: "symlink (not followed)";
|
|
393
480
|
lines.push(`- ${entry.path} (${details}${labels ? `; ${labels}` : ""})`);
|
|
481
|
+
// The deciding line: whether this file is the thing you need, and how to open it.
|
|
482
|
+
// Without it a caller is back to inferring both from the filename.
|
|
483
|
+
const shape = [entry.metadata?.format, entry.metadata?.contents].filter(Boolean).join(" — ");
|
|
484
|
+
if (shape)
|
|
485
|
+
lines.push(` ${shape}`);
|
|
394
486
|
}
|
|
395
487
|
if (scan.truncated)
|
|
396
488
|
lines.push(`Inventory truncated after scanning ${scan.scannedEntries} entries. Call list_available_refs with a narrower path to drill down.`);
|
|
397
489
|
return lines.join("\n");
|
|
398
490
|
}
|
|
399
|
-
/** Create
|
|
491
|
+
/** Create reference discovery over the host-visible reference store. */
|
|
400
492
|
export function createListAvailableRefsTool(deps) {
|
|
401
493
|
return defineTool({
|
|
402
494
|
id: "list_available_refs",
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
"
|
|
495
|
+
description: "Resolve reference data you need into a real path. Reports the reference data actually provisioned for this environment — the ONLY reference data that exists here; nothing can be downloaded at runtime. " +
|
|
496
|
+
"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. " +
|
|
497
|
+
"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. " +
|
|
498
|
+
"User-added files are returned too, with whatever labels exist — often none. Symlinks are never followed. " +
|
|
499
|
+
"Paths are reported as the analysis environment sees them, so a returned path can be used verbatim in a script. " +
|
|
406
500
|
"Results are ALWAYS bounded — prefer a targeted query over a full dump: " +
|
|
501
|
+
'`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
502
|
"`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; " +
|
|
503
|
+
'`category` is shorthand for a top-level group (e.g. "pathways", "regulatory-networks"), and the groups present come back in `categories`; ' +
|
|
410
504
|
"`limit` caps the entries returned. The response carries `returned`, `total`, and `hasMore`, so truncation is never silent. " +
|
|
411
|
-
"
|
|
505
|
+
"An empty result means the data is genuinely absent — say so and proceed with what you have; do not guess a path. Provision additional files through the host setup flow.",
|
|
412
506
|
inputSchema: ListAvailableRefsInputSchema,
|
|
413
|
-
execute: async ({ path, category, query, limit }
|
|
507
|
+
execute: async ({ path, category, query, limit }) => {
|
|
414
508
|
// `category` is shorthand for a top-level path; an explicit `path` wins.
|
|
415
509
|
const requestedInput = path ?? category;
|
|
416
510
|
const requested = resolveRequestedPath(requestedInput);
|
|
@@ -430,33 +524,11 @@ export function createListAvailableRefsTool(deps) {
|
|
|
430
524
|
};
|
|
431
525
|
return ok(response);
|
|
432
526
|
}
|
|
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));
|
|
527
|
+
// A query searches the leaves; a bare listing browses this level. Without the
|
|
528
|
+
// recursive pass a search from the store root only ever sees directories,
|
|
529
|
+
// which carry no dataset labels, so "find me a regulon network" finds nothing.
|
|
530
|
+
const needleGiven = (query?.trim() ?? "") !== "";
|
|
531
|
+
const raw = await scanStore(deps.refStorePath, posixPath.relative(REFS_ROOT, requested.path) || ".", needleGiven);
|
|
460
532
|
const enriched = enrichEntries(raw.entries, raw);
|
|
461
533
|
const categories = collectCategories(enriched);
|
|
462
534
|
const needle = query?.trim().toLowerCase();
|
|
@@ -495,166 +567,3 @@ export function createListAvailableRefsTool(deps) {
|
|
|
495
567
|
},
|
|
496
568
|
});
|
|
497
569
|
}
|
|
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
|
-
}
|