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
@@ -17,7 +17,7 @@ import { agentsHere, DESKTOP_APPS, inDesktopApp, whereToTalk } from "../core/app
17
17
  import { setupWizard } from "../cli/wizard.js";
18
18
  import { installation, stromLauncher } from "../core/self.js";
19
19
  import { VERSION } from "../core/tree.js";
20
- import { installUpdate, isNewer, knownNewerVersion, latestVersion, newerVersion } from "../core/update.js";
20
+ import { installUpdate, isNewer, knownNewerVersion, latestVersion, newerNode, newerVersion } from "../core/update.js";
21
21
  import { desktopDir } from "../core/paths.js";
22
22
  import { researchUrl, stromAppUrl, stromAppState } from "../core/stromapp.js";
23
23
  import { isInstalled } from "../agents/global.js";
@@ -189,7 +189,7 @@ function diagnose(ctx) {
189
189
  }
190
190
  // For a person: the shortcut on the desktop, the Strom app.
191
191
  const desktop = desktopDir(ctx.env);
192
- const shortcut = [`${shortcutName(lang)}.command`, `${shortcutName(lang)}.cmd`, "strom-research.desktop"].some((f) => fs.existsSync(path.join(desktop, f)));
192
+ const shortcut = [`${shortcutName(lang)}.command`, `${shortcutName(lang)}.lnk`, `${shortcutName(lang)}.cmd`, "strom-research.desktop"].some((f) => fs.existsSync(path.join(desktop, f)));
193
193
  if (shortcut)
194
194
  add("shortcut", "ok", t("ui.doc.yes"));
195
195
  else
@@ -229,7 +229,7 @@ async function repair(ctx, checks, out) {
229
229
  out(ui(lang, "ui.setup.shortcut.done", { file: ctx.display(f) }));
230
230
  }
231
231
  if (todo.has("app") && person && (await ctx.confirm(ui(lang, "ui.fix.app"), false))) {
232
- openForUser(stromAppUrl(ctx.env), ctx.env);
232
+ openForUser(stromAppUrl(ctx.settings), ctx.env);
233
233
  out(ui(lang, "ui.app.install"));
234
234
  }
235
235
  }
@@ -277,14 +277,18 @@ register({
277
277
  throw new StromError(t("ui.update.unknown"), { hint: "check the network — or run the installer again" });
278
278
  ctx.settings.config.updateCheck = { at: new Date().toISOString(), latest: newest };
279
279
  ctx.settings.save();
280
- if (!isNewer(newest, VERSION))
280
+ const inst = installation();
281
+ // The installer's Node: the newest release of its line (security fixes) is taken along.
282
+ const node = inst.kind === "installed" ? await newerNode(ctx.env, inst.node).catch(() => undefined) : undefined;
283
+ const newer = isNewer(newest, VERSION);
284
+ if (!newer && !node)
281
285
  return { text: t("ui.update.current", { version: VERSION }), data: { current: VERSION, latest: newest, newer: false } };
286
+ const nodeLine = node ? t("ui.update.node.available", { from: inst.node ?? "?", to: node.version }) : undefined;
282
287
  if (opts.check)
283
- return { text: t("ui.update.available", { version: newest, current: VERSION }), data: { current: VERSION, latest: newest, newer: true } };
284
- const inst = installation();
288
+ return { text: lines(newer ? t("ui.update.available", { version: newest, current: VERSION }) : undefined, nodeLine), data: { current: VERSION, latest: newest, newer, ...(node ? { node: node.version } : {}) } };
285
289
  if (inst.kind !== "installed")
286
290
  return { text: t(inst.kind === "npm" ? "ui.update.npm" : "ui.update.source", { version: newest }), data: { current: VERSION, latest: newest, newer: true, npm: inst.kind === "npm" } };
287
- const question = t("ui.update.sure", { version: newest, current: VERSION });
291
+ const question = newer ? t("ui.update.sure", { version: newest, current: VERSION }) : t("ui.update.node.sure", { from: inst.node ?? "?", to: node.version });
288
292
  if (isAgent(ctx.env) || !ctx.yes) {
289
293
  if (!isAgent(ctx.env) && ctx.interactive) {
290
294
  if (!(await ctx.confirm(question, true)))
@@ -295,9 +299,9 @@ register({
295
299
  }
296
300
  if (ctx.interactive)
297
301
  ctx.io.stdout(`${t("ui.update.downloading", { version: newest })}\n`);
298
- let version;
302
+ let done;
299
303
  try {
300
- version = await installUpdate(ctx.env, inst.root);
304
+ done = await installUpdate(ctx.env, inst.root);
301
305
  }
302
306
  catch (e) {
303
307
  throw new StromError(t("ui.update.failed", { detail: e.message }), { hint: "strom update — or run the installer again" });
@@ -305,7 +309,10 @@ register({
305
309
  // The agents learn what the new version tells them.
306
310
  const { command, args } = stromLauncher();
307
311
  spawnSync(command, [...args, "agents", "install"], { stdio: "ignore", env: ctx.env, windowsHide: true });
308
- return { text: t("ui.update.done", { version, previous: VERSION }), data: { previous: VERSION, version, updated: true } };
312
+ return {
313
+ text: lines(newer ? t("ui.update.done", { version: done.version, previous: VERSION }) : undefined, done.node ? t("ui.update.node.done", done.node) : undefined),
314
+ data: { previous: VERSION, version: done.version, updated: true, ...(done.node ? { node: done.node } : {}) },
315
+ };
309
316
  },
310
317
  });
311
318
  function display(ctx, def, value) {
@@ -1,4 +1,5 @@
1
1
  // source · cite · repo · recordset · place — where the evidence comes from.
2
+ import { OFF_MAP_HOW, offMapLine, placesOffMap } from "../core/places.js";
2
3
  import fs from "node:fs";
3
4
  import { commands, register } from "../cli/registry.js";
4
5
  import { lines, moreLine, paginate, table, truncate } from "../cli/format.js";
@@ -8,7 +9,8 @@ import { create, csvOpt, listOpt, normId, requireRecord, textOpt, update } from
8
9
  import { makeNote, parseDate } from "../core/actions.js";
9
10
  import { calibrationLine, fitCalibration } from "../core/calibration.js";
10
11
  import { foldText } from "../core/text.js";
11
- import { imageOfRef } from "../core/media.js";
12
+ import { clipNote, clipText, imageOfRef, MAX_CLIPS, transcriptNote } from "../core/media.js";
13
+ import { partRegion } from "../core/views.js";
12
14
  import { formatName } from "../core/people.js";
13
15
  function written(tree) {
14
16
  return lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined);
@@ -40,6 +42,26 @@ function mediaRefs(tree, refs) {
40
42
  return m.id;
41
43
  });
42
44
  }
45
+ /**
46
+ * Where the entry is on an image, as its view was cut: "M0012@0.05,0.4,0.45,0.18", "B0001:57@left:0.1,0.4,0.8,0.1"
47
+ * (a half, then a crop within it), pixels of the registered image, or "M0012" alone (the entry is the whole image).
48
+ */
49
+ function clipRefs(tree, specs) {
50
+ return specs.map((spec) => {
51
+ const m = /^([^@]+?)(?:@(.+))?$/.exec(spec.trim()) ?? [];
52
+ let [ref, where] = [m[1] ?? "", m[2]];
53
+ // "M0012:0.1,0.2,0.3,0.1" — the colon of a record set's image number stays hers
54
+ const colon = !where && /^([Mm]\d+):(.+)$/.exec(ref);
55
+ if (colon)
56
+ [ref, where] = [colon[1], colon[2]];
57
+ const media = mediaRefs(tree, [ref])[0];
58
+ if (!where)
59
+ return { media, region: { x: 0, y: 0, w: 1, h: 1 } };
60
+ const w = /^(?:(left|right|top|bottom)(?::|$))?(.*)$/.exec(where.trim());
61
+ const region = partRegion({ half: w[1], crop: w[2] || undefined }, tree.get(media));
62
+ return { media, region };
63
+ });
64
+ }
43
65
  /** "Teinitz an der Elbe@de:1850-1918" → another name of a place. */
44
66
  function altName(a) {
45
67
  const m = /^(.+?)(?:@([a-z]{2,3}))?(?::(\d{3,4})?-(\d{3,4})?)?$/.exec(a);
@@ -113,10 +135,17 @@ register({
113
135
  { name: "form", type: "string", value: "<form>", description: "original (default), derivative (copy, index — the default for --kind index and in a record set of kind index only), authored (compiled work)" },
114
136
  { name: "url", type: "string", value: "<url>", description: "link to the image or catalog entry" },
115
137
  { name: "media", type: "string", multiple: true, value: "<M…|B…:n>", description: "the registered image(s) the record is on (repeatable)" },
138
+ {
139
+ name: "clip",
140
+ type: "string",
141
+ multiple: true,
142
+ value: "<M…@x,y,w,h>",
143
+ description: "where the entry itself is on its image — the crop you read it in (the view prints it); a second one when it runs over a page break",
144
+ },
116
145
  { name: "note", type: "string", value: "<text>", description: "short note" },
117
146
  ],
118
147
  examples: [
119
- 'strom source add "Křest Jana Nováka 1905" --kind baptism --recordset B0001 --locator "fol. 45, č. 12" --language la --information primary --transcript @zapis.txt',
148
+ 'strom source add "Křest Jana Nováka 1905" --kind baptism --recordset B0001 --locator "fol. 45, č. 12" --language la --information primary --transcript @zapis.txt --clip B0001:2@0.05,0.40,0.45,0.18',
120
149
  'strom source add "Rodinný strom od tety Marie" --kind family-tree --form authored --input I0001',
121
150
  ],
122
151
  run(ctx, { args, opts }) {
@@ -126,7 +155,10 @@ register({
126
155
  requireRecord(tree, opts.repo, "repository");
127
156
  if (opts.input)
128
157
  requireRecord(tree, opts.input, "input");
129
- const media = mediaRefs(tree, csvOpt(opts.media));
158
+ const clips = clipRefs(tree, listOpt(opts.clip));
159
+ if (clips.length > MAX_CLIPS)
160
+ throw new UsageError(`at most ${MAX_CLIPS} clips: an entry, and its continuation over a page break`);
161
+ const media = [...new Set([...mediaRefs(tree, csvOpt(opts.media)), ...clips.map((c) => c.media)])];
130
162
  // a line of an index is a copy made by another hand; a book with its own index holds originals too
131
163
  const kind = oneOf(opts.kind, SOURCE_KINDS, "kind", "other");
132
164
  const form = kind === "index" || (book && book.kinds.length > 0 && book.kinds.every((k) => k === "index")) ? "derivative" : "original";
@@ -146,9 +178,10 @@ register({
146
178
  url: str(opts.url),
147
179
  accessed: str(opts.url) ? new Date().toISOString().slice(0, 10) : undefined,
148
180
  ...(media.length ? { media } : {}),
181
+ ...(clips.length ? { clips } : {}),
149
182
  note: str(opts.note),
150
183
  }, (id) => `+${id} source "${truncate(args[0], 60)}"${media.length ? ` on ${media.join(" ")}` : ""}`, media);
151
- return { text: lines(written(tree), "", `cite it: strom cite <event> ${src.id} --locator "…"`), data: { source: src } };
184
+ return { text: lines(written(tree), clipNote(tree, src), transcriptNote(tree, src), "", `cite it: strom cite <event> ${src.id} --locator "…"`), data: { source: src } };
152
185
  },
153
186
  }, {
154
187
  path: ["source", "list"],
@@ -207,7 +240,7 @@ register({
207
240
  if (c.source === s.id)
208
241
  citing.push(`${f.id} ${e.id} ${e.kind}${at(c)}`);
209
242
  }
210
- const text = lines(`${s.id} ${s.title}`, `kind ${s.kind} · ${s.form} · information ${s.information}${s.language ? ` · language ${s.language}` : ""}${s.date ? ` · dated ${s.date}` : ""}`, s.recordset || s.repository ? `in ${[s.recordset, s.repository].filter(Boolean).join(" · ")}${s.locator ? ` · ${s.locator}` : ""}` : s.locator ? `at ${s.locator}` : undefined, s.url ? `url ${s.url}` : undefined, s.media?.length ? `images ${s.media.map((id) => { const m = tree.get(id); return m ? `${id}${m.recordset ? ` (${m.recordset}:${m.image ?? "?"})` : ""}` : id; }).join(" · ")} — strom media view <M…>` : undefined, s.transcript ? `\ntranscript\n${s.transcript}` : undefined, s.translation ? `\ntranslation\n${s.translation}` : undefined, citing.length ? `\ncited by\n${citing.map((c) => ` ${c}`).join("\n")}` : "\ncited by nothing yet", ...s.notes.map((n) => `note ${n.text}`));
243
+ const text = lines(`${s.id} ${s.title}`, `kind ${s.kind} · ${s.form} · information ${s.information}${s.language ? ` · language ${s.language}` : ""}${s.date ? ` · dated ${s.date}` : ""}`, s.recordset || s.repository ? `in ${[s.recordset, s.repository].filter(Boolean).join(" · ")}${s.locator ? ` · ${s.locator}` : ""}` : s.locator ? `at ${s.locator}` : undefined, s.url ? `url ${s.url}` : undefined, s.media?.length ? `images ${s.media.map((id) => { const m = tree.get(id); return m ? `${id}${m.recordset ? ` (${m.recordset}:${m.image ?? "?"})` : ""}` : id; }).join(" · ")} — strom media view <M…>` : undefined, s.clips?.length ? `clips ${s.clips.map((c, i) => `${i + 1}. ${clipText(c)}`).join(" · ")} — where the entry is on its image` : undefined, s.transcript ? `\ntranscript\n${s.transcript}` : undefined, s.translation ? `\ntranslation\n${s.translation}` : undefined, citing.length ? `\ncited by\n${citing.map((c) => ` ${c}`).join("\n")}` : "\ncited by nothing yet", ...s.notes.map((n) => `note ${n.text}`));
211
244
  return { text, data: { source: s, citedBy: citing } };
212
245
  },
213
246
  });
@@ -228,7 +261,7 @@ register({
228
261
  { name: "automation", type: "string", value: "<a>", description: "allowed, manual (browser only), forbidden, unknown (default)" },
229
262
  { name: "note", type: "string", value: "<text>", description: "short note" },
230
263
  ],
231
- examples: ['strom repo add "SOA Praha (eBadatelna)" --country CZ --url https://ebadatelna.soapraha.cz --automation manual'],
264
+ examples: ['strom repo add "State Archive, online reading room" --country CZ --url https://archive.example.org --automation manual'],
232
265
  run(ctx, { args, opts }) {
233
266
  const tree = ctx.tree();
234
267
  const r = create(tree, "repository", {
@@ -396,10 +429,11 @@ register({
396
429
  { name: "kind", type: "string", value: "<kind>", description: "village, town, parish, estate, district, …" },
397
430
  { name: "lat", type: "string", value: "<deg>", description: "latitude" },
398
431
  { name: "lon", type: "string", value: "<deg>", description: "longitude" },
432
+ { name: "unlocated", type: "string", value: "<why>", description: "no coordinates yet, and why: not identified which of the places of this name it is (coordinates given later clear it)" },
399
433
  { name: "parent", type: "string", value: "<L…>", description: "place it belongs to" },
400
434
  { name: "note", type: "string", value: "<text>", description: "short note" },
401
435
  ],
402
- examples: ['strom place add "Týnec nad Labem" --alt "Teinitz an der Elbe@de" --kind town --lat 50.042 --lon 15.358'],
436
+ examples: ['strom place add "Týnec nad Labem" --alt "Teinitz an der Elbe@de" --kind town --lat 50.042 --lon 15.358', 'strom place add "Lhota" --kind village --unlocated "which Lhota: the record gives no parish"'],
403
437
  run(ctx, { args, opts }) {
404
438
  const tree = ctx.tree();
405
439
  const names = [{ name: args[0].trim() }];
@@ -416,6 +450,7 @@ register({
416
450
  names,
417
451
  kind: str(opts.kind),
418
452
  coords: lat !== undefined && lon !== undefined ? { lat, lon } : undefined,
453
+ unlocated: lat === undefined ? str(opts.unlocated) : undefined,
419
454
  parent: opts.parent ? normId(opts.parent) : undefined,
420
455
  jurisdictions: [],
421
456
  note: str(opts.note),
@@ -425,12 +460,21 @@ register({
425
460
  },
426
461
  }, {
427
462
  path: ["place", "list"],
428
- summary: "Places known in this tree",
463
+ summary: "Places known in this tree — or, with --off-map, the places of facts not on the Strom app's map yet",
429
464
  group: "sources",
430
465
  tree: true,
431
466
  args: [{ name: "filter", description: "part of a name" }],
432
- run(ctx, { args }) {
467
+ options: [{ name: "off-map", type: "boolean", description: "places of facts without coordinates (no place record yet, or one without them), the most facts first" }],
468
+ examples: ["strom place list", "strom place list --off-map"],
469
+ run(ctx, { args, opts }) {
433
470
  const tree = ctx.tree();
471
+ if (opts["off-map"]) {
472
+ const off = placesOffMap(tree);
473
+ return {
474
+ text: off.length ? lines(...off.map(offMapLine), OFF_MAP_HOW) : "every place of a fact is on the map (or says why not)",
475
+ data: { offMap: off.map((m) => ({ name: m.name, events: m.events, place: m.place?.id })) },
476
+ };
477
+ }
434
478
  const all = args[0] ? tree.list("place").filter((p) => p.names.some((n) => matches(n.name, args[0]))) : tree.list("place");
435
479
  return {
436
480
  text: all.length
@@ -519,15 +563,24 @@ register({
519
563
  description: "Only the options given change; --media adds images, --note adds a note. Changing what a record says or\n" +
520
564
  "how good it is (kind, form, information, date, locator, transcript, record set) needs --reason; filling in doesn't.",
521
565
  args: [{ name: "source", description: "source ID (S0001)", required: true }],
522
- options: editOptions(addOptions("source add"), [{ name: "title", type: "string", value: "<text>", description: "a better title" }]).filter((o) => o.name !== "input"),
523
- examples: ['strom source edit S0001 --translation "Jan, son of Josef" --media B0001:2', 'strom source edit S0001 --information secondary --reason "the entry was written years later"'],
566
+ options: [
567
+ ...editOptions(addOptions("source add"), [{ name: "title", type: "string", value: "<text>", description: "a better title" }]).filter((o) => o.name !== "input"),
568
+ { name: "clip-remove", type: "string", value: "<n|all>", description: "take away a clip that is wrong (its number in strom source show), or all of them" },
569
+ ],
570
+ examples: [
571
+ 'strom source edit S0001 --translation "Jan, son of Josef" --media B0001:2',
572
+ 'strom source edit S0001 --information secondary --reason "the entry was written years later"',
573
+ "strom source edit S0001 --clip M0001@0.05,0.42,0.45,0.16",
574
+ ],
524
575
  run(ctx, { args, opts }) {
525
576
  const tree = ctx.tree();
526
577
  if (opts.recordset)
527
578
  requireRecord(tree, opts.recordset, "recordset");
528
579
  if (opts.repo)
529
580
  requireRecord(tree, opts.repo, "repository");
530
- const media = mediaRefs(tree, csvOpt(opts.media));
581
+ const clips = clipRefs(tree, listOpt(opts.clip));
582
+ const drop = str(opts["clip-remove"]);
583
+ const media = [...new Set([...mediaRefs(tree, csvOpt(opts.media)), ...clips.map((c) => c.media)])];
531
584
  const s = editFields(tree, args[0], "source", {
532
585
  title: str(opts.title),
533
586
  kind: optOneOf(opts.kind, SOURCE_KINDS, "kind"),
@@ -542,8 +595,25 @@ register({
542
595
  form: optOneOf(opts.form, ["original", "derivative", "authored"], "form"),
543
596
  url: str(opts.url),
544
597
  accessed: str(opts.url) ? new Date().toISOString().slice(0, 10) : undefined,
545
- }, { kind: "other", form: undefined, information: "unknown", date: undefined, locator: undefined, transcript: undefined, recordset: undefined }, opts, (cur) => (media.some((m) => !(cur.media ?? []).includes(m)) ? { media: [...new Set([...(cur.media ?? []), ...media])] } : {}));
546
- return { text: written(tree), data: { source: s } };
598
+ }, { kind: "other", form: undefined, information: "unknown", date: undefined, locator: undefined, transcript: undefined, recordset: undefined }, opts, (cur) => {
599
+ let kept = cur.clips ?? [];
600
+ if (drop === "all")
601
+ kept = [];
602
+ else if (drop !== undefined) {
603
+ const n = Number(drop);
604
+ if (!Number.isInteger(n) || n < 1 || n > kept.length)
605
+ throw new UsageError(kept.length ? `--clip-remove takes 1–${kept.length} or all` : `${cur.id} has no clips`, { hint: `strom source show ${cur.id}` });
606
+ kept = kept.filter((_, i) => i !== n - 1);
607
+ }
608
+ const next = [...kept, ...clips.filter((c) => !kept.some((k) => clipText(k) === clipText(c)))];
609
+ if (next.length > MAX_CLIPS)
610
+ throw new UsageError(`at most ${MAX_CLIPS} clips: an entry, and its continuation over a page break`, { hint: `take one away: --clip-remove <n> (strom source show ${cur.id})` });
611
+ return {
612
+ ...(media.some((m) => !(cur.media ?? []).includes(m)) ? { media: [...new Set([...(cur.media ?? []), ...media])] } : {}),
613
+ ...(drop !== undefined || clips.length ? { clips: next.length ? next : undefined } : {}),
614
+ };
615
+ });
616
+ return { text: lines(written(tree), media.length ? clipNote(tree, s) : undefined, media.length || clips.length ? transcriptNote(tree, s) : undefined), data: { source: s } };
547
617
  },
548
618
  }, {
549
619
  path: ["repo", "edit"],
@@ -609,7 +679,7 @@ register({
609
679
  description: "--alt adds names; a place's names are never removed.",
610
680
  args: [{ name: "place", description: "place ID (L0001)", required: true }],
611
681
  options: editOptions(addOptions("place add")),
612
- examples: ['strom place edit L0001 --alt "Teinitz an der Elbe@de:1850-1918" --lat 50.042 --lon 15.358'],
682
+ examples: ['strom place edit L0001 --alt "Teinitz an der Elbe@de:1850-1918" --lat 50.042 --lon 15.358', 'strom place edit L0001 --unlocated "two villages of this name in the district; the record does not say which"'],
613
683
  run(ctx, { args, opts }) {
614
684
  const tree = ctx.tree();
615
685
  const lat = opts.lat === undefined ? undefined : Number(opts.lat);
@@ -624,10 +694,12 @@ register({
624
694
  const p = editFields(tree, args[0], "place", {
625
695
  kind: str(opts.kind),
626
696
  coords: lat !== undefined && lon !== undefined ? { lat, lon } : undefined,
697
+ unlocated: lat === undefined ? str(opts.unlocated) : undefined,
627
698
  parent: opts.parent ? normId(opts.parent) : undefined,
628
699
  }, {}, opts, (cur) => {
629
700
  const add = alt.filter((n) => !cur.names.some((x) => foldText(x.name) === foldText(n.name) && x.lang === n.lang));
630
- return add.length ? { names: [...cur.names, ...add] } : {};
701
+ // located at last: no longer "not identified"
702
+ return { ...(add.length ? { names: [...cur.names, ...add] } : {}), ...(lat !== undefined && cur.unlocated ? { unlocated: undefined } : {}) };
631
703
  });
632
704
  return { text: written(tree), data: { place: p } };
633
705
  },
@@ -16,19 +16,19 @@ import { syncAgentFiles } from "../agents/files.js";
16
16
  import { AGENTS, findAgent, isAgent } from "../core/which.js";
17
17
  import { DESKTOP_APPS, agentsHere, whereToTalk } from "../core/apps.js";
18
18
  import { assertIntact } from "../core/integrity.js";
19
- import { browserConnectors } from "../core/connector.js";
19
+ import { treeBrowserConnectors } from "../core/connector.js";
20
20
  import { appOpensResearch, importAppUrl, installedStromApp, liveAppUrl, noticeStromApp, STROM_APP_URL, stromAppUrl } from "../core/stromapp.js";
21
21
  import { liveRunning, serveLive, startLive, stopLive } from "../core/live.js";
22
22
  import { openForUser } from "../core/open.js";
23
23
  import { createShortcut, openInNewTerminal } from "../core/shortcut.js";
24
- import { chromiumBrowser, openFileWith, openInBrowser, revealFile } from "../core/chromium.js";
24
+ import { chromiumBrowser, openFileWith, openInBrowser, openWebApp, revealFile } from "../core/chromium.js";
25
25
  import { uninstallPlan } from "../core/uninstall.js";
26
26
  import { Tree } from "../core/tree.js";
27
27
  import { commitNow, shimDir } from "./session.js";
28
28
  import { abandonedSessions, closeSession, openSessions } from "../core/session.js";
29
29
  import { enterWorker, liveWorkers } from "../core/workers.js";
30
30
  import { phrase } from "../core/phrases.js";
31
- import { writeGedcoms, writeTreeGed } from "./output.js";
31
+ import { imageSettings, writeGedcoms, writeTreeGed } from "./output.js";
32
32
  import { shortcutName } from "../cli/wizard.js";
33
33
  /** Has the user trusted this folder in Claude Code already? (Its own record, read only.) */
34
34
  function claudeTrusts(root, env) {
@@ -73,7 +73,8 @@ register({
73
73
  // An agent strom started (a conversation, a run) is where the research happens already. Any other
74
74
  // agent — the one that set strom up from the web page, one opened in some folder — hands the
75
75
  // research over: its own conversation, opened where it lives (the tree folder, its permissions).
76
- const agentHere = isAgent(ctx.env);
76
+ // A window strom opened to hand the research over is the person's (STROM_HANDOVER): never handed over again from there.
77
+ const agentHere = isAgent(ctx.env) && !ctx.env.STROM_HANDOVER;
77
78
  if (agentHere && (ctx.env.STROM_WORKER || ctx.env.STROM_SESSION) && !opts.print)
78
79
  throw new UsageError("strom chat opens a conversation for the user — you are in one already", { hint: "go on with strom here" });
79
80
  const tree = ctx.tree();
@@ -117,6 +118,8 @@ register({
117
118
  if (agentHere && !opts.print) {
118
119
  const say = typeof opts.say === "string" && opts.say.trim() ? ["--say", opts.say.trim()] : [];
119
120
  const opened = openInNewTerminal(["chat", "--agent", agent, ...say], tree.root, ctx.env);
121
+ if (opened === "recent")
122
+ return { text: ui(lang, "ui.chat.handover.recent", { agent: profile.name }), data: { agent, handover: "recent", cwd: tree.root } };
120
123
  return { text: ui(lang, opened ? "ui.chat.handover" : "ui.chat.handover.failed", { agent: profile.name }), data: { agent, handover: opened ? "window" : "none", cwd: tree.root } };
121
124
  }
122
125
  const base = {
@@ -128,7 +131,7 @@ register({
128
131
  // its research when there is one, the day.
129
132
  name: ["Strom", tree.config.name, researches.length === 1 ? researches[0].name : undefined, new Date().toLocaleDateString(lang, { day: "numeric", month: "numeric" })].filter(Boolean).join(" · "),
130
133
  shared,
131
- ...(agent === "claude" ? { chrome: browserConnectors(ctx.env, shared).length > 0 } : {}),
134
+ ...(agent === "claude" ? { chrome: treeBrowserConnectors(tree, shared).length > 0 } : {}),
132
135
  };
133
136
  const args = conversationArgs(agent, base);
134
137
  if (opts.print)
@@ -137,8 +140,9 @@ register({
137
140
  // works, and has its own session. Sessions of conversations that ended without closing are closed.
138
141
  for (const s of abandonedSessions(Tree.open(tree.root, ctx.env))) {
139
142
  const t = Tree.open(tree.root, ctx.env);
140
- closeSession(t, s, { summary: phrase(t.lang, "session.chat"), next: "", interrupted: true, endedBy: "chat" });
141
- commitNow(t, `${s.id} interrupted: its conversation had ended`);
143
+ // a run that is gone (each has a worker of its own), or a conversation
144
+ closeSession(t, s, { summary: phrase(t.lang, s.runner ? "session.run" : "session.chat"), next: "", interrupted: true, endedBy: s.runner ? "run" : "chat" });
145
+ commitNow(t, `${s.id} interrupted: its ${s.runner ? "run had stopped" : "conversation had ended"}`);
142
146
  }
143
147
  const worker = `${agent}-${process.pid}-${Date.now().toString(36)}`;
144
148
  const leave = enterWorker(tree.root, worker, `${profile.name} conversation`);
@@ -146,7 +150,8 @@ register({
146
150
  // Claude Code asks once whether the folder is to be trusted — its own safety step, kept; the user knows what to answer.
147
151
  if (agent === "claude" && !claudeTrusts(tree.root, ctx.env))
148
152
  ctx.io.stdout(ui(lang, "ui.chat.trust") + "\n");
149
- const env = { ...prependPath(ctx.env, shimDir(tree)), STROM_WORKER: worker };
153
+ const env = { ...prependPath(ctx.env, shimDir(tree)), STROM_WORKER: worker, ...(base.model ? { STROM_MODEL: base.model } : {}) };
154
+ delete env.STROM_HANDOVER;
150
155
  let code;
151
156
  try {
152
157
  const started = Date.now();
@@ -176,41 +181,56 @@ register({
176
181
  group: "output",
177
182
  description: "In a tree, with an app that can take it (from its version 3.0.0), the app opens this research itself — the first time as\n" +
178
183
  "a new tree, after that the same tree updated — and follows it while somebody is at work on it (the live bridge).\n" +
179
- `Otherwise the results are ready for it in output/tree-strom.ged (in the app: Import). Web: ${STROM_APP_URL}`,
180
- options: [{ name: "live", type: "boolean", description: "the app follows this research while it goes on (the live bridge)" }],
184
+ `Otherwise the results are ready for it in output/tree-strom.ged (in the app: Import). Each entry comes with its image,\n` +
185
+ `cut out of its scan (the settings excerpts.*; excerpts.for none: without). Web: ${STROM_APP_URL}`,
186
+ options: [
187
+ { name: "live", type: "boolean", description: "the app follows this research while it goes on (the live bridge)" },
188
+ { name: "images", type: "boolean", description: "each entry with its image (the default; kept for older scripts)" },
189
+ ],
181
190
  examples: ["strom app", "strom app --live", "strom app install"],
182
191
  run(ctx, { opts }) {
183
192
  const lang = ctx.uiLang();
184
- const root = appOpensResearch(ctx.env) ? ctx.locateTree() : undefined;
193
+ const root = appOpensResearch(ctx.settings) ? ctx.locateTree() : undefined;
185
194
  if (root) {
186
195
  const tree = Tree.open(root, ctx.env);
187
196
  const name = tree.config.name;
188
197
  // Followed while somebody is at work on it: an agent strom started, or the agent asking for it now.
189
198
  const follow = Boolean(opts.live) || liveWorkers(root).length > 0 || isAgent(ctx.env);
190
- // The file as the research is now: the installed app takes it, or the user drags it in.
191
- const file = writeTreeGed(tree, { for: "strom", stromVersion: ctx.settings.stromVersion(tree.config) }).file;
192
- // B, D: the app reaches strom on this computer — in a Chromium browser, never whatever is the default (Safari cannot).
199
+ // The file as the research is now, each entry with its image: the installed app takes it, or the user drags it in.
200
+ const ged = writeTreeGed(tree, { for: "strom", stromVersion: ctx.settings.stromVersion(tree.config) });
201
+ const file = ged.file;
202
+ // what strom decided itself (smaller, left out) the user hears, in their language
203
+ const r = ged.images;
204
+ const set = ged.imageSettings;
205
+ const withImages = r && set
206
+ ? lines(ui(lang, "ui.app.images", { n: String(r.excerpts), mb: (r.bytes / 1024 / 1024).toFixed(1) }), r.level !== r.asked ? ui(lang, "ui.app.images.smaller", { limit: String(set.mb), level: r.level, asked: r.asked }) : undefined, r.dropped.length ? ui(lang, "ui.app.images.left", { n: String(r.dropped.length), limit: String(set.mb) }) : undefined, r.outOfScope ? ui(lang, "ui.app.images.scope", { n: String(r.outOfScope), for: set.for }) : undefined, r.unclipped.length ? ui(lang, "ui.app.images.unclipped", { n: String(r.unclipped.length) }) : undefined)
207
+ : undefined;
208
+ // B, D: the app reaches strom on this computer — installed from a Chromium browser (its own window: first
209
+ // choice), else in a Chromium browser's tab; never whatever is the default (Safari cannot).
210
+ // The copy of the app strom opens (strom.app.url: its beta, its development) — installed, that one only.
211
+ const installed = installedStromApp(ctx.env, process.platform, stromAppUrl(ctx.settings));
212
+ const webApp = installed?.appId && installed.browser ? { browser: installed.browser, appId: installed.appId, ...(installed.profile ? { profile: installed.profile } : {}) } : undefined;
193
213
  const browser = chromiumBrowser(ctx.env);
194
- if (browser) {
214
+ if (webApp || browser) {
195
215
  // The bridge serves the tree's GEDCOM to the app; it ends by itself when nobody asks it anything.
196
- const info = startLive(root, ctx.env);
216
+ const info = startLive(root, ctx.env, { current: true });
197
217
  if (!info)
198
218
  throw new StromError("the bridge did not start", { hint: "strom live serve shows why" });
199
- const url = follow ? liveAppUrl(info.url, ctx.env) : importAppUrl(`${info.url}/tree.ged`, ctx.env);
200
- const opened = openInBrowser(browser, url, ctx.env);
201
- const text = opened ? ui(lang, follow ? "ui.app.following" : "ui.app.research", { name, where: browser.name }) : ui(lang, "ui.app.url", { url });
202
- return { text, data: { opened, via: "browser", browser: browser.name, url, bridge: info.url, follow } };
219
+ const url = follow ? liveAppUrl(info.url, ctx.settings) : importAppUrl(`${info.url}/tree.ged`, ctx.settings);
220
+ const inApp = webApp ? openWebApp(webApp, url, ctx.env) : false;
221
+ const opened = inApp || (browser ? openInBrowser(browser, url, ctx.env) : false);
222
+ const where = inApp ? ui(lang, "ui.app.where.installed") : (browser?.name ?? "");
223
+ const text = lines(opened ? ui(lang, follow ? "ui.app.following" : "ui.app.research", { name, where }) : ui(lang, "ui.app.url", { url }), withImages);
224
+ return { text, data: { opened, via: inApp ? "app" : "browser", ...(browser ? { browser: browser.name } : {}), url, bridge: info.url, follow } };
203
225
  }
204
- // A: no such browser to hand it over, but the Strom app installed from one takes the file itself (its
205
- // file handler) — not when strom is pointed at another copy of the app (STROM_APP_URL: a beta, a development one).
206
- const installed = installedStromApp(ctx.env);
207
- if (!follow && !ctx.env.STROM_APP_URL && installed && /Chrome|Edge|Brave|Chromium|Vivaldi/.test(installed.kind) && openFileWith(installed.path, file, ctx.env))
208
- return { text: ui(lang, "ui.app.research", { name, where: installed.kind }), data: { opened: true, via: "file", file, app: installed.path, follow } };
226
+ // A: no such browser to hand it over, but the Strom app installed from one takes the file itself (its file handler).
227
+ if (!follow && installed && /Chrome|Edge|Brave|Chromium|Vivaldi/.test(installed.kind) && openFileWith(installed.path, file, ctx.env))
228
+ return { text: lines(ui(lang, "ui.app.research", { name, where: installed.kind }), withImages), data: { opened: true, via: "file", file, app: installed.path, follow } };
209
229
  // C: no such browser — the app opens, the file is shown, the user drags it into the app's window.
210
- openForUser(installed?.path ?? stromAppUrl(ctx.env), ctx.env);
230
+ openForUser(installed?.path ?? stromAppUrl(ctx.settings), ctx.env);
211
231
  const shown = revealFile(file, ctx.env);
212
232
  return {
213
- text: lines(follow ? ui(lang, "ui.app.nolive") : undefined, ui(lang, "ui.app.drag", { file: ctx.display(file), shown: shown ? ui(lang, "ui.app.drag.shown") : "" })),
233
+ text: lines(follow ? ui(lang, "ui.app.nolive") : undefined, ui(lang, "ui.app.drag", { file: ctx.display(file), shown: shown ? ui(lang, "ui.app.drag.shown") : "" }), withImages),
214
234
  data: { opened: shown, via: "drag", file, follow },
215
235
  };
216
236
  }
@@ -218,14 +238,14 @@ register({
218
238
  const info = startLive(ctx.tree().root, ctx.env);
219
239
  if (!info)
220
240
  throw new StromError("the bridge did not start", { hint: "strom live serve shows why" });
221
- const url = liveAppUrl(info.url, ctx.env);
241
+ const url = liveAppUrl(info.url, ctx.settings);
222
242
  const opened = openForUser(url, ctx.env);
223
243
  return { text: opened ? ui(lang, "ui.app.opened", { where: ui(lang, "ui.app.browser") }) : ui(lang, "ui.app.url", { url }), data: { opened, url, bridge: info.url } };
224
244
  }
225
- const app = noticeStromApp(ctx.settings, ctx.env, { look: true }) ?? installedStromApp(ctx.env);
226
- const opened = openForUser(app?.path ?? stromAppUrl(ctx.env), ctx.env);
227
- const text = opened ? ui(lang, "ui.app.opened", { where: app ? app.kind : ui(lang, "ui.app.browser") }) : ui(lang, "ui.app.url", { url: stromAppUrl(ctx.env) });
228
- return { text, data: { opened, url: stromAppUrl(ctx.env), ...(app ? { installed: app } : {}) } };
245
+ const app = noticeStromApp(ctx.settings, ctx.env, { look: true }) ?? installedStromApp(ctx.env, process.platform, stromAppUrl(ctx.settings));
246
+ const opened = openForUser(app?.path ?? stromAppUrl(ctx.settings), ctx.env);
247
+ const text = opened ? ui(lang, "ui.app.opened", { where: app ? app.kind : ui(lang, "ui.app.browser") }) : ui(lang, "ui.app.url", { url: stromAppUrl(ctx.settings) });
248
+ return { text, data: { opened, url: stromAppUrl(ctx.settings), ...(app ? { installed: app } : {}) } };
229
249
  },
230
250
  }, {
231
251
  path: ["live"],
@@ -240,7 +260,7 @@ register({
240
260
  const tree = ctx.tree();
241
261
  const info = liveRunning(tree.root);
242
262
  return info
243
- ? { text: lines(`the bridge runs: ${info.url}`, ` the Strom app following it: ${liveAppUrl(info.url, ctx.env)}`, " stop: strom live stop"), data: { running: true, ...info, app: liveAppUrl(info.url, ctx.env) } }
263
+ ? { text: lines(`the bridge runs: ${info.url}`, ` the Strom app following it: ${liveAppUrl(info.url, ctx.settings)}`, " stop: strom live stop"), data: { running: true, ...info, app: liveAppUrl(info.url, ctx.settings) } }
244
264
  : { text: "the bridge does not run — strom live start (or strom app --live)", data: { running: false } };
245
265
  },
246
266
  }, {
@@ -253,7 +273,7 @@ register({
253
273
  const info = startLive(tree.root, ctx.env);
254
274
  if (!info)
255
275
  throw new StromError("the bridge did not start", { hint: "strom live serve shows why" });
256
- return { text: lines(`the bridge runs: ${info.url}`, ` the Strom app following it: ${liveAppUrl(info.url, ctx.env)}`), data: { ...info, app: liveAppUrl(info.url, ctx.env) } };
276
+ return { text: lines(`the bridge runs: ${info.url}`, ` the Strom app following it: ${liveAppUrl(info.url, ctx.settings)}`), data: { ...info, app: liveAppUrl(info.url, ctx.settings) } };
257
277
  },
258
278
  }, {
259
279
  path: ["live", "stop"],
@@ -280,14 +300,14 @@ register({
280
300
  group: "output",
281
301
  run(ctx) {
282
302
  const lang = ctx.uiLang();
283
- const app = installedStromApp(ctx.env);
303
+ const app = installedStromApp(ctx.env, process.platform, stromAppUrl(ctx.settings));
284
304
  if (app) {
285
305
  noticeStromApp(ctx.settings, ctx.env, { look: true });
286
306
  const opened = openForUser(app.path, ctx.env);
287
307
  return { text: ui(lang, "ui.app.installed", { kind: app.kind }), data: { installed: app, opened } };
288
308
  }
289
- const opened = openForUser(stromAppUrl(ctx.env), ctx.env);
290
- return { text: opened ? ui(lang, "ui.app.install") : ui(lang, "ui.app.url", { url: stromAppUrl(ctx.env) }), data: { installed: false, opened, url: stromAppUrl(ctx.env) } };
309
+ const opened = openForUser(stromAppUrl(ctx.settings), ctx.env);
310
+ return { text: opened ? ui(lang, "ui.app.install") : ui(lang, "ui.app.url", { url: stromAppUrl(ctx.settings) }), data: { installed: false, opened, url: stromAppUrl(ctx.settings) } };
291
311
  },
292
312
  });
293
313
  register({
@@ -12,11 +12,12 @@ 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
14
  import { resolvePerson } from "../core/people.js";
15
+ import { foldText } from "../core/text.js";
15
16
  import { now, typeOfId } from "../core/tree.js";
16
17
  import { resolveResearch } from "./research.js";
17
18
  import { makeNote } from "../core/actions.js";
18
19
  import { lacksImages, rankTasks } from "../core/queue.js";
19
- import { inboxFolderFor, parseImageList } from "../core/media.js";
20
+ import { clipNote, inboxFolderFor, parseImageList, transcriptNote } from "../core/media.js";
20
21
  function written(tree) {
21
22
  return lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined);
22
23
  }
@@ -76,7 +77,7 @@ export function rankedQueue(tree, filter = {}) {
76
77
  .list("task")
77
78
  .filter((t) => !filter.research || !t.research || t.research === filter.research)
78
79
  .filter((t) => !filter.level || t.level === filter.level);
79
- return rankTasks(tree, tasks, filter.strategy);
80
+ return rankTasks(tree, tasks, filter.strategy, { storyTurn: filter.storyTurn });
80
81
  }
81
82
  function taskLine(t) {
82
83
  return [t.id, `p${t.priority}`, t.level, t.state === "open" ? "" : `[${t.state}]`, truncate(t.what, 70)];
@@ -107,6 +108,20 @@ function stateChange(tree, ref, state, fields, reason, verb) {
107
108
  return next;
108
109
  }, { op: `task.${verb}`, summary: `${id} ${verb}${fields.result ? `: ${truncate(fields.result, 60)}` : ""}`, reason });
109
110
  }
111
+ /** Tasks still to be done — the ones a new task must not repeat. */
112
+ const OPEN_STATES = ["open", "doing", "waiting", "parked"];
113
+ /** How alike two tasks' texts must be to be the same work: anywhere, or in the same books. */
114
+ const SAME_TEXT = 0.75;
115
+ const SAME_TEXT_IN_BOOK = 0.6;
116
+ /** How alike two task texts are (0–1): the share of their words in common, any script, accents and punctuation aside. */
117
+ export function alikeness(a, b) {
118
+ const words = (t) => new Set(foldText(t).split(/[^\p{L}\p{M}\p{N}]+/u).filter(Boolean));
119
+ const [x, y] = [words(a), words(b)];
120
+ if (!x.size || !y.size)
121
+ return 0;
122
+ const common = [...x].filter((w) => y.has(w)).length;
123
+ return common / (x.size + y.size - common);
124
+ }
110
125
  register({
111
126
  path: ["task", "add"],
112
127
  summary: "Add a task: what, where, why, done-when (all required)",
@@ -126,6 +141,7 @@ register({
126
141
  { name: "about", type: "string", multiple: true, value: "<who>", description: "person/record the task is about, also a conflict X… or hypothesis H… it decides (repeatable)" },
127
142
  { name: "research", type: "string", value: "<G…>", description: "research it belongs to (default: the only active one)" },
128
143
  { name: "note", type: "string", value: "<text>", description: "short note" },
144
+ { name: "anyway", type: "boolean", description: "add it although an open task of the same person and level reads the same (it is really other work)" },
129
145
  ],
130
146
  examples: [
131
147
  'strom task add "Křest Jana Nováka (~1905)" --level link --where B0001 --why "potvrdí otce Josefa" --done-when "zápis nalezen, nebo ročníky 1903–1907 prohledány celé" --about P0001 --priority 4',
@@ -141,6 +157,24 @@ register({
141
157
  if (!Number.isInteger(priority) || priority < 1 || priority > 5)
142
158
  throw new UsageError("--priority must be 1..5");
143
159
  const where = listOpt(opts.where).map((w) => (/^[Bb]\d+$/.test(w) ? requireRecord(tree, w, "recordset").id : w));
160
+ const subject = subjects(tree, listOpt(opts.about));
161
+ const research = defaultResearch(tree, opts.research);
162
+ // The same work again: an open task of the same person and level that reads the same (or nearly, in the same
163
+ // books) — not added; what is new goes into that one.
164
+ if (!opts.anyway) {
165
+ const books = new Set(where.filter((w) => /^B\d+$/.test(w)));
166
+ const same = tree
167
+ .list("task")
168
+ .filter((x) => OPEN_STATES.includes(x.state) && x.level === opts.level && x.research === research && x.subject.some((s) => subject.includes(s)))
169
+ .map((x) => ({ x, alike: alikeness(x.what, args[0]), books: x.where.some((w) => books.has(w)) }))
170
+ .filter((m) => m.alike >= SAME_TEXT || (m.books && m.alike >= SAME_TEXT_IN_BOOK))
171
+ .sort((a, b) => b.alike - a.alike)[0];
172
+ if (same)
173
+ throw new UsageError(`a task like this is open already: ${same.x.id} p${same.x.priority} ${same.x.level} "${truncate(same.x.what, 70)}"`, {
174
+ hint: `add to it: strom task edit ${same.x.id} --priority … --where … --note "…" — or, if it is really other work: --anyway`,
175
+ details: { task: same.x.id },
176
+ });
177
+ }
144
178
  const t = create(tree, "task", {
145
179
  level: opts.level,
146
180
  priority,
@@ -148,8 +182,8 @@ register({
148
182
  where,
149
183
  why: String(opts.why).trim(),
150
184
  doneWhen: String(opts["done-when"]).trim(),
151
- subject: subjects(tree, listOpt(opts.about)),
152
- research: defaultResearch(tree, opts.research),
185
+ subject,
186
+ research,
153
187
  state: "open",
154
188
  origin: tree.actor,
155
189
  note: opts.note,
@@ -158,7 +192,7 @@ register({
158
192
  const books = new Set(where.filter((w) => /^B\d+$/.test(w)));
159
193
  const similar = tree
160
194
  .list("task")
161
- .filter((x) => x.id !== t.id && ["open", "doing", "waiting", "parked"].includes(x.state) && x.level === t.level)
195
+ .filter((x) => x.id !== t.id && OPEN_STATES.includes(x.state) && x.level === t.level)
162
196
  .filter((x) => x.subject.some((s) => t.subject.includes(s)) && (books.size === 0 || x.where.some((w) => books.has(w))));
163
197
  // A link without images can only wait for the user: say now what to download, while
164
198
  // it is known — not in a session spent finding out that there is nothing to read.
@@ -338,7 +372,10 @@ register({
338
372
  for (const id of new Set([...t.subject, ...t.where]))
339
373
  if (typeOfId(id) === "input" && tree.get(id)?.state === "new")
340
374
  update(tree, id, "input", (i) => ({ ...i, state: "processed" }), { op: "input.done", summary: `${id} processed (${t.id} done)` });
341
- return { text: lines(written(tree), noSearch, handover), data: { task: t } };
375
+ // The entries it recorded from scans: each with where it is on its image.
376
+ const recorded = (t.produced ?? []).filter((id) => typeOfId(id) === "source").flatMap((id) => tree.get(id) ?? []);
377
+ const unclipped = recorded.flatMap((s) => [clipNote(tree, s), transcriptNote(tree, s)].filter((n) => !!n));
378
+ return { text: lines(written(tree), noSearch, handover, ...unclipped), data: { task: t } };
342
379
  },
343
380
  }, {
344
381
  path: ["task", "park"],