strom-research 1.5.0 → 1.6.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.
@@ -39,10 +39,35 @@ export const APP_SENDS_CHANGES = "3.3.0";
39
39
  export function appSendsChanges(settings) {
40
40
  return APP_SENDS_CHANGES !== undefined || stromAppUrl(settings) !== STROM_APP_URL;
41
41
  }
42
+ /**
43
+ * The Strom app opens strom-research:// links (the app's spec docs/ZADANI_VYZKUM_ODKAZY_Z_APLIKACE.md): it reads
44
+ * each excerpt's mark (_STROM_CLIP) and the links a research offers (_STROM_LINKS in the GEDCOM the bridge serves).
45
+ * The first version of stromapp.info that does (3.4.0, 2026-09-27); another copy of the app (its beta, its
46
+ * development: strom.app.url) is taken as current. An app of a known version (strom.version) goes by it.
47
+ */
48
+ export const APP_OPENS_LINKS = "3.4.0";
49
+ export function appOpensLinks(settings, version) {
50
+ if (stromAppUrl(settings) !== STROM_APP_URL)
51
+ return true;
52
+ if (!APP_OPENS_LINKS)
53
+ return false;
54
+ if (!version)
55
+ return true;
56
+ const n = (v) => v.split(".").map((x) => Number.parseInt(x, 10) || 0);
57
+ const [a, b] = [n(version), n(APP_OPENS_LINKS)];
58
+ for (let i = 0; i < 3; i++)
59
+ if ((a[i] ?? 0) !== (b[i] ?? 0))
60
+ return (a[i] ?? 0) > (b[i] ?? 0);
61
+ return true;
62
+ }
42
63
  /** The address that opens the Strom app to send the user's edited tree to the bridge. */
43
64
  export function sendAppUrl(bridge, settings) {
44
65
  return `${stromAppUrl(settings)}?send=${encodeURIComponent(bridge)}`;
45
66
  }
67
+ /** The address that opens the Strom app to hand one of its trees to a new research (strom-research://new). */
68
+ export function adoptAppUrl(bridge, settings) {
69
+ return `${stromAppUrl(settings)}?adopt=${encodeURIComponent(bridge)}`;
70
+ }
46
71
  /** The address that opens the Strom app following a research through its bridge. */
47
72
  export function liveAppUrl(bridge, settings) {
48
73
  return `${stromAppUrl(settings)}?live=${encodeURIComponent(bridge)}`;
package/dist/core/sync.js CHANGED
@@ -358,7 +358,86 @@ export function receivedSince(root, since) {
358
358
  return undefined;
359
359
  return fs
360
360
  .readdirSync(dir)
361
- .filter((f) => f.endsWith(".ged"))
361
+ .filter((f) => f.endsWith(".ged") && !f.startsWith("adopt-"))
362
+ .map((f) => path.join(dir, f))
363
+ .filter((f) => fs.statSync(f).mtimeMs >= since)
364
+ .sort()
365
+ .pop();
366
+ }
367
+ // ── a tree of the Strom app becomes a research (strom-research://new, ?adopt=) ──
368
+ /** Where a new research waits for the tree of the app it was started for: the app's mark, since when. */
369
+ const ADOPT_FILE = path.join(".strom", "adopt.json");
370
+ /** How long the app's mark is good (the app keeps it an hour too). */
371
+ export const ADOPT_FOR_MS = 60 * 60_000;
372
+ /** This research waits for the app's tree with this mark (the link's app=…). */
373
+ export function awaitAdoption(root, token) {
374
+ fs.mkdirSync(path.join(root, ".strom"), { recursive: true });
375
+ fs.writeFileSync(path.join(root, ADOPT_FILE), JSON.stringify({ token, at: new Date().toISOString() }));
376
+ }
377
+ /** The mark of the tree this research waits for — none when it waits for none, got it already, or waited too long. */
378
+ export function pendingAdoption(root) {
379
+ try {
380
+ const a = JSON.parse(fs.readFileSync(path.join(root, ADOPT_FILE), "utf8"));
381
+ return a.token && !a.done && Date.now() - Date.parse(a.at ?? "") < ADOPT_FOR_MS ? a.token : undefined;
382
+ }
383
+ catch {
384
+ return undefined;
385
+ }
386
+ }
387
+ /**
388
+ * The app's tree for a new research (POST <bridge>/adopt): a GEDCOM, kept in the inbox for the research in the
389
+ * terminal to take in — nothing of the research changes here. Taken once; the file it was kept as.
390
+ */
391
+ export function receiveAdopted(root, text) {
392
+ if (!pendingAdoption(root))
393
+ throw new UsageError("this research waits for no tree");
394
+ if (!/^\uFEFF?\s*0\s+HEAD/.test(text))
395
+ throw new UsageError("not a GEDCOM file");
396
+ if (!/^1 INDI\b|^0 @[^@]+@ INDI\b/m.test(text))
397
+ throw new UsageError("empty: no people in it");
398
+ const dir = path.join(root, SYNC_INBOX);
399
+ fs.mkdirSync(dir, { recursive: true });
400
+ const file = path.join(dir, `adopt-${new Date().toISOString().replace(/[:.]/g, "-")}.ged`);
401
+ fs.writeFileSync(file, text);
402
+ const a = JSON.parse(fs.readFileSync(path.join(root, ADOPT_FILE), "utf8"));
403
+ fs.writeFileSync(path.join(root, ADOPT_FILE), JSON.stringify({ ...a, done: new Date().toISOString() }));
404
+ return file;
405
+ }
406
+ /** Why the app's tree was not taken (POST <bridge>/adopt refused): noted for the research in the terminal, which stops waiting. */
407
+ export function noteAdoptFailed(root, why) {
408
+ const dir = path.join(root, SYNC_INBOX);
409
+ fs.mkdirSync(dir, { recursive: true });
410
+ fs.writeFileSync(path.join(dir, `adopt-failed-${Date.now()}.json`), JSON.stringify({ why, at: new Date().toISOString() }));
411
+ }
412
+ /** The app's tree was refused since `since` (ms): why. */
413
+ export function adoptFailedSince(root, since) {
414
+ const dir = path.join(root, SYNC_INBOX);
415
+ if (!fs.existsSync(dir))
416
+ return undefined;
417
+ const last = fs
418
+ .readdirSync(dir)
419
+ .filter((f) => f.startsWith("adopt-failed-"))
420
+ .map((f) => path.join(dir, f))
421
+ .filter((f) => fs.statSync(f).mtimeMs >= since)
422
+ .sort()
423
+ .pop();
424
+ if (!last)
425
+ return undefined;
426
+ try {
427
+ return JSON.parse(fs.readFileSync(last, "utf8")).why;
428
+ }
429
+ catch {
430
+ return "other";
431
+ }
432
+ }
433
+ /** The tree the app handed over since `since` (ms), if any. */
434
+ export function adoptedSince(root, since) {
435
+ const dir = path.join(root, SYNC_INBOX);
436
+ if (!fs.existsSync(dir))
437
+ return undefined;
438
+ return fs
439
+ .readdirSync(dir)
440
+ .filter((f) => f.startsWith("adopt-") && f.endsWith(".ged"))
362
441
  .map((f) => path.join(dir, f))
363
442
  .filter((f) => fs.statSync(f).mtimeMs >= since)
364
443
  .sort()
@@ -488,7 +567,7 @@ export function planSync(tree, incoming, edits, opts = {}) {
488
567
  }
489
568
  if (was && !partial)
490
569
  for (const b of was)
491
- if (!incKeys.has(factKey(b)) && !used.has(factKey(b)) && carried.has(kindOf(b.kind)) && ourKeys.has(factKey(b)))
570
+ if (!incKeys.has(factKey(b)) && !used.has(factKey(b)) && carried.has(kindOf(b.kind)) && ourKeys.has(factKey(b)) && !keptOnce(owner, b, inc))
492
571
  push({ kind: "fact.gone", action: "report", ...owner, was: b });
493
572
  };
494
573
  // people
@@ -559,6 +638,16 @@ export function planSync(tree, incoming, edits, opts = {}) {
559
638
  return { changes, base: !!base, ...(incoming.head ? { head: incoming.head } : {}), partial, identity };
560
639
  }
561
640
  /** Does the research's fact say all the file's fact says, and more: the same kind, and each of its date, place, value the same or missing in the file. */
641
+ /**
642
+ * The Strom app keeps a couple's marriages and divorces as the course of one union: two marriages with no divorce
643
+ * between are one marriage to it, the first (found live, 2026-09-27: a marriage from the register and a later
644
+ * record's other date). A further one of these missing while the file has one of that kind is its shape, not an edit.
645
+ */
646
+ const UNION_KINDS = new Set(["MARR", "DIV"]);
647
+ function keptOnce(owner, gone, file) {
648
+ const kind = kindOf(gone.kind);
649
+ return !!owner.family && UNION_KINDS.has(kind) && file.some((f) => kindOf(f.kind) === kind);
650
+ }
562
651
  function knowsMore(ours, file) {
563
652
  if (kindOf(ours.kind) !== kindOf(file.kind))
564
653
  return false;
@@ -658,6 +747,7 @@ export function applySync(tree, plan, incoming, source, only) {
658
747
  const title = `${(c.name ?? owner).replace(/\//g, "").replace(/\s+/g, " ").trim()}: ${c.fact.kind} — ${describe(mine)} × ${describe(c.fact)}`;
659
748
  const x = create(tree, "conflict", {
660
749
  title: title.slice(0, 200),
750
+ fact: c.fact.kind,
661
751
  subject: [owner],
662
752
  claims: [
663
753
  { ...(mine.citations[0] ? { source: mine.citations[0].source } : {}), value: describe(mine), note: `the research: ${mine.id}` },
package/dist/core/tree.js CHANGED
@@ -23,7 +23,7 @@ import * as git from "./git.js";
23
23
  import { commitSeal, createKey, fingerprint, loadKey, sha256, signOp } from "./seal.js";
24
24
  import { stringifyCanonical } from "./json.js";
25
25
  export const TREE_FILE = "strom.json";
26
- export const VERSION = "1.5.0";
26
+ export const VERSION = "1.6.0";
27
27
  const GITIGNORE = `# Strom Research — local state and large files are not versioned
28
28
  .strom/
29
29
  /media/
@@ -12,6 +12,7 @@ import { shortcutCmdDir } from "./shortcut.js";
12
12
  import { globalTargets, isInstalled, uninstallGlobal } from "../agents/global.js";
13
13
  import { PROFILES } from "../agents/profiles.js";
14
14
  import { installation } from "./self.js";
15
+ import { linkFiles, unregisterLinks } from "./links.js";
15
16
  /** The mark the installer (install/install.sh) puts on the PATH line it adds. */
16
17
  const MARK = "# strom research";
17
18
  function exists(p) {
@@ -100,6 +101,8 @@ export function uninstallPlan(env, names, opts = {}) {
100
101
  remove.push({ kind: "agent", agent: PROFILES[t.agent]?.name ?? t.agent, path: t.file, remove: () => uninstallGlobal(t) });
101
102
  for (const f of shortcuts(env, platform, names))
102
103
  remove.push({ kind: "shortcut", path: f, remove: () => (fs.rmSync(f, { force: true }), true) });
104
+ for (const f of linkFiles(env, platform))
105
+ remove.push({ kind: "links", path: f, remove: () => unregisterLinks(env, platform) });
103
106
  const inst = opts.install ?? installation();
104
107
  const git = ownGitDir(env);
105
108
  // strom's own git lives in the installation's folder on Windows; alone, it goes by itself.
@@ -123,6 +123,18 @@ export function exportGedcom(tree, opts = {}) {
123
123
  if (pl.coords)
124
124
  for (const n of pl.names)
125
125
  coords.set(foldText(n.name), pl.coords);
126
+ // What the research knows beyond the facts (the Strom profile, opts.research): read once.
127
+ const research = {
128
+ conflicts: opts.research ? tree.list("conflict") : [],
129
+ hypotheses: opts.research ? tree.list("hypothesis").filter((h) => h.state === "open") : [],
130
+ // a search is of the people of the task it served
131
+ searched: opts.research
132
+ ? tree
133
+ .list("search")
134
+ .map((q) => ({ search: q, people: (q.task ? tree.get(q.task)?.subject : undefined) ?? [] }))
135
+ .filter((x) => x.people.length)
136
+ : [],
137
+ };
126
138
  // ── header ──
127
139
  w.line(0, "HEAD");
128
140
  w.line(1, "SOUR", "STROM_RESEARCH");
@@ -136,6 +148,10 @@ export function exportGedcom(tree, opts = {}) {
136
148
  const head = opts.head ?? git.head(tree.root);
137
149
  if (head)
138
150
  w.line(1, "_STROM_HEAD", head);
151
+ if (opts.links?.length)
152
+ w.line(1, "_STROM_LINKS", opts.links.join(" "));
153
+ if (opts.research)
154
+ w.line(1, "_STROM_ASOF", new Date().toISOString().slice(0, 10));
139
155
  }
140
156
  w.line(1, "SUBM", "@U1@");
141
157
  w.line(1, "GEDC");
@@ -185,6 +201,8 @@ export function exportGedcom(tree, opts = {}) {
185
201
  for (const d of [...nameQuotes, ...deferred])
186
202
  w.text(1, "NOTE", d);
187
203
  story(p.story);
204
+ if (opts.research && opts.for === "strom")
205
+ known(p);
188
206
  for (const link of famc.get(p.id) ?? []) {
189
207
  w.line(1, "FAMC", x(link.fam));
190
208
  if (link.relation === "adopted" || link.relation === "foster")
@@ -297,6 +315,8 @@ export function exportGedcom(tree, opts = {}) {
297
315
  w.line(1, "OBJE");
298
316
  w.line(2, "FORM", "jpg");
299
317
  w.line(2, "_STROM_KIND", "excerpt");
318
+ if (opts.clips && e.clip)
319
+ w.line(2, "_STROM_CLIP", e.clip);
300
320
  if (e.url)
301
321
  w.text(2, "_URL", e.url);
302
322
  w.wrapped(2, "FILE", dataUrl(e));
@@ -417,6 +437,40 @@ export function exportGedcom(tree, opts = {}) {
417
437
  * The story of a person or a couple: _STORY as Strom reads it (the facts it
418
438
  * rests on as DATA lines, the caveat as NOTE); a note in strict GEDCOM.
419
439
  */
440
+ /** What the research knows of a person beyond the facts: its conflicts, open hypotheses, what was searched for them. */
441
+ function known(p) {
442
+ const about = (subject) => subject.includes(p.id) || subject.some((id) => families.some((f) => f.id === id && f.partners.includes(p.id)));
443
+ for (const c of research.conflicts.filter((x) => about(x.subject))) {
444
+ w.line(1, "_STROM_CONFLICT", c.id);
445
+ // the fact it is about: its tag, else a conflict of the research named by its title
446
+ w.line(2, "TYPE", c.fact ?? "EVEN");
447
+ w.text(2, "TITL", c.title);
448
+ w.line(2, "STAT", c.state === "resolved" ? "decided" : "open");
449
+ for (const claim of c.claims) {
450
+ w.text(2, "VAL", claim.value);
451
+ if (claim.source && sourceById.has(claim.source)) {
452
+ citedSources.add(claim.source);
453
+ w.line(3, "SOUR", x(claim.source));
454
+ }
455
+ }
456
+ if (c.state === "resolved" && c.resolution)
457
+ w.text(2, "DECI", c.resolution);
458
+ }
459
+ for (const h of research.hypotheses.filter((x) => about(x.subject))) {
460
+ w.line(1, "_STROM_HYPO");
461
+ w.text(2, "TITL", h.question);
462
+ w.text(2, "NOTE", h.variants.map((v) => `${v.label}: ${v.claim}`).join("\n"));
463
+ }
464
+ for (const q of research.searched.filter((x) => x.people.includes(p.id)).map((x) => x.search)) {
465
+ w.line(1, "_STROM_SEARCHED");
466
+ w.text(2, "TITL", q.question);
467
+ const years = /^(\d{3,4})(?:\s*[-–]\s*(\d{3,4}))?$/.exec(q.scope.years ?? "");
468
+ if (years)
469
+ w.line(2, "DATE", years[2] && years[2] !== years[1] ? `FROM ${years[1]} TO ${years[2]}` : years[1]);
470
+ w.line(2, "RESN", q.result === "found" ? "found" : "none");
471
+ w.line(2, "_AT", q.created.slice(0, 10));
472
+ }
473
+ }
420
474
  function story(st) {
421
475
  if (!st)
422
476
  return;
@@ -14,11 +14,15 @@ const INDI_EVENTS = [
14
14
  const FAM_EVENTS = ["MARR", "DIV", "MARB", "MARC", "MARL", "MARS", "ANUL", "DIVF", "ENGA", "CENS", "EVEN", "NCHI"];
15
15
  /** Allowed child tags by parent context ("INDI", "INDI.BIRT", …). CONC/CONT are allowed under anything with text. */
16
16
  const CHILDREN = {
17
- HEAD: ["SOUR", "DEST", "DATE", "SUBM", "SUBN", "FILE", "COPR", "GEDC", "CHAR", "LANG", "PLAC", "NOTE", "_STROM_TREE", "_STROM_HEAD"],
17
+ HEAD: ["SOUR", "DEST", "DATE", "SUBM", "SUBN", "FILE", "COPR", "GEDC", "CHAR", "LANG", "PLAC", "NOTE", "_STROM_TREE", "_STROM_HEAD", "_STROM_LINKS", "_STROM_ASOF"],
18
18
  "HEAD.SOUR": ["VERS", "NAME", "CORP", "DATA"],
19
19
  "HEAD.GEDC": ["VERS", "FORM"],
20
20
  "HEAD.DATE": ["TIME"],
21
- INDI: ["NAME", "SEX", "FAMC", "FAMS", "NOTE", "SOUR", "REFN", "ASSO", "ALIA", "OBJE", "RESN", "RIN", "CHAN", "_STORY", ...INDI_EVENTS],
21
+ INDI: ["NAME", "SEX", "FAMC", "FAMS", "NOTE", "SOUR", "REFN", "ASSO", "ALIA", "OBJE", "RESN", "RIN", "CHAN", "_STORY", "_STROM_CONFLICT", "_STROM_HYPO", "_STROM_SEARCHED", ...INDI_EVENTS],
22
+ "INDI._STROM_CONFLICT": ["TYPE", "TITL", "STAT", "VAL", "DECI"],
23
+ "INDI._STROM_HYPO": ["TITL", "NOTE"],
24
+ "INDI._STROM_SEARCHED": ["TITL", "DATE", "RESN", "_AT"],
25
+ _STROM_VALUE: ["SOUR"],
22
26
  "INDI.NAME": ["TYPE", "GIVN", "SURN", "NPFX", "NSFX", "NICK", "SPFX", "SOUR", "NOTE"],
23
27
  "INDI.FAMC": ["PEDI", "NOTE"],
24
28
  "INDI.FAMS": ["NOTE"],
@@ -42,8 +46,11 @@ const CHILDREN = {
42
46
  _WITN: ["RELA", "NOTE"],
43
47
  ADDR: ["CONT", "ADR1", "ADR2", "ADR3", "CITY", "STAE", "POST", "CTRY"],
44
48
  };
45
- const TEXT_TAGS = new Set(["NOTE", "TEXT", "TITL", "PAGE", "AUTH", "PUBL", "_STORY", "DATA", "COPR", "ADDR", "CAUS", "FILE", "_URL"]);
46
- const EXTENSIONS = new Set(["_STORY", "_WITN", "_FREL", "_MREL", "_STROM_KIND", "_URL", "_STROM_TREE", "_STROM_HEAD"]);
49
+ const TEXT_TAGS = new Set(["NOTE", "TEXT", "TITL", "PAGE", "AUTH", "PUBL", "_STORY", "DATA", "COPR", "ADDR", "CAUS", "FILE", "_URL", "VAL", "DECI"]);
50
+ const EXTENSIONS = new Set([
51
+ "_STORY", "_WITN", "_FREL", "_MREL", "_STROM_KIND", "_URL", "_STROM_TREE", "_STROM_HEAD", "_STROM_LINKS", "_STROM_CLIP",
52
+ "_STROM_ASOF", "_STROM_CONFLICT", "_STROM_HYPO", "_STROM_SEARCHED", "_AT",
53
+ ]);
47
54
  /** Context of the children of the last tag in `anc` (the ancestors of a line). */
48
55
  function contextOf(anc) {
49
56
  const record = anc[0];
@@ -74,6 +81,8 @@ function contextOf(anc) {
74
81
  }
75
82
  if (depth === 3 && parent === "ASSO")
76
83
  return "EVENT.ASSO";
84
+ if (depth === 3 && (parent === "VAL" || parent === "DECI") && anc[1] === "_STROM_CONFLICT")
85
+ return "_STROM_VALUE";
77
86
  if (depth === 3 && (parent === "HUSB" || parent === "WIFE") && record === "FAM")
78
87
  return `EVENT.${parent}`;
79
88
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strom-research",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {