strom-research 1.13.2-beta.2 → 1.13.2-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/lang/cs.json +20 -0
- package/assets/lang/de.json +20 -0
- package/assets/method/core.md +3 -1
- package/assets/method/intake.md +11 -8
- package/assets/method/link.md +2 -1
- package/assets/method/reading.md +23 -5
- package/assets/method/recording.md +4 -3
- package/assets/method/verify.md +2 -1
- package/dist/agents/files.js +6 -4
- package/dist/agents/images.js +54 -0
- package/dist/agents/models.js +248 -0
- package/dist/agents/profiles.js +16 -6
- package/dist/brief/brief.js +265 -47
- package/dist/brief/sheet.js +65 -0
- package/dist/cli/context.js +3 -1
- package/dist/cli/execute.js +58 -6
- package/dist/cli/help.js +9 -1
- package/dist/cli/main.js +32 -2
- package/dist/cli/menu-links.js +3 -2
- package/dist/cli/menu-settings.js +40 -1
- package/dist/cli/model-choice.js +35 -0
- package/dist/cli/registry.js +20 -10
- package/dist/cli/ui.js +17 -0
- package/dist/cli/wizard.js +20 -10
- package/dist/commands/analysis.js +90 -193
- package/dist/commands/batch.js +95 -16
- package/dist/commands/browse.js +2 -1
- package/dist/commands/clips.js +6 -3
- package/dist/commands/connectors.js +38 -11
- package/dist/commands/guide.js +6 -3
- package/dist/commands/index.js +2 -0
- package/dist/commands/media.js +336 -53
- package/dist/commands/meta.js +1 -1
- package/dist/commands/people.js +5 -4
- package/dist/commands/read.js +86 -23
- package/dist/commands/readers.js +13 -3
- package/dist/commands/readings.js +265 -0
- package/dist/commands/research.js +9 -4
- package/dist/commands/session.js +83 -24
- package/dist/commands/setup.js +17 -2
- package/dist/commands/show.js +108 -0
- package/dist/commands/sources.js +40 -13
- package/dist/commands/start.js +5 -3
- package/dist/commands/tasks.js +85 -36
- package/dist/commands/transcripts.js +3 -0
- package/dist/core/actions.js +118 -11
- package/dist/core/age.js +4 -2
- package/dist/core/batches.js +3 -2
- package/dist/core/changelog.js +1 -1
- package/dist/core/check.js +10 -1
- package/dist/core/config.js +20 -1
- package/dist/core/connector.js +5 -0
- package/dist/core/edge.js +7 -8
- package/dist/core/errors.js +7 -0
- package/dist/core/frontier.js +3 -2
- package/dist/core/gdate.js +31 -0
- package/dist/core/git.js +10 -1
- package/dist/core/hypolinks.js +509 -0
- package/dist/core/integrity.js +15 -0
- package/dist/core/live.js +188 -47
- package/dist/core/mediaindex.js +107 -0
- package/dist/core/mode.js +11 -2
- package/dist/core/model.js +1 -0
- package/dist/core/net.js +62 -9
- package/dist/core/overview.js +13 -3
- package/dist/core/people.js +112 -1
- package/dist/core/phrases.js +3 -0
- package/dist/core/queue.js +3 -2
- package/dist/core/reader.js +96 -14
- package/dist/core/recent.js +95 -0
- package/dist/core/records.js +2 -2
- package/dist/core/review.js +2 -1
- package/dist/core/schema.js +2 -2
- package/dist/core/session.js +144 -28
- package/dist/core/text.js +31 -0
- package/dist/core/tidy.js +14 -0
- package/dist/core/tree.js +126 -15
- package/dist/core/views.js +53 -11
- package/dist/core/years.js +42 -0
- package/dist/gedcom/export.js +5 -4
- package/dist/runners/antigravity.js +119 -51
- package/dist/runners/claude.js +48 -5
- package/dist/runners/codex.js +9 -2
- package/dist/runners/jsonl.js +2 -1
- package/dist/runners/runner.js +8 -0
- package/dist/runners/script.js +8 -2
- package/package.json +1 -1
package/dist/agents/profiles.js
CHANGED
|
@@ -12,8 +12,11 @@
|
|
|
12
12
|
export const TIERS = ["lead", "vision", "text", "cheap"];
|
|
13
13
|
const DELEGATION_RULES = `- Browsing a book, an index or a range of images ("is our surname on this page?")
|
|
14
14
|
is delegated; so is anything self-contained that returns little.
|
|
15
|
-
- About ten images per delegate, never more than twelve
|
|
16
|
-
|
|
15
|
+
- About ten scans (images B…:n) per delegate, never more than twelve; it opens
|
|
16
|
+
as many halves and crops of them as reading needs, but tell it to stop at
|
|
17
|
+
about 80 views and return what it has: every view stays in its context to
|
|
18
|
+
the end. It asks for the views of a scan (or of a few) in one call —
|
|
19
|
+
\`strom media view B0001:57-60 --half both\` — and opens them together.
|
|
17
20
|
- Every delegate gets the full question (what counts as a find, which years,
|
|
18
21
|
which names) and returns for each image: image and page, find or nothing,
|
|
19
22
|
what was illegible, the hand, and certainty per name — written as it goes.
|
|
@@ -25,13 +28,20 @@ export const SELF_READING = `## Reading scans (Codex, Antigravity, OpenCode, Gro
|
|
|
25
28
|
|
|
26
29
|
Read the images yourself, with your strongest model — never hand old
|
|
27
30
|
handwriting to a faster or cheaper model, subagent or pass. Read them
|
|
28
|
-
in batches of at most ten: open a batch
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
in batches of at most ten: open a batch (one call gives its views —
|
|
32
|
+
\`strom media view B0001:57-66 --half both\` — open them together), write
|
|
33
|
+
down what it gave (strom search add … for what was not found, facts for what
|
|
34
|
+
was), then open the next. Every image and view stays in your context to the end: after about 80
|
|
35
|
+
views, write down what you found and go on. A name or place the next search depends on:
|
|
32
36
|
read it again on a crop at full resolution (\`strom media view … --crop\`)
|
|
33
37
|
before you record it.
|
|
34
38
|
`;
|
|
39
|
+
/**
|
|
40
|
+
* The agents strom can start working alone without the user's personal add-ons (agent.addons): Claude Code
|
|
41
|
+
* (--strict-mcp-config, --tools, no auto-memory) and Codex (its plugins, apps, hooks and memories off). The others have
|
|
42
|
+
* no such switch — nothing is made up for them, and the setting is not offered.
|
|
43
|
+
*/
|
|
44
|
+
export const ADDON_SWITCHES = ["claude", "codex"];
|
|
35
45
|
export const PROFILES = {
|
|
36
46
|
claude: {
|
|
37
47
|
id: "claude",
|
package/dist/brief/brief.js
CHANGED
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
// session and its context was summarised within minutes (median ~11 min).
|
|
5
5
|
// The brief has a hard token budget; sections come in priority order and
|
|
6
6
|
// what does not fit is cut to a pointer: the command that shows the rest.
|
|
7
|
+
import fs from "node:fs";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
import { inboxFolders, inputPath } from "../core/media.js";
|
|
9
|
-
import { displayName, familiesAsChild, familiesAsPartner, formatName, label, lifespan, likelyDuplicates, parentsOf } from "../core/people.js";
|
|
10
|
+
import { displayName, familiesAsChild, familiesAsPartner, formatName, label, lifespan, likelyDuplicates, parentsOf, sameFamilyName, surnameForms } from "../core/people.js";
|
|
11
|
+
import { parseYears } from "../core/years.js";
|
|
10
12
|
import { langName } from "../core/lang.js";
|
|
13
|
+
import { dateYears } from "../core/gdate.js";
|
|
11
14
|
import { methodFor } from "../core/assets.js";
|
|
12
15
|
import { recentSessions } from "../core/session.js";
|
|
13
16
|
import { briefClock } from "../core/clock.js";
|
|
@@ -16,14 +19,24 @@ import { taskRecordsets } from "../core/frontier.js";
|
|
|
16
19
|
import { reviewItems } from "../core/review.js";
|
|
17
20
|
import { readyConnectors } from "../core/connector.js";
|
|
18
21
|
import { runs, shellArg } from "../cli/format.js";
|
|
22
|
+
import { commandSheet } from "./sheet.js";
|
|
19
23
|
import { foldText } from "../core/text.js";
|
|
20
24
|
import { subjectPeople } from "../core/records.js";
|
|
21
25
|
import { hypothesisPeople } from "../core/directions.js";
|
|
26
|
+
import { imagesIndex } from "../core/mediaindex.js";
|
|
22
27
|
export { DEFAULT_BUDGET } from "../core/config.js";
|
|
23
28
|
import { DEFAULT_BUDGET } from "../core/config.js";
|
|
24
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Estimated tokens, on the safe side. A word of plain ASCII (English, IDs, numbers) takes about 2.5 characters a
|
|
31
|
+
* token; a word with any other letter — a diacritic, another script — about one, the whole word (measured on the
|
|
32
|
+
* briefs and outputs of real sessions: a Czech research's brief is 1.7–1.8 characters a token, not 3.5).
|
|
33
|
+
*/
|
|
25
34
|
export function tokens(text) {
|
|
26
|
-
|
|
35
|
+
let other = 0;
|
|
36
|
+
for (const m of text.matchAll(/\S+/gu))
|
|
37
|
+
if (/[^\x00-\x7F]/u.test(m[0]))
|
|
38
|
+
other += m[0].length;
|
|
39
|
+
return Math.ceil((text.length - other) / 2.5 + other);
|
|
27
40
|
}
|
|
28
41
|
function eventLine(e) {
|
|
29
42
|
const cites = citesOf(e.citations);
|
|
@@ -52,16 +65,122 @@ function personBlock(tree, p, depth) {
|
|
|
52
65
|
out.push(` story: ${p.story.status}${p.story.title ? ` "${p.story.title}"` : ""}, ${p.story.text.split(/\s+/).length} words (strom story show ${p.id})`);
|
|
53
66
|
for (const n of p.notes.slice(-3))
|
|
54
67
|
out.push(` note: ${n.text}`);
|
|
68
|
+
if (p.notes.length > 3)
|
|
69
|
+
out.push(` (${p.notes.length - 3} older note${p.notes.length > 4 ? "s" : ""}: strom person show ${p.id})`);
|
|
55
70
|
return out;
|
|
56
71
|
}
|
|
72
|
+
const VITAL = new Set(["BIRT", "CHR", "BAPM", "DEAT", "BURI", "CREM"]);
|
|
73
|
+
const NOTE_SHOWN = 300;
|
|
74
|
+
/** Where a person lived and what they did, the years with each: what tells namesakes apart. */
|
|
75
|
+
function identifying(p, vitalToo) {
|
|
76
|
+
const facts = p.events.filter((e) => !e.retracted && !VITAL.has(e.kind));
|
|
77
|
+
const grouped = (list) => {
|
|
78
|
+
const by = new Map();
|
|
79
|
+
for (const [what, date] of list) {
|
|
80
|
+
const years = by.get(what) ?? new Set();
|
|
81
|
+
for (const y of dateYears(date ?? ""))
|
|
82
|
+
years.add(y);
|
|
83
|
+
by.set(what, years);
|
|
84
|
+
}
|
|
85
|
+
// the years as a span: when, not every record of it
|
|
86
|
+
return [...by].map(([what, years]) => {
|
|
87
|
+
const y = [...years].sort((a, b) => a - b);
|
|
88
|
+
return `${what}${y.length ? ` ${y.length > 1 ? `${y[0]}–${y.at(-1)}` : y[0]}` : ""}`;
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
// where they lived: a house in a place, or the place of a residence
|
|
92
|
+
const lived = (e) => !!e.house || (e.kind === "RESI" && !!e.place);
|
|
93
|
+
const houses = grouped((vitalToo ? p.events.filter((e) => !e.retracted) : facts).filter(lived).map((e) => [[e.place, e.house].filter(Boolean).join(" "), e.date]));
|
|
94
|
+
const work = grouped(facts.filter((e) => e.kind === "OCCU" && e.value).map((e) => [e.value, e.date]));
|
|
95
|
+
const more = facts.filter((e) => !lived(e) && !(e.kind === "OCCU" && e.value)).length;
|
|
96
|
+
return { houses, work, more };
|
|
97
|
+
}
|
|
98
|
+
/** A parent of the task's person: the vital facts (sources by ID), where they lived and what they did, the parents, the last notes cut short. */
|
|
99
|
+
function relativeBlock(tree, p, role) {
|
|
100
|
+
const out = [` ${label(p)} ${p.sex} — ${role}`];
|
|
101
|
+
for (const e of p.events.filter((x) => !x.retracted && VITAL.has(x.kind)))
|
|
102
|
+
out.push(` ${e.id} ${e.kind}${e.date ? ` ${e.date}` : ""}${e.place ? ` ${e.place}` : ""}${e.house ? `, house ${e.house}` : ""} [${e.status}]${e.citations?.length ? ` ← ${[...new Set(e.citations.map((c) => c.source))].join(", ")}` : ""}`);
|
|
103
|
+
const { houses, work, more } = identifying(p, false);
|
|
104
|
+
if (houses.length || work.length || more)
|
|
105
|
+
out.push(` ${[houses.length ? `lived: ${houses.join("; ")}` : "", work.length ? `occupation: ${work.join("; ")}` : "", more ? `${more} more fact${more > 1 ? "s" : ""}` : ""].filter(Boolean).join(" · ")}`);
|
|
106
|
+
const parents = parentsOf(tree, p.id);
|
|
107
|
+
out.push(` parents: ${parents.length ? parents.map(label).join(" & ") : "unknown"}`);
|
|
108
|
+
for (const n of p.notes.slice(-2))
|
|
109
|
+
out.push(` note: ${[...n.text].length > NOTE_SHOWN ? `${[...n.text].slice(0, NOTE_SHOWN).join("")}…` : n.text}`);
|
|
110
|
+
if (p.notes.length > 2)
|
|
111
|
+
out.push(` (${p.notes.length - 2} older note${p.notes.length > 3 ? "s" : ""}: strom person show ${p.id})`);
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
/** Anyone else concerned, in one line: who they are to the task's people, where they lived, what they did, their parents. */
|
|
115
|
+
function relativeLine(tree, p, role, withParents) {
|
|
116
|
+
const { houses, work } = identifying(p, true);
|
|
117
|
+
const parents = withParents ? parentsOf(tree, p.id) : [];
|
|
118
|
+
return ` · ${label(p)} ${p.sex} — ${[role, houses.length ? `lived: ${houses.join("; ")}` : "", work.length ? `occupation: ${work.join("; ")}` : "", parents.length ? `parents: ${parents.map((x) => x.id).join(" & ")}` : ""].filter(Boolean).join(" · ")}`;
|
|
119
|
+
}
|
|
120
|
+
/** A small text file of an input, as strom input show gives it; unreadable: nothing. */
|
|
121
|
+
function readSmall(file) {
|
|
122
|
+
try {
|
|
123
|
+
return fs.readFileSync(file, "utf8").trim();
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Words of a text, folded (any script; NFC or NFD alike). */
|
|
130
|
+
function foldedWords(text) {
|
|
131
|
+
return foldText(text.normalize("NFC")).split(/[^\p{L}\p{M}\p{N}]+/u).filter(Boolean);
|
|
132
|
+
}
|
|
133
|
+
/** The part of a name its forms share ("Lhoty" → lhot, "Dvořák" → dvorak), long enough to mean it, else none. */
|
|
134
|
+
function stem(name) {
|
|
135
|
+
const s = foldText(name.normalize("NFC")).replace(/[aeiouyаеёиоуыэюяієї]+$/u, "");
|
|
136
|
+
return [...s].length >= 4 ? s : undefined;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Which lessons of the whole project (no record set, archive or place of their own) go with a task: one naming a
|
|
140
|
+
* family or a place of this tree goes with a task of that family or place (its text, its people, their facts, its
|
|
141
|
+
* books); one naming none of them is general and goes with every task. A name told in a lesson's own words
|
|
142
|
+
* ("Dvořákovi", "u Lhoty") is known by its stem; one not recognised keeps the lesson in — never a lesson lost.
|
|
143
|
+
*/
|
|
144
|
+
function projectLessonFit(tree, task, persons, where) {
|
|
145
|
+
const known = new Set();
|
|
146
|
+
const add = (n) => {
|
|
147
|
+
if (!n)
|
|
148
|
+
return;
|
|
149
|
+
const first = n.trim().split(/[\s,]+/u)[0] ?? "";
|
|
150
|
+
for (const f of [first, ...surnameForms(first)]) {
|
|
151
|
+
const s = stem(f);
|
|
152
|
+
if (s)
|
|
153
|
+
known.add(s);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
for (const p of tree.list("person"))
|
|
157
|
+
if (!p.retracted)
|
|
158
|
+
for (const n of p.names)
|
|
159
|
+
add(n.surname);
|
|
160
|
+
for (const pl of tree.list("place"))
|
|
161
|
+
for (const n of pl.names)
|
|
162
|
+
add(n.name);
|
|
163
|
+
for (const b of tree.list("recordset"))
|
|
164
|
+
for (const n of b.places ?? [])
|
|
165
|
+
add(n);
|
|
166
|
+
const books = where.map((w) => tree.get(w)).filter((b) => !!b && b.type === "recordset");
|
|
167
|
+
const context = new Set(foldedWords([
|
|
168
|
+
task ? [task.what, task.why, task.doneWhen, ...task.where, ...task.notes.map((n) => n.text)].join(" ") : "",
|
|
169
|
+
...books.flatMap((b) => [b.title, ...(b.places ?? [])]),
|
|
170
|
+
...persons.flatMap((p) => [...p.names.map((n) => `${n.given} ${n.surname}`), ...p.events.map((e) => e.place ?? "")]),
|
|
171
|
+
...persons.flatMap((p) => parentsOf(tree, p.id).flatMap((x) => x.names.map((n) => n.surname))),
|
|
172
|
+
].join(" ")));
|
|
173
|
+
return (l) => {
|
|
174
|
+
// the names it gives: words written with a capital that are a family or a place of this tree
|
|
175
|
+
const names = l.rule.normalize("NFC").split(/[^\p{L}\p{M}\p{N}]+/u).filter((w) => /^\p{Lu}/u.test(w)).map((w) => foldText(w));
|
|
176
|
+
const its = [...known].filter((k) => names.some((w) => w.startsWith(k)));
|
|
177
|
+
return its.length === 0 || its.some((k) => [...context].some((w) => w.startsWith(k)));
|
|
178
|
+
};
|
|
179
|
+
}
|
|
57
180
|
/** How many images of a record set are registered, and how to look at them. */
|
|
58
181
|
function imagesLine(tree, b, shared) {
|
|
59
182
|
// each image once: its parts and other copies are the same image
|
|
60
|
-
const nums =
|
|
61
|
-
.list("media")
|
|
62
|
-
.filter((m) => m.recordset === b.id && m.image !== undefined)
|
|
63
|
-
.map((m) => m.image))]
|
|
64
|
-
.sort((x, y) => x - y);
|
|
183
|
+
const nums = imagesIndex(tree).sets.get(b.id)?.images ?? [];
|
|
65
184
|
if (nums.length === 0) {
|
|
66
185
|
const repo = b.repository ? tree.get(b.repository) : undefined;
|
|
67
186
|
const c = b.url && repo?.automation !== "forbidden" && repo?.automation !== "manual" ? readyConnectors(tree.env, shared, b.url)[0] : undefined;
|
|
@@ -74,7 +193,7 @@ function imagesLine(tree, b, shared) {
|
|
|
74
193
|
// Which ones exist, when there are gaps (a few runs), or just the span.
|
|
75
194
|
const list = runs(nums);
|
|
76
195
|
const which = list.split(", ").length <= 12 ? list : `${nums[0]}–${nums.at(-1)} with gaps (strom media list --recordset ${b.id})`;
|
|
77
|
-
return ` images registered (${nums.length}): ${which} · strom media view ${b.id}:<image> [--half left|right] [--grid] [--crop x,y,w,h]`;
|
|
196
|
+
return ` images registered (${nums.length}): ${which} · strom media view ${b.id}:<image>[-<image>] [--half left|right|both] [--grid] [--crop x,y,w,h]`;
|
|
78
197
|
}
|
|
79
198
|
/** What the user put in the shared inbox, waiting to be registered — by folder (one download each). */
|
|
80
199
|
function inboxLines(shared) {
|
|
@@ -145,6 +264,7 @@ export function buildBrief(tree, opts) {
|
|
|
145
264
|
`done: ${task.doneWhen}`,
|
|
146
265
|
task.subject.length ? `about: ${task.subject.join(" ")}` : "",
|
|
147
266
|
...task.notes.slice(-3).map((n) => `note: ${n.text}`),
|
|
267
|
+
task.notes.length > 3 ? `(notes: the last 3 of ${task.notes.length} — all: strom task show ${task.id})` : "",
|
|
148
268
|
].filter(Boolean).join("\n"),
|
|
149
269
|
});
|
|
150
270
|
// 2. a review: what each item of the task is, as the tree has it
|
|
@@ -180,11 +300,17 @@ export function buildBrief(tree, opts) {
|
|
|
180
300
|
"## The material",
|
|
181
301
|
...inputs.map((i) => {
|
|
182
302
|
const file = inputPath(tree, i);
|
|
303
|
+
// all that strom input show says of it, so it needs no extra command (K6)
|
|
304
|
+
const small = !i.text && i.kind === "text" && file && (i.size ?? 0) < 20_000 && /\.(txt|md|csv)$/i.test(file) ? readSmall(file) : undefined;
|
|
183
305
|
return [
|
|
184
|
-
`${i.id} ${i.name} [${i.kind} · ${i.state}]${i.from ? ` from ${i.from}` : ""}`,
|
|
306
|
+
`${i.id} ${i.name} [${i.kind} · ${i.state}]${i.from ? ` from ${i.from}` : ""}${i.mime ? ` · ${i.mime}${i.size !== undefined ? ` ${Math.round(i.size / 1024)} kB` : ""}` : ""}`,
|
|
185
307
|
file ? `file: ${file}${i.kind === "document" || i.kind === "photo" ? " — open it and read it yourself" : ""}` : "",
|
|
308
|
+
i.batch ? `batch: ${i.batch}${i.path ? ` · ${i.path}` : ""}` : "",
|
|
309
|
+
i.persons?.length ? `of: ${i.persons.join(", ")}` : "",
|
|
310
|
+
i.sorted ? `sorted: ${i.sorted.as}${i.sorted.source ? ` → ${i.sorted.source}` : ""}${i.sorted.reason ? ` (${i.sorted.reason})` : ""}` : "",
|
|
186
311
|
i.imported ? `imported: ${i.imported.persons} persons, ${i.imported.families} families as leads, cited as ${i.source}` : "",
|
|
187
|
-
i.
|
|
312
|
+
...i.notes.map((n) => `note: ${n.text}`),
|
|
313
|
+
i.text ? `text:\n${i.text}` : small ? `text:\n${small}` : "",
|
|
188
314
|
].filter(Boolean).join("\n");
|
|
189
315
|
}),
|
|
190
316
|
].join("\n"),
|
|
@@ -202,21 +328,67 @@ export function buildBrief(tree, opts) {
|
|
|
202
328
|
const where = new Set([...(task?.where ?? []), ...located.sets.map((b) => b.id)]);
|
|
203
329
|
// A task about a conflict or a hypothesis is about its people too.
|
|
204
330
|
const subjects = new Set([...(task?.subject ?? []), ...subjectPeople(tree, task?.subject ?? [])]);
|
|
205
|
-
const
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
.filter((s) => s.recordsets.some((b) => where.has(b)) || (s.task && s.task === task?.id) || (s.scope.surnames ?? []).some((x) => surnames.has(foldText(x))));
|
|
331
|
+
const persons = [...subjects].map((id) => tree.get(id)).filter((p) => !!p && p.type === "person");
|
|
332
|
+
const theirs = persons.flatMap((p) => p.names.map((n) => n.surname).filter(Boolean));
|
|
333
|
+
const surnames = new Set(theirs.map((n) => foldText(n)));
|
|
209
334
|
const repos = new Set([...where].map((w) => tree.get(w)?.repository).filter(Boolean));
|
|
210
|
-
|
|
335
|
+
// a lesson on a place (L…) goes with the books of that place — or of the parish it belonged to
|
|
336
|
+
const bookPlaces = new Set([...where].flatMap((w) => tree.get(w)?.places ?? []).map((x) => foldText(x)));
|
|
337
|
+
// Where and when the task is: its books' places and years, its people's places and years (±30) — a search of their
|
|
338
|
+
// surname in other places and other times is only counted (K11).
|
|
339
|
+
const placeKey = (x) => foldText(x.split(",")[0].trim());
|
|
340
|
+
const taskPlaces = new Set([...bookPlaces].map(placeKey));
|
|
341
|
+
for (const p of persons)
|
|
342
|
+
for (const e of p.events)
|
|
343
|
+
if (!e.retracted && e.place)
|
|
344
|
+
taskPlaces.add(placeKey(e.place));
|
|
345
|
+
const taskYears = [
|
|
346
|
+
...[...where].flatMap((w) => { const y = parseYears(tree.get(w)?.years); return y ? [y.from, y.to] : []; }),
|
|
347
|
+
...persons.flatMap((p) => p.events.filter((e) => !e.retracted).flatMap((e) => dateYears(e.date ?? ""))),
|
|
348
|
+
];
|
|
349
|
+
const span = taskYears.length ? { from: Math.min(...taskYears) - 30, to: Math.max(...taskYears) + 30 } : undefined;
|
|
350
|
+
const nearby = (s) => {
|
|
351
|
+
const books = s.recordsets.map((b) => tree.get(b)).filter((b) => !!b);
|
|
352
|
+
const spans = [s.scope.years, ...(s.scope.years ? [] : books.map((b) => b.years))].map((y) => parseYears(y)).filter((y) => !!y);
|
|
353
|
+
const places = [...(s.scope.places ?? []), ...books.flatMap((b) => b.places)].map(placeKey);
|
|
354
|
+
if (places.some((x) => taskPlaces.has(x)))
|
|
355
|
+
return true;
|
|
356
|
+
if (span && spans.length)
|
|
357
|
+
return spans.some((y) => y.from <= span.to && span.from <= y.to);
|
|
358
|
+
// nothing to tell it by: kept
|
|
359
|
+
return !places.length || (!span && !taskPlaces.size);
|
|
360
|
+
};
|
|
361
|
+
const named = (s) => (s.scope.surnames ?? []).some((x) => theirs.some((y) => sameFamilyName(x, y)));
|
|
362
|
+
const premise = tree.list("search").filter((s) => s.recordsets.some((b) => where.has(b)) || (s.task && s.task === task?.id) || named(s));
|
|
363
|
+
const searches = premise.filter((s) => s.recordsets.some((b) => where.has(b)) || (s.task && s.task === task?.id) || nearby(s));
|
|
364
|
+
const elsewhere = premise.filter((s) => !searches.includes(s));
|
|
365
|
+
const elsewhereNames = [...new Set(elsewhere.flatMap((s) => theirs.filter((y) => (s.scope.surnames ?? []).some((x) => sameFamilyName(x, y)))))];
|
|
366
|
+
const ofPlace = (id) => {
|
|
367
|
+
const pl = tree.get(id);
|
|
368
|
+
return pl?.type === "place" && [...pl.names.map((n) => n.name), ...pl.jurisdictions.map((j) => j.name)].some((n) => [...bookPlaces].some((b) => b === foldText(n) || b.startsWith(`${foldText(n)},`)));
|
|
369
|
+
};
|
|
370
|
+
const all = tree.list("lesson").filter((l) => !l.retracted);
|
|
371
|
+
// a lesson of the whole project goes with the task whose families or places it names — or names none of the tree's
|
|
372
|
+
const project = all.filter((l) => l.scope === "project" && !l.target);
|
|
373
|
+
const fits = project.length ? projectLessonFit(tree, task, persons, [...where]) : () => true;
|
|
374
|
+
const lessons = all.filter((l) => (l.target && (where.has(l.target) || repos.has(l.target) || ofPlace(l.target))) || (l.scope === "project" && (l.target || fits(l))));
|
|
375
|
+
const otherProject = project.filter((l) => !lessons.includes(l)).length;
|
|
376
|
+
const method = all.filter((l) => l.scope === "method").length;
|
|
211
377
|
sections.push({
|
|
212
378
|
name: "premise",
|
|
213
|
-
|
|
379
|
+
// shown before the people, but given its room after them, the last sessions and the open questions: a long
|
|
380
|
+
// list of searches never pushes out whom the task is about (P3)
|
|
381
|
+
last: true,
|
|
382
|
+
pointer: `strom searched ${[...where].find((w) => w.startsWith("B")) ?? [...surnames][0] ?? "<where>"}${lessons.length ? " · lessons: strom lesson list" : ""}`,
|
|
214
383
|
text: [
|
|
215
384
|
"## Already known (check the premise before searching)",
|
|
216
385
|
searches.length
|
|
217
386
|
? 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")
|
|
218
387
|
: " nothing searched yet for this task's record sets and surnames",
|
|
388
|
+
...(elsewhere.length ? [` +${elsewhere.length} search${elsewhere.length > 1 ? "es" : ""} of ${elsewhereNames.join(", ")} in other places and years: ${elsewhereNames.map((n) => `strom searched ${shellArg(n)}`).join(" · ")}`] : []),
|
|
219
389
|
...(lessons.length ? ["lessons:", ...lessons.map((l) => ` ${l.id}${l.target ? ` (${l.target})` : ""}: ${l.rule}`)] : []),
|
|
390
|
+
...(otherProject ? [`lessons about other families and places: ${otherProject} — strom lesson list --scope project`] : []),
|
|
391
|
+
...(method ? [`method lessons of this research: ${method} — strom lesson list --scope method`] : []),
|
|
220
392
|
].join("\n"),
|
|
221
393
|
});
|
|
222
394
|
// 4. handover from the previous sessions
|
|
@@ -227,28 +399,48 @@ export function buildBrief(tree, opts) {
|
|
|
227
399
|
pointer: "strom session list",
|
|
228
400
|
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"),
|
|
229
401
|
});
|
|
230
|
-
// 5. the people concerned
|
|
402
|
+
// 5. the people concerned: whom the task is about in full, their parents with what identifies them (facts, houses,
|
|
403
|
+
// occupations, parents), grandparents and brothers and sisters one line each — the rest is a command away
|
|
231
404
|
const people = new Map();
|
|
232
405
|
const personSubjects = [...subjects].filter((id) => tree.get(id)?.type === "person");
|
|
233
|
-
|
|
234
|
-
|
|
406
|
+
// no person named: the research's focus, to know whose tree it is
|
|
407
|
+
const about = personSubjects.length ? personSubjects : research ? [research.focus] : [];
|
|
408
|
+
for (const id of about)
|
|
409
|
+
people.set(id, { how: "full", role: "" });
|
|
410
|
+
for (const id of about) {
|
|
235
411
|
for (const p of parentsOf(tree, id)) {
|
|
236
|
-
|
|
412
|
+
if (!people.has(p.id))
|
|
413
|
+
people.set(p.id, { how: personSubjects.length ? "parent" : "line", role: `${p.sex === "F" ? "mother" : p.sex === "M" ? "father" : "parent"} of ${id}` });
|
|
237
414
|
for (const gp of parentsOf(tree, p.id))
|
|
238
415
|
if (!people.has(gp.id))
|
|
239
|
-
people.set(gp.id,
|
|
416
|
+
people.set(gp.id, { how: "line", role: `${gp.sex === "F" ? "mother" : gp.sex === "M" ? "father" : "parent"} of ${p.id}` });
|
|
240
417
|
}
|
|
241
418
|
for (const f of familiesAsChild(tree, id))
|
|
242
419
|
for (const s of f.children)
|
|
243
420
|
if (!people.has(s.person) && s.person !== id)
|
|
244
|
-
people.set(s.person,
|
|
421
|
+
people.set(s.person, { how: "line", role: `sibling of ${id}` });
|
|
245
422
|
}
|
|
246
423
|
if (people.size) {
|
|
247
|
-
const blocks = [...people.entries()].
|
|
424
|
+
const blocks = [...people.entries()].flatMap(([id, { how, role }]) => {
|
|
248
425
|
const p = tree.get(id);
|
|
249
|
-
|
|
426
|
+
if (!p)
|
|
427
|
+
return [];
|
|
428
|
+
if (how === "full")
|
|
429
|
+
return [personBlock(tree, p, 1).join("\n")];
|
|
430
|
+
if (how === "parent")
|
|
431
|
+
return [relativeBlock(tree, p, role).join("\n")];
|
|
432
|
+
return [relativeLine(tree, p, role, !role.startsWith("sibling"))];
|
|
433
|
+
});
|
|
434
|
+
const short = [...people.values()].some((x) => x.how !== "full");
|
|
435
|
+
sections.push({
|
|
436
|
+
name: "people",
|
|
437
|
+
pointer: `strom person show ${[...people.keys()][0]}`,
|
|
438
|
+
text: [
|
|
439
|
+
"## People concerned",
|
|
440
|
+
...blocks,
|
|
441
|
+
...(short ? [" (others in short — all facts, sources and notes: strom person show P… · their life with its records: strom person card P…)"] : []),
|
|
442
|
+
].join("\n"),
|
|
250
443
|
});
|
|
251
|
-
sections.push({ name: "people", pointer: `strom person show ${[...people.keys()][0]}`, text: ["## People concerned", ...blocks].join("\n") });
|
|
252
444
|
}
|
|
253
445
|
// 6. open conflicts and hypotheses about them
|
|
254
446
|
const ids = new Set(people.keys());
|
|
@@ -273,6 +465,8 @@ export function buildBrief(tree, opts) {
|
|
|
273
465
|
if (sets.length)
|
|
274
466
|
sections.push({
|
|
275
467
|
name: "record sets",
|
|
468
|
+
// the books to work in, their images and calibration: kept before the long lists when the brief is full
|
|
469
|
+
first: true,
|
|
276
470
|
pointer: `strom recordset show ${sets[0].id}`,
|
|
277
471
|
text: [
|
|
278
472
|
located.guessed
|
|
@@ -307,7 +501,12 @@ export function buildBrief(tree, opts) {
|
|
|
307
501
|
].join("\n"),
|
|
308
502
|
});
|
|
309
503
|
// 8. method for this kind of task
|
|
310
|
-
|
|
504
|
+
// (budgeted first, with the record sets and the commands: when the brief is long, the lists are cut before how to work)
|
|
505
|
+
sections.push({ name: "method", first: true, pointer: "strom guide", text: methodFor(task?.level) });
|
|
506
|
+
// 8b. the commands this kind of task uses, with every option and limit (K2): asking for them costs a turn each
|
|
507
|
+
const sheet = commandSheet(task?.level);
|
|
508
|
+
if (sheet)
|
|
509
|
+
sections.push({ name: "commands", first: true, pointer: "strom help <command>", text: sheet });
|
|
311
510
|
// 9. how to finish
|
|
312
511
|
sections.push({
|
|
313
512
|
name: "closing",
|
|
@@ -315,37 +514,56 @@ export function buildBrief(tree, opts) {
|
|
|
315
514
|
pointer: "",
|
|
316
515
|
text: [
|
|
317
516
|
"## Finishing",
|
|
318
|
-
task ? `- close the task: strom task done ${task.id} --result "…" (a complete negative search is a result) — or task park / task wait` : "",
|
|
319
517
|
"- new questions → strom task add … (what, where, why, done-when)",
|
|
320
|
-
|
|
518
|
+
task
|
|
519
|
+
? '- done: strom session close --done "what was proven, what was searched in vain" --next "the next cheapest step" (the task and the session in one call; a complete negative search is a result)'
|
|
520
|
+
: '- then: strom session close --summary "what was proven, what was searched in vain" --next "the next cheapest step"',
|
|
521
|
+
task ? `- put aside: strom task park|wait ${task.id} …, then strom session close --summary "…" --next "…"` : "",
|
|
321
522
|
`- if the task is not finished: strom session close --continue --summary "…" --next "exactly where you stopped"`,
|
|
322
523
|
"- a command you need: strom help <command> (short, with examples) — the full guide: strom guide",
|
|
323
524
|
].filter(Boolean).join("\n"),
|
|
324
525
|
});
|
|
325
|
-
// Assemble within the budget: required sections always, others in
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
|
|
526
|
+
// Assemble within the budget: required sections always, then the first ones whole when they fit, then the others in
|
|
527
|
+
// order, the last ones after them; each written where it stands. Everything written counts: the blank line between
|
|
528
|
+
// sections, the pointer of a section cut.
|
|
529
|
+
const SEP = tokens("\n\n");
|
|
530
|
+
let used = sections.filter((s) => s.required).reduce((n, s) => n + tokens(s.text) + SEP, 0);
|
|
531
|
+
const shown = new Map();
|
|
532
|
+
for (const s of sections.filter((x) => x.first && !x.required))
|
|
533
|
+
if (used + tokens(s.text) + SEP <= budget) {
|
|
534
|
+
used += tokens(s.text) + SEP;
|
|
535
|
+
shown.set(s, { text: s.text, cut: false });
|
|
536
|
+
}
|
|
537
|
+
const fit = (s) => {
|
|
330
538
|
const t = tokens(s.text);
|
|
331
|
-
if (s.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
report.push({ name: s.name, tokens: t, cut: false });
|
|
336
|
-
continue;
|
|
539
|
+
if (!s.first && used + t + SEP <= budget) {
|
|
540
|
+
used += t + SEP;
|
|
541
|
+
shown.set(s, { text: s.text, cut: false });
|
|
542
|
+
return;
|
|
337
543
|
}
|
|
338
|
-
//
|
|
339
|
-
const
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
544
|
+
// Its heading always (the agent knows what it was not given), as many whole lines as fit, then the pointer.
|
|
545
|
+
const [head = "", ...rest] = s.text.split("\n");
|
|
546
|
+
const pointer = ` … cut to fit the brief — see: ${s.pointer}`;
|
|
547
|
+
const kept = [head];
|
|
548
|
+
used += tokens(head) + tokens(pointer) + SEP;
|
|
549
|
+
for (const l of rest) {
|
|
550
|
+
if (used + tokens(l) > budget)
|
|
343
551
|
break;
|
|
344
552
|
kept.push(l);
|
|
345
553
|
used += tokens(l);
|
|
346
554
|
}
|
|
347
|
-
|
|
348
|
-
|
|
555
|
+
shown.set(s, { text: [...kept, pointer].join("\n"), cut: true });
|
|
556
|
+
};
|
|
557
|
+
// the others in order, a first one that did not fit whole cut after them, the last ones after all
|
|
558
|
+
const pending = sections.filter((s) => !s.required && !shown.has(s));
|
|
559
|
+
for (const s of [...pending.filter((x) => !x.first && !x.last), ...pending.filter((x) => x.first), ...pending.filter((x) => x.last && !x.first)])
|
|
560
|
+
fit(s);
|
|
561
|
+
const report = [];
|
|
562
|
+
const parts = [];
|
|
563
|
+
for (const s of sections) {
|
|
564
|
+
const r = s.required ? { text: s.text, cut: false } : shown.get(s);
|
|
565
|
+
parts.push(r.text);
|
|
566
|
+
report.push({ name: s.name, tokens: tokens(r.text), cut: r.cut });
|
|
349
567
|
}
|
|
350
568
|
const text = parts.join("\n\n") + "\n";
|
|
351
569
|
return { text, sections: report, total: tokens(text), budget };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The commands a task of each level uses, with their exact options and limits — so the agent need not ask
|
|
2
|
+
// `strom help` for them in every session (K2/P5). Everything shown comes from the command registry (arguments,
|
|
3
|
+
// options, the values an option takes) and from the record schemas (how long a text may be): nothing is written
|
|
4
|
+
// twice by hand. Only which commands go with which level is chosen here.
|
|
5
|
+
import { commands } from "../cli/registry.js";
|
|
6
|
+
import { SCHEMAS } from "../core/schema.js";
|
|
7
|
+
import { NOTE_MAX } from "../core/model.js";
|
|
8
|
+
const ALL = ["search add", "task add", "note add", "lesson add"];
|
|
9
|
+
const SCANS = ["media view", "read", "fetch"];
|
|
10
|
+
const LEVELS = {
|
|
11
|
+
link: [...ALL, ...SCANS, "hypothesis add", "hypothesis argue", "conflict add", "task wait"],
|
|
12
|
+
verify: [...ALL, ...SCANS, "conflict add", "hypothesis add", "hypothesis argue"],
|
|
13
|
+
enrich: [...ALL, ...SCANS, "hypothesis add", "task wait"],
|
|
14
|
+
locate: [...ALL, "recordset add", "fetch", "task wait"],
|
|
15
|
+
intake: [...ALL, "input sort", "input skip", "media view", "conflict add"],
|
|
16
|
+
request: [...ALL, "task wait", "source add"],
|
|
17
|
+
narrate: [...ALL, "story set"],
|
|
18
|
+
};
|
|
19
|
+
const camel = (name) => name.replace(/-(\p{L})/gu, (_, c) => c.toUpperCase());
|
|
20
|
+
/** How long a text may be: a note's, or from the schema of the record an "add" writes ("search add <question>" → search.question). */
|
|
21
|
+
function limit(def, name) {
|
|
22
|
+
if (name === "note" || (def.path[0] === "note" && name === "text"))
|
|
23
|
+
return NOTE_MAX;
|
|
24
|
+
if (def.path[1] !== "add")
|
|
25
|
+
return undefined;
|
|
26
|
+
const fields = SCHEMAS[def.path[0]];
|
|
27
|
+
const spec = fields?.[camel(name)];
|
|
28
|
+
return spec?.t === "string" ? spec.max : undefined;
|
|
29
|
+
}
|
|
30
|
+
/** The values an option takes when its description is just their list ("found, negative, partial"), else its placeholder. */
|
|
31
|
+
function value(def, o) {
|
|
32
|
+
if (o.type === "boolean")
|
|
33
|
+
return "";
|
|
34
|
+
const words = o.description.replace(/\s*\(default\)/gu, "").trim().split(/,\s*/u);
|
|
35
|
+
const max = limit(def, o.name);
|
|
36
|
+
if (words.length > 1 && words.every((w) => /^[\p{L}\p{N}-]+$/u.test(w)))
|
|
37
|
+
return ` ${words.join("|")}`;
|
|
38
|
+
const v = o.value ?? "<value>";
|
|
39
|
+
return ` ${max ? v.replace(/>$/u, ` ≤${max}>`) : v}`;
|
|
40
|
+
}
|
|
41
|
+
function arg(def, a) {
|
|
42
|
+
const max = limit(def, a.name);
|
|
43
|
+
const name = `${a.name}${max ? ` ≤${max}` : ""}`;
|
|
44
|
+
return a.required ? `<${name}>${a.variadic ? "…" : ""}` : `[${name}]${a.variadic ? "…" : ""}`;
|
|
45
|
+
}
|
|
46
|
+
/** One command in one line: its arguments and every option, as the registry declares them. */
|
|
47
|
+
export function synopsis(def) {
|
|
48
|
+
const opts = (def.options ?? []).filter((o) => !o.hidden);
|
|
49
|
+
// what an option needs besides, as its description says it ("required with --result found")
|
|
50
|
+
const needs = (o) => /required with (--[\p{L}\p{N}-]+(?: [\p{L}\p{N}-]+)?)/u.exec(o.description)?.[1];
|
|
51
|
+
return ["strom", ...def.path, ...(def.args ?? []).map((a) => arg(def, a)), ...opts.map((o) => `--${o.name}${value(def, o)}${o.multiple ? "…" : ""}${needs(o) ? ` (with ${needs(o)})` : ""}`)].join(" ");
|
|
52
|
+
}
|
|
53
|
+
/** The section of the brief: the commands of this level, or nothing (no level, or no registry — a unit test). */
|
|
54
|
+
export function commandSheet(level) {
|
|
55
|
+
const names = level ? LEVELS[level] : undefined;
|
|
56
|
+
if (!names)
|
|
57
|
+
return "";
|
|
58
|
+
const defs = names.map((n) => commands().find((c) => c.path.join(" ") === n)).filter((d) => !!d);
|
|
59
|
+
if (!defs.length)
|
|
60
|
+
return "";
|
|
61
|
+
return [
|
|
62
|
+
"## Commands for this task (every option; ≤n: at most n characters; … repeatable — more: strom help <command>)",
|
|
63
|
+
...defs.map((d) => ` ${synopsis(d)}`),
|
|
64
|
+
].join("\n");
|
|
65
|
+
}
|
package/dist/cli/context.js
CHANGED
|
@@ -29,6 +29,8 @@ export class Context {
|
|
|
29
29
|
limit;
|
|
30
30
|
page;
|
|
31
31
|
dryRun = false;
|
|
32
|
+
/** The record IDs the command names (T…, N…, G…): which of an agent's sessions in several researches is its. */
|
|
33
|
+
refs = [];
|
|
32
34
|
opened;
|
|
33
35
|
/**
|
|
34
36
|
* The backup before another channel or an older version could not be made (cli/backups.ts): no research is brought
|
|
@@ -417,7 +419,7 @@ export class Context {
|
|
|
417
419
|
// Inside an open session every write is logged under the session's ID;
|
|
418
420
|
// an agent writing outside a session is logged as "agent", never as the user.
|
|
419
421
|
// What the bridge writes for the Strom app (STROM_FOR_APP) is the person's in the app: the user's.
|
|
420
|
-
const session = this.env.STROM_FOR_APP === "1" ? undefined : currentSession(this.opened, this.env);
|
|
422
|
+
const session = this.env.STROM_FOR_APP === "1" ? undefined : currentSession(this.opened, this.env, this.refs);
|
|
421
423
|
if (session)
|
|
422
424
|
this.opened.actor = session.id;
|
|
423
425
|
else if (isAgent(this.env) && this.env.STROM_FOR_APP !== "1")
|