@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
@@ -0,0 +1,100 @@
1
+ /**
2
+ * pubmed — one tool over PubMed and PubMed Central (NCBI E-utilities):
3
+ * search the literature, fetch article metadata, and read the full text of an
4
+ * open-access article.
5
+ *
6
+ * Dependency-bearing: the optional NCBI API key is captured by the factory.
7
+ *
8
+ * The input is a flat object with an `action` discriminator — not a
9
+ * `z.discriminatedUnion`, which `defineTool` rejects (model tool calling needs
10
+ * a top-level `"type":"object"`). Each action's required argument is optional
11
+ * in the schema and enforced by `.refine`, so a malformed call (e.g. 'fulltext'
12
+ * with no pmcId) fails at the loop boundary and surfaces as an `is_error` tool
13
+ * result instead of reaching NCBI.
14
+ *
15
+ * The three actions form one retrieval chain — search yields PMIDs, details
16
+ * turns PMIDs into abstracts + the PMC id, fulltext turns a PMC id into the
17
+ * article body — which is why they share a tool: the identifier for each step
18
+ * is read off the previous step's result.
19
+ */
20
+ import { ok } from "neverthrow";
21
+ import { z } from "zod";
22
+ import { defineTool } from "../define-tool.js";
23
+ import { getArticleDetails, getArticleFullText, searchPubmed } from "../lib/pubmed-client.js";
24
+ const inputSchema = z
25
+ .object({
26
+ action: z
27
+ .enum(["search", "details", "fulltext"])
28
+ .describe("'search' (needs query) — returns totalFound plus, per hit, PMID, title, journal, year and an author summary. " +
29
+ "'details' (needs pmids, max 20 per call) — each article's full abstract, authors, journal, year, DOI, MeSH terms, and " +
30
+ "pmcId (non-null only when open-access full text exists in PubMed Central). " +
31
+ "'fulltext' (needs pmcId, taken from a 'details' result) — the body of ONE open-access PMC article as plain text with " +
32
+ "section headers preserved; available: false when the article is not open-access."),
33
+ query: z
34
+ .string()
35
+ .optional()
36
+ .describe("Required for action 'search'. PubMed query syntax — MeSH terms (\"breast neoplasms\"[MeSH]), field tags " +
37
+ '([Title/Abstract], [Author], [Gene]), Boolean AND/OR/NOT. Example: "BRCA1"[Gene] AND "drug resistance"[Title/Abstract].'),
38
+ maxResults: z.number().int().min(1).max(50).default(10).optional().describe("action 'search': max results, 1–50 (default 10)."),
39
+ sort: z.enum(["relevance", "date"]).default("relevance").optional().describe("action 'search': sort order (default \"relevance\")."),
40
+ dateRange: z
41
+ .object({
42
+ from: z.string().describe("Start date in YYYY/MM/DD format"),
43
+ to: z.string().describe("End date in YYYY/MM/DD format"),
44
+ })
45
+ .optional()
46
+ .describe("action 'search': optional publication date range filter."),
47
+ pmids: z
48
+ .array(z.string())
49
+ .min(1)
50
+ .max(20)
51
+ .optional()
52
+ .describe("Required for action 'details'. Non-empty array of PMID strings, max 20 — batch them, do not call once per PMID."),
53
+ pmcId: z.string().optional().describe("Required for action 'fulltext'. A PMC ID (e.g. \"PMC1234567\") copied from a 'details' result — not a PMID."),
54
+ })
55
+ .refine((d) => d.action !== "search" || (d.query !== undefined && d.query.trim().length > 0), {
56
+ message: "query is required when action is 'search'",
57
+ path: ["query"],
58
+ })
59
+ .refine((d) => d.action !== "details" || (d.pmids !== undefined && d.pmids.length > 0), {
60
+ message: "pmids is required when action is 'details' — pass a non-empty array of PMID strings (max 20), copied from a 'search' result",
61
+ path: ["pmids"],
62
+ })
63
+ .refine((d) => d.action !== "fulltext" || (d.pmcId !== undefined && d.pmcId.trim().length > 0), {
64
+ message: "pmcId is required when action is 'fulltext' — copy it from the pmcId field of a 'details' result; a PMID is not a PMC ID",
65
+ path: ["pmcId"],
66
+ });
67
+ export function createPubMedTool(deps) {
68
+ return defineTool({
69
+ id: "pubmed",
70
+ description: "Search and read the biomedical literature via PubMed / PubMed Central (NCBI E-utilities). The three actions form a chain " +
71
+ "— search, then details on the relevant hits, then fulltext; see the action parameter for what each needs and returns. Read " +
72
+ "fulltext sparingly — only where the 'details' abstract is not enough, and only for articles that have a pmcId (open-access). " +
73
+ "available: false is an expected outcome, not an error — do not retry it.",
74
+ inputSchema,
75
+ execute: async (input) => {
76
+ switch (input.action) {
77
+ case "search":
78
+ return ok(await searchPubmed(deps.ncbiApiKey, input.query, {
79
+ maxResults: input.maxResults,
80
+ sort: input.sort,
81
+ dateRange: input.dateRange,
82
+ }));
83
+ case "details":
84
+ return ok(await getArticleDetails(deps.ncbiApiKey, input.pmids));
85
+ case "fulltext": {
86
+ const parsed = await getArticleFullText(deps.ncbiApiKey, input.pmcId);
87
+ // "Not open-access" is an expected outcome — a data variant, not an error.
88
+ if (!parsed)
89
+ return ok({ pmcId: input.pmcId, available: false });
90
+ return ok({
91
+ pmcId: input.pmcId,
92
+ available: true,
93
+ fullText: parsed.fullText,
94
+ sections: parsed.sections,
95
+ });
96
+ }
97
+ }
98
+ },
99
+ });
100
+ }
@@ -8,17 +8,23 @@ import { SUPPORTED_SPECIES, bucketRank, getMultiSpeciesExpression, parseExpressi
8
8
  export { SUPPORTED_SPECIES, bucketRank, parseExpressionResponse };
9
9
  export const searchBgeeExpressionTool = defineTool({
10
10
  id: "search_bgee_expression",
11
- description: "Get baseline gene expression across species (human, mouse, rat, dog, macaque) from Bgee. " +
12
- "Returns per-species tissue × expression-rank tables. Use to assess whether a target is " +
13
- "expressed in tissues of interest, and whether model organisms are suitable surrogates. " +
14
- "NOT for differential expression — use the analysis pipeline for that.",
11
+ description: "Get BASELINE (healthy, untreated) gene expression across species from Bgee — use it to judge tissue-of-action ('where is this target expressed?') and model-organism " +
12
+ "suitability ('is the mouse a fair surrogate for this gene?'). " +
13
+ "NOT for differential expression between conditions — that is the analysis pipeline's job, never this tool. " +
14
+ "Takes a human gene symbol and resolves the per-species orthologs itself, returning a per-species tissue table: each tissue carries an expression score, a gold/silver confidence, " +
15
+ "an expressed / not-expressed state, and a bucketed rank (absent | low | medium | high). Species with no ortholog are listed in notFound. " +
16
+ "Sparse output is valid no-data: dog and macaque coverage is thin, and a null humanEnsemblId means the symbol did not resolve. Do NOT retry on empty output.",
15
17
  inputSchema: z.object({
16
- geneSymbol: z.string().min(1).describe("Human gene symbol, e.g. 'BRCA1'."),
18
+ geneSymbol: z
19
+ .string()
20
+ .min(1)
21
+ .describe("HUMAN gene symbol, e.g. 'BRCA1' — one gene per call. Ensembl IDs and non-human symbols are not accepted; the human symbol is resolved to an ENSG and orthologs are looked up per species."),
17
22
  species: z
18
23
  .array(z.enum(SUPPORTED_SPECIES))
19
24
  .min(1)
20
25
  .default([...SUPPORTED_SPECIES])
21
- .describe("Species to fetch. Defaults to all five supported: homo_sapiens, mus_musculus, " + "rattus_norvegicus, canis_lupus_familiaris, macaca_mulatta."),
26
+ .describe("Species to fetch, any subset of: homo_sapiens, mus_musculus, rattus_norvegicus, canis_lupus_familiaris, macaca_mulatta. " +
27
+ "Defaults to all five — narrow it only when the cross-species comparison is not needed."),
22
28
  }),
23
29
  execute: async ({ geneSymbol, species }) => ok(await getMultiSpeciesExpression(geneSymbol, species)),
24
30
  });
@@ -7,12 +7,23 @@ import { defineTool } from "../define-tool.js";
7
7
  import { searchTrials } from "../lib/clinical-trials-client.js";
8
8
  export const searchClinicalTrialsTool = defineTool({
9
9
  id: "search_clinical_trials",
10
- description: "Search ClinicalTrials.gov for clinical trials by condition, intervention (drug/gene therapy), or target gene. Use to understand the clinical landscape for a therapeutic target or disease indication.",
10
+ description: "Search ClinicalTrials.gov to map the clinical development landscape for a target, drug, or indication — what is being tried, at what phase, by whom, and what stopped. " +
11
+ "Returns totalFound plus per trial: NCT ID, title, status, phase, conditions, interventions, enrollment, start and completion dates, sponsor, brief summary, and whyStopped " +
12
+ "(the termination reason, when there is one). " +
13
+ "`query` is free text matched across the whole study record, so a gene symbol, a drug name, a condition, or an NCT ID all work. " +
14
+ "`phase` and `status` are optional server-side filters — omit them to see the full landscape; totalFound reports the true match count even when it exceeds the page returned. " +
15
+ "An empty trials array is a valid 'nothing matched' (often an over-narrow filter) — do not retry the identical query.",
11
16
  inputSchema: z.object({
12
- query: z.string().describe("Search term: condition name, drug name, gene symbol, or NCT ID"),
13
- phase: z.enum(["EARLY_PHASE1", "PHASE1", "PHASE2", "PHASE3", "PHASE4"]).optional().describe("Filter by trial phase"),
14
- status: z.enum(["RECRUITING", "ACTIVE_NOT_RECRUITING", "COMPLETED", "NOT_YET_RECRUITING"]).optional().describe("Filter by recruitment status"),
15
- limit: z.number().int().min(1).max(50).default(20).describe("Max trials to return"),
17
+ query: z.string().describe("Free-text search term: condition name, drug/intervention name, gene symbol, or an NCT ID."),
18
+ phase: z
19
+ .enum(["EARLY_PHASE1", "PHASE1", "PHASE2", "PHASE3", "PHASE4"])
20
+ .optional()
21
+ .describe("Optional exact phase filter. Omit for all phases (observational and expanded-access studies carry no phase and are excluded when this is set)."),
22
+ status: z
23
+ .enum(["RECRUITING", "ACTIVE_NOT_RECRUITING", "COMPLETED", "NOT_YET_RECRUITING"])
24
+ .optional()
25
+ .describe("Optional recruitment-status filter. Omit for all statuses — note TERMINATED/WITHDRAWN trials are not selectable here and appear only in an unfiltered search."),
26
+ limit: z.number().int().min(1).max(50).default(20).describe("Max trials to return (default 20, max 50)."),
16
27
  }),
17
28
  execute: async ({ query, phase, status, limit = 20 }) => {
18
29
  const result = await searchTrials(query, { phase, status, limit });
@@ -7,11 +7,15 @@ import { defineTool } from "../define-tool.js";
7
7
  import { getFaersByDrug } from "../lib/openfda-client.js";
8
8
  export const searchFaersTool = defineTool({
9
9
  id: "search_faers",
10
- description: "Search FDA FAERS (Adverse Event Reporting System) for adverse event reports associated with a drug. Returns the most frequently reported adverse reactions. Use to assess real-world safety signals for drugs or drug candidates targeting a specific gene/pathway.",
10
+ description: "Search FDA FAERS for post-market adverse-event reports on one marketed drug — the real-world safety signal for a specific molecule. " +
11
+ "Returns totalReports and adverseEvents[]: { reaction (MedDRA preferred term), count }, most-reported first. " +
12
+ "These are spontaneous report counts, NOT incidence rates: there is no denominator and reporting is heavily biased, so use them to rank signals, never to state a rate. " +
13
+ "Matching is on the openFDA GENERIC name only — 'imatinib' works, the brand name 'Gleevec' does not. For mechanism-based liabilities of a target (rather than a drug), use opentargets({action:\"safety\"}). " +
14
+ "An empty adverseEvents array is valid no-data (drug absent from FAERS under that generic name) — do not retry.",
11
15
  inputSchema: z.object({
12
- drugName: z.string().describe("Drug generic name (e.g. imatinib, pembrolizumab)"),
13
- limit: z.number().int().min(1).max(100).default(25).describe("Max adverse event types to return"),
14
- serious: z.boolean().default(false).describe("Filter to serious adverse events only"),
16
+ drugName: z.string().describe("Generic (INN) drug name, e.g. 'imatinib', 'pembrolizumab'. Brand names do not match."),
17
+ limit: z.number().int().min(1).max(100).default(25).describe("Max distinct adverse-reaction terms to return (default 25, max 100)."),
18
+ serious: z.boolean().default(false).describe("When true, count only reports flagged serious (death, hospitalization, life-threatening, disabling)."),
15
19
  }),
16
20
  execute: async ({ drugName, limit = 25, serious = false }) => {
17
21
  const result = await getFaersByDrug(drugName, { limit, serious });
@@ -38,17 +38,31 @@ const GeoEsummaryResponseSchema = z.object({
38
38
  });
39
39
  export const searchGeoDatasetsTool = defineTool({
40
40
  id: "search_geo_datasets",
41
- description: "Search NCBI GEO for public gene expression datasets by disease, tissue, or experimental condition. Returns dataset accessions and metadata for identifying external validation cohorts. Note: sandbox containers cannot download GEO data directly — use this tool to identify relevant datasets, then reference accessions in analysis plans.",
41
+ description: "Search NCBI GEO for public gene-expression datasets by disease, tissue, or experimental condition — use it to find external validation cohorts or to cite published data. " +
42
+ "Returns totalFound plus datasets[]: { accession (GSE…/GDS…), title, summary (truncated to 500 chars), platform, sampleCount, organism, pubmedIds }. " +
43
+ "HARD CAVEAT: sandbox containers cannot download GEO data. This tool identifies and cites accessions only — never plan an analysis step that fetches a GEO dataset. " +
44
+ "An empty datasets array is a valid 'nothing matched' — do not retry the identical query.",
42
45
  inputSchema: z.object({
43
- query: z.string().describe("Search terms (e.g. 'breast cancer RNA-seq responder', 'NSCLC pembrolizumab')"),
44
- organism: z.string().optional().describe("Filter by organism (e.g. 'Homo sapiens')"),
45
- datasetType: z.enum(["gds", "gse"]).default("gse").describe("GDS (curated datasets) or GSE (series, more numerous)"),
46
- limit: z.number().int().min(1).max(50).default(15).describe("Max results"),
46
+ query: z
47
+ .string()
48
+ .describe("NCBI Entrez free-text query (e.g. 'breast cancer RNA-seq responder', 'NSCLC pembrolizumab'). Entrez field tags are allowed."),
49
+ organism: z
50
+ .string()
51
+ .optional()
52
+ .describe("Optional organism filter, appended as an [Organism] term. Use the scientific name, e.g. 'Homo sapiens', 'Mus musculus'."),
53
+ datasetType: z
54
+ .enum(["gds", "gse"])
55
+ .default("gse")
56
+ .describe("'gse' (default) — GEO Series: raw submitter-deposited studies, far more numerous. 'gds' — curated DataSets: fewer, but normalized and value-added."),
57
+ limit: z.number().int().min(1).max(50).default(15).describe("Max datasets to return (default 15, max 50)."),
47
58
  }),
48
59
  execute: async ({ query, organism, datasetType = "gse", limit = 15 }) => {
49
60
  let searchQuery = query;
61
+ // Escape backslashes first, then quotes, so a `\` in the organism value
62
+ // cannot smuggle an unescaped `"` past the escaping and break out of the
63
+ // quoted Entrez phrase.
50
64
  if (organism)
51
- searchQuery += ` AND "${organism.replace(/"/g, '\\"')}"[Organism]`;
65
+ searchQuery += ` AND "${organism.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"[Organism]`;
52
66
  const db = "gds";
53
67
  const typeFilter = datasetType === "gse" ? " AND gse[Entry Type]" : " AND gds[Entry Type]";
54
68
  searchQuery += typeFilter;
@@ -23,7 +23,7 @@ interface GwasAssociation {
23
23
  }
24
24
  export declare const searchGwasCatalogTool: import("../define-tool.js").Tool<{
25
25
  query: string;
26
- searchType: "gene" | "trait" | "variant";
26
+ searchType: "gene" | "variant" | "trait";
27
27
  pValueThreshold: number;
28
28
  limit: number;
29
29
  }, {
@@ -20,11 +20,17 @@ const PharmgkbResponseSchema = z.object({
20
20
  });
21
21
  export const searchPharmgkbTool = defineTool({
22
22
  id: "search_pharmgkb",
23
- description: "Search PharmGKB for pharmacogenomic clinical annotations — gene-drug interactions, dosing guidelines (CPIC/DPWG), and variant-level clinical significance. Use to determine if a gene variant affects drug metabolism, efficacy, or toxicity.",
23
+ description: "Search PharmGKB clinical annotations for a gene-drug pharmacogenomic link — use it to judge whether a gene affects a drug's metabolism, efficacy, or toxicity. " +
24
+ "Returns annotations[]: { gene, drug, levelOfEvidence (PharmGKB 1A…4, where 1A is guideline-backed) }. The phenotype, guidelineSource and summary fields are always null " +
25
+ "in this tool's output — treat levelOfEvidence as the only strength signal and do not attribute CPIC/DPWG guideline text to it. " +
26
+ "Matching is an exact field filter, not a search: 'CYP2D6' resolves, 'cytochrome P450 2D6' does not. " +
27
+ "An empty annotations array is valid no-data (no curated PGx link) — do not retry.",
24
28
  inputSchema: z.object({
25
- query: z.string().describe("Gene symbol (e.g. CYP2D6) or drug name (e.g. tamoxifen)"),
26
- searchType: z.enum(["gene", "drug"]).describe("Search by gene or drug"),
27
- limit: z.number().int().min(1).max(50).default(20).describe("Max results"),
29
+ query: z
30
+ .string()
31
+ .describe("Exact gene symbol (e.g. 'CYP2D6') when searchType='gene', or exact drug name (e.g. 'tamoxifen') when searchType='drug'. No fuzzy matching."),
32
+ searchType: z.enum(["gene", "drug"]).describe("'gene' filters PharmGKB on location.genes.symbol; 'drug' filters on relatedChemicals.name."),
33
+ limit: z.number().int().min(1).max(50).default(20).describe("Max annotations to return (default 20, max 50); applied client-side after the fetch."),
28
34
  }),
29
35
  execute: async ({ query, searchType, limit = 20 }) => {
30
36
  const filter = searchType === "gene" ? `location.genes.symbol=${encodeURIComponent(query)}` : `relatedChemicals.name=${encodeURIComponent(query)}`;
@@ -1,4 +1,5 @@
1
1
  /**
2
2
  * Display barrel — user-facing presentation cards streamed to chat.
3
3
  */
4
+ export * from "./normalize-echart-spec.js";
4
5
  export * from "./show-user.js";
@@ -1,4 +1,5 @@
1
1
  /**
2
2
  * Display barrel — user-facing presentation cards streamed to chat.
3
3
  */
4
+ export * from "./normalize-echart-spec.js";
4
5
  export * from "./show-user.js";
@@ -0,0 +1,61 @@
1
+ /**
2
+ * ECharts layout normalization — the renderer-side invariant behind `show_user(kind: "echart")`.
3
+ *
4
+ * The layout discipline (no duplicate title, bottom legend, grid margins that fit the axis labels,
5
+ * every category label shown, a downloadable chart) is applied HERE, deterministically, rather than
6
+ * being a checklist the model must remember on every chart it composes. A rule a model must remember
7
+ * is a rule it can forget.
8
+ *
9
+ * Two invariants govern every rule below:
10
+ *
11
+ * 1. **Defaults, not overrides.** Every property is filled in only when the author left it unset —
12
+ * an explicit, sensible authored `grid`/`legend`/`rotate`/filename is respected. The two forced
13
+ * fields are `title` (deleted: the `show_user` `title` param is the card heading, so an in-spec
14
+ * title is a genuine duplicate render) and `axisLabel.interval` (pinned to `0`: ECharts' default
15
+ * `"auto"` SILENTLY DROPS labels, which is the exact bug this rule exists to prevent).
16
+ * 2. **Never turn a valid chart invalid.** Nothing here is derived from data that may be absent: a
17
+ * property is only injected where it is meaningful (a `grid` only for a cartesian chart), and an
18
+ * unknown category count yields the safe layout rather than a guessed one.
19
+ *
20
+ * Pure: the input spec is never mutated. Modified branches are copy-on-write; untouched branches are
21
+ * shared by reference. `normalize(normalize(x))` deep-equals `normalize(x)`.
22
+ */
23
+ export interface NormalizeEchartSpecOptions {
24
+ /**
25
+ * The `show_user` `title` param — the card heading rendered above the chart canvas. Seeds the
26
+ * `saveAsImage` download filename when the author supplied none.
27
+ */
28
+ title?: string;
29
+ }
30
+ /**
31
+ * Kebab-case download filename: `"HMGCR — VST Expression"` → `"hmgcr-vst-expression"`.
32
+ * Diacritics are folded, every other non-alphanumeric run collapses to a single dash.
33
+ */
34
+ export declare function kebabFilename(source: string | undefined): string;
35
+ /**
36
+ * Category count on the x axis, or `null` when the spec cannot tell us.
37
+ *
38
+ * `null` is the honest answer for a CSV-backed chart (`show_user`'s `dataPath`): the rows are loaded
39
+ * by the host at render time and deliberately never pulled through the model's context window, so
40
+ * they are NOT in the spec. Guessing a rotation for an axis of unknown length is worse than not
41
+ * rotating — an unrotated axis is also exactly what the other `null` cases (a value axis: scatter,
42
+ * time series) want. The count is used ONLY to pick a label rotation; it never touches the data, so
43
+ * an off-by-one at a bucket boundary costs at most a rotation, never a rendering.
44
+ */
45
+ export declare function categoryCount(spec: Record<string, unknown>): number | null;
46
+ /**
47
+ * Apply the ECharts layout invariant to an author-composed spec.
48
+ *
49
+ * - `title` is deleted (always) — the `show_user` `title` param is the visible card heading, and
50
+ * `title.subtext` is the documented misuse for stats that belong in the tooltip.
51
+ * - `legend` defaults to `{ bottom: 0 }` for a multi-series chart and `{ show: false }` for a
52
+ * single-series one (nothing to disambiguate); an authored legend is left alone.
53
+ * - `grid` (cartesian charts only) gets `top` 8% — 12% when a `graphic` annotation shares the canvas
54
+ * — `bottom` 20% / 25% (rotated labels) / 30% (rotated labels above a bottom legend), `left` 10%,
55
+ * `right` 5%. Only unset keys are filled.
56
+ * - `axisLabel.interval` is pinned to `0` on every axis; the x axis is rotated 45° (11–20 categories)
57
+ * or 90° (>20) unless the author chose a rotation.
58
+ * - `toolbox.feature.saveAsImage` is injected with `type: "png"` and a kebab-case filename derived
59
+ * from the `show_user` title (falling back to the stripped in-spec title, then `"chart"`).
60
+ */
61
+ export declare function normalizeEchartSpec(spec: Record<string, unknown>, opts?: NormalizeEchartSpecOptions): Record<string, unknown>;
@@ -0,0 +1,211 @@
1
+ /**
2
+ * ECharts layout normalization — the renderer-side invariant behind `show_user(kind: "echart")`.
3
+ *
4
+ * The layout discipline (no duplicate title, bottom legend, grid margins that fit the axis labels,
5
+ * every category label shown, a downloadable chart) is applied HERE, deterministically, rather than
6
+ * being a checklist the model must remember on every chart it composes. A rule a model must remember
7
+ * is a rule it can forget.
8
+ *
9
+ * Two invariants govern every rule below:
10
+ *
11
+ * 1. **Defaults, not overrides.** Every property is filled in only when the author left it unset —
12
+ * an explicit, sensible authored `grid`/`legend`/`rotate`/filename is respected. The two forced
13
+ * fields are `title` (deleted: the `show_user` `title` param is the card heading, so an in-spec
14
+ * title is a genuine duplicate render) and `axisLabel.interval` (pinned to `0`: ECharts' default
15
+ * `"auto"` SILENTLY DROPS labels, which is the exact bug this rule exists to prevent).
16
+ * 2. **Never turn a valid chart invalid.** Nothing here is derived from data that may be absent: a
17
+ * property is only injected where it is meaningful (a `grid` only for a cartesian chart), and an
18
+ * unknown category count yields the safe layout rather than a guessed one.
19
+ *
20
+ * Pure: the input spec is never mutated. Modified branches are copy-on-write; untouched branches are
21
+ * shared by reference. `normalize(normalize(x))` deep-equals `normalize(x)`.
22
+ */
23
+ /** Max length of a derived `saveAsImage` filename. */
24
+ const MAX_FILENAME_LEN = 64;
25
+ /** Fallback download filename when no heading is available to derive one from. */
26
+ const DEFAULT_FILENAME = "chart";
27
+ function isRecord(value) {
28
+ return typeof value === "object" && value !== null && !Array.isArray(value);
29
+ }
30
+ /** The axis entries of an `xAxis`/`yAxis` option, which ECharts accepts as one object or an array. */
31
+ function axisEntries(axis) {
32
+ if (Array.isArray(axis))
33
+ return axis.filter(isRecord);
34
+ return isRecord(axis) ? [axis] : [];
35
+ }
36
+ /**
37
+ * Kebab-case download filename: `"HMGCR — VST Expression"` → `"hmgcr-vst-expression"`.
38
+ * Diacritics are folded, every other non-alphanumeric run collapses to a single dash.
39
+ */
40
+ export function kebabFilename(source) {
41
+ const slug = (source ?? "")
42
+ .normalize("NFKD")
43
+ .replace(/[\u0300-\u036f]/g, "")
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9]+/g, "-")
46
+ .slice(0, MAX_FILENAME_LEN)
47
+ .replace(/^-+|-+$/g, "");
48
+ return slug.length > 0 ? slug : DEFAULT_FILENAME;
49
+ }
50
+ /** Declared series count. `0` = none declared, which is also "nothing a legend could label". */
51
+ function seriesCount(spec) {
52
+ const series = spec.series;
53
+ if (Array.isArray(series))
54
+ return series.length;
55
+ return isRecord(series) ? 1 : 0;
56
+ }
57
+ /** True when any series maps its categories to dataset COLUMNS rather than rows. */
58
+ function laysOutByRow(spec) {
59
+ const series = Array.isArray(spec.series) ? spec.series : [spec.series];
60
+ return series.some((s) => isRecord(s) && s.seriesLayoutBy === "row");
61
+ }
62
+ /** Header rows to discount from a `dataset.source` array, per ECharts' `sourceHeader` rules. */
63
+ function headerRowCount(dataset, source) {
64
+ const declared = dataset.sourceHeader;
65
+ if (declared === true)
66
+ return 1;
67
+ if (declared === false)
68
+ return 0;
69
+ if (typeof declared === "number" && Number.isFinite(declared)) {
70
+ return Math.min(Math.max(Math.trunc(declared), 0), source.length);
71
+ }
72
+ // `"auto"` (the default): a leading row of all-strings in front of further rows is dimension names.
73
+ const first = source[0];
74
+ return source.length > 1 && Array.isArray(first) && first.length > 0 && first.every((cell) => typeof cell === "string") ? 1 : 0;
75
+ }
76
+ /**
77
+ * Category count on the x axis, or `null` when the spec cannot tell us.
78
+ *
79
+ * `null` is the honest answer for a CSV-backed chart (`show_user`'s `dataPath`): the rows are loaded
80
+ * by the host at render time and deliberately never pulled through the model's context window, so
81
+ * they are NOT in the spec. Guessing a rotation for an axis of unknown length is worse than not
82
+ * rotating — an unrotated axis is also exactly what the other `null` cases (a value axis: scatter,
83
+ * time series) want. The count is used ONLY to pick a label rotation; it never touches the data, so
84
+ * an off-by-one at a bucket boundary costs at most a rotation, never a rendering.
85
+ */
86
+ export function categoryCount(spec) {
87
+ for (const axis of axisEntries(spec.xAxis)) {
88
+ // An explicit category list is unambiguous — prefer it over any dataset inference.
89
+ if (Array.isArray(axis.data))
90
+ return axis.data.length;
91
+ }
92
+ // An array of datasets, an object-of-columns `source`, or a row-major layout: do not guess.
93
+ const dataset = spec.dataset;
94
+ if (!isRecord(dataset) || laysOutByRow(spec))
95
+ return null;
96
+ const source = dataset.source;
97
+ if (!Array.isArray(source) || source.length === 0)
98
+ return null;
99
+ return Math.max(0, source.length - headerRowCount(dataset, source));
100
+ }
101
+ /** The label rotation for `count` categories, or `null` for "leave the labels horizontal". */
102
+ function rotationFor(count) {
103
+ if (count === null || count <= 10)
104
+ return null;
105
+ // >20 categories: ECharts' own escape hatch is a `dataZoom` slider, but that rewrites the chart's
106
+ // interaction model and its vertical budget — a 90° turn is the mutation that cannot surprise.
107
+ return count <= 20 ? 45 : 90;
108
+ }
109
+ /** True when the legend (authored or defaulted) sits below the chart and consumes bottom margin. */
110
+ function isBottomLegend(legend) {
111
+ const entries = Array.isArray(legend) ? legend.filter(isRecord) : isRecord(legend) ? [legend] : [];
112
+ return entries.some((entry) => entry.show !== false && entry.bottom !== undefined);
113
+ }
114
+ /** True when a component option carries any explicit placement — adding another would fight it. */
115
+ function hasPlacement(option) {
116
+ return option.left !== undefined || option.right !== undefined || option.top !== undefined || option.bottom !== undefined;
117
+ }
118
+ /**
119
+ * Apply the ECharts layout invariant to an author-composed spec.
120
+ *
121
+ * - `title` is deleted (always) — the `show_user` `title` param is the visible card heading, and
122
+ * `title.subtext` is the documented misuse for stats that belong in the tooltip.
123
+ * - `legend` defaults to `{ bottom: 0 }` for a multi-series chart and `{ show: false }` for a
124
+ * single-series one (nothing to disambiguate); an authored legend is left alone.
125
+ * - `grid` (cartesian charts only) gets `top` 8% — 12% when a `graphic` annotation shares the canvas
126
+ * — `bottom` 20% / 25% (rotated labels) / 30% (rotated labels above a bottom legend), `left` 10%,
127
+ * `right` 5%. Only unset keys are filled.
128
+ * - `axisLabel.interval` is pinned to `0` on every axis; the x axis is rotated 45° (11–20 categories)
129
+ * or 90° (>20) unless the author chose a rotation.
130
+ * - `toolbox.feature.saveAsImage` is injected with `type: "png"` and a kebab-case filename derived
131
+ * from the `show_user` title (falling back to the stripped in-spec title, then `"chart"`).
132
+ */
133
+ export function normalizeEchartSpec(spec, opts = {}) {
134
+ const { title: specTitle, ...out } = spec;
135
+ // ── Legend ──────────────────────────────────────────────────────────
136
+ const legendAuthored = out.legend !== undefined && out.legend !== null;
137
+ if (!legendAuthored) {
138
+ const count = seriesCount(out);
139
+ if (count >= 2)
140
+ out.legend = { bottom: 0 };
141
+ // A single series has nothing to disambiguate: hide it explicitly rather than omit it, so a
142
+ // themed legend component cannot reintroduce an orphan swatch. An undeclared series count
143
+ // (0) gets no legend option at all — there is nothing to show or hide.
144
+ else if (count === 1)
145
+ out.legend = { show: false };
146
+ }
147
+ const bottomLegend = isBottomLegend(out.legend);
148
+ // ── Axis labels ─────────────────────────────────────────────────────
149
+ const rotate = rotationFor(categoryCount(out));
150
+ let rotated = false;
151
+ // Rotation is an x-axis rule: turning a horizontal bar chart's y-axis category labels on their
152
+ // side would be actively wrong, so only `interval` crosses to the y axis (where it is inert on a
153
+ // value axis and prevents silent label-skipping on a category one).
154
+ const normalizeAxis = (axis, isX) => {
155
+ const axisLabel = isRecord(axis.axisLabel) ? { ...axis.axisLabel } : {};
156
+ axisLabel.interval = 0;
157
+ if (isX && rotate !== null && axisLabel.rotate === undefined)
158
+ axisLabel.rotate = rotate;
159
+ if (isX && typeof axisLabel.rotate === "number" && axisLabel.rotate !== 0)
160
+ rotated = true;
161
+ return { ...axis, axisLabel };
162
+ };
163
+ for (const key of ["xAxis", "yAxis"]) {
164
+ const axis = out[key];
165
+ // A malformed entry is passed through rather than dropped — normalization rewrites layout,
166
+ // never the shape of the spec it was handed.
167
+ if (Array.isArray(axis))
168
+ out[key] = axis.map((entry) => (isRecord(entry) ? normalizeAxis(entry, key === "xAxis") : entry));
169
+ else if (isRecord(axis))
170
+ out[key] = normalizeAxis(axis, key === "xAxis");
171
+ }
172
+ // ── Grid ────────────────────────────────────────────────────────────
173
+ const cartesian = out.xAxis !== undefined || out.yAxis !== undefined;
174
+ const grid = out.grid;
175
+ // An array of grids, or a grid the author sized explicitly (`width`/`height`): a margin added on
176
+ // top of an explicit box could over-constrain it. Non-cartesian charts (pie, sunburst, graph)
177
+ // have no grid to lay out.
178
+ const gridNormalizable = cartesian && !Array.isArray(grid) && (grid === undefined || (isRecord(grid) && grid.width === undefined && grid.height === undefined));
179
+ if (gridNormalizable) {
180
+ const authored = isRecord(grid) ? grid : {};
181
+ out.grid = {
182
+ ...authored,
183
+ ...(authored.top === undefined ? { top: out.graphic !== undefined ? "12%" : "8%" } : {}),
184
+ ...(authored.bottom === undefined ? { bottom: rotated ? (bottomLegend ? "30%" : "25%") : "20%" } : {}),
185
+ ...(authored.left === undefined ? { left: "10%" } : {}),
186
+ ...(authored.right === undefined ? { right: "5%" } : {}),
187
+ };
188
+ }
189
+ // ── Toolbox ─────────────────────────────────────────────────────────
190
+ if (!Array.isArray(out.toolbox)) {
191
+ const authored = isRecord(out.toolbox) ? out.toolbox : {};
192
+ const feature = isRecord(authored.feature) ? authored.feature : {};
193
+ const saveAsImage = isRecord(feature.saveAsImage) ? feature.saveAsImage : {};
194
+ // The in-spec title is stripped, but it is still the author's own description of the chart —
195
+ // a better filename seed than the generic fallback when no card title was given.
196
+ const specTitleText = isRecord(specTitle) && typeof specTitle.text === "string" ? specTitle.text : undefined;
197
+ out.toolbox = {
198
+ ...authored,
199
+ ...(hasPlacement(authored) ? {} : { right: 0, top: 0 }),
200
+ feature: {
201
+ ...feature,
202
+ saveAsImage: {
203
+ ...saveAsImage,
204
+ ...(saveAsImage.type === undefined ? { type: "png" } : {}),
205
+ ...(saveAsImage.name === undefined ? { name: kebabFilename(opts.title ?? specTitleText) } : {}),
206
+ },
207
+ },
208
+ };
209
+ }
210
+ return out;
211
+ }
@@ -9,6 +9,11 @@
9
9
  * downstream reconciliation concern of the emit pipeline (change 3), not a
10
10
  * per-request `Set` smuggled through an ambient context bag.
11
11
  *
12
+ * An `echart` spec's layout is normalized (`normalize-echart-spec.ts`) inside
13
+ * `buildPresentationCardData`, the one construction site both this tool and the
14
+ * reconstruct-on-read path share — the model authors the data, the renderer owns
15
+ * the layout.
16
+ *
12
17
  * For stored plans, use `show_plan`. For existing files, use `show_file`.
13
18
  */
14
19
  type ShowUserOutput = {
@@ -18,7 +23,7 @@ type ShowUserOutput = {
18
23
  id: string;
19
24
  };
20
25
  export declare const showUserTool: import("../define-tool.js").Tool<{
21
- kind: "code" | "echart" | "markdown" | "svg" | "table";
26
+ kind: "code" | "markdown" | "echart" | "svg" | "table";
22
27
  title?: string | undefined;
23
28
  spec?: Record<string, unknown> | undefined;
24
29
  dataPath?: string | undefined;