@nopeek/agent-bridge 0.7.11 → 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,9 +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 posts Telegram-style tool lines a few at a
45
- time (read file, patch, skill, memory). A quiet gap starts a new message.
46
- The final reply is the answer plus a short recap when tools were used.
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.
47
47
  Idle kill waits for **no output and no CPU** (default 15 min). Authenticated
48
48
  `GET /status` includes `bridge`, `bots[]` (each with `lastTurn`), `hermesApi`,
49
49
  and `lastTurn`. See `docs/HERMES-BRAIN-PLAN.md`.
@@ -79,6 +79,7 @@ Context is passed as environment variables:
79
79
  | `NOPEEK_BOT_USER_ID` | user id of the bot |
80
80
  | `NOPEEK_CHANNEL_ID` | channel the message arrived in |
81
81
  | `NOPEEK_SENDER_USER_ID` | who sent the message |
82
+ | `NOPEEK_FILES` | JSON array of decrypted inbound files (`path`, `name`, `contentType`, `size`, `kind`) |
82
83
 
83
84
  Examples:
84
85
 
@@ -109,7 +110,8 @@ Content-Type: application/json
109
110
  "botHandle": "weatherbot",
110
111
  "botUserId": "usr_…",
111
112
  "channelId": "ch_…",
112
- "senderUserId": "usr_…"
113
+ "senderUserId": "usr_…",
114
+ "files": []
113
115
  }
114
116
  ```
115
117
 
package/dist/bot.js CHANGED
@@ -7,7 +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 { ToolProgressFlusher } from "./tool-progress.js";
10
+ import { TurnPublisher } from "./tool-progress.js";
11
+ import { buildInboundPrompt, describeStructured, hasInboundWork, isControlType, parseAttachments, saveInboundFiles, } from "./inbound-files.js";
11
12
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
12
13
  const MAX_BACKOFF_MS = 60_000;
13
14
  // Owner-membership answers are cached per channel for a short window; a
@@ -457,8 +458,11 @@ export class BotRunner {
457
458
  }
458
459
  return;
459
460
  }
460
- 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))
461
463
  return;
464
+ const attachments = parseAttachments(body);
465
+ const caption = typeof body.text === "string" ? body.text : "";
462
466
  // OWNER-PRESENT POLICY: in a multi-party channel (anything but a direct
463
467
  // chat), a non-owner sender may use the bot ONLY while the bot's owner is
464
468
  // also a member of that channel. Owner absent → completely silent (no
@@ -516,7 +520,7 @@ export class BotRunner {
516
520
  // predictable: mention-only means even the owner must @mention there.
517
521
  // DIRECT channels always behave as "everyone" (a DM with the bot is always
518
522
  // for the bot).
519
- let text = m.body.text;
523
+ let text = caption;
520
524
  if (ch.record.kind !== "direct" && ch.record.kind !== "dm") {
521
525
  const mode = this.channelModes.get(m.channelId) ?? "everyone";
522
526
  if (mode === "off") {
@@ -534,6 +538,26 @@ export class BotRunner {
534
538
  text = stripped;
535
539
  }
536
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;
537
561
  this.log(`${m.channelId} <- ${m.senderUserId}: ${text.slice(0, 120)}`);
538
562
  ch.markRead(m.messageId).catch(() => { });
539
563
  try {
@@ -546,12 +570,8 @@ export class BotRunner {
546
570
  // local API (PUT /brains) and the very next message uses the new one.
547
571
  const resolved = resolveBrain(this.cfg, this.info.handle);
548
572
  this.brainKind = resolved.kind;
549
- // Streaming: if the brain emits chunks, open a streaming message on the
550
- // FIRST chunk and forward deltas into it (typing indicator stays on until
551
- // then). Deltas can arrive before the placeholder lands — chaining every
552
- // append onto the open promise keeps them ordered and loses none.
553
- // (Ref object rather than a `let`: TS can't see closure assignments.)
554
- const streamRef = { p: null };
573
+ // Telegram-style turn: tool lines edit one live bubble; commentary
574
+ // between tool batches is posted in place; leftover recap goes last.
555
575
  const stopTyping = () => {
556
576
  try {
557
577
  ch.typing(false);
@@ -560,23 +580,17 @@ export class BotRunner {
560
580
  /* best-effort */
561
581
  }
562
582
  };
563
- const onChunk = (delta) => {
564
- if (!delta)
565
- return;
566
- void progress.flush();
567
- if (!streamRef.p) {
568
- stopTyping();
569
- streamRef.p = ch.stream();
570
- streamRef.p.catch((err) => {
571
- this.notePostFailure(m.channelId, err);
572
- this.logErr(`stream open failed (falling back to a single send): ${err.message}`);
573
- });
574
- }
575
- streamRef.p.then((s) => s.append(delta)).catch(() => { });
576
- };
577
- const progress = new ToolProgressFlusher(async (body) => {
578
- stopTyping();
579
- await ch.send({ text: body });
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
+ },
580
594
  });
581
595
  let reply = "";
582
596
  const turn = beginTurn(this.info.handle, m.channelId, resolved.kind);
@@ -586,7 +600,8 @@ export class BotRunner {
586
600
  botUserId: this.info.userId,
587
601
  channelId: m.channelId,
588
602
  senderUserId: m.senderUserId,
589
- }, { onChunk, onTool: (ev) => progress.push(ev) });
603
+ ...(files.length ? { files } : {}),
604
+ }, { onChunk: (delta) => published.onChunk(delta), onTool: (ev) => published.onTool(ev) });
590
605
  const trimmed = reply.trim();
591
606
  const timedOut = /went quiet for over \d+s/i.test(trimmed);
592
607
  finishTurn(turn, {
@@ -600,10 +615,7 @@ export class BotRunner {
600
615
  finishTurn(turn, { ok: false, error: err.message, timedOut: false });
601
616
  // Brain blew up mid-stream: finalize the partial bubble with an honest
602
617
  // error line instead of leaving a forever-blinking cursor.
603
- await progress.flush();
604
- const stream = streamRef.p ? await streamRef.p.catch(() => null) : null;
605
- if (stream)
606
- await stream.fail(FALLBACK_REPLY).catch(() => { });
618
+ await published.fail(FALLBACK_REPLY).catch(() => { });
607
619
  throw err;
608
620
  }
609
621
  finally {
@@ -614,21 +626,13 @@ export class BotRunner {
614
626
  /* best-effort */
615
627
  }
616
628
  }
617
- await progress.flush();
618
- const stream = streamRef.p ? await streamRef.p.catch(() => null) : null;
619
- if (stream) {
620
- await stream.done(reply.trim() || undefined);
621
- this.handled++;
622
- this.log(`${m.channelId} -> streamed reply (${reply.trim().length} chars, handled=${this.handled})`);
623
- return;
624
- }
625
- if (!reply || !reply.trim()) {
629
+ const how = await published.finish(reply.trim());
630
+ if (how === "empty") {
626
631
  this.log(`brain returned empty reply — ignoring`);
627
632
  return;
628
633
  }
629
- await ch.send({ text: reply.trim() });
630
634
  this.handled++;
631
- 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})`);
632
636
  }
633
637
  /** A FORBIDDEN post (broadcast channel, bot not an operator) fails for every
634
638
  * future message too — mute the channel so the brain stops running there. */
package/dist/brain.d.ts CHANGED
@@ -1,10 +1,20 @@
1
1
  import type { BridgeConfig } from "./config.js";
2
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
+ }
3
11
  export interface BrainContext {
4
12
  botHandle: string;
5
13
  botUserId: string;
6
14
  channelId: string;
7
15
  senderUserId: string;
16
+ /** Local paths of photos / video / PDFs / any file the human just sent. */
17
+ files?: BrainFile[];
8
18
  }
9
19
  /**
10
20
  * A brain answers one message. If it can stream, it calls `onChunk(delta)` as
package/dist/brain.js CHANGED
@@ -40,6 +40,7 @@ function cmdBrain(cmd, timeoutMs) {
40
40
  NOPEEK_BOT_USER_ID: ctx.botUserId,
41
41
  NOPEEK_CHANNEL_ID: ctx.channelId,
42
42
  NOPEEK_SENDER_USER_ID: ctx.senderUserId,
43
+ ...(ctx.files?.length ? { NOPEEK_FILES: JSON.stringify(ctx.files) } : {}),
43
44
  },
44
45
  });
45
46
  let stdout = "";
@@ -113,6 +114,7 @@ function urlBrain(url, timeoutMs) {
113
114
  botUserId: ctx.botUserId,
114
115
  channelId: ctx.channelId,
115
116
  senderUserId: ctx.senderUserId,
117
+ ...(ctx.files?.length ? { files: ctx.files } : {}),
116
118
  }),
117
119
  });
118
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 {
package/dist/cli.js CHANGED
File without changes
@@ -1,5 +1,30 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { asHooks } from "./brain.js";
2
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
+ }
3
28
  let lastHealth = null;
4
29
  const HEALTH_TTL_MS = 15_000;
5
30
  export function lastHermesApiHealth() {
@@ -74,7 +99,7 @@ export function hermesHttpBrain(cfg) {
74
99
  stream: true,
75
100
  messages: [
76
101
  { role: "system", content: RECAP_HINT },
77
- { role: "user", content: text },
102
+ { role: "user", content: userContent(text, ctx.files) },
78
103
  ],
79
104
  }),
80
105
  signal: controller.signal,
@@ -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
+ }
@@ -6,24 +6,61 @@ export type ToolProgressEvent = {
6
6
  };
7
7
  /** One Telegram-style line. Running events only; completed is silent. */
8
8
  export declare function formatToolLine(ev: ToolProgressEvent): string | null;
9
- export type ProgressSender = (text: string) => Promise<void>;
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;
10
32
  /**
11
- * Collect tool lines and flush them as short chat messages.
12
- * Flush when we have `maxLines` tools, or after `gapMs` of quiet.
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.
13
36
  */
14
- export declare class ToolProgressFlusher {
15
- private readonly send;
16
- private readonly gapMs;
17
- private readonly maxLines;
18
- private lines;
19
- private timer;
20
- private chain;
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;
21
50
  private seen;
22
- constructor(send: ProgressSender, gapMs?: number, maxLines?: number);
23
- get pending(): number;
24
- push(ev: ToolProgressEvent): void;
25
- flush(): Promise<void>;
26
- private arm;
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;
27
66
  }
28
- /** Hint so Hermes ends a tool-using turn with a recap, like Telegram. */
29
- 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.";
@@ -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,246 @@ 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
+ 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
+ }
47
72
  /**
48
- * Collect tool lines and flush them as short chat messages.
49
- * Flush when we have `maxLines` tools, or after `gapMs` of quiet.
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.
50
76
  */
51
- export class ToolProgressFlusher {
52
- send;
53
- gapMs;
54
- maxLines;
55
- lines = [];
56
- timer = null;
57
- chain = Promise.resolve();
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;
58
90
  seen = new Set();
59
- constructor(send, gapMs = 2200, maxLines = 4) {
60
- this.send = send;
61
- this.gapMs = gapMs;
62
- this.maxLines = maxLines;
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;
63
98
  }
64
- get pending() {
65
- return this.lines.length;
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);
66
122
  }
67
- push(ev) {
123
+ onTool(ev) {
68
124
  const line = formatToolLine(ev);
69
125
  if (!line)
70
126
  return;
71
- if (this.lines[this.lines.length - 1] === line)
127
+ if (this.progressLines[this.progressLines.length - 1] === line)
72
128
  return;
73
- if (this.seen.has(line) && this.lines.includes(line))
129
+ if (this.seen.has(line) && this.progressLines.includes(line))
74
130
  return;
75
131
  this.seen.add(line);
76
- this.lines.push(line);
77
- if (this.lines.length >= this.maxLines) {
78
- void this.flush();
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
+ }
79
248
  return;
80
249
  }
81
- this.arm();
250
+ if (!pending.trim())
251
+ return;
252
+ this.opts.stopTyping();
253
+ await this.sink.send(pending);
254
+ this.published += pending;
82
255
  }
83
- async flush() {
84
- if (this.timer) {
85
- clearTimeout(this.timer);
86
- this.timer = null;
256
+ async addProgressLine(line) {
257
+ this.opts.stopTyping();
258
+ if (this.progressLines.length >= this.maxProgressLines && this.progressP) {
259
+ await this.closeProgress();
87
260
  }
88
- if (this.lines.length === 0)
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
+ }
89
275
  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;
276
+ }
277
+ this.progress?.replace(body);
94
278
  }
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);
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(() => { });
102
292
  }
103
293
  }
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.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",
@@ -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"
50
50
  }
51
- }
51
+ }