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/cli/wizard.js
CHANGED
|
@@ -18,6 +18,7 @@ import { openForUser } from "../core/open.js";
|
|
|
18
18
|
import { createShortcut } from "../core/shortcut.js";
|
|
19
19
|
import { desktopDir } from "../core/paths.js";
|
|
20
20
|
import { ensureShared } from "../commands/setup.js";
|
|
21
|
+
import { moveHome, planMove, repointSettings, sameFolder } from "../core/relocate.js";
|
|
21
22
|
/** The shortcut's name, in the user's language. */
|
|
22
23
|
export function shortcutName(lang) {
|
|
23
24
|
return ui(lang, "ui.dialog.title");
|
|
@@ -46,9 +47,18 @@ export async function setupWizard(ctx) {
|
|
|
46
47
|
// Run again: each choice can be left as it is (0), the current one suggested.
|
|
47
48
|
const keep = first ? {} : { back: ui(lang, "ui.keep") };
|
|
48
49
|
// 2. Where the research lives.
|
|
49
|
-
const
|
|
50
|
+
const was = s.home()?.value;
|
|
51
|
+
const typed = (await ctx.ask(ui(lang, first ? "ui.setup.home" : "ui.setup.home.again"), ctx.display(was ?? s.suggestedHome()))).trim();
|
|
52
|
+
// Run again: 0 (or nothing) keeps the folder, like every other answer here.
|
|
53
|
+
let home = was && (typed === "0" || !typed) ? was : ctx.resolvePath(typed || ctx.display(s.suggestedHome()));
|
|
54
|
+
// Another folder while the research is in the old one: it moves along (trees, the shared folder) — or stays where it is.
|
|
55
|
+
if (was && !sameFolder(home, was))
|
|
56
|
+
home = await moveResearch(ctx, lang, was, home);
|
|
50
57
|
cfg.lang = lang;
|
|
51
|
-
|
|
58
|
+
if (was && !sameFolder(home, was))
|
|
59
|
+
repointSettings(s, ctx.env, was, home);
|
|
60
|
+
else if (!was)
|
|
61
|
+
cfg.home = home;
|
|
52
62
|
s.save();
|
|
53
63
|
fs.mkdirSync(home, { recursive: true });
|
|
54
64
|
fs.mkdirSync(s.trees().value, { recursive: true });
|
|
@@ -167,3 +177,39 @@ export async function askStromApp(ctx, lang, back = ui(lang, "ui.browse.back"))
|
|
|
167
177
|
}
|
|
168
178
|
return answer;
|
|
169
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* The research to another folder: what moves is said (the trees, the shared folder with the plugins and images), and it
|
|
182
|
+
* moves on the person's yes. Where it cannot (somebody at work, a folder with something in it, into itself) it stays.
|
|
183
|
+
* Returns the folder the research is in afterwards.
|
|
184
|
+
*/
|
|
185
|
+
async function moveResearch(ctx, lang, from, to) {
|
|
186
|
+
const out = (line) => ctx.io.stdout(line + "\n");
|
|
187
|
+
// Set from outside (STROM_HOME): the folder is not the wizard's to change.
|
|
188
|
+
if (ctx.settings.home()?.source !== "config") {
|
|
189
|
+
out(ui(lang, "ui.home.env", { from: ctx.display(from) }));
|
|
190
|
+
return from;
|
|
191
|
+
}
|
|
192
|
+
const plan = planMove(from, to, ctx.knownTrees().map((k) => k.root));
|
|
193
|
+
if (!plan.content)
|
|
194
|
+
return to;
|
|
195
|
+
const where = { from: ctx.display(from), to: ctx.display(plan.to) };
|
|
196
|
+
if (plan.problem) {
|
|
197
|
+
out(ui(lang, `ui.home.${plan.problem}`, { ...where, trees: plan.busy.join(", ") }));
|
|
198
|
+
out(ui(lang, "ui.home.stays", where));
|
|
199
|
+
return from;
|
|
200
|
+
}
|
|
201
|
+
out(ui(lang, "ui.home.what", { ...where, trees: plan.trees.length ? plan.trees.join(", ") : "–" }));
|
|
202
|
+
if (!(await ctx.confirm(ui(lang, "ui.home.sure"), true))) {
|
|
203
|
+
out(ui(lang, "ui.home.stays", where));
|
|
204
|
+
return from;
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const how = moveHome(plan);
|
|
208
|
+
out(ui(lang, how === "moved" ? "ui.home.moved" : "ui.home.copied", where));
|
|
209
|
+
return plan.to;
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
out(ui(lang, "ui.home.failed", { ...where, reason: err.message }));
|
|
213
|
+
return from;
|
|
214
|
+
}
|
|
215
|
+
}
|
package/dist/commands/browse.js
CHANGED
|
@@ -112,9 +112,10 @@ register({
|
|
|
112
112
|
out.push("", ui(lang, "ui.stats.tasks", s.tasks));
|
|
113
113
|
if (s.sessions.count)
|
|
114
114
|
out.push(ui(lang, "ui.stats.sessions", { n: s.sessions.count, last: humanDay(s.sessions.last, lang) }) +
|
|
115
|
-
(s.sessions.costUsd ? ui(lang, "ui.stats.cost", { cost: humanCost(s.sessions.costUsd, lang) }) : "")
|
|
115
|
+
(s.sessions.costUsd ? ui(lang, "ui.stats.cost", { cost: humanCost(s.sessions.costUsd, lang) }) : "") +
|
|
116
|
+
(s.sessions.costUsd && s.sessions.costPartial ? ui(lang, "ui.stats.cost.partial", { n: s.sessions.costPartial }) : ""));
|
|
116
117
|
if (s.stories.written)
|
|
117
|
-
out.push(ui(lang, "ui.stats.stories", { n: s.stories.written
|
|
118
|
+
out.push(ui(lang, "ui.stats.stories", { n: s.stories.written }));
|
|
118
119
|
return { text: lines(...out), data: s };
|
|
119
120
|
},
|
|
120
121
|
}, {
|
|
@@ -163,7 +164,7 @@ register({
|
|
|
163
164
|
}
|
|
164
165
|
if (c.story) {
|
|
165
166
|
const text = c.story.text.replace(/\*\*/g, "").trim();
|
|
166
|
-
out.push("", ui(lang, "ui.card.story"
|
|
167
|
+
out.push("", ui(lang, "ui.card.story"));
|
|
167
168
|
// markdown headings as plain lines
|
|
168
169
|
out.push(text.split("\n").map((l) => (l.trim() ? ` ${l.trim().replace(/^#+\s*/, "")}` : "")).join("\n"));
|
|
169
170
|
}
|
|
@@ -26,7 +26,8 @@ import { imageSizeOfFile } from "../image/index.js";
|
|
|
26
26
|
import { Tree } from "../core/tree.js";
|
|
27
27
|
import { syncAgentFiles } from "../agents/files.js";
|
|
28
28
|
import { clearBlock, CookieJar, DEFAULT_PACE, hostAllowed, hostPace, hostState, knownHosts, MIN_INTERVAL_MS, NetError, paceText, politeRequest, refusedBy, reserveSlots, setOwnPace } from "../core/net.js";
|
|
29
|
-
import { botCheck, fileBase, findDownload, loadPage, loadPlans, pageKey, pageScript, parseResult, PLAN_TTL_MS, planScript, readSavedPage, removePlan, savePage, savePlan, SCRIPT_MS, } from "../core/browser.js";
|
|
29
|
+
import { botCheck, fileBase, findDownload, loadPage, loadPlans, pageKey, pageScript, parseResult, PLAN_TTL_MS, planScript, readSavedPage, removePlan, savePage, savePlan, SCRIPT_MS, claudeInChrome, CLAUDE_IN_CHROME_URL, } from "../core/browser.js";
|
|
30
|
+
import { PROFILES } from "../agents/profiles.js";
|
|
30
31
|
import { bareHost, changedSinceConsent, codeWarning, consentRequired, connectorHash, connectorsDir, directNetwork, ensurePluginsDir, findConnector, hostWarning, imageProblem, INTERFACE, listConnectors, loadConsents, MANIFEST, missingConsents, NAME_RE, readManifest, routeOf, treeBrowserConnectors, routesOf, ROUTES, runConnector, saveConsents, scanConnectors, suggestName, } from "../core/connector.js";
|
|
31
32
|
import { registerImages } from "./media.js";
|
|
32
33
|
function shared(ctx) {
|
|
@@ -304,7 +305,7 @@ register({
|
|
|
304
305
|
const hosts = c.manifest.hosts.map(bareHost);
|
|
305
306
|
return {
|
|
306
307
|
text: via === "browser"
|
|
307
|
-
? lines(`${c.name}: images through your browser from now on (${hosts.join(", ")})`, ` · the agent gets browser tools (Claude in Chrome) for these sites only, in the family trees that work with this`, ` archive (images fetched through it, or an archive, book or record of theirs on its site), from its next session${synced.length ? ` (permissions of ${synced.join(", ")} updated)` : ""}. Chrome with the`, " Claude extension must be running then, signed in to the account Claude Code uses; browser tools need a model", " that can review its actions (Sonnet or Opus, not Haiku)", ` · strom plans and paces every request, and takes the files over from ${ctx.display(s.downloads())}`, " (another folder: strom config set browser.downloads <folder>)", ` · the first time, Chrome asks whether ${hosts[0]} may download several files: allow it once`, ` · back: strom connector use ${c.name} --via direct
|
|
308
|
+
? lines(`${c.name}: images through your browser from now on (${hosts.join(", ")})`, ` · the agent gets browser tools (Claude in Chrome) for these sites only, in the family trees that work with this`, ` archive (images fetched through it, or an archive, book or record of theirs on its site), from its next session${synced.length ? ` (permissions of ${synced.join(", ")} updated)` : ""}. Chrome with the`, " Claude extension must be running then, signed in to the account Claude Code uses; browser tools need a model", " that can review its actions (Sonnet or Opus, not Haiku)", ` · strom plans and paces every request, and takes the files over from ${ctx.display(s.downloads())}`, " (another folder: strom config set browser.downloads <folder>)", ` · the first time, Chrome asks whether ${hosts[0]} may download several files: allow it once`, ` · back: strom connector use ${c.name} --via direct`, ...browserWarnings(ctx))
|
|
308
309
|
: lines(`${c.name}: images directly through strom from now on, paced (${hosts.join(", ")})`, routesOf(c).includes("browser") ? ` · through your browser again: strom connector use ${c.name} --via browser` : undefined),
|
|
309
310
|
data: { connector: c.name, via, hosts, synced },
|
|
310
311
|
};
|
|
@@ -1440,3 +1441,25 @@ function textFiles(dir) {
|
|
|
1440
1441
|
walk(dir);
|
|
1441
1442
|
return out;
|
|
1442
1443
|
}
|
|
1444
|
+
/**
|
|
1445
|
+
* What the person is told of the archives this tree reaches through their browser, for the agent that works: only
|
|
1446
|
+
* Claude Code has browser tools (Claude in Chrome), and the extension must be in a browser here. Nothing to say: undefined.
|
|
1447
|
+
*/
|
|
1448
|
+
export function browserNote(ctx, tree, agent) {
|
|
1449
|
+
const via = treeBrowserConnectors(tree, ctx.settings.shared()?.value);
|
|
1450
|
+
if (!via.length)
|
|
1451
|
+
return undefined;
|
|
1452
|
+
const lang = ctx.uiLang();
|
|
1453
|
+
const names = via.map((c) => c.manifest.title ?? c.name).join(", ");
|
|
1454
|
+
if (agent !== "claude")
|
|
1455
|
+
return ui(lang, "ui.browser.agent", { names, agent: PROFILES[agent]?.name ?? agent });
|
|
1456
|
+
const found = claudeInChrome(ctx.env).extension;
|
|
1457
|
+
return found.length ? undefined : ui(lang, "ui.browser.noext", { names, url: CLAUDE_IN_CHROME_URL });
|
|
1458
|
+
}
|
|
1459
|
+
/** Through the browser: what stands in the way here (English, for the agent — which tells the user). */
|
|
1460
|
+
function browserWarnings(ctx) {
|
|
1461
|
+
const agent = ctx.settings.agent(ctx.hasTree() ? ctx.tree().config : undefined).value;
|
|
1462
|
+
if (agent !== "claude")
|
|
1463
|
+
return [` ⚠ only Claude Code has browser tools (Claude in Chrome); this research's agent is ${PROFILES[agent]?.name ?? agent} — tell the user: they save these images by hand, or choose Claude Code (strom agents use claude)`];
|
|
1464
|
+
return claudeInChrome(ctx.env).extension.length ? [] : [` ⚠ the Claude in Chrome extension is in no browser here — tell the user to install it and sign in: ${CLAUDE_IN_CHROME_URL}`];
|
|
1465
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Gates: the user's condition on the agent working alone (core/gate.ts).
|
|
2
|
+
import { register } from "../cli/registry.js";
|
|
3
|
+
import { lines, table } from "../cli/format.js";
|
|
4
|
+
import { StromError } from "../core/errors.js";
|
|
5
|
+
import { askGate, ensureGatesDir, gatesDir, listGates, loadGate } from "../core/gate.js";
|
|
6
|
+
function shared(ctx) {
|
|
7
|
+
const s = ctx.settings.shared()?.value;
|
|
8
|
+
if (!s)
|
|
9
|
+
throw new StromError("strom is not set up yet", { hint: "strom setup" });
|
|
10
|
+
ensureGatesDir(s);
|
|
11
|
+
return s;
|
|
12
|
+
}
|
|
13
|
+
register({
|
|
14
|
+
path: ["gate", "list"],
|
|
15
|
+
summary: "Gates: conditions on the agent working alone (strom run) — which there are, which one is asked",
|
|
16
|
+
group: "research",
|
|
17
|
+
run(ctx) {
|
|
18
|
+
const s = shared(ctx);
|
|
19
|
+
const set = ctx.settings.runGate();
|
|
20
|
+
const setName = set?.split(" ")[0];
|
|
21
|
+
const gates = listGates(s);
|
|
22
|
+
const folder = `folder: ${ctx.display(gatesDir(s))} — copy a gate's folder in to install it (the interface: README.md there)`;
|
|
23
|
+
return {
|
|
24
|
+
text: lines(gates.length ? table(gates.map((g) => [g.name === setName ? `${g.name} ◀ run.gate${set !== setName ? ` (${set})` : ""}` : g.name, g.manifest.title ?? "", g.manifest.command.join(" ")])) : "no gates", set ? `asked before each session of strom run: ${set} (strom config unset run.gate: none)` : "none is asked: strom config set run.gate <name> (only you)", folder),
|
|
25
|
+
data: { folder: gatesDir(s), gate: set ?? null, gates: gates.map((g) => ({ name: g.name, title: g.manifest.title, command: g.manifest.command, dir: g.dir })) },
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
}, {
|
|
29
|
+
path: ["gate", "test"],
|
|
30
|
+
summary: "Ask a gate now what it would answer before a session: go on, wait (until when) or stop — and why",
|
|
31
|
+
group: "research",
|
|
32
|
+
args: [{ name: "gate", description: 'the gate and what it is given, e.g. "claude-usage 10" (default: run.gate)' }],
|
|
33
|
+
examples: ["strom gate test", 'strom gate test "claude-usage 10"'],
|
|
34
|
+
run(ctx, { args }) {
|
|
35
|
+
const s = shared(ctx);
|
|
36
|
+
const name = args[0] ?? ctx.settings.runGate();
|
|
37
|
+
if (!name)
|
|
38
|
+
throw new StromError("no gate named and none set", { hint: "strom gate list — strom gate test <name>" });
|
|
39
|
+
const gate = loadGate(s, name);
|
|
40
|
+
const tree = ctx.hasTree() ? ctx.tree() : undefined;
|
|
41
|
+
const agent = ctx.settings.agent(tree?.config).value;
|
|
42
|
+
const said = askGate(gate, ctx.env, {
|
|
43
|
+
tree: tree?.root ?? "",
|
|
44
|
+
lang: tree?.lang ?? ctx.uiLang(),
|
|
45
|
+
agent,
|
|
46
|
+
model: ctx.settings.models(agent, tree?.config).lead,
|
|
47
|
+
sessions: 0,
|
|
48
|
+
costUsd: 0,
|
|
49
|
+
});
|
|
50
|
+
const until = said.verdict === "wait" ? new Date(Date.now() + said.waitMs) : undefined;
|
|
51
|
+
const word = { go: "go on", wait: `wait until ${until?.toLocaleString(ctx.uiLang(), { weekday: "short", day: "numeric", month: "numeric", hour: "2-digit", minute: "2-digit" })}`, stop: "stop", error: "error — a run would stop" }[said.verdict];
|
|
52
|
+
return {
|
|
53
|
+
text: `${[gate.name, ...gate.args].join(" ")}: ${word}${said.reason ? ` — ${said.reason}` : ""}`,
|
|
54
|
+
data: { gate: gate.name, verdict: said.verdict, ...(said.reason ? { reason: said.reason } : {}), ...(until ? { until: until.toISOString() } : {}) },
|
|
55
|
+
exitCode: said.verdict === "error" ? 1 : 0,
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Hooks: the user's programs told of what is saved into a research (core/hooks.ts).
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { register } from "../cli/registry.js";
|
|
5
|
+
import { lines, table } from "../cli/format.js";
|
|
6
|
+
import { ui } from "../cli/ui.js";
|
|
7
|
+
import { StromError, UsageError } from "../core/errors.js";
|
|
8
|
+
import { readJsonLines } from "../core/json.js";
|
|
9
|
+
import { prependPath } from "../runners/runner.js";
|
|
10
|
+
import { shimDir } from "./session.js";
|
|
11
|
+
import { ensureHooksDir, forHook, HOOK_INTERFACE, hooksDir, listHooks, loadHook, runHookNow } from "../core/hooks.js";
|
|
12
|
+
function shared(ctx) {
|
|
13
|
+
const s = ctx.settings.shared()?.value;
|
|
14
|
+
if (!s)
|
|
15
|
+
throw new StromError("strom is not set up yet", { hint: "strom setup" });
|
|
16
|
+
ensureHooksDir(s);
|
|
17
|
+
return s;
|
|
18
|
+
}
|
|
19
|
+
/** Turning a hook on or off is the user's decision: an agent asks them (a window of the system). */
|
|
20
|
+
function setHook(ctx, name, on) {
|
|
21
|
+
const s = shared(ctx);
|
|
22
|
+
const now = ctx.settings.config.hooks ?? [];
|
|
23
|
+
if (now.includes(name) === on)
|
|
24
|
+
return { text: `${name}: ${on ? "on" : "off"} already`, data: { hook: name, on, hooks: now } };
|
|
25
|
+
// off: also a hook whose folder is gone or broken — it goes from the list all the same
|
|
26
|
+
let hook;
|
|
27
|
+
try {
|
|
28
|
+
hook = loadHook(s, name);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
if (on)
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
const title = hook?.manifest.title ?? name;
|
|
35
|
+
ctx.requireHuman(on ? `Let the hook "${name}" be told of everything saved into your researches?` : `Stop telling the hook "${name}"?`, `strom hook ${on ? "on" : "off"} ${name}`, "hooks", ui(ctx.uiLang(), on ? "ui.consent.hook.on" : "ui.consent.hook.off", { name: title }));
|
|
36
|
+
ctx.settings.reload();
|
|
37
|
+
const next = on ? [...(ctx.settings.config.hooks ?? []).filter((h) => h !== name), name] : (ctx.settings.config.hooks ?? []).filter((h) => h !== name);
|
|
38
|
+
ctx.settings.config.hooks = next.length ? next : undefined;
|
|
39
|
+
ctx.settings.save();
|
|
40
|
+
return {
|
|
41
|
+
text: on && hook ? `${name}: on — told of what is saved (${(hook.manifest.events ?? ["*"]).join(", ")}); its output: ${ctx.display(path.join(hook.dir, "hook.log"))}` : `${name}: off`,
|
|
42
|
+
data: { hook: name, on, hooks: next },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** The last operations saved into this research, newest last. */
|
|
46
|
+
function lastOps(root, n) {
|
|
47
|
+
const dir = path.join(root, "data", "ops");
|
|
48
|
+
let files = [];
|
|
49
|
+
try {
|
|
50
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
return files
|
|
56
|
+
.flatMap((f) => readJsonLines(path.join(dir, f)))
|
|
57
|
+
.sort((a, b) => a.at.localeCompare(b.at))
|
|
58
|
+
.slice(-n)
|
|
59
|
+
.map((o) => ({ op: o.op, targets: o.targets, summary: o.summary, at: o.at, by: o.by, ...(o.reason ? { reason: o.reason } : {}) }));
|
|
60
|
+
}
|
|
61
|
+
register({
|
|
62
|
+
path: ["hook", "list"],
|
|
63
|
+
summary: "Hooks: your programs told of what is saved into a research (a message to your phone, a log…) — which there are, which are on",
|
|
64
|
+
group: "setup",
|
|
65
|
+
run(ctx) {
|
|
66
|
+
const s = shared(ctx);
|
|
67
|
+
const on = ctx.settings.config.hooks ?? [];
|
|
68
|
+
const hooks = listHooks(s);
|
|
69
|
+
return {
|
|
70
|
+
text: lines(hooks.length
|
|
71
|
+
? table(hooks.map((h) => [on.includes(h.name) ? `${h.name} ◀ on` : h.name, h.manifest.title ?? "", (h.manifest.events ?? ["*"]).join(" "), h.manifest.command.join(" ")]))
|
|
72
|
+
: "no hooks", on.length ? `on: ${on.join(", ")} (strom hook off <name>)` : "none is on: strom hook on <name> (only you)", `folder: ${ctx.display(hooksDir(s))} — copy a hook's folder in to install it (the interface: README.md there)`),
|
|
73
|
+
data: {
|
|
74
|
+
folder: hooksDir(s),
|
|
75
|
+
on,
|
|
76
|
+
hooks: hooks.map((h) => ({ name: h.name, title: h.manifest.title, events: h.manifest.events ?? ["*"], command: h.manifest.command, dir: h.dir, on: on.includes(h.name) })),
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
}, {
|
|
81
|
+
path: ["hook", "on"],
|
|
82
|
+
summary: "Turn a hook on: from now on it is told of what is saved into your researches (only you)",
|
|
83
|
+
group: "setup",
|
|
84
|
+
args: [{ name: "name", description: "the hook's folder name", required: true }],
|
|
85
|
+
examples: ["strom hook on telegram"],
|
|
86
|
+
run: (ctx, { args }) => setHook(ctx, args[0], true),
|
|
87
|
+
}, {
|
|
88
|
+
path: ["hook", "off"],
|
|
89
|
+
summary: "Turn a hook off (only you)",
|
|
90
|
+
group: "setup",
|
|
91
|
+
args: [{ name: "name", description: "the hook's folder name", required: true }],
|
|
92
|
+
run: (ctx, { args }) => setHook(ctx, args[0], false),
|
|
93
|
+
}, {
|
|
94
|
+
path: ["hook", "test"],
|
|
95
|
+
summary: "Run a hook now on what was saved last in this research (or one operation you name), wait for it and show what it printed",
|
|
96
|
+
group: "setup",
|
|
97
|
+
tree: true,
|
|
98
|
+
args: [{ name: "name", description: "the hook's folder name", required: true }],
|
|
99
|
+
options: [
|
|
100
|
+
{ name: "last", type: "string", value: "<n>", description: "the last n operations saved (default 5)" },
|
|
101
|
+
{ name: "op", type: "string", value: "<op>", description: "one operation instead, e.g. person.add (with --target)" },
|
|
102
|
+
{ name: "target", type: "string", value: "<ID>", description: "the record it concerns, e.g. P0001" },
|
|
103
|
+
],
|
|
104
|
+
examples: ["strom hook test telegram", "strom hook test telegram --op person.add --target P0001"],
|
|
105
|
+
run(ctx, { args, opts }) {
|
|
106
|
+
const s = shared(ctx);
|
|
107
|
+
const hook = loadHook(s, args[0]);
|
|
108
|
+
const tree = ctx.tree();
|
|
109
|
+
const n = opts.last === undefined ? 5 : Number(opts.last);
|
|
110
|
+
if (!Number.isInteger(n) || n < 1)
|
|
111
|
+
throw new UsageError("--last must be a positive number");
|
|
112
|
+
const all = typeof opts.op === "string"
|
|
113
|
+
? [{ op: opts.op, targets: typeof opts.target === "string" ? [opts.target] : [], summary: `(a test of ${opts.op})`, at: new Date().toISOString(), by: "user" }]
|
|
114
|
+
: lastOps(tree.root, n);
|
|
115
|
+
const events = forHook(hook, all);
|
|
116
|
+
if (!events.length)
|
|
117
|
+
return {
|
|
118
|
+
text: `${hook.name} wants none of these: ${[...new Set(all.map((e) => e.op))].join(", ") || "nothing saved yet"} (its events: ${(hook.manifest.events ?? ["*"]).join(", ")})`,
|
|
119
|
+
data: { hook: hook.name, events: [] },
|
|
120
|
+
};
|
|
121
|
+
const r = runHookNow(hook, prependPath(ctx.env, shimDir(tree)), {
|
|
122
|
+
interface: HOOK_INTERFACE,
|
|
123
|
+
hook: hook.name,
|
|
124
|
+
tree: { name: tree.config.name, id: tree.config.id, root: tree.root, lang: tree.lang },
|
|
125
|
+
commit: "test",
|
|
126
|
+
at: new Date().toISOString(),
|
|
127
|
+
events,
|
|
128
|
+
});
|
|
129
|
+
const ok = !r.error && r.status === 0;
|
|
130
|
+
return {
|
|
131
|
+
text: lines(`${hook.name}: ${events.length} operation(s) — ${ok ? "ended well" : `failed: ${r.error ?? `exit status ${r.status}`}`}`, r.output
|
|
132
|
+
? r.output
|
|
133
|
+
.split("\n")
|
|
134
|
+
.map((l) => ` ${l}`)
|
|
135
|
+
.join("\n")
|
|
136
|
+
: undefined),
|
|
137
|
+
data: { hook: hook.name, events, status: r.status, ...(r.error ? { error: r.error } : {}), output: r.output },
|
|
138
|
+
exitCode: ok ? 0 : 1,
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
});
|
package/dist/commands/index.js
CHANGED
package/dist/commands/intake.js
CHANGED
|
@@ -67,8 +67,8 @@ function groupTask(tree, inputs, research, imported, folder) {
|
|
|
67
67
|
return create(tree, "task", { level: "intake", priority: 4, what, where: ids, why, doneWhen, subject: ids, research, state: "open", origin: tree.actor }, (id) => `+${id} task "${truncate(what, 60)}"`);
|
|
68
68
|
}
|
|
69
69
|
/** More images than this in one folder look like the scans of a book, not family documents. */
|
|
70
|
-
const BOOK_OF_SCANS = 20;
|
|
71
|
-
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".tif", ".tiff", ".jp2", ".gif", ".webp", ".heic"]);
|
|
70
|
+
export const BOOK_OF_SCANS = 20;
|
|
71
|
+
export const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".tif", ".tiff", ".jp2", ".gif", ".webp", ".heic"]);
|
|
72
72
|
register({
|
|
73
73
|
path: ["intake"],
|
|
74
74
|
summary: "Take in material to research from: folders, files, a family tree (GEDCOM / Strom JSON), or text",
|
|
@@ -3,12 +3,16 @@ import { register } from "../cli/registry.js";
|
|
|
3
3
|
import { lines, table } from "../cli/format.js";
|
|
4
4
|
import { addPerson, addResearch } from "../core/actions.js";
|
|
5
5
|
import { applyFrontier, researchProposals } from "./session.js";
|
|
6
|
+
import { frontier } from "../core/frontier.js";
|
|
7
|
+
import { parkedWhy } from "./tasks.js";
|
|
8
|
+
import { ui } from "../cli/ui.js";
|
|
6
9
|
import { phrase } from "../core/phrases.js";
|
|
7
10
|
import { UsageError } from "../core/errors.js";
|
|
8
11
|
import { REVIEW_SCOPES } from "../core/model.js";
|
|
9
12
|
import { ancestorGenerations, displayName, label, lifespan, parentsOf, resolvePerson } from "../core/people.js";
|
|
10
13
|
import { foldText } from "../core/text.js";
|
|
11
14
|
import { update } from "../core/records.js";
|
|
15
|
+
import { isAgent } from "../core/which.js";
|
|
12
16
|
function int(v, name) {
|
|
13
17
|
if (v === undefined)
|
|
14
18
|
return undefined;
|
|
@@ -175,27 +179,66 @@ register({
|
|
|
175
179
|
else if (opts.model)
|
|
176
180
|
throw new UsageError("--model goes with --reread");
|
|
177
181
|
const review = { scope: scope, ...(reread ? { reread } : {}) };
|
|
182
|
+
const lang = tree.lang;
|
|
178
183
|
// one review research per person: again later, it goes on where it was
|
|
179
184
|
const existing = tree.list("research").find((r) => r.direction === "person" && r.focus === p.id && r.review);
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
185
|
+
// What it would propose, asked before anything is written: with nothing to do, no research is made for it.
|
|
186
|
+
const draft = existing
|
|
187
|
+
? { ...existing, state: "active", review }
|
|
188
|
+
: { id: "G?", type: "research", name: phrase(lang, "review.research", { name: displayName(p) }), focus: p.id, direction: "person", state: "active", review, notes: [] };
|
|
189
|
+
const planned = researchProposals(tree, draft).map((x) => x.proposal);
|
|
190
|
+
let research = draft;
|
|
191
|
+
let tasks = [];
|
|
192
|
+
if (planned.length && !tree.dryRun) {
|
|
193
|
+
if (existing)
|
|
194
|
+
research = update(tree, existing.id, "research", (r) => ({ ...r, state: "active", review }), {
|
|
195
|
+
op: "research.edit",
|
|
196
|
+
summary: `${existing.id} review again: ${scope}${reread ? `, second reading with ${reread}` : ""}`,
|
|
197
|
+
});
|
|
198
|
+
else {
|
|
199
|
+
const r = addResearch(tree, { name: draft.name, focus: p.id, direction: "person" });
|
|
200
|
+
research = update(tree, r.id, "research", (x) => ({ ...x, review }), { op: "research.edit", summary: `${r.id} review: ${scope}${reread ? `, second reading with ${reread}` : ""}` });
|
|
201
|
+
}
|
|
202
|
+
tasks = applyFrontier(tree, research)
|
|
203
|
+
.map((id) => tree.get(id))
|
|
204
|
+
.filter((t) => !!t);
|
|
189
205
|
}
|
|
190
|
-
const
|
|
191
|
-
const created = tree.dryRun ? [] : applyFrontier(tree, research);
|
|
192
|
-
const tasks = created.map((id) => tree.get(id)).filter((t) => !!t);
|
|
193
|
-
const open = tree.list("task").filter((t) => t.research === research.id && ["open", "doing"].includes(t.state));
|
|
206
|
+
const open = existing ? tree.list("task").filter((t) => t.research === existing.id && ["open", "doing"].includes(t.state)) : [];
|
|
194
207
|
// what a session costs here, from those so far: the user decides with the price in view
|
|
195
|
-
const costs = tree
|
|
208
|
+
const costs = tree
|
|
209
|
+
.list("session")
|
|
210
|
+
.filter((s) => !s.metrics?.costPartial) // stopped before it said: more than it shows
|
|
211
|
+
.map((s) => s.metrics?.costUsd)
|
|
212
|
+
.filter((c) => typeof c === "number");
|
|
196
213
|
const avg = costs.length ? costs.reduce((a, b) => a + b, 0) / costs.length : undefined;
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
|
|
214
|
+
// A person at a terminal (the menu) reads the tasks and what comes next, not the operations and commands.
|
|
215
|
+
const human = ctx.io.tty && !isAgent(ctx.env);
|
|
216
|
+
const list = tree.dryRun
|
|
217
|
+
? planned.map((t) => ui(lang, "ui.review.would", { level: t.level, what: t.what }))
|
|
218
|
+
: tasks.map((t) => (human ? ` • ${t.what}` : ` ${t.id} ${t.level} · ${t.what}`));
|
|
219
|
+
// Nothing new: why — the work that covers them already (a parked task says how to go on), a search done in vain.
|
|
220
|
+
const why = list.length ? [] : coveredLines(tree, draft, lang, human);
|
|
221
|
+
const inReview = existing ?? (planned.length && !tree.dryRun ? research : undefined);
|
|
222
|
+
const text = lines(...(human ? [] : tree.written.map((o) => o.summary)), tree.dryRun ? ui(lang, "ui.review.dry") : undefined, (tree.written.length && !human) || tree.dryRun ? "" : undefined, list.length ? ui(lang, "ui.review.new", { name: label(p), n: list.length }) : ui(lang, "ui.review.nothing", { name: label(p) }), ...list, ...why, open.length || tasks.length
|
|
223
|
+
? ui(lang, "ui.review.open", { n: open.length || tasks.length, research: inReview.id }) + (avg !== undefined ? ui(lang, "ui.review.cost", { cost: avg.toFixed(2) }) : "")
|
|
224
|
+
: undefined, (open.length || tasks.length) && inReview ? ui(lang, human ? "ui.review.next.human" : "ui.review.next", { research: inReview.id }) : undefined);
|
|
225
|
+
return { text, data: { research: inReview ?? null, created: tasks, planned, open: open.map((t) => t.id), ...(why.length ? { why } : {}), ...(avg !== undefined ? { avgCostUsd: avg } : {}) } };
|
|
200
226
|
},
|
|
201
227
|
});
|
|
228
|
+
/** Why a review found nothing new, person by person: the work that covers them already, or a search done in vain. */
|
|
229
|
+
function coveredLines(tree, research, lang, human = false) {
|
|
230
|
+
const out = [];
|
|
231
|
+
for (const item of frontier(tree, research)) {
|
|
232
|
+
const name = label(item.person);
|
|
233
|
+
const t = item.coveredBy ? tree.get(item.coveredBy) : undefined;
|
|
234
|
+
if (t) {
|
|
235
|
+
const why = parkedWhy(tree, t);
|
|
236
|
+
out.push(ui(lang, "ui.review.covered", { name, task: t.id, state: ui(lang, `ui.review.state.${t.state}`) + (why ? `: ${why}` : ""), what: t.what }));
|
|
237
|
+
if (t.state === "parked")
|
|
238
|
+
out.push(ui(lang, human ? "ui.review.wake.human" : "ui.review.wake", { task: t.id }));
|
|
239
|
+
}
|
|
240
|
+
else if (item.exhausted?.length)
|
|
241
|
+
out.push(ui(lang, "ui.review.exhausted", { name, tasks: item.exhausted.join(", ") }));
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|