@nopeek/agent-bridge 0.2.1 → 0.4.0
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 +13 -2
- package/dist/bridge.js +73 -1
- package/dist/cli.js +1 -1
- package/dist/config.d.ts +17 -1
- package/dist/config.js +27 -4
- package/dist/service.d.ts +1 -1
- package/dist/service.js +14 -2
- package/package.json +1 -1
|
@@ -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.0";
|
|
3
3
|
export interface PairRequest {
|
|
4
4
|
pairingSecret: string;
|
|
5
5
|
appId: string;
|
|
@@ -10,7 +10,10 @@ export interface BrainsPatch {
|
|
|
10
10
|
brainCmd?: string | null;
|
|
11
11
|
/** Global webhook brain; null clears it. */
|
|
12
12
|
brainUrl?: string | null;
|
|
13
|
-
/**
|
|
13
|
+
/** Auto-provisioner command for newly adopted bots; null clears it. */
|
|
14
|
+
brainProvisionCmd?: string | null;
|
|
15
|
+
/** Per-handle entries — {"cmd"}, {"url"}, {"backend":"claude"|"hermes"|"echo"}
|
|
16
|
+
* or {"echo":true}; a null value removes that bot's override. */
|
|
14
17
|
map?: Record<string, BrainSpec | null>;
|
|
15
18
|
}
|
|
16
19
|
export declare class PairError extends Error {
|
|
@@ -43,6 +46,14 @@ export declare class BridgeApp {
|
|
|
43
46
|
statusMinimal(): Record<string, unknown>;
|
|
44
47
|
statusFull(): Record<string, unknown>;
|
|
45
48
|
private startBot;
|
|
49
|
+
private provisioning;
|
|
50
|
+
/**
|
|
51
|
+
* Auto-provision a brain for a newly adopted bot: run BRAIN_PROVISION_CMD
|
|
52
|
+
* (e.g. "create a Hermes profile with its own soul + memory for this handle")
|
|
53
|
+
* and store its stdout as the bot's brain command. Best-effort — on any
|
|
54
|
+
* failure the bot simply keeps the default brain.
|
|
55
|
+
*/
|
|
56
|
+
private provisionBrain;
|
|
46
57
|
/** Fetch the authoritative bot list and start anything we're missing. */
|
|
47
58
|
private syncBots;
|
|
48
59
|
private startCore;
|
package/dist/bridge.js
CHANGED
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
// Pairing, unpairing and brain config all happen at runtime (from the app) and
|
|
7
7
|
// persist to <home>/settings.json — no restart, no terminal.
|
|
8
8
|
import { hostname } from "node:os";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
9
10
|
import { saveSettings } from "./config.js";
|
|
10
11
|
import { BotRunner } from "./bot.js";
|
|
11
12
|
import { ControlSocket } from "./control.js";
|
|
12
13
|
import { resolveBrain } from "./brain.js";
|
|
13
|
-
|
|
14
|
+
import { provisionSoul } from "./backends.js";
|
|
15
|
+
import { isBrainBackend } from "./config.js";
|
|
16
|
+
export const VERSION = "0.4.0";
|
|
14
17
|
export class PairError extends Error {
|
|
15
18
|
code;
|
|
16
19
|
constructor(code, message) {
|
|
@@ -99,6 +102,8 @@ export class BridgeApp {
|
|
|
99
102
|
this.cfg.brainCmd = patch.brainCmd || null;
|
|
100
103
|
if (patch.brainUrl !== undefined)
|
|
101
104
|
this.cfg.brainUrl = patch.brainUrl || null;
|
|
105
|
+
if (patch.brainProvisionCmd !== undefined)
|
|
106
|
+
this.cfg.brainProvisionCmd = patch.brainProvisionCmd || null;
|
|
102
107
|
if (patch.map) {
|
|
103
108
|
for (const [rawHandle, spec] of Object.entries(patch.map)) {
|
|
104
109
|
const handle = rawHandle.replace(/^@/, "");
|
|
@@ -111,6 +116,19 @@ export class BridgeApp {
|
|
|
111
116
|
else if (typeof spec.url === "string" && spec.url.trim()) {
|
|
112
117
|
this.cfg.brainMap[handle] = { url: spec.url.trim() };
|
|
113
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
|
+
}
|
|
114
132
|
else if (spec.echo === true) {
|
|
115
133
|
this.cfg.brainMap[handle] = { echo: true };
|
|
116
134
|
}
|
|
@@ -146,6 +164,7 @@ export class BridgeApp {
|
|
|
146
164
|
? { url: this.cfg.brainUrl }
|
|
147
165
|
: { echo: true },
|
|
148
166
|
map: this.cfg.brainMap,
|
|
167
|
+
provisionCmd: this.cfg.brainProvisionCmd,
|
|
149
168
|
},
|
|
150
169
|
bots: [...this.bots.values()].map((b) => ({
|
|
151
170
|
handle: b.info.handle,
|
|
@@ -163,6 +182,59 @@ export class BridgeApp {
|
|
|
163
182
|
const runner = new BotRunner(info, this.cfg);
|
|
164
183
|
this.bots.set(info.userId, runner);
|
|
165
184
|
runner.start(); // background; failures are isolated inside the runner
|
|
185
|
+
void this.provisionBrain(info); // background; bot echoes until it lands
|
|
186
|
+
}
|
|
187
|
+
// A handle is provisioned at most once per process; the persisted BRAIN_MAP
|
|
188
|
+
// entry prevents re-provisioning across restarts.
|
|
189
|
+
provisioning = new Set();
|
|
190
|
+
/**
|
|
191
|
+
* Auto-provision a brain for a newly adopted bot: run BRAIN_PROVISION_CMD
|
|
192
|
+
* (e.g. "create a Hermes profile with its own soul + memory for this handle")
|
|
193
|
+
* and store its stdout as the bot's brain command. Best-effort — on any
|
|
194
|
+
* failure the bot simply keeps the default brain.
|
|
195
|
+
*/
|
|
196
|
+
async provisionBrain(info) {
|
|
197
|
+
const cmd = this.cfg.brainProvisionCmd;
|
|
198
|
+
const handle = info.handle.replace(/^@/, "");
|
|
199
|
+
if (!cmd || this.cfg.brainMap[handle] || this.provisioning.has(handle))
|
|
200
|
+
return;
|
|
201
|
+
this.provisioning.add(handle);
|
|
202
|
+
console.log(`[provision:@${handle}] running brain provisioner`);
|
|
203
|
+
const out = await new Promise((resolvePromise) => {
|
|
204
|
+
const child = spawn("bash", ["-c", cmd], {
|
|
205
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
206
|
+
env: { ...process.env, NOPEEK_BOT_HANDLE: handle, NOPEEK_BOT_USER_ID: info.userId },
|
|
207
|
+
});
|
|
208
|
+
let stdout = "";
|
|
209
|
+
let stderr = "";
|
|
210
|
+
const timer = setTimeout(() => {
|
|
211
|
+
child.kill("SIGKILL");
|
|
212
|
+
resolvePromise(null);
|
|
213
|
+
}, 120_000);
|
|
214
|
+
child.stdout.on("data", (d) => (stdout += d.toString()));
|
|
215
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
216
|
+
child.on("error", () => {
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
resolvePromise(null);
|
|
219
|
+
});
|
|
220
|
+
child.on("close", (code) => {
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
if (code !== 0) {
|
|
223
|
+
console.error(`[provision:@${handle}] exit ${code}. stderr: ${stderr.slice(0, 1000)}`);
|
|
224
|
+
resolvePromise(null);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
resolvePromise(stdout.trim());
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
if (!out) {
|
|
231
|
+
console.error(`[provision:@${handle}] provisioner produced no brain command — bot keeps the default brain`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// Use the LAST non-empty stdout line: provisioners may log progress above it.
|
|
235
|
+
const brainCmd = out.split("\n").map((l) => l.trim()).filter(Boolean).pop();
|
|
236
|
+
this.setBrains({ map: { [handle]: { cmd: brainCmd } } });
|
|
237
|
+
console.log(`[provision:@${handle}] brain provisioned`);
|
|
166
238
|
}
|
|
167
239
|
/** Fetch the authoritative bot list and start anything we're missing. */
|
|
168
240
|
async syncBots() {
|
package/dist/cli.js
CHANGED
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;
|
|
@@ -18,7 +24,16 @@ export interface BridgeConfig {
|
|
|
18
24
|
brainUrl: string | null;
|
|
19
25
|
/** Per-handle overrides: { "<handle>": {"cmd": "…"} | {"url": "…"} }. */
|
|
20
26
|
brainMap: Record<string, BrainSpec>;
|
|
27
|
+
/**
|
|
28
|
+
* Auto-provisioner: run ONCE for each adopted bot that has no BRAIN_MAP
|
|
29
|
+
* entry (env: NOPEEK_BOT_HANDLE, NOPEEK_BOT_USER_ID). Its trimmed stdout
|
|
30
|
+
* becomes that bot's brain command — e.g. a script that creates a fresh
|
|
31
|
+
* Hermes profile (own soul + memory) per bot and prints how to invoke it.
|
|
32
|
+
*/
|
|
33
|
+
brainProvisionCmd: string | null;
|
|
21
34
|
brainTimeoutMs: number;
|
|
35
|
+
/** Where `install` sends the user to finish setup (opened in the browser). */
|
|
36
|
+
appUrl: string;
|
|
22
37
|
/** Local control API port (status + pairing + brain config, loopback only). */
|
|
23
38
|
port: number;
|
|
24
39
|
/** Where per-bot device identity/key stores live. */
|
|
@@ -30,10 +45,11 @@ export interface BridgeConfig {
|
|
|
30
45
|
declineMessage: string | null;
|
|
31
46
|
}
|
|
32
47
|
export declare const DEFAULT_API_URL = "https://d3qweh72vesa98.cloudfront.net";
|
|
48
|
+
export declare const DEFAULT_APP_URL = "https://d32w3to73s0pu5.cloudfront.net/messenger/";
|
|
33
49
|
export declare const DEFAULT_PORT = 8790;
|
|
34
50
|
export declare const DEFAULT_BRAIN_TIMEOUT_MS = 180000;
|
|
35
51
|
export declare function defaultHomeDir(): string;
|
|
36
|
-
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.";
|
|
37
53
|
/** Load config from argv + env + cwd config file + home settings. Never
|
|
38
54
|
* requires pairing — an unpaired bridge waits for the app to pair it. */
|
|
39
55
|
export declare function loadConfig(argv?: string[]): BridgeConfig;
|
package/dist/config.js
CHANGED
|
@@ -9,7 +9,11 @@ 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";
|
|
16
|
+
export const DEFAULT_APP_URL = "https://d32w3to73s0pu5.cloudfront.net/messenger/";
|
|
13
17
|
export const DEFAULT_PORT = 8790;
|
|
14
18
|
export const DEFAULT_BRAIN_TIMEOUT_MS = 180_000;
|
|
15
19
|
export function defaultHomeDir() {
|
|
@@ -22,8 +26,11 @@ const CLI_OPTIONS = {
|
|
|
22
26
|
"brain-cmd": { type: "string" },
|
|
23
27
|
"brain-url": { type: "string" },
|
|
24
28
|
"brain-map": { type: "string" },
|
|
29
|
+
"brain-provision-cmd": { type: "string" },
|
|
25
30
|
"brain-timeout-ms": { type: "string" },
|
|
26
31
|
"decline-message": { type: "string" },
|
|
32
|
+
"app-url": { type: "string" },
|
|
33
|
+
"no-open": { type: "boolean" },
|
|
27
34
|
port: { type: "string" },
|
|
28
35
|
"data-dir": { type: "string" },
|
|
29
36
|
home: { type: "string" },
|
|
@@ -38,8 +45,10 @@ const FLAG_TO_KEY = {
|
|
|
38
45
|
"brain-cmd": "BRAIN_CMD",
|
|
39
46
|
"brain-url": "BRAIN_URL",
|
|
40
47
|
"brain-map": "BRAIN_MAP",
|
|
48
|
+
"brain-provision-cmd": "BRAIN_PROVISION_CMD",
|
|
41
49
|
"brain-timeout-ms": "BRAIN_TIMEOUT_MS",
|
|
42
50
|
"decline-message": "NOPEEK_DECLINE_MESSAGE",
|
|
51
|
+
"app-url": "NOPEEK_APP_URL",
|
|
43
52
|
port: "NOPEEK_BRIDGE_PORT",
|
|
44
53
|
"data-dir": "NOPEEK_BRIDGE_DATA_DIR",
|
|
45
54
|
home: "NOPEEK_BRIDGE_HOME",
|
|
@@ -62,7 +71,13 @@ Options:
|
|
|
62
71
|
--api-url <url> API base, default ${DEFAULT_API_URL} (env NOPEEK_API_URL)
|
|
63
72
|
--brain-cmd <cmd> Shell brain: message on stdin -> reply on stdout (env BRAIN_CMD)
|
|
64
73
|
--brain-url <url> Webhook brain: POST {text,...} -> {text|reply} (env BRAIN_URL)
|
|
65
|
-
--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)
|
|
76
|
+
--brain-provision-cmd <cmd> Run once per new bot; stdout becomes its brain command
|
|
77
|
+
(env BRAIN_PROVISION_CMD — e.g. a script that creates a
|
|
78
|
+
fresh Hermes profile with its own soul + memory)
|
|
79
|
+
--app-url <url> App opened after install (env NOPEEK_APP_URL)
|
|
80
|
+
--no-open install: don't open the app in the browser
|
|
66
81
|
--brain-timeout-ms <ms> Brain timeout, default ${DEFAULT_BRAIN_TIMEOUT_MS} (env BRAIN_TIMEOUT_MS)
|
|
67
82
|
--port <port> Local control API port, default ${DEFAULT_PORT} (env NOPEEK_BRIDGE_PORT)
|
|
68
83
|
--data-dir <dir> Device-key store dir (env NOPEEK_BRIDGE_DATA_DIR)
|
|
@@ -110,13 +125,18 @@ function parseBrainMap(raw) {
|
|
|
110
125
|
if (typeof spec !== "object" || spec === null) {
|
|
111
126
|
throw new Error(`BRAIN_MAP["${handle}"] must be {"cmd":"…"} or {"url":"…"}`);
|
|
112
127
|
}
|
|
113
|
-
const { cmd, url } = spec;
|
|
114
|
-
if (
|
|
115
|
-
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")`);
|
|
116
134
|
}
|
|
117
135
|
out[handle.replace(/^@/, "")] = {
|
|
118
136
|
...(typeof cmd === "string" ? { cmd } : {}),
|
|
119
137
|
...(typeof url === "string" ? { url } : {}),
|
|
138
|
+
...(isBrainBackend(backend) ? { backend } : {}),
|
|
139
|
+
...(echo === true ? { echo: true } : {}),
|
|
120
140
|
};
|
|
121
141
|
}
|
|
122
142
|
return out;
|
|
@@ -179,11 +199,13 @@ export function loadConfig(argv = process.argv.slice(2)) {
|
|
|
179
199
|
brainCmd: get("brain-cmd") ?? null,
|
|
180
200
|
brainUrl: get("brain-url") ?? null,
|
|
181
201
|
brainMap: parseBrainMap(get("brain-map")),
|
|
202
|
+
brainProvisionCmd: get("brain-provision-cmd") ?? null,
|
|
182
203
|
brainTimeoutMs,
|
|
183
204
|
port,
|
|
184
205
|
dataDir,
|
|
185
206
|
homeDir,
|
|
186
207
|
declineMessage: get("decline-message") ?? null,
|
|
208
|
+
appUrl: get("app-url") ?? DEFAULT_APP_URL,
|
|
187
209
|
};
|
|
188
210
|
}
|
|
189
211
|
/**
|
|
@@ -200,6 +222,7 @@ export function saveSettings(cfg) {
|
|
|
200
222
|
...(cfg.brainCmd ? { BRAIN_CMD: cfg.brainCmd } : {}),
|
|
201
223
|
...(cfg.brainUrl ? { BRAIN_URL: cfg.brainUrl } : {}),
|
|
202
224
|
...(Object.keys(cfg.brainMap).length ? { BRAIN_MAP: JSON.stringify(cfg.brainMap) } : {}),
|
|
225
|
+
...(cfg.brainProvisionCmd ? { BRAIN_PROVISION_CMD: cfg.brainProvisionCmd } : {}),
|
|
203
226
|
...(cfg.declineMessage ? { NOPEEK_DECLINE_MESSAGE: cfg.declineMessage } : {}),
|
|
204
227
|
};
|
|
205
228
|
const path = settingsPath(cfg.homeDir);
|
package/dist/service.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { BridgeConfig } from "./config.js";
|
|
2
|
-
export declare function installService(cfg: BridgeConfig): Promise<void>;
|
|
2
|
+
export declare function installService(cfg: BridgeConfig, noOpen?: boolean): Promise<void>;
|
|
3
3
|
export declare function uninstallService(): void;
|
|
4
4
|
export declare function printStatus(port: number): Promise<void>;
|
package/dist/service.js
CHANGED
|
@@ -41,7 +41,7 @@ function launchctl(args, ignoreFailure = false) {
|
|
|
41
41
|
throw err;
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
-
export async function installService(cfg) {
|
|
44
|
+
export async function installService(cfg, noOpen = false) {
|
|
45
45
|
if (process.platform !== "darwin" && process.platform !== "linux") {
|
|
46
46
|
throw new Error(`automatic service install supports macOS and Linux. On this platform, run the bridge with any process manager:\n nopeek-agent-bridge run`);
|
|
47
47
|
}
|
|
@@ -122,9 +122,21 @@ WantedBy=default.target
|
|
|
122
122
|
}
|
|
123
123
|
console.log(`
|
|
124
124
|
Done. Next step — in the NoPeek app on THIS computer:
|
|
125
|
-
Contacts -> My Bots ->
|
|
125
|
+
Contacts -> My Bots -> "Run your bots on this computer" -> Connect
|
|
126
126
|
Pairing, choosing your agent (Hermes, …) and everything else happens in the app.
|
|
127
127
|
Logs: ${logFile}`);
|
|
128
|
+
// Take the user straight to the app: sign in once, tap Connect, done.
|
|
129
|
+
// (Sessions persist, so next time this is automatic.)
|
|
130
|
+
if (!noOpen) {
|
|
131
|
+
const opener = process.platform === "darwin" ? "open" : "xdg-open";
|
|
132
|
+
try {
|
|
133
|
+
execFileSync(opener, [cfg.appUrl], { stdio: "ignore" });
|
|
134
|
+
console.log(`[install] opened ${cfg.appUrl}`);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
console.log(`[install] open ${cfg.appUrl} in your browser to finish setup`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
128
140
|
}
|
|
129
141
|
export function uninstallService() {
|
|
130
142
|
if (process.platform === "darwin") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nopeek/agent-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|