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.
Files changed (73) hide show
  1. package/README.md +13 -5
  2. package/assets/icon/NOTICE.md +26 -0
  3. package/assets/icon/favicon.svg +1 -0
  4. package/assets/icon/icon-256.png +0 -0
  5. package/assets/icon/icon-512.png +0 -0
  6. package/assets/icon/icon.ico +0 -0
  7. package/assets/icon/icon.svg +1 -0
  8. package/assets/lang/cs.json +184 -8
  9. package/assets/lang/de.json +184 -8
  10. package/assets/method/reading.md +4 -2
  11. package/assets/method/recording.md +10 -1
  12. package/assets/plugins/connectors/DISCOVERY.md +6 -2
  13. package/assets/plugins/connectors/README.md +27 -10
  14. package/dist/agents/files.js +27 -18
  15. package/dist/agents/global.js +15 -4
  16. package/dist/brief/brief.js +4 -0
  17. package/dist/cli/context.js +10 -4
  18. package/dist/cli/human.js +116 -0
  19. package/dist/cli/main.js +9 -0
  20. package/dist/cli/menu.js +254 -38
  21. package/dist/cli/ui.js +156 -7
  22. package/dist/cli/wizard.js +36 -9
  23. package/dist/commands/browse.js +243 -0
  24. package/dist/commands/clips.js +190 -0
  25. package/dist/commands/connectors.js +93 -28
  26. package/dist/commands/guide.js +28 -7
  27. package/dist/commands/index.js +3 -0
  28. package/dist/commands/media.js +42 -8
  29. package/dist/commands/meta.js +33 -9
  30. package/dist/commands/output.js +103 -13
  31. package/dist/commands/read.js +1 -1
  32. package/dist/commands/readers.js +62 -0
  33. package/dist/commands/research.js +62 -0
  34. package/dist/commands/session.js +76 -47
  35. package/dist/commands/setup.js +17 -10
  36. package/dist/commands/sources.js +88 -16
  37. package/dist/commands/start.js +57 -37
  38. package/dist/commands/tasks.js +43 -6
  39. package/dist/commands/transcripts.js +177 -0
  40. package/dist/core/chromium.js +60 -19
  41. package/dist/core/clipfinder.js +118 -0
  42. package/dist/core/config.js +42 -2
  43. package/dist/core/connector.js +56 -8
  44. package/dist/core/excerpt.js +219 -0
  45. package/dist/core/frontier.js +30 -5
  46. package/dist/core/git.js +1 -1
  47. package/dist/core/kin.js +90 -0
  48. package/dist/core/live.js +22 -13
  49. package/dist/core/media.js +37 -3
  50. package/dist/core/migrate.js +33 -0
  51. package/dist/core/model.js +7 -0
  52. package/dist/core/net.js +93 -19
  53. package/dist/core/overview.js +259 -0
  54. package/dist/core/phrases.js +27 -0
  55. package/dist/core/places.js +43 -0
  56. package/dist/core/queue.js +16 -3
  57. package/dist/core/review.js +369 -0
  58. package/dist/core/schema.js +4 -0
  59. package/dist/core/session.js +5 -1
  60. package/dist/core/shortcut.js +71 -11
  61. package/dist/core/stories.js +3 -4
  62. package/dist/core/stromapp.js +86 -25
  63. package/dist/core/transcriber.js +99 -0
  64. package/dist/core/tree.js +3 -1
  65. package/dist/core/uninstall.js +4 -2
  66. package/dist/core/update.js +48 -22
  67. package/dist/core/validate.js +3 -1
  68. package/dist/core/workers.js +13 -0
  69. package/dist/gedcom/export.js +58 -30
  70. package/dist/gedcom/lines.js +8 -0
  71. package/dist/gedcom/validate.js +2 -2
  72. package/dist/runners/claude.js +2 -0
  73. package/package.json +1 -1
@@ -10,6 +10,9 @@ import { verifyFast } from "../core/integrity.js";
10
10
  import { ancestorGenerations, familiesAsChild, familiesAsPartner } from "../core/people.js";
11
11
  import { resolveResearch } from "./research.js";
12
12
  import { UsageError } from "../core/errors.js";
13
+ import { excerptSettings, planExcerpts } from "../core/excerpt.js";
14
+ import { runGit, tracked } from "../core/git.js";
15
+ import { EXCERPT_QUALITIES, EXCERPT_SCOPES } from "../core/model.js";
13
16
  function findingRows(f) {
14
17
  return table(f.slice(0, 30).map((x) => [x.level === "error" ? "ERROR" : "warn", `line ${x.line}`, x.message]));
15
18
  }
@@ -30,35 +33,102 @@ function researchScope(tree, r) {
30
33
  export function gedFile(tree, profile, research) {
31
34
  return path.join(tree.root, "output", `${research ? research.id : "tree"}${profile === "strom" ? "-strom" : ""}.ged`);
32
35
  }
33
- /** Write one GEDCOM file and validate it by the rules of its reader. */
36
+ /**
37
+ * The Strom file stays out of the tree's history: it carries the entries cut out of their scans (megabytes,
38
+ * made again at will from the evidence); the history keeps the standard file (output/tree.ged). A tree that
39
+ * committed it before lets go of it once.
40
+ */
41
+ const IGNORE_STROM = ["/output/*-strom.ged", "/output/*-images.ged"];
42
+ function ignoreStromGed(tree, file) {
43
+ const ignore = path.join(tree.root, ".gitignore");
44
+ const had = fs.existsSync(ignore) ? fs.readFileSync(ignore, "utf8") : "";
45
+ const have = had.split(/\r?\n/);
46
+ const add = IGNORE_STROM.filter((l) => !have.includes(l));
47
+ const rel = tree.relative(file);
48
+ const inHistory = tracked(tree.root, rel);
49
+ if (!add.length && !inHistory)
50
+ return;
51
+ tree.withTreeLock(() => {
52
+ if (add.length)
53
+ fs.writeFileSync(ignore, `${had}${had && !had.endsWith("\n") ? "\n" : ""}${add.join("\n")}\n`);
54
+ if (inHistory)
55
+ runGit(tree.root, ["rm", "--cached", "--quiet", "--", rel]);
56
+ tree.commit("The file for the Strom app (with the images of the entries) stays out of the history", [".gitignore"]);
57
+ });
58
+ }
59
+ /**
60
+ * Write one GEDCOM file and validate it by the rules of its reader. The Strom file carries the entries cut out of
61
+ * their scans by the settings (excerpts.*; `images` overrides them, false: without); budgetMs: new excerpts are
62
+ * made for at most so long (the rest the next export makes).
63
+ */
34
64
  export function writeTreeGed(tree, opts) {
35
65
  const persons = opts.research ? researchScope(tree, opts.research) : undefined;
36
- const result = exportGedcom(tree, { for: opts.for, stromVersion: opts.stromVersion, ...(persons ? { persons } : {}) });
66
+ const set = opts.for !== "strom" || opts.images === false ? undefined : (opts.images ?? excerptSettings(tree));
67
+ const images = set && set.for !== "none"
68
+ ? planExcerpts(tree, set.shared, { quality: set.quality, for: set.for, maxBytes: set.mb * 1024 * 1024, persons, budgetMs: opts.budgetMs })
69
+ : undefined;
70
+ const result = exportGedcom(tree, { for: opts.for, stromVersion: opts.stromVersion, ...(persons ? { persons } : {}), ...(images ? { excerpts: images.of } : {}) });
37
71
  const findings = validateGedcom(result.text, opts.for === "standard" ? { strict: true } : {});
38
72
  const file = opts.out ?? gedFile(tree, opts.for, opts.research);
39
- if (!hasGedErrors(findings) && !tree.dryRun)
73
+ if (!hasGedErrors(findings) && !tree.dryRun) {
74
+ if (opts.for === "strom" && file.startsWith(path.join(tree.root, "output") + path.sep))
75
+ ignoreStromGed(tree, file);
40
76
  writeFileAtomic(file, result.text);
41
- return { ...result, findings, file, for: opts.for };
77
+ }
78
+ return { ...result, findings, file, for: opts.for, ...(images && set ? { images: images.report, imageSettings: set } : {}) };
79
+ }
80
+ const mb = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
81
+ /** What an export with images took in and left out, and what to do about it — what strom decided itself is said as a warning. */
82
+ export function imagesLines(r, images) {
83
+ const few = (ids) => `${ids.slice(0, 8).join(", ")}${ids.length > 8 ? ` … (${ids.length})` : ""}`;
84
+ return lines(` ${r.excerpts} excerpt(s) of ${r.sources} source(s), ${mb(r.bytes)} · quality ${r.level}`, r.level !== r.asked ? ` WARNING made smaller to fit the limit of ${images.mb} MB: ${r.level} instead of ${r.asked} — more room: --images-max-mb <n> (strom config set excerpts.mb <n>)` : undefined, r.dropped.length
85
+ ? ` WARNING left out to fit the limit of ${images.mb} MB, the farthest from the research first: ${r.dropped.length} source(s) (${few(r.dropped)}) — more room: --images-max-mb <n>`
86
+ : undefined, r.outOfScope ? ` ${r.outOfScope} source(s) of people farther from the research than excerpts.for=${images.for} — all of them: --images-for all` : undefined, r.missing.length ? ` the image is not on this computer: ${few(r.missing)}` : undefined, r.unclipped.length ? ` sources on images with no clip (where the entry is): ${few(r.unclipped)} — strom clips (readers find them)` : undefined, r.later.length ? ` the images of ${r.later.length} source(s) are still being made: the next export has them (all now: strom export gedcom)` : undefined);
42
87
  }
88
+ /** The images of an export: the settings, each overridden by its option. */
89
+ export function imageSettings(ctx, tree, opts) {
90
+ const shared = ctx.settings.shared()?.value;
91
+ if (!shared)
92
+ throw new UsageError("no shared folder with the images is set", { hint: "strom setup" });
93
+ const set = ctx.settings.excerpts(tree.config);
94
+ const quality = opts["images-quality"] === undefined ? set.quality : String(opts["images-quality"]);
95
+ if (!EXCERPT_QUALITIES.includes(quality))
96
+ throw new UsageError(`invalid --images-quality "${quality}"`, { hint: EXCERPT_QUALITIES.join(", ") });
97
+ const scope = opts["images-for"] === undefined ? set.for : String(opts["images-for"]);
98
+ if (!EXCERPT_SCOPES.includes(scope))
99
+ throw new UsageError(`invalid --images-for "${scope}"`, { hint: EXCERPT_SCOPES.join(", ") });
100
+ const mbv = opts["images-max-mb"] === undefined ? set.mb : Number(opts["images-max-mb"]);
101
+ if (!(mbv > 0))
102
+ throw new UsageError("--images-max-mb must be a positive number");
103
+ return { shared, quality: quality, for: scope, mb: mbv };
104
+ }
105
+ /** New excerpts after a session are made for at most so long: what is left, the next export makes. */
106
+ export const AFTER_SESSION_MS = 20_000;
43
107
  /** The GEDCOM files the settings ask for (both by default), from the same evidence. Used after sessions. */
44
108
  export function writeGedcoms(ctx, tree, research) {
45
- return ctx.settings.gedcomFor(tree.config).map((p) => writeTreeGed(tree, { for: p, stromVersion: ctx.settings.stromVersion(tree.config), ...(research ? { research } : {}) }));
109
+ return ctx.settings.gedcomFor(tree.config).map((p) => writeTreeGed(tree, { for: p, stromVersion: ctx.settings.stromVersion(tree.config), ...(research ? { research } : {}), budgetMs: AFTER_SESSION_MS }));
46
110
  }
47
111
  register({
48
112
  path: ["export", "gedcom"],
49
- summary: "Generate the GEDCOM files, validated: output/tree.ged for any program, output/tree-strom.ged for the Strom app",
113
+ summary: "Generate the GEDCOM files, validated: output/tree.ged for any program, output/tree-strom.ged for the Strom app (with the entries' images)",
50
114
  group: "output",
51
115
  tree: true,
52
116
  description: "Both come from the same evidence (GEDCOM 5.5.1, UTF-8). The standard one uses no extension tags; the Strom\n" +
53
117
  "one is shaped to what the Strom app reads (_WITN, _STORY, _FREL/_MREL, a source per page). QUAY is the\n" +
54
118
  "quality of each citation's evidence; leads say so in a note; retracted and disproven facts are left out;\n" +
55
- "of several facts of a kind the best supported comes first. Which files: --for or the setting gedcom.for.",
119
+ "of several facts of a kind the best supported comes first. Which files: --for or the setting gedcom.for.\n" +
120
+ "The Strom file carries each entry cut out of its scan (the sources' clips; the settings excerpts.*, excerpts.for\n" +
121
+ "none: without) and stays out of the tree's history — the history keeps the standard file.",
56
122
  options: [
57
123
  { name: "for", type: "string", value: "<reader>", description: "standard (any program), strom (the Strom app) or both (default: the setting gedcom.for, both)" },
58
124
  { name: "research", type: "string", value: "<G…>", description: "only the people of one research (focus, ancestors, partners)" },
59
125
  { name: "out", type: "string", value: "<file>", description: "write here instead of output/ (with --for standard or strom)" },
126
+ { name: "images", type: "boolean", description: "the Strom file with the entries' images (the default; kept for older scripts)" },
127
+ { name: "images-quality", type: "string", value: "<q>", description: "small (1000 px, grey), normal, sharp (1600 px) — default: the setting excerpts.quality (normal)" },
128
+ { name: "images-for", type: "string", value: "<whose>", description: "none, line, family, connected or all — default: the setting excerpts.for (family: the ancestors and their families)" },
129
+ { name: "images-max-mb", type: "string", value: "<n>", description: "limit of all excerpts together — over it smaller, then the farthest left out (default: the setting excerpts.mb, 200)" },
60
130
  ],
61
- examples: ["strom export gedcom", "strom export gedcom --research G0001", "strom export gedcom --for standard --out ~/Desktop/novak.ged"],
131
+ examples: ["strom export gedcom", "strom export gedcom --research G0001", "strom export gedcom --for standard --out ~/Desktop/novak.ged", "strom export gedcom --for strom --images-for all"],
62
132
  run(ctx, { opts }) {
63
133
  const tree = ctx.tree();
64
134
  const research = typeof opts.research === "string" ? resolveResearch(tree, opts.research) : undefined;
@@ -66,10 +136,20 @@ register({
66
136
  const which = typeof opts.for === "string" ? opts.for.toLowerCase() : undefined;
67
137
  if (which && which !== "both" && !GED_PROFILES.includes(which))
68
138
  throw new UsageError(`invalid --for "${opts.for}"`, { hint: "standard, strom or both" });
139
+ const asked = ["images-max-mb", "images-quality", "images-for"].some((k) => opts[k] !== undefined);
140
+ if ((asked || opts.images) && which === "standard")
141
+ throw new UsageError("images go into the file for the Strom app only", { hint: "strom export gedcom --for strom" });
69
142
  const profiles = which === "both" ? [...GED_PROFILES] : which ? [which] : ctx.settings.gedcomFor(tree.config);
70
143
  if (out && profiles.length > 1)
71
144
  throw new UsageError("--out writes one file", { hint: "add --for standard or --for strom" });
72
- const results = profiles.map((p) => writeTreeGed(tree, { for: p, stromVersion: ctx.settings.stromVersion(tree.config), ...(research ? { research } : {}), ...(out ? { out } : {}) }));
145
+ const images = asked ? imageSettings(ctx, tree, opts) : undefined;
146
+ const results = profiles.map((p) => writeTreeGed(tree, {
147
+ for: p,
148
+ stromVersion: ctx.settings.stromVersion(tree.config),
149
+ ...(research ? { research } : {}),
150
+ ...(out ? { out } : {}),
151
+ ...(images ? { images } : {}),
152
+ }));
73
153
  const bad = results.filter((r) => hasGedErrors(r.findings));
74
154
  if (bad.length) {
75
155
  return {
@@ -78,8 +158,8 @@ register({
78
158
  exitCode: 1,
79
159
  };
80
160
  }
81
- // Commit the output when it lives in the tree and the evidence is intact.
82
- const inTree = results.filter((r) => r.file.startsWith(tree.root + path.sep));
161
+ // Commit the output when it lives in the tree and the evidence is intact (the Strom file, with its images: never).
162
+ const inTree = results.filter((r) => r.for === "standard" && r.file.startsWith(tree.root + path.sep));
83
163
  let committed = false;
84
164
  if (inTree.length && !tree.dryRun && verifyFast(tree).findings.every((f) => f.level !== "error")) {
85
165
  const s0 = results[0].stats;
@@ -88,10 +168,20 @@ register({
88
168
  }
89
169
  const text = lines(...results.map((r) => {
90
170
  const s = r.stats;
91
- return lines(`${ctx.display(r.file)} (${r.for === "strom" ? "for the Strom app" : "standard, any program"}) — ${s.persons} persons · ${s.families} families · ${s.events} facts · ${s.sources} sources${s.skipped ? ` · ${s.skipped} retracted/disproven left out` : ""}`, ` valid GEDCOM 5.5.1${r.findings.length ? ` (${r.findings.length} warning(s))` : ""}`, r.findings.length ? findingRows(r.findings) : undefined);
171
+ return lines(`${ctx.display(r.file)} (${r.for === "strom" ? `for the Strom app${r.images ? ", with images" : ""}` : "standard, any program"}) — ${s.persons} persons · ${s.families} families · ${s.events} facts · ${s.sources} sources${s.skipped ? ` · ${s.skipped} retracted/disproven left out` : ""}`, ` valid GEDCOM 5.5.1${r.findings.length ? ` (${r.findings.length} warning(s))` : ""}`, r.images && r.imageSettings ? imagesLines(r.images, r.imageSettings) : undefined, r.findings.length ? findingRows(r.findings) : undefined);
92
172
  }), committed ? "committed" : undefined);
93
173
  const first = results[0];
94
- return { text, data: { ok: true, file: first.file, stats: first.stats, findings: first.findings, files: results.map((r) => ({ for: r.for, file: r.file, stats: r.stats, findings: r.findings })) } };
174
+ return {
175
+ text,
176
+ data: {
177
+ ok: true,
178
+ file: first.file,
179
+ stats: first.stats,
180
+ findings: first.findings,
181
+ ...(results.find((r) => r.images) ? { images: results.find((r) => r.images).images } : {}),
182
+ files: results.map((r) => ({ for: r.for, file: r.file, stats: r.stats, findings: r.findings })),
183
+ },
184
+ };
95
185
  },
96
186
  }, {
97
187
  path: ["gedcom", "validate"],
@@ -24,7 +24,7 @@ function range(v) {
24
24
  throw new UsageError(`invalid --images "${v}"`, { hint: "e.g. 40-69" });
25
25
  return [Number(m[1]), Number(m[2] ?? m[1])];
26
26
  }
27
- async function pool(items, size, fn) {
27
+ export async function pool(items, size, fn) {
28
28
  const results = new Array(items.length);
29
29
  let next = 0;
30
30
  const worker = async () => {
@@ -0,0 +1,62 @@
1
+ // Readers strom starts for a whole research at once (strom clips, strom
2
+ // transcripts): each a short run of the agent in its own folder outside the
3
+ // tree, allowed to open the views it is given and to write its report into
4
+ // notes/readings — never anything else. strom writes to the research from
5
+ // what the reports say.
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { StromError, UsageError } from "../core/errors.js";
9
+ import { readerSettings } from "../core/reader.js";
10
+ import { RUNNERS } from "../runners/index.js";
11
+ import { which } from "../core/which.js";
12
+ import { permissionPath } from "../agents/files.js";
13
+ /** Set up the readers of a run (--model, --parallel, --minutes; the agent of the tree). */
14
+ export function readers(ctx, tree, shared, kind, first, opts) {
15
+ const agentId = ctx.settings.agent(tree.config).value;
16
+ const runner = RUNNERS[agentId];
17
+ if (!runner)
18
+ throw new UsageError(`no reader for agent "${agentId}" yet`, { hint: `strom ${kind} works with Claude Code: strom ${kind} … --agent claude` });
19
+ if (agentId !== "script" && !which(runner.command, ctx.env))
20
+ throw new StromError(`${runner.command} is not installed`);
21
+ const model = typeof opts.model === "string" ? opts.model : ctx.settings.models(agentId, tree.config).vision;
22
+ const parallel = opts.parallel === undefined ? 3 : Math.max(1, Number(opts.parallel) || 1);
23
+ const minutes = opts.minutes === undefined ? 15 : Number(opts.minutes);
24
+ const day = new Date().toISOString().slice(0, 10);
25
+ const reportsDir = path.join(tree.root, "notes", "readings");
26
+ fs.mkdirSync(reportsDir, { recursive: true });
27
+ let stem = `${day}-${kind}`;
28
+ for (let n = 2; fs.existsSync(path.join(reportsDir, `${stem}-${first}-1.md`)); n++)
29
+ stem = `${day}-${kind}-run${n}`;
30
+ const work = path.join(shared, "cache", "readers");
31
+ const progress = (s) => (ctx.json ? ctx.io.stderr : ctx.io.stdout)(s + "\n");
32
+ let cost = 0;
33
+ const failed = [];
34
+ const read = async (name, views, title, prompt) => {
35
+ const report = path.join(reportsDir, `${stem}-${name}.md`);
36
+ const cwd = path.join(work, `${stem}-${name}`);
37
+ fs.mkdirSync(cwd, { recursive: true });
38
+ const settingsFile = path.join(cwd, "reader-settings.json");
39
+ fs.writeFileSync(settingsFile, JSON.stringify(readerSettings(views, report, permissionPath), null, 2));
40
+ fs.writeFileSync(report, `# ${title}\n\n`);
41
+ const text = prompt(report);
42
+ const { STROM_SESSION: _s, ...env } = ctx.env;
43
+ const r = await runner.run({
44
+ cwd,
45
+ prompt: text,
46
+ kickoff: text,
47
+ env: { ...env, STROM_NONINTERACTIVE: "1", STROM_READER: "1" },
48
+ settingsFile,
49
+ timeoutMs: minutes * 60_000,
50
+ logFile: path.join(tree.root, ".strom", "runs", `${kind}-${stem}-${name}.log`),
51
+ ...(model ? { model } : {}),
52
+ });
53
+ cost += r.metrics.costUsd ?? 0;
54
+ if (r.outcome !== "ok")
55
+ failed.push(`${name} (${r.outcome})`);
56
+ const written = fs.readFileSync(report, "utf8");
57
+ if (!/^##\s/m.test(written) && r.text.trim())
58
+ fs.appendFileSync(report, r.text.trim() + "\n");
59
+ return { report, text: fs.readFileSync(report, "utf8"), outcome: r.outcome };
60
+ };
61
+ return { model, parallel, stem, reportsDir, read, cost: () => cost, failed, progress };
62
+ }
@@ -2,9 +2,13 @@
2
2
  import { register } from "../cli/registry.js";
3
3
  import { lines, table } from "../cli/format.js";
4
4
  import { addPerson, addResearch } from "../core/actions.js";
5
+ import { applyFrontier, researchProposals } from "./session.js";
6
+ import { phrase } from "../core/phrases.js";
5
7
  import { UsageError } from "../core/errors.js";
8
+ import { REVIEW_SCOPES } from "../core/model.js";
6
9
  import { ancestorGenerations, displayName, label, lifespan, parentsOf, resolvePerson } from "../core/people.js";
7
10
  import { foldText } from "../core/text.js";
11
+ import { update } from "../core/records.js";
8
12
  function int(v, name) {
9
13
  if (v === undefined)
10
14
  return undefined;
@@ -137,3 +141,61 @@ register({
137
141
  };
138
142
  },
139
143
  });
144
+ register({
145
+ path: ["review"],
146
+ summary: "Review one person: what the tree already says of them elsewhere, entries to read whole, facts to check — as tasks",
147
+ group: "research",
148
+ tree: true,
149
+ writes: true,
150
+ description: "Sends the research straight to one person. strom looks at what is recorded and proposes the work, at\n" +
151
+ "most one task of each kind: what records, notes and the diary say of them outside their data; entries whose\n" +
152
+ "images are here but were not read whole (transcript, godparents, witnesses, house); facts resting on one\n" +
153
+ "reading; conflicts left open; with --reread, a second reading of what only another model read — after the\n" +
154
+ "agent or the model changed. Birth, parents and the story come as in any research. Run again later: what a\n" +
155
+ "task took up is not proposed again. Nothing is deleted; corrections go through strom with their reasons.",
156
+ args: [{ name: "person", description: "whom (ID or name)", required: true }],
157
+ options: [
158
+ { name: "scope", type: "string", value: "<scope>", description: "person (default), family (with partners and children), line (with the ancestors)" },
159
+ { name: "reread", type: "boolean", description: "also a second reading of what only another model read (the model you read with now: model.vision)" },
160
+ { name: "model", type: "string", value: "<model>", description: "with --reread: this model instead of model.vision" },
161
+ ],
162
+ examples: ["strom review P0001", 'strom review "Josef Novák" --scope family', "strom review P0001 --reread", "strom review P0001 --dry-run"],
163
+ run(ctx, { args, opts }) {
164
+ const tree = ctx.tree();
165
+ const p = resolvePerson(tree, args[0]);
166
+ const scope = opts.scope ?? "person";
167
+ if (!REVIEW_SCOPES.includes(scope))
168
+ throw new UsageError(`--scope must be one of ${REVIEW_SCOPES.join(", ")}`);
169
+ let reread;
170
+ if (opts.reread) {
171
+ reread = opts.model ?? ctx.settings.models(ctx.settings.agent(tree.config).value, tree.config).vision;
172
+ if (!reread)
173
+ throw new UsageError("--reread needs the model to read with", { hint: "strom config set model.vision <model> — or strom review … --reread --model <model>" });
174
+ }
175
+ else if (opts.model)
176
+ throw new UsageError("--model goes with --reread");
177
+ const review = { scope: scope, ...(reread ? { reread } : {}) };
178
+ // one review research per person: again later, it goes on where it was
179
+ const existing = tree.list("research").find((r) => r.direction === "person" && r.focus === p.id && r.review);
180
+ let research;
181
+ if (existing)
182
+ research = update(tree, existing.id, "research", (r) => ({ ...r, state: "active", review }), {
183
+ op: "research.edit",
184
+ summary: `${existing.id} review again: ${scope}${reread ? `, second reading with ${reread}` : ""}`,
185
+ });
186
+ else {
187
+ const r = addResearch(tree, { name: phrase(tree.lang, "review.research", { name: displayName(p) }), focus: p.id, direction: "person" });
188
+ research = update(tree, r.id, "research", (x) => ({ ...x, review }), { op: "research.edit", summary: `${r.id} review: ${scope}${reread ? `, second reading with ${reread}` : ""}` });
189
+ }
190
+ const planned = tree.dryRun ? researchProposals(tree, research).map((x) => x.proposal) : [];
191
+ const created = tree.dryRun ? [] : applyFrontier(tree, research);
192
+ const tasks = created.map((id) => tree.get(id)).filter((t) => !!t);
193
+ const open = tree.list("task").filter((t) => t.research === research.id && ["open", "doing"].includes(t.state));
194
+ // what a session costs here, from those so far: the user decides with the price in view
195
+ const costs = tree.list("session").map((s) => s.metrics?.costUsd).filter((c) => typeof c === "number");
196
+ const avg = costs.length ? costs.reduce((a, b) => a + b, 0) / costs.length : undefined;
197
+ const list = tree.dryRun ? planned.map((t) => ` would add: ${t.level} · ${t.what}`) : tasks.map((t) => ` ${t.id} ${t.level} · ${t.what}`);
198
+ const text = lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined, "", list.length ? `${label(p)} — ${tree.dryRun ? planned.length : tasks.length} new task(s):` : `${label(p)} — nothing new to review${open.length ? `; ${open.length} task(s) of the review still open` : ""}`, ...list, open.length ? `${open.length} open in ${research.id}, about ${open.length} session(s)${avg !== undefined ? ` — sessions here cost $${avg.toFixed(2)} on average` : ""}` : undefined, open.length ? `next strom run --research ${research.id} (the agent alone) · or in a conversation: strom session start --research ${research.id}` : undefined);
199
+ return { text, data: { research, created: tasks, planned, open: open.map((t) => t.id), ...(avg !== undefined ? { avgCostUsd: avg } : {}) } };
200
+ },
201
+ });
@@ -16,7 +16,8 @@ import { DEFAULT_BUDGET, DEFAULT_RUN_MINUTES, Settings } from "../core/config.js
16
16
  import { prependPath } from "../runners/runner.js";
17
17
  import { frontier } from "../core/frontier.js";
18
18
  import { storyProposals } from "../core/stories.js";
19
- import { create, requireRecord, update } from "../core/records.js";
19
+ import { OFF_MAP_HOW, offMapLine, placesOffMap } from "../core/places.js";
20
+ import { create, csvOpt, requireRecord, update } from "../core/records.js";
20
21
  import { taskQueue, waitingLines } from "./tasks.js";
21
22
  import { resolveResearch } from "./research.js";
22
23
  import { writeGedcoms } from "./output.js";
@@ -28,13 +29,13 @@ import { AGENTS, detectAgent, which } from "../core/which.js";
28
29
  import { keepAwake } from "../core/awake.js";
29
30
  import { stromLauncher } from "../core/self.js";
30
31
  import { phrase } from "../core/phrases.js";
31
- import { enterWorker } from "../core/workers.js";
32
- import { acquireLock } from "../core/lock.js";
32
+ import { enterWorker, runAlive, runsAtWork } from "../core/workers.js";
33
33
  import { assertIntact, snapshot, verifyFast } from "../core/integrity.js";
34
34
  import { guard } from "../core/guard.js";
35
35
  import { hasErrors } from "../core/check.js";
36
36
  import { Tree } from "../core/tree.js";
37
- import { browserConnectors, routeOf } from "../core/connector.js";
37
+ import { treeBrowserConnectors } from "../core/connector.js";
38
+ import { reviewProposals } from "../core/review.js";
38
39
  function written(tree) {
39
40
  return lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined);
40
41
  }
@@ -44,21 +45,29 @@ function researchOf(tree, ref) {
44
45
  const active = tree.list("research").filter((r) => r.state === "active");
45
46
  return active.length === 1 ? active[0] : undefined;
46
47
  }
47
- /** Create the frontier's proposed tasks, and the stories due (unless the user said no); returns their IDs. */
48
- export function applyFrontier(tree, research) {
49
- const created = [];
50
- for (const item of frontier(tree, research)) {
51
- if (!item.proposal)
52
- continue;
53
- const t = create(tree, "task", { ...item.proposal, state: "open", origin: "frontier" }, (id) => `+${id} task "${truncate(item.proposal.what, 60)}" (frontier)`);
54
- created.push(t.id);
55
- }
48
+ /** The places of facts not on the map yet, when a session ends: the few with most facts, to complete while they are fresh. */
49
+ function offMap(tree) {
50
+ const off = placesOffMap(tree);
51
+ if (!off.length)
52
+ return undefined;
53
+ return lines(`· for the map — places of facts without coordinates (${off.length}):`, ...off.slice(0, 3).map((m) => ` ${offMapLine(m)}`), ` ${OFF_MAP_HOW} · all: strom place list --off-map`);
54
+ }
55
+ /** The tasks strom would propose for a research now: its frontier, the review of a person, the stories due (unless the user said no). */
56
+ export function researchProposals(tree, research) {
57
+ const out = [];
58
+ for (const item of frontier(tree, research))
59
+ if (item.proposal)
60
+ out.push({ proposal: { ...item.proposal, origin: "frontier" }, kind: "frontier" });
61
+ for (const proposal of reviewProposals(tree, research))
62
+ out.push({ proposal, kind: "review" });
56
63
  if (new Settings(tree.env, {}).stories(tree.config).on)
57
- for (const proposal of storyProposals(tree, research)) {
58
- const t = create(tree, "task", { ...proposal, state: "open", origin: "frontier" }, (id) => `+${id} task "${truncate(proposal.what, 60)}" (story)`);
59
- created.push(t.id);
60
- }
61
- return created;
64
+ for (const proposal of storyProposals(tree, research))
65
+ out.push({ proposal: { ...proposal, origin: "frontier" }, kind: "story" });
66
+ return out;
67
+ }
68
+ /** Create the tasks strom proposes for a research; returns their IDs. */
69
+ export function applyFrontier(tree, research) {
70
+ return researchProposals(tree, research).map(({ proposal, kind }) => create(tree, "task", { ...proposal, state: "open" }, (id) => `+${id} task "${truncate(proposal.what, 60)}" (${kind})`).id);
62
71
  }
63
72
  /** Commit behind the same gate as every write. */
64
73
  export function commitNow(tree, message) {
@@ -139,7 +148,7 @@ register({
139
148
  const geds = tree.dryRun ? [] : writeGedcoms(ctx, tree);
140
149
  const ged = geds[0];
141
150
  return {
142
- text: lines(written(tree), ged ? `GEDCOM ${geds.map((g) => ctx.display(g.file)).join(" · ")}: ${ged.stats.persons} persons, ${ged.stats.families} families` : undefined, newTasks.length ? `${newTasks.length} new task(s) from the research frontier` : undefined,
151
+ text: lines(written(tree), ged ? `GEDCOM ${geds.map((g) => ctx.display(g.file)).join(" · ")}: ${ged.stats.persons} persons, ${ged.stats.families} families` : undefined, newTasks.length ? `${newTasks.length} new task(s) from the research frontier` : undefined, offMap(tree),
143
152
  // In a conversation, the next task is best begun in a fresh context: all of this one is in strom.
144
153
  !s.runner && ctx.env.STROM_NONINTERACTIVE !== "1" ? freshContext(ctx, tree, s) : undefined),
145
154
  data: { session: closed, newTasks, ged: ged?.file, geds: geds.map((g) => g.file) },
@@ -171,7 +180,7 @@ register({
171
180
  throw new UsageError("no sessions yet");
172
181
  const m = s.metrics ?? {};
173
182
  return {
174
- text: lines(`${s.id} ${s.state}${s.task ? ` · task ${s.task}` : ""}${s.runner ? ` · ${s.runner}` : ""} · ${s.started.slice(0, 16).replace("T", " ")}${s.ended ? ` – ${s.ended.slice(11, 16)}` : ""}`, s.summary ? `summary ${s.summary}` : undefined, s.next ? `next ${s.next}` : undefined, Object.keys(m).length
183
+ text: lines(`${s.id} ${s.state}${s.task ? ` · task ${s.task}` : ""}${s.runner ? ` · ${s.runner}` : ""}${s.agent && s.agent !== s.runner ? ` · ${s.agent}` : ""}${s.model ? ` · ${s.model}` : ""} · ${s.started.slice(0, 16).replace("T", " ")}${s.ended ? ` – ${s.ended.slice(11, 16)}` : ""}`, s.summary ? `summary ${s.summary}` : undefined, s.next ? `next ${s.next}` : undefined, Object.keys(m).length
175
184
  ? `metrics ${[
176
185
  m.turns && `${m.turns} turns`,
177
186
  m.inputTokens && `${m.inputTokens} in`,
@@ -373,9 +382,10 @@ register({
373
382
  "the tree's permissions (anything else is denied), or with the terminal (--interactive). Stops at the\n" +
374
383
  "subscription limit, when the queue is empty, or at --until (a session already running finishes, within\n" +
375
384
  "--minutes). A session longer than --minutes is stopped (its task goes back to the queue); a task that comes\n" +
376
- "back twice in a row with nothing recorded is parked. Arguments after -- go to the agent CLI unchanged.",
385
+ "back twice in a row with nothing recorded is parked. --task picks the tasks (one session each, in that order;\n" +
386
+ "one done or held by another agent meanwhile is left out). Arguments after -- go to the agent CLI unchanged.",
377
387
  options: [
378
- { name: "task", type: "string", value: "<T…>", description: "work on this task (default: the next one)" },
388
+ { name: "task", type: "string", multiple: true, value: "<T…>", description: "work on these tasks, in this order (repeatable or T0003,T0007; default: the next one of the queue)" },
379
389
  { name: "research", type: "string", value: "<G…>", description: "only tasks of this research" },
380
390
  { name: "max", type: "string", value: "<n>", description: "at most n sessions (default 1; with --until, no limit)" },
381
391
  { name: "until", type: "string", value: "<HH:MM>", description: "start no session after this time" },
@@ -384,7 +394,7 @@ register({
384
394
  { name: "model", type: "string", value: "<model>", description: "model of the main agent (default: model.lead)" },
385
395
  { name: "interactive", type: "boolean", description: "give the agent the terminal (you can talk to it)" },
386
396
  ],
387
- examples: ["strom run", "strom run --max 3 --until 23:00", "strom run --interactive", "strom run --agent codex", "strom run --minutes 30 -- --add-dir ~/Scans"],
397
+ examples: ["strom run", "strom run --max 3 --until 23:00", "strom run --task T0003,T0007", "strom run --interactive", "strom run --agent codex", "strom run --minutes 30 -- --add-dir ~/Scans"],
388
398
  run: async (ctx, { opts, extra }) => {
389
399
  const root = ctx.tree().root;
390
400
  const treeCfg = ctx.tree().config;
@@ -396,20 +406,25 @@ register({
396
406
  if (runnerId !== "script" && !which(runner.command, ctx.env))
397
407
  throw new StromError(`${runner.command} is not installed`, { hint: runnerId === "claude" ? "install Claude Code: https://claude.com/claude-code — then log in once by running: claude" : `install ${runner.command}` });
398
408
  const until = parseUntil(opts.until);
399
- const max = opts.max === undefined ? (until ? Infinity : 1) : Number(opts.max);
409
+ // Tasks the user picked: one session each, in their order (then the queue, when --max asks for more).
410
+ const picked = csvOpt(opts.task).map((id) => requireRecord(Tree.open(root, ctx.env), id, "task").id);
411
+ const max = opts.max === undefined ? (picked.length ? picked.length : until ? Infinity : 1) : Number(opts.max);
400
412
  if (max !== Infinity && (!Number.isInteger(max) || max < 1))
401
413
  throw new UsageError("--max must be a positive number");
402
414
  const minutes = ctx.settings.number("run.minutes", treeCfg, DEFAULT_RUN_MINUTES);
403
415
  const budget = ctx.settings.number("brief.budget", treeCfg, DEFAULT_BUDGET);
404
- const release = acquireLock(path.join(root, ".strom", "run.lock"), { owner: "strom run", waitMs: 0, staleMs: 24 * 3600_000 });
416
+ // Each run has a name of its own (as a conversation does): several work side by side, never on one task.
417
+ const worker = `run-${process.pid}-${Date.now().toString(36)}`;
418
+ const runEnv = { ...ctx.env, STROM_WORKER: worker };
405
419
  // Present in the tree for the others at work (conversations with agents): strom shows who works.
406
- const leave = enterWorker(root, "run", `${PROFILES[runnerId]?.name ?? runnerId} on its own`);
420
+ const beside = runsAtWork(root);
421
+ const leave = enterWorker(root, worker, `${PROFILES[runnerId]?.name ?? runnerId} on its own`);
407
422
  const stopAwake = keepAwake(ctx.env);
408
423
  // Progress goes to stderr when stdout carries JSON.
409
424
  const out = (s) => (ctx.json ? ctx.io.stderr : ctx.io.stdout)(s + "\n");
410
425
  const report = [];
411
426
  // Why the run stopped: a code for the exit status and for data, words for the user (their language).
412
- const lang = Tree.open(root, ctx.env).lang;
427
+ const lang = Tree.open(root, runEnv).lang;
413
428
  let stopCode = "done";
414
429
  let stopValues = {};
415
430
  // Ctrl-C, a closed terminal, a shutdown: stop the agent, close its session, give the task back.
@@ -423,33 +438,34 @@ register({
423
438
  const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
424
439
  for (const sig of signals)
425
440
  process.on(sig, onSignal);
441
+ if (beside.length)
442
+ out(ui(lang, "ui.run.others", { since: new Date(beside[0].since).toLocaleTimeString(lang, { hour: "2-digit", minute: "2-digit" }) }));
426
443
  try {
427
444
  {
428
- const tree = Tree.open(root, ctx.env);
445
+ const tree = Tree.open(root, runEnv);
429
446
  assertIntact(tree);
430
447
  const files = syncAgentFiles(tree);
431
448
  if (files.length)
432
449
  tree.withTreeLock(() => tree.commit(`Agent instructions: ${files.join(", ")}`, files));
433
- // A session of a run that is no longer running (we hold the run lock): the run was
434
- // killed or the computer went down. Close it; its task goes back to the queue.
435
- for (const s of openSessions(tree).filter((x) => x.runner)) {
450
+ // A session of a run that is no longer at work: the run was killed or the computer went down.
451
+ // Close it; its task goes back to the queue. (The sessions of other runs at work stay theirs.)
452
+ for (const s of openSessions(tree).filter((x) => x.runner && !runAlive(root, x))) {
436
453
  closeSession(tree, s, { summary: phrase(tree.lang, "session.run"), next: "", interrupted: true, endedBy: "run" });
437
454
  commitNow(tree, `${s.id} interrupted: its run had stopped`);
438
455
  out(ui(lang, "ui.run.gone", { session: s.id, back: s.task ? ui(lang, "ui.run.back", { task: s.task }) : "" }));
439
456
  }
440
457
  }
441
- const bin = shimDir(Tree.open(root, ctx.env));
458
+ const bin = shimDir(Tree.open(root, runEnv));
442
459
  // How the agent is let work, said once: browser tools for whose sites, and a loosening the user chose.
443
- const browser = runnerId === "claude" ? browserConnectors(ctx.env, ctx.settings.shared()?.value) : [];
460
+ // Browser tools only for the archives this tree works with (the plugins folder is shared by every tree).
461
+ const browser = runnerId === "claude" ? treeBrowserConnectors(Tree.open(root, runEnv), ctx.settings.shared()?.value) : [];
444
462
  const permissions = ctx.settings.agentPermissions();
445
- for (const c of browser) {
446
- const chosen = routeOf(ctx.env, c).chosen;
447
- out(`· ${c.name}: images through your browser — the agent gets browser tools for ${c.manifest.hosts.join(", ")}${chosen ? ` (chosen ${chosen.at.slice(0, 10)} ${chosen.by})` : ""}; Chrome with the Claude extension must be running`);
448
- }
463
+ for (const c of browser)
464
+ out(ui(lang, "ui.run.browser", { name: c.manifest.title ?? c.name, hosts: c.manifest.hosts.join(", ") }));
449
465
  if (browser.length && /haiku/i.test(models.lead ?? ""))
450
- out(` note: browser tools need a model that can review its actions — Sonnet or Opus, not ${models.lead}: strom config set model.lead sonnet`);
466
+ out(ui(lang, "ui.run.browser.model", { model: models.lead }));
451
467
  if (permissions === "full")
452
- out("· permissions: full — the agent does everything but what the tree's permissions deny (back: strom config set agent.permissions auto)");
468
+ out(ui(lang, "ui.run.full"));
453
469
  for (let i = 0; i < max; i++) {
454
470
  if (stop.signal.aborted) {
455
471
  stopCode = "user";
@@ -459,12 +475,23 @@ register({
459
475
  stopCode = "time";
460
476
  break;
461
477
  }
462
- const tree = Tree.open(root, ctx.env); // fresh: the agent wrote from other processes
478
+ const tree = Tree.open(root, runEnv); // fresh: the agent wrote from other processes
463
479
  const research = researchOf(tree, opts.research);
464
480
  // Other agents may be at work in this tree (conversations): never their tasks.
465
481
  const others = othersAtWork(tree, tree.env, runnerId).tasks;
466
- const free = () => taskQueue(tree, { ...(research ? { research: research.id } : {}), strategy: ctx.settings.strategy(tree.config) }).find((t) => !others.has(t.id));
467
- let task = typeof opts.task === "string" && i === 0 ? requireRecord(tree, opts.task, "task") : free();
482
+ // Working alone: now and then a story's turn (in a conversation the user leads).
483
+ const free = () => taskQueue(tree, { ...(research ? { research: research.id } : {}), strategy: ctx.settings.strategy(tree.config), storyTurn: true }).find((t) => !others.has(t.id));
484
+ let task;
485
+ if (i < picked.length) {
486
+ task = tree.get(picked[i]);
487
+ const left = !task || !["open", "doing", "parked", "waiting"].includes(task.state) ? task?.state : others.has(task.id) ? "held" : undefined;
488
+ if (left) {
489
+ out(ui(lang, left === "held" ? "ui.run.skip.held" : "ui.run.skip", { task: picked[i] }));
490
+ continue;
491
+ }
492
+ }
493
+ else
494
+ task = free();
468
495
  if (!task && research) {
469
496
  applyFrontier(tree, research);
470
497
  task = free();
@@ -473,7 +500,7 @@ register({
473
500
  stopCode = "empty";
474
501
  break;
475
502
  }
476
- const session = startSession(tree, { task, ...(research ? { research: research.id } : {}), runner: runnerId });
503
+ const session = startSession(tree, { task, ...(research ? { research: research.id } : {}), runner: runnerId, ...(models.lead ? { model: models.lead } : {}) });
477
504
  commitNow(tree, `${session.id} session started on ${task.id}`);
478
505
  const brief = buildBrief(tree, { task, session, budget, shared: ctx.settings.shared()?.value });
479
506
  const briefFile = path.join(root, ".strom", "briefs", `${session.id}.md`);
@@ -485,7 +512,7 @@ register({
485
512
  brief.text;
486
513
  const kickoff = `You are the researcher in strom session ${session.id}. Run \`strom brief\` and follow it; work only through strom; finish with \`strom session close\`.`;
487
514
  const env = {
488
- ...prependPath(ctx.env, bin),
515
+ ...prependPath(runEnv, bin),
489
516
  STROM_SESSION: session.id,
490
517
  ...(opts.interactive ? {} : { STROM_NONINTERACTIVE: "1" }),
491
518
  };
@@ -508,7 +535,7 @@ register({
508
535
  signal: stop.signal,
509
536
  });
510
537
  // Close what the agent left open, record the metrics, export, commit.
511
- const after = Tree.open(root, ctx.env);
538
+ const after = Tree.open(root, runEnv);
512
539
  let s = after.get(session.id);
513
540
  if (s.state === "open")
514
541
  s = closeSession(after, s, {
@@ -520,6 +547,9 @@ register({
520
547
  });
521
548
  else
522
549
  update(after, s.id, "session", (x) => ({ ...x, metrics: result.metrics }), { op: "session.metrics", summary: `${s.id} metrics` });
550
+ // the model it really ran on, as the agent said (an alias such as "opus" names no version)
551
+ if (result.metrics.model && s.model !== result.metrics.model)
552
+ s = update(after, s.id, "session", (x) => ({ ...x, model: result.metrics.model }), { op: "session.metrics", summary: `${s.id} model ${result.metrics.model}` });
523
553
  // A task that comes back again and again without anything being recorded
524
554
  // is stuck: park it (the user decides), work on the next one.
525
555
  const back = after.get(task.id);
@@ -575,10 +605,9 @@ register({
575
605
  process.off(sig, onSignal);
576
606
  stopAwake();
577
607
  leave();
578
- release();
579
608
  }
580
609
  // What the research now waits for from the user who started it.
581
- const waiting = waitingLines(Tree.open(root, ctx.env), { shared: ctx.settings.shared()?.value, display: (p) => ctx.display(p) });
610
+ const waiting = waitingLines(Tree.open(root, runEnv), { shared: ctx.settings.shared()?.value, display: (p) => ctx.display(p) });
582
611
  const reason = ui(lang, `ui.run.stop.${stopCode}`, stopValues);
583
612
  return {
584
613
  text: lines(ui(lang, "ui.run.summary", { n: report.length, reason }), waiting ? `\n${waiting}` : undefined),