@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 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) => s.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.send({ text: body });
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.23",
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
  }