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/core/browser.js
CHANGED
|
@@ -41,6 +41,56 @@ export const CHROME_ALLOW = [
|
|
|
41
41
|
export const CHROME_DENY = ["file_upload", "upload_image", "shortcuts_execute", "gif_creator", "browser_batch"].map((t) => `mcp__claude-in-chrome__${t}`);
|
|
42
42
|
/** The permission rule of Claude in Chrome for one site. */
|
|
43
43
|
export const chromeDomain = (host) => `ClaudeInChromeDomain(${host.replace(/^\*\./, "").toLowerCase()})`;
|
|
44
|
+
/** Claude in Chrome in the Chrome Web Store: the extension Claude Code's browser tools work through. */
|
|
45
|
+
export const CLAUDE_IN_CHROME_ID = "fcoeoabgfenejglbffodgkkbkcdhcgfn";
|
|
46
|
+
export const CLAUDE_IN_CHROME_URL = `https://chromewebstore.google.com/detail/${CLAUDE_IN_CHROME_ID}`;
|
|
47
|
+
/** The folders of the browsers' profiles (Chrome, Edge, Brave take extensions from the Chrome Web Store). */
|
|
48
|
+
function browserDataDirs(env, platform) {
|
|
49
|
+
const home = userHome(env);
|
|
50
|
+
if (platform === "darwin") {
|
|
51
|
+
const base = path.join(home, "Library", "Application Support");
|
|
52
|
+
return [
|
|
53
|
+
{ browser: "Chrome", dir: path.join(base, "Google", "Chrome") },
|
|
54
|
+
{ browser: "Edge", dir: path.join(base, "Microsoft Edge") },
|
|
55
|
+
{ browser: "Brave", dir: path.join(base, "BraveSoftware", "Brave-Browser") },
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
if (platform === "win32") {
|
|
59
|
+
const base = env.LOCALAPPDATA ?? path.join(home, "AppData", "Local");
|
|
60
|
+
return [
|
|
61
|
+
{ browser: "Chrome", dir: path.join(base, "Google", "Chrome", "User Data") },
|
|
62
|
+
{ browser: "Edge", dir: path.join(base, "Microsoft", "Edge", "User Data") },
|
|
63
|
+
{ browser: "Brave", dir: path.join(base, "BraveSoftware", "Brave-Browser", "User Data") },
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
const base = env.XDG_CONFIG_HOME ?? path.join(home, ".config");
|
|
67
|
+
return [
|
|
68
|
+
{ browser: "Chrome", dir: path.join(base, "google-chrome") },
|
|
69
|
+
{ browser: "Edge", dir: path.join(base, "microsoft-edge") },
|
|
70
|
+
{ browser: "Brave", dir: path.join(base, "BraveSoftware", "Brave-Browser") },
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Is Claude in Chrome installed — in which browsers? Only the extension's folder in the profiles is looked for (its
|
|
75
|
+
* name), nothing else of the browser is read. Whether it is signed in and connected strom cannot see.
|
|
76
|
+
*/
|
|
77
|
+
export function claudeInChrome(env, platform = process.platform) {
|
|
78
|
+
const browsers = [];
|
|
79
|
+
const extension = [];
|
|
80
|
+
for (const { browser, dir } of browserDataDirs(env, platform)) {
|
|
81
|
+
let profiles;
|
|
82
|
+
try {
|
|
83
|
+
profiles = fs.readdirSync(dir).filter((n) => n === "Default" || /^Profile \d+$/u.test(n));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
browsers.push(browser);
|
|
89
|
+
if (profiles.some((p) => fs.existsSync(path.join(dir, p, "Extensions", CLAUDE_IN_CHROME_ID))))
|
|
90
|
+
extension.push(browser);
|
|
91
|
+
}
|
|
92
|
+
return { browsers, extension };
|
|
93
|
+
}
|
|
44
94
|
/** The folder a browser saves downloads into by default: ~/Downloads, or what the desktop names it (Linux). */
|
|
45
95
|
export function downloadsDir(env, platform = process.platform) {
|
|
46
96
|
const home = userHome(env);
|
|
@@ -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
|
+
}
|
package/dist/core/config.js
CHANGED
|
@@ -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" },
|
|
@@ -52,6 +54,7 @@ export const SETTINGS = [
|
|
|
52
54
|
// Read from the config file only — no variable, no flag, no tree: nothing an agent can set.
|
|
53
55
|
{ key: "agent.permissions", env: "", tree: false, kind: "choice", choices: PERMISSION_LEVELS, description: "what the agent may do without asking you: ask (what the tree allows; anything else it asks), auto (default: what the tree allows; anything else the agent's own review decides, asking only when risky), full (everything but what the tree denies) — only you raise it" },
|
|
54
56
|
{ key: "agent.where", env: "STROM_AGENT_WHERE", tree: false, kind: "choice", choices: ["app", "terminal"], description: "where you talk with the agent: app (its desktop app — the easiest), terminal (its CLI) — unset: the app when it is installed" },
|
|
57
|
+
{ key: "agent.remote", env: "", tree: false, kind: "choice", choices: ["on", "off"], description: "the Claude Code sessions strom starts (strom run, strom chat in the terminal) with Remote Control: on — follow and steer them from claude.ai or the Claude app on your phone; off (default) — only you turn it on" },
|
|
55
58
|
{ key: "updates", env: "STROM_UPDATES", tree: false, kind: "choice", choices: ["check", "off"], description: "look for new versions of strom: check (default — at most once a day, one small file from the project's releases; strom says so, strom update installs it) or off" },
|
|
56
59
|
{ key: "strom.app", env: "", tree: false, kind: "choice", choices: ["yes", "no"], description: "you use the Strom app: yes (strom says which file to import into it), no (strom never mentions it) — unset: strom notices it itself" },
|
|
57
60
|
{ key: "strom.app.url", env: "STROM_APP_URL", tree: false, kind: "url", description: "another copy of the Strom app to open instead of https://stromapp.info/run/ — its beta (https://beta.stromapp.info/run/), its development (http://127.0.0.1:8080/); installed from a browser, that copy opens as its own app" },
|
|
@@ -67,12 +70,14 @@ const FIELDS = {
|
|
|
67
70
|
"browser.downloads": "browserDownloads",
|
|
68
71
|
"agent.permissions": "agentPermissions",
|
|
69
72
|
"agent.where": "agentWhere",
|
|
73
|
+
"agent.remote": "agentRemote",
|
|
70
74
|
"strom.app": "stromApp",
|
|
71
75
|
"strom.app.url": "stromAppUrl",
|
|
72
76
|
"main.person": "mainPerson",
|
|
73
77
|
"excerpts.quality": "excerptsQuality",
|
|
74
78
|
"excerpts.for": "excerptsFor",
|
|
75
79
|
"excerpts.mb": "excerptsMb",
|
|
80
|
+
"run.gate": "runGate",
|
|
76
81
|
};
|
|
77
82
|
/** Environment variables that are not settings but steer strom. */
|
|
78
83
|
export const OTHER_ENV = [
|
|
@@ -128,6 +133,11 @@ export function checkValue(def, raw, resolvePath) {
|
|
|
128
133
|
throw new UsageError(`invalid ${def.key} "${raw}"`, { hint: def.choices?.join(", ") });
|
|
129
134
|
return c;
|
|
130
135
|
}
|
|
136
|
+
case "plugin":
|
|
137
|
+
// its name, then what it is given: "claude-usage 10"
|
|
138
|
+
if (!/^[a-z0-9][a-z0-9-]*(\s+\S+)*$/.test(v))
|
|
139
|
+
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"' });
|
|
140
|
+
return v.split(/\s+/).join(" ");
|
|
131
141
|
case "person":
|
|
132
142
|
if (!/^[Pp]\d{4,}$/.test(v))
|
|
133
143
|
throw new UsageError(`invalid ${def.key} "${raw}"`, { hint: "a person's ID, e.g. P0009 (strom find <name>)" });
|
|
@@ -326,12 +336,20 @@ export class Settings {
|
|
|
326
336
|
downloads() {
|
|
327
337
|
return String(this.resolve("browser.downloads")?.value ?? downloadsDir(this.env));
|
|
328
338
|
}
|
|
339
|
+
/** Claude Code sessions strom starts with Remote Control (agent.remote: on). */
|
|
340
|
+
agentRemote() {
|
|
341
|
+
return this.resolve("agent.remote")?.value === "on";
|
|
342
|
+
}
|
|
329
343
|
/** Where the user wants to talk with the agent, if they said (env, else the config); see whereToTalk. */
|
|
330
344
|
agentWhere() {
|
|
331
345
|
const r = this.resolve("agent.where");
|
|
332
346
|
return r ? String(r.value) : undefined;
|
|
333
347
|
}
|
|
334
348
|
/** What the agent may do without asking — from the config file alone, which only the user raises. */
|
|
349
|
+
/** The gate of working alone, if the user set one (the config file only). */
|
|
350
|
+
runGate() {
|
|
351
|
+
return this.config.runGate || undefined;
|
|
352
|
+
}
|
|
335
353
|
agentPermissions() {
|
|
336
354
|
const v = this.config.agentPermissions ?? "auto";
|
|
337
355
|
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
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Hooks: the user's programs told of what was saved into a research. After
|
|
2
|
+
// every commit of a research, strom starts each hook the user turned on
|
|
3
|
+
// (`strom hook on <name>`) in the background — never waiting for it, never
|
|
4
|
+
// failing because of it — with the operations that commit saved: a person
|
|
5
|
+
// added, a fact, a task done, a session closed… A hook filters them
|
|
6
|
+
// (`events` in hook.json, or itself), may read more through strom (`strom
|
|
7
|
+
// person card P0002`) and does with it what the user wants: a message to
|
|
8
|
+
// their phone, a log, a backup.
|
|
9
|
+
//
|
|
10
|
+
// A hook is a folder in the plugins folder, <shared>/plugins/hooks/<name>/,
|
|
11
|
+
// with hook.json ({"interface": 1, "command": [...], "events": [...]}) and its
|
|
12
|
+
// program (interface: assets/plugins/hooks/README.md, copied next to the
|
|
13
|
+
// hooks). The event comes as one JSON document on the program's stdin; what it
|
|
14
|
+
// prints goes to hook.log in its folder. A hook runs only when the user turned
|
|
15
|
+
// it on — a folder an agent put there does nothing by itself.
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
19
|
+
import { StromError, UsageError } from "./errors.js";
|
|
20
|
+
import { NAME_RE, pluginsDir } from "./connector.js";
|
|
21
|
+
import { readAsset } from "./assets.js";
|
|
22
|
+
export const HOOK_INTERFACE = 1;
|
|
23
|
+
const MANIFEST = "hook.json";
|
|
24
|
+
/** How long `strom hook test` waits for a hook. */
|
|
25
|
+
const DEFAULT_TIMEOUT_S = 60;
|
|
26
|
+
/** Event files older than this are cleared away (a hook reads its own at once). */
|
|
27
|
+
const KEEP_MS = 24 * 3600_000;
|
|
28
|
+
/** The hook's output, appended; cut to its last part when it grows past this. */
|
|
29
|
+
const LOG_MAX = 1024 * 1024;
|
|
30
|
+
export function hooksDir(shared) {
|
|
31
|
+
return path.join(pluginsDir(shared), "hooks");
|
|
32
|
+
}
|
|
33
|
+
/** The hooks folder with its interface (refreshed). */
|
|
34
|
+
export function ensureHooksDir(shared) {
|
|
35
|
+
const dir = hooksDir(shared);
|
|
36
|
+
try {
|
|
37
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
38
|
+
const text = readAsset("plugins", "hooks", "README.md");
|
|
39
|
+
const file = path.join(dir, "README.md");
|
|
40
|
+
let old;
|
|
41
|
+
try {
|
|
42
|
+
old = fs.readFileSync(file, "utf8");
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
old = undefined;
|
|
46
|
+
}
|
|
47
|
+
if (text !== undefined && old !== text)
|
|
48
|
+
fs.writeFileSync(file, text);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (!["EACCES", "EPERM", "EROFS"].includes(err?.code ?? ""))
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
return dir;
|
|
55
|
+
}
|
|
56
|
+
export function listHooks(shared) {
|
|
57
|
+
let names = [];
|
|
58
|
+
try {
|
|
59
|
+
names = fs
|
|
60
|
+
.readdirSync(hooksDir(shared), { withFileTypes: true })
|
|
61
|
+
.filter((d) => d.isDirectory() && NAME_RE.test(d.name))
|
|
62
|
+
.map((d) => d.name);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const name of names.sort()) {
|
|
69
|
+
try {
|
|
70
|
+
out.push(loadHook(shared, name));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// not a hook (yet): `strom hook test <name>` says why
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
export function loadHook(shared, name) {
|
|
79
|
+
if (!NAME_RE.test(name))
|
|
80
|
+
throw new UsageError(`invalid hook name "${name}"`, { hint: "lowercase letters, digits and dashes, e.g. telegram" });
|
|
81
|
+
const dir = path.join(hooksDir(shared), name);
|
|
82
|
+
const file = path.join(dir, MANIFEST);
|
|
83
|
+
if (!fs.existsSync(file))
|
|
84
|
+
throw new StromError(`no hook "${name}" (no ${file})`, { hint: `the hooks here: strom hook list — a hook is a folder in ${hooksDir(shared)} with ${MANIFEST}` });
|
|
85
|
+
let m;
|
|
86
|
+
try {
|
|
87
|
+
m = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
throw new StromError(`hook "${name}": ${MANIFEST} is not valid JSON (${err.message})`);
|
|
91
|
+
}
|
|
92
|
+
if (m.interface !== HOOK_INTERFACE)
|
|
93
|
+
throw new StromError(`hook "${name}" is for interface ${m.interface}, this strom knows ${HOOK_INTERFACE}`, {
|
|
94
|
+
hint: m.interface > HOOK_INTERFACE ? "update strom: strom update" : `see ${path.join(hooksDir(shared), "README.md")}`,
|
|
95
|
+
});
|
|
96
|
+
if (!Array.isArray(m.command) || !m.command.length || !m.command.every((c) => typeof c === "string" && c))
|
|
97
|
+
throw new StromError(`hook "${name}": "command" must be a list of strings, e.g. ["node", "hook.ts"]`);
|
|
98
|
+
if (m.events !== undefined && (!Array.isArray(m.events) || !m.events.every((e) => typeof e === "string" && e)))
|
|
99
|
+
throw new StromError(`hook "${name}": "events" must be a list like ["person.add", "session.*"]`);
|
|
100
|
+
return { name, dir, manifest: m };
|
|
101
|
+
}
|
|
102
|
+
/** Does the hook want this operation? "*" all, "person.*" a kind, "person.add" one. */
|
|
103
|
+
export function wants(m, op) {
|
|
104
|
+
return (m.events ?? ["*"]).some((p) => p === "*" || p === op || (p.endsWith(".*") && op.startsWith(p.slice(0, -1))));
|
|
105
|
+
}
|
|
106
|
+
/** The operations of a commit the hook wants, or none. */
|
|
107
|
+
export function forHook(hook, events) {
|
|
108
|
+
return events.filter((e) => wants(hook.manifest, e.op));
|
|
109
|
+
}
|
|
110
|
+
function program(hook) {
|
|
111
|
+
const [cmd, ...args] = hook.manifest.command;
|
|
112
|
+
return { cmd: cmd === "node" ? process.execPath : cmd, args };
|
|
113
|
+
}
|
|
114
|
+
function hookEnv(env, hook, payload, file) {
|
|
115
|
+
return {
|
|
116
|
+
...env,
|
|
117
|
+
STROM_HOOK: hook.name,
|
|
118
|
+
STROM_TREE: payload.tree.root,
|
|
119
|
+
STROM_LANG: payload.tree.lang,
|
|
120
|
+
STROM_EVENT_FILE: file,
|
|
121
|
+
// what it runs of strom reads the research the event came from, as the user would
|
|
122
|
+
STROM_NONINTERACTIVE: "1",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** The event as a file (the hook's stdin, and STROM_EVENT_FILE); old ones are cleared away. */
|
|
126
|
+
function eventFile(hook, payload) {
|
|
127
|
+
const dir = path.join(hook.dir, ".events");
|
|
128
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
129
|
+
try {
|
|
130
|
+
for (const f of fs.readdirSync(dir)) {
|
|
131
|
+
const p = path.join(dir, f);
|
|
132
|
+
if (Date.now() - fs.statSync(p).mtimeMs > KEEP_MS)
|
|
133
|
+
fs.rmSync(p, { force: true });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// another process cleared it
|
|
138
|
+
}
|
|
139
|
+
const file = path.join(dir, `${payload.at.replace(/[:.]/g, "-")}-${process.pid}-${Math.random().toString(36).slice(2, 8)}.json`);
|
|
140
|
+
fs.writeFileSync(file, JSON.stringify(payload, null, 2) + "\n");
|
|
141
|
+
return file;
|
|
142
|
+
}
|
|
143
|
+
/** Its log, cut to its last part when it has grown too big. */
|
|
144
|
+
function logFd(hook) {
|
|
145
|
+
const file = path.join(hook.dir, "hook.log");
|
|
146
|
+
try {
|
|
147
|
+
if (fs.statSync(file).size > LOG_MAX) {
|
|
148
|
+
const tail = fs.readFileSync(file).subarray(-LOG_MAX / 2);
|
|
149
|
+
fs.writeFileSync(file, tail);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// no log yet
|
|
154
|
+
}
|
|
155
|
+
return fs.openSync(file, "a");
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Tell the hooks the user turned on what a commit saved — in the background: strom neither waits for them nor
|
|
159
|
+
* fails because of them. A hook's own writes (it may run strom) tell no hook again.
|
|
160
|
+
*/
|
|
161
|
+
export function fireHooks(shared, names, env, payload) {
|
|
162
|
+
if (!shared || !names?.length || !payload.events.length || env.STROM_HOOK)
|
|
163
|
+
return;
|
|
164
|
+
for (const name of names) {
|
|
165
|
+
try {
|
|
166
|
+
const hook = loadHook(shared, name);
|
|
167
|
+
const events = forHook(hook, payload.events);
|
|
168
|
+
if (!events.length)
|
|
169
|
+
continue;
|
|
170
|
+
const full = { ...payload, hook: name, events };
|
|
171
|
+
const file = eventFile(hook, full);
|
|
172
|
+
let input;
|
|
173
|
+
let log;
|
|
174
|
+
try {
|
|
175
|
+
input = fs.openSync(file, "r");
|
|
176
|
+
log = logFd(hook);
|
|
177
|
+
const { cmd, args } = program(hook);
|
|
178
|
+
const child = spawn(cmd, args, { cwd: hook.dir, env: hookEnv(env, hook, full, file), stdio: [input, log, log], detached: true, windowsHide: true });
|
|
179
|
+
child.on("error", () => undefined);
|
|
180
|
+
child.unref();
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
// the child has its own copies; ours are closed whatever happened
|
|
184
|
+
for (const fd of [input, log])
|
|
185
|
+
if (fd !== undefined)
|
|
186
|
+
fs.closeSync(fd);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// a hook that is gone or broken never stops the research: strom hook test <name> says why
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** Run a hook now and wait for it (strom hook test): what it printed and how it ended. */
|
|
195
|
+
export function runHookNow(hook, env, payload) {
|
|
196
|
+
const file = eventFile(hook, payload);
|
|
197
|
+
const { cmd, args } = program(hook);
|
|
198
|
+
const r = spawnSync(cmd, args, {
|
|
199
|
+
cwd: hook.dir,
|
|
200
|
+
env: hookEnv(env, hook, payload, file),
|
|
201
|
+
input: fs.readFileSync(file),
|
|
202
|
+
encoding: "utf8",
|
|
203
|
+
timeout: (hook.manifest.timeout ?? DEFAULT_TIMEOUT_S) * 1000,
|
|
204
|
+
windowsHide: true,
|
|
205
|
+
});
|
|
206
|
+
const output = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim();
|
|
207
|
+
if (r.error)
|
|
208
|
+
return { status: null, output, error: r.error.code === "ETIMEDOUT" ? `no end within ${hook.manifest.timeout ?? DEFAULT_TIMEOUT_S} s` : r.error.message };
|
|
209
|
+
return { status: r.status, output };
|
|
210
|
+
}
|
package/dist/core/media.js
CHANGED
|
@@ -64,22 +64,36 @@ const MIME = {
|
|
|
64
64
|
export function mimeOf(file) {
|
|
65
65
|
return MIME[path.extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
66
66
|
}
|
|
67
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Every file under the given paths (folders recursively), hidden files skipped. What is inside a folder but cannot be
|
|
69
|
+
* read (a broken link or alias, a folder the system keeps to itself) is left out; the paths given must be there.
|
|
70
|
+
*/
|
|
68
71
|
export function collectFiles(paths) {
|
|
69
72
|
const out = [];
|
|
70
|
-
const walk = (p) => {
|
|
73
|
+
const walk = (p, given) => {
|
|
71
74
|
const base = path.basename(p);
|
|
72
|
-
if (base.startsWith(".") || base === "Thumbs.db" || base === "desktop.ini")
|
|
75
|
+
if (!given && (base.startsWith(".") || base === "Thumbs.db" || base === "desktop.ini"))
|
|
76
|
+
return;
|
|
77
|
+
let st;
|
|
78
|
+
let names = [];
|
|
79
|
+
try {
|
|
80
|
+
st = fs.statSync(p);
|
|
81
|
+
if (st.isDirectory())
|
|
82
|
+
names = fs.readdirSync(p).sort();
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
if (given)
|
|
86
|
+
throw err;
|
|
73
87
|
return;
|
|
74
|
-
|
|
88
|
+
}
|
|
75
89
|
if (st.isDirectory())
|
|
76
|
-
for (const e of
|
|
77
|
-
walk(path.join(p, e));
|
|
90
|
+
for (const e of names)
|
|
91
|
+
walk(path.join(p, e), false);
|
|
78
92
|
else if (st.isFile())
|
|
79
93
|
out.push(p);
|
|
80
94
|
};
|
|
81
95
|
for (const p of paths)
|
|
82
|
-
walk(p);
|
|
96
|
+
walk(p, true);
|
|
83
97
|
return out;
|
|
84
98
|
}
|
|
85
99
|
const digitGroups = (file) => path.basename(file, path.extname(file)).match(/\d+/g) ?? [];
|
package/dist/core/overview.js
CHANGED
|
@@ -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;
|
package/dist/core/phrases.js
CHANGED
|
@@ -76,6 +76,7 @@ export const PHRASES = {
|
|
|
76
76
|
"intake.done.imported": "the leads are checked for obvious mistakes (duplicates, impossible dates) and the first locate/link tasks exist",
|
|
77
77
|
"intake.done.read": "what it adds to the tree is entered as leads citing it (strom source add … --kind family-tree --form authored --input {id}) — or the input is skipped with a reason (strom input skip {id} --reason …)",
|
|
78
78
|
"intake.done": "every person and fact in it is recorded (a source if it is an original document, leads otherwise) and cited — or the input is skipped with a reason (strom input skip I… --reason …)",
|
|
79
|
+
"task.answer": "The user answered (to: {asked}): {answer}",
|
|
79
80
|
};
|
|
80
81
|
const catalogs = new Map();
|
|
81
82
|
/**
|