strom-research 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +3 -0
  2. package/assets/lang/cs.json +117 -2
  3. package/assets/lang/de.json +117 -2
  4. package/assets/method/core.md +8 -0
  5. package/assets/method/narrate.md +5 -1
  6. package/assets/plugins/gates/claude-usage/gate.ts +2 -1
  7. package/dist/agents/files.js +6 -4
  8. package/dist/cli/main.js +21 -3
  9. package/dist/cli/menu-links.js +35 -1
  10. package/dist/cli/menu-research.js +41 -2
  11. package/dist/cli/menu-settings.js +17 -1
  12. package/dist/cli/menu.js +19 -8
  13. package/dist/cli/ui.js +57 -1
  14. package/dist/commands/guide.js +6 -1
  15. package/dist/commands/links.js +39 -3
  16. package/dist/commands/output.js +4 -2
  17. package/dist/commands/pack.js +21 -18
  18. package/dist/commands/research.js +42 -1
  19. package/dist/commands/session.js +210 -11
  20. package/dist/commands/setup.js +3 -0
  21. package/dist/commands/start.js +6 -3
  22. package/dist/commands/story.js +47 -6
  23. package/dist/commands/tasks.js +50 -12
  24. package/dist/commands/trees.js +82 -2
  25. package/dist/core/changelog.js +223 -0
  26. package/dist/core/clock.js +20 -0
  27. package/dist/core/config.js +2 -0
  28. package/dist/core/connector.js +25 -1
  29. package/dist/core/directions.js +41 -0
  30. package/dist/core/edge.js +296 -0
  31. package/dist/core/frontier.js +12 -4
  32. package/dist/core/kin.js +40 -1
  33. package/dist/core/links.js +17 -4
  34. package/dist/core/live.js +428 -52
  35. package/dist/core/pack.js +31 -0
  36. package/dist/core/phrases.js +61 -0
  37. package/dist/core/queue.js +15 -4
  38. package/dist/core/relocate.js +4 -4
  39. package/dist/core/session.js +8 -2
  40. package/dist/core/stromapp.js +22 -0
  41. package/dist/core/trash.js +72 -0
  42. package/dist/core/tree.js +1 -1
  43. package/dist/core/workers.js +28 -3
  44. package/dist/gedcom/export.js +105 -1
  45. package/dist/gedcom/validate.js +7 -1
  46. package/dist/runners/claude.js +2 -1
  47. package/dist/runners/jsonl.js +2 -2
  48. package/dist/runners/runner.js +16 -3
  49. package/dist/runners/script.js +2 -1
  50. package/package.json +1 -1
@@ -11,6 +11,7 @@ import { lines, moreLine, paginate, runs, table, truncate } from "../cli/format.
11
11
  import { UsageError } from "../core/errors.js";
12
12
  import { TASK_LEVELS } from "../core/model.js";
13
13
  import { create, csvOpt, listOpt, normId, requireRecord, update } from "../core/records.js";
14
+ import { directionOf, ofResearch, scopes } from "../core/directions.js";
14
15
  import { resolvePerson } from "../core/people.js";
15
16
  import { foldText } from "../core/text.js";
16
17
  import { now, typeOfId } from "../core/tree.js";
@@ -19,6 +20,8 @@ import { makeNote } from "../core/actions.js";
19
20
  import { phrase } from "../core/phrases.js";
20
21
  import { readJsonLines } from "../core/json.js";
21
22
  import { lacksImages, rankTasks } from "../core/queue.js";
23
+ import { joiningHypotheses, offTree } from "../core/kin.js";
24
+ import { aboutPeople } from "../core/directions.js";
22
25
  import { clipNote, inboxFolderFor, parseImageList, transcriptNote } from "../core/media.js";
23
26
  function written(tree) {
24
27
  return lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined);
@@ -35,11 +38,17 @@ function subjects(tree, refs) {
35
38
  return resolvePerson(tree, r).id;
36
39
  });
37
40
  }
38
- function defaultResearch(tree, ref) {
41
+ /** A new task's research: the one named, else the session's that adds it, else the one active research its people belong to. */
42
+ function defaultResearch(tree, ref, subject = []) {
39
43
  if (typeof ref === "string")
40
44
  return resolveResearch(tree, ref).id;
45
+ const session = typeOfId(tree.actor) === "session" ? tree.get(tree.actor) : undefined;
46
+ if (session?.research)
47
+ return session.research;
41
48
  const active = tree.list("research").filter((r) => r.state === "active");
42
- return active.length === 1 ? active[0].id : undefined;
49
+ if (active.length === 1)
50
+ return active[0].id;
51
+ return directionOf(tree, { subject }, scopes(tree).filter((s) => s.research.state === "active"));
43
52
  }
44
53
  /** What the research waits for from outside — mostly from the user: scans to download, a book to find, a reply. */
45
54
  export function waitingForUser(tree) {
@@ -75,12 +84,29 @@ export function taskQueue(tree, filter = {}) {
75
84
  return rankedQueue(tree, filter).map((r) => r.task);
76
85
  }
77
86
  export function rankedQueue(tree, filter = {}) {
87
+ const scope = filter.research ? scopes(tree).find((s) => s.research.id === filter.research) : undefined;
78
88
  const tasks = tree
79
89
  .list("task")
80
- .filter((t) => !filter.research || !t.research || t.research === filter.research)
90
+ .filter((t) => !filter.research || (scope ? ofResearch(tree, t, scope) : t.research === filter.research))
81
91
  .filter((t) => !filter.level || t.level === filter.level);
82
92
  return rankTasks(tree, tasks, filter.strategy, { storyTurn: filter.storyTurn });
83
93
  }
94
+ /**
95
+ * What an agent is told of a task about people nothing links to the tree (core/kin.ts offTree): it waits out of the
96
+ * queue until the user asks for it or the hypothesis that would join them is decided; the task that tests that
97
+ * hypothesis names it. Undefined for a task of the tree.
98
+ */
99
+ export function offTreeLine(tree, t, starting = false) {
100
+ if (!offTree(tree)(t))
101
+ return undefined;
102
+ const joins = joiningHypotheses(tree, aboutPeople(tree, t.subject));
103
+ const first = joins.length
104
+ ? `test first what would join them: ${joins.map((h) => `${h.id} "${truncate(h.question, 70)}"`).join("; ")} — a task that tests it names it (--about ${joins[0].id})`
105
+ : "first a link to a person of the tree: a hypothesis (strom hypothesis add … --about P… of the tree) and a task that tests it, naming it (--about H…)";
106
+ return starting
107
+ ? `⚠ ${t.id} is about people nothing links to the tree yet: go on only if the user asked for this work. Else close the session (strom session close --continue …) and ${first}`
108
+ : `· ${t.id} is about people nothing links to the tree yet: it waits out of the queue until the user asks for it or a hypothesis joins them — ${first}`;
109
+ }
84
110
  function taskLine(t) {
85
111
  return [t.id, `p${t.priority}`, t.level, t.state === "open" ? "" : `[${t.state}]`, truncate(t.what, 70)];
86
112
  }
@@ -183,7 +209,7 @@ register({
183
209
  throw new UsageError("--priority must be 1..5");
184
210
  const where = listOpt(opts.where).map((w) => (/^[Bb]\d+$/.test(w) ? requireRecord(tree, w, "recordset").id : w));
185
211
  const subject = subjects(tree, listOpt(opts.about));
186
- const research = defaultResearch(tree, opts.research);
212
+ const research = defaultResearch(tree, opts.research, subject);
187
213
  // The same work again: an open task of the same person and level that reads the same (or nearly, in the same
188
214
  // books) — not added; what is new goes into that one.
189
215
  if (!opts.anyway) {
@@ -223,7 +249,7 @@ register({
223
249
  // it is known — not in a session spent finding out that there is nothing to read.
224
250
  const noImages = lacksImages(tree, t);
225
251
  return {
226
- text: lines(written(tree), ...similar.map((x) => `⚠ similar ${x.state} task ${x.id} "${truncate(x.what, 60)}" — if it is the same, keep one: strom task drop ${t.id} --reason "duplicate of ${x.id}" and strom task edit ${x.id} --where … --note "…"`), noImages ? `· no images for it here yet — if the user has to download them, say which now: strom task wait ${t.id} --images B…:<numbers> --on "<the book, its link, which images>"` : undefined),
252
+ text: lines(written(tree), ...similar.map((x) => `⚠ similar ${x.state} task ${x.id} "${truncate(x.what, 60)}" — if it is the same, keep one: strom task drop ${t.id} --reason "duplicate of ${x.id}" and strom task edit ${x.id} --where … --note "…"`), offTreeLine(tree, t), noImages ? `· no images for it here yet — if the user has to download them, say which now: strom task wait ${t.id} --images B…:<numbers> --on "<the book, its link, which images>"` : undefined),
227
253
  data: { task: t, similar: similar.map((x) => x.id), needsImages: noImages },
228
254
  };
229
255
  },
@@ -242,8 +268,13 @@ register({
242
268
  const strategy = ctx.settings.strategy(tree.config);
243
269
  const queue = rankedQueue(tree, { ...(research ? { research } : {}), ...(opts.level ? { level: String(opts.level) } : {}), strategy });
244
270
  const first = queue[0];
245
- if (!first)
246
- return { text: 'no open tasks → strom research show <G…> to see what is missing, then strom task add', data: { task: null } };
271
+ if (!first) {
272
+ const held = tree.list("task").filter(offTree(tree)).filter((t) => t.state === "open").length;
273
+ const text = held
274
+ ? `no open task of the tree — ${held} wait about people nothing links to it yet (strom task list --off-tree): test the hypothesis that would join them, or ask the user`
275
+ : "no open tasks → strom research show <G…> to see what is missing, then strom task add";
276
+ return { text, data: { task: null, offTree: held } };
277
+ }
247
278
  const t = first.task;
248
279
  return {
249
280
  text: lines(taskDetail(tree, t), "", `first because: ${first.why} (queue: ${strategy})`, `start it: strom task start ${t.id}`),
@@ -263,6 +294,7 @@ register({
263
294
  { name: "research", type: "string", value: "<G…>", description: "only this research" },
264
295
  { name: "level", type: "string", value: "<level>", description: "only this level" },
265
296
  { name: "about", type: "string", value: "<who>", description: "only tasks about this person/record (also X…, H…)" },
297
+ { name: "off-tree", type: "boolean", description: "the open tasks that wait out of the queue: about people nothing links to the tree yet" },
266
298
  { name: "full", type: "boolean", description: "--json: whole records instead of one row each" },
267
299
  ],
268
300
  run(ctx, { opts }) {
@@ -270,7 +302,10 @@ register({
270
302
  const research = typeof opts.research === "string" ? resolveResearch(tree, opts.research).id : undefined;
271
303
  let all;
272
304
  const why = new Map();
273
- if (!opts.state) {
305
+ const off = offTree(tree);
306
+ if (opts["off-tree"])
307
+ all = tree.list("task").filter((t) => t.state === "open" && off(t) && (!opts.level || t.level === opts.level));
308
+ else if (!opts.state) {
274
309
  const ranked = rankedQueue(tree, { ...(research ? { research } : {}), ...(opts.level ? { level: String(opts.level) } : {}), strategy: ctx.settings.strategy(tree.config) });
275
310
  for (const r of ranked)
276
311
  why.set(r.task.id, r.why);
@@ -278,8 +313,9 @@ register({
278
313
  }
279
314
  else {
280
315
  all = tree.list("task").filter((t) => opts.state === "all" || t.state === opts.state);
281
- if (research)
282
- all = all.filter((t) => t.research === research);
316
+ const scope = research ? scopes(tree).find((s) => s.research.id === research) : undefined;
317
+ if (scope)
318
+ all = all.filter((t) => ofResearch(tree, t, scope));
283
319
  if (opts.level)
284
320
  all = all.filter((t) => t.level === opts.level);
285
321
  }
@@ -288,8 +324,10 @@ register({
288
324
  all = all.filter((t) => t.subject.includes(id));
289
325
  }
290
326
  const page = paginate(all, ctx.limit, ctx.page);
327
+ // the queue says what waits out of it for a link to the tree
328
+ const held = !opts.state && !opts["off-tree"] && !opts.about ? tree.list("task").filter((t) => t.state === "open" && off(t)).length : 0;
291
329
  return {
292
- text: all.length ? lines(table(page.items.map(taskLine)), moreLine(page, "strom task list")) : "no tasks",
330
+ text: lines(all.length ? lines(table(page.items.map(taskLine)), moreLine(page, "strom task list")) : "no tasks", held ? `${held} more wait out of the queue: about people nothing links to the tree yet (strom task list --off-tree)` : undefined),
293
331
  data: { total: page.total, tasks: opts.full ? page.items : page.items.map((t) => ({ id: t.id, priority: t.priority, level: t.level, state: t.state, what: t.what, subject: t.subject, ...(why.has(t.id) ? { why: why.get(t.id) } : {}) })) },
294
332
  };
295
333
  },
@@ -351,7 +389,7 @@ register({
351
389
  summary: `${id} ${fields.map((f) => (f === "doneWhen" ? "done-when" : f === "subject" ? "about" : f)).join(", ")}${change.where ? ` → ${truncate(change.where.join("; "), 50)}` : ""}`,
352
390
  reason: opts.reason,
353
391
  });
354
- return { text: written(tree), data: { task: t } };
392
+ return { text: lines(written(tree), change.subject ? offTreeLine(tree, t) : undefined), data: { task: t } };
355
393
  },
356
394
  }, {
357
395
  path: ["task", "start"],
@@ -6,13 +6,19 @@ import { register } from "../cli/registry.js";
6
6
  import { Context } from "../cli/context.js";
7
7
  import { lines, table } from "../cli/format.js";
8
8
  import { Tree } from "../core/tree.js";
9
- import { safeFolderName } from "../core/text.js";
9
+ import { foldText, safeFolderName } from "../core/text.js";
10
10
  import { isValidLang, langName } from "../core/lang.js";
11
- import { UsageError } from "../core/errors.js";
11
+ import { NeedsConsentError, UsageError } from "../core/errors.js";
12
12
  import { verifyFast } from "../core/integrity.js";
13
13
  import * as git from "../core/git.js";
14
14
  import { ensureShared, setTreeSetting } from "./setup.js";
15
15
  import { syncAgentFiles } from "../agents/files.js";
16
+ import { ui } from "../cli/ui.js";
17
+ import { atWork } from "../core/relocate.js";
18
+ import { moveToTrash } from "../core/trash.js";
19
+ import { stopLive } from "../core/live.js";
20
+ import { onlyItsFiles, packPlan } from "../core/pack.js";
21
+ import { defaultPackFile, packTree } from "./pack.js";
16
22
  register({
17
23
  path: ["init"],
18
24
  summary: "Create a family tree (a folder with its own history) to hold researches",
@@ -81,6 +87,80 @@ register({
81
87
  return { text: `now working on "${tree.config.name}" (${ctx.display(root)})`, data: { name: tree.config.name, root } };
82
88
  },
83
89
  });
90
+ register({
91
+ path: ["trees", "remove"],
92
+ summary: "Take a family tree off this computer — into the system's trash, a backup offered first (the person runs this)",
93
+ group: "research",
94
+ description: "Offers a backup first (strom pack --all-images: it comes back with strom unpack), then the images no other tree\n" +
95
+ "uses; the person types the tree's name to confirm. The tree's folder goes into the system's trash (never deleted\n" +
96
+ "for good), so do the images they chose. Never while somebody works on it; only the person at the computer —\n" +
97
+ "never an agent.",
98
+ args: [{ name: "tree", description: "tree name or folder", required: true }],
99
+ examples: ['strom trees remove "Novákovi"'],
100
+ async run(ctx, { args }) {
101
+ const lang = ctx.uiLang();
102
+ const probe = Context.fromOptions({ env: ctx.env, cwd: ctx.cwd, io: ctx.io, json: ctx.json, values: { tree: args[0] } });
103
+ const root = probe.locateTree();
104
+ if (!ctx.interactive)
105
+ throw new NeedsConsentError([{ key: "remove", kind: "consent", question: ui(lang, "ui.remove.person"), set: `strom trees remove "${root}"` }]);
106
+ const tree = Tree.open(root, ctx.env);
107
+ const name = tree.config.name;
108
+ // the Strom app following it live is no work: its bridge only reads, and stops before the tree goes
109
+ if (atWork(root, { bridge: false }))
110
+ throw new UsageError(ui(lang, "ui.remove.busy", { name }), { hint: "strom" });
111
+ const shared = ctx.settings.shared().value;
112
+ const others = ctx.knownTrees().map((k) => k.root).filter((r) => path.resolve(r) !== path.resolve(root));
113
+ const only = onlyItsFiles(root, others, shared);
114
+ const onlyBytes = only.reduce((s, f) => s + f.size, 0);
115
+ const mb = (b) => (b / 1e6 < 10 ? (b / 1e6).toFixed(1) : Math.round(b / 1e6).toString());
116
+ ctx.io.stdout(ui(lang, "ui.remove.what", { name, root: ctx.display(root), persons: tree.list("person").length }) + "\n");
117
+ // a way back first
118
+ let backup;
119
+ const all = packPlan(tree, shared, true).media.reduce((s, m) => s + m.size, 0);
120
+ if (await ctx.confirm(ui(lang, "ui.remove.backup", { mb: mb(all) }), true)) {
121
+ const out = defaultPackFile(ctx, tree);
122
+ backup = packTree(tree, shared, out, true).file;
123
+ ctx.io.stdout(ui(lang, "ui.remove.backup.done", { file: ctx.display(backup) }) + "\n");
124
+ }
125
+ const images = only.length > 0 && (await ctx.confirm(ui(lang, "ui.remove.images", { n: only.length, mb: mb(onlyBytes) }), backup !== undefined));
126
+ const typed = (await ctx.ask(ui(lang, "ui.remove.type", { name }))).trim();
127
+ if (!typed || foldText(typed) !== foldText(name))
128
+ return { text: ui(lang, "ui.remove.cancelled"), data: { removed: false, ...(backup ? { backup } : {}) } };
129
+ if (atWork(root, { bridge: false }))
130
+ throw new UsageError(ui(lang, "ui.remove.busy", { name }), { hint: "strom" });
131
+ const followed = stopLive(root, "the family tree is taken off this computer");
132
+ moveToTrash(root, ctx.env, path.basename(root));
133
+ let moved = 0;
134
+ if (images) {
135
+ // the images together, one folder in the trash named after the tree
136
+ const folder = path.join(shared, `.${safeFolderName(name) || "tree"}-${Date.now()}`);
137
+ for (const f of only) {
138
+ const to = path.join(folder, ...f.file.split("/"));
139
+ fs.mkdirSync(path.dirname(to), { recursive: true });
140
+ fs.renameSync(path.join(shared, ...f.file.split("/")), to);
141
+ moved++;
142
+ }
143
+ moveToTrash(folder, ctx.env, ui(lang, "ui.remove.images.folder", { name }));
144
+ }
145
+ const cfg = ctx.settings.config;
146
+ if (cfg.extraTrees)
147
+ cfg.extraTrees = cfg.extraTrees.filter((r) => path.resolve(r) !== path.resolve(root));
148
+ // the tree worked on went: the next one known is worked on now, never a menu with none chosen
149
+ let next;
150
+ if (cfg.currentTree && path.resolve(cfg.currentTree) === path.resolve(root)) {
151
+ const left = ctx.knownTrees().filter((k) => path.resolve(k.root) !== path.resolve(root));
152
+ if (left.length) {
153
+ cfg.currentTree = left[0].root;
154
+ next = left[0].name;
155
+ }
156
+ else
157
+ delete cfg.currentTree;
158
+ }
159
+ ctx.settings.save();
160
+ const text = lines(ui(lang, "ui.remove.done", { name }), followed ? ui(lang, "ui.remove.live") : undefined, moved ? ui(lang, "ui.remove.images.done", { n: moved, mb: mb(onlyBytes) }) : undefined, backup ? ui(lang, "ui.remove.backup.back", { file: ctx.display(backup) }) : undefined, ui(lang, backup ? "ui.remove.trash" : "ui.remove.trash.alone"), next ? ui(lang, "ui.remove.next", { name: next }) : undefined);
161
+ return { text, data: { removed: true, root, images: moved, ...(backup ? { backup } : {}), ...(next ? { current: cfg.currentTree } : {}) } };
162
+ },
163
+ });
84
164
  register({
85
165
  path: ["status"],
86
166
  summary: "State of the current tree: researches, data, checks, recent changes",
@@ -0,0 +1,223 @@
1
+ // What one commit of a research saved, as a person reads it — in the research
2
+ // language, records by their names: "New person: Jan Novák (*1905) [P0001]",
3
+ // "Downloaded 7 images: Kniha N 1850 (images 16–22)". Made from the operations
4
+ // the commit logged (data/ops/*.jsonl, each line one operation); a commit
5
+ // without them (the readers' notes) by its subject. For the Strom app (the
6
+ // bridge's /log and change events): strom's own commit subjects are English
7
+ // and name records by their IDs.
8
+ import { displayName, lifespan } from "./people.js";
9
+ import { ownerName, cardFact } from "./overview.js";
10
+ import { phrase } from "./phrases.js";
11
+ import { eventName, humanDate, humanPlace, humanTask } from "../cli/human.js";
12
+ /** Operations nobody needs to read: what another line says already, or bookkeeping. */
13
+ const QUIET = new Set(["session.metrics", "task.start", "task.continue", "session.note"]);
14
+ /** The one-line form of an operation, by its kind: {who} a person or family, {name} the record. */
15
+ const SAID = {
16
+ "person.add": "log.person.add",
17
+ "person.name": "log.person.name",
18
+ "person.edit": "log.person.edit",
19
+ "person.cite": "log.person.cite",
20
+ "person.retract": "log.person.retract",
21
+ "family.add": "log.family.add",
22
+ "family.edit": "log.family.edit",
23
+ "family.cite": "log.family.cite",
24
+ "source.add": "log.source.add",
25
+ "source.edit": "log.source.edit",
26
+ "recordset.add": "log.recordset.add",
27
+ "recordset.edit": "log.recordset.edit",
28
+ "recordset.calibrate": "log.recordset.calibrate",
29
+ "task.add": "log.task.add",
30
+ "task.done": "log.task.done",
31
+ "task.park": "log.task.park",
32
+ "task.drop": "log.task.drop",
33
+ "task.wait": "log.task.wait",
34
+ "task.wake": "log.task.wake",
35
+ "task.edit": "log.task.edit",
36
+ "session.start": "log.session.start",
37
+ "session.close": "log.session.close",
38
+ "lesson.add": "log.lesson.add",
39
+ "hypothesis.add": "log.hypothesis.add",
40
+ "hypothesis.argue": "log.hypothesis.argue",
41
+ "hypothesis.decide": "log.hypothesis.decide",
42
+ "conflict.add": "log.conflict.add",
43
+ "conflict.resolve": "log.conflict.resolve",
44
+ "place.add": "log.place.add",
45
+ "place.edit": "log.place.edit",
46
+ "place.jurisdiction": "log.place.edit",
47
+ "repository.add": "log.repository.add",
48
+ "repository.edit": "log.repository.edit",
49
+ "story.set": "log.story.set",
50
+ "story.approve": "log.story.approve",
51
+ "note.add": "log.note.add",
52
+ "input.add": "log.input.add",
53
+ "input.sync": "log.input.sync",
54
+ "input.done": "log.input.done",
55
+ "research.add": "log.research.add",
56
+ "research.edit": "log.research.edit",
57
+ "research.pause": "log.research.pause",
58
+ "research.resume": "log.research.resume",
59
+ "research.done": "log.research.done",
60
+ };
61
+ const EVENT_SAID = {
62
+ "event.add": "log.event.add",
63
+ "event.edit": "log.event.edit",
64
+ "event.cite": "log.event.cite",
65
+ "event.retract": "log.event.retract",
66
+ };
67
+ const SEARCH_SAID = {
68
+ found: "log.search.found",
69
+ negative: "log.search.negative",
70
+ partial: "log.search.partial",
71
+ inconclusive: "log.search.inconclusive",
72
+ };
73
+ function short(text, max = 90) {
74
+ const one = text.replace(/\s+/g, " ").trim();
75
+ return one.length > max ? `${one.slice(0, max - 1).trimEnd()}…` : one;
76
+ }
77
+ /** Image numbers as ranges: 16–22, 30. */
78
+ function ranges(ns) {
79
+ const sorted = [...new Set(ns)].sort((a, b) => a - b);
80
+ const out = [];
81
+ for (let i = 0; i < sorted.length; i++) {
82
+ let j = i;
83
+ while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1)
84
+ j++;
85
+ out.push(j > i ? `${sorted[i]}–${sorted[j]}` : String(sorted[i]));
86
+ i = j;
87
+ }
88
+ return out.join(", ");
89
+ }
90
+ function kindOf(op) {
91
+ const [record] = op.split(".");
92
+ if (record === "person" || record === "family" || record === "event")
93
+ return "person";
94
+ if (record === "source" || record === "media" || record === "recordset" || record === "repository")
95
+ return "source";
96
+ if (record === "story")
97
+ return "story";
98
+ return "other";
99
+ }
100
+ /** Lines of what a commit saved, in the research language, each with what it is about; its subject when it logged no operation. */
101
+ export function changeLines(tree, ops, subject, lang) {
102
+ const get = (id) => (id ? tree.get(id) : undefined);
103
+ const who = (id) => {
104
+ const r = get(id);
105
+ if (!r)
106
+ return id ?? "";
107
+ if (r.type === "person") {
108
+ const span = lifespan(r);
109
+ return `${displayName(r)}${span ? ` (${span})` : ""} [${r.id}]`;
110
+ }
111
+ return r.type === "family" ? ownerName(tree, r.id) : r.id;
112
+ };
113
+ const task = (id) => {
114
+ const t = get(id);
115
+ return t ? short(humanTask(tree, t.what, lang)) : (id ?? "");
116
+ };
117
+ const name = (id) => {
118
+ const r = get(id);
119
+ if (!r)
120
+ return id ?? "";
121
+ switch (r.type) {
122
+ case "person":
123
+ case "family":
124
+ return who(r.id);
125
+ case "task":
126
+ return task(r.id);
127
+ case "session":
128
+ return r.task ? task(r.task) : r.id;
129
+ case "source":
130
+ case "recordset":
131
+ case "conflict":
132
+ return short(r.title);
133
+ case "research":
134
+ case "repository":
135
+ case "input":
136
+ return short(r.name);
137
+ case "search":
138
+ return short(r.question);
139
+ case "lesson":
140
+ return short(r.rule);
141
+ case "hypothesis":
142
+ return short(r.question);
143
+ case "place":
144
+ return r.names[0]?.name ?? r.id;
145
+ default:
146
+ return r.id;
147
+ }
148
+ };
149
+ const fact = (owner, eventId) => {
150
+ const e = get(owner)?.events.find((x) => x.id === eventId);
151
+ if (!e)
152
+ return undefined;
153
+ const f = cardFact(tree, e);
154
+ const what = [f.value, humanDate(f.date, lang), humanPlace(f.place, f.house, lang)].filter(Boolean).join(", ");
155
+ return `${eventName(f.kind, lang, f.label)}${what ? `: ${what}` : ""}`;
156
+ };
157
+ const out = [];
158
+ let kind = "other";
159
+ const say = (line) => {
160
+ if (line && !out.some((l) => l.text === line))
161
+ out.push({ text: line, kind });
162
+ };
163
+ // Images downloaded: one line for each book, whatever their order.
164
+ const images = new Map();
165
+ for (const o of ops) {
166
+ if (QUIET.has(o.op))
167
+ continue;
168
+ kind = kindOf(o.op);
169
+ const [first, second] = o.targets;
170
+ if (o.op === "media.add") {
171
+ const m = get(first);
172
+ const key = m?.recordset ?? "";
173
+ if (!images.has(key)) {
174
+ images.set(key, []);
175
+ out.push({ text: `\u0000media:${key}`, kind: "source" }); // its place in the order, filled in below
176
+ }
177
+ if (m?.image !== undefined)
178
+ images.get(key).push(m.image);
179
+ continue;
180
+ }
181
+ if (EVENT_SAID[o.op]) {
182
+ const f = fact(first, second);
183
+ say(f ? phrase(lang, EVENT_SAID[o.op], { who: who(first), fact: f }) : o.summary);
184
+ continue;
185
+ }
186
+ if (o.op === "search.add" || o.op === "search.edit") {
187
+ const s = get(first);
188
+ say(s ? phrase(lang, SEARCH_SAID[s.result] ?? "log.search.found", { name: short(s.question) }) : o.summary);
189
+ continue;
190
+ }
191
+ if (o.op === "family.child") {
192
+ say(phrase(lang, "log.family.child", { who: who(first), child: who(second) }));
193
+ continue;
194
+ }
195
+ if (o.op === "person.merge" || o.op === "family.merge") {
196
+ say(phrase(lang, "log.merge", { who: who(second) }));
197
+ continue;
198
+ }
199
+ const key = SAID[o.op];
200
+ say(key ? phrase(lang, key, { who: who(first), name: name(first) }) : o.summary);
201
+ }
202
+ const lines = out.map(({ text: line, kind }) => {
203
+ if (!line.startsWith("\u0000media:"))
204
+ return { text: line, kind };
205
+ const book = line.slice("\u0000media:".length);
206
+ const ns = images.get(book) ?? [];
207
+ const title = get(book)?.title;
208
+ const n = Math.max(ns.length, 1);
209
+ const text = !title
210
+ ? phrase(lang, "log.media.loose", { n })
211
+ : ns.length === 1
212
+ ? phrase(lang, "log.media.one", { name: title, image: ns[0] })
213
+ : phrase(lang, "log.media", { name: title, n, images: ns.length ? ranges(ns) : "" });
214
+ return { text, kind };
215
+ });
216
+ if (lines.length)
217
+ return lines;
218
+ // no operations: the readers' notes of a book, else the subject as strom wrote it
219
+ const reading = /^Reading (B\d+)-(\d+)-(\d+): (\d+) images?/.exec(subject);
220
+ if (reading)
221
+ return [{ text: phrase(lang, "log.reading", { name: get(reading[1])?.title ?? reading[1], n: reading[4], images: reading[2] === reading[3] ? reading[2] : `${reading[2]}–${reading[3]}` }), kind: "source" }];
222
+ return ops.length ? [] : [{ text: subject, kind: "other" }];
223
+ }
@@ -5,12 +5,32 @@
5
5
  // brief says it), near the end every strom command it runs reminds it, and at
6
6
  // the limit an agent that can be resumed gets a few minutes more to write down
7
7
  // what it found and close the session (runners: wrapUp).
8
+ import fs from "node:fs";
9
+ import path from "node:path";
8
10
  /** From how long before the end strom's output reminds the agent (a short session: its last quarter). */
9
11
  export const REMIND_MS = 10 * 60_000;
10
12
  /** From how long before the end the reminder says to write down and close (a short session: its last eighth). */
11
13
  export const CLOSE_MS = 3 * 60_000;
12
14
  /** The time an agent stopped at its limit gets to write down what it found. */
13
15
  export const WRAP_UP_MS = 5 * 60_000;
16
+ /**
17
+ * The user asked a session to finish now (strom session finish): a file of the tree's own, read by every strom
18
+ * command the session's agent runs — a run's session cannot be told otherwise (it reads no input).
19
+ */
20
+ export function finishFile(root, session) {
21
+ return path.join(root, ".strom", "finish", `${session}.json`);
22
+ }
23
+ export function askFinish(root, session, by) {
24
+ fs.mkdirSync(path.dirname(finishFile(root, session)), { recursive: true });
25
+ fs.writeFileSync(finishFile(root, session), JSON.stringify({ at: new Date().toISOString(), by }) + "\n");
26
+ }
27
+ export function finishAsked(root, session) {
28
+ return fs.existsSync(finishFile(root, session));
29
+ }
30
+ /** What every strom command says to the agent of a session the user asked to finish. */
31
+ export const FINISH_LINE = "⏳ the user asks you to finish this session now: start nothing new; record in strom what you found and have not recorded yet " +
32
+ '(facts, sources, the images searched, in vain too), then strom session close --continue --summary "…" --next "exactly where you stopped" ' +
33
+ "(or finish the task, if it is done).";
14
34
  /** When this session is stopped (ms since the epoch), if a run set a limit. */
15
35
  export function deadlineOf(env) {
16
36
  const t = Date.parse(env.STROM_DEADLINE ?? "");
@@ -55,6 +55,7 @@ export const SETTINGS = [
55
55
  // Read from the config file only — no variable, no flag, no tree: nothing an agent can set.
56
56
  { key: "agent.permissions", env: "", tree: false, kind: "choice", choices: PERMISSION_LEVELS, description: "what the agent may do without asking you: ask (what the tree allows; anything else it asks), auto (default: what the tree allows; anything else the agent's own review decides, asking only when risky), full (everything but what the tree denies) — only you raise it" },
57
57
  { key: "agent.where", env: "STROM_AGENT_WHERE", tree: false, kind: "choice", choices: ["app", "terminal"], description: "where you talk with the agent: app (its desktop app — the easiest), terminal (its CLI) — unset: the app when it is installed" },
58
+ { key: "agent.browser", env: "", tree: true, kind: "choice", choices: ["archives", "always"], description: "browser tools (Claude in Chrome) for the Claude Code sessions strom starts: archives (default) — only in a research whose connectors fetch through the browser, for their sites; always — in every session, for the sites of the research's archives (a conversation asks about others) — only you turn it on" },
58
59
  { key: "agent.remote", env: "", tree: false, kind: "choice", choices: ["on", "off"], description: "the Claude Code sessions strom starts (strom run, strom chat in the terminal) with Remote Control: on — follow and steer them from claude.ai or the Claude app on your phone; off (default) — only you turn it on" },
59
60
  { key: "updates", env: "STROM_UPDATES", tree: false, kind: "choice", choices: ["check", "off"], description: "look for new versions of strom: check (default — at most once a day, one small file from the project's releases; strom says so, strom update installs it) or off" },
60
61
  { key: "strom.app", env: "", tree: false, kind: "choice", choices: ["yes", "no"], description: "you use the Strom app: yes (strom says which file to import into it), no (strom never mentions it) — unset: strom notices it itself" },
@@ -72,6 +73,7 @@ const FIELDS = {
72
73
  "agent.permissions": "agentPermissions",
73
74
  "agent.where": "agentWhere",
74
75
  "agent.remote": "agentRemote",
76
+ "agent.browser": "agentBrowser",
75
77
  "strom.app": "stromApp",
76
78
  "strom.app.url": "stromAppUrl",
77
79
  "main.person": "mainPerson",
@@ -236,6 +236,11 @@ export function treeBrowserConnectors(tree, shared) {
236
236
  if (!all.length)
237
237
  return [];
238
238
  const fetchedBy = new Set(tree.list("media").flatMap((m) => (m.fetched ? [m.fetched.connector] : [])));
239
+ const hosts = new Set(treeSites(tree));
240
+ return all.filter((c) => fetchedBy.has(c.name) || c.manifest.hosts.some((h) => hosts.has(siteOf(h))));
241
+ }
242
+ /** The sites of the archives a tree's records name (repositories, record sets, sources with an address). */
243
+ function treeSites(tree) {
239
244
  const hosts = new Set();
240
245
  for (const r of [...tree.list("repository"), ...tree.list("recordset"), ...tree.list("source")])
241
246
  if (r.url && !r.retracted)
@@ -245,7 +250,26 @@ export function treeBrowserConnectors(tree, shared) {
245
250
  catch {
246
251
  // not an address
247
252
  }
248
- return all.filter((c) => fetchedBy.has(c.name) || c.manifest.hosts.some((h) => hosts.has(siteOf(h))));
253
+ return [...hosts];
254
+ }
255
+ /**
256
+ * Browser tools for the tree's agent (Claude in Chrome): whether its sessions get them, and for which sites.
257
+ * `agent.browser` archives (default): only where a connector of the tree fetches through the browser, for its sites;
258
+ * always (the user's choice): every session, for the sites of the tree's archives and of the connectors — a
259
+ * conversation asks about any other, a run nobody watches uses none other.
260
+ */
261
+ export function agentBrowser(tree, shared) {
262
+ const via = treeBrowserConnectors(tree, shared);
263
+ const always = new Settings(tree.env, {}).resolve("agent.browser", tree.config)?.value === "always";
264
+ const hosts = new Set(via.flatMap((c) => c.manifest.hosts));
265
+ if (always) {
266
+ for (const c of shared ? scanConnectors(shared).connectors : [])
267
+ for (const h of c.manifest.hosts)
268
+ hosts.add(h);
269
+ for (const h of treeSites(tree))
270
+ hosts.add(h);
271
+ }
272
+ return { on: always || via.length > 0, hosts: [...hosts], always };
249
273
  }
250
274
  export function findConnector(shared, name) {
251
275
  if (!shared)
@@ -0,0 +1,41 @@
1
+ // Which direction of a research (G…) a task belongs to. A task names its
2
+ // research when it was added in a session of one (or named it); a task added
3
+ // elsewhere belongs to the direction its people are in — the narrowest one when
4
+ // several reach them (the ancestors of a grandmother before those of her
5
+ // grandson). The queue, the menu and the Strom app go by the same answer.
6
+ import { researchPeople } from "./frontier.js";
7
+ import { subjectPeople } from "./records.js";
8
+ import { typeOfId } from "./tree.js";
9
+ export function scopes(tree) {
10
+ return tree.list("research").map((research) => ({ research, people: researchPeople(tree, research) }));
11
+ }
12
+ /** The people a task is about: its persons, the partners of its families, the people of its conflicts and hypotheses. */
13
+ export function aboutPeople(tree, subject) {
14
+ const partners = subject.filter((id) => typeOfId(id) === "family").flatMap((id) => tree.get(id)?.partners ?? []);
15
+ return [...new Set([...subjectPeople(tree, subject), ...partners])];
16
+ }
17
+ /** The direction a task belongs to: its own, else the narrowest one its people are in; none for a task about nobody. */
18
+ export function directionOf(tree, t, all) {
19
+ if (t.research)
20
+ return t.research;
21
+ const about = aboutPeople(tree, t.subject);
22
+ if (!about.length)
23
+ return undefined;
24
+ return all
25
+ .filter((s) => about.some((p) => s.people.has(p)))
26
+ .sort((a, b) => a.people.size - b.people.size || a.research.id.localeCompare(b.research.id))[0]?.research.id;
27
+ }
28
+ /**
29
+ * Whether a task is work of a research (strom session start --research, strom
30
+ * run --research): its own, or about the people it is about (a task added
31
+ * elsewhere or before it began); a task about nobody and of no research, any
32
+ * research's.
33
+ */
34
+ export function ofResearch(tree, t, scope) {
35
+ if (t.research === scope.research.id)
36
+ return true;
37
+ const about = aboutPeople(tree, t.subject);
38
+ if (!about.length)
39
+ return !t.research;
40
+ return about.some((p) => scope.people.has(p)) || (!t.research && !scope.people.size);
41
+ }