strom-research 1.13.2-beta.8 → 1.13.2-beta.9

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.
@@ -382,7 +382,9 @@
382
382
  "ui.consent.connector.direct": "Pozor: jeho kód se připojuje k síti sám, mimo omezovač stromu.",
383
383
  "ui.consent.connector.unfenced": "Pozor: spouští program, který strom neohraničí ({run}) – může číst a měnit všechny soubory tohoto uživatele, spouštět jiné programy a připojit se kamkoli. Po každé změně jeho kódu se dotaz zopakuje.",
384
384
  "ui.consent.connector.terms": "Podmínky užití archivu: {terms}",
385
- "ui.consent.host.unblock": "{host} nás odmítl. Povolit znovu – jen pokud archiv řekl, že je to v pořádku?",
385
+ "ui.consent.host.unblock": "Na {host} se nesahá (odmítl požadavky, nebo řekl, že je jeho limit vyčerpán). Zrušit to hned – jen pokud archiv řekl, že je to v pořádku?",
386
+ "ui.host.unblocked": "{host}: už se na něj zase smí – strom se ho bude znovu ptát, pomalu",
387
+ "ui.host.unblock.kept": "Nic se nezměnilo: na {host} se dál nesahá.",
386
388
  "ui.menu.otheragent": "Tentokrát mluvit s jiným agentem nebo jinde",
387
389
  "ui.o.title": "strom {version} — nástroj pro genealogický výzkum s AI agenty",
388
390
  "ui.o.rule": "Tady jsou doklady výzkumu; měnit je jen příkazy strom. Nový agent: strom guide",
@@ -490,6 +492,8 @@
490
492
  "ui.doc.innode": "strom {version} v Node {node}",
491
493
  "ui.doc.fence": "stahovače v ohrádce",
492
494
  "ui.doc.fence.net": "Node {node}: stahovače běží v ohrádce, ale síť jim zavře až Node 25 nebo novější",
495
+ "ui.doc.shim": "strom pro agenty",
496
+ "ui.doc.shim.codepage": "složka instalace má písmena mimo ASCII: hooky agentů a jejich strom přes výzkum závisí na kódové stránce konzole — složka instalace bez nich tomu předejde",
493
497
  "ui.doc.oldnode": "Node {node} je příliš starý (potřeba 22.18 nebo novější)",
494
498
  "ui.doc.missing": "chybí",
495
499
  "ui.doc.notsetup": "zatím nenastaveno",
@@ -382,7 +382,9 @@
382
382
  "ui.consent.connector.direct": "Achtung: sein Code greift selbst auf das Netz zu, am Begrenzer von strom vorbei.",
383
383
  "ui.consent.connector.unfenced": "Achtung: er startet ein Programm, das strom nicht eingrenzt ({run}) – es kann alle Dateien dieses Benutzers lesen und ändern, andere Programme starten und jede Adresse erreichen. Nach jeder Änderung seines Codes wird erneut gefragt.",
384
384
  "ui.consent.connector.terms": "Nutzungsbedingungen des Archivs: {terms}",
385
- "ui.consent.host.unblock": "{host} hat uns abgewiesen. Wieder erlauben – nur wenn das Archiv gesagt hat, dass es in Ordnung ist?",
385
+ "ui.consent.host.unblock": "{host} wird in Ruhe gelassen (Anfragen abgewiesen, oder sein Limit ist laut ihm aufgebraucht). Jetzt aufheben – nur wenn das Archiv gesagt hat, dass es in Ordnung ist?",
386
+ "ui.host.unblocked": "{host}: nicht mehr in Ruhe gelassen – strom fragt wieder an, langsam",
387
+ "ui.host.unblock.kept": "Nichts geändert: {host} wird weiter in Ruhe gelassen.",
386
388
  "ui.menu.otheragent": "Diesmal mit einem anderen Agenten oder woanders sprechen",
387
389
  "ui.o.title": "strom {version} — genealogische Forschung mit KI-Agenten",
388
390
  "ui.o.rule": "Hier liegen die Belege der Forschung; ändern NUR mit strom-Befehlen. Neuer Agent: strom guide",
@@ -490,6 +492,8 @@
490
492
  "ui.doc.innode": "strom {version} in Node {node}",
491
493
  "ui.doc.fence": "Downloader eingegrenzt",
492
494
  "ui.doc.fence.net": "Node {node}: Downloader laufen eingegrenzt, aber erst Node 25 oder neuer hält sie auch vom Netz fern",
495
+ "ui.doc.shim": "strom für die Agenten",
496
+ "ui.doc.shim.codepage": "der Ordner der Installation hat Buchstaben außerhalb von ASCII: die Hooks der Agenten und ihr strom über die Forschung hängen von der Codepage der Konsole ab — ein Installationsordner ohne sie vermeidet das",
493
497
  "ui.doc.oldnode": "Node {node} ist zu alt (nötig: 22.18 oder neuer)",
494
498
  "ui.doc.missing": "fehlt",
495
499
  "ui.doc.notsetup": "noch nicht eingerichtet",
@@ -20,6 +20,10 @@ recorded, also in vain (`strom search add … --method web|catalog|full-text`).
20
20
  - **Within the brief's budget of pages** fetched or read; then record what
21
21
  was searched and close. Printed pages go to the user's model for print
22
22
  where you can hand work to one (the brief names it) — never handwriting.
23
+ The budget is the task's whole: pages through a connector count in it.
24
+ More pages of one site than the web rule allows go through its connector,
25
+ built right away when the task needs that site (`strom connector new
26
+ <site>`, then `strom fetch`).
23
27
  - **The history of a place once in a research**: what is written of it (the
24
28
  brief says) is reused, never searched again.
25
29
  - **Read the source itself** — the page, the scan. A snippet, an OCR line,
@@ -19,10 +19,12 @@ import { CHROME_ALLOW, CHROME_DENY, chromeDomain } from "../core/browser.js";
19
19
  import { claudeScanReader, opencodeScanReader, SCAN_READER, scanReaderModel, treeReading } from "./scanreader.js";
20
20
  import { claudeWebHooks, grokWebHooks, webRuleLines } from "../core/web.js";
21
21
  import { claudeDelegateHooks } from "../core/delegate.js";
22
- import { writeShim } from "../core/self.js";
22
+ import { powershellUtf8Rule, putShim } from "../core/self.js";
23
23
  export const MARKER = "<!-- strom: generated above this line (strom agents sync); your own notes below are kept -->";
24
- function agentsMd(tree) {
24
+ export function agentsMd(tree, platform = process.platform) {
25
25
  const lang = langName(tree.config.lang);
26
+ // (Windows only: written on the computer the agent works on, as its other files)
27
+ const ps = powershellUtf8Rule("`", platform);
26
28
  // the Strom app's address as strom app opens it now (the beta its beta, strom.app.url where it says another)
27
29
  const app = agentAppUrl(tree.env);
28
30
  return `# Family research: ${tree.config.name}
@@ -56,7 +58,7 @@ the researcher; \`strom\` is your only way to read and change the research.
56
58
  number) — never read a big file in pieces.
57
59
  7. Run strom commands on their own — no pipes (\`| head\`, \`| grep\`): output is
58
60
  already short, listings take \`--limit\` and \`--page\`, and piped commands may
59
- be refused by your permissions.
61
+ be refused by your permissions.${ps ? `\n ${ps}` : ""}
60
62
  8. Other agents may work on this tree too (Claude Code, Codex, Antigravity,
61
63
  OpenCode, Grok — the user's choice). \`strom\` shows who is working now; never take a task
62
64
  another one has started.
@@ -400,7 +402,11 @@ export const GROK_HOOKS = path.join(".grok", "hooks", "strom.json");
400
402
  /** Claude Code's subagent for reading scans, in the tree. */
401
403
  export const CLAUDE_SCAN_READER = path.join(".claude", "agents", `${SCAN_READER}.md`);
402
404
  export const AGENT_FILES = ["AGENTS.md", "CLAUDE.md", path.join(".claude", "settings.json"), CLAUDE_SCAN_READER, "opencode.json", path.join(".grok", "config.toml"), path.join(".grok", "rules", "strom.md"), GROK_HOOKS];
403
- export function syncAgentFiles(tree) {
405
+ /**
406
+ * strom's files of the agents written for the tree (only what changed: the files written, to commit). `said.shim`: the
407
+ * shim in .strom/bin was written now (never committed: not among the files).
408
+ */
409
+ export function syncAgentFiles(tree, said = {}) {
404
410
  // an archive: no agent works on it, and nothing of one shows in its folder (Milan's decision, 2026-10-03) — strom's own
405
411
  // files of the agents go (what a person wrote in them stays); research switched on writes them again
406
412
  if (isArchive(tree))
@@ -433,7 +439,7 @@ export function syncAgentFiles(tree) {
433
439
  // the strom the agent hooks name (the tree's .strom/bin, never committed) — there also for a conversation strom did not start
434
440
  if (!tree.dryRun) {
435
441
  try {
436
- writeShim(tree.root);
442
+ said.shim = putShim(tree.root).changed.length > 0;
437
443
  }
438
444
  catch {
439
445
  // a hook that finds no strom lets the call go
@@ -1090,7 +1090,8 @@ export function buildBrief(tree, opts) {
1090
1090
  first: true,
1091
1091
  pointer: "strom find <place>",
1092
1092
  text: [
1093
- `## Beyond the registers: about ${ENRICH_PAGES} pages fetched or read, then record the searches and close`,
1093
+ `## Beyond the registers: about ${ENRICH_PAGES} pages fetched or read in all (the task's whole budget, strom fetch too), then record the searches and close`,
1094
+ `- over ${Math.min(WEB_SOFT, new Settings(tree.env, {}).webPerHost(tree.config))} pages of one site: through its connector, built right away when the task needs that site (strom connector new <site> --url https://<host>/, then strom fetch)`,
1094
1095
  print ? `- print: a subagent on ${print} (model.text), never handwriting` : "",
1095
1096
  written.length ? `- of the places, written already (reuse): ${written.join(" · ")}` : "",
1096
1097
  ].filter(Boolean).join("\n"),
@@ -255,7 +255,19 @@ export function parseOptions(def, rest, program = "strom") {
255
255
  const cmd = `${program} ${def.path.join(" ")}`.trim();
256
256
  try {
257
257
  const r = parseArgs({ args: rest, options, allowPositionals: true, strict: true, tokens: true });
258
- return foldSurplus(def, defs, r.tokens, r.values, r.positionals);
258
+ const folded = foldSurplus(def, defs, r.tokens, r.values, r.positionals);
259
+ // where the values of a repeatable option were typed among the arguments (a command of several arguments only)
260
+ if (!def.args?.some((a) => a.variadic))
261
+ return folded;
262
+ const after = {};
263
+ let seen = 0;
264
+ for (const t of r.tokens) {
265
+ if (t.kind === "positional")
266
+ seen++;
267
+ else if (t.kind === "option" && defs.find((d) => d.name === t.name)?.multiple)
268
+ (after[t.name] ??= []).push(seen);
269
+ }
270
+ return { ...folded, after };
259
271
  }
260
272
  catch (err) {
261
273
  const e = err;
@@ -273,6 +285,12 @@ export function parseOptions(def, rest, program = "strom") {
273
285
  hint: `a status is a fact's (E…) only, never a name's or a family's: strom cite E… S… --status probable|proven, strom event edit E… --status …`,
274
286
  code: "option.unknown", hintCode: "option.unknown.status", params: { opt, cmd, options: own.join(" "), path: def.path.join(" ") }, usage,
275
287
  });
288
+ // a name agents type meaning another option (--timeout for --minutes): that one, with what it takes
289
+ const meant = optionsOf(def).find((o) => o.mistaken?.includes(opt.replace(/^-+/u, "")));
290
+ if (meant) {
291
+ const near = `--${meant.name}${meant.value ? ` ${meant.value}` : ""}`;
292
+ throw new UsageError(`unknown option ${opt} for ${cmd}`, { hint: `meant: ${near} — ${meant.description}`, code: "option.near", params: { opt, cmd, near }, usage });
293
+ }
276
294
  const near = suggest(opt, optionsOf(def).filter((o) => !o.hidden).map((o) => `--${o.name}`));
277
295
  const help = `strom help${def.path.length ? ` ${def.path.join(" ")}` : ""}`;
278
296
  throw new UsageError(`unknown option ${opt} for ${cmd}`, {
package/dist/cli/main.js CHANGED
@@ -20,7 +20,7 @@ import { isAgent } from "../core/which.js";
20
20
  import { appUrlShown, noticeStromApp } from "../core/stromapp.js";
21
21
  import { lastChannelOf, pendingTransition, updateChannel } from "../core/update.js";
22
22
  import { backupBefore, backupSaid } from "./backups.js";
23
- import { installation } from "../core/self.js";
23
+ import { installation, refreshTreeShim } from "../core/self.js";
24
24
  import { refreshGlobal } from "../agents/global.js";
25
25
  import { expandFromLine, refreshLinks } from "../core/links.js";
26
26
  import { clockLine, FINISH_LINE, finishLimitLine, finishOf } from "../core/clock.js";
@@ -274,6 +274,9 @@ export async function main(argv, io, env, cwd) {
274
274
  catch {
275
275
  // another computer's seal, a tree at work: its bridge's start settles it
276
276
  }
277
+ // …and the strom its agents' hooks and conversations start (.strom/bin) leads to this one — an older strom's, or one
278
+ // whose mark went with the program folder an update replaced; only where a research has one, never an archive
279
+ refreshTreeShim(k.root, env);
277
280
  // the bridges the Strom app follows go on with this version at their addresses: one of an older strom started
278
281
  // again, one the installer ended to replace the program (Windows: its files are held while it runs) back
279
282
  try {
@@ -301,7 +304,7 @@ export async function main(argv, io, env, cwd) {
301
304
  assertIntact(ctx.tree());
302
305
  let result;
303
306
  try {
304
- result = await (shows ?? def).run(ctx, { args, opts: v, ...(passthrough.length ? { extra: passthrough } : {}) });
307
+ result = await (shows ?? def).run(ctx, { args, opts: v, ...(passthrough.length ? { extra: passthrough } : {}), ...(parsed.after ? { after: parsed.after } : {}) });
305
308
  }
306
309
  catch (err) {
307
310
  // A writing command is a transaction: on failure nothing it wrote remains.
package/dist/cli/ui.js CHANGED
@@ -44,7 +44,9 @@ export const UI = {
44
44
  "ui.consent.connector.direct": "Warning: its code reaches the network itself, past strom's limiter.",
45
45
  "ui.consent.connector.unfenced": "Warning: it runs a program strom does not fence in ({run}) — it may read and change any file of this user, start other programs and reach any address. Asked again after every change of its code.",
46
46
  "ui.consent.connector.terms": "The archive's terms of use: {terms}",
47
- "ui.consent.host.unblock": "{host} refused us. Allow it again — only if the archive said it is fine?",
47
+ "ui.consent.host.unblock": "{host} is left alone (it refused the requests, or said its limit is used up). Lift it now — only if the archive said it is fine?",
48
+ "ui.host.unblocked": "{host}: no longer left alone — strom will ask it again, slowly",
49
+ "ui.host.unblock.kept": "Nothing changed: {host} is still left alone.",
48
50
  "ui.consent.elsewhere.connector": "Run the downloader \"{name}\"? An agent asks for it from outside the research's folder ({cwd}).",
49
51
  "ui.consent.elsewhere.run": "Start the agent working alone on the research \"{tree}\"? An agent asks for it from outside the research's folder ({cwd}); the work costs money.",
50
52
  "ui.consent.connectors.off": "Let downloaders run without asking first?",
@@ -463,6 +465,8 @@ export const UI = {
463
465
  "ui.doc.innode": "strom {version} in Node {node}",
464
466
  "ui.doc.fence": "downloaders fenced in",
465
467
  "ui.doc.fence.net": "Node {node}: downloaders run fenced in, but only Node 25 or newer also keeps them off the network",
468
+ "ui.doc.shim": "strom for the agents",
469
+ "ui.doc.shim.codepage": "the installation's folder has letters outside ASCII: the agents' hooks and their strom through the research rely on the console's code page — an installation folder without them avoids it",
466
470
  "ui.doc.oldnode": "Node {node} is too old (22.18 or newer)",
467
471
  "ui.doc.missing": "missing",
468
472
  "ui.doc.notsetup": "not set up yet",
@@ -657,10 +657,13 @@ register({
657
657
  // (strom connector discard) — in the research's own .strom, never in the plugin's folder
658
658
  const tree = ctx.hasTree() ? ctx.tree() : undefined;
659
659
  const session = tree ? currentSession(tree, ctx.env) : undefined;
660
+ // whether its agent may take it away again (strom connector discard), else it is the user's to remove
661
+ let discardable = false;
660
662
  if (tree) {
661
663
  const marker = madeMarker(tree.root, name);
662
664
  const folder = folderId(dir);
663
665
  if (session && folder) {
666
+ discardable = true;
664
667
  const made = { session: session.id, tree: tree.config.id, ...(session.research ? { research: session.research } : {}), at: new Date().toISOString(), folder };
665
668
  fs.mkdirSync(path.dirname(marker), { recursive: true });
666
669
  fs.writeFileSync(marker, JSON.stringify(made) + "\n");
@@ -671,7 +674,7 @@ register({
671
674
  const rel = ctx.display(dir);
672
675
  return {
673
676
  text: lines(`connector ${name} started in ${rel}`, "", "next:", ` 1. your agent reads ${path.join(rel, "DISCOVERY.md")} and the contract (README.md one folder up),`, " and finds out what the portal allows — before any code", " 2. it writes connector.json (policy) and connector.ts, and tests with a few requests:", ` strom connector test ${name} --find "<place>"`, consentRequired(ctx.env) ? ` 3. the first test needs your consent, in your terminal: strom allow connector ${name}` : undefined, ofSite
674
- ? `\nnote: the connector ${ofSite} is here already for ${webDomain(url.hostname)} (its hosts: ${listConnectors(shared(ctx)).find((c) => c.name === ofSite)?.manifest.hosts.join(", ") ?? "?"}) — more pages of that site go through it (strom connector show ${ofSite}); keep this one only for another portal of the site, else take it away: strom connector discard ${name}`
677
+ ? `\nnote: the connector ${ofSite} is here already for ${webDomain(url.hostname)} (its hosts: ${listConnectors(shared(ctx)).find((c) => c.name === ofSite)?.manifest.hosts.join(", ") ?? "?"}) — more pages of that site go through it (strom connector show ${ofSite}); keep this one only for another portal of the site, else take it away: ${discardable ? `strom connector discard ${name}` : `the user, in their terminal: strom connector remove ${name}`}`
675
678
  : undefined),
676
679
  data: { name, dir, ...(ofSite ? { site: { domain: webDomain(url.hostname), connector: ofSite } } : {}) },
677
680
  };
@@ -1137,7 +1140,7 @@ register({
1137
1140
  args: [{ name: "host", description: "e.g. digi.example.org", required: true }],
1138
1141
  options: [
1139
1142
  { name: "revoke", type: "boolean", description: "take the consent back" },
1140
- { name: "unblock", type: "boolean", description: "lift a refusal (401/403) early — only after the archive said it is fine" },
1143
+ { name: "unblock", type: "boolean", description: "lift a refusal (401/403), or a limit the host said is used up, early — only after the archive said it is fine" },
1141
1144
  { name: "pace", type: "string", value: "<seconds|auto>", description: `your own pause between two requests to the host (at least ${MIN_INTERVAL_MS / 1000} s); auto: the service's again` },
1142
1145
  { name: "per-hour", type: "string", value: "<n|none|auto>", description: "your own hourly cap for the host; none: no cap; auto: the service's again (strom sets none by itself)" },
1143
1146
  ],
@@ -1176,7 +1179,7 @@ register({
1176
1179
  const now = paceAt(ctx, host, c);
1177
1180
  return { text: `${host}: ${paceText(now)}${s.own ? " — yours" : " — the service's again"}`, data: { host, pace: { minIntervalMs: now.minIntervalMs, perHour: Number.isFinite(now.perHour) ? now.perHour : null }, own: s.own ?? null } };
1178
1181
  }
1179
- const how = ctx.requireHuman(`${opts.revoke ? "Take back" : opts.unblock ? "Lift the refusal of" : "Allow"} automated access to ${host}?`, `strom allow host ${host}${opts.revoke ? " --revoke" : opts.unblock ? " --unblock" : ""}`, `host:${host}`, ui(lang, opts.revoke ? "ui.consent.host.revoke" : opts.unblock ? "ui.consent.host.unblock" : "ui.consent.host", { host }));
1182
+ const how = ctx.requireHuman(`${opts.revoke ? "Take back" : opts.unblock ? "Lift the refusal or the used-up limit of" : "Allow"} automated access to ${host}?`, `strom allow host ${host}${opts.revoke ? " --revoke" : opts.unblock ? " --unblock" : ""}`, `host:${host}`, ui(lang, opts.revoke ? "ui.consent.host.revoke" : opts.unblock ? "ui.consent.host.unblock" : "ui.consent.host", { host }));
1180
1183
  const all = loadConsents(ctx.env);
1181
1184
  if (opts.revoke) {
1182
1185
  delete all.hosts[host];
@@ -1184,10 +1187,11 @@ register({
1184
1187
  return { text: `${host}: consent taken back — no connector reaches it now`, data: { host, allowed: false } };
1185
1188
  }
1186
1189
  if (opts.unblock) {
1187
- if (how === "terminal" && !(await ctx.confirm(`${host} refused us. Lift it now (only if the archive said it is fine)?`, false)))
1188
- return { text: "nothing changed" };
1190
+ // the person's question and answer, in their language (a refusal, or a limit the host said is used up)
1191
+ if (how === "terminal" && !(await ctx.confirm(ui(lang, "ui.consent.host.unblock", { host }), false)))
1192
+ return { text: ui(lang, "ui.host.unblock.kept", { host }) };
1189
1193
  clearBlock(netDir(ctx), host);
1190
- return { text: `${host}: the refusal is lifted — strom will ask it again, slowly`, data: { host, unblocked: true } };
1194
+ return { text: ui(lang, "ui.host.unblocked", { host }), data: { host, unblocked: true } };
1191
1195
  }
1192
1196
  const c = listConnectors(ctx.settings.shared()?.value).find((x) => x.manifest.hosts.some((h) => hostAllowed(host, [h])));
1193
1197
  const ok = await askHost(ctx, host, c, how === "window");
@@ -1570,10 +1574,16 @@ async function fetchWith(ctx, c, request, recordset) {
1570
1574
  throw new UsageError(`${c.manifest.title} does not allow automated download (its terms, as the connector read them)`, {
1571
1575
  hint: `the user saves them by hand: strom task wait T… --images B…:${request.cmd === "part" ? request.image : runs(request.images)} --on "<the book, its link, which images>" (the link: strom fetch ${c.name} ${request.book} --list)`,
1572
1576
  });
1577
+ // an archive marked manual (browser only) refuses downloads, not finding books in its catalogue and their links;
1578
+ // one marked forbidden refuses every automated request
1573
1579
  const repo = forbiddenBy(ctx, c);
1574
- if (repo)
1575
- throw new UsageError(`${repo.id} ${repo.name} is marked automation ${repo.automation} in this tree — no downloads through a connector`, {
1576
- hint: `strom repo show ${repo.id} — change it only if the archive allows it: strom repo edit ${repo.id} --automation allowed`,
1580
+ if (repo?.automation === "forbidden")
1581
+ throw new UsageError(`${repo.id} ${repo.name} is marked automation forbidden in this tree — no requests through a connector`, {
1582
+ hint: `strom repo show ${repo.id} — the user saves what the research needs by hand`,
1583
+ });
1584
+ if (repo && (request.cmd === "fetch" || request.cmd === "part"))
1585
+ throw new UsageError(`${repo.id} ${repo.name} is marked automation manual in this tree — no images downloaded through a connector`, {
1586
+ hint: `the user saves them by hand: strom task wait T… --images B…:${request.cmd === "part" ? request.image : runs(request.images)} --on "<the book, its link, which images>" (finding books and their links still works — the link: strom fetch ${c.name} ${request.book} --list)`,
1577
1587
  });
1578
1588
  if (recordset && request.cmd === "fetch") {
1579
1589
  // what is registered already is not asked for again (a part of an image is not the image)
@@ -17,11 +17,12 @@ register({
17
17
  description: "Searches the text files of inputs/ and notes/ (output/ too with --in output or --in all) — never data/ (the\n" +
18
18
  "records: strom find), .git or anything outside the tree, never through a symbolic link; images, PDFs and other\n" +
19
19
  "binary files are left out. A text is found as it reads: in any case, with accents or without (mlynar finds\n" +
20
- "Mlynář), a run of spaces as one. Several texts: a line with any of them (spellings of one name). --regex: a\n" +
20
+ "Mlynář), a run of spaces as one. Several texts: a line with ANY of them (spellings of one name) by default;\n" +
21
+ "--all: a line with EVERY one of them, in any order (a year and a name: 1803 \"Pag. 11\"). --regex: a\n" +
21
22
  "JavaScript regular expression, in any case, Unicode (\\p{L} a letter of any script). A line found is shown\n" +
22
23
  "file:line: text, a line round it (--context) file-line- text; a long line is cut round its find.\n" +
23
24
  `Listings are paged (--limit, --page); the search stops at ${GREP_MAX_HITS} lines and says so.`,
24
- args: [{ name: "text", description: "what to find (accents and capitals optional); several = a line with any of them", required: true, variadic: true }],
25
+ args: [{ name: "text", description: "what to find (accents and capitals optional); several = a line with any of them (--all: with every one)", required: true, variadic: true }],
25
26
  options: [
26
27
  {
27
28
  name: "in",
@@ -31,12 +32,14 @@ register({
31
32
  description: `${GREP_FOLDERS.join(" | ")} | all | a file or folder inside them | an input I… (default: ${GREP_DEFAULT.join(" and ")})`,
32
33
  },
33
34
  { name: "context", type: "string", value: "<n>", description: `lines shown before and after each line found (0–${MAX_CONTEXT}, default 0)` },
35
+ { name: "all", type: "boolean", description: "several texts: a line with every one of them (default: with any)" },
34
36
  { name: "regex", type: "boolean", description: "the text is a regular expression (JavaScript, in any case, Unicode)" },
35
37
  { name: "files", type: "boolean", description: "only the files and how many lines each has found" },
36
38
  ],
37
39
  examples: [
38
40
  'strom grep "mlynář"',
39
41
  "strom grep Novák Nowak --in inputs --context 2",
42
+ 'strom grep 1803 "Pag. 11" --all --in I0002',
40
43
  'strom grep "Lhota" --in inputs/rodokmen.md --context 3',
41
44
  'strom grep "Nov[aá]k(ov[aá])?" --regex --in notes',
42
45
  "strom grep Novák --in I0002 --files",
@@ -64,7 +67,7 @@ register({
64
67
  throw new UsageError(error, { hint: `strom grep ${patterns.map(shellArg).join(" ")} --in ${GREP_FOLDERS.join("|")}|all` });
65
68
  let match;
66
69
  try {
67
- match = grepMatcher(patterns, regex);
70
+ match = grepMatcher(patterns, regex, !!opts.all);
68
71
  }
69
72
  catch (e) {
70
73
  throw new UsageError(`not a regular expression: ${e.message}`, { hint: "strom grep <text> — without --regex the text is found as it reads" });
@@ -77,6 +80,7 @@ register({
77
80
  ...wanted.map((w) => `--in ${shellArg(w)}`),
78
81
  ...(context ? [`--context ${context}`] : []),
79
82
  ...(regex ? ["--regex"] : []),
83
+ ...(opts.all ? ["--all"] : []),
80
84
  ...(opts.files ? ["--files"] : []),
81
85
  ].join(" ");
82
86
  const tooBig = found.tooBig.length ? `left out, over 64 MB: ${found.tooBig.join(", ")}` : undefined;
@@ -86,7 +90,9 @@ register({
86
90
  data: { total: 0, searched: found.searched, hits: [], ...(found.tooBig.length ? { tooBig: found.tooBig } : {}) },
87
91
  };
88
92
  const total = `${found.hits.length}${found.capped ? "+" : ""}`;
89
- const head = `${total} line(s) in ${found.counts.size} of ${found.searched} text file(s) of ${where}${found.capped ? ` — stopped at ${GREP_MAX_HITS}: a narrower text or --in` : ""}`;
93
+ // several texts: whether a line has any or every one of them, said (agents expect every one)
94
+ const mode = patterns.length > 1 ? (opts.all ? " with every text" : " with any of the texts (every one: --all)") : "";
95
+ const head = `${total} line(s)${mode} in ${found.counts.size} of ${found.searched} text file(s) of ${where}${found.capped ? ` — stopped at ${GREP_MAX_HITS}: a narrower text or --in` : ""}`;
90
96
  if (opts.files) {
91
97
  const files = [...found.counts].map(([file, n]) => ({ file, lines: n }));
92
98
  const page = paginate(files, ctx.limit, ctx.page);
@@ -5,8 +5,10 @@
5
5
  import { langName } from "../core/lang.js";
6
6
  import { appSite } from "../core/stromapp.js";
7
7
  import { WEB_PER_HOST, webRuleLines } from "../core/web.js";
8
+ import { powershellUtf8Rule } from "../core/self.js";
8
9
  /** The guide; appUrl: the Strom app's address it names — the one `strom app` opens (appUrlShown); perHost: web.perHost here. */
9
- export function guideText(lang, appUrl, perHost = WEB_PER_HOST) {
10
+ export function guideText(lang, appUrl, perHost = WEB_PER_HOST, platform = process.platform) {
11
+ const ps = powershellUtf8Rule("", platform);
10
12
  const language = lang
11
13
  ? `The research language of this tree is ${langName(lang)} (${lang}): talk to the user in ${langName(lang)} and write notes, tasks and stories in ${langName(lang)}. Keep transcripts of records in their original language.`
12
14
  : "Talk to the user in their language. Set it as the research language: strom setup --lang <code> (cs, en, de, pl, …).";
@@ -231,7 +233,7 @@ THE STROM APP — where the user sees the result
231
233
 
232
234
  OUTPUT AND ERRORS
233
235
  - One line per record, IDs first. Read the text; add --json only to parse.
234
- - Listings are paged (--limit, --page). Do not pipe strom output.
236
+ - Listings are paged (--limit, --page). Do not pipe strom output.${ps ? `\n- ${ps}` : ""}
235
237
  - People can be named by ID (P0001) or name ("Jan Novák", no diacritics ok).
236
238
  An ambiguous name lists candidates (exit 2) — then use the ID.
237
239
  - Every error ends with "→ <what to run next>".
@@ -9,7 +9,7 @@ import { register } from "../cli/registry.js";
9
9
  import { lines, moreLine, paginate, runs, shellArg, table, truncate } from "../cli/format.js";
10
10
  import { UsageError } from "../core/errors.js";
11
11
  import { makeNote } from "../core/actions.js";
12
- import { clipText, collectFiles, coveringPart, fileSha256, findImage, imageNumbers, imageOfRef, inboxFolders, inputPath, isWhole, mimeOf, onlyParts, otherCopies, parseImageList, regionText, sharperPart, storeShared, wholeSizeOf } from "../core/media.js";
12
+ import { clipText, collectFiles, coveringPart, fileSha256, findImage, imageNumbers, imageOfRef, imageRange, inboxFolders, inputPath, isWhole, mimeOf, onlyParts, otherCopies, parseImageList, regionText, sharperPart, storeShared, wholeSizeOf } from "../core/media.js";
13
13
  import { create, normId, requireRecord, update } from "../core/records.js";
14
14
  import { imageSizeOfFile } from "../image/index.js";
15
15
  import { imageOf, pageOf } from "../core/calibration.js";
@@ -305,11 +305,46 @@ function refsText(wants) {
305
305
  }
306
306
  return out.join(" ");
307
307
  }
308
+ /** A number of an image or page after a record set (B0001 --page 112 113): it belongs to the image named before it. */
309
+ const isNumberArg = (a) => /^\d+(?:\s*(?:[-–]|\.\.\.?)\s*\d+)?(?:\s*,\s*\d+(?:\s*(?:[-–]|\.\.\.?)\s*\d+)?)*$/u.test(a.trim());
310
+ /**
311
+ * Crops typed among several images (B0001:2 --crop A B0001:3 --crop B): each crop is of the image named before it —
312
+ * never every crop of every image (N0275: 16 views where 4 were meant). Crops all after the last image, or of one
313
+ * image, are of every image as always (undefined).
314
+ */
315
+ function cropGroups(args, opts, after) {
316
+ const crops = opts.crop === undefined ? [] : Array.isArray(opts.crop) ? opts.crop.map(String) : [String(opts.crop)];
317
+ if (!crops.length || !after || after.length !== crops.length)
318
+ return undefined;
319
+ const refs = args.map((a, i) => (isNumberArg(a) ? -1 : i)).filter((i) => i >= 0);
320
+ if (refs.length < 2 || after.every((n) => n >= args.length))
321
+ return undefined;
322
+ const form = `strom media view ${args[refs[0]]} --crop ${crops[0]} ${args[refs[1]]} --crop …`;
323
+ if (after.some((n) => n === 0))
324
+ throw new UsageError("a --crop before any image: each --crop is of the image named before it", {
325
+ hint: `${form} (the same crops of every image: all of them after the last image)`,
326
+ code: "view.crop-first",
327
+ });
328
+ const groups = refs.map((r, k) => ({ args: args.slice(r, refs[k + 1] ?? args.length), crops: [] }));
329
+ crops.forEach((c, i) => {
330
+ // the image named last before it (a number after a record set is its page or image)
331
+ let k = refs.length - 1;
332
+ while (k > 0 && refs[k] >= after[i])
333
+ k--;
334
+ groups[k].crops.push(c);
335
+ });
336
+ const bare = groups.find((g) => !g.crops.length);
337
+ if (bare)
338
+ throw new UsageError(`${bare.args[0]} has no --crop of its own: each --crop is of the image named before it`, {
339
+ hint: `${form} (the same crops of every image: all of them after the last image)`,
340
+ code: "view.crop-missing",
341
+ });
342
+ return groups;
343
+ }
308
344
  /** The images a call names, at most VIEW_MAX_IMAGES and `maxViews` views of them; images not registered said. */
309
- function resolveTargets(tree, shared, all, args, opts, parts, maxViews = VIEW_MAX_VIEWS) {
310
- const numbers = (a) => /^\d+(?:\s*[-–]\s*\d+)?(?:\s*,\s*\d+(?:\s*[-–]\s*\d+)?)*$/u.test(a.trim());
311
- const nums = args.filter(numbers);
312
- const refs = args.filter((a) => !numbers(a)).map((a) => a.trim());
345
+ function resolveTargets(tree, shared, all, args, opts, parts, maxViews = VIEW_MAX_VIEWS, several = false) {
346
+ const nums = args.filter(isNumberArg);
347
+ const refs = args.filter((a) => !isNumberArg(a)).map((a) => a.trim());
313
348
  const pages = listOf(opts.page);
314
349
  const images = listOf(opts.image);
315
350
  if (pages.length && images.length)
@@ -320,6 +355,10 @@ function resolveTargets(tree, shared, all, args, opts, parts, maxViews = VIEW_MA
320
355
  const pageList = pages.length ? [...pages, ...nums] : [];
321
356
  const imageList = pages.length ? [] : [...images, ...nums];
322
357
  const list = (s, what) => {
358
+ // a range from the higher number, or of hundreds of images: said as such
359
+ for (const p of s.join(",").split(","))
360
+ if (/^\s*\d+\s*(?:[-–]|\.\.\.?)\s*\d+\s*$/u.test(p))
361
+ imageRange(p.trim(), what === "images" ? "image" : what);
323
362
  const n = parseImageList(s.join(","));
324
363
  if (n)
325
364
  return n;
@@ -377,7 +416,7 @@ function resolveTargets(tree, shared, all, args, opts, parts, maxViews = VIEW_MA
377
416
  const parts = onlyParts(all, w.b.id, image);
378
417
  const m = parts.length > 1 && (opts.crop !== undefined || opts.half !== undefined || opts.split !== undefined) ? findImage(all, w.b.id, image) : imageOfRef(all, w.b.id, image);
379
418
  if (!m) {
380
- if (wants.length === 1)
419
+ if (wants.length === 1 && !several)
381
420
  throw new UsageError(`image ${image} of ${w.b.id} is not registered`, { hint: `strom media list --recordset ${w.b.id} · strom media add <files> --recordset ${w.b.id}` });
382
421
  missing.push(`${w.b.id}:${image}`);
383
422
  continue;
@@ -393,7 +432,7 @@ function resolveTargets(tree, shared, all, args, opts, parts, maxViews = VIEW_MA
393
432
  seen.add(t.key);
394
433
  targets.push(t);
395
434
  }
396
- if (!targets.length) {
435
+ if (!targets.length && !several) {
397
436
  const b = missing[0].split(":")[0];
398
437
  throw new UsageError(`none of these images is registered: ${missing.join(", ")}`, { hint: `strom media list --recordset ${b} · strom media add <files> --recordset ${b}` });
399
438
  }
@@ -726,7 +765,7 @@ register({
726
765
  path: ["media", "view"],
727
766
  summary: "Make views of images to look at: a crop, a half page, enlarged, more contrast, a grid — several in one call",
728
767
  group: "sources",
729
- sheet: `at most ${VIEW_MAX_IMAGES} images a call; several crops of an image: --crop … --crop … (no loop); a whole view finds, only a crop is read; a book read through: strom read`,
768
+ sheet: `at most ${VIEW_MAX_IMAGES} images a call; several crops of an image: --crop … --crop … (no loop), of each image: IMG --crop … IMG --crop …; a whole view finds, only a crop is read; a book read through: strom read`,
730
769
  tree: true,
731
770
  description: `Writes the views to .strom/views/ and prints their paths: open those files with your image reader. A whole image\n` +
732
771
  `is at most ${OVERVIEW_MAX} px on the long side, enough to find an entry; a half, a crop and the parts of --split at most\n` +
@@ -737,11 +776,13 @@ register({
737
776
  "Several views in one call, then open all the files it lists together: several images (B0001:57 B0001:58, a range\n" +
738
777
  "B0001:57-60, B0001 --page 112 113), several parts of each (--half left --half right; --half both = the two pages\n" +
739
778
  "of a double page overlapping at the gutter, where they are sharper than one view of it, else the image whole;\n" +
740
- "--crop repeated; --split 2x3 = a grid of overlapping parts), each view with its image, page and --clip. At most\n" +
779
+ "--crop repeated; --split 2x3 = a grid of overlapping parts), each view with its image, page and --clip. A --crop\n" +
780
+ "typed after an image among others is of that image alone (B0001:2 --crop … B0001:7 --crop …); crops after the last\n" +
781
+ "image are of every image. At most\n" +
741
782
  `${VIEW_MAX_IMAGES} images and ${VIEW_MAX_VIEWS} views in one call: every view stays in the context that opens it.`,
742
783
  args: [{ name: "image", description: "M0012, B0001:57 (record set:image), a range B0001:57-60, B0001 with --page, or an input I0002; several = several images", required: true, variadic: true }],
743
784
  options: [
744
- { name: "crop", type: "string", multiple: true, value: "<x,y,w,h>", description: "part of the image: fractions (0.1,0.35,0.4,0.2) or pixels; repeated = several parts" },
785
+ { name: "crop", type: "string", multiple: true, value: "<x,y,w,h>", description: "part of the image: fractions (0.1,0.35,0.4,0.2) or pixels; repeated = several parts; after one image of several: of that image alone" },
745
786
  { name: "half", type: "string", multiple: true, value: "<side>", description: "left or right page of a double page (top, bottom); both = the two pages, overlapping at the gutter" },
746
787
  { name: "split", type: "string", value: "<c>x<r>", description: "the image (or each half or crop) as a grid of overlapping parts, e.g. 2x3" },
747
788
  { name: "scale", type: "string", value: "<f>", description: "size of the result: 2 = twice the original pixels" },
@@ -750,7 +791,7 @@ register({
750
791
  { name: "grey", type: "boolean", description: "greyscale" },
751
792
  { name: "grid", type: "boolean", description: "overlay a grid of tenths with labels, to point at a place" },
752
793
  { name: "rotate", type: "string", value: "<deg>", description: "90, 180 or 270" },
753
- { name: "image", type: "string", multiple: true, value: "<n>", description: "image number(s) with a record set ID: 57, 57-60" },
794
+ { name: "image", type: "string", multiple: true, value: "<n>", description: "image number(s) with a record set ID: 57, 57-60, 57..60" },
754
795
  { name: "page", type: "string", multiple: true, value: "<n>", description: "page(s) or folio(s), with a calibrated record set ID: 112, 112 113, 112-115" },
755
796
  { name: "png", type: "boolean", description: "lossless PNG instead of JPEG" },
756
797
  ],
@@ -761,13 +802,15 @@ register({
761
802
  "strom media view B0001 --page 112",
762
803
  "strom media view B0001:1-3 --half both",
763
804
  "strom media view B0001:2 --crop 0.05,0.10,0.45,0.30 --crop 0.05,0.40,0.45,0.30",
805
+ "strom media view B0001:2 --crop 0.05,0.10,0.45,0.30 B0001:3 --crop 0.50,0.60,0.45,0.20",
764
806
  "strom media view B0001 --page 112 113 --split 2x2",
765
807
  ],
766
- run(ctx, { args, opts }) {
808
+ run(ctx, { args, opts, after }) {
767
809
  const tree = ctx.tree();
768
810
  const shared = sharedDir(ctx);
769
811
  const all = tree.list("media");
770
812
  const parts = viewParts(opts);
813
+ const groups = cropGroups(args, opts, after?.crop);
771
814
  // a whole image to find the entry on; a part of it (a half, a crop, a grid's part) as big as the model takes it
772
815
  const own = agentViewSizes(ctx, tree);
773
816
  // what strom tuned for this agent and model (core/tune.ts) — as at the start of the session at work: the numbers
@@ -775,7 +818,33 @@ register({
775
818
  const since = currentSession(tree, ctx.env)?.started;
776
819
  const tuned = treeTuning(tree, ctx.settings);
777
820
  const reading = readingOf(ctx.settings.config, own.key, { since, on: tuningOn(ctx.settings), root: tree.root });
778
- const { targets, missing } = resolveTargets(tree, shared, all, args, opts, parts, reading.viewsPerCall);
821
+ // each image with its own crops (cropGroups), else every part of every image
822
+ const partsOf = new Map();
823
+ let targets = [];
824
+ let missing = [];
825
+ if (!groups)
826
+ ({ targets, missing } = resolveTargets(tree, shared, all, args, opts, parts, reading.viewsPerCall));
827
+ else {
828
+ for (const g of groups) {
829
+ const own = { ...opts, crop: g.crops };
830
+ const gp = viewParts(own);
831
+ const r = resolveTargets(tree, shared, all, g.args, own, gp, reading.viewsPerCall, true);
832
+ for (const t of r.targets)
833
+ partsOf.set(t, gp);
834
+ targets.push(...r.targets);
835
+ missing.push(...r.missing);
836
+ }
837
+ if (!targets.length) {
838
+ const b = missing[0].split(":")[0];
839
+ throw new UsageError(`none of these images is registered: ${missing.join(", ")}`, { hint: `strom media list --recordset ${b} · strom media add <files> --recordset ${b}` });
840
+ }
841
+ const views = targets.reduce((n, t) => n + partsOf.get(t).reduce((m, p) => m + (p.both ? 2 : 1) * (p.split ? p.split[0] * p.split[1] : 1), 0), 0);
842
+ if (targets.length > VIEW_MAX_IMAGES || views > reading.viewsPerCall)
843
+ throw new UsageError(`${targets.length} image(s), ${views} views — at most ${VIEW_MAX_IMAGES} images and ${reading.viewsPerCall} views in one call: every view stays in the context that opens it`, {
844
+ hint: "fewer images now — write down what they gave before the next call",
845
+ });
846
+ }
847
+ const partsFor = (t) => partsOf.get(t) ?? parts;
779
848
  const spec = viewSpec({ ...opts, half: undefined, crop: undefined });
780
849
  // a book read worse than the others: its views bigger (never above what the model takes)
781
850
  const sizesOf = (t) => bookViewSizes(own, tuned, t.media?.recordset, since);
@@ -785,7 +854,7 @@ register({
785
854
  // where the size came from, for the record of the views (core/metrics.ts)
786
855
  const capFromOf = (t) => (spec.max !== undefined || spec.scale !== undefined ? "option" : sizesOf(t).tuned ? `tuned:${sizesOf(t).tuned}` : own.calibrated ? "calibrated" : "default");
787
856
  // the old call: one image, one view — said as it always was
788
- if (targets.length === 1 && !missing.length && parts.length === 1 && !parts[0].both && !parts[0].split) {
857
+ if (!groups && targets.length === 1 && !missing.length && parts.length === 1 && !parts[0].both && !parts[0].split) {
789
858
  const t = targets[0];
790
859
  const r = oneView(ctx, tree, shared, all, t, { ...spec, max: maxOf(parts[0], t), half: parts[0].half, crop: parts[0].crop }, { capFrom: capFromOf(t) }, noSharperOf(t));
791
860
  return {
@@ -799,7 +868,7 @@ register({
799
868
  let first;
800
869
  for (const t of targets)
801
870
  try {
802
- for (const p of parts)
871
+ for (const p of partsFor(t))
803
872
  for (const s of partSpecs(t, p, capOf(t), !!sizesOf(t).halves))
804
873
  made.push({
805
874
  t,
@@ -46,10 +46,13 @@ function partCovering(parts, crop) {
46
46
  }
47
47
  /** "40-69" → [40, 69] */
48
48
  function range(v) {
49
- const m = /^(\d+)(?:-(\d+))?$/.exec(v.trim());
49
+ const m = /^(\d+)(?:\s*(?:[-–]|\.\.\.?)\s*(\d+))?$/u.exec(v.trim());
50
50
  if (!m)
51
51
  throw new UsageError(`invalid --images "${v}"`, { hint: "e.g. 40-69" });
52
- return [Number(m[1]), Number(m[2] ?? m[1])];
52
+ const [lo, hi] = [Number(m[1]), Number(m[2] ?? m[1])];
53
+ if (hi < lo)
54
+ throw new UsageError(`--images ${v.trim()}: a range from the lower number`, { hint: `--images ${hi}-${lo}` });
55
+ return [lo, hi];
53
56
  }
54
57
  export async function pool(items, size, fn) {
55
58
  const results = new Array(items.length);
@@ -93,7 +96,7 @@ register({
93
96
  { name: "batch", type: "string", value: "<n>", description: `views per reader — a double page in halves is two (default ${BATCH}, fewer where strom tuned the reading of this agent and model: strom config get reading.batch; at most ${BATCH_MAX})` },
94
97
  { name: "parallel", type: "string", value: "<n>", description: "readers at the same time (default 3)" },
95
98
  { name: "model", type: "string", value: "<model>", description: "model of the readers (default: model.vision — never weaker for handwriting)" },
96
- { name: "minutes", type: "string", value: "<n>", description: READER_MINUTES_HELP },
99
+ { name: "minutes", type: "string", value: "<n>", description: READER_MINUTES_HELP, mistaken: ["timeout", "time", "time-limit", "max-minutes"] },
97
100
  ],
98
101
  examples: [
99
102
  'strom read B0001 --images 40-69 --question "Every baptism of the surname Novák (Nowak, Nowack) in 1820–1824: the child, date, house, both parents and their parents"',