strom-research 1.0.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 (130) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +142 -0
  3. package/assets/lang/cs.json +302 -0
  4. package/assets/lang/de.json +302 -0
  5. package/assets/method/core.md +43 -0
  6. package/assets/method/enrich.md +11 -0
  7. package/assets/method/intake.md +30 -0
  8. package/assets/method/link.md +28 -0
  9. package/assets/method/locate.md +28 -0
  10. package/assets/method/narrate.md +13 -0
  11. package/assets/method/reading.md +62 -0
  12. package/assets/method/recording.md +59 -0
  13. package/assets/method/request.md +10 -0
  14. package/assets/method/verify.md +17 -0
  15. package/assets/plugins/README.md +23 -0
  16. package/assets/plugins/connectors/DISCOVERY.md +159 -0
  17. package/assets/plugins/connectors/README.md +376 -0
  18. package/assets/plugins/connectors/sdk.ts +168 -0
  19. package/assets/plugins/connectors/template.ts +38 -0
  20. package/assets/plugins/gitignore +4 -0
  21. package/dist/agents/files.js +313 -0
  22. package/dist/agents/global.js +257 -0
  23. package/dist/agents/launch.js +36 -0
  24. package/dist/agents/profiles.js +95 -0
  25. package/dist/brief/brief.js +345 -0
  26. package/dist/cli/commit.js +44 -0
  27. package/dist/cli/context.js +311 -0
  28. package/dist/cli/execute.js +154 -0
  29. package/dist/cli/fixes.js +78 -0
  30. package/dist/cli/format.js +53 -0
  31. package/dist/cli/help.js +59 -0
  32. package/dist/cli/main.js +152 -0
  33. package/dist/cli/menu.js +212 -0
  34. package/dist/cli/registry.js +96 -0
  35. package/dist/cli/ui.js +266 -0
  36. package/dist/cli/wizard.js +142 -0
  37. package/dist/cli.js +14 -0
  38. package/dist/commands/analysis.js +622 -0
  39. package/dist/commands/batch.js +181 -0
  40. package/dist/commands/checks.js +153 -0
  41. package/dist/commands/connectors.js +1377 -0
  42. package/dist/commands/guide.js +160 -0
  43. package/dist/commands/index.js +19 -0
  44. package/dist/commands/intake.js +234 -0
  45. package/dist/commands/media.js +406 -0
  46. package/dist/commands/meta.js +195 -0
  47. package/dist/commands/output.js +117 -0
  48. package/dist/commands/people.js +664 -0
  49. package/dist/commands/read.js +199 -0
  50. package/dist/commands/research.js +139 -0
  51. package/dist/commands/session.js +605 -0
  52. package/dist/commands/setup.js +465 -0
  53. package/dist/commands/sources.js +634 -0
  54. package/dist/commands/start.js +383 -0
  55. package/dist/commands/story.js +75 -0
  56. package/dist/commands/tasks.js +436 -0
  57. package/dist/commands/trees.js +128 -0
  58. package/dist/core/actions.js +852 -0
  59. package/dist/core/age.js +95 -0
  60. package/dist/core/apps.js +74 -0
  61. package/dist/core/assets.js +34 -0
  62. package/dist/core/awake.js +33 -0
  63. package/dist/core/browser.js +281 -0
  64. package/dist/core/calibration.js +48 -0
  65. package/dist/core/check.js +112 -0
  66. package/dist/core/chromium.js +88 -0
  67. package/dist/core/config.js +348 -0
  68. package/dist/core/connector.js +811 -0
  69. package/dist/core/deps.js +73 -0
  70. package/dist/core/dialog.js +61 -0
  71. package/dist/core/errors.js +89 -0
  72. package/dist/core/evidence.js +58 -0
  73. package/dist/core/frontier.js +219 -0
  74. package/dist/core/gdate.js +77 -0
  75. package/dist/core/git.js +300 -0
  76. package/dist/core/guard.js +124 -0
  77. package/dist/core/http2.js +76 -0
  78. package/dist/core/import.js +541 -0
  79. package/dist/core/install.js +28 -0
  80. package/dist/core/integrity.js +219 -0
  81. package/dist/core/json.js +87 -0
  82. package/dist/core/lang.js +70 -0
  83. package/dist/core/live.js +244 -0
  84. package/dist/core/lock.js +112 -0
  85. package/dist/core/logins.js +67 -0
  86. package/dist/core/media.js +223 -0
  87. package/dist/core/model.js +101 -0
  88. package/dist/core/net.js +366 -0
  89. package/dist/core/open.js +29 -0
  90. package/dist/core/paths.js +84 -0
  91. package/dist/core/people.js +283 -0
  92. package/dist/core/phrases.js +85 -0
  93. package/dist/core/queue.js +113 -0
  94. package/dist/core/reader.js +76 -0
  95. package/dist/core/records.js +105 -0
  96. package/dist/core/roles.js +30 -0
  97. package/dist/core/schema.js +261 -0
  98. package/dist/core/seal.js +77 -0
  99. package/dist/core/self.js +40 -0
  100. package/dist/core/session.js +155 -0
  101. package/dist/core/shortcut.js +90 -0
  102. package/dist/core/stories.js +61 -0
  103. package/dist/core/stromapp.js +138 -0
  104. package/dist/core/text.js +104 -0
  105. package/dist/core/tree.js +507 -0
  106. package/dist/core/uninstall.js +128 -0
  107. package/dist/core/update.js +193 -0
  108. package/dist/core/validate.js +260 -0
  109. package/dist/core/views.js +164 -0
  110. package/dist/core/which.js +51 -0
  111. package/dist/core/workers.js +42 -0
  112. package/dist/gedcom/export.js +454 -0
  113. package/dist/gedcom/labels.js +103 -0
  114. package/dist/gedcom/lines.js +91 -0
  115. package/dist/gedcom/parse.js +53 -0
  116. package/dist/gedcom/validate.js +183 -0
  117. package/dist/image/image.js +223 -0
  118. package/dist/image/index.js +114 -0
  119. package/dist/image/jpeg-decode.js +552 -0
  120. package/dist/image/jpeg-encode.js +254 -0
  121. package/dist/image/png.js +241 -0
  122. package/dist/runners/antigravity.js +70 -0
  123. package/dist/runners/claude.js +179 -0
  124. package/dist/runners/codex.js +45 -0
  125. package/dist/runners/index.js +13 -0
  126. package/dist/runners/jsonl.js +86 -0
  127. package/dist/runners/opencode.js +50 -0
  128. package/dist/runners/runner.js +63 -0
  129. package/dist/runners/script.js +58 -0
  130. package/package.json +44 -0
@@ -0,0 +1,179 @@
1
+ // Claude Code runner. Headless: `claude -p --output-format stream-json
2
+ // --permission-mode dontAsk --settings <tree>/.claude/settings.json` in the
3
+ // tree folder, with the brief on stdin: the tree's permissions decide what is
4
+ // allowed and anything else is denied without asking. The settings are passed
5
+ // explicitly because Claude Code ignores the allow rules of a project's own
6
+ // settings until the user has trusted the folder in an interactive session
7
+ // (found in the first live run: every strom call was refused).
8
+ // Interactive: `claude "<kickoff>"` with the terminal, in the mode of the user's
9
+ // level (agent.permissions): ask — Claude Code's own mode, it asks about what the
10
+ // permissions do not decide; auto — its own review decides and asks only when
11
+ // it is risky; full — --permission-mode bypassPermissions (only the deny rules
12
+ // count), headless or not. Browser tools
13
+ // are on only when a connector fetches through the browser (--chrome), and off
14
+ // otherwise (--no-chrome), whatever Claude Code's own default is.
15
+ import { appendLog, looksLikeLimit, spawnAgent, stopTree } from "./runner.js";
16
+ function describeTool(block) {
17
+ const input = block.input ?? {};
18
+ if (block.name === "Bash" && typeof input.command === "string")
19
+ return `$ ${input.command.split("\n")[0].slice(0, 140)}`;
20
+ if (typeof input.file_path === "string")
21
+ return `${block.name} ${input.file_path}`;
22
+ if (typeof input.url === "string")
23
+ return `${block.name} ${input.url}`;
24
+ if (typeof input.query === "string")
25
+ return `${block.name} "${input.query}"`;
26
+ return String(block.name ?? "tool");
27
+ }
28
+ /** Command-line arguments for a run (exported for tests). */
29
+ export function claudeArgs(opts) {
30
+ const level = opts.permissions ?? "auto";
31
+ const mode = level === "full" ? "bypassPermissions" : !opts.interactive ? "dontAsk" : level === "auto" ? "auto" : undefined;
32
+ const args = opts.interactive ? [opts.kickoff] : ["-p", "--output-format", "stream-json", "--verbose"];
33
+ if (mode)
34
+ args.push("--permission-mode", mode);
35
+ if (opts.settingsFile)
36
+ args.push("--settings", opts.settingsFile);
37
+ if (opts.name)
38
+ args.push("--name", opts.name);
39
+ if (opts.model)
40
+ args.push("--model", opts.model);
41
+ if (opts.chrome !== undefined)
42
+ args.push(opts.chrome ? "--chrome" : "--no-chrome");
43
+ args.push(...(opts.extraArgs ?? []));
44
+ return args;
45
+ }
46
+ /**
47
+ * The environment of a headless run. `claude -p` ends when the model ends its
48
+ * turn, and a command it left in the background dies with it (found in a live
49
+ * run: `strom read` sent to the background, a wake-up scheduled, the session
50
+ * over in 32 s and no reader ever read). So nothing goes to the background, no
51
+ * wake-ups, and one command may take as long as the session.
52
+ */
53
+ export function headlessEnv(env, timeoutMs) {
54
+ const long = String(Math.max(timeoutMs ?? 0, 30 * 60_000));
55
+ return {
56
+ ...env,
57
+ CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1",
58
+ CLAUDE_CODE_DISABLE_CRON: "1",
59
+ BASH_DEFAULT_TIMEOUT_MS: long,
60
+ BASH_MAX_TIMEOUT_MS: long,
61
+ };
62
+ }
63
+ export const claudeRunner = {
64
+ id: "claude",
65
+ command: "claude",
66
+ run(opts) {
67
+ return new Promise((resolve) => {
68
+ const child = spawnAgent("claude", claudeArgs(opts), {
69
+ cwd: opts.cwd,
70
+ env: opts.interactive ? opts.env : headlessEnv(opts.env, opts.timeoutMs),
71
+ stdio: opts.interactive ? "inherit" : ["pipe", "pipe", "pipe"],
72
+ windowsHide: !opts.interactive,
73
+ });
74
+ // The brief goes in on stdin: no limit on its length on any platform.
75
+ if (!opts.interactive)
76
+ child.stdin?.end(opts.prompt);
77
+ let timedOut = false;
78
+ const timer = opts.timeoutMs
79
+ ? setTimeout(() => {
80
+ timedOut = true;
81
+ opts.onProgress?.(`time limit reached (${Math.round(opts.timeoutMs / 60000)} min) — stopping the agent`);
82
+ stopTree(child);
83
+ }, opts.timeoutMs)
84
+ : undefined;
85
+ opts.signal?.addEventListener("abort", () => stopTree(child), { once: true });
86
+ const metrics = {};
87
+ let text = "";
88
+ let buffer = "";
89
+ let isError = false;
90
+ const denied = [];
91
+ const handle = (line) => {
92
+ if (!line.trim())
93
+ return;
94
+ let msg;
95
+ try {
96
+ msg = JSON.parse(line);
97
+ }
98
+ catch {
99
+ return;
100
+ }
101
+ if (msg.type === "assistant") {
102
+ const content = (msg.message?.content ?? []);
103
+ for (const b of content) {
104
+ if (b.type === "tool_use")
105
+ opts.onProgress?.(describeTool(b));
106
+ else if (b.type === "text" && b.text?.trim())
107
+ opts.onProgress?.(b.text.trim().split("\n")[0].slice(0, 160));
108
+ }
109
+ }
110
+ if (msg.type === "result") {
111
+ text = String(msg.result ?? "");
112
+ isError = Boolean(msg.is_error);
113
+ const u = (msg.usage ?? {});
114
+ if (u.input_tokens !== undefined)
115
+ metrics.inputTokens = u.input_tokens;
116
+ if (u.output_tokens !== undefined)
117
+ metrics.outputTokens = u.output_tokens;
118
+ if (u.cache_read_input_tokens !== undefined)
119
+ metrics.cacheReadTokens = u.cache_read_input_tokens;
120
+ if (u.cache_creation_input_tokens !== undefined)
121
+ metrics.cacheWriteTokens = u.cache_creation_input_tokens;
122
+ if (typeof msg.total_cost_usd === "number")
123
+ metrics.costUsd = msg.total_cost_usd;
124
+ if (typeof msg.num_turns === "number")
125
+ metrics.turns = msg.num_turns;
126
+ if (typeof msg.duration_ms === "number")
127
+ metrics.durationMs = msg.duration_ms;
128
+ for (const d of (msg.permission_denials ?? [])) {
129
+ const input = d.tool_input ?? {};
130
+ const what = typeof input.command === "string" ? input.command : typeof input.file_path === "string" ? input.file_path : typeof input.url === "string" ? input.url : "";
131
+ denied.push(`${d.tool_name ?? "tool"}${what ? `: ${String(what).split("\n")[0].slice(0, 120)}` : ""}`);
132
+ }
133
+ if (denied.length)
134
+ metrics.denied = denied.length;
135
+ }
136
+ };
137
+ child.stdout?.on("data", (d) => {
138
+ const s = d.toString("utf8");
139
+ appendLog(opts.logFile, s);
140
+ buffer += s;
141
+ let nl;
142
+ while ((nl = buffer.indexOf("\n")) >= 0) {
143
+ handle(buffer.slice(0, nl));
144
+ buffer = buffer.slice(nl + 1);
145
+ }
146
+ });
147
+ let stderr = "";
148
+ child.stderr?.on("data", (d) => {
149
+ stderr += d.toString("utf8");
150
+ appendLog(opts.logFile, d.toString("utf8"));
151
+ });
152
+ child.on("error", (err) => {
153
+ if (timer)
154
+ clearTimeout(timer);
155
+ resolve({ exitCode: 127, outcome: "error", text: String(err.message), metrics });
156
+ });
157
+ child.on("close", (code) => {
158
+ if (timer)
159
+ clearTimeout(timer);
160
+ if (buffer)
161
+ handle(buffer);
162
+ const all = `${text}\n${stderr}`;
163
+ const limit = looksLikeLimit(all);
164
+ const outcome = opts.signal?.aborted
165
+ ? "stopped"
166
+ : timedOut
167
+ ? "timeout"
168
+ : limit.limit
169
+ ? "limit"
170
+ : /log ?in|authenticat|api key|unauthori[sz]ed/i.test(all) && (code ?? 0) !== 0
171
+ ? "auth"
172
+ : (code ?? 0) !== 0 || isError
173
+ ? "error"
174
+ : "ok";
175
+ resolve({ exitCode: code ?? 1, outcome, text: text || stderr.trim(), metrics, ...(limit.resumeAt ? { resumeAt: limit.resumeAt } : {}), ...(denied.length ? { denied } : {}) });
176
+ });
177
+ });
178
+ },
179
+ };
@@ -0,0 +1,45 @@
1
+ // OpenAI Codex CLI runner. Headless: `codex exec --json` in the tree folder
2
+ // with the brief on stdin. The user's level decides the sandbox: ask and auto
3
+ // work in Codex's sandbox on the tree folder (the shared folder added, the
4
+ // network on — strom fetches), where what the sandbox refuses simply fails —
5
+ // nothing asks, as with Claude Code's dontAsk; full runs without the sandbox.
6
+ // Interactive conversations are started by strom chat (agents/launch.ts).
7
+ import { runJsonLines } from "./jsonl.js";
8
+ /** Command-line arguments of a headless run (exported for tests). */
9
+ export function codexArgs(opts) {
10
+ const sandbox = opts.permissions === "full"
11
+ ? ["--dangerously-bypass-approvals-and-sandbox"]
12
+ : ["--sandbox", "workspace-write", "-c", "sandbox_workspace_write.network_access=true", ...(opts.shared ? ["--add-dir", opts.shared] : [])];
13
+ return ["exec", "--json", "--skip-git-repo-check", ...sandbox, ...(opts.model ? ["--model", opts.model] : []), ...(opts.extraArgs ?? []), "-"];
14
+ }
15
+ export const codexRunner = {
16
+ id: "codex",
17
+ command: "codex",
18
+ run(opts) {
19
+ return runJsonLines("codex", codexArgs(opts), opts.env, opts, (msg, heard) => {
20
+ const item = msg.item;
21
+ if (msg.type === "item.started" && item?.type === "command_execution" && item.command)
22
+ opts.onProgress?.(`$ ${item.command.replace(/^\S*sh -lc '(.*)'$/s, "$1").split("\n")[0].slice(0, 140)}`);
23
+ if (msg.type === "item.completed" && item?.type === "agent_message" && item.text) {
24
+ heard.text = item.text;
25
+ opts.onProgress?.(item.text.trim().split("\n")[0].slice(0, 160));
26
+ }
27
+ // The sandbox refused a command (a write outside the tree, the network off): counted like a denial.
28
+ if (msg.type === "item.completed" && item?.type === "command_execution" && item.exit_code !== 0 && /operation not permitted|sandbox|read-only file system/i.test(item.aggregated_output ?? ""))
29
+ heard.denied.push(`Bash: ${String(item.command ?? "").slice(0, 120)}`);
30
+ if (msg.type === "turn.completed") {
31
+ const u = (msg.usage ?? {});
32
+ const m = heard.metrics;
33
+ m.inputTokens = (m.inputTokens ?? 0) + (u.input_tokens ?? 0);
34
+ m.outputTokens = (m.outputTokens ?? 0) + (u.output_tokens ?? 0);
35
+ m.cacheReadTokens = (m.cacheReadTokens ?? 0) + (u.cached_input_tokens ?? 0);
36
+ m.turns = (m.turns ?? 0) + 1;
37
+ }
38
+ if (msg.type === "turn.failed" || msg.type === "error") {
39
+ heard.isError = true;
40
+ const e = msg.error;
41
+ heard.text = String(e?.message ?? msg.message ?? heard.text);
42
+ }
43
+ });
44
+ },
45
+ };
@@ -0,0 +1,13 @@
1
+ import { claudeRunner } from "./claude.js";
2
+ import { codexRunner } from "./codex.js";
3
+ import { antigravityRunner } from "./antigravity.js";
4
+ import { opencodeRunner } from "./opencode.js";
5
+ import { scriptRunner } from "./script.js";
6
+ export const RUNNERS = {
7
+ claude: claudeRunner,
8
+ codex: codexRunner,
9
+ antigravity: antigravityRunner,
10
+ opencode: opencodeRunner,
11
+ script: scriptRunner,
12
+ };
13
+ export const DEFAULT_RUNNER = "claude";
@@ -0,0 +1,86 @@
1
+ // A headless agent that reports as JSON lines (Codex `exec --json`, Antigravity
2
+ // `--output-format stream-json`): the brief on stdin, one event per line on
3
+ // stdout, a time limit, Ctrl-C, the subscription limit and a missing login
4
+ // recognised — the same for every such agent; each runner only reads its own
5
+ // events.
6
+ import { appendLog, looksLikeLimit, spawnAgent, stopTree } from "./runner.js";
7
+ export function runJsonLines(command, args, env, opts, onEvent) {
8
+ return new Promise((resolve) => {
9
+ const started = Date.now();
10
+ const child = spawnAgent(command, args, { cwd: opts.cwd, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
11
+ child.stdin?.end(opts.prompt);
12
+ let timedOut = false;
13
+ const timer = opts.timeoutMs
14
+ ? setTimeout(() => {
15
+ timedOut = true;
16
+ opts.onProgress?.(`time limit reached (${Math.round(opts.timeoutMs / 60000)} min) — stopping the agent`);
17
+ stopTree(child);
18
+ }, opts.timeoutMs)
19
+ : undefined;
20
+ opts.signal?.addEventListener("abort", () => stopTree(child), { once: true });
21
+ const heard = { metrics: {}, text: "", isError: false, denied: [] };
22
+ let buffer = "";
23
+ const handle = (line) => {
24
+ if (!line.trim())
25
+ return;
26
+ let msg;
27
+ try {
28
+ msg = JSON.parse(line);
29
+ }
30
+ catch {
31
+ return; // a line of plain text among the events
32
+ }
33
+ onEvent(msg, heard);
34
+ };
35
+ child.stdout?.on("data", (d) => {
36
+ const s = d.toString("utf8");
37
+ appendLog(opts.logFile, s);
38
+ buffer += s;
39
+ let nl;
40
+ while ((nl = buffer.indexOf("\n")) >= 0) {
41
+ handle(buffer.slice(0, nl));
42
+ buffer = buffer.slice(nl + 1);
43
+ }
44
+ });
45
+ let stderr = "";
46
+ child.stderr?.on("data", (d) => {
47
+ stderr += d.toString("utf8");
48
+ appendLog(opts.logFile, d.toString("utf8"));
49
+ });
50
+ child.on("error", (err) => {
51
+ if (timer)
52
+ clearTimeout(timer);
53
+ resolve({ exitCode: 127, outcome: "error", text: String(err.message), metrics: heard.metrics });
54
+ });
55
+ child.on("close", (code) => {
56
+ if (timer)
57
+ clearTimeout(timer);
58
+ if (buffer)
59
+ handle(buffer);
60
+ heard.metrics.durationMs ??= Date.now() - started;
61
+ if (heard.denied.length)
62
+ heard.metrics.denied = heard.denied.length;
63
+ const all = `${heard.text}\n${stderr}`;
64
+ const limit = looksLikeLimit(all);
65
+ const outcome = opts.signal?.aborted
66
+ ? "stopped"
67
+ : timedOut
68
+ ? "timeout"
69
+ : limit.limit
70
+ ? "limit"
71
+ : /log ?in|authenticat|api[ _]?key|unauthori[sz]ed|GEMINI_API_KEY/i.test(all) && ((code ?? 0) !== 0 || heard.isError)
72
+ ? "auth"
73
+ : (code ?? 0) !== 0 || heard.isError
74
+ ? "error"
75
+ : "ok";
76
+ resolve({
77
+ exitCode: code ?? 1,
78
+ outcome,
79
+ text: heard.text || stderr.trim(),
80
+ metrics: heard.metrics,
81
+ ...(limit.resumeAt ? { resumeAt: limit.resumeAt } : {}),
82
+ ...(heard.denied.length ? { denied: heard.denied } : {}),
83
+ });
84
+ });
85
+ });
86
+ }
@@ -0,0 +1,50 @@
1
+ // OpenCode runner. Headless: `opencode run --format json "<kickoff>"` in the tree
2
+ // folder with the brief on stdin (it joins what is piped in with the message).
3
+ // The tree's rules are its project config (opencode.json: strom allowed, the
4
+ // evidence and git denied); what they would ask about is refused in a run
5
+ // nobody watches — under ask and auto that is all; full adds --auto
6
+ // (everything the rules do not deny). Events are JSON lines: text, tool_use,
7
+ // step_start, step_finish (tokens and cost of a step), error.
8
+ import { runJsonLines } from "./jsonl.js";
9
+ /** Command-line arguments of a headless run (exported for tests). */
10
+ export function opencodeArgs(opts) {
11
+ return ["run", "--format", "json", ...(opts.permissions === "full" ? ["--auto"] : []), ...(opts.model ? ["--model", opts.model] : []), ...(opts.extraArgs ?? []), opts.kickoff];
12
+ }
13
+ export const opencodeRunner = {
14
+ id: "opencode",
15
+ command: "opencode",
16
+ run(opts) {
17
+ const args = opencodeArgs({ ...opts, kickoff: "Your brief is above. Work only through `strom` in this folder, as it says." });
18
+ const tokens = { input: 0, output: 0, cacheRead: 0 };
19
+ return runJsonLines("opencode", args, opts.env, opts, (msg, heard) => {
20
+ const part = (msg.part ?? {});
21
+ if (msg.type === "tool_use") {
22
+ const state = (part.state ?? {});
23
+ const input = state.input ?? {};
24
+ const what = typeof input.command === "string" ? `$ ${input.command}` : typeof input.filePath === "string" ? `${String(part.tool)} ${input.filePath}` : String(part.tool ?? "tool");
25
+ opts.onProgress?.(what.split("\n")[0].slice(0, 140));
26
+ if (state.status === "error" && /reject|denied|not allowed|permission/i.test(state.error ?? ""))
27
+ heard.denied.push(`${String(part.tool)}: ${what.slice(0, 120)}`);
28
+ }
29
+ if (msg.type === "text" && typeof part.text === "string" && part.text.trim())
30
+ heard.text = part.text;
31
+ if (msg.type === "step_finish") {
32
+ // One step of the conversation; the run's use is their sum.
33
+ const t = (part.tokens ?? {});
34
+ tokens.input += t.input ?? 0;
35
+ tokens.output += (t.output ?? 0) + (t.reasoning ?? 0);
36
+ tokens.cacheRead += t.cache?.read ?? 0;
37
+ const m = heard.metrics;
38
+ m.inputTokens = tokens.input;
39
+ m.outputTokens = tokens.output;
40
+ m.cacheReadTokens = tokens.cacheRead;
41
+ m.turns = (m.turns ?? 0) + 1;
42
+ }
43
+ if (msg.type === "error") {
44
+ heard.isError = true;
45
+ const e = (msg.error ?? {});
46
+ heard.text = e.data?.message ?? e.name ?? "error";
47
+ }
48
+ });
49
+ },
50
+ };
@@ -0,0 +1,63 @@
1
+ // Runners start an AI agent CLI on a prompt in a tree folder and report back.
2
+ // Adding an agent = one file implementing Runner.
3
+ import { spawn } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { findAgent } from "../core/which.js";
7
+ /** Recognise a subscription/usage limit in an agent's message. */
8
+ export function looksLikeLimit(text) {
9
+ if (!/(usage|rate|session|weekly|5-hour|daily)\s+limit|limit (reached|exceeded)|quota|too many requests|out of (credits|usage)/i.test(text))
10
+ return { limit: false };
11
+ const at = /resets?\s+(?:at\s+)?([^.\n]+)/i.exec(text)?.[1]?.trim();
12
+ return at ? { limit: true, resumeAt: at } : { limit: true };
13
+ }
14
+ export function appendLog(file, chunk) {
15
+ fs.mkdirSync(path.dirname(file), { recursive: true });
16
+ fs.appendFileSync(file, chunk);
17
+ }
18
+ /**
19
+ * The environment with `dir` first on PATH. On Windows the variable is
20
+ * usually spelled "Path"; adding a second "PATH" beside it leaves the child
21
+ * with two, and which one wins is undefined — so the existing key is reused.
22
+ */
23
+ export function prependPath(env, dir) {
24
+ const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH";
25
+ const out = {};
26
+ for (const [k, v] of Object.entries(env))
27
+ if (k.toUpperCase() !== "PATH" || k === key)
28
+ out[k] = v;
29
+ out[key] = `${dir}${path.delimiter}${env[key] ?? ""}`;
30
+ return out;
31
+ }
32
+ /** Quote one argument for cmd.exe (only used for .cmd/.bat launchers on Windows). */
33
+ function cmdQuote(arg) {
34
+ if (/^[\w.\-/:=@]+$/.test(arg))
35
+ return arg;
36
+ return `"${arg.replace(/"/g, '""')}"`;
37
+ }
38
+ /**
39
+ * Start an agent CLI found on PATH. npm installs agents on Windows as .cmd
40
+ * launchers, which Node can only start through cmd.exe.
41
+ */
42
+ export function spawnAgent(command, args, opts) {
43
+ const found = findAgent(command, opts.env) ?? command;
44
+ if (process.platform === "win32" && /\.(cmd|bat)$/i.test(found)) {
45
+ const line = [cmdQuote(found), ...args.map(cmdQuote)].join(" ");
46
+ return spawn(opts.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `"${line}"`], { ...opts, windowsVerbatimArguments: true, env: opts.env });
47
+ }
48
+ return spawn(found, args, { ...opts, env: opts.env });
49
+ }
50
+ /** Stop a child and everything it started (Windows: the whole process tree). */
51
+ export function stopTree(child) {
52
+ if (child.exitCode !== null || child.pid === undefined)
53
+ return;
54
+ if (process.platform === "win32")
55
+ spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
56
+ else {
57
+ child.kill("SIGTERM");
58
+ setTimeout(() => {
59
+ if (child.exitCode === null)
60
+ child.kill("SIGKILL");
61
+ }, 5000).unref();
62
+ }
63
+ }
@@ -0,0 +1,58 @@
1
+ // A runner that executes a local script instead of an AI agent — for tests
2
+ // and for scripted, repeatable runs. STROM_RUNNER_SCRIPT names the script
3
+ // (run with node); it gets the same environment an agent would, and the
4
+ // prompt both on stdin (like a headless agent) and in STROM_PROMPT.
5
+ import { spawn } from "node:child_process";
6
+ import { appendLog, looksLikeLimit, stopTree } from "./runner.js";
7
+ export const scriptRunner = {
8
+ id: "script",
9
+ command: process.execPath,
10
+ run(opts) {
11
+ const script = opts.env.STROM_RUNNER_SCRIPT;
12
+ return new Promise((resolve) => {
13
+ if (!script)
14
+ return resolve({ exitCode: 2, outcome: "error", text: "STROM_RUNNER_SCRIPT is not set", metrics: {} });
15
+ const child = spawn(process.execPath, [script, ...(opts.extraArgs ?? [])], {
16
+ cwd: opts.cwd,
17
+ env: { ...opts.env, STROM_PROMPT: opts.prompt },
18
+ stdio: ["pipe", "pipe", "pipe"],
19
+ });
20
+ child.stdin.end(opts.prompt);
21
+ let timedOut = false;
22
+ const timer = opts.timeoutMs
23
+ ? setTimeout(() => {
24
+ timedOut = true;
25
+ stopTree(child);
26
+ }, opts.timeoutMs)
27
+ : undefined;
28
+ opts.signal?.addEventListener("abort", () => stopTree(child), { once: true });
29
+ let out = "";
30
+ const denied = [];
31
+ const on = (d) => {
32
+ for (const l of d.toString("utf8").split("\n"))
33
+ if (l.startsWith("denied: "))
34
+ denied.push(l.slice(8).trim());
35
+ out += d.toString("utf8");
36
+ appendLog(opts.logFile, d.toString("utf8"));
37
+ for (const l of d.toString("utf8").split("\n"))
38
+ if (l.trim())
39
+ opts.onProgress?.(l.trim().slice(0, 160));
40
+ };
41
+ child.stdout.on("data", on);
42
+ child.stderr.on("data", on);
43
+ child.on("close", (code) => {
44
+ if (timer)
45
+ clearTimeout(timer);
46
+ const limit = looksLikeLimit(out);
47
+ resolve({
48
+ exitCode: code ?? 1,
49
+ outcome: opts.signal?.aborted ? "stopped" : timedOut ? "timeout" : limit.limit ? "limit" : code === 0 ? "ok" : "error",
50
+ text: out.trim().split("\n").slice(-5).join("\n"),
51
+ metrics: { turns: 1, ...(denied.length ? { denied: denied.length } : {}) },
52
+ ...(limit.resumeAt ? { resumeAt: limit.resumeAt } : {}),
53
+ ...(denied.length ? { denied } : {}),
54
+ });
55
+ });
56
+ });
57
+ },
58
+ };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "strom-research",
3
+ "version": "1.0.0",
4
+ "description": "Genealogical research toolkit for AI coding agents",
5
+ "type": "module",
6
+ "bin": {
7
+ "strom": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "assets",
12
+ "LICENSE"
13
+ ],
14
+ "engines": {
15
+ "node": ">=22.18"
16
+ },
17
+ "scripts": {
18
+ "strom": "node src/cli.ts",
19
+ "test": "node --test --test-reporter=dot \"test/**/*.test.ts\"",
20
+ "typecheck": "tsc --noEmit -p tsconfig.json",
21
+ "build": "tsc -p tsconfig.build.json",
22
+ "prepack": "npm run build",
23
+ "release": "node scripts/build-release.ts"
24
+ },
25
+ "keywords": [
26
+ "genealogy",
27
+ "family-tree",
28
+ "gedcom",
29
+ "research",
30
+ "cli"
31
+ ],
32
+ "author": "Milan Víšek",
33
+ "homepage": "https://github.com/ACiDekCZ/strom-research#readme",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/ACiDekCZ/strom-research.git"
37
+ },
38
+ "bugs": "https://github.com/ACiDekCZ/strom-research/issues",
39
+ "license": "MPL-2.0",
40
+ "devDependencies": {
41
+ "@types/node": "^22.20.4",
42
+ "typescript": "^7.0.2"
43
+ }
44
+ }