@joe-broadhead/messaging 0.7.1 → 0.9.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 (94) hide show
  1. package/CHANGELOG.md +364 -2
  2. package/dist/bin/messaging-mcp.js +5498 -4338
  3. package/dist/bin/messaging-relay.js +8626 -5540
  4. package/dist/bin/messaging.js +4492 -3158
  5. package/dist/bin/messagingd.js +19672 -12412
  6. package/dist/lib/modules/core/agent/operations.js +19 -327
  7. package/dist/lib/modules/core/agent/present.js +20 -55
  8. package/dist/lib/modules/core/agent-tools.js +35 -139
  9. package/dist/lib/modules/core/consent.js +54 -14
  10. package/dist/lib/modules/core/contact-card.js +3 -3
  11. package/dist/lib/modules/core/conversation/event.js +29 -4
  12. package/dist/lib/modules/core/conversation/guest-invitation.js +42 -0
  13. package/dist/lib/modules/core/conversation/log.js +287 -65
  14. package/dist/lib/modules/core/conversation/request-log.js +158 -0
  15. package/dist/lib/modules/core/conversation/request-seal.js +41 -0
  16. package/dist/lib/modules/core/conversation/requests.js +87 -0
  17. package/dist/lib/modules/core/conversation/seal.js +90 -37
  18. package/dist/lib/modules/core/conversation-agent-tools.js +69 -0
  19. package/dist/lib/modules/core/federation/directory-proof.js +83 -0
  20. package/dist/lib/modules/core/federation/proof-state.js +167 -0
  21. package/dist/lib/modules/core/federation/roster-proof.js +74 -0
  22. package/dist/lib/modules/core/index.js +9 -4
  23. package/dist/lib/modules/core/jelly-host.js +8 -5
  24. package/dist/lib/modules/core/schema/common.js +33 -5
  25. package/dist/lib/modules/core/schema/consent.js +23 -5
  26. package/dist/lib/modules/core/schema/contact-request.js +22 -0
  27. package/dist/lib/modules/core/schema/conv-frames.js +101 -0
  28. package/dist/lib/modules/core/schema/conversation.js +67 -33
  29. package/dist/lib/modules/core/schema/directory-frames.js +8 -3
  30. package/dist/lib/modules/core/schema/envelope.js +8 -24
  31. package/dist/lib/modules/core/schema/errors.js +7 -5
  32. package/dist/lib/modules/core/schema/guest-invitation.js +13 -0
  33. package/dist/lib/modules/core/schema/index.js +5 -0
  34. package/dist/lib/modules/core/schema/ipc/consent.js +97 -18
  35. package/dist/lib/modules/core/schema/ipc/conversation-views.js +87 -0
  36. package/dist/lib/modules/core/schema/ipc/conversations.js +132 -0
  37. package/dist/lib/modules/core/schema/ipc/device.js +9 -0
  38. package/dist/lib/modules/core/schema/ipc/jelly.js +17 -45
  39. package/dist/lib/modules/core/schema/ipc/shapes.js +74 -73
  40. package/dist/lib/modules/core/schema/ipc/surfaces.js +12 -195
  41. package/dist/lib/modules/core/schema/ipc/tasks.js +31 -0
  42. package/dist/lib/modules/core/schema/ipc.js +68 -46
  43. package/dist/lib/modules/core/testing.js +1 -1
  44. package/dist/lib/modules/core/version.js +1 -1
  45. package/dist/lib/modules/ipc/client.js +10 -2
  46. package/dist/lib/modules/ipc/server.js +14 -5
  47. package/dist/lib/modules/ipc/testing.js +13 -23
  48. package/dist/spec/messaging-v1.md +854 -445
  49. package/dist/third-party-notices.md +1 -1
  50. package/dist/third-party.json +1 -1
  51. package/dist/types/core/agent/operations.d.ts +5 -52
  52. package/dist/types/core/agent/present.d.ts +6 -21
  53. package/dist/types/core/agent-tools.d.ts +295 -442
  54. package/dist/types/core/consent.d.ts +30 -7
  55. package/dist/types/core/contact-card.d.ts +3 -3
  56. package/dist/types/core/conversation/event.d.ts +6 -6
  57. package/dist/types/core/conversation/guest-invitation.d.ts +8 -0
  58. package/dist/types/core/conversation/log.d.ts +28 -13
  59. package/dist/types/core/conversation/request-log.d.ts +24 -0
  60. package/dist/types/core/conversation/request-seal.d.ts +6 -0
  61. package/dist/types/core/conversation/requests.d.ts +14 -0
  62. package/dist/types/core/conversation/seal.d.ts +32 -9
  63. package/dist/types/core/conversation-agent-tools.d.ts +939 -0
  64. package/dist/types/core/federation/directory-proof.d.ts +8 -0
  65. package/dist/types/core/federation/proof-state.d.ts +114 -0
  66. package/dist/types/core/federation/roster-proof.d.ts +7 -0
  67. package/dist/types/core/index.d.ts +11 -6
  68. package/dist/types/core/jelly-host.d.ts +1 -1
  69. package/dist/types/core/schema/common.d.ts +33 -6
  70. package/dist/types/core/schema/consent.d.ts +29 -8
  71. package/dist/types/core/schema/contact-request.d.ts +264 -0
  72. package/dist/types/core/schema/conv-frames.d.ts +1204 -0
  73. package/dist/types/core/schema/conversation.d.ts +1410 -264
  74. package/dist/types/core/schema/directory-frames.d.ts +8 -3
  75. package/dist/types/core/schema/envelope.d.ts +85 -130
  76. package/dist/types/core/schema/errors.d.ts +5 -1
  77. package/dist/types/core/schema/guest-invitation.d.ts +51 -0
  78. package/dist/types/core/schema/index.d.ts +5 -0
  79. package/dist/types/core/schema/ipc/consent.d.ts +464 -62
  80. package/dist/types/core/schema/ipc/conversation-views.d.ts +1199 -0
  81. package/dist/types/core/schema/ipc/conversations.d.ts +4355 -0
  82. package/dist/types/core/schema/ipc/device.d.ts +10 -0
  83. package/dist/types/core/schema/ipc/jelly.d.ts +16 -111
  84. package/dist/types/core/schema/ipc/shapes.d.ts +211 -95
  85. package/dist/types/core/schema/ipc/surfaces.d.ts +26 -427
  86. package/dist/types/core/schema/ipc/tasks.d.ts +210 -0
  87. package/dist/types/core/schema/ipc.d.ts +3859 -890
  88. package/dist/types/core/schema/relay.d.ts +12 -4
  89. package/dist/types/core/testing.d.ts +1 -1
  90. package/dist/types/core/version.d.ts +1 -1
  91. package/dist/types/ipc/client.d.ts +4 -1
  92. package/dist/types/ipc/server.d.ts +16 -1
  93. package/dist/types/ipc/testing.d.ts +21 -8
  94. 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,11 @@
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";
5
+ import { TASK_REFUSAL_CHARS, TASK_SIGN_IN_TOOL_CHARS } from "../schema/ipc.js";
3
6
  import { chainPath, EXPECT_TEXT, isSafeId, messageAttributes } from "./text.js";
4
7
  /** Per-part cap in tool outputs; the protocol caps a text part at 65,536 chars. */
5
- const PART_CHARS = 32_768;
8
+ const PART_CHARS = LIMITS.toolTextChars;
6
9
  /** Task history shown by `read`: the most recent messages. */
7
10
  const HISTORY_MESSAGES = 20;
8
11
  const ARTIFACTS = 20;
@@ -96,32 +99,31 @@ export function agentCan(entry) {
96
99
  }
97
100
  // ---- inbox ----
98
101
  export function presentInboxItem(framer, item, consent) {
102
+ if (item.cid === undefined && item.taskId === undefined)
103
+ throw new MessagingError("invalid_request", "inbox item has no conversation or task source");
99
104
  return {
100
105
  id: item.id,
101
- conversation: conversationOfPeer(item.from),
102
- from: item.from.address,
103
- kind: item.kind === "task" ? "task" : "mail",
106
+ source: item.cid !== undefined ? { kind: "conv", cid: item.cid, event: item.eventId } : { kind: "task", taskId: item.taskId },
107
+ from: framer.wrap(item.from.address, { from: "sender" }),
104
108
  preview: framer.wrap(item.preview, messageAttributes(item)),
105
- at: isoOf(item.receivedAt),
109
+ at: item.receivedAt,
106
110
  stage: item.stage,
107
111
  consent,
108
- ...(item.taskId !== undefined ? { taskId: item.taskId } : {}),
109
112
  ...(item.chain !== undefined ? { chain: presentChain(framer, item.chain) } : {}),
110
113
  };
111
114
  }
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
115
  // ---- read ----
117
- export function presentMail(framer, result, task) {
116
+ export function presentTaskEnvelope(framer, result, task) {
118
117
  const { item, message } = result;
118
+ const taskId = item.taskId ?? message.taskId;
119
+ if (item.cid !== undefined || taskId === undefined)
120
+ throw new MessagingError("invalid_request", "envelope does not name a task");
119
121
  const attributes = messageAttributes(item, message);
120
122
  const provenance = provenanceOf(message.metadata, item.id);
121
123
  return {
122
124
  id: item.id,
123
- conversation: conversationOfPeer(item.from),
124
- kind: item.kind === "task" ? "task" : "mail",
125
+ source: { kind: "task", taskId },
126
+ kind: item.kind === "task" ? "task" : "continuation",
125
127
  from: provenance?.from ?? item.from.address,
126
128
  at: isoOf(item.receivedAt),
127
129
  parts: message.parts.map((part) => presentPart(framer, part, "peer", attributes)),
@@ -137,7 +139,7 @@ export function presentTaskItem(framer, record, self, consent) {
137
139
  const author = request ? historyAuthor(record, request) : "peer";
138
140
  return {
139
141
  id: record.task.id,
140
- conversation: conversationOfPeer(record.peer),
142
+ source: { kind: "task", taskId: record.task.id },
141
143
  kind: "task",
142
144
  from: author === "ours" ? self : record.peer.address,
143
145
  at: isoOf(record.createdAt),
@@ -146,47 +148,6 @@ export function presentTaskItem(framer, record, self, consent) {
146
148
  task: presentTaskView(framer, record, consent),
147
149
  };
148
150
  }
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
151
  // ---- tasks ----
191
152
  function textOf(message) {
192
153
  if (!message)
@@ -211,6 +172,10 @@ export function presentTask(framer, record, consent) {
211
172
  state: taskStateName(record.task.status.state),
212
173
  ...(title !== undefined ? { title: shown(framer, titleAuthor, peerAttributes(record))(title, TITLE_CHARS), titleAuthor } : {}),
213
174
  ...(status !== undefined ? { status: shown(framer, statusAuthor, peerAttributes(record))(status, PART_CHARS), statusAuthor } : {}),
175
+ ...(record.signIn !== undefined
176
+ ? { signIn: { ...(record.signIn.tool !== undefined ? { tool: shown(framer, statusAuthor, peerAttributes(record))(record.signIn.tool, TASK_SIGN_IN_TOOL_CHARS) } : {}), by: record.signIn.by } }
177
+ : {}),
178
+ ...(record.refusal !== undefined ? { refusal: { reason: shown(framer, statusAuthor, peerAttributes(record))(record.refusal.reason, TASK_REFUSAL_CHARS) } } : {}),
214
179
  updatedAt: isoOf(record.updatedAt),
215
180
  ...(consent !== undefined && record.role === "executor" ? { consent } : {}),
216
181
  ...(record.chain !== undefined ? { chain: presentChain(framer, record.chain) } : {}),