@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 +6 -4
- package/dist/bot.js +46 -42
- package/dist/brain.d.ts +10 -0
- package/dist/brain.js +2 -0
- package/dist/bridge.d.ts +1 -1
- package/dist/bridge.js +1 -1
- package/dist/cli.js +0 -0
- package/dist/hermes-http.js +26 -1
- package/dist/inbound-files.d.ts +36 -0
- package/dist/inbound-files.js +241 -0
- package/dist/tool-progress.d.ts +54 -17
- package/dist/tool-progress.js +232 -44
- package/package.json +10 -10
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
|
|
45
|
-
|
|
46
|
-
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
550
|
-
//
|
|
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
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
618
|
-
|
|
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} ->
|
|
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
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/cli.js
CHANGED
|
File without changes
|
package/dist/hermes-http.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/tool-progress.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
|
15
|
-
private readonly
|
|
16
|
-
private readonly
|
|
17
|
-
private
|
|
18
|
-
private
|
|
19
|
-
private
|
|
20
|
-
private
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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.";
|
package/dist/tool-progress.js
CHANGED
|
@@ -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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
*
|
|
49
|
-
*
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
this.
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
123
|
+
onTool(ev) {
|
|
68
124
|
const line = formatToolLine(ev);
|
|
69
125
|
if (!line)
|
|
70
126
|
return;
|
|
71
|
-
if (this.
|
|
127
|
+
if (this.progressLines[this.progressLines.length - 1] === line)
|
|
72
128
|
return;
|
|
73
|
-
if (this.seen.has(line) && this.
|
|
129
|
+
if (this.seen.has(line) && this.progressLines.includes(line))
|
|
74
130
|
return;
|
|
75
131
|
this.seen.add(line);
|
|
76
|
-
this.
|
|
77
|
-
if (this.
|
|
78
|
-
|
|
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
|
-
|
|
250
|
+
if (!pending.trim())
|
|
251
|
+
return;
|
|
252
|
+
this.opts.stopTyping();
|
|
253
|
+
await this.sink.send(pending);
|
|
254
|
+
this.published += pending;
|
|
82
255
|
}
|
|
83
|
-
async
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
this.
|
|
256
|
+
async addProgressLine(line) {
|
|
257
|
+
this.opts.stopTyping();
|
|
258
|
+
if (this.progressLines.length >= this.maxProgressLines && this.progressP) {
|
|
259
|
+
await this.closeProgress();
|
|
87
260
|
}
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
this.
|
|
92
|
-
this.chain = this.chain.then(() => this.send(text).catch(() => { }));
|
|
93
|
-
await this.chain;
|
|
276
|
+
}
|
|
277
|
+
this.progress?.replace(body);
|
|
94
278
|
}
|
|
95
|
-
|
|
96
|
-
if (this.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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.
|
|
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": "
|
|
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
|
+
}
|