strom-research 1.15.0-beta.6 → 1.15.0

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 CHANGED
@@ -7,7 +7,7 @@ Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou n
7
7
 
8
8
  ## 1.15.0
9
9
 
10
- *Patří k aplikaci Strom 3.12.0.*
10
+ *16. 10. 2026 · patří k aplikaci Strom 3.12.0.*
11
11
 
12
12
  - **Archiv Stromu podle volby, i s AI agentem.** Průvodce nastavením se ptá, zda nové rodokmeny budou výzkumem
13
13
  s agentem, nebo jen Archivem Stromu; v nastavení jde volbu změnit pro jeden rodokmen nebo pro nové.
@@ -23,9 +23,14 @@ Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou n
23
23
  přečtené snímky jsou řečeny přesně a jen ty se počítají jako prohledané.
24
24
  - **Rodokmeny z jiných programů.** Soubor GEDCOM uložený se značkou BOM nebo v UTF-16 (tak ho ukládají některé programy
25
25
  ve Windows) se čte jako rodokmen; rodokmen jiného výzkumu se převezme jako cizí.
26
- - **Živé sledování končí čistě** na každém systému, i ve Windows, a aplikace Strom se dozví, že skončilo.
27
- - **Jeden strom na instalaci.** Jiný strom na stejném počítači (z npm nebo sestavený ze zdrojů) nikdy nepřevezme odkazy
28
- aplikace Strom ani kanál aktualizací.
26
+ - **Čistě na jednom počítači.** Živé sledování končí čistě na každém systému, i ve Windows, a aplikace Strom se dozví,
27
+ že skončilo. Jiný strom na stejném počítači (z npm nebo sestavený ze zdrojů) nikdy nepřevezme odkazy aplikace Strom
28
+ ani kanál aktualizací.
29
+ - **Opravy ze skutečných výzkumů.** Denní příděl práce o samotě pustí nový týden hned, v posledních dvou hodinách
30
+ týdne využije, co zbývá, místo čekání přes obnovení, a říká, kdy se týden obnoví. Věk zapsaný zlomkem (½ roku, 1 1/2
31
+ léta, půl roku) se přečte a seznam snímků (`strom read --images 5-19,21-39`) se čte tak, jak je zadaný. Knihu
32
+ stahuje sezení, které ji čte, nikdy předem pro pozdější; `strom fetch --recordset` najde knihu sám; počty a příkazy
33
+ jsou řečeny tak, jak se píšou.
29
34
  - **Co je nového.** Po `strom update` a u `strom --version` odkaz na tento přehled v jazyce výzkumu. Aplikace Strom
30
35
  ukazuje body nové verze výzkumu ve svém okně „Co je nového“.
31
36
 
package/CHANGELOG.de.md CHANGED
@@ -7,7 +7,7 @@ Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Ins
7
7
 
8
8
  ## 1.15.0
9
9
 
10
- *Gehört zur Strom-App 3.12.0.*
10
+ *16.10.2026 · gehört zur Strom-App 3.12.0.*
11
11
 
12
12
  - **Ein Strom-Archiv nach Wahl, auch mit einem KI-Agenten.** Die Einrichtung fragt, ob neue Stammbäume Forschungen mit
13
13
  dem Agenten sind oder nur ein Strom-Archiv; in den Einstellungen lässt sich das für einen Stammbaum oder für die
@@ -25,9 +25,15 @@ Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Ins
25
25
  und nur sie gelten als durchsucht.
26
26
  - **Stammbäume aus anderen Programmen.** Eine GEDCOM-Datei mit Byte-Order-Mark oder in UTF-16 (so speichern sie manche
27
27
  Windows-Programme) wird als Stammbaum gelesen; der Stammbaum einer anderen Forschung wird als fremder übernommen.
28
- - **Die Live-Ansicht endet sauber** auf jedem System, auch unter Windows, und die Strom-App erfährt, dass sie endete.
29
- - **Ein strom pro Installation.** Ein anderer strom auf demselben Computer (aus npm oder aus den Quellen gebaut)
30
- übernimmt nie die Links der Strom-App oder den Update-Kanal.
28
+ - **Sauber auf einem Computer.** Die Live-Ansicht endet sauber auf jedem System, auch unter Windows, und die Strom-App
29
+ erfährt, dass sie endete. Ein anderer strom auf demselben Computer (aus npm oder aus den Quellen gebaut) übernimmt nie
30
+ die Links der Strom-App oder den Update-Kanal.
31
+ - **Korrekturen aus echten Forschungen.** Die tägliche Ration des Alleinarbeitens lässt eine neue Woche sofort
32
+ beginnen, nutzt in den letzten zwei Stunden der Woche, was übrig ist, statt über die Erneuerung hinaus zu warten,
33
+ und sagt, wann sich die Woche erneuert. Ein Alter als Bruch (½ Jahr, 1 1/2 Jahre, ein halbes Jahr) wird gelesen, und
34
+ eine Liste von Bildern (`strom read --images 5-19,21-39`) wird so gelesen, wie sie angegeben ist. Ein Buch lädt die
35
+ Sitzung, die es liest, nie im Voraus für eine spätere; `strom fetch --recordset` findet das Buch selbst; Zahlen und
36
+ Befehle werden so genannt, wie sie geschrieben werden.
31
37
  - **Was ist neu.** Nach `strom update` und bei `strom --version` ein Link zu dieser Übersicht in der Sprache der
32
38
  Forschung. Die Strom-App zeigt die Punkte einer neuen Forschungsversion in ihrem Fenster „Was ist neu“.
33
39
 
package/CHANGELOG.md CHANGED
@@ -7,7 +7,7 @@ The changes of each version in short. The full notes and the install lines are o
7
7
 
8
8
  ## 1.15.0
9
9
 
10
- *Goes with the Strom app 3.12.0.*
10
+ *2026-10-16 · goes with the Strom app 3.12.0.*
11
11
 
12
12
  - **A Strom archive by choice, with an AI agent too.** The setup asks whether new family trees are researches with the
13
13
  agent or only a Strom archive; the settings change it for one family tree or for the new ones.
@@ -23,9 +23,14 @@ The changes of each version in short. The full notes and the install lines are o
23
23
  (`strom read --until-found`): the images read are said exactly, and only they count as searched.
24
24
  - **Family trees from other programs.** A GEDCOM file saved with a byte order mark or in UTF-16 (as some Windows
25
25
  programs save it) is read as a family tree; a family tree of another research is taken in as a foreign one.
26
- - **The live view ends cleanly** on every system, Windows too, and the Strom app learns that it ended.
27
- - **One strom per installation.** Another strom on the same computer (from npm, or built from the sources) never takes
28
- over the links of the Strom app or the update channel.
26
+ - **Cleaner on one computer.** The live view ends cleanly on every system, Windows too, and the Strom app learns that
27
+ it ended. Another strom on the same computer (from npm, or built from the sources) never takes over the links of the
28
+ Strom app or the update channel.
29
+ - **Fixes from real researches.** The daily ration of working alone lets a new week begin at once, uses what is left
30
+ in the week's last two hours instead of waiting past the reset, and says when the week resets. Ages written as
31
+ fractions (½ roku, 1 1/2 years, ein halbes Jahr) are read, and a list of images (`strom read --images 5-19,21-39`)
32
+ is read as given. A book is fetched by the session that reads it, never ahead for a later one;
33
+ `strom fetch --recordset` finds the book itself; counts and commands are said as typed.
29
34
  - **What's new.** After `strom update` and with `strom --version`, a link to this list in the research language. The
30
35
  Strom app shows the points of a new research version in its own “What's new” window.
31
36
 
@@ -104,7 +104,8 @@ conclusion so the next researcher can follow it.
104
104
  usual (about ±5 around the target, an index in batches) — fewer turns.
105
105
  `strom read` may follow a fetch at work: sooner, never more images.
106
106
  Images you will read later in the session: `--background` fetches them
107
- while you work on, `strom read` reads them as they come.
107
+ while you work on, `strom read` reads them as they come. Nothing for a
108
+ later session: the session that reads a book fetches it.
108
109
  <!-- end -->
109
110
  - **What strom or the tree denies stays denied.** Never offer the user to
110
111
  change your permissions or settings, strom's or the tree's, to get round it:
@@ -51,7 +51,8 @@ never open again a view cleared from your context — your notes hold it.
51
51
  - **Browsing a book or a range is a reader's job**: `strom read B0001
52
52
  --images 40-69 --question "…"` (small script: `--half both`) — readers in
53
53
  batches of ten, each with the full question, their reports in
54
- notes/readings/. It ends with what they found, compact: the found and
54
+ notes/readings/. For every agent, also one with subagents of its own: a
55
+ third of their cost, every reading kept. It ends with what they found, compact: the found and
55
56
  unclear entries whole, what was illegible where nothing was found (a
56
57
  possible match often hides there — look at it), the gaps in the book.
57
58
  Later: `strom readings` (`--match <surname>`, `--list`; one image's block:
@@ -110,9 +110,19 @@ usage is at least the number you give it behind the week gone:
110
110
  strom config set run.gate "claude-usage 10" gone − used ≥ 10
111
111
 
112
112
  Without a number: 0 — no faster than the week goes; 14: about a day in hand.
113
- Not enough in hand: it waits until the week has caught up (past the reset: the
114
- new week's share). A used-up session: until it resets. Another agent than
115
- Claude Code: it lets the work go on.
113
+
114
+ Early in the week the number grows with it: at least half the week gone, the
115
+ full number from twice it on (at 15: from 30 % gone). Right after the reset a
116
+ session starts at once, and the first day uses at most half of its share.
117
+ In the week's last 2 hours what is left would go with the reset: a session
118
+ starts when the room left (to 100 %, or to the cap) holds one — counted high,
119
+ 10 points.
120
+
121
+ Not enough in hand: it waits until the first of — the week has caught up, the
122
+ week's last 2 hours (with room for a session), the reset itself (a new week
123
+ starts at once); never past the reset. Every answer says when the week resets
124
+ ("weekly reset in 1.7 h"). A used-up session: until it resets. Another agent
125
+ than Claude Code: it lets the work go on.
116
126
 
117
127
  **A cap** — your own hard stop for the week:
118
128
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "interface": 1,
3
3
  "title": "Claude usage",
4
- "description": "Keeps working alone to the daily ration of your Claude subscription: a session starts only while the week's usage is at least n points behind the share of the week gone (a day ≈ 14.3): strom config set run.gate \"claude-usage 10\" (without a number: 0). A cap: \"claude-usage 10 --cap 95\" — at 95 % of the week no session starts, in any run, and no \"start anyway\" goes past it.",
4
+ "description": "Keeps working alone to the daily ration of your Claude subscription: a session starts only while the week's usage is at least n points behind the share of the week gone (a day ≈ 14.3; early in the week half the share gone, so a new week starts at once; in its last 2 hours what is left goes when it holds a session): strom config set run.gate \"claude-usage 10\" (without a number: 0). A cap: \"claude-usage 10 --cap 95\" — at 95 % of the week no session starts, in any run, and no \"start anyway\" goes past it.",
5
5
  "command": [
6
6
  "node",
7
7
  "gate.ts"
@@ -8,8 +8,19 @@
8
8
  // (a day is about 14.3 %) against the week's usage. With the number you give
9
9
  // it (strom config set run.gate "claude-usage 10") a session starts only while
10
10
  // usage is at least that many points behind the week gone: gone − used ≥ 10.
11
- // Without a number: 0 — no faster than the week goes. Else it waits until the
12
- // week has caught up (or past the reset); a full session: until it resets.
11
+ // Without a number: 0 — no faster than the week goes.
12
+ //
13
+ // Early in the week the number grows with it: at least min(n, gone / 2) — right
14
+ // after the reset (0 % gone, 0 % used) a session may start, and until 2·n % of
15
+ // the week is gone usage may reach half of it; from there on the full number.
16
+ // So the first day never spends the week (at most half its share), and a new
17
+ // week is not idle for n % of it (a day and more at 15) for nothing.
18
+ // Late in the week what is left goes with the reset: in its last END_WINDOW a
19
+ // session starts when what is left (to 100 %, or to the cap) holds one session
20
+ // (SESSION_POINTS).
21
+ // Else it waits until the first of: the week has caught up, its last
22
+ // END_WINDOW (with room for a session), its reset (a new week starts at once) —
23
+ // never past the reset. A full session: until it resets.
13
24
  //
14
25
  // A cap (strom config set run.gate "claude-usage 15 --cap 95"): at 95 % of the
15
26
  // week used no session starts — the user's own hard stop, asked before every
@@ -28,15 +39,32 @@ const FRESH_MS = 15 * 60_000;
28
39
  /** Under a cap, a session at work is asked to finish at this much of the week. */
29
40
  const FINISH_AT = 0.99;
30
41
  const WEEK = 7 * DAY;
42
+ /**
43
+ * The last stretch of the week in which what is left is spent rather than kept: about one session of `strom run`
44
+ * (60 minutes by default) with its wrap-up and the gate's next asking. What is not used by the reset is gone with
45
+ * it; what a person keeps in hand for their own work is missed for this long at most.
46
+ */
47
+ const END_WINDOW = 2 * 3_600_000;
48
+ /** What one session may take of the week, counted high (points of the week's %): the room it needs before the reset. */
49
+ const SESSION_POINTS = 10;
50
+ /** After the reset the gate asks again this much later, so the new week is what Claude Code says. */
51
+ const AFTER_RESET = 60_000;
31
52
  const lang = (process.env.STROM_LANG ?? "en").slice(0, 2);
32
53
 
33
54
  const T: Record<string, Record<string, string>> = {
34
55
  en: {
35
56
  other: "the agent is not Claude Code — nothing to check",
36
57
  api: "Claude Code runs on an API key, not a subscription — no plan limits",
37
- go: "week {used} % used, {gone} % gone, {behind} in hand (at least {min} needed)",
38
- session: "the session is used up — until it resets",
39
- week: "week {used} % used, {gone} % gone, {behind} in hand (at least {min} needed) — until the week catches up",
58
+ head: "week {used} % used, {gone} % gone, {behind} in hand (at least {min} needed{early})",
59
+ early: " — half the week gone, early in the week",
60
+ resetIn: "; weekly reset in {reset}",
61
+ catch: " — until the week catches up; weekly reset in {reset}",
62
+ last: " — until the last {window} before the weekly reset in {reset}",
63
+ reset: " — until the weekly reset in {reset}",
64
+ end: "week {used} % used, weekly reset in {reset} — the {room} % left is enough for a session",
65
+ session: "the session is used up — until it resets; weekly reset in {reset}",
66
+ hours: "{n} h",
67
+ days: "{n} days",
40
68
  bad: "the number must be 0 to 99, not \"{arg}\"",
41
69
  badcap: "the cap must be 1 to 100 (\"--cap 95\"), not \"{arg}\"",
42
70
  cap: "week {used} % used — the cap of {cap} % reached; it resets {resets}",
@@ -47,9 +75,16 @@ const T: Record<string, Record<string, string>> = {
47
75
  cs: {
48
76
  other: "agent není Claude Code — není co hlídat",
49
77
  api: "Claude Code běží na API klíči, ne na předplatném — žádné limity plánu",
50
- go: "týden: využito {used} %, uplynulo {gone} %, náskok {behind} (potřeba aspoň {min})",
51
- session: "sezení je vyčerpané — do jeho obnovení",
52
- week: "týden: využito {used} %, uplynulo {gone} %, náskok {behind} (potřeba aspoň {min}) — dokud čas nedožene",
78
+ head: "týden: využito {used} %, uplynulo {gone} %, náskok {behind} (potřeba aspoň {min}{early})",
79
+ early: " – na začátku týdne polovina uplynulého",
80
+ resetIn: "; týden se obnoví za {reset}",
81
+ catch: " — dokud čas nedožene; týden se obnoví za {reset}",
82
+ last: " — do posledních {window} před obnovením týdne za {reset}",
83
+ reset: " — do obnovení týdne za {reset}",
84
+ end: "týden: využito {used} %, obnoví se za {reset} — zbylých {room} % stačí na sezení",
85
+ session: "sezení je vyčerpané — do jeho obnovení; týden se obnoví za {reset}",
86
+ hours: "{n} h",
87
+ days: "{n} dne",
53
88
  bad: "číslo musí být 0 až 99, ne \"{arg}\"",
54
89
  badcap: "strop musí být 1 až 100 (\"--cap 95\"), ne \"{arg}\"",
55
90
  cap: "týden: využito {used} % – strop {cap} % dosažen; obnoví se {resets}",
@@ -60,9 +95,16 @@ const T: Record<string, Record<string, string>> = {
60
95
  de: {
61
96
  other: "der Agent ist nicht Claude Code — nichts zu prüfen",
62
97
  api: "Claude Code läuft mit einem API-Schlüssel, nicht im Abo — keine Planlimits",
63
- go: "Woche: {used} % genutzt, {gone} % vergangen, Vorsprung {behind} (mindestens {min} nötig)",
64
- session: "die Sitzung ist aufgebraucht — bis sie zurückgesetzt wird",
65
- week: "Woche: {used} % genutzt, {gone} % vergangen, Vorsprung {behind} (mindestens {min} nötig) — bis die Zeit aufholt",
98
+ head: "Woche: {used} % genutzt, {gone} % vergangen, Vorsprung {behind} (mindestens {min} nötig{early})",
99
+ early: " – am Wochenanfang die Hälfte des Vergangenen",
100
+ resetIn: "; Wochen-Reset in {reset}",
101
+ catch: " — bis die Zeit aufholt; Wochen-Reset in {reset}",
102
+ last: " — bis zu den letzten {window} vor dem Wochen-Reset in {reset}",
103
+ reset: " — bis zum Wochen-Reset in {reset}",
104
+ end: "Woche: {used} % genutzt, Wochen-Reset in {reset} — die übrigen {room} % reichen für eine Sitzung",
105
+ session: "die Sitzung ist aufgebraucht — bis sie zurückgesetzt wird; Wochen-Reset in {reset}",
106
+ hours: "{n} h",
107
+ days: "{n} Tagen",
66
108
  bad: "die Zahl muss 0 bis 99 sein, nicht \"{arg}\"",
67
109
  badcap: "die Obergrenze muss 1 bis 100 sein (\"--cap 95\"), nicht \"{arg}\"",
68
110
  cap: "Woche: {used} % genutzt – Obergrenze {cap} % erreicht; zurückgesetzt {resets}",
@@ -135,19 +177,39 @@ if (cap !== undefined && week.used >= cap)
135
177
  answer(2, say("cap", { used: Math.floor(week.used), cap, resets: week.resets.toLocaleString(lang, { weekday: "short", day: "numeric", month: "numeric", hour: "2-digit", minute: "2-digit" }) }), undefined, true);
136
178
 
137
179
  const now = Date.now();
138
- const start = week.resets.getTime() - WEEK;
180
+ const resets = week.resets.getTime();
181
+ const start = resets - WEEK;
139
182
  const gone = Math.max(0, Math.min(100, ((now - start) / WEEK) * 100));
140
183
  const behind = gone - week.used;
141
- // shown rounded down: never "15 in hand (at least 15 needed)" for 14.6 — and gone − used stays what it says
142
- const v = { used: Math.floor(week.used), gone: Math.floor(gone), behind: Math.floor(behind), min };
143
- if (session && session.used >= 100) answer(1, say("session"), session.resets ? new Date(session.resets.getTime() + 60_000) : undefined);
144
- if (behind < min) {
145
- // when the week gone reaches usage + the number; past the reset, the new week's number
146
- let at = start + ((week.used + min) / 100) * WEEK;
147
- if (at >= week.resets.getTime()) at = week.resets.getTime() + (min / 100) * WEEK;
148
- answer(1, say("week", v), new Date(Math.max(at, now + 5 * 60_000)));
184
+ // the number needed in hand: half the week gone early in the week, the full number from 2·n % gone on
185
+ const need = Math.min(min, gone / 2);
186
+ const early = need < min ? say("early") : "";
187
+ const reset = inTime(resets - now);
188
+ // shown rounded down: never "15 in hand (at least 15 needed)" for 14.6 — and gone − used stays what it says; the
189
+ // need early in the week is a fraction: rounded down when it goes, up when it waits, so the two never seem to disagree
190
+ const v = { used: Math.floor(week.used), gone: Math.floor(gone), behind: Math.floor(behind), early, reset };
191
+ if (session && session.used >= 100) answer(1, say("session", { reset }), session.resets ? new Date(session.resets.getTime() + AFTER_RESET) : undefined);
192
+ if (behind >= need) answer(0, say("head", { ...v, min: Math.floor(need) }) + say("resetIn", { reset }));
193
+ // what is left before the reset: (to the cap, else to 100 %) enough for a session — in the week's last stretch it goes
194
+ const room = (cap ?? 100) - week.used;
195
+ const fits = room >= SESSION_POINTS;
196
+ if (fits && resets - now <= END_WINDOW) answer(0, say("end", { used: v.used, reset, room: Math.floor(room) }));
197
+ // else it waits for the first of: the week caught up (gone − used ≥ the need: used < n → gone = 2·used, else
198
+ // used + n), the week's last stretch (with room for a session), the reset — a new week at 0 % goes at once, so
199
+ // never past it
200
+ const caught = start + ((week.used < min ? 2 * week.used : week.used + min) / 100) * WEEK;
201
+ const last = fits ? resets - END_WINDOW : Infinity;
202
+ const after = resets + AFTER_RESET;
203
+ const at = Math.min(caught, last, after);
204
+ const tail = at === caught ? say("catch", { reset }) : at === last ? say("last", { reset, window: say("hours", { n: END_WINDOW / 3_600_000 }) }) : say("reset", { reset });
205
+ answer(1, say("head", { ...v, min: Math.ceil(need) }) + tail, new Date(Math.max(at, now + 5 * 60_000)));
206
+
207
+ /** A stretch of time as a person reads it: hours to two days, then days — one decimal, in the research language. */
208
+ function inTime(ms: number): string {
209
+ const h = Math.max(0, ms) / 3_600_000;
210
+ const n = (x: number) => x.toLocaleString(lang, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
211
+ return h < 48 ? say("hours", { n: n(h) }) : say("days", { n: n(h / 24) });
149
212
  }
150
- answer(0, say("go", v));
151
213
 
152
214
  /**
153
215
  * The limits Claude Code said in this run (strom passes them: [{kind, used 0–1, resetsAt, at}]), when the week's is
@@ -30,6 +30,9 @@ function delegationRules(n) {
30
30
  return `- Browsing a book, an index or a range of images ("is our surname on this page?")
31
31
  goes to \`strom read\` (by halves: \`--half both\`); a delegate reads only crops
32
32
  of the entries found. Anything self-contained that returns little is delegated.
33
+ \`strom read\` and not subagents of your own for a range: about a third of
34
+ their cost on the same images, every reading kept (\`strom readings\`), the
35
+ places its readers found unclear held against a negative.
33
36
  - A delegate other than strom-scan-reader (text, print, a search) gets no brief:
34
37
  begin its prompt with this, word for word — "${DELEGATE_SHEET}"
35
38
  - About ${first} scans (images B…:n) per delegate, four views each (halves and
@@ -56,6 +59,13 @@ function delegationRules(n) {
56
59
  export function selfReading(n = DEFAULT_READING_NUMBERS) {
57
60
  return `## Reading scans (Codex, Antigravity, OpenCode, Grok)
58
61
 
62
+ Browsing a book, an index or a range of images goes to \`strom read\`
63
+ (\`strom read B0001 --images 40-69 --question "…"\`), also for an agent with
64
+ sub-agents of its own: cheaper than reading the range in your context or by
65
+ a sub-agent, every reading kept (\`strom readings\`), the places its readers
66
+ found unclear held against a negative. Where strom says no reader can start
67
+ here, and for the entries found:
68
+
59
69
  Read the images yourself, with your strongest model — never hand old
60
70
  handwriting to a faster or cheaper model, subagent or pass. Read them
61
71
  in batches of about ${inWords(n.batch)}: open a batch (one call gives its views —
@@ -103,7 +113,8 @@ comes back to you. Choose the model by the kind of work:
103
113
 
104
114
  | work | model |
105
115
  |---|---|
106
- | handwriting: registers, indexes, land books, any scan read closely | \`strom-scan-reader\`, ${m.vision ? `on \`${m.vision}\`` : "on your own model"} — never cheaper |
116
+ | browsing handwriting: a book, an index, a range of images | \`strom read\` (its readers on the model for handwriting) — not a subagent of yours |
117
+ | handwriting read closely: the entries found, a page of a land book | \`strom-scan-reader\`, ${m.vision ? `on \`${m.vision}\`` : "on your own model"} — never cheaper |
107
118
  | print and type: documents of the 20th century, catalogues, web pages, big text files (the lines \`strom grep\` found) | \`${m.text ?? "sonnet"}\` |
108
119
  | mechanical: downloads, renaming, counting | \`${m.cheap ?? "haiku"}\` |
109
120
  | a text in inputs/ or notes/ | nobody — \`strom grep\`, one call |
@@ -21,7 +21,7 @@ import { byHandOnly, listConnectors, missingConsents, readyConnectors, routeOf }
21
21
  import { clock, hostAllowed, hostState, REFUSED_FOREVER } from "../core/net.js";
22
22
  import { archiveLevel } from "../core/terms.js";
23
23
  import { onHost, permissionQuestions, setHosts } from "../core/archivewait.js";
24
- import { runs, shellArg } from "../cli/format.js";
24
+ import { runs, runsArg, shellArg } from "../cli/format.js";
25
25
  import { commandSheet } from "./sheet.js";
26
26
  import { foldText } from "../core/text.js";
27
27
  import { subjectPeople } from "../core/records.js";
@@ -631,10 +631,10 @@ function imagesLines(tree, b, route, shape, finder, noSharper = false, waiting)
631
631
  /** The fetches at work for the research (each book: how far), and those in the background that ended lately with images left. */
632
632
  function fetchingLines(root) {
633
633
  const active = activeFetches(root);
634
- const out = active.map((m) => `- ${m.recordset}: ${m.registered}/${m.images.length} images registered (${m.background ? `in the background${m.task ? `, for ${m.task}` : ""}` : "in the foreground"}) — strom read ${m.recordset} --images ${runs(m.images)} reads them as they come; not fetched again`);
634
+ const out = active.map((m) => `- ${m.recordset}: ${m.registered}/${m.images.length} images registered (${m.background ? `in the background${m.task ? `, for ${m.task}` : ""}` : "in the foreground"}) — strom read ${m.recordset} --images ${runsArg(m.images)} reads them as they come; not fetched again`);
635
635
  for (const e of endedFetches(root))
636
636
  if (e.missed.length && !active.some((m) => m.recordset === e.recordset))
637
- out.push(`- ${e.recordset}: a fetch in the background ended with images ${runs(e.missed)} not fetched${e.stopped ? ` (stopped: ${e.stopped})` : ""} — strom fetch ${e.connector} ${shellArg(e.book)} --images ${runs(e.missed)} --recordset ${e.recordset} if they are needed`);
637
+ out.push(`- ${e.recordset}: a fetch in the background ended with images ${runs(e.missed)} not fetched${e.stopped ? ` (stopped: ${e.stopped})` : ""} — strom fetch ${e.connector} ${shellArg(e.book)} --images ${runsArg(e.missed)} --recordset ${e.recordset} if they are needed`);
638
638
  return out;
639
639
  }
640
640
  /** What the user put in the shared inbox, waiting to be registered — by folder (one download each). */
@@ -27,10 +27,11 @@ const LEVELS = {
27
27
  const RARE = {
28
28
  "search add": ["by"],
29
29
  "task add": ["research", "anyway"],
30
- "media view": ["scale", "max", "grey", "png", "image"],
30
+ "media view": ["scale", "max", "grey", "png", "image", "whole"],
31
31
  read: ["context", "batch", "model", "minutes", "max"],
32
- // a book the research has already: the refusal says --again where it applies; --status and --stop: the method and the brief name them
33
- fetch: ["take", "result", "again", "status", "stop"],
32
+ // a book the research has already: the refusal says --again where it applies; --status and --stop: the method and the brief name them;
33
+ // --task: strom takes the task the agent works on where it is plain, and says --task where it took one
34
+ fetch: ["take", "result", "again", "status", "stop", "task"],
34
35
  "source add": ["translation", "media"],
35
36
  "source edit": ["translation", "media"],
36
37
  "recordset add": ["images", "again"],
@@ -5,7 +5,7 @@ import { parseArgs } from "node:util";
5
5
  import { UsageError } from "../core/errors.js";
6
6
  import { ALL_PREFIXES } from "../core/model.js";
7
7
  import { shellArg } from "./format.js";
8
- import { GLOBAL_OPTIONS, aliasExtends, commands, match, optionsOf, subcommandsOf, usageLine, WRITE_OPTIONS } from "./registry.js";
8
+ import { GLOBAL_OPTIONS, aliasExtends, aliasGroup, commands, match, optionsOf, subcommandsOf, usageLine, WRITE_OPTIONS } from "./registry.js";
9
9
  const VALUE_GLOBALS = new Set(GLOBAL_OPTIONS.filter((o) => o.type === "string").map((o) => `--${o.name}`));
10
10
  /** The first word of a command line as typed — a command strom knows or not; the values of global options (--lang cs) are none. */
11
11
  export function firstWord(argv) {
@@ -55,12 +55,28 @@ function distance(a, b) {
55
55
  }
56
56
  return d[a.length][b.length];
57
57
  }
58
+ /**
59
+ * How many typos a word of this many letters may hold and still be meant: one in a short word, two in a longer one,
60
+ * a few more in a command of several words — never so many that another word is offered (--whole is no --home).
61
+ */
62
+ function typos(letters) {
63
+ return letters <= 6 ? 1 : letters <= 14 ? 2 : Math.floor(letters / 5);
64
+ }
58
65
  /** The closest candidates to `input` (a prefix, or a few typos), best first — only the best ones. */
59
66
  export function suggest(input, candidates) {
60
- const q = input.toLowerCase();
67
+ // an option's dashes are no letters of it: "--bron" is "bron"
68
+ const bare = (s) => s.toLowerCase().replace(/^-+/u, "");
69
+ const q = bare(input);
70
+ if (!q)
71
+ return [];
61
72
  const scored = candidates
62
- .map((c) => ({ c, d: c.startsWith(q) || q.startsWith(c) ? 0.5 : distance(q, c.toLowerCase()) }))
63
- .filter((x) => x.d <= Math.max(2, Math.floor(q.length / 3)))
73
+ .map((c) => {
74
+ const b = bare(c);
75
+ // a prefix of at least three letters ("--surnamez" for --surname, "person ad" for person add)
76
+ const prefix = Math.min(b.length, q.length) >= 3 && (b.startsWith(q) || q.startsWith(b));
77
+ return { c, d: prefix ? 0.5 : distance(q, b) };
78
+ })
79
+ .filter((x) => x.d <= typos([...q].length))
64
80
  .sort((a, b) => a.d - b.d || a.c.length - b.c.length);
65
81
  const best = scored[0]?.d;
66
82
  return [...new Set(scored.filter((x) => x.d <= (best ?? 0) + 0.5).map((x) => x.c))].slice(0, 3);
@@ -78,11 +94,14 @@ export function resolveCommand(argv) {
78
94
  return { def: found.def, rest };
79
95
  }
80
96
  // A group ("person") with a wrong or missing subcommand.
81
- const group = found ? [...found.def.path, ...leftover] : words;
97
+ const typedGroup = found ? [...found.def.path, ...leftover] : words;
98
+ // another name of a group (strom book → strom recordset): that group
99
+ const group = aliasGroup(typedGroup) ?? typedGroup;
82
100
  if (group.length && subcommandsOf(group).length) {
83
101
  if (!extra.length)
84
102
  throw new GroupOnly(group);
85
- throw unknownCommand([...group, extra[0]]);
103
+ // said as typed (strom book B1, never "strom recordset B1"), looked up in the group it stands for
104
+ throw unknownCommand([...group, extra[0]], [...typedGroup, extra[0]]);
86
105
  }
87
106
  throw unknownCommand([...words, ...extra].slice(0, Math.max(words.length + 1, 2)));
88
107
  }
@@ -94,15 +113,22 @@ export class GroupOnly extends Error {
94
113
  this.group = group;
95
114
  }
96
115
  }
97
- function unknownCommand(words) {
98
- const typed = words.join(" ");
116
+ /**
117
+ * An unknown command, said as typed (`said`: another name of a group — strom book … — when `words` name the group it
118
+ * stands for) with the commands near it; a group's ID after it (strom book B1) offers the group's show.
119
+ */
120
+ function unknownCommand(words, said = words) {
121
+ const typed = said.join(" ");
99
122
  const all = commands().filter((c) => c.path.length > 0).map((c) => c.path.join(" "));
100
123
  // A known group narrows the choice ("person ad" → "person add", not "lesson add").
101
124
  const inGroup = all.filter((c) => c.startsWith(`${words[0]} `));
102
125
  const same = (inGroup.length ? inGroup : all).filter((c) => c.split(" ").length === words.length);
103
- const hits = suggest(typed, same.length ? same : all);
104
- const more = hits.length ? hits : suggest(words[0] ?? "", [...new Set(all.map((c) => c.split(" ")[0]))]);
105
- const near = more.map((c) => `strom ${c}`).join(" · ");
126
+ const hits = suggest(words.join(" "), same.length ? same : all);
127
+ const show = !hits.length && words.length === 2 && /^\p{Lu}\p{N}+$/u.test(words[1]) && inGroup.includes(`${words[0]} show`) ? `${said[0]} show ${said[1]}` : undefined;
128
+ const more = show ? [show] : hits.length ? hits : suggest(words[0] ?? "", [...new Set(all.map((c) => c.split(" ")[0]))]);
129
+ // in the words typed: the group's other name kept (strom book show, not strom recordset show)
130
+ const asTyped = (c) => (said[0] !== words[0] && c.split(" ")[0] === words[0] ? [said[0], ...c.split(" ").slice(1)].join(" ") : c);
131
+ const near = more.map((c) => `strom ${asTyped(c)}`).join(" · ");
106
132
  return new UsageError(`unknown command "strom ${typed}"`, {
107
133
  hint: more.length ? `similar command: ${near}` : "strom help (all commands)",
108
134
  ...(more.length ? { code: "command.near", params: { cmd: typed, near } } : { code: "command.unknown", params: { cmd: typed } }),
@@ -275,9 +301,9 @@ export function parseOptions(def, rest, program = "strom") {
275
301
  const own = [...(def.options ?? []), ...(def.writes ? optionsOf(def).filter((o) => o.name === "dry-run" || o.name === "reason") : [])].filter((o) => !o.hidden).map((o) => `--${o.name}`);
276
302
  if (e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
277
303
  // "strom brief --task T0003": the task is an argument there, not an option
278
- const arg = def.args?.find((a) => `--${a.name}` === opt);
304
+ const arg = def.args?.find((a) => `--${a.name}` === opt || a.mistaken?.includes(opt.replace(/^-+/u, "")));
279
305
  if (arg)
280
- throw new UsageError(`${opt.slice(2)} is an argument of ${cmd}, not an option`, { hint: `strom ${def.path.join(" ")} <${arg.name}>`, code: "option.is-arg", params: { opt, cmd, arg: arg.name }, usage });
306
+ throw new UsageError(`--${arg.name}` === opt ? `${opt.slice(2)} is an argument of ${cmd}, not an option` : `${opt} is no option of ${cmd}: <${arg.name}> is its argument`, { hint: `strom ${def.path.join(" ")} <${arg.name}>`, code: "option.is-arg", params: { opt, cmd, arg: arg.name }, usage });
281
307
  // a status asked of a name or a family (N0191): it is a fact's only — said as cite says it (the person's
282
308
  // language: the plain unknown option, its code kept)
283
309
  if (opt === "--status" && (def.path[0] === "name" || def.path[0] === "family"))
@@ -38,6 +38,10 @@ export function truncate(text, max) {
38
38
  const one = text.replace(/\s+/g, " ").trim();
39
39
  return [...one].length <= max ? one : [...one].slice(0, max - 1).join("") + "…";
40
40
  }
41
+ /** A count with its noun as English says it: "1 image", "2 images" (`many` where the plural is not one more s). */
42
+ export function counted(n, one, many = `${one}s`) {
43
+ return `${n} ${n === 1 ? one : many}`;
44
+ }
41
45
  export function lines(...parts) {
42
46
  return parts.filter((p) => typeof p === "string").join("\n");
43
47
  }
@@ -54,6 +58,13 @@ export function runs(nums) {
54
58
  }
55
59
  return out.join(", ");
56
60
  }
61
+ /**
62
+ * Runs of numbers as a command takes them, one word of the shell: [1, 2, 3, 7] → "1-3,7" (no spaces, no en dash — a
63
+ * hint copied as runs() says it for a person, "1–3, 7", would be three arguments).
64
+ */
65
+ export function runsArg(nums) {
66
+ return runs(nums).replace(/–/g, "-").replace(/,\s*/g, ",");
67
+ }
57
68
  /** A number as a language writes it (1,4 in Czech, 1.4 in English); a language Intl does not know: English. */
58
69
  export function numberIn(lang, n, o = { maximumFractionDigits: 1 }) {
59
70
  try {
package/dist/cli/help.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Help text generated from the registry. Short by design: examples first,
2
2
  // then arguments and options.
3
- import { GROUPS, GLOBAL_OPTIONS, WRITE_OPTIONS, commands, descriptionOf, match, subcommandsOf, usageLine } from "./registry.js";
3
+ import { GROUPS, GROUP_ALIASES, GLOBAL_OPTIONS, WRITE_OPTIONS, aliasGroup, commands, descriptionOf, match, subcommandsOf, usageLine } from "./registry.js";
4
4
  import { table } from "./format.js";
5
5
  import { suggest } from "./execute.js";
6
6
  import { UI, ui } from "./ui.js";
@@ -77,12 +77,14 @@ export function groupHelp(prefix, archive = false) {
77
77
  });
78
78
  if (!subs.length)
79
79
  return `strom ${prefix.join(" ")}: not in an archive (research is switched on by the user: strom mode research)`;
80
+ const also = prefix.length === 1 ? Object.entries(GROUP_ALIASES).filter(([, g]) => g === prefix[0]).map(([a]) => `strom ${a} <command>`) : [];
80
81
  return [
81
82
  `strom ${prefix.join(" ")} <command>`,
82
83
  "",
83
84
  table(subs),
84
85
  "",
85
86
  `Details: strom help ${prefix.join(" ")} <command>`,
87
+ ...(also.length ? [`Also: ${also.join(" · ")}`] : []),
86
88
  ].join("\n");
87
89
  }
88
90
  /** Words joined with " · ", wrapped to lines of about `width`, the next ones indented. */
@@ -130,6 +132,9 @@ export function helpFor(input, archive, appUrl) {
130
132
  return commandHelp(found.def, archive, appUrl);
131
133
  if (subcommandsOf(words).length > 0)
132
134
  return groupHelp(words, archive);
135
+ const real = aliasGroup(words);
136
+ if (real)
137
+ return groupHelp(real, archive);
133
138
  const all = commands().filter((c) => c.path.length > 0).map((c) => c.path.join(" "));
134
139
  const near = suggest(words.join(" "), all.filter((c) => c.split(" ").length === words.length));
135
140
  return `unknown command "${words.join(" ")}"${near.length ? ` — similar command: ${near.map((c) => `strom help ${c}`).join(" · ")}` : ""}\nall commands: strom help`;
@@ -205,6 +210,9 @@ export function helpAs(input, as) {
205
210
  return humanCommand(found.def, as);
206
211
  if (subcommandsOf(words).length > 0)
207
212
  return humanGroup(words, as);
213
+ const real = aliasGroup(words);
214
+ if (real)
215
+ return humanGroup(real, as);
208
216
  const all = commands().filter((c) => c.path.length > 0).map((c) => c.path.join(" "));
209
217
  const near = suggest(words.join(" "), all.filter((c) => c.split(" ").length === words.length));
210
218
  return [ui(as.lang, "ui.help.unknown", { cmd: words.join(" ") }), ...near.map((c) => ` strom help ${c} --human`)].join("\n");
package/dist/cli/main.js CHANGED
@@ -30,6 +30,7 @@ import { refreshGlobal } from "../agents/global.js";
30
30
  import { expandFromLine, refreshLinks } from "../core/links.js";
31
31
  import { clockLine, FINISH_LINE, finishLimitLine, finishOf, timeLine } from "../core/clock.js";
32
32
  import { currentSession } from "../core/session.js";
33
+ import { backgroundOf } from "../core/fetching.js";
33
34
  import { callMistake, checkArgs, GroupOnly, parseOptions, resolveCommand, firstWord, severalRecords, splitPassthrough, usageOf } from "./execute.js";
34
35
  import { placeholders, UI, ui } from "./ui.js";
35
36
  import { catalog, localized, sayCommandAs } from "../core/phrases.js";
@@ -409,7 +410,9 @@ export async function main(argv, io, env, cwd) {
409
410
  let lang;
410
411
  try {
411
412
  // an unknown command or option has no context yet: the person's language all the same
412
- lang = (ran ?? Context.fromOptions({ env, cwd, io, json, values: {} })).uiLang();
413
+ // …but a fetch in the background writes into its log, which the agent reads: English, as the rest of it
414
+ if (!backgroundOf(env))
415
+ lang = (ran ?? Context.fromOptions({ env, cwd, io, json, values: {} })).uiLang();
413
416
  }
414
417
  catch {
415
418
  // no language to say it in: English
@@ -33,8 +33,16 @@ export const WRITE_OPTIONS = [
33
33
  { name: "reason", type: "string", value: "<text>", description: "why (required when changing or retracting facts)" },
34
34
  ];
35
35
  const registry = [];
36
+ /**
37
+ * Other names of a whole group (what agents type for it: "book show" for strom recordset show): each command of the
38
+ * group is taken by it too — an alias of its own, said in its help ("Also: strom book show") and in strom commands
39
+ * --json, never a command or group of its own in the overview.
40
+ */
41
+ export const GROUP_ALIASES = { book: "recordset" };
36
42
  export function register(...defs) {
37
- for (const def of defs) {
43
+ for (const given of defs) {
44
+ const others = Object.entries(GROUP_ALIASES).filter(([, g]) => given.path.length > 1 && given.path[0] === g).map(([a]) => [a, ...given.path.slice(1)]);
45
+ const def = others.length ? { ...given, aliases: [...(given.aliases ?? []), ...others] } : given;
38
46
  const names = [def.path, ...(def.aliases ?? [])].map((p) => p.join(" "));
39
47
  if (registry.some((c) => [c.path, ...(c.aliases ?? [])].some((p) => names.includes(p.join(" ")))))
40
48
  throw new Error(`duplicate command ${def.path.join(" ")}`);
@@ -72,6 +80,11 @@ export function subcommandsOf(prefix) {
72
80
  export function aliasExtends(prefix) {
73
81
  return registry.some((c) => (c.aliases ?? []).some((a) => a.length > prefix.length && prefix.every((w, i) => a[i] === w)));
74
82
  }
83
+ /** The group another name stands for, when the words are one ("book" → "recordset"), else nothing. */
84
+ export function aliasGroup(prefix) {
85
+ const real = prefix.length === 1 ? GROUP_ALIASES[prefix[0]] : undefined;
86
+ return real && subcommandsOf([real]).length ? [real] : undefined;
87
+ }
75
88
  export function usageLine(def) {
76
89
  const args = (def.args ?? []).map((a) => {
77
90
  const name = a.variadic ? `${a.name}...` : a.name;