strom-research 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -5
- package/assets/icon/NOTICE.md +26 -0
- package/assets/icon/favicon.svg +1 -0
- package/assets/icon/icon-256.png +0 -0
- package/assets/icon/icon-512.png +0 -0
- package/assets/icon/icon.ico +0 -0
- package/assets/icon/icon.svg +1 -0
- package/assets/lang/cs.json +184 -8
- package/assets/lang/de.json +184 -8
- package/assets/method/reading.md +4 -2
- package/assets/method/recording.md +10 -1
- package/assets/plugins/connectors/DISCOVERY.md +6 -2
- package/assets/plugins/connectors/README.md +27 -10
- package/dist/agents/files.js +27 -18
- package/dist/agents/global.js +15 -4
- package/dist/brief/brief.js +4 -0
- package/dist/cli/context.js +10 -4
- package/dist/cli/human.js +116 -0
- package/dist/cli/main.js +9 -0
- package/dist/cli/menu.js +254 -38
- package/dist/cli/ui.js +156 -7
- package/dist/cli/wizard.js +36 -9
- package/dist/commands/browse.js +243 -0
- package/dist/commands/clips.js +190 -0
- package/dist/commands/connectors.js +93 -28
- package/dist/commands/guide.js +28 -7
- package/dist/commands/index.js +3 -0
- package/dist/commands/media.js +42 -8
- package/dist/commands/meta.js +33 -9
- package/dist/commands/output.js +103 -13
- package/dist/commands/read.js +1 -1
- package/dist/commands/readers.js +62 -0
- package/dist/commands/research.js +62 -0
- package/dist/commands/session.js +76 -47
- package/dist/commands/setup.js +17 -10
- package/dist/commands/sources.js +88 -16
- package/dist/commands/start.js +57 -37
- package/dist/commands/tasks.js +43 -6
- package/dist/commands/transcripts.js +177 -0
- package/dist/core/chromium.js +60 -19
- package/dist/core/clipfinder.js +118 -0
- package/dist/core/config.js +42 -2
- package/dist/core/connector.js +56 -8
- package/dist/core/excerpt.js +219 -0
- package/dist/core/frontier.js +30 -5
- package/dist/core/git.js +1 -1
- package/dist/core/kin.js +90 -0
- package/dist/core/live.js +22 -13
- package/dist/core/media.js +37 -3
- package/dist/core/migrate.js +33 -0
- package/dist/core/model.js +7 -0
- package/dist/core/net.js +93 -19
- package/dist/core/overview.js +259 -0
- package/dist/core/phrases.js +27 -0
- package/dist/core/places.js +43 -0
- package/dist/core/queue.js +16 -3
- package/dist/core/review.js +369 -0
- package/dist/core/schema.js +4 -0
- package/dist/core/session.js +5 -1
- package/dist/core/shortcut.js +71 -11
- package/dist/core/stories.js +3 -4
- package/dist/core/stromapp.js +86 -25
- package/dist/core/transcriber.js +99 -0
- package/dist/core/tree.js +3 -1
- package/dist/core/uninstall.js +4 -2
- package/dist/core/update.js +48 -22
- package/dist/core/validate.js +3 -1
- package/dist/core/workers.js +13 -0
- package/dist/gedcom/export.js +58 -30
- package/dist/gedcom/lines.js +8 -0
- package/dist/gedcom/validate.js +2 -2
- package/dist/runners/claude.js +2 -0
- package/package.json +1 -1
package/dist/commands/output.js
CHANGED
|
@@ -10,6 +10,9 @@ import { verifyFast } from "../core/integrity.js";
|
|
|
10
10
|
import { ancestorGenerations, familiesAsChild, familiesAsPartner } from "../core/people.js";
|
|
11
11
|
import { resolveResearch } from "./research.js";
|
|
12
12
|
import { UsageError } from "../core/errors.js";
|
|
13
|
+
import { excerptSettings, planExcerpts } from "../core/excerpt.js";
|
|
14
|
+
import { runGit, tracked } from "../core/git.js";
|
|
15
|
+
import { EXCERPT_QUALITIES, EXCERPT_SCOPES } from "../core/model.js";
|
|
13
16
|
function findingRows(f) {
|
|
14
17
|
return table(f.slice(0, 30).map((x) => [x.level === "error" ? "ERROR" : "warn", `line ${x.line}`, x.message]));
|
|
15
18
|
}
|
|
@@ -30,35 +33,102 @@ function researchScope(tree, r) {
|
|
|
30
33
|
export function gedFile(tree, profile, research) {
|
|
31
34
|
return path.join(tree.root, "output", `${research ? research.id : "tree"}${profile === "strom" ? "-strom" : ""}.ged`);
|
|
32
35
|
}
|
|
33
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* The Strom file stays out of the tree's history: it carries the entries cut out of their scans (megabytes,
|
|
38
|
+
* made again at will from the evidence); the history keeps the standard file (output/tree.ged). A tree that
|
|
39
|
+
* committed it before lets go of it once.
|
|
40
|
+
*/
|
|
41
|
+
const IGNORE_STROM = ["/output/*-strom.ged", "/output/*-images.ged"];
|
|
42
|
+
function ignoreStromGed(tree, file) {
|
|
43
|
+
const ignore = path.join(tree.root, ".gitignore");
|
|
44
|
+
const had = fs.existsSync(ignore) ? fs.readFileSync(ignore, "utf8") : "";
|
|
45
|
+
const have = had.split(/\r?\n/);
|
|
46
|
+
const add = IGNORE_STROM.filter((l) => !have.includes(l));
|
|
47
|
+
const rel = tree.relative(file);
|
|
48
|
+
const inHistory = tracked(tree.root, rel);
|
|
49
|
+
if (!add.length && !inHistory)
|
|
50
|
+
return;
|
|
51
|
+
tree.withTreeLock(() => {
|
|
52
|
+
if (add.length)
|
|
53
|
+
fs.writeFileSync(ignore, `${had}${had && !had.endsWith("\n") ? "\n" : ""}${add.join("\n")}\n`);
|
|
54
|
+
if (inHistory)
|
|
55
|
+
runGit(tree.root, ["rm", "--cached", "--quiet", "--", rel]);
|
|
56
|
+
tree.commit("The file for the Strom app (with the images of the entries) stays out of the history", [".gitignore"]);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Write one GEDCOM file and validate it by the rules of its reader. The Strom file carries the entries cut out of
|
|
61
|
+
* their scans by the settings (excerpts.*; `images` overrides them, false: without); budgetMs: new excerpts are
|
|
62
|
+
* made for at most so long (the rest the next export makes).
|
|
63
|
+
*/
|
|
34
64
|
export function writeTreeGed(tree, opts) {
|
|
35
65
|
const persons = opts.research ? researchScope(tree, opts.research) : undefined;
|
|
36
|
-
const
|
|
66
|
+
const set = opts.for !== "strom" || opts.images === false ? undefined : (opts.images ?? excerptSettings(tree));
|
|
67
|
+
const images = set && set.for !== "none"
|
|
68
|
+
? planExcerpts(tree, set.shared, { quality: set.quality, for: set.for, maxBytes: set.mb * 1024 * 1024, persons, budgetMs: opts.budgetMs })
|
|
69
|
+
: undefined;
|
|
70
|
+
const result = exportGedcom(tree, { for: opts.for, stromVersion: opts.stromVersion, ...(persons ? { persons } : {}), ...(images ? { excerpts: images.of } : {}) });
|
|
37
71
|
const findings = validateGedcom(result.text, opts.for === "standard" ? { strict: true } : {});
|
|
38
72
|
const file = opts.out ?? gedFile(tree, opts.for, opts.research);
|
|
39
|
-
if (!hasGedErrors(findings) && !tree.dryRun)
|
|
73
|
+
if (!hasGedErrors(findings) && !tree.dryRun) {
|
|
74
|
+
if (opts.for === "strom" && file.startsWith(path.join(tree.root, "output") + path.sep))
|
|
75
|
+
ignoreStromGed(tree, file);
|
|
40
76
|
writeFileAtomic(file, result.text);
|
|
41
|
-
|
|
77
|
+
}
|
|
78
|
+
return { ...result, findings, file, for: opts.for, ...(images && set ? { images: images.report, imageSettings: set } : {}) };
|
|
79
|
+
}
|
|
80
|
+
const mb = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
81
|
+
/** What an export with images took in and left out, and what to do about it — what strom decided itself is said as a warning. */
|
|
82
|
+
export function imagesLines(r, images) {
|
|
83
|
+
const few = (ids) => `${ids.slice(0, 8).join(", ")}${ids.length > 8 ? ` … (${ids.length})` : ""}`;
|
|
84
|
+
return lines(` ${r.excerpts} excerpt(s) of ${r.sources} source(s), ${mb(r.bytes)} · quality ${r.level}`, r.level !== r.asked ? ` WARNING made smaller to fit the limit of ${images.mb} MB: ${r.level} instead of ${r.asked} — more room: --images-max-mb <n> (strom config set excerpts.mb <n>)` : undefined, r.dropped.length
|
|
85
|
+
? ` WARNING left out to fit the limit of ${images.mb} MB, the farthest from the research first: ${r.dropped.length} source(s) (${few(r.dropped)}) — more room: --images-max-mb <n>`
|
|
86
|
+
: undefined, r.outOfScope ? ` ${r.outOfScope} source(s) of people farther from the research than excerpts.for=${images.for} — all of them: --images-for all` : undefined, r.missing.length ? ` the image is not on this computer: ${few(r.missing)}` : undefined, r.unclipped.length ? ` sources on images with no clip (where the entry is): ${few(r.unclipped)} — strom clips (readers find them)` : undefined, r.later.length ? ` the images of ${r.later.length} source(s) are still being made: the next export has them (all now: strom export gedcom)` : undefined);
|
|
42
87
|
}
|
|
88
|
+
/** The images of an export: the settings, each overridden by its option. */
|
|
89
|
+
export function imageSettings(ctx, tree, opts) {
|
|
90
|
+
const shared = ctx.settings.shared()?.value;
|
|
91
|
+
if (!shared)
|
|
92
|
+
throw new UsageError("no shared folder with the images is set", { hint: "strom setup" });
|
|
93
|
+
const set = ctx.settings.excerpts(tree.config);
|
|
94
|
+
const quality = opts["images-quality"] === undefined ? set.quality : String(opts["images-quality"]);
|
|
95
|
+
if (!EXCERPT_QUALITIES.includes(quality))
|
|
96
|
+
throw new UsageError(`invalid --images-quality "${quality}"`, { hint: EXCERPT_QUALITIES.join(", ") });
|
|
97
|
+
const scope = opts["images-for"] === undefined ? set.for : String(opts["images-for"]);
|
|
98
|
+
if (!EXCERPT_SCOPES.includes(scope))
|
|
99
|
+
throw new UsageError(`invalid --images-for "${scope}"`, { hint: EXCERPT_SCOPES.join(", ") });
|
|
100
|
+
const mbv = opts["images-max-mb"] === undefined ? set.mb : Number(opts["images-max-mb"]);
|
|
101
|
+
if (!(mbv > 0))
|
|
102
|
+
throw new UsageError("--images-max-mb must be a positive number");
|
|
103
|
+
return { shared, quality: quality, for: scope, mb: mbv };
|
|
104
|
+
}
|
|
105
|
+
/** New excerpts after a session are made for at most so long: what is left, the next export makes. */
|
|
106
|
+
export const AFTER_SESSION_MS = 20_000;
|
|
43
107
|
/** The GEDCOM files the settings ask for (both by default), from the same evidence. Used after sessions. */
|
|
44
108
|
export function writeGedcoms(ctx, tree, research) {
|
|
45
|
-
return ctx.settings.gedcomFor(tree.config).map((p) => writeTreeGed(tree, { for: p, stromVersion: ctx.settings.stromVersion(tree.config), ...(research ? { research } : {}) }));
|
|
109
|
+
return ctx.settings.gedcomFor(tree.config).map((p) => writeTreeGed(tree, { for: p, stromVersion: ctx.settings.stromVersion(tree.config), ...(research ? { research } : {}), budgetMs: AFTER_SESSION_MS }));
|
|
46
110
|
}
|
|
47
111
|
register({
|
|
48
112
|
path: ["export", "gedcom"],
|
|
49
|
-
summary: "Generate the GEDCOM files, validated: output/tree.ged for any program, output/tree-strom.ged for the Strom app",
|
|
113
|
+
summary: "Generate the GEDCOM files, validated: output/tree.ged for any program, output/tree-strom.ged for the Strom app (with the entries' images)",
|
|
50
114
|
group: "output",
|
|
51
115
|
tree: true,
|
|
52
116
|
description: "Both come from the same evidence (GEDCOM 5.5.1, UTF-8). The standard one uses no extension tags; the Strom\n" +
|
|
53
117
|
"one is shaped to what the Strom app reads (_WITN, _STORY, _FREL/_MREL, a source per page). QUAY is the\n" +
|
|
54
118
|
"quality of each citation's evidence; leads say so in a note; retracted and disproven facts are left out;\n" +
|
|
55
|
-
"of several facts of a kind the best supported comes first. Which files: --for or the setting gedcom.for
|
|
119
|
+
"of several facts of a kind the best supported comes first. Which files: --for or the setting gedcom.for.\n" +
|
|
120
|
+
"The Strom file carries each entry cut out of its scan (the sources' clips; the settings excerpts.*, excerpts.for\n" +
|
|
121
|
+
"none: without) and stays out of the tree's history — the history keeps the standard file.",
|
|
56
122
|
options: [
|
|
57
123
|
{ name: "for", type: "string", value: "<reader>", description: "standard (any program), strom (the Strom app) or both (default: the setting gedcom.for, both)" },
|
|
58
124
|
{ name: "research", type: "string", value: "<G…>", description: "only the people of one research (focus, ancestors, partners)" },
|
|
59
125
|
{ name: "out", type: "string", value: "<file>", description: "write here instead of output/ (with --for standard or strom)" },
|
|
126
|
+
{ name: "images", type: "boolean", description: "the Strom file with the entries' images (the default; kept for older scripts)" },
|
|
127
|
+
{ name: "images-quality", type: "string", value: "<q>", description: "small (1000 px, grey), normal, sharp (1600 px) — default: the setting excerpts.quality (normal)" },
|
|
128
|
+
{ name: "images-for", type: "string", value: "<whose>", description: "none, line, family, connected or all — default: the setting excerpts.for (family: the ancestors and their families)" },
|
|
129
|
+
{ name: "images-max-mb", type: "string", value: "<n>", description: "limit of all excerpts together — over it smaller, then the farthest left out (default: the setting excerpts.mb, 200)" },
|
|
60
130
|
],
|
|
61
|
-
examples: ["strom export gedcom", "strom export gedcom --research G0001", "strom export gedcom --for standard --out ~/Desktop/novak.ged"],
|
|
131
|
+
examples: ["strom export gedcom", "strom export gedcom --research G0001", "strom export gedcom --for standard --out ~/Desktop/novak.ged", "strom export gedcom --for strom --images-for all"],
|
|
62
132
|
run(ctx, { opts }) {
|
|
63
133
|
const tree = ctx.tree();
|
|
64
134
|
const research = typeof opts.research === "string" ? resolveResearch(tree, opts.research) : undefined;
|
|
@@ -66,10 +136,20 @@ register({
|
|
|
66
136
|
const which = typeof opts.for === "string" ? opts.for.toLowerCase() : undefined;
|
|
67
137
|
if (which && which !== "both" && !GED_PROFILES.includes(which))
|
|
68
138
|
throw new UsageError(`invalid --for "${opts.for}"`, { hint: "standard, strom or both" });
|
|
139
|
+
const asked = ["images-max-mb", "images-quality", "images-for"].some((k) => opts[k] !== undefined);
|
|
140
|
+
if ((asked || opts.images) && which === "standard")
|
|
141
|
+
throw new UsageError("images go into the file for the Strom app only", { hint: "strom export gedcom --for strom" });
|
|
69
142
|
const profiles = which === "both" ? [...GED_PROFILES] : which ? [which] : ctx.settings.gedcomFor(tree.config);
|
|
70
143
|
if (out && profiles.length > 1)
|
|
71
144
|
throw new UsageError("--out writes one file", { hint: "add --for standard or --for strom" });
|
|
72
|
-
const
|
|
145
|
+
const images = asked ? imageSettings(ctx, tree, opts) : undefined;
|
|
146
|
+
const results = profiles.map((p) => writeTreeGed(tree, {
|
|
147
|
+
for: p,
|
|
148
|
+
stromVersion: ctx.settings.stromVersion(tree.config),
|
|
149
|
+
...(research ? { research } : {}),
|
|
150
|
+
...(out ? { out } : {}),
|
|
151
|
+
...(images ? { images } : {}),
|
|
152
|
+
}));
|
|
73
153
|
const bad = results.filter((r) => hasGedErrors(r.findings));
|
|
74
154
|
if (bad.length) {
|
|
75
155
|
return {
|
|
@@ -78,8 +158,8 @@ register({
|
|
|
78
158
|
exitCode: 1,
|
|
79
159
|
};
|
|
80
160
|
}
|
|
81
|
-
// Commit the output when it lives in the tree and the evidence is intact.
|
|
82
|
-
const inTree = results.filter((r) => r.file.startsWith(tree.root + path.sep));
|
|
161
|
+
// Commit the output when it lives in the tree and the evidence is intact (the Strom file, with its images: never).
|
|
162
|
+
const inTree = results.filter((r) => r.for === "standard" && r.file.startsWith(tree.root + path.sep));
|
|
83
163
|
let committed = false;
|
|
84
164
|
if (inTree.length && !tree.dryRun && verifyFast(tree).findings.every((f) => f.level !== "error")) {
|
|
85
165
|
const s0 = results[0].stats;
|
|
@@ -88,10 +168,20 @@ register({
|
|
|
88
168
|
}
|
|
89
169
|
const text = lines(...results.map((r) => {
|
|
90
170
|
const s = r.stats;
|
|
91
|
-
return lines(`${ctx.display(r.file)} (${r.for === "strom" ?
|
|
171
|
+
return lines(`${ctx.display(r.file)} (${r.for === "strom" ? `for the Strom app${r.images ? ", with images" : ""}` : "standard, any program"}) — ${s.persons} persons · ${s.families} families · ${s.events} facts · ${s.sources} sources${s.skipped ? ` · ${s.skipped} retracted/disproven left out` : ""}`, ` valid GEDCOM 5.5.1${r.findings.length ? ` (${r.findings.length} warning(s))` : ""}`, r.images && r.imageSettings ? imagesLines(r.images, r.imageSettings) : undefined, r.findings.length ? findingRows(r.findings) : undefined);
|
|
92
172
|
}), committed ? "committed" : undefined);
|
|
93
173
|
const first = results[0];
|
|
94
|
-
return {
|
|
174
|
+
return {
|
|
175
|
+
text,
|
|
176
|
+
data: {
|
|
177
|
+
ok: true,
|
|
178
|
+
file: first.file,
|
|
179
|
+
stats: first.stats,
|
|
180
|
+
findings: first.findings,
|
|
181
|
+
...(results.find((r) => r.images) ? { images: results.find((r) => r.images).images } : {}),
|
|
182
|
+
files: results.map((r) => ({ for: r.for, file: r.file, stats: r.stats, findings: r.findings })),
|
|
183
|
+
},
|
|
184
|
+
};
|
|
95
185
|
},
|
|
96
186
|
}, {
|
|
97
187
|
path: ["gedcom", "validate"],
|
package/dist/commands/read.js
CHANGED
|
@@ -24,7 +24,7 @@ function range(v) {
|
|
|
24
24
|
throw new UsageError(`invalid --images "${v}"`, { hint: "e.g. 40-69" });
|
|
25
25
|
return [Number(m[1]), Number(m[2] ?? m[1])];
|
|
26
26
|
}
|
|
27
|
-
async function pool(items, size, fn) {
|
|
27
|
+
export async function pool(items, size, fn) {
|
|
28
28
|
const results = new Array(items.length);
|
|
29
29
|
let next = 0;
|
|
30
30
|
const worker = async () => {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Readers strom starts for a whole research at once (strom clips, strom
|
|
2
|
+
// transcripts): each a short run of the agent in its own folder outside the
|
|
3
|
+
// tree, allowed to open the views it is given and to write its report into
|
|
4
|
+
// notes/readings — never anything else. strom writes to the research from
|
|
5
|
+
// what the reports say.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { StromError, UsageError } from "../core/errors.js";
|
|
9
|
+
import { readerSettings } from "../core/reader.js";
|
|
10
|
+
import { RUNNERS } from "../runners/index.js";
|
|
11
|
+
import { which } from "../core/which.js";
|
|
12
|
+
import { permissionPath } from "../agents/files.js";
|
|
13
|
+
/** Set up the readers of a run (--model, --parallel, --minutes; the agent of the tree). */
|
|
14
|
+
export function readers(ctx, tree, shared, kind, first, opts) {
|
|
15
|
+
const agentId = ctx.settings.agent(tree.config).value;
|
|
16
|
+
const runner = RUNNERS[agentId];
|
|
17
|
+
if (!runner)
|
|
18
|
+
throw new UsageError(`no reader for agent "${agentId}" yet`, { hint: `strom ${kind} works with Claude Code: strom ${kind} … --agent claude` });
|
|
19
|
+
if (agentId !== "script" && !which(runner.command, ctx.env))
|
|
20
|
+
throw new StromError(`${runner.command} is not installed`);
|
|
21
|
+
const model = typeof opts.model === "string" ? opts.model : ctx.settings.models(agentId, tree.config).vision;
|
|
22
|
+
const parallel = opts.parallel === undefined ? 3 : Math.max(1, Number(opts.parallel) || 1);
|
|
23
|
+
const minutes = opts.minutes === undefined ? 15 : Number(opts.minutes);
|
|
24
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
25
|
+
const reportsDir = path.join(tree.root, "notes", "readings");
|
|
26
|
+
fs.mkdirSync(reportsDir, { recursive: true });
|
|
27
|
+
let stem = `${day}-${kind}`;
|
|
28
|
+
for (let n = 2; fs.existsSync(path.join(reportsDir, `${stem}-${first}-1.md`)); n++)
|
|
29
|
+
stem = `${day}-${kind}-run${n}`;
|
|
30
|
+
const work = path.join(shared, "cache", "readers");
|
|
31
|
+
const progress = (s) => (ctx.json ? ctx.io.stderr : ctx.io.stdout)(s + "\n");
|
|
32
|
+
let cost = 0;
|
|
33
|
+
const failed = [];
|
|
34
|
+
const read = async (name, views, title, prompt) => {
|
|
35
|
+
const report = path.join(reportsDir, `${stem}-${name}.md`);
|
|
36
|
+
const cwd = path.join(work, `${stem}-${name}`);
|
|
37
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
38
|
+
const settingsFile = path.join(cwd, "reader-settings.json");
|
|
39
|
+
fs.writeFileSync(settingsFile, JSON.stringify(readerSettings(views, report, permissionPath), null, 2));
|
|
40
|
+
fs.writeFileSync(report, `# ${title}\n\n`);
|
|
41
|
+
const text = prompt(report);
|
|
42
|
+
const { STROM_SESSION: _s, ...env } = ctx.env;
|
|
43
|
+
const r = await runner.run({
|
|
44
|
+
cwd,
|
|
45
|
+
prompt: text,
|
|
46
|
+
kickoff: text,
|
|
47
|
+
env: { ...env, STROM_NONINTERACTIVE: "1", STROM_READER: "1" },
|
|
48
|
+
settingsFile,
|
|
49
|
+
timeoutMs: minutes * 60_000,
|
|
50
|
+
logFile: path.join(tree.root, ".strom", "runs", `${kind}-${stem}-${name}.log`),
|
|
51
|
+
...(model ? { model } : {}),
|
|
52
|
+
});
|
|
53
|
+
cost += r.metrics.costUsd ?? 0;
|
|
54
|
+
if (r.outcome !== "ok")
|
|
55
|
+
failed.push(`${name} (${r.outcome})`);
|
|
56
|
+
const written = fs.readFileSync(report, "utf8");
|
|
57
|
+
if (!/^##\s/m.test(written) && r.text.trim())
|
|
58
|
+
fs.appendFileSync(report, r.text.trim() + "\n");
|
|
59
|
+
return { report, text: fs.readFileSync(report, "utf8"), outcome: r.outcome };
|
|
60
|
+
};
|
|
61
|
+
return { model, parallel, stem, reportsDir, read, cost: () => cost, failed, progress };
|
|
62
|
+
}
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
import { register } from "../cli/registry.js";
|
|
3
3
|
import { lines, table } from "../cli/format.js";
|
|
4
4
|
import { addPerson, addResearch } from "../core/actions.js";
|
|
5
|
+
import { applyFrontier, researchProposals } from "./session.js";
|
|
6
|
+
import { phrase } from "../core/phrases.js";
|
|
5
7
|
import { UsageError } from "../core/errors.js";
|
|
8
|
+
import { REVIEW_SCOPES } from "../core/model.js";
|
|
6
9
|
import { ancestorGenerations, displayName, label, lifespan, parentsOf, resolvePerson } from "../core/people.js";
|
|
7
10
|
import { foldText } from "../core/text.js";
|
|
11
|
+
import { update } from "../core/records.js";
|
|
8
12
|
function int(v, name) {
|
|
9
13
|
if (v === undefined)
|
|
10
14
|
return undefined;
|
|
@@ -137,3 +141,61 @@ register({
|
|
|
137
141
|
};
|
|
138
142
|
},
|
|
139
143
|
});
|
|
144
|
+
register({
|
|
145
|
+
path: ["review"],
|
|
146
|
+
summary: "Review one person: what the tree already says of them elsewhere, entries to read whole, facts to check — as tasks",
|
|
147
|
+
group: "research",
|
|
148
|
+
tree: true,
|
|
149
|
+
writes: true,
|
|
150
|
+
description: "Sends the research straight to one person. strom looks at what is recorded and proposes the work, at\n" +
|
|
151
|
+
"most one task of each kind: what records, notes and the diary say of them outside their data; entries whose\n" +
|
|
152
|
+
"images are here but were not read whole (transcript, godparents, witnesses, house); facts resting on one\n" +
|
|
153
|
+
"reading; conflicts left open; with --reread, a second reading of what only another model read — after the\n" +
|
|
154
|
+
"agent or the model changed. Birth, parents and the story come as in any research. Run again later: what a\n" +
|
|
155
|
+
"task took up is not proposed again. Nothing is deleted; corrections go through strom with their reasons.",
|
|
156
|
+
args: [{ name: "person", description: "whom (ID or name)", required: true }],
|
|
157
|
+
options: [
|
|
158
|
+
{ name: "scope", type: "string", value: "<scope>", description: "person (default), family (with partners and children), line (with the ancestors)" },
|
|
159
|
+
{ name: "reread", type: "boolean", description: "also a second reading of what only another model read (the model you read with now: model.vision)" },
|
|
160
|
+
{ name: "model", type: "string", value: "<model>", description: "with --reread: this model instead of model.vision" },
|
|
161
|
+
],
|
|
162
|
+
examples: ["strom review P0001", 'strom review "Josef Novák" --scope family', "strom review P0001 --reread", "strom review P0001 --dry-run"],
|
|
163
|
+
run(ctx, { args, opts }) {
|
|
164
|
+
const tree = ctx.tree();
|
|
165
|
+
const p = resolvePerson(tree, args[0]);
|
|
166
|
+
const scope = opts.scope ?? "person";
|
|
167
|
+
if (!REVIEW_SCOPES.includes(scope))
|
|
168
|
+
throw new UsageError(`--scope must be one of ${REVIEW_SCOPES.join(", ")}`);
|
|
169
|
+
let reread;
|
|
170
|
+
if (opts.reread) {
|
|
171
|
+
reread = opts.model ?? ctx.settings.models(ctx.settings.agent(tree.config).value, tree.config).vision;
|
|
172
|
+
if (!reread)
|
|
173
|
+
throw new UsageError("--reread needs the model to read with", { hint: "strom config set model.vision <model> — or strom review … --reread --model <model>" });
|
|
174
|
+
}
|
|
175
|
+
else if (opts.model)
|
|
176
|
+
throw new UsageError("--model goes with --reread");
|
|
177
|
+
const review = { scope: scope, ...(reread ? { reread } : {}) };
|
|
178
|
+
// one review research per person: again later, it goes on where it was
|
|
179
|
+
const existing = tree.list("research").find((r) => r.direction === "person" && r.focus === p.id && r.review);
|
|
180
|
+
let research;
|
|
181
|
+
if (existing)
|
|
182
|
+
research = update(tree, existing.id, "research", (r) => ({ ...r, state: "active", review }), {
|
|
183
|
+
op: "research.edit",
|
|
184
|
+
summary: `${existing.id} review again: ${scope}${reread ? `, second reading with ${reread}` : ""}`,
|
|
185
|
+
});
|
|
186
|
+
else {
|
|
187
|
+
const r = addResearch(tree, { name: phrase(tree.lang, "review.research", { name: displayName(p) }), focus: p.id, direction: "person" });
|
|
188
|
+
research = update(tree, r.id, "research", (x) => ({ ...x, review }), { op: "research.edit", summary: `${r.id} review: ${scope}${reread ? `, second reading with ${reread}` : ""}` });
|
|
189
|
+
}
|
|
190
|
+
const planned = tree.dryRun ? researchProposals(tree, research).map((x) => x.proposal) : [];
|
|
191
|
+
const created = tree.dryRun ? [] : applyFrontier(tree, research);
|
|
192
|
+
const tasks = created.map((id) => tree.get(id)).filter((t) => !!t);
|
|
193
|
+
const open = tree.list("task").filter((t) => t.research === research.id && ["open", "doing"].includes(t.state));
|
|
194
|
+
// what a session costs here, from those so far: the user decides with the price in view
|
|
195
|
+
const costs = tree.list("session").map((s) => s.metrics?.costUsd).filter((c) => typeof c === "number");
|
|
196
|
+
const avg = costs.length ? costs.reduce((a, b) => a + b, 0) / costs.length : undefined;
|
|
197
|
+
const list = tree.dryRun ? planned.map((t) => ` would add: ${t.level} · ${t.what}`) : tasks.map((t) => ` ${t.id} ${t.level} · ${t.what}`);
|
|
198
|
+
const text = lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined, "", list.length ? `${label(p)} — ${tree.dryRun ? planned.length : tasks.length} new task(s):` : `${label(p)} — nothing new to review${open.length ? `; ${open.length} task(s) of the review still open` : ""}`, ...list, open.length ? `${open.length} open in ${research.id}, about ${open.length} session(s)${avg !== undefined ? ` — sessions here cost $${avg.toFixed(2)} on average` : ""}` : undefined, open.length ? `next strom run --research ${research.id} (the agent alone) · or in a conversation: strom session start --research ${research.id}` : undefined);
|
|
199
|
+
return { text, data: { research, created: tasks, planned, open: open.map((t) => t.id), ...(avg !== undefined ? { avgCostUsd: avg } : {}) } };
|
|
200
|
+
},
|
|
201
|
+
});
|
package/dist/commands/session.js
CHANGED
|
@@ -16,7 +16,8 @@ import { DEFAULT_BUDGET, DEFAULT_RUN_MINUTES, Settings } from "../core/config.js
|
|
|
16
16
|
import { prependPath } from "../runners/runner.js";
|
|
17
17
|
import { frontier } from "../core/frontier.js";
|
|
18
18
|
import { storyProposals } from "../core/stories.js";
|
|
19
|
-
import {
|
|
19
|
+
import { OFF_MAP_HOW, offMapLine, placesOffMap } from "../core/places.js";
|
|
20
|
+
import { create, csvOpt, requireRecord, update } from "../core/records.js";
|
|
20
21
|
import { taskQueue, waitingLines } from "./tasks.js";
|
|
21
22
|
import { resolveResearch } from "./research.js";
|
|
22
23
|
import { writeGedcoms } from "./output.js";
|
|
@@ -28,13 +29,13 @@ import { AGENTS, detectAgent, which } from "../core/which.js";
|
|
|
28
29
|
import { keepAwake } from "../core/awake.js";
|
|
29
30
|
import { stromLauncher } from "../core/self.js";
|
|
30
31
|
import { phrase } from "../core/phrases.js";
|
|
31
|
-
import { enterWorker } from "../core/workers.js";
|
|
32
|
-
import { acquireLock } from "../core/lock.js";
|
|
32
|
+
import { enterWorker, runAlive, runsAtWork } from "../core/workers.js";
|
|
33
33
|
import { assertIntact, snapshot, verifyFast } from "../core/integrity.js";
|
|
34
34
|
import { guard } from "../core/guard.js";
|
|
35
35
|
import { hasErrors } from "../core/check.js";
|
|
36
36
|
import { Tree } from "../core/tree.js";
|
|
37
|
-
import {
|
|
37
|
+
import { treeBrowserConnectors } from "../core/connector.js";
|
|
38
|
+
import { reviewProposals } from "../core/review.js";
|
|
38
39
|
function written(tree) {
|
|
39
40
|
return lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined);
|
|
40
41
|
}
|
|
@@ -44,21 +45,29 @@ function researchOf(tree, ref) {
|
|
|
44
45
|
const active = tree.list("research").filter((r) => r.state === "active");
|
|
45
46
|
return active.length === 1 ? active[0] : undefined;
|
|
46
47
|
}
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
48
|
+
/** The places of facts not on the map yet, when a session ends: the few with most facts, to complete while they are fresh. */
|
|
49
|
+
function offMap(tree) {
|
|
50
|
+
const off = placesOffMap(tree);
|
|
51
|
+
if (!off.length)
|
|
52
|
+
return undefined;
|
|
53
|
+
return lines(`· for the map — places of facts without coordinates (${off.length}):`, ...off.slice(0, 3).map((m) => ` ${offMapLine(m)}`), ` ${OFF_MAP_HOW} · all: strom place list --off-map`);
|
|
54
|
+
}
|
|
55
|
+
/** The tasks strom would propose for a research now: its frontier, the review of a person, the stories due (unless the user said no). */
|
|
56
|
+
export function researchProposals(tree, research) {
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const item of frontier(tree, research))
|
|
59
|
+
if (item.proposal)
|
|
60
|
+
out.push({ proposal: { ...item.proposal, origin: "frontier" }, kind: "frontier" });
|
|
61
|
+
for (const proposal of reviewProposals(tree, research))
|
|
62
|
+
out.push({ proposal, kind: "review" });
|
|
56
63
|
if (new Settings(tree.env, {}).stories(tree.config).on)
|
|
57
|
-
for (const proposal of storyProposals(tree, research))
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
for (const proposal of storyProposals(tree, research))
|
|
65
|
+
out.push({ proposal: { ...proposal, origin: "frontier" }, kind: "story" });
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/** Create the tasks strom proposes for a research; returns their IDs. */
|
|
69
|
+
export function applyFrontier(tree, research) {
|
|
70
|
+
return researchProposals(tree, research).map(({ proposal, kind }) => create(tree, "task", { ...proposal, state: "open" }, (id) => `+${id} task "${truncate(proposal.what, 60)}" (${kind})`).id);
|
|
62
71
|
}
|
|
63
72
|
/** Commit behind the same gate as every write. */
|
|
64
73
|
export function commitNow(tree, message) {
|
|
@@ -139,7 +148,7 @@ register({
|
|
|
139
148
|
const geds = tree.dryRun ? [] : writeGedcoms(ctx, tree);
|
|
140
149
|
const ged = geds[0];
|
|
141
150
|
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,
|
|
151
|
+
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, offMap(tree),
|
|
143
152
|
// In a conversation, the next task is best begun in a fresh context: all of this one is in strom.
|
|
144
153
|
!s.runner && ctx.env.STROM_NONINTERACTIVE !== "1" ? freshContext(ctx, tree, s) : undefined),
|
|
145
154
|
data: { session: closed, newTasks, ged: ged?.file, geds: geds.map((g) => g.file) },
|
|
@@ -171,7 +180,7 @@ register({
|
|
|
171
180
|
throw new UsageError("no sessions yet");
|
|
172
181
|
const m = s.metrics ?? {};
|
|
173
182
|
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
|
|
183
|
+
text: lines(`${s.id} ${s.state}${s.task ? ` · task ${s.task}` : ""}${s.runner ? ` · ${s.runner}` : ""}${s.agent && s.agent !== s.runner ? ` · ${s.agent}` : ""}${s.model ? ` · ${s.model}` : ""} · ${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
184
|
? `metrics ${[
|
|
176
185
|
m.turns && `${m.turns} turns`,
|
|
177
186
|
m.inputTokens && `${m.inputTokens} in`,
|
|
@@ -373,9 +382,10 @@ register({
|
|
|
373
382
|
"the tree's permissions (anything else is denied), or with the terminal (--interactive). Stops at the\n" +
|
|
374
383
|
"subscription limit, when the queue is empty, or at --until (a session already running finishes, within\n" +
|
|
375
384
|
"--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.
|
|
385
|
+
"back twice in a row with nothing recorded is parked. --task picks the tasks (one session each, in that order;\n" +
|
|
386
|
+
"one done or held by another agent meanwhile is left out). Arguments after -- go to the agent CLI unchanged.",
|
|
377
387
|
options: [
|
|
378
|
-
{ name: "task", type: "string", value: "<T…>", description: "work on this
|
|
388
|
+
{ name: "task", type: "string", multiple: true, value: "<T…>", description: "work on these tasks, in this order (repeatable or T0003,T0007; default: the next one of the queue)" },
|
|
379
389
|
{ name: "research", type: "string", value: "<G…>", description: "only tasks of this research" },
|
|
380
390
|
{ name: "max", type: "string", value: "<n>", description: "at most n sessions (default 1; with --until, no limit)" },
|
|
381
391
|
{ name: "until", type: "string", value: "<HH:MM>", description: "start no session after this time" },
|
|
@@ -384,7 +394,7 @@ register({
|
|
|
384
394
|
{ name: "model", type: "string", value: "<model>", description: "model of the main agent (default: model.lead)" },
|
|
385
395
|
{ name: "interactive", type: "boolean", description: "give the agent the terminal (you can talk to it)" },
|
|
386
396
|
],
|
|
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"],
|
|
397
|
+
examples: ["strom run", "strom run --max 3 --until 23:00", "strom run --task T0003,T0007", "strom run --interactive", "strom run --agent codex", "strom run --minutes 30 -- --add-dir ~/Scans"],
|
|
388
398
|
run: async (ctx, { opts, extra }) => {
|
|
389
399
|
const root = ctx.tree().root;
|
|
390
400
|
const treeCfg = ctx.tree().config;
|
|
@@ -396,20 +406,25 @@ register({
|
|
|
396
406
|
if (runnerId !== "script" && !which(runner.command, ctx.env))
|
|
397
407
|
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
408
|
const until = parseUntil(opts.until);
|
|
399
|
-
|
|
409
|
+
// Tasks the user picked: one session each, in their order (then the queue, when --max asks for more).
|
|
410
|
+
const picked = csvOpt(opts.task).map((id) => requireRecord(Tree.open(root, ctx.env), id, "task").id);
|
|
411
|
+
const max = opts.max === undefined ? (picked.length ? picked.length : until ? Infinity : 1) : Number(opts.max);
|
|
400
412
|
if (max !== Infinity && (!Number.isInteger(max) || max < 1))
|
|
401
413
|
throw new UsageError("--max must be a positive number");
|
|
402
414
|
const minutes = ctx.settings.number("run.minutes", treeCfg, DEFAULT_RUN_MINUTES);
|
|
403
415
|
const budget = ctx.settings.number("brief.budget", treeCfg, DEFAULT_BUDGET);
|
|
404
|
-
|
|
416
|
+
// Each run has a name of its own (as a conversation does): several work side by side, never on one task.
|
|
417
|
+
const worker = `run-${process.pid}-${Date.now().toString(36)}`;
|
|
418
|
+
const runEnv = { ...ctx.env, STROM_WORKER: worker };
|
|
405
419
|
// Present in the tree for the others at work (conversations with agents): strom shows who works.
|
|
406
|
-
const
|
|
420
|
+
const beside = runsAtWork(root);
|
|
421
|
+
const leave = enterWorker(root, worker, `${PROFILES[runnerId]?.name ?? runnerId} on its own`);
|
|
407
422
|
const stopAwake = keepAwake(ctx.env);
|
|
408
423
|
// Progress goes to stderr when stdout carries JSON.
|
|
409
424
|
const out = (s) => (ctx.json ? ctx.io.stderr : ctx.io.stdout)(s + "\n");
|
|
410
425
|
const report = [];
|
|
411
426
|
// 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,
|
|
427
|
+
const lang = Tree.open(root, runEnv).lang;
|
|
413
428
|
let stopCode = "done";
|
|
414
429
|
let stopValues = {};
|
|
415
430
|
// Ctrl-C, a closed terminal, a shutdown: stop the agent, close its session, give the task back.
|
|
@@ -423,33 +438,34 @@ register({
|
|
|
423
438
|
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
424
439
|
for (const sig of signals)
|
|
425
440
|
process.on(sig, onSignal);
|
|
441
|
+
if (beside.length)
|
|
442
|
+
out(ui(lang, "ui.run.others", { since: new Date(beside[0].since).toLocaleTimeString(lang, { hour: "2-digit", minute: "2-digit" }) }));
|
|
426
443
|
try {
|
|
427
444
|
{
|
|
428
|
-
const tree = Tree.open(root,
|
|
445
|
+
const tree = Tree.open(root, runEnv);
|
|
429
446
|
assertIntact(tree);
|
|
430
447
|
const files = syncAgentFiles(tree);
|
|
431
448
|
if (files.length)
|
|
432
449
|
tree.withTreeLock(() => tree.commit(`Agent instructions: ${files.join(", ")}`, files));
|
|
433
|
-
// A session of a run that is no longer
|
|
434
|
-
//
|
|
435
|
-
for (const s of openSessions(tree).filter((x) => x.runner)) {
|
|
450
|
+
// A session of a run that is no longer at work: the run was killed or the computer went down.
|
|
451
|
+
// Close it; its task goes back to the queue. (The sessions of other runs at work stay theirs.)
|
|
452
|
+
for (const s of openSessions(tree).filter((x) => x.runner && !runAlive(root, x))) {
|
|
436
453
|
closeSession(tree, s, { summary: phrase(tree.lang, "session.run"), next: "", interrupted: true, endedBy: "run" });
|
|
437
454
|
commitNow(tree, `${s.id} interrupted: its run had stopped`);
|
|
438
455
|
out(ui(lang, "ui.run.gone", { session: s.id, back: s.task ? ui(lang, "ui.run.back", { task: s.task }) : "" }));
|
|
439
456
|
}
|
|
440
457
|
}
|
|
441
|
-
const bin = shimDir(Tree.open(root,
|
|
458
|
+
const bin = shimDir(Tree.open(root, runEnv));
|
|
442
459
|
// How the agent is let work, said once: browser tools for whose sites, and a loosening the user chose.
|
|
443
|
-
|
|
460
|
+
// Browser tools only for the archives this tree works with (the plugins folder is shared by every tree).
|
|
461
|
+
const browser = runnerId === "claude" ? treeBrowserConnectors(Tree.open(root, runEnv), ctx.settings.shared()?.value) : [];
|
|
444
462
|
const permissions = ctx.settings.agentPermissions();
|
|
445
|
-
for (const c of browser)
|
|
446
|
-
|
|
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
|
-
}
|
|
463
|
+
for (const c of browser)
|
|
464
|
+
out(ui(lang, "ui.run.browser", { name: c.manifest.title ?? c.name, hosts: c.manifest.hosts.join(", ") }));
|
|
449
465
|
if (browser.length && /haiku/i.test(models.lead ?? ""))
|
|
450
|
-
out(
|
|
466
|
+
out(ui(lang, "ui.run.browser.model", { model: models.lead }));
|
|
451
467
|
if (permissions === "full")
|
|
452
|
-
out(
|
|
468
|
+
out(ui(lang, "ui.run.full"));
|
|
453
469
|
for (let i = 0; i < max; i++) {
|
|
454
470
|
if (stop.signal.aborted) {
|
|
455
471
|
stopCode = "user";
|
|
@@ -459,12 +475,23 @@ register({
|
|
|
459
475
|
stopCode = "time";
|
|
460
476
|
break;
|
|
461
477
|
}
|
|
462
|
-
const tree = Tree.open(root,
|
|
478
|
+
const tree = Tree.open(root, runEnv); // fresh: the agent wrote from other processes
|
|
463
479
|
const research = researchOf(tree, opts.research);
|
|
464
480
|
// Other agents may be at work in this tree (conversations): never their tasks.
|
|
465
481
|
const others = othersAtWork(tree, tree.env, runnerId).tasks;
|
|
466
|
-
|
|
467
|
-
|
|
482
|
+
// Working alone: now and then a story's turn (in a conversation the user leads).
|
|
483
|
+
const free = () => taskQueue(tree, { ...(research ? { research: research.id } : {}), strategy: ctx.settings.strategy(tree.config), storyTurn: true }).find((t) => !others.has(t.id));
|
|
484
|
+
let task;
|
|
485
|
+
if (i < picked.length) {
|
|
486
|
+
task = tree.get(picked[i]);
|
|
487
|
+
const left = !task || !["open", "doing", "parked", "waiting"].includes(task.state) ? task?.state : others.has(task.id) ? "held" : undefined;
|
|
488
|
+
if (left) {
|
|
489
|
+
out(ui(lang, left === "held" ? "ui.run.skip.held" : "ui.run.skip", { task: picked[i] }));
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
else
|
|
494
|
+
task = free();
|
|
468
495
|
if (!task && research) {
|
|
469
496
|
applyFrontier(tree, research);
|
|
470
497
|
task = free();
|
|
@@ -473,7 +500,7 @@ register({
|
|
|
473
500
|
stopCode = "empty";
|
|
474
501
|
break;
|
|
475
502
|
}
|
|
476
|
-
const session = startSession(tree, { task, ...(research ? { research: research.id } : {}), runner: runnerId });
|
|
503
|
+
const session = startSession(tree, { task, ...(research ? { research: research.id } : {}), runner: runnerId, ...(models.lead ? { model: models.lead } : {}) });
|
|
477
504
|
commitNow(tree, `${session.id} session started on ${task.id}`);
|
|
478
505
|
const brief = buildBrief(tree, { task, session, budget, shared: ctx.settings.shared()?.value });
|
|
479
506
|
const briefFile = path.join(root, ".strom", "briefs", `${session.id}.md`);
|
|
@@ -485,7 +512,7 @@ register({
|
|
|
485
512
|
brief.text;
|
|
486
513
|
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
514
|
const env = {
|
|
488
|
-
...prependPath(
|
|
515
|
+
...prependPath(runEnv, bin),
|
|
489
516
|
STROM_SESSION: session.id,
|
|
490
517
|
...(opts.interactive ? {} : { STROM_NONINTERACTIVE: "1" }),
|
|
491
518
|
};
|
|
@@ -508,7 +535,7 @@ register({
|
|
|
508
535
|
signal: stop.signal,
|
|
509
536
|
});
|
|
510
537
|
// Close what the agent left open, record the metrics, export, commit.
|
|
511
|
-
const after = Tree.open(root,
|
|
538
|
+
const after = Tree.open(root, runEnv);
|
|
512
539
|
let s = after.get(session.id);
|
|
513
540
|
if (s.state === "open")
|
|
514
541
|
s = closeSession(after, s, {
|
|
@@ -520,6 +547,9 @@ register({
|
|
|
520
547
|
});
|
|
521
548
|
else
|
|
522
549
|
update(after, s.id, "session", (x) => ({ ...x, metrics: result.metrics }), { op: "session.metrics", summary: `${s.id} metrics` });
|
|
550
|
+
// the model it really ran on, as the agent said (an alias such as "opus" names no version)
|
|
551
|
+
if (result.metrics.model && s.model !== result.metrics.model)
|
|
552
|
+
s = update(after, s.id, "session", (x) => ({ ...x, model: result.metrics.model }), { op: "session.metrics", summary: `${s.id} model ${result.metrics.model}` });
|
|
523
553
|
// A task that comes back again and again without anything being recorded
|
|
524
554
|
// is stuck: park it (the user decides), work on the next one.
|
|
525
555
|
const back = after.get(task.id);
|
|
@@ -575,10 +605,9 @@ register({
|
|
|
575
605
|
process.off(sig, onSignal);
|
|
576
606
|
stopAwake();
|
|
577
607
|
leave();
|
|
578
|
-
release();
|
|
579
608
|
}
|
|
580
609
|
// What the research now waits for from the user who started it.
|
|
581
|
-
const waiting = waitingLines(Tree.open(root,
|
|
610
|
+
const waiting = waitingLines(Tree.open(root, runEnv), { shared: ctx.settings.shared()?.value, display: (p) => ctx.display(p) });
|
|
582
611
|
const reason = ui(lang, `ui.run.stop.${stopCode}`, stopValues);
|
|
583
612
|
return {
|
|
584
613
|
text: lines(ui(lang, "ui.run.summary", { n: report.length, reason }), waiting ? `\n${waiting}` : undefined),
|