strom-research 1.7.0 → 1.9.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 +3 -0
- package/assets/lang/cs.json +117 -2
- package/assets/lang/de.json +117 -2
- package/assets/method/core.md +8 -0
- package/assets/method/narrate.md +5 -1
- package/assets/plugins/gates/claude-usage/gate.ts +2 -1
- package/dist/agents/files.js +6 -4
- package/dist/cli/main.js +21 -3
- package/dist/cli/menu-links.js +35 -1
- package/dist/cli/menu-research.js +41 -2
- package/dist/cli/menu-settings.js +17 -1
- package/dist/cli/menu.js +19 -8
- package/dist/cli/ui.js +57 -1
- package/dist/commands/guide.js +6 -1
- package/dist/commands/links.js +39 -3
- package/dist/commands/output.js +4 -2
- package/dist/commands/pack.js +21 -18
- package/dist/commands/research.js +42 -1
- package/dist/commands/session.js +210 -11
- package/dist/commands/setup.js +3 -0
- package/dist/commands/start.js +6 -3
- package/dist/commands/story.js +47 -6
- package/dist/commands/tasks.js +50 -12
- package/dist/commands/trees.js +82 -2
- package/dist/core/changelog.js +223 -0
- package/dist/core/clock.js +20 -0
- package/dist/core/config.js +2 -0
- package/dist/core/connector.js +25 -1
- package/dist/core/directions.js +41 -0
- package/dist/core/edge.js +296 -0
- package/dist/core/frontier.js +12 -4
- package/dist/core/kin.js +40 -1
- package/dist/core/links.js +17 -4
- package/dist/core/live.js +428 -52
- package/dist/core/pack.js +31 -0
- package/dist/core/phrases.js +61 -0
- package/dist/core/queue.js +15 -4
- package/dist/core/relocate.js +4 -4
- package/dist/core/session.js +8 -2
- package/dist/core/stromapp.js +22 -0
- package/dist/core/trash.js +72 -0
- package/dist/core/tree.js +1 -1
- package/dist/core/workers.js +28 -3
- package/dist/gedcom/export.js +105 -1
- package/dist/gedcom/validate.js +7 -1
- package/dist/runners/claude.js +2 -1
- package/dist/runners/jsonl.js +2 -2
- package/dist/runners/runner.js +16 -3
- package/dist/runners/script.js +2 -1
- package/package.json +1 -1
package/dist/commands/session.js
CHANGED
|
@@ -5,20 +5,24 @@
|
|
|
5
5
|
// closes what the agent left open, exports the GEDCOM and commits. Between
|
|
6
6
|
// sessions not a single token is spent.
|
|
7
7
|
import fs from "node:fs";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
8
9
|
import path from "node:path";
|
|
9
10
|
import { register } from "../cli/registry.js";
|
|
10
11
|
import { ui } from "../cli/ui.js";
|
|
11
12
|
import { lines, table, truncate } from "../cli/format.js";
|
|
12
13
|
import { NeedsConsentError, UsageError, StromError } from "../core/errors.js";
|
|
13
14
|
import { closeSession, currentSession, openSessions, othersAtWork, sessionNote, startSession } from "../core/session.js";
|
|
15
|
+
import { reviveLive } from "../core/live.js";
|
|
14
16
|
import { buildBrief } from "../brief/brief.js";
|
|
15
17
|
import { DEFAULT_BUDGET, DEFAULT_RUN_MINUTES, Settings } from "../core/config.js";
|
|
16
18
|
import { prependPath } from "../runners/runner.js";
|
|
17
19
|
import { frontier } from "../core/frontier.js";
|
|
20
|
+
import { treeEdges } from "../core/edge.js";
|
|
21
|
+
import { label, parentsOf, resolvePerson } from "../core/people.js";
|
|
18
22
|
import { storyProposals } from "../core/stories.js";
|
|
19
23
|
import { OFF_MAP_HOW, offMapLine, placesOffMap } from "../core/places.js";
|
|
20
24
|
import { create, csvOpt, requireRecord, update } from "../core/records.js";
|
|
21
|
-
import { taskQueue, waitingLines } from "./tasks.js";
|
|
25
|
+
import { offTreeLine, taskQueue, waitingLines } from "./tasks.js";
|
|
22
26
|
import { resolveResearch } from "./research.js";
|
|
23
27
|
import { writeGedcoms } from "./output.js";
|
|
24
28
|
import { syncAgentFiles } from "../agents/files.js";
|
|
@@ -28,15 +32,16 @@ import { PROFILES } from "../agents/profiles.js";
|
|
|
28
32
|
import { AGENTS, detectAgent, isAgent, which, withoutAgentMarks } from "../core/which.js";
|
|
29
33
|
import { askGate, ensureGatesDir, loadGate } from "../core/gate.js";
|
|
30
34
|
import { keepAwake } from "../core/awake.js";
|
|
31
|
-
import { deadlineOf, WRAP_UP_MS } from "../core/clock.js";
|
|
35
|
+
import { askFinish, deadlineOf, finishAsked, WRAP_UP_MS } from "../core/clock.js";
|
|
36
|
+
import { OWN_GROUP } from "../runners/runner.js";
|
|
32
37
|
import { stromLauncher } from "../core/self.js";
|
|
33
38
|
import { phrase } from "../core/phrases.js";
|
|
34
|
-
import { enterWorker, runAlive, runsAtWork } from "../core/workers.js";
|
|
39
|
+
import { enterWorker, markPaused, runAlive, runsAtWork } from "../core/workers.js";
|
|
35
40
|
import { assertIntact, snapshot, verifyFast } from "../core/integrity.js";
|
|
36
41
|
import { guard } from "../core/guard.js";
|
|
37
42
|
import { hasErrors } from "../core/check.js";
|
|
38
43
|
import { Tree } from "../core/tree.js";
|
|
39
|
-
import { treeBrowserConnectors } from "../core/connector.js";
|
|
44
|
+
import { agentBrowser, treeBrowserConnectors } from "../core/connector.js";
|
|
40
45
|
import { browserNote } from "./connectors.js";
|
|
41
46
|
import { reviewProposals } from "../core/review.js";
|
|
42
47
|
function written(tree) {
|
|
@@ -70,6 +75,9 @@ export function researchProposals(tree, research) {
|
|
|
70
75
|
}
|
|
71
76
|
/** Create the tasks strom proposes for a research; returns their IDs. */
|
|
72
77
|
export function applyFrontier(tree, research) {
|
|
78
|
+
// a direction paused or ended proposes nothing (resumed: what it would, at once)
|
|
79
|
+
if (research.state !== "active")
|
|
80
|
+
return [];
|
|
73
81
|
return researchProposals(tree, research).map(({ proposal, kind }) => create(tree, "task", { ...proposal, state: "open" }, (id) => `+${id} task "${truncate(proposal.what, 60)}" (${kind})`).id);
|
|
74
82
|
}
|
|
75
83
|
/** Commit behind the same gate as every write. */
|
|
@@ -102,8 +110,11 @@ register({
|
|
|
102
110
|
if (task && !["open", "doing", "parked"].includes(task.state))
|
|
103
111
|
throw new UsageError(`${task.id} is ${task.state}`);
|
|
104
112
|
const s = startSession(tree, { ...(task ? { task } : {}), ...(research ? { research: research.id } : {}) });
|
|
113
|
+
reviveLive(tree.root, ctx.env);
|
|
105
114
|
const brief = buildBrief(tree, { ...(task ? { task } : {}), session: s, budget: ctx.settings.number("brief.budget", tree.config, DEFAULT_BUDGET), shared: ctx.settings.shared()?.value });
|
|
106
|
-
|
|
115
|
+
// a task the queue holds back, started by its ID: the agent hears why before it begins
|
|
116
|
+
const off = task && args[0] ? offTreeLine(tree, task, true) : undefined;
|
|
117
|
+
return { text: lines(written(tree), off, "", brief.text), data: { session: s, brief: brief.text, sections: brief.sections, ...(off ? { offTree: true } : {}) } };
|
|
107
118
|
},
|
|
108
119
|
}, {
|
|
109
120
|
path: ["session", "note"],
|
|
@@ -120,6 +131,30 @@ register({
|
|
|
120
131
|
sessionNote(tree, s, args[0]);
|
|
121
132
|
return { text: written(tree) };
|
|
122
133
|
},
|
|
134
|
+
}, {
|
|
135
|
+
path: ["session", "finish"],
|
|
136
|
+
summary: "Ask a session at work to finish now: its agent writes down what it found and closes it",
|
|
137
|
+
group: "research",
|
|
138
|
+
tree: true,
|
|
139
|
+
description: "For the user: a session of strom run (or a conversation) is told, from the next strom command its agent runs, to\n" +
|
|
140
|
+
"start nothing new, record what it has and close the session (the task back to the queue when unfinished).\n" +
|
|
141
|
+
"Nothing is stopped by force; the run it belongs to stops after it (as at the first Ctrl-C in its terminal).",
|
|
142
|
+
args: [{ name: "session", description: "session ID (default: the one open session)" }],
|
|
143
|
+
examples: ["strom session finish", "strom session finish N0001"],
|
|
144
|
+
run(ctx, { args }) {
|
|
145
|
+
const tree = ctx.tree();
|
|
146
|
+
const lang = tree.lang;
|
|
147
|
+
const open = openSessions(tree);
|
|
148
|
+
const s = args[0] ? requireRecord(tree, args[0], "session") : open.length === 1 ? open[0] : undefined;
|
|
149
|
+
if (!s && !open.length)
|
|
150
|
+
throw new UsageError(ui(lang, "ui.session.finish.none"), { hint: "strom session list" });
|
|
151
|
+
if (!s)
|
|
152
|
+
throw new UsageError(ui(lang, "ui.session.finish.which", { sessions: open.map((x) => x.id).join(", ") }), { hint: `strom session finish ${open[0].id}` });
|
|
153
|
+
if (s.state !== "open")
|
|
154
|
+
throw new UsageError(ui(lang, "ui.session.finish.closed", { session: s.id }), { hint: "strom session list" });
|
|
155
|
+
askFinish(tree.root, s.id, ctx.env.STROM_WORKER ?? "user");
|
|
156
|
+
return { text: ui(lang, "ui.session.finish.done", { session: s.id, task: s.task ?? "–" }), data: { session: s.id, task: s.task ?? null, asked: true } };
|
|
157
|
+
},
|
|
123
158
|
}, {
|
|
124
159
|
path: ["session", "close"],
|
|
125
160
|
summary: "Finish the session: summary, next step; exports the GEDCOM and fills the task queue",
|
|
@@ -261,7 +296,132 @@ register({
|
|
|
261
296
|
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 },
|
|
262
297
|
};
|
|
263
298
|
},
|
|
299
|
+
}, {
|
|
300
|
+
path: ["edge"],
|
|
301
|
+
summary: "Where the tree ends: above whom it does not go on, why (what the records say so far) and what comes next — what the Strom app shows on those cards",
|
|
302
|
+
group: "tasks",
|
|
303
|
+
tree: true,
|
|
304
|
+
args: [{ name: "person", description: "whose edge in full: ID (P0001) or name (default: every edge the research reaches)", variadic: true }],
|
|
305
|
+
options: [{ name: "all", type: "boolean", description: "also the edges no direction reaches: family outside the directions, people off the tree" }],
|
|
306
|
+
examples: ["strom edge", "strom edge P0001", "strom edge --all --json"],
|
|
307
|
+
run(ctx, { args, opts }) {
|
|
308
|
+
const tree = ctx.tree();
|
|
309
|
+
const { edges, islands } = treeEdges(tree);
|
|
310
|
+
const who = (id) => {
|
|
311
|
+
const p = tree.get(id);
|
|
312
|
+
return p ? label(p) : id;
|
|
313
|
+
};
|
|
314
|
+
if (args.length) {
|
|
315
|
+
const people = args.map((a) => resolvePerson(tree, a));
|
|
316
|
+
const out = [];
|
|
317
|
+
for (const p of people) {
|
|
318
|
+
const e = edges.get(p.id);
|
|
319
|
+
const isle = islands.get(p.id);
|
|
320
|
+
if (out.length)
|
|
321
|
+
out.push("");
|
|
322
|
+
if (!e)
|
|
323
|
+
out.push(`${label(p)} — ${parentsOf(tree, p.id).length === 2 ? "the tree goes on above: a record proves the parents" : "nothing the research knows of the edge above yet (no direction reaches them, no work on it)"}`);
|
|
324
|
+
else
|
|
325
|
+
out.push(...edgeLines(tree, e, who));
|
|
326
|
+
if (isle)
|
|
327
|
+
out.push(islandLine(isle, who));
|
|
328
|
+
}
|
|
329
|
+
const data = people.map((p) => ({ person: p.id, edge: edges.get(p.id) ?? null, island: islands.get(p.id) ? { ...islands.get(p.id), people: islands.get(p.id).people.length } : null }));
|
|
330
|
+
return { text: lines(...out), data: { edges: data } };
|
|
331
|
+
}
|
|
332
|
+
const reached = new Set(["in", "limit", "paused", "done"]);
|
|
333
|
+
const shown = [...edges.values()].filter((e) => opts.all || reached.has(e.scope)).sort((a, b) => (a.generation ?? 99) - (b.generation ?? 99) || a.person.localeCompare(b.person));
|
|
334
|
+
const count = (k) => [...shown.reduce((m, e) => m.set(e[k], (m.get(e[k]) ?? 0) + 1), new Map())].map(([v, n]) => `${v} ${n}`).join(", ");
|
|
335
|
+
const hidden = edges.size - shown.length;
|
|
336
|
+
return {
|
|
337
|
+
text: shown.length
|
|
338
|
+
? lines(table(shown.map((e) => [
|
|
339
|
+
e.person,
|
|
340
|
+
e.generation !== undefined ? `G${e.generation}` : "",
|
|
341
|
+
e.missing,
|
|
342
|
+
e.scope === "in" ? "" : e.scope,
|
|
343
|
+
e.end,
|
|
344
|
+
e.next,
|
|
345
|
+
truncate(edgeNext(e), 70),
|
|
346
|
+
])), "", `ends: ${count("end")}`, `next: ${count("next")}`, hidden ? `${hidden} more no direction reaches (strom edge --all)` : undefined, "one in full: strom edge P…")
|
|
347
|
+
: "no edge the research reaches — every person in its directions has parents a record proves",
|
|
348
|
+
data: { edges: shown, islands: [...new Set(islands.values())].map((i) => ({ ...i, people: i.people.length, first: i.people[0] })) },
|
|
349
|
+
};
|
|
350
|
+
},
|
|
264
351
|
});
|
|
352
|
+
/** What comes next at an edge, in a few words: the first task and where it stands, else what the user decides. */
|
|
353
|
+
function edgeNext(e) {
|
|
354
|
+
const t = e.tasks[0];
|
|
355
|
+
const hyp = e.hypotheses.find((h) => h.joins.length);
|
|
356
|
+
const join = hyp ? ` · ${hyp.id} ${hyp.island ? `joins ${hyp.joins.join(" ")} (${hyp.island.people} people off the tree)` : `would join through ${hyp.joins.join(" ")}`}` : "";
|
|
357
|
+
if (t)
|
|
358
|
+
return `${t.id}${t.position ? ` #${t.position}` : ""}${t.held ? ` (${t.held})` : ""}${e.tasks.length > 1 ? ` +${e.tasks.length - 1}` : ""}${join}`;
|
|
359
|
+
if (e.next === "decide")
|
|
360
|
+
return `tried ${e.tried.join(" ")} — ask the user${join}`;
|
|
361
|
+
return join.replace(/^ · /, "");
|
|
362
|
+
}
|
|
363
|
+
const MISSING_WORDS = {
|
|
364
|
+
parents: "both parents unknown",
|
|
365
|
+
father: "the father unknown",
|
|
366
|
+
mother: "the mother unknown",
|
|
367
|
+
proof: "no record proves the parents recorded",
|
|
368
|
+
};
|
|
369
|
+
const SCOPE_WORDS = {
|
|
370
|
+
in: "reached by",
|
|
371
|
+
limit: "at the limit of",
|
|
372
|
+
paused: "in a paused direction,",
|
|
373
|
+
done: "in an ended direction,",
|
|
374
|
+
living: "most likely alive — not researched",
|
|
375
|
+
outside: "family of the tree no direction goes through",
|
|
376
|
+
"off-tree": "of a family nothing links to the tree yet",
|
|
377
|
+
};
|
|
378
|
+
const END_WORDS = {
|
|
379
|
+
unnamed: "the baptism names no father (born out of wedlock)",
|
|
380
|
+
lost: "the records of those years are lost",
|
|
381
|
+
"before-records": "born before the known birth records of the place begin",
|
|
382
|
+
gap: "the known records have a gap in those years",
|
|
383
|
+
"not-found": "searched in vain everywhere strom knew to look",
|
|
384
|
+
offline: "the books left are only in the archive",
|
|
385
|
+
partly: "searched in part",
|
|
386
|
+
unsearched: "not searched yet",
|
|
387
|
+
"no-books": "no book of the birthplace known",
|
|
388
|
+
"no-place": "the birthplace unknown",
|
|
389
|
+
"no-clue": "nothing to go on: no year, no place of birth",
|
|
390
|
+
};
|
|
391
|
+
const NEXT_WORDS = {
|
|
392
|
+
working: "an agent works on it now",
|
|
393
|
+
waiting: "waits for the user",
|
|
394
|
+
queued: "in the queue",
|
|
395
|
+
held: "waits out of the queue",
|
|
396
|
+
proposed: "strom proposes it with the next session",
|
|
397
|
+
decide: "everything strom could propose was tried — the user decides",
|
|
398
|
+
none: "nothing planned",
|
|
399
|
+
};
|
|
400
|
+
/** One edge in full, for the agent. */
|
|
401
|
+
function edgeLines(tree, e, who) {
|
|
402
|
+
const y = (r) => (r.from === r.to ? `${r.from}` : `${r.from}–${r.to}`);
|
|
403
|
+
const est = e.estimate;
|
|
404
|
+
const basis = est?.basis ? ` (estimated from ${est.basis.kind === "MARR" ? "the marriage" : "the eldest child"} ${est.basis.year})` : "";
|
|
405
|
+
return [
|
|
406
|
+
`${who(e.person)} — the tree ends above: ${MISSING_WORDS[e.missing]}`,
|
|
407
|
+
` ${SCOPE_WORDS[e.scope]}${e.research ? ` ${e.research}, generation ${e.generation}` : ""}`,
|
|
408
|
+
` the records: ${END_WORDS[e.end]}${e.recordsFrom ? ` (from ${e.recordsFrom})` : ""}`,
|
|
409
|
+
e.window ? ` baptism sought ${y(e.window)}${est?.place ? ` in ${est.place}` : ""}${basis}` : est?.place ? ` born in ${est.place}, year unknown` : undefined,
|
|
410
|
+
e.books.length ? ` books: ${e.books.map((b) => `${b.id}${b.years ? ` (${b.years})` : ""}${b.access.startsWith("online") ? "" : ` ${b.access}`}`).join(", ")}` : undefined,
|
|
411
|
+
e.covered.length ? ` searched in vain: ${e.covered.map(y).join(", ")}` : undefined,
|
|
412
|
+
e.noRecords.length ? ` no records known: ${e.noRecords.map(y).join(", ")}` : undefined,
|
|
413
|
+
` next: ${NEXT_WORDS[e.next]}`,
|
|
414
|
+
...e.tasks.map((t) => ` ${t.id} ${t.level} ${t.state}${t.position ? ` #${t.position}` : ""}${t.held ? ` (waits: ${t.held}${t.until ? ` until ${t.until}` : ""})` : ""}${t.on ? ` — ${truncate(t.on, 60)}` : ""}: ${truncate(t.what, 90)}`),
|
|
415
|
+
e.tried.length ? ` tried: ${e.tried.join(" ")}` : undefined,
|
|
416
|
+
...e.hypotheses.map((h) => ` ${h.id} ${truncate(h.question, 90)}${!h.joins.length ? "" : h.island ? ` — would join ${h.joins.map(who).join(", ")} of a family off the tree (${h.island.people} people, ${h.island.held} tasks wait for it)` : ` — would join them to the tree through ${h.joins.map(who).join(", ")}`}${h.tests.length ? `; tested by ${h.tests.join(" ")}` : "; no task tests it"}`),
|
|
417
|
+
e.conflicts.length ? ` open conflicts: ${e.conflicts.join(" ")}` : undefined,
|
|
418
|
+
e.searches || e.sessions ? ` work so far: ${e.searches} search${e.searches === 1 ? "" : "es"}, ${e.sessions} session${e.sessions === 1 ? "" : "s"}${e.cost !== undefined ? `, $${e.cost.toFixed(2)}${e.costPartial ? "+" : ""}` : ""}${e.last ? `; last ${e.last}` : ""}` : undefined,
|
|
419
|
+
].filter((x) => x !== undefined);
|
|
420
|
+
}
|
|
421
|
+
function islandLine(i, who) {
|
|
422
|
+
const joins = i.hypotheses.map((h) => `${h.id} (to ${h.joins.map(who).join(", ")})`);
|
|
423
|
+
return ` of a family nothing links to the tree: ${i.people.length} people, ${i.held} tasks wait for it${joins.length ? `; would join it: ${joins.join(", ")}` : "; no hypothesis would join it yet"}`;
|
|
424
|
+
}
|
|
265
425
|
// ── agents ─────────────────────────────────────────────────────────────────
|
|
266
426
|
register({
|
|
267
427
|
path: ["agents", "sync"],
|
|
@@ -402,6 +562,7 @@ register({
|
|
|
402
562
|
{ name: "loop", type: "boolean", description: "session after session for as long as there is work (and the gate lets it)" },
|
|
403
563
|
{ name: "gate", type: "string", value: "<name [n]>", description: 'ask this gate before each session, with what it is given, e.g. "claude-usage 20" (default: run.gate)' },
|
|
404
564
|
{ name: "no-gate", type: "boolean", description: "ask no gate this time (only you: an agent cannot)" },
|
|
565
|
+
{ name: "follow", type: "boolean", description: "open the Strom app to follow the work live when the first session starts (after the gate)" },
|
|
405
566
|
{ name: "minutes", type: "string", value: "<n>", description: `time limit of one session (default: run.minutes, ${DEFAULT_RUN_MINUTES})` },
|
|
406
567
|
{ name: "budget", type: "string", value: "<tokens>", description: `brief size (default: brief.budget, ${DEFAULT_BUDGET})` },
|
|
407
568
|
{ name: "model", type: "string", value: "<model>", description: "model of the main agent (default: model.lead)" },
|
|
@@ -439,15 +600,27 @@ register({
|
|
|
439
600
|
const report = [];
|
|
440
601
|
// What the gate answered, for the record of the run.
|
|
441
602
|
const gates = [];
|
|
603
|
+
// The Strom app opened to follow the run (--follow): once, when its first session starts.
|
|
604
|
+
let followed = false;
|
|
442
605
|
// Why the run stopped: a code for the exit status and for data, words for the user (their language).
|
|
443
606
|
const lang = Tree.open(root, runEnv).lang;
|
|
444
607
|
let stopCode = "done";
|
|
445
608
|
let stopValues = {};
|
|
446
|
-
// Ctrl-C
|
|
609
|
+
// The first Ctrl-C while a session works: it is asked to finish (the agent writes down what it found and closes
|
|
610
|
+
// it), then the run stops — no next session. Ctrl-C again, a closed terminal, a shutdown: stop the agent now,
|
|
611
|
+
// close its session, give the task back. (Windows: the console stops the agent itself — now.)
|
|
447
612
|
const stop = new AbortController();
|
|
613
|
+
let atWorkOn;
|
|
614
|
+
let finishing = false;
|
|
448
615
|
const onSignal = (sig) => {
|
|
449
616
|
if (stop.signal.aborted)
|
|
450
|
-
process.exit(130); // a
|
|
617
|
+
process.exit(130); // a third Ctrl-C: at once
|
|
618
|
+
if (sig === "SIGINT" && atWorkOn && !finishing && OWN_GROUP && !opts.interactive) {
|
|
619
|
+
finishing = true;
|
|
620
|
+
askFinish(root, atWorkOn, "user");
|
|
621
|
+
out(`\n${ui(lang, "ui.run.finishing", { session: atWorkOn })}`);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
451
624
|
out(`\n${ui(lang, "ui.run.stopping")} (${sig})`);
|
|
452
625
|
stop.abort();
|
|
453
626
|
};
|
|
@@ -478,6 +651,10 @@ register({
|
|
|
478
651
|
const permissions = ctx.settings.agentPermissions();
|
|
479
652
|
for (const c of browser)
|
|
480
653
|
out(ui(lang, "ui.run.browser", { name: c.manifest.title ?? c.name, hosts: c.manifest.hosts.join(", ") }));
|
|
654
|
+
// agent.browser always (the user's choice): the browser in every session, for the research's archives
|
|
655
|
+
const web = runnerId === "claude" ? agentBrowser(Tree.open(root, runEnv), ctx.settings.shared()?.value) : undefined;
|
|
656
|
+
if (web?.always)
|
|
657
|
+
out(ui(lang, "ui.run.browser.always", { hosts: web.hosts.length ? web.hosts.join(", ") : "–" }));
|
|
481
658
|
if (browser.length && /haiku/i.test(models.lead ?? ""))
|
|
482
659
|
out(ui(lang, "ui.run.browser.model", { model: models.lead }));
|
|
483
660
|
// Archives through the browser with an agent that has no browser tools, or without the extension: said before it starts.
|
|
@@ -493,7 +670,7 @@ register({
|
|
|
493
670
|
if (gate)
|
|
494
671
|
out(ui(lang, gateHolds ? "ui.run.gate" : "ui.run.gate.once", { name: gate.manifest.title ?? gate.name }));
|
|
495
672
|
for (let i = 0; i < max; i++) {
|
|
496
|
-
if (stop.signal.aborted) {
|
|
673
|
+
if (stop.signal.aborted || finishing) {
|
|
497
674
|
stopCode = "user";
|
|
498
675
|
break;
|
|
499
676
|
}
|
|
@@ -558,7 +735,14 @@ register({
|
|
|
558
735
|
out(ui(lang, "ui.run.gate.wait", { at: new Date(at).toLocaleString(lang, { weekday: "short", hour: "2-digit", minute: "2-digit" }), reason: said.reason ?? "" }));
|
|
559
736
|
// nothing to do meanwhile: the computer may sleep
|
|
560
737
|
stopAwake();
|
|
561
|
-
|
|
738
|
+
// the Strom app shows the run paused until then, not at work
|
|
739
|
+
markPaused(root, worker, { until: new Date(at).toISOString(), ...(said.reason ? { reason: said.reason } : {}) });
|
|
740
|
+
try {
|
|
741
|
+
await pause(at - Date.now(), stop.signal);
|
|
742
|
+
}
|
|
743
|
+
finally {
|
|
744
|
+
markPaused(root, worker, undefined);
|
|
745
|
+
}
|
|
562
746
|
stopAwake = keepAwake(ctx.env);
|
|
563
747
|
i--; // this was no session: ask again, with the queue as it is then
|
|
564
748
|
continue;
|
|
@@ -566,6 +750,17 @@ register({
|
|
|
566
750
|
}
|
|
567
751
|
const session = startSession(tree, { task, ...(research ? { research: research.id } : {}), runner: runnerId, ...(models.lead ? { model: models.lead } : {}) });
|
|
568
752
|
commitNow(tree, `${session.id} session started on ${task.id}`);
|
|
753
|
+
// the Strom app followed the research and its bridge ended without a word: on its address again
|
|
754
|
+
reviveLive(root, ctx.env);
|
|
755
|
+
// The Strom app follows it from the first session on — not while the run still waits for its gate.
|
|
756
|
+
if (opts.follow && !followed) {
|
|
757
|
+
followed = true;
|
|
758
|
+
const { command, args: self } = stromLauncher();
|
|
759
|
+
const app = spawnSync(command, [...self, "app", "--live"], { cwd: root, env: ctx.env, encoding: "utf8", windowsHide: true, timeout: 60_000 });
|
|
760
|
+
const said = `${app.stdout ?? ""}${app.stderr ?? ""}`.trim();
|
|
761
|
+
if (said)
|
|
762
|
+
out(said);
|
|
763
|
+
}
|
|
569
764
|
// When the agent is stopped: it knows (the brief, strom's reminders near the end), and gets a few minutes more to write down what it found.
|
|
570
765
|
const deadline = Date.now() + minutes * 60_000;
|
|
571
766
|
const brief = buildBrief(tree, { task, session, budget, shared: ctx.settings.shared()?.value, deadline });
|
|
@@ -584,6 +779,7 @@ register({
|
|
|
584
779
|
STROM_MINUTES: String(minutes),
|
|
585
780
|
...(opts.interactive ? {} : { STROM_NONINTERACTIVE: "1" }),
|
|
586
781
|
};
|
|
782
|
+
atWorkOn = session.id;
|
|
587
783
|
const result = await runner.run({
|
|
588
784
|
cwd: root,
|
|
589
785
|
prompt,
|
|
@@ -605,16 +801,19 @@ register({
|
|
|
605
801
|
...(opts.interactive ? { interactive: true } : {}),
|
|
606
802
|
// the level for every agent (each maps it to its own switches); the browser and Remote Control are Claude Code's
|
|
607
803
|
permissions,
|
|
608
|
-
...(runnerId === "claude" ? { chrome:
|
|
804
|
+
...(runnerId === "claude" ? { chrome: Boolean(web?.on), remote: ctx.settings.agentRemote() } : {}),
|
|
609
805
|
...(extra?.length ? { extraArgs: extra } : {}),
|
|
610
806
|
onProgress: (l) => out(` · ${l}`),
|
|
611
807
|
signal: stop.signal,
|
|
612
808
|
});
|
|
809
|
+
atWorkOn = undefined;
|
|
613
810
|
// Close what the agent left open, record the metrics, export, commit.
|
|
614
811
|
const after = Tree.open(root, runEnv);
|
|
615
812
|
let s = after.get(session.id);
|
|
616
813
|
// strom worked for the agent when it closed the session itself or recorded something
|
|
617
814
|
const ranStrom = s.state !== "open" || sessionWrites(after, s.id) > 0;
|
|
815
|
+
// the user asked it to finish (strom session finish, the Strom app's "finish and stop"): no next session
|
|
816
|
+
const askedToFinish = finishAsked(root, s.id) || s.endedBy === "user";
|
|
618
817
|
if (s.state === "open")
|
|
619
818
|
s = closeSession(after, s, {
|
|
620
819
|
summary: result.outcome === "stopped" ? phrase(after.lang, "session.user") : phrase(after.lang, "session.agent", { outcome: result.outcome }),
|
|
@@ -653,7 +852,7 @@ register({
|
|
|
653
852
|
stopCode = "problems";
|
|
654
853
|
break;
|
|
655
854
|
}
|
|
656
|
-
if (stop.signal.aborted) {
|
|
855
|
+
if (stop.signal.aborted || finishing || askedToFinish) {
|
|
657
856
|
stopCode = "user";
|
|
658
857
|
break;
|
|
659
858
|
}
|
package/dist/commands/setup.js
CHANGED
|
@@ -453,6 +453,9 @@ function setUserSetting(ctx, key, value) {
|
|
|
453
453
|
// Loosening the agent's permissions is the user's decision alone.
|
|
454
454
|
if (key === "agent.permissions" && raises(s.agentPermissions(), value))
|
|
455
455
|
ctx.requireHuman(`Let the agent ${value === "full" ? "do everything but what the tree's permissions deny" : "work on its own, asking only about risky steps"}, without asking?`, `strom config set agent.permissions ${value}`, "agent.permissions", ui(ctx.uiLang(), value === "full" ? "ui.consent.level.full" : "ui.consent.level.auto"));
|
|
456
|
+
// The browser in every session: the user's decision alone.
|
|
457
|
+
if (key === "agent.browser" && value === "always" && s.resolve("agent.browser", ctx.hasTree() ? ctx.tree().config : undefined)?.value !== "always")
|
|
458
|
+
ctx.requireHuman("Give the agent browser tools (Claude in Chrome) in every research session?", "strom config set agent.browser always", "agent.browser", ui(ctx.uiLang(), "ui.consent.browser"));
|
|
456
459
|
// Sessions steered from elsewhere (Remote Control): the user's decision alone.
|
|
457
460
|
if (key === "agent.remote" && value === "on" && !s.agentRemote())
|
|
458
461
|
ctx.requireHuman("Start the Claude Code sessions with Remote Control (followed and steered from claude.ai or your phone)?", "strom config set agent.remote on", "agent.remote", ui(ctx.uiLang(), "ui.consent.remote"));
|
package/dist/commands/start.js
CHANGED
|
@@ -16,7 +16,7 @@ import { syncAgentFiles } from "../agents/files.js";
|
|
|
16
16
|
import { AGENTS, findAgent, isAgent, withoutAgentMarks } from "../core/which.js";
|
|
17
17
|
import { DESKTOP_APPS, agentsHere, whereToTalk } from "../core/apps.js";
|
|
18
18
|
import { assertIntact } from "../core/integrity.js";
|
|
19
|
-
import {
|
|
19
|
+
import { agentBrowser } from "../core/connector.js";
|
|
20
20
|
import { browserNote } from "./connectors.js";
|
|
21
21
|
import { offerLinks } from "../cli/wizard.js";
|
|
22
22
|
import { appOpensLinks, appOpensResearch, importAppUrl, installedStromApp, liveAppUrl, noticeStromApp, STROM_APP_URL, stromAppUrl } from "../core/stromapp.js";
|
|
@@ -134,7 +134,7 @@ register({
|
|
|
134
134
|
// its research when there is one, the day.
|
|
135
135
|
name: ["Strom", tree.config.name, researches.length === 1 ? researches[0].name : undefined, new Date().toLocaleDateString(lang, { day: "numeric", month: "numeric" })].filter(Boolean).join(" · "),
|
|
136
136
|
shared,
|
|
137
|
-
...(agent === "claude" ? { chrome:
|
|
137
|
+
...(agent === "claude" ? { chrome: agentBrowser(tree, shared).on, remote: ctx.settings.agentRemote() } : {}),
|
|
138
138
|
};
|
|
139
139
|
const args = conversationArgs(agent, base);
|
|
140
140
|
if (opts.print)
|
|
@@ -263,7 +263,10 @@ register({
|
|
|
263
263
|
tree: true,
|
|
264
264
|
description: "A small web server on this computer only (127.0.0.1, a secret address), that only reads: the Strom app, opened with\n" +
|
|
265
265
|
"strom app --live, takes the tree from it and hears what changes — who is at work on what, what was recorded, what\n" +
|
|
266
|
-
"waits for the user. It ends by itself when nobody asks it anything for two hours, or with strom live stop
|
|
266
|
+
"waits for the user. It ends by itself when nobody asks it anything for two hours, or with strom live stop.\n" +
|
|
267
|
+
"One error does not end it (a read that fails is tried again); what it did and what went wrong is in .strom/live.log.\n" +
|
|
268
|
+
"Started again it takes the address it had (its port while free), so the app following it goes on by itself;\n" +
|
|
269
|
+
"one that ended without a word is started again when a session starts.",
|
|
267
270
|
examples: ["strom live", "strom live start", "strom live stop"],
|
|
268
271
|
run(ctx) {
|
|
269
272
|
const tree = ctx.tree();
|
package/dist/commands/story.js
CHANGED
|
@@ -8,6 +8,42 @@ import { approveStory, setStory, findEventOwner } from "../core/actions.js";
|
|
|
8
8
|
import { UsageError } from "../core/errors.js";
|
|
9
9
|
import { csvOpt, normId, textOpt } from "../core/records.js";
|
|
10
10
|
import { resolvePerson } from "../core/people.js";
|
|
11
|
+
import { foldText } from "../core/text.js";
|
|
12
|
+
/**
|
|
13
|
+
* The one heading a story has is its title (--title). A first line "# …" is taken as the title when none is given,
|
|
14
|
+
* and dropped when it only repeats it; any other "# …" line stays (the Strom app shows it as a subheading).
|
|
15
|
+
*/
|
|
16
|
+
function titleOf(text, title) {
|
|
17
|
+
const [first = "", ...rest] = text.replace(/\r\n?/g, "\n").trimStart().split("\n");
|
|
18
|
+
const heading = /^#[ \t]+(.+?)[ \t#]*$/.exec(first)?.[1]?.trim();
|
|
19
|
+
if (!heading)
|
|
20
|
+
return { text, ...(title ? { title } : {}) };
|
|
21
|
+
if (!title?.trim())
|
|
22
|
+
return { text: rest.join("\n"), title: heading };
|
|
23
|
+
return foldText(heading) === foldText(title) ? { text: rest.join("\n"), title } : { text, title };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* What the Strom app shows as plain text (its set: paragraphs, "## " subheadings, "- " bullets, **bold**,
|
|
27
|
+
* *italic*): said, never refused. Numbered lists count only as two such lines in a row — a date may start a line.
|
|
28
|
+
*/
|
|
29
|
+
function outsideSet(text) {
|
|
30
|
+
const found = [];
|
|
31
|
+
const lns = text.split("\n");
|
|
32
|
+
if (/\[[^\]\n]+\]\([^)\n]+\)/.test(text))
|
|
33
|
+
found.push("links");
|
|
34
|
+
if (lns.filter((l) => /^\s*\|.*\|\s*$/.test(l)).length >= 2)
|
|
35
|
+
found.push("tables");
|
|
36
|
+
if (lns.some((l) => /^\s*>/.test(l)))
|
|
37
|
+
found.push("quotes (> )");
|
|
38
|
+
if (/`[^`\n]+`/.test(text))
|
|
39
|
+
found.push("code");
|
|
40
|
+
if (lns.some((l, i) => /^\s*\d+[.)]\s/.test(l) && /^\s*\d+[.)]\s/.test(lns[i + 1] ?? "")))
|
|
41
|
+
found.push("numbered lists");
|
|
42
|
+
// "* 1831" is the sign of a birth, no bullet
|
|
43
|
+
if (lns.some((l) => /^\s*[*+]\s+(?!\d)/.test(l)))
|
|
44
|
+
found.push('bullets other than "- "');
|
|
45
|
+
return found;
|
|
46
|
+
}
|
|
11
47
|
function owner(tree, ref) {
|
|
12
48
|
return /^F\d+$/i.test(ref.trim()) ? normId(ref, "family") : resolvePerson(tree, ref).id;
|
|
13
49
|
}
|
|
@@ -17,8 +53,10 @@ register({
|
|
|
17
53
|
group: "people",
|
|
18
54
|
tree: true,
|
|
19
55
|
writes: true,
|
|
20
|
-
description: "Every statement rests on a recorded fact: list them with --fact. Paragraphs are separated by a blank line
|
|
21
|
-
"
|
|
56
|
+
description: "Every statement rests on a recorded fact: list them with --fact. Paragraphs are separated by a blank line.\n" +
|
|
57
|
+
"Kept: \"## subheading\" lines, \"- \" bullet lines, **bold**, *italic*; anything else shows as plain text in the\n" +
|
|
58
|
+
"Strom app. The title goes in --title (a first line \"# …\" is taken as it). Writing it again replaces it (the\n" +
|
|
59
|
+
"history keeps the old one).",
|
|
22
60
|
args: [{ name: "who", description: "person (ID or name) or family (F…)", required: true }],
|
|
23
61
|
options: [
|
|
24
62
|
{ name: "text", type: "string", value: "<text|@file>", description: "the story, in the research language" },
|
|
@@ -30,18 +68,21 @@ register({
|
|
|
30
68
|
examples: ['strom story set P0001 --text @notes/story-P0001.md --title "The miller of Týnec" --fact E0001 --fact E0002 --note "The house is inferred from the census."'],
|
|
31
69
|
run(ctx, { args, opts }) {
|
|
32
70
|
const tree = ctx.tree();
|
|
33
|
-
const
|
|
34
|
-
if (!
|
|
71
|
+
const given = textOpt(opts.text, (p) => fs.readFileSync(ctx.resolvePath(p), "utf8"));
|
|
72
|
+
if (!given?.trim())
|
|
35
73
|
throw new UsageError("--text is required", { hint: "--text @notes/story.md (a file) or the text itself" });
|
|
74
|
+
const { text, title } = titleOf(given, opts.title);
|
|
36
75
|
const rec = setStory(tree, owner(tree, args[0]), {
|
|
37
76
|
text,
|
|
38
|
-
title
|
|
77
|
+
title,
|
|
39
78
|
facts: csvOpt(opts.fact).map((f) => normId(f)),
|
|
40
79
|
note: opts.note,
|
|
41
80
|
final: Boolean(opts.final),
|
|
42
81
|
});
|
|
43
82
|
const missing = rec.story.facts.length === 0 ? "note: no --fact given — say which facts the story rests on" : undefined;
|
|
44
|
-
|
|
83
|
+
const outside = outsideSet(rec.story.text);
|
|
84
|
+
const plain = outside.length ? `note: the Strom app shows ${outside.join(", ")} as plain text — keep to paragraphs, "## " subheadings, "- " bullets, **bold**, *italic*` : undefined;
|
|
85
|
+
return { text: lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined, missing, plain), data: { story: rec.story } };
|
|
45
86
|
},
|
|
46
87
|
}, {
|
|
47
88
|
path: ["story", "approve"],
|