@nopeek/agent-bridge 0.7.23 → 0.7.24
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.js +20 -3
- package/dist/outbound-media.d.ts +34 -0
- package/dist/outbound-media.js +187 -0
- package/package.json +2 -2
package/dist/bot.js
CHANGED
|
@@ -10,6 +10,7 @@ import { FileStore } from "./storage.js";
|
|
|
10
10
|
import { TurnPublisher } from "./tool-progress.js";
|
|
11
11
|
import { ChannelTurn, coalesceFollowups, isBotEcho, isPlaceholderText, isStopRequest, wrapInterruptedFollowup, } from "./mid-turn.js";
|
|
12
12
|
import { buildInboundPrompt, describeStructured, hasInboundWork, isControlType, parseAttachments, saveInboundFiles, } from "./inbound-files.js";
|
|
13
|
+
import { deliverOutbound, extractMedia, attachMediaFiles, stripMediaTags } from "./outbound-media.js";
|
|
13
14
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
14
15
|
const MAX_BACKOFF_MS = 60_000;
|
|
15
16
|
// Owner-membership answers are cached per channel for a short window; a
|
|
@@ -687,13 +688,29 @@ export class BotRunner {
|
|
|
687
688
|
/* best-effort */
|
|
688
689
|
}
|
|
689
690
|
};
|
|
691
|
+
const mediaSent = new Set();
|
|
690
692
|
const published = new TurnPublisher({
|
|
691
693
|
stream: async () => {
|
|
692
694
|
const s = await ch.stream();
|
|
693
695
|
return {
|
|
694
696
|
append: (chunk) => s.append(chunk),
|
|
695
|
-
replace: (full) => s.replace(full),
|
|
696
|
-
done: (final) =>
|
|
697
|
+
replace: (full) => s.replace(stripMediaTags(full) || full),
|
|
698
|
+
done: async (final) => {
|
|
699
|
+
const raw = typeof final === "string" ? final : "";
|
|
700
|
+
const { cleaned, paths } = extractMedia(raw);
|
|
701
|
+
if (cleaned.trim())
|
|
702
|
+
await s.done(cleaned);
|
|
703
|
+
else {
|
|
704
|
+
try {
|
|
705
|
+
s.replace("…");
|
|
706
|
+
await s.done("…");
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
await s.done("");
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
await attachMediaFiles(ch, paths, mediaSent);
|
|
713
|
+
},
|
|
697
714
|
fail: (text) => s.fail(text),
|
|
698
715
|
cancel: async () => {
|
|
699
716
|
// Do not recall() — that leaves "This message was deleted" / Message recalled.
|
|
@@ -708,7 +725,7 @@ export class BotRunner {
|
|
|
708
725
|
};
|
|
709
726
|
},
|
|
710
727
|
send: async (body) => {
|
|
711
|
-
await ch
|
|
728
|
+
await deliverOutbound(ch, body, mediaSent);
|
|
712
729
|
},
|
|
713
730
|
}, {
|
|
714
731
|
stopTyping,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AttachmentInput } from "@nopeek/chat";
|
|
2
|
+
export type ExtractedMedia = {
|
|
3
|
+
cleaned: string;
|
|
4
|
+
paths: string[];
|
|
5
|
+
};
|
|
6
|
+
export declare function extractMedia(text: string): ExtractedMedia;
|
|
7
|
+
export declare function unwrapPath(raw: string): string;
|
|
8
|
+
export declare function mediaAllowRoots(home?: string): string[];
|
|
9
|
+
export declare function resolveMediaPath(raw: string, roots?: string[]): string | null;
|
|
10
|
+
export declare function loadAttachment(path: string): AttachmentInput;
|
|
11
|
+
export type MediaChannel = {
|
|
12
|
+
send: (body: {
|
|
13
|
+
text: string;
|
|
14
|
+
}) => Promise<unknown>;
|
|
15
|
+
sendAttachment: (a: AttachmentInput, opts?: {
|
|
16
|
+
caption?: string;
|
|
17
|
+
}) => Promise<unknown>;
|
|
18
|
+
};
|
|
19
|
+
export declare function attachMediaFiles(ch: Pick<MediaChannel, "sendAttachment">, paths: string[], alreadySent?: Set<string>): Promise<{
|
|
20
|
+
sent: string[];
|
|
21
|
+
failed: string[];
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* Post cleaned text, then each MEDIA file as a native image/video/audio/file
|
|
25
|
+
* bubble. Skips paths already sent this turn so stream.done + send cannot
|
|
26
|
+
* double-attach the same PDF.
|
|
27
|
+
*/
|
|
28
|
+
export declare function deliverOutbound(ch: MediaChannel, text: string, alreadySent?: Set<string>): Promise<{
|
|
29
|
+
cleaned: string;
|
|
30
|
+
sent: string[];
|
|
31
|
+
failed: string[];
|
|
32
|
+
}>;
|
|
33
|
+
/** Strip MEDIA tags for a live stream bubble without attaching yet. */
|
|
34
|
+
export declare function stripMediaTags(text: string): string;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Turn Hermes `MEDIA:/path/to/file.pdf` tags into real NoPeek attachments.
|
|
2
|
+
// Telegram's gateway already does this; the bridge used to post the tag as text.
|
|
3
|
+
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, join, sep } from "node:path";
|
|
6
|
+
import { hermesHome } from "./backends.js";
|
|
7
|
+
import { inferContentType, MAX_ATTACHMENT_BYTES, safeFileName } from "./inbound-files.js";
|
|
8
|
+
const MEDIA_EXTS = "png|jpe?g|gif|webp|bmp|tiff?|heic|heif|svg|mp4|mov|m4v|avi|mkv|webm|ogg|opus|mp3|wav|m4a|aac|flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|md|csv|json|html?|apk|ipa|log";
|
|
9
|
+
// Mirrors Hermes gateway extract_media: MEDIA:<path>, optional quotes/backticks,
|
|
10
|
+
// optional space after the colon, absolute or ~/ paths.
|
|
11
|
+
const MEDIA_RE = new RegExp(String.raw `[\`"']?MEDIA:\s*(?<path>\`[^\`\n]+\`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+?\.(?:${MEDIA_EXTS}))[\`"']?`, "gi");
|
|
12
|
+
const CACHE_SUBS = [
|
|
13
|
+
"cache",
|
|
14
|
+
"cache/images",
|
|
15
|
+
"cache/videos",
|
|
16
|
+
"cache/audio",
|
|
17
|
+
"cache/documents",
|
|
18
|
+
"cache/screenshots",
|
|
19
|
+
"audio_cache",
|
|
20
|
+
"image_cache",
|
|
21
|
+
"video_cache",
|
|
22
|
+
"document_cache",
|
|
23
|
+
"browser_screenshots",
|
|
24
|
+
];
|
|
25
|
+
export function extractMedia(text) {
|
|
26
|
+
if (!text)
|
|
27
|
+
return { cleaned: "", paths: [] };
|
|
28
|
+
MEDIA_RE.lastIndex = 0;
|
|
29
|
+
const paths = [];
|
|
30
|
+
const cleaned = text
|
|
31
|
+
.replace(MEDIA_RE, (_full, path) => {
|
|
32
|
+
const raw = unwrapPath(path);
|
|
33
|
+
if (raw)
|
|
34
|
+
paths.push(raw);
|
|
35
|
+
return "";
|
|
36
|
+
})
|
|
37
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
38
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
39
|
+
.trim();
|
|
40
|
+
return { cleaned, paths: dedupe(paths) };
|
|
41
|
+
}
|
|
42
|
+
export function unwrapPath(raw) {
|
|
43
|
+
let path = (raw || "").trim();
|
|
44
|
+
if (path.length >= 2 && path[0] === path[path.length - 1] && `\`"'`.includes(path[0])) {
|
|
45
|
+
path = path.slice(1, -1).trim();
|
|
46
|
+
}
|
|
47
|
+
path = path.replace(/^[`"']+/, "").replace(/[`"']+$/, "");
|
|
48
|
+
path = path.replace(/[.,;:)}]+$/, "");
|
|
49
|
+
if (path.startsWith("~/"))
|
|
50
|
+
path = join(homedir(), path.slice(2));
|
|
51
|
+
return path.trim();
|
|
52
|
+
}
|
|
53
|
+
export function mediaAllowRoots(home = hermesHome()) {
|
|
54
|
+
const bases = [home, join(homedir(), ".hermes")];
|
|
55
|
+
if (homedir() !== "/Users/jarvis")
|
|
56
|
+
bases.push("/Users/jarvis/.hermes");
|
|
57
|
+
const roots = [];
|
|
58
|
+
for (const base of bases) {
|
|
59
|
+
for (const sub of CACHE_SUBS)
|
|
60
|
+
roots.push(join(base, sub));
|
|
61
|
+
}
|
|
62
|
+
const extra = process.env.HERMES_MEDIA_ALLOW_DIRS || "";
|
|
63
|
+
for (const chunk of extra.split(/[: ,]+/)) {
|
|
64
|
+
const root = chunk.trim();
|
|
65
|
+
if (root.startsWith("/") || root.startsWith("~/"))
|
|
66
|
+
roots.push(unwrapPath(root));
|
|
67
|
+
}
|
|
68
|
+
return dedupe(roots);
|
|
69
|
+
}
|
|
70
|
+
export function resolveMediaPath(raw, roots = mediaAllowRoots()) {
|
|
71
|
+
const candidate = unwrapPath(raw);
|
|
72
|
+
if (!candidate.startsWith("/"))
|
|
73
|
+
return null;
|
|
74
|
+
if (!existsSync(candidate))
|
|
75
|
+
return null;
|
|
76
|
+
let resolved;
|
|
77
|
+
try {
|
|
78
|
+
resolved = realpathSync(candidate);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
if (!lstatSync(resolved).isFile())
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
for (const root of roots) {
|
|
91
|
+
if (!root)
|
|
92
|
+
continue;
|
|
93
|
+
let resolvedRoot = root;
|
|
94
|
+
try {
|
|
95
|
+
if (existsSync(root))
|
|
96
|
+
resolvedRoot = realpathSync(root);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (isWithin(resolved, resolvedRoot))
|
|
102
|
+
return resolved;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
export function loadAttachment(path) {
|
|
107
|
+
const st = statSync(path);
|
|
108
|
+
if (st.size > MAX_ATTACHMENT_BYTES) {
|
|
109
|
+
throw new Error(`${basename(path)} is over the ${MAX_ATTACHMENT_BYTES} byte limit`);
|
|
110
|
+
}
|
|
111
|
+
const buf = readFileSync(path);
|
|
112
|
+
const name = safeFileName(basename(path), "file");
|
|
113
|
+
return {
|
|
114
|
+
bytes: toArrayBuffer(buf),
|
|
115
|
+
name,
|
|
116
|
+
contentType: inferContentType(name),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export async function attachMediaFiles(ch, paths, alreadySent = new Set()) {
|
|
120
|
+
const failed = [];
|
|
121
|
+
const sent = [];
|
|
122
|
+
for (const raw of paths) {
|
|
123
|
+
const resolved = resolveMediaPath(raw);
|
|
124
|
+
if (!resolved) {
|
|
125
|
+
failed.push(basename(unwrapPath(raw)) || raw);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (alreadySent.has(resolved))
|
|
129
|
+
continue;
|
|
130
|
+
try {
|
|
131
|
+
const att = loadAttachment(resolved);
|
|
132
|
+
await ch.sendAttachment(att);
|
|
133
|
+
alreadySent.add(resolved);
|
|
134
|
+
sent.push(resolved);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
failed.push(`${basename(resolved)} (${err.message})`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { sent, failed };
|
|
141
|
+
}
|
|
142
|
+
function failNote(failed) {
|
|
143
|
+
if (!failed.length)
|
|
144
|
+
return "";
|
|
145
|
+
if (failed.length === 1)
|
|
146
|
+
return `Couldn't attach ${failed[0]}.`;
|
|
147
|
+
return `Couldn't attach ${failed.length} files.`;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Post cleaned text, then each MEDIA file as a native image/video/audio/file
|
|
151
|
+
* bubble. Skips paths already sent this turn so stream.done + send cannot
|
|
152
|
+
* double-attach the same PDF.
|
|
153
|
+
*/
|
|
154
|
+
export async function deliverOutbound(ch, text, alreadySent = new Set()) {
|
|
155
|
+
const extracted = extractMedia(text);
|
|
156
|
+
const body = extracted.cleaned;
|
|
157
|
+
if (body.trim())
|
|
158
|
+
await ch.send({ text: body });
|
|
159
|
+
const { sent, failed } = await attachMediaFiles(ch, extracted.paths, alreadySent);
|
|
160
|
+
if (failed.length && !sent.length && !body.trim()) {
|
|
161
|
+
await ch.send({ text: failNote(failed) });
|
|
162
|
+
}
|
|
163
|
+
return { cleaned: body, sent, failed };
|
|
164
|
+
}
|
|
165
|
+
/** Strip MEDIA tags for a live stream bubble without attaching yet. */
|
|
166
|
+
export function stripMediaTags(text) {
|
|
167
|
+
return extractMedia(text).cleaned;
|
|
168
|
+
}
|
|
169
|
+
function isWithin(path, root) {
|
|
170
|
+
const p = path.endsWith(sep) ? path.slice(0, -1) : path;
|
|
171
|
+
const r = root.endsWith(sep) ? root.slice(0, -1) : root;
|
|
172
|
+
return p === r || p.startsWith(r + sep);
|
|
173
|
+
}
|
|
174
|
+
function toArrayBuffer(buf) {
|
|
175
|
+
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
|
176
|
+
}
|
|
177
|
+
function dedupe(items) {
|
|
178
|
+
const seen = new Set();
|
|
179
|
+
const out = [];
|
|
180
|
+
for (const item of items) {
|
|
181
|
+
if (!item || seen.has(item))
|
|
182
|
+
continue;
|
|
183
|
+
seen.add(item);
|
|
184
|
+
out.push(item);
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nopeek/agent-bridge",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.24",
|
|
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 src/mid-turn.test.ts"
|
|
49
|
+
"test": "tsx --test --test-concurrency=1 src/tool-progress.test.ts src/inbound-files.test.ts src/mid-turn.test.ts src/outbound-media.test.ts"
|
|
50
50
|
}
|
|
51
51
|
}
|