agent-coord-mcp 0.9.0 → 0.13.0

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.
@@ -43,6 +43,9 @@ import lockfile from "proper-lockfile";
43
43
  const args = parseArgs(process.argv.slice(2));
44
44
  let ID = args.id ?? process.env.USER ?? "human"; // reassignable so /nick can rebind it
45
45
  const ROOT = args.dir ?? process.env.AGENT_COORD_DIR ?? path.join(homedir(), "agent-coord");
46
+ // Pin the env so a dynamically-imported dist tool (e.g. /doctor's doctorTool)
47
+ // binds store.js's ROOT to the same dir when --dir overrode the default.
48
+ process.env.AGENT_COORD_DIR = ROOT;
46
49
 
47
50
  // Message-rendering state + helpers. Declared up here (above the top-level
48
51
  // printRecent() call) so they're initialized before first use — const/let
@@ -186,6 +189,15 @@ const AGENT_COLORS = [
186
189
  ];
187
190
  // Will be initialized after ROOT is set, just below.
188
191
 
192
+ // ---------- non-interactive --doctor ----------
193
+ // `coord-chat --doctor [--fix]` runs the same health check as the MCP doctor
194
+ // tool and prints its report, then exits — no registration, no TUI. Same
195
+ // renderer as the /doctor slash command, so CLI and in-chat output match.
196
+ if (args.doctor) {
197
+ const ok = await runDoctor(!!args.fix, (l) => process.stdout.write(l + "\n"));
198
+ process.exit(ok ? 0 : 1);
199
+ }
200
+
189
201
  // ---------- register and start UI ----------
190
202
 
191
203
  await register();
@@ -219,7 +231,7 @@ const SLASH_COMMANDS = [
219
231
  "/dm", "/msg", "/list", "/who", "/whoami", "/whois", "/last", "/find",
220
232
  "/clear", "/cls", "/me", "/status", "/away", "/back", "/ignore", "/unignore",
221
233
  "/nick", "/join", "/part", "/leave", "/rooms", "/channels", "/topic", "/motd",
222
- "/rules", "/prune", "/kick", "/wipe-room",
234
+ "/rules", "/prune", "/kick", "/wipe-room", "/doctor",
223
235
  "/help", "/?", "/quit", "/exit",
224
236
  ];
225
237
 
@@ -442,6 +454,9 @@ async function handleLine(line) {
442
454
  const term = text.slice(6).trim();
443
455
  if (!term) say(A.red("usage: /find <text>"));
444
456
  else await findInHistory(term);
457
+ } else if (text === "/doctor" || text.startsWith("/doctor ")) {
458
+ const fix = text.slice(7).trim() === "--fix";
459
+ await runDoctor(fix, say);
445
460
  } else if (text.startsWith("/")) {
446
461
  say(A.red(`unknown command: ${text.split(" ")[0]}`) + A.dim(" (try /help)"));
447
462
  } else {
@@ -477,10 +492,13 @@ function parseArgs(argv) {
477
492
  for (let i = 0; i < argv.length; i++) {
478
493
  if (argv[i] === "--id") out.id = argv[++i];
479
494
  else if (argv[i] === "--dir") out.dir = argv[++i];
495
+ else if (argv[i] === "--doctor") out.doctor = true;
496
+ else if (argv[i] === "--fix") out.fix = true;
480
497
  else if (argv[i] === "-h" || argv[i] === "--help") {
481
498
  console.log("coord-chat — minimal TUI for agent-coord-mcp");
482
499
  console.log("usage: coord-chat [--id <name>] [--dir <path>]");
483
- console.log("at prompt: <text>=room /dm <id> <text> /list /quit");
500
+ console.log(" coord-chat --doctor [--fix] health-check coord state, print report, exit");
501
+ console.log("at prompt: <text>=room /dm <id> <text> /list /doctor /quit");
484
502
  process.exit(0);
485
503
  }
486
504
  }
@@ -624,6 +642,7 @@ function printHelp() {
624
642
  ["/prune [days]", "drop messages older than N days (default 7)"],
625
643
  ["/kick <agent>", "unregister an agent + kill their pusher"],
626
644
  ["/wipe-room", "truncate the current channel (destructive)"],
645
+ ["/doctor [--fix]", "health-check coord state (--fix auto-remediates)"],
627
646
  [A.dim("---"), ""],
628
647
  ["/help, /?", "this list"],
629
648
  ["/quit [msg], /exit", "unregister and leave"],
@@ -735,6 +754,44 @@ async function postStatus(status) {
735
754
  say(A.dim(`→ status posted: ${status}`));
736
755
  }
737
756
 
757
+ // Run the shared doctor health check and render its structured report through
758
+ // `emit` (say for the TUI, console for CLI). Delegates to the compiled MCP
759
+ // doctorTool so the output is the single source of truth — no reimplemented,
760
+ // drift-prone checks. Returns true when nothing is at error level. `emit` is
761
+ // injected so the same renderer serves both /doctor and `--doctor`.
762
+ async function runDoctor(fix, emit) {
763
+ let doctorTool;
764
+ try {
765
+ ({ doctorTool } = await import(new URL("../dist/tools.js", import.meta.url)));
766
+ } catch (e) {
767
+ emit(A.red(`/doctor unavailable: could not load the doctor tool (${e?.message ?? e}).`));
768
+ emit(A.dim(" This build may be missing dist/ — run `npm run build` in the package."));
769
+ return false;
770
+ }
771
+ const icon = { ok: A.green("✓"), warn: A.yellow("!"), error: A.red("✗") };
772
+ const res = await doctorTool({ fix });
773
+ emit(
774
+ A.bold("coord doctor") +
775
+ A.dim(` root=${res.root}`) +
776
+ (fix ? A.dim(" (--fix applied)") : ""),
777
+ );
778
+ for (const f of res.findings) {
779
+ emit(` ${icon[f.level] ?? "?"} ${A.bold(f.check)} ${f.detail}`);
780
+ for (const item of f.items ?? []) emit(A.dim(` - ${item}`));
781
+ }
782
+ for (const done of res.fixed ?? []) emit(A.green(` fixed: ${done}`));
783
+ const s = res.summary;
784
+ emit(
785
+ A.dim(" ") +
786
+ `${A.green(`${s.ok} ok`)} ${A.yellow(`${s.warn} warn`)} ${A.red(`${s.error} error`)}` +
787
+ (res.healthy ? A.green(" — healthy") : A.red(" — needs attention")),
788
+ );
789
+ if (!fix && res.findings.some((f) => f.fixable && f.level !== "ok")) {
790
+ emit(A.dim(" run /doctor --fix to auto-remediate fixable findings"));
791
+ }
792
+ return res.summary.error === 0;
793
+ }
794
+
738
795
  async function pruneOld(days) {
739
796
  const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
740
797
  let total = 0;
package/src/server.ts CHANGED
@@ -28,6 +28,8 @@ import {
28
28
  leaveRoomTool,
29
29
  listAgentsSchema,
30
30
  listAgentsTool,
31
+ pingSchema,
32
+ pingTool,
31
33
  listRoomsSchema,
32
34
  listRoomsTool,
33
35
  postStatusSchema,
@@ -36,6 +38,8 @@ import {
36
38
  pruneTool,
37
39
  readMessagesSchema,
38
40
  readMessagesTool,
41
+ retrieveRoomHistorySchema,
42
+ retrieveRoomHistoryTool,
39
43
  registerSchema,
40
44
  registerTool,
41
45
  renameAgentSchema,
@@ -166,6 +170,13 @@ function buildServer(initialBound?: string): McpServer {
166
170
  gate("agentId", heartbeatTool as (a: Record<string, unknown>) => Promise<unknown>),
167
171
  );
168
172
 
173
+ server.tool(
174
+ "ping",
175
+ "Liveness probe for another agent, answered entirely from server-side state (registry entry, transport marker, pusher pid, tmux pane) — it never touches the target's session, so a fleet-wide sweep costs zero model tokens on the targets. Returns alive (fresh heartbeat or live transport), reachable (a DM pushed now would land), granular checks, and latencyMs. Distinct from heartbeat, which is an agent refreshing its OWN activity timestamp. Pass echo:true (default off) to additionally drop a PING DM into the target's inbox — that wakes the target's model, so use it sparingly and only when you need an agent-level acknowledgement. 'from' is enforced against the session's bound identity.",
176
+ pingSchema,
177
+ gate("from", pingTool as (a: Record<string, unknown>) => Promise<unknown>),
178
+ );
179
+
169
180
  server.tool(
170
181
  "list_agents",
171
182
  "List all known agents and whether they appear online (heartbeat <5min).",
@@ -189,11 +200,18 @@ function buildServer(initialBound?: string): McpServer {
189
200
 
190
201
  server.tool(
191
202
  "read_messages",
192
- "Read new messages from inbox|room|status. For source='room', pass 'room' to read a specific channel (default 'general'). Room reads default to 50 messages per call — pass limit to override (max 500). Inbox and status drain fully by default. Advances the per-channel cursor unless peek=true.",
203
+ "Read new messages from inbox|room|status. For source='room', pass 'room' to read a specific channel (default 'general'). Room and status reads return the most recent 50 entries per call — pass limit to override (max 500). When the backlog exceeds the window, the older overflow is replaced by a compact `history` digest carrying a retrieval hash; call retrieve_room_history(hash) to expand it. Inbox drains fully by default. Advances the per-channel cursor unless peek=true.",
193
204
  readMessagesSchema,
194
205
  gate("agentId", readMessagesTool as (a: Record<string, unknown>) => Promise<unknown>),
195
206
  );
196
207
 
208
+ server.tool(
209
+ "retrieve_room_history",
210
+ "Expand a compressed channel-history digest returned by read_messages. Pass the `hash` from the `history` field; optionally pass `query` to return only matching messages (case-insensitive substring). Entries are scoped to the agent that produced them and expire after 30 minutes — if expired, re-read the channel with a higher limit instead.",
211
+ retrieveRoomHistorySchema,
212
+ gate("agentId", retrieveRoomHistoryTool as (a: Record<string, unknown>) => Promise<unknown>),
213
+ );
214
+
197
215
  server.tool(
198
216
  "post_status",
199
217
  "Append a status broadcast to the shared status stream.",
package/src/store.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { promises as fs, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
2
3
  import { homedir } from "node:os";
3
4
  import path from "node:path";
4
5
  import lockfile from "proper-lockfile";
@@ -23,6 +24,18 @@ export const LOG_DIR = path.join(ROOT, "logs");
23
24
  // `written-to-jsonl:true`, at zero added agent token cost.
24
25
  export const RECEIPTS_DIR = path.join(ROOT, "receipts");
25
26
 
27
+ // Reversible-history cache (CCR pattern, v0.9.x). When read_messages would
28
+ // flood an agent with a large channel backlog, the overflow (everything older
29
+ // than the recent window) is stashed here as one entry and replaced inline by
30
+ // a compact digest carrying a `hash`. The agent expands it on demand via
31
+ // retrieve_room_history(hash). Entries are content+scope addressed, TTL'd, and
32
+ // scoped to the (room, agent) they were produced for so a hash can't be
33
+ // replayed to read a channel the caller never read itself. This is a cache of
34
+ // data ALREADY present in rooms/<chan>.jsonl — not a new source of truth — so
35
+ // losing it (TTL/eviction) only costs the agent a re-read at a higher limit.
36
+ export const HISTORY_DIR = path.join(ROOT, "history");
37
+ export const HISTORY_TTL_MS = 30 * 60_000; // session-scale; mirrors headroom DEFAULT_CCR_TTL_SECONDS
38
+
26
39
  // Channels. The default channel `general` keeps using the legacy single-room
27
40
  // file (room.jsonl) + the flat `roomOffset` cursor key, so existing agents and
28
41
  // the notification hooks keep working with zero migration. Every other channel
@@ -40,7 +53,7 @@ export const DEFAULT_ROOM = "general";
40
53
  export const TOKENS_FILE = path.join(ROOT, "tokens.json");
41
54
 
42
55
  export function ensureDirs(): void {
43
- for (const d of [ROOT, INBOX_DIR, CURSOR_DIR, TRANSPORT_DIR, PID_DIR, LOG_DIR, ROOMS_DIR, RECEIPTS_DIR]) {
56
+ for (const d of [ROOT, INBOX_DIR, CURSOR_DIR, TRANSPORT_DIR, PID_DIR, LOG_DIR, ROOMS_DIR, RECEIPTS_DIR, HISTORY_DIR]) {
44
57
  if (!existsSync(d)) mkdirSync(d, { recursive: true });
45
58
  }
46
59
  for (const f of [ROOM_FILE, STATUS_FILE]) {
@@ -349,3 +362,96 @@ export async function fileSize(file: string): Promise<number> {
349
362
  const st = await fs.stat(file);
350
363
  return st.size;
351
364
  }
365
+
366
+ // ---------- reversible history cache (CCR) ----------
367
+
368
+ export type HistoryEntry<T = unknown> = {
369
+ hash: string;
370
+ room: string;
371
+ forAgent: string; // scope key — only this agent may retrieve (see HISTORY_DIR note)
372
+ createdTs: number;
373
+ messages: T[];
374
+ };
375
+
376
+ function historyFile(hash: string): string {
377
+ return path.join(HISTORY_DIR, `${sanitize(hash)}.json`);
378
+ }
379
+
380
+ // Content+scope address: same backlog read by two agents (or twice by one)
381
+ // yields distinct entries, and the hash can't be guessed for a (room, agent)
382
+ // pair the caller never read. 12 hex chars ≈ 48 bits — collision-safe at this
383
+ // volume, short enough to sit inline in the digest marker.
384
+ function historyHash(room: string, forAgent: string, messages: { ts: number }[]): string {
385
+ const first = messages[0]?.ts ?? 0;
386
+ const last = messages[messages.length - 1]?.ts ?? 0;
387
+ const sig = `${room}${forAgent}${first}${last}${messages.length}`;
388
+ return createHash("sha1").update(sig).digest("hex").slice(0, 12);
389
+ }
390
+
391
+ // Best-effort sweep of expired entries. Cheap (one readdir + stat per file) and
392
+ // only the data is a disposable cache, so we swallow all errors.
393
+ export async function pruneHistory(now = Date.now()): Promise<void> {
394
+ if (!existsSync(HISTORY_DIR)) return;
395
+ let names: string[];
396
+ try {
397
+ names = await fs.readdir(HISTORY_DIR);
398
+ } catch {
399
+ return;
400
+ }
401
+ await Promise.all(
402
+ names
403
+ .filter((n) => n.endsWith(".json"))
404
+ .map(async (n) => {
405
+ const f = path.join(HISTORY_DIR, n);
406
+ try {
407
+ const e = await readJsonNoLock<HistoryEntry | null>(f, null);
408
+ if (!e || now - e.createdTs > HISTORY_TTL_MS) await fs.unlink(f).catch(() => {});
409
+ } catch {
410
+ /* leave it; next sweep retries */
411
+ }
412
+ }),
413
+ );
414
+ }
415
+
416
+ // Stash a backlog slice and return its hash. Caller embeds the hash in the
417
+ // digest marker it returns to the agent.
418
+ export async function stashHistory<T extends { ts: number }>(
419
+ room: string,
420
+ forAgent: string,
421
+ messages: T[],
422
+ ): Promise<string> {
423
+ const hash = historyHash(room, forAgent, messages);
424
+ const entry: HistoryEntry<T> = { hash, room, forAgent, createdTs: Date.now(), messages };
425
+ await writeJson(historyFile(hash), entry);
426
+ void pruneHistory();
427
+ return hash;
428
+ }
429
+
430
+ export type RetrieveHistoryResult<T = unknown> =
431
+ | { ok: true; room: string; total: number; messages: T[] }
432
+ | { ok: false; reason: "not_found" | "expired" | "forbidden" };
433
+
434
+ // Expand a stashed backlog. Enforces the (forAgent) scope and TTL. `query`, if
435
+ // given, returns only entries whose serialized form contains the substring
436
+ // (case-insensitive) — the lossless analogue of headroom's BM25 search-within.
437
+ export async function retrieveHistory<T = unknown>(
438
+ hash: string,
439
+ forAgent: string,
440
+ query?: string,
441
+ ): Promise<RetrieveHistoryResult<T>> {
442
+ const f = historyFile(hash);
443
+ const entry = await readJson<HistoryEntry<T> | null>(f, null);
444
+ if (!entry) return { ok: false, reason: "not_found" };
445
+ if (Date.now() - entry.createdTs > HISTORY_TTL_MS) {
446
+ await deleteFile(f).catch(() => {});
447
+ return { ok: false, reason: "expired" };
448
+ }
449
+ if (entry.forAgent !== forAgent) return { ok: false, reason: "forbidden" };
450
+
451
+ let messages = entry.messages;
452
+ if (query && query.trim()) {
453
+ const q = query.toLowerCase();
454
+ messages = messages.filter((m) => JSON.stringify(m).toLowerCase().includes(q));
455
+ }
456
+ return { ok: true, room: entry.room, total: entry.messages.length, messages };
457
+ }
package/src/tools.ts CHANGED
@@ -39,6 +39,9 @@ import {
39
39
  roomFile,
40
40
  rotateAgentToken,
41
41
  setRoomMeta,
42
+ stashHistory,
43
+ retrieveHistory,
44
+ pruneHistory,
42
45
  transportFile,
43
46
  TRANSPORT_DIR,
44
47
  updateJson,
@@ -86,6 +89,10 @@ type Message = {
86
89
  // its operator. The tmux pushers inject these RAW (no banner/prefix) so the
87
90
  // TUI runs them as real slash commands; every other consumer ignores them.
88
91
  control?: boolean;
92
+ // Server-generated push-now override (e.g. the post-/clear identity
93
+ // reminder). Only server code can set this — send_message constructs the
94
+ // Message from fixed fields, so peers cannot smuggle it in.
95
+ urgent?: boolean;
89
96
  };
90
97
 
91
98
  type StatusEntry = {
@@ -312,6 +319,79 @@ function isPidAlive(pid: number): boolean {
312
319
  }
313
320
  }
314
321
 
322
+ // ---------- ping ----------
323
+
324
+ export const pingSchema = {
325
+ from: z.string().min(1),
326
+ to: z.string().min(1),
327
+ echo: z.boolean().optional(),
328
+ };
329
+
330
+ // Liveness probe answered entirely from server-side state — registry entry,
331
+ // transport marker, pusher pid, tmux pane. It never touches the target's
332
+ // session, so a fleet-wide sweep costs zero model tokens on the targets.
333
+ // Distinct from `heartbeat` (the target refreshing its own activity
334
+ // timestamp): ping is a third party asking "would a DM land right now?".
335
+ // echo=true is the one exception — it drops a PING DM into the target's inbox
336
+ // (normal delivery, so the target's model DOES wake); opt-in, default off.
337
+ export async function pingTool(args: { from: string; to: string; echo?: boolean }) {
338
+ const t0 = process.hrtime.bigint();
339
+ const now = Date.now();
340
+ const latencyMs = () => Math.round(Number(process.hrtime.bigint() - t0) / 1e3) / 1e3;
341
+
342
+ const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
343
+ const entry = reg[args.to];
344
+ if (!entry) {
345
+ return { ok: true, to: args.to, alive: false, reachable: false, reason: "unregistered", latencyMs: latencyMs() };
346
+ }
347
+
348
+ const marker = await readJson<TransportMarker | null>(transportFile(args.to), null);
349
+ const heartbeatAgeSec = Math.floor((now - entry.lastHeartbeat) / 1000);
350
+ const heartbeatFresh = now - entry.lastHeartbeat < STALE_MS;
351
+
352
+ let transportLive = false;
353
+ let paneAlive: boolean | undefined;
354
+ if (marker) {
355
+ transportLive = isMarkerLive(marker, reg, now);
356
+ if (transportLive && marker.transport === "tmux-push" && marker.tmuxTarget) {
357
+ // The pusher can outlive its pane (agent window closed) — probe the pane.
358
+ const probe = spawnSync("tmux", ["display-message", "-p", "-t", marker.tmuxTarget, "ok"]);
359
+ paneAlive = probe.status === 0;
360
+ }
361
+ }
362
+
363
+ const reachable = transportLive && paneAlive !== false;
364
+ const alive = reachable || heartbeatFresh;
365
+
366
+ let echoSent = false;
367
+ if (args.echo && alive) {
368
+ await sendMessageTool({
369
+ from: args.from,
370
+ to: args.to,
371
+ text: `PING: echo requested by ${args.from} — DM back if responsive.`,
372
+ });
373
+ echoSent = true;
374
+ }
375
+
376
+ return {
377
+ ok: true,
378
+ to: args.to,
379
+ alive,
380
+ reachable,
381
+ ...(alive ? {} : { reason: marker ? "transport-dead" : "heartbeat-stale" }),
382
+ checks: {
383
+ registered: true,
384
+ heartbeatFresh,
385
+ heartbeatAgeSec,
386
+ transport: marker?.transport ?? null,
387
+ transportLive,
388
+ ...(paneAlive !== undefined ? { paneAlive, tmuxTarget: marker?.tmuxTarget } : {}),
389
+ },
390
+ ...(args.echo ? { echoSent } : {}),
391
+ latencyMs: latencyMs(),
392
+ };
393
+ }
394
+
315
395
  // ---------- send_message ----------
316
396
 
317
397
  export const sendMessageSchema = {
@@ -455,6 +535,9 @@ function scheduleReminders(
455
535
  from,
456
536
  to: r,
457
537
  text: override ?? defaultReminderText(r),
538
+ // A just-cleared agent is contextless until this lands — it must
539
+ // push immediately, never queue behind the routine tier.
540
+ urgent: true,
458
541
  };
459
542
  await appendJsonl(inboxFile(r), reminder);
460
543
  } catch (e) {
@@ -643,40 +726,65 @@ export async function readMessagesTool(args: {
643
726
  const file = sourceFile(args.source, args.agentId, args.room);
644
727
  const all = await readJsonl<Message | StatusEntry>(file);
645
728
 
646
- let limited: (Message | StatusEntry)[] = [];
729
+ let entries: (Message | StatusEntry)[] = [];
647
730
  let totalNew = 0;
648
731
 
649
- // Room reads default to 50 messages to prevent agents flooding themselves
650
- // with full channel history on join. Inbox and status drain fully by default
651
- // since they are targeted/bounded by nature.
652
- const effectiveLimit = args.limit ?? (args.source === "room" ? 50 : undefined);
732
+ // Room and status reads default to 50 entries to prevent agents flooding
733
+ // themselves with full history on join — the status stream grows unbounded
734
+ // across the fleet. Inbox drains fully since it is targeted by nature.
735
+ const effectiveLimit = args.limit ?? (args.source === "inbox" ? undefined : 50);
653
736
 
654
737
  if (args.peek) {
655
738
  const cursor = await readJson<Cursor>(cursorFile(args.agentId), {});
656
739
  const startOffset = getOffset(cursor, args.source, args.room);
657
- let entries = all.slice(startOffset);
740
+ entries = all.slice(startOffset);
658
741
  if (args.sinceTs !== undefined) entries = entries.filter((e) => e.ts > args.sinceTs!);
659
742
  totalNew = entries.length;
660
- limited = effectiveLimit ? entries.slice(0, effectiveLimit) : entries;
661
743
  } else {
662
744
  await updateJson<Cursor>(cursorFile(args.agentId), {}, (current) => {
663
745
  const startOffset = getOffset(current, args.source, args.room);
664
- let entries = all.slice(startOffset);
665
- if (args.sinceTs !== undefined) entries = entries.filter((e) => e.ts > args.sinceTs!);
666
- totalNew = entries.length;
667
- limited = effectiveLimit ? entries.slice(0, effectiveLimit) : entries;
668
- if (limited.length > 0) setOffset(current, args.source, args.room, startOffset + limited.length);
746
+ let e = all.slice(startOffset);
747
+ if (args.sinceTs !== undefined) e = e.filter((x) => x.ts > args.sinceTs!);
748
+ totalNew = e.length;
749
+ entries = e;
750
+ // Advance past EVERYTHING we account for here (recent window + any
751
+ // overflow we stash below). The overflow is recoverable via the history
752
+ // hash, so it must not requeue for the next read — that would re-flood.
753
+ if (e.length > 0) setOffset(current, args.source, args.room, startOffset + e.length);
669
754
  return current;
670
755
  });
671
756
  }
672
757
 
758
+ // CCR overflow handling (room and status sources). When the backlog exceeds
759
+ // the window, return the RECENT slice raw and replace the older overflow with
760
+ // a compact digest carrying a retrieval hash. The agent expands it on demand
761
+ // via retrieve_room_history. Peek is side-effect-free, so it never stashes —
762
+ // it reports the count and tells the agent to do a real read to get a hash.
763
+ let recent = entries;
764
+ let history: { digest: string; hash?: string; older: number } | undefined;
765
+ if (args.source !== "inbox" && effectiveLimit && entries.length > effectiveLimit) {
766
+ const overflow = entries.slice(0, entries.length - effectiveLimit);
767
+ recent = entries.slice(entries.length - effectiveLimit);
768
+ const stashKey = args.source === "room" ? normalizeRoom(args.room) : "status";
769
+ if (args.peek) {
770
+ history = { digest: digestOverflow(overflow, undefined), older: overflow.length };
771
+ } else {
772
+ const hash = await stashHistory(stashKey, args.agentId, overflow);
773
+ history = { digest: digestOverflow(overflow, hash), hash, older: overflow.length };
774
+ }
775
+ } else if (effectiveLimit && entries.length > effectiveLimit) {
776
+ // Inbox keeps the legacy oldest-first chunking (no stash) — targeted
777
+ // messages must never be skipped over.
778
+ recent = entries.slice(0, effectiveLimit);
779
+ }
780
+
673
781
  // Drop the agent's own posts on shared channels — reading your own broadcast
674
782
  // back is never useful and confuses turn-based agents into self-replies.
675
783
  // Cursor has already advanced past them, so they won't reappear.
676
784
  const visible =
677
785
  args.source === "room" || args.source === "status"
678
- ? limited.filter((e) => entryAuthor(e) !== args.agentId)
679
- : limited;
786
+ ? recent.filter((e) => entryAuthor(e) !== args.agentId)
787
+ : recent;
680
788
 
681
789
  return {
682
790
  ok: true,
@@ -684,6 +792,64 @@ export async function readMessagesTool(args: {
684
792
  totalNew,
685
793
  returned: visible.length,
686
794
  room: args.source === "room" ? normalizeRoom(args.room) : undefined,
795
+ ...(history ? { history } : {}),
796
+ };
797
+ }
798
+
799
+ // Lossless summary of a stashed backlog slice: surfaces error/failure posts
800
+ // verbatim (the lines that usually matter most in a flood) and collapses the
801
+ // rest to counts. Mirrors headroom's content-aware digest, kept deliberately
802
+ // simple — the full originals are one retrieve_room_history call away.
803
+ function digestOverflow(over: (Message | StatusEntry)[], hash: string | undefined): string {
804
+ const authors = new Set(over.map(entryAuthor).filter(Boolean));
805
+ const errorRe = /\b(error|fatal|fail(ed|ure)?|panic|exception)\b/i;
806
+ const errors = over.filter((m) => errorRe.test(JSON.stringify(m)));
807
+ const first = over[0]?.ts;
808
+ const last = over[over.length - 1]?.ts;
809
+ const span =
810
+ first && last && last > first ? ` over ${Math.round((last - first) / 60000)}m` : "";
811
+ const parts = [
812
+ `[${over.length} earlier message${over.length === 1 ? "" : "s"} compressed`,
813
+ `${authors.size} agent${authors.size === 1 ? "" : "s"}${span}`,
814
+ ];
815
+ if (errors.length) parts.push(`${errors.length} error post${errors.length === 1 ? "" : "s"}`);
816
+ const head = parts.join(", ");
817
+ const tail = hash
818
+ ? ` hash=${hash}] — call retrieve_room_history(hash="${hash}") to expand`
819
+ : `] — read without peek to get an expandable hash`;
820
+ return head + tail;
821
+ }
822
+
823
+ // ---------- retrieve_room_history ----------
824
+
825
+ export const retrieveRoomHistorySchema = {
826
+ agentId: z.string().min(1),
827
+ hash: z.string().min(1),
828
+ query: z.string().optional(),
829
+ };
830
+
831
+ export async function retrieveRoomHistoryTool(args: {
832
+ agentId: string;
833
+ hash: string;
834
+ query?: string;
835
+ }) {
836
+ const res = await retrieveHistory<Message | StatusEntry>(args.hash, args.agentId, args.query);
837
+ if (!res.ok) {
838
+ const reason =
839
+ res.reason === "expired"
840
+ ? "That history entry has expired (30m TTL). Re-read the channel with a higher limit to fetch it again."
841
+ : res.reason === "forbidden"
842
+ ? "That history hash was produced for a different agent and cannot be retrieved by you."
843
+ : "No history entry for that hash. It may have expired or never existed.";
844
+ return { ok: false, reason: res.reason, message: reason };
845
+ }
846
+ return {
847
+ ok: true,
848
+ room: res.room,
849
+ hash: args.hash,
850
+ total: res.total,
851
+ returned: res.messages.length,
852
+ messages: res.messages,
687
853
  };
688
854
  }
689
855
 
@@ -950,6 +1116,10 @@ export async function pruneTool(args: {
950
1116
  receiptsRemoved += r.removed;
951
1117
  }
952
1118
 
1119
+ // Sweep expired reversible-history entries (TTL'd cache; also self-prunes on
1120
+ // every read, so this just catches entries on a server with few reads).
1121
+ await pruneHistory();
1122
+
953
1123
  return {
954
1124
  dryRun: false,
955
1125
  cutoff,