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 +12 -9
- package/CHANGELOG.de.md +15 -11
- package/CHANGELOG.md +11 -7
- package/dist/commands/read.js +3 -1
- package/dist/commands/readers.js +1 -0
- package/dist/core/document.js +183 -19
- package/dist/core/live-read.js +3 -6
- package/dist/core/readsync.js +9 -3
- package/dist/core/tree.js +1 -1
- package/dist/runners/antigravity.js +35 -0
- package/dist/runners/codex.js +18 -6
- package/dist/runners/grok.js +9 -1
- package/dist/runners/opencode.js +43 -3
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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.
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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.
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
document
|
|
25
|
-
|
|
26
|
-
|
|
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
|
package/dist/commands/read.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/commands/readers.js
CHANGED
|
@@ -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,
|
package/dist/core/document.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
679
|
-
|
|
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
|
-
|
|
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 (
|
|
930
|
-
|
|
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
|
});
|
package/dist/core/live-read.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/core/readsync.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
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
|
};
|
package/dist/runners/codex.js
CHANGED
|
@@ -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.
|
|
51
|
-
? ["--
|
|
52
|
-
:
|
|
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.
|
|
61
|
-
? ["
|
|
62
|
-
:
|
|
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
|
/**
|
package/dist/runners/grok.js
CHANGED
|
@@ -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),
|
package/dist/runners/opencode.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|