fh-library 0.2.5 → 0.2.7
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 +14 -0
- package/README.md +8 -3
- package/dist/index.js +516 -56
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# FH Library — Change Log
|
|
2
2
|
|
|
3
|
+
### v0.2.7 — 2026-11-08
|
|
4
|
+
|
|
5
|
+
* **Verdicts are cached** (#26): `library_verify` keeps each judgement as `verify/<2>/<4>/<key>.json` — the file system as the database, laid out like `url/` — and answers the same claim on the same stored text from disk for `FH_LIBRARY_REFRESH_AFTER_DAYS`; a new full text, model or threshold is judged afresh, errors are never kept. The result is unchanged; the log line says `cached`. One atomic file per judgement: several servers on one library need no lock.
|
|
6
|
+
* **Each server publishes its cache counters:** hits and misses of `library_verify` and `library_fetch` since it started, in memory, written to `<library>/.servers/<host>-<pid>.json` on every change and every 5 min as a heartbeat, removed on exit, swept by the next starting server when older than 20 min; `library_info` reports them as `cache`. The server also logs a `startup` line.
|
|
7
|
+
* TUI v79: a `verify` row — stored verdicts ("queries cached"), then hits and misses summed over the servers alive on the library, read from `.servers/`, not from the log; `/status` carries `verdicts`. Resolves `.env` and `library/` from the caller's working directory, as the server does (#31) — `npm run --prefix <repo> tui` from a project root shows that project's library.
|
|
8
|
+
|
|
9
|
+
### v0.2.6 — 2026-11-08
|
|
10
|
+
|
|
11
|
+
* **`library_fetch(url, max_length?, start_index?, raw?)`:** the standard MCP `fetch` tool, served from the library. Same answer as the reference server — `Contents of <url>:`, the page as Markdown with its title as the first heading, the truncation note, the raw-content prefix, the `Failed to fetch …` errors — so a model keeps its fetch habits. Underneath, the page is fetched up the download ladder (plain fetch, Playwright and Chrome rungs, Bright Data) when a plain fetch is refused, and stored under `url/`: the same URL within `FH_LIBRARY_REFRESH_AFTER_DAYS` is served from disk, a failed fetch is not repeated within `FH_LIBRARY_RETRY_AFTER_DAYS`. A PDF comes back as Markdown. `library_info` counts web pages as type `url`; the log line says `cached` or `fetched`.
|
|
12
|
+
* **Script shells climb the ladder:** a page answered 200 whose converted text is under 100 words with a loading/JavaScript phrase (or under 10 words at all) is treated like a wall page — the browser rungs, then Bright Data; a browser rung with so little text waits for the network to go quiet and the text to grow (at most 8 s) before reading the page. When no rung renders more, the shell is served, as fetch would (lifeextension.com's search is such a page: nothing renders it).
|
|
13
|
+
* **Bright Data's 407 is reported:** a token's IP allowlist refusing this host used to surface as a bare "fetch failed" (Node's fetch treats a 407 as a network error); the unlocker now requests over node:https and the log alerts `!! brightdata: unlocker HTTP 407: You are not allowed to access our API via this IP (…)`.
|
|
14
|
+
* TUI v74: a `url` row (web pages, failed fetches); fetch log lines coloured — `cached` green, rung outcomes green/red; `/status` counts `fetches` and `cached`; `(u)pdate` also rescans the library.
|
|
15
|
+
* README: `library_version` returns the package "version", not "library_version" (one more word the prefix rename caught).
|
|
16
|
+
|
|
3
17
|
### v0.2.5 — 2026-11-08
|
|
4
18
|
|
|
5
19
|
* **Tool descriptions** (issue #30): `library_info` names its key `verify`, not `library_verify`; `library_status` answers for one ID of any kind, not "one paper ID"; `library_verify`'s `claim` asks for a complete statement built from the review's words, not a verbatim fragment.
|
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-

|
|
2
2
|

|
|
3
3
|
[_2026-Forever_Healthy-573D7D.svg)](https://forever-healthy.org)
|
|
4
4
|
[](https://www.npmjs.com/package/fh-library)
|
|
@@ -20,10 +20,11 @@ A [Model Context Protocol](https://modelcontextprotocol.io) server that builds a
|
|
|
20
20
|
|
|
21
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 `FH_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
|
-
* `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
|
|
23
|
+
* `library_info(ids?)` → the whole library, everything the TUI shows: the library directory and host, the decision model with its endpoint and whether it answers right now (`verify`), this server's cache hits and misses since it started (`cache`), the entries and their size on disk (`size`), per entry type (`types`: pmid, doi, dailymed, nct) the entries with their levels — `full_text`, `abstract`, `title` — and retractions; entry counts by type and level, counts of retracted and not-citable entries and of entries with errata, expressions of concern and updates, the configured tiers, library directory and host; `ids: true` also lists every entry ID
|
|
24
24
|
* `library_verify(claim, id)` → is one finding of a review stated by one paper it cites? `claim` is the finding alone, verbatim, without its citations; `id` one paper (`pmid:`, or any `doi:`), drug label or trial record the review cites for it — a finding cited to three papers is three calls with the same claim; a claim that names a source or carries citations is refused. The server reads the paper (full text, else abstract) and asks a decision model; nothing of the paper enters the model's context. `verdict` **pass** (`score` the probability), **fail** (the full text does not state it; or only the title is in the library — `reason` says so), **unverifiable** (only the abstract is in the library and does not state it — `reason`) or **error**; `basis` full_text, abstract or title. See "Verifying claims" below.
|
|
25
25
|
* `library_read(id, section?, query?, max_chars?, max_hits?)` → one small piece of a stored paper's Markdown, never the whole paper: without `section`/`query` the **outline** (headings with word counts, and whether the full text or only the abstract is stored; a heading the paper uses several times is listed as `Participants [3/12]`); `section` → that section's **own text** (heading matched as case-insensitive substring, capped at `max_chars`, default 6000, with `returned_words` against `words` when cut) and, for a parent heading, its `subsections` with word counts to pick from — a duplicated heading named bare lists the candidates instead; `query` — two or three distinctive terms the paper itself would use (a measure and a unit, an outcome and a group, a compound name), not the claim — → **passages** holding every term, each a window of at most 1000 chars around the matches (`clipped: true` when cut; a table row with its header row), with its section, best first (at most `max_hits`, default 5); matching forgives case, accents, hyphens, Markdown markup, plural and -ed/-ing forms; when no passage holds every term the best **partial** matches come back (`partial: true`, the `matched` terms per passage, the rare term first), and `missing_terms` names the terms the text never uses in any form — those are the ones to rephrase. `section` and `query` together search inside that section. `section: "Abstract"` on a full text without one answers from the stored abstract. Every full text converted from JATS XML (PMC, Europe PMC, Elsevier) has a **Declarations** section — funding, conflicts of interest, author notes, gathered from wherever the XML keeps them — and on a text without one `section: "Declarations"` assembles the sections headed funding, conflicts, acknowledgements, ethics, contributions, so it answers who paid and who declares what on every source alike; a paper with neither states nothing. Outline first, then pick. A paper not in the library is added first, as `library_add` would (its lines in the log), then library_read; the answer carries `added: true`. Reads may run in parallel.
|
|
26
|
-
* `
|
|
26
|
+
* `library_fetch(url, max_length?, start_index?, raw?)` → a web page as Markdown, exactly as the standard MCP `fetch` tool returns it (see "Web pages"): the one tool whose document enters the model's context
|
|
27
|
+
* `library_version()` → the running server's package name, version, host and library directory
|
|
27
28
|
|
|
28
29
|
IDs follow the `type:value` form: `pmid:`, `doi:`, `dailymed:`, `nct:`; bare digits count as `pmid:`, a bare NCT number as `nct:`.
|
|
29
30
|
|
|
@@ -95,6 +96,8 @@ Every full text is checked before it counts: title present, methods and results
|
|
|
95
96
|
|
|
96
97
|
**The call.** `claim` is one finding, verbatim from the review, with markdown links reduced to their text and its citations removed: `IPSS fell 2.3 points more than placebo in men with ED`. Section labels such as `**Magnitude:**` and list bullets may stay. `id` is one paper the review cites for that finding, as `pmid:<PMID>`, already in the library (`library_add` first). One claim, one paper per call: a finding cited to three papers is three calls with the same claim text, and each answer stands on its own — a source that does not carry the finding is named by its own row, so it can be dropped without touching the others. The server never aggregates over papers. A sentence that joins two findings, each with its own citation, is two claims: the text before each citation group, without the group. A sentence without a citation is not a claim.
|
|
97
98
|
|
|
99
|
+
**Verdicts are kept.** The answer to one claim against one stored text is written to `verify/<2>/<4>/<key>.json` — one file per judgement, laid out like `url/` — and served from there for `FH_LIBRARY_REFRESH_AFTER_DAYS` (30): an audit that runs twice over the same review pays the model once. The key hashes the claim (whitespace aside), the source ID, the model, the threshold and the stored text, so a paper that gains its full text, a changed model or a changed threshold is judged afresh; errors are never kept. The result JSON is the same either way; the log line carries `cached`. Several servers may share the library: a file appears whole or not at all, no lock.
|
|
100
|
+
|
|
98
101
|
**How to build the claim.** The decision model answers exactly the statement it is given, so the statement must be one the paper could make:
|
|
99
102
|
|
|
100
103
|
* **A complete statement:** subject, intervention, outcome, number. Splitting a sentence at its citations leaves fragments ("and dementia", "or silodosin", "across long-acting PDE5 inhibitors, 0.40 percentage points lower"); restore the subject from the sentence ("HbA1c was 0.40 percentage points lower with long-acting PDE5 inhibitors than with control"). Resolve "it", "both", "the drug".
|
|
@@ -139,6 +142,8 @@ PubMed's book records — National Academies reports, NTP monographs, StatPearls
|
|
|
139
142
|
|
|
140
143
|
**Clinical trial records.** `library_add("nct:05173896")` stores the current record of one trial from [ClinicalTrials.gov](https://clinicaltrials.gov/): the ID is the eight digits of the NCT number in the review's link (`…/study/NCT05173896`); `nct:NCT05173896` and a bare `NCT05173896` are accepted and read the same. The API's JSON is converted to Markdown, one section per module: identification with secondary IDs such as EudraCT, status with every date and its actual/estimated type, sponsor and collaborators, description, conditions, design (phase, allocation, masking, enrollment with its type), arms and interventions with doses, primary and secondary outcomes with time frames, eligibility, locations as a country list, references; and, when results are posted, the participant flow, baseline characteristics, every outcome measure with its group values and analyses, and the adverse events, all as tables. Values are written in plain words, "Phase 2", "Active, not recruiting", "100 (estimated)", so a figure the review states can be found as stated. The status carries `nctid`, `publications` (the results publications the record lists, as `pmid:…`, so whether a trial has been published is one status away), the brief title, the lead sponsor as `first_author`, the first-posted year as `year`, the last-update-posted date as `version`, and `publication_types` `["Trial Registration"]`. `library_read` and `library_verify` work on a record as on a paper. An entry older than `FH_LIBRARY_REFRESH_AFTER_DAYS` is re-fetched and rewritten when the record was updated since; a status the review states that has moved on then fails, which is right. Entries live under `nct/<digits 1–2>/<digits 1–4>/<digits>/` with the JSON and the Markdown as `nct-<digits>-fulltext.json` and `-fulltext.md`; the log line reads `library_add nct:05173896 ctgov fetched level=full_text updated 2024-12-09 active, not recruiting ETLAS2`.
|
|
141
144
|
|
|
145
|
+
**Web pages.** `library_fetch(url, max_length?, start_index?, raw?)` is the standard MCP [fetch](https://github.com/modelcontextprotocol/servers/tree/main/src/fetch) tool, served from the library, for every link a review carries that is not a paper, label or trial record: a guideline, a product page, a news item. Its description tells the model to prefer it over a plain fetch or a browser tool, and says why: a page anyone fetched in the last 30 days comes from disk in milliseconds, and a page a plain fetch cannot get climbs the ladder in the same call. The answer is what `fetch` returns, so a model keeps its habits: `Contents of <url>:` and the page as Markdown with the page's title as the first heading, cut at `max_length` (default 5000) with the note `Content truncated. Call the fetch tool with a start_index of N to get more content`; `raw` gives the HTML; a `text/markdown` body comes back as is, any other non-HTML body with the `Content type … cannot be simplified to markdown` prefix; a failure is `Failed to fetch <url> - status code 404` or `Failed to fetch <url>: <reason>`. Two things happen underneath. The page is fetched up the same ladder as a paper — the plain fetch, then the browser rungs on a 403, 429 or bot wall, then Bright Data — so a page a plain fetch cannot reach is still read. And it is stored: `url/<2>/<4>/<16 hex of the URL's SHA-1>/url-<hash>-{meta.md, page.md, page.html|pdf|txt}`, the URL normalised first (host lowercased, fragment and tracking parameters dropped). The same URL within `FH_LIBRARY_REFRESH_AFTER_DAYS` (30) is served from disk, a failed fetch is not repeated within `FH_LIBRARY_RETRY_AFTER_DAYS` (1) — an audit that runs twice over one review reads its links once. A PDF comes back as Markdown (`fetch` would return its bytes as text). The log says which — `fetch example.com/… cached html … https://example.com/drugs/x` or `fetched 02.3s html retrieved_by=browser hetzner … <url>` (the ID column holds the URL without scheme, cut to a PubMed ID's width; the full URL ends the line); `library_info` counts the pages under `url`, a failed fetch as level `title`.
|
|
146
|
+
|
|
142
147
|
**Funding and conflicts of interest.** Every status carries `coi`, the paper's conflict-of-interest statement, and `funding`, who paid. Both come from PubMed's record at ingest (`CoiStatement`, the grant list's agencies; a quarter of records have each) and are refreshed with it; where PubMed has none and a full text was retrieved, they are taken from the paper's own text — its Declarations section, or a section headed Funding, Conflicts of interest, Competing interests or Disclosure — as the statement verbatim. `coi` null and `funding` empty therefore mean: neither PubMed nor the stored text states anything. For an abstract-only entry that is as far as the library can see. The full statements of a paper with full text are one call away: `library_read(id, section: "Declarations")` — the JATS section, or on a PDF- or HTML-sourced text the funding, conflict and acknowledgement sections assembled. In `-meta.md` they are the lines `coi:` (a JSON string) and `funding:` (a JSON array, one item per agency or statement).
|
|
143
148
|
|
|
144
149
|
**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.
|
package/dist/index.js
CHANGED
|
@@ -11,10 +11,10 @@ function dotenvFile(env = process.env) {
|
|
|
11
11
|
return join(resolve(lib), ".env");
|
|
12
12
|
}
|
|
13
13
|
var loadedFromDotenv = /* @__PURE__ */ new Set();
|
|
14
|
-
function loadDotenv(
|
|
15
|
-
if (!existsSync(
|
|
14
|
+
function loadDotenv(file2 = dotenvFile()) {
|
|
15
|
+
if (!existsSync(file2)) return [];
|
|
16
16
|
const loaded = [];
|
|
17
|
-
for (const raw of readFileSync(
|
|
17
|
+
for (const raw of readFileSync(file2, "utf8").split(/\r?\n/)) {
|
|
18
18
|
const line = raw.trim();
|
|
19
19
|
if (!line || line.startsWith("#")) continue;
|
|
20
20
|
const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
@@ -60,6 +60,11 @@ var contactEmail = (env = process.env) => str("FH_LIBRARY_CONTACT_EMAIL", env) |
|
|
|
60
60
|
// src/http.ts
|
|
61
61
|
var TIMEOUT_MS = 3e4;
|
|
62
62
|
var CHROME_UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36";
|
|
63
|
+
var BROWSER_HEADERS = {
|
|
64
|
+
"User-Agent": CHROME_UA,
|
|
65
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
66
|
+
"Accept-Language": "en-US,en;q=0.9"
|
|
67
|
+
};
|
|
63
68
|
var LIBRARY_UA = `fh-library (+https://github.com/forever-healthy/fh-library; ${contactEmail()})`;
|
|
64
69
|
function httpReason(status, body) {
|
|
65
70
|
const m = body.length < 4e3 && /<statusCode>([A-Z_]+)<\/statusCode>|"statusCode"\s*:\s*"([A-Z_]+)"/.exec(body);
|
|
@@ -655,12 +660,12 @@ function headingOf(line) {
|
|
|
655
660
|
if (bare2.length > 60 || !/^[A-Za-z][A-Za-z &'\u2019]*$/.test(bare2)) return null;
|
|
656
661
|
return BY_SQUEEZED.get(squeeze(bare2)) ?? null;
|
|
657
662
|
}
|
|
658
|
-
async function pdfToMarkdown(bytes, title, headLine2,
|
|
663
|
+
async function pdfToMarkdown(bytes, title, headLine2, stats2) {
|
|
659
664
|
const pdf = await getDocumentProxy(new Uint8Array(bytes), { fontExtraProperties: true });
|
|
660
665
|
const text3 = await extractPages(pdf);
|
|
661
|
-
if (
|
|
662
|
-
|
|
663
|
-
|
|
666
|
+
if (stats2) {
|
|
667
|
+
stats2.pages = pdf.numPages;
|
|
668
|
+
stats2.chars = text3.replace(/\s+/g, " ").trim().length;
|
|
664
669
|
}
|
|
665
670
|
const lines = text3.split(/\r?\n/).map((l) => l.replace(/\s+/g, " ").trim());
|
|
666
671
|
const out = [`# ${title}`, "", headLine2, ""];
|
|
@@ -1000,6 +1005,8 @@ var NAV_TIMEOUT_MS = 2e4;
|
|
|
1000
1005
|
var CHALLENGE_WAIT_MS = 8e3;
|
|
1001
1006
|
var DOWNLOAD_WAIT_MS = 1e4;
|
|
1002
1007
|
var IDLE_CLOSE_MS = 12e4;
|
|
1008
|
+
var SETTLE_MS = 8e3;
|
|
1009
|
+
var SETTLE_WORDS = 100;
|
|
1003
1010
|
var ctxs = /* @__PURE__ */ new Map();
|
|
1004
1011
|
var idleTimer = null;
|
|
1005
1012
|
var LAUNCH_ARGS = ["--disable-blink-features=AutomationControlled", "--window-size=1366,900"];
|
|
@@ -1141,6 +1148,12 @@ async function browserFetch(url, endpoint, cfg = browserConfig()) {
|
|
|
1141
1148
|
if (!r.ok()) throw new Error(`HTTP ${r.status()} (pdf request)`);
|
|
1142
1149
|
return { bytes: Buffer.from(await r.body()), contentType: "application/pdf", url: page.url(), via: "request" };
|
|
1143
1150
|
}
|
|
1151
|
+
const words2 = () => page.evaluate(() => (document.body?.innerText ?? "").split(/\s+/).filter(Boolean).length).catch(() => 0);
|
|
1152
|
+
if (await words2() < SETTLE_WORDS) {
|
|
1153
|
+
await page.waitForLoadState("networkidle", { timeout: SETTLE_MS }).catch(() => void 0);
|
|
1154
|
+
const t2 = Date.now();
|
|
1155
|
+
while (Date.now() - t2 < SETTLE_MS && await words2() < SETTLE_WORDS) await new Promise((r) => setTimeout(r, 1e3));
|
|
1156
|
+
}
|
|
1144
1157
|
const html = await page.content();
|
|
1145
1158
|
return { bytes: Buffer.from(html, "utf8"), contentType: contentType || "text/html", url: page.url(), via: "page" };
|
|
1146
1159
|
} finally {
|
|
@@ -1150,14 +1163,39 @@ async function browserFetch(url, endpoint, cfg = browserConfig()) {
|
|
|
1150
1163
|
}
|
|
1151
1164
|
|
|
1152
1165
|
// src/unlock.ts
|
|
1166
|
+
import { request as httpsRequest } from "https";
|
|
1153
1167
|
var ENDPOINT = "https://api.brightdata.com/request";
|
|
1154
1168
|
var TIMEOUT_MS2 = 6e4;
|
|
1169
|
+
function post(url, init) {
|
|
1170
|
+
return new Promise((resolve3, reject) => {
|
|
1171
|
+
const req = httpsRequest(url, { method: "POST", headers: { ...init.headers, "Content-Length": String(Buffer.byteLength(init.body)) }, signal: init.signal }, (res) => {
|
|
1172
|
+
const chunks = [];
|
|
1173
|
+
res.on("data", (c) => chunks.push(c));
|
|
1174
|
+
res.on("error", reject);
|
|
1175
|
+
res.on("end", () => {
|
|
1176
|
+
const body = Buffer.concat(chunks);
|
|
1177
|
+
const status = res.statusCode ?? 0;
|
|
1178
|
+
resolve3({
|
|
1179
|
+
ok: status >= 200 && status < 300,
|
|
1180
|
+
status,
|
|
1181
|
+
headers: { get: (name) => {
|
|
1182
|
+
const v = res.headers[name.toLowerCase()];
|
|
1183
|
+
return v == null ? null : Array.isArray(v) ? v.join(", ") : v;
|
|
1184
|
+
} },
|
|
1185
|
+
arrayBuffer: async () => body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength)
|
|
1186
|
+
});
|
|
1187
|
+
});
|
|
1188
|
+
});
|
|
1189
|
+
req.on("error", (e) => reject(e.name === "AbortError" || e.name === "TimeoutError" ? new Error("timeout") : e));
|
|
1190
|
+
req.end(init.body);
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1155
1193
|
var unlockerConfigured = () => !!process.env.FH_LIBRARY_BRIGHTDATA_API_TOKEN?.trim();
|
|
1156
|
-
async function unlock(url,
|
|
1194
|
+
async function unlock(url, postImpl = post) {
|
|
1157
1195
|
const token = process.env.FH_LIBRARY_BRIGHTDATA_API_TOKEN?.trim();
|
|
1158
1196
|
if (!token) throw new Error("unlocker not configured");
|
|
1159
1197
|
const zone = process.env.FH_LIBRARY_BRIGHTDATA_ZONE?.trim() || "mcp_unlocker";
|
|
1160
|
-
const res = await
|
|
1198
|
+
const res = await postImpl(ENDPOINT, {
|
|
1161
1199
|
method: "POST",
|
|
1162
1200
|
headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
|
|
1163
1201
|
body: JSON.stringify({ zone, url, format: "raw" }),
|
|
@@ -1264,13 +1302,13 @@ async function convertBytes(p, bytes, contentType = "") {
|
|
|
1264
1302
|
let md;
|
|
1265
1303
|
if (looksLikePdf(bytes)) {
|
|
1266
1304
|
format = "pdf";
|
|
1267
|
-
const
|
|
1305
|
+
const stats2 = {};
|
|
1268
1306
|
try {
|
|
1269
|
-
md = await pdfToMarkdown(bytes, p.title, p.headLine,
|
|
1307
|
+
md = await pdfToMarkdown(bytes, p.title, p.headLine, stats2);
|
|
1270
1308
|
} catch (e) {
|
|
1271
1309
|
return { ok: false, reason: `pdf: ${errMsg(e)}` };
|
|
1272
1310
|
}
|
|
1273
|
-
if ((
|
|
1311
|
+
if ((stats2.pages ?? 0) >= 1 && (stats2.chars ?? 0) < 500 * (stats2.pages ?? 1)) return { ok: false, reason: "scanned pdf", format };
|
|
1274
1312
|
} else {
|
|
1275
1313
|
const text3 = bytes.toString("utf8");
|
|
1276
1314
|
if (isWallPage(text3)) return { ok: false, reason: "wall page" };
|
|
@@ -1462,15 +1500,15 @@ function inboxTier(dir = INBOX_DIR) {
|
|
|
1462
1500
|
always: true,
|
|
1463
1501
|
applies: (p) => p.pmid ? true : "no pmid",
|
|
1464
1502
|
async run(p, ctx) {
|
|
1465
|
-
const
|
|
1466
|
-
if (!
|
|
1467
|
-
const name =
|
|
1503
|
+
const file2 = findInboxFile(dir, p.pmid);
|
|
1504
|
+
if (!file2) return { ...miss("inbox", "no file"), skipped: true, ephemeral: true };
|
|
1505
|
+
const name = file2.slice(file2.lastIndexOf("/") + 1);
|
|
1468
1506
|
const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
|
|
1469
|
-
const bytes = readFileSync3(
|
|
1507
|
+
const bytes = readFileSync3(file2);
|
|
1470
1508
|
const r = await convertBytes(p, bytes, EXT[ext]);
|
|
1471
1509
|
if (!r.ok) return miss("inbox", `${name}: ${r.reason}`);
|
|
1472
1510
|
ctx.onTry?.(`${name} ok, removed from the inbox`);
|
|
1473
|
-
unlinkSync(
|
|
1511
|
+
unlinkSync(file2);
|
|
1474
1512
|
return { ok: true, tier: "inbox", format: r.format, raw: r.format === "pdf" ? bytes : bytes.toString("utf8"), md: r.md, version: "published" };
|
|
1475
1513
|
}
|
|
1476
1514
|
};
|
|
@@ -1743,16 +1781,16 @@ function stamp() {
|
|
|
1743
1781
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
1744
1782
|
}
|
|
1745
1783
|
var LOG_MAX_BYTES = 50 * 1024 * 1024;
|
|
1746
|
-
function rotateIfLarge(
|
|
1784
|
+
function rotateIfLarge(file2, max = LOG_MAX_BYTES) {
|
|
1747
1785
|
let size = 0;
|
|
1748
1786
|
try {
|
|
1749
|
-
size = statSync(
|
|
1787
|
+
size = statSync(file2).size;
|
|
1750
1788
|
} catch {
|
|
1751
1789
|
return false;
|
|
1752
1790
|
}
|
|
1753
1791
|
if (size <= max) return false;
|
|
1754
1792
|
try {
|
|
1755
|
-
renameSync(
|
|
1793
|
+
renameSync(file2, `${file2}.1`);
|
|
1756
1794
|
return true;
|
|
1757
1795
|
} catch {
|
|
1758
1796
|
return false;
|
|
@@ -1770,9 +1808,9 @@ function log(tool, id, message) {
|
|
|
1770
1808
|
}
|
|
1771
1809
|
}
|
|
1772
1810
|
|
|
1773
|
-
// src/
|
|
1774
|
-
import {
|
|
1775
|
-
import {
|
|
1811
|
+
// src/stats.ts
|
|
1812
|
+
import { mkdirSync as mkdirSync4, readdirSync as readdirSync3, readFileSync as readFileSync5, statSync as statSync2, unlinkSync as unlinkSync2 } from "fs";
|
|
1813
|
+
import { join as join7 } from "path";
|
|
1776
1814
|
|
|
1777
1815
|
// src/entry.ts
|
|
1778
1816
|
import { copyFileSync, existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync4, renameSync as renameSync2, rmSync, writeFileSync } from "fs";
|
|
@@ -2591,10 +2629,10 @@ function readMeta(p) {
|
|
|
2591
2629
|
attempts: splitAttempts(fm.attempts)
|
|
2592
2630
|
};
|
|
2593
2631
|
}
|
|
2594
|
-
function writeAtomic(
|
|
2595
|
-
const tmp = `${
|
|
2632
|
+
function writeAtomic(file2, data) {
|
|
2633
|
+
const tmp = `${file2}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
2596
2634
|
writeFileSync(tmp, data);
|
|
2597
|
-
renameSync2(tmp,
|
|
2635
|
+
renameSync2(tmp, file2);
|
|
2598
2636
|
}
|
|
2599
2637
|
function writeMeta(p, m) {
|
|
2600
2638
|
writeAtomic(p.meta, renderMeta(m));
|
|
@@ -2873,6 +2911,67 @@ function writeDoiEntry(p, r, json2, converter, abstractFrom = "crossref", record
|
|
|
2873
2911
|
return meta;
|
|
2874
2912
|
}
|
|
2875
2913
|
|
|
2914
|
+
// src/stats.ts
|
|
2915
|
+
var HEARTBEAT_MS = 5 * 6e4;
|
|
2916
|
+
var STALE_MS = 20 * 6e4;
|
|
2917
|
+
var stats = null;
|
|
2918
|
+
var file = null;
|
|
2919
|
+
function startStats(pkg2, libraryDir = FH_LIBRARY_DIR) {
|
|
2920
|
+
stats = { host: HOST, pid: process.pid, package: pkg2.name, version: pkg2.version, started: (/* @__PURE__ */ new Date()).toISOString(), verify: { hits: 0, misses: 0 }, fetch: { hits: 0, misses: 0 } };
|
|
2921
|
+
file = join7(libraryDir, ".servers", `${HOST}-${process.pid}.json`);
|
|
2922
|
+
sweep(join7(libraryDir, ".servers"));
|
|
2923
|
+
publish();
|
|
2924
|
+
setInterval(publish, HEARTBEAT_MS).unref();
|
|
2925
|
+
const stop = () => {
|
|
2926
|
+
try {
|
|
2927
|
+
if (file) unlinkSync2(file);
|
|
2928
|
+
} catch {
|
|
2929
|
+
}
|
|
2930
|
+
};
|
|
2931
|
+
process.on("exit", stop);
|
|
2932
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => process.exit(0));
|
|
2933
|
+
return stats;
|
|
2934
|
+
}
|
|
2935
|
+
function sweep(dir, staleMs = STALE_MS) {
|
|
2936
|
+
let n = 0;
|
|
2937
|
+
let names;
|
|
2938
|
+
try {
|
|
2939
|
+
names = readdirSync3(dir);
|
|
2940
|
+
} catch {
|
|
2941
|
+
return 0;
|
|
2942
|
+
}
|
|
2943
|
+
for (const name of names) {
|
|
2944
|
+
if (!name.endsWith(".json")) continue;
|
|
2945
|
+
const p = join7(dir, name);
|
|
2946
|
+
try {
|
|
2947
|
+
if (Date.now() - statSync2(p).mtimeMs > staleMs) {
|
|
2948
|
+
unlinkSync2(p);
|
|
2949
|
+
n++;
|
|
2950
|
+
}
|
|
2951
|
+
} catch {
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
return n;
|
|
2955
|
+
}
|
|
2956
|
+
function bump(kind, hit) {
|
|
2957
|
+
if (!stats) return;
|
|
2958
|
+
stats[kind][hit ? "hits" : "misses"]++;
|
|
2959
|
+
publish();
|
|
2960
|
+
}
|
|
2961
|
+
var currentStats = () => stats;
|
|
2962
|
+
function publish() {
|
|
2963
|
+
if (!stats || !file) return;
|
|
2964
|
+
try {
|
|
2965
|
+
mkdirSync4(join7(file, ".."), { recursive: true });
|
|
2966
|
+
writeAtomic(file, JSON.stringify(stats, null, 2) + "\n");
|
|
2967
|
+
} catch {
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
// src/tools.ts
|
|
2972
|
+
import { existsSync as existsSync10, readFileSync as readFileSync10 } from "fs";
|
|
2973
|
+
import { z } from "zod";
|
|
2974
|
+
|
|
2876
2975
|
// src/cascade.ts
|
|
2877
2976
|
var PERMANENT = /^(no pmcid|no doi|no pmid|no pmid or doi|no body|closed|no oa copy|no free copy|not in core|no full text|xml without body|template has no placeholder|title missing|title not in text|no methods\/results|no article body|scanned pdf|not a pdf|only copies already tried|only pmc copies|nothing located|unknown content|no text-mining links|needs [a-z/]+ token)/i;
|
|
2878
2977
|
var BLOCKED = /^(challenge not cleared|empty response)/i;
|
|
@@ -2945,11 +3044,11 @@ function parseId(raw) {
|
|
|
2945
3044
|
}
|
|
2946
3045
|
|
|
2947
3046
|
// src/info.ts
|
|
2948
|
-
import { existsSync as existsSync7, readdirSync as
|
|
2949
|
-
import { dirname as dirname2, join as
|
|
3047
|
+
import { existsSync as existsSync7, readdirSync as readdirSync4, readFileSync as readFileSync7, statSync as statSync3 } from "fs";
|
|
3048
|
+
import { dirname as dirname2, join as join8 } from "path";
|
|
2950
3049
|
|
|
2951
3050
|
// src/verify.ts
|
|
2952
|
-
import { existsSync as existsSync6, readFileSync as
|
|
3051
|
+
import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
|
|
2953
3052
|
var DEFAULT_MODEL = "pplx-decider-flash-v1.2";
|
|
2954
3053
|
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.";
|
|
2955
3054
|
function verifyConfig(env = process.env) {
|
|
@@ -3051,9 +3150,9 @@ async function readPaper(raw, libraryDir = FH_LIBRARY_DIR) {
|
|
|
3051
3150
|
const paths = entryPaths(libraryDir, p.type, p.id);
|
|
3052
3151
|
if (!entryExists(paths)) return { id: p.requested, error: "not in library" };
|
|
3053
3152
|
const full = existsSync6(paths.fulltextMd);
|
|
3054
|
-
if (full) return { id: p.requested, basis: "full_text", text:
|
|
3153
|
+
if (full) return { id: p.requested, basis: "full_text", text: readFileSync6(paths.fulltextMd, "utf8") };
|
|
3055
3154
|
const meta = readMeta(paths);
|
|
3056
|
-
return { id: p.requested, basis: meta.has_abstract ? "abstract" : "title", text:
|
|
3155
|
+
return { id: p.requested, basis: meta.has_abstract ? "abstract" : "title", text: readFileSync6(paths.md, "utf8") };
|
|
3057
3156
|
}
|
|
3058
3157
|
async function verifyClaim(claim, id, cfg, decide, read = readPaper) {
|
|
3059
3158
|
const out = { claim, id, verdict: "error", reason: null, score: null, basis: null, windows: 0, glyphs_unresolved: 0, superseded: null, model: cfg?.model ?? null };
|
|
@@ -3095,9 +3194,9 @@ async function verifyClaim(claim, id, cfg, decide, read = readPaper) {
|
|
|
3095
3194
|
// src/info.ts
|
|
3096
3195
|
function* metaFiles(dir) {
|
|
3097
3196
|
if (!existsSync7(dir)) return;
|
|
3098
|
-
for (const name of
|
|
3099
|
-
const p =
|
|
3100
|
-
if (
|
|
3197
|
+
for (const name of readdirSync4(dir)) {
|
|
3198
|
+
const p = join8(dir, name);
|
|
3199
|
+
if (statSync3(p).isDirectory()) yield* metaFiles(p);
|
|
3101
3200
|
else if (name.endsWith("-meta.md")) yield p;
|
|
3102
3201
|
}
|
|
3103
3202
|
}
|
|
@@ -3135,11 +3234,11 @@ function libraryInfo(libraryDir, host, withIds, tiers = resolveTiers()) {
|
|
|
3135
3234
|
updates: 0
|
|
3136
3235
|
};
|
|
3137
3236
|
const ids = [];
|
|
3138
|
-
for (const type of existsSync7(libraryDir) ?
|
|
3139
|
-
const typeDir =
|
|
3140
|
-
if (!
|
|
3237
|
+
for (const type of existsSync7(libraryDir) ? readdirSync4(libraryDir) : []) {
|
|
3238
|
+
const typeDir = join8(libraryDir, type);
|
|
3239
|
+
if (!statSync3(typeDir).isDirectory()) continue;
|
|
3141
3240
|
for (const meta of metaFiles(typeDir)) {
|
|
3142
|
-
const md =
|
|
3241
|
+
const md = readFileSync7(meta, "utf8");
|
|
3143
3242
|
const raw = field(md, "level") || "unknown";
|
|
3144
3243
|
const level = raw === "abstract" && field(md, "has_abstract") === "no" ? "title" : raw;
|
|
3145
3244
|
info.entries++;
|
|
@@ -3149,9 +3248,9 @@ function libraryInfo(libraryDir, host, withIds, tiers = resolveTiers()) {
|
|
|
3149
3248
|
t.entries++;
|
|
3150
3249
|
if (level === "full_text" || level === "abstract" || level === "title") t[level]++;
|
|
3151
3250
|
const dir = dirname2(meta);
|
|
3152
|
-
for (const f of
|
|
3251
|
+
for (const f of readdirSync4(dir)) {
|
|
3153
3252
|
try {
|
|
3154
|
-
const st =
|
|
3253
|
+
const st = statSync3(join8(dir, f));
|
|
3155
3254
|
if (st.isFile()) info.size_bytes += st.size;
|
|
3156
3255
|
} catch {
|
|
3157
3256
|
}
|
|
@@ -3180,7 +3279,7 @@ function noticeTypes(md, metaPath) {
|
|
|
3180
3279
|
return out;
|
|
3181
3280
|
}
|
|
3182
3281
|
try {
|
|
3183
|
-
const xml =
|
|
3282
|
+
const xml = readFileSync7(metaPath.replace(/-meta\.md$/, "-abstract.xml"), "utf8");
|
|
3184
3283
|
for (const m of xml.matchAll(/<CommentsCorrections RefType="([A-Za-z]+)"/g)) if (XML_TYPES[m[1]]) out.add(XML_TYPES[m[1]]);
|
|
3185
3284
|
} catch {
|
|
3186
3285
|
}
|
|
@@ -3557,15 +3656,342 @@ function parseCsl(json2) {
|
|
|
3557
3656
|
};
|
|
3558
3657
|
}
|
|
3559
3658
|
|
|
3659
|
+
// src/urlfetch.ts
|
|
3660
|
+
import { createHash as createHash2 } from "crypto";
|
|
3661
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync8 } from "fs";
|
|
3662
|
+
import { join as join9 } from "path";
|
|
3663
|
+
var TRACKING = /^(utm_[a-z]+|fbclid|gclid|dclid|msclkid|mc_cid|mc_eid|igshid|ref_src)$/i;
|
|
3664
|
+
function normalizeUrl(raw) {
|
|
3665
|
+
let u;
|
|
3666
|
+
try {
|
|
3667
|
+
u = new URL(raw.trim());
|
|
3668
|
+
} catch {
|
|
3669
|
+
return null;
|
|
3670
|
+
}
|
|
3671
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
|
3672
|
+
u.hash = "";
|
|
3673
|
+
u.hostname = u.hostname.toLowerCase();
|
|
3674
|
+
if ([...u.searchParams.keys()].some((k) => TRACKING.test(k))) {
|
|
3675
|
+
for (const k of [...u.searchParams.keys()]) if (TRACKING.test(k)) u.searchParams.delete(k);
|
|
3676
|
+
if (![...u.searchParams.keys()].length) u.search = "";
|
|
3677
|
+
}
|
|
3678
|
+
return u.toString();
|
|
3679
|
+
}
|
|
3680
|
+
var urlHash = (normalized2) => createHash2("sha1").update(normalized2).digest("hex").slice(0, 16);
|
|
3681
|
+
function urlPaths(libraryDir, normalized2) {
|
|
3682
|
+
const hash = urlHash(normalized2);
|
|
3683
|
+
const entry = `url-${hash}`;
|
|
3684
|
+
const entryDir = join9(libraryDir, "url", hash.slice(0, 2), hash.slice(0, 4), hash);
|
|
3685
|
+
return { hash, entry, entryDir, meta: join9(entryDir, `${entry}-meta.md`), md: join9(entryDir, `${entry}-page.md`), raw: (ext) => join9(entryDir, `${entry}-page.${ext}`) };
|
|
3686
|
+
}
|
|
3687
|
+
function renderUrlMeta(m) {
|
|
3688
|
+
const lines = [
|
|
3689
|
+
["url", m.url],
|
|
3690
|
+
["final_url", m.final_url],
|
|
3691
|
+
["title", m.title === null ? "" : JSON.stringify(m.title)],
|
|
3692
|
+
["format", m.format],
|
|
3693
|
+
["content_type", m.content_type],
|
|
3694
|
+
["level", m.level],
|
|
3695
|
+
["retrieved_by", m.retrieved_by],
|
|
3696
|
+
["fetched", m.fetched],
|
|
3697
|
+
["error", m.error ?? ""],
|
|
3698
|
+
["files", m.files.join(", ")],
|
|
3699
|
+
["converted_by", m.converted_by]
|
|
3700
|
+
];
|
|
3701
|
+
return lines.map(([k, v]) => `${k}: ${v}`).join("\n") + "\n";
|
|
3702
|
+
}
|
|
3703
|
+
function readUrlMeta(p) {
|
|
3704
|
+
if (!existsSync8(p.meta)) return null;
|
|
3705
|
+
const md = readFileSync8(p.meta, "utf8");
|
|
3706
|
+
const f = (k) => {
|
|
3707
|
+
const m = new RegExp(`^${k}:\\s*(.*)$`, "m").exec(md);
|
|
3708
|
+
return m ? m[1].trim() : "";
|
|
3709
|
+
};
|
|
3710
|
+
let title = null;
|
|
3711
|
+
try {
|
|
3712
|
+
title = f("title") ? JSON.parse(f("title")) : null;
|
|
3713
|
+
} catch {
|
|
3714
|
+
title = f("title") || null;
|
|
3715
|
+
}
|
|
3716
|
+
return {
|
|
3717
|
+
url: f("url"),
|
|
3718
|
+
final_url: f("final_url"),
|
|
3719
|
+
title,
|
|
3720
|
+
format: f("format") || "failed",
|
|
3721
|
+
content_type: f("content_type"),
|
|
3722
|
+
level: f("level") === "full_text" ? "full_text" : "title",
|
|
3723
|
+
retrieved_by: f("retrieved_by"),
|
|
3724
|
+
fetched: f("fetched"),
|
|
3725
|
+
error: f("error") || null,
|
|
3726
|
+
files: f("files") ? f("files").split(",").map((s) => s.trim()).filter(Boolean) : [],
|
|
3727
|
+
converted_by: f("converted_by")
|
|
3728
|
+
};
|
|
3729
|
+
}
|
|
3730
|
+
function writeUrlMeta(p, m) {
|
|
3731
|
+
mkdirSync5(p.entryDir, { recursive: true });
|
|
3732
|
+
writeAtomic(p.meta, renderUrlMeta(m));
|
|
3733
|
+
}
|
|
3734
|
+
var SHELL_WORDS = 100;
|
|
3735
|
+
var EMPTY_WORDS = 10;
|
|
3736
|
+
var SHELL_PHRASE = /\bloading\b|javascript|enable it|connecting to|please wait|redirecting/i;
|
|
3737
|
+
function isScriptShell(markdownOrHtml) {
|
|
3738
|
+
const text3 = markdownOrHtml.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<noscript[\s\S]*?<\/noscript>/gi, " ").replace(/<[^>]+>/g, " ").replace(/&[a-z#0-9]+;/gi, " ").replace(/\]\([^)]*\)/g, ")");
|
|
3739
|
+
const words2 = text3.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length;
|
|
3740
|
+
return { shell: words2 < EMPTY_WORDS || words2 < SHELL_WORDS && SHELL_PHRASE.test(text3), words: words2 };
|
|
3741
|
+
}
|
|
3742
|
+
var viaName = (via) => typeof via === "object" ? `browser ${via.label}` : via;
|
|
3743
|
+
async function rung(url, via) {
|
|
3744
|
+
const name = viaName(via);
|
|
3745
|
+
let got;
|
|
3746
|
+
try {
|
|
3747
|
+
got = via === "brightdata" ? await unlock(url) : typeof via === "object" ? await browserFetch(url, via.browser) : await fetchBytes(url, BROWSER_HEADERS);
|
|
3748
|
+
} catch (e) {
|
|
3749
|
+
return { ok: false, reason: errMsg(e), url: e?.url, via: name };
|
|
3750
|
+
}
|
|
3751
|
+
if (via === "brightdata" && !got.bytes.length) return { ok: false, reason: "empty response", via: name };
|
|
3752
|
+
const r = { ok: true, bytes: got.bytes, contentType: got.contentType, url: got.url ?? url, via: name };
|
|
3753
|
+
if (!looksLikePdf(got.bytes)) {
|
|
3754
|
+
const text3 = got.bytes.toString("utf8");
|
|
3755
|
+
if (isHtml(got.contentType, text3)) {
|
|
3756
|
+
if (isWallPage(text3)) return { ok: false, reason: "wall page", url: r.url, via: name };
|
|
3757
|
+
let md = "";
|
|
3758
|
+
try {
|
|
3759
|
+
md = htmlToMarkdown(/<body[^>]*>([\s\S]*)<\/body>/i.exec(text3)?.[1] ?? text3);
|
|
3760
|
+
} catch {
|
|
3761
|
+
}
|
|
3762
|
+
const s = isScriptShell(md || text3);
|
|
3763
|
+
if (s.shell) return { ok: false, reason: `script shell (${s.words} words)`, url: r.url, via: name, shell: r };
|
|
3764
|
+
}
|
|
3765
|
+
}
|
|
3766
|
+
return r;
|
|
3767
|
+
}
|
|
3768
|
+
var climb = async (url, onTry, onAlert) => {
|
|
3769
|
+
let r = await rung(url, "fetch");
|
|
3770
|
+
const firstReason = r.ok ? void 0 : r.reason;
|
|
3771
|
+
const target = !r.ok && r.url && r.url !== url ? r.url : url;
|
|
3772
|
+
if (target !== url) onTry?.(`\u2192 ${target}`);
|
|
3773
|
+
onTry?.(r.ok ? "ok fetch" : `failed fetch: ${r.reason}`);
|
|
3774
|
+
const escalates = (g) => !g.ok && (g.via !== "fetch" || /^(HTTP 403|HTTP 429|wall page|script shell)/.test(g.reason));
|
|
3775
|
+
let shell = !r.ok ? r.shell : void 0;
|
|
3776
|
+
for (const b of browserRungs()) {
|
|
3777
|
+
if (!escalates(r)) break;
|
|
3778
|
+
r = await rung(target, { browser: b.endpoint, label: b.label });
|
|
3779
|
+
if (!r.ok && r.shell && !shell) shell = r.shell;
|
|
3780
|
+
onTry?.(r.ok ? `ok browser ${b.label}` : `failed browser ${b.label}: ${r.reason}`);
|
|
3781
|
+
if (!r.ok && r.reason.startsWith(MISMATCH)) onAlert?.(`browser ${b.label}: ${r.reason}`);
|
|
3782
|
+
}
|
|
3783
|
+
if (!r.ok && unlockerConfigured() && (r.via !== "fetch" || /^(HTTP 403|wall page|script shell)/.test(r.reason))) {
|
|
3784
|
+
r = await rung(target, "brightdata");
|
|
3785
|
+
onTry?.(r.ok ? "ok brightdata" : `failed brightdata: ${r.reason}`);
|
|
3786
|
+
if (!r.ok && /^unlocker HTTP/.test(r.reason)) onAlert?.(`brightdata: ${r.reason}`);
|
|
3787
|
+
}
|
|
3788
|
+
if (!r.ok && shell) {
|
|
3789
|
+
onTry?.(`script shell kept: no rung rendered the page`);
|
|
3790
|
+
return shell;
|
|
3791
|
+
}
|
|
3792
|
+
return r.ok || r.via === "fetch" ? r : { ...r, first: firstReason };
|
|
3793
|
+
};
|
|
3794
|
+
var mediaType = (contentType) => contentType.split(";")[0].trim().toLowerCase();
|
|
3795
|
+
function isHtml(contentType, text3) {
|
|
3796
|
+
const ct = contentType.toLowerCase();
|
|
3797
|
+
if (/<html/i.test(text3.slice(0, 100))) return true;
|
|
3798
|
+
if (ct.includes("text/html") || ct.includes("application/xhtml+xml")) return true;
|
|
3799
|
+
return !ct && /^(<!doctype html|<html)/.test(text3.replace(/^\uFEFF/, "").trimStart().slice(0, 20).toLowerCase());
|
|
3800
|
+
}
|
|
3801
|
+
var ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", ndash: "\u2013", mdash: "\u2014", hellip: "\u2026", copy: "\xA9", reg: "\xAE", trade: "\u2122" };
|
|
3802
|
+
var decode2 = (s) => s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
|
|
3803
|
+
if (e[0] === "#") {
|
|
3804
|
+
const n = e[1].toLowerCase() === "x" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
|
|
3805
|
+
return Number.isFinite(n) ? String.fromCodePoint(n) : m;
|
|
3806
|
+
}
|
|
3807
|
+
return ENTITIES[e.toLowerCase()] ?? m;
|
|
3808
|
+
});
|
|
3809
|
+
var visibleText = (html) => decode2(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").replace(/¶\s*$/, "").trim();
|
|
3810
|
+
var normalized = (text3) => {
|
|
3811
|
+
const words2 = text3.toLowerCase().match(/[\p{L}\p{N}]+/gu);
|
|
3812
|
+
return words2 ? ` ${words2.join(" ")} ` : "";
|
|
3813
|
+
};
|
|
3814
|
+
function pageTitle(html) {
|
|
3815
|
+
const head = html.slice(0, 2e5);
|
|
3816
|
+
const og = /<meta\b[^>]*\bproperty\s*=\s*["']og:title["'][^>]*>/i.exec(head)?.[0] ?? /<meta\b[^>]*\bcontent\s*=\s*["'][^"']*["'][^>]*\bproperty\s*=\s*["']og:title["'][^>]*>/i.exec(head)?.[0];
|
|
3817
|
+
const ogTitle = og ? visibleText(/\bcontent\s*=\s*("([^"]*)"|'([^']*)')/i.exec(og)?.[2] ?? /\bcontent\s*=\s*'([^']*)'/i.exec(og)?.[1] ?? "") : "";
|
|
3818
|
+
const title = visibleText(/<title[^>]*>([\s\S]*?)<\/title>/i.exec(head)?.[1] ?? "");
|
|
3819
|
+
const h1 = visibleText(/<h1\b[^>]*>([\s\S]*?)<\/h1>/i.exec(html)?.[1] ?? "");
|
|
3820
|
+
const headingInTitle = !!normalized(h1) && normalized(title).includes(normalized(h1));
|
|
3821
|
+
if (ogTitle) return ogTitle;
|
|
3822
|
+
if (headingInTitle) return h1;
|
|
3823
|
+
return title || h1 || null;
|
|
3824
|
+
}
|
|
3825
|
+
var visibleLine = (line) => line.replace(/\]\([^)]*\)/g, " ").replace(/<[a-zA-Z/!?][^>]*>/g, " ");
|
|
3826
|
+
function opensWithTitle(content, title) {
|
|
3827
|
+
const first = content.split("\n").find((l) => l.trim()) ?? "";
|
|
3828
|
+
const line = normalized(visibleLine(first)), wanted = normalized(title);
|
|
3829
|
+
if (!line || !wanted) return false;
|
|
3830
|
+
if (line.includes(wanted)) return true;
|
|
3831
|
+
return first.trimStart().startsWith("#") && wanted.includes(line);
|
|
3832
|
+
}
|
|
3833
|
+
function withPageTitle(html, content) {
|
|
3834
|
+
if (content.startsWith("<error>")) return content;
|
|
3835
|
+
const title = pageTitle(html);
|
|
3836
|
+
if (title === null || opensWithTitle(content, title)) return content;
|
|
3837
|
+
return `# ${title.replace(/([\\`*_\[\]<>#&|~])/g, "\\$1")}
|
|
3838
|
+
|
|
3839
|
+
${content.trimStart()}`;
|
|
3840
|
+
}
|
|
3841
|
+
var SIMPLIFY_FAILED = "<error>Page failed to be simplified from HTML</error>";
|
|
3842
|
+
async function convert(bytes, contentType) {
|
|
3843
|
+
if (looksLikePdf(bytes)) {
|
|
3844
|
+
const md = (await pdfToMarkdown(bytes, "", "")).replace(/^# \n\n/, "");
|
|
3845
|
+
return { format: "pdf", md, title: null, ext: "pdf" };
|
|
3846
|
+
}
|
|
3847
|
+
const text3 = bytes.toString("utf8");
|
|
3848
|
+
if (mediaType(contentType) === "text/markdown") return { format: "markdown", md: text3, title: null, ext: "md" };
|
|
3849
|
+
if (isHtml(contentType, text3)) {
|
|
3850
|
+
const body = /<body[^>]*>([\s\S]*)<\/body>/i.exec(text3)?.[1] ?? text3;
|
|
3851
|
+
let md;
|
|
3852
|
+
try {
|
|
3853
|
+
md = htmlToMarkdown(body);
|
|
3854
|
+
} catch {
|
|
3855
|
+
md = "";
|
|
3856
|
+
}
|
|
3857
|
+
md = md.trim() ? withPageTitle(text3, md) : SIMPLIFY_FAILED;
|
|
3858
|
+
return { format: "html", md, title: pageTitle(text3), ext: "html" };
|
|
3859
|
+
}
|
|
3860
|
+
return { format: "text", md: null, title: null, ext: "txt" };
|
|
3861
|
+
}
|
|
3862
|
+
function fetchAnswer(url, body, prefix, maxLength, startIndex) {
|
|
3863
|
+
const original = body.length;
|
|
3864
|
+
let content;
|
|
3865
|
+
if (startIndex >= original) content = "<error>No more content available.</error>";
|
|
3866
|
+
else {
|
|
3867
|
+
const cut = body.slice(startIndex, startIndex + maxLength);
|
|
3868
|
+
content = cut;
|
|
3869
|
+
const remaining = original - (startIndex + cut.length);
|
|
3870
|
+
if (cut.length === maxLength && remaining > 0) content += `
|
|
3871
|
+
|
|
3872
|
+
<error>Content truncated. Call the fetch tool with a start_index of ${startIndex + cut.length} to get more content.</error>`;
|
|
3873
|
+
}
|
|
3874
|
+
return `${prefix}Contents of ${url}:
|
|
3875
|
+
${content}`;
|
|
3876
|
+
}
|
|
3877
|
+
var ID_WIDTH = 13;
|
|
3878
|
+
function logId(url) {
|
|
3879
|
+
const bare2 = url.replace(/^https?:\/\//i, "");
|
|
3880
|
+
return bare2.length > ID_WIDTH ? bare2.slice(0, ID_WIDTH - 1) + "\u2026" : bare2;
|
|
3881
|
+
}
|
|
3882
|
+
var failureText = (url, reason) => {
|
|
3883
|
+
const m = /^HTTP (\d{3})\b/.exec(reason);
|
|
3884
|
+
return m ? `Failed to fetch ${url} - status code ${m[1]}` : `Failed to fetch ${url}: ${reason}`;
|
|
3885
|
+
};
|
|
3886
|
+
function serve(requested, p, m, args, cached) {
|
|
3887
|
+
const maxLength = args.max_length ?? 5e3, startIndex = args.start_index ?? 0;
|
|
3888
|
+
const read = (file2) => existsSync8(file2) ? readFileSync8(file2, "utf8") : "";
|
|
3889
|
+
const rawFile = m.files.find((f) => !f.endsWith("-meta.md") && !f.endsWith("-page.md"));
|
|
3890
|
+
let body, prefix = "";
|
|
3891
|
+
if (m.format === "markdown") body = read(p.md);
|
|
3892
|
+
else if (m.format === "text" || args.raw) {
|
|
3893
|
+
body = rawFile ? read(join9(p.entryDir, rawFile)) : read(p.md);
|
|
3894
|
+
prefix = `Content type ${m.content_type || "(not declared)"} cannot be simplified to markdown, but here is the raw content:
|
|
3895
|
+
`;
|
|
3896
|
+
} else body = read(p.md);
|
|
3897
|
+
return { ok: true, text: fetchAnswer(requested, body, prefix, maxLength, startIndex), cached, meta: m };
|
|
3898
|
+
}
|
|
3899
|
+
var inflight = /* @__PURE__ */ new Map();
|
|
3900
|
+
async function libraryFetch(libraryDir, requested, args, converter, logs = {}, fetchImpl = climb) {
|
|
3901
|
+
const url = normalizeUrl(requested);
|
|
3902
|
+
if (!url) return { ok: false, error: `Failed to fetch ${requested}: invalid URL`, cached: false, meta: null };
|
|
3903
|
+
const p = urlPaths(libraryDir, url);
|
|
3904
|
+
const s = settings();
|
|
3905
|
+
const fresh = (m2) => daysSince(m2.fetched) < (m2.format === "failed" ? s.retry_after_days : s.refresh_after_days);
|
|
3906
|
+
const answer = (m2, cached) => m2.format === "failed" ? { ok: false, error: failureText(requested, m2.error ?? "unknown error"), cached, meta: m2 } : serve(requested, p, m2, args, cached);
|
|
3907
|
+
const stored = readUrlMeta(p);
|
|
3908
|
+
if (stored && fresh(stored)) return answer(stored, true);
|
|
3909
|
+
let run = inflight.get(p.hash);
|
|
3910
|
+
if (!run) {
|
|
3911
|
+
run = (async () => {
|
|
3912
|
+
const r = await fetchImpl(url, logs.onTry, logs.onAlert);
|
|
3913
|
+
const base = { url, fetched: today(), converted_by: converter };
|
|
3914
|
+
if (!r.ok) {
|
|
3915
|
+
const error = r.first && /^HTTP \d{3}/.test(r.first) ? r.first : r.reason;
|
|
3916
|
+
writeUrlMeta(p, { ...base, final_url: r.url ?? url, title: null, format: "failed", content_type: "", level: "title", retrieved_by: r.via, error, files: [`${p.entry}-meta.md`] });
|
|
3917
|
+
return;
|
|
3918
|
+
}
|
|
3919
|
+
const page = await convert(r.bytes, r.contentType);
|
|
3920
|
+
mkdirSync5(p.entryDir, { recursive: true });
|
|
3921
|
+
const files = [`${p.entry}-meta.md`];
|
|
3922
|
+
if (page.md !== null) {
|
|
3923
|
+
writeAtomic(p.md, page.md);
|
|
3924
|
+
files.push(`${p.entry}-page.md`);
|
|
3925
|
+
}
|
|
3926
|
+
if (page.ext !== "md") {
|
|
3927
|
+
writeAtomic(p.raw(page.ext), r.bytes);
|
|
3928
|
+
files.push(`${p.entry}-page.${page.ext}`);
|
|
3929
|
+
}
|
|
3930
|
+
writeUrlMeta(p, { ...base, final_url: r.url, title: page.title, format: page.format, content_type: mediaType(r.contentType), level: "full_text", retrieved_by: r.via, error: null, files });
|
|
3931
|
+
})().finally(() => inflight.delete(p.hash));
|
|
3932
|
+
inflight.set(p.hash, run);
|
|
3933
|
+
}
|
|
3934
|
+
await run;
|
|
3935
|
+
const m = readUrlMeta(p);
|
|
3936
|
+
if (!m) return { ok: false, error: `Failed to fetch ${requested}: nothing stored`, cached: false, meta: null };
|
|
3937
|
+
return answer(m, false);
|
|
3938
|
+
}
|
|
3939
|
+
|
|
3940
|
+
// src/verifycache.ts
|
|
3941
|
+
import { createHash as createHash3 } from "crypto";
|
|
3942
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync6, readFileSync as readFileSync9 } from "fs";
|
|
3943
|
+
import { dirname as dirname3, join as join10 } from "path";
|
|
3944
|
+
var sha = (s) => createHash3("sha1").update(s).digest("hex");
|
|
3945
|
+
var normalizeClaim = (claim) => claim.replace(/\s+/g, " ").trim();
|
|
3946
|
+
var textHash = (text3) => sha(text3).slice(0, 16);
|
|
3947
|
+
var cacheKey = (claim, id, model, threshold, text3) => sha(`${normalizeClaim(claim)}
|
|
3948
|
+
${id}
|
|
3949
|
+
${model}
|
|
3950
|
+
${threshold}
|
|
3951
|
+
${text3}`).slice(0, 16);
|
|
3952
|
+
var verdictFile = (libraryDir, key) => join10(libraryDir, "verify", key.slice(0, 2), key.slice(0, 4), `${key}.json`);
|
|
3953
|
+
function readVerdict(file2) {
|
|
3954
|
+
if (!existsSync9(file2)) return null;
|
|
3955
|
+
try {
|
|
3956
|
+
return JSON.parse(readFileSync9(file2, "utf8"));
|
|
3957
|
+
} catch {
|
|
3958
|
+
return null;
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3961
|
+
function lookup(file2, maxDays) {
|
|
3962
|
+
const c = readVerdict(file2);
|
|
3963
|
+
return c && daysSince(c.date) < maxDays ? c : null;
|
|
3964
|
+
}
|
|
3965
|
+
function remember(file2, c) {
|
|
3966
|
+
mkdirSync6(dirname3(file2), { recursive: true });
|
|
3967
|
+
writeAtomic(file2, JSON.stringify(c, null, 2) + "\n");
|
|
3968
|
+
}
|
|
3969
|
+
async function verifyCached(claim, id, cfg, decide, libraryDir = FH_LIBRARY_DIR, read = readPaper) {
|
|
3970
|
+
const paper = await read(id, libraryDir);
|
|
3971
|
+
const once = () => paper;
|
|
3972
|
+
if ("error" in paper || !cfg) return { ...await verifyClaim(claim, id, cfg, decide, once), cached: false };
|
|
3973
|
+
const text3 = textHash(paper.text);
|
|
3974
|
+
const file2 = verdictFile(libraryDir, cacheKey(claim, paper.id, cfg.model, cfg.threshold, text3));
|
|
3975
|
+
const hit = lookup(file2, settings().refresh_after_days);
|
|
3976
|
+
if (hit) {
|
|
3977
|
+
return { claim, id: paper.id, verdict: hit.verdict, reason: hit.reason, score: hit.score, basis: hit.basis, windows: hit.windows, glyphs_unresolved: hit.glyphs_unresolved, superseded: hit.superseded, model: hit.model, cached: true };
|
|
3978
|
+
}
|
|
3979
|
+
const r = await verifyClaim(claim, id, cfg, decide, once);
|
|
3980
|
+
if (!r.error && r.score !== null) {
|
|
3981
|
+
remember(file2, { key: file2.slice(-21, -5), id: paper.id, claim: normalizeClaim(claim), model: cfg.model, threshold: cfg.threshold, text: text3, date: today(), verdict: r.verdict, reason: r.reason, score: r.score, basis: r.basis, windows: r.windows, glyphs_unresolved: r.glyphs_unresolved, superseded: r.superseded });
|
|
3982
|
+
}
|
|
3983
|
+
return { ...r, cached: false };
|
|
3984
|
+
}
|
|
3985
|
+
|
|
3560
3986
|
// src/tools.ts
|
|
3561
3987
|
var pkg = (() => {
|
|
3562
3988
|
try {
|
|
3563
|
-
return JSON.parse(
|
|
3989
|
+
return JSON.parse(readFileSync10(new URL("../package.json", import.meta.url), "utf8"));
|
|
3564
3990
|
} catch {
|
|
3565
3991
|
return { name: "fh-library", version: "0.0.0" };
|
|
3566
3992
|
}
|
|
3567
3993
|
})();
|
|
3568
|
-
var INSTRUCTIONS = `fh-library keeps a local library of scientific papers, FDA drug labels and clinical trial records, so you can check what a source actually says. The server downloads each record, converts it to Markdown and stores it on disk. Every tool returns a small JSON answer; the document itself never enters your context. One ID per call.
|
|
3994
|
+
var INSTRUCTIONS = `fh-library keeps a local library of scientific papers, FDA drug labels and clinical trial records, so you can check what a source actually says. The server downloads each record, converts it to Markdown and stores it on disk. Every tool returns a small JSON answer; the document itself never enters your context \u2014 except library_fetch, which is the standard fetch tool with a cache. One ID per call.
|
|
3569
3995
|
|
|
3570
3996
|
IDs \u2014 always "type:value"
|
|
3571
3997
|
- pmid:30955454 \u2014 a paper by PubMed ID. Bare digits are taken as a PMID.
|
|
@@ -3575,11 +4001,12 @@ IDs \u2014 always "type:value"
|
|
|
3575
4001
|
|
|
3576
4002
|
Tools
|
|
3577
4003
|
- library_version() \u2014 package name, version, library directory.
|
|
3578
|
-
- library_info(ids?) \u2014 the whole library: entry counts by type and level, the retrieval sources, browsers, the decision model, the configuration. ids=true lists every entry.
|
|
4004
|
+
- library_info(ids?) \u2014 the whole library: entry counts by type and level, the retrieval sources, browsers, the decision model, the configuration, this server's cache hits and misses since it started (cache). ids=true lists every entry.
|
|
3579
4005
|
- library_add(id) \u2014 download and store one record; returns its status. A stored full text is returned without any download; an abstract-level record retries only the sources that failed transiently; a record older than 30 days gets its correction notices re-read.
|
|
3580
4006
|
- library_status(id) \u2014 the status of one record without downloading; level "missing" when it is not stored.
|
|
3581
4007
|
- library_read(id, section?, query?, max_chars?, max_hits?) \u2014 one small piece of a stored document: its outline, one section, or the passages matching a query.
|
|
3582
4008
|
- library_verify(claim, id) \u2014 does this one source state this one finding? A decision model judges the stored text and returns a verdict.
|
|
4009
|
+
- library_fetch(url, max_length?, start_index?, raw?) \u2014 a web page as Markdown, same call and same answer as the standard fetch tool ("Contents of <url>:" and the page, cut at max_length with a note on how to continue). Prefer it for every URL: a cached page answers from disk, a walled or script-rendered page climbs the browser and unlocker ladder in one call. PDFs as Markdown.
|
|
3583
4010
|
|
|
3584
4011
|
Hints
|
|
3585
4012
|
- library_read and library_verify store a missing record themselves; the library_read answer then carries added: true. No library_add necessary first.
|
|
@@ -3587,6 +4014,7 @@ Hints
|
|
|
3587
4014
|
- level \u2014 what is stored: "full_text", "abstract" (no full text could be found; attempts names each source tried and why it failed) or "title" (not even an abstract; citable false).
|
|
3588
4015
|
- error "pmid not found" or "invalid id" \u2014 the ID itself is wrong; the same call gives the same answer.
|
|
3589
4016
|
- Calls may run in parallel: library_add, library_read and library_verify. Two calls on the same missing record share one add.
|
|
4017
|
+
- library_fetch takes any URL that is not a paper, label or trial record; those have IDs, and library_add, library_read and library_verify work on the stored document. A second pass over the same review reads every page from disk; a failed fetch is remembered for a day.
|
|
3590
4018
|
|
|
3591
4019
|
Verifying a claim
|
|
3592
4020
|
- library_verify judges one claim against one stored document. A finding backed by three papers takes three calls with the same claim.
|
|
@@ -3598,6 +4026,7 @@ Verifying a claim
|
|
|
3598
4026
|
- verdict "unverifiable" \u2014 only the abstract is stored and it does not state the claim; the full text may (reason says so). A retry gives the same answer until a full text is stored.
|
|
3599
4027
|
- glyphs_unresolved above 0 \u2014 the PDF font lost symbols (minus, \xB1, \u2264); a fail on a number may be the font. library_read shows the passage.
|
|
3600
4028
|
- superseded true \u2014 a published correction replaced the figure the claim uses; the status's corrections holds the notice.
|
|
4029
|
+
- Verdicts are kept for 30 days: the same claim against the same stored text answers from disk, no model call. A paper that gains its full text is judged afresh.
|
|
3601
4030
|
|
|
3602
4031
|
Reading a document
|
|
3603
4032
|
- library_read with only the ID returns the outline: every heading with its word count, and whether the full text or only the abstract is stored.
|
|
@@ -3737,13 +4166,13 @@ function registerTools(server2) {
|
|
|
3737
4166
|
{ id: idSchema },
|
|
3738
4167
|
({ id }) => addEntry(id)
|
|
3739
4168
|
);
|
|
3740
|
-
const
|
|
4169
|
+
const inflight2 = /* @__PURE__ */ new Map();
|
|
3741
4170
|
function addEntry(id) {
|
|
3742
4171
|
const key = parseId(id).requested;
|
|
3743
|
-
let p =
|
|
4172
|
+
let p = inflight2.get(key);
|
|
3744
4173
|
if (!p) {
|
|
3745
|
-
p = addEntryNow(id).finally(() =>
|
|
3746
|
-
|
|
4174
|
+
p = addEntryNow(id).finally(() => inflight2.delete(key));
|
|
4175
|
+
inflight2.set(key, p);
|
|
3747
4176
|
}
|
|
3748
4177
|
return p;
|
|
3749
4178
|
}
|
|
@@ -3930,8 +4359,8 @@ function registerTools(server2) {
|
|
|
3930
4359
|
}
|
|
3931
4360
|
added = true;
|
|
3932
4361
|
}
|
|
3933
|
-
const full =
|
|
3934
|
-
const md =
|
|
4362
|
+
const full = existsSync10(paths.fulltextMd);
|
|
4363
|
+
const md = readFileSync10(full ? paths.fulltextMd : paths.md, "utf8");
|
|
3935
4364
|
const text3 = full ? "full_text" : "abstract";
|
|
3936
4365
|
const base = added ? { requested: parsed.requested, entry: paths.entry, text: text3, added: true } : { requested: parsed.requested, entry: paths.entry, text: text3 };
|
|
3937
4366
|
const headings2 = () => outline(md, text3).sections;
|
|
@@ -3951,8 +4380,8 @@ function registerTools(server2) {
|
|
|
3951
4380
|
const cap = max_chars ?? 6e3;
|
|
3952
4381
|
let sec = readSection(md, section2, cap);
|
|
3953
4382
|
let via = "";
|
|
3954
|
-
if (!sec && /abstract/i.test(section2) && full &&
|
|
3955
|
-
sec = readSection(
|
|
4383
|
+
if (!sec && /abstract/i.test(section2) && full && existsSync10(paths.md)) {
|
|
4384
|
+
sec = readSection(readFileSync10(paths.md, "utf8"), "abstract", cap);
|
|
3956
4385
|
via = " (from the abstract file)";
|
|
3957
4386
|
}
|
|
3958
4387
|
if (!sec && /declaration/i.test(section2)) {
|
|
@@ -3996,15 +4425,16 @@ function registerTools(server2) {
|
|
|
3996
4425
|
log("library_verify", pid.requested, "missing adding first");
|
|
3997
4426
|
await addEntry(id);
|
|
3998
4427
|
}
|
|
3999
|
-
const r = await
|
|
4428
|
+
const { cached, ...r } = await verifyCached(claim, id, cfg, cfg ? decider(cfg) : null, FH_LIBRARY_DIR);
|
|
4429
|
+
if (!r.error && r.score !== null) bump("verify", cached);
|
|
4000
4430
|
const secs = `${((Date.now() - t0) / 1e3).toFixed(1).padStart(4, "0")}s`;
|
|
4001
|
-
log("library_verify", r.id, r.error ? `${modelFailure(r.error, cfg) ? "!! " : ""}${secs} verdict=${"error".padEnd(12)} ${r.error} | ${claim}` : `${secs} verdict=${r.verdict.padEnd(12)} basis=${r.basis.padEnd(9)} score=${r.score === null ? "- " : r.score.toFixed(3)}${r.windows > 1 ? ` windows=${r.windows}` : ""}${r.superseded ? " superseded" : ""}${r.glyphs_unresolved ? ` glyphs=${r.glyphs_unresolved}` : ""} | ${claim}`);
|
|
4431
|
+
log("library_verify", r.id, r.error ? `${modelFailure(r.error, cfg) ? "!! " : ""}${secs} verdict=${"error".padEnd(12)} ${r.error} | ${claim}` : `${secs} verdict=${r.verdict.padEnd(12)} basis=${r.basis.padEnd(9)} score=${r.score === null ? "- " : r.score.toFixed(3)}${r.windows > 1 ? ` windows=${r.windows}` : ""}${r.superseded ? " superseded" : ""}${r.glyphs_unresolved ? ` glyphs=${r.glyphs_unresolved}` : ""}${cached ? " cached" : ""} | ${claim}`);
|
|
4002
4432
|
return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
|
|
4003
4433
|
}
|
|
4004
4434
|
);
|
|
4005
4435
|
server2.tool(
|
|
4006
4436
|
"library_info",
|
|
4007
|
-
"The whole library as JSON: library directory and host; entry count and size on disk; per type (pmid, doi, dailymed, nct) the entries by level (full_text, abstract, title) and retractions; counts of retracted and not-citable entries and of errata, expressions of concern and updates; the retrieval sources in run order and which are off (tiers); the download ladder (download): the Playwright servers with the version they must run, the Chrome rungs, the Bright Data unlocker; the configuration and where each value came from \u2014 environment, .env or default, never the value (config); the decision model behind library_verify and whether it answers right now (verify). ids=true also lists every entry ID.",
|
|
4437
|
+
"The whole library as JSON: library directory and host; entry count and size on disk; per type (pmid, doi, dailymed, nct) the entries by level (full_text, abstract, title) and retractions; counts of retracted and not-citable entries and of errata, expressions of concern and updates; the retrieval sources in run order and which are off (tiers); the download ladder (download): the Playwright servers with the version they must run, the Chrome rungs, the Bright Data unlocker; the configuration and where each value came from \u2014 environment, .env or default, never the value (config); the decision model behind library_verify and whether it answers right now (verify); this server's cache hits and misses for library_verify and library_fetch since it started (cache). ids=true also lists every entry ID.",
|
|
4008
4438
|
{ ids: z.boolean().optional().describe("Also list every entry ID (type:value).") },
|
|
4009
4439
|
async ({ ids }) => {
|
|
4010
4440
|
const info = libraryInfo(FH_LIBRARY_DIR, HOST, ids === true);
|
|
@@ -4019,7 +4449,35 @@ function registerTools(server2) {
|
|
|
4019
4449
|
}
|
|
4020
4450
|
}
|
|
4021
4451
|
log("library_info", null, `entries=${info.entries}${info.verify ? ` verify ${info.verify.model} ${info.verify.ok ? `ok ${info.verify.ms} ms` : `down: ${info.verify.error}`}` : " verify not configured"}`);
|
|
4022
|
-
|
|
4452
|
+
const s = currentStats();
|
|
4453
|
+
const cache = s ? { since: s.started, verify: s.verify, fetch: s.fetch } : null;
|
|
4454
|
+
return { content: [{ type: "text", text: JSON.stringify({ ...info, cache }, null, 2) }] };
|
|
4455
|
+
}
|
|
4456
|
+
);
|
|
4457
|
+
server2.tool(
|
|
4458
|
+
"library_fetch",
|
|
4459
|
+
`Fetch a web page as Markdown: the standard fetch tool, cached and hardened. Use it for any URL instead of a plain fetch or a browser. A page fetched in the last ${settings().refresh_after_days} days is answered from disk in milliseconds; a page a plain fetch cannot get (403, rate limit, bot wall, script-rendered) is fetched through the configured browsers and unlocker in this one call. PDFs come back as Markdown.`,
|
|
4460
|
+
{
|
|
4461
|
+
url: z.string().describe("URL to fetch"),
|
|
4462
|
+
max_length: z.number().int().min(1).max(999999).default(5e3).describe("Maximum number of characters to return."),
|
|
4463
|
+
start_index: z.number().int().min(0).default(0).describe("On return output starting at this character index, useful if a previous fetch was truncated and more context is required."),
|
|
4464
|
+
raw: z.boolean().default(false).describe("Get the actual HTML content of the requested page, without simplification.")
|
|
4465
|
+
},
|
|
4466
|
+
async ({ url, max_length, start_index, raw }) => {
|
|
4467
|
+
const t0 = Date.now();
|
|
4468
|
+
const id = logId(url), put = (line) => log("library_fetch", id, `${line} ${url}`);
|
|
4469
|
+
const r = await libraryFetch(FH_LIBRARY_DIR, url, { max_length, start_index, raw }, `${pkg.name} ${pkg.version}`, {
|
|
4470
|
+
onTry: put,
|
|
4471
|
+
onAlert: (line) => put(`!! ${line}`)
|
|
4472
|
+
});
|
|
4473
|
+
const secs = ((Date.now() - t0) / 1e3).toFixed(1).padStart(4, "0") + "s";
|
|
4474
|
+
if (r.meta) bump("fetch", r.cached);
|
|
4475
|
+
if (r.ok) {
|
|
4476
|
+
put(`${r.cached ? "cached" : `fetched ${secs}`} ${r.meta.format} retrieved_by=${r.meta.retrieved_by} fetched=${r.meta.fetched} chars=${r.text.length}${raw ? " raw" : ""}${start_index ? ` start=${start_index}` : ""}`);
|
|
4477
|
+
return { content: [{ type: "text", text: r.text }] };
|
|
4478
|
+
}
|
|
4479
|
+
put(`${r.cached ? "cached failure" : `failed ${secs}`}: ${r.error.replace(url, "<url>")}`);
|
|
4480
|
+
return { content: [{ type: "text", text: r.error }], isError: true };
|
|
4023
4481
|
}
|
|
4024
4482
|
);
|
|
4025
4483
|
server2.tool(
|
|
@@ -4039,6 +4497,8 @@ var server = new McpServer(
|
|
|
4039
4497
|
{ instructions: INSTRUCTIONS }
|
|
4040
4498
|
);
|
|
4041
4499
|
registerTools(server);
|
|
4500
|
+
startStats(pkg);
|
|
4501
|
+
log("startup", null, `${pkg.name} ${pkg.version}`);
|
|
4042
4502
|
for (const problem of resolveTiers().problems) log("startup", null, `!! ${problem}`);
|
|
4043
4503
|
var transport = new StdioServerTransport();
|
|
4044
4504
|
await server.connect(transport);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fh-library",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"mcpName": "io.github.forever-healthy/fh-library",
|
|
5
5
|
"description": "MCP Server that builds a local Markdown library of the scientific papers cited by evidence reviews — fetches metadata, abstract and full text per PubMed ID through a configurable cascade of retrieval services",
|
|
6
6
|
"type": "module",
|