strom-research 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,10 +17,12 @@ import { buildBrief } from "../brief/brief.js";
17
17
  import { DEFAULT_BUDGET, DEFAULT_RUN_MINUTES, Settings } from "../core/config.js";
18
18
  import { prependPath } from "../runners/runner.js";
19
19
  import { frontier } from "../core/frontier.js";
20
+ import { treeEdges } from "../core/edge.js";
21
+ import { label, parentsOf, resolvePerson } from "../core/people.js";
20
22
  import { storyProposals } from "../core/stories.js";
21
23
  import { OFF_MAP_HOW, offMapLine, placesOffMap } from "../core/places.js";
22
24
  import { create, csvOpt, requireRecord, update } from "../core/records.js";
23
- import { taskQueue, waitingLines } from "./tasks.js";
25
+ import { offTreeLine, taskQueue, waitingLines } from "./tasks.js";
24
26
  import { resolveResearch } from "./research.js";
25
27
  import { writeGedcoms } from "./output.js";
26
28
  import { syncAgentFiles } from "../agents/files.js";
@@ -68,7 +70,7 @@ export function researchProposals(tree, research) {
68
70
  out.push({ proposal, kind: "review" });
69
71
  if (new Settings(tree.env, {}).stories(tree.config).on)
70
72
  for (const proposal of storyProposals(tree, research))
71
- out.push({ proposal: { ...proposal, origin: "frontier" }, kind: "story" });
73
+ out.push({ proposal: { ...proposal, origin: proposal.origin ?? "frontier" }, kind: "story" });
72
74
  return out;
73
75
  }
74
76
  /** Create the tasks strom proposes for a research; returns their IDs. */
@@ -110,7 +112,9 @@ register({
110
112
  const s = startSession(tree, { ...(task ? { task } : {}), ...(research ? { research: research.id } : {}) });
111
113
  reviveLive(tree.root, ctx.env);
112
114
  const brief = buildBrief(tree, { ...(task ? { task } : {}), session: s, budget: ctx.settings.number("brief.budget", tree.config, DEFAULT_BUDGET), shared: ctx.settings.shared()?.value });
113
- return { text: lines(written(tree), "", brief.text), data: { session: s, brief: brief.text, sections: brief.sections } };
115
+ // a task the queue holds back, started by its ID: the agent hears why before it begins
116
+ const off = task && args[0] ? offTreeLine(tree, task, true) : undefined;
117
+ return { text: lines(written(tree), off, "", brief.text), data: { session: s, brief: brief.text, sections: brief.sections, ...(off ? { offTree: true } : {}) } };
114
118
  },
115
119
  }, {
116
120
  path: ["session", "note"],
@@ -292,7 +296,132 @@ register({
292
296
  data: { frontier: items.map((i) => ({ person: i.person.id, generation: i.generation, reason: i.reason, coveredBy: i.coveredBy, proposal: i.proposal, exhausted: i.exhausted })), stories },
293
297
  };
294
298
  },
299
+ }, {
300
+ path: ["edge"],
301
+ summary: "Where the tree ends: above whom it does not go on, why (what the records say so far) and what comes next — what the Strom app shows on those cards",
302
+ group: "tasks",
303
+ tree: true,
304
+ args: [{ name: "person", description: "whose edge in full: ID (P0001) or name (default: every edge the research reaches)", variadic: true }],
305
+ options: [{ name: "all", type: "boolean", description: "also the edges no direction reaches: family outside the directions, people off the tree" }],
306
+ examples: ["strom edge", "strom edge P0001", "strom edge --all --json"],
307
+ run(ctx, { args, opts }) {
308
+ const tree = ctx.tree();
309
+ const { edges, islands } = treeEdges(tree);
310
+ const who = (id) => {
311
+ const p = tree.get(id);
312
+ return p ? label(p) : id;
313
+ };
314
+ if (args.length) {
315
+ const people = args.map((a) => resolvePerson(tree, a));
316
+ const out = [];
317
+ for (const p of people) {
318
+ const e = edges.get(p.id);
319
+ const isle = islands.get(p.id);
320
+ if (out.length)
321
+ out.push("");
322
+ if (!e)
323
+ out.push(`${label(p)} — ${parentsOf(tree, p.id).length === 2 ? "the tree goes on above: a record proves the parents" : "nothing the research knows of the edge above yet (no direction reaches them, no work on it)"}`);
324
+ else
325
+ out.push(...edgeLines(tree, e, who));
326
+ if (isle)
327
+ out.push(islandLine(isle, who));
328
+ }
329
+ const data = people.map((p) => ({ person: p.id, edge: edges.get(p.id) ?? null, island: islands.get(p.id) ? { ...islands.get(p.id), people: islands.get(p.id).people.length } : null }));
330
+ return { text: lines(...out), data: { edges: data } };
331
+ }
332
+ const reached = new Set(["in", "limit", "paused", "done"]);
333
+ const shown = [...edges.values()].filter((e) => opts.all || reached.has(e.scope)).sort((a, b) => (a.generation ?? 99) - (b.generation ?? 99) || a.person.localeCompare(b.person));
334
+ const count = (k) => [...shown.reduce((m, e) => m.set(e[k], (m.get(e[k]) ?? 0) + 1), new Map())].map(([v, n]) => `${v} ${n}`).join(", ");
335
+ const hidden = edges.size - shown.length;
336
+ return {
337
+ text: shown.length
338
+ ? lines(table(shown.map((e) => [
339
+ e.person,
340
+ e.generation !== undefined ? `G${e.generation}` : "",
341
+ e.missing,
342
+ e.scope === "in" ? "" : e.scope,
343
+ e.end,
344
+ e.next,
345
+ truncate(edgeNext(e), 70),
346
+ ])), "", `ends: ${count("end")}`, `next: ${count("next")}`, hidden ? `${hidden} more no direction reaches (strom edge --all)` : undefined, "one in full: strom edge P…")
347
+ : "no edge the research reaches — every person in its directions has parents a record proves",
348
+ data: { edges: shown, islands: [...new Set(islands.values())].map((i) => ({ ...i, people: i.people.length, first: i.people[0] })) },
349
+ };
350
+ },
295
351
  });
352
+ /** What comes next at an edge, in a few words: the first task and where it stands, else what the user decides. */
353
+ function edgeNext(e) {
354
+ const t = e.tasks[0];
355
+ const hyp = e.hypotheses.find((h) => h.joins.length);
356
+ const join = hyp ? ` · ${hyp.id} ${hyp.island ? `joins ${hyp.joins.join(" ")} (${hyp.island.people} people off the tree)` : `would join through ${hyp.joins.join(" ")}`}` : "";
357
+ if (t)
358
+ return `${t.id}${t.position ? ` #${t.position}` : ""}${t.held ? ` (${t.held})` : ""}${e.tasks.length > 1 ? ` +${e.tasks.length - 1}` : ""}${join}`;
359
+ if (e.next === "decide")
360
+ return `tried ${e.tried.join(" ")} — ask the user${join}`;
361
+ return join.replace(/^ · /, "");
362
+ }
363
+ const MISSING_WORDS = {
364
+ parents: "both parents unknown",
365
+ father: "the father unknown",
366
+ mother: "the mother unknown",
367
+ proof: "no record proves the parents recorded",
368
+ };
369
+ const SCOPE_WORDS = {
370
+ in: "reached by",
371
+ limit: "at the limit of",
372
+ paused: "in a paused direction,",
373
+ done: "in an ended direction,",
374
+ living: "most likely alive — not researched",
375
+ outside: "family of the tree no direction goes through",
376
+ "off-tree": "of a family nothing links to the tree yet",
377
+ };
378
+ const END_WORDS = {
379
+ unnamed: "the baptism names no father (born out of wedlock)",
380
+ lost: "the records of those years are lost",
381
+ "before-records": "born before the known birth records of the place begin",
382
+ gap: "the known records have a gap in those years",
383
+ "not-found": "searched in vain everywhere strom knew to look",
384
+ offline: "the books left are only in the archive",
385
+ partly: "searched in part",
386
+ unsearched: "not searched yet",
387
+ "no-books": "no book of the birthplace known",
388
+ "no-place": "the birthplace unknown",
389
+ "no-clue": "nothing to go on: no year, no place of birth",
390
+ };
391
+ const NEXT_WORDS = {
392
+ working: "an agent works on it now",
393
+ waiting: "waits for the user",
394
+ queued: "in the queue",
395
+ held: "waits out of the queue",
396
+ proposed: "strom proposes it with the next session",
397
+ decide: "everything strom could propose was tried — the user decides",
398
+ none: "nothing planned",
399
+ };
400
+ /** One edge in full, for the agent. */
401
+ function edgeLines(tree, e, who) {
402
+ const y = (r) => (r.from === r.to ? `${r.from}` : `${r.from}–${r.to}`);
403
+ const est = e.estimate;
404
+ const basis = est?.basis ? ` (estimated from ${est.basis.kind === "MARR" ? "the marriage" : "the eldest child"} ${est.basis.year})` : "";
405
+ return [
406
+ `${who(e.person)} — the tree ends above: ${MISSING_WORDS[e.missing]}`,
407
+ ` ${SCOPE_WORDS[e.scope]}${e.research ? ` ${e.research}, generation ${e.generation}` : ""}`,
408
+ ` the records: ${END_WORDS[e.end]}${e.recordsFrom ? ` (from ${e.recordsFrom})` : ""}`,
409
+ e.window ? ` baptism sought ${y(e.window)}${est?.place ? ` in ${est.place}` : ""}${basis}` : est?.place ? ` born in ${est.place}, year unknown` : undefined,
410
+ e.books.length ? ` books: ${e.books.map((b) => `${b.id}${b.years ? ` (${b.years})` : ""}${b.access.startsWith("online") ? "" : ` ${b.access}`}`).join(", ")}` : undefined,
411
+ e.covered.length ? ` searched in vain: ${e.covered.map(y).join(", ")}` : undefined,
412
+ e.noRecords.length ? ` no records known: ${e.noRecords.map(y).join(", ")}` : undefined,
413
+ ` next: ${NEXT_WORDS[e.next]}`,
414
+ ...e.tasks.map((t) => ` ${t.id} ${t.level} ${t.state}${t.position ? ` #${t.position}` : ""}${t.held ? ` (waits: ${t.held}${t.until ? ` until ${t.until}` : ""})` : ""}${t.on ? ` — ${truncate(t.on, 60)}` : ""}: ${truncate(t.what, 90)}`),
415
+ e.tried.length ? ` tried: ${e.tried.join(" ")}` : undefined,
416
+ ...e.hypotheses.map((h) => ` ${h.id} ${truncate(h.question, 90)}${!h.joins.length ? "" : h.island ? ` — would join ${h.joins.map(who).join(", ")} of a family off the tree (${h.island.people} people, ${h.island.held} tasks wait for it)` : ` — would join them to the tree through ${h.joins.map(who).join(", ")}`}${h.tests.length ? `; tested by ${h.tests.join(" ")}` : "; no task tests it"}`),
417
+ e.conflicts.length ? ` open conflicts: ${e.conflicts.join(" ")}` : undefined,
418
+ e.searches || e.sessions ? ` work so far: ${e.searches} search${e.searches === 1 ? "" : "es"}, ${e.sessions} session${e.sessions === 1 ? "" : "s"}${e.cost !== undefined ? `, $${e.cost.toFixed(2)}${e.costPartial ? "+" : ""}` : ""}${e.last ? `; last ${e.last}` : ""}` : undefined,
419
+ ].filter((x) => x !== undefined);
420
+ }
421
+ function islandLine(i, who) {
422
+ const joins = i.hypotheses.map((h) => `${h.id} (to ${h.joins.map(who).join(", ")})`);
423
+ return ` of a family nothing links to the tree: ${i.people.length} people, ${i.held} tasks wait for it${joins.length ? `; would join it: ${joins.join(", ")}` : "; no hypothesis would join it yet"}`;
424
+ }
296
425
  // ── agents ─────────────────────────────────────────────────────────────────
297
426
  register({
298
427
  path: ["agents", "sync"],
@@ -4,7 +4,7 @@
4
4
  import fs from "node:fs";
5
5
  import { register } from "../cli/registry.js";
6
6
  import { lines } from "../cli/format.js";
7
- import { approveStory, setStory, findEventOwner } from "../core/actions.js";
7
+ import { approveStory, discardStoryDraft, setStory, findEventOwner } from "../core/actions.js";
8
8
  import { UsageError } from "../core/errors.js";
9
9
  import { csvOpt, normId, textOpt } from "../core/records.js";
10
10
  import { resolvePerson } from "../core/people.js";
@@ -56,12 +56,14 @@ register({
56
56
  description: "Every statement rests on a recorded fact: list them with --fact. Paragraphs are separated by a blank line.\n" +
57
57
  "Kept: \"## subheading\" lines, \"- \" bullet lines, **bold**, *italic*; anything else shows as plain text in the\n" +
58
58
  "Strom app. The title goes in --title (a first line \"# …\" is taken as it). Writing it again replaces it (the\n" +
59
- "history keeps the old one).",
59
+ "history keeps the old one). A story the user approved is locked: written again, the new version waits beside it\n" +
60
+ "for their approval (strom story approve), or goes (strom story discard) — the approved one stays until then.",
60
61
  args: [{ name: "who", description: "person (ID or name) or family (F…)", required: true }],
61
62
  options: [
62
63
  { name: "text", type: "string", value: "<text|@file>", description: "the story, in the research language" },
63
64
  { name: "title", type: "string", value: "<text>", description: "a heading" },
64
65
  { name: "fact", type: "string", multiple: true, value: "<E…>", description: "a fact it leans on (repeatable)" },
66
+ { name: "source", type: "string", multiple: true, value: "<S…>", description: "a source of the background of place and time it tells — not a fact of the person (repeatable)" },
65
67
  { name: "note", type: "string", value: "<text>", description: "your caveat: what is inferred, what is unknown" },
66
68
  { name: "final", type: "boolean", description: "the user approved it" },
67
69
  ],
@@ -76,29 +78,49 @@ register({
76
78
  text,
77
79
  title,
78
80
  facts: csvOpt(opts.fact).map((f) => normId(f)),
81
+ sources: csvOpt(opts.source).map((s) => normId(s)),
79
82
  note: opts.note,
80
83
  final: Boolean(opts.final),
81
84
  });
82
- const missing = rec.story.facts.length === 0 ? "note: no --fact given — say which facts the story rests on" : undefined;
83
- const outside = outsideSet(rec.story.text);
85
+ const written = rec.story.draft ?? rec.story;
86
+ const missing = written.facts.length === 0 ? "note: no --fact given — say which facts the story rests on" : undefined;
87
+ const outside = outsideSet(written.text);
88
+ const locked = rec.story.draft
89
+ ? `the user approved the story before: it stays as it is, the new version waits for them — tell them, they approve it (strom story approve ${rec.id}) or keep the old one (strom story discard ${rec.id})`
90
+ : undefined;
84
91
  const plain = outside.length ? `note: the Strom app shows ${outside.join(", ")} as plain text — keep to paragraphs, "## " subheadings, "- " bullets, **bold**, *italic*` : undefined;
85
- return { text: lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined, missing, plain), data: { story: rec.story } };
92
+ return { text: lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined, locked, missing, plain), data: { story: rec.story } };
86
93
  },
87
94
  }, {
88
95
  path: ["story", "approve"],
89
- summary: "The user approved the story as it is: no longer a draft",
96
+ summary: "The user approved the story as it is: no longer a draft, locked — or the new version waiting beside it takes its place",
90
97
  group: "people",
91
98
  tree: true,
92
99
  writes: true,
93
- description: "Only on the user's word (they read it and said it is right): the text stays, the draft becomes the story of the family book.",
100
+ description: "Only on the user's word (they read it and said it is right): the text stays, the draft becomes the story of the family book —\n" +
101
+ "locked: written again, the new version waits beside it for the user. With a new version waiting, that one is approved in its place.",
94
102
  args: [{ name: "who", description: "person (ID or name) or family (F…)", required: true }],
95
103
  examples: ["strom story approve P0001"],
96
104
  run(ctx, { args }) {
97
105
  const tree = ctx.tree();
98
106
  const id = owner(tree, args[0]);
99
- const was = tree.get(id)?.story?.status;
107
+ const was = tree.get(id)?.story;
100
108
  const rec = approveStory(tree, id);
101
- return { text: was === "final" ? `${id} story was approved already` : lines(...tree.written.map((o) => o.summary)), data: { story: rec.story } };
109
+ return { text: was?.status === "final" && !was.draft ? `${id} story was approved already` : lines(...tree.written.map((o) => o.summary)), data: { story: rec.story } };
110
+ },
111
+ }, {
112
+ path: ["story", "discard"],
113
+ summary: "The new version of an approved story is not wanted: the approved one stays",
114
+ group: "people",
115
+ tree: true,
116
+ writes: true,
117
+ description: "Only on the user's word: the new version written beside the story they approved goes (the history keeps it).",
118
+ args: [{ name: "who", description: "person (ID or name) or family (F…)", required: true }],
119
+ examples: ["strom story discard P0001"],
120
+ run(ctx, { args }) {
121
+ const tree = ctx.tree();
122
+ const rec = discardStoryDraft(tree, owner(tree, args[0]));
123
+ return { text: lines(...tree.written.map((o) => o.summary)), data: { story: rec.story } };
102
124
  },
103
125
  }, {
104
126
  path: ["story", "show"],
@@ -125,7 +147,16 @@ register({
125
147
  }
126
148
  });
127
149
  return {
128
- text: lines(`${id} story · ${st.status} · ${st.at.slice(0, 10)}${st.title ? ` · ${st.title}` : ""}`, "", st.text, "", st.facts.length ? "rests on" : "rests on no recorded fact", ...facts, st.note ? `\nnote: ${st.note}` : undefined),
150
+ text: lines(`${id} story · ${st.status} · ${st.at.slice(0, 10)}${st.title ? ` · ${st.title}` : ""}`, "", st.text, "", st.facts.length ? "rests on" : "rests on no recorded fact", ...facts, ...(st.sources?.length ? ["the background of place and time from", ...st.sources.map((s) => ` ${s} ${tree.get(s)?.title ?? "(no longer exists)"}`)] : []), st.note ? `\nnote: ${st.note}` : undefined, ...(st.draft
151
+ ? [
152
+ "",
153
+ `── a new version (${st.draft.at.slice(0, 10)}) waits for the user: strom story approve ${id} (it takes the place of the one above) · strom story discard ${id} (the one above stays)`,
154
+ st.draft.title ? `“${st.draft.title}”` : undefined,
155
+ "",
156
+ st.draft.text,
157
+ st.draft.note ? `\nnote: ${st.draft.note}` : undefined,
158
+ ]
159
+ : [])),
129
160
  data: { story: st },
130
161
  };
131
162
  },
@@ -11,6 +11,7 @@ import { EXIT, StromError, UsageError } from "../core/errors.js";
11
11
  import { fileSha256, MAX_IN_TREE, mimeOf } from "../core/media.js";
12
12
  import { create, update } from "../core/records.js";
13
13
  import { safeFolderName } from "../core/text.js";
14
+ import { humanAge } from "../core/age.js";
14
15
  import { now } from "../core/tree.js";
15
16
  import { applySync, nothingSince, planSync, readTreeFile, receivedSince, undoSync } from "../core/sync.js";
16
17
  import { startLive } from "../core/live.js";
@@ -19,7 +20,13 @@ import { chromiumBrowser, openInBrowser, openWebApp } from "../core/chromium.js"
19
20
  function factText(f, lang) {
20
21
  if (!f)
21
22
  return "—";
22
- return [f.value, humanDate(f.date, lang), humanPlace(f.place, undefined, lang)].filter(Boolean).join(", ") || "—";
23
+ return [f.value, humanDate(f.date, lang), humanPlace(f.place, undefined, lang), detailText(f, lang)].filter(Boolean).join(", ") || "—";
24
+ }
25
+ /** What a fact says besides: its house, age and cause. */
26
+ function detailText(f, lang) {
27
+ if (!f)
28
+ return "";
29
+ return [f.house && ui(lang, "ui.sync.house", { x: f.house }), f.age && humanAge(f.age, lang), f.cause && ui(lang, "ui.sync.cause", { x: f.cause })].filter(Boolean).join(", ");
23
30
  }
24
31
  /** A position on the map as a map shows it: degrees, six decimals at most. */
25
32
  const point = (p) => `${+p.lat.toFixed(6)}, ${+p.lon.toFixed(6)}`;
@@ -41,8 +48,9 @@ function changeLine(tree, c, incoming, lang) {
41
48
  name: name(c.person),
42
49
  kind: kind(c.fact ?? c.was),
43
50
  fact: `${kind(c.fact ?? c.was)} ${factText(c.fact ?? c.was, lang)}`,
44
- was: factText(c.was, lang),
45
- now: factText(c.fact, lang),
51
+ was: c.kind === "fact.detail" ? detailText(c.was, lang) || "—" : factText(c.was, lang),
52
+ now: c.kind === "fact.detail" ? detailText(c.fact, lang) : factText(c.fact, lang),
53
+ when: [humanDate(c.fact?.date, lang), humanPlace(c.fact?.place, undefined, lang)].filter(Boolean).join(", "),
46
54
  text: truncate(c.text ?? "", 120),
47
55
  child: name(c.child),
48
56
  kids: c.kind === "family.new" && c.text ? ` + ${c.text.split(" ").filter(Boolean).map(name).join(", ")}` : "",
@@ -51,7 +59,8 @@ function changeLine(tree, c, incoming, lang) {
51
59
  from: c.place?.was ? point(c.place.was) : "",
52
60
  };
53
61
  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}`)}`;
62
+ const does = c.kind === "fact.detail" && c.action === "add" ? "ui.sync.do.detail" : `ui.sync.do.${c.action}`;
63
+ return `${ui(lang, key, v)} → ${ui(lang, does)}`;
55
64
  }
56
65
  function planText(tree, plan, incoming, file, lang, edits) {
57
66
  const shown = file.includes(" ") ? `"${file}"` : file;
@@ -20,6 +20,9 @@ import { makeNote } from "../core/actions.js";
20
20
  import { phrase } from "../core/phrases.js";
21
21
  import { readJsonLines } from "../core/json.js";
22
22
  import { lacksImages, rankTasks } from "../core/queue.js";
23
+ import { joiningHypotheses, offTree } from "../core/kin.js";
24
+ import { aboutPeople } from "../core/directions.js";
25
+ import { storiesToApprove } from "../core/stories.js";
23
26
  import { clipNote, inboxFolderFor, parseImageList, transcriptNote } from "../core/media.js";
24
27
  function written(tree) {
25
28
  return lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined);
@@ -61,8 +64,11 @@ export function waitingForUser(tree) {
61
64
  */
62
65
  export function waitingLines(tree, where = {}, max = 5) {
63
66
  const w = tree.list("task").filter((t) => t.state === "waiting");
67
+ // the new versions of stories the user approved: they decide (the lock)
68
+ const stories = storiesToApprove(tree).map((s) => s.id);
69
+ const storyLine = stories.length ? ` ${ui(tree.lang, "ui.wait.stories", { ids: stories.join(" ") })}` : undefined;
64
70
  if (!w.length)
65
- return undefined;
71
+ return stories.length ? lines(ui(tree.lang, "ui.wait.title", { n: stories.length }), storyLine) : undefined;
66
72
  const show = where.display ?? ((p) => p);
67
73
  const lang = tree.lang;
68
74
  const images = (t) => {
@@ -75,7 +81,7 @@ export function waitingLines(tree, where = {}, max = 5) {
75
81
  ` ${ui(lang, "ui.wait.save", { dir: `${dir}${path.sep}` })}`,
76
82
  ];
77
83
  };
78
- return lines(ui(lang, "ui.wait.title", { n: w.length }), ...w.slice(0, max).flatMap((t) => [` ${t.id} ${truncate(t.waitingOn || t.what, 150)}`, ...images(t)]), w.length > max ? ` … strom task list --state waiting` : undefined, ` ${ui(lang, "ui.wait.how1")}`, ` ${ui(lang, "ui.wait.how2")}`);
84
+ return lines(ui(lang, "ui.wait.title", { n: w.length + stories.length }), ...w.slice(0, max).flatMap((t) => [` ${t.id} ${truncate(t.waitingOn || t.what, 150)}`, ...images(t)]), w.length > max ? ` … strom task list --state waiting` : undefined, storyLine, ` ${ui(lang, "ui.wait.how1")}`, ` ${ui(lang, "ui.wait.how2")}`);
79
85
  }
80
86
  /** The open tasks of the queue in the order to work on them (see core/queue.ts). */
81
87
  export function taskQueue(tree, filter = {}) {
@@ -89,6 +95,22 @@ export function rankedQueue(tree, filter = {}) {
89
95
  .filter((t) => !filter.level || t.level === filter.level);
90
96
  return rankTasks(tree, tasks, filter.strategy, { storyTurn: filter.storyTurn });
91
97
  }
98
+ /**
99
+ * What an agent is told of a task about people nothing links to the tree (core/kin.ts offTree): it waits out of the
100
+ * queue until the user asks for it or the hypothesis that would join them is decided; the task that tests that
101
+ * hypothesis names it. Undefined for a task of the tree.
102
+ */
103
+ export function offTreeLine(tree, t, starting = false) {
104
+ if (!offTree(tree)(t))
105
+ return undefined;
106
+ const joins = joiningHypotheses(tree, aboutPeople(tree, t.subject));
107
+ const first = joins.length
108
+ ? `test first what would join them: ${joins.map((h) => `${h.id} "${truncate(h.question, 70)}"`).join("; ")} — a task that tests it names it (--about ${joins[0].id})`
109
+ : "first a link to a person of the tree: a hypothesis (strom hypothesis add … --about P… of the tree) and a task that tests it, naming it (--about H…)";
110
+ return starting
111
+ ? `⚠ ${t.id} is about people nothing links to the tree yet: go on only if the user asked for this work. Else close the session (strom session close --continue …) and ${first}`
112
+ : `· ${t.id} is about people nothing links to the tree yet: it waits out of the queue until the user asks for it or a hypothesis joins them — ${first}`;
113
+ }
92
114
  function taskLine(t) {
93
115
  return [t.id, `p${t.priority}`, t.level, t.state === "open" ? "" : `[${t.state}]`, truncate(t.what, 70)];
94
116
  }
@@ -231,7 +253,7 @@ register({
231
253
  // it is known — not in a session spent finding out that there is nothing to read.
232
254
  const noImages = lacksImages(tree, t);
233
255
  return {
234
- text: lines(written(tree), ...similar.map((x) => `⚠ similar ${x.state} task ${x.id} "${truncate(x.what, 60)}" — if it is the same, keep one: strom task drop ${t.id} --reason "duplicate of ${x.id}" and strom task edit ${x.id} --where … --note "…"`), noImages ? `· no images for it here yet — if the user has to download them, say which now: strom task wait ${t.id} --images B…:<numbers> --on "<the book, its link, which images>"` : undefined),
256
+ text: lines(written(tree), ...similar.map((x) => `⚠ similar ${x.state} task ${x.id} "${truncate(x.what, 60)}" — if it is the same, keep one: strom task drop ${t.id} --reason "duplicate of ${x.id}" and strom task edit ${x.id} --where … --note "…"`), offTreeLine(tree, t), noImages ? `· no images for it here yet — if the user has to download them, say which now: strom task wait ${t.id} --images B…:<numbers> --on "<the book, its link, which images>"` : undefined),
235
257
  data: { task: t, similar: similar.map((x) => x.id), needsImages: noImages },
236
258
  };
237
259
  },
@@ -250,8 +272,13 @@ register({
250
272
  const strategy = ctx.settings.strategy(tree.config);
251
273
  const queue = rankedQueue(tree, { ...(research ? { research } : {}), ...(opts.level ? { level: String(opts.level) } : {}), strategy });
252
274
  const first = queue[0];
253
- if (!first)
254
- return { text: 'no open tasks → strom research show <G…> to see what is missing, then strom task add', data: { task: null } };
275
+ if (!first) {
276
+ const held = tree.list("task").filter(offTree(tree)).filter((t) => t.state === "open").length;
277
+ const text = held
278
+ ? `no open task of the tree — ${held} wait about people nothing links to it yet (strom task list --off-tree): test the hypothesis that would join them, or ask the user`
279
+ : "no open tasks → strom research show <G…> to see what is missing, then strom task add";
280
+ return { text, data: { task: null, offTree: held } };
281
+ }
255
282
  const t = first.task;
256
283
  return {
257
284
  text: lines(taskDetail(tree, t), "", `first because: ${first.why} (queue: ${strategy})`, `start it: strom task start ${t.id}`),
@@ -271,6 +298,7 @@ register({
271
298
  { name: "research", type: "string", value: "<G…>", description: "only this research" },
272
299
  { name: "level", type: "string", value: "<level>", description: "only this level" },
273
300
  { name: "about", type: "string", value: "<who>", description: "only tasks about this person/record (also X…, H…)" },
301
+ { name: "off-tree", type: "boolean", description: "the open tasks that wait out of the queue: about people nothing links to the tree yet" },
274
302
  { name: "full", type: "boolean", description: "--json: whole records instead of one row each" },
275
303
  ],
276
304
  run(ctx, { opts }) {
@@ -278,7 +306,10 @@ register({
278
306
  const research = typeof opts.research === "string" ? resolveResearch(tree, opts.research).id : undefined;
279
307
  let all;
280
308
  const why = new Map();
281
- if (!opts.state) {
309
+ const off = offTree(tree);
310
+ if (opts["off-tree"])
311
+ all = tree.list("task").filter((t) => t.state === "open" && off(t) && (!opts.level || t.level === opts.level));
312
+ else if (!opts.state) {
282
313
  const ranked = rankedQueue(tree, { ...(research ? { research } : {}), ...(opts.level ? { level: String(opts.level) } : {}), strategy: ctx.settings.strategy(tree.config) });
283
314
  for (const r of ranked)
284
315
  why.set(r.task.id, r.why);
@@ -297,8 +328,10 @@ register({
297
328
  all = all.filter((t) => t.subject.includes(id));
298
329
  }
299
330
  const page = paginate(all, ctx.limit, ctx.page);
331
+ // the queue says what waits out of it for a link to the tree
332
+ const held = !opts.state && !opts["off-tree"] && !opts.about ? tree.list("task").filter((t) => t.state === "open" && off(t)).length : 0;
300
333
  return {
301
- text: all.length ? lines(table(page.items.map(taskLine)), moreLine(page, "strom task list")) : "no tasks",
334
+ 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),
302
335
  data: { total: page.total, tasks: opts.full ? page.items : page.items.map((t) => ({ id: t.id, priority: t.priority, level: t.level, state: t.state, what: t.what, subject: t.subject, ...(why.has(t.id) ? { why: why.get(t.id) } : {}) })) },
303
336
  };
304
337
  },
@@ -360,7 +393,7 @@ register({
360
393
  summary: `${id} ${fields.map((f) => (f === "doneWhen" ? "done-when" : f === "subject" ? "about" : f)).join(", ")}${change.where ? ` → ${truncate(change.where.join("; "), 50)}` : ""}`,
361
394
  reason: opts.reason,
362
395
  });
363
- return { text: written(tree), data: { task: t } };
396
+ return { text: lines(written(tree), change.subject ? offTreeLine(tree, t) : undefined), data: { task: t } };
364
397
  },
365
398
  }, {
366
399
  path: ["task", "start"],
@@ -589,7 +589,8 @@ export function editPerson(tree, id, edit, reason) {
589
589
  */
590
590
  export function editEvent(tree, eventId, edit, reason) {
591
591
  const date = edit.date === undefined ? undefined : parseDate(edit.date);
592
- const age = edit.age === undefined ? undefined : parseAge(edit.age);
592
+ // an empty age takes it away
593
+ const age = edit.age === undefined ? undefined : edit.age.trim() ? parseAge(edit.age) : "";
593
594
  const ages = edit.ages ? Object.fromEntries(Object.entries(edit.ages).map(([who, a]) => [who, parseAge(a, who)])) : undefined;
594
595
  return replaceEvent(tree, eventId, (e) => {
595
596
  const changed = [];
@@ -821,7 +822,11 @@ export function mergeFamilies(tree, keepId, otherId, reason) {
821
822
  return { family: merged, repointed };
822
823
  });
823
824
  }
824
- /** Write (or rewrite) the story of a person or a couple. The facts it leans on must exist. */
825
+ /**
826
+ * Write (or rewrite) the story of a person or a couple. The facts it leans on must exist. A story the user approved is
827
+ * locked: written again, the new version waits beside it for their approval (story.draft) — unless they approve it now
828
+ * (final).
829
+ */
825
830
  export function setStory(tree, id, input) {
826
831
  const text = input.text.replace(/\r\n?/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
827
832
  if (!text)
@@ -829,28 +834,55 @@ export function setStory(tree, id, input) {
829
834
  const facts = [...new Set((input.facts ?? []).map((f) => f.trim().toUpperCase()))];
830
835
  for (const f of facts)
831
836
  findEventOwner(tree, f);
837
+ const sources = [...new Set((input.sources ?? []).map((s) => s.trim().toUpperCase()))];
838
+ for (const s of sources)
839
+ if (tree.get(s)?.type !== "source")
840
+ throw new UsageError(`no source ${s}`, { hint: "the background of place and time is recorded first: strom source add … --kind book|newspaper|web" });
832
841
  return tree.withTreeLock(() => {
833
842
  const rec = tree.get(id);
834
843
  if (!rec || (rec.type !== "person" && rec.type !== "family"))
835
844
  throw new UsageError(`no person or family ${id}`);
836
845
  if (rec.mergedInto)
837
846
  throw new UsageError(`${id} was merged into ${rec.mergedInto}`);
838
- const story = {
847
+ const version = {
839
848
  ...(input.title?.trim() ? { title: input.title.trim() } : {}),
840
- status: input.final ? "final" : "draft",
841
849
  text,
842
850
  facts,
851
+ ...(sources.length ? { sources } : {}),
843
852
  ...(input.note?.trim() ? { note: input.note.trim() } : {}),
844
853
  at: now(),
845
854
  by: tree.actor,
846
855
  };
847
- const updated = { ...rec, story, updated: now() };
848
856
  const words = text.split(/\s+/).length;
857
+ if (rec.story?.status === "final" && !input.final) {
858
+ const updated = { ...rec, story: { ...rec.story, draft: version }, updated: now() };
859
+ tree.put(updated, { op: "story.set", targets: [id], summary: `${id} story: a new version (${words} words) waits for the user's approval; the approved one stays` });
860
+ return updated;
861
+ }
862
+ const story = { ...version, status: input.final ? "final" : "draft" };
863
+ const updated = { ...rec, story, updated: now() };
849
864
  tree.put(updated, { op: "story.set", targets: [id], summary: `${id} story${rec.story ? " rewritten" : ""}: ${words} words, ${story.status}` });
850
865
  return updated;
851
866
  });
852
867
  }
853
- /** The user approved the story as it is: no longer a draft (the text stays). */
868
+ /** The new version of an approved story is not wanted: it goes, the approved story stays (the history keeps it). */
869
+ export function discardStoryDraft(tree, id) {
870
+ return tree.withTreeLock(() => {
871
+ const rec = tree.get(id);
872
+ if (!rec || (rec.type !== "person" && rec.type !== "family"))
873
+ throw new UsageError(`no person or family ${id}`);
874
+ if (!rec.story?.draft)
875
+ throw new UsageError(`${id} has no new version of its story waiting`, { hint: `strom story show ${id}` });
876
+ const { draft: _gone, ...story } = rec.story;
877
+ const updated = { ...rec, story, updated: now() };
878
+ tree.put(updated, { op: "story.discard", targets: [id], summary: `${id} story: the new version discarded, the approved one stays` });
879
+ return updated;
880
+ });
881
+ }
882
+ /**
883
+ * The user approved the story as it is: no longer a draft (the text stays), locked — or, when a new version waits
884
+ * beside an approved one, that new version takes its place, approved.
885
+ */
854
886
  export function approveStory(tree, id) {
855
887
  return tree.withTreeLock(() => {
856
888
  const rec = tree.get(id);
@@ -858,6 +890,11 @@ export function approveStory(tree, id) {
858
890
  throw new UsageError(`no person or family ${id}`);
859
891
  if (!rec.story)
860
892
  throw new UsageError(`${id} has no story yet`, { hint: `strom story set ${id} --text @file --fact E…` });
893
+ if (rec.story.draft) {
894
+ const updated = { ...rec, story: { ...rec.story.draft, status: "final" }, updated: now() };
895
+ tree.put(updated, { op: "story.approve", targets: [id], summary: `${id} story: the new version approved, in place of the old one` });
896
+ return updated;
897
+ }
861
898
  if (rec.story.status === "final")
862
899
  return rec;
863
900
  const updated = { ...rec, story: { ...rec.story, status: "final" }, updated: now() };
@@ -48,6 +48,7 @@ const SAID = {
48
48
  "repository.edit": "log.repository.edit",
49
49
  "story.set": "log.story.set",
50
50
  "story.approve": "log.story.approve",
51
+ "story.discard": "log.story.discard",
51
52
  "note.add": "log.note.add",
52
53
  "input.add": "log.input.add",
53
54
  "input.sync": "log.input.sync",
@@ -44,6 +44,7 @@ export const SETTINGS = [
44
44
  { key: "queue.strategy", env: "STROM_QUEUE_STRATEGY", tree: true, kind: "choice", choices: STRATEGIES, description: "order of the task queue: balanced (default — nearest ancestors first, spread over the lines, nothing taken forever), depth (stay on one line), priority (strict priority)" },
45
45
  { key: "gedcom.for", env: "STROM_GEDCOM_FOR", tree: true, kind: "choice", choices: ["both", "standard", "strom"], description: "GEDCOM files written: both (default), standard (any program), strom (the Strom app)" },
46
46
  { key: "stories", env: "STROM_STORIES", tree: true, kind: "choice", choices: ["yes", "no"], description: "stories of the ancestors for the family, written from the facts: yes (default — strom proposes one once a person's life is told by records), no — the user is told when the research starts and may say no" },
47
+ { key: "stories.sources", env: "STROM_STORIES_SOURCES", tree: true, kind: "choice", choices: ["yes", "no"], description: "before a story is written, a search beyond the registers — newspapers, directories, military, land and court records, graves, the history of the place — so the story can tell more, every word still on a record: yes (default, while stories are on), no (the story from what the research found)" },
47
48
  { key: "sync.edits", env: "STROM_SYNC_EDITS", tree: true, kind: "choice", choices: ["conflict", "user"], description: "your edits in the Strom app (or another program's file) to a fact a record proves, taken by strom sync: conflict (default — the research keeps the record's, a conflict for you to decide) or user (your edit wins, the record's fact is withdrawn with the reason)" },
48
49
  { key: "main.person", env: "STROM_MAIN_PERSON", tree: true, kind: "person", description: "the main person of the tree (P…): first in the GEDCOM files — the Strom app opens on them (default: the nearest person descended from every research's focus, else the first research's focus)" },
49
50
  { key: "excerpts.quality", env: "STROM_EXCERPTS_QUALITY", tree: true, kind: "choice", choices: EXCERPT_QUALITIES, description: "the entries cut out of their scans in the file for the Strom app (output/tree-strom.ged): small (1000 px, grey — half the size), normal (default, 1200 px), sharp (1600 px)" },
@@ -362,6 +363,10 @@ export class Settings {
362
363
  const v = this.resolve("stories", tree)?.value;
363
364
  return { on: v !== "no", said: v === "yes" || v === "no" };
364
365
  }
366
+ /** Before a story, the sources beyond the registers (core/stories.ts): on unless the user said no. */
367
+ storySources(tree) {
368
+ return this.resolve("stories.sources", tree)?.value !== "no";
369
+ }
365
370
  /** strom sync: whether the user's edit to a fact a record proves wins, or becomes a conflict. */
366
371
  syncEdits(tree) {
367
372
  return this.resolve("sync.edits", tree)?.value === "user" ? "user" : "conflict";