relay-companion 0.1.63 → 0.1.64

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.
@@ -50,7 +50,7 @@ function defaultLog(...args) {
50
50
  // silent dead end. `finish` (ack + spinner stop) fires exactly once for the
51
51
  // bridge/automation tiers; the fresh tier's openPacket owns its own lifecycle.
52
52
  async function openCodexInCurrent(
53
- { packetId, senderName = "", title = "" } = {},
53
+ { packetId, senderName = "", title = "", threadId = null, threadCount = 0 } = {},
54
54
  {
55
55
  inject, // src/codex-inject.js
56
56
  desktop, // src/codex-desktop.js
@@ -90,7 +90,7 @@ async function openCodexInCurrent(
90
90
  `last active ${new Date(target.lastActiveAt).toISOString()})`,
91
91
  );
92
92
 
93
- const instruction = inject.buildInjectionInstruction({ relayId: packetId, senderName, title });
93
+ const instruction = inject.buildInjectionInstruction({ relayId: packetId, senderName, title, threadId, threadCount });
94
94
 
95
95
  // ---- (a) bridge tier ----------------------------------------------------
96
96
  if (!bridgeSubmitEnabled(env)) {
@@ -2462,7 +2462,18 @@
2462
2462
  return renderThreads();
2463
2463
  }
2464
2464
  thDetailNameEl.textContent = thread.party;
2465
- thHistoryEl.innerHTML = thread.msgs.map((m) => `
2465
+ // Thread-level opens act on the newest inbound message (falling back to the
2466
+ // newest message overall): "current" injects with the threadId so the agent
2467
+ // fetches the whole exchange; "new chat" seeds the full transcript.
2468
+ const newestInbound = [...thread.msgs].reverse().find((m) => m.direction === "in") || thread.msgs[thread.msgs.length - 1];
2469
+ const threadActions = newestInbound ? `
2470
+ <div class="td-card row-open-card" data-stop="1">
2471
+ <div class="td-card-actions">
2472
+ <button class="td-btn primary" type="button" data-thread-open-current="${esc(newestInbound.id)}">Open thread in current chat</button>
2473
+ <button class="td-btn ghost" type="button" data-thread-open-fresh="${esc(newestInbound.id)}">Open thread in new chat</button>
2474
+ </div>
2475
+ </div>` : "";
2476
+ thHistoryEl.innerHTML = threadActions + thread.msgs.map((m) => `
2466
2477
  <button class="th-msg${m.unread ? " unread" : ""}" type="button" data-msg="${esc(m.id)}" data-dir="${m.direction}">
2467
2478
  <span class="th-msg-top">
2468
2479
  <span class="th-dir${m.direction === "out" ? " out" : ""}">${m.direction === "out" ? "←" : "→"}</span>
@@ -2477,6 +2488,10 @@
2477
2488
  openRelayFromUI(id, el.getAttribute("data-dir") === "out" ? "sent" : "relay");
2478
2489
  });
2479
2490
  }
2491
+ const curBtn = thHistoryEl.querySelector("[data-thread-open-current]");
2492
+ if (curBtn) curBtn.addEventListener("click", () => openRelayFromUI(curBtn.getAttribute("data-thread-open-current"), "relay", "current"));
2493
+ const freshBtn = thHistoryEl.querySelector("[data-thread-open-fresh]");
2494
+ if (freshBtn) freshBtn.addEventListener("click", () => openRelayFromUI(freshBtn.getAttribute("data-thread-open-fresh"), "relay", "fresh"));
2480
2495
  }
2481
2496
  thBackEl.addEventListener("click", () => {
2482
2497
  threadDetailId = null;
package/overlay/main.cjs CHANGED
@@ -1508,6 +1508,21 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
1508
1508
  // tools; the sender's content is never inlined (relay-briefing's
1509
1509
  // untrusted-content discipline). A missing row or no live session/thread
1510
1510
  // within 6h falls back to the fresh-open flow.
1511
+ // How many messages (inbound rows + sent copies, deduped) share a thread.
1512
+ // Gives the injected instruction its "N messages so far" and tells the model
1513
+ // when fetching the whole thread is worth it.
1514
+ function threadInfoFor(row) {
1515
+ const threadId = (row && row.threadId) || null;
1516
+ if (!threadId) return { threadId: null, threadCount: 0 };
1517
+ const ids = new Set();
1518
+ for (const r of readRelays()) if (r.threadId === threadId) ids.add(r.id);
1519
+ for (const s of sentCache || []) {
1520
+ const sid = s.relayId || s.id;
1521
+ if (sid && (s.threadId || sid) === threadId) ids.add(sid);
1522
+ }
1523
+ return { threadId, threadCount: ids.size };
1524
+ }
1525
+
1511
1526
  async function openPacketInCurrent(packetId) {
1512
1527
  if (!packetId) return;
1513
1528
  const row = rowById(packetId);
@@ -1539,6 +1554,7 @@ async function openPacketInCurrent(packetId) {
1539
1554
  packetId,
1540
1555
  senderName: row.senderName || "",
1541
1556
  title: row.title || row.displayTitle || "",
1557
+ ...threadInfoFor(row),
1542
1558
  },
1543
1559
  {
1544
1560
  inject,
@@ -1574,6 +1590,7 @@ async function openPacketInCurrent(packetId) {
1574
1590
  relayId: packetId,
1575
1591
  senderName: row.senderName || "",
1576
1592
  title: row.title || row.displayTitle || "",
1593
+ ...threadInfoFor(row),
1577
1594
  });
1578
1595
  } catch (error) {
1579
1596
  console.error("[overlay] claude injection staging failed:", error && error.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.63",
3
+ "version": "0.1.64",
4
4
  "description": "Relay companion for ordinary messages, with dormant coordination features available only by explicit opt-in.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -81,15 +81,28 @@ function sanitizeInline(value, cap = 140) {
81
81
  return flat.length > cap ? `${flat.slice(0, cap - 1).trimEnd()}\u2026` : flat;
82
82
  }
83
83
 
84
- function buildInjectionInstruction({ relayId, senderName, title } = {}) {
84
+ function buildInjectionInstruction({ relayId, senderName, title, threadId, threadCount } = {}) {
85
85
  const id = sanitizeInline(relayId, 80) || "unknown";
86
86
  const sender = sanitizeInline(senderName) || "someone";
87
87
  const subject = sanitizeInline(title) || "a relay";
88
- return (
88
+ const base =
89
89
  `The user received relay ${id} from ${sender}: '${subject}'. ` +
90
90
  "They clicked Open-in-current-chat. Fetch and open it now with the relay MCP tools " +
91
- "(relay_inbox_list / GET the relay), then act on it."
92
- );
91
+ "(relay_inbox_list / GET the relay), then act on it.";
92
+ const tid = sanitizeInline(threadId, 80);
93
+ const count = Number(threadCount) || 0;
94
+ if (tid && count > 1) {
95
+ return (
96
+ base +
97
+ ` This relay is part of a conversation thread (threadId ${tid}, ${count} messages so far). ` +
98
+ "Call relay_thread_fetch with that threadId FIRST so the whole exchange is in your context, " +
99
+ "then act on the latest message in the thread."
100
+ );
101
+ }
102
+ if (tid) {
103
+ return base + ` (Its threadId is ${tid}; if replies exist later, relay_thread_fetch retrieves the full conversation.)`;
104
+ }
105
+ return base;
93
106
  }
94
107
 
95
108
  // Stage one pending injection for a live session (or `sessionId: "any"` to
package/src/client.js CHANGED
@@ -201,6 +201,10 @@ export class RelayClient {
201
201
  return this.#req("POST", "/v1/tools/call", payload);
202
202
  }
203
203
 
204
+ thread(threadId) {
205
+ return this.#req("GET", `/v1/threads/${encodeURIComponent(threadId)}`);
206
+ }
207
+
204
208
  searchContacts(q) {
205
209
  return this.#req("GET", `/v1/contacts/search?q=${encodeURIComponent(q)}`);
206
210
  }
@@ -149,9 +149,43 @@ async function resolveRow(id, { log = () => {}, allowTaskRows = false } = {}) {
149
149
  // the next render echo the prior seed into itself (a doubled body). briefingMarkdown
150
150
  // is preview-only and is not read back by the seed builders, so it is safe to set.
151
151
  row.briefingMarkdown = renderRelayRowBriefing(row);
152
+ await prependThreadTranscript(row, { log });
152
153
  return { row, rowState };
153
154
  }
154
155
 
156
+ // "Open in new chat" on a relay that belongs to a multi-message thread must
157
+ // seed the WHOLE conversation — like opening a group chat — not just the one
158
+ // message. Fetch the thread from the API and prepend a chronological
159
+ // transcript to the briefing; on any failure the single-relay seed stands.
160
+ async function prependThreadTranscript(row, { log = () => {} } = {}) {
161
+ const threadId = row?.threadId || null;
162
+ if (!threadId) return;
163
+ try {
164
+ const { RelayClient } = await import("./client.js");
165
+ const client = new RelayClient();
166
+ const thread = await client.thread(threadId);
167
+ const msgs = Array.isArray(thread?.messages) ? thread.messages : Array.isArray(thread?.items) ? thread.items : [];
168
+ if (msgs.length < 2) return;
169
+ const lines = msgs.map((m) => {
170
+ const id = m.relayId || m.id || "";
171
+ const from = m.fromDisplayName || m.senderName || (m.direction === "outbound" ? "You" : "Them");
172
+ const when = m.createdAt || "";
173
+ const title = m.title || "";
174
+ const body = String(m.bodyMarkdown || m.preview || "").trim();
175
+ return `**${from}** — ${when} (relay ${id})\n${title ? `*${title}*\n` : ""}${body}`;
176
+ });
177
+ row.briefingMarkdown =
178
+ `## Conversation thread (${msgs.length} messages, oldest first — threadId ${threadId})\n\n` +
179
+ `${lines.join("\n\n---\n\n")}\n\n` +
180
+ `> The relay the user opened is the one addressed below. The full exchange above is context; ` +
181
+ `relay_thread_fetch with threadId ${threadId} re-fetches it at any time.\n\n---\n\n` +
182
+ row.briefingMarkdown;
183
+ log(`thread transcript seeded: ${msgs.length} messages from ${threadId}`);
184
+ } catch (error) {
185
+ log(`thread transcript skipped: ${error?.message || error}`);
186
+ }
187
+ }
188
+
155
189
  async function resolvePublicOpenToken(token, { log = () => {} } = {}) {
156
190
  try {
157
191
  const { RelayClient } = await import("./client.js");
package/src/mcp.js CHANGED
@@ -123,6 +123,21 @@ export const TOOLS = [
123
123
  "List ordinary Relay messages addressed to this human. Each item carries threadId (the conversation thread it belongs to — the root relay's id) and, on replies, inReplyToRelayId; use them to reconstruct the recent exchange with a sender before continuing it via relay_send's inReplyToRelayId. This does not list task coordination relays; use relay_task_status for task state.",
124
124
  inputSchema: { type: "object", properties: {} },
125
125
  },
126
+ {
127
+ name: "relay_thread_fetch",
128
+ description:
129
+ "Fetch ONE full Relay conversation thread, oldest first: every relay in the exchange (both directions) with sender, recipients, timestamps, title, body, and read state. Use this whenever a relay you are opening or replying to is part of a thread (its threadId differs from its own id, or an Open-in-chat instruction names a threadId) so the WHOLE exchange is in context before you act — never reconstruct a conversation by fetching relays one at a time. Pass the threadId (the root relay's id, carried on every relay_inbox_list item).",
130
+ inputSchema: {
131
+ type: "object",
132
+ properties: {
133
+ threadId: {
134
+ type: "string",
135
+ description: "The conversation thread id — the root relay's id, as carried on inbox items and sent responses.",
136
+ },
137
+ },
138
+ required: ["threadId"],
139
+ },
140
+ },
126
141
  {
127
142
  name: "relay_inbox_delete",
128
143
  description:
@@ -479,6 +494,7 @@ export const MESSAGE_ONLY_TOOL_NAMES = new Set([
479
494
  "relay_contact_update",
480
495
  "relay_groups_list",
481
496
  "relay_inbox_list",
497
+ "relay_thread_fetch",
482
498
  "relay_acknowledge",
483
499
  ]);
484
500
 
@@ -548,6 +564,8 @@ export async function handleCall(client, name, args, { mode = DEFAULT_COMPANION_
548
564
  );
549
565
  case "relay_inbox_list":
550
566
  return text(await client.inbox());
567
+ case "relay_thread_fetch":
568
+ return text(await client.thread(args.threadId));
551
569
  case "relay_inbox_delete":
552
570
  return text(await client.deleteInboxItem(args.itemId, { idempotencyKey: args.idempotencyKey }));
553
571
  case "relay_recently_deleted_list":