strom-research 1.6.0 → 1.6.1

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.
@@ -488,10 +488,13 @@
488
488
  "ui.sync.child.new": "{n}. {who}: dítě — {child}",
489
489
  "ui.sync.child.gone": "{n}. {who}: už to není jejich dítě — {child}",
490
490
  "ui.sync.person.gone": "{n}. už v něm není: {name}",
491
+ "ui.sync.place.coords": "{n}. {place}: poloha na mapě {from} → {at}",
492
+ "ui.sync.place.located": "{n}. {place}: poloha na mapě {at}",
491
493
  "ui.sync.do.add": "přidá se jako vodítko",
492
494
  "ui.sync.do.correct": "opraví vodítko výzkumu",
493
495
  "ui.sync.do.conflict": "rozpor k rozhodnutí (výzkum to má ze záznamu)",
494
496
  "ui.sync.do.user": "platí vaše úprava (údaj ze záznamu se stáhne s důvodem)",
497
+ "ui.sync.do.set": "výzkum převezme vaši polohu (žádný zápis ji nedokládá)",
495
498
  "ui.sync.do.pick": "jen když ho vyberete (--only)",
496
499
  "ui.sync.do.report": "nic se nemění — řekněte agentovi, co jste tím mysleli",
497
500
  "ui.sync.edits": "Vaše úpravy údajů doložených záznamem: {mode} (strom config set sync.edits conflict|user)",
@@ -488,10 +488,13 @@
488
488
  "ui.sync.child.new": "{n}. {who}: ein Kind — {child}",
489
489
  "ui.sync.child.gone": "{n}. {who}: nicht mehr ihr Kind — {child}",
490
490
  "ui.sync.person.gone": "{n}. nicht mehr darin: {name}",
491
+ "ui.sync.place.coords": "{n}. {place}: Lage auf der Karte {from} → {at}",
492
+ "ui.sync.place.located": "{n}. {place}: Lage auf der Karte {at}",
491
493
  "ui.sync.do.add": "als Hinweis übernommen",
492
494
  "ui.sync.do.correct": "berichtigt den Hinweis der Forschung",
493
495
  "ui.sync.do.conflict": "ein Widerspruch zum Entscheiden (die Forschung hat es aus einem Eintrag)",
494
496
  "ui.sync.do.user": "Ihre Änderung gilt (die Angabe aus dem Eintrag wird mit Grund zurückgezogen)",
497
+ "ui.sync.do.set": "die Forschung übernimmt Ihre Lage (kein Eintrag belegt sie)",
495
498
  "ui.sync.do.pick": "nur wenn Sie sie auswählen (--only)",
496
499
  "ui.sync.do.report": "nichts ändert sich — sagen Sie dem Agenten, was Sie meinten",
497
500
  "ui.sync.edits": "Ihre Änderungen an Angaben, die ein Eintrag belegt: {mode} (strom config set sync.edits conflict|user)",
package/dist/cli/ui.js CHANGED
@@ -420,10 +420,13 @@ export const UI = {
420
420
  "ui.sync.child.new": "{n}. {who}: a child — {child}",
421
421
  "ui.sync.child.gone": "{n}. {who}: no longer their child — {child}",
422
422
  "ui.sync.person.gone": "{n}. no longer in it: {name}",
423
+ "ui.sync.place.coords": "{n}. {place}: on the map {from} → {at}",
424
+ "ui.sync.place.located": "{n}. {place}: on the map at {at}",
423
425
  "ui.sync.do.add": "added as a lead",
424
426
  "ui.sync.do.correct": "corrects the research's lead",
425
427
  "ui.sync.do.conflict": "a conflict for you to decide (the research has it from a record)",
426
428
  "ui.sync.do.user": "your edit wins (the record's fact withdrawn with the reason)",
429
+ "ui.sync.do.set": "the research takes your position (no record proves one)",
427
430
  "ui.sync.do.pick": "only when you pick it (--only)",
428
431
  "ui.sync.do.report": "nothing changes — tell the agent what you meant",
429
432
  "ui.sync.edits": "Your edits to facts a record proves: {mode} (strom config set sync.edits conflict|user)",
@@ -613,6 +613,8 @@ register({
613
613
  // Close what the agent left open, record the metrics, export, commit.
614
614
  const after = Tree.open(root, runEnv);
615
615
  let s = after.get(session.id);
616
+ // strom worked for the agent when it closed the session itself or recorded something
617
+ const ranStrom = s.state !== "open" || sessionWrites(after, s.id) > 0;
616
618
  if (s.state === "open")
617
619
  s = closeSession(after, s, {
618
620
  summary: result.outcome === "stopped" ? phrase(after.lang, "session.user") : phrase(after.lang, "session.agent", { outcome: result.outcome }),
@@ -656,7 +658,9 @@ register({
656
658
  break;
657
659
  }
658
660
  // An agent that may not run strom can do nothing: stop, do not burn session after session.
659
- if (result.denied?.some((d) => /^Bash: (\S*\/)?strom\b/.test(d))) {
661
+ // A refused command that only starts with strom (strom … && sed …, a loop) is no such thing
662
+ // when strom worked for it otherwise.
663
+ if (!ranStrom && result.denied?.some((d) => /^Bash: (\S*\/)?strom\b/.test(d))) {
660
664
  stopCode = "denied";
661
665
  break;
662
666
  }
@@ -21,6 +21,8 @@ function factText(f, lang) {
21
21
  return "—";
22
22
  return [f.value, humanDate(f.date, lang), humanPlace(f.place, undefined, lang)].filter(Boolean).join(", ") || "—";
23
23
  }
24
+ /** A position on the map as a map shows it: degrees, six decimals at most. */
25
+ const point = (p) => `${+p.lat.toFixed(6)}, ${+p.lon.toFixed(6)}`;
24
26
  /** One change as the user reads it, with what strom does with it. */
25
27
  function changeLine(tree, c, incoming, lang) {
26
28
  const name = (key) => {
@@ -44,8 +46,12 @@ function changeLine(tree, c, incoming, lang) {
44
46
  text: truncate(c.text ?? "", 120),
45
47
  child: name(c.child),
46
48
  kids: c.kind === "family.new" && c.text ? ` + ${c.text.split(" ").filter(Boolean).map(name).join(", ")}` : "",
49
+ place: c.place?.name ?? "",
50
+ at: c.place ? point(c.place) : "",
51
+ from: c.place?.was ? point(c.place.was) : "",
47
52
  };
48
- return `${ui(lang, `ui.sync.${c.kind}`, v)} → ${ui(lang, `ui.sync.do.${c.action}`)}`;
53
+ const key = c.kind === "place.coords" && !c.place?.was ? "ui.sync.place.located" : `ui.sync.${c.kind}`;
54
+ return `${ui(lang, key, v)} → ${ui(lang, `ui.sync.do.${c.action}`)}`;
49
55
  }
50
56
  function planText(tree, plan, incoming, file, lang, edits) {
51
57
  const shown = file.includes(" ") ? `"${file}"` : file;
package/dist/core/sync.js CHANGED
@@ -40,6 +40,45 @@ const kindOf = (k) => (k === "CHR" ? "BAPM" : k);
40
40
  export const factKey = (f) => [kindOf(f.kind), f.date ?? "", exact(f.place), exact(f.value), f.date || f.place ? "" : fold(f.label)].join("|");
41
41
  /** A name for a comparison: "? /Novák/", "Jan //" and "Jan /?/" are the names the research has. */
42
42
  const nameKey = (n) => exact(n.replace(/[/?]/g, " "));
43
+ /** A place's name as the Strom app keys its coordinates (placeKey of its places.ts): no accents, case, stops or commas. */
44
+ export const placeKey = (s) => foldText(s.replace(/[.,;]/g, " ")).replace(/\s+/g, " ").trim();
45
+ /** The Strom app's own key of a place's coordinates in its JSON (places.ts). */
46
+ const appPlaceKey = (s) => s
47
+ .normalize("NFD")
48
+ .replace(/[\u0300-\u036f]/g, "")
49
+ .toLowerCase()
50
+ .replace(/[.,;]/g, " ")
51
+ .replace(/\s+/g, " ")
52
+ .trim();
53
+ /** GEDCOM's N50.042 / E15.358 (also a bare or negative number): degrees, the south and west negative. */
54
+ function degrees(v, neg, max) {
55
+ const m = /^\s*([NSEW])?\s*(-?\d{1,3}(?:\.\d+)?)\s*$/i.exec(v ?? "");
56
+ if (!m)
57
+ return undefined;
58
+ const n = Number(m[2]) * (m[1]?.toUpperCase() === neg ? -1 : 1);
59
+ return Number.isFinite(n) && Math.abs(n) <= max ? n : undefined;
60
+ }
61
+ /** The places of a GEDCOM with their MAP > LATI/LONG, wherever a PLAC is. */
62
+ function gedPlaces(records) {
63
+ const out = new Map();
64
+ const walk = (n) => {
65
+ for (const c of n.children) {
66
+ const map = c.tag === "PLAC" ? c.children.find((x) => x.tag === "MAP") : undefined;
67
+ const lat = map ? degrees(val(map, "LATI"), "S", 90) : undefined;
68
+ const lon = map ? degrees(val(map, "LONG"), "W", 180) : undefined;
69
+ const name = c.value.replace(/\s+/g, " ").trim();
70
+ if (lat !== undefined && lon !== undefined && name && !out.has(placeKey(name)))
71
+ out.set(placeKey(name), { name, lat, lon });
72
+ walk(c);
73
+ }
74
+ };
75
+ for (const r of records)
76
+ if (r.tag === "INDI" || r.tag === "FAM")
77
+ walk(r);
78
+ return out;
79
+ }
80
+ /** The same point: the Strom app keeps six decimals. */
81
+ const samePoint = (a, b) => Math.abs(a.lat - b.lat) < 5e-7 && Math.abs(a.lon - b.lon) < 5e-7;
43
82
  /** The Strom app's "Birth (alternative record)": a second birth or death of ours coming back as an event of its own. */
44
83
  const ALTERNATIVE = {
45
84
  "birth (alternative record)": "BIRT", "death (alternative record)": "DEAT",
@@ -155,7 +194,7 @@ export function readGedcom(text) {
155
194
  }
156
195
  const treeId = val(head, "_STROM_TREE");
157
196
  const at = val(head, "_STROM_HEAD");
158
- return { format: "gedcom", ...(treeId ? { treeId } : {}), ...(at ? { head: at } : {}), persons, families, problems };
197
+ return { format: "gedcom", ...(treeId ? { treeId } : {}), ...(at ? { head: at } : {}), persons, families, places: gedPlaces(records), problems };
159
198
  }
160
199
  /** The Strom app's life events → our kinds. */
161
200
  const APP_EVENTS = {
@@ -236,8 +275,19 @@ export function readStromJson(data) {
236
275
  }
237
276
  families.push({ partners, children: (u.childIds ?? []).map((c) => keys.get(c)).filter((k) => !!k), facts });
238
277
  }
278
+ // the app's coordinates are kept for the place's name (its placeKey): the names are its facts'
279
+ const places = new Map();
280
+ const geo = d.places && typeof d.places === "object" ? d.places : {};
281
+ for (const f of [...persons.values()].flatMap((p) => p.facts).concat(families.flatMap((x) => x.facts))) {
282
+ const k = f.place ? placeKey(f.place) : "";
283
+ const g = f.place ? geo[appPlaceKey(f.place)] : undefined;
284
+ if (!g || places.has(k) || typeof g.lat !== "number" || typeof g.lon !== "number")
285
+ continue;
286
+ if (Math.abs(g.lat) <= 90 && Math.abs(g.lon) <= 180)
287
+ places.set(k, { name: f.place, lat: g.lat, lon: g.lon });
288
+ }
239
289
  const r = d.research;
240
- return { format: "strom-json", ...(r?.id ? { treeId: r.id } : {}), ...(r?.head ? { head: r.head } : {}), persons, families, problems: [] };
290
+ return { format: "strom-json", ...(r?.id ? { treeId: r.id } : {}), ...(r?.head ? { head: r.head } : {}), persons, families, places, problems: [] };
241
291
  }
242
292
  /** A file coming back: GEDCOM or the Strom app's JSON; empty or unreadable throws. */
243
293
  export function readTreeFile(file) {
@@ -635,8 +685,32 @@ export function planSync(tree, incoming, edits, opts = {}) {
635
685
  push({ kind: "child.gone", action: "report", family: fam.id, partners, child: c });
636
686
  }
637
687
  }
688
+ // places on the map: no record proves a position, so the user's is taken — set where the research has none,
689
+ // or changed in the app since it was given (the research's as it was given); anything else only when picked
690
+ const ourPlaces = researchPlaces(tree);
691
+ for (const [k, p] of incoming.places ?? []) {
692
+ const mine = ourPlaces.get(k);
693
+ const here = mine?.coords;
694
+ if (here && samePoint(here, p))
695
+ continue;
696
+ const given = base?.places?.get(k);
697
+ if (given && samePoint(given, p))
698
+ continue; // as it was given: the research moved it since
699
+ const action = !here || (given && samePoint(given, here)) ? "set" : "pick";
700
+ push({ kind: "place.coords", action, place: { ...p, ...(mine ? { id: mine.id } : {}), ...(here ? { was: here } : {}) } });
701
+ }
638
702
  return { changes, base: !!base, ...(incoming.head ? { head: incoming.head } : {}), partial, identity };
639
703
  }
704
+ /** The research's places by placeKey of each of their names — one with coordinates first, as the export gives them. */
705
+ function researchPlaces(tree) {
706
+ const out = new Map();
707
+ for (const p of tree.list("place"))
708
+ if (!p.retracted)
709
+ for (const n of p.names)
710
+ if (!out.has(placeKey(n.name)) || (p.coords && !out.get(placeKey(n.name)).coords))
711
+ out.set(placeKey(n.name), p);
712
+ return out;
713
+ }
640
714
  /** 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
715
  /**
642
716
  * The Strom app keeps a couple's marriages and divorces as the course of one union: two marriages with no divorce
@@ -798,6 +872,25 @@ export function applySync(tree, plan, incoming, source, only) {
798
872
  applied.push({ do: "child.add", id: c.family, before: child });
799
873
  break;
800
874
  }
875
+ case "place.coords": {
876
+ const p = c.place;
877
+ if (!p)
878
+ break;
879
+ const coords = { lat: p.lat, lon: p.lon };
880
+ const mine = p.id ? tree.get(p.id) : undefined;
881
+ if (mine && !mine.retracted) {
882
+ update(tree, mine.id, "place", ({ unlocated: _found, ...rest }) => ({ ...rest, coords }), {
883
+ op: "place.edit",
884
+ summary: `${mine.id} at ${p.lat}, ${p.lon}: ${reason}`,
885
+ });
886
+ applied.push({ do: "place.edit", id: mine.id, before: JSON.stringify({ coords: mine.coords, unlocated: mine.unlocated }) });
887
+ }
888
+ else {
889
+ const made = create(tree, "place", { names: [{ name: p.name }], coords, jurisdictions: [], note: `the position from ${reason}` }, (id) => `+${id} place "${p.name}" (from ${source.id})`);
890
+ applied.push({ do: "place.add", id: made.id });
891
+ }
892
+ break;
893
+ }
801
894
  default:
802
895
  break;
803
896
  }
@@ -858,6 +951,17 @@ export function undoSync(tree, input) {
858
951
  if (!tree.get(a.id)?.retracted)
859
952
  retractPerson(tree, a.id, reason);
860
953
  break;
954
+ case "place.edit": {
955
+ const b = JSON.parse(String(a.before ?? "{}"));
956
+ update(tree, a.id, "place", ({ coords: _now, unlocated: _u, ...p }) => ({ ...p, ...(b.coords ? { coords: b.coords } : {}), ...(b.unlocated ? { unlocated: b.unlocated } : {}) }), {
957
+ op: "place.edit",
958
+ summary: `${a.id} position back: ${reason}`,
959
+ });
960
+ break;
961
+ }
962
+ case "place.add":
963
+ update(tree, a.id, "place", (p) => ({ ...p, retracted: { at: now(), reason } }), { op: "place.retract", summary: `${a.id} retracted: ${reason}` });
964
+ break;
861
965
  }
862
966
  n++;
863
967
  }
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.6.0";
26
+ export const VERSION = "1.6.1";
27
27
  const GITIGNORE = `# Strom Research — local state and large files are not versioned
28
28
  .strom/
29
29
  /media/
@@ -120,7 +120,7 @@ export function exportGedcom(tree, opts = {}) {
120
120
  // Places with coordinates, looked up by folded name.
121
121
  const coords = new Map();
122
122
  for (const pl of tree.list("place"))
123
- if (pl.coords)
123
+ if (pl.coords && !pl.retracted)
124
124
  for (const n of pl.names)
125
125
  coords.set(foldText(n.name), pl.coords);
126
126
  // What the research knows beyond the facts (the Strom profile, opts.research): read once.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strom-research",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {