strom-research 1.7.0 → 1.9.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.
Files changed (50) hide show
  1. package/README.md +3 -0
  2. package/assets/lang/cs.json +117 -2
  3. package/assets/lang/de.json +117 -2
  4. package/assets/method/core.md +8 -0
  5. package/assets/method/narrate.md +5 -1
  6. package/assets/plugins/gates/claude-usage/gate.ts +2 -1
  7. package/dist/agents/files.js +6 -4
  8. package/dist/cli/main.js +21 -3
  9. package/dist/cli/menu-links.js +35 -1
  10. package/dist/cli/menu-research.js +41 -2
  11. package/dist/cli/menu-settings.js +17 -1
  12. package/dist/cli/menu.js +19 -8
  13. package/dist/cli/ui.js +57 -1
  14. package/dist/commands/guide.js +6 -1
  15. package/dist/commands/links.js +39 -3
  16. package/dist/commands/output.js +4 -2
  17. package/dist/commands/pack.js +21 -18
  18. package/dist/commands/research.js +42 -1
  19. package/dist/commands/session.js +210 -11
  20. package/dist/commands/setup.js +3 -0
  21. package/dist/commands/start.js +6 -3
  22. package/dist/commands/story.js +47 -6
  23. package/dist/commands/tasks.js +50 -12
  24. package/dist/commands/trees.js +82 -2
  25. package/dist/core/changelog.js +223 -0
  26. package/dist/core/clock.js +20 -0
  27. package/dist/core/config.js +2 -0
  28. package/dist/core/connector.js +25 -1
  29. package/dist/core/directions.js +41 -0
  30. package/dist/core/edge.js +296 -0
  31. package/dist/core/frontier.js +12 -4
  32. package/dist/core/kin.js +40 -1
  33. package/dist/core/links.js +17 -4
  34. package/dist/core/live.js +428 -52
  35. package/dist/core/pack.js +31 -0
  36. package/dist/core/phrases.js +61 -0
  37. package/dist/core/queue.js +15 -4
  38. package/dist/core/relocate.js +4 -4
  39. package/dist/core/session.js +8 -2
  40. package/dist/core/stromapp.js +22 -0
  41. package/dist/core/trash.js +72 -0
  42. package/dist/core/tree.js +1 -1
  43. package/dist/core/workers.js +28 -3
  44. package/dist/gedcom/export.js +105 -1
  45. package/dist/gedcom/validate.js +7 -1
  46. package/dist/runners/claude.js +2 -1
  47. package/dist/runners/jsonl.js +2 -2
  48. package/dist/runners/runner.js +16 -3
  49. package/dist/runners/script.js +2 -1
  50. package/package.json +1 -1
@@ -0,0 +1,296 @@
1
+ // The edge of the tree: the people above whom it does not go on — no parents, one of them, or parents no record
2
+ // proves — and what the research knows there. Why it ends (what the records say so far: the known books of the
3
+ // birth, the years searched in vain, the records that begin later or are lost), what comes next (the work on it and
4
+ // where it stands in the queue, or that the user decides), and the hypotheses that would join a family nothing
5
+ // links to the tree yet. Read only, computed from the research: what the Strom app shows on the card where the
6
+ // tree ends (_STROM_EDGE, _STROM_ISLAND) and what `strom edge` says.
7
+ import { now } from "./tree.js";
8
+ import { Settings } from "./config.js";
9
+ import { aboutPeople, directionOf, scopes } from "./directions.js";
10
+ import { birthEstimate, birthRecordProven, birthWindow, frontier, FRONTIER_LEVELS, RECORD_KINDS, recordsetsCovering } from "./frontier.js";
11
+ import { kinship, offTree } from "./kin.js";
12
+ import { familiesAsChild, familiesAsPartner, parentsOf, primaryName } from "./people.js";
13
+ import { livingBorn } from "./review.js";
14
+ import { effectiveState, rankTasks } from "./queue.js";
15
+ import { subjectPeople } from "./records.js";
16
+ import { foldText } from "./text.js";
17
+ /** What is missing above the person: both parents, one of them, or a record that proves the parents recorded. */
18
+ export const EDGE_MISSING = ["parents", "father", "mother", "proof"];
19
+ /**
20
+ * Whether the research reaches them: a direction of it does (in), would but stops at its limit (limit), is paused
21
+ * or ended (paused, done); none does — they are most likely alive (living), family of the tree no direction goes
22
+ * through (outside), or a family nothing links to the tree yet (off-tree).
23
+ */
24
+ export const EDGE_SCOPES = ["in", "limit", "paused", "done", "living", "outside", "off-tree"];
25
+ /**
26
+ * What the records say so far, from the most final: the baptism names no father (born out of wedlock); every year of the
27
+ * birth is searched in vain or has no records — lost, before the known records begin, a gap in them; searched in
28
+ * vain everywhere strom knew to look; the books left are only in the archive; searched in part; not searched yet;
29
+ * no book of the birthplace known; the birthplace unknown; nothing to go on (no year, no place).
30
+ */
31
+ export const EDGE_ENDS = ["unnamed", "lost", "before-records", "gap", "not-found", "offline", "partly", "unsearched", "no-books", "no-place", "no-clue"];
32
+ /**
33
+ * What comes next: an agent works on it now, it waits for the user, it is in the queue, it waits out of the queue
34
+ * (a direction paused, people off the tree, put aside), strom proposes it with the next session, everything strom
35
+ * could propose was tried (the user decides), nothing.
36
+ */
37
+ export const EDGE_NEXT = ["working", "waiting", "queued", "held", "proposed", "decide", "none"];
38
+ const BIRTH_KINDS = RECORD_KINDS.BIRT;
39
+ const LIVE = new Set(["open", "doing", "waiting", "parked"]);
40
+ const FINISHED = new Set(["done", "dropped"]);
41
+ const EDGE_FACTS = new Set(["BIRT", "CHR", "BAPM", "FAMC"]);
42
+ function yearsOf(text) {
43
+ const m = /^\s*(\d{3,4})(?:\s*[-–]\s*(\d{3,4}))?\s*$/.exec(text ?? "");
44
+ if (!m)
45
+ return undefined;
46
+ const a = Number(m[1]);
47
+ const b = m[2] ? Number(m[2]) : a;
48
+ return { from: Math.min(a, b), to: Math.max(a, b) };
49
+ }
50
+ /** Single years as ranges ([1780, 1781, 1782, 1790] → 1780–1782, 1790). */
51
+ function ranges(years) {
52
+ const out = [];
53
+ for (const y of [...years].sort((a, b) => a - b)) {
54
+ const last = out[out.length - 1];
55
+ if (last && y === last.to + 1)
56
+ last.to = y;
57
+ else
58
+ out.push({ from: y, to: y });
59
+ }
60
+ return out;
61
+ }
62
+ const within = (y, r) => !r || (y >= r.from && y <= r.to);
63
+ /** The families nothing links to the tree, person by person (none when the tree has nobody it is for). */
64
+ function islands(tree, kin, tasks, hypotheses) {
65
+ const out = new Map();
66
+ if (!kin.size)
67
+ return out;
68
+ const off = offTree(tree);
69
+ for (const p of tree.list("person")) {
70
+ if (p.retracted || kin.has(p.id) || out.has(p.id))
71
+ continue;
72
+ const people = new Set();
73
+ const queue = [p.id];
74
+ while (queue.length) {
75
+ const id = queue.shift();
76
+ if (people.has(id) || kin.has(id))
77
+ continue;
78
+ people.add(id);
79
+ queue.push(...familiesAsPartner(tree, id).flatMap((f) => [...f.partners, ...f.children.map((c) => c.person)]), ...familiesAsChild(tree, id).flatMap((f) => f.partners));
80
+ }
81
+ const island = {
82
+ people: [...people],
83
+ hypotheses: hypotheses
84
+ .map((h) => ({ h, about: aboutPeople(tree, h.subject) }))
85
+ .filter(({ about }) => about.some((x) => people.has(x)) && about.some((x) => kin.has(x)))
86
+ .map(({ h, about }) => ({ id: h.id, joins: about.filter((x) => kin.has(x)) })),
87
+ held: tasks.filter((t) => LIVE.has(t.state) && off(t) && aboutPeople(tree, t.subject).some((x) => people.has(x))).length,
88
+ };
89
+ for (const id of people)
90
+ out.set(id, island);
91
+ }
92
+ return out;
93
+ }
94
+ /** Every edge of the tree with something the research knows of it, and the families nothing links to the tree. */
95
+ export function treeEdges(tree) {
96
+ const lang = tree.lang;
97
+ const today = now().slice(0, 10);
98
+ const kin = kinship(tree);
99
+ const all = scopes(tree).filter((s) => !s.research.retracted);
100
+ const tasks = tree.list("task").filter((t) => !t.retracted);
101
+ const hypotheses = tree.list("hypothesis").filter((h) => !h.retracted && h.state === "open");
102
+ const conflicts = tree.list("conflict").filter((c) => !c.retracted && c.state === "open");
103
+ const searches = tree.list("search").filter((q) => !q.retracted);
104
+ const sessions = tree.list("session");
105
+ const researches = new Map(tree.list("research").map((r) => [r.id, r]));
106
+ const ranked = rankTasks(tree, tasks, new Settings(tree.env, {}).strategy(tree.config)).map((r) => r.task.id);
107
+ const position = new Map(ranked.map((id, i) => [id, i + 1]));
108
+ const off = offTree(tree);
109
+ const isles = islands(tree, kin, tasks, hypotheses);
110
+ const frontiers = new Map();
111
+ const frontierOf = (r) => {
112
+ if (!frontiers.has(r.id))
113
+ frontiers.set(r.id, new Map(frontier(tree, r).map((i) => [i.person.id, i])));
114
+ return frontiers.get(r.id);
115
+ };
116
+ const hypAbout = hypotheses.map((h) => ({ h, about: aboutPeople(tree, h.subject) }));
117
+ const edges = new Map();
118
+ for (const p of tree.list("person")) {
119
+ if (p.retracted)
120
+ continue;
121
+ const parents = parentsOf(tree, p.id);
122
+ const proven = birthRecordProven(p);
123
+ let missing;
124
+ if (parents.length === 0)
125
+ missing = "parents";
126
+ else if (parents.length === 1)
127
+ missing = parents[0].sex === "F" ? "father" : parents[0].sex === "M" ? "mother" : "parents";
128
+ else if (!proven)
129
+ missing = "proof";
130
+ else
131
+ continue;
132
+ // ── whether the research reaches them ──
133
+ const est = birthEstimate(tree, p, lang);
134
+ const window = birthWindow(est);
135
+ const reach = all.filter((s) => (s.people.get(p.id) ?? 0) >= 1).sort((a, b) => a.people.size - b.people.size || a.research.id.localeCompare(b.research.id));
136
+ const limited = (s) => {
137
+ const l = s.research.limits;
138
+ return !!((l?.generations && s.people.get(p.id) >= l.generations) || (l?.before && est.year && est.year < l.before));
139
+ };
140
+ const active = reach.filter((s) => s.research.state === "active");
141
+ const through = active.find((s) => !limited(s)) ?? active[0] ?? reach.find((s) => s.research.state === "paused") ?? reach[0];
142
+ const scope = active.some((s) => !limited(s))
143
+ ? "in"
144
+ : active.length
145
+ ? "limit"
146
+ : through
147
+ ? through.research.state
148
+ : livingBorn(tree, p) !== undefined
149
+ ? "living"
150
+ : kin.size && !kin.has(p.id)
151
+ ? "off-tree"
152
+ : "outside";
153
+ // ── the books of the birth, and the work on the parents ──
154
+ const placeBooks = est.place ? recordsetsCovering(tree, est.place, undefined, BIRTH_KINDS).filter((b) => !b.retracted) : [];
155
+ const books = placeBooks.filter((b) => !window || !b.years || (() => {
156
+ const y = yearsOf(b.years);
157
+ return !y || (y.from <= window.to && y.to >= window.from);
158
+ })());
159
+ const bookIds = new Set(books.map((b) => b.id));
160
+ const hyps = hypAbout.filter(({ about }) => about.includes(p.id)).map(({ h, about }) => ({ h, about }));
161
+ const hypIds = new Set(hyps.map(({ h }) => h.id));
162
+ const onParents = (t) => (t.subject.includes(p.id) && (FRONTIER_LEVELS.has(t.level) || ((t.level === "verify" || t.level === "enrich") && t.where.some((w) => bookIds.has(w))))) ||
163
+ t.subject.some((s) => hypIds.has(s));
164
+ const mine = tasks.filter(onParents);
165
+ const mineIds = new Set(mine.map((t) => t.id));
166
+ const surname = foldText(primaryName(p).surname);
167
+ const sought = searches.filter((q) => (q.task ? mineIds.has(q.task) : !!surname && (q.scope.surnames ?? []).some((s) => foldText(s) === surname) && q.recordsets.some((b) => bookIds.has(b))));
168
+ const edgeTasks = mine
169
+ .filter((t) => LIVE.has(effectiveState(t, today)))
170
+ .map((t) => {
171
+ const state = effectiveState(t, today);
172
+ const at = position.get(t.id);
173
+ const dir = directionOf(tree, t, all);
174
+ const dirState = dir ? researches.get(dir)?.state : undefined;
175
+ const held = state === "parked" ? "parked" : state !== "open" || at ? undefined : dirState === "paused" || dirState === "done" ? dirState : off(t) ? "off-tree" : undefined;
176
+ const on = state === "waiting" ? t.waitingOn : state === "parked" ? t.parkedReason : undefined;
177
+ return {
178
+ id: t.id,
179
+ level: t.level,
180
+ state,
181
+ what: t.what,
182
+ ...(at && state === "open" ? { position: at } : {}),
183
+ ...(held ? { held } : {}),
184
+ ...(state === "parked" && t.parkedUntil ? { until: t.parkedUntil } : {}),
185
+ ...(on ? { on } : {}),
186
+ };
187
+ })
188
+ .sort((a, b) => (a.position ?? 1e9) - (b.position ?? 1e9) || a.id.localeCompare(b.id));
189
+ const tried = mine.filter((t) => FINISHED.has(t.state)).map((t) => t.id);
190
+ const item = through && through.research.state === "active" ? frontierOf(through.research).get(p.id) : undefined;
191
+ const next = edgeTasks.some((t) => t.state === "doing")
192
+ ? "working"
193
+ : edgeTasks.some((t) => t.state === "waiting")
194
+ ? "waiting"
195
+ : edgeTasks.some((t) => t.position)
196
+ ? "queued"
197
+ : edgeTasks.length
198
+ ? "held"
199
+ : scope === "in" && item?.proposal
200
+ ? "proposed"
201
+ : tried.length || item?.exhausted
202
+ ? "decide"
203
+ : "none";
204
+ // ── what the records say so far: each year of the window searched in vain, without records, or open ──
205
+ const covered = [];
206
+ const noRecords = [];
207
+ const lostYears = [];
208
+ const open = [];
209
+ if (window && placeBooks.length)
210
+ for (let y = window.from; y <= window.to; y++) {
211
+ const of = books.filter((b) => within(y, yearsOf(b.years)));
212
+ const kept = of.filter((b) => b.access !== "lost");
213
+ if (!of.length)
214
+ noRecords.push(y);
215
+ else if (!kept.length)
216
+ lostYears.push(y);
217
+ else if (kept.every((b) => sought.some((q) => q.result === "negative" && q.recordsets.includes(b.id) && within(y, yearsOf(q.scope.years) ?? (q.scope.pages ? { from: 0, to: -1 } : yearsOf(b.years))))))
218
+ covered.push(y);
219
+ else
220
+ open.push({ year: y, online: kept.some((b) => b.access === "online-free" || b.access === "online-login") });
221
+ }
222
+ const earliest = Math.min(...placeBooks.map((b) => yearsOf(b.years)?.from ?? Infinity));
223
+ let end;
224
+ // the baptism is read and names the mother alone (a child born out of wedlock); a mother missing is one not recorded yet
225
+ if (missing === "father" && proven)
226
+ end = "unnamed";
227
+ else if (!est.place)
228
+ end = est.year ? "no-place" : "no-clue";
229
+ else if (!placeBooks.length)
230
+ end = "no-books";
231
+ else if (window && !open.length)
232
+ end = lostYears.length
233
+ ? "lost"
234
+ : noRecords.length === window.to - window.from + 1 && earliest > window.to
235
+ ? "before-records"
236
+ : noRecords.length
237
+ ? noRecords.every((y) => y < earliest)
238
+ ? "before-records"
239
+ : "gap"
240
+ : "not-found";
241
+ else if (item?.exhausted)
242
+ end = "not-found";
243
+ else if (window && open.every((o) => !o.online))
244
+ end = "offline";
245
+ else
246
+ end = sought.length || tried.length ? "partly" : "unsearched";
247
+ // ── the hypotheses, conflicts, and what the work cost ──
248
+ // a hypothesis that names people on and off the tree would join them: the people on the other side of it
249
+ const onTree = kin.has(p.id);
250
+ const edgeHyps = hyps.map(({ h, about }) => {
251
+ const joining = kin.size > 0 && about.some((x) => kin.has(x)) && about.some((x) => !kin.has(x));
252
+ const joins = joining ? about.filter((x) => kin.has(x) !== onTree) : [];
253
+ const isle = joins.length && onTree ? isles.get(joins[0]) : undefined;
254
+ return {
255
+ id: h.id,
256
+ question: h.question,
257
+ joins,
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),
260
+ };
261
+ });
262
+ const edgeConflicts = conflicts
263
+ .filter((c) => subjectPeople(tree, c.subject).includes(p.id) && (!c.fact || EDGE_FACTS.has(c.fact)))
264
+ .map((c) => c.id);
265
+ const spent = sessions.filter((s) => s.task && mineIds.has(s.task));
266
+ const cost = spent.reduce((n, s) => n + (s.metrics?.costUsd ?? 0), 0);
267
+ const partial = spent.some((s) => s.state !== "open" && (s.metrics?.costPartial || s.metrics?.costUsd === undefined));
268
+ const last = [...spent.map((s) => s.ended ?? s.started), ...sought.map((q) => q.created)].sort().pop()?.slice(0, 10);
269
+ const known = scope === "in" || scope === "limit" || scope === "paused" || scope === "done" || edgeTasks.length || tried.length || edgeHyps.length || sought.length || edgeConflicts.length;
270
+ if (!known)
271
+ continue;
272
+ edges.set(p.id, {
273
+ person: p.id,
274
+ missing,
275
+ scope,
276
+ ...(through ? { research: through.research.id, generation: through.people.get(p.id) } : {}),
277
+ end,
278
+ next,
279
+ ...(est.year || est.place ? { estimate: { ...(est.year ? { year: est.year } : {}), ...(est.place ? { place: est.place } : {}), ...(est.basis ? { basis: est.basis } : {}) } } : {}),
280
+ ...(window ? { window } : {}),
281
+ ...(Number.isFinite(earliest) && (end === "before-records" || noRecords.some((y) => y < earliest)) ? { recordsFrom: earliest } : {}),
282
+ books: books.map((b) => ({ id: b.id, title: b.title, ...(b.years ? { years: b.years } : {}), access: b.access })),
283
+ covered: ranges(covered),
284
+ noRecords: ranges(noRecords),
285
+ tasks: edgeTasks,
286
+ tried,
287
+ hypotheses: edgeHyps,
288
+ conflicts: edgeConflicts,
289
+ searches: sought.length,
290
+ sessions: spent.length,
291
+ ...(spent.length ? { cost: Math.round(cost * 100) / 100, ...(partial ? { costPartial: true } : {}) } : {}),
292
+ ...(last ? { last } : {}),
293
+ });
294
+ }
295
+ return { edges, islands: isles };
296
+ }
@@ -7,8 +7,8 @@ import { foldText } from "./text.js";
7
7
  import { phrase } from "./phrases.js";
8
8
  const OPEN_STATES = new Set(["open", "doing", "parked", "waiting"]);
9
9
  const FINISHED_STATES = new Set(["done", "dropped"]);
10
- const FRONTIER_LEVELS = new Set(["locate", "link", "request"]);
11
- function birthRecordProven(p) {
10
+ export const FRONTIER_LEVELS = new Set(["locate", "link", "request"]);
11
+ export function birthRecordProven(p) {
12
12
  return p.events.some((e) => ["BIRT", "CHR", "BAPM"].includes(e.kind) && (e.status === "proven" || e.status === "probable") && !e.retracted);
13
13
  }
14
14
  /** The kinds of record set in which an event of this kind is written down. */
@@ -120,7 +120,15 @@ export function birthEstimate(tree, p, lang) {
120
120
  const year = ownYear ?? (marrYear ? marrYear - 25 : eldest ? eldest.year - 28 : undefined);
121
121
  const place = own?.place ?? marr?.place ?? eldest?.b?.place;
122
122
  const from = ownYear ? undefined : marrYear ? phrase(lang, "estimate.marriage", { year: marrYear }) : eldest ? phrase(lang, "estimate.child", { year: eldest.year }) : undefined;
123
- return { ...(year ? { year } : {}), ...(place ? { place } : {}), ...(from ? { from } : {}) };
123
+ const basis = ownYear || !year ? undefined : marrYear ? { kind: "MARR", year: marrYear } : { kind: "child", year: eldest.year };
124
+ return { ...(year ? { year } : {}), ...(place ? { place } : {}), ...(from ? { from } : {}), ...(basis ? { basis } : {}) };
125
+ }
126
+ /** The years a search for a birth has to cover: a few around a known year, wider around an estimate. */
127
+ export function birthWindow(est) {
128
+ if (!est.year)
129
+ return undefined;
130
+ const span = est.from ? 10 : 3;
131
+ return { from: est.year - span, to: est.year + span };
124
132
  }
125
133
  /**
126
134
  * The people a research is about, each with their generation from its focus:
@@ -202,7 +210,7 @@ export function frontier(tree, research) {
202
210
  const tried = new Set(finished.filter((t) => t.level === "link").flatMap((t) => t.where));
203
211
  const place = est.place;
204
212
  // an estimate needs a wider search than a known year
205
- const span = est.from ? 10 : 3;
213
+ const span = est.from ? 10 : 3; // as birthWindow
206
214
  const about = est.from && year ? phrase(lang, "born.about", { year, from: est.from }) : "";
207
215
  const reason = phrase(lang, hasBirthFamily ? "reason.unproven" : "reason.unknown");
208
216
  const sets = candidateRecordsets(tree, place, year).filter((b) => !tried.has(b.id));
package/dist/core/kin.js CHANGED
@@ -2,6 +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
6
  import { ancestorGenerations, familiesAsChild, familiesAsPartner } from "./people.js";
6
7
  function researches(tree) {
7
8
  return tree
@@ -57,7 +58,7 @@ export function mainPerson(tree) {
57
58
  * their ancestors 0, the partners, children and children's partners of those 1, anyone linked by family further 2.
58
59
  */
59
60
  export function kinship(tree) {
60
- const roots = [...new Set([...researches(tree).map((r) => r.focus), mainPerson(tree)].filter((x) => !!x))];
61
+ const roots = [...new Set([...researches(tree).flatMap((r) => [r.focus, ...(r.review?.people ?? [])]), mainPerson(tree)].filter((x) => !!x))];
61
62
  const out = new Map();
62
63
  for (const r of roots)
63
64
  for (const id of ancestorGenerations(tree, r).keys())
@@ -88,3 +89,41 @@ export function kinship(tree) {
88
89
  }
89
90
  return out;
90
91
  }
92
+ /**
93
+ * Work about people nothing links to the tree yet: none of the people a task is about (with those of the conflicts
94
+ * and hypotheses it names) is kin of the people the research is for. It waits out of the queue until the user asks
95
+ * for it (a direction or a review of one of them makes them the research's) or a hypothesis that joins them to the
96
+ * tree is decided and recorded. A task that tests that hypothesis names it (--about H…) or the person of the tree
97
+ * it would join, and so is in the queue. The user's material (intake) and work at hand never wait; a tree with
98
+ * nobody to be for holds nothing.
99
+ */
100
+ export function offTree(tree) {
101
+ const kin = kinship(tree);
102
+ if (!kin.size)
103
+ return () => false;
104
+ return (t) => {
105
+ if (t.level === "intake" || t.state === "doing")
106
+ return false;
107
+ const about = aboutPeople(tree, t.subject);
108
+ return about.length > 0 && !about.some((p) => kin.has(p));
109
+ };
110
+ }
111
+ /**
112
+ * The open hypotheses that would join people off the tree to it: each names someone of the tree and someone of
113
+ * their family (the people family links join to them) — the work to do before any work on them.
114
+ */
115
+ export function joiningHypotheses(tree, people) {
116
+ const kin = kinship(tree);
117
+ const family = new Set();
118
+ const queue = people.filter((p) => !kin.has(p));
119
+ while (queue.length) {
120
+ const p = queue.shift();
121
+ if (family.has(p))
122
+ continue;
123
+ family.add(p);
124
+ queue.push(...familiesAsPartner(tree, p).flatMap((f) => [...f.partners, ...f.children.map((c) => c.person)]), ...familiesAsChild(tree, p).flatMap((f) => f.partners));
125
+ }
126
+ return tree
127
+ .list("hypothesis")
128
+ .filter((h) => h.state === "open" && h.subject.some((s) => family.has(s)) && h.subject.some((s) => kin.has(s)));
129
+ }
@@ -29,15 +29,19 @@ export const LINK_SCHEME = "strom-research";
29
29
  * as it is now into the app's window, open the research's menu, a conversation with the agent (of one person),
30
30
  * a task (answered, put aside, given up, back in the queue), a person's review, a new direction from a person —
31
31
  * and (the app's second wave) a new research with a tree of the app, strom's update, the agent's sessions and
32
- * what they cost, a conflict decided (by the user or the agent), a story approved, a sync taken back, the setup.
32
+ * what they cost, a conflict decided (by the user or the agent), a story approved, a sync taken back, the setup —
33
+ * and (the app's 3.5.0) the research followed live in the app's window; a direction of the research paused, ended or
34
+ * taken up again, a conversation about one direction, a session at work asked to finish.
33
35
  */
34
- export const LINK_ACTIONS = ["send", "excerpt", "app", "open", "chat", "task", "review", "research", "new", "update", "sessions", "conflict", "story", "sync-undo", "setup"];
36
+ export const LINK_ACTIONS = ["send", "excerpt", "app", "open", "chat", "task", "review", "research", "new", "update", "sessions", "conflict", "story", "sync-undo", "setup", "live", "direction", "finish"];
35
37
  /** A person's review: the person, with the family, with the ancestors (strom review --scope). */
36
38
  export const LINK_SCOPES = ["person", "family", "line"];
37
39
  /** A new direction from a person (strom research new --direction). */
38
40
  export const LINK_DIRECTIONS = ["ancestors", "descendants"];
39
41
  /** What a link does with a task: put it aside, give it up, back into the queue (none: answer it). */
40
42
  export const LINK_TASK_DOS = ["park", "drop", "wake"];
43
+ /** What a link does with a direction of the research (strom research pause|done|resume). */
44
+ export const LINK_DIRECTION_DOS = ["pause", "done", "resume"];
41
45
  /** What a link does with a conflict: the user decides it, or leaves it to the agent. */
42
46
  export const LINK_CONFLICT_DOS = ["decide", "agent"];
43
47
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -46,6 +50,8 @@ const PERSON = /^P\d{1,9}$/;
46
50
  const TASK = /^T\d{1,9}$/;
47
51
  const CONFLICT = /^X\d{1,9}$/;
48
52
  const INPUT = /^I\d{1,9}$/;
53
+ const RESEARCH = /^G\d{1,9}$/;
54
+ const SESSION = /^N\d{1,9}$/;
49
55
  /** The app's own mark of a tree it hands over (32 random bytes, base64url). */
50
56
  export const APP_TOKEN = /^[A-Za-z0-9_-]{22,43}$/;
51
57
  /** An excerpt's mark (_STROM_CLIP): letters, digits and "-", at most 32. */
@@ -103,6 +109,7 @@ export function parseLink(text) {
103
109
  switch (action) {
104
110
  case "send":
105
111
  case "app":
112
+ case "live":
106
113
  case "open":
107
114
  case "update":
108
115
  case "sessions":
@@ -115,8 +122,10 @@ export function parseLink(text) {
115
122
  throw new LinkError("no excerpt named");
116
123
  return { action, tree: id, source, clip };
117
124
  }
118
- case "chat":
119
- return { action, tree: id, ...(person ? { person } : {}) };
125
+ case "chat": {
126
+ const research = idOf(q, "research", RESEARCH, false);
127
+ return { action, tree: id, ...(person ? { person } : {}), ...(research ? { research } : {}) };
128
+ }
120
129
  case "task": {
121
130
  const task = idOf(q, "task", TASK, true);
122
131
  return { action, tree: id, task, ...(q.has("do") ? { do: oneOf(q, "do", LINK_TASK_DOS) } : {}) };
@@ -136,6 +145,10 @@ export function parseLink(text) {
136
145
  return { action, tree: id, id: idOf(q, "id", CONFLICT, true), do: oneOf(q, "do", LINK_CONFLICT_DOS, "decide") };
137
146
  case "sync-undo":
138
147
  return { action, tree: id, intake: idOf(q, "intake", INPUT, true) };
148
+ case "direction":
149
+ return { action, tree: id, id: idOf(q, "id", RESEARCH, true), do: oneOf(q, "do", LINK_DIRECTION_DOS) };
150
+ case "finish":
151
+ return { action, tree: id, session: idOf(q, "session", SESSION, true) };
139
152
  }
140
153
  throw new LinkError(`unknown action "${action.slice(0, 32)}"`);
141
154
  }