relay-companion 0.1.91 → 0.1.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/overlay/inbox.html +60 -9
- package/overlay/main.cjs +66 -6
- package/overlay/preload.cjs +3 -0
- package/package.json +1 -1
- package/src/mcp.js +106 -1
package/overlay/inbox.html
CHANGED
|
@@ -1564,6 +1564,7 @@
|
|
|
1564
1564
|
// The relay row whose open-actions card is expanded. Like openingIds, this
|
|
1565
1565
|
// survives the innerHTML rebuilds renderRelays performs on every push.
|
|
1566
1566
|
let expandedRelayId = null;
|
|
1567
|
+
let expandedSentId = null; // Sent row whose open-actions menu is expanded
|
|
1567
1568
|
// A conversation expands its actions IN PLACE, exactly like a single relay.
|
|
1568
1569
|
// Taking over the whole pane (the old thread-detail push) lost the user's
|
|
1569
1570
|
// place in the list for what is really a three-item menu.
|
|
@@ -1588,7 +1589,11 @@
|
|
|
1588
1589
|
openingIds.set(id, performance.now());
|
|
1589
1590
|
for (const el of openingTargets(id)) el.classList.add("opening");
|
|
1590
1591
|
setTimeout(() => stopOpening(id), 6000); // safety: never spin forever if openDone is missed
|
|
1591
|
-
|
|
1592
|
+
// Sent rows carry the same three modes as received relays; each has its own
|
|
1593
|
+
// IPC so the sent copy is staged/forged before the open.
|
|
1594
|
+
if (source === "sent" && mode === "current" && window.relay.openSentInCurrent) window.relay.openSentInCurrent(id);
|
|
1595
|
+
else if (source === "sent" && mode === "fresh" && window.relay.openSentFresh) window.relay.openSentFresh(id);
|
|
1596
|
+
else if (source === "sent" && window.relay.openSent) window.relay.openSent(id);
|
|
1592
1597
|
else if (mode === "current" && window.relay.openInCurrent) window.relay.openInCurrent(id);
|
|
1593
1598
|
else if (mode === "fresh" && window.relay.openFresh) window.relay.openFresh(id);
|
|
1594
1599
|
else window.relay.open(id);
|
|
@@ -1731,10 +1736,12 @@
|
|
|
1731
1736
|
// Preview plus the two host-open actions, as one small text-first menu.
|
|
1732
1737
|
// Preview is the primary, non-navigation path; the other actions hand the
|
|
1733
1738
|
// relay to a current or fresh host session.
|
|
1734
|
-
function openActionsHtml(id, { thread, previewable = true, threadId = "", messageCount = 0 } = {}) {
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
const
|
|
1739
|
+
function openActionsHtml(id, { thread, previewable = true, threadId = "", messageCount = 0, sent = false } = {}) {
|
|
1740
|
+
// Sent rows use their own data-* namespace so the Sent list's wiring stays
|
|
1741
|
+
// independent of the Relays list (same menu, same three actions, own IPC).
|
|
1742
|
+
const preview = sent ? `data-sent-preview="${esc(id)}"` : thread ? `data-thread-preview="${esc(id)}"` : `data-preview="${esc(id)}"`;
|
|
1743
|
+
const cur = sent ? `data-sent-open-current="${esc(id)}"` : thread ? `data-thread-open-current="${esc(id)}"` : `data-open-current="${esc(id)}"`;
|
|
1744
|
+
const fresh = sent ? `data-sent-open-fresh="${esc(id)}"` : thread ? `data-thread-open-fresh="${esc(id)}"` : `data-open-fresh="${esc(id)}"`;
|
|
1738
1745
|
const previewItem = previewable
|
|
1739
1746
|
? `<button class="oa-item oa-primary" type="button" role="menuitem" ${preview}>${thread ? "Preview Latest Received Relay" : "Preview"}</button><div class="oa-sep" role="separator"></div>`
|
|
1740
1747
|
: "";
|
|
@@ -2102,8 +2109,10 @@
|
|
|
2102
2109
|
const preview = bodyPreview(body, subject, 118);
|
|
2103
2110
|
const detail = sentDetail(r);
|
|
2104
2111
|
const klass = sentClass(r);
|
|
2112
|
+
const expanded = id === expandedSentId;
|
|
2113
|
+
const openCard = expanded ? openActionsHtml(id, { sent: true }) : "";
|
|
2105
2114
|
return `
|
|
2106
|
-
<div class="row sent ${klass}" data-sent-id="${esc(id)}" data-opening-id="${esc(id)}">
|
|
2115
|
+
<div class="row sent ${klass}${expanded ? " expanded" : ""}" data-sent-id="${esc(id)}" data-opening-id="${esc(id)}">
|
|
2107
2116
|
<div class="rk-top">
|
|
2108
2117
|
<span class="rk-dot"></span>
|
|
2109
2118
|
<span class="rk-from">To</span>
|
|
@@ -2114,16 +2123,46 @@
|
|
|
2114
2123
|
<div class="rk-subject">${esc(subject)}</div>
|
|
2115
2124
|
${preview ? `<div class="rk-preview">${esc(preview)}</div>` : ""}
|
|
2116
2125
|
<div class="row-note">${esc(detail)}</div>
|
|
2126
|
+
${openCard}
|
|
2127
|
+
<div class="row-err${rowNotes.get(id)?.cls === "ok" ? " ok" : ""}" data-err="${esc(id)}">${esc(rowNotes.get(id)?.text || "")}</div>
|
|
2117
2128
|
</div>`;
|
|
2118
2129
|
}).join("");
|
|
2119
2130
|
wireSentRows();
|
|
2120
2131
|
}
|
|
2132
|
+
// A sent row expands into the SAME three-action menu as a received relay:
|
|
2133
|
+
// Preview (read your own message in place), Open in Current Chat, Open in
|
|
2134
|
+
// New Chat — each routed to the sent-side IPC.
|
|
2121
2135
|
function wireSentRows() {
|
|
2122
2136
|
for (const el of sentListEl.querySelectorAll(".row.sent")) {
|
|
2123
2137
|
const id = el.getAttribute("data-sent-id");
|
|
2124
2138
|
if (openingIds.has(id)) el.classList.add("opening");
|
|
2125
2139
|
el.addEventListener("click", () => {
|
|
2126
|
-
|
|
2140
|
+
expandedSentId = expandedSentId === id ? null : id;
|
|
2141
|
+
renderSent();
|
|
2142
|
+
});
|
|
2143
|
+
}
|
|
2144
|
+
for (const z of sentListEl.querySelectorAll('[data-stop="1"]')) {
|
|
2145
|
+
z.addEventListener("click", (e) => e.stopPropagation());
|
|
2146
|
+
}
|
|
2147
|
+
for (const btn of sentListEl.querySelectorAll("[data-sent-preview]")) {
|
|
2148
|
+
btn.addEventListener("click", () => {
|
|
2149
|
+
expandedSentId = null;
|
|
2150
|
+
window.relay.preview(btn.getAttribute("data-sent-preview"));
|
|
2151
|
+
renderSent();
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
2154
|
+
for (const btn of sentListEl.querySelectorAll("[data-sent-open-current]")) {
|
|
2155
|
+
const id = btn.getAttribute("data-sent-open-current");
|
|
2156
|
+
btn.addEventListener("click", () => {
|
|
2157
|
+
expandedSentId = null;
|
|
2158
|
+
openRelayFromUI(id, "sent", "current");
|
|
2159
|
+
});
|
|
2160
|
+
}
|
|
2161
|
+
for (const btn of sentListEl.querySelectorAll("[data-sent-open-fresh]")) {
|
|
2162
|
+
const id = btn.getAttribute("data-sent-open-fresh");
|
|
2163
|
+
btn.addEventListener("click", () => {
|
|
2164
|
+
expandedSentId = null;
|
|
2165
|
+
openRelayFromUI(id, "sent", "fresh");
|
|
2127
2166
|
});
|
|
2128
2167
|
}
|
|
2129
2168
|
}
|
|
@@ -2945,6 +2984,7 @@
|
|
|
2945
2984
|
// relays plus the live Sent list. Local data only — the pill shows the
|
|
2946
2985
|
// user's own exchanges; the API's /v1/threads serves web and agents.
|
|
2947
2986
|
let threadDetailId = null; // threadId while a thread's history is open
|
|
2987
|
+
let threadDetailScrolledFor = null; // thread already auto-scrolled to newest (reset on leave)
|
|
2948
2988
|
let threadDetailPartyHint = ""; // who it's with, for the header while messages load
|
|
2949
2989
|
function threadMessages() {
|
|
2950
2990
|
const msgs = [];
|
|
@@ -3044,7 +3084,11 @@
|
|
|
3044
3084
|
const threadNote = threadOpenTarget
|
|
3045
3085
|
? `<div class="row-err${rowNotes.get(threadOpenTarget.id)?.cls === "ok" ? " ok" : ""}" data-err="${esc(threadOpenTarget.id)}" data-stop="1">${esc(rowNotes.get(threadOpenTarget.id)?.text || "")}</div>`
|
|
3046
3086
|
: "";
|
|
3047
|
-
|
|
3087
|
+
// Chat-shaped, like every messaging app (and Claude itself — Sven's field
|
|
3088
|
+
// note): messages read oldest -> newest downward, the open actions sit at
|
|
3089
|
+
// the BOTTOM next to the newest message, and entering the thread lands you
|
|
3090
|
+
// at the newest message, not the stalest.
|
|
3091
|
+
thHistoryEl.innerHTML = thread.msgs.map((m) => `
|
|
3048
3092
|
<button class="th-msg${m.unread ? " unread" : ""}" type="button" data-msg="${esc(m.id)}" data-dir="${m.direction}">
|
|
3049
3093
|
<span class="th-msg-top">
|
|
3050
3094
|
<span class="th-dir${m.direction === "out" ? " out" : ""}">${m.direction === "out" ? "←" : "→"}</span>
|
|
@@ -3052,7 +3096,13 @@
|
|
|
3052
3096
|
<span class="th-time">${esc(timeAgo(m.at))}</span>
|
|
3053
3097
|
</span>
|
|
3054
3098
|
<span class="th-msg-title">${esc(m.title)}</span>
|
|
3055
|
-
</button>`).join("");
|
|
3099
|
+
</button>`).join("") + threadActions + threadNote;
|
|
3100
|
+
// Land at the newest message when ENTERING the thread; rebuilds of the same
|
|
3101
|
+
// open thread (payload pushes) keep the user's scroll position untouched.
|
|
3102
|
+
if (threadDetailScrolledFor !== thread.threadId) {
|
|
3103
|
+
threadDetailScrolledFor = thread.threadId;
|
|
3104
|
+
requestAnimationFrame(() => { scrollEl.scrollTop = scrollEl.scrollHeight; });
|
|
3105
|
+
}
|
|
3056
3106
|
for (const el of thHistoryEl.querySelectorAll(".th-msg")) {
|
|
3057
3107
|
el.addEventListener("click", () => {
|
|
3058
3108
|
const id = el.getAttribute("data-msg");
|
|
@@ -3068,6 +3118,7 @@
|
|
|
3068
3118
|
}
|
|
3069
3119
|
thBackEl.addEventListener("click", () => {
|
|
3070
3120
|
threadDetailId = null;
|
|
3121
|
+
threadDetailScrolledFor = null;
|
|
3071
3122
|
activeView = "relays";
|
|
3072
3123
|
syncTabs();
|
|
3073
3124
|
applyView();
|
package/overlay/main.cjs
CHANGED
|
@@ -1341,7 +1341,11 @@ function previewPayloadForPacket(packetId) {
|
|
|
1341
1341
|
const id = String(packetId || "");
|
|
1342
1342
|
if (!id) return null;
|
|
1343
1343
|
const row = rowById(id);
|
|
1344
|
-
if (!row || row.direction !== "inbound" || RELAY_HIDDEN_KINDS.has(row.relayNotificationKind))
|
|
1344
|
+
if (!row || row.direction !== "inbound" || RELAY_HIDDEN_KINDS.has(row.relayNotificationKind)) {
|
|
1345
|
+
// A Sent row lives in sentCache, not the staged packet store — read your own
|
|
1346
|
+
// outbound message without leaving the pill, exactly like an inbound preview.
|
|
1347
|
+
return previewPayloadForSent(id);
|
|
1348
|
+
}
|
|
1345
1349
|
return {
|
|
1346
1350
|
relayId: id,
|
|
1347
1351
|
title: String(row.title || row.displayTitle || "Relay"),
|
|
@@ -1352,6 +1356,27 @@ function previewPayloadForPacket(packetId) {
|
|
|
1352
1356
|
};
|
|
1353
1357
|
}
|
|
1354
1358
|
|
|
1359
|
+
// The preview payload for one of the user's own sent relays. `unread:false`
|
|
1360
|
+
// always: a sent message has no read state of its own to retire, and the
|
|
1361
|
+
// preview's ack path must never touch the recipient's receipts.
|
|
1362
|
+
function previewPayloadForSent(relayId) {
|
|
1363
|
+
const id = String(relayId || "");
|
|
1364
|
+
if (!id) return null;
|
|
1365
|
+
const item = (sentCache || []).find((row) => String(row.relayId || row.id || "") === id);
|
|
1366
|
+
if (!item) return null;
|
|
1367
|
+
const recipient = item.recipient || {};
|
|
1368
|
+
const to = recipient.name || recipient.email || item.groupName || "the recipient";
|
|
1369
|
+
return {
|
|
1370
|
+
relayId: id,
|
|
1371
|
+
title: String(item.title || item.displayTitle || item.preview || "Relay"),
|
|
1372
|
+
bodyMarkdown: String(item.bodyMarkdown || item.preview || ""),
|
|
1373
|
+
senderName: `You → ${to}`,
|
|
1374
|
+
createdAt: String(item.createdAt || ""),
|
|
1375
|
+
unread: false,
|
|
1376
|
+
outbound: true,
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1355
1380
|
// Find an approvalId for a share_approval row. The staged row may not carry it,
|
|
1356
1381
|
// in which case the renderer routes to the web detail page (Review) instead.
|
|
1357
1382
|
function approvalIdForRow(row) {
|
|
@@ -2038,13 +2063,34 @@ function threadInfoFor(row) {
|
|
|
2038
2063
|
return { threadId, threadCount: ids.size };
|
|
2039
2064
|
}
|
|
2040
2065
|
|
|
2041
|
-
async function openPacketInCurrent(packetId) {
|
|
2066
|
+
async function openPacketInCurrent(packetId, { sent = false } = {}) {
|
|
2042
2067
|
if (!packetId) return;
|
|
2043
|
-
|
|
2068
|
+
let row = rowById(packetId);
|
|
2069
|
+
if (sent) {
|
|
2070
|
+
// A sent relay has no staged inbound row: forge its `sent_<relayId>` copy
|
|
2071
|
+
// exactly as the fresh-open path does, then inject THAT. The renderer keeps
|
|
2072
|
+
// addressing the row by its relayId, so spinner/notes stay on the right row.
|
|
2073
|
+
const sentItem = (sentCache || []).find((item) => String(item.relayId || item.id || "") === String(packetId));
|
|
2074
|
+
if (!sentItem) {
|
|
2075
|
+
console.error("[overlay] sent relay missing from cache:", packetId);
|
|
2076
|
+
if (win && !win.isDestroyed()) win.webContents.send("openDone", packetId);
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
try {
|
|
2080
|
+
const stageSentRelayItem = await loadSentStager();
|
|
2081
|
+
const staged = stageSentRelayItem({ item: sentItem, sender: account() }, { statePath: STATE_PATH });
|
|
2082
|
+
row = rowById(staged.itemId) || row;
|
|
2083
|
+
} catch (error) {
|
|
2084
|
+
console.error("[overlay] sent staging for in-chat open failed:", packetId, error && error.message);
|
|
2085
|
+
if (win && !win.isDestroyed()) win.webContents.send("openDone", packetId);
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2044
2089
|
if (win && !win.isDestroyed()) win.webContents.send("opening", packetId);
|
|
2045
2090
|
const finish = () => {
|
|
2046
|
-
//
|
|
2047
|
-
|
|
2091
|
+
// Sent relays carry no unread state of their own; acking one would touch the
|
|
2092
|
+
// inbound copy of a self-send. Only inbound opens ack.
|
|
2093
|
+
if (!sent) ackPacket(packetId);
|
|
2048
2094
|
if (win && !win.isDestroyed()) win.webContents.send("openDone", packetId); // stop the row spinner
|
|
2049
2095
|
};
|
|
2050
2096
|
// Confirm the staging in the UI: without this, a successful injection is
|
|
@@ -2061,7 +2107,7 @@ async function openPacketInCurrent(packetId) {
|
|
|
2061
2107
|
console.error(`[overlay] openInCurrent fallback for ${packetId}: ${note}`);
|
|
2062
2108
|
if (win && !win.isDestroyed()) win.webContents.send("openError", packetId, `${note} — opening a new chat instead.`);
|
|
2063
2109
|
}
|
|
2064
|
-
openPacket(packetId, { fresh: true }).catch((error) =>
|
|
2110
|
+
openPacket(packetId, { fresh: true, sent }).catch((error) =>
|
|
2065
2111
|
console.error("[overlay] openInCurrent fresh fallback failed:", error && error.message),
|
|
2066
2112
|
);
|
|
2067
2113
|
};
|
|
@@ -3189,6 +3235,20 @@ ipcMain.handle("relay:get", () => buildPayload());
|
|
|
3189
3235
|
ipcMain.on("relay:open", (_e, id) => {
|
|
3190
3236
|
openPacket(id).catch((error) => console.error("[overlay] open failed:", error && error.message));
|
|
3191
3237
|
});
|
|
3238
|
+
// Sent rows get the same three actions as received relays. "In current chat"
|
|
3239
|
+
// stages the sent copy through the SAME injection path (the materializer's
|
|
3240
|
+
// sent stager already forges a `sent_<relayId>` row), so the agent opens the
|
|
3241
|
+
// user's own message in the chat they are looking at.
|
|
3242
|
+
ipcMain.on("relay:openSentInCurrent", (_e, id) => {
|
|
3243
|
+
openPacketInCurrent(id, { sent: true }).catch((error) =>
|
|
3244
|
+
console.error("[overlay] sent open-in-current failed:", error && error.message),
|
|
3245
|
+
);
|
|
3246
|
+
});
|
|
3247
|
+
ipcMain.on("relay:openSentFresh", (_e, id) => {
|
|
3248
|
+
openPacket(id, { sent: true, fresh: true }).catch((error) =>
|
|
3249
|
+
console.error("[overlay] sent fresh open failed:", error && error.message),
|
|
3250
|
+
);
|
|
3251
|
+
});
|
|
3192
3252
|
ipcMain.on("relay:openSent", (_e, id) => {
|
|
3193
3253
|
openPacket(id, { sent: true }).catch((error) => console.error("[overlay] sent open failed:", error && error.message));
|
|
3194
3254
|
});
|
package/overlay/preload.cjs
CHANGED
|
@@ -29,6 +29,9 @@ contextBridge.exposeInMainWorld("relay", {
|
|
|
29
29
|
// Relay rows
|
|
30
30
|
open: (id) => ipcRenderer.send("relay:open", id),
|
|
31
31
|
openSent: (id) => ipcRenderer.send("relay:openSent", id),
|
|
32
|
+
// Sent rows get the same open-actions menu as received relays.
|
|
33
|
+
openSentInCurrent: (id) => ipcRenderer.send("relay:openSentInCurrent", id),
|
|
34
|
+
openSentFresh: (id) => ipcRenderer.send("relay:openSentFresh", id),
|
|
32
35
|
// expand-card actions: inject into the live Claude session / force a fresh one
|
|
33
36
|
preview: (id) => ipcRenderer.send("relay:preview", id),
|
|
34
37
|
openInCurrent: (id) => ipcRenderer.send("relay:openInCurrent", id),
|
package/package.json
CHANGED
package/src/mcp.js
CHANGED
|
@@ -103,9 +103,56 @@ export const TOOLS = [
|
|
|
103
103
|
{
|
|
104
104
|
name: "relay_groups_list",
|
|
105
105
|
description:
|
|
106
|
-
"List this human's Relay contact groups — named rosters of their saved contacts (e.g. 'Founders', 'Family') with each group's members. Use it when the human addresses several people at once or names a roster, then pass the chosen group's id to relay_send as recipient.groupId: the message fans out to every member and the whole roster shares ONE conversation thread where every member's reply reaches everyone (true group chat).
|
|
106
|
+
"List this human's Relay contact groups — named rosters of their saved contacts (e.g. 'Founders', 'Family') with each group's members. Use it when the human addresses several people at once or names a roster, then pass the chosen group's id to relay_send as recipient.groupId: the message fans out to every member and the whole roster shares ONE conversation thread where every member's reply reaches everyone (true group chat). To CHANGE a roster, use relay_group_create / relay_group_update / relay_group_delete — always list first so you act on a real groupId.",
|
|
107
107
|
inputSchema: { type: "object", properties: {} },
|
|
108
108
|
},
|
|
109
|
+
{
|
|
110
|
+
name: "relay_group_create",
|
|
111
|
+
description:
|
|
112
|
+
"Create a Relay contact group (a named roster) for this human, optionally with its starting members. Use it when they ask for a new group ('make a Founders group with Sven and Shane') — group names are unique per person, so call relay_groups_list first and use relay_group_update when the roster already exists. Members are contactIds from relay_contacts_search; look up every name BEFORE calling, and ask the human which person they meant rather than guessing between similar matches. Returns the created group with its members.",
|
|
113
|
+
inputSchema: {
|
|
114
|
+
type: "object",
|
|
115
|
+
properties: {
|
|
116
|
+
name: { type: "string", description: "The roster's display name, e.g. 'Founders'." },
|
|
117
|
+
memberContactIds: {
|
|
118
|
+
type: "array",
|
|
119
|
+
items: { type: "string" },
|
|
120
|
+
description: "Optional starting members: exact contactIds from relay_contacts_search.",
|
|
121
|
+
},
|
|
122
|
+
idempotencyKey: { type: "string" },
|
|
123
|
+
},
|
|
124
|
+
required: ["name", "idempotencyKey"],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: "relay_group_update",
|
|
129
|
+
description:
|
|
130
|
+
"Rename a Relay contact group and/or add and remove its members — the same edits the human can make in the Relay pill and website. Pass any combination of name, addContactIds and removeContactIds; each member change is applied independently, so a partial failure still reports what landed. Members are contactIds from relay_contacts_search. Removing a member only takes them off the roster: it never deletes the contact and never touches past conversations. Returns the updated group.",
|
|
131
|
+
inputSchema: {
|
|
132
|
+
type: "object",
|
|
133
|
+
properties: {
|
|
134
|
+
groupId: { type: "string", description: "The group's id (grp_...) from relay_groups_list or relay_contacts_search." },
|
|
135
|
+
name: { type: "string", description: "New display name. Omit to leave the name unchanged." },
|
|
136
|
+
addContactIds: { type: "array", items: { type: "string" }, description: "contactIds to add to the roster." },
|
|
137
|
+
removeContactIds: { type: "array", items: { type: "string" }, description: "contactIds to take off the roster." },
|
|
138
|
+
idempotencyKey: { type: "string" },
|
|
139
|
+
},
|
|
140
|
+
required: ["groupId", "idempotencyKey"],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: "relay_group_delete",
|
|
145
|
+
description:
|
|
146
|
+
"Delete a Relay contact group. This removes the roster only — every member remains in the human's contact book and past group conversations stay intact — but the group can no longer be addressed as a recipient. Destructive and not undoable: confirm with the human first, and never delete a group they did not explicitly name.",
|
|
147
|
+
inputSchema: {
|
|
148
|
+
type: "object",
|
|
149
|
+
properties: {
|
|
150
|
+
groupId: { type: "string", description: "The group's id (grp_...) from relay_groups_list." },
|
|
151
|
+
idempotencyKey: { type: "string" },
|
|
152
|
+
},
|
|
153
|
+
required: ["groupId", "idempotencyKey"],
|
|
154
|
+
},
|
|
155
|
+
},
|
|
109
156
|
{
|
|
110
157
|
name: "relay_contact_update",
|
|
111
158
|
description:
|
|
@@ -501,6 +548,11 @@ export const MESSAGE_ONLY_TOOL_NAMES = new Set([
|
|
|
501
548
|
"relay_contacts_search",
|
|
502
549
|
"relay_contact_update",
|
|
503
550
|
"relay_groups_list",
|
|
551
|
+
// Group management is ordinary-messaging functionality (the pill and website
|
|
552
|
+
// expose it in messages-only mode), so it belongs in this profile too.
|
|
553
|
+
"relay_group_create",
|
|
554
|
+
"relay_group_update",
|
|
555
|
+
"relay_group_delete",
|
|
504
556
|
"relay_inbox_list",
|
|
505
557
|
"relay_thread_fetch",
|
|
506
558
|
"relay_acknowledge",
|
|
@@ -558,6 +610,59 @@ export async function handleCall(client, name, args, { mode = DEFAULT_COMPANION_
|
|
|
558
610
|
return text(await client.searchContacts(args.query));
|
|
559
611
|
case "relay_groups_list":
|
|
560
612
|
return text(await client.groups());
|
|
613
|
+
case "relay_group_create": {
|
|
614
|
+
const group = await client.createGroup({ name: args.name });
|
|
615
|
+
const ids = Array.isArray(args.memberContactIds) ? args.memberContactIds.filter(Boolean) : [];
|
|
616
|
+
// Members are added one call at a time (the API's own shape). Report per-id
|
|
617
|
+
// outcomes instead of failing the whole create: a group that exists with
|
|
618
|
+
// three of four members is a real, recoverable state the agent must see.
|
|
619
|
+
const added = [];
|
|
620
|
+
const failed = [];
|
|
621
|
+
let current = group;
|
|
622
|
+
for (const contactId of ids) {
|
|
623
|
+
try {
|
|
624
|
+
current = await client.addGroupMember(group.id, contactId);
|
|
625
|
+
added.push(contactId);
|
|
626
|
+
} catch (err) {
|
|
627
|
+
failed.push({ contactId, error: err?.message || String(err) });
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return text({ group: current, added, ...(failed.length ? { failed } : {}) });
|
|
631
|
+
}
|
|
632
|
+
case "relay_group_update": {
|
|
633
|
+
const groupId = args.groupId;
|
|
634
|
+
let current = null;
|
|
635
|
+
const added = [];
|
|
636
|
+
const removed = [];
|
|
637
|
+
const failed = [];
|
|
638
|
+
if (typeof args.name === "string" && args.name.trim()) {
|
|
639
|
+
current = await client.renameGroup(groupId, { name: args.name.trim() });
|
|
640
|
+
}
|
|
641
|
+
for (const contactId of Array.isArray(args.addContactIds) ? args.addContactIds.filter(Boolean) : []) {
|
|
642
|
+
try {
|
|
643
|
+
current = await client.addGroupMember(groupId, contactId);
|
|
644
|
+
added.push(contactId);
|
|
645
|
+
} catch (err) {
|
|
646
|
+
failed.push({ contactId, op: "add", error: err?.message || String(err) });
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
for (const contactId of Array.isArray(args.removeContactIds) ? args.removeContactIds.filter(Boolean) : []) {
|
|
650
|
+
try {
|
|
651
|
+
current = await client.removeGroupMember(groupId, contactId);
|
|
652
|
+
removed.push(contactId);
|
|
653
|
+
} catch (err) {
|
|
654
|
+
failed.push({ contactId, op: "remove", error: err?.message || String(err) });
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
// No-op calls still return the group, so the agent always sees real state.
|
|
658
|
+
if (!current) {
|
|
659
|
+
const all = await client.groups();
|
|
660
|
+
current = (all.groups || []).find((g) => g.id === groupId) || null;
|
|
661
|
+
}
|
|
662
|
+
return text({ group: current, added, removed, ...(failed.length ? { failed } : {}) });
|
|
663
|
+
}
|
|
664
|
+
case "relay_group_delete":
|
|
665
|
+
return text(await client.deleteGroup(args.groupId));
|
|
561
666
|
case "relay_contact_update":
|
|
562
667
|
return text(
|
|
563
668
|
await client.updateContact(args.contactId, {
|