@inflexa-ai/harness 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/dist/agents/conversation-agent.d.ts +12 -11
  2. package/dist/agents/conversation-agent.js +30 -33
  3. package/dist/agents/report-builder.js +2 -1
  4. package/dist/agents/sandbox/bulk-transcriptomics-agent.js +1 -1
  5. package/dist/agents/sandbox/cheminformatics-agent.js +1 -19
  6. package/dist/agents/sandbox/chromatin-agent.js +1 -10
  7. package/dist/agents/sandbox/dna-methylation-agent.js +1 -10
  8. package/dist/agents/sandbox/drug-repurposing-agent.js +4 -14
  9. package/dist/agents/sandbox/enrichment-agent.js +1 -10
  10. package/dist/agents/sandbox/ephemeral-executor.js +1 -10
  11. package/dist/agents/sandbox/genomic-variant-agent.js +1 -3
  12. package/dist/agents/sandbox/immune-profiling-agent.js +1 -13
  13. package/dist/agents/sandbox/metabolomics-agent.js +1 -1
  14. package/dist/agents/sandbox/microbiome-agent.js +1 -1
  15. package/dist/agents/sandbox/multi-omics-integration-agent.js +1 -12
  16. package/dist/agents/sandbox/multimodal-sc-agent.js +1 -1
  17. package/dist/agents/sandbox/network-agent.js +1 -10
  18. package/dist/agents/sandbox/pkpd-clinical-response-agent.js +2 -8
  19. package/dist/agents/sandbox/proteomics-agent.js +1 -1
  20. package/dist/agents/sandbox/scientific-executor.js +1 -10
  21. package/dist/agents/sandbox/shared.d.ts +17 -6
  22. package/dist/agents/sandbox/shared.js +49 -38
  23. package/dist/agents/sandbox/single-cell-agent.js +1 -1
  24. package/dist/agents/sandbox/spatial-omics-agent.js +1 -1
  25. package/dist/agents/sandbox/statistical-modeling-agent.js +1 -1
  26. package/dist/agents/sandbox/translational-safety-agent.js +3 -9
  27. package/dist/agents/sandbox/types.d.ts +1 -1
  28. package/dist/agents/system-prompt.d.ts +37 -11
  29. package/dist/agents/system-prompt.js +35 -12
  30. package/dist/app/data-profile-orientation.d.ts +34 -0
  31. package/dist/app/data-profile-orientation.js +117 -0
  32. package/dist/contracts/schemas/chat-parts.d.ts +9 -9
  33. package/dist/execution/artifact-metadata.js +2 -1
  34. package/dist/execution/ephemeral-runner.d.ts +13 -0
  35. package/dist/execution/ephemeral-runner.js +23 -1
  36. package/dist/execution/run-synthesis.js +20 -19
  37. package/dist/execution/step-summary.js +2 -1
  38. package/dist/index.d.ts +6 -2
  39. package/dist/index.js +19 -5
  40. package/dist/loop/__fixtures__/scripted-provider.d.ts +2 -2
  41. package/dist/loop/__fixtures__/scripted-provider.js +2 -1
  42. package/dist/loop/metrics.d.ts +33 -1
  43. package/dist/loop/metrics.js +56 -2
  44. package/dist/loop/run-agent.d.ts +13 -1
  45. package/dist/loop/run-agent.js +19 -4
  46. package/dist/memory/card-builders.d.ts +8 -1
  47. package/dist/memory/card-builders.js +20 -3
  48. package/dist/memory/reconstruct-cards.js +4 -3
  49. package/dist/memory/thread-history.d.ts +27 -2
  50. package/dist/memory/thread-history.js +45 -5
  51. package/dist/memory/working-memory.d.ts +93 -10
  52. package/dist/memory/working-memory.js +220 -84
  53. package/dist/prompts/SOUL.d.ts +22 -9
  54. package/dist/prompts/SOUL.js +56 -37
  55. package/dist/prompts/analogical-reasoner.d.ts +1 -1
  56. package/dist/prompts/analogical-reasoner.js +2 -2
  57. package/dist/prompts/briefing.d.ts +100 -0
  58. package/dist/prompts/briefing.js +166 -0
  59. package/dist/prompts/conversation.d.ts +1 -1
  60. package/dist/prompts/conversation.js +94 -440
  61. package/dist/prompts/literature-reviewer.d.ts +1 -1
  62. package/dist/prompts/literature-reviewer.js +10 -10
  63. package/dist/prompts/planner.js +28 -59
  64. package/dist/prompts/report-builder.d.ts +1 -1
  65. package/dist/prompts/report-builder.js +71 -24
  66. package/dist/prompts/sandbox/bulk-transcriptomics-agent.d.ts +1 -1
  67. package/dist/prompts/sandbox/bulk-transcriptomics-agent.js +2 -7
  68. package/dist/prompts/sandbox/cheminformatics-agent.d.ts +1 -1
  69. package/dist/prompts/sandbox/cheminformatics-agent.js +11 -15
  70. package/dist/prompts/sandbox/chromatin-agent.d.ts +1 -1
  71. package/dist/prompts/sandbox/chromatin-agent.js +3 -7
  72. package/dist/prompts/sandbox/data-profiler.d.ts +1 -1
  73. package/dist/prompts/sandbox/data-profiler.js +13 -8
  74. package/dist/prompts/sandbox/dna-methylation-agent.d.ts +1 -1
  75. package/dist/prompts/sandbox/dna-methylation-agent.js +2 -7
  76. package/dist/prompts/sandbox/drug-repurposing-agent.d.ts +1 -1
  77. package/dist/prompts/sandbox/drug-repurposing-agent.js +15 -16
  78. package/dist/prompts/sandbox/enrichment-agent.d.ts +1 -1
  79. package/dist/prompts/sandbox/enrichment-agent.js +2 -9
  80. package/dist/prompts/sandbox/genomic-variant-agent.d.ts +1 -1
  81. package/dist/prompts/sandbox/genomic-variant-agent.js +2 -6
  82. package/dist/prompts/sandbox/immune-profiling-agent.d.ts +1 -1
  83. package/dist/prompts/sandbox/immune-profiling-agent.js +3 -8
  84. package/dist/prompts/sandbox/metabolomics-agent.d.ts +1 -1
  85. package/dist/prompts/sandbox/metabolomics-agent.js +1 -7
  86. package/dist/prompts/sandbox/microbiome-agent.d.ts +1 -1
  87. package/dist/prompts/sandbox/microbiome-agent.js +2 -7
  88. package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
  89. package/dist/prompts/sandbox/multi-omics-integration-agent.js +2 -8
  90. package/dist/prompts/sandbox/multimodal-sc-agent.d.ts +1 -1
  91. package/dist/prompts/sandbox/multimodal-sc-agent.js +2 -7
  92. package/dist/prompts/sandbox/network-agent.d.ts +1 -1
  93. package/dist/prompts/sandbox/network-agent.js +2 -7
  94. package/dist/prompts/sandbox/proteomics-agent.d.ts +1 -1
  95. package/dist/prompts/sandbox/proteomics-agent.js +2 -9
  96. package/dist/prompts/sandbox/single-cell-agent.d.ts +1 -1
  97. package/dist/prompts/sandbox/single-cell-agent.js +2 -9
  98. package/dist/prompts/sandbox/spatial-omics-agent.d.ts +1 -1
  99. package/dist/prompts/sandbox/spatial-omics-agent.js +3 -6
  100. package/dist/prompts/sandbox/statistical-modeling-agent.d.ts +1 -1
  101. package/dist/prompts/sandbox/statistical-modeling-agent.js +2 -7
  102. package/dist/prompts/sandbox/translational-safety-agent.d.ts +1 -1
  103. package/dist/prompts/sandbox/translational-safety-agent.js +13 -14
  104. package/dist/prompts/sandbox-standards.d.ts +22 -14
  105. package/dist/prompts/sandbox-standards.js +91 -71
  106. package/dist/prompts/synthesis-agent.d.ts +1 -1
  107. package/dist/prompts/synthesis-agent.js +33 -131
  108. package/dist/provenance/collector.js +1 -1
  109. package/dist/providers/ai-sdk.js +31 -4
  110. package/dist/providers/prompt-cache.d.ts +74 -0
  111. package/dist/providers/prompt-cache.js +77 -0
  112. package/dist/providers/types.d.ts +33 -0
  113. package/dist/reference-data/catalog.d.ts +131 -0
  114. package/dist/reference-data/catalog.js +348 -0
  115. package/dist/reference-data/receipt.d.ts +41 -0
  116. package/dist/reference-data/receipt.js +40 -0
  117. package/dist/schemas/data-profile-schemas.js +5 -3
  118. package/dist/schemas/plan-schemas.d.ts +21 -21
  119. package/dist/schemas/plan-schemas.js +9 -3
  120. package/dist/schemas/run-synthesis.d.ts +8 -4
  121. package/dist/schemas/run-synthesis.js +92 -56
  122. package/dist/schemas/validate-plan.js +1 -1
  123. package/dist/schemas/workflow-state.d.ts +7 -1
  124. package/dist/schemas/workflow-state.js +33 -19
  125. package/dist/state/artifacts.d.ts +13 -0
  126. package/dist/state/artifacts.js +27 -0
  127. package/dist/state/data-profile.d.ts +64 -5
  128. package/dist/state/index.d.ts +3 -3
  129. package/dist/state/index.js +1 -1
  130. package/dist/tasks/data-profile.d.ts +20 -2
  131. package/dist/tasks/data-profile.js +71 -22
  132. package/dist/tools/bio/chembl.d.ts +38 -0
  133. package/dist/tools/bio/chembl.js +138 -0
  134. package/dist/tools/bio/comptox.d.ts +50 -0
  135. package/dist/tools/bio/comptox.js +149 -0
  136. package/dist/tools/bio/get-impc-ko-profile.js +9 -6
  137. package/dist/tools/bio/index.d.ts +5 -17
  138. package/dist/tools/bio/index.js +5 -17
  139. package/dist/tools/bio/keys.d.ts +14 -9
  140. package/dist/tools/bio/keys.js +16 -16
  141. package/dist/tools/bio/opentargets.d.ts +38 -0
  142. package/dist/tools/bio/opentargets.js +89 -0
  143. package/dist/tools/bio/pubchem.d.ts +29 -0
  144. package/dist/tools/bio/pubchem.js +92 -0
  145. package/dist/tools/bio/pubmed.d.ts +51 -0
  146. package/dist/tools/bio/pubmed.js +100 -0
  147. package/dist/tools/bio/search-bgee-expression.js +12 -6
  148. package/dist/tools/bio/search-clinical-trials.js +16 -5
  149. package/dist/tools/bio/search-faers.js +8 -4
  150. package/dist/tools/bio/search-geo-datasets.js +20 -6
  151. package/dist/tools/bio/search-gwas-catalog.d.ts +1 -1
  152. package/dist/tools/bio/search-pharmgkb.js +10 -4
  153. package/dist/tools/display/index.d.ts +1 -0
  154. package/dist/tools/display/index.js +1 -0
  155. package/dist/tools/display/normalize-echart-spec.d.ts +61 -0
  156. package/dist/tools/display/normalize-echart-spec.js +211 -0
  157. package/dist/tools/display/show-user.d.ts +6 -1
  158. package/dist/tools/display/show-user.js +25 -12
  159. package/dist/tools/execute-plan.js +5 -2
  160. package/dist/tools/iterate-report.d.ts +168 -118
  161. package/dist/tools/iterate-report.js +334 -96
  162. package/dist/tools/lib/chembl-client.d.ts +12 -0
  163. package/dist/tools/lib/chembl-client.js +127 -0
  164. package/dist/tools/lib/ctx-ops.d.ts +224 -0
  165. package/dist/tools/lib/ctx-ops.js +450 -0
  166. package/dist/tools/lib/iuphar-client.js +10 -1
  167. package/dist/tools/lib/openfda-client.js +11 -3
  168. package/dist/tools/lib/pathway-client.js +10 -4
  169. package/dist/tools/lib/pubchem-ops.d.ts +74 -0
  170. package/dist/tools/lib/pubchem-ops.js +224 -0
  171. package/dist/tools/memory/update-working-memory.d.ts +45 -7
  172. package/dist/tools/memory/update-working-memory.js +111 -41
  173. package/dist/tools/report/index.d.ts +3 -2
  174. package/dist/tools/report/index.js +3 -2
  175. package/dist/tools/research/generate-analogy-report.js +25 -12
  176. package/dist/tools/research/generate-plan.d.ts +12 -5
  177. package/dist/tools/research/generate-plan.js +168 -37
  178. package/dist/tools/research/index.d.ts +1 -0
  179. package/dist/tools/research/index.js +1 -0
  180. package/dist/tools/research/inspect-data-profile.d.ts +85 -0
  181. package/dist/tools/research/inspect-data-profile.js +161 -0
  182. package/dist/tools/research/literature-reviewer.js +3 -4
  183. package/dist/tools/sandbox/list-available-packages.d.ts +79 -5
  184. package/dist/tools/sandbox/list-available-packages.js +152 -10
  185. package/dist/tools/sandbox/list-available-refs.d.ts +141 -5
  186. package/dist/tools/sandbox/list-available-refs.js +647 -134
  187. package/dist/tools/sandbox/report-blocker.d.ts +39 -9
  188. package/dist/tools/sandbox/report-blocker.js +43 -20
  189. package/dist/tools/workspace/read-file.js +4 -5
  190. package/dist/tools/workspace/run-exec.d.ts +4 -4
  191. package/dist/tools/workspace/run-exec.js +4 -4
  192. package/dist/tools/workspace/show-file.js +10 -6
  193. package/dist/tools/workspace/show-plan.js +12 -6
  194. package/dist/tools/workspace/workspace-search.d.ts +1 -1
  195. package/dist/tools/workspace/workspace-search.js +11 -5
  196. package/dist/workflows/execute-analysis.d.ts +43 -4
  197. package/dist/workflows/execute-analysis.js +107 -6
  198. package/dist/workflows/target-assessment/lib/citation-validator.js +2 -2
  199. package/dist/workflows/target-assessment/lib/clinical-consequence-annotator.js +2 -1
  200. package/dist/workspace/paths.d.ts +2 -2
  201. package/dist/workspace/paths.js +2 -2
  202. package/package.json +33 -33
  203. package/dist/execution/filesystem-artifact-registry.d.ts +0 -57
  204. package/dist/execution/filesystem-artifact-registry.js +0 -92
  205. package/dist/lib/identifiers.d.ts +0 -8
  206. package/dist/lib/identifiers.js +0 -10
  207. package/dist/lib/mime-detect.d.ts +0 -17
  208. package/dist/lib/mime-detect.js +0 -29
  209. package/dist/lib/streamed-download.d.ts +0 -28
  210. package/dist/lib/streamed-download.js +0 -111
  211. package/dist/lib/trace-context.d.ts +0 -11
  212. package/dist/lib/trace-context.js +0 -49
  213. package/dist/providers/llm-capabilities.d.ts +0 -33
  214. package/dist/providers/llm-capabilities.js +0 -58
  215. package/dist/schemas/render-step-prompt.d.ts +0 -32
  216. package/dist/schemas/render-step-prompt.js +0 -76
  217. package/dist/tools/bio/find-approval-precedent.d.ts +0 -32
  218. package/dist/tools/bio/find-approval-precedent.js +0 -72
  219. package/dist/tools/bio/get-article-details.d.ts +0 -11
  220. package/dist/tools/bio/get-article-details.js +0 -20
  221. package/dist/tools/bio/get-article-full-text.d.ts +0 -19
  222. package/dist/tools/bio/get-article-full-text.js +0 -32
  223. package/dist/tools/bio/get-bioactivity.d.ts +0 -11
  224. package/dist/tools/bio/get-bioactivity.js +0 -23
  225. package/dist/tools/bio/get-drug-info.d.ts +0 -9
  226. package/dist/tools/bio/get-drug-info.js +0 -22
  227. package/dist/tools/bio/get-mechanism.d.ts +0 -8
  228. package/dist/tools/bio/get-mechanism.js +0 -18
  229. package/dist/tools/bio/get-pubchem-assays.d.ts +0 -22
  230. package/dist/tools/bio/get-pubchem-assays.js +0 -107
  231. package/dist/tools/bio/get-pubchem-cross-refs.d.ts +0 -15
  232. package/dist/tools/bio/get-pubchem-cross-refs.js +0 -68
  233. package/dist/tools/bio/get-target-safety.d.ts +0 -18
  234. package/dist/tools/bio/get-target-safety.js +0 -26
  235. package/dist/tools/bio/search-compounds.d.ts +0 -22
  236. package/dist/tools/bio/search-compounds.js +0 -160
  237. package/dist/tools/bio/search-ctx-chemical.d.ts +0 -50
  238. package/dist/tools/bio/search-ctx-chemical.js +0 -130
  239. package/dist/tools/bio/search-ctx-exposure.d.ts +0 -55
  240. package/dist/tools/bio/search-ctx-exposure.js +0 -176
  241. package/dist/tools/bio/search-ctx-hazard.d.ts +0 -111
  242. package/dist/tools/bio/search-ctx-hazard.js +0 -178
  243. package/dist/tools/bio/search-opentargets.d.ts +0 -11
  244. package/dist/tools/bio/search-opentargets.js +0 -25
  245. package/dist/tools/bio/search-pubchem-compound.d.ts +0 -26
  246. package/dist/tools/bio/search-pubchem-compound.js +0 -110
  247. package/dist/tools/bio/search-pubmed.d.ts +0 -17
  248. package/dist/tools/bio/search-pubmed.js +0 -31
  249. package/dist/tools/bio/search-targets.d.ts +0 -9
  250. package/dist/tools/bio/search-targets.js +0 -20
  251. package/dist/tools/bio/search-toxcast.d.ts +0 -36
  252. package/dist/tools/bio/search-toxcast.js +0 -141
  253. package/dist/workflows/register-workflows.d.ts +0 -51
  254. package/dist/workflows/register-workflows.js +0 -48
  255. package/dist/workflows/resume-execute-analysis.d.ts +0 -70
  256. package/dist/workflows/resume-execute-analysis.js +0 -80
  257. package/dist/workflows/target-assessment/correction-loop.d.ts +0 -45
  258. package/dist/workflows/target-assessment/correction-loop.js +0 -62
  259. package/dist/workflows/target-assessment/steps/phase0-resolve.d.ts +0 -71
  260. package/dist/workflows/target-assessment/steps/phase0-resolve.js +0 -21
  261. package/dist/workflows/target-assessment/steps/phase1-aggregate.d.ts +0 -1018
  262. package/dist/workflows/target-assessment/steps/phase1-aggregate.js +0 -25
  263. package/dist/workspace/mount-strategy.d.ts +0 -50
  264. package/dist/workspace/mount-strategy.js +0 -67
  265. package/dist/workspace/provenance-collector.d.ts +0 -34
  266. package/dist/workspace/provenance-collector.js +0 -16
  267. package/dist/workspace/workspace-paths.d.ts +0 -26
  268. package/dist/workspace/workspace-paths.js +0 -31
@@ -1,147 +1,660 @@
1
1
  /**
2
- * listAvailableRefs — list reference data available in the sandbox.
2
+ * Reference-store discovery — two coordinated surfaces over the read-only
3
+ * store at /mnt/refs:
4
+ *
5
+ * - `createListAvailableRefsTool` — the wired sandbox tool. A bounded, no-follow
6
+ * inventory of what is ACTUALLY mounted (managed installs, user-added files),
7
+ * scanned live inside the sandbox. Narrow with `path`/`category`, filter with
8
+ * `query`, cap with `limit`; every response carries `returned`/`total`/
9
+ * `hasMore` and the `categories` present, so a listing is never an unbounded
10
+ * dump. Symlinks are never followed; nothing can be downloaded at runtime.
11
+ * - `queryRefs` — a pure query over a parsed `/mnt/refs/registry.json` manifest
12
+ * (`files.by_category`), filtering on fields the registry carries: `local_path`,
13
+ * `category`, `subtype`, `organism`, `tax_id`, `dataset`, `rows`.
3
14
  */
4
- import { readFile } from "node:fs/promises";
5
- import { basename, join } from "node:path";
15
+ import { basename, join, posix as posixPath } from "node:path";
6
16
  import { ok } from "neverthrow";
7
17
  import { z } from "zod";
18
+ import { REFERENCE_DATA_CATALOG } from "../../reference-data/catalog.js";
19
+ import { parseReferenceInstallReceipt } from "../../reference-data/receipt.js";
8
20
  import { defineTool } from "../define-tool.js";
21
+ import { runSandboxExec } from "../workspace/run-exec.js";
9
22
  const REFS_ROOT = "/mnt/refs";
10
- const REGISTRY_FILE = join(REFS_ROOT, "registry.json");
11
- function formatCategory(category, entries) {
12
- const labels = {
13
- atlas_singlecell: "Single-cell reference atlases for label transfer (Pan-Cancer T cell — " +
14
- "Zheng 2021, Pan-Cancer Myeloid — Cheng 2021, Tumor Immune Cell Atlas — " +
15
- "Nieto 2021, Tabula Sapiens v2). Available as .h5ad (Python: anndata, " +
16
- "scanpy, scvi-tools, symphonypy) and/or .rds (R: Seurat, ProjecTILs). " +
17
- "Tabula Sapiens ships full + immune_subset variants. " +
18
- "Use these for label transfer BEFORE falling back to marker genes.",
19
- atlas_azimuth: "Azimuth references (Seurat-native): PBMC v1.0.0 and Tonsil v1.0.0. " +
20
- "Each entry is a (ref.Rds, idx.annoy) pair stored under " +
21
- "/mnt/refs/atlas_azimuth/{pbmc,tonsil}/. Load with " +
22
- "Azimuth::LoadReference(path) where path is the directory containing " +
23
- "ref.Rds + idx.annoy (e.g. '/mnt/refs/atlas_azimuth/pbmc'), then " +
24
- "Azimuth::RunAzimuth(query, reference = path).",
25
- atlas_projectils: "ProjecTILs reference atlases (Seurat .rds): human + mouse, CD8 + CD4. " +
26
- "Use ProjecTILs::ProjecTILs.classifier() to assign every CD8 to one of " +
27
- "the canonical TCF7+/GZMK+/TEX/TPEX/MAIT substates without re-deriving " +
28
- "the classifier from scratch.",
29
- celltypist_models: "Pre-staged CellTypist .pkl classifier models (Immune_All_Low backed by " +
30
- "the Cross-Tissue Immune Cell Atlas; Immune_All_High; Pan_Fetal_Human; " +
31
- "COVID19_Immune_Landscape). CELLTYPIST_FOLDER env var is preset, so " +
32
- "celltypist.annotate_cells(model='Immune_All_Low') resolves with no " +
33
- "network call.",
34
- marker_panels: "Marker gene panels: PanglaoDB (community, parquet long-format), " +
35
- "CellMarker 2.0 (tumor-context, parquet long-format), and the Cortex " +
36
- "hand-curated panel for Stage 1 substates (TCF7+ memory CD8, exhausted " +
37
- "CD8, effector-memory CD8, proliferating T, cDC1/cDC2/LAMP3+ DC, SPP1+ " +
38
- "TAM, C1Q+ TAM, Tregs) — JSON with full provenance.",
39
- gene_signatures: "Curated gene signatures from key tumor-immune papers: Sade-Feldman " +
40
- "2018 exhausted CD8 (Table S2), Jerby-Arnon 2018 malignant resistance " +
41
- "program (Table S1, Stage 3 classifier substrate), Tirosh 2016 AXL/MITF " +
42
- "programs, Puram 2017 p-EMT (Table S5, HNSCC), Reactome cGAS-STING " +
43
- "pathway (Stage 2.3 type-I IFN). JSON with PMID/DOI per entry.",
44
- normal_reference: "Normal-tissue references for safety firing checks: GTEx v8 per-tissue " +
45
- "median TPM (parquet) + sample attributes (parquet). Use to quantify " +
46
- "off-target activation of any nominated classifier across healthy tissues.",
47
- gene_mappings: "Gene ID Conversion Tables: Entrez/Ensembl/RefSeq/UniProt to symbol (NCBI); " +
48
- "orthologs (Ensembl Compara) — file: orthologs_{tax_id}.parquet, columns: " +
49
- "tax_id, ensembl_gene_id, entrez_id, symbol, relationship, other_tax_id, " +
50
- "other_ensembl_gene_id, other_entrez_id, other_symbol; symmetric — to find " +
51
- "human orthologs of Macaca fascicularis genes, open orthologs_9541.parquet " +
52
- "and filter other_tax_id == '9606'; cross-division (e.g. yeast↔mouse) " +
53
- "requires pivoting through human: yeast→human via orthologs_4896.parquet, " +
54
- "then human→mouse via orthologs_9606.parquet",
55
- omnipath: "OmniPath (interactions, regulons, annotations)",
56
- reactome: "Reactome Pathways",
57
- progeny: "PROGENy Pathway Weights (decoupler: source/target/weight/padj)",
58
- collectri: "CollecTRI TF-Target Regulons (decoupler: source/target/mor)",
59
- dorothea: "DoRothEA TF Regulons (decoupler: source/target/weight/confidence)",
60
- lincs: "LINCS L1000 Drug Perturbation Consensus Signatures (up/down gene sets)",
61
- hpa: "Human Protein Atlas (tissue RNA expression, target safety/secretome, full 107-column atlas)",
62
- wikipathways: "WikiPathways Gene Sets",
63
- msigdb: "MSigDB Gene Set Collections",
64
- safety_targets: "Curated Off-Target Safety Panel (CSV + JSON: chembl_id, gene_symbol, uniprot, organ_system, severity, clinical_consequence)",
65
- };
66
- const lines = [`\n## ${labels[category] ?? category}`];
67
- const grouped = new Map();
68
- for (const e of entries) {
69
- const key = e.organism ?? e.tax_id ?? e.subtype ?? "general";
70
- if (!grouped.has(key))
71
- grouped.set(key, []);
72
- grouped.get(key).push(e);
23
+ const MAX_ENTRIES = 200;
24
+ const MAX_SCANNED_ENTRIES = 2_000;
25
+ const MAX_PATH_BYTES = 40_000;
26
+ const MAX_ENVELOPE_BYTES = 64_000;
27
+ const MAX_INPUT_PATH_BYTES = 4_096;
28
+ /** Default number of file entries a registry listing (`queryRefs`) returns. */
29
+ const DEFAULT_LIMIT = 40;
30
+ const SCAN_SCRIPT = String.raw `
31
+ import json, os, sys
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
+ `;
237
+ const ListAvailableRefsInputSchema = z.object({
238
+ path: z
239
+ .string()
240
+ .max(MAX_INPUT_PATH_BYTES)
241
+ .optional()
242
+ .describe("Directory beneath /mnt/refs to inspect — a returned absolute path or a store-relative path. Use it to drill into a subtree when a listing is truncated."),
243
+ category: z
244
+ .string()
245
+ .max(MAX_INPUT_PATH_BYTES)
246
+ .optional()
247
+ .describe('Shorthand for a top-level group to inspect (e.g. "atlas_singlecell", "msigdb"); the groups present come back in `categories`. Ignored when `path` is given.'),
248
+ query: z
249
+ .string()
250
+ .optional()
251
+ .describe("Case-insensitive substring filter over each entry's path and its dataset, title, category, organism, and subtype labels."),
252
+ limit: z
253
+ .number()
254
+ .int()
255
+ .min(1)
256
+ .max(MAX_ENTRIES)
257
+ .optional()
258
+ .describe(`Max entries to return (default ${MAX_ENTRIES}); the response carries \`returned\`, \`total\`, and \`hasMore\` so truncation is never silent.`),
259
+ });
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
+ function resolveRequestedPath(input) {
280
+ if (input === undefined || input === "" || input === REFS_ROOT)
281
+ return { ok: true, path: REFS_ROOT };
282
+ if (Buffer.byteLength(input, "utf8") > MAX_INPUT_PATH_BYTES) {
283
+ return { ok: false, reason: `Path exceeds the ${MAX_INPUT_PATH_BYTES}-byte POSIX path limit.` };
284
+ }
285
+ if (input.includes("\0") || input.includes("\\"))
286
+ return { ok: false, reason: "Path must use safe POSIX segments beneath /mnt/refs." };
287
+ const relative = input.startsWith(`${REFS_ROOT}/`) ? input.slice(REFS_ROOT.length + 1) : input.startsWith("/") ? undefined : input;
288
+ if (relative === undefined || relative.length === 0)
289
+ return { ok: false, reason: "Path is outside /mnt/refs." };
290
+ const segments = relative.split("/");
291
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
292
+ return { ok: false, reason: "Path traversal and empty path segments are not allowed." };
73
293
  }
74
- for (const [group, items] of grouped) {
75
- if (grouped.size > 1)
76
- lines.push(`\n### ${group}`);
77
- for (const e of items) {
78
- const path = join(REFS_ROOT, e.local_path);
79
- const name = basename(e.local_path);
80
- const info = [e.rows != null ? `${e.rows.toLocaleString()} rows` : null, e.subtype, e.dataset].filter(Boolean).join(", ");
81
- lines.push(` ${name}: ${path}${info ? ` (${info})` : ""}`);
294
+ if (segments[0] === ".inflexa" || relative === "registry.json")
295
+ return { ok: false, reason: "Installer metadata is not reference data." };
296
+ const normalized = posixPath.join(REFS_ROOT, relative);
297
+ if (!normalized.startsWith(`${REFS_ROOT}/`))
298
+ return { ok: false, reason: "Path is outside /mnt/refs." };
299
+ return { ok: true, path: normalized };
300
+ }
301
+ function stringValue(value) {
302
+ return typeof value === "string" && value.length > 0 ? value : undefined;
303
+ }
304
+ function numberValue(value) {
305
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
306
+ }
307
+ function enrichEntries(entries, raw) {
308
+ const metadata = new Map();
309
+ for (const value of raw.receipts ?? []) {
310
+ const receipt = parseReferenceInstallReceipt(value);
311
+ if (!receipt)
312
+ continue;
313
+ const dataset = REFERENCE_DATA_CATALOG.datasets.find((candidate) => candidate.id === receipt.datasetId && candidate.version === receipt.datasetVersion);
314
+ for (const artifact of receipt.artifacts) {
315
+ metadata.set(`${REFS_ROOT}/managed/${receipt.datasetId}/${receipt.datasetVersion}/${artifact.path}`, {
316
+ datasetId: receipt.datasetId,
317
+ version: receipt.datasetVersion,
318
+ ...(dataset
319
+ ? {
320
+ title: dataset.title,
321
+ description: dataset.description,
322
+ sourceUrl: dataset.sourceUrl,
323
+ license: dataset.license.url ?? dataset.license.identifier,
324
+ }
325
+ : {}),
326
+ });
82
327
  }
83
328
  }
329
+ for (const value of raw.legacyEntries ?? []) {
330
+ if (!value || typeof value !== "object")
331
+ continue;
332
+ const item = value;
333
+ const localPath = stringValue(item.local_path);
334
+ if (!localPath)
335
+ continue;
336
+ const resolved = resolveRequestedPath(localPath);
337
+ if (!resolved.ok)
338
+ continue;
339
+ const legacyMetadata = {
340
+ ...(stringValue(item.dataset) ? { datasetId: stringValue(item.dataset) } : {}),
341
+ ...(stringValue(item.category) ? { category: stringValue(item.category) } : {}),
342
+ ...(stringValue(item.subtype) ? { subtype: stringValue(item.subtype) } : {}),
343
+ ...(stringValue(item.organism) ? { organism: stringValue(item.organism) } : {}),
344
+ ...(numberValue(item.rows) !== undefined ? { rows: numberValue(item.rows) } : {}),
345
+ ...(stringValue(item.endpoint) ? { endpoint: stringValue(item.endpoint) } : {}),
346
+ };
347
+ metadata.set(resolved.path, { ...legacyMetadata, ...metadata.get(resolved.path) });
348
+ }
349
+ return entries.map((entry) => {
350
+ const extra = metadata.get(entry.path);
351
+ return extra ? { ...entry, metadata: extra } : entry;
352
+ });
353
+ }
354
+ /** Case-insensitive substring match over an inventory entry's path and joined metadata labels. */
355
+ function entryMatchesQuery(entry, needle) {
356
+ const fields = [entry.path, entry.metadata?.datasetId, entry.metadata?.title, entry.metadata?.category, entry.metadata?.subtype, entry.metadata?.organism];
357
+ return fields.some((field) => typeof field === "string" && field.toLowerCase().includes(needle));
358
+ }
359
+ /** The drill-in index: directory groups present in the scan, plus any category labels joined from metadata. */
360
+ function collectCategories(entries) {
361
+ const names = new Set();
362
+ for (const entry of entries) {
363
+ if (entry.kind === "directory")
364
+ names.add(posixPath.basename(entry.path));
365
+ if (entry.metadata?.category)
366
+ names.add(entry.metadata.category);
367
+ }
368
+ return [...names].sort();
369
+ }
370
+ function renderContent(path, scan, entries) {
371
+ if (scan.state === "unavailable")
372
+ return "Reference store is not mounted at /mnt/refs. Reference data cannot be downloaded from sandbox workloads; provision it through the host setup flow.";
373
+ if (scan.state === "not_found")
374
+ return `Reference path does not exist: ${path}`;
375
+ if (scan.state === "not_a_directory")
376
+ return `Reference path is not a directory: ${path}`;
377
+ if (scan.state === "symlink")
378
+ return `Reference path crosses a symlink and was not scanned: ${path}`;
379
+ if (scan.state === "empty")
380
+ return `Reference store path is mounted but contains no reference files: ${path}`;
381
+ const lines = [`Reference files visible beneath ${path}:`];
382
+ for (const entry of entries) {
383
+ const labels = entry.metadata
384
+ ? [entry.metadata.title ?? entry.metadata.datasetId, entry.metadata.version, entry.metadata.category, entry.metadata.organism]
385
+ .filter(Boolean)
386
+ .join(", ")
387
+ : "";
388
+ const details = entry.kind === "directory"
389
+ ? `directory, ${entry.fileCount} files, ${entry.bytes} bytes${entry.fileTypes.length > 0 ? `, types: ${entry.fileTypes.join(", ")}` : ""}${entry.truncated ? ", summary truncated" : ""}`
390
+ : entry.kind === "file"
391
+ ? `file, ${entry.bytes} bytes`
392
+ : "symlink (not followed)";
393
+ lines.push(`- ${entry.path} (${details}${labels ? `; ${labels}` : ""})`);
394
+ }
395
+ if (scan.truncated)
396
+ lines.push(`Inventory truncated after scanning ${scan.scannedEntries} entries. Call list_available_refs with a narrower path to drill down.`);
84
397
  return lines.join("\n");
85
398
  }
86
- export const listAvailableRefsTool = defineTool({
87
- id: "list_available_refs",
88
- description: "List all reference data available in the sandbox: single-cell atlases for " +
89
- "label transfer (Pan-Cancer T/Myeloid, TICA, Tabula Sapiens, Azimuth PBMC/Tonsil), " +
90
- "ProjecTILs CD8/CD4 atlases, CellTypist models, marker panels (PanglaoDB, " +
91
- "CellMarker 2.0, Cortex curated), gene signatures (Sade-Feldman, Jerby-Arnon, " +
92
- "Tirosh, Puram, cGAS-STING), normal-tissue references (GTEx v8), gene mappings, " +
93
- "UniProt, OmniPath networks, Reactome, PROGENy, CollecTRI, DoRothEA, " +
94
- "WikiPathways, MSigDB, LINCS L1000, HPA, and the curated off-target safety " +
95
- "panel. Returns file paths for h5ad/rds/pkl/parquet/GMT/CSV/JSON resources. " +
96
- "No data can be downloaded at runtime — only what this tool returns is available.",
97
- inputSchema: z.object({}),
98
- execute: async () => {
99
- // A missing reference-store mount is an expected environment state —
100
- // model it as an `available: false` data variant with a fallback note.
101
- try {
102
- const raw = await readFile(REGISTRY_FILE, "utf-8");
103
- const registry = JSON.parse(raw);
104
- const header = [
105
- `Reference Store (build ${registry.build_id}, ${registry.generated_at})`,
106
- `Total files: ${registry.summary.total_output_files}`,
107
- `Mount: ${REFS_ROOT}`,
108
- ].join("\n");
109
- const categoryOrder = [
110
- "atlas_singlecell",
111
- "atlas_azimuth",
112
- "atlas_projectils",
113
- "celltypist_models",
114
- "marker_panels",
115
- "gene_signatures",
116
- "normal_reference",
117
- "safety_targets",
118
- "progeny",
119
- "collectri",
120
- "dorothea",
121
- "omnipath",
122
- "lincs",
123
- "msigdb",
124
- "wikipathways",
125
- "reactome",
126
- "hpa",
127
- "gene_mappings",
128
- ];
129
- const known = new Set(categoryOrder);
130
- const allCategories = [
131
- ...categoryOrder.filter((c) => registry.files.by_category[c]),
132
- ...Object.keys(registry.files.by_category).filter((c) => !known.has(c)),
133
- ];
134
- const sections = allCategories.map((c) => formatCategory(c, registry.files.by_category[c]));
135
- return ok({ available: true, content: header + "\n" + sections.join("\n") });
136
- }
137
- catch {
138
- return ok({
139
- available: false,
140
- content: "Reference store not available. The reference store may not be mounted at /mnt/refs. " +
141
- "Gene mappings, UniProt, OmniPath, Reactome, PROGENy, CollecTRI, DoRothEA, " +
142
- "WikiPathways, MSigDB, LINCS L1000, HPA, and the curated off-target safety panel " +
143
- "cannot be loaded from pre-staged files.",
399
+ /** Create replay-safe reference discovery bound to the active sandbox. */
400
+ export function createListAvailableRefsTool(deps) {
401
+ return defineTool({
402
+ id: "list_available_refs",
403
+ executionMode: "workflow",
404
+ description: "Inspect the reference files actually mounted read-only at /mnt/refs — the ONLY reference data that exists here; nothing can be downloaded at runtime. " +
405
+ "Returns managed and user-added files even when no manifest names them; valid receipts and legacy registry data only add labels (dataset, category, organism, subtype). Symlinks are never followed. " +
406
+ "Results are ALWAYS bounded — prefer a targeted query over a full dump: " +
407
+ "`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. "atlas_singlecell", "msigdb"), and the groups present come back in `categories`; ' +
409
+ "`query` is a case-insensitive substring filter over each entry's path and its dataset/title/category/organism/subtype labels; " +
410
+ "`limit` caps the entries returned. The response carries `returned`, `total`, and `hasMore`, so truncation is never silent. " +
411
+ "Provision additional files through the host and re-run in a sandbox with that store mounted.",
412
+ inputSchema: ListAvailableRefsInputSchema,
413
+ execute: async ({ path, category, query, limit }, ctx) => {
414
+ // `category` is shorthand for a top-level path; an explicit `path` wins.
415
+ const requestedInput = path ?? category;
416
+ const requested = resolveRequestedPath(requestedInput);
417
+ if (!requested.ok) {
418
+ const response = {
419
+ available: false,
420
+ state: "out_of_scope",
421
+ path: requestedInput !== undefined && Buffer.byteLength(requestedInput, "utf8") <= MAX_INPUT_PATH_BYTES ? requestedInput : REFS_ROOT,
422
+ entries: [],
423
+ scannedEntries: 0,
424
+ returned: 0,
425
+ total: 0,
426
+ truncated: false,
427
+ hasMore: false,
428
+ categories: [],
429
+ content: requested.reason,
430
+ };
431
+ return ok(response);
432
+ }
433
+ const execId = `${deps.workflowId}:${deps.stepId}:${deps.nextFunctionId()}`;
434
+ const result = await runSandboxExec({
435
+ sandboxClient: deps.sandboxClient,
436
+ sandbox: deps.sandbox,
437
+ execId,
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 } : {}),
144
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));
460
+ const enriched = enrichEntries(raw.entries, raw);
461
+ const categories = collectCategories(enriched);
462
+ const needle = query?.trim().toLowerCase();
463
+ const matched = needle ? enriched.filter((entry) => entryMatchesQuery(entry, needle)) : enriched;
464
+ const total = matched.length;
465
+ const cap = limit ?? MAX_ENTRIES;
466
+ let boundedEntries = matched.length > cap ? matched.slice(0, cap) : matched;
467
+ const build = (list, contentPath) => {
468
+ const truncated = raw.truncated || list.length < total;
469
+ return {
470
+ available: raw.state !== "unavailable",
471
+ state: raw.state,
472
+ path: contentPath,
473
+ entries: list,
474
+ scannedEntries: raw.scannedEntries,
475
+ returned: list.length,
476
+ total,
477
+ truncated,
478
+ hasMore: truncated,
479
+ categories,
480
+ content: renderContent(contentPath, { ...raw, truncated }, list),
481
+ };
482
+ };
483
+ let response = build(boundedEntries, requested.path);
484
+ while (Buffer.byteLength(JSON.stringify(response), "utf8") > MAX_ENVELOPE_BYTES && boundedEntries.length > 0) {
485
+ boundedEntries = boundedEntries.slice(0, -1);
486
+ response = build(boundedEntries, requested.path);
487
+ }
488
+ if (Buffer.byteLength(JSON.stringify(response), "utf8") > MAX_ENVELOPE_BYTES) {
489
+ response = {
490
+ ...build([], REFS_ROOT),
491
+ content: "Reference inventory output exceeded its bound. Call list_available_refs with a shorter, narrower path.",
492
+ };
493
+ }
494
+ return ok(response);
495
+ },
496
+ });
497
+ }
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;
145
638
  }
146
- },
147
- });
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
+ }