@nopeek/agent-bridge 0.7.14 → 0.7.16
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 +18 -0
- package/dist/tool-progress.js +192 -32
- 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,21 @@ 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;
|
|
30
|
+
/** If nothing arrives for this long, the next chunk is a new message. 0 = off. */
|
|
31
|
+
bubbleGapMs?: number;
|
|
28
32
|
};
|
|
29
33
|
export type TurnFinishKind = "streamed" | "sent" | "empty";
|
|
30
34
|
/** Text in `full` that has not already been posted as commentary. */
|
|
31
35
|
export declare function unpublishedTail(full: string, published: string): string;
|
|
36
|
+
/** Unified diffs / `| review` dumps should never land in the chat. */
|
|
37
|
+
export declare function isDumpLine(line: string): boolean;
|
|
38
|
+
export declare function isToolDump(text: string): boolean;
|
|
39
|
+
/** Drop dump lines from a live chunk without swallowing normal punctuation. */
|
|
40
|
+
export declare function stripDumps(text: string): string;
|
|
41
|
+
/** Split a reply into short chat bubbles. Drops tool-dump paragraphs. */
|
|
42
|
+
export declare function splitBubbles(text: string, max?: number): string[];
|
|
32
43
|
/**
|
|
33
44
|
* One chat turn: live tool progress (edited in place) with commentary
|
|
34
45
|
* messages in between tool batches, then any leftover recap below.
|
|
@@ -47,20 +58,27 @@ export declare class TurnPublisher {
|
|
|
47
58
|
private published;
|
|
48
59
|
private toolsUsed;
|
|
49
60
|
private startTimer;
|
|
61
|
+
private gapTimer;
|
|
50
62
|
private seen;
|
|
51
63
|
private readonly startDelayMs;
|
|
52
64
|
private readonly maxProgressLines;
|
|
65
|
+
private readonly maxBubbleChars;
|
|
66
|
+
private readonly bubbleGapMs;
|
|
53
67
|
constructor(sink: TurnSink, opts: TurnPublisherOpts);
|
|
54
68
|
onChunk(delta: string): void;
|
|
55
69
|
onTool(ev: ToolProgressEvent): void;
|
|
56
70
|
finish(reply: string): Promise<TurnFinishKind>;
|
|
57
71
|
fail(text: string): Promise<void>;
|
|
58
72
|
private cancelStart;
|
|
73
|
+
private cancelGap;
|
|
74
|
+
/** After `bubbleGapMs` of silence, seal the current bubble. Next text is new. */
|
|
75
|
+
private armGap;
|
|
59
76
|
private enqueue;
|
|
60
77
|
/** Open or append the current commentary/answer stream with this chunk only. */
|
|
61
78
|
private appendCommentary;
|
|
62
79
|
/** Finalize the current commentary so later tools land below it. */
|
|
63
80
|
private commitText;
|
|
81
|
+
private sendBubbles;
|
|
64
82
|
private addProgressLine;
|
|
65
83
|
private closeProgress;
|
|
66
84
|
}
|
package/dist/tool-progress.js
CHANGED
|
@@ -60,15 +60,125 @@ 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 isDumpLine(line) {
|
|
81
|
+
const l = line.trim();
|
|
82
|
+
if (!l)
|
|
83
|
+
return false;
|
|
84
|
+
if (/^review diff\b/i.test(l))
|
|
85
|
+
return true;
|
|
86
|
+
if (/^\|\s*(review|read|search|terminal|patch|write)\b/i.test(l))
|
|
87
|
+
return true;
|
|
88
|
+
if (/^@@\s+-/.test(l))
|
|
89
|
+
return true;
|
|
90
|
+
if (/^diff --git /.test(l))
|
|
91
|
+
return true;
|
|
92
|
+
if (/^\*\*\*\s+(Begin|Update|Add|Delete|End)\b/.test(l))
|
|
93
|
+
return true;
|
|
94
|
+
if (/^[ab]:?\/{1,2}Volumes\//.test(l))
|
|
95
|
+
return true;
|
|
96
|
+
if (/^index [0-9a-f]+\.\.[0-9a-f]+/.test(l))
|
|
97
|
+
return true;
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
export function isToolDump(text) {
|
|
101
|
+
const t = text.trim();
|
|
102
|
+
if (!t)
|
|
103
|
+
return false;
|
|
104
|
+
if (isDumpLine(t))
|
|
105
|
+
return true;
|
|
106
|
+
if (/^diff --git /m.test(t) && /^@@ /m.test(t))
|
|
107
|
+
return true;
|
|
108
|
+
const lines = t.split("\n");
|
|
109
|
+
if (lines.length >= 6) {
|
|
110
|
+
const dumpy = lines.filter((l) => isDumpLine(l) || /^(@@ |[+-](?![+-])|\| )/.test(l)).length;
|
|
111
|
+
if (dumpy / lines.length >= 0.4)
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
/** Drop dump lines from a live chunk without swallowing normal punctuation. */
|
|
117
|
+
export function stripDumps(text) {
|
|
118
|
+
if (!text)
|
|
119
|
+
return text;
|
|
120
|
+
if (!text.includes("\n"))
|
|
121
|
+
return isDumpLine(text) ? "" : text;
|
|
122
|
+
if (isToolDump(text))
|
|
123
|
+
return "";
|
|
124
|
+
return text
|
|
125
|
+
.split("\n")
|
|
126
|
+
.filter((l) => !isDumpLine(l))
|
|
127
|
+
.join("\n");
|
|
128
|
+
}
|
|
129
|
+
/** Split a reply into short chat bubbles. Drops tool-dump paragraphs. */
|
|
130
|
+
export function splitBubbles(text, max = DEFAULT_MAX_BUBBLE) {
|
|
131
|
+
const cleaned = text.replace(/\r\n/g, "\n").trim();
|
|
132
|
+
if (!cleaned)
|
|
133
|
+
return [];
|
|
134
|
+
const paras = cleaned.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
|
|
135
|
+
const out = [];
|
|
136
|
+
for (const p of paras) {
|
|
137
|
+
if (isToolDump(p))
|
|
138
|
+
continue;
|
|
139
|
+
if (p.length <= max) {
|
|
140
|
+
out.push(p);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
let buf = "";
|
|
144
|
+
for (const line of p.split("\n")) {
|
|
145
|
+
const next = buf ? `${buf}\n${line}` : line;
|
|
146
|
+
if (next.length > max && buf) {
|
|
147
|
+
out.push(buf);
|
|
148
|
+
buf = line.length > max ? "" : line;
|
|
149
|
+
if (line.length > max) {
|
|
150
|
+
for (const piece of hardWrap(line, max))
|
|
151
|
+
out.push(piece);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
else if (next.length > max) {
|
|
155
|
+
for (const piece of hardWrap(next, max))
|
|
156
|
+
out.push(piece);
|
|
157
|
+
buf = "";
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
buf = next;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (buf.trim())
|
|
164
|
+
out.push(buf.trim());
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
function hardWrap(s, max) {
|
|
169
|
+
const out = [];
|
|
170
|
+
let rest = s.trim();
|
|
171
|
+
while (rest.length > max) {
|
|
172
|
+
let at = rest.lastIndexOf(" ", max);
|
|
173
|
+
if (at < max * 0.5)
|
|
174
|
+
at = max;
|
|
175
|
+
out.push(rest.slice(0, at).trim());
|
|
176
|
+
rest = rest.slice(at).trim();
|
|
177
|
+
}
|
|
178
|
+
if (rest)
|
|
179
|
+
out.push(rest);
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
72
182
|
/**
|
|
73
183
|
* One chat turn: live tool progress (edited in place) with commentary
|
|
74
184
|
* messages in between tool batches, then any leftover recap below.
|
|
@@ -87,37 +197,44 @@ export class TurnPublisher {
|
|
|
87
197
|
published = "";
|
|
88
198
|
toolsUsed = false;
|
|
89
199
|
startTimer = null;
|
|
200
|
+
gapTimer = null;
|
|
90
201
|
seen = new Set();
|
|
91
202
|
startDelayMs;
|
|
92
203
|
maxProgressLines;
|
|
204
|
+
maxBubbleChars;
|
|
205
|
+
bubbleGapMs;
|
|
93
206
|
constructor(sink, opts) {
|
|
94
207
|
this.sink = sink;
|
|
95
208
|
this.opts = opts;
|
|
96
209
|
this.startDelayMs = opts.startDelayMs ?? 400;
|
|
97
|
-
this.maxProgressLines = opts.maxProgressLines ??
|
|
210
|
+
this.maxProgressLines = opts.maxProgressLines ?? 8;
|
|
211
|
+
this.maxBubbleChars = opts.maxBubbleChars ?? DEFAULT_MAX_BUBBLE;
|
|
212
|
+
this.bubbleGapMs = opts.bubbleGapMs ?? 2000;
|
|
98
213
|
}
|
|
99
214
|
onChunk(delta) {
|
|
100
|
-
|
|
215
|
+
const chunk = stripDumps(delta);
|
|
216
|
+
if (!chunk)
|
|
101
217
|
return;
|
|
218
|
+
this.armGap();
|
|
102
219
|
if (this.toolsUsed) {
|
|
103
220
|
this.cancelStart();
|
|
104
|
-
this.enqueue(() => this.appendCommentary(
|
|
221
|
+
this.enqueue(() => this.appendCommentary(chunk));
|
|
105
222
|
return;
|
|
106
223
|
}
|
|
107
224
|
if (this.textP) {
|
|
108
|
-
this.enqueue(() => this.appendCommentary(
|
|
225
|
+
this.enqueue(() => this.appendCommentary(chunk));
|
|
109
226
|
return;
|
|
110
227
|
}
|
|
111
|
-
this.held +=
|
|
228
|
+
this.held += chunk;
|
|
112
229
|
if (this.startTimer)
|
|
113
230
|
return;
|
|
114
231
|
this.startTimer = setTimeout(() => {
|
|
115
232
|
this.startTimer = null;
|
|
116
233
|
if (this.toolsUsed || this.textP || !this.held)
|
|
117
234
|
return;
|
|
118
|
-
const
|
|
235
|
+
const ready = this.held;
|
|
119
236
|
this.held = "";
|
|
120
|
-
this.enqueue(() => this.appendCommentary(
|
|
237
|
+
this.enqueue(() => this.appendCommentary(ready));
|
|
121
238
|
}, this.startDelayMs);
|
|
122
239
|
}
|
|
123
240
|
onTool(ev) {
|
|
@@ -130,40 +247,32 @@ export class TurnPublisher {
|
|
|
130
247
|
return;
|
|
131
248
|
this.seen.add(line);
|
|
132
249
|
this.toolsUsed = true;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
this.startTimer = null;
|
|
136
|
-
}
|
|
137
|
-
const pending = this.held;
|
|
138
|
-
this.held = "";
|
|
250
|
+
this.cancelStart();
|
|
251
|
+
this.cancelGap();
|
|
139
252
|
this.enqueue(async () => {
|
|
253
|
+
const pending = this.held;
|
|
254
|
+
this.held = "";
|
|
140
255
|
await this.commitText(pending);
|
|
141
256
|
await this.addProgressLine(line);
|
|
142
257
|
});
|
|
143
258
|
}
|
|
144
259
|
async finish(reply) {
|
|
145
260
|
this.cancelStart();
|
|
261
|
+
this.cancelGap();
|
|
146
262
|
await this.write;
|
|
147
263
|
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
264
|
await this.commitText(this.held);
|
|
158
|
-
|
|
265
|
+
this.held = "";
|
|
266
|
+
const rest = unpublishedTail(reply.trim(), this.published);
|
|
159
267
|
if (!rest)
|
|
160
268
|
return this.published.trim() ? "streamed" : "empty";
|
|
161
269
|
this.opts.stopTyping();
|
|
162
|
-
await this.
|
|
163
|
-
return "sent";
|
|
270
|
+
const sent = await this.sendBubbles(rest);
|
|
271
|
+
return sent ? "sent" : this.published.trim() ? "streamed" : "empty";
|
|
164
272
|
}
|
|
165
273
|
async fail(text) {
|
|
166
274
|
this.cancelStart();
|
|
275
|
+
this.cancelGap();
|
|
167
276
|
await this.write;
|
|
168
277
|
await this.closeProgress();
|
|
169
278
|
if (this.textP) {
|
|
@@ -185,6 +294,26 @@ export class TurnPublisher {
|
|
|
185
294
|
clearTimeout(this.startTimer);
|
|
186
295
|
this.startTimer = null;
|
|
187
296
|
}
|
|
297
|
+
cancelGap() {
|
|
298
|
+
if (!this.gapTimer)
|
|
299
|
+
return;
|
|
300
|
+
clearTimeout(this.gapTimer);
|
|
301
|
+
this.gapTimer = null;
|
|
302
|
+
}
|
|
303
|
+
/** After `bubbleGapMs` of silence, seal the current bubble. Next text is new. */
|
|
304
|
+
armGap() {
|
|
305
|
+
this.cancelGap();
|
|
306
|
+
if (this.bubbleGapMs <= 0)
|
|
307
|
+
return;
|
|
308
|
+
this.gapTimer = setTimeout(() => {
|
|
309
|
+
this.gapTimer = null;
|
|
310
|
+
this.enqueue(async () => {
|
|
311
|
+
const pending = this.held;
|
|
312
|
+
this.held = "";
|
|
313
|
+
await this.commitText(pending);
|
|
314
|
+
});
|
|
315
|
+
}, this.bubbleGapMs);
|
|
316
|
+
}
|
|
188
317
|
enqueue(fn) {
|
|
189
318
|
this.write = this.write.then(fn).catch((err) => {
|
|
190
319
|
console.error(`[turn] ${err.message}`);
|
|
@@ -194,8 +323,24 @@ export class TurnPublisher {
|
|
|
194
323
|
async appendCommentary(chunk) {
|
|
195
324
|
if (!chunk)
|
|
196
325
|
return;
|
|
197
|
-
if (this.toolsUsed)
|
|
326
|
+
if (this.toolsUsed) {
|
|
198
327
|
await this.closeProgress();
|
|
328
|
+
this.held += chunk;
|
|
329
|
+
const cut = this.held.lastIndexOf("\n\n");
|
|
330
|
+
if (cut >= 0) {
|
|
331
|
+
const ready = this.held.slice(0, cut);
|
|
332
|
+
this.held = this.held.slice(cut + 2);
|
|
333
|
+
this.opts.stopTyping();
|
|
334
|
+
await this.sendBubbles(ready);
|
|
335
|
+
}
|
|
336
|
+
else if (this.held.length >= this.maxBubbleChars) {
|
|
337
|
+
const ready = this.held;
|
|
338
|
+
this.held = "";
|
|
339
|
+
this.opts.stopTyping();
|
|
340
|
+
await this.sendBubbles(ready);
|
|
341
|
+
}
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
199
344
|
this.opts.stopTyping();
|
|
200
345
|
if (!this.textP) {
|
|
201
346
|
this.textP = this.sink.stream();
|
|
@@ -236,22 +381,37 @@ export class TurnPublisher {
|
|
|
236
381
|
if (s && final) {
|
|
237
382
|
if (extra)
|
|
238
383
|
s.append(extra);
|
|
239
|
-
|
|
240
|
-
|
|
384
|
+
const parts = splitBubbles(final, this.maxBubbleChars);
|
|
385
|
+
const first = parts[0] || final;
|
|
386
|
+
await s.done(first).catch(() => { });
|
|
387
|
+
this.published += first;
|
|
388
|
+
for (const more of parts.slice(1)) {
|
|
389
|
+
this.opts.stopTyping();
|
|
390
|
+
await this.sink.send(more);
|
|
391
|
+
this.published += `\n\n${more}`;
|
|
392
|
+
}
|
|
241
393
|
return;
|
|
242
394
|
}
|
|
243
395
|
if (final.trim()) {
|
|
244
396
|
this.opts.stopTyping();
|
|
245
|
-
await this.
|
|
246
|
-
this.published += final;
|
|
397
|
+
await this.sendBubbles(final);
|
|
247
398
|
}
|
|
248
399
|
return;
|
|
249
400
|
}
|
|
250
401
|
if (!pending.trim())
|
|
251
402
|
return;
|
|
252
403
|
this.opts.stopTyping();
|
|
253
|
-
await this.
|
|
254
|
-
|
|
404
|
+
await this.sendBubbles(pending);
|
|
405
|
+
}
|
|
406
|
+
async sendBubbles(text) {
|
|
407
|
+
const parts = splitBubbles(text, this.maxBubbleChars);
|
|
408
|
+
if (!parts.length)
|
|
409
|
+
return false;
|
|
410
|
+
for (const part of parts) {
|
|
411
|
+
await this.sink.send(part);
|
|
412
|
+
this.published += this.published ? `\n\n${part}` : part;
|
|
413
|
+
}
|
|
414
|
+
return true;
|
|
255
415
|
}
|
|
256
416
|
async addProgressLine(line) {
|
|
257
417
|
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.16",
|
|
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
|
}
|