@nopeek/agent-bridge 0.7.8 → 0.7.10
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/README.md +6 -0
- package/dist/backends.d.ts +12 -0
- package/dist/backends.js +76 -2
- package/dist/bot.d.ts +3 -0
- package/dist/bot.js +15 -0
- package/dist/brain.js +5 -1
- package/dist/bridge.d.ts +2 -2
- package/dist/bridge.js +40 -13
- package/dist/capabilities.d.ts +2 -1
- package/dist/capabilities.js +10 -4
- package/dist/cli.js +1 -0
- package/dist/config.d.ts +13 -3
- package/dist/config.js +17 -9
- package/dist/hermes-http.d.ts +15 -0
- package/dist/hermes-http.js +153 -0
- package/dist/last-turn.d.ts +22 -0
- package/dist/last-turn.js +50 -0
- package/dist/localapi.js +2 -2
- package/dist/service.d.ts +0 -11
- package/dist/service.js +19 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,6 +39,12 @@ npx @nopeek/agent-bridge \
|
|
|
39
39
|
|
|
40
40
|
The `npr_…` code comes from Settings → Connect your computer (bots) in the app. `install` also accepts `--pair`/`--app-id` to pre-pair the service from the terminal.
|
|
41
41
|
|
|
42
|
+
Hermes brains prefer the local agent API (`HERMES_API_URL`, default
|
|
43
|
+
`http://127.0.0.1:8642`) when `/health` is up, and fall back to `hermes chat`.
|
|
44
|
+
Idle kill waits for **no output and no CPU** (default 15 min). Authenticated
|
|
45
|
+
`GET /status` includes `bridge`, `bots[]` (each with `lastTurn`), `hermesApi`,
|
|
46
|
+
and `lastTurn`. See `docs/HERMES-BRAIN-PLAN.md`.
|
|
47
|
+
|
|
42
48
|
## Local control API
|
|
43
49
|
|
|
44
50
|
The bridge serves a loopback-only HTTP API on `127.0.0.1:8790` — this is what the NoPeek app uses; you can script it too.
|
package/dist/backends.d.ts
CHANGED
|
@@ -10,6 +10,12 @@ export declare function soulPath(homeDir: string, handle: string): string;
|
|
|
10
10
|
export declare function provisionSoul(handle: string, homeDir: string): string;
|
|
11
11
|
/** Resolve a binary: $<ENVVAR> > PATH (command -v) > common install dirs. */
|
|
12
12
|
export declare function resolveBin(name: string, envVar: string): string | null;
|
|
13
|
+
/**
|
|
14
|
+
* Accumulated CPU seconds for a pid (`ps -o cputime=`). Used so a silent but
|
|
15
|
+
* working Hermes child (quiet mode + long tools) does not trip the idle kill.
|
|
16
|
+
* Returns null if the process is gone or `ps` fails.
|
|
17
|
+
*/
|
|
18
|
+
export declare function cpuTimeSeconds(pid: number | undefined): number | null;
|
|
13
19
|
/**
|
|
14
20
|
* Deterministic per-(bot, channel) session UUID so each chat is one running
|
|
15
21
|
* Claude conversation. UUIDv5-ish (sha1 of the key formatted as a UUID) —
|
|
@@ -33,4 +39,10 @@ export declare function hermesHome(): string;
|
|
|
33
39
|
* stays authenticated from one login). Idempotent.
|
|
34
40
|
*/
|
|
35
41
|
export declare function provisionHermesProfile(handle: string): string;
|
|
42
|
+
/**
|
|
43
|
+
* Hermes brain. Prefers the long-running agent API (`HERMES_API_URL`, default
|
|
44
|
+
* loopback :8642) when `/health` is up — that path does not shell out, so a
|
|
45
|
+
* CLI flag change cannot empty-reply the bot. Falls back to `hermes chat -Q`.
|
|
46
|
+
*/
|
|
36
47
|
export declare function hermesBrain(cfg: BridgeConfig): Brain;
|
|
48
|
+
export declare function hermesHttpOnlyBrain(cfg: BridgeConfig): Brain;
|
package/dist/backends.js
CHANGED
|
@@ -17,6 +17,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFi
|
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
18
|
import { basename, join } from "node:path";
|
|
19
19
|
import { stripAnsi } from "./brain.js";
|
|
20
|
+
import { hermesHttpBrain, probeHermesApi } from "./hermes-http.js";
|
|
20
21
|
const BRAIN_UNREACHABLE = "⚠️ I couldn't reach my brain just now — please try again in a moment.";
|
|
21
22
|
// ------------------------------------------------------------------ souls ----
|
|
22
23
|
/** Built-in personality template — personalized per bot by provisionSoul(). */
|
|
@@ -77,6 +78,41 @@ export function resolveBin(name, envVar) {
|
|
|
77
78
|
}
|
|
78
79
|
return null;
|
|
79
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Accumulated CPU seconds for a pid (`ps -o cputime=`). Used so a silent but
|
|
83
|
+
* working Hermes child (quiet mode + long tools) does not trip the idle kill.
|
|
84
|
+
* Returns null if the process is gone or `ps` fails.
|
|
85
|
+
*/
|
|
86
|
+
export function cpuTimeSeconds(pid) {
|
|
87
|
+
if (pid == null || pid <= 0)
|
|
88
|
+
return null;
|
|
89
|
+
try {
|
|
90
|
+
const r = spawnSync("ps", ["-o", "cputime=", "-p", String(pid)], { encoding: "utf8", timeout: 2_000 });
|
|
91
|
+
if (r.status !== 0)
|
|
92
|
+
return null;
|
|
93
|
+
const raw = (r.stdout || "").trim();
|
|
94
|
+
// Formats: "SS.ss", "MM:SS", "HH:MM:SS", "[D-]HH:MM:SS"
|
|
95
|
+
const daySplit = raw.split("-");
|
|
96
|
+
const clock = daySplit.length === 2 ? daySplit[1] : daySplit[0];
|
|
97
|
+
const days = daySplit.length === 2 ? Number(daySplit[0]) : 0;
|
|
98
|
+
const parts = clock.split(":").map(Number);
|
|
99
|
+
if (parts.some((n) => !Number.isFinite(n)))
|
|
100
|
+
return null;
|
|
101
|
+
let secs = days * 86400;
|
|
102
|
+
if (parts.length === 3)
|
|
103
|
+
secs += parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
104
|
+
else if (parts.length === 2)
|
|
105
|
+
secs += parts[0] * 60 + parts[1];
|
|
106
|
+
else if (parts.length === 1)
|
|
107
|
+
secs += parts[0];
|
|
108
|
+
else
|
|
109
|
+
return null;
|
|
110
|
+
return secs;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
80
116
|
/**
|
|
81
117
|
* Deterministic per-(bot, channel) session UUID so each chat is one running
|
|
82
118
|
* Claude conversation. UUIDv5-ish (sha1 of the key formatted as a UUID) —
|
|
@@ -349,7 +385,7 @@ function detectHermesProfileMode(bin) {
|
|
|
349
385
|
console.log(`[hermes] profile selection mode: ${hermesProfileMode}`);
|
|
350
386
|
return hermesProfileMode;
|
|
351
387
|
}
|
|
352
|
-
|
|
388
|
+
function hermesCliBrain(cfg) {
|
|
353
389
|
const runOnce = (profile, text, sessionName, tag, onChunk) => new Promise((resolvePromise) => {
|
|
354
390
|
const bin = resolveBin("hermes", "HERMES_BIN");
|
|
355
391
|
const mode = detectHermesProfileMode(bin);
|
|
@@ -418,6 +454,7 @@ export function hermesBrain(cfg) {
|
|
|
418
454
|
if (settled)
|
|
419
455
|
return;
|
|
420
456
|
settled = true;
|
|
457
|
+
clearInterval(cpuPoll);
|
|
421
458
|
if (lineBuf)
|
|
422
459
|
handleLine(lineBuf);
|
|
423
460
|
// The session footer lands on STDERR in -Q mode — scan there too.
|
|
@@ -443,13 +480,28 @@ export function hermesBrain(cfg) {
|
|
|
443
480
|
const armIdleTimer = () => {
|
|
444
481
|
clearTimeout(timer);
|
|
445
482
|
timer = setTimeout(() => {
|
|
446
|
-
console.error(`${tag} idle ${cfg.brainTimeoutMs / 1000}s (no output), killing`);
|
|
483
|
+
console.error(`${tag} idle ${cfg.brainTimeoutMs / 1000}s (no output and no CPU), killing`);
|
|
447
484
|
timedOut = true;
|
|
448
485
|
child.kill("SIGKILL");
|
|
449
486
|
finish();
|
|
450
487
|
}, cfg.brainTimeoutMs);
|
|
451
488
|
};
|
|
452
489
|
armIdleTimer();
|
|
490
|
+
// Hermes -Q is silent during long tool use. Reset the idle timer whenever
|
|
491
|
+
// the child's accumulated CPU time advances — that is real work, not a hang.
|
|
492
|
+
let lastCpu = cpuTimeSeconds(child.pid);
|
|
493
|
+
const cpuPoll = setInterval(() => {
|
|
494
|
+
if (settled || child.pid == null)
|
|
495
|
+
return;
|
|
496
|
+
const now = cpuTimeSeconds(child.pid);
|
|
497
|
+
if (now != null && lastCpu != null && now > lastCpu + 0.05) {
|
|
498
|
+
lastCpu = now;
|
|
499
|
+
armIdleTimer();
|
|
500
|
+
}
|
|
501
|
+
else if (now != null && lastCpu == null) {
|
|
502
|
+
lastCpu = now;
|
|
503
|
+
}
|
|
504
|
+
}, 10_000);
|
|
453
505
|
child.stdout.on("data", (d) => {
|
|
454
506
|
armIdleTimer();
|
|
455
507
|
lineBuf += d.toString();
|
|
@@ -546,3 +598,25 @@ export function hermesBrain(cfg) {
|
|
|
546
598
|
return run.reply;
|
|
547
599
|
};
|
|
548
600
|
}
|
|
601
|
+
/**
|
|
602
|
+
* Hermes brain. Prefers the long-running agent API (`HERMES_API_URL`, default
|
|
603
|
+
* loopback :8642) when `/health` is up — that path does not shell out, so a
|
|
604
|
+
* CLI flag change cannot empty-reply the bot. Falls back to `hermes chat -Q`.
|
|
605
|
+
*/
|
|
606
|
+
export function hermesBrain(cfg) {
|
|
607
|
+
const cli = hermesCliBrain(cfg);
|
|
608
|
+
const http = hermesHttpBrain(cfg);
|
|
609
|
+
return async (text, ctx, onChunk) => {
|
|
610
|
+
const health = await probeHermesApi(cfg);
|
|
611
|
+
if (health.ok) {
|
|
612
|
+
const reply = await http(text, ctx, onChunk);
|
|
613
|
+
if (reply)
|
|
614
|
+
return reply;
|
|
615
|
+
console.error(`[brain:hermes:@${ctx.botHandle.replace(/^@/, "")}] API returned empty — falling back to CLI`);
|
|
616
|
+
}
|
|
617
|
+
return cli(text, ctx, onChunk);
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
export function hermesHttpOnlyBrain(cfg) {
|
|
621
|
+
return hermesHttpBrain(cfg);
|
|
622
|
+
}
|
package/dist/bot.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { BridgeConfig, Pairing, BrainBackend } from "./config.js";
|
|
2
2
|
import { type ChannelMode } from "./control.js";
|
|
3
|
+
import { type BrainTurn } from "./last-turn.js";
|
|
3
4
|
export interface BotInfo {
|
|
4
5
|
userId: string;
|
|
5
6
|
handle: string;
|
|
@@ -46,6 +47,8 @@ export declare class BotRunner {
|
|
|
46
47
|
msSinceHealthy(): number;
|
|
47
48
|
/** When the watchdog last force-restarted this runner (null = never). */
|
|
48
49
|
get lastForcedRestartAt(): number | null;
|
|
50
|
+
/** Most recent brain turn for this bot (shared last-turn store). */
|
|
51
|
+
lastTurn(): BrainTurn | null;
|
|
49
52
|
/**
|
|
50
53
|
* Watchdog hard restart: tear the SDK client down completely and rebuild it
|
|
51
54
|
* from scratch (fresh session mint + NoPeek.connect) — the proven-working
|
package/dist/bot.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { NoPeek } from "@nopeek/chat";
|
|
6
6
|
import { isChannelMode } from "./control.js";
|
|
7
7
|
import { FALLBACK_REPLY, resolveBrain } from "./brain.js";
|
|
8
|
+
import { beginTurn, finishTurn, lastTurnFor } from "./last-turn.js";
|
|
8
9
|
import { FileStore } from "./storage.js";
|
|
9
10
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
10
11
|
const MAX_BACKOFF_MS = 60_000;
|
|
@@ -94,6 +95,10 @@ export class BotRunner {
|
|
|
94
95
|
get lastForcedRestartAt() {
|
|
95
96
|
return this.lastForcedRestart;
|
|
96
97
|
}
|
|
98
|
+
/** Most recent brain turn for this bot (shared last-turn store). */
|
|
99
|
+
lastTurn() {
|
|
100
|
+
return lastTurnFor(this.info.handle);
|
|
101
|
+
}
|
|
97
102
|
/**
|
|
98
103
|
* Watchdog hard restart: tear the SDK client down completely and rebuild it
|
|
99
104
|
* from scratch (fresh session mint + NoPeek.connect) — the proven-working
|
|
@@ -565,6 +570,7 @@ export class BotRunner {
|
|
|
565
570
|
streamRef.p.then((s) => s.append(delta)).catch(() => { });
|
|
566
571
|
};
|
|
567
572
|
let reply = "";
|
|
573
|
+
const turn = beginTurn(this.info.handle, m.channelId, resolved.kind);
|
|
568
574
|
try {
|
|
569
575
|
reply = await resolved.brain(text, {
|
|
570
576
|
botHandle: this.info.handle,
|
|
@@ -572,8 +578,17 @@ export class BotRunner {
|
|
|
572
578
|
channelId: m.channelId,
|
|
573
579
|
senderUserId: m.senderUserId,
|
|
574
580
|
}, onChunk);
|
|
581
|
+
const trimmed = reply.trim();
|
|
582
|
+
const timedOut = /went quiet for over \d+s/i.test(trimmed);
|
|
583
|
+
finishTurn(turn, {
|
|
584
|
+
ok: Boolean(trimmed) && !trimmed.startsWith("⚠️"),
|
|
585
|
+
chars: trimmed.length,
|
|
586
|
+
timedOut,
|
|
587
|
+
error: !trimmed ? "empty reply" : trimmed.startsWith("⚠️") ? trimmed.slice(0, 180) : null,
|
|
588
|
+
});
|
|
575
589
|
}
|
|
576
590
|
catch (err) {
|
|
591
|
+
finishTurn(turn, { ok: false, error: err.message, timedOut: false });
|
|
577
592
|
// Brain blew up mid-stream: finalize the partial bubble with an honest
|
|
578
593
|
// error line instead of leaving a forever-blinking cursor.
|
|
579
594
|
const stream = streamRef.p ? await streamRef.p.catch(() => null) : null;
|
package/dist/brain.js
CHANGED
|
@@ -6,7 +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
|
+
import { claudeBrain, hermesBrain, hermesHttpOnlyBrain } from "./backends.js";
|
|
10
10
|
export const FALLBACK_REPLY = "Sorry — I hit an error processing that. Please try again.";
|
|
11
11
|
// ANSI escape sequences (CSI, OSC, and lone ESC controls). Agent runtimes like
|
|
12
12
|
// Hermes color their stdout; the chat must receive plain text.
|
|
@@ -151,6 +151,8 @@ export function resolveBrain(cfg, handle) {
|
|
|
151
151
|
return { brain: claudeBrain(cfg), kind: "claude (per-bot)" };
|
|
152
152
|
if (override?.backend === "hermes")
|
|
153
153
|
return { brain: hermesBrain(cfg), kind: "hermes (per-bot)" };
|
|
154
|
+
if (override?.backend === "hermes-http")
|
|
155
|
+
return { brain: hermesHttpOnlyBrain(cfg), kind: "hermes-http (per-bot)" };
|
|
154
156
|
if (override?.backend === "echo" || override?.echo)
|
|
155
157
|
return { brain: echoBrain, kind: "echo (per-bot)" };
|
|
156
158
|
// No local override — honor the server-mediated backend if one was pushed.
|
|
@@ -159,6 +161,8 @@ export function resolveBrain(cfg, handle) {
|
|
|
159
161
|
return { brain: claudeBrain(cfg), kind: "claude (server)" };
|
|
160
162
|
if (server === "hermes")
|
|
161
163
|
return { brain: hermesBrain(cfg), kind: "hermes (server)" };
|
|
164
|
+
if (server === "hermes-http")
|
|
165
|
+
return { brain: hermesHttpOnlyBrain(cfg), kind: "hermes-http (server)" };
|
|
162
166
|
if (server === "echo")
|
|
163
167
|
return { brain: echoBrain, kind: "echo (server)" };
|
|
164
168
|
if (cfg.brainCmd)
|
package/dist/bridge.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { BridgeConfig, Pairing, BrainSpec, BrainBackend } from "./config.js";
|
|
2
|
-
export declare const VERSION = "0.7.
|
|
2
|
+
export declare const VERSION = "0.7.10";
|
|
3
3
|
export interface PairRequest {
|
|
4
4
|
pairingSecret: string;
|
|
5
5
|
appId: string;
|
|
@@ -94,5 +94,5 @@ export declare class BridgeApp {
|
|
|
94
94
|
/** Full status (authenticated). `callerRuntimeId` — when known — keeps the
|
|
95
95
|
* legacy top-level `runtime`/`appId` fields pointing at the CALLER's own
|
|
96
96
|
* pairing so pre-0.6 clients keep working unchanged. */
|
|
97
|
-
statusFull(callerRuntimeId?: string): Record<string, unknown
|
|
97
|
+
statusFull(callerRuntimeId?: string): Promise<Record<string, unknown>>;
|
|
98
98
|
}
|
package/dist/bridge.js
CHANGED
|
@@ -14,10 +14,28 @@ import { saveSettings } from "./config.js";
|
|
|
14
14
|
import { BotRunner } from "./bot.js";
|
|
15
15
|
import { ControlSocket } from "./control.js";
|
|
16
16
|
import { resolveBrain } from "./brain.js";
|
|
17
|
-
import { provisionSoul, provisionHermesProfile } from "./backends.js";
|
|
17
|
+
import { provisionSoul, provisionHermesProfile, resolveBin } from "./backends.js";
|
|
18
18
|
import { reportCapabilities } from "./capabilities.js";
|
|
19
|
+
import { lastHermesApiHealth, probeHermesApi } from "./hermes-http.js";
|
|
20
|
+
import { lastTurnGlobal, turnSnapshot } from "./last-turn.js";
|
|
19
21
|
import { isBrainBackend } from "./config.js";
|
|
20
|
-
export const VERSION = "0.7.
|
|
22
|
+
export const VERSION = "0.7.10";
|
|
23
|
+
function hermesApiStatus(cfg) {
|
|
24
|
+
const api = lastHermesApiHealth();
|
|
25
|
+
return {
|
|
26
|
+
url: cfg.hermesApiUrl,
|
|
27
|
+
healthy: api?.ok === true,
|
|
28
|
+
reason: api ? (api.reason ?? null) : "not probed yet",
|
|
29
|
+
checkedAt: api?.checkedAt ?? null,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function hermesHostStatus(cfg) {
|
|
33
|
+
return {
|
|
34
|
+
cli: Boolean(resolveBin("hermes", "HERMES_BIN")),
|
|
35
|
+
apiUrl: cfg.hermesApiUrl,
|
|
36
|
+
api: hermesApiStatus(cfg),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
21
39
|
/** How often to re-probe + report brain availability to the server. */
|
|
22
40
|
const CAPABILITIES_INTERVAL_MS = 5 * 60_000;
|
|
23
41
|
// ---- Reconnect watchdog -----------------------------------------------------
|
|
@@ -161,6 +179,7 @@ class PairingRuntime {
|
|
|
161
179
|
msSinceHealthy: b.msSinceHealthy(),
|
|
162
180
|
lastForcedRestart: b.lastForcedRestartAt,
|
|
163
181
|
brain: resolveBrain(this.cfg, b.info.handle).kind,
|
|
182
|
+
lastTurn: turnSnapshot(b.lastTurn()),
|
|
164
183
|
}));
|
|
165
184
|
}
|
|
166
185
|
/**
|
|
@@ -590,8 +609,19 @@ export class BridgeApp {
|
|
|
590
609
|
/** Full status (authenticated). `callerRuntimeId` — when known — keeps the
|
|
591
610
|
* legacy top-level `runtime`/`appId` fields pointing at the CALLER's own
|
|
592
611
|
* pairing so pre-0.6 clients keep working unchanged. */
|
|
593
|
-
statusFull(callerRuntimeId) {
|
|
612
|
+
async statusFull(callerRuntimeId) {
|
|
594
613
|
const own = (callerRuntimeId && this.runtimes.find((r) => r.runtimeId === callerRuntimeId)) || this.runtimes[0] || null;
|
|
614
|
+
await probeHermesApi(this.cfg).catch(() => null);
|
|
615
|
+
const bots = this.runtimes.flatMap((r) => r.botStatuses());
|
|
616
|
+
const watchdog = {
|
|
617
|
+
intervalMs: WATCHDOG_INTERVAL_MS,
|
|
618
|
+
softStaleMs: SOFT_STALE_MS,
|
|
619
|
+
hardStaleMs: HARD_STALE_MS,
|
|
620
|
+
hardStaleStreak: this.hardStaleStreak,
|
|
621
|
+
lastCheckAt: this.lastWatchdogAt || null,
|
|
622
|
+
};
|
|
623
|
+
const hermesApi = hermesApiStatus(this.cfg);
|
|
624
|
+
const lastTurn = turnSnapshot(lastTurnGlobal());
|
|
595
625
|
return {
|
|
596
626
|
...this.statusMinimal(),
|
|
597
627
|
runtime: own?.runtimeId ?? null,
|
|
@@ -610,16 +640,13 @@ export class BridgeApp {
|
|
|
610
640
|
provisionCmd: this.cfg.brainProvisionCmd,
|
|
611
641
|
},
|
|
612
642
|
// Legacy flat list = every pairing's bots (pre-0.6 clients read this).
|
|
613
|
-
bots
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
hardStaleStreak: this.hardStaleStreak,
|
|
621
|
-
lastCheckAt: this.lastWatchdogAt || null,
|
|
622
|
-
},
|
|
643
|
+
bots,
|
|
644
|
+
watchdog,
|
|
645
|
+
hermes: hermesHostStatus(this.cfg),
|
|
646
|
+
// Plan fields (docs/HERMES-BRAIN-PLAN.md) — additive, same payload.
|
|
647
|
+
bridge: { ...this.statusMinimal(), watchdog },
|
|
648
|
+
hermesApi,
|
|
649
|
+
lastTurn,
|
|
623
650
|
};
|
|
624
651
|
}
|
|
625
652
|
}
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -7,13 +7,14 @@ export interface BackendCapability {
|
|
|
7
7
|
export interface Capabilities {
|
|
8
8
|
claude: BackendCapability;
|
|
9
9
|
hermes: BackendCapability;
|
|
10
|
+
hermesHttp: BackendCapability;
|
|
10
11
|
}
|
|
11
12
|
/**
|
|
12
13
|
* Probe which native brains are ready on this computer. Best-effort and FREE:
|
|
13
14
|
* binary resolution + credential file/keychain/env presence only, never a paid
|
|
14
15
|
* model call. Echo is always available and isn't probed.
|
|
15
16
|
*/
|
|
16
|
-
export declare function probeCapabilities(
|
|
17
|
+
export declare function probeCapabilities(cfg: BridgeConfig): Promise<Capabilities>;
|
|
17
18
|
/**
|
|
18
19
|
* Probe, then PUT the result to the server so the phone's picker reflects it.
|
|
19
20
|
* Authed with ONE pairing's runtime token — the SAME Bearer auth the bridge
|
package/dist/capabilities.js
CHANGED
|
@@ -11,6 +11,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
11
11
|
import { homedir, platform } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { resolveBin, hermesHome } from "./backends.js";
|
|
14
|
+
import { probeHermesApi } from "./hermes-http.js";
|
|
14
15
|
// Reasons are stable strings the app renders verbatim ("Claude — not logged in").
|
|
15
16
|
const NOT_INSTALLED = "not installed";
|
|
16
17
|
const NOT_LOGGED_IN = "not logged in";
|
|
@@ -109,8 +110,13 @@ function probeHermes() {
|
|
|
109
110
|
* binary resolution + credential file/keychain/env presence only, never a paid
|
|
110
111
|
* model call. Echo is always available and isn't probed.
|
|
111
112
|
*/
|
|
112
|
-
export async function probeCapabilities(
|
|
113
|
-
|
|
113
|
+
export async function probeCapabilities(cfg) {
|
|
114
|
+
const api = await probeHermesApi(cfg);
|
|
115
|
+
return {
|
|
116
|
+
claude: probeClaude(),
|
|
117
|
+
hermes: probeHermes(),
|
|
118
|
+
hermesHttp: api.ok ? { available: true } : { available: false, reason: api.reason ?? "unreachable" },
|
|
119
|
+
};
|
|
114
120
|
}
|
|
115
121
|
function summarize(c) {
|
|
116
122
|
return c.available ? "ok" : (c.reason ?? "unavailable");
|
|
@@ -132,7 +138,7 @@ export async function reportCapabilities(cfg, pairing) {
|
|
|
132
138
|
console.error(`[caps] probe failed: ${err.message}`);
|
|
133
139
|
return;
|
|
134
140
|
}
|
|
135
|
-
console.log(`[caps] claude=${summarize(caps.claude)} hermes=${summarize(caps.hermes)} (reporting as ${pairing.label ?? pairing.appId})`);
|
|
141
|
+
console.log(`[caps] claude=${summarize(caps.claude)} hermes=${summarize(caps.hermes)} hermes-http=${summarize(caps.hermesHttp)} (reporting as ${pairing.label ?? pairing.appId})`);
|
|
136
142
|
try {
|
|
137
143
|
const res = await fetch(`${pairing.apiUrl}/v1/runtime/capabilities`, {
|
|
138
144
|
method: "PUT",
|
|
@@ -140,7 +146,7 @@ export async function reportCapabilities(cfg, pairing) {
|
|
|
140
146
|
authorization: `Bearer ${pairing.pairingCode}`,
|
|
141
147
|
"content-type": "application/json",
|
|
142
148
|
},
|
|
143
|
-
body: JSON.stringify({ backends: { claude: caps.claude, hermes: caps.hermes } }),
|
|
149
|
+
body: JSON.stringify({ backends: { claude: caps.claude, hermes: caps.hermes, hermesHttp: caps.hermesHttp } }),
|
|
144
150
|
signal: AbortSignal.timeout(15_000),
|
|
145
151
|
});
|
|
146
152
|
if (!res.ok) {
|
package/dist/cli.js
CHANGED
|
@@ -92,6 +92,7 @@ console.log(`[bridge] NoPeek agent-bridge v${VERSION} starting`);
|
|
|
92
92
|
console.log(`[bridge] api=${cfg.apiUrl} accounts=${cfg.pairings.length ? cfg.pairings.map((p) => p.label ?? p.appId).join(", ") : "(not paired)"} data=${cfg.dataDir} local-api=:${cfg.port} home=${cfg.homeDir}`);
|
|
93
93
|
console.log(`[bridge] default brain: ${cfg.brainCmd ? "cmd" : cfg.brainUrl ? "url" : "echo (choose an agent in the NoPeek app, or set --brain-cmd)"}` +
|
|
94
94
|
(Object.keys(cfg.brainMap).length ? ` + ${Object.keys(cfg.brainMap).length} per-bot override(s)` : ""));
|
|
95
|
+
console.log(`[bridge] hermes-api=${cfg.hermesApiUrl} timeout=${cfg.brainTimeoutMs}ms`);
|
|
95
96
|
const app = new BridgeApp(cfg);
|
|
96
97
|
let localApi;
|
|
97
98
|
try {
|
package/dist/config.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Built-in native backends (see backends.ts): claude = Claude Code headless,
|
|
2
2
|
* hermes = per-bot Hermes profile, echo = smoke test. */
|
|
3
|
-
export type BrainBackend = "claude" | "hermes" | "echo";
|
|
3
|
+
export type BrainBackend = "claude" | "hermes" | "hermes-http" | "echo";
|
|
4
4
|
/** Per-bot brain override, keyed by bot handle in BRAIN_MAP.
|
|
5
5
|
* `backend` selects a native built-in (claude/hermes/echo);
|
|
6
6
|
* `echo: true` pins the bot to echo mode even when a global brain is set. */
|
|
@@ -69,6 +69,15 @@ export interface BridgeConfig {
|
|
|
69
69
|
brainTimeoutMs: number;
|
|
70
70
|
/** Where `install` sends the user to finish setup (opened in the browser). */
|
|
71
71
|
appUrl: string;
|
|
72
|
+
/**
|
|
73
|
+
* Hermes agent API server (gateway `api_server` platform). When set, the
|
|
74
|
+
* hermes brain talks HTTP (`/v1/chat/completions` + `/health`) instead of
|
|
75
|
+
* shelling out to `hermes chat`. Flag changes in the CLI then cannot silently
|
|
76
|
+
* kill bots. Default: HERMES_API_URL or http://127.0.0.1:8642.
|
|
77
|
+
*/
|
|
78
|
+
hermesApiUrl: string;
|
|
79
|
+
/** Optional bearer for the Hermes API server (`API_SERVER_KEY`). */
|
|
80
|
+
hermesApiKey: string | null;
|
|
72
81
|
/** Local control API port (status + pairing + brain config, loopback only). */
|
|
73
82
|
port: number;
|
|
74
83
|
/** Where per-bot device identity/key stores live. */
|
|
@@ -82,9 +91,10 @@ export interface BridgeConfig {
|
|
|
82
91
|
export declare const DEFAULT_API_URL = "https://d3qweh72vesa98.cloudfront.net";
|
|
83
92
|
export declare const DEFAULT_APP_URL = "https://d32w3to73s0pu5.cloudfront.net/messenger/";
|
|
84
93
|
export declare const DEFAULT_PORT = 8790;
|
|
85
|
-
export declare const DEFAULT_BRAIN_TIMEOUT_MS =
|
|
94
|
+
export declare const DEFAULT_BRAIN_TIMEOUT_MS = 900000;
|
|
95
|
+
export declare const DEFAULT_HERMES_API_URL = "http://127.0.0.1:8642";
|
|
86
96
|
export declare function defaultHomeDir(): string;
|
|
87
|
-
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
|
|
97
|
+
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 idle timeout, default 900000 (env BRAIN_TIMEOUT_MS)\n --hermes-api-url <url> Hermes agent API (default http://127.0.0.1:8642) (env HERMES_API_URL)\n --hermes-api-key <key> Bearer for the Hermes API server (env HERMES_API_KEY)\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.";
|
|
88
98
|
/** Load config from argv + env + cwd config file + home settings. Never
|
|
89
99
|
* requires pairing — an unpaired bridge waits for the app to pair it. */
|
|
90
100
|
export declare function loadConfig(argv?: string[]): BridgeConfig;
|
package/dist/config.js
CHANGED
|
@@ -10,17 +10,17 @@ import { homedir } from "node:os";
|
|
|
10
10
|
import { join, resolve } from "node:path";
|
|
11
11
|
import { parseArgs } from "node:util";
|
|
12
12
|
export function isBrainBackend(v) {
|
|
13
|
-
return v === "claude" || v === "hermes" || v === "echo";
|
|
13
|
+
return v === "claude" || v === "hermes" || v === "hermes-http" || v === "echo";
|
|
14
14
|
}
|
|
15
15
|
export const DEFAULT_API_URL = "https://d3qweh72vesa98.cloudfront.net";
|
|
16
16
|
export const DEFAULT_APP_URL = "https://d32w3to73s0pu5.cloudfront.net/messenger/";
|
|
17
17
|
export const DEFAULT_PORT = 8790;
|
|
18
|
-
// Idle timeout (resets on any child-process output
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
export const
|
|
18
|
+
// Idle timeout (resets on any child-process output OR CPU-time advance —
|
|
19
|
+
// see backends.ts). Hermes -Q often does real tool work with zero stdout for
|
|
20
|
+
// many minutes; 15 min of *true* silence (no bytes AND no CPU) is the kill
|
|
21
|
+
// line. A hung process that is not scheduled still dies; a working agent does not.
|
|
22
|
+
export const DEFAULT_BRAIN_TIMEOUT_MS = 900_000;
|
|
23
|
+
export const DEFAULT_HERMES_API_URL = "http://127.0.0.1:8642";
|
|
24
24
|
export function defaultHomeDir() {
|
|
25
25
|
return process.env.NOPEEK_BRIDGE_HOME || join(homedir(), ".nopeek-bridge");
|
|
26
26
|
}
|
|
@@ -33,6 +33,8 @@ const CLI_OPTIONS = {
|
|
|
33
33
|
"brain-map": { type: "string" },
|
|
34
34
|
"brain-provision-cmd": { type: "string" },
|
|
35
35
|
"brain-timeout-ms": { type: "string" },
|
|
36
|
+
"hermes-api-url": { type: "string" },
|
|
37
|
+
"hermes-api-key": { type: "string" },
|
|
36
38
|
"decline-message": { type: "string" },
|
|
37
39
|
"app-url": { type: "string" },
|
|
38
40
|
"no-open": { type: "boolean" },
|
|
@@ -52,6 +54,8 @@ const FLAG_TO_KEY = {
|
|
|
52
54
|
"brain-map": "BRAIN_MAP",
|
|
53
55
|
"brain-provision-cmd": "BRAIN_PROVISION_CMD",
|
|
54
56
|
"brain-timeout-ms": "BRAIN_TIMEOUT_MS",
|
|
57
|
+
"hermes-api-url": "HERMES_API_URL",
|
|
58
|
+
"hermes-api-key": "HERMES_API_KEY",
|
|
55
59
|
"decline-message": "NOPEEK_DECLINE_MESSAGE",
|
|
56
60
|
"app-url": "NOPEEK_APP_URL",
|
|
57
61
|
port: "NOPEEK_BRIDGE_PORT",
|
|
@@ -83,7 +87,9 @@ Options:
|
|
|
83
87
|
fresh Hermes profile with its own soul + memory)
|
|
84
88
|
--app-url <url> App opened after install (env NOPEEK_APP_URL)
|
|
85
89
|
--no-open install: don't open the app in the browser
|
|
86
|
-
--brain-timeout-ms <ms> Brain timeout, default ${DEFAULT_BRAIN_TIMEOUT_MS}
|
|
90
|
+
--brain-timeout-ms <ms> Brain idle timeout, default ${DEFAULT_BRAIN_TIMEOUT_MS} (env BRAIN_TIMEOUT_MS)
|
|
91
|
+
--hermes-api-url <url> Hermes agent API (default ${DEFAULT_HERMES_API_URL}) (env HERMES_API_URL)
|
|
92
|
+
--hermes-api-key <key> Bearer for the Hermes API server (env HERMES_API_KEY)
|
|
87
93
|
--port <port> Local control API port, default ${DEFAULT_PORT} (env NOPEEK_BRIDGE_PORT)
|
|
88
94
|
--data-dir <dir> Device-key store dir (env NOPEEK_BRIDGE_DATA_DIR)
|
|
89
95
|
--home <dir> Bridge home, default ~/.nopeek-bridge (env NOPEEK_BRIDGE_HOME)
|
|
@@ -132,7 +138,7 @@ function parseBrainMap(raw) {
|
|
|
132
138
|
}
|
|
133
139
|
const { cmd, url, backend, echo } = spec;
|
|
134
140
|
if (backend !== undefined && !isBrainBackend(backend)) {
|
|
135
|
-
throw new Error(`BRAIN_MAP["${handle}"].backend must be "claude", "hermes" or "echo"`);
|
|
141
|
+
throw new Error(`BRAIN_MAP["${handle}"].backend must be "claude", "hermes", "hermes-http" or "echo"`);
|
|
136
142
|
}
|
|
137
143
|
if (typeof cmd !== "string" && typeof url !== "string" && !isBrainBackend(backend) && echo !== true) {
|
|
138
144
|
throw new Error(`BRAIN_MAP["${handle}"] needs a "cmd", "url" or "backend" ("claude"|"hermes"|"echo")`);
|
|
@@ -286,6 +292,8 @@ export function loadConfig(argv = process.argv.slice(2)) {
|
|
|
286
292
|
serverBackends: parseServerBackends(process.env.NOPEEK_SERVER_BACKENDS ?? file.NOPEEK_SERVER_BACKENDS ?? saved.NOPEEK_SERVER_BACKENDS),
|
|
287
293
|
brainProvisionCmd: get("brain-provision-cmd") ?? null,
|
|
288
294
|
brainTimeoutMs,
|
|
295
|
+
hermesApiUrl: (get("hermes-api-url") ?? process.env.HERMES_API_URL ?? file.HERMES_API_URL ?? saved.HERMES_API_URL ?? DEFAULT_HERMES_API_URL).replace(/\/+$/, ""),
|
|
296
|
+
hermesApiKey: get("hermes-api-key") ?? process.env.HERMES_API_KEY ?? file.HERMES_API_KEY ?? saved.HERMES_API_KEY ?? null,
|
|
289
297
|
port,
|
|
290
298
|
dataDir,
|
|
291
299
|
homeDir,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { BridgeConfig } from "./config.js";
|
|
2
|
+
import type { Brain } from "./brain.js";
|
|
3
|
+
export interface HermesApiHealth {
|
|
4
|
+
ok: boolean;
|
|
5
|
+
url: string;
|
|
6
|
+
reason?: string;
|
|
7
|
+
checkedAt: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function lastHermesApiHealth(): HermesApiHealth | null;
|
|
10
|
+
export declare function probeHermesApi(cfg: BridgeConfig, force?: boolean): Promise<HermesApiHealth>;
|
|
11
|
+
/**
|
|
12
|
+
* Stream one turn through Hermes' OpenAI-compatible chat completions.
|
|
13
|
+
* Session continuity: X-Hermes-Session-Key = nopeek-<channelId> (stable per chat).
|
|
14
|
+
*/
|
|
15
|
+
export declare function hermesHttpBrain(cfg: BridgeConfig): Brain;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
let lastHealth = null;
|
|
2
|
+
const HEALTH_TTL_MS = 15_000;
|
|
3
|
+
export function lastHermesApiHealth() {
|
|
4
|
+
return lastHealth;
|
|
5
|
+
}
|
|
6
|
+
export async function probeHermesApi(cfg, force = false) {
|
|
7
|
+
const url = cfg.hermesApiUrl.replace(/\/+$/, "");
|
|
8
|
+
if (!force && lastHealth && Date.now() - lastHealth.checkedAt < HEALTH_TTL_MS && lastHealth.url === url) {
|
|
9
|
+
return lastHealth;
|
|
10
|
+
}
|
|
11
|
+
const headers = {};
|
|
12
|
+
if (cfg.hermesApiKey)
|
|
13
|
+
headers.authorization = `Bearer ${cfg.hermesApiKey}`;
|
|
14
|
+
try {
|
|
15
|
+
const res = await fetch(`${url}/health`, {
|
|
16
|
+
headers,
|
|
17
|
+
signal: AbortSignal.timeout(3_000),
|
|
18
|
+
});
|
|
19
|
+
lastHealth = res.ok
|
|
20
|
+
? { ok: true, url, checkedAt: Date.now() }
|
|
21
|
+
: { ok: false, url, reason: `HTTP ${res.status}`, checkedAt: Date.now() };
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
lastHealth = {
|
|
25
|
+
ok: false,
|
|
26
|
+
url,
|
|
27
|
+
reason: err.message || "unreachable",
|
|
28
|
+
checkedAt: Date.now(),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return lastHealth;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Stream one turn through Hermes' OpenAI-compatible chat completions.
|
|
35
|
+
* Session continuity: X-Hermes-Session-Key = nopeek-<channelId> (stable per chat).
|
|
36
|
+
*/
|
|
37
|
+
export function hermesHttpBrain(cfg) {
|
|
38
|
+
return async (text, ctx, onChunk) => {
|
|
39
|
+
const handle = ctx.botHandle.replace(/^@/, "");
|
|
40
|
+
const tag = `[brain:hermes-http:@${handle}]`;
|
|
41
|
+
const url = cfg.hermesApiUrl.replace(/\/+$/, "");
|
|
42
|
+
const headers = {
|
|
43
|
+
"content-type": "application/json",
|
|
44
|
+
"X-Hermes-Session-Key": `nopeek-${ctx.channelId}`,
|
|
45
|
+
};
|
|
46
|
+
if (cfg.hermesApiKey)
|
|
47
|
+
headers.authorization = `Bearer ${cfg.hermesApiKey}`;
|
|
48
|
+
// Idle abort — same rule as the CLI path. A wall-clock timeout on the
|
|
49
|
+
// whole POST would kill a healthy 20-minute agentic turn. Any SSE byte
|
|
50
|
+
// (token, keepalive, tool-progress) resets the idle timer.
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
let idle;
|
|
53
|
+
const armIdle = () => {
|
|
54
|
+
clearTimeout(idle);
|
|
55
|
+
idle = setTimeout(() => {
|
|
56
|
+
console.error(`${tag} idle ${cfg.brainTimeoutMs / 1000}s (no SSE), aborting`);
|
|
57
|
+
controller.abort();
|
|
58
|
+
}, cfg.brainTimeoutMs);
|
|
59
|
+
};
|
|
60
|
+
armIdle();
|
|
61
|
+
let res;
|
|
62
|
+
try {
|
|
63
|
+
res = await fetch(`${url}/v1/chat/completions`, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers,
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
model: "hermes-agent",
|
|
68
|
+
stream: true,
|
|
69
|
+
messages: [{ role: "user", content: text }],
|
|
70
|
+
}),
|
|
71
|
+
signal: controller.signal,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
clearTimeout(idle);
|
|
76
|
+
console.error(`${tag} request failed: ${err.message}`);
|
|
77
|
+
return "";
|
|
78
|
+
}
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
clearTimeout(idle);
|
|
81
|
+
const body = await res.text().catch(() => "");
|
|
82
|
+
console.error(`${tag} HTTP ${res.status}: ${body.slice(0, 300)}`);
|
|
83
|
+
return "";
|
|
84
|
+
}
|
|
85
|
+
if (!res.body) {
|
|
86
|
+
clearTimeout(idle);
|
|
87
|
+
console.error(`${tag} empty body`);
|
|
88
|
+
return "";
|
|
89
|
+
}
|
|
90
|
+
const reader = res.body.getReader();
|
|
91
|
+
const decoder = new TextDecoder();
|
|
92
|
+
let buf = "";
|
|
93
|
+
let reply = "";
|
|
94
|
+
try {
|
|
95
|
+
while (true) {
|
|
96
|
+
const { done, value } = await reader.read();
|
|
97
|
+
if (done)
|
|
98
|
+
break;
|
|
99
|
+
armIdle();
|
|
100
|
+
buf += decoder.decode(value, { stream: true });
|
|
101
|
+
const lines = buf.split("\n");
|
|
102
|
+
buf = lines.pop() ?? "";
|
|
103
|
+
for (const line of lines) {
|
|
104
|
+
const trimmed = line.trim();
|
|
105
|
+
if (!trimmed.startsWith("data:"))
|
|
106
|
+
continue;
|
|
107
|
+
const payload = trimmed.slice(5).trim();
|
|
108
|
+
if (!payload || payload === "[DONE]")
|
|
109
|
+
continue;
|
|
110
|
+
let parsed;
|
|
111
|
+
try {
|
|
112
|
+
parsed = JSON.parse(payload);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const delta = extractDelta(parsed);
|
|
118
|
+
if (!delta)
|
|
119
|
+
continue;
|
|
120
|
+
reply += delta;
|
|
121
|
+
onChunk?.(delta);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
const msg = err.message || "";
|
|
127
|
+
if (controller.signal.aborted) {
|
|
128
|
+
return `⚠️ My brain went quiet for over ${Math.round(cfg.brainTimeoutMs / 1000)}s with no output and I had to give up on that turn — a long tool-use step or a very slow provider response can trigger this. Try messaging me again.`;
|
|
129
|
+
}
|
|
130
|
+
console.error(`${tag} stream error: ${msg}`);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
clearTimeout(idle);
|
|
134
|
+
}
|
|
135
|
+
return reply.trim();
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function extractDelta(parsed) {
|
|
139
|
+
if (!parsed || typeof parsed !== "object")
|
|
140
|
+
return "";
|
|
141
|
+
const obj = parsed;
|
|
142
|
+
const choice = obj.choices?.[0];
|
|
143
|
+
const fromChoice = choice?.delta?.content ?? choice?.message?.content;
|
|
144
|
+
if (typeof fromChoice === "string")
|
|
145
|
+
return fromChoice;
|
|
146
|
+
// Hermes also emits custom SSE tool-progress events; ignore those for the
|
|
147
|
+
// chat body (they keep the HTTP connection alive, which is the point).
|
|
148
|
+
if (typeof obj.data?.content === "string")
|
|
149
|
+
return obj.data.content;
|
|
150
|
+
if (typeof obj.data?.text === "string")
|
|
151
|
+
return obj.data.text;
|
|
152
|
+
return "";
|
|
153
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface BrainTurn {
|
|
2
|
+
handle: string;
|
|
3
|
+
channelId: string;
|
|
4
|
+
via: string;
|
|
5
|
+
startedAt: number;
|
|
6
|
+
endedAt: number | null;
|
|
7
|
+
inFlight: boolean;
|
|
8
|
+
ok: boolean | null;
|
|
9
|
+
chars: number;
|
|
10
|
+
timedOut: boolean;
|
|
11
|
+
error: string | null;
|
|
12
|
+
}
|
|
13
|
+
export declare function beginTurn(handle: string, channelId: string, via: string): BrainTurn;
|
|
14
|
+
export declare function finishTurn(t: BrainTurn, result: {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
chars?: number;
|
|
17
|
+
timedOut?: boolean;
|
|
18
|
+
error?: string | null;
|
|
19
|
+
}): void;
|
|
20
|
+
export declare function lastTurnFor(handle: string): BrainTurn | null;
|
|
21
|
+
export declare function lastTurnGlobal(): BrainTurn | null;
|
|
22
|
+
export declare function turnSnapshot(t: BrainTurn | null): Record<string, unknown> | null;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const byHandle = new Map();
|
|
2
|
+
let global = null;
|
|
3
|
+
export function beginTurn(handle, channelId, via) {
|
|
4
|
+
const t = {
|
|
5
|
+
handle: handle.replace(/^@/, ""),
|
|
6
|
+
channelId,
|
|
7
|
+
via,
|
|
8
|
+
startedAt: Date.now(),
|
|
9
|
+
endedAt: null,
|
|
10
|
+
inFlight: true,
|
|
11
|
+
ok: null,
|
|
12
|
+
chars: 0,
|
|
13
|
+
timedOut: false,
|
|
14
|
+
error: null,
|
|
15
|
+
};
|
|
16
|
+
byHandle.set(t.handle, t);
|
|
17
|
+
global = t;
|
|
18
|
+
return t;
|
|
19
|
+
}
|
|
20
|
+
export function finishTurn(t, result) {
|
|
21
|
+
t.endedAt = Date.now();
|
|
22
|
+
t.inFlight = false;
|
|
23
|
+
t.ok = result.ok;
|
|
24
|
+
t.chars = result.chars ?? 0;
|
|
25
|
+
t.timedOut = result.timedOut === true;
|
|
26
|
+
t.error = result.error ?? null;
|
|
27
|
+
}
|
|
28
|
+
export function lastTurnFor(handle) {
|
|
29
|
+
return byHandle.get(handle.replace(/^@/, "")) ?? null;
|
|
30
|
+
}
|
|
31
|
+
export function lastTurnGlobal() {
|
|
32
|
+
return global;
|
|
33
|
+
}
|
|
34
|
+
export function turnSnapshot(t) {
|
|
35
|
+
if (!t)
|
|
36
|
+
return null;
|
|
37
|
+
return {
|
|
38
|
+
handle: t.handle,
|
|
39
|
+
channelId: t.channelId,
|
|
40
|
+
via: t.via,
|
|
41
|
+
startedAt: t.startedAt,
|
|
42
|
+
endedAt: t.endedAt,
|
|
43
|
+
ms: t.endedAt != null ? t.endedAt - t.startedAt : Date.now() - t.startedAt,
|
|
44
|
+
inFlight: t.inFlight,
|
|
45
|
+
ok: t.ok,
|
|
46
|
+
chars: t.chars,
|
|
47
|
+
timedOut: t.timedOut,
|
|
48
|
+
error: t.error,
|
|
49
|
+
};
|
|
50
|
+
}
|
package/dist/localapi.js
CHANGED
|
@@ -164,7 +164,7 @@ async function handle(app, req, res) {
|
|
|
164
164
|
return;
|
|
165
165
|
}
|
|
166
166
|
if (method === "GET" && path === "/status") {
|
|
167
|
-
json(res, 200, app.statusFull(auth.runtimeId));
|
|
167
|
+
json(res, 200, await app.statusFull(auth.runtimeId));
|
|
168
168
|
return;
|
|
169
169
|
}
|
|
170
170
|
if (method === "GET" && path === "/detect") {
|
|
@@ -181,7 +181,7 @@ async function handle(app, req, res) {
|
|
|
181
181
|
return;
|
|
182
182
|
}
|
|
183
183
|
app.setBrains(body);
|
|
184
|
-
json(res, 200, { ok: true, status: app.statusFull(auth.runtimeId) });
|
|
184
|
+
json(res, 200, { ok: true, status: await app.statusFull(auth.runtimeId) });
|
|
185
185
|
return;
|
|
186
186
|
}
|
|
187
187
|
// ?runtimeId=rt_… removes ONE account's pairing; no query = remove ALL
|
package/dist/service.d.ts
CHANGED
|
@@ -1,15 +1,4 @@
|
|
|
1
1
|
import type { BridgeConfig } from "./config.js";
|
|
2
|
-
/**
|
|
3
|
-
* Periodic self-update: compares the running version against npm's published
|
|
4
|
-
* `latest` and, if newer, re-installs using the SAME method this process is
|
|
5
|
-
* actually running from (global npm root, or the private <home>/app copy —
|
|
6
|
-
* see resolveEntrypoint above) and exits so the always-restart service
|
|
7
|
-
* relaunches running the fresh code. Only acts on an install method it
|
|
8
|
-
* recognizes — a pnpm-linked dev checkout or anything unrecognized is left
|
|
9
|
-
* alone (safety over completeness). This is how a published fix (like a
|
|
10
|
-
* reconnect-reliability bug) actually reaches already-installed bridges
|
|
11
|
-
* instead of sitting unused until someone manually reinstalls.
|
|
12
|
-
*/
|
|
13
2
|
export declare function checkForUpdate(currentVersion: string, homeDir: string): Promise<void>;
|
|
14
3
|
export declare function installService(cfg: BridgeConfig, noOpen?: boolean): Promise<void>;
|
|
15
4
|
export declare function uninstallService(): void;
|
package/dist/service.js
CHANGED
|
@@ -43,7 +43,21 @@ function resolveEntrypoint(homeDir) {
|
|
|
43
43
|
* reconnect-reliability bug) actually reaches already-installed bridges
|
|
44
44
|
* instead of sitting unused until someone manually reinstalls.
|
|
45
45
|
*/
|
|
46
|
+
function isNewerVersion(latest, current) {
|
|
47
|
+
const a = latest.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
48
|
+
const b = current.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
49
|
+
const n = Math.max(a.length, b.length);
|
|
50
|
+
for (let i = 0; i < n; i++) {
|
|
51
|
+
if ((a[i] ?? 0) > (b[i] ?? 0))
|
|
52
|
+
return true;
|
|
53
|
+
if ((a[i] ?? 0) < (b[i] ?? 0))
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
46
58
|
export async function checkForUpdate(currentVersion, homeDir) {
|
|
59
|
+
if (process.env.NOPEEK_BRIDGE_NO_AUTO_UPDATE === "1")
|
|
60
|
+
return;
|
|
47
61
|
let latest;
|
|
48
62
|
try {
|
|
49
63
|
const res = await fetch("https://registry.npmjs.org/@nopeek/agent-bridge/latest", {
|
|
@@ -57,7 +71,10 @@ export async function checkForUpdate(currentVersion, homeDir) {
|
|
|
57
71
|
catch {
|
|
58
72
|
return; // offline / registry unreachable — try again next check
|
|
59
73
|
}
|
|
60
|
-
|
|
74
|
+
// Only UPGRADE. A local/unpublished build (0.7.10) must never be replaced
|
|
75
|
+
// by an older published latest (0.7.8) — that exact downgrade put the 300s
|
|
76
|
+
// idle kill back on this machine after we installed the fix.
|
|
77
|
+
if (!latest || !isNewerVersion(latest, currentVersion))
|
|
61
78
|
return;
|
|
62
79
|
const self = realpathSync(process.argv[1]);
|
|
63
80
|
let installArgs = null;
|
|
@@ -123,6 +140,7 @@ export async function installService(cfg, noOpen = false) {
|
|
|
123
140
|
<key>EnvironmentVariables</key>
|
|
124
141
|
<dict>
|
|
125
142
|
<key>NOPEEK_BRIDGE_HOME</key><string>${xmlEscape(cfg.homeDir)}</string>
|
|
143
|
+
<key>NOPEEK_BRIDGE_NO_AUTO_UPDATE</key><string>${xmlEscape(process.env.NOPEEK_BRIDGE_NO_AUTO_UPDATE ?? "0")}</string>
|
|
126
144
|
<key>PATH</key><string>${xmlEscape(process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin")}</string>
|
|
127
145
|
</dict>
|
|
128
146
|
<key>RunAtLoad</key><true/>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nopeek/agent-bridge",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.10",
|
|
4
4
|
"description": "Run your own agents as E2EE NoPeek bots. Pairs with one-time codes (multiple accounts per computer), runs every bot each account owns, and pipes messages to any command or webhook.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|