strom-research 1.0.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 (130) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +142 -0
  3. package/assets/lang/cs.json +302 -0
  4. package/assets/lang/de.json +302 -0
  5. package/assets/method/core.md +43 -0
  6. package/assets/method/enrich.md +11 -0
  7. package/assets/method/intake.md +30 -0
  8. package/assets/method/link.md +28 -0
  9. package/assets/method/locate.md +28 -0
  10. package/assets/method/narrate.md +13 -0
  11. package/assets/method/reading.md +62 -0
  12. package/assets/method/recording.md +59 -0
  13. package/assets/method/request.md +10 -0
  14. package/assets/method/verify.md +17 -0
  15. package/assets/plugins/README.md +23 -0
  16. package/assets/plugins/connectors/DISCOVERY.md +159 -0
  17. package/assets/plugins/connectors/README.md +376 -0
  18. package/assets/plugins/connectors/sdk.ts +168 -0
  19. package/assets/plugins/connectors/template.ts +38 -0
  20. package/assets/plugins/gitignore +4 -0
  21. package/dist/agents/files.js +313 -0
  22. package/dist/agents/global.js +257 -0
  23. package/dist/agents/launch.js +36 -0
  24. package/dist/agents/profiles.js +95 -0
  25. package/dist/brief/brief.js +345 -0
  26. package/dist/cli/commit.js +44 -0
  27. package/dist/cli/context.js +311 -0
  28. package/dist/cli/execute.js +154 -0
  29. package/dist/cli/fixes.js +78 -0
  30. package/dist/cli/format.js +53 -0
  31. package/dist/cli/help.js +59 -0
  32. package/dist/cli/main.js +152 -0
  33. package/dist/cli/menu.js +212 -0
  34. package/dist/cli/registry.js +96 -0
  35. package/dist/cli/ui.js +266 -0
  36. package/dist/cli/wizard.js +142 -0
  37. package/dist/cli.js +14 -0
  38. package/dist/commands/analysis.js +622 -0
  39. package/dist/commands/batch.js +181 -0
  40. package/dist/commands/checks.js +153 -0
  41. package/dist/commands/connectors.js +1377 -0
  42. package/dist/commands/guide.js +160 -0
  43. package/dist/commands/index.js +19 -0
  44. package/dist/commands/intake.js +234 -0
  45. package/dist/commands/media.js +406 -0
  46. package/dist/commands/meta.js +195 -0
  47. package/dist/commands/output.js +117 -0
  48. package/dist/commands/people.js +664 -0
  49. package/dist/commands/read.js +199 -0
  50. package/dist/commands/research.js +139 -0
  51. package/dist/commands/session.js +605 -0
  52. package/dist/commands/setup.js +465 -0
  53. package/dist/commands/sources.js +634 -0
  54. package/dist/commands/start.js +383 -0
  55. package/dist/commands/story.js +75 -0
  56. package/dist/commands/tasks.js +436 -0
  57. package/dist/commands/trees.js +128 -0
  58. package/dist/core/actions.js +852 -0
  59. package/dist/core/age.js +95 -0
  60. package/dist/core/apps.js +74 -0
  61. package/dist/core/assets.js +34 -0
  62. package/dist/core/awake.js +33 -0
  63. package/dist/core/browser.js +281 -0
  64. package/dist/core/calibration.js +48 -0
  65. package/dist/core/check.js +112 -0
  66. package/dist/core/chromium.js +88 -0
  67. package/dist/core/config.js +348 -0
  68. package/dist/core/connector.js +811 -0
  69. package/dist/core/deps.js +73 -0
  70. package/dist/core/dialog.js +61 -0
  71. package/dist/core/errors.js +89 -0
  72. package/dist/core/evidence.js +58 -0
  73. package/dist/core/frontier.js +219 -0
  74. package/dist/core/gdate.js +77 -0
  75. package/dist/core/git.js +300 -0
  76. package/dist/core/guard.js +124 -0
  77. package/dist/core/http2.js +76 -0
  78. package/dist/core/import.js +541 -0
  79. package/dist/core/install.js +28 -0
  80. package/dist/core/integrity.js +219 -0
  81. package/dist/core/json.js +87 -0
  82. package/dist/core/lang.js +70 -0
  83. package/dist/core/live.js +244 -0
  84. package/dist/core/lock.js +112 -0
  85. package/dist/core/logins.js +67 -0
  86. package/dist/core/media.js +223 -0
  87. package/dist/core/model.js +101 -0
  88. package/dist/core/net.js +366 -0
  89. package/dist/core/open.js +29 -0
  90. package/dist/core/paths.js +84 -0
  91. package/dist/core/people.js +283 -0
  92. package/dist/core/phrases.js +85 -0
  93. package/dist/core/queue.js +113 -0
  94. package/dist/core/reader.js +76 -0
  95. package/dist/core/records.js +105 -0
  96. package/dist/core/roles.js +30 -0
  97. package/dist/core/schema.js +261 -0
  98. package/dist/core/seal.js +77 -0
  99. package/dist/core/self.js +40 -0
  100. package/dist/core/session.js +155 -0
  101. package/dist/core/shortcut.js +90 -0
  102. package/dist/core/stories.js +61 -0
  103. package/dist/core/stromapp.js +138 -0
  104. package/dist/core/text.js +104 -0
  105. package/dist/core/tree.js +507 -0
  106. package/dist/core/uninstall.js +128 -0
  107. package/dist/core/update.js +193 -0
  108. package/dist/core/validate.js +260 -0
  109. package/dist/core/views.js +164 -0
  110. package/dist/core/which.js +51 -0
  111. package/dist/core/workers.js +42 -0
  112. package/dist/gedcom/export.js +454 -0
  113. package/dist/gedcom/labels.js +103 -0
  114. package/dist/gedcom/lines.js +91 -0
  115. package/dist/gedcom/parse.js +53 -0
  116. package/dist/gedcom/validate.js +183 -0
  117. package/dist/image/image.js +223 -0
  118. package/dist/image/index.js +114 -0
  119. package/dist/image/jpeg-decode.js +552 -0
  120. package/dist/image/jpeg-encode.js +254 -0
  121. package/dist/image/png.js +241 -0
  122. package/dist/runners/antigravity.js +70 -0
  123. package/dist/runners/claude.js +179 -0
  124. package/dist/runners/codex.js +45 -0
  125. package/dist/runners/index.js +13 -0
  126. package/dist/runners/jsonl.js +86 -0
  127. package/dist/runners/opencode.js +50 -0
  128. package/dist/runners/runner.js +63 -0
  129. package/dist/runners/script.js +58 -0
  130. package/package.json +44 -0
@@ -0,0 +1,199 @@
1
+ // strom read — browse images with readers: batches of at most ten images, each
2
+ // read by an agent with a clean context that writes its report to
3
+ // notes/readings/ as it goes. The researcher gets a few lines back: where
4
+ // something was found. Readers never write to the research.
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { register } from "../cli/registry.js";
8
+ import { lines, runs } from "../cli/format.js";
9
+ import { StromError, UsageError } from "../core/errors.js";
10
+ import { normId, requireRecord } from "../core/records.js";
11
+ import { pageOf } from "../core/calibration.js";
12
+ import { findImage, imageOfRef } from "../core/media.js";
13
+ import { makeView, parseCrop, tiles, VIEW_MAX } from "../core/views.js";
14
+ import { BATCH, BATCH_MAX, batches, parseReport, readerPrompt, readerSettings } from "../core/reader.js";
15
+ import { RUNNERS } from "../runners/index.js";
16
+ import { which } from "../core/which.js";
17
+ import { permissionPath } from "../agents/files.js";
18
+ import { verifyFast } from "../core/integrity.js";
19
+ import { truncate } from "../cli/format.js";
20
+ /** "40-69" → [40, 69] */
21
+ function range(v) {
22
+ const m = /^(\d+)(?:-(\d+))?$/.exec(v.trim());
23
+ if (!m)
24
+ throw new UsageError(`invalid --images "${v}"`, { hint: "e.g. 40-69" });
25
+ return [Number(m[1]), Number(m[2] ?? m[1])];
26
+ }
27
+ async function pool(items, size, fn) {
28
+ const results = new Array(items.length);
29
+ let next = 0;
30
+ const worker = async () => {
31
+ while (next < items.length) {
32
+ const i = next++;
33
+ results[i] = await fn(items[i], i);
34
+ }
35
+ };
36
+ await Promise.all(Array.from({ length: Math.min(size, items.length) }, worker));
37
+ return results;
38
+ }
39
+ register({
40
+ path: ["read"],
41
+ summary: "Browse images with readers: batches of ≤10, each a clean context writing its report — you get the finds",
42
+ group: "sources",
43
+ tree: true,
44
+ description: "For agents without their own subagents (and to keep batches small for any agent). Readers see only their views\n" +
45
+ "and write only their report (notes/readings/); they never change the research. Record the result yourself:\n" +
46
+ "a found entry after looking at it with your own eyes, and the search — also a negative one — with --by reader.\n" +
47
+ "It waits for its readers (often ten minutes or more): run it in the foreground and let it finish.",
48
+ args: [{ name: "images", description: "a record set (with --images), or images: M… or B…:<image>", variadic: true, required: true }],
49
+ options: [
50
+ { name: "images", type: "string", value: "<from-to>", description: "image numbers of the record set, e.g. 40-69" },
51
+ { name: "question", type: "string", value: "<text>", description: "what to look for and what counts as a find (the whole question — readers know nothing else)" },
52
+ { name: "context", type: "string", value: "<text>", description: "what is known that helps recognise the family (names, house, years)" },
53
+ { name: "blind", type: "boolean", description: "blind reading: transcribe exactly, no expectations (for verification)" },
54
+ { name: "half", type: "string", value: "<side>", description: "only the left or right page of each spread" },
55
+ { name: "crop", type: "string", value: "<x,y,w,h>", description: "only this part of each image, at full resolution (an entry to verify: find it with strom media view --grid)" },
56
+ { name: "contrast", type: "boolean", description: "more contrast (faded ink)" },
57
+ { name: "max", type: "string", value: "<px>", description: `size of the views (default ${VIEW_MAX}); smaller = cheaper browsing` },
58
+ { name: "batch", type: "string", value: "<n>", description: `images per reader (default ${BATCH}, at most ${BATCH_MAX})` },
59
+ { name: "parallel", type: "string", value: "<n>", description: "readers at the same time (default 3)" },
60
+ { name: "model", type: "string", value: "<model>", description: "model of the readers (default: model.vision — never weaker for handwriting)" },
61
+ { name: "minutes", type: "string", value: "<n>", description: "time limit of one reader (default 15)" },
62
+ ],
63
+ examples: [
64
+ 'strom read B0001 --images 40-69 --question "Every baptism of the surname Novák (Nowak, Nowack) in 1820–1824: the child, date, house, both parents and their parents"',
65
+ 'strom read B0001:2 --blind --crop 0.05,0.40,0.45,0.18 --question "Transcribe this entry completely; mark what is uncertain with [?]"',
66
+ ],
67
+ run: async (ctx, { args, opts }) => {
68
+ const tree = ctx.tree();
69
+ const question = typeof opts.question === "string" ? opts.question.trim() : "";
70
+ if (!question)
71
+ throw new UsageError("--question is required", { hint: "the whole question: what to look for, what counts as a find, which years and names" });
72
+ const all = tree.list("media");
73
+ let images;
74
+ let label;
75
+ if (args.length === 1 && /^[Bb]\d+$/.test(args[0])) {
76
+ const b = requireRecord(tree, args[0], "recordset");
77
+ if (typeof opts.images !== "string")
78
+ throw new UsageError("which images?", { hint: `--images 40-69 (strom media list --recordset ${b.id})` });
79
+ const [lo, hi] = range(opts.images);
80
+ // each image once, as the sharpest copy of it
81
+ const nums = [...new Set(all.filter((m) => m.recordset === b.id && !m.part && m.image !== undefined && m.image >= lo && m.image <= hi).map((m) => m.image))];
82
+ images = nums.sort((x, y) => x - y).map((n) => findImage(all, b.id, n));
83
+ if (images.length === 0)
84
+ throw new UsageError(`no registered images ${lo}–${hi} of ${b.id}`, { hint: `strom media add <folder> --recordset ${b.id}` });
85
+ label = `${b.id}-${lo}-${hi}`;
86
+ }
87
+ else {
88
+ images = args.map((a) => {
89
+ const at = /^([Bb]\d+):(\d+)$/.exec(a.trim());
90
+ if (!at) {
91
+ // a copy of an image: the sharpest copy of it is read
92
+ const m = requireRecord(tree, normId(a), "media");
93
+ return (!m.part && m.recordset !== undefined && m.image !== undefined ? findImage(all, m.recordset, m.image) : undefined) ?? m;
94
+ }
95
+ const b = requireRecord(tree, at[1], "recordset");
96
+ const m = imageOfRef(all, b.id, Number(at[2]));
97
+ if (!m)
98
+ throw new UsageError(`image ${at[2]} of ${b.id} is not registered`, { hint: `strom media list --recordset ${b.id}` });
99
+ return m;
100
+ });
101
+ label = images.map((m) => m.id).join("-").slice(0, 40);
102
+ }
103
+ const agentId = ctx.settings.agent(tree.config).value;
104
+ const runner = RUNNERS[agentId];
105
+ if (!runner)
106
+ throw new UsageError(`no reader for agent "${agentId}" yet`, { hint: "strom read works with Claude Code: strom read … --agent claude" });
107
+ if (agentId !== "script" && !which(runner.command, ctx.env))
108
+ throw new StromError(`${runner.command} is not installed`);
109
+ const model = typeof opts.model === "string" ? opts.model : ctx.settings.models(agentId, tree.config).vision;
110
+ const size = opts.batch === undefined ? BATCH : Number(opts.batch);
111
+ if (!Number.isInteger(size) || size < 1)
112
+ throw new UsageError("--batch must be a positive number");
113
+ const parallel = opts.parallel === undefined ? 3 : Math.max(1, Number(opts.parallel) || 1);
114
+ const minutes = opts.minutes === undefined ? 15 : Number(opts.minutes);
115
+ const half = opts.half;
116
+ if (half && !["left", "right"].includes(half))
117
+ throw new UsageError("--half is left or right");
118
+ // Views at browsing size; the readers may open exactly these.
119
+ const prepared = images.map((m) => {
120
+ const b = m.recordset ? tree.get(m.recordset) : undefined;
121
+ const file = path.join(ctx.settings.shared().value, m.file);
122
+ const page = m.page ?? (b && m.image !== undefined ? pageOf(b, m.image) : undefined);
123
+ const contrast = Boolean(opts.contrast);
124
+ if (typeof opts.crop === "string" && m.width && m.height) {
125
+ // a crop is read at full resolution — in overlapping parts when it is bigger than one look
126
+ const parts = tiles(parseCrop(opts.crop, m.width, m.height)).map((t) => ({ label: t.label, view: makeView(tree, file, m.id, { crop: t.crop, contrast }).file }));
127
+ return { id: m.id, image: m.image, page, view: parts[0].view, ...(parts.length > 1 ? { parts } : {}) };
128
+ }
129
+ const v = makeView(tree, file, m.id, { half, max: opts.max === undefined ? VIEW_MAX : Number(opts.max), contrast });
130
+ return { id: m.id, image: m.image, page, view: v.file };
131
+ });
132
+ const groups = batches(prepared, size);
133
+ const day = new Date().toISOString().slice(0, 10);
134
+ const reportsDir = path.join(tree.root, "notes", "readings");
135
+ fs.mkdirSync(reportsDir, { recursive: true });
136
+ // Another reading of the same images on the same day keeps the earlier reports.
137
+ let stem = `${day}-${label}`;
138
+ for (let n = 2; fs.existsSync(path.join(reportsDir, `${stem}-1.md`)); n++)
139
+ stem = `${day}-${label}-run${n}`;
140
+ const work = path.join(ctx.settings.shared().value, "cache", "readers");
141
+ const progress = (s) => (ctx.json ? ctx.io.stderr : ctx.io.stdout)(s + "\n");
142
+ const results = await pool(groups, parallel, async (group, k) => {
143
+ const report = path.join(reportsDir, `${stem}-${k + 1}.md`);
144
+ // Outside the tree: a reader must not pick up the researcher's instructions (CLAUDE.md).
145
+ const cwd = path.join(work, `${stem}-${k + 1}`);
146
+ fs.mkdirSync(cwd, { recursive: true });
147
+ const settingsFile = path.join(cwd, "reader-settings.json");
148
+ fs.writeFileSync(settingsFile, JSON.stringify(readerSettings(group.flatMap((g) => [g.view, ...(g.parts ?? []).map((p) => p.view)]), report, permissionPath), null, 2));
149
+ fs.writeFileSync(report, `# Reading ${label} · batch ${k + 1} of ${groups.length}\nQuestion: ${question}\n\n`);
150
+ const prompt = readerPrompt({ question, images: group, report, lang: tree.lang, context: opts.context, blind: Boolean(opts.blind) });
151
+ progress(`▶ reader ${k + 1}/${groups.length}: ${group.length} images (${runs(group.map((g) => g.image ?? 0))})`);
152
+ const { STROM_SESSION: _s, ...env } = ctx.env;
153
+ const r = await runner.run({
154
+ cwd,
155
+ prompt,
156
+ kickoff: prompt,
157
+ env: { ...env, STROM_NONINTERACTIVE: "1", STROM_READER: "1" },
158
+ settingsFile,
159
+ timeoutMs: minutes * 60_000,
160
+ logFile: path.join(tree.root, ".strom", "runs", `read-${stem}-${k + 1}.log`),
161
+ ...(model ? { model } : {}),
162
+ });
163
+ // A reader that answered but did not write: keep its answer as the report.
164
+ const text = fs.readFileSync(report, "utf8");
165
+ if (!/^##\s/m.test(text) && r.text.trim())
166
+ fs.appendFileSync(report, r.text.trim() + "\n");
167
+ const findings = parseReport(fs.readFileSync(report, "utf8"));
168
+ progress(`■ reader ${k + 1}: ${r.outcome}${r.metrics.costUsd !== undefined ? ` · $${r.metrics.costUsd.toFixed(2)}` : ""} · ${findings.filter((f) => f.result === "found").length} with finds`);
169
+ return { group, report, outcome: r.outcome, cost: r.metrics.costUsd, findings };
170
+ });
171
+ // The reports are working notes of the research: committed with it.
172
+ if (verifyFast(tree).findings.every((f) => f.level !== "error") && !tree.dryRun)
173
+ tree.withTreeLock(() => tree.commit(`Reading ${label}: ${prepared.length} images by readers`, [tree.relative(reportsDir)]));
174
+ const byId = new Map(prepared.map((p) => [p.id, p]));
175
+ const numberOf = (f) => byId.get(f.image)?.image ?? Number(f.image);
176
+ const findings = results.flatMap((r) => r.findings);
177
+ const found = findings.filter((f) => f.result === "found");
178
+ const unclear = findings.filter((f) => f.result === "unclear");
179
+ const seen = new Set(findings.map((f) => byId.has(f.image) ? f.image : prepared.find((p) => p.image === Number(f.image))?.id));
180
+ const missing = prepared.filter((p) => !seen.has(p.id));
181
+ const failed = results.filter((r) => r.outcome !== "ok");
182
+ const cost = results.reduce((s, r) => s + (r.cost ?? 0), 0);
183
+ const b = images[0]?.recordset;
184
+ const text = lines(`read ${prepared.length} images in ${results.length} batch(es)${model ? ` · ${model}` : ""}${cost ? ` · $${cost.toFixed(2)}` : ""}`, `found on: ${found.length ? runs(found.map(numberOf)) : "none"}`, unclear.length ? `unclear on: ${runs(unclear.map(numberOf))} — look yourself or read again` : undefined, missing.length ? `NOT reported: ${runs(missing.map((m) => m.image ?? 0))} — read these again` : undefined, failed.length ? `readers that failed: ${failed.map((r) => `${runs(r.group.map((g) => g.image ?? 0))} (${r.outcome})`).join(", ")}` : undefined, `reports: ${results.map((r) => ctx.display(r.report)).join(" · ")}`, "", "next: look at each find yourself (strom media view <M…> --crop …) before it becomes a source;", `record the search: strom search add "${truncate(question, 50)}"${b ? ` --recordset ${b}` : ""} --pages ${runs(prepared.map((p) => p.image ?? 0)).replace(/–/g, "-")} --method page-by-page --by reader --result ${found.length ? "found" : "negative"}`);
185
+ return {
186
+ text,
187
+ data: {
188
+ images: prepared.length,
189
+ found: found.map((f) => ({ image: numberOf(f), media: byId.has(f.image) ? f.image : undefined })),
190
+ unclear: unclear.map(numberOf),
191
+ missing: missing.map((m) => m.id),
192
+ failed: failed.map((r) => ({ images: r.group.map((g) => g.id), outcome: r.outcome })),
193
+ reports: results.map((r) => r.report),
194
+ costUsd: cost || undefined,
195
+ },
196
+ exitCode: failed.length ? 1 : 0,
197
+ };
198
+ },
199
+ });
@@ -0,0 +1,139 @@
1
+ // research new · list · show — named goals inside a tree.
2
+ import { register } from "../cli/registry.js";
3
+ import { lines, table } from "../cli/format.js";
4
+ import { addPerson, addResearch } from "../core/actions.js";
5
+ import { UsageError } from "../core/errors.js";
6
+ import { ancestorGenerations, displayName, label, lifespan, parentsOf, resolvePerson } from "../core/people.js";
7
+ import { foldText } from "../core/text.js";
8
+ function int(v, name) {
9
+ if (v === undefined)
10
+ return undefined;
11
+ const n = Number(v);
12
+ if (!Number.isInteger(n))
13
+ throw new UsageError(`--${name} must be a whole number`);
14
+ return n;
15
+ }
16
+ export function resolveResearch(tree, ref) {
17
+ const all = tree.list("research");
18
+ const id = ref.toUpperCase();
19
+ const byId = all.find((r) => r.id === id || r.id === "G" + id.replace(/^G/, "").padStart(4, "0"));
20
+ if (byId)
21
+ return byId;
22
+ const hits = all.filter((r) => foldText(r.name).includes(foldText(ref)));
23
+ if (hits.length === 1)
24
+ return hits[0];
25
+ if (hits.length === 0)
26
+ throw new UsageError(`no research "${ref}"`, { hint: "strom research list" });
27
+ throw new UsageError(`"${ref}" matches ${hits.length} researches`, { hint: hits.map((r) => `${r.id} ${r.name}`).join(" · ") });
28
+ }
29
+ function researchLine(tree, r) {
30
+ const focus = tree.get(r.focus);
31
+ return [r.id, r.name, `[${r.state}]`, r.direction, focus ? label(focus) : r.focus];
32
+ }
33
+ register({
34
+ path: ["research", "new"],
35
+ summary: "Start a named research, e.g. the ancestors of one person",
36
+ group: "research",
37
+ tree: true,
38
+ writes: true,
39
+ description: "The focus person must exist (--person) or is created (--new-person). What the user remembers is\n" +
40
+ "recorded as leads, never as proven facts.",
41
+ args: [{ name: "name", description: 'research name, e.g. "Ancestors of Jan Novák"', required: true }],
42
+ options: [
43
+ { name: "person", type: "string", value: "<who>", description: "focus person (ID or name)" },
44
+ { name: "new-person", type: "string", value: "<name>", description: 'create the focus person: "Jan /Novák/"' },
45
+ { name: "sex", type: "string", value: "M|F|U", description: "sex of the new person" },
46
+ { name: "born", type: "string", value: "<date>", description: "birth date of the new person (lead)" },
47
+ { name: "born-place", type: "string", value: "<place>", description: "birth place of the new person (lead)" },
48
+ { name: "direction", type: "string", value: "<dir>", description: "ancestors (default), descendants, person, question" },
49
+ { name: "generations", type: "string", value: "<n>", description: "stop after n generations" },
50
+ { name: "before", type: "string", value: "<year>", description: "stop at people born before this year" },
51
+ { name: "question", type: "string", value: "<text>", description: "the research question (direction question)" },
52
+ { name: "priority", type: "string", value: "1-5", description: "priority among researches (default 3)" },
53
+ { name: "note", type: "string", value: "<text>", description: "what the user told you (short)" },
54
+ ],
55
+ examples: [
56
+ 'strom research new "Předci Jana Nováka" --new-person "Jan /Novák/" --sex M --born "ABT 1905" --born-place "Týnec nad Labem"',
57
+ 'strom research new "Linie Svobodová" --person P0003 --generations 5',
58
+ 'strom research new "Kdo byl otec Marie?" --person "Marie Nováková" --direction question --question "Who was her father?"',
59
+ ],
60
+ run(ctx, { args, opts }) {
61
+ const tree = ctx.tree();
62
+ if (!opts.person === !opts["new-person"])
63
+ throw new UsageError("give exactly one of --person <who> or --new-person <name>");
64
+ let focus;
65
+ if (opts["new-person"]) {
66
+ focus = addPerson(tree, {
67
+ name: opts["new-person"],
68
+ sex: opts.sex,
69
+ born: opts.born,
70
+ bornPlace: opts["born-place"],
71
+ });
72
+ }
73
+ else
74
+ focus = resolvePerson(tree, opts.person);
75
+ const r = addResearch(tree, {
76
+ name: args[0],
77
+ focus: focus.id,
78
+ direction: opts.direction,
79
+ generations: int(opts.generations, "generations"),
80
+ before: int(opts.before, "before"),
81
+ question: opts.question,
82
+ priority: int(opts.priority, "priority"),
83
+ note: opts.note,
84
+ });
85
+ const text = lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined, "", tree.count("input") === 0
86
+ ? `next strom intake --text "<what the user told you about ${displayName(focus)} and the family>"`
87
+ : `next strom session start`);
88
+ return { text, data: { research: r, focus } };
89
+ },
90
+ }, {
91
+ path: ["research", "list"],
92
+ summary: "Researches in this tree",
93
+ group: "research",
94
+ tree: true,
95
+ options: [{ name: "state", type: "string", value: "<state>", description: "active, paused or done" }],
96
+ run(ctx, { opts }) {
97
+ const tree = ctx.tree();
98
+ const all = tree.list("research").filter((r) => !opts.state || r.state === opts.state);
99
+ if (all.length === 0)
100
+ return { text: 'no researches yet → strom research new "<name>" --new-person "<Given /Surname/>"', data: { researches: [] } };
101
+ return { text: table(all.map((r) => researchLine(tree, r))), data: { researches: all } };
102
+ },
103
+ }, {
104
+ path: ["research", "show"],
105
+ summary: "One research: goal, focus person, known ancestors, what is missing",
106
+ group: "research",
107
+ tree: true,
108
+ args: [{ name: "research", description: "ID (G0001) or part of the name", required: true }],
109
+ run(ctx, { args }) {
110
+ const tree = ctx.tree();
111
+ const r = resolveResearch(tree, args[0]);
112
+ const focus = tree.get(r.focus);
113
+ const gens = ancestorGenerations(tree, r.focus, r.limits?.generations ?? 50);
114
+ const byGen = new Map();
115
+ for (const [id, g] of gens) {
116
+ const p = tree.get(id);
117
+ if (p)
118
+ byGen.set(g, [...(byGen.get(g) ?? []), p]);
119
+ }
120
+ const missingParents = [...gens.keys()]
121
+ .map((id) => tree.get(id))
122
+ .filter((p) => p && parentsOf(tree, p.id).length < 2);
123
+ const limits = [r.limits?.generations ? `${r.limits.generations} generations` : "", r.limits?.before ? `born after ${r.limits.before}` : ""].filter(Boolean).join(", ");
124
+ const text = lines(`${r.id} ${r.name} [${r.state}] priority ${r.priority}`, `goal ${r.direction}${r.question ? `: ${r.question}` : ""} of ${focus ? label(focus) : r.focus}${limits ? ` (${limits})` : ""}`, ...r.notes.map((n) => `note ${n.text}`), "", "known ancestors by generation", table([...byGen.entries()]
125
+ .sort((a, b) => a[0] - b[0])
126
+ .flatMap(([g, ps]) => ps.map((p) => [` G${g}`, p.id, displayName(p), lifespan(p)]))), "", missingParents.length
127
+ ? `missing parents (${missingParents.length}): ` + missingParents.slice(0, 10).map((p) => `${p.id} ${displayName(p)}`).join(" · ")
128
+ : "every person in scope has both parents");
129
+ return {
130
+ text,
131
+ data: {
132
+ research: r,
133
+ focus,
134
+ generations: Object.fromEntries([...gens.entries()]),
135
+ missingParents: missingParents.map((p) => p.id),
136
+ },
137
+ };
138
+ },
139
+ });