strom-research 1.4.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/lang/cs.json +160 -1
- package/assets/lang/de.json +160 -1
- package/assets/method/narrate.md +2 -1
- package/assets/method/recording.md +3 -1
- package/dist/agents/files.js +8 -1
- package/dist/cli/human.js +18 -0
- package/dist/cli/main.js +9 -0
- package/dist/cli/menu-links.js +249 -0
- package/dist/cli/menu-parts.js +6 -3
- package/dist/cli/menu-research.js +185 -7
- package/dist/cli/menu-settings.js +17 -3
- package/dist/cli/menu-waiting.js +61 -47
- package/dist/cli/menu.js +3 -2
- package/dist/cli/ui.js +159 -1
- package/dist/cli/wizard.js +34 -1
- package/dist/commands/analysis.js +6 -2
- package/dist/commands/browse.js +5 -19
- package/dist/commands/checks.js +7 -1
- package/dist/commands/guide.js +15 -1
- package/dist/commands/index.js +2 -0
- package/dist/commands/intake.js +3 -0
- package/dist/commands/links.js +367 -0
- package/dist/commands/output.js +7 -1
- package/dist/commands/people.js +58 -1
- package/dist/commands/research.js +113 -24
- package/dist/commands/setup.js +13 -2
- package/dist/commands/start.js +6 -2
- package/dist/commands/story.js +17 -1
- package/dist/commands/sync.js +243 -0
- package/dist/core/actions.js +15 -0
- package/dist/core/config.js +5 -0
- package/dist/core/dialog.js +25 -0
- package/dist/core/excerpt.js +53 -5
- package/dist/core/frontier.js +10 -1
- package/dist/core/links.js +357 -0
- package/dist/core/live.js +156 -7
- package/dist/core/phrases.js +1 -0
- package/dist/core/review.js +43 -5
- package/dist/core/schema.js +1 -0
- package/dist/core/self.js +17 -0
- package/dist/core/session.js +6 -0
- package/dist/core/stromapp.js +38 -0
- package/dist/core/sync.js +876 -0
- package/dist/core/tree.js +1 -1
- package/dist/core/uninstall.js +3 -0
- package/dist/core/validate.js +4 -0
- package/dist/gedcom/export.js +66 -1
- package/dist/gedcom/validate.js +13 -4
- package/package.json +1 -1
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// What the Strom app's menus ask through strom-research:// links (the app's
|
|
2
|
+
// second wave): a task put aside, given up or back in the queue, the agent's
|
|
3
|
+
// sessions and what they cost, a conflict decided, a story approved, a sending
|
|
4
|
+
// taken back — and a tree of the app that becomes a new research. Each is said
|
|
5
|
+
// in the research's terminal and done on the person's word, as the menu does.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { outOfAnswers, translator } from "./menu-parts.js";
|
|
9
|
+
import { offerChat } from "./menu-research.js";
|
|
10
|
+
import { humanCost, humanDay, humanTask } from "./human.js";
|
|
11
|
+
import { lines, truncate } from "./format.js";
|
|
12
|
+
import { Tree } from "../core/tree.js";
|
|
13
|
+
import { monthSpend } from "../core/session.js";
|
|
14
|
+
import { adoptFailedSince, adoptedSince, awaitAdoption, nothingSince } from "../core/sync.js";
|
|
15
|
+
import { startLive } from "../core/live.js";
|
|
16
|
+
import { adoptAppUrl } from "../core/stromapp.js";
|
|
17
|
+
import { isValidLang } from "../core/lang.js";
|
|
18
|
+
import { agentsHere } from "../core/apps.js";
|
|
19
|
+
import { PROFILES } from "../agents/profiles.js";
|
|
20
|
+
import { foldText, safeFolderName } from "../core/text.js";
|
|
21
|
+
import { StromError } from "../core/errors.js";
|
|
22
|
+
/** What the Strom app asks, said before anything is done; true when the person goes on. */
|
|
23
|
+
export async function asks(ctx, lang, root, what, note) {
|
|
24
|
+
const name = Tree.open(root, ctx.env).config.name;
|
|
25
|
+
ctx.io.stdout(lines(translator(lang)("ui.link.asks", { what, name }), note, "") + "\n");
|
|
26
|
+
return ctx.confirm(translator(lang)("ui.link.go"), true);
|
|
27
|
+
}
|
|
28
|
+
const out = (ctx, line) => ctx.io.stdout(line + "\n");
|
|
29
|
+
/** What stopped it, said; nothing was done. */
|
|
30
|
+
function stop(ctx, line) {
|
|
31
|
+
out(ctx, line);
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
/** A task put aside, given up (each with the person's reason) or back in the queue. */
|
|
35
|
+
export async function taskDo(ctx, run, lang, root, id, what) {
|
|
36
|
+
const t = translator(lang);
|
|
37
|
+
const tree = Tree.open(root, ctx.env);
|
|
38
|
+
const task = tree.get(id);
|
|
39
|
+
if (!task || task.retracted)
|
|
40
|
+
return stop(ctx, t("ui.link.task.none", { task: id }));
|
|
41
|
+
const can = { park: ["open"], drop: ["open", "parked", "waiting"], wake: ["parked", "waiting"] }[what];
|
|
42
|
+
if (!can.includes(task.state))
|
|
43
|
+
return stop(ctx, t("ui.link.task.cannot"));
|
|
44
|
+
if (!(await asks(ctx, lang, root, t(`ui.link.what.task.${what}`, { what: humanTask(tree, task.what, lang) }))))
|
|
45
|
+
return false;
|
|
46
|
+
let reason = [];
|
|
47
|
+
if (what !== "wake") {
|
|
48
|
+
const why = (await ctx.ask(t("ui.link.task.why"), t(`ui.link.task.${what}.default`))).trim();
|
|
49
|
+
if (why === "0")
|
|
50
|
+
return false;
|
|
51
|
+
reason = [`--reason=${why || t(`ui.link.task.${what}.default`)}`];
|
|
52
|
+
}
|
|
53
|
+
if ((await run(["task", what, id, ...reason], true)) !== 0)
|
|
54
|
+
return false;
|
|
55
|
+
out(ctx, t(`ui.link.task.${what}.done`));
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
/** The agent's sessions, the latest first, and what this month's cost. */
|
|
59
|
+
export function sessionsView(ctx, lang, root) {
|
|
60
|
+
const t = translator(lang);
|
|
61
|
+
const tree = Tree.open(root, ctx.env);
|
|
62
|
+
const all = tree.list("session").sort((a, b) => b.started.localeCompare(a.started));
|
|
63
|
+
out(ctx, t("ui.link.sessions.title", { name: tree.config.name }));
|
|
64
|
+
if (!all.length)
|
|
65
|
+
return void out(ctx, ` ${t("ui.link.sessions.none")}`);
|
|
66
|
+
const month = monthSpend(tree, new Date().toISOString().slice(0, 7));
|
|
67
|
+
out(ctx, t("ui.link.sessions.month", { n: month.sessions, cost: humanCost(month.amount, lang) }));
|
|
68
|
+
out(ctx, "");
|
|
69
|
+
const shown = all.slice(0, 15);
|
|
70
|
+
for (const s of shown) {
|
|
71
|
+
const task = s.task ? tree.get(s.task) : undefined;
|
|
72
|
+
const agent = PROFILES[s.agent ?? ""]?.name ?? s.agent ?? "";
|
|
73
|
+
const what = truncate(humanTask(tree, task?.what ?? s.summary ?? "", lang), 80);
|
|
74
|
+
const cost = s.metrics?.costUsd !== undefined ? `${humanCost(s.metrics.costUsd, lang)}${s.metrics.costPartial ? "+" : ""}` : "";
|
|
75
|
+
out(ctx, ` ${humanDay(s.started, lang)} ${[agent, what, cost].filter(Boolean).join(" · ")}`);
|
|
76
|
+
}
|
|
77
|
+
if (all.length > shown.length)
|
|
78
|
+
out(ctx, ` ${t("ui.link.sessions.more", { n: all.length - shown.length })}`);
|
|
79
|
+
}
|
|
80
|
+
/** A conflict the person decides: which claim holds (or their own words), and why. */
|
|
81
|
+
export async function decideConflict(ctx, run, lang, root, id) {
|
|
82
|
+
const t = translator(lang);
|
|
83
|
+
const tree = Tree.open(root, ctx.env);
|
|
84
|
+
const c = tree.get(id);
|
|
85
|
+
if (!c || c.type !== "conflict")
|
|
86
|
+
return stop(ctx, t("ui.link.conflict.none", { id }));
|
|
87
|
+
if (c.state === "resolved")
|
|
88
|
+
return stop(ctx, t("ui.link.conflict.decided", { resolution: c.resolution ?? "" }));
|
|
89
|
+
if (!(await asks(ctx, lang, root, t("ui.link.what.conflict.decide", { title: c.title }))))
|
|
90
|
+
return false;
|
|
91
|
+
const claim = (x) => `${x.value}${x.source ? ` — ${tree.get(x.source)?.title ?? x.source}` : ""}`;
|
|
92
|
+
const i = await ctx.choose(t("ui.link.conflict.pick"), [...c.claims.map((x) => ({ label: claim(x) })), { label: t("ui.link.conflict.other") }], 0, { back: t("ui.browse.back") });
|
|
93
|
+
if (i === undefined)
|
|
94
|
+
return false;
|
|
95
|
+
let resolution = i < c.claims.length ? `${c.claims[i].value}${c.claims[i].source ? ` (${c.claims[i].source})` : ""}` : "";
|
|
96
|
+
if (!resolution) {
|
|
97
|
+
resolution = (await ctx.ask(t("ui.link.conflict.own"))).trim();
|
|
98
|
+
if (!resolution || resolution === "0")
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
let why = "";
|
|
102
|
+
while (!why) {
|
|
103
|
+
why = (await ctx.ask(t("ui.link.conflict.why"))).trim();
|
|
104
|
+
if (why === "0" || outOfAnswers(ctx))
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
if ((await run(["conflict", "resolve", id, `--resolution=${resolution}`, `--reasoning=${t("ui.link.conflict.by", { why })}`], true)) !== 0)
|
|
108
|
+
return false;
|
|
109
|
+
out(ctx, t("ui.link.conflict.done"));
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
/** A story the person read and approves: no longer a draft. */
|
|
113
|
+
export async function approveStory(ctx, run, lang, root, person, who) {
|
|
114
|
+
const t = translator(lang);
|
|
115
|
+
const st = person.story;
|
|
116
|
+
if (!st)
|
|
117
|
+
return stop(ctx, t("ui.link.story.none", { person: who }));
|
|
118
|
+
if (st.status === "final")
|
|
119
|
+
return stop(ctx, t("ui.link.story.final", { person: who }));
|
|
120
|
+
out(ctx, lines(st.title ? `„${st.title}“` : undefined, "", st.text.replace(/\*\*/g, ""), ""));
|
|
121
|
+
if (!(await asks(ctx, lang, root, t("ui.link.what.story", { person: who }))))
|
|
122
|
+
return false;
|
|
123
|
+
if ((await run(["story", "approve", person.id], true)) !== 0)
|
|
124
|
+
return false;
|
|
125
|
+
out(ctx, t("ui.link.story.done"));
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
/** What the research took from the Strom app, taken back on the person's word. */
|
|
129
|
+
export async function undoSending(ctx, run, lang, root, id) {
|
|
130
|
+
const t = translator(lang);
|
|
131
|
+
const input = Tree.open(root, ctx.env).get(id);
|
|
132
|
+
if (!input || input.type !== "input" || !input.sync)
|
|
133
|
+
return stop(ctx, t("ui.link.sync.none", { input: id }));
|
|
134
|
+
if (input.sync.undone)
|
|
135
|
+
return stop(ctx, t("ui.link.sync.was"));
|
|
136
|
+
if (!(await asks(ctx, lang, root, t("ui.link.what.sync", { day: humanDay(input.created, lang), n: input.sync.applied.length }))))
|
|
137
|
+
return false;
|
|
138
|
+
return (await run(["sync", "undo", id])) === 0;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* A tree of the Strom app becomes a new research (strom-research://new?app=<its mark>): named, its language and
|
|
142
|
+
* agent chosen, made; the app opened to hand the tree over (?adopt=<the bridge>), the tree taken in as leads,
|
|
143
|
+
* the research sent back to the app — then the conversation offered.
|
|
144
|
+
*/
|
|
145
|
+
export async function newFromApp(ctx, runIn, token, openAt, forward) {
|
|
146
|
+
// strom not set up yet (the app is how this person came): the setup first
|
|
147
|
+
if (!ctx.settings.home()?.value) {
|
|
148
|
+
await (await runIn(undefined))(["setup"]);
|
|
149
|
+
ctx.settings.reload();
|
|
150
|
+
if (!ctx.settings.home()?.value)
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
let lang = ctx.uiLang();
|
|
154
|
+
let t = translator(lang);
|
|
155
|
+
out(ctx, t("ui.link.new.title"));
|
|
156
|
+
out(ctx, "");
|
|
157
|
+
// the name; an empty research of that name (a handover that did not come) is taken again
|
|
158
|
+
let name = "";
|
|
159
|
+
let root;
|
|
160
|
+
for (;;) {
|
|
161
|
+
name = (await ctx.ask(t("ui.link.new.name"))).trim().replace(/\s+/gu, " ");
|
|
162
|
+
if (!name || name === "0")
|
|
163
|
+
return false;
|
|
164
|
+
const same = ctx.knownTrees().find((k) => foldText(k.name) === foldText(name) || path.basename(k.root) === safeFolderName(name));
|
|
165
|
+
if (!same)
|
|
166
|
+
break;
|
|
167
|
+
if (Tree.open(same.root, ctx.env).count("person") === 0) {
|
|
168
|
+
root = same.root;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
out(ctx, t("ui.link.new.exists", { name }));
|
|
172
|
+
if (outOfAnswers(ctx))
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
for (;;) {
|
|
176
|
+
const a = (await ctx.ask(t("ui.setup.lang"), lang)).trim().toLowerCase();
|
|
177
|
+
if (a === "0")
|
|
178
|
+
return false;
|
|
179
|
+
if (isValidLang(a)) {
|
|
180
|
+
lang = a;
|
|
181
|
+
t = translator(lang);
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
if (outOfAnswers(ctx))
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
const here = agentsHere(ctx.env).map((a) => a.id);
|
|
188
|
+
const mine = ctx.settings.agent().value;
|
|
189
|
+
let agent = mine;
|
|
190
|
+
if (here.length > 1) {
|
|
191
|
+
const i = await ctx.choose(t("ui.setup.agent.pick"), here.map((id) => ({ label: PROFILES[id].name })), Math.max(0, here.indexOf(mine)), { back: t("ui.browse.back") });
|
|
192
|
+
if (i === undefined)
|
|
193
|
+
return false;
|
|
194
|
+
agent = here[i];
|
|
195
|
+
}
|
|
196
|
+
if (!root) {
|
|
197
|
+
let said = "";
|
|
198
|
+
const code = await (await runIn(undefined))(["init", name, "--lang", lang, "--json"], true, (s) => (said += s));
|
|
199
|
+
if (code !== 0)
|
|
200
|
+
return false;
|
|
201
|
+
root = JSON.parse(said).root;
|
|
202
|
+
}
|
|
203
|
+
const run = await runIn(root);
|
|
204
|
+
if (agent !== mine)
|
|
205
|
+
await run(["agents", "use", agent, "--for-tree"], true);
|
|
206
|
+
// the app hands its tree over to the bridge of this research
|
|
207
|
+
awaitAdoption(root, token);
|
|
208
|
+
const info = startLive(root, ctx.env, { current: true });
|
|
209
|
+
if (!info)
|
|
210
|
+
throw new StromError("the bridge did not start", { hint: "strom live serve shows why" });
|
|
211
|
+
const url = adoptAppUrl(info.url, ctx.settings);
|
|
212
|
+
const since = Date.now();
|
|
213
|
+
const waitMs = Number(ctx.env.STROM_ADOPT_WAIT_MS ?? 30 * 60_000);
|
|
214
|
+
const minutes = Math.max(1, Math.round(waitMs / 60_000));
|
|
215
|
+
out(ctx, openAt(url) ? `${t("ui.link.new.wait", { min: minutes })}\n${t("ui.sync.wait.open", { url })}` : t("ui.app.url", { url }));
|
|
216
|
+
let file;
|
|
217
|
+
while (Date.now() < since + waitMs) {
|
|
218
|
+
file = adoptedSince(root, since);
|
|
219
|
+
if (file)
|
|
220
|
+
break;
|
|
221
|
+
const failed = adoptFailedSince(root, since);
|
|
222
|
+
if (failed) {
|
|
223
|
+
forward();
|
|
224
|
+
return stop(ctx, t(failed === "empty" ? "ui.link.new.empty" : "ui.link.new.failed", { name }));
|
|
225
|
+
}
|
|
226
|
+
if (nothingSince(root, since)) {
|
|
227
|
+
forward();
|
|
228
|
+
out(ctx, t("ui.link.new.none", { name }));
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
232
|
+
}
|
|
233
|
+
forward();
|
|
234
|
+
if (!file)
|
|
235
|
+
return stop(ctx, t("ui.link.new.waited", { name, min: minutes }));
|
|
236
|
+
// the family's tree, as leads (without the images written into it: the research keeps its own)
|
|
237
|
+
const { withoutImages } = await import("../commands/sync.js");
|
|
238
|
+
const kept = path.join(path.dirname(file), `${safeFolderName(name)}.ged`);
|
|
239
|
+
fs.writeFileSync(kept, withoutImages(fs.readFileSync(file, "utf8")));
|
|
240
|
+
if ((await run(["intake", kept], true)) !== 0)
|
|
241
|
+
return false;
|
|
242
|
+
const tree = Tree.open(root, ctx.env);
|
|
243
|
+
out(ctx, t("ui.link.new.taken", { name, persons: tree.count("person"), families: tree.count("family") }));
|
|
244
|
+
// the research as it is now back into the app's window: the same tree, now of the research
|
|
245
|
+
if ((await run(["app"], true)) === 0)
|
|
246
|
+
out(ctx, t("ui.link.new.back"));
|
|
247
|
+
await offerChat(ctx, run, lang, root, t("ui.link.new.chat"), t("ui.link.new.say"));
|
|
248
|
+
return true;
|
|
249
|
+
}
|
package/dist/cli/menu-parts.js
CHANGED
|
@@ -11,9 +11,9 @@ export function outOfAnswers(ctx) {
|
|
|
11
11
|
return ctx.io.answers !== undefined && ctx.io.answers.length === 0;
|
|
12
12
|
}
|
|
13
13
|
/** "Press Enter to go back to the menu." — unless there is nobody to press it. */
|
|
14
|
-
export async function pause(ctx, lang) {
|
|
14
|
+
export async function pause(ctx, lang, enter = "ui.enter") {
|
|
15
15
|
if (!outOfAnswers(ctx))
|
|
16
|
-
await ctx.ask(ui(lang,
|
|
16
|
+
await ctx.ask(ui(lang, enter));
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
19
|
* A submenu: its items (built afresh each time, so what they say is current), numbered in the order shown, 0 back.
|
|
@@ -32,7 +32,10 @@ export async function subMenu(ctx, lang, build) {
|
|
|
32
32
|
const i = await ctx.choose(title ?? "", all.map((it) => ({ key: it.key === "0" ? "0" : String(++n), label: it.label })), all.length - 1);
|
|
33
33
|
if (i === undefined)
|
|
34
34
|
return;
|
|
35
|
-
|
|
35
|
+
const done = await guarded(ctx, lang, all[i].act);
|
|
36
|
+
if (done === "quit")
|
|
37
|
+
return "quit";
|
|
38
|
+
if (done === true)
|
|
36
39
|
return;
|
|
37
40
|
}
|
|
38
41
|
}
|
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
// agent takes it up in the next conversation or run.
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
|
-
import { agentReady, droppedPaths, pause, pickPerson, subMenu, translator } from "./menu-parts.js";
|
|
7
|
+
import { agentReady, droppedPaths, outOfAnswers, pause, pickPerson, subMenu, translator } from "./menu-parts.js";
|
|
8
8
|
import { Tree } from "../core/tree.js";
|
|
9
|
-
import { displayName } from "../core/people.js";
|
|
9
|
+
import { displayName, lifespan } from "../core/people.js";
|
|
10
|
+
import { DEATH_AFTER_YEARS, unprovenPeople } from "../core/review.js";
|
|
11
|
+
import { planSync, readTreeFile, receivedSince } from "../core/sync.js";
|
|
12
|
+
import { appSendsChanges, stromAppState } from "../core/stromapp.js";
|
|
13
|
+
import { UNPROVEN_BATCH } from "../commands/research.js";
|
|
10
14
|
import { expandHome } from "../core/paths.js";
|
|
11
15
|
import { collectFiles } from "../core/media.js";
|
|
12
16
|
import { BOOK_OF_SCANS, IMAGE_EXT } from "../commands/intake.js";
|
|
@@ -17,8 +21,15 @@ export async function addToResearch(ctx, run, lang, root) {
|
|
|
17
21
|
{ key: "1", label: t("ui.more.intake"), act: async () => void (await addMaterial(ctx, run, lang, root)) },
|
|
18
22
|
{ key: "2", label: t("ui.more.research"), act: async () => void (await newResearch(ctx, run, lang, root)) },
|
|
19
23
|
];
|
|
20
|
-
|
|
24
|
+
const tree = Tree.open(root, ctx.env);
|
|
25
|
+
if (tree.count("person") > 0) {
|
|
21
26
|
items.push({ key: "3", label: t("ui.menu.review"), act: async () => void (await reviewPerson(ctx, run, lang, root)) });
|
|
27
|
+
items.push({ key: "4", label: t(noApp(ctx) ? "ui.more.sync.noapp" : "ui.more.sync"), act: async () => void (await syncTree(ctx, run, lang, root)) });
|
|
28
|
+
}
|
|
29
|
+
// only when there are some: the people no record of their own proves — last, it shows only sometimes
|
|
30
|
+
const unproven = unprovenPeople(tree).filter((u) => !u.living).length;
|
|
31
|
+
if (unproven)
|
|
32
|
+
items.push({ key: "5", label: t("ui.menu.unproven", { n: unproven }), act: async () => void (await reviewUnproven(ctx, run, lang, root)) });
|
|
22
33
|
return { title: t("ui.more.title"), items };
|
|
23
34
|
});
|
|
24
35
|
}
|
|
@@ -147,6 +158,14 @@ async function newResearch(ctx, run, lang, root) {
|
|
|
147
158
|
return;
|
|
148
159
|
who = [`--new-person=${name}`, "--sex", ["M", "F", "U"][sex]];
|
|
149
160
|
}
|
|
161
|
+
await startDirection(ctx, run, lang, root, direction, who, name, question);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* A new direction for someone chosen (the menu, a link of the Strom app): its title and what the person knows of
|
|
165
|
+
* it asked, the research made; the same research still going is only named.
|
|
166
|
+
*/
|
|
167
|
+
export async function startDirection(ctx, run, lang, root, direction, who, name, question) {
|
|
168
|
+
const t = translator(lang);
|
|
150
169
|
// The same research again (this person, this direction, still going): said which one it is, nothing made.
|
|
151
170
|
const focus = who[0] === "--person" ? who[1] : undefined;
|
|
152
171
|
const same = focus && direction !== "question"
|
|
@@ -176,6 +195,160 @@ async function newResearch(ctx, run, lang, root) {
|
|
|
176
195
|
ctx.io.stdout(t("ui.research.done", { name: made.name }) + "\n");
|
|
177
196
|
await offerChat(ctx, run, lang, root, t("ui.research.chat"), t("ui.research.say", { name: made.name }));
|
|
178
197
|
}
|
|
198
|
+
/** The person said no to the Strom app: it is not named. */
|
|
199
|
+
const noApp = (ctx) => stromAppState(ctx.settings) === "no";
|
|
200
|
+
/** A family tree coming back (the Strom app, another program): what changed in it, shown, then written on the person's word. */
|
|
201
|
+
async function syncTree(ctx, run, lang, root) {
|
|
202
|
+
const t = translator(lang);
|
|
203
|
+
let file;
|
|
204
|
+
// straight from the Strom app, where it can send the tree (and the person wants it)
|
|
205
|
+
if (!noApp(ctx) && appSendsChanges(ctx.settings)) {
|
|
206
|
+
const from = await ctx.choose(t("ui.sync.from"), [{ label: t("ui.sync.from.app") }, { label: t("ui.sync.from.file") }], 0, { back: t("ui.browse.back") });
|
|
207
|
+
if (from === undefined)
|
|
208
|
+
return;
|
|
209
|
+
if (from === 0)
|
|
210
|
+
return syncFromApp(ctx, run, lang, root);
|
|
211
|
+
}
|
|
212
|
+
for (;;) {
|
|
213
|
+
if (outOfAnswers(ctx))
|
|
214
|
+
return;
|
|
215
|
+
const answer = (await ctx.ask(t(noApp(ctx) ? "ui.sync.path.noapp" : "ui.sync.path"))).trim();
|
|
216
|
+
if (!answer || answer === "0")
|
|
217
|
+
return;
|
|
218
|
+
const resolve = (p) => path.resolve(ctx.cwd, expandHome(p, ctx.env));
|
|
219
|
+
const dropped = droppedPaths(answer).map(resolve);
|
|
220
|
+
file = [dropped[0], resolve(answer)].find((p) => !!p && fs.existsSync(p) && fs.statSync(p).isFile());
|
|
221
|
+
if (file)
|
|
222
|
+
break;
|
|
223
|
+
ctx.io.stdout(t("ui.intake.missing", { path: answer }) + "\n");
|
|
224
|
+
}
|
|
225
|
+
// what it would take, asked first: nothing to write — nothing asked
|
|
226
|
+
let count;
|
|
227
|
+
try {
|
|
228
|
+
const tree = Tree.open(root, ctx.env);
|
|
229
|
+
count = planSync(tree, readTreeFile(file), ctx.settings.syncEdits(tree.config)).changes.length;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
count = 0;
|
|
233
|
+
}
|
|
234
|
+
if ((await run(["sync", file])) !== 0 || !count)
|
|
235
|
+
return pause(ctx, lang);
|
|
236
|
+
return confirmSync(ctx, run, lang, root, file, count);
|
|
237
|
+
}
|
|
238
|
+
/** The tree straight from the Strom app (the menu, or a strom-research://send link): shown, then written on the person's word. */
|
|
239
|
+
export async function syncFromApp(ctx, run, lang, root, enter = "ui.enter", back) {
|
|
240
|
+
const since = Date.now();
|
|
241
|
+
const code = await run(["sync", "--app"]);
|
|
242
|
+
// the person is in the app: what came of it is here (a terminal opened for a link brings itself forward)
|
|
243
|
+
back?.();
|
|
244
|
+
if (code !== 0)
|
|
245
|
+
return pause(ctx, lang, enter);
|
|
246
|
+
const file = receivedSince(root, since);
|
|
247
|
+
if (!file)
|
|
248
|
+
return pause(ctx, lang, enter);
|
|
249
|
+
return confirmSync(ctx, run, lang, root, file, undefined, enter);
|
|
250
|
+
}
|
|
251
|
+
/** What the tree brings is shown: written on the person's word — all, or the ones they pick. */
|
|
252
|
+
async function confirmSync(ctx, run, lang, root, file, known, enter = "ui.enter") {
|
|
253
|
+
const t = translator(lang);
|
|
254
|
+
let count = known ?? 0;
|
|
255
|
+
if (known === undefined)
|
|
256
|
+
try {
|
|
257
|
+
const tree = Tree.open(root, ctx.env);
|
|
258
|
+
count = planSync(tree, readTreeFile(file), ctx.settings.syncEdits(tree.config)).changes.length;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
count = 0;
|
|
262
|
+
}
|
|
263
|
+
if (!count)
|
|
264
|
+
return pause(ctx, lang, enter);
|
|
265
|
+
const how = await ctx.choose(t("ui.sync.how"), [{ label: t("ui.sync.all") }, { label: t("ui.sync.some") }], 0, { back: t("ui.browse.back") });
|
|
266
|
+
if (how === undefined)
|
|
267
|
+
return;
|
|
268
|
+
let only = [];
|
|
269
|
+
if (how === 1) {
|
|
270
|
+
const picked = await pickNumbers(ctx, lang, Array.from({ length: count }, (_, i) => String(i + 1)));
|
|
271
|
+
if (!picked?.length)
|
|
272
|
+
return;
|
|
273
|
+
only = ["--only", picked.map((i) => i + 1).join(",")];
|
|
274
|
+
}
|
|
275
|
+
if ((await run(["sync", file, "--apply", ...only])) === 0 && !noApp(ctx))
|
|
276
|
+
ctx.io.stdout(t("ui.sync.app") + "\n");
|
|
277
|
+
await pause(ctx, lang, enter);
|
|
278
|
+
}
|
|
279
|
+
/** The people no record of their own proves: listed, then all of them a batch at a time, or the ones picked by number. */
|
|
280
|
+
async function reviewUnproven(ctx, run, lang, root) {
|
|
281
|
+
const t = translator(lang);
|
|
282
|
+
const out = (line) => ctx.io.stdout(line + "\n");
|
|
283
|
+
const tree = Tree.open(root, ctx.env);
|
|
284
|
+
const found = unprovenPeople(tree);
|
|
285
|
+
const all = found.filter((u) => !u.living);
|
|
286
|
+
const living = found.filter((u) => u.living);
|
|
287
|
+
const open = tree.list("task").filter((x) => ["open", "doing", "parked", "waiting"].includes(x.state));
|
|
288
|
+
out(t("ui.unproven.title"));
|
|
289
|
+
all.forEach((u, i) => {
|
|
290
|
+
const busy = open.some((x) => x.subject.includes(u.person.id));
|
|
291
|
+
const life = lifespan(u.person);
|
|
292
|
+
out(` ${String(i + 1).padStart(2)}. ${u.person.id} ${displayName(u.person)}${life ? ` (${life})` : ""} — ${t(`ui.unproven.${u.kind}`)}${busy ? ` · ${t("ui.unproven.tasks")}` : ""}`);
|
|
293
|
+
});
|
|
294
|
+
if (living.length)
|
|
295
|
+
out(t("ui.unproven.living", { years: DEATH_AFTER_YEARS, names: living.map((u) => `${u.person.id} ${displayName(u.person)}`).join(", ") }));
|
|
296
|
+
const how = await ctx.choose(t("ui.unproven.how"), [{ label: t("ui.unproven.all", { n: UNPROVEN_BATCH }) }, { label: t("ui.unproven.pick") }], 0, { back: t("ui.browse.back") });
|
|
297
|
+
if (how === undefined)
|
|
298
|
+
return;
|
|
299
|
+
let argv;
|
|
300
|
+
if (how === 0)
|
|
301
|
+
argv = ["review", "--unproven"];
|
|
302
|
+
else {
|
|
303
|
+
const picked = await pickNumbers(ctx, lang, all.map((u) => u.person.id));
|
|
304
|
+
if (!picked?.length)
|
|
305
|
+
return;
|
|
306
|
+
argv = ["review", ...picked.map((i) => all[i].person.id)];
|
|
307
|
+
}
|
|
308
|
+
if ((await run(argv)) !== 0)
|
|
309
|
+
return pause(ctx, lang);
|
|
310
|
+
const after = Tree.open(root, ctx.env);
|
|
311
|
+
const ids = argv.slice(1).filter((a) => a.startsWith("P"));
|
|
312
|
+
const research = after
|
|
313
|
+
.list("research")
|
|
314
|
+
.find((r) => r.direction === "person" && r.state === "active" && (ids.length ? r.review?.people?.length === ids.length && ids.every((id) => r.review.people.includes(id)) : r.review?.unproven));
|
|
315
|
+
const waiting = research ? after.list("task").filter((x) => x.research === research.id && x.state === "open").length : 0;
|
|
316
|
+
if (research && waiting && (await ctx.confirm(t("ui.review.run", { n: waiting }), false)))
|
|
317
|
+
await run(["run", "--research", research.id, "--max", String(waiting)]);
|
|
318
|
+
await pause(ctx, lang);
|
|
319
|
+
}
|
|
320
|
+
/** Numbers of a list as a person types them: "1 3 5-7", "2,4", or the IDs shown ("P0012"); each once, in their order. Undefined: back. */
|
|
321
|
+
async function pickNumbers(ctx, lang, ids) {
|
|
322
|
+
const count = ids.length;
|
|
323
|
+
const t = translator(lang);
|
|
324
|
+
for (;;) {
|
|
325
|
+
const answer = (await ctx.ask(t("ui.unproven.numbers"))).trim();
|
|
326
|
+
if (!answer || answer === "0")
|
|
327
|
+
return undefined;
|
|
328
|
+
const out = [];
|
|
329
|
+
const bad = [];
|
|
330
|
+
for (const part of answer.split(/[\s,;]+/u).filter(Boolean)) {
|
|
331
|
+
const id = ids.indexOf(part.toUpperCase());
|
|
332
|
+
if (id >= 0) {
|
|
333
|
+
if (!out.includes(id))
|
|
334
|
+
out.push(id);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const m = /^(\d+)(?:\s*[-–]\s*(\d+))?$/u.exec(part);
|
|
338
|
+
const from = m ? Number(m[1]) : NaN;
|
|
339
|
+
const to = m?.[2] ? Number(m[2]) : from;
|
|
340
|
+
if (!m || from < 1 || to > count || to < from)
|
|
341
|
+
bad.push(part);
|
|
342
|
+
else
|
|
343
|
+
for (let i = from; i <= to; i++)
|
|
344
|
+
if (!out.includes(i - 1))
|
|
345
|
+
out.push(i - 1);
|
|
346
|
+
}
|
|
347
|
+
if (!bad.length)
|
|
348
|
+
return out;
|
|
349
|
+
ctx.io.stdout(t("ui.unproven.bad", { x: bad.join(", ") }) + "\n");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
179
352
|
/** One person, looked at again: what the tree says of them elsewhere, what to read whole, what to check. */
|
|
180
353
|
async function reviewPerson(ctx, run, lang, root) {
|
|
181
354
|
const t = translator(lang);
|
|
@@ -183,12 +356,17 @@ async function reviewPerson(ctx, run, lang, root) {
|
|
|
183
356
|
if (!person)
|
|
184
357
|
return;
|
|
185
358
|
const family = await ctx.confirm(t("ui.review.family"), false);
|
|
186
|
-
|
|
187
|
-
|
|
359
|
+
await reviewOne(ctx, run, lang, root, person.id, family ? "family" : "person");
|
|
360
|
+
await pause(ctx, lang);
|
|
361
|
+
}
|
|
362
|
+
/** One person's review (the menu, a link of the Strom app), then the agent offered for its tasks — the person's choice, no suggested. */
|
|
363
|
+
export async function reviewOne(ctx, run, lang, root, person, scope) {
|
|
364
|
+
const t = translator(lang);
|
|
365
|
+
if ((await run(["review", person, ...(scope !== "person" ? ["--scope", scope] : [])])) !== 0)
|
|
366
|
+
return;
|
|
188
367
|
const tree = Tree.open(root, ctx.env);
|
|
189
|
-
const research = tree.list("research").find((r) => r.direction === "person" && r.focus === person.
|
|
368
|
+
const research = tree.list("research").find((r) => r.direction === "person" && r.focus === person && r.review && !r.review.people);
|
|
190
369
|
const open = research ? tree.list("task").filter((x) => x.research === research.id && x.state === "open").length : 0;
|
|
191
370
|
if (research && open && (await ctx.confirm(t("ui.review.run", { n: open }), false)))
|
|
192
371
|
await run(["run", "--research", research.id, "--max", String(open)]);
|
|
193
|
-
await pause(ctx, lang);
|
|
194
372
|
}
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// the person at the terminal does, through the ordinary commands.
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { claudeHere, pause, subMenu, translator } from "./menu-parts.js";
|
|
8
|
-
import { Tree } from "../core/tree.js";
|
|
8
|
+
import { Tree, VERSION } from "../core/tree.js";
|
|
9
|
+
import { replacedOnDisk } from "../core/self.js";
|
|
10
|
+
import { shortcutName } from "./wizard.js";
|
|
9
11
|
import { DEFAULT_RUN_MINUTES } from "../core/config.js";
|
|
10
12
|
import { askGate, ensureGatesDir, listGates, loadGate } from "../core/gate.js";
|
|
11
13
|
import { ensureHooksDir, hooksDir, listHooks } from "../core/hooks.js";
|
|
@@ -16,7 +18,7 @@ import { loadLogins } from "../core/logins.js";
|
|
|
16
18
|
import { claudeRemoteAtStartup } from "../agents/global.js";
|
|
17
19
|
export async function settingsMenu(ctx, run, lang, root, newer) {
|
|
18
20
|
const t = translator(lang);
|
|
19
|
-
|
|
21
|
+
return subMenu(ctx, lang, () => {
|
|
20
22
|
ctx.settings.reload();
|
|
21
23
|
const shared = ctx.settings.shared()?.value;
|
|
22
24
|
const tree = root ? Tree.open(root, ctx.env).config : undefined;
|
|
@@ -62,7 +64,19 @@ export async function settingsMenu(ctx, run, lang, root, newer) {
|
|
|
62
64
|
});
|
|
63
65
|
}
|
|
64
66
|
if (newer)
|
|
65
|
-
items.push({
|
|
67
|
+
items.push({
|
|
68
|
+
key: "7",
|
|
69
|
+
label: t("ui.menu.update", { version: newer }),
|
|
70
|
+
act: async () => {
|
|
71
|
+
if ((await run(["update"])) !== 0)
|
|
72
|
+
return;
|
|
73
|
+
// this menu is still the old strom: the new one starts with the next strom
|
|
74
|
+
if (!replacedOnDisk(VERSION))
|
|
75
|
+
return;
|
|
76
|
+
ctx.io.stdout(t("ui.update.restart", { shortcut: shortcutName(lang) }) + "\n");
|
|
77
|
+
return "quit";
|
|
78
|
+
},
|
|
79
|
+
});
|
|
66
80
|
return { title: t("ui.settings.title"), items };
|
|
67
81
|
});
|
|
68
82
|
}
|