strom-research 1.7.0 → 1.8.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/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/pack.js +21 -18
- package/dist/commands/research.js +42 -1
- package/dist/commands/session.js +79 -9
- 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 +15 -6
- 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/links.js +17 -4
- package/dist/core/live.js +393 -50
- package/dist/core/pack.js +31 -0
- package/dist/core/phrases.js +61 -0
- package/dist/core/queue.js +7 -2
- package/dist/core/relocate.js +4 -4
- package/dist/core/session.js +8 -2
- package/dist/core/trash.js +72 -0
- package/dist/core/tree.js +1 -1
- package/dist/core/workers.js +28 -3
- 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,12 +5,14 @@
|
|
|
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";
|
|
@@ -28,15 +30,16 @@ import { PROFILES } from "../agents/profiles.js";
|
|
|
28
30
|
import { AGENTS, detectAgent, isAgent, which, withoutAgentMarks } from "../core/which.js";
|
|
29
31
|
import { askGate, ensureGatesDir, loadGate } from "../core/gate.js";
|
|
30
32
|
import { keepAwake } from "../core/awake.js";
|
|
31
|
-
import { deadlineOf, WRAP_UP_MS } from "../core/clock.js";
|
|
33
|
+
import { askFinish, deadlineOf, finishAsked, WRAP_UP_MS } from "../core/clock.js";
|
|
34
|
+
import { OWN_GROUP } from "../runners/runner.js";
|
|
32
35
|
import { stromLauncher } from "../core/self.js";
|
|
33
36
|
import { phrase } from "../core/phrases.js";
|
|
34
|
-
import { enterWorker, runAlive, runsAtWork } from "../core/workers.js";
|
|
37
|
+
import { enterWorker, markPaused, runAlive, runsAtWork } from "../core/workers.js";
|
|
35
38
|
import { assertIntact, snapshot, verifyFast } from "../core/integrity.js";
|
|
36
39
|
import { guard } from "../core/guard.js";
|
|
37
40
|
import { hasErrors } from "../core/check.js";
|
|
38
41
|
import { Tree } from "../core/tree.js";
|
|
39
|
-
import { treeBrowserConnectors } from "../core/connector.js";
|
|
42
|
+
import { agentBrowser, treeBrowserConnectors } from "../core/connector.js";
|
|
40
43
|
import { browserNote } from "./connectors.js";
|
|
41
44
|
import { reviewProposals } from "../core/review.js";
|
|
42
45
|
function written(tree) {
|
|
@@ -70,6 +73,9 @@ export function researchProposals(tree, research) {
|
|
|
70
73
|
}
|
|
71
74
|
/** Create the tasks strom proposes for a research; returns their IDs. */
|
|
72
75
|
export function applyFrontier(tree, research) {
|
|
76
|
+
// a direction paused or ended proposes nothing (resumed: what it would, at once)
|
|
77
|
+
if (research.state !== "active")
|
|
78
|
+
return [];
|
|
73
79
|
return researchProposals(tree, research).map(({ proposal, kind }) => create(tree, "task", { ...proposal, state: "open" }, (id) => `+${id} task "${truncate(proposal.what, 60)}" (${kind})`).id);
|
|
74
80
|
}
|
|
75
81
|
/** Commit behind the same gate as every write. */
|
|
@@ -102,6 +108,7 @@ register({
|
|
|
102
108
|
if (task && !["open", "doing", "parked"].includes(task.state))
|
|
103
109
|
throw new UsageError(`${task.id} is ${task.state}`);
|
|
104
110
|
const s = startSession(tree, { ...(task ? { task } : {}), ...(research ? { research: research.id } : {}) });
|
|
111
|
+
reviveLive(tree.root, ctx.env);
|
|
105
112
|
const brief = buildBrief(tree, { ...(task ? { task } : {}), session: s, budget: ctx.settings.number("brief.budget", tree.config, DEFAULT_BUDGET), shared: ctx.settings.shared()?.value });
|
|
106
113
|
return { text: lines(written(tree), "", brief.text), data: { session: s, brief: brief.text, sections: brief.sections } };
|
|
107
114
|
},
|
|
@@ -120,6 +127,30 @@ register({
|
|
|
120
127
|
sessionNote(tree, s, args[0]);
|
|
121
128
|
return { text: written(tree) };
|
|
122
129
|
},
|
|
130
|
+
}, {
|
|
131
|
+
path: ["session", "finish"],
|
|
132
|
+
summary: "Ask a session at work to finish now: its agent writes down what it found and closes it",
|
|
133
|
+
group: "research",
|
|
134
|
+
tree: true,
|
|
135
|
+
description: "For the user: a session of strom run (or a conversation) is told, from the next strom command its agent runs, to\n" +
|
|
136
|
+
"start nothing new, record what it has and close the session (the task back to the queue when unfinished).\n" +
|
|
137
|
+
"Nothing is stopped by force; the run it belongs to stops after it (as at the first Ctrl-C in its terminal).",
|
|
138
|
+
args: [{ name: "session", description: "session ID (default: the one open session)" }],
|
|
139
|
+
examples: ["strom session finish", "strom session finish N0001"],
|
|
140
|
+
run(ctx, { args }) {
|
|
141
|
+
const tree = ctx.tree();
|
|
142
|
+
const lang = tree.lang;
|
|
143
|
+
const open = openSessions(tree);
|
|
144
|
+
const s = args[0] ? requireRecord(tree, args[0], "session") : open.length === 1 ? open[0] : undefined;
|
|
145
|
+
if (!s && !open.length)
|
|
146
|
+
throw new UsageError(ui(lang, "ui.session.finish.none"), { hint: "strom session list" });
|
|
147
|
+
if (!s)
|
|
148
|
+
throw new UsageError(ui(lang, "ui.session.finish.which", { sessions: open.map((x) => x.id).join(", ") }), { hint: `strom session finish ${open[0].id}` });
|
|
149
|
+
if (s.state !== "open")
|
|
150
|
+
throw new UsageError(ui(lang, "ui.session.finish.closed", { session: s.id }), { hint: "strom session list" });
|
|
151
|
+
askFinish(tree.root, s.id, ctx.env.STROM_WORKER ?? "user");
|
|
152
|
+
return { text: ui(lang, "ui.session.finish.done", { session: s.id, task: s.task ?? "–" }), data: { session: s.id, task: s.task ?? null, asked: true } };
|
|
153
|
+
},
|
|
123
154
|
}, {
|
|
124
155
|
path: ["session", "close"],
|
|
125
156
|
summary: "Finish the session: summary, next step; exports the GEDCOM and fills the task queue",
|
|
@@ -402,6 +433,7 @@ register({
|
|
|
402
433
|
{ name: "loop", type: "boolean", description: "session after session for as long as there is work (and the gate lets it)" },
|
|
403
434
|
{ 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
435
|
{ name: "no-gate", type: "boolean", description: "ask no gate this time (only you: an agent cannot)" },
|
|
436
|
+
{ name: "follow", type: "boolean", description: "open the Strom app to follow the work live when the first session starts (after the gate)" },
|
|
405
437
|
{ name: "minutes", type: "string", value: "<n>", description: `time limit of one session (default: run.minutes, ${DEFAULT_RUN_MINUTES})` },
|
|
406
438
|
{ name: "budget", type: "string", value: "<tokens>", description: `brief size (default: brief.budget, ${DEFAULT_BUDGET})` },
|
|
407
439
|
{ name: "model", type: "string", value: "<model>", description: "model of the main agent (default: model.lead)" },
|
|
@@ -439,15 +471,27 @@ register({
|
|
|
439
471
|
const report = [];
|
|
440
472
|
// What the gate answered, for the record of the run.
|
|
441
473
|
const gates = [];
|
|
474
|
+
// The Strom app opened to follow the run (--follow): once, when its first session starts.
|
|
475
|
+
let followed = false;
|
|
442
476
|
// Why the run stopped: a code for the exit status and for data, words for the user (their language).
|
|
443
477
|
const lang = Tree.open(root, runEnv).lang;
|
|
444
478
|
let stopCode = "done";
|
|
445
479
|
let stopValues = {};
|
|
446
|
-
// Ctrl-C
|
|
480
|
+
// The first Ctrl-C while a session works: it is asked to finish (the agent writes down what it found and closes
|
|
481
|
+
// it), then the run stops — no next session. Ctrl-C again, a closed terminal, a shutdown: stop the agent now,
|
|
482
|
+
// close its session, give the task back. (Windows: the console stops the agent itself — now.)
|
|
447
483
|
const stop = new AbortController();
|
|
484
|
+
let atWorkOn;
|
|
485
|
+
let finishing = false;
|
|
448
486
|
const onSignal = (sig) => {
|
|
449
487
|
if (stop.signal.aborted)
|
|
450
|
-
process.exit(130); // a
|
|
488
|
+
process.exit(130); // a third Ctrl-C: at once
|
|
489
|
+
if (sig === "SIGINT" && atWorkOn && !finishing && OWN_GROUP && !opts.interactive) {
|
|
490
|
+
finishing = true;
|
|
491
|
+
askFinish(root, atWorkOn, "user");
|
|
492
|
+
out(`\n${ui(lang, "ui.run.finishing", { session: atWorkOn })}`);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
451
495
|
out(`\n${ui(lang, "ui.run.stopping")} (${sig})`);
|
|
452
496
|
stop.abort();
|
|
453
497
|
};
|
|
@@ -478,6 +522,10 @@ register({
|
|
|
478
522
|
const permissions = ctx.settings.agentPermissions();
|
|
479
523
|
for (const c of browser)
|
|
480
524
|
out(ui(lang, "ui.run.browser", { name: c.manifest.title ?? c.name, hosts: c.manifest.hosts.join(", ") }));
|
|
525
|
+
// agent.browser always (the user's choice): the browser in every session, for the research's archives
|
|
526
|
+
const web = runnerId === "claude" ? agentBrowser(Tree.open(root, runEnv), ctx.settings.shared()?.value) : undefined;
|
|
527
|
+
if (web?.always)
|
|
528
|
+
out(ui(lang, "ui.run.browser.always", { hosts: web.hosts.length ? web.hosts.join(", ") : "–" }));
|
|
481
529
|
if (browser.length && /haiku/i.test(models.lead ?? ""))
|
|
482
530
|
out(ui(lang, "ui.run.browser.model", { model: models.lead }));
|
|
483
531
|
// Archives through the browser with an agent that has no browser tools, or without the extension: said before it starts.
|
|
@@ -493,7 +541,7 @@ register({
|
|
|
493
541
|
if (gate)
|
|
494
542
|
out(ui(lang, gateHolds ? "ui.run.gate" : "ui.run.gate.once", { name: gate.manifest.title ?? gate.name }));
|
|
495
543
|
for (let i = 0; i < max; i++) {
|
|
496
|
-
if (stop.signal.aborted) {
|
|
544
|
+
if (stop.signal.aborted || finishing) {
|
|
497
545
|
stopCode = "user";
|
|
498
546
|
break;
|
|
499
547
|
}
|
|
@@ -558,7 +606,14 @@ register({
|
|
|
558
606
|
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
607
|
// nothing to do meanwhile: the computer may sleep
|
|
560
608
|
stopAwake();
|
|
561
|
-
|
|
609
|
+
// the Strom app shows the run paused until then, not at work
|
|
610
|
+
markPaused(root, worker, { until: new Date(at).toISOString(), ...(said.reason ? { reason: said.reason } : {}) });
|
|
611
|
+
try {
|
|
612
|
+
await pause(at - Date.now(), stop.signal);
|
|
613
|
+
}
|
|
614
|
+
finally {
|
|
615
|
+
markPaused(root, worker, undefined);
|
|
616
|
+
}
|
|
562
617
|
stopAwake = keepAwake(ctx.env);
|
|
563
618
|
i--; // this was no session: ask again, with the queue as it is then
|
|
564
619
|
continue;
|
|
@@ -566,6 +621,17 @@ register({
|
|
|
566
621
|
}
|
|
567
622
|
const session = startSession(tree, { task, ...(research ? { research: research.id } : {}), runner: runnerId, ...(models.lead ? { model: models.lead } : {}) });
|
|
568
623
|
commitNow(tree, `${session.id} session started on ${task.id}`);
|
|
624
|
+
// the Strom app followed the research and its bridge ended without a word: on its address again
|
|
625
|
+
reviveLive(root, ctx.env);
|
|
626
|
+
// The Strom app follows it from the first session on — not while the run still waits for its gate.
|
|
627
|
+
if (opts.follow && !followed) {
|
|
628
|
+
followed = true;
|
|
629
|
+
const { command, args: self } = stromLauncher();
|
|
630
|
+
const app = spawnSync(command, [...self, "app", "--live"], { cwd: root, env: ctx.env, encoding: "utf8", windowsHide: true, timeout: 60_000 });
|
|
631
|
+
const said = `${app.stdout ?? ""}${app.stderr ?? ""}`.trim();
|
|
632
|
+
if (said)
|
|
633
|
+
out(said);
|
|
634
|
+
}
|
|
569
635
|
// 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
636
|
const deadline = Date.now() + minutes * 60_000;
|
|
571
637
|
const brief = buildBrief(tree, { task, session, budget, shared: ctx.settings.shared()?.value, deadline });
|
|
@@ -584,6 +650,7 @@ register({
|
|
|
584
650
|
STROM_MINUTES: String(minutes),
|
|
585
651
|
...(opts.interactive ? {} : { STROM_NONINTERACTIVE: "1" }),
|
|
586
652
|
};
|
|
653
|
+
atWorkOn = session.id;
|
|
587
654
|
const result = await runner.run({
|
|
588
655
|
cwd: root,
|
|
589
656
|
prompt,
|
|
@@ -605,16 +672,19 @@ register({
|
|
|
605
672
|
...(opts.interactive ? { interactive: true } : {}),
|
|
606
673
|
// the level for every agent (each maps it to its own switches); the browser and Remote Control are Claude Code's
|
|
607
674
|
permissions,
|
|
608
|
-
...(runnerId === "claude" ? { chrome:
|
|
675
|
+
...(runnerId === "claude" ? { chrome: Boolean(web?.on), remote: ctx.settings.agentRemote() } : {}),
|
|
609
676
|
...(extra?.length ? { extraArgs: extra } : {}),
|
|
610
677
|
onProgress: (l) => out(` · ${l}`),
|
|
611
678
|
signal: stop.signal,
|
|
612
679
|
});
|
|
680
|
+
atWorkOn = undefined;
|
|
613
681
|
// Close what the agent left open, record the metrics, export, commit.
|
|
614
682
|
const after = Tree.open(root, runEnv);
|
|
615
683
|
let s = after.get(session.id);
|
|
616
684
|
// strom worked for the agent when it closed the session itself or recorded something
|
|
617
685
|
const ranStrom = s.state !== "open" || sessionWrites(after, s.id) > 0;
|
|
686
|
+
// the user asked it to finish (strom session finish, the Strom app's "finish and stop"): no next session
|
|
687
|
+
const askedToFinish = finishAsked(root, s.id) || s.endedBy === "user";
|
|
618
688
|
if (s.state === "open")
|
|
619
689
|
s = closeSession(after, s, {
|
|
620
690
|
summary: result.outcome === "stopped" ? phrase(after.lang, "session.user") : phrase(after.lang, "session.agent", { outcome: result.outcome }),
|
|
@@ -653,7 +723,7 @@ register({
|
|
|
653
723
|
stopCode = "problems";
|
|
654
724
|
break;
|
|
655
725
|
}
|
|
656
|
-
if (stop.signal.aborted) {
|
|
726
|
+
if (stop.signal.aborted || finishing || askedToFinish) {
|
|
657
727
|
stopCode = "user";
|
|
658
728
|
break;
|
|
659
729
|
}
|
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"],
|
package/dist/commands/tasks.js
CHANGED
|
@@ -11,6 +11,7 @@ import { lines, moreLine, paginate, runs, table, truncate } from "../cli/format.
|
|
|
11
11
|
import { UsageError } from "../core/errors.js";
|
|
12
12
|
import { TASK_LEVELS } from "../core/model.js";
|
|
13
13
|
import { create, csvOpt, listOpt, normId, requireRecord, update } from "../core/records.js";
|
|
14
|
+
import { directionOf, ofResearch, scopes } from "../core/directions.js";
|
|
14
15
|
import { resolvePerson } from "../core/people.js";
|
|
15
16
|
import { foldText } from "../core/text.js";
|
|
16
17
|
import { now, typeOfId } from "../core/tree.js";
|
|
@@ -35,11 +36,17 @@ function subjects(tree, refs) {
|
|
|
35
36
|
return resolvePerson(tree, r).id;
|
|
36
37
|
});
|
|
37
38
|
}
|
|
38
|
-
|
|
39
|
+
/** A new task's research: the one named, else the session's that adds it, else the one active research its people belong to. */
|
|
40
|
+
function defaultResearch(tree, ref, subject = []) {
|
|
39
41
|
if (typeof ref === "string")
|
|
40
42
|
return resolveResearch(tree, ref).id;
|
|
43
|
+
const session = typeOfId(tree.actor) === "session" ? tree.get(tree.actor) : undefined;
|
|
44
|
+
if (session?.research)
|
|
45
|
+
return session.research;
|
|
41
46
|
const active = tree.list("research").filter((r) => r.state === "active");
|
|
42
|
-
|
|
47
|
+
if (active.length === 1)
|
|
48
|
+
return active[0].id;
|
|
49
|
+
return directionOf(tree, { subject }, scopes(tree).filter((s) => s.research.state === "active"));
|
|
43
50
|
}
|
|
44
51
|
/** What the research waits for from outside — mostly from the user: scans to download, a book to find, a reply. */
|
|
45
52
|
export function waitingForUser(tree) {
|
|
@@ -75,9 +82,10 @@ export function taskQueue(tree, filter = {}) {
|
|
|
75
82
|
return rankedQueue(tree, filter).map((r) => r.task);
|
|
76
83
|
}
|
|
77
84
|
export function rankedQueue(tree, filter = {}) {
|
|
85
|
+
const scope = filter.research ? scopes(tree).find((s) => s.research.id === filter.research) : undefined;
|
|
78
86
|
const tasks = tree
|
|
79
87
|
.list("task")
|
|
80
|
-
.filter((t) => !filter.research ||
|
|
88
|
+
.filter((t) => !filter.research || (scope ? ofResearch(tree, t, scope) : t.research === filter.research))
|
|
81
89
|
.filter((t) => !filter.level || t.level === filter.level);
|
|
82
90
|
return rankTasks(tree, tasks, filter.strategy, { storyTurn: filter.storyTurn });
|
|
83
91
|
}
|
|
@@ -183,7 +191,7 @@ register({
|
|
|
183
191
|
throw new UsageError("--priority must be 1..5");
|
|
184
192
|
const where = listOpt(opts.where).map((w) => (/^[Bb]\d+$/.test(w) ? requireRecord(tree, w, "recordset").id : w));
|
|
185
193
|
const subject = subjects(tree, listOpt(opts.about));
|
|
186
|
-
const research = defaultResearch(tree, opts.research);
|
|
194
|
+
const research = defaultResearch(tree, opts.research, subject);
|
|
187
195
|
// The same work again: an open task of the same person and level that reads the same (or nearly, in the same
|
|
188
196
|
// books) — not added; what is new goes into that one.
|
|
189
197
|
if (!opts.anyway) {
|
|
@@ -278,8 +286,9 @@ register({
|
|
|
278
286
|
}
|
|
279
287
|
else {
|
|
280
288
|
all = tree.list("task").filter((t) => opts.state === "all" || t.state === opts.state);
|
|
281
|
-
|
|
282
|
-
|
|
289
|
+
const scope = research ? scopes(tree).find((s) => s.research.id === research) : undefined;
|
|
290
|
+
if (scope)
|
|
291
|
+
all = all.filter((t) => ofResearch(tree, t, scope));
|
|
283
292
|
if (opts.level)
|
|
284
293
|
all = all.filter((t) => t.level === opts.level);
|
|
285
294
|
}
|
package/dist/commands/trees.js
CHANGED
|
@@ -6,13 +6,19 @@ import { register } from "../cli/registry.js";
|
|
|
6
6
|
import { Context } from "../cli/context.js";
|
|
7
7
|
import { lines, table } from "../cli/format.js";
|
|
8
8
|
import { Tree } from "../core/tree.js";
|
|
9
|
-
import { safeFolderName } from "../core/text.js";
|
|
9
|
+
import { foldText, safeFolderName } from "../core/text.js";
|
|
10
10
|
import { isValidLang, langName } from "../core/lang.js";
|
|
11
|
-
import { UsageError } from "../core/errors.js";
|
|
11
|
+
import { NeedsConsentError, UsageError } from "../core/errors.js";
|
|
12
12
|
import { verifyFast } from "../core/integrity.js";
|
|
13
13
|
import * as git from "../core/git.js";
|
|
14
14
|
import { ensureShared, setTreeSetting } from "./setup.js";
|
|
15
15
|
import { syncAgentFiles } from "../agents/files.js";
|
|
16
|
+
import { ui } from "../cli/ui.js";
|
|
17
|
+
import { atWork } from "../core/relocate.js";
|
|
18
|
+
import { moveToTrash } from "../core/trash.js";
|
|
19
|
+
import { stopLive } from "../core/live.js";
|
|
20
|
+
import { onlyItsFiles, packPlan } from "../core/pack.js";
|
|
21
|
+
import { defaultPackFile, packTree } from "./pack.js";
|
|
16
22
|
register({
|
|
17
23
|
path: ["init"],
|
|
18
24
|
summary: "Create a family tree (a folder with its own history) to hold researches",
|
|
@@ -81,6 +87,80 @@ register({
|
|
|
81
87
|
return { text: `now working on "${tree.config.name}" (${ctx.display(root)})`, data: { name: tree.config.name, root } };
|
|
82
88
|
},
|
|
83
89
|
});
|
|
90
|
+
register({
|
|
91
|
+
path: ["trees", "remove"],
|
|
92
|
+
summary: "Take a family tree off this computer — into the system's trash, a backup offered first (the person runs this)",
|
|
93
|
+
group: "research",
|
|
94
|
+
description: "Offers a backup first (strom pack --all-images: it comes back with strom unpack), then the images no other tree\n" +
|
|
95
|
+
"uses; the person types the tree's name to confirm. The tree's folder goes into the system's trash (never deleted\n" +
|
|
96
|
+
"for good), so do the images they chose. Never while somebody works on it; only the person at the computer —\n" +
|
|
97
|
+
"never an agent.",
|
|
98
|
+
args: [{ name: "tree", description: "tree name or folder", required: true }],
|
|
99
|
+
examples: ['strom trees remove "Novákovi"'],
|
|
100
|
+
async run(ctx, { args }) {
|
|
101
|
+
const lang = ctx.uiLang();
|
|
102
|
+
const probe = Context.fromOptions({ env: ctx.env, cwd: ctx.cwd, io: ctx.io, json: ctx.json, values: { tree: args[0] } });
|
|
103
|
+
const root = probe.locateTree();
|
|
104
|
+
if (!ctx.interactive)
|
|
105
|
+
throw new NeedsConsentError([{ key: "remove", kind: "consent", question: ui(lang, "ui.remove.person"), set: `strom trees remove "${root}"` }]);
|
|
106
|
+
const tree = Tree.open(root, ctx.env);
|
|
107
|
+
const name = tree.config.name;
|
|
108
|
+
// the Strom app following it live is no work: its bridge only reads, and stops before the tree goes
|
|
109
|
+
if (atWork(root, { bridge: false }))
|
|
110
|
+
throw new UsageError(ui(lang, "ui.remove.busy", { name }), { hint: "strom" });
|
|
111
|
+
const shared = ctx.settings.shared().value;
|
|
112
|
+
const others = ctx.knownTrees().map((k) => k.root).filter((r) => path.resolve(r) !== path.resolve(root));
|
|
113
|
+
const only = onlyItsFiles(root, others, shared);
|
|
114
|
+
const onlyBytes = only.reduce((s, f) => s + f.size, 0);
|
|
115
|
+
const mb = (b) => (b / 1e6 < 10 ? (b / 1e6).toFixed(1) : Math.round(b / 1e6).toString());
|
|
116
|
+
ctx.io.stdout(ui(lang, "ui.remove.what", { name, root: ctx.display(root), persons: tree.list("person").length }) + "\n");
|
|
117
|
+
// a way back first
|
|
118
|
+
let backup;
|
|
119
|
+
const all = packPlan(tree, shared, true).media.reduce((s, m) => s + m.size, 0);
|
|
120
|
+
if (await ctx.confirm(ui(lang, "ui.remove.backup", { mb: mb(all) }), true)) {
|
|
121
|
+
const out = defaultPackFile(ctx, tree);
|
|
122
|
+
backup = packTree(tree, shared, out, true).file;
|
|
123
|
+
ctx.io.stdout(ui(lang, "ui.remove.backup.done", { file: ctx.display(backup) }) + "\n");
|
|
124
|
+
}
|
|
125
|
+
const images = only.length > 0 && (await ctx.confirm(ui(lang, "ui.remove.images", { n: only.length, mb: mb(onlyBytes) }), backup !== undefined));
|
|
126
|
+
const typed = (await ctx.ask(ui(lang, "ui.remove.type", { name }))).trim();
|
|
127
|
+
if (!typed || foldText(typed) !== foldText(name))
|
|
128
|
+
return { text: ui(lang, "ui.remove.cancelled"), data: { removed: false, ...(backup ? { backup } : {}) } };
|
|
129
|
+
if (atWork(root, { bridge: false }))
|
|
130
|
+
throw new UsageError(ui(lang, "ui.remove.busy", { name }), { hint: "strom" });
|
|
131
|
+
const followed = stopLive(root, "the family tree is taken off this computer");
|
|
132
|
+
moveToTrash(root, ctx.env, path.basename(root));
|
|
133
|
+
let moved = 0;
|
|
134
|
+
if (images) {
|
|
135
|
+
// the images together, one folder in the trash named after the tree
|
|
136
|
+
const folder = path.join(shared, `.${safeFolderName(name) || "tree"}-${Date.now()}`);
|
|
137
|
+
for (const f of only) {
|
|
138
|
+
const to = path.join(folder, ...f.file.split("/"));
|
|
139
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
140
|
+
fs.renameSync(path.join(shared, ...f.file.split("/")), to);
|
|
141
|
+
moved++;
|
|
142
|
+
}
|
|
143
|
+
moveToTrash(folder, ctx.env, ui(lang, "ui.remove.images.folder", { name }));
|
|
144
|
+
}
|
|
145
|
+
const cfg = ctx.settings.config;
|
|
146
|
+
if (cfg.extraTrees)
|
|
147
|
+
cfg.extraTrees = cfg.extraTrees.filter((r) => path.resolve(r) !== path.resolve(root));
|
|
148
|
+
// the tree worked on went: the next one known is worked on now, never a menu with none chosen
|
|
149
|
+
let next;
|
|
150
|
+
if (cfg.currentTree && path.resolve(cfg.currentTree) === path.resolve(root)) {
|
|
151
|
+
const left = ctx.knownTrees().filter((k) => path.resolve(k.root) !== path.resolve(root));
|
|
152
|
+
if (left.length) {
|
|
153
|
+
cfg.currentTree = left[0].root;
|
|
154
|
+
next = left[0].name;
|
|
155
|
+
}
|
|
156
|
+
else
|
|
157
|
+
delete cfg.currentTree;
|
|
158
|
+
}
|
|
159
|
+
ctx.settings.save();
|
|
160
|
+
const text = lines(ui(lang, "ui.remove.done", { name }), followed ? ui(lang, "ui.remove.live") : undefined, moved ? ui(lang, "ui.remove.images.done", { n: moved, mb: mb(onlyBytes) }) : undefined, backup ? ui(lang, "ui.remove.backup.back", { file: ctx.display(backup) }) : undefined, ui(lang, backup ? "ui.remove.trash" : "ui.remove.trash.alone"), next ? ui(lang, "ui.remove.next", { name: next }) : undefined);
|
|
161
|
+
return { text, data: { removed: true, root, images: moved, ...(backup ? { backup } : {}), ...(next ? { current: cfg.currentTree } : {}) } };
|
|
162
|
+
},
|
|
163
|
+
});
|
|
84
164
|
register({
|
|
85
165
|
path: ["status"],
|
|
86
166
|
summary: "State of the current tree: researches, data, checks, recent changes",
|