@nopeek/agent-bridge 0.7.15 → 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.
@@ -27,12 +27,17 @@ export type TurnPublisherOpts = {
27
27
  maxProgressLines?: number;
28
28
  /** Split commentary/recap into a new bubble after this many chars. */
29
29
  maxBubbleChars?: number;
30
+ /** If nothing arrives for this long, the next chunk is a new message. 0 = off. */
31
+ bubbleGapMs?: number;
30
32
  };
31
33
  export type TurnFinishKind = "streamed" | "sent" | "empty";
32
34
  /** Text in `full` that has not already been posted as commentary. */
33
35
  export declare function unpublishedTail(full: string, published: string): string;
34
36
  /** Unified diffs / `| review` dumps should never land in the chat. */
37
+ export declare function isDumpLine(line: string): boolean;
35
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;
36
41
  /** Split a reply into short chat bubbles. Drops tool-dump paragraphs. */
37
42
  export declare function splitBubbles(text: string, max?: number): string[];
38
43
  /**
@@ -53,16 +58,21 @@ export declare class TurnPublisher {
53
58
  private published;
54
59
  private toolsUsed;
55
60
  private startTimer;
61
+ private gapTimer;
56
62
  private seen;
57
63
  private readonly startDelayMs;
58
64
  private readonly maxProgressLines;
59
65
  private readonly maxBubbleChars;
66
+ private readonly bubbleGapMs;
60
67
  constructor(sink: TurnSink, opts: TurnPublisherOpts);
61
68
  onChunk(delta: string): void;
62
69
  onTool(ev: ToolProgressEvent): void;
63
70
  finish(reply: string): Promise<TurnFinishKind>;
64
71
  fail(text: string): Promise<void>;
65
72
  private cancelStart;
73
+ private cancelGap;
74
+ /** After `bubbleGapMs` of silence, seal the current bubble. Next text is new. */
75
+ private armGap;
66
76
  private enqueue;
67
77
  /** Open or append the current commentary/answer stream with this chunk only. */
68
78
  private appendCommentary;
@@ -77,26 +77,55 @@ export function unpublishedTail(full, published) {
77
77
  }
78
78
  const DEFAULT_MAX_BUBBLE = 480;
79
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
+ }
80
100
  export function isToolDump(text) {
81
101
  const t = text.trim();
82
102
  if (!t)
83
103
  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))
104
+ if (isDumpLine(t))
89
105
  return true;
90
106
  if (/^diff --git /m.test(t) && /^@@ /m.test(t))
91
107
  return true;
92
108
  const lines = t.split("\n");
93
109
  if (lines.length >= 6) {
94
- const diffy = lines.filter((l) => /^(@@ |[+-](?![+-])|\| )/.test(l)).length;
95
- if (diffy / lines.length >= 0.4)
110
+ const dumpy = lines.filter((l) => isDumpLine(l) || /^(@@ |[+-](?![+-])|\| )/.test(l)).length;
111
+ if (dumpy / lines.length >= 0.4)
96
112
  return true;
97
113
  }
98
114
  return false;
99
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
+ }
100
129
  /** Split a reply into short chat bubbles. Drops tool-dump paragraphs. */
101
130
  export function splitBubbles(text, max = DEFAULT_MAX_BUBBLE) {
102
131
  const cleaned = text.replace(/\r\n/g, "\n").trim();
@@ -168,39 +197,44 @@ export class TurnPublisher {
168
197
  published = "";
169
198
  toolsUsed = false;
170
199
  startTimer = null;
200
+ gapTimer = null;
171
201
  seen = new Set();
172
202
  startDelayMs;
173
203
  maxProgressLines;
174
204
  maxBubbleChars;
205
+ bubbleGapMs;
175
206
  constructor(sink, opts) {
176
207
  this.sink = sink;
177
208
  this.opts = opts;
178
209
  this.startDelayMs = opts.startDelayMs ?? 400;
179
210
  this.maxProgressLines = opts.maxProgressLines ?? 8;
180
211
  this.maxBubbleChars = opts.maxBubbleChars ?? DEFAULT_MAX_BUBBLE;
212
+ this.bubbleGapMs = opts.bubbleGapMs ?? 2000;
181
213
  }
182
214
  onChunk(delta) {
183
- if (!delta)
215
+ const chunk = stripDumps(delta);
216
+ if (!chunk)
184
217
  return;
218
+ this.armGap();
185
219
  if (this.toolsUsed) {
186
220
  this.cancelStart();
187
- this.enqueue(() => this.appendCommentary(delta));
221
+ this.enqueue(() => this.appendCommentary(chunk));
188
222
  return;
189
223
  }
190
224
  if (this.textP) {
191
- this.enqueue(() => this.appendCommentary(delta));
225
+ this.enqueue(() => this.appendCommentary(chunk));
192
226
  return;
193
227
  }
194
- this.held += delta;
228
+ this.held += chunk;
195
229
  if (this.startTimer)
196
230
  return;
197
231
  this.startTimer = setTimeout(() => {
198
232
  this.startTimer = null;
199
233
  if (this.toolsUsed || this.textP || !this.held)
200
234
  return;
201
- const chunk = this.held;
235
+ const ready = this.held;
202
236
  this.held = "";
203
- this.enqueue(() => this.appendCommentary(chunk));
237
+ this.enqueue(() => this.appendCommentary(ready));
204
238
  }, this.startDelayMs);
205
239
  }
206
240
  onTool(ev) {
@@ -213,10 +247,8 @@ export class TurnPublisher {
213
247
  return;
214
248
  this.seen.add(line);
215
249
  this.toolsUsed = true;
216
- if (this.startTimer) {
217
- clearTimeout(this.startTimer);
218
- this.startTimer = null;
219
- }
250
+ this.cancelStart();
251
+ this.cancelGap();
220
252
  this.enqueue(async () => {
221
253
  const pending = this.held;
222
254
  this.held = "";
@@ -226,27 +258,12 @@ export class TurnPublisher {
226
258
  }
227
259
  async finish(reply) {
228
260
  this.cancelStart();
261
+ this.cancelGap();
229
262
  await this.write;
230
263
  await this.closeProgress();
231
- const trimmed = reply.trim();
232
- if (this.textP && !this.toolsUsed) {
233
- const s = await this.textP.catch(() => null);
234
- this.textP = null;
235
- if (s) {
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";
246
- }
247
- }
248
264
  await this.commitText(this.held);
249
- const rest = unpublishedTail(trimmed, this.published);
265
+ this.held = "";
266
+ const rest = unpublishedTail(reply.trim(), this.published);
250
267
  if (!rest)
251
268
  return this.published.trim() ? "streamed" : "empty";
252
269
  this.opts.stopTyping();
@@ -255,6 +272,7 @@ export class TurnPublisher {
255
272
  }
256
273
  async fail(text) {
257
274
  this.cancelStart();
275
+ this.cancelGap();
258
276
  await this.write;
259
277
  await this.closeProgress();
260
278
  if (this.textP) {
@@ -276,6 +294,26 @@ export class TurnPublisher {
276
294
  clearTimeout(this.startTimer);
277
295
  this.startTimer = null;
278
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
+ }
279
317
  enqueue(fn) {
280
318
  this.write = this.write.then(fn).catch((err) => {
281
319
  console.error(`[turn] ${err.message}`);
@@ -343,8 +381,15 @@ export class TurnPublisher {
343
381
  if (s && final) {
344
382
  if (extra)
345
383
  s.append(extra);
346
- await s.done(final).catch(() => { });
347
- 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
+ }
348
393
  return;
349
394
  }
350
395
  if (final.trim()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nopeek/agent-bridge",
3
- "version": "0.7.15",
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",