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 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 in its browser (a login, 2FA, a password) posts in the thread it is working in, or the approvals channel, with a link to take over.
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.15.1 -m v0.15.1 && git push origin main v0.15.1
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.15.1",
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
- const { text: body, choices } = extractChoices(reply ?? "");
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.15.1",
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 in its browser. Posted where the agent is working. */
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 in ${p.browser ? `the shared ${p.browser} browser` : "my browser"}${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.`;
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.15.1",
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",