@nopeek/agent-bridge 0.7.22 → 0.7.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backends.js +24 -7
- package/dist/bot.js +31 -4
- package/dist/bridge.d.ts +1 -1
- package/dist/bridge.js +1 -1
- package/dist/outbound-media.d.ts +34 -0
- package/dist/outbound-media.js +187 -0
- package/dist/tool-progress.d.ts +10 -0
- package/dist/tool-progress.js +58 -1
- package/package.json +2 -2
package/dist/backends.js
CHANGED
|
@@ -18,6 +18,7 @@ import { homedir } from "node:os";
|
|
|
18
18
|
import { basename, join } from "node:path";
|
|
19
19
|
import { asHooks, stripAnsi } from "./brain.js";
|
|
20
20
|
import { hermesHttpBrain, probeHermesApi } from "./hermes-http.js";
|
|
21
|
+
import { parseHermesActivityLine } from "./tool-progress.js";
|
|
21
22
|
const BRAIN_UNREACHABLE = "⚠️ I couldn't reach my brain just now — please try again in a moment.";
|
|
22
23
|
// ------------------------------------------------------------------ souls ----
|
|
23
24
|
/** Built-in personality template — personalized per bot by provisionSoul(). */
|
|
@@ -347,7 +348,15 @@ export function provisionHermesProfile(handle) {
|
|
|
347
348
|
}
|
|
348
349
|
// Chat chrome Hermes prints even in -Q mode: ruler lines, the echoed prompt
|
|
349
350
|
// bubble (● …), the goodbye line, and session-info footers.
|
|
350
|
-
const HERMES_CHROME = [
|
|
351
|
+
const HERMES_CHROME = [
|
|
352
|
+
/^[─━—-]{4,}\s*─*$/,
|
|
353
|
+
/^●/,
|
|
354
|
+
/^Goodbye!/,
|
|
355
|
+
/^session(_| )?id:/i,
|
|
356
|
+
/^↻/,
|
|
357
|
+
/^[╭╰┌┐└┘]/,
|
|
358
|
+
/^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/,
|
|
359
|
+
];
|
|
351
360
|
function isHermesChrome(line) {
|
|
352
361
|
return HERMES_CHROME.some((re) => re.test(line));
|
|
353
362
|
}
|
|
@@ -393,15 +402,17 @@ function detectHermesProfileMode(bin) {
|
|
|
393
402
|
return hermesProfileMode;
|
|
394
403
|
}
|
|
395
404
|
function hermesCliBrain(cfg) {
|
|
396
|
-
const runOnce = (profile, text, sessionName, tag, onChunk) => new Promise((resolvePromise) => {
|
|
405
|
+
const runOnce = (profile, text, sessionName, tag, onChunk, onTool) => new Promise((resolvePromise) => {
|
|
397
406
|
const bin = resolveBin("hermes", "HERMES_BIN");
|
|
398
407
|
const mode = detectHermesProfileMode(bin);
|
|
399
408
|
// v0.14+ takes the profile NAME via env; legacy took the PATH via flag.
|
|
400
409
|
const profileName = basename(profile);
|
|
401
|
-
|
|
410
|
+
// Do NOT pass -Q. Quiet mode hides tool previews, so NoPeek looks dead
|
|
411
|
+
// while Hermes works. Telegram publishes those lines; we do the same.
|
|
412
|
+
const args = mode === "flag" ? ["--profile", profile, "chat"] : ["chat"];
|
|
402
413
|
if (sessionName)
|
|
403
414
|
args.push("--continue", sessionName);
|
|
404
|
-
args.push("-q", text);
|
|
415
|
+
args.push("--accept-hooks", "--source", "nopeek", "-q", text);
|
|
405
416
|
const child = spawn(bin, args, {
|
|
406
417
|
stdio: ["ignore", "pipe", "pipe"],
|
|
407
418
|
// Hermes keys everything off $HOME; point it at the Hermes install.
|
|
@@ -452,6 +463,12 @@ function hermesCliBrain(cfg) {
|
|
|
452
463
|
}
|
|
453
464
|
if (noSession)
|
|
454
465
|
return; // swallow the "Use 'hermes sessions list'…" tail too
|
|
466
|
+
const activity = parseHermesActivityLine(line);
|
|
467
|
+
if (activity) {
|
|
468
|
+
sawContent = true;
|
|
469
|
+
onTool?.(activity);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
455
472
|
sawContent = true;
|
|
456
473
|
out += `${line}\n`;
|
|
457
474
|
onChunk?.(`${line}\n`);
|
|
@@ -535,7 +552,7 @@ function hermesCliBrain(cfg) {
|
|
|
535
552
|
});
|
|
536
553
|
});
|
|
537
554
|
return async (text, ctx, hooks) => {
|
|
538
|
-
const { onChunk } = asHooks(hooks);
|
|
555
|
+
const { onChunk, onTool } = asHooks(hooks);
|
|
539
556
|
const handle = ctx.botHandle.replace(/^@/, "");
|
|
540
557
|
const tag = `[brain:hermes:@${handle}]`;
|
|
541
558
|
if (!resolveBin("hermes", "HERMES_BIN")) {
|
|
@@ -551,12 +568,12 @@ function hermesCliBrain(cfg) {
|
|
|
551
568
|
return BRAIN_UNREACHABLE;
|
|
552
569
|
}
|
|
553
570
|
const sessionName = `nopeek-${ctx.channelId}`;
|
|
554
|
-
let run = await runOnce(profile, text, sessionName, tag, onChunk);
|
|
571
|
+
let run = await runOnce(profile, text, sessionName, tag, onChunk, onTool);
|
|
555
572
|
if (run.noSession && !run.reply) {
|
|
556
573
|
// First message in this channel: the named session doesn't exist yet.
|
|
557
574
|
// Start fresh, then name the new session so the NEXT message continues it.
|
|
558
575
|
console.log(`${tag} no session ${sessionName} yet — starting fresh`);
|
|
559
|
-
run = await runOnce(profile, text, null, tag, onChunk);
|
|
576
|
+
run = await runOnce(profile, text, null, tag, onChunk, onTool);
|
|
560
577
|
if (run.sessionId) {
|
|
561
578
|
const bin = resolveBin("hermes", "HERMES_BIN");
|
|
562
579
|
const mode = detectHermesProfileMode(bin);
|
package/dist/bot.js
CHANGED
|
@@ -10,6 +10,7 @@ import { FileStore } from "./storage.js";
|
|
|
10
10
|
import { TurnPublisher } from "./tool-progress.js";
|
|
11
11
|
import { ChannelTurn, coalesceFollowups, isBotEcho, isPlaceholderText, isStopRequest, wrapInterruptedFollowup, } from "./mid-turn.js";
|
|
12
12
|
import { buildInboundPrompt, describeStructured, hasInboundWork, isControlType, parseAttachments, saveInboundFiles, } from "./inbound-files.js";
|
|
13
|
+
import { deliverOutbound, extractMedia, attachMediaFiles, stripMediaTags } from "./outbound-media.js";
|
|
13
14
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
14
15
|
const MAX_BACKOFF_MS = 60_000;
|
|
15
16
|
// Owner-membership answers are cached per channel for a short window; a
|
|
@@ -687,19 +688,44 @@ export class BotRunner {
|
|
|
687
688
|
/* best-effort */
|
|
688
689
|
}
|
|
689
690
|
};
|
|
691
|
+
const mediaSent = new Set();
|
|
690
692
|
const published = new TurnPublisher({
|
|
691
693
|
stream: async () => {
|
|
692
694
|
const s = await ch.stream();
|
|
693
695
|
return {
|
|
694
696
|
append: (chunk) => s.append(chunk),
|
|
695
|
-
replace: (full) => s.replace(full),
|
|
696
|
-
done: (final) =>
|
|
697
|
+
replace: (full) => s.replace(stripMediaTags(full) || full),
|
|
698
|
+
done: async (final) => {
|
|
699
|
+
const raw = typeof final === "string" ? final : "";
|
|
700
|
+
const { cleaned, paths } = extractMedia(raw);
|
|
701
|
+
if (cleaned.trim())
|
|
702
|
+
await s.done(cleaned);
|
|
703
|
+
else {
|
|
704
|
+
try {
|
|
705
|
+
s.replace("…");
|
|
706
|
+
await s.done("…");
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
await s.done("");
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
await attachMediaFiles(ch, paths, mediaSent);
|
|
713
|
+
},
|
|
697
714
|
fail: (text) => s.fail(text),
|
|
698
|
-
cancel: () =>
|
|
715
|
+
cancel: async () => {
|
|
716
|
+
// Do not recall() — that leaves "This message was deleted" / Message recalled.
|
|
717
|
+
try {
|
|
718
|
+
s.replace("…");
|
|
719
|
+
await s.done("…");
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
/* best-effort drop */
|
|
723
|
+
}
|
|
724
|
+
},
|
|
699
725
|
};
|
|
700
726
|
},
|
|
701
727
|
send: async (body) => {
|
|
702
|
-
await ch
|
|
728
|
+
await deliverOutbound(ch, body, mediaSent);
|
|
703
729
|
},
|
|
704
730
|
}, {
|
|
705
731
|
stopTyping,
|
|
@@ -708,6 +734,7 @@ export class BotRunner {
|
|
|
708
734
|
this.logErr(`stream open failed (falling back to a single send): ${err.message}`);
|
|
709
735
|
},
|
|
710
736
|
});
|
|
737
|
+
published.markStarted();
|
|
711
738
|
const live = new ChannelTurn();
|
|
712
739
|
this.turns.set(channelId, live);
|
|
713
740
|
const prompt = interrupted ? wrapInterruptedFollowup(text) : text;
|
package/dist/bridge.d.ts
CHANGED
package/dist/bridge.js
CHANGED
|
@@ -19,7 +19,7 @@ import { reportCapabilities } from "./capabilities.js";
|
|
|
19
19
|
import { lastHermesApiHealth, probeHermesApi } from "./hermes-http.js";
|
|
20
20
|
import { lastTurnGlobal, turnSnapshot } from "./last-turn.js";
|
|
21
21
|
import { isBrainBackend } from "./config.js";
|
|
22
|
-
export const VERSION = "0.7.
|
|
22
|
+
export const VERSION = "0.7.23";
|
|
23
23
|
function hermesApiStatus(cfg) {
|
|
24
24
|
const api = lastHermesApiHealth();
|
|
25
25
|
return {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AttachmentInput } from "@nopeek/chat";
|
|
2
|
+
export type ExtractedMedia = {
|
|
3
|
+
cleaned: string;
|
|
4
|
+
paths: string[];
|
|
5
|
+
};
|
|
6
|
+
export declare function extractMedia(text: string): ExtractedMedia;
|
|
7
|
+
export declare function unwrapPath(raw: string): string;
|
|
8
|
+
export declare function mediaAllowRoots(home?: string): string[];
|
|
9
|
+
export declare function resolveMediaPath(raw: string, roots?: string[]): string | null;
|
|
10
|
+
export declare function loadAttachment(path: string): AttachmentInput;
|
|
11
|
+
export type MediaChannel = {
|
|
12
|
+
send: (body: {
|
|
13
|
+
text: string;
|
|
14
|
+
}) => Promise<unknown>;
|
|
15
|
+
sendAttachment: (a: AttachmentInput, opts?: {
|
|
16
|
+
caption?: string;
|
|
17
|
+
}) => Promise<unknown>;
|
|
18
|
+
};
|
|
19
|
+
export declare function attachMediaFiles(ch: Pick<MediaChannel, "sendAttachment">, paths: string[], alreadySent?: Set<string>): Promise<{
|
|
20
|
+
sent: string[];
|
|
21
|
+
failed: string[];
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* Post cleaned text, then each MEDIA file as a native image/video/audio/file
|
|
25
|
+
* bubble. Skips paths already sent this turn so stream.done + send cannot
|
|
26
|
+
* double-attach the same PDF.
|
|
27
|
+
*/
|
|
28
|
+
export declare function deliverOutbound(ch: MediaChannel, text: string, alreadySent?: Set<string>): Promise<{
|
|
29
|
+
cleaned: string;
|
|
30
|
+
sent: string[];
|
|
31
|
+
failed: string[];
|
|
32
|
+
}>;
|
|
33
|
+
/** Strip MEDIA tags for a live stream bubble without attaching yet. */
|
|
34
|
+
export declare function stripMediaTags(text: string): string;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Turn Hermes `MEDIA:/path/to/file.pdf` tags into real NoPeek attachments.
|
|
2
|
+
// Telegram's gateway already does this; the bridge used to post the tag as text.
|
|
3
|
+
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, join, sep } from "node:path";
|
|
6
|
+
import { hermesHome } from "./backends.js";
|
|
7
|
+
import { inferContentType, MAX_ATTACHMENT_BYTES, safeFileName } from "./inbound-files.js";
|
|
8
|
+
const MEDIA_EXTS = "png|jpe?g|gif|webp|bmp|tiff?|heic|heif|svg|mp4|mov|m4v|avi|mkv|webm|ogg|opus|mp3|wav|m4a|aac|flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|md|csv|json|html?|apk|ipa|log";
|
|
9
|
+
// Mirrors Hermes gateway extract_media: MEDIA:<path>, optional quotes/backticks,
|
|
10
|
+
// optional space after the colon, absolute or ~/ paths.
|
|
11
|
+
const MEDIA_RE = new RegExp(String.raw `[\`"']?MEDIA:\s*(?<path>\`[^\`\n]+\`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+?\.(?:${MEDIA_EXTS}))[\`"']?`, "gi");
|
|
12
|
+
const CACHE_SUBS = [
|
|
13
|
+
"cache",
|
|
14
|
+
"cache/images",
|
|
15
|
+
"cache/videos",
|
|
16
|
+
"cache/audio",
|
|
17
|
+
"cache/documents",
|
|
18
|
+
"cache/screenshots",
|
|
19
|
+
"audio_cache",
|
|
20
|
+
"image_cache",
|
|
21
|
+
"video_cache",
|
|
22
|
+
"document_cache",
|
|
23
|
+
"browser_screenshots",
|
|
24
|
+
];
|
|
25
|
+
export function extractMedia(text) {
|
|
26
|
+
if (!text)
|
|
27
|
+
return { cleaned: "", paths: [] };
|
|
28
|
+
MEDIA_RE.lastIndex = 0;
|
|
29
|
+
const paths = [];
|
|
30
|
+
const cleaned = text
|
|
31
|
+
.replace(MEDIA_RE, (_full, path) => {
|
|
32
|
+
const raw = unwrapPath(path);
|
|
33
|
+
if (raw)
|
|
34
|
+
paths.push(raw);
|
|
35
|
+
return "";
|
|
36
|
+
})
|
|
37
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
38
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
39
|
+
.trim();
|
|
40
|
+
return { cleaned, paths: dedupe(paths) };
|
|
41
|
+
}
|
|
42
|
+
export function unwrapPath(raw) {
|
|
43
|
+
let path = (raw || "").trim();
|
|
44
|
+
if (path.length >= 2 && path[0] === path[path.length - 1] && `\`"'`.includes(path[0])) {
|
|
45
|
+
path = path.slice(1, -1).trim();
|
|
46
|
+
}
|
|
47
|
+
path = path.replace(/^[`"']+/, "").replace(/[`"']+$/, "");
|
|
48
|
+
path = path.replace(/[.,;:)}]+$/, "");
|
|
49
|
+
if (path.startsWith("~/"))
|
|
50
|
+
path = join(homedir(), path.slice(2));
|
|
51
|
+
return path.trim();
|
|
52
|
+
}
|
|
53
|
+
export function mediaAllowRoots(home = hermesHome()) {
|
|
54
|
+
const bases = [home, join(homedir(), ".hermes")];
|
|
55
|
+
if (homedir() !== "/Users/jarvis")
|
|
56
|
+
bases.push("/Users/jarvis/.hermes");
|
|
57
|
+
const roots = [];
|
|
58
|
+
for (const base of bases) {
|
|
59
|
+
for (const sub of CACHE_SUBS)
|
|
60
|
+
roots.push(join(base, sub));
|
|
61
|
+
}
|
|
62
|
+
const extra = process.env.HERMES_MEDIA_ALLOW_DIRS || "";
|
|
63
|
+
for (const chunk of extra.split(/[: ,]+/)) {
|
|
64
|
+
const root = chunk.trim();
|
|
65
|
+
if (root.startsWith("/") || root.startsWith("~/"))
|
|
66
|
+
roots.push(unwrapPath(root));
|
|
67
|
+
}
|
|
68
|
+
return dedupe(roots);
|
|
69
|
+
}
|
|
70
|
+
export function resolveMediaPath(raw, roots = mediaAllowRoots()) {
|
|
71
|
+
const candidate = unwrapPath(raw);
|
|
72
|
+
if (!candidate.startsWith("/"))
|
|
73
|
+
return null;
|
|
74
|
+
if (!existsSync(candidate))
|
|
75
|
+
return null;
|
|
76
|
+
let resolved;
|
|
77
|
+
try {
|
|
78
|
+
resolved = realpathSync(candidate);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
if (!lstatSync(resolved).isFile())
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
for (const root of roots) {
|
|
91
|
+
if (!root)
|
|
92
|
+
continue;
|
|
93
|
+
let resolvedRoot = root;
|
|
94
|
+
try {
|
|
95
|
+
if (existsSync(root))
|
|
96
|
+
resolvedRoot = realpathSync(root);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (isWithin(resolved, resolvedRoot))
|
|
102
|
+
return resolved;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
export function loadAttachment(path) {
|
|
107
|
+
const st = statSync(path);
|
|
108
|
+
if (st.size > MAX_ATTACHMENT_BYTES) {
|
|
109
|
+
throw new Error(`${basename(path)} is over the ${MAX_ATTACHMENT_BYTES} byte limit`);
|
|
110
|
+
}
|
|
111
|
+
const buf = readFileSync(path);
|
|
112
|
+
const name = safeFileName(basename(path), "file");
|
|
113
|
+
return {
|
|
114
|
+
bytes: toArrayBuffer(buf),
|
|
115
|
+
name,
|
|
116
|
+
contentType: inferContentType(name),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export async function attachMediaFiles(ch, paths, alreadySent = new Set()) {
|
|
120
|
+
const failed = [];
|
|
121
|
+
const sent = [];
|
|
122
|
+
for (const raw of paths) {
|
|
123
|
+
const resolved = resolveMediaPath(raw);
|
|
124
|
+
if (!resolved) {
|
|
125
|
+
failed.push(basename(unwrapPath(raw)) || raw);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (alreadySent.has(resolved))
|
|
129
|
+
continue;
|
|
130
|
+
try {
|
|
131
|
+
const att = loadAttachment(resolved);
|
|
132
|
+
await ch.sendAttachment(att);
|
|
133
|
+
alreadySent.add(resolved);
|
|
134
|
+
sent.push(resolved);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
failed.push(`${basename(resolved)} (${err.message})`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { sent, failed };
|
|
141
|
+
}
|
|
142
|
+
function failNote(failed) {
|
|
143
|
+
if (!failed.length)
|
|
144
|
+
return "";
|
|
145
|
+
if (failed.length === 1)
|
|
146
|
+
return `Couldn't attach ${failed[0]}.`;
|
|
147
|
+
return `Couldn't attach ${failed.length} files.`;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Post cleaned text, then each MEDIA file as a native image/video/audio/file
|
|
151
|
+
* bubble. Skips paths already sent this turn so stream.done + send cannot
|
|
152
|
+
* double-attach the same PDF.
|
|
153
|
+
*/
|
|
154
|
+
export async function deliverOutbound(ch, text, alreadySent = new Set()) {
|
|
155
|
+
const extracted = extractMedia(text);
|
|
156
|
+
const body = extracted.cleaned;
|
|
157
|
+
if (body.trim())
|
|
158
|
+
await ch.send({ text: body });
|
|
159
|
+
const { sent, failed } = await attachMediaFiles(ch, extracted.paths, alreadySent);
|
|
160
|
+
if (failed.length && !sent.length && !body.trim()) {
|
|
161
|
+
await ch.send({ text: failNote(failed) });
|
|
162
|
+
}
|
|
163
|
+
return { cleaned: body, sent, failed };
|
|
164
|
+
}
|
|
165
|
+
/** Strip MEDIA tags for a live stream bubble without attaching yet. */
|
|
166
|
+
export function stripMediaTags(text) {
|
|
167
|
+
return extractMedia(text).cleaned;
|
|
168
|
+
}
|
|
169
|
+
function isWithin(path, root) {
|
|
170
|
+
const p = path.endsWith(sep) ? path.slice(0, -1) : path;
|
|
171
|
+
const r = root.endsWith(sep) ? root.slice(0, -1) : root;
|
|
172
|
+
return p === r || p.startsWith(r + sep);
|
|
173
|
+
}
|
|
174
|
+
function toArrayBuffer(buf) {
|
|
175
|
+
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
|
176
|
+
}
|
|
177
|
+
function dedupe(items) {
|
|
178
|
+
const seen = new Set();
|
|
179
|
+
const out = [];
|
|
180
|
+
for (const item of items) {
|
|
181
|
+
if (!item || seen.has(item))
|
|
182
|
+
continue;
|
|
183
|
+
seen.add(item);
|
|
184
|
+
out.push(item);
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
package/dist/tool-progress.d.ts
CHANGED
|
@@ -4,6 +4,13 @@ export type ToolProgressEvent = {
|
|
|
4
4
|
label?: string;
|
|
5
5
|
status?: string;
|
|
6
6
|
};
|
|
7
|
+
/** Shown the moment a turn starts so the chat is never silent like Telegram. */
|
|
8
|
+
export declare const WORKING_LINE = "Working\u2026";
|
|
9
|
+
/**
|
|
10
|
+
* Hermes CLI activity (`┊ 📋 plan 3 task(s)`). Used so the CLI brain can
|
|
11
|
+
* publish the same Telegram-style progress as the HTTP SSE path.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseHermesActivityLine(raw: string): ToolProgressEvent | null;
|
|
7
14
|
/** One Telegram-style line. Running events only; completed is silent. */
|
|
8
15
|
export declare function formatToolLine(ev: ToolProgressEvent): string | null;
|
|
9
16
|
/** Hint so Hermes ends a tool-using turn with a recap, like Telegram. */
|
|
@@ -66,11 +73,14 @@ export declare class TurnPublisher {
|
|
|
66
73
|
private startTimer;
|
|
67
74
|
private gapTimer;
|
|
68
75
|
private seen;
|
|
76
|
+
private started;
|
|
69
77
|
private readonly startDelayMs;
|
|
70
78
|
private readonly maxProgressLines;
|
|
71
79
|
private readonly maxBubbleChars;
|
|
72
80
|
private readonly bubbleGapMs;
|
|
73
81
|
constructor(sink: TurnSink, opts: TurnPublisherOpts);
|
|
82
|
+
/** Open a live bubble immediately so HTTP and CLI turns never look dead. */
|
|
83
|
+
markStarted(): void;
|
|
74
84
|
onChunk(delta: string): void;
|
|
75
85
|
onTool(ev: ToolProgressEvent): void;
|
|
76
86
|
finish(reply: string): Promise<TurnFinishKind>;
|
package/dist/tool-progress.js
CHANGED
|
@@ -27,6 +27,52 @@ const FALLBACK_EMOJI = {
|
|
|
27
27
|
cronjob: "⏰",
|
|
28
28
|
process: "⚙️",
|
|
29
29
|
};
|
|
30
|
+
/** Shown the moment a turn starts so the chat is never silent like Telegram. */
|
|
31
|
+
export const WORKING_LINE = "Working…";
|
|
32
|
+
const CLI_ACTIVITY = {
|
|
33
|
+
plan: { tool: "todo", emoji: "📋" },
|
|
34
|
+
read: { tool: "read_file", emoji: "📖" },
|
|
35
|
+
write: { tool: "write_file", emoji: "✍️" },
|
|
36
|
+
patch: { tool: "patch", emoji: "🔧" },
|
|
37
|
+
grep: { tool: "search_files", emoji: "🔎" },
|
|
38
|
+
find: { tool: "search_files", emoji: "🔎" },
|
|
39
|
+
$: { tool: "terminal", emoji: "💻" },
|
|
40
|
+
fetch: { tool: "web_extract", emoji: "📄" },
|
|
41
|
+
crawl: { tool: "web_crawl", emoji: "🕸️" },
|
|
42
|
+
memory: { tool: "memory", emoji: "🧠" },
|
|
43
|
+
recall: { tool: "session_search", emoji: "🔍" },
|
|
44
|
+
proc: { tool: "process", emoji: "⚙️" },
|
|
45
|
+
navigate: { tool: "browser_navigate", emoji: "🌐" },
|
|
46
|
+
snapshot: { tool: "browser_snapshot", emoji: "📸" },
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Hermes CLI activity (`┊ 📋 plan 3 task(s)`). Used so the CLI brain can
|
|
50
|
+
* publish the same Telegram-style progress as the HTTP SSE path.
|
|
51
|
+
*/
|
|
52
|
+
export function parseHermesActivityLine(raw) {
|
|
53
|
+
const t = (raw || "").trim();
|
|
54
|
+
if (!t || !/^[┊│|]/.test(t))
|
|
55
|
+
return null;
|
|
56
|
+
const rest = t.replace(/^[┊│|]\s*/, "");
|
|
57
|
+
if (!rest || /^review\s+diff\b/i.test(rest))
|
|
58
|
+
return null;
|
|
59
|
+
const m = rest.match(/^(?:(\S+)\s+)?(\$|[A-Za-z_][\w.]*)\s*(.*)$/);
|
|
60
|
+
if (!m)
|
|
61
|
+
return null;
|
|
62
|
+
const maybeEmoji = m[1] || "";
|
|
63
|
+
const verb = (m[2] || "").replace(/:+$/, "");
|
|
64
|
+
if (!verb)
|
|
65
|
+
return null;
|
|
66
|
+
let label = (m[3] || "").replace(/\s+\d+(?:\.\d+)?s\s*$/, "").trim();
|
|
67
|
+
const mapped = CLI_ACTIVITY[verb];
|
|
68
|
+
const emoji = mapped?.emoji || (/^\p{Extended_Pictographic}/u.test(maybeEmoji) ? maybeEmoji : "⚡");
|
|
69
|
+
return {
|
|
70
|
+
tool: mapped?.tool || verb,
|
|
71
|
+
emoji,
|
|
72
|
+
...(label ? { label } : {}),
|
|
73
|
+
status: "running",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
30
76
|
/** One Telegram-style line. Running events only; completed is silent. */
|
|
31
77
|
export function formatToolLine(ev) {
|
|
32
78
|
if (ev.status && ev.status !== "running")
|
|
@@ -261,6 +307,7 @@ export class TurnPublisher {
|
|
|
261
307
|
startTimer = null;
|
|
262
308
|
gapTimer = null;
|
|
263
309
|
seen = new Set();
|
|
310
|
+
started = false;
|
|
264
311
|
startDelayMs;
|
|
265
312
|
maxProgressLines;
|
|
266
313
|
maxBubbleChars;
|
|
@@ -273,6 +320,13 @@ export class TurnPublisher {
|
|
|
273
320
|
this.maxBubbleChars = opts.maxBubbleChars ?? DEFAULT_MAX_BUBBLE;
|
|
274
321
|
this.bubbleGapMs = opts.bubbleGapMs ?? 2000;
|
|
275
322
|
}
|
|
323
|
+
/** Open a live bubble immediately so HTTP and CLI turns never look dead. */
|
|
324
|
+
markStarted() {
|
|
325
|
+
if (this.started || this.toolsUsed || this.textP || this.progressP)
|
|
326
|
+
return;
|
|
327
|
+
this.started = true;
|
|
328
|
+
this.enqueue(() => this.addProgressLine(WORKING_LINE));
|
|
329
|
+
}
|
|
276
330
|
onChunk(delta) {
|
|
277
331
|
const chunk = stripDumps(delta);
|
|
278
332
|
if (!chunk)
|
|
@@ -317,6 +371,9 @@ export class TurnPublisher {
|
|
|
317
371
|
const pending = this.held;
|
|
318
372
|
this.held = "";
|
|
319
373
|
await this.commitText(pending);
|
|
374
|
+
if (this.progressLines.length === 1 && this.progressLines[0] === WORKING_LINE) {
|
|
375
|
+
this.progressLines = [];
|
|
376
|
+
}
|
|
320
377
|
}
|
|
321
378
|
await this.addProgressLine(line);
|
|
322
379
|
});
|
|
@@ -531,7 +588,7 @@ export class TurnPublisher {
|
|
|
531
588
|
this.progressLines = [];
|
|
532
589
|
if (!s)
|
|
533
590
|
return;
|
|
534
|
-
if (text)
|
|
591
|
+
if (text && text !== WORKING_LINE)
|
|
535
592
|
await s.done(text).catch(() => { });
|
|
536
593
|
else
|
|
537
594
|
await s.cancel().catch(() => { });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nopeek/agent-bridge",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.24",
|
|
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",
|
|
@@ -46,6 +46,6 @@
|
|
|
46
46
|
"start": "node dist/cli.js",
|
|
47
47
|
"dev": "tsx src/cli.ts",
|
|
48
48
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
49
|
-
"test": "tsx --test --test-concurrency=1 src/tool-progress.test.ts src/inbound-files.test.ts src/mid-turn.test.ts"
|
|
49
|
+
"test": "tsx --test --test-concurrency=1 src/tool-progress.test.ts src/inbound-files.test.ts src/mid-turn.test.ts src/outbound-media.test.ts"
|
|
50
50
|
}
|
|
51
51
|
}
|