@nopeek/agent-bridge 0.7.8 → 0.7.11
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 +9 -0
- package/dist/backends.d.ts +12 -0
- package/dist/backends.js +89 -6
- package/dist/bot.d.ts +3 -0
- package/dist/bot.js +33 -7
- package/dist/brain.d.ts +8 -1
- package/dist/brain.js +14 -2
- 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 +209 -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/dist/tool-progress.d.ts +29 -0
- package/dist/tool-progress.js +105 -0
- package/package.json +10 -9
package/README.md
CHANGED
|
@@ -39,6 +39,15 @@ 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
|
+
While Hermes is working, the bot posts Telegram-style tool lines a few at a
|
|
45
|
+
time (read file, patch, skill, memory). A quiet gap starts a new message.
|
|
46
|
+
The final reply is the answer plus a short recap when tools were used.
|
|
47
|
+
Idle kill waits for **no output and no CPU** (default 15 min). Authenticated
|
|
48
|
+
`GET /status` includes `bridge`, `bots[]` (each with `lastTurn`), `hermesApi`,
|
|
49
|
+
and `lastTurn`. See `docs/HERMES-BRAIN-PLAN.md`.
|
|
50
|
+
|
|
42
51
|
## Local control API
|
|
43
52
|
|
|
44
53
|
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
|
@@ -16,7 +16,8 @@ import { createHash } from "node:crypto";
|
|
|
16
16
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
18
|
import { basename, join } from "node:path";
|
|
19
|
-
import { stripAnsi } from "./brain.js";
|
|
19
|
+
import { asHooks, 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) —
|
|
@@ -188,7 +224,8 @@ function runClaudeOnce(bin, args, text, cwd, timeoutMs, tag, onDelta) {
|
|
|
188
224
|
* host powers from wherever the bridge happens to run.
|
|
189
225
|
*/
|
|
190
226
|
export function claudeBrain(cfg) {
|
|
191
|
-
return async (text, ctx,
|
|
227
|
+
return async (text, ctx, hooks) => {
|
|
228
|
+
const { onChunk } = asHooks(hooks);
|
|
192
229
|
const bin = resolveBin("claude", "CLAUDE_BIN");
|
|
193
230
|
if (!bin) {
|
|
194
231
|
console.error(`[brain:claude:@${ctx.botHandle}] claude not found on PATH`);
|
|
@@ -266,8 +303,15 @@ you reply. Your replies are chat messages — every word you print is sent verba
|
|
|
266
303
|
## Guardrails
|
|
267
304
|
- Never expose secrets, keys, or tokens.
|
|
268
305
|
- Confirm before destructive or outward-facing actions.
|
|
269
|
-
- Report honestly
|
|
306
|
+
- Report honestly. If something failed, say so.
|
|
270
307
|
- If a request is far outside your purpose, say so briefly and offer what you can do.
|
|
308
|
+
|
|
309
|
+
## After tool work
|
|
310
|
+
When you used tools this turn (read or wrote files, ran commands, changed skills, saved memory), end with a short recap:
|
|
311
|
+
- What was going on (one line).
|
|
312
|
+
- What you did (one or two lines).
|
|
313
|
+
- Result (one line).
|
|
314
|
+
Skip the recap for simple conversation with no tools.
|
|
271
315
|
`;
|
|
272
316
|
/**
|
|
273
317
|
* Create (or reuse) the bot's isolated Hermes profile: its own SOUL.md and
|
|
@@ -349,7 +393,7 @@ function detectHermesProfileMode(bin) {
|
|
|
349
393
|
console.log(`[hermes] profile selection mode: ${hermesProfileMode}`);
|
|
350
394
|
return hermesProfileMode;
|
|
351
395
|
}
|
|
352
|
-
|
|
396
|
+
function hermesCliBrain(cfg) {
|
|
353
397
|
const runOnce = (profile, text, sessionName, tag, onChunk) => new Promise((resolvePromise) => {
|
|
354
398
|
const bin = resolveBin("hermes", "HERMES_BIN");
|
|
355
399
|
const mode = detectHermesProfileMode(bin);
|
|
@@ -418,6 +462,7 @@ export function hermesBrain(cfg) {
|
|
|
418
462
|
if (settled)
|
|
419
463
|
return;
|
|
420
464
|
settled = true;
|
|
465
|
+
clearInterval(cpuPoll);
|
|
421
466
|
if (lineBuf)
|
|
422
467
|
handleLine(lineBuf);
|
|
423
468
|
// The session footer lands on STDERR in -Q mode — scan there too.
|
|
@@ -443,13 +488,28 @@ export function hermesBrain(cfg) {
|
|
|
443
488
|
const armIdleTimer = () => {
|
|
444
489
|
clearTimeout(timer);
|
|
445
490
|
timer = setTimeout(() => {
|
|
446
|
-
console.error(`${tag} idle ${cfg.brainTimeoutMs / 1000}s (no output), killing`);
|
|
491
|
+
console.error(`${tag} idle ${cfg.brainTimeoutMs / 1000}s (no output and no CPU), killing`);
|
|
447
492
|
timedOut = true;
|
|
448
493
|
child.kill("SIGKILL");
|
|
449
494
|
finish();
|
|
450
495
|
}, cfg.brainTimeoutMs);
|
|
451
496
|
};
|
|
452
497
|
armIdleTimer();
|
|
498
|
+
// Hermes -Q is silent during long tool use. Reset the idle timer whenever
|
|
499
|
+
// the child's accumulated CPU time advances — that is real work, not a hang.
|
|
500
|
+
let lastCpu = cpuTimeSeconds(child.pid);
|
|
501
|
+
const cpuPoll = setInterval(() => {
|
|
502
|
+
if (settled || child.pid == null)
|
|
503
|
+
return;
|
|
504
|
+
const now = cpuTimeSeconds(child.pid);
|
|
505
|
+
if (now != null && lastCpu != null && now > lastCpu + 0.05) {
|
|
506
|
+
lastCpu = now;
|
|
507
|
+
armIdleTimer();
|
|
508
|
+
}
|
|
509
|
+
else if (now != null && lastCpu == null) {
|
|
510
|
+
lastCpu = now;
|
|
511
|
+
}
|
|
512
|
+
}, 10_000);
|
|
453
513
|
child.stdout.on("data", (d) => {
|
|
454
514
|
armIdleTimer();
|
|
455
515
|
lineBuf += d.toString();
|
|
@@ -475,7 +535,8 @@ export function hermesBrain(cfg) {
|
|
|
475
535
|
finish();
|
|
476
536
|
});
|
|
477
537
|
});
|
|
478
|
-
return async (text, ctx,
|
|
538
|
+
return async (text, ctx, hooks) => {
|
|
539
|
+
const { onChunk } = asHooks(hooks);
|
|
479
540
|
const handle = ctx.botHandle.replace(/^@/, "");
|
|
480
541
|
const tag = `[brain:hermes:@${handle}]`;
|
|
481
542
|
if (!resolveBin("hermes", "HERMES_BIN")) {
|
|
@@ -546,3 +607,25 @@ export function hermesBrain(cfg) {
|
|
|
546
607
|
return run.reply;
|
|
547
608
|
};
|
|
548
609
|
}
|
|
610
|
+
/**
|
|
611
|
+
* Hermes brain. Prefers the long-running agent API (`HERMES_API_URL`, default
|
|
612
|
+
* loopback :8642) when `/health` is up — that path does not shell out, so a
|
|
613
|
+
* CLI flag change cannot empty-reply the bot. Falls back to `hermes chat -Q`.
|
|
614
|
+
*/
|
|
615
|
+
export function hermesBrain(cfg) {
|
|
616
|
+
const cli = hermesCliBrain(cfg);
|
|
617
|
+
const http = hermesHttpBrain(cfg);
|
|
618
|
+
return async (text, ctx, hooks) => {
|
|
619
|
+
const health = await probeHermesApi(cfg);
|
|
620
|
+
if (health.ok) {
|
|
621
|
+
const reply = await http(text, ctx, hooks);
|
|
622
|
+
if (reply)
|
|
623
|
+
return reply;
|
|
624
|
+
console.error(`[brain:hermes:@${ctx.botHandle.replace(/^@/, "")}] API returned empty — falling back to CLI`);
|
|
625
|
+
}
|
|
626
|
+
return cli(text, ctx, hooks);
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
export function hermesHttpOnlyBrain(cfg) {
|
|
630
|
+
return hermesHttpBrain(cfg);
|
|
631
|
+
}
|
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,7 +5,9 @@
|
|
|
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";
|
|
10
|
+
import { ToolProgressFlusher } from "./tool-progress.js";
|
|
9
11
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
10
12
|
const MAX_BACKOFF_MS = 60_000;
|
|
11
13
|
// Owner-membership answers are cached per channel for a short window; a
|
|
@@ -94,6 +96,10 @@ export class BotRunner {
|
|
|
94
96
|
get lastForcedRestartAt() {
|
|
95
97
|
return this.lastForcedRestart;
|
|
96
98
|
}
|
|
99
|
+
/** Most recent brain turn for this bot (shared last-turn store). */
|
|
100
|
+
lastTurn() {
|
|
101
|
+
return lastTurnFor(this.info.handle);
|
|
102
|
+
}
|
|
97
103
|
/**
|
|
98
104
|
* Watchdog hard restart: tear the SDK client down completely and rebuild it
|
|
99
105
|
* from scratch (fresh session mint + NoPeek.connect) — the proven-working
|
|
@@ -546,16 +552,20 @@ export class BotRunner {
|
|
|
546
552
|
// append onto the open promise keeps them ordered and loses none.
|
|
547
553
|
// (Ref object rather than a `let`: TS can't see closure assignments.)
|
|
548
554
|
const streamRef = { p: null };
|
|
555
|
+
const stopTyping = () => {
|
|
556
|
+
try {
|
|
557
|
+
ch.typing(false);
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
/* best-effort */
|
|
561
|
+
}
|
|
562
|
+
};
|
|
549
563
|
const onChunk = (delta) => {
|
|
550
564
|
if (!delta)
|
|
551
565
|
return;
|
|
566
|
+
void progress.flush();
|
|
552
567
|
if (!streamRef.p) {
|
|
553
|
-
|
|
554
|
-
ch.typing(false); // the live bubble replaces the typing indicator
|
|
555
|
-
}
|
|
556
|
-
catch {
|
|
557
|
-
/* best-effort */
|
|
558
|
-
}
|
|
568
|
+
stopTyping();
|
|
559
569
|
streamRef.p = ch.stream();
|
|
560
570
|
streamRef.p.catch((err) => {
|
|
561
571
|
this.notePostFailure(m.channelId, err);
|
|
@@ -564,18 +574,33 @@ export class BotRunner {
|
|
|
564
574
|
}
|
|
565
575
|
streamRef.p.then((s) => s.append(delta)).catch(() => { });
|
|
566
576
|
};
|
|
577
|
+
const progress = new ToolProgressFlusher(async (body) => {
|
|
578
|
+
stopTyping();
|
|
579
|
+
await ch.send({ text: body });
|
|
580
|
+
});
|
|
567
581
|
let reply = "";
|
|
582
|
+
const turn = beginTurn(this.info.handle, m.channelId, resolved.kind);
|
|
568
583
|
try {
|
|
569
584
|
reply = await resolved.brain(text, {
|
|
570
585
|
botHandle: this.info.handle,
|
|
571
586
|
botUserId: this.info.userId,
|
|
572
587
|
channelId: m.channelId,
|
|
573
588
|
senderUserId: m.senderUserId,
|
|
574
|
-
}, onChunk);
|
|
589
|
+
}, { onChunk, onTool: (ev) => progress.push(ev) });
|
|
590
|
+
const trimmed = reply.trim();
|
|
591
|
+
const timedOut = /went quiet for over \d+s/i.test(trimmed);
|
|
592
|
+
finishTurn(turn, {
|
|
593
|
+
ok: Boolean(trimmed) && !trimmed.startsWith("⚠️"),
|
|
594
|
+
chars: trimmed.length,
|
|
595
|
+
timedOut,
|
|
596
|
+
error: !trimmed ? "empty reply" : trimmed.startsWith("⚠️") ? trimmed.slice(0, 180) : null,
|
|
597
|
+
});
|
|
575
598
|
}
|
|
576
599
|
catch (err) {
|
|
600
|
+
finishTurn(turn, { ok: false, error: err.message, timedOut: false });
|
|
577
601
|
// Brain blew up mid-stream: finalize the partial bubble with an honest
|
|
578
602
|
// error line instead of leaving a forever-blinking cursor.
|
|
603
|
+
await progress.flush();
|
|
579
604
|
const stream = streamRef.p ? await streamRef.p.catch(() => null) : null;
|
|
580
605
|
if (stream)
|
|
581
606
|
await stream.fail(FALLBACK_REPLY).catch(() => { });
|
|
@@ -589,6 +614,7 @@ export class BotRunner {
|
|
|
589
614
|
/* best-effort */
|
|
590
615
|
}
|
|
591
616
|
}
|
|
617
|
+
await progress.flush();
|
|
592
618
|
const stream = streamRef.p ? await streamRef.p.catch(() => null) : null;
|
|
593
619
|
if (stream) {
|
|
594
620
|
await stream.done(reply.trim() || undefined);
|
package/dist/brain.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BridgeConfig } from "./config.js";
|
|
2
|
+
import type { ToolProgressEvent } from "./tool-progress.js";
|
|
2
3
|
export interface BrainContext {
|
|
3
4
|
botHandle: string;
|
|
4
5
|
botUserId: string;
|
|
@@ -9,8 +10,14 @@ export interface BrainContext {
|
|
|
9
10
|
* A brain answers one message. If it can stream, it calls `onChunk(delta)` as
|
|
10
11
|
* text arrives (plain text, ANSI-stripped) and STILL returns the full reply —
|
|
11
12
|
* the returned string is authoritative for the final message body.
|
|
13
|
+
* `onTool` is optional: Hermes HTTP uses it for live tool-progress lines.
|
|
12
14
|
*/
|
|
13
|
-
export type
|
|
15
|
+
export type BrainHooks = {
|
|
16
|
+
onChunk?: (delta: string) => void;
|
|
17
|
+
onTool?: (ev: ToolProgressEvent) => void;
|
|
18
|
+
};
|
|
19
|
+
export type Brain = (text: string, ctx: BrainContext, hooks?: BrainHooks | ((delta: string) => void)) => Promise<string>;
|
|
20
|
+
export declare function asHooks(hooks?: BrainHooks | ((delta: string) => void)): BrainHooks;
|
|
14
21
|
export declare const FALLBACK_REPLY = "Sorry \u2014 I hit an error processing that. Please try again.";
|
|
15
22
|
export declare function stripAnsi(s: string): string;
|
|
16
23
|
export interface ResolvedBrain {
|
package/dist/brain.js
CHANGED
|
@@ -6,7 +6,14 @@
|
|
|
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
|
+
export function asHooks(hooks) {
|
|
11
|
+
if (!hooks)
|
|
12
|
+
return {};
|
|
13
|
+
if (typeof hooks === "function")
|
|
14
|
+
return { onChunk: hooks };
|
|
15
|
+
return hooks;
|
|
16
|
+
}
|
|
10
17
|
export const FALLBACK_REPLY = "Sorry — I hit an error processing that. Please try again.";
|
|
11
18
|
// ANSI escape sequences (CSI, OSC, and lone ESC controls). Agent runtimes like
|
|
12
19
|
// Hermes color their stdout; the chat must receive plain text.
|
|
@@ -23,7 +30,8 @@ export function stripAnsi(s) {
|
|
|
23
30
|
* NOPEEK_BOT_HANDLE, NOPEEK_BOT_USER_ID, NOPEEK_CHANNEL_ID, NOPEEK_SENDER_USER_ID.
|
|
24
31
|
*/
|
|
25
32
|
function cmdBrain(cmd, timeoutMs) {
|
|
26
|
-
return (text, ctx,
|
|
33
|
+
return (text, ctx, hooks) => new Promise((resolvePromise) => {
|
|
34
|
+
const { onChunk } = asHooks(hooks);
|
|
27
35
|
const child = spawn("bash", ["-c", cmd], {
|
|
28
36
|
stdio: ["pipe", "pipe", "pipe"],
|
|
29
37
|
env: {
|
|
@@ -151,6 +159,8 @@ export function resolveBrain(cfg, handle) {
|
|
|
151
159
|
return { brain: claudeBrain(cfg), kind: "claude (per-bot)" };
|
|
152
160
|
if (override?.backend === "hermes")
|
|
153
161
|
return { brain: hermesBrain(cfg), kind: "hermes (per-bot)" };
|
|
162
|
+
if (override?.backend === "hermes-http")
|
|
163
|
+
return { brain: hermesHttpOnlyBrain(cfg), kind: "hermes-http (per-bot)" };
|
|
154
164
|
if (override?.backend === "echo" || override?.echo)
|
|
155
165
|
return { brain: echoBrain, kind: "echo (per-bot)" };
|
|
156
166
|
// No local override — honor the server-mediated backend if one was pushed.
|
|
@@ -159,6 +169,8 @@ export function resolveBrain(cfg, handle) {
|
|
|
159
169
|
return { brain: claudeBrain(cfg), kind: "claude (server)" };
|
|
160
170
|
if (server === "hermes")
|
|
161
171
|
return { brain: hermesBrain(cfg), kind: "hermes (server)" };
|
|
172
|
+
if (server === "hermes-http")
|
|
173
|
+
return { brain: hermesHttpOnlyBrain(cfg), kind: "hermes-http (server)" };
|
|
162
174
|
if (server === "echo")
|
|
163
175
|
return { brain: echoBrain, kind: "echo (server)" };
|
|
164
176
|
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,209 @@
|
|
|
1
|
+
import { asHooks } from "./brain.js";
|
|
2
|
+
import { RECAP_HINT } from "./tool-progress.js";
|
|
3
|
+
let lastHealth = null;
|
|
4
|
+
const HEALTH_TTL_MS = 15_000;
|
|
5
|
+
export function lastHermesApiHealth() {
|
|
6
|
+
return lastHealth;
|
|
7
|
+
}
|
|
8
|
+
export async function probeHermesApi(cfg, force = false) {
|
|
9
|
+
const url = cfg.hermesApiUrl.replace(/\/+$/, "");
|
|
10
|
+
if (!force && lastHealth && Date.now() - lastHealth.checkedAt < HEALTH_TTL_MS && lastHealth.url === url) {
|
|
11
|
+
return lastHealth;
|
|
12
|
+
}
|
|
13
|
+
const headers = {};
|
|
14
|
+
if (cfg.hermesApiKey)
|
|
15
|
+
headers.authorization = `Bearer ${cfg.hermesApiKey}`;
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetch(`${url}/health`, {
|
|
18
|
+
headers,
|
|
19
|
+
signal: AbortSignal.timeout(3_000),
|
|
20
|
+
});
|
|
21
|
+
lastHealth = res.ok
|
|
22
|
+
? { ok: true, url, checkedAt: Date.now() }
|
|
23
|
+
: { ok: false, url, reason: `HTTP ${res.status}`, checkedAt: Date.now() };
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
lastHealth = {
|
|
27
|
+
ok: false,
|
|
28
|
+
url,
|
|
29
|
+
reason: err.message || "unreachable",
|
|
30
|
+
checkedAt: Date.now(),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return lastHealth;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Stream one turn through Hermes' OpenAI-compatible chat completions.
|
|
37
|
+
* Session continuity: X-Hermes-Session-Key = nopeek-<channelId> (stable per chat).
|
|
38
|
+
*/
|
|
39
|
+
export function hermesHttpBrain(cfg) {
|
|
40
|
+
return async (text, ctx, hooks) => {
|
|
41
|
+
const { onChunk, onTool } = asHooks(hooks);
|
|
42
|
+
const handle = ctx.botHandle.replace(/^@/, "");
|
|
43
|
+
const tag = `[brain:hermes-http:@${handle}]`;
|
|
44
|
+
const url = cfg.hermesApiUrl.replace(/\/+$/, "");
|
|
45
|
+
const headers = {
|
|
46
|
+
"content-type": "application/json",
|
|
47
|
+
};
|
|
48
|
+
// Hermes rejects X-Hermes-Session-Key with 403 unless API_SERVER_KEY is set.
|
|
49
|
+
// Without a key, skip the header so the turn still runs (no session memory).
|
|
50
|
+
if (cfg.hermesApiKey) {
|
|
51
|
+
headers.authorization = `Bearer ${cfg.hermesApiKey}`;
|
|
52
|
+
headers["X-Hermes-Session-Key"] = `nopeek-${ctx.channelId}`;
|
|
53
|
+
}
|
|
54
|
+
// Idle abort — same rule as the CLI path. A wall-clock timeout on the
|
|
55
|
+
// whole POST would kill a healthy 20-minute agentic turn. Any SSE byte
|
|
56
|
+
// (token, keepalive, tool-progress) resets the idle timer.
|
|
57
|
+
const controller = new AbortController();
|
|
58
|
+
let idle;
|
|
59
|
+
const armIdle = () => {
|
|
60
|
+
clearTimeout(idle);
|
|
61
|
+
idle = setTimeout(() => {
|
|
62
|
+
console.error(`${tag} idle ${cfg.brainTimeoutMs / 1000}s (no SSE), aborting`);
|
|
63
|
+
controller.abort();
|
|
64
|
+
}, cfg.brainTimeoutMs);
|
|
65
|
+
};
|
|
66
|
+
armIdle();
|
|
67
|
+
let res;
|
|
68
|
+
try {
|
|
69
|
+
res = await fetch(`${url}/v1/chat/completions`, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers,
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
model: "hermes-agent",
|
|
74
|
+
stream: true,
|
|
75
|
+
messages: [
|
|
76
|
+
{ role: "system", content: RECAP_HINT },
|
|
77
|
+
{ role: "user", content: text },
|
|
78
|
+
],
|
|
79
|
+
}),
|
|
80
|
+
signal: controller.signal,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
clearTimeout(idle);
|
|
85
|
+
console.error(`${tag} request failed: ${err.message}`);
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
clearTimeout(idle);
|
|
90
|
+
const body = await res.text().catch(() => "");
|
|
91
|
+
console.error(`${tag} HTTP ${res.status}: ${body.slice(0, 300)}`);
|
|
92
|
+
return "";
|
|
93
|
+
}
|
|
94
|
+
if (!res.body) {
|
|
95
|
+
clearTimeout(idle);
|
|
96
|
+
console.error(`${tag} empty body`);
|
|
97
|
+
return "";
|
|
98
|
+
}
|
|
99
|
+
const reader = res.body.getReader();
|
|
100
|
+
const decoder = new TextDecoder();
|
|
101
|
+
let buf = "";
|
|
102
|
+
let reply = "";
|
|
103
|
+
try {
|
|
104
|
+
while (true) {
|
|
105
|
+
const { done, value } = await reader.read();
|
|
106
|
+
if (done)
|
|
107
|
+
break;
|
|
108
|
+
armIdle();
|
|
109
|
+
buf += decoder.decode(value, { stream: true });
|
|
110
|
+
const frames = buf.split("\n\n");
|
|
111
|
+
buf = frames.pop() ?? "";
|
|
112
|
+
for (const frame of frames) {
|
|
113
|
+
const ev = parseSseFrame(frame);
|
|
114
|
+
if (!ev)
|
|
115
|
+
continue;
|
|
116
|
+
if (ev.event === "hermes.tool.progress") {
|
|
117
|
+
const tool = asToolProgress(ev.data);
|
|
118
|
+
if (tool)
|
|
119
|
+
onTool?.(tool);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const delta = extractDelta(ev.data);
|
|
123
|
+
if (!delta)
|
|
124
|
+
continue;
|
|
125
|
+
reply += delta;
|
|
126
|
+
onChunk?.(delta);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (buf.trim()) {
|
|
130
|
+
const ev = parseSseFrame(buf);
|
|
131
|
+
if (ev?.event === "hermes.tool.progress") {
|
|
132
|
+
const tool = asToolProgress(ev.data);
|
|
133
|
+
if (tool)
|
|
134
|
+
onTool?.(tool);
|
|
135
|
+
}
|
|
136
|
+
else if (ev) {
|
|
137
|
+
const delta = extractDelta(ev.data);
|
|
138
|
+
if (delta) {
|
|
139
|
+
reply += delta;
|
|
140
|
+
onChunk?.(delta);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
const msg = err.message || "";
|
|
147
|
+
if (controller.signal.aborted) {
|
|
148
|
+
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.`;
|
|
149
|
+
}
|
|
150
|
+
console.error(`${tag} stream error: ${msg}`);
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
clearTimeout(idle);
|
|
154
|
+
}
|
|
155
|
+
return reply.trim();
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function parseSseFrame(frame) {
|
|
159
|
+
let event = "message";
|
|
160
|
+
const dataLines = [];
|
|
161
|
+
for (const raw of frame.split("\n")) {
|
|
162
|
+
const line = raw.replace(/\r$/, "");
|
|
163
|
+
if (!line || line.startsWith(":"))
|
|
164
|
+
continue;
|
|
165
|
+
if (line.startsWith("event:"))
|
|
166
|
+
event = line.slice(6).trim();
|
|
167
|
+
else if (line.startsWith("data:"))
|
|
168
|
+
dataLines.push(line.slice(5).trimStart());
|
|
169
|
+
}
|
|
170
|
+
if (dataLines.length === 0)
|
|
171
|
+
return null;
|
|
172
|
+
const payload = dataLines.join("\n");
|
|
173
|
+
if (!payload || payload === "[DONE]")
|
|
174
|
+
return null;
|
|
175
|
+
try {
|
|
176
|
+
return { event, data: JSON.parse(payload) };
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return { event, data: payload };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function asToolProgress(data) {
|
|
183
|
+
if (!data || typeof data !== "object")
|
|
184
|
+
return null;
|
|
185
|
+
const obj = data;
|
|
186
|
+
const tool = typeof obj.tool === "string" ? obj.tool : typeof obj.name === "string" ? obj.name : "";
|
|
187
|
+
if (!tool)
|
|
188
|
+
return null;
|
|
189
|
+
return {
|
|
190
|
+
tool,
|
|
191
|
+
emoji: typeof obj.emoji === "string" ? obj.emoji : undefined,
|
|
192
|
+
label: typeof obj.label === "string" ? obj.label : undefined,
|
|
193
|
+
status: typeof obj.status === "string" ? obj.status : "running",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function extractDelta(parsed) {
|
|
197
|
+
if (!parsed || typeof parsed !== "object")
|
|
198
|
+
return "";
|
|
199
|
+
const obj = parsed;
|
|
200
|
+
const choice = obj.choices?.[0];
|
|
201
|
+
const fromChoice = choice?.delta?.content ?? choice?.message?.content;
|
|
202
|
+
if (typeof fromChoice === "string")
|
|
203
|
+
return fromChoice;
|
|
204
|
+
if (typeof obj.data?.content === "string")
|
|
205
|
+
return obj.data.content;
|
|
206
|
+
if (typeof obj.data?.text === "string")
|
|
207
|
+
return obj.data.text;
|
|
208
|
+
return "";
|
|
209
|
+
}
|
|
@@ -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/>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type ToolProgressEvent = {
|
|
2
|
+
tool: string;
|
|
3
|
+
emoji?: string;
|
|
4
|
+
label?: string;
|
|
5
|
+
status?: string;
|
|
6
|
+
};
|
|
7
|
+
/** One Telegram-style line. Running events only; completed is silent. */
|
|
8
|
+
export declare function formatToolLine(ev: ToolProgressEvent): string | null;
|
|
9
|
+
export type ProgressSender = (text: string) => Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* Collect tool lines and flush them as short chat messages.
|
|
12
|
+
* Flush when we have `maxLines` tools, or after `gapMs` of quiet.
|
|
13
|
+
*/
|
|
14
|
+
export declare class ToolProgressFlusher {
|
|
15
|
+
private readonly send;
|
|
16
|
+
private readonly gapMs;
|
|
17
|
+
private readonly maxLines;
|
|
18
|
+
private lines;
|
|
19
|
+
private timer;
|
|
20
|
+
private chain;
|
|
21
|
+
private seen;
|
|
22
|
+
constructor(send: ProgressSender, gapMs?: number, maxLines?: number);
|
|
23
|
+
get pending(): number;
|
|
24
|
+
push(ev: ToolProgressEvent): void;
|
|
25
|
+
flush(): Promise<void>;
|
|
26
|
+
private arm;
|
|
27
|
+
}
|
|
28
|
+
/** Hint so Hermes ends a tool-using turn with a recap, like Telegram. */
|
|
29
|
+
export declare const RECAP_HINT = "When this turn used tools (read/write files, commands, skills, memory), end your reply with a short recap: what was going on, what you did, and the result. Skip the recap for simple chat with no tools. No em dashes.";
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Telegram-style tool progress for NoPeek bots.
|
|
2
|
+
//
|
|
3
|
+
// Hermes emits `hermes.tool.progress` SSE events while it works. Telegram
|
|
4
|
+
// already shows those as short status messages (a few tools, then a new
|
|
5
|
+
// message after a quiet gap). We do the same in the encrypted chat so the
|
|
6
|
+
// human can see the bot is actually taking action.
|
|
7
|
+
const FALLBACK_EMOJI = {
|
|
8
|
+
read_file: "📖",
|
|
9
|
+
write_file: "✍️",
|
|
10
|
+
patch: "🔧",
|
|
11
|
+
search_files: "🔎",
|
|
12
|
+
terminal: "💻",
|
|
13
|
+
web_search: "🔍",
|
|
14
|
+
web_extract: "📄",
|
|
15
|
+
web_crawl: "🕸️",
|
|
16
|
+
memory: "🧠",
|
|
17
|
+
skill_view: "📘",
|
|
18
|
+
skill_manage: "🧩",
|
|
19
|
+
skills_list: "📚",
|
|
20
|
+
todo: "✅",
|
|
21
|
+
execute_code: "🐍",
|
|
22
|
+
delegate_task: "👥",
|
|
23
|
+
cronjob: "⏰",
|
|
24
|
+
process: "⚙️",
|
|
25
|
+
};
|
|
26
|
+
/** One Telegram-style line. Running events only; completed is silent. */
|
|
27
|
+
export function formatToolLine(ev) {
|
|
28
|
+
if (ev.status && ev.status !== "running")
|
|
29
|
+
return null;
|
|
30
|
+
const tool = (ev.tool || "").trim();
|
|
31
|
+
if (!tool || tool.startsWith("_"))
|
|
32
|
+
return null;
|
|
33
|
+
const emoji = (ev.emoji || FALLBACK_EMOJI[tool] || "⚡").trim() || "⚡";
|
|
34
|
+
const label = tidyLabel(ev.label);
|
|
35
|
+
if (label)
|
|
36
|
+
return `${emoji} ${tool}: "${label}"`;
|
|
37
|
+
return `${emoji} ${tool}...`;
|
|
38
|
+
}
|
|
39
|
+
function tidyLabel(raw) {
|
|
40
|
+
if (!raw)
|
|
41
|
+
return "";
|
|
42
|
+
const s = raw.replace(/\s+/g, " ").trim();
|
|
43
|
+
if (!s)
|
|
44
|
+
return "";
|
|
45
|
+
return s.length > 80 ? `${s.slice(0, 77)}...` : s;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Collect tool lines and flush them as short chat messages.
|
|
49
|
+
* Flush when we have `maxLines` tools, or after `gapMs` of quiet.
|
|
50
|
+
*/
|
|
51
|
+
export class ToolProgressFlusher {
|
|
52
|
+
send;
|
|
53
|
+
gapMs;
|
|
54
|
+
maxLines;
|
|
55
|
+
lines = [];
|
|
56
|
+
timer = null;
|
|
57
|
+
chain = Promise.resolve();
|
|
58
|
+
seen = new Set();
|
|
59
|
+
constructor(send, gapMs = 2200, maxLines = 4) {
|
|
60
|
+
this.send = send;
|
|
61
|
+
this.gapMs = gapMs;
|
|
62
|
+
this.maxLines = maxLines;
|
|
63
|
+
}
|
|
64
|
+
get pending() {
|
|
65
|
+
return this.lines.length;
|
|
66
|
+
}
|
|
67
|
+
push(ev) {
|
|
68
|
+
const line = formatToolLine(ev);
|
|
69
|
+
if (!line)
|
|
70
|
+
return;
|
|
71
|
+
if (this.lines[this.lines.length - 1] === line)
|
|
72
|
+
return;
|
|
73
|
+
if (this.seen.has(line) && this.lines.includes(line))
|
|
74
|
+
return;
|
|
75
|
+
this.seen.add(line);
|
|
76
|
+
this.lines.push(line);
|
|
77
|
+
if (this.lines.length >= this.maxLines) {
|
|
78
|
+
void this.flush();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
this.arm();
|
|
82
|
+
}
|
|
83
|
+
async flush() {
|
|
84
|
+
if (this.timer) {
|
|
85
|
+
clearTimeout(this.timer);
|
|
86
|
+
this.timer = null;
|
|
87
|
+
}
|
|
88
|
+
if (this.lines.length === 0)
|
|
89
|
+
return;
|
|
90
|
+
const text = this.lines.join("\n");
|
|
91
|
+
this.lines = [];
|
|
92
|
+
this.chain = this.chain.then(() => this.send(text).catch(() => { }));
|
|
93
|
+
await this.chain;
|
|
94
|
+
}
|
|
95
|
+
arm() {
|
|
96
|
+
if (this.timer)
|
|
97
|
+
clearTimeout(this.timer);
|
|
98
|
+
this.timer = setTimeout(() => {
|
|
99
|
+
this.timer = null;
|
|
100
|
+
void this.flush();
|
|
101
|
+
}, this.gapMs);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Hint so Hermes ends a tool-using turn with a recap, like Telegram. */
|
|
105
|
+
export const RECAP_HINT = "When this turn used tools (read/write files, commands, skills, memory), end your reply with a short recap: what was going on, what you did, and the result. Skip the recap for simple chat with no tools. No em dashes.";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nopeek/agent-bridge",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.11",
|
|
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",
|
|
@@ -22,8 +22,15 @@
|
|
|
22
22
|
"engines": {
|
|
23
23
|
"node": ">=22"
|
|
24
24
|
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc -p tsconfig.json",
|
|
27
|
+
"prepack": "tsc -p tsconfig.json",
|
|
28
|
+
"start": "node dist/cli.js",
|
|
29
|
+
"dev": "tsx src/cli.ts",
|
|
30
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
31
|
+
},
|
|
25
32
|
"dependencies": {
|
|
26
|
-
"@nopeek/chat": "
|
|
33
|
+
"@nopeek/chat": "workspace:^0.2.4"
|
|
27
34
|
},
|
|
28
35
|
"devDependencies": {
|
|
29
36
|
"@types/node": "^22.10.0",
|
|
@@ -40,11 +47,5 @@
|
|
|
40
47
|
],
|
|
41
48
|
"publishConfig": {
|
|
42
49
|
"access": "public"
|
|
43
|
-
},
|
|
44
|
-
"scripts": {
|
|
45
|
-
"build": "tsc -p tsconfig.json",
|
|
46
|
-
"start": "node dist/cli.js",
|
|
47
|
-
"dev": "tsx src/cli.ts",
|
|
48
|
-
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
49
50
|
}
|
|
50
|
-
}
|
|
51
|
+
}
|