strom-research 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/assets/lang/cs.json +117 -2
- package/assets/lang/de.json +117 -2
- package/assets/method/core.md +8 -0
- package/assets/method/narrate.md +5 -1
- package/assets/plugins/gates/claude-usage/gate.ts +2 -1
- package/dist/agents/files.js +6 -4
- package/dist/cli/main.js +21 -3
- package/dist/cli/menu-links.js +35 -1
- package/dist/cli/menu-research.js +41 -2
- package/dist/cli/menu-settings.js +17 -1
- package/dist/cli/menu.js +19 -8
- package/dist/cli/ui.js +57 -1
- package/dist/commands/guide.js +6 -1
- package/dist/commands/links.js +39 -3
- package/dist/commands/output.js +4 -2
- package/dist/commands/pack.js +21 -18
- package/dist/commands/research.js +42 -1
- package/dist/commands/session.js +210 -11
- package/dist/commands/setup.js +3 -0
- package/dist/commands/start.js +6 -3
- package/dist/commands/story.js +47 -6
- package/dist/commands/tasks.js +50 -12
- package/dist/commands/trees.js +82 -2
- package/dist/core/changelog.js +223 -0
- package/dist/core/clock.js +20 -0
- package/dist/core/config.js +2 -0
- package/dist/core/connector.js +25 -1
- package/dist/core/directions.js +41 -0
- package/dist/core/edge.js +296 -0
- package/dist/core/frontier.js +12 -4
- package/dist/core/kin.js +40 -1
- package/dist/core/links.js +17 -4
- package/dist/core/live.js +428 -52
- package/dist/core/pack.js +31 -0
- package/dist/core/phrases.js +61 -0
- package/dist/core/queue.js +15 -4
- package/dist/core/relocate.js +4 -4
- package/dist/core/session.js +8 -2
- package/dist/core/stromapp.js +22 -0
- package/dist/core/trash.js +72 -0
- package/dist/core/tree.js +1 -1
- package/dist/core/workers.js +28 -3
- package/dist/gedcom/export.js +105 -1
- package/dist/gedcom/validate.js +7 -1
- package/dist/runners/claude.js +2 -1
- package/dist/runners/jsonl.js +2 -2
- package/dist/runners/runner.js +16 -3
- package/dist/runners/script.js +2 -1
- package/package.json +1 -1
package/dist/commands/tasks.js
CHANGED
|
@@ -11,6 +11,7 @@ import { lines, moreLine, paginate, runs, table, truncate } from "../cli/format.
|
|
|
11
11
|
import { UsageError } from "../core/errors.js";
|
|
12
12
|
import { TASK_LEVELS } from "../core/model.js";
|
|
13
13
|
import { create, csvOpt, listOpt, normId, requireRecord, update } from "../core/records.js";
|
|
14
|
+
import { directionOf, ofResearch, scopes } from "../core/directions.js";
|
|
14
15
|
import { resolvePerson } from "../core/people.js";
|
|
15
16
|
import { foldText } from "../core/text.js";
|
|
16
17
|
import { now, typeOfId } from "../core/tree.js";
|
|
@@ -19,6 +20,8 @@ import { makeNote } from "../core/actions.js";
|
|
|
19
20
|
import { phrase } from "../core/phrases.js";
|
|
20
21
|
import { readJsonLines } from "../core/json.js";
|
|
21
22
|
import { lacksImages, rankTasks } from "../core/queue.js";
|
|
23
|
+
import { joiningHypotheses, offTree } from "../core/kin.js";
|
|
24
|
+
import { aboutPeople } from "../core/directions.js";
|
|
22
25
|
import { clipNote, inboxFolderFor, parseImageList, transcriptNote } from "../core/media.js";
|
|
23
26
|
function written(tree) {
|
|
24
27
|
return lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined);
|
|
@@ -35,11 +38,17 @@ function subjects(tree, refs) {
|
|
|
35
38
|
return resolvePerson(tree, r).id;
|
|
36
39
|
});
|
|
37
40
|
}
|
|
38
|
-
|
|
41
|
+
/** A new task's research: the one named, else the session's that adds it, else the one active research its people belong to. */
|
|
42
|
+
function defaultResearch(tree, ref, subject = []) {
|
|
39
43
|
if (typeof ref === "string")
|
|
40
44
|
return resolveResearch(tree, ref).id;
|
|
45
|
+
const session = typeOfId(tree.actor) === "session" ? tree.get(tree.actor) : undefined;
|
|
46
|
+
if (session?.research)
|
|
47
|
+
return session.research;
|
|
41
48
|
const active = tree.list("research").filter((r) => r.state === "active");
|
|
42
|
-
|
|
49
|
+
if (active.length === 1)
|
|
50
|
+
return active[0].id;
|
|
51
|
+
return directionOf(tree, { subject }, scopes(tree).filter((s) => s.research.state === "active"));
|
|
43
52
|
}
|
|
44
53
|
/** What the research waits for from outside — mostly from the user: scans to download, a book to find, a reply. */
|
|
45
54
|
export function waitingForUser(tree) {
|
|
@@ -75,12 +84,29 @@ export function taskQueue(tree, filter = {}) {
|
|
|
75
84
|
return rankedQueue(tree, filter).map((r) => r.task);
|
|
76
85
|
}
|
|
77
86
|
export function rankedQueue(tree, filter = {}) {
|
|
87
|
+
const scope = filter.research ? scopes(tree).find((s) => s.research.id === filter.research) : undefined;
|
|
78
88
|
const tasks = tree
|
|
79
89
|
.list("task")
|
|
80
|
-
.filter((t) => !filter.research ||
|
|
90
|
+
.filter((t) => !filter.research || (scope ? ofResearch(tree, t, scope) : t.research === filter.research))
|
|
81
91
|
.filter((t) => !filter.level || t.level === filter.level);
|
|
82
92
|
return rankTasks(tree, tasks, filter.strategy, { storyTurn: filter.storyTurn });
|
|
83
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* What an agent is told of a task about people nothing links to the tree (core/kin.ts offTree): it waits out of the
|
|
96
|
+
* queue until the user asks for it or the hypothesis that would join them is decided; the task that tests that
|
|
97
|
+
* hypothesis names it. Undefined for a task of the tree.
|
|
98
|
+
*/
|
|
99
|
+
export function offTreeLine(tree, t, starting = false) {
|
|
100
|
+
if (!offTree(tree)(t))
|
|
101
|
+
return undefined;
|
|
102
|
+
const joins = joiningHypotheses(tree, aboutPeople(tree, t.subject));
|
|
103
|
+
const first = joins.length
|
|
104
|
+
? `test first what would join them: ${joins.map((h) => `${h.id} "${truncate(h.question, 70)}"`).join("; ")} — a task that tests it names it (--about ${joins[0].id})`
|
|
105
|
+
: "first a link to a person of the tree: a hypothesis (strom hypothesis add … --about P… of the tree) and a task that tests it, naming it (--about H…)";
|
|
106
|
+
return starting
|
|
107
|
+
? `⚠ ${t.id} is about people nothing links to the tree yet: go on only if the user asked for this work. Else close the session (strom session close --continue …) and ${first}`
|
|
108
|
+
: `· ${t.id} is about people nothing links to the tree yet: it waits out of the queue until the user asks for it or a hypothesis joins them — ${first}`;
|
|
109
|
+
}
|
|
84
110
|
function taskLine(t) {
|
|
85
111
|
return [t.id, `p${t.priority}`, t.level, t.state === "open" ? "" : `[${t.state}]`, truncate(t.what, 70)];
|
|
86
112
|
}
|
|
@@ -183,7 +209,7 @@ register({
|
|
|
183
209
|
throw new UsageError("--priority must be 1..5");
|
|
184
210
|
const where = listOpt(opts.where).map((w) => (/^[Bb]\d+$/.test(w) ? requireRecord(tree, w, "recordset").id : w));
|
|
185
211
|
const subject = subjects(tree, listOpt(opts.about));
|
|
186
|
-
const research = defaultResearch(tree, opts.research);
|
|
212
|
+
const research = defaultResearch(tree, opts.research, subject);
|
|
187
213
|
// The same work again: an open task of the same person and level that reads the same (or nearly, in the same
|
|
188
214
|
// books) — not added; what is new goes into that one.
|
|
189
215
|
if (!opts.anyway) {
|
|
@@ -223,7 +249,7 @@ register({
|
|
|
223
249
|
// it is known — not in a session spent finding out that there is nothing to read.
|
|
224
250
|
const noImages = lacksImages(tree, t);
|
|
225
251
|
return {
|
|
226
|
-
text: lines(written(tree), ...similar.map((x) => `⚠ similar ${x.state} task ${x.id} "${truncate(x.what, 60)}" — if it is the same, keep one: strom task drop ${t.id} --reason "duplicate of ${x.id}" and strom task edit ${x.id} --where … --note "…"`), noImages ? `· no images for it here yet — if the user has to download them, say which now: strom task wait ${t.id} --images B…:<numbers> --on "<the book, its link, which images>"` : undefined),
|
|
252
|
+
text: lines(written(tree), ...similar.map((x) => `⚠ similar ${x.state} task ${x.id} "${truncate(x.what, 60)}" — if it is the same, keep one: strom task drop ${t.id} --reason "duplicate of ${x.id}" and strom task edit ${x.id} --where … --note "…"`), offTreeLine(tree, t), noImages ? `· no images for it here yet — if the user has to download them, say which now: strom task wait ${t.id} --images B…:<numbers> --on "<the book, its link, which images>"` : undefined),
|
|
227
253
|
data: { task: t, similar: similar.map((x) => x.id), needsImages: noImages },
|
|
228
254
|
};
|
|
229
255
|
},
|
|
@@ -242,8 +268,13 @@ register({
|
|
|
242
268
|
const strategy = ctx.settings.strategy(tree.config);
|
|
243
269
|
const queue = rankedQueue(tree, { ...(research ? { research } : {}), ...(opts.level ? { level: String(opts.level) } : {}), strategy });
|
|
244
270
|
const first = queue[0];
|
|
245
|
-
if (!first)
|
|
246
|
-
|
|
271
|
+
if (!first) {
|
|
272
|
+
const held = tree.list("task").filter(offTree(tree)).filter((t) => t.state === "open").length;
|
|
273
|
+
const text = held
|
|
274
|
+
? `no open task of the tree — ${held} wait about people nothing links to it yet (strom task list --off-tree): test the hypothesis that would join them, or ask the user`
|
|
275
|
+
: "no open tasks → strom research show <G…> to see what is missing, then strom task add";
|
|
276
|
+
return { text, data: { task: null, offTree: held } };
|
|
277
|
+
}
|
|
247
278
|
const t = first.task;
|
|
248
279
|
return {
|
|
249
280
|
text: lines(taskDetail(tree, t), "", `first because: ${first.why} (queue: ${strategy})`, `start it: strom task start ${t.id}`),
|
|
@@ -263,6 +294,7 @@ register({
|
|
|
263
294
|
{ name: "research", type: "string", value: "<G…>", description: "only this research" },
|
|
264
295
|
{ name: "level", type: "string", value: "<level>", description: "only this level" },
|
|
265
296
|
{ name: "about", type: "string", value: "<who>", description: "only tasks about this person/record (also X…, H…)" },
|
|
297
|
+
{ name: "off-tree", type: "boolean", description: "the open tasks that wait out of the queue: about people nothing links to the tree yet" },
|
|
266
298
|
{ name: "full", type: "boolean", description: "--json: whole records instead of one row each" },
|
|
267
299
|
],
|
|
268
300
|
run(ctx, { opts }) {
|
|
@@ -270,7 +302,10 @@ register({
|
|
|
270
302
|
const research = typeof opts.research === "string" ? resolveResearch(tree, opts.research).id : undefined;
|
|
271
303
|
let all;
|
|
272
304
|
const why = new Map();
|
|
273
|
-
|
|
305
|
+
const off = offTree(tree);
|
|
306
|
+
if (opts["off-tree"])
|
|
307
|
+
all = tree.list("task").filter((t) => t.state === "open" && off(t) && (!opts.level || t.level === opts.level));
|
|
308
|
+
else if (!opts.state) {
|
|
274
309
|
const ranked = rankedQueue(tree, { ...(research ? { research } : {}), ...(opts.level ? { level: String(opts.level) } : {}), strategy: ctx.settings.strategy(tree.config) });
|
|
275
310
|
for (const r of ranked)
|
|
276
311
|
why.set(r.task.id, r.why);
|
|
@@ -278,8 +313,9 @@ register({
|
|
|
278
313
|
}
|
|
279
314
|
else {
|
|
280
315
|
all = tree.list("task").filter((t) => opts.state === "all" || t.state === opts.state);
|
|
281
|
-
|
|
282
|
-
|
|
316
|
+
const scope = research ? scopes(tree).find((s) => s.research.id === research) : undefined;
|
|
317
|
+
if (scope)
|
|
318
|
+
all = all.filter((t) => ofResearch(tree, t, scope));
|
|
283
319
|
if (opts.level)
|
|
284
320
|
all = all.filter((t) => t.level === opts.level);
|
|
285
321
|
}
|
|
@@ -288,8 +324,10 @@ register({
|
|
|
288
324
|
all = all.filter((t) => t.subject.includes(id));
|
|
289
325
|
}
|
|
290
326
|
const page = paginate(all, ctx.limit, ctx.page);
|
|
327
|
+
// the queue says what waits out of it for a link to the tree
|
|
328
|
+
const held = !opts.state && !opts["off-tree"] && !opts.about ? tree.list("task").filter((t) => t.state === "open" && off(t)).length : 0;
|
|
291
329
|
return {
|
|
292
|
-
text: all.length ? lines(table(page.items.map(taskLine)), moreLine(page, "strom task list")) : "no tasks",
|
|
330
|
+
text: lines(all.length ? lines(table(page.items.map(taskLine)), moreLine(page, "strom task list")) : "no tasks", held ? `${held} more wait out of the queue: about people nothing links to the tree yet (strom task list --off-tree)` : undefined),
|
|
293
331
|
data: { total: page.total, tasks: opts.full ? page.items : page.items.map((t) => ({ id: t.id, priority: t.priority, level: t.level, state: t.state, what: t.what, subject: t.subject, ...(why.has(t.id) ? { why: why.get(t.id) } : {}) })) },
|
|
294
332
|
};
|
|
295
333
|
},
|
|
@@ -351,7 +389,7 @@ register({
|
|
|
351
389
|
summary: `${id} ${fields.map((f) => (f === "doneWhen" ? "done-when" : f === "subject" ? "about" : f)).join(", ")}${change.where ? ` → ${truncate(change.where.join("; "), 50)}` : ""}`,
|
|
352
390
|
reason: opts.reason,
|
|
353
391
|
});
|
|
354
|
-
return { text: written(tree), data: { task: t } };
|
|
392
|
+
return { text: lines(written(tree), change.subject ? offTreeLine(tree, t) : undefined), data: { task: t } };
|
|
355
393
|
},
|
|
356
394
|
}, {
|
|
357
395
|
path: ["task", "start"],
|
package/dist/commands/trees.js
CHANGED
|
@@ -6,13 +6,19 @@ import { register } from "../cli/registry.js";
|
|
|
6
6
|
import { Context } from "../cli/context.js";
|
|
7
7
|
import { lines, table } from "../cli/format.js";
|
|
8
8
|
import { Tree } from "../core/tree.js";
|
|
9
|
-
import { safeFolderName } from "../core/text.js";
|
|
9
|
+
import { foldText, safeFolderName } from "../core/text.js";
|
|
10
10
|
import { isValidLang, langName } from "../core/lang.js";
|
|
11
|
-
import { UsageError } from "../core/errors.js";
|
|
11
|
+
import { NeedsConsentError, UsageError } from "../core/errors.js";
|
|
12
12
|
import { verifyFast } from "../core/integrity.js";
|
|
13
13
|
import * as git from "../core/git.js";
|
|
14
14
|
import { ensureShared, setTreeSetting } from "./setup.js";
|
|
15
15
|
import { syncAgentFiles } from "../agents/files.js";
|
|
16
|
+
import { ui } from "../cli/ui.js";
|
|
17
|
+
import { atWork } from "../core/relocate.js";
|
|
18
|
+
import { moveToTrash } from "../core/trash.js";
|
|
19
|
+
import { stopLive } from "../core/live.js";
|
|
20
|
+
import { onlyItsFiles, packPlan } from "../core/pack.js";
|
|
21
|
+
import { defaultPackFile, packTree } from "./pack.js";
|
|
16
22
|
register({
|
|
17
23
|
path: ["init"],
|
|
18
24
|
summary: "Create a family tree (a folder with its own history) to hold researches",
|
|
@@ -81,6 +87,80 @@ register({
|
|
|
81
87
|
return { text: `now working on "${tree.config.name}" (${ctx.display(root)})`, data: { name: tree.config.name, root } };
|
|
82
88
|
},
|
|
83
89
|
});
|
|
90
|
+
register({
|
|
91
|
+
path: ["trees", "remove"],
|
|
92
|
+
summary: "Take a family tree off this computer — into the system's trash, a backup offered first (the person runs this)",
|
|
93
|
+
group: "research",
|
|
94
|
+
description: "Offers a backup first (strom pack --all-images: it comes back with strom unpack), then the images no other tree\n" +
|
|
95
|
+
"uses; the person types the tree's name to confirm. The tree's folder goes into the system's trash (never deleted\n" +
|
|
96
|
+
"for good), so do the images they chose. Never while somebody works on it; only the person at the computer —\n" +
|
|
97
|
+
"never an agent.",
|
|
98
|
+
args: [{ name: "tree", description: "tree name or folder", required: true }],
|
|
99
|
+
examples: ['strom trees remove "Novákovi"'],
|
|
100
|
+
async run(ctx, { args }) {
|
|
101
|
+
const lang = ctx.uiLang();
|
|
102
|
+
const probe = Context.fromOptions({ env: ctx.env, cwd: ctx.cwd, io: ctx.io, json: ctx.json, values: { tree: args[0] } });
|
|
103
|
+
const root = probe.locateTree();
|
|
104
|
+
if (!ctx.interactive)
|
|
105
|
+
throw new NeedsConsentError([{ key: "remove", kind: "consent", question: ui(lang, "ui.remove.person"), set: `strom trees remove "${root}"` }]);
|
|
106
|
+
const tree = Tree.open(root, ctx.env);
|
|
107
|
+
const name = tree.config.name;
|
|
108
|
+
// the Strom app following it live is no work: its bridge only reads, and stops before the tree goes
|
|
109
|
+
if (atWork(root, { bridge: false }))
|
|
110
|
+
throw new UsageError(ui(lang, "ui.remove.busy", { name }), { hint: "strom" });
|
|
111
|
+
const shared = ctx.settings.shared().value;
|
|
112
|
+
const others = ctx.knownTrees().map((k) => k.root).filter((r) => path.resolve(r) !== path.resolve(root));
|
|
113
|
+
const only = onlyItsFiles(root, others, shared);
|
|
114
|
+
const onlyBytes = only.reduce((s, f) => s + f.size, 0);
|
|
115
|
+
const mb = (b) => (b / 1e6 < 10 ? (b / 1e6).toFixed(1) : Math.round(b / 1e6).toString());
|
|
116
|
+
ctx.io.stdout(ui(lang, "ui.remove.what", { name, root: ctx.display(root), persons: tree.list("person").length }) + "\n");
|
|
117
|
+
// a way back first
|
|
118
|
+
let backup;
|
|
119
|
+
const all = packPlan(tree, shared, true).media.reduce((s, m) => s + m.size, 0);
|
|
120
|
+
if (await ctx.confirm(ui(lang, "ui.remove.backup", { mb: mb(all) }), true)) {
|
|
121
|
+
const out = defaultPackFile(ctx, tree);
|
|
122
|
+
backup = packTree(tree, shared, out, true).file;
|
|
123
|
+
ctx.io.stdout(ui(lang, "ui.remove.backup.done", { file: ctx.display(backup) }) + "\n");
|
|
124
|
+
}
|
|
125
|
+
const images = only.length > 0 && (await ctx.confirm(ui(lang, "ui.remove.images", { n: only.length, mb: mb(onlyBytes) }), backup !== undefined));
|
|
126
|
+
const typed = (await ctx.ask(ui(lang, "ui.remove.type", { name }))).trim();
|
|
127
|
+
if (!typed || foldText(typed) !== foldText(name))
|
|
128
|
+
return { text: ui(lang, "ui.remove.cancelled"), data: { removed: false, ...(backup ? { backup } : {}) } };
|
|
129
|
+
if (atWork(root, { bridge: false }))
|
|
130
|
+
throw new UsageError(ui(lang, "ui.remove.busy", { name }), { hint: "strom" });
|
|
131
|
+
const followed = stopLive(root, "the family tree is taken off this computer");
|
|
132
|
+
moveToTrash(root, ctx.env, path.basename(root));
|
|
133
|
+
let moved = 0;
|
|
134
|
+
if (images) {
|
|
135
|
+
// the images together, one folder in the trash named after the tree
|
|
136
|
+
const folder = path.join(shared, `.${safeFolderName(name) || "tree"}-${Date.now()}`);
|
|
137
|
+
for (const f of only) {
|
|
138
|
+
const to = path.join(folder, ...f.file.split("/"));
|
|
139
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
140
|
+
fs.renameSync(path.join(shared, ...f.file.split("/")), to);
|
|
141
|
+
moved++;
|
|
142
|
+
}
|
|
143
|
+
moveToTrash(folder, ctx.env, ui(lang, "ui.remove.images.folder", { name }));
|
|
144
|
+
}
|
|
145
|
+
const cfg = ctx.settings.config;
|
|
146
|
+
if (cfg.extraTrees)
|
|
147
|
+
cfg.extraTrees = cfg.extraTrees.filter((r) => path.resolve(r) !== path.resolve(root));
|
|
148
|
+
// the tree worked on went: the next one known is worked on now, never a menu with none chosen
|
|
149
|
+
let next;
|
|
150
|
+
if (cfg.currentTree && path.resolve(cfg.currentTree) === path.resolve(root)) {
|
|
151
|
+
const left = ctx.knownTrees().filter((k) => path.resolve(k.root) !== path.resolve(root));
|
|
152
|
+
if (left.length) {
|
|
153
|
+
cfg.currentTree = left[0].root;
|
|
154
|
+
next = left[0].name;
|
|
155
|
+
}
|
|
156
|
+
else
|
|
157
|
+
delete cfg.currentTree;
|
|
158
|
+
}
|
|
159
|
+
ctx.settings.save();
|
|
160
|
+
const text = lines(ui(lang, "ui.remove.done", { name }), followed ? ui(lang, "ui.remove.live") : undefined, moved ? ui(lang, "ui.remove.images.done", { n: moved, mb: mb(onlyBytes) }) : undefined, backup ? ui(lang, "ui.remove.backup.back", { file: ctx.display(backup) }) : undefined, ui(lang, backup ? "ui.remove.trash" : "ui.remove.trash.alone"), next ? ui(lang, "ui.remove.next", { name: next }) : undefined);
|
|
161
|
+
return { text, data: { removed: true, root, images: moved, ...(backup ? { backup } : {}), ...(next ? { current: cfg.currentTree } : {}) } };
|
|
162
|
+
},
|
|
163
|
+
});
|
|
84
164
|
register({
|
|
85
165
|
path: ["status"],
|
|
86
166
|
summary: "State of the current tree: researches, data, checks, recent changes",
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// What one commit of a research saved, as a person reads it — in the research
|
|
2
|
+
// language, records by their names: "New person: Jan Novák (*1905) [P0001]",
|
|
3
|
+
// "Downloaded 7 images: Kniha N 1850 (images 16–22)". Made from the operations
|
|
4
|
+
// the commit logged (data/ops/*.jsonl, each line one operation); a commit
|
|
5
|
+
// without them (the readers' notes) by its subject. For the Strom app (the
|
|
6
|
+
// bridge's /log and change events): strom's own commit subjects are English
|
|
7
|
+
// and name records by their IDs.
|
|
8
|
+
import { displayName, lifespan } from "./people.js";
|
|
9
|
+
import { ownerName, cardFact } from "./overview.js";
|
|
10
|
+
import { phrase } from "./phrases.js";
|
|
11
|
+
import { eventName, humanDate, humanPlace, humanTask } from "../cli/human.js";
|
|
12
|
+
/** Operations nobody needs to read: what another line says already, or bookkeeping. */
|
|
13
|
+
const QUIET = new Set(["session.metrics", "task.start", "task.continue", "session.note"]);
|
|
14
|
+
/** The one-line form of an operation, by its kind: {who} a person or family, {name} the record. */
|
|
15
|
+
const SAID = {
|
|
16
|
+
"person.add": "log.person.add",
|
|
17
|
+
"person.name": "log.person.name",
|
|
18
|
+
"person.edit": "log.person.edit",
|
|
19
|
+
"person.cite": "log.person.cite",
|
|
20
|
+
"person.retract": "log.person.retract",
|
|
21
|
+
"family.add": "log.family.add",
|
|
22
|
+
"family.edit": "log.family.edit",
|
|
23
|
+
"family.cite": "log.family.cite",
|
|
24
|
+
"source.add": "log.source.add",
|
|
25
|
+
"source.edit": "log.source.edit",
|
|
26
|
+
"recordset.add": "log.recordset.add",
|
|
27
|
+
"recordset.edit": "log.recordset.edit",
|
|
28
|
+
"recordset.calibrate": "log.recordset.calibrate",
|
|
29
|
+
"task.add": "log.task.add",
|
|
30
|
+
"task.done": "log.task.done",
|
|
31
|
+
"task.park": "log.task.park",
|
|
32
|
+
"task.drop": "log.task.drop",
|
|
33
|
+
"task.wait": "log.task.wait",
|
|
34
|
+
"task.wake": "log.task.wake",
|
|
35
|
+
"task.edit": "log.task.edit",
|
|
36
|
+
"session.start": "log.session.start",
|
|
37
|
+
"session.close": "log.session.close",
|
|
38
|
+
"lesson.add": "log.lesson.add",
|
|
39
|
+
"hypothesis.add": "log.hypothesis.add",
|
|
40
|
+
"hypothesis.argue": "log.hypothesis.argue",
|
|
41
|
+
"hypothesis.decide": "log.hypothesis.decide",
|
|
42
|
+
"conflict.add": "log.conflict.add",
|
|
43
|
+
"conflict.resolve": "log.conflict.resolve",
|
|
44
|
+
"place.add": "log.place.add",
|
|
45
|
+
"place.edit": "log.place.edit",
|
|
46
|
+
"place.jurisdiction": "log.place.edit",
|
|
47
|
+
"repository.add": "log.repository.add",
|
|
48
|
+
"repository.edit": "log.repository.edit",
|
|
49
|
+
"story.set": "log.story.set",
|
|
50
|
+
"story.approve": "log.story.approve",
|
|
51
|
+
"note.add": "log.note.add",
|
|
52
|
+
"input.add": "log.input.add",
|
|
53
|
+
"input.sync": "log.input.sync",
|
|
54
|
+
"input.done": "log.input.done",
|
|
55
|
+
"research.add": "log.research.add",
|
|
56
|
+
"research.edit": "log.research.edit",
|
|
57
|
+
"research.pause": "log.research.pause",
|
|
58
|
+
"research.resume": "log.research.resume",
|
|
59
|
+
"research.done": "log.research.done",
|
|
60
|
+
};
|
|
61
|
+
const EVENT_SAID = {
|
|
62
|
+
"event.add": "log.event.add",
|
|
63
|
+
"event.edit": "log.event.edit",
|
|
64
|
+
"event.cite": "log.event.cite",
|
|
65
|
+
"event.retract": "log.event.retract",
|
|
66
|
+
};
|
|
67
|
+
const SEARCH_SAID = {
|
|
68
|
+
found: "log.search.found",
|
|
69
|
+
negative: "log.search.negative",
|
|
70
|
+
partial: "log.search.partial",
|
|
71
|
+
inconclusive: "log.search.inconclusive",
|
|
72
|
+
};
|
|
73
|
+
function short(text, max = 90) {
|
|
74
|
+
const one = text.replace(/\s+/g, " ").trim();
|
|
75
|
+
return one.length > max ? `${one.slice(0, max - 1).trimEnd()}…` : one;
|
|
76
|
+
}
|
|
77
|
+
/** Image numbers as ranges: 16–22, 30. */
|
|
78
|
+
function ranges(ns) {
|
|
79
|
+
const sorted = [...new Set(ns)].sort((a, b) => a - b);
|
|
80
|
+
const out = [];
|
|
81
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
82
|
+
let j = i;
|
|
83
|
+
while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1)
|
|
84
|
+
j++;
|
|
85
|
+
out.push(j > i ? `${sorted[i]}–${sorted[j]}` : String(sorted[i]));
|
|
86
|
+
i = j;
|
|
87
|
+
}
|
|
88
|
+
return out.join(", ");
|
|
89
|
+
}
|
|
90
|
+
function kindOf(op) {
|
|
91
|
+
const [record] = op.split(".");
|
|
92
|
+
if (record === "person" || record === "family" || record === "event")
|
|
93
|
+
return "person";
|
|
94
|
+
if (record === "source" || record === "media" || record === "recordset" || record === "repository")
|
|
95
|
+
return "source";
|
|
96
|
+
if (record === "story")
|
|
97
|
+
return "story";
|
|
98
|
+
return "other";
|
|
99
|
+
}
|
|
100
|
+
/** Lines of what a commit saved, in the research language, each with what it is about; its subject when it logged no operation. */
|
|
101
|
+
export function changeLines(tree, ops, subject, lang) {
|
|
102
|
+
const get = (id) => (id ? tree.get(id) : undefined);
|
|
103
|
+
const who = (id) => {
|
|
104
|
+
const r = get(id);
|
|
105
|
+
if (!r)
|
|
106
|
+
return id ?? "";
|
|
107
|
+
if (r.type === "person") {
|
|
108
|
+
const span = lifespan(r);
|
|
109
|
+
return `${displayName(r)}${span ? ` (${span})` : ""} [${r.id}]`;
|
|
110
|
+
}
|
|
111
|
+
return r.type === "family" ? ownerName(tree, r.id) : r.id;
|
|
112
|
+
};
|
|
113
|
+
const task = (id) => {
|
|
114
|
+
const t = get(id);
|
|
115
|
+
return t ? short(humanTask(tree, t.what, lang)) : (id ?? "");
|
|
116
|
+
};
|
|
117
|
+
const name = (id) => {
|
|
118
|
+
const r = get(id);
|
|
119
|
+
if (!r)
|
|
120
|
+
return id ?? "";
|
|
121
|
+
switch (r.type) {
|
|
122
|
+
case "person":
|
|
123
|
+
case "family":
|
|
124
|
+
return who(r.id);
|
|
125
|
+
case "task":
|
|
126
|
+
return task(r.id);
|
|
127
|
+
case "session":
|
|
128
|
+
return r.task ? task(r.task) : r.id;
|
|
129
|
+
case "source":
|
|
130
|
+
case "recordset":
|
|
131
|
+
case "conflict":
|
|
132
|
+
return short(r.title);
|
|
133
|
+
case "research":
|
|
134
|
+
case "repository":
|
|
135
|
+
case "input":
|
|
136
|
+
return short(r.name);
|
|
137
|
+
case "search":
|
|
138
|
+
return short(r.question);
|
|
139
|
+
case "lesson":
|
|
140
|
+
return short(r.rule);
|
|
141
|
+
case "hypothesis":
|
|
142
|
+
return short(r.question);
|
|
143
|
+
case "place":
|
|
144
|
+
return r.names[0]?.name ?? r.id;
|
|
145
|
+
default:
|
|
146
|
+
return r.id;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
const fact = (owner, eventId) => {
|
|
150
|
+
const e = get(owner)?.events.find((x) => x.id === eventId);
|
|
151
|
+
if (!e)
|
|
152
|
+
return undefined;
|
|
153
|
+
const f = cardFact(tree, e);
|
|
154
|
+
const what = [f.value, humanDate(f.date, lang), humanPlace(f.place, f.house, lang)].filter(Boolean).join(", ");
|
|
155
|
+
return `${eventName(f.kind, lang, f.label)}${what ? `: ${what}` : ""}`;
|
|
156
|
+
};
|
|
157
|
+
const out = [];
|
|
158
|
+
let kind = "other";
|
|
159
|
+
const say = (line) => {
|
|
160
|
+
if (line && !out.some((l) => l.text === line))
|
|
161
|
+
out.push({ text: line, kind });
|
|
162
|
+
};
|
|
163
|
+
// Images downloaded: one line for each book, whatever their order.
|
|
164
|
+
const images = new Map();
|
|
165
|
+
for (const o of ops) {
|
|
166
|
+
if (QUIET.has(o.op))
|
|
167
|
+
continue;
|
|
168
|
+
kind = kindOf(o.op);
|
|
169
|
+
const [first, second] = o.targets;
|
|
170
|
+
if (o.op === "media.add") {
|
|
171
|
+
const m = get(first);
|
|
172
|
+
const key = m?.recordset ?? "";
|
|
173
|
+
if (!images.has(key)) {
|
|
174
|
+
images.set(key, []);
|
|
175
|
+
out.push({ text: `\u0000media:${key}`, kind: "source" }); // its place in the order, filled in below
|
|
176
|
+
}
|
|
177
|
+
if (m?.image !== undefined)
|
|
178
|
+
images.get(key).push(m.image);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (EVENT_SAID[o.op]) {
|
|
182
|
+
const f = fact(first, second);
|
|
183
|
+
say(f ? phrase(lang, EVENT_SAID[o.op], { who: who(first), fact: f }) : o.summary);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (o.op === "search.add" || o.op === "search.edit") {
|
|
187
|
+
const s = get(first);
|
|
188
|
+
say(s ? phrase(lang, SEARCH_SAID[s.result] ?? "log.search.found", { name: short(s.question) }) : o.summary);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (o.op === "family.child") {
|
|
192
|
+
say(phrase(lang, "log.family.child", { who: who(first), child: who(second) }));
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (o.op === "person.merge" || o.op === "family.merge") {
|
|
196
|
+
say(phrase(lang, "log.merge", { who: who(second) }));
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const key = SAID[o.op];
|
|
200
|
+
say(key ? phrase(lang, key, { who: who(first), name: name(first) }) : o.summary);
|
|
201
|
+
}
|
|
202
|
+
const lines = out.map(({ text: line, kind }) => {
|
|
203
|
+
if (!line.startsWith("\u0000media:"))
|
|
204
|
+
return { text: line, kind };
|
|
205
|
+
const book = line.slice("\u0000media:".length);
|
|
206
|
+
const ns = images.get(book) ?? [];
|
|
207
|
+
const title = get(book)?.title;
|
|
208
|
+
const n = Math.max(ns.length, 1);
|
|
209
|
+
const text = !title
|
|
210
|
+
? phrase(lang, "log.media.loose", { n })
|
|
211
|
+
: ns.length === 1
|
|
212
|
+
? phrase(lang, "log.media.one", { name: title, image: ns[0] })
|
|
213
|
+
: phrase(lang, "log.media", { name: title, n, images: ns.length ? ranges(ns) : "" });
|
|
214
|
+
return { text, kind };
|
|
215
|
+
});
|
|
216
|
+
if (lines.length)
|
|
217
|
+
return lines;
|
|
218
|
+
// no operations: the readers' notes of a book, else the subject as strom wrote it
|
|
219
|
+
const reading = /^Reading (B\d+)-(\d+)-(\d+): (\d+) images?/.exec(subject);
|
|
220
|
+
if (reading)
|
|
221
|
+
return [{ text: phrase(lang, "log.reading", { name: get(reading[1])?.title ?? reading[1], n: reading[4], images: reading[2] === reading[3] ? reading[2] : `${reading[2]}–${reading[3]}` }), kind: "source" }];
|
|
222
|
+
return ops.length ? [] : [{ text: subject, kind: "other" }];
|
|
223
|
+
}
|
package/dist/core/clock.js
CHANGED
|
@@ -5,12 +5,32 @@
|
|
|
5
5
|
// brief says it), near the end every strom command it runs reminds it, and at
|
|
6
6
|
// the limit an agent that can be resumed gets a few minutes more to write down
|
|
7
7
|
// what it found and close the session (runners: wrapUp).
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
8
10
|
/** From how long before the end strom's output reminds the agent (a short session: its last quarter). */
|
|
9
11
|
export const REMIND_MS = 10 * 60_000;
|
|
10
12
|
/** From how long before the end the reminder says to write down and close (a short session: its last eighth). */
|
|
11
13
|
export const CLOSE_MS = 3 * 60_000;
|
|
12
14
|
/** The time an agent stopped at its limit gets to write down what it found. */
|
|
13
15
|
export const WRAP_UP_MS = 5 * 60_000;
|
|
16
|
+
/**
|
|
17
|
+
* The user asked a session to finish now (strom session finish): a file of the tree's own, read by every strom
|
|
18
|
+
* command the session's agent runs — a run's session cannot be told otherwise (it reads no input).
|
|
19
|
+
*/
|
|
20
|
+
export function finishFile(root, session) {
|
|
21
|
+
return path.join(root, ".strom", "finish", `${session}.json`);
|
|
22
|
+
}
|
|
23
|
+
export function askFinish(root, session, by) {
|
|
24
|
+
fs.mkdirSync(path.dirname(finishFile(root, session)), { recursive: true });
|
|
25
|
+
fs.writeFileSync(finishFile(root, session), JSON.stringify({ at: new Date().toISOString(), by }) + "\n");
|
|
26
|
+
}
|
|
27
|
+
export function finishAsked(root, session) {
|
|
28
|
+
return fs.existsSync(finishFile(root, session));
|
|
29
|
+
}
|
|
30
|
+
/** What every strom command says to the agent of a session the user asked to finish. */
|
|
31
|
+
export const FINISH_LINE = "⏳ the user asks you to finish this session now: start nothing new; record in strom what you found and have not recorded yet " +
|
|
32
|
+
'(facts, sources, the images searched, in vain too), then strom session close --continue --summary "…" --next "exactly where you stopped" ' +
|
|
33
|
+
"(or finish the task, if it is done).";
|
|
14
34
|
/** When this session is stopped (ms since the epoch), if a run set a limit. */
|
|
15
35
|
export function deadlineOf(env) {
|
|
16
36
|
const t = Date.parse(env.STROM_DEADLINE ?? "");
|
package/dist/core/config.js
CHANGED
|
@@ -55,6 +55,7 @@ export const SETTINGS = [
|
|
|
55
55
|
// Read from the config file only — no variable, no flag, no tree: nothing an agent can set.
|
|
56
56
|
{ 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" },
|
|
57
57
|
{ 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" },
|
|
58
|
+
{ key: "agent.browser", env: "", tree: true, kind: "choice", choices: ["archives", "always"], description: "browser tools (Claude in Chrome) for the Claude Code sessions strom starts: archives (default) — only in a research whose connectors fetch through the browser, for their sites; always — in every session, for the sites of the research's archives (a conversation asks about others) — only you turn it on" },
|
|
58
59
|
{ 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" },
|
|
59
60
|
{ 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" },
|
|
60
61
|
{ 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" },
|
|
@@ -72,6 +73,7 @@ const FIELDS = {
|
|
|
72
73
|
"agent.permissions": "agentPermissions",
|
|
73
74
|
"agent.where": "agentWhere",
|
|
74
75
|
"agent.remote": "agentRemote",
|
|
76
|
+
"agent.browser": "agentBrowser",
|
|
75
77
|
"strom.app": "stromApp",
|
|
76
78
|
"strom.app.url": "stromAppUrl",
|
|
77
79
|
"main.person": "mainPerson",
|
package/dist/core/connector.js
CHANGED
|
@@ -236,6 +236,11 @@ export function treeBrowserConnectors(tree, shared) {
|
|
|
236
236
|
if (!all.length)
|
|
237
237
|
return [];
|
|
238
238
|
const fetchedBy = new Set(tree.list("media").flatMap((m) => (m.fetched ? [m.fetched.connector] : [])));
|
|
239
|
+
const hosts = new Set(treeSites(tree));
|
|
240
|
+
return all.filter((c) => fetchedBy.has(c.name) || c.manifest.hosts.some((h) => hosts.has(siteOf(h))));
|
|
241
|
+
}
|
|
242
|
+
/** The sites of the archives a tree's records name (repositories, record sets, sources with an address). */
|
|
243
|
+
function treeSites(tree) {
|
|
239
244
|
const hosts = new Set();
|
|
240
245
|
for (const r of [...tree.list("repository"), ...tree.list("recordset"), ...tree.list("source")])
|
|
241
246
|
if (r.url && !r.retracted)
|
|
@@ -245,7 +250,26 @@ export function treeBrowserConnectors(tree, shared) {
|
|
|
245
250
|
catch {
|
|
246
251
|
// not an address
|
|
247
252
|
}
|
|
248
|
-
return
|
|
253
|
+
return [...hosts];
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Browser tools for the tree's agent (Claude in Chrome): whether its sessions get them, and for which sites.
|
|
257
|
+
* `agent.browser` archives (default): only where a connector of the tree fetches through the browser, for its sites;
|
|
258
|
+
* always (the user's choice): every session, for the sites of the tree's archives and of the connectors — a
|
|
259
|
+
* conversation asks about any other, a run nobody watches uses none other.
|
|
260
|
+
*/
|
|
261
|
+
export function agentBrowser(tree, shared) {
|
|
262
|
+
const via = treeBrowserConnectors(tree, shared);
|
|
263
|
+
const always = new Settings(tree.env, {}).resolve("agent.browser", tree.config)?.value === "always";
|
|
264
|
+
const hosts = new Set(via.flatMap((c) => c.manifest.hosts));
|
|
265
|
+
if (always) {
|
|
266
|
+
for (const c of shared ? scanConnectors(shared).connectors : [])
|
|
267
|
+
for (const h of c.manifest.hosts)
|
|
268
|
+
hosts.add(h);
|
|
269
|
+
for (const h of treeSites(tree))
|
|
270
|
+
hosts.add(h);
|
|
271
|
+
}
|
|
272
|
+
return { on: always || via.length > 0, hosts: [...hosts], always };
|
|
249
273
|
}
|
|
250
274
|
export function findConnector(shared, name) {
|
|
251
275
|
if (!shared)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Which direction of a research (G…) a task belongs to. A task names its
|
|
2
|
+
// research when it was added in a session of one (or named it); a task added
|
|
3
|
+
// elsewhere belongs to the direction its people are in — the narrowest one when
|
|
4
|
+
// several reach them (the ancestors of a grandmother before those of her
|
|
5
|
+
// grandson). The queue, the menu and the Strom app go by the same answer.
|
|
6
|
+
import { researchPeople } from "./frontier.js";
|
|
7
|
+
import { subjectPeople } from "./records.js";
|
|
8
|
+
import { typeOfId } from "./tree.js";
|
|
9
|
+
export function scopes(tree) {
|
|
10
|
+
return tree.list("research").map((research) => ({ research, people: researchPeople(tree, research) }));
|
|
11
|
+
}
|
|
12
|
+
/** The people a task is about: its persons, the partners of its families, the people of its conflicts and hypotheses. */
|
|
13
|
+
export function aboutPeople(tree, subject) {
|
|
14
|
+
const partners = subject.filter((id) => typeOfId(id) === "family").flatMap((id) => tree.get(id)?.partners ?? []);
|
|
15
|
+
return [...new Set([...subjectPeople(tree, subject), ...partners])];
|
|
16
|
+
}
|
|
17
|
+
/** The direction a task belongs to: its own, else the narrowest one its people are in; none for a task about nobody. */
|
|
18
|
+
export function directionOf(tree, t, all) {
|
|
19
|
+
if (t.research)
|
|
20
|
+
return t.research;
|
|
21
|
+
const about = aboutPeople(tree, t.subject);
|
|
22
|
+
if (!about.length)
|
|
23
|
+
return undefined;
|
|
24
|
+
return all
|
|
25
|
+
.filter((s) => about.some((p) => s.people.has(p)))
|
|
26
|
+
.sort((a, b) => a.people.size - b.people.size || a.research.id.localeCompare(b.research.id))[0]?.research.id;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Whether a task is work of a research (strom session start --research, strom
|
|
30
|
+
* run --research): its own, or about the people it is about (a task added
|
|
31
|
+
* elsewhere or before it began); a task about nobody and of no research, any
|
|
32
|
+
* research's.
|
|
33
|
+
*/
|
|
34
|
+
export function ofResearch(tree, t, scope) {
|
|
35
|
+
if (t.research === scope.research.id)
|
|
36
|
+
return true;
|
|
37
|
+
const about = aboutPeople(tree, t.subject);
|
|
38
|
+
if (!about.length)
|
|
39
|
+
return !t.research;
|
|
40
|
+
return about.some((p) => scope.people.has(p)) || (!t.research && !scope.people.size);
|
|
41
|
+
}
|