strom-research 1.0.0 → 1.1.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/README.md +13 -5
- package/assets/icon/NOTICE.md +26 -0
- package/assets/icon/favicon.svg +1 -0
- package/assets/icon/icon-256.png +0 -0
- package/assets/icon/icon-512.png +0 -0
- package/assets/icon/icon.ico +0 -0
- package/assets/icon/icon.svg +1 -0
- package/assets/lang/cs.json +184 -8
- package/assets/lang/de.json +184 -8
- package/assets/method/reading.md +4 -2
- package/assets/method/recording.md +10 -1
- package/assets/plugins/connectors/DISCOVERY.md +6 -2
- package/assets/plugins/connectors/README.md +27 -10
- package/dist/agents/files.js +27 -18
- package/dist/agents/global.js +15 -4
- package/dist/brief/brief.js +4 -0
- package/dist/cli/context.js +10 -4
- package/dist/cli/human.js +116 -0
- package/dist/cli/main.js +9 -0
- package/dist/cli/menu.js +254 -38
- package/dist/cli/ui.js +156 -7
- package/dist/cli/wizard.js +36 -9
- package/dist/commands/browse.js +243 -0
- package/dist/commands/clips.js +190 -0
- package/dist/commands/connectors.js +93 -28
- package/dist/commands/guide.js +28 -7
- package/dist/commands/index.js +3 -0
- package/dist/commands/media.js +42 -8
- package/dist/commands/meta.js +33 -9
- package/dist/commands/output.js +103 -13
- package/dist/commands/read.js +1 -1
- package/dist/commands/readers.js +62 -0
- package/dist/commands/research.js +62 -0
- package/dist/commands/session.js +76 -47
- package/dist/commands/setup.js +17 -10
- package/dist/commands/sources.js +88 -16
- package/dist/commands/start.js +57 -37
- package/dist/commands/tasks.js +43 -6
- package/dist/commands/transcripts.js +177 -0
- package/dist/core/chromium.js +60 -19
- package/dist/core/clipfinder.js +118 -0
- package/dist/core/config.js +42 -2
- package/dist/core/connector.js +56 -8
- package/dist/core/excerpt.js +219 -0
- package/dist/core/frontier.js +30 -5
- package/dist/core/git.js +1 -1
- package/dist/core/kin.js +90 -0
- package/dist/core/live.js +22 -13
- package/dist/core/media.js +37 -3
- package/dist/core/migrate.js +33 -0
- package/dist/core/model.js +7 -0
- package/dist/core/net.js +93 -19
- package/dist/core/overview.js +259 -0
- package/dist/core/phrases.js +27 -0
- package/dist/core/places.js +43 -0
- package/dist/core/queue.js +16 -3
- package/dist/core/review.js +369 -0
- package/dist/core/schema.js +4 -0
- package/dist/core/session.js +5 -1
- package/dist/core/shortcut.js +71 -11
- package/dist/core/stories.js +3 -4
- package/dist/core/stromapp.js +86 -25
- package/dist/core/transcriber.js +99 -0
- package/dist/core/tree.js +3 -1
- package/dist/core/uninstall.js +4 -2
- package/dist/core/update.js +48 -22
- package/dist/core/validate.js +3 -1
- package/dist/core/workers.js +13 -0
- package/dist/gedcom/export.js +58 -30
- package/dist/gedcom/lines.js +8 -0
- package/dist/gedcom/validate.js +2 -2
- package/dist/runners/claude.js +2 -0
- package/package.json +1 -1
package/dist/cli/wizard.js
CHANGED
|
@@ -13,7 +13,8 @@ import { agentsHere, DESKTOP_APPS } from "../core/apps.js";
|
|
|
13
13
|
import { PROFILES } from "../agents/profiles.js";
|
|
14
14
|
import { writeStored, PERMISSION_LEVELS } from "../core/config.js";
|
|
15
15
|
import { globalTargets, installGlobal } from "../agents/global.js";
|
|
16
|
-
import { noticeStromApp, researchUrl } from "../core/stromapp.js";
|
|
16
|
+
import { noticeStromApp, researchUrl, stromAppState, stromAppUrl } from "../core/stromapp.js";
|
|
17
|
+
import { openForUser } from "../core/open.js";
|
|
17
18
|
import { createShortcut } from "../core/shortcut.js";
|
|
18
19
|
import { desktopDir } from "../core/paths.js";
|
|
19
20
|
import { ensureShared } from "../commands/setup.js";
|
|
@@ -24,7 +25,7 @@ export function shortcutName(lang) {
|
|
|
24
25
|
function shortcutExists(ctx, lang) {
|
|
25
26
|
const dir = desktopDir(ctx.env);
|
|
26
27
|
const name = shortcutName(lang);
|
|
27
|
-
return [`${name}.command`, `${name}.cmd`, "strom-research.desktop"].some((f) => fs.existsSync(path.join(dir, f)));
|
|
28
|
+
return [`${name}.command`, `${name}.lnk`, `${name}.cmd`, "strom-research.desktop"].some((f) => fs.existsSync(path.join(dir, f)));
|
|
28
29
|
}
|
|
29
30
|
export async function setupWizard(ctx) {
|
|
30
31
|
const s = ctx.settings;
|
|
@@ -42,6 +43,8 @@ export async function setupWizard(ctx) {
|
|
|
42
43
|
break;
|
|
43
44
|
}
|
|
44
45
|
}
|
|
46
|
+
// Run again: each choice can be left as it is (0), the current one suggested.
|
|
47
|
+
const keep = first ? {} : { back: ui(lang, "ui.keep") };
|
|
45
48
|
// 2. Where the research lives.
|
|
46
49
|
const home = ctx.resolvePath(await ctx.ask(ui(lang, "ui.setup.home"), ctx.display(s.home()?.value ?? s.suggestedHome())));
|
|
47
50
|
cfg.lang = lang;
|
|
@@ -71,7 +74,7 @@ export async function setupWizard(ctx) {
|
|
|
71
74
|
agent = installed[0];
|
|
72
75
|
else if (installed.length > 1) {
|
|
73
76
|
const current = Math.max(0, installed.indexOf(cfg.agent ?? "claude"));
|
|
74
|
-
const i = await ctx.choose(ui(lang, "ui.setup.agent.pick"), installed.map((id) => ({ label: PROFILES[id].name })), current);
|
|
77
|
+
const i = await ctx.choose(ui(lang, "ui.setup.agent.pick"), installed.map((id) => ({ label: PROFILES[id].name })), current, keep);
|
|
75
78
|
agent = installed[i ?? current];
|
|
76
79
|
}
|
|
77
80
|
if (agent) {
|
|
@@ -82,7 +85,7 @@ export async function setupWizard(ctx) {
|
|
|
82
85
|
if (has.app && has.cli) {
|
|
83
86
|
const app = DESKTOP_APPS[agent].name;
|
|
84
87
|
const current = cfg.agentWhere === "terminal" ? 1 : 0;
|
|
85
|
-
const i = await ctx.choose(ui(lang, "ui.setup.where"), [{ label: ui(lang, "ui.setup.where.app", { app }) }, { label: ui(lang, "ui.setup.where.terminal", { agent: PROFILES[agent].name }) }], current);
|
|
88
|
+
const i = await ctx.choose(ui(lang, "ui.setup.where"), [{ label: ui(lang, "ui.setup.where.app", { app }) }, { label: ui(lang, "ui.setup.where.terminal", { agent: PROFILES[agent].name }) }], current, keep);
|
|
86
89
|
cfg.agentWhere = (i ?? current) === 1 ? "terminal" : "app";
|
|
87
90
|
}
|
|
88
91
|
}
|
|
@@ -93,18 +96,18 @@ export async function setupWizard(ctx) {
|
|
|
93
96
|
const models = ["opus", "sonnet", undefined];
|
|
94
97
|
const current = cfg.models?.claude?.lead;
|
|
95
98
|
const suggested = current === undefined ? (first ? 0 : 2) : Math.max(0, models.indexOf(current));
|
|
96
|
-
const i = await ctx.choose(ui(lang, "ui.setup.model"), [{ label: ui(lang, "ui.setup.model.opus") }, { label: ui(lang, "ui.setup.model.sonnet") }, { label: ui(lang, "ui.setup.model.own") }], suggested);
|
|
99
|
+
const i = await ctx.choose(ui(lang, "ui.setup.model"), [{ label: ui(lang, "ui.setup.model.opus") }, { label: ui(lang, "ui.setup.model.sonnet") }, { label: ui(lang, "ui.setup.model.own") }], suggested, keep);
|
|
97
100
|
writeStored(cfg, "model.lead", "claude", models[i ?? suggested]);
|
|
98
101
|
}
|
|
99
102
|
else if (agent)
|
|
100
103
|
out(ui(lang, "ui.setup.model.strong", { agent: PROFILES[agent].name }));
|
|
101
104
|
// 5b. Stories of the ancestors for the family book: on unless the person says no.
|
|
102
|
-
const si = await ctx.choose(ui(lang, "ui.setup.stories"), [{ label: ui(lang, "ui.setup.stories.yes") }, { label: ui(lang, "ui.setup.stories.no") }], cfg.stories === "no" ? 1 : 0);
|
|
105
|
+
const si = await ctx.choose(ui(lang, "ui.setup.stories"), [{ label: ui(lang, "ui.setup.stories.yes") }, { label: ui(lang, "ui.setup.stories.no") }], cfg.stories === "no" ? 1 : 0, keep);
|
|
103
106
|
cfg.stories = (si ?? (cfg.stories === "no" ? 1 : 0)) === 1 ? "no" : "yes";
|
|
104
107
|
// 6. What the agent may do without asking.
|
|
105
108
|
const now = s.agentPermissions();
|
|
106
109
|
const labels = { ask: ui(lang, "ui.setup.level.ask"), auto: ui(lang, "ui.setup.level.auto"), full: ui(lang, "ui.setup.level.full") };
|
|
107
|
-
const li = await ctx.choose(ui(lang, "ui.setup.level"), PERMISSION_LEVELS.map((l) => ({ label: labels[l] })), PERMISSION_LEVELS.indexOf(now));
|
|
110
|
+
const li = await ctx.choose(ui(lang, "ui.setup.level"), PERMISSION_LEVELS.map((l) => ({ label: labels[l] })), PERMISSION_LEVELS.indexOf(now), keep);
|
|
108
111
|
let level = PERMISSION_LEVELS[li ?? PERMISSION_LEVELS.indexOf(now)];
|
|
109
112
|
if (level === "full" && now !== "full") {
|
|
110
113
|
out(ui(lang, "ui.setup.level.warn"));
|
|
@@ -127,16 +130,40 @@ export async function setupWizard(ctx) {
|
|
|
127
130
|
}
|
|
128
131
|
else
|
|
129
132
|
createShortcut(shortcutName(lang), ctx.env);
|
|
130
|
-
// Quietly: the installed agents learn about strom
|
|
133
|
+
// Quietly: the installed agents learn about strom.
|
|
131
134
|
const taught = new Set();
|
|
132
135
|
for (const t of globalTargets(ctx.env).filter((t) => installed.includes(t.agent)))
|
|
133
136
|
if (installGlobal(t))
|
|
134
137
|
taught.add(t.agent);
|
|
135
138
|
for (const a of taught)
|
|
136
139
|
out(ui(lang, "ui.setup.skill", { agent: PROFILES[a].name }));
|
|
137
|
-
|
|
140
|
+
// 8. The Strom app: noticed when it is here; else asked once whether they want it (pre-filled after).
|
|
141
|
+
if (noticeStromApp(s, ctx.env, { look: true }) || stromAppState(s) === "seen")
|
|
138
142
|
out(ui(lang, "ui.setup.app"));
|
|
143
|
+
else
|
|
144
|
+
await askStromApp(ctx, lang, first ? undefined : ui(lang, "ui.keep"));
|
|
139
145
|
out();
|
|
140
146
|
out(`${ui(lang, "ui.setup.done")} (${langName(lang, lang)} · ${ctx.display(home)})`);
|
|
141
147
|
return { home, lang, ...(agent ? { agent } : {}), permissions: level };
|
|
142
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Does the person want the Strom app (not found on this computer)? Yes and
|
|
151
|
+
* install it now: it opens in the browser and strom says where to click; yes
|
|
152
|
+
* but later; no: strom never mentions it again (strom.app no). Undefined when
|
|
153
|
+
* not answered or 0 (back) — nothing changes.
|
|
154
|
+
*/
|
|
155
|
+
export async function askStromApp(ctx, lang, back = ui(lang, "ui.browse.back")) {
|
|
156
|
+
const said = ctx.settings.config.stromApp;
|
|
157
|
+
const answers = ["install", "later", "no"];
|
|
158
|
+
const i = await ctx.choose(ui(lang, "ui.setup.stromapp"), [{ label: ui(lang, "ui.setup.stromapp.install") }, { label: ui(lang, "ui.setup.stromapp.later") }, { label: ui(lang, "ui.setup.stromapp.no") }], said === "no" ? 2 : said === "yes" ? 1 : 0, back ? { back } : {});
|
|
159
|
+
if (i === undefined)
|
|
160
|
+
return undefined;
|
|
161
|
+
const answer = answers[i];
|
|
162
|
+
ctx.settings.config.stromApp = answer === "no" ? "no" : "yes";
|
|
163
|
+
ctx.settings.save();
|
|
164
|
+
if (answer === "install") {
|
|
165
|
+
const url = stromAppUrl(ctx.settings);
|
|
166
|
+
ctx.io.stdout(ui(lang, openForUser(url, ctx.env) ? "ui.app.install" : "ui.app.url", { url }) + "\n");
|
|
167
|
+
}
|
|
168
|
+
return answer;
|
|
169
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// stats · pedigree · person card · recent · plan — the tree as a person looks through
|
|
2
|
+
// it (the guided menu's "Look through the family tree"), in the research
|
|
3
|
+
// language. Read only; --json gives the same for a program.
|
|
4
|
+
import { register } from "../cli/registry.js";
|
|
5
|
+
import { lines, table, truncate } from "../cli/format.js";
|
|
6
|
+
import { ui } from "../cli/ui.js";
|
|
7
|
+
import { eventName, generationName, humanCost, humanDate, humanDay, humanPlace, statusName } from "../cli/human.js";
|
|
8
|
+
import { UsageError } from "../core/errors.js";
|
|
9
|
+
import { mainPerson } from "../core/kin.js";
|
|
10
|
+
import { resolvePerson } from "../core/people.js";
|
|
11
|
+
import { knownGenerations, ownerName, pedigree, personCard, recent, treeStats } from "../core/overview.js";
|
|
12
|
+
import { displayName } from "../core/people.js";
|
|
13
|
+
import { lacksImages } from "../core/queue.js";
|
|
14
|
+
import { rankedQueue, waitingForUser } from "./tasks.js";
|
|
15
|
+
/** Most rows a section of `recent` lists; the rest is counted. */
|
|
16
|
+
const RECENT_ROWS = 15;
|
|
17
|
+
function whoText(w) {
|
|
18
|
+
return w.years ? `${w.name} (${w.years})` : w.name;
|
|
19
|
+
}
|
|
20
|
+
/** The person asked for, else the main person of the tree. */
|
|
21
|
+
function personOrMain(tree, ref) {
|
|
22
|
+
if (ref)
|
|
23
|
+
return resolvePerson(tree, ref).id;
|
|
24
|
+
const main = mainPerson(tree);
|
|
25
|
+
if (!main)
|
|
26
|
+
throw new UsageError("this tree has no research yet, so no main person", { hint: 'name one: strom pedigree "<name>"' });
|
|
27
|
+
return main;
|
|
28
|
+
}
|
|
29
|
+
function positive(value, name, fallback, max) {
|
|
30
|
+
if (value === undefined)
|
|
31
|
+
return fallback;
|
|
32
|
+
const n = Number(value);
|
|
33
|
+
if (!Number.isInteger(n) || n < 1 || n > max)
|
|
34
|
+
throw new UsageError(`--${name} must be a whole number from 1 to ${max}`);
|
|
35
|
+
return n;
|
|
36
|
+
}
|
|
37
|
+
/** A fact as a person reads it: "25. 6. 1905, Týnec nad Labem č. 12 · doloženo — Křest Jana Nováka 1905". */
|
|
38
|
+
function factText(f, lang) {
|
|
39
|
+
const core = [f.value, humanDate(f.date, lang), humanPlace(f.place, f.house, lang), f.cause].filter(Boolean).join(", ");
|
|
40
|
+
return `${core ? `${core} · ` : ""}${statusName(f.status, lang)}${f.source ? ` — ${truncate(f.source, 70)}` : ""}`;
|
|
41
|
+
}
|
|
42
|
+
function factRows(facts, lang, indent) {
|
|
43
|
+
return table(facts.map((f) => [`${indent}${eventName(f.kind, lang, f.label)}`, factText(f, lang)]));
|
|
44
|
+
}
|
|
45
|
+
function drawPedigree(root, lang) {
|
|
46
|
+
const label = (n) => `${whoText(n.person)}${n.person.proven ? " ✓" : ""}${n.seen ? ` ${ui(lang, "ui.ped.seen")}` : ""}`;
|
|
47
|
+
const out = [label(root)];
|
|
48
|
+
const walk = (n, prefix) => {
|
|
49
|
+
if (!n.father || !n.mother)
|
|
50
|
+
return;
|
|
51
|
+
if (!n.father.person && !n.mother.person) {
|
|
52
|
+
out.push(`${prefix}└─ ? ${ui(lang, "ui.ped.noparents")}`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const parents = [[n.father, "ui.ped.nofather"], [n.mother, "ui.ped.nomother"]];
|
|
56
|
+
parents.forEach(([p, missing], i) => {
|
|
57
|
+
const last = i === parents.length - 1;
|
|
58
|
+
out.push(`${prefix}${last ? "└─ " : "├─ "}${p.person ? label(p) : `? ${ui(lang, missing)}`}`);
|
|
59
|
+
if (p.person)
|
|
60
|
+
walk(p, prefix + (last ? " " : "│ "));
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
walk(root, "");
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/** What the agent wrote into a task, for the user: the records it names by ID by their names ("S0001" → its title). */
|
|
67
|
+
export function humanTask(tree, text, lang) {
|
|
68
|
+
const name = (id) => {
|
|
69
|
+
const r = tree.get(id);
|
|
70
|
+
if (!r)
|
|
71
|
+
return id;
|
|
72
|
+
if (r.type === "session")
|
|
73
|
+
return ui(lang, "ui.plan.session", { day: humanDay(r.started, lang) });
|
|
74
|
+
if (r.type === "person")
|
|
75
|
+
return displayName(r);
|
|
76
|
+
if (r.type === "family")
|
|
77
|
+
return r.partners.map((x) => tree.get(x)).filter((x) => !!x).map(displayName).join(" & ") || id;
|
|
78
|
+
return r.title;
|
|
79
|
+
};
|
|
80
|
+
// A list of IDs as a list of names, each once (two sessions of one day read as one).
|
|
81
|
+
return text.replace(/\b[PFSBN]\d{4,}\b(?:, [PFSBN]\d{4,}\b)*/g, (ids) => [...new Set(ids.split(", ").map(name))].join(", "));
|
|
82
|
+
}
|
|
83
|
+
function more(n, lang) {
|
|
84
|
+
return n > 0 ? ` ${ui(lang, "ui.recent.more", { n })}` : undefined;
|
|
85
|
+
}
|
|
86
|
+
register({
|
|
87
|
+
path: ["stats"],
|
|
88
|
+
summary: "The research at a glance: people, facts and how sure they are, the ancestors known in each generation, tasks, sessions and their cost",
|
|
89
|
+
group: "analysis",
|
|
90
|
+
tree: true,
|
|
91
|
+
args: [{ name: "person", description: "whose ancestors to count (default: the main person of the tree)" }],
|
|
92
|
+
examples: ["strom stats", 'strom stats "Jan Novák"'],
|
|
93
|
+
run(ctx, { args }) {
|
|
94
|
+
const tree = ctx.tree();
|
|
95
|
+
const lang = ctx.uiLang();
|
|
96
|
+
const from = args[0] ? resolvePerson(tree, args[0]).id : mainPerson(tree);
|
|
97
|
+
const s = treeStats(tree, from);
|
|
98
|
+
const out = [
|
|
99
|
+
ui(lang, "ui.stats.title", { name: tree.config.name }),
|
|
100
|
+
ui(lang, "ui.stats.counts", { persons: s.persons, families: s.families, sources: s.sources, images: s.images, places: s.places }),
|
|
101
|
+
ui(lang, "ui.stats.facts", s.facts),
|
|
102
|
+
];
|
|
103
|
+
if (s.from) {
|
|
104
|
+
out.push("", ui(lang, "ui.stats.from", { name: whoText(s.from) }));
|
|
105
|
+
if (!s.generations.length)
|
|
106
|
+
out.push(` ${ui(lang, "ui.stats.noancestors")}`);
|
|
107
|
+
else
|
|
108
|
+
out.push(table(s.generations.map((g) => [` ${generationName(g.generation, lang)}`, ui(lang, "ui.stats.gen", { known: g.known, expected: g.expected, proven: g.proven })])));
|
|
109
|
+
if (s.oldest)
|
|
110
|
+
out.push(ui(lang, "ui.stats.oldest", { name: whoText(s.oldest) }));
|
|
111
|
+
}
|
|
112
|
+
out.push("", ui(lang, "ui.stats.tasks", s.tasks));
|
|
113
|
+
if (s.sessions.count)
|
|
114
|
+
out.push(ui(lang, "ui.stats.sessions", { n: s.sessions.count, last: humanDay(s.sessions.last, lang) }) +
|
|
115
|
+
(s.sessions.costUsd ? ui(lang, "ui.stats.cost", { cost: humanCost(s.sessions.costUsd, lang) }) : ""));
|
|
116
|
+
if (s.stories.written)
|
|
117
|
+
out.push(ui(lang, "ui.stats.stories", { n: s.stories.written, final: s.stories.final }));
|
|
118
|
+
return { text: lines(...out), data: s };
|
|
119
|
+
},
|
|
120
|
+
}, {
|
|
121
|
+
path: ["pedigree"],
|
|
122
|
+
summary: "The ancestors of a person as a tree, father and mother at each step — who is known, whose birth a record proves, where it ends",
|
|
123
|
+
group: "analysis",
|
|
124
|
+
tree: true,
|
|
125
|
+
args: [{ name: "person", description: "ID (P0001) or name (default: the main person of the tree)" }],
|
|
126
|
+
options: [{ name: "generations", type: "string", value: "<n>", description: "how many generations, the person included (default 5, at most 12)" }],
|
|
127
|
+
examples: ["strom pedigree", 'strom pedigree "Jan Novák" --generations 7'],
|
|
128
|
+
run(ctx, { args, opts }) {
|
|
129
|
+
const tree = ctx.tree();
|
|
130
|
+
const lang = ctx.uiLang();
|
|
131
|
+
const id = personOrMain(tree, args[0]);
|
|
132
|
+
const generations = positive(opts.generations, "generations", 5, 12);
|
|
133
|
+
const root = pedigree(tree, id, generations);
|
|
134
|
+
const known = knownGenerations(tree, id);
|
|
135
|
+
return {
|
|
136
|
+
text: lines(...drawPedigree(root, lang), "", ui(lang, "ui.ped.legend"), known > generations ? ui(lang, "ui.ped.more", { n: known, id }) : undefined),
|
|
137
|
+
data: { generations, known, pedigree: root },
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
}, {
|
|
141
|
+
path: ["person", "card"],
|
|
142
|
+
summary: "One person for the family to read: their life, parents, marriages and children, each fact with how sure it is and its record — and their story",
|
|
143
|
+
group: "people",
|
|
144
|
+
tree: true,
|
|
145
|
+
args: [{ name: "person", description: "ID (P0001) or name", required: true }],
|
|
146
|
+
examples: ['strom person card "Jan Novák"', "strom person card P0001"],
|
|
147
|
+
run(ctx, { args }) {
|
|
148
|
+
const tree = ctx.tree();
|
|
149
|
+
const lang = ctx.uiLang();
|
|
150
|
+
const c = personCard(tree, resolvePerson(tree, args[0]));
|
|
151
|
+
const out = [whoText(c.person)];
|
|
152
|
+
if (c.otherNames.length)
|
|
153
|
+
out.push(` ${ui(lang, "ui.card.also", { names: c.otherNames.join(", ") })}`);
|
|
154
|
+
out.push(c.facts.length ? factRows(c.facts, lang, " ") : ` ${ui(lang, "ui.card.nofacts")}`, "");
|
|
155
|
+
out.push(c.parents.length ? ui(lang, "ui.card.parents", { names: c.parents.map(whoText).join(", ") }) : ui(lang, "ui.card.noparents"));
|
|
156
|
+
for (const f of c.families) {
|
|
157
|
+
const key = f.partner?.sex === "F" ? "ui.card.wife" : f.partner?.sex === "M" ? "ui.card.husband" : c.sex === "M" ? "ui.card.wife" : c.sex === "F" ? "ui.card.husband" : "ui.card.partner";
|
|
158
|
+
out.push(ui(lang, key, { name: f.partner ? whoText(f.partner) : ui(lang, "ui.card.unknown") }));
|
|
159
|
+
if (f.facts.length)
|
|
160
|
+
out.push(factRows(f.facts, lang, " "));
|
|
161
|
+
if (f.children.length)
|
|
162
|
+
out.push(` ${ui(lang, "ui.card.children", { names: f.children.map(whoText).join(", ") })}`);
|
|
163
|
+
}
|
|
164
|
+
if (c.story) {
|
|
165
|
+
const text = c.story.text.replace(/\*\*/g, "").trim();
|
|
166
|
+
out.push("", ui(lang, "ui.card.story", { status: ui(lang, c.story.status === "final" ? "ui.card.story.final" : "ui.card.story.draft") }));
|
|
167
|
+
// markdown headings as plain lines
|
|
168
|
+
out.push(text.split("\n").map((l) => (l.trim() ? ` ${l.trim().replace(/^#+\s*/, "")}` : "")).join("\n"));
|
|
169
|
+
}
|
|
170
|
+
return { text: lines(...out), data: c };
|
|
171
|
+
},
|
|
172
|
+
}, {
|
|
173
|
+
path: ["plan"],
|
|
174
|
+
summary: "What the agent will do next, for the user: the first tasks of the queue in its order, what waits for scans, the stories after them",
|
|
175
|
+
group: "tasks",
|
|
176
|
+
tree: true,
|
|
177
|
+
options: [{ name: "count", type: "string", value: "<n>", description: "how many tasks (default 10)" }],
|
|
178
|
+
examples: ["strom plan", "strom plan --count 20"],
|
|
179
|
+
run(ctx, { opts }) {
|
|
180
|
+
const tree = ctx.tree();
|
|
181
|
+
const lang = ctx.uiLang();
|
|
182
|
+
const count = positive(opts.count, "count", 10, 200);
|
|
183
|
+
// The order of a conversation (the story's turn is only for working alone).
|
|
184
|
+
const queue = rankedQueue(tree, { strategy: ctx.settings.strategy(tree.config) }).map((r) => r.task);
|
|
185
|
+
const work = queue.filter((t) => t.level !== "narrate");
|
|
186
|
+
const stories = queue.filter((t) => t.level === "narrate");
|
|
187
|
+
const waiting = waitingForUser(tree).length;
|
|
188
|
+
const shown = work.slice(0, count);
|
|
189
|
+
let n = 0;
|
|
190
|
+
const rows = shown.map((t) => [
|
|
191
|
+
` ${t.state === "doing" ? ui(lang, "ui.plan.now") : `${++n}.`}`,
|
|
192
|
+
`${truncate(humanTask(tree, t.what, lang), 110)}${lacksImages(tree, t) ? ui(lang, "ui.plan.images") : ""}`,
|
|
193
|
+
]);
|
|
194
|
+
const storyNames = [...new Set(stories.map((t) => tree.get(t.subject[0] ?? "")).filter((p) => !!p).map(displayName))];
|
|
195
|
+
const out = [ui(lang, "ui.plan.title", { name: tree.config.name })];
|
|
196
|
+
if (!queue.length)
|
|
197
|
+
out.push(` ${ui(lang, "ui.plan.none")}`);
|
|
198
|
+
else {
|
|
199
|
+
if (rows.length)
|
|
200
|
+
out.push(table(rows));
|
|
201
|
+
if (work.length > count)
|
|
202
|
+
out.push(` ${ui(lang, "ui.plan.more", { n: work.length - count })}`);
|
|
203
|
+
if (stories.length)
|
|
204
|
+
out.push(ui(lang, "ui.plan.stories", { n: stories.length, names: storyNames.join(", ") }));
|
|
205
|
+
}
|
|
206
|
+
if (waiting)
|
|
207
|
+
out.push(ui(lang, "ui.plan.waiting", { n: waiting }));
|
|
208
|
+
if (queue.length)
|
|
209
|
+
out.push("", ui(lang, "ui.plan.steer"));
|
|
210
|
+
const item = (t) => ({ id: t.id, what: humanTask(tree, t.what, lang), level: t.level, state: t.state, needsImages: lacksImages(tree, t) });
|
|
211
|
+
return { text: lines(...out), data: { tasks: work.map(item), stories: stories.map(item), waiting } };
|
|
212
|
+
},
|
|
213
|
+
}, {
|
|
214
|
+
path: ["recent"],
|
|
215
|
+
summary: "What came in lately: new people, facts added or refined, records, stories, and what the agent did in its sessions",
|
|
216
|
+
group: "analysis",
|
|
217
|
+
tree: true,
|
|
218
|
+
options: [{ name: "days", type: "string", value: "<n>", description: "how many days back (default 7)" }],
|
|
219
|
+
examples: ["strom recent", "strom recent --days 30"],
|
|
220
|
+
run(ctx, { opts }) {
|
|
221
|
+
const tree = ctx.tree();
|
|
222
|
+
const lang = ctx.uiLang();
|
|
223
|
+
const days = positive(opts.days, "days", 7, 3660);
|
|
224
|
+
const r = recent(tree, new Date(Date.now() - days * 86_400_000).toISOString());
|
|
225
|
+
const since = humanDay(r.since, lang);
|
|
226
|
+
const refined = r.facts.filter((f) => f.refined).length;
|
|
227
|
+
if (!r.persons.length && !r.facts.length && !r.sources.length && !r.stories.length && !r.sessions.length && !r.tasksDone)
|
|
228
|
+
return { text: ui(lang, "ui.recent.nothing", { since }), data: { days, ...r } };
|
|
229
|
+
const out = [
|
|
230
|
+
ui(lang, "ui.recent.title", { since }),
|
|
231
|
+
ui(lang, "ui.recent.counts", { persons: r.persons.length, facts: r.facts.length - refined, refined, sources: r.sources.length, stories: r.stories.length, done: r.tasksDone }),
|
|
232
|
+
];
|
|
233
|
+
if (r.persons.length)
|
|
234
|
+
out.push("", ui(lang, "ui.recent.persons"), ...r.persons.slice(0, RECENT_ROWS).map((p) => ` ${whoText(p)}`), more(r.persons.length - RECENT_ROWS, lang));
|
|
235
|
+
if (r.facts.length)
|
|
236
|
+
out.push("", ui(lang, "ui.recent.facts"), ...r.facts.slice(0, RECENT_ROWS).map((f) => ` ${ownerName(tree, f.owner)} – ${eventName(f.fact.kind, lang, f.fact.label)}: ${factText(f.fact, lang)}${f.refined ? ` (${ui(lang, "ui.recent.refined")})` : ""}`), more(r.facts.length - RECENT_ROWS, lang));
|
|
237
|
+
if (r.stories.length)
|
|
238
|
+
out.push("", ui(lang, "ui.recent.stories"), ...r.stories.map((p) => ` ${whoText(p)}`));
|
|
239
|
+
if (r.sessions.length)
|
|
240
|
+
out.push("", ui(lang, "ui.recent.sessions"), ...r.sessions.slice(0, RECENT_ROWS).map((s) => ` ${humanDay(s.at, lang)} – ${[s.task, s.summary ? truncate(s.summary, 220) : undefined].filter(Boolean).join(": ")}`), more(r.sessions.length - RECENT_ROWS, lang));
|
|
241
|
+
return { text: lines(...out), data: { days, ...r } };
|
|
242
|
+
},
|
|
243
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// strom clips — mark where the entries already read are on their scans, for
|
|
2
|
+
// sources that have none (read before clips existed, or brought in from an
|
|
3
|
+
// older research): readers find each entry on its image, strom cuts it out, a
|
|
4
|
+
// second look checks the cut-out; what passes becomes the source's clip. Run
|
|
5
|
+
// again, it takes up only what is still missing.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { register } from "../cli/registry.js";
|
|
9
|
+
import { lines, truncate } from "../cli/format.js";
|
|
10
|
+
import { UsageError } from "../core/errors.js";
|
|
11
|
+
import { normId, requireRecord, update } from "../core/records.js";
|
|
12
|
+
import { makeView, VIEW_MAX } from "../core/views.js";
|
|
13
|
+
import { clipText, MAX_CLIPS, regionText } from "../core/media.js";
|
|
14
|
+
import { withMargin } from "../core/excerpt.js";
|
|
15
|
+
import { batches } from "../core/reader.js";
|
|
16
|
+
import { checkPrompt, locatePrompt, parseChecked, parseLocated, widen } from "../core/clipfinder.js";
|
|
17
|
+
import { displayName } from "../core/people.js";
|
|
18
|
+
import { readers } from "./readers.js";
|
|
19
|
+
import { verifyFast } from "../core/integrity.js";
|
|
20
|
+
import { pool } from "./read.js";
|
|
21
|
+
/** Entries per reader: each is one image (sometimes two) the reader looks at. */
|
|
22
|
+
const PER_READER = 10;
|
|
23
|
+
/** How many times a cut-out found cut off is cut out wider and checked again. */
|
|
24
|
+
const WIDER = 2;
|
|
25
|
+
/** Who a source names: the people whose facts cite it. */
|
|
26
|
+
export function namesIn(tree, id) {
|
|
27
|
+
const out = new Set();
|
|
28
|
+
for (const p of tree.list("person"))
|
|
29
|
+
if (p.events.some((e) => e.citations.some((c) => c.source === id)) || p.names.some((n) => n.citations?.some((c) => c.source === id)))
|
|
30
|
+
out.add(displayName(p));
|
|
31
|
+
for (const f of tree.list("family"))
|
|
32
|
+
if (f.events.some((e) => e.citations.some((c) => c.source === id)) || f.citations?.some((c) => c.source === id))
|
|
33
|
+
for (const id2 of f.partners) {
|
|
34
|
+
const p = tree.get(id2);
|
|
35
|
+
if (p)
|
|
36
|
+
out.add(displayName(p));
|
|
37
|
+
}
|
|
38
|
+
return [...out].slice(0, 8);
|
|
39
|
+
}
|
|
40
|
+
register({
|
|
41
|
+
path: ["clips"],
|
|
42
|
+
summary: "Mark where entries already read are on their scans (sources without clips): readers find them, a second look checks",
|
|
43
|
+
group: "sources",
|
|
44
|
+
tree: true,
|
|
45
|
+
writes: true,
|
|
46
|
+
lock: "sections",
|
|
47
|
+
description: "For sources on images that have no clip yet — read before clips existed, or brought in from an older research.\n" +
|
|
48
|
+
"A reader finds each entry on its image (its words and the people it names are given), strom cuts it out, and a\n" +
|
|
49
|
+
"second reader checks the cut-out shows that entry whole: only then it becomes the source's clip (for the Strom app's\n" +
|
|
50
|
+
"excerpts: output/tree-strom.ged, strom app). Paid model work, about two looks per entry: --dry-run says how many first.\n" +
|
|
51
|
+
"Run again, it takes up only what is still missing. It waits for its readers: run it in the foreground.",
|
|
52
|
+
args: [{ name: "sources", description: "only these sources (S…); default every source on images without a clip", variadic: true }],
|
|
53
|
+
options: [
|
|
54
|
+
{ name: "recordset", type: "string", value: "<B…>", description: "only the sources of this book" },
|
|
55
|
+
{ name: "limit", type: "string", value: "<n>", description: "at most this many sources this time" },
|
|
56
|
+
{ name: "again", type: "boolean", description: "also the sources that have clips (found again, replaced when the check passes)" },
|
|
57
|
+
{ name: "batch", type: "string", value: "<n>", description: `entries per reader (default ${PER_READER})` },
|
|
58
|
+
{ name: "parallel", type: "string", value: "<n>", description: "readers at the same time (default 3)" },
|
|
59
|
+
{ name: "model", type: "string", value: "<model>", description: "model of the readers (default: model.vision — it reads the handwriting to find the entry)" },
|
|
60
|
+
{ name: "minutes", type: "string", value: "<n>", description: "time limit of one reader (default 15)" },
|
|
61
|
+
],
|
|
62
|
+
examples: ["strom clips --dry-run", "strom clips --recordset B0001 --limit 20", "strom clips S0001 S0002"],
|
|
63
|
+
run: async (ctx, { args, opts }) => {
|
|
64
|
+
const tree = ctx.tree();
|
|
65
|
+
const shared = ctx.settings.shared()?.value;
|
|
66
|
+
if (!shared)
|
|
67
|
+
throw new UsageError("no shared folder with the images is set", { hint: "strom setup" });
|
|
68
|
+
const all = new Map(tree.list("media").map((m) => [m.id, m]));
|
|
69
|
+
const only = new Set(args.map((a) => requireRecord(tree, normId(a), "source").id));
|
|
70
|
+
const book = typeof opts.recordset === "string" ? normId(opts.recordset) : undefined;
|
|
71
|
+
const imageOf = (id) => {
|
|
72
|
+
const m = all.get(id);
|
|
73
|
+
return m && /^image\/(jpeg|png)$/.test(m.mime) && fs.existsSync(path.join(shared, m.file)) ? m : undefined;
|
|
74
|
+
};
|
|
75
|
+
let todo = tree
|
|
76
|
+
.list("source")
|
|
77
|
+
.filter((s) => !s.retracted && s.media?.length && (only.size ? only.has(s.id) : true))
|
|
78
|
+
.filter((s) => (book ? s.recordset === book || s.media.some((m) => all.get(m)?.recordset === book) : true))
|
|
79
|
+
.filter((s) => opts.again || !s.clips?.length);
|
|
80
|
+
const away = todo.filter((s) => !s.media.some((m) => imageOf(m)));
|
|
81
|
+
todo = todo.filter((s) => s.media.some((m) => imageOf(m)));
|
|
82
|
+
const limit = opts.limit === undefined ? undefined : Number(opts.limit);
|
|
83
|
+
if (limit !== undefined && !(Number.isInteger(limit) && limit > 0))
|
|
84
|
+
throw new UsageError("--limit must be a positive number");
|
|
85
|
+
if (limit)
|
|
86
|
+
todo = todo.slice(0, limit);
|
|
87
|
+
const size = opts.batch === undefined ? PER_READER : Number(opts.batch);
|
|
88
|
+
if (!Number.isInteger(size) || size < 1)
|
|
89
|
+
throw new UsageError("--batch must be a positive number");
|
|
90
|
+
const groups = batches(todo, size);
|
|
91
|
+
if (!todo.length)
|
|
92
|
+
return { text: lines("nothing to do: every source on images has its clip", away.length ? `${away.length} source(s) on images not on this computer (or not JPEG/PNG)` : undefined), data: { sources: 0 } };
|
|
93
|
+
if (tree.dryRun)
|
|
94
|
+
return {
|
|
95
|
+
text: lines(`${todo.length} source(s) to find on their images — ${groups.length} reader(s) to find them, as many to check`, away.length ? `${away.length} more on images not on this computer (or not JPEG/PNG)` : undefined, "(dry run — no reader started)"),
|
|
96
|
+
data: { sources: todo.length, readers: groups.length, away: away.map((s) => s.id) },
|
|
97
|
+
};
|
|
98
|
+
const r0 = readers(ctx, tree, shared, "clips", "find", opts);
|
|
99
|
+
const { model, parallel, reportsDir, failed, progress } = r0;
|
|
100
|
+
const reader = r0.read;
|
|
101
|
+
// 1. Find: each image whole, with a grid, and what the entry says.
|
|
102
|
+
const byId = new Map(todo.map((s) => [s.id, s]));
|
|
103
|
+
const found = await pool(groups, parallel, async (group, k) => {
|
|
104
|
+
const jobs = group.map((s) => ({
|
|
105
|
+
source: s.id,
|
|
106
|
+
title: s.title,
|
|
107
|
+
facts: [
|
|
108
|
+
s.locator || s.date ? `where: ${[s.locator, s.date ? `recorded ${s.date}` : ""].filter(Boolean).join("; ")}` : "",
|
|
109
|
+
namesIn(tree, s.id).length ? `people in it: ${namesIn(tree, s.id).join(", ")}` : "",
|
|
110
|
+
].filter(Boolean),
|
|
111
|
+
words: s.transcript ? truncate(s.transcript.replace(/\s+/g, " "), 400) : undefined,
|
|
112
|
+
images: s.media.flatMap((id) => {
|
|
113
|
+
const m = imageOf(id);
|
|
114
|
+
return m ? [{ media: m.id, view: makeView(tree, path.join(shared, m.file), m.id, { grid: true, max: VIEW_MAX }).file }] : [];
|
|
115
|
+
}),
|
|
116
|
+
}));
|
|
117
|
+
progress(`▶ finding ${k + 1}/${groups.length}: ${group.map((s) => s.id).join(" ")}`);
|
|
118
|
+
const r = await reader(`find-${k + 1}`, jobs.flatMap((j) => j.images.map((i) => i.view)), `Where the entries are · batch ${k + 1} of ${groups.length}`, (report) => locatePrompt(jobs, report));
|
|
119
|
+
const located = parseLocated(r.text).filter((l) => byId.get(l.source)?.media?.includes(l.media));
|
|
120
|
+
progress(`■ finding ${k + 1}: ${r.outcome} · ${located.filter((l) => l.result === "found").length} found`);
|
|
121
|
+
return located;
|
|
122
|
+
});
|
|
123
|
+
const locs = found.flat();
|
|
124
|
+
const hits = locs.filter((l) => l.result === "found" && !!l.region);
|
|
125
|
+
// 2. Check: the cut-out as the Strom app will show it. Cut off: wider on those sides, and checked again.
|
|
126
|
+
const check = async (list, round) => {
|
|
127
|
+
const checks = list.map((l) => {
|
|
128
|
+
const m = all.get(l.media);
|
|
129
|
+
const s = byId.get(l.source);
|
|
130
|
+
return {
|
|
131
|
+
source: l.source,
|
|
132
|
+
media: l.media,
|
|
133
|
+
title: s.title,
|
|
134
|
+
words: s.transcript ? truncate(s.transcript.replace(/\s+/g, " "), 400) : undefined,
|
|
135
|
+
view: makeView(tree, path.join(shared, m.file), m.id, { crop: regionText(withMargin(l.region)) }).file,
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
const checkGroups = batches(checks, size);
|
|
139
|
+
const name = (k) => (round ? `check${round + 1}-${k + 1}` : `check-${k + 1}`);
|
|
140
|
+
return (await pool(checkGroups, parallel, async (group, k) => {
|
|
141
|
+
progress(`▶ checking${round ? ` again (${round + 1})` : ""} ${k + 1}/${checkGroups.length}: ${group.length} cut-outs`);
|
|
142
|
+
const r = await reader(name(k), group.map((g) => g.view), `Checking the cut-outs${round ? `, cut out wider` : ""} · batch ${k + 1} of ${checkGroups.length}`, (report) => checkPrompt(group, report));
|
|
143
|
+
const v = parseChecked(r.text);
|
|
144
|
+
progress(`■ checking ${k + 1}: ${r.outcome} · ${v.filter((x) => x.verdict === "ok").length} ok`);
|
|
145
|
+
return v;
|
|
146
|
+
})).flat();
|
|
147
|
+
};
|
|
148
|
+
const verdicts = [];
|
|
149
|
+
let open = hits;
|
|
150
|
+
for (let round = 0; open.length && round <= WIDER; round++) {
|
|
151
|
+
const got = await check(open, round);
|
|
152
|
+
const key = (x) => `${x.source} ${x.media}`;
|
|
153
|
+
const cut = new Map(got.filter((v) => v.verdict === "cut").map((v) => [key(v), v]));
|
|
154
|
+
const last = round === WIDER;
|
|
155
|
+
verdicts.push(...got.filter((v) => v.verdict !== "cut" || last));
|
|
156
|
+
open = last ? [] : open.filter((l) => cut.has(key(l))).map((l) => ({ ...l, region: widen(l.region, cut.get(key(l)).missing) }));
|
|
157
|
+
hits.splice(0, hits.length, ...hits.filter((h) => !open.some((o) => key(o) === key(h))), ...open);
|
|
158
|
+
}
|
|
159
|
+
const passed = new Set(verdicts.filter((v) => v.verdict === "ok").map((v) => `${v.source} ${v.media}`));
|
|
160
|
+
// 3. What passed becomes the source's clips.
|
|
161
|
+
const clipsOf = new Map();
|
|
162
|
+
for (const l of hits)
|
|
163
|
+
if (passed.has(`${l.source} ${l.media}`))
|
|
164
|
+
clipsOf.set(l.source, [...(clipsOf.get(l.source) ?? []), { media: l.media, region: l.region }].slice(0, MAX_CLIPS));
|
|
165
|
+
for (const [id, clips] of clipsOf)
|
|
166
|
+
update(tree, id, "source", (s) => ({ ...s, clips }), { op: "source.edit", summary: `${id} clips ${clips.map(clipText).join(" ")} (found by a reader, checked)` });
|
|
167
|
+
// The reports are working notes of the research: committed with it.
|
|
168
|
+
if (verifyFast(tree).findings.every((f) => f.level !== "error"))
|
|
169
|
+
tree.withTreeLock(() => tree.commit(`Clips: ${clipsOf.size} of ${todo.length} entries marked on their scans`, [tree.relative(reportsDir)]));
|
|
170
|
+
const rejected = verdicts.filter((v) => v.verdict !== "ok");
|
|
171
|
+
// a search over many entries, not one entry: no clip, and said so
|
|
172
|
+
const many = [...new Set(locs.filter((l) => l.result === "many").map((l) => l.source))];
|
|
173
|
+
const missed = todo.filter((s) => !clipsOf.has(s.id));
|
|
174
|
+
const text = lines(`${clipsOf.size} of ${todo.length} source(s) got their clip${model ? ` · ${model}` : ""}${r0.cost() ? ` · $${r0.cost().toFixed(2)}` : ""}`, many.length ? `not one entry but a search over many (no clip — a page of it would show nothing): ${many.join(", ")}` : undefined, rejected.length ? `the check turned down: ${rejected.map((v) => `${v.source} (${v.verdict}${v.why ? `: ${truncate(v.why, 40)}` : ""})`).join(", ")}` : undefined, missed.length ? `without a clip: ${missed.map((s) => s.id).slice(0, 20).join(", ")}${missed.length > 20 ? ` … (${missed.length})` : ""} — strom source edit S… --clip M…@x,y,w,h, or strom clips again` : undefined, away.length ? `on images not on this computer (or not JPEG/PNG): ${away.length}` : undefined, failed.length ? `readers that failed: ${failed.join(", ")}` : undefined, "the entries cut out for the Strom app: strom app (output/tree-strom.ged)");
|
|
175
|
+
return {
|
|
176
|
+
text,
|
|
177
|
+
data: {
|
|
178
|
+
sources: todo.length,
|
|
179
|
+
clipped: [...clipsOf.keys()],
|
|
180
|
+
rejected,
|
|
181
|
+
many,
|
|
182
|
+
missed: missed.map((s) => s.id),
|
|
183
|
+
away: away.map((s) => s.id),
|
|
184
|
+
failed,
|
|
185
|
+
costUsd: r0.cost() || undefined,
|
|
186
|
+
},
|
|
187
|
+
exitCode: failed.length ? 1 : 0,
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
});
|