@nopeek/agent-bridge 0.7.23 → 0.7.25
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/bot.js +22 -5
- package/dist/bridge.d.ts +1 -1
- package/dist/bridge.js +1 -1
- package/dist/hermes-http.js +2 -0
- package/dist/hermes-session-store.d.ts +5 -0
- package/dist/hermes-session-store.js +65 -0
- package/dist/mid-turn.d.ts +3 -0
- package/dist/mid-turn.js +26 -7
- package/dist/outbound-media.d.ts +34 -0
- package/dist/outbound-media.js +187 -0
- package/dist/tool-progress.d.ts +1 -1
- package/dist/tool-progress.js +1 -1
- package/package.json +2 -2
package/dist/bot.js
CHANGED
|
@@ -8,8 +8,9 @@ import { FALLBACK_REPLY, resolveBrain } from "./brain.js";
|
|
|
8
8
|
import { beginTurn, finishTurn, lastTurnFor } from "./last-turn.js";
|
|
9
9
|
import { FileStore } from "./storage.js";
|
|
10
10
|
import { TurnPublisher } from "./tool-progress.js";
|
|
11
|
-
import { ChannelTurn, coalesceFollowups, isBotEcho, isPlaceholderText, isStopRequest, wrapInterruptedFollowup, } from "./mid-turn.js";
|
|
11
|
+
import { ChannelTurn, coalesceFollowups, isBotEcho, isPlaceholderText, isStopRequest, wrapInterruptedFollowup, wrapUserTurn, } 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,13 +688,29 @@ 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
715
|
cancel: async () => {
|
|
699
716
|
// Do not recall() — that leaves "This message was deleted" / Message recalled.
|
|
@@ -708,7 +725,7 @@ export class BotRunner {
|
|
|
708
725
|
};
|
|
709
726
|
},
|
|
710
727
|
send: async (body) => {
|
|
711
|
-
await ch
|
|
728
|
+
await deliverOutbound(ch, body, mediaSent);
|
|
712
729
|
},
|
|
713
730
|
}, {
|
|
714
731
|
stopTyping,
|
|
@@ -720,7 +737,7 @@ export class BotRunner {
|
|
|
720
737
|
published.markStarted();
|
|
721
738
|
const live = new ChannelTurn();
|
|
722
739
|
this.turns.set(channelId, live);
|
|
723
|
-
const prompt = interrupted ? wrapInterruptedFollowup(text) : text;
|
|
740
|
+
const prompt = interrupted ? wrapInterruptedFollowup(text) : wrapUserTurn(text);
|
|
724
741
|
let reply = "";
|
|
725
742
|
const stat = beginTurn(this.info.handle, channelId, resolved.kind);
|
|
726
743
|
try {
|
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.25";
|
|
23
23
|
function hermesApiStatus(cfg) {
|
|
24
24
|
const api = lastHermesApiHealth();
|
|
25
25
|
return {
|
package/dist/hermes-http.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { asHooks } from "./brain.js";
|
|
3
3
|
import { hermesSessionHeaders } from "./mid-turn.js";
|
|
4
|
+
import { rememberHermesSessionId } from "./hermes-session-store.js";
|
|
4
5
|
import { RECAP_HINT } from "./tool-progress.js";
|
|
5
6
|
/** Skip data-URL vision parts above this so a gallery cannot blow the POST. */
|
|
6
7
|
const MAX_IMAGE_DATA_URL_BYTES = 8 * 1024 * 1024;
|
|
@@ -120,6 +121,7 @@ export function hermesHttpBrain(cfg) {
|
|
|
120
121
|
console.error(`${tag} HTTP ${res.status}: ${body.slice(0, 300)}`);
|
|
121
122
|
return "";
|
|
122
123
|
}
|
|
124
|
+
rememberHermesSessionId(ctx.channelId, res.headers.get("X-Hermes-Session-Id"));
|
|
123
125
|
if (!res.body) {
|
|
124
126
|
clearTimeout(idle);
|
|
125
127
|
console.error(`${tag} empty body`);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Test-only. Pass null to restore the real bridge home. */
|
|
2
|
+
export declare function setBridgeHomeForTests(dir: string | null): void;
|
|
3
|
+
export declare function resolvedHermesSessionId(channelId: string): string;
|
|
4
|
+
export declare function rememberHermesSessionId(channelId: string, returnedId: string | null | undefined): void;
|
|
5
|
+
export declare function forgetHermesSessionId(channelId: string): void;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Hermes compression rotates X-Hermes-Session-Id (parent → 20260821_…).
|
|
2
|
+
// The bridge used to keep sending the original nopeek-<channel> id, so every
|
|
3
|
+
// turn reloaded the uncompressed parent (301 mixed messages) and Olie snapped
|
|
4
|
+
// back to an old NoPeek backlog. Persist the returned id per channel.
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
function fallbackId(channelId) {
|
|
9
|
+
return `nopeek-${channelId}`;
|
|
10
|
+
}
|
|
11
|
+
let homeOverride = null;
|
|
12
|
+
/** Test-only. Pass null to restore the real bridge home. */
|
|
13
|
+
export function setBridgeHomeForTests(dir) {
|
|
14
|
+
homeOverride = dir;
|
|
15
|
+
}
|
|
16
|
+
function storePath() {
|
|
17
|
+
const home = homeOverride || process.env.NOPEEK_BRIDGE_HOME || join(homedir(), ".nopeek-bridge");
|
|
18
|
+
return join(home, "hermes-sessions.json");
|
|
19
|
+
}
|
|
20
|
+
function readMap() {
|
|
21
|
+
const path = storePath();
|
|
22
|
+
if (!existsSync(path))
|
|
23
|
+
return {};
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
26
|
+
if (!parsed || typeof parsed !== "object")
|
|
27
|
+
return {};
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
30
|
+
if (typeof v === "string" && v.trim())
|
|
31
|
+
out[k] = v.trim();
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function writeMap(map) {
|
|
40
|
+
const path = storePath();
|
|
41
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
42
|
+
const tmp = `${path}.tmp`;
|
|
43
|
+
writeFileSync(tmp, JSON.stringify(map, null, 2));
|
|
44
|
+
renameSync(tmp, path);
|
|
45
|
+
}
|
|
46
|
+
export function resolvedHermesSessionId(channelId) {
|
|
47
|
+
return readMap()[channelId] || fallbackId(channelId);
|
|
48
|
+
}
|
|
49
|
+
export function rememberHermesSessionId(channelId, returnedId) {
|
|
50
|
+
const next = (returnedId || "").trim();
|
|
51
|
+
if (!next)
|
|
52
|
+
return;
|
|
53
|
+
const map = readMap();
|
|
54
|
+
if (map[channelId] === next)
|
|
55
|
+
return;
|
|
56
|
+
map[channelId] = next;
|
|
57
|
+
writeMap(map);
|
|
58
|
+
}
|
|
59
|
+
export function forgetHermesSessionId(channelId) {
|
|
60
|
+
const map = readMap();
|
|
61
|
+
if (!(channelId in map))
|
|
62
|
+
return;
|
|
63
|
+
delete map[channelId];
|
|
64
|
+
writeMap(map);
|
|
65
|
+
}
|
package/dist/mid-turn.d.ts
CHANGED
|
@@ -20,6 +20,9 @@ export declare function isBotEcho(m: {
|
|
|
20
20
|
} | null;
|
|
21
21
|
}, botUserId: string): boolean;
|
|
22
22
|
export declare function coalesceFollowups(texts: string[]): FollowupDecision;
|
|
23
|
+
/** Pinned on every turn so compaction leftovers cannot become the job. */
|
|
24
|
+
export declare const JOB_PIN: string;
|
|
25
|
+
export declare function wrapUserTurn(text: string): string;
|
|
23
26
|
export declare function wrapInterruptedFollowup(text: string): string;
|
|
24
27
|
export declare function hermesSessionId(channelId: string): string;
|
|
25
28
|
export declare function hermesSessionHeaders(apiKey: string, channelId: string): Record<string, string>;
|
package/dist/mid-turn.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// This module is the shared policy: detect stop, coalesce a burst, abort the
|
|
9
9
|
// in-flight turn, and keep one stable Hermes session per channel.
|
|
10
|
+
import { resolvedHermesSessionId } from "./hermes-session-store.js";
|
|
10
11
|
const STOP_RE = /^(?:\/)?(?:please\s+)?stop(?:\s+please)?[.!]?$/i;
|
|
11
12
|
export function isStopRequest(text) {
|
|
12
13
|
return STOP_RE.test((text || "").trim());
|
|
@@ -41,11 +42,28 @@ export function coalesceFollowups(texts) {
|
|
|
41
42
|
const kept = cleaned.filter((t) => !isStopRequest(t));
|
|
42
43
|
return { action: "continue", text: kept.join("\n\n") };
|
|
43
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Keep this marker. Hermes api_server `is_mid_task_followup` looks for it and
|
|
47
|
+
* otherwise prepends its own "ONE combined task" note — that is what made
|
|
48
|
+
* "do all 5" merge with a compacted NoPeek backlog.
|
|
49
|
+
*/
|
|
50
|
+
const INTERRUPT_MARKER = "[System note: You were already working on a task";
|
|
51
|
+
/** Pinned on every turn so compaction leftovers cannot become the job. */
|
|
52
|
+
export const JOB_PIN = "[System note: The message below is the only live job. " +
|
|
53
|
+
"Do not resume older unfinished todos, numbered lists, or other products " +
|
|
54
|
+
"from compacted history unless this message clearly continues that same job. " +
|
|
55
|
+
'"Do all 5" means the list in YOUR LAST REPLY, not an older backlog. ' +
|
|
56
|
+
"Latest user message wins.]";
|
|
57
|
+
export function wrapUserTurn(text) {
|
|
58
|
+
return `${JOB_PIN}\n\n${text}`;
|
|
59
|
+
}
|
|
44
60
|
export function wrapInterruptedFollowup(text) {
|
|
45
|
-
return (
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
"
|
|
61
|
+
return (`${INTERRUPT_MARKER}. The user interrupted. ` +
|
|
62
|
+
"The new message below REPLACES the in-flight task unless it is a small " +
|
|
63
|
+
'add-on to the same job (e.g. "also add X"). Do not resume older unfinished ' +
|
|
64
|
+
"todos or other products from compacted history. " +
|
|
65
|
+
'"Do all 5" means the list in your last reply, not an older backlog. ' +
|
|
66
|
+
"Latest user message wins.]\n\n" +
|
|
49
67
|
text);
|
|
50
68
|
}
|
|
51
69
|
export function hermesSessionId(channelId) {
|
|
@@ -55,10 +73,11 @@ export function hermesSessionHeaders(apiKey, channelId) {
|
|
|
55
73
|
const headers = {};
|
|
56
74
|
if (!apiKey)
|
|
57
75
|
return headers;
|
|
58
|
-
|
|
76
|
+
// Key stays the channel. Id follows compression rotations so we do not
|
|
77
|
+
// reload the uncompressed parent every turn.
|
|
59
78
|
headers.authorization = `Bearer ${apiKey}`;
|
|
60
|
-
headers["X-Hermes-Session-Id"] =
|
|
61
|
-
headers["X-Hermes-Session-Key"] =
|
|
79
|
+
headers["X-Hermes-Session-Id"] = resolvedHermesSessionId(channelId);
|
|
80
|
+
headers["X-Hermes-Session-Key"] = hermesSessionId(channelId);
|
|
62
81
|
return headers;
|
|
63
82
|
}
|
|
64
83
|
export class ChannelTurn {
|
|
@@ -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
|
@@ -14,7 +14,7 @@ export declare function parseHermesActivityLine(raw: string): ToolProgressEvent
|
|
|
14
14
|
/** One Telegram-style line. Running events only; completed is silent. */
|
|
15
15
|
export declare function formatToolLine(ev: ToolProgressEvent): string | null;
|
|
16
16
|
/** Hint so Hermes ends a tool-using turn with a recap, like Telegram. */
|
|
17
|
-
export declare const RECAP_HINT = "When this turn used tools, your LAST message must be ONE phone briefing (not split). Use **bold** labels and blank lines like Telegram: **What was wrong**, **What I did**, **Current state**. Put copyable commands in ``` fences. Never end on a tool list, a diff, or \"tasks completed\". Skip the briefing only for simple chat with no tools. No em dashes.";
|
|
17
|
+
export declare const RECAP_HINT = "The latest user message is the only live job. Do not resume compacted leftovers or an older numbered list from another product. When this turn used tools, your LAST message must be ONE phone briefing (not split). Use **bold** labels and blank lines like Telegram: **What was wrong**, **What I did**, **Current state**. Put copyable commands in ``` fences. Never end on a tool list, a diff, or \"tasks completed\". Skip the briefing only for simple chat with no tools. No em dashes.";
|
|
18
18
|
/** Posted when tools ran but the model never wrote a human recap. */
|
|
19
19
|
export declare const MISSING_BRIEFING = "Done. I finished that work. Ask if you want the recap: what was wrong, what I did, and where things stand.";
|
|
20
20
|
export interface StreamHandle {
|
package/dist/tool-progress.js
CHANGED
|
@@ -95,7 +95,7 @@ function tidyLabel(raw) {
|
|
|
95
95
|
return s.length > 80 ? `${s.slice(0, 77)}...` : s;
|
|
96
96
|
}
|
|
97
97
|
/** Hint so Hermes ends a tool-using turn with a recap, like Telegram. */
|
|
98
|
-
export const RECAP_HINT = "When this turn used tools, your LAST message must be ONE phone briefing (not split). Use **bold** labels and blank lines like Telegram: **What was wrong**, **What I did**, **Current state**. Put copyable commands in ``` fences. Never end on a tool list, a diff, or \"tasks completed\". Skip the briefing only for simple chat with no tools. No em dashes.";
|
|
98
|
+
export const RECAP_HINT = "The latest user message is the only live job. Do not resume compacted leftovers or an older numbered list from another product. When this turn used tools, your LAST message must be ONE phone briefing (not split). Use **bold** labels and blank lines like Telegram: **What was wrong**, **What I did**, **Current state**. Put copyable commands in ``` fences. Never end on a tool list, a diff, or \"tasks completed\". Skip the briefing only for simple chat with no tools. No em dashes.";
|
|
99
99
|
/** Posted when tools ran but the model never wrote a human recap. */
|
|
100
100
|
export const MISSING_BRIEFING = "Done. I finished that work. Ask if you want the recap: what was wrong, what I did, and where things stand.";
|
|
101
101
|
/** Text in `full` that has not already been posted as commentary. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nopeek/agent-bridge",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.25",
|
|
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 src/hermes-session-store.test.ts"
|
|
50
50
|
}
|
|
51
51
|
}
|