strom-research 1.1.0 → 1.2.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.
@@ -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";
@@ -161,7 +163,7 @@ register({
161
163
  tree: true,
162
164
  run(ctx) {
163
165
  const all = ctx.tree().list("session").slice().reverse();
164
- const cost = (s) => (s.metrics?.costUsd !== undefined ? `$${s.metrics.costUsd.toFixed(2)}` : "");
166
+ const cost = (s) => costText(s.metrics);
165
167
  return {
166
168
  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
169
  data: { sessions: all },
@@ -186,7 +188,7 @@ register({
186
188
  m.inputTokens && `${m.inputTokens} in`,
187
189
  m.outputTokens && `${m.outputTokens} out`,
188
190
  m.cacheReadTokens && `${m.cacheReadTokens} cache read`,
189
- m.costUsd !== undefined && `$${m.costUsd.toFixed(2)}`,
191
+ costText(m),
190
192
  s.ended && `${Math.max(1, Math.round((Date.parse(s.ended) - Date.parse(s.started)) / 60000))} min`,
191
193
  m.denied && `${m.denied} refused by permissions`,
192
194
  ]
@@ -210,7 +212,7 @@ register({
210
212
  const tree = ctx.tree();
211
213
  const s = currentSession(tree, ctx.env);
212
214
  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 });
215
+ 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
216
  if (opts.stats)
215
217
  return {
216
218
  text: lines(table(b.sections.map((x) => [x.name, `${x.tokens}`, x.cut ? "cut" : ""])), `total ~${b.total} tokens of ${b.budget}`),
@@ -381,20 +383,30 @@ register({
381
383
  description: "One session by default; with --until, session after session until that time. The agent works headless with\n" +
382
384
  "the tree's permissions (anything else is denied), or with the terminal (--interactive). Stops at the\n" +
383
385
  "subscription limit, when the queue is empty, or at --until (a session already running finishes, within\n" +
384
- "--minutes). A session longer than --minutes is stopped (its task goes back to the queue); a task that comes\n" +
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.",
386
+ "--minutes). The agent knows when its session is stopped (the brief; strom's output counts down its last\n" +
387
+ "10 minutes, a short session its last quarter); at --minutes it is stopped, and Claude Code, Codex and OpenCode\n" +
388
+ "get 5 minutes more to write down what they found and close (an unfinished task goes back to the queue); a task\n" +
389
+ "that comes back twice in a row with nothing recorded is parked. --task picks the tasks (one session each, in that order;\n" +
390
+ "one done or held by another agent meanwhile is left out). --loop: session after session for as long as there\n" +
391
+ "is work. A gate (setting run.gate, or --gate; plugins/gates/<name>) is asked before each session of --loop and\n" +
392
+ "--until: go on, wait (the run waits and asks again) or stop — the user's condition, e.g. the subscription's room\n" +
393
+ "(strom gate list). Tasks started otherwise (one, --max, --task) are the user's choice: the gate is asked once,\n" +
394
+ "and when it would not start, the user is asked whether to start anyway (an agent: a window of the system).\n" +
395
+ "Arguments after -- go to the agent CLI unchanged.",
387
396
  options: [
388
397
  { 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
398
  { name: "research", type: "string", value: "<G…>", description: "only tasks of this research" },
390
399
  { name: "max", type: "string", value: "<n>", description: "at most n sessions (default 1; with --until, no limit)" },
391
400
  { name: "until", type: "string", value: "<HH:MM>", description: "start no session after this time" },
401
+ { name: "loop", type: "boolean", description: "session after session for as long as there is work (and the gate lets it)" },
402
+ { 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)' },
403
+ { name: "no-gate", type: "boolean", description: "ask no gate this time (only you: an agent cannot)" },
392
404
  { name: "minutes", type: "string", value: "<n>", description: `time limit of one session (default: run.minutes, ${DEFAULT_RUN_MINUTES})` },
393
405
  { name: "budget", type: "string", value: "<tokens>", description: `brief size (default: brief.budget, ${DEFAULT_BUDGET})` },
394
406
  { name: "model", type: "string", value: "<model>", description: "model of the main agent (default: model.lead)" },
395
407
  { name: "interactive", type: "boolean", description: "give the agent the terminal (you can talk to it)" },
396
408
  ],
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"],
409
+ 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
410
  run: async (ctx, { opts, extra }) => {
399
411
  const root = ctx.tree().root;
400
412
  const treeCfg = ctx.tree().config;
@@ -408,10 +420,11 @@ register({
408
420
  const until = parseUntil(opts.until);
409
421
  // Tasks the user picked: one session each, in their order (then the queue, when --max asks for more).
410
422
  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);
423
+ const max = opts.max === undefined ? (picked.length ? picked.length : until || opts.loop ? Infinity : 1) : Number(opts.max);
412
424
  if (max !== Infinity && (!Number.isInteger(max) || max < 1))
413
425
  throw new UsageError("--max must be a positive number");
414
426
  const minutes = ctx.settings.number("run.minutes", treeCfg, DEFAULT_RUN_MINUTES);
427
+ const gate = runGate(ctx, opts);
415
428
  const budget = ctx.settings.number("brief.budget", treeCfg, DEFAULT_BUDGET);
416
429
  // Each run has a name of its own (as a conversation does): several work side by side, never on one task.
417
430
  const worker = `run-${process.pid}-${Date.now().toString(36)}`;
@@ -419,10 +432,12 @@ register({
419
432
  // Present in the tree for the others at work (conversations with agents): strom shows who works.
420
433
  const beside = runsAtWork(root);
421
434
  const leave = enterWorker(root, worker, `${PROFILES[runnerId]?.name ?? runnerId} on its own`);
422
- const stopAwake = keepAwake(ctx.env);
435
+ let stopAwake = keepAwake(ctx.env);
423
436
  // Progress goes to stderr when stdout carries JSON.
424
437
  const out = (s) => (ctx.json ? ctx.io.stderr : ctx.io.stdout)(s + "\n");
425
438
  const report = [];
439
+ // What the gate answered, for the record of the run.
440
+ const gates = [];
426
441
  // Why the run stopped: a code for the exit status and for data, words for the user (their language).
427
442
  const lang = Tree.open(root, runEnv).lang;
428
443
  let stopCode = "done";
@@ -466,6 +481,12 @@ register({
466
481
  out(ui(lang, "ui.run.browser.model", { model: models.lead }));
467
482
  if (permissions === "full")
468
483
  out(ui(lang, "ui.run.full"));
484
+ // The gate holds a run that goes on by itself (--loop, --until). Tasks the user starts themselves (one, --max n,
485
+ // --task) are their choice: the gate is asked once, and when it would not start, the user decides.
486
+ const gateHolds = Boolean(opts.loop || until);
487
+ let gateAnswered = false;
488
+ if (gate)
489
+ out(ui(lang, gateHolds ? "ui.run.gate" : "ui.run.gate.once", { name: gate.manifest.title ?? gate.name }));
469
490
  for (let i = 0; i < max; i++) {
470
491
  if (stop.signal.aborted) {
471
492
  stopCode = "user";
@@ -500,9 +521,49 @@ register({
500
521
  stopCode = "empty";
501
522
  break;
502
523
  }
524
+ // The user's condition, between sessions only: go on, wait and ask again, or stop — in a run that goes on by
525
+ // itself. Tasks the user started themselves: asked once, and the user decides when it would not start.
526
+ if (gate && !gateAnswered) {
527
+ 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 });
528
+ const answer = { at: new Date().toISOString(), verdict: said.verdict, ...(said.reason ? { reason: said.reason } : {}) };
529
+ gates.push(answer);
530
+ const name = gate.manifest.title ?? gate.name;
531
+ if (!gateHolds) {
532
+ gateAnswered = true;
533
+ if (said.verdict !== "go") {
534
+ if (!(await startAnyway(ctx, name, gateReason(said, lang), lang))) {
535
+ stopCode = "gate.declined";
536
+ stopValues = { name, reason: gateReason(said, lang) };
537
+ break;
538
+ }
539
+ answer.anyway = true;
540
+ }
541
+ }
542
+ else if (said.verdict === "stop" || said.verdict === "error") {
543
+ stopCode = said.verdict === "stop" ? "gate" : "gate.error";
544
+ stopValues = { name, reason: said.reason ?? "" };
545
+ break;
546
+ }
547
+ else if (said.verdict === "wait") {
548
+ const at = Date.now() + said.waitMs;
549
+ if (until && at > until) {
550
+ stopCode = "time";
551
+ break;
552
+ }
553
+ out(ui(lang, "ui.run.gate.wait", { at: new Date(at).toLocaleString(lang, { weekday: "short", hour: "2-digit", minute: "2-digit" }), reason: said.reason ?? "" }));
554
+ // nothing to do meanwhile: the computer may sleep
555
+ stopAwake();
556
+ await pause(at - Date.now(), stop.signal);
557
+ stopAwake = keepAwake(ctx.env);
558
+ i--; // this was no session: ask again, with the queue as it is then
559
+ continue;
560
+ }
561
+ }
503
562
  const session = startSession(tree, { task, ...(research ? { research: research.id } : {}), runner: runnerId, ...(models.lead ? { model: models.lead } : {}) });
504
563
  commitNow(tree, `${session.id} session started on ${task.id}`);
505
- const brief = buildBrief(tree, { task, session, budget, shared: ctx.settings.shared()?.value });
564
+ // 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.
565
+ const deadline = Date.now() + minutes * 60_000;
566
+ const brief = buildBrief(tree, { task, session, budget, shared: ctx.settings.shared()?.value, deadline });
506
567
  const briefFile = path.join(root, ".strom", "briefs", `${session.id}.md`);
507
568
  fs.mkdirSync(path.dirname(briefFile), { recursive: true });
508
569
  fs.writeFileSync(briefFile, brief.text);
@@ -514,6 +575,8 @@ register({
514
575
  const env = {
515
576
  ...prependPath(runEnv, bin),
516
577
  STROM_SESSION: session.id,
578
+ STROM_DEADLINE: new Date(deadline).toISOString(),
579
+ STROM_MINUTES: String(minutes),
517
580
  ...(opts.interactive ? {} : { STROM_NONINTERACTIVE: "1" }),
518
581
  };
519
582
  const result = await runner.run({
@@ -525,6 +588,12 @@ register({
525
588
  name: ["Strom", tree.config.name, tree.get(task.research ?? research?.id ?? "")?.name, `${truncate(task.what, 50)} (${task.id})`].filter(Boolean).join(" · "),
526
589
  logFile: path.join(root, ".strom", "runs", `${session.id}.log`),
527
590
  timeoutMs: minutes * 60_000,
591
+ wrapUp: {
592
+ ms: WRAP_UP_MS,
593
+ 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: ` +
594
+ "record in strom what you found and have not recorded yet (facts, sources, the images searched, in vain too), " +
595
+ `then \`strom session close --continue --summary "…" --next "exactly where you stopped"\` (or finish the task, if it is done).`,
596
+ },
528
597
  settingsFile: path.join(root, ".claude", "settings.json"),
529
598
  shared: ctx.settings.shared()?.value,
530
599
  ...(models.lead ? { model: models.lead } : {}),
@@ -567,7 +636,7 @@ register({
567
636
  const geds = writeGedcoms(ctx, after);
568
637
  const committed = commitNow(after, `${s.id} ${s.state}: ${truncate(s.summary ?? "", 60)} · ${geds.map((g) => after.relative(g.file)).join(", ")}`);
569
638
  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.costUsd !== undefined ? ` · $${result.metrics.costUsd.toFixed(2)}` : ""}${s.summary ? ` · ${truncate(s.summary, 80)}` : ""}`);
639
+ out(`■ ${s.id} ${s.state} · ${result.outcome}${costText(result.metrics) ? ` · ${costText(result.metrics)}` : ""}${s.summary ? ` · ${truncate(s.summary, 80)}` : ""}`);
571
640
  if (result.denied?.length)
572
641
  out(ui(lang, "ui.run.denied", { n: result.denied.length, what: result.denied.slice(0, 3).join(" · ") }));
573
642
  if (!committed) {
@@ -611,11 +680,79 @@ register({
611
680
  const reason = ui(lang, `ui.run.stop.${stopCode}`, stopValues);
612
681
  return {
613
682
  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,
683
+ data: { sessions: report, stopped: reason, stop: stopCode, ...(gate ? { gate: { name: gate.name, answers: gates } } : {}) },
684
+ exitCode: ["failed", "problems", "auth", "denied", "gate.error"].includes(stopCode) ? 1 : 0,
616
685
  };
617
686
  },
618
687
  });
688
+ /**
689
+ * The gate this run asks (run.gate, or --gate), or none (--no-gate). Going round the user's gate is the user's
690
+ * decision: an agent asks them (a window of the system).
691
+ */
692
+ function runGate(ctx, opts) {
693
+ const set = ctx.settings.runGate();
694
+ const asked = typeof opts.gate === "string" ? opts.gate : undefined;
695
+ if (opts["no-gate"]) {
696
+ if (set && isAgent(ctx.env))
697
+ 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 }));
698
+ return undefined;
699
+ }
700
+ const name = asked ?? set;
701
+ if (!name)
702
+ return undefined;
703
+ if (asked && set && asked !== set && isAgent(ctx.env))
704
+ ctx.requireHuman(`Ask the gate "${asked}" instead of "${set}"?`, `strom run --gate ${asked}`, "run.gate", ui(ctx.uiLang(), "ui.consent.gate.set", { name: asked }));
705
+ const shared = ctx.settings.shared()?.value;
706
+ if (!shared)
707
+ throw new StromError("no shared folder, so no gates", { hint: "strom setup" });
708
+ ensureGatesDir(shared);
709
+ return loadGate(shared, name);
710
+ }
711
+ /** Why the gate would not start, for the user: its own words, else what it answered. */
712
+ function gateReason(said, lang) {
713
+ return said.reason ?? ui(lang, said.verdict === "error" ? "ui.run.gate.noanswer" : said.verdict === "wait" ? "ui.run.gate.later" : "ui.run.gate.no");
714
+ }
715
+ /**
716
+ * Tasks the user started themselves while the gate would not start: the user decides — on a terminal, a question;
717
+ * asked by an agent, a window of the system (going round the user's condition is never the agent's decision). A run
718
+ * nobody can ask keeps to the gate.
719
+ */
720
+ async function startAnyway(ctx, name, reason, lang) {
721
+ const question = ui(lang, "ui.run.gate.anyway", { name, reason });
722
+ if (ctx.interactive && !isAgent(ctx.env))
723
+ return ctx.confirm(question, false);
724
+ if (!isAgent(ctx.env))
725
+ return false;
726
+ try {
727
+ ctx.requireHuman(`Start the tasks although the condition "${name}" would not (${reason})?`, "strom run --no-gate", "run.gate", question);
728
+ return true;
729
+ }
730
+ catch (err) {
731
+ if (err instanceof NeedsConsentError)
732
+ throw err; // nobody to ask: the user runs it themselves
733
+ return false; // the user said no in the window
734
+ }
735
+ }
736
+ /** Wait, until the time or until the user stops the run. */
737
+ function pause(ms, signal) {
738
+ return new Promise((resolve) => {
739
+ if (signal.aborted || ms <= 0)
740
+ return resolve();
741
+ const t = setTimeout(done, ms);
742
+ signal.addEventListener("abort", done, { once: true });
743
+ function done() {
744
+ clearTimeout(t);
745
+ signal.removeEventListener("abort", done);
746
+ resolve();
747
+ }
748
+ });
749
+ }
750
+ /** What a session cost: "$1.20"; "$0.30+" or "cost unknown" when the agent was stopped before it said. */
751
+ function costText(m) {
752
+ if (m?.costPartial)
753
+ return m.costUsd ? `$${m.costUsd.toFixed(2)}+` : "cost unknown";
754
+ return m?.costUsd !== undefined ? `$${m.costUsd.toFixed(2)}` : "";
755
+ }
619
756
  /** How the user clears the agent's context, in the agent's own words. */
620
757
  const CLEAR = { claude: "/clear", codex: "/new", opencode: "/new" };
621
758
  /** 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). */
@@ -30,12 +30,14 @@ import { NeedsInputError, StromError, UsageError } from "../core/errors.js";
30
30
  import { check } from "../core/check.js";
31
31
  import { assertIntact, verifyFull } from "../core/integrity.js";
32
32
  import { ensurePluginsDir } from "../core/connector.js";
33
+ import { ensureGatesDir, loadGate } from "../core/gate.js";
33
34
  import { downloadsDir } from "../core/browser.js";
34
35
  export const SHARED_DIRS = ["media", "catalog", "tools", "cache", "inbox"];
35
36
  export function ensureShared(dir) {
36
37
  for (const d of SHARED_DIRS)
37
38
  fs.mkdirSync(path.join(dir, d), { recursive: true });
38
39
  ensurePluginsDir(dir);
40
+ ensureGatesDir(dir);
39
41
  }
40
42
  register({
41
43
  path: ["setup"],
@@ -397,6 +399,15 @@ function setUserSetting(ctx, key, value) {
397
399
  // Asking before a connector runs is the user's safeguard: only they take it away.
398
400
  if (key === "connectors.consent" && value !== "on" && s.connectorsConsent())
399
401
  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"));
402
+ // The gate decides what working alone spends: set and taken away by the user alone.
403
+ if (key === "run.gate" && value !== s.runGate())
404
+ 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()) }));
405
+ // a gate that is not there (or not a gate) is said now, not at the next run
406
+ const sh = s.shared()?.value;
407
+ if (key === "run.gate" && typeof value === "string" && sh) {
408
+ ensureGatesDir(sh);
409
+ loadGate(sh, value);
410
+ }
400
411
  writeStored(s.config, key, s.agent(ctx.hasTree() ? ctx.tree().config : undefined).value, value);
401
412
  s.save();
402
413
  if (key === "shared" && typeof value === "string")
@@ -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(),
@@ -0,0 +1,55 @@
1
+ // The session's time. `strom run` stops an agent at its time limit (run.minutes),
2
+ // and what the agent had only in its context is lost then (found in a live run:
3
+ // 25 minutes of reading a census film, nothing written down, all gone). So the
4
+ // agent is told when its session is stopped (STROM_DEADLINE, set by the run; the
5
+ // brief says it), near the end every strom command it runs reminds it, and at
6
+ // the limit an agent that can be resumed gets a few minutes more to write down
7
+ // what it found and close the session (runners: wrapUp).
8
+ /** From how long before the end strom's output reminds the agent (a short session: its last quarter). */
9
+ export const REMIND_MS = 10 * 60_000;
10
+ /** From how long before the end the reminder says to write down and close (a short session: its last eighth). */
11
+ export const CLOSE_MS = 3 * 60_000;
12
+ /** The time an agent stopped at its limit gets to write down what it found. */
13
+ export const WRAP_UP_MS = 5 * 60_000;
14
+ /** When this session is stopped (ms since the epoch), if a run set a limit. */
15
+ export function deadlineOf(env) {
16
+ const t = Date.parse(env.STROM_DEADLINE ?? "");
17
+ return Number.isNaN(t) ? undefined : t;
18
+ }
19
+ /** The session's whole time limit (STROM_MINUTES), if the run said it. */
20
+ function limitOf(env) {
21
+ const m = Number(env.STROM_MINUTES);
22
+ return m > 0 ? m * 60_000 : undefined;
23
+ }
24
+ /** A time of day as the agent reads it in its brief and in the reminders. */
25
+ export function clockTime(t) {
26
+ return new Date(t).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
27
+ }
28
+ /**
29
+ * The line strom adds to a command's output near the end of the session (none before): first that the time is
30
+ * getting short (go on, writing down), in the last minutes to write down and close, then that it is up. A short
31
+ * session is reminded in proportion — a reminder from its first minute would only make the agent give up.
32
+ */
33
+ export function clockLine(env, now = Date.now()) {
34
+ const end = deadlineOf(env);
35
+ if (end === undefined)
36
+ return undefined;
37
+ const limit = limitOf(env);
38
+ const remind = limit ? Math.min(REMIND_MS, limit / 4) : REMIND_MS;
39
+ const close = limit ? Math.min(CLOSE_MS, limit / 8) : CLOSE_MS;
40
+ const left = end - now;
41
+ if (left > remind)
42
+ return undefined;
43
+ const min = Math.max(1, Math.ceil(left / 60_000));
44
+ if (left <= 0)
45
+ return '⏳ this session\'s time is up: record what you found and close it now: strom session close --continue --summary "…" --next "exactly where you stopped"';
46
+ if (left <= close)
47
+ return `⏳ ${min} min left: this session is stopped at ${clockTime(end)}. Record what you have found now (facts, sources, the images searched), then close: strom session close --summary "…" --next "…"`;
48
+ return `⏳ ${min} min left: this session is stopped at ${clockTime(end)}. Go on, but write each find down as you have it and start nothing you cannot finish by then.`;
49
+ }
50
+ /** What the brief says of the session's time: when, and not to hurry — strom says when it is getting short. */
51
+ export function briefClock(end, now = Date.now()) {
52
+ return (`Time: this session is stopped at ${clockTime(end)} (it is ${clockTime(now)} now). Do not hurry and do not stop early: strom's output tells you ` +
53
+ "when the time is getting short; until then work on the task as usual. Write each find and each stretch of images searched into strom the " +
54
+ "moment you have it: what is only in your context is lost when the session ends.");
55
+ }
@@ -39,6 +39,8 @@ export const SETTINGS = [
39
39
  })),
40
40
  { key: "brief.budget", env: "STROM_BRIEF_BUDGET", tree: true, kind: "number", description: `size of the brief in tokens (default ${DEFAULT_BUDGET})` },
41
41
  { key: "run.minutes", env: "STROM_RUN_MINUTES", tree: true, kind: "number", description: `time limit of one \`strom run\` session (default ${DEFAULT_RUN_MINUTES})` },
42
+ // Read from the config file only, changed by the user alone: the gate decides what working alone spends.
43
+ { key: "run.gate", env: "", tree: false, kind: "plugin", description: "a condition on the agent working alone: the gate (plugins/gates/<name>) strom asks before each session of strom run — go on, wait or stop; its name, then what it is given (strom gate list; e.g. claude-usage 10: the Claude subscription's daily ration, 10 points in hand) — only you set it" },
42
44
  { key: "queue.strategy", env: "STROM_QUEUE_STRATEGY", tree: true, kind: "choice", choices: STRATEGIES, description: "order of the task queue: balanced (default — nearest ancestors first, spread over the lines, nothing taken forever), depth (stay on one line), priority (strict priority)" },
43
45
  { key: "gedcom.for", env: "STROM_GEDCOM_FOR", tree: true, kind: "choice", choices: ["both", "standard", "strom"], description: "GEDCOM files written: both (default), standard (any program), strom (the Strom app)" },
44
46
  { key: "stories", env: "STROM_STORIES", tree: true, kind: "choice", choices: ["yes", "no"], description: "stories of the ancestors for the family, written from the facts: yes (default — strom proposes one once a person's life is told by records), no — the user is told when the research starts and may say no" },
@@ -73,6 +75,7 @@ const FIELDS = {
73
75
  "excerpts.quality": "excerptsQuality",
74
76
  "excerpts.for": "excerptsFor",
75
77
  "excerpts.mb": "excerptsMb",
78
+ "run.gate": "runGate",
76
79
  };
77
80
  /** Environment variables that are not settings but steer strom. */
78
81
  export const OTHER_ENV = [
@@ -128,6 +131,11 @@ export function checkValue(def, raw, resolvePath) {
128
131
  throw new UsageError(`invalid ${def.key} "${raw}"`, { hint: def.choices?.join(", ") });
129
132
  return c;
130
133
  }
134
+ case "plugin":
135
+ // its name, then what it is given: "claude-usage 10"
136
+ if (!/^[a-z0-9][a-z0-9-]*(\s+\S+)*$/.test(v))
137
+ throw new UsageError(`invalid ${def.key} "${raw}"`, { hint: 'a plugin\'s name (lowercase letters, digits and dashes), then what it is given, e.g. "claude-usage 10"' });
138
+ return v.split(/\s+/).join(" ");
131
139
  case "person":
132
140
  if (!/^[Pp]\d{4,}$/.test(v))
133
141
  throw new UsageError(`invalid ${def.key} "${raw}"`, { hint: "a person's ID, e.g. P0009 (strom find <name>)" });
@@ -332,6 +340,10 @@ export class Settings {
332
340
  return r ? String(r.value) : undefined;
333
341
  }
334
342
  /** What the agent may do without asking — from the config file alone, which only the user raises. */
343
+ /** The gate of working alone, if the user set one (the config file only). */
344
+ runGate() {
345
+ return this.config.runGate || undefined;
346
+ }
335
347
  agentPermissions() {
336
348
  const v = this.config.agentPermissions ?? "auto";
337
349
  return PERMISSION_ALIASES[v] ?? (PERMISSION_LEVELS.includes(v) ? v : "auto");
@@ -0,0 +1,175 @@
1
+ // Gates: a condition the user sets on working alone. Before each session of
2
+ // `strom run` (--loop runs on for as long as there is work) strom asks the
3
+ // gate the user chose (setting run.gate) whether to go on, wait or stop — the
4
+ // subscription's capacity, a budget, the night's tariff: whatever the gate's
5
+ // program decides, strom holds only the interface.
6
+ //
7
+ // A gate is a folder in the plugins folder, <shared>/plugins/gates/<name>/,
8
+ // with gate.json ({"interface": 1, "command": [...]}) and its program; the
9
+ // user may give it arguments after its name (run.gate "claude-usage 10"). The
10
+ // interface (assets/plugins/gates/README.md, copied next to the gates): the
11
+ // program's exit status says it — 0 go on, 1 wait, 2 stop — and one line of
12
+ // JSON on stdout may add why ("reason") and how long to wait ("wait" seconds
13
+ // or "until" a time). Anything else (a crash, no answer in time) stops the
14
+ // run: working alone never spends blind.
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { spawnSync } from "node:child_process";
18
+ import { StromError, UsageError } from "./errors.js";
19
+ import { NAME_RE, pluginsDir } from "./connector.js";
20
+ import { readAsset } from "./assets.js";
21
+ export const GATE_INTERFACE = 1;
22
+ const MANIFEST = "gate.json";
23
+ /** How long a gate may think (it may ask a service). */
24
+ const DEFAULT_TIMEOUT_S = 120;
25
+ /** A gate that says wait without saying how long is asked again after this. */
26
+ export const DEFAULT_WAIT_MS = 15 * 60_000;
27
+ /** Never ask again sooner than this (a gate answering "wait 0" must not spin). */
28
+ const MIN_WAIT_MS = 60_000;
29
+ export function gatesDir(shared) {
30
+ return path.join(pluginsDir(shared), "gates");
31
+ }
32
+ /** The gates folder with strom's own files: the interface and the gates strom ships (refreshed). */
33
+ export function ensureGatesDir(shared) {
34
+ const dir = gatesDir(shared);
35
+ try {
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ const own = [[path.join(dir, "README.md"), readAsset("plugins", "gates", "README.md")]];
38
+ for (const name of SHIPPED)
39
+ for (const f of ["gate.json", "gate.ts"])
40
+ own.push([path.join(dir, name, f), readAsset("plugins", "gates", name, f)]);
41
+ for (const [file, text] of own) {
42
+ if (text === undefined)
43
+ continue;
44
+ let old;
45
+ try {
46
+ old = fs.readFileSync(file, "utf8");
47
+ }
48
+ catch {
49
+ old = undefined;
50
+ }
51
+ if (old === text)
52
+ continue;
53
+ fs.mkdirSync(path.dirname(file), { recursive: true });
54
+ fs.writeFileSync(file, text);
55
+ }
56
+ }
57
+ catch (err) {
58
+ // a folder strom may not write here (an agent's sandbox): the gates there still run
59
+ if (!["EACCES", "EPERM", "EROFS"].includes(err?.code ?? ""))
60
+ throw err;
61
+ }
62
+ return dir;
63
+ }
64
+ /** Gates strom ships: ready in the gates folder, used only when the user sets run.gate. */
65
+ export const SHIPPED = ["claude-usage"];
66
+ export function listGates(shared) {
67
+ const dir = gatesDir(shared);
68
+ let names = [];
69
+ try {
70
+ names = fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory() && NAME_RE.test(d.name)).map((d) => d.name);
71
+ }
72
+ catch {
73
+ return [];
74
+ }
75
+ const out = [];
76
+ for (const name of names.sort()) {
77
+ try {
78
+ out.push(loadGate(shared, name));
79
+ }
80
+ catch {
81
+ // not a gate (yet): no gate.json, or one strom cannot read — `strom gate test <name>` says why
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+ /** A gate as the user names it: its name, then what it is given ("claude-usage 10"). */
87
+ export function loadGate(shared, spec) {
88
+ const [name = "", ...args] = spec.trim().split(/\s+/);
89
+ if (!NAME_RE.test(name))
90
+ throw new UsageError(`invalid gate name "${name}"`, { hint: "lowercase letters, digits and dashes, e.g. claude-usage" });
91
+ const dir = path.join(gatesDir(shared), name);
92
+ const file = path.join(dir, MANIFEST);
93
+ if (!fs.existsSync(file))
94
+ throw new StromError(`no gate "${name}" (no ${file})`, { hint: `the gates here: strom gate list — a gate is a folder in ${gatesDir(shared)} with ${MANIFEST}` });
95
+ let m;
96
+ try {
97
+ m = JSON.parse(fs.readFileSync(file, "utf8"));
98
+ }
99
+ catch (err) {
100
+ throw new StromError(`gate "${name}": ${MANIFEST} is not valid JSON (${err.message})`);
101
+ }
102
+ if (m.interface !== GATE_INTERFACE)
103
+ throw new StromError(`gate "${name}" is for interface ${m.interface}, this strom knows ${GATE_INTERFACE}`, { hint: m.interface > GATE_INTERFACE ? "update strom: strom update" : `see ${path.join(gatesDir(shared), "README.md")}` });
104
+ if (!Array.isArray(m.command) || !m.command.length || !m.command.every((c) => typeof c === "string" && c))
105
+ throw new StromError(`gate "${name}": "command" must be a list of strings, e.g. ["node", "gate.ts"]`);
106
+ return { name, dir, manifest: m, args };
107
+ }
108
+ /** Ask the gate once. Never throws: a gate that fails answers "error". */
109
+ export function askGate(gate, env, facts) {
110
+ const [cmd, ...args] = gate.manifest.command;
111
+ const program = cmd === "node" ? process.execPath : cmd;
112
+ const timeout = (gate.manifest.timeout ?? DEFAULT_TIMEOUT_S) * 1000;
113
+ const r = spawnSync(program, [...args, ...gate.args], {
114
+ cwd: gate.dir,
115
+ encoding: "utf8",
116
+ timeout,
117
+ windowsHide: true,
118
+ env: {
119
+ ...env,
120
+ STROM_GATE: gate.name,
121
+ STROM_TREE: facts.tree,
122
+ STROM_LANG: facts.lang,
123
+ STROM_AGENT: facts.agent,
124
+ STROM_MODEL: facts.model ?? "",
125
+ STROM_SESSIONS: String(facts.sessions),
126
+ STROM_COST_USD: facts.costUsd.toFixed(2),
127
+ STROM_NEXT_TASK: facts.nextTask ?? "",
128
+ },
129
+ });
130
+ if (r.error) {
131
+ const timedOut = r.error.code === "ETIMEDOUT";
132
+ return { verdict: "error", reason: timedOut ? `no answer within ${timeout / 1000} s` : r.error.message };
133
+ }
134
+ const said = parseSaid(r.stdout ?? "");
135
+ const reason = said.reason ?? (r.status !== 0 && r.status !== 1 && r.status !== 2 ? lastLine(r.stderr ?? "") : undefined);
136
+ switch (r.status) {
137
+ case 0:
138
+ return { verdict: "go", ...(reason ? { reason } : {}) };
139
+ case 1:
140
+ return { verdict: "wait", ...(reason ? { reason } : {}), waitMs: Math.max(MIN_WAIT_MS, said.waitMs ?? DEFAULT_WAIT_MS) };
141
+ case 2:
142
+ return { verdict: "stop", ...(reason ? { reason } : {}) };
143
+ default:
144
+ return { verdict: "error", reason: reason ?? (r.signal ? `ended by ${r.signal}` : `exit status ${r.status}`) };
145
+ }
146
+ }
147
+ /** The last line of JSON the gate printed: {"reason", "wait" (seconds) | "until" (a time)}; plain text is the reason. */
148
+ function parseSaid(stdout) {
149
+ const line = lastLine(stdout);
150
+ if (!line)
151
+ return {};
152
+ if (!line.startsWith("{"))
153
+ return { reason: line.slice(0, 300) };
154
+ try {
155
+ const j = JSON.parse(line);
156
+ const out = {};
157
+ if (typeof j.reason === "string" && j.reason.trim())
158
+ out.reason = j.reason.trim().slice(0, 300);
159
+ if (typeof j.wait === "number" && Number.isFinite(j.wait) && j.wait >= 0)
160
+ out.waitMs = j.wait * 1000;
161
+ else if (typeof j.until === "string" && !Number.isNaN(Date.parse(j.until)))
162
+ out.waitMs = Date.parse(j.until) - Date.now();
163
+ return out;
164
+ }
165
+ catch {
166
+ return { reason: line.slice(0, 300) };
167
+ }
168
+ }
169
+ function lastLine(s) {
170
+ return s
171
+ .split(/\r?\n/)
172
+ .map((l) => l.trim())
173
+ .filter(Boolean)
174
+ .pop();
175
+ }
@@ -72,6 +72,8 @@ export function treeStats(tree, from) {
72
72
  if (!stats.sessions.last || at > stats.sessions.last)
73
73
  stats.sessions.last = at;
74
74
  cost += s.metrics?.costUsd ?? 0;
75
+ if (s.metrics?.costPartial)
76
+ stats.sessions.costPartial = (stats.sessions.costPartial ?? 0) + 1;
75
77
  }
76
78
  if (cost > 0)
77
79
  stats.sessions.costUsd = Math.round(cost * 100) / 100;