@nopeek/agent-bridge 0.7.15 → 0.7.17

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.
@@ -7,7 +7,9 @@ export type ToolProgressEvent = {
7
7
  /** One Telegram-style line. Running events only; completed is silent. */
8
8
  export declare function formatToolLine(ev: ToolProgressEvent): string | null;
9
9
  /** Hint so Hermes ends a tool-using turn with a recap, like Telegram. */
10
- export declare const RECAP_HINT = "When this turn used tools (read/write files, commands, skills, memory), end your reply with a short recap: what was going on, what you did, and the result. Skip the recap for simple chat with no tools. No em dashes.";
10
+ export declare const RECAP_HINT = "When this turn used tools, your LAST message must be a short phone briefing: (1) what was wrong, (2) what you did, (3) the solution / current state. Never end on a tool list, a diff, code, or \"tasks completed\". Skip the briefing only for simple chat with no tools. No em dashes.";
11
+ /** Posted when tools ran but the model never wrote a human recap. */
12
+ export declare const MISSING_BRIEFING = "Done. I finished that work. Ask if you want the recap: what was wrong, what I did, and where things stand.";
11
13
  export interface StreamHandle {
12
14
  append(chunk: string): void;
13
15
  replace(full: string): void;
@@ -27,12 +29,19 @@ export type TurnPublisherOpts = {
27
29
  maxProgressLines?: number;
28
30
  /** Split commentary/recap into a new bubble after this many chars. */
29
31
  maxBubbleChars?: number;
32
+ /** If nothing arrives for this long, the next chunk is a new message. 0 = off. */
33
+ bubbleGapMs?: number;
30
34
  };
31
35
  export type TurnFinishKind = "streamed" | "sent" | "empty";
32
36
  /** Text in `full` that has not already been posted as commentary. */
33
37
  export declare function unpublishedTail(full: string, published: string): string;
34
38
  /** Unified diffs / `| review` dumps should never land in the chat. */
39
+ export declare function isDumpLine(line: string): boolean;
40
+ /** True when the text has a user-readable recap, not just tools/dumps. */
41
+ export declare function isHumanBriefing(text: string): boolean;
35
42
  export declare function isToolDump(text: string): boolean;
43
+ /** Drop dump lines from a live chunk without swallowing normal punctuation. */
44
+ export declare function stripDumps(text: string): string;
36
45
  /** Split a reply into short chat bubbles. Drops tool-dump paragraphs. */
37
46
  export declare function splitBubbles(text: string, max?: number): string[];
38
47
  /**
@@ -53,16 +62,21 @@ export declare class TurnPublisher {
53
62
  private published;
54
63
  private toolsUsed;
55
64
  private startTimer;
65
+ private gapTimer;
56
66
  private seen;
57
67
  private readonly startDelayMs;
58
68
  private readonly maxProgressLines;
59
69
  private readonly maxBubbleChars;
70
+ private readonly bubbleGapMs;
60
71
  constructor(sink: TurnSink, opts: TurnPublisherOpts);
61
72
  onChunk(delta: string): void;
62
73
  onTool(ev: ToolProgressEvent): void;
63
74
  finish(reply: string): Promise<TurnFinishKind>;
64
75
  fail(text: string): Promise<void>;
65
76
  private cancelStart;
77
+ private cancelGap;
78
+ /** After `bubbleGapMs` of silence, seal the current bubble. Next text is new. */
79
+ private armGap;
66
80
  private enqueue;
67
81
  /** Open or append the current commentary/answer stream with this chunk only. */
68
82
  private appendCommentary;
@@ -49,7 +49,9 @@ function tidyLabel(raw) {
49
49
  return s.length > 80 ? `${s.slice(0, 77)}...` : s;
50
50
  }
51
51
  /** Hint so Hermes ends a tool-using turn with a recap, like Telegram. */
52
- export const RECAP_HINT = "When this turn used tools (read/write files, commands, skills, memory), end your reply with a short recap: what was going on, what you did, and the result. Skip the recap for simple chat with no tools. No em dashes.";
52
+ export const RECAP_HINT = "When this turn used tools, your LAST message must be a short phone briefing: (1) what was wrong, (2) what you did, (3) the solution / current state. Never end on a tool list, a diff, code, or \"tasks completed\". Skip the briefing only for simple chat with no tools. No em dashes.";
53
+ /** Posted when tools ran but the model never wrote a human recap. */
54
+ export const MISSING_BRIEFING = "Done. I finished that work. Ask if you want the recap: what was wrong, what I did, and where things stand.";
53
55
  /** Text in `full` that has not already been posted as commentary. */
54
56
  export function unpublishedTail(full, published) {
55
57
  const f = full.trim();
@@ -77,26 +79,82 @@ export function unpublishedTail(full, published) {
77
79
  }
78
80
  const DEFAULT_MAX_BUBBLE = 480;
79
81
  /** Unified diffs / `| review` dumps should never land in the chat. */
82
+ export function isDumpLine(line) {
83
+ const l = line.trim();
84
+ if (!l)
85
+ return false;
86
+ if (/^review diff\b/i.test(l))
87
+ return true;
88
+ if (/^\|\s*(review|read|search|terminal|patch|write)\b/i.test(l))
89
+ return true;
90
+ if (/^@@\s+-/.test(l))
91
+ return true;
92
+ if (/^diff --git /.test(l))
93
+ return true;
94
+ if (/^\*\*\*\s+(Begin|Update|Add|Delete|End)\b/.test(l))
95
+ return true;
96
+ if (/^[ab]:?\/{1,2}Volumes\//.test(l))
97
+ return true;
98
+ if (/^index [0-9a-f]+\.\.[0-9a-f]+/.test(l))
99
+ return true;
100
+ // Unified-diff / review lines: "+ const x =" not "+1 country".
101
+ if (/^[+-]\s{2,}\S/.test(l))
102
+ return true;
103
+ if (/^[+-]\s*(import |from |export |const |let |function |class |def |if cmd\b|return )/.test(l))
104
+ return true;
105
+ if (/^[+-]\s*["'`{]/.test(l))
106
+ return true;
107
+ return false;
108
+ }
109
+ function isToolProgressLine(line) {
110
+ const l = line.trim();
111
+ if (!l)
112
+ return false;
113
+ return /^.+ \S+: ".+"$/.test(l) || /^.+ \S+\.\.\.$/.test(l);
114
+ }
115
+ /** True when the text has a user-readable recap, not just tools/dumps. */
116
+ export function isHumanBriefing(text) {
117
+ const prose = text
118
+ .split("\n")
119
+ .filter((l) => {
120
+ const t = l.trim();
121
+ return Boolean(t) && !isDumpLine(t) && !isToolProgressLine(t);
122
+ })
123
+ .join("\n")
124
+ .trim();
125
+ if (!prose || isToolDump(prose))
126
+ return false;
127
+ return true;
128
+ }
80
129
  export function isToolDump(text) {
81
130
  const t = text.trim();
82
131
  if (!t)
83
132
  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))
133
+ if (isDumpLine(t))
89
134
  return true;
90
135
  if (/^diff --git /m.test(t) && /^@@ /m.test(t))
91
136
  return true;
92
137
  const lines = t.split("\n");
93
138
  if (lines.length >= 6) {
94
- const diffy = lines.filter((l) => /^(@@ |[+-](?![+-])|\| )/.test(l)).length;
95
- if (diffy / lines.length >= 0.4)
139
+ const dumpy = lines.filter((l) => isDumpLine(l) || /^(@@ |[+-](?![+-])|\| )/.test(l)).length;
140
+ if (dumpy / lines.length >= 0.4)
96
141
  return true;
97
142
  }
98
143
  return false;
99
144
  }
145
+ /** Drop dump lines from a live chunk without swallowing normal punctuation. */
146
+ export function stripDumps(text) {
147
+ if (!text)
148
+ return text;
149
+ if (!text.includes("\n"))
150
+ return isDumpLine(text) ? "" : text;
151
+ if (isToolDump(text))
152
+ return "";
153
+ return text
154
+ .split("\n")
155
+ .filter((l) => !isDumpLine(l))
156
+ .join("\n");
157
+ }
100
158
  /** Split a reply into short chat bubbles. Drops tool-dump paragraphs. */
101
159
  export function splitBubbles(text, max = DEFAULT_MAX_BUBBLE) {
102
160
  const cleaned = text.replace(/\r\n/g, "\n").trim();
@@ -168,39 +226,44 @@ export class TurnPublisher {
168
226
  published = "";
169
227
  toolsUsed = false;
170
228
  startTimer = null;
229
+ gapTimer = null;
171
230
  seen = new Set();
172
231
  startDelayMs;
173
232
  maxProgressLines;
174
233
  maxBubbleChars;
234
+ bubbleGapMs;
175
235
  constructor(sink, opts) {
176
236
  this.sink = sink;
177
237
  this.opts = opts;
178
238
  this.startDelayMs = opts.startDelayMs ?? 400;
179
239
  this.maxProgressLines = opts.maxProgressLines ?? 8;
180
240
  this.maxBubbleChars = opts.maxBubbleChars ?? DEFAULT_MAX_BUBBLE;
241
+ this.bubbleGapMs = opts.bubbleGapMs ?? 2000;
181
242
  }
182
243
  onChunk(delta) {
183
- if (!delta)
244
+ const chunk = stripDumps(delta);
245
+ if (!chunk)
184
246
  return;
247
+ this.armGap();
185
248
  if (this.toolsUsed) {
186
249
  this.cancelStart();
187
- this.enqueue(() => this.appendCommentary(delta));
250
+ this.enqueue(() => this.appendCommentary(chunk));
188
251
  return;
189
252
  }
190
253
  if (this.textP) {
191
- this.enqueue(() => this.appendCommentary(delta));
254
+ this.enqueue(() => this.appendCommentary(chunk));
192
255
  return;
193
256
  }
194
- this.held += delta;
257
+ this.held += chunk;
195
258
  if (this.startTimer)
196
259
  return;
197
260
  this.startTimer = setTimeout(() => {
198
261
  this.startTimer = null;
199
262
  if (this.toolsUsed || this.textP || !this.held)
200
263
  return;
201
- const chunk = this.held;
264
+ const ready = this.held;
202
265
  this.held = "";
203
- this.enqueue(() => this.appendCommentary(chunk));
266
+ this.enqueue(() => this.appendCommentary(ready));
204
267
  }, this.startDelayMs);
205
268
  }
206
269
  onTool(ev) {
@@ -213,10 +276,8 @@ export class TurnPublisher {
213
276
  return;
214
277
  this.seen.add(line);
215
278
  this.toolsUsed = true;
216
- if (this.startTimer) {
217
- clearTimeout(this.startTimer);
218
- this.startTimer = null;
219
- }
279
+ this.cancelStart();
280
+ this.cancelGap();
220
281
  this.enqueue(async () => {
221
282
  const pending = this.held;
222
283
  this.held = "";
@@ -226,35 +287,27 @@ export class TurnPublisher {
226
287
  }
227
288
  async finish(reply) {
228
289
  this.cancelStart();
290
+ this.cancelGap();
229
291
  await this.write;
230
292
  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
293
  await this.commitText(this.held);
249
- const rest = unpublishedTail(trimmed, this.published);
250
- if (!rest)
251
- return this.published.trim() ? "streamed" : "empty";
252
- this.opts.stopTyping();
253
- const sent = await this.sendBubbles(rest);
254
- return sent ? "sent" : this.published.trim() ? "streamed" : "empty";
294
+ this.held = "";
295
+ const rest = unpublishedTail(reply.trim(), this.published);
296
+ if (rest) {
297
+ this.opts.stopTyping();
298
+ await this.sendBubbles(rest);
299
+ }
300
+ if (this.toolsUsed && reply.trim() && !isHumanBriefing(this.published)) {
301
+ this.opts.stopTyping();
302
+ await this.sendBubbles(MISSING_BRIEFING);
303
+ }
304
+ if (!this.published.trim())
305
+ return "empty";
306
+ return rest || this.toolsUsed ? "sent" : "streamed";
255
307
  }
256
308
  async fail(text) {
257
309
  this.cancelStart();
310
+ this.cancelGap();
258
311
  await this.write;
259
312
  await this.closeProgress();
260
313
  if (this.textP) {
@@ -276,6 +329,26 @@ export class TurnPublisher {
276
329
  clearTimeout(this.startTimer);
277
330
  this.startTimer = null;
278
331
  }
332
+ cancelGap() {
333
+ if (!this.gapTimer)
334
+ return;
335
+ clearTimeout(this.gapTimer);
336
+ this.gapTimer = null;
337
+ }
338
+ /** After `bubbleGapMs` of silence, seal the current bubble. Next text is new. */
339
+ armGap() {
340
+ this.cancelGap();
341
+ if (this.bubbleGapMs <= 0)
342
+ return;
343
+ this.gapTimer = setTimeout(() => {
344
+ this.gapTimer = null;
345
+ this.enqueue(async () => {
346
+ const pending = this.held;
347
+ this.held = "";
348
+ await this.commitText(pending);
349
+ });
350
+ }, this.bubbleGapMs);
351
+ }
279
352
  enqueue(fn) {
280
353
  this.write = this.write.then(fn).catch((err) => {
281
354
  console.error(`[turn] ${err.message}`);
@@ -343,8 +416,19 @@ export class TurnPublisher {
343
416
  if (s && final) {
344
417
  if (extra)
345
418
  s.append(extra);
346
- await s.done(final).catch(() => { });
347
- this.published += final;
419
+ const parts = splitBubbles(final, this.maxBubbleChars);
420
+ if (!parts.length) {
421
+ await s.done("…").catch(() => { });
422
+ return;
423
+ }
424
+ const first = parts[0];
425
+ await s.done(first).catch(() => { });
426
+ this.published += first;
427
+ for (const more of parts.slice(1)) {
428
+ this.opts.stopTyping();
429
+ await this.sink.send(more);
430
+ this.published += `\n\n${more}`;
431
+ }
348
432
  return;
349
433
  }
350
434
  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.17",
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",