@nopeek/agent-bridge 0.7.14 → 0.7.16

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,21 @@ 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;
30
+ /** If nothing arrives for this long, the next chunk is a new message. 0 = off. */
31
+ bubbleGapMs?: number;
28
32
  };
29
33
  export type TurnFinishKind = "streamed" | "sent" | "empty";
30
34
  /** Text in `full` that has not already been posted as commentary. */
31
35
  export declare function unpublishedTail(full: string, published: string): string;
36
+ /** Unified diffs / `| review` dumps should never land in the chat. */
37
+ export declare function isDumpLine(line: string): boolean;
38
+ export declare function isToolDump(text: string): boolean;
39
+ /** Drop dump lines from a live chunk without swallowing normal punctuation. */
40
+ export declare function stripDumps(text: string): string;
41
+ /** Split a reply into short chat bubbles. Drops tool-dump paragraphs. */
42
+ export declare function splitBubbles(text: string, max?: number): string[];
32
43
  /**
33
44
  * One chat turn: live tool progress (edited in place) with commentary
34
45
  * messages in between tool batches, then any leftover recap below.
@@ -47,20 +58,27 @@ export declare class TurnPublisher {
47
58
  private published;
48
59
  private toolsUsed;
49
60
  private startTimer;
61
+ private gapTimer;
50
62
  private seen;
51
63
  private readonly startDelayMs;
52
64
  private readonly maxProgressLines;
65
+ private readonly maxBubbleChars;
66
+ private readonly bubbleGapMs;
53
67
  constructor(sink: TurnSink, opts: TurnPublisherOpts);
54
68
  onChunk(delta: string): void;
55
69
  onTool(ev: ToolProgressEvent): void;
56
70
  finish(reply: string): Promise<TurnFinishKind>;
57
71
  fail(text: string): Promise<void>;
58
72
  private cancelStart;
73
+ private cancelGap;
74
+ /** After `bubbleGapMs` of silence, seal the current bubble. Next text is new. */
75
+ private armGap;
59
76
  private enqueue;
60
77
  /** Open or append the current commentary/answer stream with this chunk only. */
61
78
  private appendCommentary;
62
79
  /** Finalize the current commentary so later tools land below it. */
63
80
  private commitText;
81
+ private sendBubbles;
64
82
  private addProgressLine;
65
83
  private closeProgress;
66
84
  }
@@ -60,15 +60,125 @@ 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 isDumpLine(line) {
81
+ const l = line.trim();
82
+ if (!l)
83
+ return false;
84
+ if (/^review diff\b/i.test(l))
85
+ return true;
86
+ if (/^\|\s*(review|read|search|terminal|patch|write)\b/i.test(l))
87
+ return true;
88
+ if (/^@@\s+-/.test(l))
89
+ return true;
90
+ if (/^diff --git /.test(l))
91
+ return true;
92
+ if (/^\*\*\*\s+(Begin|Update|Add|Delete|End)\b/.test(l))
93
+ return true;
94
+ if (/^[ab]:?\/{1,2}Volumes\//.test(l))
95
+ return true;
96
+ if (/^index [0-9a-f]+\.\.[0-9a-f]+/.test(l))
97
+ return true;
98
+ return false;
99
+ }
100
+ export function isToolDump(text) {
101
+ const t = text.trim();
102
+ if (!t)
103
+ return false;
104
+ if (isDumpLine(t))
105
+ return true;
106
+ if (/^diff --git /m.test(t) && /^@@ /m.test(t))
107
+ return true;
108
+ const lines = t.split("\n");
109
+ if (lines.length >= 6) {
110
+ const dumpy = lines.filter((l) => isDumpLine(l) || /^(@@ |[+-](?![+-])|\| )/.test(l)).length;
111
+ if (dumpy / lines.length >= 0.4)
112
+ return true;
113
+ }
114
+ return false;
115
+ }
116
+ /** Drop dump lines from a live chunk without swallowing normal punctuation. */
117
+ export function stripDumps(text) {
118
+ if (!text)
119
+ return text;
120
+ if (!text.includes("\n"))
121
+ return isDumpLine(text) ? "" : text;
122
+ if (isToolDump(text))
123
+ return "";
124
+ return text
125
+ .split("\n")
126
+ .filter((l) => !isDumpLine(l))
127
+ .join("\n");
128
+ }
129
+ /** Split a reply into short chat bubbles. Drops tool-dump paragraphs. */
130
+ export function splitBubbles(text, max = DEFAULT_MAX_BUBBLE) {
131
+ const cleaned = text.replace(/\r\n/g, "\n").trim();
132
+ if (!cleaned)
133
+ return [];
134
+ const paras = cleaned.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
135
+ const out = [];
136
+ for (const p of paras) {
137
+ if (isToolDump(p))
138
+ continue;
139
+ if (p.length <= max) {
140
+ out.push(p);
141
+ continue;
142
+ }
143
+ let buf = "";
144
+ for (const line of p.split("\n")) {
145
+ const next = buf ? `${buf}\n${line}` : line;
146
+ if (next.length > max && buf) {
147
+ out.push(buf);
148
+ buf = line.length > max ? "" : line;
149
+ if (line.length > max) {
150
+ for (const piece of hardWrap(line, max))
151
+ out.push(piece);
152
+ }
153
+ }
154
+ else if (next.length > max) {
155
+ for (const piece of hardWrap(next, max))
156
+ out.push(piece);
157
+ buf = "";
158
+ }
159
+ else {
160
+ buf = next;
161
+ }
162
+ }
163
+ if (buf.trim())
164
+ out.push(buf.trim());
165
+ }
166
+ return out;
167
+ }
168
+ function hardWrap(s, max) {
169
+ const out = [];
170
+ let rest = s.trim();
171
+ while (rest.length > max) {
172
+ let at = rest.lastIndexOf(" ", max);
173
+ if (at < max * 0.5)
174
+ at = max;
175
+ out.push(rest.slice(0, at).trim());
176
+ rest = rest.slice(at).trim();
177
+ }
178
+ if (rest)
179
+ out.push(rest);
180
+ return out;
181
+ }
72
182
  /**
73
183
  * One chat turn: live tool progress (edited in place) with commentary
74
184
  * messages in between tool batches, then any leftover recap below.
@@ -87,37 +197,44 @@ export class TurnPublisher {
87
197
  published = "";
88
198
  toolsUsed = false;
89
199
  startTimer = null;
200
+ gapTimer = null;
90
201
  seen = new Set();
91
202
  startDelayMs;
92
203
  maxProgressLines;
204
+ maxBubbleChars;
205
+ bubbleGapMs;
93
206
  constructor(sink, opts) {
94
207
  this.sink = sink;
95
208
  this.opts = opts;
96
209
  this.startDelayMs = opts.startDelayMs ?? 400;
97
- this.maxProgressLines = opts.maxProgressLines ?? 16;
210
+ this.maxProgressLines = opts.maxProgressLines ?? 8;
211
+ this.maxBubbleChars = opts.maxBubbleChars ?? DEFAULT_MAX_BUBBLE;
212
+ this.bubbleGapMs = opts.bubbleGapMs ?? 2000;
98
213
  }
99
214
  onChunk(delta) {
100
- if (!delta)
215
+ const chunk = stripDumps(delta);
216
+ if (!chunk)
101
217
  return;
218
+ this.armGap();
102
219
  if (this.toolsUsed) {
103
220
  this.cancelStart();
104
- this.enqueue(() => this.appendCommentary(delta));
221
+ this.enqueue(() => this.appendCommentary(chunk));
105
222
  return;
106
223
  }
107
224
  if (this.textP) {
108
- this.enqueue(() => this.appendCommentary(delta));
225
+ this.enqueue(() => this.appendCommentary(chunk));
109
226
  return;
110
227
  }
111
- this.held += delta;
228
+ this.held += chunk;
112
229
  if (this.startTimer)
113
230
  return;
114
231
  this.startTimer = setTimeout(() => {
115
232
  this.startTimer = null;
116
233
  if (this.toolsUsed || this.textP || !this.held)
117
234
  return;
118
- const chunk = this.held;
235
+ const ready = this.held;
119
236
  this.held = "";
120
- this.enqueue(() => this.appendCommentary(chunk));
237
+ this.enqueue(() => this.appendCommentary(ready));
121
238
  }, this.startDelayMs);
122
239
  }
123
240
  onTool(ev) {
@@ -130,40 +247,32 @@ export class TurnPublisher {
130
247
  return;
131
248
  this.seen.add(line);
132
249
  this.toolsUsed = true;
133
- if (this.startTimer) {
134
- clearTimeout(this.startTimer);
135
- this.startTimer = null;
136
- }
137
- const pending = this.held;
138
- this.held = "";
250
+ this.cancelStart();
251
+ this.cancelGap();
139
252
  this.enqueue(async () => {
253
+ const pending = this.held;
254
+ this.held = "";
140
255
  await this.commitText(pending);
141
256
  await this.addProgressLine(line);
142
257
  });
143
258
  }
144
259
  async finish(reply) {
145
260
  this.cancelStart();
261
+ this.cancelGap();
146
262
  await this.write;
147
263
  await this.closeProgress();
148
- const trimmed = reply.trim();
149
- if (this.textP && !this.toolsUsed) {
150
- const s = await this.textP.catch(() => null);
151
- this.textP = null;
152
- if (s) {
153
- await s.done(trimmed || undefined);
154
- return trimmed ? "streamed" : "empty";
155
- }
156
- }
157
264
  await this.commitText(this.held);
158
- const rest = unpublishedTail(trimmed, this.published);
265
+ this.held = "";
266
+ const rest = unpublishedTail(reply.trim(), this.published);
159
267
  if (!rest)
160
268
  return this.published.trim() ? "streamed" : "empty";
161
269
  this.opts.stopTyping();
162
- await this.sink.send(rest);
163
- return "sent";
270
+ const sent = await this.sendBubbles(rest);
271
+ return sent ? "sent" : this.published.trim() ? "streamed" : "empty";
164
272
  }
165
273
  async fail(text) {
166
274
  this.cancelStart();
275
+ this.cancelGap();
167
276
  await this.write;
168
277
  await this.closeProgress();
169
278
  if (this.textP) {
@@ -185,6 +294,26 @@ export class TurnPublisher {
185
294
  clearTimeout(this.startTimer);
186
295
  this.startTimer = null;
187
296
  }
297
+ cancelGap() {
298
+ if (!this.gapTimer)
299
+ return;
300
+ clearTimeout(this.gapTimer);
301
+ this.gapTimer = null;
302
+ }
303
+ /** After `bubbleGapMs` of silence, seal the current bubble. Next text is new. */
304
+ armGap() {
305
+ this.cancelGap();
306
+ if (this.bubbleGapMs <= 0)
307
+ return;
308
+ this.gapTimer = setTimeout(() => {
309
+ this.gapTimer = null;
310
+ this.enqueue(async () => {
311
+ const pending = this.held;
312
+ this.held = "";
313
+ await this.commitText(pending);
314
+ });
315
+ }, this.bubbleGapMs);
316
+ }
188
317
  enqueue(fn) {
189
318
  this.write = this.write.then(fn).catch((err) => {
190
319
  console.error(`[turn] ${err.message}`);
@@ -194,8 +323,24 @@ export class TurnPublisher {
194
323
  async appendCommentary(chunk) {
195
324
  if (!chunk)
196
325
  return;
197
- if (this.toolsUsed)
326
+ if (this.toolsUsed) {
198
327
  await this.closeProgress();
328
+ this.held += chunk;
329
+ const cut = this.held.lastIndexOf("\n\n");
330
+ if (cut >= 0) {
331
+ const ready = this.held.slice(0, cut);
332
+ this.held = this.held.slice(cut + 2);
333
+ this.opts.stopTyping();
334
+ await this.sendBubbles(ready);
335
+ }
336
+ else if (this.held.length >= this.maxBubbleChars) {
337
+ const ready = this.held;
338
+ this.held = "";
339
+ this.opts.stopTyping();
340
+ await this.sendBubbles(ready);
341
+ }
342
+ return;
343
+ }
199
344
  this.opts.stopTyping();
200
345
  if (!this.textP) {
201
346
  this.textP = this.sink.stream();
@@ -236,22 +381,37 @@ export class TurnPublisher {
236
381
  if (s && final) {
237
382
  if (extra)
238
383
  s.append(extra);
239
- await s.done(final).catch(() => { });
240
- this.published += final;
384
+ const parts = splitBubbles(final, this.maxBubbleChars);
385
+ const first = parts[0] || final;
386
+ await s.done(first).catch(() => { });
387
+ this.published += first;
388
+ for (const more of parts.slice(1)) {
389
+ this.opts.stopTyping();
390
+ await this.sink.send(more);
391
+ this.published += `\n\n${more}`;
392
+ }
241
393
  return;
242
394
  }
243
395
  if (final.trim()) {
244
396
  this.opts.stopTyping();
245
- await this.sink.send(final).catch(() => { });
246
- this.published += final;
397
+ await this.sendBubbles(final);
247
398
  }
248
399
  return;
249
400
  }
250
401
  if (!pending.trim())
251
402
  return;
252
403
  this.opts.stopTyping();
253
- await this.sink.send(pending);
254
- this.published += pending;
404
+ await this.sendBubbles(pending);
405
+ }
406
+ async sendBubbles(text) {
407
+ const parts = splitBubbles(text, this.maxBubbleChars);
408
+ if (!parts.length)
409
+ return false;
410
+ for (const part of parts) {
411
+ await this.sink.send(part);
412
+ this.published += this.published ? `\n\n${part}` : part;
413
+ }
414
+ return true;
255
415
  }
256
416
  async addProgressLine(line) {
257
417
  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.16",
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
  }