plures 1.3.1 → 1.7.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 +7 -2
- package/dist/chunk-XOB4CPDP.js +174 -0
- package/dist/cli.cjs +284 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +119 -0
- package/dist/index.cjs +81 -2
- package/dist/index.d.cts +378 -5
- package/dist/index.d.ts +378 -5
- package/dist/index.js +6 -88
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -44,8 +44,13 @@ if (event.type === "task.completed" && event.data.result?.agreement === 1) {
|
|
|
44
44
|
|
|
45
45
|
## API
|
|
46
46
|
|
|
47
|
-
- `plures.tasks.create(params, { idempotencyKey? })`: send a task. Params: `question`, `options` (2 to 8, strings or `{ id, label }`), `context`, `image_url`, `audio_url` or `video_url` with `media_seconds` (a clip up to 180 seconds that people play through before answering), `sensitive`, `instructions`, `verifiers` (1, 3, 5, 7 or 9), `max_verifiers`, `presence_check` (default true), `webhook_url`, `metadata`, `expires_in
|
|
48
|
-
- `plures.tasks.retrieve(id)`, `plures.tasks.list({ status?, limit?, starting_after? })`, `plures.tasks.cancel(id)`
|
|
47
|
+
- `plures.tasks.create(params, { idempotencyKey? })`: send a task. Params: `question`, `options` (2 to 8, strings or `{ id, label }`), `context`, `image_url`, `audio_url` or `video_url` with `media_seconds` (a clip up to 180 seconds that people play through before answering), `sensitive`, `instructions`, `verifiers` (1, 3, 5, 7 or 9), `max_verifiers`, `presence_check` (default true), `webhook_url`, `metadata`, `expires_in`, `route` (`"team"` sends it straight to your own people, $0.05 per internal vote live), `category` (matches your routing rules) and `team` (one of your named teams), `team_votes` and `team_unanimous` (internal consensus: how many of your team vote, and whether they must all agree; a split goes to the team's lead).
|
|
48
|
+
- `plures.tasks.retrieve(id)`, `plures.tasks.list({ status?, limit?, starting_after?, app? })`, `plures.tasks.cancel(id)`
|
|
49
|
+
- `new Plures(key, { app: "support-agent" })` or `PLURES_APP`: send everything to one of your apps. `plures.apps.list()`, `.create({ name, id? })`, `.retrieve(id)`, `.update(id, { name?, id?, default? })`, `.del(id)`. Each app keeps its own tasks, ledger view, map and quality.
|
|
50
|
+
- `plures.maps.guide()`, `.retrieve(app)`, `.upload(app, map)`, `.setCheckpoint(app, id, status)`: the agent map, where your agent needs a person. Or run `npx plures map` in your repo: your own coding agent reads the code and only the map is sent.
|
|
51
|
+
- `plures.quality.sample({ rubric, output, input? })`: send every output; a share is graded by people against the rubric, with personal details taken out first. `plures.quality.rubrics.create({ name, question, options, sample_rate })`, `.retrieve(name)` (score by day, this week against the four before, drift), `.list()`, `.update()`, `.del()`. `plures.quality.compare({ release, input, a, b })` and `plures.quality.release(name)` for blind release checks. `plures.quality.export(rubric)` returns graded samples for evals or fine tuning.
|
|
52
|
+
- `plures.testMix.set({ unanimous: 70, split: 20, tie: 5, expired: 5 })`, `.get()`, `.clear()`: with a test key, makes a share of test tasks split, tie or expire at random so you can test routing to your team without changing your agent. A task can override it with `metadata.test_mix`, and `metadata.test_outcome` beats both.
|
|
53
|
+
- `plures.routing.get()`, `plures.routing.set({ groups, rules, team })`: named teams of your own people and rules by category, like `{ groups: [{ name: "Finance", emails: ["ap@acme.com"] }], rules: [{ category: "refunds", route: "team", group: "Finance" }] }`. What you send replaces what's there; the rest stays.
|
|
49
54
|
- `plures.balance()`: your prepaid credit for live tasks, in cents. Live tasks cost $0.20 per answer (more for clips); a task that could cost more than your available credit throws a `PluresError` with `type` `insufficient_credits` (status 402).
|
|
50
55
|
- `plures.tasks.waitFor(id, { timeoutMs?, intervalMs? })`: poll until settled
|
|
51
56
|
- `plures.verify(params, { timeoutMs?, idempotencyKey? })`: create and wait
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var VERSION = "1.7.0";
|
|
3
|
+
var PluresError = class extends Error {
|
|
4
|
+
constructor(message, status, type) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.type = type;
|
|
8
|
+
}
|
|
9
|
+
status;
|
|
10
|
+
type;
|
|
11
|
+
};
|
|
12
|
+
var Plures = class {
|
|
13
|
+
base;
|
|
14
|
+
apiKey;
|
|
15
|
+
/** The app tasks go to when they don't name one. From opts.app or PLURES_APP. */
|
|
16
|
+
app;
|
|
17
|
+
ua;
|
|
18
|
+
constructor(apiKey, opts = {}) {
|
|
19
|
+
const key = apiKey ?? (typeof process !== "undefined" ? process.env?.PLURES_API_KEY : void 0);
|
|
20
|
+
if (!key) throw new Error("Pass your Plures API key, or set PLURES_API_KEY. Get a free test key at https://plures.si/?for=developers#join");
|
|
21
|
+
this.apiKey = key;
|
|
22
|
+
this.ua = opts.userAgent ?? `plures-js/${VERSION}`;
|
|
23
|
+
this.app = opts.app ?? (typeof process !== "undefined" ? process.env?.PLURES_APP : void 0) ?? null;
|
|
24
|
+
this.base = (opts.baseUrl ?? (typeof process !== "undefined" ? process.env?.PLURES_BASE_URL : void 0) ?? "https://plures.si").replace(/\/$/, "");
|
|
25
|
+
}
|
|
26
|
+
async req(method, path, body, headers = {}) {
|
|
27
|
+
for (let attempt = 0; ; attempt++) {
|
|
28
|
+
const r = await fetch(this.base + path, {
|
|
29
|
+
method,
|
|
30
|
+
headers: { authorization: `Bearer ${this.apiKey}`, "user-agent": this.ua, ...body ? { "content-type": "application/json" } : {}, ...this.app ? { "plures-app": this.app } : {}, ...headers },
|
|
31
|
+
body: body ? JSON.stringify(body) : void 0
|
|
32
|
+
});
|
|
33
|
+
if (r.status >= 500 && attempt < 2) {
|
|
34
|
+
await sleep(500 * 2 ** attempt);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const data = await r.json().catch(() => ({}));
|
|
38
|
+
if (!r.ok) throw new PluresError(data?.error?.message ?? `HTTP ${r.status}`, r.status, data?.error?.type ?? "api_error");
|
|
39
|
+
return data;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
tasks = {
|
|
43
|
+
/** Sends a question to verified people. Pass an idempotency key to make retries safe. */
|
|
44
|
+
create: (params, opts = {}) => this.req("POST", "/api/v1/tasks", params, opts.idempotencyKey ? { "idempotency-key": opts.idempotencyKey } : {}),
|
|
45
|
+
retrieve: (id) => this.req("GET", `/api/v1/tasks/${encodeURIComponent(id)}`),
|
|
46
|
+
list: (params = {}) => {
|
|
47
|
+
const q = new URLSearchParams(Object.entries(params).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)]));
|
|
48
|
+
return this.req("GET", `/api/v1/tasks${q.size ? `?${q}` : ""}`);
|
|
49
|
+
},
|
|
50
|
+
cancel: (id) => this.req("POST", `/api/v1/tasks/${encodeURIComponent(id)}/cancel`),
|
|
51
|
+
/** Polls until the task is settled. Prefer webhooks in production. */
|
|
52
|
+
waitFor: async (id, opts = {}) => {
|
|
53
|
+
const end = Date.now() + (opts.timeoutMs ?? 10 * 6e4);
|
|
54
|
+
for (; ; ) {
|
|
55
|
+
const t = await this.tasks.retrieve(id);
|
|
56
|
+
if (t.status !== "pending") return t;
|
|
57
|
+
if (Date.now() > end) return t;
|
|
58
|
+
await sleep(opts.intervalMs ?? 3e3);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Test keys only. How test tasks behave when they don't set metadata.test_outcome,
|
|
64
|
+
* so an agent running unchanged sends a share of decisions to your team.
|
|
65
|
+
* A single task can override it with metadata.test_mix.
|
|
66
|
+
*/
|
|
67
|
+
testMix = {
|
|
68
|
+
get: () => this.req("GET", "/api/v1/test_mix"),
|
|
69
|
+
/** For example { unanimous: 70, split: 20, tie: 5, expired: 5 }. Pass null to turn it off. */
|
|
70
|
+
set: (mix) => this.req("PUT", "/api/v1/test_mix", { mix }),
|
|
71
|
+
clear: () => this.req("DELETE", "/api/v1/test_mix")
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Where tasks go. set() replaces what you send (team, groups or rules) and keeps the rest.
|
|
75
|
+
* For example { groups: [{ name: "Finance", emails: ["ap@acme.com"] }], rules: [{ category: "refunds", route: "team", group: "Finance" }] }.
|
|
76
|
+
*/
|
|
77
|
+
routing = {
|
|
78
|
+
get: () => this.req("GET", "/api/v1/routing"),
|
|
79
|
+
set: (update) => this.req("PUT", "/api/v1/routing", update)
|
|
80
|
+
};
|
|
81
|
+
/** Your apps. Each agent you run can be its own app, with its own tasks, ledger view and map. */
|
|
82
|
+
apps = {
|
|
83
|
+
list: () => this.req("GET", "/api/v1/apps"),
|
|
84
|
+
/** { name: "Support agent" }, with an optional id like "support-agent". */
|
|
85
|
+
create: (params) => this.req("POST", "/api/v1/apps", params),
|
|
86
|
+
retrieve: (id) => this.req("GET", `/api/v1/apps/${encodeURIComponent(id)}`),
|
|
87
|
+
/** Rename it, change its id (its tasks move with it), or make it the default. */
|
|
88
|
+
update: (id, params) => this.req("PATCH", `/api/v1/apps/${encodeURIComponent(id)}`, params),
|
|
89
|
+
/** Only an app with no tasks can be deleted. */
|
|
90
|
+
del: (id) => this.req("DELETE", `/api/v1/apps/${encodeURIComponent(id)}`)
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Agent maps: where each app's agent needs a person. Your coding agent reads your code and
|
|
94
|
+
* writes the map; Plures gets only the map. guide() returns the steps it follows.
|
|
95
|
+
*/
|
|
96
|
+
maps = {
|
|
97
|
+
guide: async () => {
|
|
98
|
+
const r = await fetch(`${this.base}/api/v1/map_guide`);
|
|
99
|
+
return r.text();
|
|
100
|
+
},
|
|
101
|
+
retrieve: (app) => this.req("GET", `/api/v1/apps/${encodeURIComponent(app)}/map`),
|
|
102
|
+
/** Uploads a new version. Checks you already added or dismissed keep that. */
|
|
103
|
+
upload: (app, map) => this.req("PUT", `/api/v1/apps/${encodeURIComponent(app)}/map`, map),
|
|
104
|
+
setCheckpoint: (app, id, status) => this.req("PATCH", `/api/v1/apps/${encodeURIComponent(app)}/map/checkpoints/${encodeURIComponent(id)}`, { status })
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Quality: people grade a sample of your agent's outputs against a rubric, so you see drift
|
|
108
|
+
* early, and compare two versions blind before you ship. Personal details are taken out first.
|
|
109
|
+
*/
|
|
110
|
+
quality = {
|
|
111
|
+
/** Send every output; the rubric's sample rate decides which ones people grade. */
|
|
112
|
+
sample: (p) => this.req("POST", "/api/v1/samples", p),
|
|
113
|
+
/** A blind release check: a is the current version's output, b the new one's, for the same input. */
|
|
114
|
+
compare: (p) => this.req("POST", "/api/v1/comparisons", p),
|
|
115
|
+
/** How often people preferred the new version, with a 95% range and a verdict. */
|
|
116
|
+
release: (name, opts = {}) => this.req("GET", `/api/v1/releases/${encodeURIComponent(name)}${opts.app ? `?app=${encodeURIComponent(opts.app)}` : ""}`),
|
|
117
|
+
rubrics: {
|
|
118
|
+
list: (opts = {}) => this.req("GET", `/api/v1/rubrics${opts.app ? `?app=${encodeURIComponent(opts.app)}` : ""}`),
|
|
119
|
+
/** { name: "reply_quality", question: "Is this reply correct and kind?", options: ["Good", "Okay", "Bad"], sample_rate: 0.05 } */
|
|
120
|
+
create: (p) => this.req("POST", "/api/v1/rubrics", p),
|
|
121
|
+
retrieve: (name, opts = {}) => this.req("GET", `/api/v1/rubrics/${encodeURIComponent(name)}${opts.app ? `?app=${encodeURIComponent(opts.app)}` : ""}`),
|
|
122
|
+
update: (name, p) => this.req("PATCH", `/api/v1/rubrics/${encodeURIComponent(name)}${p.app ? `?app=${encodeURIComponent(p.app)}` : ""}`, p),
|
|
123
|
+
del: (name, opts = {}) => this.req("DELETE", `/api/v1/rubrics/${encodeURIComponent(name)}${opts.app ? `?app=${encodeURIComponent(opts.app)}` : ""}`)
|
|
124
|
+
},
|
|
125
|
+
/** Graded samples as JSON lines (input, output, grade, score, agreement), for fine tuning or an eval set. */
|
|
126
|
+
export: async (rubric, opts = {}) => {
|
|
127
|
+
const q = new URLSearchParams(Object.entries({ app: opts.app ?? this.app ?? void 0, since: opts.since }).filter(([, v]) => v));
|
|
128
|
+
const r = await fetch(`${this.base}/api/v1/rubrics/${encodeURIComponent(rubric)}/export${q.size ? `?${q}` : ""}`, { headers: { authorization: `Bearer ${this.apiKey}`, "user-agent": this.ua } });
|
|
129
|
+
if (!r.ok) {
|
|
130
|
+
const d = await r.json().catch(() => ({}));
|
|
131
|
+
throw new PluresError(d?.error?.message ?? `HTTP ${r.status}`, r.status, d?.error?.type ?? "api_error");
|
|
132
|
+
}
|
|
133
|
+
return (await r.text()).split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
/** Your prepaid credit. A live task that could cost more than available_cents is refused with a 402 insufficient_credits error. */
|
|
137
|
+
balance = () => this.req("GET", "/api/v1/balance");
|
|
138
|
+
/** Creates a task and waits for people to answer it. */
|
|
139
|
+
async verify(params, opts = {}) {
|
|
140
|
+
const t = await this.tasks.create(params, { idempotencyKey: opts.idempotencyKey });
|
|
141
|
+
return t.status === "pending" ? this.tasks.waitFor(t.id, { timeoutMs: opts.timeoutMs }) : t;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Checks the Plures-Signature header on a webhook and returns the parsed event.
|
|
145
|
+
* Pass the raw request body exactly as received. Throws if the signature is wrong
|
|
146
|
+
* or older than toleranceSec.
|
|
147
|
+
*/
|
|
148
|
+
static async verifyWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) {
|
|
149
|
+
const parts = Object.fromEntries((signatureHeader ?? "").split(",").map((p) => p.split("=", 2)));
|
|
150
|
+
const t = Number(parts.t);
|
|
151
|
+
if (!t || !parts.v1) throw new Error("Missing Plures-Signature header");
|
|
152
|
+
if (Math.abs(Date.now() / 1e3 - t) > toleranceSec) throw new Error("Webhook timestamp is too old");
|
|
153
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
154
|
+
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${t}.${rawBody}`)));
|
|
155
|
+
const expected = Array.from(mac, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
156
|
+
if (!timingSafeEqual(expected, parts.v1)) throw new Error("Webhook signature does not match");
|
|
157
|
+
return JSON.parse(rawBody);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
function timingSafeEqual(a, b) {
|
|
161
|
+
if (a.length !== b.length) return false;
|
|
162
|
+
let diff = 0;
|
|
163
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
164
|
+
return diff === 0;
|
|
165
|
+
}
|
|
166
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
167
|
+
var index_default = Plures;
|
|
168
|
+
|
|
169
|
+
export {
|
|
170
|
+
VERSION,
|
|
171
|
+
PluresError,
|
|
172
|
+
Plures,
|
|
173
|
+
index_default
|
|
174
|
+
};
|
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// src/cli.ts
|
|
5
|
+
var import_node_child_process = require("child_process");
|
|
6
|
+
var import_node_fs = require("fs");
|
|
7
|
+
|
|
8
|
+
// src/index.ts
|
|
9
|
+
var VERSION = "1.7.0";
|
|
10
|
+
var PluresError = class extends Error {
|
|
11
|
+
constructor(message, status, type) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.type = type;
|
|
15
|
+
}
|
|
16
|
+
status;
|
|
17
|
+
type;
|
|
18
|
+
};
|
|
19
|
+
var Plures = class {
|
|
20
|
+
base;
|
|
21
|
+
apiKey;
|
|
22
|
+
/** The app tasks go to when they don't name one. From opts.app or PLURES_APP. */
|
|
23
|
+
app;
|
|
24
|
+
ua;
|
|
25
|
+
constructor(apiKey, opts = {}) {
|
|
26
|
+
const key2 = apiKey ?? (typeof process !== "undefined" ? process.env?.PLURES_API_KEY : void 0);
|
|
27
|
+
if (!key2) throw new Error("Pass your Plures API key, or set PLURES_API_KEY. Get a free test key at https://plures.si/?for=developers#join");
|
|
28
|
+
this.apiKey = key2;
|
|
29
|
+
this.ua = opts.userAgent ?? `plures-js/${VERSION}`;
|
|
30
|
+
this.app = opts.app ?? (typeof process !== "undefined" ? process.env?.PLURES_APP : void 0) ?? null;
|
|
31
|
+
this.base = (opts.baseUrl ?? (typeof process !== "undefined" ? process.env?.PLURES_BASE_URL : void 0) ?? "https://plures.si").replace(/\/$/, "");
|
|
32
|
+
}
|
|
33
|
+
async req(method, path, body, headers = {}) {
|
|
34
|
+
for (let attempt = 0; ; attempt++) {
|
|
35
|
+
const r = await fetch(this.base + path, {
|
|
36
|
+
method,
|
|
37
|
+
headers: { authorization: `Bearer ${this.apiKey}`, "user-agent": this.ua, ...body ? { "content-type": "application/json" } : {}, ...this.app ? { "plures-app": this.app } : {}, ...headers },
|
|
38
|
+
body: body ? JSON.stringify(body) : void 0
|
|
39
|
+
});
|
|
40
|
+
if (r.status >= 500 && attempt < 2) {
|
|
41
|
+
await sleep(500 * 2 ** attempt);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const data = await r.json().catch(() => ({}));
|
|
45
|
+
if (!r.ok) throw new PluresError(data?.error?.message ?? `HTTP ${r.status}`, r.status, data?.error?.type ?? "api_error");
|
|
46
|
+
return data;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
tasks = {
|
|
50
|
+
/** Sends a question to verified people. Pass an idempotency key to make retries safe. */
|
|
51
|
+
create: (params, opts = {}) => this.req("POST", "/api/v1/tasks", params, opts.idempotencyKey ? { "idempotency-key": opts.idempotencyKey } : {}),
|
|
52
|
+
retrieve: (id) => this.req("GET", `/api/v1/tasks/${encodeURIComponent(id)}`),
|
|
53
|
+
list: (params = {}) => {
|
|
54
|
+
const q = new URLSearchParams(Object.entries(params).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)]));
|
|
55
|
+
return this.req("GET", `/api/v1/tasks${q.size ? `?${q}` : ""}`);
|
|
56
|
+
},
|
|
57
|
+
cancel: (id) => this.req("POST", `/api/v1/tasks/${encodeURIComponent(id)}/cancel`),
|
|
58
|
+
/** Polls until the task is settled. Prefer webhooks in production. */
|
|
59
|
+
waitFor: async (id, opts = {}) => {
|
|
60
|
+
const end = Date.now() + (opts.timeoutMs ?? 10 * 6e4);
|
|
61
|
+
for (; ; ) {
|
|
62
|
+
const t = await this.tasks.retrieve(id);
|
|
63
|
+
if (t.status !== "pending") return t;
|
|
64
|
+
if (Date.now() > end) return t;
|
|
65
|
+
await sleep(opts.intervalMs ?? 3e3);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Test keys only. How test tasks behave when they don't set metadata.test_outcome,
|
|
71
|
+
* so an agent running unchanged sends a share of decisions to your team.
|
|
72
|
+
* A single task can override it with metadata.test_mix.
|
|
73
|
+
*/
|
|
74
|
+
testMix = {
|
|
75
|
+
get: () => this.req("GET", "/api/v1/test_mix"),
|
|
76
|
+
/** For example { unanimous: 70, split: 20, tie: 5, expired: 5 }. Pass null to turn it off. */
|
|
77
|
+
set: (mix) => this.req("PUT", "/api/v1/test_mix", { mix }),
|
|
78
|
+
clear: () => this.req("DELETE", "/api/v1/test_mix")
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Where tasks go. set() replaces what you send (team, groups or rules) and keeps the rest.
|
|
82
|
+
* For example { groups: [{ name: "Finance", emails: ["ap@acme.com"] }], rules: [{ category: "refunds", route: "team", group: "Finance" }] }.
|
|
83
|
+
*/
|
|
84
|
+
routing = {
|
|
85
|
+
get: () => this.req("GET", "/api/v1/routing"),
|
|
86
|
+
set: (update) => this.req("PUT", "/api/v1/routing", update)
|
|
87
|
+
};
|
|
88
|
+
/** Your apps. Each agent you run can be its own app, with its own tasks, ledger view and map. */
|
|
89
|
+
apps = {
|
|
90
|
+
list: () => this.req("GET", "/api/v1/apps"),
|
|
91
|
+
/** { name: "Support agent" }, with an optional id like "support-agent". */
|
|
92
|
+
create: (params) => this.req("POST", "/api/v1/apps", params),
|
|
93
|
+
retrieve: (id) => this.req("GET", `/api/v1/apps/${encodeURIComponent(id)}`),
|
|
94
|
+
/** Rename it, change its id (its tasks move with it), or make it the default. */
|
|
95
|
+
update: (id, params) => this.req("PATCH", `/api/v1/apps/${encodeURIComponent(id)}`, params),
|
|
96
|
+
/** Only an app with no tasks can be deleted. */
|
|
97
|
+
del: (id) => this.req("DELETE", `/api/v1/apps/${encodeURIComponent(id)}`)
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Agent maps: where each app's agent needs a person. Your coding agent reads your code and
|
|
101
|
+
* writes the map; Plures gets only the map. guide() returns the steps it follows.
|
|
102
|
+
*/
|
|
103
|
+
maps = {
|
|
104
|
+
guide: async () => {
|
|
105
|
+
const r = await fetch(`${this.base}/api/v1/map_guide`);
|
|
106
|
+
return r.text();
|
|
107
|
+
},
|
|
108
|
+
retrieve: (app2) => this.req("GET", `/api/v1/apps/${encodeURIComponent(app2)}/map`),
|
|
109
|
+
/** Uploads a new version. Checks you already added or dismissed keep that. */
|
|
110
|
+
upload: (app2, map2) => this.req("PUT", `/api/v1/apps/${encodeURIComponent(app2)}/map`, map2),
|
|
111
|
+
setCheckpoint: (app2, id, status) => this.req("PATCH", `/api/v1/apps/${encodeURIComponent(app2)}/map/checkpoints/${encodeURIComponent(id)}`, { status })
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Quality: people grade a sample of your agent's outputs against a rubric, so you see drift
|
|
115
|
+
* early, and compare two versions blind before you ship. Personal details are taken out first.
|
|
116
|
+
*/
|
|
117
|
+
quality = {
|
|
118
|
+
/** Send every output; the rubric's sample rate decides which ones people grade. */
|
|
119
|
+
sample: (p) => this.req("POST", "/api/v1/samples", p),
|
|
120
|
+
/** A blind release check: a is the current version's output, b the new one's, for the same input. */
|
|
121
|
+
compare: (p) => this.req("POST", "/api/v1/comparisons", p),
|
|
122
|
+
/** How often people preferred the new version, with a 95% range and a verdict. */
|
|
123
|
+
release: (name, opts = {}) => this.req("GET", `/api/v1/releases/${encodeURIComponent(name)}${opts.app ? `?app=${encodeURIComponent(opts.app)}` : ""}`),
|
|
124
|
+
rubrics: {
|
|
125
|
+
list: (opts = {}) => this.req("GET", `/api/v1/rubrics${opts.app ? `?app=${encodeURIComponent(opts.app)}` : ""}`),
|
|
126
|
+
/** { name: "reply_quality", question: "Is this reply correct and kind?", options: ["Good", "Okay", "Bad"], sample_rate: 0.05 } */
|
|
127
|
+
create: (p) => this.req("POST", "/api/v1/rubrics", p),
|
|
128
|
+
retrieve: (name, opts = {}) => this.req("GET", `/api/v1/rubrics/${encodeURIComponent(name)}${opts.app ? `?app=${encodeURIComponent(opts.app)}` : ""}`),
|
|
129
|
+
update: (name, p) => this.req("PATCH", `/api/v1/rubrics/${encodeURIComponent(name)}${p.app ? `?app=${encodeURIComponent(p.app)}` : ""}`, p),
|
|
130
|
+
del: (name, opts = {}) => this.req("DELETE", `/api/v1/rubrics/${encodeURIComponent(name)}${opts.app ? `?app=${encodeURIComponent(opts.app)}` : ""}`)
|
|
131
|
+
},
|
|
132
|
+
/** Graded samples as JSON lines (input, output, grade, score, agreement), for fine tuning or an eval set. */
|
|
133
|
+
export: async (rubric, opts = {}) => {
|
|
134
|
+
const q = new URLSearchParams(Object.entries({ app: opts.app ?? this.app ?? void 0, since: opts.since }).filter(([, v]) => v));
|
|
135
|
+
const r = await fetch(`${this.base}/api/v1/rubrics/${encodeURIComponent(rubric)}/export${q.size ? `?${q}` : ""}`, { headers: { authorization: `Bearer ${this.apiKey}`, "user-agent": this.ua } });
|
|
136
|
+
if (!r.ok) {
|
|
137
|
+
const d = await r.json().catch(() => ({}));
|
|
138
|
+
throw new PluresError(d?.error?.message ?? `HTTP ${r.status}`, r.status, d?.error?.type ?? "api_error");
|
|
139
|
+
}
|
|
140
|
+
return (await r.text()).split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
/** Your prepaid credit. A live task that could cost more than available_cents is refused with a 402 insufficient_credits error. */
|
|
144
|
+
balance = () => this.req("GET", "/api/v1/balance");
|
|
145
|
+
/** Creates a task and waits for people to answer it. */
|
|
146
|
+
async verify(params, opts = {}) {
|
|
147
|
+
const t = await this.tasks.create(params, { idempotencyKey: opts.idempotencyKey });
|
|
148
|
+
return t.status === "pending" ? this.tasks.waitFor(t.id, { timeoutMs: opts.timeoutMs }) : t;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Checks the Plures-Signature header on a webhook and returns the parsed event.
|
|
152
|
+
* Pass the raw request body exactly as received. Throws if the signature is wrong
|
|
153
|
+
* or older than toleranceSec.
|
|
154
|
+
*/
|
|
155
|
+
static async verifyWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) {
|
|
156
|
+
const parts = Object.fromEntries((signatureHeader ?? "").split(",").map((p) => p.split("=", 2)));
|
|
157
|
+
const t = Number(parts.t);
|
|
158
|
+
if (!t || !parts.v1) throw new Error("Missing Plures-Signature header");
|
|
159
|
+
if (Math.abs(Date.now() / 1e3 - t) > toleranceSec) throw new Error("Webhook timestamp is too old");
|
|
160
|
+
const key2 = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
161
|
+
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key2, new TextEncoder().encode(`${t}.${rawBody}`)));
|
|
162
|
+
const expected = Array.from(mac, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
163
|
+
if (!timingSafeEqual(expected, parts.v1)) throw new Error("Webhook signature does not match");
|
|
164
|
+
return JSON.parse(rawBody);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
function timingSafeEqual(a, b) {
|
|
168
|
+
if (a.length !== b.length) return false;
|
|
169
|
+
let diff = 0;
|
|
170
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
171
|
+
return diff === 0;
|
|
172
|
+
}
|
|
173
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
174
|
+
|
|
175
|
+
// src/cli.ts
|
|
176
|
+
var argv = process.argv.slice(2);
|
|
177
|
+
var flag = (name) => {
|
|
178
|
+
const i = argv.indexOf(`--${name}`);
|
|
179
|
+
if (i < 0) return void 0;
|
|
180
|
+
const v = argv[i + 1];
|
|
181
|
+
argv.splice(i, 2);
|
|
182
|
+
return v;
|
|
183
|
+
};
|
|
184
|
+
var app = flag("app") ?? process.env.PLURES_APP ?? "default";
|
|
185
|
+
var key = flag("key") ?? process.env.PLURES_API_KEY;
|
|
186
|
+
var base = flag("base-url") ?? process.env.PLURES_BASE_URL ?? "https://plures.si";
|
|
187
|
+
var tty = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
188
|
+
var c = (code) => (s) => tty ? `\x1B[${code}m${s}\x1B[0m` : s;
|
|
189
|
+
var dim = c("2");
|
|
190
|
+
var bold = c("1");
|
|
191
|
+
var cyan = c("36");
|
|
192
|
+
var coral = c("38;5;209");
|
|
193
|
+
var violet = c("38;5;147");
|
|
194
|
+
var FILE = "plures.map.json";
|
|
195
|
+
function die(msg) {
|
|
196
|
+
console.error(coral(msg));
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
function client() {
|
|
200
|
+
if (!key) die("Set PLURES_API_KEY (a free pk_test_ key works), or pass --key. Get one at https://plures.si/?for=developers#join");
|
|
201
|
+
return new Plures(key, { baseUrl: base, app, userAgent: `plures-cli/${VERSION}` });
|
|
202
|
+
}
|
|
203
|
+
async function upload(file) {
|
|
204
|
+
if (!(0, import_node_fs.existsSync)(file)) die(`No ${file} here. Run npx plures map to make one, or pass the file's path.`);
|
|
205
|
+
let map2;
|
|
206
|
+
try {
|
|
207
|
+
map2 = JSON.parse((0, import_node_fs.readFileSync)(file, "utf8"));
|
|
208
|
+
} catch {
|
|
209
|
+
die(`${file} isn't valid JSON.`);
|
|
210
|
+
}
|
|
211
|
+
console.log(dim(`Sending the map to Plures. No source code is sent.`));
|
|
212
|
+
let m;
|
|
213
|
+
try {
|
|
214
|
+
m = await client().maps.upload(app, map2);
|
|
215
|
+
} catch (e) {
|
|
216
|
+
die(e instanceof PluresError ? `Plures said: ${e.message}` : String(e));
|
|
217
|
+
}
|
|
218
|
+
report(m);
|
|
219
|
+
}
|
|
220
|
+
function report(m) {
|
|
221
|
+
const nodes = new Map(m.nodes.map((n) => [n.id, n]));
|
|
222
|
+
const actions = m.nodes.filter((n) => n.kind === "action");
|
|
223
|
+
console.log(`
|
|
224
|
+
${bold(m.name ?? m.app)}: ${m.nodes.length} steps, ${actions.length} ${actions.length === 1 ? "action" : "actions"} with consequences
|
|
225
|
+
`);
|
|
226
|
+
const at = new Map(m.checkpoints.map((cp) => [cp.at, cp]));
|
|
227
|
+
for (const a of actions) {
|
|
228
|
+
const cp = at.get(a.id);
|
|
229
|
+
const loc = a.file ? dim(`${a.file}${a.line ? `:${a.line}` : ""}`) : "";
|
|
230
|
+
if (cp?.status === "added") console.log(` ${cyan("checked ")} ${a.label.padEnd(26)} ${loc}`);
|
|
231
|
+
else if (cp?.status === "suggested") console.log(` ${violet("suggested")} ${a.label.padEnd(26)} ${dim(cp.why)}`);
|
|
232
|
+
else console.log(` ${a.risk === "high" ? coral("no check ") : dim("no check ")} ${a.label.padEnd(26)} ${loc}`);
|
|
233
|
+
}
|
|
234
|
+
for (const cp of m.checkpoints.filter((x) => nodes.get(x.at)?.kind !== "action" && x.status !== "dismissed"))
|
|
235
|
+
console.log(` ${violet("suggested")} ${(nodes.get(cp.at)?.label ?? cp.at).padEnd(26)} ${dim(cp.why)}`);
|
|
236
|
+
console.log(`
|
|
237
|
+
Version ${m.version}. Open ${bold(m.url ?? `${base}/dashboard/apps/${m.app}/map`)}`);
|
|
238
|
+
}
|
|
239
|
+
function hasClaude() {
|
|
240
|
+
return (0, import_node_child_process.spawnSync)(process.platform === "win32" ? "where" : "which", ["claude"], { stdio: "ignore" }).status === 0;
|
|
241
|
+
}
|
|
242
|
+
async function map() {
|
|
243
|
+
const guide = await (await fetch(`${base}/api/v1/map_guide`)).text().catch(() => "");
|
|
244
|
+
if (!guide) die("Couldn't reach Plures for the mapping guide. Check your connection.");
|
|
245
|
+
if (!hasClaude()) {
|
|
246
|
+
console.log(`${bold("Map this app with your coding agent")}
|
|
247
|
+
|
|
248
|
+
Ask it: ${cyan(`Read ${base}/api/v1/map_guide and follow it to write ${FILE} for this repo.`)}
|
|
249
|
+
Then run: ${cyan(`npx plures map upload${app !== "default" ? ` --app ${app}` : ""}`)}
|
|
250
|
+
|
|
251
|
+
With plures-mcp connected, just ask: ${cyan("Map this app for Plures")}`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const before = (0, import_node_fs.existsSync)(FILE) ? (0, import_node_fs.statSync)(FILE).mtimeMs : 0;
|
|
255
|
+
console.log(`Reading this repo on your machine with Claude Code. ${dim("Only the map leaves it.")}
|
|
256
|
+
`);
|
|
257
|
+
const prompt = `${guide}
|
|
258
|
+
|
|
259
|
+
Write the map to ${FILE} in the current directory now. Read the code; don't run it or change anything else.`;
|
|
260
|
+
const code = await new Promise((resolve) => {
|
|
261
|
+
const p = (0, import_node_child_process.spawn)("claude", ["-p", prompt, "--allowedTools", "Read,Grep,Glob,Write,Bash(git rev-parse:*),Bash(git remote:*)"], { stdio: ["ignore", "inherit", "inherit"] });
|
|
262
|
+
p.on("close", (n) => resolve(n ?? 1));
|
|
263
|
+
p.on("error", () => resolve(1));
|
|
264
|
+
});
|
|
265
|
+
if (code !== 0 || !(0, import_node_fs.existsSync)(FILE) || (0, import_node_fs.statSync)(FILE).mtimeMs === before) die(`Claude Code didn't write ${FILE}. Ask it directly, then run npx plures map upload.`);
|
|
266
|
+
await upload(FILE);
|
|
267
|
+
}
|
|
268
|
+
async function main() {
|
|
269
|
+
const [cmd, sub, arg] = argv;
|
|
270
|
+
if (cmd !== "map") {
|
|
271
|
+
console.log(`Plures
|
|
272
|
+
|
|
273
|
+
npx plures map [--app <id>] map this repo's agent and upload it
|
|
274
|
+
npx plures map upload [file] upload ${FILE}
|
|
275
|
+
npx plures map guide print the steps for any coding agent
|
|
276
|
+
|
|
277
|
+
Docs: https://plures.si/docs`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (sub === "upload") return upload(arg ?? FILE);
|
|
281
|
+
if (sub === "guide") return void console.log(await (await fetch(`${base}/api/v1/map_guide`)).text());
|
|
282
|
+
return map();
|
|
283
|
+
}
|
|
284
|
+
main().catch((e) => die(String(e?.message ?? e)));
|
package/dist/cli.d.cts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
Plures,
|
|
4
|
+
PluresError,
|
|
5
|
+
VERSION
|
|
6
|
+
} from "./chunk-XOB4CPDP.js";
|
|
7
|
+
|
|
8
|
+
// src/cli.ts
|
|
9
|
+
import { spawnSync, spawn } from "child_process";
|
|
10
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
11
|
+
var argv = process.argv.slice(2);
|
|
12
|
+
var flag = (name) => {
|
|
13
|
+
const i = argv.indexOf(`--${name}`);
|
|
14
|
+
if (i < 0) return void 0;
|
|
15
|
+
const v = argv[i + 1];
|
|
16
|
+
argv.splice(i, 2);
|
|
17
|
+
return v;
|
|
18
|
+
};
|
|
19
|
+
var app = flag("app") ?? process.env.PLURES_APP ?? "default";
|
|
20
|
+
var key = flag("key") ?? process.env.PLURES_API_KEY;
|
|
21
|
+
var base = flag("base-url") ?? process.env.PLURES_BASE_URL ?? "https://plures.si";
|
|
22
|
+
var tty = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
23
|
+
var c = (code) => (s) => tty ? `\x1B[${code}m${s}\x1B[0m` : s;
|
|
24
|
+
var dim = c("2");
|
|
25
|
+
var bold = c("1");
|
|
26
|
+
var cyan = c("36");
|
|
27
|
+
var coral = c("38;5;209");
|
|
28
|
+
var violet = c("38;5;147");
|
|
29
|
+
var FILE = "plures.map.json";
|
|
30
|
+
function die(msg) {
|
|
31
|
+
console.error(coral(msg));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
function client() {
|
|
35
|
+
if (!key) die("Set PLURES_API_KEY (a free pk_test_ key works), or pass --key. Get one at https://plures.si/?for=developers#join");
|
|
36
|
+
return new Plures(key, { baseUrl: base, app, userAgent: `plures-cli/${VERSION}` });
|
|
37
|
+
}
|
|
38
|
+
async function upload(file) {
|
|
39
|
+
if (!existsSync(file)) die(`No ${file} here. Run npx plures map to make one, or pass the file's path.`);
|
|
40
|
+
let map2;
|
|
41
|
+
try {
|
|
42
|
+
map2 = JSON.parse(readFileSync(file, "utf8"));
|
|
43
|
+
} catch {
|
|
44
|
+
die(`${file} isn't valid JSON.`);
|
|
45
|
+
}
|
|
46
|
+
console.log(dim(`Sending the map to Plures. No source code is sent.`));
|
|
47
|
+
let m;
|
|
48
|
+
try {
|
|
49
|
+
m = await client().maps.upload(app, map2);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
die(e instanceof PluresError ? `Plures said: ${e.message}` : String(e));
|
|
52
|
+
}
|
|
53
|
+
report(m);
|
|
54
|
+
}
|
|
55
|
+
function report(m) {
|
|
56
|
+
const nodes = new Map(m.nodes.map((n) => [n.id, n]));
|
|
57
|
+
const actions = m.nodes.filter((n) => n.kind === "action");
|
|
58
|
+
console.log(`
|
|
59
|
+
${bold(m.name ?? m.app)}: ${m.nodes.length} steps, ${actions.length} ${actions.length === 1 ? "action" : "actions"} with consequences
|
|
60
|
+
`);
|
|
61
|
+
const at = new Map(m.checkpoints.map((cp) => [cp.at, cp]));
|
|
62
|
+
for (const a of actions) {
|
|
63
|
+
const cp = at.get(a.id);
|
|
64
|
+
const loc = a.file ? dim(`${a.file}${a.line ? `:${a.line}` : ""}`) : "";
|
|
65
|
+
if (cp?.status === "added") console.log(` ${cyan("checked ")} ${a.label.padEnd(26)} ${loc}`);
|
|
66
|
+
else if (cp?.status === "suggested") console.log(` ${violet("suggested")} ${a.label.padEnd(26)} ${dim(cp.why)}`);
|
|
67
|
+
else console.log(` ${a.risk === "high" ? coral("no check ") : dim("no check ")} ${a.label.padEnd(26)} ${loc}`);
|
|
68
|
+
}
|
|
69
|
+
for (const cp of m.checkpoints.filter((x) => nodes.get(x.at)?.kind !== "action" && x.status !== "dismissed"))
|
|
70
|
+
console.log(` ${violet("suggested")} ${(nodes.get(cp.at)?.label ?? cp.at).padEnd(26)} ${dim(cp.why)}`);
|
|
71
|
+
console.log(`
|
|
72
|
+
Version ${m.version}. Open ${bold(m.url ?? `${base}/dashboard/apps/${m.app}/map`)}`);
|
|
73
|
+
}
|
|
74
|
+
function hasClaude() {
|
|
75
|
+
return spawnSync(process.platform === "win32" ? "where" : "which", ["claude"], { stdio: "ignore" }).status === 0;
|
|
76
|
+
}
|
|
77
|
+
async function map() {
|
|
78
|
+
const guide = await (await fetch(`${base}/api/v1/map_guide`)).text().catch(() => "");
|
|
79
|
+
if (!guide) die("Couldn't reach Plures for the mapping guide. Check your connection.");
|
|
80
|
+
if (!hasClaude()) {
|
|
81
|
+
console.log(`${bold("Map this app with your coding agent")}
|
|
82
|
+
|
|
83
|
+
Ask it: ${cyan(`Read ${base}/api/v1/map_guide and follow it to write ${FILE} for this repo.`)}
|
|
84
|
+
Then run: ${cyan(`npx plures map upload${app !== "default" ? ` --app ${app}` : ""}`)}
|
|
85
|
+
|
|
86
|
+
With plures-mcp connected, just ask: ${cyan("Map this app for Plures")}`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const before = existsSync(FILE) ? statSync(FILE).mtimeMs : 0;
|
|
90
|
+
console.log(`Reading this repo on your machine with Claude Code. ${dim("Only the map leaves it.")}
|
|
91
|
+
`);
|
|
92
|
+
const prompt = `${guide}
|
|
93
|
+
|
|
94
|
+
Write the map to ${FILE} in the current directory now. Read the code; don't run it or change anything else.`;
|
|
95
|
+
const code = await new Promise((resolve) => {
|
|
96
|
+
const p = spawn("claude", ["-p", prompt, "--allowedTools", "Read,Grep,Glob,Write,Bash(git rev-parse:*),Bash(git remote:*)"], { stdio: ["ignore", "inherit", "inherit"] });
|
|
97
|
+
p.on("close", (n) => resolve(n ?? 1));
|
|
98
|
+
p.on("error", () => resolve(1));
|
|
99
|
+
});
|
|
100
|
+
if (code !== 0 || !existsSync(FILE) || statSync(FILE).mtimeMs === before) die(`Claude Code didn't write ${FILE}. Ask it directly, then run npx plures map upload.`);
|
|
101
|
+
await upload(FILE);
|
|
102
|
+
}
|
|
103
|
+
async function main() {
|
|
104
|
+
const [cmd, sub, arg] = argv;
|
|
105
|
+
if (cmd !== "map") {
|
|
106
|
+
console.log(`Plures
|
|
107
|
+
|
|
108
|
+
npx plures map [--app <id>] map this repo's agent and upload it
|
|
109
|
+
npx plures map upload [file] upload ${FILE}
|
|
110
|
+
npx plures map guide print the steps for any coding agent
|
|
111
|
+
|
|
112
|
+
Docs: https://plures.si/docs`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (sub === "upload") return upload(arg ?? FILE);
|
|
116
|
+
if (sub === "guide") return void console.log(await (await fetch(`${base}/api/v1/map_guide`)).text());
|
|
117
|
+
return map();
|
|
118
|
+
}
|
|
119
|
+
main().catch((e) => die(String(e?.message ?? e)));
|