@nopeek/agent-bridge 0.7.20 → 0.7.22
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 +4 -0
- package/dist/bot.d.ts +9 -0
- package/dist/bot.js +77 -2
- package/dist/bridge.d.ts +1 -1
- package/dist/bridge.js +1 -1
- package/dist/inbound-files.js +1 -1
- package/dist/mid-turn.d.ts +15 -0
- package/dist/mid-turn.js +21 -0
- package/package.json +1 -1
package/dist/backends.js
CHANGED
|
@@ -620,6 +620,10 @@ export function hermesBrain(cfg) {
|
|
|
620
620
|
const reply = await http(text, ctx, hooks);
|
|
621
621
|
if (reply)
|
|
622
622
|
return reply;
|
|
623
|
+
// A mid-turn abort is intentional (user follow-up). Do not spawn a
|
|
624
|
+
// second Hermes CLI job — that is how empty "…" bubbles stacked up.
|
|
625
|
+
if (ctx.signal?.aborted)
|
|
626
|
+
return "";
|
|
623
627
|
console.error(`[brain:hermes:@${ctx.botHandle.replace(/^@/, "")}] API returned empty — falling back to CLI`);
|
|
624
628
|
}
|
|
625
629
|
return cli(text, ctx, hooks);
|
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, 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",
|
|
@@ -378,6 +387,10 @@ export class BotRunner {
|
|
|
378
387
|
np.on("member.joined", onRosterChange);
|
|
379
388
|
np.on("member.left", onRosterChange);
|
|
380
389
|
np.on("message", ((m) => {
|
|
390
|
+
// Own stream() placeholders echo back as message.new. Never interrupt
|
|
391
|
+
// or answer those — that loop is what left "…" edited bubbles.
|
|
392
|
+
if (isBotEcho(m, this.info.userId))
|
|
393
|
+
return;
|
|
381
394
|
const live = this.turns.get(m.channelId);
|
|
382
395
|
if (live?.active) {
|
|
383
396
|
live.interrupt({ id: m.messageId, message: m });
|
|
@@ -397,6 +410,68 @@ export class BotRunner {
|
|
|
397
410
|
}));
|
|
398
411
|
await this.refreshAccess(); // load the allow list before we answer anyone
|
|
399
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;
|
|
400
475
|
}
|
|
401
476
|
/** Runtime sessions expire; reconnect with a fresh one shortly before that. */
|
|
402
477
|
scheduleRefresh(expiresAt) {
|
|
@@ -731,7 +806,7 @@ export class BotRunner {
|
|
|
731
806
|
};
|
|
732
807
|
}
|
|
733
808
|
async ingestFollowup(m) {
|
|
734
|
-
if (m
|
|
809
|
+
if (isBotEcho(m, this.info.userId))
|
|
735
810
|
return null;
|
|
736
811
|
if (this.cantPost.has(m.channelId))
|
|
737
812
|
return null;
|
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.22";
|
|
23
23
|
function hermesApiStatus(cfg) {
|
|
24
24
|
const api = lastHermesApiHealth();
|
|
25
25
|
return {
|
package/dist/inbound-files.js
CHANGED
|
@@ -56,7 +56,7 @@ export function hasInboundWork(body) {
|
|
|
56
56
|
const b = body;
|
|
57
57
|
if (isControlType(b.type))
|
|
58
58
|
return false;
|
|
59
|
-
if (typeof b.text === "string" && b.text.trim())
|
|
59
|
+
if (typeof b.text === "string" && b.text.trim() && b.text.trim() !== "…" && b.text.trim() !== "...")
|
|
60
60
|
return true;
|
|
61
61
|
if (parseAttachments(body).length > 0)
|
|
62
62
|
return true;
|
package/dist/mid-turn.d.ts
CHANGED
|
@@ -4,6 +4,21 @@ export interface FollowupDecision {
|
|
|
4
4
|
text: string;
|
|
5
5
|
}
|
|
6
6
|
export declare function isStopRequest(text: string): boolean;
|
|
7
|
+
/** Bare stream() placeholder. Must never count as a user follow-up. */
|
|
8
|
+
export declare function isPlaceholderText(text: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Own stream() posts come back as message.new on the same socket. If we treat
|
|
11
|
+
* those as mid-turn interrupts, the bot aborts itself on every "…" bubble,
|
|
12
|
+
* Hermes HTTP returns empty, CLI fallback starts a second job, and the chat
|
|
13
|
+
* fills with edited dots. Telegram never sees the bot's own outbound messages.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isBotEcho(m: {
|
|
16
|
+
senderUserId?: string;
|
|
17
|
+
body?: {
|
|
18
|
+
text?: unknown;
|
|
19
|
+
streaming?: unknown;
|
|
20
|
+
} | null;
|
|
21
|
+
}, botUserId: string): boolean;
|
|
7
22
|
export declare function coalesceFollowups(texts: string[]): FollowupDecision;
|
|
8
23
|
export declare function wrapInterruptedFollowup(text: string): string;
|
|
9
24
|
export declare function hermesSessionId(channelId: string): string;
|
package/dist/mid-turn.js
CHANGED
|
@@ -11,6 +11,27 @@ const STOP_RE = /^(?:\/)?(?:please\s+)?stop(?:\s+please)?[.!]?$/i;
|
|
|
11
11
|
export function isStopRequest(text) {
|
|
12
12
|
return STOP_RE.test((text || "").trim());
|
|
13
13
|
}
|
|
14
|
+
/** Bare stream() placeholder. Must never count as a user follow-up. */
|
|
15
|
+
export function isPlaceholderText(text) {
|
|
16
|
+
const t = (text || "").trim();
|
|
17
|
+
return t === "…" || t === "...";
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Own stream() posts come back as message.new on the same socket. If we treat
|
|
21
|
+
* those as mid-turn interrupts, the bot aborts itself on every "…" bubble,
|
|
22
|
+
* Hermes HTTP returns empty, CLI fallback starts a second job, and the chat
|
|
23
|
+
* fills with edited dots. Telegram never sees the bot's own outbound messages.
|
|
24
|
+
*/
|
|
25
|
+
export function isBotEcho(m, botUserId) {
|
|
26
|
+
if (botUserId && m.senderUserId === botUserId)
|
|
27
|
+
return true;
|
|
28
|
+
const body = m.body;
|
|
29
|
+
if (body && body.streaming === true)
|
|
30
|
+
return true;
|
|
31
|
+
if (typeof body?.text === "string" && isPlaceholderText(body.text))
|
|
32
|
+
return true;
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
14
35
|
export function coalesceFollowups(texts) {
|
|
15
36
|
const cleaned = texts.map((t) => (t || "").trim()).filter(Boolean);
|
|
16
37
|
if (cleaned.length === 0)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nopeek/agent-bridge",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.22",
|
|
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",
|