strom-research 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +6 -1
  2. package/assets/lang/cs.json +177 -6
  3. package/assets/lang/de.json +177 -6
  4. package/assets/method/core.md +6 -2
  5. package/assets/method/reading.md +2 -1
  6. package/assets/plugins/README.md +16 -2
  7. package/assets/plugins/gates/README.md +95 -0
  8. package/assets/plugins/gates/claude-usage/gate.json +10 -0
  9. package/assets/plugins/gates/claude-usage/gate.ts +125 -0
  10. package/assets/plugins/hooks/README.md +91 -0
  11. package/dist/agents/global.js +10 -0
  12. package/dist/agents/launch.js +1 -1
  13. package/dist/brief/brief.js +2 -0
  14. package/dist/cli/main.js +43 -2
  15. package/dist/cli/menu-parts.js +140 -0
  16. package/dist/cli/menu-research.js +194 -0
  17. package/dist/cli/menu-settings.js +273 -0
  18. package/dist/cli/menu-waiting.js +79 -0
  19. package/dist/cli/menu.js +113 -99
  20. package/dist/cli/ui.js +175 -5
  21. package/dist/cli/wizard.js +48 -2
  22. package/dist/commands/browse.js +4 -3
  23. package/dist/commands/connectors.js +25 -2
  24. package/dist/commands/gates.js +58 -0
  25. package/dist/commands/hooks.js +141 -0
  26. package/dist/commands/index.js +2 -0
  27. package/dist/commands/intake.js +2 -2
  28. package/dist/commands/research.js +60 -17
  29. package/dist/commands/session.js +161 -18
  30. package/dist/commands/setup.js +57 -1
  31. package/dist/commands/sources.js +24 -0
  32. package/dist/commands/start.js +5 -1
  33. package/dist/commands/tasks.js +41 -4
  34. package/dist/core/browser.js +50 -0
  35. package/dist/core/clock.js +55 -0
  36. package/dist/core/config.js +18 -0
  37. package/dist/core/gate.js +175 -0
  38. package/dist/core/hooks.js +210 -0
  39. package/dist/core/media.js +21 -7
  40. package/dist/core/overview.js +2 -0
  41. package/dist/core/phrases.js +1 -0
  42. package/dist/core/relocate.js +111 -0
  43. package/dist/core/tree.js +19 -2
  44. package/dist/runners/antigravity.js +2 -1
  45. package/dist/runners/claude.js +160 -114
  46. package/dist/runners/codex.js +14 -1
  47. package/dist/runners/jsonl.js +63 -37
  48. package/dist/runners/opencode.js +7 -2
  49. package/dist/runners/script.js +56 -47
  50. package/package.json +1 -1
@@ -0,0 +1,125 @@
1
+ // A strom gate (interface 1): let the research work alone while the Claude
2
+ // subscription has room. It asks Claude Code itself (`claude -p /usage`, the
3
+ // plan's limits as the server reports them) and answers strom:
4
+ // exit 0 go on exit 1 wait (JSON "until": when to ask again)
5
+ // exit 2 stop the run — one line of JSON on stdout says why.
6
+ //
7
+ // The rule, the pace of the week: the share of the week gone since its reset
8
+ // (a day is about 14.3 %) against the week's usage. With the number you give
9
+ // it (strom config set run.gate "claude-usage 10") a session starts only while
10
+ // usage is at least that many points behind the week gone: gone − used ≥ 10.
11
+ // Without a number: 0 — no faster than the week goes. Else it waits until the
12
+ // week has caught up (or past the reset); a full session: until it resets.
13
+ //
14
+ // strom writes this file; a copy of your own goes into a folder of its own name.
15
+
16
+ import { spawnSync } from "node:child_process";
17
+
18
+ const DAY = 86_400_000;
19
+ const WEEK = 7 * DAY;
20
+ const lang = (process.env.STROM_LANG ?? "en").slice(0, 2);
21
+
22
+ const T: Record<string, Record<string, string>> = {
23
+ en: {
24
+ other: "the agent is not Claude Code — nothing to check",
25
+ api: "Claude Code runs on an API key, not a subscription — no plan limits",
26
+ go: "week {used} % used, {gone} % gone, {behind} in hand (at least {min} needed)",
27
+ session: "the session is used up — until it resets",
28
+ week: "week {used} % used, {gone} % gone, {behind} in hand (at least {min} needed) — until the week catches up",
29
+ bad: "the number must be 0 to 99, not \"{arg}\"",
30
+ unread: "cannot read `claude -p /usage`: {detail}",
31
+ },
32
+ cs: {
33
+ other: "agent není Claude Code — není co hlídat",
34
+ api: "Claude Code běží na API klíči, ne na předplatném — žádné limity plánu",
35
+ go: "týden: využito {used} %, uplynulo {gone} %, náskok {behind} (potřeba aspoň {min})",
36
+ session: "sezení je vyčerpané — do jeho obnovení",
37
+ week: "týden: využito {used} %, uplynulo {gone} %, náskok {behind} (potřeba aspoň {min}) — dokud čas nedožene",
38
+ bad: "číslo musí být 0 až 99, ne \"{arg}\"",
39
+ unread: "nelze přečíst `claude -p /usage`: {detail}",
40
+ },
41
+ de: {
42
+ other: "der Agent ist nicht Claude Code — nichts zu prüfen",
43
+ api: "Claude Code läuft mit einem API-Schlüssel, nicht im Abo — keine Planlimits",
44
+ go: "Woche: {used} % genutzt, {gone} % vergangen, Vorsprung {behind} (mindestens {min} nötig)",
45
+ session: "die Sitzung ist aufgebraucht — bis sie zurückgesetzt wird",
46
+ week: "Woche: {used} % genutzt, {gone} % vergangen, Vorsprung {behind} (mindestens {min} nötig) — bis die Zeit aufholt",
47
+ bad: "die Zahl muss 0 bis 99 sein, nicht \"{arg}\"",
48
+ unread: "`claude -p /usage` nicht lesbar: {detail}",
49
+ },
50
+ };
51
+ function say(key: string, v: Record<string, string | number> = {}): string {
52
+ const s = (T[lang] ?? T.en!)[key] ?? T.en![key]!;
53
+ return s.replace(/\{(\w+)\}/g, (_, k: string) => String(v[k] ?? ""));
54
+ }
55
+ function answer(code: 0 | 1 | 2, reason: string, until?: Date): never {
56
+ console.log(JSON.stringify({ reason, ...(until ? { until: until.toISOString() } : {}) }));
57
+ process.exit(code);
58
+ }
59
+
60
+ // ── your number: how many points usage must be behind the week gone ─────────
61
+ const arg = process.argv[2] ?? "0";
62
+ const min = Number(arg);
63
+ if (!/^\d{1,2}$/.test(arg)) answer(2, say("bad", { arg }));
64
+
65
+ if ((process.env.STROM_AGENT ?? "claude") !== "claude") answer(0, say("other"));
66
+
67
+ // ── what Claude Code says ───────────────────────────────────────────────────
68
+ const r = spawnSync("claude", ["-p", "/usage"], { encoding: "utf8", timeout: 90_000, shell: process.platform === "win32", windowsHide: true });
69
+ if (r.error || r.status !== 0) answer(2, say("unread", { detail: r.error?.message ?? (r.stderr || r.stdout || `exit ${r.status}`).trim().split("\n")[0] ?? "" }));
70
+ const text = r.stdout;
71
+ if (/API key/i.test(text) && !/Current week/i.test(text)) answer(0, say("api"));
72
+
73
+ const LINE = /^Current (session|week)(?: \(([^)]+)\))?:\s*(\d+)%\s*used(?:\s*·\s*resets\s*(.+))?$/i;
74
+ let session: { used: number; resets?: Date } | undefined;
75
+ let week: { used: number; resets?: Date } | undefined;
76
+ for (const raw of text.split(/\r?\n/)) {
77
+ const m = LINE.exec(raw.trim());
78
+ if (!m) continue;
79
+ const item = { used: Number(m[3]), ...(m[4] ? { resets: resetAt(m[4]) } : {}) };
80
+ if (m[1]!.toLowerCase() === "session") session = item;
81
+ // the limit of all models is the one that stops the work; a model's own week only if there is no other
82
+ else if (!week || /all models/i.test(m[2] ?? "")) week = item;
83
+ }
84
+ if (!week?.resets) answer(2, say("unread", { detail: "no line \"Current week … resets …\"" }));
85
+
86
+ const now = Date.now();
87
+ const start = week.resets.getTime() - WEEK;
88
+ const gone = Math.max(0, Math.min(100, ((now - start) / WEEK) * 100));
89
+ const behind = gone - week.used;
90
+ const v = { used: week.used, gone: Math.round(gone), behind: Math.round(behind), min };
91
+ if (session && session.used >= 100) answer(1, say("session"), session.resets ? new Date(session.resets.getTime() + 60_000) : undefined);
92
+ if (behind < min) {
93
+ // when the week gone reaches usage + the number; past the reset, the new week's number
94
+ let at = start + ((week.used + min) / 100) * WEEK;
95
+ if (at >= week.resets.getTime()) at = week.resets.getTime() + (min / 100) * WEEK;
96
+ answer(1, say("week", v), new Date(Math.max(at, now + 5 * 60_000)));
97
+ }
98
+ answer(0, say("go", v));
99
+
100
+ /** "Sep 30 at 2pm (Europe/Prague)", "Oct 2 at 11:30am (UTC)" → the moment. */
101
+ function resetAt(s: string): Date | undefined {
102
+ const m = /^(\w{3})\w*\s+(\d{1,2})\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(([^)]+)\)/i.exec(s.trim());
103
+ if (!m) return undefined;
104
+ const month = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"].indexOf(m[1]!.toLowerCase());
105
+ if (month < 0) return undefined;
106
+ const hour = (Number(m[3]) % 12) + (m[5]!.toLowerCase() === "pm" ? 12 : 0);
107
+ const zone = m[6]!;
108
+ for (const year of [new Date().getUTCFullYear(), new Date().getUTCFullYear() + 1]) {
109
+ const guess = Date.UTC(year, month, Number(m[2]), hour, Number(m[4] ?? 0));
110
+ const at = guess - offsetMs(guess, zone);
111
+ if (at > Date.now() - DAY) return new Date(at);
112
+ }
113
+ return undefined;
114
+ }
115
+
116
+ /** The zone's offset from UTC at that moment ("Europe/Prague" in summer: +2 h). */
117
+ function offsetMs(at: number, zone: string): number {
118
+ try {
119
+ const name = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "longOffset" }).formatToParts(new Date(at)).find((p) => p.type === "timeZoneName")?.value ?? "";
120
+ const m = /GMT([+-])(\d{2}):?(\d{2})?/.exec(name);
121
+ return m ? (m[1] === "-" ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3] ?? 0)) * 60_000 : 0;
122
+ } catch {
123
+ return 0;
124
+ }
125
+ }
@@ -0,0 +1,91 @@
1
+ # strom hooks — interface 1
2
+
3
+ A hook is a program of yours that strom tells what was saved into a research:
4
+ a person added to the family tree, a fact found, a task done, a session
5
+ closed. What it does with it is up to you — a message to your phone, a line
6
+ in a log, a backup. strom starts it in the background after each save and
7
+ never waits for it: a hook that is slow or fails never holds up or breaks the
8
+ research.
9
+
10
+ plugins/hooks/
11
+ README.md this interface
12
+ my-hook/ one hook: hook.json and its program
13
+ hook.log what it printed (strom keeps it)
14
+
15
+ **Turn one on**: `strom hook on <name>` — only you (an agent asks you in a
16
+ window of the system). A folder alone does nothing: an agent may build a hook
17
+ for you, but it runs only once you turn it on. `strom hook off <name>`,
18
+ `strom hook list`. **Try it**: `strom hook test <name>` runs it now on the last
19
+ operations saved in the research you are in, waits and shows what it printed
20
+ (`--op person.add --target P0001`: one operation you name).
21
+
22
+ ## hook.json
23
+
24
+ {
25
+ "interface": 1,
26
+ "title": "My phone",
27
+ "command": ["node", "hook.ts"],
28
+ "events": ["person.add", "family.child", "session.close"]
29
+ }
30
+
31
+ - `command`: the program and its arguments, run in the hook's folder. `node`
32
+ is strom's own Node (TypeScript runs as it is) — best on every system.
33
+ - `events`: the operations it wants — one (`person.add`), a kind
34
+ (`person.*`) or all (`*`, the default). Saves with none of them do not start
35
+ it.
36
+ - `timeout`: seconds `strom hook test` waits for it (default 60).
37
+
38
+ ## What it gets
39
+
40
+ One JSON document on stdin (also in the file `STROM_EVENT_FILE`): one save
41
+ into one research, with the operations it saved that the hook wants.
42
+
43
+ {
44
+ "interface": 1,
45
+ "hook": "my-hook",
46
+ "tree": { "name": "Novákovi", "id": "…", "root": "/…/Novákovi", "lang": "en" },
47
+ "commit": "3f2a9c1…",
48
+ "at": "2026-10-02T14:03:11.000Z",
49
+ "events": [
50
+ { "op": "person.add", "targets": ["P0012"], "summary": "+P0012 Josef /Novák/ · E0031 BIRT 1851 …",
51
+ "at": "2026-10-02T14:03:10.412Z", "by": "N0007" }
52
+ ]
53
+ }
54
+
55
+ - `op`: what was done — `person.add` (a person in the family tree),
56
+ `family.add`, `family.child` (a child linked to parents), `event.add` (a
57
+ fact: birth, marriage, death…), `event.edit`, `person.merge`, `source.add`
58
+ (a record read), `media.add` (a scan), `task.add`, `task.done`, `task.wait`
59
+ (waiting for you), `story.set`, `session.start`, `session.close`… — the same
60
+ names as the research's history.
61
+ - `targets`: the records it concerns — read more of them through strom, as
62
+ the user would: `strom person card P0012`, `strom person show P0012 --json`,
63
+ `strom session show N0007`.
64
+ - `by`: the session that saved it (N0007), or `user`.
65
+ - `tree`: which research. A hook you turn on hears from all your researches;
66
+ to hear from some only, let it skip the others by `tree.name` or `tree.id`
67
+ (e.g. a list in a config file of its own).
68
+
69
+ Environment variables, besides the user's own: `STROM_HOOK` (its name),
70
+ `STROM_TREE` (the research's folder — run strom there), `STROM_LANG` (the
71
+ research language), `STROM_EVENT_FILE`. What the hook itself writes through
72
+ strom tells no hook again.
73
+
74
+ ## An example: a message to your phone (Telegram)
75
+
76
+ // hook.ts — the bot's token and your chat's id in config.json beside it
77
+ import fs from "node:fs";
78
+ import { execFileSync } from "node:child_process";
79
+ const cfg = JSON.parse(fs.readFileSync("config.json", "utf8"));
80
+ const save = JSON.parse(fs.readFileSync(0, "utf8"));
81
+ for (const e of save.events.filter((e) => e.op === "person.add")) {
82
+ const card = execFileSync("strom", ["person", "card", e.targets[0]], { cwd: save.tree.root, encoding: "utf8" });
83
+ await fetch(`https://api.telegram.org/bot${cfg.token}/sendMessage`, {
84
+ method: "POST",
85
+ headers: { "content-type": "application/json" },
86
+ body: JSON.stringify({ chat_id: cfg.chat, text: `${save.tree.name}: a new person\n\n${card}` }),
87
+ });
88
+ }
89
+
90
+ Keep secrets such as a token in the hook's own folder: the `.gitignore` of the
91
+ plugins folder keeps every plugin out of any repository.
@@ -80,6 +80,16 @@ const OPENCODE_STROM = "strom *";
80
80
  function opencodeDir(env) {
81
81
  return path.join(env.XDG_CONFIG_HOME ?? path.join(userHome(env), ".config"), "opencode");
82
82
  }
83
+ /** Claude Code starts its own conversations with Remote Control (its setting remoteControlAtStartup, the user's own). */
84
+ export function claudeRemoteAtStartup(env) {
85
+ try {
86
+ const file = path.join(env.CLAUDE_CONFIG_DIR ?? path.join(userHome(env), ".claude"), "settings.json");
87
+ return JSON.parse(fs.readFileSync(file, "utf8")).remoteControlAtStartup === true;
88
+ }
89
+ catch {
90
+ return false; // no settings, or not readable: not on
91
+ }
92
+ }
83
93
  export function globalTargets(env) {
84
94
  const home = userHome(env);
85
95
  return [
@@ -19,7 +19,7 @@ import { claudeArgs } from "../runners/claude.js";
19
19
  /** The command line of a conversation with this agent. */
20
20
  export function conversationArgs(agent, o) {
21
21
  if (agent === "claude")
22
- return claudeArgs({ interactive: true, kickoff: o.kickoff, permissions: o.level, settingsFile: o.settingsFile, ...(o.name ? { name: o.name } : {}), ...(o.model ? { model: o.model } : {}), ...(o.chrome !== undefined ? { chrome: o.chrome } : {}) });
22
+ return claudeArgs({ interactive: true, kickoff: o.kickoff, permissions: o.level, settingsFile: o.settingsFile, ...(o.name ? { name: o.name } : {}), ...(o.model ? { model: o.model } : {}), ...(o.chrome !== undefined ? { chrome: o.chrome } : {}), ...(o.remote ? { remote: true } : {}) });
23
23
  if (agent === "codex") {
24
24
  const args = o.level === "full"
25
25
  ? ["--dangerously-bypass-approvals-and-sandbox"]
@@ -10,6 +10,7 @@ import { displayName, familiesAsChild, familiesAsPartner, formatName, label, lif
10
10
  import { langName } from "../core/lang.js";
11
11
  import { methodFor } from "../core/assets.js";
12
12
  import { recentSessions } from "../core/session.js";
13
+ import { briefClock } from "../core/clock.js";
13
14
  import { calibrationLine } from "../core/calibration.js";
14
15
  import { taskRecordsets } from "../core/frontier.js";
15
16
  import { reviewItems } from "../core/review.js";
@@ -122,6 +123,7 @@ export function buildBrief(tree, opts) {
122
123
  ...(research?.notes ?? []).slice(-3).map((n) => `From the user: ${n.text}`),
123
124
  `Research language: ${langName(lang)} — talk to the user and write notes, tasks and summaries in ${langName(lang)}; transcripts stay in the original language.`,
124
125
  "Work ONLY through `strom` commands; never edit files in data/ (it is detected and blocks all writing). Record findings as you go.",
126
+ opts.deadline !== undefined ? briefClock(opts.deadline) : "",
125
127
  ].filter(Boolean).join("\n"),
126
128
  });
127
129
  // 2. the task
package/dist/cli/main.js CHANGED
@@ -6,11 +6,16 @@ import { groupHelp, helpFor } from "./help.js";
6
6
  import { autoCommit } from "./commit.js";
7
7
  import { assertIntact } from "../core/integrity.js";
8
8
  import { resetCache } from "../core/git.js";
9
- import { VERSION } from "../core/tree.js";
9
+ import { Tree, VERSION } from "../core/tree.js";
10
+ import { fireHooks, HOOK_INTERFACE } from "../core/hooks.js";
11
+ import { prependPath } from "../runners/runner.js";
12
+ import { shimDir } from "../commands/session.js";
10
13
  import { isAgent } from "../core/which.js";
11
14
  import { noticeStromApp } from "../core/stromapp.js";
12
15
  import { isNewer } from "../core/update.js";
13
16
  import { refreshGlobal } from "../agents/global.js";
17
+ import { clockLine } from "../core/clock.js";
18
+ import { currentSession } from "../core/session.js";
14
19
  import { checkArgs, GroupOnly, parseOptions, resolveCommand, splitPassthrough } from "./execute.js";
15
20
  import "../commands/index.js";
16
21
  export { splitCommand } from "./execute.js";
@@ -74,6 +79,9 @@ export async function main(argv, io, env, cwd) {
74
79
  const json = argv.includes("--json");
75
80
  const debug = argv.includes("--debug");
76
81
  resetCache();
82
+ // what ran, for the reminder of the session's time
83
+ let ran;
84
+ let command;
77
85
  try {
78
86
  // "strom 'person list'" (one quoted word) means the same as strom person list.
79
87
  if (argv[0] && !argv[0].startsWith("-") && /\s/.test(argv[0].trim()))
@@ -109,6 +117,21 @@ export async function main(argv, io, env, cwd) {
109
117
  return EXIT.ok;
110
118
  }
111
119
  const ctx = Context.fromOptions({ env, cwd, io, json, values: v });
120
+ ran = ctx;
121
+ command = def.path.join(" ");
122
+ // What is saved into a research, the user's hooks are told of (in the background).
123
+ Tree.onCommit = (tree, ops, commit) => {
124
+ if (!ctx.settings.config.hooks?.length || env.STROM_HOOK)
125
+ return;
126
+ // this strom on the hook's PATH: it reads more of what was saved (strom person card P0012)
127
+ fireHooks(ctx.settings.shared()?.value, ctx.settings.config.hooks, prependPath(env, shimDir(tree)), {
128
+ interface: HOOK_INTERFACE,
129
+ tree: { name: tree.config.name, id: tree.config.id, root: tree.root, lang: tree.lang },
130
+ commit,
131
+ at: new Date().toISOString(),
132
+ events: ops.map((o) => ({ op: o.op, targets: o.targets, summary: o.summary, at: o.at, by: o.by, ...(o.reason ? { reason: o.reason } : {}) })),
133
+ });
134
+ };
112
135
  // Started by the Strom app: remembered quietly (it is where the results go).
113
136
  if (env.STROM_APP)
114
137
  noticeStromApp(ctx.settings, env);
@@ -153,9 +176,27 @@ export async function main(argv, io, env, cwd) {
153
176
  // command has the tree to itself from its first read to its commit.
154
177
  const result = def.writes && def.tree && def.lock !== "sections" ? await ctx.tree().holdTreeLock(work) : await work();
155
178
  print(io, ctx, result);
179
+ remind(io, ctx, command);
156
180
  return result.exitCode ?? EXIT.ok;
157
181
  }
158
182
  catch (err) {
159
- return printError(io, json, err, debug);
183
+ const code = printError(io, json, err, debug);
184
+ remind(io, ran, command);
185
+ return code;
186
+ }
187
+ }
188
+ /** Near the end of a session with a time limit (strom run), every command reminds the agent how long it has left. */
189
+ function remind(io, ctx, command) {
190
+ const line = ctx && command !== "session close" ? clockLine(ctx.env) : undefined;
191
+ if (!line)
192
+ return;
193
+ try {
194
+ // closed already: nothing to remind of
195
+ if (ctx.hasTree() && !currentSession(ctx.tree(), ctx.env))
196
+ return;
197
+ }
198
+ catch {
199
+ return;
160
200
  }
201
+ io.stderr(line + "\n");
161
202
  }
@@ -0,0 +1,140 @@
1
+ // What the guided menu and its submenus share: an item, the loop of a submenu,
2
+ // asking for a person, and the paths a person drops into the terminal.
3
+ import { fileURLToPath } from "node:url";
4
+ import { ui } from "./ui.js";
5
+ import { Tree } from "../core/tree.js";
6
+ import { findPersons, label } from "../core/people.js";
7
+ import { agentsHere } from "../core/apps.js";
8
+ import { AGENTS } from "../core/which.js";
9
+ /** The test (or a script) gave all its answers: nothing more is asked. */
10
+ export function outOfAnswers(ctx) {
11
+ return ctx.io.answers !== undefined && ctx.io.answers.length === 0;
12
+ }
13
+ /** "Press Enter to go back to the menu." — unless there is nobody to press it. */
14
+ export async function pause(ctx, lang) {
15
+ if (!outOfAnswers(ctx))
16
+ await ctx.ask(ui(lang, "ui.enter"));
17
+ }
18
+ /**
19
+ * A submenu: its items (built afresh each time, so what they say is current), numbered in the order shown, 0 back.
20
+ * An item's act returning true leaves the submenu. An act waits for Enter itself, only after a long answer —
21
+ * never after 0 or a question left: the person went back, nothing to read.
22
+ */
23
+ export async function subMenu(ctx, lang, build) {
24
+ for (;;) {
25
+ if (outOfAnswers(ctx))
26
+ return;
27
+ const { title, items } = build();
28
+ const all = [...items, { key: "0", label: ui(lang, "ui.browse.back"), act: async () => true }];
29
+ ctx.io.stdout("\n");
30
+ let n = 0;
31
+ // Enter goes back: an item here changes or starts something, a person picks it by its number
32
+ const i = await ctx.choose(title ?? "", all.map((it) => ({ key: it.key === "0" ? "0" : String(++n), label: it.label })), all.length - 1);
33
+ if (i === undefined)
34
+ return;
35
+ if ((await guarded(ctx, lang, all[i].act)) === true)
36
+ return;
37
+ }
38
+ }
39
+ /** Ask for a person by name or ID; several match: pick one. Nobody (Enter, or no answers left): undefined. */
40
+ export async function pickPerson(ctx, lang, root, question, suggested) {
41
+ const tree = Tree.open(root, ctx.env);
42
+ for (;;) {
43
+ if (outOfAnswers(ctx))
44
+ return undefined;
45
+ const who = (await ctx.ask(question, suggested)).trim();
46
+ if (!who || who === "0")
47
+ return undefined;
48
+ const byId = /^P\d+$/i.test(who) ? tree.get(who.toUpperCase()) : undefined;
49
+ const hits = byId && !byId.retracted ? [byId] : findPersons(tree, who);
50
+ if (!hits.length)
51
+ ctx.io.stdout(ui(lang, "ui.review.none", { who }) + "\n");
52
+ else if (hits.length === 1)
53
+ return hits[0];
54
+ else {
55
+ const i = await ctx.choose(ui(lang, "ui.review.pick"), hits.slice(0, 9).map((p) => ({ label: label(p) })), 0, { back: ui(lang, "ui.browse.back") });
56
+ return i === undefined ? undefined : hits[i];
57
+ }
58
+ }
59
+ }
60
+ /**
61
+ * The paths in what a person typed or dropped into the terminal: a file dragged in comes quoted ('…', "…") or
62
+ * with its spaces escaped (macOS, Linux: My\ Folder), several of them separated by spaces. On Windows a backslash
63
+ * is the path's own, never an escape.
64
+ */
65
+ export function droppedPaths(answer, platform = process.platform) {
66
+ const out = [];
67
+ let cur = "";
68
+ let quote;
69
+ let any = false;
70
+ const s = answer.trim();
71
+ for (let i = 0; i < s.length; i++) {
72
+ const c = s[i];
73
+ if (quote) {
74
+ if (c === quote)
75
+ quote = undefined;
76
+ else
77
+ cur += c;
78
+ }
79
+ else if (c === "'" || c === '"') {
80
+ quote = c;
81
+ any = true;
82
+ }
83
+ else if (c === "\\" && platform !== "win32" && i + 1 < s.length) {
84
+ cur += s[++i];
85
+ any = true;
86
+ }
87
+ else if (/\s/u.test(c)) {
88
+ if (cur || any)
89
+ out.push(cur);
90
+ cur = "";
91
+ any = false;
92
+ }
93
+ else
94
+ cur += c;
95
+ }
96
+ if (cur || any)
97
+ out.push(cur);
98
+ // some terminals drop a file as its address: file:///Users/…/My%20Folder
99
+ return out.filter(Boolean).map((p) => {
100
+ // Git Bash on Windows: /c/Users/… is C:\Users\…
101
+ const drive = platform === "win32" ? /^\/([a-zA-Z])(\/.*)?$/u.exec(p) : null;
102
+ if (drive)
103
+ return `${drive[1].toUpperCase()}:${(drive[2] ?? "/").replace(/\//gu, "\\")}`;
104
+ if (!/^file:\/\//iu.test(p))
105
+ return p;
106
+ try {
107
+ return fileURLToPath(p);
108
+ }
109
+ catch {
110
+ return p;
111
+ }
112
+ });
113
+ }
114
+ /** The translator of one language, as the menu's items use it. */
115
+ export function translator(lang) {
116
+ return (key, values = {}) => ui(lang, key, values);
117
+ }
118
+ /** Is the agent of the research on this computer — its CLI or its desktop app? Without it, nothing is offered to start with it. */
119
+ export function agentReady(ctx, tree) {
120
+ const agent = ctx.settings.agent(tree).value;
121
+ // an agent strom does not look for (a script of the user's) is theirs to have
122
+ return !AGENTS.some((a) => a.id === agent) || agentsHere(ctx.env).some((a) => a.id === agent);
123
+ }
124
+ /** Claude Code is the agent of the research and on this computer: only then does the menu speak of what is Claude's (Remote Control, its usage). */
125
+ export function claudeHere(ctx, tree) {
126
+ return ctx.settings.agent(tree).value === "claude" && agentsHere(ctx.env).some((a) => a.id === "claude" && a.cli);
127
+ }
128
+ /**
129
+ * An item's act, and what goes wrong in it said in a sentence — the menu goes on (a folder that could not be read, a
130
+ * command that refused): never a crash of the menu with an agent's error.
131
+ */
132
+ export async function guarded(ctx, lang, act) {
133
+ try {
134
+ return await act();
135
+ }
136
+ catch (err) {
137
+ ctx.io.stdout(ui(lang, "ui.menu.failed", { reason: err.message }) + "\n");
138
+ await pause(ctx, lang);
139
+ }
140
+ }