paperclip-plugin-mattermost 0.11.2 → 0.12.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 +1 -2
- package/dist/manifest.js +1 -1
- package/dist/worker.js +70 -96
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Your [Paperclip](https://github.com/paperclipai/paperclip) agents as Mattermost
|
|
|
5
5
|
## What it does
|
|
6
6
|
|
|
7
7
|
- **A bot per agent.** Bots are created, renamed and switched off as agents change (every 5 minutes). Choose all agents, or only the ones you pick.
|
|
8
|
-
- **You see it working.** The agent's bot reacts 👀 to your message straight away
|
|
8
|
+
- **You see it working.** The agent's bot reacts 👀 to your message straight away, and shows Mattermost's "is typing…" indicator until its answer arrives.
|
|
9
9
|
- **Files both ways.** Attach files to a message and the agent can read them. Files the agent saves are attached to its reply.
|
|
10
10
|
- **Choices as buttons.** An agent can offer options (`choices: A | B | C` on its last line), which appear as buttons. A click is sent back as your answer.
|
|
11
11
|
- **Approvals with buttons.** Every Paperclip approval is posted in an approvals channel with Approve / Reject buttons, including hires, budget overrides, board approvals and high-risk tool calls. An approval raised during a conversation also appears in that thread.
|
|
@@ -60,7 +60,6 @@ Or install it from **Settings → Plugins** in Paperclip.
|
|
|
60
60
|
- **Multi-choice questions** and free-text "Other" answers are answered in Paperclip; the card links there.
|
|
61
61
|
- **Mattermost can't hide bots** from particular people. Someone without access can still DM an agent bot; they get a private note and the agent doesn't run.
|
|
62
62
|
- **Agents with a custom uploaded portrait** keep the default bot picture, because those images need a Paperclip login to download.
|
|
63
|
-
- **Live progress** shows steps for agents run through ACPX (Claude Code, and others Paperclip runs that way). Other adapters show only _thinking…_.
|
|
64
63
|
|
|
65
64
|
## Development
|
|
66
65
|
|
package/dist/manifest.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
var manifest = {
|
|
3
3
|
id: "mattermost-bridge",
|
|
4
4
|
apiVersion: 1,
|
|
5
|
-
version: "0.
|
|
5
|
+
version: "0.12.0",
|
|
6
6
|
displayName: "Mattermost",
|
|
7
7
|
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.",
|
|
8
8
|
author: "Haseeb Azhar",
|
package/dist/worker.js
CHANGED
|
@@ -44992,57 +44992,9 @@ function chosenProps(choice, who) {
|
|
|
44992
44992
|
return { attachments: [{ text: `${who} chose **${choice}**`, actions: [] }] };
|
|
44993
44993
|
}
|
|
44994
44994
|
|
|
44995
|
-
// src/progress.ts
|
|
44996
|
-
var THINKING = "_thinking\u2026_";
|
|
44997
|
-
var MAX_STEPS = 6;
|
|
44998
|
-
var MAX_LABEL2 = 100;
|
|
44999
|
-
var Progress = class {
|
|
45000
|
-
buffer = "";
|
|
45001
|
-
steps = /* @__PURE__ */ new Map();
|
|
45002
|
-
/** Feeds one stdout chunk. Returns true when what `render` shows changed. */
|
|
45003
|
-
feed(chunk) {
|
|
45004
|
-
this.buffer += chunk;
|
|
45005
|
-
const lines = this.buffer.split("\n");
|
|
45006
|
-
this.buffer = lines.pop() ?? "";
|
|
45007
|
-
let changed = false;
|
|
45008
|
-
for (const line of lines) {
|
|
45009
|
-
if (!line.startsWith("{")) continue;
|
|
45010
|
-
let e;
|
|
45011
|
-
try {
|
|
45012
|
-
e = JSON.parse(line);
|
|
45013
|
-
} catch {
|
|
45014
|
-
continue;
|
|
45015
|
-
}
|
|
45016
|
-
if (e.type !== "acpx.tool_call" || !e.toolCallId) continue;
|
|
45017
|
-
const prev = this.steps.get(e.toolCallId);
|
|
45018
|
-
const tool3 = prev?.tool ?? e.name ?? "tool";
|
|
45019
|
-
const label2 = e.name && e.name !== tool3 ? e.name : prev?.label ?? tool3;
|
|
45020
|
-
const status = e.status ?? prev?.status ?? "pending";
|
|
45021
|
-
if (prev?.label !== label2 || prev?.status !== status) {
|
|
45022
|
-
this.steps.set(e.toolCallId, { tool: tool3, label: label2, status });
|
|
45023
|
-
changed = true;
|
|
45024
|
-
}
|
|
45025
|
-
}
|
|
45026
|
-
return changed;
|
|
45027
|
-
}
|
|
45028
|
-
render() {
|
|
45029
|
-
const head = THINKING;
|
|
45030
|
-
const all = [...this.steps.values()];
|
|
45031
|
-
if (!all.length) return head;
|
|
45032
|
-
const shown = all.slice(-MAX_STEPS);
|
|
45033
|
-
const lines = shown.map((s) => {
|
|
45034
|
-
const mark = s.status === "completed" ? "\u2713" : s.status === "failed" ? "\u2717" : "\u2026";
|
|
45035
|
-
const label2 = s.label.replace(/\s+/g, " ").trim();
|
|
45036
|
-
return `- ${mark} \`${(label2.length > MAX_LABEL2 ? label2.slice(0, MAX_LABEL2) + "\u2026" : label2).replace(/`/g, "'")}\``;
|
|
45037
|
-
});
|
|
45038
|
-
if (all.length > shown.length) lines.unshift(`- _${all.length - shown.length} earlier steps_`);
|
|
45039
|
-
return `${head}
|
|
45040
|
-
${lines.join("\n")}`;
|
|
45041
|
-
}
|
|
45042
|
-
};
|
|
45043
|
-
|
|
45044
44995
|
// src/bridge.ts
|
|
45045
|
-
var
|
|
44996
|
+
var TYPING_EVERY_MS = 3e3;
|
|
44997
|
+
var OLD_PLACEHOLDER = /^_(thinking…|.+ is working on it…)_/;
|
|
45046
44998
|
var MAX_POST = 15e3;
|
|
45047
44999
|
var MAX_HISTORY = 8e3;
|
|
45048
45000
|
var MENTION = /(?:^|[^\w@])@([a-z0-9][a-z0-9._-]*)/gi;
|
|
@@ -45128,7 +45080,7 @@ var Bridge = class {
|
|
|
45128
45080
|
const roster = this.roster();
|
|
45129
45081
|
const lines = [];
|
|
45130
45082
|
for (const p of await this.directory.threadPosts(rootId)) {
|
|
45131
|
-
if (p.id === currentId || p.type || !p.message.trim() || p.message
|
|
45083
|
+
if (p.id === currentId || p.type || !p.message.trim() || OLD_PLACEHOLDER.test(p.message)) continue;
|
|
45132
45084
|
const who = p.user_id === me.botUserId ? "You" : `@${roster.byBotId(p.user_id)?.username ?? (await this.directory.userName(p.user_id)).username}`;
|
|
45133
45085
|
lines.push(`${who}: ${p.message}`);
|
|
45134
45086
|
}
|
|
@@ -45159,10 +45111,23 @@ var Bridge = class {
|
|
|
45159
45111
|
const sender = await this.directory.userName(post.user_id);
|
|
45160
45112
|
const where = isDirect ? "a direct message" : `#${evt.channelName}`;
|
|
45161
45113
|
const history = post.root_id ? await this.history(post.root_id, post.id, member) : "";
|
|
45162
|
-
const
|
|
45163
|
-
|
|
45164
|
-
|
|
45165
|
-
|
|
45114
|
+
const typing = () => {
|
|
45115
|
+
void member.chat.typing(post.channel_id, rootId).catch(() => {
|
|
45116
|
+
});
|
|
45117
|
+
void member.chat.typing(post.channel_id, "").catch(() => {
|
|
45118
|
+
});
|
|
45119
|
+
};
|
|
45120
|
+
typing();
|
|
45121
|
+
const typingTimer = setInterval(typing, TYPING_EVERY_MS);
|
|
45122
|
+
try {
|
|
45123
|
+
await this.answer(evt, member, rootId, threadKey, thread, { sender, where, history, text: text2, fileIds });
|
|
45124
|
+
} finally {
|
|
45125
|
+
clearInterval(typingTimer);
|
|
45126
|
+
}
|
|
45127
|
+
}
|
|
45128
|
+
async answer(evt, member, rootId, threadKey, thread, m) {
|
|
45129
|
+
const { post } = evt;
|
|
45130
|
+
const { sender, where, history, text: text2, fileIds } = m;
|
|
45166
45131
|
const attached = fileIds.length ? await this.files.receive(member.chat, post.id, fileIds) : [];
|
|
45167
45132
|
const outbox = this.files ? await this.files.outbox(post.id).catch(() => null) : null;
|
|
45168
45133
|
const prompt = `${sender.displayName} (@${sender.username}) 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.` + (history ? `
|
|
@@ -45178,23 +45143,6 @@ Attached files, saved on disk (read them at these paths):
|
|
|
45178
45143
|
${attached.join("\n")}` : "") + (outbox ? `
|
|
45179
45144
|
|
|
45180
45145
|
To send files back with your reply, save them in ${outbox}` : "");
|
|
45181
|
-
let timer = null;
|
|
45182
|
-
let edit = Promise.resolve();
|
|
45183
|
-
let finished = false;
|
|
45184
|
-
const onOutput = (chunk) => {
|
|
45185
|
-
if (finished || !progress.feed(chunk) || timer) return;
|
|
45186
|
-
timer = setTimeout(() => {
|
|
45187
|
-
timer = null;
|
|
45188
|
-
if (finished) return;
|
|
45189
|
-
edit = edit.then(() => member.chat.patchPost(placeholder.id, progress.render())).catch(() => {
|
|
45190
|
-
});
|
|
45191
|
-
}, PROGRESS_EVERY_MS);
|
|
45192
|
-
};
|
|
45193
|
-
const settle2 = async () => {
|
|
45194
|
-
finished = true;
|
|
45195
|
-
if (timer) clearTimeout(timer);
|
|
45196
|
-
await edit;
|
|
45197
|
-
};
|
|
45198
45146
|
let reply;
|
|
45199
45147
|
const here = { channelId: post.channel_id, rootId };
|
|
45200
45148
|
this.active.set(member.agentId, here);
|
|
@@ -45202,21 +45150,19 @@ To send files back with your reply, save them in ${outbox}` : "");
|
|
|
45202
45150
|
let sessionId = thread && thread.agentId === member.agentId ? thread.sessionId : null;
|
|
45203
45151
|
try {
|
|
45204
45152
|
if (!sessionId) throw new Error("no session yet");
|
|
45205
|
-
reply = await this.agents.ask(sessionId, prompt
|
|
45153
|
+
reply = await this.agents.ask(sessionId, prompt);
|
|
45206
45154
|
} catch (first2) {
|
|
45207
45155
|
if (sessionId) this.log("session unusable, starting a new one", { sessionId, err: String(first2) });
|
|
45208
45156
|
sessionId = await this.agents.createSession(member.agentId, `mattermost:${rootId}`);
|
|
45209
45157
|
await this.store.set(threadKey, { agentId: member.agentId, sessionId });
|
|
45210
|
-
reply = await this.agents.ask(sessionId, prompt
|
|
45158
|
+
reply = await this.agents.ask(sessionId, prompt);
|
|
45211
45159
|
}
|
|
45212
45160
|
} catch (err) {
|
|
45213
45161
|
if (this.active.get(member.agentId) === here) this.active.delete(member.agentId);
|
|
45214
|
-
await
|
|
45215
|
-
await member.chat.patchPost(placeholder.id, `:warning: ${member.agentName} could not answer: ${String(err?.message ?? err).slice(0, 500)}`);
|
|
45162
|
+
await member.chat.createPost(post.channel_id, rootId, `:warning: ${member.agentName} could not answer: ${String(err?.message ?? err).slice(0, 500)}`);
|
|
45216
45163
|
return;
|
|
45217
45164
|
}
|
|
45218
45165
|
if (this.active.get(member.agentId) === here) this.active.delete(member.agentId);
|
|
45219
|
-
await settle2();
|
|
45220
45166
|
const { text: body, choices } = extractChoices(reply ?? "");
|
|
45221
45167
|
const origin = { channelId: post.channel_id, rootId, channelType: evt.channelType, channelName: evt.channelName };
|
|
45222
45168
|
const buttons = choices.length && this.choiceButtons ? this.choiceButtons(origin, choices) : null;
|
|
@@ -45224,7 +45170,7 @@ To send files back with your reply, save them in ${outbox}` : "");
|
|
|
45224
45170
|
|
|
45225
45171
|
${choices.map((c, i) => `${i + 1}. ${c}`).join("\n")}` : body;
|
|
45226
45172
|
const [first, ...more] = splitMessage(shown.trim() || `_${member.agentName} finished without a reply._`);
|
|
45227
|
-
await member.chat.
|
|
45173
|
+
await member.chat.createPost(post.channel_id, rootId, first);
|
|
45228
45174
|
for (const part of more) await member.chat.createPost(post.channel_id, rootId, part);
|
|
45229
45175
|
if (buttons) await member.chat.createPost(post.channel_id, rootId, "", buttons);
|
|
45230
45176
|
if (outbox) {
|
|
@@ -45281,7 +45227,7 @@ var MattermostClient = class {
|
|
|
45281
45227
|
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
45282
45228
|
signal: AbortSignal.timeout(3e4)
|
|
45283
45229
|
});
|
|
45284
|
-
if (!r.ok) throw new Error(`Mattermost ${method} ${path3}: ${r.status} ${(await r.text()).slice(0, 200)}`);
|
|
45230
|
+
if (!r.ok) throw Object.assign(new Error(`Mattermost ${method} ${path3}: ${r.status} ${(await r.text()).slice(0, 200)}`), { status: r.status });
|
|
45285
45231
|
return r.status === 204 ? null : await r.json();
|
|
45286
45232
|
}
|
|
45287
45233
|
async init() {
|
|
@@ -45316,9 +45262,6 @@ var MattermostClient = class {
|
|
|
45316
45262
|
async react(postId, emoji3) {
|
|
45317
45263
|
await this.request("POST", "/reactions", { user_id: this.botUserId, post_id: postId, emoji_name: emoji3 });
|
|
45318
45264
|
}
|
|
45319
|
-
async patchPost(postId, message2) {
|
|
45320
|
-
await this.request("PUT", `/posts/${postId}/patch`, { message: message2 });
|
|
45321
|
-
}
|
|
45322
45265
|
async typing(channelId, rootId) {
|
|
45323
45266
|
await this.request("POST", "/users/me/typing", { channel_id: channelId, parent_id: rootId });
|
|
45324
45267
|
}
|
|
@@ -45541,18 +45484,19 @@ async function verifyUrl(url2, pluginKey) {
|
|
|
45541
45484
|
}
|
|
45542
45485
|
|
|
45543
45486
|
// src/roster.ts
|
|
45487
|
+
var notFound = (err) => err?.status === 404;
|
|
45544
45488
|
var TOKEN_DESCRIPTION = "paperclip-plugin";
|
|
45545
45489
|
var AgentBots = class {
|
|
45546
45490
|
members = /* @__PURE__ */ new Map();
|
|
45547
45491
|
manager;
|
|
45548
|
-
|
|
45492
|
+
newClient;
|
|
45549
45493
|
listAgents;
|
|
45550
45494
|
store;
|
|
45551
45495
|
log;
|
|
45552
45496
|
onPosted;
|
|
45553
|
-
constructor(manager, url2, listAgents, store, log, onPosted) {
|
|
45497
|
+
constructor(manager, url2, listAgents, store, log, onPosted, newClient) {
|
|
45554
45498
|
this.manager = manager;
|
|
45555
|
-
this.
|
|
45499
|
+
this.newClient = newClient ?? ((token) => new MattermostClient(url2, token, log));
|
|
45556
45500
|
this.listAgents = listAgents;
|
|
45557
45501
|
this.store = store;
|
|
45558
45502
|
this.log = log;
|
|
@@ -45578,13 +45522,33 @@ var AgentBots = class {
|
|
|
45578
45522
|
get size() {
|
|
45579
45523
|
return this.members.size;
|
|
45580
45524
|
}
|
|
45525
|
+
/**
|
|
45526
|
+
* The bot, or null only when Mattermost says it does not exist. Any other failure (the
|
|
45527
|
+
* server restarting, a timeout) throws, so the agent is skipped this round rather than
|
|
45528
|
+
* given a second bot: that is how Carma's Operations Agent became @operations-agent-agent
|
|
45529
|
+
* while @operations-agent, the one people mention, was left with nobody behind it.
|
|
45530
|
+
*/
|
|
45581
45531
|
async bot(id) {
|
|
45582
45532
|
try {
|
|
45583
45533
|
return await this.manager.request("GET", `/bots/${id}?include_deleted=true`);
|
|
45584
|
-
} catch {
|
|
45585
|
-
return null;
|
|
45534
|
+
} catch (err) {
|
|
45535
|
+
if (notFound(err)) return null;
|
|
45536
|
+
throw err;
|
|
45586
45537
|
}
|
|
45587
45538
|
}
|
|
45539
|
+
/** The bot at an agent's own username, if it is one of ours and no other agent uses it. */
|
|
45540
|
+
async reclaimable(agent, map2) {
|
|
45541
|
+
let user2;
|
|
45542
|
+
try {
|
|
45543
|
+
user2 = await this.manager.request("GET", `/users/username/${botUsername(agent.name)}`);
|
|
45544
|
+
} catch (err) {
|
|
45545
|
+
if (notFound(err)) return null;
|
|
45546
|
+
throw err;
|
|
45547
|
+
}
|
|
45548
|
+
if (!user2.is_bot || Object.entries(map2).some(([other, id]) => other !== agent.id && id === user2.id)) return null;
|
|
45549
|
+
const bot = await this.bot(user2.id);
|
|
45550
|
+
return bot && bot.owner_id === this.manager.botUserId ? bot : null;
|
|
45551
|
+
}
|
|
45588
45552
|
async createBot(agent) {
|
|
45589
45553
|
const base = botUsername(agent.name);
|
|
45590
45554
|
let last;
|
|
@@ -45614,9 +45578,19 @@ var AgentBots = class {
|
|
|
45614
45578
|
try {
|
|
45615
45579
|
let bot = map2[agent.id] ? await this.bot(map2[agent.id]) : null;
|
|
45616
45580
|
if (!bot) {
|
|
45617
|
-
|
|
45618
|
-
|
|
45619
|
-
|
|
45581
|
+
const mine = await this.reclaimable(agent, map2);
|
|
45582
|
+
bot = mine ?? await this.createBot(agent);
|
|
45583
|
+
this.log(mine ? "reused the agent's existing bot" : "created agent bot", { agent: agent.name, username: bot.username });
|
|
45584
|
+
} else if (bot.username !== botUsername(agent.name)) {
|
|
45585
|
+
const original = await this.reclaimable(agent, map2);
|
|
45586
|
+
if (original && original.user_id !== bot.user_id) {
|
|
45587
|
+
await this.manager.request("POST", `/bots/${bot.user_id}/disable`).catch(() => {
|
|
45588
|
+
});
|
|
45589
|
+
this.log("moved agent back to its own bot", { agent: agent.name, from: bot.username, to: original.username });
|
|
45590
|
+
bot = original;
|
|
45591
|
+
}
|
|
45592
|
+
}
|
|
45593
|
+
{
|
|
45620
45594
|
if (bot.delete_at > 0) await this.manager.request("POST", `/bots/${bot.user_id}/enable`);
|
|
45621
45595
|
if (bot.display_name !== agent.name) {
|
|
45622
45596
|
bot = await this.manager.request("PUT", `/bots/${bot.user_id}`, { username: bot.username, display_name: agent.name, description: agent.title || bot.description });
|
|
@@ -45644,7 +45618,7 @@ var AgentBots = class {
|
|
|
45644
45618
|
continue;
|
|
45645
45619
|
}
|
|
45646
45620
|
current?.client.close();
|
|
45647
|
-
const client =
|
|
45621
|
+
const client = this.newClient(await this.mintToken(bot.user_id));
|
|
45648
45622
|
await client.init();
|
|
45649
45623
|
client.connect(this.onPosted);
|
|
45650
45624
|
this.members.set(agent.id, { agentId: agent.id, agentName: agent.name, botUserId: bot.user_id, username: bot.username, chat: client, client });
|
|
@@ -45683,7 +45657,7 @@ var AgentBots = class {
|
|
|
45683
45657
|
var manifest = {
|
|
45684
45658
|
id: "mattermost-bridge",
|
|
45685
45659
|
apiVersion: 1,
|
|
45686
|
-
version: "0.
|
|
45660
|
+
version: "0.12.0",
|
|
45687
45661
|
displayName: "Mattermost",
|
|
45688
45662
|
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.",
|
|
45689
45663
|
author: "Haseeb Azhar",
|
|
@@ -45811,14 +45785,13 @@ var Tenant = class {
|
|
|
45811
45785
|
const key = `plugin:${PLUGIN_KEY}:session:${taskKey}`;
|
|
45812
45786
|
return (await ctx.agents.sessions.create(agentId, companyId, { taskKey: key, reason: "Mattermost thread" })).sessionId;
|
|
45813
45787
|
},
|
|
45814
|
-
ask(sessionId, prompt
|
|
45788
|
+
ask(sessionId, prompt) {
|
|
45815
45789
|
return new Promise((resolve, reject) => {
|
|
45816
45790
|
const timer = setTimeout(() => reject(new Error("no reply after 30 minutes")), ASK_TIMEOUT_MS);
|
|
45817
45791
|
ctx.agents.sessions.sendMessage(sessionId, companyId, {
|
|
45818
45792
|
prompt,
|
|
45819
45793
|
reason: "Mattermost message",
|
|
45820
45794
|
onEvent: (e) => {
|
|
45821
|
-
if (e.eventType === "chunk" && e.stream === "stdout" && e.message) onOutput?.(e.message);
|
|
45822
45795
|
if (e.eventType === "done") {
|
|
45823
45796
|
clearTimeout(timer);
|
|
45824
45797
|
resolve(e.message);
|
|
@@ -46002,11 +45975,12 @@ var Tenant = class {
|
|
|
46002
45975
|
const who = req.user_name ? `@${req.user_name}` : "Someone";
|
|
46003
45976
|
await this.manager.request("PUT", `/posts/${req.post_id}/patch`, { props: chosenProps(choice.choice, who) }).catch(() => {
|
|
46004
45977
|
});
|
|
46005
|
-
|
|
45978
|
+
const bridge = this.bridge;
|
|
45979
|
+
detached(() => void bridge.handle({
|
|
46006
45980
|
post: { id: `choice:${req.post_id}`, user_id: req.user_id, channel_id: choice.channelId, root_id: choice.rootId, message: choice.choice },
|
|
46007
45981
|
channelType: choice.channelType,
|
|
46008
45982
|
channelName: choice.channelName
|
|
46009
|
-
});
|
|
45983
|
+
}));
|
|
46010
45984
|
return;
|
|
46011
45985
|
}
|
|
46012
45986
|
if (this.approvals) await this.notify(req, await this.approvals.click(req).catch(failed));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paperclip-plugin-mattermost",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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",
|