@nopeek/agent-bridge 0.3.0 → 0.4.1
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/backends.d.ts +38 -0
- package/dist/backends.js +384 -0
- package/dist/bot.js +40 -2
- package/dist/brain.d.ts +9 -3
- package/dist/brain.js +22 -5
- package/dist/bridge.d.ts +3 -2
- package/dist/bridge.js +16 -1
- package/dist/config.d.ts +7 -1
- package/dist/config.js +13 -4
- package/package.json +2 -2
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { BridgeConfig } from "./config.js";
|
|
2
|
+
import { type Brain } from "./brain.js";
|
|
3
|
+
export declare function soulsDir(homeDir: string): string;
|
|
4
|
+
export declare function soulPath(homeDir: string, handle: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* Write <home>/agent-souls/<handle>.md from the built-in template if absent
|
|
7
|
+
* (idempotent — an existing, possibly hand-edited soul is never overwritten).
|
|
8
|
+
* Returns the soul path.
|
|
9
|
+
*/
|
|
10
|
+
export declare function provisionSoul(handle: string, homeDir: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* Deterministic per-(bot, channel) session UUID so each chat is one running
|
|
13
|
+
* Claude conversation. UUIDv5-ish (sha1 of the key formatted as a UUID) —
|
|
14
|
+
* byte-for-byte identical to tools/agent/bot-brain.sh so existing sessions
|
|
15
|
+
* keep their history when a bot moves from the shell brain to the native one.
|
|
16
|
+
*/
|
|
17
|
+
export declare function sessionUuid(handle: string, channelId: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* Claude Code backend: `claude -p` headless (reliable — no OAuth flakiness),
|
|
20
|
+
* one continuous session per (bot, channel). Streams assistant text deltas
|
|
21
|
+
* only. Runs in a dedicated empty cwd with --strict-mcp-config and a tool
|
|
22
|
+
* denylist, so a chat bot never inherits repo CLAUDE.md, MCP servers, or
|
|
23
|
+
* host powers from wherever the bridge happens to run.
|
|
24
|
+
*/
|
|
25
|
+
export declare function claudeBrain(cfg: BridgeConfig): Brain;
|
|
26
|
+
/**
|
|
27
|
+
* Create (or reuse) the bot's isolated Hermes profile: its own SOUL.md and
|
|
28
|
+
* memories/, inheriting the main install's model config and provider auth
|
|
29
|
+
* (auth.json symlink — OAuth tokens refresh in place, so every bot profile
|
|
30
|
+
* stays authenticated from one login). Idempotent.
|
|
31
|
+
*/
|
|
32
|
+
export declare function provisionHermesProfile(handle: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Hermes backend: per-bot profile (own soul + memories), one persistent
|
|
35
|
+
* session per channel (`--continue nopeek-<channel>`), streaming cleaned
|
|
36
|
+
* stdout lines to onChunk.
|
|
37
|
+
*/
|
|
38
|
+
export declare function hermesBrain(cfg: BridgeConfig): Brain;
|
package/dist/backends.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// Native brain backends — no external shell scripts required. Two batteries-
|
|
2
|
+
// included agent runtimes the bridge can drive directly:
|
|
3
|
+
//
|
|
4
|
+
// claude — Claude Code headless (`claude -p`), one continuous session per
|
|
5
|
+
// (bot, channel) via a deterministic session UUID, streaming
|
|
6
|
+
// assistant text deltas (stream-json) to onChunk.
|
|
7
|
+
// hermes — a per-bot Hermes profile (own soul + memories) under
|
|
8
|
+
// $HERMES_HOME/profiles/bot-<handle>, one persistent chat session
|
|
9
|
+
// per channel (--continue), streaming stdout lines to onChunk.
|
|
10
|
+
//
|
|
11
|
+
// Both are Brains: (text, ctx, onChunk?) => Promise<fullReply>. The returned
|
|
12
|
+
// string is authoritative for the final message body; onChunk deltas are
|
|
13
|
+
// display-only. Selected per bot via BRAIN_MAP entries like {"backend":"claude"}.
|
|
14
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { stripAnsi } from "./brain.js";
|
|
20
|
+
const BRAIN_UNREACHABLE = "⚠️ I couldn't reach my brain just now — please try again in a moment.";
|
|
21
|
+
// ------------------------------------------------------------------ souls ----
|
|
22
|
+
/** Built-in personality template — personalized per bot by provisionSoul(). */
|
|
23
|
+
const SOUL_TEMPLATE = `You are {{NAME}} (handle @{{HANDLE}}), a personal AI agent reachable through the
|
|
24
|
+
NoPeek end-to-end-encrypted messenger. A human messages you in a chat; you help;
|
|
25
|
+
you reply. Everything you output is sent verbatim as a chat message.
|
|
26
|
+
|
|
27
|
+
Voice: friendly, sharp, concise. This is a chat, not a report — short paragraphs
|
|
28
|
+
or brief lists, no markdown headers. Answer the question directly.
|
|
29
|
+
|
|
30
|
+
You have your own memory of past conversations in this chat (each chat is a
|
|
31
|
+
separate ongoing thread). Remember what the humans tell you.
|
|
32
|
+
|
|
33
|
+
Guardrails: never expose secrets or tokens; confirm before anything destructive
|
|
34
|
+
or outward-facing; report honestly when something failed; if a request is far
|
|
35
|
+
outside what you can do, say so briefly and offer what you can.
|
|
36
|
+
`;
|
|
37
|
+
export function soulsDir(homeDir) {
|
|
38
|
+
return join(homeDir, "agent-souls");
|
|
39
|
+
}
|
|
40
|
+
export function soulPath(homeDir, handle) {
|
|
41
|
+
return join(soulsDir(homeDir), `${handle.replace(/^@/, "")}.md`);
|
|
42
|
+
}
|
|
43
|
+
/** "weather_bot" -> "weather", "ops_helper" -> "ops helper". */
|
|
44
|
+
function displayName(handle) {
|
|
45
|
+
return handle.replace(/^@/, "").replace(/_bot$/, "").replace(/_/g, " ") || handle;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Write <home>/agent-souls/<handle>.md from the built-in template if absent
|
|
49
|
+
* (idempotent — an existing, possibly hand-edited soul is never overwritten).
|
|
50
|
+
* Returns the soul path.
|
|
51
|
+
*/
|
|
52
|
+
export function provisionSoul(handle, homeDir) {
|
|
53
|
+
const h = handle.replace(/^@/, "");
|
|
54
|
+
const path = soulPath(homeDir, h);
|
|
55
|
+
if (!existsSync(path)) {
|
|
56
|
+
mkdirSync(soulsDir(homeDir), { recursive: true });
|
|
57
|
+
const soul = SOUL_TEMPLATE.replace(/\{\{HANDLE\}\}/g, h).replace(/\{\{NAME\}\}/g, displayName(h));
|
|
58
|
+
writeFileSync(path, soul);
|
|
59
|
+
console.log(`[soul:@${h}] provisioned ${path}`);
|
|
60
|
+
}
|
|
61
|
+
return path;
|
|
62
|
+
}
|
|
63
|
+
// ----------------------------------------------------------------- helpers ----
|
|
64
|
+
/** Resolve a binary: $<ENVVAR> > PATH (command -v) > common install dirs. */
|
|
65
|
+
function resolveBin(name, envVar) {
|
|
66
|
+
const fromEnv = process.env[envVar];
|
|
67
|
+
if (fromEnv)
|
|
68
|
+
return fromEnv;
|
|
69
|
+
const r = spawnSync("bash", ["-lc", `command -v ${name}`], { encoding: "utf8" });
|
|
70
|
+
const found = (r.stdout || "").trim().split("\n")[0];
|
|
71
|
+
if (r.status === 0 && found)
|
|
72
|
+
return found;
|
|
73
|
+
for (const dir of [join(homedir(), ".local", "bin"), "/usr/local/bin", "/opt/homebrew/bin"]) {
|
|
74
|
+
const p = join(dir, name);
|
|
75
|
+
if (existsSync(p))
|
|
76
|
+
return p;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Deterministic per-(bot, channel) session UUID so each chat is one running
|
|
82
|
+
* Claude conversation. UUIDv5-ish (sha1 of the key formatted as a UUID) —
|
|
83
|
+
* byte-for-byte identical to tools/agent/bot-brain.sh so existing sessions
|
|
84
|
+
* keep their history when a bot moves from the shell brain to the native one.
|
|
85
|
+
*/
|
|
86
|
+
export function sessionUuid(handle, channelId) {
|
|
87
|
+
const h = createHash("sha1").update(`nopeek:${handle}:${channelId}`).digest("hex").slice(0, 32);
|
|
88
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-5${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
89
|
+
}
|
|
90
|
+
function runClaudeOnce(bin, args, text, cwd, timeoutMs, tag, onDelta) {
|
|
91
|
+
return new Promise((resolvePromise) => {
|
|
92
|
+
const child = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"], cwd });
|
|
93
|
+
let deltas = "";
|
|
94
|
+
let resultText = "";
|
|
95
|
+
let assistantText = "";
|
|
96
|
+
let lineBuf = "";
|
|
97
|
+
let stderr = "";
|
|
98
|
+
let settled = false;
|
|
99
|
+
const handleLine = (line) => {
|
|
100
|
+
if (!line.trim())
|
|
101
|
+
return;
|
|
102
|
+
let parsed;
|
|
103
|
+
try {
|
|
104
|
+
parsed = JSON.parse(line);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return; // non-JSON noise
|
|
108
|
+
}
|
|
109
|
+
if (parsed.type === "stream_event" &&
|
|
110
|
+
parsed.event?.type === "content_block_delta" &&
|
|
111
|
+
parsed.event.delta?.type === "text_delta" &&
|
|
112
|
+
typeof parsed.event.delta.text === "string") {
|
|
113
|
+
deltas += parsed.event.delta.text;
|
|
114
|
+
onDelta(parsed.event.delta.text);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// Fallbacks if no text_delta ever arrives (older CLIs / odd runs):
|
|
118
|
+
if (parsed.type === "result" && typeof parsed.result === "string") {
|
|
119
|
+
resultText = parsed.result;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (parsed.type === "assistant" && Array.isArray(parsed.message?.content)) {
|
|
123
|
+
const t = parsed.message.content
|
|
124
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
125
|
+
.map((b) => b.text)
|
|
126
|
+
.join("");
|
|
127
|
+
if (t)
|
|
128
|
+
assistantText = t;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const finish = (exitCode) => {
|
|
132
|
+
if (settled)
|
|
133
|
+
return;
|
|
134
|
+
settled = true;
|
|
135
|
+
if (lineBuf)
|
|
136
|
+
handleLine(lineBuf);
|
|
137
|
+
const reply = (deltas.trim() || resultText.trim() || assistantText.trim());
|
|
138
|
+
if (!reply && stderr.trim())
|
|
139
|
+
console.error(`${tag} stderr: ${stderr.slice(0, 1000)}`);
|
|
140
|
+
resolvePromise({ reply, exitCode });
|
|
141
|
+
};
|
|
142
|
+
const timer = setTimeout(() => {
|
|
143
|
+
console.error(`${tag} timed out after ${timeoutMs / 1000}s, killing`);
|
|
144
|
+
child.kill("SIGKILL");
|
|
145
|
+
finish(null);
|
|
146
|
+
}, timeoutMs);
|
|
147
|
+
child.stdout.on("data", (d) => {
|
|
148
|
+
lineBuf += d.toString();
|
|
149
|
+
const lines = lineBuf.split("\n");
|
|
150
|
+
lineBuf = lines.pop() ?? "";
|
|
151
|
+
for (const line of lines)
|
|
152
|
+
handleLine(line);
|
|
153
|
+
});
|
|
154
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
155
|
+
child.on("error", (err) => {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
console.error(`${tag} spawn error: ${err.message}`);
|
|
158
|
+
finish(null);
|
|
159
|
+
});
|
|
160
|
+
child.on("close", (code) => {
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
finish(code);
|
|
163
|
+
});
|
|
164
|
+
child.stdin.on("error", () => {
|
|
165
|
+
/* claude may exit before reading stdin — close handler settles */
|
|
166
|
+
});
|
|
167
|
+
child.stdin.write(text);
|
|
168
|
+
child.stdin.end();
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Claude Code backend: `claude -p` headless (reliable — no OAuth flakiness),
|
|
173
|
+
* one continuous session per (bot, channel). Streams assistant text deltas
|
|
174
|
+
* only. Runs in a dedicated empty cwd with --strict-mcp-config and a tool
|
|
175
|
+
* denylist, so a chat bot never inherits repo CLAUDE.md, MCP servers, or
|
|
176
|
+
* host powers from wherever the bridge happens to run.
|
|
177
|
+
*/
|
|
178
|
+
export function claudeBrain(cfg) {
|
|
179
|
+
return async (text, ctx, onChunk) => {
|
|
180
|
+
const bin = resolveBin("claude", "CLAUDE_BIN");
|
|
181
|
+
if (!bin) {
|
|
182
|
+
console.error(`[brain:claude:@${ctx.botHandle}] claude not found on PATH`);
|
|
183
|
+
return BRAIN_UNREACHABLE;
|
|
184
|
+
}
|
|
185
|
+
const handle = ctx.botHandle.replace(/^@/, "");
|
|
186
|
+
const soulFile = provisionSoul(handle, cfg.homeDir);
|
|
187
|
+
let soul = "";
|
|
188
|
+
try {
|
|
189
|
+
soul = readFileSync(soulFile, "utf8");
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
/* missing/unreadable soul — run with base behavior */
|
|
193
|
+
}
|
|
194
|
+
const sid = sessionUuid(handle, ctx.channelId);
|
|
195
|
+
const runDir = join(cfg.homeDir, "agent-run");
|
|
196
|
+
mkdirSync(runDir, { recursive: true });
|
|
197
|
+
const tag = `[brain:claude:@${handle}]`;
|
|
198
|
+
const baseArgs = [
|
|
199
|
+
"-p",
|
|
200
|
+
"--verbose", // required by -p + --output-format stream-json
|
|
201
|
+
"--append-system-prompt",
|
|
202
|
+
soul,
|
|
203
|
+
"--disallowedTools",
|
|
204
|
+
"Bash Edit Write NotebookEdit MultiEdit Task WebFetch",
|
|
205
|
+
"--strict-mcp-config", // never inherit the host's MCP servers
|
|
206
|
+
"--output-format",
|
|
207
|
+
"stream-json",
|
|
208
|
+
"--include-partial-messages",
|
|
209
|
+
];
|
|
210
|
+
// Only forward deltas from the attempt that actually produces text, so a
|
|
211
|
+
// failed --resume can't leak a half-answer before the retry streams.
|
|
212
|
+
let emitted = false;
|
|
213
|
+
const emit = (delta) => {
|
|
214
|
+
emitted = true;
|
|
215
|
+
onChunk?.(delta);
|
|
216
|
+
};
|
|
217
|
+
// Resume this chat's session; if it doesn't exist yet, create it with that id.
|
|
218
|
+
const first = await runClaudeOnce(bin, [...baseArgs, "--resume", sid], text, runDir, cfg.brainTimeoutMs, tag, emit);
|
|
219
|
+
if (first.reply)
|
|
220
|
+
return first.reply;
|
|
221
|
+
if (emitted) {
|
|
222
|
+
// Deltas reached the chat but the run died before a final — don't retry
|
|
223
|
+
// into a duplicate answer; tell the user honestly.
|
|
224
|
+
return BRAIN_UNREACHABLE;
|
|
225
|
+
}
|
|
226
|
+
console.error(`${tag} --resume ${sid} yielded nothing (exit ${first.exitCode}) — retrying with --session-id`);
|
|
227
|
+
const second = await runClaudeOnce(bin, [...baseArgs, "--session-id", sid], text, runDir, cfg.brainTimeoutMs, tag, emit);
|
|
228
|
+
if (second.reply)
|
|
229
|
+
return second.reply;
|
|
230
|
+
console.error(`${tag} both attempts yielded nothing (exit ${second.exitCode})`);
|
|
231
|
+
return BRAIN_UNREACHABLE;
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
// ----------------------------------------------------------------- hermes ----
|
|
235
|
+
function hermesHome() {
|
|
236
|
+
return process.env.HERMES_HOME || "/Volumes/x10drive/hermes";
|
|
237
|
+
}
|
|
238
|
+
/** Hermes profile-local soul (mirrors tools/hermes/SOUL-template.md). */
|
|
239
|
+
const HERMES_SOUL_TEMPLATE = `# {{NAME}} — Hermes profile for a NoPeek bot
|
|
240
|
+
|
|
241
|
+
You are **{{NAME}}** (handle \`@{{HANDLE}}\`), a personal AI agent reachable through the
|
|
242
|
+
**NoPeek** end-to-end-encrypted messenger. A human messages you in the app; you help;
|
|
243
|
+
you reply. Your replies are chat messages — every word you print is sent verbatim.
|
|
244
|
+
|
|
245
|
+
## Voice
|
|
246
|
+
- Friendly, sharp, practical. Concise by default — this is a chat, not a report.
|
|
247
|
+
- Plain language. No markdown headers in replies; short paragraphs or brief lists only.
|
|
248
|
+
|
|
249
|
+
## Memory
|
|
250
|
+
- This profile is yours alone: your soul (this file) and your memories
|
|
251
|
+
(\`memories/\` in this profile) belong to \`@{{HANDLE}}\` and no other bot.
|
|
252
|
+
- Remember durable facts the humans tell you; forget nothing they'd expect you to keep.
|
|
253
|
+
|
|
254
|
+
## Guardrails
|
|
255
|
+
- Never expose secrets, keys, or tokens.
|
|
256
|
+
- Confirm before destructive or outward-facing actions.
|
|
257
|
+
- Report honestly — if something failed, say so.
|
|
258
|
+
- If a request is far outside your purpose, say so briefly and offer what you can do.
|
|
259
|
+
`;
|
|
260
|
+
/**
|
|
261
|
+
* Create (or reuse) the bot's isolated Hermes profile: its own SOUL.md and
|
|
262
|
+
* memories/, inheriting the main install's model config and provider auth
|
|
263
|
+
* (auth.json symlink — OAuth tokens refresh in place, so every bot profile
|
|
264
|
+
* stays authenticated from one login). Idempotent.
|
|
265
|
+
*/
|
|
266
|
+
export function provisionHermesProfile(handle) {
|
|
267
|
+
const h = handle.replace(/^@/, "");
|
|
268
|
+
const home = hermesHome();
|
|
269
|
+
const profile = `bot-${h}`;
|
|
270
|
+
const pdir = join(home, "profiles", profile);
|
|
271
|
+
if (!existsSync(pdir)) {
|
|
272
|
+
mkdirSync(join(pdir, "memories"), { recursive: true });
|
|
273
|
+
mkdirSync(join(pdir, "home"), { recursive: true });
|
|
274
|
+
const soul = HERMES_SOUL_TEMPLATE.replace(/\{\{HANDLE\}\}/g, h).replace(/\{\{NAME\}\}/g, displayName(h));
|
|
275
|
+
writeFileSync(join(pdir, "SOUL.md"), soul);
|
|
276
|
+
writeFileSync(join(pdir, "memories", "MEMORY.md"), "");
|
|
277
|
+
const mainConfig = join(home, "config.yaml");
|
|
278
|
+
if (existsSync(mainConfig))
|
|
279
|
+
copyFileSync(mainConfig, join(pdir, "config.yaml"));
|
|
280
|
+
const mainAuth = join(home, "auth.json");
|
|
281
|
+
if (existsSync(mainAuth) && !existsSync(join(pdir, "auth.json"))) {
|
|
282
|
+
try {
|
|
283
|
+
symlinkSync(mainAuth, join(pdir, "auth.json"));
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
/* best-effort — hermes still runs, just unauthenticated */
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
console.log(`[hermes:@${h}] provisioned profile ${pdir}`);
|
|
290
|
+
}
|
|
291
|
+
return profile;
|
|
292
|
+
}
|
|
293
|
+
// Chat chrome Hermes prints even in -Q mode: ruler lines, the echoed prompt
|
|
294
|
+
// bubble (● …), the goodbye line, and session-info footers.
|
|
295
|
+
const HERMES_CHROME = [/^[─━—-]{4,}\s*─*$/, /^●/, /^Goodbye!/, /^session( id)?:/i];
|
|
296
|
+
function isHermesChrome(line) {
|
|
297
|
+
return HERMES_CHROME.some((re) => re.test(line));
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Hermes backend: per-bot profile (own soul + memories), one persistent
|
|
301
|
+
* session per channel (`--continue nopeek-<channel>`), streaming cleaned
|
|
302
|
+
* stdout lines to onChunk.
|
|
303
|
+
*/
|
|
304
|
+
export function hermesBrain(cfg) {
|
|
305
|
+
return (text, ctx, onChunk) => new Promise((resolvePromise) => {
|
|
306
|
+
const handle = ctx.botHandle.replace(/^@/, "");
|
|
307
|
+
const tag = `[brain:hermes:@${handle}]`;
|
|
308
|
+
const bin = resolveBin("hermes", "HERMES_BIN");
|
|
309
|
+
if (!bin) {
|
|
310
|
+
console.error(`${tag} hermes not found on PATH`);
|
|
311
|
+
resolvePromise(BRAIN_UNREACHABLE);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
let profile;
|
|
315
|
+
try {
|
|
316
|
+
profile = provisionHermesProfile(handle);
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
console.error(`${tag} profile provisioning failed: ${err.message}`);
|
|
320
|
+
resolvePromise(BRAIN_UNREACHABLE);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const child = spawn(bin, ["--profile", profile, "chat", "-Q", "--continue", `nopeek-${ctx.channelId}`, "-q", text], {
|
|
324
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
325
|
+
// Hermes keys everything off $HOME; point it at the Hermes install.
|
|
326
|
+
env: { ...process.env, HOME: hermesHome() },
|
|
327
|
+
});
|
|
328
|
+
let out = "";
|
|
329
|
+
let lineBuf = "";
|
|
330
|
+
let sawContent = false; // suppress leading blank lines
|
|
331
|
+
let stderr = "";
|
|
332
|
+
let settled = false;
|
|
333
|
+
const handleLine = (raw) => {
|
|
334
|
+
const line = stripAnsi(raw);
|
|
335
|
+
if (isHermesChrome(line))
|
|
336
|
+
return;
|
|
337
|
+
if (!sawContent && !line.trim())
|
|
338
|
+
return;
|
|
339
|
+
sawContent = true;
|
|
340
|
+
out += `${line}\n`;
|
|
341
|
+
onChunk?.(`${line}\n`);
|
|
342
|
+
};
|
|
343
|
+
const finish = () => {
|
|
344
|
+
if (settled)
|
|
345
|
+
return;
|
|
346
|
+
settled = true;
|
|
347
|
+
if (lineBuf)
|
|
348
|
+
handleLine(lineBuf);
|
|
349
|
+
const reply = out.trim();
|
|
350
|
+
if (!reply) {
|
|
351
|
+
if (stderr.trim())
|
|
352
|
+
console.error(`${tag} stderr: ${stderr.slice(0, 1000)}`);
|
|
353
|
+
resolvePromise("⚠️ My brain isn't reachable right now — Hermes has no authenticated provider on the host. Run 'hermes model' there, then message me again.");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
resolvePromise(reply);
|
|
357
|
+
};
|
|
358
|
+
const timer = setTimeout(() => {
|
|
359
|
+
console.error(`${tag} timed out after ${cfg.brainTimeoutMs / 1000}s, killing`);
|
|
360
|
+
child.kill("SIGKILL");
|
|
361
|
+
finish();
|
|
362
|
+
}, cfg.brainTimeoutMs);
|
|
363
|
+
child.stdout.on("data", (d) => {
|
|
364
|
+
lineBuf += d.toString();
|
|
365
|
+
const lines = lineBuf.split("\n");
|
|
366
|
+
lineBuf = lines.pop() ?? "";
|
|
367
|
+
for (const line of lines)
|
|
368
|
+
handleLine(line);
|
|
369
|
+
});
|
|
370
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
371
|
+
child.on("error", (err) => {
|
|
372
|
+
clearTimeout(timer);
|
|
373
|
+
console.error(`${tag} spawn error: ${err.message}`);
|
|
374
|
+
if (!settled) {
|
|
375
|
+
settled = true;
|
|
376
|
+
resolvePromise(BRAIN_UNREACHABLE);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
child.on("close", () => {
|
|
380
|
+
clearTimeout(timer);
|
|
381
|
+
finish();
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
}
|
package/dist/bot.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// for decrypted messages and answers through the resolved brain. Failures are
|
|
4
4
|
// isolated — a broken bot retries with backoff and never takes down its peers.
|
|
5
5
|
import { NoPeek } from "@nopeek/chat";
|
|
6
|
-
import { resolveBrain } from "./brain.js";
|
|
6
|
+
import { FALLBACK_REPLY, resolveBrain } from "./brain.js";
|
|
7
7
|
import { FileStore } from "./storage.js";
|
|
8
8
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
9
9
|
const MAX_BACKOFF_MS = 60_000;
|
|
@@ -254,6 +254,29 @@ export class BotRunner {
|
|
|
254
254
|
// local API (PUT /brains) and the very next message uses the new one.
|
|
255
255
|
const resolved = resolveBrain(this.cfg, this.info.handle);
|
|
256
256
|
this.brainKind = resolved.kind;
|
|
257
|
+
// Streaming: if the brain emits chunks, open a streaming message on the
|
|
258
|
+
// FIRST chunk and forward deltas into it (typing indicator stays on until
|
|
259
|
+
// then). Deltas can arrive before the placeholder lands — chaining every
|
|
260
|
+
// append onto the open promise keeps them ordered and loses none.
|
|
261
|
+
// (Ref object rather than a `let`: TS can't see closure assignments.)
|
|
262
|
+
const streamRef = { p: null };
|
|
263
|
+
const onChunk = (delta) => {
|
|
264
|
+
if (!delta)
|
|
265
|
+
return;
|
|
266
|
+
if (!streamRef.p) {
|
|
267
|
+
try {
|
|
268
|
+
ch.typing(false); // the live bubble replaces the typing indicator
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
/* best-effort */
|
|
272
|
+
}
|
|
273
|
+
streamRef.p = ch.stream();
|
|
274
|
+
streamRef.p.catch((err) => {
|
|
275
|
+
this.logErr(`stream open failed (falling back to a single send): ${err.message}`);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
streamRef.p.then((s) => s.append(delta)).catch(() => { });
|
|
279
|
+
};
|
|
257
280
|
let reply = "";
|
|
258
281
|
try {
|
|
259
282
|
reply = await resolved.brain(text, {
|
|
@@ -261,7 +284,15 @@ export class BotRunner {
|
|
|
261
284
|
botUserId: this.info.userId,
|
|
262
285
|
channelId: m.channelId,
|
|
263
286
|
senderUserId: m.senderUserId,
|
|
264
|
-
});
|
|
287
|
+
}, onChunk);
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
// Brain blew up mid-stream: finalize the partial bubble with an honest
|
|
291
|
+
// error line instead of leaving a forever-blinking cursor.
|
|
292
|
+
const stream = streamRef.p ? await streamRef.p.catch(() => null) : null;
|
|
293
|
+
if (stream)
|
|
294
|
+
await stream.fail(FALLBACK_REPLY).catch(() => { });
|
|
295
|
+
throw err;
|
|
265
296
|
}
|
|
266
297
|
finally {
|
|
267
298
|
try {
|
|
@@ -271,6 +302,13 @@ export class BotRunner {
|
|
|
271
302
|
/* best-effort */
|
|
272
303
|
}
|
|
273
304
|
}
|
|
305
|
+
const stream = streamRef.p ? await streamRef.p.catch(() => null) : null;
|
|
306
|
+
if (stream) {
|
|
307
|
+
await stream.done(reply.trim() || undefined);
|
|
308
|
+
this.handled++;
|
|
309
|
+
this.log(`${m.channelId} -> streamed reply (${reply.trim().length} chars, handled=${this.handled})`);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
274
312
|
if (!reply || !reply.trim()) {
|
|
275
313
|
this.log(`brain returned empty reply — ignoring`);
|
|
276
314
|
return;
|
package/dist/brain.d.ts
CHANGED
|
@@ -5,7 +5,12 @@ export interface BrainContext {
|
|
|
5
5
|
channelId: string;
|
|
6
6
|
senderUserId: string;
|
|
7
7
|
}
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* A brain answers one message. If it can stream, it calls `onChunk(delta)` as
|
|
10
|
+
* text arrives (plain text, ANSI-stripped) and STILL returns the full reply —
|
|
11
|
+
* the returned string is authoritative for the final message body.
|
|
12
|
+
*/
|
|
13
|
+
export type Brain = (text: string, ctx: BrainContext, onChunk?: (delta: string) => void) => Promise<string>;
|
|
9
14
|
export declare const FALLBACK_REPLY = "Sorry \u2014 I hit an error processing that. Please try again.";
|
|
10
15
|
export declare function stripAnsi(s: string): string;
|
|
11
16
|
export interface ResolvedBrain {
|
|
@@ -14,7 +19,8 @@ export interface ResolvedBrain {
|
|
|
14
19
|
kind: string;
|
|
15
20
|
}
|
|
16
21
|
/**
|
|
17
|
-
* Pick the brain for one bot:
|
|
18
|
-
*
|
|
22
|
+
* Pick the brain for one bot. Per-bot precedence within a BRAIN_MAP entry:
|
|
23
|
+
* cmd > url > backend (native claude/hermes/echo) > echo; then the globals:
|
|
24
|
+
* BRAIN_CMD > BRAIN_URL > echo.
|
|
19
25
|
*/
|
|
20
26
|
export declare function resolveBrain(cfg: BridgeConfig, handle: string): ResolvedBrain;
|
package/dist/brain.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// This is how ANY agentic runtime plugs in (Hermes, OpenClaw, a curl to your
|
|
7
7
|
// own service): the bridge never knows or cares what's on the other side.
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
|
+
import { claudeBrain, hermesBrain } from "./backends.js";
|
|
9
10
|
export const FALLBACK_REPLY = "Sorry — I hit an error processing that. Please try again.";
|
|
10
11
|
// ANSI escape sequences (CSI, OSC, and lone ESC controls). Agent runtimes like
|
|
11
12
|
// Hermes color their stdout; the chat must receive plain text.
|
|
@@ -22,7 +23,7 @@ export function stripAnsi(s) {
|
|
|
22
23
|
* NOPEEK_BOT_HANDLE, NOPEEK_BOT_USER_ID, NOPEEK_CHANNEL_ID, NOPEEK_SENDER_USER_ID.
|
|
23
24
|
*/
|
|
24
25
|
function cmdBrain(cmd, timeoutMs) {
|
|
25
|
-
return (text, ctx) => new Promise((resolvePromise) => {
|
|
26
|
+
return (text, ctx, onChunk) => new Promise((resolvePromise) => {
|
|
26
27
|
const child = spawn("bash", ["-c", cmd], {
|
|
27
28
|
stdio: ["pipe", "pipe", "pipe"],
|
|
28
29
|
env: {
|
|
@@ -47,7 +48,18 @@ function cmdBrain(cmd, timeoutMs) {
|
|
|
47
48
|
child.kill("SIGKILL");
|
|
48
49
|
finish(stripAnsi(stdout).trim() || FALLBACK_REPLY);
|
|
49
50
|
}, timeoutMs);
|
|
50
|
-
child.stdout.on("data", (d) =>
|
|
51
|
+
child.stdout.on("data", (d) => {
|
|
52
|
+
const chunk = d.toString();
|
|
53
|
+
stdout += chunk;
|
|
54
|
+
if (onChunk) {
|
|
55
|
+
// Stream deltas as they arrive, ANSI-stripped. (A sequence split
|
|
56
|
+
// across chunk boundaries can slip through; the final reply is
|
|
57
|
+
// stripped over the WHOLE buffer, so the finalized text is clean.)
|
|
58
|
+
const clean = stripAnsi(chunk);
|
|
59
|
+
if (clean)
|
|
60
|
+
onChunk(clean);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
51
63
|
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
52
64
|
child.on("error", (err) => {
|
|
53
65
|
clearTimeout(timer);
|
|
@@ -116,8 +128,9 @@ function urlBrain(url, timeoutMs) {
|
|
|
116
128
|
/** Zero-config smoke test. */
|
|
117
129
|
const echoBrain = async (text) => `You said: ${text}`;
|
|
118
130
|
/**
|
|
119
|
-
* Pick the brain for one bot:
|
|
120
|
-
*
|
|
131
|
+
* Pick the brain for one bot. Per-bot precedence within a BRAIN_MAP entry:
|
|
132
|
+
* cmd > url > backend (native claude/hermes/echo) > echo; then the globals:
|
|
133
|
+
* BRAIN_CMD > BRAIN_URL > echo.
|
|
121
134
|
*/
|
|
122
135
|
export function resolveBrain(cfg, handle) {
|
|
123
136
|
const override = cfg.brainMap[handle.replace(/^@/, "")];
|
|
@@ -125,7 +138,11 @@ export function resolveBrain(cfg, handle) {
|
|
|
125
138
|
return { brain: cmdBrain(override.cmd, cfg.brainTimeoutMs), kind: "cmd (per-bot)" };
|
|
126
139
|
if (override?.url)
|
|
127
140
|
return { brain: urlBrain(override.url, cfg.brainTimeoutMs), kind: "url (per-bot)" };
|
|
128
|
-
if (override?.
|
|
141
|
+
if (override?.backend === "claude")
|
|
142
|
+
return { brain: claudeBrain(cfg), kind: "claude (per-bot)" };
|
|
143
|
+
if (override?.backend === "hermes")
|
|
144
|
+
return { brain: hermesBrain(cfg), kind: "hermes (per-bot)" };
|
|
145
|
+
if (override?.backend === "echo" || override?.echo)
|
|
129
146
|
return { brain: echoBrain, kind: "echo (per-bot)" };
|
|
130
147
|
if (cfg.brainCmd)
|
|
131
148
|
return { brain: cmdBrain(cfg.brainCmd, cfg.brainTimeoutMs), kind: "cmd" };
|
package/dist/bridge.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { BridgeConfig, BrainSpec } from "./config.js";
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.4.1";
|
|
3
3
|
export interface PairRequest {
|
|
4
4
|
pairingSecret: string;
|
|
5
5
|
appId: string;
|
|
@@ -12,7 +12,8 @@ export interface BrainsPatch {
|
|
|
12
12
|
brainUrl?: string | null;
|
|
13
13
|
/** Auto-provisioner command for newly adopted bots; null clears it. */
|
|
14
14
|
brainProvisionCmd?: string | null;
|
|
15
|
-
/** Per-handle entries
|
|
15
|
+
/** Per-handle entries — {"cmd"}, {"url"}, {"backend":"claude"|"hermes"|"echo"}
|
|
16
|
+
* or {"echo":true}; a null value removes that bot's override. */
|
|
16
17
|
map?: Record<string, BrainSpec | null>;
|
|
17
18
|
}
|
|
18
19
|
export declare class PairError extends Error {
|
package/dist/bridge.js
CHANGED
|
@@ -11,7 +11,9 @@ import { saveSettings } from "./config.js";
|
|
|
11
11
|
import { BotRunner } from "./bot.js";
|
|
12
12
|
import { ControlSocket } from "./control.js";
|
|
13
13
|
import { resolveBrain } from "./brain.js";
|
|
14
|
-
|
|
14
|
+
import { provisionSoul } from "./backends.js";
|
|
15
|
+
import { isBrainBackend } from "./config.js";
|
|
16
|
+
export const VERSION = "0.4.1";
|
|
15
17
|
export class PairError extends Error {
|
|
16
18
|
code;
|
|
17
19
|
constructor(code, message) {
|
|
@@ -114,6 +116,19 @@ export class BridgeApp {
|
|
|
114
116
|
else if (typeof spec.url === "string" && spec.url.trim()) {
|
|
115
117
|
this.cfg.brainMap[handle] = { url: spec.url.trim() };
|
|
116
118
|
}
|
|
119
|
+
else if (isBrainBackend(spec.backend)) {
|
|
120
|
+
this.cfg.brainMap[handle] = { backend: spec.backend };
|
|
121
|
+
// Native backends personify through a soul file — make sure it
|
|
122
|
+
// exists the moment the backend is chosen (idempotent).
|
|
123
|
+
if (spec.backend !== "echo") {
|
|
124
|
+
try {
|
|
125
|
+
provisionSoul(handle, this.cfg.homeDir);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
console.error(`[bridge] soul provisioning for @${handle} failed: ${err.message}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
117
132
|
else if (spec.echo === true) {
|
|
118
133
|
this.cfg.brainMap[handle] = { echo: true };
|
|
119
134
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
+
/** Built-in native backends (see backends.ts): claude = Claude Code headless,
|
|
2
|
+
* hermes = per-bot Hermes profile, echo = smoke test. */
|
|
3
|
+
export type BrainBackend = "claude" | "hermes" | "echo";
|
|
1
4
|
/** Per-bot brain override, keyed by bot handle in BRAIN_MAP.
|
|
5
|
+
* `backend` selects a native built-in (claude/hermes/echo);
|
|
2
6
|
* `echo: true` pins the bot to echo mode even when a global brain is set. */
|
|
3
7
|
export interface BrainSpec {
|
|
4
8
|
cmd?: string;
|
|
5
9
|
url?: string;
|
|
10
|
+
backend?: BrainBackend;
|
|
6
11
|
echo?: boolean;
|
|
7
12
|
}
|
|
13
|
+
export declare function isBrainBackend(v: unknown): v is BrainBackend;
|
|
8
14
|
export interface BridgeConfig {
|
|
9
15
|
/** NoPeek API base, e.g. https://d3qweh72vesa98.cloudfront.net */
|
|
10
16
|
apiUrl: string;
|
|
@@ -43,7 +49,7 @@ export declare const DEFAULT_APP_URL = "https://d32w3to73s0pu5.cloudfront.net/me
|
|
|
43
49
|
export declare const DEFAULT_PORT = 8790;
|
|
44
50
|
export declare const DEFAULT_BRAIN_TIMEOUT_MS = 180000;
|
|
45
51
|
export declare function defaultHomeDir(): string;
|
|
46
|
-
export declare const HELP = "nopeek-agent-bridge \u2014 run your agents as E2EE NoPeek bots\n\nUsage:\n nopeek-agent-bridge install Install as a background service (launchd/systemd),\n then finish setup from the NoPeek app:\n Bots -> Connect this computer.\n nopeek-agent-bridge uninstall Remove the background service (keeps data/settings).\n nopeek-agent-bridge status Show the running bridge's status.\n nopeek-agent-bridge [run] Run in the foreground. Unpaired bridges wait to be\n paired from the NoPeek app; --pair still works:\n npx @nopeek/agent-bridge --pair npr_\u2026 --app-id app_\u2026\n\nOptions:\n --pair <code> Pairing code from the NoPeek app (env NOPEEK_PAIRING_CODE)\n --app-id <id> NoPeek app id (env NOPEEK_APP_ID)\n --api-url <url> API base, default https://d3qweh72vesa98.cloudfront.net (env NOPEEK_API_URL)\n --brain-cmd <cmd> Shell brain: message on stdin -> reply on stdout (env BRAIN_CMD)\n --brain-url <url> Webhook brain: POST {text,...} -> {text|reply} (env BRAIN_URL)\n --brain-map <json> Per-bot overrides {\"<handle>\":{\"cmd\":\"\u2026\"}|{\"url\":\"\u2026\"}}
|
|
52
|
+
export declare const HELP = "nopeek-agent-bridge \u2014 run your agents as E2EE NoPeek bots\n\nUsage:\n nopeek-agent-bridge install Install as a background service (launchd/systemd),\n then finish setup from the NoPeek app:\n Bots -> Connect this computer.\n nopeek-agent-bridge uninstall Remove the background service (keeps data/settings).\n nopeek-agent-bridge status Show the running bridge's status.\n nopeek-agent-bridge [run] Run in the foreground. Unpaired bridges wait to be\n paired from the NoPeek app; --pair still works:\n npx @nopeek/agent-bridge --pair npr_\u2026 --app-id app_\u2026\n\nOptions:\n --pair <code> Pairing code from the NoPeek app (env NOPEEK_PAIRING_CODE)\n --app-id <id> NoPeek app id (env NOPEEK_APP_ID)\n --api-url <url> API base, default https://d3qweh72vesa98.cloudfront.net (env NOPEEK_API_URL)\n --brain-cmd <cmd> Shell brain: message on stdin -> reply on stdout (env BRAIN_CMD)\n --brain-url <url> Webhook brain: POST {text,...} -> {text|reply} (env BRAIN_URL)\n --brain-map <json> Per-bot overrides {\"<handle>\":{\"cmd\":\"\u2026\"}|{\"url\":\"\u2026\"}|\n {\"backend\":\"claude\"|\"hermes\"|\"echo\"}} (env BRAIN_MAP)\n --brain-provision-cmd <cmd> Run once per new bot; stdout becomes its brain command\n (env BRAIN_PROVISION_CMD \u2014 e.g. a script that creates a\n fresh Hermes profile with its own soul + memory)\n --app-url <url> App opened after install (env NOPEEK_APP_URL)\n --no-open install: don't open the app in the browser\n --brain-timeout-ms <ms> Brain timeout, default 180000 (env BRAIN_TIMEOUT_MS)\n --port <port> Local control API port, default 8790 (env NOPEEK_BRIDGE_PORT)\n --data-dir <dir> Device-key store dir (env NOPEEK_BRIDGE_DATA_DIR)\n --home <dir> Bridge home, default ~/.nopeek-bridge (env NOPEEK_BRIDGE_HOME)\n --config <path> Config file, default ./nopeek-bridge.config.json\n -h, --help Show this help\n\nWith no brain configured, bots run in echo mode (\"You said: \u2026\") \u2014 a zero-config smoke test.\nPairing and brains can be managed entirely from the NoPeek app once the service is running.";
|
|
47
53
|
/** Load config from argv + env + cwd config file + home settings. Never
|
|
48
54
|
* requires pairing — an unpaired bridge waits for the app to pair it. */
|
|
49
55
|
export declare function loadConfig(argv?: string[]): BridgeConfig;
|
package/dist/config.js
CHANGED
|
@@ -9,6 +9,9 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "
|
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { join, resolve } from "node:path";
|
|
11
11
|
import { parseArgs } from "node:util";
|
|
12
|
+
export function isBrainBackend(v) {
|
|
13
|
+
return v === "claude" || v === "hermes" || v === "echo";
|
|
14
|
+
}
|
|
12
15
|
export const DEFAULT_API_URL = "https://d3qweh72vesa98.cloudfront.net";
|
|
13
16
|
export const DEFAULT_APP_URL = "https://d32w3to73s0pu5.cloudfront.net/messenger/";
|
|
14
17
|
export const DEFAULT_PORT = 8790;
|
|
@@ -68,7 +71,8 @@ Options:
|
|
|
68
71
|
--api-url <url> API base, default ${DEFAULT_API_URL} (env NOPEEK_API_URL)
|
|
69
72
|
--brain-cmd <cmd> Shell brain: message on stdin -> reply on stdout (env BRAIN_CMD)
|
|
70
73
|
--brain-url <url> Webhook brain: POST {text,...} -> {text|reply} (env BRAIN_URL)
|
|
71
|
-
--brain-map <json> Per-bot overrides {"<handle>":{"cmd":"…"}|{"url":"…"}
|
|
74
|
+
--brain-map <json> Per-bot overrides {"<handle>":{"cmd":"…"}|{"url":"…"}|
|
|
75
|
+
{"backend":"claude"|"hermes"|"echo"}} (env BRAIN_MAP)
|
|
72
76
|
--brain-provision-cmd <cmd> Run once per new bot; stdout becomes its brain command
|
|
73
77
|
(env BRAIN_PROVISION_CMD — e.g. a script that creates a
|
|
74
78
|
fresh Hermes profile with its own soul + memory)
|
|
@@ -121,13 +125,18 @@ function parseBrainMap(raw) {
|
|
|
121
125
|
if (typeof spec !== "object" || spec === null) {
|
|
122
126
|
throw new Error(`BRAIN_MAP["${handle}"] must be {"cmd":"…"} or {"url":"…"}`);
|
|
123
127
|
}
|
|
124
|
-
const { cmd, url } = spec;
|
|
125
|
-
if (
|
|
126
|
-
throw new Error(`BRAIN_MAP["${handle}"]
|
|
128
|
+
const { cmd, url, backend, echo } = spec;
|
|
129
|
+
if (backend !== undefined && !isBrainBackend(backend)) {
|
|
130
|
+
throw new Error(`BRAIN_MAP["${handle}"].backend must be "claude", "hermes" or "echo"`);
|
|
131
|
+
}
|
|
132
|
+
if (typeof cmd !== "string" && typeof url !== "string" && !isBrainBackend(backend) && echo !== true) {
|
|
133
|
+
throw new Error(`BRAIN_MAP["${handle}"] needs a "cmd", "url" or "backend" ("claude"|"hermes"|"echo")`);
|
|
127
134
|
}
|
|
128
135
|
out[handle.replace(/^@/, "")] = {
|
|
129
136
|
...(typeof cmd === "string" ? { cmd } : {}),
|
|
130
137
|
...(typeof url === "string" ? { url } : {}),
|
|
138
|
+
...(isBrainBackend(backend) ? { backend } : {}),
|
|
139
|
+
...(echo === true ? { echo: true } : {}),
|
|
131
140
|
};
|
|
132
141
|
}
|
|
133
142
|
return out;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nopeek/agent-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Run your own agents as E2EE NoPeek bots. Pairs with a one-time code, runs every bot you own, and pipes messages to any command or webhook.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"node": ">=22"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@nopeek/chat": "0.
|
|
26
|
+
"@nopeek/chat": "0.2.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^22.10.0",
|