@nopeek/agent-bridge 0.7.14 → 0.7.15
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/bot.d.ts +4 -0
- package/dist/bot.js +188 -61
- package/dist/brain.d.ts +2 -0
- package/dist/hermes-http.d.ts +2 -1
- package/dist/hermes-http.js +13 -7
- package/dist/mid-turn.d.ts +25 -0
- package/dist/mid-turn.js +67 -0
- package/dist/tool-progress.d.ts +8 -0
- package/dist/tool-progress.js +127 -12
- package/package.json +2 -2
package/dist/bot.d.ts
CHANGED
|
@@ -38,6 +38,7 @@ export declare class BotRunner {
|
|
|
38
38
|
private channelModes;
|
|
39
39
|
private mutedOff;
|
|
40
40
|
private chains;
|
|
41
|
+
private turns;
|
|
41
42
|
private cantPost;
|
|
42
43
|
private log;
|
|
43
44
|
private logErr;
|
|
@@ -102,6 +103,9 @@ export declare class BotRunner {
|
|
|
102
103
|
/** Bounded dedupe so a redelivered frame is never answered twice. */
|
|
103
104
|
private remember;
|
|
104
105
|
private handleMessage;
|
|
106
|
+
private runBrainTurn;
|
|
107
|
+
private collectFollowups;
|
|
108
|
+
private ingestFollowup;
|
|
105
109
|
/** A FORBIDDEN post (broadcast channel, bot not an operator) fails for every
|
|
106
110
|
* future message too — mute the channel so the brain stops running there. */
|
|
107
111
|
private notePostFailure;
|
package/dist/bot.js
CHANGED
|
@@ -8,6 +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
12
|
import { buildInboundPrompt, describeStructured, hasInboundWork, isControlType, parseAttachments, saveInboundFiles, } from "./inbound-files.js";
|
|
12
13
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
13
14
|
const MAX_BACKOFF_MS = 60_000;
|
|
@@ -74,6 +75,9 @@ export class BotRunner {
|
|
|
74
75
|
// order, one at a time — concurrent brain runs against the same agent session
|
|
75
76
|
// (e.g. one Hermes session per channel) deadlock or reply out of order.
|
|
76
77
|
chains = new Map();
|
|
78
|
+
// Live brain turn per channel so a follow-up can abort Hermes immediately
|
|
79
|
+
// instead of waiting for the current 30-minute job to finish.
|
|
80
|
+
turns = new Map();
|
|
77
81
|
// Channels the bot can't post to (e.g. broadcast, non-operator): after the
|
|
78
82
|
// first FORBIDDEN, skip the brain entirely — replies there can never land.
|
|
79
83
|
cantPost = new Set();
|
|
@@ -374,6 +378,12 @@ export class BotRunner {
|
|
|
374
378
|
np.on("member.joined", onRosterChange);
|
|
375
379
|
np.on("member.left", onRosterChange);
|
|
376
380
|
np.on("message", ((m) => {
|
|
381
|
+
const live = this.turns.get(m.channelId);
|
|
382
|
+
if (live?.active) {
|
|
383
|
+
live.interrupt({ id: m.messageId, message: m });
|
|
384
|
+
this.log(`${m.channelId} mid-turn interrupt from ${m.senderUserId}: ${String(m.body && "text" in m.body ? m.body.text : "").slice(0, 80)}`);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
377
387
|
const prev = this.chains.get(m.channelId) ?? Promise.resolve();
|
|
378
388
|
const next = prev.then(() => this.handleMessage(m).catch((err) => {
|
|
379
389
|
this.notePostFailure(m.channelId, err);
|
|
@@ -560,79 +570,196 @@ export class BotRunner {
|
|
|
560
570
|
return;
|
|
561
571
|
this.log(`${m.channelId} <- ${m.senderUserId}: ${text.slice(0, 120)}`);
|
|
562
572
|
ch.markRead(m.messageId).catch(() => { });
|
|
563
|
-
|
|
564
|
-
ch
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
573
|
+
await this.runBrainTurn({
|
|
574
|
+
ch,
|
|
575
|
+
channelId: m.channelId,
|
|
576
|
+
senderUserId: m.senderUserId,
|
|
577
|
+
text,
|
|
578
|
+
files,
|
|
579
|
+
interrupted: false,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
async runBrainTurn(args) {
|
|
583
|
+
const { ch, channelId, senderUserId } = args;
|
|
584
|
+
let text = args.text;
|
|
585
|
+
let files = args.files;
|
|
586
|
+
let interrupted = args.interrupted;
|
|
587
|
+
while (true) {
|
|
588
|
+
if (!interrupted && isStopRequest(text)) {
|
|
589
|
+
this.log(`${channelId} stop with nothing running`);
|
|
590
|
+
try {
|
|
591
|
+
await ch.send({ text: "Nothing is running." });
|
|
592
|
+
}
|
|
593
|
+
catch (err) {
|
|
594
|
+
this.notePostFailure(channelId, err);
|
|
595
|
+
}
|
|
596
|
+
this.handled++;
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
576
599
|
try {
|
|
577
|
-
ch.typing(
|
|
600
|
+
ch.typing(true);
|
|
578
601
|
}
|
|
579
602
|
catch {
|
|
580
|
-
/* best-effort */
|
|
603
|
+
/* typing is best-effort */
|
|
581
604
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
...(files.length ? { files } : {}),
|
|
604
|
-
}, { onChunk: (delta) => published.onChunk(delta), onTool: (ev) => published.onTool(ev) });
|
|
605
|
-
const trimmed = reply.trim();
|
|
606
|
-
const timedOut = /went quiet for over \d+s/i.test(trimmed);
|
|
607
|
-
finishTurn(turn, {
|
|
608
|
-
ok: Boolean(trimmed) && !trimmed.startsWith("⚠️"),
|
|
609
|
-
chars: trimmed.length,
|
|
610
|
-
timedOut,
|
|
611
|
-
error: !trimmed ? "empty reply" : trimmed.startsWith("⚠️") ? trimmed.slice(0, 180) : null,
|
|
605
|
+
const resolved = resolveBrain(this.cfg, this.info.handle);
|
|
606
|
+
this.brainKind = resolved.kind;
|
|
607
|
+
const stopTyping = () => {
|
|
608
|
+
try {
|
|
609
|
+
ch.typing(false);
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
/* best-effort */
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
const published = new TurnPublisher({
|
|
616
|
+
stream: () => ch.stream(),
|
|
617
|
+
send: async (body) => {
|
|
618
|
+
await ch.send({ text: body });
|
|
619
|
+
},
|
|
620
|
+
}, {
|
|
621
|
+
stopTyping,
|
|
622
|
+
onStreamError: (err) => {
|
|
623
|
+
this.notePostFailure(channelId, err);
|
|
624
|
+
this.logErr(`stream open failed (falling back to a single send): ${err.message}`);
|
|
625
|
+
},
|
|
612
626
|
});
|
|
627
|
+
const live = new ChannelTurn();
|
|
628
|
+
this.turns.set(channelId, live);
|
|
629
|
+
const prompt = interrupted ? wrapInterruptedFollowup(text) : text;
|
|
630
|
+
let reply = "";
|
|
631
|
+
const stat = beginTurn(this.info.handle, channelId, resolved.kind);
|
|
632
|
+
try {
|
|
633
|
+
reply = await resolved.brain(prompt, {
|
|
634
|
+
botHandle: this.info.handle,
|
|
635
|
+
botUserId: this.info.userId,
|
|
636
|
+
channelId,
|
|
637
|
+
senderUserId,
|
|
638
|
+
signal: live.abort.signal,
|
|
639
|
+
...(files.length ? { files } : {}),
|
|
640
|
+
}, { onChunk: (delta) => published.onChunk(delta), onTool: (ev) => published.onTool(ev) });
|
|
641
|
+
if (live.abort.signal.aborted) {
|
|
642
|
+
finishTurn(stat, { ok: true, chars: 0, error: "interrupted" });
|
|
643
|
+
await published.finish("").catch(() => { });
|
|
644
|
+
const next = await this.collectFollowups(channelId, live, ch);
|
|
645
|
+
if (!next)
|
|
646
|
+
return;
|
|
647
|
+
text = next.text;
|
|
648
|
+
files = next.files;
|
|
649
|
+
interrupted = true;
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
const trimmed = reply.trim();
|
|
653
|
+
const timedOut = /went quiet for over \d+s/i.test(trimmed);
|
|
654
|
+
finishTurn(stat, {
|
|
655
|
+
ok: Boolean(trimmed) && !trimmed.startsWith("⚠️"),
|
|
656
|
+
chars: trimmed.length,
|
|
657
|
+
timedOut,
|
|
658
|
+
error: !trimmed ? "empty reply" : trimmed.startsWith("⚠️") ? trimmed.slice(0, 180) : null,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
catch (err) {
|
|
662
|
+
finishTurn(stat, { ok: false, error: err.message, timedOut: false });
|
|
663
|
+
await published.fail(FALLBACK_REPLY).catch(() => { });
|
|
664
|
+
throw err;
|
|
665
|
+
}
|
|
666
|
+
finally {
|
|
667
|
+
if (this.turns.get(channelId) === live) {
|
|
668
|
+
live.finish();
|
|
669
|
+
this.turns.delete(channelId);
|
|
670
|
+
}
|
|
671
|
+
try {
|
|
672
|
+
ch.typing(false);
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
/* best-effort */
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const how = await published.finish(reply.trim());
|
|
679
|
+
if (how === "empty") {
|
|
680
|
+
this.log(`brain returned empty reply — ignoring`);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
this.handled++;
|
|
684
|
+
this.log(`${channelId} -> ${how} reply (${reply.trim().length} chars, handled=${this.handled})`);
|
|
685
|
+
return;
|
|
613
686
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
687
|
+
}
|
|
688
|
+
async collectFollowups(channelId, live, ch) {
|
|
689
|
+
await new Promise((r) => setTimeout(r, 180));
|
|
690
|
+
const queued = live.takeIncoming();
|
|
691
|
+
live.finish();
|
|
692
|
+
if (this.turns.get(channelId) === live)
|
|
693
|
+
this.turns.delete(channelId);
|
|
694
|
+
const prepared = [];
|
|
695
|
+
for (const item of queued) {
|
|
696
|
+
const got = await this.ingestFollowup(item.message);
|
|
697
|
+
if (!got)
|
|
698
|
+
continue;
|
|
699
|
+
ch.markRead(item.message.messageId).catch(() => { });
|
|
700
|
+
prepared.push(got);
|
|
620
701
|
}
|
|
621
|
-
|
|
702
|
+
if (!prepared.length) {
|
|
703
|
+
this.log(`${channelId} interrupted with no usable follow-up`);
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
const decision = coalesceFollowups(prepared.map((p) => p.text));
|
|
707
|
+
if (decision.action === "stop") {
|
|
708
|
+
this.log(`${channelId} stopped by user (${prepared.length} follow-up(s))`);
|
|
622
709
|
try {
|
|
623
|
-
ch.
|
|
710
|
+
await ch.send({ text: "Stopped." });
|
|
624
711
|
}
|
|
625
|
-
catch {
|
|
626
|
-
|
|
712
|
+
catch (err) {
|
|
713
|
+
this.notePostFailure(channelId, err);
|
|
627
714
|
}
|
|
715
|
+
this.handled++;
|
|
716
|
+
return null;
|
|
628
717
|
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
718
|
+
this.log(`${channelId} folding ${prepared.length} follow-up(s) into the same session`);
|
|
719
|
+
return {
|
|
720
|
+
text: decision.text,
|
|
721
|
+
files: prepared.flatMap((p) => p.files),
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
async ingestFollowup(m) {
|
|
725
|
+
if (m.senderUserId === this.info.userId)
|
|
726
|
+
return null;
|
|
727
|
+
if (this.cantPost.has(m.channelId))
|
|
728
|
+
return null;
|
|
729
|
+
if (m.decryptionFailed)
|
|
730
|
+
return null;
|
|
731
|
+
if (!this.isAllowed(m.senderUserId))
|
|
732
|
+
return null;
|
|
733
|
+
const body = m.body;
|
|
734
|
+
if (!body || isControlType(body.type) || !hasInboundWork(body))
|
|
735
|
+
return null;
|
|
736
|
+
const attachments = parseAttachments(body);
|
|
737
|
+
let text = typeof body.text === "string" ? body.text : "";
|
|
738
|
+
let files = [];
|
|
739
|
+
const failures = [];
|
|
740
|
+
const previews = [];
|
|
741
|
+
if (attachments.length) {
|
|
742
|
+
try {
|
|
743
|
+
const ch = await this.getChannel(m.channelId);
|
|
744
|
+
const saved = await saveInboundFiles(async (att) => {
|
|
745
|
+
const blob = await ch.fetchAttachment(att);
|
|
746
|
+
return Buffer.from(await blob.arrayBuffer());
|
|
747
|
+
}, attachments, m.messageId);
|
|
748
|
+
files = saved.files;
|
|
749
|
+
failures.push(...saved.failures);
|
|
750
|
+
previews.push(...saved.previews);
|
|
751
|
+
}
|
|
752
|
+
catch (err) {
|
|
753
|
+
this.logErr(`follow-up ingest failed for ${m.messageId}: ${err.message}`);
|
|
754
|
+
}
|
|
633
755
|
}
|
|
634
|
-
|
|
635
|
-
|
|
756
|
+
const structured = describeStructured(body);
|
|
757
|
+
text = buildInboundPrompt({ caption: text, files, structured, failures });
|
|
758
|
+
if (previews.length)
|
|
759
|
+
text = `${previews.join("\n\n")}\n\n${text}`;
|
|
760
|
+
if (!text.trim())
|
|
761
|
+
return null;
|
|
762
|
+
return { text, files };
|
|
636
763
|
}
|
|
637
764
|
/** A FORBIDDEN post (broadcast channel, bot not an operator) fails for every
|
|
638
765
|
* future message too — mute the channel so the brain stops running there. */
|
package/dist/brain.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface BrainContext {
|
|
|
15
15
|
senderUserId: string;
|
|
16
16
|
/** Local paths of photos / video / PDFs / any file the human just sent. */
|
|
17
17
|
files?: BrainFile[];
|
|
18
|
+
/** Abort the in-flight brain when a later message interrupts this turn. */
|
|
19
|
+
signal?: AbortSignal;
|
|
18
20
|
}
|
|
19
21
|
/**
|
|
20
22
|
* A brain answers one message. If it can stream, it calls `onChunk(delta)` as
|
package/dist/hermes-http.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export declare function lastHermesApiHealth(): HermesApiHealth | null;
|
|
|
10
10
|
export declare function probeHermesApi(cfg: BridgeConfig, force?: boolean): Promise<HermesApiHealth>;
|
|
11
11
|
/**
|
|
12
12
|
* Stream one turn through Hermes' OpenAI-compatible chat completions.
|
|
13
|
-
* Session continuity: X-Hermes-Session-Key = nopeek-<channelId>
|
|
13
|
+
* Session continuity: X-Hermes-Session-Id + Key = nopeek-<channelId>
|
|
14
|
+
* so follow-ups share history instead of starting a blank api-* job.
|
|
14
15
|
*/
|
|
15
16
|
export declare function hermesHttpBrain(cfg: BridgeConfig): Brain;
|
package/dist/hermes-http.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { asHooks } from "./brain.js";
|
|
3
|
+
import { hermesSessionHeaders } from "./mid-turn.js";
|
|
3
4
|
import { RECAP_HINT } from "./tool-progress.js";
|
|
4
5
|
/** Skip data-URL vision parts above this so a gallery cannot blow the POST. */
|
|
5
6
|
const MAX_IMAGE_DATA_URL_BYTES = 8 * 1024 * 1024;
|
|
@@ -59,7 +60,8 @@ export async function probeHermesApi(cfg, force = false) {
|
|
|
59
60
|
}
|
|
60
61
|
/**
|
|
61
62
|
* Stream one turn through Hermes' OpenAI-compatible chat completions.
|
|
62
|
-
* Session continuity: X-Hermes-Session-Key = nopeek-<channelId>
|
|
63
|
+
* Session continuity: X-Hermes-Session-Id + Key = nopeek-<channelId>
|
|
64
|
+
* so follow-ups share history instead of starting a blank api-* job.
|
|
63
65
|
*/
|
|
64
66
|
export function hermesHttpBrain(cfg) {
|
|
65
67
|
return async (text, ctx, hooks) => {
|
|
@@ -69,17 +71,19 @@ export function hermesHttpBrain(cfg) {
|
|
|
69
71
|
const url = cfg.hermesApiUrl.replace(/\/+$/, "");
|
|
70
72
|
const headers = {
|
|
71
73
|
"content-type": "application/json",
|
|
74
|
+
...hermesSessionHeaders(cfg.hermesApiKey ?? "", ctx.channelId),
|
|
72
75
|
};
|
|
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) {
|
|
76
|
-
headers.authorization = `Bearer ${cfg.hermesApiKey}`;
|
|
77
|
-
headers["X-Hermes-Session-Key"] = `nopeek-${ctx.channelId}`;
|
|
78
|
-
}
|
|
79
76
|
// Idle abort — same rule as the CLI path. A wall-clock timeout on the
|
|
80
77
|
// whole POST would kill a healthy 20-minute agentic turn. Any SSE byte
|
|
81
78
|
// (token, keepalive, tool-progress) resets the idle timer.
|
|
79
|
+
// ctx.signal aborts immediately when the user sends a mid-turn follow-up.
|
|
82
80
|
const controller = new AbortController();
|
|
81
|
+
if (ctx.signal) {
|
|
82
|
+
if (ctx.signal.aborted)
|
|
83
|
+
controller.abort();
|
|
84
|
+
else
|
|
85
|
+
ctx.signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
86
|
+
}
|
|
83
87
|
let idle;
|
|
84
88
|
const armIdle = () => {
|
|
85
89
|
clearTimeout(idle);
|
|
@@ -170,6 +174,8 @@ export function hermesHttpBrain(cfg) {
|
|
|
170
174
|
catch (err) {
|
|
171
175
|
const msg = err.message || "";
|
|
172
176
|
if (controller.signal.aborted) {
|
|
177
|
+
if (ctx.signal?.aborted)
|
|
178
|
+
return "";
|
|
173
179
|
return `⚠️ My brain went quiet for over ${Math.round(cfg.brainTimeoutMs / 1000)}s with no output and I had to give up on that turn — a long tool-use step or a very slow provider response can trigger this. Try messaging me again.`;
|
|
174
180
|
}
|
|
175
181
|
console.error(`${tag} stream error: ${msg}`);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type FollowupAction = "stop" | "continue";
|
|
2
|
+
export interface FollowupDecision {
|
|
3
|
+
action: FollowupAction;
|
|
4
|
+
text: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function isStopRequest(text: string): boolean;
|
|
7
|
+
export declare function coalesceFollowups(texts: string[]): FollowupDecision;
|
|
8
|
+
export declare function wrapInterruptedFollowup(text: string): string;
|
|
9
|
+
export declare function hermesSessionId(channelId: string): string;
|
|
10
|
+
export declare function hermesSessionHeaders(apiKey: string, channelId: string): Record<string, string>;
|
|
11
|
+
export declare class ChannelTurn<T extends {
|
|
12
|
+
id: string;
|
|
13
|
+
} = {
|
|
14
|
+
id: string;
|
|
15
|
+
text: string;
|
|
16
|
+
}> {
|
|
17
|
+
readonly abort: AbortController;
|
|
18
|
+
active: boolean;
|
|
19
|
+
private incoming;
|
|
20
|
+
private seen;
|
|
21
|
+
interrupt(item: T): void;
|
|
22
|
+
consumed(id: string): boolean;
|
|
23
|
+
takeIncoming(): T[];
|
|
24
|
+
finish(): void;
|
|
25
|
+
}
|
package/dist/mid-turn.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Mid-turn interrupt policy for NoPeek → Hermes.
|
|
2
|
+
//
|
|
3
|
+
// Telegram can /stop a live agent immediately and fold the next message into
|
|
4
|
+
// the same session. The HTTP brain used to serialize the whole turn, then
|
|
5
|
+
// send each follow-up as a brand-new history=0 job — so "Stop" sat for 33
|
|
6
|
+
// minutes and then replied "nothing to stop" three times.
|
|
7
|
+
//
|
|
8
|
+
// This module is the shared policy: detect stop, coalesce a burst, abort the
|
|
9
|
+
// in-flight turn, and keep one stable Hermes session per channel.
|
|
10
|
+
const STOP_RE = /^(?:\/)?(?:please\s+)?stop(?:\s+please)?[.!]?$/i;
|
|
11
|
+
export function isStopRequest(text) {
|
|
12
|
+
return STOP_RE.test((text || "").trim());
|
|
13
|
+
}
|
|
14
|
+
export function coalesceFollowups(texts) {
|
|
15
|
+
const cleaned = texts.map((t) => (t || "").trim()).filter(Boolean);
|
|
16
|
+
if (cleaned.length === 0)
|
|
17
|
+
return { action: "continue", text: "" };
|
|
18
|
+
if (cleaned.every(isStopRequest))
|
|
19
|
+
return { action: "stop", text: cleaned[cleaned.length - 1] };
|
|
20
|
+
const kept = cleaned.filter((t) => !isStopRequest(t));
|
|
21
|
+
return { action: "continue", text: kept.join("\n\n") };
|
|
22
|
+
}
|
|
23
|
+
export function wrapInterruptedFollowup(text) {
|
|
24
|
+
return ("[System note: You were already working on a task. The user interrupted. " +
|
|
25
|
+
"Reassess the previous work together with this new message and continue as " +
|
|
26
|
+
"ONE combined task. Do not start a separate independent job. If the new " +
|
|
27
|
+
"message replaces the old task, switch to it.]\n\n" +
|
|
28
|
+
text);
|
|
29
|
+
}
|
|
30
|
+
export function hermesSessionId(channelId) {
|
|
31
|
+
return `nopeek-${channelId}`;
|
|
32
|
+
}
|
|
33
|
+
export function hermesSessionHeaders(apiKey, channelId) {
|
|
34
|
+
const headers = {};
|
|
35
|
+
if (!apiKey)
|
|
36
|
+
return headers;
|
|
37
|
+
const sid = hermesSessionId(channelId);
|
|
38
|
+
headers.authorization = `Bearer ${apiKey}`;
|
|
39
|
+
headers["X-Hermes-Session-Id"] = sid;
|
|
40
|
+
headers["X-Hermes-Session-Key"] = sid;
|
|
41
|
+
return headers;
|
|
42
|
+
}
|
|
43
|
+
export class ChannelTurn {
|
|
44
|
+
abort = new AbortController();
|
|
45
|
+
active = true;
|
|
46
|
+
incoming = [];
|
|
47
|
+
seen = new Set();
|
|
48
|
+
interrupt(item) {
|
|
49
|
+
if (!this.seen.has(item.id)) {
|
|
50
|
+
this.seen.add(item.id);
|
|
51
|
+
this.incoming.push(item);
|
|
52
|
+
}
|
|
53
|
+
if (!this.abort.signal.aborted)
|
|
54
|
+
this.abort.abort();
|
|
55
|
+
}
|
|
56
|
+
consumed(id) {
|
|
57
|
+
return this.seen.has(id);
|
|
58
|
+
}
|
|
59
|
+
takeIncoming() {
|
|
60
|
+
const items = this.incoming;
|
|
61
|
+
this.incoming = [];
|
|
62
|
+
return items;
|
|
63
|
+
}
|
|
64
|
+
finish() {
|
|
65
|
+
this.active = false;
|
|
66
|
+
}
|
|
67
|
+
}
|
package/dist/tool-progress.d.ts
CHANGED
|
@@ -25,10 +25,16 @@ export type TurnPublisherOpts = {
|
|
|
25
25
|
startDelayMs?: number;
|
|
26
26
|
/** Start a fresh progress bubble after this many lines. */
|
|
27
27
|
maxProgressLines?: number;
|
|
28
|
+
/** Split commentary/recap into a new bubble after this many chars. */
|
|
29
|
+
maxBubbleChars?: number;
|
|
28
30
|
};
|
|
29
31
|
export type TurnFinishKind = "streamed" | "sent" | "empty";
|
|
30
32
|
/** Text in `full` that has not already been posted as commentary. */
|
|
31
33
|
export declare function unpublishedTail(full: string, published: string): string;
|
|
34
|
+
/** Unified diffs / `| review` dumps should never land in the chat. */
|
|
35
|
+
export declare function isToolDump(text: string): boolean;
|
|
36
|
+
/** Split a reply into short chat bubbles. Drops tool-dump paragraphs. */
|
|
37
|
+
export declare function splitBubbles(text: string, max?: number): string[];
|
|
32
38
|
/**
|
|
33
39
|
* One chat turn: live tool progress (edited in place) with commentary
|
|
34
40
|
* messages in between tool batches, then any leftover recap below.
|
|
@@ -50,6 +56,7 @@ export declare class TurnPublisher {
|
|
|
50
56
|
private seen;
|
|
51
57
|
private readonly startDelayMs;
|
|
52
58
|
private readonly maxProgressLines;
|
|
59
|
+
private readonly maxBubbleChars;
|
|
53
60
|
constructor(sink: TurnSink, opts: TurnPublisherOpts);
|
|
54
61
|
onChunk(delta: string): void;
|
|
55
62
|
onTool(ev: ToolProgressEvent): void;
|
|
@@ -61,6 +68,7 @@ export declare class TurnPublisher {
|
|
|
61
68
|
private appendCommentary;
|
|
62
69
|
/** Finalize the current commentary so later tools land below it. */
|
|
63
70
|
private commitText;
|
|
71
|
+
private sendBubbles;
|
|
64
72
|
private addProgressLine;
|
|
65
73
|
private closeProgress;
|
|
66
74
|
}
|
package/dist/tool-progress.js
CHANGED
|
@@ -60,15 +60,96 @@ export function unpublishedTail(full, published) {
|
|
|
60
60
|
return f;
|
|
61
61
|
if (f === p)
|
|
62
62
|
return "";
|
|
63
|
+
const nf = f.replace(/\s+/g, " ");
|
|
64
|
+
const np = p.replace(/\s+/g, " ");
|
|
65
|
+
if (nf === np || np.includes(nf))
|
|
66
|
+
return "";
|
|
63
67
|
if (full.startsWith(published))
|
|
64
68
|
return full.slice(published.length).trim();
|
|
65
69
|
if (f.startsWith(p))
|
|
66
70
|
return f.slice(p.length).trim();
|
|
71
|
+
if (nf.startsWith(np))
|
|
72
|
+
return "";
|
|
67
73
|
const idx = f.indexOf(p);
|
|
68
74
|
if (idx >= 0)
|
|
69
75
|
return (f.slice(0, idx) + f.slice(idx + p.length)).trim();
|
|
70
76
|
return f;
|
|
71
77
|
}
|
|
78
|
+
const DEFAULT_MAX_BUBBLE = 480;
|
|
79
|
+
/** Unified diffs / `| review` dumps should never land in the chat. */
|
|
80
|
+
export function isToolDump(text) {
|
|
81
|
+
const t = text.trim();
|
|
82
|
+
if (!t)
|
|
83
|
+
return false;
|
|
84
|
+
if (/^\|\s*(review|read|search|terminal|patch|write)\b/i.test(t))
|
|
85
|
+
return true;
|
|
86
|
+
if (/^@@\s+-\d+/.test(t))
|
|
87
|
+
return true;
|
|
88
|
+
if (/^\*\*\*\s+(Begin|Update|Add|Delete) Patch/m.test(t))
|
|
89
|
+
return true;
|
|
90
|
+
if (/^diff --git /m.test(t) && /^@@ /m.test(t))
|
|
91
|
+
return true;
|
|
92
|
+
const lines = t.split("\n");
|
|
93
|
+
if (lines.length >= 6) {
|
|
94
|
+
const diffy = lines.filter((l) => /^(@@ |[+-](?![+-])|\| )/.test(l)).length;
|
|
95
|
+
if (diffy / lines.length >= 0.4)
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
/** Split a reply into short chat bubbles. Drops tool-dump paragraphs. */
|
|
101
|
+
export function splitBubbles(text, max = DEFAULT_MAX_BUBBLE) {
|
|
102
|
+
const cleaned = text.replace(/\r\n/g, "\n").trim();
|
|
103
|
+
if (!cleaned)
|
|
104
|
+
return [];
|
|
105
|
+
const paras = cleaned.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const p of paras) {
|
|
108
|
+
if (isToolDump(p))
|
|
109
|
+
continue;
|
|
110
|
+
if (p.length <= max) {
|
|
111
|
+
out.push(p);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
let buf = "";
|
|
115
|
+
for (const line of p.split("\n")) {
|
|
116
|
+
const next = buf ? `${buf}\n${line}` : line;
|
|
117
|
+
if (next.length > max && buf) {
|
|
118
|
+
out.push(buf);
|
|
119
|
+
buf = line.length > max ? "" : line;
|
|
120
|
+
if (line.length > max) {
|
|
121
|
+
for (const piece of hardWrap(line, max))
|
|
122
|
+
out.push(piece);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else if (next.length > max) {
|
|
126
|
+
for (const piece of hardWrap(next, max))
|
|
127
|
+
out.push(piece);
|
|
128
|
+
buf = "";
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
buf = next;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (buf.trim())
|
|
135
|
+
out.push(buf.trim());
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
function hardWrap(s, max) {
|
|
140
|
+
const out = [];
|
|
141
|
+
let rest = s.trim();
|
|
142
|
+
while (rest.length > max) {
|
|
143
|
+
let at = rest.lastIndexOf(" ", max);
|
|
144
|
+
if (at < max * 0.5)
|
|
145
|
+
at = max;
|
|
146
|
+
out.push(rest.slice(0, at).trim());
|
|
147
|
+
rest = rest.slice(at).trim();
|
|
148
|
+
}
|
|
149
|
+
if (rest)
|
|
150
|
+
out.push(rest);
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
72
153
|
/**
|
|
73
154
|
* One chat turn: live tool progress (edited in place) with commentary
|
|
74
155
|
* messages in between tool batches, then any leftover recap below.
|
|
@@ -90,11 +171,13 @@ export class TurnPublisher {
|
|
|
90
171
|
seen = new Set();
|
|
91
172
|
startDelayMs;
|
|
92
173
|
maxProgressLines;
|
|
174
|
+
maxBubbleChars;
|
|
93
175
|
constructor(sink, opts) {
|
|
94
176
|
this.sink = sink;
|
|
95
177
|
this.opts = opts;
|
|
96
178
|
this.startDelayMs = opts.startDelayMs ?? 400;
|
|
97
|
-
this.maxProgressLines = opts.maxProgressLines ??
|
|
179
|
+
this.maxProgressLines = opts.maxProgressLines ?? 8;
|
|
180
|
+
this.maxBubbleChars = opts.maxBubbleChars ?? DEFAULT_MAX_BUBBLE;
|
|
98
181
|
}
|
|
99
182
|
onChunk(delta) {
|
|
100
183
|
if (!delta)
|
|
@@ -134,9 +217,9 @@ export class TurnPublisher {
|
|
|
134
217
|
clearTimeout(this.startTimer);
|
|
135
218
|
this.startTimer = null;
|
|
136
219
|
}
|
|
137
|
-
const pending = this.held;
|
|
138
|
-
this.held = "";
|
|
139
220
|
this.enqueue(async () => {
|
|
221
|
+
const pending = this.held;
|
|
222
|
+
this.held = "";
|
|
140
223
|
await this.commitText(pending);
|
|
141
224
|
await this.addProgressLine(line);
|
|
142
225
|
});
|
|
@@ -150,8 +233,16 @@ export class TurnPublisher {
|
|
|
150
233
|
const s = await this.textP.catch(() => null);
|
|
151
234
|
this.textP = null;
|
|
152
235
|
if (s) {
|
|
153
|
-
|
|
154
|
-
|
|
236
|
+
const parts = splitBubbles(trimmed || this.textBuf, this.maxBubbleChars);
|
|
237
|
+
const first = parts[0] || trimmed;
|
|
238
|
+
await s.done(first || undefined);
|
|
239
|
+
this.published += first || "";
|
|
240
|
+
for (const extra of parts.slice(1)) {
|
|
241
|
+
this.opts.stopTyping();
|
|
242
|
+
await this.sink.send(extra);
|
|
243
|
+
this.published += `\n\n${extra}`;
|
|
244
|
+
}
|
|
245
|
+
return first ? "streamed" : "empty";
|
|
155
246
|
}
|
|
156
247
|
}
|
|
157
248
|
await this.commitText(this.held);
|
|
@@ -159,8 +250,8 @@ export class TurnPublisher {
|
|
|
159
250
|
if (!rest)
|
|
160
251
|
return this.published.trim() ? "streamed" : "empty";
|
|
161
252
|
this.opts.stopTyping();
|
|
162
|
-
await this.
|
|
163
|
-
return "sent";
|
|
253
|
+
const sent = await this.sendBubbles(rest);
|
|
254
|
+
return sent ? "sent" : this.published.trim() ? "streamed" : "empty";
|
|
164
255
|
}
|
|
165
256
|
async fail(text) {
|
|
166
257
|
this.cancelStart();
|
|
@@ -194,8 +285,24 @@ export class TurnPublisher {
|
|
|
194
285
|
async appendCommentary(chunk) {
|
|
195
286
|
if (!chunk)
|
|
196
287
|
return;
|
|
197
|
-
if (this.toolsUsed)
|
|
288
|
+
if (this.toolsUsed) {
|
|
198
289
|
await this.closeProgress();
|
|
290
|
+
this.held += chunk;
|
|
291
|
+
const cut = this.held.lastIndexOf("\n\n");
|
|
292
|
+
if (cut >= 0) {
|
|
293
|
+
const ready = this.held.slice(0, cut);
|
|
294
|
+
this.held = this.held.slice(cut + 2);
|
|
295
|
+
this.opts.stopTyping();
|
|
296
|
+
await this.sendBubbles(ready);
|
|
297
|
+
}
|
|
298
|
+
else if (this.held.length >= this.maxBubbleChars) {
|
|
299
|
+
const ready = this.held;
|
|
300
|
+
this.held = "";
|
|
301
|
+
this.opts.stopTyping();
|
|
302
|
+
await this.sendBubbles(ready);
|
|
303
|
+
}
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
199
306
|
this.opts.stopTyping();
|
|
200
307
|
if (!this.textP) {
|
|
201
308
|
this.textP = this.sink.stream();
|
|
@@ -242,16 +349,24 @@ export class TurnPublisher {
|
|
|
242
349
|
}
|
|
243
350
|
if (final.trim()) {
|
|
244
351
|
this.opts.stopTyping();
|
|
245
|
-
await this.
|
|
246
|
-
this.published += final;
|
|
352
|
+
await this.sendBubbles(final);
|
|
247
353
|
}
|
|
248
354
|
return;
|
|
249
355
|
}
|
|
250
356
|
if (!pending.trim())
|
|
251
357
|
return;
|
|
252
358
|
this.opts.stopTyping();
|
|
253
|
-
await this.
|
|
254
|
-
|
|
359
|
+
await this.sendBubbles(pending);
|
|
360
|
+
}
|
|
361
|
+
async sendBubbles(text) {
|
|
362
|
+
const parts = splitBubbles(text, this.maxBubbleChars);
|
|
363
|
+
if (!parts.length)
|
|
364
|
+
return false;
|
|
365
|
+
for (const part of parts) {
|
|
366
|
+
await this.sink.send(part);
|
|
367
|
+
this.published += this.published ? `\n\n${part}` : part;
|
|
368
|
+
}
|
|
369
|
+
return true;
|
|
255
370
|
}
|
|
256
371
|
async addProgressLine(line) {
|
|
257
372
|
this.opts.stopTyping();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nopeek/agent-bridge",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.15",
|
|
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",
|
|
@@ -46,6 +46,6 @@
|
|
|
46
46
|
"start": "node dist/cli.js",
|
|
47
47
|
"dev": "tsx src/cli.ts",
|
|
48
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
|
+
"test": "tsx --test --test-concurrency=1 src/tool-progress.test.ts src/inbound-files.test.ts src/mid-turn.test.ts"
|
|
50
50
|
}
|
|
51
51
|
}
|