paperclip-plugin-mattermost 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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.16.1 -m v0.16.1 && git push origin main v0.16.1
88
+ git tag -a v0.18.0 -m v0.18.0 && git push origin main v0.18.0
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
- - **Pick the size of the work.** A quick question: answer in the thread. A task: create a Paperclip
20
- task (assign it to yourself or delegate it) and say so in the thread with its identifier. An
21
- investigation, incident or project, or anything involving several agents or more than a day:
22
- create the task, then open a work channel for it (below), with the person who asked and whoever
23
- you need. Name it for what it is: "inc checkout 5xx", "inv q3 conversion drop", "proj crm migration".
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,13 +84,14 @@ 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.16.1",
87
+ version: "0.18.0",
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",
89
91
  categories: ["connector"],
90
92
  capabilities: [
91
93
  "agents.read",
94
+ "companies.read",
92
95
  "agent.sessions.create",
93
96
  "agent.sessions.send",
94
97
  "agent.sessions.list",
package/dist/worker.js CHANGED
@@ -45192,7 +45192,7 @@ var Actions = class {
45192
45192
  return ok(await this.doPost(bot, ch.id, threadId, text2));
45193
45193
  }
45194
45194
  async doPost(bot, channelId, threadId, text2) {
45195
- const p = await bot.request("POST", "/posts", { channel_id: channelId, root_id: threadId, message: text2 });
45195
+ const p = await bot.request("POST", "/posts", { channel_id: channelId, root_id: threadId, message: this.d.format ? this.d.format(text2) : text2 });
45196
45196
  return { posted: p.id, threadId: p.root_id || p.id };
45197
45197
  }
45198
45198
  async react(bot, input2) {
@@ -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
- - **Pick the size of the work.** A quick question: answer in the thread. A task: create a Paperclip
45447
- task (assign it to yourself or delegate it) and say so in the thread with its identifier. An
45448
- investigation, incident or project, or anything involving several agents or more than a day:
45449
- create the task, then open a work channel for it (below), with the person who asked and whoever
45450
- you need. Name it for what it is: "inc checkout 5xx", "inv q3 conversion drop", "proj crm migration".
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.
@@ -45529,6 +45551,13 @@ function absoluteLinks(text2, base) {
45529
45551
  const root = base.replace(/\/+$/, "");
45530
45552
  return text2.replace(/\]\((\/(?!\/)[^)\s]*)\)/g, (_m, path3) => `](${root}${path3})`);
45531
45553
  }
45554
+ function linkTasks(text2, base, prefix) {
45555
+ if (!base || !/^[A-Z][A-Z0-9]{0,15}$/.test(prefix)) return text2;
45556
+ const root = base.replace(/\/+$/, "");
45557
+ const skip = /(```[\s\S]*?```|`[^`\n]*`|\[[^\]]*\]\([^)]*\)|<[^>\s]+>|https?:\/\/[^\s)]+)/;
45558
+ const id = new RegExp(`(?<![\\w/-])(${prefix}-\\d+)(?![\\w-])`, "g");
45559
+ return text2.split(skip).map((part, i) => i % 2 ? part : part.replace(id, (m) => `[${m}](${root}/${prefix}/issues/${m})`)).join("");
45560
+ }
45532
45561
  function splitMessage(text2, max = MAX_POST) {
45533
45562
  const parts = [];
45534
45563
  let rest = text2;
@@ -45554,8 +45583,9 @@ var Bridge = class {
45554
45583
  choiceButtons;
45555
45584
  canTalk;
45556
45585
  turnLimit;
45557
- /** Paperclip's public URL, for agents' relative links. */
45586
+ /** Paperclip's public URL, for agents' relative links, and the company's task prefix (BAD). */
45558
45587
  linkBase = "";
45588
+ taskPrefix = "";
45559
45589
  /**
45560
45590
  * Steering: messages people send in a thread while its agent is mid-run, kept for that agent to
45561
45591
  * read in the same run (`takeInbox`). One it read is not run again afterwards.
@@ -45742,7 +45772,7 @@ var Bridge = class {
45742
45772
  const attached = fileIds.length ? await this.files.receive(member.chat, post.id, fileIds) : [];
45743
45773
  const outbox = this.files ? await this.files.outbox(post.id).catch(() => null) : null;
45744
45774
  const fromAgent = this.roster().isBot(post.user_id);
45745
- 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 ? `
45775
+ 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 ? `
45746
45776
 
45747
45777
  Earlier in this thread (oldest first):
45748
45778
  ${history}
@@ -45777,7 +45807,7 @@ To send files back with your reply, save them in ${outbox}` : "");
45777
45807
  }
45778
45808
  if (this.active.get(member.agentId) === here) this.active.delete(member.agentId);
45779
45809
  this.inbox.delete(member.agentId);
45780
- const { text: body, choices } = extractChoices(absoluteLinks(reply ?? "", this.linkBase));
45810
+ const { text: body, choices } = extractChoices(linkTasks(absoluteLinks(reply ?? "", this.linkBase), this.linkBase, this.taskPrefix));
45781
45811
  const origin = { channelId: post.channel_id, rootId, channelType: evt.channelType, channelName: evt.channelName };
45782
45812
  const buttons = choices.length && this.choiceButtons ? this.choiceButtons(origin, choices) : null;
45783
45813
  const shown = choices.length && !buttons ? `${body}
@@ -46275,13 +46305,14 @@ var route = (routeKey, method, path3) => ({ routeKey, method, path: path3, auth:
46275
46305
  var manifest = {
46276
46306
  id: "mattermost-bridge",
46277
46307
  apiVersion: 1,
46278
- version: "0.16.1",
46308
+ version: "0.18.0",
46279
46309
  displayName: "Mattermost",
46280
46310
  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.",
46281
46311
  author: "Haseeb Azhar",
46282
46312
  categories: ["connector"],
46283
46313
  capabilities: [
46284
46314
  "agents.read",
46315
+ "companies.read",
46285
46316
  "agent.sessions.create",
46286
46317
  "agent.sessions.send",
46287
46318
  "agent.sessions.list",
@@ -46555,6 +46586,8 @@ var Tenant = class {
46555
46586
  void bridge.handle(evt);
46556
46587
  };
46557
46588
  bridge.linkBase = paperclipUrl;
46589
+ const company = await ctx.companies.get(companyId).catch(() => null);
46590
+ bridge.taskPrefix = company?.issuePrefix ?? "";
46558
46591
  bridge.onSteer = (agentId, postId) => {
46559
46592
  void ctx.events.emit("person-wrote", companyId, { agentId, postId }).catch(() => {
46560
46593
  });
@@ -46601,6 +46634,7 @@ var Tenant = class {
46601
46634
  agents: () => roster.all().map((m) => ({ agentId: m.agentId, agentName: m.agentName, username: m.username, botUserId: m.botUserId })),
46602
46635
  canTalk: async (userId, agentId) => !canTalk || canTalk(userId, agentId),
46603
46636
  origin: (agentId) => bridge.activeThread(agentId),
46637
+ format: (text2) => linkTasks(absoluteLinks(text2, paperclipUrl), paperclipUrl, bridge.taskPrefix),
46604
46638
  // The task this run works on (its execution or checkout run), and the thread it was asked for in.
46605
46639
  taskThread: async (agentId, runId, channelId) => {
46606
46640
  if (!runId) return null;
@@ -46625,7 +46659,8 @@ var Tenant = class {
46625
46659
  store,
46626
46660
  post: async (agentId, channelId, rootId, text2) => {
46627
46661
  const as = roster.clientOf(agentId) ?? manager;
46628
- await as.request("POST", "/posts", { channel_id: channelId, root_id: rootId, message: text2, props: FROM_PAPERCLIP });
46662
+ const message2 = linkTasks(absoluteLinks(text2, paperclipUrl), paperclipUrl, bridge.taskPrefix);
46663
+ await as.request("POST", "/posts", { channel_id: channelId, root_id: rootId, message: message2, props: FROM_PAPERCLIP });
46629
46664
  },
46630
46665
  hasBot: (agentId) => Boolean(roster.clientOf(agentId)),
46631
46666
  agentName: (agentId) => agentName(agentId),
@@ -46832,7 +46867,10 @@ var plugin = definePlugin({
46832
46867
  if (e.entityId && commentId) await tenants.get(e.companyId)?.work?.comment(e.entityId, commentId);
46833
46868
  }));
46834
46869
  ctx.events.on("issue.updated", (e) => quietly("could not follow a task update", async () => {
46835
- if (e.entityId) await tenants.get(e.companyId)?.work?.updated(e.entityId);
46870
+ if (!e.entityId) return;
46871
+ const work = tenants.get(e.companyId)?.work;
46872
+ await work?.updated(e.entityId);
46873
+ await work?.threadUpdate(e.entityId);
46836
46874
  }));
46837
46875
  ctx.events.on("plugin.agent-computer.needs-person", (e) => quietly("could not post a computer help request", async () => {
46838
46876
  await tenants.get(e.companyId)?.computerNeedsPerson(e.payload ?? {});
@@ -46843,7 +46881,7 @@ var plugin = definePlugin({
46843
46881
  const issue2 = await ctx.issues.get(e.entityId, e.companyId);
46844
46882
  const by = issue2?.createdByAgentId;
46845
46883
  const at = by ? tenant.bridge.activeThread(by) : null;
46846
- if (at) await tenant.store().set(`issuethread:${e.entityId}`, at);
46884
+ if (at) await tenant.store().set(`issuethread:${e.entityId}`, { ...at, agentId: by, status: issue2?.status });
46847
46885
  }));
46848
46886
  ctx.events.on("approval.decided", (e) => quietly("could not update approval", async () => {
46849
46887
  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.16.1",
3
+ "version": "0.18.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",