@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.
@@ -0,0 +1,241 @@
1
+ // Decrypt NoPeek attachments onto the Hermes cache so any brain (Hermes HTTP,
2
+ // Hermes CLI, Claude Code, cmd/webhook) can read photos, video, PDFs, docx,
3
+ // and any other file the human sent. The old hop forwarded only m.body.text.
4
+ import { mkdirSync, writeFileSync } from "node:fs";
5
+ import { extname, join } from "node:path";
6
+ import { hermesHome } from "./backends.js";
7
+ export const CONTROL_TYPES = new Set([
8
+ "reaction",
9
+ "poll_vote",
10
+ "rsvp",
11
+ "edit",
12
+ "recall",
13
+ "link_preview_update",
14
+ ]);
15
+ export const MAX_ATTACHMENT_BYTES = 200 * 1024 * 1024;
16
+ export const MAX_ATTACHMENTS = 20;
17
+ export const MAX_TEXT_INJECT_BYTES = 100 * 1024;
18
+ const MAX_NAME = 80;
19
+ const TEXT_INJECT_EXT = new Set([".txt", ".md", ".csv", ".log", ".json", ".xml", ".yaml", ".yml", ".toml", ".ini", ".cfg"]);
20
+ export function isControlType(type) {
21
+ return typeof type === "string" && CONTROL_TYPES.has(type);
22
+ }
23
+ export function parseAttachments(body) {
24
+ if (!body || typeof body !== "object")
25
+ return [];
26
+ const raw = body.attachments;
27
+ if (!Array.isArray(raw))
28
+ return [];
29
+ const out = [];
30
+ for (const item of raw) {
31
+ if (!item || typeof item !== "object")
32
+ continue;
33
+ const a = item;
34
+ if (typeof a.blobUrl !== "string" || !a.blobUrl)
35
+ continue;
36
+ if (typeof a.contentKey !== "string" || !a.contentKey)
37
+ continue;
38
+ if (typeof a.iv !== "string" || !a.iv)
39
+ continue;
40
+ out.push({
41
+ blobUrl: a.blobUrl,
42
+ contentKey: a.contentKey,
43
+ iv: a.iv,
44
+ name: typeof a.name === "string" && a.name.trim() ? a.name : undefined,
45
+ contentType: typeof a.contentType === "string" ? a.contentType : undefined,
46
+ size: typeof a.size === "number" && Number.isFinite(a.size) ? a.size : undefined,
47
+ });
48
+ if (out.length >= MAX_ATTACHMENTS)
49
+ break;
50
+ }
51
+ return out;
52
+ }
53
+ export function hasInboundWork(body) {
54
+ if (!body || typeof body !== "object")
55
+ return false;
56
+ const b = body;
57
+ if (isControlType(b.type))
58
+ return false;
59
+ if (typeof b.text === "string" && b.text.trim())
60
+ return true;
61
+ if (parseAttachments(body).length > 0)
62
+ return true;
63
+ if (typeof b.type === "string" && b.type !== "text")
64
+ return true;
65
+ return false;
66
+ }
67
+ export function safeFileName(name, fallback) {
68
+ const base = (name ?? fallback).split(/[/\\]/).pop()?.trim() || fallback;
69
+ const cleaned = base.replace(/[^\w.\- ()[\]]+/g, "_").replace(/^\.+/, "") || fallback;
70
+ if (cleaned.length <= MAX_NAME)
71
+ return cleaned;
72
+ const ext = extname(cleaned);
73
+ const stem = cleaned.slice(0, Math.max(1, MAX_NAME - ext.length));
74
+ return `${stem}${ext}`;
75
+ }
76
+ export function classifyKind(contentType, name) {
77
+ const ct = (contentType ?? "").toLowerCase();
78
+ if (ct.startsWith("image/"))
79
+ return "image";
80
+ if (ct.startsWith("video/"))
81
+ return "video";
82
+ if (ct.startsWith("audio/"))
83
+ return "audio";
84
+ const ext = extname(name).toLowerCase();
85
+ if ([".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif", ".bmp", ".tif", ".tiff"].includes(ext))
86
+ return "image";
87
+ if ([".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi"].includes(ext))
88
+ return "video";
89
+ if ([".mp3", ".m4a", ".aac", ".wav", ".ogg", ".flac", ".opus"].includes(ext))
90
+ return "audio";
91
+ return "document";
92
+ }
93
+ export function inferContentType(name, contentType) {
94
+ if (contentType && contentType !== "application/octet-stream")
95
+ return contentType;
96
+ const ext = extname(name).toLowerCase();
97
+ const map = {
98
+ ".jpg": "image/jpeg",
99
+ ".jpeg": "image/jpeg",
100
+ ".png": "image/png",
101
+ ".gif": "image/gif",
102
+ ".webp": "image/webp",
103
+ ".heic": "image/heic",
104
+ ".mp4": "video/mp4",
105
+ ".mov": "video/quicktime",
106
+ ".webm": "video/webm",
107
+ ".mp3": "audio/mpeg",
108
+ ".m4a": "audio/mp4",
109
+ ".wav": "audio/wav",
110
+ ".ogg": "audio/ogg",
111
+ ".pdf": "application/pdf",
112
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
113
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
114
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
115
+ ".txt": "text/plain",
116
+ ".md": "text/markdown",
117
+ ".csv": "text/csv",
118
+ ".json": "application/json",
119
+ };
120
+ return map[ext] || contentType || "application/octet-stream";
121
+ }
122
+ export function cacheDirFor(kind, home = hermesHome()) {
123
+ const sub = kind === "image" ? "images" : kind === "video" ? "videos" : kind === "audio" ? "audio" : "documents";
124
+ return join(home, "cache", sub);
125
+ }
126
+ export function describeStructured(body) {
127
+ if (!body || typeof body.type !== "string")
128
+ return "";
129
+ if (body.type === "poll" && body.poll && typeof body.poll === "object") {
130
+ const poll = body.poll;
131
+ const q = typeof poll.question === "string" ? poll.question : "poll";
132
+ const opts = Array.isArray(poll.options)
133
+ ? poll.options
134
+ .map((o) => (o && typeof o === "object" && typeof o.label === "string" ? o.label : ""))
135
+ .filter(Boolean)
136
+ : [];
137
+ return `[The user sent a poll: "${q}"${opts.length ? ` options: ${opts.join(" / ")}` : ""}]`;
138
+ }
139
+ if (body.type === "contact" && body.contact && typeof body.contact === "object") {
140
+ const c = body.contact;
141
+ const name = typeof c.name === "string" ? c.name : "contact";
142
+ const org = typeof c.organization === "string" ? ` (${c.organization})` : "";
143
+ return `[The user sent a contact card: ${name}${org}]`;
144
+ }
145
+ if ((body.type === "calendar_event" || body.calendarEvent) && body.calendarEvent && typeof body.calendarEvent === "object") {
146
+ const ev = body.calendarEvent;
147
+ const title = typeof ev.title === "string" ? ev.title : "event";
148
+ const start = typeof ev.start === "string" ? ` at ${ev.start}` : "";
149
+ return `[The user sent a calendar event: ${title}${start}]`;
150
+ }
151
+ if (body.type === "location" && body.location && typeof body.location === "object") {
152
+ const loc = body.location;
153
+ const label = typeof loc.label === "string" ? loc.label : "location";
154
+ const lat = typeof loc.lat === "number" ? loc.lat : "?";
155
+ const lng = typeof loc.lng === "number" ? loc.lng : "?";
156
+ return `[The user sent a location: ${label} (${lat}, ${lng})]`;
157
+ }
158
+ return "";
159
+ }
160
+ export function fileNote(file) {
161
+ const size = file.size > 0 ? `, ${formatBytes(file.size)}` : "";
162
+ const noun = file.kind === "image" ? "photo" : file.kind === "video" ? "video" : file.kind === "audio" ? "audio file" : "document";
163
+ return (`[The user sent a ${noun}: '${file.name}' (${file.contentType}${size}). ` +
164
+ `The file is saved at: ${file.path}. ` +
165
+ `Read, inspect, and work from that path. If you need to edit it, copy it into the project first — this cache is temporary.]`);
166
+ }
167
+ export function buildInboundPrompt(opts) {
168
+ const parts = [];
169
+ for (const file of opts.files)
170
+ parts.push(fileNote(file));
171
+ for (const fail of opts.failures ?? [])
172
+ parts.push(fail);
173
+ if (opts.structured)
174
+ parts.push(opts.structured);
175
+ if (opts.caption.trim())
176
+ parts.push(opts.caption.trim());
177
+ if (parts.length === 0)
178
+ return "";
179
+ return parts.join("\n\n");
180
+ }
181
+ export function injectTextPreview(file, bytes) {
182
+ const ext = extname(file.name).toLowerCase();
183
+ if (!TEXT_INJECT_EXT.has(ext))
184
+ return null;
185
+ if (bytes.length > MAX_TEXT_INJECT_BYTES)
186
+ return null;
187
+ try {
188
+ const text = bytes.toString("utf8");
189
+ if (!text.trim())
190
+ return null;
191
+ return `[Content of ${file.name}]:\n${text}`;
192
+ }
193
+ catch {
194
+ return null;
195
+ }
196
+ }
197
+ export async function saveInboundFiles(fetchAtt, attachments, messageId, home = hermesHome()) {
198
+ const files = [];
199
+ const failures = [];
200
+ const previews = [];
201
+ const shortId = (messageId || "msg").replace(/[^a-zA-Z0-9]/g, "").slice(-10) || "msg";
202
+ let i = 0;
203
+ for (const att of attachments) {
204
+ i += 1;
205
+ const name = safeFileName(att.name, `attachment-${i}`);
206
+ const contentType = inferContentType(name, att.contentType);
207
+ const kind = classifyKind(contentType, name);
208
+ if (typeof att.size === "number" && att.size > MAX_ATTACHMENT_BYTES) {
209
+ failures.push(`[Could not save '${name}': ${formatBytes(att.size)} is over the ${formatBytes(MAX_ATTACHMENT_BYTES)} limit.]`);
210
+ continue;
211
+ }
212
+ try {
213
+ const bytes = await fetchAtt(att);
214
+ if (bytes.length > MAX_ATTACHMENT_BYTES) {
215
+ failures.push(`[Could not save '${name}': ${formatBytes(bytes.length)} is over the ${formatBytes(MAX_ATTACHMENT_BYTES)} limit.]`);
216
+ continue;
217
+ }
218
+ const dir = cacheDirFor(kind, home);
219
+ mkdirSync(dir, { recursive: true });
220
+ const prefix = kind === "image" ? "img" : kind === "video" ? "vid" : kind === "audio" ? "aud" : "doc";
221
+ const path = join(dir, `${prefix}_nopeek_${shortId}_${i}_${name}`);
222
+ writeFileSync(path, bytes);
223
+ const file = { path, name, contentType, size: bytes.length, kind };
224
+ files.push(file);
225
+ const preview = injectTextPreview(file, bytes);
226
+ if (preview)
227
+ previews.push(preview);
228
+ }
229
+ catch (err) {
230
+ failures.push(`[Could not download attachment '${name}': ${err.message}]`);
231
+ }
232
+ }
233
+ return { files, failures, previews };
234
+ }
235
+ function formatBytes(n) {
236
+ if (n < 1024)
237
+ return `${n} B`;
238
+ if (n < 1024 * 1024)
239
+ return `${Math.round(n / 102.4) / 10} KB`;
240
+ return `${Math.round(n / (1024 * 102.4)) / 10} MB`;
241
+ }
@@ -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
+ }
@@ -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
+ }
@@ -6,24 +6,69 @@ export type ToolProgressEvent = {
6
6
  };
7
7
  /** One Telegram-style line. Running events only; completed is silent. */
8
8
  export declare function formatToolLine(ev: ToolProgressEvent): string | null;
9
- export type ProgressSender = (text: string) => Promise<void>;
9
+ /** Hint so Hermes ends a tool-using turn with a recap, like Telegram. */
10
+ export declare const RECAP_HINT = "When this turn used tools (read/write files, commands, skills, memory), end your reply with a short recap: what was going on, what you did, and the result. Skip the recap for simple chat with no tools. No em dashes.";
11
+ export interface StreamHandle {
12
+ append(chunk: string): void;
13
+ replace(full: string): void;
14
+ done(finalText?: string): Promise<unknown>;
15
+ fail(text: string): Promise<void>;
16
+ }
17
+ export interface TurnSink {
18
+ stream(): Promise<StreamHandle>;
19
+ send(text: string): Promise<void>;
20
+ }
21
+ export type TurnPublisherOpts = {
22
+ stopTyping: () => void;
23
+ onStreamError: (err: Error) => void;
24
+ /** Wait this long for a tool before opening a streamed answer. */
25
+ startDelayMs?: number;
26
+ /** Start a fresh progress bubble after this many lines. */
27
+ maxProgressLines?: number;
28
+ /** Split commentary/recap into a new bubble after this many chars. */
29
+ maxBubbleChars?: number;
30
+ };
31
+ export type TurnFinishKind = "streamed" | "sent" | "empty";
32
+ /** Text in `full` that has not already been posted as commentary. */
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[];
10
38
  /**
11
- * Collect tool lines and flush them as short chat messages.
12
- * Flush when we have `maxLines` tools, or after `gapMs` of quiet.
39
+ * One chat turn: live tool progress (edited in place) with commentary
40
+ * messages in between tool batches, then any leftover recap below.
41
+ * All channel writes run on a single promise chain so bubbles stay in order.
13
42
  */
14
- export declare class ToolProgressFlusher {
15
- private readonly send;
16
- private readonly gapMs;
17
- private readonly maxLines;
18
- private lines;
19
- private timer;
20
- private chain;
43
+ export declare class TurnPublisher {
44
+ private readonly sink;
45
+ private readonly opts;
46
+ private write;
47
+ private progressP;
48
+ private progress;
49
+ private progressLines;
50
+ private textP;
51
+ private textBuf;
52
+ private held;
53
+ private published;
54
+ private toolsUsed;
55
+ private startTimer;
21
56
  private seen;
22
- constructor(send: ProgressSender, gapMs?: number, maxLines?: number);
23
- get pending(): number;
24
- push(ev: ToolProgressEvent): void;
25
- flush(): Promise<void>;
26
- private arm;
57
+ private readonly startDelayMs;
58
+ private readonly maxProgressLines;
59
+ private readonly maxBubbleChars;
60
+ constructor(sink: TurnSink, opts: TurnPublisherOpts);
61
+ onChunk(delta: string): void;
62
+ onTool(ev: ToolProgressEvent): void;
63
+ finish(reply: string): Promise<TurnFinishKind>;
64
+ fail(text: string): Promise<void>;
65
+ private cancelStart;
66
+ private enqueue;
67
+ /** Open or append the current commentary/answer stream with this chunk only. */
68
+ private appendCommentary;
69
+ /** Finalize the current commentary so later tools land below it. */
70
+ private commitText;
71
+ private sendBubbles;
72
+ private addProgressLine;
73
+ private closeProgress;
27
74
  }
28
- /** Hint so Hermes ends a tool-using turn with a recap, like Telegram. */
29
- export declare const RECAP_HINT = "When this turn used tools (read/write files, commands, skills, memory), end your reply with a short recap: what was going on, what you did, and the result. Skip the recap for simple chat with no tools. No em dashes.";