@shanesaravia/hive 0.2.1 → 0.4.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 (56) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +18 -1
  3. package/node_modules/@hive/shared/dist/directStudio.d.ts +6 -0
  4. package/node_modules/@hive/shared/dist/directStudio.js +12 -0
  5. package/node_modules/@hive/shared/dist/index.d.ts +2 -0
  6. package/node_modules/@hive/shared/dist/index.js +2 -0
  7. package/node_modules/@hive/shared/dist/reviewHall.d.ts +15 -0
  8. package/node_modules/@hive/shared/dist/reviewHall.js +49 -0
  9. package/node_modules/@hive/shared/dist/status.js +9 -0
  10. package/node_modules/@hive/shared/dist/types.d.ts +191 -1
  11. package/node_modules/@hive/shared/dist/types.js +27 -0
  12. package/node_modules/@hive/shared/dist/workers.d.ts +14 -0
  13. package/node_modules/@hive/shared/dist/workers.js +22 -0
  14. package/package.json +1 -1
  15. package/packages/server/dist/agents/agentDiscovery.js +64 -0
  16. package/packages/server/dist/api/rest.js +538 -23
  17. package/packages/server/dist/api/ws.js +90 -9
  18. package/packages/server/dist/control/launcher.js +50 -11
  19. package/packages/server/dist/control/messaging.js +3 -2
  20. package/packages/server/dist/control/missionQuiesce.js +66 -0
  21. package/packages/server/dist/health/deriveAlerts.js +9 -2
  22. package/packages/server/dist/hooks/hookIngest.js +69 -10
  23. package/packages/server/dist/index.js +44 -4
  24. package/packages/server/dist/loops/loopCommand.js +56 -0
  25. package/packages/server/dist/loops/loopNoop.js +38 -0
  26. package/packages/server/dist/loops/loopScheduler.js +58 -0
  27. package/packages/server/dist/loops/loopStore.js +118 -0
  28. package/packages/server/dist/loops/monitors.js +38 -0
  29. package/packages/server/dist/messages/attachmentStore.js +92 -0
  30. package/packages/server/dist/messages/messagesStore.js +110 -33
  31. package/packages/server/dist/missions/missionsStore.js +9 -0
  32. package/packages/server/dist/missions/reopenOnWork.js +20 -0
  33. package/packages/server/dist/plans/planReconcile.js +114 -0
  34. package/packages/server/dist/plans/plansStore.js +46 -3
  35. package/packages/server/dist/reviews/reviewDiff.js +47 -0
  36. package/packages/server/dist/roster/missionReplay.js +82 -0
  37. package/packages/server/dist/roster/replyAsk.js +62 -0
  38. package/packages/server/dist/roster/rosterBuilder.js +78 -147
  39. package/packages/server/dist/roster/workerIdentity.js +923 -0
  40. package/packages/server/dist/skills/skillDiscovery.js +28 -4
  41. package/packages/server/dist/terminals/claudeStreamClient.js +90 -0
  42. package/packages/server/dist/terminals/codexAppServerClient.js +195 -0
  43. package/packages/server/dist/terminals/providerDetection.js +27 -0
  44. package/packages/server/dist/terminals/terminalCapability.js +45 -0
  45. package/packages/server/dist/terminals/terminalFeatures.js +11 -0
  46. package/packages/server/dist/terminals/terminalObservability.js +21 -0
  47. package/packages/server/dist/terminals/terminalRuntime.js +125 -0
  48. package/packages/server/dist/terminals/terminalStream.js +30 -0
  49. package/packages/server/dist/transcripts/transcriptReader.js +345 -0
  50. package/packages/server/dist/watch/jobsWatcher.js +55 -24
  51. package/packages/web/dist/assets/index-DWjqiitn.js +17 -0
  52. package/packages/web/dist/assets/index-rd4RnLqj.css +2 -0
  53. package/packages/web/dist/index.html +2 -2
  54. package/templates/agents/hive-orchestrator.md +1 -0
  55. package/packages/web/dist/assets/index-Bzle5Xla.css +0 -2
  56. package/packages/web/dist/assets/index-C6AY0vYC.js +0 -11
@@ -0,0 +1,114 @@
1
+ import { matchesWorker } from "@hive/shared";
2
+ import { allPlanTasks } from "./plansStore.js";
3
+ const key = (value) => value?.trim().toLowerCase() || undefined;
4
+ /**
5
+ * Reconciles the board against the workers that are actually running.
6
+ *
7
+ * Orchestrators reliably launch workers but often forget to republish the plan,
8
+ * and a worker launched without a `delegating` event carries no task id at all.
9
+ * Either way the board sits at "queued" while work is visibly in flight. Rather
10
+ * than warn about that, derive the link from evidence Hive already has: the
11
+ * task ids bound to the worker's identity, or an exact match between the Agent
12
+ * description and a task's title or deliverable.
13
+ *
14
+ * Deliberately one-directional. A running worker is proof work started, so
15
+ * queued/blocked tasks may advance to working. A worker *stopping* is not proof
16
+ * the task succeeded, so completion still requires the orchestrator's report or
17
+ * a gate — this never marks anything done.
18
+ */
19
+ export function reconcilePlanWorkers(plan, workers) {
20
+ if (!plan)
21
+ return [];
22
+ const tasks = plan.phases.flatMap((phase) => allPlanTasks(phase.tasks));
23
+ if (!tasks.length)
24
+ return [];
25
+ const open = (task) => task.status !== "completed" && task.status !== "cancelled";
26
+ const bindings = [];
27
+ // A task already claimed by another worker is never re-pointed here.
28
+ const claimedTasks = new Set(tasks.filter((task) => task.workerId || task.owner).map((task) => task.id));
29
+ for (const worker of workers) {
30
+ const explicit = (worker.taskIds ?? []).filter((id) => tasks.some((task) => task.id === id && open(task)));
31
+ if (explicit.length) {
32
+ for (const taskId of explicit)
33
+ bindings.push({ taskId, canonicalWorker: worker.id, label: worker.label, inferred: false });
34
+ continue;
35
+ }
36
+ // No `--task` was ever emitted for this worker. Fall back to an exact,
37
+ // unambiguous text match: orchestrators normally pass the task's title or
38
+ // its deliverable as the Agent description.
39
+ const name = key(worker.label);
40
+ if (!name)
41
+ continue;
42
+ const candidates = tasks.filter((task) => open(task) && !claimedTasks.has(task.id)
43
+ && (key(task.title) === name || key(task.assignment) === name || key(task.description) === name));
44
+ if (candidates.length !== 1)
45
+ continue;
46
+ claimedTasks.add(candidates[0].id);
47
+ bindings.push({ taskId: candidates[0].id, canonicalWorker: worker.id, label: worker.label, inferred: true });
48
+ }
49
+ // Skip anything the board already reflects.
50
+ return bindings.filter(({ taskId, canonicalWorker }) => {
51
+ const task = tasks.find((item) => item.id === taskId);
52
+ if (!task)
53
+ return false;
54
+ const worker = workers.find((item) => item.id === canonicalWorker);
55
+ const named = matchesWorker(task.workerId, worker) || matchesWorker(task.owner, worker);
56
+ const started = task.status === "working" || task.status === "reviewing";
57
+ return !named || !started;
58
+ });
59
+ }
60
+ /**
61
+ * Fill each task's changed files from the writes Hive already observed.
62
+ *
63
+ * `changedFiles` appears in the orchestrator's plan schema but nothing in its
64
+ * instructions ever asks it to be populated, so in practice it stays empty and
65
+ * the acceptance view has no idea what a mission touched. Hive does not need
66
+ * to be told: every file-write hook carries the path and the worker that made
67
+ * it, and workers are already bound to tasks. Deriving beats reporting here —
68
+ * it cannot drift from what actually happened, and it needs no cooperation
69
+ * from the model.
70
+ *
71
+ * Anything the orchestrator *did* report is kept and merged, never replaced:
72
+ * it may know about writes made before Hive was watching.
73
+ */
74
+ export function deriveChangedFiles(plan, workers, events) {
75
+ if (!plan)
76
+ return plan;
77
+ const writes = events.filter((event) => event.activityKind === "file_write" && event.filePath);
78
+ if (!writes.length)
79
+ return plan;
80
+ const tasks = plan.phases.flatMap((phase) => allPlanTasks(phase.tasks));
81
+ const byTask = new Map();
82
+ const add = (taskId, filePath) => {
83
+ const set = byTask.get(taskId) ?? new Set();
84
+ set.add(filePath);
85
+ byTask.set(taskId, set);
86
+ };
87
+ for (const event of writes) {
88
+ // An explicit `--task` is the orchestrator's own attribution; trust it.
89
+ if (event.targetTask && tasks.some((task) => task.id === event.targetTask)) {
90
+ add(event.targetTask, event.filePath);
91
+ continue;
92
+ }
93
+ // Otherwise go through the worker, using the one identity comparison the
94
+ // rest of Hive uses, so an alias or a delegation label resolves the same.
95
+ const worker = workers.find((item) => matchesWorker(event.targetWorker, item));
96
+ if (!worker)
97
+ continue;
98
+ const owned = tasks.filter((task) => (worker.taskIds ?? []).includes(task.id) || matchesWorker(task.workerId, worker) || matchesWorker(task.owner, worker));
99
+ // A worker covering several tasks gives no evidence about which one a
100
+ // write belongs to, so attribute only when the answer is unambiguous.
101
+ if (owned.length === 1)
102
+ add(owned[0].id, event.filePath);
103
+ }
104
+ if (!byTask.size)
105
+ return plan;
106
+ const merge = (task) => {
107
+ const derived = byTask.get(task.id);
108
+ const subtasks = task.subtasks?.length ? task.subtasks.map(merge) : task.subtasks;
109
+ if (!derived)
110
+ return subtasks === task.subtasks ? task : { ...task, subtasks };
111
+ return { ...task, changedFiles: [...new Set([...(task.changedFiles ?? []), ...derived])].sort(), subtasks };
112
+ };
113
+ return { ...plan, phases: plan.phases.map((phase) => ({ ...phase, tasks: phase.tasks.map(merge) })) };
114
+ }
@@ -124,8 +124,11 @@ export class PlansStore {
124
124
  const now = Date.now();
125
125
  const mapTasks = (tasks) => tasks.map((task) => {
126
126
  const subtasks = task.subtasks?.length ? mapTasks(task.subtasks) : task.subtasks;
127
+ // A worker is addressed by whichever name the orchestrator used; the plan
128
+ // stores the canonical identity so every consumer can compare exactly.
129
+ const workerNames = [input.canonicalWorker, input.targetWorker].filter((value) => Boolean(value));
127
130
  const matched = (input.targetTask !== undefined && task.id === input.targetTask)
128
- || (input.targetWorker !== undefined && (task.workerId === input.targetWorker || task.owner === input.targetWorker));
131
+ || workerNames.some((name) => task.workerId === name || task.owner === name);
129
132
  // A parent cannot complete ahead of its subtasks (normalize rejects it).
130
133
  const blockedBySubtasks = nextStatus === "completed" && (subtasks ?? []).some((subtask) => subtask.status !== "completed" && subtask.status !== "cancelled");
131
134
  if (!matched || blockedBySubtasks || !allowedFrom[nextStatus].includes(task.status)) {
@@ -138,8 +141,8 @@ export class PlansStore {
138
141
  status: nextStatus,
139
142
  updatedAt: now,
140
143
  startedAt: task.startedAt ?? now,
141
- owner: task.owner ?? input.targetWorker,
142
- workerId: task.workerId ?? input.targetWorker,
144
+ owner: task.owner ?? input.canonicalWorker ?? input.targetWorker,
145
+ workerId: input.canonicalWorker ?? task.workerId ?? input.targetWorker,
143
146
  evidence: nextStatus === "completed" && input.evidence?.trim() ? [...task.evidence, input.evidence.trim().slice(0, 300)] : task.evidence,
144
147
  };
145
148
  });
@@ -157,6 +160,46 @@ export class PlansStore {
157
160
  return undefined;
158
161
  return this.replace(missionId, { ...(plan.layout === "flat" ? { tasks: phases[0]?.tasks ?? [] } : { phases }), gates: plan.gates ?? [], approvalStatus: plan.approvalStatus ?? "proposed", approvalReason: plan.approvalReason });
159
162
  }
163
+ /**
164
+ * Records that a worker is running a task: stamps the canonical identity and
165
+ * advances a queued/blocked task to working. Forward-only and idempotent, so
166
+ * a repeated snapshot cannot churn revisions, and never marks work complete —
167
+ * a running worker proves work started, not that it succeeded.
168
+ */
169
+ attachWorker(missionId, input) {
170
+ const plan = this.get(missionId);
171
+ if (!plan)
172
+ return undefined;
173
+ let changed = false;
174
+ const now = Date.now();
175
+ const mapTasks = (tasks) => tasks.map((task) => {
176
+ const subtasks = task.subtasks?.length ? mapTasks(task.subtasks) : task.subtasks;
177
+ if (task.id !== input.taskId)
178
+ return subtasks === task.subtasks ? task : { ...task, subtasks };
179
+ const advancing = task.status === "queued" || task.status === "blocked";
180
+ if (task.workerId === input.canonicalWorker && !advancing)
181
+ return subtasks === task.subtasks ? task : { ...task, subtasks };
182
+ changed = true;
183
+ return {
184
+ ...task,
185
+ subtasks,
186
+ status: advancing ? "working" : task.status,
187
+ owner: task.owner ?? input.label ?? input.canonicalWorker,
188
+ workerId: input.canonicalWorker,
189
+ startedAt: task.startedAt ?? now,
190
+ updatedAt: now,
191
+ };
192
+ });
193
+ const phases = plan.phases.map((phase) => {
194
+ const tasks = mapTasks(phase.tasks);
195
+ const statuses = allPlanTasks(tasks).map((task) => task.status);
196
+ const status = (phase.status === "queued" || phase.status === "completed") && statuses.some((s) => s === "working" || s === "reviewing") ? "working" : phase.status;
197
+ return { ...phase, status, tasks };
198
+ });
199
+ if (!changed)
200
+ return undefined;
201
+ return this.replace(missionId, { ...(plan.layout === "flat" ? { tasks: phases[0]?.tasks ?? [] } : { phases }), gates: plan.gates ?? [], approvalStatus: plan.approvalStatus ?? "proposed", approvalReason: plan.approvalReason });
202
+ }
160
203
  removeMission(missionId) { this.db.transaction(() => { this.db.prepare("DELETE FROM mission_plans WHERE mission_id = ?").run(missionId); this.db.prepare("DELETE FROM mission_plan_revisions WHERE mission_id = ?").run(missionId); })(); for (const listener of this.listeners)
161
204
  listener(); }
162
205
  migrateMission(oldId, newId) { this.db.transaction(() => { const current = this.get(oldId); if (current) {
@@ -0,0 +1,47 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ const MAX_PATCH = 200_000;
4
+ function git(cwd, args) {
5
+ return execFileSync("git", args, { cwd, encoding: "utf8", timeout: 5_000, maxBuffer: 2_000_000, stdio: ["ignore", "pipe", "pipe"] }).trim();
6
+ }
7
+ function gitDiff(cwd, args) {
8
+ try {
9
+ return git(cwd, ["diff", ...args]);
10
+ }
11
+ catch (error) {
12
+ const stdout = error.stdout;
13
+ if (stdout !== undefined)
14
+ return String(stdout).trim();
15
+ throw error;
16
+ }
17
+ }
18
+ /** Read-only source-control evidence for a mission-controlled repository. */
19
+ export function reviewDiff(cwd) {
20
+ if (!cwd || !fs.existsSync(cwd))
21
+ return { available: false, stat: "", patch: "", files: [], truncated: false, error: "Repository is unavailable." };
22
+ try {
23
+ if (git(cwd, ["rev-parse", "--is-inside-work-tree"]) !== "true")
24
+ throw new Error("Not a Git worktree");
25
+ let base;
26
+ for (const candidate of ["origin/main", "main", "origin/master", "master"]) {
27
+ try {
28
+ base = git(cwd, ["merge-base", "HEAD", candidate]);
29
+ if (base)
30
+ break;
31
+ }
32
+ catch { /* try next baseline */ }
33
+ }
34
+ const range = base ? [`${base}..HEAD`] : [];
35
+ const committed = gitDiff(cwd, range);
36
+ const working = gitDiff(cwd, ["HEAD"]);
37
+ const untracked = git(cwd, ["ls-files", "--others", "--exclude-standard"]).split("\n").filter(Boolean);
38
+ const untrackedPatches = untracked.map((file) => gitDiff(cwd, ["--no-index", "--", "/dev/null", file]));
39
+ const patch = [committed, working, ...untrackedPatches].filter(Boolean).join("\n");
40
+ const stat = [git(cwd, ["diff", "--stat", ...range]), git(cwd, ["diff", "--stat", "HEAD"]), untracked.length ? `${untracked.length} untracked file${untracked.length === 1 ? "" : "s"}` : ""].filter(Boolean).join("\n");
41
+ const files = [...new Set([...git(cwd, ["diff", "--name-only", ...range]).split("\n"), ...git(cwd, ["diff", "--name-only", "HEAD"]).split("\n"), ...untracked].filter(Boolean))];
42
+ return { available: true, base, stat, patch: patch.slice(0, MAX_PATCH), files, truncated: patch.length > MAX_PATCH };
43
+ }
44
+ catch (error) {
45
+ return { available: false, stat: "", patch: "", files: [], truncated: false, error: error.message };
46
+ }
47
+ }
@@ -0,0 +1,82 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { MissionsStore } from "../missions/missionsStore.js";
6
+ import { buildFleetSnapshot } from "./rosterBuilder.js";
7
+ import { WorkerIdentityStore } from "./workerIdentity.js";
8
+ const fixturesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
9
+ export function loadMissionFixture(name) {
10
+ return JSON.parse(fs.readFileSync(path.join(fixturesDir, `${name}.events.json`), "utf8"));
11
+ }
12
+ /**
13
+ * The manager's job is `done` between a Stop and the next prompt — the turn
14
+ * boundary that repeatedly got mistaken for the end of the mission.
15
+ */
16
+ function jobStateAt(events) {
17
+ let state = "working";
18
+ for (const event of events) {
19
+ if (event.hookEventName === "Stop")
20
+ state = "done";
21
+ if (event.hookEventName === "UserPromptSubmit")
22
+ state = "working";
23
+ }
24
+ return state;
25
+ }
26
+ export function replayMission(fixture, options = {}) {
27
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `hive-replay-${fixture.name}-`));
28
+ options.cleanup?.(() => fs.rmSync(dir, { recursive: true, force: true }));
29
+ const missions = new MissionsStore(path.join(dir, "missions.json"), path.join(dir, "threads.json"));
30
+ missions.init();
31
+ const primaryJob = fixture.jobs[0];
32
+ const mission = missions.create({
33
+ name: fixture.name, objective: fixture.objective, jobId: primaryJob?.jobId ?? fixture.missionId,
34
+ sessionId: fixture.sessionId, mode: fixture.mode,
35
+ });
36
+ for (const { jobId } of fixture.jobs.slice(1))
37
+ missions.linkJob(mission.id, jobId);
38
+ const identity = new WorkerIdentityStore(path.join(dir, "hive.db"));
39
+ // A read-only stand-in for the plans store: the recorded revisions already
40
+ // contain the worker bindings production wrote, so replaying must not write
41
+ // new ones over them.
42
+ const revisions = [...(fixture.planRevisions ?? [])].sort((a, b) => a.updatedAt - b.updatedAt);
43
+ const plansAt = (at) => ({
44
+ get: () => [...revisions].reverse().find((entry) => entry.updatedAt <= at)?.plan,
45
+ attachWorker: () => undefined,
46
+ });
47
+ const start = fixture.events[0]?.ts ?? 0;
48
+ const steps = [];
49
+ for (let index = 1; index <= fixture.events.length; index += 1) {
50
+ const seen = fixture.events.slice(0, index);
51
+ const event = seen[seen.length - 1];
52
+ const now = event.ts + 1;
53
+ // The REST ingest moves the mission to review on this event; a replay that
54
+ // left it active never showed the office the moment the manager leaves
55
+ // for the hall while workers are still on their way out (mission test41).
56
+ if (event.source === "custom" && event.phase === "ready_for_review")
57
+ missions.setLifecycleStatus(mission.id, "ready_for_review");
58
+ const state = jobStateAt(seen);
59
+ const jobs = new Map(fixture.jobs.map(({ jobId, job }) => [jobId, {
60
+ ...job,
61
+ state,
62
+ // The provider's fan file is written as workers finish; before the turn
63
+ // ends the roster only has hook evidence to go on. A recorded fixture
64
+ // holds the file's *final* contents, so entries — and their doneAt — are
65
+ // masked until the moment they were really written. Without that the
66
+ // replay hands the roster a worker's completion long before it happened.
67
+ fan: state === "done"
68
+ ? job.fan.filter((entry) => entry.startedAt <= now).map((entry) => ({ ...entry, doneAt: entry.doneAt !== undefined && entry.doneAt <= now ? entry.doneAt : undefined }))
69
+ : [],
70
+ }]));
71
+ const events = {
72
+ recentFor: (id, limit = 50) => seen.filter((item) => item.sessionId === id || item.jobId === id).slice(-limit),
73
+ sessionIds: () => [...new Set(seen.map((item) => item.sessionId))],
74
+ };
75
+ steps.push({ elapsedMs: event.ts - start, event, snapshot: buildFleetSnapshot(new Map(), jobs, events, missions, undefined, plansAt(now), now, identity) });
76
+ }
77
+ return steps;
78
+ }
79
+ /** The mission node under replay, for the one mission a fixture contains. */
80
+ export function replayedMission(step) {
81
+ return step.snapshot.orchestrators[0];
82
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Phrases that mark a reply as genuinely handing a choice back to the reader.
3
+ *
4
+ * Deliberately narrow. A turn ending "let me know if you need anything else"
5
+ * is politeness, not a decision, and surfacing those as "Needs you" would
6
+ * teach the reader to ignore the one signal that should never be ignored.
7
+ */
8
+ const ASK_PATTERNS = [
9
+ /\bfor you to decide\b/i,
10
+ /\byour (call|decision|choice)\b/i,
11
+ /\bup to you\b/i,
12
+ /\bsay the word\b/i,
13
+ /\bwhich (one )?(would|do) you (prefer|want)\b/i,
14
+ /\b(do|would) you want me to\b/i,
15
+ /\bwould you like me to\b/i,
16
+ /\bshall I\b/i,
17
+ /\bshould I\b[^?]*\?/i,
18
+ /\bplease (confirm|advise|choose)\b/i,
19
+ /\blet me know (which|whether|if you want|if you'd)\b/i,
20
+ /\bneeds? your (input|decision|sign-?off|approval|steer)\b/i,
21
+ /\bwaiting (on|for) your (call|decision|answer|input)\b/i,
22
+ ];
23
+ /** Sign-offs that look like asks but close a turn rather than pausing it. */
24
+ const CLOSING_PATTERNS = [
25
+ /^\s*(anything else|any(thing)? more)\b/i,
26
+ /\blet me know if you (need|have|spot|want) (anything|any|something)\b/i,
27
+ ];
28
+ const MAX_QUESTION_CHARS = 800;
29
+ /** Strips leading markdown emphasis and list markers so the ask reads as prose. */
30
+ function tidy(line) {
31
+ return line.replace(/^\s*[-*]\s+/, "").replace(/\*\*/g, "").trim();
32
+ }
33
+ /**
34
+ * Finds a decision the agent handed back in the prose of its reply.
35
+ *
36
+ * Hive's decision inbox is fed by structured signals — emitted decisions,
37
+ * parked permissions, provider prompts — none of which fire when an agent
38
+ * simply finishes its turn by asking a question. Those asks were reaching the
39
+ * transcript with no affordance at all: no "Needs you", nothing in the inbox,
40
+ * nothing to answer, and a mission that looked finished while it waited.
41
+ *
42
+ * Returns the ask from the first marker line onward, so an enumerated list of
43
+ * choices arrives with the question that introduced it.
44
+ */
45
+ export function detectReplyAsk(reply) {
46
+ if (!reply?.trim())
47
+ return undefined;
48
+ const lines = reply.split("\n");
49
+ const at = lines.findIndex((line) => {
50
+ if (CLOSING_PATTERNS.some((pattern) => pattern.test(line)))
51
+ return false;
52
+ return ASK_PATTERNS.some((pattern) => pattern.test(line));
53
+ });
54
+ if (at === -1)
55
+ return undefined;
56
+ // From the marker to the end: an ask is routinely a header followed by the
57
+ // options it introduces, and the header alone would be unanswerable.
58
+ const question = lines.slice(at).map(tidy).filter(Boolean).join("\n").trim();
59
+ if (!question)
60
+ return undefined;
61
+ return { question: question.length > MAX_QUESTION_CHARS ? `${question.slice(0, MAX_QUESTION_CHARS).trimEnd()}…` : question };
62
+ }