jefrichat-mcp 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.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # acp-mcp
1
+ # jefrichat-mcp
2
2
 
3
3
  The **ACP connector** — join the [Agent Communication Protocol](https://github.com/) network
4
4
  from any MCP client (Claude Code, Claude Desktop, Codex, Cursor, …). It gives your
@@ -11,7 +11,7 @@ Create an agent in your ACP web app to get a **token**, then:
11
11
 
12
12
  ### Claude Code (local, via npx)
13
13
  ```bash
14
- claude mcp add acp -e ACP_SERVER=https://your-hub -e ACP_TOKEN=acp_xxx -- npx -y acp-mcp
14
+ claude mcp add acp -e ACP_SERVER=https://your-hub -e ACP_TOKEN=acp_xxx -- npx -y jefrichat-mcp
15
15
  ```
16
16
 
17
17
  ### Claude Desktop / Codex / Cursor (config)
@@ -20,7 +20,7 @@ claude mcp add acp -e ACP_SERVER=https://your-hub -e ACP_TOKEN=acp_xxx -- npx -y
20
20
  "mcpServers": {
21
21
  "acp": {
22
22
  "command": "npx",
23
- "args": ["-y", "acp-mcp"],
23
+ "args": ["-y", "jefrichat-mcp"],
24
24
  "env": { "ACP_SERVER": "https://your-hub", "ACP_TOKEN": "acp_xxx" }
25
25
  }
26
26
  }
@@ -29,7 +29,7 @@ claude mcp add acp -e ACP_SERVER=https://your-hub -e ACP_TOKEN=acp_xxx -- npx -y
29
29
 
30
30
  ### Remote / ChatGPT (no install)
31
31
  Point your client at the hosted remote MCP URL with `Authorization: Bearer acp_xxx`
32
- — or run your own with `npx acp-mcp-http`.
32
+ — or run your own with `npx jefrichat-mcp-http`.
33
33
 
34
34
  ## Env
35
35
  | var | meaning |
@@ -40,7 +40,7 @@ Point your client at the hosted remote MCP URL with `Authorization: Bearer acp_x
40
40
  | `ACP_NAME`, `ACP_TAGS` | display name / tags when using `ACP_AS` |
41
41
 
42
42
  ## Tools
43
- `acp_whoami`, `acp_agents`, `acp_send`, `acp_inbox`, `acp_history`, `acp_search`,
44
- `acp_create_task`, `acp_assign_task`, `acp_set_status`.
43
+ `jefri_whoami`, `jefri_agents`, `jefri_send`, `jefri_inbox`, `jefri_history`, `jefri_search`,
44
+ `jefri_create_task`, `jefri_assign_task`, `jefri_set_status`.
45
45
 
46
46
  MIT
package/dist/http.js CHANGED
@@ -48905,6 +48905,9 @@ import { webcrypto } from "node:crypto";
48905
48905
  function dmConversationId(a, b) {
48906
48906
  return "dm:" + [a, b].sort().join(":");
48907
48907
  }
48908
+ function groupConversationId(groupId) {
48909
+ return "group:" + groupId;
48910
+ }
48908
48911
 
48909
48912
  // ../sdk/src/index.ts
48910
48913
  var subtle = webcrypto.subtle;
@@ -49035,7 +49038,7 @@ var AcpClient = class _AcpClient {
49035
49038
  });
49036
49039
  });
49037
49040
  }
49038
- // Keep the hub connection alive through idle-timeout proxies (Azure ingress).
49041
+ // Keep the hub connection alive through idle-timeout proxies.
49039
49042
  startPing() {
49040
49043
  this.stopPing();
49041
49044
  this.pingTimer = setInterval(() => {
@@ -49394,13 +49397,71 @@ var MIME = {
49394
49397
  ".zip": "application/zip"
49395
49398
  };
49396
49399
  var mimeOf = (name) => MIME[np.extname(name).toLowerCase()] ?? "application/octet-stream";
49400
+ function screenshotDir() {
49401
+ try {
49402
+ const out = cp.execFileSync("defaults", ["read", "com.apple.screencapture", "location"], { encoding: "utf8", timeout: 2e3 }).trim();
49403
+ if (out) return out.startsWith("~") ? np.join(os2.homedir(), out.slice(1)) : out;
49404
+ } catch {
49405
+ }
49406
+ const desktop = np.join(os2.homedir(), "Desktop");
49407
+ return fs2.existsSync(desktop) ? desktop : null;
49408
+ }
49409
+ function resolveLocalFile(rawPath) {
49410
+ const expand = (p) => p.startsWith("~") ? np.join(os2.homedir(), p.slice(1)) : p;
49411
+ const exists = (p) => {
49412
+ try {
49413
+ return fs2.existsSync(p) ? p : null;
49414
+ } catch {
49415
+ return null;
49416
+ }
49417
+ };
49418
+ const abs = expand(rawPath);
49419
+ let hit = exists(abs);
49420
+ if (hit) return hit;
49421
+ const UNI = /[    ⁠]/g;
49422
+ for (const v of [abs.replace(/ /g, "\u202F"), abs.replace(UNI, " "), abs.replace(/ (AM|PM)(\.[a-z0-9]+)?$/i, "\u202F$1$2")]) {
49423
+ hit = exists(v);
49424
+ if (hit) return hit;
49425
+ }
49426
+ const base = np.basename(abs);
49427
+ const looksLikeShot = /screencaptureui/i.test(abs) || /^Screenshot[\s  ]/i.test(base);
49428
+ if (looksLikeShot) {
49429
+ const dir = screenshotDir();
49430
+ if (dir) {
49431
+ let files = [];
49432
+ try {
49433
+ files = fs2.readdirSync(dir);
49434
+ } catch {
49435
+ }
49436
+ const norm = (s) => s.replace(/[    ⁠\s]+/g, " ").trim().toLowerCase();
49437
+ const want = norm(base);
49438
+ for (const f of files) if (norm(f) === want) {
49439
+ hit = exists(np.join(dir, f));
49440
+ if (hit) return hit;
49441
+ }
49442
+ const shots = files.filter((f) => /^Screenshot[\s  ].*\.(png|jpe?g)$/i.test(f)).map((f) => {
49443
+ const p = np.join(dir, f);
49444
+ try {
49445
+ return { p, m: fs2.statSync(p).mtimeMs };
49446
+ } catch {
49447
+ return { p, m: 0 };
49448
+ }
49449
+ }).filter((x) => x.m > 0).sort((a, b) => b.m - a.m);
49450
+ if (shots.length && Date.now() - shots[0].m < 5 * 60 * 1e3) return shots[0].p;
49451
+ }
49452
+ }
49453
+ return null;
49454
+ }
49397
49455
  var MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
49398
49456
  var MB = (n) => (n / 1024 / 1024).toFixed(1);
49399
49457
  var WS_SAFE_FILE_BYTES = 45 * 1024 * 1024;
49400
- async function uploadFileHttp(c, serverUrl, to, fileName, mime, bytes, caption) {
49458
+ async function uploadFileHttp(c, serverUrl, target, fileName, mime, bytes, caption) {
49401
49459
  const hub = serverUrl.replace(/\/$/, "");
49402
- const q = `to=${encodeURIComponent(to)}&fileName=${encodeURIComponent(fileName)}` + (caption ? `&caption=${encodeURIComponent(caption)}` : "");
49403
- const res = await fetch(`${hub}/api/files?${q}`, {
49460
+ const params = new URLSearchParams({ fileName });
49461
+ if (target.to) params.set("to", target.to);
49462
+ if (target.groupId) params.set("groupId", target.groupId);
49463
+ if (caption) params.set("caption", caption);
49464
+ const res = await fetch(`${hub}/api/files?${params.toString()}`, {
49404
49465
  method: "POST",
49405
49466
  headers: { Authorization: `Bearer ${c.token}`, "Content-Type": mime },
49406
49467
  body: Buffer.from(bytes)
@@ -49428,6 +49489,8 @@ var ok = (text) => ({ content: [{ type: "text", text }] });
49428
49489
  var fail = (text) => ({ content: [{ type: "text", text }], isError: true });
49429
49490
  var inboxWatermark = /* @__PURE__ */ new Map();
49430
49491
  function registerAcpTools(server, ctx) {
49492
+ const ENABLE_E2E = process.env.ACP_ENABLE_E2E === "true";
49493
+ const e2eServer = ENABLE_E2E ? server : { registerTool: () => void 0 };
49431
49494
  const withClient = async (fn) => {
49432
49495
  try {
49433
49496
  const c = await ctx.ensureClient();
@@ -49481,7 +49544,7 @@ ${e?.message ?? e}`);
49481
49544
  return ok(`Sent to @${to}: "${text}"`);
49482
49545
  })
49483
49546
  );
49484
- server.registerTool(
49547
+ e2eServer.registerTool(
49485
49548
  "jefri_send_private",
49486
49549
  {
49487
49550
  title: "Send a private (client-encrypted) message",
@@ -49499,7 +49562,7 @@ ${e?.message ?? e}`);
49499
49562
  return ok(`Sent private E2E message to @${to}.`);
49500
49563
  })
49501
49564
  );
49502
- server.registerTool(
49565
+ e2eServer.registerTool(
49503
49566
  "jefri_e2e_fingerprint",
49504
49567
  {
49505
49568
  title: "Show E2E key fingerprint",
@@ -49512,7 +49575,7 @@ ${e?.message ?? e}`);
49512
49575
  ${fp}`);
49513
49576
  })
49514
49577
  );
49515
- server.registerTool(
49578
+ e2eServer.registerTool(
49516
49579
  "jefri_send_private_file",
49517
49580
  {
49518
49581
  title: "Send a private (client-encrypted) file",
@@ -49528,10 +49591,15 @@ ${fp}`);
49528
49591
  async ({ to, path: path2, dataUrl, fileName, caption }) => withClient(async (c) => {
49529
49592
  let name, mime, url;
49530
49593
  if (path2) {
49531
- const abs = path2.startsWith("~") ? np.join(process.env.HOME ?? "", path2.slice(1)) : path2;
49532
- if (!fs2.existsSync(abs)) {
49594
+ if (!ctx.local) {
49533
49595
  return fail(
49534
- `I can't read that local file from this connector. Use a local/stdin Jefri Chat connector or open the web chat and drop the file there.`
49596
+ `I can't read local files from the cloud connector. Use a local/stdin Jefri Chat connector, or open the web chat and drop the file there.`
49597
+ );
49598
+ }
49599
+ const abs = resolveLocalFile(path2);
49600
+ if (!abs) {
49601
+ return fail(
49602
+ `I can't find a file at ${path2} on this machine.`
49535
49603
  );
49536
49604
  }
49537
49605
  const buf = fs2.readFileSync(abs);
@@ -49550,7 +49618,7 @@ ${fp}`);
49550
49618
  return ok(`Sent private E2E file to @${to}.`);
49551
49619
  })
49552
49620
  );
49553
- server.registerTool(
49621
+ e2eServer.registerTool(
49554
49622
  "jefri_send_private_group",
49555
49623
  {
49556
49624
  title: "Send a private (client-encrypted) group message",
@@ -49568,7 +49636,7 @@ ${fp}`);
49568
49636
  return ok(`Sent private E2E group message.`);
49569
49637
  })
49570
49638
  );
49571
- server.registerTool(
49639
+ e2eServer.registerTool(
49572
49640
  "jefri_send_private_group_file",
49573
49641
  {
49574
49642
  title: "Send a private (client-encrypted) group file",
@@ -49584,10 +49652,15 @@ ${fp}`);
49584
49652
  async ({ groupId, path: path2, dataUrl, fileName, caption }) => withClient(async (c) => {
49585
49653
  let name, mime, url;
49586
49654
  if (path2) {
49587
- const abs = path2.startsWith("~") ? np.join(process.env.HOME ?? "", path2.slice(1)) : path2;
49588
- if (!fs2.existsSync(abs)) {
49655
+ if (!ctx.local) {
49656
+ return fail(
49657
+ `I can't read local files from the cloud connector. Use a local/stdin Jefri Chat connector, or open the web chat and drop the file there.`
49658
+ );
49659
+ }
49660
+ const abs = resolveLocalFile(path2);
49661
+ if (!abs) {
49589
49662
  return fail(
49590
- `I can't read that local file from this connector. Use a local/stdin Jefri Chat connector or open the web chat and drop the file there.`
49663
+ `I can't find a file at ${path2} on this machine.`
49591
49664
  );
49592
49665
  }
49593
49666
  const buf = fs2.readFileSync(abs);
@@ -49622,25 +49695,32 @@ ${fp}`);
49622
49695
  async ({ to, path: path2, caption, dataUrl, fileName }) => withClient(async (c) => {
49623
49696
  let name, mime, url;
49624
49697
  if (path2) {
49625
- const abs = path2.startsWith("~") ? np.join(process.env.HOME ?? "", path2.slice(1)) : path2;
49626
- if (!fs2.existsSync(abs)) {
49627
- const fname = np.basename(abs);
49698
+ if (!ctx.local) {
49699
+ const fname = np.basename(path2);
49628
49700
  const fmime = mimeOf(fname);
49629
49701
  const hub = ctx.serverUrl.replace(/\/$/, "");
49630
49702
  const web = hub.replace("acp-hub.", "acp-web.").replace(":4000", ":4321");
49631
49703
  const dropLink = `${web}/?to=${encodeURIComponent(to)}`;
49632
49704
  const q = `to=${encodeURIComponent(to)}&fileName=${encodeURIComponent(fname)}` + (caption ? `&caption=${encodeURIComponent(caption)}` : "");
49633
- const cmd = `curl -s -X POST ${shq(`${hub}/api/files?${q}`)} -H "Authorization: Bearer ${c.token}" -H "Content-Type: ${fmime}" --data-binary @${shq(path2)}`;
49705
+ const uploadUrl = `${hub}/api/files?${q}`;
49706
+ const cmd = `curl -s -X POST ${shq(uploadUrl)} -H "Authorization: Bearer ${c.token}" -H "Content-Type: ${fmime}" --data-binary @${shq(path2)}`;
49707
+ const psCmd = `Invoke-RestMethod -Uri "${uploadUrl}" -Method Post -Headers @{ Authorization = "Bearer ${c.token}" } -ContentType "${fmime}" -InFile "${path2}"`;
49634
49708
  return fail(
49635
- `I can't read local files from here (this connector runs in the cloud), and sending your private files to another user's agent is gated for safety \u2014 so this needs to come from you.
49709
+ `ASSISTANT: run the one command below in your shell to send this file \u2014 it reads the file locally and uploads it directly, fast, at any size. Do NOT read the file or base64 it into a tool call (that is slow and breaks on large files); just run this:
49636
49710
 
49637
- \u2705 Easiest \u2014 open the chat and drop "${fname}" in:
49638
- ${dropLink}
49711
+ \u2022 macOS / Linux:
49712
+ ${cmd}
49639
49713
 
49640
- Or run this yourself in the terminal (reads + uploads it locally):
49641
- ${cmd}`
49714
+ \u2022 Windows (PowerShell \u2014 avoids the curl/schannel TLS error):
49715
+ ${psCmd}
49716
+
49717
+ (Alternatively the user can drag "${fname}" straight into the chat: ${dropLink})`
49642
49718
  );
49643
49719
  }
49720
+ const abs = resolveLocalFile(path2);
49721
+ if (!abs) {
49722
+ return fail(`I can't find a file at ${path2} on this machine.`);
49723
+ }
49644
49724
  const buf = fs2.readFileSync(abs);
49645
49725
  name = np.basename(abs);
49646
49726
  mime = mimeOf(name);
@@ -49657,7 +49737,7 @@ ${cmd}`
49657
49737
  if (rawBytes.length > MAX_UPLOAD_BYTES)
49658
49738
  return fail(`${name} is ${MB(rawBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
49659
49739
  try {
49660
- await uploadFileHttp(c, ctx.serverUrl, to, name, mime, rawBytes, caption);
49740
+ await uploadFileHttp(c, ctx.serverUrl, { to }, name, mime, rawBytes, caption);
49661
49741
  } catch (e2) {
49662
49742
  return fail(`Rejected: ${e2?.message ?? e2}`);
49663
49743
  }
@@ -49691,7 +49771,7 @@ ${cmd}`
49691
49771
  if (contentBytes.length > MAX_UPLOAD_BYTES)
49692
49772
  return fail(`That content is ${MB(contentBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
49693
49773
  try {
49694
- await uploadFileHttp(c, ctx.serverUrl, to, fileName, mime, contentBytes, caption);
49774
+ await uploadFileHttp(c, ctx.serverUrl, { to }, fileName, mime, contentBytes, caption);
49695
49775
  } catch (e2) {
49696
49776
  return fail(`Rejected: ${e2?.message ?? e2}`);
49697
49777
  }
@@ -49723,7 +49803,7 @@ ${cmd}`
49723
49803
  `Sending a folder reads files off disk, which this connector can't do \u2014 it runs in the cloud. Run jefri_send_folder from a LOCAL agent (Claude Code with the stdio connector) inside the project.`
49724
49804
  );
49725
49805
  const rawDir = path2 && path2.trim() ? path2 : process.cwd();
49726
- const dir = rawDir.startsWith("~") ? np.join(process.env.HOME ?? "", rawDir.slice(1)) : rawDir;
49806
+ const dir = rawDir.startsWith("~") ? np.join(os2.homedir(), rawDir.slice(1)) : rawDir;
49727
49807
  if (!fs2.existsSync(dir))
49728
49808
  return fail(
49729
49809
  `I can't see "${rawDir}" from here. Sending a folder reads it off disk, which only works on the LOCAL (stdio) connector \u2014 run this from Claude Code (or another local agent) inside the project.`
@@ -49808,27 +49888,42 @@ ${cmd}`
49808
49888
  "jefri_download_file",
49809
49889
  {
49810
49890
  title: "Download / save a file someone sent you",
49811
- description: "Save a file (PDF, image, video, any document) that another agent/user sent you, onto this machine. Give the sender's username `from` (and `fileName` if they sent several). Returns a ready-to-run command that downloads the file locally \u2014 run it in the terminal. Use this when the user says things like 'download that file', 'save the pdf antonio sent', etc.",
49891
+ description: "Save a file (PDF, image, video, any document) that another agent/user sent you, onto this machine. For a DM give the sender's username `from`; for a file sent in a GROUP give `groupId` instead (from jefri_groups / jefri_inbox). Add `fileName` if there are several. Returns a ready-to-run command that downloads the file locally \u2014 run it in the terminal. Use this when the user says things like 'download that file', 'save the pdf antonio sent', etc.",
49812
49892
  inputSchema: {
49813
- from: external_exports.string().describe("who sent you the file (their username)"),
49814
- fileName: external_exports.string().optional().describe("which file, if there are several from them"),
49893
+ from: external_exports.string().optional().describe("who sent you the file (their username) \u2014 for a DM"),
49894
+ groupId: external_exports.string().optional().describe("the group's id \u2014 for a file sent in a group"),
49895
+ fileName: external_exports.string().optional().describe("which file, if there are several"),
49815
49896
  savePath: external_exports.string().optional().describe("where to save it (default: the file's own name in the current folder)")
49816
49897
  }
49817
49898
  },
49818
- async ({ from, fileName, savePath }) => withClient(async (c) => {
49819
- const convId = dmConversationId(c.identity.username, from);
49899
+ async ({ from, groupId, fileName, savePath }) => withClient(async (c) => {
49900
+ if (!from && !groupId) return fail("Give `from` (a username, for a DM) or `groupId` (for a group file).");
49901
+ const convId = groupId ? groupConversationId(groupId) : dmConversationId(c.identity.username, from);
49902
+ const where = groupId ? "this group" : `@${from}`;
49820
49903
  const p = waitFor(c, "history", (e) => e.conversationId === convId, 4e3);
49821
49904
  c.history(convId);
49822
49905
  const res = await p;
49823
49906
  const files = (res?.messages ?? []).filter((m) => m.kind === "file");
49824
- if (!files.length) return fail(`No files from @${from} yet.`);
49907
+ if (!files.length) return fail(`No files in ${where} yet.`);
49825
49908
  const match = fileName ? [...files].reverse().find((m) => m.fileName === fileName || m.fileName?.includes(fileName)) : files[files.length - 1];
49826
- if (!match) return fail(`No file named "${fileName}" from @${from}.`);
49909
+ if (!match) return fail(`No file named "${fileName}" in ${where}.`);
49827
49910
  const hub = ctx.serverUrl.replace(/\/$/, "");
49828
49911
  const out = savePath || match.fileName || "downloaded.file";
49912
+ if (ctx.local) {
49913
+ const outPath = out.startsWith("~") ? np.join(os2.homedir(), out.slice(1)) : out;
49914
+ try {
49915
+ const r = await fetch(`${hub}/api/files/${match.id}`, { headers: { authorization: `Bearer ${c.token}` } });
49916
+ if (!r.ok) throw new Error(`download failed (${r.status})`);
49917
+ const buf = Buffer.from(await r.arrayBuffer());
49918
+ fs2.writeFileSync(outPath, buf);
49919
+ return ok(`\u2705 Saved \u{1F4CE} ${match.fileName} from ${where} to ${outPath} (${MB(buf.length)} MB).`);
49920
+ } catch (e) {
49921
+ return fail(`Couldn't save the file: ${e?.message ?? e}`);
49922
+ }
49923
+ }
49829
49924
  const cmd = `curl -s -o ${shq(out)} -H "Authorization: Bearer ${c.token}" ${shq(`${hub}/api/files/${match.id}`)}`;
49830
49925
  return ok(
49831
- `To save \u{1F4CE} ${match.fileName} from @${from}, run this in the terminal:
49926
+ `To save \u{1F4CE} ${match.fileName} from ${where}, run this in the terminal:
49832
49927
 
49833
49928
  ${cmd}
49834
49929
 
@@ -50076,41 +50171,149 @@ ${cmd}`);
50076
50171
  if (!msgs.length) return ok("\u{1F4ED} No new messages.");
50077
50172
  msgs.sort((a, b) => a.createdAt < b.createdAt ? -1 : 1);
50078
50173
  inboxWatermark.set(me, msgs[msgs.length - 1].createdAt);
50174
+ const groupNames = /* @__PURE__ */ new Map();
50175
+ if (msgs.some((m) => m.groupId)) {
50176
+ try {
50177
+ for (const g of await c.groups()) groupNames.set(g.id, g.name);
50178
+ } catch {
50179
+ }
50180
+ }
50181
+ const where = (m) => {
50182
+ if (!m.groupId) return "";
50183
+ const name = groupNames.get(m.groupId);
50184
+ return name ? ` (in group "${name}" \xB7 reply with jefri_send_group groupId="${m.groupId}")` : ` (in group id ${m.groupId})`;
50185
+ };
50079
50186
  const lines = await Promise.all(msgs.map(async (m) => {
50080
50187
  if (m.encryptionMode === "private_e2e" && m.encryptedPayload) {
50081
50188
  try {
50082
50189
  const plain = await c.decryptPrivatePayload(m.encryptedPayload);
50083
50190
  const body2 = plain.kind === "file" ? `\u{1F512}\u{1F4CE} private file: ${plain.fileName ?? m.fileName}` : `\u{1F512} ${plain.content ?? ""}`;
50084
- return `@${m.senderUsername}${m.groupId ? " (in group)" : ""}: ${body2}`;
50191
+ return `@${m.senderUsername}${where(m)}: ${body2}`;
50085
50192
  } catch {
50086
- return `@${m.senderUsername}${m.groupId ? " (in group)" : ""}: \u{1F512} Private message unavailable on this device`;
50193
+ return `@${m.senderUsername}${where(m)}: \u{1F512} Private message unavailable on this device`;
50087
50194
  }
50088
50195
  }
50089
50196
  const body = m.kind === "file" ? `\u{1F4CE} sent a file: ${m.fileName} \u2014 to save it call jefri_download_file(from: "${m.senderUsername}"${m.fileName ? `, fileName: "${m.fileName}"` : ""})` : m.content;
50090
- const where = m.groupId ? ` (in group)` : "";
50091
- return `@${m.senderUsername}${where}: ${body}`;
50197
+ return `@${m.senderUsername}${where(m)}: ${body}`;
50092
50198
  }));
50093
50199
  return ok(`\u{1F4E8} ${lines.length} message(s):
50094
50200
  ` + lines.join("\n"));
50095
50201
  })
50096
50202
  );
50203
+ server.registerTool(
50204
+ "jefri_groups",
50205
+ {
50206
+ title: "List your groups",
50207
+ description: "List the groups you're a member of, with each group's id (needed to post to it) and its members. Use this to find a group's id before jefri_send_group.",
50208
+ inputSchema: {}
50209
+ },
50210
+ async () => withClient(async (c) => {
50211
+ const gs = await c.groups();
50212
+ if (!gs.length) return ok("You're not in any groups yet.");
50213
+ return ok(
50214
+ `You're in ${gs.length} group(s):
50215
+ ` + gs.map((g) => `\u2022 "${g.name}" \u2014 id: ${g.id} \u2014 ${g.memberUsernames.length} members: ${g.memberUsernames.join(", ")}`).join("\n")
50216
+ );
50217
+ })
50218
+ );
50219
+ server.registerTool(
50220
+ "jefri_send_group",
50221
+ {
50222
+ title: "Send a group message",
50223
+ description: "Post a message to a GROUP (everyone in the group sees it), by the group's id. Get the id from jefri_groups, or from jefri_inbox \u2014 it shows the id of the group any incoming group message came from. NOTE: this needs the group's id, not its display name.",
50224
+ inputSchema: {
50225
+ groupId: external_exports.string().describe("the group's id (from jefri_groups or jefri_inbox)"),
50226
+ text: external_exports.string().describe("the message to post to the group")
50227
+ }
50228
+ },
50229
+ async ({ groupId, text }) => withClient(async (c) => {
50230
+ const err = waitFor(c, "error", () => true, 800);
50231
+ c.groupMessage(groupId, text);
50232
+ const e = await err;
50233
+ if (e) return fail(`Couldn't post to the group: ${e.message}`);
50234
+ return ok(`Posted to the group.`);
50235
+ })
50236
+ );
50237
+ server.registerTool(
50238
+ "jefri_send_group_file",
50239
+ {
50240
+ title: "Send a file to a group",
50241
+ description: "Send a file/image to a GROUP by its group id. Give a local file `path` (works on the local/stdio connector) or a `dataUrl` + `fileName`. Get the group id from jefri_groups / jefri_inbox.",
50242
+ inputSchema: {
50243
+ groupId: external_exports.string().describe("the group's id (from jefri_groups or jefri_inbox)"),
50244
+ path: external_exports.string().optional().describe("local file path, e.g. ~/Downloads/report.pdf"),
50245
+ caption: external_exports.string().optional().describe("optional text caption"),
50246
+ dataUrl: external_exports.string().optional().describe("alternatively, a data: URL of the file (small files only)"),
50247
+ fileName: external_exports.string().optional().describe("file name when using dataUrl")
50248
+ }
50249
+ },
50250
+ async ({ groupId, path: path2, caption, dataUrl, fileName }) => withClient(async (c) => {
50251
+ let name, mime, url;
50252
+ if (path2) {
50253
+ if (!ctx.local) {
50254
+ const hub = ctx.serverUrl.replace(/\/$/, "");
50255
+ const fname = np.basename(path2);
50256
+ const fmime = mimeOf(fname);
50257
+ const q = `groupId=${encodeURIComponent(groupId)}&fileName=${encodeURIComponent(fname)}` + (caption ? `&caption=${encodeURIComponent(caption)}` : "");
50258
+ const cmd = `curl -s -X POST ${shq(`${hub}/api/files?${q}`)} -H "Authorization: Bearer ${c.token}" -H "Content-Type: ${fmime}" --data-binary @${shq(path2)}`;
50259
+ return fail(
50260
+ `ASSISTANT: run this one command in your shell to send the file to the group (it reads + uploads locally, fast, any size \u2014 do NOT base64 it into a tool call):
50261
+
50262
+ ${cmd}`
50263
+ );
50264
+ }
50265
+ const abs = resolveLocalFile(path2);
50266
+ if (!abs) return fail(`I can't find a file at ${path2} on this machine.`);
50267
+ const buf = fs2.readFileSync(abs);
50268
+ name = np.basename(abs);
50269
+ mime = mimeOf(name);
50270
+ url = `data:${mime};base64,${buf.toString("base64")}`;
50271
+ } else if (dataUrl && fileName) {
50272
+ name = fileName;
50273
+ mime = dataUrl.match(/^data:([^;]+)/)?.[1] ?? mimeOf(fileName);
50274
+ url = dataUrl;
50275
+ } else {
50276
+ return fail("provide a local file `path` (or `dataUrl` + `fileName`)");
50277
+ }
50278
+ const rawBytes = Buffer.from(url.split(",")[1] ?? "", "base64");
50279
+ if (rawBytes.length > WS_SAFE_FILE_BYTES) {
50280
+ if (rawBytes.length > MAX_UPLOAD_BYTES)
50281
+ return fail(`${name} is ${MB(rawBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
50282
+ try {
50283
+ await uploadFileHttp(c, ctx.serverUrl, { groupId }, name, mime, rawBytes, caption);
50284
+ } catch (e2) {
50285
+ return fail(`Rejected: ${e2?.message ?? e2}`);
50286
+ }
50287
+ return ok(`Sent \u{1F4CE} ${name} (${MB(rawBytes.length)} MB) to the group.`);
50288
+ }
50289
+ const err = waitFor(c, "error", () => true, 800);
50290
+ c.sendGroupFile(groupId, name, mime, url);
50291
+ if (caption) c.groupMessage(groupId, caption);
50292
+ const e = await err;
50293
+ if (e) return fail(`Rejected: ${e.message}`);
50294
+ return ok(`Sent \u{1F4CE} ${name} to the group.`);
50295
+ })
50296
+ );
50097
50297
  server.registerTool(
50098
50298
  "jefri_history",
50099
50299
  {
50100
50300
  title: "Read conversation history",
50101
- description: "Read the recent message history of your DM thread with a given user.",
50301
+ description: "Read the recent message history of a conversation \u2014 either a DM (pass `with` = a username) or a GROUP (pass `groupId`, from jefri_groups). Shows past messages AND any files/images (with how to download them).",
50102
50302
  inputSchema: {
50103
- with: external_exports.string().describe("the other person's username"),
50303
+ with: external_exports.string().optional().describe("the other person's username (for a DM)"),
50304
+ groupId: external_exports.string().optional().describe("a group's id (for group history) \u2014 from jefri_groups"),
50104
50305
  limit: external_exports.number().optional().describe("max messages to return (default 20)")
50105
50306
  }
50106
50307
  },
50107
- async ({ with: other, limit }) => withClient(async (c) => {
50108
- const convId = dmConversationId(c.identity.username, other);
50308
+ async ({ with: other, groupId, limit }) => withClient(async (c) => {
50309
+ if (!other && !groupId) return fail("Give `with` (a username, for a DM) or `groupId` (for a group).");
50310
+ const convId = groupId ? groupConversationId(groupId) : dmConversationId(c.identity.username, other);
50311
+ const label = groupId ? "this group" : `@${other}`;
50109
50312
  const p = waitFor(c, "history", (e) => e.conversationId === convId);
50110
50313
  c.history(convId);
50111
50314
  const res = await p;
50112
50315
  const msgs = (res?.messages ?? []).slice(-(limit ?? 20));
50113
- if (!msgs.length) return ok(`No messages yet with @${other}.`);
50316
+ if (!msgs.length) return ok(`No messages yet in ${label}.`);
50114
50317
  const lines = await Promise.all(msgs.map(async (m) => {
50115
50318
  if (m.encryptionMode === "private_e2e" && m.encryptedPayload) {
50116
50319
  try {
@@ -50120,7 +50323,11 @@ ${cmd}`);
50120
50323
  return `${m.senderUsername}: \u{1F512} Private message unavailable on this device`;
50121
50324
  }
50122
50325
  }
50123
- return `${m.senderUsername}: ${m.kind === "file" ? `\u{1F4CE} ${m.fileName}` : m.content}`;
50326
+ if (m.kind === "file") {
50327
+ const dl = groupId ? `jefri_download_file(groupId: "${groupId}", fileName: "${m.fileName}")` : `jefri_download_file(from: "${m.senderUsername}", fileName: "${m.fileName}")`;
50328
+ return `${m.senderUsername}: \u{1F4CE} ${m.fileName} \u2014 to save it call ${dl}`;
50329
+ }
50330
+ return `${m.senderUsername}: ${m.content}`;
50124
50331
  }));
50125
50332
  return ok(lines.join("\n"));
50126
50333
  })
@@ -50165,8 +50372,10 @@ ${cmd}`);
50165
50372
  inputSchema: { taskId: external_exports.string(), to: external_exports.string() }
50166
50373
  },
50167
50374
  async ({ taskId, to }) => withClient(async (c) => {
50375
+ const err = waitFor(c, "error", () => true, 800);
50168
50376
  c.assignTask(taskId, to);
50169
- await new Promise((r) => setTimeout(r, 300));
50377
+ const e = await err;
50378
+ if (e) return fail(`Couldn't assign task ${taskId}: ${e.message}`);
50170
50379
  return ok(`Assigned task ${taskId} to @${to}.`);
50171
50380
  })
50172
50381
  );
@@ -50264,7 +50473,7 @@ app.use((_req, res, next) => {
50264
50473
  next();
50265
50474
  });
50266
50475
  app.use(import_express.default.json({ limit: "25mb" }));
50267
- var health = (_req, res) => res.json({ ok: true, service: "acp-mcp-http", hub: HUB, sessions: sessions.size });
50476
+ var health = (_req, res) => res.json({ ok: true, service: "jefrichat-mcp-http", hub: HUB, sessions: sessions.size });
50268
50477
  app.get("/health", health);
50269
50478
  app.get("/mcp/health", health);
50270
50479
  app.post("/mcp", async (req, res) => {
package/dist/index.js CHANGED
@@ -24813,6 +24813,9 @@ import { webcrypto } from "node:crypto";
24813
24813
  function dmConversationId(a, b) {
24814
24814
  return "dm:" + [a, b].sort().join(":");
24815
24815
  }
24816
+ function groupConversationId(groupId) {
24817
+ return "group:" + groupId;
24818
+ }
24816
24819
 
24817
24820
  // ../sdk/src/index.ts
24818
24821
  var subtle = webcrypto.subtle;
@@ -24943,7 +24946,7 @@ var AcpClient = class _AcpClient {
24943
24946
  });
24944
24947
  });
24945
24948
  }
24946
- // Keep the hub connection alive through idle-timeout proxies (Azure ingress).
24949
+ // Keep the hub connection alive through idle-timeout proxies.
24947
24950
  startPing() {
24948
24951
  this.stopPing();
24949
24952
  this.pingTimer = setInterval(() => {
@@ -25302,13 +25305,71 @@ var MIME = {
25302
25305
  ".zip": "application/zip"
25303
25306
  };
25304
25307
  var mimeOf = (name) => MIME[np.extname(name).toLowerCase()] ?? "application/octet-stream";
25308
+ function screenshotDir() {
25309
+ try {
25310
+ const out = cp.execFileSync("defaults", ["read", "com.apple.screencapture", "location"], { encoding: "utf8", timeout: 2e3 }).trim();
25311
+ if (out) return out.startsWith("~") ? np.join(os2.homedir(), out.slice(1)) : out;
25312
+ } catch {
25313
+ }
25314
+ const desktop = np.join(os2.homedir(), "Desktop");
25315
+ return fs2.existsSync(desktop) ? desktop : null;
25316
+ }
25317
+ function resolveLocalFile(rawPath) {
25318
+ const expand = (p) => p.startsWith("~") ? np.join(os2.homedir(), p.slice(1)) : p;
25319
+ const exists = (p) => {
25320
+ try {
25321
+ return fs2.existsSync(p) ? p : null;
25322
+ } catch {
25323
+ return null;
25324
+ }
25325
+ };
25326
+ const abs = expand(rawPath);
25327
+ let hit = exists(abs);
25328
+ if (hit) return hit;
25329
+ const UNI = /[    ⁠]/g;
25330
+ for (const v of [abs.replace(/ /g, "\u202F"), abs.replace(UNI, " "), abs.replace(/ (AM|PM)(\.[a-z0-9]+)?$/i, "\u202F$1$2")]) {
25331
+ hit = exists(v);
25332
+ if (hit) return hit;
25333
+ }
25334
+ const base = np.basename(abs);
25335
+ const looksLikeShot = /screencaptureui/i.test(abs) || /^Screenshot[\s  ]/i.test(base);
25336
+ if (looksLikeShot) {
25337
+ const dir = screenshotDir();
25338
+ if (dir) {
25339
+ let files = [];
25340
+ try {
25341
+ files = fs2.readdirSync(dir);
25342
+ } catch {
25343
+ }
25344
+ const norm = (s) => s.replace(/[    ⁠\s]+/g, " ").trim().toLowerCase();
25345
+ const want = norm(base);
25346
+ for (const f of files) if (norm(f) === want) {
25347
+ hit = exists(np.join(dir, f));
25348
+ if (hit) return hit;
25349
+ }
25350
+ const shots = files.filter((f) => /^Screenshot[\s  ].*\.(png|jpe?g)$/i.test(f)).map((f) => {
25351
+ const p = np.join(dir, f);
25352
+ try {
25353
+ return { p, m: fs2.statSync(p).mtimeMs };
25354
+ } catch {
25355
+ return { p, m: 0 };
25356
+ }
25357
+ }).filter((x) => x.m > 0).sort((a, b) => b.m - a.m);
25358
+ if (shots.length && Date.now() - shots[0].m < 5 * 60 * 1e3) return shots[0].p;
25359
+ }
25360
+ }
25361
+ return null;
25362
+ }
25305
25363
  var MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
25306
25364
  var MB = (n) => (n / 1024 / 1024).toFixed(1);
25307
25365
  var WS_SAFE_FILE_BYTES = 45 * 1024 * 1024;
25308
- async function uploadFileHttp(c, serverUrl, to, fileName, mime, bytes, caption) {
25366
+ async function uploadFileHttp(c, serverUrl, target, fileName, mime, bytes, caption) {
25309
25367
  const hub = serverUrl.replace(/\/$/, "");
25310
- const q = `to=${encodeURIComponent(to)}&fileName=${encodeURIComponent(fileName)}` + (caption ? `&caption=${encodeURIComponent(caption)}` : "");
25311
- const res = await fetch(`${hub}/api/files?${q}`, {
25368
+ const params = new URLSearchParams({ fileName });
25369
+ if (target.to) params.set("to", target.to);
25370
+ if (target.groupId) params.set("groupId", target.groupId);
25371
+ if (caption) params.set("caption", caption);
25372
+ const res = await fetch(`${hub}/api/files?${params.toString()}`, {
25312
25373
  method: "POST",
25313
25374
  headers: { Authorization: `Bearer ${c.token}`, "Content-Type": mime },
25314
25375
  body: Buffer.from(bytes)
@@ -25336,6 +25397,8 @@ var ok = (text) => ({ content: [{ type: "text", text }] });
25336
25397
  var fail = (text) => ({ content: [{ type: "text", text }], isError: true });
25337
25398
  var inboxWatermark = /* @__PURE__ */ new Map();
25338
25399
  function registerAcpTools(server2, ctx) {
25400
+ const ENABLE_E2E = process.env.ACP_ENABLE_E2E === "true";
25401
+ const e2eServer = ENABLE_E2E ? server2 : { registerTool: () => void 0 };
25339
25402
  const withClient = async (fn) => {
25340
25403
  try {
25341
25404
  const c = await ctx.ensureClient();
@@ -25389,7 +25452,7 @@ ${e?.message ?? e}`);
25389
25452
  return ok(`Sent to @${to}: "${text}"`);
25390
25453
  })
25391
25454
  );
25392
- server2.registerTool(
25455
+ e2eServer.registerTool(
25393
25456
  "jefri_send_private",
25394
25457
  {
25395
25458
  title: "Send a private (client-encrypted) message",
@@ -25407,7 +25470,7 @@ ${e?.message ?? e}`);
25407
25470
  return ok(`Sent private E2E message to @${to}.`);
25408
25471
  })
25409
25472
  );
25410
- server2.registerTool(
25473
+ e2eServer.registerTool(
25411
25474
  "jefri_e2e_fingerprint",
25412
25475
  {
25413
25476
  title: "Show E2E key fingerprint",
@@ -25420,7 +25483,7 @@ ${e?.message ?? e}`);
25420
25483
  ${fp}`);
25421
25484
  })
25422
25485
  );
25423
- server2.registerTool(
25486
+ e2eServer.registerTool(
25424
25487
  "jefri_send_private_file",
25425
25488
  {
25426
25489
  title: "Send a private (client-encrypted) file",
@@ -25436,10 +25499,15 @@ ${fp}`);
25436
25499
  async ({ to, path: path3, dataUrl, fileName, caption }) => withClient(async (c) => {
25437
25500
  let name, mime, url;
25438
25501
  if (path3) {
25439
- const abs = path3.startsWith("~") ? np.join(process.env.HOME ?? "", path3.slice(1)) : path3;
25440
- if (!fs2.existsSync(abs)) {
25502
+ if (!ctx.local) {
25441
25503
  return fail(
25442
- `I can't read that local file from this connector. Use a local/stdin Jefri Chat connector or open the web chat and drop the file there.`
25504
+ `I can't read local files from the cloud connector. Use a local/stdin Jefri Chat connector, or open the web chat and drop the file there.`
25505
+ );
25506
+ }
25507
+ const abs = resolveLocalFile(path3);
25508
+ if (!abs) {
25509
+ return fail(
25510
+ `I can't find a file at ${path3} on this machine.`
25443
25511
  );
25444
25512
  }
25445
25513
  const buf = fs2.readFileSync(abs);
@@ -25458,7 +25526,7 @@ ${fp}`);
25458
25526
  return ok(`Sent private E2E file to @${to}.`);
25459
25527
  })
25460
25528
  );
25461
- server2.registerTool(
25529
+ e2eServer.registerTool(
25462
25530
  "jefri_send_private_group",
25463
25531
  {
25464
25532
  title: "Send a private (client-encrypted) group message",
@@ -25476,7 +25544,7 @@ ${fp}`);
25476
25544
  return ok(`Sent private E2E group message.`);
25477
25545
  })
25478
25546
  );
25479
- server2.registerTool(
25547
+ e2eServer.registerTool(
25480
25548
  "jefri_send_private_group_file",
25481
25549
  {
25482
25550
  title: "Send a private (client-encrypted) group file",
@@ -25492,10 +25560,15 @@ ${fp}`);
25492
25560
  async ({ groupId, path: path3, dataUrl, fileName, caption }) => withClient(async (c) => {
25493
25561
  let name, mime, url;
25494
25562
  if (path3) {
25495
- const abs = path3.startsWith("~") ? np.join(process.env.HOME ?? "", path3.slice(1)) : path3;
25496
- if (!fs2.existsSync(abs)) {
25563
+ if (!ctx.local) {
25497
25564
  return fail(
25498
- `I can't read that local file from this connector. Use a local/stdin Jefri Chat connector or open the web chat and drop the file there.`
25565
+ `I can't read local files from the cloud connector. Use a local/stdin Jefri Chat connector, or open the web chat and drop the file there.`
25566
+ );
25567
+ }
25568
+ const abs = resolveLocalFile(path3);
25569
+ if (!abs) {
25570
+ return fail(
25571
+ `I can't find a file at ${path3} on this machine.`
25499
25572
  );
25500
25573
  }
25501
25574
  const buf = fs2.readFileSync(abs);
@@ -25530,25 +25603,32 @@ ${fp}`);
25530
25603
  async ({ to, path: path3, caption, dataUrl, fileName }) => withClient(async (c) => {
25531
25604
  let name, mime, url;
25532
25605
  if (path3) {
25533
- const abs = path3.startsWith("~") ? np.join(process.env.HOME ?? "", path3.slice(1)) : path3;
25534
- if (!fs2.existsSync(abs)) {
25535
- const fname = np.basename(abs);
25606
+ if (!ctx.local) {
25607
+ const fname = np.basename(path3);
25536
25608
  const fmime = mimeOf(fname);
25537
25609
  const hub = ctx.serverUrl.replace(/\/$/, "");
25538
25610
  const web = hub.replace("acp-hub.", "acp-web.").replace(":4000", ":4321");
25539
25611
  const dropLink = `${web}/?to=${encodeURIComponent(to)}`;
25540
25612
  const q = `to=${encodeURIComponent(to)}&fileName=${encodeURIComponent(fname)}` + (caption ? `&caption=${encodeURIComponent(caption)}` : "");
25541
- const cmd = `curl -s -X POST ${shq(`${hub}/api/files?${q}`)} -H "Authorization: Bearer ${c.token}" -H "Content-Type: ${fmime}" --data-binary @${shq(path3)}`;
25613
+ const uploadUrl = `${hub}/api/files?${q}`;
25614
+ const cmd = `curl -s -X POST ${shq(uploadUrl)} -H "Authorization: Bearer ${c.token}" -H "Content-Type: ${fmime}" --data-binary @${shq(path3)}`;
25615
+ const psCmd = `Invoke-RestMethod -Uri "${uploadUrl}" -Method Post -Headers @{ Authorization = "Bearer ${c.token}" } -ContentType "${fmime}" -InFile "${path3}"`;
25542
25616
  return fail(
25543
- `I can't read local files from here (this connector runs in the cloud), and sending your private files to another user's agent is gated for safety \u2014 so this needs to come from you.
25617
+ `ASSISTANT: run the one command below in your shell to send this file \u2014 it reads the file locally and uploads it directly, fast, at any size. Do NOT read the file or base64 it into a tool call (that is slow and breaks on large files); just run this:
25544
25618
 
25545
- \u2705 Easiest \u2014 open the chat and drop "${fname}" in:
25546
- ${dropLink}
25619
+ \u2022 macOS / Linux:
25620
+ ${cmd}
25547
25621
 
25548
- Or run this yourself in the terminal (reads + uploads it locally):
25549
- ${cmd}`
25622
+ \u2022 Windows (PowerShell \u2014 avoids the curl/schannel TLS error):
25623
+ ${psCmd}
25624
+
25625
+ (Alternatively the user can drag "${fname}" straight into the chat: ${dropLink})`
25550
25626
  );
25551
25627
  }
25628
+ const abs = resolveLocalFile(path3);
25629
+ if (!abs) {
25630
+ return fail(`I can't find a file at ${path3} on this machine.`);
25631
+ }
25552
25632
  const buf = fs2.readFileSync(abs);
25553
25633
  name = np.basename(abs);
25554
25634
  mime = mimeOf(name);
@@ -25565,7 +25645,7 @@ ${cmd}`
25565
25645
  if (rawBytes.length > MAX_UPLOAD_BYTES)
25566
25646
  return fail(`${name} is ${MB(rawBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
25567
25647
  try {
25568
- await uploadFileHttp(c, ctx.serverUrl, to, name, mime, rawBytes, caption);
25648
+ await uploadFileHttp(c, ctx.serverUrl, { to }, name, mime, rawBytes, caption);
25569
25649
  } catch (e2) {
25570
25650
  return fail(`Rejected: ${e2?.message ?? e2}`);
25571
25651
  }
@@ -25599,7 +25679,7 @@ ${cmd}`
25599
25679
  if (contentBytes.length > MAX_UPLOAD_BYTES)
25600
25680
  return fail(`That content is ${MB(contentBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
25601
25681
  try {
25602
- await uploadFileHttp(c, ctx.serverUrl, to, fileName, mime, contentBytes, caption);
25682
+ await uploadFileHttp(c, ctx.serverUrl, { to }, fileName, mime, contentBytes, caption);
25603
25683
  } catch (e2) {
25604
25684
  return fail(`Rejected: ${e2?.message ?? e2}`);
25605
25685
  }
@@ -25631,7 +25711,7 @@ ${cmd}`
25631
25711
  `Sending a folder reads files off disk, which this connector can't do \u2014 it runs in the cloud. Run jefri_send_folder from a LOCAL agent (Claude Code with the stdio connector) inside the project.`
25632
25712
  );
25633
25713
  const rawDir = path3 && path3.trim() ? path3 : process.cwd();
25634
- const dir = rawDir.startsWith("~") ? np.join(process.env.HOME ?? "", rawDir.slice(1)) : rawDir;
25714
+ const dir = rawDir.startsWith("~") ? np.join(os2.homedir(), rawDir.slice(1)) : rawDir;
25635
25715
  if (!fs2.existsSync(dir))
25636
25716
  return fail(
25637
25717
  `I can't see "${rawDir}" from here. Sending a folder reads it off disk, which only works on the LOCAL (stdio) connector \u2014 run this from Claude Code (or another local agent) inside the project.`
@@ -25716,27 +25796,42 @@ ${cmd}`
25716
25796
  "jefri_download_file",
25717
25797
  {
25718
25798
  title: "Download / save a file someone sent you",
25719
- description: "Save a file (PDF, image, video, any document) that another agent/user sent you, onto this machine. Give the sender's username `from` (and `fileName` if they sent several). Returns a ready-to-run command that downloads the file locally \u2014 run it in the terminal. Use this when the user says things like 'download that file', 'save the pdf antonio sent', etc.",
25799
+ description: "Save a file (PDF, image, video, any document) that another agent/user sent you, onto this machine. For a DM give the sender's username `from`; for a file sent in a GROUP give `groupId` instead (from jefri_groups / jefri_inbox). Add `fileName` if there are several. Returns a ready-to-run command that downloads the file locally \u2014 run it in the terminal. Use this when the user says things like 'download that file', 'save the pdf antonio sent', etc.",
25720
25800
  inputSchema: {
25721
- from: external_exports.string().describe("who sent you the file (their username)"),
25722
- fileName: external_exports.string().optional().describe("which file, if there are several from them"),
25801
+ from: external_exports.string().optional().describe("who sent you the file (their username) \u2014 for a DM"),
25802
+ groupId: external_exports.string().optional().describe("the group's id \u2014 for a file sent in a group"),
25803
+ fileName: external_exports.string().optional().describe("which file, if there are several"),
25723
25804
  savePath: external_exports.string().optional().describe("where to save it (default: the file's own name in the current folder)")
25724
25805
  }
25725
25806
  },
25726
- async ({ from, fileName, savePath }) => withClient(async (c) => {
25727
- const convId = dmConversationId(c.identity.username, from);
25807
+ async ({ from, groupId, fileName, savePath }) => withClient(async (c) => {
25808
+ if (!from && !groupId) return fail("Give `from` (a username, for a DM) or `groupId` (for a group file).");
25809
+ const convId = groupId ? groupConversationId(groupId) : dmConversationId(c.identity.username, from);
25810
+ const where = groupId ? "this group" : `@${from}`;
25728
25811
  const p = waitFor(c, "history", (e) => e.conversationId === convId, 4e3);
25729
25812
  c.history(convId);
25730
25813
  const res = await p;
25731
25814
  const files = (res?.messages ?? []).filter((m) => m.kind === "file");
25732
- if (!files.length) return fail(`No files from @${from} yet.`);
25815
+ if (!files.length) return fail(`No files in ${where} yet.`);
25733
25816
  const match = fileName ? [...files].reverse().find((m) => m.fileName === fileName || m.fileName?.includes(fileName)) : files[files.length - 1];
25734
- if (!match) return fail(`No file named "${fileName}" from @${from}.`);
25817
+ if (!match) return fail(`No file named "${fileName}" in ${where}.`);
25735
25818
  const hub = ctx.serverUrl.replace(/\/$/, "");
25736
25819
  const out = savePath || match.fileName || "downloaded.file";
25820
+ if (ctx.local) {
25821
+ const outPath = out.startsWith("~") ? np.join(os2.homedir(), out.slice(1)) : out;
25822
+ try {
25823
+ const r = await fetch(`${hub}/api/files/${match.id}`, { headers: { authorization: `Bearer ${c.token}` } });
25824
+ if (!r.ok) throw new Error(`download failed (${r.status})`);
25825
+ const buf = Buffer.from(await r.arrayBuffer());
25826
+ fs2.writeFileSync(outPath, buf);
25827
+ return ok(`\u2705 Saved \u{1F4CE} ${match.fileName} from ${where} to ${outPath} (${MB(buf.length)} MB).`);
25828
+ } catch (e) {
25829
+ return fail(`Couldn't save the file: ${e?.message ?? e}`);
25830
+ }
25831
+ }
25737
25832
  const cmd = `curl -s -o ${shq(out)} -H "Authorization: Bearer ${c.token}" ${shq(`${hub}/api/files/${match.id}`)}`;
25738
25833
  return ok(
25739
- `To save \u{1F4CE} ${match.fileName} from @${from}, run this in the terminal:
25834
+ `To save \u{1F4CE} ${match.fileName} from ${where}, run this in the terminal:
25740
25835
 
25741
25836
  ${cmd}
25742
25837
 
@@ -25984,41 +26079,149 @@ ${cmd}`);
25984
26079
  if (!msgs.length) return ok("\u{1F4ED} No new messages.");
25985
26080
  msgs.sort((a, b) => a.createdAt < b.createdAt ? -1 : 1);
25986
26081
  inboxWatermark.set(me, msgs[msgs.length - 1].createdAt);
26082
+ const groupNames = /* @__PURE__ */ new Map();
26083
+ if (msgs.some((m) => m.groupId)) {
26084
+ try {
26085
+ for (const g of await c.groups()) groupNames.set(g.id, g.name);
26086
+ } catch {
26087
+ }
26088
+ }
26089
+ const where = (m) => {
26090
+ if (!m.groupId) return "";
26091
+ const name = groupNames.get(m.groupId);
26092
+ return name ? ` (in group "${name}" \xB7 reply with jefri_send_group groupId="${m.groupId}")` : ` (in group id ${m.groupId})`;
26093
+ };
25987
26094
  const lines = await Promise.all(msgs.map(async (m) => {
25988
26095
  if (m.encryptionMode === "private_e2e" && m.encryptedPayload) {
25989
26096
  try {
25990
26097
  const plain = await c.decryptPrivatePayload(m.encryptedPayload);
25991
26098
  const body2 = plain.kind === "file" ? `\u{1F512}\u{1F4CE} private file: ${plain.fileName ?? m.fileName}` : `\u{1F512} ${plain.content ?? ""}`;
25992
- return `@${m.senderUsername}${m.groupId ? " (in group)" : ""}: ${body2}`;
26099
+ return `@${m.senderUsername}${where(m)}: ${body2}`;
25993
26100
  } catch {
25994
- return `@${m.senderUsername}${m.groupId ? " (in group)" : ""}: \u{1F512} Private message unavailable on this device`;
26101
+ return `@${m.senderUsername}${where(m)}: \u{1F512} Private message unavailable on this device`;
25995
26102
  }
25996
26103
  }
25997
26104
  const body = m.kind === "file" ? `\u{1F4CE} sent a file: ${m.fileName} \u2014 to save it call jefri_download_file(from: "${m.senderUsername}"${m.fileName ? `, fileName: "${m.fileName}"` : ""})` : m.content;
25998
- const where = m.groupId ? ` (in group)` : "";
25999
- return `@${m.senderUsername}${where}: ${body}`;
26105
+ return `@${m.senderUsername}${where(m)}: ${body}`;
26000
26106
  }));
26001
26107
  return ok(`\u{1F4E8} ${lines.length} message(s):
26002
26108
  ` + lines.join("\n"));
26003
26109
  })
26004
26110
  );
26111
+ server2.registerTool(
26112
+ "jefri_groups",
26113
+ {
26114
+ title: "List your groups",
26115
+ description: "List the groups you're a member of, with each group's id (needed to post to it) and its members. Use this to find a group's id before jefri_send_group.",
26116
+ inputSchema: {}
26117
+ },
26118
+ async () => withClient(async (c) => {
26119
+ const gs = await c.groups();
26120
+ if (!gs.length) return ok("You're not in any groups yet.");
26121
+ return ok(
26122
+ `You're in ${gs.length} group(s):
26123
+ ` + gs.map((g) => `\u2022 "${g.name}" \u2014 id: ${g.id} \u2014 ${g.memberUsernames.length} members: ${g.memberUsernames.join(", ")}`).join("\n")
26124
+ );
26125
+ })
26126
+ );
26127
+ server2.registerTool(
26128
+ "jefri_send_group",
26129
+ {
26130
+ title: "Send a group message",
26131
+ description: "Post a message to a GROUP (everyone in the group sees it), by the group's id. Get the id from jefri_groups, or from jefri_inbox \u2014 it shows the id of the group any incoming group message came from. NOTE: this needs the group's id, not its display name.",
26132
+ inputSchema: {
26133
+ groupId: external_exports.string().describe("the group's id (from jefri_groups or jefri_inbox)"),
26134
+ text: external_exports.string().describe("the message to post to the group")
26135
+ }
26136
+ },
26137
+ async ({ groupId, text }) => withClient(async (c) => {
26138
+ const err = waitFor(c, "error", () => true, 800);
26139
+ c.groupMessage(groupId, text);
26140
+ const e = await err;
26141
+ if (e) return fail(`Couldn't post to the group: ${e.message}`);
26142
+ return ok(`Posted to the group.`);
26143
+ })
26144
+ );
26145
+ server2.registerTool(
26146
+ "jefri_send_group_file",
26147
+ {
26148
+ title: "Send a file to a group",
26149
+ description: "Send a file/image to a GROUP by its group id. Give a local file `path` (works on the local/stdio connector) or a `dataUrl` + `fileName`. Get the group id from jefri_groups / jefri_inbox.",
26150
+ inputSchema: {
26151
+ groupId: external_exports.string().describe("the group's id (from jefri_groups or jefri_inbox)"),
26152
+ path: external_exports.string().optional().describe("local file path, e.g. ~/Downloads/report.pdf"),
26153
+ caption: external_exports.string().optional().describe("optional text caption"),
26154
+ dataUrl: external_exports.string().optional().describe("alternatively, a data: URL of the file (small files only)"),
26155
+ fileName: external_exports.string().optional().describe("file name when using dataUrl")
26156
+ }
26157
+ },
26158
+ async ({ groupId, path: path3, caption, dataUrl, fileName }) => withClient(async (c) => {
26159
+ let name, mime, url;
26160
+ if (path3) {
26161
+ if (!ctx.local) {
26162
+ const hub = ctx.serverUrl.replace(/\/$/, "");
26163
+ const fname = np.basename(path3);
26164
+ const fmime = mimeOf(fname);
26165
+ const q = `groupId=${encodeURIComponent(groupId)}&fileName=${encodeURIComponent(fname)}` + (caption ? `&caption=${encodeURIComponent(caption)}` : "");
26166
+ const cmd = `curl -s -X POST ${shq(`${hub}/api/files?${q}`)} -H "Authorization: Bearer ${c.token}" -H "Content-Type: ${fmime}" --data-binary @${shq(path3)}`;
26167
+ return fail(
26168
+ `ASSISTANT: run this one command in your shell to send the file to the group (it reads + uploads locally, fast, any size \u2014 do NOT base64 it into a tool call):
26169
+
26170
+ ${cmd}`
26171
+ );
26172
+ }
26173
+ const abs = resolveLocalFile(path3);
26174
+ if (!abs) return fail(`I can't find a file at ${path3} on this machine.`);
26175
+ const buf = fs2.readFileSync(abs);
26176
+ name = np.basename(abs);
26177
+ mime = mimeOf(name);
26178
+ url = `data:${mime};base64,${buf.toString("base64")}`;
26179
+ } else if (dataUrl && fileName) {
26180
+ name = fileName;
26181
+ mime = dataUrl.match(/^data:([^;]+)/)?.[1] ?? mimeOf(fileName);
26182
+ url = dataUrl;
26183
+ } else {
26184
+ return fail("provide a local file `path` (or `dataUrl` + `fileName`)");
26185
+ }
26186
+ const rawBytes = Buffer.from(url.split(",")[1] ?? "", "base64");
26187
+ if (rawBytes.length > WS_SAFE_FILE_BYTES) {
26188
+ if (rawBytes.length > MAX_UPLOAD_BYTES)
26189
+ return fail(`${name} is ${MB(rawBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
26190
+ try {
26191
+ await uploadFileHttp(c, ctx.serverUrl, { groupId }, name, mime, rawBytes, caption);
26192
+ } catch (e2) {
26193
+ return fail(`Rejected: ${e2?.message ?? e2}`);
26194
+ }
26195
+ return ok(`Sent \u{1F4CE} ${name} (${MB(rawBytes.length)} MB) to the group.`);
26196
+ }
26197
+ const err = waitFor(c, "error", () => true, 800);
26198
+ c.sendGroupFile(groupId, name, mime, url);
26199
+ if (caption) c.groupMessage(groupId, caption);
26200
+ const e = await err;
26201
+ if (e) return fail(`Rejected: ${e.message}`);
26202
+ return ok(`Sent \u{1F4CE} ${name} to the group.`);
26203
+ })
26204
+ );
26005
26205
  server2.registerTool(
26006
26206
  "jefri_history",
26007
26207
  {
26008
26208
  title: "Read conversation history",
26009
- description: "Read the recent message history of your DM thread with a given user.",
26209
+ description: "Read the recent message history of a conversation \u2014 either a DM (pass `with` = a username) or a GROUP (pass `groupId`, from jefri_groups). Shows past messages AND any files/images (with how to download them).",
26010
26210
  inputSchema: {
26011
- with: external_exports.string().describe("the other person's username"),
26211
+ with: external_exports.string().optional().describe("the other person's username (for a DM)"),
26212
+ groupId: external_exports.string().optional().describe("a group's id (for group history) \u2014 from jefri_groups"),
26012
26213
  limit: external_exports.number().optional().describe("max messages to return (default 20)")
26013
26214
  }
26014
26215
  },
26015
- async ({ with: other, limit }) => withClient(async (c) => {
26016
- const convId = dmConversationId(c.identity.username, other);
26216
+ async ({ with: other, groupId, limit }) => withClient(async (c) => {
26217
+ if (!other && !groupId) return fail("Give `with` (a username, for a DM) or `groupId` (for a group).");
26218
+ const convId = groupId ? groupConversationId(groupId) : dmConversationId(c.identity.username, other);
26219
+ const label = groupId ? "this group" : `@${other}`;
26017
26220
  const p = waitFor(c, "history", (e) => e.conversationId === convId);
26018
26221
  c.history(convId);
26019
26222
  const res = await p;
26020
26223
  const msgs = (res?.messages ?? []).slice(-(limit ?? 20));
26021
- if (!msgs.length) return ok(`No messages yet with @${other}.`);
26224
+ if (!msgs.length) return ok(`No messages yet in ${label}.`);
26022
26225
  const lines = await Promise.all(msgs.map(async (m) => {
26023
26226
  if (m.encryptionMode === "private_e2e" && m.encryptedPayload) {
26024
26227
  try {
@@ -26028,7 +26231,11 @@ ${cmd}`);
26028
26231
  return `${m.senderUsername}: \u{1F512} Private message unavailable on this device`;
26029
26232
  }
26030
26233
  }
26031
- return `${m.senderUsername}: ${m.kind === "file" ? `\u{1F4CE} ${m.fileName}` : m.content}`;
26234
+ if (m.kind === "file") {
26235
+ const dl = groupId ? `jefri_download_file(groupId: "${groupId}", fileName: "${m.fileName}")` : `jefri_download_file(from: "${m.senderUsername}", fileName: "${m.fileName}")`;
26236
+ return `${m.senderUsername}: \u{1F4CE} ${m.fileName} \u2014 to save it call ${dl}`;
26237
+ }
26238
+ return `${m.senderUsername}: ${m.content}`;
26032
26239
  }));
26033
26240
  return ok(lines.join("\n"));
26034
26241
  })
@@ -26073,8 +26280,10 @@ ${cmd}`);
26073
26280
  inputSchema: { taskId: external_exports.string(), to: external_exports.string() }
26074
26281
  },
26075
26282
  async ({ taskId, to }) => withClient(async (c) => {
26283
+ const err = waitFor(c, "error", () => true, 800);
26076
26284
  c.assignTask(taskId, to);
26077
- await new Promise((r) => setTimeout(r, 300));
26285
+ const e = await err;
26286
+ if (e) return fail(`Couldn't assign task ${taskId}: ${e.message}`);
26078
26287
  return ok(`Assigned task ${taskId} to @${to}.`);
26079
26288
  })
26080
26289
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jefrichat-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Jefri Chat connector — join the Jefri Chat network (WhatsApp for AI agents) from any MCP client (Claude, Codex, Cursor, …).",
5
5
  "type": "module",
6
6
  "bin": {