paperclip-plugin-mattermost 0.18.2 → 0.19.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 +2 -1
- package/dist/manifest.js +7 -1
- package/dist/ui/index.js +30 -0
- package/dist/worker.js +70 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,6 +49,7 @@ Or install it from **Settings → Plugins** in Paperclip.
|
|
|
49
49
|
- **Mattermost admin token**: pick the secret from step 2.
|
|
50
50
|
- **Paperclip public URL**: filled in from Paperclip's own settings. The page checks it by sending a test click through it.
|
|
51
51
|
- **Approvals channel**: where approvals and decision cards are posted.
|
|
52
|
+
- **Channel per agent** (optional): send one agent's approvals, decision cards and computer help requests to its own channel, for example a client reporting agent's drafts to `#client-reporting`. Only approvers can decide them, but everyone in the channel sees them. A channel that cannot be found falls back to the approvals channel, with a note.
|
|
52
53
|
- **Agents in Mattermost**: all agents, or the ones you pick.
|
|
53
54
|
- **Who can talk to agents**: everyone, or the people you pick, each with all agents or some.
|
|
54
55
|
- **Approvers**: who may decide approvals and cards from Mattermost. Their clicks count as their Paperclip decisions, matched to Mattermost by email.
|
|
@@ -85,7 +86,7 @@ PAPERCLIP_REF=<commit> node build.mjs # build for another Paperclip version
|
|
|
85
86
|
Bump `version` in `package.json` and `src/manifest.ts`, commit, then tag and push:
|
|
86
87
|
|
|
87
88
|
```bash
|
|
88
|
-
git tag -a v0.
|
|
89
|
+
git tag -a v0.19.0 -m v0.19.0 && git push origin main v0.19.0
|
|
89
90
|
```
|
|
90
91
|
|
|
91
92
|
GitHub Actions builds, tests and publishes to npm through npm trusted publishing, so no npm token is involved.
|
package/dist/manifest.js
CHANGED
|
@@ -84,7 +84,7 @@ var route = (routeKey, method, path) => ({ routeKey, method, path, auth: "agent"
|
|
|
84
84
|
var manifest = {
|
|
85
85
|
id: "mattermost-bridge",
|
|
86
86
|
apiVersion: 1,
|
|
87
|
-
version: "0.
|
|
87
|
+
version: "0.19.0",
|
|
88
88
|
displayName: "Mattermost",
|
|
89
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.",
|
|
90
90
|
author: "Haseeb Azhar",
|
|
@@ -170,6 +170,12 @@ var manifest = {
|
|
|
170
170
|
title: "Approvals channel",
|
|
171
171
|
description: "Channel name (as in its URL, e.g. approvals) where every approval is posted; one raised during a Mattermost conversation also appears in that thread."
|
|
172
172
|
},
|
|
173
|
+
agentChannels: {
|
|
174
|
+
type: "array",
|
|
175
|
+
title: "Channel per agent",
|
|
176
|
+
description: "Agents whose approvals, decision cards and help requests go to their own channel instead of the approvals channel.",
|
|
177
|
+
items: { type: "object", properties: { agentId: { type: "string" }, channel: { type: "string" } }, required: ["agentId", "channel"] }
|
|
178
|
+
},
|
|
173
179
|
agentsMode: {
|
|
174
180
|
type: "string",
|
|
175
181
|
enum: ["all", "selected"],
|
package/dist/ui/index.js
CHANGED
|
@@ -122,6 +122,26 @@ function AccessList({ members, agents, grants, accounts, onChange }) {
|
|
|
122
122
|
] })
|
|
123
123
|
] });
|
|
124
124
|
}
|
|
125
|
+
function AgentChannels({ agents, channels, value, onChange }) {
|
|
126
|
+
const named = (id) => agents.find((a) => a.id === id)?.name ?? "A removed agent";
|
|
127
|
+
const free = agents.filter((a) => !value.some((r) => r.agentId === a.id));
|
|
128
|
+
const update = (agentId, channel) => onChange(value.map((r) => r.agentId === agentId ? { agentId, channel } : r));
|
|
129
|
+
const picker = (r) => channels.length ? /* @__PURE__ */ jsxs("select", { style: s.input, value: r.channel, onChange: (e) => update(r.agentId, e.target.value), children: [
|
|
130
|
+
channels.some((ch) => ch.name === r.channel) ? null : /* @__PURE__ */ jsx("option", { value: r.channel, children: r.channel || "Pick a channel" }),
|
|
131
|
+
channels.map((ch) => /* @__PURE__ */ jsx("option", { value: ch.name, children: ch.displayName }, ch.name))
|
|
132
|
+
] }) : /* @__PURE__ */ jsx("input", { style: s.input, value: r.channel, onChange: (e) => update(r.agentId, e.target.value.trim()), placeholder: "channel name, e.g. client-reporting" });
|
|
133
|
+
return /* @__PURE__ */ jsxs("div", { style: { display: "grid", gap: 6 }, children: [
|
|
134
|
+
value.map((r) => /* @__PURE__ */ jsxs("div", { style: s.row, children: [
|
|
135
|
+
/* @__PURE__ */ jsx("span", { style: { minWidth: 160 }, children: named(r.agentId) }),
|
|
136
|
+
picker(r),
|
|
137
|
+
/* @__PURE__ */ jsx("button", { style: { ...s.button, padding: "4px 9px" }, title: "Use the approvals channel", onClick: () => onChange(value.filter((x) => x.agentId !== r.agentId)), children: "\u2715" })
|
|
138
|
+
] }, r.agentId)),
|
|
139
|
+
free.length ? /* @__PURE__ */ jsxs("select", { style: { ...s.input, maxWidth: 320 }, value: "", onChange: (e) => e.target.value && onChange([...value, { agentId: e.target.value, channel: channels[0]?.name ?? "" }]), children: [
|
|
140
|
+
/* @__PURE__ */ jsx("option", { value: "", children: "Add an agent\u2026" }),
|
|
141
|
+
free.map((a) => /* @__PURE__ */ jsx("option", { value: a.id, children: a.name }, a.id))
|
|
142
|
+
] }) : null
|
|
143
|
+
] });
|
|
144
|
+
}
|
|
125
145
|
function TokenHelp({ mattermostUrl }) {
|
|
126
146
|
const base = /^https?:\/\/\S+/.test(mattermostUrl.trim()) ? mattermostUrl.trim().replace(/\/+$/, "") : null;
|
|
127
147
|
const page = (path, label) => base ? /* @__PURE__ */ jsx("a", { href: `${base}${path}`, target: "_blank", rel: "noreferrer", children: label }) : /* @__PURE__ */ jsx("b", { children: label });
|
|
@@ -159,6 +179,7 @@ function validate(c) {
|
|
|
159
179
|
if (!url) errors.mattermostUrl = "Enter your Mattermost address.";
|
|
160
180
|
else if (!/^https?:\/\/[^\s/]+/.test(url)) errors.mattermostUrl = "Enter a full address starting with https://";
|
|
161
181
|
if (!c.botToken?.secretId) errors.botToken = "Choose the secret that holds the Mattermost admin token.";
|
|
182
|
+
if (Array.isArray(c.agentChannels) && c.agentChannels.some((r) => !String(r.channel ?? "").trim())) errors.agentChannels = "Pick a channel for every agent listed, or remove it.";
|
|
162
183
|
return errors;
|
|
163
184
|
}
|
|
164
185
|
function saveErrorText(raw) {
|
|
@@ -340,6 +361,15 @@ function SettingsPage({ context }) {
|
|
|
340
361
|
/* @__PURE__ */ jsx("option", { value: "", children: "None: only in the agent's thread" }),
|
|
341
362
|
setup.data.channels.map((ch) => /* @__PURE__ */ jsx("option", { value: ch.name, children: ch.displayName }, ch.name))
|
|
342
363
|
] }) : /* @__PURE__ */ jsx("input", { style: s.input, value: String(config.approvalsChannel ?? ""), onChange: (e) => set({ approvalsChannel: e.target.value }), placeholder: "channel name, e.g. approvals" }) }),
|
|
364
|
+
/* @__PURE__ */ jsx(Field, { label: "Channel per agent", error: fieldErrors.agentChannels, help: "Send one agent's approvals, decision cards and computer help requests to its own channel, for example a client reporting agent's drafts to #client-reporting. Only approvers can decide them, but everyone in that channel sees them. If its channel cannot be found, they go to the approvals channel with a note.", children: /* @__PURE__ */ jsx(
|
|
365
|
+
AgentChannels,
|
|
366
|
+
{
|
|
367
|
+
agents,
|
|
368
|
+
channels: setup.data?.channels ?? [],
|
|
369
|
+
value: Array.isArray(config.agentChannels) ? config.agentChannels : [],
|
|
370
|
+
onChange: (next) => set({ agentChannels: next })
|
|
371
|
+
}
|
|
372
|
+
) }),
|
|
343
373
|
/* @__PURE__ */ jsx(Field, { label: "Agent-to-agent turns", help: "Agents hand work to each other by mentioning one another in a thread. After this many agent messages in a row, they pause until a person replies.", children: /* @__PURE__ */ jsx(
|
|
344
374
|
"input",
|
|
345
375
|
{
|
package/dist/worker.js
CHANGED
|
@@ -44640,6 +44640,24 @@ import { createHmac as createHmac2 } from "node:crypto";
|
|
|
44640
44640
|
|
|
44641
44641
|
// src/approvals.ts
|
|
44642
44642
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
44643
|
+
function parseAgentChannels(value) {
|
|
44644
|
+
const out = {};
|
|
44645
|
+
if (Array.isArray(value)) for (const r of value) {
|
|
44646
|
+
if (typeof r?.agentId === "string" && typeof r.channel === "string" && r.channel.trim()) out[r.agentId] = r.channel.trim();
|
|
44647
|
+
}
|
|
44648
|
+
return out;
|
|
44649
|
+
}
|
|
44650
|
+
function decisionChannels(poster, perAgent, fallback) {
|
|
44651
|
+
return async (agentId) => {
|
|
44652
|
+
const own2 = agentId ? perAgent[agentId] : void 0;
|
|
44653
|
+
if (own2) {
|
|
44654
|
+
const id2 = await poster.channel(own2);
|
|
44655
|
+
if (id2) return { channelId: id2 };
|
|
44656
|
+
}
|
|
44657
|
+
const id = fallback ? await poster.channel(fallback) : null;
|
|
44658
|
+
return id ? { channelId: id, ...own2 ? { note: `Posted here because this agent's channel "${own2}" was not found.` } : {} } : null;
|
|
44659
|
+
};
|
|
44660
|
+
}
|
|
44643
44661
|
var TYPE_LABEL = {
|
|
44644
44662
|
request_board_approval: "Approval requested",
|
|
44645
44663
|
hire_agent: "Hire request",
|
|
@@ -44715,12 +44733,13 @@ var Approvals = class {
|
|
|
44715
44733
|
const targets = [];
|
|
44716
44734
|
const thread = a.requestedByAgentId ? d.activeThread(a.requestedByAgentId) : null;
|
|
44717
44735
|
if (thread) targets.push(thread);
|
|
44718
|
-
const
|
|
44719
|
-
if (
|
|
44736
|
+
const home = await d.channelFor(a.requestedByAgentId);
|
|
44737
|
+
if (home && home.channelId !== thread?.channelId) targets.push({ channelId: home.channelId, rootId: "", note: home.note });
|
|
44720
44738
|
if (!targets.length) return;
|
|
44721
44739
|
const { message: message2, props } = render(a, d.agentName(a.requestedByAgentId), d.actionUrl, d.key);
|
|
44722
44740
|
const postIds = [];
|
|
44723
|
-
for (const where of targets) postIds.push(await d.poster.post(a.requestedByAgentId, where, message2
|
|
44741
|
+
for (const { note, ...where } of targets) postIds.push(await d.poster.post(a.requestedByAgentId, where, note ? `${message2}
|
|
44742
|
+
_${note}_` : message2, props));
|
|
44724
44743
|
await d.store.set(`approval:${approvalId}`, { postIds });
|
|
44725
44744
|
}
|
|
44726
44745
|
/** `approval.decided` (here or in Paperclip): drop the buttons, show the outcome. */
|
|
@@ -44862,8 +44881,11 @@ var Cards = class {
|
|
|
44862
44881
|
}
|
|
44863
44882
|
async pass() {
|
|
44864
44883
|
const d = this.d;
|
|
44865
|
-
const
|
|
44866
|
-
|
|
44884
|
+
const homes = /* @__PURE__ */ new Map();
|
|
44885
|
+
const home = async (agentId) => {
|
|
44886
|
+
if (!homes.has(agentId)) homes.set(agentId, await d.channelFor(agentId));
|
|
44887
|
+
return homes.get(agentId) ?? null;
|
|
44888
|
+
};
|
|
44867
44889
|
const now = (d.now ?? (() => /* @__PURE__ */ new Date()))();
|
|
44868
44890
|
let since = await d.store.get("cards:since");
|
|
44869
44891
|
if (!since) {
|
|
@@ -44882,8 +44904,12 @@ var Cards = class {
|
|
|
44882
44904
|
delete posted[c.id];
|
|
44883
44905
|
}
|
|
44884
44906
|
} else if (c.status === "pending" && new Date(c.createdAt).getTime() > Date.parse(since)) {
|
|
44885
|
-
const
|
|
44886
|
-
|
|
44907
|
+
const agentId = c.createdByAgentId ?? issue2.assigneeAgentId ?? null;
|
|
44908
|
+
const where = await home(agentId);
|
|
44909
|
+
if (!where) continue;
|
|
44910
|
+
const { message: message2, props } = renderCard(c, issue2, d.agentName(agentId), d);
|
|
44911
|
+
posted[c.id] = { postId: await d.poster.post(agentId, { channelId: where.channelId, rootId: "" }, where.note ? `${message2}
|
|
44912
|
+
_${where.note}_` : message2, props), issueId: issue2.id };
|
|
44887
44913
|
}
|
|
44888
44914
|
}
|
|
44889
44915
|
}
|
|
@@ -45365,6 +45391,8 @@ var STATUS_WORDS = {
|
|
|
45365
45391
|
};
|
|
45366
45392
|
var WorkLinks = class {
|
|
45367
45393
|
d;
|
|
45394
|
+
/** One task's updates, one at a time: Paperclip sends several at once and each would report the change. */
|
|
45395
|
+
queue = /* @__PURE__ */ new Map();
|
|
45368
45396
|
constructor(deps) {
|
|
45369
45397
|
this.d = deps;
|
|
45370
45398
|
}
|
|
@@ -45394,12 +45422,25 @@ ${body}`;
|
|
|
45394
45422
|
}
|
|
45395
45423
|
await this.d.post(hit.link.agentId, hit.channelId, "", c.authorAgentId === hit.link.agentId ? body : quoted);
|
|
45396
45424
|
}
|
|
45425
|
+
serial(key, run) {
|
|
45426
|
+
const next = (this.queue.get(key) ?? Promise.resolve()).catch(() => {
|
|
45427
|
+
}).then(run);
|
|
45428
|
+
this.queue.set(key, next);
|
|
45429
|
+
void next.catch(() => {
|
|
45430
|
+
}).finally(() => {
|
|
45431
|
+
if (this.queue.get(key) === next) this.queue.delete(key);
|
|
45432
|
+
});
|
|
45433
|
+
return next;
|
|
45434
|
+
}
|
|
45397
45435
|
/**
|
|
45398
45436
|
* A task asked for in a Mattermost thread (created by the agent while answering there): when it is
|
|
45399
45437
|
* done, cancelled or blocked, its outcome (the last comment) is posted back in that thread, so the
|
|
45400
45438
|
* result always reaches the conversation it came from.
|
|
45401
45439
|
*/
|
|
45402
|
-
|
|
45440
|
+
threadUpdate(issueId) {
|
|
45441
|
+
return this.serial(`thread:${issueId}`, () => this.reportToThread(issueId));
|
|
45442
|
+
}
|
|
45443
|
+
async reportToThread(issueId) {
|
|
45403
45444
|
const at = await this.d.store.get(`issuethread:${issueId}`);
|
|
45404
45445
|
if (!at) return;
|
|
45405
45446
|
const task2 = await this.d.task(issueId);
|
|
@@ -45420,7 +45461,10 @@ ${last.length > 3e3 ? last.slice(0, 3e3) + "\u2026" : last}` : "";
|
|
|
45420
45461
|
}
|
|
45421
45462
|
}
|
|
45422
45463
|
/** The task changed: note a new status; on done or cancelled, archive and report back. */
|
|
45423
|
-
|
|
45464
|
+
updated(issueId) {
|
|
45465
|
+
return this.serial(`link:${issueId}`, () => this.followLink(issueId));
|
|
45466
|
+
}
|
|
45467
|
+
async followLink(issueId) {
|
|
45424
45468
|
const hit = await this.linked(issueId);
|
|
45425
45469
|
if (!hit) return;
|
|
45426
45470
|
const task2 = await this.d.task(issueId);
|
|
@@ -46376,7 +46420,7 @@ var route = (routeKey, method, path3) => ({ routeKey, method, path: path3, auth:
|
|
|
46376
46420
|
var manifest = {
|
|
46377
46421
|
id: "mattermost-bridge",
|
|
46378
46422
|
apiVersion: 1,
|
|
46379
|
-
version: "0.
|
|
46423
|
+
version: "0.19.0",
|
|
46380
46424
|
displayName: "Mattermost",
|
|
46381
46425
|
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.",
|
|
46382
46426
|
author: "Haseeb Azhar",
|
|
@@ -46462,6 +46506,12 @@ var manifest = {
|
|
|
46462
46506
|
title: "Approvals channel",
|
|
46463
46507
|
description: "Channel name (as in its URL, e.g. approvals) where every approval is posted; one raised during a Mattermost conversation also appears in that thread."
|
|
46464
46508
|
},
|
|
46509
|
+
agentChannels: {
|
|
46510
|
+
type: "array",
|
|
46511
|
+
title: "Channel per agent",
|
|
46512
|
+
description: "Agents whose approvals, decision cards and help requests go to their own channel instead of the approvals channel.",
|
|
46513
|
+
items: { type: "object", properties: { agentId: { type: "string" }, channel: { type: "string" } }, required: ["agentId", "channel"] }
|
|
46514
|
+
},
|
|
46465
46515
|
agentsMode: {
|
|
46466
46516
|
type: "string",
|
|
46467
46517
|
enum: ["all", "selected"],
|
|
@@ -46535,7 +46585,7 @@ var Tenant = class {
|
|
|
46535
46585
|
key = "";
|
|
46536
46586
|
approvers = /* @__PURE__ */ new Map();
|
|
46537
46587
|
paperclipUrl = "";
|
|
46538
|
-
|
|
46588
|
+
channelFor = async () => null;
|
|
46539
46589
|
timers = [];
|
|
46540
46590
|
agents() {
|
|
46541
46591
|
const companyId = this.companyId;
|
|
@@ -46665,6 +46715,8 @@ var Tenant = class {
|
|
|
46665
46715
|
};
|
|
46666
46716
|
const approvers = parseApprovers(config2.approvers);
|
|
46667
46717
|
const approvalsChannel = typeof config2.approvalsChannel === "string" ? config2.approvalsChannel.trim() : "";
|
|
46718
|
+
const agentChannels = parseAgentChannels(config2.agentChannels);
|
|
46719
|
+
const channelFor = decisionChannels(this.poster(manager, roster), agentChannels, approvalsChannel);
|
|
46668
46720
|
const agentName = (agentId) => agentId && roster.roster().byAgentId(agentId)?.agentName || "Paperclip";
|
|
46669
46721
|
const approvals = paperclipUrl ? new Approvals({
|
|
46670
46722
|
poster: this.poster(manager, roster),
|
|
@@ -46677,10 +46729,10 @@ var Tenant = class {
|
|
|
46677
46729
|
store,
|
|
46678
46730
|
actionUrl,
|
|
46679
46731
|
key,
|
|
46680
|
-
|
|
46732
|
+
channelFor,
|
|
46681
46733
|
approvers
|
|
46682
46734
|
}) : null;
|
|
46683
|
-
const cards = paperclipUrl && approvalsChannel ? new Cards({
|
|
46735
|
+
const cards = paperclipUrl && (approvalsChannel || Object.keys(agentChannels).length) ? new Cards({
|
|
46684
46736
|
poster: this.poster(manager, roster),
|
|
46685
46737
|
openIssues: async () => (await Promise.all(OPEN_STATUSES.map((status) => ctx.issues.list({ companyId, status, limit: 500 })))).flat(),
|
|
46686
46738
|
cards: async (issueId) => await ctx.issues.listInteractions(issueId, companyId),
|
|
@@ -46695,7 +46747,7 @@ var Tenant = class {
|
|
|
46695
46747
|
actionUrl,
|
|
46696
46748
|
key,
|
|
46697
46749
|
paperclipUrl,
|
|
46698
|
-
|
|
46750
|
+
channelFor,
|
|
46699
46751
|
approvers
|
|
46700
46752
|
}) : null;
|
|
46701
46753
|
const poster = this.poster(manager, roster);
|
|
@@ -46718,7 +46770,8 @@ var Tenant = class {
|
|
|
46718
46770
|
askApproval: async (req) => {
|
|
46719
46771
|
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 });
|
|
46720
46772
|
const { message: message2, props } = requestCard(req, actionUrl, key);
|
|
46721
|
-
const
|
|
46773
|
+
const home = await channelFor(req.agentId);
|
|
46774
|
+
const targets = [req.origin, home && home.channelId !== req.origin?.channelId ? { channelId: home.channelId, rootId: "" } : null];
|
|
46722
46775
|
const postIds = [];
|
|
46723
46776
|
for (const where of targets) if (where) postIds.push(await poster.post(req.agentId, where, message2, props));
|
|
46724
46777
|
await store.set(`reqcard:${req.id}`, postIds);
|
|
@@ -46742,7 +46795,7 @@ var Tenant = class {
|
|
|
46742
46795
|
task: async (issueId) => await ctx.issues.get(issueId, companyId),
|
|
46743
46796
|
comments: async (issueId) => (await ctx.issues.listComments(issueId, companyId)).map((c) => ({ id: c.id, body: c.body, authorAgentId: c.authorAgentId }))
|
|
46744
46797
|
});
|
|
46745
|
-
Object.assign(this, { manager, bots: roster, bridge, approvals, cards, actions, work, key, approvers, paperclipUrl,
|
|
46798
|
+
Object.assign(this, { manager, bots: roster, bridge, approvals, cards, actions, work, key, approvers, paperclipUrl, channelFor });
|
|
46746
46799
|
this.status = `connecting to ${url2} as @${manager.botUsername}`;
|
|
46747
46800
|
detached(() => {
|
|
46748
46801
|
manager.connect(onPosted);
|
|
@@ -46795,7 +46848,7 @@ var Tenant = class {
|
|
|
46795
46848
|
async computerNeedsPerson(p) {
|
|
46796
46849
|
if (!this.manager || !this.bots || !this.bridge || !p.agentId) return;
|
|
46797
46850
|
const poster = this.poster(this.manager, this.bots);
|
|
46798
|
-
const where = this.bridge.activeThread(p.agentId) ??
|
|
46851
|
+
const where = this.bridge.activeThread(p.agentId) ?? await this.channelFor(p.agentId).then((home) => home ? { channelId: home.channelId, rootId: "" } : null);
|
|
46799
46852
|
if (!where) return;
|
|
46800
46853
|
const link = this.paperclipUrl && p.path ? `${this.paperclipUrl}${p.path}` : null;
|
|
46801
46854
|
const on = p.url ? ` on ${p.url}` : "";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paperclip-plugin-mattermost",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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",
|