@nopeek/agent-bridge 0.5.4 → 0.5.6
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 +0 -5
- package/dist/backends.js +98 -27
- package/dist/bot.d.ts +5 -0
- package/dist/bot.js +28 -1
- package/dist/bridge.d.ts +1 -1
- package/dist/bridge.js +1 -1
- package/package.json +1 -1
package/dist/backends.d.ts
CHANGED
|
@@ -33,9 +33,4 @@ export declare function hermesHome(): string;
|
|
|
33
33
|
* stays authenticated from one login). Idempotent.
|
|
34
34
|
*/
|
|
35
35
|
export declare function provisionHermesProfile(handle: string): string;
|
|
36
|
-
/**
|
|
37
|
-
* Hermes backend: per-bot profile (own soul + memories), one persistent
|
|
38
|
-
* session per channel (`--continue nopeek-<channel>`), streaming cleaned
|
|
39
|
-
* stdout lines to onChunk.
|
|
40
|
-
*/
|
|
41
36
|
export declare function hermesBrain(cfg: BridgeConfig): Brain;
|
package/dist/backends.js
CHANGED
|
@@ -292,7 +292,7 @@ export function provisionHermesProfile(handle) {
|
|
|
292
292
|
}
|
|
293
293
|
// Chat chrome Hermes prints even in -Q mode: ruler lines, the echoed prompt
|
|
294
294
|
// bubble (● …), the goodbye line, and session-info footers.
|
|
295
|
-
const HERMES_CHROME = [/^[─━—-]{4,}\s*─*$/, /^●/, /^Goodbye!/, /^session( id
|
|
295
|
+
const HERMES_CHROME = [/^[─━—-]{4,}\s*─*$/, /^●/, /^Goodbye!/, /^session(_| )?id:/i, /^↻/];
|
|
296
296
|
function isHermesChrome(line) {
|
|
297
297
|
return HERMES_CHROME.some((re) => re.test(line));
|
|
298
298
|
}
|
|
@@ -301,26 +301,20 @@ function isHermesChrome(line) {
|
|
|
301
301
|
* session per channel (`--continue nopeek-<channel>`), streaming cleaned
|
|
302
302
|
* stdout lines to onChunk.
|
|
303
303
|
*/
|
|
304
|
+
/** `hermes chat --continue <name>` FAILS (does not create) when the named
|
|
305
|
+
* session doesn't exist yet — this is what its error looks like. */
|
|
306
|
+
const HERMES_NO_SESSION = /^No session found matching/i;
|
|
307
|
+
/** Session footer Hermes prints on exit; we filter it as chrome but capture
|
|
308
|
+
* the id so a fresh session can be renamed for future --continue. */
|
|
309
|
+
const HERMES_SESSION_ID = /^session(_| )?id:\s*(\S+)/i;
|
|
304
310
|
export function hermesBrain(cfg) {
|
|
305
|
-
|
|
306
|
-
const handle = ctx.botHandle.replace(/^@/, "");
|
|
307
|
-
const tag = `[brain:hermes:@${handle}]`;
|
|
311
|
+
const runOnce = (profile, text, sessionName, tag, onChunk) => new Promise((resolvePromise) => {
|
|
308
312
|
const bin = resolveBin("hermes", "HERMES_BIN");
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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], {
|
|
313
|
+
const args = ["--profile", profile, "chat", "-Q"];
|
|
314
|
+
if (sessionName)
|
|
315
|
+
args.push("--continue", sessionName);
|
|
316
|
+
args.push("-q", text);
|
|
317
|
+
const child = spawn(bin, args, {
|
|
324
318
|
stdio: ["ignore", "pipe", "pipe"],
|
|
325
319
|
// Hermes keys everything off $HOME; point it at the Hermes install.
|
|
326
320
|
env: { ...process.env, HOME: hermesHome() },
|
|
@@ -330,12 +324,42 @@ export function hermesBrain(cfg) {
|
|
|
330
324
|
let sawContent = false; // suppress leading blank lines
|
|
331
325
|
let stderr = "";
|
|
332
326
|
let settled = false;
|
|
327
|
+
let noSession = false;
|
|
328
|
+
let sessionId = null;
|
|
329
|
+
let bannerCont = false; // inside a wrapped "↻ Resumed session …" banner
|
|
333
330
|
const handleLine = (raw) => {
|
|
334
331
|
const line = stripAnsi(raw);
|
|
332
|
+
const sid = HERMES_SESSION_ID.exec(line);
|
|
333
|
+
if (sid)
|
|
334
|
+
sessionId = sid[2];
|
|
335
|
+
// The resume banner ("↻ Resumed session … (1 message, 2 total messages)")
|
|
336
|
+
// can wrap across lines; swallow continuations until the closing paren.
|
|
337
|
+
if (bannerCont) {
|
|
338
|
+
if (/\)\s*$/.test(line) || !line.trim())
|
|
339
|
+
bannerCont = false;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (/^↻/.test(line.trim())) {
|
|
343
|
+
if (!/\)\s*$/.test(line.trim()))
|
|
344
|
+
bannerCont = true;
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
335
347
|
if (isHermesChrome(line))
|
|
336
348
|
return;
|
|
337
349
|
if (!sawContent && !line.trim())
|
|
338
350
|
return;
|
|
351
|
+
// Leading host warnings ("⚠ tirith security scanner…") are chrome,
|
|
352
|
+
// not reply. Only before real content — a ⚠ mid-reply is kept.
|
|
353
|
+
if (!sawContent && /^[⚠✗]/.test(line.trim()))
|
|
354
|
+
return;
|
|
355
|
+
// First content line saying "No session found" = the --continue miss,
|
|
356
|
+
// not a reply. Don't stream it to the chat; the caller retries fresh.
|
|
357
|
+
if (!sawContent && HERMES_NO_SESSION.test(line.trim())) {
|
|
358
|
+
noSession = true;
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (noSession)
|
|
362
|
+
return; // swallow the "Use 'hermes sessions list'…" tail too
|
|
339
363
|
sawContent = true;
|
|
340
364
|
out += `${line}\n`;
|
|
341
365
|
onChunk?.(`${line}\n`);
|
|
@@ -346,14 +370,18 @@ export function hermesBrain(cfg) {
|
|
|
346
370
|
settled = true;
|
|
347
371
|
if (lineBuf)
|
|
348
372
|
handleLine(lineBuf);
|
|
349
|
-
|
|
350
|
-
if (!
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
373
|
+
// The session footer lands on STDERR in -Q mode — scan there too.
|
|
374
|
+
if (!sessionId) {
|
|
375
|
+
for (const line of stderr.split("\n")) {
|
|
376
|
+
const sid = HERMES_SESSION_ID.exec(stripAnsi(line).trim());
|
|
377
|
+
if (sid)
|
|
378
|
+
sessionId = sid[2];
|
|
379
|
+
}
|
|
355
380
|
}
|
|
356
|
-
|
|
381
|
+
// "No session found" can land on stderr in some hermes builds.
|
|
382
|
+
if (!out.trim() && HERMES_NO_SESSION.test(stderr.trim()))
|
|
383
|
+
noSession = true;
|
|
384
|
+
resolvePromise({ reply: out.trim(), noSession, sessionId, stderr });
|
|
357
385
|
};
|
|
358
386
|
const timer = setTimeout(() => {
|
|
359
387
|
console.error(`${tag} timed out after ${cfg.brainTimeoutMs / 1000}s, killing`);
|
|
@@ -373,7 +401,7 @@ export function hermesBrain(cfg) {
|
|
|
373
401
|
console.error(`${tag} spawn error: ${err.message}`);
|
|
374
402
|
if (!settled) {
|
|
375
403
|
settled = true;
|
|
376
|
-
resolvePromise(
|
|
404
|
+
resolvePromise({ reply: "", noSession: false, sessionId: null, stderr: err.message });
|
|
377
405
|
}
|
|
378
406
|
});
|
|
379
407
|
child.on("close", () => {
|
|
@@ -381,4 +409,47 @@ export function hermesBrain(cfg) {
|
|
|
381
409
|
finish();
|
|
382
410
|
});
|
|
383
411
|
});
|
|
412
|
+
return async (text, ctx, onChunk) => {
|
|
413
|
+
const handle = ctx.botHandle.replace(/^@/, "");
|
|
414
|
+
const tag = `[brain:hermes:@${handle}]`;
|
|
415
|
+
if (!resolveBin("hermes", "HERMES_BIN")) {
|
|
416
|
+
console.error(`${tag} hermes not found on PATH`);
|
|
417
|
+
return BRAIN_UNREACHABLE;
|
|
418
|
+
}
|
|
419
|
+
let profile;
|
|
420
|
+
try {
|
|
421
|
+
profile = provisionHermesProfile(handle);
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
console.error(`${tag} profile provisioning failed: ${err.message}`);
|
|
425
|
+
return BRAIN_UNREACHABLE;
|
|
426
|
+
}
|
|
427
|
+
const sessionName = `nopeek-${ctx.channelId}`;
|
|
428
|
+
let run = await runOnce(profile, text, sessionName, tag, onChunk);
|
|
429
|
+
if (run.noSession && !run.reply) {
|
|
430
|
+
// First message in this channel: the named session doesn't exist yet.
|
|
431
|
+
// Start fresh, then name the new session so the NEXT message continues it.
|
|
432
|
+
console.log(`${tag} no session ${sessionName} yet — starting fresh`);
|
|
433
|
+
run = await runOnce(profile, text, null, tag, onChunk);
|
|
434
|
+
if (run.sessionId) {
|
|
435
|
+
const bin = resolveBin("hermes", "HERMES_BIN");
|
|
436
|
+
const rn = spawn(bin, ["--profile", profile, "sessions", "rename", run.sessionId, sessionName], {
|
|
437
|
+
stdio: "ignore",
|
|
438
|
+
env: { ...process.env, HOME: hermesHome() },
|
|
439
|
+
});
|
|
440
|
+
rn.on("error", () => {
|
|
441
|
+
/* best-effort — worst case the next message starts fresh again */
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
console.error(`${tag} fresh session id not captured — next message will start fresh again`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (!run.reply) {
|
|
449
|
+
if (run.stderr.trim())
|
|
450
|
+
console.error(`${tag} stderr: ${run.stderr.slice(0, 1000)}`);
|
|
451
|
+
return "⚠️ My brain isn't reachable right now — Hermes has no authenticated provider on the host. Run 'hermes model' there, then message me again.";
|
|
452
|
+
}
|
|
453
|
+
return run.reply;
|
|
454
|
+
};
|
|
384
455
|
}
|
package/dist/bot.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ export declare class BotRunner {
|
|
|
24
24
|
private allowed;
|
|
25
25
|
private accessLoaded;
|
|
26
26
|
private declined;
|
|
27
|
+
private chains;
|
|
28
|
+
private cantPost;
|
|
27
29
|
private log;
|
|
28
30
|
private logErr;
|
|
29
31
|
constructor(info: BotInfo, cfg: BridgeConfig);
|
|
@@ -50,4 +52,7 @@ export declare class BotRunner {
|
|
|
50
52
|
/** Bounded dedupe so a redelivered frame is never answered twice. */
|
|
51
53
|
private remember;
|
|
52
54
|
private handleMessage;
|
|
55
|
+
/** A FORBIDDEN post (broadcast channel, bot not an operator) fails for every
|
|
56
|
+
* future message too — mute the channel so the brain stops running there. */
|
|
57
|
+
private notePostFailure;
|
|
53
58
|
}
|
package/dist/bot.js
CHANGED
|
@@ -28,6 +28,13 @@ export class BotRunner {
|
|
|
28
28
|
allowed = new Set();
|
|
29
29
|
accessLoaded = false;
|
|
30
30
|
declined = new Set(); // (senderId) already told "not authorized" once
|
|
31
|
+
// Per-channel serialization: two messages in one channel must be answered in
|
|
32
|
+
// order, one at a time — concurrent brain runs against the same agent session
|
|
33
|
+
// (e.g. one Hermes session per channel) deadlock or reply out of order.
|
|
34
|
+
chains = new Map();
|
|
35
|
+
// Channels the bot can't post to (e.g. broadcast, non-operator): after the
|
|
36
|
+
// first FORBIDDEN, skip the brain entirely — replies there can never land.
|
|
37
|
+
cantPost = new Set();
|
|
31
38
|
log;
|
|
32
39
|
logErr;
|
|
33
40
|
constructor(info, cfg) {
|
|
@@ -153,8 +160,15 @@ export class BotRunner {
|
|
|
153
160
|
this.log(`received channel key for ${p.channelId} (welcome ceremony completed)`);
|
|
154
161
|
}));
|
|
155
162
|
np.on("message", ((m) => {
|
|
156
|
-
|
|
163
|
+
const prev = this.chains.get(m.channelId) ?? Promise.resolve();
|
|
164
|
+
const next = prev.then(() => this.handleMessage(m).catch((err) => {
|
|
165
|
+
this.notePostFailure(m.channelId, err);
|
|
157
166
|
this.logErr(`handler error for ${m.messageId}: ${err.message}`);
|
|
167
|
+
}));
|
|
168
|
+
this.chains.set(m.channelId, next);
|
|
169
|
+
void next.finally(() => {
|
|
170
|
+
if (this.chains.get(m.channelId) === next)
|
|
171
|
+
this.chains.delete(m.channelId);
|
|
158
172
|
});
|
|
159
173
|
}));
|
|
160
174
|
await this.refreshAccess(); // load the allow list before we answer anyone
|
|
@@ -216,6 +230,8 @@ export class BotRunner {
|
|
|
216
230
|
this.remember(m.messageId);
|
|
217
231
|
if (m.senderUserId === this.info.userId)
|
|
218
232
|
return; // never answer ourselves
|
|
233
|
+
if (this.cantPost.has(m.channelId))
|
|
234
|
+
return; // replies can't land here — don't burn a brain run
|
|
219
235
|
if (m.decryptionFailed) {
|
|
220
236
|
// Likely a missed welcome (message arrived before our key). Sync the key
|
|
221
237
|
// so the NEXT message decrypts; this frame's plaintext is unrecoverable.
|
|
@@ -278,6 +294,7 @@ export class BotRunner {
|
|
|
278
294
|
}
|
|
279
295
|
streamRef.p = ch.stream();
|
|
280
296
|
streamRef.p.catch((err) => {
|
|
297
|
+
this.notePostFailure(m.channelId, err);
|
|
281
298
|
this.logErr(`stream open failed (falling back to a single send): ${err.message}`);
|
|
282
299
|
});
|
|
283
300
|
}
|
|
@@ -323,4 +340,14 @@ export class BotRunner {
|
|
|
323
340
|
this.handled++;
|
|
324
341
|
this.log(`${m.channelId} -> replied (${reply.trim().length} chars, handled=${this.handled})`);
|
|
325
342
|
}
|
|
343
|
+
/** A FORBIDDEN post (broadcast channel, bot not an operator) fails for every
|
|
344
|
+
* future message too — mute the channel so the brain stops running there. */
|
|
345
|
+
notePostFailure(channelId, err) {
|
|
346
|
+
if (!/only operators can post|FORBIDDEN/i.test(err.message))
|
|
347
|
+
return;
|
|
348
|
+
if (this.cantPost.has(channelId))
|
|
349
|
+
return;
|
|
350
|
+
this.cantPost.add(channelId);
|
|
351
|
+
this.log(`muting ${channelId} — bot cannot post here (${err.message.slice(0, 80)})`);
|
|
352
|
+
}
|
|
326
353
|
}
|
package/dist/bridge.d.ts
CHANGED
package/dist/bridge.js
CHANGED
|
@@ -14,7 +14,7 @@ import { resolveBrain } from "./brain.js";
|
|
|
14
14
|
import { provisionSoul, provisionHermesProfile } from "./backends.js";
|
|
15
15
|
import { reportCapabilities } from "./capabilities.js";
|
|
16
16
|
import { isBrainBackend } from "./config.js";
|
|
17
|
-
export const VERSION = "0.5.
|
|
17
|
+
export const VERSION = "0.5.6";
|
|
18
18
|
/** How often to re-probe + report brain availability to the server. */
|
|
19
19
|
const CAPABILITIES_INTERVAL_MS = 5 * 60_000;
|
|
20
20
|
export class PairError extends Error {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nopeek/agent-bridge",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
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",
|