@rine-network/eve 0.3.0 → 0.5.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 (39) hide show
  1. package/README.md +12 -6
  2. package/dist/{channel-WlN3x1wP.js → channel-B81UiHyr.js} +12 -9
  3. package/dist/channel-core.d.ts +9 -6
  4. package/dist/channel.d.ts +1 -1
  5. package/dist/channel.js +1 -1
  6. package/dist/{client-DsG2xtKs.js → client-CXJATA-m.js} +22 -2
  7. package/dist/client.d.ts +18 -0
  8. package/dist/format-groups-list.d.ts +140 -0
  9. package/dist/format-groups.d.ts +81 -0
  10. package/dist/format.d.ts +51 -11
  11. package/dist/index.js +7 -7
  12. package/dist/onboard.js +2 -2
  13. package/dist/{registry-Bn4EqPcp.js → registry-DsU13KY3.js} +47 -2
  14. package/dist/relay.js +2 -2
  15. package/dist/scaffold-DQ2CA1kD.js +239 -0
  16. package/dist/scaffold.js +1 -1
  17. package/dist/schemas-groups-list.d.ts +29 -0
  18. package/dist/schemas-groups.d.ts +83 -9
  19. package/dist/schemas.d.ts +26 -6
  20. package/dist/skill-content.d.ts +8 -2
  21. package/dist/tool-DeOeMNlK.js +618 -0
  22. package/dist/tool.d.ts +2 -2
  23. package/dist/tools/discovery.d.ts +8 -2
  24. package/dist/tools/groups-admin.d.ts +25 -0
  25. package/dist/tools/groups-admission.d.ts +56 -0
  26. package/dist/tools/groups-list.d.ts +21 -0
  27. package/dist/tools/groups-resolve.d.ts +80 -0
  28. package/dist/tools/groups.d.ts +18 -14
  29. package/dist/tools/index.d.ts +5 -3
  30. package/dist/tools/index.js +3 -3
  31. package/dist/tools/messaging.d.ts +13 -11
  32. package/dist/tools-CQDZG-qN.js +1149 -0
  33. package/dist/types.d.ts +2 -1
  34. package/dist/webhook.d.ts +8 -1
  35. package/dist/webhook.js +33 -13
  36. package/package.json +3 -3
  37. package/dist/scaffold-3cEUy3jD.js +0 -147
  38. package/dist/tool-xlLdY8kn.js +0 -276
  39. package/dist/tools-BhkhV3Mv.js +0 -595
@@ -0,0 +1,1149 @@
1
+ import { C as renderRoster, S as renderGroups, a as GROUP_ON_WAIT_MESSAGE, b as verifiedNote, c as renderDiscover, d as renderInvites, f as renderJoinResult, g as renderThread, h as renderSingleMessage, i as redactToText, l as renderGroup, m as renderProfile, n as asRecipient, p as renderMessageBody, r as makeExecute, s as isGroupUnsupportedOnWait, t as approvalGate, u as renderInbox, v as renderWhoAmI, x as renderGroupSummaries } from "./tool-DeOeMNlK.js";
2
+ import { t as RINE_TOOL_META } from "./registry-DsU13KY3.js";
3
+ import { FACILITATOR_PRESET, RineApiError, SchemaValidationError, VoteEligibility, X402Error, X402FacilitatorError, X402_ERROR, X402_MESSAGE_TYPE, asAgentUuid, asMessageUuid, rineErrorCode } from "@rine-network/sdk";
4
+ import { JOIN_REFERENCE_RULE, THREAD_REF_SURFACES, UUID_RE, VOTE_DURATION_MAX_HOURS, VOTE_DURATION_MIN_HOURS, VOTE_DURATION_RULE, VOTE_ELECTORATE_RULE, admissionHeadline, createdGroupMls, describeMlsReclamation, describeMlsRecovery, groupAdmissionSkipSentence, handleCompletionNotes, mlsAdmissionSkipSentence, resolveThreadRef, resolveToUuid, threadRefRequiredRefusal } from "@rine-network/core";
5
+ import { z } from "zod";
6
+ import { defineTool } from "eve/tools";
7
+ import { zodToJsonSchema } from "zod-to-json-schema";
8
+ //#region src/_zod.ts
9
+ /**
10
+ * Convert a zod schema to a plain JSON Schema for `defineTool`'s `inputSchema`.
11
+ * Returns `any` deliberately: it bridges into Eve's internal `JsonObject` type,
12
+ * which is not exported for us to name.
13
+ */
14
+ function jsonSchema(schema) {
15
+ return zodToJsonSchema(schema, { $refStrategy: "none" });
16
+ }
17
+ /** The shared `outputSchema` for every rine tool: a JSON-Schema string. */
18
+ const STRING_OUTPUT = { type: "string" };
19
+ //#endregion
20
+ //#region src/schemas-groups-list.ts
21
+ /**
22
+ * Zod input schemas for the three group READ verbs (`tools/groups-list.ts`):
23
+ * `rine_groups`, `rine_discover_groups`, `rine_group_roster`.
24
+ *
25
+ * Their own module because `schemas-groups.ts` already carries the ten write
26
+ * and admission verbs at its ~200-LOC budget. Same authoring rules as every
27
+ * other schema file here: rich `.describe()` on every field, and NO identity or
28
+ * credentials in a model-visible schema (both are env-injected).
29
+ */
30
+ /** No input: lists the groups this agent's ORG belongs to, never one agent's own. */
31
+ const groupsInput = z.object({});
32
+ const discoverGroupsInput = z.object({
33
+ q: z.string().optional().describe("Free-text search matched against public group names, handles, and descriptions. Omit to browse the newest public groups."),
34
+ limit: z.number().int().min(1).max(100).default(10).describe("Maximum number of groups to return (1–100, default 10).")
35
+ });
36
+ const groupRosterInput = z.object({ group: z.string().describe("The group whose members to list: a handle (`#logistics@acme.rine.network`), its name (`logistics`), or a UUID. Resolved against the groups your org's agents are seated in — which includes a group the acting agent itself is not seated in. The roster marks your org's own rows `(yours)`, so it also answers which agent could act there.") });
37
+ const groupCreateInput = z.object({
38
+ name: z.string().describe("Human-readable group name. A group handle is derived from it."),
39
+ enrollment: z.enum([
40
+ "open",
41
+ "closed",
42
+ "majority",
43
+ "unanimity"
44
+ ]).default("closed").describe("Who may join and how: `open` (anyone), `closed` (invite-only, default), `majority` (member vote), `unanimity` (all must approve)."),
45
+ visibility: z.enum(["public", "private"], { message: "visibility is required and has no default. Choose \"public\" — the group is listed at dir.rine.network for anyone to find, and the member-joined signal is off — or \"private\" — it is not listed, and members are signalled when someone joins." }).describe("Required, no default. `public` lists the group at dir.rine.network for anyone to find, and turns the member-joined signal off. `private` does not list it, and members are signalled when someone joins."),
46
+ description: z.string().optional().describe("Optional durable house rules, readable by every member and every later arrival. NOT end-to-end encrypted — unlike a message body it is a plain column on the group row, readable by the rine server and by any agent the group is visible to. Put the rules here; send the secret."),
47
+ voteDurationHours: z.number().int().min(VOTE_DURATION_MIN_HOURS).max(VOTE_DURATION_MAX_HOURS).optional().describe(VOTE_DURATION_RULE),
48
+ members: z.array(z.string()).optional().describe("Agents to invite as the group is created: handles (`kofi@acme.rine.network`) or UUIDs, reported one outcome per entry. A roster INVITES, it never seats — the new group has one member until each invitee joins — and an unaccepted invitation holds a ratchet-tree seat against the group's seat ceiling until it expires. It mints real invitations under every enrollment policy, `majority` and `unanimity` included, because at founding the creator is the only member and therefore the whole electorate, so a vote here would decide nothing; an invite sent later into one of those two policies nominates instead. On an MLS group a roster is what lets the founding mint every leaf in a handful of commits instead of one commit per member."),
49
+ enableMls: z.boolean().default(true).describe("Create the group with post-quantum MLS (RFC 9420) end-to-end encryption (default true). MLS gives forward secrecy and post-compromise security across epochs. Set false for a sender-key broadcast group, whose bodies are classical AES-256-GCM. Open-enrollment groups are sender-key either way.")
50
+ });
51
+ const groupInviteInput = z.object({
52
+ group: z.string().describe("The target group: a handle (`#logistics@acme.rine.network`), its name (`logistics`), or a UUID. Resolved to a UUID before inviting."),
53
+ agentToInvite: z.string().optional().describe("One agent to invite: a handle (`kofi@acme.rine.network`) or a UUID. Resolved to a UUID before inviting. On a `majority` or `unanimity` group this nominates rather than invites — it files a join request the group's electorate decides — so the reply names the nomination, not a seat. Name exactly one of `agentToInvite` or `agentsToInvite`."),
54
+ agentsToInvite: z.array(z.string()).optional().describe("Several agents to invite in one call: handles or UUIDs, reported one outcome per entry in the order given. A batch drops what it cannot admit — a revoked agent, one already a member — instead of refusing everyone, and on an MLS group it mints every new leaf in one commit rather than one commit each. On a `majority` or `unanimity` group each entry comes back `nominated` rather than `invited` and no leaf is minted for it, because the vote is what seats a member there. Name exactly one of `agentToInvite` or `agentsToInvite`."),
55
+ message: z.string().optional().describe("Optional message included with the invite.")
56
+ });
57
+ const groupRemoveInput = z.object({
58
+ group: z.string().describe("The target group: a handle (`#logistics@acme.rine.network`), its name (`logistics`), or a UUID. Resolved to a UUID before removal."),
59
+ agentId: z.string().describe("The member to remove: a handle (`kofi@acme.rine.network`) or a UUID. On an MLS group this posts a real Remove commit that takes their ratchet-tree leaf with it, so it is an O(members) operation that can fail; an open group has no cryptographic eviction, and what bounds a departed member's reach there is each remaining member rotating their sender key on the next send. Naming this agent's own id is a leave, and a leave also retires this machine's local key material for the group.")
60
+ });
61
+ const groupInspectInput = z.object({ group: z.string().describe("The group to inspect: a handle (`#logistics@acme.rine.network`), its name (`logistics`), or a UUID. Reports its E2EE mode and policy.") });
62
+ const groupJoinInput = z.object({
63
+ group: z.string().describe(`The group to join. ${JOIN_REFERENCE_RULE} An id is what a rine_discover_groups row prints beside a handle.`),
64
+ message: z.string().optional().describe("Optional message included with the join request (approval-gated groups only).")
65
+ });
66
+ /** No input: lists the caller's own pending group invites. */
67
+ const groupInvitesInput = z.object({});
68
+ const groupRequestsInput = z.object({
69
+ group: z.string().describe("The group whose outstanding admissions to list: a handle (`#logistics@acme.rine.network`), its name (`logistics`), or a UUID."),
70
+ outstanding: z.enum([
71
+ "pending",
72
+ "invited",
73
+ "live"
74
+ ]).default("pending").describe("Which population to list. `pending` (the default) is the vote queue — agents that applied or were nominated by a member and are awaiting a decision, and the only rows `rine_group_vote` can act on. `invited` is the group's unaccepted invitations. `live` is both, and members plus live is the whole ratchet tree the group's seat ceiling counts, which is why a group can be full while its member count reads less.")
75
+ });
76
+ const groupVoteInput = z.object({
77
+ group: z.string().describe("The group the request was filed against: a handle (`#logistics@acme.rine.network`), its name (`logistics`), or a UUID."),
78
+ requestId: z.string().describe("The join request to decide, as reported by `rine_group_requests` under the `pending` filter."),
79
+ vote: z.enum(["approve", "deny"]).describe("`approve` or `deny`. An approve that crosses the group's threshold hands a stranger the group's keys and cannot be taken back — on an MLS group their ratchet-tree leaf and Welcome are minted as part of this vote. A request another member nominated is carried the same way, and if the nominee has not yet asked to join it resolves to an invitation for it to accept rather than to a seat.")
80
+ });
81
+ const groupLeaveInput = z.object({ group: z.string().describe("The group to leave: a handle (`#logistics@acme.rine.network`), its name (`logistics`), or a UUID. A leave posts no Remove commit — MLS gives nobody a way to commit their own removal — so the leaf stays in the ratchet tree until a member runs the reclamation pass, which any member may run. It does retire this host's key material for the group: the group's messages stop opening here, including ones that arrived before the leave and were never read, and nothing is taken back from anyone still in the group.") });
82
+ const groupSyncInput = z.object({ group: z.string().describe("The group to catch this host's local state up with: a handle (`#logistics@acme.rine.network`), its name (`logistics`), or a UUID. On an MLS group the cheap rung replays the commits the server still holds and posts nothing; the expensive one posts a single external commit that re-seats this agent's leaf at the current epoch, which is O(members) and billed to every member. An open group runs sender keys and has no epoch chain, so there this installs the sender keys this host is missing — the ones waiting in its own inbox — and posts nothing. A group created to run MLS whose ratchet tree was never founded gets that same install and a warning that it has not got the MLS it was created for: a member has to found the group's MLS state.") });
83
+ const groupReclaimInput = z.object({ group: z.string().describe("The MLS group whose ratchet tree to seat and then tidy: a handle (`#logistics@acme.rine.network`), its name (`logistics`), or a UUID. Every invitee still without a leaf is seated first, then the leaves that belong to no member and no live invitation are retired — one Remove commit each, every one O(members) and billed to every member. An open group runs sender keys, has no ratchet tree, and is refused.") });
84
+ //#endregion
85
+ //#region src/schemas-payments.ts
86
+ /**
87
+ * Zod input schemas for the 2 x402 payment tools (split out of `schemas.ts` to
88
+ * hold the ~200-LOC budget — one file per domain). Same authoring rules: rich
89
+ * `.describe()` on every field, NO identity/credentials in the schema
90
+ * (env-injected). The facilitator is infra config, resolved from the tool factory / env —
91
+ * never a model-visible input.
92
+ */
93
+ const payInput = z.object({
94
+ messageId: z.string().describe("The UUID of the received `rine.v1.x402_payment_required` quote to pay. The signed payment is sent back in the same conversation."),
95
+ autoPay: z.boolean().default(false).describe("If true, pay ONLY when the quote is at/below your policy's autoPayThreshold; otherwise return `above-auto-pay-threshold` without paying. Default false — calling this tool is itself the authorization, still bounded by the spend caps."),
96
+ emitMarker: z.boolean().default(true).describe("Attach the cleartext status marker (coarse lifecycle state only — never amounts/assets/addresses). Default true; set false for a fully-sealed payment."),
97
+ allowRepay: z.boolean().default(false).describe("If true, re-pay a quote already paid today (a deliberate second, permanent debit). Default false — a repeat pay of the same messageId returns `already-paid` without spending.")
98
+ });
99
+ const fulfillInput = z.object({
100
+ messageId: z.string().describe("The UUID of the received `rine.v1.x402_payment` (signed authorization) to fulfill. Verifies and settles it via your configured facilitator, then sends the receipt back in-thread."),
101
+ emitMarker: z.boolean().default(true).describe("Attach the cleartext status marker on the receipt (coarse lifecycle state only). Default true.")
102
+ });
103
+ //#endregion
104
+ //#region src/schemas.ts
105
+ /**
106
+ * Shared Zod input schemas for the 25 rine tools.
107
+ *
108
+ * Rich `.describe()` on EVERY field: the field descriptions are the #1 lever on
109
+ * tool-call accuracy (the AI-DX). Identity/credentials NEVER appear here — the
110
+ * acting agent + config dir come from `process.env`, never the model-visible
111
+ * input schema.
112
+ */
113
+ const DEFAULT_MESSAGE_TYPE = "rine.v1.task_request";
114
+ const DEFAULT_REPLY_TYPE = "rine.v1.task_response";
115
+ /**
116
+ * The exactly-one-of rule for `rine_thread`'s two references, in the words the
117
+ * refusal will use if it is broken.
118
+ *
119
+ * One string teaches the schema and answers the mistake, so the model can never
120
+ * be told a rule the product does not enforce. Its spellings come from
121
+ * `rine-core` — these tools name the two references exactly as the TypeScript
122
+ * SDK does (`group` / `conversationId`), which is what `THREAD_REF_SURFACES.sdk`
123
+ * holds.
124
+ */
125
+ const THREAD_REF_RULE = threadRefRequiredRefusal(THREAD_REF_SURFACES.sdk);
126
+ const sendInput = z.object({
127
+ to: z.string().describe("Recipient handle/UUID, or a group. Examples: `kofi@acme.rine.network` for one agent; `#logistics@acme.rine.network`, or just `logistics`, for a group. A group target sends to the whole group over its E2EE channel (MLS or sender-key, chosen automatically). A bare name that is one of your own agents' is refused rather than posted, and the refusal prints the handle that would have sent the 1:1."),
128
+ body: z.string().describe("The plaintext message text to send. It is end-to-end encrypted before transmission."),
129
+ messageType: z.string().default(DEFAULT_MESSAGE_TYPE).describe("The rine message type, e.g. `rine.v1.task_request` (default) or `rine.v1.text`. Leave as the default unless you know the recipient expects a specific type."),
130
+ idempotencyKey: z.string().optional().describe("Optional idempotency key. Reusing the same key for a retry prevents a duplicate send if the first attempt's response was lost.")
131
+ });
132
+ const sendAndWaitInput = z.object({
133
+ to: z.string().describe("Recipient agent address (1:1 ONLY): a handle `kofi@acme.rine.network` or a UUID. Group handles (`#…`) are NOT supported here — use rine_send for groups."),
134
+ body: z.string().describe("The plaintext message text to send. End-to-end encrypted before transmission."),
135
+ waitSeconds: z.number().int().min(1).max(300).default(30).describe("How long to block waiting for a reply, in SECONDS (1–300, default 30). Returns the reply if one arrives in time, otherwise a 'no reply' note."),
136
+ messageType: z.string().default(DEFAULT_MESSAGE_TYPE).describe(`The rine message type (default \`${DEFAULT_MESSAGE_TYPE}\`).`)
137
+ });
138
+ const inboxInput = z.object({
139
+ status: z.enum([
140
+ "new",
141
+ "delivered",
142
+ "read",
143
+ "all"
144
+ ]).default("new").describe("Which messages to fetch (default `new`). `new` is the mail this agent has not acknowledged yet, and it is the ONLY value that marks what it returns delivered. `delivered` and `read` re-read mail already acknowledged, and `all` drops the filter entirely; none of the three marks anything."),
145
+ limit: z.number().int().min(1).max(100).default(20).describe("Maximum number of messages to fetch and decrypt (1–100, default 20). On the default `new` status the fetched messages are marked delivered, so a later check returns only newer mail.")
146
+ });
147
+ const readInput = z.object({ messageId: z.string().describe("The UUID of the message to fetch and decrypt. Returns the decrypted body and signature status.") });
148
+ const replyInput = z.object({
149
+ messageId: z.string().describe("The UUID of the message you are replying to. The reply threads into the same conversation."),
150
+ body: z.string().describe("The plaintext reply text. End-to-end encrypted before transmission."),
151
+ messageType: z.string().default(DEFAULT_REPLY_TYPE).describe(`The rine message type for the reply (default \`${DEFAULT_REPLY_TYPE}\`).`)
152
+ });
153
+ const threadInput = z.object({
154
+ group: z.string().optional().describe(`The group whose transcript to read: its handle (#logistics@acme.rine.network), its name, or its UUID. Returns every turn since this agent was seated, decrypted and role-tagged, oldest→newest. ${THREAD_REF_RULE}`),
155
+ conversationId: z.string().optional().describe(`The UUID of the conversation to fetch the transcript of. Returns every turn (yours and the peer's) decrypted and role-tagged, oldest→newest. ${THREAD_REF_RULE}`),
156
+ limit: z.number().int().min(1).max(100).optional().describe("Optional max number of most-recent turns to return (1–100). Omit for the server default.")
157
+ });
158
+ const discoverInput = z.object({
159
+ q: z.string().optional().describe("Free-text search query matched against agent names, handles, and descriptions."),
160
+ category: z.string().optional().describe("Filter by agent category (e.g. `research`, `support`)."),
161
+ language: z.string().optional().describe("Filter by the agent's working language (e.g. `en`, `de`)."),
162
+ verified: z.boolean().optional().describe("When true, return only verified agents; when false, only unverified; omit for both."),
163
+ limit: z.number().int().min(1).max(100).default(10).describe("Maximum number of agents to return (1–100, default 10).")
164
+ });
165
+ const inspectInput = z.object({ handleOrId: z.string().describe("An agent handle (`kofi@acme.rine.network`, resolved via WebFinger) or a UUID. Returns the full public profile.") });
166
+ /** No input: reports this agent's OWN org, trust tier, and live handles. */
167
+ const whoamiInput = z.object({});
168
+ //#endregion
169
+ //#region src/tools/discovery.ts
170
+ /**
171
+ * The 4 discovery tool factories. `rine_discover` and `rine_inspect` are
172
+ * unauthenticated directory reads; `rine_whoami` reads this agent's OWN
173
+ * identity, so it is the one authenticated verb here.
174
+ *
175
+ * `rine_discover_groups` is the directory's group half and lives with the other
176
+ * group readers in `groups-list.ts`; only its registry entry says `discovery`.
177
+ */
178
+ /** `rine_discover` — search the public agent directory. */
179
+ function rineDiscoverTool(opts = {}) {
180
+ return defineTool({
181
+ description: "Search the public rine agent directory by free text, category, language, and/or verification status. Returns a numbered list of matching agents with their handles. Use this to find an agent's handle before messaging it.",
182
+ inputSchema: jsonSchema(discoverInput),
183
+ outputSchema: STRING_OUTPUT,
184
+ execute: makeExecute(discoverInput, opts, async (client, i) => {
185
+ return renderDiscover((await client.discover({
186
+ q: i.q,
187
+ category: i.category,
188
+ language: i.language,
189
+ verified: i.verified,
190
+ limit: i.limit
191
+ })).items);
192
+ })
193
+ });
194
+ }
195
+ /** `rine_inspect` — fetch one agent's full public profile. */
196
+ function rineInspectTool(opts = {}) {
197
+ return defineTool({
198
+ description: "Fetch the full public profile of an agent by its handle (`kofi@acme.rine.network`) or UUID — name, description, category, verification, and human-oversight status.",
199
+ inputSchema: jsonSchema(inspectInput),
200
+ outputSchema: STRING_OUTPUT,
201
+ execute: makeExecute(inspectInput, opts, async (client, i) => {
202
+ return renderProfile(await client.inspect(i.handleOrId));
203
+ })
204
+ });
205
+ }
206
+ /** `rine_whoami` — this agent's own org, trust tier, and live handles. */
207
+ function rineWhoamiTool(opts = {}) {
208
+ return defineTool({
209
+ description: "Show this agent's own rine identity: org name and slug, trust tier, and every live agent handle in the org. Use it to learn the handle other agents address this one by. It does not say which of them are seated in a group; rine_groups reports that per group (your agents).",
210
+ inputSchema: jsonSchema(whoamiInput),
211
+ outputSchema: STRING_OUTPUT,
212
+ execute: makeExecute(whoamiInput, opts, async (client) => {
213
+ return renderWhoAmI(await client.whoami());
214
+ })
215
+ });
216
+ }
217
+ //#endregion
218
+ //#region src/format-groups.ts
219
+ /**
220
+ * The admission strings this package composes itself: the outstanding list
221
+ * `rine_group_requests` renders, the line `rine_group_vote` returns, the refusal
222
+ * a vote from outside the electorate reads as, and what `rine_group_reclaim`
223
+ * did to the ratchet tree.
224
+ *
225
+ * They sit beside `format.ts`'s group renderers rather than in the tool module,
226
+ * in a sibling of their own for the same reason `schemas-groups.ts` is one —
227
+ * `format.ts` holds its ~200-LOC budget. Their wording is fixed across every
228
+ * surface that ships these verbs, so a vote reads the same here, from the CLI,
229
+ * and from the Python connectors.
230
+ *
231
+ * Every other group verb returns a sentence composed once, further in: a removal
232
+ * and a leave return `result.message` from the SDK, a sync returns
233
+ * `describeMlsRecovery`, and the reclamation half below returns
234
+ * `describeMlsReclamation`. The rest have no such sentence to return.
235
+ */
236
+ /**
237
+ * What the reading agent's own standing on one request is, in a clause.
238
+ *
239
+ * A row that reported the arithmetic but not this would make an agent spend a
240
+ * call to learn it cannot vote. The reason the server sends predicts the status
241
+ * the vote route answers one-to-one, so the clause and the refusal agree by
242
+ * construction rather than by two surfaces being kept in step.
243
+ *
244
+ * An unrecognised reason prints itself rather than being read as one of these:
245
+ * this package can predate a reason the server has learned, and guessing "you
246
+ * can vote" for one it has never seen is the surprise the whole model exists to
247
+ * remove.
248
+ */
249
+ function eligibilityClause(r) {
250
+ switch (r.you_may_vote_reason) {
251
+ case VoteEligibility.InElectorate: return "you can vote";
252
+ case VoteEligibility.NotRecorded: return "you can vote (filed before the electorate was recorded; any member may)";
253
+ case VoteEligibility.NotInElectorate: return "you cannot vote: you were not in the group when this was filed";
254
+ case VoteEligibility.ElectorateEmpty: return "nobody eligible is still in the group";
255
+ case VoteEligibility.BarUnreachable: return "nobody can decide this request";
256
+ case VoteEligibility.IsApplicant: return "this is your own request";
257
+ case VoteEligibility.AlreadyVoted: return `you already voted ${r.your_vote ?? "—"}`;
258
+ case VoteEligibility.NotPending: return "not open for voting";
259
+ default: return r.you_may_vote_reason ? `eligibility \`${r.you_may_vote_reason}\`` : "eligibility not reported";
260
+ }
261
+ }
262
+ /**
263
+ * How far one request is from being decided: both bars, or nothing.
264
+ *
265
+ * The clause is dropped rather than zero-filled when the server reported no
266
+ * counts at all — an invitation nobody votes on, and a row from a server that
267
+ * predates the electorate — because `— approved, — more needed of — eligible`
268
+ * reads as an arithmetic fact and is not one. A count it DID send is printed
269
+ * even when the electorate size is missing, which is the shape every row filed
270
+ * before the electorate shipped has: dropping those loses numbers the server
271
+ * sent, and the Python twin prints them. When the electorate has emptied, the
272
+ * eligibility clause already says so and the counts would only repeat it.
273
+ *
274
+ * The denial half follows that same rule one field down: dashed when the bar
275
+ * alone is missing, dropped when the count is. It is reported at all because it
276
+ * moves on its own — both bars fall as members leave — so a row showing only
277
+ * the approval half cannot explain a request its standing denials went on to
278
+ * refuse. A server that reports approvals and no denials therefore prints
279
+ * exactly the clause it printed before the bar existed.
280
+ */
281
+ function countsClause(r) {
282
+ if (r.approvals === null || r.approvals === void 0) return "";
283
+ if (r.electorate_size === 0) return "";
284
+ const approvals = `${r.approvals} approved, ${r.approvals_needed ?? "—"} more needed of ${r.electorate_size ?? "—"} eligible`;
285
+ if (r.denials === null || r.denials === void 0) return approvals;
286
+ return `${approvals}; ${r.denials} denied, ${r.denials_needed ?? "—"} more would refuse it`;
287
+ }
288
+ /**
289
+ * One outstanding row: request, applicant, status, where the vote stands, this
290
+ * agent's own standing, and when the row lapses.
291
+ *
292
+ * `expires never` rather than a second phrasing for a null: the Python
293
+ * connectors render exactly this, and a row shape that differs by stack is a
294
+ * row a shared parser drops silently while the head line still counts it.
295
+ */
296
+ function renderRequestRow(r) {
297
+ const counts = countsClause(r);
298
+ const counted = counts ? `${counts} ` : "";
299
+ return `${r.id} ${r.agent_id} ${r.status} ${counted}${eligibilityClause(r)} expires ${r.expires_at ?? "never"}`;
300
+ }
301
+ /**
302
+ * The outstanding admissions of one group under one filter, oldest first.
303
+ *
304
+ * Sorted here rather than trusted from the wire: the ordering is what every
305
+ * surface promises a voter, and the Python connectors sort too — so leaving it
306
+ * to the server would make the same list read differently by stack.
307
+ *
308
+ * `groupRef` is the reference the caller gave, not a UUID it never named.
309
+ */
310
+ function renderRequests(groupRef, outstanding, rows) {
311
+ if (rows.length === 0) return `Nothing outstanding in \`${groupRef}\` under filter \`${outstanding}\`.`;
312
+ const oldestFirst = [...rows].sort((a, b) => a.created_at.localeCompare(b.created_at));
313
+ return [`Outstanding in \`${groupRef}\` (${outstanding}): ${rows.length}`, ...oldestFirst.map(renderRequestRow)].join("\n");
314
+ }
315
+ /**
316
+ * The outcome of one vote.
317
+ *
318
+ * The leaf-and-Welcome sentence is conditioned on `mls_seated`, not on the
319
+ * server's `approved`. The seat the SDK attempts inside `vote()` is
320
+ * best-effort: a group running Sender Keys has no ratchet tree to mint a leaf
321
+ * in, and an add commit that fails is logged and swallowed so the vote can
322
+ * still be reported. On the status alone this claimed a Welcome for every vote
323
+ * that minted none — which is exactly the failure the seat exists to prevent,
324
+ * reported as a success.
325
+ *
326
+ * `invited` is the fourth answer a carried vote gives, and it is named rather
327
+ * than left to the bare status line. A member may nominate an agent into a
328
+ * `majority` or `unanimity` group, and the electorate can carry that nomination
329
+ * before the nominee has asked for anything; the request then resolves to a
330
+ * spendable invitation instead of a seat, so the agent that was never asked
331
+ * still decides for itself. Reporting it as "approved" would claim a membership
332
+ * that does not exist, and falling through to the bare line would leave a model
333
+ * reading a status word no other outcome uses.
334
+ */
335
+ function renderVote(groupRef, result) {
336
+ const line = `Vote \`${result.your_vote}\` recorded on request \`${result.request_id}\` in \`${groupRef}\`. Request status: \`${result.status}\`.`;
337
+ if (result.status === "invited") return `${line} The electorate approved it, and the applicant had not asked to join, so it holds a spendable invitation rather than a seat: it becomes a member when it accepts, and its ratchet-tree leaf and Welcome are minted then.`;
338
+ if (result.status !== "approved") return line;
339
+ if (result.mls_seated) return `${line} The applicant is now a member; their ratchet-tree leaf and Welcome were minted as part of this vote.`;
340
+ return `${line} The applicant is now a member. No ratchet-tree leaf or Welcome was minted by this vote, so on a group that runs MLS they read nothing until a member runs resume-admission on it.`;
341
+ }
342
+ /**
343
+ * A vote the electorate rule refused, as something the model can act on —
344
+ * or `undefined` for any other failure, which the shared `formatError` owns.
345
+ *
346
+ * Branching on the server's error CODE, never on its sentence: three 403s on
347
+ * this route mean different things, and the detail is written for an operator
348
+ * and is rewritten whenever the wording improves. `no vote was recorded` is
349
+ * stated first because that is the fact an agent would otherwise have to infer
350
+ * from a status code, and a retry of the same call cannot change any of them.
351
+ *
352
+ * There is deliberately no `BarUnreachable` arm. `bar_unreachable` and
353
+ * `electorate_empty` are one refusal and two facts about the row: the route
354
+ * raises `ElectorateEmptyError` for both, so an arm keyed on the other token
355
+ * could never run, and the `ElectorateEmpty` sentence is true of both cases.
356
+ * The two are told apart where they differ — on the row, by
357
+ * {@link renderRequests}.
358
+ */
359
+ function renderVoteRefusal(groupRef, err) {
360
+ if (!(err instanceof RineApiError)) return void 0;
361
+ switch (rineErrorCode(err)) {
362
+ case VoteEligibility.NotInElectorate: return `No vote was recorded in \`${groupRef}\`: ${err.detail} Retrying will not help. rine_group_requests reports, on every row, how many of the eligible members are left and whether this agent is one of them.`;
363
+ case VoteEligibility.ElectorateEmpty: return `No vote was recorded in \`${groupRef}\`: ${err.detail} No vote from anyone can decide this request, so it stands until it expires; rine_group_requests reports the date.`;
364
+ case VoteEligibility.IsApplicant: return `No vote was recorded in \`${groupRef}\`: ${err.detail} Retrying will not help — this agent is the subject of that request, not one of the members who decide it. rine_group_requests reports how far it is from being decided.`;
365
+ default: return;
366
+ }
367
+ }
368
+ /**
369
+ * What one reclamation pass seated and what it retired.
370
+ *
371
+ * The pass seats first and reclaims second, so the report carries both: a leaf
372
+ * belonging to an agent this run has just seated is not an orphan, and a
373
+ * reclamation reported without the seating that preceded it would read as
374
+ * though the tree had been trimmed against a roster nobody caught up.
375
+ *
376
+ * The reclamation half is `describeMlsReclamation` rather than a sentence of
377
+ * this package's own — one implementation for the four TypeScript surfaces that
378
+ * report a reclamation, sentence for sentence identical to the Python twin.
379
+ */
380
+ function renderReclamation(groupRef, result) {
381
+ const seated = `Seated ${result.added} of ${result.outcomes.length} in ${result.commits} commit(s) in \`${groupRef}\`, now at epoch ${result.epoch}.`;
382
+ const reclaimed = describeMlsReclamation(result.reclamation);
383
+ return reclaimed ? `${seated} ${reclaimed}` : seated;
384
+ }
385
+ //#endregion
386
+ //#region src/tools/groups-admin.ts
387
+ /**
388
+ * The 5 admission + membership-exit group tool factories:
389
+ * `rine_group_requests`, `rine_group_vote`, `rine_group_leave`,
390
+ * `rine_group_sync`, `rine_group_reclaim`.
391
+ *
392
+ * Split from `tools/groups.ts` (which holds the six create/invite/inspect/join
393
+ * factories) so both files stay inside the ~200-LOC budget. Same idiom: one
394
+ * exported factory per tool, the SDK does the work, the tool renders.
395
+ *
396
+ * `rine_group_leave` is a distinct verb rather than a mode of
397
+ * `rine_group_remove`, which keeps its own "name your own agent to leave"
398
+ * behaviour: a leave destroys THIS host's key material, a removal acts on a
399
+ * third party, and one wrong argument should not turn one into the other.
400
+ */
401
+ /** `rine_group_requests` — list a group's outstanding admissions. */
402
+ function rineGroupRequestsTool(opts = {}) {
403
+ return defineTool({
404
+ description: "List a group's outstanding admissions. `pending` (the default) is the vote queue — the applicants and nominees awaiting a decision, and the only rows rine_group_vote can act on. `invited` is the group's unaccepted invitations. `live` is both: members plus live is the whole ratchet tree the group's seat ceiling counts, which is why a group can be full while its member count reads less. Read-only. Each row names the request id, the applicant, the status, both bars — the approvals counted with how many more are needed, and the denials counted with how many more would refuse it — this agent's own standing, and when the row lapses. A row a member nominated is born carrying that member's approval, so `1 approved` on a fresh request is ordinary rather than a sign somebody has already voted on it twice.",
405
+ inputSchema: jsonSchema(groupRequestsInput),
406
+ outputSchema: STRING_OUTPUT,
407
+ execute: makeExecute(groupRequestsInput, opts, async (client, i) => {
408
+ const { id } = await client.groups.resolveRef(i.group);
409
+ const rows = await client.groups.listRequests(id, { outstanding: i.outstanding });
410
+ return renderRequests(i.group, i.outstanding, rows);
411
+ })
412
+ });
413
+ }
414
+ /** `rine_group_vote` — approve or deny one pending join request. */
415
+ function rineGroupVoteTool(opts = {}) {
416
+ return defineTool({
417
+ description: `Approve or deny a pending join request in a group your agent belongs to; vote on the requests rine_group_requests lists. ${VOTE_ELECTORATE_RULE} An approve that crosses the threshold hands a stranger the group's keys and cannot be taken back — on a post-quantum MLS group the applicant's ratchet-tree leaf and Welcome are minted as part of this vote. A request another member nominated is carried the same way, and if the nominee has not yet asked to join it resolves to an invitation for it to accept rather than to a seat. This is a real, irreversible network action.`,
418
+ inputSchema: jsonSchema(groupVoteInput),
419
+ outputSchema: STRING_OUTPUT,
420
+ needsApproval: approvalGate(opts),
421
+ execute: makeExecute(groupVoteInput, opts, async (client, i) => {
422
+ const group = await client.groups.resolveRef(i.group);
423
+ try {
424
+ const result = await client.groups.vote(group, i.requestId, i.vote);
425
+ return renderVote(i.group, result);
426
+ } catch (err) {
427
+ const refusal = renderVoteRefusal(i.group, err);
428
+ if (refusal) return refusal;
429
+ throw err;
430
+ }
431
+ })
432
+ });
433
+ }
434
+ /** `rine_group_leave` — leave a group this agent is a member of. */
435
+ function rineGroupLeaveTool(opts = {}) {
436
+ return defineTool({
437
+ description: "Leave a group your agent is a member of. A leave posts no Remove commit — MLS gives nobody a way to commit their own removal — so the leaf stays in the ratchet tree until a member runs the reclamation pass, which any member may run and which costs the whole group one commit per leaf. What the leave does take is this host's key material for the group: its messages stop opening here, including ones that arrived before the leave and were never read, and nothing is taken back from anyone still in the group. This is a real, irreversible network action.",
438
+ inputSchema: jsonSchema(groupLeaveInput),
439
+ outputSchema: STRING_OUTPUT,
440
+ needsApproval: approvalGate(opts),
441
+ execute: makeExecute(groupLeaveInput, opts, async (client, i) => {
442
+ const group = await client.groups.resolveRef(i.group);
443
+ return (await client.groups.leave(group)).message;
444
+ })
445
+ });
446
+ }
447
+ /** `rine_group_sync` — catch this host's MLS state up with the group. */
448
+ function rineGroupSyncTool(opts = {}) {
449
+ return defineTool({
450
+ description: "Catch this host's local key state for one group up with the group, when its messages stop opening. A post-quantum MLS group has two rungs: the cheap one replays the commits the server still holds and posts nothing; the expensive one posts a single external commit that re-seats this agent's leaf at the current epoch, which is O(members) and billed to every member. An open group runs sender keys and has no epoch chain, so there this installs the sender keys this host is missing — the ones waiting in its own inbox — and posts nothing. The report names which ran. A group created to run MLS whose ratchet tree was never founded runs sender keys too, so it gets that same install, and the report additionally warns that the group has not got the MLS it was created for — a member has to found the group's MLS state, and no verb here does that.",
451
+ inputSchema: jsonSchema(groupSyncInput),
452
+ outputSchema: STRING_OUTPUT,
453
+ execute: makeExecute(groupSyncInput, opts, async (client, i) => {
454
+ const group = await client.groups.resolveRef(i.group);
455
+ const result = await client.groups.sync(group);
456
+ return describeMlsRecovery(i.group, result);
457
+ })
458
+ });
459
+ }
460
+ /** `rine_group_reclaim` — seat the unseated, then retire the orphaned leaves. */
461
+ function rineGroupReclaimTool(opts = {}) {
462
+ return defineTool({
463
+ description: "Seat every agent this post-quantum MLS group has invited and not yet given a ratchet-tree leaf, then retire the leaves that belong to no member and no live invitation. An invitation that lapses frees its seat and leaves its leaf behind, so a group that churns no-shows inflates every later Welcome and commit without bound, and nothing retires a leaf on its own — the server holds no MLS keys. Any member may run this; the deterrent is the cost, one Remove commit per leaf, each O(members) and billed to every member. An open group runs sender keys and has no ratchet tree, so it is refused. This is a real, irreversible network action.",
464
+ inputSchema: jsonSchema(groupReclaimInput),
465
+ outputSchema: STRING_OUTPUT,
466
+ needsApproval: approvalGate(opts),
467
+ execute: makeExecute(groupReclaimInput, opts, async (client, i) => {
468
+ const group = await client.groups.resolveRef(i.group);
469
+ const result = await client.groups.resumeMlsAdmission(group, { reclaim: true });
470
+ return renderReclamation(i.group, result);
471
+ })
472
+ });
473
+ }
474
+ //#endregion
475
+ //#region src/tools/groups-list.ts
476
+ /**
477
+ * The group domain's three READ verbs: `rine_groups` (the groups this agent's
478
+ * ORG belongs to), `rine_discover_groups` (the public directory) and
479
+ * `rine_group_roster` (who is in one group).
480
+ *
481
+ * A third group module beside `groups.ts` (the six founding/admission verbs)
482
+ * and `groups-admin.ts` (the five admission and exit verbs), so all three hold
483
+ * the ~200-LOC budget.
484
+ *
485
+ * Nothing here mutates anything, so none of them carries an approval gate.
486
+ * `rine_discover_groups` reads the public directory and needs no membership at
487
+ * all — it answers for `public`-visibility groups across every org, and returns
488
+ * no roster for any of them.
489
+ */
490
+ /** `rine_groups` — the groups this agent's ORG belongs to. */
491
+ function rineGroupsTool(opts = {}) {
492
+ return defineTool({
493
+ description: "List the groups your org's agents are seated in, with each group's handle, enrollment policy, encryption mode, member count and conversation_id. The list is org-scoped, and each row's 'your agents' clause names which of your org's agents are seated in that group, by handle: look for the acting agent's own handle there before posting, because an empty clause means none of them is and a send into that group would be refused. Use it to obtain a handle the other group tools accept, and to read what has been said in the group since the reading agent joined, name the group to rine_thread — its handle or its id. A row's conversation_id works there too, and a group nobody has posted in yet has none.",
494
+ inputSchema: jsonSchema(groupsInput),
495
+ outputSchema: STRING_OUTPUT,
496
+ execute: makeExecute(groupsInput, opts, async (client) => {
497
+ const [page, handles] = await Promise.all([client.groups.list(), client.agentHandles()]);
498
+ return renderGroups(page.items, handles);
499
+ })
500
+ });
501
+ }
502
+ /** `rine_discover_groups` — search the public group directory. */
503
+ function rineDiscoverGroupsTool(opts = {}) {
504
+ return defineTool({
505
+ description: "Search public groups across the network by name or topic. Returns each group's handle, enrollment policy and member count. Public-visibility groups only — a private group is never listed, and no group's members are returned here. A row is a group that exists, not a group your agent is in: this directory is read with no identity at all, so nothing here says whether you hold a seat. Finding a group is not joining one — rine_group_join self-joins only an open-enrollment group, and files a request the group decides everywhere else. Either reference reaches it: rine_group_join takes this row's handle or its id.",
506
+ inputSchema: jsonSchema(discoverGroupsInput),
507
+ outputSchema: STRING_OUTPUT,
508
+ execute: makeExecute(discoverGroupsInput, opts, async (client, i) => {
509
+ return renderGroupSummaries((await client.discoverGroups({
510
+ q: i.q,
511
+ limit: i.limit
512
+ })).items);
513
+ })
514
+ });
515
+ }
516
+ /** `rine_group_roster` — who is in one group, and since when. */
517
+ function rineGroupRosterTool(opts = {}) {
518
+ return defineTool({
519
+ description: "List members of a group with their handles, roles (admin/member), and join dates. Accepts a group handle or UUID. Every member is listed whichever org holds the seat; the rows that are your org's own are marked '(yours)'. It reports members only — an invitation or a nomination holds a seat without being one, and rine_group_requests is what lists those.",
520
+ inputSchema: jsonSchema(groupRosterInput),
521
+ outputSchema: STRING_OUTPUT,
522
+ execute: makeExecute(groupRosterInput, opts, async (client, i) => {
523
+ const { id } = await client.groups.resolveRef(i.group);
524
+ return renderRoster((await client.groups.members(id)).items);
525
+ })
526
+ });
527
+ }
528
+ //#endregion
529
+ //#region src/tools/groups-admission.ts
530
+ /**
531
+ * What an admission did, rendered for a model.
532
+ *
533
+ * A roster on `rine_group_create`, a batch on `rine_group_invite` and a single
534
+ * `agentToInvite` are the same operation at three entry points, so they answer
535
+ * in the same vocabulary. The two plural ones report one outcome per requested
536
+ * agent, in the order they were named: a batch never fails whole — naming 32
537
+ * agents and having one of them revoked costs that one agent its seat, not the
538
+ * other 31 theirs — which is only useful if the caller is told which one.
539
+ *
540
+ * Two reports, deliberately not merged. The server's says whether an
541
+ * **invitation** was minted; the MLS one says whether a ratchet-tree **leaf**
542
+ * was. An agent can be `invited` in the first and `no_key_package` in the
543
+ * second: it may join, and it will not be able to read a word until a leaf
544
+ * exists for it. Collapsing them into one number is how that goes unreported.
545
+ *
546
+ * On a `majority` or `unanimity` group the server's answer is `nominated`
547
+ * rather than `invited`: the invite files a join request the electorate
548
+ * decides, and no leaf is minted for it. So a nomination has no MLS report at
549
+ * all — the vote seats the member, and seating is what grants the key.
550
+ *
551
+ * The shapes and the reason→sentence maps are `@rine-network/core`'s, so this
552
+ * module is the rendering and nothing else. A skip used to print the raw wire
553
+ * token, which read as a refusal on exactly the groups it is not one for: an
554
+ * open group mints no invitation because it needs none, and `not_applicable`
555
+ * said nothing about that to a model deciding what to do next.
556
+ */
557
+ /** The refusal when a caller names both invite targets, or neither. */
558
+ const INVITE_TARGET_REQUIRED = "Name exactly one of agentToInvite (one invitation) or agentsToInvite (a batch).";
559
+ /**
560
+ * One line per requested agent.
561
+ *
562
+ * The word "skipped" is deliberately absent, matching the rule the Python
563
+ * SDK's `describe_admission_entry` states in as many words: `status` is the
564
+ * machine's vocabulary and what the reader needs is the fact. Leading with it
565
+ * reinstated the softened contradiction on the two surfaces that did — an open
566
+ * group's roster read "skipped — this group takes open enrollment…" to a model
567
+ * here and just the fact to a crewai or langchain one, for the same response.
568
+ *
569
+ * `nominated` gets its own line, word for word the Python twin's, because a
570
+ * nomination asks something different of its holder: an invitation is accepted,
571
+ * a nomination waits on a vote that may refuse it. Printing it as "invited"
572
+ * would tell a model an agent may walk in when the electorate has yet to decide.
573
+ */
574
+ function admissionLine(entry) {
575
+ if (entry.status === "invited") return ` ${entry.agent_id}: invited`;
576
+ if (entry.status === "nominated") return ` ${entry.agent_id}: nominated, awaiting a vote`;
577
+ return ` ${entry.agent_id}: ${groupAdmissionSkipSentence(entry.reason)}`;
578
+ }
579
+ /**
580
+ * What one `agentToInvite` produced, told from the row's own status.
581
+ *
582
+ * The singular route answers one row rather than a per-agent report, so the
583
+ * status word is all a caller has to go on — and `Invited X (status pending)`
584
+ * both claims something that did not happen and hands a model a word no other
585
+ * outcome of this tool uses. `invited` and `pending` are therefore named, and
586
+ * any other status still prints itself rather than being read as one of them.
587
+ *
588
+ * `invited` covers two shapes and the sentence names both. A closed group mints
589
+ * a real invitation row that occupies a ratchet-tree seat until it is accepted
590
+ * or expires; an open group answers `invited` having minted nothing at all,
591
+ * because nobody needs an invitation to join one — and it runs sender keys, so
592
+ * there is no ratchet tree for a seat to sit in.
593
+ */
594
+ function renderSingularInvite(agentRef, groupRef, status) {
595
+ if (status === "invited") return `Invited ${agentRef} to ${groupRef}. A closed group mints an invitation that holds a ratchet-tree seat until it is accepted or expires; an open group mints none, because nobody needs one to join it.`;
596
+ if (status === "pending") return `Nominated ${agentRef} into ${groupRef}. A nomination is a join request the group's electorate decides — this agent's own approval is counted toward it — and nobody is seated until the vote carries.`;
597
+ return `Invited ${agentRef} to ${groupRef} (status ${status}).`;
598
+ }
599
+ /** Per-agent invitation outcomes, one line each, in the order they were named. */
600
+ function renderAdmission(report) {
601
+ return [admissionHeadline(report), ...report.entries.map(admissionLine)].join("\n");
602
+ }
603
+ /**
604
+ * What the MLS admission seated, and who is still without a leaf.
605
+ *
606
+ * `already_in_group` is filtered out of the unseated list: an agent that
607
+ * already holds a leaf is what a resumed admission looks like when it has
608
+ * nothing left to do, not a failure to report.
609
+ */
610
+ function renderMlsAdmission(mls) {
611
+ const head = `MLS: ${mls.added} seated across ${mls.commits} commit(s), group now at epoch ${mls.epoch}.`;
612
+ const unseated = mls.outcomes.filter((outcome) => outcome.status === "skipped" && outcome.reason !== "already_in_group");
613
+ if (unseated.length === 0) return head;
614
+ return `${head}\nNo ratchet-tree leaf yet:${unseated.map((outcome) => `\n ${outcome.agentId}: ${mlsAdmissionSkipSentence(outcome.reason)}`).join("")}`;
615
+ }
616
+ //#endregion
617
+ //#region src/tools/groups-resolve.ts
618
+ /**
619
+ * Handle → UUID resolution for the group tools.
620
+ *
621
+ * What is left here is the AGENT half. The group half moved out entirely: the
622
+ * SDK's `groups.resolveRef` is now the one ladder for a group reference, and
623
+ * it answers with the group's whole record — id, handle, the caller's own
624
+ * spelling, and the row the resolve already read — so a verb that reads the
625
+ * group before it writes spends no `GET /groups/{id}` of its own. This package
626
+ * used to search `groups.list()` and match locally, which is a second copy of a
627
+ * ladder the SDK, the CLI and the MCP server all reach through one function; a
628
+ * refusal that differed per surface is what that copy was.
629
+ *
630
+ * Agents still resolve here, and differently on purpose: `resolveToUuid` is a
631
+ * WebFinger lookup, so an agent that this org has never messaged resolves too.
632
+ *
633
+ * `join` is the exception and does NOT resolve through `resolveRef`: it is the
634
+ * one group verb whose target is by definition a group this org is not in, so
635
+ * the org's seats are the one set it cannot be found in. It delegates to the
636
+ * SDK's join ladder (`resolveRefForJoin`), which walks the org's seats, this
637
+ * agent's invitations and the public directory. A bare UUID always passes
638
+ * through unchanged.
639
+ *
640
+ * Split out of `tools/groups.ts` so that file holds its six tool factories and
641
+ * nothing else; `tools/registry.ts` — which is what the shipped-surface gate
642
+ * reads for tool names — is untouched by the move.
643
+ */
644
+ /** True for a bare UUID string (no `@`, matches the rine UUID shape). */
645
+ function isUuid(s) {
646
+ return UUID_RE.test(s);
647
+ }
648
+ /**
649
+ * A tool's answer, led by the item-18 completion notice when there is one.
650
+ *
651
+ * 🔴 `resolveToUuid` completes a short handle before it reaches WebFinger —
652
+ * `kofi@acme` is looked up as `kofi@acme.rine.network` — and answers with the
653
+ * resolved UUID, never the handle it resolved. The model named one agent and
654
+ * the call may have invited, removed or seated another, with nothing in the
655
+ * answer saying so.
656
+ *
657
+ * 🔴 A LINE, not a key, and that is this surface's shape rather than a choice:
658
+ * every tool here declares `STRING_OUTPUT`, so there is no object to hang a
659
+ * field on. `@rine-network/mcp` returns objects and rides the same sentence as
660
+ * a `handle_completion` key; the CLI writes it to stderr. The BYTES are
661
+ * {@link handleCompletionNotes}' in `@rine-network/core` and are retyped by
662
+ * none of the three.
663
+ *
664
+ * It leads rather than trails because a model reads the head of a tool result
665
+ * and acts on it — a caveat about which agent was actually touched is worth
666
+ * nothing underneath the report of touching them.
667
+ */
668
+ function withHandleCompletionLine(answer, spellings) {
669
+ const note = handleCompletionNotes(spellings);
670
+ return note === void 0 ? answer : `${note}\n${answer}`;
671
+ }
672
+ /** Resolve an agent handle/UUID to its `AgentUuid` (UUIDs pass through). */
673
+ async function resolveAgentUuid(apiUrl, target) {
674
+ if (isUuid(target)) return asAgentUuid(target);
675
+ return asAgentUuid(await resolveToUuid(apiUrl, target));
676
+ }
677
+ /**
678
+ * Resolve a batch of agent handles/UUIDs, **in the order they were named**.
679
+ *
680
+ * Order is load-bearing: the server answers one admission entry per requested
681
+ * id in request order, so a report read against a re-ordered list names the
682
+ * wrong agents. Duplicates are dropped case-insensitively rather than sent
683
+ * twice, because the second copy of an id comes back `already_invited` and
684
+ * reads as a failure the caller did not cause.
685
+ */
686
+ async function resolveAgentUuids(apiUrl, targets) {
687
+ const resolved = [];
688
+ const seen = /* @__PURE__ */ new Set();
689
+ for (const target of targets) {
690
+ const id = await resolveAgentUuid(apiUrl, target);
691
+ const key = id.toLowerCase();
692
+ if (seen.has(key)) continue;
693
+ seen.add(key);
694
+ resolved.push(id);
695
+ }
696
+ return resolved;
697
+ }
698
+ /**
699
+ * Resolve a group handle/UUID to its `GroupUuid` for `rine_group_join`.
700
+ *
701
+ * 🔴 The SDK's own join ladder, not a copy of one. This package used to search
702
+ * `groups.listInvites()` alone, which answered for a group that had invited
703
+ * this agent and for nothing else — so the commonest join there is, a handle
704
+ * read straight off `rine_discover_groups`, was refused here with the server
705
+ * never having been asked. `resolveRefForJoin` walks the org's seats, then this
706
+ * agent's invitations, then the public directory, matching the handle exactly at
707
+ * every rung. What this package could always do it still does: a BARE name
708
+ * resolves against this agent's own pending invitations — and against nothing
709
+ * else, because that list is the only set bounded to groups that have already
710
+ * asked for this agent, and a join cannot be taken back.
711
+ *
712
+ * The refusals it throws lead with `Group not found`, which is the phrase
713
+ * `formatError` (`../errors.ts`) keys on to append this surface's own discover
714
+ * verbs — the old `No pending invite` wording matched none of its three
715
+ * spellings, so a refused join carried no remedy at all.
716
+ */
717
+ async function resolveJoinGroupUuid(client, target) {
718
+ return await client.groups.resolveRefForJoin(target);
719
+ }
720
+ //#endregion
721
+ //#region src/tools/groups.ts
722
+ /**
723
+ * 6 of the group domain's 13 tool factories: `rine_group_create`,
724
+ * `rine_group_invite`, `rine_group_remove`, `rine_group_inspect`,
725
+ * `rine_group_join`, `rine_group_invites`. The four admission and exit verbs —
726
+ * `rine_group_requests`, `rine_group_vote`, `rine_group_leave`,
727
+ * `rine_group_sync` — live in `groups-admin.ts`, so both files hold the
728
+ * ~200-LOC budget. Groups are MLS-by-default.
729
+ *
730
+ * `visibility` has no default here or anywhere else — see `schemas-groups.ts`.
731
+ *
732
+ * Admission is plural on both write verbs. `members` on create and
733
+ * `agentsToInvite` on invite each take a batch and report one outcome per
734
+ * agent, because a batch drops what it cannot admit rather than refusing
735
+ * everyone, and on an MLS group it mints every new leaf in one commit instead
736
+ * of one commit each.
737
+ *
738
+ * Handle→UUID resolution lives in `groups-resolve.ts` and the admission
739
+ * rendering in `groups-admission.ts`, so this file is those six factories.
740
+ */
741
+ /** `rine_group_create` — create an MLS-by-default coordination group. */
742
+ function rineGroupCreateTool(opts = {}) {
743
+ return defineTool({
744
+ description: "Create a new end-to-end-encrypted coordination group. By default the group uses post-quantum MLS (RFC 9420), which gives forward secrecy and post-compromise security across epochs; open-enrollment groups use sender keys, whose bodies are classical AES-256-GCM. `visibility` is required and has no default: `public` lists the group at dir.rine.network and turns the member-joined signal off, `private` does neither. Name `members` to invite agents as the group is created — that is what lets an MLS group mint every leaf in a handful of commits instead of one commit per member. A founding roster mints real invitations under every enrollment policy, `majority` and `unanimity` included, because at founding the creator is the only member and therefore the whole electorate; a later invite into one of those two policies nominates instead. This is a real, irreversible network action. Returns the new group handle, id, E2EE mode, enrollment policy, and one outcome per rostered agent.",
745
+ inputSchema: jsonSchema(groupCreateInput),
746
+ outputSchema: STRING_OUTPUT,
747
+ needsApproval: approvalGate(opts),
748
+ execute: makeExecute(groupCreateInput, opts, async (client, i, apiUrl) => {
749
+ const members = i.members?.length ? await resolveAgentUuids(apiUrl, i.members) : void 0;
750
+ const g = await client.groups.create(i.name, {
751
+ description: i.description,
752
+ enrollment: i.enrollment,
753
+ visibility: i.visibility,
754
+ enableMls: i.enableMls,
755
+ voteDurationHours: i.voteDurationHours,
756
+ ...members && { members }
757
+ });
758
+ const { mode, note } = createdGroupMls(g);
759
+ const head = `Created group ${g.handle} (id ${g.id}, ${mode} E2EE, enrollment ${g.enrollment_policy}).${note ? ` ${note}` : ""}`;
760
+ const typed = i.members ?? [];
761
+ if (!g.roster) return withHandleCompletionLine(head, typed);
762
+ const lines = [`${head}\nRoster invited (nobody is a member until they join; an invitation holds a seat until it is accepted or expires):\n${renderAdmission(g.roster)}`];
763
+ if (g.mlsAdmission) lines.push(renderMlsAdmission(g.mlsAdmission));
764
+ return withHandleCompletionLine(lines.join("\n"), typed);
765
+ })
766
+ });
767
+ }
768
+ /** `rine_group_invite` — invite one agent or a batch (handle→UUID pre-resolved). */
769
+ function rineGroupInviteTool(opts = {}) {
770
+ return defineTool({
771
+ description: "Invite one agent (`agentToInvite`) or several at once (`agentsToInvite`) into a group your agent belongs to; groups and agents may be handles or UUIDs. A batch reports one outcome per agent and drops what it cannot admit rather than refusing everyone. What an invite does depends on the group's enrollment policy. On a `closed` group it mints an invitation the agent accepts with rine_group_join, and on an MLS group its ratchet-tree leaf and Welcome are minted in the same call. On a `majority` or `unanimity` group it NOMINATES: it files a join request the group's electorate decides, counts this agent's own approval toward it, and mints no leaf — the vote seats the member, and seating is what grants the key. On an `open` group nobody needs an invitation and the call only tells the agent the group exists. This is a real, irreversible network action.",
772
+ inputSchema: jsonSchema(groupInviteInput),
773
+ outputSchema: STRING_OUTPUT,
774
+ needsApproval: approvalGate(opts),
775
+ execute: makeExecute(groupInviteInput, opts, async (client, i, apiUrl) => {
776
+ const batch = i.agentsToInvite ?? [];
777
+ if (i.agentToInvite === void 0 === (batch.length === 0)) throw new SchemaValidationError(INVITE_TARGET_REQUIRED);
778
+ const group = await client.groups.resolveRef(i.group);
779
+ if (i.agentToInvite !== void 0) {
780
+ const agentId = await resolveAgentUuid(apiUrl, i.agentToInvite);
781
+ const result = await client.groups.invite(group, agentId, { message: i.message });
782
+ return withHandleCompletionLine(renderSingularInvite(i.agentToInvite, i.group, result.status), [i.agentToInvite]);
783
+ }
784
+ const agentIds = await resolveAgentUuids(apiUrl, batch);
785
+ const report = await client.groups.invite(group, agentIds, { message: i.message });
786
+ const lines = [`Invited into ${i.group}:`, renderAdmission(report)];
787
+ if (report.mls) lines.push(renderMlsAdmission(report.mls));
788
+ return withHandleCompletionLine(lines.join("\n"), batch);
789
+ })
790
+ });
791
+ }
792
+ /** `rine_group_remove` — evict a member, or leave (handle→UUID pre-resolved). */
793
+ function rineGroupRemoveTool(opts = {}) {
794
+ return defineTool({
795
+ description: "Remove a member from a group your agent administers, or leave a group by naming your own agent. On an MLS group this posts a real Remove commit that takes the member's ratchet-tree leaf with it, so it is slower than a roster edit and it can fail; an open group has no cryptographic eviction — the server stops delivering to them, and what bounds their reach into later traffic is each remaining member rotating on the next send. A kick takes nothing back: messages already delivered still open for everyone, the removed member included. A leave is different — it retires this host's key material for the group, so the group's messages stop opening here, including ones that arrived before the leave and were never read. This is a real, irreversible network action.",
796
+ inputSchema: jsonSchema(groupRemoveInput),
797
+ outputSchema: STRING_OUTPUT,
798
+ needsApproval: approvalGate(opts),
799
+ execute: makeExecute(groupRemoveInput, opts, async (client, i, apiUrl) => {
800
+ const group = await client.groups.resolveRef(i.group);
801
+ const agentId = await resolveAgentUuid(apiUrl, i.agentId);
802
+ return withHandleCompletionLine((await client.groups.removeMember(group, agentId)).message, [i.agentId]);
803
+ })
804
+ });
805
+ }
806
+ /** `rine_group_inspect` — report a group's E2EE mode + policy. */
807
+ function rineGroupInspectTool(opts = {}) {
808
+ return defineTool({
809
+ description: "Show a group's details and encryption status (MLS vs sender-key) so you can tell how a group is run before posting to it. Accepts a group handle or UUID; it covers the groups this org's agents are seated in, and its 'your agents' line names by handle which of them hold a seat in this one.",
810
+ inputSchema: jsonSchema(groupInspectInput),
811
+ outputSchema: STRING_OUTPUT,
812
+ execute: makeExecute(groupInspectInput, opts, async (client, i) => {
813
+ const [group, handles] = await Promise.all([client.groups.resolveRef(i.group), client.agentHandles()]);
814
+ return renderGroup(group.row, handles);
815
+ })
816
+ });
817
+ }
818
+ /** `rine_group_join` — accept an invite (or self-join an open group). */
819
+ function rineGroupJoinTool(opts = {}) {
820
+ return defineTool({
821
+ description: "Join a group by its handle or its UUID — or, when the group has already invited this agent, by its bare name: accept a pending invite, or self-join a publicly discovered open-enrollment group. Instant for open enrollment, and instant against an invitation this agent already holds. On a `majority` or `unanimity` group with no invitation it submits a request the group's electorate votes on. Calling it against a nomination another member filed for this agent is how consent is given: the request is not seated until this agent has asked for it, so a nomination that carries before that becomes an invitation to accept rather than a seat. This is a real, irreversible network action.",
822
+ inputSchema: jsonSchema(groupJoinInput),
823
+ outputSchema: STRING_OUTPUT,
824
+ needsApproval: approvalGate(opts),
825
+ execute: makeExecute(groupJoinInput, opts, async (client, i) => {
826
+ const groupId = await resolveJoinGroupUuid(client, i.group);
827
+ const result = await client.groups.join(groupId, { message: i.message });
828
+ return renderJoinResult(i.group, result);
829
+ })
830
+ });
831
+ }
832
+ /** `rine_group_invites` — list the caller's pending group invites. */
833
+ function rineGroupInvitesTool(opts = {}) {
834
+ return defineTool({
835
+ description: "List the live admissions addressed to your agent — group, who invited or nominated you, status, and any message. It reports both kinds and they are told apart by `status`: `invited` is an invitation you accept, `pending` is a nomination a member filed for you that the group's electorate is deciding. This is the only place a non-member reads its own nomination, and calling rine_group_join on one is how consent to it is given. Join or consent with rine_group_join using the group handle or id.",
836
+ inputSchema: jsonSchema(groupInvitesInput),
837
+ outputSchema: STRING_OUTPUT,
838
+ execute: makeExecute(groupInvitesInput, opts, async (client) => {
839
+ return renderInvites(await client.groups.listInvites());
840
+ })
841
+ });
842
+ }
843
+ //#endregion
844
+ //#region src/tools/messaging.ts
845
+ /**
846
+ * The 6 messaging tool factories: `rine_send`, `rine_send_and_wait`,
847
+ * `rine_inbox`, `rine_read`, `rine_thread`, `rine_reply`. Each returns an Eve
848
+ * `defineTool` descriptor — one `AsyncRineClient` call rendered to a string,
849
+ * wrapped by `makeExecute` (lazy env client + formatError). The runtime tool
850
+ * NAME comes from the filename slug of the scaffolded `agent/tools/<name>.ts`,
851
+ * not from here.
852
+ *
853
+ * Renderers read only plaintext/decrypt_error/verification,
854
+ * never the ciphertext envelope. `read`/`inbox`/`thread` add a `toModelOutput`
855
+ * redactor as defence-in-depth.
856
+ */
857
+ /** `rine_send` — send a 1:1 or group message (the SDK auto-routes groups). */
858
+ function rineSendTool(opts = {}) {
859
+ return defineTool({
860
+ description: "Send an end-to-end-encrypted message to another agent (`kofi@acme.rine.network` / UUID) or to a whole group over its E2EE channel — name the group by its own name (`logistics`), or by its full handle (`#logistics@acme.rine.network`). This is a real, irreversible network action: the message is delivered to a live recipient. Returns the new message id and conversation id.",
861
+ inputSchema: jsonSchema(sendInput),
862
+ outputSchema: STRING_OUTPUT,
863
+ needsApproval: approvalGate(opts),
864
+ execute: makeExecute(sendInput, opts, async (client, i) => {
865
+ const msg = await client.send(asRecipient(i.to), { text: i.body }, {
866
+ type: i.messageType,
867
+ idempotencyKey: i.idempotencyKey
868
+ });
869
+ return `Sent message ${msg.id} to ${i.to} (conversation ${msg.conversation_id}).`;
870
+ })
871
+ });
872
+ }
873
+ /** `rine_send_and_wait` — 1:1 send that blocks for a reply (ms timeout). */
874
+ function rineSendAndWaitTool(opts = {}) {
875
+ return defineTool({
876
+ description: "Send an end-to-end-encrypted message to a single agent (`kofi@acme.rine.network` / UUID) and block up to `waitSeconds` for a reply, returning the decrypted reply if one arrives. 1:1 only — use rine_send for groups. This is a real, irreversible network action.",
877
+ inputSchema: jsonSchema(sendAndWaitInput),
878
+ outputSchema: STRING_OUTPUT,
879
+ needsApproval: approvalGate(opts),
880
+ execute: makeExecute(sendAndWaitInput, opts, async (client, i) => {
881
+ try {
882
+ const { sent, reply } = await client.sendAndWait(asRecipient(i.to), { text: i.body }, {
883
+ timeout: i.waitSeconds * 1e3,
884
+ type: i.messageType
885
+ });
886
+ if (!reply) return `Sent ${sent.id}; no reply within ${i.waitSeconds}s.`;
887
+ return `Reply: ${renderMessageBody(reply)} (${verifiedNote(reply)})`;
888
+ } catch (err) {
889
+ if (isGroupUnsupportedOnWait(err)) return GROUP_ON_WAIT_MESSAGE;
890
+ throw err;
891
+ }
892
+ })
893
+ });
894
+ }
895
+ /**
896
+ * `rine_inbox` — poll the inbox under one status filter, decrypt what it
897
+ * returns, and on the `new` path best-effort `markDelivered` the decryptable
898
+ * ids so a later check returns only newer mail. On ack failure: warn but still
899
+ * return the reads.
900
+ */
901
+ function rineInboxTool(opts = {}) {
902
+ return defineTool({
903
+ description: "Fetch and decrypt messages from your inbox (1:1 and group). Defaults to `status: \"new\"` — the mail this agent has not acknowledged yet — and marks what it returns delivered, so a later check returns only newer mail. `delivered`, `read` and `all` re-read mail that was already acknowledged and mark nothing. Returns a numbered list of decrypted messages, or 'No new messages.' when nothing matched.",
904
+ inputSchema: jsonSchema(inboxInput),
905
+ outputSchema: STRING_OUTPUT,
906
+ toModelOutput: redactToText,
907
+ execute: makeExecute(inboxInput, opts, async (client, i) => {
908
+ const items = (await client.inbox({
909
+ status: i.status === "all" ? void 0 : i.status,
910
+ limit: i.limit
911
+ })).items;
912
+ const rendered = renderInbox(items);
913
+ if (i.status !== "new") return rendered;
914
+ const decryptableIds = items.filter((m) => !m.decrypt_error).map((m) => m.id);
915
+ if (decryptableIds.length === 0) return rendered;
916
+ try {
917
+ await client.markDelivered(decryptableIds);
918
+ return rendered;
919
+ } catch {
920
+ return `${rendered}\n[WARN] could not mark messages delivered; they may reappear on the next check.`;
921
+ }
922
+ })
923
+ });
924
+ }
925
+ /** `rine_read` — fetch + decrypt one message by id. */
926
+ function rineReadTool(opts = {}) {
927
+ return defineTool({
928
+ description: "Fetch and decrypt a single message by its UUID. Returns the sender, type, decrypted body, and signature status.",
929
+ inputSchema: jsonSchema(readInput),
930
+ outputSchema: STRING_OUTPUT,
931
+ toModelOutput: redactToText,
932
+ execute: makeExecute(readInput, opts, async (client, i) => {
933
+ return renderSingleMessage(await client.read(asMessageUuid(i.messageId)));
934
+ })
935
+ });
936
+ }
937
+ /** `rine_thread` — fetch the both-sided, decrypted transcript of a conversation. */
938
+ function rineThreadTool(opts = {}) {
939
+ return defineTool({
940
+ description: `Fetch the both-sided, decrypted transcript of a conversation or a group. ${THREAD_REF_RULE} Returns every turn ordered oldest→newest, each role-tagged (\`[sent] you:\` / \`[received] handle:\`). A turn you cannot decrypt renders \`[unavailable]\`, except one sealed under a sender key this agent does not hold, which says so on the first such turn and marks the rest. A group's transcript starts where the reading agent was seated. Use to recover the full context of a conversation or a group on demand.`,
941
+ inputSchema: jsonSchema(threadInput),
942
+ outputSchema: STRING_OUTPUT,
943
+ toModelOutput: redactToText,
944
+ execute: makeExecute(threadInput, opts, async (client, i) => {
945
+ const ref = resolveThreadRef({
946
+ group: i.group ?? null,
947
+ conversationId: i.conversationId ?? null
948
+ }, THREAD_REF_SURFACES.sdk);
949
+ return renderThread(ref.kind === "group" ? await client.thread({
950
+ group: ref.ref,
951
+ limit: i.limit
952
+ }) : await client.thread(ref.conversationId, { limit: i.limit }));
953
+ })
954
+ });
955
+ }
956
+ /** `rine_reply` — reply to a message, threading into the same conversation. */
957
+ function rineReplyTool(opts = {}) {
958
+ return defineTool({
959
+ description: "Send an end-to-end-encrypted reply to a message (by its UUID), threading into the same conversation. This is a real, irreversible network action. Returns the reply's message id.",
960
+ inputSchema: jsonSchema(replyInput),
961
+ outputSchema: STRING_OUTPUT,
962
+ needsApproval: approvalGate(opts),
963
+ execute: makeExecute(replyInput, opts, async (client, i) => {
964
+ const reply = await client.reply(asMessageUuid(i.messageId), { text: i.body }, { type: i.messageType });
965
+ return `Replied to ${i.messageId} -> ${reply.id}.`;
966
+ })
967
+ });
968
+ }
969
+ //#endregion
970
+ //#region src/tools/payments.ts
971
+ /**
972
+ * The 2 x402 payment tool factories: `rine_pay` (payer) and `rine_fulfill`
973
+ * (payee). Thin adapters over the ts-sdk `client.payments` facade — signing,
974
+ * spend policy, journal, and facilitator verify/settle all live in rine-core; the
975
+ * wallet key is never surfaced. Each returns an Eve `defineTool` descriptor whose
976
+ * `execute` resolves to a TYPED STATUS STRING (never a reject for an expected
977
+ * refusal), so the agent reasons over the outcome.
978
+ *
979
+ * `rine_pay` reuses the shipped `rine_pay` MCP status vocabulary VERBATIM:
980
+ * `payment-submitted` / `no-wallet` / `not-payment-required` / `policy-refused` /
981
+ * `above-auto-pay-threshold` / `already-paid` / `wallet-busy`. `rine_fulfill`
982
+ * reports the PINNED payee vocabulary (`settled` / `settlement-failed` /
983
+ * `verification-failed` / `facilitator-error` / `no-facilitator` / `not-payment`),
984
+ * identical across every surface (CLI/MCP/eve/mastra), with the facilitator's
985
+ * network slug stored VERBATIM (never CAIP-2 string-matched).
986
+ */
987
+ /** The `rine_pay` terminal statuses, reused verbatim from the MCP payer tool. */
988
+ const PAY_STATUS = {
989
+ SUBMITTED: "payment-submitted",
990
+ NO_WALLET: "no-wallet",
991
+ NOT_PAYMENT_REQUIRED: "not-payment-required",
992
+ POLICY_REFUSED: "policy-refused",
993
+ ABOVE_AUTO_PAY_THRESHOLD: "above-auto-pay-threshold",
994
+ ALREADY_PAID: "already-paid",
995
+ WALLET_BUSY: "wallet-busy"
996
+ };
997
+ /**
998
+ * The `rine_fulfill` payee statuses — PINNED across every surface (CLI/MCP/eve/
999
+ * mastra) so an LLM or operator script parses one vocabulary everywhere. Mirrors
1000
+ * the MCP `rine_fulfill` reference set; eve emits the subset it can reach (the
1001
+ * ts-sdk `fulfill` facade owns decryption, so `no-keys` never surfaces here).
1002
+ */
1003
+ const FULFILL_STATUS = {
1004
+ SETTLED: "settled",
1005
+ SETTLEMENT_FAILED: "settlement-failed",
1006
+ VERIFICATION_FAILED: "verification-failed",
1007
+ FACILITATOR_ERROR: "facilitator-error",
1008
+ NO_FACILITATOR: "no-facilitator",
1009
+ NOT_PAYMENT: "not-payment"
1010
+ };
1011
+ /** `<status> — <detail>`: the stable, parseable typed-status line. */
1012
+ function status(word, detail) {
1013
+ return `${word} — ${detail}`;
1014
+ }
1015
+ /** Atomic-unit amount of a requirement (x402 V2 `amount`, else V1 spelling). */
1016
+ function requirementSummary(r) {
1017
+ return `${r.amount ?? r.maxAmountRequired ?? "?"} of ${r.asset} on ${r.network} → ${r.payTo}`;
1018
+ }
1019
+ /** Map an x402 pay refusal to its typed status line (parity with MCP rine_pay). */
1020
+ function mapPayError(err, autoPay) {
1021
+ if (err instanceof X402Error) switch (err.code) {
1022
+ case X402_ERROR.ALREADY_PAID: return status(PAY_STATUS.ALREADY_PAID, `${err.message}. Pass allowRepay to pay it again.`);
1023
+ case X402_ERROR.WALLET_BUSY: return status(PAY_STATUS.WALLET_BUSY, err.message);
1024
+ case X402_ERROR.PER_TX_CAP_EXCEEDED:
1025
+ if (autoPay) return status(PAY_STATUS.ABOVE_AUTO_PAY_THRESHOLD, `${err.message}. Re-run without autoPay to authorize explicitly.`);
1026
+ return status(PAY_STATUS.POLICY_REFUSED, err.message);
1027
+ default: return status(PAY_STATUS.POLICY_REFUSED, err.message);
1028
+ }
1029
+ throw err;
1030
+ }
1031
+ /** `rine_pay` — pay a received x402 quote in-thread (payer). */
1032
+ function rinePayTool(opts = {}) {
1033
+ return defineTool({
1034
+ description: "Pay a received x402 payment request (rine.v1.x402_payment_required) in-thread. Decrypts the quote, selects an acceptable requirement under your local spend policy (deny-by-default, caps), signs an EIP-3009 stablecoin authorization with your wallet, and sends the signed payment back in the same conversation. The spend is reserved as the last act before the send (fail-closed, permanent, no refund). Returns a typed `status` — `payment-submitted` on success, else `no-wallet` / `not-payment-required` / `policy-refused` / `above-auto-pay-threshold` / `already-paid` / `wallet-busy`. Does NOT wait for settlement; the receipt arrives later as an ordinary inbox message.",
1035
+ inputSchema: jsonSchema(payInput),
1036
+ outputSchema: STRING_OUTPUT,
1037
+ needsApproval: approvalGate(opts),
1038
+ execute: makeExecute(payInput, opts, async (client, i) => {
1039
+ const msg = await client.read(asMessageUuid(i.messageId));
1040
+ if (msg.type !== X402_MESSAGE_TYPE.PAYMENT_REQUIRED) return status(PAY_STATUS.NOT_PAYMENT_REQUIRED, `message ${i.messageId} is type '${msg.type}', not an x402 payment request.`);
1041
+ try {
1042
+ await client.payments.walletAddress();
1043
+ } catch {
1044
+ return status(PAY_STATUS.NO_WALLET, "no payment wallet is configured for this agent; create one before paying.");
1045
+ }
1046
+ try {
1047
+ const res = await client.payments.pay(msg, {
1048
+ autoPay: i.autoPay,
1049
+ emitMarker: i.emitMarker,
1050
+ allowRepay: i.allowRepay
1051
+ });
1052
+ return status(PAY_STATUS.SUBMITTED, `sent x402 payment ${res.payment.id} (${requirementSummary(res.requirement)}). The settlement receipt will arrive as a later inbox message.`);
1053
+ } catch (err) {
1054
+ return mapPayError(err, i.autoPay);
1055
+ }
1056
+ })
1057
+ });
1058
+ }
1059
+ /**
1060
+ * Resolve the facilitator config from factory opts or `RINE_FACILITATOR`. A
1061
+ * `null` return means none is configured (surfaced as `no-facilitator`). An
1062
+ * unrecognised reference — neither a preset name nor an `http(s)://` base URL —
1063
+ * throws an actionable config error instead of being handed to `fetch` as a
1064
+ * bogus URL: parity with the CLI/MCP `resolveFacilitator`, so a typo like
1065
+ * `payia` surfaces as a config error naming the bad value, not an opaque
1066
+ * `facilitator-error` from a doomed network call.
1067
+ */
1068
+ function resolveFacilitator(opts) {
1069
+ const ref = opts.facilitator ?? process.env.RINE_FACILITATOR;
1070
+ if (!ref) return null;
1071
+ const headers = opts.facilitatorHeaders;
1072
+ const preset = FACILITATOR_PRESET[ref];
1073
+ if (preset) return headers ? {
1074
+ ...preset,
1075
+ headers
1076
+ } : preset;
1077
+ if (!/^https?:\/\//.test(ref)) throw new Error(`Unknown facilitator '${ref}'. Use a preset (${Object.keys(FACILITATOR_PRESET).join(" | ")}) or an http(s):// base URL.`);
1078
+ return headers ? {
1079
+ url: ref,
1080
+ headers
1081
+ } : { url: ref };
1082
+ }
1083
+ /** Render a settle-first {@link FulfillResult} to a typed outcome line. */
1084
+ function renderFulfill(res) {
1085
+ if (!res.verification.isValid) return status(FULFILL_STATUS.VERIFICATION_FAILED, `verification failed (${res.verification.invalidReason ?? "verification_failed"}); a failure receipt was sent so the payer reaches a terminal state.`);
1086
+ const s = res.settlement;
1087
+ if (s?.success) return status(FULFILL_STATUS.SETTLED, `receipt sent; tx ${s.transaction} on ${s.network}${s.payer ? `, payer ${s.payer}` : ""}.`);
1088
+ return status(FULFILL_STATUS.SETTLEMENT_FAILED, `settlement did not succeed (${s?.errorReason ?? "unknown"}); receipt sent.`);
1089
+ }
1090
+ /** `rine_fulfill` — verify + settle a received payment and send the receipt (payee). */
1091
+ function rineFulfillTool(opts = {}) {
1092
+ return defineTool({
1093
+ description: "Fulfill a received x402 payment (rine.v1.x402_payment): verify the signed authorization and settle it on-chain via your configured facilitator, then send the settlement receipt back in-thread. Settle-first — on a failed verification, a `success:false` receipt is sent so the payer reaches a terminal state. Facilitator verify/settle is plain external HTTP, never a rine endpoint. Returns a typed `status` — `settled` on success, else `settlement-failed` / `verification-failed` / `facilitator-error` / `no-facilitator` / `not-payment`.",
1094
+ inputSchema: jsonSchema(fulfillInput),
1095
+ outputSchema: STRING_OUTPUT,
1096
+ needsApproval: approvalGate(opts),
1097
+ execute: makeExecute(fulfillInput, opts, async (client, i) => {
1098
+ const msg = await client.read(asMessageUuid(i.messageId));
1099
+ if (msg.type !== X402_MESSAGE_TYPE.PAYMENT) return status(FULFILL_STATUS.NOT_PAYMENT, `message ${i.messageId} is type '${msg.type}', not an x402 payment authorization.`);
1100
+ const facilitator = resolveFacilitator(opts);
1101
+ if (!facilitator) return status(FULFILL_STATUS.NO_FACILITATOR, "no facilitator is configured; set RINE_FACILITATOR (a preset name — cdp/payai/x402-rs — or a base URL) to verify and settle payments.");
1102
+ try {
1103
+ return renderFulfill(await client.payments.fulfill(msg, {
1104
+ facilitator,
1105
+ emitMarker: i.emitMarker
1106
+ }));
1107
+ } catch (err) {
1108
+ if (err instanceof X402FacilitatorError) return status(FULFILL_STATUS.FACILITATOR_ERROR, err.message);
1109
+ throw err;
1110
+ }
1111
+ })
1112
+ });
1113
+ }
1114
+ //#endregion
1115
+ //#region src/tools/index.ts
1116
+ const FACTORIES = {
1117
+ rineSendTool,
1118
+ rineSendAndWaitTool,
1119
+ rineInboxTool,
1120
+ rineReadTool,
1121
+ rineReplyTool,
1122
+ rineThreadTool,
1123
+ rineDiscoverTool,
1124
+ rineInspectTool,
1125
+ rineDiscoverGroupsTool,
1126
+ rineWhoamiTool,
1127
+ rineGroupsTool,
1128
+ rineGroupCreateTool,
1129
+ rineGroupInviteTool,
1130
+ rineGroupRemoveTool,
1131
+ rineGroupInspectTool,
1132
+ rineGroupRosterTool,
1133
+ rineGroupJoinTool,
1134
+ rineGroupInvitesTool,
1135
+ rineGroupRequestsTool,
1136
+ rineGroupVoteTool,
1137
+ rineGroupLeaveTool,
1138
+ rineGroupSyncTool,
1139
+ rineGroupReclaimTool,
1140
+ rinePayTool,
1141
+ rineFulfillTool
1142
+ };
1143
+ /** The metadata registry with live factories attached (programmatic use). */
1144
+ const RINE_TOOLS = RINE_TOOL_META.map((m) => ({
1145
+ ...m,
1146
+ factory: FACTORIES[m.factoryName]
1147
+ }));
1148
+ //#endregion
1149
+ export { rineGroupVoteTool as C, rineWhoamiTool as E, rineGroupSyncTool as S, rineInspectTool as T, rineGroupRosterTool as _, rineReadTool as a, rineGroupReclaimTool as b, rineSendTool as c, rineGroupInspectTool as d, rineGroupInviteTool as f, rineDiscoverGroupsTool as g, rineGroupRemoveTool as h, rineInboxTool as i, rineThreadTool as l, rineGroupJoinTool as m, rineFulfillTool as n, rineReplyTool as o, rineGroupInvitesTool as p, rinePayTool as r, rineSendAndWaitTool as s, RINE_TOOLS as t, rineGroupCreateTool as u, rineGroupsTool as v, rineDiscoverTool as w, rineGroupRequestsTool as x, rineGroupLeaveTool as y };