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,605 @@
|
|
|
1
|
+
// session · brief · frontier · agents · run — the research loop.
|
|
2
|
+
//
|
|
3
|
+
// `strom run` is plain code, not an LLM: it picks the task, starts a session,
|
|
4
|
+
// hands the brief to an agent CLI as a child process, waits for it to exit,
|
|
5
|
+
// closes what the agent left open, exports the GEDCOM and commits. Between
|
|
6
|
+
// sessions not a single token is spent.
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { register } from "../cli/registry.js";
|
|
10
|
+
import { ui } from "../cli/ui.js";
|
|
11
|
+
import { lines, table, truncate } from "../cli/format.js";
|
|
12
|
+
import { UsageError, StromError } from "../core/errors.js";
|
|
13
|
+
import { closeSession, currentSession, openSessions, othersAtWork, sessionNote, startSession } from "../core/session.js";
|
|
14
|
+
import { buildBrief } from "../brief/brief.js";
|
|
15
|
+
import { DEFAULT_BUDGET, DEFAULT_RUN_MINUTES, Settings } from "../core/config.js";
|
|
16
|
+
import { prependPath } from "../runners/runner.js";
|
|
17
|
+
import { frontier } from "../core/frontier.js";
|
|
18
|
+
import { storyProposals } from "../core/stories.js";
|
|
19
|
+
import { create, requireRecord, update } from "../core/records.js";
|
|
20
|
+
import { taskQueue, waitingLines } from "./tasks.js";
|
|
21
|
+
import { resolveResearch } from "./research.js";
|
|
22
|
+
import { writeGedcoms } from "./output.js";
|
|
23
|
+
import { syncAgentFiles } from "../agents/files.js";
|
|
24
|
+
import { setTreeSetting } from "./setup.js";
|
|
25
|
+
import { RUNNERS } from "../runners/index.js";
|
|
26
|
+
import { PROFILES } from "../agents/profiles.js";
|
|
27
|
+
import { AGENTS, detectAgent, which } from "../core/which.js";
|
|
28
|
+
import { keepAwake } from "../core/awake.js";
|
|
29
|
+
import { stromLauncher } from "../core/self.js";
|
|
30
|
+
import { phrase } from "../core/phrases.js";
|
|
31
|
+
import { enterWorker } from "../core/workers.js";
|
|
32
|
+
import { acquireLock } from "../core/lock.js";
|
|
33
|
+
import { assertIntact, snapshot, verifyFast } from "../core/integrity.js";
|
|
34
|
+
import { guard } from "../core/guard.js";
|
|
35
|
+
import { hasErrors } from "../core/check.js";
|
|
36
|
+
import { Tree } from "../core/tree.js";
|
|
37
|
+
import { browserConnectors, routeOf } from "../core/connector.js";
|
|
38
|
+
function written(tree) {
|
|
39
|
+
return lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined);
|
|
40
|
+
}
|
|
41
|
+
function researchOf(tree, ref) {
|
|
42
|
+
if (typeof ref === "string")
|
|
43
|
+
return resolveResearch(tree, ref);
|
|
44
|
+
const active = tree.list("research").filter((r) => r.state === "active");
|
|
45
|
+
return active.length === 1 ? active[0] : undefined;
|
|
46
|
+
}
|
|
47
|
+
/** Create the frontier's proposed tasks, and the stories due (unless the user said no); returns their IDs. */
|
|
48
|
+
export function applyFrontier(tree, research) {
|
|
49
|
+
const created = [];
|
|
50
|
+
for (const item of frontier(tree, research)) {
|
|
51
|
+
if (!item.proposal)
|
|
52
|
+
continue;
|
|
53
|
+
const t = create(tree, "task", { ...item.proposal, state: "open", origin: "frontier" }, (id) => `+${id} task "${truncate(item.proposal.what, 60)}" (frontier)`);
|
|
54
|
+
created.push(t.id);
|
|
55
|
+
}
|
|
56
|
+
if (new Settings(tree.env, {}).stories(tree.config).on)
|
|
57
|
+
for (const proposal of storyProposals(tree, research)) {
|
|
58
|
+
const t = create(tree, "task", { ...proposal, state: "open", origin: "frontier" }, (id) => `+${id} task "${truncate(proposal.what, 60)}" (story)`);
|
|
59
|
+
created.push(t.id);
|
|
60
|
+
}
|
|
61
|
+
return created;
|
|
62
|
+
}
|
|
63
|
+
/** Commit behind the same gate as every write. */
|
|
64
|
+
export function commitNow(tree, message) {
|
|
65
|
+
const snap = snapshot(tree);
|
|
66
|
+
if (hasErrors([...verifyFast(tree, snap).findings, ...guard(tree, snap)]))
|
|
67
|
+
return false;
|
|
68
|
+
tree.withTreeLock(() => tree.commit(message));
|
|
69
|
+
tree.settle();
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
// ── sessions ───────────────────────────────────────────────────────────────
|
|
73
|
+
register({
|
|
74
|
+
path: ["session", "start"],
|
|
75
|
+
summary: "Start a working session on the next task (or a given one) and print its brief",
|
|
76
|
+
group: "research",
|
|
77
|
+
tree: true,
|
|
78
|
+
writes: true,
|
|
79
|
+
args: [{ name: "task", description: "task ID (default: strom task next)" }],
|
|
80
|
+
options: [
|
|
81
|
+
{ name: "research", type: "string", value: "<G…>", description: "take the next task of this research" },
|
|
82
|
+
{ name: "budget", type: "string", value: "<tokens>", description: `brief size (default ${DEFAULT_BUDGET})` },
|
|
83
|
+
],
|
|
84
|
+
run(ctx, { args, opts }) {
|
|
85
|
+
const tree = ctx.tree();
|
|
86
|
+
const research = researchOf(tree, opts.research);
|
|
87
|
+
// The next task nobody else is working on (other agents may be at work in this tree).
|
|
88
|
+
const others = othersAtWork(tree).tasks;
|
|
89
|
+
const task = args[0] ? requireRecord(tree, args[0], "task") : taskQueue(tree, { ...(research ? { research: research.id } : {}), strategy: ctx.settings.strategy(tree.config) }).find((t) => !others.has(t.id));
|
|
90
|
+
if (task && !["open", "doing", "parked"].includes(task.state))
|
|
91
|
+
throw new UsageError(`${task.id} is ${task.state}`);
|
|
92
|
+
const s = startSession(tree, { ...(task ? { task } : {}), ...(research ? { research: research.id } : {}) });
|
|
93
|
+
const brief = buildBrief(tree, { ...(task ? { task } : {}), session: s, budget: ctx.settings.number("brief.budget", tree.config, DEFAULT_BUDGET), shared: ctx.settings.shared()?.value });
|
|
94
|
+
return { text: lines(written(tree), "", brief.text), data: { session: s, brief: brief.text, sections: brief.sections } };
|
|
95
|
+
},
|
|
96
|
+
}, {
|
|
97
|
+
path: ["session", "note"],
|
|
98
|
+
summary: "A note in the session diary (what you are doing, what you noticed)",
|
|
99
|
+
group: "research",
|
|
100
|
+
tree: true,
|
|
101
|
+
writes: true,
|
|
102
|
+
args: [{ name: "text", description: "the note", required: true }],
|
|
103
|
+
run(ctx, { args }) {
|
|
104
|
+
const tree = ctx.tree();
|
|
105
|
+
const s = currentSession(tree, ctx.env);
|
|
106
|
+
if (!s)
|
|
107
|
+
throw new UsageError("no open session", { hint: "strom session start" });
|
|
108
|
+
sessionNote(tree, s, args[0]);
|
|
109
|
+
return { text: written(tree) };
|
|
110
|
+
},
|
|
111
|
+
}, {
|
|
112
|
+
path: ["session", "close"],
|
|
113
|
+
summary: "Finish the session: summary, next step; exports the GEDCOM and fills the task queue",
|
|
114
|
+
group: "research",
|
|
115
|
+
tree: true,
|
|
116
|
+
writes: true,
|
|
117
|
+
description: "The task must be done, parked or waiting — or --continue returns it to the queue for the next session.",
|
|
118
|
+
args: [{ name: "session", description: "session ID (default: the open one)" }],
|
|
119
|
+
options: [
|
|
120
|
+
{ name: "summary", type: "string", value: "<text>", description: "what was proven, what was searched in vain" },
|
|
121
|
+
{ name: "next", type: "string", value: "<text>", description: "the next cheapest step" },
|
|
122
|
+
{ name: "continue", type: "boolean", description: "the task is not finished: back to the queue with --next as handover" },
|
|
123
|
+
{ name: "interrupted", type: "boolean", description: "close a session whose agent died (no summary needed)" },
|
|
124
|
+
],
|
|
125
|
+
examples: ['strom session close --summary "baptism of Jan found, parents Josef and Marie" --next "marriage of Josef ~1898, B0002"'],
|
|
126
|
+
run(ctx, { args, opts }) {
|
|
127
|
+
const tree = ctx.tree();
|
|
128
|
+
const s = args[0] ? requireRecord(tree, args[0], "session") : currentSession(tree, ctx.env);
|
|
129
|
+
if (!s || s.state !== "open")
|
|
130
|
+
throw new UsageError("no open session", { hint: "strom session list" });
|
|
131
|
+
const closed = closeSession(tree, s, {
|
|
132
|
+
summary: String(opts.summary ?? ""),
|
|
133
|
+
next: String(opts.next ?? ""),
|
|
134
|
+
continueTask: Boolean(opts.continue),
|
|
135
|
+
interrupted: Boolean(opts.interrupted),
|
|
136
|
+
});
|
|
137
|
+
const research = s.research ? tree.get(s.research) : undefined;
|
|
138
|
+
const newTasks = research ? applyFrontier(tree, research) : [];
|
|
139
|
+
const geds = tree.dryRun ? [] : writeGedcoms(ctx, tree);
|
|
140
|
+
const ged = geds[0];
|
|
141
|
+
return {
|
|
142
|
+
text: lines(written(tree), ged ? `GEDCOM ${geds.map((g) => ctx.display(g.file)).join(" · ")}: ${ged.stats.persons} persons, ${ged.stats.families} families` : undefined, newTasks.length ? `${newTasks.length} new task(s) from the research frontier` : undefined,
|
|
143
|
+
// In a conversation, the next task is best begun in a fresh context: all of this one is in strom.
|
|
144
|
+
!s.runner && ctx.env.STROM_NONINTERACTIVE !== "1" ? freshContext(ctx, tree, s) : undefined),
|
|
145
|
+
data: { session: closed, newTasks, ged: ged?.file, geds: geds.map((g) => g.file) },
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
}, {
|
|
149
|
+
path: ["session", "list"],
|
|
150
|
+
summary: "Sessions so far: task, outcome, summary, cost",
|
|
151
|
+
group: "research",
|
|
152
|
+
tree: true,
|
|
153
|
+
run(ctx) {
|
|
154
|
+
const all = ctx.tree().list("session").slice().reverse();
|
|
155
|
+
const cost = (s) => (s.metrics?.costUsd !== undefined ? `$${s.metrics.costUsd.toFixed(2)}` : "");
|
|
156
|
+
return {
|
|
157
|
+
text: all.length ? table(all.map((s) => [s.id, s.state, s.task ?? "", s.started.slice(0, 16).replace("T", " "), cost(s), truncate(s.summary ?? "", 70)])) : "no sessions yet → strom session start",
|
|
158
|
+
data: { sessions: all },
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
}, {
|
|
162
|
+
path: ["session", "show"],
|
|
163
|
+
summary: "One session: task, summary, handover, metrics, diary",
|
|
164
|
+
group: "research",
|
|
165
|
+
tree: true,
|
|
166
|
+
args: [{ name: "session", description: "session ID (default: the open one or the last)", required: false }],
|
|
167
|
+
run(ctx, { args }) {
|
|
168
|
+
const tree = ctx.tree();
|
|
169
|
+
const s = args[0] ? requireRecord(tree, args[0], "session") : (currentSession(tree, ctx.env) ?? tree.list("session").at(-1));
|
|
170
|
+
if (!s)
|
|
171
|
+
throw new UsageError("no sessions yet");
|
|
172
|
+
const m = s.metrics ?? {};
|
|
173
|
+
return {
|
|
174
|
+
text: lines(`${s.id} ${s.state}${s.task ? ` · task ${s.task}` : ""}${s.runner ? ` · ${s.runner}` : ""} · ${s.started.slice(0, 16).replace("T", " ")}${s.ended ? ` – ${s.ended.slice(11, 16)}` : ""}`, s.summary ? `summary ${s.summary}` : undefined, s.next ? `next ${s.next}` : undefined, Object.keys(m).length
|
|
175
|
+
? `metrics ${[
|
|
176
|
+
m.turns && `${m.turns} turns`,
|
|
177
|
+
m.inputTokens && `${m.inputTokens} in`,
|
|
178
|
+
m.outputTokens && `${m.outputTokens} out`,
|
|
179
|
+
m.cacheReadTokens && `${m.cacheReadTokens} cache read`,
|
|
180
|
+
m.costUsd !== undefined && `$${m.costUsd.toFixed(2)}`,
|
|
181
|
+
s.ended && `${Math.max(1, Math.round((Date.parse(s.ended) - Date.parse(s.started)) / 60000))} min`,
|
|
182
|
+
m.denied && `${m.denied} refused by permissions`,
|
|
183
|
+
]
|
|
184
|
+
.filter(Boolean)
|
|
185
|
+
.join(" · ")}`
|
|
186
|
+
: undefined, ...s.notes.map((n) => ` ${n.at.slice(11, 16)} ${n.text}`)),
|
|
187
|
+
data: { session: s },
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
}, {
|
|
191
|
+
path: ["brief"],
|
|
192
|
+
summary: "The brief for a task (what `session start` prints), with --stats for its size by section",
|
|
193
|
+
group: "research",
|
|
194
|
+
tree: true,
|
|
195
|
+
args: [{ name: "task", description: "task ID (default: the open session's task, else the next task)" }],
|
|
196
|
+
options: [
|
|
197
|
+
{ name: "stats", type: "boolean", description: "only the size of each section" },
|
|
198
|
+
{ name: "budget", type: "string", value: "<tokens>", description: `size limit (default ${DEFAULT_BUDGET})` },
|
|
199
|
+
],
|
|
200
|
+
run(ctx, { args, opts }) {
|
|
201
|
+
const tree = ctx.tree();
|
|
202
|
+
const s = currentSession(tree, ctx.env);
|
|
203
|
+
const task = args[0] ? requireRecord(tree, args[0], "task") : s?.task ? tree.get(s.task) : taskQueue(tree, { strategy: ctx.settings.strategy(tree.config) })[0];
|
|
204
|
+
const b = buildBrief(tree, { ...(task ? { task } : {}), ...(s ? { session: s } : {}), budget: ctx.settings.number("brief.budget", tree.config, DEFAULT_BUDGET), shared: ctx.settings.shared()?.value });
|
|
205
|
+
if (opts.stats)
|
|
206
|
+
return {
|
|
207
|
+
text: lines(table(b.sections.map((x) => [x.name, `${x.tokens}`, x.cut ? "cut" : ""])), `total ~${b.total} tokens of ${b.budget}`),
|
|
208
|
+
data: b,
|
|
209
|
+
};
|
|
210
|
+
return { text: b.text, data: b };
|
|
211
|
+
},
|
|
212
|
+
}, {
|
|
213
|
+
path: ["frontier"],
|
|
214
|
+
summary: "The research frontier: who still lacks proven parents, and the tasks that would find them",
|
|
215
|
+
group: "tasks",
|
|
216
|
+
tree: true,
|
|
217
|
+
writes: true,
|
|
218
|
+
options: [
|
|
219
|
+
{ name: "research", type: "string", value: "<G…>", description: "which research (default: the only active one)" },
|
|
220
|
+
{ name: "apply", type: "boolean", description: "create the proposed tasks" },
|
|
221
|
+
],
|
|
222
|
+
run(ctx, { opts }) {
|
|
223
|
+
const tree = ctx.tree();
|
|
224
|
+
const r = researchOf(tree, opts.research);
|
|
225
|
+
if (!r)
|
|
226
|
+
throw new UsageError("which research?", { hint: "strom frontier --research G0001" });
|
|
227
|
+
if (opts.apply) {
|
|
228
|
+
const ids = applyFrontier(tree, r);
|
|
229
|
+
return { text: ids.length ? written(tree) : "nothing to add — every frontier person has a task", data: { created: ids } };
|
|
230
|
+
}
|
|
231
|
+
const items = frontier(tree, r);
|
|
232
|
+
const stories = ctx.settings.stories(tree.config).on ? storyProposals(tree, r) : [];
|
|
233
|
+
const storyLine = stories.length ? `\nstories to write: ${stories.length} (${stories.map((p) => p.subject[0]).join(" ")}) → strom frontier --apply` : undefined;
|
|
234
|
+
return {
|
|
235
|
+
text: items.length
|
|
236
|
+
? lines(table(items.map((i) => [
|
|
237
|
+
i.person.id,
|
|
238
|
+
`G${i.generation}`,
|
|
239
|
+
truncate(i.reason, 30),
|
|
240
|
+
i.coveredBy
|
|
241
|
+
? `task ${i.coveredBy}`
|
|
242
|
+
: i.proposal
|
|
243
|
+
? `→ ${i.proposal.level}: ${truncate(i.proposal.what, 60)}`
|
|
244
|
+
: i.exhausted
|
|
245
|
+
? `exhausted (${i.exhausted.join(" ")}) — ask the user how to go on`
|
|
246
|
+
: "needs a birth year or place first",
|
|
247
|
+
])), items.some((i) => i.proposal) ? "\ncreate the proposed tasks: strom frontier --apply" : undefined, storyLine)
|
|
248
|
+
: lines("the frontier is empty — every ancestor in scope has proven parents (or the research has no limits reached)", storyLine),
|
|
249
|
+
data: { frontier: items.map((i) => ({ person: i.person.id, generation: i.generation, reason: i.reason, coveredBy: i.coveredBy, proposal: i.proposal, exhausted: i.exhausted })), stories },
|
|
250
|
+
};
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
// ── agents ─────────────────────────────────────────────────────────────────
|
|
254
|
+
register({
|
|
255
|
+
path: ["agents", "sync"],
|
|
256
|
+
summary: "Write AGENTS.md, CLAUDE.md and the agent permissions for this tree",
|
|
257
|
+
group: "setup",
|
|
258
|
+
tree: true,
|
|
259
|
+
run(ctx) {
|
|
260
|
+
const tree = ctx.tree();
|
|
261
|
+
const files = syncAgentFiles(tree);
|
|
262
|
+
if (files.length)
|
|
263
|
+
tree.withTreeLock(() => tree.commit(`Agent instructions: ${files.join(", ")}`, files));
|
|
264
|
+
return { text: files.length ? `updated ${files.join(", ")}` : "agent files are up to date", data: { files } };
|
|
265
|
+
},
|
|
266
|
+
}, {
|
|
267
|
+
path: ["agents", "list"],
|
|
268
|
+
summary: "Agents: which one does the research, which are installed, how they delegate",
|
|
269
|
+
group: "setup",
|
|
270
|
+
run(ctx) {
|
|
271
|
+
const tree = ctx.hasTree() ? ctx.tree().config : undefined;
|
|
272
|
+
const active = ctx.settings.agent(tree);
|
|
273
|
+
const found = AGENTS.map((a) => ({
|
|
274
|
+
...a,
|
|
275
|
+
path: which(a.command, ctx.env),
|
|
276
|
+
runner: a.id in RUNNERS,
|
|
277
|
+
active: a.id === active.value,
|
|
278
|
+
delegation: PROFILES[a.id]?.delegation,
|
|
279
|
+
models: ctx.settings.models(a.id, tree),
|
|
280
|
+
}));
|
|
281
|
+
const tiers = (m) => Object.entries(m).filter(([, v]) => v).map(([k, v]) => `${k}=${v}`).join(" ");
|
|
282
|
+
return {
|
|
283
|
+
text: lines(table(found.map((a) => [a.active ? "▸" : " ", a.id, a.name, a.path ? "installed" : "not installed", `delegates ${a.delegation === "native" ? "to its own subagents" : "via strom"}`, tiers(a.models), a.runner ? "" : "(no runner yet)"])), "", `active: ${active.value} (${active.source}) · change: strom agents use <agent> [--for-tree] · one run: --agent <agent> · models: strom config set model.<tier> <model>`),
|
|
284
|
+
data: { agents: found, active },
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
}, {
|
|
288
|
+
path: ["agents", "use"],
|
|
289
|
+
summary: "Choose the agent: for you (default) or only for this tree (--for-tree)",
|
|
290
|
+
group: "setup",
|
|
291
|
+
args: [{ name: "agent", description: Object.keys(PROFILES).join(", "), required: true }],
|
|
292
|
+
options: [{ name: "for-tree", type: "boolean", description: "only for the current tree (saved in strom.json)" }],
|
|
293
|
+
examples: ["strom agents use codex", "strom agents use claude --for-tree"],
|
|
294
|
+
run(ctx, { args, opts }) {
|
|
295
|
+
const id = args[0].toLowerCase();
|
|
296
|
+
if (!PROFILES[id])
|
|
297
|
+
throw new UsageError(`unknown agent "${id}"`, { hint: Object.keys(PROFILES).join(", ") });
|
|
298
|
+
if (opts["for-tree"])
|
|
299
|
+
setTreeSetting(ctx, "agent", id);
|
|
300
|
+
else {
|
|
301
|
+
ctx.settings.config.agent = id;
|
|
302
|
+
ctx.settings.save();
|
|
303
|
+
}
|
|
304
|
+
const installed = which(PROFILES[id].command, ctx.env);
|
|
305
|
+
return { text: `${PROFILES[id].name} ${opts["for-tree"] ? "for this tree" : "is your default agent"}${installed ? "" : " — not installed yet"}`, data: { agent: id, tree: Boolean(opts["for-tree"]) } };
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
// ── run ────────────────────────────────────────────────────────────────────
|
|
309
|
+
/** A `strom` on PATH for the agent, pointing at this very installation. */
|
|
310
|
+
export function shimDir(tree) {
|
|
311
|
+
const dir = path.join(tree.root, ".strom", "bin");
|
|
312
|
+
const { command, args } = stromLauncher();
|
|
313
|
+
const run = [command, ...args].map((a) => `"${a}"`).join(" ");
|
|
314
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
315
|
+
fs.writeFileSync(path.join(dir, "strom"), `#!/bin/sh\nexec ${run} "$@"\n`, { mode: 0o755 });
|
|
316
|
+
fs.writeFileSync(path.join(dir, "strom.cmd"), `@echo off\r\n${run} %*\r\n`);
|
|
317
|
+
return dir;
|
|
318
|
+
}
|
|
319
|
+
function parseUntil(v) {
|
|
320
|
+
if (typeof v !== "string")
|
|
321
|
+
return undefined;
|
|
322
|
+
const m = /^(\d{1,2}):(\d{2})$/.exec(v.trim());
|
|
323
|
+
if (!m || Number(m[1]) > 23 || Number(m[2]) > 59)
|
|
324
|
+
throw new UsageError(`--until must be a time of day, HH:MM (e.g. 23:30), not "${v}"`);
|
|
325
|
+
const d = new Date();
|
|
326
|
+
d.setHours(Number(m[1]), Number(m[2]), 0, 0);
|
|
327
|
+
if (d.getTime() < Date.now())
|
|
328
|
+
d.setDate(d.getDate() + 1);
|
|
329
|
+
return d.getTime();
|
|
330
|
+
}
|
|
331
|
+
/** Sessions in a row that worked on a task without writing anything to the research before it is parked. */
|
|
332
|
+
const MAX_IDLE_SESSIONS = 2;
|
|
333
|
+
/** How many research writes a session made (bookkeeping of sessions and tasks does not count). */
|
|
334
|
+
function sessionWrites(tree, sessionId) {
|
|
335
|
+
const file = path.join(tree.dataDir, "ops", `${sessionId}.jsonl`);
|
|
336
|
+
if (!fs.existsSync(file))
|
|
337
|
+
return 0;
|
|
338
|
+
let n = 0;
|
|
339
|
+
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
|
|
340
|
+
const op = /"op":"([^"]+)"/.exec(line)?.[1];
|
|
341
|
+
if (op && !op.startsWith("session.") && !op.startsWith("task."))
|
|
342
|
+
n++;
|
|
343
|
+
}
|
|
344
|
+
return n;
|
|
345
|
+
}
|
|
346
|
+
/** Sessions ended from outside: not the agent's failure to make progress. */
|
|
347
|
+
const BY_USER = "stopped by the user";
|
|
348
|
+
const RUN_GONE = "the run stopped before the session ended (interrupted, or the computer went down)";
|
|
349
|
+
/** The last sessions on a task that ended without a single research write, newest first. */
|
|
350
|
+
function idleSessions(tree, taskId) {
|
|
351
|
+
const sessions = tree
|
|
352
|
+
.list("session")
|
|
353
|
+
.filter((x) => x.task === taskId && x.state !== "open")
|
|
354
|
+
.sort((a, b) => (b.ended ?? b.updated).localeCompare(a.ended ?? a.updated));
|
|
355
|
+
let n = 0;
|
|
356
|
+
for (const x of sessions) {
|
|
357
|
+
if (sessionWrites(tree, x.id) > 0)
|
|
358
|
+
break;
|
|
359
|
+
// Ended from outside (the user, the run gone) is no failure of the agent; the English summaries are of older sessions.
|
|
360
|
+
if (x.endedBy === "user" || x.endedBy === "run" || x.endedBy === "chat" || x.summary === BY_USER || x.summary === RUN_GONE)
|
|
361
|
+
continue;
|
|
362
|
+
n++;
|
|
363
|
+
}
|
|
364
|
+
return n;
|
|
365
|
+
}
|
|
366
|
+
register({
|
|
367
|
+
path: ["run"],
|
|
368
|
+
summary: "Run research sessions with an AI agent: pick task → brief → agent → close → export → commit",
|
|
369
|
+
group: "research",
|
|
370
|
+
tree: true,
|
|
371
|
+
passthrough: true,
|
|
372
|
+
description: "One session by default; with --until, session after session until that time. The agent works headless with\n" +
|
|
373
|
+
"the tree's permissions (anything else is denied), or with the terminal (--interactive). Stops at the\n" +
|
|
374
|
+
"subscription limit, when the queue is empty, or at --until (a session already running finishes, within\n" +
|
|
375
|
+
"--minutes). A session longer than --minutes is stopped (its task goes back to the queue); a task that comes\n" +
|
|
376
|
+
"back twice in a row with nothing recorded is parked. Arguments after -- go to the agent CLI unchanged.",
|
|
377
|
+
options: [
|
|
378
|
+
{ name: "task", type: "string", value: "<T…>", description: "work on this task (default: the next one)" },
|
|
379
|
+
{ name: "research", type: "string", value: "<G…>", description: "only tasks of this research" },
|
|
380
|
+
{ name: "max", type: "string", value: "<n>", description: "at most n sessions (default 1; with --until, no limit)" },
|
|
381
|
+
{ name: "until", type: "string", value: "<HH:MM>", description: "start no session after this time" },
|
|
382
|
+
{ name: "minutes", type: "string", value: "<n>", description: `time limit of one session (default: run.minutes, ${DEFAULT_RUN_MINUTES})` },
|
|
383
|
+
{ name: "budget", type: "string", value: "<tokens>", description: `brief size (default: brief.budget, ${DEFAULT_BUDGET})` },
|
|
384
|
+
{ name: "model", type: "string", value: "<model>", description: "model of the main agent (default: model.lead)" },
|
|
385
|
+
{ name: "interactive", type: "boolean", description: "give the agent the terminal (you can talk to it)" },
|
|
386
|
+
],
|
|
387
|
+
examples: ["strom run", "strom run --max 3 --until 23:00", "strom run --interactive", "strom run --agent codex", "strom run --minutes 30 -- --add-dir ~/Scans"],
|
|
388
|
+
run: async (ctx, { opts, extra }) => {
|
|
389
|
+
const root = ctx.tree().root;
|
|
390
|
+
const treeCfg = ctx.tree().config;
|
|
391
|
+
const runnerId = ctx.settings.agent(treeCfg).value;
|
|
392
|
+
const runner = RUNNERS[runnerId];
|
|
393
|
+
if (!runner)
|
|
394
|
+
throw new UsageError(`no runner for agent "${runnerId}" yet`, { hint: `available: ${Object.keys(RUNNERS).filter((r) => r !== "script").join(", ")} — strom agents use claude` });
|
|
395
|
+
const models = ctx.settings.models(runnerId, treeCfg);
|
|
396
|
+
if (runnerId !== "script" && !which(runner.command, ctx.env))
|
|
397
|
+
throw new StromError(`${runner.command} is not installed`, { hint: runnerId === "claude" ? "install Claude Code: https://claude.com/claude-code — then log in once by running: claude" : `install ${runner.command}` });
|
|
398
|
+
const until = parseUntil(opts.until);
|
|
399
|
+
const max = opts.max === undefined ? (until ? Infinity : 1) : Number(opts.max);
|
|
400
|
+
if (max !== Infinity && (!Number.isInteger(max) || max < 1))
|
|
401
|
+
throw new UsageError("--max must be a positive number");
|
|
402
|
+
const minutes = ctx.settings.number("run.minutes", treeCfg, DEFAULT_RUN_MINUTES);
|
|
403
|
+
const budget = ctx.settings.number("brief.budget", treeCfg, DEFAULT_BUDGET);
|
|
404
|
+
const release = acquireLock(path.join(root, ".strom", "run.lock"), { owner: "strom run", waitMs: 0, staleMs: 24 * 3600_000 });
|
|
405
|
+
// Present in the tree for the others at work (conversations with agents): strom shows who works.
|
|
406
|
+
const leave = enterWorker(root, "run", `${PROFILES[runnerId]?.name ?? runnerId} on its own`);
|
|
407
|
+
const stopAwake = keepAwake(ctx.env);
|
|
408
|
+
// Progress goes to stderr when stdout carries JSON.
|
|
409
|
+
const out = (s) => (ctx.json ? ctx.io.stderr : ctx.io.stdout)(s + "\n");
|
|
410
|
+
const report = [];
|
|
411
|
+
// Why the run stopped: a code for the exit status and for data, words for the user (their language).
|
|
412
|
+
const lang = Tree.open(root, ctx.env).lang;
|
|
413
|
+
let stopCode = "done";
|
|
414
|
+
let stopValues = {};
|
|
415
|
+
// Ctrl-C, a closed terminal, a shutdown: stop the agent, close its session, give the task back.
|
|
416
|
+
const stop = new AbortController();
|
|
417
|
+
const onSignal = (sig) => {
|
|
418
|
+
if (stop.signal.aborted)
|
|
419
|
+
process.exit(130); // a second Ctrl-C: now
|
|
420
|
+
out(`\n${ui(lang, "ui.run.stopping")} (${sig})`);
|
|
421
|
+
stop.abort();
|
|
422
|
+
};
|
|
423
|
+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
424
|
+
for (const sig of signals)
|
|
425
|
+
process.on(sig, onSignal);
|
|
426
|
+
try {
|
|
427
|
+
{
|
|
428
|
+
const tree = Tree.open(root, ctx.env);
|
|
429
|
+
assertIntact(tree);
|
|
430
|
+
const files = syncAgentFiles(tree);
|
|
431
|
+
if (files.length)
|
|
432
|
+
tree.withTreeLock(() => tree.commit(`Agent instructions: ${files.join(", ")}`, files));
|
|
433
|
+
// A session of a run that is no longer running (we hold the run lock): the run was
|
|
434
|
+
// killed or the computer went down. Close it; its task goes back to the queue.
|
|
435
|
+
for (const s of openSessions(tree).filter((x) => x.runner)) {
|
|
436
|
+
closeSession(tree, s, { summary: phrase(tree.lang, "session.run"), next: "", interrupted: true, endedBy: "run" });
|
|
437
|
+
commitNow(tree, `${s.id} interrupted: its run had stopped`);
|
|
438
|
+
out(ui(lang, "ui.run.gone", { session: s.id, back: s.task ? ui(lang, "ui.run.back", { task: s.task }) : "" }));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const bin = shimDir(Tree.open(root, ctx.env));
|
|
442
|
+
// How the agent is let work, said once: browser tools for whose sites, and a loosening the user chose.
|
|
443
|
+
const browser = runnerId === "claude" ? browserConnectors(ctx.env, ctx.settings.shared()?.value) : [];
|
|
444
|
+
const permissions = ctx.settings.agentPermissions();
|
|
445
|
+
for (const c of browser) {
|
|
446
|
+
const chosen = routeOf(ctx.env, c).chosen;
|
|
447
|
+
out(`· ${c.name}: images through your browser — the agent gets browser tools for ${c.manifest.hosts.join(", ")}${chosen ? ` (chosen ${chosen.at.slice(0, 10)} ${chosen.by})` : ""}; Chrome with the Claude extension must be running`);
|
|
448
|
+
}
|
|
449
|
+
if (browser.length && /haiku/i.test(models.lead ?? ""))
|
|
450
|
+
out(` note: browser tools need a model that can review its actions — Sonnet or Opus, not ${models.lead}: strom config set model.lead sonnet`);
|
|
451
|
+
if (permissions === "full")
|
|
452
|
+
out("· permissions: full — the agent does everything but what the tree's permissions deny (back: strom config set agent.permissions auto)");
|
|
453
|
+
for (let i = 0; i < max; i++) {
|
|
454
|
+
if (stop.signal.aborted) {
|
|
455
|
+
stopCode = "user";
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
if (until && Date.now() > until) {
|
|
459
|
+
stopCode = "time";
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
const tree = Tree.open(root, ctx.env); // fresh: the agent wrote from other processes
|
|
463
|
+
const research = researchOf(tree, opts.research);
|
|
464
|
+
// Other agents may be at work in this tree (conversations): never their tasks.
|
|
465
|
+
const others = othersAtWork(tree, tree.env, runnerId).tasks;
|
|
466
|
+
const free = () => taskQueue(tree, { ...(research ? { research: research.id } : {}), strategy: ctx.settings.strategy(tree.config) }).find((t) => !others.has(t.id));
|
|
467
|
+
let task = typeof opts.task === "string" && i === 0 ? requireRecord(tree, opts.task, "task") : free();
|
|
468
|
+
if (!task && research) {
|
|
469
|
+
applyFrontier(tree, research);
|
|
470
|
+
task = free();
|
|
471
|
+
}
|
|
472
|
+
if (!task) {
|
|
473
|
+
stopCode = "empty";
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
const session = startSession(tree, { task, ...(research ? { research: research.id } : {}), runner: runnerId });
|
|
477
|
+
commitNow(tree, `${session.id} session started on ${task.id}`);
|
|
478
|
+
const brief = buildBrief(tree, { task, session, budget, shared: ctx.settings.shared()?.value });
|
|
479
|
+
const briefFile = path.join(root, ".strom", "briefs", `${session.id}.md`);
|
|
480
|
+
fs.mkdirSync(path.dirname(briefFile), { recursive: true });
|
|
481
|
+
fs.writeFileSync(briefFile, brief.text);
|
|
482
|
+
out(`${ui(lang, "ui.run.start.session", { session: session.id, task: task.id, what: truncate(task.what, 70) })} · brief ~${brief.total} tokens`);
|
|
483
|
+
const prompt = `You are the researcher in strom session ${session.id}. Below is the brief for your task. ` +
|
|
484
|
+
"Work only through `strom` commands in this folder; record findings as you go; finish with `strom session close` as the brief says.\n\n" +
|
|
485
|
+
brief.text;
|
|
486
|
+
const kickoff = `You are the researcher in strom session ${session.id}. Run \`strom brief\` and follow it; work only through strom; finish with \`strom session close\`.`;
|
|
487
|
+
const env = {
|
|
488
|
+
...prependPath(ctx.env, bin),
|
|
489
|
+
STROM_SESSION: session.id,
|
|
490
|
+
...(opts.interactive ? {} : { STROM_NONINTERACTIVE: "1" }),
|
|
491
|
+
};
|
|
492
|
+
const result = await runner.run({
|
|
493
|
+
cwd: root,
|
|
494
|
+
prompt,
|
|
495
|
+
kickoff,
|
|
496
|
+
env,
|
|
497
|
+
// Found again in the agent's list of sessions: the family, the line, the task.
|
|
498
|
+
name: ["Strom", tree.config.name, tree.get(task.research ?? research?.id ?? "")?.name, `${truncate(task.what, 50)} (${task.id})`].filter(Boolean).join(" · "),
|
|
499
|
+
logFile: path.join(root, ".strom", "runs", `${session.id}.log`),
|
|
500
|
+
timeoutMs: minutes * 60_000,
|
|
501
|
+
settingsFile: path.join(root, ".claude", "settings.json"),
|
|
502
|
+
shared: ctx.settings.shared()?.value,
|
|
503
|
+
...(models.lead ? { model: models.lead } : {}),
|
|
504
|
+
...(opts.interactive ? { interactive: true } : {}),
|
|
505
|
+
...(runnerId === "claude" ? { chrome: browser.length > 0, permissions } : {}),
|
|
506
|
+
...(extra?.length ? { extraArgs: extra } : {}),
|
|
507
|
+
onProgress: (l) => out(` · ${l}`),
|
|
508
|
+
signal: stop.signal,
|
|
509
|
+
});
|
|
510
|
+
// Close what the agent left open, record the metrics, export, commit.
|
|
511
|
+
const after = Tree.open(root, ctx.env);
|
|
512
|
+
let s = after.get(session.id);
|
|
513
|
+
if (s.state === "open")
|
|
514
|
+
s = closeSession(after, s, {
|
|
515
|
+
summary: result.outcome === "stopped" ? phrase(after.lang, "session.user") : phrase(after.lang, "session.agent", { outcome: result.outcome }),
|
|
516
|
+
next: "",
|
|
517
|
+
interrupted: true,
|
|
518
|
+
metrics: result.metrics,
|
|
519
|
+
endedBy: result.outcome === "stopped" ? "user" : "agent",
|
|
520
|
+
});
|
|
521
|
+
else
|
|
522
|
+
update(after, s.id, "session", (x) => ({ ...x, metrics: result.metrics }), { op: "session.metrics", summary: `${s.id} metrics` });
|
|
523
|
+
// A task that comes back again and again without anything being recorded
|
|
524
|
+
// is stuck: park it (the user decides), work on the next one.
|
|
525
|
+
const back = after.get(task.id);
|
|
526
|
+
const idle = back && back.state === "open" ? idleSessions(after, task.id) : 0;
|
|
527
|
+
if (idle >= MAX_IDLE_SESSIONS) {
|
|
528
|
+
update(after, task.id, "task", (t) => ({ ...t, state: "parked" }), {
|
|
529
|
+
op: "task.park",
|
|
530
|
+
summary: `${task.id} park: ${idle} sessions without a result`,
|
|
531
|
+
reason: `${idle} sessions in a row recorded nothing for it — needs a look (strom task show ${task.id})`,
|
|
532
|
+
});
|
|
533
|
+
out(ui(lang, "ui.run.parked", { task: task.id, n: idle }));
|
|
534
|
+
}
|
|
535
|
+
if (research)
|
|
536
|
+
applyFrontier(after, research);
|
|
537
|
+
const geds = writeGedcoms(ctx, after);
|
|
538
|
+
const committed = commitNow(after, `${s.id} ${s.state}: ${truncate(s.summary ?? "", 60)} · ${geds.map((g) => after.relative(g.file)).join(", ")}`);
|
|
539
|
+
report.push({ session: s.id, task: task.id, outcome: result.outcome, ...(s.summary ? { summary: s.summary } : {}), ...(result.metrics.costUsd !== undefined ? { costUsd: result.metrics.costUsd } : {}) });
|
|
540
|
+
out(`■ ${s.id} ${s.state} · ${result.outcome}${result.metrics.costUsd !== undefined ? ` · $${result.metrics.costUsd.toFixed(2)}` : ""}${s.summary ? ` · ${truncate(s.summary, 80)}` : ""}`);
|
|
541
|
+
if (result.denied?.length)
|
|
542
|
+
out(ui(lang, "ui.run.denied", { n: result.denied.length, what: result.denied.slice(0, 3).join(" · ") }));
|
|
543
|
+
if (!committed) {
|
|
544
|
+
stopCode = "problems";
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
if (stop.signal.aborted) {
|
|
548
|
+
stopCode = "user";
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
// An agent that may not run strom can do nothing: stop, do not burn session after session.
|
|
552
|
+
if (result.denied?.some((d) => /^Bash: (\S*\/)?strom\b/.test(d))) {
|
|
553
|
+
stopCode = "denied";
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
if (result.outcome === "limit") {
|
|
557
|
+
stopCode = "limit";
|
|
558
|
+
stopValues = { resets: result.resumeAt ? ui(lang, "ui.run.stop.resets", { at: result.resumeAt }) : "" };
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
if (result.outcome === "auth") {
|
|
562
|
+
stopCode = "auth";
|
|
563
|
+
stopValues = { command: runner.command };
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
if (result.outcome === "error") {
|
|
567
|
+
stopCode = "failed";
|
|
568
|
+
stopValues = { detail: truncate(result.text, 120) };
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
finally {
|
|
574
|
+
for (const sig of signals)
|
|
575
|
+
process.off(sig, onSignal);
|
|
576
|
+
stopAwake();
|
|
577
|
+
leave();
|
|
578
|
+
release();
|
|
579
|
+
}
|
|
580
|
+
// What the research now waits for from the user who started it.
|
|
581
|
+
const waiting = waitingLines(Tree.open(root, ctx.env), { shared: ctx.settings.shared()?.value, display: (p) => ctx.display(p) });
|
|
582
|
+
const reason = ui(lang, `ui.run.stop.${stopCode}`, stopValues);
|
|
583
|
+
return {
|
|
584
|
+
text: lines(ui(lang, "ui.run.summary", { n: report.length, reason }), waiting ? `\n${waiting}` : undefined),
|
|
585
|
+
data: { sessions: report, stopped: reason, stop: stopCode },
|
|
586
|
+
exitCode: ["failed", "problems", "auth", "denied"].includes(stopCode) ? 1 : 0,
|
|
587
|
+
};
|
|
588
|
+
},
|
|
589
|
+
});
|
|
590
|
+
/** How the user clears the agent's context, in the agent's own words. */
|
|
591
|
+
const CLEAR = { claude: "/clear", codex: "/new", opencode: "/new" };
|
|
592
|
+
/** The hint after a session in a conversation: the next task in a fresh context (images stay in a context and are paid on every turn). */
|
|
593
|
+
function freshContext(ctx, tree, s) {
|
|
594
|
+
const since = Date.parse(s.started);
|
|
595
|
+
let images = 0;
|
|
596
|
+
try {
|
|
597
|
+
const dir = path.join(tree.root, ".strom", "views");
|
|
598
|
+
images = fs.readdirSync(dir).filter((f) => fs.statSync(path.join(dir, f)).mtimeMs >= since).length;
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
// no views
|
|
602
|
+
}
|
|
603
|
+
const how = CLEAR[detectAgent(ctx.env) ?? ""];
|
|
604
|
+
return lines("", `next task: best in a fresh context${images ? ` — this session opened ${images} image(s), and they stay in yours` : ""}. Nothing is lost: strom keeps it all.`, ` Suggest it to the user in a sentence: ${how ? `typing ${how}` : "a new conversation"}, then \`strom\` goes on. A long queue: suggest letting the agent`, " work on its own (strom's menu) — every task in a fresh session. If they would rather go on here, go on.");
|
|
605
|
+
}
|