strom-research 1.1.1 → 1.3.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 +6 -1
- package/assets/lang/cs.json +177 -6
- package/assets/lang/de.json +177 -6
- package/assets/method/core.md +6 -2
- package/assets/method/reading.md +2 -1
- package/assets/plugins/README.md +16 -2
- package/assets/plugins/gates/README.md +95 -0
- package/assets/plugins/gates/claude-usage/gate.json +10 -0
- package/assets/plugins/gates/claude-usage/gate.ts +125 -0
- package/assets/plugins/hooks/README.md +91 -0
- package/dist/agents/global.js +10 -0
- package/dist/agents/launch.js +1 -1
- package/dist/brief/brief.js +2 -0
- package/dist/cli/main.js +43 -2
- package/dist/cli/menu-parts.js +140 -0
- package/dist/cli/menu-research.js +194 -0
- package/dist/cli/menu-settings.js +273 -0
- package/dist/cli/menu-waiting.js +79 -0
- package/dist/cli/menu.js +113 -99
- package/dist/cli/ui.js +175 -5
- package/dist/cli/wizard.js +48 -2
- package/dist/commands/browse.js +4 -3
- package/dist/commands/connectors.js +25 -2
- package/dist/commands/gates.js +58 -0
- package/dist/commands/hooks.js +141 -0
- package/dist/commands/index.js +2 -0
- package/dist/commands/intake.js +2 -2
- package/dist/commands/research.js +60 -17
- package/dist/commands/session.js +161 -18
- package/dist/commands/setup.js +57 -1
- package/dist/commands/sources.js +24 -0
- package/dist/commands/start.js +5 -1
- package/dist/commands/tasks.js +41 -4
- package/dist/core/browser.js +50 -0
- package/dist/core/clock.js +55 -0
- package/dist/core/config.js +18 -0
- package/dist/core/gate.js +175 -0
- package/dist/core/hooks.js +210 -0
- package/dist/core/media.js +21 -7
- package/dist/core/overview.js +2 -0
- package/dist/core/phrases.js +1 -0
- package/dist/core/relocate.js +111 -0
- package/dist/core/tree.js +19 -2
- package/dist/runners/antigravity.js +2 -1
- package/dist/runners/claude.js +160 -114
- package/dist/runners/codex.js +14 -1
- package/dist/runners/jsonl.js +63 -37
- package/dist/runners/opencode.js +7 -2
- package/dist/runners/script.js +56 -47
- package/package.json +1 -1
package/dist/commands/session.js
CHANGED
|
@@ -9,7 +9,7 @@ import path from "node:path";
|
|
|
9
9
|
import { register } from "../cli/registry.js";
|
|
10
10
|
import { ui } from "../cli/ui.js";
|
|
11
11
|
import { lines, table, truncate } from "../cli/format.js";
|
|
12
|
-
import { UsageError, StromError } from "../core/errors.js";
|
|
12
|
+
import { NeedsConsentError, UsageError, StromError } from "../core/errors.js";
|
|
13
13
|
import { closeSession, currentSession, openSessions, othersAtWork, sessionNote, startSession } from "../core/session.js";
|
|
14
14
|
import { buildBrief } from "../brief/brief.js";
|
|
15
15
|
import { DEFAULT_BUDGET, DEFAULT_RUN_MINUTES, Settings } from "../core/config.js";
|
|
@@ -25,8 +25,10 @@ import { syncAgentFiles } from "../agents/files.js";
|
|
|
25
25
|
import { setTreeSetting } from "./setup.js";
|
|
26
26
|
import { RUNNERS } from "../runners/index.js";
|
|
27
27
|
import { PROFILES } from "../agents/profiles.js";
|
|
28
|
-
import { AGENTS, detectAgent, which } from "../core/which.js";
|
|
28
|
+
import { AGENTS, detectAgent, isAgent, which } from "../core/which.js";
|
|
29
|
+
import { askGate, ensureGatesDir, loadGate } from "../core/gate.js";
|
|
29
30
|
import { keepAwake } from "../core/awake.js";
|
|
31
|
+
import { deadlineOf, WRAP_UP_MS } from "../core/clock.js";
|
|
30
32
|
import { stromLauncher } from "../core/self.js";
|
|
31
33
|
import { phrase } from "../core/phrases.js";
|
|
32
34
|
import { enterWorker, runAlive, runsAtWork } from "../core/workers.js";
|
|
@@ -35,6 +37,7 @@ import { guard } from "../core/guard.js";
|
|
|
35
37
|
import { hasErrors } from "../core/check.js";
|
|
36
38
|
import { Tree } from "../core/tree.js";
|
|
37
39
|
import { treeBrowserConnectors } from "../core/connector.js";
|
|
40
|
+
import { browserNote } from "./connectors.js";
|
|
38
41
|
import { reviewProposals } from "../core/review.js";
|
|
39
42
|
function written(tree) {
|
|
40
43
|
return lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined);
|
|
@@ -161,7 +164,7 @@ register({
|
|
|
161
164
|
tree: true,
|
|
162
165
|
run(ctx) {
|
|
163
166
|
const all = ctx.tree().list("session").slice().reverse();
|
|
164
|
-
const cost = (s) => (s.metrics
|
|
167
|
+
const cost = (s) => costText(s.metrics);
|
|
165
168
|
return {
|
|
166
169
|
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",
|
|
167
170
|
data: { sessions: all },
|
|
@@ -186,7 +189,7 @@ register({
|
|
|
186
189
|
m.inputTokens && `${m.inputTokens} in`,
|
|
187
190
|
m.outputTokens && `${m.outputTokens} out`,
|
|
188
191
|
m.cacheReadTokens && `${m.cacheReadTokens} cache read`,
|
|
189
|
-
m
|
|
192
|
+
costText(m),
|
|
190
193
|
s.ended && `${Math.max(1, Math.round((Date.parse(s.ended) - Date.parse(s.started)) / 60000))} min`,
|
|
191
194
|
m.denied && `${m.denied} refused by permissions`,
|
|
192
195
|
]
|
|
@@ -210,7 +213,7 @@ register({
|
|
|
210
213
|
const tree = ctx.tree();
|
|
211
214
|
const s = currentSession(tree, ctx.env);
|
|
212
215
|
const task = args[0] ? requireRecord(tree, args[0], "task") : s?.task ? tree.get(s.task) : taskQueue(tree, { strategy: ctx.settings.strategy(tree.config) })[0];
|
|
213
|
-
const b = buildBrief(tree, { ...(task ? { task } : {}), ...(s ? { session: s } : {}), budget: ctx.settings.number("brief.budget", tree.config, DEFAULT_BUDGET), shared: ctx.settings.shared()?.value });
|
|
216
|
+
const b = buildBrief(tree, { ...(task ? { task } : {}), ...(s ? { session: s, deadline: deadlineOf(ctx.env) } : {}), budget: ctx.settings.number("brief.budget", tree.config, DEFAULT_BUDGET), shared: ctx.settings.shared()?.value });
|
|
214
217
|
if (opts.stats)
|
|
215
218
|
return {
|
|
216
219
|
text: lines(table(b.sections.map((x) => [x.name, `${x.tokens}`, x.cut ? "cut" : ""])), `total ~${b.total} tokens of ${b.budget}`),
|
|
@@ -381,20 +384,30 @@ register({
|
|
|
381
384
|
description: "One session by default; with --until, session after session until that time. The agent works headless with\n" +
|
|
382
385
|
"the tree's permissions (anything else is denied), or with the terminal (--interactive). Stops at the\n" +
|
|
383
386
|
"subscription limit, when the queue is empty, or at --until (a session already running finishes, within\n" +
|
|
384
|
-
"--minutes).
|
|
385
|
-
"
|
|
386
|
-
"
|
|
387
|
+
"--minutes). The agent knows when its session is stopped (the brief; strom's output counts down its last\n" +
|
|
388
|
+
"10 minutes, a short session its last quarter); at --minutes it is stopped, and Claude Code, Codex and OpenCode\n" +
|
|
389
|
+
"get 5 minutes more to write down what they found and close (an unfinished task goes back to the queue); a task\n" +
|
|
390
|
+
"that comes back twice in a row with nothing recorded is parked. --task picks the tasks (one session each, in that order;\n" +
|
|
391
|
+
"one done or held by another agent meanwhile is left out). --loop: session after session for as long as there\n" +
|
|
392
|
+
"is work. A gate (setting run.gate, or --gate; plugins/gates/<name>) is asked before each session of --loop and\n" +
|
|
393
|
+
"--until: go on, wait (the run waits and asks again) or stop — the user's condition, e.g. the subscription's room\n" +
|
|
394
|
+
"(strom gate list). Tasks started otherwise (one, --max, --task) are the user's choice: the gate is asked once,\n" +
|
|
395
|
+
"and when it would not start, the user is asked whether to start anyway (an agent: a window of the system).\n" +
|
|
396
|
+
"Arguments after -- go to the agent CLI unchanged.",
|
|
387
397
|
options: [
|
|
388
398
|
{ 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)" },
|
|
389
399
|
{ name: "research", type: "string", value: "<G…>", description: "only tasks of this research" },
|
|
390
400
|
{ name: "max", type: "string", value: "<n>", description: "at most n sessions (default 1; with --until, no limit)" },
|
|
391
401
|
{ name: "until", type: "string", value: "<HH:MM>", description: "start no session after this time" },
|
|
402
|
+
{ name: "loop", type: "boolean", description: "session after session for as long as there is work (and the gate lets it)" },
|
|
403
|
+
{ 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
|
+
{ name: "no-gate", type: "boolean", description: "ask no gate this time (only you: an agent cannot)" },
|
|
392
405
|
{ name: "minutes", type: "string", value: "<n>", description: `time limit of one session (default: run.minutes, ${DEFAULT_RUN_MINUTES})` },
|
|
393
406
|
{ name: "budget", type: "string", value: "<tokens>", description: `brief size (default: brief.budget, ${DEFAULT_BUDGET})` },
|
|
394
407
|
{ name: "model", type: "string", value: "<model>", description: "model of the main agent (default: model.lead)" },
|
|
395
408
|
{ name: "interactive", type: "boolean", description: "give the agent the terminal (you can talk to it)" },
|
|
396
409
|
],
|
|
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"],
|
|
410
|
+
examples: ["strom run", "strom run --max 3 --until 23:00", "strom run --loop", "strom run --task T0003,T0007", "strom run --interactive", "strom run --agent codex", "strom run --minutes 30 -- --add-dir ~/Scans"],
|
|
398
411
|
run: async (ctx, { opts, extra }) => {
|
|
399
412
|
const root = ctx.tree().root;
|
|
400
413
|
const treeCfg = ctx.tree().config;
|
|
@@ -408,10 +421,11 @@ register({
|
|
|
408
421
|
const until = parseUntil(opts.until);
|
|
409
422
|
// Tasks the user picked: one session each, in their order (then the queue, when --max asks for more).
|
|
410
423
|
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);
|
|
424
|
+
const max = opts.max === undefined ? (picked.length ? picked.length : until || opts.loop ? Infinity : 1) : Number(opts.max);
|
|
412
425
|
if (max !== Infinity && (!Number.isInteger(max) || max < 1))
|
|
413
426
|
throw new UsageError("--max must be a positive number");
|
|
414
427
|
const minutes = ctx.settings.number("run.minutes", treeCfg, DEFAULT_RUN_MINUTES);
|
|
428
|
+
const gate = runGate(ctx, opts);
|
|
415
429
|
const budget = ctx.settings.number("brief.budget", treeCfg, DEFAULT_BUDGET);
|
|
416
430
|
// Each run has a name of its own (as a conversation does): several work side by side, never on one task.
|
|
417
431
|
const worker = `run-${process.pid}-${Date.now().toString(36)}`;
|
|
@@ -419,10 +433,12 @@ register({
|
|
|
419
433
|
// Present in the tree for the others at work (conversations with agents): strom shows who works.
|
|
420
434
|
const beside = runsAtWork(root);
|
|
421
435
|
const leave = enterWorker(root, worker, `${PROFILES[runnerId]?.name ?? runnerId} on its own`);
|
|
422
|
-
|
|
436
|
+
let stopAwake = keepAwake(ctx.env);
|
|
423
437
|
// Progress goes to stderr when stdout carries JSON.
|
|
424
438
|
const out = (s) => (ctx.json ? ctx.io.stderr : ctx.io.stdout)(s + "\n");
|
|
425
439
|
const report = [];
|
|
440
|
+
// What the gate answered, for the record of the run.
|
|
441
|
+
const gates = [];
|
|
426
442
|
// Why the run stopped: a code for the exit status and for data, words for the user (their language).
|
|
427
443
|
const lang = Tree.open(root, runEnv).lang;
|
|
428
444
|
let stopCode = "done";
|
|
@@ -464,8 +480,18 @@ register({
|
|
|
464
480
|
out(ui(lang, "ui.run.browser", { name: c.manifest.title ?? c.name, hosts: c.manifest.hosts.join(", ") }));
|
|
465
481
|
if (browser.length && /haiku/i.test(models.lead ?? ""))
|
|
466
482
|
out(ui(lang, "ui.run.browser.model", { model: models.lead }));
|
|
483
|
+
// Archives through the browser with an agent that has no browser tools, or without the extension: said before it starts.
|
|
484
|
+
const browserSays = browserNote(ctx, Tree.open(root, runEnv), runnerId);
|
|
485
|
+
if (browserSays)
|
|
486
|
+
out(browserSays);
|
|
467
487
|
if (permissions === "full")
|
|
468
488
|
out(ui(lang, "ui.run.full"));
|
|
489
|
+
// The gate holds a run that goes on by itself (--loop, --until). Tasks the user starts themselves (one, --max n,
|
|
490
|
+
// --task) are their choice: the gate is asked once, and when it would not start, the user decides.
|
|
491
|
+
const gateHolds = Boolean(opts.loop || until);
|
|
492
|
+
let gateAnswered = false;
|
|
493
|
+
if (gate)
|
|
494
|
+
out(ui(lang, gateHolds ? "ui.run.gate" : "ui.run.gate.once", { name: gate.manifest.title ?? gate.name }));
|
|
469
495
|
for (let i = 0; i < max; i++) {
|
|
470
496
|
if (stop.signal.aborted) {
|
|
471
497
|
stopCode = "user";
|
|
@@ -500,9 +526,49 @@ register({
|
|
|
500
526
|
stopCode = "empty";
|
|
501
527
|
break;
|
|
502
528
|
}
|
|
529
|
+
// The user's condition, between sessions only: go on, wait and ask again, or stop — in a run that goes on by
|
|
530
|
+
// itself. Tasks the user started themselves: asked once, and the user decides when it would not start.
|
|
531
|
+
if (gate && !gateAnswered) {
|
|
532
|
+
const said = askGate(gate, runEnv, { tree: root, lang, agent: runnerId, model: models.lead, sessions: report.length, costUsd: report.reduce((a, r) => a + (r.costUsd ?? 0), 0), nextTask: task.id });
|
|
533
|
+
const answer = { at: new Date().toISOString(), verdict: said.verdict, ...(said.reason ? { reason: said.reason } : {}) };
|
|
534
|
+
gates.push(answer);
|
|
535
|
+
const name = gate.manifest.title ?? gate.name;
|
|
536
|
+
if (!gateHolds) {
|
|
537
|
+
gateAnswered = true;
|
|
538
|
+
if (said.verdict !== "go") {
|
|
539
|
+
if (!(await startAnyway(ctx, name, gateReason(said, lang), lang))) {
|
|
540
|
+
stopCode = "gate.declined";
|
|
541
|
+
stopValues = { name, reason: gateReason(said, lang) };
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
answer.anyway = true;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
else if (said.verdict === "stop" || said.verdict === "error") {
|
|
548
|
+
stopCode = said.verdict === "stop" ? "gate" : "gate.error";
|
|
549
|
+
stopValues = { name, reason: said.reason ?? "" };
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
else if (said.verdict === "wait") {
|
|
553
|
+
const at = Date.now() + said.waitMs;
|
|
554
|
+
if (until && at > until) {
|
|
555
|
+
stopCode = "time";
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
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
|
+
// nothing to do meanwhile: the computer may sleep
|
|
560
|
+
stopAwake();
|
|
561
|
+
await pause(at - Date.now(), stop.signal);
|
|
562
|
+
stopAwake = keepAwake(ctx.env);
|
|
563
|
+
i--; // this was no session: ask again, with the queue as it is then
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
503
567
|
const session = startSession(tree, { task, ...(research ? { research: research.id } : {}), runner: runnerId, ...(models.lead ? { model: models.lead } : {}) });
|
|
504
568
|
commitNow(tree, `${session.id} session started on ${task.id}`);
|
|
505
|
-
|
|
569
|
+
// 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
|
+
const deadline = Date.now() + minutes * 60_000;
|
|
571
|
+
const brief = buildBrief(tree, { task, session, budget, shared: ctx.settings.shared()?.value, deadline });
|
|
506
572
|
const briefFile = path.join(root, ".strom", "briefs", `${session.id}.md`);
|
|
507
573
|
fs.mkdirSync(path.dirname(briefFile), { recursive: true });
|
|
508
574
|
fs.writeFileSync(briefFile, brief.text);
|
|
@@ -514,6 +580,8 @@ register({
|
|
|
514
580
|
const env = {
|
|
515
581
|
...prependPath(runEnv, bin),
|
|
516
582
|
STROM_SESSION: session.id,
|
|
583
|
+
STROM_DEADLINE: new Date(deadline).toISOString(),
|
|
584
|
+
STROM_MINUTES: String(minutes),
|
|
517
585
|
...(opts.interactive ? {} : { STROM_NONINTERACTIVE: "1" }),
|
|
518
586
|
};
|
|
519
587
|
const result = await runner.run({
|
|
@@ -525,11 +593,17 @@ register({
|
|
|
525
593
|
name: ["Strom", tree.config.name, tree.get(task.research ?? research?.id ?? "")?.name, `${truncate(task.what, 50)} (${task.id})`].filter(Boolean).join(" · "),
|
|
526
594
|
logFile: path.join(root, ".strom", "runs", `${session.id}.log`),
|
|
527
595
|
timeoutMs: minutes * 60_000,
|
|
596
|
+
wrapUp: {
|
|
597
|
+
ms: WRAP_UP_MS,
|
|
598
|
+
prompt: `Time is up: strom stopped you at the session's limit (${minutes} min). You have ${WRAP_UP_MS / 60_000} minutes, no more. Do not open anything new: ` +
|
|
599
|
+
"record in strom what you found and have not recorded yet (facts, sources, the images searched, in vain too), " +
|
|
600
|
+
`then \`strom session close --continue --summary "…" --next "exactly where you stopped"\` (or finish the task, if it is done).`,
|
|
601
|
+
},
|
|
528
602
|
settingsFile: path.join(root, ".claude", "settings.json"),
|
|
529
603
|
shared: ctx.settings.shared()?.value,
|
|
530
604
|
...(models.lead ? { model: models.lead } : {}),
|
|
531
605
|
...(opts.interactive ? { interactive: true } : {}),
|
|
532
|
-
...(runnerId === "claude" ? { chrome: browser.length > 0, permissions } : {}),
|
|
606
|
+
...(runnerId === "claude" ? { chrome: browser.length > 0, permissions, remote: ctx.settings.agentRemote() } : {}),
|
|
533
607
|
...(extra?.length ? { extraArgs: extra } : {}),
|
|
534
608
|
onProgress: (l) => out(` · ${l}`),
|
|
535
609
|
signal: stop.signal,
|
|
@@ -555,10 +629,11 @@ register({
|
|
|
555
629
|
const back = after.get(task.id);
|
|
556
630
|
const idle = back && back.state === "open" ? idleSessions(after, task.id) : 0;
|
|
557
631
|
if (idle >= MAX_IDLE_SESSIONS) {
|
|
558
|
-
|
|
632
|
+
const why = `${idle} sessions in a row recorded nothing for it — needs a look (strom task show ${task.id})`;
|
|
633
|
+
update(after, task.id, "task", (t) => ({ ...t, state: "parked", parkedReason: why }), {
|
|
559
634
|
op: "task.park",
|
|
560
635
|
summary: `${task.id} park: ${idle} sessions without a result`,
|
|
561
|
-
reason:
|
|
636
|
+
reason: why,
|
|
562
637
|
});
|
|
563
638
|
out(ui(lang, "ui.run.parked", { task: task.id, n: idle }));
|
|
564
639
|
}
|
|
@@ -567,7 +642,7 @@ register({
|
|
|
567
642
|
const geds = writeGedcoms(ctx, after);
|
|
568
643
|
const committed = commitNow(after, `${s.id} ${s.state}: ${truncate(s.summary ?? "", 60)} · ${geds.map((g) => after.relative(g.file)).join(", ")}`);
|
|
569
644
|
report.push({ session: s.id, task: task.id, outcome: result.outcome, ...(s.summary ? { summary: s.summary } : {}), ...(result.metrics.costUsd !== undefined ? { costUsd: result.metrics.costUsd } : {}) });
|
|
570
|
-
out(`■ ${s.id} ${s.state} · ${result.outcome}${result.metrics
|
|
645
|
+
out(`■ ${s.id} ${s.state} · ${result.outcome}${costText(result.metrics) ? ` · ${costText(result.metrics)}` : ""}${s.summary ? ` · ${truncate(s.summary, 80)}` : ""}`);
|
|
571
646
|
if (result.denied?.length)
|
|
572
647
|
out(ui(lang, "ui.run.denied", { n: result.denied.length, what: result.denied.slice(0, 3).join(" · ") }));
|
|
573
648
|
if (!committed) {
|
|
@@ -611,11 +686,79 @@ register({
|
|
|
611
686
|
const reason = ui(lang, `ui.run.stop.${stopCode}`, stopValues);
|
|
612
687
|
return {
|
|
613
688
|
text: lines(ui(lang, "ui.run.summary", { n: report.length, reason }), waiting ? `\n${waiting}` : undefined),
|
|
614
|
-
data: { sessions: report, stopped: reason, stop: stopCode },
|
|
615
|
-
exitCode: ["failed", "problems", "auth", "denied"].includes(stopCode) ? 1 : 0,
|
|
689
|
+
data: { sessions: report, stopped: reason, stop: stopCode, ...(gate ? { gate: { name: gate.name, answers: gates } } : {}) },
|
|
690
|
+
exitCode: ["failed", "problems", "auth", "denied", "gate.error"].includes(stopCode) ? 1 : 0,
|
|
616
691
|
};
|
|
617
692
|
},
|
|
618
693
|
});
|
|
694
|
+
/**
|
|
695
|
+
* The gate this run asks (run.gate, or --gate), or none (--no-gate). Going round the user's gate is the user's
|
|
696
|
+
* decision: an agent asks them (a window of the system).
|
|
697
|
+
*/
|
|
698
|
+
function runGate(ctx, opts) {
|
|
699
|
+
const set = ctx.settings.runGate();
|
|
700
|
+
const asked = typeof opts.gate === "string" ? opts.gate : undefined;
|
|
701
|
+
if (opts["no-gate"]) {
|
|
702
|
+
if (set && isAgent(ctx.env))
|
|
703
|
+
ctx.requireHuman(`Let the agent work on its own without the gate "${set}"?`, "strom run --no-gate", "run.gate", ui(ctx.uiLang(), "ui.consent.gate.skip", { name: set }));
|
|
704
|
+
return undefined;
|
|
705
|
+
}
|
|
706
|
+
const name = asked ?? set;
|
|
707
|
+
if (!name)
|
|
708
|
+
return undefined;
|
|
709
|
+
if (asked && set && asked !== set && isAgent(ctx.env))
|
|
710
|
+
ctx.requireHuman(`Ask the gate "${asked}" instead of "${set}"?`, `strom run --gate ${asked}`, "run.gate", ui(ctx.uiLang(), "ui.consent.gate.set", { name: asked }));
|
|
711
|
+
const shared = ctx.settings.shared()?.value;
|
|
712
|
+
if (!shared)
|
|
713
|
+
throw new StromError("no shared folder, so no gates", { hint: "strom setup" });
|
|
714
|
+
ensureGatesDir(shared);
|
|
715
|
+
return loadGate(shared, name);
|
|
716
|
+
}
|
|
717
|
+
/** Why the gate would not start, for the user: its own words, else what it answered. */
|
|
718
|
+
function gateReason(said, lang) {
|
|
719
|
+
return said.reason ?? ui(lang, said.verdict === "error" ? "ui.run.gate.noanswer" : said.verdict === "wait" ? "ui.run.gate.later" : "ui.run.gate.no");
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Tasks the user started themselves while the gate would not start: the user decides — on a terminal, a question;
|
|
723
|
+
* asked by an agent, a window of the system (going round the user's condition is never the agent's decision). A run
|
|
724
|
+
* nobody can ask keeps to the gate.
|
|
725
|
+
*/
|
|
726
|
+
async function startAnyway(ctx, name, reason, lang) {
|
|
727
|
+
const question = ui(lang, "ui.run.gate.anyway", { name, reason });
|
|
728
|
+
if (ctx.interactive && !isAgent(ctx.env))
|
|
729
|
+
return ctx.confirm(question, false);
|
|
730
|
+
if (!isAgent(ctx.env))
|
|
731
|
+
return false;
|
|
732
|
+
try {
|
|
733
|
+
ctx.requireHuman(`Start the tasks although the condition "${name}" would not (${reason})?`, "strom run --no-gate", "run.gate", question);
|
|
734
|
+
return true;
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
if (err instanceof NeedsConsentError)
|
|
738
|
+
throw err; // nobody to ask: the user runs it themselves
|
|
739
|
+
return false; // the user said no in the window
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
/** Wait, until the time or until the user stops the run. */
|
|
743
|
+
function pause(ms, signal) {
|
|
744
|
+
return new Promise((resolve) => {
|
|
745
|
+
if (signal.aborted || ms <= 0)
|
|
746
|
+
return resolve();
|
|
747
|
+
const t = setTimeout(done, ms);
|
|
748
|
+
signal.addEventListener("abort", done, { once: true });
|
|
749
|
+
function done() {
|
|
750
|
+
clearTimeout(t);
|
|
751
|
+
signal.removeEventListener("abort", done);
|
|
752
|
+
resolve();
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
/** What a session cost: "$1.20"; "$0.30+" or "cost unknown" when the agent was stopped before it said. */
|
|
757
|
+
function costText(m) {
|
|
758
|
+
if (m?.costPartial)
|
|
759
|
+
return m.costUsd ? `$${m.costUsd.toFixed(2)}+` : "cost unknown";
|
|
760
|
+
return m?.costUsd !== undefined ? `$${m.costUsd.toFixed(2)}` : "";
|
|
761
|
+
}
|
|
619
762
|
/** How the user clears the agent's context, in the agent's own words. */
|
|
620
763
|
const CLEAR = { claude: "/clear", codex: "/new", opencode: "/new" };
|
|
621
764
|
/** 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). */
|
package/dist/commands/setup.js
CHANGED
|
@@ -19,6 +19,7 @@ import { installation, stromLauncher } from "../core/self.js";
|
|
|
19
19
|
import { VERSION } from "../core/tree.js";
|
|
20
20
|
import { installUpdate, isNewer, knownNewerVersion, latestVersion, newerNode, newerVersion } from "../core/update.js";
|
|
21
21
|
import { desktopDir } from "../core/paths.js";
|
|
22
|
+
import { planMove } from "../core/relocate.js";
|
|
22
23
|
import { researchUrl, stromAppUrl, stromAppState } from "../core/stromapp.js";
|
|
23
24
|
import { isInstalled } from "../agents/global.js";
|
|
24
25
|
import { shortcutName } from "../cli/wizard.js";
|
|
@@ -30,12 +31,17 @@ import { NeedsInputError, StromError, UsageError } from "../core/errors.js";
|
|
|
30
31
|
import { check } from "../core/check.js";
|
|
31
32
|
import { assertIntact, verifyFull } from "../core/integrity.js";
|
|
32
33
|
import { ensurePluginsDir } from "../core/connector.js";
|
|
33
|
-
import {
|
|
34
|
+
import { ensureGatesDir, loadGate } from "../core/gate.js";
|
|
35
|
+
import { ensureHooksDir } from "../core/hooks.js";
|
|
36
|
+
import { claudeInChrome, CLAUDE_IN_CHROME_URL, downloadsDir } from "../core/browser.js";
|
|
37
|
+
import { browserConnectors } from "../core/connector.js";
|
|
34
38
|
export const SHARED_DIRS = ["media", "catalog", "tools", "cache", "inbox"];
|
|
35
39
|
export function ensureShared(dir) {
|
|
36
40
|
for (const d of SHARED_DIRS)
|
|
37
41
|
fs.mkdirSync(path.join(dir, d), { recursive: true });
|
|
38
42
|
ensurePluginsDir(dir);
|
|
43
|
+
ensureGatesDir(dir);
|
|
44
|
+
ensureHooksDir(dir);
|
|
39
45
|
}
|
|
40
46
|
register({
|
|
41
47
|
path: ["setup"],
|
|
@@ -67,6 +73,12 @@ register({
|
|
|
67
73
|
set: `strom setup --home "${ctx.display(suggestedHome)}" --yes`,
|
|
68
74
|
},
|
|
69
75
|
]);
|
|
76
|
+
if (cfg.home && flags.home)
|
|
77
|
+
guardResearchFolder(ctx, "home", flags.home);
|
|
78
|
+
if (flags.shared)
|
|
79
|
+
guardResearchFolder(ctx, "shared", flags.shared);
|
|
80
|
+
if (flags.trees)
|
|
81
|
+
guardResearchFolder(ctx, "trees", flags.trees);
|
|
70
82
|
let home = flags.home ? ctx.resolvePath(flags.home) : suggestedHome;
|
|
71
83
|
let shared = flags.shared ? ctx.resolvePath(flags.shared) : undefined;
|
|
72
84
|
let trees = flags.trees ? ctx.resolvePath(flags.trees) : undefined;
|
|
@@ -187,6 +199,19 @@ function diagnose(ctx) {
|
|
|
187
199
|
if (model)
|
|
188
200
|
add("model", "ok", model);
|
|
189
201
|
}
|
|
202
|
+
// Archives through the browser: only Claude Code has browser tools, and they work through the Claude in Chrome extension.
|
|
203
|
+
const shared = ctx.settings.shared()?.value;
|
|
204
|
+
const viaBrowser = shared ? browserConnectors(ctx.env, shared) : [];
|
|
205
|
+
if (viaBrowser.length) {
|
|
206
|
+
const names = viaBrowser.map((c) => c.manifest.title ?? c.name).join(", ");
|
|
207
|
+
const ext = claudeInChrome(ctx.env).extension;
|
|
208
|
+
if (chosen !== "claude")
|
|
209
|
+
add("browser", "warn", t("ui.doc.browser.agent", { names, agent: PROFILES[chosen]?.name ?? chosen }), found.includes("claude") ? "strom agents use claude" : undefined);
|
|
210
|
+
else if (ext.length)
|
|
211
|
+
add("browser", "ok", t("ui.doc.browser.ok", { browsers: ext.join(", ") }));
|
|
212
|
+
else
|
|
213
|
+
add("browser", "warn", t("ui.doc.browser.noext", { names }), CLAUDE_IN_CHROME_URL);
|
|
214
|
+
}
|
|
190
215
|
// For a person: the shortcut on the desktop, the Strom app.
|
|
191
216
|
const desktop = desktopDir(ctx.env);
|
|
192
217
|
const shortcut = [`${shortcutName(lang)}.command`, `${shortcutName(lang)}.lnk`, `${shortcutName(lang)}.cmd`, "strom-research.desktop"].some((f) => fs.existsSync(path.join(desktop, f)));
|
|
@@ -389,14 +414,45 @@ function raises(now, value) {
|
|
|
389
414
|
return PERMISSION_LEVELS.indexOf(next) > PERMISSION_LEVELS.indexOf(now);
|
|
390
415
|
}
|
|
391
416
|
/** Change a user setting (the config file on this computer). */
|
|
417
|
+
/**
|
|
418
|
+
* Where the research lives (home, trees, shared) changes only with the research moving along — which the person does in
|
|
419
|
+
* the wizard (strom setup in a terminal, the menu's settings). A folder set to another place with the research left in
|
|
420
|
+
* the old one would lose the trees from the list and the plugins and images from sight.
|
|
421
|
+
*/
|
|
422
|
+
function guardResearchFolder(ctx, key, next) {
|
|
423
|
+
const now = key === "home" ? ctx.settings.home()?.value : key === "trees" ? ctx.settings.trees()?.value : key === "shared" ? ctx.settings.shared()?.value : undefined;
|
|
424
|
+
// unset: back to the default under home (none for home itself)
|
|
425
|
+
const home = ctx.settings.home()?.value;
|
|
426
|
+
const target = next !== undefined ? ctx.resolvePath(next) : key === "shared" && home ? path.join(home, "shared") : key === "trees" ? home : undefined;
|
|
427
|
+
if (!now || (target && path.resolve(target) === path.resolve(now)))
|
|
428
|
+
return;
|
|
429
|
+
if (!planMove(now, target ?? now, []).content)
|
|
430
|
+
return;
|
|
431
|
+
throw new UsageError(`the research is in ${ctx.display(now)} — changing ${key} would leave it behind`, {
|
|
432
|
+
hint: "the user moves it: strom setup in their terminal (or the menu: Settings → the setup), which moves the trees and the shared folder along",
|
|
433
|
+
});
|
|
434
|
+
}
|
|
392
435
|
function setUserSetting(ctx, key, value) {
|
|
393
436
|
const s = ctx.settings;
|
|
437
|
+
guardResearchFolder(ctx, key, value === undefined ? undefined : String(value));
|
|
394
438
|
// Loosening the agent's permissions is the user's decision alone.
|
|
395
439
|
if (key === "agent.permissions" && raises(s.agentPermissions(), value))
|
|
396
440
|
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"));
|
|
441
|
+
// Sessions steered from elsewhere (Remote Control): the user's decision alone.
|
|
442
|
+
if (key === "agent.remote" && value === "on" && !s.agentRemote())
|
|
443
|
+
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"));
|
|
397
444
|
// Asking before a connector runs is the user's safeguard: only they take it away.
|
|
398
445
|
if (key === "connectors.consent" && value !== "on" && s.connectorsConsent())
|
|
399
446
|
ctx.requireHuman("Let connectors run without asking you first?", `strom config set connectors.consent ${value ?? "off"}`, "connectors.consent", ui(ctx.uiLang(), "ui.consent.connectors.off"));
|
|
447
|
+
// The gate decides what working alone spends: set and taken away by the user alone.
|
|
448
|
+
if (key === "run.gate" && value !== s.runGate())
|
|
449
|
+
ctx.requireHuman(value ? `Let the gate "${value}" decide when the agent working alone goes on?` : `Remove the gate "${s.runGate()}" — the agent working alone no longer asks it?`, value ? `strom config set run.gate ${value}` : "strom config unset run.gate", "run.gate", ui(ctx.uiLang(), value ? "ui.consent.gate.set" : "ui.consent.gate.unset", { name: String(value ?? s.runGate()) }));
|
|
450
|
+
// a gate that is not there (or not a gate) is said now, not at the next run
|
|
451
|
+
const sh = s.shared()?.value;
|
|
452
|
+
if (key === "run.gate" && typeof value === "string" && sh) {
|
|
453
|
+
ensureGatesDir(sh);
|
|
454
|
+
loadGate(sh, value);
|
|
455
|
+
}
|
|
400
456
|
writeStored(s.config, key, s.agent(ctx.hasTree() ? ctx.tree().config : undefined).value, value);
|
|
401
457
|
s.save();
|
|
402
458
|
if (key === "shared" && typeof value === "string")
|
package/dist/commands/sources.js
CHANGED
|
@@ -245,6 +245,23 @@ register({
|
|
|
245
245
|
},
|
|
246
246
|
});
|
|
247
247
|
// ── repositories ───────────────────────────────────────────────────────────
|
|
248
|
+
/** The website's host without "www.", lowercase: https://www.FamilySearch.org/search → familysearch.org. */
|
|
249
|
+
function siteOf(url) {
|
|
250
|
+
if (!url)
|
|
251
|
+
return undefined;
|
|
252
|
+
try {
|
|
253
|
+
return new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? url : `https://${url}`).hostname.toLowerCase().replace(/^www\./, "");
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** An archive already here under this name or on this website. */
|
|
260
|
+
export function sameRepository(all, name, url) {
|
|
261
|
+
const folded = foldText(name);
|
|
262
|
+
const site = siteOf(url);
|
|
263
|
+
return all.find((r) => foldText(r.name) === folded || (site !== undefined && siteOf(r.url) === site));
|
|
264
|
+
}
|
|
248
265
|
register({
|
|
249
266
|
path: ["repo", "add"],
|
|
250
267
|
summary: "Add an archive, library or portal that holds records",
|
|
@@ -260,10 +277,17 @@ register({
|
|
|
260
277
|
{ name: "terms", type: "string", value: "<text>", description: "terms of use in one sentence (+ link)" },
|
|
261
278
|
{ name: "automation", type: "string", value: "<a>", description: "allowed, manual (browser only), forbidden, unknown (default)" },
|
|
262
279
|
{ name: "note", type: "string", value: "<text>", description: "short note" },
|
|
280
|
+
{ name: "another", type: "boolean", description: "a different archive, though one here has its name or website" },
|
|
263
281
|
],
|
|
264
282
|
examples: ['strom repo add "State Archive, online reading room" --country CZ --url https://archive.example.org --automation manual'],
|
|
265
283
|
run(ctx, { args, opts }) {
|
|
266
284
|
const tree = ctx.tree();
|
|
285
|
+
// One archive, one record: a second "FamilySearch" splits its books, terms and lessons in two (found in a live run).
|
|
286
|
+
const twin = opts.another ? undefined : sameRepository(tree.list("repository"), args[0], str(opts.url));
|
|
287
|
+
if (twin)
|
|
288
|
+
throw new UsageError(`${twin.id} "${twin.name}" is this archive already${twin.url ? ` (${twin.url})` : ""}`, {
|
|
289
|
+
hint: `use it: --repo ${twin.id} (strom repo show ${twin.id}) — a different archive after all: add --another`,
|
|
290
|
+
});
|
|
267
291
|
const r = create(tree, "repository", {
|
|
268
292
|
name: args[0].trim(),
|
|
269
293
|
country: str(opts.country)?.toUpperCase(),
|
package/dist/commands/start.js
CHANGED
|
@@ -17,6 +17,7 @@ import { AGENTS, findAgent, isAgent } from "../core/which.js";
|
|
|
17
17
|
import { DESKTOP_APPS, agentsHere, whereToTalk } from "../core/apps.js";
|
|
18
18
|
import { assertIntact } from "../core/integrity.js";
|
|
19
19
|
import { treeBrowserConnectors } from "../core/connector.js";
|
|
20
|
+
import { browserNote } from "./connectors.js";
|
|
20
21
|
import { appOpensResearch, importAppUrl, installedStromApp, liveAppUrl, noticeStromApp, STROM_APP_URL, stromAppUrl } from "../core/stromapp.js";
|
|
21
22
|
import { liveRunning, serveLive, startLive, stopLive } from "../core/live.js";
|
|
22
23
|
import { openForUser } from "../core/open.js";
|
|
@@ -131,7 +132,7 @@ register({
|
|
|
131
132
|
// its research when there is one, the day.
|
|
132
133
|
name: ["Strom", tree.config.name, researches.length === 1 ? researches[0].name : undefined, new Date().toLocaleDateString(lang, { day: "numeric", month: "numeric" })].filter(Boolean).join(" · "),
|
|
133
134
|
shared,
|
|
134
|
-
...(agent === "claude" ? { chrome: treeBrowserConnectors(tree, shared).length > 0 } : {}),
|
|
135
|
+
...(agent === "claude" ? { chrome: treeBrowserConnectors(tree, shared).length > 0, remote: ctx.settings.agentRemote() } : {}),
|
|
135
136
|
};
|
|
136
137
|
const args = conversationArgs(agent, base);
|
|
137
138
|
if (opts.print)
|
|
@@ -147,6 +148,9 @@ register({
|
|
|
147
148
|
const worker = `${agent}-${process.pid}-${Date.now().toString(36)}`;
|
|
148
149
|
const leave = enterWorker(tree.root, worker, `${profile.name} conversation`);
|
|
149
150
|
ctx.io.stdout(ui(lang, "ui.chat.open", { agent: profile.name, exit: profile.exit }) + "\n");
|
|
151
|
+
const browserSays = browserNote(ctx, tree, agent);
|
|
152
|
+
if (browserSays)
|
|
153
|
+
ctx.io.stdout(browserSays + "\n");
|
|
150
154
|
// Claude Code asks once whether the folder is to be trusted — its own safety step, kept; the user knows what to answer.
|
|
151
155
|
if (agent === "claude" && !claudeTrusts(tree.root, ctx.env))
|
|
152
156
|
ctx.io.stdout(ui(lang, "ui.chat.trust") + "\n");
|
package/dist/commands/tasks.js
CHANGED
|
@@ -16,6 +16,8 @@ import { foldText } from "../core/text.js";
|
|
|
16
16
|
import { now, typeOfId } from "../core/tree.js";
|
|
17
17
|
import { resolveResearch } from "./research.js";
|
|
18
18
|
import { makeNote } from "../core/actions.js";
|
|
19
|
+
import { phrase } from "../core/phrases.js";
|
|
20
|
+
import { readJsonLines } from "../core/json.js";
|
|
19
21
|
import { lacksImages, rankTasks } from "../core/queue.js";
|
|
20
22
|
import { clipNote, inboxFolderFor, parseImageList, transcriptNote } from "../core/media.js";
|
|
21
23
|
function written(tree) {
|
|
@@ -82,6 +84,25 @@ export function rankedQueue(tree, filter = {}) {
|
|
|
82
84
|
function taskLine(t) {
|
|
83
85
|
return [t.id, `p${t.priority}`, t.level, t.state === "open" ? "" : `[${t.state}]`, truncate(t.what, 70)];
|
|
84
86
|
}
|
|
87
|
+
/** Why a task was put aside: on the task, or — parked by an older strom — in the history. */
|
|
88
|
+
export function parkedWhy(tree, t) {
|
|
89
|
+
if (t.state !== "parked")
|
|
90
|
+
return undefined;
|
|
91
|
+
if (t.parkedReason)
|
|
92
|
+
return t.parkedReason;
|
|
93
|
+
const dir = path.join(tree.dataDir, "ops");
|
|
94
|
+
let last;
|
|
95
|
+
try {
|
|
96
|
+
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith(".jsonl")))
|
|
97
|
+
for (const o of readJsonLines(path.join(dir, f)))
|
|
98
|
+
if (o.op === "task.park" && o.targets.includes(t.id) && (!last || o.at > last.at))
|
|
99
|
+
last = o;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// no history to read
|
|
103
|
+
}
|
|
104
|
+
return last?.reason;
|
|
105
|
+
}
|
|
85
106
|
function taskDetail(tree, t) {
|
|
86
107
|
const where = t.where.map((w) => {
|
|
87
108
|
const b = /^B\d{4,}$/.test(w) ? tree.get(w) : undefined;
|
|
@@ -89,7 +110,7 @@ function taskDetail(tree, t) {
|
|
|
89
110
|
});
|
|
90
111
|
const searches = tree.list("search").filter((s) => s.task === t.id || s.recordsets.some((b) => t.where.includes(b)));
|
|
91
112
|
const lessons = tree.list("lesson").filter((l) => l.target && t.where.includes(l.target));
|
|
92
|
-
return lines(`${t.id} ${t.what}`, `level ${t.level} · priority ${t.priority} · ${t.state}${t.research ? ` · research ${t.research}` : ""}${t.parkedUntil ? ` · parked until ${t.parkedUntil}` : ""}${t.waitingOn ? ` · waiting on ${t.waitingOn}` : ""}`, `where ${where.join("\n ")}`, `why ${t.why}`, `done ${t.doneWhen}`, t.subject.length ? `about ${t.subject.join(" ")}` : undefined, t.result ? `result ${t.result}${t.produced?.length ? ` → ${t.produced.join(" ")}` : ""}` : undefined, searches.length ? "\nalready searched there\n" + table(searches.map((s) => [` ${s.id}`, s.result, truncate(s.question, 60), s.scope.years ?? ""])) : undefined, lessons.length ? "\nlessons for these record sets\n" + lessons.map((l) => ` ${l.id} ${l.rule}`).join("\n") : undefined, ...t.notes.map((n) => `note ${n.text}`));
|
|
113
|
+
return lines(`${t.id} ${t.what}`, `level ${t.level} · priority ${t.priority} · ${t.state}${t.research ? ` · research ${t.research}` : ""}${t.parkedUntil ? ` · parked until ${t.parkedUntil}` : ""}${t.waitingOn ? ` · waiting on ${t.waitingOn}` : ""}`, t.state === "parked" ? `parked ${parkedWhy(tree, t) ?? "(no reason recorded)"} — strom task wake ${t.id} to go on` : undefined, `where ${where.join("\n ")}`, `why ${t.why}`, `done ${t.doneWhen}`, t.subject.length ? `about ${t.subject.join(" ")}` : undefined, t.result ? `result ${t.result}${t.produced?.length ? ` → ${t.produced.join(" ")}` : ""}` : undefined, searches.length ? "\nalready searched there\n" + table(searches.map((s) => [` ${s.id}`, s.result, truncate(s.question, 60), s.scope.years ?? ""])) : undefined, lessons.length ? "\nlessons for these record sets\n" + lessons.map((l) => ` ${l.id} ${l.rule}`).join("\n") : undefined, ...t.notes.map((n) => `note ${n.text}`));
|
|
93
114
|
}
|
|
94
115
|
function stateChange(tree, ref, state, fields, reason, verb) {
|
|
95
116
|
const id = normId(ref, "task");
|
|
@@ -97,8 +118,12 @@ function stateChange(tree, ref, state, fields, reason, verb) {
|
|
|
97
118
|
if (t.state === "done" && state !== "open")
|
|
98
119
|
throw new UsageError(`${id} is already done`);
|
|
99
120
|
const next = { ...t, state, ...fields };
|
|
100
|
-
if (state !== "parked")
|
|
121
|
+
if (state !== "parked") {
|
|
101
122
|
delete next.parkedUntil;
|
|
123
|
+
delete next.parkedReason;
|
|
124
|
+
}
|
|
125
|
+
else if (reason)
|
|
126
|
+
next.parkedReason = reason;
|
|
102
127
|
if (state !== "waiting") {
|
|
103
128
|
delete next.waitingOn;
|
|
104
129
|
delete next.awaits;
|
|
@@ -401,10 +426,22 @@ register({
|
|
|
401
426
|
group: "tasks",
|
|
402
427
|
tree: true,
|
|
403
428
|
writes: true,
|
|
429
|
+
description: "--answer: what the user found or decided for a task that waited for them (a link, a book's number) — kept on\nthe task with what it asked, so the agent reads both in its brief.",
|
|
404
430
|
args: [{ name: "task", description: "task ID", required: true }],
|
|
405
|
-
|
|
431
|
+
options: [{ name: "answer", type: "string", value: "<text>", description: "the user's answer to what the task waited for" }],
|
|
432
|
+
examples: ["strom task wake T0002", 'strom task wake T0003 --answer "https://archive.example.org/book/5359"'],
|
|
433
|
+
run(ctx, { args, opts }) {
|
|
406
434
|
const tree = ctx.tree();
|
|
407
|
-
const
|
|
435
|
+
const id = normId(args[0], "task");
|
|
436
|
+
const answer = typeof opts.answer === "string" && opts.answer.trim() ? opts.answer.trim() : undefined;
|
|
437
|
+
const t = tree.withTreeLock(() => {
|
|
438
|
+
const before = requireRecord(tree, id, "task");
|
|
439
|
+
// an answer is to what the task waits for: one that waits no more (done, dropped, taken up) is left as it is
|
|
440
|
+
if (answer && before.state !== "waiting")
|
|
441
|
+
throw new UsageError(`${id} does not wait for the user (it is ${before.state}) — the answer is not needed there`, { hint: `strom task show ${id}` });
|
|
442
|
+
const note = answer ? makeNote(tree, phrase(tree.lang, "task.answer", { asked: truncate(before.waitingOn || before.what, 120), answer })) : undefined;
|
|
443
|
+
return stateChange(tree, id, "open", note ? { notes: [...before.notes, note] } : {}, undefined, "wake");
|
|
444
|
+
});
|
|
408
445
|
return { text: written(tree), data: { task: t } };
|
|
409
446
|
},
|
|
410
447
|
}, {
|