@nopeek/agent-bridge 0.7.10 → 0.7.14

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/README.md CHANGED
@@ -41,6 +41,9 @@ The `npr_…` code comes from Settings → Connect your computer (bots) in the a
41
41
 
42
42
  Hermes brains prefer the local agent API (`HERMES_API_URL`, default
43
43
  `http://127.0.0.1:8642`) when `/health` is up, and fall back to `hermes chat`.
44
+ While Hermes is working, the bot edits one Telegram-style progress bubble
45
+ (read file, patch, skill, memory). New tools update that bubble instead of
46
+ stacking under a running summary. The recap is a separate message underneath.
44
47
  Idle kill waits for **no output and no CPU** (default 15 min). Authenticated
45
48
  `GET /status` includes `bridge`, `bots[]` (each with `lastTurn`), `hermesApi`,
46
49
  and `lastTurn`. See `docs/HERMES-BRAIN-PLAN.md`.
@@ -76,6 +79,7 @@ Context is passed as environment variables:
76
79
  | `NOPEEK_BOT_USER_ID` | user id of the bot |
77
80
  | `NOPEEK_CHANNEL_ID` | channel the message arrived in |
78
81
  | `NOPEEK_SENDER_USER_ID` | who sent the message |
82
+ | `NOPEEK_FILES` | JSON array of decrypted inbound files (`path`, `name`, `contentType`, `size`, `kind`) |
79
83
 
80
84
  Examples:
81
85
 
@@ -106,7 +110,8 @@ Content-Type: application/json
106
110
  "botHandle": "weatherbot",
107
111
  "botUserId": "usr_…",
108
112
  "channelId": "ch_…",
109
- "senderUserId": "usr_…"
113
+ "senderUserId": "usr_…",
114
+ "files": []
110
115
  }
111
116
  ```
112
117
 
package/dist/backends.js CHANGED
@@ -16,7 +16,7 @@ import { createHash } from "node:crypto";
16
16
  import { copyFileSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
17
17
  import { homedir } from "node:os";
18
18
  import { basename, join } from "node:path";
19
- import { stripAnsi } from "./brain.js";
19
+ import { asHooks, stripAnsi } from "./brain.js";
20
20
  import { hermesHttpBrain, probeHermesApi } from "./hermes-http.js";
21
21
  const BRAIN_UNREACHABLE = "⚠️ I couldn't reach my brain just now — please try again in a moment.";
22
22
  // ------------------------------------------------------------------ souls ----
@@ -224,7 +224,8 @@ function runClaudeOnce(bin, args, text, cwd, timeoutMs, tag, onDelta) {
224
224
  * host powers from wherever the bridge happens to run.
225
225
  */
226
226
  export function claudeBrain(cfg) {
227
- return async (text, ctx, onChunk) => {
227
+ return async (text, ctx, hooks) => {
228
+ const { onChunk } = asHooks(hooks);
228
229
  const bin = resolveBin("claude", "CLAUDE_BIN");
229
230
  if (!bin) {
230
231
  console.error(`[brain:claude:@${ctx.botHandle}] claude not found on PATH`);
@@ -302,8 +303,15 @@ you reply. Your replies are chat messages — every word you print is sent verba
302
303
  ## Guardrails
303
304
  - Never expose secrets, keys, or tokens.
304
305
  - Confirm before destructive or outward-facing actions.
305
- - Report honestly if something failed, say so.
306
+ - Report honestly. If something failed, say so.
306
307
  - If a request is far outside your purpose, say so briefly and offer what you can do.
308
+
309
+ ## After tool work
310
+ When you used tools this turn (read or wrote files, ran commands, changed skills, saved memory), end with a short recap:
311
+ - What was going on (one line).
312
+ - What you did (one or two lines).
313
+ - Result (one line).
314
+ Skip the recap for simple conversation with no tools.
307
315
  `;
308
316
  /**
309
317
  * Create (or reuse) the bot's isolated Hermes profile: its own SOUL.md and
@@ -527,7 +535,8 @@ function hermesCliBrain(cfg) {
527
535
  finish();
528
536
  });
529
537
  });
530
- return async (text, ctx, onChunk) => {
538
+ return async (text, ctx, hooks) => {
539
+ const { onChunk } = asHooks(hooks);
531
540
  const handle = ctx.botHandle.replace(/^@/, "");
532
541
  const tag = `[brain:hermes:@${handle}]`;
533
542
  if (!resolveBin("hermes", "HERMES_BIN")) {
@@ -606,15 +615,15 @@ function hermesCliBrain(cfg) {
606
615
  export function hermesBrain(cfg) {
607
616
  const cli = hermesCliBrain(cfg);
608
617
  const http = hermesHttpBrain(cfg);
609
- return async (text, ctx, onChunk) => {
618
+ return async (text, ctx, hooks) => {
610
619
  const health = await probeHermesApi(cfg);
611
620
  if (health.ok) {
612
- const reply = await http(text, ctx, onChunk);
621
+ const reply = await http(text, ctx, hooks);
613
622
  if (reply)
614
623
  return reply;
615
624
  console.error(`[brain:hermes:@${ctx.botHandle.replace(/^@/, "")}] API returned empty — falling back to CLI`);
616
625
  }
617
- return cli(text, ctx, onChunk);
626
+ return cli(text, ctx, hooks);
618
627
  };
619
628
  }
620
629
  export function hermesHttpOnlyBrain(cfg) {
package/dist/bot.js CHANGED
@@ -7,6 +7,8 @@ import { isChannelMode } from "./control.js";
7
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
+ import { TurnPublisher } from "./tool-progress.js";
11
+ import { buildInboundPrompt, describeStructured, hasInboundWork, isControlType, parseAttachments, saveInboundFiles, } from "./inbound-files.js";
10
12
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
11
13
  const MAX_BACKOFF_MS = 60_000;
12
14
  // Owner-membership answers are cached per channel for a short window; a
@@ -456,8 +458,11 @@ export class BotRunner {
456
458
  }
457
459
  return;
458
460
  }
459
- if (m.body?.type !== "text" || typeof m.body.text !== "string" || !m.body.text.trim())
461
+ const body = m.body;
462
+ if (!body || isControlType(body.type) || !hasInboundWork(body))
460
463
  return;
464
+ const attachments = parseAttachments(body);
465
+ const caption = typeof body.text === "string" ? body.text : "";
461
466
  // OWNER-PRESENT POLICY: in a multi-party channel (anything but a direct
462
467
  // chat), a non-owner sender may use the bot ONLY while the bot's owner is
463
468
  // also a member of that channel. Owner absent → completely silent (no
@@ -515,7 +520,7 @@ export class BotRunner {
515
520
  // predictable: mention-only means even the owner must @mention there.
516
521
  // DIRECT channels always behave as "everyone" (a DM with the bot is always
517
522
  // for the bot).
518
- let text = m.body.text;
523
+ let text = caption;
519
524
  if (ch.record.kind !== "direct" && ch.record.kind !== "dm") {
520
525
  const mode = this.channelModes.get(m.channelId) ?? "everyone";
521
526
  if (mode === "off") {
@@ -533,6 +538,26 @@ export class BotRunner {
533
538
  text = stripped;
534
539
  }
535
540
  }
541
+ let files = [];
542
+ const failures = [];
543
+ const previews = [];
544
+ if (attachments.length) {
545
+ const saved = await saveInboundFiles(async (att) => {
546
+ const blob = await ch.fetchAttachment(att);
547
+ return Buffer.from(await blob.arrayBuffer());
548
+ }, attachments, m.messageId);
549
+ files = saved.files;
550
+ failures.push(...saved.failures);
551
+ previews.push(...saved.previews);
552
+ this.log(`${m.channelId} inbound ${body.type}: ${files.length} file(s) saved` +
553
+ (failures.length ? `, ${failures.length} failed` : ""));
554
+ }
555
+ const structured = describeStructured(body);
556
+ text = buildInboundPrompt({ caption: text, files, structured, failures });
557
+ if (previews.length)
558
+ text = `${previews.join("\n\n")}\n\n${text}`;
559
+ if (!text.trim())
560
+ return;
536
561
  this.log(`${m.channelId} <- ${m.senderUserId}: ${text.slice(0, 120)}`);
537
562
  ch.markRead(m.messageId).catch(() => { });
538
563
  try {
@@ -545,30 +570,28 @@ export class BotRunner {
545
570
  // local API (PUT /brains) and the very next message uses the new one.
546
571
  const resolved = resolveBrain(this.cfg, this.info.handle);
547
572
  this.brainKind = resolved.kind;
548
- // Streaming: if the brain emits chunks, open a streaming message on the
549
- // FIRST chunk and forward deltas into it (typing indicator stays on until
550
- // then). Deltas can arrive before the placeholder lands — chaining every
551
- // append onto the open promise keeps them ordered and loses none.
552
- // (Ref object rather than a `let`: TS can't see closure assignments.)
553
- const streamRef = { p: null };
554
- const onChunk = (delta) => {
555
- if (!delta)
556
- return;
557
- if (!streamRef.p) {
558
- try {
559
- ch.typing(false); // the live bubble replaces the typing indicator
560
- }
561
- catch {
562
- /* best-effort */
563
- }
564
- streamRef.p = ch.stream();
565
- streamRef.p.catch((err) => {
566
- this.notePostFailure(m.channelId, err);
567
- this.logErr(`stream open failed (falling back to a single send): ${err.message}`);
568
- });
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 = () => {
576
+ try {
577
+ ch.typing(false);
578
+ }
579
+ catch {
580
+ /* best-effort */
569
581
  }
570
- streamRef.p.then((s) => s.append(delta)).catch(() => { });
571
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
+ });
572
595
  let reply = "";
573
596
  const turn = beginTurn(this.info.handle, m.channelId, resolved.kind);
574
597
  try {
@@ -577,7 +600,8 @@ export class BotRunner {
577
600
  botUserId: this.info.userId,
578
601
  channelId: m.channelId,
579
602
  senderUserId: m.senderUserId,
580
- }, onChunk);
603
+ ...(files.length ? { files } : {}),
604
+ }, { onChunk: (delta) => published.onChunk(delta), onTool: (ev) => published.onTool(ev) });
581
605
  const trimmed = reply.trim();
582
606
  const timedOut = /went quiet for over \d+s/i.test(trimmed);
583
607
  finishTurn(turn, {
@@ -591,9 +615,7 @@ export class BotRunner {
591
615
  finishTurn(turn, { ok: false, error: err.message, timedOut: false });
592
616
  // Brain blew up mid-stream: finalize the partial bubble with an honest
593
617
  // error line instead of leaving a forever-blinking cursor.
594
- const stream = streamRef.p ? await streamRef.p.catch(() => null) : null;
595
- if (stream)
596
- await stream.fail(FALLBACK_REPLY).catch(() => { });
618
+ await published.fail(FALLBACK_REPLY).catch(() => { });
597
619
  throw err;
598
620
  }
599
621
  finally {
@@ -604,20 +626,13 @@ export class BotRunner {
604
626
  /* best-effort */
605
627
  }
606
628
  }
607
- const stream = streamRef.p ? await streamRef.p.catch(() => null) : null;
608
- if (stream) {
609
- await stream.done(reply.trim() || undefined);
610
- this.handled++;
611
- this.log(`${m.channelId} -> streamed reply (${reply.trim().length} chars, handled=${this.handled})`);
612
- return;
613
- }
614
- if (!reply || !reply.trim()) {
629
+ const how = await published.finish(reply.trim());
630
+ if (how === "empty") {
615
631
  this.log(`brain returned empty reply — ignoring`);
616
632
  return;
617
633
  }
618
- await ch.send({ text: reply.trim() });
619
634
  this.handled++;
620
- this.log(`${m.channelId} -> replied (${reply.trim().length} chars, handled=${this.handled})`);
635
+ this.log(`${m.channelId} -> ${how} reply (${reply.trim().length} chars, handled=${this.handled})`);
621
636
  }
622
637
  /** A FORBIDDEN post (broadcast channel, bot not an operator) fails for every
623
638
  * future message too — mute the channel so the brain stops running there. */
package/dist/brain.d.ts CHANGED
@@ -1,16 +1,33 @@
1
1
  import type { BridgeConfig } from "./config.js";
2
+ import type { ToolProgressEvent } from "./tool-progress.js";
3
+ /** A decrypted inbound file written onto the Hermes cache for this turn. */
4
+ export interface BrainFile {
5
+ path: string;
6
+ name: string;
7
+ contentType: string;
8
+ size: number;
9
+ kind: "image" | "video" | "audio" | "document";
10
+ }
2
11
  export interface BrainContext {
3
12
  botHandle: string;
4
13
  botUserId: string;
5
14
  channelId: string;
6
15
  senderUserId: string;
16
+ /** Local paths of photos / video / PDFs / any file the human just sent. */
17
+ files?: BrainFile[];
7
18
  }
8
19
  /**
9
20
  * A brain answers one message. If it can stream, it calls `onChunk(delta)` as
10
21
  * text arrives (plain text, ANSI-stripped) and STILL returns the full reply —
11
22
  * the returned string is authoritative for the final message body.
23
+ * `onTool` is optional: Hermes HTTP uses it for live tool-progress lines.
12
24
  */
13
- export type Brain = (text: string, ctx: BrainContext, onChunk?: (delta: string) => void) => Promise<string>;
25
+ export type BrainHooks = {
26
+ onChunk?: (delta: string) => void;
27
+ onTool?: (ev: ToolProgressEvent) => void;
28
+ };
29
+ export type Brain = (text: string, ctx: BrainContext, hooks?: BrainHooks | ((delta: string) => void)) => Promise<string>;
30
+ export declare function asHooks(hooks?: BrainHooks | ((delta: string) => void)): BrainHooks;
14
31
  export declare const FALLBACK_REPLY = "Sorry \u2014 I hit an error processing that. Please try again.";
15
32
  export declare function stripAnsi(s: string): string;
16
33
  export interface ResolvedBrain {
package/dist/brain.js CHANGED
@@ -7,6 +7,13 @@
7
7
  // own service): the bridge never knows or cares what's on the other side.
8
8
  import { spawn } from "node:child_process";
9
9
  import { claudeBrain, hermesBrain, hermesHttpOnlyBrain } from "./backends.js";
10
+ export function asHooks(hooks) {
11
+ if (!hooks)
12
+ return {};
13
+ if (typeof hooks === "function")
14
+ return { onChunk: hooks };
15
+ return hooks;
16
+ }
10
17
  export const FALLBACK_REPLY = "Sorry — I hit an error processing that. Please try again.";
11
18
  // ANSI escape sequences (CSI, OSC, and lone ESC controls). Agent runtimes like
12
19
  // Hermes color their stdout; the chat must receive plain text.
@@ -23,7 +30,8 @@ export function stripAnsi(s) {
23
30
  * NOPEEK_BOT_HANDLE, NOPEEK_BOT_USER_ID, NOPEEK_CHANNEL_ID, NOPEEK_SENDER_USER_ID.
24
31
  */
25
32
  function cmdBrain(cmd, timeoutMs) {
26
- return (text, ctx, onChunk) => new Promise((resolvePromise) => {
33
+ return (text, ctx, hooks) => new Promise((resolvePromise) => {
34
+ const { onChunk } = asHooks(hooks);
27
35
  const child = spawn("bash", ["-c", cmd], {
28
36
  stdio: ["pipe", "pipe", "pipe"],
29
37
  env: {
@@ -32,6 +40,7 @@ function cmdBrain(cmd, timeoutMs) {
32
40
  NOPEEK_BOT_USER_ID: ctx.botUserId,
33
41
  NOPEEK_CHANNEL_ID: ctx.channelId,
34
42
  NOPEEK_SENDER_USER_ID: ctx.senderUserId,
43
+ ...(ctx.files?.length ? { NOPEEK_FILES: JSON.stringify(ctx.files) } : {}),
35
44
  },
36
45
  });
37
46
  let stdout = "";
@@ -105,6 +114,7 @@ function urlBrain(url, timeoutMs) {
105
114
  botUserId: ctx.botUserId,
106
115
  channelId: ctx.channelId,
107
116
  senderUserId: ctx.senderUserId,
117
+ ...(ctx.files?.length ? { files: ctx.files } : {}),
108
118
  }),
109
119
  });
110
120
  if (!res.ok) {
package/dist/bridge.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { BridgeConfig, Pairing, BrainSpec, BrainBackend } from "./config.js";
2
- export declare const VERSION = "0.7.10";
2
+ export declare const VERSION = "0.7.14";
3
3
  export interface PairRequest {
4
4
  pairingSecret: string;
5
5
  appId: string;
package/dist/bridge.js CHANGED
@@ -19,7 +19,7 @@ import { reportCapabilities } from "./capabilities.js";
19
19
  import { lastHermesApiHealth, probeHermesApi } from "./hermes-http.js";
20
20
  import { lastTurnGlobal, turnSnapshot } from "./last-turn.js";
21
21
  import { isBrainBackend } from "./config.js";
22
- export const VERSION = "0.7.10";
22
+ export const VERSION = "0.7.14";
23
23
  function hermesApiStatus(cfg) {
24
24
  const api = lastHermesApiHealth();
25
25
  return {
@@ -1,5 +1,5 @@
1
1
  import type { BridgeConfig } from "./config.js";
2
- import type { Brain } from "./brain.js";
2
+ import { type Brain } from "./brain.js";
3
3
  export interface HermesApiHealth {
4
4
  ok: boolean;
5
5
  url: string;
@@ -1,3 +1,30 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { asHooks } from "./brain.js";
3
+ import { RECAP_HINT } from "./tool-progress.js";
4
+ /** Skip data-URL vision parts above this so a gallery cannot blow the POST. */
5
+ const MAX_IMAGE_DATA_URL_BYTES = 8 * 1024 * 1024;
6
+ const MAX_IMAGE_DATA_URL_TOTAL = 20 * 1024 * 1024;
7
+ function userContent(text, files) {
8
+ const images = (files ?? []).filter((f) => f.kind === "image" && f.size > 0 && f.size <= MAX_IMAGE_DATA_URL_BYTES);
9
+ if (!images.length)
10
+ return text;
11
+ const parts = [{ type: "text", text }];
12
+ let used = 0;
13
+ for (const img of images) {
14
+ if (used + img.size > MAX_IMAGE_DATA_URL_TOTAL)
15
+ break;
16
+ try {
17
+ const b64 = readFileSync(img.path).toString("base64");
18
+ const mime = img.contentType.startsWith("image/") ? img.contentType : "image/jpeg";
19
+ parts.push({ type: "image_url", image_url: { url: `data:${mime};base64,${b64}` } });
20
+ used += img.size;
21
+ }
22
+ catch (err) {
23
+ console.error(`[brain:hermes-http] could not attach ${img.path}: ${err.message}`);
24
+ }
25
+ }
26
+ return parts.length > 1 ? parts : text;
27
+ }
1
28
  let lastHealth = null;
2
29
  const HEALTH_TTL_MS = 15_000;
3
30
  export function lastHermesApiHealth() {
@@ -35,16 +62,20 @@ export async function probeHermesApi(cfg, force = false) {
35
62
  * Session continuity: X-Hermes-Session-Key = nopeek-<channelId> (stable per chat).
36
63
  */
37
64
  export function hermesHttpBrain(cfg) {
38
- return async (text, ctx, onChunk) => {
65
+ return async (text, ctx, hooks) => {
66
+ const { onChunk, onTool } = asHooks(hooks);
39
67
  const handle = ctx.botHandle.replace(/^@/, "");
40
68
  const tag = `[brain:hermes-http:@${handle}]`;
41
69
  const url = cfg.hermesApiUrl.replace(/\/+$/, "");
42
70
  const headers = {
43
71
  "content-type": "application/json",
44
- "X-Hermes-Session-Key": `nopeek-${ctx.channelId}`,
45
72
  };
46
- if (cfg.hermesApiKey)
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) {
47
76
  headers.authorization = `Bearer ${cfg.hermesApiKey}`;
77
+ headers["X-Hermes-Session-Key"] = `nopeek-${ctx.channelId}`;
78
+ }
48
79
  // Idle abort — same rule as the CLI path. A wall-clock timeout on the
49
80
  // whole POST would kill a healthy 20-minute agentic turn. Any SSE byte
50
81
  // (token, keepalive, tool-progress) resets the idle timer.
@@ -66,7 +97,10 @@ export function hermesHttpBrain(cfg) {
66
97
  body: JSON.stringify({
67
98
  model: "hermes-agent",
68
99
  stream: true,
69
- messages: [{ role: "user", content: text }],
100
+ messages: [
101
+ { role: "system", content: RECAP_HINT },
102
+ { role: "user", content: userContent(text, ctx.files) },
103
+ ],
70
104
  }),
71
105
  signal: controller.signal,
72
106
  });
@@ -98,29 +132,40 @@ export function hermesHttpBrain(cfg) {
98
132
  break;
99
133
  armIdle();
100
134
  buf += decoder.decode(value, { stream: true });
101
- const lines = buf.split("\n");
102
- buf = lines.pop() ?? "";
103
- for (const line of lines) {
104
- const trimmed = line.trim();
105
- if (!trimmed.startsWith("data:"))
135
+ const frames = buf.split("\n\n");
136
+ buf = frames.pop() ?? "";
137
+ for (const frame of frames) {
138
+ const ev = parseSseFrame(frame);
139
+ if (!ev)
106
140
  continue;
107
- const payload = trimmed.slice(5).trim();
108
- if (!payload || payload === "[DONE]")
141
+ if (ev.event === "hermes.tool.progress") {
142
+ const tool = asToolProgress(ev.data);
143
+ if (tool)
144
+ onTool?.(tool);
109
145
  continue;
110
- let parsed;
111
- try {
112
- parsed = JSON.parse(payload);
113
146
  }
114
- catch {
115
- continue;
116
- }
117
- const delta = extractDelta(parsed);
147
+ const delta = extractDelta(ev.data);
118
148
  if (!delta)
119
149
  continue;
120
150
  reply += delta;
121
151
  onChunk?.(delta);
122
152
  }
123
153
  }
154
+ if (buf.trim()) {
155
+ const ev = parseSseFrame(buf);
156
+ if (ev?.event === "hermes.tool.progress") {
157
+ const tool = asToolProgress(ev.data);
158
+ if (tool)
159
+ onTool?.(tool);
160
+ }
161
+ else if (ev) {
162
+ const delta = extractDelta(ev.data);
163
+ if (delta) {
164
+ reply += delta;
165
+ onChunk?.(delta);
166
+ }
167
+ }
168
+ }
124
169
  }
125
170
  catch (err) {
126
171
  const msg = err.message || "";
@@ -135,6 +180,44 @@ export function hermesHttpBrain(cfg) {
135
180
  return reply.trim();
136
181
  };
137
182
  }
183
+ function parseSseFrame(frame) {
184
+ let event = "message";
185
+ const dataLines = [];
186
+ for (const raw of frame.split("\n")) {
187
+ const line = raw.replace(/\r$/, "");
188
+ if (!line || line.startsWith(":"))
189
+ continue;
190
+ if (line.startsWith("event:"))
191
+ event = line.slice(6).trim();
192
+ else if (line.startsWith("data:"))
193
+ dataLines.push(line.slice(5).trimStart());
194
+ }
195
+ if (dataLines.length === 0)
196
+ return null;
197
+ const payload = dataLines.join("\n");
198
+ if (!payload || payload === "[DONE]")
199
+ return null;
200
+ try {
201
+ return { event, data: JSON.parse(payload) };
202
+ }
203
+ catch {
204
+ return { event, data: payload };
205
+ }
206
+ }
207
+ function asToolProgress(data) {
208
+ if (!data || typeof data !== "object")
209
+ return null;
210
+ const obj = data;
211
+ const tool = typeof obj.tool === "string" ? obj.tool : typeof obj.name === "string" ? obj.name : "";
212
+ if (!tool)
213
+ return null;
214
+ return {
215
+ tool,
216
+ emoji: typeof obj.emoji === "string" ? obj.emoji : undefined,
217
+ label: typeof obj.label === "string" ? obj.label : undefined,
218
+ status: typeof obj.status === "string" ? obj.status : "running",
219
+ };
220
+ }
138
221
  function extractDelta(parsed) {
139
222
  if (!parsed || typeof parsed !== "object")
140
223
  return "";
@@ -143,8 +226,6 @@ function extractDelta(parsed) {
143
226
  const fromChoice = choice?.delta?.content ?? choice?.message?.content;
144
227
  if (typeof fromChoice === "string")
145
228
  return fromChoice;
146
- // Hermes also emits custom SSE tool-progress events; ignore those for the
147
- // chat body (they keep the HTTP connection alive, which is the point).
148
229
  if (typeof obj.data?.content === "string")
149
230
  return obj.data.content;
150
231
  if (typeof obj.data?.text === "string")
@@ -0,0 +1,36 @@
1
+ import type { BrainFile } from "./brain.js";
2
+ export declare const CONTROL_TYPES: Set<string>;
3
+ export declare const MAX_ATTACHMENT_BYTES: number;
4
+ export declare const MAX_ATTACHMENTS = 20;
5
+ export declare const MAX_TEXT_INJECT_BYTES: number;
6
+ export type EncryptedAtt = {
7
+ blobUrl: string;
8
+ contentKey: string;
9
+ iv: string;
10
+ name?: string;
11
+ contentType?: string;
12
+ size?: number;
13
+ };
14
+ export type FileKind = BrainFile["kind"];
15
+ export declare function isControlType(type?: string | null): boolean;
16
+ export declare function parseAttachments(body: unknown): EncryptedAtt[];
17
+ export declare function hasInboundWork(body: unknown): boolean;
18
+ export declare function safeFileName(name: string | undefined, fallback: string): string;
19
+ export declare function classifyKind(contentType: string | undefined, name: string): FileKind;
20
+ export declare function inferContentType(name: string, contentType?: string): string;
21
+ export declare function cacheDirFor(kind: FileKind, home?: string): string;
22
+ export declare function describeStructured(body: Record<string, unknown> | null | undefined): string;
23
+ export declare function fileNote(file: BrainFile): string;
24
+ export declare function buildInboundPrompt(opts: {
25
+ caption: string;
26
+ files: BrainFile[];
27
+ structured?: string;
28
+ failures?: string[];
29
+ }): string;
30
+ export declare function injectTextPreview(file: BrainFile, bytes: Buffer): string | null;
31
+ export type AttachmentFetcher = (att: EncryptedAtt) => Promise<Buffer>;
32
+ export declare function saveInboundFiles(fetchAtt: AttachmentFetcher, attachments: EncryptedAtt[], messageId: string, home?: string): Promise<{
33
+ files: BrainFile[];
34
+ failures: string[];
35
+ previews: string[];
36
+ }>;
@@ -0,0 +1,241 @@
1
+ // Decrypt NoPeek attachments onto the Hermes cache so any brain (Hermes HTTP,
2
+ // Hermes CLI, Claude Code, cmd/webhook) can read photos, video, PDFs, docx,
3
+ // and any other file the human sent. The old hop forwarded only m.body.text.
4
+ import { mkdirSync, writeFileSync } from "node:fs";
5
+ import { extname, join } from "node:path";
6
+ import { hermesHome } from "./backends.js";
7
+ export const CONTROL_TYPES = new Set([
8
+ "reaction",
9
+ "poll_vote",
10
+ "rsvp",
11
+ "edit",
12
+ "recall",
13
+ "link_preview_update",
14
+ ]);
15
+ export const MAX_ATTACHMENT_BYTES = 200 * 1024 * 1024;
16
+ export const MAX_ATTACHMENTS = 20;
17
+ export const MAX_TEXT_INJECT_BYTES = 100 * 1024;
18
+ const MAX_NAME = 80;
19
+ const TEXT_INJECT_EXT = new Set([".txt", ".md", ".csv", ".log", ".json", ".xml", ".yaml", ".yml", ".toml", ".ini", ".cfg"]);
20
+ export function isControlType(type) {
21
+ return typeof type === "string" && CONTROL_TYPES.has(type);
22
+ }
23
+ export function parseAttachments(body) {
24
+ if (!body || typeof body !== "object")
25
+ return [];
26
+ const raw = body.attachments;
27
+ if (!Array.isArray(raw))
28
+ return [];
29
+ const out = [];
30
+ for (const item of raw) {
31
+ if (!item || typeof item !== "object")
32
+ continue;
33
+ const a = item;
34
+ if (typeof a.blobUrl !== "string" || !a.blobUrl)
35
+ continue;
36
+ if (typeof a.contentKey !== "string" || !a.contentKey)
37
+ continue;
38
+ if (typeof a.iv !== "string" || !a.iv)
39
+ continue;
40
+ out.push({
41
+ blobUrl: a.blobUrl,
42
+ contentKey: a.contentKey,
43
+ iv: a.iv,
44
+ name: typeof a.name === "string" && a.name.trim() ? a.name : undefined,
45
+ contentType: typeof a.contentType === "string" ? a.contentType : undefined,
46
+ size: typeof a.size === "number" && Number.isFinite(a.size) ? a.size : undefined,
47
+ });
48
+ if (out.length >= MAX_ATTACHMENTS)
49
+ break;
50
+ }
51
+ return out;
52
+ }
53
+ export function hasInboundWork(body) {
54
+ if (!body || typeof body !== "object")
55
+ return false;
56
+ const b = body;
57
+ if (isControlType(b.type))
58
+ return false;
59
+ if (typeof b.text === "string" && b.text.trim())
60
+ return true;
61
+ if (parseAttachments(body).length > 0)
62
+ return true;
63
+ if (typeof b.type === "string" && b.type !== "text")
64
+ return true;
65
+ return false;
66
+ }
67
+ export function safeFileName(name, fallback) {
68
+ const base = (name ?? fallback).split(/[/\\]/).pop()?.trim() || fallback;
69
+ const cleaned = base.replace(/[^\w.\- ()[\]]+/g, "_").replace(/^\.+/, "") || fallback;
70
+ if (cleaned.length <= MAX_NAME)
71
+ return cleaned;
72
+ const ext = extname(cleaned);
73
+ const stem = cleaned.slice(0, Math.max(1, MAX_NAME - ext.length));
74
+ return `${stem}${ext}`;
75
+ }
76
+ export function classifyKind(contentType, name) {
77
+ const ct = (contentType ?? "").toLowerCase();
78
+ if (ct.startsWith("image/"))
79
+ return "image";
80
+ if (ct.startsWith("video/"))
81
+ return "video";
82
+ if (ct.startsWith("audio/"))
83
+ return "audio";
84
+ const ext = extname(name).toLowerCase();
85
+ if ([".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif", ".bmp", ".tif", ".tiff"].includes(ext))
86
+ return "image";
87
+ if ([".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi"].includes(ext))
88
+ return "video";
89
+ if ([".mp3", ".m4a", ".aac", ".wav", ".ogg", ".flac", ".opus"].includes(ext))
90
+ return "audio";
91
+ return "document";
92
+ }
93
+ export function inferContentType(name, contentType) {
94
+ if (contentType && contentType !== "application/octet-stream")
95
+ return contentType;
96
+ const ext = extname(name).toLowerCase();
97
+ const map = {
98
+ ".jpg": "image/jpeg",
99
+ ".jpeg": "image/jpeg",
100
+ ".png": "image/png",
101
+ ".gif": "image/gif",
102
+ ".webp": "image/webp",
103
+ ".heic": "image/heic",
104
+ ".mp4": "video/mp4",
105
+ ".mov": "video/quicktime",
106
+ ".webm": "video/webm",
107
+ ".mp3": "audio/mpeg",
108
+ ".m4a": "audio/mp4",
109
+ ".wav": "audio/wav",
110
+ ".ogg": "audio/ogg",
111
+ ".pdf": "application/pdf",
112
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
113
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
114
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
115
+ ".txt": "text/plain",
116
+ ".md": "text/markdown",
117
+ ".csv": "text/csv",
118
+ ".json": "application/json",
119
+ };
120
+ return map[ext] || contentType || "application/octet-stream";
121
+ }
122
+ export function cacheDirFor(kind, home = hermesHome()) {
123
+ const sub = kind === "image" ? "images" : kind === "video" ? "videos" : kind === "audio" ? "audio" : "documents";
124
+ return join(home, "cache", sub);
125
+ }
126
+ export function describeStructured(body) {
127
+ if (!body || typeof body.type !== "string")
128
+ return "";
129
+ if (body.type === "poll" && body.poll && typeof body.poll === "object") {
130
+ const poll = body.poll;
131
+ const q = typeof poll.question === "string" ? poll.question : "poll";
132
+ const opts = Array.isArray(poll.options)
133
+ ? poll.options
134
+ .map((o) => (o && typeof o === "object" && typeof o.label === "string" ? o.label : ""))
135
+ .filter(Boolean)
136
+ : [];
137
+ return `[The user sent a poll: "${q}"${opts.length ? ` options: ${opts.join(" / ")}` : ""}]`;
138
+ }
139
+ if (body.type === "contact" && body.contact && typeof body.contact === "object") {
140
+ const c = body.contact;
141
+ const name = typeof c.name === "string" ? c.name : "contact";
142
+ const org = typeof c.organization === "string" ? ` (${c.organization})` : "";
143
+ return `[The user sent a contact card: ${name}${org}]`;
144
+ }
145
+ if ((body.type === "calendar_event" || body.calendarEvent) && body.calendarEvent && typeof body.calendarEvent === "object") {
146
+ const ev = body.calendarEvent;
147
+ const title = typeof ev.title === "string" ? ev.title : "event";
148
+ const start = typeof ev.start === "string" ? ` at ${ev.start}` : "";
149
+ return `[The user sent a calendar event: ${title}${start}]`;
150
+ }
151
+ if (body.type === "location" && body.location && typeof body.location === "object") {
152
+ const loc = body.location;
153
+ const label = typeof loc.label === "string" ? loc.label : "location";
154
+ const lat = typeof loc.lat === "number" ? loc.lat : "?";
155
+ const lng = typeof loc.lng === "number" ? loc.lng : "?";
156
+ return `[The user sent a location: ${label} (${lat}, ${lng})]`;
157
+ }
158
+ return "";
159
+ }
160
+ export function fileNote(file) {
161
+ const size = file.size > 0 ? `, ${formatBytes(file.size)}` : "";
162
+ const noun = file.kind === "image" ? "photo" : file.kind === "video" ? "video" : file.kind === "audio" ? "audio file" : "document";
163
+ return (`[The user sent a ${noun}: '${file.name}' (${file.contentType}${size}). ` +
164
+ `The file is saved at: ${file.path}. ` +
165
+ `Read, inspect, and work from that path. If you need to edit it, copy it into the project first — this cache is temporary.]`);
166
+ }
167
+ export function buildInboundPrompt(opts) {
168
+ const parts = [];
169
+ for (const file of opts.files)
170
+ parts.push(fileNote(file));
171
+ for (const fail of opts.failures ?? [])
172
+ parts.push(fail);
173
+ if (opts.structured)
174
+ parts.push(opts.structured);
175
+ if (opts.caption.trim())
176
+ parts.push(opts.caption.trim());
177
+ if (parts.length === 0)
178
+ return "";
179
+ return parts.join("\n\n");
180
+ }
181
+ export function injectTextPreview(file, bytes) {
182
+ const ext = extname(file.name).toLowerCase();
183
+ if (!TEXT_INJECT_EXT.has(ext))
184
+ return null;
185
+ if (bytes.length > MAX_TEXT_INJECT_BYTES)
186
+ return null;
187
+ try {
188
+ const text = bytes.toString("utf8");
189
+ if (!text.trim())
190
+ return null;
191
+ return `[Content of ${file.name}]:\n${text}`;
192
+ }
193
+ catch {
194
+ return null;
195
+ }
196
+ }
197
+ export async function saveInboundFiles(fetchAtt, attachments, messageId, home = hermesHome()) {
198
+ const files = [];
199
+ const failures = [];
200
+ const previews = [];
201
+ const shortId = (messageId || "msg").replace(/[^a-zA-Z0-9]/g, "").slice(-10) || "msg";
202
+ let i = 0;
203
+ for (const att of attachments) {
204
+ i += 1;
205
+ const name = safeFileName(att.name, `attachment-${i}`);
206
+ const contentType = inferContentType(name, att.contentType);
207
+ const kind = classifyKind(contentType, name);
208
+ if (typeof att.size === "number" && att.size > MAX_ATTACHMENT_BYTES) {
209
+ failures.push(`[Could not save '${name}': ${formatBytes(att.size)} is over the ${formatBytes(MAX_ATTACHMENT_BYTES)} limit.]`);
210
+ continue;
211
+ }
212
+ try {
213
+ const bytes = await fetchAtt(att);
214
+ if (bytes.length > MAX_ATTACHMENT_BYTES) {
215
+ failures.push(`[Could not save '${name}': ${formatBytes(bytes.length)} is over the ${formatBytes(MAX_ATTACHMENT_BYTES)} limit.]`);
216
+ continue;
217
+ }
218
+ const dir = cacheDirFor(kind, home);
219
+ mkdirSync(dir, { recursive: true });
220
+ const prefix = kind === "image" ? "img" : kind === "video" ? "vid" : kind === "audio" ? "aud" : "doc";
221
+ const path = join(dir, `${prefix}_nopeek_${shortId}_${i}_${name}`);
222
+ writeFileSync(path, bytes);
223
+ const file = { path, name, contentType, size: bytes.length, kind };
224
+ files.push(file);
225
+ const preview = injectTextPreview(file, bytes);
226
+ if (preview)
227
+ previews.push(preview);
228
+ }
229
+ catch (err) {
230
+ failures.push(`[Could not download attachment '${name}': ${err.message}]`);
231
+ }
232
+ }
233
+ return { files, failures, previews };
234
+ }
235
+ function formatBytes(n) {
236
+ if (n < 1024)
237
+ return `${n} B`;
238
+ if (n < 1024 * 1024)
239
+ return `${Math.round(n / 102.4) / 10} KB`;
240
+ return `${Math.round(n / (1024 * 102.4)) / 10} MB`;
241
+ }
@@ -0,0 +1,66 @@
1
+ export type ToolProgressEvent = {
2
+ tool: string;
3
+ emoji?: string;
4
+ label?: string;
5
+ status?: string;
6
+ };
7
+ /** One Telegram-style line. Running events only; completed is silent. */
8
+ export declare function formatToolLine(ev: ToolProgressEvent): string | null;
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.";
11
+ export interface StreamHandle {
12
+ append(chunk: string): void;
13
+ replace(full: string): void;
14
+ done(finalText?: string): Promise<unknown>;
15
+ fail(text: string): Promise<void>;
16
+ }
17
+ export interface TurnSink {
18
+ stream(): Promise<StreamHandle>;
19
+ send(text: string): Promise<void>;
20
+ }
21
+ export type TurnPublisherOpts = {
22
+ stopTyping: () => void;
23
+ onStreamError: (err: Error) => void;
24
+ /** Wait this long for a tool before opening a streamed answer. */
25
+ startDelayMs?: number;
26
+ /** Start a fresh progress bubble after this many lines. */
27
+ maxProgressLines?: number;
28
+ };
29
+ export type TurnFinishKind = "streamed" | "sent" | "empty";
30
+ /** Text in `full` that has not already been posted as commentary. */
31
+ export declare function unpublishedTail(full: string, published: string): string;
32
+ /**
33
+ * One chat turn: live tool progress (edited in place) with commentary
34
+ * messages in between tool batches, then any leftover recap below.
35
+ * All channel writes run on a single promise chain so bubbles stay in order.
36
+ */
37
+ export declare class TurnPublisher {
38
+ private readonly sink;
39
+ private readonly opts;
40
+ private write;
41
+ private progressP;
42
+ private progress;
43
+ private progressLines;
44
+ private textP;
45
+ private textBuf;
46
+ private held;
47
+ private published;
48
+ private toolsUsed;
49
+ private startTimer;
50
+ private seen;
51
+ private readonly startDelayMs;
52
+ private readonly maxProgressLines;
53
+ constructor(sink: TurnSink, opts: TurnPublisherOpts);
54
+ onChunk(delta: string): void;
55
+ onTool(ev: ToolProgressEvent): void;
56
+ finish(reply: string): Promise<TurnFinishKind>;
57
+ fail(text: string): Promise<void>;
58
+ private cancelStart;
59
+ private enqueue;
60
+ /** Open or append the current commentary/answer stream with this chunk only. */
61
+ private appendCommentary;
62
+ /** Finalize the current commentary so later tools land below it. */
63
+ private commitText;
64
+ private addProgressLine;
65
+ private closeProgress;
66
+ }
@@ -0,0 +1,293 @@
1
+ // Telegram-style tool progress for NoPeek bots.
2
+ //
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().
11
+ const FALLBACK_EMOJI = {
12
+ read_file: "📖",
13
+ write_file: "✍️",
14
+ patch: "🔧",
15
+ search_files: "🔎",
16
+ terminal: "💻",
17
+ web_search: "🔍",
18
+ web_extract: "📄",
19
+ web_crawl: "🕸️",
20
+ memory: "🧠",
21
+ skill_view: "📘",
22
+ skill_manage: "🧩",
23
+ skills_list: "📚",
24
+ todo: "✅",
25
+ execute_code: "🐍",
26
+ delegate_task: "👥",
27
+ cronjob: "⏰",
28
+ process: "⚙️",
29
+ };
30
+ /** One Telegram-style line. Running events only; completed is silent. */
31
+ export function formatToolLine(ev) {
32
+ if (ev.status && ev.status !== "running")
33
+ return null;
34
+ const tool = (ev.tool || "").trim();
35
+ if (!tool || tool.startsWith("_"))
36
+ return null;
37
+ const emoji = (ev.emoji || FALLBACK_EMOJI[tool] || "⚡").trim() || "⚡";
38
+ const label = tidyLabel(ev.label);
39
+ if (label)
40
+ return `${emoji} ${tool}: "${label}"`;
41
+ return `${emoji} ${tool}...`;
42
+ }
43
+ function tidyLabel(raw) {
44
+ if (!raw)
45
+ return "";
46
+ const s = raw.replace(/\s+/g, " ").trim();
47
+ if (!s)
48
+ return "";
49
+ return s.length > 80 ? `${s.slice(0, 77)}...` : s;
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
+ if (full.startsWith(published))
64
+ return full.slice(published.length).trim();
65
+ if (f.startsWith(p))
66
+ return f.slice(p.length).trim();
67
+ const idx = f.indexOf(p);
68
+ if (idx >= 0)
69
+ return (f.slice(0, idx) + f.slice(idx + p.length)).trim();
70
+ return f;
71
+ }
72
+ /**
73
+ * One chat turn: live tool progress (edited in place) with commentary
74
+ * messages in between tool batches, then any leftover recap below.
75
+ * All channel writes run on a single promise chain so bubbles stay in order.
76
+ */
77
+ export class TurnPublisher {
78
+ sink;
79
+ opts;
80
+ write = Promise.resolve();
81
+ progressP = null;
82
+ progress = null;
83
+ progressLines = [];
84
+ textP = null;
85
+ textBuf = "";
86
+ held = "";
87
+ published = "";
88
+ toolsUsed = false;
89
+ startTimer = null;
90
+ seen = new Set();
91
+ startDelayMs;
92
+ maxProgressLines;
93
+ constructor(sink, opts) {
94
+ this.sink = sink;
95
+ this.opts = opts;
96
+ this.startDelayMs = opts.startDelayMs ?? 400;
97
+ this.maxProgressLines = opts.maxProgressLines ?? 16;
98
+ }
99
+ onChunk(delta) {
100
+ if (!delta)
101
+ return;
102
+ if (this.toolsUsed) {
103
+ this.cancelStart();
104
+ this.enqueue(() => this.appendCommentary(delta));
105
+ return;
106
+ }
107
+ if (this.textP) {
108
+ this.enqueue(() => this.appendCommentary(delta));
109
+ return;
110
+ }
111
+ this.held += delta;
112
+ if (this.startTimer)
113
+ return;
114
+ this.startTimer = setTimeout(() => {
115
+ this.startTimer = null;
116
+ if (this.toolsUsed || this.textP || !this.held)
117
+ return;
118
+ const chunk = this.held;
119
+ this.held = "";
120
+ this.enqueue(() => this.appendCommentary(chunk));
121
+ }, this.startDelayMs);
122
+ }
123
+ onTool(ev) {
124
+ const line = formatToolLine(ev);
125
+ if (!line)
126
+ return;
127
+ if (this.progressLines[this.progressLines.length - 1] === line)
128
+ return;
129
+ if (this.seen.has(line) && this.progressLines.includes(line))
130
+ return;
131
+ this.seen.add(line);
132
+ this.toolsUsed = true;
133
+ if (this.startTimer) {
134
+ clearTimeout(this.startTimer);
135
+ this.startTimer = null;
136
+ }
137
+ const pending = this.held;
138
+ this.held = "";
139
+ this.enqueue(async () => {
140
+ await this.commitText(pending);
141
+ await this.addProgressLine(line);
142
+ });
143
+ }
144
+ async finish(reply) {
145
+ this.cancelStart();
146
+ await this.write;
147
+ 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
+ await this.commitText(this.held);
158
+ const rest = unpublishedTail(trimmed, this.published);
159
+ if (!rest)
160
+ return this.published.trim() ? "streamed" : "empty";
161
+ this.opts.stopTyping();
162
+ await this.sink.send(rest);
163
+ return "sent";
164
+ }
165
+ async fail(text) {
166
+ this.cancelStart();
167
+ await this.write;
168
+ await this.closeProgress();
169
+ if (this.textP) {
170
+ const s = await this.textP.catch(() => null);
171
+ this.textP = null;
172
+ this.textBuf = "";
173
+ this.held = "";
174
+ if (s) {
175
+ await s.fail(text);
176
+ return;
177
+ }
178
+ }
179
+ this.opts.stopTyping();
180
+ await this.sink.send(text);
181
+ }
182
+ cancelStart() {
183
+ if (!this.startTimer)
184
+ return;
185
+ clearTimeout(this.startTimer);
186
+ this.startTimer = null;
187
+ }
188
+ enqueue(fn) {
189
+ this.write = this.write.then(fn).catch((err) => {
190
+ console.error(`[turn] ${err.message}`);
191
+ });
192
+ }
193
+ /** Open or append the current commentary/answer stream with this chunk only. */
194
+ async appendCommentary(chunk) {
195
+ if (!chunk)
196
+ return;
197
+ if (this.toolsUsed)
198
+ await this.closeProgress();
199
+ this.opts.stopTyping();
200
+ if (!this.textP) {
201
+ this.textP = this.sink.stream();
202
+ try {
203
+ const s = await this.textP;
204
+ s.append(chunk);
205
+ this.textBuf += chunk;
206
+ }
207
+ catch (err) {
208
+ this.textP = null;
209
+ this.opts.onStreamError(err);
210
+ await this.sink.send(chunk).catch(() => { });
211
+ this.published += chunk;
212
+ }
213
+ return;
214
+ }
215
+ try {
216
+ const s = await this.textP;
217
+ s.append(chunk);
218
+ this.textBuf += chunk;
219
+ }
220
+ catch (err) {
221
+ this.textP = null;
222
+ this.opts.onStreamError(err);
223
+ await this.sink.send(this.textBuf + chunk).catch(() => { });
224
+ this.published += this.textBuf + chunk;
225
+ this.textBuf = "";
226
+ }
227
+ }
228
+ /** Finalize the current commentary so later tools land below it. */
229
+ async commitText(pending = "") {
230
+ if (this.textP) {
231
+ const s = await this.textP.catch(() => null);
232
+ this.textP = null;
233
+ const extra = pending;
234
+ const final = this.textBuf + extra;
235
+ this.textBuf = "";
236
+ if (s && final) {
237
+ if (extra)
238
+ s.append(extra);
239
+ await s.done(final).catch(() => { });
240
+ this.published += final;
241
+ return;
242
+ }
243
+ if (final.trim()) {
244
+ this.opts.stopTyping();
245
+ await this.sink.send(final).catch(() => { });
246
+ this.published += final;
247
+ }
248
+ return;
249
+ }
250
+ if (!pending.trim())
251
+ return;
252
+ this.opts.stopTyping();
253
+ await this.sink.send(pending);
254
+ this.published += pending;
255
+ }
256
+ async addProgressLine(line) {
257
+ this.opts.stopTyping();
258
+ if (this.progressLines.length >= this.maxProgressLines && this.progressP) {
259
+ await this.closeProgress();
260
+ }
261
+ this.progressLines.push(line);
262
+ const body = this.progressLines.join("\n");
263
+ if (!this.progressP) {
264
+ this.progressP = this.sink.stream();
265
+ try {
266
+ this.progress = await this.progressP;
267
+ this.progress.replace(body);
268
+ }
269
+ catch (err) {
270
+ this.progressP = null;
271
+ this.progress = null;
272
+ this.opts.onStreamError(err);
273
+ await this.sink.send(body).catch(() => { });
274
+ }
275
+ return;
276
+ }
277
+ this.progress?.replace(body);
278
+ }
279
+ async closeProgress() {
280
+ if (!this.progressP) {
281
+ this.progressLines = [];
282
+ this.progress = null;
283
+ return;
284
+ }
285
+ const s = await this.progressP.catch(() => null);
286
+ this.progressP = null;
287
+ this.progress = null;
288
+ const text = this.progressLines.join("\n");
289
+ this.progressLines = [];
290
+ if (s && text)
291
+ await s.done(text).catch(() => { });
292
+ }
293
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nopeek/agent-bridge",
3
- "version": "0.7.10",
3
+ "version": "0.7.14",
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",
@@ -45,6 +45,7 @@
45
45
  "build": "tsc -p tsconfig.json",
46
46
  "start": "node dist/cli.js",
47
47
  "dev": "tsx src/cli.ts",
48
- "typecheck": "tsc -p tsconfig.json --noEmit"
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
50
  }
50
51
  }