strom-research 1.13.0 → 1.13.1-beta.2

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.
@@ -256,6 +256,7 @@
256
256
  "ui.doc.links.ours": "ano",
257
257
  "ui.doc.links.other": "vedou do jiného programu (nebo tam, kde strom býval)",
258
258
  "ui.doc.links.none": "žádné",
259
+ "ui.doc.links.unknown": "systém neodpověděl (zjistí se příště)",
259
260
  "ui.doc.app": "aplikace Strom",
260
261
  "ui.doc.tree": "tento rodokmen",
261
262
  "ui.doc.innode": "strom {version} v Node {node}",
@@ -256,6 +256,7 @@
256
256
  "ui.doc.links.ours": "ja",
257
257
  "ui.doc.links.other": "führen zu einem anderen Programm (oder dorthin, wo strom früher war)",
258
258
  "ui.doc.links.none": "keine",
259
+ "ui.doc.links.unknown": "das System hat nicht geantwortet (beim nächsten Mal erneut gefragt)",
259
260
  "ui.doc.app": "Strom-App",
260
261
  "ui.doc.tree": "dieser Stammbaum",
261
262
  "ui.doc.innode": "strom {version} in Node {node}",
@@ -18,6 +18,7 @@ import { readyConnectors } from "../core/connector.js";
18
18
  import { runs, shellArg } from "../cli/format.js";
19
19
  import { foldText } from "../core/text.js";
20
20
  import { subjectPeople } from "../core/records.js";
21
+ import { hypothesisPeople } from "../core/directions.js";
21
22
  export { DEFAULT_BUDGET } from "../core/config.js";
22
23
  import { DEFAULT_BUDGET } from "../core/config.js";
23
24
  /** Rough token estimate: good enough for a soft budget. */
@@ -254,7 +255,7 @@ export function buildBrief(tree, opts) {
254
255
  // the task's own conflicts and hypotheses come first, whatever their state
255
256
  const own = (id) => subjects.has(id);
256
257
  const conflicts = tree.list("conflict").filter((c) => own(c.id) || (c.state === "open" && c.subject.some((s) => ids.has(s))));
257
- const hyps = tree.list("hypothesis").filter((h) => own(h.id) || (h.state === "open" && h.subject.some((s) => ids.has(s))));
258
+ const hyps = tree.list("hypothesis").filter((h) => own(h.id) || (h.state === "open" && hypothesisPeople(tree, h).some((s) => ids.has(s))));
258
259
  const first = (list) => [...list.filter((x) => own(x.id)), ...list.filter((x) => !own(x.id))];
259
260
  const mark = (id, state) => `${own(id) ? "→ " : " "}${id}${state === "open" ? "" : ` [${state}]`}`;
260
261
  if (conflicts.length || hyps.length)
package/dist/cli/ui.js CHANGED
@@ -223,6 +223,7 @@ export const UI = {
223
223
  "ui.doc.links.ours": "yes",
224
224
  "ui.doc.links.other": "lead to another program (or where strom was before)",
225
225
  "ui.doc.links.none": "none",
226
+ "ui.doc.links.unknown": "the system did not answer (asked again next time)",
226
227
  "ui.doc.app": "Strom app",
227
228
  "ui.doc.tree": "this family tree",
228
229
  "ui.doc.innode": "strom {version} in Node {node}",
@@ -14,6 +14,25 @@ export function aboutPeople(tree, subject) {
14
14
  const partners = subject.filter((id) => typeOfId(id) === "family").flatMap((id) => tree.get(id)?.partners ?? []);
15
15
  return [...new Set([...subjectPeople(tree, subject), ...partners])];
16
16
  }
17
+ /** Whether a text names a record by its ID (any script around it: never \b, which knows only ASCII letters). */
18
+ export function namesId(text, id) {
19
+ return new RegExp(`(?<![\\p{L}\\p{M}\\p{N}])${id}(?![\\p{L}\\p{M}\\p{N}])`, "u").test(text);
20
+ }
21
+ const PERSON_ID = /(?<![\p{L}\p{M}\p{N}])P\d{4,}(?![\p{L}\p{M}\p{N}])/gu;
22
+ /**
23
+ * The people a hypothesis is about: those of its subject, and the people of the tree its question and its variants'
24
+ * claims name by ID ("the son of P0012 and P0013") — a variant that names a family nothing links to the tree would
25
+ * join it, though the subject names only the person of the tree. What each variant cites for or against it is
26
+ * evidence, not who it is about.
27
+ */
28
+ export function hypothesisPeople(tree, h) {
29
+ const named = [h.question, ...h.variants.map((v) => v.claim)].flatMap((text) => text.match(PERSON_ID) ?? []);
30
+ const people = named.filter((id) => {
31
+ const p = tree.get(id);
32
+ return p && !p.retracted;
33
+ });
34
+ return [...new Set([...aboutPeople(tree, h.subject), ...people])];
35
+ }
17
36
  /** The direction a task belongs to: its own, else the narrowest one its people are in; none for a task about nobody. */
18
37
  export function directionOf(tree, t, all) {
19
38
  if (t.research)
package/dist/core/edge.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // tree ends (_STROM_EDGE, _STROM_ISLAND) and what `strom edge` says.
7
7
  import { now } from "./tree.js";
8
8
  import { Settings } from "./config.js";
9
- import { aboutPeople, directionOf, scopes } from "./directions.js";
9
+ import { aboutPeople, directionOf, hypothesisPeople, namesId, scopes } from "./directions.js";
10
10
  import { birthEstimate, birthRecordProven, birthWindow, frontier, FRONTIER_LEVELS, RECORD_KINDS, recordsetsCovering } from "./frontier.js";
11
11
  import { kinship, offTree } from "./kin.js";
12
12
  import { familiesAsChild, familiesAsPartner, parentsOf, primaryName } from "./people.js";
@@ -81,7 +81,7 @@ function islands(tree, kin, tasks, hypotheses) {
81
81
  const island = {
82
82
  people: [...people],
83
83
  hypotheses: hypotheses
84
- .map((h) => ({ h, about: aboutPeople(tree, h.subject) }))
84
+ .map((h) => ({ h, about: hypothesisPeople(tree, h) }))
85
85
  .filter(({ about }) => about.some((x) => people.has(x)) && about.some((x) => kin.has(x)))
86
86
  .map(({ h, about }) => ({ id: h.id, joins: about.filter((x) => kin.has(x)) })),
87
87
  held: tasks.filter((t) => LIVE.has(t.state) && off(t) && aboutPeople(tree, t.subject).some((x) => people.has(x))).length,
@@ -113,7 +113,7 @@ export function treeEdges(tree) {
113
113
  frontiers.set(r.id, new Map(frontier(tree, r).map((i) => [i.person.id, i])));
114
114
  return frontiers.get(r.id);
115
115
  };
116
- const hypAbout = hypotheses.map((h) => ({ h, about: aboutPeople(tree, h.subject) }));
116
+ const hypAbout = hypotheses.map((h) => ({ h, about: hypothesisPeople(tree, h) }));
117
117
  const edges = new Map();
118
118
  for (const p of tree.list("person")) {
119
119
  if (p.retracted)
@@ -256,7 +256,8 @@ export function treeEdges(tree) {
256
256
  question: h.question,
257
257
  joins,
258
258
  ...(isle ? { island: { people: isle.people.length, held: isle.held } } : {}),
259
- tests: tasks.filter((t) => LIVE.has(t.state) && t.subject.includes(h.id)).map((t) => t.id),
259
+ // a task about it, or one whose words name it ("the baptism … (H0007 B)")
260
+ tests: tasks.filter((t) => LIVE.has(t.state) && (t.subject.includes(h.id) || namesId(t.what, h.id))).map((t) => t.id),
260
261
  };
261
262
  });
262
263
  const edgeConflicts = conflicts
package/dist/core/kin.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // app opens on them), and how near anyone is to the people the research is
3
3
  // for (which entries get their image in the Strom app first).
4
4
  import { Settings } from "./config.js";
5
- import { aboutPeople } from "./directions.js";
5
+ import { aboutPeople, hypothesisPeople } from "./directions.js";
6
6
  import { ancestorGenerations, familiesAsChild, familiesAsPartner } from "./people.js";
7
7
  function researches(tree) {
8
8
  return tree
@@ -125,5 +125,10 @@ export function joiningHypotheses(tree, people) {
125
125
  }
126
126
  return tree
127
127
  .list("hypothesis")
128
- .filter((h) => h.state === "open" && h.subject.some((s) => family.has(s)) && h.subject.some((s) => kin.has(s)));
128
+ .filter((h) => {
129
+ if (h.state !== "open")
130
+ return false;
131
+ const about = hypothesisPeople(tree, h);
132
+ return about.some((s) => family.has(s)) && about.some((s) => kin.has(s));
133
+ });
129
134
  }
@@ -329,22 +329,22 @@ const sys = (cmd, args) => {
329
329
  return { status: r.error ? null : r.status, stdout: r.stdout ?? "" };
330
330
  };
331
331
  const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
332
- /** The app the system opens a link of this scheme with (macOS), or "". */
332
+ /** The app the system opens a link of this scheme with (macOS), "" for none, undefined when the system did not answer. */
333
333
  function macHandlerPath(run, scheme) {
334
334
  const js = `ObjC.import("AppKit");var u=$.NSWorkspace.sharedWorkspace.URLForApplicationToOpenURL($.NSURL.URLWithString("${scheme}://status"));u.isNil()?"":u.path.js`;
335
335
  const r = run("osascript", ["-l", "JavaScript", "-e", js]);
336
- return r.status === 0 ? r.stdout.trim() : "";
336
+ return r.status === 0 ? r.stdout.trim() : undefined;
337
337
  }
338
338
  /** The mark inside strom's applet: what it runs (another strom's applet, or one of an older place, is not ours). */
339
339
  function macMark(app) {
340
340
  return path.join(app, "Contents", "Resources", "strom-link.json");
341
341
  }
342
- /**
343
- * linkHandlerState without waiting: the system asked by a program in the background (the bridge answers meanwhile).
344
- * What it answers is read the same way.
345
- */
346
- export async function linkHandlerStateLater(env, platform = process.platform) {
347
- if (env.STROM_NO_INSTALL === "1")
342
+ const sysLater = (cmd, args) => new Promise((resolve) => {
343
+ // a program that failed to start or was stopped for taking too long: no status (no answer)
344
+ execFile(cmd, args, { encoding: "utf8", timeout: 30_000, windowsHide: true }, (err, stdout) => resolve({ status: err ? (typeof err.code === "number" ? err.code : null) : 0, stdout: stdout ?? "" }));
345
+ });
346
+ export async function linkHandlerStateLater(env, platform = process.platform, exec = sysLater) {
347
+ if (env.STROM_NO_INSTALL === "1" && exec === sysLater)
348
348
  return "none";
349
349
  const calls = [];
350
350
  // the commands it would run, found by a dry pass, then each asked in the background
@@ -353,18 +353,22 @@ export async function linkHandlerStateLater(env, platform = process.platform) {
353
353
  return { status: null, stdout: "" };
354
354
  });
355
355
  for (const c of calls)
356
- c.r = await new Promise((resolve) => {
357
- execFile(c.cmd, c.args, { encoding: "utf8", timeout: 30_000, windowsHide: true }, (err, stdout) => resolve({ status: err ? (typeof err.code === "number" ? err.code : null) : 0, stdout: stdout ?? "" }));
358
- });
356
+ c.r = await exec(c.cmd, c.args);
359
357
  return linkHandlerState(env, platform, (cmd, args) => calls.find((c) => c.cmd === cmd && JSON.stringify(c.args) === JSON.stringify(args))?.r ?? { status: null, stdout: "" });
360
358
  }
361
- /** Does the scheme lead to this strom? No system is asked from a test (STROM_NO_INSTALL). */
359
+ /**
360
+ * Does the scheme lead to this strom? No system is asked from a test (STROM_NO_INSTALL). "none" only when the system
361
+ * answered that nothing takes the links; a query that failed or timed out is "unknown" (found: a bridge said no links
362
+ * for a minute while osascript was slow, and the Strom app forgot them).
363
+ */
362
364
  export function linkHandlerState(env, platform = process.platform, run = sys) {
363
365
  if (env.STROM_NO_INSTALL === "1" && run === sys)
364
366
  return "none";
365
367
  const argv = handlerArgv(env, platform);
366
368
  if (platform === "darwin") {
367
369
  const at = macHandlerPath(run, linkScheme(env));
370
+ if (at === undefined)
371
+ return "unknown";
368
372
  if (!at)
369
373
  return "none";
370
374
  const app = macApp(env);
@@ -379,12 +383,17 @@ export function linkHandlerState(env, platform = process.platform, run = sys) {
379
383
  }
380
384
  if (platform === "win32") {
381
385
  const r = run("reg.exe", ["query", `${winKey(env)}\\shell\\open\\command`, "/ve"]);
386
+ // reg.exe that answered: no such key (1); one that did not run or answer: nothing known
387
+ if (r.status === null)
388
+ return "unknown";
382
389
  if (r.status !== 0)
383
390
  return "none";
384
391
  const value = /REG_(?:EXPAND_)?SZ\s+(.*)$/m.exec(r.stdout)?.[1]?.trim();
385
392
  return value === windowsCommand(argv) ? "ours" : value ? "other" : "none";
386
393
  }
387
394
  const r = run("xdg-mime", ["query", "default", `x-scheme-handler/${linkScheme(env)}`]);
395
+ if (r.status === null)
396
+ return "unknown";
388
397
  const entry = r.status === 0 ? r.stdout.trim() : "";
389
398
  if (!entry)
390
399
  return "none";
@@ -415,28 +424,42 @@ export function registerLinks(env, platform = process.platform, run = sys) {
415
424
  if (platform === "darwin") {
416
425
  const app = macApp(env);
417
426
  fs.mkdirSync(path.dirname(app), { recursive: true });
418
- fs.rmSync(app, { recursive: true, force: true });
419
- const script = path.join(path.dirname(app), ".strom-link.applescript");
420
- fs.writeFileSync(script, macScript(argv));
421
- const made = run("osacompile", ["-o", app, script]);
422
- fs.rmSync(script, { force: true });
423
- if (made.status !== 0)
424
- return false;
425
- const plist = path.join(app, "Contents", "Info.plist");
426
- run("plutil", ["-replace", "CFBundleIdentifier", "-string", `info.stromapp.research.link${suffix ? `.${suffix}` : ""}`, plist]);
427
- run("plutil", ["-replace", "CFBundleName", "-string", name, plist]);
428
- run("plutil", ["-replace", "LSUIElement", "-bool", "true", plist]);
429
- run("plutil", ["-replace", "CFBundleURLTypes", "-json", JSON.stringify([{ CFBundleURLName: name, CFBundleURLSchemes: [scheme] }]), plist]);
430
- // its icon: Strom Research's own
431
- if (fs.existsSync(ICON.png))
432
- run("sips", ["-s", "format", "icns", ICON.png, "--out", path.join(app, "Contents", "Resources", "applet.icns")]);
433
- fs.writeFileSync(macMark(app), JSON.stringify({ argv }, null, 2));
434
- // the applet's seal covers its Info.plist: sealed again, by itself (no one's certificate)
435
- run("codesign", ["--force", "--sign", "-", app]);
427
+ // The new applet is made beside, whole (its mark, its seal), and only then put in the old one's place: the links
428
+ // lead here at every moment (found on Mac: made again in place, a bridge said no links meanwhile and the Strom app
429
+ // forgot them). An applet that cannot be made leaves the one there as it was.
430
+ const work = path.join(path.dirname(app), `.strom-link-${process.pid}`);
431
+ fs.rmSync(work, { recursive: true, force: true });
432
+ fs.mkdirSync(work);
433
+ try {
434
+ const fresh = path.join(work, path.basename(app));
435
+ const script = path.join(work, "strom-link.applescript");
436
+ fs.writeFileSync(script, macScript(argv));
437
+ if (run("osacompile", ["-o", fresh, script]).status !== 0 || !fs.existsSync(fresh))
438
+ return false;
439
+ const plist = path.join(fresh, "Contents", "Info.plist");
440
+ run("plutil", ["-replace", "CFBundleIdentifier", "-string", `info.stromapp.research.link${suffix ? `.${suffix}` : ""}`, plist]);
441
+ run("plutil", ["-replace", "CFBundleName", "-string", name, plist]);
442
+ run("plutil", ["-replace", "LSUIElement", "-bool", "true", plist]);
443
+ run("plutil", ["-replace", "CFBundleURLTypes", "-json", JSON.stringify([{ CFBundleURLName: name, CFBundleURLSchemes: [scheme] }]), plist]);
444
+ // its icon: Strom Research's own
445
+ if (fs.existsSync(ICON.png))
446
+ run("sips", ["-s", "format", "icns", ICON.png, "--out", path.join(fresh, "Contents", "Resources", "applet.icns")]);
447
+ fs.writeFileSync(macMark(fresh), JSON.stringify({ argv }, null, 2));
448
+ // the applet's seal covers its Info.plist: sealed again, by itself (no one's certificate)
449
+ run("codesign", ["--force", "--sign", "-", fresh]);
450
+ // into its place: a folder is not renamed over another, so the old one aside first — a moment of two renames
451
+ if (fs.existsSync(app))
452
+ fs.renameSync(app, path.join(work, "old.app"));
453
+ fs.renameSync(fresh, app);
454
+ }
455
+ finally {
456
+ fs.rmSync(work, { recursive: true, force: true });
457
+ }
436
458
  run(LSREGISTER, ["-f", app]);
437
459
  return linkHandlerState(env, platform, run) === "ours";
438
460
  }
439
461
  if (platform === "win32") {
462
+ // each value written over the one there (reg add /f): the command is never missing meanwhile
440
463
  const add = (key, args) => run("reg.exe", ["add", key, ...args, "/f"]).status === 0;
441
464
  const key = winKey(env);
442
465
  const ok = add(key, ["/ve", "/d", `URL:${name}`]) &&
@@ -447,7 +470,9 @@ export function registerLinks(env, platform = process.platform, run = sys) {
447
470
  }
448
471
  const file = linuxEntry(env);
449
472
  fs.mkdirSync(path.dirname(file), { recursive: true });
450
- fs.writeFileSync(file, linuxDesktopEntry(argv, scheme, name));
473
+ // written beside, then put in its place: never read half written
474
+ fs.writeFileSync(`${file}.${process.pid}`, linuxDesktopEntry(argv, scheme, name));
475
+ fs.renameSync(`${file}.${process.pid}`, file);
451
476
  run("xdg-mime", ["default", linuxEntryName(env), `x-scheme-handler/${scheme}`]);
452
477
  run("update-desktop-database", [path.dirname(file)]);
453
478
  return linkHandlerState(env, platform, run) === "ours";
package/dist/core/live.js CHANGED
@@ -172,22 +172,91 @@ export function liveLogFile(root) {
172
172
  return path.join(root, ".strom", "live.log");
173
173
  }
174
174
  const LIVE_LOG_BYTES = 200 * 1024;
175
+ /** The tree's folder as the system names it (its links followed: /tmp is /private/tmp on a Mac) — never normalized. */
176
+ function realRoot(root) {
177
+ try {
178
+ return fs.realpathSync(root);
179
+ }
180
+ catch {
181
+ return path.resolve(root);
182
+ }
183
+ }
184
+ /** Whether two paths are one folder: the same name, else the same folder of the same disk; undefined when one is not there. */
185
+ function sameFolder(a, b) {
186
+ if (a === b)
187
+ return true;
188
+ try {
189
+ const x = fs.statSync(a, { bigint: true });
190
+ const y = fs.statSync(b, { bigint: true });
191
+ return x.dev === y.dev && x.ino === y.ino;
192
+ }
193
+ catch {
194
+ return undefined;
195
+ }
196
+ }
197
+ /**
198
+ * Where a process runs (its working folder): a bridge runs in its tree's folder. Undefined where it cannot be asked
199
+ * (Windows; lsof missing) — then a process is taken for what its note says, as before.
200
+ */
201
+ function processCwd(pid) {
202
+ if (process.platform === "linux") {
203
+ try {
204
+ return fs.readlinkSync(`/proc/${pid}/cwd`);
205
+ }
206
+ catch {
207
+ return undefined;
208
+ }
209
+ }
210
+ if (process.platform !== "darwin")
211
+ return undefined;
212
+ const r = spawnSync("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], { encoding: "utf8", timeout: 10_000 });
213
+ if (r.error || r.status !== 0)
214
+ return undefined;
215
+ const line = (r.stdout ?? "").split("\n").find((l) => l.startsWith("n"));
216
+ return line ? line.slice(1) : undefined;
217
+ }
218
+ /** A bridge running in another folder than this tree's — never ended for this tree. Its folder, else undefined. */
219
+ function bridgeElsewhere(pid, root) {
220
+ const cwd = processCwd(pid);
221
+ return cwd && sameFolder(cwd, realRoot(root)) === false ? cwd : undefined;
222
+ }
223
+ /**
224
+ * The folder a bridge's note (live.json, live-last.json) belongs to when it is not this tree's: a research copied with
225
+ * its .strom (by hand, Finder) carries the original's notes — its bridge never taken for the copy's own, never ended by
226
+ * it, its address never taken over (found on Mac: the copy's strom ended the original's bridge and served the Strom app
227
+ * following the original at its address). A note of an older strom names no folder: its process, while it is a bridge,
228
+ * is asked where it runs. A folder named that is no longer there is not another one: the research was moved.
229
+ */
230
+ function foreignNote(root, note) {
231
+ if (typeof note.root === "string")
232
+ return sameFolder(note.root, realRoot(root)) === false ? note.root : undefined;
233
+ if (typeof note.pid === "number" && note.pid !== process.pid && isBridge(note.pid))
234
+ return bridgeElsewhere(note.pid, root);
235
+ return undefined;
236
+ }
175
237
  function lastFile(root) {
176
238
  return path.join(root, ".strom", "live-last.json");
177
239
  }
178
- function readLast(root) {
240
+ /** The last bridge of this tree — and, when the note there is another folder's (a research copied with its .strom), that folder. */
241
+ function readLastOf(root) {
179
242
  try {
180
243
  const last = JSON.parse(fs.readFileSync(lastFile(root), "utf8"));
181
- return Number.isInteger(last.port) && (last.token === undefined || /^[0-9a-f]{32}$/.test(last.token)) ? last : undefined;
244
+ if (!Number.isInteger(last.port) || !(last.token === undefined || /^[0-9a-f]{32}$/.test(last.token)))
245
+ return {};
246
+ const foreign = foreignNote(root, last);
247
+ return foreign ? { foreign } : { last };
182
248
  }
183
249
  catch {
184
- return undefined;
250
+ return {};
185
251
  }
186
252
  }
253
+ function readLast(root) {
254
+ return readLastOf(root).last;
255
+ }
187
256
  function writeLast(root, last) {
188
257
  try {
189
258
  const file = lastFile(root);
190
- fs.writeFileSync(`${file}.${process.pid}`, JSON.stringify(last, null, 2));
259
+ fs.writeFileSync(`${file}.${process.pid}`, JSON.stringify({ ...last, root: realRoot(root) }, null, 2));
191
260
  fs.renameSync(`${file}.${process.pid}`, file);
192
261
  }
193
262
  catch {
@@ -227,11 +296,11 @@ function alive(pid) {
227
296
  return e.code === "EPERM";
228
297
  }
229
298
  }
230
- /** The bridge of this tree when it runs. */
299
+ /** The bridge of this tree when it runs (never another folder's whose note came with a copy of the research). */
231
300
  export function liveRunning(root) {
232
301
  try {
233
- const info = JSON.parse(fs.readFileSync(liveFile(root), "utf8"));
234
- if (info.pid && alive(info.pid))
302
+ const { root: of, ...info } = JSON.parse(fs.readFileSync(liveFile(root), "utf8"));
303
+ if (info.pid && alive(info.pid) && !foreignNote(root, { root: of, pid: info.pid }))
235
304
  return info;
236
305
  }
237
306
  catch {
@@ -351,9 +420,12 @@ function isBridge(pid) {
351
420
  * End a bridge's process: asked to (SIGTERM — it closes as it should), and when it does not end within a while
352
421
  * (a bridge stuck on something) ended for good (SIGKILL). How it ended; "alive" when even that did not end it.
353
422
  */
354
- function endBridge(pid, waitMs = 3000) {
423
+ function endBridge(pid, root, waitMs = 3000) {
355
424
  if (!isBridge(pid))
356
425
  return "gone";
426
+ // a bridge of another folder (a note copied with the research) is never ended for this tree
427
+ if (bridgeElsewhere(pid, root))
428
+ return "elsewhere";
357
429
  // ended: no such process, or one that only waits for its parent to hear it ended (a zombie)
358
430
  const ended = () => {
359
431
  if (!alive(pid))
@@ -396,7 +468,12 @@ export function stopLive(root, why = "strom live stop") {
396
468
  const last = readLast(root);
397
469
  if (last?.pid === info.pid)
398
470
  writeLast(root, { ...last, ended: { at: new Date().toISOString(), reason: why } });
399
- const how = endBridge(info.pid);
471
+ const how = endBridge(info.pid, root);
472
+ if (how === "elsewhere") {
473
+ noteLive(root, `a bridge of another folder (${info.pid}) is named in live.json: not ended, not this tree's`);
474
+ fs.rmSync(liveFile(root), { force: true });
475
+ return "none";
476
+ }
400
477
  if (how === "killed")
401
478
  noteLive(root, `the bridge ${info.pid} did not end when asked: ended for good (SIGKILL)`);
402
479
  if (how === "alive") {
@@ -450,7 +527,7 @@ function masked(url) {
450
527
  function writeLive(root, info) {
451
528
  const file = liveFile(root);
452
529
  fs.mkdirSync(path.dirname(file), { recursive: true });
453
- fs.writeFileSync(`${file}.${process.pid}`, JSON.stringify(info, null, 2));
530
+ fs.writeFileSync(`${file}.${process.pid}`, JSON.stringify({ ...info, root: realRoot(root) }, null, 2));
454
531
  fs.renameSync(`${file}.${process.pid}`, file);
455
532
  }
456
533
  function head(root) {
@@ -693,27 +770,59 @@ function directions(tree, all, queued, workingOn) {
693
770
  }
694
771
  /** How long what the system says of the links is taken as true (asking it costs a program started). */
695
772
  const LINKS_FRESH_MS = 60_000;
696
- let linksSeen;
697
- let linksAsking = false;
773
+ /** No links after links that worked: asked again so soon, and taken only when said again. */
774
+ const LINKS_AGAIN_MS = 5_000;
698
775
  /**
699
- * The strom-research:// links this computer takes: said to the app only while the scheme leads to this strom. The
700
- * system is asked in the background (it starts a program, slow on a busy computer): until it answers, what it said
701
- * last — the bridge never waits for it.
776
+ * What the bridge says of the links, asked of the system now and then: the first time at once, then in the background
777
+ * (the bridge never waits for it) — until it answers, what it said last. The system that does not answer (a failed or
778
+ * slow query: unknown) changes nothing. No links after links that worked are taken only when the system says so twice
779
+ * in a row: an applet being made again, a moment of LaunchServices — and the Strom app forgot the links (found on Mac).
702
780
  */
781
+ export function linksWatch(read, opts = {}) {
782
+ const freshMs = opts.freshMs ?? LINKS_FRESH_MS;
783
+ const againMs = opts.againMs ?? LINKS_AGAIN_MS;
784
+ const clock = opts.clock ?? Date.now;
785
+ let seen;
786
+ let asking = false;
787
+ // none said once after links that worked: not taken yet
788
+ let doubt = false;
789
+ // asked again in a few seconds rather than in a minute
790
+ const soon = () => clock() - freshMs + againMs;
791
+ return () => {
792
+ if (!seen) {
793
+ const state = read.now();
794
+ seen = { at: state === "unknown" ? soon() : clock(), actions: linkActions(state) };
795
+ }
796
+ else if (clock() - seen.at > freshMs && !asking) {
797
+ asking = true;
798
+ read.later().then((state) => {
799
+ asking = false;
800
+ const was = seen?.actions ?? [];
801
+ if (state === "unknown") {
802
+ seen = { at: was.length ? clock() : soon(), actions: was };
803
+ return;
804
+ }
805
+ const actions = linkActions(state);
806
+ if (!actions.length && was.length && !doubt) {
807
+ doubt = true;
808
+ seen = { at: soon(), actions: was };
809
+ return;
810
+ }
811
+ doubt = false;
812
+ seen = { at: clock(), actions };
813
+ }, () => {
814
+ asking = false;
815
+ seen = { at: clock(), actions: seen?.actions ?? [] };
816
+ });
817
+ }
818
+ return seen.actions;
819
+ };
820
+ }
821
+ let linksOf;
822
+ /** The strom-research:// links this computer takes: said to the app only while the scheme leads to this strom. */
703
823
  function links(env) {
704
- if (!linksSeen)
705
- linksSeen = { at: Date.now(), actions: linkActions(linkHandlerState(env)) };
706
- else if (Date.now() - linksSeen.at > LINKS_FRESH_MS && !linksAsking) {
707
- linksAsking = true;
708
- linkHandlerStateLater(env).then((state) => {
709
- linksSeen = { at: Date.now(), actions: linkActions(state) };
710
- linksAsking = false;
711
- }, () => {
712
- linksSeen = { at: Date.now(), actions: linksSeen?.actions ?? [] };
713
- linksAsking = false;
714
- });
715
- }
716
- return linksSeen.actions;
824
+ linksOf ??= linksWatch({ now: () => linkHandlerState(env), later: () => linkHandlerStateLater(env) });
825
+ return linksOf();
717
826
  }
718
827
  /**
719
828
  * A second installation's own scheme (strom-research-beta), for the app to build its links with — said whenever it is
@@ -883,7 +992,11 @@ export function serveLive(root, env) {
883
992
  }
884
993
  // the address of the last bridge, so the app that followed it finds this one (its secret: none once it was ended for
885
994
  // good — a new one); replaced while it runs when a page that is no Strom app comes with it (leaked)
886
- const last = readLast(root);
995
+ const { last, foreign } = readLastOf(root);
996
+ // the notes of another folder's bridge (the research copied with its .strom): a new address of its own — a port the
997
+ // system picks and a new secret, so the app following the original never comes here
998
+ if (foreign)
999
+ noteLive(root, `the bridge's notes came from another folder (${foreign}): a new address`);
887
1000
  let token = last?.token ?? crypto.randomBytes(16).toString("hex");
888
1001
  // what this bridge says of itself (live.json), once it listens
889
1002
  let live;
@@ -1847,7 +1960,14 @@ export function serveLive(root, env) {
1847
1960
  // the bridge before this one still holds it (stuck, or ending just now): ended, its port taken again — the
1848
1961
  // app following the address goes on; a port another program holds: another one (the app needs ?live= again)
1849
1962
  const before = last && last.pid !== process.pid ? last.pid : undefined;
1850
- if (before && tries === 0 && endBridge(before) !== "gone") {
1963
+ const ended = before && tries === 0 ? endBridge(before, root) : "gone";
1964
+ if (ended === "elsewhere") {
1965
+ // a bridge of another folder (its note copied with the research): never ended, never its address — a new one
1966
+ noteLive(root, `a bridge of another folder (${before}) holds port ${wanted}: not ended`);
1967
+ token = crypto.randomBytes(16).toString("hex");
1968
+ tries = 20;
1969
+ }
1970
+ else if (ended !== "gone") {
1851
1971
  noteLive(root, `the bridge before (${before}) still held port ${wanted}: ended`);
1852
1972
  tries = 1;
1853
1973
  }
@@ -7,6 +7,7 @@
7
7
  // Each kind is at most one task per person at a time, and what a task of that
8
8
  // kind took up is never proposed again.
9
9
  import { dateYears } from "./gdate.js";
10
+ import { hypothesisPeople } from "./directions.js";
10
11
  import { birthEstimate, RECORD_KINDS, recordsetsCovering, researchPeople } from "./frontier.js";
11
12
  import { birthEvent, displayName, familiesAsPartner, lifespan } from "./people.js";
12
13
  import { phrase } from "./phrases.js";
@@ -209,7 +210,7 @@ function openConflicts(tree, p) {
209
210
  const about = (subject) => subject.includes(p.id) || subject.some((s) => families.has(s));
210
211
  return [
211
212
  ...tree.list("conflict").filter((c) => c.state === "open" && about(c.subject)),
212
- ...tree.list("hypothesis").filter((h) => h.state === "open" && about(h.subject)),
213
+ ...tree.list("hypothesis").filter((h) => h.state === "open" && hypothesisPeople(tree, h).includes(p.id)),
213
214
  ].map((x) => x.id);
214
215
  }
215
216
  /** The tasks strom proposes for the people of a person research (strom review). */
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.13.0";
30
+ export const VERSION = "1.13.1-beta.2";
31
31
  const GITIGNORE = `# Strom Research — local state and large files are not versioned
32
32
  .strom/
33
33
  /media/
@@ -34,6 +34,7 @@ import { quay } from "../core/evidence.js";
34
34
  import { VERSION } from "../core/tree.js";
35
35
  import { dataUrl, turned } from "../core/excerpt.js";
36
36
  import { mainPerson } from "../core/kin.js";
37
+ import { hypothesisPeople } from "../core/directions.js";
37
38
  import * as git from "../core/git.js";
38
39
  import { treeEdges } from "../core/edge.js";
39
40
  import { readersOf } from "../core/review.js";
@@ -141,7 +142,13 @@ export function exportGedcom(tree, opts = {}) {
141
142
  // What the research knows beyond the facts (the Strom profile, opts.research): read once.
142
143
  const research = {
143
144
  conflicts: opts.research ? tree.list("conflict") : [],
144
- hypotheses: opts.research ? tree.list("hypothesis").filter((h) => h.state === "open") : [],
145
+ // with the people each is about: its subject's, and those its variants name (core/directions.ts hypothesisPeople)
146
+ hypotheses: opts.research
147
+ ? tree
148
+ .list("hypothesis")
149
+ .filter((h) => h.state === "open")
150
+ .map((h) => ({ h, people: hypothesisPeople(tree, h) }))
151
+ : [],
145
152
  // a search is of the people of the task it served
146
153
  searched: opts.research
147
154
  ? tree
@@ -559,7 +566,7 @@ export function exportGedcom(tree, opts = {}) {
559
566
  if (c.state === "resolved" && c.resolution)
560
567
  w.text(2, "DECI", c.resolution);
561
568
  }
562
- for (const h of research.hypotheses.filter((x) => about(x.subject))) {
569
+ for (const { h } of research.hypotheses.filter((x) => x.people.includes(p.id))) {
563
570
  w.line(1, "_STROM_HYPO", h.id);
564
571
  w.text(2, "TITL", h.question);
565
572
  w.text(2, "NOTE", h.variants.map((v) => `${v.label}: ${v.claim}`).join("\n"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strom-research",
3
- "version": "1.13.0",
3
+ "version": "1.13.1-beta.2",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,5 +45,8 @@
45
45
  "devDependencies": {
46
46
  "@types/node": "^22.20.4",
47
47
  "typescript": "^7.0.2"
48
+ },
49
+ "publishConfig": {
50
+ "tag": "beta"
48
51
  }
49
52
  }