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 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.17.0 -m v0.17.0 && git push origin main v0.17.0
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
- - **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,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.17.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
- - **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.
@@ -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 (first2) {
45775
- if (sessionId) this.log("session unusable, starting a new one", { sessionId, err: String(first2) });
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.17.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
- await as.request("POST", "/posts", { channel_id: channelId, root_id: rootId, message: text2, props: FROM_PAPERCLIP });
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) await tenants.get(e.companyId)?.work?.updated(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.17.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",