@cotal-ai/connector-core 0.58.0 → 0.59.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 +120 -19
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +438 -21
- package/dist/agent.js.map +1 -1
- package/dist/agui-holder.d.ts +6 -6
- package/dist/agui-holder.d.ts.map +1 -1
- package/dist/agui-holder.js +6 -6
- package/dist/agui-holder.js.map +1 -1
- package/dist/agui.d.ts +35 -17
- package/dist/agui.d.ts.map +1 -1
- package/dist/agui.js +68 -19
- package/dist/agui.js.map +1 -1
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/docs-bundle.generated.js +31 -31
- package/dist/docs-bundle.generated.js.map +1 -1
- package/dist/event-wal.d.ts +2 -2
- package/dist/event-wal.d.ts.map +1 -1
- package/dist/event-wal.js +2 -2
- package/dist/event-wal.js.map +1 -1
- package/dist/runtime.d.ts +13 -13
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +13 -13
- package/dist/runtime.js.map +1 -1
- package/dist/tool-specs.d.ts +10 -11
- package/dist/tool-specs.d.ts.map +1 -1
- package/dist/tool-specs.js +202 -31
- package/dist/tool-specs.js.map +1 -1
- package/package.json +2 -2
package/dist/tool-specs.js
CHANGED
|
@@ -11,6 +11,8 @@ import { execFileSync } from "node:child_process";
|
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
import { isConcreteChannel, channelInAllow, AmbiguousPeerError, assertLifecycleToken, isPermissionDenied, renderLifecycleBlocked, LANG_PROBLEM_DETAIL_KIND } from "@cotal-ai/core";
|
|
13
13
|
import { afterRecallMark } from "./agent.js";
|
|
14
|
+
// The neutralization and the per-item rendering live in `framing.ts`, one convention shared with
|
|
15
|
+
// the auto-injected block, and are used here rather than restated. See that file for the rule.
|
|
14
16
|
import { attributionSafe, fmtBody, fmtItem, fmtFrom } from "./framing.js";
|
|
15
17
|
import { FEEDBACK_URL, PUBLIC_FEEDBACK_URL, isAuthed } from "./config.js";
|
|
16
18
|
import { buildOrientation, renderOrientation } from "./orientation.js";
|
|
@@ -81,6 +83,9 @@ export function parseToolArgs(spec, args) {
|
|
|
81
83
|
.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
|
|
82
84
|
.join("; ")}`);
|
|
83
85
|
}
|
|
86
|
+
/** The closed EMPTY input, for an adapter that republishes a tool with no arguments of its own.
|
|
87
|
+
* A host given this refuses extras itself; a host given no `inputSchema` at all forwards them. */
|
|
88
|
+
export const NO_TOOL_ARGS = z.strictObject({});
|
|
84
89
|
/**
|
|
85
90
|
* Refuse ANY caller-supplied argument to a tool an adapter publishes with none — returning the
|
|
86
91
|
* refusal text, or `undefined` when the call is clean.
|
|
@@ -91,13 +96,21 @@ export function parseToolArgs(spec, args) {
|
|
|
91
96
|
* tool while every sibling refuses it. The wording matches {@link parseToolArgs} so a caller cannot
|
|
92
97
|
* tell which mechanism turned it away, and this stays dependency-free for hosts that bundle.
|
|
93
98
|
*/
|
|
94
|
-
/** The closed EMPTY input, for an adapter that republishes a tool with no arguments of its own.
|
|
95
|
-
* A host given this refuses extras itself; a host given no `inputSchema` at all forwards them. */
|
|
96
|
-
export const NO_TOOL_ARGS = z.strictObject({});
|
|
97
99
|
export function refuseAnyArgs(name, args) {
|
|
98
100
|
const keys = args && typeof args === "object" ? Object.keys(args) : [];
|
|
99
101
|
return keys.length ? `${name}: unknown argument(s): ${keys.join(", ")} — this tool takes no arguments` : undefined;
|
|
100
102
|
}
|
|
103
|
+
/** Compact age: `12s`, `47m`, `3h`, `2d`. */
|
|
104
|
+
function ageText(ms) {
|
|
105
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
106
|
+
if (s < 60)
|
|
107
|
+
return `${s}s`;
|
|
108
|
+
if (s < 3600)
|
|
109
|
+
return `${Math.floor(s / 60)}m`;
|
|
110
|
+
if (s < 86_400)
|
|
111
|
+
return `${Math.floor(s / 3600)}h`;
|
|
112
|
+
return `${Math.floor(s / 86_400)}d`;
|
|
113
|
+
}
|
|
101
114
|
function statusGlyph(s) {
|
|
102
115
|
return s === "working" ? "●" : s === "waiting" ? "◐" : s === "idle" ? "○" : "·";
|
|
103
116
|
}
|
|
@@ -108,8 +121,6 @@ const ATTENTION_DESC = {
|
|
|
108
121
|
dnd: "dnd — channel chatter no longer wakes you (it still arrives in your next turn); DMs, anycast, and @mentions still wake you",
|
|
109
122
|
focus: "focus — only DMs and anycast reach your context; an @mention wakes you to pull; untagged channel chatter is held on the channel — read it with cotal_inbox",
|
|
110
123
|
};
|
|
111
|
-
/** The neutralization and the per-item rendering live in `framing.ts`, one convention shared with
|
|
112
|
-
* the auto-injected block, and are used here rather than restated. See that file for the rule. */
|
|
113
124
|
/**
|
|
114
125
|
* HOW MUCH OF THE INBOX ONE RESPONSE MAY CARRY, in characters.
|
|
115
126
|
*
|
|
@@ -157,9 +168,12 @@ export function renderInbox(opts) {
|
|
|
157
168
|
//
|
|
158
169
|
// Stuck means "no response could carry this", so it is measured against the friendliest response
|
|
159
170
|
// there is: this item alone, its head, and any rider, with no held-note at all.
|
|
171
|
+
// Every set in this walk is keyed by receive key. An id-less item's wire id is the empty string,
|
|
172
|
+
// so a set of wire ids makes every id-less item the same item: a held giant vanished from the
|
|
173
|
+
// note when a small id-less item went out, and recall moved its mark past an unread one (#613).
|
|
160
174
|
const stuck = new Set(ordered
|
|
161
175
|
.filter((i) => opts.head([i]).length + 1 + itemCost(i) + (warning ? warning.length + 2 : 0) > budget)
|
|
162
|
-
.map((i) => i.
|
|
176
|
+
.map((i) => i.recvKey));
|
|
163
177
|
const assemble = (shown, held, tier) => {
|
|
164
178
|
const note = heldNote(held, peek, stuck, tier);
|
|
165
179
|
const parts = [];
|
|
@@ -194,14 +208,14 @@ export function renderInbox(opts) {
|
|
|
194
208
|
let used = 0;
|
|
195
209
|
let strictGap = false; // the in-order lane stops at its first gap; the free lane steps over its own
|
|
196
210
|
for (const i of ordered) {
|
|
197
|
-
const strict = strictIds.has(i.
|
|
211
|
+
const strict = strictIds.has(i.recvKey);
|
|
198
212
|
if (strict && strictGap)
|
|
199
213
|
continue;
|
|
200
214
|
const cost = itemCost(i);
|
|
201
215
|
if (used + cost > budget) {
|
|
202
|
-
//
|
|
203
|
-
// walk
|
|
204
|
-
if (strict
|
|
216
|
+
// In the ordered lane even a message no response could carry whole is a gap: the mark would
|
|
217
|
+
// pass it if the walk stepped over it, so the lane waits for it to go out in parts (#613).
|
|
218
|
+
if (strict)
|
|
205
219
|
strictGap = true;
|
|
206
220
|
continue;
|
|
207
221
|
}
|
|
@@ -209,8 +223,8 @@ export function renderInbox(opts) {
|
|
|
209
223
|
used += cost;
|
|
210
224
|
}
|
|
211
225
|
const heldOf = () => {
|
|
212
|
-
const
|
|
213
|
-
return ordered.filter((i) => !
|
|
226
|
+
const keys = new Set(shown.map((i) => i.recvKey));
|
|
227
|
+
return ordered.filter((i) => !keys.has(i.recvKey));
|
|
214
228
|
};
|
|
215
229
|
let held = heldOf();
|
|
216
230
|
let text = assemble(shown, held, "full");
|
|
@@ -241,9 +255,9 @@ function itemCost(i) {
|
|
|
241
255
|
* The tail that keeps a windowed response honest: what is still there, and that it was not lost.
|
|
242
256
|
*
|
|
243
257
|
* TWO KINDS OF HELD, because they are not the same promise. Most held mail is waiting its turn and
|
|
244
|
-
* a later call delivers it. A message larger than one whole response
|
|
245
|
-
*
|
|
246
|
-
*
|
|
258
|
+
* a later call delivers it whole. A message larger than one whole response never arrives whole: it
|
|
259
|
+
* goes out in parts through {@link renderPart}, after the smaller mail, so the note says that rather
|
|
260
|
+
* than promising it in the next batch.
|
|
247
261
|
*
|
|
248
262
|
* THE NOTE IS BOUNDED. It names at most {@link NAMED_STUCK} of the stuck messages and counts the
|
|
249
263
|
* rest, and it truncates a sender's name, because a steady stream of oversized mail would otherwise
|
|
@@ -252,7 +266,7 @@ function itemCost(i) {
|
|
|
252
266
|
function heldNote(held, peek = false, stuckIds = new Set(), tier = "full") {
|
|
253
267
|
if (!held.length || tier === "none")
|
|
254
268
|
return "";
|
|
255
|
-
const stuck = held.filter((i) => stuckIds.has(i.
|
|
269
|
+
const stuck = held.filter((i) => stuckIds.has(i.recvKey));
|
|
256
270
|
const waiting = held.length - stuck.length;
|
|
257
271
|
if (tier === "compact") {
|
|
258
272
|
const bits = [];
|
|
@@ -269,7 +283,7 @@ function heldNote(held, peek = false, stuckIds = new Set(), tier = "full") {
|
|
|
269
283
|
}
|
|
270
284
|
const parts = [];
|
|
271
285
|
if (waiting) {
|
|
272
|
-
const dms = held.filter((i) => i.kind !== "channel" && !stuckIds.has(i.
|
|
286
|
+
const dms = held.filter((i) => i.kind !== "channel" && !stuckIds.has(i.recvKey)).length;
|
|
273
287
|
// Under peek nothing is cleared, so the next call returns THIS window again. Telling a peeking
|
|
274
288
|
// caller to call again for the next batch is a promise the read cannot keep, and an obedient
|
|
275
289
|
// caller loops on it forever.
|
|
@@ -284,10 +298,115 @@ function heldNote(held, peek = false, stuckIds = new Set(), tier = "full") {
|
|
|
284
298
|
.map((i) => `${fmtFrom(i).slice(0, 40)} (${itemCost(i).toLocaleString("en-US")} chars)`)
|
|
285
299
|
.join(", ");
|
|
286
300
|
const rest = stuck.length - Math.min(NAMED_STUCK, stuck.length);
|
|
287
|
-
parts.push(`${stuck.length} message${stuck.length === 1 ? " is" : "s are"} larger than one response can carry
|
|
301
|
+
parts.push(`${stuck.length} message${stuck.length === 1 ? " is" : "s are"} larger than one response can carry: ${named}${rest ? `, and ${rest} more` : ""}. ${stuck.length === 1 ? "It stays" : "They stay"} buffered and uncleared, and once no smaller mail is waiting, each call delivers the next part of one of them; a message is cleared only after its last part goes out.`);
|
|
288
302
|
}
|
|
289
303
|
return `\n\n… ${parts.join(" ")}`;
|
|
290
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* How far each oversized message has been read, by receive key, per agent (#613).
|
|
307
|
+
*
|
|
308
|
+
* It lives beside the agent rather than in the reply, so a reconnect, which keeps the agent and its
|
|
309
|
+
* buffer, resumes where the last part ended. A process restart loses it, and the redelivered message
|
|
310
|
+
* starts again from its first part: repeated, never skipped. An entry goes when its message is no
|
|
311
|
+
* longer offered, whether by its last part or by anything else that consumed it.
|
|
312
|
+
*/
|
|
313
|
+
const partOffsets = new WeakMap();
|
|
314
|
+
/**
|
|
315
|
+
* One `cotal_inbox` read at a time per agent (#613). A read decides what to hide, render and clear from
|
|
316
|
+
* state it took before awaiting recall, so a second read that overlapped it decided from a snapshot the
|
|
317
|
+
* first had already moved past: it hid a recalled message the first had just read in part, and moved
|
|
318
|
+
* the recall mark past the rest of it. A read waits for the one before it to finish, failed or not.
|
|
319
|
+
*/
|
|
320
|
+
const inboxReads = new WeakMap();
|
|
321
|
+
function oneReadAtATime(read) {
|
|
322
|
+
return (agent, config, args) => {
|
|
323
|
+
const turn = (inboxReads.get(agent) ?? Promise.resolve()).then(() => read(agent, config, args));
|
|
324
|
+
inboxReads.set(agent, turn.catch(() => undefined));
|
|
325
|
+
return turn;
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
/** The read position for an oversized message, after dropping positions of messages no longer offered. */
|
|
329
|
+
function partCursor(agent, offered) {
|
|
330
|
+
let m = partOffsets.get(agent);
|
|
331
|
+
if (!m)
|
|
332
|
+
partOffsets.set(agent, (m = new Map()));
|
|
333
|
+
const live = new Set(offered.map((i) => i.recvKey));
|
|
334
|
+
for (const k of m.keys())
|
|
335
|
+
if (!live.has(k))
|
|
336
|
+
m.delete(k);
|
|
337
|
+
return m;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* One part of a message no single response can carry: the next slice of its rendered form, with a
|
|
341
|
+
* line saying which characters these are and whether more follow (#613).
|
|
342
|
+
*
|
|
343
|
+
* The slice is of the RENDERED item, so its indented continuations come with it, and it starts on
|
|
344
|
+
* its own indented line, so a cut that lands mid-line cannot put peer text at column zero. Its size
|
|
345
|
+
* is what the window has left once the line above it, the note and any rider are in, and the
|
|
346
|
+
* finished text is measured like {@link renderInbox}'s. `end` is where the next part starts; the
|
|
347
|
+
* caller clears the message only when `done`, and advances nothing on a peek.
|
|
348
|
+
*/
|
|
349
|
+
function renderPart(opts) {
|
|
350
|
+
const budget = opts.budget ?? INBOX_WINDOW_CHARS;
|
|
351
|
+
const full = fmtItem(opts.item);
|
|
352
|
+
const total = full.length;
|
|
353
|
+
const fmt = (n) => n.toLocaleString("en-US");
|
|
354
|
+
const assemble = (end, tier) => {
|
|
355
|
+
const done = end >= total;
|
|
356
|
+
const next = opts.peek
|
|
357
|
+
? "A peek advances nothing, so read without peek to take this part."
|
|
358
|
+
: done
|
|
359
|
+
? "This was its last part, so it is now cleared."
|
|
360
|
+
: "It stays buffered and uncleared until its last part goes out; call cotal_inbox again for the next part.";
|
|
361
|
+
const head = `Part of a message larger than one response, from ${fmtFrom(opts.item).slice(0, 40)}: characters ${fmt(opts.offset + 1)}-${fmt(end)} of ${fmt(total)}${opts.peek ? " (peek: nothing cleared)" : ""}. ${next}`;
|
|
362
|
+
const body = `${head}\n ${full.slice(opts.offset, end)}${heldNote(opts.others, opts.peek, opts.stuck, tier)}`;
|
|
363
|
+
return opts.warning ? `${body}\n\n${opts.warning}` : body;
|
|
364
|
+
};
|
|
365
|
+
for (const tier of NOTE_TIERS) {
|
|
366
|
+
// Measure everything but the slice with the widest numbers the header can carry, then fill.
|
|
367
|
+
const room = budget - assemble(opts.offset, tier).length - 2 * fmt(total).length;
|
|
368
|
+
if (room <= 0)
|
|
369
|
+
continue;
|
|
370
|
+
let end = Math.min(total, opts.offset + room);
|
|
371
|
+
// Never split a surrogate pair across two parts.
|
|
372
|
+
if (end < total && /[\uD800-\uDBFF]/.test(full[end - 1]))
|
|
373
|
+
end--;
|
|
374
|
+
const text = assemble(end, tier);
|
|
375
|
+
if (text.length <= budget && end > opts.offset)
|
|
376
|
+
return { text, end, done: end >= total };
|
|
377
|
+
}
|
|
378
|
+
throw new Error(`cotal_inbox: no room for a part of a ${fmt(total)}-character message in a ${fmt(budget)}-character window`);
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* The reply for a call that could carry no whole message: the next part of `item`, a message too
|
|
382
|
+
* large for any response, or `undefined` when there is none. The read position moves only when the
|
|
383
|
+
* part went out, and `last` records the message as handed over only with its last part, so nothing
|
|
384
|
+
* is cleared before it was handed over (#603). `last` is a buffered message's ack, or for focus
|
|
385
|
+
* recall the mark or the ahead record that a whole recalled item would have moved.
|
|
386
|
+
*/
|
|
387
|
+
function partReply(opts) {
|
|
388
|
+
const { agent, item, peek } = opts;
|
|
389
|
+
if (!item)
|
|
390
|
+
return undefined;
|
|
391
|
+
const cursor = partCursor(agent, opts.offered);
|
|
392
|
+
const part = renderPart({
|
|
393
|
+
item,
|
|
394
|
+
offset: cursor.get(item.recvKey) ?? 0,
|
|
395
|
+
peek,
|
|
396
|
+
others: opts.offered.filter((i) => i !== item),
|
|
397
|
+
stuck: opts.stuck,
|
|
398
|
+
warning: opts.warning,
|
|
399
|
+
});
|
|
400
|
+
if (!peek) {
|
|
401
|
+
if (part.done) {
|
|
402
|
+
opts.last(item);
|
|
403
|
+
cursor.delete(item.recvKey);
|
|
404
|
+
}
|
|
405
|
+
else
|
|
406
|
+
cursor.set(item.recvKey, part.end);
|
|
407
|
+
}
|
|
408
|
+
return part.text;
|
|
409
|
+
}
|
|
291
410
|
/**
|
|
292
411
|
* The recall warning, bounded and budgeted like every other part of a response.
|
|
293
412
|
*
|
|
@@ -589,7 +708,12 @@ export function cotalToolSpecs(config, source = "connector") {
|
|
|
589
708
|
.map(([c]) => `#${c}`)
|
|
590
709
|
: [];
|
|
591
710
|
const mutedHint = muted.length ? ` (locally muted ${muted.join(", ")}; DM to reach)` : "";
|
|
592
|
-
|
|
711
|
+
// The condition with how long it has held, and the age of the last harness-reported work
|
|
712
|
+
// event (#618): a turn that died or stopped advancing 40m ago no longer reads like a live one.
|
|
713
|
+
const now = Date.now();
|
|
714
|
+
const since = p.condition?.since;
|
|
715
|
+
const active = p.activeAt === undefined ? "" : ` · active ${ageText(now - p.activeAt)} ago`;
|
|
716
|
+
const condition = (p.condition ? ` (${p.condition.code}${since === undefined ? "" : ` for ${ageText(now - since)}`})` : "") + active;
|
|
593
717
|
const progress = p.status === "working" ? `working${condition} · progress unknown` : `${p.status}${condition}`;
|
|
594
718
|
return `${statusGlyph(p.status)} ${who} — ${progress}${p.activity ? `: ${p.activity}` : ""}${attn}${me}${mutedHint}${id}`;
|
|
595
719
|
});
|
|
@@ -603,11 +727,11 @@ export function cotalToolSpecs(config, source = "connector") {
|
|
|
603
727
|
{
|
|
604
728
|
name: "cotal_inbox",
|
|
605
729
|
title: "Cotal: read incoming messages",
|
|
606
|
-
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
|
|
730
|
+
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 delivered in parts: once no smaller mail is waiting, each call carries the next part of it, a peek shows the current part without moving on, and the message is cleared only after its last part goes out. In focus mode it also pulls back the channel chatter held since you entered focus.",
|
|
607
731
|
schema: {
|
|
608
732
|
peek: z.boolean().optional().describe("If true, show messages without clearing them."),
|
|
609
733
|
},
|
|
610
|
-
async
|
|
734
|
+
run: oneReadAtATime(async (agent, _config, { peek, scope }) => {
|
|
611
735
|
const inboxScope = scope ?? "all";
|
|
612
736
|
// SELECT, RENDER, THEN CLEAR EXACTLY WHAT WENT OUT (#603). The old order drained the whole
|
|
613
737
|
// scope up front, so a payload too large for the host to deliver had already been marked
|
|
@@ -616,10 +740,10 @@ export function cotalToolSpecs(config, source = "connector") {
|
|
|
616
740
|
// It is not the only acker: the inbox's own overflow valve acks what it evicts, so an item
|
|
617
741
|
// that arrives while this call is awaiting recall can still be evicted and lost. That is the
|
|
618
742
|
// buffer's documented bounded local loss (see MeshAgent.buffer), unchanged by this path.
|
|
619
|
-
const buffered = agent.peekInbox(inboxScope);
|
|
620
743
|
const automaticPending = scope ? agent.inboxCount("automatic") : 0;
|
|
621
744
|
if (agent.attention !== "focus") {
|
|
622
|
-
const
|
|
745
|
+
const buffered = agent.peekInbox(inboxScope);
|
|
746
|
+
const { text, shown, held, stuck } = renderInbox({
|
|
623
747
|
items: buffered,
|
|
624
748
|
peek,
|
|
625
749
|
head: (s) => scope
|
|
@@ -630,6 +754,18 @@ export function cotalToolSpecs(config, source = "connector") {
|
|
|
630
754
|
return ok(scope
|
|
631
755
|
? `No pull-only messages.${automaticPending ? ` ${automaticPending} connector-managed automatic message${automaticPending === 1 ? " is" : "s are"} still queued.` : ""}`
|
|
632
756
|
: "Inbox empty, no new messages.");
|
|
757
|
+
if (!shown.length) {
|
|
758
|
+
const part = partReply({
|
|
759
|
+
agent,
|
|
760
|
+
item: buffered.find((i) => stuck.has(i.recvKey)),
|
|
761
|
+
offered: buffered,
|
|
762
|
+
stuck,
|
|
763
|
+
peek: peek ?? false,
|
|
764
|
+
last: (i) => agent.drainInboxDeliveries([i.recvKey]),
|
|
765
|
+
});
|
|
766
|
+
if (part)
|
|
767
|
+
return ok(part);
|
|
768
|
+
}
|
|
633
769
|
// The response exists before anything is acked: an ack is a claim that these messages were
|
|
634
770
|
// handed over, so nothing may be cleared while the handing-over is still hypothetical. And
|
|
635
771
|
// it is the ASSEMBLED response that decides, so what is acked is what a caller was handed.
|
|
@@ -640,8 +776,14 @@ export function cotalToolSpecs(config, source = "connector") {
|
|
|
640
776
|
}
|
|
641
777
|
// Focus: the live buffer holds only DMs/anycast; the channel ambient + @mentions were
|
|
642
778
|
// acked-and-dropped at ingest, so pull them back from the channel stream here (replay-gated,
|
|
643
|
-
// "since you entered focus"). Recall
|
|
644
|
-
|
|
779
|
+
// "since you entered focus"). Recall acks nothing, so peek only affects the live buffer.
|
|
780
|
+
// A recalled message already part read stays offered until its last part goes out (#613).
|
|
781
|
+
const before = new Set(agent.peekInbox("all").map((i) => i.recvKey));
|
|
782
|
+
const underway = new Set([...(partOffsets.get(agent)?.keys() ?? [])].filter((k) => !before.has(k)));
|
|
783
|
+
const recall = await agent.recallAmbient(underway);
|
|
784
|
+
// Read after recall, which can hand an id-less message back into the buffer (#662).
|
|
785
|
+
const buffered = agent.peekInbox(inboxScope);
|
|
786
|
+
const bufferedIds = new Set(buffered.map((i) => i.recvKey));
|
|
645
787
|
// RECALL HAS TO ADVANCE, or windowing it starves it. Recall is re-derived from an unchanged
|
|
646
788
|
// frontier on every call, so showing its first window and stopping there returned the same
|
|
647
789
|
// prefix forever while the reply promised a next batch: measured as three identical replies
|
|
@@ -690,12 +832,11 @@ export function cotalToolSpecs(config, source = "connector") {
|
|
|
690
832
|
const warning = [droppedNote(recall.droppedChannels), aheadNote(aheadWithheld)]
|
|
691
833
|
.filter(Boolean)
|
|
692
834
|
.join(" ");
|
|
693
|
-
const bufferedIds = new Set(buffered.map((i) => i.recvKey));
|
|
694
835
|
const { text, shown: all, stuck } = renderInbox({
|
|
695
836
|
items: [...buffered, ...fresh],
|
|
696
837
|
peek,
|
|
697
838
|
warning,
|
|
698
|
-
strictIds: new Set(clocked.map((i) => i.
|
|
839
|
+
strictIds: new Set(clocked.map((i) => i.recvKey)),
|
|
699
840
|
head: (s) => scope
|
|
700
841
|
? `${s.length} message${s.length === 1 ? "" : "s"}. Buffered pull-only items were cleared; normal focus channel items are read-only recall:`
|
|
701
842
|
: `${s.length} message${s.length === 1 ? "" : "s"}${peek ? " (peek: live buffer not cleared)" : ""} in focus mode; channel items are recall since you focused:`,
|
|
@@ -704,6 +845,32 @@ export function cotalToolSpecs(config, source = "connector") {
|
|
|
704
845
|
return ok(scope
|
|
705
846
|
? `No pull-only messages and no normal focus recall.${automaticPending ? ` ${automaticPending} connector-managed automatic message${automaticPending === 1 ? " is" : "s are"} still queued.` : ""}`
|
|
706
847
|
: "Inbox empty, no new messages, and no channel chatter since you entered focus.");
|
|
848
|
+
if (!all.length) {
|
|
849
|
+
// A recalled message goes out in parts too, and its last part moves what a whole one would
|
|
850
|
+
// have: the mark for the ordered lane, whose first item is the only one it may take, or the
|
|
851
|
+
// ahead record, so the mark never passes a message that has not been handed over.
|
|
852
|
+
const first = clocked[0];
|
|
853
|
+
const item = buffered.find((i) => stuck.has(i.recvKey)) ??
|
|
854
|
+
(first && stuck.has(first.recvKey) ? first : aheadFresh.find((i) => stuck.has(i.recvKey)));
|
|
855
|
+
const part = partReply({
|
|
856
|
+
agent,
|
|
857
|
+
item,
|
|
858
|
+
offered: [...buffered, ...fresh],
|
|
859
|
+
stuck,
|
|
860
|
+
peek: peek ?? false,
|
|
861
|
+
warning,
|
|
862
|
+
last: (i) => {
|
|
863
|
+
if (bufferedIds.has(i.recvKey))
|
|
864
|
+
agent.drainInboxDeliveries([i.recvKey]);
|
|
865
|
+
else if (aheadIds.has(i.recvKey))
|
|
866
|
+
agent.noteRecalledAhead(i.recvKey);
|
|
867
|
+
else
|
|
868
|
+
agent.noteRecalled({ ts: i.ts, id: i.recvKey });
|
|
869
|
+
},
|
|
870
|
+
});
|
|
871
|
+
if (part)
|
|
872
|
+
return ok(part);
|
|
873
|
+
}
|
|
707
874
|
// Render first, ack second, and only ever ids from the buffered lane: acking a recall id
|
|
708
875
|
// would mark it handled, so a later live copy of that channel message would be dropped.
|
|
709
876
|
if (!peek) {
|
|
@@ -723,14 +890,14 @@ export function cotalToolSpecs(config, source = "connector") {
|
|
|
723
890
|
for (const i of shownRecall)
|
|
724
891
|
if (aheadIds.has(i.recvKey))
|
|
725
892
|
agent.noteRecalledAhead(i.recvKey);
|
|
726
|
-
const shownClocked = shownRecall.filter((i) => !aheadIds.has(i.
|
|
893
|
+
const shownClocked = shownRecall.filter((i) => !aheadIds.has(i.recvKey));
|
|
727
894
|
const last = shownClocked[shownClocked.length - 1];
|
|
728
895
|
if (last)
|
|
729
896
|
agent.noteRecalled({ ts: last.ts, id: last.recvKey });
|
|
730
897
|
void stuck;
|
|
731
898
|
}
|
|
732
899
|
return ok(text);
|
|
733
|
-
},
|
|
900
|
+
}),
|
|
734
901
|
},
|
|
735
902
|
{
|
|
736
903
|
name: "cotal_send",
|
|
@@ -767,10 +934,14 @@ export function cotalToolSpecs(config, source = "connector") {
|
|
|
767
934
|
schema: {
|
|
768
935
|
to: z.string().describe("The peer's name (or instance id)."),
|
|
769
936
|
text: z.string().describe("The message."),
|
|
937
|
+
replyTo: z
|
|
938
|
+
.string()
|
|
939
|
+
.optional()
|
|
940
|
+
.describe("The id of the peer's message this DM answers. Omit it to answer the peer's oldest unanswered message; when that peer's waiting messages belong to more than one conversation, the DM is refused with their ids."),
|
|
770
941
|
},
|
|
771
|
-
async run(agent, _config, { to, text: msg }) {
|
|
942
|
+
async run(agent, _config, { to, text: msg, replyTo }) {
|
|
772
943
|
try {
|
|
773
|
-
const { peer, ack, recipientStatusAtSend } = await agent.dm(to, msg);
|
|
944
|
+
const { peer, ack, recipientStatusAtSend } = await agent.dm(to, msg, { replyTo });
|
|
774
945
|
const dup = ack.duplicate ? " duplicate publication." : "";
|
|
775
946
|
return ok(`DM stored as seq ${ack.seq} for ${peer.card.name} (recipient was ${recipientStatusAtSend} at send; delivery not confirmed).${dup}`);
|
|
776
947
|
}
|