strom-research 1.15.0-beta.2 → 1.15.0-beta.3

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.
@@ -1537,6 +1537,9 @@
1537
1537
  "ui.intake.done": "✓ Přidáno do výzkumu: {n}. Agent to projde při další práci.",
1538
1538
  "ui.intake.done.archive": "✓ Uloženo do archivu: {n}. Projde se, až se zapne výzkum.",
1539
1539
  "ui.intake.done.waiting": "✓ Uloženo do výzkumu: {n}. Agent to projde, až bude na počítači.",
1540
+ "ui.intake.tree": "{id} rodokmen převzat – osoby: {persons} · rodiny: {families} · údaje: {events}, vše jako vodítka (pramen {source})",
1541
+ "ui.intake.tree.matched": " – už ve výzkumu: {n}",
1542
+ "ui.intake.tree.back": "Je to rodokmen tohoto výzkumu, který se vrací: strom sync \"{file}\" ukáže i to, co se v něm změnilo.",
1540
1543
  "ui.intake.same": "Už tu bylo dřív: {n}.",
1541
1544
  "ui.intake.chat": "Projít to s agentem hned?",
1542
1545
  "ui.intake.say": "Do výzkumu jsem přidal(a) nové podklady – pojďme je projít.",
@@ -1912,6 +1915,17 @@
1912
1915
  "ui.error.pack.no-manifest.hint": "zadat ZIP soubor, který přišel",
1913
1916
  "ui.error.fs.no-permission": "chybí oprávnění{where}",
1914
1917
  "ui.error.fs.no-permission.hint": "vybrat složku, do které lze zapisovat",
1918
+ "ui.task.none": "Žádné úkoly.",
1919
+ "ui.research.none": "Žádné směry výzkumu.",
1920
+ "ui.person.none": "Žádné osoby.",
1921
+ "ui.person.nomatch": "Žádná osoba neodpovídá.",
1922
+ "ui.person.leadsonly": "jen vodítka",
1923
+ "ui.person.events": "údaje: {n}",
1924
+ "ui.list.more": "… dalších {n}: {command}",
1925
+ "ui.error.archive": "Tento výzkum je archiv: data přicházejí z aplikace Strom a nic se nezkoumá. Výzkum se zapíná v menu, první položka.",
1926
+ "ui.error.archive.hint": "nebo v terminálu: strom mode research",
1927
+ "ui.error.agent.missing": "Výzkum čeká na AI agenta ({name}), který na tomto počítači není: do té doby drží data z aplikace Strom jako archiv. Agenta nainstalovat nebo vybrat jiného: menu, první položka.",
1928
+ "ui.error.agent.missing.hint": "nebo v terminálu: strom setup",
1915
1929
  "ui.error.fs.missing": "nenalezeno: {path}",
1916
1930
  "ui.error.fs.missing.hint": "zkontrolovat cestu (relativní cesta začíná v aktuální složce)",
1917
1931
  "ui.error.grep.in.missing": "--in {where}: ve výzkumu nenalezeno",
@@ -1537,6 +1537,9 @@
1537
1537
  "ui.intake.done": "✓ Zur Forschung hinzugefügt: {n}. Der Agent sieht es bei der nächsten Arbeit durch.",
1538
1538
  "ui.intake.done.archive": "✓ Im Archiv aufbewahrt: {n}. Es wird durchgesehen, sobald die Forschung eingeschaltet ist.",
1539
1539
  "ui.intake.done.waiting": "✓ In der Forschung aufbewahrt: {n}. Der Agent sieht es durch, sobald er auf diesem Computer ist.",
1540
+ "ui.intake.tree": "{id} Stammbaum übernommen – Personen: {persons} · Familien: {families} · Angaben: {events}, alle als Hinweise (Quelle {source})",
1541
+ "ui.intake.tree.matched": " – schon in der Forschung: {n}",
1542
+ "ui.intake.tree.back": "Es ist der Stammbaum dieser Forschung, der zurückkommt: strom sync \"{file}\" zeigt auch, was darin geändert wurde.",
1540
1543
  "ui.intake.same": "Schon vorher hier: {n}.",
1541
1544
  "ui.intake.chat": "Jetzt mit dem Agenten durchgehen?",
1542
1545
  "ui.intake.say": "Ich habe der Forschung neue Unterlagen hinzugefügt – gehen wir sie durch.",
@@ -1912,6 +1915,17 @@
1912
1915
  "ui.error.pack.no-manifest.hint": "die ZIP-Datei angeben, die gekommen ist",
1913
1916
  "ui.error.fs.no-permission": "keine Berechtigung{where}",
1914
1917
  "ui.error.fs.no-permission.hint": "einen Ordner wählen, in den geschrieben werden darf",
1918
+ "ui.task.none": "Keine Aufgaben.",
1919
+ "ui.research.none": "Keine Forschungsrichtungen.",
1920
+ "ui.person.none": "Keine Personen.",
1921
+ "ui.person.nomatch": "Keine Person passt.",
1922
+ "ui.person.leadsonly": "nur Hinweise",
1923
+ "ui.person.events": "Angaben: {n}",
1924
+ "ui.list.more": "… {n} weitere: {command}",
1925
+ "ui.error.archive": "Diese Forschung ist ein Archiv: die Daten kommen aus der Strom-App, und nichts wird erforscht. Die Forschung wird im Menü eingeschaltet, erster Punkt.",
1926
+ "ui.error.archive.hint": "oder im Terminal: strom mode research",
1927
+ "ui.error.agent.missing": "Die Forschung wartet auf ihren KI-Agenten ({name}), der auf diesem Computer nicht ist: bis dahin bewahrt sie die Daten aus der Strom-App als Archiv. Den Agenten installieren oder einen anderen wählen: das Menü, erster Punkt.",
1928
+ "ui.error.agent.missing.hint": "oder im Terminal: strom setup",
1915
1929
  "ui.error.fs.missing": "nicht gefunden: {path}",
1916
1930
  "ui.error.fs.missing.hint": "den Pfad prüfen (ein relativer Pfad beginnt im aktuellen Ordner)",
1917
1931
  "ui.error.grep.in.missing": "--in {where}: in der Forschung nicht gefunden",
@@ -1,5 +1,6 @@
1
1
  // Compact, token-efficient text output: aligned columns, one row per record,
2
2
  // no box drawing, IDs first.
3
+ import { ui } from "./ui.js";
3
4
  export function table(rows, opts = {}) {
4
5
  const all = opts.header ? [opts.header, ...rows] : rows;
5
6
  if (all.length === 0)
@@ -25,12 +26,13 @@ export function paginate(items, limit, page) {
25
26
  * — an agent's tool keeps a longer output in a file and shows only its start (N0280, N0281: the filters never seen).
26
27
  */
27
28
  export const LIST_PAGE_MAX = 100;
28
- /** Footer telling the caller how to see more. */
29
- export function moreLine(page, command) {
29
+ /** Footer telling the caller how to see more — in the research's language where the listing is (lang), else English. */
30
+ export function moreLine(page, command, lang) {
30
31
  if (page.page >= page.pages)
31
32
  return "";
32
33
  const left = page.total - page.page * page.limit;
33
- return `… ${left} more: ${command} --page ${page.page + 1}`;
34
+ const next = `${command} --page ${page.page + 1}`;
35
+ return lang ? ui(lang, "ui.list.more", { n: String(left), command: next }) : `… ${left} more: ${next}`;
34
36
  }
35
37
  export function truncate(text, max) {
36
38
  const one = text.replace(/\s+/g, " ").trim();
@@ -20,18 +20,10 @@ export async function switchTo(ctx, run, lang, root, to) {
20
20
  return;
21
21
  let asked = false;
22
22
  if (to === "research" && !agentReady(ctx, tree.config)) {
23
- out(t("ui.mode.agent.get"));
24
- if (agentsHere(ctx.env).length) {
25
- await run(["setup", "--research"]);
26
- asked = true;
27
- }
28
- else
29
- await offerAgent(ctx, out);
30
- ctx.settings.reload();
31
- if (!agentReady(ctx, Tree.open(root, ctx.env).config)) {
32
- out(t("ui.mode.later"));
23
+ const got = await agentFirst(ctx, run, lang, root);
24
+ if (!got.ready)
33
25
  return pause(ctx, lang);
34
- }
26
+ asked = got.asked;
35
27
  }
36
28
  if (!(await ctx.confirm(t(to === "archive" ? "ui.consent.mode.archive" : "ui.consent.mode.research", { name: tree.config.name }), false)))
37
29
  return;
@@ -48,8 +40,30 @@ export async function switchTo(ctx, run, lang, root, to) {
48
40
  }
49
41
  await pause(ctx, lang);
50
42
  }
43
+ /**
44
+ * Research switched on where the research's agent is not here: the agent first — the wizard when another one is here,
45
+ * else the one offered. None got: a sentence says the archive stays as it was (the caller writes nothing). `asked`: the
46
+ * wizard's agent steps were asked just now.
47
+ */
48
+ export async function agentFirst(ctx, run, lang, root) {
49
+ const t = translator(lang);
50
+ const out = (line) => ctx.io.stdout(line + "\n");
51
+ out(t("ui.mode.agent.get"));
52
+ let asked = false;
53
+ if (agentsHere(ctx.env).length) {
54
+ await run(["setup", "--research"]);
55
+ asked = true;
56
+ }
57
+ else
58
+ await offerAgent(ctx, out);
59
+ ctx.settings.reload();
60
+ if (agentReady(ctx, Tree.open(root, ctx.env).config))
61
+ return { ready: true, asked };
62
+ out(t("ui.mode.later"));
63
+ return { ready: false, asked };
64
+ }
51
65
  /** The setup's steps of an agent never asked on this computer (stories: asked with them by every wizard before these). */
52
- function agentStepsSkipped(ctx) {
66
+ export function agentStepsSkipped(ctx) {
53
67
  ctx.settings.reload();
54
68
  const c = ctx.settings.config;
55
69
  return !c.agentAsked && c.stories === undefined;
package/dist/cli/ui.js CHANGED
@@ -1450,6 +1450,9 @@ export const UI = {
1450
1450
  "ui.intake.done": "✓ Added to the research: {n}. The agent goes through it in its next work.",
1451
1451
  "ui.intake.done.archive": "✓ Kept in the archive: {n}. It is gone through once research is switched on.",
1452
1452
  "ui.intake.done.waiting": "✓ Kept in the research: {n}. The agent goes through it once it is on this computer.",
1453
+ "ui.intake.tree": "{id} family tree taken in – people: {persons} · families: {families} · facts: {events}, all as leads (source {source})",
1454
+ "ui.intake.tree.matched": " – already in the research: {n}",
1455
+ "ui.intake.tree.back": "It is this research's own family tree coming back: strom sync \"{file}\" shows what was changed in it too.",
1453
1456
  "ui.intake.same": "Already here before: {n}.",
1454
1457
  "ui.intake.chat": "Go through it with the agent now?",
1455
1458
  "ui.intake.say": "I added new material to the research — let us go through it.",
@@ -1716,6 +1719,17 @@ export const UI = {
1716
1719
  "ui.error.pack.no-manifest.hint": "the ZIP file that came",
1717
1720
  "ui.error.fs.no-permission": "no permission to use{where}",
1718
1721
  "ui.error.fs.no-permission.hint": "a folder with the right to write in it",
1722
+ "ui.task.none": "No tasks.",
1723
+ "ui.research.none": "No research directions.",
1724
+ "ui.person.none": "No people.",
1725
+ "ui.person.nomatch": "No people match.",
1726
+ "ui.person.leadsonly": "leads only",
1727
+ "ui.person.events": "{n} ev",
1728
+ "ui.list.more": "… {n} more: {command}",
1729
+ "ui.error.archive": "This research is an archive: its data come from the Strom app and nothing is researched. Research is switched on in the menu, its first item.",
1730
+ "ui.error.archive.hint": "or in a terminal: strom mode research",
1731
+ "ui.error.agent.missing": "The research waits for its AI agent ({name}), which is not on this computer: until then it keeps the data from the Strom app as an archive. Installing the agent or choosing another: the menu, its first item.",
1732
+ "ui.error.agent.missing.hint": "or in a terminal: strom setup",
1719
1733
  "ui.error.fs.missing": "no such file or folder: {path}",
1720
1734
  "ui.error.fs.missing.hint": "check the path (relative paths start in the current folder)",
1721
1735
  "ui.error.grep.in.missing": "--in {where}: no such file or folder in the research",
@@ -14,7 +14,7 @@ import { collectFiles, fileSha256, inputPath, MAX_IN_TREE, mimeOf, storeShared }
14
14
  import { create, normId, requireRecord, update } from "../core/records.js";
15
15
  import { phrase } from "../core/phrases.js";
16
16
  import { importGedcom, importStromJson, isStromJson } from "../core/import.js";
17
- import { safeFolderName } from "../core/text.js";
17
+ import { decodeText, safeFolderName } from "../core/text.js";
18
18
  import { amendInput, checkOriginalMeta } from "../core/originals.js";
19
19
  import { batchStatus, closeBatch, listBatches, readBatch } from "../core/batches.js";
20
20
  import { SORT_KINDS } from "../core/model.js";
@@ -134,6 +134,9 @@ register({
134
134
  });
135
135
  return { folder: folder ? path.basename(p) : undefined, files };
136
136
  });
137
+ // where nobody researches (an archive, a research waiting for its agent): said to the person, in the research's
138
+ // language — kept, no task to take next (an archive's waits put aside until research is switched on)
139
+ const quiet = actsAsArchive(tree);
137
140
  const known = new Map(tree.list("input").filter((i) => i.sha).map((i) => [i.sha, i.id]));
138
141
  const report = [];
139
142
  const added = [];
@@ -154,7 +157,8 @@ register({
154
157
  }
155
158
  const size = fs.statSync(file).size;
156
159
  const isText = size < 50 * 1024 * 1024 && [".ged", ".json", ".txt", ".md", ".csv"].includes(path.extname(file).toLowerCase());
157
- const text = isText ? fs.readFileSync(file, "utf8") : undefined;
160
+ // as whatever saved it: a BOM (Notepad, PowerShell 5) or UTF-16 is still the same family tree
161
+ const text = isText ? decodeText(fs.readFileSync(file)) : undefined;
158
162
  const kind = kindOf(file, text);
159
163
  const id = tree.peekId("I");
160
164
  let stored;
@@ -179,16 +183,25 @@ register({
179
183
  op: "input.imported",
180
184
  summary: `${input.id} imported: ${imported.persons} persons, ${imported.families} families${imported.matched ? `, ${imported.matched} matched` : ""}`,
181
185
  });
182
- report.push(` ${input.id} tree: ${imported.persons} persons, ${imported.families} families, ${imported.events} facts as leads${imported.matched ? `, ${imported.matched} matched to existing` : ""}${imported.problems.length ? ` (${imported.problems.length} unreadable lines)` : ""}`);
183
- if (imported.extended.length)
184
- report.push(` added to what we have (leads — check them): ${imported.extended.slice(0, 6).join(" · ")}${imported.extended.length > 6 ? " …" : ""}`);
185
- // the research's own tree coming back: sync sees what was changed in it too, not only what was added
186
- if (imported.matched && imported.matched >= imported.persons)
187
- report.push(` it is this research's tree coming back: strom sync "${ctx.display(file)}" shows what was changed in it too`);
188
- const system = `gedcom:${sha.slice(0, 12)}`;
189
- const dupes = likelyDuplicates(tree, tree.list("person").filter((p) => p.refs?.some((r) => r.system === system)).map((p) => p.id));
190
- if (dupes.length)
191
- report.push(` probably already in the tree: ${dupes.map((d) => `${d.person.id}≈${d.same.id}`).join(", ")} (the review task lists them)`);
186
+ const back = imported.matched && imported.matched >= imported.persons;
187
+ if (quiet) {
188
+ // where nobody researches the person reads it, in the research's language: the tree is in it (no review talk)
189
+ report.push(` ${ui(tree.lang, "ui.intake.tree", { id: input.id, persons: imported.persons, families: imported.families, events: imported.events, source: imported.source })}${imported.matched ? ui(tree.lang, "ui.intake.tree.matched", { n: imported.matched }) : ""}`);
190
+ if (back)
191
+ report.push(` ${ui(tree.lang, "ui.intake.tree.back", { file: ctx.display(file) })}`);
192
+ }
193
+ else {
194
+ report.push(` ${input.id} tree: ${imported.persons} persons, ${imported.families} families, ${imported.events} facts as leads${imported.matched ? `, ${imported.matched} matched to existing` : ""}${imported.problems.length ? ` (${imported.problems.length} unreadable lines)` : ""}`);
195
+ if (imported.extended.length)
196
+ report.push(` added to what we have (leads — check them): ${imported.extended.slice(0, 6).join(" · ")}${imported.extended.length > 6 ? " …" : ""}`);
197
+ // the research's own tree coming back: sync sees what was changed in it too, not only what was added
198
+ if (back)
199
+ report.push(` it is this research's tree coming back: strom sync "${ctx.display(file)}" shows what was changed in it too`);
200
+ const system = `gedcom:${sha.slice(0, 12)}`;
201
+ const dupes = likelyDuplicates(tree, tree.list("person").filter((p) => p.refs?.some((r) => r.system === system)).map((p) => p.id));
202
+ if (dupes.length)
203
+ report.push(` probably already in the tree: ${dupes.map((d) => `${d.person.id}≈${d.same.id}`).join(", ")} (the review task lists them)`);
204
+ }
192
205
  }
193
206
  // A family tree gets its own review task; the other files of a folder share one — files for a task: none
194
207
  if (forTask)
@@ -215,9 +228,6 @@ register({
215
228
  added.push(input);
216
229
  }
217
230
  });
218
- // where nobody researches (an archive, a research waiting for its agent): said to the person, in the research's
219
- // language — kept, no task to take next (an archive's waits put aside until research is switched on)
220
- const quiet = actsAsArchive(tree);
221
231
  const text = lines(quiet
222
232
  ? added.length
223
233
  ? `${ui(tree.lang, isArchive(tree) ? "ui.intake.done.archive" : "ui.intake.done.waiting", { n: added.length })}${skipped ? ` ${ui(tree.lang, "ui.intake.same", { n: skipped })}` : ""}`
@@ -211,10 +211,12 @@ register({
211
211
  displayName(p),
212
212
  lifespan(p),
213
213
  gens ? `G${gens.get(p.id)}` : "",
214
- `${p.events.length} ev`,
215
- p.events.some((e) => e.status === "proven" || e.status === "probable") ? "" : "leads only",
214
+ ui(tree.lang, "ui.person.events", { n: String(p.events.length) }),
215
+ p.events.some((e) => e.status === "proven" || e.status === "probable") ? "" : ui(tree.lang, "ui.person.leadsonly"),
216
216
  ]);
217
- const text = people.length === 0 ? "no people match" : lines(table(rows), moreLine(page, "strom person list"));
217
+ // in the research's language (the person follows it in the agent's session too)
218
+ const filtered = !!args[0] || !!opts.research;
219
+ const text = people.length === 0 ? ui(tree.lang, filtered ? "ui.person.nomatch" : "ui.person.none") : lines(table(rows), moreLine(page, "strom person list", tree.lang));
218
220
  return {
219
221
  text,
220
222
  data: {
@@ -6,7 +6,7 @@ import { applyFrontier, researchProposals } from "./session.js";
6
6
  import { frontier } from "../core/frontier.js";
7
7
  import { DEATH_AFTER_YEARS, livingBorn, unprovenPeople } from "../core/review.js";
8
8
  import { parkedWhy } from "./tasks.js";
9
- import { ui } from "../cli/ui.js";
9
+ import { placeholders, ui } from "../cli/ui.js";
10
10
  import { phrase } from "../core/phrases.js";
11
11
  import { UsageError } from "../core/errors.js";
12
12
  import { REVIEW_SCOPES } from "../core/model.js";
@@ -15,7 +15,8 @@ import { foldText } from "../core/text.js";
15
15
  import { now } from "../core/tree.js";
16
16
  import { directionOf, scopes } from "../core/directions.js";
17
17
  import { otherKind, otherKindHint, update } from "../core/records.js";
18
- import { isAgent } from "../core/which.js";
18
+ import { detectAgent, isAgent } from "../core/which.js";
19
+ import { actsAsArchive, refuseInArchive } from "../core/state.js";
19
20
  import { costPartial, sessionCost } from "../core/session.js";
20
21
  function int(v, name) {
21
22
  if (v === undefined)
@@ -73,6 +74,8 @@ register({
73
74
  ],
74
75
  run(ctx, { args, opts }) {
75
76
  const tree = ctx.tree();
77
+ // a direction is the agent's work: refused where nobody researches (an archive, a research waiting for its agent)
78
+ refuseInArchive(tree, "strom research new", detectAgent(ctx.env) ?? ctx.settings.agent(tree.config).value);
76
79
  if (!opts.person === !opts["new-person"])
77
80
  throw new UsageError("give exactly one of --person <who> or --new-person <name>");
78
81
  let focus;
@@ -110,8 +113,12 @@ register({
110
113
  run(ctx, { opts }) {
111
114
  const tree = ctx.tree();
112
115
  const all = tree.list("research").filter((r) => !opts.state || r.state === opts.state);
116
+ // in the research's language; an archive (or one waiting for its agent) is told of no direction it would refuse
113
117
  if (all.length === 0)
114
- return { text: 'no researches yet → strom research new "<name>" --new-person "<Given /Surname/>"', data: { researches: [] } };
118
+ return {
119
+ text: `${ui(tree.lang, "ui.research.none")}${actsAsArchive(tree) ? "" : ` → ${placeholders(tree.lang, 'strom research new "<research name>" --new-person "<Given /Surname/>"')}`}`,
120
+ data: { researches: [] },
121
+ };
115
122
  return { text: table(all.map((r) => researchLine(tree, r))), data: { researches: all } };
116
123
  },
117
124
  }, ...["pause", "resume", "done"].map((act) => ({
@@ -230,6 +237,8 @@ register({
230
237
  ],
231
238
  async run(ctx, { args, opts }) {
232
239
  const tree = ctx.tree();
240
+ // its tasks are the agent's work: refused where nobody researches, before anything is asked or written
241
+ refuseInArchive(tree, "strom review", detectAgent(ctx.env) ?? ctx.settings.agent(tree.config).value);
233
242
  const unproven = !!opts.unproven;
234
243
  if (unproven && args.length)
235
244
  throw new UsageError("--unproven reviews the people without a record — no person with it", { hint: "strom review --unproven · or strom review <person>" });
@@ -12,7 +12,7 @@ import { eventName, humanDate, humanDay, humanPlace } from "../cli/human.js";
12
12
  import { EXIT, StromError, UsageError } from "../core/errors.js";
13
13
  import { fileSha256, MAX_IN_TREE, mimeOf } from "../core/media.js";
14
14
  import { create, update } from "../core/records.js";
15
- import { safeFolderName } from "../core/text.js";
15
+ import { decodeText, safeFolderName } from "../core/text.js";
16
16
  import { humanAge } from "../core/age.js";
17
17
  import { now, Tree } from "../core/tree.js";
18
18
  import { actsAsArchive } from "../core/state.js";
@@ -363,7 +363,7 @@ register({
363
363
  fs.mkdirSync(path.join(tree.root, "inputs"), { recursive: true });
364
364
  // kept as the document the source cites — without the images in it (the research's own excerpts, back from
365
365
  // the app: megabytes in the history for nothing)
366
- fs.writeFileSync(path.join(tree.root, stored), withoutImages(fs.readFileSync(file, "utf8")));
366
+ fs.writeFileSync(path.join(tree.root, stored), withoutImages(decodeText(fs.readFileSync(file))));
367
367
  }
368
368
  const made = create(tree, "input", { name: path.basename(file), ...(stored ? { file: stored } : {}), sha, size, mime: mimeOf(file), from: ctx.display(file), kind: "tree", state: "processed" }, (iid) => `+${iid} input tree "${truncate(path.basename(file), 50)}" (sync)`);
369
369
  const day = humanDay(now(), lang);
@@ -212,7 +212,9 @@ function taskDetail(tree, t) {
212
212
  });
213
213
  const searches = tree.list("search").filter((s) => s.task === t.id || s.recordsets.some((b) => t.where.includes(b)));
214
214
  const lessons = tree.list("lesson").filter((l) => !l.retracted && l.target && t.where.includes(l.target));
215
- return lines(`${t.id} ${t.what}`, `level ${t.level} · priority ${t.priority} · ${t.state}${t.research ? ` · research ${t.research}` : ""}${t.parkedUntil ? ` · parked until ${t.parkedUntil}` : ""}${t.waitingOn ? ` · waiting on ${t.waitingOn}` : ""}`, t.state === "parked" ? `parked ${parkedWhy(tree, t) ?? "(no reason recorded)"} — strom task wake ${t.id} to go on` : undefined, `where ${where.join("\n ")}`, `why ${t.why}`, `done ${t.doneWhen}`, t.subject.length ? `about ${t.subject.join(" ")}` : undefined, t.result ? `result ${t.result}${t.produced?.length ? ` → ${t.produced.join(" ")}` : ""}` : undefined, searches.length ? "\nalready searched there\n" + table(searches.map((s) => [` ${s.id}`, s.result, truncate(s.question, 60), s.scope.years ?? ""])) : undefined, lessons.length ? "\nlessons for these record sets\n" + lessons.map((l) => ` ${l.id} ${l.rule}`).join("\n") : undefined, ...t.notes.map((n) => `note ${n.text}`), t.awaitsCheck ? `check the person checks ${t.awaitsCheck.url}: ${t.awaitsCheck.ask}` : undefined, t.state === "waiting" ? `waits ${waitSaid(tree, t)}` : undefined, t.state === "waiting" && t.wait?.to ? `to ${t.wait.to}` : undefined, request ? `text ${request}` : undefined, t.state === "waiting" && t.wait?.url ? `url ${t.wait.url}` : undefined,
215
+ return lines(`${t.id} ${t.what}`, `level ${t.level} · priority ${t.priority} · ${t.state}${t.research ? ` · research ${t.research}` : ""}${t.parkedUntil ? ` · parked until ${t.parkedUntil}` : ""}${t.waitingOn ? ` · waiting on ${t.waitingOn}` : ""}`,
216
+ // put aside by the archive: its own sentence in the research's language (back when research is switched on, no wake)
217
+ t.state === "parked" ? (t.heldBy === "archive" && t.parkedReason ? t.parkedReason : `parked ${parkedWhy(tree, t) ?? "(no reason recorded)"} — strom task wake ${t.id} to go on`) : undefined, `where ${where.join("\n ")}`, `why ${t.why}`, `done ${t.doneWhen}`, t.subject.length ? `about ${t.subject.join(" ")}` : undefined, t.result ? `result ${t.result}${t.produced?.length ? ` → ${t.produced.join(" ")}` : ""}` : undefined, searches.length ? "\nalready searched there\n" + table(searches.map((s) => [` ${s.id}`, s.result, truncate(s.question, 60), s.scope.years ?? ""])) : undefined, lessons.length ? "\nlessons for these record sets\n" + lessons.map((l) => ` ${l.id} ${l.rule}`).join("\n") : undefined, ...t.notes.map((n) => `note ${n.text}`), t.awaitsCheck ? `check the person checks ${t.awaitsCheck.url}: ${t.awaitsCheck.ask}` : undefined, t.state === "waiting" ? `waits ${waitSaid(tree, t)}` : undefined, t.state === "waiting" && t.wait?.to ? `to ${t.wait.to}` : undefined, request ? `text ${request}` : undefined, t.state === "waiting" && t.wait?.url ? `url ${t.wait.url}` : undefined,
216
218
  // the person's own answers (only theirs: strom task wake --answer/--check), apart from the notes
217
219
  ...(t.answers ?? []).map((a) => `person ${a.at.slice(0, 10)} answered${a.check ? ` ${a.check}` : ""}${a.answer ? `: ${a.answer}` : ""} (asked: ${truncate(a.asked, 80)})`),
218
220
  // the files that came for what it waited for: only that they came (strom intake --answers, the Strom app)
@@ -600,7 +602,7 @@ register({
600
602
  // the queue says what waits out of it for a link to the tree
601
603
  const held = !opts.state && !opts["off-tree"] && !opts.about ? tree.list("task").filter((t) => t.state === "open" && off(t)).length : 0;
602
604
  return {
603
- text: lines(all.length ? lines(table(page.items.map(taskLine)), moreLine(page, "strom task list")) : "no tasks", held ? `${held} more wait out of the queue: about people nothing links to the tree yet (strom task list --off-tree)` : undefined, !opts.state && !opts["off-tree"] && !opts.about ? decisionLines(tree, (t) => (!opts.level || t.level === opts.level) && (!research || t.research === research)) : undefined),
605
+ text: lines(all.length ? lines(table(page.items.map(taskLine)), moreLine(page, "strom task list")) : ui(tree.lang, "ui.task.none"), held ? `${held} more wait out of the queue: about people nothing links to the tree yet (strom task list --off-tree)` : undefined, !opts.state && !opts["off-tree"] && !opts.about ? decisionLines(tree, (t) => (!opts.level || t.level === opts.level) && (!research || t.research === research)) : undefined),
604
606
  data: {
605
607
  total: page.total,
606
608
  tasks: page.items.map((t) => ({
@@ -30,7 +30,9 @@ import { humanWhen } from "../cli/human.js";
30
30
  import { onlyItsFiles, packPlan } from "../core/pack.js";
31
31
  import { defaultPackFile, packTree } from "./pack.js";
32
32
  import { archiveWaitingItems } from "../core/archiveasks.js";
33
- import { claudeHere } from "../cli/menu-parts.js";
33
+ import { agentReady, claudeHere } from "../cli/menu-parts.js";
34
+ import { agentFirst, agentStepsSkipped } from "../cli/menu-mode.js";
35
+ import { isAgent } from "../core/which.js";
34
36
  register({
35
37
  path: ["init"],
36
38
  summary: "Create a family tree (a folder with its own history) to hold researches",
@@ -80,7 +82,10 @@ register({
80
82
  ctx.adopt(tree);
81
83
  // in the research's language, also when an agent runs it (found on Mac: English to a Czech research)
82
84
  const t = (k, v = {}) => ui(lang, k, v);
83
- const text = lines(t("ui.init.created", { name, path: ctx.display(root) }), t(langR.source === "detected" ? "ui.init.lang.detected" : "ui.init.lang", { lang: langName(lang, lang), code: lang }), mode === "archive" ? t("ui.init.archive") : undefined, "", t("ui.init.next", { command: mode === "archive" ? "strom app" : placeholders(lang, `strom research new "<research name>" --new-person "<Given /Surname/>" --born "<date>" --born-place "<place>"`) }));
85
+ const waiting = mode !== "archive" && stateOf(tree) === "waiting";
86
+ const text = lines(t("ui.init.created", { name, path: ctx.display(root) }), t(langR.source === "detected" ? "ui.init.lang.detected" : "ui.init.lang", { lang: langName(lang, lang), code: lang }), mode === "archive" ? t("ui.init.archive") : waiting ? t("ui.why.waitagent.human") : undefined, "",
87
+ // waiting for its agent: no direction to start (it would be refused) — the menu installs the agent
88
+ t("ui.init.next", { command: mode === "archive" ? "strom app" : waiting ? "strom" : placeholders(lang, `strom research new "<research name>" --new-person "<Given /Surname/>" --born "<date>" --born-place "<place>"`) }));
84
89
  return { text, data: { name, root, lang, mode } };
85
90
  },
86
91
  });
@@ -305,7 +310,7 @@ register({
305
310
  "put aside (never while somebody works on it); back, they come back to the queue. Nothing is deleted.",
306
311
  args: [{ name: "mode", description: "research or archive (none: say which it is)" }],
307
312
  examples: ["strom mode", "strom mode research"],
308
- run(ctx, { args }) {
313
+ async run(ctx, { args }) {
309
314
  const tree = ctx.tree();
310
315
  const lang = tree.lang;
311
316
  const now = modeOf(tree);
@@ -322,6 +327,20 @@ register({
322
327
  // nobody's work is cut short: a session at work finishes first
323
328
  if (to === "archive" && (liveWorkers(tree.root).length > 0 || runsAtWork(tree.root).length > 0))
324
329
  throw new UsageError("somebody is at work on this research: an archive only once they finished", { hint: "strom session finish (the agent writes down what it found and closes), then strom mode archive" });
330
+ // research on with no agent here, as the menu's "switch research on": the agent first — a person at the terminal
331
+ // gets one offered; none got (or nobody here to get one), the archive stays as it was, nothing written, said why
332
+ let asked;
333
+ if (to === "research" && !agentReady(ctx, tree.config)) {
334
+ const later = { text: ui(lang, "ui.mode.later"), data: { mode: now, state, changed: false, agentHere: false } };
335
+ if (!ctx.interactive || isAgent(ctx.env) || tree.dryRun)
336
+ return later;
337
+ const { main } = await import("../cli/main.js");
338
+ const run = (argv, quiet) => main(argv, quiet ? { ...ctx.io, stdout: () => undefined } : ctx.io, ctx.env, tree.root);
339
+ const got = await agentFirst(ctx, run, lang, tree.root);
340
+ if (!got.ready)
341
+ return { ...later, text: "" };
342
+ asked = got.asked;
343
+ }
325
344
  ctx.requireHuman(to === "archive" ? "Make this research an archive (its tasks put aside)?" : "Switch research on (the tasks put aside back in the queue)?", `strom mode ${to}`, "mode", ui(lang, to === "archive" ? "ui.consent.mode.archive" : "ui.consent.mode.research", { name: tree.config.name }));
326
345
  let moved = [];
327
346
  tree.withTreeLock(() => {
@@ -335,6 +354,15 @@ register({
335
354
  installGlobal(t);
336
355
  if (files.length && !tree.dryRun)
337
356
  tree.withTreeLock(() => tree.commit(`Agent instructions: ${files.join(", ")}`, files));
357
+ // the agent got here just now, its steps of the setup never asked: offered, as the menu does
358
+ if (asked === false && agentStepsSkipped(ctx)) {
359
+ ctx.io.stdout(ui(lang, "ui.mode.now.research", { n: moved.length }) + "\n");
360
+ if (await ctx.confirm(ui(lang, "ui.mode.setup"), true)) {
361
+ const { main } = await import("../cli/main.js");
362
+ await main(["setup", "--research"], ctx.io, ctx.env, tree.root);
363
+ }
364
+ return { text: "", data: { mode: to, changed: true, tasks: moved } };
365
+ }
338
366
  return {
339
367
  // the sentence in the research's language (found on Windows: "T0001 put aside: the research is an archive")
340
368
  text: ui(lang, to === "archive" ? "ui.mode.now.archive" : "ui.mode.now.research", { n: moved.length }),
@@ -3,7 +3,8 @@
3
3
  // A family tree is somebody's conclusions, not a record: everything becomes
4
4
  // status "lead", citing one source "family tree <file>", and every person
5
5
  // keeps a reference to where it came from. Persons whose REFN is one of our
6
- // IDs (a tree coming back from the Strom app) are matched, not duplicated.
6
+ // IDs in a tree of this research coming back from the Strom app are matched,
7
+ // not duplicated — another research's IDs never (oursByRefn).
7
8
  import { eventKind, FAMILY_EVENT_KINDS, NOTE_MAX } from "./model.js";
8
9
  import { normalizeAge } from "./age.js";
9
10
  import { foldText } from "./text.js";
@@ -198,13 +199,11 @@ export function importGedcom(tree, text, opts) {
198
199
  const individuals = records.filter((x) => x.tag === "INDI" && x.xref);
199
200
  // IDs first, so that associations (a godparent's ASSO) point at the right person.
200
201
  const fresh = new Set();
201
- for (const r of individuals) {
202
- const refn = val(r, "REFN");
203
- // one of ours — merged into another since the export: that one
204
- let ours = refn && /^P\d{4,}$/.test(refn) ? tree.get(refn) : undefined;
205
- for (let hops = 0; ours?.mergedInto && hops < 10; hops++)
206
- ours = tree.get(ours.mergedInto);
207
- if (ours && !ours.retracted) {
202
+ const treeId = val(records.find((x) => x.tag === "HEAD"), "_STROM_TREE")?.trim();
203
+ const ourPeople = oursByRefn(tree, treeId, individuals.map((r) => ({ refn: val(r, "REFN"), names: r.children.filter((c) => c.tag === "NAME").map((c) => c.value) })));
204
+ for (const [i, r] of individuals.entries()) {
205
+ const ours = ourPeople[i];
206
+ if (ours) {
208
207
  xrefToId.set(r.xref, ours.id);
209
208
  result.matched++;
210
209
  }
@@ -497,6 +496,38 @@ export function importGedcom(tree, text, opts) {
497
496
  });
498
497
  return result;
499
498
  }
499
+ /**
500
+ * Our people by the REFNs a family tree carries (a tree of this research coming back from the Strom app), in the order
501
+ * of its people. A file of this research (its _STROM_TREE) is ours by the IDs alone, whatever the names; another
502
+ * research's never — its P0001 is somebody else (found: the app's file of another tree matched by its IDs, "this
503
+ * research's tree coming back"); a file that names no research (the standard file, another program) only where most
504
+ * of the people its IDs name are alike by name — and then each by its name too. Merged since: the one it went into.
505
+ */
506
+ function oursByRefn(tree, treeId, people) {
507
+ if (treeId && treeId !== tree.config.id)
508
+ return people.map(() => undefined);
509
+ const found = people.map(({ refn }) => {
510
+ if (!refn || !/^P\d{4,}$/.test(refn.trim()))
511
+ return undefined;
512
+ let ours = tree.get(refn.trim());
513
+ for (let hops = 0; ours?.mergedInto && hops < 10; hops++)
514
+ ours = tree.get(ours.mergedInto);
515
+ return ours && ours.type === "person" && !ours.retracted ? ours : undefined;
516
+ });
517
+ if (treeId)
518
+ return found;
519
+ // a word of the name in common, or the same name of a single letter or digit ("1", "J.")
520
+ const norm = (s) => foldText(s.replace(/\//g, " "));
521
+ const words = (s) => new Set(norm(s).split(" ").filter((w) => w.length > 1));
522
+ const alike = (ours, names) => ours.names.some((n) => {
523
+ const mine = `${n.given} ${n.surname}`;
524
+ const w = words(mine);
525
+ return names.some((t) => (!!norm(t) && norm(t) === norm(mine)) || [...words(t)].some((x) => w.has(x)));
526
+ });
527
+ const named = found.map((ours, i) => (ours && alike(ours, people[i].names) ? ours : undefined));
528
+ const resolved = found.filter(Boolean).length;
529
+ return named.filter(Boolean).length * 2 > resolved ? named : people.map(() => undefined);
530
+ }
500
531
  /** The title of the source an imported tree becomes, in the research language. */
501
532
  function importLabel(lang) {
502
533
  return { cs: "Rodokmen", de: "Stammbaum", pl: "Drzewo genealogiczne", sk: "Rodokmeň" }[lang] ?? "Family tree";
@@ -530,9 +561,12 @@ export function importStromJson(tree, data, opts) {
530
561
  result.source = source.id;
531
562
  const idMap = new Map();
532
563
  const cite = (sp, what) => [{ source: source.id, locator: `${sp.id} ${what}` }];
533
- for (const sp of Object.values(data.persons)) {
534
- if (sp.refn && /^P\d{4,}$/.test(sp.refn) && tree.get(sp.refn)) {
535
- idMap.set(sp.id, sp.refn);
564
+ const all = Object.values(data.persons);
565
+ const ourPeople = oursByRefn(tree, data.research?.id, all.map((sp) => ({ refn: sp.refn, names: [[sp.firstName, sp.lastName].filter(Boolean).join(" "), ...(sp.nameVariants ?? [])] })));
566
+ for (const [i, sp] of all.entries()) {
567
+ const ours = ourPeople[i];
568
+ if (ours) {
569
+ idMap.set(sp.id, ours.id);
536
570
  result.matched++;
537
571
  continue;
538
572
  }
package/dist/core/live.js CHANGED
@@ -14,7 +14,9 @@
14
14
  // text: its lines as the user reads them, kinds: what each is about)
15
15
  // GET <token>/recent what the research added in the last hours (?hours=1…168, 24 when not given): the people and
16
16
  // sources added, the commits — the same as /status recent (core/recent.ts)
17
- // GET <token>/events server-sent events: hello, change, working
17
+ // GET <token>/events server-sent events: hello, change, working; end before the bridge ends ({reason: stopped |
18
+ // restart | idle | gone | signal, back: another bridge at this address at once — absent: not
19
+ // known, at}): strom live stop asks it through .strom/live-stop.json, the same on every system
18
20
  // POST <token>/sync the Strom app sends the user's edited tree back (?send=, or on its own): kept in the
19
21
  // inbox to be shown and written on the user's word (strom sync) — nothing of the
20
22
  // research changes; /status says what waits there (inbox) and how each send went (sends)
@@ -376,7 +378,7 @@ export function startLive(root, env, opts = {}) {
376
378
  }
377
379
  function startNow(root, env, running) {
378
380
  if (running)
379
- stopLive(root, `a bridge of strom ${VERSION} is wanted (this one: ${running.version ?? "older"})`);
381
+ stopLive(root, `a bridge of strom ${VERSION} is wanted (this one: ${running.version ?? "older"})`, { back: true });
380
382
  fs.rmSync(liveFile(root), { force: true });
381
383
  const before = readLast(root);
382
384
  spawnBridge(root, env);
@@ -434,10 +436,10 @@ export function forgetLive(root) {
434
436
  noteLive(root, "its address forgotten (strom live stop --forget): the next bridge gets a new one");
435
437
  }
436
438
  /** Whether a process is a bridge of strom (its command says "live serve"): a number taken by another program is never stopped. */
437
- function isBridge(pid) {
439
+ function isBridge(pid, platform = process.platform) {
438
440
  if (!alive(pid))
439
441
  return false;
440
- if (process.platform === "win32")
442
+ if (platform === "win32")
441
443
  return true;
442
444
  const r = spawnSync("ps", ["-o", "stat=,command=", "-p", String(pid)], { encoding: "utf8", timeout: 10_000 });
443
445
  // ps not to be asked: the bridge's own note is what there is
@@ -446,12 +448,29 @@ function isBridge(pid) {
446
448
  const out = (r.stdout ?? "").trim();
447
449
  return !out.startsWith("Z") && /\blive\s+serve\b/.test(out);
448
450
  }
451
+ /** Where a bridge is asked to end: the same way on every system (a signal on Windows ends a process without a word). */
452
+ export function liveStopFile(root) {
453
+ return path.join(root, ".strom", "live-stop.json");
454
+ }
455
+ function readStopAsk(root) {
456
+ try {
457
+ const ask = JSON.parse(fs.readFileSync(liveStopFile(root), "utf8"));
458
+ return Number.isInteger(ask.pid) && typeof ask.why === "string" ? ask : undefined;
459
+ }
460
+ catch {
461
+ return undefined;
462
+ }
463
+ }
449
464
  /**
450
- * End a bridge's process: asked to (SIGTERM — it closes as it should), and when it does not end within a while
451
- * (a bridge stuck on something) ended for good (SIGKILL). How it ended; "alive" when even that did not end it.
465
+ * End a bridge's process: asked to through its stop file (it says the end to the app, closes its streams and ends — the
466
+ * same on every system), else (a bridge of an older strom, or one stuck) by a signal: SIGTERM where a process hears it,
467
+ * and when it does not end within a while ended for good (SIGKILL; on Windows a signal is that at once). How it ended:
468
+ * "stopped" as it should, "killed" for good, "alive" when even that did not end it, "gone" when no bridge ran.
452
469
  */
453
- function endBridge(pid, root, waitMs = 3000) {
454
- if (!isBridge(pid))
470
+ export function endBridge(pid, root, opts = {}) {
471
+ const platform = opts.platform ?? process.platform;
472
+ const waitMs = opts.waitMs ?? 3000;
473
+ if (!isBridge(pid, platform))
455
474
  return "gone";
456
475
  // a bridge of another folder (a note copied with the research) is never ended for this tree
457
476
  if (bridgeElsewhere(pid, root))
@@ -460,37 +479,70 @@ function endBridge(pid, root, waitMs = 3000) {
460
479
  const ended = () => {
461
480
  if (!alive(pid))
462
481
  return true;
463
- if (process.platform === "win32")
482
+ if (platform === "win32")
464
483
  return false;
465
484
  const r = spawnSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf8", timeout: 5000 });
466
485
  return !r.error && (!(r.stdout ?? "").trim() || (r.stdout ?? "").trim().startsWith("Z"));
467
486
  };
468
- const gone = (ms) => {
487
+ const gone = (ms, also = () => false) => {
469
488
  for (let t = 0; t < ms; t += 100) {
470
- if (ended())
489
+ if (ended() || also())
471
490
  return true;
472
491
  sleep(100);
473
492
  }
474
- return ended();
493
+ return ended() || also();
475
494
  };
495
+ const file = liveStopFile(root);
496
+ const taken = () => readStopAsk(root)?.pid !== pid;
476
497
  try {
477
- process.kill(pid, "SIGTERM");
478
- }
479
- catch {
480
- return "gone";
481
- }
482
- if (gone(waitMs))
483
- return "stopped";
484
- try {
485
- process.kill(pid, "SIGKILL");
498
+ let asked = false;
499
+ try {
500
+ fs.mkdirSync(path.dirname(file), { recursive: true });
501
+ fs.writeFileSync(`${file}.${process.pid}`, JSON.stringify({ pid, why: opts.why ?? "asked to end", ...(opts.back === undefined ? {} : { back: opts.back }) }));
502
+ fs.renameSync(`${file}.${process.pid}`, file);
503
+ asked = true;
504
+ }
505
+ catch {
506
+ // its folder cannot be written: a signal is what there is
507
+ }
508
+ // heard: it takes the ask within a moment, then ends (its last streams closed)
509
+ if (asked && gone(opts.askMs ?? 1000, taken)) {
510
+ if (gone(waitMs + 1000))
511
+ return "stopped";
512
+ }
513
+ else {
514
+ // not heard (a bridge of an older strom, one stuck): asked by a signal — on Windows that ends it at once, unheard
515
+ const signal = platform === "win32" ? "SIGKILL" : "SIGTERM";
516
+ try {
517
+ process.kill(pid, signal);
518
+ }
519
+ catch (e) {
520
+ return e.code === "ESRCH" ? "stopped" : "alive";
521
+ }
522
+ if (gone(platform === "win32" ? 2000 : waitMs))
523
+ return platform === "win32" ? "killed" : "stopped";
524
+ if (platform === "win32")
525
+ return "alive";
526
+ }
527
+ try {
528
+ process.kill(pid, "SIGKILL");
529
+ }
530
+ catch (e) {
531
+ return e.code === "ESRCH" ? "stopped" : "alive";
532
+ }
533
+ return gone(2000) ? "killed" : "alive";
486
534
  }
487
- catch {
488
- return "stopped";
535
+ finally {
536
+ // an ask nobody took is not left for a bridge that comes later with that number
537
+ if (readStopAsk(root)?.pid === pid)
538
+ fs.rmSync(file, { force: true });
489
539
  }
490
- return gone(2000) ? "killed" : "alive";
491
540
  }
492
- /** Stop the bridge of a tree (why: into its log): how it went — "none" when none ran, "alive" when it could not be ended. */
493
- export function stopLive(root, why = "strom live stop") {
541
+ /**
542
+ * Stop the bridge of a tree (why: into its log; back: another bridge comes at its address at once — the app following it
543
+ * goes on): how it went — "none" when none ran, "alive" when it could not be ended.
544
+ */
545
+ export function stopLive(root, why = "strom live stop", opts = {}) {
494
546
  const info = liveRunning(root);
495
547
  if (!info)
496
548
  return "none";
@@ -498,7 +550,7 @@ export function stopLive(root, why = "strom live stop") {
498
550
  const last = readLast(root);
499
551
  if (last?.pid === info.pid)
500
552
  writeLast(root, { ...last, ended: { at: new Date().toISOString(), reason: why } });
501
- const how = endBridge(info.pid, root);
553
+ const how = endBridge(info.pid, root, { why, back: opts.back ?? false });
502
554
  if (how === "elsewhere") {
503
555
  noteLive(root, `a bridge of another folder (${info.pid}) is named in live.json: not ended, not this tree's`);
504
556
  fs.rmSync(liveFile(root), { force: true });
@@ -511,7 +563,8 @@ export function stopLive(root, why = "strom live stop") {
511
563
  return "alive";
512
564
  }
513
565
  fs.rmSync(liveFile(root), { force: true });
514
- return how === "gone" ? "stopped" : how;
566
+ // no bridge of that number ran (it ended meanwhile, the number another program's): none was stopped
567
+ return how === "gone" ? "none" : how;
515
568
  }
516
569
  /**
517
570
  * The bridge of a tree that ended without a word (a crash, killed) is started again — on its address, so the
@@ -623,7 +676,7 @@ function idsOf(applied, known) {
623
676
  return Object.keys(ids.persons).length || Object.keys(ids.sources).length ? { ids } : {};
624
677
  }
625
678
  /** What the bridge does that an app may ask about (each added once, never taken away). */
626
- export const BRIDGE_FEATURES = ["sync.again", "sync.undoneSince", "sync.takenBack", "sync.conflictEdit", "sync.since", "sync.ids", "family.noCouple", "family.alone", "adopt.transfer", "adopt.empty", "material.list", "person.titles", "media.codes", "hypothesis.links", "status.recent", "spend.readers", "hypothesis.intree", "archive.permission", "task.check", "waiting.groups", "waiting.sent", "waiting.note", "batch.kept"];
679
+ export const BRIDGE_FEATURES = ["sync.again", "sync.undoneSince", "sync.takenBack", "sync.conflictEdit", "sync.since", "sync.ids", "family.noCouple", "family.alone", "adopt.transfer", "adopt.empty", "material.list", "person.titles", "media.codes", "hypothesis.links", "status.recent", "spend.readers", "hypothesis.intree", "archive.permission", "task.check", "waiting.groups", "waiting.sent", "waiting.note", "batch.kept", "live.end"];
627
680
  /** /status recent, when it is ready. */
628
681
  function recentOf(root, env, head) {
629
682
  const recent = head ? recentNow(root, env, head) : undefined;
@@ -2330,7 +2383,7 @@ export function serveLive(root, env) {
2330
2383
  // the bridge before this one still holds it (stuck, or ending just now): ended, its port taken again — the
2331
2384
  // app following the address goes on; a port another program holds: another one (the app needs ?live= again)
2332
2385
  const before = last && last.pid !== process.pid ? last.pid : undefined;
2333
- const ended = before && tries === 0 ? endBridge(before, root) : "gone";
2386
+ const ended = before && tries === 0 ? endBridge(before, root, { why: `a new bridge (${process.pid}) takes port ${wanted}`, back: true }) : "gone";
2334
2387
  if (ended === "elsewhere") {
2335
2388
  // a bridge of another folder (its note copied with the research): never ended, never its address — a new one
2336
2389
  noteLive(root, `a bridge of another folder (${before}) holds port ${wanted}: not ended`);
@@ -2441,6 +2494,7 @@ export function serveLive(root, env) {
2441
2494
  return noteLive(root, `strom ${disk.version} is on disk but does not start (exit ${probe.status ?? probe.signal ?? "?"}${probe.stderr ? `: ${String(probe.stderr).trim().split("\n").pop()?.slice(0, 200)}` : ""}): this bridge (${runs}) goes on`);
2442
2495
  }
2443
2496
  noteLive(root, `strom ${disk.version} on disk: the bridge starts again with it (port ${port}, its token kept)`);
2497
+ send("end", { reason: "restart", back: true, at: new Date().toISOString() });
2444
2498
  for (const st of streams)
2445
2499
  st.end();
2446
2500
  streams.clear();
@@ -2485,6 +2539,7 @@ export function serveLive(root, env) {
2485
2539
  noteLive(root, `handed over to the bridge of strom ${disk.version} (${child.pid})`);
2486
2540
  clearInterval(timer);
2487
2541
  clearInterval(keepAlive);
2542
+ clearInterval(stopAsked);
2488
2543
  return resolve();
2489
2544
  }
2490
2545
  if (Date.now() < until && child.exitCode === null)
@@ -2509,7 +2564,7 @@ export function serveLive(root, env) {
2509
2564
  // its research gone (taken off the computer, a test's folder): nothing to follow — even while handing over
2510
2565
  // (found: bridges of a folder removed in the middle of it left running for hours)
2511
2566
  if (!fs.existsSync(path.join(root, "strom.json")))
2512
- return stop("the research is no longer there");
2567
+ return stop("the research is no longer there", "gone", false);
2513
2568
  if (renewing)
2514
2569
  return;
2515
2570
  if (Date.now() - renewChecked > RENEW_MS) {
@@ -2539,10 +2594,10 @@ export function serveLive(root, env) {
2539
2594
  if (streams.size)
2540
2595
  lastAsked = Date.now();
2541
2596
  if (Date.now() - lastAsked > idleMs)
2542
- return stop("idle: nobody asked for a while");
2597
+ return stop("idle: nobody asked for a while", "idle", false);
2543
2598
  // its research gone (taken off the computer, a test's folder): nothing to follow
2544
2599
  if (!fs.existsSync(path.join(root, "strom.json")))
2545
- return stop("the research is no longer there");
2600
+ return stop("the research is no longer there", "gone", false);
2546
2601
  if (!streams.size)
2547
2602
  return;
2548
2603
  safe("a tick", () => {
@@ -2565,9 +2620,22 @@ export function serveLive(root, env) {
2565
2620
  for (const s of streams)
2566
2621
  s.write(": still here\n\n");
2567
2622
  }, 20_000);
2568
- const stop = (why) => {
2623
+ // asked to end (strom live stop, a newer bridge wanted): the same on every system — a signal on Windows ends the
2624
+ // process unheard, and the app following it would learn the end only when it gives up asking
2625
+ const stopAsked = setInterval(() => {
2626
+ const ask = readStopAsk(root);
2627
+ if (ask?.pid !== process.pid)
2628
+ return;
2629
+ fs.rmSync(liveStopFile(root), { force: true });
2630
+ stop(ask.why, ask.back ? "restart" : "stopped", ask.back ?? false);
2631
+ }, Number(env.STROM_LIVE_STOP_MS ?? 200));
2632
+ // the end said to the app on its streams before they close: whether another bridge comes at this address (it goes
2633
+ // on following) or none (following ends now); a signal from outside: not known (the app asks /status)
2634
+ const stop = (why, reason = "stopped", back) => {
2569
2635
  clearInterval(timer);
2570
2636
  clearInterval(keepAlive);
2637
+ clearInterval(stopAsked);
2638
+ send("end", { reason, ...(back === undefined ? {} : { back }), at: new Date().toISOString() });
2571
2639
  for (const s of streams)
2572
2640
  s.end();
2573
2641
  server.close();
@@ -2585,8 +2653,8 @@ export function serveLive(root, env) {
2585
2653
  noteLive(root, `ended: ${why}`);
2586
2654
  resolve();
2587
2655
  };
2588
- process.once("SIGTERM", () => stop("SIGTERM"));
2589
- process.once("SIGINT", () => stop("SIGINT"));
2656
+ process.once("SIGTERM", () => stop("SIGTERM", "signal"));
2657
+ process.once("SIGINT", () => stop("SIGINT", "signal"));
2590
2658
  });
2591
2659
  });
2592
2660
  }
@@ -41,16 +41,18 @@ export function actsAsArchive(tree) {
41
41
  /**
42
42
  * Work of an agent refused where nobody researches: an archive (nothing of an agent said: where research is switched
43
43
  * on), or a research waiting for its agent when the agent asked for is not here either (`strom run --agent` of another
44
- * one here goes on). One sentence in the research's language, a code for a program (the Strom app says it in its own).
44
+ * one here goes on). One sentence in the research's language (ui.error.archive, ui.error.agent.missing), the command for a
45
+ * terminal after it; the English and a code for a program and an agent (the Strom app says it in its own).
45
46
  */
46
47
  export function refuseInArchive(tree, what, agent) {
47
48
  const state = stateOf(tree);
48
49
  if (state === "research")
49
50
  return;
50
51
  if (state === "archive")
51
- throw new UsageError(ui(tree.lang, "ui.archive.refuse"), { code: "archive", params: { what }, hint: "strom mode research (the person: strom's menu, its first item)" });
52
+ throw new UsageError(ui("en", "ui.archive.refuse"), { code: "archive", params: { what }, hint: "strom mode research (the person: strom's menu, its first item)" });
52
53
  const named = agent ?? new Settings(tree.env, {}).agent(tree.config).value;
53
54
  if (agentHere(named, tree.env))
54
55
  return;
55
- throw new UsageError(ui(tree.lang, "ui.waiting.refuse", { agent: PROFILES[named]?.name ?? named }), { code: "agent.missing", params: { what, agent: named }, hint: "strom setup (the person installs or chooses the agent: strom's menu, its first item)" });
56
+ const name = PROFILES[named]?.name ?? named;
57
+ throw new UsageError(ui("en", "ui.waiting.refuse", { agent: name }), { code: "agent.missing", params: { what, agent: named, name }, hint: "strom setup (the person installs or chooses the agent: strom's menu, its first item)" });
56
58
  }
package/dist/core/sync.js CHANGED
@@ -31,7 +31,7 @@ import { fromFlexDate, houseOf, importDate, readGedName, readGedSex, stromAppFil
31
31
  import { humanAge, normalizeAge } from "./age.js";
32
32
  import { dateYears } from "./gdate.js";
33
33
  import { create, update } from "./records.js";
34
- import { foldText, safeFolderName } from "./text.js";
34
+ import { decodeText, foldText, safeFolderName } from "./text.js";
35
35
  import { roleWord } from "./roles.js";
36
36
  import { labels } from "../gedcom/labels.js";
37
37
  import { eventName, humanDate } from "../cli/human.js";
@@ -936,7 +936,7 @@ export function readStromJson(data) {
936
936
  }
937
937
  /** A file coming back: GEDCOM or the Strom app's JSON; empty or unreadable throws. */
938
938
  export function readTreeFile(file) {
939
- const text = fs.readFileSync(file, "utf8");
939
+ const text = decodeText(fs.readFileSync(file));
940
940
  if (!text.trim())
941
941
  throw new UsageError(`${path.basename(file)} is empty`, { code: "tree.empty", params: { file: path.basename(file) } });
942
942
  let snap;
package/dist/core/text.js CHANGED
@@ -133,3 +133,25 @@ export function safeFolderName(name) {
133
133
  return `${cleaned || "tree"}-1`;
134
134
  return cleaned;
135
135
  }
136
+ /**
137
+ * A text file's bytes as text, whatever saved it: UTF-8 with or without a BOM (Notepad, PowerShell 5's
138
+ * `-Encoding UTF8`), UTF-16 LE or BE by its BOM (PowerShell's `-Encoding Unicode`), or without one when it opens with
139
+ * two characters of ASCII (a GEDCOM's "0 ", a JSON's "{"). The BOM is never part of the text.
140
+ */
141
+ export function decodeText(buf) {
142
+ const b = Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
143
+ const le = (from) => b.subarray(from, from + ((b.length - from) & ~1)).toString("utf16le");
144
+ const be = (from) => Buffer.from(b.subarray(from, from + ((b.length - from) & ~1))).swap16().toString("utf16le");
145
+ if (b[0] === 0xef && b[1] === 0xbb && b[2] === 0xbf)
146
+ return b.subarray(3).toString("utf8");
147
+ if (b[0] === 0xff && b[1] === 0xfe)
148
+ return le(2);
149
+ if (b[0] === 0xfe && b[1] === 0xff)
150
+ return be(2);
151
+ const ascii = (x) => x !== undefined && x > 0 && x < 0x80;
152
+ if (b.length >= 4 && ascii(b[0]) && b[1] === 0 && ascii(b[2]) && b[3] === 0)
153
+ return le(0);
154
+ if (b.length >= 4 && b[0] === 0 && ascii(b[1]) && b[2] === 0 && ascii(b[3]))
155
+ return be(0);
156
+ return b.toString("utf8").replace(/^/, "");
157
+ }
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.15.0-beta.2";
30
+ export const VERSION = "1.15.0-beta.3";
31
31
  const GITIGNORE = `# Strom Research — local state and large files are not versioned
32
32
  .strom/
33
33
  /media/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strom-research",
3
- "version": "1.15.0-beta.2",
3
+ "version": "1.15.0-beta.3",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {