@nopeek/agent-bridge 0.7.11 → 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/README.md +6 -4
- package/dist/bot.d.ts +4 -0
- package/dist/bot.js +212 -81
- package/dist/brain.d.ts +12 -0
- package/dist/brain.js +2 -0
- package/dist/bridge.d.ts +1 -1
- package/dist/bridge.js +1 -1
- package/dist/cli.js +0 -0
- package/dist/hermes-http.d.ts +2 -1
- package/dist/hermes-http.js +39 -8
- package/dist/inbound-files.d.ts +36 -0
- package/dist/inbound-files.js +241 -0
- package/dist/mid-turn.d.ts +25 -0
- package/dist/mid-turn.js +67 -0
- package/dist/tool-progress.d.ts +62 -17
- package/dist/tool-progress.js +347 -44
- package/package.json +10 -10
package/README.md
CHANGED
|
@@ -41,9 +41,9 @@ The `npr_…` code comes from Settings → Connect your computer (bots) in the a
|
|
|
41
41
|
|
|
42
42
|
Hermes brains prefer the local agent API (`HERMES_API_URL`, default
|
|
43
43
|
`http://127.0.0.1:8642`) when `/health` is up, and fall back to `hermes chat`.
|
|
44
|
-
While Hermes is working, the bot
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
While Hermes is working, the bot edits one Telegram-style progress bubble
|
|
45
|
+
(read file, patch, skill, memory). New tools update that bubble instead of
|
|
46
|
+
stacking under a running summary. The recap is a separate message underneath.
|
|
47
47
|
Idle kill waits for **no output and no CPU** (default 15 min). Authenticated
|
|
48
48
|
`GET /status` includes `bridge`, `bots[]` (each with `lastTurn`), `hermesApi`,
|
|
49
49
|
and `lastTurn`. See `docs/HERMES-BRAIN-PLAN.md`.
|
|
@@ -79,6 +79,7 @@ Context is passed as environment variables:
|
|
|
79
79
|
| `NOPEEK_BOT_USER_ID` | user id of the bot |
|
|
80
80
|
| `NOPEEK_CHANNEL_ID` | channel the message arrived in |
|
|
81
81
|
| `NOPEEK_SENDER_USER_ID` | who sent the message |
|
|
82
|
+
| `NOPEEK_FILES` | JSON array of decrypted inbound files (`path`, `name`, `contentType`, `size`, `kind`) |
|
|
82
83
|
|
|
83
84
|
Examples:
|
|
84
85
|
|
|
@@ -109,7 +110,8 @@ Content-Type: application/json
|
|
|
109
110
|
"botHandle": "weatherbot",
|
|
110
111
|
"botUserId": "usr_…",
|
|
111
112
|
"channelId": "ch_…",
|
|
112
|
-
"senderUserId": "usr_…"
|
|
113
|
+
"senderUserId": "usr_…",
|
|
114
|
+
"files": []
|
|
113
115
|
}
|
|
114
116
|
```
|
|
115
117
|
|
package/dist/bot.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
|
@@ -7,7 +7,9 @@ import { isChannelMode } from "./control.js";
|
|
|
7
7
|
import { FALLBACK_REPLY, resolveBrain } from "./brain.js";
|
|
8
8
|
import { beginTurn, finishTurn, lastTurnFor } from "./last-turn.js";
|
|
9
9
|
import { FileStore } from "./storage.js";
|
|
10
|
-
import {
|
|
10
|
+
import { TurnPublisher } from "./tool-progress.js";
|
|
11
|
+
import { ChannelTurn, coalesceFollowups, isStopRequest, wrapInterruptedFollowup, } from "./mid-turn.js";
|
|
12
|
+
import { buildInboundPrompt, describeStructured, hasInboundWork, isControlType, parseAttachments, saveInboundFiles, } from "./inbound-files.js";
|
|
11
13
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
12
14
|
const MAX_BACKOFF_MS = 60_000;
|
|
13
15
|
// Owner-membership answers are cached per channel for a short window; a
|
|
@@ -73,6 +75,9 @@ export class BotRunner {
|
|
|
73
75
|
// order, one at a time — concurrent brain runs against the same agent session
|
|
74
76
|
// (e.g. one Hermes session per channel) deadlock or reply out of order.
|
|
75
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();
|
|
76
81
|
// Channels the bot can't post to (e.g. broadcast, non-operator): after the
|
|
77
82
|
// first FORBIDDEN, skip the brain entirely — replies there can never land.
|
|
78
83
|
cantPost = new Set();
|
|
@@ -373,6 +378,12 @@ export class BotRunner {
|
|
|
373
378
|
np.on("member.joined", onRosterChange);
|
|
374
379
|
np.on("member.left", onRosterChange);
|
|
375
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
|
+
}
|
|
376
387
|
const prev = this.chains.get(m.channelId) ?? Promise.resolve();
|
|
377
388
|
const next = prev.then(() => this.handleMessage(m).catch((err) => {
|
|
378
389
|
this.notePostFailure(m.channelId, err);
|
|
@@ -457,8 +468,11 @@ export class BotRunner {
|
|
|
457
468
|
}
|
|
458
469
|
return;
|
|
459
470
|
}
|
|
460
|
-
|
|
471
|
+
const body = m.body;
|
|
472
|
+
if (!body || isControlType(body.type) || !hasInboundWork(body))
|
|
461
473
|
return;
|
|
474
|
+
const attachments = parseAttachments(body);
|
|
475
|
+
const caption = typeof body.text === "string" ? body.text : "";
|
|
462
476
|
// OWNER-PRESENT POLICY: in a multi-party channel (anything but a direct
|
|
463
477
|
// chat), a non-owner sender may use the bot ONLY while the bot's owner is
|
|
464
478
|
// also a member of that channel. Owner absent → completely silent (no
|
|
@@ -516,7 +530,7 @@ export class BotRunner {
|
|
|
516
530
|
// predictable: mention-only means even the owner must @mention there.
|
|
517
531
|
// DIRECT channels always behave as "everyone" (a DM with the bot is always
|
|
518
532
|
// for the bot).
|
|
519
|
-
let text =
|
|
533
|
+
let text = caption;
|
|
520
534
|
if (ch.record.kind !== "direct" && ch.record.kind !== "dm") {
|
|
521
535
|
const mode = this.channelModes.get(m.channelId) ?? "everyone";
|
|
522
536
|
if (mode === "off") {
|
|
@@ -534,101 +548,218 @@ export class BotRunner {
|
|
|
534
548
|
text = stripped;
|
|
535
549
|
}
|
|
536
550
|
}
|
|
551
|
+
let files = [];
|
|
552
|
+
const failures = [];
|
|
553
|
+
const previews = [];
|
|
554
|
+
if (attachments.length) {
|
|
555
|
+
const saved = await saveInboundFiles(async (att) => {
|
|
556
|
+
const blob = await ch.fetchAttachment(att);
|
|
557
|
+
return Buffer.from(await blob.arrayBuffer());
|
|
558
|
+
}, attachments, m.messageId);
|
|
559
|
+
files = saved.files;
|
|
560
|
+
failures.push(...saved.failures);
|
|
561
|
+
previews.push(...saved.previews);
|
|
562
|
+
this.log(`${m.channelId} inbound ${body.type}: ${files.length} file(s) saved` +
|
|
563
|
+
(failures.length ? `, ${failures.length} failed` : ""));
|
|
564
|
+
}
|
|
565
|
+
const structured = describeStructured(body);
|
|
566
|
+
text = buildInboundPrompt({ caption: text, files, structured, failures });
|
|
567
|
+
if (previews.length)
|
|
568
|
+
text = `${previews.join("\n\n")}\n\n${text}`;
|
|
569
|
+
if (!text.trim())
|
|
570
|
+
return;
|
|
537
571
|
this.log(`${m.channelId} <- ${m.senderUserId}: ${text.slice(0, 120)}`);
|
|
538
572
|
ch.markRead(m.messageId).catch(() => { });
|
|
539
|
-
|
|
540
|
-
ch
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
+
}
|
|
556
599
|
try {
|
|
557
|
-
ch.typing(
|
|
600
|
+
ch.typing(true);
|
|
558
601
|
}
|
|
559
602
|
catch {
|
|
560
|
-
/* best-effort */
|
|
603
|
+
/* typing is best-effort */
|
|
561
604
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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);
|
|
572
624
|
this.logErr(`stream open failed (falling back to a single send): ${err.message}`);
|
|
625
|
+
},
|
|
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,
|
|
573
659
|
});
|
|
574
660
|
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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;
|
|
598
686
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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);
|
|
608
701
|
}
|
|
609
|
-
|
|
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))`);
|
|
610
709
|
try {
|
|
611
|
-
ch.
|
|
710
|
+
await ch.send({ text: "Stopped." });
|
|
612
711
|
}
|
|
613
|
-
catch {
|
|
614
|
-
|
|
712
|
+
catch (err) {
|
|
713
|
+
this.notePostFailure(channelId, err);
|
|
615
714
|
}
|
|
616
|
-
}
|
|
617
|
-
await progress.flush();
|
|
618
|
-
const stream = streamRef.p ? await streamRef.p.catch(() => null) : null;
|
|
619
|
-
if (stream) {
|
|
620
|
-
await stream.done(reply.trim() || undefined);
|
|
621
715
|
this.handled++;
|
|
622
|
-
|
|
623
|
-
return;
|
|
716
|
+
return null;
|
|
624
717
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
+
}
|
|
628
755
|
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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 };
|
|
632
763
|
}
|
|
633
764
|
/** A FORBIDDEN post (broadcast channel, bot not an operator) fails for every
|
|
634
765
|
* future message too — mute the channel so the brain stops running there. */
|
package/dist/brain.d.ts
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import type { BridgeConfig } from "./config.js";
|
|
2
2
|
import type { ToolProgressEvent } from "./tool-progress.js";
|
|
3
|
+
/** A decrypted inbound file written onto the Hermes cache for this turn. */
|
|
4
|
+
export interface BrainFile {
|
|
5
|
+
path: string;
|
|
6
|
+
name: string;
|
|
7
|
+
contentType: string;
|
|
8
|
+
size: number;
|
|
9
|
+
kind: "image" | "video" | "audio" | "document";
|
|
10
|
+
}
|
|
3
11
|
export interface BrainContext {
|
|
4
12
|
botHandle: string;
|
|
5
13
|
botUserId: string;
|
|
6
14
|
channelId: string;
|
|
7
15
|
senderUserId: string;
|
|
16
|
+
/** Local paths of photos / video / PDFs / any file the human just sent. */
|
|
17
|
+
files?: BrainFile[];
|
|
18
|
+
/** Abort the in-flight brain when a later message interrupts this turn. */
|
|
19
|
+
signal?: AbortSignal;
|
|
8
20
|
}
|
|
9
21
|
/**
|
|
10
22
|
* A brain answers one message. If it can stream, it calls `onChunk(delta)` as
|
package/dist/brain.js
CHANGED
|
@@ -40,6 +40,7 @@ function cmdBrain(cmd, timeoutMs) {
|
|
|
40
40
|
NOPEEK_BOT_USER_ID: ctx.botUserId,
|
|
41
41
|
NOPEEK_CHANNEL_ID: ctx.channelId,
|
|
42
42
|
NOPEEK_SENDER_USER_ID: ctx.senderUserId,
|
|
43
|
+
...(ctx.files?.length ? { NOPEEK_FILES: JSON.stringify(ctx.files) } : {}),
|
|
43
44
|
},
|
|
44
45
|
});
|
|
45
46
|
let stdout = "";
|
|
@@ -113,6 +114,7 @@ function urlBrain(url, timeoutMs) {
|
|
|
113
114
|
botUserId: ctx.botUserId,
|
|
114
115
|
channelId: ctx.channelId,
|
|
115
116
|
senderUserId: ctx.senderUserId,
|
|
117
|
+
...(ctx.files?.length ? { files: ctx.files } : {}),
|
|
116
118
|
}),
|
|
117
119
|
});
|
|
118
120
|
if (!res.ok) {
|
package/dist/bridge.d.ts
CHANGED
package/dist/bridge.js
CHANGED
|
@@ -19,7 +19,7 @@ import { reportCapabilities } from "./capabilities.js";
|
|
|
19
19
|
import { lastHermesApiHealth, probeHermesApi } from "./hermes-http.js";
|
|
20
20
|
import { lastTurnGlobal, turnSnapshot } from "./last-turn.js";
|
|
21
21
|
import { isBrainBackend } from "./config.js";
|
|
22
|
-
export const VERSION = "0.7.
|
|
22
|
+
export const VERSION = "0.7.14";
|
|
23
23
|
function hermesApiStatus(cfg) {
|
|
24
24
|
const api = lastHermesApiHealth();
|
|
25
25
|
return {
|
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/hermes-http.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,31 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import { asHooks } from "./brain.js";
|
|
3
|
+
import { hermesSessionHeaders } from "./mid-turn.js";
|
|
2
4
|
import { RECAP_HINT } from "./tool-progress.js";
|
|
5
|
+
/** Skip data-URL vision parts above this so a gallery cannot blow the POST. */
|
|
6
|
+
const MAX_IMAGE_DATA_URL_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
const MAX_IMAGE_DATA_URL_TOTAL = 20 * 1024 * 1024;
|
|
8
|
+
function userContent(text, files) {
|
|
9
|
+
const images = (files ?? []).filter((f) => f.kind === "image" && f.size > 0 && f.size <= MAX_IMAGE_DATA_URL_BYTES);
|
|
10
|
+
if (!images.length)
|
|
11
|
+
return text;
|
|
12
|
+
const parts = [{ type: "text", text }];
|
|
13
|
+
let used = 0;
|
|
14
|
+
for (const img of images) {
|
|
15
|
+
if (used + img.size > MAX_IMAGE_DATA_URL_TOTAL)
|
|
16
|
+
break;
|
|
17
|
+
try {
|
|
18
|
+
const b64 = readFileSync(img.path).toString("base64");
|
|
19
|
+
const mime = img.contentType.startsWith("image/") ? img.contentType : "image/jpeg";
|
|
20
|
+
parts.push({ type: "image_url", image_url: { url: `data:${mime};base64,${b64}` } });
|
|
21
|
+
used += img.size;
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
console.error(`[brain:hermes-http] could not attach ${img.path}: ${err.message}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return parts.length > 1 ? parts : text;
|
|
28
|
+
}
|
|
3
29
|
let lastHealth = null;
|
|
4
30
|
const HEALTH_TTL_MS = 15_000;
|
|
5
31
|
export function lastHermesApiHealth() {
|
|
@@ -34,7 +60,8 @@ export async function probeHermesApi(cfg, force = false) {
|
|
|
34
60
|
}
|
|
35
61
|
/**
|
|
36
62
|
* Stream one turn through Hermes' OpenAI-compatible chat completions.
|
|
37
|
-
* 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.
|
|
38
65
|
*/
|
|
39
66
|
export function hermesHttpBrain(cfg) {
|
|
40
67
|
return async (text, ctx, hooks) => {
|
|
@@ -44,17 +71,19 @@ export function hermesHttpBrain(cfg) {
|
|
|
44
71
|
const url = cfg.hermesApiUrl.replace(/\/+$/, "");
|
|
45
72
|
const headers = {
|
|
46
73
|
"content-type": "application/json",
|
|
74
|
+
...hermesSessionHeaders(cfg.hermesApiKey ?? "", ctx.channelId),
|
|
47
75
|
};
|
|
48
|
-
// Hermes rejects X-Hermes-Session-Key with 403 unless API_SERVER_KEY is set.
|
|
49
|
-
// Without a key, skip the header so the turn still runs (no session memory).
|
|
50
|
-
if (cfg.hermesApiKey) {
|
|
51
|
-
headers.authorization = `Bearer ${cfg.hermesApiKey}`;
|
|
52
|
-
headers["X-Hermes-Session-Key"] = `nopeek-${ctx.channelId}`;
|
|
53
|
-
}
|
|
54
76
|
// Idle abort — same rule as the CLI path. A wall-clock timeout on the
|
|
55
77
|
// whole POST would kill a healthy 20-minute agentic turn. Any SSE byte
|
|
56
78
|
// (token, keepalive, tool-progress) resets the idle timer.
|
|
79
|
+
// ctx.signal aborts immediately when the user sends a mid-turn follow-up.
|
|
57
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
|
+
}
|
|
58
87
|
let idle;
|
|
59
88
|
const armIdle = () => {
|
|
60
89
|
clearTimeout(idle);
|
|
@@ -74,7 +103,7 @@ export function hermesHttpBrain(cfg) {
|
|
|
74
103
|
stream: true,
|
|
75
104
|
messages: [
|
|
76
105
|
{ role: "system", content: RECAP_HINT },
|
|
77
|
-
{ role: "user", content: text },
|
|
106
|
+
{ role: "user", content: userContent(text, ctx.files) },
|
|
78
107
|
],
|
|
79
108
|
}),
|
|
80
109
|
signal: controller.signal,
|
|
@@ -145,6 +174,8 @@ export function hermesHttpBrain(cfg) {
|
|
|
145
174
|
catch (err) {
|
|
146
175
|
const msg = err.message || "";
|
|
147
176
|
if (controller.signal.aborted) {
|
|
177
|
+
if (ctx.signal?.aborted)
|
|
178
|
+
return "";
|
|
148
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.`;
|
|
149
180
|
}
|
|
150
181
|
console.error(`${tag} stream error: ${msg}`);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { BrainFile } from "./brain.js";
|
|
2
|
+
export declare const CONTROL_TYPES: Set<string>;
|
|
3
|
+
export declare const MAX_ATTACHMENT_BYTES: number;
|
|
4
|
+
export declare const MAX_ATTACHMENTS = 20;
|
|
5
|
+
export declare const MAX_TEXT_INJECT_BYTES: number;
|
|
6
|
+
export type EncryptedAtt = {
|
|
7
|
+
blobUrl: string;
|
|
8
|
+
contentKey: string;
|
|
9
|
+
iv: string;
|
|
10
|
+
name?: string;
|
|
11
|
+
contentType?: string;
|
|
12
|
+
size?: number;
|
|
13
|
+
};
|
|
14
|
+
export type FileKind = BrainFile["kind"];
|
|
15
|
+
export declare function isControlType(type?: string | null): boolean;
|
|
16
|
+
export declare function parseAttachments(body: unknown): EncryptedAtt[];
|
|
17
|
+
export declare function hasInboundWork(body: unknown): boolean;
|
|
18
|
+
export declare function safeFileName(name: string | undefined, fallback: string): string;
|
|
19
|
+
export declare function classifyKind(contentType: string | undefined, name: string): FileKind;
|
|
20
|
+
export declare function inferContentType(name: string, contentType?: string): string;
|
|
21
|
+
export declare function cacheDirFor(kind: FileKind, home?: string): string;
|
|
22
|
+
export declare function describeStructured(body: Record<string, unknown> | null | undefined): string;
|
|
23
|
+
export declare function fileNote(file: BrainFile): string;
|
|
24
|
+
export declare function buildInboundPrompt(opts: {
|
|
25
|
+
caption: string;
|
|
26
|
+
files: BrainFile[];
|
|
27
|
+
structured?: string;
|
|
28
|
+
failures?: string[];
|
|
29
|
+
}): string;
|
|
30
|
+
export declare function injectTextPreview(file: BrainFile, bytes: Buffer): string | null;
|
|
31
|
+
export type AttachmentFetcher = (att: EncryptedAtt) => Promise<Buffer>;
|
|
32
|
+
export declare function saveInboundFiles(fetchAtt: AttachmentFetcher, attachments: EncryptedAtt[], messageId: string, home?: string): Promise<{
|
|
33
|
+
files: BrainFile[];
|
|
34
|
+
failures: string[];
|
|
35
|
+
previews: string[];
|
|
36
|
+
}>;
|