paperclip-plugin-mattermost 0.17.0 → 0.18.1
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 +2 -1
- package/dist/manifest.js +8 -6
- package/dist/worker.js +102 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@ Your [Paperclip](https://github.com/paperclipai/paperclip) agents as Mattermost
|
|
|
14
14
|
- **Single-choice questions** get a button per option. Your pick is posted on the task as a comment from you, which wakes the agent.
|
|
15
15
|
- **Access control.** Choose who may talk to agents: everyone, or only the people you pick. Each person can be limited to some agents, and people outside Paperclip can be added by email.
|
|
16
16
|
- **Agents work in Mattermost themselves.** Through plugin API routes that only agents can call, each agent reads the channels its bot is in, posts, reacts, searches, and DMs the people allowed to talk to it, always as its own bot. Each company gets a Mattermost skill that tells agents how, and saving the settings adds it to every agent with a bot, so they can message people from tasks and scheduled runs too.
|
|
17
|
+
- **Work happens in Paperclip tasks.** Asked to do something, an agent creates a Paperclip task, replies with its number, and does the work there. When the task is done, cancelled or blocked, its outcome is posted back in the thread it was asked in.
|
|
17
18
|
- **Steering.** A message you send in a thread while its agent is still working reaches that run: the agent reads it between steps (and an agent waiting at its computer stops waiting to read it), so "stop" or a correction lands mid-task. One it read is marked ✅ and not run again.
|
|
18
19
|
- **Agents work with agents.** An agent mentioning another agent starts it, and it answers in the thread. After a set number of agent-to-agent messages in a thread (10 by default, set in settings), they pause until a person replies.
|
|
19
20
|
- **Work channels.** For an investigation, incident or project, an agent opens a private channel (`inc-checkout-5xx`), pulls in the people and agents it needs, and ties it to a Paperclip task. Task comments and status changes show up in the channel. When the task is done or cancelled, the channel is archived and the outcome is posted back where the work was asked for.
|
|
@@ -84,7 +85,7 @@ PAPERCLIP_REF=<commit> node build.mjs # build for another Paperclip version
|
|
|
84
85
|
Bump `version` in `package.json` and `src/manifest.ts`, commit, then tag and push:
|
|
85
86
|
|
|
86
87
|
```bash
|
|
87
|
-
git tag -a v0.
|
|
88
|
+
git tag -a v0.18.1 -m v0.18.1 && git push origin main v0.18.1
|
|
88
89
|
```
|
|
89
90
|
|
|
90
91
|
GitHub Actions builds, tests and publishes to npm through npm trusted publishing, so no npm token is involved.
|
package/dist/manifest.js
CHANGED
|
@@ -16,11 +16,13 @@ tell whoever you are working with and carry on. A 403 says why something is not
|
|
|
16
16
|
|
|
17
17
|
## How to work
|
|
18
18
|
|
|
19
|
-
- **
|
|
20
|
-
task (assign it to yourself or delegate it) and say so in the thread
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
- **Work within Paperclip tasks.** A quick question: answer in the thread. Anything you are asked to
|
|
20
|
+
do: create a Paperclip task first (assign it to yourself or delegate it) and say so in the thread
|
|
21
|
+
with its number. Do the work in the task and end with the outcome as its last comment: when the
|
|
22
|
+
task is done, cancelled or blocked, that comment is posted back in the thread it was asked in. An
|
|
23
|
+
investigation, incident or project, or anything involving several agents or more than a day: also
|
|
24
|
+
open a work channel for it (below), with the person who asked and whoever you need. Name it for
|
|
25
|
+
what it is: "inc checkout 5xx", "inv q3 conversion drop", "proj crm migration".
|
|
24
26
|
- **Announce a work channel where the request came in**, in one message: what it is for, who you
|
|
25
27
|
pulled in, and that you will report back there. When the task is done or cancelled the plugin
|
|
26
28
|
archives the channel and posts the outcome back where you asked; you do not need to.
|
|
@@ -82,7 +84,7 @@ var route = (routeKey, method, path) => ({ routeKey, method, path, auth: "agent"
|
|
|
82
84
|
var manifest = {
|
|
83
85
|
id: "mattermost-bridge",
|
|
84
86
|
apiVersion: 1,
|
|
85
|
-
version: "0.
|
|
87
|
+
version: "0.18.1",
|
|
86
88
|
displayName: "Mattermost",
|
|
87
89
|
description: "Every agent becomes its own Mattermost bot. DM an agent or mention it in a channel and it answers in the thread, showing its steps as it works; follow-ups continue the conversation. Paperclip approvals arrive with Approve / Reject buttons.",
|
|
88
90
|
author: "Haseeb Azhar",
|
package/dist/worker.js
CHANGED
|
@@ -45353,6 +45353,7 @@ var Actions = class {
|
|
|
45353
45353
|
// src/worklinks.ts
|
|
45354
45354
|
var FROM_PAPERCLIP = { from_paperclip: "true" };
|
|
45355
45355
|
var CLOSED = /* @__PURE__ */ new Set(["done", "cancelled"]);
|
|
45356
|
+
var THREAD_REPORTS = { done: ":white_check_mark: Done:", cancelled: ":no_entry_sign: Cancelled:", blocked: ":pause_button: Blocked:" };
|
|
45356
45357
|
var STATUS_WORDS = {
|
|
45357
45358
|
todo: "to do",
|
|
45358
45359
|
in_progress: "in progress",
|
|
@@ -45388,6 +45389,25 @@ var WorkLinks = class {
|
|
|
45388
45389
|
${body}`;
|
|
45389
45390
|
await this.d.post(own2 ? c.authorAgentId : hit.link.agentId, hit.channelId, "", text2);
|
|
45390
45391
|
}
|
|
45392
|
+
/**
|
|
45393
|
+
* A task asked for in a Mattermost thread (created by the agent while answering there): when it is
|
|
45394
|
+
* done, cancelled or blocked, its outcome (the last comment) is posted back in that thread, so the
|
|
45395
|
+
* result always reaches the conversation it came from.
|
|
45396
|
+
*/
|
|
45397
|
+
async threadUpdate(issueId) {
|
|
45398
|
+
const at = await this.d.store.get(`issuethread:${issueId}`);
|
|
45399
|
+
if (!at) return;
|
|
45400
|
+
const task2 = await this.d.task(issueId);
|
|
45401
|
+
if (!task2 || task2.status === at.status || !THREAD_REPORTS[task2.status]) return;
|
|
45402
|
+
await this.d.store.set(`issuethread:${issueId}`, { ...at, status: task2.status });
|
|
45403
|
+
const last = (await this.d.comments(issueId)).at(-1)?.body?.trim();
|
|
45404
|
+
const outcome = last ? `
|
|
45405
|
+
|
|
45406
|
+
${last.length > 3e3 ? last.slice(0, 3e3) + "\u2026" : last}` : "";
|
|
45407
|
+
const by = [task2.assigneeAgentId, at.agentId].find((a) => Boolean(a && this.d.hasBot(a)));
|
|
45408
|
+
if (!by) return;
|
|
45409
|
+
await this.d.post(by, at.channelId, at.rootId, `${THREAD_REPORTS[task2.status]} **${task2.identifier ?? "The task"}** ${task2.title}${outcome}`);
|
|
45410
|
+
}
|
|
45391
45411
|
/** The task changed: note a new status; on done or cancelled, archive and report back. */
|
|
45392
45412
|
async updated(issueId) {
|
|
45393
45413
|
const hit = await this.linked(issueId);
|
|
@@ -45443,11 +45463,13 @@ tell whoever you are working with and carry on. A 403 says why something is not
|
|
|
45443
45463
|
|
|
45444
45464
|
## How to work
|
|
45445
45465
|
|
|
45446
|
-
- **
|
|
45447
|
-
task (assign it to yourself or delegate it) and say so in the thread
|
|
45448
|
-
|
|
45449
|
-
|
|
45450
|
-
|
|
45466
|
+
- **Work within Paperclip tasks.** A quick question: answer in the thread. Anything you are asked to
|
|
45467
|
+
do: create a Paperclip task first (assign it to yourself or delegate it) and say so in the thread
|
|
45468
|
+
with its number. Do the work in the task and end with the outcome as its last comment: when the
|
|
45469
|
+
task is done, cancelled or blocked, that comment is posted back in the thread it was asked in. An
|
|
45470
|
+
investigation, incident or project, or anything involving several agents or more than a day: also
|
|
45471
|
+
open a work channel for it (below), with the person who asked and whoever you need. Name it for
|
|
45472
|
+
what it is: "inc checkout 5xx", "inv q3 conversion drop", "proj crm migration".
|
|
45451
45473
|
- **Announce a work channel where the request came in**, in one message: what it is for, who you
|
|
45452
45474
|
pulled in, and that you will report back there. When the task is done or cancelled the plugin
|
|
45453
45475
|
archives the channel and posts the outcome back where you asked; you do not need to.
|
|
@@ -45508,6 +45530,9 @@ ${HELP}`;
|
|
|
45508
45530
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
45509
45531
|
|
|
45510
45532
|
// src/bridge.ts
|
|
45533
|
+
var IN_FLIGHT = "inflight";
|
|
45534
|
+
var IN_FLIGHT_MAX_AGE = 3 * 60 * 60 * 1e3;
|
|
45535
|
+
var RESUME_PROMPT = "The Mattermost plugin restarted while you were working on the conversation above, so your reply did not reach the thread. When you have finished, give your answer to the latest message again; it is posted in the thread.";
|
|
45511
45536
|
var DEFAULT_TURN_LIMIT = 10;
|
|
45512
45537
|
var TYPING_EVERY_MS = 3e3;
|
|
45513
45538
|
var OLD_PLACEHOLDER = /^_(thinking…|.+ is working on it…)_/;
|
|
@@ -45750,7 +45775,7 @@ var Bridge = class {
|
|
|
45750
45775
|
const attached = fileIds.length ? await this.files.receive(member.chat, post.id, fileIds) : [];
|
|
45751
45776
|
const outbox = this.files ? await this.files.outbox(post.id).catch(() => null) : null;
|
|
45752
45777
|
const fromAgent = this.roster().isBot(post.user_id);
|
|
45753
|
-
const prompt = `${sender.displayName} (@${sender.username}${fromAgent ? ", another agent" : ""}) wrote to you (@${member.username}) in ${where} on Mattermost. Your reply is posted back there, so answer in Markdown. To offer choices, end your reply with one line \`choices: First | Second\` (2 to ${MAX_CHOICES} short options); they are shown as buttons, and the pick comes back as the next message. You can also read channels, post, message people, mention other agents and open work channels in Mattermost: GET <your Paperclip API URL>/api/plugins/mattermost-bridge/api/mm/help shows how. If this takes more than a minute or two, check GET \u2026/api/plugins/mattermost-bridge/api/mm/inbox between steps: it returns anything people write in this thread meanwhile (a correction, "stop", "never mind"); act on it then, as it is not sent to you again.` + (history ? `
|
|
45778
|
+
const prompt = `${sender.displayName} (@${sender.username}${fromAgent ? ", another agent" : ""}) wrote to you (@${member.username}) in ${where} on Mattermost. Your reply is posted back there, so answer in Markdown. Work within Paperclip tasks: if the message asks you to do something (anything more than answering a quick question), first create a Paperclip task for it, assigned to yourself or to the agent it belongs with, and reply here with the task's number, what will happen, and that the result will come back to this thread. Then do the work in that task, not in this chat, and finish with the outcome as the task's last comment: when the task is done, cancelled or blocked, that comment is posted back here for you. Quick questions you can answer directly. To offer choices, end your reply with one line \`choices: First | Second\` (2 to ${MAX_CHOICES} short options); they are shown as buttons, and the pick comes back as the next message. You can also read channels, post, message people, mention other agents and open work channels in Mattermost: GET <your Paperclip API URL>/api/plugins/mattermost-bridge/api/mm/help shows how. If this takes more than a minute or two, check GET \u2026/api/plugins/mattermost-bridge/api/mm/inbox between steps: it returns anything people write in this thread meanwhile (a correction, "stop", "never mind"); act on it then, as it is not sent to you again.` + (history ? `
|
|
45754
45779
|
|
|
45755
45780
|
Earlier in this thread (oldest first):
|
|
45756
45781
|
${history}
|
|
@@ -45768,25 +45793,82 @@ To send files back with your reply, save them in ${outbox}` : "");
|
|
|
45768
45793
|
this.active.set(member.agentId, here);
|
|
45769
45794
|
try {
|
|
45770
45795
|
let sessionId = thread && thread.agentId === member.agentId ? thread.sessionId : null;
|
|
45796
|
+
const flight = (id) => this.inFlight(rootId, { agentId: member.agentId, sessionId: id, channelId: post.channel_id, channelType: evt.channelType, channelName: evt.channelName, at: Date.now() });
|
|
45771
45797
|
try {
|
|
45772
45798
|
if (!sessionId) throw new Error("no session yet");
|
|
45799
|
+
await flight(sessionId);
|
|
45773
45800
|
reply = await this.agents.ask(sessionId, prompt);
|
|
45774
|
-
} catch (
|
|
45775
|
-
if (sessionId) this.log("session unusable, starting a new one", { sessionId, err: String(
|
|
45801
|
+
} catch (first) {
|
|
45802
|
+
if (sessionId) this.log("session unusable, starting a new one", { sessionId, err: String(first) });
|
|
45776
45803
|
sessionId = await this.agents.createSession(member.agentId, `mattermost:${rootId}`);
|
|
45777
45804
|
await this.store.set(threadKey, { agentId: member.agentId, sessionId });
|
|
45805
|
+
await flight(sessionId);
|
|
45778
45806
|
reply = await this.agents.ask(sessionId, prompt);
|
|
45779
45807
|
}
|
|
45780
45808
|
} catch (err) {
|
|
45781
45809
|
if (this.active.get(member.agentId) === here) this.active.delete(member.agentId);
|
|
45782
45810
|
this.inbox.delete(member.agentId);
|
|
45811
|
+
await this.inFlight(rootId, null);
|
|
45783
45812
|
await member.chat.createPost(post.channel_id, rootId, `:warning: ${member.agentName} could not answer: ${String(err?.message ?? err).slice(0, 500)}`);
|
|
45784
45813
|
return;
|
|
45785
45814
|
}
|
|
45786
45815
|
if (this.active.get(member.agentId) === here) this.active.delete(member.agentId);
|
|
45787
45816
|
this.inbox.delete(member.agentId);
|
|
45817
|
+
await this.inFlight(rootId, null);
|
|
45818
|
+
await this.postReply(member, { channelId: post.channel_id, rootId, channelType: evt.channelType, channelName: evt.channelName }, reply, outbox);
|
|
45819
|
+
}
|
|
45820
|
+
/** Records (or with null clears) the reply this thread is waiting for. */
|
|
45821
|
+
async inFlight(rootId, f) {
|
|
45822
|
+
const all = await this.store.get(IN_FLIGHT) ?? {};
|
|
45823
|
+
if (f) all[rootId] = f;
|
|
45824
|
+
else delete all[rootId];
|
|
45825
|
+
await this.store.set(IN_FLIGHT, all);
|
|
45826
|
+
}
|
|
45827
|
+
/**
|
|
45828
|
+
* After a plugin restart: every reply that was being waited for is asked for again from the same
|
|
45829
|
+
* agent session (which queues behind the run still going), and posted in its thread when it comes.
|
|
45830
|
+
*/
|
|
45831
|
+
async resume() {
|
|
45832
|
+
const all = await this.store.get(IN_FLIGHT) ?? {};
|
|
45833
|
+
let n = 0;
|
|
45834
|
+
for (const [rootId, f] of Object.entries(all)) {
|
|
45835
|
+
const member = this.roster().byAgentId(f.agentId);
|
|
45836
|
+
if (!member || Date.now() - f.at > IN_FLIGHT_MAX_AGE) {
|
|
45837
|
+
await this.inFlight(rootId, null);
|
|
45838
|
+
continue;
|
|
45839
|
+
}
|
|
45840
|
+
n++;
|
|
45841
|
+
const prev = this.queues.get(rootId) ?? Promise.resolve();
|
|
45842
|
+
const next = prev.then(async () => {
|
|
45843
|
+
const typing = () => {
|
|
45844
|
+
void member.chat.typing(f.channelId, rootId).catch(() => {
|
|
45845
|
+
});
|
|
45846
|
+
};
|
|
45847
|
+
typing();
|
|
45848
|
+
const timer = setInterval(typing, TYPING_EVERY_MS);
|
|
45849
|
+
try {
|
|
45850
|
+
const reply = await this.agents.ask(f.sessionId, RESUME_PROMPT);
|
|
45851
|
+
await this.inFlight(rootId, null);
|
|
45852
|
+
await this.postReply(member, { channelId: f.channelId, rootId, channelType: f.channelType, channelName: f.channelName }, reply, null);
|
|
45853
|
+
} catch (err) {
|
|
45854
|
+
await this.inFlight(rootId, null);
|
|
45855
|
+
await member.chat.createPost(f.channelId, rootId, `:warning: ${member.agentName}'s reply was lost when the plugin restarted: ${String(err?.message ?? err).slice(0, 300)}. Ask again here.`).catch(() => {
|
|
45856
|
+
});
|
|
45857
|
+
} finally {
|
|
45858
|
+
clearInterval(timer);
|
|
45859
|
+
}
|
|
45860
|
+
}).catch((err) => this.log("resume failed", { err: String(err) }));
|
|
45861
|
+
this.queues.set(rootId, next);
|
|
45862
|
+
void next.finally(() => {
|
|
45863
|
+
if (this.queues.get(rootId) === next) this.queues.delete(rootId);
|
|
45864
|
+
});
|
|
45865
|
+
}
|
|
45866
|
+
return n;
|
|
45867
|
+
}
|
|
45868
|
+
async postReply(member, origin, reply, outbox) {
|
|
45869
|
+
const post = { channel_id: origin.channelId };
|
|
45870
|
+
const rootId = origin.rootId;
|
|
45788
45871
|
const { text: body, choices } = extractChoices(linkTasks(absoluteLinks(reply ?? "", this.linkBase), this.linkBase, this.taskPrefix));
|
|
45789
|
-
const origin = { channelId: post.channel_id, rootId, channelType: evt.channelType, channelName: evt.channelName };
|
|
45790
45872
|
const buttons = choices.length && this.choiceButtons ? this.choiceButtons(origin, choices) : null;
|
|
45791
45873
|
const shown = choices.length && !buttons ? `${body}
|
|
45792
45874
|
|
|
@@ -46283,7 +46365,7 @@ var route = (routeKey, method, path3) => ({ routeKey, method, path: path3, auth:
|
|
|
46283
46365
|
var manifest = {
|
|
46284
46366
|
id: "mattermost-bridge",
|
|
46285
46367
|
apiVersion: 1,
|
|
46286
|
-
version: "0.
|
|
46368
|
+
version: "0.18.1",
|
|
46287
46369
|
displayName: "Mattermost",
|
|
46288
46370
|
description: "Every agent becomes its own Mattermost bot. DM an agent or mention it in a channel and it answers in the thread, showing its steps as it works; follow-ups continue the conversation. Paperclip approvals arrive with Approve / Reject buttons.",
|
|
46289
46371
|
author: "Haseeb Azhar",
|
|
@@ -46637,7 +46719,8 @@ var Tenant = class {
|
|
|
46637
46719
|
store,
|
|
46638
46720
|
post: async (agentId, channelId, rootId, text2) => {
|
|
46639
46721
|
const as = roster.clientOf(agentId) ?? manager;
|
|
46640
|
-
|
|
46722
|
+
const message2 = linkTasks(absoluteLinks(text2, paperclipUrl), paperclipUrl, bridge.taskPrefix);
|
|
46723
|
+
await as.request("POST", "/posts", { channel_id: channelId, root_id: rootId, message: message2, props: FROM_PAPERCLIP });
|
|
46641
46724
|
},
|
|
46642
46725
|
hasBot: (agentId) => Boolean(roster.clientOf(agentId)),
|
|
46643
46726
|
agentName: (agentId) => agentName(agentId),
|
|
@@ -46658,7 +46741,9 @@ var Tenant = class {
|
|
|
46658
46741
|
this.status = `agent bot sync failed: ${errText(err)}`;
|
|
46659
46742
|
ctx.logger.error(this.status, { companyId });
|
|
46660
46743
|
});
|
|
46661
|
-
void sync()
|
|
46744
|
+
void sync().then(() => bridge.resume()).then((n) => {
|
|
46745
|
+
if (n) ctx.logger.info("resuming replies interrupted by a restart", { companyId, threads: n });
|
|
46746
|
+
}).catch((err) => warn("could not resume interrupted replies", { err: errText(err) }));
|
|
46662
46747
|
this.timers.push(setInterval(sync, SYNC_EVERY_MS));
|
|
46663
46748
|
void ensureSkill(companyId).catch((err) => warn("could not install the Mattermost skill", { err: errText(err) }));
|
|
46664
46749
|
if (cards) {
|
|
@@ -46844,7 +46929,10 @@ var plugin = definePlugin({
|
|
|
46844
46929
|
if (e.entityId && commentId) await tenants.get(e.companyId)?.work?.comment(e.entityId, commentId);
|
|
46845
46930
|
}));
|
|
46846
46931
|
ctx.events.on("issue.updated", (e) => quietly("could not follow a task update", async () => {
|
|
46847
|
-
if (e.entityId)
|
|
46932
|
+
if (!e.entityId) return;
|
|
46933
|
+
const work = tenants.get(e.companyId)?.work;
|
|
46934
|
+
await work?.updated(e.entityId);
|
|
46935
|
+
await work?.threadUpdate(e.entityId);
|
|
46848
46936
|
}));
|
|
46849
46937
|
ctx.events.on("plugin.agent-computer.needs-person", (e) => quietly("could not post a computer help request", async () => {
|
|
46850
46938
|
await tenants.get(e.companyId)?.computerNeedsPerson(e.payload ?? {});
|
|
@@ -46855,7 +46943,7 @@ var plugin = definePlugin({
|
|
|
46855
46943
|
const issue2 = await ctx.issues.get(e.entityId, e.companyId);
|
|
46856
46944
|
const by = issue2?.createdByAgentId;
|
|
46857
46945
|
const at = by ? tenant.bridge.activeThread(by) : null;
|
|
46858
|
-
if (at) await tenant.store().set(`issuethread:${e.entityId}`, at);
|
|
46946
|
+
if (at) await tenant.store().set(`issuethread:${e.entityId}`, { ...at, agentId: by, status: issue2?.status });
|
|
46859
46947
|
}));
|
|
46860
46948
|
ctx.events.on("approval.decided", (e) => quietly("could not update approval", async () => {
|
|
46861
46949
|
if (e.entityId) await tenants.get(e.companyId)?.approvals?.decided(e.entityId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paperclip-plugin-mattermost",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"description": "Paperclip agents in Mattermost: a bot per agent, live progress, files, approval and decision buttons, and per-person access, for each Paperclip company.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|