strom-research 1.18.0-beta.11 → 1.18.0-beta.13

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.18.0
9
9
 
10
- *Připravuje se: bety do 1.18.0-beta.11. Oddíl bude úplný s vydáním.*
10
+ *Připravuje se: bety do 1.18.0-beta.13. Oddíl bude úplný s vydáním.*
11
11
 
12
12
  - **Fotky z telefonu nastojato.** Fotka otočená podle údaje EXIF se čte tak, jak se má dívat, nastojato; místa na ní
13
13
  vyznačená (výřezy, poloviny dvoustrany, uložená část) jsou místa obrázku, jak se ukazuje. Fotka sama zůstává, jak
@@ -16,14 +16,17 @@ Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou n
16
16
  na macOS, Windows i Linuxu — a originál zůstává, jak přišel. Fotka HEIC jde do aplikace Strom jako výřez JPEG.
17
17
  Kde obrázek na počítači nic nepřevede, řekne to jedna věta i s tím, co by pomohlo; `strom tidy` převedené obrázky
18
18
  po 30 dnech nebo nad 500 MB uklidí.
19
- - **Jeden dokument přečtený naráz.** `strom document read` přečte fotku, sken nebo PDF jednoho dokumentu — zápis
20
- z matriky, dopis, listinu, náhrobek, rub fotky: přepis slovo od slova, překlad, význam starých slov a zkratek
21
- a osoby, které jmenuje, i s tím, kdo ze stromu to může být. Nic se nedomýšlí: jméno, které v dokumentu není,
22
- vypadne — i příjmení, nikdy se nebere od otce ani z jiné rodiny —, co nejde přečíst, je označené; druhý čtenář
23
- může číst naslepo. Do stromu se z toho nic nezapíše samo. Dokument už přečtený to o sobě řekne, takže se omylem
24
- nečte podruhé. Dokument čtený znovu se ukazuje pod názvem svého souboru, nikdy pod kódem.
25
- Dokument poslaný z aplikace Strom najde agenta stejně jako terminál — i nainstalovaného do vlastní složky, přes npm
26
- nebo Homebrew — místo hlášení, že agent není nastavený.
19
+ - **Jeden dokument přečtený naráz.** `strom document read` přečte fotku, sken nebo PDF jednoho dokumentu — zápis z
20
+ matriky, dopis, listinu, náhrobek, rub fotky: přepis slovo od slova, překlad, význam starých slov a zkratek a osoby,
21
+ které jmenuje, i s tím, kdo ze stromu to může být. Nic se nedomýšlí: jméno, které v dokumentu není, vypadne — i
22
+ příjmení, nikdy se nebere od otce ani z jiné rodiny —, co nejde přečíst, je označené; druhý čtenář může číst naslepo.
23
+ Dokument na úředním formuláři, jako rodný list, si své osoby ponechá — dítě i oba rodiče s jejich podílem —, a dokument
24
+ přečtený dřív je po aktualizaci ukáže, aniž by se četl znovu. U každé osoby a události je i místo na stránce, kde
25
+ stojí, takže aplikace Strom ukáže výřez každé z nich.
26
+ Do stromu se z toho nic nezapíše samo. Dokument už přečtený to o sobě řekne, takže se omylem nečte podruhé. Dokument
27
+ čtený znovu se ukazuje pod názvem svého souboru, nikdy pod kódem. Dokument poslaný z aplikace Strom najde agenta
28
+ stejně jako terminál — i nainstalovaného do vlastní složky, přes npm nebo Homebrew — místo hlášení, že agent není
29
+ nastavený. Čte ho každý agent, se kterým výzkum pracuje, s modelem a úsilím nastaveným pro výzkum.
27
30
  - **Fotky z telefonu.** `strom phone on`, které zapíná jen člověk, pustí k počítači telefon v domácí síti (Wi-Fi
28
31
  nebo kabel — nikdy mobilní síť ani VPN). Telefon se jednou spáruje přes QR kód; jeho stránka pak fotí nebo vybírá
29
32
  z galerie a posílá fotky celé do stromu výzkumu, s poznámkou a osobou, kterou ukazují — stejně jako „Přidat
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.18.0
9
9
 
10
- *In Vorbereitung: die Betas bis 1.18.0-beta.11. Der Abschnitt ist mit dem Release vollständig.*
10
+ *In Vorbereitung: die Betas bis 1.18.0-beta.13. Der Abschnitt ist mit dem Release vollständig.*
11
11
 
12
12
  - **Handyfotos aufrecht.** Ein Foto, das nach seiner EXIF-Ausrichtung gedreht ist, wird gelesen, wie es gesehen
13
13
  werden soll, aufrecht; die darauf markierten Stellen (Ausschnitte, die Hälften einer Doppelseite, ein gespeicherter
@@ -16,16 +16,20 @@ Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Ins
16
16
  umgewandelt — unter macOS, Windows und Linux —, das Original bleibt, wie es kam. Ein HEIC-Foto geht als
17
17
  JPEG-Ausschnitt in die Strom-App. Wo nichts auf dem Computer ein Bild umwandelt, sagt es ein Satz, auch was helfen
18
18
  würde; `strom tidy` räumt umgewandelte Bilder nach 30 Tagen oder über 500 MB weg.
19
- - **Ein Dokument auf einmal gelesen.** `strom document read` liest ein Foto, einen Scan oder eine PDF eines
20
- Dokuments — einen Matrikeleintrag, einen Brief, eine Urkunde, einen Grabstein, die Rückseite eines Fotos: die
21
- Abschrift Wort für Wort, ihre Übersetzung, was alte Wörter und Abkürzungen bedeuten, und die genannten Personen,
22
- samt wem im Stammbaum sie entsprechen können. Nichts wird ergänzt: ein Name, der nicht im Dokument steht, fällt
23
- weg — auch ein Nachname, nie vom Vater oder aus einer anderen Familie genommen —, Unleserliches wird markiert; ein
24
- zweiter Leser kann blind lesen. In den Stammbaum gelangt nichts von selbst. Ein schon gelesenes Dokument sagt es,
25
- so wird es nicht versehentlich ein zweites Mal gelesen. Ein erneut gelesenes Dokument wird unter dem Namen seiner
26
- Datei gezeigt, nie unter einem Code. Ein aus der Strom-App gesendetes Dokument findet den Agenten wie das Terminal —
27
- auch einen in einem eigenen Ordner, über npm oder Homebrew installierten —, statt zu melden, es sei kein Agent
28
- eingerichtet.
19
+ - **Ein Dokument auf einmal gelesen.** `strom document read` liest ein Foto, einen Scan oder eine PDF eines Dokuments —
20
+ einen Matrikeleintrag, einen Brief, eine Urkunde, einen Grabstein, die Rückseite eines Fotos: die Abschrift Wort für
21
+ Wort, ihre Übersetzung, was alte Wörter und Abkürzungen bedeuten, und die genannten Personen, samt wem im Stammbaum
22
+ sie entsprechen können. Nichts wird ergänzt: ein Name, der nicht im Dokument steht, fällt weg — auch ein Nachname, nie
23
+ vom Vater oder aus einer anderen Familie genommen —, Unleserliches wird markiert; ein zweiter Leser kann blind lesen.
24
+ Ein Dokument auf einem amtlichen Formular, etwa eine Geburtsurkunde, behält seine Personen — das Kind und beide
25
+ Eltern mit ihrem Anteil —, und ein früher gelesenes Dokument zeigt sie nach dem Update, ohne erneut gelesen zu werden.
26
+ Jede Person und jedes Ereignis kommt mit der Stelle auf der Seite, an der es steht, so zeigt die Strom-App von jedem
27
+ einen eigenen Ausschnitt.
28
+ In den Stammbaum gelangt nichts von selbst. Ein schon gelesenes Dokument sagt es, so wird es nicht versehentlich ein
29
+ zweites Mal gelesen. Ein erneut gelesenes Dokument wird unter dem Namen seiner Datei gezeigt, nie unter einem Code.
30
+ Ein aus der Strom-App gesendetes Dokument findet den Agenten wie das Terminal — auch einen in einem eigenen Ordner,
31
+ über npm oder Homebrew installierten —, statt zu melden, es sei kein Agent eingerichtet. Jeder Agent, mit dem die
32
+ Forschung arbeitet, liest es, mit dem Modell und dem Aufwand, die für die Forschung eingestellt sind.
29
33
  - **Fotos vom Handy.** `strom phone on`, das nur ein Mensch einschaltet, lässt ein Handy im Heimnetz (WLAN oder
30
34
  Kabel — nie ein Mobilfunknetz oder ein VPN) zu diesem Computer. Das Handy wird einmal per QR-Code gekoppelt; seine
31
35
  Seite fotografiert dann oder wählt aus der Galerie und schickt die Fotos ganz in einen Stammbaum der Forschung, mit
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.18.0
9
9
 
10
- *In preparation: the betas up to 1.18.0-beta.11. The section is complete with the release.*
10
+ *In preparation: the betas up to 1.18.0-beta.13. The section is complete with the release.*
11
11
 
12
12
  - **Phone photos upright.** A photo turned by its EXIF orientation is read as it is meant to be seen, upright; the
13
13
  places marked on it (excerpts, the halves of a spread, a cut-out part) are of the picture as it is shown. The
@@ -18,12 +18,16 @@ The changes of each version in short. The full notes and the install lines are o
18
18
  help; `strom tidy` clears the converted pictures away after 30 days or above 500 MB.
19
19
  - **One document read at once.** `strom document read` reads a photo, a scan or a PDF of one document — a register
20
20
  entry, a letter, a deed, a gravestone, the back of a photo: the transcript word for word, its translation, what its
21
- old words and abbreviations mean, and the people it names, with who of the tree they may be. Nothing is filled in:
22
- a name the document does not have is left out — a surname too, never taken from a father or another family —,
23
- what cannot be read is marked; a second reader can read it blind. Nothing of it enters the tree by itself. A
24
- document read already says so, so it is not read a second time by mistake. A document read again is shown under
25
- the name of its file, never a code. A document sent from the Strom app finds the agent as the terminal does — one
26
- installed in a folder of its own, by npm or Homebrew too — instead of saying no agent is set up.
21
+ old words and abbreviations mean, and the people it names, with who of the tree they may be. Nothing is filled in: a
22
+ name the document does not have is left out — a surname too, never taken from a father or another family —, what
23
+ cannot be read is marked; a second reader can read it blind. A
24
+ document on a printed form, as a birth certificate, keeps its people — the child and both parents with their part in
25
+ it —, and a document read before shows them after the update with nothing read again. Each person and event it names
26
+ comes with the place on the page where it stands, so the Strom app shows each its own excerpt. Nothing of it enters the tree by itself. A document read
27
+ already says so, so it is not read a second time by mistake. A document read again is shown under the name of its
28
+ file, never a code. A document sent from the Strom app finds the agent as the terminal does — one installed in a
29
+ folder of its own, by npm or Homebrew too — instead of saying no agent is set up. Every agent the research works with
30
+ reads it, with the model and effort set for the research.
27
31
  - **Photos from a phone.** `strom phone on`, turned on by the person alone, lets a phone on the home network (Wi-Fi
28
32
  or a cable — never a mobile network or a VPN) reach this computer. The phone is paired once by its QR code; its
29
33
  page then takes photos or picks them from the gallery and sends them whole into a tree of the research, with a
@@ -440,7 +440,8 @@ register({
440
440
  const own = path.join(work, `${stem}-${k + 1}`);
441
441
  fs.mkdirSync(own, { recursive: true });
442
442
  const settingsFile = path.join(own, "reader-settings.json");
443
- fs.writeFileSync(settingsFile, JSON.stringify(readerSettings(group.flatMap((g) => [g.view, ...(g.parts ?? []).map((p) => p.view)]), report, permissionPath), null, 2));
443
+ const seen = group.flatMap((g) => [g.view, ...(g.parts ?? []).map((p) => p.view)]);
444
+ fs.writeFileSync(settingsFile, JSON.stringify(readerSettings(seen, report, permissionPath), null, 2));
444
445
  const context = typeof opts.context === "string" && opts.context.trim() ? `Context: ${opts.context.trim().replace(/\s*\n\s*/g, " ")}\n` : "";
445
446
  // a blind reading says so in its head: its "unclear" over a place the researcher looked at before is a check
446
447
  const head = `# Reading ${label} · batch ${k + 1}${fetching ? "" : ` of ${groups.length}`}\nQuestion: ${question.replace(/\s*\n\s*/g, " ")}\n${context}${opts.blind ? "Blind: yes\n" : ""}\n${NEXT_BLOCK}\n`;
@@ -459,6 +460,7 @@ register({
459
460
  env: { ...withoutAgentMarks(env), STROM_NONINTERACTIVE: "1", STROM_READER: "1" },
460
461
  settingsFile,
461
462
  reader: true,
463
+ readerFiles: { views: seen, report },
462
464
  // without the user's own add-ons (skills, plugins, MCP servers) unless they said otherwise
463
465
  ...(ctx.settings.agentAddons(tree.config) ? {} : { clean: true }),
464
466
  timeoutMs: minutes * 60_000,
@@ -135,6 +135,7 @@ export function readers(ctx, tree, shared, kind, first, opts, reportsAt, own = {
135
135
  env: { ...withoutAgentMarks(env), STROM_NONINTERACTIVE: "1", STROM_READER: "1" },
136
136
  settingsFile,
137
137
  reader: true,
138
+ readerFiles: { views, report },
138
139
  // without the user's own add-ons (skills, plugins, MCP servers) unless they said otherwise
139
140
  ...(ctx.settings.agentAddons(tree.config) ? {} : { clean: true }),
140
141
  timeoutMs: limit.minutes * 60_000,
@@ -110,12 +110,15 @@ term: <a word or abbreviation as written> = <what it means, in ${into}> (term |
110
110
  — one line each, for the old words, formulas, abbreviations, occupations, and on a gravestone its symbols
111
111
  `}person: <ref> | <role> | ${blind ? "" : `<the role in ${into}> | `}written=<the name exactly as it stands in the transcript> | given=<the given
112
112
  name in today's form> | surname=<the surname in today's form> | sex=M|F|U | age=<as written> | occupation=<…> |
113
- residence=<…> — one line each person named; ref p1, p2, … in this entry; role: ${ROLES.join(", ")};
113
+ residence=<…> | region=x,y,w,h — one line each person named; ref p1, p2, … in this entry; role: ${ROLES.join(", ")};
114
114
  given and surname only turn the written name into today's form ("Joannes" → "Jan"): never a name the transcript
115
115
  does not have — no surname the entry does not write by the person (a child's is not taken from its father's);
116
- a name you could not read stays out; leave out what the entry does not say
116
+ on a form (a certificate) whose fields hold a name apart, written= the words of its fields as written there;
117
+ a name you could not read stays out; leave out what the entry does not say; region: where the person's name
118
+ stands — its line, or its field on a form — as the entry's region, of the page's whole view (tight, not the entry)
117
119
  event: <BIRT | BAPM | MARR | DEAT | BURI | RESI | EVEN> | date=<as on the record, in numbers: 3.4.1846, or a year> |
118
- place=<the place as written> | persons=<ref>:<principal|father|mother|godparent|witness|groom|bride|deceased|other>,…
120
+ place=<the place as written> | persons=<ref>:<principal|father|mother|godparent|witness|groom|bride|deceased|other>,… |
121
+ region=x,y,w,h — where its date (and place) stand, as a person's region
119
122
  relation: parent <ref of the parent> <ref of the child> — or — spouse <ref> <ref>
120
123
  ${blind
121
124
  ? ""
@@ -259,12 +262,18 @@ export function parseDocReport(text) {
259
262
  const ref = /^p\d+$/iu.test(parts[0].trim()) ? parts.shift().trim().toLowerCase() : `p${entry.persons.length + 1}`;
260
263
  const role = parts[0] && !parts[0].includes("=") ? parts.shift().trim().toLowerCase() : "other";
261
264
  const roleText = parts[0] && !parts[0].includes("=") ? parts.shift().trim() : undefined;
262
- entry.persons.push({ ref, role, ...(roleText ? { roleText } : {}), fields: pairs(parts) });
265
+ const fields = pairs(parts);
266
+ const region = parseRegion(fields.region);
267
+ delete fields.region;
268
+ entry.persons.push({ ref, role, ...(roleText ? { roleText } : {}), ...(region ? { region } : {}), fields });
263
269
  }
264
270
  for (const e of fields.get("event") ?? []) {
265
271
  const parts = e.split(/\s*\|\s*/u);
266
272
  const type = parts[0] && !parts[0].includes("=") ? parts.shift().trim() : "EVEN";
267
- entry.events.push({ type, fields: pairs(parts) });
273
+ const fields = pairs(parts);
274
+ const region = parseRegion(fields.region);
275
+ delete fields.region;
276
+ entry.events.push({ type, ...(region ? { region } : {}), fields });
268
277
  }
269
278
  for (const r of fields.get("relation") ?? []) {
270
279
  const w = r.split(/[\s,]+/u).filter(Boolean);
@@ -280,6 +289,9 @@ export function parseDocReport(text) {
280
289
  /** A kind a reader wrote in its own words: the nearest of the list. */
281
290
  function kindOf(said) {
282
291
  const f = foldText(said);
292
+ // a certificate first: a birth certificate is no register of births ("rodný list", "Geburtsurkunde", "odpis aktu")
293
+ if (/certif|urkunde|zaswiadcz|odpis|extrait|auszug|vypis|(?:rodn|oddac|umrtn)\p{L}*\s+list/u.test(f))
294
+ return "certificate";
283
295
  if (/bapt|krest|tauf|chrzest|christen/u.test(f))
284
296
  return "register.baptism";
285
297
  if (/birth|narozen|geburt|urodzen/u.test(f))
@@ -327,6 +339,29 @@ function find(whole, part) {
327
339
  }
328
340
  return -1;
329
341
  }
342
+ /**
343
+ * Where the words of `part` stand in `whole` apart — a form's fields, the surname in one and the given names in
344
+ * another —: each at a place of its own, after the word before where it can be; undefined when one is not there
345
+ * (a word not read, "[...]", is never found apart).
346
+ */
347
+ function findApart(whole, part) {
348
+ if (!part.length || part.some((w) => w.illegible))
349
+ return undefined;
350
+ const used = new Set();
351
+ const out = [];
352
+ let from = 0;
353
+ for (const w of part) {
354
+ let i = whole.findIndex((x, k) => k >= from && !used.has(k) && x.key === w.key);
355
+ if (i < 0)
356
+ i = whole.findIndex((x, k) => !used.has(k) && x.key === w.key);
357
+ if (i < 0)
358
+ return undefined;
359
+ used.add(i);
360
+ out.push(whole[i]);
361
+ from = i + 1;
362
+ }
363
+ return out;
364
+ }
330
365
  /** A name's word in letters alone (folded): Latin and old spellings as today's (ph f, th t, w v, y i, j i, ae e). */
331
366
  function spelled(key) {
332
367
  return key
@@ -458,7 +493,8 @@ export function gedcomDate(text) {
458
493
  }
459
494
  /**
460
495
  * A reader's entry as strom gives it on: each person's written name must stand in the transcript (folded — no
461
- * accents, no case — as words in a row), else the person is left out; its surname must be a form of one of those
496
+ * accents, no case — as words in a row; on a form, `form`, each word in a field of its own), else the person is left
497
+ * out; its surname must be a form of one of those
462
498
  * words in the form of the person's sex, else it is left out (a surname of another person, of the parish's head, of
463
499
  * nowhere, a woman's form for a man written in a man's); its given name in
464
500
  * today's form must be of the letters of the others, else the name is as written and the reader's form a lead
@@ -466,7 +502,7 @@ export function gedcomDate(text) {
466
502
  * transcript marks "[?]" is uncertain, one it marks "[...]" all through is left out (the reader cannot have read it);
467
503
  * a date of "[...]" has no GEDCOM; references to people left out go too. What was left out is said (checks).
468
504
  */
469
- export function checkEntry(raw, id, blind = false) {
505
+ export function checkEntry(raw, id, blind = false, form = false) {
470
506
  const checks = [];
471
507
  const text = words(raw.transcript);
472
508
  const persons = [];
@@ -478,11 +514,13 @@ export function checkEntry(raw, id, blind = false) {
478
514
  }
479
515
  const w = words(written);
480
516
  const at = find(text, w);
481
- if (at < 0) {
517
+ // a form (a certificate) holds a name in its fields apart — the surname in one, the given names in another —:
518
+ // its words each written there are the name; elsewhere a name stands in a row as written
519
+ const span = at >= 0 ? text.slice(at, at + w.length) : form ? findApart(text, w) : undefined;
520
+ if (!span) {
482
521
  checks.push({ entry: id, ref: p.ref, what: written, why: "not-in-transcript" });
483
522
  continue;
484
523
  }
485
- const span = text.slice(at, at + w.length);
486
524
  const role = (ROLES.includes(p.role) ? p.role : "other");
487
525
  const person = { ref: p.ref, role, written: span.map((x) => x.text).join(" ") };
488
526
  if (p.roleText && !blind)
@@ -544,6 +582,9 @@ export function checkEntry(raw, id, blind = false) {
544
582
  }
545
583
  if (uncertain.size)
546
584
  person.uncertain = [...uncertain];
585
+ const region = partOf(p.region, raw.region);
586
+ if (region)
587
+ person.region = region;
547
588
  persons.push(person);
548
589
  }
549
590
  const refs = new Set(persons.map((p) => p.ref));
@@ -573,6 +614,9 @@ export function checkEntry(raw, id, blind = false) {
573
614
  }
574
615
  if (uncertain.size)
575
616
  ev.uncertain = [...uncertain];
617
+ const region = partOf(e.region, raw.region);
618
+ if (region)
619
+ ev.region = region;
576
620
  events.push(ev);
577
621
  }
578
622
  const relations = [];
@@ -593,6 +637,24 @@ export function checkEntry(raw, id, blind = false) {
593
637
  entry.glossary = raw.terms;
594
638
  return { entry, checks };
595
639
  }
640
+ /** A name's or a date's line is no more than a quarter of the page. */
641
+ const PART_AREA = 0.25;
642
+ /** How far a part may lie out of its entry's region (a reader's regions are rough). */
643
+ const PART_SLACK = 0.05;
644
+ /**
645
+ * A person's or an event's region as a reader gave it, where it can be one: a part of the page no bigger than a
646
+ * quarter of it, that lies (roughly) in its entry's region; else none — the app shows the entry's region then.
647
+ */
648
+ function partOf(r, entry) {
649
+ if (!r || r[2] * r[3] > PART_AREA)
650
+ return undefined;
651
+ if (!entry)
652
+ return r;
653
+ const [x, y, w, h] = [entry[0] - PART_SLACK, entry[1] - PART_SLACK, entry[2] + 2 * PART_SLACK, entry[3] + 2 * PART_SLACK];
654
+ const across = Math.min(r[0] + r[2], x + w) - Math.max(r[0], x);
655
+ const down = Math.min(r[1] + r[3], y + h) - Math.max(r[1], y);
656
+ return across > 0 && down > 0 ? r : undefined;
657
+ }
596
658
  // ── two readers ───────────────────────────────────────────────────────────────────────────────────────────────────
597
659
  /** The hunks where two transcripts differ, word by word (their keys; the marks [?] are no difference). */
598
660
  export function wordDiff(a, b) {
@@ -630,6 +692,46 @@ export function wordDiff(a, b) {
630
692
  return out;
631
693
  }
632
694
  const same = (a, b) => foldText(a ?? "") === foldText(b ?? "");
695
+ /**
696
+ * The people of one reader paired with the other's by the name written, the best match first: the whole name alike,
697
+ * else the most of its words shared, a word of the surname weighing more. A person two match equally well (a father
698
+ * and his son of one given name, two of the same name) is paired with none — missing on one side rather than another
699
+ * one's. Index in `mine` → index in `theirs`.
700
+ */
701
+ function pairByName(mine, theirs) {
702
+ const keys = (p) => words(p.written).filter((x) => !x.illegible).map((x) => x.key);
703
+ const surname = (p) => words(p.surname ?? "").map((x) => x.key);
704
+ const WHOLE = 1000;
705
+ const score = (p, q) => {
706
+ const a = keys(p);
707
+ const b = keys(q);
708
+ if (a.length && a.join(" ") === b.join(" "))
709
+ return WHOLE;
710
+ const other = new Set(b);
711
+ const sur = new Set([...surname(p), ...surname(q)]);
712
+ let n = 0;
713
+ for (const k of new Set(a))
714
+ if (other.has(k))
715
+ n += sur.has(k) ? 2 : 1;
716
+ return n;
717
+ };
718
+ let open = mine.flatMap((p, i) => theirs.map((q, j) => ({ i, j, s: score(p, q) }))).filter((x) => x.s > 0);
719
+ const out = new Map();
720
+ while (open.length) {
721
+ const best = Math.max(...open.map((x) => x.s));
722
+ const top = open.filter((x) => x.s === best);
723
+ const done = { i: new Set(), j: new Set() };
724
+ for (const x of top) {
725
+ // equally good for either side: a tie, neither paired
726
+ if (!top.some((y) => y !== x && (y.i === x.i || y.j === x.j)))
727
+ out.set(x.i, x.j);
728
+ done.i.add(x.i);
729
+ done.j.add(x.j);
730
+ }
731
+ open = open.filter((x) => !done.i.has(x.i) && !done.j.has(x.j));
732
+ }
733
+ return out;
734
+ }
633
735
  /**
634
736
  * The first reader's entries against the blind reader's: paired by page and order; the transcripts word by word, the
635
737
  * people by role and order (given name, surname), the events by type and order (date, place). Where they differ is a
@@ -650,6 +752,7 @@ export function compareReadings(first, blind) {
650
752
  used.set(e.page, k + 1);
651
753
  const b = other.get(e.page)?.[k];
652
754
  const variants = [];
755
+ const regions = new Map();
653
756
  if (!b)
654
757
  variants.push({ field: "entry", at: e.id, other: "" });
655
758
  else {
@@ -663,20 +766,41 @@ export function compareReadings(first, blind) {
663
766
  };
664
767
  const theirs = groups(b.persons);
665
768
  const seen = new Map();
769
+ const fields = (p, q) => {
770
+ for (const f of ["given", "surname"])
771
+ if (!same(p[f], q[f]))
772
+ variants.push({ field: `${p.ref}.${f}`, at: p[f] ?? "", other: q[f] ?? "" });
773
+ // where the name stands: the first reader's, else the blind one's of the same person
774
+ if (!p.region && q.region)
775
+ regions.set(p, q.region);
776
+ };
777
+ const alone = [];
666
778
  for (const p of e.persons) {
667
779
  const i = seen.get(p.role) ?? 0;
668
780
  seen.set(p.role, i + 1);
669
781
  const q = theirs.get(p.role)?.[i];
670
- if (!q) {
782
+ if (q)
783
+ fields(p, q);
784
+ else
785
+ alone.push(p);
786
+ }
787
+ // a person the blind reader gave another role (other / writer): the same one by the name written, the role a
788
+ // variant as its other fields
789
+ const left = [...theirs].flatMap(([role, qs]) => qs.slice(seen.get(role) ?? 0));
790
+ const pairs = pairByName(alone, left);
791
+ for (const [i, p] of alone.entries()) {
792
+ const j = pairs.get(i);
793
+ if (j === undefined) {
671
794
  variants.push({ field: `${p.ref}`, at: [p.given, p.surname].filter(Boolean).join(" ") || p.written, other: "" });
672
795
  continue;
673
796
  }
674
- for (const f of ["given", "surname"])
675
- if (!same(p[f], q[f]))
676
- variants.push({ field: `${p.ref}.${f}`, at: p[f] ?? "", other: q[f] ?? "" });
797
+ const q = left[j];
798
+ variants.push({ field: `${p.ref}.role`, at: p.role, other: q.role });
799
+ fields(p, q);
677
800
  }
678
- for (const [role, qs] of theirs)
679
- for (const q of qs.slice(seen.get(role) ?? 0))
801
+ const taken = new Set(pairs.values());
802
+ for (const [j, q] of left.entries())
803
+ if (!taken.has(j))
680
804
  variants.push({ field: "person", at: "", other: [q.given, q.surname].filter(Boolean).join(" ") || q.written });
681
805
  const evs = new Map();
682
806
  for (const ev of e.events) {
@@ -687,6 +811,8 @@ export function compareReadings(first, blind) {
687
811
  variants.push({ field: ev.type, at: ev.date?.text ?? ev.type, other: "" });
688
812
  continue;
689
813
  }
814
+ if (!ev.region && q.region)
815
+ regions.set(ev, q.region);
690
816
  if ((ev.date?.gedcom ?? ev.date?.text) !== (q.date?.gedcom ?? q.date?.text) && !same(ev.date?.text, q.date?.text))
691
817
  variants.push({ field: `${ev.type}.date`, at: ev.date?.text ?? "", other: q.date?.text ?? "" });
692
818
  if (!same(ev.place?.text, q.place?.text))
@@ -696,7 +822,8 @@ export function compareReadings(first, blind) {
696
822
  const agree = variants.length ? "differ" : "same";
697
823
  if (variants.length)
698
824
  all = false;
699
- return { ...e, agree, ...(variants.length ? { variants } : {}) };
825
+ const placed = (x) => (regions.has(x) ? { ...x, region: regions.get(x) } : x);
826
+ return { ...e, persons: e.persons.map(placed), events: e.events.map(placed), agree, ...(variants.length ? { variants } : {}) };
700
827
  });
701
828
  // entries only the blind reader has
702
829
  for (const [page, list] of other)
@@ -881,6 +1008,37 @@ export function alive(pid) {
881
1008
  }
882
1009
  }
883
1010
  const LEGIBLE_ORDER = { yes: 0, partly: 1, no: 2 };
1011
+ /** A variant of a person's role (`p2.role`): no words of the transcript. */
1012
+ const ROLE_VARIANT = /^[\p{L}\p{N}_-]+\.role$/u;
1013
+ /**
1014
+ * The result as the Strom app is given it (GET /read/<id>): a variant of a person's role is no words of the
1015
+ * transcript, and the app (3.14) marks each variant where its words stand in the transcript and counts it as a
1016
+ * difference — a role code ("other") stood inside "Mother". So the role the blind reader gave goes to the person
1017
+ * (`blindRole`) and out of `variants`; the result kept and `strom document show` keep it as a variant.
1018
+ */
1019
+ export function resultForApp(r) {
1020
+ return {
1021
+ ...r,
1022
+ entries: r.entries.map((e) => {
1023
+ const roles = new Map();
1024
+ for (const v of e.variants ?? [])
1025
+ if (ROLE_VARIANT.test(v.field))
1026
+ roles.set(v.field.slice(0, -".role".length), v.other);
1027
+ if (!roles.size)
1028
+ return e;
1029
+ const variants = (e.variants ?? []).filter((v) => !ROLE_VARIANT.test(v.field));
1030
+ const { variants: _, ...rest } = e;
1031
+ return {
1032
+ ...rest,
1033
+ ...(variants.length ? { variants } : {}),
1034
+ persons: e.persons.map((p) => {
1035
+ const other = roles.get(p.ref);
1036
+ return other && ROLES.includes(other) ? { ...p, blindRole: other } : p;
1037
+ }),
1038
+ };
1039
+ }),
1040
+ };
1041
+ }
884
1042
  /**
885
1043
  * The result of a reading as it stands (while its readers write too: what they wrote so far): the first reader's
886
1044
  * entries checked and matched with the tree, the blind reader's held against them.
@@ -921,13 +1079,19 @@ export function buildResult(tree, s) {
921
1079
  }
922
1080
  for (const e of rep?.entries ?? []) {
923
1081
  const id = `e${out.length + 1}`;
924
- const c = checkEntry(e, id, blind);
1082
+ const c = checkEntry(e, id, blind, rep?.doc.kind === "certificate");
925
1083
  if (!blind)
926
1084
  checks.push(...c.checks);
927
1085
  // a region of a part asked (--region): of the whole page
928
1086
  const page = s.pages.find((p) => p.n === e.page);
929
- if (c.entry.region && page?.region)
930
- c.entry.region = within(page.region, c.entry.region);
1087
+ if (page?.region) {
1088
+ const part = page.region;
1089
+ if (c.entry.region)
1090
+ c.entry.region = within(part, c.entry.region);
1091
+ for (const x of [...c.entry.persons, ...c.entry.events])
1092
+ if (x.region)
1093
+ x.region = within(part, x.region);
1094
+ }
931
1095
  out.push(c.entry);
932
1096
  }
933
1097
  });
@@ -15,7 +15,7 @@ import path from "node:path";
15
15
  import { spawn } from "node:child_process";
16
16
  import { newerTree, Tree } from "./tree.js";
17
17
  import { actsAsArchive } from "./state.js";
18
- import { APP_READ_FROM, alive, backBefore, buildResult, DOC_PAGES, FINAL_STATES, loadState, parseRegion, READ_ID, readsDir, reportPath, resultPath, saveState, shownName, statePath } from "./document.js";
18
+ import { APP_READ_FROM, alive, backBefore, buildResult, DOC_PAGES, FINAL_STATES, loadState, parseRegion, READ_ID, readsDir, reportPath, resultForApp, resultPath, saveState, shownName, statePath } from "./document.js";
19
19
  import { knownOriginal } from "./originals.js";
20
20
  import { readableTypes } from "./readable.js";
21
21
  import { makeView } from "./views.js";
@@ -33,8 +33,6 @@ export const APP_READ_DEFAULT = { hour: 20, day: 60 };
33
33
  export const READ_QUEUE_MAX = 3;
34
34
  /** The largest file read (a page is read at 2000 px at most: a larger file adds nothing). */
35
35
  export const READ_MAX_BYTES = 50 * 1024 * 1024;
36
- /** The agents whose reader of documents was tried (the others wait for a live test: why agent.untested). */
37
- export const READ_TESTED = ["claude", "script"];
38
36
  /** The longest side of a page's picture for the app. */
39
37
  const PAGE_PX = 2000;
40
38
  const DAY_MS = 24 * 3600_000;
@@ -283,8 +281,6 @@ function notReady(tree, settings, env, seen, now) {
283
281
  const known = AGENTS.find((a) => a.id === agent);
284
282
  if (agent !== "script" && (!known || !findAgent(known.command, env)))
285
283
  return { why: "agent.none" };
286
- if (!READ_TESTED.includes(agent))
287
- return { why: "agent.untested" };
288
284
  // the last reading that ended: its agent out of its limit, or not signed in — until another reads
289
285
  const last = (seen ?? new ReadingsSeen(tree.root, env)).all().find((r) => r.state && FINAL_STATES.includes(r.state.state))?.state;
290
286
  if (last?.state === "failed") {
@@ -397,7 +393,8 @@ export class AppReadings {
397
393
  const n = this.none(id);
398
394
  return this.json(res, n.code, n.body);
399
395
  }
400
- this.json(res, 200, { ...got.result, pages: got.result.pages.map((p) => ({ ...p, image: `read/${id}/page/${p.n}.jpg` })) });
396
+ const r = resultForApp(got.result);
397
+ this.json(res, 200, { ...r, pages: r.pages.map((p) => ({ ...p, image: `read/${id}/page/${p.n}.jpg` })) });
401
398
  }
402
399
  /** GET /read/<id>/page/<n>.jpg: the page upright as a JPEG of 2000 px at most (a HEIC's, a PDF's page too). */
403
400
  page(res, id, rest) {
@@ -13,10 +13,17 @@ import { shownToFile } from "../image/image.js";
13
13
  import { inputPath, mimeOf, storeShared } from "./media.js";
14
14
  import { create, update } from "./records.js";
15
15
  import { foldText } from "./text.js";
16
- /** The reading's result as the research keeps it: its finished result (in git), else its state here when done. */
16
+ /**
17
+ * The reading's result as the research keeps it: read here, from its readers' reports as the app is shown it (GET
18
+ * /read/<id>: a newer strom's checks on reports read before), else its finished result (in git — read on another
19
+ * computer).
20
+ */
17
21
  export function readingResult(tree, id) {
18
22
  if (!READ_ID.test(id))
19
23
  return undefined;
24
+ const s = loadState(tree.root, id);
25
+ if (s?.state === "done")
26
+ return buildResult(tree, s);
20
27
  try {
21
28
  const file = resultPath(tree.root, id);
22
29
  if (fs.existsSync(file))
@@ -25,8 +32,7 @@ export function readingResult(tree, id) {
25
32
  catch {
26
33
  // an unreadable result is none
27
34
  }
28
- const s = loadState(tree.root, id);
29
- return s?.state === "done" ? buildResult(tree, s) : undefined;
35
+ return undefined;
30
36
  }
31
37
  /**
32
38
  * The readings of an input (Input.read) as an agent is told of them: each one's ID and state here (its state on this
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.18.0-beta.11";
30
+ export const VERSION = "1.18.0-beta.13";
31
31
  const GITIGNORE = `# Strom Research — local state and large files are not versioned
32
32
  .strom/
33
33
  /media/
@@ -21,6 +21,7 @@ import path from "node:path";
21
21
  import { addResponses, heardResponse, runJsonLines, webUse } from "./jsonl.js";
22
22
  import { usageNumber, tellUsage, tellWeb } from "./runner.js";
23
23
  import { effortArgs } from "../agents/effort.js";
24
+ import { DOC_NEXT } from "../core/document.js";
24
25
  /** Command-line arguments of a headless run (exported for tests); `first` is its first message. */
25
26
  export function antigravityArgs(opts, first) {
26
27
  return [
@@ -36,6 +37,30 @@ export function antigravityArgs(opts, first) {
36
37
  ...(opts.extraArgs ?? []),
37
38
  ];
38
39
  }
40
+ /**
41
+ * A reader in agy: its views in its workspace (--add-dir: read without asking); never a skipped permission, whatever the
42
+ * level. agy refuses every file it would write in a headless run, its own folder's too (only rules in the person's
43
+ * settings would allow one, and strom writes none there): its report is its answer, which strom adds to the report
44
+ * (found live 2026-10-20 — the views refused outside its folder, then the report).
45
+ */
46
+ export function antigravityReaderArgs(opts, files, first) {
47
+ const dirs = [...new Set(files.views.map((v) => path.dirname(path.resolve(v))))];
48
+ return antigravityArgs({ ...opts, permissions: "auto" }, first).flatMap((a, i, all) => (i === all.indexOf("stream-json") ? [a, ...dirs.flatMap((d) => ["--add-dir", d])] : [a]));
49
+ }
50
+ /** What a reader in agy is told beside its task: the report as its answer (exported for tests). */
51
+ export function antigravityReaderNote(report) {
52
+ return (`\n\nHERE: this agent cannot write files. Do not edit ${report}: give every block you would add to it, in order, ` +
53
+ "as your final answer — the blocks alone, nothing before or after them — and strom adds them to the report.");
54
+ }
55
+ /** A reader's answer added to its report at its last line (DOC_NEXT), when it added nothing itself (exported for tests). */
56
+ export function addReaderAnswer(report, answer, next) {
57
+ const text = fs.existsSync(report) ? fs.readFileSync(report, "utf8") : "";
58
+ const at = text.lastIndexOf(next);
59
+ if (at < 0 || !answer.trim() || text.slice(0, at).includes("## Document"))
60
+ return false;
61
+ fs.writeFileSync(report, `${text.slice(0, at)}${answer.trim()}\n${text.slice(at)}`);
62
+ return true;
63
+ }
39
64
  /** The first message of a run whose brief is in a file (exported for tests). */
40
65
  export function antigravityFirstMessage(briefFile) {
41
66
  return (`Your brief for this strom session is the file ${briefFile}. Read that whole file first, every line of it ` +
@@ -94,6 +119,16 @@ export const antigravityRunner = {
94
119
  resume: (id, message, timeoutMs) => ({ args: antigravityGoOnArgs({ ...opts, timeoutMs }, id, message), input: "" }),
95
120
  };
96
121
  const web = { seen: new Set(), on: (w) => tellWeb(opts, w) };
122
+ const reader = opts.readerFiles;
123
+ if (reader) {
124
+ // a reader: its report is its answer, and it is never told to go on through strom
125
+ const args = antigravityReaderArgs(opts, reader, first + antigravityReaderNote(reader.report));
126
+ return runJsonLines("agy", args, opts.env, { ...opts, prompt: "" }, undefined, (msg, heard) => readEvent(msg, heard, said, opts.onProgress, opts.onUsage, web), { totalsAtEnd: true }).then((r) => {
127
+ if (r.outcome === "ok")
128
+ addReaderAnswer(reader.report, r.text, DOC_NEXT);
129
+ return r;
130
+ });
131
+ }
97
132
  return runJsonLines("agy", antigravityArgs(opts, first), opts.env, { ...opts, prompt: "" }, undefined, (msg, heard) => readEvent(msg, heard, said, opts.onProgress, opts.onUsage, web), { totalsAtEnd: true, goOn });
98
133
  },
99
134
  };
@@ -18,6 +18,14 @@ import { isolated } from "../core/paths.js";
18
18
  export function codexWritable(root, shared) {
19
19
  return ["-c", `sandbox_workspace_write.writable_roots=${JSON.stringify([path.join(root, ".git"), ...(shared ? [shared] : [])])}`];
20
20
  }
21
+ /**
22
+ * A reader's sandbox: the folder of its report the one place it writes besides the folder it works in (found live: the
23
+ * report in the tree's notes/readings was refused, "writing outside of the project"), and no network — it reads the
24
+ * views it is given, it fetches nothing. Whatever the person's level: a reader opens its views and writes its report.
25
+ */
26
+ export function codexReaderWritable(files) {
27
+ return ["-c", `sandbox_workspace_write.writable_roots=${JSON.stringify([path.dirname(path.resolve(files.report))])}`];
28
+ }
21
29
  /**
22
30
  * The folder Codex works in, trusted for this process alone in an isolated installation: a session Codex starts in a
23
31
  * folder its config does not trust it records as trusted in ~/.codex/config.toml (`[projects."<folder>"]`, found
@@ -47,9 +55,11 @@ export function codexTrust(env, cwd) {
47
55
  export const CODEX_CLEAN = ["-c", "features.plugins=false", "-c", "features.apps=false", "-c", "features.hooks=false", "-c", "features.memories=false"];
48
56
  /** Command-line arguments of a headless run (exported for tests). */
49
57
  export function codexArgs(opts) {
50
- const sandbox = opts.permissions === "full"
51
- ? ["--dangerously-bypass-approvals-and-sandbox"]
52
- : ["--sandbox", "workspace-write", "-c", "sandbox_workspace_write.network_access=true", ...codexWritable(opts.cwd, opts.shared)];
58
+ const sandbox = opts.readerFiles
59
+ ? ["--sandbox", "workspace-write", ...codexReaderWritable(opts.readerFiles)]
60
+ : opts.permissions === "full"
61
+ ? ["--dangerously-bypass-approvals-and-sandbox"]
62
+ : ["--sandbox", "workspace-write", "-c", "sandbox_workspace_write.network_access=true", ...codexWritable(opts.cwd, opts.shared)];
53
63
  return ["exec", "--json", "--skip-git-repo-check", ...sandbox, ...codexTrust(opts.env ?? {}, opts.cwd), ...(opts.clean ? CODEX_CLEAN : []), ...(opts.model ? ["--model", opts.model] : []), ...effortArgs("codex", opts.effort), ...(opts.extraArgs ?? []), "-"];
54
64
  }
55
65
  /**
@@ -57,9 +67,11 @@ export function codexArgs(opts) {
57
67
  * --sandbox or --add-dir: the same sandbox is set through its configuration.
58
68
  */
59
69
  export function codexResumeArgs(id, opts) {
60
- const sandbox = opts.permissions === "full"
61
- ? ["--dangerously-bypass-approvals-and-sandbox"]
62
- : ["-c", 'sandbox_mode="workspace-write"', "-c", "sandbox_workspace_write.network_access=true", ...codexWritable(opts.cwd, opts.shared)];
70
+ const sandbox = opts.readerFiles
71
+ ? ["-c", 'sandbox_mode="workspace-write"', ...codexReaderWritable(opts.readerFiles)]
72
+ : opts.permissions === "full"
73
+ ? ["--dangerously-bypass-approvals-and-sandbox"]
74
+ : ["-c", 'sandbox_mode="workspace-write"', "-c", "sandbox_workspace_write.network_access=true", ...codexWritable(opts.cwd, opts.shared)];
63
75
  return ["exec", "resume", "--json", "--skip-git-repo-check", ...sandbox, ...codexTrust(opts.env ?? {}, opts.cwd), ...(opts.clean ? CODEX_CLEAN : []), ...(opts.model ? ["--model", opts.model] : []), ...effortArgs("codex", opts.effort), id, "-"];
64
76
  }
65
77
  /**
@@ -35,6 +35,14 @@ export function grokTrust(env, reader) {
35
35
  return { args: [], env: {} };
36
36
  return isolated(env) ? { args: [], env: { GROK_FOLDER_TRUST: "0" } } : { args: ["--trust"], env: {} };
37
37
  }
38
+ /**
39
+ * A reader's report, the one file it may write: dontAsk refuses an edit outside the folder it works in, and a reader
40
+ * works beside its views, its report in the tree (found live 2026-10-20: the report refused, the reading lost).
41
+ */
42
+ export function grokReaderAllow(files) {
43
+ const report = path.resolve(files.report).replace(/\\/g, "/");
44
+ return ["--allow", `Edit(${report})`, "--allow", `Write(${report})`];
45
+ }
38
46
  /** Command-line arguments of a headless run (exported for tests). */
39
47
  export function grokArgs(opts, prompt, session) {
40
48
  return [
@@ -42,7 +50,7 @@ export function grokArgs(opts, prompt, session) {
42
50
  ...prompt,
43
51
  "--output-format",
44
52
  "streaming-json",
45
- ...(opts.permissions === "full" ? ["--always-approve"] : ["--permission-mode", "dontAsk"]),
53
+ ...(opts.readerFiles ? ["--permission-mode", "dontAsk", ...grokReaderAllow(opts.readerFiles)] : opts.permissions === "full" ? ["--always-approve"] : ["--permission-mode", "dontAsk"]),
46
54
  ...session,
47
55
  ...(opts.model ? ["--model", opts.model] : []),
48
56
  ...effortArgs("grok", opts.effort),
@@ -7,11 +7,48 @@
7
7
  // (everything the rules do not deny). Events are JSON lines: text, tool_use,
8
8
  // step_start, step_finish (tokens and cost of a step), error; each names its
9
9
  // session (sessionID), which `--session` resumes.
10
+ import path from "node:path";
10
11
  import { runJsonLines, webUse } from "./jsonl.js";
11
12
  import { OPENCODE_RUN_AGENT } from "../agents/files.js";
12
13
  import { usageNumber, tellUsage, tellWeb } from "./runner.js";
13
14
  /** OpenCode's own web tools (OpenCode 2: webfetch with its url, websearch with its query), counted once each call is done. */
14
15
  export const OPENCODE_WEB = { page: ["webfetch"], search: ["websearch"] };
16
+ /** OpenCode's agent of a reader, given for its run alone (OPENCODE_CONFIG_CONTENT): no tree's config is where it works. */
17
+ export const OPENCODE_READER_AGENT = "strom-reader";
18
+ /** A reader's message: its task comes on stdin, joined with it. */
19
+ const READER_KICKOFF = "Your task is above: open the pages it names and write your report as it says.";
20
+ /**
21
+ * A reader's agent (exported for tests): it opens its views and edits its report — every other file, the shell, the
22
+ * web, subagents and questions refused (refused, never asked: OpenCode 2 ends a headless run at a question). Paths with
23
+ * forward slashes, as OpenCode matches them.
24
+ */
25
+ export function opencodeReaderConfig(files) {
26
+ const abs = (p) => path.resolve(p).replace(/\\/g, "/");
27
+ const only = (paths) => ({ "*": "deny", ...Object.fromEntries(paths.map((p) => [p, "allow"])) });
28
+ const report = abs(files.report);
29
+ const views = files.views.map(abs);
30
+ return {
31
+ agent: {
32
+ [OPENCODE_READER_AGENT]: {
33
+ mode: "primary",
34
+ description: "a reader of strom: opens the views it is given, writes its report — nothing else",
35
+ permission: {
36
+ read: only([...views, report]),
37
+ edit: only([report]),
38
+ external_directory: only([...new Set([...views, report].map((f) => `${path.posix.dirname(f)}/*`))]),
39
+ ...Object.fromEntries(["bash", "webfetch", "websearch", "task", "question", "skill", "todowrite", "glob", "grep", "list"].map((t) => [t, "deny"])),
40
+ },
41
+ },
42
+ },
43
+ };
44
+ }
45
+ /**
46
+ * Command-line arguments of a reader (exported for tests): a server of its own (--standalone — never the person's
47
+ * background service, which another installation's settings may have started: found live, its port taken), its agent.
48
+ */
49
+ export function opencodeReaderArgs(opts) {
50
+ return ["run", "--standalone", "--format", "json", "--agent", OPENCODE_READER_AGENT, ...(opts.model ? ["--model", opts.model] : []), ...(opts.extraArgs ?? []), READER_KICKOFF];
51
+ }
15
52
  /** Command-line arguments of a headless run (exported for tests). */
16
53
  export function opencodeArgs(opts) {
17
54
  const level = opts.permissions === "full" ? ["--auto"] : ["--agent", OPENCODE_RUN_AGENT];
@@ -21,14 +58,17 @@ export const opencodeRunner = {
21
58
  id: "opencode",
22
59
  command: "opencode",
23
60
  run(opts) {
24
- const args = opencodeArgs({ ...opts, kickoff: "Your brief is above. Work only through `strom` in this folder, as it says." });
61
+ // a reader: its own agent and rules, given for this run (no tree's opencode.json where it works)
62
+ const reader = opts.readerFiles;
63
+ const env = reader ? { ...opts.env, OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeReaderConfig(reader)) } : opts.env;
64
+ const args = reader ? opencodeReaderArgs(opts) : opencodeArgs({ ...opts, kickoff: "Your brief is above. Work only through `strom` in this folder, as it says." });
25
65
  const tokens = { input: 0, output: 0, cacheRead: 0 };
26
66
  let main;
27
67
  // the web tools' calls passed on (a call is said again as its state changes)
28
68
  const webSeen = new Set();
29
69
  // Resumed: its session by id, the message as the prompt.
30
- const resume = (id, message) => ({ args: [...opencodeArgs({ ...opts, kickoff: message }).slice(0, -1), "--session", id, message], input: "" });
31
- return runJsonLines("opencode", args, opts.env, opts, resume, (msg, heard) => {
70
+ const resume = (id, message) => ({ args: [...(reader ? opencodeReaderArgs(opts) : opencodeArgs({ ...opts, kickoff: message })).slice(0, -1), "--session", id, message], input: "" });
71
+ return runJsonLines("opencode", args, env, opts, resume, (msg, heard) => {
32
72
  const part = (msg.part ?? {});
33
73
  // the run's own session is the first one named; another one is a subagent's (its scan reader) — the run's own
34
74
  // is the one resumed at the time limit, never the subagent's that spoke last
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strom-research",
3
- "version": "1.18.0-beta.11",
3
+ "version": "1.18.0-beta.13",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {