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

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.12. 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,16 @@ 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.
25
+ 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
26
+ čtený znovu se ukazuje pod názvem svého souboru, nikdy pod kódem. Dokument poslaný z aplikace Strom najde agenta
27
+ stejně jako terminál — i nainstalovaného do vlastní složky, přes npm nebo Homebrew — místo hlášení, že agent není
28
+ nastavený. Čte ho každý agent, se kterým výzkum pracuje, s modelem a úsilím nastaveným pro výzkum.
27
29
  - **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
30
  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
31
  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.12. 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,18 @@ 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
+ In den Stammbaum gelangt nichts von selbst. Ein schon gelesenes Dokument sagt es, so wird es nicht versehentlich ein
27
+ zweites Mal gelesen. Ein erneut gelesenes Dokument wird unter dem Namen seiner Datei gezeigt, nie unter einem Code.
28
+ Ein aus der Strom-App gesendetes Dokument findet den Agenten wie das Terminal — auch einen in einem eigenen Ordner,
29
+ über npm oder Homebrew installierten —, statt zu melden, es sei kein Agent eingerichtet. Jeder Agent, mit dem die
30
+ Forschung arbeitet, liest es, mit dem Modell und dem Aufwand, die für die Forschung eingestellt sind.
29
31
  - **Fotos vom Handy.** `strom phone on`, das nur ein Mensch einschaltet, lässt ein Handy im Heimnetz (WLAN oder
30
32
  Kabel — nie ein Mobilfunknetz oder ein VPN) zu diesem Computer. Das Handy wird einmal per QR-Code gekoppelt; seine
31
33
  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.12. 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,15 @@ 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. Nothing of it enters the tree by itself. A document read
26
+ already says so, so it is not read a second time by mistake. A document read again is shown under the name of its
27
+ file, never a code. A document sent from the Strom app finds the agent as the terminal does — one installed in a
28
+ folder of its own, by npm or Homebrew too — instead of saying no agent is set up. Every agent the research works with
29
+ reads it, with the model and effort set for the research.
27
30
  - **Photos from a phone.** `strom phone on`, turned on by the person alone, lets a phone on the home network (Wi-Fi
28
31
  or a cable — never a mobile network or a VPN) reach this computer. The phone is paired once by its QR code; its
29
32
  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,
@@ -113,6 +113,7 @@ term: <a word or abbreviation as written> = <what it means, in ${into}> (term |
113
113
  residence=<…> — 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
+ on a form (a certificate) whose fields hold a name apart, written= the words of its fields as written there;
116
117
  a name you could not read stays out; leave out what the entry does not say
117
118
  event: <BIRT | BAPM | MARR | DEAT | BURI | RESI | EVEN> | date=<as on the record, in numbers: 3.4.1846, or a year> |
118
119
  place=<the place as written> | persons=<ref>:<principal|father|mother|godparent|witness|groom|bride|deceased|other>,…
@@ -280,6 +281,9 @@ export function parseDocReport(text) {
280
281
  /** A kind a reader wrote in its own words: the nearest of the list. */
281
282
  function kindOf(said) {
282
283
  const f = foldText(said);
284
+ // a certificate first: a birth certificate is no register of births ("rodný list", "Geburtsurkunde", "odpis aktu")
285
+ if (/certif|urkunde|zaswiadcz|odpis|extrait|auszug|vypis|(?:rodn|oddac|umrtn)\p{L}*\s+list/u.test(f))
286
+ return "certificate";
283
287
  if (/bapt|krest|tauf|chrzest|christen/u.test(f))
284
288
  return "register.baptism";
285
289
  if (/birth|narozen|geburt|urodzen/u.test(f))
@@ -327,6 +331,29 @@ function find(whole, part) {
327
331
  }
328
332
  return -1;
329
333
  }
334
+ /**
335
+ * Where the words of `part` stand in `whole` apart — a form's fields, the surname in one and the given names in
336
+ * another —: each at a place of its own, after the word before where it can be; undefined when one is not there
337
+ * (a word not read, "[...]", is never found apart).
338
+ */
339
+ function findApart(whole, part) {
340
+ if (!part.length || part.some((w) => w.illegible))
341
+ return undefined;
342
+ const used = new Set();
343
+ const out = [];
344
+ let from = 0;
345
+ for (const w of part) {
346
+ let i = whole.findIndex((x, k) => k >= from && !used.has(k) && x.key === w.key);
347
+ if (i < 0)
348
+ i = whole.findIndex((x, k) => !used.has(k) && x.key === w.key);
349
+ if (i < 0)
350
+ return undefined;
351
+ used.add(i);
352
+ out.push(whole[i]);
353
+ from = i + 1;
354
+ }
355
+ return out;
356
+ }
330
357
  /** 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
358
  function spelled(key) {
332
359
  return key
@@ -458,7 +485,8 @@ export function gedcomDate(text) {
458
485
  }
459
486
  /**
460
487
  * 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
488
+ * 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
489
+ * out; its surname must be a form of one of those
462
490
  * 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
491
  * nowhere, a woman's form for a man written in a man's); its given name in
464
492
  * 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 +494,7 @@ export function gedcomDate(text) {
466
494
  * transcript marks "[?]" is uncertain, one it marks "[...]" all through is left out (the reader cannot have read it);
467
495
  * a date of "[...]" has no GEDCOM; references to people left out go too. What was left out is said (checks).
468
496
  */
469
- export function checkEntry(raw, id, blind = false) {
497
+ export function checkEntry(raw, id, blind = false, form = false) {
470
498
  const checks = [];
471
499
  const text = words(raw.transcript);
472
500
  const persons = [];
@@ -478,11 +506,13 @@ export function checkEntry(raw, id, blind = false) {
478
506
  }
479
507
  const w = words(written);
480
508
  const at = find(text, w);
481
- if (at < 0) {
509
+ // a form (a certificate) holds a name in its fields apart — the surname in one, the given names in another —:
510
+ // its words each written there are the name; elsewhere a name stands in a row as written
511
+ const span = at >= 0 ? text.slice(at, at + w.length) : form ? findApart(text, w) : undefined;
512
+ if (!span) {
482
513
  checks.push({ entry: id, ref: p.ref, what: written, why: "not-in-transcript" });
483
514
  continue;
484
515
  }
485
- const span = text.slice(at, at + w.length);
486
516
  const role = (ROLES.includes(p.role) ? p.role : "other");
487
517
  const person = { ref: p.ref, role, written: span.map((x) => x.text).join(" ") };
488
518
  if (p.roleText && !blind)
@@ -630,6 +660,46 @@ export function wordDiff(a, b) {
630
660
  return out;
631
661
  }
632
662
  const same = (a, b) => foldText(a ?? "") === foldText(b ?? "");
663
+ /**
664
+ * The people of one reader paired with the other's by the name written, the best match first: the whole name alike,
665
+ * else the most of its words shared, a word of the surname weighing more. A person two match equally well (a father
666
+ * and his son of one given name, two of the same name) is paired with none — missing on one side rather than another
667
+ * one's. Index in `mine` → index in `theirs`.
668
+ */
669
+ function pairByName(mine, theirs) {
670
+ const keys = (p) => words(p.written).filter((x) => !x.illegible).map((x) => x.key);
671
+ const surname = (p) => words(p.surname ?? "").map((x) => x.key);
672
+ const WHOLE = 1000;
673
+ const score = (p, q) => {
674
+ const a = keys(p);
675
+ const b = keys(q);
676
+ if (a.length && a.join(" ") === b.join(" "))
677
+ return WHOLE;
678
+ const other = new Set(b);
679
+ const sur = new Set([...surname(p), ...surname(q)]);
680
+ let n = 0;
681
+ for (const k of new Set(a))
682
+ if (other.has(k))
683
+ n += sur.has(k) ? 2 : 1;
684
+ return n;
685
+ };
686
+ let open = mine.flatMap((p, i) => theirs.map((q, j) => ({ i, j, s: score(p, q) }))).filter((x) => x.s > 0);
687
+ const out = new Map();
688
+ while (open.length) {
689
+ const best = Math.max(...open.map((x) => x.s));
690
+ const top = open.filter((x) => x.s === best);
691
+ const done = { i: new Set(), j: new Set() };
692
+ for (const x of top) {
693
+ // equally good for either side: a tie, neither paired
694
+ if (!top.some((y) => y !== x && (y.i === x.i || y.j === x.j)))
695
+ out.set(x.i, x.j);
696
+ done.i.add(x.i);
697
+ done.j.add(x.j);
698
+ }
699
+ open = open.filter((x) => !done.i.has(x.i) && !done.j.has(x.j));
700
+ }
701
+ return out;
702
+ }
633
703
  /**
634
704
  * The first reader's entries against the blind reader's: paired by page and order; the transcripts word by word, the
635
705
  * people by role and order (given name, surname), the events by type and order (date, place). Where they differ is a
@@ -663,20 +733,38 @@ export function compareReadings(first, blind) {
663
733
  };
664
734
  const theirs = groups(b.persons);
665
735
  const seen = new Map();
736
+ const fields = (p, q) => {
737
+ for (const f of ["given", "surname"])
738
+ if (!same(p[f], q[f]))
739
+ variants.push({ field: `${p.ref}.${f}`, at: p[f] ?? "", other: q[f] ?? "" });
740
+ };
741
+ const alone = [];
666
742
  for (const p of e.persons) {
667
743
  const i = seen.get(p.role) ?? 0;
668
744
  seen.set(p.role, i + 1);
669
745
  const q = theirs.get(p.role)?.[i];
670
- if (!q) {
746
+ if (q)
747
+ fields(p, q);
748
+ else
749
+ alone.push(p);
750
+ }
751
+ // a person the blind reader gave another role (other / writer): the same one by the name written, the role a
752
+ // variant as its other fields
753
+ const left = [...theirs].flatMap(([role, qs]) => qs.slice(seen.get(role) ?? 0));
754
+ const pairs = pairByName(alone, left);
755
+ for (const [i, p] of alone.entries()) {
756
+ const j = pairs.get(i);
757
+ if (j === undefined) {
671
758
  variants.push({ field: `${p.ref}`, at: [p.given, p.surname].filter(Boolean).join(" ") || p.written, other: "" });
672
759
  continue;
673
760
  }
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] ?? "" });
761
+ const q = left[j];
762
+ variants.push({ field: `${p.ref}.role`, at: p.role, other: q.role });
763
+ fields(p, q);
677
764
  }
678
- for (const [role, qs] of theirs)
679
- for (const q of qs.slice(seen.get(role) ?? 0))
765
+ const taken = new Set(pairs.values());
766
+ for (const [j, q] of left.entries())
767
+ if (!taken.has(j))
680
768
  variants.push({ field: "person", at: "", other: [q.given, q.surname].filter(Boolean).join(" ") || q.written });
681
769
  const evs = new Map();
682
770
  for (const ev of e.events) {
@@ -881,6 +969,37 @@ export function alive(pid) {
881
969
  }
882
970
  }
883
971
  const LEGIBLE_ORDER = { yes: 0, partly: 1, no: 2 };
972
+ /** A variant of a person's role (`p2.role`): no words of the transcript. */
973
+ const ROLE_VARIANT = /^[\p{L}\p{N}_-]+\.role$/u;
974
+ /**
975
+ * The result as the Strom app is given it (GET /read/<id>): a variant of a person's role is no words of the
976
+ * transcript, and the app (3.14) marks each variant where its words stand in the transcript and counts it as a
977
+ * difference — a role code ("other") stood inside "Mother". So the role the blind reader gave goes to the person
978
+ * (`blindRole`) and out of `variants`; the result kept and `strom document show` keep it as a variant.
979
+ */
980
+ export function resultForApp(r) {
981
+ return {
982
+ ...r,
983
+ entries: r.entries.map((e) => {
984
+ const roles = new Map();
985
+ for (const v of e.variants ?? [])
986
+ if (ROLE_VARIANT.test(v.field))
987
+ roles.set(v.field.slice(0, -".role".length), v.other);
988
+ if (!roles.size)
989
+ return e;
990
+ const variants = (e.variants ?? []).filter((v) => !ROLE_VARIANT.test(v.field));
991
+ const { variants: _, ...rest } = e;
992
+ return {
993
+ ...rest,
994
+ ...(variants.length ? { variants } : {}),
995
+ persons: e.persons.map((p) => {
996
+ const other = roles.get(p.ref);
997
+ return other && ROLES.includes(other) ? { ...p, blindRole: other } : p;
998
+ }),
999
+ };
1000
+ }),
1001
+ };
1002
+ }
884
1003
  /**
885
1004
  * The result of a reading as it stands (while its readers write too: what they wrote so far): the first reader's
886
1005
  * entries checked and matched with the tree, the blind reader's held against them.
@@ -921,7 +1040,7 @@ export function buildResult(tree, s) {
921
1040
  }
922
1041
  for (const e of rep?.entries ?? []) {
923
1042
  const id = `e${out.length + 1}`;
924
- const c = checkEntry(e, id, blind);
1043
+ const c = checkEntry(e, id, blind, rep?.doc.kind === "certificate");
925
1044
  if (!blind)
926
1045
  checks.push(...c.checks);
927
1046
  // a region of a part asked (--region): of the whole page
@@ -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.12";
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.12",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {