paperclip-plugin-mattermost 0.15.1 → 0.16.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 +3 -2
- package/dist/manifest.js +11 -1
- package/dist/worker.js +80 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,10 +14,11 @@ 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
|
+
- **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.
|
|
17
18
|
- **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.
|
|
18
19
|
- **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.
|
|
19
20
|
- **Ask first.** Some actions wait for an approver to click Approve: `@channel`, `@all` and `@here`, public channels, and adding people to a channel the agent didn't create. Agents can't change or archive channels they didn't create, remove anyone, or touch team and server settings.
|
|
20
|
-
- **Agent computer requests.** With the Agent computer plugin installed, an agent that needs a person
|
|
21
|
+
- **Agent computer requests.** With the Agent computer plugin installed, an agent that needs a person at its computer (a login, 2FA, a password) posts in the thread it is working in, or the approvals channel, with a link to take over.
|
|
21
22
|
- **One install, many companies.** Each Paperclip company connects its own Mattermost, with its own settings, bots and state.
|
|
22
23
|
|
|
23
24
|
## Requirements
|
|
@@ -83,7 +84,7 @@ PAPERCLIP_REF=<commit> node build.mjs # build for another Paperclip version
|
|
|
83
84
|
Bump `version` in `package.json` and `src/manifest.ts`, commit, then tag and push:
|
|
84
85
|
|
|
85
86
|
```bash
|
|
86
|
-
git tag -a v0.
|
|
87
|
+
git tag -a v0.16.0 -m v0.16.0 && git push origin main v0.16.0
|
|
87
88
|
```
|
|
88
89
|
|
|
89
90
|
GitHub Actions builds, tests and publishes to npm through npm trusted publishing, so no npm token is involved.
|
package/dist/manifest.js
CHANGED
|
@@ -32,6 +32,13 @@ tell whoever you are working with and carry on. A 403 says why something is not
|
|
|
32
32
|
- Do not write @channel, @all or @here unless everyone in the channel must be notified: it needs
|
|
33
33
|
approval.
|
|
34
34
|
|
|
35
|
+
## While you work
|
|
36
|
+
|
|
37
|
+
If people write in the thread you are answering while you work (a correction, "stop", "never mind"),
|
|
38
|
+
it reaches you: GET /mm/inbox returns those messages, and any other /mm call you make carries them
|
|
39
|
+
as \`newMessages\`. Check /mm/inbox between steps of anything that takes more than a minute or two,
|
|
40
|
+
and act on what you find then. Each message is handed to you once; it is not sent again afterwards.
|
|
41
|
+
|
|
35
42
|
## Calls
|
|
36
43
|
|
|
37
44
|
Reading (GET):
|
|
@@ -42,6 +49,7 @@ Reading (GET):
|
|
|
42
49
|
- /mm/search?q=words search messages in your channels
|
|
43
50
|
- /mm/people?q=name find people and agents by name
|
|
44
51
|
- /mm/requests/{id} an approval request you made
|
|
52
|
+
- /mm/inbox what people wrote in your thread while you were working
|
|
45
53
|
|
|
46
54
|
Talking (POST, JSON body):
|
|
47
55
|
- /mm/post {"channel": "...", "text": "...", "threadId": "optional post id to reply to"}
|
|
@@ -74,7 +82,7 @@ var route = (routeKey, method, path) => ({ routeKey, method, path, auth: "agent"
|
|
|
74
82
|
var manifest = {
|
|
75
83
|
id: "mattermost-bridge",
|
|
76
84
|
apiVersion: 1,
|
|
77
|
-
version: "0.
|
|
85
|
+
version: "0.16.0",
|
|
78
86
|
displayName: "Mattermost",
|
|
79
87
|
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.",
|
|
80
88
|
author: "Haseeb Azhar",
|
|
@@ -90,6 +98,7 @@ var manifest = {
|
|
|
90
98
|
"plugin.state.write",
|
|
91
99
|
"http.outbound",
|
|
92
100
|
"events.subscribe",
|
|
101
|
+
"events.emit",
|
|
93
102
|
"webhooks.receive",
|
|
94
103
|
"approvals.read",
|
|
95
104
|
"approvals.respond",
|
|
@@ -110,6 +119,7 @@ var manifest = {
|
|
|
110
119
|
// checks the caller is an agent and scopes it to that agent's company.
|
|
111
120
|
apiRoutes: [
|
|
112
121
|
route("help", "GET", "/mm/help"),
|
|
122
|
+
route("inbox", "GET", "/mm/inbox"),
|
|
113
123
|
route("channels", "GET", "/mm/channels"),
|
|
114
124
|
route("channel-posts", "GET", "/mm/channels/:channel/posts"),
|
|
115
125
|
route("thread", "GET", "/mm/posts/:postId/thread"),
|
package/dist/worker.js
CHANGED
|
@@ -45456,6 +45456,13 @@ tell whoever you are working with and carry on. A 403 says why something is not
|
|
|
45456
45456
|
- Do not write @channel, @all or @here unless everyone in the channel must be notified: it needs
|
|
45457
45457
|
approval.
|
|
45458
45458
|
|
|
45459
|
+
## While you work
|
|
45460
|
+
|
|
45461
|
+
If people write in the thread you are answering while you work (a correction, "stop", "never mind"),
|
|
45462
|
+
it reaches you: GET /mm/inbox returns those messages, and any other /mm call you make carries them
|
|
45463
|
+
as \`newMessages\`. Check /mm/inbox between steps of anything that takes more than a minute or two,
|
|
45464
|
+
and act on what you find then. Each message is handed to you once; it is not sent again afterwards.
|
|
45465
|
+
|
|
45459
45466
|
## Calls
|
|
45460
45467
|
|
|
45461
45468
|
Reading (GET):
|
|
@@ -45466,6 +45473,7 @@ Reading (GET):
|
|
|
45466
45473
|
- /mm/search?q=words search messages in your channels
|
|
45467
45474
|
- /mm/people?q=name find people and agents by name
|
|
45468
45475
|
- /mm/requests/{id} an approval request you made
|
|
45476
|
+
- /mm/inbox what people wrote in your thread while you were working
|
|
45469
45477
|
|
|
45470
45478
|
Talking (POST, JSON body):
|
|
45471
45479
|
- /mm/post {"channel": "...", "text": "...", "threadId": "optional post id to reply to"}
|
|
@@ -45513,6 +45521,11 @@ function mentions(text2) {
|
|
|
45513
45521
|
function stripMention(text2, username) {
|
|
45514
45522
|
return text2.replace(new RegExp(`(^|[^\\w@])@${username.replace(/[.-]/g, "\\$&")}\\b[:,]?`, "gi"), "$1").replace(/\s{2,}/g, " ").trim();
|
|
45515
45523
|
}
|
|
45524
|
+
function absoluteLinks(text2, base) {
|
|
45525
|
+
if (!base) return text2;
|
|
45526
|
+
const root = base.replace(/\/+$/, "");
|
|
45527
|
+
return text2.replace(/\]\((\/(?!\/)[^)\s]*)\)/g, (_m, path3) => `](${root}${path3})`);
|
|
45528
|
+
}
|
|
45516
45529
|
function splitMessage(text2, max = MAX_POST) {
|
|
45517
45530
|
const parts = [];
|
|
45518
45531
|
let rest = text2;
|
|
@@ -45538,6 +45551,16 @@ var Bridge = class {
|
|
|
45538
45551
|
choiceButtons;
|
|
45539
45552
|
canTalk;
|
|
45540
45553
|
turnLimit;
|
|
45554
|
+
/** Paperclip's public URL, for agents' relative links. */
|
|
45555
|
+
linkBase = "";
|
|
45556
|
+
/**
|
|
45557
|
+
* Steering: messages people send in a thread while its agent is mid-run, kept for that agent to
|
|
45558
|
+
* read in the same run (`takeInbox`). One it read is not run again afterwards.
|
|
45559
|
+
*/
|
|
45560
|
+
inbox = /* @__PURE__ */ new Map();
|
|
45561
|
+
delivered = /* @__PURE__ */ new Set();
|
|
45562
|
+
/** Told when a person writes to an agent that is busy (the computer plugin wakes a waiting agent). */
|
|
45563
|
+
onSteer = null;
|
|
45541
45564
|
constructor(roster, directory, agents, store, log = () => {
|
|
45542
45565
|
}, files = null, choiceButtons = null, canTalk = null, turnLimit = DEFAULT_TURN_LIMIT) {
|
|
45543
45566
|
this.files = files;
|
|
@@ -45559,6 +45582,7 @@ var Bridge = class {
|
|
|
45559
45582
|
this.seen.add(evt.post.id);
|
|
45560
45583
|
if (this.seen.size > 5e3) this.seen.delete(this.seen.values().next().value);
|
|
45561
45584
|
const rootId = evt.post.root_id || evt.post.id;
|
|
45585
|
+
this.steer(evt, rootId);
|
|
45562
45586
|
const prev = this.queues.get(rootId) ?? Promise.resolve();
|
|
45563
45587
|
const next = prev.then(() => this.process(evt, rootId)).catch((err) => this.log("bridge error", { err: String(err) }));
|
|
45564
45588
|
this.queues.set(rootId, next);
|
|
@@ -45567,6 +45591,36 @@ var Bridge = class {
|
|
|
45567
45591
|
});
|
|
45568
45592
|
return next;
|
|
45569
45593
|
}
|
|
45594
|
+
/** A person writing in a thread whose agent is mid-run: offered to that run as well as queued. */
|
|
45595
|
+
steer(evt, rootId) {
|
|
45596
|
+
const { post } = evt;
|
|
45597
|
+
if (!post.root_id || post.type || post.props?.from_webhook === "true" || post.props?.from_paperclip === "true" || post.props?.from_bot === "true") return;
|
|
45598
|
+
if (this.roster().isBot(post.user_id) || !post.message?.trim()) return;
|
|
45599
|
+
for (const [agentId, at] of this.active) {
|
|
45600
|
+
if (at.rootId !== rootId) continue;
|
|
45601
|
+
const list2 = this.inbox.get(agentId) ?? [];
|
|
45602
|
+
list2.push({ id: post.id, rootId, userId: post.user_id, text: post.message.slice(0, 4e3), at: Date.now() });
|
|
45603
|
+
this.inbox.set(agentId, list2.slice(-20));
|
|
45604
|
+
this.onSteer?.(agentId, post.id);
|
|
45605
|
+
}
|
|
45606
|
+
}
|
|
45607
|
+
/** Whether the agent has unread messages from people in the thread it is answering. */
|
|
45608
|
+
hasInbox(agentId) {
|
|
45609
|
+
return (this.inbox.get(agentId)?.length ?? 0) > 0;
|
|
45610
|
+
}
|
|
45611
|
+
/** What people wrote in the agent's thread since its run started; each is handed over once. */
|
|
45612
|
+
async takeInbox(agentId) {
|
|
45613
|
+
const list2 = this.inbox.get(agentId) ?? [];
|
|
45614
|
+
this.inbox.delete(agentId);
|
|
45615
|
+
const out = [];
|
|
45616
|
+
for (const m of list2) {
|
|
45617
|
+
this.delivered.add(m.id);
|
|
45618
|
+
const who = await this.directory.userName(m.userId).catch(() => ({ username: m.userId, displayName: m.userId }));
|
|
45619
|
+
out.push({ from: `${who.displayName} (@${who.username})`, text: m.text, at: new Date(m.at).toISOString() });
|
|
45620
|
+
}
|
|
45621
|
+
if (this.delivered.size > 2e3) this.delivered.delete(this.delivered.values().next().value);
|
|
45622
|
+
return out;
|
|
45623
|
+
}
|
|
45570
45624
|
/** The thread an agent is answering right now, for posts its run causes (approvals). */
|
|
45571
45625
|
activeThread(agentId) {
|
|
45572
45626
|
return this.active.get(agentId) ?? null;
|
|
@@ -45644,6 +45698,11 @@ var Bridge = class {
|
|
|
45644
45698
|
if (!fromAgent && post.root_id) await this.resetTurns(rootId);
|
|
45645
45699
|
return;
|
|
45646
45700
|
}
|
|
45701
|
+
if (this.delivered.has(post.id)) {
|
|
45702
|
+
await member.chat.react(post.id, "white_check_mark").catch(() => {
|
|
45703
|
+
});
|
|
45704
|
+
return;
|
|
45705
|
+
}
|
|
45647
45706
|
if (!await this.allowAgentTurn(rootId, fromAgent, member, post.channel_id)) return;
|
|
45648
45707
|
const text2 = stripMention(post.message, member.username);
|
|
45649
45708
|
const fileIds = this.files ? post.file_ids ?? [] : [];
|
|
@@ -45680,7 +45739,7 @@ var Bridge = class {
|
|
|
45680
45739
|
const attached = fileIds.length ? await this.files.receive(member.chat, post.id, fileIds) : [];
|
|
45681
45740
|
const outbox = this.files ? await this.files.outbox(post.id).catch(() => null) : null;
|
|
45682
45741
|
const fromAgent = this.roster().isBot(post.user_id);
|
|
45683
|
-
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.` + (history ? `
|
|
45742
|
+
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 ? `
|
|
45684
45743
|
|
|
45685
45744
|
Earlier in this thread (oldest first):
|
|
45686
45745
|
${history}
|
|
@@ -45709,11 +45768,13 @@ To send files back with your reply, save them in ${outbox}` : "");
|
|
|
45709
45768
|
}
|
|
45710
45769
|
} catch (err) {
|
|
45711
45770
|
if (this.active.get(member.agentId) === here) this.active.delete(member.agentId);
|
|
45771
|
+
this.inbox.delete(member.agentId);
|
|
45712
45772
|
await member.chat.createPost(post.channel_id, rootId, `:warning: ${member.agentName} could not answer: ${String(err?.message ?? err).slice(0, 500)}`);
|
|
45713
45773
|
return;
|
|
45714
45774
|
}
|
|
45715
45775
|
if (this.active.get(member.agentId) === here) this.active.delete(member.agentId);
|
|
45716
|
-
|
|
45776
|
+
this.inbox.delete(member.agentId);
|
|
45777
|
+
const { text: body, choices } = extractChoices(absoluteLinks(reply ?? "", this.linkBase));
|
|
45717
45778
|
const origin = { channelId: post.channel_id, rootId, channelType: evt.channelType, channelName: evt.channelName };
|
|
45718
45779
|
const buttons = choices.length && this.choiceButtons ? this.choiceButtons(origin, choices) : null;
|
|
45719
45780
|
const shown = choices.length && !buttons ? `${body}
|
|
@@ -46211,7 +46272,7 @@ var route = (routeKey, method, path3) => ({ routeKey, method, path: path3, auth:
|
|
|
46211
46272
|
var manifest = {
|
|
46212
46273
|
id: "mattermost-bridge",
|
|
46213
46274
|
apiVersion: 1,
|
|
46214
|
-
version: "0.
|
|
46275
|
+
version: "0.16.0",
|
|
46215
46276
|
displayName: "Mattermost",
|
|
46216
46277
|
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.",
|
|
46217
46278
|
author: "Haseeb Azhar",
|
|
@@ -46227,6 +46288,7 @@ var manifest = {
|
|
|
46227
46288
|
"plugin.state.write",
|
|
46228
46289
|
"http.outbound",
|
|
46229
46290
|
"events.subscribe",
|
|
46291
|
+
"events.emit",
|
|
46230
46292
|
"webhooks.receive",
|
|
46231
46293
|
"approvals.read",
|
|
46232
46294
|
"approvals.respond",
|
|
@@ -46247,6 +46309,7 @@ var manifest = {
|
|
|
46247
46309
|
// checks the caller is an agent and scopes it to that agent's company.
|
|
46248
46310
|
apiRoutes: [
|
|
46249
46311
|
route("help", "GET", "/mm/help"),
|
|
46312
|
+
route("inbox", "GET", "/mm/inbox"),
|
|
46250
46313
|
route("channels", "GET", "/mm/channels"),
|
|
46251
46314
|
route("channel-posts", "GET", "/mm/channels/:channel/posts"),
|
|
46252
46315
|
route("thread", "GET", "/mm/posts/:postId/thread"),
|
|
@@ -46488,6 +46551,11 @@ var Tenant = class {
|
|
|
46488
46551
|
onPosted = (evt) => {
|
|
46489
46552
|
void bridge.handle(evt);
|
|
46490
46553
|
};
|
|
46554
|
+
bridge.linkBase = paperclipUrl;
|
|
46555
|
+
bridge.onSteer = (agentId, postId) => {
|
|
46556
|
+
void ctx.events.emit("person-wrote", companyId, { agentId, postId }).catch(() => {
|
|
46557
|
+
});
|
|
46558
|
+
};
|
|
46491
46559
|
const approvers = parseApprovers(config2.approvers);
|
|
46492
46560
|
const approvalsChannel = typeof config2.approvalsChannel === "string" ? config2.approvalsChannel.trim() : "";
|
|
46493
46561
|
const agentName = (agentId) => agentId && roster.roster().byAgentId(agentId)?.agentName || "Paperclip";
|
|
@@ -46604,7 +46672,7 @@ var Tenant = class {
|
|
|
46604
46672
|
if (!this.actions) return { status: 503, body: { error: `Mattermost is not connected for this company (${this.status})` } };
|
|
46605
46673
|
return this.actions.handle(agentId, routeKey, params, input2);
|
|
46606
46674
|
}
|
|
46607
|
-
/** The Agent computer plugin: an agent wants a person
|
|
46675
|
+
/** The Agent computer plugin: an agent wants a person at its computer. Posted where the agent is working. */
|
|
46608
46676
|
async computerNeedsPerson(p) {
|
|
46609
46677
|
if (!this.manager || !this.bots || !this.bridge || !p.agentId) return;
|
|
46610
46678
|
const poster = this.poster(this.manager, this.bots);
|
|
@@ -46612,7 +46680,7 @@ var Tenant = class {
|
|
|
46612
46680
|
if (!where) return;
|
|
46613
46681
|
const link = this.paperclipUrl && p.path ? `${this.paperclipUrl}${p.path}` : null;
|
|
46614
46682
|
const on = p.url ? ` on ${p.url}` : "";
|
|
46615
|
-
const message2 = p.kind === "password" ? `:key: I ${p.reason ?? "need a password"}${on}. ${link ? `[Enter it here](${link})` : "Enter it from Computers in Paperclip"}: it goes straight into the page, and I never see it.` : `:raising_hand: I need a person
|
|
46683
|
+
const message2 = p.kind === "password" ? `:key: I ${p.reason ?? "need a password"}${on}. ${link ? `[Enter it here](${link})` : "Enter it from Computers in Paperclip"}: it goes straight into the page, and I never see it.` : `:raising_hand: I need a person at ${p.computer ?? p.browser ? `the shared ${p.computer ?? p.browser} computer` : "my computer"}${on}: ${p.reason ?? "I am stuck"}. ${link ? `[Take over](${link})` : "Take over from Computers in Paperclip"}, then hand it back; I carry on as soon as you do.`;
|
|
46616
46684
|
await poster.post(p.agentId, where, message2, { ...FROM_PAPERCLIP });
|
|
46617
46685
|
}
|
|
46618
46686
|
async notify(req, notice) {
|
|
@@ -46793,7 +46861,14 @@ var plugin = definePlugin({
|
|
|
46793
46861
|
if (!tenant) return { status: 503, body: { error: "Mattermost is not set up for your company." } };
|
|
46794
46862
|
const query = Object.fromEntries(Object.entries(input2.query).map(([k, v]) => [k, Array.isArray(v) ? v[0] : v]));
|
|
46795
46863
|
const body = input2.body && typeof input2.body === "object" && !Array.isArray(input2.body) ? input2.body : {};
|
|
46864
|
+
if (input2.routeKey === "inbox") {
|
|
46865
|
+
const messages = tenant.bridge ? await tenant.bridge.takeInbox(agentId) : [];
|
|
46866
|
+
return { status: 200, body: { messages, note: messages.length ? "Written to you while you were working. Act on them now: they are not sent to you again." : "Nothing new." } };
|
|
46867
|
+
}
|
|
46796
46868
|
const res = await tenant.api(agentId, input2.routeKey, input2.params, { ...query, ...body });
|
|
46869
|
+
if (tenant.bridge?.hasInbox(agentId) && res.body && typeof res.body === "object" && !Array.isArray(res.body)) {
|
|
46870
|
+
res.body = { ...res.body, newMessages: await tenant.bridge.takeInbox(agentId) };
|
|
46871
|
+
}
|
|
46797
46872
|
return { status: res.status, body: res.body };
|
|
46798
46873
|
},
|
|
46799
46874
|
async onHealth() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paperclip-plugin-mattermost",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
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",
|