@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 +6 -1
- package/dist/backends.js +16 -7
- package/dist/bot.js +53 -38
- package/dist/brain.d.ts +18 -1
- package/dist/brain.js +11 -1
- package/dist/bridge.d.ts +1 -1
- package/dist/bridge.js +1 -1
- package/dist/hermes-http.d.ts +1 -1
- package/dist/hermes-http.js +101 -20
- package/dist/inbound-files.d.ts +36 -0
- package/dist/inbound-files.js +241 -0
- package/dist/tool-progress.d.ts +66 -0
- package/dist/tool-progress.js +293 -0
- package/package.json +3 -2
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,
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
549
|
-
//
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
608
|
-
if (
|
|
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} ->
|
|
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
|
|
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,
|
|
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
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.
|
|
22
|
+
export const VERSION = "0.7.14";
|
|
23
23
|
function hermesApiStatus(cfg) {
|
|
24
24
|
const api = lastHermesApiHealth();
|
|
25
25
|
return {
|
package/dist/hermes-http.d.ts
CHANGED
package/dist/hermes-http.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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: [
|
|
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
|
|
102
|
-
buf =
|
|
103
|
-
for (const
|
|
104
|
-
const
|
|
105
|
-
if (!
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|