@nopeek/agent-bridge 0.7.10 → 0.7.11

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 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
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`.
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,7 @@ 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
11
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
11
12
  const MAX_BACKOFF_MS = 60_000;
12
13
  // Owner-membership answers are cached per channel for a short window; a
@@ -551,16 +552,20 @@ export class BotRunner {
551
552
  // append onto the open promise keeps them ordered and loses none.
552
553
  // (Ref object rather than a `let`: TS can't see closure assignments.)
553
554
  const streamRef = { p: null };
555
+ const stopTyping = () => {
556
+ try {
557
+ ch.typing(false);
558
+ }
559
+ catch {
560
+ /* best-effort */
561
+ }
562
+ };
554
563
  const onChunk = (delta) => {
555
564
  if (!delta)
556
565
  return;
566
+ void progress.flush();
557
567
  if (!streamRef.p) {
558
- try {
559
- ch.typing(false); // the live bubble replaces the typing indicator
560
- }
561
- catch {
562
- /* best-effort */
563
- }
568
+ stopTyping();
564
569
  streamRef.p = ch.stream();
565
570
  streamRef.p.catch((err) => {
566
571
  this.notePostFailure(m.channelId, err);
@@ -569,6 +574,10 @@ export class BotRunner {
569
574
  }
570
575
  streamRef.p.then((s) => s.append(delta)).catch(() => { });
571
576
  };
577
+ const progress = new ToolProgressFlusher(async (body) => {
578
+ stopTyping();
579
+ await ch.send({ text: body });
580
+ });
572
581
  let reply = "";
573
582
  const turn = beginTurn(this.info.handle, m.channelId, resolved.kind);
574
583
  try {
@@ -577,7 +586,7 @@ export class BotRunner {
577
586
  botUserId: this.info.userId,
578
587
  channelId: m.channelId,
579
588
  senderUserId: m.senderUserId,
580
- }, onChunk);
589
+ }, { onChunk, onTool: (ev) => progress.push(ev) });
581
590
  const trimmed = reply.trim();
582
591
  const timedOut = /went quiet for over \d+s/i.test(trimmed);
583
592
  finishTurn(turn, {
@@ -591,6 +600,7 @@ export class BotRunner {
591
600
  finishTurn(turn, { ok: false, error: err.message, timedOut: false });
592
601
  // Brain blew up mid-stream: finalize the partial bubble with an honest
593
602
  // error line instead of leaving a forever-blinking cursor.
603
+ await progress.flush();
594
604
  const stream = streamRef.p ? await streamRef.p.catch(() => null) : null;
595
605
  if (stream)
596
606
  await stream.fail(FALLBACK_REPLY).catch(() => { });
@@ -604,6 +614,7 @@ export class BotRunner {
604
614
  /* best-effort */
605
615
  }
606
616
  }
617
+ await progress.flush();
607
618
  const stream = streamRef.p ? await streamRef.p.catch(() => null) : null;
608
619
  if (stream) {
609
620
  await stream.done(reply.trim() || undefined);
package/dist/brain.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { BridgeConfig } from "./config.js";
2
+ import type { ToolProgressEvent } from "./tool-progress.js";
2
3
  export interface BrainContext {
3
4
  botHandle: string;
4
5
  botUserId: string;
@@ -9,8 +10,14 @@ export interface BrainContext {
9
10
  * A brain answers one message. If it can stream, it calls `onChunk(delta)` as
10
11
  * text arrives (plain text, ANSI-stripped) and STILL returns the full reply —
11
12
  * the returned string is authoritative for the final message body.
13
+ * `onTool` is optional: Hermes HTTP uses it for live tool-progress lines.
12
14
  */
13
- export type Brain = (text: string, ctx: BrainContext, onChunk?: (delta: string) => void) => Promise<string>;
15
+ export type BrainHooks = {
16
+ onChunk?: (delta: string) => void;
17
+ onTool?: (ev: ToolProgressEvent) => void;
18
+ };
19
+ export type Brain = (text: string, ctx: BrainContext, hooks?: BrainHooks | ((delta: string) => void)) => Promise<string>;
20
+ export declare function asHooks(hooks?: BrainHooks | ((delta: string) => void)): BrainHooks;
14
21
  export declare const FALLBACK_REPLY = "Sorry \u2014 I hit an error processing that. Please try again.";
15
22
  export declare function stripAnsi(s: string): string;
16
23
  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: {
package/dist/cli.js CHANGED
File without changes
@@ -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,5 @@
1
+ import { asHooks } from "./brain.js";
2
+ import { RECAP_HINT } from "./tool-progress.js";
1
3
  let lastHealth = null;
2
4
  const HEALTH_TTL_MS = 15_000;
3
5
  export function lastHermesApiHealth() {
@@ -35,16 +37,20 @@ export async function probeHermesApi(cfg, force = false) {
35
37
  * Session continuity: X-Hermes-Session-Key = nopeek-<channelId> (stable per chat).
36
38
  */
37
39
  export function hermesHttpBrain(cfg) {
38
- return async (text, ctx, onChunk) => {
40
+ return async (text, ctx, hooks) => {
41
+ const { onChunk, onTool } = asHooks(hooks);
39
42
  const handle = ctx.botHandle.replace(/^@/, "");
40
43
  const tag = `[brain:hermes-http:@${handle}]`;
41
44
  const url = cfg.hermesApiUrl.replace(/\/+$/, "");
42
45
  const headers = {
43
46
  "content-type": "application/json",
44
- "X-Hermes-Session-Key": `nopeek-${ctx.channelId}`,
45
47
  };
46
- if (cfg.hermesApiKey)
48
+ // Hermes rejects X-Hermes-Session-Key with 403 unless API_SERVER_KEY is set.
49
+ // Without a key, skip the header so the turn still runs (no session memory).
50
+ if (cfg.hermesApiKey) {
47
51
  headers.authorization = `Bearer ${cfg.hermesApiKey}`;
52
+ headers["X-Hermes-Session-Key"] = `nopeek-${ctx.channelId}`;
53
+ }
48
54
  // Idle abort — same rule as the CLI path. A wall-clock timeout on the
49
55
  // whole POST would kill a healthy 20-minute agentic turn. Any SSE byte
50
56
  // (token, keepalive, tool-progress) resets the idle timer.
@@ -66,7 +72,10 @@ export function hermesHttpBrain(cfg) {
66
72
  body: JSON.stringify({
67
73
  model: "hermes-agent",
68
74
  stream: true,
69
- messages: [{ role: "user", content: text }],
75
+ messages: [
76
+ { role: "system", content: RECAP_HINT },
77
+ { role: "user", content: text },
78
+ ],
70
79
  }),
71
80
  signal: controller.signal,
72
81
  });
@@ -98,29 +107,40 @@ export function hermesHttpBrain(cfg) {
98
107
  break;
99
108
  armIdle();
100
109
  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:"))
106
- continue;
107
- const payload = trimmed.slice(5).trim();
108
- if (!payload || payload === "[DONE]")
110
+ const frames = buf.split("\n\n");
111
+ buf = frames.pop() ?? "";
112
+ for (const frame of frames) {
113
+ const ev = parseSseFrame(frame);
114
+ if (!ev)
109
115
  continue;
110
- let parsed;
111
- try {
112
- parsed = JSON.parse(payload);
113
- }
114
- catch {
116
+ if (ev.event === "hermes.tool.progress") {
117
+ const tool = asToolProgress(ev.data);
118
+ if (tool)
119
+ onTool?.(tool);
115
120
  continue;
116
121
  }
117
- const delta = extractDelta(parsed);
122
+ const delta = extractDelta(ev.data);
118
123
  if (!delta)
119
124
  continue;
120
125
  reply += delta;
121
126
  onChunk?.(delta);
122
127
  }
123
128
  }
129
+ if (buf.trim()) {
130
+ const ev = parseSseFrame(buf);
131
+ if (ev?.event === "hermes.tool.progress") {
132
+ const tool = asToolProgress(ev.data);
133
+ if (tool)
134
+ onTool?.(tool);
135
+ }
136
+ else if (ev) {
137
+ const delta = extractDelta(ev.data);
138
+ if (delta) {
139
+ reply += delta;
140
+ onChunk?.(delta);
141
+ }
142
+ }
143
+ }
124
144
  }
125
145
  catch (err) {
126
146
  const msg = err.message || "";
@@ -135,6 +155,44 @@ export function hermesHttpBrain(cfg) {
135
155
  return reply.trim();
136
156
  };
137
157
  }
158
+ function parseSseFrame(frame) {
159
+ let event = "message";
160
+ const dataLines = [];
161
+ for (const raw of frame.split("\n")) {
162
+ const line = raw.replace(/\r$/, "");
163
+ if (!line || line.startsWith(":"))
164
+ continue;
165
+ if (line.startsWith("event:"))
166
+ event = line.slice(6).trim();
167
+ else if (line.startsWith("data:"))
168
+ dataLines.push(line.slice(5).trimStart());
169
+ }
170
+ if (dataLines.length === 0)
171
+ return null;
172
+ const payload = dataLines.join("\n");
173
+ if (!payload || payload === "[DONE]")
174
+ return null;
175
+ try {
176
+ return { event, data: JSON.parse(payload) };
177
+ }
178
+ catch {
179
+ return { event, data: payload };
180
+ }
181
+ }
182
+ function asToolProgress(data) {
183
+ if (!data || typeof data !== "object")
184
+ return null;
185
+ const obj = data;
186
+ const tool = typeof obj.tool === "string" ? obj.tool : typeof obj.name === "string" ? obj.name : "";
187
+ if (!tool)
188
+ return null;
189
+ return {
190
+ tool,
191
+ emoji: typeof obj.emoji === "string" ? obj.emoji : undefined,
192
+ label: typeof obj.label === "string" ? obj.label : undefined,
193
+ status: typeof obj.status === "string" ? obj.status : "running",
194
+ };
195
+ }
138
196
  function extractDelta(parsed) {
139
197
  if (!parsed || typeof parsed !== "object")
140
198
  return "";
@@ -143,8 +201,6 @@ function extractDelta(parsed) {
143
201
  const fromChoice = choice?.delta?.content ?? choice?.message?.content;
144
202
  if (typeof fromChoice === "string")
145
203
  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
204
  if (typeof obj.data?.content === "string")
149
205
  return obj.data.content;
150
206
  if (typeof obj.data?.text === "string")
@@ -0,0 +1,29 @@
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
+ export type ProgressSender = (text: string) => Promise<void>;
10
+ /**
11
+ * Collect tool lines and flush them as short chat messages.
12
+ * Flush when we have `maxLines` tools, or after `gapMs` of quiet.
13
+ */
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;
21
+ 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;
27
+ }
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.";
@@ -0,0 +1,105 @@
1
+ // Telegram-style tool progress for NoPeek bots.
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.
7
+ const FALLBACK_EMOJI = {
8
+ read_file: "📖",
9
+ write_file: "✍️",
10
+ patch: "🔧",
11
+ search_files: "🔎",
12
+ terminal: "💻",
13
+ web_search: "🔍",
14
+ web_extract: "📄",
15
+ web_crawl: "🕸️",
16
+ memory: "🧠",
17
+ skill_view: "📘",
18
+ skill_manage: "🧩",
19
+ skills_list: "📚",
20
+ todo: "✅",
21
+ execute_code: "🐍",
22
+ delegate_task: "👥",
23
+ cronjob: "⏰",
24
+ process: "⚙️",
25
+ };
26
+ /** One Telegram-style line. Running events only; completed is silent. */
27
+ export function formatToolLine(ev) {
28
+ if (ev.status && ev.status !== "running")
29
+ return null;
30
+ const tool = (ev.tool || "").trim();
31
+ if (!tool || tool.startsWith("_"))
32
+ return null;
33
+ const emoji = (ev.emoji || FALLBACK_EMOJI[tool] || "⚡").trim() || "⚡";
34
+ const label = tidyLabel(ev.label);
35
+ if (label)
36
+ return `${emoji} ${tool}: "${label}"`;
37
+ return `${emoji} ${tool}...`;
38
+ }
39
+ function tidyLabel(raw) {
40
+ if (!raw)
41
+ return "";
42
+ const s = raw.replace(/\s+/g, " ").trim();
43
+ if (!s)
44
+ return "";
45
+ return s.length > 80 ? `${s.slice(0, 77)}...` : s;
46
+ }
47
+ /**
48
+ * Collect tool lines and flush them as short chat messages.
49
+ * Flush when we have `maxLines` tools, or after `gapMs` of quiet.
50
+ */
51
+ export class ToolProgressFlusher {
52
+ send;
53
+ gapMs;
54
+ maxLines;
55
+ lines = [];
56
+ timer = null;
57
+ chain = Promise.resolve();
58
+ seen = new Set();
59
+ constructor(send, gapMs = 2200, maxLines = 4) {
60
+ this.send = send;
61
+ this.gapMs = gapMs;
62
+ this.maxLines = maxLines;
63
+ }
64
+ get pending() {
65
+ return this.lines.length;
66
+ }
67
+ push(ev) {
68
+ const line = formatToolLine(ev);
69
+ if (!line)
70
+ return;
71
+ if (this.lines[this.lines.length - 1] === line)
72
+ return;
73
+ if (this.seen.has(line) && this.lines.includes(line))
74
+ return;
75
+ this.seen.add(line);
76
+ this.lines.push(line);
77
+ if (this.lines.length >= this.maxLines) {
78
+ void this.flush();
79
+ return;
80
+ }
81
+ this.arm();
82
+ }
83
+ async flush() {
84
+ if (this.timer) {
85
+ clearTimeout(this.timer);
86
+ this.timer = null;
87
+ }
88
+ if (this.lines.length === 0)
89
+ 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;
94
+ }
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);
102
+ }
103
+ }
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.10",
3
+ "version": "0.7.11",
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,8 +22,15 @@
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
+ },
25
32
  "dependencies": {
26
- "@nopeek/chat": "^0.2.4"
33
+ "@nopeek/chat": "workspace:^0.2.4"
27
34
  },
28
35
  "devDependencies": {
29
36
  "@types/node": "^22.10.0",
@@ -40,11 +47,5 @@
40
47
  ],
41
48
  "publishConfig": {
42
49
  "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
50
  }
50
- }
51
+ }