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,112 @@
1
+ // `strom check` — does the evidence hold together? Findings are either
2
+ // errors (block the automatic commit) or warnings.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { RECORD_TYPES } from "./model.js";
6
+ import { recordRefs, validateRecord } from "./validate.js";
7
+ import { readJson, readJsonIfExists } from "./json.js";
8
+ import { Tree, prefixOf } from "./tree.js";
9
+ import { isBirthFamily } from "./people.js";
10
+ export function check(tree) {
11
+ const out = [];
12
+ const seen = new Map();
13
+ // 1. every file parses, is valid, and its name matches its ID
14
+ for (const [type, def] of Object.entries(RECORD_TYPES)) {
15
+ const dir = path.join(tree.dataDir, def.dir);
16
+ if (!fs.existsSync(dir))
17
+ continue;
18
+ for (const f of fs.readdirSync(dir).sort()) {
19
+ if (!f.endsWith(".json"))
20
+ continue;
21
+ const file = tree.relative(path.join(dir, f));
22
+ let rec;
23
+ try {
24
+ rec = readJson(path.join(dir, f));
25
+ }
26
+ catch (err) {
27
+ out.push({ level: "error", code: "json", file, message: err.message });
28
+ continue;
29
+ }
30
+ if (rec.id !== f.slice(0, -5))
31
+ out.push({ level: "error", code: "file-name", file, message: `file name does not match id ${rec.id}` });
32
+ if (rec.type !== type)
33
+ out.push({ level: "error", code: "wrong-dir", file, message: `record of type ${rec.type} in ${def.dir}/` });
34
+ for (const p of validateRecord(rec))
35
+ out.push({ level: "error", code: "schema", id: rec.id, file, message: `${p.path} ${p.message}` });
36
+ if (rec.id) {
37
+ if (seen.has(rec.id))
38
+ out.push({ level: "error", code: "duplicate-id", id: rec.id, message: `also in ${seen.get(rec.id)}` });
39
+ seen.set(rec.id, file);
40
+ }
41
+ }
42
+ }
43
+ // 2. references point to existing records
44
+ const persons = tree.list("person");
45
+ const families = tree.list("family");
46
+ for (const type of Object.keys(RECORD_TYPES))
47
+ for (const rec of tree.list(type))
48
+ for (const ref of recordRefs(rec))
49
+ if (!tree.get(ref.id))
50
+ out.push({ level: "error", code: "dangling", id: rec.id, message: `${ref.path} → ${ref.id} does not exist` });
51
+ // 3. a child belongs to at most one birth family
52
+ const birthFamily = new Map();
53
+ for (const f of families.filter((x) => !x.retracted))
54
+ for (const c of f.children) {
55
+ if (!isBirthFamily(f, c.person))
56
+ continue;
57
+ const prev = birthFamily.get(c.person);
58
+ if (prev)
59
+ out.push({ level: "error", code: "two-birth-families", id: c.person, message: `child of both ${prev} and ${f.id}` });
60
+ else
61
+ birthFamily.set(c.person, f.id);
62
+ }
63
+ // 4. event IDs are unique across the tree
64
+ const eventOwner = new Map();
65
+ for (const r of [...persons, ...families])
66
+ for (const e of r.events) {
67
+ const prev = eventOwner.get(e.id);
68
+ if (prev)
69
+ out.push({ level: "error", code: "duplicate-id", id: e.id, message: `event in both ${prev} and ${r.id}` });
70
+ eventOwner.set(e.id, r.id);
71
+ }
72
+ // 5. a proven fact beside an open conflict about the same person: the proof is not finished (GPS)
73
+ for (const x of tree.list("conflict").filter((c) => c.state === "open"))
74
+ for (const id of x.subject) {
75
+ const p = tree.get(id);
76
+ if (p?.type === "person" && p.events.some((e) => e.status === "proven" && !e.retracted))
77
+ out.push({ level: "warn", code: "open-conflict", id: x.id, message: `open while ${id} has proven facts — resolve it: strom conflict resolve ${x.id} --resolution … --reasoning …` });
78
+ }
79
+ // 5b. a house number written into the place: the place stops matching its books and its other facts
80
+ for (const owner of [...persons, ...families])
81
+ for (const e of owner.events) {
82
+ const m = e.place && !e.retracted ? houseIn(e.place) : undefined;
83
+ if (m)
84
+ out.push({
85
+ level: "warn",
86
+ code: "house-in-place",
87
+ id: e.id,
88
+ message: `place "${e.place}" holds a house number`,
89
+ hint: `strom event edit ${e.id} --place "${m.place}" --house ${m.house} --reason "the house number out of the place"`,
90
+ });
91
+ }
92
+ // 6. counters are ahead of every ID on disk
93
+ const counters = readJsonIfExists(path.join(tree.dataDir, "_counters.json")) ?? {};
94
+ for (const id of [...seen.keys(), ...eventOwner.keys()]) {
95
+ const prefix = prefixOf(id);
96
+ if (!prefix)
97
+ continue;
98
+ if ((counters[prefix] ?? 0) < Number(id.slice(1)))
99
+ out.push({ level: "warn", code: "counter", id, message: `counter ${prefix} is behind existing IDs (it self-heals on next write)` });
100
+ }
101
+ return out;
102
+ }
103
+ export function hasErrors(findings) {
104
+ return findings.some((f) => f.level === "error");
105
+ }
106
+ /** "Vavřinec čp. 13", "Týnec, Haus-Nr. 5", "Oakham No. 12" → the settlement and the house. */
107
+ export function houseIn(place) {
108
+ const m = /^(.*?)[\s,]+(?:č\.\s?p\.?|čp\.?|č\.\s?d\.?|c\.\s?p\.?|cp\.|Haus-?\s?N(?:r|ro)\.?|Nr\.|No\.|house\s+(?:No\.?)?|dům\s+(?:č\.)?)\s*(\d+[\w/-]*)\s*$/iu.exec(place.trim());
109
+ if (!m || !m[1].trim())
110
+ return undefined;
111
+ return { place: m[1].trim().replace(/,$/, ""), house: m[2] };
112
+ }
@@ -0,0 +1,88 @@
1
+ // A browser that lets the Strom app reach strom on this computer. The app is a
2
+ // web page (https://stromapp.info); it takes a research from strom's bridge on
3
+ // 127.0.0.1. Chrome, Edge and the other Chromium browsers allow that; Safari
4
+ // does not (and Firefox is not proven), so strom opens the research in a
5
+ // Chromium browser when there is one — never in whatever is the default — and
6
+ // otherwise shows the file to drag into the app's window.
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { spawn } from "node:child_process";
10
+ import { userHome } from "./paths.js";
11
+ import { which } from "./which.js";
12
+ const BROWSERS = [
13
+ { name: "Google Chrome", mac: "Google Chrome.app", win: ["Google\\Chrome\\Application\\chrome.exe"], linux: ["google-chrome", "google-chrome-stable"] },
14
+ { name: "Microsoft Edge", mac: "Microsoft Edge.app", win: ["Microsoft\\Edge\\Application\\msedge.exe"], linux: ["microsoft-edge", "microsoft-edge-stable"] },
15
+ { name: "Brave", mac: "Brave Browser.app", win: ["BraveSoftware\\Brave-Browser\\Application\\brave.exe"], linux: ["brave-browser", "brave"] },
16
+ { name: "Chromium", mac: "Chromium.app", win: ["Chromium\\Application\\chrome.exe"], linux: ["chromium", "chromium-browser"] },
17
+ { name: "Vivaldi", mac: "Vivaldi.app", win: ["Vivaldi\\Application\\vivaldi.exe"], linux: ["vivaldi", "vivaldi-stable"] },
18
+ { name: "Arc", mac: "Arc.app", win: [], linux: [] },
19
+ ];
20
+ /** A Chromium browser on this computer, the most common first. STROM_APP_DIRS: folders to look in instead (tests). */
21
+ export function chromiumBrowser(env, platform = process.platform) {
22
+ const own = env.STROM_APP_DIRS !== undefined ? env.STROM_APP_DIRS.split(path.delimiter).filter(Boolean) : undefined;
23
+ for (const b of BROWSERS) {
24
+ if (platform === "darwin") {
25
+ for (const d of own ?? ["/Applications", path.join(userHome(env), "Applications")])
26
+ if (fs.existsSync(path.join(d, b.mac)))
27
+ return { name: b.name, path: path.join(d, b.mac) };
28
+ }
29
+ else if (platform === "win32") {
30
+ const roots = own ?? [env.LOCALAPPDATA, env.ProgramFiles, env["ProgramFiles(x86)"]].filter((r) => Boolean(r));
31
+ for (const root of roots)
32
+ for (const rel of b.win) {
33
+ const exe = own ? path.join(root, path.win32.basename(rel)) : path.join(root, rel);
34
+ if (fs.existsSync(exe))
35
+ return { name: b.name, path: exe };
36
+ }
37
+ }
38
+ else {
39
+ for (const cmd of b.linux) {
40
+ const hit = own ? own.map((d) => path.join(d, cmd)).find((f) => fs.existsSync(f)) : which(cmd, env, platform);
41
+ if (hit)
42
+ return { name: b.name, path: hit };
43
+ }
44
+ }
45
+ }
46
+ return undefined;
47
+ }
48
+ function launch(cmd, args, env) {
49
+ try {
50
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true, env: env });
51
+ child.on("error", () => undefined);
52
+ child.unref();
53
+ return true;
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ /** Open an address in that browser; false when this computer cannot (no desktop) or tests ask not to. */
60
+ export function openInBrowser(browser, url, env, platform = process.platform) {
61
+ if (env.STROM_NO_OPEN === "1")
62
+ return false;
63
+ if (platform === "darwin")
64
+ return launch("open", ["-a", browser.path, url], env);
65
+ if (platform === "win32")
66
+ return launch(browser.path, [url], env);
67
+ if (!env.DISPLAY && !env.WAYLAND_DISPLAY)
68
+ return false;
69
+ return launch(browser.path, [url], env);
70
+ }
71
+ /** Open a file with an app (the installed Strom app takes a .ged through its file handler) — macOS. */
72
+ export function openFileWith(app, file, env, platform = process.platform) {
73
+ if (env.STROM_NO_OPEN === "1" || platform !== "darwin")
74
+ return false;
75
+ return launch("open", ["-a", app, file], env);
76
+ }
77
+ /** Show a file in Finder / Explorer / the file manager, selected where the system can. */
78
+ export function revealFile(file, env, platform = process.platform) {
79
+ if (env.STROM_NO_OPEN === "1")
80
+ return false;
81
+ if (platform === "darwin")
82
+ return launch("open", ["-R", file], env);
83
+ if (platform === "win32")
84
+ return launch("explorer.exe", [`/select,${file}`], env);
85
+ if (!env.DISPLAY && !env.WAYLAND_DISPLAY)
86
+ return false;
87
+ return launch("xdg-open", [path.dirname(file)], env);
88
+ }
@@ -0,0 +1,348 @@
1
+ // User configuration and setting resolution.
2
+ //
3
+ // Every setting is resolved in one order: command flag > environment variable
4
+ // > the tree (strom.json) > the user config > a derived default. Settings a
5
+ // tree can carry are marked `tree`. If a required setting is still missing,
6
+ // the caller asks the user (TTY) or fails with NeedsInputError (no TTY).
7
+ import path from "node:path";
8
+ import { configDir, defaultHome, expandHome } from "./paths.js";
9
+ import { readJsonIfExists, writeJson } from "./json.js";
10
+ import { detectLang, isValidLang } from "./lang.js";
11
+ import { UsageError } from "./errors.js";
12
+ import { DEFAULT_AGENT, PROFILES, TIERS } from "../agents/profiles.js";
13
+ import { STRATEGIES } from "./model.js";
14
+ import { downloadsDir } from "./browser.js";
15
+ import { acquireLock } from "./lock.js";
16
+ export const PERMISSION_LEVELS = ["ask", "auto", "full"];
17
+ /** Earlier names of the levels, still understood. */
18
+ const PERMISSION_ALIASES = { list: "auto", bypass: "full" };
19
+ export const DEFAULT_BUDGET = 25_000;
20
+ export const DEFAULT_RUN_MINUTES = 60;
21
+ export const SETTINGS = [
22
+ { key: "home", env: "STROM_HOME", tree: false, kind: "path", description: "folder with the trees and shared data" },
23
+ { key: "shared", env: "STROM_SHARED", tree: false, kind: "path", description: "shared data: scans, catalog, tools (default <home>/shared)" },
24
+ { key: "trees", env: "STROM_TREES", tree: false, kind: "path", description: "folder holding the trees (default <home>)" },
25
+ { key: "lang", env: "STROM_LANG", tree: true, kind: "lang", description: "research language: the agent talks and writes in it" },
26
+ { key: "agent", env: "STROM_AGENT", tree: true, kind: "agent", description: "AI agent doing the research: claude, codex, antigravity, opencode" },
27
+ ...TIERS.map((t) => ({
28
+ key: `model.${t}`,
29
+ env: t === "lead" ? "STROM_MODEL" : `STROM_MODEL_${t.toUpperCase()}`,
30
+ tree: true,
31
+ kind: "model",
32
+ description: {
33
+ lead: "model of the main researcher (default: the agent's own)",
34
+ vision: "model for handwriting and scans — never weaker than lead",
35
+ text: "model for print, catalogues, web pages",
36
+ cheap: "model for mechanical work",
37
+ }[t],
38
+ })),
39
+ { key: "brief.budget", env: "STROM_BRIEF_BUDGET", tree: true, kind: "number", description: `size of the brief in tokens (default ${DEFAULT_BUDGET})` },
40
+ { key: "run.minutes", env: "STROM_RUN_MINUTES", tree: true, kind: "number", description: `time limit of one \`strom run\` session (default ${DEFAULT_RUN_MINUTES})` },
41
+ { key: "queue.strategy", env: "STROM_QUEUE_STRATEGY", tree: true, kind: "choice", choices: STRATEGIES, description: "order of the task queue: balanced (default — nearest ancestors first, spread over the lines, nothing taken forever), depth (stay on one line), priority (strict priority)" },
42
+ { key: "gedcom.for", env: "STROM_GEDCOM_FOR", tree: true, kind: "choice", choices: ["both", "standard", "strom"], description: "GEDCOM files written: both (default), standard (any program), strom (the Strom app)" },
43
+ { key: "stories", env: "STROM_STORIES", tree: true, kind: "choice", choices: ["yes", "no"], description: "stories of the ancestors for the family, written from the facts: yes (default — strom proposes one once a person's life is told by records), no — the user is told when the research starts and may say no" },
44
+ { key: "strom.version", env: "STROM_APP_VERSION", tree: true, kind: "version", description: "version of the Strom app the Strom GEDCOM is for (set by the app)" },
45
+ { key: "connectors.consent", env: "STROM_CONNECTORS_CONSENT", tree: false, kind: "choice", choices: ["off", "on"], description: "off (default): connectors run without asking — paced by strom, only to their hosts; on: each needs the user's yes, in their terminal, to it and to each archive host" },
46
+ { key: "browser.downloads", env: "STROM_BROWSER_DOWNLOADS", tree: false, kind: "path", description: "the folder your browser saves downloads into (default: the system's Downloads folder) — strom takes a connector's images over from there" },
47
+ // Read from the config file only — no variable, no flag, no tree: nothing an agent can set.
48
+ { 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" },
49
+ { 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" },
50
+ { 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" },
51
+ { 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" },
52
+ ];
53
+ /** Fields of the stored settings whose key is not the field name. */
54
+ const FIELDS = {
55
+ "brief.budget": "briefBudget",
56
+ "run.minutes": "runMinutes",
57
+ "gedcom.for": "gedcomFor",
58
+ "strom.version": "stromVersion",
59
+ "queue.strategy": "queueStrategy",
60
+ "connectors.consent": "connectorsConsent",
61
+ "browser.downloads": "browserDownloads",
62
+ "agent.permissions": "agentPermissions",
63
+ "agent.where": "agentWhere",
64
+ "strom.app": "stromApp",
65
+ };
66
+ /** Environment variables that are not settings but steer strom. */
67
+ export const OTHER_ENV = [
68
+ { env: "STROM_TREE", description: "the tree to work on (a folder or a tree name)" },
69
+ { env: "STROM_CONFIG_DIR", description: "where the user config and seal keys live" },
70
+ { env: "STROM_NONINTERACTIVE", description: "1 = never ask, fail with needs-input instead" },
71
+ { env: "STROM_DOCUMENTS", description: "the Documents folder, where strom suggests its home (default: the system's)" },
72
+ { env: "STROM_APP_URL", description: "another copy of the Strom app to open (its development: http://127.0.0.1:8080/)" },
73
+ { env: "STROM_APP", description: "set by the Strom app when it starts strom (or an agent for it): strom then knows the app is there" },
74
+ ];
75
+ export function settingDef(key) {
76
+ const def = SETTINGS.find((s) => s.key === key);
77
+ if (!def)
78
+ throw new UsageError(`unknown setting "${key}"`, { hint: `settings: ${SETTINGS.map((s) => s.key).join(", ")}` });
79
+ return def;
80
+ }
81
+ export function configFile(env) {
82
+ return path.join(configDir(env), "config.json");
83
+ }
84
+ export function loadUserConfig(env) {
85
+ return readJsonIfExists(configFile(env)) ?? {};
86
+ }
87
+ export function saveUserConfig(env, cfg) {
88
+ writeJson(configFile(env), cfg);
89
+ }
90
+ function asPath(value, env) {
91
+ return path.resolve(expandHome(value, env));
92
+ }
93
+ /** Check and normalise a value for a setting; throws a UsageError with the allowed values. */
94
+ export function checkValue(def, raw, resolvePath) {
95
+ const v = raw.trim();
96
+ switch (def.kind) {
97
+ case "path":
98
+ return resolvePath(v);
99
+ case "lang": {
100
+ const code = v.toLowerCase();
101
+ if (!isValidLang(code))
102
+ throw new UsageError(`invalid language code "${raw}"`, { hint: "use a code like cs, en, de" });
103
+ return code;
104
+ }
105
+ case "agent": {
106
+ const id = v.toLowerCase();
107
+ if (!PROFILES[id])
108
+ throw new UsageError(`unknown agent "${raw}"`, { hint: Object.keys(PROFILES).join(", ") });
109
+ return id;
110
+ }
111
+ case "model":
112
+ if (!v)
113
+ throw new UsageError("the model name is empty");
114
+ return v;
115
+ case "choice": {
116
+ const c = def.key === "agent.permissions" ? (PERMISSION_ALIASES[v.toLowerCase()] ?? v.toLowerCase()) : v.toLowerCase();
117
+ if (!def.choices?.includes(c))
118
+ throw new UsageError(`invalid ${def.key} "${raw}"`, { hint: def.choices?.join(", ") });
119
+ return c;
120
+ }
121
+ case "version":
122
+ if (!/^\d+(\.\d+){0,2}$/.test(v))
123
+ throw new UsageError(`invalid ${def.key} "${raw}"`, { hint: "a version like 1.4.0" });
124
+ return v;
125
+ case "number": {
126
+ const n = Number(v);
127
+ if (!Number.isFinite(n) || n <= 0)
128
+ throw new UsageError(`${def.key} must be a positive number`);
129
+ return def.key === "brief.budget" ? Math.round(n) : n;
130
+ }
131
+ }
132
+ }
133
+ /** Where a setting is stored in the user config or in strom.json. */
134
+ function readStored(store, key, agent) {
135
+ if (!store)
136
+ return undefined;
137
+ if (key.startsWith("model."))
138
+ return store.models?.[agent]?.[key.slice(6)];
139
+ const v = store[FIELDS[key] ?? key];
140
+ return typeof v === "string" || typeof v === "number" ? v : undefined;
141
+ }
142
+ /** Write (or with undefined, remove) a setting in a store. */
143
+ export function writeStored(store, key, agent, value) {
144
+ const s = store;
145
+ if (key.startsWith("model.")) {
146
+ const tier = key.slice(6);
147
+ const models = (s.models ??= {});
148
+ const forAgent = (models[agent] ??= {});
149
+ if (value === undefined)
150
+ delete forAgent[tier];
151
+ else
152
+ forAgent[tier] = String(value);
153
+ if (Object.keys(forAgent).length === 0)
154
+ delete models[agent];
155
+ if (Object.keys(models).length === 0)
156
+ delete s.models;
157
+ return;
158
+ }
159
+ const field = FIELDS[key] ?? key;
160
+ if (value === undefined)
161
+ delete s[field];
162
+ else
163
+ s[field] = value;
164
+ }
165
+ export class Settings {
166
+ env;
167
+ flags;
168
+ config;
169
+ /** The config as it was read: save() writes only what changed since. */
170
+ baseline;
171
+ constructor(env, flags, config) {
172
+ this.env = env;
173
+ this.flags = flags;
174
+ this.config = config ?? loadUserConfig(env);
175
+ this.baseline = structuredClone(this.config);
176
+ }
177
+ /** Read the config file again (another strom process may have changed it). */
178
+ reload() {
179
+ this.replace(loadUserConfig(this.env));
180
+ }
181
+ /** Take these values in place: whoever holds `config` sees them. */
182
+ replace(next) {
183
+ const cur = this.config;
184
+ for (const k of Object.keys(cur))
185
+ delete cur[k];
186
+ Object.assign(cur, next);
187
+ this.baseline = structuredClone(this.config);
188
+ }
189
+ flagOf(key) {
190
+ if (key === "model.lead")
191
+ return this.flags.model;
192
+ if (key === "brief.budget")
193
+ return this.flags.budget;
194
+ if (key === "run.minutes")
195
+ return this.flags.minutes;
196
+ return this.flags[key];
197
+ }
198
+ /**
199
+ * Resolve any setting: flag > env > tree > user config. Undefined when none
200
+ * of them has it (the caller applies its default). Model keys resolve for
201
+ * `agent` (default: the resolved agent).
202
+ */
203
+ resolve(key, tree, agent) {
204
+ const def = settingDef(key);
205
+ if (key === "agent")
206
+ return this.agent(tree);
207
+ const who = agent ?? (def.kind === "model" ? this.agent(tree).value : "");
208
+ const check = (raw) => checkValue(def, raw, (p) => asPath(p, this.env));
209
+ const flag = this.flagOf(key);
210
+ if (flag)
211
+ return { value: check(flag), source: "flag" };
212
+ const env = def.env ? this.env[def.env] : undefined;
213
+ if (env)
214
+ return { value: check(env), source: "env" };
215
+ if (def.tree) {
216
+ const t = readStored(tree, key, who);
217
+ if (t !== undefined)
218
+ return { value: t, source: "tree" };
219
+ }
220
+ const u = readStored(this.config, key, who);
221
+ if (u !== undefined)
222
+ return { value: def.kind === "path" ? asPath(String(u), this.env) : u, source: "config" };
223
+ return undefined;
224
+ }
225
+ /** Home, or undefined when nothing is configured (the caller asks). */
226
+ home() {
227
+ return this.resolve("home");
228
+ }
229
+ suggestedHome() {
230
+ return defaultHome(this.env);
231
+ }
232
+ shared() {
233
+ const r = this.resolve("shared");
234
+ if (r)
235
+ return r;
236
+ const home = this.home();
237
+ return home ? { value: path.join(home.value, "shared"), source: "default" } : undefined;
238
+ }
239
+ trees() {
240
+ const r = this.resolve("trees");
241
+ if (r)
242
+ return r;
243
+ const home = this.home();
244
+ return home ? { value: home.value, source: "default" } : undefined;
245
+ }
246
+ /** Research language: flag > STROM_LANG > the tree > the user's default > detected from the system. */
247
+ lang(tree) {
248
+ return this.resolve("lang", tree) ?? { value: detectLang(this.env), source: "detected" };
249
+ }
250
+ /** Which agent does the research: flag > STROM_AGENT > tree > user > default. */
251
+ agent(tree) {
252
+ // Flags and env are not checked here: `strom run` says which agents it can
253
+ // drive (it also knows the test runner "script").
254
+ const r = this.agentAsSaid(tree);
255
+ // Gemini CLI ended for personal accounts (2026): Google's agent is Antigravity now.
256
+ return r.value === "gemini" ? { ...r, value: "antigravity" } : r;
257
+ }
258
+ agentAsSaid(tree) {
259
+ const f = this.flags.agent;
260
+ if (f)
261
+ return { value: f.toLowerCase(), source: "flag" };
262
+ const env = this.env.STROM_AGENT;
263
+ if (env)
264
+ return { value: env.toLowerCase(), source: "env" };
265
+ if (tree?.agent)
266
+ return { value: tree.agent, source: "tree" };
267
+ if (this.config.agent)
268
+ return { value: this.config.agent, source: "config" };
269
+ return { value: DEFAULT_AGENT, source: "default" };
270
+ }
271
+ /** Model per tier for an agent: profile defaults < user config < tree < env < flag. */
272
+ models(agent, tree) {
273
+ const out = { ...(PROFILES[agent]?.models ?? {}) };
274
+ for (const t of TIERS) {
275
+ const r = this.resolve(`model.${t}`, tree, agent);
276
+ if (r)
277
+ out[t] = String(r.value);
278
+ }
279
+ return out;
280
+ }
281
+ number(key, tree, fallback) {
282
+ const r = this.resolve(key, tree);
283
+ return r ? Number(r.value) : fallback;
284
+ }
285
+ /** How the task queue is ordered (core/queue.ts). */
286
+ strategy(tree) {
287
+ return String(this.resolve("queue.strategy", tree)?.value ?? "balanced");
288
+ }
289
+ /** The GEDCOM files to write: standard (any program), strom (the Strom app), or both. */
290
+ gedcomFor(tree) {
291
+ const v = String(this.resolve("gedcom.for", tree)?.value ?? "both");
292
+ return v === "both" ? ["standard", "strom"] : [v];
293
+ }
294
+ /** Does a connector need the user's consent before it runs? (Off unless they turned it on.) */
295
+ connectorsConsent() {
296
+ return this.resolve("connectors.consent")?.value === "on";
297
+ }
298
+ /** The folder the browser saves downloads into: the setting, else the system's Downloads folder. */
299
+ downloads() {
300
+ return String(this.resolve("browser.downloads")?.value ?? downloadsDir(this.env));
301
+ }
302
+ /** Where the user wants to talk with the agent, if they said (env, else the config); see whereToTalk. */
303
+ agentWhere() {
304
+ const r = this.resolve("agent.where");
305
+ return r ? String(r.value) : undefined;
306
+ }
307
+ /** What the agent may do without asking — from the config file alone, which only the user raises. */
308
+ agentPermissions() {
309
+ const v = this.config.agentPermissions ?? "auto";
310
+ return PERMISSION_ALIASES[v] ?? (PERMISSION_LEVELS.includes(v) ? v : "auto");
311
+ }
312
+ /** Stories of the ancestors: on unless the user said no; `said` — they chose (else they are to be told). */
313
+ stories(tree) {
314
+ const v = this.resolve("stories", tree)?.value;
315
+ return { on: v !== "no", said: v === "yes" || v === "no" };
316
+ }
317
+ stromVersion(tree) {
318
+ const r = this.resolve("strom.version", tree);
319
+ return r ? String(r.value) : undefined;
320
+ }
321
+ /**
322
+ * Write what this process changed — only that, onto the file as it is now:
323
+ * several strom processes run side by side (the menu, a conversation with an
324
+ * agent, a run) and none may wipe out what another saved meanwhile.
325
+ */
326
+ save() {
327
+ const file = configFile(this.env);
328
+ const release = acquireLock(`${file}.lock`, { owner: "strom config", waitMs: 10_000, staleMs: 60_000 });
329
+ try {
330
+ const fresh = loadUserConfig(this.env);
331
+ const mine = this.config;
332
+ const before = this.baseline;
333
+ for (const k of new Set([...Object.keys(before), ...Object.keys(mine)])) {
334
+ if (JSON.stringify(before[k]) === JSON.stringify(mine[k]))
335
+ continue;
336
+ if (mine[k] === undefined)
337
+ delete fresh[k];
338
+ else
339
+ fresh[k] = mine[k];
340
+ }
341
+ saveUserConfig(this.env, fresh);
342
+ this.replace(fresh);
343
+ }
344
+ finally {
345
+ release();
346
+ }
347
+ }
348
+ }