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.
- package/README.md +6 -1
- package/assets/lang/cs.json +177 -6
- package/assets/lang/de.json +177 -6
- package/assets/method/core.md +6 -2
- package/assets/method/reading.md +2 -1
- package/assets/plugins/README.md +16 -2
- package/assets/plugins/gates/README.md +95 -0
- package/assets/plugins/gates/claude-usage/gate.json +10 -0
- package/assets/plugins/gates/claude-usage/gate.ts +125 -0
- package/assets/plugins/hooks/README.md +91 -0
- package/dist/agents/global.js +10 -0
- package/dist/agents/launch.js +1 -1
- package/dist/brief/brief.js +2 -0
- package/dist/cli/main.js +43 -2
- package/dist/cli/menu-parts.js +140 -0
- package/dist/cli/menu-research.js +194 -0
- package/dist/cli/menu-settings.js +273 -0
- package/dist/cli/menu-waiting.js +79 -0
- package/dist/cli/menu.js +113 -99
- package/dist/cli/ui.js +175 -5
- package/dist/cli/wizard.js +48 -2
- package/dist/commands/browse.js +4 -3
- package/dist/commands/connectors.js +25 -2
- package/dist/commands/gates.js +58 -0
- package/dist/commands/hooks.js +141 -0
- package/dist/commands/index.js +2 -0
- package/dist/commands/intake.js +2 -2
- package/dist/commands/research.js +60 -17
- package/dist/commands/session.js +161 -18
- package/dist/commands/setup.js +57 -1
- package/dist/commands/sources.js +24 -0
- package/dist/commands/start.js +5 -1
- package/dist/commands/tasks.js +41 -4
- package/dist/core/browser.js +50 -0
- package/dist/core/clock.js +55 -0
- package/dist/core/config.js +18 -0
- package/dist/core/gate.js +175 -0
- package/dist/core/hooks.js +210 -0
- package/dist/core/media.js +21 -7
- package/dist/core/overview.js +2 -0
- package/dist/core/phrases.js +1 -0
- package/dist/core/relocate.js +111 -0
- package/dist/core/tree.js +19 -2
- package/dist/runners/antigravity.js +2 -1
- package/dist/runners/claude.js +160 -114
- package/dist/runners/codex.js +14 -1
- package/dist/runners/jsonl.js +63 -37
- package/dist/runners/opencode.js +7 -2
- package/dist/runners/script.js +56 -47
- 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.
|
package/dist/agents/global.js
CHANGED
|
@@ -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 [
|
package/dist/agents/launch.js
CHANGED
|
@@ -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"]
|
package/dist/brief/brief.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|