@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.
@@ -1,9 +1,13 @@
1
1
  // Telegram-style tool progress for NoPeek bots.
2
2
  //
3
- // Hermes emits `hermes.tool.progress` SSE events while it works. Telegram
4
- // already shows those as short status messages (a few tools, then a new
5
- // message after a quiet gap). We do the same in the encrypted chat so the
6
- // human can see the bot is actually taking action.
3
+ // Hermes emits `hermes.tool.progress` SSE events while it works, interleaved
4
+ // with assistant text deltas (the "Skills are loaded. Next I'll…" lines).
5
+ // On Telegram the gateway posts each commentary as its own message and starts
6
+ // a fresh progress bubble underneath. This publisher matches that:
7
+ // 1. Tool lines edit a single live bubble (roll to a new one if it gets long).
8
+ // 2. Commentary after a tool batch finalizes that bubble and is posted below.
9
+ // 3. The next tool batch opens a NEW progress bubble under the commentary.
10
+ // 4. The recap is whatever text is still unpublished at finish().
7
11
  const FALLBACK_EMOJI = {
8
12
  read_file: "📖",
9
13
  write_file: "✍️",
@@ -44,62 +48,361 @@ function tidyLabel(raw) {
44
48
  return "";
45
49
  return s.length > 80 ? `${s.slice(0, 77)}...` : s;
46
50
  }
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.";
53
+ /** Text in `full` that has not already been posted as commentary. */
54
+ export function unpublishedTail(full, published) {
55
+ const f = full.trim();
56
+ const p = published.trim();
57
+ if (!f)
58
+ return "";
59
+ if (!p)
60
+ return f;
61
+ if (f === p)
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 "";
67
+ if (full.startsWith(published))
68
+ return full.slice(published.length).trim();
69
+ if (f.startsWith(p))
70
+ return f.slice(p.length).trim();
71
+ if (nf.startsWith(np))
72
+ return "";
73
+ const idx = f.indexOf(p);
74
+ if (idx >= 0)
75
+ return (f.slice(0, idx) + f.slice(idx + p.length)).trim();
76
+ return f;
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
+ }
47
153
  /**
48
- * Collect tool lines and flush them as short chat messages.
49
- * Flush when we have `maxLines` tools, or after `gapMs` of quiet.
154
+ * One chat turn: live tool progress (edited in place) with commentary
155
+ * messages in between tool batches, then any leftover recap below.
156
+ * All channel writes run on a single promise chain so bubbles stay in order.
50
157
  */
51
- export class ToolProgressFlusher {
52
- send;
53
- gapMs;
54
- maxLines;
55
- lines = [];
56
- timer = null;
57
- chain = Promise.resolve();
158
+ export class TurnPublisher {
159
+ sink;
160
+ opts;
161
+ write = Promise.resolve();
162
+ progressP = null;
163
+ progress = null;
164
+ progressLines = [];
165
+ textP = null;
166
+ textBuf = "";
167
+ held = "";
168
+ published = "";
169
+ toolsUsed = false;
170
+ startTimer = null;
58
171
  seen = new Set();
59
- constructor(send, gapMs = 2200, maxLines = 4) {
60
- this.send = send;
61
- this.gapMs = gapMs;
62
- this.maxLines = maxLines;
172
+ startDelayMs;
173
+ maxProgressLines;
174
+ maxBubbleChars;
175
+ constructor(sink, opts) {
176
+ this.sink = sink;
177
+ this.opts = opts;
178
+ this.startDelayMs = opts.startDelayMs ?? 400;
179
+ this.maxProgressLines = opts.maxProgressLines ?? 8;
180
+ this.maxBubbleChars = opts.maxBubbleChars ?? DEFAULT_MAX_BUBBLE;
63
181
  }
64
- get pending() {
65
- return this.lines.length;
182
+ onChunk(delta) {
183
+ if (!delta)
184
+ return;
185
+ if (this.toolsUsed) {
186
+ this.cancelStart();
187
+ this.enqueue(() => this.appendCommentary(delta));
188
+ return;
189
+ }
190
+ if (this.textP) {
191
+ this.enqueue(() => this.appendCommentary(delta));
192
+ return;
193
+ }
194
+ this.held += delta;
195
+ if (this.startTimer)
196
+ return;
197
+ this.startTimer = setTimeout(() => {
198
+ this.startTimer = null;
199
+ if (this.toolsUsed || this.textP || !this.held)
200
+ return;
201
+ const chunk = this.held;
202
+ this.held = "";
203
+ this.enqueue(() => this.appendCommentary(chunk));
204
+ }, this.startDelayMs);
66
205
  }
67
- push(ev) {
206
+ onTool(ev) {
68
207
  const line = formatToolLine(ev);
69
208
  if (!line)
70
209
  return;
71
- if (this.lines[this.lines.length - 1] === line)
210
+ if (this.progressLines[this.progressLines.length - 1] === line)
72
211
  return;
73
- if (this.seen.has(line) && this.lines.includes(line))
212
+ if (this.seen.has(line) && this.progressLines.includes(line))
74
213
  return;
75
214
  this.seen.add(line);
76
- this.lines.push(line);
77
- if (this.lines.length >= this.maxLines) {
78
- void this.flush();
215
+ this.toolsUsed = true;
216
+ if (this.startTimer) {
217
+ clearTimeout(this.startTimer);
218
+ this.startTimer = null;
219
+ }
220
+ this.enqueue(async () => {
221
+ const pending = this.held;
222
+ this.held = "";
223
+ await this.commitText(pending);
224
+ await this.addProgressLine(line);
225
+ });
226
+ }
227
+ async finish(reply) {
228
+ this.cancelStart();
229
+ await this.write;
230
+ 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
+ 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";
255
+ }
256
+ async fail(text) {
257
+ this.cancelStart();
258
+ await this.write;
259
+ await this.closeProgress();
260
+ if (this.textP) {
261
+ const s = await this.textP.catch(() => null);
262
+ this.textP = null;
263
+ this.textBuf = "";
264
+ this.held = "";
265
+ if (s) {
266
+ await s.fail(text);
267
+ return;
268
+ }
269
+ }
270
+ this.opts.stopTyping();
271
+ await this.sink.send(text);
272
+ }
273
+ cancelStart() {
274
+ if (!this.startTimer)
275
+ return;
276
+ clearTimeout(this.startTimer);
277
+ this.startTimer = null;
278
+ }
279
+ enqueue(fn) {
280
+ this.write = this.write.then(fn).catch((err) => {
281
+ console.error(`[turn] ${err.message}`);
282
+ });
283
+ }
284
+ /** Open or append the current commentary/answer stream with this chunk only. */
285
+ async appendCommentary(chunk) {
286
+ if (!chunk)
287
+ return;
288
+ if (this.toolsUsed) {
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
+ }
306
+ this.opts.stopTyping();
307
+ if (!this.textP) {
308
+ this.textP = this.sink.stream();
309
+ try {
310
+ const s = await this.textP;
311
+ s.append(chunk);
312
+ this.textBuf += chunk;
313
+ }
314
+ catch (err) {
315
+ this.textP = null;
316
+ this.opts.onStreamError(err);
317
+ await this.sink.send(chunk).catch(() => { });
318
+ this.published += chunk;
319
+ }
79
320
  return;
80
321
  }
81
- this.arm();
322
+ try {
323
+ const s = await this.textP;
324
+ s.append(chunk);
325
+ this.textBuf += chunk;
326
+ }
327
+ catch (err) {
328
+ this.textP = null;
329
+ this.opts.onStreamError(err);
330
+ await this.sink.send(this.textBuf + chunk).catch(() => { });
331
+ this.published += this.textBuf + chunk;
332
+ this.textBuf = "";
333
+ }
82
334
  }
83
- async flush() {
84
- if (this.timer) {
85
- clearTimeout(this.timer);
86
- this.timer = null;
335
+ /** Finalize the current commentary so later tools land below it. */
336
+ async commitText(pending = "") {
337
+ if (this.textP) {
338
+ const s = await this.textP.catch(() => null);
339
+ this.textP = null;
340
+ const extra = pending;
341
+ const final = this.textBuf + extra;
342
+ this.textBuf = "";
343
+ if (s && final) {
344
+ if (extra)
345
+ s.append(extra);
346
+ await s.done(final).catch(() => { });
347
+ this.published += final;
348
+ return;
349
+ }
350
+ if (final.trim()) {
351
+ this.opts.stopTyping();
352
+ await this.sendBubbles(final);
353
+ }
354
+ return;
87
355
  }
88
- if (this.lines.length === 0)
356
+ if (!pending.trim())
89
357
  return;
90
- const text = this.lines.join("\n");
91
- this.lines = [];
92
- this.chain = this.chain.then(() => this.send(text).catch(() => { }));
93
- await this.chain;
358
+ this.opts.stopTyping();
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;
94
370
  }
95
- arm() {
96
- if (this.timer)
97
- clearTimeout(this.timer);
98
- this.timer = setTimeout(() => {
99
- this.timer = null;
100
- void this.flush();
101
- }, this.gapMs);
371
+ async addProgressLine(line) {
372
+ this.opts.stopTyping();
373
+ if (this.progressLines.length >= this.maxProgressLines && this.progressP) {
374
+ await this.closeProgress();
375
+ }
376
+ this.progressLines.push(line);
377
+ const body = this.progressLines.join("\n");
378
+ if (!this.progressP) {
379
+ this.progressP = this.sink.stream();
380
+ try {
381
+ this.progress = await this.progressP;
382
+ this.progress.replace(body);
383
+ }
384
+ catch (err) {
385
+ this.progressP = null;
386
+ this.progress = null;
387
+ this.opts.onStreamError(err);
388
+ await this.sink.send(body).catch(() => { });
389
+ }
390
+ return;
391
+ }
392
+ this.progress?.replace(body);
393
+ }
394
+ async closeProgress() {
395
+ if (!this.progressP) {
396
+ this.progressLines = [];
397
+ this.progress = null;
398
+ return;
399
+ }
400
+ const s = await this.progressP.catch(() => null);
401
+ this.progressP = null;
402
+ this.progress = null;
403
+ const text = this.progressLines.join("\n");
404
+ this.progressLines = [];
405
+ if (s && text)
406
+ await s.done(text).catch(() => { });
102
407
  }
103
408
  }
104
- /** Hint so Hermes ends a tool-using turn with a recap, like Telegram. */
105
- 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.";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nopeek/agent-bridge",
3
- "version": "0.7.11",
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",
@@ -22,15 +22,8 @@
22
22
  "engines": {
23
23
  "node": ">=22"
24
24
  },
25
- "scripts": {
26
- "build": "tsc -p tsconfig.json",
27
- "prepack": "tsc -p tsconfig.json",
28
- "start": "node dist/cli.js",
29
- "dev": "tsx src/cli.ts",
30
- "typecheck": "tsc -p tsconfig.json --noEmit"
31
- },
32
25
  "dependencies": {
33
- "@nopeek/chat": "workspace:^0.2.4"
26
+ "@nopeek/chat": "^0.2.4"
34
27
  },
35
28
  "devDependencies": {
36
29
  "@types/node": "^22.10.0",
@@ -47,5 +40,12 @@
47
40
  ],
48
41
  "publishConfig": {
49
42
  "access": "public"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.json",
46
+ "start": "node dist/cli.js",
47
+ "dev": "tsx src/cli.ts",
48
+ "typecheck": "tsc -p tsconfig.json --noEmit",
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
+ }