strom-research 1.4.0 → 1.5.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.
- package/assets/lang/cs.json +78 -1
- package/assets/lang/de.json +78 -1
- package/assets/method/reading.md +8 -1
- package/dist/agents/files.js +12 -2
- package/dist/agents/global.js +9 -4
- package/dist/agents/profiles.js +8 -4
- package/dist/cli/menu-parts.js +4 -1
- package/dist/cli/menu-research.js +163 -4
- package/dist/cli/menu-settings.js +17 -3
- package/dist/cli/menu.js +3 -2
- package/dist/cli/ui.js +77 -1
- package/dist/commands/browse.js +4 -3
- package/dist/commands/checks.js +7 -1
- package/dist/commands/guide.js +28 -7
- package/dist/commands/index.js +1 -0
- package/dist/commands/intake.js +3 -0
- package/dist/commands/people.js +58 -1
- package/dist/commands/research.js +113 -24
- package/dist/commands/sync.js +229 -0
- package/dist/core/config.js +5 -0
- package/dist/core/frontier.js +10 -1
- package/dist/core/live.js +79 -2
- package/dist/core/phrases.js +1 -0
- package/dist/core/review.js +43 -5
- package/dist/core/self.js +17 -0
- package/dist/core/stromapp.js +13 -0
- package/dist/core/sync.js +786 -0
- package/dist/core/tree.js +1 -1
- package/dist/core/validate.js +4 -0
- package/dist/gedcom/export.js +12 -1
- package/dist/gedcom/validate.js +2 -2
- package/package.json +1 -1
package/dist/cli/ui.js
CHANGED
|
@@ -241,6 +241,18 @@ export const UI = {
|
|
|
241
241
|
"ui.review.pick": "Which one?",
|
|
242
242
|
"ui.review.family": "With their spouse and children?",
|
|
243
243
|
"ui.review.run": "Let the agent work on it now, on its own? ({n} tasks)",
|
|
244
|
+
"ui.menu.unproven": "People without a record of their own ({n})",
|
|
245
|
+
"ui.unproven.title": "No record of their own proves these people yet:",
|
|
246
|
+
"ui.unproven.named": "named in a record",
|
|
247
|
+
"ui.unproven.leads": "only leads",
|
|
248
|
+
"ui.unproven.none": "no record",
|
|
249
|
+
"ui.unproven.tasks": "work on them is under way",
|
|
250
|
+
"ui.unproven.living": "Most likely living (born less than {years} years ago) — the family's to tell, no tasks: {names}",
|
|
251
|
+
"ui.unproven.how": "What should the agent do?",
|
|
252
|
+
"ui.unproven.all": "All of them, step by step ({n} at a time)",
|
|
253
|
+
"ui.unproven.pick": "Only the ones I pick",
|
|
254
|
+
"ui.unproven.numbers": "Their numbers from the list (e.g. 1 3 5-7, or their IDs; Enter or 0 goes back)",
|
|
255
|
+
"ui.unproven.bad": "Not in the list: {x}",
|
|
244
256
|
"ui.o.reread": "second reading",
|
|
245
257
|
"ui.o.reread.offer": "{n} facts rest only on readings by another model ({models}); the user reads with {model} now — offer it once, in a sentence: strom review <whom> --reread (a task per person; the user decides)",
|
|
246
258
|
"ui.o.stories": "stories",
|
|
@@ -278,6 +290,7 @@ export const UI = {
|
|
|
278
290
|
"ui.update.downloading": "Downloading strom {version}…",
|
|
279
291
|
"ui.update.failed": "The update did not work: {detail}. strom stays as it was.",
|
|
280
292
|
"ui.update.done": "✓ strom is updated: {previous} → {version}.",
|
|
293
|
+
"ui.update.restart": "strom is updated — this menu still runs the old version. Start strom again (or the shortcut \"{shortcut}\"), and the new version is at work.",
|
|
281
294
|
"ui.o.update": "update",
|
|
282
295
|
"ui.o.update.new": "{version} is out (this is {current}) — tell the user; strom update installs it with their yes",
|
|
283
296
|
"ui.app.where.installed": "the installed app",
|
|
@@ -380,6 +393,60 @@ export const UI = {
|
|
|
380
393
|
"ui.browse.plan": "What the agent will do next",
|
|
381
394
|
"ui.review.new": "{name} — {n} new task(s):",
|
|
382
395
|
"ui.review.nothing": "{name} — nothing new to review",
|
|
396
|
+
"ui.sync.title": "{file}: {n} change(s) against the research",
|
|
397
|
+
"ui.sync.base": "Compared with what the research gave the Strom app ({head}): only what was changed since counts as your edit.",
|
|
398
|
+
"ui.sync.nobase": "The file does not say which state of the research it came from: additions are taken, differences only shown (pick them with --only).",
|
|
399
|
+
"ui.sync.headmissing": "The research's history does not have the state {head} the file names: additions are taken, differences only shown (pick them with --only).",
|
|
400
|
+
"ui.sync.partial": "The file has far fewer people than it was given: nothing is taken as removed.",
|
|
401
|
+
"ui.sync.strangers": "These IDs are not the research's people of that name — taken as new people: {ids}",
|
|
402
|
+
"ui.sync.nothing": "{file}: nothing new — the research has everything in it.",
|
|
403
|
+
"ui.sync.person.new": "{n}. new person: {name}",
|
|
404
|
+
"ui.sync.fact.new": "{n}. {who}: new — {fact}",
|
|
405
|
+
"ui.sync.fact.changed": "{n}. {who}: {kind} changed — {was} → {now}",
|
|
406
|
+
"ui.sync.fact.differs": "{n}. {who}: {kind} differs — the research {was}, the file {now}",
|
|
407
|
+
"ui.sync.fact.gone": "{n}. {who}: no longer in it — {fact}",
|
|
408
|
+
"ui.sync.name.new": "{n}. {who}: another name — {text}",
|
|
409
|
+
"ui.sync.sex.changed": "{n}. {who}: sex {text}",
|
|
410
|
+
"ui.sync.note.new": "{n}. {who}: a note — “{text}”",
|
|
411
|
+
"ui.sync.family.new": "{n}. a new family: {who}{kids}",
|
|
412
|
+
"ui.sync.child.new": "{n}. {who}: a child — {child}",
|
|
413
|
+
"ui.sync.child.gone": "{n}. {who}: no longer their child — {child}",
|
|
414
|
+
"ui.sync.person.gone": "{n}. no longer in it: {name}",
|
|
415
|
+
"ui.sync.do.add": "added as a lead",
|
|
416
|
+
"ui.sync.do.correct": "corrects the research's lead",
|
|
417
|
+
"ui.sync.do.conflict": "a conflict for you to decide (the research has it from a record)",
|
|
418
|
+
"ui.sync.do.user": "your edit wins (the record's fact withdrawn with the reason)",
|
|
419
|
+
"ui.sync.do.pick": "only when you pick it (--only)",
|
|
420
|
+
"ui.sync.do.report": "nothing changes — tell the agent what you meant",
|
|
421
|
+
"ui.sync.edits": "Your edits to facts a record proves: {mode} (strom config set sync.edits conflict|user)",
|
|
422
|
+
"ui.sync.mode.conflict": "a conflict for you to decide",
|
|
423
|
+
"ui.sync.mode.user": "your edit wins",
|
|
424
|
+
"ui.sync.next": "To write it: strom sync {file} --apply · only some: --apply --only 1,3",
|
|
425
|
+
"ui.sync.done": "{n} change(s) from {file} written ({input}) — to take them back: strom sync undo {input}",
|
|
426
|
+
"ui.sync.undone": "Sync {input} taken back: {n} step(s).",
|
|
427
|
+
"ui.sync.wasundone": "Sync {input} was taken back already.",
|
|
428
|
+
"ui.sync.bridge.large": "the tree is too large to send — export it as GEDCOM and load it in the research (strom sync <file>)",
|
|
429
|
+
"ui.sync.bridge.other": "this tree is of another research",
|
|
430
|
+
"ui.sync.bridge.foreign": "this does not look like the research's family tree — export it and load it in the research as another family tree",
|
|
431
|
+
"ui.sync.bridge.empty": "the tree came empty or unreadable",
|
|
432
|
+
"ui.sync.wait": "The Strom app opens with this research — choose which tree to send, and confirm there. Waiting for it (at most {min} min; Ctrl+C stops) …",
|
|
433
|
+
"ui.sync.waited": "The Strom app did not send the tree within {min} min — try again: strom sync --app, or export it as GEDCOM: strom sync <file>",
|
|
434
|
+
"ui.sync.got": "The Strom app sent the tree.",
|
|
435
|
+
"ui.sync.nothing.unchanged": "The Strom app sends nothing: the tree has not changed since it was last loaded from the research.",
|
|
436
|
+
"ui.sync.nothing.cancelled": "Nothing was sent from the Strom app (cancelled there).",
|
|
437
|
+
"ui.sync.nothing.no-tree": "The Strom app has no tree of this research — open it there first (the Strom app item of the menu), or export the tree and load the file.",
|
|
438
|
+
"ui.sync.nosend": "This copy of the Strom app cannot send the tree yet (it comes with a newer version) — export it in the app as GEDCOM and load it: strom sync <file>",
|
|
439
|
+
"ui.sync.from": "From where?",
|
|
440
|
+
"ui.sync.from.app": "Straight from the Strom app (it sends the tree itself)",
|
|
441
|
+
"ui.sync.from.file": "From a file (.ged, .json)",
|
|
442
|
+
"ui.sync.again": "The research took this file in already ({input}) — once more: --again",
|
|
443
|
+
"ui.sync.source.app": "Edits in the Strom app ({file}, {day})",
|
|
444
|
+
"ui.sync.source.file": "Family tree {file} ({day})",
|
|
445
|
+
"ui.review.living": "{who}: most likely alive (born less than {years} years ago, no death recorded). The family knows more than the registers, and those of recent years are mostly closed. Review all the same?",
|
|
446
|
+
"ui.review.living.no": "Nothing added.",
|
|
447
|
+
"ui.review.unproven.none": "Everyone in the tree rests on a record of their own — or strom has nothing more to propose for those who do not (strom person list --unproven).",
|
|
448
|
+
"ui.review.unproven.left": "{n} more without a record of their own: strom review --unproven again takes the next ones once these are done",
|
|
449
|
+
"ui.review.unproven.left.human": "{n} more people wait without a record of their own — the next ones come when these are done.",
|
|
383
450
|
"ui.review.would": " would add: {level} · {what}",
|
|
384
451
|
"ui.review.dry": "(dry run — nothing written)",
|
|
385
452
|
"ui.review.covered": " {name}: already covered by {task} ({state}) — {what}",
|
|
@@ -439,10 +506,19 @@ export const UI = {
|
|
|
439
506
|
"ui.consent.remote": "Start the Claude Code sessions strom opens with Remote Control, so that you can follow and steer them from claude.ai or the Claude app on your phone?",
|
|
440
507
|
"ui.consent.hook.on": "Let the hook “{name}” be told of everything saved into your researches? It is a program on this computer and does with it what it was written to do.",
|
|
441
508
|
"ui.consent.hook.off": "Stop telling the hook “{name}” what is saved?",
|
|
442
|
-
"ui.menu.more": "Add to the research: material, a new direction, one person reviewed",
|
|
509
|
+
"ui.menu.more": "Add to the research: material, a new direction, one person reviewed, another family tree",
|
|
510
|
+
"ui.menu.more.app": "Add to the research: material, a new direction, one person reviewed, edits from the Strom app",
|
|
443
511
|
"ui.more.title": "Add to the research",
|
|
444
512
|
"ui.more.intake": "Material from the family: documents, photos, a family tree from another program, what you know",
|
|
445
513
|
"ui.more.research": "A new direction: the ancestors or descendants of someone else, or one question",
|
|
514
|
+
"ui.more.sync": "Take in edits from the Strom app or another family tree (.ged, .json)",
|
|
515
|
+
"ui.more.sync.noapp": "Take in another family tree's changes (.ged, .json)",
|
|
516
|
+
"ui.sync.path.noapp": "Drag the family tree file (.ged or .json) here, or type its path (Enter or 0 goes back)",
|
|
517
|
+
"ui.sync.path": "Drag the file exported from the Strom app (.ged or .json) here, or type its path (Enter or 0 goes back)",
|
|
518
|
+
"ui.sync.how": "Write it into the research?",
|
|
519
|
+
"ui.sync.all": "Yes, everything as shown",
|
|
520
|
+
"ui.sync.some": "Only the ones I pick",
|
|
521
|
+
"ui.sync.app": "Open the research in the Strom app again (the Strom app item of the menu): it asks whether to update the tree — then it has your edits as the research took them.",
|
|
446
522
|
"ui.intake.what": "What do you bring?",
|
|
447
523
|
"ui.intake.files": "Files or a folder from this computer",
|
|
448
524
|
"ui.intake.text": "What you know — names, dates, places, family memories",
|
package/dist/commands/browse.js
CHANGED
|
@@ -14,8 +14,9 @@ import { lacksImages } from "../core/queue.js";
|
|
|
14
14
|
import { rankedQueue, waitingForUser } from "./tasks.js";
|
|
15
15
|
/** Most rows a section of `recent` lists; the rest is counted. */
|
|
16
16
|
const RECENT_ROWS = 15;
|
|
17
|
+
/** A person as the user reads them, with the ID to name them by later ("strom review P0012", the agent). */
|
|
17
18
|
function whoText(w) {
|
|
18
|
-
return w.years ? `${w.name} (${w.years})` : w.name
|
|
19
|
+
return `${w.years ? `${w.name} (${w.years})` : w.name} [${w.id}]`;
|
|
19
20
|
}
|
|
20
21
|
/** The person asked for, else the main person of the tree. */
|
|
21
22
|
function personOrMain(tree, ref) {
|
|
@@ -72,7 +73,7 @@ export function humanTask(tree, text, lang) {
|
|
|
72
73
|
if (r.type === "session")
|
|
73
74
|
return ui(lang, "ui.plan.session", { day: humanDay(r.started, lang) });
|
|
74
75
|
if (r.type === "person")
|
|
75
|
-
return displayName(r)
|
|
76
|
+
return `${displayName(r)} [${r.id}]`;
|
|
76
77
|
if (r.type === "family")
|
|
77
78
|
return r.partners.map((x) => tree.get(x)).filter((x) => !!x).map(displayName).join(" & ") || id;
|
|
78
79
|
return r.title;
|
|
@@ -192,7 +193,7 @@ register({
|
|
|
192
193
|
` ${t.state === "doing" ? ui(lang, "ui.plan.now") : `${++n}.`}`,
|
|
193
194
|
`${truncate(humanTask(tree, t.what, lang), 110)}${lacksImages(tree, t) ? ui(lang, "ui.plan.images") : ""}`,
|
|
194
195
|
]);
|
|
195
|
-
const storyNames = [...new Set(stories.map((t) => tree.get(t.subject[0] ?? "")).filter((p) => !!p).map(displayName))];
|
|
196
|
+
const storyNames = [...new Set(stories.map((t) => tree.get(t.subject[0] ?? "")).filter((p) => !!p).map((p) => `${displayName(p)} [${p.id}]`))];
|
|
196
197
|
const out = [ui(lang, "ui.plan.title", { name: tree.config.name })];
|
|
197
198
|
if (!queue.length)
|
|
198
199
|
out.push(` ${ui(lang, "ui.plan.none")}`);
|
package/dist/commands/checks.js
CHANGED
|
@@ -5,6 +5,7 @@ import { register } from "../cli/registry.js";
|
|
|
5
5
|
import { lines, table } from "../cli/format.js";
|
|
6
6
|
import { check } from "../core/check.js";
|
|
7
7
|
import { guard } from "../core/guard.js";
|
|
8
|
+
import { unprovenPeople } from "../core/review.js";
|
|
8
9
|
import { verifyFast, verifyFull } from "../core/integrity.js";
|
|
9
10
|
import { createKey, verifyCommitSeal } from "../core/seal.js";
|
|
10
11
|
import { NeedsConsentError, StromError } from "../core/errors.js";
|
|
@@ -23,7 +24,12 @@ register({
|
|
|
23
24
|
tree: true,
|
|
24
25
|
run(ctx) {
|
|
25
26
|
const tree = ctx.tree();
|
|
26
|
-
|
|
27
|
+
const out = report([...verifyFull(tree).findings, ...check(tree), ...guard(tree)], "ok — data consistent, sealed, nothing lost");
|
|
28
|
+
// not an error — research to do: who rests on no record of their own
|
|
29
|
+
const unproven = unprovenPeople(tree).filter((u) => !u.living).length;
|
|
30
|
+
if (!unproven)
|
|
31
|
+
return out;
|
|
32
|
+
return { ...out, text: lines(out.text, `note: ${unproven} people rest on no record of their own → strom person list --unproven`), data: { ...out.data, unproven } };
|
|
27
33
|
},
|
|
28
34
|
}, {
|
|
29
35
|
path: ["verify"],
|
package/dist/commands/guide.js
CHANGED
|
@@ -32,6 +32,12 @@ HARD RULES
|
|
|
32
32
|
the research needs has none yet: build one first (strom connector new,
|
|
33
33
|
its DISCOVERY.md) and tell the user in a sentence. The user saves images
|
|
34
34
|
by hand only where the archive does not allow automation.
|
|
35
|
+
8. Old handwriting is read by your strongest model — you, or a subagent on
|
|
36
|
+
that same model; never a faster, cheaper or "quick" pass. A name or place
|
|
37
|
+
the next search depends on is read again on a crop at full resolution
|
|
38
|
+
(strom media view … --crop) before it is recorded; unsure stays unsure.
|
|
39
|
+
9. A find is told to the user only once it is recorded (the source with its
|
|
40
|
+
transcript, the facts citing it), and told from strom: strom person card.
|
|
35
41
|
${language}
|
|
36
42
|
|
|
37
43
|
GETTING STARTED (once)
|
|
@@ -42,14 +48,20 @@ GETTING STARTED (once)
|
|
|
42
48
|
strom intake --text "<what the user told you, in their words>"
|
|
43
49
|
strom intake <file or folder> documents, photos, a GEDCOM or Strom tree
|
|
44
50
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
THE WORK, STEP BY STEP (every agent — also one strom did not start: a
|
|
52
|
+
conversation opened anywhere, a bot on its own server)
|
|
53
|
+
strom where things stand and the next step — run it before each step
|
|
54
|
+
strom session start the next task of the queue (or: strom session start T0003, the one
|
|
55
|
+
the user chose): its brief — the task, what is known, the method
|
|
56
|
+
… work on that task only; record every finding at once …
|
|
48
57
|
strom task done T0001 --result "…" a complete negative search is a result
|
|
49
|
-
strom session close --summary "…" --next "…" (not finished: --continue)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
strom session close --summary "…" --next "…" (not finished: --continue) — it proposes the next tasks
|
|
59
|
+
then tell the user what was found (strom person card …) and what comes next (strom plan), and ask
|
|
60
|
+
before the next session. The next step is strom's queue: what the user wants instead becomes a
|
|
61
|
+
task first (strom task add …, or strom research new …) — never search outside a session and its task.
|
|
62
|
+
In a conversation the next task is best begun in a fresh context (Claude Code: /clear) — suggest
|
|
63
|
+
it to the user; many tasks waiting: the agent working on its own (strom run, the user starts it
|
|
64
|
+
from strom's menu), every task in a fresh session
|
|
53
65
|
|
|
54
66
|
RECORDING
|
|
55
67
|
A record (register entry, certificate) — the source first, then its facts:
|
|
@@ -124,6 +136,15 @@ ONE PERSON, LOOKED AT AGAIN (the user asks you to check or complete someone; a n
|
|
|
124
136
|
diary say of them outside their data, entries whose images are here but were not read whole, facts
|
|
125
137
|
resting on one reading, conflicts left open (--reread: a second reading with the model the user reads
|
|
126
138
|
with now). Each task's brief lists its items. Tell the user how many and what they cost; they decide.
|
|
139
|
+
Several at once: strom review P… P… (one review). The people no record of their own proves (strom check
|
|
140
|
+
counts them): strom person list --unproven (how each stands; the living apart — no tasks for them);
|
|
141
|
+
strom review --unproven proposes their work a batch at a time — again for the next batch, nothing twice.
|
|
142
|
+
THE STROM APP'S EDITS, ANOTHER FAMILY TREE (the user changed the tree in the Strom app, or brings a .ged/.json)
|
|
143
|
+
strom sync <file> shows what it would take: what the file adds, what the user changed since the research
|
|
144
|
+
gave it (_STROM_HEAD), what is no longer in it. Show the user the list; write it on their yes (--apply, some:
|
|
145
|
+
--only 1,3). Additions become leads; a change to a fact a record proves becomes a conflict (sync.edits: the
|
|
146
|
+
user's choice). A file of another research, or of few of our people, is refused; strom sync undo I… takes a
|
|
147
|
+
sync back. After it: the user opens the research in the Strom app again to have the tree updated.
|
|
127
148
|
STORIES OF THE ANCESTORS (the setting stories — on by default; strom shows it)
|
|
128
149
|
Once records tell a person's life (a baptism and more facts from records), strom proposes a narrate
|
|
129
150
|
task: write the story for the family book — plain words, the research language, every statement on a
|
package/dist/commands/index.js
CHANGED
package/dist/commands/intake.js
CHANGED
|
@@ -149,6 +149,9 @@ register({
|
|
|
149
149
|
report.push(` ${input.id} tree: ${imported.persons} persons, ${imported.families} families, ${imported.events} facts as leads${imported.matched ? `, ${imported.matched} matched to existing` : ""}${imported.problems.length ? ` (${imported.problems.length} unreadable lines)` : ""}`);
|
|
150
150
|
if (imported.extended.length)
|
|
151
151
|
report.push(` added to what we have (leads — check them): ${imported.extended.slice(0, 6).join(" · ")}${imported.extended.length > 6 ? " …" : ""}`);
|
|
152
|
+
// the research's own tree coming back: sync sees what was changed in it too, not only what was added
|
|
153
|
+
if (imported.matched && imported.matched >= imported.persons)
|
|
154
|
+
report.push(` it is this research's tree coming back: strom sync "${ctx.display(file)}" shows what was changed in it too`);
|
|
152
155
|
const system = `gedcom:${sha.slice(0, 12)}`;
|
|
153
156
|
const dupes = likelyDuplicates(tree, tree.list("person").filter((p) => p.refs?.some((r) => r.system === system)).map((p) => p.id));
|
|
154
157
|
if (dupes.length)
|
package/dist/commands/people.js
CHANGED
|
@@ -9,6 +9,7 @@ import { INFORMATION } from "../core/evidence.js";
|
|
|
9
9
|
import { EVENT_KINDS, NAME_KINDS } from "../core/model.js";
|
|
10
10
|
import { ancestorGenerations, displayName, familiesAsChild, familiesAsPartner, findPersons, formatName, label, lifespan, relationTo, resolvePerson, } from "../core/people.js";
|
|
11
11
|
import { resolveResearch } from "./research.js";
|
|
12
|
+
import { DEATH_AFTER_YEARS, unprovenPeople } from "../core/review.js";
|
|
12
13
|
import { foldText } from "../core/text.js";
|
|
13
14
|
function list(v) {
|
|
14
15
|
if (v === undefined)
|
|
@@ -158,9 +159,10 @@ register({
|
|
|
158
159
|
options: [
|
|
159
160
|
{ name: "research", type: "string", value: "<G…>", description: "only people in scope of this research, with generations" },
|
|
160
161
|
{ name: "generation", type: "string", value: "<n>", description: "only this generation (with --research)" },
|
|
162
|
+
{ name: "unproven", type: "boolean", description: "only people no record of their own proves: how each stands, the records naming them, open tasks" },
|
|
161
163
|
{ name: "full", type: "boolean", description: "--json: whole records instead of one row each" },
|
|
162
164
|
],
|
|
163
|
-
examples: ["strom person list", "strom person list visek", "strom person list --research G0001 --generation 3"],
|
|
165
|
+
examples: ["strom person list", "strom person list visek", "strom person list --research G0001 --generation 3", "strom person list --unproven"],
|
|
164
166
|
run(ctx, { args, opts }) {
|
|
165
167
|
const tree = ctx.tree();
|
|
166
168
|
let people = tree.list("person").filter((p) => !p.retracted);
|
|
@@ -175,6 +177,8 @@ register({
|
|
|
175
177
|
people = people.filter((p) => gens.get(p.id) === Number(opts.generation));
|
|
176
178
|
people.sort((a, b) => gens.get(a.id) - gens.get(b.id) || a.id.localeCompare(b.id));
|
|
177
179
|
}
|
|
180
|
+
if (opts.unproven)
|
|
181
|
+
return unprovenList(ctx, tree, people, gens);
|
|
178
182
|
const page = paginate(people, ctx.limit, ctx.page);
|
|
179
183
|
const rows = page.items.map((p) => [
|
|
180
184
|
p.id,
|
|
@@ -662,3 +666,56 @@ register({
|
|
|
662
666
|
return { text: written(tree), data: { event: e } };
|
|
663
667
|
},
|
|
664
668
|
});
|
|
669
|
+
const UNPROVEN_KIND = { named: "named in a record", leads: "leads only", none: "no record" };
|
|
670
|
+
/** The people no record of their own proves: how each stands, the records that name them, the tasks already on them. */
|
|
671
|
+
function unprovenList(ctx, tree, people, gens) {
|
|
672
|
+
const among = new Set(people.map((p) => p.id));
|
|
673
|
+
const found = unprovenPeople(tree).filter((u) => among.has(u.person.id));
|
|
674
|
+
// the living come apart: the family's to tell, no research of the registers
|
|
675
|
+
const all = found.filter((u) => !u.living);
|
|
676
|
+
const living = found.filter((u) => u.living);
|
|
677
|
+
const open = tree.list("task").filter((t) => ["open", "doing", "parked", "waiting"].includes(t.state));
|
|
678
|
+
const tasksOf = (id) => open.filter((t) => t.subject.includes(id)).map((t) => t.id);
|
|
679
|
+
const counts = { named: 0, leads: 0, none: 0 };
|
|
680
|
+
for (const u of all)
|
|
681
|
+
counts[u.kind]++;
|
|
682
|
+
const page = paginate(all, ctx.limit, ctx.page);
|
|
683
|
+
const rows = page.items.map((u) => {
|
|
684
|
+
const tasks = tasksOf(u.person.id);
|
|
685
|
+
return [
|
|
686
|
+
u.person.id,
|
|
687
|
+
displayName(u.person),
|
|
688
|
+
lifespan(u.person),
|
|
689
|
+
gens ? `G${gens.get(u.person.id)}` : "",
|
|
690
|
+
UNPROVEN_KIND[u.kind] + (u.sources.length ? `: ${u.sources.slice(0, 3).join(" ")}${u.sources.length > 3 ? " …" : ""}` : ""),
|
|
691
|
+
u.kind === "none" && !u.person.notes.length ? "no note where from" : "",
|
|
692
|
+
tasks.length ? `tasks ${tasks.slice(0, 2).join(" ")}${tasks.length > 2 ? " …" : ""}` : "",
|
|
693
|
+
];
|
|
694
|
+
});
|
|
695
|
+
const livingLine = living.length
|
|
696
|
+
? `living, most likely (born less than ${DEATH_AFTER_YEARS} years ago, no death recorded) — not counted, no tasks: ${living.map((u) => `${u.person.id} ${displayName(u.person)}`).join(" · ")}`
|
|
697
|
+
: undefined;
|
|
698
|
+
const text = !all.length
|
|
699
|
+
? lines("everyone rests on a record of their own", livingLine)
|
|
700
|
+
: lines(`${all.length} without a record of their own — named in a record: ${counts.named} · leads only: ${counts.leads} · no record: ${counts.none}`, table(rows), moreLine(page, "strom person list --unproven"), livingLine, "", "tasks for them: strom review --unproven (a batch at a time) · strom review P… P… (these) · strom review P… (one)");
|
|
701
|
+
return {
|
|
702
|
+
text,
|
|
703
|
+
data: {
|
|
704
|
+
total: page.total,
|
|
705
|
+
page: page.page,
|
|
706
|
+
pages: page.pages,
|
|
707
|
+
counts,
|
|
708
|
+
living: living.map((u) => u.person.id),
|
|
709
|
+
persons: page.items.map((u) => ({
|
|
710
|
+
id: u.person.id,
|
|
711
|
+
name: displayName(u.person),
|
|
712
|
+
lifespan: lifespan(u.person),
|
|
713
|
+
kind: u.kind,
|
|
714
|
+
sources: u.sources,
|
|
715
|
+
notes: u.person.notes.length,
|
|
716
|
+
tasks: tasksOf(u.person.id),
|
|
717
|
+
...(gens ? { generation: gens.get(u.person.id) } : {}),
|
|
718
|
+
})),
|
|
719
|
+
},
|
|
720
|
+
};
|
|
721
|
+
}
|
|
@@ -4,6 +4,7 @@ import { lines, table } from "../cli/format.js";
|
|
|
4
4
|
import { addPerson, addResearch } from "../core/actions.js";
|
|
5
5
|
import { applyFrontier, researchProposals } from "./session.js";
|
|
6
6
|
import { frontier } from "../core/frontier.js";
|
|
7
|
+
import { DEATH_AFTER_YEARS, livingBorn, unprovenPeople } from "../core/review.js";
|
|
7
8
|
import { parkedWhy } from "./tasks.js";
|
|
8
9
|
import { ui } from "../cli/ui.js";
|
|
9
10
|
import { phrase } from "../core/phrases.js";
|
|
@@ -145,31 +146,57 @@ register({
|
|
|
145
146
|
};
|
|
146
147
|
},
|
|
147
148
|
});
|
|
149
|
+
/** People without a record in one review at a time. */
|
|
150
|
+
export const UNPROVEN_BATCH = 10;
|
|
148
151
|
register({
|
|
149
152
|
path: ["review"],
|
|
150
|
-
summary: "Review
|
|
153
|
+
summary: "Review a person, several, or those no record proves: what the tree says of them elsewhere, entries to read, facts to check — as tasks",
|
|
151
154
|
group: "research",
|
|
152
155
|
tree: true,
|
|
153
156
|
writes: true,
|
|
154
|
-
description: "Sends the research straight to one person
|
|
155
|
-
"
|
|
156
|
-
"
|
|
157
|
-
"
|
|
158
|
-
"
|
|
159
|
-
"
|
|
160
|
-
|
|
157
|
+
description: "Sends the research straight to one person — or to several named at once (one review for them all), or with\n" +
|
|
158
|
+
"--unproven to the people no record of their own proves (strom person list --unproven), a batch at a time: run\n" +
|
|
159
|
+
"it again for the next batch; who is proved leaves the review. strom looks at what is recorded and proposes\n" +
|
|
160
|
+
"the work, at most one task of each kind for a person: what records, notes and the diary say of them outside\n" +
|
|
161
|
+
"their data; entries whose images are here but were not read whole (transcript, godparents, witnesses,\n" +
|
|
162
|
+
"house); facts resting on one reading; conflicts left open; with --reread, a second reading of what only\n" +
|
|
163
|
+
"another model read — after the agent or the model changed. Birth, parents and the story come as in any\n" +
|
|
164
|
+
"research. Run again later: what a task took up, or a task open elsewhere covers, is not proposed again.\n" +
|
|
165
|
+
"Nothing is deleted; corrections go through strom with their reasons.",
|
|
166
|
+
args: [{ name: "person", description: "whom (ID or name); several: one review for them all; none with --unproven", variadic: true }],
|
|
161
167
|
options: [
|
|
162
|
-
{ name: "scope", type: "string", value: "<scope>", description: "person (default), family (with partners and children), line (with the ancestors)" },
|
|
168
|
+
{ name: "scope", type: "string", value: "<scope>", description: "one person: person (default), family (with partners and children), line (with the ancestors)" },
|
|
169
|
+
{ name: "unproven", type: "boolean", description: "the people no record of their own proves (strom person list --unproven), a batch at a time" },
|
|
170
|
+
{ name: "max", type: "string", value: "<n>", description: `with --unproven: at most so many people in the batch (default ${UNPROVEN_BATCH})` },
|
|
171
|
+
{ name: "living", type: "boolean", description: "review someone most likely alive (born less than 100 years ago) all the same — the user asked for it" },
|
|
163
172
|
{ name: "reread", type: "boolean", description: "also a second reading of what only another model read (the model you read with now: model.vision)" },
|
|
164
173
|
{ name: "model", type: "string", value: "<model>", description: "with --reread: this model instead of model.vision" },
|
|
165
174
|
],
|
|
166
|
-
examples: [
|
|
167
|
-
|
|
175
|
+
examples: [
|
|
176
|
+
"strom review P0001",
|
|
177
|
+
'strom review "Josef Novák" --scope family',
|
|
178
|
+
"strom review P0001 P0002",
|
|
179
|
+
"strom review --unproven",
|
|
180
|
+
"strom review P0001 --reread",
|
|
181
|
+
"strom review P0001 --dry-run",
|
|
182
|
+
],
|
|
183
|
+
async run(ctx, { args, opts }) {
|
|
168
184
|
const tree = ctx.tree();
|
|
169
|
-
const
|
|
185
|
+
const unproven = !!opts.unproven;
|
|
186
|
+
if (unproven && args.length)
|
|
187
|
+
throw new UsageError("--unproven reviews the people without a record — no person with it", { hint: "strom review --unproven · or strom review <person>" });
|
|
188
|
+
if (!unproven && !args.length)
|
|
189
|
+
throw new UsageError("whom? a person, several, or --unproven", { hint: "strom review P0001 · strom review P0001 P0002 · strom review --unproven" });
|
|
190
|
+
if (!unproven && opts.max !== undefined)
|
|
191
|
+
throw new UsageError("--max goes with --unproven");
|
|
192
|
+
if ((unproven || args.length > 1) && opts.scope !== undefined)
|
|
193
|
+
throw new UsageError("--scope is for one person's review");
|
|
170
194
|
const scope = opts.scope ?? "person";
|
|
171
195
|
if (!REVIEW_SCOPES.includes(scope))
|
|
172
196
|
throw new UsageError(`--scope must be one of ${REVIEW_SCOPES.join(", ")}`);
|
|
197
|
+
const max = int(opts.max, "max") ?? UNPROVEN_BATCH;
|
|
198
|
+
if (max < 1)
|
|
199
|
+
throw new UsageError("--max must be 1 or more");
|
|
173
200
|
let reread;
|
|
174
201
|
if (opts.reread) {
|
|
175
202
|
reread = opts.model ?? ctx.settings.models(ctx.settings.agent(tree.config).value, tree.config).vision;
|
|
@@ -178,26 +205,61 @@ register({
|
|
|
178
205
|
}
|
|
179
206
|
else if (opts.model)
|
|
180
207
|
throw new UsageError("--model goes with --reread");
|
|
181
|
-
const review = { scope: scope, ...(reread ? { reread } : {}) };
|
|
182
208
|
const lang = tree.lang;
|
|
183
|
-
|
|
184
|
-
|
|
209
|
+
const chosen = [...new Map(args.map((a) => resolvePerson(tree, a)).map((x) => [x.id, x])).values()];
|
|
210
|
+
// the living are the family's to tell: reviewed only when the person says so
|
|
211
|
+
const living = chosen.map((x) => ({ x, born: livingBorn(tree, x) })).filter((l) => l.born !== undefined);
|
|
212
|
+
if (living.length && !opts.living && !ctx.yes && !tree.dryRun) {
|
|
213
|
+
const who = living.map((l) => `${label(l.x)}`).join(", ");
|
|
214
|
+
if (ctx.interactive && !isAgent(ctx.env)) {
|
|
215
|
+
if (!(await ctx.confirm(ui(lang, "ui.review.living", { who, years: DEATH_AFTER_YEARS }), false)))
|
|
216
|
+
return { text: ui(lang, "ui.review.living.no"), data: { research: null, created: [], planned: [], open: [], living: living.map((l) => l.x.id) } };
|
|
217
|
+
}
|
|
218
|
+
else
|
|
219
|
+
throw new UsageError(`most likely alive (born less than ${DEATH_AFTER_YEARS} years ago, no death recorded): ${who}`, {
|
|
220
|
+
hint: `ask the user whether to review them all the same; if yes: strom review ${args.join(" ")} --living`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
const p = chosen.length === 1 ? chosen[0] : undefined;
|
|
224
|
+
const several = chosen.length > 1;
|
|
225
|
+
const sameSet = (ids) => !!ids && ids.length === chosen.length && chosen.every((x) => ids.includes(x.id));
|
|
226
|
+
// one review research per person, per set of people, one of the people without a record: again later, it goes on where it was
|
|
227
|
+
const existing = tree
|
|
228
|
+
.list("research")
|
|
229
|
+
.find((r) => r.direction === "person" &&
|
|
230
|
+
r.review &&
|
|
231
|
+
(unproven ? !!r.review.unproven : several ? !r.review.unproven && sameSet(r.review.people) : !r.review.people && r.focus === p.id));
|
|
232
|
+
let review = { scope: scope, ...(reread ? { reread } : {}) };
|
|
233
|
+
let left = 0;
|
|
234
|
+
if (unproven) {
|
|
235
|
+
const batch = unprovenBatch(tree, existing, max, review);
|
|
236
|
+
if (!batch.people.length)
|
|
237
|
+
return { text: ui(lang, "ui.review.unproven.none"), data: { research: existing ?? null, created: [], planned: [], open: [] } };
|
|
238
|
+
review = { ...review, people: batch.people, unproven: true };
|
|
239
|
+
left = batch.left;
|
|
240
|
+
}
|
|
241
|
+
else if (several)
|
|
242
|
+
review = { ...review, people: chosen.map((x) => x.id) };
|
|
243
|
+
const names = chosen.map((x) => displayName(x));
|
|
244
|
+
const name = unproven ? phrase(lang, "review.unproven.research") : names.length > 3 ? `${names.slice(0, 3).join(", ")} +${names.length - 3}` : names.join(", ");
|
|
245
|
+
const focus = p?.id ?? review.people[0];
|
|
185
246
|
// What it would propose, asked before anything is written: with nothing to do, no research is made for it.
|
|
186
247
|
const draft = existing
|
|
187
|
-
? { ...existing, state: "active", review }
|
|
188
|
-
: { id: "G?", type: "research", name: phrase(lang, "review.research", { name
|
|
248
|
+
? { ...existing, focus, state: "active", review }
|
|
249
|
+
: { id: "G?", type: "research", name: unproven ? name : phrase(lang, "review.research", { name }), focus, direction: "person", state: "active", review, notes: [] };
|
|
189
250
|
const planned = researchProposals(tree, draft).map((x) => x.proposal);
|
|
190
251
|
let research = draft;
|
|
191
252
|
let tasks = [];
|
|
253
|
+
const what = unproven ? `${review.people.length} without a record of their own` : several ? review.people.join(" ") : scope;
|
|
192
254
|
if (planned.length && !tree.dryRun) {
|
|
193
255
|
if (existing)
|
|
194
|
-
research = update(tree, existing.id, "research", (r) => ({ ...r, state: "active", review }), {
|
|
256
|
+
research = update(tree, existing.id, "research", (r) => ({ ...r, focus, state: "active", review }), {
|
|
195
257
|
op: "research.edit",
|
|
196
|
-
summary: `${existing.id} review again: ${
|
|
258
|
+
summary: `${existing.id} review again: ${what}${reread ? `, second reading with ${reread}` : ""}`,
|
|
197
259
|
});
|
|
198
260
|
else {
|
|
199
|
-
const r = addResearch(tree, { name: draft.name, focus
|
|
200
|
-
research = update(tree, r.id, "research", (x) => ({ ...x, review }), { op: "research.edit", summary: `${r.id} review: ${
|
|
261
|
+
const r = addResearch(tree, { name: draft.name, focus, direction: "person" });
|
|
262
|
+
research = update(tree, r.id, "research", (x) => ({ ...x, review }), { op: "research.edit", summary: `${r.id} review: ${what}${reread ? `, second reading with ${reread}` : ""}` });
|
|
201
263
|
}
|
|
202
264
|
tasks = applyFrontier(tree, research)
|
|
203
265
|
.map((id) => tree.get(id))
|
|
@@ -219,12 +281,39 @@ register({
|
|
|
219
281
|
// Nothing new: why — the work that covers them already (a parked task says how to go on), a search done in vain.
|
|
220
282
|
const why = list.length ? [] : coveredLines(tree, draft, lang, human);
|
|
221
283
|
const inReview = existing ?? (planned.length && !tree.dryRun ? research : undefined);
|
|
222
|
-
const text = lines(...(human ? [] : tree.written.map((o) => o.summary)), tree.dryRun ? ui(lang, "ui.review.dry") : undefined, (tree.written.length && !human) || tree.dryRun ? "" : undefined, list.length ? ui(lang, "ui.review.new", { name: label(p), n: list.length }) : ui(lang, "ui.review.nothing", { name: label(p) }), ...list, ...why, open.length || tasks.length
|
|
284
|
+
const text = lines(...(human ? [] : tree.written.map((o) => o.summary)), tree.dryRun ? ui(lang, "ui.review.dry") : undefined, (tree.written.length && !human) || tree.dryRun ? "" : undefined, list.length ? ui(lang, "ui.review.new", { name: p ? label(p) : name, n: list.length }) : ui(lang, "ui.review.nothing", { name: p ? label(p) : name }), ...list, ...why, open.length || tasks.length
|
|
223
285
|
? ui(lang, "ui.review.open", { n: open.length || tasks.length, research: inReview.id }) + (avg !== undefined ? ui(lang, "ui.review.cost", { cost: avg.toFixed(2) }) : "")
|
|
224
|
-
: undefined, (open.length || tasks.length) && inReview ? ui(lang, human ? "ui.review.next.human" : "ui.review.next", { research: inReview.id }) : undefined);
|
|
225
|
-
return {
|
|
286
|
+
: undefined, (open.length || tasks.length) && inReview ? ui(lang, human ? "ui.review.next.human" : "ui.review.next", { research: inReview.id }) : undefined, left ? ui(lang, human ? "ui.review.unproven.left.human" : "ui.review.unproven.left", { n: left }) : undefined);
|
|
287
|
+
return {
|
|
288
|
+
text,
|
|
289
|
+
data: { research: inReview ?? null, created: tasks, planned, open: open.map((t) => t.id), ...(unproven ? { people: review.people, left } : {}), ...(why.length ? { why } : {}), ...(avg !== undefined ? { avgCostUsd: avg } : {}) },
|
|
290
|
+
};
|
|
226
291
|
},
|
|
227
292
|
});
|
|
293
|
+
const OPEN_TASK = new Set(["open", "doing", "parked", "waiting"]);
|
|
294
|
+
/**
|
|
295
|
+
* The next batch of people without a record of their own: those whose tasks of the review are still open stay;
|
|
296
|
+
* then the ones strom has work for — a record naming them first (the quickest to prove), then leads, then
|
|
297
|
+
* nothing — those never in a batch before those tried already. How many more wait besides.
|
|
298
|
+
*/
|
|
299
|
+
function unprovenBatch(tree, existing, max, review) {
|
|
300
|
+
// the living are the family's to tell: no research of the registers for them
|
|
301
|
+
const all = unprovenPeople(tree).filter((u) => !u.living);
|
|
302
|
+
const tasks = existing ? tree.list("task").filter((t) => t.research === existing.id) : [];
|
|
303
|
+
const busy = (id) => tasks.some((t) => t.subject.includes(id) && OPEN_TASK.has(t.state));
|
|
304
|
+
const keep = all.filter((u) => busy(u.person.id)).map((u) => u.person.id);
|
|
305
|
+
const before = new Set(existing?.review?.people ?? []);
|
|
306
|
+
const order = { named: 0, leads: 1, none: 2 };
|
|
307
|
+
const rest = all
|
|
308
|
+
.filter((u) => !keep.includes(u.person.id))
|
|
309
|
+
.sort((a, b) => Number(before.has(a.person.id)) - Number(before.has(b.person.id)) || order[a.kind] - order[b.kind] || a.person.id.localeCompare(b.person.id));
|
|
310
|
+
// only those strom has work for: what it would propose for all of them, asked once
|
|
311
|
+
const draft = { ...(existing ?? { id: "G?", type: "research", name: "", direction: "person", state: "active", notes: [] }), review: { ...review, people: rest.map((u) => u.person.id), unproven: true } };
|
|
312
|
+
const planned = researchProposals(tree, { ...draft, focus: rest[0]?.person.id ?? "" }).map((x) => x.proposal);
|
|
313
|
+
const workable = rest.filter((u) => planned.some((x) => x.subject.includes(u.person.id))).map((u) => u.person.id);
|
|
314
|
+
const people = [...keep, ...workable].slice(0, Math.max(max, keep.length));
|
|
315
|
+
return { people, left: all.length - people.length };
|
|
316
|
+
}
|
|
228
317
|
/** Why a review found nothing new, person by person: the work that covers them already, or a search done in vain. */
|
|
229
318
|
function coveredLines(tree, research, lang, human = false) {
|
|
230
319
|
const out = [];
|