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,95 @@
1
+ // Agent profiles: how each agent CLI delegates work and which model does what.
2
+ //
3
+ // Tiers (lessons of earlier research, generalised):
4
+ // lead the main researcher — judgement, evidence, decisions
5
+ // vision reading handwriting and scans — never weaker than lead
6
+ // text print, catalogues, indexes, typed documents
7
+ // cheap mechanical work: downloads, renaming, counting
8
+ //
9
+ // Claude Code has native subagents with a model per call, so it delegates
10
+ // inside its session. Agents without model-selectable subagents read scans
11
+ // themselves in small batches (a reader started by strom is planned).
12
+ export const TIERS = ["lead", "vision", "text", "cheap"];
13
+ const DELEGATION_RULES = `- Browsing a book, an index or a range of images ("is our surname on this page?")
14
+ is delegated; so is anything self-contained that returns little.
15
+ - About ten images per delegate, never more than twelve: an image stays in the
16
+ context of whoever opened it and is paid for again on every turn.
17
+ - Every delegate gets the full question (what counts as a find, which years,
18
+ which names) and returns for each image: image and page, find or nothing,
19
+ what was illegible, the hand, and certainty per name — written as it goes.
20
+ - Delegates never write to the research. You record their findings through
21
+ strom; a negative result of a delegate is recorded as a search "by reader".
22
+ - Only entries that will get a citation need your own eyes.`;
23
+ /** For agents that cannot pick a model for a subagent (Codex, Antigravity, OpenCode): read yourself, in small batches. */
24
+ export const SELF_READING = `## Reading scans (Codex, Antigravity, OpenCode)
25
+
26
+ Read the images yourself, in batches of at most ten: open a batch, write down
27
+ what it gave (strom search add … for what was not found, facts for what was),
28
+ then open the next. Images stay in your context and are paid for on every turn,
29
+ so never keep more than one batch open.
30
+ `;
31
+ export const PROFILES = {
32
+ claude: {
33
+ id: "claude",
34
+ name: "Claude Code",
35
+ command: "claude",
36
+ exit: "/exit",
37
+ url: "https://claude.com/claude-code",
38
+ delegation: "native",
39
+ models: { lead: undefined, vision: "opus", text: "sonnet", cheap: "haiku" },
40
+ instructions: (m) => `## Delegating work (Claude Code)
41
+
42
+ Use the Agent tool for subagents; each has a clean context and only its answer
43
+ comes back to you. Choose the model by the kind of work:
44
+
45
+ | work | model |
46
+ |---|---|
47
+ | handwriting: registers, indexes, land books, any scan read closely | \`${m.vision ?? "opus"}\` — never cheaper |
48
+ | print and type: documents of the 20th century, catalogues, web pages, big text files | \`${m.text ?? "sonnet"}\` |
49
+ | mechanical: downloads, renaming, counting, a plain grep | \`${m.cheap ?? "haiku"}\` |
50
+ | judgement: identity, conflicts, which task next, writing to strom | nobody — you |
51
+
52
+ ${DELEGATION_RULES}
53
+ - Send independent batches in ONE message so they run in parallel.
54
+ - Do not read a subagent's transcript or output file — its images would come
55
+ into your context. Use only its final answer.
56
+ `,
57
+ },
58
+ codex: {
59
+ id: "codex",
60
+ name: "OpenAI Codex CLI",
61
+ command: "codex",
62
+ exit: "/quit",
63
+ url: "https://developers.openai.com/codex/cli",
64
+ delegation: "strom",
65
+ models: {},
66
+ instructions: () => SELF_READING,
67
+ },
68
+ antigravity: {
69
+ id: "antigravity",
70
+ name: "Antigravity CLI",
71
+ command: "agy",
72
+ exit: "/quit",
73
+ url: "https://antigravity.google/docs/cli",
74
+ delegation: "strom",
75
+ models: {},
76
+ instructions: () => SELF_READING,
77
+ },
78
+ opencode: {
79
+ id: "opencode",
80
+ name: "OpenCode",
81
+ command: "opencode",
82
+ exit: "/exit",
83
+ url: "https://opencode.ai",
84
+ delegation: "strom",
85
+ models: {},
86
+ instructions: () => SELF_READING,
87
+ },
88
+ };
89
+ export const DEFAULT_AGENT = "claude";
90
+ export function profile(id) {
91
+ const p = PROFILES[id];
92
+ if (!p)
93
+ throw new Error(`unknown agent "${id}"`);
94
+ return p;
95
+ }
@@ -0,0 +1,345 @@
1
+ // The brief: everything an agent needs for one task, and nothing more.
2
+ //
3
+ // In the old workflow the agent read the whole protocol at the start of a
4
+ // session and its context was summarised within minutes (median ~11 min).
5
+ // The brief has a hard token budget; sections come in priority order and
6
+ // what does not fit is cut to a pointer: the command that shows the rest.
7
+ import path from "node:path";
8
+ import { inboxFolders, inputPath } from "../core/media.js";
9
+ import { displayName, familiesAsChild, familiesAsPartner, formatName, label, lifespan, likelyDuplicates, parentsOf } from "../core/people.js";
10
+ import { langName } from "../core/lang.js";
11
+ import { methodFor } from "../core/assets.js";
12
+ import { recentSessions } from "../core/session.js";
13
+ import { calibrationLine } from "../core/calibration.js";
14
+ import { taskRecordsets } from "../core/frontier.js";
15
+ import { readyConnectors } from "../core/connector.js";
16
+ import { runs, shellArg } from "../cli/format.js";
17
+ import { foldText } from "../core/text.js";
18
+ import { subjectPeople } from "../core/records.js";
19
+ export { DEFAULT_BUDGET } from "../core/config.js";
20
+ import { DEFAULT_BUDGET } from "../core/config.js";
21
+ /** Rough token estimate: good enough for a soft budget. */
22
+ export function tokens(text) {
23
+ return Math.ceil(text.length / 3.5);
24
+ }
25
+ function eventLine(e) {
26
+ const cites = citesOf(e.citations);
27
+ return ` ${e.id} ${e.kind}${e.label ? ` ${e.label}` : ""}${e.date ? ` ${e.date}` : ""}${e.place ? ` ${e.place}` : ""}${e.house ? `, house ${e.house}` : ""}${e.value ? ` "${e.value}"` : ""} [${e.status}]${cites ? ` ← ${cites}` : ""}`;
28
+ }
29
+ function citesOf(citations) {
30
+ return (citations ?? []).map((c) => `${c.source}${c.locator ? ` ${c.locator}` : ""}`).join(", ");
31
+ }
32
+ function personBlock(tree, p, depth) {
33
+ const out = [` ${label(p)} ${p.sex}`];
34
+ // other names, and where any name comes from
35
+ if (p.names.length > 1 || p.names.some((n) => n.citations?.length))
36
+ out.push(` names: ${p.names.map((n) => `${formatName(n)}${n.kind ? ` (${n.kind})` : ""}${n.citations?.length ? ` ← ${citesOf(n.citations)}` : ""}`).join("; ")}`);
37
+ for (const e of p.events.filter((x) => !x.retracted))
38
+ out.push(eventLine(e));
39
+ const parents = parentsOf(tree, p.id);
40
+ const birthFamily = familiesAsChild(tree, p.id)[0];
41
+ out.push(` parents: ${parents.length ? parents.map(label).join(" & ") : "unknown"}${birthFamily?.citations?.length ? ` (${birthFamily.id} ← ${citesOf(birthFamily.citations)})` : ""}`);
42
+ if (depth > 0)
43
+ for (const f of familiesAsPartner(tree, p.id)) {
44
+ const partner = f.partners.filter((x) => x !== p.id).map((x) => tree.get(x)).filter(Boolean).map((x) => label(x));
45
+ const kids = f.children.map((c) => tree.get(c.person)).filter(Boolean).map((x) => `${displayName(x)}${lifespan(x) ? ` ${lifespan(x)}` : ""}`);
46
+ out.push(` ${f.id} with ${partner.join(", ") || "?"}${kids.length ? ` · children: ${kids.join(", ")}` : ""}`);
47
+ }
48
+ if (p.story)
49
+ out.push(` story: ${p.story.status}${p.story.title ? ` "${p.story.title}"` : ""}, ${p.story.text.split(/\s+/).length} words (strom story show ${p.id})`);
50
+ for (const n of p.notes.slice(-3))
51
+ out.push(` note: ${n.text}`);
52
+ return out;
53
+ }
54
+ /** How many images of a record set are registered, and how to look at them. */
55
+ function imagesLine(tree, b, shared) {
56
+ // each image once: its parts and other copies are the same image
57
+ const nums = [...new Set(tree
58
+ .list("media")
59
+ .filter((m) => m.recordset === b.id && m.image !== undefined)
60
+ .map((m) => m.image))]
61
+ .sort((x, y) => x - y);
62
+ if (nums.length === 0) {
63
+ const repo = b.repository ? tree.get(b.repository) : undefined;
64
+ const c = b.url && repo?.automation !== "forbidden" && repo?.automation !== "manual" ? readyConnectors(tree.env, shared, b.url)[0] : undefined;
65
+ if (c)
66
+ return ` no images here yet — connector ${c.name} fetches the ones you need: strom fetch ${c.name} <book> --images <from-to> --recordset ${b.id} (<book>: its ID on the portal — strom fetch ${c.name} --find "<place>" finds it)`;
67
+ if (b.url && repo?.automation !== "forbidden" && repo?.automation !== "manual")
68
+ return ` no images here yet, and no connector for this archive — build one now (strom connector new <name> --url <portal>, then its DISCOVERY.md; tell the user in a sentence), then strom fetch; the user saves them by hand only where the archive does not allow automation`;
69
+ return ` no images here yet — the user saves them by hand (never scrape an archive): strom task wait <T…> --images ${b.id}:<numbers> --on "<for the user, in their language: the book, its link, which images as its viewer counts them>", then take the next task`;
70
+ }
71
+ // Which ones exist, when there are gaps (a few runs), or just the span.
72
+ const list = runs(nums);
73
+ const which = list.split(", ").length <= 12 ? list : `${nums[0]}–${nums.at(-1)} with gaps (strom media list --recordset ${b.id})`;
74
+ return ` images registered (${nums.length}): ${which} · strom media view ${b.id}:<image> [--half left|right] [--grid] [--crop x,y,w,h]`;
75
+ }
76
+ /** What the user put in the shared inbox, waiting to be registered — by folder (one download each). */
77
+ function inboxLines(shared) {
78
+ if (!shared)
79
+ return { files: 0, lines: [] };
80
+ try {
81
+ const folders = inboxFolders(path.join(shared, "inbox"));
82
+ const name = (f) => path.basename(f);
83
+ return {
84
+ files: folders.reduce((n, f) => n + f.files.length, 0),
85
+ lines: folders.slice(0, 8).map(({ folder, files }) => {
86
+ const shown = files.length > 3 ? `${name(files[0])} … ${name(files.at(-1))}` : files.map(name).join(", ");
87
+ // a folder strom made for a waiting task names its record set
88
+ const mine = folder && /^[Bb]\d+(?=\s|$)/.test(folder) ? ` → strom media add --inbox ${shellArg(folder)}` : "";
89
+ return folder ? ` ${folder}/ (${files.length} file${files.length > 1 ? "s" : ""}): ${shown}${mine}` : ` ${shown}`;
90
+ }),
91
+ };
92
+ }
93
+ catch {
94
+ return { files: 0, lines: [] };
95
+ }
96
+ }
97
+ /** The archives already known, so registering a download needs no lookup. */
98
+ function repoHint(tree) {
99
+ const repos = tree.list("repository");
100
+ if (!repos.length)
101
+ return 'the archive first: strom repo add "<archive>" --country <CC> --url <its website>';
102
+ const shown = repos.slice(0, 4).map((r) => `${r.id} ${r.name}`).join(" · ");
103
+ return `archives: ${shown}${repos.length > 4 ? " …" : ""} (another: strom repo add "<archive>" --url …)`;
104
+ }
105
+ export function buildBrief(tree, opts) {
106
+ const budget = opts.budget ?? DEFAULT_BUDGET;
107
+ const task = opts.task;
108
+ const research = (task?.research ? tree.get(task.research) : undefined) ?? tree.list("research").find((r) => r.state === "active");
109
+ const lang = tree.lang;
110
+ const sections = [];
111
+ const focus = research ? tree.get(research.focus) : undefined;
112
+ // 1. who, where, rules — and what the research is for
113
+ sections.push({
114
+ name: "header",
115
+ required: true,
116
+ pointer: "",
117
+ text: [
118
+ `# Strom research session${opts.session ? ` ${opts.session.id}` : ""}`,
119
+ `Tree "${tree.config.name}"${research ? ` · research ${research.id} "${research.name}" (${research.direction} of ${focus ? label(focus) : research.focus})` : ""}.`,
120
+ research?.question ? `Research question: ${research.question}` : "",
121
+ ...(research?.notes ?? []).slice(-3).map((n) => `From the user: ${n.text}`),
122
+ `Research language: ${langName(lang)} — talk to the user and write notes, tasks and summaries in ${langName(lang)}; transcripts stay in the original language.`,
123
+ "Work ONLY through `strom` commands; never edit files in data/ (it is detected and blocks all writing). Record findings as you go.",
124
+ ].filter(Boolean).join("\n"),
125
+ });
126
+ // 2. the task
127
+ if (task) {
128
+ const where = task.where.map((w) => {
129
+ const b = /^B\d{4,}$/.test(w) ? tree.get(w) : undefined;
130
+ return b ? `${b.id} ${b.title}` : w;
131
+ });
132
+ sections.push({
133
+ name: "task",
134
+ required: true,
135
+ pointer: `strom task show ${task.id}`,
136
+ text: [
137
+ `## Task ${task.id} (${task.level}, priority ${task.priority})`,
138
+ `what: ${task.what}`,
139
+ `where: ${where.join("; ")}`,
140
+ `why: ${task.why}`,
141
+ `done: ${task.doneWhen}`,
142
+ task.subject.length ? `about: ${task.subject.join(" ")}` : "",
143
+ ...task.notes.slice(-3).map((n) => `note: ${n.text}`),
144
+ ].filter(Boolean).join("\n"),
145
+ });
146
+ // 2a. an imported tree: who in it is probably already in the tree
147
+ const treeInputs = [...new Set([...(task?.subject ?? []), ...(task?.where ?? [])])]
148
+ .map((id) => (/^I\d{4,}$/.test(id) ? tree.get(id) : undefined))
149
+ .filter((i) => !!i && i.type === "input" && i.kind === "tree" && !!i.sha);
150
+ for (const input of treeInputs) {
151
+ const system = `gedcom:${input.sha.slice(0, 12)}`;
152
+ const ids = tree.list("person").filter((p) => !p.retracted && p.refs?.some((r) => r.system === system)).map((p) => p.id);
153
+ const dupes = likelyDuplicates(tree, ids);
154
+ if (dupes.length)
155
+ sections.push({
156
+ name: "duplicates",
157
+ pointer: "strom person list",
158
+ text: [
159
+ `## Probably already in the tree (${input.id}) — look, then merge into the researched person`,
160
+ ...dupes.map((d) => ` ${label(d.person)} ≈ ${label(d.same)} → strom person merge ${d.same.id} ${d.person.id} --reason "…"`),
161
+ ].join("\n"),
162
+ });
163
+ }
164
+ // 2b. the material of an intake task, so it needs no extra command
165
+ const inputs = [...new Set([...(task?.subject ?? []), ...(task?.where ?? [])])]
166
+ .map((id) => (/^I\d{4,}$/.test(id) ? tree.get(id) : undefined))
167
+ .filter((i) => !!i && i.type === "input");
168
+ if (inputs.length)
169
+ sections.push({
170
+ name: "input",
171
+ pointer: `strom input show ${inputs[0].id}`,
172
+ text: [
173
+ "## The material",
174
+ ...inputs.map((i) => {
175
+ const file = inputPath(tree, i);
176
+ return [
177
+ `${i.id} ${i.name} [${i.kind} · ${i.state}]${i.from ? ` from ${i.from}` : ""}`,
178
+ file ? `file: ${file}${i.kind === "document" || i.kind === "photo" ? " — open it and read it yourself" : ""}` : "",
179
+ i.imported ? `imported: ${i.imported.persons} persons, ${i.imported.families} families as leads, cited as ${i.source}` : "",
180
+ i.text ? `text:\n${i.text}` : "",
181
+ ].filter(Boolean).join("\n");
182
+ }),
183
+ ].join("\n"),
184
+ });
185
+ }
186
+ else
187
+ sections.push({
188
+ name: "task",
189
+ required: true,
190
+ pointer: "strom task next",
191
+ text: "## No task\nThe queue is empty: look at the research (`strom research show`, `strom frontier`) and add tasks.",
192
+ });
193
+ // 3. premise: what was already searched there, and lessons for those places
194
+ const located = task ? taskRecordsets(tree, task) : { sets: [], guessed: false };
195
+ const where = new Set([...(task?.where ?? []), ...located.sets.map((b) => b.id)]);
196
+ // A task about a conflict or a hypothesis is about its people too.
197
+ const subjects = new Set([...(task?.subject ?? []), ...subjectPeople(tree, task?.subject ?? [])]);
198
+ const surnames = new Set([...subjects].map((id) => tree.get(id)).filter((p) => !!p && p.type === "person").flatMap((p) => p.names.map((n) => foldText(n.surname)).filter(Boolean)));
199
+ const searches = tree
200
+ .list("search")
201
+ .filter((s) => s.recordsets.some((b) => where.has(b)) || (s.task && s.task === task?.id) || (s.scope.surnames ?? []).some((x) => surnames.has(foldText(x))));
202
+ const repos = new Set([...where].map((w) => tree.get(w)?.repository).filter(Boolean));
203
+ const lessons = tree.list("lesson").filter((l) => (l.target && (where.has(l.target) || repos.has(l.target))) || l.scope === "project");
204
+ sections.push({
205
+ name: "premise",
206
+ pointer: `strom searched ${[...where].find((w) => w.startsWith("B")) ?? [...surnames][0] ?? "<where>"}`,
207
+ text: [
208
+ "## Already known (check the premise before searching)",
209
+ searches.length
210
+ ? searches.map((s) => ` ${s.id} [${s.result}] ${s.question}${s.scope.years ? ` · ${s.scope.years}` : ""}${s.scope.pages ? ` · pages ${s.scope.pages}` : ""} · ${s.recordsets.join(" ")}${s.by !== "main" ? ` (by ${s.by})` : ""}`).join("\n")
211
+ : " nothing searched yet for this task's record sets and surnames",
212
+ ...(lessons.length ? ["lessons:", ...lessons.map((l) => ` ${l.id}${l.target ? ` (${l.target})` : ""}: ${l.rule}`)] : []),
213
+ ].join("\n"),
214
+ });
215
+ // 4. handover from the previous sessions
216
+ const recent = recentSessions(tree, research?.id);
217
+ if (recent.length)
218
+ sections.push({
219
+ name: "handover",
220
+ pointer: "strom session list",
221
+ text: ["## Last sessions", ...recent.map((s) => ` ${s.id} ${s.state}${s.task ? ` on ${s.task}` : ""}: ${s.summary ?? "(no summary)"}${s.next ? `\n next: ${s.next}` : ""}`)].join("\n"),
222
+ });
223
+ // 5. the people concerned
224
+ const people = new Map();
225
+ const personSubjects = [...subjects].filter((id) => tree.get(id)?.type === "person");
226
+ for (const id of personSubjects.length ? personSubjects : research ? [research.focus] : []) {
227
+ people.set(id, 1);
228
+ for (const p of parentsOf(tree, id)) {
229
+ people.set(p.id, Math.max(people.get(p.id) ?? 0, 0));
230
+ for (const gp of parentsOf(tree, p.id))
231
+ if (!people.has(gp.id))
232
+ people.set(gp.id, 0);
233
+ }
234
+ for (const f of familiesAsChild(tree, id))
235
+ for (const s of f.children)
236
+ if (!people.has(s.person) && s.person !== id)
237
+ people.set(s.person, 0);
238
+ }
239
+ if (people.size) {
240
+ const blocks = [...people.entries()].map(([id, depth]) => {
241
+ const p = tree.get(id);
242
+ return p ? personBlock(tree, p, depth).join("\n") : "";
243
+ });
244
+ sections.push({ name: "people", pointer: `strom person show ${[...people.keys()][0]}`, text: ["## People concerned", ...blocks].join("\n") });
245
+ }
246
+ // 6. open conflicts and hypotheses about them
247
+ const ids = new Set(people.keys());
248
+ // the task's own conflicts and hypotheses come first, whatever their state
249
+ const own = (id) => subjects.has(id);
250
+ const conflicts = tree.list("conflict").filter((c) => own(c.id) || (c.state === "open" && c.subject.some((s) => ids.has(s))));
251
+ const hyps = tree.list("hypothesis").filter((h) => own(h.id) || (h.state === "open" && h.subject.some((s) => ids.has(s))));
252
+ const first = (list) => [...list.filter((x) => own(x.id)), ...list.filter((x) => !own(x.id))];
253
+ const mark = (id, state) => `${own(id) ? "→ " : " "}${id}${state === "open" ? "" : ` [${state}]`}`;
254
+ if (conflicts.length || hyps.length)
255
+ sections.push({
256
+ name: "open questions",
257
+ pointer: "strom conflict list · strom hypothesis list",
258
+ text: [
259
+ "## Open conflicts and hypotheses" + (conflicts.some((c) => own(c.id)) || hyps.some((h) => own(h.id)) ? " (→ this task is about it)" : ""),
260
+ ...first(conflicts).map((c) => `${mark(c.id, c.state)} ${c.title}: ${c.claims.map((x) => `${x.source ? `${x.source} ` : ""}${x.value}`).join(" | ")}${c.resolution ? ` — resolved: ${c.resolution}` : ""}`),
261
+ ...first(hyps).map((h) => `${mark(h.id, h.state)} ${h.question}: ${h.variants.map((v) => `${v.label}) ${v.claim}`).join("; ")}${h.decision ? ` — ${h.state}: ${h.decision}` : ""}`),
262
+ ].join("\n"),
263
+ });
264
+ // 7. the record sets to work in
265
+ const sets = located.sets;
266
+ if (sets.length)
267
+ sections.push({
268
+ name: "record sets",
269
+ pointer: `strom recordset show ${sets[0].id}`,
270
+ text: [
271
+ located.guessed
272
+ ? `## Record sets (the task names none; these cover it — point it at the right one: strom task edit ${task.id} --where ${sets[0].id})`
273
+ : "## Record sets",
274
+ ...sets.map((b) => {
275
+ const repo = b.repository ? tree.get(b.repository) : undefined;
276
+ return [
277
+ ` ${b.id} ${b.title}`,
278
+ ` ${[b.kinds.join(", "), b.places.join(", "), b.years].filter(Boolean).join(" · ")} · access ${b.access}${b.url ? ` · ${b.url}` : ""}`,
279
+ repo ? ` ${repo.name} · automated download: ${repo.automation}${repo.terms ? ` · terms: ${repo.terms}` : ""}` : "",
280
+ b.layout ? ` layout: ${b.layout}` : "",
281
+ calibrationLine(b) ? ` ${calibrationLine(b)}` : "",
282
+ imagesLine(tree, b, opts.shared),
283
+ ].filter(Boolean).join("\n");
284
+ }),
285
+ ].join("\n"),
286
+ });
287
+ // 7b. what the user put in the shared inbox — scans or documents nobody registered yet
288
+ const inbox = inboxLines(opts.shared);
289
+ if (inbox.files)
290
+ sections.push({
291
+ name: "inbox",
292
+ pointer: "strom media add --inbox <folder> --recordset B…",
293
+ text: [
294
+ `## Waiting in the inbox (${inbox.files} file${inbox.files > 1 ? "s" : ""} from the user)`,
295
+ ...inbox.lines,
296
+ " one folder = one download = one record set. Register the book, then its scans:",
297
+ ` ${repoHint(tree)}`,
298
+ ' strom recordset add "<title>" --repo R… --call-number <sig> --kinds baptism --places <places> --years <from-to>',
299
+ ' strom media add --inbox "<folder>" --recordset B… · a document (certificate, letter): strom intake <file>',
300
+ ].join("\n"),
301
+ });
302
+ // 8. method for this kind of task
303
+ sections.push({ name: "method", pointer: "strom guide", text: methodFor(task?.level) });
304
+ // 9. how to finish
305
+ sections.push({
306
+ name: "closing",
307
+ required: true,
308
+ pointer: "",
309
+ text: [
310
+ "## Finishing",
311
+ task ? `- close the task: strom task done ${task.id} --result "…" (a complete negative search is a result) — or task park / task wait` : "",
312
+ "- new questions → strom task add … (what, where, why, done-when)",
313
+ '- then: strom session close --summary "what was proven, what was searched in vain" --next "the next cheapest step"',
314
+ `- if the task is not finished: strom session close --continue --summary "…" --next "exactly where you stopped"`,
315
+ "- a command you need: strom help <command> (short, with examples) — the full guide: strom guide",
316
+ ].filter(Boolean).join("\n"),
317
+ });
318
+ // Assemble within the budget: required sections always, others in order.
319
+ let used = sections.filter((s) => s.required).reduce((n, s) => n + tokens(s.text), 0);
320
+ const report = [];
321
+ const parts = [];
322
+ for (const s of sections) {
323
+ const t = tokens(s.text);
324
+ if (s.required || used + t <= budget) {
325
+ if (!s.required)
326
+ used += t;
327
+ parts.push(s.text);
328
+ report.push({ name: s.name, tokens: t, cut: false });
329
+ continue;
330
+ }
331
+ // Keep as many whole lines as fit, then the pointer.
332
+ const lines = s.text.split("\n");
333
+ const kept = [];
334
+ for (const l of lines) {
335
+ if (used + tokens(l) + 20 > budget)
336
+ break;
337
+ kept.push(l);
338
+ used += tokens(l);
339
+ }
340
+ parts.push([...kept, ` … cut to fit the brief — see: ${s.pointer}`].join("\n"));
341
+ report.push({ name: s.name, tokens: tokens(kept.join("\n")), cut: true });
342
+ }
343
+ const text = parts.join("\n\n") + "\n";
344
+ return { text, sections: report, total: tokens(text), budget };
345
+ }
@@ -0,0 +1,44 @@
1
+ // Automatic commit after a writing command. Git is invisible to the user:
2
+ // every change is committed by strom, behind the check + guard gate.
3
+ import { hasErrors } from "../core/check.js";
4
+ import { guard } from "../core/guard.js";
5
+ import { snapshot, verifyFast } from "../core/integrity.js";
6
+ export function commitMessage(summaries, command) {
7
+ const unique = [...new Set(summaries)];
8
+ const subject = unique.length === 1 ? unique[0] : `${command}: ${unique.length} changes`;
9
+ const body = unique.length > 1 ? "\n\n" + unique.map((s) => `- ${s}`).join("\n") : "";
10
+ return subject + body;
11
+ }
12
+ /** Commit the changes of this command. Returns what blocked the commit, if anything did. */
13
+ export function autoCommit(ctx, def) {
14
+ const tree = ctx.current();
15
+ if (!tree || tree.dryRun || tree.written.length === 0)
16
+ return undefined;
17
+ // Incremental gate: records were validated when written; here only what
18
+ // changed since the last commit is checked, so the cost stays flat.
19
+ const snap = snapshot(tree);
20
+ const findings = [...verifyFast(tree, snap).findings, ...guard(tree, snap)];
21
+ if (hasErrors(findings)) {
22
+ const errors = findings.filter((f) => f.level === "error");
23
+ return {
24
+ message: [
25
+ `error: written but NOT committed — ${errors.length} problem(s) in the data:`,
26
+ ...errors.slice(0, 5).map((f) => ` ${f.id ?? f.file ?? ""} ${f.message}`),
27
+ "→ strom check",
28
+ ].join("\n"),
29
+ problems: errors.map((f) => ({ ...(f.id ? { id: f.id } : {}), ...(f.file ? { file: f.file } : {}), message: f.message })),
30
+ };
31
+ }
32
+ const message = commitMessage(tree.written.map((o) => o.summary), `strom ${def.path.join(" ")}`);
33
+ // Only the files this command wrote, plus the free-form folders the agent
34
+ // may edit — `git add` then never has to scan the whole data/ tree.
35
+ const written = new Set(["data/_counters.json"]);
36
+ for (const op of tree.written)
37
+ for (const f of op.files)
38
+ written.add(f.path);
39
+ for (const f of tree.opsFilesTouched())
40
+ written.add(f);
41
+ tree.withTreeLock(() => tree.commit(message, [...written, "notes", "tools", "inputs", "output"]));
42
+ tree.settle();
43
+ return undefined;
44
+ }