@nopeek/agent-bridge 0.7.11 → 0.7.15

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/README.md CHANGED
@@ -41,9 +41,9 @@ The `npr_…` code comes from Settings → Connect your computer (bots) in the a
41
41
 
42
42
  Hermes brains prefer the local agent API (`HERMES_API_URL`, default
43
43
  `http://127.0.0.1:8642`) when `/health` is up, and fall back to `hermes chat`.
44
- While Hermes is working, the bot posts Telegram-style tool lines a few at a
45
- time (read file, patch, skill, memory). A quiet gap starts a new message.
46
- The final reply is the answer plus a short recap when tools were used.
44
+ While Hermes is working, the bot edits one Telegram-style progress bubble
45
+ (read file, patch, skill, memory). New tools update that bubble instead of
46
+ stacking under a running summary. The recap is a separate message underneath.
47
47
  Idle kill waits for **no output and no CPU** (default 15 min). Authenticated
48
48
  `GET /status` includes `bridge`, `bots[]` (each with `lastTurn`), `hermesApi`,
49
49
  and `lastTurn`. See `docs/HERMES-BRAIN-PLAN.md`.
@@ -79,6 +79,7 @@ Context is passed as environment variables:
79
79
  | `NOPEEK_BOT_USER_ID` | user id of the bot |
80
80
  | `NOPEEK_CHANNEL_ID` | channel the message arrived in |
81
81
  | `NOPEEK_SENDER_USER_ID` | who sent the message |
82
+ | `NOPEEK_FILES` | JSON array of decrypted inbound files (`path`, `name`, `contentType`, `size`, `kind`) |
82
83
 
83
84
  Examples:
84
85
 
@@ -109,7 +110,8 @@ Content-Type: application/json
109
110
  "botHandle": "weatherbot",
110
111
  "botUserId": "usr_…",
111
112
  "channelId": "ch_…",
112
- "senderUserId": "usr_…"
113
+ "senderUserId": "usr_…",
114
+ "files": []
113
115
  }
114
116
  ```
115
117
 
package/dist/bot.d.ts CHANGED
@@ -38,6 +38,7 @@ export declare class BotRunner {
38
38
  private channelModes;
39
39
  private mutedOff;
40
40
  private chains;
41
+ private turns;
41
42
  private cantPost;
42
43
  private log;
43
44
  private logErr;
@@ -102,6 +103,9 @@ export declare class BotRunner {
102
103
  /** Bounded dedupe so a redelivered frame is never answered twice. */
103
104
  private remember;
104
105
  private handleMessage;
106
+ private runBrainTurn;
107
+ private collectFollowups;
108
+ private ingestFollowup;
105
109
  /** A FORBIDDEN post (broadcast channel, bot not an operator) fails for every
106
110
  * future message too — mute the channel so the brain stops running there. */
107
111
  private notePostFailure;
package/dist/bot.js CHANGED
@@ -7,7 +7,9 @@ import { isChannelMode } from "./control.js";
7
7
  import { FALLBACK_REPLY, resolveBrain } from "./brain.js";
8
8
  import { beginTurn, finishTurn, lastTurnFor } from "./last-turn.js";
9
9
  import { FileStore } from "./storage.js";
10
- import { ToolProgressFlusher } from "./tool-progress.js";
10
+ import { TurnPublisher } from "./tool-progress.js";
11
+ import { ChannelTurn, coalesceFollowups, isStopRequest, wrapInterruptedFollowup, } from "./mid-turn.js";
12
+ import { buildInboundPrompt, describeStructured, hasInboundWork, isControlType, parseAttachments, saveInboundFiles, } from "./inbound-files.js";
11
13
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
12
14
  const MAX_BACKOFF_MS = 60_000;
13
15
  // Owner-membership answers are cached per channel for a short window; a
@@ -73,6 +75,9 @@ export class BotRunner {
73
75
  // order, one at a time — concurrent brain runs against the same agent session
74
76
  // (e.g. one Hermes session per channel) deadlock or reply out of order.
75
77
  chains = new Map();
78
+ // Live brain turn per channel so a follow-up can abort Hermes immediately
79
+ // instead of waiting for the current 30-minute job to finish.
80
+ turns = new Map();
76
81
  // Channels the bot can't post to (e.g. broadcast, non-operator): after the
77
82
  // first FORBIDDEN, skip the brain entirely — replies there can never land.
78
83
  cantPost = new Set();
@@ -373,6 +378,12 @@ export class BotRunner {
373
378
  np.on("member.joined", onRosterChange);
374
379
  np.on("member.left", onRosterChange);
375
380
  np.on("message", ((m) => {
381
+ const live = this.turns.get(m.channelId);
382
+ if (live?.active) {
383
+ live.interrupt({ id: m.messageId, message: m });
384
+ this.log(`${m.channelId} mid-turn interrupt from ${m.senderUserId}: ${String(m.body && "text" in m.body ? m.body.text : "").slice(0, 80)}`);
385
+ return;
386
+ }
376
387
  const prev = this.chains.get(m.channelId) ?? Promise.resolve();
377
388
  const next = prev.then(() => this.handleMessage(m).catch((err) => {
378
389
  this.notePostFailure(m.channelId, err);
@@ -457,8 +468,11 @@ export class BotRunner {
457
468
  }
458
469
  return;
459
470
  }
460
- if (m.body?.type !== "text" || typeof m.body.text !== "string" || !m.body.text.trim())
471
+ const body = m.body;
472
+ if (!body || isControlType(body.type) || !hasInboundWork(body))
461
473
  return;
474
+ const attachments = parseAttachments(body);
475
+ const caption = typeof body.text === "string" ? body.text : "";
462
476
  // OWNER-PRESENT POLICY: in a multi-party channel (anything but a direct
463
477
  // chat), a non-owner sender may use the bot ONLY while the bot's owner is
464
478
  // also a member of that channel. Owner absent → completely silent (no
@@ -516,7 +530,7 @@ export class BotRunner {
516
530
  // predictable: mention-only means even the owner must @mention there.
517
531
  // DIRECT channels always behave as "everyone" (a DM with the bot is always
518
532
  // for the bot).
519
- let text = m.body.text;
533
+ let text = caption;
520
534
  if (ch.record.kind !== "direct" && ch.record.kind !== "dm") {
521
535
  const mode = this.channelModes.get(m.channelId) ?? "everyone";
522
536
  if (mode === "off") {
@@ -534,101 +548,218 @@ export class BotRunner {
534
548
  text = stripped;
535
549
  }
536
550
  }
551
+ let files = [];
552
+ const failures = [];
553
+ const previews = [];
554
+ if (attachments.length) {
555
+ const saved = await saveInboundFiles(async (att) => {
556
+ const blob = await ch.fetchAttachment(att);
557
+ return Buffer.from(await blob.arrayBuffer());
558
+ }, attachments, m.messageId);
559
+ files = saved.files;
560
+ failures.push(...saved.failures);
561
+ previews.push(...saved.previews);
562
+ this.log(`${m.channelId} inbound ${body.type}: ${files.length} file(s) saved` +
563
+ (failures.length ? `, ${failures.length} failed` : ""));
564
+ }
565
+ const structured = describeStructured(body);
566
+ text = buildInboundPrompt({ caption: text, files, structured, failures });
567
+ if (previews.length)
568
+ text = `${previews.join("\n\n")}\n\n${text}`;
569
+ if (!text.trim())
570
+ return;
537
571
  this.log(`${m.channelId} <- ${m.senderUserId}: ${text.slice(0, 120)}`);
538
572
  ch.markRead(m.messageId).catch(() => { });
539
- try {
540
- ch.typing(true);
541
- }
542
- catch {
543
- /* typing is best-effort */
544
- }
545
- // Resolve the brain PER MESSAGE: the NoPeek app can change it live over the
546
- // local API (PUT /brains) and the very next message uses the new one.
547
- const resolved = resolveBrain(this.cfg, this.info.handle);
548
- this.brainKind = resolved.kind;
549
- // Streaming: if the brain emits chunks, open a streaming message on the
550
- // FIRST chunk and forward deltas into it (typing indicator stays on until
551
- // then). Deltas can arrive before the placeholder lands — chaining every
552
- // append onto the open promise keeps them ordered and loses none.
553
- // (Ref object rather than a `let`: TS can't see closure assignments.)
554
- const streamRef = { p: null };
555
- const stopTyping = () => {
573
+ await this.runBrainTurn({
574
+ ch,
575
+ channelId: m.channelId,
576
+ senderUserId: m.senderUserId,
577
+ text,
578
+ files,
579
+ interrupted: false,
580
+ });
581
+ }
582
+ async runBrainTurn(args) {
583
+ const { ch, channelId, senderUserId } = args;
584
+ let text = args.text;
585
+ let files = args.files;
586
+ let interrupted = args.interrupted;
587
+ while (true) {
588
+ if (!interrupted && isStopRequest(text)) {
589
+ this.log(`${channelId} stop with nothing running`);
590
+ try {
591
+ await ch.send({ text: "Nothing is running." });
592
+ }
593
+ catch (err) {
594
+ this.notePostFailure(channelId, err);
595
+ }
596
+ this.handled++;
597
+ return;
598
+ }
556
599
  try {
557
- ch.typing(false);
600
+ ch.typing(true);
558
601
  }
559
602
  catch {
560
- /* best-effort */
603
+ /* typing is best-effort */
561
604
  }
562
- };
563
- const onChunk = (delta) => {
564
- if (!delta)
565
- return;
566
- void progress.flush();
567
- if (!streamRef.p) {
568
- stopTyping();
569
- streamRef.p = ch.stream();
570
- streamRef.p.catch((err) => {
571
- this.notePostFailure(m.channelId, err);
605
+ const resolved = resolveBrain(this.cfg, this.info.handle);
606
+ this.brainKind = resolved.kind;
607
+ const stopTyping = () => {
608
+ try {
609
+ ch.typing(false);
610
+ }
611
+ catch {
612
+ /* best-effort */
613
+ }
614
+ };
615
+ const published = new TurnPublisher({
616
+ stream: () => ch.stream(),
617
+ send: async (body) => {
618
+ await ch.send({ text: body });
619
+ },
620
+ }, {
621
+ stopTyping,
622
+ onStreamError: (err) => {
623
+ this.notePostFailure(channelId, err);
572
624
  this.logErr(`stream open failed (falling back to a single send): ${err.message}`);
625
+ },
626
+ });
627
+ const live = new ChannelTurn();
628
+ this.turns.set(channelId, live);
629
+ const prompt = interrupted ? wrapInterruptedFollowup(text) : text;
630
+ let reply = "";
631
+ const stat = beginTurn(this.info.handle, channelId, resolved.kind);
632
+ try {
633
+ reply = await resolved.brain(prompt, {
634
+ botHandle: this.info.handle,
635
+ botUserId: this.info.userId,
636
+ channelId,
637
+ senderUserId,
638
+ signal: live.abort.signal,
639
+ ...(files.length ? { files } : {}),
640
+ }, { onChunk: (delta) => published.onChunk(delta), onTool: (ev) => published.onTool(ev) });
641
+ if (live.abort.signal.aborted) {
642
+ finishTurn(stat, { ok: true, chars: 0, error: "interrupted" });
643
+ await published.finish("").catch(() => { });
644
+ const next = await this.collectFollowups(channelId, live, ch);
645
+ if (!next)
646
+ return;
647
+ text = next.text;
648
+ files = next.files;
649
+ interrupted = true;
650
+ continue;
651
+ }
652
+ const trimmed = reply.trim();
653
+ const timedOut = /went quiet for over \d+s/i.test(trimmed);
654
+ finishTurn(stat, {
655
+ ok: Boolean(trimmed) && !trimmed.startsWith("⚠️"),
656
+ chars: trimmed.length,
657
+ timedOut,
658
+ error: !trimmed ? "empty reply" : trimmed.startsWith("⚠️") ? trimmed.slice(0, 180) : null,
573
659
  });
574
660
  }
575
- streamRef.p.then((s) => s.append(delta)).catch(() => { });
576
- };
577
- const progress = new ToolProgressFlusher(async (body) => {
578
- stopTyping();
579
- await ch.send({ text: body });
580
- });
581
- let reply = "";
582
- const turn = beginTurn(this.info.handle, m.channelId, resolved.kind);
583
- try {
584
- reply = await resolved.brain(text, {
585
- botHandle: this.info.handle,
586
- botUserId: this.info.userId,
587
- channelId: m.channelId,
588
- senderUserId: m.senderUserId,
589
- }, { onChunk, onTool: (ev) => progress.push(ev) });
590
- const trimmed = reply.trim();
591
- const timedOut = /went quiet for over \d+s/i.test(trimmed);
592
- finishTurn(turn, {
593
- ok: Boolean(trimmed) && !trimmed.startsWith("⚠️"),
594
- chars: trimmed.length,
595
- timedOut,
596
- error: !trimmed ? "empty reply" : trimmed.startsWith("⚠️") ? trimmed.slice(0, 180) : null,
597
- });
661
+ catch (err) {
662
+ finishTurn(stat, { ok: false, error: err.message, timedOut: false });
663
+ await published.fail(FALLBACK_REPLY).catch(() => { });
664
+ throw err;
665
+ }
666
+ finally {
667
+ if (this.turns.get(channelId) === live) {
668
+ live.finish();
669
+ this.turns.delete(channelId);
670
+ }
671
+ try {
672
+ ch.typing(false);
673
+ }
674
+ catch {
675
+ /* best-effort */
676
+ }
677
+ }
678
+ const how = await published.finish(reply.trim());
679
+ if (how === "empty") {
680
+ this.log(`brain returned empty reply — ignoring`);
681
+ return;
682
+ }
683
+ this.handled++;
684
+ this.log(`${channelId} -> ${how} reply (${reply.trim().length} chars, handled=${this.handled})`);
685
+ return;
598
686
  }
599
- catch (err) {
600
- finishTurn(turn, { ok: false, error: err.message, timedOut: false });
601
- // Brain blew up mid-stream: finalize the partial bubble with an honest
602
- // error line instead of leaving a forever-blinking cursor.
603
- await progress.flush();
604
- const stream = streamRef.p ? await streamRef.p.catch(() => null) : null;
605
- if (stream)
606
- await stream.fail(FALLBACK_REPLY).catch(() => { });
607
- throw err;
687
+ }
688
+ async collectFollowups(channelId, live, ch) {
689
+ await new Promise((r) => setTimeout(r, 180));
690
+ const queued = live.takeIncoming();
691
+ live.finish();
692
+ if (this.turns.get(channelId) === live)
693
+ this.turns.delete(channelId);
694
+ const prepared = [];
695
+ for (const item of queued) {
696
+ const got = await this.ingestFollowup(item.message);
697
+ if (!got)
698
+ continue;
699
+ ch.markRead(item.message.messageId).catch(() => { });
700
+ prepared.push(got);
608
701
  }
609
- finally {
702
+ if (!prepared.length) {
703
+ this.log(`${channelId} interrupted with no usable follow-up`);
704
+ return null;
705
+ }
706
+ const decision = coalesceFollowups(prepared.map((p) => p.text));
707
+ if (decision.action === "stop") {
708
+ this.log(`${channelId} stopped by user (${prepared.length} follow-up(s))`);
610
709
  try {
611
- ch.typing(false);
710
+ await ch.send({ text: "Stopped." });
612
711
  }
613
- catch {
614
- /* best-effort */
712
+ catch (err) {
713
+ this.notePostFailure(channelId, err);
615
714
  }
616
- }
617
- await progress.flush();
618
- const stream = streamRef.p ? await streamRef.p.catch(() => null) : null;
619
- if (stream) {
620
- await stream.done(reply.trim() || undefined);
621
715
  this.handled++;
622
- this.log(`${m.channelId} -> streamed reply (${reply.trim().length} chars, handled=${this.handled})`);
623
- return;
716
+ return null;
624
717
  }
625
- if (!reply || !reply.trim()) {
626
- this.log(`brain returned empty reply — ignoring`);
627
- return;
718
+ this.log(`${channelId} folding ${prepared.length} follow-up(s) into the same session`);
719
+ return {
720
+ text: decision.text,
721
+ files: prepared.flatMap((p) => p.files),
722
+ };
723
+ }
724
+ async ingestFollowup(m) {
725
+ if (m.senderUserId === this.info.userId)
726
+ return null;
727
+ if (this.cantPost.has(m.channelId))
728
+ return null;
729
+ if (m.decryptionFailed)
730
+ return null;
731
+ if (!this.isAllowed(m.senderUserId))
732
+ return null;
733
+ const body = m.body;
734
+ if (!body || isControlType(body.type) || !hasInboundWork(body))
735
+ return null;
736
+ const attachments = parseAttachments(body);
737
+ let text = typeof body.text === "string" ? body.text : "";
738
+ let files = [];
739
+ const failures = [];
740
+ const previews = [];
741
+ if (attachments.length) {
742
+ try {
743
+ const ch = await this.getChannel(m.channelId);
744
+ const saved = await saveInboundFiles(async (att) => {
745
+ const blob = await ch.fetchAttachment(att);
746
+ return Buffer.from(await blob.arrayBuffer());
747
+ }, attachments, m.messageId);
748
+ files = saved.files;
749
+ failures.push(...saved.failures);
750
+ previews.push(...saved.previews);
751
+ }
752
+ catch (err) {
753
+ this.logErr(`follow-up ingest failed for ${m.messageId}: ${err.message}`);
754
+ }
628
755
  }
629
- await ch.send({ text: reply.trim() });
630
- this.handled++;
631
- this.log(`${m.channelId} -> replied (${reply.trim().length} chars, handled=${this.handled})`);
756
+ const structured = describeStructured(body);
757
+ text = buildInboundPrompt({ caption: text, files, structured, failures });
758
+ if (previews.length)
759
+ text = `${previews.join("\n\n")}\n\n${text}`;
760
+ if (!text.trim())
761
+ return null;
762
+ return { text, files };
632
763
  }
633
764
  /** A FORBIDDEN post (broadcast channel, bot not an operator) fails for every
634
765
  * future message too — mute the channel so the brain stops running there. */
package/dist/brain.d.ts CHANGED
@@ -1,10 +1,22 @@
1
1
  import type { BridgeConfig } from "./config.js";
2
2
  import type { ToolProgressEvent } from "./tool-progress.js";
3
+ /** A decrypted inbound file written onto the Hermes cache for this turn. */
4
+ export interface BrainFile {
5
+ path: string;
6
+ name: string;
7
+ contentType: string;
8
+ size: number;
9
+ kind: "image" | "video" | "audio" | "document";
10
+ }
3
11
  export interface BrainContext {
4
12
  botHandle: string;
5
13
  botUserId: string;
6
14
  channelId: string;
7
15
  senderUserId: string;
16
+ /** Local paths of photos / video / PDFs / any file the human just sent. */
17
+ files?: BrainFile[];
18
+ /** Abort the in-flight brain when a later message interrupts this turn. */
19
+ signal?: AbortSignal;
8
20
  }
9
21
  /**
10
22
  * A brain answers one message. If it can stream, it calls `onChunk(delta)` as
package/dist/brain.js CHANGED
@@ -40,6 +40,7 @@ function cmdBrain(cmd, timeoutMs) {
40
40
  NOPEEK_BOT_USER_ID: ctx.botUserId,
41
41
  NOPEEK_CHANNEL_ID: ctx.channelId,
42
42
  NOPEEK_SENDER_USER_ID: ctx.senderUserId,
43
+ ...(ctx.files?.length ? { NOPEEK_FILES: JSON.stringify(ctx.files) } : {}),
43
44
  },
44
45
  });
45
46
  let stdout = "";
@@ -113,6 +114,7 @@ function urlBrain(url, timeoutMs) {
113
114
  botUserId: ctx.botUserId,
114
115
  channelId: ctx.channelId,
115
116
  senderUserId: ctx.senderUserId,
117
+ ...(ctx.files?.length ? { files: ctx.files } : {}),
116
118
  }),
117
119
  });
118
120
  if (!res.ok) {
package/dist/bridge.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { BridgeConfig, Pairing, BrainSpec, BrainBackend } from "./config.js";
2
- export declare const VERSION = "0.7.10";
2
+ export declare const VERSION = "0.7.14";
3
3
  export interface PairRequest {
4
4
  pairingSecret: string;
5
5
  appId: string;
package/dist/bridge.js CHANGED
@@ -19,7 +19,7 @@ import { reportCapabilities } from "./capabilities.js";
19
19
  import { lastHermesApiHealth, probeHermesApi } from "./hermes-http.js";
20
20
  import { lastTurnGlobal, turnSnapshot } from "./last-turn.js";
21
21
  import { isBrainBackend } from "./config.js";
22
- export const VERSION = "0.7.10";
22
+ export const VERSION = "0.7.14";
23
23
  function hermesApiStatus(cfg) {
24
24
  const api = lastHermesApiHealth();
25
25
  return {
package/dist/cli.js CHANGED
File without changes
@@ -10,6 +10,7 @@ export declare function lastHermesApiHealth(): HermesApiHealth | null;
10
10
  export declare function probeHermesApi(cfg: BridgeConfig, force?: boolean): Promise<HermesApiHealth>;
11
11
  /**
12
12
  * Stream one turn through Hermes' OpenAI-compatible chat completions.
13
- * Session continuity: X-Hermes-Session-Key = nopeek-<channelId> (stable per chat).
13
+ * Session continuity: X-Hermes-Session-Id + Key = nopeek-<channelId>
14
+ * so follow-ups share history instead of starting a blank api-* job.
14
15
  */
15
16
  export declare function hermesHttpBrain(cfg: BridgeConfig): Brain;
@@ -1,5 +1,31 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { asHooks } from "./brain.js";
3
+ import { hermesSessionHeaders } from "./mid-turn.js";
2
4
  import { RECAP_HINT } from "./tool-progress.js";
5
+ /** Skip data-URL vision parts above this so a gallery cannot blow the POST. */
6
+ const MAX_IMAGE_DATA_URL_BYTES = 8 * 1024 * 1024;
7
+ const MAX_IMAGE_DATA_URL_TOTAL = 20 * 1024 * 1024;
8
+ function userContent(text, files) {
9
+ const images = (files ?? []).filter((f) => f.kind === "image" && f.size > 0 && f.size <= MAX_IMAGE_DATA_URL_BYTES);
10
+ if (!images.length)
11
+ return text;
12
+ const parts = [{ type: "text", text }];
13
+ let used = 0;
14
+ for (const img of images) {
15
+ if (used + img.size > MAX_IMAGE_DATA_URL_TOTAL)
16
+ break;
17
+ try {
18
+ const b64 = readFileSync(img.path).toString("base64");
19
+ const mime = img.contentType.startsWith("image/") ? img.contentType : "image/jpeg";
20
+ parts.push({ type: "image_url", image_url: { url: `data:${mime};base64,${b64}` } });
21
+ used += img.size;
22
+ }
23
+ catch (err) {
24
+ console.error(`[brain:hermes-http] could not attach ${img.path}: ${err.message}`);
25
+ }
26
+ }
27
+ return parts.length > 1 ? parts : text;
28
+ }
3
29
  let lastHealth = null;
4
30
  const HEALTH_TTL_MS = 15_000;
5
31
  export function lastHermesApiHealth() {
@@ -34,7 +60,8 @@ export async function probeHermesApi(cfg, force = false) {
34
60
  }
35
61
  /**
36
62
  * Stream one turn through Hermes' OpenAI-compatible chat completions.
37
- * Session continuity: X-Hermes-Session-Key = nopeek-<channelId> (stable per chat).
63
+ * Session continuity: X-Hermes-Session-Id + Key = nopeek-<channelId>
64
+ * so follow-ups share history instead of starting a blank api-* job.
38
65
  */
39
66
  export function hermesHttpBrain(cfg) {
40
67
  return async (text, ctx, hooks) => {
@@ -44,17 +71,19 @@ export function hermesHttpBrain(cfg) {
44
71
  const url = cfg.hermesApiUrl.replace(/\/+$/, "");
45
72
  const headers = {
46
73
  "content-type": "application/json",
74
+ ...hermesSessionHeaders(cfg.hermesApiKey ?? "", ctx.channelId),
47
75
  };
48
- // Hermes rejects X-Hermes-Session-Key with 403 unless API_SERVER_KEY is set.
49
- // Without a key, skip the header so the turn still runs (no session memory).
50
- if (cfg.hermesApiKey) {
51
- headers.authorization = `Bearer ${cfg.hermesApiKey}`;
52
- headers["X-Hermes-Session-Key"] = `nopeek-${ctx.channelId}`;
53
- }
54
76
  // Idle abort — same rule as the CLI path. A wall-clock timeout on the
55
77
  // whole POST would kill a healthy 20-minute agentic turn. Any SSE byte
56
78
  // (token, keepalive, tool-progress) resets the idle timer.
79
+ // ctx.signal aborts immediately when the user sends a mid-turn follow-up.
57
80
  const controller = new AbortController();
81
+ if (ctx.signal) {
82
+ if (ctx.signal.aborted)
83
+ controller.abort();
84
+ else
85
+ ctx.signal.addEventListener("abort", () => controller.abort(), { once: true });
86
+ }
58
87
  let idle;
59
88
  const armIdle = () => {
60
89
  clearTimeout(idle);
@@ -74,7 +103,7 @@ export function hermesHttpBrain(cfg) {
74
103
  stream: true,
75
104
  messages: [
76
105
  { role: "system", content: RECAP_HINT },
77
- { role: "user", content: text },
106
+ { role: "user", content: userContent(text, ctx.files) },
78
107
  ],
79
108
  }),
80
109
  signal: controller.signal,
@@ -145,6 +174,8 @@ export function hermesHttpBrain(cfg) {
145
174
  catch (err) {
146
175
  const msg = err.message || "";
147
176
  if (controller.signal.aborted) {
177
+ if (ctx.signal?.aborted)
178
+ return "";
148
179
  return `⚠️ My brain went quiet for over ${Math.round(cfg.brainTimeoutMs / 1000)}s with no output and I had to give up on that turn — a long tool-use step or a very slow provider response can trigger this. Try messaging me again.`;
149
180
  }
150
181
  console.error(`${tag} stream error: ${msg}`);
@@ -0,0 +1,36 @@
1
+ import type { BrainFile } from "./brain.js";
2
+ export declare const CONTROL_TYPES: Set<string>;
3
+ export declare const MAX_ATTACHMENT_BYTES: number;
4
+ export declare const MAX_ATTACHMENTS = 20;
5
+ export declare const MAX_TEXT_INJECT_BYTES: number;
6
+ export type EncryptedAtt = {
7
+ blobUrl: string;
8
+ contentKey: string;
9
+ iv: string;
10
+ name?: string;
11
+ contentType?: string;
12
+ size?: number;
13
+ };
14
+ export type FileKind = BrainFile["kind"];
15
+ export declare function isControlType(type?: string | null): boolean;
16
+ export declare function parseAttachments(body: unknown): EncryptedAtt[];
17
+ export declare function hasInboundWork(body: unknown): boolean;
18
+ export declare function safeFileName(name: string | undefined, fallback: string): string;
19
+ export declare function classifyKind(contentType: string | undefined, name: string): FileKind;
20
+ export declare function inferContentType(name: string, contentType?: string): string;
21
+ export declare function cacheDirFor(kind: FileKind, home?: string): string;
22
+ export declare function describeStructured(body: Record<string, unknown> | null | undefined): string;
23
+ export declare function fileNote(file: BrainFile): string;
24
+ export declare function buildInboundPrompt(opts: {
25
+ caption: string;
26
+ files: BrainFile[];
27
+ structured?: string;
28
+ failures?: string[];
29
+ }): string;
30
+ export declare function injectTextPreview(file: BrainFile, bytes: Buffer): string | null;
31
+ export type AttachmentFetcher = (att: EncryptedAtt) => Promise<Buffer>;
32
+ export declare function saveInboundFiles(fetchAtt: AttachmentFetcher, attachments: EncryptedAtt[], messageId: string, home?: string): Promise<{
33
+ files: BrainFile[];
34
+ failures: string[];
35
+ previews: string[];
36
+ }>;