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.
- package/LICENSE +373 -0
- package/README.md +142 -0
- package/assets/lang/cs.json +302 -0
- package/assets/lang/de.json +302 -0
- package/assets/method/core.md +43 -0
- package/assets/method/enrich.md +11 -0
- package/assets/method/intake.md +30 -0
- package/assets/method/link.md +28 -0
- package/assets/method/locate.md +28 -0
- package/assets/method/narrate.md +13 -0
- package/assets/method/reading.md +62 -0
- package/assets/method/recording.md +59 -0
- package/assets/method/request.md +10 -0
- package/assets/method/verify.md +17 -0
- package/assets/plugins/README.md +23 -0
- package/assets/plugins/connectors/DISCOVERY.md +159 -0
- package/assets/plugins/connectors/README.md +376 -0
- package/assets/plugins/connectors/sdk.ts +168 -0
- package/assets/plugins/connectors/template.ts +38 -0
- package/assets/plugins/gitignore +4 -0
- package/dist/agents/files.js +313 -0
- package/dist/agents/global.js +257 -0
- package/dist/agents/launch.js +36 -0
- package/dist/agents/profiles.js +95 -0
- package/dist/brief/brief.js +345 -0
- package/dist/cli/commit.js +44 -0
- package/dist/cli/context.js +311 -0
- package/dist/cli/execute.js +154 -0
- package/dist/cli/fixes.js +78 -0
- package/dist/cli/format.js +53 -0
- package/dist/cli/help.js +59 -0
- package/dist/cli/main.js +152 -0
- package/dist/cli/menu.js +212 -0
- package/dist/cli/registry.js +96 -0
- package/dist/cli/ui.js +266 -0
- package/dist/cli/wizard.js +142 -0
- package/dist/cli.js +14 -0
- package/dist/commands/analysis.js +622 -0
- package/dist/commands/batch.js +181 -0
- package/dist/commands/checks.js +153 -0
- package/dist/commands/connectors.js +1377 -0
- package/dist/commands/guide.js +160 -0
- package/dist/commands/index.js +19 -0
- package/dist/commands/intake.js +234 -0
- package/dist/commands/media.js +406 -0
- package/dist/commands/meta.js +195 -0
- package/dist/commands/output.js +117 -0
- package/dist/commands/people.js +664 -0
- package/dist/commands/read.js +199 -0
- package/dist/commands/research.js +139 -0
- package/dist/commands/session.js +605 -0
- package/dist/commands/setup.js +465 -0
- package/dist/commands/sources.js +634 -0
- package/dist/commands/start.js +383 -0
- package/dist/commands/story.js +75 -0
- package/dist/commands/tasks.js +436 -0
- package/dist/commands/trees.js +128 -0
- package/dist/core/actions.js +852 -0
- package/dist/core/age.js +95 -0
- package/dist/core/apps.js +74 -0
- package/dist/core/assets.js +34 -0
- package/dist/core/awake.js +33 -0
- package/dist/core/browser.js +281 -0
- package/dist/core/calibration.js +48 -0
- package/dist/core/check.js +112 -0
- package/dist/core/chromium.js +88 -0
- package/dist/core/config.js +348 -0
- package/dist/core/connector.js +811 -0
- package/dist/core/deps.js +73 -0
- package/dist/core/dialog.js +61 -0
- package/dist/core/errors.js +89 -0
- package/dist/core/evidence.js +58 -0
- package/dist/core/frontier.js +219 -0
- package/dist/core/gdate.js +77 -0
- package/dist/core/git.js +300 -0
- package/dist/core/guard.js +124 -0
- package/dist/core/http2.js +76 -0
- package/dist/core/import.js +541 -0
- package/dist/core/install.js +28 -0
- package/dist/core/integrity.js +219 -0
- package/dist/core/json.js +87 -0
- package/dist/core/lang.js +70 -0
- package/dist/core/live.js +244 -0
- package/dist/core/lock.js +112 -0
- package/dist/core/logins.js +67 -0
- package/dist/core/media.js +223 -0
- package/dist/core/model.js +101 -0
- package/dist/core/net.js +366 -0
- package/dist/core/open.js +29 -0
- package/dist/core/paths.js +84 -0
- package/dist/core/people.js +283 -0
- package/dist/core/phrases.js +85 -0
- package/dist/core/queue.js +113 -0
- package/dist/core/reader.js +76 -0
- package/dist/core/records.js +105 -0
- package/dist/core/roles.js +30 -0
- package/dist/core/schema.js +261 -0
- package/dist/core/seal.js +77 -0
- package/dist/core/self.js +40 -0
- package/dist/core/session.js +155 -0
- package/dist/core/shortcut.js +90 -0
- package/dist/core/stories.js +61 -0
- package/dist/core/stromapp.js +138 -0
- package/dist/core/text.js +104 -0
- package/dist/core/tree.js +507 -0
- package/dist/core/uninstall.js +128 -0
- package/dist/core/update.js +193 -0
- package/dist/core/validate.js +260 -0
- package/dist/core/views.js +164 -0
- package/dist/core/which.js +51 -0
- package/dist/core/workers.js +42 -0
- package/dist/gedcom/export.js +454 -0
- package/dist/gedcom/labels.js +103 -0
- package/dist/gedcom/lines.js +91 -0
- package/dist/gedcom/parse.js +53 -0
- package/dist/gedcom/validate.js +183 -0
- package/dist/image/image.js +223 -0
- package/dist/image/index.js +114 -0
- package/dist/image/jpeg-decode.js +552 -0
- package/dist/image/jpeg-encode.js +254 -0
- package/dist/image/png.js +241 -0
- package/dist/runners/antigravity.js +70 -0
- package/dist/runners/claude.js +179 -0
- package/dist/runners/codex.js +45 -0
- package/dist/runners/index.js +13 -0
- package/dist/runners/jsonl.js +86 -0
- package/dist/runners/opencode.js +50 -0
- package/dist/runners/runner.js +63 -0
- package/dist/runners/script.js +58 -0
- 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
|
+
}
|