@ziggs-ai/ziggs-mcp 0.18.0 → 0.19.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.
@@ -18,6 +18,26 @@ export interface ReadPlanCall {
18
18
  export interface ReadPlanResult {
19
19
  plan: ReadPlanCall[];
20
20
  truncated: number;
21
+ /**
22
+ * Rows the server said this reader cannot open. Listed so the agent can tell
23
+ * its person what arrived and what it would take to read it, instead of
24
+ * running a pre-filled read that refuses.
25
+ */
26
+ unreadable: Array<{
27
+ resourceId: string;
28
+ } & OutOfReachNote>;
29
+ }
30
+ /**
31
+ * The server's note on a row whose container this reader holds nothing to open.
32
+ *
33
+ * Read off the delivery rather than taken from the contract type: this client
34
+ * is installed independently of the server it talks to, so it has to work
35
+ * against a backend that predates the field as well as one that sends it.
36
+ */
37
+ interface OutOfReachNote {
38
+ scopeKind: string;
39
+ scopeId: string;
40
+ remedy: string;
21
41
  }
22
42
  /**
23
43
  * replace the free-text `nextActions` hints with typed `readPlan`
@@ -83,3 +103,4 @@ export declare function formatInboxToolResult(inbox: InboxEnvelope, ack?: InboxA
83
103
  * `decisions`, and they must be marked correctly.
84
104
  */
85
105
  self?: DecisionSelfIds): Record<string, unknown>;
106
+ export {};
@@ -1,7 +1,18 @@
1
- import { grantCaveat, resolvePendingApprovalPartyId } from '@ziggs-ai/api-client';
1
+ import { grantCaveat, planInboxAck, planPartialInboxAck, resolvePendingApprovalPartyId, } from '@ziggs-ai/api-client';
2
2
  import { formatPendingDecisionsPayload, resolveWebAppOrigin, } from './pendingDecisions.js';
3
3
  /** Keep the plan bounded; the full deliveries array still carries everything. */
4
4
  const MAX_READ_PLAN = 12;
5
+ function outOfReachOf(d) {
6
+ const note = d.outOfReach;
7
+ if (!note || typeof note !== 'object')
8
+ return null;
9
+ const { scopeKind, scopeId, remedy } = note;
10
+ return typeof scopeKind === 'string' &&
11
+ typeof scopeId === 'string' &&
12
+ typeof remedy === 'string'
13
+ ? { scopeKind, scopeId, remedy }
14
+ : null;
15
+ }
5
16
  /**
6
17
  * Where to read an artifact delivery from — a total function, so there is no
7
18
  * "none of the above" that silently drops the row. Container first (the parties
@@ -48,13 +59,38 @@ export function buildReadPlan(inbox, grantsByScope, self = { agentId: '' }) {
48
59
  // twice when several deliveries land in one chat. Collect candidates
49
60
  // uncapped; the cap is applied once, after the ack is reserved, so the ack
50
61
  // step always survives.
62
+ //
63
+ // Two lists, because only one of them can cost the ack.
64
+ // `mine` is what was addressed to this caller (decisions, and rows stamped
65
+ // for it); `ambient` is context nobody asked it to handle. A mailbox full of
66
+ // ambient mail — a person's, read by their assistant — overflowed the one
67
+ // list, the overflow dropped the ack step, and with no ack the window never
68
+ // moved, so the next read overflowed identically. Measured on one account:
69
+ // 200 rows, 23 reads dropped, ten days behind, and no way forward.
51
70
  const candidates = [];
71
+ const ambient = [];
52
72
  const seen = new Set();
53
- const add = (key, call) => {
73
+ // Which assigned rows each candidate step settles, so a checkpoint ack can
74
+ // name what is finished once that step is done. Keyed by the step's own
75
+ // dedup key, because one read covers every row that landed in its chat.
76
+ const settledBy = new Map();
77
+ const candidateKeys = [];
78
+ const add = (key, call, mine = true, settles) => {
79
+ // Recorded before the dedup return: the second row landing in a chat adds
80
+ // no step, but the one step there already covers it.
81
+ if (settles) {
82
+ const rows = settledBy.get(key);
83
+ if (rows)
84
+ rows.push(settles);
85
+ else
86
+ settledBy.set(key, [settles]);
87
+ }
54
88
  if (seen.has(key))
55
89
  return;
56
90
  seen.add(key);
57
- candidates.push(call);
91
+ if (mine)
92
+ candidateKeys.push(key);
93
+ (mine ? candidates : ambient).push(call);
58
94
  };
59
95
  // Decisions first — these also drive humanAttention (pull-only: no push).
60
96
  // The decision (approve/reject) is the human's; we only pre-fill the target.
@@ -100,7 +136,13 @@ export function buildReadPlan(inbox, grantsByScope, self = { agentId: '' }) {
100
136
  // holds one, so the read presents the right X-Context-Grant-Id without a
101
137
  // separate discover round-trip. Untagged reads still work by id.
102
138
  const grantFor = (kind, id) => grantsByScope?.get(`${kind}:${id}`)?.grantId;
103
- const read = (type, viaKind, viaId) => add(`read:${type}:${viaKind}:${viaId}`, readContextCall(type, viaKind, viaId, grantFor(viaKind, viaId)));
139
+ const read = (type, viaKind, viaId, mine = true, settles) => add(`read:${type}:${viaKind}:${viaId}`, readContextCall(type, viaKind, viaId, grantFor(viaKind, viaId)), mine, settles);
140
+ /**
141
+ * Is this row the caller's own? With no self id configured every row counts
142
+ * as its own, which is the old behaviour: the plan then holds one list and
143
+ * the ack rides on all of it fitting, exactly as before.
144
+ */
145
+ const isMine = (d) => self.agentId === '' || d.assigneeId === self.agentId;
104
146
  // Reads — one call per place mail actually landed. The delivery names the
105
147
  // chat, agreement or task directly, so nothing has to be inferred from a scope.
106
148
  //
@@ -116,15 +158,33 @@ export function buildReadPlan(inbox, grantsByScope, self = { agentId: '' }) {
116
158
  // nothing else, and an agent following the plan acked work it never read. The
117
159
  // second is that hole closed ahead of an emitter — nothing writes an
118
160
  // anchor-less delivery today, and when something does it is planned.
119
- for (const d of deliveries) {
161
+ //
162
+ // A row the server marked out of reach is not planned at all. The
163
+ // read behind it refuses, and handing the agent a call that always fails is
164
+ // what left it with no honest move: it could not open the row, could not
165
+ // report it, and could not ack past it.
166
+ const unreadable = [];
167
+ // Assigned first, ambient second, so a chat that carries both is planned as
168
+ // the caller's own work rather than as droppable context.
169
+ const inPlanOrder = [
170
+ ...deliveries.filter((d) => isMine(d)),
171
+ ...deliveries.filter((d) => !isMine(d)),
172
+ ];
173
+ for (const d of inPlanOrder) {
174
+ const note = outOfReachOf(d);
175
+ if (note) {
176
+ unreadable.push({ resourceId: d.resourceId, ...note });
177
+ continue;
178
+ }
179
+ const mine = isMine(d);
120
180
  switch (d.kind) {
121
181
  case 'message':
122
182
  // A message always lands in a chat; there is nowhere else to read it.
123
183
  if (d.chatId)
124
- read('messages', 'chat', d.chatId);
184
+ read('messages', 'chat', d.chatId, mine, mine ? d.resourceId : undefined);
125
185
  break;
126
186
  case 'artifact':
127
- read('artifacts', ...artifactEntry(d));
187
+ read('artifacts', ...artifactEntry(d), mine, mine ? d.resourceId : undefined);
128
188
  break;
129
189
  case 'task-state':
130
190
  case 'agreement':
@@ -148,32 +208,67 @@ export function buildReadPlan(inbox, grantsByScope, self = { agentId: '' }) {
148
208
  // never gets squeezed out exactly when there's the most news. Report how many
149
209
  // read/decision candidates the cap dropped as an explicit count.
150
210
  //
151
- // Only pre-fill ack when this plan covers the whole envelope.
152
- // A truncated plan (or a capped delivery list) must not hand back an ack
153
- // that would clear deliveries the plan never asked the agent to handle.
154
- // Reserve the ack slot only when every candidate still fits beside it;
155
- // otherwise spend the full budget on reads and omit ack.
156
- // A capped list is the oldest head. Acking it (with the listed ids) is
157
- // safe — the newer tail is past ackTo. A truncated *plan* still omits
158
- // ack, because those steps never asked the agent to handle the rest.
159
- const canAckFully = !!inbox.ackTo && (inbox.truncatedRequests ?? 0) === 0;
160
- const leaveRoomForAck = canAckFully && candidates.length <= MAX_READ_PLAN - 1;
161
- const budget = leaveRoomForAck ? MAX_READ_PLAN - 1 : MAX_READ_PLAN;
162
- const truncated = Math.max(0, candidates.length - budget);
163
- const plan = candidates.slice(0, budget);
164
- if (leaveRoomForAck && truncated === 0) {
165
- // Assigned rows only: the server's bury-guard checks what was MINE to
166
- // handle. Rows without my stamp are readable context, never mine to ack.
167
- // With no self id configured, fall back to listing everything — a
168
- // superset is always accepted; the guard only refuses missing ids.
169
- const handledResourceIds = [
170
- ...new Set([
171
- ...(inbox.deliveries ?? [])
172
- .filter((d) => self.agentId === '' || d.assigneeId === self.agentId)
173
- .map((d) => d.resourceId),
174
- ...(inbox.openRequestsAwaitingMe ?? []).map((q) => q.agreementId),
175
- ]),
176
- ].filter((id) => typeof id === 'string' && id.length > 0);
211
+ // The ack rides on MY steps all fitting, not on the whole plan fitting. The
212
+ // server's bury-guard is the thing this protects, and it asks one question:
213
+ // was every row assigned to me in this window handled? Ambient reads dropped
214
+ // by the cap are not part of that answer they were never mine to handle,
215
+ // and the owner's own seen mark tracks them. Letting them veto the ack is
216
+ // what pinned a busy mailbox in place for good.
217
+ //
218
+ // A capped delivery list is the oldest head; acking it with the listed ids
219
+ // is safe, because the newer tail is past ackTo.
220
+ const ack = planInboxAck(inbox, {
221
+ ownAgentId: self.agentId || undefined,
222
+ });
223
+ // One checkpoint ack, after the FIRST assigned step.
224
+ //
225
+ // A single ack at the end freezes the watermark for the caller's whole turn,
226
+ // and the server hands an unanswered row to the person's stand-in after 90
227
+ // seconds. A delegate's turn is a model turn, so that is the long one.
228
+ //
229
+ // One checkpoint, not one per step. The plan is capped at MAX_READ_PLAN, and
230
+ // acking after every read would roughly halve the reads that fit — the exact
231
+ // pressure the two-list split above exists to relieve, and overflow here is
232
+ // what put an account ten days behind with no way forward.
233
+ //
234
+ // The first step is the valuable one anyway. The takeover only reaches rows
235
+ // already past the window, which are the oldest, and assigned reads are
236
+ // planned oldest-first (deliveries arrive oldest-first and `inPlanOrder`
237
+ // keeps assigned rows ahead of ambient). So this releases exactly the rows
238
+ // that are in danger, at step one instead of step eleven.
239
+ const firstKey = candidateKeys[0];
240
+ const settledFirst = firstKey ? (settledBy.get(firstKey) ?? []) : [];
241
+ const checkpoint = settledFirst.length > 0
242
+ ? planPartialInboxAck(inbox, new Set(settledFirst), {
243
+ ownAgentId: self.agentId || undefined,
244
+ })
245
+ : null;
246
+ // Never the same mark as the final ack — that is a wasted call and a second
247
+ // chance for the model to ack work it has not done.
248
+ const useCheckpoint = !!checkpoint && checkpoint.ackTo !== inbox.ackTo;
249
+ const reserved = (ack.allowed ? 1 : 0) + (useCheckpoint ? 1 : 0);
250
+ const leaveRoomForAck = ack.allowed && candidates.length <= MAX_READ_PLAN - reserved;
251
+ const budget = leaveRoomForAck
252
+ ? MAX_READ_PLAN - reserved
253
+ : MAX_READ_PLAN;
254
+ const ordered = [...candidates, ...ambient];
255
+ const truncated = Math.max(0, ordered.length - budget);
256
+ const plan = ordered.slice(0, budget);
257
+ if (leaveRoomForAck && useCheckpoint && plan.length > 1) {
258
+ plan.splice(1, 0, {
259
+ tool: 'ziggs_inbox',
260
+ args: {
261
+ ack: checkpoint.ackTo,
262
+ handledResourceIds: checkpoint.handledResourceIds,
263
+ },
264
+ why: 'checkpoint — run this as soon as the ONE step above it is done, not later: ' +
265
+ 'it hands back the oldest mail so nobody answers it on top of you while you ' +
266
+ 'work through the rest. Pass ack back VERBATIM (it is opaque). If the step ' +
267
+ 'above is not done, skip this one and let the final ack cover it',
268
+ });
269
+ }
270
+ if (leaveRoomForAck) {
271
+ const handledResourceIds = ack.handledResourceIds;
177
272
  plan.push({
178
273
  tool: 'ziggs_inbox',
179
274
  args: {
@@ -185,7 +280,7 @@ export function buildReadPlan(inbox, grantsByScope, self = { agentId: '' }) {
185
280
  'delivery assigned to you in this envelope',
186
281
  });
187
282
  }
188
- return { plan, truncated };
283
+ return { plan, truncated, unreadable };
189
284
  }
190
285
  /**
191
286
  * forward-continuation for a read_context page. Built only from fields
@@ -279,7 +374,7 @@ export function formatInboxToolResult(inbox, ack, webOrigin, activeTasks, reach,
279
374
  */
280
375
  self = { agentId: '' }) {
281
376
  const byScope = reach?.length ? indexReachByScope(reach) : undefined;
282
- const { plan: readPlan, truncated: readPlanTruncated } = buildReadPlan(inbox, byScope, self);
377
+ const { plan: readPlan, truncated: readPlanTruncated, unreadable, } = buildReadPlan(inbox, byScope, self);
283
378
  const origin = resolveWebAppOrigin(webOrigin);
284
379
  // This is the news half of the inbox result: counts, not cards. The cold-read
285
380
  // session block (decisions, activeWork, sessionChatCard) is layered on top of
@@ -312,6 +407,10 @@ self = { agentId: '' }) {
312
407
  : {}),
313
408
  ...(readPlan.length ? { readPlan } : {}),
314
409
  ...(readPlanTruncated ? { readPlanTruncated } : {}),
410
+ // Named `outOfReach` on the result too, so the one word covers the whole
411
+ // path: the row carries it, the plan skips it, and this is where the agent
412
+ // reads what to say about it.
413
+ ...(unreadable.length ? { outOfReach: unreadable } : {}),
315
414
  };
316
415
  const { humanAttention, ...rest } = inbox;
317
416
  const payload = ack
@@ -430,11 +430,22 @@ export function formatPendingDecisionsPayload(inbox, webOrigin, self, opts) {
430
430
  const humanOnlyNote = humanOnly.length
431
431
  ? ` ${humanOnly.length} of them ${humanOnly.length === 1 ? 'is' : 'are'} the human's own to answer (respondableBy "human") — for those, hand over the link and do not call ziggs_agreement_respond; it is refused for a delegate.`
432
432
  : '';
433
+ // "nothing pending" is only true about mail the read actually
434
+ // reached. A window that stops short of the present says how far short, and
435
+ // that has to reach the line the model reads, not just a field beside it:
436
+ // an assistant answered "nothing pending" for its person from a window ten
437
+ // days old, while that morning's unanswered question sat outside it.
438
+ const backlog = inbox.backlog;
439
+ const behindNote = backlog
440
+ ? ` You are looking at a PARTIAL window: ${backlog.beyondWindow} more ${backlog.beyondWindow === 1 ? 'delivery is' : 'deliveries are'} unread past ${backlog.windowEndsAt}${backlog.newestAt ? `, the newest from ${backlog.newestAt}` : ''}. Say so rather than reporting an empty inbox, and ack this window to reach the rest.`
441
+ : '';
433
442
  const instruction = actionCount === 0
434
- ? 'No pending decisions or active tasks — continue with ziggs_inbox for scope news.'
443
+ ? backlog
444
+ ? `No pending decisions or active tasks IN THIS WINDOW.${behindNote}`
445
+ : 'No pending decisions or active tasks — continue with ziggs_inbox for scope news.'
435
446
  : withSessionCard
436
- ? `Paste sessionChatCard at the top of your reply. Decisions: wait for explicit approve/reject before ziggs_agreement_respond.${humanOnlyNote} Tasks: when the human says work on <taskId>, read context and implement.`
437
- : `Counts only here — ${SESSION_CARD_POINTER} Decisions: wait for explicit approve/reject before ziggs_agreement_respond.${humanOnlyNote}`;
447
+ ? `Paste sessionChatCard at the top of your reply. Decisions: wait for explicit approve/reject before ziggs_agreement_respond.${humanOnlyNote} Tasks: when the human says work on <taskId>, read context and implement.${behindNote}`
448
+ : `Counts only here — ${SESSION_CARD_POINTER} Decisions: wait for explicit approve/reject before ziggs_agreement_respond.${humanOnlyNote}${behindNote}`;
438
449
  return {
439
450
  pendingCount,
440
451
  hasPending: pendingCount > 0,
package/dist/tools.js CHANGED
@@ -13,12 +13,14 @@ import { toolError } from './toolError.js';
13
13
  import { registerCapability, registerCapabilities, textResult, } from './capabilityAdapter.js';
14
14
  // Shared protocol paragraphs live on connect `instructions` only.
15
15
  // This description is the tool's own fields and next calls — not PROTOCOL.*.
16
- const ZIGGS_INBOX_DESCRIPTION = "Where you stand, in one call. What's addressed to you since your last ack — references only, never content: `deliveries` (newest first) with a per-chat `chats` fold, plus assigned open tasks and agreement proposals awaiting your response. " +
16
+ const ZIGGS_INBOX_DESCRIPTION = "Where you stand, in one call. What's addressed to you since your last ack — references only, never content: `deliveries` (OLDEST first — this is a drain window, not a view of the newest mail; see `backlog` for how far it is from the present) with a per-chat `chats` fold, plus assigned open tasks and agreement proposals awaiting your response. " +
17
17
  'Open the conversations behind the references with ziggs_context_read (type=messages, via=chat:<chatId>). ' +
18
18
  'A cold call (no waitSeconds) is the session-start read: it also carries `session` (who you are acting as, in which org, against which backend), the structured `decisions` and `activeWork` awaiting an answer, and the `sessionChatCard` to paste for the human. Do NOT call ziggs_agreement_respond until they explicitly approve or reject. ' +
19
19
  'A long-poll call (waitSeconds) is the working loop and returns news only — the session block is a session-start cost, not a per-poll one. ' +
20
- 'A `readPlan` array gives the exact next calls (tool + pre-filled args) for the news in this response — run them verbatim to read each chat and ack; when the plan overflows or deliveries are capped, `readPlanTruncated` counts dropped reads and the ack step is omitted so a partial plan cannot bury other mail. ' +
21
- 'readPlan reads come pre-pinned with the covering contextGrantId when you hold one, so no separate ziggs_grant_list call is needed.';
20
+ 'A `readPlan` array gives the exact next calls (tool + pre-filled args) for the news in this response — run them verbatim to read each chat and ack. Your own rows are planned first; `readPlanTruncated` counts reads the plan could not fit, and the ack step is omitted only when one of YOUR OWN reads was dropped, since that is the one case where acking would bury your work. ' +
21
+ 'readPlan reads come pre-pinned with the covering contextGrantId when you hold one, so no separate ziggs_grant_list call is needed. ' +
22
+ '`outOfReach` lists rows you hold nothing to open: they are not yours to handle and not planned as reads, and each carries the one line saying what would put it in reach — tell your human rather than retrying the read. ' +
23
+ '`backlog` is present when this window stops short of the present: it says how many deliveries are unread past it and when the newest arrived. Never answer "nothing pending" while it is there — say how far back you are looking, and ack to reach the rest.';
22
24
  // The requirement is one grant, and saying so is the whole point: this used to
23
25
  // promise a cross-org reach test on every send (propose a link, or fail with
24
26
  // AGENT_NOT_PUBLISHED), which no longer exists. Reach is decided once, when
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/ziggs-mcp",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "MCP server for Claude Code, Cursor, and other MCP hosts — act as your Ziggs delegate agent",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@modelcontextprotocol/sdk": "^1.29.0",
42
- "@ziggs-ai/api-client": "0.18.0",
42
+ "@ziggs-ai/api-client": "0.19.0",
43
43
  "dotenv": "^16.6.1",
44
44
  "zod": "^3.24.2",
45
45
  "zod-to-json-schema": "^3.25.1"