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 +6 -6
- package/dist/http.js +256 -47
- package/dist/index.js +255 -46
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
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
|
|
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", "
|
|
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
|
|
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
|
-
`
|
|
44
|
-
`
|
|
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
|
|
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,
|
|
49458
|
+
async function uploadFileHttp(c, serverUrl, target, fileName, mime, bytes, caption) {
|
|
49401
49459
|
const hub = serverUrl.replace(/\/$/, "");
|
|
49402
|
-
const
|
|
49403
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49532
|
-
if (!fs2.existsSync(abs)) {
|
|
49594
|
+
if (!ctx.local) {
|
|
49533
49595
|
return fail(
|
|
49534
|
-
`I can't read
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49588
|
-
|
|
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
|
|
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
|
-
|
|
49626
|
-
|
|
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
|
|
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
|
-
`
|
|
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
|
-
\
|
|
49638
|
-
${
|
|
49711
|
+
\u2022 macOS / Linux:
|
|
49712
|
+
${cmd}
|
|
49639
49713
|
|
|
49640
|
-
|
|
49641
|
-
${
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}"
|
|
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
|
|
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
|
|
50191
|
+
return `@${m.senderUsername}${where(m)}: ${body2}`;
|
|
50085
50192
|
} catch {
|
|
50086
|
-
return `@${m.senderUsername}${m
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
|
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,
|
|
25366
|
+
async function uploadFileHttp(c, serverUrl, target, fileName, mime, bytes, caption) {
|
|
25309
25367
|
const hub = serverUrl.replace(/\/$/, "");
|
|
25310
|
-
const
|
|
25311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25440
|
-
if (!fs2.existsSync(abs)) {
|
|
25502
|
+
if (!ctx.local) {
|
|
25441
25503
|
return fail(
|
|
25442
|
-
`I can't read
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25496
|
-
if (!fs2.existsSync(abs)) {
|
|
25563
|
+
if (!ctx.local) {
|
|
25497
25564
|
return fail(
|
|
25498
|
-
`I can't read
|
|
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
|
-
|
|
25534
|
-
|
|
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
|
|
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
|
-
`
|
|
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
|
-
\
|
|
25546
|
-
${
|
|
25619
|
+
\u2022 macOS / Linux:
|
|
25620
|
+
${cmd}
|
|
25547
25621
|
|
|
25548
|
-
|
|
25549
|
-
${
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}"
|
|
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
|
|
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
|
|
26099
|
+
return `@${m.senderUsername}${where(m)}: ${body2}`;
|
|
25993
26100
|
} catch {
|
|
25994
|
-
return `@${m.senderUsername}${m
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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