@nopeek/agent-bridge 0.7.21 → 0.7.23
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/dist/backends.js +24 -7
- package/dist/bot.d.ts +9 -0
- package/dist/bot.js +83 -2
- package/dist/bridge.d.ts +1 -1
- package/dist/bridge.js +1 -1
- package/dist/tool-progress.d.ts +10 -0
- package/dist/tool-progress.js +58 -1
- package/package.json +1 -1
package/dist/backends.js
CHANGED
|
@@ -18,6 +18,7 @@ import { homedir } from "node:os";
|
|
|
18
18
|
import { basename, join } from "node:path";
|
|
19
19
|
import { asHooks, stripAnsi } from "./brain.js";
|
|
20
20
|
import { hermesHttpBrain, probeHermesApi } from "./hermes-http.js";
|
|
21
|
+
import { parseHermesActivityLine } from "./tool-progress.js";
|
|
21
22
|
const BRAIN_UNREACHABLE = "⚠️ I couldn't reach my brain just now — please try again in a moment.";
|
|
22
23
|
// ------------------------------------------------------------------ souls ----
|
|
23
24
|
/** Built-in personality template — personalized per bot by provisionSoul(). */
|
|
@@ -347,7 +348,15 @@ export function provisionHermesProfile(handle) {
|
|
|
347
348
|
}
|
|
348
349
|
// Chat chrome Hermes prints even in -Q mode: ruler lines, the echoed prompt
|
|
349
350
|
// bubble (● …), the goodbye line, and session-info footers.
|
|
350
|
-
const HERMES_CHROME = [
|
|
351
|
+
const HERMES_CHROME = [
|
|
352
|
+
/^[─━—-]{4,}\s*─*$/,
|
|
353
|
+
/^●/,
|
|
354
|
+
/^Goodbye!/,
|
|
355
|
+
/^session(_| )?id:/i,
|
|
356
|
+
/^↻/,
|
|
357
|
+
/^[╭╰┌┐└┘]/,
|
|
358
|
+
/^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/,
|
|
359
|
+
];
|
|
351
360
|
function isHermesChrome(line) {
|
|
352
361
|
return HERMES_CHROME.some((re) => re.test(line));
|
|
353
362
|
}
|
|
@@ -393,15 +402,17 @@ function detectHermesProfileMode(bin) {
|
|
|
393
402
|
return hermesProfileMode;
|
|
394
403
|
}
|
|
395
404
|
function hermesCliBrain(cfg) {
|
|
396
|
-
const runOnce = (profile, text, sessionName, tag, onChunk) => new Promise((resolvePromise) => {
|
|
405
|
+
const runOnce = (profile, text, sessionName, tag, onChunk, onTool) => new Promise((resolvePromise) => {
|
|
397
406
|
const bin = resolveBin("hermes", "HERMES_BIN");
|
|
398
407
|
const mode = detectHermesProfileMode(bin);
|
|
399
408
|
// v0.14+ takes the profile NAME via env; legacy took the PATH via flag.
|
|
400
409
|
const profileName = basename(profile);
|
|
401
|
-
|
|
410
|
+
// Do NOT pass -Q. Quiet mode hides tool previews, so NoPeek looks dead
|
|
411
|
+
// while Hermes works. Telegram publishes those lines; we do the same.
|
|
412
|
+
const args = mode === "flag" ? ["--profile", profile, "chat"] : ["chat"];
|
|
402
413
|
if (sessionName)
|
|
403
414
|
args.push("--continue", sessionName);
|
|
404
|
-
args.push("-q", text);
|
|
415
|
+
args.push("--accept-hooks", "--source", "nopeek", "-q", text);
|
|
405
416
|
const child = spawn(bin, args, {
|
|
406
417
|
stdio: ["ignore", "pipe", "pipe"],
|
|
407
418
|
// Hermes keys everything off $HOME; point it at the Hermes install.
|
|
@@ -452,6 +463,12 @@ function hermesCliBrain(cfg) {
|
|
|
452
463
|
}
|
|
453
464
|
if (noSession)
|
|
454
465
|
return; // swallow the "Use 'hermes sessions list'…" tail too
|
|
466
|
+
const activity = parseHermesActivityLine(line);
|
|
467
|
+
if (activity) {
|
|
468
|
+
sawContent = true;
|
|
469
|
+
onTool?.(activity);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
455
472
|
sawContent = true;
|
|
456
473
|
out += `${line}\n`;
|
|
457
474
|
onChunk?.(`${line}\n`);
|
|
@@ -535,7 +552,7 @@ function hermesCliBrain(cfg) {
|
|
|
535
552
|
});
|
|
536
553
|
});
|
|
537
554
|
return async (text, ctx, hooks) => {
|
|
538
|
-
const { onChunk } = asHooks(hooks);
|
|
555
|
+
const { onChunk, onTool } = asHooks(hooks);
|
|
539
556
|
const handle = ctx.botHandle.replace(/^@/, "");
|
|
540
557
|
const tag = `[brain:hermes:@${handle}]`;
|
|
541
558
|
if (!resolveBin("hermes", "HERMES_BIN")) {
|
|
@@ -551,12 +568,12 @@ function hermesCliBrain(cfg) {
|
|
|
551
568
|
return BRAIN_UNREACHABLE;
|
|
552
569
|
}
|
|
553
570
|
const sessionName = `nopeek-${ctx.channelId}`;
|
|
554
|
-
let run = await runOnce(profile, text, sessionName, tag, onChunk);
|
|
571
|
+
let run = await runOnce(profile, text, sessionName, tag, onChunk, onTool);
|
|
555
572
|
if (run.noSession && !run.reply) {
|
|
556
573
|
// First message in this channel: the named session doesn't exist yet.
|
|
557
574
|
// Start fresh, then name the new session so the NEXT message continues it.
|
|
558
575
|
console.log(`${tag} no session ${sessionName} yet — starting fresh`);
|
|
559
|
-
run = await runOnce(profile, text, null, tag, onChunk);
|
|
576
|
+
run = await runOnce(profile, text, null, tag, onChunk, onTool);
|
|
560
577
|
if (run.sessionId) {
|
|
561
578
|
const bin = resolveBin("hermes", "HERMES_BIN");
|
|
562
579
|
const mode = detectHermesProfileMode(bin);
|
package/dist/bot.d.ts
CHANGED
|
@@ -64,6 +64,8 @@ export declare class BotRunner {
|
|
|
64
64
|
/** Fire-and-forget: runs the connect loop in the background, isolated. */
|
|
65
65
|
start(): void;
|
|
66
66
|
stop(): void;
|
|
67
|
+
/** Crash/restart must not leave Hermes running against a dead socket. */
|
|
68
|
+
private abortLiveTurns;
|
|
67
69
|
private mintSession;
|
|
68
70
|
/** Fetch (or refresh) the set of users allowed to talk to this bot. Called on
|
|
69
71
|
* connect and whenever a grant changes. Best-effort: on failure we keep the
|
|
@@ -96,6 +98,13 @@ export declare class BotRunner {
|
|
|
96
98
|
private ownerIsChannelMember;
|
|
97
99
|
private run;
|
|
98
100
|
private connectOnce;
|
|
101
|
+
/**
|
|
102
|
+
* A bridge restart mid-stream leaves `{streaming:true}` bubbles with a live
|
|
103
|
+
* caret forever. On connect, finalize our own orphan streams and tell the
|
|
104
|
+
* human we got cut off — Telegram never sits on a typing caret for hours.
|
|
105
|
+
*/
|
|
106
|
+
private settleAllOrphans;
|
|
107
|
+
private settleOrphanStreams;
|
|
99
108
|
/** Runtime sessions expire; reconnect with a fresh one shortly before that. */
|
|
100
109
|
private scheduleRefresh;
|
|
101
110
|
private reconnect;
|
package/dist/bot.js
CHANGED
|
@@ -8,7 +8,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
10
|
import { TurnPublisher } from "./tool-progress.js";
|
|
11
|
-
import { ChannelTurn, coalesceFollowups, isBotEcho, isStopRequest, wrapInterruptedFollowup, } from "./mid-turn.js";
|
|
11
|
+
import { ChannelTurn, coalesceFollowups, isBotEcho, isPlaceholderText, isStopRequest, wrapInterruptedFollowup, } from "./mid-turn.js";
|
|
12
12
|
import { buildInboundPrompt, describeStructured, hasInboundWork, isControlType, parseAttachments, saveInboundFiles, } from "./inbound-files.js";
|
|
13
13
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
14
14
|
const MAX_BACKOFF_MS = 60_000;
|
|
@@ -116,6 +116,7 @@ export class BotRunner {
|
|
|
116
116
|
if (this.stopped)
|
|
117
117
|
return; // truly stopped (unpair) — never resurrect
|
|
118
118
|
this.lastForcedRestart = Date.now();
|
|
119
|
+
this.abortLiveTurns();
|
|
119
120
|
if (this.refreshTimer)
|
|
120
121
|
clearTimeout(this.refreshTimer);
|
|
121
122
|
this.refreshTimer = null;
|
|
@@ -143,6 +144,7 @@ export class BotRunner {
|
|
|
143
144
|
}
|
|
144
145
|
stop() {
|
|
145
146
|
this.stopped = true;
|
|
147
|
+
this.abortLiveTurns();
|
|
146
148
|
if (this.refreshTimer)
|
|
147
149
|
clearTimeout(this.refreshTimer);
|
|
148
150
|
try {
|
|
@@ -154,6 +156,13 @@ export class BotRunner {
|
|
|
154
156
|
this.np = null;
|
|
155
157
|
this.connected = false;
|
|
156
158
|
}
|
|
159
|
+
/** Crash/restart must not leave Hermes running against a dead socket. */
|
|
160
|
+
abortLiveTurns() {
|
|
161
|
+
for (const turn of this.turns.values()) {
|
|
162
|
+
if (turn.active)
|
|
163
|
+
turn.abort.abort();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
157
166
|
async mintSession() {
|
|
158
167
|
const res = await fetch(`${this.pairing.apiUrl}/v1/apps/${this.pairing.appId}/bots/${this.info.userId}/runtime-session`, {
|
|
159
168
|
method: "POST",
|
|
@@ -401,6 +410,68 @@ export class BotRunner {
|
|
|
401
410
|
}));
|
|
402
411
|
await this.refreshAccess(); // load the allow list before we answer anyone
|
|
403
412
|
this.scheduleRefresh(session.expiresAt);
|
|
413
|
+
void this.settleAllOrphans();
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* A bridge restart mid-stream leaves `{streaming:true}` bubbles with a live
|
|
417
|
+
* caret forever. On connect, finalize our own orphan streams and tell the
|
|
418
|
+
* human we got cut off — Telegram never sits on a typing caret for hours.
|
|
419
|
+
*/
|
|
420
|
+
async settleAllOrphans() {
|
|
421
|
+
if (!this.np || this.stopped)
|
|
422
|
+
return;
|
|
423
|
+
let channels;
|
|
424
|
+
try {
|
|
425
|
+
channels = await this.np.channels.list();
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
this.logErr(`orphan scan list failed: ${err.message}`);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
for (const ch of channels) {
|
|
432
|
+
if (this.stopped)
|
|
433
|
+
return;
|
|
434
|
+
if (this.turns.get(ch.channelId)?.active)
|
|
435
|
+
continue;
|
|
436
|
+
try {
|
|
437
|
+
const n = await this.settleOrphanStreams(ch);
|
|
438
|
+
if (n > 0) {
|
|
439
|
+
this.log(`${ch.channelId} finalized ${n} orphan stream(s)`);
|
|
440
|
+
await ch.send({
|
|
441
|
+
text: "I got cut off mid-turn. Send another message and I'll pick up.",
|
|
442
|
+
}).catch((err) => this.notePostFailure(ch.channelId, err));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
this.logErr(`orphan settle ${ch.channelId}: ${err.message}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
async settleOrphanStreams(ch) {
|
|
451
|
+
try {
|
|
452
|
+
await ch.ensureKey();
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
return 0;
|
|
456
|
+
}
|
|
457
|
+
const recent = await ch.history({ limit: 40 });
|
|
458
|
+
let n = 0;
|
|
459
|
+
for (const m of recent) {
|
|
460
|
+
if (m.senderUserId !== this.info.userId)
|
|
461
|
+
continue;
|
|
462
|
+
const body = m.body;
|
|
463
|
+
if (!body || body.streaming !== true)
|
|
464
|
+
continue;
|
|
465
|
+
const text = typeof body.text === "string" ? body.text : "";
|
|
466
|
+
if (!text.trim() || isPlaceholderText(text)) {
|
|
467
|
+
await ch.recall(m.messageId).catch(() => { });
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
await ch.edit(m.messageId, { text, streaming: false }).catch(() => { });
|
|
471
|
+
}
|
|
472
|
+
n += 1;
|
|
473
|
+
}
|
|
474
|
+
return n;
|
|
404
475
|
}
|
|
405
476
|
/** Runtime sessions expire; reconnect with a fresh one shortly before that. */
|
|
406
477
|
scheduleRefresh(expiresAt) {
|
|
@@ -624,7 +695,16 @@ export class BotRunner {
|
|
|
624
695
|
replace: (full) => s.replace(full),
|
|
625
696
|
done: (final) => s.done(final),
|
|
626
697
|
fail: (text) => s.fail(text),
|
|
627
|
-
cancel: () =>
|
|
698
|
+
cancel: async () => {
|
|
699
|
+
// Do not recall() — that leaves "This message was deleted" / Message recalled.
|
|
700
|
+
try {
|
|
701
|
+
s.replace("…");
|
|
702
|
+
await s.done("…");
|
|
703
|
+
}
|
|
704
|
+
catch {
|
|
705
|
+
/* best-effort drop */
|
|
706
|
+
}
|
|
707
|
+
},
|
|
628
708
|
};
|
|
629
709
|
},
|
|
630
710
|
send: async (body) => {
|
|
@@ -637,6 +717,7 @@ export class BotRunner {
|
|
|
637
717
|
this.logErr(`stream open failed (falling back to a single send): ${err.message}`);
|
|
638
718
|
},
|
|
639
719
|
});
|
|
720
|
+
published.markStarted();
|
|
640
721
|
const live = new ChannelTurn();
|
|
641
722
|
this.turns.set(channelId, live);
|
|
642
723
|
const prompt = interrupted ? wrapInterruptedFollowup(text) : text;
|
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.23";
|
|
23
23
|
function hermesApiStatus(cfg) {
|
|
24
24
|
const api = lastHermesApiHealth();
|
|
25
25
|
return {
|
package/dist/tool-progress.d.ts
CHANGED
|
@@ -4,6 +4,13 @@ export type ToolProgressEvent = {
|
|
|
4
4
|
label?: string;
|
|
5
5
|
status?: string;
|
|
6
6
|
};
|
|
7
|
+
/** Shown the moment a turn starts so the chat is never silent like Telegram. */
|
|
8
|
+
export declare const WORKING_LINE = "Working\u2026";
|
|
9
|
+
/**
|
|
10
|
+
* Hermes CLI activity (`┊ 📋 plan 3 task(s)`). Used so the CLI brain can
|
|
11
|
+
* publish the same Telegram-style progress as the HTTP SSE path.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseHermesActivityLine(raw: string): ToolProgressEvent | null;
|
|
7
14
|
/** One Telegram-style line. Running events only; completed is silent. */
|
|
8
15
|
export declare function formatToolLine(ev: ToolProgressEvent): string | null;
|
|
9
16
|
/** Hint so Hermes ends a tool-using turn with a recap, like Telegram. */
|
|
@@ -66,11 +73,14 @@ export declare class TurnPublisher {
|
|
|
66
73
|
private startTimer;
|
|
67
74
|
private gapTimer;
|
|
68
75
|
private seen;
|
|
76
|
+
private started;
|
|
69
77
|
private readonly startDelayMs;
|
|
70
78
|
private readonly maxProgressLines;
|
|
71
79
|
private readonly maxBubbleChars;
|
|
72
80
|
private readonly bubbleGapMs;
|
|
73
81
|
constructor(sink: TurnSink, opts: TurnPublisherOpts);
|
|
82
|
+
/** Open a live bubble immediately so HTTP and CLI turns never look dead. */
|
|
83
|
+
markStarted(): void;
|
|
74
84
|
onChunk(delta: string): void;
|
|
75
85
|
onTool(ev: ToolProgressEvent): void;
|
|
76
86
|
finish(reply: string): Promise<TurnFinishKind>;
|
package/dist/tool-progress.js
CHANGED
|
@@ -27,6 +27,52 @@ const FALLBACK_EMOJI = {
|
|
|
27
27
|
cronjob: "⏰",
|
|
28
28
|
process: "⚙️",
|
|
29
29
|
};
|
|
30
|
+
/** Shown the moment a turn starts so the chat is never silent like Telegram. */
|
|
31
|
+
export const WORKING_LINE = "Working…";
|
|
32
|
+
const CLI_ACTIVITY = {
|
|
33
|
+
plan: { tool: "todo", emoji: "📋" },
|
|
34
|
+
read: { tool: "read_file", emoji: "📖" },
|
|
35
|
+
write: { tool: "write_file", emoji: "✍️" },
|
|
36
|
+
patch: { tool: "patch", emoji: "🔧" },
|
|
37
|
+
grep: { tool: "search_files", emoji: "🔎" },
|
|
38
|
+
find: { tool: "search_files", emoji: "🔎" },
|
|
39
|
+
$: { tool: "terminal", emoji: "💻" },
|
|
40
|
+
fetch: { tool: "web_extract", emoji: "📄" },
|
|
41
|
+
crawl: { tool: "web_crawl", emoji: "🕸️" },
|
|
42
|
+
memory: { tool: "memory", emoji: "🧠" },
|
|
43
|
+
recall: { tool: "session_search", emoji: "🔍" },
|
|
44
|
+
proc: { tool: "process", emoji: "⚙️" },
|
|
45
|
+
navigate: { tool: "browser_navigate", emoji: "🌐" },
|
|
46
|
+
snapshot: { tool: "browser_snapshot", emoji: "📸" },
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Hermes CLI activity (`┊ 📋 plan 3 task(s)`). Used so the CLI brain can
|
|
50
|
+
* publish the same Telegram-style progress as the HTTP SSE path.
|
|
51
|
+
*/
|
|
52
|
+
export function parseHermesActivityLine(raw) {
|
|
53
|
+
const t = (raw || "").trim();
|
|
54
|
+
if (!t || !/^[┊│|]/.test(t))
|
|
55
|
+
return null;
|
|
56
|
+
const rest = t.replace(/^[┊│|]\s*/, "");
|
|
57
|
+
if (!rest || /^review\s+diff\b/i.test(rest))
|
|
58
|
+
return null;
|
|
59
|
+
const m = rest.match(/^(?:(\S+)\s+)?(\$|[A-Za-z_][\w.]*)\s*(.*)$/);
|
|
60
|
+
if (!m)
|
|
61
|
+
return null;
|
|
62
|
+
const maybeEmoji = m[1] || "";
|
|
63
|
+
const verb = (m[2] || "").replace(/:+$/, "");
|
|
64
|
+
if (!verb)
|
|
65
|
+
return null;
|
|
66
|
+
let label = (m[3] || "").replace(/\s+\d+(?:\.\d+)?s\s*$/, "").trim();
|
|
67
|
+
const mapped = CLI_ACTIVITY[verb];
|
|
68
|
+
const emoji = mapped?.emoji || (/^\p{Extended_Pictographic}/u.test(maybeEmoji) ? maybeEmoji : "⚡");
|
|
69
|
+
return {
|
|
70
|
+
tool: mapped?.tool || verb,
|
|
71
|
+
emoji,
|
|
72
|
+
...(label ? { label } : {}),
|
|
73
|
+
status: "running",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
30
76
|
/** One Telegram-style line. Running events only; completed is silent. */
|
|
31
77
|
export function formatToolLine(ev) {
|
|
32
78
|
if (ev.status && ev.status !== "running")
|
|
@@ -261,6 +307,7 @@ export class TurnPublisher {
|
|
|
261
307
|
startTimer = null;
|
|
262
308
|
gapTimer = null;
|
|
263
309
|
seen = new Set();
|
|
310
|
+
started = false;
|
|
264
311
|
startDelayMs;
|
|
265
312
|
maxProgressLines;
|
|
266
313
|
maxBubbleChars;
|
|
@@ -273,6 +320,13 @@ export class TurnPublisher {
|
|
|
273
320
|
this.maxBubbleChars = opts.maxBubbleChars ?? DEFAULT_MAX_BUBBLE;
|
|
274
321
|
this.bubbleGapMs = opts.bubbleGapMs ?? 2000;
|
|
275
322
|
}
|
|
323
|
+
/** Open a live bubble immediately so HTTP and CLI turns never look dead. */
|
|
324
|
+
markStarted() {
|
|
325
|
+
if (this.started || this.toolsUsed || this.textP || this.progressP)
|
|
326
|
+
return;
|
|
327
|
+
this.started = true;
|
|
328
|
+
this.enqueue(() => this.addProgressLine(WORKING_LINE));
|
|
329
|
+
}
|
|
276
330
|
onChunk(delta) {
|
|
277
331
|
const chunk = stripDumps(delta);
|
|
278
332
|
if (!chunk)
|
|
@@ -317,6 +371,9 @@ export class TurnPublisher {
|
|
|
317
371
|
const pending = this.held;
|
|
318
372
|
this.held = "";
|
|
319
373
|
await this.commitText(pending);
|
|
374
|
+
if (this.progressLines.length === 1 && this.progressLines[0] === WORKING_LINE) {
|
|
375
|
+
this.progressLines = [];
|
|
376
|
+
}
|
|
320
377
|
}
|
|
321
378
|
await this.addProgressLine(line);
|
|
322
379
|
});
|
|
@@ -531,7 +588,7 @@ export class TurnPublisher {
|
|
|
531
588
|
this.progressLines = [];
|
|
532
589
|
if (!s)
|
|
533
590
|
return;
|
|
534
|
-
if (text)
|
|
591
|
+
if (text && text !== WORKING_LINE)
|
|
535
592
|
await s.done(text).catch(() => { });
|
|
536
593
|
else
|
|
537
594
|
await s.cancel().catch(() => { });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nopeek/agent-bridge",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.23",
|
|
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",
|