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/core/pack.js
CHANGED
|
@@ -309,3 +309,34 @@ export function unpack(pack, treesDir, shared, env) {
|
|
|
309
309
|
throw e;
|
|
310
310
|
}
|
|
311
311
|
}
|
|
312
|
+
/** The files of the shared store a tree names (its images, the family's material kept there), by its folder alone. */
|
|
313
|
+
export function sharedFilesOf(root) {
|
|
314
|
+
const out = new Map();
|
|
315
|
+
const read = (dir, f) => {
|
|
316
|
+
let names = [];
|
|
317
|
+
try {
|
|
318
|
+
names = fs.readdirSync(path.join(root, "data", dir)).filter((n) => n.endsWith(".json"));
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
for (const n of names)
|
|
324
|
+
try {
|
|
325
|
+
f(JSON.parse(fs.readFileSync(path.join(root, "data", dir, n), "utf8")));
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// an unreadable record names nothing
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
read("images", (m) => m.file && out.set(m.file, m.size ?? 0));
|
|
332
|
+
read("inputs", (i) => i.file?.startsWith("media:") && out.set(i.file.slice(6), i.size ?? 0));
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
/** The files of the shared store only this tree names — none of the other trees (their folders) does. */
|
|
336
|
+
export function onlyItsFiles(root, others, shared) {
|
|
337
|
+
const theirs = new Set();
|
|
338
|
+
for (const o of others)
|
|
339
|
+
for (const f of sharedFilesOf(o).keys())
|
|
340
|
+
theirs.add(f);
|
|
341
|
+
return [...sharedFilesOf(root)].filter(([f]) => !theirs.has(f) && fs.existsSync(path.join(shared, f))).map(([file, size]) => ({ file, size }));
|
|
342
|
+
}
|
package/dist/core/phrases.js
CHANGED
|
@@ -78,6 +78,67 @@ export const PHRASES = {
|
|
|
78
78
|
"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 …)",
|
|
79
79
|
"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 …)",
|
|
80
80
|
"task.answer": "The user answered (to: {asked}): {answer}",
|
|
81
|
+
// who is at work, for the Strom app (the bridge's working)
|
|
82
|
+
"who.run": "{agent} on its own",
|
|
83
|
+
"who.chat": "{agent} conversation",
|
|
84
|
+
// what a commit saved, for the Strom app (core/changelog.ts)
|
|
85
|
+
"log.person.add": "New person: {who}",
|
|
86
|
+
"log.person.name": "Name recorded: {who}",
|
|
87
|
+
"log.person.edit": "Changed: {who}",
|
|
88
|
+
"log.person.cite": "A record for {who}",
|
|
89
|
+
"log.person.retract": "Withdrawn: {who}",
|
|
90
|
+
"log.merge": "Two records of one merged: {who}",
|
|
91
|
+
"log.family.add": "New family: {who}",
|
|
92
|
+
"log.family.child": "A child of {who}: {child}",
|
|
93
|
+
"log.family.edit": "Family changed: {who}",
|
|
94
|
+
"log.family.cite": "A record for the family {who}",
|
|
95
|
+
"log.event.add": "{who} – {fact}",
|
|
96
|
+
"log.event.edit": "Refined: {who} – {fact}",
|
|
97
|
+
"log.event.cite": "A record for: {who} – {fact}",
|
|
98
|
+
"log.event.retract": "Withdrawn: {who} – {fact}",
|
|
99
|
+
"log.source.add": "Record from a source: {name}",
|
|
100
|
+
"log.source.edit": "Record changed: {name}",
|
|
101
|
+
"log.search.found": "Searched, found: {name}",
|
|
102
|
+
"log.search.negative": "Searched, nothing there: {name}",
|
|
103
|
+
"log.search.partial": "Searched, found in part: {name}",
|
|
104
|
+
"log.search.inconclusive": "Searched, not clear: {name}",
|
|
105
|
+
"log.recordset.add": "New book: {name}",
|
|
106
|
+
"log.recordset.edit": "Book changed: {name}",
|
|
107
|
+
"log.recordset.calibrate": "Which years are where in the book: {name}",
|
|
108
|
+
"log.media": "New images: {name} (images {images})",
|
|
109
|
+
"log.media.one": "New image: {name} (image {image})",
|
|
110
|
+
"log.media.loose": "New images: {n}",
|
|
111
|
+
"log.reading": "Read through: {name} (images {images})",
|
|
112
|
+
"log.task.add": "New task: {name}",
|
|
113
|
+
"log.task.done": "Done: {name}",
|
|
114
|
+
"log.task.park": "Put aside: {name}",
|
|
115
|
+
"log.task.drop": "Dropped: {name}",
|
|
116
|
+
"log.task.wait": "Waits for you: {name}",
|
|
117
|
+
"log.task.wake": "Back to the agent: {name}",
|
|
118
|
+
"log.task.edit": "Task changed: {name}",
|
|
119
|
+
"log.session.start": "The agent began: {name}",
|
|
120
|
+
"log.session.close": "The agent finished: {name}",
|
|
121
|
+
"log.lesson.add": "Learned: {name}",
|
|
122
|
+
"log.hypothesis.add": "A question weighed: {name}",
|
|
123
|
+
"log.hypothesis.argue": "Weighed further: {name}",
|
|
124
|
+
"log.hypothesis.decide": "Decided: {name}",
|
|
125
|
+
"log.conflict.add": "The records disagree: {name}",
|
|
126
|
+
"log.conflict.resolve": "Disagreement settled: {name}",
|
|
127
|
+
"log.place.add": "New place: {name}",
|
|
128
|
+
"log.place.edit": "Place refined: {name}",
|
|
129
|
+
"log.repository.add": "New archive: {name}",
|
|
130
|
+
"log.repository.edit": "Archive changed: {name}",
|
|
131
|
+
"log.story.set": "Story: {who}",
|
|
132
|
+
"log.story.approve": "Story approved: {who}",
|
|
133
|
+
"log.note.add": "Note: {name}",
|
|
134
|
+
"log.input.add": "Material from the family: {name}",
|
|
135
|
+
"log.input.sync": "Family tree taken in: {name}",
|
|
136
|
+
"log.input.done": "Material gone through: {name}",
|
|
137
|
+
"log.research.add": "New direction of the research: {name}",
|
|
138
|
+
"log.research.edit": "Direction changed: {name}",
|
|
139
|
+
"log.research.pause": "Direction paused: {name}",
|
|
140
|
+
"log.research.resume": "Direction taken up again: {name}",
|
|
141
|
+
"log.research.done": "Direction ended: {name}",
|
|
81
142
|
};
|
|
82
143
|
const catalogs = new Map();
|
|
83
144
|
/**
|
package/dist/core/queue.js
CHANGED
|
@@ -6,12 +6,16 @@
|
|
|
6
6
|
// after work that waits for a download: they are never what a session is
|
|
7
7
|
// spent on instead of the research. Working alone (strom run) they still get
|
|
8
8
|
// their turn: after STORY_EVERY sessions without one, a story first. In a
|
|
9
|
-
// conversation the user leads, and a story is written when they want it.
|
|
9
|
+
// conversation the user leads, and a story is written when they want it. Work
|
|
10
|
+
// about people nothing links to the tree waits until the user asks for it or a
|
|
11
|
+
// hypothesis joins them (core/kin.ts offTree). Strategies (setting queue.strategy): balanced (the default), depth
|
|
10
12
|
// (stay on the line of the last sessions), priority (strict priority).
|
|
11
13
|
import { parentsOf } from "./people.js";
|
|
12
14
|
import { taskRecordsets } from "./frontier.js";
|
|
13
15
|
import { now } from "./tree.js";
|
|
14
16
|
import { subjectPeople } from "./records.js";
|
|
17
|
+
import { directionOf, scopes } from "./directions.js";
|
|
18
|
+
import { offTree } from "./kin.js";
|
|
15
19
|
export const LEVEL_ORDER = { intake: 0, locate: 1, link: 2, verify: 3, enrich: 4, request: 5, narrate: 6 };
|
|
16
20
|
/** Levels that read records: without images of their record sets they can only ask the user for them. */
|
|
17
21
|
const NEEDS_IMAGES = new Set(["link", "verify"]);
|
|
@@ -53,9 +57,15 @@ export function ancestorLines(tree, focus, max = 50) {
|
|
|
53
57
|
/** The open tasks in the order to work on them, each with the reason. */
|
|
54
58
|
export function rankTasks(tree, tasks, strategy = "balanced", opts = {}) {
|
|
55
59
|
const today = now().slice(0, 10);
|
|
56
|
-
const open = tasks.filter((t) => ["open", "doing"].includes(effectiveState(t, today)));
|
|
57
|
-
const withImages = new Set(tree.list("media").map((m) => m.recordset));
|
|
58
60
|
const researches = tree.list("research");
|
|
61
|
+
// a direction the user paused or ended: its tasks wait (one at work goes on)
|
|
62
|
+
const stopped = new Set(researches.filter((r) => r.state !== "active").map((r) => r.id));
|
|
63
|
+
const all = scopes(tree);
|
|
64
|
+
// work about people nothing links to the tree waits for the user or for the hypothesis that joins them (core/kin.ts)
|
|
65
|
+
const off = offTree(tree);
|
|
66
|
+
const waits = (t) => t.state !== "doing" && ((stopped.size > 0 && stopped.has(directionOf(tree, t, all) ?? "")) || off(t));
|
|
67
|
+
const open = tasks.filter((t) => ["open", "doing"].includes(effectiveState(t, today)) && !waits(t));
|
|
68
|
+
const withImages = new Set(tree.list("media").map((m) => m.recordset));
|
|
59
69
|
const lines = new Map();
|
|
60
70
|
const linesOf = (researchId) => {
|
|
61
71
|
const r = researches.find((x) => x.id === researchId) ?? (researches.length === 1 ? researches[0] : undefined);
|
|
@@ -67,7 +77,8 @@ export function rankTasks(tree, tasks, strategy = "balanced", opts = {}) {
|
|
|
67
77
|
};
|
|
68
78
|
/** The nearest ancestor a task is about: its generation and line. */
|
|
69
79
|
const place = (t) => {
|
|
70
|
-
|
|
80
|
+
// a task of no research of its own: the direction its people are in (directions.ts), as the menu and the app say
|
|
81
|
+
const map = linesOf(t.research ?? directionOf(tree, t, all));
|
|
71
82
|
const found = [...t.subject, ...subjectPeople(tree, t.subject)].map((s) => map?.get(s)).filter((x) => !!x);
|
|
72
83
|
return found.sort((a, b) => a.gen - b.gen)[0];
|
|
73
84
|
};
|
package/dist/core/relocate.js
CHANGED
|
@@ -33,15 +33,15 @@ export function within(inner, outer, platform = process.platform) {
|
|
|
33
33
|
export function sameFolder(a, b, platform = process.platform) {
|
|
34
34
|
return within(path.resolve(a), path.resolve(b), platform) && within(path.resolve(b), path.resolve(a), platform);
|
|
35
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));
|
|
36
|
+
/** Somebody at work in a tree: an agent present, a run, a command holding its lock, the live bridge (unless `bridge: false`: it only reads). */
|
|
37
|
+
export function atWork(root, opts = {}) {
|
|
38
|
+
return liveWorkers(root).length > 0 || runsAtWork(root).length > 0 || Boolean(liveHolder(path.join(root, ".strom", "tree.lock"))) || (opts.bridge !== false && Boolean(liveRunning(root)));
|
|
39
39
|
}
|
|
40
40
|
/** `trees`: the folders of the trees strom knows (those inside `from` move along). */
|
|
41
41
|
export function planMove(from, to, trees) {
|
|
42
42
|
const names = entries(from);
|
|
43
43
|
const inside = trees.filter((t) => within(t, from));
|
|
44
|
-
const busy = inside.filter(atWork).map((t) => path.basename(t));
|
|
44
|
+
const busy = inside.filter((t) => atWork(t)).map((t) => path.basename(t));
|
|
45
45
|
// The top of a disk (its own hidden folders only): into a folder named like the old one there.
|
|
46
46
|
const there = entries(to);
|
|
47
47
|
const target = there.length && there.every((n) => DISK.has(n)) ? path.join(to, path.basename(from) || "Strom") : to;
|
package/dist/core/session.js
CHANGED
|
@@ -11,6 +11,7 @@ import { UsageError } from "./errors.js";
|
|
|
11
11
|
import { now, VERSION } from "./tree.js";
|
|
12
12
|
import { makeNote } from "./actions.js";
|
|
13
13
|
import { detectAgent } from "./which.js";
|
|
14
|
+
import { finishAsked, finishFile } from "./clock.js";
|
|
14
15
|
import { isLiveWorker, isWorkerId } from "./workers.js";
|
|
15
16
|
function currentFile(tree, env = tree.env) {
|
|
16
17
|
const w = env.STROM_WORKER;
|
|
@@ -121,7 +122,7 @@ export function sessionNote(tree, s, text) {
|
|
|
121
122
|
export function closeSession(tree, s, input) {
|
|
122
123
|
if (!input.interrupted && (!input.summary.trim() || !input.next.trim()))
|
|
123
124
|
throw new UsageError("closing needs --summary and --next", { hint: 'e.g. --summary "found the baptism, parents Josef and Marie" --next "marriage of Josef ~1898 in B0002"' });
|
|
124
|
-
|
|
125
|
+
const closed = tree.withTreeLock(() => {
|
|
125
126
|
s = tree.get(s.id) ?? s;
|
|
126
127
|
const task = s.task ? tree.get(s.task) : undefined;
|
|
127
128
|
if (task && task.state === "doing") {
|
|
@@ -140,7 +141,8 @@ export function closeSession(tree, s, input) {
|
|
|
140
141
|
...(input.summary.trim() ? { summary: input.summary.trim() } : {}),
|
|
141
142
|
...(input.next.trim() ? { next: input.next.trim() } : {}),
|
|
142
143
|
...(input.metrics ? { metrics: input.metrics } : {}),
|
|
143
|
-
|
|
144
|
+
// asked to finish by the user (strom session finish): theirs, and a run stops after it
|
|
145
|
+
...(input.endedBy ? { endedBy: input.endedBy } : finishAsked(tree.root, s.id) ? { endedBy: "user" } : {}),
|
|
144
146
|
};
|
|
145
147
|
tree.put(closed, { op: "session.close", targets: [s.id], summary: `${s.id} ${closed.state}: ${(input.summary || "no summary").slice(0, 80)}` });
|
|
146
148
|
// Only the session this agent calls its current one is forgotten — not another agent's.
|
|
@@ -148,6 +150,10 @@ export function closeSession(tree, s, input) {
|
|
|
148
150
|
setCurrent(tree, undefined);
|
|
149
151
|
return closed;
|
|
150
152
|
});
|
|
153
|
+
// asked to finish (strom session finish): done
|
|
154
|
+
if (!tree.dryRun)
|
|
155
|
+
fs.rmSync(finishFile(tree.root, closed.id), { force: true });
|
|
156
|
+
return closed;
|
|
151
157
|
}
|
|
152
158
|
/** The last closed sessions (newest first), optionally of one research. */
|
|
153
159
|
export function recentSessions(tree, research, n = 2) {
|
package/dist/core/stromapp.js
CHANGED
|
@@ -60,6 +60,28 @@ export function appOpensLinks(settings, version) {
|
|
|
60
60
|
return (a[i] ?? 0) > (b[i] ?? 0);
|
|
61
61
|
return true;
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* The Strom app shows where the tree ends (the app's spec docs/ZADANI_VYZKUM_kraj-stromu.md): per person above whom
|
|
65
|
+
* the tree does not go on, what the research knows there (_STROM_EDGE), and the families nothing links to the tree
|
|
66
|
+
* (_STROM_ISLAND). The first version of stromapp.info that does (3.6.0, 2026-09-30); another copy of the app (its
|
|
67
|
+
* beta, its development: strom.app.url) is taken as current. An app of a known version (strom.version) goes by it;
|
|
68
|
+
* an unknown one is taken as today's (an older app only skips the tags).
|
|
69
|
+
*/
|
|
70
|
+
export const APP_SHOWS_EDGES = "3.6.0";
|
|
71
|
+
export function appShowsEdges(settings, version) {
|
|
72
|
+
if (stromAppUrl(settings) !== STROM_APP_URL)
|
|
73
|
+
return true;
|
|
74
|
+
if (!APP_SHOWS_EDGES)
|
|
75
|
+
return false;
|
|
76
|
+
if (!version)
|
|
77
|
+
return true;
|
|
78
|
+
const n = (v) => v.split(".").map((x) => Number.parseInt(x, 10) || 0);
|
|
79
|
+
const [a, b] = [n(version), n(APP_SHOWS_EDGES)];
|
|
80
|
+
for (let i = 0; i < 3; i++)
|
|
81
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0))
|
|
82
|
+
return (a[i] ?? 0) > (b[i] ?? 0);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
63
85
|
/** The address that opens the Strom app to send the user's edited tree to the bridge. */
|
|
64
86
|
export function sendAppUrl(bridge, settings) {
|
|
65
87
|
return `${stromAppUrl(settings)}?send=${encodeURIComponent(bridge)}`;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Into the system's trash, never deleted for good: a person can take it back.
|
|
2
|
+
// macOS: ~/.Trash (Finder's own delete when it lies on another disk); Windows:
|
|
3
|
+
// the Recycle Bin (PowerShell's VisualBasic FileSystem); Linux: gio trash, else
|
|
4
|
+
// the XDG trash folder of the user's home.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { StromError } from "./errors.js";
|
|
9
|
+
import { userHome } from "./paths.js";
|
|
10
|
+
function free(dir, name) {
|
|
11
|
+
let to = path.join(dir, name);
|
|
12
|
+
for (let n = 2; fs.existsSync(to); n++)
|
|
13
|
+
to = path.join(dir, `${name} (${n})`);
|
|
14
|
+
return to;
|
|
15
|
+
}
|
|
16
|
+
function run(command, args, env) {
|
|
17
|
+
const r = spawnSync(command, args, { stdio: "ignore", windowsHide: true, timeout: 120_000, env: { ...process.env, ...env } });
|
|
18
|
+
return !r.error && r.status === 0;
|
|
19
|
+
}
|
|
20
|
+
/** Move a file or folder into the trash; `name` is what it is called there. Throws when no trash takes it. */
|
|
21
|
+
export function moveToTrash(target, env, name = path.basename(target), platform = process.platform) {
|
|
22
|
+
if (!fs.existsSync(target))
|
|
23
|
+
return;
|
|
24
|
+
const fail = () => new StromError(`could not put ${target} into the trash`, { hint: "move the folder into the trash yourself" });
|
|
25
|
+
if (env.STROM_TRASH) {
|
|
26
|
+
// a test's own trash: nothing of the computer's
|
|
27
|
+
fs.mkdirSync(env.STROM_TRASH, { recursive: true });
|
|
28
|
+
fs.renameSync(target, free(env.STROM_TRASH, name));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (platform === "darwin") {
|
|
32
|
+
const trash = path.join(userHome(env), ".Trash");
|
|
33
|
+
try {
|
|
34
|
+
fs.mkdirSync(trash, { recursive: true });
|
|
35
|
+
fs.renameSync(target, free(trash, name));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
if (e.code !== "EXDEV")
|
|
40
|
+
throw fail();
|
|
41
|
+
}
|
|
42
|
+
// on another disk: Finder puts it into that disk's own trash
|
|
43
|
+
const script = `tell application "Finder" to delete (POSIX file ${JSON.stringify(target)} as alias)`;
|
|
44
|
+
if (!run("osascript", ["-e", script], env))
|
|
45
|
+
throw fail();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (platform === "win32") {
|
|
49
|
+
const kind = fs.statSync(target).isDirectory() ? "DeleteDirectory" : "DeleteFile";
|
|
50
|
+
const lit = `'${target.replace(/'/g, "''")}'`;
|
|
51
|
+
const ps = `Add-Type -AssemblyName Microsoft.VisualBasic; [Microsoft.VisualBasic.FileIO.FileSystem]::${kind}(${lit}, 'OnlyErrorDialogs', 'SendToRecycleBin')`;
|
|
52
|
+
if (!run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps], env) || fs.existsSync(target))
|
|
53
|
+
throw fail();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (run("gio", ["trash", target], env) && !fs.existsSync(target))
|
|
57
|
+
return;
|
|
58
|
+
// the XDG trash of the home: the file, and a note of where it came from
|
|
59
|
+
const base = env.XDG_DATA_HOME || path.join(userHome(env), ".local", "share");
|
|
60
|
+
const files = path.join(base, "Trash", "files");
|
|
61
|
+
const info = path.join(base, "Trash", "info");
|
|
62
|
+
try {
|
|
63
|
+
fs.mkdirSync(files, { recursive: true });
|
|
64
|
+
fs.mkdirSync(info, { recursive: true });
|
|
65
|
+
const to = free(files, name);
|
|
66
|
+
fs.renameSync(target, to);
|
|
67
|
+
fs.writeFileSync(path.join(info, `${path.basename(to)}.trashinfo`), `[Trash Info]\nPath=${encodeURI(target)}\nDeletionDate=${new Date().toISOString().slice(0, 19)}\n`);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw fail();
|
|
71
|
+
}
|
|
72
|
+
}
|
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.9.0";
|
|
27
27
|
const GITIGNORE = `# Strom Research — local state and large files are not versioned
|
|
28
28
|
.strom/
|
|
29
29
|
/media/
|
package/dist/core/workers.js
CHANGED
|
@@ -9,6 +9,26 @@ import { acquireLock, liveHolder } from "./lock.js";
|
|
|
9
9
|
function dir(root) {
|
|
10
10
|
return path.join(root, ".strom", "workers");
|
|
11
11
|
}
|
|
12
|
+
function pauseFile(root, id) {
|
|
13
|
+
return path.join(dir(root), `${id}.paused`);
|
|
14
|
+
}
|
|
15
|
+
/** A run waits for its gate (the app shows it paused, not at work) — or goes on (undefined). */
|
|
16
|
+
export function markPaused(root, id, paused) {
|
|
17
|
+
const file = pauseFile(root, id);
|
|
18
|
+
if (!paused)
|
|
19
|
+
return fs.rmSync(file, { force: true });
|
|
20
|
+
fs.mkdirSync(dir(root), { recursive: true });
|
|
21
|
+
fs.writeFileSync(file, JSON.stringify(paused));
|
|
22
|
+
}
|
|
23
|
+
function pausedOf(root, id) {
|
|
24
|
+
try {
|
|
25
|
+
const p = JSON.parse(fs.readFileSync(pauseFile(root, id), "utf8"));
|
|
26
|
+
return typeof p.until === "string" ? { until: p.until, ...(typeof p.reason === "string" ? { reason: p.reason } : {}) } : undefined;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
12
32
|
/** A worker's name, safe as a file name. */
|
|
13
33
|
export function isWorkerId(id) {
|
|
14
34
|
return !!id && /^[\w.-]{1,80}$/.test(id);
|
|
@@ -29,11 +49,16 @@ export function liveWorkers(root) {
|
|
|
29
49
|
}
|
|
30
50
|
for (const f of files) {
|
|
31
51
|
const file = path.join(dir(root), f);
|
|
52
|
+
const id = f.slice(0, -5);
|
|
32
53
|
const info = liveHolder(file, 7 * 24 * 3600_000);
|
|
33
|
-
if (info)
|
|
34
|
-
|
|
35
|
-
|
|
54
|
+
if (info) {
|
|
55
|
+
const paused = pausedOf(root, id);
|
|
56
|
+
out.push({ id, label: info.owner, since: info.at, ...(paused ? { paused } : {}) });
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
36
59
|
fs.rmSync(file, { force: true });
|
|
60
|
+
fs.rmSync(pauseFile(root, id), { force: true });
|
|
61
|
+
}
|
|
37
62
|
}
|
|
38
63
|
return out;
|
|
39
64
|
}
|
package/dist/gedcom/export.js
CHANGED
|
@@ -35,6 +35,8 @@ import { VERSION } from "../core/tree.js";
|
|
|
35
35
|
import { dataUrl } from "../core/excerpt.js";
|
|
36
36
|
import { mainPerson } from "../core/kin.js";
|
|
37
37
|
import * as git from "../core/git.js";
|
|
38
|
+
import { treeEdges } from "../core/edge.js";
|
|
39
|
+
import { humanTask } from "../cli/human.js";
|
|
38
40
|
export const GED_PROFILES = ["standard", "strom"];
|
|
39
41
|
/** The first Strom version that reads AGE, CAUS, ADDR, _FREL/_MREL and more source notes itself (not released yet). */
|
|
40
42
|
export const STROM_READS_TAGS = undefined;
|
|
@@ -135,6 +137,8 @@ export function exportGedcom(tree, opts = {}) {
|
|
|
135
137
|
.filter((x) => x.people.length)
|
|
136
138
|
: [],
|
|
137
139
|
};
|
|
140
|
+
// Where the tree ends, and the families nothing links to it (the Strom profile, opts.edges).
|
|
141
|
+
const ends = opts.edges && opts.for === "strom" ? treeEdges(tree) : undefined;
|
|
138
142
|
// ── header ──
|
|
139
143
|
w.line(0, "HEAD");
|
|
140
144
|
w.line(1, "SOUR", "STROM_RESEARCH");
|
|
@@ -203,6 +207,14 @@ export function exportGedcom(tree, opts = {}) {
|
|
|
203
207
|
story(p.story);
|
|
204
208
|
if (opts.research && opts.for === "strom")
|
|
205
209
|
known(p);
|
|
210
|
+
if (ends) {
|
|
211
|
+
const e = ends.edges.get(p.id);
|
|
212
|
+
if (e)
|
|
213
|
+
edge(e);
|
|
214
|
+
const isle = ends.islands.get(p.id);
|
|
215
|
+
if (isle)
|
|
216
|
+
island(isle);
|
|
217
|
+
}
|
|
206
218
|
for (const link of famc.get(p.id) ?? []) {
|
|
207
219
|
w.line(1, "FAMC", x(link.fam));
|
|
208
220
|
if (link.relation === "adopted" || link.relation === "foster")
|
|
@@ -457,7 +469,7 @@ export function exportGedcom(tree, opts = {}) {
|
|
|
457
469
|
w.text(2, "DECI", c.resolution);
|
|
458
470
|
}
|
|
459
471
|
for (const h of research.hypotheses.filter((x) => about(x.subject))) {
|
|
460
|
-
w.line(1, "_STROM_HYPO");
|
|
472
|
+
w.line(1, "_STROM_HYPO", h.id);
|
|
461
473
|
w.text(2, "TITL", h.question);
|
|
462
474
|
w.text(2, "NOTE", h.variants.map((v) => `${v.label}: ${v.claim}`).join("\n"));
|
|
463
475
|
}
|
|
@@ -471,6 +483,98 @@ export function exportGedcom(tree, opts = {}) {
|
|
|
471
483
|
w.line(2, "_AT", q.created.slice(0, 10));
|
|
472
484
|
}
|
|
473
485
|
}
|
|
486
|
+
/** Years as a GEDCOM date: one year, or FROM … TO …. */
|
|
487
|
+
function years(r) {
|
|
488
|
+
return r.from === r.to ? String(r.from) : `FROM ${r.from} TO ${r.to}`;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Where the tree ends above a person: what is missing, whether the research reaches them, what the records say
|
|
492
|
+
* so far, what comes next — and what that rests on (core/edge.ts; the app's ZADANI_VYZKUM_kraj-stromu.md).
|
|
493
|
+
*/
|
|
494
|
+
function edge(e) {
|
|
495
|
+
w.line(1, "_STROM_EDGE", e.missing);
|
|
496
|
+
w.line(2, "_SCOPE", e.scope);
|
|
497
|
+
if (e.research)
|
|
498
|
+
w.line(2, "_RESEARCH", e.research);
|
|
499
|
+
if (e.generation !== undefined)
|
|
500
|
+
w.line(2, "_GEN", String(e.generation));
|
|
501
|
+
w.line(2, "_END", e.end);
|
|
502
|
+
w.line(2, "_NEXT", e.next);
|
|
503
|
+
if (e.estimate) {
|
|
504
|
+
w.line(2, "_EST", e.estimate.year ? String(e.estimate.year) : "");
|
|
505
|
+
if (e.estimate.place)
|
|
506
|
+
w.text(3, "PLAC", e.estimate.place);
|
|
507
|
+
if (e.estimate.basis)
|
|
508
|
+
w.line(3, "_BASIS", `${e.estimate.basis.kind} ${e.estimate.basis.year}`);
|
|
509
|
+
}
|
|
510
|
+
if (e.window)
|
|
511
|
+
w.line(2, "DATE", years(e.window));
|
|
512
|
+
if (e.recordsFrom)
|
|
513
|
+
w.line(2, "_RECORDS", String(e.recordsFrom));
|
|
514
|
+
for (const b of e.books) {
|
|
515
|
+
w.line(2, "_BOOK", b.id);
|
|
516
|
+
w.text(3, "TITL", b.title);
|
|
517
|
+
const y = /^(\d{3,4})(?:\s*[-–]\s*(\d{3,4}))?$/.exec(b.years ?? "");
|
|
518
|
+
if (y)
|
|
519
|
+
w.line(3, "DATE", years({ from: Number(y[1]), to: Number(y[2] ?? y[1]) }));
|
|
520
|
+
w.line(3, "_ACCESS", b.access);
|
|
521
|
+
}
|
|
522
|
+
for (const r of e.covered)
|
|
523
|
+
w.line(2, "_COVERED", years(r));
|
|
524
|
+
for (const r of e.noRecords)
|
|
525
|
+
w.line(2, "_NORECORDS", years(r));
|
|
526
|
+
for (const t of e.tasks) {
|
|
527
|
+
w.line(2, "_TASK", t.id);
|
|
528
|
+
w.line(3, "_LEVEL", t.level);
|
|
529
|
+
w.line(3, "STAT", t.state);
|
|
530
|
+
w.text(3, "TITL", humanTask(tree, t.what, lang));
|
|
531
|
+
if (t.position)
|
|
532
|
+
w.line(3, "_POS", String(t.position));
|
|
533
|
+
if (t.held)
|
|
534
|
+
w.line(3, "_HELD", t.held);
|
|
535
|
+
if (t.until)
|
|
536
|
+
w.line(3, "_UNTIL", t.until);
|
|
537
|
+
if (t.on)
|
|
538
|
+
w.text(3, "NOTE", t.on);
|
|
539
|
+
}
|
|
540
|
+
for (const id of e.tried)
|
|
541
|
+
w.line(2, "_TRIED", id);
|
|
542
|
+
for (const h of e.hypotheses) {
|
|
543
|
+
w.line(2, "_HYPO", h.id);
|
|
544
|
+
for (const j of h.joins)
|
|
545
|
+
w.line(3, "_JOIN", j);
|
|
546
|
+
if (h.island) {
|
|
547
|
+
w.line(3, "_ISLAND", String(h.island.people));
|
|
548
|
+
w.line(3, "_HELD", String(h.island.held));
|
|
549
|
+
}
|
|
550
|
+
for (const t of h.tests)
|
|
551
|
+
w.line(3, "_TEST", t);
|
|
552
|
+
}
|
|
553
|
+
for (const c of e.conflicts)
|
|
554
|
+
w.line(2, "_CONFLICT", c);
|
|
555
|
+
if (e.searches)
|
|
556
|
+
w.line(2, "_SEARCHES", String(e.searches));
|
|
557
|
+
if (e.sessions) {
|
|
558
|
+
w.line(2, "_SESSIONS", String(e.sessions));
|
|
559
|
+
if (e.cost !== undefined)
|
|
560
|
+
w.line(3, "_COST", e.cost.toFixed(2));
|
|
561
|
+
if (e.costPartial)
|
|
562
|
+
w.line(3, "_PARTIAL", "Y");
|
|
563
|
+
}
|
|
564
|
+
if (e.last)
|
|
565
|
+
w.line(2, "_LAST", e.last);
|
|
566
|
+
}
|
|
567
|
+
/** A person of a family nothing links to the tree: how many people it has, the hypotheses that would join it, the tasks waiting for it. */
|
|
568
|
+
function island(i) {
|
|
569
|
+
w.line(1, "_STROM_ISLAND", String(i.people.length));
|
|
570
|
+
for (const h of i.hypotheses) {
|
|
571
|
+
w.line(2, "_HYPO", h.id);
|
|
572
|
+
for (const j of h.joins)
|
|
573
|
+
w.line(3, "_JOIN", j);
|
|
574
|
+
}
|
|
575
|
+
if (i.held)
|
|
576
|
+
w.line(2, "_HELD", String(i.held));
|
|
577
|
+
}
|
|
474
578
|
function story(st) {
|
|
475
579
|
if (!st)
|
|
476
580
|
return;
|
package/dist/gedcom/validate.js
CHANGED
|
@@ -18,10 +18,12 @@ const CHILDREN = {
|
|
|
18
18
|
"HEAD.SOUR": ["VERS", "NAME", "CORP", "DATA"],
|
|
19
19
|
"HEAD.GEDC": ["VERS", "FORM"],
|
|
20
20
|
"HEAD.DATE": ["TIME"],
|
|
21
|
-
INDI: ["NAME", "SEX", "FAMC", "FAMS", "NOTE", "SOUR", "REFN", "ASSO", "ALIA", "OBJE", "RESN", "RIN", "CHAN", "_STORY", "_STROM_CONFLICT", "_STROM_HYPO", "_STROM_SEARCHED", ...INDI_EVENTS],
|
|
21
|
+
INDI: ["NAME", "SEX", "FAMC", "FAMS", "NOTE", "SOUR", "REFN", "ASSO", "ALIA", "OBJE", "RESN", "RIN", "CHAN", "_STORY", "_STROM_CONFLICT", "_STROM_HYPO", "_STROM_SEARCHED", "_STROM_EDGE", "_STROM_ISLAND", ...INDI_EVENTS],
|
|
22
22
|
"INDI._STROM_CONFLICT": ["TYPE", "TITL", "STAT", "VAL", "DECI"],
|
|
23
23
|
"INDI._STROM_HYPO": ["TITL", "NOTE"],
|
|
24
24
|
"INDI._STROM_SEARCHED": ["TITL", "DATE", "RESN", "_AT"],
|
|
25
|
+
"INDI._STROM_EDGE": ["_SCOPE", "_RESEARCH", "_GEN", "_END", "_NEXT", "_EST", "DATE", "_RECORDS", "_BOOK", "_COVERED", "_NORECORDS", "_TASK", "_TRIED", "_HYPO", "_CONFLICT", "_SEARCHES", "_SESSIONS", "_LAST"],
|
|
26
|
+
"INDI._STROM_ISLAND": ["_HYPO", "_HELD"],
|
|
25
27
|
_STROM_VALUE: ["SOUR"],
|
|
26
28
|
"INDI.NAME": ["TYPE", "GIVN", "SURN", "NPFX", "NSFX", "NICK", "SPFX", "SOUR", "NOTE"],
|
|
27
29
|
"INDI.FAMC": ["PEDI", "NOTE"],
|
|
@@ -50,6 +52,10 @@ const TEXT_TAGS = new Set(["NOTE", "TEXT", "TITL", "PAGE", "AUTH", "PUBL", "_STO
|
|
|
50
52
|
const EXTENSIONS = new Set([
|
|
51
53
|
"_STORY", "_WITN", "_FREL", "_MREL", "_STROM_KIND", "_URL", "_STROM_TREE", "_STROM_HEAD", "_STROM_LINKS", "_STROM_CLIP",
|
|
52
54
|
"_STROM_ASOF", "_STROM_CONFLICT", "_STROM_HYPO", "_STROM_SEARCHED", "_AT",
|
|
55
|
+
// where the tree ends (_STROM_EDGE) and the families nothing links to it (_STROM_ISLAND), with their parts
|
|
56
|
+
"_STROM_EDGE", "_STROM_ISLAND", "_SCOPE", "_RESEARCH", "_GEN", "_END", "_NEXT", "_EST", "_BASIS", "_RECORDS", "_BOOK", "_ACCESS",
|
|
57
|
+
"_COVERED", "_NORECORDS", "_TASK", "_LEVEL", "_POS", "_HELD", "_UNTIL", "_TRIED", "_HYPO", "_JOIN", "_ISLAND", "_TEST", "_CONFLICT",
|
|
58
|
+
"_SEARCHES", "_SESSIONS", "_COST", "_PARTIAL", "_LAST",
|
|
53
59
|
]);
|
|
54
60
|
/** Context of the children of the last tag in `anc` (the ancestors of a line). */
|
|
55
61
|
function contextOf(anc) {
|
package/dist/runners/claude.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// are on only when a connector fetches through the browser (--chrome), and off
|
|
14
14
|
// otherwise (--no-chrome), whatever Claude Code's own default is.
|
|
15
15
|
import { randomUUID } from "node:crypto";
|
|
16
|
-
import { appendLog, looksLikeLimit, spawnAgent, stopTree } from "./runner.js";
|
|
16
|
+
import { appendLog, looksLikeLimit, OWN_GROUP, spawnAgent, stopTree } from "./runner.js";
|
|
17
17
|
function describeTool(block) {
|
|
18
18
|
const input = block.input ?? {};
|
|
19
19
|
if (block.name === "Bash" && typeof input.command === "string")
|
|
@@ -71,6 +71,7 @@ function attempt(opts, args, input, timeoutMs) {
|
|
|
71
71
|
env: opts.interactive ? opts.env : headlessEnv(opts.env, opts.timeoutMs),
|
|
72
72
|
stdio: opts.interactive ? "inherit" : ["pipe", "pipe", "pipe"],
|
|
73
73
|
windowsHide: !opts.interactive,
|
|
74
|
+
detached: !opts.interactive && OWN_GROUP,
|
|
74
75
|
});
|
|
75
76
|
// The brief goes in on stdin: no limit on its length on any platform.
|
|
76
77
|
if (!opts.interactive)
|
package/dist/runners/jsonl.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// recognised — the same for every such agent; each runner only reads its own
|
|
5
5
|
// events. Stopped at its time limit, an agent that can resume its session gets a
|
|
6
6
|
// few minutes to write down what it found (RunOptions.wrapUp).
|
|
7
|
-
import { appendLog, looksLikeLimit, spawnAgent, stopTree } from "./runner.js";
|
|
7
|
+
import { appendLog, looksLikeLimit, OWN_GROUP, spawnAgent, stopTree } from "./runner.js";
|
|
8
8
|
export async function runJsonLines(command, args, env, opts, resume, onEvent) {
|
|
9
9
|
const started = Date.now();
|
|
10
10
|
const heard = { metrics: {}, text: "", isError: false, denied: [] };
|
|
@@ -54,7 +54,7 @@ export async function runJsonLines(command, args, env, opts, resume, onEvent) {
|
|
|
54
54
|
/** One start of the agent; its events go to `heard`. */
|
|
55
55
|
function attempt(command, args, input, env, opts, timeoutMs, heard, onEvent) {
|
|
56
56
|
return new Promise((resolve) => {
|
|
57
|
-
const child = spawnAgent(command, args, { cwd: opts.cwd, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
57
|
+
const child = spawnAgent(command, args, { cwd: opts.cwd, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, detached: OWN_GROUP });
|
|
58
58
|
child.stdin?.end(input);
|
|
59
59
|
let timedOut = false;
|
|
60
60
|
const timer = timeoutMs
|
package/dist/runners/runner.js
CHANGED
|
@@ -47,17 +47,30 @@ export function spawnAgent(command, args, opts) {
|
|
|
47
47
|
}
|
|
48
48
|
return spawn(found, args, { ...opts, env: opts.env });
|
|
49
49
|
}
|
|
50
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* A headless agent in a process group of its own (not on Windows, whose console sends Ctrl-C to every process of
|
|
52
|
+
* it): Ctrl-C in the terminal reaches strom alone, which asks the session to finish first (strom run).
|
|
53
|
+
*/
|
|
54
|
+
export const OWN_GROUP = process.platform !== "win32";
|
|
55
|
+
/** Stop a child and everything it started (Windows: the whole process tree; elsewhere its own group, when it has one). */
|
|
51
56
|
export function stopTree(child) {
|
|
52
57
|
if (child.exitCode !== null || child.pid === undefined)
|
|
53
58
|
return;
|
|
54
59
|
if (process.platform === "win32")
|
|
55
60
|
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
56
61
|
else {
|
|
57
|
-
|
|
62
|
+
const kill = (sig) => {
|
|
63
|
+
try {
|
|
64
|
+
process.kill(-child.pid, sig); // its group: what it started too
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
child.kill(sig); // no group of its own
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
kill("SIGTERM");
|
|
58
71
|
setTimeout(() => {
|
|
59
72
|
if (child.exitCode === null)
|
|
60
|
-
|
|
73
|
+
kill("SIGKILL");
|
|
61
74
|
}, 5000).unref();
|
|
62
75
|
}
|
|
63
76
|
}
|
package/dist/runners/script.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// (run with node); it gets the same environment an agent would, and the
|
|
4
4
|
// prompt both on stdin (like a headless agent) and in STROM_PROMPT.
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
-
import { appendLog, looksLikeLimit, stopTree } from "./runner.js";
|
|
6
|
+
import { appendLog, looksLikeLimit, OWN_GROUP, stopTree } from "./runner.js";
|
|
7
7
|
export const scriptRunner = {
|
|
8
8
|
id: "script",
|
|
9
9
|
command: process.execPath,
|
|
@@ -26,6 +26,7 @@ function once(opts, prompt, timeoutMs, wrapUp) {
|
|
|
26
26
|
cwd: opts.cwd,
|
|
27
27
|
env: { ...opts.env, STROM_PROMPT: prompt, ...(wrapUp ? { STROM_WRAP_UP: "1" } : {}) },
|
|
28
28
|
stdio: ["pipe", "pipe", "pipe"],
|
|
29
|
+
detached: OWN_GROUP,
|
|
29
30
|
});
|
|
30
31
|
child.stdin.end(prompt);
|
|
31
32
|
let timedOut = false;
|