Turns Claude Code into a rare-disease research workstation: phenotype-driven diagnosis, variant interpretation (ACMG), sequence-to-function models…

A Claude Code mod that turns Claude Code into a rare-disease research workstation — for patients and families on a diagnostic odyssey, for clinicians, and for researchers.
"When you hear hoofbeats, think horses, not zebras." Medicine is taught to expect the common. People with rare diseases call themselves zebras because their answers lie off the beaten path. zebra-mod is built to look there — and to show its evidence for every step.
| Job | How zebra-mod does it |
|---|---|
| "What could this be?" — phenotype-driven differential diagnosis | Records → HPO profile (verified terms, present/excluded, onset, source page) → three independent rankings (offline Resnik over HPO annotations, Monarch semantic similarity, PubCaseFinder) kept separate, agreement as signal → discriminating features → which test finds each candidate (exome, genome, CMA, repeat assays, methylation, mtDNA, metabolic) |
| "What does this variant mean?" — ACMG/AMP interpretation | VEP (MANE, AlphaMissense, REVEL, CADD) + SpliceAI at ±4,999 nt + gnomAD (grpmax, faf95; mtDNA heteroplasmy counts) + ClinVar (n of 4 stars) + ClinGen validity and dosage + MaveDB scores under their own calibration + LitVar (other alleles at the position excluded) + Chinese-cohort frequencies → data-driven codes with ClinGen-calibrated thresholds, PVS1 and PS1/PM5 inputs → your judged codes → points computed by code (Tavtigian 2020) and the 2015 combining rules side by side; GRCh37 input mapped to GRCh38 |
| Sequence-to-function (S2F) — splicing, non-coding, regulatory | SpliceAI/Pangolin (Broad lookup), AlphaGenome, Evo 2 and GPN-MSA through the s2f-penguin s2f CLI; axis by axis with claim ceilings, combined by agreement, never averaged; mechanism → how to confirm with RNA, in a tissue GTEx shows the gene is expressed in; a splice-switching antisense screen (aso_screen: the aberrant event checked in the patient sequence, candidate target windows, published N-of-1 precedents) |
| The other report forms — microarray/CNV, exon-level deletions, SMN1 copy number, repeat expansions | cnv_interpret: genes covered, ClinGen dosage sensitivity, ACMG/ClinGen CNV evidence inputs (never a classification), and for an out-of-frame exon deletion the frame arithmetic that says which exon skipping would restore it |
| Exome/genome reanalysis on your own machine | zebra qc: sex check, KING kinship (swaps, non-paternity), runs of homozygosity, Mendelian errors and uniparental disomy, mosaic de novo calls. VCF triage: quality, inheritance models (de novo, homozygous, compound het, X-hemizygous), PED input, phenotype-gene restriction, SpliceAI on the top splice/non-coding candidates; a whole exome through a MyVariant frequency prefilter. The VCF file stays on the computer; variant positions go to the annotation services — by default only the filtered candidates, with the prefilter every variant (the mod asks first) |
| Genotype → therapy (G2T) | Mechanism first (LoF / GoF / DN / splicing / repeat) → approved and investigational drugs (Open Targets / ChEMBL), access: FDA and EMA status from the agencies' own records, approval in China from official NMPA/CDE documents, China's 2025 reimbursement list with its restriction text, trials with sites in China; literature, N-of-1 screens (antisense, gene replacement, base editing feasibility) → leads tiered A–E with a mechanism-fit check |
| Classical statistics | Cosegregation LR → PP1, maximum credible allele frequency (BS1), carrier frequency and genetic prevalence, recurrence risk (incl. Bayesian X-linked), Fisher/burden, de novo enrichment, Kaplan–Meier natural history + log-rank, N-of-1 trial design and analysis |
| Literature | Europe PMC, PubTator3, LitVar2 — PMIDs only as returned, findings quoted from the paper |
| For families | Plain-language explanations (Chinese by default for Chinese speakers), VUS explained honestly, visit preparation, recurrence and cascade testing, patient organisations, China's national rare disease lists and the collaboration-network hospitals by province |
| Over months and years | case_recheck asks the case's questions again — ClinVar class and stars of each variant, ClinGen validity of its genes, recruiting trials and new papers for each open hypothesis — and reports what changed since the last check, into the case timeline |
| Reports | Clinician summary, family letter and visit-preparation sheet, every claim cited, an independent adversarial audit before it is final, then exported to Word and PDF with Chinese typography — so a clinician or volunteer can run zebra-mod for a family and hand them the files |
E1, E2, …) of every source used.zebra engine; the model never writes or adjusts a number.case_update → identifiers when records name the patient), and redact before sharing if that matters to you. Exports to Word/PDF refuse a report that still holds a registered identifier or an ID number. Matching is best effort over encodings and spellings, not a guarantee.Measured, not claimed — docs/BENCHMARK.md has the method, the data digests and the confidence intervals.
evals/ holds 10 end-to-end cases for claude plugin eval (a Chinese parent's records, urgent symptoms first, a VUS, a CNV, mtDNA, therapy and trials, privacy, an out-of-scope request).mcp__zebra-mod__*): case_status, case_update, case_recheck, hpo_search, phenotype_rank, gene_card, variant_card, disease_card, acmg, cnv_interpret, s2f_predict, therapy_landscape, trials_search, literature_search, rare_stats, edit_check, china_rare, access, expression, aso_screen, report_export. Every call goes through your permission rules and the privacy gate; read-only lookups skip the prompt unless a rule of yours says otherwise, and case writes follow your permission mode (with an "allow for this session" choice).🦓 Variant card NM_001165963.4:c.2134C>T, then ⎿ 15 evidence rows (E39–E53) · from Ensembl VEP, gnomAD, ClinVar, LitVar2 · 3 cached) instead of a JSON envelope, and a case_update row never shows the identifiers it registers; the spinner names the databases being queried; a small galloping zebra above the prompt while a query runs, a shield there after the privacy gate stops a call; a 🦓 label in the footer (with the open case's title); and one line at the end of a turn that used it — calls, databases, evidence rows, calls stopped. Chinese or English, from the case's language or your prompts. Option interface: full (default), quiet (no animation, no spinner text) or off./zebra board) and the case in the footer label (a pinned status line when interface is off): phenotypes, variants and their research class, hypotheses, therapy leads, open questions, evidence count — live as the case changes./zebra command: with no argument, the guide (how to use it, what it does); demo (the synthetic demo case, first question ready), new <dir> [title], case <dir>, board, ledger, doctor, close./zebra-mod:zebra-start routes): zebra-safety (urgent red flags and disease-specific drug, anaesthesia and procedure hazards), zebra-intake, zebra-diagnose, zebra-variant, zebra-reanalysis, zebra-s2f, zebra-therapy, zebra-stats, zebra-literature, zebra-family, zebra-report.phenotype-curator, variant-curator, s2f-analyst, therapy-scout, literature-scout, evidence-auditor.zebra CLI (Python standard library only, Python ≥ 3.9): the single implementation behind every tool, usable from any shell, notebook or other agent.Requirements: Claude Code ≥ 2.1.289 (function-hook mods) and Python ≥ 3.9. No sudo, no pip installs.
One sentence. In Claude Code, say:
Install https://github.com/zwbao/zebra-mod
Claude Code reads INSTALL.md and does the rest: checks versions and Python, registers and installs the plugin, downloads the HPO release (~80 MB), and runs zebra doctor. Then start a new session (or type /reload-plugins) and run /zebra-mod:zebra-start.
One command (the same steps):
curl -fsSL https://raw.githubusercontent.com/zwbao/zebra-mod/main/install.sh | sh
# while the repository is private, clone it first:
git clone https://github.com/zwbao/zebra-mod ~/zebra-mod && ~/zebra-mod/install.sh
By hand:
claude plugin marketplace add zwbao/zebra-mod
claude plugin install zebra-mod@zebra-mod
python3 <installPath>/bin/zebra hpo fetch # optional: offline phenotype ranking, instant HPO search
# one session only, no install: claude --plugin-dir ~/zebra-mod
Optional heavy S2F models: install the s2f CLI from s2f-penguin (uv tool install "git+https://github.com/zwbao/s2f-penguin"), and set ALPHAGENOME_API_KEY (AlphaGenome: non-commercial, not for clinical decisions) and/or NVCF_RUN_KEY (Evo 2 on NVIDIA). Check everything with /zebra doctor.
Options (/config → zebra-mod, or pluginConfigs in settings): python (interpreter), doctrine (auto by default: the full rules while a case is open, otherwise a short section that only applies to rare-disease questions, so the rest of your work in Claude Code is untouched; or always | case | off), privacyGate (on by default; with no case open, an email or phone number in another tool's call is asked about rather than refused), interface (full by default: its own tool rows, the spinner text, the galloping zebra and the end-of-turn line; quiet without the animation and spinner text; off for Claude Code's own drawing only).
Just ask. Describe symptoms, paste test results or a genetic report, or ask about a variant, a disease, a treatment or a trial, in English or Chinese: Claude calls zebra-mod's tools by itself. There are no commands to learn; /zebra shows what it does.
Try it first: /zebra demo opens a synthetic demo case (a clinic note and a genetic report, identifiers already registered; the installer creates it at ~/zebra-cases/demo-lily or demo-xiaoyu) and puts the first question in the prompt box. Press Enter and watch it read the records, rank a differential and interpret the variant.
With your own records:
/zebra new ~/cases/lily "Lily — seizures since 6 months"
# put reports, lab sheets, the genetic report in ~/cases/lily/records/
/zebra-mod:zebra-intake
/zebra-mod:zebra-diagnose
/zebra-mod:zebra-variant NM_001165963.4:c.2134C>T
/zebra-mod:zebra-therapy
/zebra-mod:zebra-report family letter in Chinese
Or just ask in your own words — "我女儿6个月开始发热抽搐,基因报告说SCN1A有个变异,这是什么意思?" — the router skill takes it from there.
Researchers can drive the engine directly:
zebra hpo rank HP:0002373 HP:0007359 HP:0002133 HP:0001263 --exclude HP:0001252
zebra phenotype rank --present HP:0002373 HP:0001263 --sources local,monarch,pubcasefinder
zebra variant NM_000492.4:c.1652G>A --json
zebra acmg suggest NM_001165963.4:c.2134C>T --inheritance AD
zebra acmg classify PVS1 PS2 PM2_Supporting
zebra s2f predict 7-117559590-ATCT-A --models spliceai,pangolin
zebra vcf triage trio.vcf.gz --proband P --mother M --father F --sex female --hpo-genes --case ~/cases/lily
zebra stats maxaf --prevalence 0.0000625 --allelic 0.05 --penetrance 0.9 --faf95 0.00012
zebra edit 1-12345678-T-C --assembly GRCh38
.claude-plugin/plugin.json manifest (+ marketplace.json)
hooks/register.tsx the mod: tools, doctrine, case board, /zebra, privacy gate
hooks/{tools,doctrine,privacy}.ts
types/index.d.ts the mod's state contract
skills/<name>/SKILL.md 12 skills (vercel-labs/skills layout)
agents/*.md 6 subagents
zebra/ Python engine (stdlib only): sources/, commands/, acmg, stats, hpo_local, vcf, s2f, editing, case
bin/zebra CLI launcher
tests/ pytest (offline + live-marked) and mod tests
docs/ PRD, architecture, data sources and terms
zebra-mod queries public resources live and keeps their provenance; each keeps its own licence (see docs/DATA-SOURCES.md). Notably: AlphaGenome outputs are non-commercial and not for clinical decision-making; SpliceAI weights are CC BY-NC; OMIM content is not redistributed.
zebra-mod produces research-grade analyses to help people ask better questions. It does not diagnose, prescribe, or replace a clinician or genetic counsellor.
zebra-mod's sequence-to-function (S2F) layer builds on s2f-penguin: sequence-to-function models with run receipts, claim ceilings, and triangulation by logic rather than arithmetic. zebra-mod calls its s2f CLI for the heavy models.
The galloping zebra above the prompt is traced from Eadweard Muybridge's The Horse in Motion (1878, public domain): eleven positions of one stride, the rider removed, aligned on the back so the body stays level, drawn in braille (tools/zebra_sprite/build.py rebuilds it).
MIT licence © 2026 zwbao.
hooks/register.tsx 906 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Board, Ready } from '../types'
5import { DOCTRINE, DOCTRINE_BRIEF, renderDoctrine } from './doctrine'
6import { schemaArgs, TOOLS, toolArgv, type ToolDef } from './tools'
7import { registerUi } from './ui'
8import { guardInput, isOutboundShell, outboundText, sendsVariantList, shellWords, uploadedPaths, uploadsGenome } from './privacy'
9
10const PLUGIN = 'zebra-mod'
11const PANE = 'zebra-board'
12const TOOL_PREFIX = `mcp__${PLUGIN}__`
13const MAX_RESULT_CHARS = 60_000
14const MAX_ARTIFACT_SCAN = 200_000
15// The case summary is pinned as a status line only when the mod's own drawing is off: Claude Code
16// draws pinned lines as notices (with a warning sign), and ui.tsx carries the summary in the footer.
17// A case that cannot be read is still pinned: that one is a warning.
18let pinCaseStatus = false
19
20// Tools that never reach the network: they read and write the local case only.
21const LOCAL_TOOLS = new Set(['case_status', 'case_update', 'report_export'])
22// Tools whose answer is a lookup with no side effect, safe to run without asking.
23// tools that can carry text off the machine: what a crashed gate refuses instead of passing
24const OUTBOUND_HINT = /^(?:Bash|WebFetch|WebSearch|Artifact|SendMessage|Agent|mcp__)/
25const READ_ONLY_TOOLS = new Set([
26 'case_status', 'hpo_search', 'phenotype_rank', 'gene_card', 'variant_card', 'disease_card', 'acmg',
27 's2f_predict', 'therapy_landscape', 'trials_search', 'literature_search', 'rare_stats', 'edit_check', 'china_rare',
28 'cnv_interpret', 'access', 'expression', 'aso_screen',
29])
30
31const board = atom({ plugin: 'zebra-mod', key: 'board' } as const, null as Board | null)
32const casePath = atom({ plugin: 'zebra-mod', key: 'casePath' } as const, null as string | null)
33const guard = atom({ plugin: 'zebra-mod', key: 'guard' } as const, [] as string[])
34const ready = atom({ plugin: 'zebra-mod', key: 'ready' } as const, null as Ready | null)
35// zebra tools the person allowed for the rest of this session in zebra's own approval dialog
36const trusted = atom({ plugin: 'zebra-mod', key: 'trusted' } as const, [] as string[])
37// identifiers registered while no case was open: protected for this session, kept only in memory,
38// and added to the next case that is opened or created
39const sessionIds = atom({ plugin: 'zebra-mod', key: 'sessionIds' } as const, [] as string[])
40
41type Envelope = {
42 ok: boolean
43 result?: unknown
44 sources?: unknown[]
45 warnings?: string[]
46 ledger?: string[]
47 error?: { type: string; message: string }
48}
49
50export const register: Register = (on, options) => {
51 // first, so its hooks wrap the ones below: what the person sees of zebra-mod at work
52 registerUi(on, options)
53 pinCaseStatus = options.interface === 'off'
54 const python = String(options.python ?? 'python3')
55 const doctrineMode = String(options.doctrine ?? 'auto')
56 const privacyOn = options.privacyGate !== false
57
58 // ------------------------------------------------------------ session
59
60 on('session.start', async ($, e, next) => {
61 const started = await next(e)
62
63 // The tools first: a slow interpreter must not delay the first prompt.
64 for (const def of TOOLS) {
65 await $.tool.register({ name: def.name, description: def.description, inputSchema: def.inputSchema })
66 }
67 await $.command.register({
68 name: 'zebra',
69 description: 'zebra-mod: what it does and how to start — /zebra [demo|new <dir> [title]|case <dir>|board|ledger|doctor|close]',
70 argumentHint: '[demo|new <dir> [title]|case <dir>|board|ledger|doctor|close]',
71 })
72
73 // bin/ on PATH, and the interpreter the tools use, so `zebra` in Bash is the same CLI
74 const path = (await $.env.get('PATH')) ?? ''
75 const bin = `${$.plugin.root}/bin`
76 if (!path.split(':').includes(bin)) await $.env.set('PATH', `${bin}:${path}`)
77 await $.env.set('ZEBRA_PYTHON', python)
78
79 void (async () => {
80 const r = await checkCli($, python)
81 if (!r.version) {
82 $.ui.toast(r.python
83 ? `zebra-mod: ${python} could not run the zebra CLI — run /zebra doctor`
84 : `zebra-mod: ${python} not found; set the plugin's python option (needs Python 3.9+)`)
85 }
86
87 // A case is adopted only when this directory is one or lies inside one, or when it was
88 // the case last used in this directory. A case is never inherited from another project.
89 const held = await read($, casePath)
90 let active: string | null = held
91 if (active === null) {
92 const own = (await caseAt($, e.cwd)) ?? (await enclosingCase($, e.cwd))
93 if (own) active = own
94 else {
95 const remembered = await rememberedFor($, e.cwd)
96 if (remembered && (await caseAt($, remembered))) active = remembered
97 else if (remembered) {
98 $.ui.toast(`zebra-mod: the case remembered here (${remembered}) is gone; /zebra case <dir> to pick one`)
99 }
100 }
101 }
102 if (active) await setCase($, python, active, e.isInteractive)
103 else $.ui.status(undefined)
104
105 // keep the board in step with edits made outside the mod's tools (the CLI in Bash, an editor)
106 let lastSeen = ''
107 $.clock.every(4000, async () => {
108 const now = await read($, casePath)
109 if (!now) return
110 try {
111 const st = await $.fs.stat(`${now}/case.json`)
112 const led = await $.fs.stat(`${now}/evidence/ledger.jsonl`).catch(() => undefined)
113 const stamp = `${st.mtimeMs}:${led?.mtimeMs ?? 0}:${led?.size ?? 0}`
114 if (stamp !== lastSeen) {
115 lastSeen = stamp
116 await refreshBoard($, python)
117 }
118 } catch {
119 // the case folder moved or was deleted: leave the board as it was
120 }
121 })
122 })().catch(() => undefined) // background work: a failure here must not surface as an unhandled rejection
123
124 return started
125 })
126
127 // ------------------------------------------------------------ the doctrine
128
129 on('prompt.compose', async ($, e, next) => {
130 const composed = await next(e)
131 if (doctrineMode === 'off' || e.traits.includes('bare')) return composed
132 const active = await read($, casePath)
133 if (doctrineMode === 'case' && !active) return composed
134 // auto (the default): the full doctrine with a case open; otherwise a short section that
135 // applies only to rare-disease questions, so other work in the same Claude Code is untouched
136 const text = doctrineMode === 'auto' && !active ? DOCTRINE_BRIEF : renderDoctrine(DOCTRINE, active, await read($, board))
137 return {
138 sections: [...composed.sections, { id: 'zebra-mod:doctrine', text, scope: 'session' as const }],
139 }
140 })
141
142 // ------------------------------------------------------------ tools
143
144 on('tool.call', async ($, e, next) => {
145 if (!e.tool.startsWith(TOOL_PREFIX)) return next(e)
146 const def = TOOLS.find(t => `${TOOL_PREFIX}${t.name}` === e.tool)
147 if (!def) return next(e)
148 const input = e as unknown as Record<string, unknown>
149 // A tool this plugin registers is answered here, and nothing beneath this hook runs
150 // the engine's permission chain for it. So the decision is asked for explicitly:
151 // the person's rules, the session's mode and the privacy gate apply to zebra's own
152 // tools exactly as they do to any other tool.
153 const verdict = await $.tool.check({ tool: e.tool, input: schemaArgs(def, input) })
154 if (verdict.decision === 'deny') return { deny: verdict.reason ?? `${def.name} was refused` }
155 if (verdict.decision === 'ask' && !(await approved($, def, verdict))) {
156 return { deny: `${def.name} was not approved` }
157 }
158 if (def.name === 'case_update' && !(await read($, casePath))) {
159 const held = await holdForSession($, input)
160 if (held) return { result: held }
161 }
162 let argv: string[]
163 try {
164 argv = await toolArgv(def, input, await read($, casePath))
165 } catch (err) {
166 return { result: `zebra ${def.name}: ${String(err instanceof Error ? err.message : err)}` }
167 }
168 const got = await runZebra($, python, argv, def.timeoutMs ?? 120_000)
169 if (def.touchesCase) await refreshBoard($, python)
170 return { result: formatEnvelope(def, got) }
171 })
172
173 on('tool.describe', async ($, e, next) => {
174 if (!e.tool.startsWith(TOOL_PREFIX)) return next(e)
175 const def = TOOLS.find(t => `${TOOL_PREFIX}${t.name}` === e.tool)
176 if (!def) return next(e)
177 const described = await next(e)
178 return { ...described, isDeferred: def.deferred === true }
179 })
180
181 // ------------------------------------------------------------ permissions and privacy
182
183 on('tool.check', async ($, e, next) => {
184 const own = e.tool.startsWith(TOOL_PREFIX) ? TOOLS.find(t => `${TOOL_PREFIX}${t.name}` === e.tool) : undefined
185 const isLocalOwn = own !== undefined && LOCAL_TOOLS.has(own.name)
186 const command = e.tool === 'Bash' ? String((e.input as { command?: unknown })?.command ?? '') : ''
187 const isRemoteAgent = e.tool === 'Agent' && (e.input as { isolation?: unknown })?.isolation === 'remote'
188 const outbound =
189 (own !== undefined && !isLocalOwn) ||
190 e.tool === 'WebFetch' ||
191 e.tool === 'WebSearch' ||
192 e.tool === 'Artifact' ||
193 e.tool === 'SendMessage' ||
194 isRemoteAgent ||
195 (e.tool.startsWith('mcp__') && own === undefined) ||
196 (e.tool === 'Bash' && isOutboundShell(command))
197
198 if (privacyOn && outbound) {
199 const active = await read($, casePath)
200 const now = await identifiersNow($, active)
201 // a copy: the session list comes from engine state, which must not be mutated in place
202 const held = { isClosed: now.isClosed, ids: [...now.ids, ...(e.tool === 'Bash' ? await namedCaseIdentifiers($, command, active) : [])] }
203 if (held.isClosed) {
204 return {
205 decision: 'deny' as const,
206 reason: `zebra-mod privacy gate: ${active}/case.json cannot be read, so the protected identifiers are unknown and the gate is closed. Fix or re-create the case file, or /zebra close to work without one.`,
207 }
208 }
209 // Scan what actually leaves: a shell command's outbound segments without local paths
210 // or sample names; a published page's text below; anything else as given.
211 const scanned = e.tool === 'Bash' ? { command: outboundText(command) }
212 : e.tool === 'Artifact' ? artifactFields(e.input)
213 : own !== undefined ? schemaArgs(own, (e.input ?? {}) as Record<string, unknown>)
214 : e.input
215 const hit = guardInput(scanned, held.ids)
216 if (hit && !hit.startsWith('protected identifier') && !active && own === undefined) {
217 // No case is open, so this is not known to be patient work: an email or a phone number
218 // in an ordinary call (a PR body, an API request) is asked about, never refused outright.
219 return {
220 decision: 'ask' as const,
221 reason: `zebra-mod privacy gate: this call would send ${hit} off this machine. If it belongs to a patient, do not send it; if it is yours or public, confirm.`,
222 }
223 }
224 if (hit) {
225 return {
226 decision: 'deny' as const,
227 reason: `zebra-mod privacy gate: this call would send ${hit} off this machine. Query public databases with HPO ids, gene symbols, variants and disease names only.`,
228 }
229 }
230 // a file from the case folder about to be sent somewhere
231 const paths = e.tool === 'Bash' ? uploadedPaths(command) : artifactPaths(e.input)
232 if (paths.length > 0 && active) {
233 const inside = await firstInsideCase($, active, paths, (e.input as { root?: unknown })?.root)
234 if (inside) {
235 return {
236 decision: 'ask' as const,
237 reason: `zebra-mod: this would send ${inside}, a file inside the case folder, off this machine. Its contents are not checked for names or record numbers — confirm only if you have read it and trust the destination.`,
238 }
239 }
240 }
241 if (e.tool === 'Artifact') {
242 const scan = await artifactLeak($, e.input, held.ids)
243 if (scan.leak) {
244 return {
245 decision: 'deny' as const,
246 reason: `zebra-mod privacy gate: the page about to be published contains ${scan.leak}. Publishing puts it on the web.`,
247 }
248 }
249 if (scan.unread.length > 0) {
250 return {
251 decision: 'ask' as const,
252 reason: `zebra-mod: ${scan.unread.join(', ')} could not be read to check for names or record numbers before publishing — confirm only if you have checked it yourself.`,
253 }
254 }
255 }
256 if (e.tool === 'Bash' && uploadsGenome(command)) {
257 return {
258 decision: 'ask' as const,
259 reason: 'zebra-mod: this command may move raw genome data (VCF/BAM/CRAM/FASTQ) to another machine. A genome identifies a person and their relatives — confirm the destination is one you trust.',
260 }
261 }
262 if (e.tool === 'Bash' && sendsVariantList(command)) {
263 return {
264 decision: 'ask' as const,
265 reason: 'zebra-mod: --prefilter myvariant sends every quality-passing variant position in this VCF (often tens of thousands) to myvariant.info. Taken together they are the person\'s genome — confirm, or run triage without the prefilter (only the candidates that survive local filtering go out).',
266 }
267 }
268 }
269
270 // The engine's own decision stands; this only spares the person a prompt for a
271 // read-only lookup, and never overrides a deny, a rule or the mode's own answer.
272 const below = await next(e)
273 const writes = own?.name === 'cnv_interpret' && (e.input as { record?: unknown })?.record === true
274 if (own && !writes && below.decision === 'ask' && below.rule === undefined && READ_ONLY_TOOLS.has(own.name)) {
275 return { decision: 'allow' as const, reason: 'zebra-mod: read-only lookup in public databases and local case files' }
276 }
277 return below
278 }).catch(async ($, e, next) => {
279 if (OUTBOUND_HINT.test(e.tool)) {
280 return { decision: 'deny' as const, reason: `zebra-mod privacy gate could not check this call (${next.error.kind}); it was refused rather than let through unchecked` }
281 }
282 return next(e)
283 })
284
285 // ------------------------------------------------------------ /zebra
286
287 on('command.run', { command: 'zebra' }, async ($, e) => {
288 const words = shellWords(e.args.trim())
289 const verb = words[0] ?? ''
290 const rest = words.slice(1)
291 if (verb === '' || verb === 'help' || verb === 'zh' || verb === 'en') {
292 if ((await read($, ready)) === null) await checkCli($, python)
293 const r = await read($, ready)
294 const active = await read($, casePath)
295 if (active) await $.ui.open({ id: PANE, title: 'zebra · case board' })
296 return { text: helpText(r, active, await guideIsZh($, verb === 'zh' || verb === 'en' ? verb : rest[0])) }
297 }
298 if (verb === 'demo') {
299 // the bundled synthetic case: created (or reopened), made active, the first question put in the prompt box
300 const zh = await guideIsZh($, rest[0])
301 const dirArg = rest.find(w => w !== 'zh' && w !== 'en')
302 const made = await runZebra($, python, [
303 'case', 'demo', ...(dirArg ? [await absolute($, dirArg)] : []), '--lang', zh ? 'zh' : 'en',
304 ])
305 if (!made.ok) return { text: `${zh ? '示例病例没能准备好:' : 'Could not prepare the demo case: '}${made.error?.message ?? 'unknown error'}` }
306 const demo = made.result as DemoResult
307 await setCase($, python, demo.path, e.origin.kind === 'composer')
308 await $.ui.open({ id: PANE, title: 'zebra · case board' })
309 const filled = await $.prompt.fill({ text: demo.first_prompt, mode: 'replace' })
310 return { text: demoText(demo, zh, filled.isFilled) }
311 }
312 if (verb === 'board') {
313 const active = await read($, casePath)
314 if (!active) return { text: 'No active case. /zebra new <dir> [title] creates one; /zebra case <dir> opens one.' }
315 await refreshBoard($, python)
316 const opened = await $.ui.open({ id: PANE, title: 'zebra · case board' })
317 return { text: opened.isPlaced ? 'Case board opened.' : 'Case board waits for a wider terminal.' }
318 }
319 if (verb === 'case') {
320 const arg = rest.join(' ')
321 if (!arg) return { text: `Active case: ${(await read($, casePath)) ?? 'none'}` }
322 const dir = await absolute($, rest.length === 1 ? (rest[0] as string) : arg)
323 if (!(await $.fs.exists(`${dir}/case.json`))) return { text: `No case.json in ${dir}. /zebra new ${arg} creates one.` }
324 await setCase($, python, dir, e.origin.kind === 'composer')
325 await $.ui.open({ id: PANE, title: 'zebra · case board' })
326 return { text: `Active case: ${dir}` }
327 }
328 if (verb === 'new') {
329 const dirArg = rest[0]
330 const title = rest.slice(1).join(' ')
331 if (!dirArg) return { text: 'Usage: /zebra new <dir> [title] (quote a path or title that contains spaces)' }
332 const dir = await absolute($, dirArg)
333 const made = await runZebra($, python, ['case', 'init', dir, ...(title ? ['--title', title] : [])])
334 if (!made.ok) return { text: `Could not create the case: ${made.error?.message ?? 'unknown error'}` }
335 await setCase($, python, dir, e.origin.kind === 'composer')
336 await $.ui.open({ id: PANE, title: 'zebra · case board' })
337 return {
338 text: `Case created at ${dir} (case.json, records/, evidence/, reports/). Put reports and lab results in records/.`,
339 context: [`A new zebra case is active at ${dir}. Next useful step: /zebra-mod:zebra-intake to turn records into an HPO profile.`],
340 }
341 }
342 if (verb === 'close') {
343 await setCase($, python, null, e.origin.kind === 'composer')
344 await $.ui.close({ id: PANE })
345 return { text: 'No active case.' }
346 }
347 if (verb === 'doctor') {
348 const got = await runZebra($, python, ['doctor'], 90_000)
349 return { text: got.ok ? doctorText(got.result) : `zebra doctor failed: ${got.error?.message}` }
350 }
351 if (verb === 'ledger') {
352 const active = await read($, casePath)
353 if (!active) return { text: 'No active case.' }
354 // --tail: the CLI keeps the newest rows and reports the true total (an output trim would keep the oldest)
355 const got = await runZebra($, python, ['case', 'ledger', '--case', active, '--tail', '25'])
356 if (!got.ok) return { text: `Could not read the ledger: ${got.error?.message ?? 'unknown error'}` }
357 const r = (got.result ?? {}) as { total?: number; rows?: Array<Record<string, unknown>> }
358 const rows = r.rows ?? []
359 const tail = rows.map(row => `${row.eid} ${row.db} ${row.record ?? ''} ${row.url ?? ''}`)
360 return { text: rows.length ? `${r.total ?? rows.length} evidence rows (newest ${rows.length}):\n${tail.join('\n')}` : 'The evidence ledger is empty.' }
361 }
362 return { text: `Unknown: /zebra ${verb}. Try /zebra help.` }
363 })
364
365 // ------------------------------------------------------------ the case board
366
367 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
368 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
369 const b = await read($, board)
370 if (!b) {
371 return (
372 <Box flexDirection="column">
373 <Text dimColor>No active case. /zebra new <dir> [title]</Text>
374 </Box>
375 )
376 }
377 return (
378 <Box flexDirection="column">
379 <Text bold>🦓 {b.title}</Text>
380 <Text dimColor>
381 {b.role} · {b.evidence_count} evidence rows · {b.identifiers} protected identifiers
382 </Text>
383 <Markdown key="body" text={boardMarkdown(b)} />
384 <Box flexDirection="row" gap={1}>
385 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => refreshBoard($, python)} />
386 <Button key="close" label="Close" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
387 </Box>
388 </Box>
389 )
390 })
391}
392
393// ------------------------------------------------------------ $-using helpers (top level, as the engine requires)
394
395async function runZebra($: EngineInterface, python: string, args: readonly string[], timeoutMs = 120_000): Promise<Envelope> {
396 const active = await read($, casePath)
397 const env: Record<string, string> = { ZEBRA_DEADLINE_MS: String(Math.max(5_000, timeoutMs - 10_000)) }
398 if (active) env.ZEBRA_CASE = active
399 try {
400 const ran = await $.process.run([python, `${$.plugin.root}/bin/zebra`, '--json', ...args], { env, timeoutMs })
401 if (ran.isStdoutTruncated) {
402 return { ok: false, error: { type: 'OutputTooLarge', message: 'the CLI wrote more than 4 MiB; narrow the query (fewer items, a shorter range)' } }
403 }
404 const out = ran.stdout.trim()
405 if (!out) {
406 // a traceback ends with the exception, so keep the tail, not the head
407 const err = (ran.stderr || `exit ${ran.exitCode}`).trim()
408 return { ok: false, error: { type: 'NoOutput', message: err.slice(-2000) } }
409 }
410 return JSON.parse(out) as Envelope
411 } catch (err) {
412 return { ok: false, error: { type: 'ProcessError', message: String(err).slice(0, 2000) } }
413 }
414}
415
416/** Run the CLI's version check now and record the answer. */
417async function checkCli($: EngineInterface, python: string): Promise<Ready> {
418 let r: Ready
419 try {
420 const v = await $.process.run([python, `${$.plugin.root}/bin/zebra`, '--version'], { timeoutMs: 20_000 })
421 const version = v.exitCode === 0 ? v.stdout.trim() : null
422 r = { python, version, error: version ? null : (v.stderr || 'zebra CLI failed').slice(0, 300) }
423 } catch (err) {
424 r = { python: null, version: null, error: String(err).slice(0, 300) }
425 }
426 await update($, ready, () => r)
427 return r
428}
429
430/**
431 * Ask the person whether a zebra tool may run; no one to ask means no. The engine's permission
432 * dialog only opens for tools it runs itself, so this is an AskUserQuestion. An ask that comes
433 * from the session's mode may be answered once for the session; one a settings rule asks for
434 * (`rule` set) is asked every time, as the person configured.
435 */
436async function approved($: EngineInterface, def: ToolDef, verdict: { reason?: string; rule?: unknown }): Promise<boolean> {
437 const sessionable = verdict.rule === undefined
438 if (sessionable && (await read($, trusted)).includes(def.name)) return true
439 const always = `Allow ${def.name} for this session`
440 const options = sessionable ? ['Allow', always, 'Deny'] : ['Allow', 'Deny']
441 try {
442 const why = verdict.reason ? `${verdict.reason.replace(/[??.。]\s*$/, '')}. ` : ''
443 const answer = await $.ui.ask(`${why}Allow zebra-mod to run ${def.name}?`, options)
444 if (sessionable && answer === always) {
445 await update($, trusted, t => (t.includes(def.name) ? t : [...t, def.name]))
446 return true
447 }
448 return answer === 'Allow'
449 } catch {
450 return false // dismissed, or a headless run with nobody to ask
451 }
452}
453
454/** The case directory at `dir`, or null when it holds no zebra case. */
455async function caseAt($: EngineInterface, dir: string): Promise<string | null> {
456 try {
457 if (!(await $.fs.exists(`${dir}/case.json`))) return null
458 const raw = JSON.parse(await $.fs.read(`${dir}/case.json`)) as { schema?: string }
459 return raw.schema === 'zebra.case/1' ? dir : null
460 } catch {
461 return null
462 }
463}
464
465/** The case folder this directory lies inside (a session started in <case>/records), up to four levels up. */
466async function enclosingCase($: EngineInterface, cwd: string): Promise<string | null> {
467 let dir = cwd.replace(/\/+$/, '')
468 for (let i = 0; i < 4; i++) {
469 const parent = dir.slice(0, dir.lastIndexOf('/'))
470 if (!parent || parent === dir) return null
471 const found = await caseAt($, parent)
472 if (found) return found
473 dir = parent
474 }
475 return null
476}
477
478/** The case last used in this working directory, if any. Cases are never shared between projects. */
479async function rememberedFor($: EngineInterface, cwd: string): Promise<string | null> {
480 const saved = await $.store.get('casesByDir')
481 if (saved && typeof saved === 'object') {
482 const held = (saved as Record<string, unknown>)[cwd]
483 if (typeof held === 'string') return held
484 }
485 return null
486}
487
488async function refreshBoard($: EngineInterface, python: string): Promise<void> {
489 const active = await read($, casePath)
490 if (!active) {
491 await update($, board, () => null)
492 await update($, guard, () => [])
493 $.ui.status(undefined)
494 return
495 }
496 const got = await runZebra($, python, ['case', 'summary', active], 30_000)
497 if (got.ok && got.result) {
498 const b = got.result as Board
499 await update($, board, () => b)
500 $.ui.status(pinCaseStatus ? statusLine(b) : undefined)
501 } else {
502 $.ui.status(`zebra: case ${active} unreadable`)
503 }
504 const held = await identifiersNow($, active)
505 if (!held.isClosed) await update($, guard, () => held.ids)
506}
507
508/**
509 * The case's protected identifiers, read now rather than from the last poll, so a
510 * `zebra case identifiers --add` in the same turn is already in force.
511 * `isClosed` means the file could not be read: the caller must refuse outbound calls.
512 */
513async function identifiersNow($: EngineInterface, active: string | null): Promise<{ ids: string[]; isClosed: boolean }> {
514 const session = await read($, sessionIds)
515 if (!active) return { ids: session, isClosed: false }
516 try {
517 const raw = JSON.parse(await $.fs.read(`${active}/case.json`)) as { privacy?: { identifiers?: unknown } }
518 const listed = raw.privacy?.identifiers
519 if (listed === undefined || listed === null) return { ids: session, isClosed: false }
520 if (!Array.isArray(listed)) return { ids: session, isClosed: true }
521 return { ids: [...session, ...listed.filter((s): s is string => typeof s === 'string' && s.trim().length >= 2)], isClosed: false }
522 } catch {
523 const kept = await read($, guard)
524 return { ids: [...session, ...kept], isClosed: true }
525 }
526}
527
528/**
529 * case_update with no case open. Identifiers are the one thing worth keeping without a case: a
530 * parent pastes a clinic note before any folder exists, and the gate must know the child's name
531 * from that moment. They are held in memory for this session (written nowhere) and added to the
532 * next case opened or created. Anything else in the call needs a case and is reported as not
533 * recorded. Returns the tool's answer, or undefined when the call carries no identifiers.
534 */
535async function holdForSession($: EngineInterface, input: Record<string, unknown>): Promise<string | undefined> {
536 const raw = Array.isArray(input.identifiers) ? input.identifiers : []
537 const values = raw.filter((v): v is string => typeof v === 'string' && v.trim().length >= 2).map(v => v.trim())
538 if (values.length === 0) return undefined
539 const held = await update($, sessionIds, list => [...new Set([...list, ...values])])
540 const other = Object.keys(input).filter(k => k !== 'identifiers' && k !== 'tool' && k !== 'tool_use_id' && input[k] !== undefined)
541 const fields = TOOLS.find(t => t.name === 'case_update')
542 const declared = new Set(Object.keys(((fields?.inputSchema as { properties?: object })?.properties ?? {}) as object))
543 const lost = other.filter(k => declared.has(k))
544 return JSON.stringify({
545 warnings: lost.length ? [`no case is open, so ${lost.join(', ')} ${lost.length === 1 ? 'was' : 'were'} not recorded: /zebra new <dir> starts a case`] : [],
546 result: {
547 identifiers: { protected_for_this_session: held.length },
548 note: 'No case is open: these identifiers are protected for the rest of this session (kept in memory only, never written) and will be added to the next case opened or created.',
549 },
550 })
551}
552
553async function setCase($: EngineInterface, python: string, path: string | null, isInteractive: boolean): Promise<void> {
554 await update($, casePath, () => path)
555 if (isInteractive) {
556 const cwd = await $.session.cwd()
557 const saved = await $.store.get('casesByDir')
558 const map: Record<string, unknown> = saved && typeof saved === 'object' ? { ...(saved as Record<string, unknown>) } : {}
559 if (path) map[cwd] = path
560 else delete map[cwd]
561 await $.store.set('casesByDir', map)
562 }
563 if (path) await $.env.set('ZEBRA_CASE', path)
564 else await $.env.set('ZEBRA_CASE', undefined)
565 if (path) {
566 const pending = await read($, sessionIds)
567 if (pending.length) {
568 const got = await runZebra($, python, ['case', 'apply', path, '--ops', JSON.stringify({ identifiers: pending })], 30_000)
569 if (!got.ok) $.ui.toast(`zebra-mod: the identifiers held for this session could not be added to ${path}: ${got.error?.message ?? 'unknown error'}`)
570 }
571 }
572 await refreshBoard($, python)
573}
574
575/** The first of `paths` that lies inside the case folder, resolved through links. */
576async function firstInsideCase($: EngineInterface, active: string, paths: readonly string[], root?: unknown): Promise<string | undefined> {
577 const caseRoot = await $.fs.stat(active, { resolve: true }).catch(() => undefined)
578 const realRoot = caseRoot?.realPath
579 if (realRoot === undefined) return undefined
580 const home = (await $.env.get('HOME')) ?? ''
581 const base = typeof root === 'string' && root ? root.replace(/\/$/, '') : ''
582 for (const p of paths) {
583 let q = p.replace(/^\$HOME(?=\/|$)/, home).replace(/^~(?=\/|$)/, home)
584 if (base && !q.startsWith('/')) q = `${base}/${q}`
585 const stat = await $.fs.stat(q, { resolve: true }).catch(() => undefined)
586 const real = stat?.realPath
587 if (real !== undefined && (real === realRoot || real.startsWith(`${realRoot}/`))) return p
588 }
589 return undefined
590}
591
592function artifactPaths(input: unknown): string[] {
593 const i = (input ?? {}) as { file_path?: unknown; file_paths?: unknown; files?: unknown; root?: unknown }
594 const root = typeof i.root === 'string' && i.root ? i.root.replace(/\/$/, '') : ''
595 const under = (p: string) => (root && !p.startsWith('/') ? `${root}/${p}` : p)
596 const out: string[] = []
597 if (typeof i.file_path === 'string') out.push(i.file_path)
598 if (Array.isArray(i.file_paths)) for (const p of i.file_paths) if (typeof p === 'string') out.push(p)
599 if (Array.isArray(i.files)) {
600 for (const f of i.files) {
601 if (typeof f === 'string') out.push(under(f))
602 else if (f && typeof f === 'object' && typeof (f as { path?: unknown }).path === 'string') out.push(under((f as { path: string }).path))
603 }
604 } else if (i.files && typeof i.files === 'object') {
605 for (const v of Object.values(i.files as Record<string, unknown>)) {
606 if (typeof v === 'string') out.push(under(v))
607 else if (v && typeof v === 'object' && typeof (v as { from?: unknown }).from === 'string') out.push(under((v as { from: string }).from))
608 }
609 }
610 return out
611}
612
613/** The parts of an Artifact call that are published themselves (title, description), not paths. */
614const ARTIFACT_PATH_KEYS = new Set(['file_path', 'file_paths', 'files', 'root', 'out_dir', 'url', 'from_url', 'type_url',
615 'path', 'paths'])
616
617function artifactFields(input: unknown): Record<string, unknown> {
618 const i = (input ?? {}) as Record<string, unknown>
619 const out: Record<string, unknown> = {}
620 for (const [k, v] of Object.entries(i)) if (!ARTIFACT_PATH_KEYS.has(k)) out[k] = v
621 return out
622}
623
624/**
625 * The identifiers of every other case a shell command names with --case: a clinician with several
626 * cases open in turn may query for one while another is active. Unreadable case files add nothing
627 * here (the active case's own unreadable file still closes the gate).
628 */
629async function namedCaseIdentifiers($: EngineInterface, command: string, active: string | null): Promise<string[]> {
630 const words = shellWords(command)
631 const named = new Set<string>()
632 words.forEach((w, i) => {
633 if (w === '--case' && words[i + 1]) named.add(words[i + 1] as string)
634 else if (w.startsWith('--case=')) named.add(w.slice('--case='.length))
635 })
636 if (named.size === 0) return []
637 const home = (await $.env.get('HOME')) ?? ''
638 const out: string[] = []
639 for (const raw of named) {
640 const dir = raw.replace(/^\$HOME(?=\/|$)/, home).replace(/^~(?=\/|$)/, home).replace(/\/+$/, '')
641 if (!dir || dir === active?.replace(/\/+$/, '')) continue
642 try {
643 const data = JSON.parse(await $.fs.read(`${dir}/case.json`)) as { privacy?: { identifiers?: unknown } }
644 const listed = data.privacy?.identifiers
645 if (Array.isArray(listed)) out.push(...listed.filter((x): x is string => typeof x === 'string' && x.trim().length >= 2))
646 } catch {
647 // not a case, or not readable: nothing to add
648 }
649 }
650 return out
651}
652
653/**
654 * What a page about to be published carries: the case's identifiers and the ID/phone/email
655 * patterns, checked in every file it publishes. A file that cannot be read (missing, over the
656 * 4 MiB read limit) is reported as unread, so the caller asks instead of passing it unseen.
657 */
658async function artifactLeak($: EngineInterface, input: unknown, ids: readonly string[]): Promise<{ leak?: string; unread: string[] }> {
659 const unread: string[] = []
660 const root = (input as { root?: unknown })?.root
661 const base = typeof root === 'string' && root ? root.replace(/\/$/, '') : ''
662 for (const p of artifactPaths(input)) {
663 const path = base && !p.startsWith('/') ? `${base}/${p}` : p
664 const text = await $.fs.read(path).catch(() => undefined)
665 if (text === undefined) {
666 unread.push(p)
667 continue
668 }
669 // paragraph by paragraph (tags, entities and soft line breaks folded first): numbers from all
670 // over a page must never join into a record number, but a name split by a line break still counts
671 const chunks = text.split(/\n\s*\n|<\/(?:p|div|li|tr|td|th|h[1-6]|section|table)>|<br\s*\/?>/i)
672 .map(c => c.replace(/\s*\n\s*/g, ' '))
673 const hit = guardInput(chunks, ids)
674 if (hit) return { leak: `${hit} (in ${p})`, unread }
675 }
676 return { unread }
677}
678
679// ------------------------------------------------------------ text
680
681async function absolute($: EngineInterface, p: string): Promise<string> {
682 const home = (await $.env.get('HOME')) ?? ''
683 const expanded = p === '~' ? home : p.startsWith('~/') ? `${home}/${p.slice(2)}` : p
684 const raw = expanded.startsWith('/') ? expanded : `${await $.session.cwd()}/${expanded}`
685 const parts: string[] = []
686 for (const part of raw.split('/')) {
687 if (part === '' || part === '.') continue
688 if (part === '..') parts.pop()
689 else parts.push(part)
690 }
691 return `/${parts.join('/')}`
692}
693
694function statusLine(b: Board): string {
695 const present = b.phenotypes.filter(p => p.status === 'present').length
696 const lead = b.hypotheses.find(h => h.status === 'confirmed' || h.status === 'leading')
697 return `🦓 ${b.title} · HPO ${present} · variants ${b.variants.length}${lead ? ` · ${lead.status}: ${lead.disease}` : ''} · E${b.evidence_count}`
698}
699
700/** Case text is the person's own; it must not be read as Markdown when the board draws it. */
701function plain(text: string | null | undefined, fallback = ''): string {
702 const t = (text ?? fallback).replace(/[\r\n]+/g, ' ')
703 return t.replace(/[\\`*_[\]<>|#~]/g, m => `\\${m}`)
704}
705
706function boardMarkdown(b: Board): string {
707 const lines: string[] = []
708 const present = b.phenotypes.filter(p => p.status === 'present')
709 const excluded = b.phenotypes.filter(p => p.status === 'excluded')
710 lines.push(`**Phenotypes** (${present.length} present, ${excluded.length} excluded)`)
711 for (const p of present.slice(0, 20)) lines.push(`- ${plain(p.label, p.id)} (${plain(p.id)})`)
712 if (present.length > 20) lines.push(`- … ${present.length - 20} more`)
713 for (const p of excluded.slice(0, 8)) lines.push(`- ${plain(p.label, p.id)} (${plain(p.id)}, absent)`)
714 lines.push('', `**Variants** (${b.variants.length})`)
715 for (const v of b.variants) {
716 lines.push(`- ${plain(v.gene)} ${plain(v.label)} ${plain(v.zygosity)} — ${plain(v.classification, 'unclassified')}`)
717 }
718 lines.push('', `**Hypotheses** (${b.hypotheses.length})`)
719 for (const h of b.hypotheses) lines.push(`- [${plain(h.status)}] ${plain(h.disease)} (+${h.support} / −${h.against})`)
720 if (b.therapy_leads.length) {
721 lines.push('', `**Therapy leads** (${b.therapy_leads.length})`)
722 for (const t of b.therapy_leads) lines.push(`- [${plain(t.kind)}] ${plain(t.name)}${t.status ? ` — ${plain(t.status)}` : ''}`)
723 }
724 if (b.questions.length) {
725 lines.push('', `**Questions for the care team** (${b.questions.length})`)
726 for (const q of b.questions.slice(0, 10)) lines.push(`- ${plain(q)}`)
727 }
728 return lines.join('\n')
729}
730
731type DemoResult = {
732 path: string
733 reused: boolean
734 title: string
735 records: string[]
736 identifiers: number
737 first_prompt: string
738 next_prompts: string[]
739}
740
741/** The language of the guide: an explicit `zh`/`en`, else the open case's, else LANG. */
742async function guideIsZh($: EngineInterface, asked: string | undefined): Promise<boolean> {
743 if (asked === 'zh') return true
744 if (asked === 'en') return false
745 const b = await read($, board)
746 if (b?.language) return b.language.toLowerCase().startsWith('zh')
747 const env = (await $.env.get('LC_ALL')) || (await $.env.get('LANG')) || ''
748 return env.toLowerCase().startsWith('zh')
749}
750
751function demoText(d: DemoResult, zh: boolean, isFilled: boolean): string {
752 const next = d.next_prompts.map(q => `- ${q}`).join('\n')
753 if (zh) {
754 return [
755 `🦓 **已打开示例病例**「${d.title}」`,
756 `\`${d.path}\``,
757 '',
758 `- records/ 里有两份合成资料:${d.records.join('、')}。`,
759 `- 其中的姓名、出生日期、电话和病历号已登记为受保护身份信息(${d.identifiers} 项),隐私闸门会拦住带有它们的外发查询。`,
760 isFilled
761 ? '- **第一个问题已经放进输入框,按回车就开始。** 它会整理病历、做鉴别诊断、解读变异;过程中能看到工具行、奔跑的斑马和右侧病例看板的变化。'
762 : `- 把这个问题发给 Claude 就开始:\n\n ${d.first_prompt}`,
763 '',
764 '之后可以接着问:',
765 next,
766 '',
767 '用你自己的资料:`/zebra new ~/cases/<名字>`,把病历、化验单、基因报告放进它的 records/,再直接提问。',
768 ].join('\n')
769 }
770 return [
771 `🦓 **Demo case open:** ${d.title}`,
772 `\`${d.path}\``,
773 '',
774 `- records/ holds two synthetic files: ${d.records.join(', ')}.`,
775 `- The name, date of birth, phone and record numbers in them are registered as protected identifiers (${d.identifiers}); the privacy gate keeps them out of every outgoing query.`,
776 isFilled
777 ? '- **The first question is in the prompt box: press Enter.** It reads the records, ranks a differential and interprets the variant; you will see its tool rows, the galloping zebra and the case board on the right fill in.'
778 : `- Send Claude this to begin:\n\n ${d.first_prompt}`,
779 '',
780 'Then try:',
781 next,
782 '',
783 'Your own records: `/zebra new ~/cases/<name>`, put reports, lab sheets and the genetic report in its records/, and ask.',
784 ].join('\n')
785}
786
787/** `/zebra`: what the mod does and how to start, in the person's language. */
788function helpText(r: Ready | null, active: string | null, zh: boolean): string {
789 const version = r?.version ?? (zh ? `未就绪(${r?.error ?? '尚未检查 Python'})` : `not ready (${r?.error ?? 'python not checked'})`)
790 if (zh) {
791 return [
792 `🦓 **zebra-mod** · 罕见病研究工作台 · ${version} · 当前病例:${active ?? '无'}`,
793 '',
794 '**怎么用:直接提问。** 用中文或英文描述症状,贴检查结果或基因报告,Claude 会自己调用 zebra-mod 去查公开数据库、做计算,每个结论都标出处。不需要记命令。例如:',
795 '- 孩子 6 个月开始发热抽搐,基因报告说 SCN1A c.2134C>T,这是什么意思?',
796 '- 这些表现可能是什么病:头围小、听力下降、走路晚,肌张力正常',
797 '- Dravet 综合征现在有哪些获批的药和在招募的临床试验?我们在中国',
798 '',
799 '**先试一下:`/zebra demo`** 打开一个合成示例病例,第一个问题会放进输入框,按回车即可。',
800 '',
801 '**能做什么**',
802 '- 鉴别诊断:病历 → 标准表型(HPO)→ 三个来源分别排序 → 该做哪种基因检测',
803 '- 变异解读:ClinVar、gnomAD、预测分数 → ACMG 证据,分数由程序计算',
804 '- 拷贝数变异、外显子缺失、SMN1 拷贝数、重复扩增;剪接与非编码变异的序列功能预测',
805 '- 治疗与试验:获批药物(含中国获批与医保)、在招募的试验(含中国中心)、反义寡核苷酸与碱基编辑可行性',
806 '- 统计:共分离、再发风险、携带频率;文献检索;在本机重分析 VCF',
807 '- 给家属的说明信、就诊准备单,导出 Word / PDF;隔几个月复查有无新进展',
808 '',
809 '**病例命令**(病例文件只保存在本机)',
810 '`/zebra demo` 示例病例 · `/zebra new <目录> [标题]` 新建 · `/zebra case <目录>` 切换 · `/zebra board` 病例看板 · `/zebra ledger` 证据台账 · `/zebra doctor` 环境自检 · `/zebra close` 关闭病例',
811 '',
812 '隐私:Claude 读到的病历会照常发给模型服务商;zebra-mod 只保证已登记的姓名、证件号、病历号不会出现在它对外的数据库查询里。研究用途,不能代替医生。',
813 ].join('\n')
814 }
815 return [
816 `🦓 **zebra-mod** · rare-disease research workstation · ${version} · active case: ${active ?? 'none'}`,
817 '',
818 '**How to use it: just ask.** Describe symptoms, paste test results or a genetic report, in English or Chinese; Claude calls zebra-mod to query public databases and compute, and cites a source for every claim. No commands to learn. For example:',
819 '- My daughter has had seizures with fever since 6 months; her report says SCN1A c.2134C>T. What does it mean?',
820 '- What could this be: small head, hearing loss, walked late, normal muscle tone',
821 '- Which treatments are approved for Dravet syndrome, and are any trials recruiting?',
822 '',
823 '**Try it first: `/zebra demo`** opens a synthetic demo case and puts the first question in the prompt box; press Enter.',
824 '',
825 '**What it does**',
826 '- Differential diagnosis: records → HPO phenotypes → three rankings kept apart → which test finds each candidate',
827 '- Variant interpretation: ClinVar, gnomAD, predictors → ACMG evidence, points computed by code',
828 '- CNVs, exon deletions, SMN1 copy number, repeat expansions; sequence-to-function for splicing and non-coding variants',
829 '- Therapy and trials: approved drugs (incl. China approval and reimbursement), recruiting trials, antisense and base-editing feasibility',
830 '- Statistics: segregation, recurrence risk, carrier frequency; literature; VCF reanalysis on this machine',
831 '- A family letter and a visit-preparation sheet, exported to Word / PDF; rechecks months later',
832 '',
833 '**Case commands** (case files stay on this machine)',
834 '`/zebra demo` · `/zebra new <dir> [title]` · `/zebra case <dir>` · `/zebra board` · `/zebra ledger` · `/zebra doctor` · `/zebra close`',
835 '',
836 'Privacy: records Claude reads reach the model provider as in any session; zebra-mod keeps registered names and record numbers out of its outgoing database queries. Research-grade, not a substitute for a clinician.',
837 ].join('\n')
838}
839
840function doctorText(result: unknown): string {
841 const r = (result ?? {}) as {
842 checks?: Array<{ name: string; ok: boolean; optional?: boolean; detail?: string }>
843 summary?: { ok?: number; failed?: number; optional_missing?: number }
844 }
845 // ○ marks what zebra works without (research keys, offline data): not a failure
846 const rows = (r.checks ?? []).map(c => `${c.ok ? '✓' : c.optional ? '○' : '✗'} ${c.name}${c.detail ? ` — ${c.detail}` : ''}`)
847 const s = r.summary
848 if (s) rows.push(`${s.ok ?? 0} ok, ${s.failed ?? 0} failed, ${s.optional_missing ?? 0} optional not set up (○)`)
849 return rows.length ? rows.join('\n') : JSON.stringify(result, null, 1)
850}
851
852/** The longest list inside `result`, so a too-large answer loses items rather than its provenance. */
853function trimLongestList(result: unknown): { result: unknown; dropped: number; key: string } | undefined {
854 if (!result || typeof result !== 'object') return undefined
855 let best: { holder: Record<string, unknown>; key: string; list: unknown[] } | undefined
856 const walk = (value: unknown, depth: number): void => {
857 if (depth > 4 || !value || typeof value !== 'object') return
858 for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
859 if (Array.isArray(v) && v.length > 1 && (best === undefined || v.length > best.list.length)) {
860 best = { holder: value as Record<string, unknown>, key, list: v }
861 }
862 walk(v, depth + 1)
863 }
864 }
865 walk(result, 0)
866 if (!best) return undefined
867 const keep = Math.max(1, Math.floor(best.list.length / 2))
868 const dropped = best.list.length - keep
869 best.holder[best.key] = best.list.slice(0, keep)
870 return { result, dropped, key: best.key }
871}
872
873function formatEnvelope(def: ToolDef, got: Envelope): string {
874 if (!got.ok) {
875 return `zebra ${def.name} failed: ${got.error?.type ?? 'error'}: ${got.error?.message ?? 'no message'}`
876 }
877 const warnings = [...(got.warnings ?? [])]
878 // Pair each source with the evidence id the ledger gave it: one claim, one row to cite.
879 const ledger = got.ledger ?? []
880 const sources = (got.sources ?? []).map((src, i) =>
881 ledger.length === (got.sources ?? []).length && src && typeof src === 'object'
882 ? { eid: ledger[i], ...(src as Record<string, unknown>) }
883 : src)
884 // warnings, ledger and sources first: they are what the answer must cite, so a
885 // trim never costs them. The result is trimmed until the whole envelope fits.
886 let body = { warnings, ledger, sources, result: got.result }
887 let text = JSON.stringify(body)
888 for (let i = 0; i < 12 && text.length > MAX_RESULT_CHARS; i++) {
889 const trimmed = trimLongestList(body.result)
890 if (!trimmed) break
891 warnings.push(`zebra-mod trimmed ${trimmed.dropped} of "${trimmed.key}" to fit the tool result; run the same query with the zebra CLI and --json for all of it`)
892 body = { ...body, warnings, result: trimmed.result }
893 text = JSON.stringify(body)
894 }
895 if (text.length > MAX_RESULT_CHARS) {
896 body = {
897 warnings: [...warnings, 'zebra-mod could not fit this result; only its provenance is shown. Run the same query with the zebra CLI and --json.'],
898 ledger: body.ledger,
899 sources: body.sources,
900 result: null,
901 }
902 text = JSON.stringify(body)
903 }
904 return text
905}
906hooks/doctrine.ts 45 lines1import type { Board } from '../types'
2
3// The research doctrine zebra-mod adds to the system prompt. Short on purpose:
4// the skills carry the procedures; this carries the rules every answer keeps.
5export const DOCTRINE = `# zebra-mod: rare-disease research mode
6
7This Claude Code has zebra-mod: a rare-disease research workstation for patients and families on a diagnostic odyssey, clinicians and researchers. Its tools (mcp__zebra-mod__*) query live public databases (HPO, Monarch, Orphanet, ClinVar, gnomAD, Ensembl VEP, ClinGen, PanelApp, Open Targets, ClinicalTrials.gov, Europe PMC, PubTator/LitVar, SpliceAI) and run local analyses; every result carries its sources and, with an active case, is written to the case's evidence ledger (ids E1, E2, ...). The skills (/zebra-mod:zebra-start to start) hold the procedures.
8
9Rules for every answer in this mode:
100. Urgent first. A symptom happening now that needs care today (a seizure past 5 minutes, suspected metabolic decompensation, respiratory or cardiac decline, a stroke-like episode, adrenal crisis) is said first, plainly, with "contact your doctor or go to the emergency department" — before any analysis. Read /zebra-mod:zebra-safety for those, and for the drug, anaesthesia and procedure hazards of specific rare diseases.
111. Evidence, not recall. A gene–disease link, a variant's classification or frequency, a prevalence, a drug's approval status, a trial, a paper: state it only from a tool result or a source fetched in this session, and cite it (ledger id, PMID, database record). What was not retrieved is "not checked", never filled in from memory. Never synthesize evidence.
122. Code scores, you interpret. Phenotype rankings, ACMG points and statistics come from zebra tools; explain them, question their inputs, never invent or adjust a number.
133. Identifiers are verified, not written from memory: HPO, OMIM, ORPHA, MONDO ids, HGVS, rsIDs, NCT numbers and PMIDs appear only as tools returned them.
144. Clarify, never invent. Genome assembly (GRCh38/GRCh37), transcript, zygosity, inheritance, sex and ancestry change conclusions; when one is missing and matters, ask, or state the conclusion as conditional on it.
155. Research, not a clinical report. Classifications here are research-grade until an accredited laboratory or clinical geneticist confirms them. No dosing, no stopping or starting a treatment; a possible diagnosis is a question for the care team, never news delivered to a family as fact.
166. Sequence-to-function predictions (SpliceAI, AlphaGenome, Evo 2, AlphaMissense) are hypotheses: give model, score and what level they speak to (molecular, cellular); combine independent axes by agreement, never average them into one number.
177. Look beyond the obvious ("think zebras"), and say how strong each lead is: established, emerging, speculative.
188. Privacy: zebra writes case files only on this machine, but what you read (records, reports, this conversation) reaches the model provider as in any Claude Code session — say so if asked. When records name the patient, first register those identifiers with case_update \`identifiers\` (name in every spelling, date of birth, record and ID numbers); the privacy gate then keeps them out of every outgoing call. Never send names, birth dates, record numbers or raw genome files to a web service; query with HPO ids, genes and variants. Do not write them back in your replies either (address the person as 您 / you and the patient as 孩子 / your child).
199. Register: with families plain, warm and exact (Chinese by default for Chinese speakers); with clinicians and researchers precise and technical.
2010. Work through the skills: zebra-start routes to the procedure for the question (intake, diagnose, variant, reanalysis, s2f, therapy, stats, literature, family, report); a wide search goes to the zebra-mod subagents, several in one message.\``
21
22// What a session without an active case carries under the default `auto` mode: the mod is
23// installed in Claude Code used for everything else too, so it speaks only when asked about
24// a rare disease and never changes how unrelated work is done.
25export const DOCTRINE_BRIEF = `# zebra-mod (installed)
26
27This Claude Code has zebra-mod, a rare-disease research workstation. When — and only when — the person asks about a rare or undiagnosed disease, a patient's records, a genetic test report or variant, or treatment of a rare disease, load /zebra-mod:zebra-start and follow it. In short: a symptom that needs care today comes first ("contact your doctor or go to the emergency department"); state facts only from the mod's tools or sources fetched in this session, and cite them; never write HPO/OMIM/ORPHA ids, HGVS, rsIDs, NCT numbers or PMIDs from memory; research-grade only, no dosing; never send a patient's name, birth date or record numbers to a web service, nor repeat them in replies. For anything else, ignore this section.`
28
29export function renderDoctrine(base: string, active: string | null, board: Board | null): string {
30 if (!active) {
31 return `${base}\n\nNo active case. /zebra new <dir> [title] starts one; without one, tool results are not written to a ledger.`
32 }
33 const lines = [`${base}`, '', `Active case: ${active}${board ? ` — "${board.title}" (role: ${board.role}, language: ${board.language ?? 'zh'})` : ''}.`]
34 if (board) {
35 const present = board.phenotypes.filter(p => p.status === 'present').length
36 lines.push(
37 `It holds ${present} present phenotypes, ${board.variants.length} variants, ${board.hypotheses.length} hypotheses, ${board.tests?.length ?? 0} tests done, ${board.evidence_count} evidence rows. Read it with mcp__zebra-mod__case_status before relying on it; record findings with mcp__zebra-mod__case_update.`,
38 )
39 if (board.identifiers === 0) {
40 lines.push('No protected identifiers are registered for this case: if its records name the patient, register them (case_update identifiers) before any outgoing lookup.')
41 }
42 }
43 return lines.join('\n')
44}
45hooks/tools.ts 671 lines1// The tools zebra-mod gives the model. Each one is a thin, typed door onto a
2// `zebra` CLI command (bin/zebra, Python standard library only), so the CLI
3// stays the single implementation: the same answers come back whether the
4// model calls the tool, a skill runs the command in Bash, or a researcher
5// scripts it.
6
7export type ToolDef = {
8 name: string
9 description: string
10 inputSchema: Record<string, unknown>
11 argv: (input: Record<string, unknown>, casePath: string | null) => string[]
12 deferred?: boolean
13 touchesCase?: boolean
14 timeoutMs?: number
15}
16
17const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
18const num = (v: unknown): string | undefined => (typeof v === 'number' && Number.isFinite(v) ? String(v) : undefined)
19const list = (v: unknown): string[] =>
20 Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map(x => x.trim()) : []
21const flag = (name: string, v: string | undefined): string[] => (v === undefined ? [] : [name, v])
22
23// What each statistics method accepts, so a params key cannot smuggle --help or --case.
24const STATS_FLAGS: Record<string, string[]> = {
25 segregation: ['ad-meioses', 'xlr-male-meioses', 'ar-affected-sibs', 'ar-unaffected-sibs', 'nonsegregations',
26 'unaffected-carriers', 'full-penetrance'],
27 maxaf: ['prevalence', 'allelic', 'genetic', 'penetrance', 'inheritance', 'an', 'faf95'],
28 carrier: ['prevalence', 'allele-freqs'],
29 recurrence: ['mode', 'penetrance', 'mosaic', 'prior', 'unaffected-sons', 'affected-sons'],
30 fisher: ['a', 'b', 'c', 'd'],
31 burden: ['case-carriers', 'case-n', 'control-carriers', 'control-n'],
32 denovo: ['observed', 'trios', 'mu'],
33 km: ['csv', 'time-col', 'event-col', 'group-col', 'event-coding'],
34 nof1: ['effect', 'sd-diff', 'alpha', 'power', 'treatment', 'control'],
35}
36
37const HPO = { type: 'string', pattern: '^HP:\\d{7}$' }
38const ASSEMBLY = { type: 'string', enum: ['GRCh38', 'GRCh37'], description: 'Genome build of genomic coordinates; ask when unknown.' }
39
40export const TOOLS: ToolDef[] = [
41 {
42 name: 'case_status',
43 description:
44 'Read the active zebra case: phenotypes (HPO), variants, hypotheses, therapy leads, open questions and how many evidence rows back them. Call before relying on what the case holds.',
45 inputSchema: { type: 'object', properties: {} },
46 argv: (_i, casePath) => {
47 if (!casePath) throw new Error('no active case (the person can run /zebra new <dir> or /zebra case <dir>)')
48 return ['case', 'summary', casePath]
49 },
50 },
51 {
52 name: 'case_update',
53 description:
54 'Record findings in the active case. identifiers FIRST when records name the patient: the name (every spelling: 汉字, pinyin), date of birth, record/ID numbers — the privacy gate then keeps them out of every outgoing call; they are never echoed back. profile: role (family/patient/clinician/researcher), language, proband sex/age, consanguinity. phenotypes: HPO terms present or excluded (labels are verified against HPO; never invent an id — find it with hpo_search). variants, hypotheses (with ORPHA/OMIM/MONDO ids and ledger evidence ids for and against), ACMG readings of recorded variants (codes in, class computed by zebra), therapy leads, tests already done (CMA, panel, exome…, with result), family members (relation, affected, genotype — never names), timeline events, questions for the care team, and removals. Several at once.',
55 inputSchema: {
56 type: 'object',
57 properties: {
58 profile: {
59 type: 'object',
60 description: 'who the case is for and how to write to them; proband basics (no names or birth dates)',
61 properties: {
62 title: { type: 'string' },
63 role: { type: 'string', enum: ['family', 'patient', 'clinician', 'researcher'] },
64 language: { type: 'string', description: 'zh or en' },
65 sex: { type: 'string', enum: ['female', 'male', 'unknown'] },
66 age: { type: 'string', description: 'age or age band, e.g. "2y11m"' },
67 ancestry: { type: 'string' },
68 consanguinity: { type: 'boolean' },
69 },
70 },
71 phenotypes: {
72 type: 'array',
73 items: {
74 type: 'object',
75 properties: {
76 id: HPO,
77 status: { type: 'string', enum: ['present', 'excluded'] },
78 onset: { type: 'string' },
79 source: { type: 'string', description: 'where it is documented, e.g. records/neuro-2023.pdf p2' },
80 note: { type: 'string' },
81 },
82 required: ['id'],
83 },
84 },
85 variants: {
86 type: 'array',
87 items: {
88 type: 'object',
89 properties: {
90 gene: { type: 'string' },
91 hgvs_c: { type: 'string', description: 'with transcript, e.g. NM_001165963.4:c.2134C>T' },
92 hgvs_g: { type: 'string' },
93 hgvs_p: { type: 'string' },
94 vcf: { type: 'string', description: 'chrom-pos-ref-alt, e.g. 2-166001-C-T' },
95 assembly: ASSEMBLY,
96 zygosity: { type: 'string', enum: ['het', 'hom', 'hemi', 'mosaic', 'unknown'] },
97 inheritance: { type: 'string', enum: ['de_novo', 'maternal', 'paternal', 'biparental', 'unknown'] },
98 classification_lab: { type: 'string', description: 'as written on the lab report' },
99 source: { type: 'string' },
100 description: { type: 'string' },
101 kind: {
102 type: 'string',
103 enum: ['small', 'cnv', 'exon_cnv', 'copy_number', 'repeat_expansion'],
104 description: 'small (default) for an SNV/indel; cnv for a CMA/CNV-seq result; exon_cnv for an exon-level deletion or duplication; copy_number for SMN1-type dosage; repeat_expansion for a repeat expansion',
105 },
106 region: { type: 'string', description: 'cnv: chr15:23123715-28193120' },
107 iscn: { type: 'string', description: 'cnv: the ISCN string as reported, e.g. arr[GRCh38] 22q11.21(18648855_21800471)x1' },
108 cnv_type: { type: 'string', enum: ['loss', 'gain'] },
109 copy_number: { type: 'number', description: 'copies reported (SMN1 exon 7 = 0, 1, 2 …)' },
110 exons: { type: 'string', description: 'exon_cnv: 45-50, or a single exon number' },
111 genes: { type: 'array', items: { type: 'string' }, description: 'genes the finding covers, as a tool returned them' },
112 motif: { type: 'string', description: 'repeat: the repeat unit, e.g. CGG' },
113 repeat_count: { type: 'number', description: 'repeat: the number of units reported' },
114 method: { type: 'string', description: 'how it was measured: CMA, CNV-seq, MLPA, ddPCR, repeat-primed PCR …' },
115 },
116 },
117 },
118 hypotheses: {
119 type: 'array',
120 items: {
121 type: 'object',
122 properties: {
123 disease: { type: 'string' },
124 status: { type: 'string', enum: ['leading', 'considered', 'excluded', 'confirmed'] },
125 ids: { type: 'array', items: { type: 'string' }, description: 'ORPHA:33069, OMIM:607208, MONDO:0100135 as tools returned them' },
126 support: { type: 'array', items: { type: 'string' }, description: 'ledger ids, e.g. E4' },
127 against: { type: 'array', items: { type: 'string' } },
128 note: { type: 'string' },
129 },
130 required: ['disease'],
131 },
132 },
133 leads: {
134 type: 'array',
135 items: {
136 type: 'object',
137 properties: {
138 name: { type: 'string' },
139 kind: { type: 'string', enum: ['approved', 'trial', 'repurposing', 'n-of-1', 'supportive', 'other'] },
140 status: { type: 'string' },
141 evidence: { type: 'array', items: { type: 'string' } },
142 note: { type: 'string' },
143 },
144 required: ['name', 'kind'],
145 },
146 },
147 acmg: {
148 type: 'array',
149 description: 'store a research-grade ACMG reading on a recorded variant: the codes you justified (classified by zebra, not by you)',
150 items: {
151 type: 'object',
152 properties: {
153 variant_id: { type: 'string', description: 'v1, v2 ... as case_status lists them' },
154 codes: { type: 'array', items: { type: 'string' } },
155 note: { type: 'string' },
156 },
157 required: ['variant_id', 'codes'],
158 },
159 },
160 questions: { type: 'array', items: { type: 'string' } },
161 identifiers: {
162 type: 'array',
163 items: { type: 'string' },
164 description: 'protected identifiers to add (name in every spelling, date of birth, record/ID numbers); kept out of every outgoing call, never shown back',
165 },
166 tests: {
167 type: 'array',
168 description: 'tests already done and their reported result',
169 items: {
170 type: 'object',
171 properties: {
172 type: { type: 'string', description: 'CMA, karyotype, gene panel, trio exome, genome, MLPA, repeat sizing, metabolic screen, MRI, EEG…' },
173 date: { type: 'string' },
174 result: { type: 'string', description: 'as reported, e.g. "normal", "VUS SCN1A c.…", "arr[GRCh38] 22q11.21(…)x1"' },
175 lab: { type: 'string' },
176 method: { type: 'string' },
177 source: { type: 'string', description: 'which record file it came from' },
178 note: { type: 'string' },
179 },
180 required: ['type'],
181 },
182 },
183 family: {
184 type: 'array',
185 description: 'relatives as the records describe them — relation, affected or not, genotype if tested; never names',
186 items: {
187 type: 'object',
188 properties: {
189 relation: { type: 'string', enum: ['mother', 'father', 'sibling', 'brother', 'sister', 'half-sibling', 'child', 'son', 'daughter', 'maternal grandmother', 'maternal grandfather', 'paternal grandmother', 'paternal grandfather', 'maternal aunt', 'maternal uncle', 'paternal aunt', 'paternal uncle', 'cousin', 'twin', 'other'] },
190 sex: { type: 'string' },
191 affected: { type: ['boolean', 'string'], description: 'true, false or "unknown"' },
192 status: { type: 'string', description: 'alive, deceased, …' },
193 genotype: { type: 'string', description: 'e.g. "het for v1", "not carrier of v1", "not tested"' },
194 age: { type: 'string' },
195 note: { type: 'string' },
196 source: { type: 'string' },
197 },
198 },
199 },
200 timeline: {
201 type: 'array',
202 items: {
203 type: 'object',
204 properties: { date: { type: 'string' }, event: { type: 'string' }, source: { type: 'string' } },
205 required: ['event'],
206 },
207 },
208 remove: {
209 type: 'array',
210 items: {
211 type: 'object',
212 properties: {
213 kind: { type: 'string', enum: ['phenotype', 'variant', 'hypothesis', 'lead', 'test', 'relative'] },
214 id: { type: 'string' },
215 },
216 required: ['kind', 'id'],
217 },
218 },
219 },
220 },
221 argv: (input, casePath) => {
222 if (!casePath) throw new Error('no active case (the person can run /zebra new <dir> or /zebra case <dir>)')
223 return ['case', 'apply', casePath, '--ops', JSON.stringify(input)]
224 },
225 touchesCase: true,
226 },
227 {
228 name: 'hpo_search',
229 description:
230 'Find Human Phenotype Ontology terms for a clinical phrase. Chinese: official Chinese labels once the HPO release is fetched, and the lay phrases families use (走路晚, 抽风, 不会说话, 发热惊厥, 头围小, 听力下降) with or without it — also inside a short sentence ("孩子走路晚"); a negation (无明显抽搐) is not read as the feature and is warned about. English works either way. Returns verified ids with the label, the Chinese label where there is one, and what matched (label, synonym or lay phrase). Use it for every phenotype before recording one.',
231 inputSchema: {
232 type: 'object',
233 properties: { text: { type: 'string' }, limit: { type: 'number', default: 8 } },
234 required: ['text'],
235 },
236 argv: i => ['hpo', 'search', str(i.text) ?? '', ...flag('--limit', num(i.limit))],
237 },
238 {
239 name: 'phenotype_rank',
240 description:
241 'Phenotype-driven differential diagnosis: rank diseases and genes for a set of HPO terms (present, and excluded). Sources: local (offline Resnik best-match over HPO annotations), monarch (Monarch semantic similarity), pubcasefinder (PubCaseFinder). Each source ranks on its own; agreement across them is the signal. Excluded terms are checked against each disease and listed in `excluded_hits` (a disease that usually has a feature the patient lacks) but do not lower the local score — on the phenopacket-store benchmark that ranked better (docs/BENCHMARK.md); read the hits and weigh them yourself, or set excluded_weight 1 for the 0.1.0 penalty. Ties at the top are reported. Measured accuracy (held-out cases whose own paper is not an HPO annotation source): correct disease in the local top 10 for 28%; all held-out cases 70% (an upper bound). from_case uses the active case\'s phenotypes.',
242 inputSchema: {
243 type: 'object',
244 properties: {
245 present: { type: 'array', items: HPO },
246 excluded: { type: 'array', items: HPO },
247 from_case: { type: 'boolean' },
248 sources: { type: 'array', items: { type: 'string', enum: ['local', 'monarch', 'pubcasefinder'] } },
249 top: { type: 'number', default: 15 },
250 local_method: { type: 'string', enum: ['resnik', 'lr'], description: 'local scoring (default resnik)' },
251 excluded_weight: { type: 'number', description: 'local penalty for excluded terms present in a disease: 0 (default, flagged only) to 10; 1 = zebra 0.1.0' },
252 },
253 },
254 argv: i => {
255 const out = ['phenotype', 'rank']
256 const present = list(i.present)
257 const excluded = list(i.excluded)
258 if (present.length) out.push('--present', ...present)
259 if (excluded.length) out.push('--exclude', ...excluded)
260 if (i.from_case === true) out.push('--from-case')
261 const sources = list(i.sources)
262 if (sources.length) out.push('--sources', sources.join(','))
263 return [...out, ...flag('--top', num(i.top)), ...flag('--local-method', str(i.local_method)),
264 ...flag('--excluded-weight', num(i.excluded_weight))]
265 },
266 timeoutMs: 180_000,
267 },
268 {
269 name: 'gene_card',
270 description:
271 'Everything that matters about one gene for rare disease: HGNC identity (aliases resolved), causal disease associations with inheritance (Monarch: OMIM and ClinGen), ClinGen gene–disease validity classes and dosage sensitivity (haploinsufficiency / triplosensitivity), gnomAD constraint (pLI, LOEUF, missense Z), PanelApp (England and Australia) panels with confidence, protein function (UniProt) and AlphaFold model. Use symbols as HGNC spells them.',
272 inputSchema: { type: 'object', properties: { symbol: { type: 'string' } }, required: ['symbol'] },
273 argv: i => ['gene', str(i.symbol) ?? ''],
274 },
275 {
276 name: 'variant_card',
277 description:
278 'Annotate one variant: normalized forms (HGVS, GRCh38/37 coordinates), consequence on the MANE transcript (Ensembl VEP), population frequency (gnomAD, by genetic ancestry group, with filtering AF), ClinVar classification with review status, in-silico predictors (REVEL, AlphaMissense, CADD, SpliceAI where available) and literature mentions (LitVar). Input: HGVS with transcript (NM_...:c.), rsID, or chrom-pos-ref-alt with assembly.',
279 inputSchema: {
280 type: 'object',
281 properties: {
282 variant: { type: 'string', description: 'HGVS, chrom-pos-ref-alt, rsID, or an mtDNA change such as "m.3243A>G 35%"' },
283 assembly: ASSEMBLY,
284 gene: { type: 'string' },
285 heteroplasmy: { type: 'number', description: 'mtDNA only: the reported heteroplasmy, in percent' },
286 },
287 required: ['variant'],
288 },
289 argv: i => [
290 'variant', str(i.variant) ?? '', ...flag('--assembly', str(i.assembly)), ...flag('--gene', str(i.gene)),
291 ...flag('--heteroplasmy', num(i.heteroplasmy)),
292 ],
293 timeoutMs: 180_000,
294 },
295 {
296 name: 'disease_card',
297 description:
298 'One rare disease: identifiers across ORPHA, OMIM, MONDO, ICD; definition, prevalence, inheritance, age of onset, associated genes (Orphanet / Monarch), GeneReviews chapter, whether it is on China\'s national rare disease lists. Input: a name or an id (ORPHA:33069, OMIM:607208, MONDO:0100135).',
299 inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
300 argv: i => ['disease', str(i.query) ?? ''],
301 timeoutMs: 180_000,
302 },
303 {
304 name: 'acmg',
305 description:
306 'ACMG/AMP classification arithmetic. classify: give the evidence codes you have justified (e.g. PVS1, PS2, PM2_Supporting, PP3_Strong, BS1) and get the points (Tavtigian 2020) and the 2015 combining-rule class, with rule warnings. suggest: for a variant, the codes that follow from numbers alone (frequency, REVEL, SpliceAI) with their ClinGen thresholds — judgement codes (PVS1, PS3, PM3, PP1, PS4...) stay yours to justify.',
307 inputSchema: {
308 type: 'object',
309 properties: {
310 mode: { type: 'string', enum: ['classify', 'suggest'] },
311 codes: { type: 'array', items: { type: 'string' } },
312 variant: { type: 'string' },
313 assembly: ASSEMBLY,
314 inheritance: { type: 'string', enum: ['AD', 'AR', 'XLD', 'XLR', 'unknown'] },
315 prevalence: { type: 'number', description: 'suggest: disease prevalence (e.g. 0.0001) for the Whiffin maximum credible AF behind BS1' },
316 allelic: { type: 'number', description: 'suggest: maximum allelic contribution of one variant (0-1]' },
317 genetic: { type: 'number', description: 'suggest: maximum genetic contribution of this gene (0-1]' },
318 penetrance: { type: 'number', description: 'suggest: penetrance (0-1]' },
319 inheritance_mode: { type: 'string', enum: ['monoallelic', 'biallelic'], description: 'suggest: only for XLR, XLD or unknown inheritance; AD/AR choose it themselves' },
320 pm2_max_af: { type: 'number', description: 'suggest: a gene-specific PM2 ceiling from a ClinGen VCEP' },
321 heteroplasmy: { type: 'number', description: 'suggest, mtDNA: heteroplasmy in percent' },
322 no_splice_lookup: { type: 'boolean', description: 'suggest: skip the SpliceAI/Pangolin lookup (±4,999 nt) and use VEP\'s precomputed SpliceAI' },
323 },
324 required: ['mode'],
325 },
326 argv: i => {
327 if (i.mode === 'suggest') {
328 return [
329 'acmg', 'suggest', str(i.variant) ?? '', ...flag('--assembly', str(i.assembly)), ...flag('--inheritance', str(i.inheritance)),
330 ...flag('--prevalence', num(i.prevalence)), ...flag('--allelic', num(i.allelic)), ...flag('--genetic', num(i.genetic)),
331 ...flag('--penetrance', num(i.penetrance)), ...flag('--inheritance-mode', str(i.inheritance_mode)),
332 ...flag('--pm2-max-af', num(i.pm2_max_af)), ...flag('--heteroplasmy', num(i.heteroplasmy)),
333 ...(i.no_splice_lookup === true ? ['--no-splice-lookup'] : []),
334 ]
335 }
336 return ['acmg', 'classify', ...list(i.codes)]
337 },
338 timeoutMs: 240_000,
339 },
340 {
341 name: 's2f_predict',
342 description:
343 'Sequence-to-function predictions for one variant, each model reported separately with its claim ceiling (never averaged): spliceai and pangolin (Broad lookup service; GRCh38 and GRCh37) — the default; and, through the s2f CLI when installed with keys, gpn_msa (conservation, hg38 SNVs, ~1 min), alphagenome (expression/splicing/chromatin in one tissue: needs ontology, e.g. UBERON:0000955 brain; ~5 s) and evo2 (zero-shot likelihood; 3–10 min). A model that cannot run is not_run with the reason. Use for splice-region, deep intronic, UTR, promoter and other non-coding variants, and to test a mechanism.',
344 inputSchema: {
345 type: 'object',
346 properties: {
347 variant: { type: 'string', description: 'chrom-pos-ref-alt, transcript HGVS or rsID' },
348 assembly: ASSEMBLY,
349 models: { type: 'array', items: { type: 'string', enum: ['spliceai', 'pangolin', 'gpn_msa', 'alphagenome', 'evo2'] }, description: 'default spliceai + pangolin' },
350 ontology: { type: 'string', description: 'tissue/cell CURIE for alphagenome (UBERON:/CL:), chosen for the disease' },
351 distance: { type: 'number', description: 'SpliceAI window around the variant (default 500)' },
352 timeout: { type: 'number', description: 'seconds per s2f model run (default 540)' },
353 },
354 required: ['variant'],
355 },
356 argv: i => {
357 const models = list(i.models)
358 return [
359 's2f', 'predict', str(i.variant) ?? '',
360 ...flag('--assembly', str(i.assembly)),
361 '--models', (models.length ? models : ['spliceai', 'pangolin']).join(','),
362 ...flag('--ontology', str(i.ontology)),
363 ...flag('--distance', num(i.distance)),
364 ...flag('--timeout', num(i.timeout) ?? '540'),
365 ]
366 },
367 timeoutMs: 600_000,
368 deferred: true,
369 },
370 {
371 name: 'therapy_landscape',
372 description:
373 'Therapy landscape for a disease or gene (data only, no judgement): approved and investigational drugs and clinical candidates with stage and mechanism (Open Targets / ChEMBL), target tractability for a gene, and EU orphan designations (EMA; US designations are not checked). Input: a disease name or MONDO/EFO id, or a gene symbol (OMIM:/ORPHA: ids are not accepted — use the name). Mechanism fit and N-of-1 routes are for the zebra-therapy skill to judge.',
374 inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'disease name or MONDO/EFO id, or gene symbol' } }, required: ['query'] },
375 argv: i => ['therapy', str(i.query) ?? ''],
376 timeoutMs: 180_000,
377 deferred: true,
378 },
379 {
380 name: 'trials_search',
381 description:
382 'Clinical trials from ClinicalTrials.gov (v2 API): by condition and optionally a keyword (gene, drug, modality), country and status (default RECRUITING; ANY for all). With a country, only trials with a site there are returned, and those sites are listed. Returns NCT ids, phase, status, interventions, ages, eligibility criteria, contacts, sites and URL, and flags a trial whose status looks stale or inconsistent. ChiCTR (the Chinese registry) is not covered: for China, say so and point to it.',
383 inputSchema: {
384 type: 'object',
385 properties: {
386 condition: { type: 'string' },
387 term: { type: 'string', description: 'extra keyword: gene, drug, modality' },
388 country: { type: 'string', description: 'e.g. China, United States; only trials with a site there, and those sites are listed' },
389 full_eligibility: { type: 'boolean', description: 'the whole eligibility text instead of its first 600 characters' },
390 keep_unrelated: { type: 'boolean', description: 'keep trials whose conditions do not name this disease (normally moved to result.filtered)' },
391 status: { type: 'string', enum: ['RECRUITING', 'NOT_YET_RECRUITING', 'ACTIVE_NOT_RECRUITING', 'COMPLETED', 'ANY'] },
392 limit: { type: 'number', default: 20 },
393 },
394 required: ['condition'],
395 },
396 argv: i => [
397 'trials', str(i.condition) ?? '',
398 ...flag('--term', str(i.term)), ...flag('--country', str(i.country)), ...flag('--status', str(i.status)),
399 ...(i.full_eligibility === true ? ['--full-eligibility'] : []), ...flag('--limit', num(i.limit)),
400 ...(i.keep_unrelated === true ? ['--keep-unrelated'] : []),
401 ],
402 deferred: true,
403 },
404 {
405 name: 'literature_search',
406 description:
407 'Search the literature (Europe PMC; with gene/variant, PubTator3 and LitVar for papers that mention them). Returns PMIDs, titles, years, journals and open-access links; read a paper before citing what it says.',
408 inputSchema: {
409 type: 'object',
410 properties: {
411 query: { type: 'string' },
412 gene: { type: 'string' },
413 variant: { type: 'string', description: 'rsID or HGVS protein/cDNA change' },
414 sort: { type: 'string', enum: ['relevance', 'date', 'cited'] },
415 abstract: { type: 'array', items: { type: 'string' }, description: 'PMIDs whose abstracts to return' },
416 limit: { type: 'number', default: 15 },
417 },
418 },
419 argv: i => [
420 'lit', ...(str(i.query) ? [str(i.query) as string] : []),
421 ...flag('--gene', str(i.gene)), ...flag('--variant', str(i.variant)), ...flag('--sort', str(i.sort)),
422 ...(list(i.abstract).length ? ['--abstract', ...list(i.abstract)] : []), ...flag('--limit', num(i.limit)),
423 ],
424 deferred: true,
425 },
426 {
427 name: 'rare_stats',
428 description:
429 'Classical statistics for rare-disease genetics (no network): segregation (cosegregation LR and PP1 strength), maxaf (Whiffin maximum credible allele frequency), carrier (Hardy–Weinberg carrier frequency from prevalence or allele frequencies), recurrence, fisher, burden (case/control collapsing test), denovo (de novo enrichment, Poisson), km (Kaplan–Meier from a CSV), nof1 (N-of-1 trial design or analysis). params are the CLI flags of `zebra stats <method>` without dashes.',
430 inputSchema: {
431 type: 'object',
432 properties: {
433 method: { type: 'string', enum: ['segregation', 'maxaf', 'carrier', 'recurrence', 'fisher', 'burden', 'denovo', 'km', 'nof1'] },
434 params: { type: 'object', additionalProperties: true },
435 },
436 required: ['method'],
437 },
438 argv: i => {
439 const method = str(i.method) ?? ''
440 const allowed = STATS_FLAGS[method]
441 if (!allowed) throw new Error(`unknown method ${JSON.stringify(method)}`)
442 const out = ['stats', method]
443 const params = (i.params && typeof i.params === 'object' ? i.params : {}) as Record<string, unknown>
444 for (const [k, v] of Object.entries(params)) {
445 const flag = k.replace(/_/g, '-')
446 if (!allowed.includes(flag)) {
447 throw new Error(`${method} does not take ${JSON.stringify(k)}; it takes ${allowed.join(', ')}`)
448 }
449 const key = `--${flag}`
450 if (Array.isArray(v)) out.push(key, ...v.map(String))
451 else if (typeof v === 'boolean') {
452 if (v) out.push(key)
453 } else if (v !== null && v !== undefined) out.push(key, String(v))
454 }
455 return out
456 },
457 deferred: true,
458 },
459 {
460 name: 'edit_check',
461 description:
462 'Base-editing feasibility screen for an SNV: can an adenine or cytosine base editor revert the patient allele to reference, and which SpCas9 protospacers (PAM NGG, or NG for relaxed-PAM variants) put the base in the editing window, with bystander bases (annotate_bystanders checks their coding effect via VEP). Reference sequence from Ensembl. A screen for researchers, not a guide design; delivery to the tissue is the hard part.',
463 inputSchema: {
464 type: 'object',
465 properties: {
466 variant: { type: 'string', description: 'chrom-pos-ref-alt (SNV), transcript HGVS or rsID' },
467 assembly: ASSEMBLY,
468 pam: { type: 'string', enum: ['NGG', 'NG'], description: 'NG when no NGG protospacer exists' },
469 window: { type: 'string', description: 'editing window in protospacer positions, default "4-8"' },
470 annotate_bystanders: { type: 'boolean' },
471 },
472 required: ['variant'],
473 },
474 argv: i => [
475 'edit', str(i.variant) ?? '',
476 ...flag('--assembly', str(i.assembly)), ...flag('--pam', str(i.pam)), ...flag('--window', str(i.window)),
477 ...(i.annotate_bystanders === true ? ['--annotate-bystanders'] : []),
478 ],
479 deferred: true,
480 },
481 {
482 name: 'cnv_interpret',
483 description:
484 'Read the report forms that are not a single sequence variant. A CNV or microarray result (region or ISCN, with build, mosaicism such as x1~2 or 30%, de novo/maternal/paternal) → the genes it spans, every one checked against ClinGen gene dosage, and the ClinGen curated regions it overlaps (22q11.2, 1p36, Williams, PWS/AS, 16p11.2 …) with HI/TS scores, coverage and the ACMG/ClinGen 2020 section-2 row they point to; section 3 by gene count for losses and gains separately — inputs, never a classification (sections 4-5 need the case). An exon-level deletion or duplication (DMD exon 45-50 deletion, NM_004006.3:c.6439-?_7309+?del, intronic breakpoints) → exons, coding bases, whether the frame is kept, and — only when it is not — which additional exon skip restores it. SMN1/SMN2 copy number (clinical exon numbering 1, 2a, 2b, 3-8) read with the SMN2 count, framed as population data, not a prognosis. Repeat expansions (FMR1, HTT, DMPK, FXN, C9orf72) placed in their published size bands with sex-specific wording and what else is needed (methylation, AGG interruptions, the other allele) — a referenced reading, not a classification. Coordinates from Ensembl.',
485 inputSchema: {
486 type: 'object',
487 properties: {
488 result: {
489 type: 'string',
490 description: 'the finding as the report gives it: "chr15:23123715-28193120 loss" | "arr[GRCh38] 22q11.21(18648855_21800471)x1" | "DMD exon 45-50 deletion" | "NM_004006.3:c.6439-?_7309+?del" | "SMN1 exon 7 copy number 0" | "FMR1 CGG 230"',
491 },
492 assembly: ASSEMBLY,
493 gene: { type: 'string', description: 'when the string does not name one' },
494 copies: { type: 'number', description: 'copy number the report gives, when it is not in the string' },
495 inheritance: { type: 'string', enum: ['de_novo', 'maternal', 'paternal', 'biparental', 'unknown'] },
496 method: { type: 'string', description: 'CMA, CNV-seq, MLPA, ddPCR, repeat-primed PCR …' },
497 record: { type: 'boolean', description: 'also record it in the active case (a write: asked about like any case change)' },
498 sex: { type: 'string', enum: ['male', 'female'], description: 'decides what an X/Y copy number means (default: the case profile)' },
499 smn2_copies: { type: 'number', description: 'SMN2 copy number, read together with SMN1' },
500 related: { type: 'array', items: { type: 'string' }, description: 'other findings in the same report (the second allele of a repeat, a second CNV)' },
501 },
502 required: ['result'],
503 },
504 argv: i => [
505 'cnv', str(i.result) ?? '',
506 ...flag('--assembly', str(i.assembly)), ...flag('--gene', str(i.gene)), ...flag('--copies', num(i.copies)),
507 ...flag('--inheritance', str(i.inheritance)), ...flag('--method', str(i.method)),
508 ...flag('--sex', str(i.sex)), ...flag('--smn2-copies', num(i.smn2_copies)),
509 ...(list(i.related).length ? ['--related', ...list(i.related)] : []),
510 ...(i.record === true ? ['--record'] : []),
511 ],
512 touchesCase: true,
513 timeoutMs: 180_000,
514 },
515 {
516 name: 'china_rare',
517 description:
518 "China's national rare disease lists (第一批罕见病目录 2018, 第二批 2023): is a disease on them, its Chinese name and list number. status: on_list (the published entry, or a named member of a listed group); qualified (only a subtype or form is listed — the result says which); possible (closest entries to verify, e.g. an acronym or a shared stretch of text — not a match); not_found. With query \"hospitals\": the 国家罕见病诊疗协作网 hospitals (NHC 2024 list, 419), by province with the lead hospitals. Input: Chinese or English name.",
519 inputSchema: {
520 type: 'object',
521 properties: {
522 query: { type: 'string', description: 'a disease name, or "hospitals" for the collaboration-network hospitals' },
523 province: { type: 'string', description: 'with "hospitals": e.g. 浙江 or 浙江省' },
524 },
525 required: ['query'],
526 },
527 argv: i => ['china', str(i.query) ?? '', ...flag('--province', str(i.province))],
528 deferred: true,
529 },
530 {
531 name: 'case_recheck',
532 description:
533 'Ask the active case\'s questions again and say what changed since the last recheck: ClinVar classification and stars of each recorded sequence variant, gnomAD frequency, ClinGen gene-disease validity of its genes, recruiting trials (new and no longer recruiting) and papers first published since then for each open hypothesis. The first run records the baseline. Each change says what to look at again (an ACMG reading, a trial\'s eligibility); nothing is concluded. Queries carry biology only and skip anything holding a protected identifier. plan: true lists the questions without sending them.',
534 inputSchema: { type: 'object', properties: { plan: { type: 'boolean' } } },
535 argv: (i, casePath) => {
536 if (!casePath) throw new Error('no active case (the person can run /zebra new <dir> or /zebra case <dir>)')
537 return ['case', 'recheck', casePath, ...(i.plan === true ? ['--plan'] : [])]
538 },
539 touchesCase: true,
540 deferred: true,
541 timeoutMs: 600_000,
542 },
543 {
544 name: 'access',
545 description:
546 'Access to a treatment, for a drug, a disease or a gene, with the source of every line: FDA and EMA status from the agencies\' own records (openFDA labels, Drugs@FDA, EMA medicine data — withdrawn and refused shown as such), approval in China from the bundled official NMPA/CDE documents (approved_in_china, named_not_approved, or not_in_bundled_list — which is not "not approved"), China\'s 2025 national reimbursement list (NRDL) entry with its restriction text verbatim, trials with sites in China (ClinicalTrials.gov; ChiCTR cannot be queried by a script — it blocks them), and the collaboration-network hospitals for a province. A name it cannot resolve exactly comes back unresolved with candidates; it never answers for a near match.',
547 inputSchema: {
548 type: 'object',
549 properties: {
550 query: { type: 'string', description: 'drug (INN or Chinese name), disease (Chinese or English) or gene symbol' },
551 as: { type: 'string', enum: ['auto', 'drug', 'disease', 'gene'] },
552 province: { type: 'string', description: 'list the collaboration-network hospitals of this province, e.g. 浙江' },
553 trials: { type: 'number', description: 'how many trials with a site in China (default a few)' },
554 status: { type: 'string', description: 'trial status filter, e.g. RECRUITING or ANY' },
555 },
556 required: ['query'],
557 },
558 argv: i => [
559 'access', str(i.query) ?? '', ...flag('--as', str(i.as)), ...flag('--province', str(i.province)),
560 ...flag('--trials', num(i.trials)), ...flag('--status', str(i.status)),
561 ],
562 timeoutMs: 180_000,
563 },
564 {
565 name: 'expression',
566 description:
567 'Median expression of a gene per tissue (GTEx v8/v10) with each tissue\'s ontology id (UBERON/EFO) — for choosing the AlphaGenome tissue and the tissue an RNA test can use: the result states whether blood, lymphoblastoid cells, fibroblasts, skin or muscle reach 1 TPM. Bulk adult medians, not a detection limit; an NMD-degraded transcript reads low.',
568 inputSchema: {
569 type: 'object',
570 properties: {
571 gene: { type: 'string' },
572 top: { type: 'number' },
573 dataset: { type: 'string', enum: ['gtex_v8', 'gtex_v10'] },
574 },
575 required: ['gene'],
576 },
577 argv: i => ['expression', str(i.gene) ?? '', ...flag('--top', num(i.top)), ...flag('--dataset', str(i.dataset))],
578 deferred: true,
579 },
580 {
581 name: 'aso_screen',
582 description:
583 'Splice-switching antisense feasibility screen for researchers: from SpliceAI for the variant, the aberrant event (pseudoexon / cryptic acceptor / cryptic donor, boundaries checked for AG/GT in the patient sequence; or exon_skip to restore a reading frame), then candidate target windows on the pre-mRNA with coordinates, target and antisense sequence, GC, hairpin and homopolymer flags, whether the variant lies inside, every ranking component shown, and published N-of-1 precedents retrieved from Europe PMC. When no aberrant splicing is predicted it says so and stops. Optional genomic uniqueness via NCBI BLAST (slow: 30-700 s). A screen, not a design: no chemistry, dose or delivery; RNA evidence of the aberrant splicing comes first.',
584 inputSchema: {
585 type: 'object',
586 properties: {
587 variant: { type: 'string', description: 'chrom-pos-ref-alt, transcript HGVS or rsID' },
588 assembly: ASSEMBLY,
589 event: { type: 'string', enum: ['auto', 'pseudoexon', 'cryptic_acceptor', 'cryptic_donor', 'exon_skip'] },
590 lengths: { type: 'string', description: 'target lengths LO-HI between 12 and 40 (default 18-25)' },
591 min_delta: { type: 'number', description: 'SpliceAI delta threshold (default 0.2)' },
592 top: { type: 'number', description: '1-60 (default 12)' },
593 uniqueness: { type: 'boolean', description: 'one NCBI BLAST search of the shortlist; adds 30-700 s' },
594 distance: { type: 'number' },
595 },
596 required: ['variant'],
597 },
598 argv: i => [
599 'aso', str(i.variant) ?? '', ...flag('--assembly', str(i.assembly)), ...flag('--event', str(i.event)),
600 ...flag('--lengths', str(i.lengths)), ...flag('--min-delta', num(i.min_delta)), ...flag('--top', num(i.top)),
601 ...flag('--distance', num(i.distance)), ...(i.uniqueness === true ? ['--uniqueness'] : []),
602 ],
603 deferred: true,
604 timeoutMs: 600_000,
605 },
606 {
607 name: 'report_export',
608 description:
609 'Turn a Markdown report from the case (family letter, visit-preparation sheet, clinician summary) into Word (.docx) and PDF — and HTML — with Chinese typography, for a family that does not use a terminal: the person running zebra-mod hands them the files. Written next to the report. Refuses a report that still contains the case\'s protected identifiers or a resident ID number. PDF is printed by a browser on this machine (Chrome, Edge, Chromium, Brave) or LibreOffice; without one the result says so and the other formats are still made.',
610 inputSchema: {
611 type: 'object',
612 properties: {
613 report: { type: 'string', description: 'the .md report: an absolute path, or relative to the case folder (reports/family-letter-2026-10-06.md)' },
614 formats: { type: 'array', items: { type: 'string', enum: ['docx', 'pdf', 'html'] }, description: 'default docx and pdf' },
615 title: { type: 'string', description: 'document title (default: the first heading)' },
616 },
617 required: ['report'],
618 },
619 argv: (i, casePath) => {
620 const report = str(i.report) ?? ''
621 const path = report && casePath && !/^(?:\/|~)/.test(report) ? `${casePath.replace(/\/$/, '')}/${report}` : report
622 const formats = list(i.formats)
623 return ['report', 'export', path, ...flag('--to', formats.length ? formats.join(',') : undefined), ...flag('--title', str(i.title))]
624 },
625 deferred: true,
626 timeoutMs: 150_000,
627 },
628]
629
630/** The keys the tool's own schema declares: the event also carries `tool`, `tool_use_id` and more. */
631export function schemaArgs(def: ToolDef, input: Record<string, unknown>): Record<string, unknown> {
632 const props = (def.inputSchema.properties ?? {}) as Record<string, unknown>
633 const out: Record<string, unknown> = {}
634 for (const key of Object.keys(props)) if (input[key] !== undefined) out[key] = input[key]
635 return out
636}
637
638/** No value the model supplies legitimately starts with "-": the CLI would read it as a flag. */
639function flagShaped(value: unknown, path = ''): string | undefined {
640 if (typeof value === 'string') {
641 return value.trimStart().startsWith('-') ? `${path || 'value'}: ${JSON.stringify(value)}` : undefined
642 }
643 if (Array.isArray(value)) {
644 for (let i = 0; i < value.length; i++) {
645 const hit = flagShaped(value[i], `${path}[${i}]`)
646 if (hit) return hit
647 }
648 return undefined
649 }
650 if (value && typeof value === 'object') {
651 for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
652 const hit = flagShaped(v, path ? `${path}.${k}` : k)
653 if (hit) return hit
654 }
655 }
656 return undefined
657}
658
659export async function toolArgv(def: ToolDef, input: Record<string, unknown>, casePath: string | null): Promise<string[]> {
660 const clean = schemaArgs(def, input)
661 // `case_update` carries the person's own prose (notes, questions), where a leading
662 // dash is harmless: it is passed as one JSON argument, never as a command-line value.
663 if (def.name !== 'case_update') {
664 const shaped = flagShaped(clean)
665 if (shaped) throw new Error(`${shaped} is not a value this tool takes (it reads as a command-line flag)`)
666 }
667 const argv = def.argv(clean, casePath)
668 if (argv.some(a => a === '')) throw new Error('a required argument is empty')
669 return argv
670}
671hooks/ui.tsx 623 lines1// What zebra-mod shows in Claude Code, so a person can see when it is at work and what it did:
2// - its own rows for its tool calls (what is looked up, in which databases, how much evidence came back)
3// in place of a raw JSON envelope; a case_update row never shows the identifiers it registers
4// - the spinner says which databases are being queried
5// - a galloping zebra in the band above the prompt while a query runs, and a standing one with a
6// shield for a few seconds after the privacy gate stopped an outgoing call
7// - a footer label (🦓 and the open case) so it is always clear the mod is installed
8// - one line at the end of a turn that used it: calls, databases, evidence rows, calls refused
9// Everything is additive and scoped to zebra-mod's own activity: unrelated work draws as before.
10// These hooks are registered first, so they wrap the tool, gate and drawing hooks in register.tsx.
11
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
14
15import type { Board, ZebraRun, ZebraTurn } from '../types'
16import { FRAME_COUNT, RASTER_COLUMNS, RASTER_ROWS, zebraCells } from './ui-sprite'
17
18type On = Parameters<Register>[0]
19type Options = Parameters<Register>[1]
20export type UiMode = 'full' | 'quiet' | 'off'
21
22const PREFIX = 'mcp__zebra-mod__'
23const LOCAL = 'local'
24const FRAME_MS = 70 // eleven frames: one stride in about 0.8 s
25
26const EMPTY_TURN: ZebraTurn = { calls: 0, failed: 0, blocked: 0, sources: [], cached: 0, evidence: 0, firstEvidence: null, lastEvidence: null }
27
28const board = atom({ plugin: 'zebra-mod', key: 'board' } as const, null as Board | null)
29const running = atom({ plugin: 'zebra-mod', key: 'running' } as const, [] as ZebraRun[])
30const turnStats = atom({ plugin: 'zebra-mod', key: 'turn' } as const, EMPTY_TURN)
31const blockedAt = atom({ plugin: 'zebra-mod', key: 'blockedAt' } as const, null as number | null)
32const lang = atom({ plugin: 'zebra-mod', key: 'lang' } as const, null as string | null)
33// why the gate refused: an identifier in the call, or a case file it could not read (the gate closed)
34// true from the first interactive session after installing until the person's first prompt
35const onboarding = atom({ plugin: 'zebra-mod', key: 'onboarding' } as const, false as boolean)
36const blockedWhy = atom({ plugin: 'zebra-mod', key: 'blockedWhy' } as const, 'identifier' as string)
37
38// ------------------------------------------------------------ words
39
40// [Chinese, English]
41const LABELS: Record<string, [string, string]> = {
42 case_status: ['病例概况', 'Case status'],
43 case_update: ['更新病例', 'Case update'],
44 case_recheck: ['病例复查', 'Case recheck'],
45 hpo_search: ['HPO 术语检索', 'HPO term search'],
46 phenotype_rank: ['表型排序', 'Phenotype ranking'],
47 gene_card: ['基因卡', 'Gene card'],
48 variant_card: ['变异卡', 'Variant card'],
49 disease_card: ['疾病卡', 'Disease card'],
50 acmg: ['ACMG 计分', 'ACMG points'],
51 cnv_interpret: ['拷贝数变异解读', 'CNV reading'],
52 s2f_predict: ['序列功能预测', 'Sequence-to-function'],
53 therapy_landscape: ['治疗全景', 'Therapy landscape'],
54 trials_search: ['临床试验检索', 'Trial search'],
55 literature_search: ['文献检索', 'Literature search'],
56 rare_stats: ['统计计算', 'Statistics'],
57 edit_check: ['碱基编辑可行性', 'Base-editing screen'],
58 china_rare: ['中国罕见病目录', 'China rare disease lists'],
59 access: ['药物可及性', 'Drug access'],
60 expression: ['组织表达', 'Tissue expression'],
61 aso_screen: ['反义寡核苷酸筛查', 'Antisense screen'],
62 report_export: ['导出报告', 'Report export'],
63}
64
65// What each tool is expected to query, shown while it runs; the row afterwards names what it did query.
66const EXPECTED: Record<string, string[]> = {
67 case_status: [LOCAL], case_update: [LOCAL], report_export: [LOCAL], rare_stats: [LOCAL],
68 case_recheck: ['ClinVar', 'ClinGen', 'ClinicalTrials.gov', 'Europe PMC'],
69 hpo_search: ['HPO'],
70 phenotype_rank: ['HPO', 'Monarch', 'PubCaseFinder'],
71 gene_card: ['HGNC', 'ClinGen', 'gnomAD', 'PanelApp', 'UniProt'],
72 variant_card: ['Ensembl VEP', 'gnomAD', 'ClinVar', 'LitVar2'],
73 disease_card: ['Orphanet', 'Monarch', 'GeneReviews', 'HPO'],
74 cnv_interpret: ['Ensembl', 'ClinGen'],
75 s2f_predict: ['SpliceAI', 'Pangolin'],
76 therapy_landscape: ['Open Targets', 'EMA'],
77 trials_search: ['ClinicalTrials.gov'],
78 literature_search: ['Europe PMC', 'PubTator3', 'LitVar2'],
79 edit_check: ['Ensembl'],
80 china_rare: ['China national rare disease list'],
81 access: ['Open Targets', 'EMA', 'China drug approvals', '国家医保药品目录', 'ClinicalTrials.gov'],
82 expression: ['GTEx'],
83 aso_screen: ['Ensembl', 'SpliceAI', 'Europe PMC'],
84}
85
86// Source names whose short form is not their first words.
87const RENAMED: Array<[RegExp, string, string]> = [
88 [/^China drug approvals/, 'NMPA/CDE', 'NMPA/CDE'],
89 [/^China national rare disease list/, '国家罕见病目录', 'China rare disease lists'],
90 [/^China rare disease list alias/, '', ''],
91 [/^国家医保药品目录/, '国家医保目录', 'China NRDL'],
92 [/^全国罕见病诊疗协作网/, '罕见病诊疗协作网', 'China rare disease network'],
93 [/^GeneReviews/, 'GeneReviews', 'GeneReviews'],
94 [/^local$/, '本机', 'this computer'],
95]
96
97/** "ClinVar esearch" → ClinVar, "HPO annotations (phenotype.hpoa)" → HPO: the database, not the endpoint. */
98export function sourceName(db: string, zh: boolean): string {
99 for (const [re, z, en] of RENAMED) if (re.test(db)) return zh ? z : en
100 const head = (db.split(' (')[0] ?? db).trim()
101 const kept: string[] = []
102 for (const word of head.split(/\s+/)) {
103 if (kept.length > 0 && /^[a-z]/.test(word)) break
104 kept.push(word)
105 }
106 return kept.join(' ') || head
107}
108
109function sourceNames(dbs: readonly string[], zh: boolean): string[] {
110 return [...new Set(dbs.map(d => sourceName(d, zh)).filter(Boolean))]
111}
112
113export function labelOf(tool: string, zh: boolean): string {
114 const l = LABELS[tool]
115 return l ? (zh ? l[0] : l[1]) : tool
116}
117
118function clip(text: string, max: number): string {
119 const one = text.replace(/\s+/g, ' ').trim()
120 return one.length > max ? `${one.slice(0, Math.max(1, max - 1))}…` : one
121}
122
123/** What a call is about, for its row. Never the identifiers a case_update registers. */
124export function queryOf(tool: string, input: unknown, zh: boolean): string {
125 const i = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>
126 const s = (k: string): string => (typeof i[k] === 'string' ? (i[k] as string).trim() : '')
127 const n = (k: string): number => (Array.isArray(i[k]) ? (i[k] as unknown[]).length : 0)
128 // the first plain argument, when the usual ones are absent (never the reserved keys or identifiers)
129 const any = (): string => {
130 for (const [k, v] of Object.entries(i)) {
131 if (['tool', 'tool_use_id', 'consent', 'agentId', 'identifiers'].includes(k)) continue
132 if (typeof v === 'string' && v.trim()) return v.trim()
133 if (Array.isArray(v) && v.length && v.every(x => typeof x === 'string')) return (v as string[]).join(' ')
134 }
135 return ''
136 }
137 return pick() || (tool === 'case_update' || tool === 'case_status' || tool === 'case_recheck' ? '' : any())
138 function pick(): string {
139 switch (tool) {
140 case 'case_update': {
141 const parts: string[] = []
142 const ids = n('identifiers')
143 if (ids) parts.push(zh ? `登记受保护身份信息 ${ids} 项(内容不显示)` : `${ids} protected identifiers registered (not shown)`)
144 const kinds: Array<[string, string, string]> = [
145 ['phenotypes', '表型', 'phenotypes'], ['variants', '变异', 'variants'], ['hypotheses', '诊断假设', 'hypotheses'],
146 ['acmg', 'ACMG 判读', 'ACMG readings'], ['leads', '治疗线索', 'therapy leads'], ['tests', '已做检查', 'tests'],
147 ['family', '家庭成员', 'relatives'], ['timeline', '时间线', 'timeline events'], ['questions', '待问问题', 'questions'],
148 ]
149 for (const [k, z, en] of kinds) {
150 const c = n(k)
151 if (c) parts.push(zh ? `${z} ${c} 项` : `${c} ${en}`)
152 }
153 if (i.profile && typeof i.profile === 'object') parts.push(zh ? '基本信息' : 'profile')
154 if (n('remove')) parts.push(zh ? `删除 ${n('remove')} 项` : `${n('remove')} removals`)
155 return parts.join(' · ')
156 }
157 case 'case_status':
158 case 'case_recheck':
159 return ''
160 case 'hpo_search':
161 return s('text')
162 case 'phenotype_rank': {
163 if (i.from_case === true) return zh ? '使用病例中的表型' : 'phenotypes from the case'
164 const p = n('present')
165 const x = n('excluded')
166 return zh ? `${p} 项表型${x ? ` · 排除 ${x} 项` : ''}` : `${p} present${x ? ` · ${x} excluded` : ''}`
167 }
168 case 'gene_card':
169 return s('symbol')
170 case 'acmg': {
171 const codes = Array.isArray(i.codes) ? (i.codes as unknown[]).filter((c): c is string => typeof c === 'string') : []
172 return codes.length ? codes.join(' + ') : s('variant')
173 }
174 case 'trials_search':
175 return [s('condition'), s('term'), s('country')].filter(Boolean).join(' · ')
176 case 'literature_search':
177 return s('query') || [s('gene'), s('variant')].filter(Boolean).join(' ')
178 case 'rare_stats':
179 return s('method')
180 case 'cnv_interpret':
181 return s('result')
182 case 'report_export':
183 return s('report')
184 default: {
185 for (const k of ['variant', 'query', 'gene', 'symbol', 'text']) if (s(k)) return s(k)
186 return ''
187 }
188 }
189 }
190}
191
192// ------------------------------------------------------------ the tool's answer
193
194type Envelope = { warnings?: unknown; ledger?: unknown; sources?: unknown; result?: unknown }
195
196/** The JSON envelope register.tsx answers a zebra tool with, from a ToolResult's output, or null. */
197export function envelopeOf(output: unknown): Envelope | null {
198 let text: string | undefined
199 if (typeof output === 'string') text = output
200 else if (Array.isArray(output)) {
201 text = output.map(b => (b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string' ? (b as { text: string }).text : '')).join('')
202 } else if (output && typeof output === 'object') {
203 if ('ledger' in output || 'sources' in output) return output as Envelope
204 const t = (output as { text?: unknown }).text
205 if (typeof t === 'string') text = t
206 }
207 if (!text || text[0] !== '{') return null
208 try {
209 const parsed = JSON.parse(text) as unknown
210 return parsed && typeof parsed === 'object' && ('ledger' in parsed || 'sources' in parsed) ? (parsed as Envelope) : null
211 } catch {
212 return null
213 }
214}
215
216type Digest = { dbs: string[]; cached: number; evidence: number; first: number | null; last: number | null; warnings: number }
217
218export function digest(env: Envelope): Digest {
219 const sources = Array.isArray(env.sources) ? env.sources : []
220 const dbs: string[] = []
221 let cached = 0
222 for (const s of sources) {
223 if (!s || typeof s !== 'object') continue
224 const db = (s as { db?: unknown }).db
225 if (typeof db === 'string' && db.trim()) dbs.push(db.trim())
226 if ((s as { cached?: unknown }).cached === true) cached++
227 }
228 const ids = (Array.isArray(env.ledger) ? env.ledger : [])
229 .map(x => (typeof x === 'string' ? /^E(\d+)$/.exec(x.trim()) : null))
230 .filter((m): m is RegExpExecArray => m !== null)
231 .map(m => Number(m[1]))
232 const warnings = Array.isArray(env.warnings) ? env.warnings.length : 0
233 return {
234 dbs: [...new Set(dbs)],
235 cached,
236 evidence: ids.length,
237 first: ids.length ? Math.min(...ids) : null,
238 last: ids.length ? Math.max(...ids) : null,
239 warnings,
240 }
241}
242
243function range(first: number | null, last: number | null): string {
244 if (first === null || last === null) return ''
245 return first === last ? `E${first}` : `E${first}–E${last}`
246}
247
248function listed(names: string[], zh: boolean, max = 4): string {
249 if (names.length <= max) return names.join(zh ? '、' : ', ')
250 const shown = names.slice(0, max).join(zh ? '、' : ', ')
251 return zh ? `${shown} 等 ${names.length} 个` : `${shown} +${names.length - max} more`
252}
253
254/** The one line under a zebra tool's row: evidence, databases, cache, notes. */
255export function summaryLine(env: Envelope, zh: boolean): string {
256 const d = digest(env)
257 const names = sourceNames(d.dbs, zh)
258 const parts: string[] = []
259 if (d.evidence) parts.push(zh ? `证据 ${d.evidence} 条(${range(d.first, d.last)})` : `${d.evidence} evidence rows (${range(d.first, d.last)})`)
260 if (names.length) parts.push(zh ? `来源 ${listed(names, zh)}` : `from ${listed(names, zh)}`)
261 if (d.cached) parts.push(zh ? `${d.cached} 条来自缓存` : `${d.cached} cached`)
262 if (d.warnings) parts.push(zh ? `${d.warnings} 条提示` : `${d.warnings} notes`)
263 if (!parts.length) return zh ? '已在本机完成' : 'done on this computer'
264 return parts.join(' · ')
265}
266
267/** The line left at the end of a turn that used zebra-mod. */
268export function turnLine(t: ZebraTurn, zh: boolean): string | undefined {
269 if (t.calls === 0 && t.blocked === 0) return undefined
270 if (t.calls === 0) {
271 return zh
272 ? `🛡 本轮隐私闸门拦下 ${t.blocked} 次外发调用,相关内容未发出。`
273 : `🛡 The privacy gate stopped ${t.blocked} outgoing call${t.blocked > 1 ? 's' : ''} this turn; nothing in them was sent.`
274 }
275 const names = sourceNames(t.sources, zh).filter(n => n !== (zh ? '本机' : 'this computer'))
276 const parts: string[] = [zh ? `调用 ${t.calls} 次` : `${t.calls} call${t.calls > 1 ? 's' : ''}`]
277 if (names.length) parts.push(zh ? `查询 ${names.length} 个数据源(${listed(names, zh, 5)})` : `${names.length} databases (${listed(names, zh, 5)})`)
278 if (t.evidence) parts.push(zh ? `新增证据 ${t.evidence} 条(${range(t.firstEvidence, t.lastEvidence)})` : `${t.evidence} evidence rows (${range(t.firstEvidence, t.lastEvidence)})`)
279 if (t.failed) parts.push(zh ? `${t.failed} 次未执行` : `${t.failed} not run`)
280 if (t.blocked) parts.push(zh ? `隐私闸门拦截 ${t.blocked} 次` : `${t.blocked} stopped by the privacy gate`)
281 return `🦓 ${zh ? '本轮 zebra-mod:' : 'zebra-mod this turn: '}${parts.join(' · ')}`
282}
283
284// ------------------------------------------------------------ hooks
285
286// What the animation and the language choice read between render passes; a reload resets them.
287let envZh: boolean | undefined
288let bandId: string | undefined
289let frame = 0
290let scroll = 0
291let ticker: { cancel: () => void; owner: string } | undefined
292
293async function isZh($: EngineInterface): Promise<boolean> {
294 const b = await read($, board)
295 if (b?.language) return b.language.toLowerCase().startsWith('zh')
296 const l = await read($, lang)
297 if (l) return l === 'zh'
298 if (envZh === undefined) {
299 const env = (await $.env.get('LC_ALL')) || (await $.env.get('LANG')) || ''
300 envZh = env.toLowerCase().startsWith('zh')
301 }
302 return envZh
303}
304
305// The gallop runs inside the tool call that started it (that dispatch lasts as long as the query)
306// and stops with it.
307function startTicker($: EngineInterface, owner: string): void {
308 if (ticker) return
309 let timer: { cancel: () => void }
310 try {
311 timer = $.clock.every(FRAME_MS, () => {
312 frame = (frame + 1) % FRAME_COUNT
313 scroll += 2
314 if (bandId === undefined) return
315 void $.ui
316 .blit({ requestId: bandId, key: 'zebra', cells: zebraCells(frame, scroll), columns: RASTER_COLUMNS, rows: RASTER_ROWS })
317 .catch(() => undefined)
318 })
319 } catch {
320 return // no timer here: the band stays a still picture
321 }
322 ticker = { cancel: () => timer.cancel(), owner }
323}
324
325function stopTicker(owner: string): void {
326 if (ticker?.owner !== owner) return
327 ticker.cancel()
328 ticker = undefined
329}
330
331/** One zebra tool call, wrapped: in flight for the spinner and the band, then tallied for the turn. */
332async function observeCall<E extends { tool_use_id?: string }>(
333 $: EngineInterface,
334 e: E,
335 next: (e: E) => Promise<ToolCallResult>,
336 tool: string,
337 mode: UiMode,
338): Promise<ToolCallResult> {
339 const now = Date.now()
340 const id = String(e.tool_use_id ?? `${tool}:${now}`)
341 const run: ZebraRun = {
342 id,
343 tool,
344 queryZh: clip(queryOf(tool, e, true), 80),
345 queryEn: clip(queryOf(tool, e, false), 80),
346 sources: EXPECTED[tool] ?? [],
347 startedAt: now,
348 }
349 await update($, running, list => [...list, run])
350 if (mode === 'full' && run.sources.some(s => s !== LOCAL)) startTicker($, id)
351 let answer: ToolCallResult | undefined
352 try {
353 answer = await next(e)
354 return answer
355 } finally {
356 stopTicker(id)
357 await update($, running, list => list.filter(r => r.id !== id))
358 await update($, turnStats, t => recordCall(t, tool, answer))
359 }
360}
361
362/** A tool-result row that carries the privacy gate's refusal: a shield until the next turn. */
363async function noteRefusals($: EngineInterface, content: unknown, mode: UiMode): Promise<void> {
364 const texts = gateRefusals(content)
365 const refusals = texts.length
366 if (refusals === 0) return
367 const at = Date.now()
368 await update($, blockedWhy, () => (texts.some(t => t.includes('gate is closed')) ? 'closed' : 'identifier'))
369 await update($, blockedAt, () => at)
370 await update($, turnStats, t => ({ ...t, blocked: t.blocked + refusals }))
371 if (mode === 'quiet') {
372 $.ui.toast(
373 (await isZh($))
374 ? '🛡 隐私闸门拦下一次外发调用:其中含有受保护的身份信息,未发出。'
375 : '🛡 The privacy gate stopped an outgoing call that carried a protected identifier; it was not sent.',
376 )
377 }
378}
379
380/** The first interactive session after installing (or updating to this version): say what to do. */
381async function startOnboarding($: EngineInterface): Promise<void> {
382 if ((await $.store.get('onboarded')) === true) return
383 await update($, onboarding, () => true)
384 $.ui.toast(
385 (await isZh($))
386 ? '🦓 zebra-mod 已就绪:直接用中文描述病情或检查结果即可。输入 /zebra demo 打开示例病例试试,/zebra 查看全部功能。'
387 : '🦓 zebra-mod is ready: just describe symptoms or test results. Type /zebra demo to try a demo case, /zebra for everything it does.',
388 )
389}
390
391/** The first prompt ends the onboarding card, for good. */
392async function endOnboarding($: EngineInterface): Promise<void> {
393 if (!(await read($, onboarding))) return
394 await update($, onboarding, () => false)
395 await $.store.set('onboarded', true)
396}
397
398async function closeTurn($: EngineInterface): Promise<void> {
399 const line = turnLine(await read($, turnStats), await isZh($))
400 if (line) await $.session.append({ message: { type: 'system', content: [{ type: 'text', text: line }] } }).catch(() => undefined)
401 await update($, turnStats, () => EMPTY_TURN)
402 await update($, running, () => [])
403}
404
405export function registerUi(on: On, options: Options): void {
406 const mode: UiMode = options.interface === 'quiet' || options.interface === 'off' ? options.interface : 'full'
407 if (mode === 'off') return
408
409 on('turn.start', async ($, e, next) => {
410 await update($, turnStats, () => EMPTY_TURN)
411 await update($, blockedAt, () => null)
412 await update($, running, () => []) // nothing of a previous turn is still running
413 if (/[㐀-鿿]/.test(e.text)) await update($, lang, () => 'zh')
414 await endOnboarding($)
415 return next(e)
416 })
417
418 on('session.start', { isInteractive: true }, async ($, e, next) => {
419 const started = await next(e)
420 await startOnboarding($)
421 return started
422 })
423
424 on('turn.complete', async ($, e, next) => {
425 const done = await next(e)
426 if (!e.agentId) await closeTurn($)
427 return done
428 })
429
430 // Wraps the tool hook in register.tsx (registered after these): what runs, and what it brought back.
431 for (const tool of Object.keys(LABELS)) {
432 on('tool.call', { tool: `${PREFIX}${tool}` }, async ($, e, next) => observeCall($, e, next, tool, mode)).catch(($, e, next) => next(e))
433 }
434
435 // The privacy gate's refusals, from the tool results they leave (any tool, zebra's or not).
436 on('session.append', async ($, e, next) => {
437 const stored = await next(e)
438 if (e.door === 'tool-result') await noteRefusals($, e.message.content, mode)
439 return stored
440 }).catch(($, e, next) => next(e))
441
442 // ---------------------------------------------------------- drawing
443
444 // A zebra tool's row: 🦓, what it is, what it is about; while it runs, the databases it queries.
445 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
446 const tool = e.props.tool.startsWith(PREFIX) ? e.props.tool.slice(PREFIX.length) : ''
447 if (!LABELS[tool] || e.props.isInterrupted) return next(e)
448 const zh = await isZh($)
449 const { Box, Text } = $.ui.resolve(e)
450 const width = e.viewport?.columns ?? 100
451 const label = labelOf(tool, zh)
452 const query = clip(queryOf(tool, e.props.input, zh), Math.max(16, width - label.length - 16))
453 const head = [<Text color="claude">🦓 </Text>, <Text bold>{label}</Text>]
454 if (query) head.push(<Text dimColor>{` ${query}`}</Text>)
455 if (e.props.isRunning) head.push(<Text color="subtle">{zh ? ' 查询中…' : ' running…'}</Text>)
456 else if (e.props.isErrored) head.push(<Text color="error">{zh ? ' 未执行' : ' not run'}</Text>)
457 const rows = [<Box flexDirection="row">{head}</Box>]
458 const expected = sourceNames(EXPECTED[tool] ?? [], zh)
459 if (e.props.isRunning && expected.length) {
460 rows.push(<Text dimColor wrap="truncate-end">{` ${zh ? '数据源:' : 'sources: '}${expected.join(' · ')}`}</Text>)
461 }
462 return <Box flexDirection="column">{rows}</Box>
463 })
464
465 // Its result: evidence, databases, cache and notes in one line, in place of the JSON envelope.
466 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
467 const tool = e.props.tool.startsWith(PREFIX) ? e.props.tool.slice(PREFIX.length) : ''
468 if (!LABELS[tool] || e.props.isErrored) return next(e)
469 const env = envelopeOf(e.props.output)
470 if (!env) return next(e)
471 const { Text } = $.ui.resolve(e)
472 return <Text dimColor wrap="truncate-end">{` ⎿ ${summaryLine(env, await isZh($))}`}</Text>
473 })
474
475 // The spinner names the databases being queried.
476 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
477 if (mode !== 'full' || e.props.message) return next(e)
478 const runs = await read($, running)
479 if (!runs.length) return next(e)
480 const zh = await isZh($)
481 const names = sourceNames(runs.flatMap(r => r.sources), zh).filter(n => n !== (zh ? '本机' : 'this computer'))
482 const word = names.length
483 ? `🦓 ${zh ? '正在查询' : 'Querying'} ${listed(names, zh, 3)}`
484 : `🦓 ${labelOf((runs[0] as ZebraRun).tool, zh)}`
485 return next({ ...e, props: { ...e.props, word } })
486 })
487
488 // A footer label: the mod is here (🦓), with the open case's title, or a shield after a refusal.
489 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
490 const b = await read($, board)
491 const at = await read($, blockedAt)
492 const label = `${at !== null ? '🛡' : '🦓'} ${b ? caseLabel(b) : 'zebra'}`
493 return next({ ...e, props: { ...e.props, modes: [...e.props.modes, label] } })
494 })
495
496 // The band above the prompt: a galloping zebra while a query runs; a standing one with a shield
497 // after the gate refused a call, until the next turn. Nothing otherwise.
498 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
499 if (mode !== 'full' || e.props.hasSurvey) return next(e)
500 const runs = (await read($, running)).filter(r => r.sources.some(s => s !== LOCAL))
501 const at = await read($, blockedAt)
502 // the welcome card until the first prompt, and not once a case is open (the demo, a new case)
503 const isWelcome = !runs.length && at === null && (await read($, onboarding)) && (await read($, board)) === null
504 if (!runs.length && at === null && !isWelcome) return next(e)
505 const zh = await isZh($)
506 bandId = e.requestId
507 const lines = runs.length ? runningLines(runs, zh) : isWelcome ? welcomeLines(zh) : shieldLines(zh, (await read($, blockedWhy)) === 'closed')
508 const tone = runs.length || isWelcome ? 'claude' : 'success'
509 if (e.surface === 'terminal' && e.props.bodyColumns >= RASTER_COLUMNS + 30) {
510 const { Box, Text, Raster } = $.ui.resolve(e)
511 return (
512 <Box flexDirection="row" gap={2}>
513 <Raster key="zebra" columns={RASTER_COLUMNS} rows={RASTER_ROWS} cells={zebraCells(frame, scroll, runs.length === 0)} />
514 <Box flexDirection="column" justifyContent="center" width={Math.max(20, e.props.bodyColumns - RASTER_COLUMNS - 2)}>
515 <Text bold color={tone} wrap="truncate-end">{lines[0]}</Text>
516 <Text wrap="truncate-end">{lines[1]}</Text>
517 <Text dimColor wrap="truncate-end">{lines[2]}</Text>
518 </Box>
519 </Box>
520 )
521 }
522 const { Box, Text } = $.ui.resolve(e)
523 return (
524 <Box flexDirection="column">
525 <Text bold color={tone} wrap="truncate-end">{lines[0]}</Text>
526 <Text wrap="truncate-end">{lines[1]}</Text>
527 <Text dimColor wrap="truncate-end">{lines[2]}</Text>
528 </Box>
529 )
530 })
531}
532
533/** The open case in the footer: its title, phenotypes present, evidence rows. */
534export function caseLabel(b: Board): string {
535 const present = (b.phenotypes ?? []).filter(p => p.status === 'present').length
536 return `${clip(b.title, 20)} · HPO ${present} · E${b.evidence_count ?? 0}`
537}
538
539/** The band's three lines while queries run. */
540export function runningLines(runs: readonly ZebraRun[], zh: boolean): [string, string, string] {
541 const first = runs[0] as ZebraRun
542 const query = zh ? first.queryZh : first.queryEn
543 const what = runs.length === 1
544 ? `${labelOf(first.tool, zh)}${query ? `${zh ? ':' : ': '}${query}` : ''}`
545 : (zh ? `${runs.length} 项查询同时进行` : `${runs.length} queries at once`)
546 return [
547 zh ? '🦓 zebra-mod 正在查询公开数据库' : '🦓 zebra-mod is querying public databases',
548 what,
549 `${zh ? '数据源:' : 'sources: '}${sourceNames(runs.flatMap(r => r.sources), zh).join(' · ')}`,
550 ]
551}
552
553/** The band's three lines in the first session after installing, until the first prompt. */
554export function welcomeLines(zh: boolean): [string, string, string] {
555 return zh
556 ? [
557 '🦓 zebra-mod 已就绪 · 罕见病研究工作台',
558 '直接用中文描述症状、检查结果或基因报告,Claude 会调用它查证、计算并标出处。',
559 '先试一下:/zebra demo 打开示例病例 · /zebra 查看全部功能',
560 ]
561 : [
562 '🦓 zebra-mod is ready · rare-disease research workstation',
563 'Just describe symptoms, test results or a genetic report; Claude calls it to look things up, compute and cite.',
564 'Try it: /zebra demo opens a demo case · /zebra shows everything it does',
565 ]
566}
567
568/** The band's three lines after the privacy gate refused a call. */
569export function shieldLines(zh: boolean, isClosed = false): [string, string, string] {
570 if (isClosed) {
571 return [
572 zh ? '🛡 隐私闸门已关闭外发调用' : '🛡 The privacy gate is holding outgoing calls',
573 zh ? '病例的受保护身份信息名单无法读取,调用未发出。' : "The case's list of protected identifiers cannot be read; the call was not sent.",
574 zh ? '修复或重建 case.json,或用 /zebra close 关闭病例后再查询。' : 'Fix or re-create case.json, or /zebra close, then query again.',
575 ]
576 }
577 return [
578 zh ? '🛡 隐私闸门拦下了一次外发调用' : '🛡 The privacy gate stopped an outgoing call',
579 zh ? '调用中含有受保护的身份信息,内容未发出。' : 'It carried a protected identifier; it was not sent.',
580 zh ? '查询公开数据库时只使用 HPO 编号、基因、变异和疾病名称。' : 'Public databases are queried with HPO ids, genes, variants and disease names only.',
581 ]
582}
583
584/** The privacy gate's refusal texts among a tool-result row's blocks (register.tsx words them). */
585export function gateRefusals(content: unknown): string[] {
586 const out: string[] = []
587 const visit = (block: unknown): void => {
588 if (typeof block === 'string') {
589 if (block.startsWith('zebra-mod privacy gate:') && !block.includes('confirm.')) out.push(block)
590 return
591 }
592 if (Array.isArray(block)) return block.forEach(visit)
593 if (!block || typeof block !== 'object') return
594 const b = block as { type?: unknown; text?: unknown; content?: unknown; is_error?: unknown }
595 if (b.type === 'tool_result' && b.is_error !== true) return
596 if (typeof b.text === 'string') visit(b.text)
597 if (b.content !== undefined) visit(b.content)
598 }
599 visit(content)
600 return out
601}
602
603/** The turn's tally after one zebra call answered `answer`. */
604export function recordCall(t: ZebraTurn, tool: string, answer: ToolCallResult | undefined): ZebraTurn {
605 const next: ZebraTurn = { ...t, calls: t.calls + 1, sources: [...t.sources] }
606 if (!answer || 'deny' in answer && answer.deny !== undefined) {
607 next.failed++
608 return next
609 }
610 const env = envelopeOf(answer.result)
611 if (!env) {
612 if ((EXPECTED[tool] ?? []).includes(LOCAL) && !next.sources.includes(LOCAL)) next.sources.push(LOCAL)
613 return next
614 }
615 const d = digest(env)
616 for (const db of d.dbs) if (!next.sources.includes(db)) next.sources.push(db)
617 next.cached += d.cached
618 next.evidence += d.evidence
619 if (d.first !== null) next.firstEvidence = next.firstEvidence === null ? d.first : Math.min(next.firstEvidence, d.first)
620 if (d.last !== null) next.lastEvidence = next.lastEvidence === null ? d.last : Math.max(next.lastEvidence, d.last)
621 return next
622}
623hooks/privacy.ts 547 lines1// The privacy gate. Three jobs, each best effort and each said plainly where it is not:
2// 1. keep a case's registered identifiers out of anything that leaves the machine,
3// 2. notice the shell commands and tools that leave the machine at all,
4// 3. notice when a file from the case folder is about to be sent somewhere.
5// Matching happens on a normalised form, because a name reaches a web service
6// percent-encoded, '+'-joined, full-width or re-spaced far more often than verbatim.
7
8const ZERO_WIDTH = /[--]/g
9const SEPARATORS = /[\s\-_/.,·'"()[\]{}]+/g
10
11/** Percent-decode, '+'-decode, \uXXXX-decode, NFKC-fold, strip zero-width, lowercase. */
12function fold(text: string): string {
13 let out = text
14 for (let i = 0; i < 3; i++) {
15 const before = out
16 if (/%[0-9a-fA-F]{2}/.test(out)) {
17 try {
18 out = decodeURIComponent(out.replace(/\+/g, ' '))
19 } catch {
20 out = out.replace(/%([0-9a-fA-F]{2})/g, (_m, h) => String.fromCharCode(parseInt(h, 16))).replace(/\+/g, ' ')
21 }
22 }
23 if (/\\u[0-9a-fA-F]{4}/.test(out)) {
24 out = out.replace(/\\u([0-9a-fA-F]{4})/g, (_m, h) => String.fromCharCode(parseInt(h, 16)))
25 }
26 // HTML: a page can spell a name as entities (张) or split it with tags (<b>张</b>小明)
27 if (/&#?\w+;/.test(out)) {
28 out = out.replace(/&#x([0-9a-f]+);/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16)))
29 .replace(/&#(\d+);/g, (_m, d) => String.fromCodePoint(Number(d)))
30 .replace(/&(amp|lt|gt|quot|apos|nbsp);/g, (_m, n) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' } as Record<string, string>)[n] ?? _m)
31 }
32 if (/<\/?[a-z][^<>]{0,200}>/i.test(out)) out = out.replace(/<\/?[a-z][^<>]{0,200}>/gi, '')
33 if (out === before) break
34 }
35 // tone marks (Zhāng Xiǎomíng) and other diacritics never hide a name
36 return out.normalize('NFKD').replace(/\p{M}+/gu, '').normalize('NFKC').replace(ZERO_WIDTH, '').toLowerCase()
37}
38
39// Identifiers of science, not of people: their digits must never be read as a record number
40// (HP:0012345 shares its digits with a record number MZ0012345).
41const SCIENTIFIC_IDS = new RegExp([
42 String.raw`\b(?:hp|omim|mim|orpha|orphanet|mondo|doid|ncit|efo|uberon|go|hgnc|cl|chebi|mp)\s*[:_]\s*\d+`,
43 String.raw`\brs\d+`,
44 String.raw`\b(?:nm|nr|np|nc|ng|nt|xm|xp|enst|ensg|ensp|ccds|lrg)_?\d+(?:\.\d+)?`,
45 String.raw`\bpmid\s*:?\s*\d+`, String.raw`\bpmc\d+`, String.raw`\bnct\d{8}\b`, String.raw`\bchictr-?\w+`,
46 String.raw`\bchr(?:\d{1,2}|x|y|mt?)\s*[:-]\s*[\d,]+(?:\s*[-_]\s*[\d,]+)?`,
47 String.raw`(?<![\w-])(?:\d{1,2}|x|y|mt?)\s*:\s*[\d,]+(?:\s*[-_]\s*[\d,]+)?`,
48 String.raw`\b[cgpnmr]\.\S+`,
49 String.raw`\b10\.\d{4,9}/\S+`,
50].join('|'), 'g')
51
52/** The folded text with separators kept (for word-boundary checks), removed, and digits only. */
53function forms(text: string): { spaced: string; tight: string; digits: string; scrubbed: string } {
54 const folded = fold(text)
55 // scientific identifiers removed: what a record number, a date or an ID number could hide in
56 const scrubbed = folded.replace(SCIENTIFIC_IDS, ' ').replace(/[\s_]+/g, ' ')
57 return {
58 spaced: folded.replace(/[\s_]+/g, ' '),
59 tight: folded.replace(SEPARATORS, ''),
60 digits: scrubbed.replace(/\D+/g, ''),
61 scrubbed,
62 }
63}
64
65const HAS_CJK = /[㐀-鿿豈-]/
66
67const MAX_DEPTH = 12
68const MAX_LEAVES = 5000
69
70/**
71 * Every string in the input: values, and the keys of free-form objects (another server's
72 * `{ data: { "Zhang Wei": 1 } }` carries the name in a key). A key is checked as a whole value,
73 * never joined to its neighbours, so a schema key such as "variant" matches nothing.
74 * Past MAX_DEPTH or MAX_LEAVES the input is too large to read in full: `TOO_DEEP` is returned
75 * among the leaves so the caller refuses instead of passing what it did not read.
76 */
77export const TOO_DEEP = '\u0000zebra:unread'
78function leaves(value: unknown, depth = 0, out: string[] = []): string[] {
79 if (depth > MAX_DEPTH || out.length > MAX_LEAVES) {
80 if (out[out.length - 1] !== TOO_DEEP) out.push(TOO_DEEP)
81 return out
82 }
83 if (typeof value === 'string') out.push(value)
84 else if (Array.isArray(value)) for (const v of value) leaves(v, depth + 1, out)
85 else if (value && typeof value === 'object') {
86 for (const [k, v] of Object.entries(value)) {
87 if (!/^[a-z][a-z0-9_]*$/.test(k)) out.push(k) // a key that is not an identifier-shaped field name
88 leaves(v, depth + 1, out)
89 }
90 } else if (typeof value === 'number' || typeof value === 'boolean') out.push(String(value))
91 return out
92}
93
94const LONG_DIGITS = /\d{6,}/
95// written as one number, or in its 6-8-4 / 6-4-2-2-4 groups; never two separate numbers joined
96const CN_RESIDENT_ID = /(?<![\d])[1-9]\d{5}[ -]?(?:18|19|20)\d{2}[ -]?(?:0[1-9]|1[0-2])[ -]?(?:0[1-9]|[12]\d|3[01])[ -]?\d{3}[\dx](?![\d])/
97// Separators inside are allowed; a letter next to it is not, so rs13812345678 is a variant id
98const CN_MOBILE = /(?<![a-z0-9])(?:\+?0{0,2}86[\s-]?)?1[3-9]\d(?:[\s-]?\d){8}(?![\s-]?\d)(?![a-z0-9])/
99
100const MONTHS = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october',
101 'november', 'december']
102
103/** [year, month, day] readings of a registered date; d/m/y and m/d/y are both kept when ambiguous. */
104function dateParts(identifier: string): Array<[string, number, number]> {
105 const t = identifier.trim()
106 const ymd = /^(\d{4})\s*[-/.年]\s*(\d{1,2})\s*[-/.月]\s*(\d{1,2})\s*日?$/.exec(t)
107 if (ymd) return [[ymd[1] as string, Number(ymd[2]), Number(ymd[3])]]
108 const xy = /^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})$/.exec(t)
109 if (!xy) return []
110 const a = Number(xy[1])
111 const b = Number(xy[2])
112 return [[xy[3] as string, b, a], [xy[3] as string, a, b]] // day-month-year, month-day-year
113}
114
115/**
116 * Patterns for a registered date in the ways people write one: 2019-03-02, 2019/3/2, 20190302,
117 * 2019年3月2日, 02/03/2019, 3/2/2019, March 2, 2019, 2 Mar 2019. Digit boundaries on both sides,
118 * so the same digits inside a coordinate or another date (12 March) never match.
119 */
120function dateMatchers(identifier: string): RegExp[] {
121 const out: RegExp[] = []
122 for (const [y, m, d] of dateParts(identifier)) {
123 if (m < 1 || m > 12 || d < 1 || d > 31) continue
124 const mo = `0?${m}`
125 const da = `0?${d}`
126 const sep = String.raw`\s*[-/.]\s*`
127 const month = MONTHS[m - 1] as string
128 const name = `(?:${month}|${month.slice(0, 3)}\\.?)`
129 const ord = '(?:st|nd|rd|th)?'
130 const yy = y.slice(2)
131 const loose = String.raw`[\s\-/.,]*`
132 out.push(
133 new RegExp(String.raw`(?<!\d)${y}\s*[-/.年]\s*${mo}\s*[-/.月]\s*${da}(?!\d)`),
134 new RegExp(String.raw`(?<!\d)${y}${String(m).padStart(2, '0')}${String(d).padStart(2, '0')}(?!\d)`),
135 new RegExp(String.raw`(?<!\d)${da}${sep}${mo}${sep}${y}(?!\d)`),
136 new RegExp(String.raw`(?<!\d)${mo}${sep}${da}${sep}${y}(?!\d)`),
137 new RegExp(String.raw`(?<!\d)${yy}[-/.]${mo}[-/.]${da}(?!\d)`), // 19-03-02
138 new RegExp(String.raw`(?<![a-z])${name}${loose}${da}${ord}${loose}${y}(?!\d)`), // March 2, 2019 / Mar-02-2019
139 new RegExp(String.raw`(?<!\d)${da}${ord}${loose}(?:of\s*)?${name}${loose}${y}(?!\d)`), // 2 March 2019 / 02-Mar-2019
140 )
141 }
142 return out
143}
144
145function escapeRe(text: string): string {
146 return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
147}
148
149/**
150 * What in `input` must not leave the machine, described without repeating it.
151 * `identifiers` are the case's registered strings (names, dates of birth, record numbers).
152 */
153export function guardInput(input: unknown, identifiers: readonly string[]): string | undefined {
154 const all = leaves(input)
155 if (all.includes(TOO_DEEP)) return 'more nested data than the gate can read in full'
156 const values = all.filter(v => !isLocalPath(v))
157 if (values.length === 0) return undefined
158 // Each value is folded on its own: digits or letters from two unrelated values
159 // (an MRN and an HPO id next to it) must never join into a match.
160 const folded = values.map(forms)
161
162 for (let i = 0; i < identifiers.length; i++) {
163 const raw = (identifiers[i] ?? '').trim()
164 if (!raw) continue
165 const label = `protected identifier #${i + 1} from the case`
166 const id = forms(raw)
167 // A short number (a year, a floor, an age) identifies nobody and would block
168 // ordinary queries; a record number is longer. Dates are handled below.
169 if (/^\d+$/.test(id.tight) && id.tight.length < 6) continue
170 const dates = dateMatchers(raw)
171 const tokens = id.spaced.split(' ').filter(t => t.length >= 2)
172
173 for (const f of folded) {
174 // a date of birth, however it is written
175 if (dates.some(re => re.test(f.scrubbed))) return label
176 // a record or ID number: digits only, so separators cannot hide it
177 // (not for a date: its digits joined across a value's other numbers would match by accident)
178 if (dates.length === 0 && LONG_DIGITS.test(id.digits) && f.digits.includes(id.digits)) return label
179 // a number-only identifier is matched by its digits alone (a PMID or rsID with the same digits is not it)
180 if (/^\d+$/.test(id.tight)) continue
181 if (HAS_CJK.test(raw)) {
182 // Chinese names: two characters identify, and spacing carries no meaning
183 if (id.tight.length >= 2 && f.tight.includes(id.tight)) return label
184 continue
185 }
186 // every token present as a whole word, in any order ("Xiaoming Zhang" too)
187 if (tokens.length > 1 && tokens.every(t => wordIn(t, f.spaced))) return label
188 if (id.tight.length >= 3) {
189 if (wordIn(id.tight, f.spaced)) return label
190 if (tokens.length <= 1 && id.tight.length >= 6 && f.tight.includes(id.tight)) return label
191 }
192 }
193 }
194
195 // Patterns that identify a person even when no case is open, checked per value.
196 for (const f of folded) {
197 if (CN_RESIDENT_ID.test(f.scrubbed)) return 'what looks like a Chinese resident ID number'
198 if (CN_MOBILE.test(f.scrubbed)) return 'what looks like a mobile phone number'
199 if (emailIn(f.spaced)) return 'an email address'
200 }
201 return undefined
202}
203
204/** An email address in `text` that is not a host login (ssh user@host) or a git remote. */
205function emailIn(text: string): boolean {
206 // anchored at each '@' and bounded on both sides: a 200 kB sequence without one costs nothing
207 for (let at = text.indexOf('@'); at >= 0; at = text.indexOf('@', at + 1)) {
208 const left = /[a-z0-9._%+-]{1,64}$/.exec(text.slice(Math.max(0, at - 64), at))
209 const right = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,24}/.exec(text.slice(at + 1, at + 256))
210 if (!left || !right) continue
211 const addr = `${left[0]}@${right[0]}`
212 const m = { index: at - left[0].length }
213 if (/^git@/.test(addr) || /@(?:github|gitlab|bitbucket)\.(?:com|org)$/.test(addr)) continue
214 const before = text.slice(Math.max(0, m.index - 400), m.index)
215 if (/\b(?:ssh|scp|sftp|rsync|mosh|ssh-copy-id|autossh)\b[^;|&]*$/.test(before)) continue
216 return true
217 }
218 return false
219}
220
221function wordIn(word: string, text: string): boolean {
222 return new RegExp(`(?:^|[^a-z0-9])${escapeRe(word)}(?:[^a-z0-9]|$)`).test(text)
223}
224
225/** A bare local path (a case folder, a records file): its text never leaves the machine by itself. */
226export function isLocalPath(value: string): boolean {
227 const v = value.trim()
228 if (!v || /\s/.test(v) || v.includes('://') || /[?=&#]/.test(v)) return false
229 return /^(?:~|\$HOME|\.{1,2})?\//.test(v) || /^[A-Za-z]:\\/.test(v)
230}
231
232// ---------------------------------------------------------------- shell commands
233
234const NETWORK_TOOL =
235 /\b(?:curl|wget|nc|ncat|socat|telnet|scp|sftp|rsync|ssh|mosh|ftp|lftp|gh|glab|aws|gsutil|azcopy|rclone|mail|mailx|sendmail|mutt|osascript|dig|nslookup|host|s2f|httpie|http|xh|aria2c)\b|\bgit\s+(?:push|clone|fetch|pull|remote|ls-remote)\b|\b(?:npm|pnpm|yarn)\s+(?:publish|install|i|add)\b|\bnpx\b|\bpip3?\s+install\b|https?:\/\/|\/dev\/(?:tcp|udp)\/|\bopen\b[^|;&]*\b(?:mailto|https?):|\bdocker\s+(?:run|push|exec)\b|\b(?:Invoke-WebRequest|Invoke-RestMethod|iwr|irm)\b/i
236const SCRIPT_RUN = /\bpython3?(?:\.\d+)?\b|\bnode\b|\bdeno\b|\bbun\b|\bruby\b|\bperl\b|\bphp\b|\blua\b|\bjulia\b|\bjava\b|\bRscript\b|\bR\s+(?:-e|--vanilla|-f)\b|\buvx?\b|\bpipx\s+run\b|\b(?:pwsh|powershell)\b/
237const ZEBRA_CLI = /(?:^|[\s;&|(/])zebra(?![\w-])/
238// `zebra case …` reads and writes the local case only (its HPO checks send ids, never text),
239// so it stays usable while the gate is closed and its arguments are not outbound content.
240// Only when those are the leading words of the segment, as the shell will read them.
241function isZebraLocal(segment: string): boolean {
242 const w = shellWords(segment)
243 let i = 0
244 while (i < w.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[i] as string)) i++ // VAR=value prefixes
245 if (/(?:^|\/)python3?(?:\.\d+)?$/.test(w[i] ?? '')) i++
246 if (!/(?:^|\/)zebra$/.test(w[i] ?? '')) return false
247 i++
248 while (i < w.length) {
249 const a = w[i] as string
250 if (a === '--json') i++
251 else if (a === '--case') i += 2
252 else if (a.startsWith('--case=')) i++
253 else break
254 }
255 // `case <action>` reads and writes the case; `case recheck` asks public databases again.
256 // No readable action (`zebra case $(…)`) is not local. `report export` writes files here only.
257 const action = w[i + 1] ?? ''
258 if (w[i] === 'case') return /^[a-z][a-z-]*$/.test(action) && action !== 'recheck'
259 return w[i] === 'report' && action === 'export'
260}
261
262/** The words of a zebra invocation in this segment, from the subcommand on; null when it is not zebra. */
263function zebraWords(segment: string): string[] | null {
264 const w = shellWords(segment)
265 const i = w.findIndex(a => /(?:^|\/)zebra$/.test(a))
266 return i < 0 ? null : w.slice(i + 1)
267}
268// flags whose values stay on this machine in any zebra command (sample names, file paths)
269const ZEBRA_LOCAL_FLAGS = new Set(['--case', '--proband', '--mother', '--father', '--sibling', '--out', '--genes',
270 '--ped', '--csv', '--workspace', '--vcf'])
271
272/** Split a command into the pieces that run on their own, so one exempt piece cannot cover the rest. */
273export function shellSegments(command: string): string[] {
274 const out: string[] = []
275 let depth = 0
276 let current = ''
277 let quote: '"' | "'" | null = null
278 for (let i = 0; i < command.length; i++) {
279 const c = command[i] as string
280 const two = command.slice(i, i + 2)
281 // inside quotes, ; & | and newlines are text (curl "…?a=1&b=2"), not separators
282 if (quote) {
283 if (c === '\\' && quote === '"' && i + 1 < command.length) {
284 current += c + command[++i]
285 continue
286 }
287 if (c === quote) quote = null
288 current += c
289 continue
290 }
291 if (c === '"' || c === "'") {
292 quote = c
293 current += c
294 continue
295 }
296 if (c === '`') {
297 // a backtick substitution runs on its own
298 if (current.trim()) out.push(current)
299 current = ''
300 continue
301 }
302 if (two === '$(' || two === '<(' || two === '>(') {
303 depth++
304 i++
305 if (current.trim()) out.push(current)
306 current = ''
307 continue
308 }
309 if (c === ')' && depth > 0) {
310 depth--
311 if (current.trim()) out.push(current)
312 current = ''
313 continue
314 }
315 if (two === '&&' || two === '||') {
316 i++
317 if (current.trim()) out.push(current)
318 current = ''
319 continue
320 }
321 if (c === ';' || c === '|' || c === '&' || c === '\n') {
322 if (current.trim()) out.push(current)
323 current = ''
324 continue
325 }
326 current += c
327 }
328 if (current.trim()) out.push(current)
329 return out
330}
331
332/**
333 * A segment as the shell will read it: quotes removed and words rejoined, so `c'u'rl` and
334 * `"cu""rl"` read as curl, and the text of `bash -c "…"` or `eval "…"` is seen as the command it is.
335 */
336function asRun(segment: string): string {
337 return shellWords(segment).join(' ')
338}
339
340function outboundSegment(segment: string): boolean {
341 if (isZebraLocal(segment)) return false // the local case only
342 for (const text of [segment, asRun(segment)]) {
343 if (NETWORK_TOOL.test(text)) return true
344 if (ZEBRA_CLI.test(text)) return true // zebra's other commands query public databases
345 if (SCRIPT_RUN.test(text)) return true // a script can reach the network; its source is not read here
346 }
347 // `bash -c "…"`, `sh -c`, `eval`: judge the inner command the same way
348 const w = shellWords(segment)
349 const inner = /^(?:ba|z|da|k)?sh$|^eval$/.test(w[0] ?? '') ? w.slice(1).filter(a => a !== '-c').join(' ') : ''
350 return inner !== '' && inner !== segment && isOutboundShell(inner)
351}
352
353/** True when a shell command may send data off the machine. Judged per segment. */
354export function isOutboundShell(command: string): boolean {
355 return shellSegments(command).some(outboundSegment)
356}
357
358/**
359 * The text of a shell command that may reach the network. Once any part of it does, every part is
360 * scanned — a pipe (echo … | curl), a heredoc, a variable set before the call or a script's source
361 * all carry text to the outbound piece — except what demonstrably stays here: segments that only
362 * touch the local case, the values of zebra's local-only flags (sample names, case paths) inside
363 * zebra commands, local paths, and the folder given to `cd`.
364 */
365export function outboundText(command: string): string {
366 const segments = shellSegments(command)
367 if (!segments.some(outboundSegment)) return ''
368 const kept: string[] = []
369 for (const segment of segments) {
370 if (isZebraLocal(segment)) continue
371 const words = shellWords(segment)
372 const zebra = zebraWords(segment) !== null
373 if (words[0] === 'cd') continue
374 for (let i = 0; i < words.length; i++) {
375 const w = words[i] as string
376 if (zebra && ZEBRA_LOCAL_FLAGS.has(w)) {
377 i++
378 continue
379 }
380 if (zebra && /^--[\w-]+=/.test(w) && ZEBRA_LOCAL_FLAGS.has(w.split('=')[0] as string)) continue
381 if (isLocalPath(w)) continue
382 kept.push(w)
383 }
384 }
385 return kept.join(' ')
386}
387
388const GENOME_FILE = /\.(?:g?vcf(?:\.gz|\.bgz)?|bcf|bam|cram|sam|fastq(?:\.gz)?|fq(?:\.gz)?|bed(?:\.gz)?)(?![a-z0-9])/i
389const UPLOADER =
390 /\b(?:scp|sftp|rclone|gsutil|azcopy)\b|\baws\s+s3\b|\bgh\s+(?:gist|release)\s+\w+|\brsync\b[^|;&]*\S+:\S*|\bcurl\b[^|;&]*(?:\s-T\b|--upload-file|\s-F\b|--form|--data-binary|\s-d\s*@|--data(?:-raw|-urlencode)?\s*@)|\bwget\b[^|;&]*--post-file|\b(?:nc|ncat|socat)\b|\bssh\b[^|;&]*\bcat\s*>|\bmail(?:x)?\b|\bpython3?\s+-m\s+http\.server\b/i
391
392// folders a desktop client uploads on its own: copying a file there sends it
393const SYNC_FOLDER = /(?:^|[\s'"=])(?:~|\$HOME|\/Users\/[^/\s]+|\/home\/[^/\s]+)?\/?(?:Dropbox|Google Drive|GoogleDrive|My Drive|OneDrive[^/\s]*|Library\/Mobile Documents|Library\/CloudStorage|iCloud Drive|Nutstore[^/\s]*|坚果云[^/\s]*|BaiduNetdisk[^/\s]*|百度网盘[^/\s]*|WeDrive[^/\s]*|Box Sync|pCloud Drive|MEGA)(?:\/|['"\s]|$)/i
394const COPY_INTO = /\b(?:cp|mv|rsync|ditto|ln|install|tee)\b/
395
396/** True when a zebra command would send a whole VCF's variant list to a web service (triage's MyVariant prefilter). */
397export function sendsVariantList(command: string): boolean {
398 // argparse accepts any unique prefix of a long option: --pref myvariant, --pre=myvariant
399 const isFlag = (a: string) => a.length >= 5 && '--prefilter'.startsWith(a)
400 return shellSegments(command).some(seg => {
401 const rest = zebraWords(seg)
402 if (!rest) return false
403 return rest.some((a, i) => {
404 const [flag, value] = a.includes('=') ? [a.slice(0, a.indexOf('=')), a.slice(a.indexOf('=') + 1)] : [a, rest[i + 1]]
405 return isFlag(flag) && value === 'myvariant'
406 })
407 })
408}
409
410/** True when a command looks like it sends a raw genome file to another machine. */
411export function uploadsGenome(command: string): boolean {
412 if (GENOME_FILE.test(command) && UPLOADER.test(command)) return true
413 // copying genome data into a synced folder uploads it as surely as scp
414 if (GENOME_FILE.test(command) && COPY_INTO.test(command) && SYNC_FOLDER.test(command)) return true
415 // an archive that holds genome data, then sent: zip -r g.zip genome/ && curl -F f=@g.zip …
416 const archive = /\b(?:zip|7z|7za|tar|gzip|bgzip|xz|zstd)\b[^|;&]*?([\w.-]+\.(?:zip|7z|tar(?:\.gz|\.bz2|\.xz|\.zst)?|tgz|gz))\b/i.exec(command)
417 if (archive && (GENOME_FILE.test(command) || /\b(?:genome|exome|wes|wgs|vcf|bam|cram|fastq)s?\b/i.test(command)) && UPLOADER.test(command)) return true
418 const segments = shellSegments(command)
419 if (segments.some(s => GENOME_FILE.test(s)) && segments.some(s => UPLOADER.test(s))) return true
420 // a whole directory going out (scp -r genome_dir host:, tar czf - dir | ssh)
421 return /\bscp\s+-r\b|\btar\b[^|;&]*\bc/.test(command) && /\b(?:ssh|scp|rclone)\b|\baws\s+s3\b/.test(command)
422}
423
424const FILE_ARGS: RegExp[] = [
425 /--(?:upload-file|post-file)[=\s]+("[^"]+"|'[^']+'|\S+)/gi,
426 /\s-T\s*("[^"]+"|'[^']+'|\S+)/gi,
427 /(?:--data-binary|--data-urlencode|--data-raw|--data|-d|-F|--form)\s*(?:[^@\s]*@)("[^"]+"|'[^']+'|[^\s;|&]+)/gi,
428 /<\s*("[^"]+"|'[^']+'|[^\s;|&]+)/g,
429]
430// commands whose non-option, non-remote arguments are local files being sent
431const COPY_TOOLS = /^(?:scp|sftp|rsync|rclone|gsutil|azcopy)$/
432const REMOTE_ARG = /^(?:[\w.-]+@)?[\w.-]+:(?!\/\/)|^(?:s3|gs|az|https?):\/\/|^[\w-]+:$/
433
434/**
435 * The local file paths a command would send somewhere, relative paths resolved against a
436 * preceding `cd` in the same command, so the caller can check whether they lie in the case folder.
437 */
438export function uploadedPaths(command: string): string[] {
439 const found = new Set<string>()
440 // a file copied into a synced folder is uploaded by the desktop client
441 for (const segment of shellSegments(command)) {
442 const w = shellWords(segment)
443 if (COPY_INTO.test(w[0] ?? '') && w.length >= 3 && SYNC_FOLDER.test(` ${w[w.length - 1]}`)) {
444 for (const a of w.slice(1, -1)) if (!a.startsWith('-')) found.add(a)
445 }
446 }
447 let cwd = ''
448 const segments = shellSegments(command)
449 const join = (p: string) => (cwd && !/^(?:\/|~|\$HOME)/.test(p) ? `${cwd.replace(/\/$/, '')}/${p}` : p)
450 // files read on the left of a pipe that ends in an uploader reading stdin
451 const pipeReaders: string[] = []
452 const archived: string[] = []
453 const scripted = SCRIPT_RUN.test(command)
454 for (const segment of segments) {
455 const words = shellWords(segment)
456 const head = words[0] ?? ''
457 if (head === 'cd' && words[1]) {
458 cwd = words[1]
459 continue
460 }
461 if (/^(?:cat|zcat|gzip|bgzip|tar|base64|xxd|head|tail)$/.test(head)) {
462 for (const w of words.slice(1)) if (!w.startsWith('-')) pipeReaders.push(join(w))
463 }
464 for (const re of FILE_ARGS) {
465 re.lastIndex = 0
466 let m: RegExpExecArray | null
467 while ((m = re.exec(segment)) !== null) {
468 const capture = m[1] ?? ''
469 const parts = /^['"]/.test(capture) ? [capture] : capture.split(/\s+/)
470 for (const part of parts) {
471 const clean = part.replace(/^['"]|['"]$/g, '')
472 if (clean === '-') pipeReaders.forEach(r => found.add(r))
473 else if (clean && !/^https?:/.test(clean)) found.add(join(clean))
474 }
475 }
476 }
477 if (/^(?:zip|7z|7za|tar)$/.test(head)) {
478 // what goes into an archive leaves with it: zip -r out.zip ~/cases/x, tar czf out.tgz -C ~/cases x
479 let base = ''
480 for (let k = 1; k < words.length; k++) {
481 const a = words[k] as string
482 if (a === '-C' && words[k + 1]) {
483 base = words[++k] as string
484 continue
485 }
486 if (a.startsWith('-') || /\.(?:zip|7z|tar|tgz|gz|bz2|xz|zst)$/.test(a)) continue
487 archived.push(base && !/^(?:\/|~|\$HOME)/.test(a) ? `${base.replace(/\/$/, '')}/${a}` : join(a))
488 }
489 }
490 if (scripted && zebraWords(segment) === null) {
491 // a script that opens a file from the case folder sends its contents: python -c "…open('…')…",
492 // or a heredoc body; only paths that start a word (not the tail of records/x or host:/tmp)
493 for (const m of segment.replace(/https?:\/\/\S+/g, ' ').matchAll(/(?<=^|[\s'"=(,])(?:~|\$HOME)?\/[^\s'"<>|;&(),]{2,}/g)) found.add(m[0])
494 }
495 if (head === 'gh' && words[1] === 'release' && words[2] === 'upload') {
496 for (const w of words.slice(4)) if (!w.startsWith('-')) found.add(join(w))
497 }
498 if (COPY_TOOLS.test(head) || (head === 'aws' && words[1] === 's3') || (head === 'gh' && words[1] === 'gist')) {
499 const args = words.slice(head === 'aws' || head === 'gh' ? 3 : 1)
500 for (const w of args) {
501 if (w.startsWith('-') || REMOTE_ARG.test(w)) continue
502 found.add(join(w))
503 }
504 }
505 }
506 // the archive's sources count once anything in the command sends something somewhere
507 if (archived.length && (UPLOADER.test(command) || segments.some(outboundSegment))) archived.forEach(a => found.add(a))
508 return [...found]
509}
510
511/** A tiny shell-words splitter, so `/zebra new "my case" A title` works. */
512export function shellWords(text: string): string[] {
513 const out: string[] = []
514 let current = ''
515 let quote: '"' | "'" | null = null
516 let started = false
517 for (let i = 0; i < text.length; i++) {
518 const c = text[i] as string
519 if (quote) {
520 if (c === quote) quote = null
521 else if (c === '\\' && quote === '"' && i + 1 < text.length) current += text[++i]
522 else current += c
523 continue
524 }
525 if (c === '"' || c === "'") {
526 quote = c as '"' | "'"
527 started = true
528 continue
529 }
530 if (c === '\\' && i + 1 < text.length) {
531 current += text[++i]
532 started = true
533 continue
534 }
535 if (/\s/.test(c)) {
536 if (current || started) out.push(current)
537 current = ''
538 started = false
539 continue
540 }
541 current += c
542 started = true
543 }
544 if (current || started) out.push(current)
545 return out
546}
547hooks/ui-sprite.ts 163 lines1// The galloping zebra drawn in the band above the prompt while zebra-mod queries a database.
2// Eleven positions of one gallop stride, traced from Eadweard Muybridge's "The Horse in Motion" (1878,
3// public domain): rider removed, frames aligned on the back (croup to withers) so the body stays
4// level while the legs move, given zebra stripes, drawn in braille (2 x 4 dots to a terminal cell). Regenerate with
5// `python3 tools/zebra_sprite/build.py`. One colour, the terminal's own text colour, so the zebra reads
6// on light and dark themes alike; the ground is a dim dotted line that runs backwards.
7// Pure functions of (frame, scroll): no state here.
8
9const FRAMES: readonly (readonly string[])[] = [
10 [
11 '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⢠⡴⣟⣶⣤⡀⠀',
12 '⠀⠀⠀⠀⢀⣀⢠⣤⡖⣤⢠⣤⡆⣶⣾⡟⣵⡏⠉⠉⠁⠀',
13 '⠀⠈⠷⠾⠛⠁⢻⡟⣼⣿⢸⣿⡇⣿⢏⣾⠟⠀⠀⠀⠀⠀',
14 '⠀⠀⠀⠀⠀⡟⢟⠘⠋⠈⠘⠛⠃⢻⡿⠟⠀⠀⠀⠀⠀⠀',
15 '⠀⠀⠀⠀⠴⠃⠀⠱⠀⠀⠒⠦⠤⠟⠀⠀⠀⠀⠀⠀⠀⠀',
16 '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
17 ],
18 [
19 '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⣾⣳⣦⣀⠀⠀',
20 '⠀⠀⠀⠀⠀⠀⢀⣤⣶⣤⢠⣤⡄⣦⣿⡟⣽⠉⠉⠙⠀⠀',
21 '⠀⢴⣤⡶⠟⠁⢿⡟⣼⣿⢸⣿⡇⣿⢏⣾⠃⠀⠀⠀⠀⠀',
22 '⠀⠀⠉⠀⠀⠀⢀⣼⣿⠏⠘⠛⠃⣧⢿⡋⠀⠀⠀⠀⠀⠀',
23 '⠀⠀⠀⠀⠀⠀⠀⠈⢻⢓⣢⣀⡼⠳⠔⠛⠀⠀⠀⠀⠀⠀',
24 '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
25 ],
26 [
27 '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣤⡿⢶⣤⡀⠀⠀',
28 '⠀⠀⠀⠀⠀⠀⢀⣴⣶⣦⢠⣤⡄⣦⣿⡟⡵⠉⠉⠋⠀⠀',
29 '⠀⢠⣤⠾⠛⠃⢿⡟⣼⣿⢸⣿⡇⣿⢏⣾⠃⠀⠀⠀⠀⠀',
30 '⠀⠀⠁⠀⠀⠀⠀⢨⣿⣿⠘⠛⠇⣧⠿⠟⣄⠀⠀⠀⠀⠀',
31 '⠀⠀⠀⠀⠀⠀⠀⠈⠉⠙⠛⠦⡤⠏⠰⠖⠁⠀⠀⠀⠀⠀',
32 '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
33 ],
34 [
35 '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⡷⡶⣤⡀⠀⠀',
36 '⠀⠀⠀⠀⠀⠀⢀⣤⣤⣤⢠⣤⡄⣦⣿⡟⣵⠛⠛⠿⠂⠀',
37 '⠀⢀⣴⠿⠻⠃⢿⡟⣼⣿⢸⣿⡇⣿⢏⣾⡇⠀⠀⠀⠀⠀',
38 '⠀⠙⠁⠀⠀⠀⠈⠘⢿⣿⠸⠿⠇⠧⣿⣟⠔⢲⠀⠀⠀⠀',
39 '⠀⠀⠀⠀⠀⠀⠀⠀⠘⢿⡓⠒⠦⠜⠶⠚⠁⠸⠀⠀⠀⠀',
40 '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢙⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
41 ],
42 [
43 '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣄⡶⡶⣦⡀⠀⠀',
44 '⠀⠀⠀⢀⣀⣀⢀⣤⣤⣤⢠⣤⡄⣦⣿⡟⣵⠋⠛⠻⠂⠀',
45 '⠀⢠⣾⠟⠛⠁⣿⡟⣼⣿⢸⣿⡇⣿⢏⣾⡇⠀⠀⠀⠀⠀',
46 '⠀⠋⠁⠀⠀⠀⠈⣼⠟⢿⡘⠛⠃⠧⠟⠛⢼⠿⢄⡀⠀⠀',
47 '⠀⠀⠀⠀⠀⠀⠰⡏⠀⠈⠙⠢⢤⡀⠀⠘⠋⠀⠀⠙⠀⠀',
48 '⠀⠀⠀⠀⠀⠀⠀⠳⠤⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
49 ],
50 [
51 '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡦⣣⡷⣄⠀⠀',
52 '⠀⠀⠀⣀⣀⣀⢀⣤⡄⣤⢠⣤⡄⣶⣾⡟⣵⠟⠙⠻⠷⠀',
53 '⠀⣴⠟⠋⠉⢡⣿⡟⣼⣿⢸⣿⡇⣿⣏⣾⡏⠀⠀⠀⠀⠀',
54 '⠀⠁⠀⠀⢀⣠⠟⠘⣿⠉⠘⠛⠃⠛⠛⠛⠸⢿⡇⠀⠀⠀',
55 '⠀⠀⠀⢀⡞⠁⠀⠀⠻⡀⠀⠀⠀⠀⠀⠀⠀⠐⠋⠓⠦⠄',
56 '⠀⠀⠀⠸⠷⠀⠀⠀⠀⠹⠶⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
57 ],
58 [
59 '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⡰⣠⡶⣄⠀⠀',
60 '⠀⠀⠀⠀⣀⣀⢠⣤⣤⣤⢀⣤⡄⣶⣾⡟⣵⠟⠙⠻⠷⠀',
61 '⠤⠾⠛⠋⠉⢠⣿⡟⣼⣿⢸⣿⡇⣿⢏⣾⡏⠀⠀⠀⠀⠀',
62 '⠀⠀⠀⣠⡠⣾⡟⠈⠉⠉⠘⠛⠃⠛⠛⠛⣜⠛⠒⢤⡀⠀',
63 '⠠⠤⠞⠁⢰⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢧⡀⠀⠙⠂',
64 '⠀⠀⠀⠀⠘⠓⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠑⠂⠀⠀',
65 ],
66 [
67 '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡴⣣⣶⣄⠀⠀',
68 '⠀⠀⠀⠀⣀⡤⢠⣤⣤⣤⢠⣤⡄⣶⣾⡟⣵⡿⠛⠺⠷⠀',
69 '⠛⠻⠟⠛⠉⢠⣿⡟⣼⣿⢸⣿⡇⣿⣏⣾⡿⠀⠀⠀⠀⠀',
70 '⠀⢀⣠⠴⣢⡿⠛⠈⠀⠉⠘⠛⠃⠛⠻⡟⠘⠷⣤⡀⠀⠀',
71 '⠉⠉⢀⡔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣧⠀⠀⠀⠈⠉⠋',
72 '⠀⠀⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⠂⠀⠀⠀⠀⠀',
73 ],
74 [
75 '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⢠⡰⣲⢶⣄⠀⠀',
76 '⢀⢀⠀⢀⣠⣤⢠⣤⣤⣤⢠⣤⡄⣶⣾⡟⣵⡿⠛⠻⠷⠀',
77 '⠈⠛⠛⠛⠉⢠⣿⡟⣼⣿⢸⣿⡇⣿⢏⣾⡟⠁⠀⠀⠀⠀',
78 '⠀⠀⢀⣼⡧⠟⠛⠈⠁⠉⠘⠛⠇⣿⠿⠛⢴⡄⠀⠀⠀⠀',
79 '⠀⠛⠋⠁⠀⠀⠀⠀⠀⠀⠀⠀⡰⠋⠀⠀⠀⠙⢢⡀⠀⠀',
80 '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⠃⠀⠀⠀⠀⠀⠀⠉⠋⠀',
81 ],
82 [
83 '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⢀⡠⣞⣶⣄⠀⠀',
84 '⠀⢀⡀⢀⣀⣤⢠⣴⡖⣤⢠⣤⡆⣶⣾⡟⣵⡟⠙⠛⠃⠀',
85 '⠀⠀⠻⠟⠋⠁⣿⡟⣼⣿⢸⣿⡇⣿⢏⣾⠏⠀⠀⠀⠀⠀',
86 '⠀⠀⠀⢀⠞⢻⠛⠘⠉⠈⠘⠛⠃⣿⣿⠋⠀⠀⠀⠀⠀⠀',
87 '⠀⠀⠈⠉⠀⠰⠁⠀⠀⠀⠀⠀⠼⠁⠿⠀⠀⠀⠀⠀⠀⠀',
88 '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
89 ],
90 [
91 '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⢠⡴⣞⣶⣄⠀⠀',
92 '⠀⠀⡀⣀⣀⣀⢀⣤⡄⣤⢠⣤⡄⣶⣾⡟⣵⡟⠙⠛⠃⠀',
93 '⠀⠘⠿⠿⠟⠃⢹⡟⣼⣿⢸⣿⡇⣿⢏⣾⡟⠀⠀⠀⠀⠀',
94 '⠀⠀⠀⠀⠀⠐⢞⣼⠟⠉⠘⠛⠃⣿⡿⠟⠀⠀⠀⠀⠀⠀',
95 '⠀⠀⠀⠀⠀⠀⠈⡏⠙⢢⠀⣀⠿⠿⠇⠀⠀⠀⠀⠀⠀⠀',
96 '⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀',
97 ],
98]
99
100const DEFAULT = 0x01000000 // the terminal's own colour
101const GROUND = 0x6e6c64
102const SPRITE_COLS = 22
103const STANDING = 5 // the pose shown still (the welcome card, the shield)
104
105/** Columns and rows of the zebra's Raster: the sprite with a little ground before and behind it. */
106export const RASTER_COLUMNS = 28
107export const RASTER_ROWS = 6
108export const FRAME_COUNT = FRAMES.length
109const OFFSET = 3
110
111/** The braille dot bits of the cell at (col, row): the zebra's, then the ground's in the lowest dot row. */
112function cell(frame: number, scroll: number, col: number, row: number, isStanding: boolean): { horse: number; ground: number } {
113 const rows = FRAMES[isStanding ? STANDING : frame % FRAMES.length] as readonly string[]
114 const sc = col - OFFSET
115 const ch = sc >= 0 && sc < SPRITE_COLS ? (rows[row] as string).codePointAt(sc) ?? 0x2800 : 0x2800
116 const horse = ch - 0x2800
117 let ground = 0
118 if (row === RASTER_ROWS - 1) {
119 // dot 7 (left column, bottom) and dot 8 (right column, bottom), one in every six dots
120 const s = isStanding ? 0 : scroll
121 if ((col * 2 + s) % 6 === 0) ground |= 0x40
122 if ((col * 2 + 1 + s) % 6 === 0) ground |= 0x80
123 }
124 return { horse, ground }
125}
126
127/** The Raster `cells` of one frame: braille dots in the terminal's colour, the ground dimmed. */
128export function zebraCells(frame: number, scroll: number, isStanding = false): string {
129 const words = new Uint32Array(RASTER_COLUMNS * RASTER_ROWS * 3)
130 let i = 0
131 for (let row = 0; row < RASTER_ROWS; row++) {
132 for (let col = 0; col < RASTER_COLUMNS; col++) {
133 const { horse, ground } = cell(frame, scroll, col, row, isStanding)
134 const bits = horse | ground
135 words[i++] = bits ? 0x2800 + bits : 0x20
136 words[i++] = horse ? DEFAULT : ground ? GROUND : DEFAULT
137 words[i++] = DEFAULT
138 }
139 }
140 return base64(new Uint8Array(words.buffer))
141}
142
143const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
144
145/** Standard padded base64 (the module runs with no Node and no DOM, so no Buffer or btoa to lean on). */
146export function base64(bytes: Uint8Array): string {
147 let out = ''
148 let i = 0
149 for (; i + 2 < bytes.length; i += 3) {
150 const n = ((bytes[i] as number) << 16) | ((bytes[i + 1] as number) << 8) | (bytes[i + 2] as number)
151 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
152 }
153 const rest = bytes.length - i
154 if (rest === 1) {
155 const n = (bytes[i] as number) << 16
156 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
157 } else if (rest === 2) {
158 const n = ((bytes[i] as number) << 16) | ((bytes[i + 1] as number) << 8)
159 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
160 }
161 return out
162}
163types/index.d.ts 72 lines1export type BoardPhenotype = { id: string; label: string | null; status: string }
2
3export type BoardVariant = {
4 id: string
5 gene: string | null
6 label: string | null
7 zygosity: string | null
8 classification: string | null
9}
10
11export type BoardHypothesis = { id: string; disease: string; status: string; support: number; against: number }
12
13export type BoardLead = { id: string; name: string; kind: string; status: string | null }
14
15export type BoardTest = { id: string; type: string; date: string | null; result: string | null }
16
17export type BoardRelative = { id: string; relation: string | null; affected: boolean | string | null; genotype: string | null }
18
19export type Board = {
20 path: string
21 id: string
22 title: string
23 role: string
24 language: string | null
25 updated_at: string
26 phenotypes: BoardPhenotype[]
27 variants: BoardVariant[]
28 hypotheses: BoardHypothesis[]
29 therapy_leads: BoardLead[]
30 questions: string[]
31 tests?: BoardTest[]
32 family?: BoardRelative[]
33 evidence_count: number
34 identifiers: number
35}
36
37/** A zebra tool call in flight, for the spinner, the band and the row. */
38export type ZebraRun = { id: string; tool: string; queryZh: string; queryEn: string; sources: string[]; startedAt: number }
39
40/** What zebra-mod did in the current turn: the line left at its end. */
41export type ZebraTurn = {
42 calls: number
43 failed: number
44 blocked: number
45 sources: string[]
46 cached: number
47 evidence: number
48 firstEvidence: number | null
49 lastEvidence: number | null
50}
51
52export type Ready = { python: string | null; version: string | null; error: string | null }
53
54declare module 'claude-code' {
55 interface PluginState {
56 'zebra-mod': {
57 board: Board | null
58 casePath: string | null
59 guard: string[]
60 ready: Ready | null
61 trusted: string[]
62 sessionIds: string[]
63 running: ZebraRun[]
64 turn: ZebraTurn
65 blockedAt: number | null
66 lang: string | null
67 blockedWhy: string
68 onboarding: boolean
69 }
70 }
71}
72