@nopeek/agent-bridge 0.7.23 → 0.7.25

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
@@ -8,8 +8,9 @@ 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, isBotEcho, isPlaceholderText, isStopRequest, wrapInterruptedFollowup, } from "./mid-turn.js";
11
+ import { ChannelTurn, coalesceFollowups, isBotEcho, isPlaceholderText, isStopRequest, wrapInterruptedFollowup, wrapUserTurn, } 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,
@@ -720,7 +737,7 @@ export class BotRunner {
720
737
  published.markStarted();
721
738
  const live = new ChannelTurn();
722
739
  this.turns.set(channelId, live);
723
- const prompt = interrupted ? wrapInterruptedFollowup(text) : text;
740
+ const prompt = interrupted ? wrapInterruptedFollowup(text) : wrapUserTurn(text);
724
741
  let reply = "";
725
742
  const stat = beginTurn(this.info.handle, channelId, resolved.kind);
726
743
  try {
package/dist/bridge.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { BridgeConfig, Pairing, BrainSpec, BrainBackend } from "./config.js";
2
- export declare const VERSION = "0.7.23";
2
+ export declare const VERSION = "0.7.25";
3
3
  export interface PairRequest {
4
4
  pairingSecret: string;
5
5
  appId: string;
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.23";
22
+ export const VERSION = "0.7.25";
23
23
  function hermesApiStatus(cfg) {
24
24
  const api = lastHermesApiHealth();
25
25
  return {
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { asHooks } from "./brain.js";
3
3
  import { hermesSessionHeaders } from "./mid-turn.js";
4
+ import { rememberHermesSessionId } from "./hermes-session-store.js";
4
5
  import { RECAP_HINT } from "./tool-progress.js";
5
6
  /** Skip data-URL vision parts above this so a gallery cannot blow the POST. */
6
7
  const MAX_IMAGE_DATA_URL_BYTES = 8 * 1024 * 1024;
@@ -120,6 +121,7 @@ export function hermesHttpBrain(cfg) {
120
121
  console.error(`${tag} HTTP ${res.status}: ${body.slice(0, 300)}`);
121
122
  return "";
122
123
  }
124
+ rememberHermesSessionId(ctx.channelId, res.headers.get("X-Hermes-Session-Id"));
123
125
  if (!res.body) {
124
126
  clearTimeout(idle);
125
127
  console.error(`${tag} empty body`);
@@ -0,0 +1,5 @@
1
+ /** Test-only. Pass null to restore the real bridge home. */
2
+ export declare function setBridgeHomeForTests(dir: string | null): void;
3
+ export declare function resolvedHermesSessionId(channelId: string): string;
4
+ export declare function rememberHermesSessionId(channelId: string, returnedId: string | null | undefined): void;
5
+ export declare function forgetHermesSessionId(channelId: string): void;
@@ -0,0 +1,65 @@
1
+ // Hermes compression rotates X-Hermes-Session-Id (parent → 20260821_…).
2
+ // The bridge used to keep sending the original nopeek-<channel> id, so every
3
+ // turn reloaded the uncompressed parent (301 mixed messages) and Olie snapped
4
+ // back to an old NoPeek backlog. Persist the returned id per channel.
5
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { dirname, join } from "node:path";
8
+ function fallbackId(channelId) {
9
+ return `nopeek-${channelId}`;
10
+ }
11
+ let homeOverride = null;
12
+ /** Test-only. Pass null to restore the real bridge home. */
13
+ export function setBridgeHomeForTests(dir) {
14
+ homeOverride = dir;
15
+ }
16
+ function storePath() {
17
+ const home = homeOverride || process.env.NOPEEK_BRIDGE_HOME || join(homedir(), ".nopeek-bridge");
18
+ return join(home, "hermes-sessions.json");
19
+ }
20
+ function readMap() {
21
+ const path = storePath();
22
+ if (!existsSync(path))
23
+ return {};
24
+ try {
25
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
26
+ if (!parsed || typeof parsed !== "object")
27
+ return {};
28
+ const out = {};
29
+ for (const [k, v] of Object.entries(parsed)) {
30
+ if (typeof v === "string" && v.trim())
31
+ out[k] = v.trim();
32
+ }
33
+ return out;
34
+ }
35
+ catch {
36
+ return {};
37
+ }
38
+ }
39
+ function writeMap(map) {
40
+ const path = storePath();
41
+ mkdirSync(dirname(path), { recursive: true });
42
+ const tmp = `${path}.tmp`;
43
+ writeFileSync(tmp, JSON.stringify(map, null, 2));
44
+ renameSync(tmp, path);
45
+ }
46
+ export function resolvedHermesSessionId(channelId) {
47
+ return readMap()[channelId] || fallbackId(channelId);
48
+ }
49
+ export function rememberHermesSessionId(channelId, returnedId) {
50
+ const next = (returnedId || "").trim();
51
+ if (!next)
52
+ return;
53
+ const map = readMap();
54
+ if (map[channelId] === next)
55
+ return;
56
+ map[channelId] = next;
57
+ writeMap(map);
58
+ }
59
+ export function forgetHermesSessionId(channelId) {
60
+ const map = readMap();
61
+ if (!(channelId in map))
62
+ return;
63
+ delete map[channelId];
64
+ writeMap(map);
65
+ }
@@ -20,6 +20,9 @@ export declare function isBotEcho(m: {
20
20
  } | null;
21
21
  }, botUserId: string): boolean;
22
22
  export declare function coalesceFollowups(texts: string[]): FollowupDecision;
23
+ /** Pinned on every turn so compaction leftovers cannot become the job. */
24
+ export declare const JOB_PIN: string;
25
+ export declare function wrapUserTurn(text: string): string;
23
26
  export declare function wrapInterruptedFollowup(text: string): string;
24
27
  export declare function hermesSessionId(channelId: string): string;
25
28
  export declare function hermesSessionHeaders(apiKey: string, channelId: string): Record<string, string>;
package/dist/mid-turn.js CHANGED
@@ -7,6 +7,7 @@
7
7
  //
8
8
  // This module is the shared policy: detect stop, coalesce a burst, abort the
9
9
  // in-flight turn, and keep one stable Hermes session per channel.
10
+ import { resolvedHermesSessionId } from "./hermes-session-store.js";
10
11
  const STOP_RE = /^(?:\/)?(?:please\s+)?stop(?:\s+please)?[.!]?$/i;
11
12
  export function isStopRequest(text) {
12
13
  return STOP_RE.test((text || "").trim());
@@ -41,11 +42,28 @@ export function coalesceFollowups(texts) {
41
42
  const kept = cleaned.filter((t) => !isStopRequest(t));
42
43
  return { action: "continue", text: kept.join("\n\n") };
43
44
  }
45
+ /**
46
+ * Keep this marker. Hermes api_server `is_mid_task_followup` looks for it and
47
+ * otherwise prepends its own "ONE combined task" note — that is what made
48
+ * "do all 5" merge with a compacted NoPeek backlog.
49
+ */
50
+ const INTERRUPT_MARKER = "[System note: You were already working on a task";
51
+ /** Pinned on every turn so compaction leftovers cannot become the job. */
52
+ export const JOB_PIN = "[System note: The message below is the only live job. " +
53
+ "Do not resume older unfinished todos, numbered lists, or other products " +
54
+ "from compacted history unless this message clearly continues that same job. " +
55
+ '"Do all 5" means the list in YOUR LAST REPLY, not an older backlog. ' +
56
+ "Latest user message wins.]";
57
+ export function wrapUserTurn(text) {
58
+ return `${JOB_PIN}\n\n${text}`;
59
+ }
44
60
  export function wrapInterruptedFollowup(text) {
45
- return ("[System note: You were already working on a task. The user interrupted. " +
46
- "Reassess the previous work together with this new message and continue as " +
47
- "ONE combined task. Do not start a separate independent job. If the new " +
48
- "message replaces the old task, switch to it.]\n\n" +
61
+ return (`${INTERRUPT_MARKER}. The user interrupted. ` +
62
+ "The new message below REPLACES the in-flight task unless it is a small " +
63
+ 'add-on to the same job (e.g. "also add X"). Do not resume older unfinished ' +
64
+ "todos or other products from compacted history. " +
65
+ '"Do all 5" means the list in your last reply, not an older backlog. ' +
66
+ "Latest user message wins.]\n\n" +
49
67
  text);
50
68
  }
51
69
  export function hermesSessionId(channelId) {
@@ -55,10 +73,11 @@ export function hermesSessionHeaders(apiKey, channelId) {
55
73
  const headers = {};
56
74
  if (!apiKey)
57
75
  return headers;
58
- const sid = hermesSessionId(channelId);
76
+ // Key stays the channel. Id follows compression rotations so we do not
77
+ // reload the uncompressed parent every turn.
59
78
  headers.authorization = `Bearer ${apiKey}`;
60
- headers["X-Hermes-Session-Id"] = sid;
61
- headers["X-Hermes-Session-Key"] = sid;
79
+ headers["X-Hermes-Session-Id"] = resolvedHermesSessionId(channelId);
80
+ headers["X-Hermes-Session-Key"] = hermesSessionId(channelId);
62
81
  return headers;
63
82
  }
64
83
  export class ChannelTurn {
@@ -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
+ }
@@ -14,7 +14,7 @@ export declare function parseHermesActivityLine(raw: string): ToolProgressEvent
14
14
  /** One Telegram-style line. Running events only; completed is silent. */
15
15
  export declare function formatToolLine(ev: ToolProgressEvent): string | null;
16
16
  /** Hint so Hermes ends a tool-using turn with a recap, like Telegram. */
17
- export declare const RECAP_HINT = "When this turn used tools, your LAST message must be ONE phone briefing (not split). Use **bold** labels and blank lines like Telegram: **What was wrong**, **What I did**, **Current state**. Put copyable commands in ``` fences. Never end on a tool list, a diff, or \"tasks completed\". Skip the briefing only for simple chat with no tools. No em dashes.";
17
+ export declare const RECAP_HINT = "The latest user message is the only live job. Do not resume compacted leftovers or an older numbered list from another product. When this turn used tools, your LAST message must be ONE phone briefing (not split). Use **bold** labels and blank lines like Telegram: **What was wrong**, **What I did**, **Current state**. Put copyable commands in ``` fences. Never end on a tool list, a diff, or \"tasks completed\". Skip the briefing only for simple chat with no tools. No em dashes.";
18
18
  /** Posted when tools ran but the model never wrote a human recap. */
19
19
  export declare const MISSING_BRIEFING = "Done. I finished that work. Ask if you want the recap: what was wrong, what I did, and where things stand.";
20
20
  export interface StreamHandle {
@@ -95,7 +95,7 @@ function tidyLabel(raw) {
95
95
  return s.length > 80 ? `${s.slice(0, 77)}...` : s;
96
96
  }
97
97
  /** Hint so Hermes ends a tool-using turn with a recap, like Telegram. */
98
- export const RECAP_HINT = "When this turn used tools, your LAST message must be ONE phone briefing (not split). Use **bold** labels and blank lines like Telegram: **What was wrong**, **What I did**, **Current state**. Put copyable commands in ``` fences. Never end on a tool list, a diff, or \"tasks completed\". Skip the briefing only for simple chat with no tools. No em dashes.";
98
+ export const RECAP_HINT = "The latest user message is the only live job. Do not resume compacted leftovers or an older numbered list from another product. When this turn used tools, your LAST message must be ONE phone briefing (not split). Use **bold** labels and blank lines like Telegram: **What was wrong**, **What I did**, **Current state**. Put copyable commands in ``` fences. Never end on a tool list, a diff, or \"tasks completed\". Skip the briefing only for simple chat with no tools. No em dashes.";
99
99
  /** Posted when tools ran but the model never wrote a human recap. */
100
100
  export const MISSING_BRIEFING = "Done. I finished that work. Ask if you want the recap: what was wrong, what I did, and where things stand.";
101
101
  /** Text in `full` that has not already been posted as commentary. */
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.25",
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 src/hermes-session-store.test.ts"
50
50
  }
51
51
  }