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/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
|
@@ -115,7 +115,7 @@ register({
|
|
|
115
115
|
(s.sessions.costUsd ? ui(lang, "ui.stats.cost", { cost: humanCost(s.sessions.costUsd, lang) }) : "") +
|
|
116
116
|
(s.sessions.costUsd && s.sessions.costPartial ? ui(lang, "ui.stats.cost.partial", { n: s.sessions.costPartial }) : ""));
|
|
117
117
|
if (s.stories.written)
|
|
118
|
-
out.push(ui(lang, "ui.stats.stories", { n: s.stories.written
|
|
118
|
+
out.push(ui(lang, "ui.stats.stories", { n: s.stories.written }));
|
|
119
119
|
return { text: lines(...out), data: s };
|
|
120
120
|
},
|
|
121
121
|
}, {
|
|
@@ -164,7 +164,7 @@ register({
|
|
|
164
164
|
}
|
|
165
165
|
if (c.story) {
|
|
166
166
|
const text = c.story.text.replace(/\*\*/g, "").trim();
|
|
167
|
-
out.push("", ui(lang, "ui.card.story"
|
|
167
|
+
out.push("", ui(lang, "ui.card.story"));
|
|
168
168
|
// markdown headings as plain lines
|
|
169
169
|
out.push(text.split("\n").map((l) => (l.trim() ? ` ${l.trim().replace(/^#+\s*/, "")}` : "")).join("\n"));
|
|
170
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,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,22 +179,31 @@ 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
208
|
const costs = tree
|
|
196
209
|
.list("session")
|
|
@@ -198,8 +211,34 @@ register({
|
|
|
198
211
|
.map((s) => s.metrics?.costUsd)
|
|
199
212
|
.filter((c) => typeof c === "number");
|
|
200
213
|
const avg = costs.length ? costs.reduce((a, b) => a + b, 0) / costs.length : undefined;
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
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 } : {}) } };
|
|
204
226
|
},
|
|
205
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
|
+
}
|
package/dist/commands/session.js
CHANGED
|
@@ -37,6 +37,7 @@ import { guard } from "../core/guard.js";
|
|
|
37
37
|
import { hasErrors } from "../core/check.js";
|
|
38
38
|
import { Tree } from "../core/tree.js";
|
|
39
39
|
import { treeBrowserConnectors } from "../core/connector.js";
|
|
40
|
+
import { browserNote } from "./connectors.js";
|
|
40
41
|
import { reviewProposals } from "../core/review.js";
|
|
41
42
|
function written(tree) {
|
|
42
43
|
return lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined);
|
|
@@ -479,6 +480,10 @@ register({
|
|
|
479
480
|
out(ui(lang, "ui.run.browser", { name: c.manifest.title ?? c.name, hosts: c.manifest.hosts.join(", ") }));
|
|
480
481
|
if (browser.length && /haiku/i.test(models.lead ?? ""))
|
|
481
482
|
out(ui(lang, "ui.run.browser.model", { model: models.lead }));
|
|
483
|
+
// Archives through the browser with an agent that has no browser tools, or without the extension: said before it starts.
|
|
484
|
+
const browserSays = browserNote(ctx, Tree.open(root, runEnv), runnerId);
|
|
485
|
+
if (browserSays)
|
|
486
|
+
out(browserSays);
|
|
482
487
|
if (permissions === "full")
|
|
483
488
|
out(ui(lang, "ui.run.full"));
|
|
484
489
|
// The gate holds a run that goes on by itself (--loop, --until). Tasks the user starts themselves (one, --max n,
|
|
@@ -598,7 +603,7 @@ register({
|
|
|
598
603
|
shared: ctx.settings.shared()?.value,
|
|
599
604
|
...(models.lead ? { model: models.lead } : {}),
|
|
600
605
|
...(opts.interactive ? { interactive: true } : {}),
|
|
601
|
-
...(runnerId === "claude" ? { chrome: browser.length > 0, permissions } : {}),
|
|
606
|
+
...(runnerId === "claude" ? { chrome: browser.length > 0, permissions, remote: ctx.settings.agentRemote() } : {}),
|
|
602
607
|
...(extra?.length ? { extraArgs: extra } : {}),
|
|
603
608
|
onProgress: (l) => out(` · ${l}`),
|
|
604
609
|
signal: stop.signal,
|
|
@@ -624,10 +629,11 @@ register({
|
|
|
624
629
|
const back = after.get(task.id);
|
|
625
630
|
const idle = back && back.state === "open" ? idleSessions(after, task.id) : 0;
|
|
626
631
|
if (idle >= MAX_IDLE_SESSIONS) {
|
|
627
|
-
|
|
632
|
+
const why = `${idle} sessions in a row recorded nothing for it — needs a look (strom task show ${task.id})`;
|
|
633
|
+
update(after, task.id, "task", (t) => ({ ...t, state: "parked", parkedReason: why }), {
|
|
628
634
|
op: "task.park",
|
|
629
635
|
summary: `${task.id} park: ${idle} sessions without a result`,
|
|
630
|
-
reason:
|
|
636
|
+
reason: why,
|
|
631
637
|
});
|
|
632
638
|
out(ui(lang, "ui.run.parked", { task: task.id, n: idle }));
|
|
633
639
|
}
|
package/dist/commands/setup.js
CHANGED
|
@@ -19,6 +19,7 @@ import { installation, stromLauncher } from "../core/self.js";
|
|
|
19
19
|
import { VERSION } from "../core/tree.js";
|
|
20
20
|
import { installUpdate, isNewer, knownNewerVersion, latestVersion, newerNode, newerVersion } from "../core/update.js";
|
|
21
21
|
import { desktopDir } from "../core/paths.js";
|
|
22
|
+
import { planMove } from "../core/relocate.js";
|
|
22
23
|
import { researchUrl, stromAppUrl, stromAppState } from "../core/stromapp.js";
|
|
23
24
|
import { isInstalled } from "../agents/global.js";
|
|
24
25
|
import { shortcutName } from "../cli/wizard.js";
|
|
@@ -31,13 +32,16 @@ import { check } from "../core/check.js";
|
|
|
31
32
|
import { assertIntact, verifyFull } from "../core/integrity.js";
|
|
32
33
|
import { ensurePluginsDir } from "../core/connector.js";
|
|
33
34
|
import { ensureGatesDir, loadGate } from "../core/gate.js";
|
|
34
|
-
import {
|
|
35
|
+
import { ensureHooksDir } from "../core/hooks.js";
|
|
36
|
+
import { claudeInChrome, CLAUDE_IN_CHROME_URL, downloadsDir } from "../core/browser.js";
|
|
37
|
+
import { browserConnectors } from "../core/connector.js";
|
|
35
38
|
export const SHARED_DIRS = ["media", "catalog", "tools", "cache", "inbox"];
|
|
36
39
|
export function ensureShared(dir) {
|
|
37
40
|
for (const d of SHARED_DIRS)
|
|
38
41
|
fs.mkdirSync(path.join(dir, d), { recursive: true });
|
|
39
42
|
ensurePluginsDir(dir);
|
|
40
43
|
ensureGatesDir(dir);
|
|
44
|
+
ensureHooksDir(dir);
|
|
41
45
|
}
|
|
42
46
|
register({
|
|
43
47
|
path: ["setup"],
|
|
@@ -69,6 +73,12 @@ register({
|
|
|
69
73
|
set: `strom setup --home "${ctx.display(suggestedHome)}" --yes`,
|
|
70
74
|
},
|
|
71
75
|
]);
|
|
76
|
+
if (cfg.home && flags.home)
|
|
77
|
+
guardResearchFolder(ctx, "home", flags.home);
|
|
78
|
+
if (flags.shared)
|
|
79
|
+
guardResearchFolder(ctx, "shared", flags.shared);
|
|
80
|
+
if (flags.trees)
|
|
81
|
+
guardResearchFolder(ctx, "trees", flags.trees);
|
|
72
82
|
let home = flags.home ? ctx.resolvePath(flags.home) : suggestedHome;
|
|
73
83
|
let shared = flags.shared ? ctx.resolvePath(flags.shared) : undefined;
|
|
74
84
|
let trees = flags.trees ? ctx.resolvePath(flags.trees) : undefined;
|
|
@@ -189,6 +199,19 @@ function diagnose(ctx) {
|
|
|
189
199
|
if (model)
|
|
190
200
|
add("model", "ok", model);
|
|
191
201
|
}
|
|
202
|
+
// Archives through the browser: only Claude Code has browser tools, and they work through the Claude in Chrome extension.
|
|
203
|
+
const shared = ctx.settings.shared()?.value;
|
|
204
|
+
const viaBrowser = shared ? browserConnectors(ctx.env, shared) : [];
|
|
205
|
+
if (viaBrowser.length) {
|
|
206
|
+
const names = viaBrowser.map((c) => c.manifest.title ?? c.name).join(", ");
|
|
207
|
+
const ext = claudeInChrome(ctx.env).extension;
|
|
208
|
+
if (chosen !== "claude")
|
|
209
|
+
add("browser", "warn", t("ui.doc.browser.agent", { names, agent: PROFILES[chosen]?.name ?? chosen }), found.includes("claude") ? "strom agents use claude" : undefined);
|
|
210
|
+
else if (ext.length)
|
|
211
|
+
add("browser", "ok", t("ui.doc.browser.ok", { browsers: ext.join(", ") }));
|
|
212
|
+
else
|
|
213
|
+
add("browser", "warn", t("ui.doc.browser.noext", { names }), CLAUDE_IN_CHROME_URL);
|
|
214
|
+
}
|
|
192
215
|
// For a person: the shortcut on the desktop, the Strom app.
|
|
193
216
|
const desktop = desktopDir(ctx.env);
|
|
194
217
|
const shortcut = [`${shortcutName(lang)}.command`, `${shortcutName(lang)}.lnk`, `${shortcutName(lang)}.cmd`, "strom-research.desktop"].some((f) => fs.existsSync(path.join(desktop, f)));
|
|
@@ -391,11 +414,33 @@ function raises(now, value) {
|
|
|
391
414
|
return PERMISSION_LEVELS.indexOf(next) > PERMISSION_LEVELS.indexOf(now);
|
|
392
415
|
}
|
|
393
416
|
/** Change a user setting (the config file on this computer). */
|
|
417
|
+
/**
|
|
418
|
+
* Where the research lives (home, trees, shared) changes only with the research moving along — which the person does in
|
|
419
|
+
* the wizard (strom setup in a terminal, the menu's settings). A folder set to another place with the research left in
|
|
420
|
+
* the old one would lose the trees from the list and the plugins and images from sight.
|
|
421
|
+
*/
|
|
422
|
+
function guardResearchFolder(ctx, key, next) {
|
|
423
|
+
const now = key === "home" ? ctx.settings.home()?.value : key === "trees" ? ctx.settings.trees()?.value : key === "shared" ? ctx.settings.shared()?.value : undefined;
|
|
424
|
+
// unset: back to the default under home (none for home itself)
|
|
425
|
+
const home = ctx.settings.home()?.value;
|
|
426
|
+
const target = next !== undefined ? ctx.resolvePath(next) : key === "shared" && home ? path.join(home, "shared") : key === "trees" ? home : undefined;
|
|
427
|
+
if (!now || (target && path.resolve(target) === path.resolve(now)))
|
|
428
|
+
return;
|
|
429
|
+
if (!planMove(now, target ?? now, []).content)
|
|
430
|
+
return;
|
|
431
|
+
throw new UsageError(`the research is in ${ctx.display(now)} — changing ${key} would leave it behind`, {
|
|
432
|
+
hint: "the user moves it: strom setup in their terminal (or the menu: Settings → the setup), which moves the trees and the shared folder along",
|
|
433
|
+
});
|
|
434
|
+
}
|
|
394
435
|
function setUserSetting(ctx, key, value) {
|
|
395
436
|
const s = ctx.settings;
|
|
437
|
+
guardResearchFolder(ctx, key, value === undefined ? undefined : String(value));
|
|
396
438
|
// Loosening the agent's permissions is the user's decision alone.
|
|
397
439
|
if (key === "agent.permissions" && raises(s.agentPermissions(), value))
|
|
398
440
|
ctx.requireHuman(`Let the agent ${value === "full" ? "do everything but what the tree's permissions deny" : "work on its own, asking only about risky steps"}, without asking?`, `strom config set agent.permissions ${value}`, "agent.permissions", ui(ctx.uiLang(), value === "full" ? "ui.consent.level.full" : "ui.consent.level.auto"));
|
|
441
|
+
// Sessions steered from elsewhere (Remote Control): the user's decision alone.
|
|
442
|
+
if (key === "agent.remote" && value === "on" && !s.agentRemote())
|
|
443
|
+
ctx.requireHuman("Start the Claude Code sessions with Remote Control (followed and steered from claude.ai or your phone)?", "strom config set agent.remote on", "agent.remote", ui(ctx.uiLang(), "ui.consent.remote"));
|
|
399
444
|
// Asking before a connector runs is the user's safeguard: only they take it away.
|
|
400
445
|
if (key === "connectors.consent" && value !== "on" && s.connectorsConsent())
|
|
401
446
|
ctx.requireHuman("Let connectors run without asking you first?", `strom config set connectors.consent ${value ?? "off"}`, "connectors.consent", ui(ctx.uiLang(), "ui.consent.connectors.off"));
|
package/dist/commands/start.js
CHANGED
|
@@ -17,6 +17,7 @@ import { AGENTS, findAgent, isAgent } from "../core/which.js";
|
|
|
17
17
|
import { DESKTOP_APPS, agentsHere, whereToTalk } from "../core/apps.js";
|
|
18
18
|
import { assertIntact } from "../core/integrity.js";
|
|
19
19
|
import { treeBrowserConnectors } from "../core/connector.js";
|
|
20
|
+
import { browserNote } from "./connectors.js";
|
|
20
21
|
import { appOpensResearch, importAppUrl, installedStromApp, liveAppUrl, noticeStromApp, STROM_APP_URL, stromAppUrl } from "../core/stromapp.js";
|
|
21
22
|
import { liveRunning, serveLive, startLive, stopLive } from "../core/live.js";
|
|
22
23
|
import { openForUser } from "../core/open.js";
|
|
@@ -131,7 +132,7 @@ register({
|
|
|
131
132
|
// its research when there is one, the day.
|
|
132
133
|
name: ["Strom", tree.config.name, researches.length === 1 ? researches[0].name : undefined, new Date().toLocaleDateString(lang, { day: "numeric", month: "numeric" })].filter(Boolean).join(" · "),
|
|
133
134
|
shared,
|
|
134
|
-
...(agent === "claude" ? { chrome: treeBrowserConnectors(tree, shared).length > 0 } : {}),
|
|
135
|
+
...(agent === "claude" ? { chrome: treeBrowserConnectors(tree, shared).length > 0, remote: ctx.settings.agentRemote() } : {}),
|
|
135
136
|
};
|
|
136
137
|
const args = conversationArgs(agent, base);
|
|
137
138
|
if (opts.print)
|
|
@@ -147,6 +148,9 @@ register({
|
|
|
147
148
|
const worker = `${agent}-${process.pid}-${Date.now().toString(36)}`;
|
|
148
149
|
const leave = enterWorker(tree.root, worker, `${profile.name} conversation`);
|
|
149
150
|
ctx.io.stdout(ui(lang, "ui.chat.open", { agent: profile.name, exit: profile.exit }) + "\n");
|
|
151
|
+
const browserSays = browserNote(ctx, tree, agent);
|
|
152
|
+
if (browserSays)
|
|
153
|
+
ctx.io.stdout(browserSays + "\n");
|
|
150
154
|
// Claude Code asks once whether the folder is to be trusted — its own safety step, kept; the user knows what to answer.
|
|
151
155
|
if (agent === "claude" && !claudeTrusts(tree.root, ctx.env))
|
|
152
156
|
ctx.io.stdout(ui(lang, "ui.chat.trust") + "\n");
|