@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.
- package/dist/agent.d.ts +92 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +109 -0
- package/dist/agent.js.map +1 -1
- package/dist/config.d.ts +36 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +126 -11
- package/dist/config.js.map +1 -1
- package/dist/control.d.ts +4 -0
- package/dist/control.d.ts.map +1 -1
- package/dist/control.js +12 -1
- package/dist/control.js.map +1 -1
- package/dist/docs-bundle.generated.d.ts.map +1 -1
- package/dist/docs-bundle.generated.js +17 -24
- package/dist/docs-bundle.generated.js.map +1 -1
- package/dist/launch.d.ts +89 -36
- package/dist/launch.d.ts.map +1 -1
- package/dist/launch.js +156 -65
- package/dist/launch.js.map +1 -1
- package/dist/relay.d.ts.map +1 -1
- package/dist/relay.js +52 -7
- package/dist/relay.js.map +1 -1
- package/dist/tool-specs.d.ts +61 -1
- package/dist/tool-specs.d.ts.map +1 -1
- package/dist/tool-specs.js +374 -27
- package/dist/tool-specs.js.map +1 -1
- package/package.json +3 -3
package/dist/tool-specs.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import type
|
|
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
|
package/dist/tool-specs.d.ts.map
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/tool-specs.js
CHANGED
|
@@ -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
|
-
|
|
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)}] ${
|
|
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)}] ${
|
|
90
|
-
return `[#${i.channel}${i.mentionsMe ? " @you" : ""} ${fmtFrom(i)}] ${
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
542
|
+
// "since you entered focus"). Recall is read-only, so peek only affects the live buffer.
|
|
265
543
|
const recall = await agent.recallAmbient();
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
{
|