strom-research 1.6.1 → 1.8.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 (47) hide show
  1. package/README.md +9 -0
  2. package/assets/lang/cs.json +150 -4
  3. package/assets/lang/de.json +150 -4
  4. package/assets/method/narrate.md +5 -1
  5. package/assets/plugins/gates/claude-usage/gate.ts +2 -1
  6. package/dist/agents/files.js +6 -4
  7. package/dist/cli/main.js +21 -3
  8. package/dist/cli/menu-links.js +35 -1
  9. package/dist/cli/menu-research.js +41 -2
  10. package/dist/cli/menu-settings.js +17 -1
  11. package/dist/cli/menu.js +47 -9
  12. package/dist/cli/ui.js +90 -3
  13. package/dist/commands/connectors.js +12 -8
  14. package/dist/commands/guide.js +6 -1
  15. package/dist/commands/index.js +1 -0
  16. package/dist/commands/links.js +39 -3
  17. package/dist/commands/media.js +11 -3
  18. package/dist/commands/pack.js +134 -0
  19. package/dist/commands/research.js +42 -1
  20. package/dist/commands/session.js +79 -9
  21. package/dist/commands/setup.js +3 -0
  22. package/dist/commands/start.js +6 -3
  23. package/dist/commands/story.js +47 -6
  24. package/dist/commands/tasks.js +15 -6
  25. package/dist/commands/trees.js +82 -2
  26. package/dist/core/changelog.js +223 -0
  27. package/dist/core/clock.js +20 -0
  28. package/dist/core/config.js +2 -0
  29. package/dist/core/connector.js +25 -1
  30. package/dist/core/directions.js +41 -0
  31. package/dist/core/links.js +17 -4
  32. package/dist/core/live.js +393 -50
  33. package/dist/core/pack.js +342 -0
  34. package/dist/core/phrases.js +61 -0
  35. package/dist/core/queue.js +7 -2
  36. package/dist/core/relocate.js +4 -4
  37. package/dist/core/session.js +8 -2
  38. package/dist/core/trash.js +72 -0
  39. package/dist/core/tree.js +1 -1
  40. package/dist/core/views.js +10 -2
  41. package/dist/core/workers.js +28 -3
  42. package/dist/core/zip.js +226 -0
  43. package/dist/runners/claude.js +2 -1
  44. package/dist/runners/jsonl.js +2 -2
  45. package/dist/runners/runner.js +16 -3
  46. package/dist/runners/script.js +2 -1
  47. package/package.json +1 -1
@@ -6,6 +6,7 @@ import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { agentReady, droppedPaths, outOfAnswers, pause, pickPerson, subMenu, translator } from "./menu-parts.js";
8
8
  import { Tree } from "../core/tree.js";
9
+ import { directionOf, scopes } from "../core/directions.js";
9
10
  import { displayName, lifespan } from "../core/people.js";
10
11
  import { DEATH_AFTER_YEARS, unprovenPeople } from "../core/review.js";
11
12
  import { planSync, readTreeFile, receivedSince } from "../core/sync.js";
@@ -26,10 +27,12 @@ export async function addToResearch(ctx, run, lang, root) {
26
27
  items.push({ key: "3", label: t("ui.menu.review"), act: async () => void (await reviewPerson(ctx, run, lang, root)) });
27
28
  items.push({ key: "4", label: t(noApp(ctx) ? "ui.more.sync.noapp" : "ui.more.sync"), act: async () => void (await syncTree(ctx, run, lang, root)) });
28
29
  }
30
+ if (tree.count("research") > 0)
31
+ items.push({ key: "5", label: t("ui.more.directions"), act: async () => void (await directions(ctx, run, lang, root)) });
29
32
  // only when there are some: the people no record of their own proves — last, it shows only sometimes
30
33
  const unproven = unprovenPeople(tree).filter((u) => !u.living).length;
31
34
  if (unproven)
32
- items.push({ key: "5", label: t("ui.menu.unproven", { n: unproven }), act: async () => void (await reviewUnproven(ctx, run, lang, root)) });
35
+ items.push({ key: String(items.length + 1), label: t("ui.menu.unproven", { n: unproven }), act: async () => void (await reviewUnproven(ctx, run, lang, root)) });
33
36
  return { title: t("ui.more.title"), items };
34
37
  });
35
38
  }
@@ -175,6 +178,13 @@ export async function startDirection(ctx, run, lang, root, direction, who, name,
175
178
  : undefined;
176
179
  if (same)
177
180
  return void ctx.io.stdout(t("ui.research.exists", { name: same.name }) + "\n");
181
+ // …paused or ended: taken up again rather than made twice
182
+ const stopped = focus && direction !== "question" ? Tree.open(root, ctx.env).list("research").find((r) => r.focus === focus && r.direction === direction) : undefined;
183
+ if (stopped) {
184
+ if (await ctx.confirm(t("ui.research.resume", { name: stopped.name, state: t(`ui.dirs.${stopped.state}`) })))
185
+ await setDirection(ctx, run, lang, root, stopped, "resume");
186
+ return;
187
+ }
178
188
  const suggested = question ?? t(direction === "ancestors" ? "ui.research.title.ancestors" : "ui.research.title.descendants", { name });
179
189
  const title = (await ctx.ask(t("ui.research.title"), suggested)).trim();
180
190
  if (title === "0")
@@ -193,7 +203,36 @@ export async function startDirection(ctx, run, lang, root, direction, who, name,
193
203
  if (knows && knows !== "0")
194
204
  await run(["intake", `--text=${knows}`, "--research", made.id], true);
195
205
  ctx.io.stdout(t("ui.research.done", { name: made.name }) + "\n");
196
- await offerChat(ctx, run, lang, root, t("ui.research.chat"), t("ui.research.say", { name: made.name }));
206
+ await offerChat(ctx, run, lang, root, t("ui.research.chat"), t("ui.research.say", { name: made.name, id: made.id }));
207
+ }
208
+ /** A direction's tasks not finished: open, at work, put aside or waiting for the user. */
209
+ export function directionTasks(tree, research) {
210
+ const all = scopes(tree);
211
+ return tree.list("task").filter((x) => ["open", "doing", "parked", "waiting"].includes(x.state) && directionOf(tree, x, all) === research).length;
212
+ }
213
+ /** The directions of the research, each with how it goes and its tasks: one paused, ended or taken up again. */
214
+ async function directions(ctx, run, lang, root) {
215
+ const t = translator(lang);
216
+ const tree = Tree.open(root, ctx.env);
217
+ const all = tree.list("research");
218
+ const label = (r) => t("ui.dirs.item", { name: r.name, state: t(`ui.dirs.${r.state}`), n: directionTasks(tree, r.id) });
219
+ const i = await ctx.choose(t("ui.dirs.pick"), all.map((r) => ({ label: label(r) })), all.length, { back: t("ui.browse.back") });
220
+ if (i === undefined)
221
+ return;
222
+ const r = all[i];
223
+ const acts = r.state === "active" ? ["pause", "done"] : r.state === "paused" ? ["resume", "done"] : ["resume"];
224
+ const a = await ctx.choose(t("ui.dirs.what", { name: r.name }), acts.map((x) => ({ label: t(`ui.dirs.do.${x}`) })), acts.length, { back: t("ui.browse.back") });
225
+ if (a === undefined)
226
+ return;
227
+ await setDirection(ctx, run, lang, root, r, acts[a]);
228
+ }
229
+ /** A direction paused, ended or taken up again (with the person's reason), and what that means for its tasks. */
230
+ export async function setDirection(ctx, run, lang, root, r, act, reason) {
231
+ const t = translator(lang);
232
+ if ((await run(["research", act, r.id, ...(reason ? [`--reason=${reason}`] : [])], true)) !== 0)
233
+ return;
234
+ const state = act === "pause" ? "paused" : act === "resume" ? "active" : "done";
235
+ ctx.io.stdout(t(`ui.dirs.${state}.now`, { name: r.name, n: directionTasks(Tree.open(root, ctx.env), r.id) }) + "\n");
197
236
  }
198
237
  /** The person said no to the Strom app: it is not named. */
199
238
  const noApp = (ctx) => stromAppState(ctx.settings) === "no";
@@ -226,7 +226,9 @@ async function archives(ctx, run, lang, shared, root) {
226
226
  const saved = loadLogins(ctx.env);
227
227
  const connectors = listConnectors(shared);
228
228
  const consent = ctx.settings.connectorsConsent();
229
- const agent = ctx.settings.agent(root ? Tree.open(root, ctx.env).config : undefined).value;
229
+ const treeConfig = root ? Tree.open(root, ctx.env).config : undefined;
230
+ const agent = ctx.settings.agent(treeConfig).value;
231
+ const always = ctx.settings.resolve("agent.browser", treeConfig)?.value === "always";
230
232
  const via = (c) => (c.manifest.policy.automation === "manual" ? "manual" : routeOf(ctx.env, c).via);
231
233
  // Each downloader on a line of its own: how its images come, and its login where it can use one.
232
234
  const lines = connectors.map((c) => {
@@ -260,6 +262,20 @@ async function archives(ctx, run, lang, shared, root) {
260
262
  await run(["config", "set", "connectors.consent", consent ? "off" : "on"], true);
261
263
  },
262
264
  },
265
+ // The browser for the agent in every session: Claude Code's alone, shown only when it does the research and is here.
266
+ ...(claudeHere(ctx, treeConfig)
267
+ ? [
268
+ {
269
+ key: "b",
270
+ label: t("ui.archives.agentbrowser", { state: t(always ? "ui.archives.agentbrowser.always" : "ui.archives.agentbrowser.archives") }),
271
+ act: async () => {
272
+ out(t("ui.archives.agentbrowser.about"));
273
+ if (await ctx.confirm(t(always ? "ui.archives.agentbrowser.off" : "ui.archives.agentbrowser.on"), false))
274
+ await run(always ? ["config", "unset", "agent.browser"] : ["config", "set", "agent.browser", "always"], true);
275
+ },
276
+ },
277
+ ]
278
+ : []),
263
279
  ...switchable.map((c) => {
264
280
  const next = via(c) === "browser" ? "direct" : "browser";
265
281
  const name = c.manifest.title ?? c.name;
package/dist/cli/menu.js CHANGED
@@ -25,7 +25,9 @@ import { liveWorkers, runsAtWork } from "../core/workers.js";
25
25
  import { askStromApp } from "./wizard.js";
26
26
  import { openForUser } from "../core/open.js";
27
27
  import { displayName } from "../core/people.js";
28
- import { agentReady, guarded, pause as partsPause, pickPerson, subMenu } from "./menu-parts.js";
28
+ import { agentReady, droppedPaths, guarded, pause as partsPause, pickPerson, subMenu } from "./menu-parts.js";
29
+ import { packPlan } from "../core/pack.js";
30
+ import { expandHome } from "../core/paths.js";
29
31
  import { offerAgent } from "./fixes.js";
30
32
  import { addToResearch } from "./menu-research.js";
31
33
  import { waitingForYou } from "./menu-waiting.js";
@@ -201,8 +203,11 @@ export async function runMenu(ctx, run) {
201
203
  ctx.settings.config.stromAppFollow = follow ? "yes" : "no";
202
204
  ctx.settings.save();
203
205
  }
204
- if (follow)
205
- await run(["app", "--live"]);
206
+ // opened when the first session starts: a run may wait for its gate first
207
+ if (follow) {
208
+ args.push("--follow");
209
+ out(t("ui.run.follow.later"));
210
+ }
206
211
  }
207
212
  out(t("ui.run.start"));
208
213
  await run(["run", ...args]);
@@ -244,13 +249,15 @@ export async function runMenu(ctx, run) {
244
249
  await run(["chat", "--agent", others[i].id]);
245
250
  },
246
251
  };
252
+ // Another tree, right after the research's own items.
253
+ items.push({ key: "6", label: known.length > 1 ? t("ui.menu.trees") : t("ui.menu.newtree"), act: async () => pickTree(ctx, run, lang) });
247
254
  // The Strom app: open the research in it (live while an agent is at work); not known yet: what it is, and whether they want it.
248
255
  const app = stromAppState(ctx.settings);
249
256
  if (app !== "no") {
250
257
  const atWork = liveWorkers(root).length > 0 || runsAtWork(root).length > 0;
251
258
  const hasApp = app !== "unknown" || Boolean(installedStromApp(ctx.env, process.platform, stromAppUrl(ctx.settings)));
252
259
  appItem = {
253
- key: "5",
260
+ key: "7",
254
261
  label: t(!hasApp ? "ui.menu.app.new" : atWork ? "ui.menu.app.watch" : "ui.menu.app"),
255
262
  act: async () => openStromApp(ctx, run, lang, hasApp),
256
263
  };
@@ -262,12 +269,9 @@ export async function runMenu(ctx, run) {
262
269
  // Several trees and none chosen: pick one first.
263
270
  items.push({ key: "1", label: t("ui.menu.trees"), act: async () => pickTree(ctx, run, lang) });
264
271
  }
265
- // What changes nothing of the research: the newer strom, the settings of this computer (with the check), another tree.
266
- // At most nine: the newer strom is taken in the settings, the line above says where.
267
- const settings = { key: "6", label: t("ui.menu.settings"), act: async () => settingsMenu(ctx, run, lang, root, newer) };
272
+ // The settings of this computer (with the check); at most nine: the newer strom is taken in the settings, the line above says where.
273
+ const settings = { key: "8", label: t("ui.menu.settings"), act: async () => settingsMenu(ctx, run, lang, root, newer) };
268
274
  items.push(settings);
269
- if (root)
270
- items.push({ key: "8", label: known.length > 1 ? t("ui.menu.trees") : t("ui.menu.newtree"), act: async () => pickTree(ctx, run, lang) });
271
275
  // Only with several agents here: last, so that it moves no other number.
272
276
  if (other)
273
277
  items.push(other);
@@ -423,6 +427,10 @@ async function pickTree(ctx, run, lang) {
423
427
  }
424
428
  const at = known.findIndex((k) => k.root === here);
425
429
  const options = [...known.map((k) => ({ label: `${k.name} (${ctx.display(k.root)})` })), { label: ui(lang, "ui.trees.new") }];
430
+ // handing a research over: this one packed, one someone sent unpacked
431
+ const packAt = at >= 0 ? options.push({ label: ui(lang, "ui.trees.pack", { name: known[at].name }) }) - 1 : -1;
432
+ const unpackAt = options.push({ label: ui(lang, "ui.trees.unpack") }) - 1;
433
+ const removeAt = known.length ? options.push({ label: ui(lang, "ui.trees.remove") }) - 1 : -1;
426
434
  const back = at >= 0 ? { back: ui(lang, "ui.back.stay", { name: known[at].name }) } : {};
427
435
  const i = await ctx.choose(ui(lang, "ui.trees.pick"), options, Math.max(0, at), back);
428
436
  if (i === undefined || i === at)
@@ -431,6 +439,36 @@ async function pickTree(ctx, run, lang) {
431
439
  await run(["trees", "use", known[i].root], true);
432
440
  return;
433
441
  }
442
+ if (i === packAt) {
443
+ const tree = Tree.open(known[at].root, ctx.env);
444
+ const shared = ctx.settings.shared().value;
445
+ const size = (all) => (packPlan(tree, shared, all).media.reduce((s, m) => s + m.size, 0) / 1e6).toFixed(0);
446
+ const which = await ctx.choose(ui(lang, "ui.pack.which"), [{ label: ui(lang, "ui.pack.opt.records", { mb: size(false) }) }, { label: ui(lang, "ui.pack.opt.all", { mb: size(true) }) }], 0, { back: ui(lang, "ui.browse.back") });
447
+ if (which === undefined)
448
+ return;
449
+ await run(["pack", "--tree", tree.root, ...(which === 1 ? ["--all-images"] : [])]);
450
+ await partsPause(ctx, lang);
451
+ return;
452
+ }
453
+ if (i === removeAt) {
454
+ const which = await ctx.choose(ui(lang, "ui.remove.which"), known.map((k) => ({ label: `${k.name} (${ctx.display(k.root)})` })), known.length, { back: ui(lang, "ui.browse.back") }); // Enter goes back
455
+ if (which === undefined)
456
+ return;
457
+ await run(["trees", "remove", known[which].root]);
458
+ await partsPause(ctx, lang);
459
+ return;
460
+ }
461
+ if (i === unpackAt) {
462
+ const answer = (await ctx.ask(ui(lang, "ui.unpack.file"))).trim();
463
+ if (!answer || answer === "0")
464
+ return;
465
+ const resolve = (p) => path.resolve(ctx.cwd, expandHome(p, ctx.env));
466
+ const dropped = droppedPaths(answer).map(resolve);
467
+ const file = dropped.length === 1 && fs.existsSync(dropped[0]) ? dropped[0] : resolve(answer);
468
+ await run(["unpack", file]);
469
+ await partsPause(ctx, lang);
470
+ return;
471
+ }
434
472
  const name = (await ctx.ask(ui(lang, "ui.tree.name.new"), ui(lang, "ui.tree.default"))).trim();
435
473
  if (name === "0")
436
474
  return;
package/dist/cli/ui.js CHANGED
@@ -41,8 +41,8 @@ export const UI = {
41
41
  "ui.menu.waitlist": "What waits for you",
42
42
  "ui.menu.settings": "Settings: the agent, working alone, messages, archives, the check",
43
43
  "ui.menu.doctor": "Check the installation",
44
- "ui.menu.trees": "Another family tree",
45
- "ui.menu.newtree": "Create a family tree",
44
+ "ui.menu.trees": "Another family tree · send or receive one",
45
+ "ui.menu.newtree": "Create a family tree · send or receive one",
46
46
  "ui.menu.quit": "Quit",
47
47
  "ui.menu.bye": "See you next time. Start again with: strom",
48
48
  "ui.run.how": "How many tasks? (it stops earlier when there is nothing to do; 0 goes back)",
@@ -104,6 +104,7 @@ export const UI = {
104
104
  "ui.menu.app.live": "The agent is at work — you can watch it live in the Strom app: choice {key}.",
105
105
  "ui.app.open.now": "Once it is installed: open the research in it?",
106
106
  "ui.run.follow": "Watch the work live in the Strom app meanwhile?",
107
+ "ui.run.follow.later": "The Strom app opens when the agent begins its first session.",
107
108
  "ui.app.opened": "Opening the Strom app ({where}).",
108
109
  "ui.app.browser": "in your browser",
109
110
  "ui.app.url": "Open it here: {url}",
@@ -162,6 +163,7 @@ export const UI = {
162
163
  "ui.run.stop.resets": " (resets {at})",
163
164
  "ui.run.stop.auth": "the agent is not logged in — run {command} once and log in",
164
165
  "ui.run.stop.failed": "the agent failed: {detail}",
166
+ "ui.run.finishing": "■ Ctrl-C: session {session} is asked to finish — the agent writes down what it found and closes it, then the run stops (no next session). Ctrl-C again stops it now.",
165
167
  "ui.run.stopping": "■ stopping — the session is closed and its task goes back to the queue; Ctrl-C again to quit at once",
166
168
  "ui.run.gone": "· {session} closed — its run had stopped{back}",
167
169
  "ui.run.back": "; {task} is back in the queue",
@@ -515,6 +517,14 @@ export const UI = {
515
517
  "ui.consent.gate.set": "Let the condition “{name}” decide when the agent working alone goes on?",
516
518
  "ui.consent.gate.unset": "Remove the condition “{name}” — the agent working alone no longer asks it?",
517
519
  "ui.consent.gate.skip": "Let the agent work on its own without the condition “{name}”?",
520
+ "ui.run.browser.always": "· the browser (Claude in Chrome) in every session, as you set it (agent.browser always) — without asking for {hosts}; Chrome with the Claude extension must be running",
521
+ "ui.consent.browser": "Give the agent browser tools (Claude in Chrome) in every research session? It works in your own Chrome — where you are signed in — for the sites of the research's archives without asking; in a conversation it asks about any other site.",
522
+ "ui.archives.agentbrowser": "The browser for the agent (Claude in Chrome): {state}",
523
+ "ui.archives.agentbrowser.archives": "only for archives through the browser",
524
+ "ui.archives.agentbrowser.always": "in every session",
525
+ "ui.archives.agentbrowser.about": "In every session, the agent can work in your own Chrome — where you are signed in — so you can ask it for anything through the browser. The sites of the research's archives without asking; in a conversation it asks about any other site, working alone it uses none other. Chrome with the Claude extension must be running.",
526
+ "ui.archives.agentbrowser.on": "Give the agent the browser in every session?",
527
+ "ui.archives.agentbrowser.off": "Back to the browser only for archives through the browser?",
518
528
  "ui.consent.remote": "Start the Claude Code sessions strom opens with Remote Control, so that you can follow and steer them from claude.ai or the Claude app on your phone?",
519
529
  "ui.consent.hook.on": "Let the hook “{name}” be told of everything saved into your researches? It is a program on this computer and does with it what it was written to do.",
520
530
  "ui.consent.hook.off": "Stop telling the hook “{name}” what is saved?",
@@ -546,6 +556,16 @@ export const UI = {
546
556
  "ui.link.what.chat.person": "a conversation with the agent ({agent}) about {person}.",
547
557
  "ui.link.chat.say": "Let us look at {person}: tell me briefly what the research knows of this person, and ask me what I want to find out.",
548
558
  "ui.link.what.review.person": "look at {person} again.",
559
+ "ui.link.what.direction.pause": "pause the direction “{name}”: its tasks ({n}) wait out of the queue and nothing new is proposed for it. Nothing is deleted.",
560
+ "ui.link.what.direction.done": "end the direction “{name}”: its tasks ({n}) leave the queue. Nothing is deleted, it can be taken up again.",
561
+ "ui.link.what.direction.resume": "take the direction “{name}” up again: its tasks ({n}) come back to the queue and the research proposes what is missing.",
562
+ "ui.link.direction.none": "Direction {id} is not in this research. Nothing was done.",
563
+ "ui.link.direction.cannot": "The direction “{name}” is {state} already. Nothing was done.",
564
+ "ui.link.direction.why": "Why? (a few words, kept with the direction; Enter: no reason)",
565
+ "ui.link.what.chat.research": "a conversation with the agent ({agent}) about the direction “{name}”, only its tasks.",
566
+ "ui.link.chat.research.say": "Let us work on the direction “{name}” ({id}), on its tasks.",
567
+ "ui.link.what.finish": "ask {agent} to finish session {session} ({task}): it writes down what it found and closes the session; working alone, it starts no next one.",
568
+ "ui.link.finish.none": "Session {session} is not at work (it has closed meanwhile). Nothing was done.",
549
569
  "ui.link.what.review.family": "look at {person} again, with the partners and children.",
550
570
  "ui.link.what.review.line": "look at {person} again, with the ancestors.",
551
571
  "ui.link.what.research.ancestors": "search for the ancestors of {person} (a new direction of the research).",
@@ -635,7 +655,7 @@ export const UI = {
635
655
  "ui.research.knows": "What do you know of it? Dates, places, stories of the family help the agent (Enter: nothing)",
636
656
  "ui.research.done": "✓ The research “{name}” is started.",
637
657
  "ui.research.chat": "Start on it with the agent now?",
638
- "ui.research.say": "I started a new research, “{name}” — let us begin with it.",
658
+ "ui.research.say": "I started a new research, “{name}” ({id}) — let us begin with it, with its tasks.",
639
659
  "ui.settings.title": "Settings (on this computer)",
640
660
  "ui.settings.wizard": "Language, agent, model, what the agent may do alone, stories… (the setup)",
641
661
  "ui.settings.alone": "The agent working alone: {minutes} min a task · condition: {gate}",
@@ -697,6 +717,20 @@ export const UI = {
697
717
  "ui.intake.empty": "There is nothing in it to take in.",
698
718
  "ui.intake.ours": "{path} is strom's own folder (the research itself), not material of the family.",
699
719
  "ui.research.exists": "This research is there already: “{name}” — the agent goes on with it.",
720
+ "ui.more.directions": "The directions of the research: pause, end or take one up again",
721
+ "ui.dirs.pick": "Which direction?",
722
+ "ui.dirs.item": "{name} — {state} · tasks: {n}",
723
+ "ui.dirs.active": "going on",
724
+ "ui.dirs.paused": "paused",
725
+ "ui.dirs.done": "ended",
726
+ "ui.dirs.what": "“{name}”:",
727
+ "ui.dirs.do.pause": "Pause it — its tasks wait, the agent takes others",
728
+ "ui.dirs.do.done": "End it — its tasks leave the queue (nothing is deleted)",
729
+ "ui.dirs.do.resume": "Take it up again — its tasks back in the queue",
730
+ "ui.dirs.paused.now": "⏸ “{name}” is paused: its tasks ({n}) wait until you take it up again (here).",
731
+ "ui.dirs.done.now": "■ “{name}” is ended: its tasks ({n}) are out of the queue — nothing is deleted, it can be taken up again here.",
732
+ "ui.dirs.active.now": "▶ “{name}” goes on: its tasks ({n}) are back in the queue.",
733
+ "ui.research.resume": "This research is there already, {state}: “{name}”. Take it up again?",
700
734
  "ui.alone.gate.bad": "Please a number of points from 0 to 100 (e.g. 10).",
701
735
  "ui.waiting.long": "That is long for an answer here (at most {n} characters) — a link or a sentence; the rest tell the agent in a conversation.",
702
736
  "ui.settings.remote.chats": "conversations yes, working alone no",
@@ -740,6 +774,59 @@ export const UI = {
740
774
  "ui.review.next.human": "The agent takes them up in a conversation or when it works alone.",
741
775
  "ui.review.wake.human": " to go on with it, tell the agent in a conversation",
742
776
  "ui.waiting.gone": "This one no longer waits for you — the agent has taken it up meanwhile.",
777
+ "ui.trees.pack": "pack \"{name}\" to send to someone (a ZIP file)",
778
+ "ui.trees.unpack": "a family tree someone sent you (a ZIP file)",
779
+ "ui.pack.which": "Which images go along?",
780
+ "ui.pack.opt.records": "the ones its records stand on — {mb} MB (the pages searched through the archive gives again)",
781
+ "ui.pack.opt.all": "all of them — {mb} MB",
782
+ "ui.pack.done": "Packed \"{name}\": {file} ({mb} MB)",
783
+ "ui.pack.saved": " the family tree as last saved, with its history",
784
+ "ui.pack.images": " images: {n} ({mb} MB) — the ones its records stand on and the ones nobody can fetch again",
785
+ "ui.pack.images.all": " images: all {n} ({mb} MB)",
786
+ "ui.pack.left": " left out: {n} images ({mb} MB) — pages searched through; strom fetches them from the archive again when they are needed",
787
+ "ui.pack.connectors": " connectors: {list}",
788
+ "ui.pack.noconnector": "⚠ some images left out came through a connector that is not on this computer — nobody could fetch them again: strom pack --all-images",
789
+ "ui.pack.missing": "⚠ {n} image(s) the family tree names are not on this computer — not in the package",
790
+ "ui.pack.send": "Send the file. The one who gets it unpacks it and double-clicks Windows.cmd or macOS.command inside: strom is installed where it is missing and the research opens (with strom already: strom unpack \"<the file>\").",
791
+ "ui.pack.readme.file": "HOW-TO-CONTINUE.txt",
792
+ "ui.pack.readme": "The research \"{name}\" - how to go on with it\n=============================================\n\n1. Unpack the whole ZIP file: on Windows right click it - Extract All; on a Mac double click it.\n\n2. In the unpacked folder start, by a double click:\n Windows: Windows.cmd\n macOS: macOS.command (the first time: right click - Open; when macOS refuses:\n System Settings - Privacy & Security - Open Anyway)\n Linux: in a terminal: bash Linux.sh\n It installs Strom Research where it is not yet (the official installer), puts the\n research in place - the family tree among yours, the images into your shared folder,\n all of it checked - takes it over on your computer and opens strom.\n\n3. Go on: in strom's menu, the conversation with the agent.\n\nWithout the launchers: install strom yourself\n Windows (PowerShell): {win}\n macOS, Linux (Terminal): {unix}\nthen: strom unpack \"<this ZIP file>\" (type strom unpack and a space, drag the file into\nthe terminal, press Enter) - or in strom's menu: another family tree - a family tree someone sent you.\n\nThe images the records stand on are in the package. The other pages of the books\n(searched through, nothing found on them) are not, for size - strom fetches them from\nthe archive again when they are needed.\n",
793
+ "ui.unpack.file": "Drag the ZIP file here (or type where it is; 0 goes back):",
794
+ "ui.unpack.what": "\"{name}\" (sent from strom {strom}, saved {date}): {images} image(s){connectors} → {root}",
795
+ "ui.unpack.ask": "Put it here and take it over on this computer?",
796
+ "ui.unpack.done": "\"{name}\" is here: {root}",
797
+ "ui.unpack.images": " {n} image(s) into the shared folder{had}",
798
+ "ui.unpack.had": " ({n} were here already)",
799
+ "ui.unpack.connectors": " connectors: {list}",
800
+ "ui.unpack.kept": " connector {name} was here already — yours stays",
801
+ "ui.unpack.left": " {n} pages searched through are not in it: strom fetches them from the archive when they are needed",
802
+ "ui.unpack.adopted": " taken over on this computer",
803
+ "ui.unpack.next": "Go on: strom (the menu) or strom chat.",
804
+ "ui.unpack.same": "this research is here already: {root}",
805
+ "ui.unpack.person": "only the person at the computer unpacks a research (it adds connectors)",
806
+ "ui.pack.launch.extract": "First unpack the whole ZIP file - on Windows: right click, Extract All - then start this again from the unpacked folder.",
807
+ "ui.pack.launch.failed": "Strom Research could not be installed. Install it by the line in HOW-TO-CONTINUE.txt and run this again.",
808
+ "ui.trees.remove": "take a family tree off this computer",
809
+ "ui.session.finish.done": "Session {session} (task {task}) is asked to finish: its agent sees it at its next strom command, writes down what it found and closes the session.",
810
+ "ui.session.finish.none": "no session is at work in this research",
811
+ "ui.session.finish.which": "several sessions are at work ({sessions}) — say which one",
812
+ "ui.session.finish.closed": "session {session} is closed already",
813
+ "ui.remove.which": "Which family tree to take off this computer?",
814
+ "ui.remove.person": "only the person at the computer takes a family tree off it",
815
+ "ui.remove.busy": "somebody is at work on \"{name}\" right now — it can go once the work is over",
816
+ "ui.remove.what": "\"{name}\" ({root}) · people: {persons}",
817
+ "ui.remove.backup": "Pack it first as a backup, with all its images ({mb} MB)? It comes back any time with strom unpack.",
818
+ "ui.remove.backup.done": " backup: {file}",
819
+ "ui.remove.images": "Into the trash too the images no other family tree uses ({n}, {mb} MB)?",
820
+ "ui.remove.type": "To confirm, type the name of the family tree ({name}) — anything else takes nothing off:",
821
+ "ui.remove.cancelled": "Nothing was taken off.",
822
+ "ui.remove.done": "\"{name}\" is in the trash.",
823
+ "ui.remove.live": " the Strom app no longer follows it live",
824
+ "ui.remove.images.done": " its images in the trash: {n} ({mb} MB)",
825
+ "ui.remove.images.folder": "{name} – images",
826
+ "ui.remove.backup.back": " it comes back from the backup: strom unpack \"{file}\"",
827
+ "ui.remove.trash.alone": " it comes back from the system's trash, until it is emptied",
828
+ "ui.remove.next": "Now you work on \"{name}\" — another one: Another family tree in the menu.",
829
+ "ui.remove.trash": " or from the system's trash, until it is emptied",
743
830
  };
744
831
  /** A text for the person, in their language. */
745
832
  export function ui(lang, key, values = {}) {
@@ -1094,14 +1094,16 @@ async function fetchWith(ctx, c, request, recordset) {
1094
1094
  });
1095
1095
  if (recordset && request.cmd === "fetch") {
1096
1096
  // what is registered already is not asked for again (a part of an image is not the image)
1097
- const have = new Set(tree.list("media").filter((m) => m.recordset === recordset && !m.part && m.image !== undefined).map((m) => m.image));
1097
+ // …unless its file is not here (a research handed over without its images): fetched again, put back
1098
+ const here = (m) => fs.existsSync(path.join(shared(ctx), m.file));
1099
+ const have = new Set(tree.list("media").filter((m) => m.recordset === recordset && !m.part && m.image !== undefined && here(m)).map((m) => m.image));
1098
1100
  const asked = request.images;
1099
1101
  request.images = asked.filter((n) => !have.has(n));
1100
1102
  if (!request.images.length)
1101
1103
  return { text: `images ${runs(asked)} of ${recordset} are registered already — nothing fetched`, data: { added: [], again: asked } };
1102
1104
  }
1103
1105
  if (recordset && request.cmd === "part") {
1104
- const had = tree.list("media").find((m) => m.recordset === recordset && m.image === request.image && m.part && sameRegion(m.part, request.region));
1106
+ const had = tree.list("media").find((m) => m.recordset === recordset && m.image === request.image && m.part && sameRegion(m.part, request.region) && fs.existsSync(path.join(shared(ctx), m.file)));
1105
1107
  if (had)
1106
1108
  return { text: `part ${regionText(request.region)} of image ${request.image} of ${recordset} is registered already: ${had.id} — nothing fetched`, data: { added: [], again: [had.id] } };
1107
1109
  }
@@ -1158,16 +1160,18 @@ async function fetchWith(ctx, c, request, recordset) {
1158
1160
  ? lines(`part ${regionText(m.part)} of image ${request.image} of ${recordset} fetched and registered: ${m.id}${m.width ? ` · ${m.width}×${m.height} px` : ""}${gain ? ` · ${gain.toFixed(1)}× the detail of the whole image` : ""} · ${took}`, gain !== undefined && gain < 1.2 ? " no sharper than the whole image: the portal gives no more detail than that" : undefined, `look at it: strom media view ${recordset}:${request.image} --crop ${regionText(m.part)} (a view of the image uses the part by itself)`)
1159
1161
  : res.again.length && whole && res.again.includes(whole.id) && r.images.every((i) => isWhole(i.region))
1160
1162
  ? lines(`the portal's sharpest of image ${request.image} is the whole scan, registered already: ${whole.id}${whole.width ? ` · ${whole.width}×${whole.height} px` : ""} — no part needed`, `look at it: strom media view ${recordset}:${request.image} --crop ${regionText(request.region)}`)
1161
- : res.again.length
1162
- ? lines(`that part is registered already: ${res.again.join(" ")}`, ...res.clashes.map((x) => `⚠ ${x}`))
1163
- : "no part fetched";
1163
+ : res.restored.length
1164
+ ? `part ${regionText(request.region)} of image ${request.image} of ${recordset} fetched again and put back (its file was not here; the same scan): ${res.restored.join(" ")} · ${took}`
1165
+ : res.again.length
1166
+ ? lines(`that part is registered already: ${res.again.join(" ")}`, ...res.clashes.map((x) => `⚠ ${x}`))
1167
+ : "no part fetched";
1164
1168
  data = { ...data, added: res.added.map((x) => ({ id: x.id, image: x.image, part: x.part })), again: res.again };
1165
1169
  }
1166
1170
  else if (recordset && r.images.length) {
1167
1171
  const res = registerImages(tree, shared(ctx), r.images.map((i) => ({ file: i.file, image: i.n, url: i.url, from: from(i.file), fetched })), recordset);
1168
1172
  const nums = res.added.map((m) => m.image).filter((n) => n !== undefined);
1169
- text = lines(`${res.added.length} image(s) of ${recordset}${nums.length ? ` (images ${Math.min(...nums)}–${Math.max(...nums)})` : ""} fetched and registered · ${took}`, res.again.length ? `${res.again.length} already registered` : undefined, ...res.clashes.slice(0, 10).map((x) => `⚠ ${x}`), res.woken.length ? `back in the queue (they waited for these images): ${res.woken.join(" ")}` : undefined);
1170
- data = { ...data, added: res.added.map((m) => ({ id: m.id, image: m.image })), again: res.again, woken: res.woken, clashes: res.clashes };
1173
+ text = lines(res.added.length || !res.restored.length ? `${res.added.length} image(s) of ${recordset}${nums.length ? ` (images ${Math.min(...nums)}–${Math.max(...nums)})` : ""} fetched and registered · ${took}` : undefined, res.restored.length ? `${res.restored.length} image(s) of ${recordset} fetched again and put back (their file was not here; the same scan): ${res.restored.slice(0, 5).join(" ")}${res.restored.length > 5 ? " …" : ""} · ${took}` : undefined, res.again.length ? `${res.again.length} already registered` : undefined, ...res.clashes.slice(0, 10).map((x) => `⚠ ${x}`), res.woken.length ? `back in the queue (they waited for these images): ${res.woken.join(" ")}` : undefined);
1174
+ data = { ...data, added: res.added.map((m) => ({ id: m.id, image: m.image })), again: res.again, restored: res.restored, woken: res.woken, clashes: res.clashes };
1171
1175
  fs.rmSync(workDir, { recursive: true, force: true });
1172
1176
  }
1173
1177
  else if (r.images.length) {
@@ -1307,7 +1311,7 @@ async function takeOver(ctx, c, result) {
1307
1311
  if (plan.recordset) {
1308
1312
  const res = registerImages(tree, shared(ctx), mine.map((t) => ({ file: t.file, image: t.item.n, url: t.item.url, from: `connector ${c.name} · your browser · ${path.basename(t.file)}`, part: t.item.region, fetched })), plan.recordset);
1309
1313
  added.push(...res.added.map((m) => ({ id: m.id, ...(m.image !== undefined ? { image: m.image } : {}) })));
1310
- again.push(...res.again);
1314
+ again.push(...res.again, ...res.restored);
1311
1315
  notes.push(...res.clashes);
1312
1316
  woken.push(...res.woken);
1313
1317
  for (const t of mine)
@@ -59,6 +59,10 @@ conversation opened anywhere, a bot on its own server)
59
59
  then tell the user what was found (strom person card …) and what comes next (strom plan), and ask
60
60
  before the next session. The next step is strom's queue: what the user wants instead becomes a
61
61
  task first (strom task add …, or strom research new …) — never search outside a session and its task.
62
+ The user names a research (G…) to work on: its tasks only — strom session start --research G….
63
+ A side of the family no direction reaches (a spouse's line) is a direction of its own: strom research
64
+ new --person <them>. The user wants a direction to wait or to end: strom research pause|done G… (its
65
+ tasks leave the queue, nothing is deleted; strom research resume G… brings them back).
62
66
  In a conversation the next task is best begun in a fresh context (Claude Code: /clear) — suggest
63
67
  it to the user; many tasks waiting: the agent working on its own (strom run, the user starts it
64
68
  from strom's menu), every task in a fresh session
@@ -153,7 +157,8 @@ THE STROM APP'S EDITS, ANOTHER FAMILY TREE (the user changed the tree in the Str
153
157
  STORIES OF THE ANCESTORS (the setting stories — on by default; strom shows it)
154
158
  Once records tell a person's life (a baptism and more facts from records), strom proposes a narrate
155
159
  task: write the story for the family book — plain words, the research language, every statement on a
156
- recorded fact (strom story set P… --text @notes/story-P….md --fact E… …). More facts later: it
160
+ recorded fact (strom story set P… --text @notes/story-P….md --title "…" --fact E… …; paragraphs,
161
+ "## " subheadings, "- " bullets, **bold**, *italic* — nothing else shows in the app). More facts later: it
157
162
  proposes adding to it. Stories wait behind all the research; working alone
158
163
  (strom run), every few sessions one comes first. In a conversation the user leads: write one when they
159
164
  want it, or offer it when the research has a pause. A story is a draft until the user approves it (strom story approve P…; the Strom app asks it too). When strom says to tell
@@ -5,6 +5,7 @@ import "./trees.js";
5
5
  import "./research.js";
6
6
  import "./intake.js";
7
7
  import "./sync.js";
8
+ import "./pack.js";
8
9
  import "./tasks.js";
9
10
  import "./people.js";
10
11
  import "./batch.js";
@@ -80,6 +80,10 @@ function who(p) {
80
80
  function aboutPerson(lang, p) {
81
81
  return ui(lang, "ui.link.chat.say", { person: who(p) });
82
82
  }
83
+ /** The conversation's first message about one direction: its name and ID, from the research's own record. */
84
+ function aboutDirection(lang, r) {
85
+ return ui(lang, "ui.link.chat.research.say", { name: r.name, id: r.id });
86
+ }
83
87
  /** The conversation's first message about a conflict: its ID and title from the research itself. */
84
88
  function aboutConflict(root, ctx, id) {
85
89
  const tree = Tree.open(root, ctx.env);
@@ -121,7 +125,8 @@ async function inTerminal(ctx, link) {
121
125
  // commands, which ask themselves
122
126
  case "open":
123
127
  case "app":
124
- return { text: "", data: { done: (await run(link.action === "open" ? [] : ["app"])) === 0, action: link.action, tree: root } };
128
+ case "live":
129
+ return { text: "", data: { done: (await run(link.action === "open" ? [] : link.action === "live" ? ["app", "--live"] : ["app"])) === 0, action: link.action, tree: root } };
125
130
  case "update":
126
131
  case "setup":
127
132
  return end((await run([link.action])) === 0);
@@ -158,6 +163,14 @@ async function inTerminal(ctx, link) {
158
163
  const { undoSending } = await import("../cli/menu-links.js");
159
164
  return end(await undoSending(ctx, run, treeLang, root, link.intake));
160
165
  }
166
+ case "direction": {
167
+ const { directionDo } = await import("../cli/menu-links.js");
168
+ return end(await directionDo(ctx, run, treeLang, root, link.id, link.do));
169
+ }
170
+ case "finish": {
171
+ const { finishSession } = await import("../cli/menu-links.js");
172
+ return end(await finishSession(ctx, run, treeLang, root, link.session));
173
+ }
161
174
  }
162
175
  const named = "person" in link ? link.person : undefined;
163
176
  const person = named ? personOf(root, ctx, named) : undefined;
@@ -166,11 +179,20 @@ async function inTerminal(ctx, link) {
166
179
  const { asks } = await import("../cli/menu-links.js");
167
180
  switch (link.action) {
168
181
  case "chat": {
182
+ // one direction: its tasks only (the research's own name and ID in the first message)
183
+ const direction = link.research ? tree.get(link.research) : undefined;
184
+ if (link.research && direction?.type !== "research")
185
+ return said("ui.link.direction.none", { id: link.research });
169
186
  // the agent costs: said and asked first, then the conversation as strom chat starts it
170
- const what = person ? ui(treeLang, "ui.link.what.chat.person", { agent, person: who(person) }) : ui(treeLang, "ui.link.what.chat", { agent });
187
+ const what = direction
188
+ ? ui(treeLang, "ui.link.what.chat.research", { agent, name: direction.name })
189
+ : person
190
+ ? ui(treeLang, "ui.link.what.chat.person", { agent, person: who(person) })
191
+ : ui(treeLang, "ui.link.what.chat", { agent });
171
192
  if (!(await asks(ctx, treeLang, root, what, ui(treeLang, "ui.link.cost"))))
172
193
  return { text: "", data: { done: false, action: link.action, tree: root } };
173
- return { text: "", data: { done: (await run(["chat", ...(person ? [`--say=${aboutPerson(treeLang, person)}`] : [])])) === 0, action: link.action, tree: root } };
194
+ const first = direction ? aboutDirection(treeLang, direction) : person ? aboutPerson(treeLang, person) : undefined;
195
+ return { text: "", data: { done: (await run(["chat", ...(first ? [`--say=${first}`] : [])])) === 0, action: link.action, tree: root } };
174
196
  }
175
197
  case "story": {
176
198
  const { approveStory } = await import("../cli/menu-links.js");
@@ -206,6 +228,9 @@ async function withoutTerminal(ctx, link, root) {
206
228
  const run = await runnerOf(ctx)(root);
207
229
  if (link.action === "app")
208
230
  return { text: "", data: { done: (await run(["app"], true)) === 0, action: "app", tree: root } };
231
+ // followed live: the bridge started (or the one running), its address into the app's window — strom app --live
232
+ if (link.action === "live")
233
+ return { text: "", data: { done: (await run(["app", "--live"], true)) === 0, action: "live", tree: root } };
209
234
  if (link.action !== "chat" && !(link.action === "conflict" && link.do === "agent"))
210
235
  return undefined;
211
236
  const tree = Tree.open(root, ctx.env);
@@ -217,6 +242,12 @@ async function withoutTerminal(ctx, link, root) {
217
242
  if (!first)
218
243
  return say(ctx, "ui.link.conflict.none", { id: link.id }, { tree: link.tree });
219
244
  }
245
+ else if (link.action === "chat" && link.research) {
246
+ const direction = tree.get(link.research);
247
+ if (direction?.type !== "research")
248
+ return say(ctx, "ui.link.direction.none", { id: link.research }, { tree: link.tree });
249
+ first = aboutDirection(tree.lang, direction);
250
+ }
220
251
  else if (link.action === "chat" && link.person) {
221
252
  const person = personOf(root, ctx, link.person);
222
253
  if (!person)
@@ -251,6 +282,7 @@ register({
251
282
  "strom-research://excerpt?tree=<id>&source=S0042&clip=<its mark> — the excerpt from the scan, not made smaller, in the\n" +
252
283
  "viewer of the system (the page in the online archive when the scan is not here). Nothing is written.\n" +
253
284
  "strom-research://app?tree=<id> — the research as it is now into the app's open window (strom app).\n" +
285
+ "strom-research://live?tree=<id> — the research followed live in the app's window (strom app --live).\n" +
254
286
  "strom-research://open?tree=<id> — the research's menu in a terminal.\n" +
255
287
  "strom-research://chat?tree=<id>[&person=P0012] — a conversation with the agent (strom chat; of one person: its first\n" +
256
288
  "message names only who). strom-research://task?tree=<id>&task=T0007 — the task that waits for the user, answered.\n" +
@@ -260,6 +292,10 @@ register({
260
292
  "strom-research://conflict?tree=<id>&id=X0007&do=decide|agent — decided by the user (which claim, why) or left to the agent.\n" +
261
293
  "strom-research://story?tree=<id>&person=P0012&do=final — the story approved (strom story approve).\n" +
262
294
  "strom-research://sync-undo?tree=<id>&intake=I0042 — a sending from the app taken back (strom sync undo).\n" +
295
+ "strom-research://direction?tree=<id>&id=G0002&do=pause|done|resume — a direction paused, ended (the reason asked) or\n" +
296
+ "taken up again (strom research pause|done|resume).\n" +
297
+ "strom-research://chat?tree=<id>&research=G0002 — a conversation about one direction, its tasks only.\n" +
298
+ "strom-research://finish?tree=<id>&session=N0012 — the session at work asked to finish (strom session finish).\n" +
263
299
  "strom-research://update|sessions|setup?tree=<id> — strom update, the agent's sessions and their cost, strom setup.\n" +
264
300
  "strom-research://new?app=<the app's mark> — a tree of the app becomes a new research: named, made, the app hands the\n" +
265
301
  "tree to its bridge (?adopt=, GET/POST /adopt), taken in as leads, sent back to the app.\n" +
@@ -26,12 +26,20 @@ export function registerImages(tree, shared, items, recordset) {
26
26
  const known = new Map(tree.list("media").filter((m) => !m.retracted).map((m) => [m.sha, m]));
27
27
  const added = [];
28
28
  const again = [];
29
+ const restored = [];
29
30
  const clashes = [];
30
31
  const copies = [];
31
32
  tree.withTreeLock(() => {
32
33
  for (const it of items) {
33
34
  const sha = fileSha256(it.file);
34
35
  const k = known.get(sha);
36
+ if (k && !tree.dryRun && !fs.existsSync(path.join(shared, k.file))) {
37
+ // its file is not here (a research handed over without its images): the same scan, put back where it was
38
+ fs.mkdirSync(path.dirname(path.join(shared, k.file)), { recursive: true });
39
+ fs.copyFileSync(it.file, path.join(shared, k.file));
40
+ restored.push(k.id);
41
+ continue;
42
+ }
35
43
  if (k) {
36
44
  again.push(k.id);
37
45
  // the same scan as another image: a portal serving another book's images, or a file given the wrong number
@@ -85,7 +93,7 @@ export function registerImages(tree, shared, items, recordset) {
85
93
  });
86
94
  woken.push(t.id);
87
95
  }
88
- return { added, again, woken, clashes, copies };
96
+ return { added, again, restored, woken, clashes, copies };
89
97
  }
90
98
  function sharedDir(ctx) {
91
99
  return ctx.settings.shared().value;
@@ -264,7 +272,7 @@ register({
264
272
  hint: "rename the files by the image number the viewer shows (9.jpg), or --image <n> for one file",
265
273
  });
266
274
  }
267
- const { added, again, woken, clashes, copies } = registerImages(tree, shared, items, recordset);
275
+ const { added, again, restored, woken, clashes, copies } = registerImages(tree, shared, items, recordset);
268
276
  if (opts.inbox && !tree.dryRun) {
269
277
  for (const f of files)
270
278
  fs.rmSync(f, { force: true }); // now in the store
@@ -275,7 +283,7 @@ register({
275
283
  const nums = added.map((m) => m.image).filter((n) => n !== undefined);
276
284
  const text = lines(added.length
277
285
  ? `${added.length} image(s) registered: ${added[0].id}${added.length > 1 ? `–${added.at(-1).id}` : ""}${recordset ? ` of ${recordset}` : ""}${nums.length ? ` (images ${runs(nums)})` : ""}`
278
- : "no new images", again.length ? `${again.length} already registered (same content): ${again.slice(0, 5).join(" ")}${again.length > 5 ? " …" : ""}` : undefined, ...clashes.slice(0, 10).map((c) => `⚠ ${c}`), ...copies.slice(0, 10), copies.length > 10 ? `… and ${copies.length - 10} more images with a sharper copy` : undefined, woken.length ? `back in the queue (they waited for these images): ${woken.join(" ")}` : undefined, tree.dryRun ? "(dry run — nothing written)" : undefined, added[0] ? `\nlook at one: strom media view ${recordset && added[0].image !== undefined ? `${recordset}:${added[0].image}` : added[0].id} --grid` : undefined);
286
+ : restored.length ? undefined : "no new images", restored.length ? `${restored.length} image(s) put back (their file was not here; the same scan): ${restored.slice(0, 5).join(" ")}${restored.length > 5 ? " …" : ""}` : undefined, again.length ? `${again.length} already registered (same content): ${again.slice(0, 5).join(" ")}${again.length > 5 ? " …" : ""}` : undefined, ...clashes.slice(0, 10).map((c) => `⚠ ${c}`), ...copies.slice(0, 10), copies.length > 10 ? `… and ${copies.length - 10} more images with a sharper copy` : undefined, woken.length ? `back in the queue (they waited for these images): ${woken.join(" ")}` : undefined, tree.dryRun ? "(dry run — nothing written)" : undefined, added[0] ? `\nlook at one: strom media view ${recordset && added[0].image !== undefined ? `${recordset}:${added[0].image}` : added[0].id} --grid` : undefined);
279
287
  return { text, data: { added: added.map((m) => ({ id: m.id, image: m.image })), again, woken, clashes, copies } };
280
288
  },
281
289
  }, {