ai4l-library 0.1.3 → 0.1.4

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,14 @@
1
1
  # AI4L Library — Change Log
2
2
 
3
+ ### v0.1.4 — 2026-11-07
4
+
5
+ * `.env` is the whole configuration: every value an environment variable, the tier set and the defaults built in. `library/config.json` and `AI4L_LIBRARY_CONFIG` are gone, and with them `${NAME}` references and the per-tier `description`.
6
+ * Renamed, no aliases — `CONTACT_EMAIL` → `AI4L_LIBRARY_CONTACT_EMAIL`; `ELSEVIER_API_KEY` → `AI4L_LIBRARY_ELSEVIER_API_KEY`; `WILEY_TDM_TOKEN` → `AI4L_LIBRARY_WILEY_TDM_TOKEN`; `CORE_API_KEY` → `AI4L_LIBRARY_CORE_API_KEY`; `PERPLEXITY_URL` / `PERPLEXITY_API_KEY` / `PERPLEXITY_MODEL` → `AI4L_LIBRARY_VERIFY_URL` / `_API_KEY` / `_MODEL`; config.json's `threshold`, `timeout_seconds`, `retry_after_days`, `retry_blocked`, `refresh_after_days`, `download.headless` → `AI4L_LIBRARY_VERIFY_THRESHOLD`, `AI4L_LIBRARY_VERIFY_TIMEOUT_SECS`, `AI4L_LIBRARY_RETRY_AFTER_DAYS`, `AI4L_LIBRARY_RETRY_BLOCKED`, `AI4L_LIBRARY_REFRESH_AFTER_DAYS`, `AI4L_LIBRARY_BROWSER_HEADLESS`. `NCBI_API_KEY`, `BRIGHTDATA_API_TOKEN`, `BRIGHTDATA_ZONE` and `AI4L_LIBRARY_BROWSER_n` unchanged.
7
+ * `AI4L_LIBRARY_TIERS` is the whole ladder: the listed tiers in exactly that order, a tier not listed is off (an unlisted tier no longer runs after the listed ones).
8
+ * One proxy tier, `proxy`, instead of url tiers: `AI4L_LIBRARY_PROXY_URL` (template with `{doi}` `{pmid}` `{pmcid}`) and `AI4L_LIBRARY_PROXY_HEADERS`; `PROXY_TOKEN` and the `my-proxy` example are gone.
9
+ * `library_info`: `config` replaces `tiers_config` — the cascade's settings, problems with `AI4L_LIBRARY_TIERS`, and per variable where its value came from (environment, `.env`, default), never the value; `tiers` in run order without descriptions (the README describes them).
10
+ * The container's working directory is `/library`; the configuration arrives as its environment.
11
+
3
12
  ### v0.1.3 — 2026-11-07
4
13
 
5
14
  * IDs: `pmid:` (bare digits count as one), `doi:` for papers PubMed does not index (a DOI PubMed indexes is stored under its PMID and copied under the DOI, so every tool takes either), `dailymed:<set ID>` drug labels, `nct:` trial records.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- ![Version 0.1.3](https://img.shields.io/badge/Version-0.1.3-green.svg)
1
+ ![Version 0.1.3](https://img.shields.io/badge/Version-0.1.4-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-ai4l--library-cb3837.svg)](https://www.npmjs.com/package/ai4l-library)
@@ -11,14 +11,14 @@
11
11
 
12
12
  A [Model Context Protocol](https://modelcontextprotocol.io) server that builds a local library of the scientific papers an evidence review cites. Given a paper ID (`type:value`, e.g. `pmid:30955454`), it fetches the paper's metadata, abstract and — where obtainable — full text through a configurable cascade of retrieval services (PubMed, PMC, Europe PMC, Unpaywall, OpenAlex, …), converts everything to Markdown, and stores it under a mounted `library/` directory. Nothing flows through the model's context: the server writes to disk itself and returns only a small status per call.
13
13
 
14
- **Out of the box, keyless, everywhere:** PubMed, PMC, Europe PMC, Unpaywall and OpenAlex, a plain fetch, and search inside stored papers. Everything stronger is an endpoint or a key the deployment adds in `config.json` and `.env` — browser rungs (Playwright servers, a real Chrome), Bright Data, CORE, the Elsevier and Wiley text-mining tokens, your own url tiers — each with an honest fallback when absent; `library_info` shows what is configured.
14
+ **Out of the box, keyless, everywhere:** PubMed, PMC, Europe PMC, Unpaywall and OpenAlex, a plain fetch, and search inside stored papers. Everything stronger is an endpoint or a key the deployment adds in `.env` — browser rungs (Playwright servers, a real Chrome), Bright Data, CORE, the Elsevier and Wiley text-mining tokens, your own proxy — each with an honest fallback when absent; `library_info` shows what is configured.
15
15
 
16
- **Status: v0.2, early development.** Four ID types: papers by `pmid:` and, when PubMed does not index them, by `doi:` (record from Crossref); drug labels by `dailymed:`; trial records by `nct:`. Every paper entry gets metadata and abstract from PubMed (or Crossref and OpenAlex); the full-text cascade then tries PMC (JATS XML), the publishers' text-and-data-mining links (Crossref; Elsevier and Wiley with a token), and open-access copies located via Unpaywall and OpenAlex (PDF, XML or HTML). A full text counts only after checks: the paper's title or DOI in the text, 800+ words of running prose (a publisher's abstract page has 200–600), Methods and Results or a sectioned body, no wall page. `library_read` serves outline, section or search passages for claim verification. Papers whose PMC XML body is withheld by the publisher are reachable only through open-access copies or a licensed url tier; PMC's web pages are for humans and are not scraped. What follows: better PDF layout.
16
+ **Status: v0.2, early development.** Four ID types: papers by `pmid:` and, when PubMed does not index them, by `doi:` (record from Crossref); drug labels by `dailymed:`; trial records by `nct:`. Every paper entry gets metadata and abstract from PubMed (or Crossref and OpenAlex); the full-text cascade then tries PMC (JATS XML), the publishers' text-and-data-mining links (Crossref; Elsevier and Wiley with a token), and open-access copies located via Unpaywall and OpenAlex (PDF, XML or HTML). A full text counts only after checks: the paper's title or DOI in the text, 800+ words of running prose (a publisher's abstract page has 200–600), Methods and Results or a sectioned body, no wall page. `library_read` serves outline, section or search passages for claim verification. Papers whose PMC XML body is withheld by the publisher are reachable only through open-access copies or a licensed proxy; PMC's web pages are for humans and are not scraped. What follows: better PDF layout.
17
17
 
18
18
 
19
19
  ## Tools
20
20
 
21
- * `library_add(id)` → build or complete the library entry for one ID — a paper `pmid:<PMID>` or `doi:<DOI>` (see "Papers without a PMID"), a drug label `dailymed:<set ID>` (see "Drug labels"), or a trial record `nct:<8 digits>` (see "Clinical trial records"); returns its status (level `full_text` / `abstract` / `title` — what the entry holds, `title` when there is neither a full text nor abstract text — retrieving tier, `coi` and `funding` (PubMed's record, else the paper's own text), `citable` with `comment` — the one flag to check before citing, from `retracted` and PubMed's correction notices in `corrections` — `has_abstract` — false for title-only PubMed records such as letters and editorials — files, failed tiers in `attempts`). Idempotent: a `full_text` entry is never refetched, an `abstract` or `title` entry retries only tiers whose earlier failure was transient and past the cooldown; an entry whose PubMed record is older than `refresh_after_days` gets the record re-read for new notices. One ID per call.
21
+ * `library_add(id)` → build or complete the library entry for one ID — a paper `pmid:<PMID>` or `doi:<DOI>` (see "Papers without a PMID"), a drug label `dailymed:<set ID>` (see "Drug labels"), or a trial record `nct:<8 digits>` (see "Clinical trial records"); returns its status (level `full_text` / `abstract` / `title` — what the entry holds, `title` when there is neither a full text nor abstract text — retrieving tier, `coi` and `funding` (PubMed's record, else the paper's own text), `citable` with `comment` — the one flag to check before citing, from `retracted` and PubMed's correction notices in `corrections` — `has_abstract` — false for title-only PubMed records such as letters and editorials — files, failed tiers in `attempts`). Idempotent: a `full_text` entry is never refetched, an `abstract` or `title` entry retries only tiers whose earlier failure was transient and past the cooldown; an entry whose PubMed record is older than `AI4L_LIBRARY_REFRESH_AFTER_DAYS` gets the record re-read for new notices. One ID per call.
22
22
  * `library_status(id)` → read-only check of presence and level; never fetches
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`), 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 joins two findings 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), **na** (only the abstract is in the library and does not state it) or **error**; `basis` full_text or abstract. See "Verifying claims" below.
@@ -35,63 +35,40 @@ IDs follow the `type:value` form: `pmid:`, `doi:`, `dailymed:`, `nct:`; bare dig
35
35
  | `inbox` | a file you downloaded yourself: drop it into `<library>/inbox/` with the PMID in its name (`39025385.pdf`; PDF, XML or HTML) and the next `library_add` for that ID converts it into the entry and removes it from the inbox. For publishers that hand a PDF to a browser but nothing to a script | PMID |
36
36
  | `pmc` | full text as JATS XML via E-utilities; counts only with a `<body>` | PMCID |
37
37
  | `europepmc` | Europe PMC's record: its own full text (JATS XML for the open-access subset, the rendered PDF for everything it holds, author manuscripts included) and the free links it lists | – |
38
- | `tdm` | the publishers' text-and-data-mining links from Crossref; Elsevier (`X-ELS-APIKey`) and Wiley (`Wiley-TDM-Client-Token`) need a token, configured at the tier; full text for open-access and entitled articles by the front door | DOI |
38
+ | `tdm` | the publishers' text-and-data-mining links from Crossref; Elsevier (`X-ELS-APIKey`) and Wiley (`Wiley-TDM-Client-Token`) need a token; full text for open-access and entitled articles by the front door | DOI; `AI4L_LIBRARY_ELSEVIER_API_KEY`, `AI4L_LIBRARY_WILEY_TDM_TOKEN` |
39
39
  | `unpaywall` | locates open-access copies (publisher, repositories) and downloads them, best first | DOI |
40
40
  | `openalex` | locates open-access copies and downloads them, second opinion; skips URLs the previous tier already tried | PMID or DOI |
41
- | `core` | CORE (core.ac.uk), the aggregator of repository copies, by DOI; skipped without a key | `CORE_API_KEY` (free) |
41
+ | `core` | CORE (core.ac.uk), the aggregator of repository copies, by DOI; skipped without a key | `AI4L_LIBRARY_CORE_API_KEY` (free) |
42
+ | `proxy` | your own service: an institutional proxy, a licensed service, a local mirror — a URL template filled from the paper's IDs (below) | `AI4L_LIBRARY_PROXY_URL`; the IDs the template uses |
42
43
 
43
- A located copy is fetched up a ladder, the way AI4L verifies URLs: a plain **fetch** (no browser) with a desktop Chrome User-Agent; then each configured **browser** rung in order (a Playwright server, or a real Google Chrome attached over the DevTools protocol, `browser/chrome.md`) (`download.browsers` in `config.json`: Playwright `run-server` endpoints such as `ws://127.0.0.1:8932`, typically one next to the server and one on a residential line; `browser/compose.yaml` runs one, headed under Xvfb on Microsoft's Playwright image; `"local"` uses this host's own Playwright Chromium) when the plain fetch met a 403, a bot wall or a script-rendered landing page; then the Bright Data **unlocker** when every browser was refused. No browser ships in the package and none is downloaded at run time. Nothing disguises the automation: a site that blocks automated browsers by policy stays blocked, and the attempt says so.
44
+ A located copy is fetched up a ladder, the way AI4L verifies URLs: a plain **fetch** (no browser) with a desktop Chrome User-Agent; then each configured **browser** rung in order (a Playwright server, or a real Google Chrome attached over the DevTools protocol, `browser/chrome.md`) (`AI4L_LIBRARY_BROWSER_1`, `_2`, `_3`: Playwright `run-server` endpoints such as `ws://127.0.0.1:8932`, typically one next to the server and one on a residential line; `browser/compose.yaml` runs one, headed under Xvfb on Microsoft's Playwright image; `"local"` uses this host's own Playwright Chromium) when the plain fetch met a 403, a bot wall or a script-rendered landing page; then the Bright Data **unlocker** when every browser was refused. No browser ships in the package and none is downloaded at run time. Nothing disguises the automation: a site that blocks automated browsers by policy stays blocked, and the attempt says so.
44
45
 
45
- | *url tiers* | user-configured: a URL template filled from the paper's IDs — an institutional proxy, a licensed service, a local mirror | the IDs the template uses |
46
46
 
47
47
  Downloads are plain HTTP with a desktop Chrome User-Agent (API calls identify the library with the contact address), PDFs first and repository copies before publisher sites; PDF (text only, no tables), JATS XML or HTML are converted to Markdown. NCBI/PMC web URLs are never fetched (PMC content comes through E-utilities). When a publisher answers a located open-access copy with a 403 or a bot wall, the page is requested once more through the [Bright Data](https://brightdata.com) Web Unlocker — the same last-resort tier AI4L's URL verification uses — if `BRIGHTDATA_API_TOKEN` is set (`BRIGHTDATA_ZONE`, default `mcp_unlocker`). Without the token the plain failure stands and is retried later.
48
48
 
49
- ### Configuring tiers
49
+ ### Configuring
50
50
 
51
- Which tiers run, and in which order, comes from `config.json` in the library directory (or the file named by `AI4L_LIBRARY_CONFIG`). `retry_after_days` is the cooldown before a transient tier failure is retried; `refresh_after_days` (default 30) is the age of an entry's PubMed record after which `library_add` re-reads it for new correction notices and retractions. A tdm link behind a token that is not configured is recorded as `needs elsevier token` and not re-checked; after adding the token, remove those attempts from the entries (or re-add the papers with `retry_blocked` set) to give them a go. `retry_blocked` (default false) decides whether a browser challenge that did not clear, or a Bright Data refusal, is final: with the same method tomorrow ends the same way, so set it true only after a change of method (a new browser rung, a new service) to give those papers another go. `description` on an entry is free text (shown by `library_info`), the comment JSON lacks. `contact_email` is the one address sent to every service (NCBI `tool`/`email`, Unpaywall and OpenAlex polite pools, Crossref, the User-Agent). Credentials go at the tier entry: `tokens` for `tdm` (`elsevier`, `wiley`), `headers` for url tiers. A value `${NAME}` is the environment variable `NAME` — the tracked default file references `${CONTACT_EMAIL}`, `${ELSEVIER_API_KEY}` and `${WILEY_TDM_TOKEN}`, never a literal secret. Put the values in `.env` in the working directory (`.env.example` lists them all) (the project root; `AI4L_LIBRARY_ENV` names another file; without either, `.env` in the library directory, next to `config.json`): it is loaded at startup, and variables already set in the environment win. File order is run order; `enabled` switches a tier on or off, a tier not listed is off; an entry with `url` is a user tier with placeholders `{doi}`, `{pmid}`, `{pmcid}`:
51
+ `.env` is the whole configuration: every value the library reads is an environment variable, listed and explained in [`.env.example`](.env.example) — copy it to `.env` next to the library and fill in what you use. It is loaded at startup from the working directory (or the file `AI4L_LIBRARY_ENV` names, or `.env` in the library directory); a variable already set in the environment wins, and an unset one takes the built-in default. The library itself is `library/` under the working directory (`/library` when that exists, as in the container); keep it beside `.env`, gitignored.
52
52
 
53
- ```json
54
- {
55
- "contact_email": "${CONTACT_EMAIL}",
56
- "retry_after_days": 1,
57
- "refresh_after_days": 30,
58
- "verify": { "base_url": "${PERPLEXITY_URL}", "api_key": "${PERPLEXITY_API_KEY}", "model": "${PERPLEXITY_MODEL}", "threshold": 0.5, "timeout_seconds": 60 },
59
- "tiers": [
60
- { "name": "inbox", "enabled": true },
61
- { "name": "pmc", "enabled": true },
62
- { "name": "europepmc", "enabled": true },
63
- { "name": "tdm", "enabled": true, "tokens": { "elsevier": "${ELSEVIER_API_KEY}", "wiley": "${WILEY_TDM_TOKEN}" } },
64
- { "name": "unpaywall", "enabled": true },
65
- { "name": "openalex", "enabled": true },
66
- { "name": "core", "enabled": true, "api_key": "${CORE_API_KEY}" },
67
- { "name": "my-proxy", "enabled": false, "url": "https://proxy.example.org/login?url=https://doi.org/{doi}",
68
- "headers": { "Authorization": "Bearer ${PROXY_TOKEN}" } }
69
- ]
70
- }
71
- ```
53
+ * **Which tiers run, in which order:** `AI4L_LIBRARY_TIERS=pmc,tdm,unpaywall,openalex` — exactly these, in this order; a tier not listed is off. Unset, every tier runs in the order of the table above, the proxy last.
54
+ * **Keys:** `AI4L_LIBRARY_ELSEVIER_API_KEY` and `AI4L_LIBRARY_WILEY_TDM_TOKEN` for `tdm`, `AI4L_LIBRARY_CORE_API_KEY` for `core`, `NCBI_API_KEY` (10 instead of 3 requests/s), `BRIGHTDATA_API_TOKEN` and `BRIGHTDATA_ZONE` for the unlocker. `AI4L_LIBRARY_CONTACT_EMAIL` is the one address sent to every service (NCBI `tool`/`email`, Unpaywall and OpenAlex polite pools, Crossref, the User-Agent).
55
+ * **Retries:** `AI4L_LIBRARY_RETRY_AFTER_DAYS` (default 1) is the cooldown before a transient tier failure is retried; `AI4L_LIBRARY_REFRESH_AFTER_DAYS` (30) the age of an entry's PubMed record after which `library_add` re-reads it for new correction notices and retractions. A tdm link behind a token that is not configured is recorded as `needs elsevier token` and not re-checked; after adding the token, remove those attempts from the entries to give them a go. `AI4L_LIBRARY_RETRY_BLOCKED` (false) decides whether a browser challenge that did not clear, or a Bright Data refusal, is final: with the same method tomorrow ends the same way, so set it true only after a change of method (a new browser rung, a new service).
56
+ * **Browsers and verify:** `AI4L_LIBRARY_BROWSER_1..3` and `AI4L_LIBRARY_BROWSER_HEADLESS` (above); `AI4L_LIBRARY_VERIFY_*` ("Verifying claims").
72
57
 
73
- The repo ships a default `library/config.json` (tracked; everything else under `library/` is ignored) it ends with a disabled `my-proxy` url tier (an institutional proxy by DOI, with a token header) showing the shape; any number of url tiers may follow, each with its own name. Without the file every built-in tier runs in the order above. `AI4L_LIBRARY_TIERS=pmc,tdm,unpaywall,openalex` (environment) overrides both: exactly these, in this order. Credentials never go into the file; they come from the environment. `library_info` reports the resolved tiers and where they came from. Optional environment: `NCBI_API_KEY` (10 instead of 3 requests/s), `CONTACT_EMAIL` (when config.json references it), `BRIGHTDATA_API_TOKEN` / `BRIGHTDATA_ZONE` (unlocker escalation, above). `AI4L_LIBRARY_DIR` sets the library directory (default `./library`, or `/library` when mounted).
58
+ `library_info` reports the tiers in run order and, per variable, whether its value came from the environment, `.env` or the default — never the value.
74
59
 
75
- **Using the url tier.** The url tier fetches a paper from an address you give it — an institutional proxy, a licensed service, a local mirror. You use it by adding an entry with a `url` to `tiers`; it runs at its position in the list, like the other tiers, and may be used several times, each entry with its own `name`:
60
+ **The proxy tier.** One service of your own — an institutional proxy, a licensed service, a local mirror — fetched at its position in the ladder like any other tier:
76
61
 
77
- ```json
78
- {
79
- "name": "my-proxy",
80
- "enabled": false,
81
- "description": "Example of a url tier: an institutional proxy, by DOI. Placeholders {doi} {pmid} {pmcid}; headers for credentials, ${NAME} from .env.",
82
- "url": "https://proxy.example.org/login?url=https://doi.org/{doi}",
83
- "headers": { "Authorization": "Bearer ${PROXY_TOKEN}" }
84
- }
62
+ ```sh
63
+ AI4L_LIBRARY_PROXY_URL=https://proxy.example.org/login?url=https://doi.org/{doi}
64
+ AI4L_LIBRARY_PROXY_HEADERS=Authorization: Bearer <token>
85
65
  ```
86
66
 
87
- This is the entry the shipped `library/config.json` ends with, disabled: set `url` to your proxy, put the token in `.env` as `PROXY_TOKEN`, and set `enabled` to `true`.
88
-
89
- * `url` — the template. Placeholders: `{doi}` (`10.1056/NEJMoa042274`), `{pmid}` (`15516696`), `{pmcid}` (`PMC6728809`, with its prefix). Each is URL-encoded when filled — the slash in a DOI becomes `%2F` — so put a placeholder in a path segment or a query value, as in both examples. A template may use several. The tier applies only to a paper for which every placeholder it uses is known; otherwise it is skipped as `no doi` (or `no pmcid`, …), a permanent reason. A template without any placeholder never runs (`template has no placeholder`).
90
- * `headers` — optional request headers, for credentials (`Authorization`, an API-key header, a cookie).
91
- * `${NAME}` in `url` or a header value is the environment variable `NAME` (from `.env`), so the tracked file never holds a token; an unset variable resolves to an empty string.
92
- * `enabled` — `false` keeps the entry but skips it; `description` — free text shown by `library_info`.
67
+ * The URL is a template. Placeholders: `{doi}` (`10.1056/NEJMoa042274`), `{pmid}` (`15516696`), `{pmcid}` (`PMC6728809`, with its prefix). Each is URL-encoded when filled — the slash in a DOI becomes `%2F` — so put a placeholder in a path segment or a query value. A template may use several. The tier applies only to a paper for which every placeholder it uses is known; otherwise it is skipped as `no doi` (or `no pmcid`, …), a permanent reason. A template without any placeholder never runs (`template has no placeholder`).
68
+ * The headers are optional request headers for credentials (`Authorization`, an API-key header, a cookie), as `Name: value; Name: value`.
69
+ * Unset URL: no proxy tier. `AI4L_LIBRARY_TIERS` places `proxy` in the order like any built-in tier.
93
70
 
94
- **What the url tier expects back.** The tier sends one GET to the filled template, with a desktop Chrome User-Agent and the entry's `headers`; redirects are followed. The service answers the paper as one of two formats, read from the response bytes, not the URL or the Content-Type:
71
+ **What the proxy tier expects back.** The tier sends one GET to the filled template, with a desktop Chrome User-Agent and the headers; redirects are followed. The service answers the paper as one of two formats, read from the response bytes, not the URL or the Content-Type:
95
72
 
96
73
  * **PDF** — the body starts with `%PDF-`. It needs a text layer: under 500 characters per page counts as a scan (`scanned pdf`).
97
74
  * **XML** — JATS (as PMC delivers it) or Elsevier's full-text XML, with a `<body>`; recognised by `<?xml` or `<article` at the start.
@@ -100,17 +77,17 @@ Any other body — an HTML page, JSON, plain text, an empty response — is the
100
77
 
101
78
  | response | recorded as | retried |
102
79
  |---|---|---|
103
- | 403, 429, 5xx, timeout, network error | `HTTP 403`, `HTTP 429`, `HTTP 503`, `timeout`, … | after `retry_after_days` |
104
- | 404 or another 4xx | `HTTP 404`, … — with the service's message when its body carries one: `HTTP 404 — 404 Not Found GET /10.1056%2FNEJMoa042274` | after `retry_after_days` |
105
- | 200, but neither PDF nor XML | `service: <message>` | after `retry_after_days` |
80
+ | 403, 429, 5xx, timeout, network error | `HTTP 403`, `HTTP 429`, `HTTP 503`, `timeout`, … | after the cooldown (`AI4L_LIBRARY_RETRY_AFTER_DAYS`) |
81
+ | 404 or another 4xx | `HTTP 404`, … — with the service's message when its body carries one: `HTTP 404 — 404 Not Found GET /10.1056%2FNEJMoa042274` | after the cooldown (`AI4L_LIBRARY_RETRY_AFTER_DAYS`) |
82
+ | 200, but neither PDF nor XML | `service: <message>` | after the cooldown (`AI4L_LIBRARY_RETRY_AFTER_DAYS`) |
106
83
  | XML without a body | `xml without body` | never |
107
84
  | another document (title and DOI absent) | `neither title nor DOI in text (…)` | never |
108
85
  | no article body | `no article body (pdf, 342 words, …)` | never |
109
86
  | a PDF without a text layer | `scanned pdf` | never |
110
87
 
111
- The url tier is not escalated to the browser rungs or Bright Data: it is the service's own answer that counts. NCBI and PMC web URLs are never fetched (the `pmc` tier covers them), and a URL already tried for the paper in the same cascade run is skipped.
88
+ The proxy tier is not escalated to the browser rungs or Bright Data: it is the service's own answer that counts. NCBI and PMC web URLs are never fetched (the `pmc` tier covers them), and a URL already tried for the paper in the same cascade run is skipped.
112
89
 
113
- Every full text is checked before it counts: title present, methods and results present (or a substantial sectioned body), not an error, login, paywall or bot-wall page. The original (XML, HTML or PDF) is stored next to the Markdown. A failed tier is recorded in the entry's `attempts` with reason and date, e.g. `unpaywall (HTTP 403: nature.com) 2026-10-26`. **Retries:** a permanent reason (no PMCID, no body, closed, no open-access copy, content checks failed) is never retried; a transient one (timeout, network error, HTTP 403/429/5xx, wall page) is retried by the next `library_add` once `retry_after_days` (config.json, default 1) have passed, replacing the old entry. A `full_text` entry is never touched again. A failed PubMed fetch writes no entry at all, so it is simply retried next time.
90
+ Every full text is checked before it counts: title present, methods and results present (or a substantial sectioned body), not an error, login, paywall or bot-wall page. The original (XML, HTML or PDF) is stored next to the Markdown. A failed tier is recorded in the entry's `attempts` with reason and date, e.g. `unpaywall (HTTP 403: nature.com) 2026-10-26`. **Retries:** a permanent reason (no PMCID, no body, closed, no open-access copy, content checks failed) is never retried; a transient one (timeout, network error, HTTP 403/429/5xx, wall page) is retried by the next `library_add` once `AI4L_LIBRARY_RETRY_AFTER_DAYS` (default 1) have passed, replacing the old entry. A `full_text` entry is never touched again. A failed PubMed fetch writes no entry at all, so it is simply retried next time.
114
91
 
115
92
  ### Verifying claims
116
93
 
@@ -132,10 +109,10 @@ Every full text is checked before it counts: title present, methods and results
132
109
 
133
110
  | verdict | meaning |
134
111
  |---|---|
135
- | `pass` | the paper states the finding in substance: `score` reached `threshold` (default 0.5) |
112
+ | `pass` | the paper states the finding in substance: `score` reached the threshold (default 0.5) |
136
113
  | `fail` | the paper's full text was read and does not state it |
137
114
  | `na` | only the paper's abstract is in the library, and that does not state it — the finding may be in the full text (drop it into the inbox and verify again); or only its title is there (PubMed has no abstract for it, no full text was found), in which case nothing is judged and `score` is null |
138
- | `error` | the call could not run: a refused claim (empty, too short, a fragment, naming a source, compound), paper not in the library, no model configured, the model failing or not answering within `timeout_seconds` |
115
+ | `error` | the call could not run: a refused claim (empty, too short, a fragment, naming a source, compound), paper not in the library, no model configured, the model failing or not answering within the timeout |
139
116
 
140
117
  "In substance": wording may differ, a number may be rounded or derived from the paper's numbers; a changed number, an opposite direction, another population or intervention is not backed. A paper is read whole; nothing of it enters the caller's context, and the model returns one probability, no quotes and no prose. For a look at the text behind a verdict there is `library_read`; on the hosted model real findings score 0.95 and up and changed numbers 0.20 and under, so a `pass` under 0.7 or a `fail` over 0.3 is the one worth that look.
141
118
 
@@ -148,25 +125,25 @@ The verdict comes from [Perplexity's decision model](https://docs.perplexity.ai/
148
125
  * **Hosted** at `api.perplexity.ai`: model `pplx-decider-v1.2`, a whole paper in about two seconds, 2 cents per million input tokens, needs a key from [console.perplexity.ai](https://console.perplexity.ai). The paper text leaves the machine.
149
126
  * **Local**, the [Decider for Mac](https://docs.perplexity.ai/docs/decisions/decider-for-mac) app on a Mac in the network: model `pplx-decider-flash-v1.2`, no key, text stays local; 32k-token requests, so long papers are judged in windows cut at headings. Use the Mac's IP in the URL, not a host name, with "Allow Network Access" on, and leave its `max_tokens` at 32768 (it serves one request at a time and a larger request blocks it for minutes).
150
127
 
151
- Configure `"verify": {"base_url": "${PERPLEXITY_URL}", "api_key": "${PERPLEXITY_API_KEY}", "model": "${PERPLEXITY_MODEL}", "threshold": 0.5, "timeout_seconds": 60}` in `config.json`; hosted or local is then three lines in `.env`: `PERPLEXITY_URL`, `PERPLEXITY_MODEL`, `PERPLEXITY_API_KEY`. `threshold` is the probability at or above which a paper counts as backing the claim; `timeout_seconds` caps one model request; `window_chars` (optional) is the paper length above which it is judged in windows, default 800000 characters hosted (a whole paper) and 90000 for the Mac app. Without the block or the URL the tool answers `error`. Self-test of an endpoint, the app's own example:
128
+ Hosted or local is three lines in `.env`: `AI4L_LIBRARY_VERIFY_URL`, `AI4L_LIBRARY_VERIFY_MODEL`, `AI4L_LIBRARY_VERIFY_API_KEY`. `AI4L_LIBRARY_VERIFY_THRESHOLD` (0.5) is the probability at or above which a paper counts as backing the claim; `AI4L_LIBRARY_VERIFY_TIMEOUT_SECS` (60) caps one model request. A paper longer than 800000 characters hosted (a whole paper), 90000 on the Mac app, is judged in windows. Without the URL the tool answers `error`. Self-test of an endpoint, the app's own example:
152
129
 
153
130
  ```sh
154
131
  curl -s http://<ip>:8080/v1/decisions -H 'Content-Type: application/json' -d '{"model":"pplx-decider-flash-v1.2","state":"I was charged twice for my order.","questions":{"team":{"type":"choice","criteria":{"billing":null,"shipping":null,"technical":null}}}}'
155
132
  ```
156
133
 
157
- **Papers without a PMID.** A review may cite a paper PubMed does not index by its DOI (`https://doi.org/<DOI>`); `library_add("doi:10.5812/asjsm.110753")` takes it. If PubMed does index the DOI, the call builds that paper's `pmid:` entry and stores a copy of it under `doi:` — the same files, renamed, with `pmid` set in its meta — so the DOI resolves on disk: every later call answers from the `doi:` entry without asking PubMed (5 of the 101 DOIs evipedia cites). Each `library_add` of such a DOI completes the `pmid:` entry (retries, record refresh) and copies it again, so the copy is as current as the original. A mapped paper counts once under `pmid` and once under `doi` in `library_info`. Otherwise the record comes from [Crossref](https://www.crossref.org/) — or, for a DOI another registry holds (DataCite, mEDRA, …), from doi.org's content negotiation as CSL JSON, stored as `-csl.json`: title, authors, journal, year, the abstract — Crossref's when the publisher deposited one, else OpenAlex's (of the 97 DOI-only papers evipedia cites, Crossref has 55 abstracts, OpenAlex 86; 11 have none anywhere and stay title-only unless a full text is found) — the funders as `funding`, and Crossref's update notices — corrections, retractions, expressions of concern — as `corrections`, so `citable` works as for a PubMed paper. The status has `pmid` null, the abstract's head line says "not in PubMed", `retrieved_by` names the source of what the entry holds — `crossref`, `doi.org` or `openalex` for the abstract, `crossref` or `doi.org` for a title alone, the tier once a full text is found; the full-text cascade then runs on the DOI (`unpaywall`, `openalex`, `tdm`, `core`, url tiers; `pmc`, `europepmc` and `inbox` need a PMID). An entry older than `refresh_after_days` gets its Crossref record re-read for new notices. Entries live under `doi/<hash 2>/<hash 4>/<slug>/`, the slug being the DOI in lowercase with every run of other characters than letters, digits, `.` and `-` as `_` (`doi-10.5812_asjsm.110753`), the Crossref record as `-crossref.json`. `library_status`, `library_read` and `library_verify` take any DOI `library_add` took: they read its `doi:` entry, the copy or the Crossref one, and never look a DOI up. Measured on evipedia's 915 live reviews (2026-11-06): 87 cite 101 papers by DOI only, against 34,378 PubMed citations.
134
+ **Papers without a PMID.** A review may cite a paper PubMed does not index by its DOI (`https://doi.org/<DOI>`); `library_add("doi:10.5812/asjsm.110753")` takes it. If PubMed does index the DOI, the call builds that paper's `pmid:` entry and stores a copy of it under `doi:` — the same files, renamed, with `pmid` set in its meta — so the DOI resolves on disk: every later call answers from the `doi:` entry without asking PubMed (5 of the 101 DOIs evipedia cites). Each `library_add` of such a DOI completes the `pmid:` entry (retries, record refresh) and copies it again, so the copy is as current as the original. A mapped paper counts once under `pmid` and once under `doi` in `library_info`. Otherwise the record comes from [Crossref](https://www.crossref.org/) — or, for a DOI another registry holds (DataCite, mEDRA, …), from doi.org's content negotiation as CSL JSON, stored as `-csl.json`: title, authors, journal, year, the abstract — Crossref's when the publisher deposited one, else OpenAlex's (of the 97 DOI-only papers evipedia cites, Crossref has 55 abstracts, OpenAlex 86; 11 have none anywhere and stay title-only unless a full text is found) — the funders as `funding`, and Crossref's update notices — corrections, retractions, expressions of concern — as `corrections`, so `citable` works as for a PubMed paper. The status has `pmid` null, the abstract's head line says "not in PubMed", `retrieved_by` names the source of what the entry holds — `crossref`, `doi.org` or `openalex` for the abstract, `crossref` or `doi.org` for a title alone, the tier once a full text is found; the full-text cascade then runs on the DOI (`unpaywall`, `openalex`, `tdm`, `core`, `proxy`; `pmc`, `europepmc` and `inbox` need a PMID). An entry older than `AI4L_LIBRARY_REFRESH_AFTER_DAYS` gets its Crossref record re-read for new notices. Entries live under `doi/<hash 2>/<hash 4>/<slug>/`, the slug being the DOI in lowercase with every run of other characters than letters, digits, `.` and `-` as `_` (`doi-10.5812_asjsm.110753`), the Crossref record as `-crossref.json`. `library_status`, `library_read` and `library_verify` take any DOI `library_add` took: they read its `doi:` entry, the copy or the Crossref one, and never look a DOI up. Measured on evipedia's 915 live reviews (2026-11-06): 87 cite 101 papers by DOI only, against 34,378 PubMed citations.
158
135
 
159
136
  PubMed's book records — National Academies reports, NTP monographs, StatPearls chapters in NCBI Bookshelf — are read as papers too: the chapter or book title, the authors (else the editors), the publisher in place of the journal, publication type `Book`.
160
137
 
161
- **Drug labels.** `library_add("dailymed:<set ID>")` stores the current FDA prescribing information of one product from [DailyMed](https://dailymed.nlm.nih.gov/), the set ID being the fixed identifier in the label's page URL (`…/drugInfo.cfm?setid=<set ID>`) across all its revisions — the same ID a review links. The SPL XML is converted to Markdown: first a **Highlights** section with the label's header lines — the product line and "Initial U.S. Approval: 2003" (the year is also in the head line) — then every text section in order, a boxed warning headed "Boxed Warning" and the recent major changes headed as such even where the label gives them no title, (indications, dosage, contraindications, warnings, adverse reactions with their tables, drug interactions, clinical pharmacology, clinical studies); the structured product data and package images are left out; there is no abstract. The status carries `setid`, the title as `<brand> (<generic>) <form> — prescribing information`, the labeler as `first_author`, the effective year as `year`, the label version as `version` (`spl 52`), and `publication_types` `["Drug Label"]`. `library_read` and `library_verify` work on a label as on a paper, so a figure the review takes from a label is verified against the label. An entry older than `refresh_after_days` is re-fetched and rewritten when DailyMed has a newer version. Entries live under `dailymed/<first 2>/<first 4>/<set ID>/`, with the SPL XML and the Markdown as `dailymed-<set ID>-fulltext.xml` and `-fulltext.md`, and the log line reads `add dailymed:<set ID> dailymed fetched level=full_text spl 52 effective=2026-06-03 Cialis (Tadalafil)`.
138
+ **Drug labels.** `library_add("dailymed:<set ID>")` stores the current FDA prescribing information of one product from [DailyMed](https://dailymed.nlm.nih.gov/), the set ID being the fixed identifier in the label's page URL (`…/drugInfo.cfm?setid=<set ID>`) across all its revisions — the same ID a review links. The SPL XML is converted to Markdown: first a **Highlights** section with the label's header lines — the product line and "Initial U.S. Approval: 2003" (the year is also in the head line) — then every text section in order, a boxed warning headed "Boxed Warning" and the recent major changes headed as such even where the label gives them no title, (indications, dosage, contraindications, warnings, adverse reactions with their tables, drug interactions, clinical pharmacology, clinical studies); the structured product data and package images are left out; there is no abstract. The status carries `setid`, the title as `<brand> (<generic>) <form> — prescribing information`, the labeler as `first_author`, the effective year as `year`, the label version as `version` (`spl 52`), and `publication_types` `["Drug Label"]`. `library_read` and `library_verify` work on a label as on a paper, so a figure the review takes from a label is verified against the label. An entry older than `AI4L_LIBRARY_REFRESH_AFTER_DAYS` is re-fetched and rewritten when DailyMed has a newer version. Entries live under `dailymed/<first 2>/<first 4>/<set ID>/`, with the SPL XML and the Markdown as `dailymed-<set ID>-fulltext.xml` and `-fulltext.md`, and the log line reads `add dailymed:<set ID> dailymed fetched level=full_text spl 52 effective=2026-06-03 Cialis (Tadalafil)`.
162
139
 
163
- **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 `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 `add nct:05173896 ctgov fetched level=full_text updated 2024-12-09 active, not recruiting ETLAS2`.
140
+ **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 `AI4L_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 `add nct:05173896 ctgov fetched level=full_text updated 2024-12-09 active, not recruiting ETLAS2`.
164
141
 
165
142
  **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")`. In `-meta.md` they are the lines `coi:` (a JSON string) and `funding:` (a JSON array, one item per agency or statement).
166
143
 
167
144
  **Symbols a PDF font hides.** Some publishers' symbol fonts carry no Unicode for their glyphs, so text extraction gives a control character where the PDF shows a minus, ±, ≤ or β. The library restores the symbol from the glyph's name in the font — the names of the Adobe Pi fonts (`H11350` is ≥ everywhere) and of the Advent symbol font most Elsevier and Wiley PDFs use — each name checked against the glyph's outline in the library's PDFs; a name it does not know is left as it is and counted: the status's `glyphs_unresolved` is the number left in the full text (162 of 1,119 PDF-derived entries have some, down from 277), and `library_verify` returns the same count for the text it judged. A `fail` on a paper with `glyphs_unresolved` above 0 says "check the symbol", not "the paper does not state it". Words a PDF breaks at a line end ("differ- ence") are joined back when the paper uses the word elsewhere or no compound is known at that break; a hyphen the paper uses elsewhere ("time-dependent") and a suspended one ("dose- and time-dependent") are kept.
168
145
 
169
- **Citable, and correction notices.** `citable` is the one flag an auditor checks before a paper counts as evidence: false when PubMed records the paper as retracted — by the "Retracted Publication" type or by a retraction notice — and, once correction notices are assessed (below), when a correction changed its data. `comment` says why, or, when the paper is citable, names what to be aware of: an erratum (its severity is not assessed yet; `library_add` on the notice's PMID fetches it when it has one), an expression of concern, or a newer version that supersedes this one, typically an updated Cochrane review, which should be cited instead. Behind the flag, `corrections` lists PubMed's notices about the paper as `{type, pmid, source}` — `erratum_in`, `retraction_in`, `partial_retraction_in`, `retracted_and_republished_in`, `expression_of_concern_in` or `update_in`, the notice's own PMID when PubMed has one, and its citation verbatim. Comments are not notices and are not carried: PubMed's CommentIn, SummaryForPatientsIn, ReprintIn and the "Of/For/From" direction (this paper being the notice about another) are ignored. `update_in` is not an erratum: every case seen is a Cochrane review superseded by a newer version. Notices appear after ingest, so `library_add` on an existing entry re-reads the PubMed record once it is older than `refresh_after_days` (config.json, default 30; `pubmed_checked` in the entry is the date of the last read) and updates the notices, retraction, publication types and a PMCID that appeared since — never the stored abstract or full text, and without reopening full-text retries. The notice's own text is not fetched or applied. Where NLM has corrected the PubMed abstract after an erratum — it marks each corrected sentence `[corrected]` — those sentences are copied to the end of the stored full text under `## Corrected in PubMed abstract`, at ingest and on every refresh, so a full text that predates the correction still carries the corrected figures and `library_verify` passes a claim that states them — and marks a claim that states a replaced figure as `superseded`. The `comment` then reads "erratum, corrected figures stored" instead of "not assessed". In `-meta.md` each notice is one `corrections:` line, `<type> <pmid or -> "<source>"`; entries written before this feature are read from their stored PubMed XML until they are next rewritten.
146
+ **Citable, and correction notices.** `citable` is the one flag an auditor checks before a paper counts as evidence: false when PubMed records the paper as retracted — by the "Retracted Publication" type or by a retraction notice — and, once correction notices are assessed (below), when a correction changed its data. `comment` says why, or, when the paper is citable, names what to be aware of: an erratum (its severity is not assessed yet; `library_add` on the notice's PMID fetches it when it has one), an expression of concern, or a newer version that supersedes this one, typically an updated Cochrane review, which should be cited instead. Behind the flag, `corrections` lists PubMed's notices about the paper as `{type, pmid, source}` — `erratum_in`, `retraction_in`, `partial_retraction_in`, `retracted_and_republished_in`, `expression_of_concern_in` or `update_in`, the notice's own PMID when PubMed has one, and its citation verbatim. Comments are not notices and are not carried: PubMed's CommentIn, SummaryForPatientsIn, ReprintIn and the "Of/For/From" direction (this paper being the notice about another) are ignored. `update_in` is not an erratum: every case seen is a Cochrane review superseded by a newer version. Notices appear after ingest, so `library_add` on an existing entry re-reads the PubMed record once it is older than `AI4L_LIBRARY_REFRESH_AFTER_DAYS` (default 30; `pubmed_checked` in the entry is the date of the last read) and updates the notices, retraction, publication types and a PMCID that appeared since — never the stored abstract or full text, and without reopening full-text retries. The notice's own text is not fetched or applied. Where NLM has corrected the PubMed abstract after an erratum — it marks each corrected sentence `[corrected]` — those sentences are copied to the end of the stored full text under `## Corrected in PubMed abstract`, at ingest and on every refresh, so a full text that predates the correction still carries the corrected figures and `library_verify` passes a claim that states them — and marks a claim that states a replaced figure as `superseded`. The `comment` then reads "erratum, corrected figures stored" instead of "not assessed". In `-meta.md` each notice is one `corrections:` line, `<type> <pmid or -> "<source>"`; entries written before this feature are read from their stored PubMed XML until they are next rewritten.
170
147
 
171
148
  **Why a notice may have no PMID.** PubMed gives the notice's PMID only when the notice is itself a PubMed record. For about a quarter of the errata in a 6,900-entry library it is not: the journal printed the correction, PubMed recorded the citation in the paper's record, and never indexed the correction as a record of its own (NEJM, JAMA and Lancet errata from 1988 to 2014 are typical). There is nothing to redirect to, so `pmid` is null and `source` — the citation as PubMed wrote it — is the notice's only identifier. Measured on the 79 such errata in the library (2026-11-04): PubMed's citation matcher resolves 18 after all (13 directly, 5 after picking the "Published Erratum" among two candidates), Crossref gives a DOI for 38 more when journal, volume and page match, and 23 have no identifier anywhere. The planned second stage resolves them at ingest (PMID first, then DOI, the citation always kept), fetches the notice's text and classifies it once per paper as major (a number, table, figure, result or conclusion changed) or minor (authors, funding, typos): a major erratum makes the paper not citable, like a retraction, so the audit never has to apply a correction; until then an erratum is a note on a citable paper.
172
149
 
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ function dotenvFile(env = process.env) {
10
10
  const lib = env.AI4L_LIBRARY_DIR?.trim() || (existsSync("/library") ? "/library" : "library");
11
11
  return join(resolve(lib), ".env");
12
12
  }
13
+ var loadedFromDotenv = /* @__PURE__ */ new Set();
13
14
  function loadDotenv(file = dotenvFile()) {
14
15
  if (!existsSync(file)) return [];
15
16
  const loaded = [];
@@ -24,81 +25,46 @@ function loadDotenv(file = dotenvFile()) {
24
25
  if (process.env[m[1]] === void 0) {
25
26
  process.env[m[1]] = v;
26
27
  loaded.push(m[1]);
28
+ loadedFromDotenv.add(m[1]);
27
29
  }
28
30
  }
29
31
  return loaded;
30
32
  }
33
+ function sourceOf(name, env = process.env) {
34
+ if (!env[name]?.trim()) return "default";
35
+ return loadedFromDotenv.has(name) ? ".env" : "environment";
36
+ }
37
+ var str = (name, env = process.env) => env[name]?.trim() ?? "";
38
+ function num(name, def, env = process.env, ok = (n) => n >= 0) {
39
+ const v = str(name, env);
40
+ if (!v) return def;
41
+ const n = Number(v);
42
+ return Number.isFinite(n) && ok(n) ? n : def;
43
+ }
44
+ function bool(name, def, env = process.env) {
45
+ const v = str(name, env).toLowerCase();
46
+ return ["true", "1", "yes", "on"].includes(v) ? true : ["false", "0", "no", "off"].includes(v) ? false : def;
47
+ }
31
48
 
32
49
  // src/index.ts
33
50
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
34
51
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
35
52
 
36
53
  // src/tools.ts
37
- import { existsSync as existsSync10, readFileSync as readFileSync9 } from "fs";
54
+ import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
38
55
  import { z } from "zod";
39
56
 
40
57
  // src/entry.ts
41
- import { copyFileSync, existsSync as existsSync4, mkdirSync, readdirSync, readFileSync as readFileSync3, renameSync, rmSync, writeFileSync } from "fs";
42
- import { join as join4 } from "path";
58
+ import { copyFileSync, existsSync as existsSync2, mkdirSync, readdirSync, readFileSync as readFileSync2, renameSync, rmSync, writeFileSync } from "fs";
59
+ import { join as join2 } from "path";
43
60
  import { basename, dirname } from "path";
44
61
 
45
62
  // src/pubmed.ts
46
63
  import { XMLParser } from "fast-xml-parser";
47
64
 
48
- // src/contact.ts
49
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
50
- import { join as join3 } from "path";
51
-
52
- // src/layout.ts
53
- import { createHash } from "crypto";
54
- import { existsSync as existsSync2 } from "fs";
55
- import { join as join2, resolve as resolve2 } from "path";
56
- var AI4L_LIBRARY_DIR = (() => {
57
- const env = process.env.AI4L_LIBRARY_DIR;
58
- if (env && env.trim()) return resolve2(env.trim());
59
- if (existsSync2("/library")) return "/library";
60
- return resolve2("library");
61
- })();
62
- var doiSlug = (doi) => doi.toLowerCase().replace(/[^a-z0-9.-]+/g, "_");
63
- function entryDirRel(type, num) {
64
- if (type === "doi") {
65
- const h = createHash("sha1").update(num).digest("hex");
66
- return `${type}/${h.slice(0, 2)}/${h.slice(0, 4)}/${num}`;
67
- }
68
- if (!/^\d+$/.test(num)) return `${type}/${num.slice(0, 2)}/${num.slice(0, 4)}/${num}`;
69
- const l1 = num.length > 6 ? num.slice(0, -6) : "0";
70
- const l2 = num.length > 4 ? num.slice(0, -4) : "0";
71
- return `${type}/${l1}/${l2}/${num}`;
72
- }
73
- function entryPaths(libraryDir, type, num) {
74
- if (type === "doi") num = doiSlug(num);
75
- const entry = `${type}-${num}`;
76
- const entryDir = join2(libraryDir, entryDirRel(type, num));
77
- return {
78
- entry,
79
- entryDir,
80
- xml: join2(entryDir, `${entry}-abstract.xml`),
81
- md: join2(entryDir, `${entry}-abstract.md`),
82
- meta: join2(entryDir, `${entry}-meta.md`),
83
- fulltextMd: join2(entryDir, `${entry}-fulltext.md`),
84
- fulltextRaw: (format) => join2(entryDir, `${entry}-fulltext.${format}`)
85
- };
86
- }
87
-
88
65
  // src/contact.ts
89
66
  var DEFAULT = "hello@forever-healthy.org";
90
- function contactEmail() {
91
- const file = process.env.AI4L_LIBRARY_CONFIG?.trim() || join3(AI4L_LIBRARY_DIR, "config.json");
92
- let v = "";
93
- if (existsSync3(file)) {
94
- try {
95
- v = String(JSON.parse(readFileSync2(file, "utf8")).contact_email ?? "");
96
- } catch {
97
- }
98
- }
99
- v = v.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, n) => process.env[n] ?? "").trim();
100
- return v || process.env.CONTACT_EMAIL?.trim() || DEFAULT;
101
- }
67
+ var contactEmail = (env = process.env) => str("AI4L_LIBRARY_CONTACT_EMAIL", env) || DEFAULT;
102
68
 
103
69
  // src/http.ts
104
70
  var TIMEOUT_MS = 3e4;
@@ -1047,7 +1013,7 @@ ${body}
1047
1013
  }
1048
1014
  function readHeadLine(p) {
1049
1015
  try {
1050
- const lines = readFileSync3(p.md, "utf8").split("\n").filter((l) => l.trim());
1016
+ const lines = readFileSync2(p.md, "utf8").split("\n").filter((l) => l.trim());
1051
1017
  return lines[1] ?? "";
1052
1018
  } catch {
1053
1019
  return "";
@@ -1169,28 +1135,28 @@ function splitPublicationTypes(v) {
1169
1135
  }
1170
1136
  function abstractMissing(p) {
1171
1137
  try {
1172
- return readFileSync3(p.md, "utf8").includes("_No abstract available._");
1138
+ return readFileSync2(p.md, "utf8").includes("_No abstract available._");
1173
1139
  } catch {
1174
1140
  return false;
1175
1141
  }
1176
1142
  }
1177
1143
  function correctionsFromXml(p) {
1178
1144
  try {
1179
- return parsePubmedXml(readFileSync3(p.xml, "utf8"))?.corrections ?? [];
1145
+ return parsePubmedXml(readFileSync2(p.xml, "utf8"))?.corrections ?? [];
1180
1146
  } catch {
1181
1147
  return [];
1182
1148
  }
1183
1149
  }
1184
1150
  function declarationsFromXml(p) {
1185
1151
  try {
1186
- const r = parsePubmedXml(readFileSync3(p.xml, "utf8"));
1152
+ const r = parsePubmedXml(readFileSync2(p.xml, "utf8"));
1187
1153
  return { coi: r?.coi ?? null, funding: r?.funding ?? [] };
1188
1154
  } catch {
1189
1155
  return { coi: null, funding: [] };
1190
1156
  }
1191
1157
  }
1192
1158
  function readMeta(p) {
1193
- const { fm, all } = parseFrontmatter(readFileSync3(p.meta, "utf8"));
1159
+ const { fm, all } = parseFrontmatter(readFileSync2(p.meta, "utf8"));
1194
1160
  let title = fm.title ?? "";
1195
1161
  if (title.startsWith('"')) {
1196
1162
  try {
@@ -1251,12 +1217,12 @@ function copyEntry(from, to) {
1251
1217
  rmSync(tmp, { recursive: true, force: true });
1252
1218
  mkdirSync(tmp, { recursive: true });
1253
1219
  const rename = (n) => n.startsWith(from.entry) ? to.entry + n.slice(from.entry.length) : n;
1254
- for (const n of readdirSync(from.entryDir)) if (join4(from.entryDir, n) !== from.meta && !n.endsWith(".tmp")) copyFileSync(join4(from.entryDir, n), join4(tmp, rename(n)));
1220
+ for (const n of readdirSync(from.entryDir)) if (join2(from.entryDir, n) !== from.meta && !n.endsWith(".tmp")) copyFileSync(join2(from.entryDir, n), join2(tmp, rename(n)));
1255
1221
  const m = readMeta(from);
1256
1222
  const copy = { ...m, files: m.files.map(rename) };
1257
- writeFileSync(join4(tmp, basename(to.meta)), renderMeta(copy));
1223
+ writeFileSync(join2(tmp, basename(to.meta)), renderMeta(copy));
1258
1224
  mkdirSync(dirname(to.entryDir), { recursive: true });
1259
- if (existsSync4(to.entryDir)) renameSync(to.entryDir, old);
1225
+ if (existsSync2(to.entryDir)) renameSync(to.entryDir, old);
1260
1226
  renameSync(tmp, to.entryDir);
1261
1227
  rmSync(old, { recursive: true, force: true });
1262
1228
  return copy;
@@ -1276,7 +1242,7 @@ function citability(m) {
1276
1242
  return { citable: true, comment: notes.length ? notes.join("; ") : null };
1277
1243
  }
1278
1244
  function statusFromMeta(p, m, base) {
1279
- const full = existsSync4(p.fulltextMd) ? readFileSync3(p.fulltextMd, "utf8") : null;
1245
+ const full = existsSync2(p.fulltextMd) ? readFileSync2(p.fulltextMd, "utf8") : null;
1280
1246
  const cit = citability(m);
1281
1247
  if (full?.includes(`
1282
1248
  ${CORRECTED_HEADING}
@@ -1318,7 +1284,7 @@ function readStatus(p, base) {
1318
1284
  return statusFromMeta(p, readMeta(p), base);
1319
1285
  }
1320
1286
  function entryExists(p) {
1321
- return existsSync4(p.meta);
1287
+ return existsSync2(p.meta);
1322
1288
  }
1323
1289
  function writeEntry(p, r, xml, converter) {
1324
1290
  mkdirSync(p.entryDir, { recursive: true });
@@ -1401,15 +1367,15 @@ ${sentences.join("\n\n")}
1401
1367
  `;
1402
1368
  }
1403
1369
  function syncCorrectedBlock(p) {
1404
- if (!existsSync4(p.fulltextMd) || !existsSync4(p.xml)) return;
1370
+ if (!existsSync2(p.fulltextMd) || !existsSync2(p.xml)) return;
1405
1371
  let r;
1406
1372
  try {
1407
- r = parsePubmedXml(readFileSync3(p.xml, "utf8"));
1373
+ r = parsePubmedXml(readFileSync2(p.xml, "utf8"));
1408
1374
  } catch {
1409
1375
  return;
1410
1376
  }
1411
1377
  if (!r) return;
1412
- const md = readFileSync3(p.fulltextMd, "utf8");
1378
+ const md = readFileSync2(p.fulltextMd, "utf8");
1413
1379
  const next = withCorrectedBlock(md, correctedSentences(r));
1414
1380
  if (next !== md) writeAtomic(p.fulltextMd, next);
1415
1381
  }
@@ -1518,10 +1484,6 @@ function writeDoiEntry(p, r, json2, converter, abstractFrom = "crossref", record
1518
1484
  return meta;
1519
1485
  }
1520
1486
 
1521
- // src/config.ts
1522
- import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
1523
- import { join as join7 } from "path";
1524
-
1525
1487
  // src/checks.ts
1526
1488
  var GREEK = { "\u03B1": "alpha", "\u03B2": "beta", "\xDF": "beta", "\u03B3": "gamma", "\u03B4": "delta", "\u03B5": "epsilon", "\u03B6": "zeta", "\u03B7": "eta", "\u03B8": "theta", "\u03B9": "iota", "\u03BA": "kappa", "\u03BB": "lambda", "\u03BC": "mu", "\xB5": "mu", "\u03BD": "nu", "\u03BE": "xi", "\u03C0": "pi", "\u03C1": "rho", "\u03C3": "sigma", "\u03C4": "tau", "\u03C5": "upsilon", "\u03C6": "phi", "\u03C7": "chi", "\u03C8": "psi", "\u03C9": "omega", "\u0391": "alpha", "\u0392": "beta", "\u0393": "gamma", "\u0394": "delta", "\u03A9": "omega", "\u03A3": "sigma", "\u039A": "kappa", "\u039B": "lambda", "\u03A0": "pi", "\u03A6": "phi", "\u03A8": "psi" };
1527
1489
  var greek = (s) => s.replace(/[\u0370-\u03ff\u00b5\u00df]/g, (c) => GREEK[c] ?? c);
@@ -1934,24 +1896,55 @@ function renderFulltextMd(doc, headLine2) {
1934
1896
  }
1935
1897
 
1936
1898
  // src/browser.ts
1937
- import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
1938
- import { join as join5 } from "path";
1899
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
1900
+ import { join as join4 } from "path";
1939
1901
  import { chromium } from "playwright-core";
1940
- function browserConfig(file = CONFIG_FILE) {
1902
+
1903
+ // src/layout.ts
1904
+ import { createHash } from "crypto";
1905
+ import { existsSync as existsSync3 } from "fs";
1906
+ import { join as join3, resolve as resolve2 } from "path";
1907
+ var AI4L_LIBRARY_DIR = (() => {
1908
+ const env = process.env.AI4L_LIBRARY_DIR;
1909
+ if (env && env.trim()) return resolve2(env.trim());
1910
+ if (existsSync3("/library")) return "/library";
1911
+ return resolve2("library");
1912
+ })();
1913
+ var doiSlug = (doi) => doi.toLowerCase().replace(/[^a-z0-9.-]+/g, "_");
1914
+ function entryDirRel(type, num2) {
1915
+ if (type === "doi") {
1916
+ const h = createHash("sha1").update(num2).digest("hex");
1917
+ return `${type}/${h.slice(0, 2)}/${h.slice(0, 4)}/${num2}`;
1918
+ }
1919
+ if (!/^\d+$/.test(num2)) return `${type}/${num2.slice(0, 2)}/${num2.slice(0, 4)}/${num2}`;
1920
+ const l1 = num2.length > 6 ? num2.slice(0, -6) : "0";
1921
+ const l2 = num2.length > 4 ? num2.slice(0, -4) : "0";
1922
+ return `${type}/${l1}/${l2}/${num2}`;
1923
+ }
1924
+ function entryPaths(libraryDir, type, num2) {
1925
+ if (type === "doi") num2 = doiSlug(num2);
1926
+ const entry = `${type}-${num2}`;
1927
+ const entryDir = join3(libraryDir, entryDirRel(type, num2));
1928
+ return {
1929
+ entry,
1930
+ entryDir,
1931
+ xml: join3(entryDir, `${entry}-abstract.xml`),
1932
+ md: join3(entryDir, `${entry}-abstract.md`),
1933
+ meta: join3(entryDir, `${entry}-meta.md`),
1934
+ fulltextMd: join3(entryDir, `${entry}-fulltext.md`),
1935
+ fulltextRaw: (format) => join3(entryDir, `${entry}-fulltext.${format}`)
1936
+ };
1937
+ }
1938
+
1939
+ // src/browser.ts
1940
+ function browserConfig(env = process.env) {
1941
1941
  const out = { browsers: [], headless: "auto" };
1942
- if (!existsSync5(file)) return out;
1943
- try {
1944
- const d = JSON.parse(readFileSync4(file, "utf8")).download ?? {};
1945
- if (Array.isArray(d.browsers)) {
1946
- for (const b of d.browsers) {
1947
- if (typeof b !== "string") continue;
1948
- const v = resolveEnv(b).trim();
1949
- if (v && !/^\$\{[A-Z0-9_]+\}$/.test(v) && !out.browsers.includes(v)) out.browsers.push(v);
1950
- }
1951
- }
1952
- if (d.headless === "auto" || typeof d.headless === "boolean") out.headless = d.headless;
1953
- } catch {
1942
+ for (let i = 1; i <= 9; i++) {
1943
+ const v = str(`AI4L_LIBRARY_BROWSER_${i}`, env);
1944
+ if (v && !out.browsers.includes(v)) out.browsers.push(v);
1954
1945
  }
1946
+ const h = str("AI4L_LIBRARY_BROWSER_HEADLESS", env).toLowerCase();
1947
+ if (h === "true" || h === "false") out.headless = h === "true";
1955
1948
  return out;
1956
1949
  }
1957
1950
  var INSTALL_HINT = "npx playwright-core install chromium";
@@ -1962,7 +1955,7 @@ var rungKind = (endpoint) => isCdp(endpoint) ? "chrome" : isRemote(endpoint) ? "
1962
1955
  function localChromium() {
1963
1956
  try {
1964
1957
  const p = chromium.executablePath();
1965
- return existsSync5(p) ? p : null;
1958
+ return existsSync4(p) ? p : null;
1966
1959
  } catch {
1967
1960
  return null;
1968
1961
  }
@@ -1999,12 +1992,12 @@ var IDLE_CLOSE_MS = 12e4;
1999
1992
  var ctxs = /* @__PURE__ */ new Map();
2000
1993
  var idleTimer = null;
2001
1994
  function profileDir() {
2002
- const dir = join5(AI4L_LIBRARY_DIR, ".browser");
2003
- mkdirSync2(join5(dir, "Default"), { recursive: true });
2004
- const prefs = join5(dir, "Default", "Preferences");
1995
+ const dir = join4(AI4L_LIBRARY_DIR, ".browser");
1996
+ mkdirSync2(join4(dir, "Default"), { recursive: true });
1997
+ const prefs = join4(dir, "Default", "Preferences");
2005
1998
  let p = {};
2006
1999
  try {
2007
- p = JSON.parse(readFileSync4(prefs, "utf8"));
2000
+ p = JSON.parse(readFileSync3(prefs, "utf8"));
2008
2001
  } catch {
2009
2002
  }
2010
2003
  p.plugins = { ...p.plugins ?? {}, always_open_pdf_externally: true };
@@ -2017,9 +2010,9 @@ var LOCAL_WINDOW_ARGS = [...LAUNCH_ARGS, "--window-position=4000,4000"];
2017
2010
  var TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone || "Europe/Berlin";
2018
2011
  var CONTEXT_OPTS = { acceptDownloads: true, viewport: null, locale: "en-US", timezoneId: TIMEZONE };
2019
2012
  function stateFile(endpoint) {
2020
- const dir = join5(AI4L_LIBRARY_DIR, ".browser");
2013
+ const dir = join4(AI4L_LIBRARY_DIR, ".browser");
2021
2014
  mkdirSync2(dir, { recursive: true });
2022
- return join5(dir, `${browserLabel(endpoint).replace(/[^A-Za-z0-9.-]+/g, "_")}.json`);
2015
+ return join4(dir, `${browserLabel(endpoint).replace(/[^A-Za-z0-9.-]+/g, "_")}.json`);
2023
2016
  }
2024
2017
  async function context(endpoint, cfg) {
2025
2018
  let p = ctxs.get(endpoint);
@@ -2042,7 +2035,7 @@ async function context(endpoint, cfg) {
2042
2035
  });
2043
2036
  browser.on("disconnected", drop);
2044
2037
  const state = stateFile(endpoint);
2045
- const ctx2 = await browser.newContext({ ...CONTEXT_OPTS, ...existsSync5(state) ? { storageState: state } : {} });
2038
+ const ctx2 = await browser.newContext({ ...CONTEXT_OPTS, ...existsSync4(state) ? { storageState: state } : {} });
2046
2039
  return { ctx: ctx2, browser };
2047
2040
  }
2048
2041
  if (!localChromium()) throw new Error(`no local Chromium (${INSTALL_HINT})`);
@@ -2137,7 +2130,7 @@ async function browserFetch(url, endpoint, cfg = browserConfig()) {
2137
2130
  }
2138
2131
  if (download2) {
2139
2132
  const d = download2;
2140
- return { bytes: readFileSync4(await d.path()), contentType: "application/pdf", url: d.url(), via: "download" };
2133
+ return { bytes: readFileSync3(await d.path()), contentType: "application/pdf", url: d.url(), via: "download" };
2141
2134
  }
2142
2135
  const title = await page.title().catch(() => "");
2143
2136
  if (CHALLENGE.test(title)) throw new Error(`challenge not cleared (${title.slice(0, 40)})`);
@@ -2456,16 +2449,16 @@ function europepmcCandidates(r, pmcid) {
2456
2449
  }
2457
2450
 
2458
2451
  // src/tiers/inbox.ts
2459
- import { existsSync as existsSync6, readdirSync as readdirSync2, readFileSync as readFileSync5, unlinkSync } from "fs";
2460
- import { join as join6 } from "path";
2461
- var INBOX_DIR = join6(AI4L_LIBRARY_DIR, "inbox");
2452
+ import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync4, unlinkSync } from "fs";
2453
+ import { join as join5 } from "path";
2454
+ var INBOX_DIR = join5(AI4L_LIBRARY_DIR, "inbox");
2462
2455
  var EXT = { ".pdf": "application/pdf", ".xml": "text/xml", ".html": "text/html", ".htm": "text/html" };
2463
2456
  function findInboxFile(dir, pmid) {
2464
- if (!existsSync6(dir)) return null;
2457
+ if (!existsSync5(dir)) return null;
2465
2458
  const re = new RegExp(`(^|[^0-9])${pmid}([^0-9]|$)`);
2466
2459
  for (const name of readdirSync2(dir).sort()) {
2467
2460
  const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
2468
- if (ext in EXT && re.test(name.slice(0, -ext.length))) return join6(dir, name);
2461
+ if (ext in EXT && re.test(name.slice(0, -ext.length))) return join5(dir, name);
2469
2462
  }
2470
2463
  return null;
2471
2464
  }
@@ -2479,7 +2472,7 @@ function inboxTier(dir = INBOX_DIR) {
2479
2472
  if (!file) return { ...miss("inbox", "no file"), skipped: true, ephemeral: true };
2480
2473
  const name = file.slice(file.lastIndexOf("/") + 1);
2481
2474
  const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
2482
- const bytes = readFileSync5(file);
2475
+ const bytes = readFileSync4(file);
2483
2476
  const r = await convertBytes(p, bytes, EXT[ext]);
2484
2477
  if (!r.ok) return miss("inbox", `${name}: ${r.reason}`);
2485
2478
  ctx.onTry?.(`${name} ok, removed from the inbox`);
@@ -2678,74 +2671,70 @@ function urlTier(name, template, headers) {
2678
2671
  }
2679
2672
 
2680
2673
  // src/config.ts
2681
- var CONFIG_FILE = process.env.AI4L_LIBRARY_CONFIG?.trim() || join7(AI4L_LIBRARY_DIR, "config.json");
2682
- function resolveEnv(v, env = process.env) {
2683
- return v.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, n) => env[n] ?? "");
2684
- }
2685
- var resolveMap = (m) => m ? Object.fromEntries(Object.entries(m).map(([k, v]) => [k, resolveEnv(String(v))])) : void 0;
2686
- function settings(file = CONFIG_FILE) {
2687
- const out = { retry_after_days: 1, retry_blocked: false, refresh_after_days: 30 };
2688
- if (!existsSync7(file)) return out;
2689
- try {
2690
- const cfg = JSON.parse(readFileSync6(file, "utf8"));
2691
- if (typeof cfg.retry_after_days === "number" && cfg.retry_after_days >= 0) out.retry_after_days = cfg.retry_after_days;
2692
- if (typeof cfg.retry_blocked === "boolean") out.retry_blocked = cfg.retry_blocked;
2693
- if (typeof cfg.refresh_after_days === "number" && cfg.refresh_after_days >= 0) out.refresh_after_days = cfg.refresh_after_days;
2694
- } catch {
2674
+ var VARIABLES = [
2675
+ "AI4L_LIBRARY_DIR",
2676
+ "AI4L_LIBRARY_ENV",
2677
+ "AI4L_LIBRARY_TIERS",
2678
+ "AI4L_LIBRARY_CONTACT_EMAIL",
2679
+ "AI4L_LIBRARY_ELSEVIER_API_KEY",
2680
+ "AI4L_LIBRARY_WILEY_TDM_TOKEN",
2681
+ "AI4L_LIBRARY_CORE_API_KEY",
2682
+ "AI4L_LIBRARY_PROXY_URL",
2683
+ "AI4L_LIBRARY_PROXY_HEADERS",
2684
+ "AI4L_LIBRARY_BROWSER_1",
2685
+ "AI4L_LIBRARY_BROWSER_2",
2686
+ "AI4L_LIBRARY_BROWSER_3",
2687
+ "AI4L_LIBRARY_BROWSER_HEADLESS",
2688
+ "AI4L_LIBRARY_VERIFY_URL",
2689
+ "AI4L_LIBRARY_VERIFY_API_KEY",
2690
+ "AI4L_LIBRARY_VERIFY_MODEL",
2691
+ "AI4L_LIBRARY_VERIFY_THRESHOLD",
2692
+ "AI4L_LIBRARY_VERIFY_TIMEOUT_SECS",
2693
+ "AI4L_LIBRARY_RETRY_AFTER_DAYS",
2694
+ "AI4L_LIBRARY_RETRY_BLOCKED",
2695
+ "AI4L_LIBRARY_REFRESH_AFTER_DAYS",
2696
+ "NCBI_API_KEY",
2697
+ "BRIGHTDATA_API_TOKEN",
2698
+ "BRIGHTDATA_ZONE"
2699
+ ];
2700
+ var sources = (env = process.env) => Object.fromEntries(VARIABLES.map((v) => [v, sourceOf(v, env)]));
2701
+ function settings(env = process.env) {
2702
+ return {
2703
+ retry_after_days: num("AI4L_LIBRARY_RETRY_AFTER_DAYS", 1, env),
2704
+ retry_blocked: bool("AI4L_LIBRARY_RETRY_BLOCKED", false, env),
2705
+ refresh_after_days: num("AI4L_LIBRARY_REFRESH_AFTER_DAYS", 30, env)
2706
+ };
2707
+ }
2708
+ function proxyHeaders(spec) {
2709
+ const out = {};
2710
+ for (const part of spec.split(";")) {
2711
+ const i = part.indexOf(":");
2712
+ if (i < 0) continue;
2713
+ const name = part.slice(0, i).trim(), value = part.slice(i + 1).trim();
2714
+ if (name && value) out[name] = value;
2695
2715
  }
2696
2716
  return out;
2697
2717
  }
2698
- function resolveTiers(env = process.env.AI4L_LIBRARY_TIERS, file = CONFIG_FILE) {
2718
+ function resolveTiers(env = process.env) {
2699
2719
  const problems = [];
2700
- const builtin = new Map(BUILTIN.map((t) => [t.name, t]));
2701
- const known = /* @__PURE__ */ new Map();
2702
- let source = "default";
2703
- for (const t of BUILTIN) known.set(t.name, { tier: t.make({}), enabled: true, kind: "builtin" });
2704
- if (existsSync7(file)) {
2705
- source = "file";
2706
- let cfg = {};
2707
- try {
2708
- cfg = JSON.parse(readFileSync6(file, "utf8"));
2709
- } catch (e) {
2710
- problems.push(`${file}: ${e instanceof Error ? e.message : String(e)}`);
2711
- }
2712
- if (Array.isArray(cfg.tiers)) {
2713
- for (const k of known.keys()) known.get(k).enabled = false;
2714
- const ordered = /* @__PURE__ */ new Map();
2715
- for (const e of cfg.tiers) {
2716
- if (!e || typeof e.name !== "string" || !e.name.trim()) {
2717
- problems.push(`${file}: tier entry without name`);
2718
- continue;
2719
- }
2720
- const enabled = e.enabled !== false;
2721
- const description = typeof e.description === "string" ? e.description : void 0;
2722
- if (typeof e.url === "string" && e.url.trim()) ordered.set(e.name, { tier: urlTier(e.name, resolveEnv(e.url.trim()), resolveMap(e.headers)), enabled, kind: "url", description });
2723
- else if (builtin.has(e.name)) ordered.set(e.name, { tier: builtin.get(e.name).make({ tokens: resolveMap(e.tokens), api_key: typeof e.api_key === "string" ? resolveEnv(e.api_key) : void 0 }), enabled, kind: "builtin", description });
2724
- else problems.push(`${file}: unknown tier "${e.name}" (built-in: ${[...builtin.keys()].join(", ")}; a user tier needs "url")`);
2725
- }
2726
- for (const [k, v] of known) if (!ordered.has(k)) ordered.set(k, v);
2727
- known.clear();
2728
- for (const [k, v] of ordered) known.set(k, v);
2729
- }
2730
- }
2731
- if (env && env.trim()) {
2732
- source = "env";
2733
- for (const v of known.values()) v.enabled = false;
2734
- const ordered = /* @__PURE__ */ new Map();
2735
- for (const name of env.split(",").map((s) => s.trim()).filter(Boolean)) {
2736
- const v = known.get(name);
2737
- if (!v) {
2738
- problems.push(`AI4L_LIBRARY_TIERS: unknown tier "${name}"`);
2739
- continue;
2740
- }
2741
- ordered.set(name, { ...v, enabled: true });
2742
- }
2743
- for (const [k, v] of known) if (!ordered.has(k)) ordered.set(k, v);
2744
- known.clear();
2745
- for (const [k, v] of ordered) known.set(k, v);
2746
- }
2747
- const all = [...known.entries()].map(([name, v]) => ({ name, enabled: v.enabled, description: v.description ?? (v.kind === "url" ? "url tier" : "built-in") }));
2748
- return { tiers: [...known.values()].filter((v) => v.enabled).map((v) => v.tier), all, source, file, problems };
2720
+ const available = /* @__PURE__ */ new Map();
2721
+ const tokens = { elsevier: str("AI4L_LIBRARY_ELSEVIER_API_KEY", env) || void 0, wiley: str("AI4L_LIBRARY_WILEY_TDM_TOKEN", env) || void 0 };
2722
+ for (const t of BUILTIN) available.set(t.name, t.make({ tokens, api_key: str("AI4L_LIBRARY_CORE_API_KEY", env) || void 0 }));
2723
+ const proxyUrl = str("AI4L_LIBRARY_PROXY_URL", env);
2724
+ if (proxyUrl) available.set("proxy", urlTier("proxy", proxyUrl, proxyHeaders(str("AI4L_LIBRARY_PROXY_HEADERS", env))));
2725
+ const listed = str("AI4L_LIBRARY_TIERS", env);
2726
+ const order2 = listed ? listed.split(",").map((s) => s.trim()).filter(Boolean) : [...available.keys()];
2727
+ const tiers = [], seen = /* @__PURE__ */ new Set();
2728
+ for (const name of order2) {
2729
+ if (seen.has(name)) continue;
2730
+ seen.add(name);
2731
+ const t = available.get(name);
2732
+ if (t) tiers.push(t);
2733
+ else if (name === "proxy") problems.push('AI4L_LIBRARY_TIERS: "proxy" listed, but AI4L_LIBRARY_PROXY_URL is unset');
2734
+ else problems.push(`AI4L_LIBRARY_TIERS: unknown tier "${name}" (built-in: ${BUILTIN.map((t2) => t2.name).join(", ")}; proxy when AI4L_LIBRARY_PROXY_URL is set)`);
2735
+ }
2736
+ const all = [...tiers.map((t) => ({ name: t.name, enabled: true })), ...[...available.keys()].filter((n) => !seen.has(n)).map((name) => ({ name, enabled: false }))];
2737
+ return { tiers, all, source: sourceOf("AI4L_LIBRARY_TIERS", env), problems };
2749
2738
  }
2750
2739
 
2751
2740
  // src/cascade.ts
@@ -2820,34 +2809,26 @@ function parseId(raw) {
2820
2809
  }
2821
2810
 
2822
2811
  // src/info.ts
2823
- import { existsSync as existsSync9, readdirSync as readdirSync3, readFileSync as readFileSync8, statSync } from "fs";
2824
- import { dirname as dirname2, join as join8 } from "path";
2812
+ import { existsSync as existsSync7, readdirSync as readdirSync3, readFileSync as readFileSync6, statSync } from "fs";
2813
+ import { dirname as dirname2, join as join6 } from "path";
2825
2814
 
2826
2815
  // src/verify.ts
2827
- import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
2816
+ import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
2828
2817
  var DEFAULT_MODEL = "pplx-decider-flash-v1.2";
2829
2818
  var QUESTION = "Does the paper state this, in substance? Ignore section labels and definitions of terms in parentheses. Every fact and number of the statement must be stated in the paper or follow from its numbers; a rounded number counts. If any part of the statement is absent from the paper or contradicted by it, answer false.";
2830
- function verifyConfig(file = CONFIG_FILE) {
2831
- if (!existsSync8(file)) return null;
2832
- try {
2833
- const v = JSON.parse(readFileSync7(file, "utf8")).verify;
2834
- if (!v || typeof v.base_url !== "string") return null;
2835
- const base_url = resolveEnv(v.base_url).trim().replace(/\/+$/, "");
2836
- if (!base_url) return null;
2837
- const key = typeof v.api_key === "string" ? resolveEnv(v.api_key).trim() : "";
2838
- return {
2839
- base_url,
2840
- api_key: key || null,
2841
- model: typeof v.model === "string" && resolveEnv(v.model).trim() ? resolveEnv(v.model).trim() : DEFAULT_MODEL,
2842
- threshold: typeof v.threshold === "number" && v.threshold > 0 && v.threshold < 1 ? v.threshold : 0.5,
2843
- // Hosted takes 256k tokens in one request (a 50k-token paper in 2 s); the
2844
- // Mac app is limited to 32k and slows steeply beyond 25k, so it windows.
2845
- window_chars: typeof v.window_chars === "number" && v.window_chars >= 1e4 ? v.window_chars : isHosted(base_url) ? 8e5 : 9e4,
2846
- timeout_seconds: typeof v.timeout_seconds === "number" && v.timeout_seconds > 0 ? v.timeout_seconds : 60
2847
- };
2848
- } catch {
2849
- return null;
2850
- }
2819
+ function verifyConfig(env = process.env) {
2820
+ const base_url = str("AI4L_LIBRARY_VERIFY_URL", env).replace(/\/+$/, "");
2821
+ if (!base_url) return null;
2822
+ return {
2823
+ base_url,
2824
+ api_key: str("AI4L_LIBRARY_VERIFY_API_KEY", env) || null,
2825
+ model: str("AI4L_LIBRARY_VERIFY_MODEL", env) || DEFAULT_MODEL,
2826
+ threshold: num("AI4L_LIBRARY_VERIFY_THRESHOLD", 0.5, env, (n) => n > 0 && n < 1),
2827
+ // Hosted takes 256k tokens in one request (a 50k-token paper in 2 s); the
2828
+ // Mac app is limited to 32k and slows steeply beyond 25k, so it windows.
2829
+ window_chars: isHosted(base_url) ? 8e5 : 9e4,
2830
+ timeout_seconds: num("AI4L_LIBRARY_VERIFY_TIMEOUT_SECS", 60, env, (n) => n > 0)
2831
+ };
2851
2832
  }
2852
2833
  var isHosted = (baseUrl) => {
2853
2834
  try {
@@ -2933,10 +2914,10 @@ async function readPaper(raw, libraryDir = AI4L_LIBRARY_DIR) {
2933
2914
  if (p.error) return { id: p.requested, error: p.error };
2934
2915
  const paths = entryPaths(libraryDir, p.type, p.id);
2935
2916
  if (!entryExists(paths)) return { id: p.requested, error: "not in library" };
2936
- const full = existsSync8(paths.fulltextMd);
2937
- if (full) return { id: p.requested, basis: "full_text", text: readFileSync7(paths.fulltextMd, "utf8") };
2917
+ const full = existsSync6(paths.fulltextMd);
2918
+ if (full) return { id: p.requested, basis: "full_text", text: readFileSync5(paths.fulltextMd, "utf8") };
2938
2919
  const meta = readMeta(paths);
2939
- return { id: p.requested, basis: meta.has_abstract ? "abstract" : "title", text: readFileSync7(paths.md, "utf8") };
2920
+ return { id: p.requested, basis: meta.has_abstract ? "abstract" : "title", text: readFileSync5(paths.md, "utf8") };
2940
2921
  }
2941
2922
  async function verifyClaim(claim, id, cfg, decide, read = readPaper) {
2942
2923
  const out = { claim, id, verdict: "error", score: null, basis: null, windows: 0, glyphs_unresolved: 0, superseded: null, model: cfg?.model ?? null };
@@ -2950,7 +2931,7 @@ async function verifyClaim(claim, id, cfg, decide, read = readPaper) {
2950
2931
  const paper = await read(id);
2951
2932
  out.id = paper.id;
2952
2933
  if ("error" in paper) return { ...out, error: paper.error };
2953
- if (!cfg || !decide) return { ...out, error: "no verify model configured (config.json: verify)" };
2934
+ if (!cfg || !decide) return { ...out, error: "no verify model configured (AI4L_LIBRARY_VERIFY_URL)" };
2954
2935
  out.basis = paper.basis;
2955
2936
  out.glyphs_unresolved = countUnresolved(paper.text);
2956
2937
  if (paper.basis === "title") return { ...out, verdict: "na" };
@@ -2977,9 +2958,9 @@ async function verifyClaim(claim, id, cfg, decide, read = readPaper) {
2977
2958
 
2978
2959
  // src/info.ts
2979
2960
  function* metaFiles(dir) {
2980
- if (!existsSync9(dir)) return;
2961
+ if (!existsSync7(dir)) return;
2981
2962
  for (const name of readdirSync3(dir)) {
2982
- const p = join8(dir, name);
2963
+ const p = join6(dir, name);
2983
2964
  if (statSync(p).isDirectory()) yield* metaFiles(p);
2984
2965
  else if (name.endsWith("-meta.md")) yield p;
2985
2966
  }
@@ -3002,7 +2983,7 @@ function libraryInfo(libraryDir, host, withIds, tiers = resolveTiers()) {
3002
2983
  library_dir: libraryDir,
3003
2984
  host,
3004
2985
  tiers: tiers.all,
3005
- tiers_config: { source: tiers.source, file: tiers.file, problems: tiers.problems, retry_after_days: settings().retry_after_days, retry_blocked: settings().retry_blocked },
2986
+ config: { ...settings(), problems: tiers.problems, sources: sources() },
3006
2987
  download: downloadInfo(),
3007
2988
  verify: verifyInfo(),
3008
2989
  entries: 0,
@@ -3018,11 +2999,11 @@ function libraryInfo(libraryDir, host, withIds, tiers = resolveTiers()) {
3018
2999
  updates: 0
3019
3000
  };
3020
3001
  const ids = [];
3021
- for (const type of existsSync9(libraryDir) ? readdirSync3(libraryDir) : []) {
3022
- const typeDir = join8(libraryDir, type);
3002
+ for (const type of existsSync7(libraryDir) ? readdirSync3(libraryDir) : []) {
3003
+ const typeDir = join6(libraryDir, type);
3023
3004
  if (!statSync(typeDir).isDirectory()) continue;
3024
3005
  for (const meta of metaFiles(typeDir)) {
3025
- const md = readFileSync8(meta, "utf8");
3006
+ const md = readFileSync6(meta, "utf8");
3026
3007
  const raw = field(md, "level") || "unknown";
3027
3008
  const level = raw === "abstract" && field(md, "has_abstract") === "no" ? "title" : raw;
3028
3009
  info.entries++;
@@ -3034,7 +3015,7 @@ function libraryInfo(libraryDir, host, withIds, tiers = resolveTiers()) {
3034
3015
  const dir = dirname2(meta);
3035
3016
  for (const f of readdirSync3(dir)) {
3036
3017
  try {
3037
- const st = statSync(join8(dir, f));
3018
+ const st = statSync(join6(dir, f));
3038
3019
  if (st.isFile()) info.size_bytes += st.size;
3039
3020
  } catch {
3040
3021
  }
@@ -3063,7 +3044,7 @@ function noticeTypes(md, metaPath) {
3063
3044
  return out;
3064
3045
  }
3065
3046
  try {
3066
- const xml = readFileSync8(metaPath.replace(/-meta\.md$/, "-abstract.xml"), "utf8");
3047
+ const xml = readFileSync6(metaPath.replace(/-meta\.md$/, "-abstract.xml"), "utf8");
3067
3048
  for (const m of xml.matchAll(/<CommentsCorrections RefType="([A-Za-z]+)"/g)) if (XML_TYPES[m[1]]) out.add(XML_TYPES[m[1]]);
3068
3049
  } catch {
3069
3050
  }
@@ -3165,9 +3146,9 @@ var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3165
3146
  // src/log.ts
3166
3147
  import { appendFileSync, mkdirSync as mkdirSync3, renameSync as renameSync2, statSync as statSync2 } from "fs";
3167
3148
  import { hostname } from "os";
3168
- import { join as join9 } from "path";
3149
+ import { join as join7 } from "path";
3169
3150
  var HOST = hostname();
3170
- var LOG_FILE = join9(AI4L_LIBRARY_DIR, "library.log");
3151
+ var LOG_FILE = join7(AI4L_LIBRARY_DIR, "library.log");
3171
3152
  function stamp() {
3172
3153
  const d = /* @__PURE__ */ new Date();
3173
3154
  const p = (n) => String(n).padStart(2, "0");
@@ -3355,7 +3336,7 @@ function parseCsl(json2) {
3355
3336
  // src/tools.ts
3356
3337
  var pkg = (() => {
3357
3338
  try {
3358
- return JSON.parse(readFileSync9(new URL("../package.json", import.meta.url), "utf8"));
3339
+ return JSON.parse(readFileSync7(new URL("../package.json", import.meta.url), "utf8"));
3359
3340
  } catch {
3360
3341
  return { name: "ai4l-library", version: "0.0.0" };
3361
3342
  }
@@ -3367,7 +3348,7 @@ Tools:
3367
3348
  - library_status(id) \u2014 read-only: the entry's status, or level "missing".
3368
3349
  - library_read(id, section?, query?, max_chars?, max_hits?) \u2014 one small piece of a stored paper's Markdown, never the whole paper. No section/query: the outline (headings with word counts, and whether it is the full text or the abstract only). section: a heading from the outline (case-insensitive substring) \u2192 that section's text, capped at max_chars. query: search terms \u2192 the paragraphs containing all of them, each with its section, best first, at most max_hits; with no hit the answer lists the headings instead. Every full text converted from PMC/JATS XML has a "Declarations" section \u2014 funding, conflicts of interest, author notes \u2014 so section "Declarations" answers who paid and who declares what in one call; an empty answer there means the paper states nothing. Reads are read-only and may run in parallel.
3369
3350
  - library_verify(claim, id) \u2014 is one finding of a review stated by one source it cites? claim: the finding alone as a complete statement (subject, intervention, outcome, number), without its citations and without the review's framing or inference; id: one source the review cites for it \u2014 a paper, a drug label (dailymed:\u2026) or a trial record (nct:\u2026), each judged on its stored full text the same way. A finding cited to three papers is three calls with the same claim; a claim that names a source, joins two findings or is a fragment is refused. The server reads the paper (full text, else abstract) and asks a decision model; nothing of the paper enters your context. Result: verdict "pass" (score is the probability), "fail" (the full text does not state it), "na" (only the abstract is in the library and does not state it, or only the title) or "error" (claim empty, too short, a fragment, naming a source or compound; paper not in the library; no model; model failure); basis full_text, abstract or title; glyphs_unresolved, the symbols the paper's PDF font gave no character for (above 0: a fail may be the font, not the paper); superseded, true when the claim uses a figure a stored correction replaced (the verdict is kept), null without a stored correction. Call library_add on the paper first.
3370
- - library_info(ids?) \u2014 library-wide, everything the TUI shows: the library directory, the decision model and whether it answers, entries and size on disk, per type (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 (enabled/disabled, in order), the verify model with its endpoint and whether it answers right now (ok, ms, error); ids=true lists every entry ID.
3351
+ - library_info(ids?) \u2014 library-wide, everything the TUI shows: the library directory, the decision model and whether it answers, entries and size on disk, per type (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 retrieval tiers in run order (and which are off), the configuration (config: retry and refresh settings, problems with AI4L_LIBRARY_TIERS, and per variable whether its value came from the environment, .env or the default \u2014 never the value), the verify model with its endpoint and whether it answers right now (ok, ms, error); ids=true lists every entry ID.
3371
3352
  - get_version() \u2014 package name, version and the resolved library directory.
3372
3353
 
3373
3354
  Every action is appended to <library>/library.log, one line per action, prefixed with the host the server runs on.
@@ -3674,8 +3655,8 @@ function registerTools(server2) {
3674
3655
  log("library_read", parsed.requested, "error missing");
3675
3656
  return out({ requested: parsed.requested, entry: paths.entry, error: "missing \u2014 call library_add first" });
3676
3657
  }
3677
- const full = existsSync10(paths.fulltextMd);
3678
- const md = readFileSync9(full ? paths.fulltextMd : paths.md, "utf8");
3658
+ const full = existsSync8(paths.fulltextMd);
3659
+ const md = readFileSync7(full ? paths.fulltextMd : paths.md, "utf8");
3679
3660
  const text3 = full ? "full_text" : "abstract";
3680
3661
  const base = { requested: parsed.requested, entry: paths.entry, text: text3 };
3681
3662
  if (section2) {
@@ -3713,7 +3694,7 @@ function registerTools(server2) {
3713
3694
  );
3714
3695
  server2.tool(
3715
3696
  "library_info",
3716
- "Library-wide summary \u2014 everything the TUI shows: the library directory and host; the decision model with its endpoint and whether it answers; the entry count and the size on disk (size, size_bytes); per entry type (types) the entries and 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 retrieval tiers (enabled/disabled, in order, and where that configuration came from), the decision model behind library_verify (model, endpoint, whether it runs on this machine, and whether it answers right now: ok with the answer time, or the error), the library directory and host. ids=true also lists every entry ID.",
3697
+ "Library-wide summary \u2014 everything the TUI shows: the library directory and host; the decision model with its endpoint and whether it answers; the entry count and the size on disk (size, size_bytes); per entry type (types) the entries and 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 retrieval tiers in run order and which are off (tiers), the configuration (config: settings, problems, and per variable the source of its value \u2014 environment, .env or default \u2014 never the value), the decision model behind library_verify (model, endpoint, whether it runs on this machine, and whether it answers right now: ok with the answer time, or the error), the library directory and host. ids=true also lists every entry ID.",
3717
3698
  { ids: z.boolean().optional().describe("Also list every entry ID (type:value).") },
3718
3699
  async ({ ids }) => {
3719
3700
  const info = libraryInfo(AI4L_LIBRARY_DIR, HOST, ids === true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai4l-library",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "mcpName": "io.github.forever-healthy/ai4l-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",