@nopeek/agent-bridge 0.7.14 → 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/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
@@ -8,6 +8,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
10
  import { TurnPublisher } from "./tool-progress.js";
11
+ import { ChannelTurn, coalesceFollowups, isStopRequest, wrapInterruptedFollowup, } from "./mid-turn.js";
11
12
  import { buildInboundPrompt, describeStructured, hasInboundWork, isControlType, parseAttachments, saveInboundFiles, } from "./inbound-files.js";
12
13
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
13
14
  const MAX_BACKOFF_MS = 60_000;
@@ -74,6 +75,9 @@ export class BotRunner {
74
75
  // order, one at a time — concurrent brain runs against the same agent session
75
76
  // (e.g. one Hermes session per channel) deadlock or reply out of order.
76
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();
77
81
  // Channels the bot can't post to (e.g. broadcast, non-operator): after the
78
82
  // first FORBIDDEN, skip the brain entirely — replies there can never land.
79
83
  cantPost = new Set();
@@ -374,6 +378,12 @@ export class BotRunner {
374
378
  np.on("member.joined", onRosterChange);
375
379
  np.on("member.left", onRosterChange);
376
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
+ }
377
387
  const prev = this.chains.get(m.channelId) ?? Promise.resolve();
378
388
  const next = prev.then(() => this.handleMessage(m).catch((err) => {
379
389
  this.notePostFailure(m.channelId, err);
@@ -560,79 +570,196 @@ export class BotRunner {
560
570
  return;
561
571
  this.log(`${m.channelId} <- ${m.senderUserId}: ${text.slice(0, 120)}`);
562
572
  ch.markRead(m.messageId).catch(() => { });
563
- try {
564
- ch.typing(true);
565
- }
566
- catch {
567
- /* typing is best-effort */
568
- }
569
- // Resolve the brain PER MESSAGE: the NoPeek app can change it live over the
570
- // local API (PUT /brains) and the very next message uses the new one.
571
- const resolved = resolveBrain(this.cfg, this.info.handle);
572
- this.brainKind = resolved.kind;
573
- // Telegram-style turn: tool lines edit one live bubble; commentary
574
- // between tool batches is posted in place; leftover recap goes last.
575
- 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
+ }
576
599
  try {
577
- ch.typing(false);
600
+ ch.typing(true);
578
601
  }
579
602
  catch {
580
- /* best-effort */
603
+ /* typing is best-effort */
581
604
  }
582
- };
583
- const published = new TurnPublisher({
584
- stream: () => ch.stream(),
585
- send: async (body) => {
586
- await ch.send({ text: body });
587
- },
588
- }, {
589
- stopTyping,
590
- onStreamError: (err) => {
591
- this.notePostFailure(m.channelId, err);
592
- this.logErr(`stream open failed (falling back to a single send): ${err.message}`);
593
- },
594
- });
595
- let reply = "";
596
- const turn = beginTurn(this.info.handle, m.channelId, resolved.kind);
597
- try {
598
- reply = await resolved.brain(text, {
599
- botHandle: this.info.handle,
600
- botUserId: this.info.userId,
601
- channelId: m.channelId,
602
- senderUserId: m.senderUserId,
603
- ...(files.length ? { files } : {}),
604
- }, { onChunk: (delta) => published.onChunk(delta), onTool: (ev) => published.onTool(ev) });
605
- const trimmed = reply.trim();
606
- const timedOut = /went quiet for over \d+s/i.test(trimmed);
607
- finishTurn(turn, {
608
- ok: Boolean(trimmed) && !trimmed.startsWith("⚠️"),
609
- chars: trimmed.length,
610
- timedOut,
611
- error: !trimmed ? "empty reply" : trimmed.startsWith("⚠️") ? trimmed.slice(0, 180) : null,
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);
624
+ this.logErr(`stream open failed (falling back to a single send): ${err.message}`);
625
+ },
612
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,
659
+ });
660
+ }
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;
613
686
  }
614
- catch (err) {
615
- finishTurn(turn, { ok: false, error: err.message, timedOut: false });
616
- // Brain blew up mid-stream: finalize the partial bubble with an honest
617
- // error line instead of leaving a forever-blinking cursor.
618
- await published.fail(FALLBACK_REPLY).catch(() => { });
619
- 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);
620
701
  }
621
- 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))`);
622
709
  try {
623
- ch.typing(false);
710
+ await ch.send({ text: "Stopped." });
624
711
  }
625
- catch {
626
- /* best-effort */
712
+ catch (err) {
713
+ this.notePostFailure(channelId, err);
627
714
  }
715
+ this.handled++;
716
+ return null;
628
717
  }
629
- const how = await published.finish(reply.trim());
630
- if (how === "empty") {
631
- this.log(`brain returned empty reply — ignoring`);
632
- 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
+ }
633
755
  }
634
- this.handled++;
635
- this.log(`${m.channelId} -> ${how} reply (${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 };
636
763
  }
637
764
  /** A FORBIDDEN post (broadcast channel, bot not an operator) fails for every
638
765
  * future message too — mute the channel so the brain stops running there. */
package/dist/brain.d.ts CHANGED
@@ -15,6 +15,8 @@ export interface BrainContext {
15
15
  senderUserId: string;
16
16
  /** Local paths of photos / video / PDFs / any file the human just sent. */
17
17
  files?: BrainFile[];
18
+ /** Abort the in-flight brain when a later message interrupts this turn. */
19
+ signal?: AbortSignal;
18
20
  }
19
21
  /**
20
22
  * A brain answers one message. If it can stream, it calls `onChunk(delta)` as
@@ -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,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { asHooks } from "./brain.js";
3
+ import { hermesSessionHeaders } from "./mid-turn.js";
3
4
  import { RECAP_HINT } from "./tool-progress.js";
4
5
  /** Skip data-URL vision parts above this so a gallery cannot blow the POST. */
5
6
  const MAX_IMAGE_DATA_URL_BYTES = 8 * 1024 * 1024;
@@ -59,7 +60,8 @@ export async function probeHermesApi(cfg, force = false) {
59
60
  }
60
61
  /**
61
62
  * Stream one turn through Hermes' OpenAI-compatible chat completions.
62
- * 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.
63
65
  */
64
66
  export function hermesHttpBrain(cfg) {
65
67
  return async (text, ctx, hooks) => {
@@ -69,17 +71,19 @@ export function hermesHttpBrain(cfg) {
69
71
  const url = cfg.hermesApiUrl.replace(/\/+$/, "");
70
72
  const headers = {
71
73
  "content-type": "application/json",
74
+ ...hermesSessionHeaders(cfg.hermesApiKey ?? "", ctx.channelId),
72
75
  };
73
- // Hermes rejects X-Hermes-Session-Key with 403 unless API_SERVER_KEY is set.
74
- // Without a key, skip the header so the turn still runs (no session memory).
75
- if (cfg.hermesApiKey) {
76
- headers.authorization = `Bearer ${cfg.hermesApiKey}`;
77
- headers["X-Hermes-Session-Key"] = `nopeek-${ctx.channelId}`;
78
- }
79
76
  // Idle abort — same rule as the CLI path. A wall-clock timeout on the
80
77
  // whole POST would kill a healthy 20-minute agentic turn. Any SSE byte
81
78
  // (token, keepalive, tool-progress) resets the idle timer.
79
+ // ctx.signal aborts immediately when the user sends a mid-turn follow-up.
82
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
+ }
83
87
  let idle;
84
88
  const armIdle = () => {
85
89
  clearTimeout(idle);
@@ -170,6 +174,8 @@ export function hermesHttpBrain(cfg) {
170
174
  catch (err) {
171
175
  const msg = err.message || "";
172
176
  if (controller.signal.aborted) {
177
+ if (ctx.signal?.aborted)
178
+ return "";
173
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.`;
174
180
  }
175
181
  console.error(`${tag} stream error: ${msg}`);
@@ -0,0 +1,25 @@
1
+ export type FollowupAction = "stop" | "continue";
2
+ export interface FollowupDecision {
3
+ action: FollowupAction;
4
+ text: string;
5
+ }
6
+ export declare function isStopRequest(text: string): boolean;
7
+ export declare function coalesceFollowups(texts: string[]): FollowupDecision;
8
+ export declare function wrapInterruptedFollowup(text: string): string;
9
+ export declare function hermesSessionId(channelId: string): string;
10
+ export declare function hermesSessionHeaders(apiKey: string, channelId: string): Record<string, string>;
11
+ export declare class ChannelTurn<T extends {
12
+ id: string;
13
+ } = {
14
+ id: string;
15
+ text: string;
16
+ }> {
17
+ readonly abort: AbortController;
18
+ active: boolean;
19
+ private incoming;
20
+ private seen;
21
+ interrupt(item: T): void;
22
+ consumed(id: string): boolean;
23
+ takeIncoming(): T[];
24
+ finish(): void;
25
+ }
@@ -0,0 +1,67 @@
1
+ // Mid-turn interrupt policy for NoPeek → Hermes.
2
+ //
3
+ // Telegram can /stop a live agent immediately and fold the next message into
4
+ // the same session. The HTTP brain used to serialize the whole turn, then
5
+ // send each follow-up as a brand-new history=0 job — so "Stop" sat for 33
6
+ // minutes and then replied "nothing to stop" three times.
7
+ //
8
+ // This module is the shared policy: detect stop, coalesce a burst, abort the
9
+ // in-flight turn, and keep one stable Hermes session per channel.
10
+ const STOP_RE = /^(?:\/)?(?:please\s+)?stop(?:\s+please)?[.!]?$/i;
11
+ export function isStopRequest(text) {
12
+ return STOP_RE.test((text || "").trim());
13
+ }
14
+ export function coalesceFollowups(texts) {
15
+ const cleaned = texts.map((t) => (t || "").trim()).filter(Boolean);
16
+ if (cleaned.length === 0)
17
+ return { action: "continue", text: "" };
18
+ if (cleaned.every(isStopRequest))
19
+ return { action: "stop", text: cleaned[cleaned.length - 1] };
20
+ const kept = cleaned.filter((t) => !isStopRequest(t));
21
+ return { action: "continue", text: kept.join("\n\n") };
22
+ }
23
+ export function wrapInterruptedFollowup(text) {
24
+ return ("[System note: You were already working on a task. The user interrupted. " +
25
+ "Reassess the previous work together with this new message and continue as " +
26
+ "ONE combined task. Do not start a separate independent job. If the new " +
27
+ "message replaces the old task, switch to it.]\n\n" +
28
+ text);
29
+ }
30
+ export function hermesSessionId(channelId) {
31
+ return `nopeek-${channelId}`;
32
+ }
33
+ export function hermesSessionHeaders(apiKey, channelId) {
34
+ const headers = {};
35
+ if (!apiKey)
36
+ return headers;
37
+ const sid = hermesSessionId(channelId);
38
+ headers.authorization = `Bearer ${apiKey}`;
39
+ headers["X-Hermes-Session-Id"] = sid;
40
+ headers["X-Hermes-Session-Key"] = sid;
41
+ return headers;
42
+ }
43
+ export class ChannelTurn {
44
+ abort = new AbortController();
45
+ active = true;
46
+ incoming = [];
47
+ seen = new Set();
48
+ interrupt(item) {
49
+ if (!this.seen.has(item.id)) {
50
+ this.seen.add(item.id);
51
+ this.incoming.push(item);
52
+ }
53
+ if (!this.abort.signal.aborted)
54
+ this.abort.abort();
55
+ }
56
+ consumed(id) {
57
+ return this.seen.has(id);
58
+ }
59
+ takeIncoming() {
60
+ const items = this.incoming;
61
+ this.incoming = [];
62
+ return items;
63
+ }
64
+ finish() {
65
+ this.active = false;
66
+ }
67
+ }
@@ -25,10 +25,16 @@ export type TurnPublisherOpts = {
25
25
  startDelayMs?: number;
26
26
  /** Start a fresh progress bubble after this many lines. */
27
27
  maxProgressLines?: number;
28
+ /** Split commentary/recap into a new bubble after this many chars. */
29
+ maxBubbleChars?: number;
28
30
  };
29
31
  export type TurnFinishKind = "streamed" | "sent" | "empty";
30
32
  /** Text in `full` that has not already been posted as commentary. */
31
33
  export declare function unpublishedTail(full: string, published: string): string;
34
+ /** Unified diffs / `| review` dumps should never land in the chat. */
35
+ export declare function isToolDump(text: string): boolean;
36
+ /** Split a reply into short chat bubbles. Drops tool-dump paragraphs. */
37
+ export declare function splitBubbles(text: string, max?: number): string[];
32
38
  /**
33
39
  * One chat turn: live tool progress (edited in place) with commentary
34
40
  * messages in between tool batches, then any leftover recap below.
@@ -50,6 +56,7 @@ export declare class TurnPublisher {
50
56
  private seen;
51
57
  private readonly startDelayMs;
52
58
  private readonly maxProgressLines;
59
+ private readonly maxBubbleChars;
53
60
  constructor(sink: TurnSink, opts: TurnPublisherOpts);
54
61
  onChunk(delta: string): void;
55
62
  onTool(ev: ToolProgressEvent): void;
@@ -61,6 +68,7 @@ export declare class TurnPublisher {
61
68
  private appendCommentary;
62
69
  /** Finalize the current commentary so later tools land below it. */
63
70
  private commitText;
71
+ private sendBubbles;
64
72
  private addProgressLine;
65
73
  private closeProgress;
66
74
  }
@@ -60,15 +60,96 @@ export function unpublishedTail(full, published) {
60
60
  return f;
61
61
  if (f === p)
62
62
  return "";
63
+ const nf = f.replace(/\s+/g, " ");
64
+ const np = p.replace(/\s+/g, " ");
65
+ if (nf === np || np.includes(nf))
66
+ return "";
63
67
  if (full.startsWith(published))
64
68
  return full.slice(published.length).trim();
65
69
  if (f.startsWith(p))
66
70
  return f.slice(p.length).trim();
71
+ if (nf.startsWith(np))
72
+ return "";
67
73
  const idx = f.indexOf(p);
68
74
  if (idx >= 0)
69
75
  return (f.slice(0, idx) + f.slice(idx + p.length)).trim();
70
76
  return f;
71
77
  }
78
+ const DEFAULT_MAX_BUBBLE = 480;
79
+ /** Unified diffs / `| review` dumps should never land in the chat. */
80
+ export function isToolDump(text) {
81
+ const t = text.trim();
82
+ if (!t)
83
+ return false;
84
+ if (/^\|\s*(review|read|search|terminal|patch|write)\b/i.test(t))
85
+ return true;
86
+ if (/^@@\s+-\d+/.test(t))
87
+ return true;
88
+ if (/^\*\*\*\s+(Begin|Update|Add|Delete) Patch/m.test(t))
89
+ return true;
90
+ if (/^diff --git /m.test(t) && /^@@ /m.test(t))
91
+ return true;
92
+ const lines = t.split("\n");
93
+ if (lines.length >= 6) {
94
+ const diffy = lines.filter((l) => /^(@@ |[+-](?![+-])|\| )/.test(l)).length;
95
+ if (diffy / lines.length >= 0.4)
96
+ return true;
97
+ }
98
+ return false;
99
+ }
100
+ /** Split a reply into short chat bubbles. Drops tool-dump paragraphs. */
101
+ export function splitBubbles(text, max = DEFAULT_MAX_BUBBLE) {
102
+ const cleaned = text.replace(/\r\n/g, "\n").trim();
103
+ if (!cleaned)
104
+ return [];
105
+ const paras = cleaned.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
106
+ const out = [];
107
+ for (const p of paras) {
108
+ if (isToolDump(p))
109
+ continue;
110
+ if (p.length <= max) {
111
+ out.push(p);
112
+ continue;
113
+ }
114
+ let buf = "";
115
+ for (const line of p.split("\n")) {
116
+ const next = buf ? `${buf}\n${line}` : line;
117
+ if (next.length > max && buf) {
118
+ out.push(buf);
119
+ buf = line.length > max ? "" : line;
120
+ if (line.length > max) {
121
+ for (const piece of hardWrap(line, max))
122
+ out.push(piece);
123
+ }
124
+ }
125
+ else if (next.length > max) {
126
+ for (const piece of hardWrap(next, max))
127
+ out.push(piece);
128
+ buf = "";
129
+ }
130
+ else {
131
+ buf = next;
132
+ }
133
+ }
134
+ if (buf.trim())
135
+ out.push(buf.trim());
136
+ }
137
+ return out;
138
+ }
139
+ function hardWrap(s, max) {
140
+ const out = [];
141
+ let rest = s.trim();
142
+ while (rest.length > max) {
143
+ let at = rest.lastIndexOf(" ", max);
144
+ if (at < max * 0.5)
145
+ at = max;
146
+ out.push(rest.slice(0, at).trim());
147
+ rest = rest.slice(at).trim();
148
+ }
149
+ if (rest)
150
+ out.push(rest);
151
+ return out;
152
+ }
72
153
  /**
73
154
  * One chat turn: live tool progress (edited in place) with commentary
74
155
  * messages in between tool batches, then any leftover recap below.
@@ -90,11 +171,13 @@ export class TurnPublisher {
90
171
  seen = new Set();
91
172
  startDelayMs;
92
173
  maxProgressLines;
174
+ maxBubbleChars;
93
175
  constructor(sink, opts) {
94
176
  this.sink = sink;
95
177
  this.opts = opts;
96
178
  this.startDelayMs = opts.startDelayMs ?? 400;
97
- this.maxProgressLines = opts.maxProgressLines ?? 16;
179
+ this.maxProgressLines = opts.maxProgressLines ?? 8;
180
+ this.maxBubbleChars = opts.maxBubbleChars ?? DEFAULT_MAX_BUBBLE;
98
181
  }
99
182
  onChunk(delta) {
100
183
  if (!delta)
@@ -134,9 +217,9 @@ export class TurnPublisher {
134
217
  clearTimeout(this.startTimer);
135
218
  this.startTimer = null;
136
219
  }
137
- const pending = this.held;
138
- this.held = "";
139
220
  this.enqueue(async () => {
221
+ const pending = this.held;
222
+ this.held = "";
140
223
  await this.commitText(pending);
141
224
  await this.addProgressLine(line);
142
225
  });
@@ -150,8 +233,16 @@ export class TurnPublisher {
150
233
  const s = await this.textP.catch(() => null);
151
234
  this.textP = null;
152
235
  if (s) {
153
- await s.done(trimmed || undefined);
154
- return trimmed ? "streamed" : "empty";
236
+ const parts = splitBubbles(trimmed || this.textBuf, this.maxBubbleChars);
237
+ const first = parts[0] || trimmed;
238
+ await s.done(first || undefined);
239
+ this.published += first || "";
240
+ for (const extra of parts.slice(1)) {
241
+ this.opts.stopTyping();
242
+ await this.sink.send(extra);
243
+ this.published += `\n\n${extra}`;
244
+ }
245
+ return first ? "streamed" : "empty";
155
246
  }
156
247
  }
157
248
  await this.commitText(this.held);
@@ -159,8 +250,8 @@ export class TurnPublisher {
159
250
  if (!rest)
160
251
  return this.published.trim() ? "streamed" : "empty";
161
252
  this.opts.stopTyping();
162
- await this.sink.send(rest);
163
- return "sent";
253
+ const sent = await this.sendBubbles(rest);
254
+ return sent ? "sent" : this.published.trim() ? "streamed" : "empty";
164
255
  }
165
256
  async fail(text) {
166
257
  this.cancelStart();
@@ -194,8 +285,24 @@ export class TurnPublisher {
194
285
  async appendCommentary(chunk) {
195
286
  if (!chunk)
196
287
  return;
197
- if (this.toolsUsed)
288
+ if (this.toolsUsed) {
198
289
  await this.closeProgress();
290
+ this.held += chunk;
291
+ const cut = this.held.lastIndexOf("\n\n");
292
+ if (cut >= 0) {
293
+ const ready = this.held.slice(0, cut);
294
+ this.held = this.held.slice(cut + 2);
295
+ this.opts.stopTyping();
296
+ await this.sendBubbles(ready);
297
+ }
298
+ else if (this.held.length >= this.maxBubbleChars) {
299
+ const ready = this.held;
300
+ this.held = "";
301
+ this.opts.stopTyping();
302
+ await this.sendBubbles(ready);
303
+ }
304
+ return;
305
+ }
199
306
  this.opts.stopTyping();
200
307
  if (!this.textP) {
201
308
  this.textP = this.sink.stream();
@@ -242,16 +349,24 @@ export class TurnPublisher {
242
349
  }
243
350
  if (final.trim()) {
244
351
  this.opts.stopTyping();
245
- await this.sink.send(final).catch(() => { });
246
- this.published += final;
352
+ await this.sendBubbles(final);
247
353
  }
248
354
  return;
249
355
  }
250
356
  if (!pending.trim())
251
357
  return;
252
358
  this.opts.stopTyping();
253
- await this.sink.send(pending);
254
- this.published += pending;
359
+ await this.sendBubbles(pending);
360
+ }
361
+ async sendBubbles(text) {
362
+ const parts = splitBubbles(text, this.maxBubbleChars);
363
+ if (!parts.length)
364
+ return false;
365
+ for (const part of parts) {
366
+ await this.sink.send(part);
367
+ this.published += this.published ? `\n\n${part}` : part;
368
+ }
369
+ return true;
255
370
  }
256
371
  async addProgressLine(line) {
257
372
  this.opts.stopTyping();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nopeek/agent-bridge",
3
- "version": "0.7.14",
3
+ "version": "0.7.15",
4
4
  "description": "Run your own agents as E2EE NoPeek bots. Pairs with one-time codes (multiple accounts per computer), runs every bot each account owns, and pipes messages to any command or webhook.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -46,6 +46,6 @@
46
46
  "start": "node dist/cli.js",
47
47
  "dev": "tsx src/cli.ts",
48
48
  "typecheck": "tsc -p tsconfig.json --noEmit",
49
- "test": "tsx --test --test-concurrency=1 src/tool-progress.test.ts src/inbound-files.test.ts"
49
+ "test": "tsx --test --test-concurrency=1 src/tool-progress.test.ts src/inbound-files.test.ts src/mid-turn.test.ts"
50
50
  }
51
51
  }