strom-research 1.2.0 → 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 +3 -0
- package/assets/lang/cs.json +162 -6
- package/assets/lang/de.json +162 -6
- package/assets/plugins/README.md +8 -1
- package/assets/plugins/hooks/README.md +91 -0
- package/dist/agents/global.js +10 -0
- package/dist/agents/launch.js +1 -1
- package/dist/cli/main.js +17 -1
- 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 +91 -97
- package/dist/cli/ui.js +160 -5
- package/dist/cli/wizard.js +48 -2
- package/dist/commands/browse.js +2 -2
- package/dist/commands/connectors.js +25 -2
- package/dist/commands/hooks.js +141 -0
- package/dist/commands/index.js +1 -0
- package/dist/commands/intake.js +2 -2
- package/dist/commands/research.js +55 -16
- package/dist/commands/session.js +9 -3
- package/dist/commands/setup.js +46 -1
- package/dist/commands/start.js +5 -1
- package/dist/commands/tasks.js +41 -4
- package/dist/core/browser.js +50 -0
- package/dist/core/config.js +6 -0
- package/dist/core/hooks.js +210 -0
- package/dist/core/media.js +21 -7
- package/dist/core/phrases.js +1 -0
- package/dist/core/relocate.js +111 -0
- package/dist/core/tree.js +19 -2
- package/dist/runners/claude.js +3 -0
- package/package.json +1 -1
package/dist/commands/tasks.js
CHANGED
|
@@ -16,6 +16,8 @@ import { foldText } from "../core/text.js";
|
|
|
16
16
|
import { now, typeOfId } from "../core/tree.js";
|
|
17
17
|
import { resolveResearch } from "./research.js";
|
|
18
18
|
import { makeNote } from "../core/actions.js";
|
|
19
|
+
import { phrase } from "../core/phrases.js";
|
|
20
|
+
import { readJsonLines } from "../core/json.js";
|
|
19
21
|
import { lacksImages, rankTasks } from "../core/queue.js";
|
|
20
22
|
import { clipNote, inboxFolderFor, parseImageList, transcriptNote } from "../core/media.js";
|
|
21
23
|
function written(tree) {
|
|
@@ -82,6 +84,25 @@ export function rankedQueue(tree, filter = {}) {
|
|
|
82
84
|
function taskLine(t) {
|
|
83
85
|
return [t.id, `p${t.priority}`, t.level, t.state === "open" ? "" : `[${t.state}]`, truncate(t.what, 70)];
|
|
84
86
|
}
|
|
87
|
+
/** Why a task was put aside: on the task, or — parked by an older strom — in the history. */
|
|
88
|
+
export function parkedWhy(tree, t) {
|
|
89
|
+
if (t.state !== "parked")
|
|
90
|
+
return undefined;
|
|
91
|
+
if (t.parkedReason)
|
|
92
|
+
return t.parkedReason;
|
|
93
|
+
const dir = path.join(tree.dataDir, "ops");
|
|
94
|
+
let last;
|
|
95
|
+
try {
|
|
96
|
+
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith(".jsonl")))
|
|
97
|
+
for (const o of readJsonLines(path.join(dir, f)))
|
|
98
|
+
if (o.op === "task.park" && o.targets.includes(t.id) && (!last || o.at > last.at))
|
|
99
|
+
last = o;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// no history to read
|
|
103
|
+
}
|
|
104
|
+
return last?.reason;
|
|
105
|
+
}
|
|
85
106
|
function taskDetail(tree, t) {
|
|
86
107
|
const where = t.where.map((w) => {
|
|
87
108
|
const b = /^B\d{4,}$/.test(w) ? tree.get(w) : undefined;
|
|
@@ -89,7 +110,7 @@ function taskDetail(tree, t) {
|
|
|
89
110
|
});
|
|
90
111
|
const searches = tree.list("search").filter((s) => s.task === t.id || s.recordsets.some((b) => t.where.includes(b)));
|
|
91
112
|
const lessons = tree.list("lesson").filter((l) => l.target && t.where.includes(l.target));
|
|
92
|
-
return lines(`${t.id} ${t.what}`, `level ${t.level} · priority ${t.priority} · ${t.state}${t.research ? ` · research ${t.research}` : ""}${t.parkedUntil ? ` · parked until ${t.parkedUntil}` : ""}${t.waitingOn ? ` · waiting on ${t.waitingOn}` : ""}`, `where ${where.join("\n ")}`, `why ${t.why}`, `done ${t.doneWhen}`, t.subject.length ? `about ${t.subject.join(" ")}` : undefined, t.result ? `result ${t.result}${t.produced?.length ? ` → ${t.produced.join(" ")}` : ""}` : undefined, searches.length ? "\nalready searched there\n" + table(searches.map((s) => [` ${s.id}`, s.result, truncate(s.question, 60), s.scope.years ?? ""])) : undefined, lessons.length ? "\nlessons for these record sets\n" + lessons.map((l) => ` ${l.id} ${l.rule}`).join("\n") : undefined, ...t.notes.map((n) => `note ${n.text}`));
|
|
113
|
+
return lines(`${t.id} ${t.what}`, `level ${t.level} · priority ${t.priority} · ${t.state}${t.research ? ` · research ${t.research}` : ""}${t.parkedUntil ? ` · parked until ${t.parkedUntil}` : ""}${t.waitingOn ? ` · waiting on ${t.waitingOn}` : ""}`, t.state === "parked" ? `parked ${parkedWhy(tree, t) ?? "(no reason recorded)"} — strom task wake ${t.id} to go on` : undefined, `where ${where.join("\n ")}`, `why ${t.why}`, `done ${t.doneWhen}`, t.subject.length ? `about ${t.subject.join(" ")}` : undefined, t.result ? `result ${t.result}${t.produced?.length ? ` → ${t.produced.join(" ")}` : ""}` : undefined, searches.length ? "\nalready searched there\n" + table(searches.map((s) => [` ${s.id}`, s.result, truncate(s.question, 60), s.scope.years ?? ""])) : undefined, lessons.length ? "\nlessons for these record sets\n" + lessons.map((l) => ` ${l.id} ${l.rule}`).join("\n") : undefined, ...t.notes.map((n) => `note ${n.text}`));
|
|
93
114
|
}
|
|
94
115
|
function stateChange(tree, ref, state, fields, reason, verb) {
|
|
95
116
|
const id = normId(ref, "task");
|
|
@@ -97,8 +118,12 @@ function stateChange(tree, ref, state, fields, reason, verb) {
|
|
|
97
118
|
if (t.state === "done" && state !== "open")
|
|
98
119
|
throw new UsageError(`${id} is already done`);
|
|
99
120
|
const next = { ...t, state, ...fields };
|
|
100
|
-
if (state !== "parked")
|
|
121
|
+
if (state !== "parked") {
|
|
101
122
|
delete next.parkedUntil;
|
|
123
|
+
delete next.parkedReason;
|
|
124
|
+
}
|
|
125
|
+
else if (reason)
|
|
126
|
+
next.parkedReason = reason;
|
|
102
127
|
if (state !== "waiting") {
|
|
103
128
|
delete next.waitingOn;
|
|
104
129
|
delete next.awaits;
|
|
@@ -401,10 +426,22 @@ register({
|
|
|
401
426
|
group: "tasks",
|
|
402
427
|
tree: true,
|
|
403
428
|
writes: true,
|
|
429
|
+
description: "--answer: what the user found or decided for a task that waited for them (a link, a book's number) — kept on\nthe task with what it asked, so the agent reads both in its brief.",
|
|
404
430
|
args: [{ name: "task", description: "task ID", required: true }],
|
|
405
|
-
|
|
431
|
+
options: [{ name: "answer", type: "string", value: "<text>", description: "the user's answer to what the task waited for" }],
|
|
432
|
+
examples: ["strom task wake T0002", 'strom task wake T0003 --answer "https://archive.example.org/book/5359"'],
|
|
433
|
+
run(ctx, { args, opts }) {
|
|
406
434
|
const tree = ctx.tree();
|
|
407
|
-
const
|
|
435
|
+
const id = normId(args[0], "task");
|
|
436
|
+
const answer = typeof opts.answer === "string" && opts.answer.trim() ? opts.answer.trim() : undefined;
|
|
437
|
+
const t = tree.withTreeLock(() => {
|
|
438
|
+
const before = requireRecord(tree, id, "task");
|
|
439
|
+
// an answer is to what the task waits for: one that waits no more (done, dropped, taken up) is left as it is
|
|
440
|
+
if (answer && before.state !== "waiting")
|
|
441
|
+
throw new UsageError(`${id} does not wait for the user (it is ${before.state}) — the answer is not needed there`, { hint: `strom task show ${id}` });
|
|
442
|
+
const note = answer ? makeNote(tree, phrase(tree.lang, "task.answer", { asked: truncate(before.waitingOn || before.what, 120), answer })) : undefined;
|
|
443
|
+
return stateChange(tree, id, "open", note ? { notes: [...before.notes, note] } : {}, undefined, "wake");
|
|
444
|
+
});
|
|
408
445
|
return { text: written(tree), data: { task: t } };
|
|
409
446
|
},
|
|
410
447
|
}, {
|
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);
|
package/dist/core/config.js
CHANGED
|
@@ -54,6 +54,7 @@ export const SETTINGS = [
|
|
|
54
54
|
// Read from the config file only — no variable, no flag, no tree: nothing an agent can set.
|
|
55
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" },
|
|
56
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" },
|
|
57
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" },
|
|
58
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" },
|
|
59
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" },
|
|
@@ -69,6 +70,7 @@ const FIELDS = {
|
|
|
69
70
|
"browser.downloads": "browserDownloads",
|
|
70
71
|
"agent.permissions": "agentPermissions",
|
|
71
72
|
"agent.where": "agentWhere",
|
|
73
|
+
"agent.remote": "agentRemote",
|
|
72
74
|
"strom.app": "stromApp",
|
|
73
75
|
"strom.app.url": "stromAppUrl",
|
|
74
76
|
"main.person": "mainPerson",
|
|
@@ -334,6 +336,10 @@ export class Settings {
|
|
|
334
336
|
downloads() {
|
|
335
337
|
return String(this.resolve("browser.downloads")?.value ?? downloadsDir(this.env));
|
|
336
338
|
}
|
|
339
|
+
/** Claude Code sessions strom starts with Remote Control (agent.remote: on). */
|
|
340
|
+
agentRemote() {
|
|
341
|
+
return this.resolve("agent.remote")?.value === "on";
|
|
342
|
+
}
|
|
337
343
|
/** Where the user wants to talk with the agent, if they said (env, else the config); see whereToTalk. */
|
|
338
344
|
agentWhere() {
|
|
339
345
|
const r = this.resolve("agent.where");
|
|
@@ -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/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
|
/**
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Moving the research to another folder (the setting `home`). The trees and the
|
|
2
|
+
// shared folder (connectors, hooks, gates, the inbox, big images) live under it:
|
|
3
|
+
// changing the setting alone would leave them behind — the trees gone from the
|
|
4
|
+
// list, the plugins and images not found. So the whole folder moves with it:
|
|
5
|
+
// renamed where it can be (one disk), else copied and the old one left for the
|
|
6
|
+
// user to delete. Never while somebody works in a tree, never into a folder
|
|
7
|
+
// that holds something already, never into itself.
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { loadConsents, saveConsents } from "./connector.js";
|
|
11
|
+
import { liveHolder } from "./lock.js";
|
|
12
|
+
import { liveRunning } from "./live.js";
|
|
13
|
+
import { liveWorkers, runsAtWork } from "./workers.js";
|
|
14
|
+
/** Files a system leaves in any folder: a folder with only these is empty. */
|
|
15
|
+
const LITTER = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
|
|
16
|
+
/** What the top of a disk holds of the system's own: the research goes into a folder of its own there. */
|
|
17
|
+
const DISK = new Set([".Spotlight-V100", ".fseventsd", ".Trashes", ".TemporaryItems", ".DocumentRevisions-V100", "System Volume Information", "$RECYCLE.BIN"]);
|
|
18
|
+
function entries(dir) {
|
|
19
|
+
try {
|
|
20
|
+
return fs.readdirSync(dir).filter((n) => !LITTER.has(n));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Is `inner` the folder `outer` or somewhere in it? (Windows: letters of any case are the same.) */
|
|
27
|
+
export function within(inner, outer, platform = process.platform) {
|
|
28
|
+
const [a, b] = platform === "win32" ? [inner.toLowerCase(), outer.toLowerCase()] : [inner, outer];
|
|
29
|
+
const rel = path.relative(b, a);
|
|
30
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
31
|
+
}
|
|
32
|
+
/** The same folder? (Windows: letters of any case are the same.) */
|
|
33
|
+
export function sameFolder(a, b, platform = process.platform) {
|
|
34
|
+
return within(path.resolve(a), path.resolve(b), platform) && within(path.resolve(b), path.resolve(a), platform);
|
|
35
|
+
}
|
|
36
|
+
/** Somebody at work in a tree: an agent present, a run, a command holding its lock, the live bridge. */
|
|
37
|
+
function atWork(root) {
|
|
38
|
+
return liveWorkers(root).length > 0 || runsAtWork(root).length > 0 || Boolean(liveHolder(path.join(root, ".strom", "tree.lock"))) || Boolean(liveRunning(root));
|
|
39
|
+
}
|
|
40
|
+
/** `trees`: the folders of the trees strom knows (those inside `from` move along). */
|
|
41
|
+
export function planMove(from, to, trees) {
|
|
42
|
+
const names = entries(from);
|
|
43
|
+
const inside = trees.filter((t) => within(t, from));
|
|
44
|
+
const busy = inside.filter(atWork).map((t) => path.basename(t));
|
|
45
|
+
// The top of a disk (its own hidden folders only): into a folder named like the old one there.
|
|
46
|
+
const there = entries(to);
|
|
47
|
+
const target = there.length && there.every((n) => DISK.has(n)) ? path.join(to, path.basename(from) || "Strom") : to;
|
|
48
|
+
const plan = { from, to: target, content: names.length > 0, trees: inside.map((t) => path.basename(t)), busy };
|
|
49
|
+
if (!plan.content)
|
|
50
|
+
return plan;
|
|
51
|
+
if (within(target, from) || within(from, target))
|
|
52
|
+
plan.problem = "inside";
|
|
53
|
+
else if (entries(target).length)
|
|
54
|
+
plan.problem = "notEmpty";
|
|
55
|
+
else if (busy.length)
|
|
56
|
+
plan.problem = "busy";
|
|
57
|
+
return plan;
|
|
58
|
+
}
|
|
59
|
+
/** Move the folder; "copied" when it had to be copied to another disk (the old one stays). */
|
|
60
|
+
export function moveHome(plan) {
|
|
61
|
+
fs.mkdirSync(path.dirname(plan.to), { recursive: true });
|
|
62
|
+
if (fs.existsSync(plan.to)) {
|
|
63
|
+
// empty but for what a system leaves (planMove said so)
|
|
64
|
+
for (const n of LITTER)
|
|
65
|
+
fs.rmSync(path.join(plan.to, n), { force: true });
|
|
66
|
+
fs.rmdirSync(plan.to);
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
fs.renameSync(plan.from, plan.to);
|
|
70
|
+
return "moved";
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
if (err.code !== "EXDEV")
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
fs.cpSync(plan.from, plan.to, { recursive: true, errorOnExist: true, force: false, preserveTimestamps: true, verbatimSymlinks: true });
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
fs.rmSync(plan.to, { recursive: true, force: true }); // half a copy is no copy
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
return "copied";
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* What pointed into the old folder points into the new one: the settings (the folders under it, the tree worked on) and
|
|
87
|
+
* the consents to connectors, which know a connector by its folder.
|
|
88
|
+
*/
|
|
89
|
+
export function repointSettings(settings, env, from, to) {
|
|
90
|
+
const cfg = settings.config;
|
|
91
|
+
const move = (p) => (p && within(p, from) ? path.join(to, path.relative(from, p)) : p);
|
|
92
|
+
cfg.home = to;
|
|
93
|
+
if (cfg.shared)
|
|
94
|
+
cfg.shared = move(cfg.shared);
|
|
95
|
+
if (cfg.trees)
|
|
96
|
+
cfg.trees = move(cfg.trees);
|
|
97
|
+
if (cfg.currentTree)
|
|
98
|
+
cfg.currentTree = move(cfg.currentTree);
|
|
99
|
+
if (cfg.extraTrees)
|
|
100
|
+
cfg.extraTrees = cfg.extraTrees.map((p) => move(p));
|
|
101
|
+
settings.save();
|
|
102
|
+
const consents = loadConsents(env);
|
|
103
|
+
let changed = false;
|
|
104
|
+
for (const c of Object.values(consents.connectors))
|
|
105
|
+
if (c.dir && within(c.dir, from)) {
|
|
106
|
+
c.dir = move(c.dir);
|
|
107
|
+
changed = true;
|
|
108
|
+
}
|
|
109
|
+
if (changed)
|
|
110
|
+
saveConsents(env, consents);
|
|
111
|
+
}
|
package/dist/core/tree.js
CHANGED
|
@@ -23,7 +23,7 @@ import * as git from "./git.js";
|
|
|
23
23
|
import { commitSeal, createKey, fingerprint, loadKey, sha256, signOp } from "./seal.js";
|
|
24
24
|
import { stringifyCanonical } from "./json.js";
|
|
25
25
|
export const TREE_FILE = "strom.json";
|
|
26
|
-
export const VERSION = "1.
|
|
26
|
+
export const VERSION = "1.3.0";
|
|
27
27
|
const GITIGNORE = `# Strom Research — local state and large files are not versioned
|
|
28
28
|
.strom/
|
|
29
29
|
/media/
|
|
@@ -84,6 +84,10 @@ export class Tree {
|
|
|
84
84
|
undo = new Map();
|
|
85
85
|
/** Ops written during this process (for the automatic commit message). */
|
|
86
86
|
written = [];
|
|
87
|
+
/** Of `written`, how many were told to onCommit already. */
|
|
88
|
+
told = 0;
|
|
89
|
+
/** Told of each commit with the operations it saved (the CLI tells the user's hooks: core/hooks.ts). */
|
|
90
|
+
static onCommit;
|
|
87
91
|
env;
|
|
88
92
|
/** Bumped on every write; lets derived indexes know they are stale. */
|
|
89
93
|
version = 0;
|
|
@@ -233,6 +237,7 @@ export class Tree {
|
|
|
233
237
|
this.touchedOps.clear();
|
|
234
238
|
this.counters = undefined;
|
|
235
239
|
this.written.length = 0;
|
|
240
|
+
this.told = 0;
|
|
236
241
|
this.version++;
|
|
237
242
|
return restored;
|
|
238
243
|
}
|
|
@@ -414,7 +419,19 @@ export class Tree {
|
|
|
414
419
|
commit(message, pathspec = ["."]) {
|
|
415
420
|
const key = this.requireKey();
|
|
416
421
|
git.resetCache(this.root); // another process may have committed since this one last looked
|
|
417
|
-
|
|
422
|
+
const hash = git.commitAll(this.root, message, pathspec, (treeHash) => commitSeal(key, treeHash));
|
|
423
|
+
if (hash) {
|
|
424
|
+
const ops = this.written.slice(this.told);
|
|
425
|
+
this.told = this.written.length;
|
|
426
|
+
try {
|
|
427
|
+
if (ops.length)
|
|
428
|
+
Tree.onCommit?.(this, ops, hash);
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
// whoever listens never undoes a commit
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return hash;
|
|
418
435
|
}
|
|
419
436
|
/** Append a signed operation to the current log (inside withTreeLock). */
|
|
420
437
|
appendOp(op) {
|
package/dist/runners/claude.js
CHANGED
|
@@ -41,6 +41,9 @@ export function claudeArgs(opts) {
|
|
|
41
41
|
args.push("--model", opts.model);
|
|
42
42
|
if (opts.chrome !== undefined)
|
|
43
43
|
args.push(opts.chrome ? "--chrome" : "--no-chrome");
|
|
44
|
+
// named always: its value is optional, and a bare flag would take the next argument
|
|
45
|
+
if (opts.remote)
|
|
46
|
+
args.push("--remote-control", opts.name ?? "Strom");
|
|
44
47
|
args.push(...(opts.extraArgs ?? []));
|
|
45
48
|
return args;
|
|
46
49
|
}
|