strom-research 1.0.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.
Files changed (130) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +142 -0
  3. package/assets/lang/cs.json +302 -0
  4. package/assets/lang/de.json +302 -0
  5. package/assets/method/core.md +43 -0
  6. package/assets/method/enrich.md +11 -0
  7. package/assets/method/intake.md +30 -0
  8. package/assets/method/link.md +28 -0
  9. package/assets/method/locate.md +28 -0
  10. package/assets/method/narrate.md +13 -0
  11. package/assets/method/reading.md +62 -0
  12. package/assets/method/recording.md +59 -0
  13. package/assets/method/request.md +10 -0
  14. package/assets/method/verify.md +17 -0
  15. package/assets/plugins/README.md +23 -0
  16. package/assets/plugins/connectors/DISCOVERY.md +159 -0
  17. package/assets/plugins/connectors/README.md +376 -0
  18. package/assets/plugins/connectors/sdk.ts +168 -0
  19. package/assets/plugins/connectors/template.ts +38 -0
  20. package/assets/plugins/gitignore +4 -0
  21. package/dist/agents/files.js +313 -0
  22. package/dist/agents/global.js +257 -0
  23. package/dist/agents/launch.js +36 -0
  24. package/dist/agents/profiles.js +95 -0
  25. package/dist/brief/brief.js +345 -0
  26. package/dist/cli/commit.js +44 -0
  27. package/dist/cli/context.js +311 -0
  28. package/dist/cli/execute.js +154 -0
  29. package/dist/cli/fixes.js +78 -0
  30. package/dist/cli/format.js +53 -0
  31. package/dist/cli/help.js +59 -0
  32. package/dist/cli/main.js +152 -0
  33. package/dist/cli/menu.js +212 -0
  34. package/dist/cli/registry.js +96 -0
  35. package/dist/cli/ui.js +266 -0
  36. package/dist/cli/wizard.js +142 -0
  37. package/dist/cli.js +14 -0
  38. package/dist/commands/analysis.js +622 -0
  39. package/dist/commands/batch.js +181 -0
  40. package/dist/commands/checks.js +153 -0
  41. package/dist/commands/connectors.js +1377 -0
  42. package/dist/commands/guide.js +160 -0
  43. package/dist/commands/index.js +19 -0
  44. package/dist/commands/intake.js +234 -0
  45. package/dist/commands/media.js +406 -0
  46. package/dist/commands/meta.js +195 -0
  47. package/dist/commands/output.js +117 -0
  48. package/dist/commands/people.js +664 -0
  49. package/dist/commands/read.js +199 -0
  50. package/dist/commands/research.js +139 -0
  51. package/dist/commands/session.js +605 -0
  52. package/dist/commands/setup.js +465 -0
  53. package/dist/commands/sources.js +634 -0
  54. package/dist/commands/start.js +383 -0
  55. package/dist/commands/story.js +75 -0
  56. package/dist/commands/tasks.js +436 -0
  57. package/dist/commands/trees.js +128 -0
  58. package/dist/core/actions.js +852 -0
  59. package/dist/core/age.js +95 -0
  60. package/dist/core/apps.js +74 -0
  61. package/dist/core/assets.js +34 -0
  62. package/dist/core/awake.js +33 -0
  63. package/dist/core/browser.js +281 -0
  64. package/dist/core/calibration.js +48 -0
  65. package/dist/core/check.js +112 -0
  66. package/dist/core/chromium.js +88 -0
  67. package/dist/core/config.js +348 -0
  68. package/dist/core/connector.js +811 -0
  69. package/dist/core/deps.js +73 -0
  70. package/dist/core/dialog.js +61 -0
  71. package/dist/core/errors.js +89 -0
  72. package/dist/core/evidence.js +58 -0
  73. package/dist/core/frontier.js +219 -0
  74. package/dist/core/gdate.js +77 -0
  75. package/dist/core/git.js +300 -0
  76. package/dist/core/guard.js +124 -0
  77. package/dist/core/http2.js +76 -0
  78. package/dist/core/import.js +541 -0
  79. package/dist/core/install.js +28 -0
  80. package/dist/core/integrity.js +219 -0
  81. package/dist/core/json.js +87 -0
  82. package/dist/core/lang.js +70 -0
  83. package/dist/core/live.js +244 -0
  84. package/dist/core/lock.js +112 -0
  85. package/dist/core/logins.js +67 -0
  86. package/dist/core/media.js +223 -0
  87. package/dist/core/model.js +101 -0
  88. package/dist/core/net.js +366 -0
  89. package/dist/core/open.js +29 -0
  90. package/dist/core/paths.js +84 -0
  91. package/dist/core/people.js +283 -0
  92. package/dist/core/phrases.js +85 -0
  93. package/dist/core/queue.js +113 -0
  94. package/dist/core/reader.js +76 -0
  95. package/dist/core/records.js +105 -0
  96. package/dist/core/roles.js +30 -0
  97. package/dist/core/schema.js +261 -0
  98. package/dist/core/seal.js +77 -0
  99. package/dist/core/self.js +40 -0
  100. package/dist/core/session.js +155 -0
  101. package/dist/core/shortcut.js +90 -0
  102. package/dist/core/stories.js +61 -0
  103. package/dist/core/stromapp.js +138 -0
  104. package/dist/core/text.js +104 -0
  105. package/dist/core/tree.js +507 -0
  106. package/dist/core/uninstall.js +128 -0
  107. package/dist/core/update.js +193 -0
  108. package/dist/core/validate.js +260 -0
  109. package/dist/core/views.js +164 -0
  110. package/dist/core/which.js +51 -0
  111. package/dist/core/workers.js +42 -0
  112. package/dist/gedcom/export.js +454 -0
  113. package/dist/gedcom/labels.js +103 -0
  114. package/dist/gedcom/lines.js +91 -0
  115. package/dist/gedcom/parse.js +53 -0
  116. package/dist/gedcom/validate.js +183 -0
  117. package/dist/image/image.js +223 -0
  118. package/dist/image/index.js +114 -0
  119. package/dist/image/jpeg-decode.js +552 -0
  120. package/dist/image/jpeg-encode.js +254 -0
  121. package/dist/image/png.js +241 -0
  122. package/dist/runners/antigravity.js +70 -0
  123. package/dist/runners/claude.js +179 -0
  124. package/dist/runners/codex.js +45 -0
  125. package/dist/runners/index.js +13 -0
  126. package/dist/runners/jsonl.js +86 -0
  127. package/dist/runners/opencode.js +50 -0
  128. package/dist/runners/runner.js +63 -0
  129. package/dist/runners/script.js +58 -0
  130. package/package.json +44 -0
@@ -0,0 +1,193 @@
1
+ // A new version of strom: noticed (at most once a day, quietly, never in the
2
+ // way) and installed with the user's yes (strom update). strom's code comes
3
+ // from the project's releases (strom-app.tar.gz, checked against the release's
4
+ // SHASUMS256.txt like the installer does) and replaces app/ of the
5
+ // installation; the Node beside it is replaced only when the release asks for
6
+ // another one (NODE_VERSION) — the official build from nodejs.org, checked
7
+ // against nodejs.org's SHASUMS256.txt. A release carries a VERSION file, so the
8
+ // newest version is one small file — the same way from GitHub, a mirror or a
9
+ // folder (STROM_DOWNLOAD_BASE, STROM_NODE_BASE).
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import crypto from "node:crypto";
13
+ import { spawn, spawnSync } from "node:child_process";
14
+ import { VERSION } from "./tree.js";
15
+ export const RELEASES = "https://github.com/ACiDekCZ/strom-research/releases/latest/download";
16
+ /** How often strom asks whether there is a new version. */
17
+ export const CHECK_EVERY_MS = 24 * 3600_000;
18
+ export function releaseBase(env) {
19
+ return (env.STROM_DOWNLOAD_BASE ?? "").trim() || RELEASES;
20
+ }
21
+ /** Is version a newer than b? (1.10.0 > 1.9.2) */
22
+ export function isNewer(a, b) {
23
+ const n = (v) => v.replace(/^v/, "").split(/[.-]/).map((x) => Number.parseInt(x, 10) || 0);
24
+ const [x, y] = [n(a), n(b)];
25
+ for (let i = 0; i < 3; i++)
26
+ if ((x[i] ?? 0) !== (y[i] ?? 0))
27
+ return (x[i] ?? 0) > (y[i] ?? 0);
28
+ return false;
29
+ }
30
+ /** Where the official Node comes from (nodejs.org's layout: <base>/v<version>/<archive>, SHASUMS256.txt beside). */
31
+ export function nodeBase(env) {
32
+ return (env.STROM_NODE_BASE ?? "").trim() || "https://nodejs.org/dist";
33
+ }
34
+ /** The official Node archive for this computer. */
35
+ export function nodeArchive(version, platform = process.platform, arch = process.arch) {
36
+ const cpu = arch === "arm64" ? "arm64" : "x64";
37
+ if (platform === "win32")
38
+ return `node-v${version}-win-${cpu}.zip`;
39
+ return `node-v${version}-${platform === "darwin" ? "darwin" : "linux"}-${cpu}.tar.gz`;
40
+ }
41
+ async function get(url, timeoutMs) {
42
+ if (url.startsWith("file://"))
43
+ return fs.readFileSync(new URL(url));
44
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), redirect: "follow" });
45
+ if (!res.ok)
46
+ throw new Error(`HTTP ${res.status} for ${url}`);
47
+ return Buffer.from(await res.arrayBuffer());
48
+ }
49
+ /** The newest version released, or undefined when it cannot be learned now. */
50
+ export async function latestVersion(env, timeoutMs = 4000) {
51
+ try {
52
+ const v = (await get(`${releaseBase(env)}/VERSION`, timeoutMs)).toString("utf8").trim();
53
+ return /^\d+\.\d+\.\d+/.test(v) ? v : undefined;
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ /** Does strom look for new versions? (the setting updates: check, the default, or off) */
60
+ export function checksUpdates(settings, env) {
61
+ return (env.STROM_UPDATES ?? settings.config.updates ?? "check") !== "off";
62
+ }
63
+ /**
64
+ * A newer version than this one, if there is one — asked at most once a day
65
+ * (the answer is kept in the user config), with a short wait, silent when
66
+ * there is no network. `fresh` asks now (strom update, strom doctor).
67
+ */
68
+ export async function newerVersion(settings, env, opts = {}) {
69
+ if (!checksUpdates(settings, env))
70
+ return undefined;
71
+ const seen = settings.config.updateCheck;
72
+ const due = opts.fresh || !seen || Date.now() - Date.parse(seen.at) > CHECK_EVERY_MS;
73
+ let latest = seen?.latest;
74
+ if (due) {
75
+ const found = await latestVersion(env, opts.timeoutMs ?? 1500);
76
+ if (found) {
77
+ latest = found;
78
+ settings.config.updateCheck = { at: new Date().toISOString(), latest: found };
79
+ try {
80
+ settings.save();
81
+ }
82
+ catch {
83
+ // a read-only config: ask again next time
84
+ }
85
+ }
86
+ }
87
+ return latest && isNewer(latest, VERSION) ? latest : undefined;
88
+ }
89
+ /** A newer version known already (no network): for places that must not wait. */
90
+ export function knownNewerVersion(settings, env) {
91
+ if (!checksUpdates(settings, env))
92
+ return undefined;
93
+ const latest = settings.config.updateCheck?.latest;
94
+ return latest && isNewer(latest, VERSION) ? latest : undefined;
95
+ }
96
+ const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
97
+ /** The line of a SHASUMS256.txt for one file, or undefined. */
98
+ function wanted(sums, name) {
99
+ return sums
100
+ .toString("utf8")
101
+ .split("\n")
102
+ .find((l) => l.trim().endsWith(` ${name}`))
103
+ ?.split(/\s+/)[0]
104
+ ?.toLowerCase();
105
+ }
106
+ function untar(archive, into) {
107
+ // tar unpacks both: .tar.gz, and .zip on Windows 10 and later.
108
+ fs.mkdirSync(into, { recursive: true });
109
+ const r = spawnSync("tar", ["-xf", archive, "-C", into], { stdio: "ignore", windowsHide: true });
110
+ if (r.status !== 0)
111
+ throw new Error("the download could not be unpacked");
112
+ }
113
+ /** Put the official Node of this version into the installation (node/node.exe, node/bin/node). */
114
+ async function installNode(env, root, version, work, platform) {
115
+ const name = nodeArchive(version, platform);
116
+ const dir = `${nodeBase(env)}/v${version}`;
117
+ const [archive, sums] = await Promise.all([get(`${dir}/${name}`, 300_000), get(`${dir}/SHASUMS256.txt`, 20_000)]);
118
+ if (wanted(sums, name) !== sha256(archive))
119
+ throw new Error("the Node download is damaged (checksum mismatch) — try again");
120
+ const file = path.join(work, name);
121
+ fs.writeFileSync(file, archive);
122
+ untar(file, path.join(work, "node"));
123
+ const top = path.join(work, "node", name.replace(/\.(zip|tar\.gz)$/, ""));
124
+ const rel = platform === "win32" ? "node.exe" : path.join("bin", "node");
125
+ const fresh = path.join(top, rel);
126
+ if (!fs.existsSync(fresh))
127
+ throw new Error("the Node download has no node program");
128
+ const target = path.join(root, "node", rel);
129
+ fs.mkdirSync(path.dirname(target), { recursive: true });
130
+ if (platform === "win32") {
131
+ // The running node.exe cannot be overwritten on Windows, but it can be renamed; deleted after strom ends.
132
+ const old = path.join(root, "node", "node.old.exe");
133
+ fs.rmSync(old, { force: true });
134
+ if (fs.existsSync(target))
135
+ fs.renameSync(target, old);
136
+ fs.copyFileSync(fresh, target);
137
+ const child = spawn(env.ComSpec ?? "cmd.exe", ["/d", "/c", `ping 127.0.0.1 -n 3 >nul & del /f /q "${old}"`], { detached: true, stdio: "ignore", windowsHide: true });
138
+ child.unref();
139
+ }
140
+ else {
141
+ fs.copyFileSync(fresh, `${target}.new`);
142
+ fs.chmodSync(`${target}.new`, 0o755);
143
+ fs.renameSync(`${target}.new`, target);
144
+ }
145
+ const license = path.join(top, "LICENSE");
146
+ if (fs.existsSync(license))
147
+ fs.copyFileSync(license, path.join(root, "node", "LICENSE"));
148
+ }
149
+ /**
150
+ * Download, check and put the newest strom into the installation at `root`
151
+ * (the installer's folder: node/, app/, install.json). Returns the version
152
+ * installed; throws with a plain reason when it cannot — strom stays as it was.
153
+ */
154
+ export async function installUpdate(env, root, platform = process.platform) {
155
+ const base = releaseBase(env);
156
+ const version = await latestVersion(env, 10_000);
157
+ if (!version)
158
+ throw new Error("the newest version could not be learned (no network?)");
159
+ const [archive, sums, nodeWanted] = await Promise.all([
160
+ get(`${base}/strom-app.tar.gz`, 120_000),
161
+ get(`${base}/SHASUMS256.txt`, 20_000),
162
+ get(`${base}/NODE_VERSION`, 20_000).then((b) => b.toString("utf8").trim(), () => ""),
163
+ ]);
164
+ if (wanted(sums, "strom-app.tar.gz") !== sha256(archive))
165
+ throw new Error("the download is damaged (checksum mismatch) — try again");
166
+ const infoFile = path.join(root, "install.json");
167
+ const info = JSON.parse(fs.readFileSync(infoFile, "utf8"));
168
+ // Unpacked beside the installation (the same disk: a rename moves it into place).
169
+ const work = fs.mkdtempSync(path.join(root, ".update-"));
170
+ try {
171
+ const file = path.join(work, "strom-app.tar.gz");
172
+ fs.writeFileSync(file, archive);
173
+ untar(file, work);
174
+ if (!fs.existsSync(path.join(work, "app", "dist", "cli.js")))
175
+ throw new Error("the download has no strom in it");
176
+ if (/^\d+\.\d+\.\d+$/.test(nodeWanted) && nodeWanted !== info.node) {
177
+ await installNode(env, root, nodeWanted, work, platform);
178
+ info.node = nodeWanted;
179
+ }
180
+ const app = path.join(root, "app");
181
+ const old = path.join(root, "app.old");
182
+ fs.rmSync(old, { recursive: true, force: true });
183
+ fs.renameSync(app, old);
184
+ fs.renameSync(path.join(work, "app"), app);
185
+ fs.rmSync(old, { recursive: true, force: true });
186
+ info.version = version;
187
+ fs.writeFileSync(infoFile, `${JSON.stringify(info, null, 2)}\n`);
188
+ }
189
+ finally {
190
+ fs.rmSync(work, { recursive: true, force: true });
191
+ }
192
+ return version;
193
+ }
@@ -0,0 +1,260 @@
1
+ // Record validation. Returns a list of problems instead of throwing, so the
2
+ // same code serves write-time validation and `strom check`.
3
+ import { DIRECTIONS, EVENT_KINDS, NOTE_MAX, RECORD_TYPES, RESEARCH_STATES, STATUSES, } from "./model.js";
4
+ import { normalizeDate } from "./gdate.js";
5
+ import { isGedcomAge } from "./age.js";
6
+ import { SCHEMAS, checkFields } from "./schema.js";
7
+ import { ALL_PREFIXES, CHILD_RELATIONS, NAME_KINDS, PARTICIPANT_ROLES } from "./model.js";
8
+ const ID_RE = Object.fromEntries(Object.entries(RECORD_TYPES).map(([t, v]) => [t, new RegExp(`^${v.prefix}\\d{4,}$`)]));
9
+ const EVENT_ID = /^E\d{4,}$/;
10
+ const ISO = /^\d{4}-\d{2}-\d{2}T/;
11
+ function isObj(v) {
12
+ return v !== null && typeof v === "object" && !Array.isArray(v);
13
+ }
14
+ function str(v) {
15
+ return typeof v === "string" && v.length > 0;
16
+ }
17
+ function checkNotes(notes, path, out) {
18
+ if (!Array.isArray(notes)) {
19
+ out.push({ path, message: "must be an array" });
20
+ return;
21
+ }
22
+ notes.forEach((n, i) => {
23
+ const p = `${path}[${i}]`;
24
+ if (!isObj(n))
25
+ return void out.push({ path: p, message: "must be an object" });
26
+ const note = n;
27
+ if (!str(note.text))
28
+ out.push({ path: `${p}.text`, message: "is required" });
29
+ else if (note.text.length > NOTE_MAX)
30
+ out.push({ path: `${p}.text`, message: `is longer than ${NOTE_MAX} characters — use a conflict, hypothesis or story` });
31
+ if (!str(note.at) || !ISO.test(note.at))
32
+ out.push({ path: `${p}.at`, message: "must be an ISO timestamp" });
33
+ if (!str(note.by))
34
+ out.push({ path: `${p}.by`, message: "is required" });
35
+ });
36
+ }
37
+ function checkCitations(list, path, out) {
38
+ if (list === undefined)
39
+ return;
40
+ if (!Array.isArray(list))
41
+ return void out.push({ path, message: "must be an array" });
42
+ list.forEach((c, i) => {
43
+ if (!isObj(c) || !str(c.source))
44
+ out.push({ path: `${path}[${i}].source`, message: "is required" });
45
+ const info = isObj(c) ? c.information : undefined;
46
+ if (info !== undefined && !["primary", "secondary", "unknown"].includes(String(info)))
47
+ out.push({ path: `${path}[${i}].information`, message: "must be primary, secondary or unknown" });
48
+ });
49
+ }
50
+ export function checkEvent(e, path, out, family = false) {
51
+ if (!isObj(e))
52
+ return void out.push({ path, message: "must be an object" });
53
+ const ev = e;
54
+ if (!str(ev.id) || !EVENT_ID.test(ev.id))
55
+ out.push({ path: `${path}.id`, message: "must be an event ID like E0001" });
56
+ if (!str(ev.kind) || !(ev.kind in EVENT_KINDS))
57
+ out.push({ path: `${path}.kind`, message: `unknown event kind "${String(ev.kind)}"` });
58
+ if (ev.date !== undefined && (!str(ev.date) || normalizeDate(ev.date) !== ev.date))
59
+ out.push({ path: `${path}.date`, message: `"${String(ev.date)}" is not a normalized GEDCOM date` });
60
+ if (!STATUSES.includes(ev.status))
61
+ out.push({ path: `${path}.status`, message: `must be one of ${STATUSES.join(", ")}` });
62
+ if (!Array.isArray(ev.citations))
63
+ out.push({ path: `${path}.citations`, message: "must be an array" });
64
+ else
65
+ checkCitations(ev.citations, `${path}.citations`, out);
66
+ if ((ev.status === "proven" || ev.status === "probable") && Array.isArray(ev.citations) && ev.citations.length === 0)
67
+ out.push({ path: `${path}.status`, message: `${ev.status} needs at least one citation` });
68
+ if (ev.house !== undefined && !str(ev.house))
69
+ out.push({ path: `${path}.house`, message: "must be a non-empty string" });
70
+ if (ev.cause !== undefined && !str(ev.cause))
71
+ out.push({ path: `${path}.cause`, message: "must be a non-empty string" });
72
+ if (ev.kind === "EVEN" && !ev.label && !ev.value)
73
+ out.push({ path: `${path}.label`, message: "EVEN needs a label (what happened)" });
74
+ if (["OCCU", "RELI", "TITL", "NATI"].includes(String(ev.kind)) && !ev.value)
75
+ out.push({ path: `${path}.value`, message: `${ev.kind} needs a value (the fact itself, e.g. "blacksmith")` });
76
+ if (ev.participants !== undefined) {
77
+ if (!Array.isArray(ev.participants))
78
+ out.push({ path: `${path}.participants`, message: "must be a list" });
79
+ else
80
+ ev.participants.forEach((pt, i) => {
81
+ const pp = `${path}.participants[${i}]`;
82
+ if (!isObj(pt))
83
+ return void out.push({ path: pp, message: "must be an object" });
84
+ if (!pt.person && !pt.name)
85
+ out.push({ path: pp, message: "needs a person ID or a name" });
86
+ if (!PARTICIPANT_ROLES.includes(pt.role))
87
+ out.push({ path: `${pp}.role`, message: `must be one of ${PARTICIPANT_ROLES.join(", ")}` });
88
+ });
89
+ }
90
+ if (ev.age !== undefined) {
91
+ if (family)
92
+ out.push({ path: `${path}.age`, message: "a family event gives the age of each partner: ages" });
93
+ else if (!str(ev.age) || !isGedcomAge(ev.age))
94
+ out.push({ path: `${path}.age`, message: `"${String(ev.age)}" is not a GEDCOM age like 27y, 27y 3m, INFANT` });
95
+ }
96
+ if (ev.ages !== undefined) {
97
+ if (!family)
98
+ out.push({ path: `${path}.ages`, message: "ages are for family events; a person's event has age" });
99
+ else if (!isObj(ev.ages))
100
+ out.push({ path: `${path}.ages`, message: "must be an object: person ID → age" });
101
+ else
102
+ for (const [who, age] of Object.entries(ev.ages)) {
103
+ if (!/^P\d{4,}$/.test(who))
104
+ out.push({ path: `${path}.ages`, message: `"${who}" is not a person ID` });
105
+ if (typeof age !== "string" || !isGedcomAge(age))
106
+ out.push({ path: `${path}.ages.${who}`, message: `"${String(age)}" is not a GEDCOM age` });
107
+ }
108
+ }
109
+ }
110
+ function checkBase(r, type, out) {
111
+ const re = ID_RE[type];
112
+ if (!str(r.id) || !re?.test(r.id))
113
+ out.push({ path: "id", message: `must be a ${type} ID` });
114
+ if (!str(r.created) || !ISO.test(r.created))
115
+ out.push({ path: "created", message: "must be an ISO timestamp" });
116
+ if (!str(r.updated) || !ISO.test(r.updated))
117
+ out.push({ path: "updated", message: "must be an ISO timestamp" });
118
+ }
119
+ function checkStory(st, out) {
120
+ if (st === undefined)
121
+ return;
122
+ if (!isObj(st))
123
+ return void out.push({ path: "story", message: "must be an object" });
124
+ const s = st;
125
+ if (!str(s.text))
126
+ out.push({ path: "story.text", message: "is required" });
127
+ if (s.status !== "draft" && s.status !== "final")
128
+ out.push({ path: "story.status", message: "must be draft or final" });
129
+ if (!Array.isArray(s.facts) || s.facts.some((f) => !EVENT_ID.test(String(f))))
130
+ out.push({ path: "story.facts", message: "must be a list of event IDs" });
131
+ }
132
+ function checkPerson(p, out) {
133
+ if (!Array.isArray(p.names) || p.names.length === 0)
134
+ out.push({ path: "names", message: "needs at least one name" });
135
+ else
136
+ p.names.forEach((n, i) => {
137
+ if (!isObj(n))
138
+ return void out.push({ path: `names[${i}]`, message: "must be an object" });
139
+ if (typeof n.given !== "string")
140
+ out.push({ path: `names[${i}].given`, message: "must be a string" });
141
+ if (typeof n.surname !== "string")
142
+ out.push({ path: `names[${i}].surname`, message: "must be a string" });
143
+ if (!n.given && !n.surname)
144
+ out.push({ path: `names[${i}]`, message: "needs a given name or a surname" });
145
+ if (n.kind !== undefined && !NAME_KINDS.includes(n.kind))
146
+ out.push({ path: `names[${i}].kind`, message: `must be one of ${NAME_KINDS.join(", ")}` });
147
+ checkCitations(n.citations, `names[${i}].citations`, out);
148
+ });
149
+ if (!["M", "F", "U"].includes(p.sex))
150
+ out.push({ path: "sex", message: "must be M, F or U" });
151
+ checkStory(p.story, out);
152
+ if (!Array.isArray(p.events))
153
+ out.push({ path: "events", message: "must be an array" });
154
+ else
155
+ p.events.forEach((e, i) => checkEvent(e, `events[${i}]`, out));
156
+ checkNotes(p.notes, "notes", out);
157
+ }
158
+ function checkFamily(f, out) {
159
+ if (!Array.isArray(f.partners) || f.partners.length > 2)
160
+ out.push({ path: "partners", message: "must be an array of at most 2 persons" });
161
+ if (Array.isArray(f.events) && Array.isArray(f.partners))
162
+ f.events.forEach((e, i) => {
163
+ for (const who of Object.keys(e?.ages ?? {}))
164
+ if (!f.partners.includes(who))
165
+ out.push({ path: `events[${i}].ages.${who}`, message: "is not a partner of this family" });
166
+ });
167
+ if (!Array.isArray(f.children))
168
+ out.push({ path: "children", message: "must be an array" });
169
+ else
170
+ f.children.forEach((c, i) => {
171
+ if (!isObj(c) || !str(c.person))
172
+ return void out.push({ path: `children[${i}].person`, message: "is required" });
173
+ if (!CHILD_RELATIONS.includes(c.relation))
174
+ out.push({ path: `children[${i}].relation`, message: `must be one of ${CHILD_RELATIONS.join(", ")}` });
175
+ if (c.relations !== undefined) {
176
+ if (!isObj(c.relations))
177
+ out.push({ path: `children[${i}].relations`, message: "must be an object: partner ID → relation" });
178
+ else
179
+ for (const [who, rel] of Object.entries(c.relations)) {
180
+ if (!f.partners?.includes(who))
181
+ out.push({ path: `children[${i}].relations.${who}`, message: "is not a partner of this family" });
182
+ if (!CHILD_RELATIONS.includes(rel))
183
+ out.push({ path: `children[${i}].relations.${who}`, message: `must be one of ${CHILD_RELATIONS.join(", ")}` });
184
+ }
185
+ }
186
+ });
187
+ checkCitations(f.citations, "citations", out);
188
+ checkStory(f.story, out);
189
+ const members = (f.partners?.length ?? 0) + (f.children?.length ?? 0);
190
+ if (members === 0)
191
+ out.push({ path: "partners", message: "a family needs at least one member" });
192
+ if (!Array.isArray(f.events))
193
+ out.push({ path: "events", message: "must be an array" });
194
+ else
195
+ f.events.forEach((e, i) => checkEvent(e, `events[${i}]`, out, true));
196
+ checkNotes(f.notes, "notes", out);
197
+ }
198
+ function checkResearch(r, out) {
199
+ if (!str(r.name))
200
+ out.push({ path: "name", message: "is required" });
201
+ if (!str(r.focus))
202
+ out.push({ path: "focus", message: "is required (a person ID)" });
203
+ if (!DIRECTIONS.includes(r.direction))
204
+ out.push({ path: "direction", message: `must be one of ${DIRECTIONS.join(", ")}` });
205
+ if (!RESEARCH_STATES.includes(r.state))
206
+ out.push({ path: "state", message: `must be one of ${RESEARCH_STATES.join(", ")}` });
207
+ if (typeof r.priority !== "number" || r.priority < 1 || r.priority > 5)
208
+ out.push({ path: "priority", message: "must be 1..5" });
209
+ if (r.direction === "question" && !str(r.question))
210
+ out.push({ path: "question", message: "is required for direction question" });
211
+ checkNotes(r.notes, "notes", out);
212
+ }
213
+ export function validateRecord(value) {
214
+ const out = [];
215
+ if (!isObj(value))
216
+ return [{ path: "", message: "record must be an object" }];
217
+ const type = value.type;
218
+ if (typeof type !== "string" || !(type in RECORD_TYPES))
219
+ return [{ path: "type", message: `unknown record type "${String(type)}"` }];
220
+ checkBase(value, type, out);
221
+ const rec = value;
222
+ if (rec.type === "person")
223
+ checkPerson(rec, out);
224
+ else if (rec.type === "family")
225
+ checkFamily(rec, out);
226
+ else if (rec.type === "research")
227
+ checkResearch(rec, out);
228
+ else {
229
+ const spec = SCHEMAS[rec.type];
230
+ if (spec)
231
+ checkFields(value, spec, ALL_PREFIXES, "", out, []);
232
+ checkNotes(value.notes, "notes", out);
233
+ }
234
+ return out;
235
+ }
236
+ /** Every reference a record makes to another record (for dangling-reference checks). */
237
+ export function recordRefs(value) {
238
+ const refs = [];
239
+ const events = value.events ?? [];
240
+ events.forEach((e, i) => {
241
+ e.citations?.forEach((c, j) => refs.push({ path: `events[${i}].citations[${j}]`, id: c.source, to: ["source"] }));
242
+ e.participants?.forEach((pt, j) => pt.person && refs.push({ path: `events[${i}].participants[${j}]`, id: pt.person, to: ["person"] }));
243
+ });
244
+ if (value.type === "person")
245
+ value.names.forEach((n, i) => n.citations?.forEach((c, j) => refs.push({ path: `names[${i}].citations[${j}]`, id: c.source, to: ["source"] })));
246
+ if (value.type === "family") {
247
+ value.citations?.forEach((c, j) => refs.push({ path: `citations[${j}]`, id: c.source, to: ["source"] }));
248
+ value.partners.forEach((p, i) => refs.push({ path: `partners[${i}]`, id: p, to: ["person"] }));
249
+ value.children.forEach((c, i) => refs.push({ path: `children[${i}]`, id: c.person, to: ["person"] }));
250
+ }
251
+ if (value.type === "research") {
252
+ refs.push({ path: "focus", id: value.focus, to: ["person"] });
253
+ if (value.parent)
254
+ refs.push({ path: "parent", id: value.parent, to: ["research"] });
255
+ }
256
+ const spec = SCHEMAS[value.type];
257
+ if (spec)
258
+ checkFields(value, spec, ALL_PREFIXES, "", [], refs);
259
+ return refs;
260
+ }
@@ -0,0 +1,164 @@
1
+ // Views of images: the only way an agent looks at a scan. A view is a file in
2
+ // .strom/views/ (the one folder of images an agent may read) made from a
3
+ // registered image: a crop, a half of a double page, scaled to what a reader
4
+ // can take in, contrast stretched on the part being read. Making views through
5
+ // strom also tells strom which images were looked at.
6
+ //
7
+ // Why: an image an agent opens stays in its context and is paid for on every
8
+ // later turn; a whole double page at full resolution is the most expensive
9
+ // thing there is, and readers shrink big images anyway — small script then
10
+ // becomes illegible. So: browse at a reduced size, read at full resolution
11
+ // only the column or entry that matters.
12
+ import crypto from "node:crypto";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { UsageError } from "./errors.js";
16
+ import { now } from "./tree.js";
17
+ import { crop, grid, resize, rotate, stretch, toGrey } from "../image/image.js";
18
+ import { decodeImage, encodeImage, ImageFormatError, imageSize } from "../image/index.js";
19
+ /** Longest side of a view by default: what vision models take in without shrinking it again. */
20
+ export const VIEW_MAX = 1568;
21
+ /** Small crops are enlarged at least to this long side (script gets bigger, not sharper). */
22
+ const VIEW_MIN = 800;
23
+ export function parseCrop(spec, w, h) {
24
+ const nums = spec.split(/[,\s]+/).filter(Boolean).map(Number);
25
+ if (nums.length !== 4 || nums.some((n) => !Number.isFinite(n) || n < 0))
26
+ throw new UsageError(`invalid --crop "${spec}"`, { hint: 'give x,y,width,height as fractions ("0.1,0.35,0.4,0.2") or pixels ("400,1200,1500,600")' });
27
+ const relative = nums.every((n) => n <= 1);
28
+ const [x, y, cw, ch] = relative ? [nums[0] * w, nums[1] * h, nums[2] * w, nums[3] * h] : nums;
29
+ if (cw <= 0 || ch <= 0)
30
+ throw new UsageError("--crop needs a width and a height");
31
+ return { x: Math.max(0, x), y: Math.max(0, y), w: Math.min(cw, w - x), h: Math.min(ch, h - y) };
32
+ }
33
+ /** The part of a W×H image a view shows (--half, then --crop within it), in its pixels. */
34
+ export function viewRegion(spec, W, H) {
35
+ let region = { x: 0, y: 0, w: W, h: H };
36
+ if (spec.half === "left")
37
+ region = { x: 0, y: 0, w: Math.ceil(W / 2), h: H };
38
+ else if (spec.half === "right")
39
+ region = { x: Math.floor(W / 2), y: 0, w: W - Math.floor(W / 2), h: H };
40
+ else if (spec.half === "top")
41
+ region = { x: 0, y: 0, w: W, h: Math.ceil(H / 2) };
42
+ else if (spec.half === "bottom")
43
+ region = { x: 0, y: Math.floor(H / 2), w: W, h: H - Math.floor(H / 2) };
44
+ if (spec.crop) {
45
+ const c = parseCrop(spec.crop, region.w, region.h);
46
+ region = { x: region.x + c.x, y: region.y + c.y, w: c.w, h: c.h };
47
+ }
48
+ return region;
49
+ }
50
+ /**
51
+ * A region too big for one look at full resolution, as overlapping tiles of at
52
+ * most `max` px (in original pixels, "x,y,w,h" for --crop). One tile when it fits.
53
+ */
54
+ export function tiles(region, max = VIEW_MAX, overlap = 0.1) {
55
+ const step = Math.floor(max * (1 - overlap));
56
+ const cols = Math.max(1, Math.ceil((region.w - max) / step) + 1);
57
+ const rows = Math.max(1, Math.ceil((region.h - max) / step) + 1);
58
+ const out = [];
59
+ for (let r = 0; r < rows; r++)
60
+ for (let c = 0; c < cols; c++) {
61
+ const x = cols === 1 ? region.x : Math.round(region.x + ((region.w - Math.min(max, region.w)) * c) / (cols - 1));
62
+ const y = rows === 1 ? region.y : Math.round(region.y + ((region.h - Math.min(max, region.h)) * r) / (rows - 1));
63
+ const w = Math.round(Math.min(max, region.w));
64
+ const h = Math.round(Math.min(max, region.h));
65
+ const where = [rows > 1 ? ["top", "middle", "bottom"][r === 0 ? 0 : r === rows - 1 ? 2 : 1] : "", cols > 1 ? ["left", "centre", "right"][c === 0 ? 0 : c === cols - 1 ? 2 : 1] : ""].filter(Boolean).join(" ");
66
+ out.push({ crop: `${Math.round(x)},${Math.round(y)},${w},${h}`, label: `part ${r * cols + c + 1} of ${rows * cols}${where ? ` (${where})` : ""}` });
67
+ }
68
+ return out;
69
+ }
70
+ /** Make (or reuse) a view of an image file. `key` names it (M0012, I0003). */
71
+ export function makeView(tree, source, key, spec) {
72
+ if (!fs.existsSync(source))
73
+ throw new UsageError(`the image file is missing: ${source}`, { hint: "the shared folder may have moved: strom config where" });
74
+ const bytes = new Uint8Array(fs.readFileSync(source));
75
+ const size = imageSize(bytes);
76
+ if (!size) {
77
+ // not JPEG/PNG: decodeImage says what to do
78
+ try {
79
+ decodeImage(bytes);
80
+ }
81
+ catch (err) {
82
+ if (err instanceof ImageFormatError)
83
+ throw new UsageError(err.message);
84
+ throw err;
85
+ }
86
+ }
87
+ const W = size.width;
88
+ const H = size.height;
89
+ let region = viewRegion(spec, W, H);
90
+ region = { x: Math.round(region.x), y: Math.round(region.y), w: Math.max(1, Math.round(region.w)), h: Math.max(1, Math.round(region.h)) };
91
+ const long = Math.max(region.w, region.h);
92
+ const scale = spec.scale !== undefined
93
+ ? spec.scale
94
+ : long > (spec.max ?? VIEW_MAX)
95
+ ? (spec.max ?? VIEW_MAX) / long
96
+ : spec.crop || spec.half
97
+ ? Math.min(3, Math.max(1, VIEW_MIN / long))
98
+ : 1;
99
+ if (!(scale > 0) || scale > 8)
100
+ throw new UsageError("--scale must be between 0 and 8");
101
+ const outW = Math.max(1, Math.round(region.w * scale));
102
+ const outH = Math.max(1, Math.round(region.h * scale));
103
+ if (Math.max(outW, outH) > 6000)
104
+ throw new UsageError(`the view would be ${outW}×${outH} px`, { hint: "crop a smaller part, or a smaller --scale" });
105
+ const sha = crypto.createHash("sha256").update(bytes).update(JSON.stringify({ region, scale, spec: { ...spec, crop: undefined, half: undefined, scale: undefined, max: undefined } })).digest("hex");
106
+ const ext = spec.png ? "png" : "jpg";
107
+ const dir = path.join(tree.root, ".strom", "views");
108
+ const file = path.join(dir, `${key}-${sha.slice(0, 10)}.${ext}`);
109
+ const cached = fs.existsSync(file);
110
+ let dims = { width: outW, height: outH };
111
+ if (!cached) {
112
+ let img = decodeImage(bytes);
113
+ img = crop(img, region.x, region.y, region.w, region.h);
114
+ if (spec.grey)
115
+ img = toGrey(img);
116
+ if (outW !== img.width || outH !== img.height)
117
+ img = resize(img, outW, outH);
118
+ if (spec.contrast)
119
+ img = stretch(img);
120
+ if (spec.rotate)
121
+ img = rotate(img, spec.rotate);
122
+ if (spec.grid)
123
+ img = grid(img);
124
+ dims = { width: img.width, height: img.height };
125
+ fs.mkdirSync(dir, { recursive: true });
126
+ fs.writeFileSync(file, encodeImage(img, spec.png ? "png" : "jpeg"));
127
+ }
128
+ else if (spec.rotate === 90 || spec.rotate === 270)
129
+ dims = { width: outH, height: outW };
130
+ // Which images were looked at, and by whom (session costs, "who read this").
131
+ fs.appendFileSync(path.join(dir, "views.jsonl"), JSON.stringify({ at: now(), key, by: tree.actor, view: path.basename(file), region, scale }) + "\n");
132
+ return { file, ...dims, scale, original: { width: W, height: H }, region, cached };
133
+ }
134
+ /** One line telling the reader what it sees and how to see more. */
135
+ export function describeView(v, display, fetchPart) {
136
+ const pct = Math.round(v.scale * 100);
137
+ const part = v.region.w === v.original.width && v.region.h === v.original.height ? "whole image" : `part ${v.region.x},${v.region.y} ${v.region.w}×${v.region.h} px`;
138
+ const hint = v.scale < 0.75
139
+ ? " — reduced: crop the part you need to read it at full size (--crop x,y,w,h, --half left|right, --grid to find it)"
140
+ : v.scale > 1.25
141
+ ? `\nenlarged from ${v.region.w}×${v.region.h} px of the scan: it has no more detail than that. What you cannot read for sure is marked [?] in the transcript and stays out of the fields; ${fetchPart
142
+ ? `this part sharper from the archive (one request): ${fetchPart}`
143
+ : 'a sharper scan: ask the user (strom task wait … --images B…:<n> --on "…": zoomed in on the entry, or the full-resolution scan)'}`
144
+ : "";
145
+ return `${display(v.file)}\n${v.width}×${v.height} px · ${part} of ${v.original.width}×${v.original.height} · ${pct} %${hint}`;
146
+ }
147
+ /** A part of an image from --half / --crop (fractions, or pixels of the registered whole image). */
148
+ export function partRegion(opts, whole) {
149
+ const half = opts.half === undefined ? undefined : String(opts.half);
150
+ if (half !== undefined && !["left", "right", "top", "bottom"].includes(half))
151
+ throw new UsageError(`invalid --half "${half}"`, { hint: "left, right, top or bottom" });
152
+ const crop = opts.crop === undefined ? undefined : String(opts.crop);
153
+ const pixels = !!crop && crop.split(/[,\s]+/).filter(Boolean).map(Number).some((n) => n > 1);
154
+ if (pixels && !(whole?.width && whole.height))
155
+ throw new UsageError("--crop in pixels needs the image registered (its size); give fractions of it", { hint: "--crop 0.5,0.2,0.5,0.3 — x, y, width, height as parts of the whole image" });
156
+ // fractions are measured on a large image, so that halves come out exact
157
+ const [W, H] = pixels ? [whole.width, whole.height] : [1_000_000, 1_000_000];
158
+ const px = viewRegion({ half: half, crop }, W, H);
159
+ const f = (n) => Math.round(n * 10_000) / 10_000;
160
+ const r = { x: f(px.x / W), y: f(px.y / H), w: f(px.w / W), h: f(px.h / H) };
161
+ if (r.w * r.h > 0.95)
162
+ throw new UsageError("that is the whole image", { hint: "without --crop and --half it is the image itself" });
163
+ return r;
164
+ }
@@ -0,0 +1,51 @@
1
+ // Find executables on PATH without running them (running an outdated,
2
+ // blocked binary can trigger OS malware warnings).
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { agentBinDirs } from "./install.js";
6
+ export function which(cmd, env, platform = process.platform) {
7
+ const dirs = (env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean);
8
+ const exts = platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").map((e) => e.toLowerCase()) : [""];
9
+ for (const dir of dirs)
10
+ for (const ext of exts) {
11
+ const file = path.join(dir, cmd + ext);
12
+ try {
13
+ const st = fs.statSync(file);
14
+ if (st.isFile() && (platform === "win32" || (st.mode & 0o111) !== 0))
15
+ return file;
16
+ }
17
+ catch {
18
+ // not here
19
+ }
20
+ }
21
+ return undefined;
22
+ }
23
+ /** Agent CLIs strom can drive. */
24
+ export const AGENTS = [
25
+ { id: "claude", command: "claude", name: "Claude Code" },
26
+ { id: "codex", command: "codex", name: "OpenAI Codex CLI" },
27
+ { id: "antigravity", command: "agy", name: "Antigravity CLI" },
28
+ { id: "opencode", command: "opencode", name: "OpenCode" },
29
+ ];
30
+ /** Which AI agent's shell tool runs strom? (they set these variables) */
31
+ export function detectAgent(env) {
32
+ if (env.CLAUDECODE)
33
+ return "claude";
34
+ if (Object.keys(env).some((k) => k.startsWith("ANTIGRAVITY_")))
35
+ return "antigravity";
36
+ if (Object.keys(env).some((k) => k.startsWith("CODEX_")))
37
+ return "codex";
38
+ if (env.OPENCODE === "1" || env.OPENCODE_PID)
39
+ return "opencode";
40
+ if (env.AI_AGENT)
41
+ return env.AI_AGENT.toLowerCase();
42
+ return undefined;
43
+ }
44
+ /** Is an agent running strom — its shell tool, or an agent strom run started? */
45
+ export function isAgent(env) {
46
+ return Boolean(detectAgent(env) || env.STROM_SESSION);
47
+ }
48
+ /** An agent CLI on PATH, or where its installer puts it (a PATH not updated yet in this terminal). */
49
+ export function findAgent(cmd, env, platform = process.platform) {
50
+ return which(cmd, env, platform) ?? which(cmd, { ...env, PATH: agentBinDirs(env, platform).join(path.delimiter), Path: undefined }, platform);
51
+ }