@joe-broadhead/messaging 0.7.1 → 0.8.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +214 -2
  2. package/dist/bin/messaging-mcp.js +5043 -4054
  3. package/dist/bin/messaging-relay.js +8263 -5425
  4. package/dist/bin/messaging.js +5982 -4906
  5. package/dist/bin/messagingd.js +19956 -13716
  6. package/dist/lib/modules/core/agent/operations.js +19 -327
  7. package/dist/lib/modules/core/agent/present.js +15 -55
  8. package/dist/lib/modules/core/agent-tools.js +23 -139
  9. package/dist/lib/modules/core/consent.js +1 -1
  10. package/dist/lib/modules/core/conversation/event.js +29 -4
  11. package/dist/lib/modules/core/conversation/guest-invitation.js +42 -0
  12. package/dist/lib/modules/core/conversation/log.js +287 -65
  13. package/dist/lib/modules/core/conversation/request-log.js +158 -0
  14. package/dist/lib/modules/core/conversation/request-seal.js +41 -0
  15. package/dist/lib/modules/core/conversation/requests.js +87 -0
  16. package/dist/lib/modules/core/conversation/seal.js +90 -37
  17. package/dist/lib/modules/core/conversation-agent-tools.js +69 -0
  18. package/dist/lib/modules/core/federation/directory-proof.js +83 -0
  19. package/dist/lib/modules/core/federation/proof-state.js +167 -0
  20. package/dist/lib/modules/core/federation/roster-proof.js +74 -0
  21. package/dist/lib/modules/core/index.js +8 -3
  22. package/dist/lib/modules/core/jelly-host.js +5 -5
  23. package/dist/lib/modules/core/schema/common.js +31 -5
  24. package/dist/lib/modules/core/schema/consent.js +5 -4
  25. package/dist/lib/modules/core/schema/contact-request.js +22 -0
  26. package/dist/lib/modules/core/schema/conv-frames.js +94 -0
  27. package/dist/lib/modules/core/schema/conversation.js +67 -33
  28. package/dist/lib/modules/core/schema/envelope.js +8 -24
  29. package/dist/lib/modules/core/schema/errors.js +4 -3
  30. package/dist/lib/modules/core/schema/guest-invitation.js +13 -0
  31. package/dist/lib/modules/core/schema/index.js +5 -0
  32. package/dist/lib/modules/core/schema/ipc/consent.js +13 -9
  33. package/dist/lib/modules/core/schema/ipc/conversation-views.js +80 -0
  34. package/dist/lib/modules/core/schema/ipc/conversations.js +128 -0
  35. package/dist/lib/modules/core/schema/ipc/device.js +2 -0
  36. package/dist/lib/modules/core/schema/ipc/jelly.js +3 -38
  37. package/dist/lib/modules/core/schema/ipc/shapes.js +14 -72
  38. package/dist/lib/modules/core/schema/ipc/surfaces.js +12 -195
  39. package/dist/lib/modules/core/schema/ipc/tasks.js +31 -0
  40. package/dist/lib/modules/core/schema/ipc.js +18 -44
  41. package/dist/lib/modules/core/testing.js +1 -1
  42. package/dist/lib/modules/core/version.js +1 -1
  43. package/dist/lib/modules/ipc/client.js +10 -2
  44. package/dist/lib/modules/ipc/server.js +14 -5
  45. package/dist/lib/modules/ipc/testing.js +13 -23
  46. package/dist/spec/messaging-v1.md +546 -409
  47. package/dist/third-party-notices.md +1 -1
  48. package/dist/third-party.json +1 -1
  49. package/dist/types/core/agent/operations.d.ts +5 -52
  50. package/dist/types/core/agent/present.d.ts +6 -21
  51. package/dist/types/core/agent-tools.d.ts +263 -442
  52. package/dist/types/core/conversation/event.d.ts +6 -6
  53. package/dist/types/core/conversation/guest-invitation.d.ts +8 -0
  54. package/dist/types/core/conversation/log.d.ts +28 -13
  55. package/dist/types/core/conversation/request-log.d.ts +24 -0
  56. package/dist/types/core/conversation/request-seal.d.ts +6 -0
  57. package/dist/types/core/conversation/requests.d.ts +14 -0
  58. package/dist/types/core/conversation/seal.d.ts +32 -9
  59. package/dist/types/core/conversation-agent-tools.d.ts +918 -0
  60. package/dist/types/core/federation/directory-proof.d.ts +8 -0
  61. package/dist/types/core/federation/proof-state.d.ts +114 -0
  62. package/dist/types/core/federation/roster-proof.d.ts +7 -0
  63. package/dist/types/core/index.d.ts +10 -5
  64. package/dist/types/core/jelly-host.d.ts +1 -1
  65. package/dist/types/core/schema/common.d.ts +31 -6
  66. package/dist/types/core/schema/consent.d.ts +7 -7
  67. package/dist/types/core/schema/contact-request.d.ts +264 -0
  68. package/dist/types/core/schema/conv-frames.d.ts +1200 -0
  69. package/dist/types/core/schema/conversation.d.ts +1410 -264
  70. package/dist/types/core/schema/envelope.d.ts +83 -130
  71. package/dist/types/core/schema/errors.d.ts +3 -1
  72. package/dist/types/core/schema/guest-invitation.d.ts +51 -0
  73. package/dist/types/core/schema/index.d.ts +5 -0
  74. package/dist/types/core/schema/ipc/consent.d.ts +89 -49
  75. package/dist/types/core/schema/ipc/conversation-views.d.ts +1023 -0
  76. package/dist/types/core/schema/ipc/conversations.d.ts +3987 -0
  77. package/dist/types/core/schema/ipc/device.d.ts +4 -0
  78. package/dist/types/core/schema/ipc/jelly.d.ts +2 -105
  79. package/dist/types/core/schema/ipc/shapes.d.ts +13 -95
  80. package/dist/types/core/schema/ipc/surfaces.d.ts +25 -427
  81. package/dist/types/core/schema/ipc/tasks.d.ts +208 -0
  82. package/dist/types/core/schema/ipc.d.ts +2671 -525
  83. package/dist/types/core/schema/relay.d.ts +8 -4
  84. package/dist/types/core/testing.d.ts +1 -1
  85. package/dist/types/core/version.d.ts +1 -1
  86. package/dist/types/ipc/client.d.ts +4 -1
  87. package/dist/types/ipc/server.d.ts +10 -1
  88. package/dist/types/ipc/testing.d.ts +21 -8
  89. package/package.json +1 -1
@@ -1,34 +1,15 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { a2aTaskState, conversationOf, decodeToolCursor, DONE_STATES, encodeToolCursor, formatToolAddress, parseToolAddress, taskStateName, TOOL_REMEDIES, } from "../agent-tools.js";
3
- import { LIMITS } from "../schema/common.js";
2
+ import { a2aTaskState, DONE_STATES, taskStateName, } from "../agent-tools.js";
4
3
  import { MessagingError } from "../schema/errors.js";
5
4
  import { canTransition, isTerminal } from "../task.js";
6
- import { agentCan, conversationOfPeer, isoOf, presentAgent, presentConvEntry, presentInboxItem, presentLine, presentMail, presentSent, presentTask, presentTaskItem, presentTaskView, } from "./present.js";
7
- import { compact, delegationPrompt, EXPECT_TEXT, validatedAddress } from "./text.js";
5
+ import { agentCan, isoOf, presentAgent, presentTaskEnvelope, presentTask, presentTaskItem, presentTaskView, } from "./present.js";
6
+ import { compact, validatedAddress } from "./text.js";
8
7
  const DEFAULT_CONTACTS = 20;
9
- const DEFAULT_INBOX = 20;
10
- const DEFAULT_PAGE = 30;
11
8
  const DEFAULT_TASKS = 20;
12
9
  /** The daemon's largest `inbox.list` and `task.list` page. */
13
10
  const PAGE = 100;
14
11
  /** Pages a filtered task listing reads at most. */
15
12
  const TASK_PAGES = 10;
16
- const NOTE = {
17
- line: "A line to a person: they see it in their chats, and it wakes no agent. messaging_read {with} shows when it is delivered.",
18
- post: "Posted: everyone in the channel sees it, and it wakes no agent.",
19
- sent: "Sent. messaging_read {id} shows how far it got.",
20
- task: " Follow the task with messaging_read {id: taskId}.",
21
- held: "Their person will see a request card first; you will be told when they answer. Follow it with messaging_read {id}.",
22
- suggest: "Your person sees your line and your suggestion of that contact; only they can send a message request, and you are not told what they decide.",
23
- };
24
- /** `me`, or one's own person by name: the person's own conversation, `dm:<self>` (spec §6.8, design §5.9). */
25
- function ownPerson(address, self) {
26
- return address.kind === "me" || (address.kind === "person" && address.person === self);
27
- }
28
- /** `me` as the person it stands for, for the conversation checks and the daemon. */
29
- function asPerson(address, self) {
30
- return address.kind === "me" ? { kind: "person", person: self } : address;
31
- }
32
13
  export class Operations {
33
14
  ctx;
34
15
  constructor(ctx) {
@@ -40,15 +21,6 @@ export class Operations {
40
21
  consent(person, s) {
41
22
  return this.ctx.consent(person, s.person);
42
23
  }
43
- /** Refuses a chat conversation this client may not reach, before any daemon call. */
44
- reachable(target, field) {
45
- const { access } = this.ctx;
46
- if (target.kind === "person" && !access.dms)
47
- fail("forbidden", `${field}: conversations with persons are not available to this client`);
48
- if (target.kind === "channel" && access.channels !== undefined && !access.channels.has(target.name)) {
49
- fail("forbidden", `${field}: this client may reach only the channels its user allowed: ${[...access.channels].map((name) => `#${name}`).join(", ")}`);
50
- }
51
- }
52
24
  // ---- contacts ----
53
25
  async contacts(sessionID, input, signal) {
54
26
  const s = await this.ctx.session(sessionID, signal);
@@ -60,10 +32,11 @@ export class Operations {
60
32
  const byPerson = new Map();
61
33
  for (const entry of others)
62
34
  byPerson.set(entry.person, [...(byPerson.get(entry.person) ?? []), entry]);
63
- const unread = new Map(rooms.map((room) => [room.conversation, room.unread]));
64
35
  for (const room of rooms)
65
- if (room.kind === "dm")
66
- byPerson.set(room.conversation.slice("dm:".length), byPerson.get(room.conversation.slice("dm:".length)) ?? []);
36
+ if (room.kind === "dm" && room.dmWith) {
37
+ const person = room.dmWith.split("/")[0];
38
+ byPerson.set(person, byPerson.get(person) ?? []);
39
+ }
67
40
  const name = (text) => this.framer.wrap(text, {}, 64);
68
41
  // What each person's card says (spec §6.9, "Contact cards"), or the defaults; the daemon's, never a peer's text.
69
42
  const cards = new Map(reach.map((r) => [r.person, r]));
@@ -106,25 +79,23 @@ export class Operations {
106
79
  can: card
107
80
  ? { ...card, chat: card.chat && this.ctx.access.dms }
108
81
  : { chat: !self && this.ctx.access.dms, agentMail: can.some((c) => c.agentMail), ask: can.some((c) => c.ask === "open") ? "open" : "no", source: "default" },
109
- ...(unread.get(`dm:${person}`) ? { unread: unread.get(`dm:${person}`) } : {}),
110
82
  },
111
83
  };
112
84
  });
113
85
  const allowed = this.ctx.access.channels;
114
86
  const channels = rooms
115
- .filter((room) => room.kind === "channel" && (allowed === undefined || allowed.has(room.conversation.slice("channel:".length))))
87
+ .filter((room) => room.kind === "channel" && (allowed === undefined || allowed.has(room.cid)))
116
88
  .map((room) => {
117
- const local = room.conversation.slice("channel:".length);
89
+ const local = room.cid;
118
90
  return {
119
91
  match: [local, room.name ?? local],
120
92
  contact: {
121
- address: `#${local}`,
93
+ address: local,
122
94
  kind: "channel",
123
95
  name: name(room.name ?? local),
124
96
  org: "same",
125
97
  presence: "unknown",
126
98
  can: { chat: true, agentMail: false, ask: "no", source: "default" },
127
- ...(room.unread > 0 ? { unread: room.unread } : {}),
128
99
  },
129
100
  };
130
101
  });
@@ -149,286 +120,37 @@ export class Operations {
149
120
  ...(contacts.length > 0 ? { notice: this.framer.notice() } : {}),
150
121
  };
151
122
  }
152
- // ---- send ----
153
- /**
154
- * Checks a send without side effects: the address, the peer-turn allowance, what may go to whom,
155
- * what `replyTo` and `onBehalfOf` name. Returns what the send will do, for the permission prompt.
156
- */
157
- async planSend(sessionID, input, signal) {
158
- const s = await this.ctx.session(sessionID, signal);
159
- const to = parseToolAddress(input.to, s.person, "to");
160
- const own = ownPerson(to, s.person);
161
- if (s.peer !== undefined && !own && !((to.kind === "person" || to.kind === "agent") && to.person === s.peer)) {
162
- throw fail("guarded_turn", "to: this turn was started by a teammate's agent, so it can send only to that teammate or to your person");
163
- }
164
- const conversation = conversationOf(to, s.person);
165
- // Spec §6.9, "Message requests": an agent never sends a request; it asks its own person to.
166
- if (input.request !== undefined && !own)
167
- throw fail("invalid_request", 'request: only with to: "me"; your person sends the message request');
168
- if (to.kind !== "agent") {
169
- const target = asPerson(to, s.person);
170
- this.reachable(target, "to");
171
- if (input.ask && own)
172
- throw fail("invalid_request", "ask: a request card to your own person is not in this build; send them a line (without ask)");
173
- if (input.ask)
174
- throw fail("invalid_request", "ask: a task goes to an agent, <person>/<agent>");
175
- if (input.files)
176
- throw fail("invalid_request", "files: files go to an agent, never in a chat line");
177
- if (input.onBehalfOf)
178
- throw fail("invalid_request", "onBehalfOf: only with ask, to an agent");
179
- if (input.text.length > LIMITS.chatChars)
180
- throw fail("too_large", `text: a chat line is at most ${LIMITS.chatChars} characters`);
181
- return {
182
- kind: target.kind === "person" ? "line" : "post",
183
- to,
184
- conversation,
185
- target: target.kind === "person" ? target.person : `#${target.name}`,
186
- text: input.text,
187
- ...(input.replyTo !== undefined ? { thread: input.replyTo } : {}),
188
- ...(input.request !== undefined ? { request: input.request } : {}),
189
- };
190
- }
191
- if (input.onBehalfOf !== undefined && !input.ask)
192
- throw fail("invalid_request", "onBehalfOf: only with ask: true");
193
- const reply = input.replyTo !== undefined ? await this.replyOf(s, to, input.replyTo, input.ask === true, signal) : {};
194
- const parent = input.onBehalfOf !== undefined ? await this.delegationParent(s, input.onBehalfOf, signal) : undefined;
195
- const parts = [{ text: input.text }, ...filesParts(input.files)];
196
- return {
197
- kind: input.ask ? "task" : "mail",
198
- to,
199
- conversation,
200
- params: compact({
201
- to: formatToolAddress(to),
202
- kind: input.ask ? "task" : "message",
203
- parts,
204
- ...reply,
205
- onBehalfOf: input.onBehalfOf !== undefined ? { taskId: input.onBehalfOf } : undefined,
206
- wake: true,
207
- }),
208
- ...(input.ask ? { delegation: delegationPrompt(parent?.chain) } : {}),
209
- };
210
- }
211
- async send(sessionID, plan, idempotencyKey, signal) {
212
- const s = await this.ctx.session(sessionID, signal);
213
- const to = formatToolAddress(plan.to);
214
- if ("target" in plan) {
215
- const sent = s.link.call("msg.send", compact({ key: s.key, to: plan.target, kind: "message", parts: [{ text: plan.text }], replyTo: plan.thread, request: plan.request, idempotencyKey }), signal);
216
- // With a thread, not_found may be the thread's: it stays not_found.
217
- const { results } = await (plan.thread === undefined ? unknownAs(sent, "not_found", "to") : sent);
218
- return { id: results[0]?.envelopeId ?? "", conversation: plan.conversation, kind: plan.kind, results: [{ to, stage: "accepted" }], note: plan.request !== undefined ? NOTE.suggest : NOTE[plan.kind] };
219
- }
220
- const { results } = await unknownAs(s.link.call("msg.send", { ...plan.params, key: s.key, idempotencyKey }, signal), "no_recipients", "to");
221
- const first = results[0];
222
- const failed = first?.code !== undefined && (first.stage === "rejected" || first.stage === "expired");
223
- const held = results.some((result) => result.stage === "held");
224
- const note = failed
225
- ? `Not delivered (${first.code}). Next: ${TOOL_REMEDIES[first.code]}`
226
- : held
227
- ? NOTE.held
228
- : (first?.expect !== undefined ? EXPECT_TEXT[first.expect] : NOTE.sent) + (plan.kind === "task" ? NOTE.task : "");
229
- return {
230
- id: first?.envelopeId ?? "",
231
- conversation: plan.conversation,
232
- kind: plan.kind,
233
- ...(first?.taskId !== undefined ? { taskId: first.taskId } : {}),
234
- results: results.map((result) => compact({ to: result.to, stage: result.stage, code: result.code, expect: result.stage === "held" ? "on_consent" : result.expect })),
235
- note,
236
- };
237
- }
238
- /**
239
- * What `replyTo` names, for a send to the agent `to`: a message it sent this session (the reply
240
- * joins its conversation), or a task this session asked it to run (the text is input for it).
241
- */
242
- async replyOf(s, to, id, ask, signal) {
243
- const item = await found(s.link.call("inbox.get", { key: s.key, id }, signal));
244
- if (item) {
245
- const from = item.item.from;
246
- if (from.person !== to.person || from.agent !== to.session)
247
- throw fail("invalid_request", `replyTo: that message is from ${from.address}; send the reply there`);
248
- return compact({ replyTo: item.item.id, contextId: item.item.contextId });
249
- }
250
- const record = await found(s.link.call("task.get", { key: s.key, taskId: id }, signal));
251
- if (!record || record.key !== s.key)
252
- throw fail("not_found", "replyTo: no message or task with this id belongs to this session");
253
- if (ask)
254
- throw fail("invalid_request", "replyTo: a task request answers a message, not a task");
255
- if (record.role !== "requester")
256
- throw fail("invalid_state", "replyTo: you run this task; report on it with messaging_task_update");
257
- if (record.peer.person !== to.person || record.peer.agent !== to.session)
258
- throw fail("invalid_state", `replyTo: that task runs at ${record.peer.address}; send the input there`);
259
- return { taskId: record.task.id };
260
- }
261
- /** The task a send `onBehalfOf` delegates for: this session runs it and it is not finished (spec §6.7). */
262
- async delegationParent(s, taskId, signal) {
263
- const record = await this.ownTask(s, taskId, "onBehalfOf", signal);
264
- if (record.role !== "executor")
265
- throw fail("invalid_state", "onBehalfOf: name a task this session runs; this session asked for it");
266
- if (isTerminal(record.task.status.state))
267
- throw fail("invalid_state", `onBehalfOf: the task is already ${taskStateName(record.task.status.state)}`);
268
- return record;
269
- }
270
- // ---- inbox ----
271
- /** Lists items; previews put in front of the agent are acknowledged `handed_to_agent` (spec §8.2). */
272
- async inbox(sessionID, input, signal) {
273
- const s = await this.ctx.session(sessionID, signal);
274
- const unreadOnly = input.unread ?? true;
275
- const limit = input.limit ?? DEFAULT_INBOX;
276
- const from = input.from !== undefined ? parseToolAddress(input.from, s.person, "from") : undefined;
277
- if (from?.kind === "channel")
278
- throw fail("invalid_request", "from: channels hold no mail; read one with messaging_read {with}");
279
- const filtered = from !== undefined || s.peer !== undefined;
280
- const listed = await s.link.call("inbox.list", { key: s.key, unreadOnly, limit: filtered ? PAGE : limit }, signal);
281
- const keep = (item) => (s.peer === undefined || item.from.person === s.peer) && (from === undefined || fromMatches(item, from, s.person));
282
- const items = listed.items.filter(keep).slice(0, limit);
283
- const shown = items.filter((item) => item.stage === "delivered");
284
- for (const item of shown)
285
- this.ctx.hooks.shown(item.id);
286
- await Promise.all(shown.map((item) => this.ctx.hooks.ack(s.link, s.key, item.id)));
287
- let unread;
288
- if (unreadOnly && !filtered && listed.items.length < limit)
289
- unread = listed.items.length;
290
- else
291
- unread = (await s.link.call("inbox.list", { key: s.key, unreadOnly: true, limit: PAGE }, signal)).items.filter((item) => s.peer === undefined || item.from.person === s.peer).length;
292
- return {
293
- items: items.map((item) => presentInboxItem(this.framer, { ...item, stage: item.stage === "delivered" ? "handed_to_agent" : item.stage }, item.consent ?? this.consent(item.from.person, s))),
294
- unread,
295
- ...(items.length > 0 ? { notice: this.framer.notice() } : {}),
296
- };
297
- }
298
123
  // ---- read ----
299
124
  /**
300
- * One item: a message or task request this session received (marks it read and sends a read
301
- * receipt), a task this session asked for or runs, or something it sent (how far it got).
302
- * `assert` gets the item's conversation before anything changes or is shown.
125
+ * Read a retained task or one of its received envelopes. The peer check precedes any side effect.
303
126
  */
304
- async readItem(sessionID, id, assert, signal) {
127
+ async readItem(sessionID, id, assertPeer, signal) {
305
128
  const s = await this.ctx.session(sessionID, signal);
306
129
  const got = await found(s.link.call("inbox.get", { key: s.key, id }, signal));
307
130
  if (got) {
308
131
  scoped(s.peer, got.item.from.person);
309
- await assert(conversationOfPeer(got.item.from));
132
+ if (got.item.cid !== undefined || got.item.taskId === undefined)
133
+ throw fail("not_found", "id: not a retained task envelope");
134
+ await assertPeer(got.item.from.person);
310
135
  const result = await s.link.call("item.read", { key: s.key, id }, signal);
311
136
  if (result.kind !== "received")
312
137
  throw fail("not_found", "id: no message, task or sent item with this id belongs to this session");
313
138
  this.ctx.hooks.read(id);
314
139
  const consent = result.item.consent ?? this.consent(result.item.from.person, s);
315
- const taskId = result.item.kind === "task" ? (result.item.taskId ?? result.message.taskId) : undefined;
140
+ const taskId = result.item.taskId ?? result.message.taskId;
316
141
  const record = taskId !== undefined ? await found(s.link.call("task.get", { key: s.key, taskId }, signal)) : undefined;
317
142
  if (record)
318
143
  this.ctx.hooks.sawTask(record);
319
- return { item: presentMail(this.framer, result, record ? presentTaskView(this.framer, record, consent) : undefined), notice: this.framer.notice() };
144
+ return { item: presentTaskEnvelope(this.framer, result, record ? presentTaskView(this.framer, record, consent) : undefined), notice: this.framer.notice() };
320
145
  }
321
146
  const record = await found(s.link.call("task.get", { key: s.key, taskId: id }, signal));
322
147
  if (record && record.key === s.key) {
323
148
  scoped(s.peer, record.peer.person);
324
- await assert(conversationOfPeer(record.peer));
149
+ await assertPeer(record.peer.person);
325
150
  this.ctx.hooks.sawTask(record);
326
151
  return { item: presentTaskItem(this.framer, record, s.address, this.consent(record.peer.person, s)), notice: this.framer.notice() };
327
152
  }
328
- const item = await found(s.link.call("item.read", { key: s.key, id }, signal));
329
- const sent = item?.kind === "sent" ? item : undefined;
330
- if (sent) {
331
- scoped(s.peer, sent.to.slice(0, sent.to.indexOf("/")));
332
- const item = presentSent(sent, s.address);
333
- await assert(item.conversation);
334
- return { item };
335
- }
336
- throw fail("not_found", "id: no message, task or sent item with this id belongs to this session");
337
- }
338
- /**
339
- * A page of one conversation, oldest first: the chat lines with a person or in a channel, or the
340
- * mail and tasks with an agent. Mail previews shown here count as handed to the agent.
341
- */
342
- async readConversation(sessionID, input, assert, signal) {
343
- const s = await this.ctx.session(sessionID, signal);
344
- const named = parseToolAddress(input.with, s.person, "with");
345
- const target = asPerson(named, s.person);
346
- if (s.peer !== undefined && (target.kind === "channel" || target.person !== s.peer))
347
- scoped(s.peer, "");
348
- if (input.before !== undefined && input.after !== undefined)
349
- throw fail("invalid_request", "before, after: pass one, not both");
350
- if (target.kind !== "agent")
351
- this.reachable(target, "with");
352
- const conversation = conversationOf(target, s.person);
353
- const cursor = input.before ?? input.after;
354
- const place = cursor !== undefined ? decodeToolCursor(cursor, input.before !== undefined ? "before" : "after") : undefined;
355
- const limit = input.limit ?? DEFAULT_PAGE;
356
- await assert(conversation);
357
- if (target.kind !== "agent") {
358
- if (place?.kind === "conv")
359
- throw fail("invalid_request", "before, after: that cursor is from another conversation");
360
- const room = target.kind === "person" ? `dm:${target.person}` : `channel:${target.name}`;
361
- const seq = place?.seq;
362
- const page = await unknownAs(s.link.call("conv.read", compact({ conversation: room, limit, before: input.before !== undefined ? seq : undefined, after: input.after !== undefined ? seq : undefined }), signal), "not_found", "with");
363
- if (page.kind !== "lines")
364
- throw fail("internal", "with: the daemon answered a DM or channel page with agent entries");
365
- const { lines: messages, more } = page;
366
- return this.page(conversation, messages, (line) => presentLine(this.framer, line, target.kind === "person" ? "line" : "post", conversation), (line) => ({ kind: "seq", seq: line.seq }), more);
367
- }
368
- if (place?.kind === "seq")
369
- throw fail("invalid_request", "before, after: that cursor is from another conversation");
370
- const at = place?.kind === "conv" ? { at: place.at, id: place.id } : undefined;
371
- const page = await s.link.call("conv.read", compact({ key: s.key, conversation: `agent:${target.person}/${target.session}`, limit, before: input.before !== undefined ? at : undefined, after: input.after !== undefined ? at : undefined }), signal);
372
- if (page.kind !== "agent")
373
- throw fail("internal", "with: the daemon answered an agent page with lines");
374
- const { entries, more } = page;
375
- const shown = entries.filter((entry) => entry.kind === "mail" && entry.direction === "in" && entry.stage === "delivered");
376
- for (const entry of shown)
377
- this.ctx.hooks.shown(entry.id);
378
- await Promise.all(shown.map((entry) => this.ctx.hooks.ack(s.link, s.key, entry.id)));
379
- return this.page(conversation, entries, (entry) => presentConvEntry(this.framer, entry, s.address), (entry) => ({ kind: "conv", at: entry.at, id: entry.id }), more);
380
- }
381
- page(conversation, rows, present, place, more) {
382
- const first = rows[0];
383
- const last = rows.at(-1);
384
- return {
385
- conversation,
386
- entries: rows.map(present),
387
- ...(first !== undefined ? { before: encodeToolCursor(place(first)) } : {}),
388
- ...(last !== undefined ? { after: encodeToolCursor(place(last)) } : {}),
389
- more,
390
- ...(rows.length > 0 ? { notice: this.framer.notice() } : {}),
391
- };
392
- }
393
- // ---- search ----
394
- /** Chat lines with persons and in channels. Each conversation in the hits is asserted; `allowed` false drops it. */
395
- async search(sessionID, input, allowed, signal) {
396
- const s = await this.ctx.session(sessionID, signal);
397
- let room;
398
- if (input.with !== undefined) {
399
- const named = parseToolAddress(input.with, s.person, "with");
400
- if (named.kind === "agent")
401
- throw fail("invalid_request", "with: agent mail is not searched; page it with messaging_read {with}");
402
- const target = asPerson(named, s.person);
403
- this.reachable(target, "with");
404
- room = target.kind === "person" ? `dm:${target.person}` : `channel:${target.name}`;
405
- }
406
- if (s.peer !== undefined)
407
- room = room === undefined || room === `dm:${s.peer}` ? `dm:${s.peer}` : (scoped(s.peer, ""), room);
408
- const channels = this.ctx.access.channels;
409
- // A client limited to some channels searches each of them: an unscoped search would read the rest.
410
- const rooms = room === undefined && channels !== undefined ? [...channels].map((name) => `channel:${name}`) : [room];
411
- const limit = input.limit ?? 20;
412
- const search = (one) => s.link.call("search", compact({ query: input.query, conversation: one, limit: input.limit }), signal);
413
- // A named conversation the daemon does not know is unknown_address; an allowed channel this
414
- // person is not in (or no longer) has nothing to find.
415
- const pages = await Promise.all(rooms.map((one) => (room !== undefined ? unknownAs(search(one), "not_found", "with") : found(search(one)).then((page) => page ?? { hits: [] }))));
416
- const messages = pages
417
- .flatMap((page) => page.hits)
418
- .filter((line) => (line.conversation.startsWith("dm:") ? this.ctx.access.dms : channels === undefined || channels.has(line.conversation.slice("channel:".length))))
419
- .sort((a, b) => b.at - a.at)
420
- .slice(0, limit);
421
- const verdicts = new Map();
422
- for (const conversation of new Set(messages.map((line) => roomConversation(line.conversation))))
423
- verdicts.set(conversation, await allowed(conversation));
424
- const hits = messages
425
- .filter((line) => verdicts.get(roomConversation(line.conversation)))
426
- .map((line) => {
427
- const conversation = roomConversation(line.conversation);
428
- const from = line.agent === undefined ? line.person : `${line.person}/${line.agent}`;
429
- return { id: line.id, conversation, from, at: new Date(line.at).toISOString(), snippet: this.framer.wrap(line.body, { from, conversation, id: line.id }, 2_000) };
430
- });
431
- return { hits, ...(hits.length > 0 ? { notice: this.framer.notice() } : {}) };
153
+ throw fail("not_found", "id: no task or task envelope with this id belongs to this session");
432
154
  }
433
155
  // ---- tasks ----
434
156
  async tasks(sessionID, input, signal) {
@@ -524,21 +246,6 @@ export class Operations {
524
246
  function fail(code, message) {
525
247
  throw new MessagingError(code, message);
526
248
  }
527
- /**
528
- * A daemon call that names an address: the daemon's code for a person, agent or channel it does not
529
- * know (`not_found` for a person or channel, `no_recipients` for an agent, from `msg.send`) is `unknown_address`
530
- * to the agent (spec §11), naming the field.
531
- */
532
- async function unknownAs(call, code, field) {
533
- try {
534
- return await call;
535
- }
536
- catch (error) {
537
- if (error instanceof MessagingError && error.code === code)
538
- fail("unknown_address", `${field}: no person, agent or channel by that name here`);
539
- throw error;
540
- }
541
- }
542
249
  /** The result, or undefined for `not_found`. */
543
250
  async function found(call) {
544
251
  try {
@@ -555,21 +262,6 @@ function scoped(peer, person) {
555
262
  if (peer !== undefined && person !== peer)
556
263
  throw fail("not_found", "nothing with this id or address concerns the teammate who started this turn");
557
264
  }
558
- function fromMatches(item, from, self) {
559
- switch (from.kind) {
560
- case "me":
561
- return item.from.person === self;
562
- case "person":
563
- return item.from.person === from.person;
564
- case "agent":
565
- return item.from.person === from.person && item.from.agent === from.session && (from.label === undefined || item.from.label === from.label);
566
- case "channel":
567
- return false;
568
- }
569
- }
570
- function roomConversation(room) {
571
- return room.startsWith("dm:") ? `person:${room.slice("dm:".length)}` : room;
572
- }
573
265
  /** Media types sent as text parts: their content stays readable to an A2A client and to the recipient's agent. */
574
266
  const TEXTUAL = /^(?:text\/[\w.+-]+|application\/(?:json|xml|yaml|x-yaml|javascript|x-sh|x-diff|x-patch|[\w.-]+\+(?:json|xml)))(?:\s*;.*)?$/i;
575
267
  /**
@@ -1,8 +1,10 @@
1
1
  import { taskStateName } from "../agent-tools.js";
2
+ import { MessagingError } from "../schema/errors.js";
2
3
  import { createFence, PEER_FENCE_TAG, provenanceOf } from "../peer-text.js";
4
+ import { LIMITS } from "../schema/common.js";
3
5
  import { chainPath, EXPECT_TEXT, isSafeId, messageAttributes } from "./text.js";
4
6
  /** Per-part cap in tool outputs; the protocol caps a text part at 65,536 chars. */
5
- const PART_CHARS = 32_768;
7
+ const PART_CHARS = LIMITS.toolTextChars;
6
8
  /** Task history shown by `read`: the most recent messages. */
7
9
  const HISTORY_MESSAGES = 20;
8
10
  const ARTIFACTS = 20;
@@ -96,32 +98,31 @@ export function agentCan(entry) {
96
98
  }
97
99
  // ---- inbox ----
98
100
  export function presentInboxItem(framer, item, consent) {
101
+ if (item.cid === undefined && item.taskId === undefined)
102
+ throw new MessagingError("invalid_request", "inbox item has no conversation or task source");
99
103
  return {
100
104
  id: item.id,
101
- conversation: conversationOfPeer(item.from),
102
- from: item.from.address,
103
- kind: item.kind === "task" ? "task" : "mail",
105
+ source: item.cid !== undefined ? { kind: "conv", cid: item.cid, event: item.eventId } : { kind: "task", taskId: item.taskId },
106
+ from: framer.wrap(item.from.address, { from: "sender" }),
104
107
  preview: framer.wrap(item.preview, messageAttributes(item)),
105
- at: isoOf(item.receivedAt),
108
+ at: item.receivedAt,
106
109
  stage: item.stage,
107
110
  consent,
108
- ...(item.taskId !== undefined ? { taskId: item.taskId } : {}),
109
111
  ...(item.chain !== undefined ? { chain: presentChain(framer, item.chain) } : {}),
110
112
  };
111
113
  }
112
- /** The agent conversation with a peer: `agent:<person>/<agent>`, or `person:<person>` for the person's own. */
113
- export function conversationOfPeer(peer) {
114
- return peer.agent === undefined ? `person:${peer.person}` : `agent:${peer.person}/${peer.agent}`;
115
- }
116
114
  // ---- read ----
117
- export function presentMail(framer, result, task) {
115
+ export function presentTaskEnvelope(framer, result, task) {
118
116
  const { item, message } = result;
117
+ const taskId = item.taskId ?? message.taskId;
118
+ if (item.cid !== undefined || taskId === undefined)
119
+ throw new MessagingError("invalid_request", "envelope does not name a task");
119
120
  const attributes = messageAttributes(item, message);
120
121
  const provenance = provenanceOf(message.metadata, item.id);
121
122
  return {
122
123
  id: item.id,
123
- conversation: conversationOfPeer(item.from),
124
- kind: item.kind === "task" ? "task" : "mail",
124
+ source: { kind: "task", taskId },
125
+ kind: item.kind === "task" ? "task" : "continuation",
125
126
  from: provenance?.from ?? item.from.address,
126
127
  at: isoOf(item.receivedAt),
127
128
  parts: message.parts.map((part) => presentPart(framer, part, "peer", attributes)),
@@ -137,7 +138,7 @@ export function presentTaskItem(framer, record, self, consent) {
137
138
  const author = request ? historyAuthor(record, request) : "peer";
138
139
  return {
139
140
  id: record.task.id,
140
- conversation: conversationOfPeer(record.peer),
141
+ source: { kind: "task", taskId: record.task.id },
141
142
  kind: "task",
142
143
  from: author === "ours" ? self : record.peer.address,
143
144
  at: isoOf(record.createdAt),
@@ -146,47 +147,6 @@ export function presentTaskItem(framer, record, self, consent) {
146
147
  task: presentTaskView(framer, record, consent),
147
148
  };
148
149
  }
149
- /** Something this session sent: no copy is kept, so only how far it got. */
150
- export function presentSent(result, self) {
151
- const [person = "", rest = ""] = result.to.split("/");
152
- const session = rest.split("@")[0];
153
- return {
154
- id: result.envelopeId,
155
- conversation: `agent:${person}/${session}`,
156
- kind: "sent",
157
- from: self,
158
- at: isoOf(result.history[0]?.at ?? 0),
159
- parts: [],
160
- delivery: result.history.map((entry) => ({ to: result.to, stage: entry.stage, at: isoOf(entry.at), ...(entry.code !== undefined ? { code: entry.code } : {}) })),
161
- };
162
- }
163
- // ---- conversation pages ----
164
- export function presentLine(framer, line, kind, conversation) {
165
- const from = line.agent === undefined ? line.person : `${line.person}/${line.agent}`;
166
- return {
167
- id: line.id,
168
- from,
169
- kind: line.notice ? "notice" : kind,
170
- at: isoOf(line.at),
171
- text: framer.wrap(line.body, { from, conversation, id: line.id }, 8_000),
172
- ...(line.thread !== undefined ? { thread: line.thread } : {}),
173
- ...(line.state !== undefined ? { stage: line.state } : {}),
174
- };
175
- }
176
- export function presentConvEntry(framer, entry, self) {
177
- const author = entry.direction === "out" ? "ours" : "peer";
178
- const from = author === "ours" ? self : entry.peer;
179
- return {
180
- id: entry.id,
181
- from,
182
- kind: entry.kind,
183
- at: isoOf(entry.at),
184
- ...(entry.text !== undefined ? { author, text: shown(framer, author, { from, id: entry.id })(entry.text) } : {}),
185
- ...(entry.taskId !== undefined ? { taskId: entry.taskId } : {}),
186
- ...(entry.stage !== undefined ? { stage: entry.stage } : {}),
187
- ...(entry.state !== undefined ? { state: taskStateName(entry.state) } : {}),
188
- };
189
- }
190
150
  // ---- tasks ----
191
151
  function textOf(message) {
192
152
  if (!message)