fh-library 0.2.7 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # FH Library — Change Log
2
2
 
3
+ ### v0.2.9 — 2026-11-08
4
+
5
+ * TUI v80: a `search` row — cached registry searches, hits and misses from the servers' counters; the decision-model row is labelled `decider`; `/status` carries `searches`.
6
+ * ClinicalTrials tool descriptions and server instructions one statement each.
7
+
8
+ ### v0.2.8 — 2026-11-08
9
+
10
+ * **`library_clinicaltrials_get_study_record(nct)` and `library_clinicaltrials_get_study_results(nct, max_chars?)`:** thin answers over the stored `nct:` entry (added first when missing) — the review's detail of one trial, and its posted results as Markdown with the outcome titles for `library_read`. With the search, the three tools map one to one onto what the AI4L models used of the ClinicalTrials.gov MCP server (#28).
11
+ * `library_verify` cache key folds the claim's typography (#33): case, whitespace and non-breaking spaces, curly quotes, en/em dashes, "+/-" for "±", a trailing period — never a word. The claim is judged and stored as written.
12
+ * `library_verify`: a claim that opens with an odds ratio ("OR 2.15 (95% CI 1.06 to 4.34) for …") is a statement, not a fragment starting with the conjunction "or" (seen in an AI4L audit, pmid:25358826).
13
+ * **Smaller answers (#32):** every tool answers compact JSON (no indentation); `library_add`, `library_status`, `library_read` and `library_clinicaltrials_search_studies` leave out null values, empty lists and the entry's paths and file names (`entry_dir`, `library_dir`, `files`). `library_verify` keeps its nulls (`score`, `reason` null carry meaning). A status answer is about 30% smaller, a 20-row trial search about 25%.
14
+ * **`library_fetch(…, title_only)`:** the title line alone (`Contents of <url>:` and `# Title`); the page is fetched and stored in full as usual. For verifying a link at a few tokens instead of a page (#32).
15
+ * **`library_clinicaltrials_search_studies(query | intervention | condition, status?, sort?, max_results?)`:** a ClinicalTrials.gov search (API v2, no key) — total count and rows as arrays in `columns` order (nct, title, status with why stopped, phase, enrollment, start, primary_completion, primary_outcome, conditions, interventions, has_results — the names once, not per row); `status` ongoing / completed / all; `sort` relevance / newest / largest / completion. Cached under `search/<2>/<4>/<key>.json` for 7 days. With `library_add("nct:…")` and `library_read` for the record and posted results, this covers what the AI4L models asked the ClinicalTrials.gov MCP server for (#28). `library_info`'s `cache` and the `.servers/` files count `search` hits and misses.
16
+
3
17
  ### v0.2.7 — 2026-11-08
4
18
 
5
19
  * **Verdicts are cached** (#26): `library_verify` keeps each judgement as `verify/<2>/<4>/<key>.json` — the file system as the database, laid out like `url/` — and answers the same claim on the same stored text from disk for `FH_LIBRARY_REFRESH_AFTER_DAYS`; a new full text, model or threshold is judged afresh, errors are never kept. The result is unchanged; the log line says `cached`. One atomic file per judgement: several servers on one library need no lock.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- ![Version 0.1.3](https://img.shields.io/badge/Version-0.2.7-green.svg)
1
+ ![Version 0.1.3](https://img.shields.io/badge/Version-0.2.11-green.svg)
2
2
  ![License MIT](https://img.shields.io/badge/License-MIT-yellow.svg)
3
3
  [![Forever Healthy](https://img.shields.io/badge/(c)_2026-Forever_Healthy-573D7D.svg)](https://forever-healthy.org)
4
4
  [![npm](https://img.shields.io/badge/npm-fh--library-cb3837.svg)](https://www.npmjs.com/package/fh-library)
@@ -23,11 +23,16 @@ A [Model Context Protocol](https://modelcontextprotocol.io) server that builds a
23
23
  * `library_info(ids?)` → the whole library, everything the TUI shows: the library directory and host, the decision model with its endpoint and whether it answers right now (`verify`), this server's cache hits and misses since it started (`cache`), the entries and their size on disk (`size`), per entry type (`types`: pmid, doi, dailymed, nct) the entries with their levels — `full_text`, `abstract`, `title` — and retractions; entry counts by type and level, counts of retracted and not-citable entries and of entries with errata, expressions of concern and updates, the configured tiers, library directory and host; `ids: true` also lists every entry ID
24
24
  * `library_verify(claim, id)` → is one finding of a review stated by one paper it cites? `claim` is the finding alone, verbatim, without its citations; `id` one paper (`pmid:`, or any `doi:`), drug label or trial record the review cites for it — a finding cited to three papers is three calls with the same claim; a claim that names a source or carries citations is refused. The server reads the paper (full text, else abstract) and asks a decision model; nothing of the paper enters the model's context. `verdict` **pass** (`score` the probability), **fail** (the full text does not state it; or only the title is in the library — `reason` says so), **unverifiable** (only the abstract is in the library and does not state it — `reason`) or **error**; `basis` full_text, abstract or title. See "Verifying claims" below.
25
25
  * `library_read(id, section?, query?, max_chars?, max_hits?)` → one small piece of a stored paper's Markdown, never the whole paper: without `section`/`query` the **outline** (headings with word counts, and whether the full text or only the abstract is stored; a heading the paper uses several times is listed as `Participants [3/12]`); `section` → that section's **own text** (heading matched as case-insensitive substring, capped at `max_chars`, default 6000, with `returned_words` against `words` when cut) and, for a parent heading, its `subsections` with word counts to pick from — a duplicated heading named bare lists the candidates instead; `query` — two or three distinctive terms the paper itself would use (a measure and a unit, an outcome and a group, a compound name), not the claim — → **passages** holding every term, each a window of at most 1000 chars around the matches (`clipped: true` when cut; a table row with its header row), with its section, best first (at most `max_hits`, default 5); matching forgives case, accents, hyphens, Markdown markup, plural and -ed/-ing forms; when no passage holds every term the best **partial** matches come back (`partial: true`, the `matched` terms per passage, the rare term first), and `missing_terms` names the terms the text never uses in any form — those are the ones to rephrase. `section` and `query` together search inside that section. `section: "Abstract"` on a full text without one answers from the stored abstract. Every full text converted from JATS XML (PMC, Europe PMC, Elsevier) has a **Declarations** section — funding, conflicts of interest, author notes, gathered from wherever the XML keeps them — and on a text without one `section: "Declarations"` assembles the sections headed funding, conflicts, acknowledgements, ethics, contributions, so it answers who paid and who declares what on every source alike; a paper with neither states nothing. Outline first, then pick. A paper not in the library is added first, as `library_add` would (its lines in the log), then library_read; the answer carries `added: true`. Reads may run in parallel.
26
- * `library_fetch(url, max_length?, start_index?, raw?)` → a web page as Markdown, exactly as the standard MCP `fetch` tool returns it (see "Web pages"): the one tool whose document enters the model's context
26
+ * `library_clinicaltrials_search_studies(query | intervention | condition, status?, sort?, max_results?)` → a ClinicalTrials.gov search (see "Trial search"): the total count and compact rows, cached for a week
27
+ * `library_clinicaltrials_get_study_record(nct)` → one trial as a review needs it (title, status, phase, design, enrollment, dates, sponsor, conditions, interventions, primary outcomes, eligibility, sites, result publications, has_results, brief summary), from the stored record
28
+ * `library_clinicaltrials_get_study_results(nct, max_chars?)` → a trial's posted results as Markdown, capped, with the outcome measure titles for `library_read`
29
+ * `library_fetch(url, max_length?, start_index?, raw?, title_only?)` → a web page as Markdown, exactly as the standard MCP `fetch` tool returns it (see "Web pages"): the one tool whose document enters the model's context
27
30
  * `library_version()` → the running server's package name, version, host and library directory
28
31
 
29
32
  IDs follow the `type:value` form: `pmid:`, `doi:`, `dailymed:`, `nct:`; bare digits count as `pmid:`, a bare NCT number as `nct:`.
30
33
 
34
+ **Context budget.** Every answer is sized to what a model does with it in an evidence review, because every character comes out of the model's context. Answers are compact JSON; a status answer leaves out what carries nothing (null values, empty lists, the entry's paths and file names), while `library_verify` keeps its nulls, because a null `score` or `reason` means something there. A link is verified with `library_fetch(url, title_only: true)`, one line instead of a page; the page is stored in full for a later read. The ClinicalTrials.gov tools follow the same rule: `library_clinicaltrials_search_studies` returns rows as arrays under one `columns` list, so the field names are sent once, not per trial, and carries only what a review's Emerging Research item states about a trial — the rest of a record is one `library_clinicaltrials_get_study_record` call away, and the posted results one `library_clinicaltrials_get_study_results` call, capped by `max_chars`; measured against the ClinicalTrials.gov MCP server on the same calls, the search matches its compact index in size while carrying the primary outcome and interventions it lacks, the record is an eighth of its size, the results half. Searches and verdicts are cached, so a repeated audit pays no second answer. The PubMed tools to come (search, fetch) are built the same way: the parameters the live audits actually use, rows shaped for the review's sections on evidence, benefits, risks and interactions, cached.
35
+
31
36
  ### Retrieval tiers
32
37
 
33
38
  | Tier | Does | Needs |
@@ -96,7 +101,7 @@ Every full text is checked before it counts: title present, methods and results
96
101
 
97
102
  **The call.** `claim` is one finding, verbatim from the review, with markdown links reduced to their text and its citations removed: `IPSS fell 2.3 points more than placebo in men with ED`. Section labels such as `**Magnitude:**` and list bullets may stay. `id` is one paper the review cites for that finding, as `pmid:<PMID>`, already in the library (`library_add` first). One claim, one paper per call: a finding cited to three papers is three calls with the same claim text, and each answer stands on its own — a source that does not carry the finding is named by its own row, so it can be dropped without touching the others. The server never aggregates over papers. A sentence that joins two findings, each with its own citation, is two claims: the text before each citation group, without the group. A sentence without a citation is not a claim.
98
103
 
99
- **Verdicts are kept.** The answer to one claim against one stored text is written to `verify/<2>/<4>/<key>.json` — one file per judgement, laid out like `url/` — and served from there for `FH_LIBRARY_REFRESH_AFTER_DAYS` (30): an audit that runs twice over the same review pays the model once. The key hashes the claim (whitespace aside), the source ID, the model, the threshold and the stored text, so a paper that gains its full text, a changed model or a changed threshold is judged afresh; errors are never kept. The result JSON is the same either way; the log line carries `cached`. Several servers may share the library: a file appears whole or not at all, no lock.
104
+ **Verdicts are kept.** The answer to one claim against one stored text is written to `verify/<2>/<4>/<key>.json` — one file per judgement, laid out like `url/` — and served from there for `FH_LIBRARY_REFRESH_AFTER_DAYS` (30): an audit that runs twice over the same review pays the model once. The key hashes the claim with its typography folded (case, whitespace, curly quotes, dashes, "±" forms, a trailing period — never a word: a reworded claim is another statement), the source ID, the model, the threshold and the stored text, so a paper that gains its full text, a changed model or a changed threshold is judged afresh; errors are never kept. The result JSON is the same either way; the log line carries `cached`. Several servers may share the library: a file appears whole or not at all, no lock.
100
105
 
101
106
  **How to build the claim.** The decision model answers exactly the statement it is given, so the statement must be one the paper could make:
102
107
 
@@ -142,7 +147,9 @@ PubMed's book records — National Academies reports, NTP monographs, StatPearls
142
147
 
143
148
  **Clinical trial records.** `library_add("nct:05173896")` stores the current record of one trial from [ClinicalTrials.gov](https://clinicaltrials.gov/): the ID is the eight digits of the NCT number in the review's link (`…/study/NCT05173896`); `nct:NCT05173896` and a bare `NCT05173896` are accepted and read the same. The API's JSON is converted to Markdown, one section per module: identification with secondary IDs such as EudraCT, status with every date and its actual/estimated type, sponsor and collaborators, description, conditions, design (phase, allocation, masking, enrollment with its type), arms and interventions with doses, primary and secondary outcomes with time frames, eligibility, locations as a country list, references; and, when results are posted, the participant flow, baseline characteristics, every outcome measure with its group values and analyses, and the adverse events, all as tables. Values are written in plain words, "Phase 2", "Active, not recruiting", "100 (estimated)", so a figure the review states can be found as stated. The status carries `nctid`, `publications` (the results publications the record lists, as `pmid:…`, so whether a trial has been published is one status away), the brief title, the lead sponsor as `first_author`, the first-posted year as `year`, the last-update-posted date as `version`, and `publication_types` `["Trial Registration"]`. `library_read` and `library_verify` work on a record as on a paper. An entry older than `FH_LIBRARY_REFRESH_AFTER_DAYS` is re-fetched and rewritten when the record was updated since; a status the review states that has moved on then fails, which is right. Entries live under `nct/<digits 1–2>/<digits 1–4>/<digits>/` with the JSON and the Markdown as `nct-<digits>-fulltext.json` and `-fulltext.md`; the log line reads `library_add nct:05173896 ctgov fetched level=full_text updated 2024-12-09 active, not recruiting ETLAS2`.
144
149
 
145
- **Web pages.** `library_fetch(url, max_length?, start_index?, raw?)` is the standard MCP [fetch](https://github.com/modelcontextprotocol/servers/tree/main/src/fetch) tool, served from the library, for every link a review carries that is not a paper, label or trial record: a guideline, a product page, a news item. Its description tells the model to prefer it over a plain fetch or a browser tool, and says why: a page anyone fetched in the last 30 days comes from disk in milliseconds, and a page a plain fetch cannot get climbs the ladder in the same call. The answer is what `fetch` returns, so a model keeps its habits: `Contents of <url>:` and the page as Markdown with the page's title as the first heading, cut at `max_length` (default 5000) with the note `Content truncated. Call the fetch tool with a start_index of N to get more content`; `raw` gives the HTML; a `text/markdown` body comes back as is, any other non-HTML body with the `Content type … cannot be simplified to markdown` prefix; a failure is `Failed to fetch <url> - status code 404` or `Failed to fetch <url>: <reason>`. Two things happen underneath. The page is fetched up the same ladder as a paper — the plain fetch, then the browser rungs on a 403, 429 or bot wall, then Bright Data — so a page a plain fetch cannot reach is still read. And it is stored: `url/<2>/<4>/<16 hex of the URL's SHA-1>/url-<hash>-{meta.md, page.md, page.html|pdf|txt}`, the URL normalised first (host lowercased, fragment and tracking parameters dropped). The same URL within `FH_LIBRARY_REFRESH_AFTER_DAYS` (30) is served from disk, a failed fetch is not repeated within `FH_LIBRARY_RETRY_AFTER_DAYS` (1) — an audit that runs twice over one review reads its links once. A PDF comes back as Markdown (`fetch` would return its bytes as text). The log says which — `fetch example.com/… cached html … https://example.com/drugs/x` or `fetched 02.3s html retrieved_by=browser hetzner … <url>` (the ID column holds the URL without scheme, cut to a PubMed ID's width; the full URL ends the line); `library_info` counts the pages under `url`, a failed fetch as level `title`.
150
+ **Trial search.** `library_clinicaltrials_search_studies(query?, intervention?, condition?, status?, sort?, max_results?)` asks the ClinicalTrials.gov API (v2, no key) what a review needs from the registry: the trials of an intervention, the ongoing ones (`status: "ongoing"` is recruiting, not yet recruiting, active not recruiting, enrolling by invitation), a free-text or condition search, sorted by relevance, newest, largest or completion date, up to 100 rows. The rows are arrays in the order of `columns` — the field names once, not per row — shaped for the review's Emerging Research item (what AI4L's prompt asks about an ongoing trial; the link is `https://clinicaltrials.gov/study/<NCT ID>`): NCT ID, title, status with why a trial stopped, phase, enrollment as "100 (estimated)", start and primary completion dates (whether "ongoing" still holds), the primary outcome with its time frame, conditions, interventions, `has_results`; the full detail of one row is `library_clinicaltrials_get_study_record`; `total` is the registry's count, for a "no trials found" statement. The same search is answered from `search/<2>/<4>/<key>.json` for 7 days — a registry listing a week old is current enough for a review, and an audit that runs again reads from disk. Two thin tools answer from the stored record of any row (added first when missing): `library_clinicaltrials_get_study_record(nct)` is the review's detail of one trial — official title, design, enrollment, dates, eligibility, sites, result publications as `pmid:` IDs, the brief summary — and `library_clinicaltrials_get_study_results(nct, max_chars?)` is the posted results part of the stored Markdown (participant flow, baseline, outcome measures, adverse events), with the outcome titles listed so `library_read("nct:…", section)` can return one measure whole. Together with the search they cover what the AI4L models asked the ClinicalTrials.gov MCP server for.
151
+
152
+ **Web pages.** `library_fetch(url, max_length?, start_index?, raw?)` is the standard MCP [fetch](https://github.com/modelcontextprotocol/servers/tree/main/src/fetch) tool, served from the library, for every link a review carries that is not a paper, label or trial record: a guideline, a product page, a news item. Its description tells the model to prefer it over a plain fetch or a browser tool, and says why: a page anyone fetched in the last 30 days comes from disk in milliseconds, and a page a plain fetch cannot get climbs the ladder in the same call. The answer is what `fetch` returns, so a model keeps its habits: `Contents of <url>:` and the page as Markdown with the page's title as the first heading, cut at `max_length` (default 5000) with the note `Content truncated. Call the fetch tool with a start_index of N to get more content`; `raw` gives the HTML; a `text/markdown` body comes back as is, any other non-HTML body with the `Content type … cannot be simplified to markdown` prefix; a failure is `Failed to fetch <url> - status code 404` or `Failed to fetch <url>: <reason>`. Two things happen underneath. The page is fetched up the same ladder as a paper — the plain fetch, then the browser rungs on a 403, 429 or bot wall, then Bright Data — so a page a plain fetch cannot reach is still read. And it is stored: `url/<2>/<4>/<16 hex of the URL's SHA-1>/url-<hash>-{meta.md, page.md, page.html|pdf|txt}`, the URL normalised first (host lowercased, fragment and tracking parameters dropped). The same URL within `FH_LIBRARY_REFRESH_AFTER_DAYS` (30) is served from disk, a failed fetch is not repeated within `FH_LIBRARY_RETRY_AFTER_DAYS` (1) — an audit that runs twice over one review reads its links once. A PDF comes back as Markdown (`fetch` would return its bytes as text). `title_only` returns `Contents of <url>:` and the title line alone — the page is still fetched and stored in full — so a link check costs a few tokens instead of a page. The log says which — `fetch example.com/… cached html … https://example.com/drugs/x` or `fetched 02.3s html retrieved_by=browser hetzner … <url>` (the ID column holds the URL without scheme, cut to a PubMed ID's width; the full URL ends the line); `library_info` counts the pages under `url`, a failed fetch as level `title`.
146
153
 
147
154
  **Funding and conflicts of interest.** Every status carries `coi`, the paper's conflict-of-interest statement, and `funding`, who paid. Both come from PubMed's record at ingest (`CoiStatement`, the grant list's agencies; a quarter of records have each) and are refreshed with it; where PubMed has none and a full text was retrieved, they are taken from the paper's own text — its Declarations section, or a section headed Funding, Conflicts of interest, Competing interests or Disclosure — as the statement verbatim. `coi` null and `funding` empty therefore mean: neither PubMed nor the stored text states anything. For an abstract-only entry that is as far as the library can see. The full statements of a paper with full text are one call away: `library_read(id, section: "Declarations")` — the JATS section, or on a PDF- or HTML-sourced text the funding, conflict and acknowledgement sections assembled. In `-meta.md` they are the lines `coi:` (a JSON string) and `funding:` (a JSON array, one item per agency or statement).
148
155
 
package/dist/index.js CHANGED
@@ -669,11 +669,11 @@ async function pdfToMarkdown(bytes, title, headLine2, stats2) {
669
669
  }
670
670
  const lines = text3.split(/\r?\n/).map((l) => l.replace(/\s+/g, " ").trim());
671
671
  const out = [`# ${title}`, "", headLine2, ""];
672
- let para2 = [];
672
+ let para3 = [];
673
673
  const flush = () => {
674
- if (para2.length) {
675
- out.push(para2.join(" "), "");
676
- para2 = [];
674
+ if (para3.length) {
675
+ out.push(para3.join(" "), "");
676
+ para3 = [];
677
677
  }
678
678
  };
679
679
  for (const line of lines) {
@@ -687,7 +687,7 @@ async function pdfToMarkdown(bytes, title, headLine2, stats2) {
687
687
  out.push(`## ${h}`, "");
688
688
  continue;
689
689
  }
690
- para2.push(line);
690
+ para3.push(line);
691
691
  }
692
692
  flush();
693
693
  return fixGlyphs(out.join("\n")).replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
@@ -880,9 +880,9 @@ function dehyphenate(text3) {
880
880
  return text3.replace(/([A-Za-z]+)\u0000\n([A-Za-z]+)/g, (m, x, y) => {
881
881
  if (!/^[a-z]/.test(y)) return `${x}-${y}`;
882
882
  if (SUSPENDED.has(y.toLowerCase())) return `${x}- ${y}`;
883
- const hyph = `${x}-${y}`.toLowerCase(), joined = `${x}${y}`.toLowerCase();
883
+ const hyph = `${x}-${y}`.toLowerCase(), joined3 = `${x}${y}`.toLowerCase();
884
884
  if (seen(hyph)) return `${x}-${y}`;
885
- if (seen(joined)) return `${x}${y}`;
885
+ if (seen(joined3)) return `${x}${y}`;
886
886
  return COMPOUND_PREFIX.has(x.toLowerCase()) ? `${x}-${y}` : `${x}${y}`;
887
887
  }).replace(/\u0000\n/g, "-\n").replace(/\u0000/g, "-");
888
888
  }
@@ -1797,8 +1797,8 @@ function rotateIfLarge(file2, max = LOG_MAX_BYTES) {
1797
1797
  }
1798
1798
  }
1799
1799
  function log(tool, id, message) {
1800
- const clean = message.replace(/\x1b\[[0-9;]*m/g, "").replace(/\s*\n\s*/g, " | ");
1801
- const line = `${stamp()} ${HOST} ${tool.replace(/^library_/, "")} ${id ?? "-"} ${clean}
1800
+ const clean2 = message.replace(/\x1b\[[0-9;]*m/g, "").replace(/\s*\n\s*/g, " | ");
1801
+ const line = `${stamp()} ${HOST} ${tool.replace(/^library_/, "")} ${id ?? "-"} ${clean2}
1802
1802
  `;
1803
1803
  try {
1804
1804
  mkdirSync2(FH_LIBRARY_DIR, { recursive: true });
@@ -2917,7 +2917,7 @@ var STALE_MS = 20 * 6e4;
2917
2917
  var stats = null;
2918
2918
  var file = null;
2919
2919
  function startStats(pkg2, libraryDir = FH_LIBRARY_DIR) {
2920
- stats = { host: HOST, pid: process.pid, package: pkg2.name, version: pkg2.version, started: (/* @__PURE__ */ new Date()).toISOString(), verify: { hits: 0, misses: 0 }, fetch: { hits: 0, misses: 0 } };
2920
+ stats = { host: HOST, pid: process.pid, package: pkg2.name, version: pkg2.version, started: (/* @__PURE__ */ new Date()).toISOString(), verify: { hits: 0, misses: 0 }, fetch: { hits: 0, misses: 0 }, search: { hits: 0, misses: 0 } };
2921
2921
  file = join7(libraryDir, ".servers", `${HOST}-${process.pid}.json`);
2922
2922
  sweep(join7(libraryDir, ".servers"));
2923
2923
  publish();
@@ -2969,7 +2969,7 @@ function publish() {
2969
2969
  }
2970
2970
 
2971
2971
  // src/tools.ts
2972
- import { existsSync as existsSync10, readFileSync as readFileSync10 } from "fs";
2972
+ import { existsSync as existsSync11, readFileSync as readFileSync11 } from "fs";
2973
2973
  import { z } from "zod";
2974
2974
 
2975
2975
  // src/cascade.ts
@@ -3112,7 +3112,8 @@ function citationGroups(claim) {
3112
3112
  function fragment(claim) {
3113
3113
  const body = claim.trim().replace(/^[-*+]\s+/, "").replace(/^\*\*[^*]+:\*\*\s*/, "").replace(/^[("'\u201c]+/, "");
3114
3114
  const first = /^(\S+)/.exec(body)?.[1] ?? "";
3115
- if (/^(and|or|but|nor|which|whereas|while)$/i.test(first.replace(/[,;:]$/, ""))) return `claim is a fragment: starts with "${first}" \u2014 send a complete statement with its subject`;
3115
+ const oddsRatio = first === "OR" && /^OR\s+(\d|=|\(|of\b|for\b)/.test(body);
3116
+ if (!oddsRatio && /^(and|or|but|nor|which|whereas|while)$/i.test(first.replace(/[,;:]$/, ""))) return `claim is a fragment: starts with "${first}" \u2014 send a complete statement with its subject`;
3116
3117
  if (/^[a-z]/.test(body)) return `claim is a fragment: starts with "${first}" in lowercase \u2014 send a complete statement with its subject`;
3117
3118
  return null;
3118
3119
  }
@@ -3428,10 +3429,10 @@ function paragraphs(md, scope) {
3428
3429
  return h;
3429
3430
  };
3430
3431
  const out = [];
3431
- let para2 = [], paraStart = 0, header = null;
3432
+ let para3 = [], paraStart = 0, header = null;
3432
3433
  const flush = () => {
3433
- const text3 = para2.join("\n").trim();
3434
- para2 = [];
3434
+ const text3 = para3.join("\n").trim();
3435
+ para3 = [];
3435
3436
  if (text3) out.push({ section: sectionAt(paraStart), text: text3 });
3436
3437
  };
3437
3438
  for (let i = from; i < to; i++) {
@@ -3452,8 +3453,8 @@ function paragraphs(md, scope) {
3452
3453
  continue;
3453
3454
  }
3454
3455
  header = null;
3455
- if (!para2.length) paraStart = i;
3456
- para2.push(l);
3456
+ if (!para3.length) paraStart = i;
3457
+ para3.push(l);
3457
3458
  }
3458
3459
  flush();
3459
3460
  return out;
@@ -3704,7 +3705,7 @@ function readUrlMeta(p) {
3704
3705
  if (!existsSync8(p.meta)) return null;
3705
3706
  const md = readFileSync8(p.meta, "utf8");
3706
3707
  const f = (k) => {
3707
- const m = new RegExp(`^${k}:\\s*(.*)$`, "m").exec(md);
3708
+ const m = new RegExp(`^${k}:[ \\t]*(.*)$`, "m").exec(md);
3708
3709
  return m ? m[1].trim() : "";
3709
3710
  };
3710
3711
  let title = null;
@@ -3883,9 +3884,17 @@ var failureText = (url, reason) => {
3883
3884
  const m = /^HTTP (\d{3})\b/.exec(reason);
3884
3885
  return m ? `Failed to fetch ${url} - status code ${m[1]}` : `Failed to fetch ${url}: ${reason}`;
3885
3886
  };
3887
+ function titleLine(m, md) {
3888
+ if (m.title) return `# ${m.title}`;
3889
+ const first = md.split("\n").find((l) => l.trim());
3890
+ if (first) return first.trim();
3891
+ return `(${m.content_type || "no content type"}, no title)`;
3892
+ }
3886
3893
  function serve(requested, p, m, args, cached) {
3887
3894
  const maxLength = args.max_length ?? 5e3, startIndex = args.start_index ?? 0;
3888
3895
  const read = (file2) => existsSync8(file2) ? readFileSync8(file2, "utf8") : "";
3896
+ if (args.title_only) return { ok: true, text: `Contents of ${requested}:
3897
+ ${titleLine(m, read(p.md))}`, cached, meta: m };
3889
3898
  const rawFile = m.files.find((f) => !f.endsWith("-meta.md") && !f.endsWith("-page.md"));
3890
3899
  let body, prefix = "";
3891
3900
  if (m.format === "markdown") body = read(p.md);
@@ -3943,8 +3952,11 @@ import { existsSync as existsSync9, mkdirSync as mkdirSync6, readFileSync as rea
3943
3952
  import { dirname as dirname3, join as join10 } from "path";
3944
3953
  var sha = (s) => createHash3("sha1").update(s).digest("hex");
3945
3954
  var normalizeClaim = (claim) => claim.replace(/\s+/g, " ").trim();
3955
+ function foldClaim(claim) {
3956
+ return claim.normalize("NFKC").toLowerCase().replace(/[\u2018\u2019\u201a\u201b\u2032]/g, "'").replace(/[\u201c\u201d\u201e\u201f\u2033]/g, '"').replace(/[\u2010-\u2015\u2212]/g, "-").replace(/\+\/-|\+-/g, "\xB1").replace(/\s+/g, " ").trim().replace(/[.;:,]+$/, "").trim();
3957
+ }
3946
3958
  var textHash = (text3) => sha(text3).slice(0, 16);
3947
- var cacheKey = (claim, id, model, threshold, text3) => sha(`${normalizeClaim(claim)}
3959
+ var cacheKey = (claim, id, model, threshold, text3) => sha(`${foldClaim(claim)}
3948
3960
  ${id}
3949
3961
  ${model}
3950
3962
  ${threshold}
@@ -3983,15 +3995,229 @@ async function verifyCached(claim, id, cfg, decide, libraryDir = FH_LIBRARY_DIR,
3983
3995
  return { ...r, cached: false };
3984
3996
  }
3985
3997
 
3998
+ // src/json.ts
3999
+ var DROP = /* @__PURE__ */ new Set(["entry_dir", "library_dir", "files"]);
4000
+ var compact2 = (o) => JSON.stringify(o);
4001
+ function lean(o) {
4002
+ if (Array.isArray(o)) return o.map((row2) => row2 && typeof row2 === "object" && !Array.isArray(row2) ? leanObject(row2) : row2);
4003
+ if (o && typeof o === "object") return leanObject(o);
4004
+ return o;
4005
+ }
4006
+ function leanObject(o) {
4007
+ const out = {};
4008
+ for (const [k, v] of Object.entries(o)) {
4009
+ if (DROP.has(k) || v === null || v === void 0 || Array.isArray(v) && v.length === 0) continue;
4010
+ out[k] = Array.isArray(v) ? lean(v) : v;
4011
+ }
4012
+ return out;
4013
+ }
4014
+ var leanJson = (o) => compact2(lean(o));
4015
+
4016
+ // src/trials.ts
4017
+ import { createHash as createHash4 } from "crypto";
4018
+ import { existsSync as existsSync10, mkdirSync as mkdirSync7, readFileSync as readFileSync10 } from "fs";
4019
+ import { dirname as dirname4, join as join11 } from "path";
4020
+ var SEARCH_AFTER_DAYS = 7;
4021
+ var API2 = "https://clinicaltrials.gov/api/v2/studies";
4022
+ var FIELDS = ["NCTId", "BriefTitle", "OverallStatus", "WhyStopped", "Phase", "StudyType", "EnrollmentCount", "EnrollmentType", "StartDate", "PrimaryCompletionDate", "CompletionDate", "LastUpdatePostDate", "PrimaryOutcomeMeasure", "PrimaryOutcomeTimeFrame", "Condition", "InterventionName", "LeadSponsorName", "HasResults"];
4023
+ var ONGOING = ["RECRUITING", "NOT_YET_RECRUITING", "ACTIVE_NOT_RECRUITING", "ENROLLING_BY_INVITATION"];
4024
+ var MAX_RESULTS = 100;
4025
+ var DEFAULT_RESULTS = 20;
4026
+ var SORTS = { relevance: null, newest: "LastUpdatePostDate:desc", largest: "EnrollmentCount:desc", completion: "CompletionDate:desc" };
4027
+ var COLUMNS = [
4028
+ "nct",
4029
+ "title",
4030
+ "status",
4031
+ // "Terminated — slow accrual": a trial past its completion date is not "ongoing" (ER.md 28.7)
4032
+ "phase",
4033
+ "enrollment",
4034
+ // "100 (estimated)" / "48 (actual)" — participant count (28.5)
4035
+ "start",
4036
+ "primary_completion",
4037
+ "primary_outcome",
4038
+ // the first primary outcome with its time frame, cut at 80 characters — the primary endpoint (28.5)
4039
+ "conditions",
4040
+ "interventions",
4041
+ // "IVF; Infertility, Female" — one string each
4042
+ "has_results"
4043
+ // posted results: get_study_results (28.8)
4044
+ ];
4045
+ var clean = (s) => (s ?? "").replace(/\s+/g, " ").trim();
4046
+ function normalizeQuery(q) {
4047
+ const out = { status: q.status ?? "all", sort: q.sort ?? "relevance", max_results: Math.min(MAX_RESULTS, Math.max(1, Math.floor(q.max_results ?? DEFAULT_RESULTS))) };
4048
+ if (clean(q.query)) out.query = clean(q.query);
4049
+ if (clean(q.intervention)) out.intervention = clean(q.intervention);
4050
+ if (clean(q.condition)) out.condition = clean(q.condition);
4051
+ if (!out.query && !out.intervention && !out.condition) return { error: "empty search: give query, intervention or condition" };
4052
+ if (!["ongoing", "completed", "all"].includes(out.status)) return { error: `status must be ongoing, completed or all, not "${String(q.status)}"` };
4053
+ if (!(out.sort in SORTS)) return { error: `sort must be relevance, newest, largest or completion, not "${String(q.sort)}"` };
4054
+ return out;
4055
+ }
4056
+ var searchKey = (q) => createHash4("sha1").update(JSON.stringify(["clinicaltrials", q.query ?? "", q.intervention ?? "", q.condition ?? "", q.status, q.sort, q.max_results])).digest("hex").slice(0, 16);
4057
+ var searchFile = (libraryDir, key) => join11(libraryDir, "search", key.slice(0, 2), key.slice(0, 4), `${key}.json`);
4058
+ function searchUrl(q) {
4059
+ const p = new URLSearchParams();
4060
+ if (q.query) p.set("query.term", q.query);
4061
+ if (q.intervention) p.set("query.intr", q.intervention);
4062
+ if (q.condition) p.set("query.cond", q.condition);
4063
+ if (q.status === "ongoing") p.set("filter.overallStatus", ONGOING.join(","));
4064
+ if (q.status === "completed") p.set("filter.overallStatus", "COMPLETED");
4065
+ if (SORTS[q.sort]) p.set("sort", SORTS[q.sort]);
4066
+ p.set("fields", FIELDS.join(","));
4067
+ p.set("pageSize", String(q.max_results));
4068
+ p.set("countTotal", "true");
4069
+ return `${API2}?${p.toString()}`;
4070
+ }
4071
+ var STATUS2 = { NOT_YET_RECRUITING: "Not yet recruiting", RECRUITING: "Recruiting", ENROLLING_BY_INVITATION: "Enrolling by invitation", ACTIVE_NOT_RECRUITING: "Active, not recruiting", SUSPENDED: "Suspended", TERMINATED: "Terminated", COMPLETED: "Completed", WITHDRAWN: "Withdrawn", UNKNOWN: "Unknown status", AVAILABLE: "Available", NO_LONGER_AVAILABLE: "No longer available", TEMPORARILY_NOT_AVAILABLE: "Temporarily not available", APPROVED_FOR_MARKETING: "Approved for marketing", WITHHELD: "Withheld" };
4072
+ var PHASE2 = { EARLY_PHASE1: "Early Phase 1", PHASE1: "Phase 1", PHASE2: "Phase 2", PHASE3: "Phase 3", PHASE4: "Phase 4", NA: "Not applicable" };
4073
+ var joined = (xs) => xs.length ? xs.map((x) => x.replace(/\s+/g, " ").trim()).filter(Boolean).join("; ") || null : null;
4074
+ function parseRows(json2) {
4075
+ const d = JSON.parse(json2);
4076
+ const rows = [];
4077
+ for (const s of Array.isArray(d.studies) ? d.studies : []) {
4078
+ const p = s.protocolSection ?? {};
4079
+ const id = p.identificationModule ?? {}, st = p.statusModule ?? {}, des = p.designModule ?? {};
4080
+ const phases = Array.isArray(des.phases) ? des.phases : [];
4081
+ const nct = String(id.nctId ?? "");
4082
+ const en = des.enrollmentInfo ?? {};
4083
+ const outcomes = Array.isArray(p.outcomesModule?.primaryOutcomes) ? p.outcomesModule.primaryOutcomes : [];
4084
+ const first = outcomes[0];
4085
+ const cut = (t, n) => t.length > n ? t.slice(0, n - 1).trimEnd() + "\u2026" : t;
4086
+ const primary = first?.measure ? cut(`${String(first.measure).replace(/\s+/g, " ").trim()}${first.timeFrame ? ` (${String(first.timeFrame).replace(/\s+/g, " ").trim()})` : ""}`, 80) + (outcomes.length > 1 ? ` +${outcomes.length - 1}` : "") : null;
4087
+ const status = (STATUS2[st.overallStatus] ?? String(st.overallStatus ?? "")) + (st.whyStopped ? ` \u2014 ${String(st.whyStopped).replace(/\s+/g, " ").trim()}` : "");
4088
+ rows.push([
4089
+ nct,
4090
+ String(id.briefTitle ?? ""),
4091
+ status,
4092
+ phases.length ? phases.map((x) => PHASE2[x] ?? x).join(" / ") : null,
4093
+ typeof en.count === "number" ? `${en.count}${en.type ? ` (${String(en.type).toLowerCase()})` : ""}` : null,
4094
+ st.startDateStruct?.date ?? null,
4095
+ st.primaryCompletionDateStruct?.date ?? null,
4096
+ primary,
4097
+ joined(Array.isArray(p.conditionsModule?.conditions) ? p.conditionsModule.conditions.map(String) : []),
4098
+ joined(Array.isArray(p.armsInterventionsModule?.interventions) ? p.armsInterventionsModule.interventions.map((i) => String(i?.name ?? "")).filter(Boolean) : []),
4099
+ s.hasResults === true
4100
+ ]);
4101
+ }
4102
+ return { total: typeof d.totalCount === "number" ? d.totalCount : rows.length, rows };
4103
+ }
4104
+ var liveFetch = (url) => fetchText(url, { "User-Agent": LIBRARY_UA, Accept: "application/json" });
4105
+ async function searchTrials(raw, libraryDir = FH_LIBRARY_DIR, fetchImpl = liveFetch) {
4106
+ const q = normalizeQuery(raw);
4107
+ if ("error" in q) return { ok: false, error: q.error };
4108
+ const file2 = searchFile(libraryDir, searchKey(q));
4109
+ if (existsSync10(file2)) {
4110
+ try {
4111
+ const r = JSON.parse(readFileSync10(file2, "utf8"));
4112
+ if (daysSince(r.searched) < SEARCH_AFTER_DAYS) return { ok: true, result: r, cached: true };
4113
+ } catch {
4114
+ }
4115
+ }
4116
+ let json2;
4117
+ try {
4118
+ json2 = await fetchImpl(searchUrl(q));
4119
+ } catch (e) {
4120
+ return { ok: false, error: `clinicaltrials.gov: ${e instanceof Error ? e.message : String(e)}` };
4121
+ }
4122
+ let parsed;
4123
+ try {
4124
+ parsed = parseRows(json2);
4125
+ } catch {
4126
+ return { ok: false, error: "clinicaltrials.gov: not a studies answer" };
4127
+ }
4128
+ const result = { source: "clinicaltrials", query: q, total: parsed.total, columns: COLUMNS, rows: parsed.rows, searched: today() };
4129
+ try {
4130
+ mkdirSync7(dirname4(file2), { recursive: true });
4131
+ writeAtomic(file2, JSON.stringify(result, null, 2) + "\n");
4132
+ } catch {
4133
+ }
4134
+ return { ok: true, result, cached: false };
4135
+ }
4136
+ function trialsLogId(q) {
4137
+ const s = (q.intervention ?? q.condition ?? q.query ?? "").replace(/\s+/g, " ");
4138
+ return s.length > 10 ? s.slice(0, 9) + "\u2026" : s || "-";
4139
+ }
4140
+
4141
+ // src/trialrecord.ts
4142
+ var list3 = (a) => Array.isArray(a) ? a : [];
4143
+ var para2 = (s, max = Infinity) => {
4144
+ const t = String(s ?? "").replace(/\s+/g, " ").trim();
4145
+ if (!t) return null;
4146
+ return t.length > max ? t.slice(0, max - 1).trimEnd() + "\u2026" : t;
4147
+ };
4148
+ var joined2 = (xs) => {
4149
+ const ys = xs.filter((x) => !!x);
4150
+ return ys.length ? ys.join("; ") : null;
4151
+ };
4152
+ var dated2 = (d) => d?.date ? `${d.date}${d.type ? ` (${String(d.type).toLowerCase()})` : ""}` : null;
4153
+ function recordSummary(json2) {
4154
+ let d;
4155
+ try {
4156
+ d = JSON.parse(json2);
4157
+ } catch {
4158
+ return null;
4159
+ }
4160
+ const ps = d?.protocolSection;
4161
+ if (!ps) return null;
4162
+ const idm = ps.identificationModule ?? {}, st = ps.statusModule ?? {}, sp = ps.sponsorCollaboratorsModule ?? {}, de = ps.descriptionModule ?? {};
4163
+ const dm = ps.designModule ?? {}, ai = ps.armsInterventionsModule ?? {}, om = ps.outcomesModule ?? {}, el = ps.eligibilityModule ?? {}, cl = ps.contactsLocationsModule ?? {}, rf = ps.referencesModule ?? {};
4164
+ const nct = String(idm.nctId ?? "").toUpperCase();
4165
+ const title = para2(idm.briefTitle) ?? nct;
4166
+ const official = para2(idm.officialTitle);
4167
+ const di = dm.designInfo ?? {}, mi = di.maskingInfo ?? {};
4168
+ const design = joined2([di.allocation ? words(di.allocation) : null, di.interventionModel ? words(di.interventionModel) : null, mi.masking ? `masking ${words(mi.masking).toLowerCase()}` : null, di.primaryPurpose ? `purpose ${words(di.primaryPurpose).toLowerCase()}` : null]);
4169
+ const outcome = (o) => `${para2(o.measure) ?? ""}${o.timeFrame ? ` (${para2(o.timeFrame)})` : ""}`.trim();
4170
+ const countries = [...new Set(list3(cl.locations).map((l) => l.country).filter(Boolean))];
4171
+ const refs = list3(rf.references);
4172
+ const age = el.minimumAge || el.maximumAge ? `${el.minimumAge ?? "no minimum"} to ${el.maximumAge ?? "no maximum"}` : null;
4173
+ return {
4174
+ nct,
4175
+ title,
4176
+ official_title: official && official !== title ? official : null,
4177
+ acronym: para2(idm.acronym),
4178
+ status: st.overallStatus ? words(st.overallStatus) : null,
4179
+ why_stopped: para2(st.whyStopped),
4180
+ phase: list3(dm.phases).length ? list3(dm.phases).map(words).join(" / ") : null,
4181
+ study_type: dm.studyType ? words(dm.studyType) : null,
4182
+ design,
4183
+ enrollment: dm.enrollmentInfo?.count !== void 0 ? `${dm.enrollmentInfo.count}${dm.enrollmentInfo.type ? ` (${String(dm.enrollmentInfo.type).toLowerCase()})` : ""}` : null,
4184
+ start: dated2(st.startDateStruct),
4185
+ primary_completion: dated2(st.primaryCompletionDateStruct),
4186
+ completion: dated2(st.completionDateStruct),
4187
+ first_posted: st.studyFirstPostDateStruct?.date ?? null,
4188
+ last_update: st.lastUpdatePostDateStruct?.date ?? null,
4189
+ results_first_posted: st.resultsFirstPostDateStruct?.date ?? null,
4190
+ sponsor: para2(sp.leadSponsor?.name),
4191
+ collaborators: joined2(list3(sp.collaborators).map((c) => para2(c.name))),
4192
+ conditions: joined2(list3(ps.conditionsModule?.conditions).map((c) => para2(c))),
4193
+ interventions: joined2(list3(ai.interventions).map((i) => para2(`${i.type ? `${words(i.type)}: ` : ""}${i.name ?? ""}`))),
4194
+ primary_outcomes: list3(om.primaryOutcomes).map(outcome).filter(Boolean),
4195
+ secondary_outcomes: list3(om.secondaryOutcomes).length,
4196
+ eligibility: joined2([el.sex ? `sex ${words(el.sex).toLowerCase()}` : null, age ? `age ${age}` : null, el.healthyVolunteers !== void 0 ? `healthy volunteers ${el.healthyVolunteers ? "yes" : "no"}` : null]),
4197
+ locations: countries.length ? `${list3(cl.locations).length} site${list3(cl.locations).length === 1 ? "" : "s"} in ${countries.join(", ")}` : null,
4198
+ publications: refs.filter((r) => r.pmid && ["RESULT", "DERIVED"].includes(String(r.type))).map((r) => `pmid:${r.pmid}`),
4199
+ has_results: d.hasResults === true,
4200
+ summary: para2(de.briefSummary, 400)
4201
+ };
4202
+ }
4203
+ function resultsText(nct, md, maxChars) {
4204
+ const i = md.search(/^## Results\s*$/m);
4205
+ if (i < 0) return { nct, has_results: false, text: null, truncated: false, outcomes: [], note: "no posted results in the registry record" };
4206
+ const part = md.slice(i).replace(/^## Results\s*\n/, "").trim();
4207
+ const outcomes = [...part.matchAll(/^#### (.+)$/gm)].map((m) => m[1].trim());
4208
+ const truncated = part.length > maxChars;
4209
+ return { nct, has_results: true, text: truncated ? part.slice(0, maxChars).trimEnd() + "\n\n[truncated]" : part, truncated, outcomes, note: truncated ? "cut at max_chars; library_read(id, section: <an outcome title>) returns one measure whole" : null };
4210
+ }
4211
+
3986
4212
  // src/tools.ts
3987
4213
  var pkg = (() => {
3988
4214
  try {
3989
- return JSON.parse(readFileSync10(new URL("../package.json", import.meta.url), "utf8"));
4215
+ return JSON.parse(readFileSync11(new URL("../package.json", import.meta.url), "utf8"));
3990
4216
  } catch {
3991
4217
  return { name: "fh-library", version: "0.0.0" };
3992
4218
  }
3993
4219
  })();
3994
- var INSTRUCTIONS = `fh-library keeps a local library of scientific papers, FDA drug labels and clinical trial records, so you can check what a source actually says. The server downloads each record, converts it to Markdown and stores it on disk. Every tool returns a small JSON answer; the document itself never enters your context \u2014 except library_fetch, which is the standard fetch tool with a cache. One ID per call.
4220
+ var INSTRUCTIONS = `fh-library keeps a local library of scientific papers, FDA drug labels, clinical trial records and web pages, so you can check what a source actually says, and searches ClinicalTrials.gov. The server downloads each record, converts it to Markdown and stores it on disk. Every tool returns a small JSON answer; the document itself never enters your context \u2014 except library_fetch, which is the standard fetch tool with a cache. One ID per call.
3995
4221
 
3996
4222
  IDs \u2014 always "type:value"
3997
4223
  - pmid:30955454 \u2014 a paper by PubMed ID. Bare digits are taken as a PMID.
@@ -4006,7 +4232,10 @@ Tools
4006
4232
  - library_status(id) \u2014 the status of one record without downloading; level "missing" when it is not stored.
4007
4233
  - library_read(id, section?, query?, max_chars?, max_hits?) \u2014 one small piece of a stored document: its outline, one section, or the passages matching a query.
4008
4234
  - library_verify(claim, id) \u2014 does this one source state this one finding? A decision model judges the stored text and returns a verdict.
4009
- - library_fetch(url, max_length?, start_index?, raw?) \u2014 a web page as Markdown, same call and same answer as the standard fetch tool ("Contents of <url>:" and the page, cut at max_length with a note on how to continue). Prefer it for every URL: a cached page answers from disk, a walled or script-rendered page climbs the browser and unlocker ladder in one call. PDFs as Markdown.
4235
+ - library_clinicaltrials_search_studies(query | intervention | condition, status?, sort?, max_results?) \u2014 search ClinicalTrials.gov: the total and rows in columns order (NCT ID, title, status, phase, enrollment, dates, primary outcome, conditions, interventions, has_results). Cached a week.
4236
+ - library_clinicaltrials_get_study_record(nct) \u2014 one trial as a review needs it (status, design, enrollment, dates, outcomes, eligibility, result publications, summary), from the stored record; added first when missing.
4237
+ - library_clinicaltrials_get_study_results(nct, max_chars?) \u2014 a trial's posted results as Markdown, capped; the outcome titles listed for library_read.
4238
+ - library_fetch(url, max_length?, start_index?, raw?, title_only?) \u2014 a web page as Markdown, same call and same answer as the standard fetch tool ("Contents of <url>:" and the page, cut at max_length with a note on how to continue). Prefer it for every URL: a cached page answers from disk, a walled or script-rendered page climbs the browser and unlocker ladder in one call. PDFs as Markdown. title_only returns the title line alone \u2014 enough to verify a link, at a few tokens.
4010
4239
 
4011
4240
  Hints
4012
4241
  - library_read and library_verify store a missing record themselves; the library_read answer then carries added: true. No library_add necessary first.
@@ -4015,6 +4244,7 @@ Hints
4015
4244
  - error "pmid not found" or "invalid id" \u2014 the ID itself is wrong; the same call gives the same answer.
4016
4245
  - Calls may run in parallel: library_add, library_read and library_verify. Two calls on the same missing record share one add.
4017
4246
  - library_fetch takes any URL that is not a paper, label or trial record; those have IDs, and library_add, library_read and library_verify work on the stored document. A second pass over the same review reads every page from disk; a failed fetch is remembered for a day.
4247
+ - To verify that a web page exists and what it is: library_fetch(url, title_only: true). The answer is "Contents of <url>:" and the page's title line \u2014 a few tokens \u2014 or the fetch error; the page itself is stored in full, so a later library_fetch of the same URL reads from disk.
4018
4248
 
4019
4249
  Verifying a claim
4020
4250
  - library_verify judges one claim against one stored document. A finding backed by three papers takes three calls with the same claim.
@@ -4037,12 +4267,12 @@ Reading a document
4037
4267
  - section and query together search inside that section only.
4038
4268
  - section "Abstract" on a full text without one returns the stored abstract. section "Declarations" returns funding, conflicts of interest, acknowledgements and ethics statements, assembled when the document has no section of that name.
4039
4269
 
4040
- Status fields: requested, type, id, entry, entry_dir, library_dir, pmid, pmcid, doi, setid, nctid, publications, title, first_author, year, publication_types, retracted, corrections, coi (conflict-of-interest statement), funding, citable, comment, has_abstract, level, version, retrieved_by, retrieved_date, pubmed_checked, attempts, files, glyphs_unresolved, error.
4270
+ Status fields: requested, type, id, entry, pmid, pmcid, doi, setid, nctid, publications, title, first_author, year, publication_types, retracted, corrections, coi (conflict-of-interest statement), funding, citable, comment, has_abstract, level, version, retrieved_by, retrieved_date, pubmed_checked, attempts, glyphs_unresolved, error. Answers are compact JSON; a field that is null or an empty list is left out (library_verify keeps its nulls: score and reason null mean something).
4041
4271
 
4042
4272
  Where records come from: PubMed for the record and abstract; the full text from a file you drop into <library>/inbox/ (PMID in the file name), PubMed Central, Europe PMC, publishers' text-mining links, Unpaywall, OpenAlex, CORE, and a proxy URL template from the configuration. Every action is appended as one line to <library>/library.log.`;
4043
4273
  var idSchema = z.string().describe('ID as "type:value": a paper "pmid:30955454" (bare digits count as pmid) or "doi:10.5812/asjsm.110753" (a paper PubMed does not index; a DOI PubMed indexes is taken as its PMID), a drug label "dailymed:<set ID>" (the set ID from its DailyMed page URL), or a trial record "nct:05173896" (the eight digits; nct:NCT05173896 and a bare NCT05173896 are accepted).');
4044
4274
  var levelWord = (m) => m.level === "abstract" && !m.has_abstract ? "title" : m.level;
4045
- var json = (s) => ({ content: [{ type: "text", text: JSON.stringify(s, null, 2) }] });
4275
+ var json = (s) => ({ content: [{ type: "text", text: leanJson(s) }] });
4046
4276
  function noticeTag(m) {
4047
4277
  const n = (t) => m.corrections.filter((c) => c.type === t).length;
4048
4278
  const parts = [
@@ -4343,7 +4573,7 @@ function registerTools(server2) {
4343
4573
  },
4344
4574
  async ({ id, section: section2, query, max_chars, max_hits }) => {
4345
4575
  const parsed = parseId(id);
4346
- const out = (o2) => ({ content: [{ type: "text", text: JSON.stringify(o2, null, 2) }] });
4576
+ const out = (o2) => ({ content: [{ type: "text", text: leanJson(o2) }] });
4347
4577
  if (parsed.error) {
4348
4578
  log("library_read", parsed.requested, `error ${parsed.error}`);
4349
4579
  return out({ requested: parsed.requested, error: parsed.error });
@@ -4359,8 +4589,8 @@ function registerTools(server2) {
4359
4589
  }
4360
4590
  added = true;
4361
4591
  }
4362
- const full = existsSync10(paths.fulltextMd);
4363
- const md = readFileSync10(full ? paths.fulltextMd : paths.md, "utf8");
4592
+ const full = existsSync11(paths.fulltextMd);
4593
+ const md = readFileSync11(full ? paths.fulltextMd : paths.md, "utf8");
4364
4594
  const text3 = full ? "full_text" : "abstract";
4365
4595
  const base = added ? { requested: parsed.requested, entry: paths.entry, text: text3, added: true } : { requested: parsed.requested, entry: paths.entry, text: text3 };
4366
4596
  const headings2 = () => outline(md, text3).sections;
@@ -4380,8 +4610,8 @@ function registerTools(server2) {
4380
4610
  const cap = max_chars ?? 6e3;
4381
4611
  let sec = readSection(md, section2, cap);
4382
4612
  let via = "";
4383
- if (!sec && /abstract/i.test(section2) && full && existsSync10(paths.md)) {
4384
- sec = readSection(readFileSync10(paths.md, "utf8"), "abstract", cap);
4613
+ if (!sec && /abstract/i.test(section2) && full && existsSync11(paths.md)) {
4614
+ sec = readSection(readFileSync11(paths.md, "utf8"), "abstract", cap);
4385
4615
  via = " (from the abstract file)";
4386
4616
  }
4387
4617
  if (!sec && /declaration/i.test(section2)) {
@@ -4429,7 +4659,7 @@ function registerTools(server2) {
4429
4659
  if (!r.error && r.score !== null) bump("verify", cached);
4430
4660
  const secs = `${((Date.now() - t0) / 1e3).toFixed(1).padStart(4, "0")}s`;
4431
4661
  log("library_verify", r.id, r.error ? `${modelFailure(r.error, cfg) ? "!! " : ""}${secs} verdict=${"error".padEnd(12)} ${r.error} | ${claim}` : `${secs} verdict=${r.verdict.padEnd(12)} basis=${r.basis.padEnd(9)} score=${r.score === null ? "- " : r.score.toFixed(3)}${r.windows > 1 ? ` windows=${r.windows}` : ""}${r.superseded ? " superseded" : ""}${r.glyphs_unresolved ? ` glyphs=${r.glyphs_unresolved}` : ""}${cached ? " cached" : ""} | ${claim}`);
4432
- return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
4662
+ return { content: [{ type: "text", text: compact2(r) }] };
4433
4663
  }
4434
4664
  );
4435
4665
  server2.tool(
@@ -4450,36 +4680,111 @@ function registerTools(server2) {
4450
4680
  }
4451
4681
  log("library_info", null, `entries=${info.entries}${info.verify ? ` verify ${info.verify.model} ${info.verify.ok ? `ok ${info.verify.ms} ms` : `down: ${info.verify.error}`}` : " verify not configured"}`);
4452
4682
  const s = currentStats();
4453
- const cache = s ? { since: s.started, verify: s.verify, fetch: s.fetch } : null;
4454
- return { content: [{ type: "text", text: JSON.stringify({ ...info, cache }, null, 2) }] };
4683
+ const cache = s ? { since: s.started, verify: s.verify, fetch: s.fetch, search: s.search } : null;
4684
+ return { content: [{ type: "text", text: compact2({ ...info, cache }) }] };
4455
4685
  }
4456
4686
  );
4457
4687
  server2.tool(
4458
4688
  "library_fetch",
4459
- `Fetch a web page as Markdown: the standard fetch tool, cached and hardened. Use it for any URL instead of a plain fetch or a browser. A page fetched in the last ${settings().refresh_after_days} days is answered from disk in milliseconds; a page a plain fetch cannot get (403, rate limit, bot wall, script-rendered) is fetched through the configured browsers and unlocker in this one call. PDFs come back as Markdown.`,
4689
+ `Fetch a web page as Markdown: the standard fetch tool, cached and hardened. Use it for any URL instead of a plain fetch or a browser. A page fetched in the last ${settings().refresh_after_days} days is answered from disk in milliseconds; a page a plain fetch cannot get (403, rate limit, bot wall, script-rendered) is fetched through the configured browsers and unlocker in this one call. PDFs come back as Markdown. To verify a link, title_only returns just its title line.`,
4460
4690
  {
4461
4691
  url: z.string().describe("URL to fetch"),
4462
4692
  max_length: z.number().int().min(1).max(999999).default(5e3).describe("Maximum number of characters to return."),
4463
4693
  start_index: z.number().int().min(0).default(0).describe("On return output starting at this character index, useful if a previous fetch was truncated and more context is required."),
4464
- raw: z.boolean().default(false).describe("Get the actual HTML content of the requested page, without simplification.")
4694
+ raw: z.boolean().default(false).describe("Get the actual HTML content of the requested page, without simplification."),
4695
+ title_only: z.boolean().default(false).describe("Return only the page's title line. The whole page is still fetched and stored; use this to verify that a link resolves and what it points to.")
4465
4696
  },
4466
- async ({ url, max_length, start_index, raw }) => {
4697
+ async ({ url, max_length, start_index, raw, title_only }) => {
4467
4698
  const t0 = Date.now();
4468
4699
  const id = logId(url), put = (line) => log("library_fetch", id, `${line} ${url}`);
4469
- const r = await libraryFetch(FH_LIBRARY_DIR, url, { max_length, start_index, raw }, `${pkg.name} ${pkg.version}`, {
4700
+ const r = await libraryFetch(FH_LIBRARY_DIR, url, { max_length, start_index, raw, title_only }, `${pkg.name} ${pkg.version}`, {
4470
4701
  onTry: put,
4471
4702
  onAlert: (line) => put(`!! ${line}`)
4472
4703
  });
4473
4704
  const secs = ((Date.now() - t0) / 1e3).toFixed(1).padStart(4, "0") + "s";
4474
4705
  if (r.meta) bump("fetch", r.cached);
4475
4706
  if (r.ok) {
4476
- put(`${r.cached ? "cached" : `fetched ${secs}`} ${r.meta.format} retrieved_by=${r.meta.retrieved_by} fetched=${r.meta.fetched} chars=${r.text.length}${raw ? " raw" : ""}${start_index ? ` start=${start_index}` : ""}`);
4707
+ put(`${r.cached ? "cached" : `fetched ${secs}`} ${r.meta.format} retrieved_by=${r.meta.retrieved_by} fetched=${r.meta.fetched} chars=${r.text.length}${raw ? " raw" : ""}${title_only ? " title" : ""}${start_index ? ` start=${start_index}` : ""}`);
4477
4708
  return { content: [{ type: "text", text: r.text }] };
4478
4709
  }
4479
4710
  put(`${r.cached ? "cached failure" : `failed ${secs}`}: ${r.error.replace(url, "<url>")}`);
4480
4711
  return { content: [{ type: "text", text: r.error }], isError: true };
4481
4712
  }
4482
4713
  );
4714
+ server2.tool(
4715
+ "library_clinicaltrials_search_studies",
4716
+ `Search ClinicalTrials.gov by free text, intervention or condition; status ongoing | completed | all; sort relevance | newest | largest | completion. Returns the total and up to ${MAX_RESULTS} rows as arrays in columns order: NCT ID, title, status, phase, enrollment, start, primary completion, primary outcome, conditions, interventions, has_results. Link: https://clinicaltrials.gov/study/<NCT ID>. Cached ${SEARCH_AFTER_DAYS} days. One trial in detail: library_clinicaltrials_get_study_record.`,
4717
+ {
4718
+ query: z.string().optional().describe("Free-text search across all fields (title, conditions, interventions, summary, \u2026); AND / OR / NOT allowed."),
4719
+ intervention: z.string().optional().describe('Intervention or treatment, e.g. "tadalafil", "cognitive behavioral therapy".'),
4720
+ condition: z.string().optional().describe('Condition or disease, e.g. "hypertension", "type 2 diabetes".'),
4721
+ status: z.enum(["ongoing", "completed", "all"]).default("all").describe("ongoing: recruiting, not yet recruiting, active not recruiting, enrolling by invitation. completed: completed. all: no filter."),
4722
+ sort: z.enum(["relevance", "newest", "largest", "completion"]).default("relevance").describe("relevance (the registry's ranking), newest (last update first), largest (enrollment first), completion (completion date, latest first)."),
4723
+ max_results: z.number().int().min(1).max(MAX_RESULTS).default(20).describe("Rows to return (1\u2013100).")
4724
+ },
4725
+ async (args) => {
4726
+ const t0 = Date.now();
4727
+ const r = await searchTrials(args, FH_LIBRARY_DIR);
4728
+ const secs = ((Date.now() - t0) / 1e3).toFixed(1).padStart(4, "0") + "s";
4729
+ if (!r.ok) {
4730
+ log("library_search", "ct:-", `error: ${r.error}`);
4731
+ return { content: [{ type: "text", text: compact2({ error: r.error }) }], isError: true };
4732
+ }
4733
+ bump("search", r.cached);
4734
+ const q = r.result.query, parts = [q.intervention && `intervention=${q.intervention}`, q.condition && `condition=${q.condition}`, q.query && `query=${q.query}`].filter(Boolean).join(" ");
4735
+ log("library_search", `ct:${trialsLogId(q)}`, `${r.cached ? "cached" : `searched ${secs}`} rows=${r.result.rows.length} total=${r.result.total} status=${q.status}${q.sort !== "relevance" ? ` sort=${q.sort}` : ""} | ${parts}`);
4736
+ return { content: [{ type: "text", text: leanJson(r.result) }] };
4737
+ }
4738
+ );
4739
+ const nctEntry = async (id) => {
4740
+ const p = parseId(/^\d{8}$/.test(id.trim()) ? `nct:${id.trim()}` : id);
4741
+ if (p.error) return { error: p.error };
4742
+ if (p.type !== "nct") return { error: `not a trial ID: ${p.requested}` };
4743
+ const paths = entryPaths(FH_LIBRARY_DIR, p.type, p.id);
4744
+ if (!entryExists(paths)) {
4745
+ const r = await addEntry(p.requested);
4746
+ const s = JSON.parse(r.content[0].text);
4747
+ if (s.error) return { error: s.error };
4748
+ }
4749
+ return { paths, requested: p.requested };
4750
+ };
4751
+ server2.tool(
4752
+ "library_clinicaltrials_get_study_record",
4753
+ 'One ClinicalTrials.gov trial as a review needs it: titles, status and why stopped, phase, design, enrollment, dates, sponsor, conditions, interventions, primary outcomes, eligibility, sites, result publications as pmid: IDs, has_results, brief summary. From the stored record; a missing trial is added first. The whole record: library_read("nct:\u2026").',
4754
+ { nct: z.string().describe('The NCT ID: "nct:05173896", "NCT05173896" or the eight digits.') },
4755
+ async ({ nct }) => {
4756
+ const t0 = Date.now();
4757
+ const e = await nctEntry(nct);
4758
+ if ("error" in e) {
4759
+ log("library_trials", nct, `record error: ${e.error}`);
4760
+ return { content: [{ type: "text", text: compact2({ error: e.error }) }], isError: true };
4761
+ }
4762
+ const raw = e.paths.fulltextRaw("json");
4763
+ const r = existsSync11(raw) ? recordSummary(readFileSync11(raw, "utf8")) : null;
4764
+ if (!r) {
4765
+ log("library_trials", e.requested, "record error: no stored record");
4766
+ return { content: [{ type: "text", text: compact2({ error: "no stored record" }) }], isError: true };
4767
+ }
4768
+ log("library_trials", e.requested, `record ${((Date.now() - t0) / 1e3).toFixed(1).padStart(4, "0")}s ${r.status ?? ""}${r.has_results ? " results" : ""} | ${r.title}`);
4769
+ return { content: [{ type: "text", text: leanJson(r) }] };
4770
+ }
4771
+ );
4772
+ server2.tool(
4773
+ "library_clinicaltrials_get_study_results",
4774
+ 'The posted results of one ClinicalTrials.gov trial \u2014 participant flow, baseline, every outcome measure as a table, adverse events \u2014 as Markdown from the stored record, capped at max_chars, outcome titles listed; library_read("nct:\u2026", section: <title>) returns one measure whole. has_results false when the registry holds none. A missing trial is added first.',
4775
+ { nct: z.string().describe('The NCT ID: "nct:05173896", "NCT05173896" or the eight digits.'), max_chars: z.number().int().min(200).max(6e4).default(6e3).describe("Cap for the results text (default 6000).") },
4776
+ async ({ nct, max_chars }) => {
4777
+ const e = await nctEntry(nct);
4778
+ if ("error" in e) {
4779
+ log("library_trials", nct, `results error: ${e.error}`);
4780
+ return { content: [{ type: "text", text: compact2({ error: e.error }) }], isError: true };
4781
+ }
4782
+ const md = existsSync11(e.paths.fulltextMd) ? readFileSync11(e.paths.fulltextMd, "utf8") : "";
4783
+ const r = resultsText(e.requested.replace(/^nct:/, "NCT"), md, max_chars);
4784
+ log("library_trials", e.requested, `results ${r.has_results ? `${r.outcomes.length} outcomes${r.truncated ? " truncated" : ""}` : "none"}`);
4785
+ return { content: [{ type: "text", text: leanJson(r) }] };
4786
+ }
4787
+ );
4483
4788
  server2.tool(
4484
4789
  "library_version",
4485
4790
  "Get the running fh-library MCP server's package name and version, and the resolved library directory. Useful to confirm which build is loaded.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fh-library",
3
- "version": "0.2.7",
3
+ "version": "0.2.11",
4
4
  "mcpName": "io.github.forever-healthy/fh-library",
5
5
  "description": "MCP Server that builds a local Markdown library of the scientific papers cited by evidence reviews — fetches metadata, abstract and full text per PubMed ID through a configurable cascade of retrieval services",
6
6
  "type": "module",