strom-research 1.18.0 → 1.19.0-beta.1
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.cs.md +15 -0
- package/CHANGELOG.de.md +17 -0
- package/CHANGELOG.md +16 -0
- package/assets/archives.json +35 -5
- package/assets/lang/cs.json +1 -0
- package/assets/lang/de.json +1 -0
- package/assets/method/core.md +3 -0
- package/assets/method/enrich.md +2 -0
- package/dist/cli/menu.js +16 -3
- package/dist/cli/ui.js +1 -0
- package/dist/commands/connectors.js +51 -14
- package/dist/commands/grep.js +7 -4
- package/dist/commands/readings.js +27 -6
- package/dist/commands/sources.js +4 -2
- package/dist/commands/web.js +8 -2
- package/dist/core/connector.js +7 -3
- package/dist/core/errors.js +4 -1
- package/dist/core/knownterms.js +18 -0
- package/dist/core/net.js +73 -12
- package/dist/core/tidy.js +19 -0
- package/dist/core/tree.js +1 -1
- package/dist/core/web.js +6 -2
- package/package.json +4 -1
package/CHANGELOG.cs.md
CHANGED
|
@@ -5,6 +5,21 @@
|
|
|
5
5
|
Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou na
|
|
6
6
|
[GitHub Releases](https://github.com/ACiDekCZ/strom-research/releases) (anglicky).
|
|
7
7
|
|
|
8
|
+
## 1.19.0
|
|
9
|
+
|
|
10
|
+
*Připravuje se: bety do 1.19.0-beta.1. Oddíl bude úplný s vydáním.*
|
|
11
|
+
|
|
12
|
+
- **Na archiv, který zmlkl, se čeká, místo opakovaných dotazů.** Když archiv přestane odpovídat, strom ho nechá na
|
|
13
|
+
pokoji hodinu — a když týž den mlčí dál, dvě a pak čtyři hodiny — a agent se dozví, do kdy: mezitím dělá
|
|
14
|
+
jinou práci, nechá stahování na pozadí na archiv počkat (až 30 minut) a pokračovat samo, nebo sezení ukončí a
|
|
15
|
+
naváže později. Archiv se už mezitím neobtěžuje dotazy pořád dokola.
|
|
16
|
+
- **První úklid staršího výzkumu se jednou nabídne.** Výzkum, u kterého se nastřádalo hodně dat dřív, než si v tom
|
|
17
|
+
strom začal sám dělat pořádek (záznamy práce, náhledy snímků a podobně), dostane v menu jednu nabídku: co by se
|
|
18
|
+
smazalo, kolik a proč — bez souhlasu nic. Po souhlasu se v tom pak udržuje pořádek samo; po odmítnutí menu a
|
|
19
|
+
nastavení dál říkají, co jde uvolnit.
|
|
20
|
+
- **Digitální knihovny napoprvé správně.** U Krameria Národní knihovny ČR a Moravské zemské knihovny a u ANNO
|
|
21
|
+
Rakouské národní knihovny dostane agent jejich vlastní vyhledávací adresy — méně neúspěšných dotazů a hádání.
|
|
22
|
+
|
|
8
23
|
## 1.18.0
|
|
9
24
|
|
|
10
25
|
*21. 10. 2026 · patří k aplikaci Strom 3.14.0.*
|
package/CHANGELOG.de.md
CHANGED
|
@@ -5,6 +5,23 @@
|
|
|
5
5
|
Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Installationszeilen stehen bei
|
|
6
6
|
[GitHub Releases](https://github.com/ACiDekCZ/strom-research/releases) (auf Englisch).
|
|
7
7
|
|
|
8
|
+
## 1.19.0
|
|
9
|
+
|
|
10
|
+
*In Vorbereitung: die Betas bis 1.19.0-beta.1. Der Abschnitt ist mit dem Release vollständig.*
|
|
11
|
+
|
|
12
|
+
- **Auf ein verstummtes Archiv wird gewartet, statt es immer wieder zu fragen.** Antwortet ein Archiv nicht mehr,
|
|
13
|
+
lässt strom es eine Stunde in Ruhe — zwei, dann vier, wenn es am selben Tag still bleibt — und der Agent erfährt, bis
|
|
14
|
+
wann: Er macht inzwischen andere Arbeit, lässt einen Download im Hintergrund auf das Archiv warten (bis zu 30 Minuten)
|
|
15
|
+
und von selbst weiterlaufen, oder beendet die Sitzung, um später weiterzumachen. Das Archiv wird inzwischen nicht mehr
|
|
16
|
+
immer wieder angefragt.
|
|
17
|
+
- **Das erste Aufräumen einer älteren Forschung wird einmal angeboten.** Eine Forschung, neben der sich viel
|
|
18
|
+
angesammelt hat, bevor strom dort selbst Ordnung hielt (Protokolle der Arbeitssitzungen, Ansichten von Scans und
|
|
19
|
+
Ähnliches), bekommt im Menü ein Angebot: was ginge, wie viel und warum — ohne Ja geht nichts. Nach einem Ja wird dort
|
|
20
|
+
von selbst Ordnung gehalten; nach einem Nein sagen Menü und Einstellungen weiter, was sich freigeben lässt.
|
|
21
|
+
- **Digitale Bibliotheken beim ersten Versuch richtig durchsucht.** Für Kramerius der Tschechischen
|
|
22
|
+
Nationalbibliothek und der Mährischen Landesbibliothek und für ANNO der Österreichischen Nationalbibliothek bekommt
|
|
23
|
+
der Agent die eigenen Suchadressen der Bibliotheken — weniger fehlgeschlagene Anfragen und weniger Raten.
|
|
24
|
+
|
|
8
25
|
## 1.18.0
|
|
9
26
|
|
|
10
27
|
*21.10.2026 · gehört zur Strom-App 3.14.0.*
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ English · [Čeština](CHANGELOG.cs.md) · [Deutsch](CHANGELOG.de.md)
|
|
|
5
5
|
The changes of each version in short. The full notes and the install lines are on
|
|
6
6
|
[GitHub Releases](https://github.com/ACiDekCZ/strom-research/releases).
|
|
7
7
|
|
|
8
|
+
## 1.19.0
|
|
9
|
+
|
|
10
|
+
*In preparation: the betas up to 1.19.0-beta.1. The section is complete with the release.*
|
|
11
|
+
|
|
12
|
+
- **An archive that went silent is waited for, not asked again and again.** When an archive stops answering, strom
|
|
13
|
+
leaves it alone for an hour — two, then four when it stays silent the same day — and the agent hears until when:
|
|
14
|
+
it goes on with other work, lets a download in the background wait for the archive (up to 30 minutes) and go on by
|
|
15
|
+
itself, or closes the session to go on later. No more asking the archive over and over meanwhile.
|
|
16
|
+
- **The first tidy of an older research is offered once.** A research that gathered much beside it before strom
|
|
17
|
+
kept order there by itself (logs of work sessions, views of scans and the like) gets one offer in the menu: what
|
|
18
|
+
would go, how much and why, and nothing goes without a yes. After a yes it is kept in order by itself; after a no,
|
|
19
|
+
the menu and the settings keep saying what can be freed.
|
|
20
|
+
- **Digital libraries searched right at the first try.** For Kramerius of the Czech National Library and of the
|
|
21
|
+
Moravian Library and for ANNO of the Austrian National Library, the agent gets the libraries' own search
|
|
22
|
+
addresses — fewer failed requests and less guessing.
|
|
23
|
+
|
|
8
24
|
## 1.18.0
|
|
9
25
|
|
|
10
26
|
*2026-10-21 · goes with the Strom app 3.14.0.*
|
package/assets/archives.json
CHANGED
|
@@ -180,7 +180,11 @@
|
|
|
180
180
|
"quote": "actions: { pdf: 'available', print: 'available', jpeg: 'available', text: 'available' …",
|
|
181
181
|
"robots": "none",
|
|
182
182
|
"scope": "public-domain works only, through its API (works under copyright or DNNT need an institution's login: never)",
|
|
183
|
-
"checked": "2026-10-11"
|
|
183
|
+
"checked": "2026-10-11",
|
|
184
|
+
"search": [
|
|
185
|
+
{"what": "full text of its books and newspapers (Solr: q a word, or (word OR word); the years in it; public works only: accessibility)", "url": "https://api.kramerius.mzk.cz/search/api/client/v7.0/search?q=text_ocr%3A{q}%20AND%20date_range_start.year%3A%5B{from}%20TO%20{to}%5D&fl=pid,root.title,date.str,page.number,accessibility&rows=50"},
|
|
186
|
+
{"what": "the text of a page found (its pid)", "url": "https://api.kramerius.mzk.cz/search/api/client/v7.0/items/{pid}/ocr/text"}
|
|
187
|
+
]
|
|
184
188
|
},
|
|
185
189
|
{
|
|
186
190
|
"host": "www.digitalniknihovna.cz",
|
|
@@ -190,7 +194,11 @@
|
|
|
190
194
|
"quote": "actions: { pdf: 'available', print: 'available', jpeg: 'available', text: 'available' …",
|
|
191
195
|
"robots": "none (the application's page)",
|
|
192
196
|
"scope": "public-domain works only, through its API (works under copyright or DNNT need an institution's login: never)",
|
|
193
|
-
"checked": "2026-10-11"
|
|
197
|
+
"checked": "2026-10-11",
|
|
198
|
+
"search": [
|
|
199
|
+
{"what": "full text of its books and newspapers (Solr: q a word, or (word OR word); the years in it; public works only: accessibility)", "url": "https://api.kramerius.mzk.cz/search/api/client/v7.0/search?q=text_ocr%3A{q}%20AND%20date_range_start.year%3A%5B{from}%20TO%20{to}%5D&fl=pid,root.title,date.str,page.number,accessibility&rows=50"},
|
|
200
|
+
{"what": "the text of a page found (its pid)", "url": "https://api.kramerius.mzk.cz/search/api/client/v7.0/items/{pid}/ocr/text"}
|
|
201
|
+
]
|
|
194
202
|
},
|
|
195
203
|
{
|
|
196
204
|
"host": "api.kramerius.mzk.cz",
|
|
@@ -200,7 +208,11 @@
|
|
|
200
208
|
"quote": "actions: { pdf: 'available', print: 'available', jpeg: 'available', text: 'available' …",
|
|
201
209
|
"robots": "none (404)",
|
|
202
210
|
"scope": "public-domain works only (works under copyright or DNNT need an institution's login: never)",
|
|
203
|
-
"checked": "2026-10-11"
|
|
211
|
+
"checked": "2026-10-11",
|
|
212
|
+
"search": [
|
|
213
|
+
{"what": "full text of its books and newspapers (Solr: q a word, or (word OR word); the years in it; public works only: accessibility)", "url": "https://api.kramerius.mzk.cz/search/api/client/v7.0/search?q=text_ocr%3A{q}%20AND%20date_range_start.year%3A%5B{from}%20TO%20{to}%5D&fl=pid,root.title,date.str,page.number,accessibility&rows=50"},
|
|
214
|
+
{"what": "the text of a page found (its pid)", "url": "https://api.kramerius.mzk.cz/search/api/client/v7.0/items/{pid}/ocr/text"}
|
|
215
|
+
]
|
|
204
216
|
},
|
|
205
217
|
{
|
|
206
218
|
"host": "kramerius5.nkp.cz",
|
|
@@ -210,7 +222,11 @@
|
|
|
210
222
|
"quote": "actions: { pdf: 'available', print: 'available', jpeg: 'available', text: 'available' …",
|
|
211
223
|
"robots": "none (the application's page)",
|
|
212
224
|
"scope": "public-domain works only, through its API (works under copyright or DNNT need an institution's login: never)",
|
|
213
|
-
"checked": "2026-10-11"
|
|
225
|
+
"checked": "2026-10-11",
|
|
226
|
+
"search": [
|
|
227
|
+
{"what": "full text of its books and newspapers (Solr: q a word, or (word OR word); fq the years)", "url": "https://kramerius5.nkp.cz/search/api/v5.0/search?q=text_ocr%3A{q}&fq=datum_begin%3A%5B{from}%20TO%20{to}%5D&fl=PID,root_title,datum_str,details,root_pid&rows=30&wt=json"},
|
|
228
|
+
{"what": "the text of a page found (its PID)", "url": "https://kramerius5.nkp.cz/search/api/v5.0/item/{pid}/streams/TEXT_OCR"}
|
|
229
|
+
]
|
|
214
230
|
},
|
|
215
231
|
{
|
|
216
232
|
"host": "ndk.cz",
|
|
@@ -220,7 +236,21 @@
|
|
|
220
236
|
"quote": "actions: { pdf: 'available', print: 'available', jpeg: 'available', text: 'available' …",
|
|
221
237
|
"robots": "none (the application's page)",
|
|
222
238
|
"scope": "public-domain works only, through its API (works under copyright or DNNT need an institution's login: never)",
|
|
223
|
-
"checked": "2026-10-11"
|
|
239
|
+
"checked": "2026-10-11",
|
|
240
|
+
"search": [
|
|
241
|
+
{"what": "full text of its books and newspapers (Solr: q a word, or (word OR word); fq the years)", "url": "https://kramerius5.nkp.cz/search/api/v5.0/search?q=text_ocr%3A{q}&fq=datum_begin%3A%5B{from}%20TO%20{to}%5D&fl=PID,root_title,datum_str,details,root_pid&rows=30&wt=json"},
|
|
242
|
+
{"what": "the text of a page found (its PID)", "url": "https://kramerius5.nkp.cz/search/api/v5.0/item/{pid}/streams/TEXT_OCR"}
|
|
243
|
+
]
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
"host": "anno.onb.ac.at",
|
|
247
|
+
"name": "Österreichische Nationalbibliothek (ANNO)",
|
|
248
|
+
"level": "unknown",
|
|
249
|
+
"robots": "User-agent: * · Disallow: /core/, /profiles/, /admin/, /search/, /user/… (nothing on /anno-suche or /cgi-content)",
|
|
250
|
+
"checked": "2026-10-21",
|
|
251
|
+
"search": [
|
|
252
|
+
{"what": "full text of its newspapers and journals (text a word; the years; from=0 the first hits) — the search page itself is empty without a browser", "url": "https://anno.onb.ac.at/anno-suche/rest/search/complex?text={q}&dateFrom={from}&dateTo={to}&from=0&facets=false"}
|
|
253
|
+
]
|
|
224
254
|
},
|
|
225
255
|
{
|
|
226
256
|
"host": "geneteka.genealodzy.pl",
|
package/assets/lang/cs.json
CHANGED
|
@@ -1066,6 +1066,7 @@
|
|
|
1066
1066
|
"ui.tidy.done": "✓ Uvolněno {size}. Odteď to strom po každém sezení udržuje v pořádku sám.",
|
|
1067
1067
|
"ui.tidy.no": "Nic se nesmazalo.",
|
|
1068
1068
|
"ui.consent.tidy": "Uvolnit asi {size} vedle výzkumu {name} (staré záznamy sezení, náhledy skenů, dávno zapsaná poslání)? Výzkum i jeho skeny zůstanou.",
|
|
1069
|
+
"ui.tidy.first": "Vedle výzkumu se nastřádalo, co jde uvolnit, asi {size} (záznamy práce, náhledy snímků a podobně – nikdy výzkum). Ukazuje se tu jednou; později v menu Nastavení → Údržba → Místo na disku.",
|
|
1069
1070
|
"ui.tidy.hint": "Vedle výzkumu si strom drží {strom}, z toho jde uvolnit {size}: strom tidy (v menu Nastavení → Údržba → Místo na disku)",
|
|
1070
1071
|
"ui.error.consent": "tady je potřeba souhlas – {question}",
|
|
1071
1072
|
"ui.error.consent.hint": "spustit ve vlastním terminálu: {set}",
|
package/assets/lang/de.json
CHANGED
|
@@ -1066,6 +1066,7 @@
|
|
|
1066
1066
|
"ui.tidy.done": "✓ {size} freigegeben. Ab jetzt hält strom es nach jeder Sitzung selbst in Ordnung.",
|
|
1067
1067
|
"ui.tidy.no": "Nichts wurde gelöscht.",
|
|
1068
1068
|
"ui.consent.tidy": "Etwa {size} neben der Forschung {name} freigeben (alte Sitzungsprotokolle, Ansichten von Scans, längst geschriebene Sendungen)? Die Forschung und ihre Scans bleiben.",
|
|
1069
|
+
"ui.tidy.first": "Neben der Forschung hat sich angesammelt, was freigegeben werden kann, etwa {size} (Protokolle, Ansichten von Scans und Ähnliches – nie die Forschung). Wird hier einmal gezeigt; später im Menü Einstellungen → Wartung → Speicherplatz.",
|
|
1069
1070
|
"ui.tidy.hint": "Neben der Forschung hält strom {strom}, davon lassen sich {size} freigeben: strom tidy (im Menü Einstellungen → Wartung → Speicherplatz)",
|
|
1070
1071
|
"ui.error.consent": "dafür braucht es eine Zustimmung – {question}",
|
|
1071
1072
|
"ui.error.consent.hint": "im eigenen Terminal ausführen: {set}",
|
package/assets/method/core.md
CHANGED
|
@@ -107,6 +107,9 @@ conclusion so the next researcher can follow it.
|
|
|
107
107
|
Images you will read later in the session: `--background` fetches them
|
|
108
108
|
while you work on, `strom read` reads them as they come. Nothing for a
|
|
109
109
|
later session: the session that reads a book fetches it.
|
|
110
|
+
An archive unavailable for now (exit 7, "try again at …"): never wait for
|
|
111
|
+
it in a loop of fetches — other work, `--background` when it frees within
|
|
112
|
+
30 minutes, or `strom session close --continue`.
|
|
110
113
|
<!-- end -->
|
|
111
114
|
- **What strom or the tree denies stays denied.** Never offer the user to
|
|
112
115
|
change your permissions or settings, strom's or the tree's, to get round it:
|
package/assets/method/enrich.md
CHANGED
|
@@ -25,6 +25,8 @@ recorded, also in vain (`strom search add … --method web|catalog|full-text`).
|
|
|
25
25
|
built right away when the task needs that site (`strom connector new
|
|
26
26
|
<site>`, then `strom fetch`) — never for a site that refused strom or
|
|
27
27
|
restricts automation (`strom net web --check <url>`): another source.
|
|
28
|
+
A digital library strom knows gives its own search there: fetch its
|
|
29
|
+
addresses as given, the words URL-encoded — never a guessed API.
|
|
28
30
|
- **The history of a place once in a research**: what is written of it (the
|
|
29
31
|
brief says) is reused, never searched again.
|
|
30
32
|
- **Read the source itself** — the page, the scan. A snippet, an OCR line,
|
package/dist/cli/menu.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import { ui } from "./ui.js";
|
|
12
|
-
import {
|
|
12
|
+
import { isAgent } from "../core/which.js";
|
|
13
|
+
import { mb, setTidyAsked, tidyAsked, tidyOn, tidyPlan, TIDY_SAID } from "../core/tidy.js";
|
|
13
14
|
import { calibrationLabel, calibrationOffer } from "../core/viewsizes.js";
|
|
14
15
|
/** A line as a sentence: its first letter capital (a catalog's text that also stands inside a line). */
|
|
15
16
|
const sentence = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
@@ -192,8 +193,20 @@ export async function runMenu(ctx, run) {
|
|
|
192
193
|
out(line);
|
|
193
194
|
// much kept beside the research that can go (an older strom never tidied it): said, freed in the settings
|
|
194
195
|
const disk = tidyPlan(tree);
|
|
195
|
-
if (disk.frees >= TIDY_SAID)
|
|
196
|
-
|
|
196
|
+
if (disk.frees >= TIDY_SAID) {
|
|
197
|
+
// never tidied (an older strom gathered it): its first tidy offered once, shown and asked as strom tidy asks
|
|
198
|
+
// (Enter says no) — nothing goes unasked; then kept in order by itself. Nobody to ask (no terminal,
|
|
199
|
+
// STROM_NONINTERACTIVE): only said, the offer kept for the person
|
|
200
|
+
if (!tidyOn(root) && !tidyAsked(root) && !isAgent(ctx.env) && ctx.interactive) {
|
|
201
|
+
setTidyAsked(root);
|
|
202
|
+
out(t("ui.tidy.first", { size: mb(disk.frees, lang) }));
|
|
203
|
+
out();
|
|
204
|
+
await run(["tidy"]);
|
|
205
|
+
out();
|
|
206
|
+
}
|
|
207
|
+
else
|
|
208
|
+
out(t("ui.tidy.hint", { strom: mb(disk.size.strom, lang), size: mb(disk.frees, lang) }));
|
|
209
|
+
}
|
|
197
210
|
// the scan views tuned before, but for another agent or model: offered again (the settings), never run unasked
|
|
198
211
|
const offer = !archive && ready ? calibrationOffer(ctx.settings, ctx.settings.agent(tree.config).value, tree.config, tree.root) : undefined;
|
|
199
212
|
if (offer)
|
package/dist/cli/ui.js
CHANGED
|
@@ -957,6 +957,7 @@ export const UI = {
|
|
|
957
957
|
"ui.tidy.done": "✓ Freed {size}. From now on strom keeps it in order by itself after each session.",
|
|
958
958
|
"ui.tidy.no": "Nothing was removed.",
|
|
959
959
|
"ui.consent.tidy": "Free about {size} beside the research {name} (old logs, views of scans, sends written long ago)? The research and its images stay.",
|
|
960
|
+
"ui.tidy.first": "Beside the research strom has gathered what can go, about {size} (logs, views of scans and the like — never the research). Shown once here; later in the menu: Settings → Maintenance → Disk space.",
|
|
960
961
|
"ui.tidy.hint": "Beside the research strom keeps {strom}, {size} of it can go: strom tidy (the menu: Settings → Maintenance → Disk space)",
|
|
961
962
|
"ui.error.consent": "this needs a yes — {question}",
|
|
962
963
|
"ui.error.consent.hint": "run in a terminal of its own: {set}",
|
|
@@ -30,7 +30,7 @@ import { runGit } from "../core/git.js";
|
|
|
30
30
|
import { imageSizeOfFile } from "../image/index.js";
|
|
31
31
|
import { Tree } from "../core/tree.js";
|
|
32
32
|
import { syncAgentFiles } from "../agents/files.js";
|
|
33
|
-
import { CAP_WARN, capNear, clearBlock, clock, CookieJar, DEFAULT_PACE, hostAllowed, hostPace, hostState, knownHosts, leftUntil, MIN_INTERVAL_MS, NetError, paceText, politeRequest, REFUSED_FOREVER, refusedBy, reserveSlots, setOwnPace, testHooks } from "../core/net.js";
|
|
33
|
+
import { CAP_WARN, capNear, clearBlock, clock, clockUp, CookieJar, DEFAULT_PACE, hostAllowed, hostPace, hostState, knownHosts, leftAloneFor, leftUntil, MIN_INTERVAL_MS, NetError, paceText, politeRequest, REFUSED_FOREVER, refusedBy, reserveSlots, setOwnPace, testHooks } from "../core/net.js";
|
|
34
34
|
import { match, optionsOf } from "../cli/registry.js";
|
|
35
35
|
import { botCheck, fileBase, findDownload, loadPage, loadPlans, pageKey, pageScript, parseResult, PLAN_TTL_MS, planScript, readSavedPage, removePlan, savePage, savePlan, SCRIPT_MS, claudeInChrome, CLAUDE_IN_CHROME_URL, } from "../core/browser.js";
|
|
36
36
|
import { PROFILES } from "../agents/profiles.js";
|
|
@@ -399,7 +399,7 @@ function limitWait(ctx) {
|
|
|
399
399
|
const waitMs = Math.max(0, Math.min(FETCH_WAIT_MS, left));
|
|
400
400
|
return {
|
|
401
401
|
waitMs,
|
|
402
|
-
onWait: (w) => ctx.io.stderr(` · ${w.host}: ${w.why === "cap" ? "its hourly cap is used up" : "it says its limit is used up"} — strom waits until ${
|
|
402
|
+
onWait: (w) => ctx.io.stderr(` · ${w.host}: ${w.why === "cap" ? "its hourly cap is used up" : w.why === "down" ? "left alone for a while (it went silent, or asked to slow down)" : "it says its limit is used up"} — strom waits until ${clockUp(w.until)} (${Math.max(1, Math.ceil(w.ms / 60_000))} min), then goes on by itself (it waits at most ${Math.round(waitMs / 60_000)} min); do not stop it\n`),
|
|
403
403
|
};
|
|
404
404
|
}
|
|
405
405
|
/** The limiter's pauses and requests counted for the measure of a run (core/metrics.ts). */
|
|
@@ -439,11 +439,22 @@ function measure(ctx, c, meter, rec, r) {
|
|
|
439
439
|
// a measurement is no reason to fail
|
|
440
440
|
}
|
|
441
441
|
}
|
|
442
|
-
/**
|
|
443
|
-
|
|
442
|
+
/**
|
|
443
|
+
* A run that ended at a limit used up, or at a host left alone for a while (it went silent, asked to slow down): when
|
|
444
|
+
* to try again (rounded up — never a minute too early), what to do meanwhile, and the exit code that says "later".
|
|
445
|
+
* `background`: the command of a fetch in the background (it waits for a host that frees within FETCH_WAIT_MS).
|
|
446
|
+
*/
|
|
447
|
+
function later(r, again, background) {
|
|
444
448
|
if (r.later === undefined)
|
|
445
449
|
return {};
|
|
446
|
-
|
|
450
|
+
const at = clockUp(r.later);
|
|
451
|
+
const what = r.laterDown ? `${r.laterDown} is left alone until ${at} (it went silent, or asked to slow down — strom sends it nothing before then)` : "the archive's limit is used up";
|
|
452
|
+
const soon = background && r.later - Date.now() <= FETCH_WAIT_MS ? `; or let it wait by itself: ${background} --background` : "";
|
|
453
|
+
return {
|
|
454
|
+
line: `→ ${what}: try again at ${at}, not sooner (${again})${soon}; meanwhile other work, or close the session for later (strom session close --continue) — no sleeping, no asking again sooner`,
|
|
455
|
+
exitCode: EXIT.later,
|
|
456
|
+
data: { retryAt: new Date(Math.ceil(r.later / 60_000) * 60_000).toISOString(), ...(r.laterDown ? { leftAlone: r.laterDown } : {}) },
|
|
457
|
+
};
|
|
447
458
|
}
|
|
448
459
|
/** Does this strom's task wait take --check <URL> (a check of a site by the person)? Said in the advice only then. */
|
|
449
460
|
function waitChecks() {
|
|
@@ -1227,7 +1238,10 @@ register({
|
|
|
1227
1238
|
description: "Every request goes through strom's limiter: one at a time, paced, capped per hour; a refusal (401/403)\n" +
|
|
1228
1239
|
"stops the run and leaves the archive alone for a day. An archive's hourly cap used up: strom waits for it by\n" +
|
|
1229
1240
|
"itself (up to 30 min, never into a session's last 10) and says until when — no need to sleep and ask again;\n" +
|
|
1230
|
-
"longer, it ends with exit 7 and the time to try again.
|
|
1241
|
+
"longer, it ends with exit 7 and the time to try again. An archive that went silent (or asked twice to slow\n" +
|
|
1242
|
+
"down) is left alone for an hour (longer when it goes silent again): a fetch then ends at once with exit 7\n" +
|
|
1243
|
+
"and the time — other work meanwhile, or close the session with --continue; a fetch with --background waits\n" +
|
|
1244
|
+
"for it by itself when it frees within 30 min. With --recordset the images are registered as they\n" +
|
|
1231
1245
|
"come, 10 at a time (strom read of the book may begin meanwhile); Ctrl-C keeps what came, the same command\n" +
|
|
1232
1246
|
"fetches the rest;\n" +
|
|
1233
1247
|
"without it they go into the inbox, a folder for the book.\n" +
|
|
@@ -2200,6 +2214,17 @@ const BACKGROUND_START_MS = 15_000;
|
|
|
2200
2214
|
*/
|
|
2201
2215
|
async function startBackground(ctx, c, request, recordset, owner, noIndex, again) {
|
|
2202
2216
|
const tree = ctx.tree();
|
|
2217
|
+
// a host of the archive left alone for a while (it went silent, asked to slow down): waited for when it frees in
|
|
2218
|
+
// time, else said when to try again — no strom started only to end at once
|
|
2219
|
+
const alone = leftAloneHosts(ctx, c);
|
|
2220
|
+
const by = Date.now() + limitWait(ctx).waitMs;
|
|
2221
|
+
const tooLong = alone.filter((a) => a.until > by);
|
|
2222
|
+
if (tooLong.length) {
|
|
2223
|
+
const last = tooLong.reduce((a, b) => (b.until > a.until ? b : a));
|
|
2224
|
+
const wait = later({ later: last.until, laterDown: last.host }, `strom fetch ${c.name} ${shellArg(String(request.book))} --images ${imagesArg(request.images)} --recordset ${recordset} --background`);
|
|
2225
|
+
return { text: lines(`nothing started in the background: ${last.host} is left alone longer than a fetch in the background waits (${Math.round(limitWait(ctx).waitMs / 60_000)} min)`, wait.line), data: { started: false, ...wait.data }, exitCode: EXIT.later };
|
|
2226
|
+
}
|
|
2227
|
+
const waits = alone.length ? alone.reduce((a, b) => (b.until > a.until ? b : a)) : undefined;
|
|
2203
2228
|
const log = backgroundLog(tree.root, recordset);
|
|
2204
2229
|
fs.mkdirSync(path.dirname(log), { recursive: true });
|
|
2205
2230
|
const out = fs.openSync(log, "a");
|
|
@@ -2232,9 +2257,9 @@ async function startBackground(ctx, c, request, recordset, owner, noIndex, again
|
|
|
2232
2257
|
child.on("error", () => (exit = 1));
|
|
2233
2258
|
child.unref();
|
|
2234
2259
|
const pid = child.pid ?? 0;
|
|
2235
|
-
const
|
|
2260
|
+
const startBy = Date.now() + BACKGROUND_START_MS;
|
|
2236
2261
|
let began = false;
|
|
2237
|
-
while (Date.now() <
|
|
2262
|
+
while (Date.now() < startBy && exit === undefined) {
|
|
2238
2263
|
if (activeFetches(tree.root, recordset).some((m) => m.pid === pid)) {
|
|
2239
2264
|
began = true;
|
|
2240
2265
|
break;
|
|
@@ -2258,10 +2283,21 @@ async function startBackground(ctx, c, request, recordset, owner, noIndex, again
|
|
|
2258
2283
|
}
|
|
2259
2284
|
const n = request.images.length;
|
|
2260
2285
|
return {
|
|
2261
|
-
text: lines(`fetching ${counted(n, "image")} of ${recordset} (images ${runs(request.images)}) in the background — pid ${pid}, for ${owner.task ?? `session ${owner.session}`}`, ` read them as they come: strom read ${recordset} --images ${imagesArg(request.images)} · what came: strom fetch --status · stop it: strom fetch --stop ${recordset}`, ` it stops when the session is asked to finish or closes${isRunWorker(owner.worker) ? " (in this run: when the run ends)" : ""}; what came stays registered`, owner.guessed ? ` for ${owner.task}: the one task this session worked on that names ${recordset}${currentSession(tree, ctx.env)?.task ? ` (the session's own: ${currentSession(tree, ctx.env).task})` : ""} — for another one: --task T…` : undefined),
|
|
2262
|
-
data: { pid, recordset, images: request.images, session: owner.session, ...(owner.task ? { task: owner.task } : {}), log },
|
|
2286
|
+
text: lines(`fetching ${counted(n, "image")} of ${recordset} (images ${runs(request.images)}) in the background — pid ${pid}, for ${owner.task ?? `session ${owner.session}`}`, waits ? ` ${waits.host} is left alone until ${clockUp(waits.until)} (it went silent, or asked to slow down): the fetch waits for it, then goes on by itself — other work meanwhile, no asking again` : undefined, ` read them as they come: strom read ${recordset} --images ${imagesArg(request.images)} · what came: strom fetch --status · stop it: strom fetch --stop ${recordset}`, ` it stops when the session is asked to finish or closes${isRunWorker(owner.worker) ? " (in this run: when the run ends)" : ""}; what came stays registered`, owner.guessed ? ` for ${owner.task}: the one task this session worked on that names ${recordset}${currentSession(tree, ctx.env)?.task ? ` (the session's own: ${currentSession(tree, ctx.env).task})` : ""} — for another one: --task T…` : undefined),
|
|
2287
|
+
data: { pid, recordset, images: request.images, session: owner.session, ...(owner.task ? { task: owner.task } : {}), log, ...(waits ? { waitsUntil: new Date(waits.until).toISOString() } : {}) },
|
|
2263
2288
|
};
|
|
2264
2289
|
}
|
|
2290
|
+
/** The hosts of a connector left alone now for a while without a refusal (silence, "slow down"), and until when. */
|
|
2291
|
+
function leftAloneHosts(ctx, c) {
|
|
2292
|
+
const dir = netDir(ctx);
|
|
2293
|
+
const now = Date.now();
|
|
2294
|
+
return knownHosts(dir)
|
|
2295
|
+
.filter((h) => hostAllowed(h, c.manifest.hosts))
|
|
2296
|
+
.flatMap((host) => {
|
|
2297
|
+
const until = leftAloneFor(hostState(dir, host), now);
|
|
2298
|
+
return until !== undefined ? [{ host, until }] : [];
|
|
2299
|
+
});
|
|
2300
|
+
}
|
|
2265
2301
|
/** A fetch in the foreground whose images another strom is fetching already: waits for them (Ctrl-C stops waiting). */
|
|
2266
2302
|
async function joinFetch(ctx, recordset, joined, said) {
|
|
2267
2303
|
const tree = ctx.tree();
|
|
@@ -2289,7 +2325,7 @@ async function joinFetch(ctx, recordset, joined, said) {
|
|
|
2289
2325
|
/** One line of a fetch at work, for strom fetch --status. */
|
|
2290
2326
|
function activeLine(m) {
|
|
2291
2327
|
const since = m.since ? ` · since ${clock(Date.parse(m.since))}` : "";
|
|
2292
|
-
const state = m.waitUntil && m.waitUntil > Date.now() ? ` · waits for the archive
|
|
2328
|
+
const state = m.waitUntil && m.waitUntil > Date.now() ? ` · waits for the archive until ${clockUp(m.waitUntil)}, then goes on by itself` : m.yielding ? " · waits while a fetch in the foreground asks the archive" : "";
|
|
2293
2329
|
return `${m.recordset} (${m.connector} ${m.book}): ${m.registered}/${m.images.length} images registered · ${m.background ? `in the background${m.task ? ` for ${m.task}` : ""}${m.session ? ` (session ${m.session})` : ""}` : "in the foreground"}${since}${state} · pid ${m.pid}`;
|
|
2294
2330
|
}
|
|
2295
2331
|
function endedLine(e) {
|
|
@@ -2495,7 +2531,7 @@ async function fetchWith(ctx, c, request, recordset, noIndex = false, again = fa
|
|
|
2495
2531
|
const wait = later(r, "the same command");
|
|
2496
2532
|
return {
|
|
2497
2533
|
text: lines(describeRun(r, `${request.cmd} (${took})`), r.books.length ? [`books (${r.books.length}):`, ...bookLines(r, c, `strom fetch ${c.name} <id> --list`, tree)].join("\n") : advice ?? (r.stopped ? "no books — the run stopped before any came" : "no books found"), r.books.length ? advice : undefined, wait.line, near),
|
|
2498
|
-
data: r,
|
|
2534
|
+
data: { ...r, ...wait.data },
|
|
2499
2535
|
...(wait.exitCode ? { exitCode: wait.exitCode } : r.stopped && !r.books.length ? { exitCode: 1 } : {}),
|
|
2500
2536
|
};
|
|
2501
2537
|
}
|
|
@@ -2549,12 +2585,13 @@ async function fetchWith(ctx, c, request, recordset, noIndex = false, again = fa
|
|
|
2549
2585
|
text = "no images fetched";
|
|
2550
2586
|
}
|
|
2551
2587
|
// the images it got are registered; the rest when the archive takes requests again
|
|
2552
|
-
const
|
|
2588
|
+
const again = request.cmd === "fetch" ? `strom fetch ${c.name} ${shellArg(String(request.book))} --images ${imagesArg(request.images)}${recordset ? ` --recordset ${recordset}` : ""}` : undefined;
|
|
2589
|
+
const wait = later(r, again ? `${again} — what is here already is not fetched again` : "the same command", again && recordset && !child && currentSession(tree, ctx.env) ? again : undefined);
|
|
2553
2590
|
// images another strom was fetching: waited for once this one's are here (the foreground)
|
|
2554
2591
|
const after = joined && !batches?.interrupted() && recordset ? await joinFetch(ctx, recordset, joined, `images ${runs(joined.images)} of ${recordset}: the other fetch`) : undefined;
|
|
2555
2592
|
const code = wait.exitCode ? wait.exitCode : batches?.stopped() ? 0 : batches?.interrupted() ? EXIT.cancelled : r.stopped && !r.images.length ? 1 : after?.exitCode;
|
|
2556
2593
|
endCode = code ?? 0;
|
|
2557
|
-
return { text: lines(describeRun(r, request.cmd), text, advice, wait.line, near, after?.text), data: { ...data, ...(after ? { joined: after.data } : {}) }, ...(code ? { exitCode: code } : {}) };
|
|
2594
|
+
return { text: lines(describeRun(r, request.cmd), text, advice, wait.line, near, after?.text), data: { ...data, ...wait.data, ...(after ? { joined: after.data } : {}) }, ...(code ? { exitCode: code } : {}) };
|
|
2558
2595
|
}
|
|
2559
2596
|
finally {
|
|
2560
2597
|
// a fetch in the background says how it ended (strom fetch --status, the next brief) before its mark goes
|
package/dist/commands/grep.js
CHANGED
|
@@ -62,9 +62,12 @@ register({
|
|
|
62
62
|
const regex = !!opts.regex;
|
|
63
63
|
if (!patterns.length || (!regex && !patterns.some((p) => foldText(p))))
|
|
64
64
|
throw new UsageError("what to find?", { hint: 'strom grep "<text>"' });
|
|
65
|
-
const
|
|
66
|
-
if (!Number.isInteger(
|
|
65
|
+
const asked = opts.context === undefined ? 0 : Number(opts.context);
|
|
66
|
+
if (!Number.isInteger(asked) || asked < 0)
|
|
67
67
|
throw new UsageError(`--context: a number of lines, 0–${MAX_CONTEXT}`);
|
|
68
|
+
// more than the most: the most, said (no turn lost on it)
|
|
69
|
+
const context = Math.min(asked, MAX_CONTEXT);
|
|
70
|
+
const cut = asked > MAX_CONTEXT ? `--context: at most ${MAX_CONTEXT} lines — ${MAX_CONTEXT} shown` : undefined;
|
|
68
71
|
// an input named by its ID: its file in inputs/ (one kept in the shared store is an image or a document)
|
|
69
72
|
const wanted = listOpt(opts.in).map((w) => {
|
|
70
73
|
if (!/^I\p{N}+$/iu.test(w))
|
|
@@ -123,7 +126,7 @@ register({
|
|
|
123
126
|
const tooBig = found.tooBig.length ? `left out, over 64 MB: ${found.tooBig.join(", ")}` : undefined;
|
|
124
127
|
if (!found.hits.length)
|
|
125
128
|
return {
|
|
126
|
-
text: lines(`not found in ${counted(found.searched, "text file")} of ${where}`, tooBig, "→ the records: strom find <text>"),
|
|
129
|
+
text: lines(`not found in ${counted(found.searched, "text file")} of ${where}`, tooBig, cut, "→ the records: strom find <text>"),
|
|
127
130
|
data: { total: 0, searched: found.searched, hits: [], ...(found.tooBig.length ? { tooBig: found.tooBig } : {}) },
|
|
128
131
|
};
|
|
129
132
|
const total = `${found.hits.length}${found.capped ? "+" : ""}`;
|
|
@@ -140,7 +143,7 @@ register({
|
|
|
140
143
|
}
|
|
141
144
|
const page = paginate(found.hits, ctx.limit, ctx.page);
|
|
142
145
|
return {
|
|
143
|
-
text: lines(head + ":", ...grepLines(page.items), moreLine(page, again) || undefined, tooBig),
|
|
146
|
+
text: lines(head + ":", ...grepLines(page.items), moreLine(page, again) || undefined, tooBig, cut),
|
|
144
147
|
data: { total: found.hits.length, capped: found.capped, searched: found.searched, files: found.counts.size, hits: page.items, ...(found.tooBig.length ? { tooBig: found.tooBig } : {}) },
|
|
145
148
|
};
|
|
146
149
|
},
|
|
@@ -131,11 +131,13 @@ export function summary(blocks, o = {}) {
|
|
|
131
131
|
else
|
|
132
132
|
out.push(...ls);
|
|
133
133
|
}
|
|
134
|
+
// words looked for: the blocks that mention them alone — the book's gaps and parts belong to its overview
|
|
135
|
+
const overview = !(o.match ?? []).some((m) => m.trim());
|
|
134
136
|
const gaps = blocks.flatMap((b) => (b.gaps ?? []).map((g) => `${label(b)}: ${g}`));
|
|
135
137
|
const parts = blocks.flatMap((b) => (b.section ?? []).map((s) => `${label(b)}: ${s}`));
|
|
136
|
-
if (gaps.length)
|
|
138
|
+
if (gaps.length && overview)
|
|
137
139
|
out.push(`gaps in the book (by image): ${gaps.join(" · ")}`);
|
|
138
|
-
if (parts.length)
|
|
140
|
+
if (parts.length && overview)
|
|
139
141
|
out.push(`parts of the book (by image): ${parts.join(" · ")}`);
|
|
140
142
|
return { lines: out, more, gaps: blocks.flatMap((b) => b.gaps ?? []), shown };
|
|
141
143
|
}
|
|
@@ -389,6 +391,8 @@ export function earlierLines(e) {
|
|
|
389
391
|
...(e.readings.length || e.searches.length ? ["(reading them again is right for another question or a blind check — not for the same one)"] : []),
|
|
390
392
|
];
|
|
391
393
|
}
|
|
394
|
+
/** The readings strom readings --match shows at most unless --limit says otherwise (the newest first). */
|
|
395
|
+
const MATCH_READINGS = 20;
|
|
392
396
|
register({
|
|
393
397
|
path: ["readings"],
|
|
394
398
|
summary: "What earlier readers reported (strom read), compact: finds, unclear entries, what was illegible, gaps in the book",
|
|
@@ -397,13 +401,15 @@ register({
|
|
|
397
401
|
description: "The readers' reports in notes/readings, without reading them whole: each found and unclear block, from the\n" +
|
|
398
402
|
"blocks with nothing found only what the reader could not read (a possible match is often there), the gaps in\n" +
|
|
399
403
|
"the page numbering and the parts of the book they saw. One image's block word for word: --image N.\n" +
|
|
400
|
-
"Default: the newest reading; with --images, --image or --match all of them
|
|
404
|
+
"Default: the newest reading; with --images, --image or --match all of them — with --match only the readings\n" +
|
|
405
|
+
"that mention it (the newest 20; --limit for more), the others counted. Nothing is written.",
|
|
401
406
|
args: [{ name: "which", description: "a record set (B…), a reading: its report's name or the start of it (a day), or a reading of a document (D…)" }],
|
|
402
407
|
options: [
|
|
403
408
|
{ name: "images", type: "string", value: "<from-to>", description: "only these image numbers, e.g. 40-69 or 5-113,130-228" },
|
|
404
409
|
{ name: "image", type: "string", value: "<n>", description: "that image's whole block, word for word (a few: 35-38)" },
|
|
405
410
|
{ name: "match", type: "string", multiple: true, value: "<words>", description: "only blocks that mention it — any spelling of accents (repeatable: variants)" },
|
|
406
411
|
{ name: "list", type: "boolean", description: "the readings there are, one line each" },
|
|
412
|
+
{ name: "limit", type: "string", value: "<n>", description: `at most this many readings (with --match: ${MATCH_READINGS} unless said)` },
|
|
407
413
|
],
|
|
408
414
|
examples: ["strom readings", "strom readings B0001 --images 40-69 --match Novák --match Nowak", "strom readings B0001 --image 57", "strom readings D20260101120000-ab12", "strom readings --list"],
|
|
409
415
|
run(ctx, { args, opts }) {
|
|
@@ -443,10 +449,21 @@ register({
|
|
|
443
449
|
// one image, or a few (35-38, 35..38): each one's block word for word
|
|
444
450
|
const image = opts.image === undefined ? undefined : new Set(imageRange(String(opts.image)));
|
|
445
451
|
const match = (Array.isArray(opts.match) ? opts.match : typeof opts.match === "string" ? [opts.match] : []).map(String).filter((m) => m.trim());
|
|
452
|
+
const limit = opts.limit === undefined ? undefined : Number(opts.limit);
|
|
453
|
+
if (limit !== undefined && !(Number.isInteger(limit) && limit > 0))
|
|
454
|
+
throw new UsageError(`invalid --limit "${String(opts.limit)}": a whole number, 1 or more`, { hint: "e.g. --limit 40" });
|
|
446
455
|
const pick = (r) => r.blocks.filter((b) => (!book || b.recordset === book) && (!ranges || within(b.num, ranges)) && (image === undefined || (b.num !== undefined && image.has(b.num))));
|
|
447
|
-
const
|
|
456
|
+
const picked = all.map((r) => ({ r, blocks: pick(r) })).filter((x) => x.blocks.length);
|
|
457
|
+
// words looked for: only the readings that mention them (the rest counted), the newest first, at most the limit
|
|
458
|
+
const readings = match.length ? picked.filter(({ r, blocks }) => select(blocks, { match, question: r.question }).length) : picked;
|
|
459
|
+
const silent = picked.length - readings.length;
|
|
448
460
|
// nothing asked: the newest reading only
|
|
449
|
-
const
|
|
461
|
+
const every = which || ranges || image !== undefined || match.length || opts.list ? readings : readings.slice(0, 1);
|
|
462
|
+
const cap = limit ?? (match.length ? MATCH_READINGS : Infinity);
|
|
463
|
+
const shown = every.slice(0, cap);
|
|
464
|
+
const beyond = every.length - shown.length;
|
|
465
|
+
if (shown.length === 0 && match.length && picked.length)
|
|
466
|
+
return { text: `no reading mentions ${match.map((m) => `"${m}"`).join(" or ")} (${counted(picked.length, "reading")} looked through) · strom grep for the notes too`, data: { readings: [], without: picked.length } };
|
|
450
467
|
if (shown.length === 0)
|
|
451
468
|
return { text: `no reader reported ${image !== undefined ? `image ${runs([...image])}` : "these images"}${book ? ` of ${book}` : ""} · strom readings --list`, data: { readings: [] } };
|
|
452
469
|
if (opts.list) {
|
|
@@ -477,7 +494,11 @@ register({
|
|
|
477
494
|
};
|
|
478
495
|
});
|
|
479
496
|
const at = book ?? (which ? shellArg(which) : shown[0].blocks[0]?.recordset ?? shellArg(shown[0].r.stem));
|
|
497
|
+
if (beyond > 0)
|
|
498
|
+
parts.push(`… ${counted(beyond, "more reading")}${match.length ? " mention it" : ""}: --limit ${every.length}`);
|
|
499
|
+
if (silent > 0)
|
|
500
|
+
parts.push(`${counted(silent, "other reading")} ${silent === 1 ? "does" : "do"} not mention it`);
|
|
480
501
|
parts.push(`one image's whole block: strom readings ${at} --image <n>`);
|
|
481
|
-
return { text: lines(...parts), data: { readings: data } };
|
|
502
|
+
return { text: lines(...parts), data: { readings: data, ...(beyond > 0 ? { more: beyond } : {}), ...(match.length ? { without: silent } : {}) } };
|
|
482
503
|
},
|
|
483
504
|
});
|
package/dist/commands/sources.js
CHANGED
|
@@ -147,7 +147,9 @@ register({
|
|
|
147
147
|
tree: true,
|
|
148
148
|
writes: true,
|
|
149
149
|
description: "An original register entry or certificate is primary evidence; family trees and memories are\n" +
|
|
150
|
-
"authored/derivative — facts citing only them stay leads. --transcript @file reads a file
|
|
150
|
+
"authored/derivative — facts citing only them stay leads. --transcript @file reads a file.\n" +
|
|
151
|
+
`One source is one entry (its clips: the entry, and its continuation over a page break — at most ${MAX_CLIPS});\n` +
|
|
152
|
+
"several entries of one page are several sources.",
|
|
151
153
|
args: [{ name: "title", description: 'short title: "Baptism of Jan Novák, Týnec 1905"', required: true }],
|
|
152
154
|
options: [
|
|
153
155
|
{ name: "kind", type: "string", value: "<kind>", description: `${SOURCE_KINDS.slice(0, 8).join(", ")}, … (default other)` },
|
|
@@ -185,7 +187,7 @@ register({
|
|
|
185
187
|
requireRecord(tree, opts.input, "input");
|
|
186
188
|
const clips = clipRefs(tree, listOpt(opts.clip), ctx.settings.shared()?.value);
|
|
187
189
|
if (clips.length > MAX_CLIPS)
|
|
188
|
-
throw new UsageError(`at most ${MAX_CLIPS} clips: an entry, and its continuation over a page break`);
|
|
190
|
+
throw new UsageError(`at most ${MAX_CLIPS} clips: an entry, and its continuation over a page break — several entries are several sources (one strom source add each)`);
|
|
189
191
|
const media = [...new Set([...mediaRefs(tree, csvOpt(opts.media)), ...clips.map((c) => c.media)])];
|
|
190
192
|
// a line of an index is a copy made by another hand; a book with its own index holds originals too
|
|
191
193
|
const kind = oneOf(opts.kind, SOURCE_KINDS, "kind", "other");
|
package/dist/commands/web.js
CHANGED
|
@@ -7,6 +7,7 @@ import { webDomain, webRequestsOf } from "../core/metrics.js";
|
|
|
7
7
|
import { currentSession } from "../core/session.js";
|
|
8
8
|
import { requireRecord } from "../core/records.js";
|
|
9
9
|
import { webGate } from "../core/webgate.js";
|
|
10
|
+
import { knownSearch } from "../core/knownterms.js";
|
|
10
11
|
import { termsFor } from "../core/terms.js";
|
|
11
12
|
import { clock } from "../core/net.js";
|
|
12
13
|
import { UsageError } from "../core/errors.js";
|
|
@@ -28,7 +29,8 @@ register({
|
|
|
28
29
|
"(their yes or no to that question stands for it) — counted once it went out (the same hook after the fetch ran).\n" +
|
|
29
30
|
"Without --hook: this session's requests by server (no session open: the last one with web requests, said which).\n" +
|
|
30
31
|
"--check <url>: whether strom would let your web tools fetch that address now, and why — the archive's level, the\n" +
|
|
31
|
-
"person's permission, the robots.txt line, a refusal of strom's client, the host left alone — read only, nothing sent
|
|
32
|
+
"person's permission, the robots.txt line, a refusal of strom's client, the host left alone — read only, nothing sent;\n" +
|
|
33
|
+
"a digital library strom knows: its own search addresses too, to fetch as given (the words URL-encoded).\n" +
|
|
32
34
|
"An agent without strom's hook (Codex, OpenCode, Antigravity) runs it before its own web fetch of an archive's site\n" +
|
|
33
35
|
"and keeps to its answer.",
|
|
34
36
|
options: [
|
|
@@ -122,6 +124,7 @@ function checkUrl(ctx, url) {
|
|
|
122
124
|
const session = tree ? currentSession(tree, ctx.env, ctx.refs) : undefined;
|
|
123
125
|
const g = webGate({ env: ctx.env, ...(tree ? { tree } : {}), url: u.href, shared, ...(session?.task ? { task: session.task } : {}) });
|
|
124
126
|
const ok = g.allow && !g.blocked;
|
|
127
|
+
const search = knownSearch(u.hostname);
|
|
125
128
|
const a = g.level;
|
|
126
129
|
const answer = !g.allow ? "refused" : g.blocked ? "refused now" : g.why === "terms" ? "only this page (its terms)" : "allowed";
|
|
127
130
|
const why = !g.allow
|
|
@@ -132,7 +135,9 @@ function checkUrl(ctx, url) {
|
|
|
132
135
|
? `its archive is closed to automated tools; its terms page only, at most a few times a day`
|
|
133
136
|
: "pages of it through your web tools within the web rule (strom guide); images of an archive through strom fetch";
|
|
134
137
|
return {
|
|
135
|
-
text: lines(`${u.href}: ${answer}`, a ? ` archive ${a.host}: ${a.level}${a.from.length ? ` (${a.from.join(", ")})` : ""}${a.permission ? ` · the person's permission: ${a.permission}` : ""}${a.refused ? " · refused strom's client" : ""}${a.browserOnly ? " · only through a person's browser" : ""}` : undefined, g.robots ? ` robots.txt: ${g.robots.line} (${g.robots.path})` : undefined, ` ${why}
|
|
138
|
+
text: lines(`${u.href}: ${answer}`, a ? ` archive ${a.host}: ${a.level}${a.from.length ? ` (${a.from.join(", ")})` : ""}${a.permission ? ` · the person's permission: ${a.permission}` : ""}${a.refused ? " · refused strom's client" : ""}${a.browserOnly ? " · only through a person's browser" : ""}` : undefined, g.robots ? ` robots.txt: ${g.robots.line} (${g.robots.path})` : undefined, ` ${why}`,
|
|
139
|
+
// its own search, as strom's list gives it: fetched as given, never guessed
|
|
140
|
+
...(g.allow && search.length ? [" its search (fetch as given; {q} the words, a space %20, ( %28, ) %29, [ %5B, ] %5D, : %3A, \" %22):", ...search.map((x) => ` ${x.what}: ${x.url}`)] : [])),
|
|
136
141
|
data: {
|
|
137
142
|
url: u.href,
|
|
138
143
|
host: g.host,
|
|
@@ -144,6 +149,7 @@ function checkUrl(ctx, url) {
|
|
|
144
149
|
...(g.blocked ? { blocked: { until: new Date(g.blocked.until).toISOString(), ...(g.blocked.reason ? { reason: g.blocked.reason } : {}) } } : {}),
|
|
145
150
|
...(g.termsToday !== undefined ? { termsToday: g.termsToday } : {}),
|
|
146
151
|
reason: why,
|
|
152
|
+
...(g.allow && search.length ? { search } : {}),
|
|
147
153
|
},
|
|
148
154
|
};
|
|
149
155
|
}
|
package/dist/core/connector.js
CHANGED
|
@@ -25,7 +25,7 @@ import { loadUserConfig, Settings } from "./config.js";
|
|
|
25
25
|
import { readJsonIfExists, writeJson } from "./json.js";
|
|
26
26
|
import { readAsset } from "./assets.js";
|
|
27
27
|
import { foldText } from "./text.js";
|
|
28
|
-
import { CookieJar, hostAllowed, hostState, NetError, paceOf, paceText, politeRequest, releaseForeground } from "./net.js";
|
|
28
|
+
import { CookieJar, hostAllowed, hostState, NetError, paceOf, paceText, politeRequest, REFUSED_FOREVER, releaseForeground } from "./net.js";
|
|
29
29
|
import { loginOf, redactor } from "./logins.js";
|
|
30
30
|
import { HYPOTHESIS_LINKS_ORIGIN } from "./hypolinks.js";
|
|
31
31
|
import { decodeImage, encodeImage, imageSize, imageSizeOfFile } from "../image/index.js";
|
|
@@ -1052,10 +1052,14 @@ export async function runConnector(c, request, opts) {
|
|
|
1052
1052
|
catch (err) {
|
|
1053
1053
|
if (!(err instanceof NetError))
|
|
1054
1054
|
throw err;
|
|
1055
|
-
if (err.failure === "cap" && err.until !== undefined)
|
|
1056
|
-
report.later = err.until;
|
|
1057
1055
|
// a refusal: why, and the limiter's advice with it (the agent reads it with what strom fetch says next)
|
|
1058
1056
|
const refusal = (err.failure === "refused" && (err.status === 401 || err.status === 403)) || (err.failure === "blocked" && !!err.refusedKind) || err.failure === "robots";
|
|
1057
|
+
// a limit used up, or a host left alone for a while (silence, "slow down"): when to try again — exit 7
|
|
1058
|
+
if (!refusal && err.until !== undefined && err.until < REFUSED_FOREVER && (err.failure === "cap" || err.failure === "blocked" || err.failure === "silent" || err.failure === "refused")) {
|
|
1059
|
+
report.later = err.until;
|
|
1060
|
+
if (err.failure !== "cap")
|
|
1061
|
+
report.laterDown = err.host;
|
|
1062
|
+
}
|
|
1059
1063
|
if (refusal)
|
|
1060
1064
|
report.refused = {
|
|
1061
1065
|
host: err.host,
|
package/dist/core/errors.js
CHANGED
|
@@ -9,7 +9,10 @@ export const EXIT = {
|
|
|
9
9
|
locked: 5,
|
|
10
10
|
/** A backup before another channel or an older version could not be made: nothing is switched (core/backup.ts). */
|
|
11
11
|
noBackup: 6,
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* An archive's limit is used up (its hourly cap), or it is left alone for a while (it went silent, asked to slow
|
|
14
|
+
* down): nothing more goes to it until the time said — other work meanwhile.
|
|
15
|
+
*/
|
|
13
16
|
later: 7,
|
|
14
17
|
cancelled: 130,
|
|
15
18
|
};
|
package/dist/core/knownterms.js
CHANGED
|
@@ -53,3 +53,21 @@ export function knownPaceMs(host) {
|
|
|
53
53
|
export function knownDisallow(host) {
|
|
54
54
|
return [...new Set(shippedTerms().filter((k) => within(host, k.host)).flatMap((k) => (Array.isArray(k.disallow) ? k.disallow.filter((d) => typeof d === "string" && d.startsWith("/")) : [])))];
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* The search addresses strom's list gives for a host (its own, else its site's): what an agent tried blind before
|
|
58
|
+
* (a Solr query's brackets and spaces unencoded: HTTP 400; a search page empty without a browser).
|
|
59
|
+
*/
|
|
60
|
+
export function knownSearch(host) {
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
return shippedTerms()
|
|
63
|
+
.filter((k) => within(host, k.host) && Array.isArray(k.search))
|
|
64
|
+
.flatMap((k) => k.search.filter((x) => !!x && typeof x.url === "string" && /^https:\/\//.test(x.url) && typeof x.what === "string"))
|
|
65
|
+
.filter((x) => !seen.has(x.url) && !!seen.add(x.url));
|
|
66
|
+
}
|
|
67
|
+
/** The search addresses as lines for the agent (none: undefined). */
|
|
68
|
+
export function searchText(host) {
|
|
69
|
+
const all = knownSearch(host);
|
|
70
|
+
if (!all.length)
|
|
71
|
+
return undefined;
|
|
72
|
+
return `strom: ${host}'s own search, fetched as given — {q} the words (a word, or (word OR word)), {from} {to} the years, {pid} an item found; every space %20, ( %28, ) %29, [ %5B, ] %5D, : %3A, " %22: ${all.map((x) => `${x.what}: ${x.url}`).join(" · ")}`;
|
|
73
|
+
}
|
package/dist/core/net.js
CHANGED
|
@@ -60,6 +60,13 @@ export const AGENT_TOOL_TOKENS = { claude: ["claude-user", "claude-web", "claude
|
|
|
60
60
|
export const MAX_WAIT_MS = 2 * 60_000;
|
|
61
61
|
/** An hour off after a host kept asking us to slow down, or stopped answering. */
|
|
62
62
|
export const COOL_OFF_MS = 3600_000;
|
|
63
|
+
/**
|
|
64
|
+
* Off after a host went silent: an hour, and longer each time it goes silent again before it answered (within a day of
|
|
65
|
+
* the last time) — never shorter than an hour (gentle on archives: a firewall that drops requests looks the same).
|
|
66
|
+
*/
|
|
67
|
+
export const SILENT_COOL_MS = [COOL_OFF_MS, 2 * COOL_OFF_MS, 4 * COOL_OFF_MS];
|
|
68
|
+
/** How long a silence counts towards the next, longer, time off (SILENT_COOL_MS). */
|
|
69
|
+
const SILENT_RUN_MS = 24 * 3600_000;
|
|
63
70
|
const TIMEOUT_MS = 60_000;
|
|
64
71
|
/** Attempts per request: a busy server (5xx) a few, "slow down" (429) and silence only one more. */
|
|
65
72
|
const ATTEMPTS = { busy: 3, slowDown: 2, silent: 2 };
|
|
@@ -115,7 +122,7 @@ export function capNear(s, pace, now) {
|
|
|
115
122
|
/** A limit used up: try again then. */
|
|
116
123
|
function capError(host, why, until, perHour) {
|
|
117
124
|
const what = why === "cap" ? `${perHour} requests to ${host} in the last hour — its hourly cap` : `${host} says its limit is used up`;
|
|
118
|
-
return new NetError("cap", host, `${what}; try again at ${
|
|
125
|
+
return new NetError("cap", host, `${what}; try again at ${clockUp(until)}`, `go on with other work; run it again at ${clockUp(until)} — not sooner: strom sends nothing to ${host} before then${why === "limit" ? `; the person can lift it early: strom allow host ${host} --unblock` : ""}`, undefined, until);
|
|
119
126
|
}
|
|
120
127
|
/**
|
|
121
128
|
* A fetch in the foreground holds a host this long after its last request (a mark beside the host's state, renewed
|
|
@@ -449,6 +456,11 @@ function checkHostState(raw, at) {
|
|
|
449
456
|
drop("refusedFirst");
|
|
450
457
|
if (has("okAt") && !until(raw.okAt))
|
|
451
458
|
drop("okAt");
|
|
459
|
+
if (has("silentRun")) {
|
|
460
|
+
const r = raw.silentRun;
|
|
461
|
+
if (!(r && typeof r === "object" && typeof r.n === "number" && Number.isInteger(r.n) && r.n >= 1 && until(r.at)))
|
|
462
|
+
drop("silentRun");
|
|
463
|
+
}
|
|
452
464
|
if (has("tracked") && !until(raw.tracked))
|
|
453
465
|
drop("tracked");
|
|
454
466
|
if (has("own")) {
|
|
@@ -511,7 +523,7 @@ export function stamp(ms) {
|
|
|
511
523
|
}
|
|
512
524
|
/** Until when a host is left alone, in words: a time, or until the person lifts it (a refusal of strom's client). */
|
|
513
525
|
export function leftUntil(host, until) {
|
|
514
|
-
return until >= REFUSED_FOREVER ? `until the person lifts it (strom allow host ${host} --unblock)` : `until ${
|
|
526
|
+
return until >= REFUSED_FOREVER ? `until the person lifts it (strom allow host ${host} --unblock)` : `until ${clockUp(until)}`;
|
|
515
527
|
}
|
|
516
528
|
/**
|
|
517
529
|
* The groups of a robots.txt that apply to strom: User-agent strom-research (its product token, any case, a version
|
|
@@ -672,7 +684,11 @@ function netError(failure, host, message, hint, status, extra = {}) {
|
|
|
672
684
|
/** A host left alone (a refusal, silence, "slow down"): what strom says of a request it does not send. */
|
|
673
685
|
function blockedError(host, s) {
|
|
674
686
|
const until = s.blockedUntil;
|
|
675
|
-
return netError("blocked", host, `${host}: ${s.reason ?? "refused us"} — left alone ${leftUntil(host, until)}`, until >= REFUSED_FOREVER ? `do not try it through your web tools or a browser either; only the person lifts it: strom allow host ${host} --unblock` : `do not retry: go on with other work; the user can lift it early with strom allow host ${host} --unblock`, undefined, { refusedKind: s.refusedKind, until });
|
|
687
|
+
return netError("blocked", host, `${host}: ${s.reason ?? "refused us"} — left alone ${leftUntil(host, until)}`, until >= REFUSED_FOREVER ? `do not try it through your web tools or a browser either; only the person lifts it: strom allow host ${host} --unblock` : `do not retry before ${clockUp(until)}: strom sends ${host} nothing until then — go on with other work; the user can lift it early with strom allow host ${host} --unblock`, undefined, { refusedKind: s.refusedKind, until });
|
|
688
|
+
}
|
|
689
|
+
/** A host left alone for a while without a refusal (it went silent, asked to slow down): waited for in the background. */
|
|
690
|
+
export function leftAloneFor(s, now) {
|
|
691
|
+
return s.blockedUntil && s.blockedUntil > now && s.blockedUntil < REFUSED_FOREVER && !s.refusedKind && !(s.reason ?? "").includes("refused") ? s.blockedUntil : undefined;
|
|
676
692
|
}
|
|
677
693
|
/** A time as the clock on the wall shows it here ("14:32"), with the day when it is not today. */
|
|
678
694
|
export function clock(ms, today = Date.now()) {
|
|
@@ -683,7 +699,28 @@ export function clock(ms, today = Date.now()) {
|
|
|
683
699
|
const sameDay = d.getFullYear() === t.getFullYear() && d.getMonth() === t.getMonth() && d.getDate() === t.getDate();
|
|
684
700
|
return `${sameDay ? "" : `${day} `}${two(d.getHours())}:${two(d.getMinutes())}`;
|
|
685
701
|
}
|
|
702
|
+
/** A time something waits until, as clock() says it, rounded up to the minute: "11:04" for 11:03:22 — never too early. */
|
|
703
|
+
export function clockUp(ms, today = Date.now()) {
|
|
704
|
+
return clock(Math.ceil(ms / 60_000) * 60_000, today);
|
|
705
|
+
}
|
|
686
706
|
/** One request, politely. Throws NetError when the host is not allowed, refused us, went silent, or is capped. */
|
|
707
|
+
/**
|
|
708
|
+
* A wait that ends early when the run is stopped: its own timer, cleared then (a process stopped never stays for a
|
|
709
|
+
* timer left behind) — or a caller's sleep (a test's clock) raced with the stop.
|
|
710
|
+
*/
|
|
711
|
+
function stoppable(ms, stop, sleep) {
|
|
712
|
+
if (stop.aborted)
|
|
713
|
+
return Promise.resolve();
|
|
714
|
+
return new Promise((r) => {
|
|
715
|
+
let timer;
|
|
716
|
+
const done = () => (stop.removeEventListener("abort", done), timer !== undefined && clearTimeout(timer), r());
|
|
717
|
+
stop.addEventListener("abort", done, { once: true });
|
|
718
|
+
if (sleep)
|
|
719
|
+
void sleep(ms).then(done);
|
|
720
|
+
else
|
|
721
|
+
timer = setTimeout(done, ms);
|
|
722
|
+
});
|
|
723
|
+
}
|
|
687
724
|
/** A pause told to the caller's measure; whatever it does, the request goes on. */
|
|
688
725
|
function paused(opts, p) {
|
|
689
726
|
try {
|
|
@@ -880,8 +917,13 @@ export async function politeRequest(url, opts, redirects = 0) {
|
|
|
880
917
|
pace = hostPace(s, opts.pace, human, host);
|
|
881
918
|
slowed = (s.slowdown ?? 1) > 1;
|
|
882
919
|
const t = now();
|
|
883
|
-
if (s.blockedUntil && s.blockedUntil > t)
|
|
920
|
+
if (s.blockedUntil && s.blockedUntil > t) {
|
|
921
|
+
// in the background a host left alone for a while is waited for (with the host let go), never one that refused
|
|
922
|
+
const free = opts.lane === "background" ? leftAloneFor(s, t) : undefined;
|
|
923
|
+
if (free !== undefined && free <= waitBy)
|
|
924
|
+
throw new WaitOutside(free, "down");
|
|
884
925
|
throw blockedError(host, s);
|
|
926
|
+
}
|
|
885
927
|
s.recent = s.recent.filter((x) => x > t - 3600_000);
|
|
886
928
|
if (s.recent.length >= pace.perHour) {
|
|
887
929
|
const free = capFree(s.recent, pace.perHour);
|
|
@@ -904,7 +946,7 @@ export async function politeRequest(url, opts, redirects = 0) {
|
|
|
904
946
|
const gap = (s.last ?? 0) + gapOf(s, pace) - now();
|
|
905
947
|
// a pause longer than the caller waits (a site's long Crawl-delay): said when to come back, never sooner
|
|
906
948
|
if (gap > MAX_WAIT_MS && now() + gap > waitBy)
|
|
907
|
-
throw new NetError("cap", host, `${host}'s pace (${paceText(pace)}${robotsDelayMs(s.robots) >= pace.minIntervalMs ? ", its robots.txt's Crawl-delay" : ""}) — the next request at ${
|
|
949
|
+
throw new NetError("cap", host, `${host}'s pace (${paceText(pace)}${robotsDelayMs(s.robots) >= pace.minIntervalMs ? ", its robots.txt's Crawl-delay" : ""}) — the next request at ${clockUp(now() + gap)}`, `go on with other work; run it again at ${clockUp(now() + gap)} — not sooner`, undefined, now() + gap);
|
|
908
950
|
if (gap > 0) {
|
|
909
951
|
paused(opts, { host, ms: gap, why: "pace" });
|
|
910
952
|
await sleep(gap);
|
|
@@ -937,6 +979,12 @@ export async function politeRequest(url, opts, redirects = 0) {
|
|
|
937
979
|
took = now() - asked;
|
|
938
980
|
const cool = (ms, reason, kind) => {
|
|
939
981
|
s = hostState(opts.stateDir, host, now());
|
|
982
|
+
if (ms === "silent") {
|
|
983
|
+
// silent again within a day, no answer since: longer off each time (an hour, two, four)
|
|
984
|
+
const run = s.silentRun && now() - s.silentRun.at < SILENT_RUN_MS ? s.silentRun.n : 0;
|
|
985
|
+
ms = SILENT_COOL_MS[Math.min(run, SILENT_COOL_MS.length - 1)];
|
|
986
|
+
s.silentRun = { n: run + 1, at: now() };
|
|
987
|
+
}
|
|
940
988
|
s.blockedUntil = kind === "client" ? REFUSED_FOREVER : now() + ms;
|
|
941
989
|
s.reason = reason;
|
|
942
990
|
if (kind)
|
|
@@ -957,7 +1005,7 @@ export async function politeRequest(url, opts, redirects = 0) {
|
|
|
957
1005
|
else if (!robotsAsked && res?.status === 429 && tries.slowDown + 1 >= ATTEMPTS.slowDown)
|
|
958
1006
|
cool(COOL_OFF_MS, `it asked us to slow down twice (HTTP 429 at ${stamp(now())})`);
|
|
959
1007
|
else if (!robotsAsked && !res && tries.silent + 1 >= ATTEMPTS.silent)
|
|
960
|
-
cool(
|
|
1008
|
+
cool("silent", `no answer at ${stamp(now())} — the server is down, or it blocks this IP`);
|
|
961
1009
|
}
|
|
962
1010
|
catch (err) {
|
|
963
1011
|
if (!(err instanceof WaitOutside))
|
|
@@ -970,10 +1018,14 @@ export async function politeRequest(url, opts, redirects = 0) {
|
|
|
970
1018
|
if (outside) {
|
|
971
1019
|
// a slot freed may go to another strom first: waited again, a few times at most, then said when to try again
|
|
972
1020
|
if (++waitedOutside > 3)
|
|
973
|
-
throw capError(host, outside.why, outside.until, pace.perHour);
|
|
1021
|
+
throw outside.why === "down" ? blockedError(host, hostState(opts.stateDir, host, now())) : capError(host, outside.why, outside.until, pace.perHour);
|
|
974
1022
|
opts.onWait?.({ host, until: outside.until, ms: Math.max(0, outside.until - now()), why: outside.why });
|
|
975
1023
|
paused(opts, { host, ms: Math.max(0, outside.until - now()), why: outside.why });
|
|
976
|
-
|
|
1024
|
+
// a long wait in the background ends when its run is stopped (its session closed)
|
|
1025
|
+
const ms = Math.max(0, outside.until - now());
|
|
1026
|
+
await (opts.stop ? stoppable(ms, opts.stop, opts.sleep ?? testHooks.sleep) : sleep(ms));
|
|
1027
|
+
if (opts.stop?.aborted)
|
|
1028
|
+
throw new NetError("http", host, `stopped while it waited for ${host}`);
|
|
977
1029
|
continue;
|
|
978
1030
|
}
|
|
979
1031
|
// 426 "Upgrade Required": the server speaks HTTP/2 only (Node's fetch speaks HTTP/1.1).
|
|
@@ -1026,6 +1078,8 @@ export async function politeRequest(url, opts, redirects = 0) {
|
|
|
1026
1078
|
delete cur.refusedFirst;
|
|
1027
1079
|
}
|
|
1028
1080
|
cur.latencyMs = Math.round(cur.latencyMs === undefined ? took : cur.latencyMs * 0.7 + took * 0.3);
|
|
1081
|
+
// it answered: a silence later starts from an hour off again
|
|
1082
|
+
delete cur.silentRun;
|
|
1029
1083
|
// the pause counts from the answer: a host that works long on each gets as long to rest
|
|
1030
1084
|
cur.last = Math.max(cur.last ?? 0, now());
|
|
1031
1085
|
const until = limitUsedUp(res.headers, now());
|
|
@@ -1044,13 +1098,17 @@ export async function politeRequest(url, opts, redirects = 0) {
|
|
|
1044
1098
|
// Busy, "slow down", or no answer: slow down, and try again — a little.
|
|
1045
1099
|
const cur = hostState(opts.stateDir, host, now());
|
|
1046
1100
|
cur.slowdown = Math.min(16, (cur.slowdown ?? 1) * 2);
|
|
1101
|
+
if (res)
|
|
1102
|
+
delete cur.silentRun;
|
|
1047
1103
|
writeJson(file, cur);
|
|
1048
1104
|
tries[kind]++;
|
|
1049
1105
|
if (tries[kind] >= attempts[kind]) {
|
|
1106
|
+
const left = hostState(opts.stateDir, host, now()).blockedUntil;
|
|
1107
|
+
const off = left !== undefined && left > now() ? left : undefined;
|
|
1050
1108
|
if (kind === "silent")
|
|
1051
|
-
throw new NetError("silent", host, `no answer from ${host} (${failed?.message ?? "timeout"}) — it is down, or it blocks this IP; strom leaves it alone for an hour`, "do not retry: a firewall that drops requests looks exactly like this");
|
|
1109
|
+
throw new NetError("silent", host, `no answer from ${host} (${failed?.message ?? "timeout"}) — it is down, or it blocks this IP; strom leaves it alone${off ? ` until ${clockUp(off)}` : " for an hour"}`, "do not retry: a firewall that drops requests looks exactly like this", undefined, off);
|
|
1052
1110
|
if (kind === "slowDown")
|
|
1053
|
-
throw new NetError("refused", host, `${host} asked us twice to slow down (429) — strom leaves it alone for an hour`, "go on with other work; a smaller batch next time", 429);
|
|
1111
|
+
throw new NetError("refused", host, `${host} asked us twice to slow down (429) — strom leaves it alone${off ? ` until ${clockUp(off)}` : " for an hour"}`, "go on with other work; a smaller batch next time", 429, off);
|
|
1054
1112
|
throw new NetError("http", host, `${host} answered ${res.status} ${tries.busy} times — the server has trouble; try later`, undefined, res.status);
|
|
1055
1113
|
}
|
|
1056
1114
|
const asked = res ? retryAfterMs(res.headers.get("retry-after"), now()) : undefined;
|
|
@@ -1087,7 +1145,7 @@ export function reserveSlots(stateDir, host, asked, count, opts = {}) {
|
|
|
1087
1145
|
const first = Math.max(t + (opts.leadMs ?? 0), (s.last ?? 0) + gap, s.waitUntil ?? 0);
|
|
1088
1146
|
// never further ahead than SLOTS_AHEAD_MS: fewer than asked, the rest next time
|
|
1089
1147
|
if (first > t + SLOTS_AHEAD_MS)
|
|
1090
|
-
throw new NetError("cap", host, `${host}: strom has planned its requests for the next hour already; try again at ${
|
|
1148
|
+
throw new NetError("cap", host, `${host}: strom has planned its requests for the next hour already; try again at ${clockUp(first - SLOTS_AHEAD_MS)}`, `go on with other work; run it again at ${clockUp(first - SLOTS_AHEAD_MS)}`, undefined, first - SLOTS_AHEAD_MS);
|
|
1091
1149
|
const times = Array.from({ length: Math.min(free, Math.floor((t + SLOTS_AHEAD_MS - first) / gap) + 1) }, (_, i) => first + i * gap);
|
|
1092
1150
|
s.last = times.at(-1);
|
|
1093
1151
|
s.recent.push(...times);
|
|
@@ -1111,8 +1169,11 @@ export function refusedBy(stateDir, host, status, now = Date.now) {
|
|
|
1111
1169
|
const reason = status === 429 ? `it asked the browser to slow down (HTTP 429 at ${stamp(now())})` : `it refused the browser (HTTP ${status} at ${stamp(now())})`;
|
|
1112
1170
|
s.blockedUntil = Math.max(s.blockedUntil ?? 0, now() + ms);
|
|
1113
1171
|
s.reason = reason;
|
|
1172
|
+
// a refusal is said as one (strom fetch: refused, not "went silent"); a kind it had already stays
|
|
1173
|
+
if (status !== 429)
|
|
1174
|
+
s.refusedKind ??= "load";
|
|
1114
1175
|
writeJson(file, s);
|
|
1115
|
-
return `${host}: ${reason} — strom leaves it alone until ${
|
|
1176
|
+
return `${host}: ${reason} — strom leaves it alone until ${clockUp(s.blockedUntil)}`;
|
|
1116
1177
|
}
|
|
1117
1178
|
finally {
|
|
1118
1179
|
release();
|
package/dist/core/tidy.js
CHANGED
|
@@ -49,6 +49,25 @@ export function tidyOn(root) {
|
|
|
49
49
|
return false;
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* The first tidy of an old tree offered to the person once (the menu): asked already, whatever the answer — the menu
|
|
54
|
+
* and the settings go on saying what can be freed.
|
|
55
|
+
*/
|
|
56
|
+
export function tidyAsked(root) {
|
|
57
|
+
try {
|
|
58
|
+
return typeof JSON.parse(fs.readFileSync(path.join(root, ".strom", "tidy.json"), "utf8")).asked === "string";
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function setTidyAsked(root) {
|
|
65
|
+
if (tidyOn(root))
|
|
66
|
+
return;
|
|
67
|
+
const file = path.join(root, ".strom", "tidy.json");
|
|
68
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
69
|
+
fs.writeFileSync(file, JSON.stringify({ auto: false, asked: new Date().toISOString() }) + "\n");
|
|
70
|
+
}
|
|
52
71
|
export function setTidyOn(root) {
|
|
53
72
|
const file = path.join(root, ".strom", "tidy.json");
|
|
54
73
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
package/dist/core/tree.js
CHANGED
|
@@ -27,7 +27,7 @@ import { lastBackup } from "./backup.js";
|
|
|
27
27
|
export const TREE_FILE = "strom.json";
|
|
28
28
|
// between releases the candidate being made (a bridge run from the sources says what it is: the Strom app goes by it);
|
|
29
29
|
// at a release the release's own (package.json)
|
|
30
|
-
export const VERSION = "1.
|
|
30
|
+
export const VERSION = "1.19.0-beta.1";
|
|
31
31
|
const GITIGNORE = `# Strom Research — local state and large files are not versioned
|
|
32
32
|
.strom/
|
|
33
33
|
/media/
|
package/dist/core/web.js
CHANGED
|
@@ -44,6 +44,7 @@ import { calibrationKey, viewModel } from "./viewsizes.js";
|
|
|
44
44
|
import { ui } from "../cli/ui.js";
|
|
45
45
|
import { isAgent } from "./which.js";
|
|
46
46
|
import { otherWay, webGate } from "./webgate.js";
|
|
47
|
+
import { searchText } from "./knownterms.js";
|
|
47
48
|
/**
|
|
48
49
|
* The thresholds of an agent's own web fetch, per site (webDomain) and session (core/metrics.ts): up to WEB_SOFT at the
|
|
49
50
|
* host's pace alone; then never refused for their number, one per WEB_SOFT_GAP_MS to the whole site, the connector
|
|
@@ -466,10 +467,13 @@ export async function answerWebEvent(ev, o) {
|
|
|
466
467
|
return {};
|
|
467
468
|
const n = had.domains.get(site) ?? 0;
|
|
468
469
|
const event = ev.phase === "failed" ? "PostToolUseFailure" : "PostToolUse";
|
|
470
|
+
// a site whose own search strom's list knows: its addresses with the first page of it in the session, and after a
|
|
471
|
+
// call that failed (what was tried blind before: brackets unencoded, HTTP 400)
|
|
472
|
+
const search = n === 1 || ev.phase === "failed" ? searchText(ev.netHost ?? host) : undefined;
|
|
469
473
|
if (n < Math.max(1, perHost - WEB_NOTE_BEFORE)) {
|
|
470
474
|
if (!had.advised.has(ev.toolUse))
|
|
471
|
-
return {};
|
|
472
|
-
return { context: `${softText(host, site, soft, perHost)} ${wayText(host, way())} ${NEVER}`, event };
|
|
475
|
+
return search ? { context: search, event } : {};
|
|
476
|
+
return { context: `${softText(host, site, soft, perHost)} ${wayText(host, way())} ${NEVER}${search ? ` ${search}` : ""}`, event };
|
|
473
477
|
}
|
|
474
478
|
const head = n <= perHost
|
|
475
479
|
? `strom: ${n} of the ${perHost} requests to ${siteText(host, site)} the web fetch tool takes in this session (web.perHost) used${n === perHost ? " — the next one is refused working alone, and asked of the user once in a conversation" : ""}.`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "strom-research",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.0-beta.1",
|
|
4
4
|
"description": "Genealogical research toolkit for AI coding agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -57,5 +57,8 @@
|
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/node": "^22.20.4",
|
|
59
59
|
"typescript": "^7.0.2"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"tag": "beta"
|
|
60
63
|
}
|
|
61
64
|
}
|