strom-research 1.7.0 → 1.8.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 (42) hide show
  1. package/README.md +3 -0
  2. package/assets/lang/cs.json +117 -2
  3. package/assets/lang/de.json +117 -2
  4. package/assets/method/narrate.md +5 -1
  5. package/assets/plugins/gates/claude-usage/gate.ts +2 -1
  6. package/dist/agents/files.js +6 -4
  7. package/dist/cli/main.js +21 -3
  8. package/dist/cli/menu-links.js +35 -1
  9. package/dist/cli/menu-research.js +41 -2
  10. package/dist/cli/menu-settings.js +17 -1
  11. package/dist/cli/menu.js +19 -8
  12. package/dist/cli/ui.js +57 -1
  13. package/dist/commands/guide.js +6 -1
  14. package/dist/commands/links.js +39 -3
  15. package/dist/commands/pack.js +21 -18
  16. package/dist/commands/research.js +42 -1
  17. package/dist/commands/session.js +79 -9
  18. package/dist/commands/setup.js +3 -0
  19. package/dist/commands/start.js +6 -3
  20. package/dist/commands/story.js +47 -6
  21. package/dist/commands/tasks.js +15 -6
  22. package/dist/commands/trees.js +82 -2
  23. package/dist/core/changelog.js +223 -0
  24. package/dist/core/clock.js +20 -0
  25. package/dist/core/config.js +2 -0
  26. package/dist/core/connector.js +25 -1
  27. package/dist/core/directions.js +41 -0
  28. package/dist/core/links.js +17 -4
  29. package/dist/core/live.js +393 -50
  30. package/dist/core/pack.js +31 -0
  31. package/dist/core/phrases.js +61 -0
  32. package/dist/core/queue.js +7 -2
  33. package/dist/core/relocate.js +4 -4
  34. package/dist/core/session.js +8 -2
  35. package/dist/core/trash.js +72 -0
  36. package/dist/core/tree.js +1 -1
  37. package/dist/core/workers.js +28 -3
  38. package/dist/runners/claude.js +2 -1
  39. package/dist/runners/jsonl.js +2 -2
  40. package/dist/runners/runner.js +16 -3
  41. package/dist/runners/script.js +2 -1
  42. package/package.json +1 -1
@@ -0,0 +1,223 @@
1
+ // What one commit of a research saved, as a person reads it — in the research
2
+ // language, records by their names: "New person: Jan Novák (*1905) [P0001]",
3
+ // "Downloaded 7 images: Kniha N 1850 (images 16–22)". Made from the operations
4
+ // the commit logged (data/ops/*.jsonl, each line one operation); a commit
5
+ // without them (the readers' notes) by its subject. For the Strom app (the
6
+ // bridge's /log and change events): strom's own commit subjects are English
7
+ // and name records by their IDs.
8
+ import { displayName, lifespan } from "./people.js";
9
+ import { ownerName, cardFact } from "./overview.js";
10
+ import { phrase } from "./phrases.js";
11
+ import { eventName, humanDate, humanPlace, humanTask } from "../cli/human.js";
12
+ /** Operations nobody needs to read: what another line says already, or bookkeeping. */
13
+ const QUIET = new Set(["session.metrics", "task.start", "task.continue", "session.note"]);
14
+ /** The one-line form of an operation, by its kind: {who} a person or family, {name} the record. */
15
+ const SAID = {
16
+ "person.add": "log.person.add",
17
+ "person.name": "log.person.name",
18
+ "person.edit": "log.person.edit",
19
+ "person.cite": "log.person.cite",
20
+ "person.retract": "log.person.retract",
21
+ "family.add": "log.family.add",
22
+ "family.edit": "log.family.edit",
23
+ "family.cite": "log.family.cite",
24
+ "source.add": "log.source.add",
25
+ "source.edit": "log.source.edit",
26
+ "recordset.add": "log.recordset.add",
27
+ "recordset.edit": "log.recordset.edit",
28
+ "recordset.calibrate": "log.recordset.calibrate",
29
+ "task.add": "log.task.add",
30
+ "task.done": "log.task.done",
31
+ "task.park": "log.task.park",
32
+ "task.drop": "log.task.drop",
33
+ "task.wait": "log.task.wait",
34
+ "task.wake": "log.task.wake",
35
+ "task.edit": "log.task.edit",
36
+ "session.start": "log.session.start",
37
+ "session.close": "log.session.close",
38
+ "lesson.add": "log.lesson.add",
39
+ "hypothesis.add": "log.hypothesis.add",
40
+ "hypothesis.argue": "log.hypothesis.argue",
41
+ "hypothesis.decide": "log.hypothesis.decide",
42
+ "conflict.add": "log.conflict.add",
43
+ "conflict.resolve": "log.conflict.resolve",
44
+ "place.add": "log.place.add",
45
+ "place.edit": "log.place.edit",
46
+ "place.jurisdiction": "log.place.edit",
47
+ "repository.add": "log.repository.add",
48
+ "repository.edit": "log.repository.edit",
49
+ "story.set": "log.story.set",
50
+ "story.approve": "log.story.approve",
51
+ "note.add": "log.note.add",
52
+ "input.add": "log.input.add",
53
+ "input.sync": "log.input.sync",
54
+ "input.done": "log.input.done",
55
+ "research.add": "log.research.add",
56
+ "research.edit": "log.research.edit",
57
+ "research.pause": "log.research.pause",
58
+ "research.resume": "log.research.resume",
59
+ "research.done": "log.research.done",
60
+ };
61
+ const EVENT_SAID = {
62
+ "event.add": "log.event.add",
63
+ "event.edit": "log.event.edit",
64
+ "event.cite": "log.event.cite",
65
+ "event.retract": "log.event.retract",
66
+ };
67
+ const SEARCH_SAID = {
68
+ found: "log.search.found",
69
+ negative: "log.search.negative",
70
+ partial: "log.search.partial",
71
+ inconclusive: "log.search.inconclusive",
72
+ };
73
+ function short(text, max = 90) {
74
+ const one = text.replace(/\s+/g, " ").trim();
75
+ return one.length > max ? `${one.slice(0, max - 1).trimEnd()}…` : one;
76
+ }
77
+ /** Image numbers as ranges: 16–22, 30. */
78
+ function ranges(ns) {
79
+ const sorted = [...new Set(ns)].sort((a, b) => a - b);
80
+ const out = [];
81
+ for (let i = 0; i < sorted.length; i++) {
82
+ let j = i;
83
+ while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1)
84
+ j++;
85
+ out.push(j > i ? `${sorted[i]}–${sorted[j]}` : String(sorted[i]));
86
+ i = j;
87
+ }
88
+ return out.join(", ");
89
+ }
90
+ function kindOf(op) {
91
+ const [record] = op.split(".");
92
+ if (record === "person" || record === "family" || record === "event")
93
+ return "person";
94
+ if (record === "source" || record === "media" || record === "recordset" || record === "repository")
95
+ return "source";
96
+ if (record === "story")
97
+ return "story";
98
+ return "other";
99
+ }
100
+ /** Lines of what a commit saved, in the research language, each with what it is about; its subject when it logged no operation. */
101
+ export function changeLines(tree, ops, subject, lang) {
102
+ const get = (id) => (id ? tree.get(id) : undefined);
103
+ const who = (id) => {
104
+ const r = get(id);
105
+ if (!r)
106
+ return id ?? "";
107
+ if (r.type === "person") {
108
+ const span = lifespan(r);
109
+ return `${displayName(r)}${span ? ` (${span})` : ""} [${r.id}]`;
110
+ }
111
+ return r.type === "family" ? ownerName(tree, r.id) : r.id;
112
+ };
113
+ const task = (id) => {
114
+ const t = get(id);
115
+ return t ? short(humanTask(tree, t.what, lang)) : (id ?? "");
116
+ };
117
+ const name = (id) => {
118
+ const r = get(id);
119
+ if (!r)
120
+ return id ?? "";
121
+ switch (r.type) {
122
+ case "person":
123
+ case "family":
124
+ return who(r.id);
125
+ case "task":
126
+ return task(r.id);
127
+ case "session":
128
+ return r.task ? task(r.task) : r.id;
129
+ case "source":
130
+ case "recordset":
131
+ case "conflict":
132
+ return short(r.title);
133
+ case "research":
134
+ case "repository":
135
+ case "input":
136
+ return short(r.name);
137
+ case "search":
138
+ return short(r.question);
139
+ case "lesson":
140
+ return short(r.rule);
141
+ case "hypothesis":
142
+ return short(r.question);
143
+ case "place":
144
+ return r.names[0]?.name ?? r.id;
145
+ default:
146
+ return r.id;
147
+ }
148
+ };
149
+ const fact = (owner, eventId) => {
150
+ const e = get(owner)?.events.find((x) => x.id === eventId);
151
+ if (!e)
152
+ return undefined;
153
+ const f = cardFact(tree, e);
154
+ const what = [f.value, humanDate(f.date, lang), humanPlace(f.place, f.house, lang)].filter(Boolean).join(", ");
155
+ return `${eventName(f.kind, lang, f.label)}${what ? `: ${what}` : ""}`;
156
+ };
157
+ const out = [];
158
+ let kind = "other";
159
+ const say = (line) => {
160
+ if (line && !out.some((l) => l.text === line))
161
+ out.push({ text: line, kind });
162
+ };
163
+ // Images downloaded: one line for each book, whatever their order.
164
+ const images = new Map();
165
+ for (const o of ops) {
166
+ if (QUIET.has(o.op))
167
+ continue;
168
+ kind = kindOf(o.op);
169
+ const [first, second] = o.targets;
170
+ if (o.op === "media.add") {
171
+ const m = get(first);
172
+ const key = m?.recordset ?? "";
173
+ if (!images.has(key)) {
174
+ images.set(key, []);
175
+ out.push({ text: `\u0000media:${key}`, kind: "source" }); // its place in the order, filled in below
176
+ }
177
+ if (m?.image !== undefined)
178
+ images.get(key).push(m.image);
179
+ continue;
180
+ }
181
+ if (EVENT_SAID[o.op]) {
182
+ const f = fact(first, second);
183
+ say(f ? phrase(lang, EVENT_SAID[o.op], { who: who(first), fact: f }) : o.summary);
184
+ continue;
185
+ }
186
+ if (o.op === "search.add" || o.op === "search.edit") {
187
+ const s = get(first);
188
+ say(s ? phrase(lang, SEARCH_SAID[s.result] ?? "log.search.found", { name: short(s.question) }) : o.summary);
189
+ continue;
190
+ }
191
+ if (o.op === "family.child") {
192
+ say(phrase(lang, "log.family.child", { who: who(first), child: who(second) }));
193
+ continue;
194
+ }
195
+ if (o.op === "person.merge" || o.op === "family.merge") {
196
+ say(phrase(lang, "log.merge", { who: who(second) }));
197
+ continue;
198
+ }
199
+ const key = SAID[o.op];
200
+ say(key ? phrase(lang, key, { who: who(first), name: name(first) }) : o.summary);
201
+ }
202
+ const lines = out.map(({ text: line, kind }) => {
203
+ if (!line.startsWith("\u0000media:"))
204
+ return { text: line, kind };
205
+ const book = line.slice("\u0000media:".length);
206
+ const ns = images.get(book) ?? [];
207
+ const title = get(book)?.title;
208
+ const n = Math.max(ns.length, 1);
209
+ const text = !title
210
+ ? phrase(lang, "log.media.loose", { n })
211
+ : ns.length === 1
212
+ ? phrase(lang, "log.media.one", { name: title, image: ns[0] })
213
+ : phrase(lang, "log.media", { name: title, n, images: ns.length ? ranges(ns) : "" });
214
+ return { text, kind };
215
+ });
216
+ if (lines.length)
217
+ return lines;
218
+ // no operations: the readers' notes of a book, else the subject as strom wrote it
219
+ const reading = /^Reading (B\d+)-(\d+)-(\d+): (\d+) images?/.exec(subject);
220
+ if (reading)
221
+ return [{ text: phrase(lang, "log.reading", { name: get(reading[1])?.title ?? reading[1], n: reading[4], images: reading[2] === reading[3] ? reading[2] : `${reading[2]}–${reading[3]}` }), kind: "source" }];
222
+ return ops.length ? [] : [{ text: subject, kind: "other" }];
223
+ }
@@ -5,12 +5,32 @@
5
5
  // brief says it), near the end every strom command it runs reminds it, and at
6
6
  // the limit an agent that can be resumed gets a few minutes more to write down
7
7
  // what it found and close the session (runners: wrapUp).
8
+ import fs from "node:fs";
9
+ import path from "node:path";
8
10
  /** From how long before the end strom's output reminds the agent (a short session: its last quarter). */
9
11
  export const REMIND_MS = 10 * 60_000;
10
12
  /** From how long before the end the reminder says to write down and close (a short session: its last eighth). */
11
13
  export const CLOSE_MS = 3 * 60_000;
12
14
  /** The time an agent stopped at its limit gets to write down what it found. */
13
15
  export const WRAP_UP_MS = 5 * 60_000;
16
+ /**
17
+ * The user asked a session to finish now (strom session finish): a file of the tree's own, read by every strom
18
+ * command the session's agent runs — a run's session cannot be told otherwise (it reads no input).
19
+ */
20
+ export function finishFile(root, session) {
21
+ return path.join(root, ".strom", "finish", `${session}.json`);
22
+ }
23
+ export function askFinish(root, session, by) {
24
+ fs.mkdirSync(path.dirname(finishFile(root, session)), { recursive: true });
25
+ fs.writeFileSync(finishFile(root, session), JSON.stringify({ at: new Date().toISOString(), by }) + "\n");
26
+ }
27
+ export function finishAsked(root, session) {
28
+ return fs.existsSync(finishFile(root, session));
29
+ }
30
+ /** What every strom command says to the agent of a session the user asked to finish. */
31
+ export const FINISH_LINE = "⏳ the user asks you to finish this session now: start nothing new; record in strom what you found and have not recorded yet " +
32
+ '(facts, sources, the images searched, in vain too), then strom session close --continue --summary "…" --next "exactly where you stopped" ' +
33
+ "(or finish the task, if it is done).";
14
34
  /** When this session is stopped (ms since the epoch), if a run set a limit. */
15
35
  export function deadlineOf(env) {
16
36
  const t = Date.parse(env.STROM_DEADLINE ?? "");
@@ -55,6 +55,7 @@ export const SETTINGS = [
55
55
  // Read from the config file only — no variable, no flag, no tree: nothing an agent can set.
56
56
  { key: "agent.permissions", env: "", tree: false, kind: "choice", choices: PERMISSION_LEVELS, description: "what the agent may do without asking you: ask (what the tree allows; anything else it asks), auto (default: what the tree allows; anything else the agent's own review decides, asking only when risky), full (everything but what the tree denies) — only you raise it" },
57
57
  { key: "agent.where", env: "STROM_AGENT_WHERE", tree: false, kind: "choice", choices: ["app", "terminal"], description: "where you talk with the agent: app (its desktop app — the easiest), terminal (its CLI) — unset: the app when it is installed" },
58
+ { key: "agent.browser", env: "", tree: true, kind: "choice", choices: ["archives", "always"], description: "browser tools (Claude in Chrome) for the Claude Code sessions strom starts: archives (default) — only in a research whose connectors fetch through the browser, for their sites; always — in every session, for the sites of the research's archives (a conversation asks about others) — only you turn it on" },
58
59
  { key: "agent.remote", env: "", tree: false, kind: "choice", choices: ["on", "off"], description: "the Claude Code sessions strom starts (strom run, strom chat in the terminal) with Remote Control: on — follow and steer them from claude.ai or the Claude app on your phone; off (default) — only you turn it on" },
59
60
  { key: "updates", env: "STROM_UPDATES", tree: false, kind: "choice", choices: ["check", "off"], description: "look for new versions of strom: check (default — at most once a day, one small file from the project's releases; strom says so, strom update installs it) or off" },
60
61
  { key: "strom.app", env: "", tree: false, kind: "choice", choices: ["yes", "no"], description: "you use the Strom app: yes (strom says which file to import into it), no (strom never mentions it) — unset: strom notices it itself" },
@@ -72,6 +73,7 @@ const FIELDS = {
72
73
  "agent.permissions": "agentPermissions",
73
74
  "agent.where": "agentWhere",
74
75
  "agent.remote": "agentRemote",
76
+ "agent.browser": "agentBrowser",
75
77
  "strom.app": "stromApp",
76
78
  "strom.app.url": "stromAppUrl",
77
79
  "main.person": "mainPerson",
@@ -236,6 +236,11 @@ export function treeBrowserConnectors(tree, shared) {
236
236
  if (!all.length)
237
237
  return [];
238
238
  const fetchedBy = new Set(tree.list("media").flatMap((m) => (m.fetched ? [m.fetched.connector] : [])));
239
+ const hosts = new Set(treeSites(tree));
240
+ return all.filter((c) => fetchedBy.has(c.name) || c.manifest.hosts.some((h) => hosts.has(siteOf(h))));
241
+ }
242
+ /** The sites of the archives a tree's records name (repositories, record sets, sources with an address). */
243
+ function treeSites(tree) {
239
244
  const hosts = new Set();
240
245
  for (const r of [...tree.list("repository"), ...tree.list("recordset"), ...tree.list("source")])
241
246
  if (r.url && !r.retracted)
@@ -245,7 +250,26 @@ export function treeBrowserConnectors(tree, shared) {
245
250
  catch {
246
251
  // not an address
247
252
  }
248
- return all.filter((c) => fetchedBy.has(c.name) || c.manifest.hosts.some((h) => hosts.has(siteOf(h))));
253
+ return [...hosts];
254
+ }
255
+ /**
256
+ * Browser tools for the tree's agent (Claude in Chrome): whether its sessions get them, and for which sites.
257
+ * `agent.browser` archives (default): only where a connector of the tree fetches through the browser, for its sites;
258
+ * always (the user's choice): every session, for the sites of the tree's archives and of the connectors — a
259
+ * conversation asks about any other, a run nobody watches uses none other.
260
+ */
261
+ export function agentBrowser(tree, shared) {
262
+ const via = treeBrowserConnectors(tree, shared);
263
+ const always = new Settings(tree.env, {}).resolve("agent.browser", tree.config)?.value === "always";
264
+ const hosts = new Set(via.flatMap((c) => c.manifest.hosts));
265
+ if (always) {
266
+ for (const c of shared ? scanConnectors(shared).connectors : [])
267
+ for (const h of c.manifest.hosts)
268
+ hosts.add(h);
269
+ for (const h of treeSites(tree))
270
+ hosts.add(h);
271
+ }
272
+ return { on: always || via.length > 0, hosts: [...hosts], always };
249
273
  }
250
274
  export function findConnector(shared, name) {
251
275
  if (!shared)
@@ -0,0 +1,41 @@
1
+ // Which direction of a research (G…) a task belongs to. A task names its
2
+ // research when it was added in a session of one (or named it); a task added
3
+ // elsewhere belongs to the direction its people are in — the narrowest one when
4
+ // several reach them (the ancestors of a grandmother before those of her
5
+ // grandson). The queue, the menu and the Strom app go by the same answer.
6
+ import { researchPeople } from "./frontier.js";
7
+ import { subjectPeople } from "./records.js";
8
+ import { typeOfId } from "./tree.js";
9
+ export function scopes(tree) {
10
+ return tree.list("research").map((research) => ({ research, people: researchPeople(tree, research) }));
11
+ }
12
+ /** The people a task is about: its persons, the partners of its families, the people of its conflicts and hypotheses. */
13
+ export function aboutPeople(tree, subject) {
14
+ const partners = subject.filter((id) => typeOfId(id) === "family").flatMap((id) => tree.get(id)?.partners ?? []);
15
+ return [...new Set([...subjectPeople(tree, subject), ...partners])];
16
+ }
17
+ /** The direction a task belongs to: its own, else the narrowest one its people are in; none for a task about nobody. */
18
+ export function directionOf(tree, t, all) {
19
+ if (t.research)
20
+ return t.research;
21
+ const about = aboutPeople(tree, t.subject);
22
+ if (!about.length)
23
+ return undefined;
24
+ return all
25
+ .filter((s) => about.some((p) => s.people.has(p)))
26
+ .sort((a, b) => a.people.size - b.people.size || a.research.id.localeCompare(b.research.id))[0]?.research.id;
27
+ }
28
+ /**
29
+ * Whether a task is work of a research (strom session start --research, strom
30
+ * run --research): its own, or about the people it is about (a task added
31
+ * elsewhere or before it began); a task about nobody and of no research, any
32
+ * research's.
33
+ */
34
+ export function ofResearch(tree, t, scope) {
35
+ if (t.research === scope.research.id)
36
+ return true;
37
+ const about = aboutPeople(tree, t.subject);
38
+ if (!about.length)
39
+ return !t.research;
40
+ return about.some((p) => scope.people.has(p)) || (!t.research && !scope.people.size);
41
+ }
@@ -29,15 +29,19 @@ export const LINK_SCHEME = "strom-research";
29
29
  * as it is now into the app's window, open the research's menu, a conversation with the agent (of one person),
30
30
  * a task (answered, put aside, given up, back in the queue), a person's review, a new direction from a person —
31
31
  * and (the app's second wave) a new research with a tree of the app, strom's update, the agent's sessions and
32
- * what they cost, a conflict decided (by the user or the agent), a story approved, a sync taken back, the setup.
32
+ * what they cost, a conflict decided (by the user or the agent), a story approved, a sync taken back, the setup —
33
+ * and (the app's 3.5.0) the research followed live in the app's window; a direction of the research paused, ended or
34
+ * taken up again, a conversation about one direction, a session at work asked to finish.
33
35
  */
34
- export const LINK_ACTIONS = ["send", "excerpt", "app", "open", "chat", "task", "review", "research", "new", "update", "sessions", "conflict", "story", "sync-undo", "setup"];
36
+ export const LINK_ACTIONS = ["send", "excerpt", "app", "open", "chat", "task", "review", "research", "new", "update", "sessions", "conflict", "story", "sync-undo", "setup", "live", "direction", "finish"];
35
37
  /** A person's review: the person, with the family, with the ancestors (strom review --scope). */
36
38
  export const LINK_SCOPES = ["person", "family", "line"];
37
39
  /** A new direction from a person (strom research new --direction). */
38
40
  export const LINK_DIRECTIONS = ["ancestors", "descendants"];
39
41
  /** What a link does with a task: put it aside, give it up, back into the queue (none: answer it). */
40
42
  export const LINK_TASK_DOS = ["park", "drop", "wake"];
43
+ /** What a link does with a direction of the research (strom research pause|done|resume). */
44
+ export const LINK_DIRECTION_DOS = ["pause", "done", "resume"];
41
45
  /** What a link does with a conflict: the user decides it, or leaves it to the agent. */
42
46
  export const LINK_CONFLICT_DOS = ["decide", "agent"];
43
47
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -46,6 +50,8 @@ const PERSON = /^P\d{1,9}$/;
46
50
  const TASK = /^T\d{1,9}$/;
47
51
  const CONFLICT = /^X\d{1,9}$/;
48
52
  const INPUT = /^I\d{1,9}$/;
53
+ const RESEARCH = /^G\d{1,9}$/;
54
+ const SESSION = /^N\d{1,9}$/;
49
55
  /** The app's own mark of a tree it hands over (32 random bytes, base64url). */
50
56
  export const APP_TOKEN = /^[A-Za-z0-9_-]{22,43}$/;
51
57
  /** An excerpt's mark (_STROM_CLIP): letters, digits and "-", at most 32. */
@@ -103,6 +109,7 @@ export function parseLink(text) {
103
109
  switch (action) {
104
110
  case "send":
105
111
  case "app":
112
+ case "live":
106
113
  case "open":
107
114
  case "update":
108
115
  case "sessions":
@@ -115,8 +122,10 @@ export function parseLink(text) {
115
122
  throw new LinkError("no excerpt named");
116
123
  return { action, tree: id, source, clip };
117
124
  }
118
- case "chat":
119
- return { action, tree: id, ...(person ? { person } : {}) };
125
+ case "chat": {
126
+ const research = idOf(q, "research", RESEARCH, false);
127
+ return { action, tree: id, ...(person ? { person } : {}), ...(research ? { research } : {}) };
128
+ }
120
129
  case "task": {
121
130
  const task = idOf(q, "task", TASK, true);
122
131
  return { action, tree: id, task, ...(q.has("do") ? { do: oneOf(q, "do", LINK_TASK_DOS) } : {}) };
@@ -136,6 +145,10 @@ export function parseLink(text) {
136
145
  return { action, tree: id, id: idOf(q, "id", CONFLICT, true), do: oneOf(q, "do", LINK_CONFLICT_DOS, "decide") };
137
146
  case "sync-undo":
138
147
  return { action, tree: id, intake: idOf(q, "intake", INPUT, true) };
148
+ case "direction":
149
+ return { action, tree: id, id: idOf(q, "id", RESEARCH, true), do: oneOf(q, "do", LINK_DIRECTION_DOS) };
150
+ case "finish":
151
+ return { action, tree: id, session: idOf(q, "session", SESSION, true) };
139
152
  }
140
153
  throw new LinkError(`unknown action "${action.slice(0, 32)}"`);
141
154
  }