@cotal-ai/connector-core 0.24.0 → 0.26.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.
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import type { MeshAgent, InboxItem } from "./agent.js";
2
+ import { type MeshAgent, type InboxItem } from "./agent.js";
3
3
  import { type AgentConfig } from "./config.js";
4
4
  /** What a Cotal tool returns: text to show the model, flagged on failure. MCP wraps it in
5
5
  * `content`; the OpenCode plugin returns the string. */
@@ -56,6 +56,66 @@ export declare const NO_TOOL_ARGS: CotalToolInput;
56
56
  export declare function refuseAnyArgs(name: string, args: unknown): string | undefined;
57
57
  /** "name/role" (or just "name") for a message's sender. */
58
58
  export declare function fmtFrom(i: InboxItem): string;
59
+ /**
60
+ * HOW MUCH OF THE INBOX ONE RESPONSE MAY CARRY, in characters.
61
+ *
62
+ * A read is destructive, and the payload is largest exactly where recovery happens: reconnecting
63
+ * brings a channel-history replay with it. Measured on a real reconnect: 200 messages, 3,490 lines,
64
+ * 451 KB, an order of magnitude past what a host will hand to a model, so the call both CONSUMED
65
+ * its contents and failed to deliver them. Whatever the host's own cap is, a response above this
66
+ * bound is a response the caller may never see, so it is never a response we may clear.
67
+ *
68
+ * The budget is deliberately far below the smallest plausible host cap: overshooting costs a lost
69
+ * message, undershooting costs one more call, and the response says so in its own text.
70
+ */
71
+ export declare const INBOX_WINDOW_CHARS = 48000;
72
+ /** What one response carries, what it leaves buffered, and the exact text that says so. */
73
+ export interface InboxResponse {
74
+ /** The reply, already assembled and already inside the budget. Nothing may be appended to it. */
75
+ text: string;
76
+ /** What that text actually carries. Only these may be cleared. */
77
+ shown: InboxItem[];
78
+ /** Everything it does not carry. */
79
+ held: InboxItem[];
80
+ /** Ids no response could ever carry, whatever the window held at the time. */
81
+ stuck: ReadonlySet<string>;
82
+ }
83
+ /**
84
+ * Build one inbox response, and make it impossible for the response to outgrow its own budget.
85
+ *
86
+ * THE HISTORY THIS SHAPE COMES FROM, because it explains why it assembles rather than estimates.
87
+ * Three separate escapes were found here, each the same class one level further out: the items were
88
+ * budgeted but an oversized one was shown alone anyway; the items were budgeted but the head line
89
+ * and the held-note were not; the head and note were budgeted but the focus branch's recall warning
90
+ * was appended afterwards. Every one of them was a writer to the response body that the arithmetic
91
+ * did not know about. So the arithmetic is gone: this function ASSEMBLES the whole reply, measures
92
+ * what it actually built, and drops trailing items until the real string fits. A future writer is
93
+ * inside the bound by construction, because the bound is checked on the finished text.
94
+ *
95
+ * The order it drops in is the second rule: **mail before replay.** Direct messages and anycast
96
+ * requests are first-party traffic with a sender waiting; replayed channel history is a backfill the
97
+ * channel still holds. What gets dropped first is what someone else can still re-serve.
98
+ *
99
+ * And the third: **what does not fit is not cut off the end of the text.** It stays in the buffer,
100
+ * unacked, named in {@link heldNote}. Only `shown` may be cleared, which is #603 itself.
101
+ */
102
+ export declare function renderInbox(opts: {
103
+ items: readonly InboxItem[];
104
+ /** The line above the messages, given whatever ends up being shown. */
105
+ head: (shown: readonly InboxItem[]) => string;
106
+ peek?: boolean;
107
+ /** A rider the response must carry, such as the focus branch's recall warning. */
108
+ warning?: string;
109
+ budget?: number;
110
+ /**
111
+ * Ids of a lane that must be delivered IN ORDER, with no gaps: focus recall, which a caller walks
112
+ * with a single mark rather than an acknowledgement per item. Stepping over one of these to fit a
113
+ * later one would either strand it, if the mark then passes it, or re-serve everything after it,
114
+ * if the mark stops short. The buffered lane has no such constraint, because each of its items is
115
+ * acked by id.
116
+ */
117
+ strictIds?: ReadonlySet<string>;
118
+ }): InboxResponse;
59
119
  /** Routing context for a `<channel …>` tag. Keys must be [A-Za-z0-9_] (others are dropped). */
60
120
  export declare function channelMeta(i: InboxItem): Record<string, string>;
61
121
  /** The full Cotal tool set for a given config. Renderers iterate this; `source` names the
@@ -1 +1 @@
1
- {"version":3,"file":"tool-specs.d.ts","sourceRoot":"","sources":["../src/tool-specs.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvD,OAAO,EAAqC,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAIlF;yDACyD;AACzD,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAqBD;;;;;;;sGAOsG;AACtG,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC;AAExD,0DAA0D;AAC1D,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB;;;;8FAI0F;IAC1F,MAAM,EAAE,cAAc,CAAC;IACvB,GAAG,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,GAAG,GAAG,OAAO,CAAC,UAAU,CAAC,GAAG,UAAU,CAAC;CACzF;AAUD;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAYzF;AAED;;;;;;;;;GASG;AACH;mGACmG;AACnG,eAAO,MAAM,YAAY,EAAE,cAAmC,CAAC;AAE/D,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAG7E;AAeD,2DAA2D;AAC3D,wBAAgB,OAAO,CAAC,CAAC,EAAE,SAAS,GAAG,MAAM,CAE5C;AA2CD,+FAA+F;AAC/F,wBAAgB,WAAW,CAAC,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAQhE;AAED;+DAC+D;AAC/D,wBAAgB,cAAc,CAAC,MAAM,EAAE,WAAW,EAAE,MAAM,SAAc,GAAG,aAAa,EAAE,CA6mBzF"}
1
+ {"version":3,"file":"tool-specs.d.ts","sourceRoot":"","sources":["../src/tool-specs.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAmB,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,YAAY,CAAC;AAC7E,OAAO,EAAqC,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAIlF;yDACyD;AACzD,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAqBD;;;;;;;sGAOsG;AACtG,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC;AAExD,0DAA0D;AAC1D,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB;;;;8FAI0F;IAC1F,MAAM,EAAE,cAAc,CAAC;IACvB,GAAG,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,GAAG,GAAG,OAAO,CAAC,UAAU,CAAC,GAAG,UAAU,CAAC;CACzF;AAUD;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAYzF;AAED;;;;;;;;;GASG;AACH;mGACmG;AACnG,eAAO,MAAM,YAAY,EAAE,cAAmC,CAAC;AAE/D,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAG7E;AAeD,2DAA2D;AAC3D,wBAAgB,OAAO,CAAC,CAAC,EAAE,SAAS,GAAG,MAAM,CAG5C;AAeD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,kBAAkB,QAAS,CAAC;AAEzC,2FAA2F;AAC3F,MAAM,WAAW,aAAa;IAC5B,iGAAiG;IACjG,IAAI,EAAE,MAAM,CAAC;IACb,kEAAkE;IAClE,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,oCAAoC;IACpC,IAAI,EAAE,SAAS,EAAE,CAAC;IAClB,8EAA8E;IAC9E,KAAK,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE;IAChC,KAAK,EAAE,SAAS,SAAS,EAAE,CAAC;IAC5B,uEAAuE;IACvE,IAAI,EAAE,CAAC,KAAK,EAAE,SAAS,SAAS,EAAE,KAAK,MAAM,CAAC;IAC9C,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,kFAAkF;IAClF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;CACjC,GAAG,aAAa,CAyFhB;AA0LD,+FAA+F;AAC/F,wBAAgB,WAAW,CAAC,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAQhE;AAED;+DAC+D;AAC/D,wBAAgB,cAAc,CAAC,MAAM,EAAE,WAAW,EAAE,MAAM,SAAc,GAAG,aAAa,EAAE,CA4rBzF"}
@@ -10,6 +10,7 @@
10
10
  import { execFileSync } from "node:child_process";
11
11
  import { z } from "zod";
12
12
  import { isConcreteChannel, channelInAllow, AmbiguousPeerError, isPermissionDenied } from "@cotal-ai/core";
13
+ import { afterRecallMark } from "./agent.js";
13
14
  import { FEEDBACK_URL, PUBLIC_FEEDBACK_URL } from "./config.js";
14
15
  import { buildOrientation, renderOrientation } from "./orientation.js";
15
16
  import { runDocs } from "./docs.js";
@@ -79,16 +80,276 @@ const ATTENTION_DESC = {
79
80
  };
80
81
  /** "name/role" (or just "name") for a message's sender. */
81
82
  export function fmtFrom(i) {
82
- return i.fromRole ? `${i.fromName}/${i.fromRole}` : i.fromName;
83
+ const name = attributionSafe(i.fromName);
84
+ return i.fromRole ? `${name}/${attributionSafe(i.fromRole)}` : name;
83
85
  }
86
+ /**
87
+ * A PEER NAMES ITSELF, so its name is data and never framing.
88
+ *
89
+ * Attribution is rendered inside brackets, and every surface that carries it (this tool's reply, the
90
+ * connectors' wake hints) puts it on a line of its own. A name holding a closing bracket or a newline
91
+ * therefore ends the attribution early and starts writing the surface's own syntax: measured, a peer
92
+ * calling itself `Ada] hi [DM from Boss` rendered as a message from Ada followed by a second one from
93
+ * Boss. Neither character survives into a rendered name.
94
+ */
95
+ function attributionSafe(s) {
96
+ return s.replace(/[\r\n\v\f\u0085\u2028\u2029\]]+/g, " ");
97
+ }
98
+ /**
99
+ * HOW MUCH OF THE INBOX ONE RESPONSE MAY CARRY, in characters.
100
+ *
101
+ * A read is destructive, and the payload is largest exactly where recovery happens: reconnecting
102
+ * brings a channel-history replay with it. Measured on a real reconnect: 200 messages, 3,490 lines,
103
+ * 451 KB, an order of magnitude past what a host will hand to a model, so the call both CONSUMED
104
+ * its contents and failed to deliver them. Whatever the host's own cap is, a response above this
105
+ * bound is a response the caller may never see, so it is never a response we may clear.
106
+ *
107
+ * The budget is deliberately far below the smallest plausible host cap: overshooting costs a lost
108
+ * message, undershooting costs one more call, and the response says so in its own text.
109
+ */
110
+ export const INBOX_WINDOW_CHARS = 48_000;
111
+ /**
112
+ * Build one inbox response, and make it impossible for the response to outgrow its own budget.
113
+ *
114
+ * THE HISTORY THIS SHAPE COMES FROM, because it explains why it assembles rather than estimates.
115
+ * Three separate escapes were found here, each the same class one level further out: the items were
116
+ * budgeted but an oversized one was shown alone anyway; the items were budgeted but the head line
117
+ * and the held-note were not; the head and note were budgeted but the focus branch's recall warning
118
+ * was appended afterwards. Every one of them was a writer to the response body that the arithmetic
119
+ * did not know about. So the arithmetic is gone: this function ASSEMBLES the whole reply, measures
120
+ * what it actually built, and drops trailing items until the real string fits. A future writer is
121
+ * inside the bound by construction, because the bound is checked on the finished text.
122
+ *
123
+ * The order it drops in is the second rule: **mail before replay.** Direct messages and anycast
124
+ * requests are first-party traffic with a sender waiting; replayed channel history is a backfill the
125
+ * channel still holds. What gets dropped first is what someone else can still re-serve.
126
+ *
127
+ * And the third: **what does not fit is not cut off the end of the text.** It stays in the buffer,
128
+ * unacked, named in {@link heldNote}. Only `shown` may be cleared, which is #603 itself.
129
+ */
130
+ export function renderInbox(opts) {
131
+ const budget = opts.budget ?? INBOX_WINDOW_CHARS;
132
+ const peek = opts.peek ?? false;
133
+ const warning = opts.warning ?? "";
134
+ const rank = (i) => (i.kind !== "channel" ? 0 : i.historical ? 2 : 1);
135
+ const ordered = [...opts.items].sort((a, b) => rank(a) - rank(b)); // stable: receive order within a rank
136
+ // WHY THIS AGREES WITH THE ASSEMBLED REPLY, and what would break the agreement. Deliverability is
137
+ // decided here and delivery is decided by `assemble`, and the two can only agree because both
138
+ // measure the item through the SAME `fmtItem`. That is what makes the agreement invariant to how an
139
+ // item renders: the continuation indent that keeps a peer from forging a line raised both sides by
140
+ // the same characters, so nothing here had to change for it. Fork the rendering, and this
141
+ // classification starts calling a message deliverable that the reply cannot carry.
142
+ //
143
+ // Stuck means "no response could carry this", so it is measured against the friendliest response
144
+ // there is: this item alone, its head, and any rider, with no held-note at all.
145
+ const stuck = new Set(ordered
146
+ .filter((i) => opts.head([i]).length + 1 + itemCost(i) + (warning ? warning.length + 2 : 0) > budget)
147
+ .map((i) => i.id));
148
+ const assemble = (shown, held, tier) => {
149
+ const note = heldNote(held, peek, stuck, tier);
150
+ const parts = [];
151
+ if (shown.length)
152
+ parts.push(`${opts.head(shown)}\n${shown.map(fmtItem).join("\n")}${note}`);
153
+ else if (held.length)
154
+ parts.push(`Nothing could be delivered in this response.${note}`);
155
+ if (warning)
156
+ parts.push(warning);
157
+ return parts.join("\n\n");
158
+ };
159
+ // THE NOTE YIELDS BEFORE THE LAST MESSAGE DOES, in this order: names, then counts, then nothing.
160
+ // Measured before this rule: a 47,775-character direct message that renders alone at 47,823 was
161
+ // never delivered at all while a 60,000-character message sat behind it, because the note NAMING
162
+ // the undeliverable one pushed the pair over the window and the trim gave back the deliverable
163
+ // message rather than the description of the other. Three calls, byte-identical at 396 characters,
164
+ // nothing acked, every one of them saying to call again for the next batch.
165
+ const fit = (shown, held) => {
166
+ for (const tier of NOTE_TIERS) {
167
+ const text = assemble(shown, held, tier);
168
+ if (text.length <= budget)
169
+ return text;
170
+ }
171
+ return assemble(shown, held, NOTE_TIERS[NOTE_TIERS.length - 1]);
172
+ };
173
+ // Fill from a cheap estimate first, SKIPPING what will not fit rather than stopping at it: one
174
+ // message too large for any response must not block the mail behind it. Then assemble for real
175
+ // and give back trailing items until the finished string fits, which is the part no future writer
176
+ // to the response body can slip past.
177
+ const strictIds = opts.strictIds ?? new Set();
178
+ const shown = [];
179
+ let used = 0;
180
+ let strictGap = false; // the in-order lane stops at its first gap; the free lane steps over its own
181
+ for (const i of ordered) {
182
+ const strict = strictIds.has(i.id);
183
+ if (strict && strictGap)
184
+ continue;
185
+ const cost = itemCost(i);
186
+ if (used + cost > budget) {
187
+ // A message nothing could ever carry is not a gap: it will never become deliverable, so the
188
+ // walk steps over it and the note says so. Anything else IS a gap, and the ordered lane waits.
189
+ if (strict && !stuck.has(i.id))
190
+ strictGap = true;
191
+ continue;
192
+ }
193
+ shown.push(i);
194
+ used += cost;
195
+ }
196
+ const heldOf = () => {
197
+ const ids = new Set(shown.map((i) => i.id));
198
+ return ordered.filter((i) => !ids.has(i.id));
199
+ };
200
+ let held = heldOf();
201
+ let text = assemble(shown, held, "full");
202
+ while (text.length > budget && shown.length) {
203
+ // While another message still rides in the response, the full note is worth an item: the caller
204
+ // learns WHICH mail is undeliverable, and the item given back arrives on the next call. Down to
205
+ // the last message that trade reverses, because giving THAT one back delivers nothing at all and
206
+ // the next call rebuilds the same reply forever, so the note yields instead.
207
+ if (shown.length === 1) {
208
+ const yielded = fit(shown, held);
209
+ if (yielded.length <= budget) {
210
+ text = yielded;
211
+ break;
212
+ }
213
+ }
214
+ shown.pop();
215
+ held = heldOf();
216
+ text = assemble(shown, held, "full");
217
+ }
218
+ return { text, shown, held, stuck };
219
+ }
220
+ const NOTE_TIERS = ["full", "compact", "none"];
221
+ /** What one rendered item costs a response: its own text plus the newline that joins it. */
222
+ function itemCost(i) {
223
+ return fmtItem(i).length + 1;
224
+ }
225
+ /**
226
+ * The tail that keeps a windowed response honest: what is still there, and that it was not lost.
227
+ *
228
+ * TWO KINDS OF HELD, because they are not the same promise. Most held mail is waiting its turn and
229
+ * a later call delivers it. A message larger than one whole response is not waiting for anything:
230
+ * calling again will never produce it, and saying "call again for the next batch" over it would be
231
+ * a queue that looks like it is moving when it is not.
232
+ *
233
+ * THE NOTE IS BOUNDED. It names at most {@link NAMED_STUCK} of the stuck messages and counts the
234
+ * rest, and it truncates a sender's name, because a steady stream of oversized mail would otherwise
235
+ * fill every reply with metadata about mail it cannot carry, which is the same overflow one layer up.
236
+ */
237
+ function heldNote(held, peek = false, stuckIds = new Set(), tier = "full") {
238
+ if (!held.length || tier === "none")
239
+ return "";
240
+ const stuck = held.filter((i) => stuckIds.has(i.id));
241
+ const waiting = held.length - stuck.length;
242
+ if (tier === "compact") {
243
+ const bits = [];
244
+ if (waiting)
245
+ bits.push(`${waiting} more message${waiting === 1 ? "" : "s"} held`);
246
+ if (stuck.length)
247
+ bits.push(`${stuck.length} too large for any response to carry`);
248
+ const next = waiting
249
+ ? peek
250
+ ? " A peek clears nothing, so read without peek to take this window."
251
+ : " Call cotal_inbox again for the next batch."
252
+ : "";
253
+ return `\n\n… ${bits.join(", ")}. Nothing held was cleared.${next}`;
254
+ }
255
+ const parts = [];
256
+ if (waiting) {
257
+ const dms = held.filter((i) => i.kind !== "channel" && !stuckIds.has(i.id)).length;
258
+ // Under peek nothing is cleared, so the next call returns THIS window again. Telling a peeking
259
+ // caller to call again for the next batch is a promise the read cannot keep, and an obedient
260
+ // caller loops on it forever.
261
+ const next = peek
262
+ ? "A peek clears nothing, so calling again returns this same window; read without peek to take it and see the next."
263
+ : "Call cotal_inbox again for the next batch.";
264
+ parts.push(`${waiting} more message${waiting === 1 ? "" : "s"} held (${dms} direct). This response was capped at the receivable window, and nothing held was cleared. ${next}`);
265
+ }
266
+ if (stuck.length) {
267
+ const named = stuck
268
+ .slice(0, NAMED_STUCK)
269
+ .map((i) => `${fmtFrom(i).slice(0, 40)} (${itemCost(i).toLocaleString("en-US")} chars)`)
270
+ .join(", ");
271
+ const rest = stuck.length - Math.min(NAMED_STUCK, stuck.length);
272
+ parts.push(`${stuck.length} message${stuck.length === 1 ? " is" : "s are"} larger than one response can carry and cannot be delivered by this tool at all: ${named}${rest ? `, and ${rest} more` : ""}. ${stuck.length === 1 ? "It stays" : "They stay"} buffered and uncleared, and calling again will not produce ${stuck.length === 1 ? "it" : "them"}.`);
273
+ }
274
+ return `\n\n… ${parts.join(" ")}`;
275
+ }
276
+ /**
277
+ * The recall warning, bounded and budgeted like every other part of a response.
278
+ *
279
+ * It used to be appended AFTER the window had been filled, so its length rode outside the bound:
280
+ * measured at a 49,598-character response, over the cap, with twenty already-acked messages inside
281
+ * it. A caller with many silenced or expired channels is exactly the caller who gets a long list, so
282
+ * the list itself is capped and counted rather than trusted to stay short.
283
+ */
284
+ function droppedNote(channels) {
285
+ if (!channels.length)
286
+ return "";
287
+ const named = channels.slice(0, NAMED_DROPPED).map((c) => `#${attributionSafe(c).slice(0, 40)}`).join(", ");
288
+ const rest = channels.length - Math.min(NAMED_DROPPED, channels.length);
289
+ return `⚠ Some earlier chatter could not be recalled completely on ${named}${rest ? `, and ${rest} more channel${rest === 1 ? "" : "s"}` : ""} (retention or local safety bounds were reached).`;
290
+ }
291
+ /** How many channels the recall warning names before it starts counting them instead. */
292
+ const NAMED_DROPPED = 5;
293
+ /** Every note in a reply starts at column zero, so any peer-controlled text it names goes through
294
+ * {@link attributionSafe} first. A warning is not a lesser surface than a message line: it is the
295
+ * part of the reply a caller is most likely to read as the tool speaking. */
296
+ /** How many senders the future-stamp note names before it starts counting them instead. */
297
+ const NAMED_AHEAD = 3;
298
+ /** Say that recall items were withheld because this session will not take responsibility for
299
+ * remembering them, and name who sent them, so a peer spending that bound is visible rather than
300
+ * silent. Not a drop: nothing was cleared and the stream still holds them. */
301
+ function aheadNote(items) {
302
+ if (!items.length)
303
+ return "";
304
+ const senders = [...new Set(items.map((i) => attributionSafe(i.fromName).slice(0, 40)))];
305
+ const named = senders.slice(0, NAMED_AHEAD).join(", ");
306
+ const rest = senders.length - Math.min(NAMED_AHEAD, senders.length);
307
+ const one = items.length === 1;
308
+ return `⚠ ${items.length} recalled message${one ? "" : "s"} from ${named}${rest ? `, and ${rest} more sender${rest === 1 ? "" : "s"}` : ""} ${one ? "is" : "are"} stamped ahead of this session's clock, more than it will hold a place for, so ${one ? "it is" : "they are"} not being handed over. Nothing was cleared.`;
309
+ }
310
+ /** How many oversized messages the note names before it starts counting them instead. */
311
+ const NAMED_STUCK = 3;
84
312
  function fmtItem(i) {
85
313
  const h = i.historical ? "(history) " : ""; // backfilled on join — pre-dates you, not live
314
+ const body = `${h}${fmtBody(i.text)}`;
86
315
  if (i.kind === "dm")
87
- return `[DM from ${fmtFrom(i)}] ${h}${i.text}`;
316
+ return `[DM from ${fmtFrom(i)}] ${body}`;
317
+ // The sender is not the only peer-controlled field inside these brackets. `toService` is written
318
+ // by the publisher and is not checked against the subject it arrived on, and a channel label is
319
+ // rewritten by the subject token on the official paths but not on every path that can reach this
320
+ // renderer. Both are neutralized HERE so the rule holds without depending on which upstream path
321
+ // validated what.
88
322
  if (i.kind === "anycast")
89
- return `[@${i.service} from ${fmtFrom(i)}] ${h}${i.text}`;
90
- return `[#${i.channel}${i.mentionsMe ? " @you" : ""} ${fmtFrom(i)}] ${h}${i.text}`;
323
+ return `[@${attributionSafe(i.service ?? "")} from ${fmtFrom(i)}] ${body}`;
324
+ return `[#${attributionSafe(i.channel ?? "")}${i.mentionsMe ? " @you" : ""} ${fmtFrom(i)}] ${body}`;
91
325
  }
326
+ /**
327
+ * A LINE THAT BEGINS AT COLUMN ZERO IS WRITTEN BY THIS TOOL, NEVER BY A PEER.
328
+ *
329
+ * The reply is structured: a head line, one line per message with its sender in brackets, then the
330
+ * held-note and any warning. All of it is assembled from text a peer controls, so a message carrying
331
+ * newlines was writing that structure itself. Measured before this rule, one message forged a whole
332
+ * second message line attributed to another named peer, the held-note including its call-again
333
+ * promise, and the recall warning, in a reply with nothing to tell the forgery from the frame.
334
+ *
335
+ * One message is one line plus indented continuations. Indentation is not decoration here; it is the
336
+ * only thing that separates what the tool said from what a peer said it said.
337
+ */
338
+ function fmtBody(text) {
339
+ return text.replace(LINE_BREAK, "\n ");
340
+ }
341
+ /**
342
+ * What counts as a line break, which is more than what JavaScript splits on.
343
+ *
344
+ * Measured through the host frame a model is handed (an MCP text content part, stringified and
345
+ * parsed back): U+2028, U+2029 and U+0085 survive JSON transport intact, so a message carrying one
346
+ * of them put an unindented attribution line into the bytes the model receives. A JavaScript split
347
+ * on a newline does not see a line there and neither does `wc -l`, but a Unicode-aware splitter
348
+ * does, and the rule this serves is stated absolutely: a line at column zero is written by this
349
+ * tool. A rule whose truth depends on which splitter the consumer happens to use is not that rule,
350
+ * so the class is every code point a line splitter may honour, not the two this file used to know.
351
+ */
352
+ const LINE_BREAK = /\r\n?|[\n\v\f\u0085\u2028\u2029]/g;
92
353
  /** Render a channel's registry text as ATTRIBUTED, ADVISORY data — never as instructions to
93
354
  * obey. The registry is privileged-write but still untrusted from the model's seat (a write
94
355
  * reaches every joiner's context), so the fence — advisory framing plus the caveat travelling
@@ -241,45 +502,131 @@ export function cotalToolSpecs(config, source = "connector") {
241
502
  {
242
503
  name: "cotal_inbox",
243
504
  title: "Cotal: read incoming messages",
244
- description: "Read messages other agents have sent you since you last checked: channel broadcasts, direct messages, and role requests. Clears them unless peek is true. In focus mode it also pulls back the channel chatter held since you entered focus.",
505
+ description: "Read messages other agents have sent you since you last checked: channel broadcasts, direct messages, and role requests. It clears ONLY what it actually returns to you (nothing at all when peek is true), and one call carries at most a receivable window: direct messages and role requests first, then channel traffic, with replayed history last. Anything that does not fit stays buffered and is named in the reply, so call again for the next batch. A single message larger than one whole response is never consumed either: it is named with its sender and size and stays buffered, since delivering it is impossible and clearing it would lose it. In focus mode it also pulls back the channel chatter held since you entered focus.",
245
506
  schema: {
246
507
  peek: z.boolean().optional().describe("If true, show messages without clearing them."),
247
508
  },
248
509
  async run(agent, _config, { peek, scope }) {
249
510
  const inboxScope = scope ?? "all";
250
- const live = peek ? agent.peekInbox(inboxScope) : agent.drainInbox(undefined, inboxScope);
511
+ // SELECT, RENDER, THEN CLEAR EXACTLY WHAT WENT OUT (#603). The old order drained the whole
512
+ // scope up front, so a payload too large for the host to deliver had already been marked
513
+ // read, and a reconnect replay is both the largest payload and the one most likely to have
514
+ // a real DM inside it. This READ acks nothing outside the window it returned, on any path.
515
+ // It is not the only acker: the inbox's own overflow valve acks what it evicts, so an item
516
+ // that arrives while this call is awaiting recall can still be evicted and lost. That is the
517
+ // buffer's documented bounded local loss (see MeshAgent.buffer), unchanged by this path.
518
+ const buffered = agent.peekInbox(inboxScope);
251
519
  const automaticPending = scope ? agent.inboxCount("automatic") : 0;
252
520
  if (agent.attention !== "focus") {
253
- if (!live.length)
521
+ const { text, shown, held } = renderInbox({
522
+ items: buffered,
523
+ peek,
524
+ head: (s) => scope
525
+ ? `${s.length} pull-only message${s.length === 1 ? "" : "s"} (cleared; automatic traffic remains connector-managed):`
526
+ : `${s.length} message${s.length === 1 ? "" : "s"}${peek ? " (peek: nothing cleared)" : ""}:`,
527
+ });
528
+ if (!buffered.length)
254
529
  return ok(scope
255
530
  ? `No pull-only messages.${automaticPending ? ` ${automaticPending} connector-managed automatic message${automaticPending === 1 ? " is" : "s are"} still queued.` : ""}`
256
- : "Inbox empty no new messages.");
257
- const head = scope
258
- ? `${live.length} pull-only message${live.length === 1 ? "" : "s"} (cleared; automatic traffic remains connector-managed):`
259
- : `${live.length} message${live.length === 1 ? "" : "s"}${peek ? " (peek not cleared)" : ""}:`;
260
- return ok(`${head}\n${live.map(fmtItem).join("\n")}`);
531
+ : "Inbox empty, no new messages.");
532
+ // The response exists before anything is acked: an ack is a claim that these messages were
533
+ // handed over, so nothing may be cleared while the handing-over is still hypothetical. And
534
+ // it is the ASSEMBLED response that decides, so what is acked is what a caller was handed.
535
+ if (!peek)
536
+ agent.drainInboxIds(shown.map((i) => i.id));
537
+ void held;
538
+ return ok(text);
261
539
  }
262
540
  // Focus: the live buffer holds only DMs/anycast; the channel ambient + @mentions were
263
541
  // acked-and-dropped at ingest, so pull them back from the channel stream here (replay-gated,
264
- // "since you entered focus"). Recall is read-only peek only affects the live buffer drain.
542
+ // "since you entered focus"). Recall is read-only, so peek only affects the live buffer.
265
543
  const recall = await agent.recallAmbient();
266
- const all = [...live, ...recall.items];
267
- if (!all.length && !recall.droppedChannels.length)
544
+ // RECALL HAS TO ADVANCE, or windowing it starves it. Recall is re-derived from an unchanged
545
+ // frontier on every call, so showing its first window and stopping there returned the same
546
+ // prefix forever while the reply promised a next batch: measured as three identical replies
547
+ // where fifteen of thirty messages never appeared. The cursor is this session's own mark of
548
+ // how far it has read, and it moves only when a call actually delivered them.
549
+ // A SENDER'S CLOCK DOES NOT GET TO MOVE THIS SESSION'S MARK. `ts` is stamped by the sending
550
+ // endpoint, so one peer running ahead, or one peer writing whatever it likes, otherwise parks
551
+ // the mark in the future and every ordinary message after it is filtered out of recall for the
552
+ // rest of the session, under a reply saying there is no chatter. So the walk splits: items at
553
+ // or behind the clock are ordered by timestamp and move the mark, and items ahead of it are
554
+ // handed over once, tracked by id, and never move it. The ahead lane needs no gap rule for the
555
+ // same reason it needs no mark, since each of its items is accounted for on its own.
556
+ const byTsThenId = (a, b) => a.ts !== b.ts ? a.ts - b.ts : a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
557
+ const clocked = [];
558
+ const aheadFresh = [];
559
+ const aheadWithheld = [];
560
+ let aheadRoom = agent.recallAheadRoom();
561
+ for (const i of recall.items) {
562
+ if (!agent.recallAhead(i)) {
563
+ // AN ITEM CAN CROSS BETWEEN THE LANES, because the local clock eventually passes a stamp
564
+ // that was ahead of it. It was handed over by id while it was ahead, and the mark never
565
+ // moved for it, so the mark alone would offer it a second time the moment it decays into
566
+ // this lane. The record it was handed over under is what closes that.
567
+ if (agent.recallAheadSeen(i.id))
568
+ continue;
569
+ if (afterRecallMark({ ts: i.ts, id: i.id }, agent.recallCursor))
570
+ clocked.push(i);
571
+ continue;
572
+ }
573
+ if (agent.recallAheadSeen(i.id))
574
+ continue;
575
+ // Never show what cannot be recorded: an unrecorded item comes back on every call forever.
576
+ if (aheadRoom <= 0)
577
+ aheadWithheld.push(i);
578
+ else {
579
+ aheadRoom--;
580
+ aheadFresh.push(i);
581
+ }
582
+ }
583
+ clocked.sort(byTsThenId);
584
+ aheadFresh.sort(byTsThenId);
585
+ const fresh = [...clocked, ...aheadFresh];
586
+ const aheadIds = new Set(aheadFresh.map((i) => i.id));
587
+ const warning = [droppedNote(recall.droppedChannels), aheadNote(aheadWithheld)]
588
+ .filter(Boolean)
589
+ .join(" ");
590
+ const bufferedIds = new Set(buffered.map((i) => i.id));
591
+ const { text, shown: all, stuck } = renderInbox({
592
+ items: [...buffered, ...fresh],
593
+ peek,
594
+ warning,
595
+ strictIds: new Set(clocked.map((i) => i.id)),
596
+ head: (s) => scope
597
+ ? `${s.length} message${s.length === 1 ? "" : "s"}. Buffered pull-only items were cleared; normal focus channel items are read-only recall:`
598
+ : `${s.length} message${s.length === 1 ? "" : "s"}${peek ? " (peek: live buffer not cleared)" : ""} in focus mode; channel items are recall since you focused:`,
599
+ });
600
+ if (!buffered.length && !fresh.length && !recall.droppedChannels.length && !aheadWithheld.length)
268
601
  return ok(scope
269
602
  ? `No pull-only messages and no normal focus recall.${automaticPending ? ` ${automaticPending} connector-managed automatic message${automaticPending === 1 ? " is" : "s are"} still queued.` : ""}`
270
- : "Inbox empty no new messages, and no channel chatter since you entered focus.");
271
- const parts = [];
272
- if (all.length) {
273
- const head = scope
274
- ? `${all.length} message${all.length === 1 ? "" : "s"} — buffered pull-only items were cleared; normal focus channel items are read-only recall and may appear again:`
275
- : `${all.length} message${all.length === 1 ? "" : "s"}${peek ? " (peek live buffer not cleared)" : ""} — focus mode, channel items are recall since you focused:`;
276
- parts.push(`${head}\n${all.map(fmtItem).join("\n")}`);
603
+ : "Inbox empty, no new messages, and no channel chatter since you entered focus.");
604
+ // Render first, ack second, and only ever ids from the buffered lane: acking a recall id
605
+ // would mark it handled, so a later live copy of that channel message would be dropped.
606
+ if (!peek) {
607
+ agent.drainInboxIds(all.filter((i) => bufferedIds.has(i.id)).map((i) => i.id));
608
+ // THE MARK MOVES OVER AN UNBROKEN PREFIX, and stops at the first thing this reply did not
609
+ // carry. Two recall items can share a millisecond, so the mark is a (timestamp, id) pair:
610
+ // a timestamp alone either strands the twin, if it moves past both, or re-serves the one
611
+ // already delivered, if it stops below them. And it is the PREFIX that decides, not the
612
+ // last item shown, because a pair too large to share one window leaves a hole: advancing
613
+ // past a hole strands what is in it, which is total progress lost on an input that a
614
+ // replay burst produces routinely.
615
+ // The recall lane is filled in order and stops at its first gap, so what this reply carried
616
+ // of it IS an unbroken prefix: the last recall item shown is the end of that prefix, and
617
+ // the mark is exactly it. A walk over the prefix would compute the same value, which is why
618
+ // the mutation for it survived and the code went rather than the test being weakened.
619
+ const shownRecall = all.filter((i) => !bufferedIds.has(i.id));
620
+ for (const i of shownRecall)
621
+ if (aheadIds.has(i.id))
622
+ agent.noteRecalledAhead(i.id);
623
+ const shownClocked = shownRecall.filter((i) => !aheadIds.has(i.id));
624
+ const last = shownClocked[shownClocked.length - 1];
625
+ if (last)
626
+ agent.noteRecalled({ ts: last.ts, id: last.id });
627
+ void stuck;
277
628
  }
278
- if (recall.droppedChannels.length)
279
- parts.push(`⚠ Some earlier chatter could not be recalled completely on ${recall.droppedChannels
280
- .map((c) => `#${c}`)
281
- .join(", ")} (retention or local safety bounds were reached).`);
282
- return ok(parts.join("\n\n"));
629
+ return ok(text);
283
630
  },
284
631
  },
285
632
  {