strom-research 1.13.2-beta.12 → 1.13.2-beta.13

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 (46) hide show
  1. package/assets/lang/cs.json +112 -23
  2. package/assets/lang/de.json +112 -23
  3. package/assets/method/core.md +9 -0
  4. package/assets/method/locate.md +3 -2
  5. package/assets/method/reading.md +8 -0
  6. package/assets/method/request.md +12 -3
  7. package/assets/method/waiting.md +66 -0
  8. package/dist/agents/files.js +8 -2
  9. package/dist/brief/brief.js +23 -3
  10. package/dist/brief/sheet.js +3 -1
  11. package/dist/cli/human.js +28 -3
  12. package/dist/cli/menu-archives.js +73 -76
  13. package/dist/cli/menu-links.js +90 -1
  14. package/dist/cli/menu-research.js +1 -1
  15. package/dist/cli/menu-waiting.js +487 -112
  16. package/dist/cli/menu.js +6 -8
  17. package/dist/cli/ui.js +102 -20
  18. package/dist/commands/archives.js +9 -2
  19. package/dist/commands/guide.js +8 -2
  20. package/dist/commands/intake.js +45 -11
  21. package/dist/commands/links.js +11 -0
  22. package/dist/commands/media.js +20 -3
  23. package/dist/commands/meta.js +15 -1
  24. package/dist/commands/read.js +6 -3
  25. package/dist/commands/readings.js +25 -5
  26. package/dist/commands/session.js +18 -1
  27. package/dist/commands/start.js +37 -5
  28. package/dist/commands/tasks.js +199 -13
  29. package/dist/core/archivewait.js +4 -2
  30. package/dist/core/changelog.js +7 -0
  31. package/dist/core/conflicts.js +1 -1
  32. package/dist/core/impersonal.js +69 -0
  33. package/dist/core/links.js +6 -2
  34. package/dist/core/live.js +191 -146
  35. package/dist/core/media.js +2 -0
  36. package/dist/core/model.js +2 -0
  37. package/dist/core/originals.js +5 -5
  38. package/dist/core/phrases.js +9 -2
  39. package/dist/core/reader.js +19 -2
  40. package/dist/core/schema.js +4 -1
  41. package/dist/core/stromapp.js +2 -1
  42. package/dist/core/taskfiles.js +64 -0
  43. package/dist/core/tree.js +1 -1
  44. package/dist/core/waiting.js +350 -0
  45. package/dist/core/waitkind.js +276 -0
  46. package/package.json +1 -1
@@ -0,0 +1,66 @@
1
+ # Method: a conversation about what waits for the user
2
+
3
+ The user picked a task that waits for them (strom's menu, What waits; the Strom
4
+ app) and the conversation begins with it: its ID, what it waits for, where it is
5
+ dealt with, and the other tasks waiting at the same place. The user is here to
6
+ get it done with your help — lead them through it, one step at a time, in the
7
+ research language.
8
+
9
+ - First `strom task show T…` for the task and for each of the others named
10
+ with it: what it waits for, the book and its images, the folder for images
11
+ saved by hand, whom a request goes to, its text in `notes/`.
12
+ - Take the tasks of one place one after another, in the order given; say which
13
+ one you are on.
14
+ - The user's answer is theirs to give in strom's menu or the Strom app — never
15
+ record it with `strom task wake T… --answer`. What you find together you
16
+ record with the usual commands (a source with its transcript, the facts
17
+ citing it; what the user tells you: `strom intake --text "…"`).
18
+
19
+ ## Images saved by hand
20
+
21
+ An archive that allows no automation (or whose connector cannot fetch images):
22
+ the user saves the images, you guide them. You open nothing and download
23
+ nothing yourself — not through a browser either.
24
+
25
+ 1. The book: its title and its address on the archive's site (the task shows
26
+ them). An archive that needs an account: the user logs in with their own.
27
+ 2. Which images: the numbers the task names, as the archive's viewer counts
28
+ them (the viewer's own image number, not a folio or a page printed in the
29
+ book).
30
+ 3. How: the viewer's download or export, as JPEG; where the viewer offers
31
+ none, the browser's save of the image itself.
32
+ 4. The name of each file: its image number (`9.jpg`; a zoomed-in part of it
33
+ `9a.jpg`).
34
+ 5. Where: the exact folder of the shared inbox the task shows — say it whole.
35
+
36
+ When the user says the images are saved:
37
+ `strom media add --inbox "<the folder>" --recordset B…` registers them and the
38
+ task comes back to the queue (it says which images are still missing — ask for
39
+ those). Then `strom session start T…`: its brief says how to read and record.
40
+ Read them, record what they say, tell the user what was found
41
+ (`strom person card P…`) and what is needed next — all in this conversation.
42
+
43
+ ## A letter or the family
44
+
45
+ 1. Help write the request: short, in the language of whoever reads it; for an
46
+ office or an archive whose record, which type, the place, the years and what
47
+ is known already (method of requests). Save the text in `notes/` and name it
48
+ on the task (the family: `--kind family`):
49
+ `strom task edit T… --kind letter --to "<the addressee>" --request notes/<file>.md`
50
+ 2. Say to whom it goes and how — e-mail, a form on the office's site, the post,
51
+ a call to a relative — and what to attach.
52
+ 3. Once the user says it went out (never before):
53
+ `strom task sent T…` — the task still waits for the reply.
54
+ 4. A reply that came as a file: `strom intake <file> --answers T…` — the file
55
+ becomes material of the task's people, the task comes back to the queue
56
+ with it (no intake task of its own). A reply the user tells you:
57
+ `strom intake --text "…"`, then `strom task wake T…`. The work goes on in a
58
+ session.
59
+
60
+ ## A site
61
+
62
+ Say which site, where on it and what to look for there; the user looks. What
63
+ they find is recorded (an online record read with them is a source; a hint is a
64
+ lead: `strom intake --text "…"`), then `strom task wake T…`. A check of a site
65
+ that keeps automated tools off (`strom task wait --check`) is the user's alone
66
+ to answer, in strom's menu or the Strom app: say what to look at, nothing more.
@@ -69,6 +69,10 @@ the researcher; \`strom\` is your only way to read and change the research.
69
69
  either — check with \`strom net web --check <url>\`. Another way to the
70
70
  same records first; a task for the user (\`strom task wait T… --check
71
71
  <url> --on "…"\`) only if the source stays necessary.
72
+ What a task waits for (\`--on\`) is a sentence the user reads in strom's
73
+ menu: impersonal, what is needed and from whom, never addressed to them;
74
+ say its kind (\`--kind letter|family|web|other\`) and, when known,
75
+ \`--to\`, \`--request notes/<file>.md\`, \`--url\`.
72
76
  11. What strom or this tree denies you (a command, a site, a file) stays
73
77
  denied: never offer the user to change your permissions or settings,
74
78
  strom's or the tree's, to get round it — go another way, or say what was
@@ -98,7 +102,7 @@ says how it ends.)
98
102
  there, strom says how the user clears your context (Claude Code: /clear)
99
103
  — nothing is lost, strom keeps it all; suggest it in a sentence, go on
100
104
  here if they prefer. Working alone (strom run) each task starts fresh by
101
- itself and nobody reads a closing message: no such advice there;
105
+ itself and its closing message gives no such advice;
102
106
  - what only they can do: when strom needs their consent it opens a window
103
107
  on their screen — tell them to answer it, you cannot; images a portal gives
104
108
  only by hand — strom says which ones and where to save them.
@@ -125,7 +129,9 @@ says how it ends.)
125
129
  would take — tell them, write it on their yes (\`--apply\`); a sync is
126
130
  undone with \`strom sync undo I…\`. Straight from the app: \`strom sync --app\`.
127
131
  - **What waits for them** (\`strom\` shows it): tell them plainly what to do
128
- and where, one thing at a time.
132
+ and where, one thing at a time. A conversation that begins with a task that
133
+ waits for them (they picked it in the menu or the Strom app): \`strom guide
134
+ waiting\` says how to lead it.
129
135
  - **Results and the Strom app**: \`output/tree-strom.ged\` is the family tree
130
136
  for the Strom app (${appSite(app)}) — strom's companion: a free family
131
137
  tree app, no account, their data stay on their computer; it shows the tree,
@@ -753,6 +753,21 @@ function archiveLine(tree, b, repo, shared, installed, qs = []) {
753
753
  }
754
754
  return parts.length ? ` archive ${a.host}: ${parts.join(" · ")}` : "";
755
755
  }
756
+ /**
757
+ * The files that came for what the task waited for (Task.files: strom intake --answers, the Strom app's file to the
758
+ * task) — that they came, never the person's words: read each as the task's next step.
759
+ */
760
+ function filesCame(task) {
761
+ const all = task.files ?? [];
762
+ if (!all.length)
763
+ return [];
764
+ const who = (by) => (by === "app" ? "from the Strom app" : by === "user" ? "from the user" : `by ${by}`);
765
+ return [
766
+ ...all.slice(-5).map((f) => `file came (${f.at.slice(0, 10)}, ${who(f.by)}; asked: ${f.asked}): ${f.input} — strom ${f.input.startsWith("M") ? "media show" : "input show"} ${f.input}`),
767
+ all.length > 5 ? `(files: the last 5 of ${all.length} — all: strom task show ${task.id})` : "",
768
+ "read what came first: it is what the task waited for (what it says is recorded as from any material; strom input done <I…> once used)",
769
+ ].filter(Boolean);
770
+ }
756
771
  /**
757
772
  * What the person answered for the task (Task.answers: only theirs, through strom task wake --answer/--check in their
758
773
  * terminal, a window or strom's menu) — apart from the notes, which anybody writes — and the way on from a check.
@@ -833,7 +848,11 @@ export function buildBrief(tree, opts) {
833
848
  research?.question ? `Research question: ${research.question}` : "",
834
849
  ...(research?.notes ?? []).slice(-3).map((n) => `From the user: ${n.text}`),
835
850
  `Research language: ${langName(lang)} — ${alone ? "" : "talk to the user and "}write notes, tasks and summaries in ${langName(lang)}; transcripts stay in the original language.`,
836
- alone ? "Working alone (strom run): nobody reads this session live — nothing in it is said to the user, its last words neither." : "",
851
+ // the user may read a run afterwards (its output, its log): every word in the research language, impersonal (N0288,
852
+ // N0292, N0295: a last message in English, "it waits for you")
853
+ alone
854
+ ? `Working alone (strom run): nobody answers here, but the user may read this session afterwards (the run's output, its log) — every message of yours, the last one too, in ${langName(lang)}, about the work and never addressed to the user ("waits for a check", never "waits for you"); a step only the user can take goes into strom task wait.`
855
+ : "",
837
856
  STROM_ONLY,
838
857
  webRule(new Settings(tree.env, {}).webPerHost(tree.config)),
839
858
  opts.deadline !== undefined ? briefClock(opts.deadline) : "",
@@ -863,6 +882,7 @@ export function buildBrief(tree, opts) {
863
882
  ...task.notes.slice(-3).map((n) => `note: ${n.text}`),
864
883
  task.notes.length > 3 ? `(notes: the last 3 of ${task.notes.length} — all: strom task show ${task.id})` : "",
865
884
  ...personAnswers(tree, task),
885
+ ...filesCame(task),
866
886
  ].filter(Boolean).join("\n"),
867
887
  });
868
888
  // 2. a review: what each item of the task is, as the tree has it
@@ -1265,12 +1285,12 @@ export function buildBrief(tree, opts) {
1265
1285
  // there are impersonal, so is what it shows of the agent's
1266
1286
  alone
1267
1287
  ? // working alone: every task starts in a fresh session by itself; nobody reads a closing message
1268
- "- the summary: plain words of the research for the record (menu, Strom app), never addressed to the user, no advice (/clear, starting anything); after strom session close you are done: no closing message"
1288
+ `- the summary: plain words of the research for the record (menu, Strom app), never addressed to the user, no advice (/clear, starting anything); after strom session close you are done: a last message of one line at most, in ${langName(lang)}, never addressed to the user`
1269
1289
  : "- the user reads the summary (menu, Strom app): plain words, never addressed to them",
1270
1290
  // nobody answers a question in a run: what only the user can decide waits for them where they look (the menu's
1271
1291
  // What waits for you, the Strom app), their answer comes back on the task (strom task wake --answer)
1272
1292
  alone && task
1273
- ? `- a decision or a step only the user can take: strom task wait ${task.id} --on "<the question, impersonal, in the research language>" — they see it in the menu and the Strom app; a question in the summary gets no answer`
1293
+ ? `- a decision or a step only the user can take: strom task wait ${task.id} --on "<the question, impersonal, in the research language>" — they see it in the menu and the Strom app; a question in the summary gets no answer; its kind: --kind letter|family|web|other`
1274
1294
  : "",
1275
1295
  "- a command you need: strom help <command> (short, with examples) — the full guide: strom guide",
1276
1296
  ].filter(Boolean).join("\n"),
@@ -18,7 +18,7 @@ const LEVELS = {
18
18
  enrich: [...ALL, ...SCANS, ...ENTRY, "hypothesis add", "task wait"],
19
19
  locate: [...ALL, "place add", "place jurisdiction", "repo add", "recordset add", "fetch", "task edit", "task wait"],
20
20
  intake: [...ALL, "input sort", "input skip", "media view", ...ENTRY.filter((c) => c !== "event edit"), "person merge", "recordset add", "conflict add"],
21
- request: [...ALL, "task wait"],
21
+ request: [...ALL, "task wait", "task sent"],
22
22
  narrate: ["task add", "note add", "story set"],
23
23
  // a task that links hypotheses to the tree (METHOD_LINKS): it reads the hypotheses and their people, and links
24
24
  links: ["hypothesis show", "hypothesis link", "hypothesis argue", "hypothesis variant", "person show", "family show", "task add", "note add", "search add"],
@@ -34,6 +34,8 @@ const RARE = {
34
34
  "source edit": ["translation", "media"],
35
35
  "recordset add": ["images"],
36
36
  grep: ["regex", "files"],
37
+ // what a waiting task waits for is said when it is set waiting (task wait); edit fills it for an older one
38
+ "task edit": ["on", "kind", "to", "request", "url"],
37
39
  };
38
40
  /** Where a source is cited — written once as +cite, the same in every command that takes all of it. */
39
41
  const CITE = ["cite", "locator", "quote", "information"];
package/dist/cli/human.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { MONTHS } from "../core/gdate.js";
6
6
  import { ui } from "./ui.js";
7
7
  import { displayName } from "../core/people.js";
8
+ import { foldText } from "../core/text.js";
8
9
  /**
9
10
  * English reads "31 Aug 1830"; the others their own numeric form ("31. 8. 1830", "31.8.1830"). A month and
10
11
  * year alone are named ("srpen 1830") — after a word ("před 3/1853") in numbers, which no grammatical case bends.
@@ -121,8 +122,32 @@ export function humanPlace(place, house, lang) {
121
122
  return place ?? "";
122
123
  return ui(lang, "ui.card.house", { place: place ?? "", house }).replace(/^[,\s]+/, "");
123
124
  }
124
- /** What the agent wrote into a task, for the user: the records it names by ID by their names ("S0001" → its title). */
125
- export function humanTask(tree, text, lang) {
125
+ /** The words of a person's name, folded: what a text that names them may decline ("Václava" for Václav). */
126
+ function nameWords(p) {
127
+ return foldText(displayName(p)).split(/[^\p{L}\p{M}\p{N}]+/u).filter((w) => w.length >= 2);
128
+ }
129
+ /** Does a word name one of these (its stem: the first letters but the last, five at most)? */
130
+ function namesOne(word, names) {
131
+ return names.some((n) => {
132
+ const k = Math.min(5, Math.max(2, n.length - 1));
133
+ return word.length >= k && word.slice(0, k) === n.slice(0, k);
134
+ });
135
+ }
136
+ /**
137
+ * What the agent wrote into a task, for the user: the records it names by ID by their names ("S0001" → its title).
138
+ * `asWritten`: a person's ID right after their name in the text stays the bare ID in brackets ("Václava Nováka P0001" →
139
+ * "Václava Nováka [P0001]"), never the name said twice — the short lines of What waits.
140
+ */
141
+ export function humanTask(tree, text, lang, o = {}) {
142
+ const named = (id, before) => {
143
+ const p = tree.get(id);
144
+ if (p?.type !== "person")
145
+ return false;
146
+ // the two words before the ID (letters only, none of them an ID)
147
+ const words = foldText(before.slice(-60)).split(/[^\p{L}\p{M}\p{N}]+/u).filter(Boolean).slice(-2).filter((w) => !/^[pfsbn]\d{4,}$/u.test(w));
148
+ const names = nameWords(p);
149
+ return words.some((w) => namesOne(w, names));
150
+ };
126
151
  const name = (id) => {
127
152
  const r = tree.get(id);
128
153
  if (!r)
@@ -136,5 +161,5 @@ export function humanTask(tree, text, lang) {
136
161
  return r.title;
137
162
  };
138
163
  // A list of IDs as a list of names, each once (two sessions of one day read as one).
139
- return text.replace(/\b[PFSBN]\d{4,}\b(?:, [PFSBN]\d{4,}\b)*/g, (ids) => [...new Set(ids.split(", ").map(name))].join(", "));
164
+ return text.replace(/\b[PFSBN]\d{4,}\b(?:, [PFSBN]\d{4,}\b)*/g, (ids, at) => o.asWritten && !ids.includes(",") && named(ids, text.slice(0, at)) ? `[${ids}]` : [...new Set(ids.split(", ").map(name))].join(", "));
140
165
  }
@@ -12,64 +12,16 @@ import { Tree } from "../core/tree.js";
12
12
  import { isArchive } from "../core/mode.js";
13
13
  import { bareHost, listConnectors, routeOf, routesOf } from "../core/connector.js";
14
14
  import { claimOf, loadArchives, rank } from "../core/terms.js";
15
- import { archiveQuestions, connectorFor, levelWords, tasksStanding, tasksWaitingOn, termsText, waysOffered, wokenLines } from "../core/archiveasks.js";
15
+ import { connectorFor, levelWords, tasksStanding, tasksWaitingOn, termsText, waysOffered, wokenLines } from "../core/archiveasks.js";
16
16
  import { wakeForArchive } from "../core/archivewait.js";
17
- import { archivesOverview, levelsOf, paceFor } from "../commands/archives.js";
18
- /** A pace in the person's words: "one image at most every 10 s, at most 120 an hour". */
17
+ import { archivesOverview, capWords, levelsOf, paceFor } from "../commands/archives.js";
18
+ /** A pace in the person's words: "one image at most every 10 s, at most 120 requests … an hour" — or no hourly cap. */
19
19
  export function paceWords(lang, p) {
20
- const n = p.perHour === null || !Number.isFinite(p.perHour) ? "∞" : p.perHour;
21
- return ui(lang, "ui.archive.pace", { seconds: secondsIn(lang, p.minIntervalMs), n });
20
+ return ui(lang, "ui.archive.pace", { seconds: secondsIn(lang, p.minIntervalMs), cap: capWords(lang, p.perHour, true) });
22
21
  }
23
22
  function connectorsHere(ctx) {
24
23
  return listConnectors(ctx.settings.shared()?.value, ctx.env);
25
24
  }
26
- /** The question's lines in What waits: the archive, its terms (strom's quote, or the connector's reading), the tasks. */
27
- function permissionLines(ctx, t, lang, tree, q) {
28
- const tasks = tree ? tasksStanding(tree, q) : [];
29
- return [
30
- ` ${termsText(lang, ctx.env, q)}`,
31
- ...(tasks.length ? [` ${t("ui.archive.item.tasks", { ids: tasks.join(", ") })}`] : []),
32
- // since when it waits (the first time asked) and how many times a command needed it
33
- ` ${t("ui.archive.item.since", { date: humanDay(q.at, lang), tries: q.tries ?? 1 })}`,
34
- ];
35
- }
36
- /** A question the menu can put: its archive named (a permission, a milder reading), the connectors taken over. */
37
- const answerable = (q) => (q.kind === "takeover" ? !!q.connectors?.length : !!q.host && (q.kind === "permission" || !!q.connector));
38
- /** One question in What waits: its text, the label of its item, what choosing it does. */
39
- export function archiveItems(ctx, run, lang, root, start, max) {
40
- const t = translator(lang);
41
- const tree = Tree.open(root, ctx.env);
42
- const qs = archiveQuestions(tree).filter(answerable);
43
- const text = [];
44
- const items = [];
45
- const connectors = qs.length ? connectorsHere(ctx) : [];
46
- for (const q of qs) {
47
- if (items.length >= max)
48
- break;
49
- const k = start + items.length + 1;
50
- if (q.kind === "permission" && q.host) {
51
- const title = q.title ?? q.host;
52
- text.push("", ` ${k}. ${t("ui.archive.item", { title, host: q.host })}`, ...permissionLines(ctx, t, lang, tree, q));
53
- items.push({ key: String(k), label: t("ui.archive.item.read", { k, title }), act: async () => void (await decideArchive(ctx, run, lang, root, q.host, q)) });
54
- }
55
- else if (q.kind === "takeover" && q.connectors?.length) {
56
- const names = q.connectors.map((n) => connectors.find((c) => c.name === n)?.manifest.title ?? n);
57
- text.push("", ` ${k}. ${t("ui.archive.takeover.item", { n: names.length, names: names.join(", ") })}`);
58
- items.push({ key: String(k), label: t("ui.archive.takeover.read", { k }), act: () => decideTakeover(ctx, run, lang, q) });
59
- }
60
- else if (q.kind === "milder" && q.host && q.connector) {
61
- const c = connectors.find((x) => x.name === q.connector);
62
- const title = c?.manifest.title ?? q.title ?? q.connector;
63
- text.push("", ` ${k}. ${t("ui.archive.milder.item", { title, host: q.host, to: levelWords(lang, q.to ?? "allowed"), from: levelWords(lang, q.from ?? "restricted") })}`);
64
- items.push({ key: String(k), label: t("ui.archive.milder.read", { k, title }), act: () => decideMilder(ctx, run, lang, q, title) });
65
- }
66
- }
67
- return { text, items, count: qs.length };
68
- }
69
- /** How many questions about archives wait with this research (none in an archive). */
70
- export function archiveQuestionCount(tree) {
71
- return archiveQuestions(tree).filter(answerable).length;
72
- }
73
25
  /** Every research on this computer, the one worked on first: a decision about an archive is the person's for all. */
74
26
  export function rootsHere(ctx, root) {
75
27
  return [...new Set([...(root ? [root] : []), ...ctx.knownTrees().map((k) => k.root)])];
@@ -78,11 +30,18 @@ export function rootsHere(ctx, root) {
78
30
  function levelHere(ctx, c, host) {
79
31
  return levelsOf(ctx, c, true).find((a) => a.host === host);
80
32
  }
33
+ /** Can a downloader give images at all — directly (fetch, part) or through the browser (locate)? Else they go only by hand. */
34
+ function givesImages(c) {
35
+ const can = c.manifest.can;
36
+ const routes = routesOf(c);
37
+ return (routes.includes("direct") && (can.includes("fetch") || can.includes("part"))) || (routes.includes("browser") && (can.includes("locate") || can.includes("fetch")));
38
+ }
81
39
  /**
82
40
  * One archive: what its terms say and the ways to get its images — a question that waited (What waits, a link), or
83
41
  * the person's look at it in the settings (the current way suggested, its permission to be forgotten). True: decided.
42
+ * `all`: the archive's hosts (its downloader's, the same state on each) — said once, decided for all of them.
84
43
  */
85
- export async function decideArchive(ctx, run, lang, root, host, q) {
44
+ export async function decideArchive(ctx, run, lang, root, host, q, all) {
86
45
  const t = translator(lang);
87
46
  const out = (line = "") => ctx.io.stdout(line + "\n");
88
47
  const c = connectorFor(connectorsHere(ctx), host, q?.connector);
@@ -93,12 +52,18 @@ export async function decideArchive(ctx, run, lang, root, host, q) {
93
52
  const a = levelHere(ctx, c, host);
94
53
  const title = a?.title ?? q?.title ?? c.manifest.title ?? host;
95
54
  const tree = root ? Tree.open(root, ctx.env) : undefined;
55
+ const hosts = all && all.length > 1 ? all : [host];
56
+ // the command's target: the downloader for all its hosts at once, else the host
57
+ const target = hosts.length > 1 ? c.name : host;
96
58
  out("");
97
- out(` ${t("ui.archive.item", { title, host })}`);
59
+ out(` ${t("ui.archive.item", { title, host: hosts.join(", ") })}`);
98
60
  out(` ${termsText(lang, ctx.env, { host, ...(a?.quote ? { quote: a.quote } : q?.quote ? { quote: q.quote } : {}), ...(a?.terms ?? q?.terms ? { terms: (a?.terms ?? q?.terms) } : {}), ...(a?.quoteBy ? { quoteBy: a.quoteBy } : q?.quoteBy ? { quoteBy: q.quoteBy } : {}) })}`);
99
- const tasks = tree && !isArchive(tree) ? (q?.kind === "permission" ? tasksStanding(tree, q) : tasksWaitingOn(tree, host).map((x) => x.id)) : [];
61
+ const tasks = tree && !isArchive(tree) ? (q?.kind === "permission" ? tasksStanding(tree, q) : [...new Set(hosts.flatMap((h) => tasksWaitingOn(tree, h).map((x) => x.id)))]) : [];
100
62
  if (tasks.length)
101
63
  out(` ${t("ui.archive.item.tasks", { ids: tasks.join(", ") })}`);
64
+ // a question that waits: since when (the first time asked) and how many times a command needed it
65
+ if (q?.kind === "permission")
66
+ out(` ${t("ui.archive.item.since", { date: humanDay(q.at, lang), tries: q.tries ?? 1 })}`);
102
67
  // a server that refused strom's client: its answer, not the archive's terms — only the person lifts it (--unblock)
103
68
  if (a?.level === "forbidden" && a.refused && !a.refused.unreadable) {
104
69
  const h = a.refused.host;
@@ -106,9 +71,10 @@ export async function decideArchive(ctx, run, lang, root, host, q) {
106
71
  await pause(ctx, lang);
107
72
  return false;
108
73
  }
109
- // a level no permission opens (by hand only, nothing at all): said, and who lifts it
74
+ // a level no permission opens (by hand only, nothing at all): said, and who lifts it — only where lifting it would
75
+ // open a way (its downloader gives images, the archive refuses strom nothing); else the images go by hand
110
76
  if (a && (a.level === "manual" || a.level === "forbidden")) {
111
- out(` ${t("ui.archive.closed", { title, level: levelWords(lang, a.level, false, a.from), host })}`);
77
+ out(` ${t(givesImages(c) && !a.refused ? "ui.archive.closed" : "ui.archive.closed.hand", { title, level: levelWords(lang, a.level, false, a.from), host: target })}`);
112
78
  await pause(ctx, lang);
113
79
  return false;
114
80
  }
@@ -125,7 +91,7 @@ export async function decideArchive(ctx, run, lang, root, host, q) {
125
91
  const now = a?.permission;
126
92
  const choices = ways.map((w) => ({
127
93
  way: w,
128
- label: t(w === "slow" ? "ui.archive.way.slow" : w === "browser" ? "ui.archive.way.browser" : "ui.archive.way.manual", { seconds: secondsIn(lang, slow.minIntervalMs), n: Number.isFinite(slow.perHour) ? slow.perHour : "∞" }),
94
+ label: t(w === "slow" ? "ui.archive.way.slow" : w === "browser" ? "ui.archive.way.browser" : "ui.archive.way.manual", { seconds: secondsIn(lang, slow.minIntervalMs), cap: capWords(lang, slow.perHour) }),
129
95
  }));
130
96
  if (now)
131
97
  choices.push({ label: t("ui.archive.way.revoke") });
@@ -136,14 +102,14 @@ export async function decideArchive(ctx, run, lang, root, host, q) {
136
102
  return false;
137
103
  const chosen = choices[i].way;
138
104
  if (!chosen) {
139
- if ((await run(["allow", "archive", host, "--revoke"], true)) !== 0)
105
+ if ((await run(["allow", "archive", target, "--revoke"], true)) !== 0)
140
106
  return false;
141
107
  out(t("ui.archive.revoked", { title }));
142
108
  return true;
143
109
  }
144
110
  // what the decision does to the tasks that waited (the command wakes them, in every research here): said after it
145
- const plan = wakeForArchive(ctx.env, rootsHere(ctx, root), host, { ...(ctx.settings.shared()?.value ? { shared: ctx.settings.shared().value } : {}), way: chosen, dry: true });
146
- if ((await run(["allow", "archive", host, "--way", chosen], true)) !== 0)
111
+ const plan = hosts.flatMap((h) => wakeForArchive(ctx.env, rootsHere(ctx, root), h, { ...(ctx.settings.shared()?.value ? { shared: ctx.settings.shared().value } : {}), way: chosen, dry: true }));
112
+ if ((await run(["allow", "archive", target, "--way", chosen], true)) !== 0)
147
113
  return false;
148
114
  // the connector's route follows the way chosen: through the browser, or straight through strom
149
115
  const via = routeOf(ctx.env, c).via;
@@ -157,7 +123,7 @@ export async function decideArchive(ctx, run, lang, root, host, q) {
157
123
  return true;
158
124
  }
159
125
  /** The connectors taken over from an older strom: confirmed all at once, or gone through one by one. */
160
- async function decideTakeover(ctx, run, lang, q) {
126
+ export async function decideTakeover(ctx, run, lang, q) {
161
127
  const t = translator(lang);
162
128
  const out = (line = "") => ctx.io.stdout(line + "\n");
163
129
  const all = connectorsHere(ctx).filter((c) => q.connectors?.includes(c.name));
@@ -202,7 +168,7 @@ async function decideTakeover(ctx, run, lang, q) {
202
168
  }
203
169
  }
204
170
  /** A connector that says now an archive allows more than strom keeps: accepted, or kept as it is. */
205
- async function decideMilder(ctx, run, lang, q, title) {
171
+ export async function decideMilder(ctx, run, lang, q, title) {
206
172
  const t = translator(lang);
207
173
  const out = (line = "") => ctx.io.stdout(line + "\n");
208
174
  const c = connectorsHere(ctx).find((x) => x.name === q.connector);
@@ -227,13 +193,15 @@ function onlyBrowser(t, c, robots) {
227
193
  export function browserOnlyHere(ctx, c) {
228
194
  return archivesOverview(ctx).some((r) => r.connector === c.name && r.browserOnly && r.level !== "manual" && r.level !== "forbidden");
229
195
  }
230
- /** What holds for a downloader's archive, on its line in the settings: its level, the person's way and since when, the pace. */
231
- export function archiveState(ctx, lang, c) {
196
+ /**
197
+ * What holds for a downloader's archive, on its line in the settings: its level, the person's way and since when, the
198
+ * pace — once for all its hosts where they are the same (`host`: that host alone).
199
+ */
200
+ export function archiveState(ctx, lang, c, host) {
232
201
  const t = translator(lang);
233
- const rows = archivesOverview(ctx).filter((r) => r.connector === c.name);
202
+ const rows = archivesOverview(ctx).filter((r) => r.connector === c.name && (!host || r.host === host));
234
203
  const store = loadArchives(ctx.env).archives;
235
- return rows
236
- .map((r) => {
204
+ const said = rows.map((r) => {
237
205
  const rec = store[r.host];
238
206
  const level = r.level;
239
207
  const closed = level === "manual" || level === "forbidden";
@@ -242,23 +210,52 @@ export function archiveState(ctx, lang, c) {
242
210
  const only = !closed && !r.way && r.browserOnly ? onlyBrowser(t, c, !!r.robots) : undefined;
243
211
  const way = only ?? (!closed && r.way ? t("ui.archives.state.way", { way: t(`ui.archive.is.${r.way}`), date: rec?.at ? humanDay(rec.at, lang) : "–" }) : levelWords(lang, level, !!r.robots, r.from));
244
212
  const pace = !closed && r.way !== "manual" && (level === "allowed" || r.way) ? `; ${paceWords(lang, r.pace)}` : "";
245
- return `${rows.length > 1 ? `${r.host}: ` : ""}${way}${pace}`;
246
- })
247
- .join(" · ");
213
+ return { host: r.host, text: `${way}${pace}` };
214
+ });
215
+ // said once where every host of the archive is the same, per host only where they differ
216
+ return said.every((x) => x.text === said[0].text) ? (said[0]?.text ?? "") : said.map((x) => `${x.host}: ${x.text}`).join(" · ");
217
+ }
218
+ /** Each host of a downloader with what holds for it. */
219
+ function hostStates(ctx, lang, c) {
220
+ return [...new Set(archivesOverview(ctx).filter((r) => r.connector === c.name).map((r) => r.host))].map((host) => ({ host, text: archiveState(ctx, lang, c, host) }));
248
221
  }
249
222
  /** Settings → Archives → permissions: each archive of a downloader here, then the same choices as in What waits. */
250
223
  export async function archivePermissions(ctx, run, lang, root) {
251
224
  const t = translator(lang);
225
+ const pending = (hosts) => loadArchives(ctx.env).pending.find((p) => p.kind === "permission" && hosts.includes(p.host ?? ""));
252
226
  await subMenu(ctx, lang, () => {
253
227
  const rows = archivesOverview(ctx);
254
- const hosts = [...new Map(rows.map((r) => [r.host, r])).values()];
255
228
  const connectors = connectorsHere(ctx);
256
- const items = hosts.slice(0, 9).map((r, k) => {
257
- const c = connectors.find((x) => x.name === r.connector);
229
+ // one line per archive (its downloader) with its hosts inside it — a host two downloaders reach under the first
230
+ const seen = new Set();
231
+ const archives = [];
232
+ for (const name of [...new Set(rows.map((r) => r.connector))]) {
233
+ const c = connectors.find((x) => x.name === name);
234
+ const hosts = [...new Set(rows.filter((r) => r.connector === name && !seen.has(r.host)).map((r) => r.host))];
235
+ if (!c || !hosts.length)
236
+ continue;
237
+ for (const h of hosts)
238
+ seen.add(h);
239
+ archives.push({ c, hosts });
240
+ }
241
+ const items = archives.slice(0, 9).map(({ c, hosts }, k) => {
242
+ const states = hostStates(ctx, lang, c).filter((x) => hosts.includes(x.host));
243
+ const same = states.every((x) => x.text === states[0]?.text);
244
+ const title = c.manifest.title ?? c.name;
258
245
  return {
259
246
  key: String(k + 1),
260
- label: `${c?.manifest.title ?? r.connector} (${r.host}) – ${c ? archiveState(ctx, lang, c) : levelWords(lang, r.level, !!r.robots, r.from)}`,
261
- act: async () => void (await decideArchive(ctx, run, lang, root, r.host, loadArchives(ctx.env).pending.find((p) => p.kind === "permission" && p.host === r.host))),
247
+ label: `${title} (${hosts.join(", ")}) – ${same ? (states[0]?.text ?? "") : states.map((x) => `${x.host}: ${x.text}`).join(" · ")}`,
248
+ act: async () => {
249
+ // the same on every host: decided for the archive at once; else for the host the person picks
250
+ if (same || hosts.length === 1) {
251
+ const q = pending(hosts);
252
+ return void (await decideArchive(ctx, run, lang, root, q?.host ?? hosts[0], q, hosts));
253
+ }
254
+ const i = await ctx.choose(title, states.map((x) => ({ label: `${x.host} – ${x.text}` })), states.length, { back: t("ui.browse.back") });
255
+ if (i === undefined)
256
+ return;
257
+ await decideArchive(ctx, run, lang, root, states[i].host, pending([states[i].host]));
258
+ },
262
259
  };
263
260
  });
264
261
  return { title: t(items.length ? "ui.archives.permissions.title" : "ui.archives.empty"), items };
@@ -6,7 +6,7 @@
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
8
8
  import { outOfAnswers, translator } from "./menu-parts.js";
9
- import { directionTasks, offerChat, setDirection } from "./menu-research.js";
9
+ import { askPaths, directionTasks, offerChat, setDirection } from "./menu-research.js";
10
10
  import { humanCost, humanDay, humanTask, humanWhen } from "./human.js";
11
11
  import { lines, truncate } from "./format.js";
12
12
  import { isArchive } from "../core/mode.js";
@@ -36,6 +36,10 @@ import { forgetTree } from "../commands/trees.js";
36
36
  import { archiveQuestions } from "../core/archiveasks.js";
37
37
  import { bareHost, listConnectors } from "../core/connector.js";
38
38
  import { decideArchive } from "./menu-archives.js";
39
+ import { inboxOf, requestFile, sameGroup } from "../core/waiting.js";
40
+ import { SENDABLE, waitGroupOf, waitKindOf } from "../core/waitkind.js";
41
+ import { openForUser } from "../core/open.js";
42
+ import { fileRefused } from "../core/taskfiles.js";
39
43
  /** What the Strom app asks, said before anything is done; true when the person goes on. */
40
44
  export async function asks(ctx, lang, root, what, note, suggested = true) {
41
45
  const name = Tree.open(root, ctx.env).config.name;
@@ -74,6 +78,91 @@ export async function taskDo(ctx, run, lang, root, id, what) {
74
78
  out(ctx, t(`ui.link.task.${what}.done`));
75
79
  return true;
76
80
  }
81
+ /**
82
+ * What a link of the Strom app does with a task that waits: a conversation about it and what waits with it at the same
83
+ * place (said and asked first: the agent costs), its request's text or its folder of images opened (nothing written),
84
+ * its request marked sent on the person's word (Enter: no). True: done.
85
+ *
86
+ * `menu`: the same chosen in the menu's What waits — the person picked it there, nothing said as the app's: a
87
+ * conversation starts at once, sent is asked in a line of its own (Enter: no).
88
+ */
89
+ export async function waitingDo(ctx, run, lang, root, id, what, agent, menu = false) {
90
+ const t = translator(lang);
91
+ const tree = Tree.open(root, ctx.env);
92
+ const task = tree.get(id);
93
+ if (!task || task.type !== "task" || task.retracted)
94
+ return stop(ctx, t("ui.link.task.none", { task: id }));
95
+ if (task.state !== "waiting")
96
+ return stop(ctx, t("ui.link.notask"));
97
+ const shown = humanTask(tree, task.what, lang);
98
+ if (what === "chat") {
99
+ if (menu)
100
+ return (await run(["chat", "--task", task.id])) === 0;
101
+ const more = sameGroup(tree, task, ctx.settings.shared()?.value).length;
102
+ const said = `${t("ui.link.what.task.chat", { agent, what: shown })}${more ? t("ui.link.what.task.chat.more", { n: more }) : ""}`;
103
+ if (!(await asks(ctx, lang, root, said, t("ui.link.cost"))))
104
+ return false;
105
+ return (await run(["chat", "--task", task.id])) === 0;
106
+ }
107
+ if (what === "note") {
108
+ const file = requestFile(tree, task);
109
+ if (!file)
110
+ return stop(ctx, t("ui.link.note.none", { task: task.id }));
111
+ openForUser(file, ctx.env);
112
+ out(ctx, t("ui.link.note.opened", { file: ctx.display(file) }));
113
+ return true;
114
+ }
115
+ if (what === "folder") {
116
+ const inbox = inboxOf(ctx.settings.shared()?.value, task);
117
+ if (!inbox)
118
+ return stop(ctx, t("ui.link.folder.none", { task: task.id }));
119
+ openForUser(inbox.dir, ctx.env);
120
+ out(ctx, t("ui.waiting.opened", { dir: ctx.display(inbox.dir) }));
121
+ return true;
122
+ }
123
+ // a file that came for it: dragged in, a short note — an input of its people, the task back in the queue (Enter or 0
124
+ // at the file, 0 at the note: nothing written)
125
+ if (what === "file") {
126
+ if (fileRefused(task, task.id))
127
+ return stop(ctx, t("ui.link.file.cannot", { task: task.id }));
128
+ if (!menu) {
129
+ out(ctx, t("ui.link.asks", { what: t("ui.link.what.task.file", { what: shown }), name: tree.config.name }));
130
+ if (task.waitingOn)
131
+ out(ctx, ` ${t("ui.waiting.do", { what: task.waitingOn })}`);
132
+ }
133
+ const paths = await askPaths(ctx, lang, root);
134
+ if (!paths?.length)
135
+ return false;
136
+ const note = (await ctx.ask(t("ui.link.file.note"))).trim();
137
+ if (note === "0")
138
+ return false;
139
+ if ((await run(["intake", ...paths, `--answers=${task.id}`, ...(note ? [`--note=${note}`] : [])], true)) !== 0)
140
+ return false;
141
+ out(ctx, t("ui.link.task.file.done"));
142
+ return true;
143
+ }
144
+ // sent: a fact the person gives, never a consent — the task still waits for the reply
145
+ if (!SENDABLE.includes(waitKindOf(task)))
146
+ return stop(ctx, t("ui.link.sent.cannot", { task: task.id }));
147
+ if (task.sent)
148
+ return stop(ctx, t("ui.link.sent.already", { task: task.id, day: humanDay(task.sent.at, lang) }));
149
+ const group = waitGroupOf(tree, task, lang);
150
+ const to = task.wait?.to?.trim() || (group.kind === "office" || group.kind === "family" ? group.name : undefined);
151
+ if (menu) {
152
+ if (!(await ctx.confirm(t("ui.wait.sent.sure", { to: to ? ` (${to})` : "" }), false)))
153
+ return false;
154
+ }
155
+ else {
156
+ if (task.waitingOn)
157
+ out(ctx, ` ${t("ui.waiting.do", { what: task.waitingOn })}`);
158
+ if (!(await asks(ctx, lang, root, t("ui.link.what.task.sent", { what: shown, to: to ? ` (${to})` : "" }), undefined, false)))
159
+ return false;
160
+ }
161
+ if ((await run(["task", "sent", task.id], true)) !== 0)
162
+ return false;
163
+ out(ctx, t("ui.link.task.sent.done"));
164
+ return true;
165
+ }
77
166
  /**
78
167
  * The question about an archive's images a link of the Strom app opens: only an archive that waits for the person's
79
168
  * decision, or one a downloader here reaches (its permission looked at again) — anything else only said.
@@ -122,7 +122,7 @@ export async function offerChat(ctx, run, lang, root, question, say) {
122
122
  await run(["chat", "--say", say]);
123
123
  }
124
124
  /** Paths typed or dropped in, each one there and none of strom's own; asked again until they are, nothing (Enter, 0): undefined. */
125
- async function askPaths(ctx, lang, root) {
125
+ export async function askPaths(ctx, lang, root) {
126
126
  const t = translator(lang);
127
127
  const own = [root, ctx.settings.home()?.value].filter((d) => Boolean(d));
128
128
  const inside = (p, dir) => {