paperclip-plugin-mattermost 0.15.2 → 0.16.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
+ - **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.
@@ -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.2 -m v0.15.2 && git push origin main v0.15.2
87
+ git tag -a v0.16.1 -m v0.16.1 && git push origin main v0.16.1
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.2",
85
+ version: "0.16.1",
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
@@ -45032,7 +45032,9 @@ var Actions = class {
45032
45032
  this.d = deps;
45033
45033
  }
45034
45034
  /** One route call. `params` are the route's path parameters, `input` its query or body. */
45035
- async handle(agentId, routeKey, params, input2) {
45035
+ runId = null;
45036
+ async handle(agentId, routeKey, params, input2, runId = null) {
45037
+ this.runId = runId;
45036
45038
  const bot = this.d.bot(agentId);
45037
45039
  if (!bot) return bad(409, "You have no Mattermost bot in this company yet (your agent may be switched off in the plugin's settings).");
45038
45040
  try {
@@ -45184,7 +45186,7 @@ var Actions = class {
45184
45186
  const text2 = str3(input2.text);
45185
45187
  if (!text2) return bad(400, "text is empty");
45186
45188
  const ch = await this.channel(bot, str3(input2.channel));
45187
- const threadId = str3(input2.threadId);
45189
+ const threadId = str3(input2.threadId) || await this.d.taskThread?.(agentId, this.runId, ch.id).catch(() => null) || "";
45188
45190
  const waiting = await this.gate(agentId, canPost(text2), "post", `post in ~${ch.name}: "${text2.slice(0, 200)}"`, { channelId: ch.id, threadId, text: text2 });
45189
45191
  if (waiting) return waiting;
45190
45192
  return ok(await this.doPost(bot, ch.id, threadId, text2));
@@ -45207,7 +45209,8 @@ var Actions = class {
45207
45209
  const verdict = canDm(await this.d.canTalk(user2.id, agentId));
45208
45210
  if (!verdict.ok) return bad(403, `Not allowed: ${verdict.why}.`);
45209
45211
  const channel2 = await bot.request("POST", "/channels/direct", [bot.botUserId, user2.id]);
45210
- return ok(await this.doPost(bot, channel2.id, "", text2));
45212
+ const threadId = str3(input2.threadId) || await this.d.taskThread?.(agentId, this.runId, channel2.id).catch(() => null) || "";
45213
+ return ok(await this.doPost(bot, channel2.id, threadId, text2));
45211
45214
  }
45212
45215
  // ---- channels ----------------------------------------------------------------------
45213
45216
  async created() {
@@ -45456,6 +45459,13 @@ tell whoever you are working with and carry on. A 403 says why something is not
45456
45459
  - Do not write @channel, @all or @here unless everyone in the channel must be notified: it needs
45457
45460
  approval.
45458
45461
 
45462
+ ## While you work
45463
+
45464
+ If people write in the thread you are answering while you work (a correction, "stop", "never mind"),
45465
+ it reaches you: GET /mm/inbox returns those messages, and any other /mm call you make carries them
45466
+ as \`newMessages\`. Check /mm/inbox between steps of anything that takes more than a minute or two,
45467
+ and act on what you find then. Each message is handed to you once; it is not sent again afterwards.
45468
+
45459
45469
  ## Calls
45460
45470
 
45461
45471
  Reading (GET):
@@ -45466,6 +45476,7 @@ Reading (GET):
45466
45476
  - /mm/search?q=words search messages in your channels
45467
45477
  - /mm/people?q=name find people and agents by name
45468
45478
  - /mm/requests/{id} an approval request you made
45479
+ - /mm/inbox what people wrote in your thread while you were working
45469
45480
 
45470
45481
  Talking (POST, JSON body):
45471
45482
  - /mm/post {"channel": "...", "text": "...", "threadId": "optional post id to reply to"}
@@ -45513,6 +45524,11 @@ function mentions(text2) {
45513
45524
  function stripMention(text2, username) {
45514
45525
  return text2.replace(new RegExp(`(^|[^\\w@])@${username.replace(/[.-]/g, "\\$&")}\\b[:,]?`, "gi"), "$1").replace(/\s{2,}/g, " ").trim();
45515
45526
  }
45527
+ function absoluteLinks(text2, base) {
45528
+ if (!base) return text2;
45529
+ const root = base.replace(/\/+$/, "");
45530
+ return text2.replace(/\]\((\/(?!\/)[^)\s]*)\)/g, (_m, path3) => `](${root}${path3})`);
45531
+ }
45516
45532
  function splitMessage(text2, max = MAX_POST) {
45517
45533
  const parts = [];
45518
45534
  let rest = text2;
@@ -45538,6 +45554,16 @@ var Bridge = class {
45538
45554
  choiceButtons;
45539
45555
  canTalk;
45540
45556
  turnLimit;
45557
+ /** Paperclip's public URL, for agents' relative links. */
45558
+ linkBase = "";
45559
+ /**
45560
+ * Steering: messages people send in a thread while its agent is mid-run, kept for that agent to
45561
+ * read in the same run (`takeInbox`). One it read is not run again afterwards.
45562
+ */
45563
+ inbox = /* @__PURE__ */ new Map();
45564
+ delivered = /* @__PURE__ */ new Set();
45565
+ /** Told when a person writes to an agent that is busy (the computer plugin wakes a waiting agent). */
45566
+ onSteer = null;
45541
45567
  constructor(roster, directory, agents, store, log = () => {
45542
45568
  }, files = null, choiceButtons = null, canTalk = null, turnLimit = DEFAULT_TURN_LIMIT) {
45543
45569
  this.files = files;
@@ -45559,6 +45585,7 @@ var Bridge = class {
45559
45585
  this.seen.add(evt.post.id);
45560
45586
  if (this.seen.size > 5e3) this.seen.delete(this.seen.values().next().value);
45561
45587
  const rootId = evt.post.root_id || evt.post.id;
45588
+ this.steer(evt, rootId);
45562
45589
  const prev = this.queues.get(rootId) ?? Promise.resolve();
45563
45590
  const next = prev.then(() => this.process(evt, rootId)).catch((err) => this.log("bridge error", { err: String(err) }));
45564
45591
  this.queues.set(rootId, next);
@@ -45567,6 +45594,36 @@ var Bridge = class {
45567
45594
  });
45568
45595
  return next;
45569
45596
  }
45597
+ /** A person writing in a thread whose agent is mid-run: offered to that run as well as queued. */
45598
+ steer(evt, rootId) {
45599
+ const { post } = evt;
45600
+ if (!post.root_id || post.type || post.props?.from_webhook === "true" || post.props?.from_paperclip === "true" || post.props?.from_bot === "true") return;
45601
+ if (this.roster().isBot(post.user_id) || !post.message?.trim()) return;
45602
+ for (const [agentId, at] of this.active) {
45603
+ if (at.rootId !== rootId) continue;
45604
+ const list2 = this.inbox.get(agentId) ?? [];
45605
+ list2.push({ id: post.id, rootId, userId: post.user_id, text: post.message.slice(0, 4e3), at: Date.now() });
45606
+ this.inbox.set(agentId, list2.slice(-20));
45607
+ this.onSteer?.(agentId, post.id);
45608
+ }
45609
+ }
45610
+ /** Whether the agent has unread messages from people in the thread it is answering. */
45611
+ hasInbox(agentId) {
45612
+ return (this.inbox.get(agentId)?.length ?? 0) > 0;
45613
+ }
45614
+ /** What people wrote in the agent's thread since its run started; each is handed over once. */
45615
+ async takeInbox(agentId) {
45616
+ const list2 = this.inbox.get(agentId) ?? [];
45617
+ this.inbox.delete(agentId);
45618
+ const out = [];
45619
+ for (const m of list2) {
45620
+ this.delivered.add(m.id);
45621
+ const who = await this.directory.userName(m.userId).catch(() => ({ username: m.userId, displayName: m.userId }));
45622
+ out.push({ from: `${who.displayName} (@${who.username})`, text: m.text, at: new Date(m.at).toISOString() });
45623
+ }
45624
+ if (this.delivered.size > 2e3) this.delivered.delete(this.delivered.values().next().value);
45625
+ return out;
45626
+ }
45570
45627
  /** The thread an agent is answering right now, for posts its run causes (approvals). */
45571
45628
  activeThread(agentId) {
45572
45629
  return this.active.get(agentId) ?? null;
@@ -45644,6 +45701,11 @@ var Bridge = class {
45644
45701
  if (!fromAgent && post.root_id) await this.resetTurns(rootId);
45645
45702
  return;
45646
45703
  }
45704
+ if (this.delivered.has(post.id)) {
45705
+ await member.chat.react(post.id, "white_check_mark").catch(() => {
45706
+ });
45707
+ return;
45708
+ }
45647
45709
  if (!await this.allowAgentTurn(rootId, fromAgent, member, post.channel_id)) return;
45648
45710
  const text2 = stripMention(post.message, member.username);
45649
45711
  const fileIds = this.files ? post.file_ids ?? [] : [];
@@ -45680,7 +45742,7 @@ var Bridge = class {
45680
45742
  const attached = fileIds.length ? await this.files.receive(member.chat, post.id, fileIds) : [];
45681
45743
  const outbox = this.files ? await this.files.outbox(post.id).catch(() => null) : null;
45682
45744
  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 ? `
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 ? `
45684
45746
 
45685
45747
  Earlier in this thread (oldest first):
45686
45748
  ${history}
@@ -45709,11 +45771,13 @@ To send files back with your reply, save them in ${outbox}` : "");
45709
45771
  }
45710
45772
  } catch (err) {
45711
45773
  if (this.active.get(member.agentId) === here) this.active.delete(member.agentId);
45774
+ this.inbox.delete(member.agentId);
45712
45775
  await member.chat.createPost(post.channel_id, rootId, `:warning: ${member.agentName} could not answer: ${String(err?.message ?? err).slice(0, 500)}`);
45713
45776
  return;
45714
45777
  }
45715
45778
  if (this.active.get(member.agentId) === here) this.active.delete(member.agentId);
45716
- const { text: body, choices } = extractChoices(reply ?? "");
45779
+ this.inbox.delete(member.agentId);
45780
+ const { text: body, choices } = extractChoices(absoluteLinks(reply ?? "", this.linkBase));
45717
45781
  const origin = { channelId: post.channel_id, rootId, channelType: evt.channelType, channelName: evt.channelName };
45718
45782
  const buttons = choices.length && this.choiceButtons ? this.choiceButtons(origin, choices) : null;
45719
45783
  const shown = choices.length && !buttons ? `${body}
@@ -46211,7 +46275,7 @@ var route = (routeKey, method, path3) => ({ routeKey, method, path: path3, auth:
46211
46275
  var manifest = {
46212
46276
  id: "mattermost-bridge",
46213
46277
  apiVersion: 1,
46214
- version: "0.15.2",
46278
+ version: "0.16.1",
46215
46279
  displayName: "Mattermost",
46216
46280
  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
46281
  author: "Haseeb Azhar",
@@ -46227,6 +46291,7 @@ var manifest = {
46227
46291
  "plugin.state.write",
46228
46292
  "http.outbound",
46229
46293
  "events.subscribe",
46294
+ "events.emit",
46230
46295
  "webhooks.receive",
46231
46296
  "approvals.read",
46232
46297
  "approvals.respond",
@@ -46247,6 +46312,7 @@ var manifest = {
46247
46312
  // checks the caller is an agent and scopes it to that agent's company.
46248
46313
  apiRoutes: [
46249
46314
  route("help", "GET", "/mm/help"),
46315
+ route("inbox", "GET", "/mm/inbox"),
46250
46316
  route("channels", "GET", "/mm/channels"),
46251
46317
  route("channel-posts", "GET", "/mm/channels/:channel/posts"),
46252
46318
  route("thread", "GET", "/mm/posts/:postId/thread"),
@@ -46488,6 +46554,11 @@ var Tenant = class {
46488
46554
  onPosted = (evt) => {
46489
46555
  void bridge.handle(evt);
46490
46556
  };
46557
+ bridge.linkBase = paperclipUrl;
46558
+ bridge.onSteer = (agentId, postId) => {
46559
+ void ctx.events.emit("person-wrote", companyId, { agentId, postId }).catch(() => {
46560
+ });
46561
+ };
46491
46562
  const approvers = parseApprovers(config2.approvers);
46492
46563
  const approvalsChannel = typeof config2.approvalsChannel === "string" ? config2.approvalsChannel.trim() : "";
46493
46564
  const agentName = (agentId) => agentId && roster.roster().byAgentId(agentId)?.agentName || "Paperclip";
@@ -46530,6 +46601,14 @@ var Tenant = class {
46530
46601
  agents: () => roster.all().map((m) => ({ agentId: m.agentId, agentName: m.agentName, username: m.username, botUserId: m.botUserId })),
46531
46602
  canTalk: async (userId, agentId) => !canTalk || canTalk(userId, agentId),
46532
46603
  origin: (agentId) => bridge.activeThread(agentId),
46604
+ // The task this run works on (its execution or checkout run), and the thread it was asked for in.
46605
+ taskThread: async (agentId, runId, channelId) => {
46606
+ if (!runId) return null;
46607
+ const issues = await ctx.issues.list({ companyId, assigneeAgentId: agentId, limit: 200 });
46608
+ const issue2 = issues.find((i) => i.executionRunId === runId || i.checkoutRunId === runId);
46609
+ const at = issue2 ? await store.get(`issuethread:${issue2.id}`) : null;
46610
+ return at && at.channelId === channelId ? at.rootId : null;
46611
+ },
46533
46612
  task: async (taskId) => await ctx.issues.get(taskId, companyId),
46534
46613
  askApproval: async (req) => {
46535
46614
  if (!paperclipUrl || !approvers.size) throw Object.assign(new Error("this needs a person's approval, and nobody can approve from Mattermost yet (the plugin needs its Paperclip public URL and approvers set)"), { status: 403 });
@@ -46600,9 +46679,9 @@ var Tenant = class {
46600
46679
  return null;
46601
46680
  }
46602
46681
  /** An agent's call to one of the plugin's `/mm/...` routes. */
46603
- async api(agentId, routeKey, params, input2) {
46682
+ async api(agentId, routeKey, params, input2, runId = null) {
46604
46683
  if (!this.actions) return { status: 503, body: { error: `Mattermost is not connected for this company (${this.status})` } };
46605
- return this.actions.handle(agentId, routeKey, params, input2);
46684
+ return this.actions.handle(agentId, routeKey, params, input2, runId);
46606
46685
  }
46607
46686
  /** The Agent computer plugin: an agent wants a person at its computer. Posted where the agent is working. */
46608
46687
  async computerNeedsPerson(p) {
@@ -46758,6 +46837,14 @@ var plugin = definePlugin({
46758
46837
  ctx.events.on("plugin.agent-computer.needs-person", (e) => quietly("could not post a computer help request", async () => {
46759
46838
  await tenants.get(e.companyId)?.computerNeedsPerson(e.payload ?? {});
46760
46839
  }));
46840
+ ctx.events.on("issue.created", (e) => quietly("could not link a task to its thread", async () => {
46841
+ const tenant = tenants.get(e.companyId);
46842
+ if (!tenant?.bridge || !e.entityId) return;
46843
+ const issue2 = await ctx.issues.get(e.entityId, e.companyId);
46844
+ const by = issue2?.createdByAgentId;
46845
+ const at = by ? tenant.bridge.activeThread(by) : null;
46846
+ if (at) await tenant.store().set(`issuethread:${e.entityId}`, at);
46847
+ }));
46761
46848
  ctx.events.on("approval.decided", (e) => quietly("could not update approval", async () => {
46762
46849
  if (e.entityId) await tenants.get(e.companyId)?.approvals?.decided(e.entityId);
46763
46850
  }));
@@ -46793,7 +46880,14 @@ var plugin = definePlugin({
46793
46880
  if (!tenant) return { status: 503, body: { error: "Mattermost is not set up for your company." } };
46794
46881
  const query = Object.fromEntries(Object.entries(input2.query).map(([k, v]) => [k, Array.isArray(v) ? v[0] : v]));
46795
46882
  const body = input2.body && typeof input2.body === "object" && !Array.isArray(input2.body) ? input2.body : {};
46796
- const res = await tenant.api(agentId, input2.routeKey, input2.params, { ...query, ...body });
46883
+ if (input2.routeKey === "inbox") {
46884
+ const messages = tenant.bridge ? await tenant.bridge.takeInbox(agentId) : [];
46885
+ 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." } };
46886
+ }
46887
+ const res = await tenant.api(agentId, input2.routeKey, input2.params, { ...query, ...body }, input2.actor.runId ?? null);
46888
+ if (tenant.bridge?.hasInbox(agentId) && res.body && typeof res.body === "object" && !Array.isArray(res.body)) {
46889
+ res.body = { ...res.body, newMessages: await tenant.bridge.takeInbox(agentId) };
46890
+ }
46797
46891
  return { status: res.status, body: res.body };
46798
46892
  },
46799
46893
  async onHealth() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperclip-plugin-mattermost",
3
- "version": "0.15.2",
3
+ "version": "0.16.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",