@nopeek/agent-bridge 0.7.25 → 0.8.1

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.
@@ -22,6 +22,17 @@ export declare function cpuTimeSeconds(pid: number | undefined): number | null;
22
22
  * byte-for-byte identical to tools/agent/bot-brain.sh so existing sessions
23
23
  * keep their history when a bot moves from the shell brain to the native one.
24
24
  */
25
+ /**
26
+ * Where Claude Code keeps a session for a given cwd: ~/.claude/projects with
27
+ * every "/" and "." in the path turned into "-", then <session-uuid>.jsonl.
28
+ *
29
+ * Used only to answer "is there history here?" before deciding whether a failed
30
+ * --resume may be retried as a fresh --session-id. Errors read as "no session",
31
+ * which is the conservative answer for a NEW chat and merely costs a retry for
32
+ * an existing one.
33
+ */
34
+ export declare function claudeSessionFile(runDir: string, sid: string): string;
35
+ export declare function sessionExists(runDir: string, sid: string): boolean;
25
36
  export declare function sessionUuid(handle: string, channelId: string): string;
26
37
  /**
27
38
  * Claude Code backend: `claude -p` headless (reliable — no OAuth flakiness),
package/dist/backends.js CHANGED
@@ -15,7 +15,7 @@ import { spawn, spawnSync } from "node:child_process";
15
15
  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
- import { basename, join } from "node:path";
18
+ import { basename, join, resolve as resolvePath } from "node:path";
19
19
  import { asHooks, stripAnsi } from "./brain.js";
20
20
  import { hermesHttpBrain, probeHermesApi } from "./hermes-http.js";
21
21
  import { parseHermesActivityLine } from "./tool-progress.js";
@@ -119,6 +119,27 @@ export function cpuTimeSeconds(pid) {
119
119
  * byte-for-byte identical to tools/agent/bot-brain.sh so existing sessions
120
120
  * keep their history when a bot moves from the shell brain to the native one.
121
121
  */
122
+ /**
123
+ * Where Claude Code keeps a session for a given cwd: ~/.claude/projects with
124
+ * every "/" and "." in the path turned into "-", then <session-uuid>.jsonl.
125
+ *
126
+ * Used only to answer "is there history here?" before deciding whether a failed
127
+ * --resume may be retried as a fresh --session-id. Errors read as "no session",
128
+ * which is the conservative answer for a NEW chat and merely costs a retry for
129
+ * an existing one.
130
+ */
131
+ export function claudeSessionFile(runDir, sid) {
132
+ const slug = resolvePath(runDir).replace(/[/.]/g, "-");
133
+ return join(homedir(), ".claude", "projects", slug, `${sid}.jsonl`);
134
+ }
135
+ export function sessionExists(runDir, sid) {
136
+ try {
137
+ return existsSync(claudeSessionFile(runDir, sid));
138
+ }
139
+ catch {
140
+ return false;
141
+ }
142
+ }
122
143
  export function sessionUuid(handle, channelId) {
123
144
  const h = createHash("sha1").update(`nopeek:${handle}:${channelId}`).digest("hex").slice(0, 32);
124
145
  return `${h.slice(0, 8)}-${h.slice(8, 12)}-5${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`;
@@ -240,6 +261,19 @@ export function claudeBrain(cfg) {
240
261
  catch {
241
262
  /* missing/unreadable soul — run with base behavior */
242
263
  }
264
+ // Read and Grep are ALLOWED for this brain (only Bash/Edit/Write/Task/
265
+ // WebFetch are denied), so pointing at the transcript costs no new powers
266
+ // and is the difference between "I lost the context" and looking it up.
267
+ if (ctx.transcriptFile) {
268
+ soul +=
269
+ `\n\n## Recovering something you no longer remember\n\n` +
270
+ `The full transcript of THIS chat is at \`${ctx.transcriptFile}\` — every message, ` +
271
+ `oldest first, as \`[seq] speaker: text\`. It is rewritten before every turn and is ` +
272
+ `the durable record; your own session memory is not, it gets compacted.\n\n` +
273
+ `If you are missing a detail — an id, a number, what was decided — **Grep or Read ` +
274
+ `that file instead of telling the user you lost context**. Saying "the context ` +
275
+ `rolled" when the answer is one Grep away is not acceptable.\n`;
276
+ }
243
277
  const sid = sessionUuid(handle, ctx.channelId);
244
278
  const runDir = join(cfg.homeDir, "agent-run");
245
279
  mkdirSync(runDir, { recursive: true });
@@ -272,7 +306,17 @@ export function claudeBrain(cfg) {
272
306
  // into a duplicate answer; tell the user honestly.
273
307
  return BRAIN_UNREACHABLE;
274
308
  }
275
- console.error(`${tag} --resume ${sid} yielded nothing (exit ${first.exitCode}) retrying with --session-id`);
309
+ // --session-id on an id that ALREADY has history starts an empty
310
+ // conversation under that id: it does not resume, it replaces. Falling
311
+ // through to it on any failure is how one timeout silently threw away a
312
+ // chat's entire memory, with nothing but a line on the bridge's stderr to
313
+ // show for it. Only do it when there is genuinely nothing to lose.
314
+ if (sessionExists(runDir, sid)) {
315
+ console.error(`${tag} --resume ${sid} failed (exit ${first.exitCode}) but that session EXISTS — ` +
316
+ `refusing to reset it. The next message retries the resume.`);
317
+ return BRAIN_UNREACHABLE;
318
+ }
319
+ console.error(`${tag} no session for ${sid} yet — starting one with --session-id`);
276
320
  const second = await runClaudeOnce(bin, [...baseArgs, "--session-id", sid], text, runDir, cfg.brainTimeoutMs, tag, emit);
277
321
  if (second.reply)
278
322
  return second.reply;
package/dist/bot.d.ts CHANGED
@@ -12,6 +12,11 @@ export interface BotInfo {
12
12
  /** runtime/bots serializes via Views.bot, which names the field brainBackend. */
13
13
  brainBackend?: BrainBackend | null;
14
14
  }
15
+ /** A say() failure a caller can act on, rather than a generic 500. */
16
+ export declare class SayError extends Error {
17
+ readonly code: "NOT_CONNECTED" | "NO_SUCH_CHANNEL" | "AMBIGUOUS_CHANNEL" | "NO_SUCH_BOT";
18
+ constructor(code: "NOT_CONNECTED" | "NO_SUCH_CHANNEL" | "AMBIGUOUS_CHANNEL" | "NO_SUCH_BOT", message: string);
19
+ }
15
20
  export declare class BotRunner {
16
21
  readonly info: BotInfo;
17
22
  connected: boolean;
@@ -50,6 +55,36 @@ export declare class BotRunner {
50
55
  get lastForcedRestartAt(): number | null;
51
56
  /** Most recent brain turn for this bot (shared last-turn store). */
52
57
  lastTurn(): BrainTurn | null;
58
+ /**
59
+ * PROACTIVE SEND — post into a channel nobody asked us to post in.
60
+ *
61
+ * Every other path in this runner is a REPLY: a frame arrives, the brain
62
+ * runs, an answer goes back on that frame's channel. Monitoring inverts
63
+ * that. An alarm fires at 03:00 and no one has messaged the bot, so there
64
+ * is no inbound frame to hang a reply on and no channel implied by one.
65
+ * This is the only entry point that starts a conversation.
66
+ *
67
+ * `target` is a channelId OR a channel NAME (case-insensitive), resolved
68
+ * against the channels this bot is actually a member of. Names are the
69
+ * whole point: an ops script should name "xhd-alerts" and keep working if
70
+ * that channel is ever recreated, rather than carry an id that silently
71
+ * goes stale and starts posting nowhere.
72
+ *
73
+ * ensureKey() first. A bot added to a group while it was offline holds no
74
+ * MLS key for that group yet and send() would throw. The reply path gets
75
+ * this for free — an inbound frame is itself proof the key exists — but a
76
+ * proactive send has no such proof.
77
+ */
78
+ say(target: string, text: string): Promise<{
79
+ channelId: string;
80
+ channelName: string;
81
+ messageId: string;
82
+ }>;
83
+ /** The channels this bot can say() into — the address book, and the error
84
+ * message when a caller names one that isn't there. */
85
+ listChannels(): Promise<Array<Record<string, unknown>>>;
86
+ private requireClient;
87
+ private resolveChannel;
53
88
  /**
54
89
  * Watchdog hard restart: tear the SDK client down completely and rebuild it
55
90
  * from scratch (fresh session mint + NoPeek.connect) — the proven-working
package/dist/bot.js CHANGED
@@ -9,6 +9,7 @@ import { beginTurn, finishTurn, lastTurnFor } from "./last-turn.js";
9
9
  import { FileStore } from "./storage.js";
10
10
  import { TurnPublisher } from "./tool-progress.js";
11
11
  import { ChannelTurn, coalesceFollowups, isBotEcho, isPlaceholderText, isStopRequest, wrapInterruptedFollowup, wrapUserTurn, } from "./mid-turn.js";
12
+ import { buildChatMemory, EMPTY_MEMORY } from "./chat-memory.js";
12
13
  import { buildInboundPrompt, describeStructured, hasInboundWork, isControlType, parseAttachments, saveInboundFiles, } from "./inbound-files.js";
13
14
  import { deliverOutbound, extractMedia, attachMediaFiles, stripMediaTags } from "./outbound-media.js";
14
15
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -19,6 +20,15 @@ const OWNER_PRESENCE_TTL_MS = 60_000;
19
20
  // Refresh the bot session 5 min before it expires (clamped to a sane window).
20
21
  const REFRESH_MARGIN_MS = 5 * 60_000;
21
22
  const MAX_REFRESH_DELAY_MS = 6 * 24 * 60 * 60_000;
23
+ /** A say() failure a caller can act on, rather than a generic 500. */
24
+ export class SayError extends Error {
25
+ code;
26
+ constructor(code, message) {
27
+ super(message);
28
+ this.code = code;
29
+ this.name = "SayError";
30
+ }
31
+ }
22
32
  export class BotRunner {
23
33
  info;
24
34
  connected = false;
@@ -106,6 +116,82 @@ export class BotRunner {
106
116
  lastTurn() {
107
117
  return lastTurnFor(this.info.handle);
108
118
  }
119
+ /**
120
+ * PROACTIVE SEND — post into a channel nobody asked us to post in.
121
+ *
122
+ * Every other path in this runner is a REPLY: a frame arrives, the brain
123
+ * runs, an answer goes back on that frame's channel. Monitoring inverts
124
+ * that. An alarm fires at 03:00 and no one has messaged the bot, so there
125
+ * is no inbound frame to hang a reply on and no channel implied by one.
126
+ * This is the only entry point that starts a conversation.
127
+ *
128
+ * `target` is a channelId OR a channel NAME (case-insensitive), resolved
129
+ * against the channels this bot is actually a member of. Names are the
130
+ * whole point: an ops script should name "xhd-alerts" and keep working if
131
+ * that channel is ever recreated, rather than carry an id that silently
132
+ * goes stale and starts posting nowhere.
133
+ *
134
+ * ensureKey() first. A bot added to a group while it was offline holds no
135
+ * MLS key for that group yet and send() would throw. The reply path gets
136
+ * this for free — an inbound frame is itself proof the key exists — but a
137
+ * proactive send has no such proof.
138
+ */
139
+ async say(target, text) {
140
+ const ch = await this.resolveChannel(target);
141
+ await ch.ensureKey();
142
+ const sent = await ch.send({ text });
143
+ this.log(`say -> ${ch.record.name || ch.record.channelId} (${text.length} chars)`);
144
+ return { channelId: ch.record.channelId, channelName: ch.record.name, messageId: sent.messageId };
145
+ }
146
+ /** The channels this bot can say() into — the address book, and the error
147
+ * message when a caller names one that isn't there. */
148
+ async listChannels() {
149
+ const chans = await this.requireClient().channels.list();
150
+ return chans.map((c) => ({
151
+ channelId: c.record.channelId,
152
+ name: c.record.name,
153
+ kind: c.record.kind,
154
+ e2ee: c.record.e2ee,
155
+ memberCount: c.record.memberCount,
156
+ }));
157
+ }
158
+ requireClient() {
159
+ if (!this.np || !this.connected) {
160
+ throw new SayError("NOT_CONNECTED", `bot ${this.info.handle} is not connected right now`);
161
+ }
162
+ return this.np;
163
+ }
164
+ async resolveChannel(target) {
165
+ const np = this.requireClient();
166
+ const chans = await np.channels.list();
167
+ const byId = chans.find((c) => c.record.channelId === target);
168
+ if (byId)
169
+ return byId;
170
+ const want = target.trim().toLowerCase();
171
+ // RESERVED TARGET "owner" — the bot's direct chat with its owner. Direct
172
+ // channels carry no name, so without this every caller would have to
173
+ // hardcode a channelId, which is the thing that rots. An ops script says
174
+ // "owner" and keeps working across a re-pair.
175
+ if (want === "owner" || want === "dm" || want === "direct") {
176
+ const dms = chans.filter((c) => c.record.kind === "direct" || c.record.kind === "dm");
177
+ if (dms.length === 1)
178
+ return dms[0];
179
+ if (dms.length > 1) {
180
+ throw new SayError("AMBIGUOUS_CHANNEL", `bot ${this.info.handle} has ${dms.length} direct chats - pass a channelId`);
181
+ }
182
+ throw new SayError("NO_SUCH_CHANNEL", `bot ${this.info.handle} has no direct chat yet - message it once from the app first`);
183
+ }
184
+ const byName = chans.filter((c) => (c.record.name ?? "").trim().toLowerCase() === want);
185
+ if (byName.length === 1)
186
+ return byName[0];
187
+ // Two channels with one name must FAIL, never pick. An alert delivered to
188
+ // the wrong group reads as "no alert" in the right one.
189
+ if (byName.length > 1) {
190
+ throw new SayError("AMBIGUOUS_CHANNEL", `${byName.length} channels are named "${target}" - pass a channelId instead`);
191
+ }
192
+ const known = chans.map((c) => c.record.name).filter(Boolean).join(", ") || "(none)";
193
+ throw new SayError("NO_SUCH_CHANNEL", `bot ${this.info.handle} is not in a channel named "${target}". It is in: ${known}`);
194
+ }
109
195
  /**
110
196
  * Watchdog hard restart: tear the SDK client down completely and rebuild it
111
197
  * from scratch (fresh session mint + NoPeek.connect) — the proven-working
@@ -737,7 +823,17 @@ export class BotRunner {
737
823
  published.markStarted();
738
824
  const live = new ChannelTurn();
739
825
  this.turns.set(channelId, live);
740
- const prompt = interrupted ? wrapInterruptedFollowup(text) : wrapUserTurn(text);
826
+ // The chat itself is the durable memory — the Claude session is not, it
827
+ // compacts. Inline a recent window and hand over the full transcript path
828
+ // so a compacted session can still answer "which ticket?" exactly.
829
+ const memory = await buildChatMemory({
830
+ channel: ch,
831
+ botUserId: this.info.userId,
832
+ channelId,
833
+ homeDir: this.cfg.homeDir,
834
+ }).catch(() => EMPTY_MEMORY);
835
+ const turnText = interrupted ? wrapInterruptedFollowup(text) : wrapUserTurn(text);
836
+ const prompt = memory.recent ? `${memory.recent}\n\n${turnText}` : turnText;
741
837
  let reply = "";
742
838
  const stat = beginTurn(this.info.handle, channelId, resolved.kind);
743
839
  try {
@@ -747,6 +843,7 @@ export class BotRunner {
747
843
  channelId,
748
844
  senderUserId,
749
845
  signal: live.abort.signal,
846
+ ...(memory.file ? { transcriptFile: memory.file } : {}),
750
847
  ...(files.length ? { files } : {}),
751
848
  }, { onChunk: (delta) => published.onChunk(delta), onTool: (ev) => published.onTool(ev) });
752
849
  if (live.abort.signal.aborted) {
package/dist/brain.d.ts CHANGED
@@ -17,6 +17,11 @@ export interface BrainContext {
17
17
  files?: BrainFile[];
18
18
  /** Abort the in-flight brain when a later message interrupts this turn. */
19
19
  signal?: AbortSignal;
20
+ /**
21
+ * Full transcript of this chat on disk. The brain may point the model at it
22
+ * (Read/Grep are allowed) so a compacted session can still recover detail.
23
+ */
24
+ transcriptFile?: string;
20
25
  }
21
26
  /**
22
27
  * A brain answers one message. If it can stream, it calls `onChunk(delta)` as
package/dist/bridge.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { BridgeConfig, Pairing, BrainSpec, BrainBackend } from "./config.js";
2
- export declare const VERSION = "0.7.25";
2
+ import { BotRunner } from "./bot.js";
3
+ export declare const VERSION: string;
3
4
  export interface PairRequest {
4
5
  pairingSecret: string;
5
6
  appId: string;
@@ -48,6 +49,19 @@ export declare class BridgeApp {
48
49
  /** Every runtime id this bridge answers for (persisted or live-auth'd).
49
50
  * The local API accepts ANY of these as the x-nopeek-runtime capability. */
50
51
  runtimeIds(): string[];
52
+ /**
53
+ * Resolve a bot handle across ALL pairings. This bridge holds one runner per
54
+ * (pairing, bot), and a handle is unique within an app, so the first match
55
+ * wins. Kept separate from runtimeIds() because a caller with ANY pairing's
56
+ * runtime id may address any bot on this machine — the capability is "you
57
+ * are on this computer", which is already what loopback + the runtime header
58
+ * mean together.
59
+ */
60
+ findBot(handle: string): BotRunner;
61
+ /** Proactive post. See BotRunner.say — this is the alarm/report entry point. */
62
+ say(handle: string, channel: string, text: string): Promise<Record<string, unknown>>;
63
+ /** Channels a given bot can be addressed on. */
64
+ botChannels(handle: string): Promise<Array<Record<string, unknown>>>;
51
65
  start(): void;
52
66
  stop(): void;
53
67
  private startRuntime;
package/dist/bridge.js CHANGED
@@ -8,10 +8,13 @@
8
8
  // persist to <home>/settings.json — no restart, no terminal. Brains
9
9
  // (brainMap/serverBackends) are GLOBAL per machine: handles are globally
10
10
  // unique, so a handle→brain map needs no per-account scoping.
11
+ import { readFileSync } from "node:fs";
11
12
  import { hostname } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
12
15
  import { spawn } from "node:child_process";
13
16
  import { saveSettings } from "./config.js";
14
- import { BotRunner } from "./bot.js";
17
+ import { BotRunner, SayError } from "./bot.js";
15
18
  import { ControlSocket } from "./control.js";
16
19
  import { resolveBrain } from "./brain.js";
17
20
  import { provisionSoul, provisionHermesProfile, resolveBin } from "./backends.js";
@@ -19,7 +22,23 @@ import { reportCapabilities } from "./capabilities.js";
19
22
  import { lastHermesApiHealth, probeHermesApi } from "./hermes-http.js";
20
23
  import { lastTurnGlobal, turnSnapshot } from "./last-turn.js";
21
24
  import { isBrainBackend } from "./config.js";
22
- export const VERSION = "0.7.25";
25
+ /**
26
+ * Read from package.json rather than hardcoded, because the hardcoded copy
27
+ * DRIFTED: 0.8.0 shipped to npm still reporting "0.7.25", which made the one
28
+ * question that matters after an upgrade — "is the fix actually running?" —
29
+ * impossible to answer from /status. One source of truth, checked by a test.
30
+ */
31
+ function readVersion() {
32
+ try {
33
+ const here = dirname(fileURLToPath(import.meta.url));
34
+ // dist/bridge.js -> ../package.json ; src/bridge.ts -> ../package.json
35
+ return String(JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")).version || "");
36
+ }
37
+ catch {
38
+ return "unknown";
39
+ }
40
+ }
41
+ export const VERSION = readVersion();
23
42
  function hermesApiStatus(cfg) {
24
43
  const api = lastHermesApiHealth();
25
44
  return {
@@ -170,6 +189,15 @@ class PairingRuntime {
170
189
  bots: this.botStatuses(),
171
190
  };
172
191
  }
192
+ /** This pairing's runner for `handle`, or null. Handles are unique per app. */
193
+ findBot(handle) {
194
+ const want = handle.trim().toLowerCase().replace(/^@/, "");
195
+ for (const b of this.bots.values()) {
196
+ if (b.info.handle.trim().toLowerCase() === want)
197
+ return b;
198
+ }
199
+ return null;
200
+ }
173
201
  botStatuses() {
174
202
  return [...this.bots.values()].map((b) => ({
175
203
  handle: b.info.handle,
@@ -328,6 +356,31 @@ export class BridgeApp {
328
356
  }
329
357
  return ids;
330
358
  }
359
+ /**
360
+ * Resolve a bot handle across ALL pairings. This bridge holds one runner per
361
+ * (pairing, bot), and a handle is unique within an app, so the first match
362
+ * wins. Kept separate from runtimeIds() because a caller with ANY pairing's
363
+ * runtime id may address any bot on this machine — the capability is "you
364
+ * are on this computer", which is already what loopback + the runtime header
365
+ * mean together.
366
+ */
367
+ findBot(handle) {
368
+ for (const r of this.runtimes) {
369
+ const b = r.findBot(handle);
370
+ if (b)
371
+ return b;
372
+ }
373
+ const known = this.runtimes.flatMap((r) => r.botStatuses().map((b) => b.handle)).join(", ") || "(none running)";
374
+ throw new SayError("NO_SUCH_BOT", `no bot "${handle}" on this bridge. Running: ${known}`);
375
+ }
376
+ /** Proactive post. See BotRunner.say — this is the alarm/report entry point. */
377
+ async say(handle, channel, text) {
378
+ return await this.findBot(handle).say(channel, text);
379
+ }
380
+ /** Channels a given bot can be addressed on. */
381
+ async botChannels(handle) {
382
+ return await this.findBot(handle).listChannels();
383
+ }
331
384
  start() {
332
385
  if (!this.paired) {
333
386
  console.log(`[bridge] not paired yet — open the NoPeek app: Bots -> Connect this computer`);
@@ -0,0 +1,46 @@
1
+ /** Just the shape we need — avoids importing the SDK's Channel type here. */
2
+ interface HistoryReader {
3
+ history(opts: {
4
+ beforeSeq?: number;
5
+ limit?: number;
6
+ }): Promise<HistoryMessage[]>;
7
+ }
8
+ interface HistoryMessage {
9
+ messageId: string;
10
+ senderUserId: string;
11
+ seq: number;
12
+ createdAt?: string;
13
+ recalledAt?: string | null;
14
+ body?: {
15
+ type?: string;
16
+ text?: string;
17
+ streaming?: boolean;
18
+ } | null;
19
+ }
20
+ export interface ChatMemory {
21
+ /** Labelled transcript block to prepend to the turn. "" when there is none. */
22
+ recent: string;
23
+ /** Absolute path to the full transcript, or null if it could not be written. */
24
+ file: string | null;
25
+ /** How many messages the full transcript holds. */
26
+ count: number;
27
+ }
28
+ export declare const EMPTY_MEMORY: ChatMemory;
29
+ /**
30
+ * Pull this channel's history, write the full transcript, and return a compact
31
+ * recent window. Never throws: a bot that cannot read its history should still
32
+ * answer, just with less memory.
33
+ */
34
+ export declare function buildChatMemory(opts: {
35
+ channel: HistoryReader;
36
+ botUserId: string;
37
+ channelId: string;
38
+ homeDir: string;
39
+ /** Messages inlined into the prompt. */
40
+ inline?: number;
41
+ /** Messages kept in the on-disk transcript. */
42
+ depth?: number;
43
+ /** Per-message character cap for the INLINE block only. */
44
+ inlineChars?: number;
45
+ }): Promise<ChatMemory>;
46
+ export {};
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Durable chat memory for a bot turn.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * The Claude brain runs `claude -p --resume <per-channel uuid>`, so the whole
7
+ * conversation lives in one Claude Code session — and Claude Code COMPACTS when
8
+ * that session outgrows the context window. Compaction replaces detail with a
9
+ * summary, which is how a bot ends up saying "the context rolled and I don't
10
+ * have the id from earlier".
11
+ *
12
+ * The fix is not a bigger window. The conversation is already stored durably in
13
+ * the NoPeek channel itself; the bot was simply the one participant who could
14
+ * not read its own chat — the prompt it received was the single incoming
15
+ * message and nothing else. This module gives it two ways back:
16
+ *
17
+ * 1. `recent` — a compact transcript inlined into every turn, so ordinary
18
+ * "what did we just decide" questions never depend on session memory.
19
+ * 2. `file` — the full transcript written to the bot's own home dir. Read and
20
+ * Grep are on the brain's ALLOWED tool list (only Bash/Edit/Write/Task/
21
+ * WebFetch are denied), so the bot can search far past the inline window
22
+ * without any MCP plumbing.
23
+ *
24
+ * ## On writing plaintext to disk
25
+ *
26
+ * The transcript holds decrypted chat text, so it is deliberately written only
27
+ * under the bot's own home directory (0600, in a 0700 dir) on the owner's
28
+ * machine. That is not a new exposure: the Claude session file in
29
+ * ~/.claude/projects already contains the same plaintext on the same disk, and
30
+ * the brain is handed the message text regardless. It never leaves the host.
31
+ */
32
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
33
+ import { join } from "node:path";
34
+ export const EMPTY_MEMORY = { recent: "", file: null, count: 0 };
35
+ /** One page is the server's practical maximum; paging is by descending seq. */
36
+ const PAGE = 100;
37
+ /**
38
+ * Render one message as a transcript line.
39
+ *
40
+ * Senders are labelled by ROLE, not name: the members endpoint carries no
41
+ * nickname, and resolving one lookup per participant per turn is not worth a
42
+ * round trip. In a DM "user" is unambiguous; in a group the short id keeps two
43
+ * humans apart, and the bot can still correlate it with anything it knows.
44
+ */
45
+ function line(m, botUserId, multiHuman) {
46
+ if (m.recalledAt)
47
+ return null;
48
+ const body = m.body ?? null;
49
+ const text = typeof body?.text === "string" ? body.text.trim() : "";
50
+ // A bare "…" is a streaming placeholder the bot posted, not content.
51
+ if (!text || text === "…" || text === "...") {
52
+ // Keep non-text attachments visible so "you sent me a photo" still parses.
53
+ const kind = body?.type;
54
+ if (!kind || kind === "text")
55
+ return null;
56
+ const who = m.senderUserId === botUserId ? "assistant" : "user";
57
+ return `[${m.seq}] ${who}: (${kind})`;
58
+ }
59
+ const who = m.senderUserId === botUserId
60
+ ? "assistant"
61
+ : multiHuman
62
+ ? `user:${m.senderUserId.slice(-6)}`
63
+ : "user";
64
+ // Long messages are clipped in the INLINE block only; the file keeps them
65
+ // whole, which is the point of having the file.
66
+ return `[${m.seq}] ${who}: ${text}`;
67
+ }
68
+ function clip(s, max) {
69
+ return s.length <= max ? s : `${s.slice(0, max)}… (clipped — full text in the transcript file)`;
70
+ }
71
+ /**
72
+ * Pull this channel's history, write the full transcript, and return a compact
73
+ * recent window. Never throws: a bot that cannot read its history should still
74
+ * answer, just with less memory.
75
+ */
76
+ export async function buildChatMemory(opts) {
77
+ const inline = opts.inline ?? 30;
78
+ const depth = opts.depth ?? 400;
79
+ const inlineChars = opts.inlineChars ?? 700;
80
+ let all = [];
81
+ try {
82
+ let before;
83
+ while (all.length < depth) {
84
+ const page = await opts.channel.history({
85
+ limit: Math.min(PAGE, depth - all.length),
86
+ ...(before ? { beforeSeq: before } : {}),
87
+ });
88
+ if (!page.length)
89
+ break;
90
+ all = all.concat(page);
91
+ const lowest = Math.min(...page.map((m) => m.seq));
92
+ if (!Number.isFinite(lowest) || lowest <= 1)
93
+ break;
94
+ if (before !== undefined && lowest >= before)
95
+ break; // no progress — stop
96
+ before = lowest;
97
+ }
98
+ }
99
+ catch {
100
+ return EMPTY_MEMORY;
101
+ }
102
+ // The API's order is not guaranteed; seq is. Dedupe, then sort oldest-first.
103
+ const byId = new Map();
104
+ for (const m of all)
105
+ byId.set(m.messageId, m);
106
+ const ordered = [...byId.values()].sort((a, b) => a.seq - b.seq);
107
+ if (!ordered.length)
108
+ return EMPTY_MEMORY;
109
+ const humans = new Set(ordered.map((m) => m.senderUserId).filter((u) => u !== opts.botUserId));
110
+ const multiHuman = humans.size > 1;
111
+ const rendered = ordered
112
+ .map((m) => line(m, opts.botUserId, multiHuman))
113
+ .filter((l) => l !== null);
114
+ if (!rendered.length)
115
+ return EMPTY_MEMORY;
116
+ // ---- full transcript on disk -------------------------------------------
117
+ let file = null;
118
+ try {
119
+ const dir = join(opts.homeDir, "chats");
120
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
121
+ file = join(dir, `${opts.channelId}.md`);
122
+ const header = `# Transcript — NoPeek chat ${opts.channelId}\n` +
123
+ `# ${rendered.length} messages, oldest first. Regenerated before every turn.\n` +
124
+ `# Lines are "[seq] speaker: text".\n\n`;
125
+ writeFileSync(file, header + rendered.join("\n") + "\n", { mode: 0o600 });
126
+ chmodSync(file, 0o600);
127
+ }
128
+ catch {
129
+ file = null; // disk trouble must not break the turn
130
+ }
131
+ // ---- compact recent window for the prompt -------------------------------
132
+ // Drop the final line: it is the message being answered, which the turn
133
+ // already carries verbatim, and repeating it invites the bot to answer twice.
134
+ const window = rendered.slice(-(inline + 1), -1).map((l) => clip(l, inlineChars));
135
+ const recent = window.length
136
+ ? [
137
+ "[CHAT TRANSCRIPT — the real record of this NoPeek chat, oldest first.",
138
+ "Your session memory may have been compacted; this has not. If you are",
139
+ "missing a detail (an id, a number, what was decided), take it from here",
140
+ `rather than saying you lost context.${file ? ` The full history is at ${file} — Read or Grep it when you need more than this window.` : ""}`,
141
+ "Do NOT answer these again; they are context. Only the message after the",
142
+ "transcript is the one to answer.]",
143
+ "",
144
+ ...window,
145
+ "[END TRANSCRIPT]",
146
+ ].join("\n")
147
+ : "";
148
+ return { recent, file, count: rendered.length };
149
+ }
package/dist/localapi.js CHANGED
@@ -17,12 +17,15 @@
17
17
  // read bot lists or change brain commands.
18
18
  // DELETE /pair?runtimeId=rt_… removes ONE pairing; no query = remove ALL
19
19
  // (the legacy pre-0.6 shape keeps working).
20
+ // POST /say proactive send (see the route) — runtime header required.
21
+ // GET /channels?bot= which channels that bot can be addressed on.
20
22
  //
21
23
  // CORS reflects the caller origin (the app may be served from any white-label
22
24
  // domain) and answers Chrome's Private Network Access preflight.
23
25
  import { createServer } from "node:http";
24
26
  import { createHash, timingSafeEqual } from "node:crypto";
25
27
  import { PairError } from "./bridge.js";
28
+ import { SayError } from "./bot.js";
26
29
  import { detectRuntimes } from "./detect.js";
27
30
  const BODY_LIMIT = 64 * 1024;
28
31
  function setCors(req, res) {
@@ -171,6 +174,71 @@ async function handle(app, req, res) {
171
174
  json(res, 200, { runtimes: await detectRuntimes() });
172
175
  return;
173
176
  }
177
+ // ── PROACTIVE SEND ────────────────────────────────────────────────────────
178
+ // POST /say {bot, channel, text} — post into an E2EE channel WITHOUT an
179
+ // inbound message to reply to. This is what makes the bridge usable for
180
+ // monitoring: alarms and scheduled reports have no inbound frame to answer,
181
+ // so without this route nothing on this machine can start a conversation.
182
+ //
183
+ // The device keys stay here. Callers (ops scripts, cron, the MCP server)
184
+ // hand over plaintext and never touch MLS state, which is the reason this
185
+ // is a bridge route and not a second key-holder somewhere else.
186
+ //
187
+ // `channel` accepts a channelId or a channel NAME, resolved against the
188
+ // channels that bot is actually in. Unknown/ambiguous names 404/409 with the
189
+ // list of real ones rather than guessing — a misrouted alert is silence.
190
+ if (method === "POST" && path === "/say") {
191
+ let body;
192
+ try {
193
+ body = await readBody(req);
194
+ }
195
+ catch (err) {
196
+ json(res, 400, { error: "BAD_REQUEST", message: err.message });
197
+ return;
198
+ }
199
+ const bot = typeof body.bot === "string" ? body.bot.trim() : "";
200
+ const channel = typeof body.channel === "string" ? body.channel.trim() : "";
201
+ const text = typeof body.text === "string" ? body.text : "";
202
+ if (!bot || !channel || !text.trim()) {
203
+ json(res, 400, { error: "BAD_REQUEST", message: "bot, channel and text are all required" });
204
+ return;
205
+ }
206
+ try {
207
+ const sent = await app.say(bot, channel, text);
208
+ json(res, 200, { ok: true, ...sent });
209
+ }
210
+ catch (err) {
211
+ if (err instanceof SayError) {
212
+ const status = err.code === "NO_SUCH_BOT" || err.code === "NO_SUCH_CHANNEL" ? 404 : err.code === "AMBIGUOUS_CHANNEL" ? 409 : 503;
213
+ json(res, status, { error: err.code, message: err.message });
214
+ }
215
+ else {
216
+ json(res, 502, { error: "SEND_FAILED", message: err.message });
217
+ }
218
+ }
219
+ return;
220
+ }
221
+ // GET /channels?bot=handle — the address book for /say, and the answer to
222
+ // "which groups is this bot in?" after it's been added to new ones.
223
+ if (method === "GET" && path === "/channels") {
224
+ const bot = (query.get("bot") ?? "").trim();
225
+ if (!bot) {
226
+ json(res, 400, { error: "BAD_REQUEST", message: "?bot=handle is required" });
227
+ return;
228
+ }
229
+ try {
230
+ json(res, 200, { bot, channels: await app.botChannels(bot) });
231
+ }
232
+ catch (err) {
233
+ if (err instanceof SayError) {
234
+ json(res, err.code === "NO_SUCH_BOT" ? 404 : 503, { error: err.code, message: err.message });
235
+ }
236
+ else {
237
+ json(res, 502, { error: "LIST_FAILED", message: err.message });
238
+ }
239
+ }
240
+ return;
241
+ }
174
242
  if (method === "PUT" && path === "/brains") {
175
243
  let body;
176
244
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nopeek/agent-bridge",
3
- "version": "0.7.25",
3
+ "version": "0.8.1",
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 src/outbound-media.test.ts src/hermes-session-store.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 src/chat-memory.test.ts"
50
50
  }
51
51
  }