strom-research 1.21.0-beta.1 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.cs.md CHANGED
@@ -7,7 +7,7 @@ Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou n
7
7
 
8
8
  ## 1.21.0
9
9
 
10
- *Připravuje se: bety do 1.21.0-beta.1. Oddíl bude úplný s vydáním.*
10
+ *26. 10. 2026 · patří k aplikaci Strom 3.16.0.*
11
11
 
12
12
  - **„Čeká“ říká, co aplikace Strom neukazuje.** Řádek v menu a `strom plan` řekne, kolik z čekajícího je jen tady
13
13
  v terminálu a kolik čeká na odpověď; zbytek je číslo, které ukazuje aplikace Strom, takže se obě shodují.
@@ -15,6 +15,9 @@ Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou n
15
15
  z aplikace, otázka ke čtení skenů), je pod „Jen tady v terminálu“, zbytek pod „I v aplikaci Strom“.
16
16
  - **`strom input assign`, `hide` a `unskip` řeknou, co se změnilo**, v jazyce výzkumu: každý soubor, každý
17
17
  uvolněný nebo nový úkol, lidi jménem a jak změnu vrátit. Jejich JSON zůstává stejný.
18
+ - **Přehled výzkumu pro widget na ploše macOS.** Dokud běží most (`strom live`), drží se přehled každého stromu
19
+ v souboru pro widget na ploše Macu: co se právě dělá, nálezy za poslední den a co čeká, každý text i bez jmen
20
+ lidí. Mimo macOS se nic nezapisuje.
18
21
 
19
22
  ## 1.20.0
20
23
 
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.21.0
9
9
 
10
- *In Vorbereitung: die Betas bis 1.21.0-beta.1. Der Abschnitt ist mit dem Release vollständig.*
10
+ *26.10.2026 · gehört zur Strom-App 3.16.0.*
11
11
 
12
12
  - **„Wartet“ sagt, was die Strom-App nicht zeigt.** Die Zeile im Menü und `strom plan` sagt, wie viel des Wartenden
13
13
  nur hier im Terminal ist und wie viel auf Antwort wartet; der Rest ist die Zahl, die die Strom-App zeigt, sodass
@@ -17,6 +17,10 @@ Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Ins
17
17
  „Auch in der Strom-App“.
18
18
  - **`strom input assign`, `hide` und `unskip` sagen, was sich geändert hat**, in der Sprache der Forschung: jede
19
19
  Datei, jede freigegebene oder neue Aufgabe, Personen mit Namen und wie es rückgängig geht. Ihr JSON bleibt gleich.
20
+ - **Eine Übersicht der Forschung für ein Widget auf dem macOS-Schreibtisch.** Solange die Brücke läuft
21
+ (`strom live`), wird die Übersicht jedes Stammbaums in einer Datei für ein Widget auf dem Mac-Schreibtisch
22
+ gehalten: was gerade läuft, die Funde des letzten Tages und was wartet, jeder Text auch ohne die Namen von
23
+ Personen. Außerhalb von macOS wird nichts geschrieben.
20
24
 
21
25
  ## 1.20.0
22
26
 
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.21.0
9
9
 
10
- *In preparation: the betas up to 1.21.0-beta.1. The section is complete with the release.*
10
+ *2026-10-26 · goes with the Strom app 3.16.0.*
11
11
 
12
12
  - **"Waiting" says what the Strom app does not show.** The line in the menu and `strom plan` says how many of the
13
13
  waiting things are only here in the terminal and how many wait for a reply; the rest is the number the Strom app
@@ -17,6 +17,9 @@ The changes of each version in short. The full notes and the install lines are o
17
17
  "Also in the Strom app".
18
18
  - **`strom input assign`, `hide` and `unskip` say what changed** in the research's language: each file, each task
19
19
  let go of or made, people by name, and how to take it back. Their JSON stays the same.
20
+ - **An overview of the research for a widget on the macOS desktop.** While the bridge runs (`strom live`), each
21
+ tree's overview is kept in a file for a widget on the Mac's desktop: what is at work now, the finds of the last day
22
+ and what waits, each text also without the names of people. Nothing is written off macOS.
20
23
 
21
24
  ## 1.20.0
22
25
 
@@ -1373,6 +1373,12 @@
1373
1373
  "ui.phone.removed": "telefon {name} je odebraný ze spárovaných",
1374
1374
  "ui.phone.ended": "server telefonu skončil",
1375
1375
  "ui.phone.waiting.item": "Něco k vyřízení na počítači",
1376
+ "ui.widget.person": "osoba",
1377
+ "ui.widget.family": "rodina",
1378
+ "ui.widget.up": "Předci osoby{span}",
1379
+ "ui.widget.down": "Potomci osoby{span}",
1380
+ "ui.widget.one": "Osoba{span}",
1381
+ "ui.widget.question": "Otázka výzkumu",
1376
1382
  "ui.phone.qr": "QR kód ke spárování telefonu (jeden telefon, do {time}): fotoaparátem telefonu ve stejné Wi-Fi jako tento počítač.",
1377
1383
  "ui.phone.qr.url": "adresa pod kódem: {url}",
1378
1384
  "ui.phone.qr.more": "další adresa tohoto počítače: strom phone qr --address {url}",
@@ -1373,6 +1373,12 @@
1373
1373
  "ui.phone.removed": "das Telefon {name} ist von den gekoppelten entfernt",
1374
1374
  "ui.phone.ended": "der Server des Telefons ist beendet",
1375
1375
  "ui.phone.waiting.item": "Etwas am Computer zu erledigen",
1376
+ "ui.widget.person": "Person",
1377
+ "ui.widget.family": "Familie",
1378
+ "ui.widget.up": "Vorfahren einer Person{span}",
1379
+ "ui.widget.down": "Nachkommen einer Person{span}",
1380
+ "ui.widget.one": "Eine Person{span}",
1381
+ "ui.widget.question": "Eine Frage der Forschung",
1376
1382
  "ui.phone.qr": "Ein QR-Code zum Koppeln eines Telefons (ein Telefon, bis {time}): mit der Kamera des Telefons im selben WLAN wie dieser Computer.",
1377
1383
  "ui.phone.qr.url": "die Adresse unter dem Code: {url}",
1378
1384
  "ui.phone.qr.more": "eine weitere Adresse dieses Computers: strom phone qr --address {url}",
package/dist/cli/ui.js CHANGED
@@ -1362,6 +1362,12 @@ export const UI = {
1362
1362
  "ui.phone.removed": "the phone {name} is taken off the paired ones",
1363
1363
  "ui.phone.ended": "the phone's server ended",
1364
1364
  "ui.phone.waiting.item": "Something to see to on the computer",
1365
+ "ui.widget.person": "person",
1366
+ "ui.widget.family": "family",
1367
+ "ui.widget.up": "Ancestors of a person{span}",
1368
+ "ui.widget.down": "Descendants of a person{span}",
1369
+ "ui.widget.one": "A person{span}",
1370
+ "ui.widget.question": "A question of the research",
1365
1371
  "ui.phone.qr": "A QR code to pair a phone (one phone, until {time}): the phone's camera, on the same Wi-Fi as this computer.",
1366
1372
  "ui.phone.qr.url": "the address under the code: {url}",
1367
1373
  "ui.phone.qr.more": "another address of this computer: strom phone qr --address {url}",
package/dist/core/live.js CHANGED
@@ -130,6 +130,7 @@ const EXCERPT_MS = 20_000;
130
130
  /** A source with more excerpts than this is answered without the pictures: the app asks for each (?clip=). */
131
131
  const EXCERPTS_AT_ONCE = 12;
132
132
  import { anyoneAtWork, liveWorkers } from "./workers.js";
133
+ import { widgetWriter } from "./widget.js";
133
134
  import { monthSpend, openSessions } from "./session.js";
134
135
  import { rankTasks } from "./queue.js";
135
136
  import { humanTask } from "../cli/human.js";
@@ -3029,6 +3030,11 @@ export function serveLive(root, env) {
3029
3030
  return atWorkNow;
3030
3031
  };
3031
3032
  let workers = safe("the start", () => JSON.stringify(atWork(seen))) ?? "";
3033
+ // the research on the desktop of a Mac (the Strom Widget): the tree's overview written while the bridge runs
3034
+ // (core/widget.ts; none off macOS) — the first a moment after the start, never in its way
3035
+ const widget = widgetWriter(root, env);
3036
+ if (widget)
3037
+ setTimeout(() => safe("the widget", () => widget.tick(true)), 1000).unref?.();
3032
3038
  const send = (event, data) => {
3033
3039
  for (const s of streams)
3034
3040
  s.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
@@ -3179,6 +3185,7 @@ export function serveLive(root, env) {
3179
3185
  // its research gone (taken off the computer, a test's folder): nothing to follow
3180
3186
  if (researchGone())
3181
3187
  return stop("the research is no longer there", "gone", false);
3188
+ safe("the widget", () => widget?.tick());
3182
3189
  if (!streams.size)
3183
3190
  return;
3184
3191
  safe("a tick", () => {
@@ -3218,6 +3225,7 @@ export function serveLive(root, env) {
3218
3225
  clearInterval(timer);
3219
3226
  clearInterval(keepAlive);
3220
3227
  clearInterval(stopAsked);
3228
+ safe("the widget", () => widget?.end(reason === "gone"));
3221
3229
  send("end", { reason, ...(back === undefined ? {} : { back }), at: new Date().toISOString() });
3222
3230
  for (const s of streams)
3223
3231
  s.end();
@@ -22,6 +22,7 @@ import { factSaid } from "./changelog.js";
22
22
  import { conflictTitle, displayName, lifespan } from "./people.js";
23
23
  import { ownerName } from "./overview.js";
24
24
  import { forPhone } from "./phone-watch.js";
25
+ import { eventName } from "../cli/human.js";
25
26
  /** The window when the page asks for none: the last 24 hours. */
26
27
  export const FINDS_HOURS = 24;
27
28
  /** How far back the page may ask, and how far back a find before the window is looked for. */
@@ -128,7 +129,7 @@ export function findGroups(tree, ops, lang = tree.lang) {
128
129
  if (r.type === "person") {
129
130
  const span = lifespan(r);
130
131
  const title = forPhone(`${displayName(r)}${span ? ` (${span})` : ""}`);
131
- return title ? { kind: "person", title } : undefined;
132
+ return title ? { kind: "person", title, name: forPhone(displayName(r)), ...(span ? { span } : {}) } : undefined;
132
133
  }
133
134
  const title = forPhone(ownerName(tree, r.id));
134
135
  return title ? { kind: "family", title } : undefined;
@@ -171,7 +172,7 @@ export function findGroups(tree, ops, lang = tree.lang) {
171
172
  const span = lifespan(child);
172
173
  const text = forPhone(`${displayName(child)}${span ? ` (${span})` : ""}`);
173
174
  if (text)
174
- add(ofRecord(r), { kind: "child", text, at });
175
+ add(ofRecord(r), { kind: "child", text, at, ...(span ? { span } : {}) });
175
176
  break;
176
177
  }
177
178
  case "event.add":
@@ -182,9 +183,12 @@ export function findGroups(tree, ops, lang = tree.lang) {
182
183
  const e = r?.events.find((x) => x.id === id);
183
184
  if (!r || !e || e.retracted)
184
185
  break;
185
- const said = forPhone(factSaid(tree, r.id, e.id, lang) ?? "");
186
+ const raw = factSaid(tree, r.id, e.id, lang) ?? "";
187
+ const said = forPhone(raw);
186
188
  if (!said)
187
189
  break;
190
+ const name = eventName(e.kind, lang, e.label);
191
+ const fact = { kind: e.kind, name, what: raw.startsWith(`${name}: `) ? forPhone(raw.slice(name.length + 2)) : "" };
188
192
  // a line of its own on the phone: its first letter as a sentence's
189
193
  const text = said.charAt(0).toLocaleUpperCase(lang) + said.slice(1);
190
194
  const kind = o.op === "event.add" ? "fact" : o.op === "event.edit" ? "fixed" : "cited";
@@ -192,13 +196,14 @@ export function findGroups(tree, ops, lang = tree.lang) {
192
196
  if (was) {
193
197
  // said already: as it is now, at its newest moment
194
198
  was.text = text;
199
+ was.fact = fact;
195
200
  was.at = at;
196
201
  const g = ofRecord(r);
197
202
  if (g && at > g.at)
198
203
  g.at = at;
199
204
  break;
200
205
  }
201
- const line = { kind, text, at };
206
+ const line = { kind, text, at, fact };
202
207
  facts.set(`${r.id}:${e.id}`, line);
203
208
  add(ofRecord(r), line);
204
209
  break;
@@ -17,6 +17,7 @@ import { history, whoAtWork } from "./live.js";
17
17
  import { agentsHere } from "./apps.js";
18
18
  import { humanTask } from "../cli/human.js";
19
19
  import { ui } from "../cli/ui.js";
20
+ import { lifespan } from "./people.js";
20
21
  import { ALL_PREFIXES } from "./model.js";
21
22
  /** How long the overview of the tree shown is kept (the page asks every 10 s); the marks of the other trees in the switcher. */
22
23
  export const OVERVIEW_MS = 15_000;
@@ -177,7 +178,17 @@ export function treeOverview(root, env) {
177
178
  const way = r?.direction === "ancestors" ? "up" : r?.direction === "descendants" ? "down" : "other";
178
179
  const what = task ? taskText(tree, task) : undefined;
179
180
  const name = (r ? forPhone(r.name) : "") || whoAtWork(w.label, tree.lang);
180
- return { way, name, ...(what ? { task: what } : {}), since: w.since, ...(w.paused ? { paused: { until: w.paused.until } } : {}) };
181
+ const focus = r?.focus ? tree.get(r.focus) : undefined;
182
+ const span = focus?.type === "person" ? lifespan(focus) : "";
183
+ return {
184
+ way,
185
+ name,
186
+ ...(what ? { task: what } : {}),
187
+ since: w.since,
188
+ ...(w.paused ? { paused: { until: w.paused.until } } : {}),
189
+ ...(r ? { direction: r.direction } : {}),
190
+ ...(span ? { span } : {}),
191
+ };
181
192
  });
182
193
  const sessions = tree.list("session");
183
194
  const last = sessions.map((s) => s.ended ?? s.started).sort().at(-1);
package/dist/core/tree.js CHANGED
@@ -29,7 +29,7 @@ export const TREE_FILE = "strom.json";
29
29
  export const SEAL_FOREIGN = "seal.foreign";
30
30
  // between releases the candidate being made (a bridge run from the sources says what it is: the Strom app goes by it);
31
31
  // at a release the release's own (package.json)
32
- export const VERSION = "1.21.0-beta.1";
32
+ export const VERSION = "1.21.0";
33
33
  const GITIGNORE = `# Strom Research — local state and large files are not versioned
34
34
  .strom/
35
35
  /media/
@@ -0,0 +1,415 @@
1
+ // The research on the desktop of a Mac (the Strom Widget, a program of its own, strom-mac): each tree's overview written
2
+ // into a file the widget reads — who works on what now, the finds of the last 24 hours, what waits for the person in
3
+ // the Strom app, the last 24 hours — worked out by the same functions as the phone's page (phone-watch.ts,
4
+ // phone-finds.ts) and cleaned as for it (forPhone): never a path, a record's ID, a spend or a model. Beside each text
5
+ // that names a person, the same without the names (the widget's "hide names").
6
+ //
7
+ // Written by the tree's bridge (strom live) while it runs, on macOS alone: ~/Library/Application Support/Strom/widget/
8
+ // <tree id>.json and index.json (the trees there), each whole (a file beside it renamed over it). Looked at every
9
+ // WIDGET_CHECK_MS, written when what it says changed, else every WIDGET_HEARTBEAT_MS; the bridge ending as it should
10
+ // writes `running: false`, its research gone takes the file away. An archive has none. The widget only reads: nothing
11
+ // of it comes back. An isolated installation (STROM_ISOLATED: a test's, a second strom) writes into its own settings
12
+ // folder — a research of a test never shows in the person's widget.
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { configDir, isolated, userHome } from "./paths.js";
16
+ import { Settings } from "./config.js";
17
+ import { Tree } from "./tree.js";
18
+ import { actsAsArchive } from "./state.js";
19
+ import { foldText } from "./text.js";
20
+ import { writeFileAtomic } from "./json.js";
21
+ import { appUrlSetting } from "./stromapp.js";
22
+ import { forPhone, treeOverview } from "./phone-watch.js";
23
+ import { treeFinds } from "./phone-finds.js";
24
+ import { waitingFor, inApp } from "./waiting.js";
25
+ import { scopes } from "./directions.js";
26
+ import { agentsHere } from "./apps.js";
27
+ import { humanTask } from "../cli/human.js";
28
+ import { ui } from "../cli/ui.js";
29
+ /** The version of the files' shape the widget reads (a newer one: the widget asks for its own update). */
30
+ export const WIDGET_SCHEMA = 1;
31
+ /** How often the bridge looks whether what the widget shows changed; written at least this often all the same. */
32
+ export const WIDGET_CHECK_MS = 15_000;
33
+ export const WIDGET_HEARTBEAT_MS = 60_000;
34
+ /** At most so many finds (the newest), so many of what waits with its text. */
35
+ export const WIDGET_FINDS = 50;
36
+ export const WIDGET_PENDING = 3;
37
+ /** What stands for a name taken out of a text. */
38
+ const HIDDEN = "…";
39
+ /**
40
+ * Where the widget's files are written: none but on macOS; STROM_WIDGET_DIR when set; an isolated installation into its
41
+ * own settings folder; else ~/Library/Application Support/Strom/widget of the HOME strom runs with.
42
+ */
43
+ export function widgetDir(env, platform = process.platform) {
44
+ if (platform !== "darwin")
45
+ return undefined;
46
+ const said = env.STROM_WIDGET_DIR?.trim();
47
+ if (said)
48
+ return path.resolve(said);
49
+ if (isolated(env))
50
+ return path.join(configDir(env, platform), "widget");
51
+ return path.join(userHome(env), "Library", "Application Support", "Strom", "widget");
52
+ }
53
+ /** A moment as the widget reads it: ISO 8601 to the second, in UTC (Swift's .iso8601 reads no fractions). */
54
+ export function isoSecond(at) {
55
+ const t = typeof at === "number" ? at : Date.parse(at);
56
+ return Number.isFinite(t) ? new Date(t).toISOString().replace(/\.\d{3}Z$/u, "Z") : "";
57
+ }
58
+ // The app's slug of a tree's name (strom-beta src/filenames.ts asciiSlug): ?tree=<slug> opens the tree of that name.
59
+ const STANDALONE = { ß: "ss", æ: "ae", œ: "oe", ø: "o", đ: "d", ð: "d", þ: "th", ł: "l", ħ: "h", ı: "i", ŧ: "t", ŋ: "n", ĸ: "k" };
60
+ export function appSlug(name) {
61
+ return name
62
+ .toLowerCase()
63
+ .replace(/[ßæœøđðþłħıŧŋĸ]/gu, (c) => STANDALONE[c] ?? c)
64
+ .normalize("NFD")
65
+ .replace(/[̀-ͯ]/gu, "")
66
+ .replace(/[^a-z0-9]+/gu, "-")
67
+ .replace(/^-+|-+$/gu, "");
68
+ }
69
+ /** The tree in the Strom app strom uses (the setting strom.app.url, the channel's copy): ?tree=<slug of its name>. */
70
+ function treeUrl(env, name) {
71
+ const url = new URL(appUrlSetting(new Settings(env, {})).url);
72
+ const slug = appSlug(name);
73
+ if (slug)
74
+ url.searchParams.set("tree", slug);
75
+ return url.href;
76
+ }
77
+ // ── names out of a text ─────────────────────────────────────────────────────────────────────────────────────────────
78
+ /** The stems of every name of the tree's people, folded: a word is a name when it is one of them and a few letters more. */
79
+ const stemsKept = new Map();
80
+ const MIN_STEM = 3;
81
+ const MAX_ENDING = 4;
82
+ function stemOf(folded) {
83
+ return folded.length > MIN_STEM ? folded.slice(0, Math.max(MIN_STEM, folded.length - 1)) : folded;
84
+ }
85
+ function nameStems(tree, key) {
86
+ const was = stemsKept.get(tree.root);
87
+ if (was && was.at === key)
88
+ return was.stems;
89
+ const names = [];
90
+ for (const p of tree.list("person"))
91
+ if (!p.retracted)
92
+ for (const n of p.names)
93
+ names.push(n.given, n.surname);
94
+ const stems = stemsOfNames(names);
95
+ stemsKept.set(tree.root, { at: key, stems });
96
+ return stems;
97
+ }
98
+ /** A name that drops its e before the last consonant in the other cases (Karel → Karla, Cvrlek → Cvrlka, Němec → Němcová). */
99
+ const MOVABLE_E = /^(.*[bcdfghjklmnpqrstvwxz])e([kclnr])$/u;
100
+ /** A stem ending in two consonants, as such a name has it without its e (Cvrlková → Cvrlk-, Němcová → Němc-). */
101
+ const WITHOUT_E = /^(.*[bcdfghjklmnpqrstvwxz])([kclnr])$/u;
102
+ /** The stems of names (folded): what a word of a text starts with when it is one of the names in any of its forms. */
103
+ export function stemsOfNames(names) {
104
+ const stems = new Set();
105
+ for (const name of names)
106
+ for (const part of name.split(/[^\p{L}\p{M}]+/u)) {
107
+ const f = foldText(part);
108
+ if ([...f].length < 2)
109
+ continue;
110
+ const e = MOVABLE_E.exec(f);
111
+ if (e && e[1].length >= 2) {
112
+ // the name as it stands (Cvrlek) and without its e (Cvrlka, Cvrlkovi, Cvrlková) — not "Něme…", which "neměl" is too
113
+ stems.add(f);
114
+ stems.add(e[1] + e[2]);
115
+ stems.add(stemOf(`${e[1]}${e[2]}ova`));
116
+ continue;
117
+ }
118
+ stems.add(stemOf(f));
119
+ // a woman's surname said of the man or the family ("Lhotáková" → "Lhoták", "Lhotákovi"), and a man's said of her
120
+ const base = /^(.{3,})ova$/u.exec(f)?.[1];
121
+ const cut = base ? WITHOUT_E.exec(base) : null;
122
+ if (cut && base.length >= 4) {
123
+ // "Cvrlková" said of the man: Cvrlk- (Cvrlka) and Cvrlek
124
+ stems.add(base);
125
+ stems.add(`${cut[1]}e${cut[2]}`);
126
+ }
127
+ else if (base)
128
+ stems.add(stemOf(base));
129
+ else if (f.length >= 4)
130
+ stems.add(stemOf(`${f}ova`));
131
+ }
132
+ return stems;
133
+ }
134
+ /** Whether a word is a name of the tree's people: one of their stems and at most MAX_ENDING letters more (Kryštofa, Lhotákovou). */
135
+ function isName(word, stems) {
136
+ const f = foldText(word);
137
+ const chars = [...f];
138
+ for (let k = Math.max(1, chars.length - MAX_ENDING); k <= chars.length; k++)
139
+ if (stems.has(chars.slice(0, k).join("")))
140
+ return true;
141
+ return false;
142
+ }
143
+ /**
144
+ * A text with the names of the tree's people taken out: each run of them (with what joins them: "Kryštofa Lhotáka")
145
+ * one "…". Any script; a word that only looks like a name goes too (better one word too many than a name shown).
146
+ * "" when nothing but names is left.
147
+ */
148
+ export function withoutNames(text, stems) {
149
+ const parts = text.split(/([\p{L}\p{M}]+)/u);
150
+ const out = [];
151
+ let hidden = false;
152
+ for (let i = 0; i < parts.length; i++) {
153
+ const p = parts[i];
154
+ const word = i % 2 === 1;
155
+ if (word && isName(p, stems)) {
156
+ if (!hidden)
157
+ out.push(HIDDEN);
158
+ hidden = true;
159
+ continue;
160
+ }
161
+ // what stands between two names of one run (a space, a hyphen) goes with them
162
+ if (!word && hidden && /^[\s-]*$/u.test(p) && i + 1 < parts.length && isName(parts[i + 1], stems))
163
+ continue;
164
+ hidden = false;
165
+ out.push(p);
166
+ }
167
+ const s = out.join("").replace(/\s+/gu, " ").trim();
168
+ return /[\p{L}\p{N}]/u.test(s.replaceAll(HIDDEN, "")) ? s : "";
169
+ }
170
+ // ── the overview ─────────────────────────────────────────────────────────────────────────────────────────────────────
171
+ /** A fact's name as a heading: its first letter capital ("povolání" → "Povolání"), in the research language's way. */
172
+ function heading(name, lang) {
173
+ const [first = "", ...rest] = [...name];
174
+ return first.toLocaleUpperCase(lang) + rest.join("");
175
+ }
176
+ const FACTS = { BIRT: "birth", CHR: "baptism", BAPM: "baptism", MARR: "marriage", DEAT: "death", BURI: "burial" };
177
+ /** The finds of the phone as the widget's lines: newest first, each its text and the same without names. */
178
+ function findItems(tree, groups, stems, openUrl) {
179
+ const lang = tree.lang;
180
+ const person = ui(lang, "ui.widget.person");
181
+ const anon = (span) => (span ? `${person} (${span})` : person);
182
+ const out = [];
183
+ const at = (l) => isoSecond(l.at);
184
+ for (const g of groups)
185
+ for (const l of g.lines) {
186
+ const who = g.kind === "person" ? (g.name ?? g.title ?? "") : (g.title ?? "");
187
+ const whoNoNames = g.kind === "person" ? person : ui(lang, "ui.widget.family");
188
+ const base = { at: at(l), openUrl };
189
+ switch (l.kind) {
190
+ case "person":
191
+ out.push({ kind: "person_new", text: g.title ?? "", textNoNames: anon(g.span), ...base });
192
+ break;
193
+ case "family":
194
+ out.push({ kind: "family_new", text: g.title ?? "", textNoNames: whoNoNames, ...base });
195
+ break;
196
+ case "name":
197
+ out.push({ kind: "name", text: l.text, textNoNames: anon(g.span), ...base });
198
+ break;
199
+ case "child":
200
+ out.push({ kind: "child", text: l.text, textNoNames: anon(l.span), ...base });
201
+ break;
202
+ case "fact":
203
+ case "fixed":
204
+ case "cited": {
205
+ const fact = FACTS[l.fact?.kind ?? ""] ?? "other";
206
+ const what = l.fact?.what ?? "";
207
+ out.push({
208
+ kind: "fact",
209
+ fact,
210
+ label: fact === "other" && l.fact?.name ? heading(l.fact.name, lang) : null,
211
+ text: [who, what].filter(Boolean).join(", "),
212
+ textNoNames: [whoNoNames, withoutNames(what, stems)].filter(Boolean).join(", "),
213
+ ...base,
214
+ });
215
+ break;
216
+ }
217
+ case "source": {
218
+ // places, dates and sources stay (the widget's "hide names"); a name in a source's title goes
219
+ const none = withoutNames(l.text, stems);
220
+ out.push({ kind: "source_new", text: l.text, ...(none ? { textNoNames: none } : {}), ...base });
221
+ break;
222
+ }
223
+ case "conflict":
224
+ case "hypothesis": {
225
+ const none = withoutNames(l.text, stems);
226
+ out.push({ kind: "resolved", resolved: l.kind === "conflict" ? "conflict" : "hypothesis_confirmed", text: l.text, ...(none ? { textNoNames: none } : {}), ...base });
227
+ break;
228
+ }
229
+ }
230
+ }
231
+ return out.filter((f) => f.text).sort((a, b) => b.at.localeCompare(a.at)).slice(0, WIDGET_FINDS);
232
+ }
233
+ /** What waits for the person as the Strom app counts it: its /status `waiting`, only what is to do (not sent, waiting for a reply). */
234
+ function pendingOf(tree, env) {
235
+ const settings = new Settings(env, {});
236
+ return waitingFor(tree, {
237
+ shared: settings.shared()?.value,
238
+ claude: () => settings.agent(tree.config).value === "claude" && agentsHere(env).some((a) => a.id === "claude" && a.cli),
239
+ stories: true,
240
+ scopes: scopes(tree),
241
+ }).filter((w) => inApp(w) && w.item.phase === "todo");
242
+ }
243
+ /**
244
+ * A line of "Now" without names: its direction and the years of whom it is about ("Předci osoby (1784)"); a question,
245
+ * by its words without the names; the agent itself (no research), as it is said (no name in it).
246
+ */
247
+ function lineNoNames(lang, l, stems) {
248
+ const of = l.span ? ` (${l.span})` : "";
249
+ if (l.direction === "ancestors")
250
+ return ui(lang, "ui.widget.up", { span: of });
251
+ if (l.direction === "descendants")
252
+ return ui(lang, "ui.widget.down", { span: of });
253
+ if (l.direction === "person")
254
+ return ui(lang, "ui.widget.one", { span: of });
255
+ return withoutNames(l.name, stems) || ui(lang, "ui.widget.question");
256
+ }
257
+ /**
258
+ * The overview of a tree as the widget reads it (`running`: its bridge runs); undefined for an archive (nobody works
259
+ * on it: nothing to show of an agent) and a research that cannot be read.
260
+ */
261
+ export function widgetTree(root, env, opts) {
262
+ const now = opts.now ?? Date.now();
263
+ const tree = Tree.open(root, env);
264
+ if (actsAsArchive(tree))
265
+ return undefined;
266
+ const lang = tree.lang;
267
+ const openUrl = treeUrl(env, tree.config.name);
268
+ const o = treeOverview(root, env);
269
+ const stems = nameStems(tree, o.latest ?? "");
270
+ const lines = o.now.map((l) => {
271
+ const task = l.task;
272
+ const taskNoNames = task ? withoutNames(task, stems) : "";
273
+ return {
274
+ dir: l.way,
275
+ title: l.name,
276
+ titleNoNames: lineNoNames(lang, l, stems),
277
+ ...(task ? { task } : {}),
278
+ ...(taskNoNames ? { taskNoNames } : {}),
279
+ since: isoSecond(l.since),
280
+ pausedUntil: l.paused ? isoSecond(l.paused.until) : null,
281
+ openUrl,
282
+ };
283
+ });
284
+ const windowStart = now - 24 * 3_600_000;
285
+ const finds = treeFinds(root, env, new Date(windowStart).toISOString(), now);
286
+ const waits = pendingOf(tree, env);
287
+ const pendingItems = waits.slice(0, WIDGET_PENDING).map(({ item }) => {
288
+ const text = forPhone(humanTask(tree, item.what, lang)) || ui(lang, "ui.phone.waiting.item");
289
+ const none = withoutNames(text, stems);
290
+ return { text, ...(none ? { textNoNames: none } : {}) };
291
+ });
292
+ const at = isoSecond(now);
293
+ return {
294
+ schemaVersion: WIDGET_SCHEMA,
295
+ treeKey: tree.config.id,
296
+ treeName: tree.config.name,
297
+ lang,
298
+ generatedAt: at,
299
+ running: opts.running,
300
+ lastActiveAt: at,
301
+ openUrl,
302
+ now: { lines, ...(o.lastWorked ? { lastWorkedAt: isoSecond(o.lastWorked) } : {}) },
303
+ findings: { windowStart: isoSecond(finds.since), items: findItems(tree, finds.groups, stems, openUrl) },
304
+ pending: { count: waits.length, items: pendingItems, openUrl },
305
+ ...(o.day ? { stats24h: o.day } : {}),
306
+ };
307
+ }
308
+ // ── the files ────────────────────────────────────────────────────────────────────────────────────────────────────────
309
+ const treeFile = (dir, key) => path.join(dir, `${key}.json`);
310
+ /** A tree's key as a file's name: the tree's ID (a UUID) — never a path. */
311
+ const KEY = /^[0-9A-Za-z-]{1,64}$/u;
312
+ /** index.json as the files of the trees there say it, written whole when it says something else. */
313
+ export function writeWidgetIndex(dir, now = Date.now()) {
314
+ const trees = [];
315
+ let names = [];
316
+ try {
317
+ names = fs.readdirSync(dir).filter((n) => n.endsWith(".json") && n !== "index.json" && !n.startsWith("."));
318
+ }
319
+ catch {
320
+ return;
321
+ }
322
+ for (const n of names.sort()) {
323
+ try {
324
+ const t = JSON.parse(fs.readFileSync(path.join(dir, n), "utf8"));
325
+ if (typeof t.treeKey !== "string" || `${t.treeKey}.json` !== n)
326
+ continue;
327
+ trees.push({ treeKey: t.treeKey, treeName: String(t.treeName ?? ""), lang: String(t.lang ?? ""), running: t.running === true, lastActiveAt: String(t.lastActiveAt ?? ""), generatedAt: String(t.generatedAt ?? "") });
328
+ }
329
+ catch {
330
+ // being written by another bridge this moment: said the next time
331
+ }
332
+ }
333
+ const file = path.join(dir, "index.json");
334
+ try {
335
+ const was = JSON.parse(fs.readFileSync(file, "utf8"));
336
+ if (JSON.stringify(was.trees) === JSON.stringify(trees) && was.schemaVersion === WIDGET_SCHEMA)
337
+ return;
338
+ }
339
+ catch {
340
+ // none yet
341
+ }
342
+ writeFileAtomic(file, `${JSON.stringify({ schemaVersion: WIDGET_SCHEMA, generatedAt: isoSecond(now), trees }, null, 2)}\n`);
343
+ }
344
+ /** A tree's file written whole (a file beside it renamed over it), then index.json. */
345
+ export function writeWidgetTree(dir, data) {
346
+ if (!KEY.test(data.treeKey))
347
+ return;
348
+ writeFileAtomic(treeFile(dir, data.treeKey), `${JSON.stringify(data, null, 2)}\n`);
349
+ writeWidgetIndex(dir, Date.parse(data.generatedAt) || Date.now());
350
+ }
351
+ /** A tree's file taken away (its research gone, an archive now), and index.json without it. */
352
+ export function dropWidgetTree(dir, key) {
353
+ if (!KEY.test(key))
354
+ return;
355
+ try {
356
+ fs.rmSync(treeFile(dir, key), { force: true });
357
+ }
358
+ catch {
359
+ return;
360
+ }
361
+ writeWidgetIndex(dir);
362
+ }
363
+ /** The writer of a tree's bridge; none off macOS. */
364
+ export function widgetWriter(root, env, platform = process.platform, clock = Date.now) {
365
+ const dir = widgetDir(env, platform);
366
+ if (!dir)
367
+ return undefined;
368
+ const checkMs = Number(env.STROM_WIDGET_CHECK_MS ?? WIDGET_CHECK_MS);
369
+ const beatMs = Number(env.STROM_WIDGET_HEARTBEAT_MS ?? WIDGET_HEARTBEAT_MS);
370
+ let checked = -Infinity;
371
+ let written = -Infinity;
372
+ let said = "";
373
+ let last;
374
+ let key;
375
+ // what moves with the clock alone says nothing new
376
+ const content = (t) => JSON.stringify({ ...t, generatedAt: "", lastActiveAt: "", findings: { ...t.findings, windowStart: "" } });
377
+ return {
378
+ tick(force = false) {
379
+ const now = clock();
380
+ if (!force && now - checked < checkMs)
381
+ return;
382
+ checked = now;
383
+ const t = widgetTree(root, env, { running: true, now });
384
+ if (!t) {
385
+ // an archive now: nothing of it on the desktop
386
+ if (key)
387
+ dropWidgetTree(dir, key);
388
+ key = undefined;
389
+ last = undefined;
390
+ said = "";
391
+ return;
392
+ }
393
+ key = t.treeKey;
394
+ last = t;
395
+ const c = content(t);
396
+ if (c === said && now - written < beatMs)
397
+ return;
398
+ writeWidgetTree(dir, t);
399
+ said = c;
400
+ written = now;
401
+ },
402
+ end(gone) {
403
+ if (!key)
404
+ return;
405
+ // what the file says now is no longer what was written: the next look writes it again
406
+ said = "";
407
+ if (gone)
408
+ return dropWidgetTree(dir, key);
409
+ if (!last)
410
+ return;
411
+ const at = isoSecond(clock());
412
+ writeWidgetTree(dir, { ...last, running: false, generatedAt: at, lastActiveAt: at });
413
+ },
414
+ };
415
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strom-research",
3
- "version": "1.21.0-beta.1",
3
+ "version": "1.21.0",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,8 +57,5 @@
57
57
  "devDependencies": {
58
58
  "@types/node": "^22.20.4",
59
59
  "typescript": "^7.0.2"
60
- },
61
- "publishConfig": {
62
- "tag": "beta"
63
60
  }
64
61
  }