plures 1.10.0 → 1.12.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 -0
- package/dist/chunk-GDI4KDAH.js +205 -0
- package/dist/cli.cjs +290 -26
- package/dist/cli.js +246 -197
- package/dist/index.cjs +3 -1
- package/dist/index.d.cts +17 -1
- package/dist/index.d.ts +17 -1
- package/dist/index.js +6 -196
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,6 +49,8 @@ if (event.type === "task.completed" && event.data.result?.agreement === 1) {
|
|
|
49
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
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
51
|
- `plures.maps.createKey(app, { label? })`, `.keys(app)`, `.revokeKey(app, id)`: map keys for CI, which can only read and upload that app's map. `.workflow(app, branch?)` returns the GitHub Action that maps on every push; `npx plures map workflow` writes it to `.github/workflows/plures-map.yml`. Or use **Map with GitHub** on the app's map page and Plures opens the pull request for you, then **Turn on mapping** merges it. `plures.maps.startRun(app)` and `.runs(app)`: in CI with no Claude key, `npx plures map` starts a run where Plures runs Claude (first map free, then Claude's cost plus 30%, at most $5).
|
|
52
|
+
- In your terminal, `npx plures map` opens your map page so you can watch it build (`--no-open` to skip), keeps one live line of what it's reading, and ends by drawing the map's flows with the checks Plures suggests. On GitHub and other CI it prints a plain log.
|
|
53
|
+
- `npx plures map` shows each file as Claude Code reads it and sends the file names (never their contents) to Plures, so the map page shows the reading live and the map builds itself when it lands. `plures.maps.progress(app, { session, phase, events })` is the call behind it.
|
|
52
54
|
- `plures.github.repos()`, `.connect(app, "owner/repo")`, `.turnOn(app)`, `.status(app)`, `.disconnect(app)`: Map with GitHub from code. Plures sets the repo's map key secret and opens a pull request adding the workflow; `turnOn` merges it. Installing the Plures GitHub App needs a browser once (`install_url`). From a terminal: `npx plures map github connect`, then `npx plures map github on`.
|
|
53
55
|
- `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.
|
|
54
56
|
- `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.
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var VERSION = "1.12.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
|
+
/** A key that can only read and upload this app's map, for CI. The key is in the response once. */
|
|
106
|
+
createKey: (app, opts = {}) => this.req("POST", `/api/v1/apps/${encodeURIComponent(app)}/map_keys`, opts),
|
|
107
|
+
keys: (app) => this.req("GET", `/api/v1/apps/${encodeURIComponent(app)}/map_keys`),
|
|
108
|
+
revokeKey: (app, id) => this.req("DELETE", `/api/v1/apps/${encodeURIComponent(app)}/map_keys/${encodeURIComponent(id)}`),
|
|
109
|
+
/** Starts a map run where Plures runs Claude (CI with no Anthropic key). npx plures map does this. */
|
|
110
|
+
startRun: (app) => this.req("POST", `/api/v1/apps/${encodeURIComponent(app)}/map_runs`, {}),
|
|
111
|
+
runs: (app) => this.req("GET", `/api/v1/apps/${encodeURIComponent(app)}/map_runs`),
|
|
112
|
+
/** While mapping, which files the coding agent is reading, shown live on the map page. File names only. npx plures map does this. */
|
|
113
|
+
progress: (app, p) => this.req("POST", `/api/v1/apps/${encodeURIComponent(app)}/map/progress`, p),
|
|
114
|
+
/** The GitHub Actions workflow that maps a repo on every push to its main branch. */
|
|
115
|
+
workflow: async (app, branch = "main") => {
|
|
116
|
+
const r = await fetch(`${this.base}/api/v1/map_workflow?app=${encodeURIComponent(app)}&branch=${encodeURIComponent(branch)}`);
|
|
117
|
+
return r.text();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Map with GitHub: Plures adds a workflow to your repo by pull request and keeps the app's map
|
|
122
|
+
* current on every push. Installing the Plures GitHub App needs a browser once (installUrl);
|
|
123
|
+
* everything after that works here.
|
|
124
|
+
*/
|
|
125
|
+
github = {
|
|
126
|
+
/** Repos the Plures GitHub App can reach. installed is false until you install it at install_url. */
|
|
127
|
+
repos: () => this.req("GET", "/api/v1/github/repos"),
|
|
128
|
+
/** Where an app's GitHub link stands: repo, pull request, mapping on, latest run, last map. */
|
|
129
|
+
status: (app) => this.req("GET", `/api/v1/apps/${encodeURIComponent(app)}/github`),
|
|
130
|
+
/** Connects a repo ("owner/name"): sets its map key secret and opens the pull request. */
|
|
131
|
+
connect: (app, repo) => this.req("POST", `/api/v1/apps/${encodeURIComponent(app)}/github`, { repo }),
|
|
132
|
+
/** Turns mapping on by merging the pull request. The repo's own rules (reviews, checks) still apply. */
|
|
133
|
+
turnOn: (app) => this.req("POST", `/api/v1/apps/${encodeURIComponent(app)}/github/merge`, {}),
|
|
134
|
+
/** Revokes the repo's map key. Delete the workflow file in the repo to stop runs. */
|
|
135
|
+
disconnect: (app) => this.req("DELETE", `/api/v1/apps/${encodeURIComponent(app)}/github`)
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* Quality: people grade a sample of your agent's outputs against a rubric, so you see drift
|
|
139
|
+
* early, and compare two versions blind before you ship. Personal details are taken out first.
|
|
140
|
+
*/
|
|
141
|
+
quality = {
|
|
142
|
+
/** Send every output; the rubric's sample rate decides which ones people grade. */
|
|
143
|
+
sample: (p) => this.req("POST", "/api/v1/samples", p),
|
|
144
|
+
/** A blind release check: a is the current version's output, b the new one's, for the same input. */
|
|
145
|
+
compare: (p) => this.req("POST", "/api/v1/comparisons", p),
|
|
146
|
+
/** How often people preferred the new version, with a 95% range and a verdict. */
|
|
147
|
+
release: (name, opts = {}) => this.req("GET", `/api/v1/releases/${encodeURIComponent(name)}${opts.app ? `?app=${encodeURIComponent(opts.app)}` : ""}`),
|
|
148
|
+
rubrics: {
|
|
149
|
+
list: (opts = {}) => this.req("GET", `/api/v1/rubrics${opts.app ? `?app=${encodeURIComponent(opts.app)}` : ""}`),
|
|
150
|
+
/** { name: "reply_quality", question: "Is this reply correct and kind?", options: ["Good", "Okay", "Bad"], sample_rate: 0.05 } */
|
|
151
|
+
create: (p) => this.req("POST", "/api/v1/rubrics", p),
|
|
152
|
+
retrieve: (name, opts = {}) => this.req("GET", `/api/v1/rubrics/${encodeURIComponent(name)}${opts.app ? `?app=${encodeURIComponent(opts.app)}` : ""}`),
|
|
153
|
+
update: (name, p) => this.req("PATCH", `/api/v1/rubrics/${encodeURIComponent(name)}${p.app ? `?app=${encodeURIComponent(p.app)}` : ""}`, p),
|
|
154
|
+
del: (name, opts = {}) => this.req("DELETE", `/api/v1/rubrics/${encodeURIComponent(name)}${opts.app ? `?app=${encodeURIComponent(opts.app)}` : ""}`)
|
|
155
|
+
},
|
|
156
|
+
/** Graded samples as JSON lines (input, output, grade, score, agreement), for fine tuning or an eval set. */
|
|
157
|
+
export: async (rubric, opts = {}) => {
|
|
158
|
+
const q = new URLSearchParams(Object.entries({ app: opts.app ?? this.app ?? void 0, since: opts.since }).filter(([, v]) => v));
|
|
159
|
+
const r = await fetch(`${this.base}/api/v1/rubrics/${encodeURIComponent(rubric)}/export${q.size ? `?${q}` : ""}`, { headers: { authorization: `Bearer ${this.apiKey}`, "user-agent": this.ua } });
|
|
160
|
+
if (!r.ok) {
|
|
161
|
+
const d = await r.json().catch(() => ({}));
|
|
162
|
+
throw new PluresError(d?.error?.message ?? `HTTP ${r.status}`, r.status, d?.error?.type ?? "api_error");
|
|
163
|
+
}
|
|
164
|
+
return (await r.text()).split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
/** Your prepaid credit. A live task that could cost more than available_cents is refused with a 402 insufficient_credits error. */
|
|
168
|
+
balance = () => this.req("GET", "/api/v1/balance");
|
|
169
|
+
/** Creates a task and waits for people to answer it. */
|
|
170
|
+
async verify(params, opts = {}) {
|
|
171
|
+
const t = await this.tasks.create(params, { idempotencyKey: opts.idempotencyKey });
|
|
172
|
+
return t.status === "pending" ? this.tasks.waitFor(t.id, { timeoutMs: opts.timeoutMs }) : t;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Checks the Plures-Signature header on a webhook and returns the parsed event.
|
|
176
|
+
* Pass the raw request body exactly as received. Throws if the signature is wrong
|
|
177
|
+
* or older than toleranceSec.
|
|
178
|
+
*/
|
|
179
|
+
static async verifyWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) {
|
|
180
|
+
const parts = Object.fromEntries((signatureHeader ?? "").split(",").map((p) => p.split("=", 2)));
|
|
181
|
+
const t = Number(parts.t);
|
|
182
|
+
if (!t || !parts.v1) throw new Error("Missing Plures-Signature header");
|
|
183
|
+
if (Math.abs(Date.now() / 1e3 - t) > toleranceSec) throw new Error("Webhook timestamp is too old");
|
|
184
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
185
|
+
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${t}.${rawBody}`)));
|
|
186
|
+
const expected = Array.from(mac, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
187
|
+
if (!timingSafeEqual(expected, parts.v1)) throw new Error("Webhook signature does not match");
|
|
188
|
+
return JSON.parse(rawBody);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
function timingSafeEqual(a, b) {
|
|
192
|
+
if (a.length !== b.length) return false;
|
|
193
|
+
let diff = 0;
|
|
194
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
195
|
+
return diff === 0;
|
|
196
|
+
}
|
|
197
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
198
|
+
var index_default = Plures;
|
|
199
|
+
|
|
200
|
+
export {
|
|
201
|
+
VERSION,
|
|
202
|
+
PluresError,
|
|
203
|
+
Plures,
|
|
204
|
+
index_default
|
|
205
|
+
};
|
package/dist/cli.cjs
CHANGED
|
@@ -1,20 +1,44 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key2 of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key2) && key2 !== except)
|
|
13
|
+
__defProp(to, key2, { get: () => from[key2], enumerable: !(desc = __getOwnPropDesc(from, key2)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
3
25
|
|
|
4
26
|
// src/cli.ts
|
|
5
27
|
var import_node_child_process = require("child_process");
|
|
28
|
+
var import_node_crypto = require("crypto");
|
|
29
|
+
var import_node_path = require("path");
|
|
6
30
|
var import_node_fs = require("fs");
|
|
7
31
|
|
|
8
|
-
// src/index.
|
|
9
|
-
var VERSION = "1.
|
|
32
|
+
// src/index.ts
|
|
33
|
+
var VERSION = "1.12.0";
|
|
10
34
|
var PluresError = class extends Error {
|
|
11
|
-
status;
|
|
12
|
-
type;
|
|
13
35
|
constructor(message, status, type) {
|
|
14
36
|
super(message);
|
|
15
37
|
this.status = status;
|
|
16
38
|
this.type = type;
|
|
17
39
|
}
|
|
40
|
+
status;
|
|
41
|
+
type;
|
|
18
42
|
};
|
|
19
43
|
var Plures = class {
|
|
20
44
|
base;
|
|
@@ -24,8 +48,7 @@ var Plures = class {
|
|
|
24
48
|
ua;
|
|
25
49
|
constructor(apiKey, opts = {}) {
|
|
26
50
|
const key2 = apiKey ?? (typeof process !== "undefined" ? process.env?.PLURES_API_KEY : void 0);
|
|
27
|
-
if (!key2)
|
|
28
|
-
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");
|
|
51
|
+
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");
|
|
29
52
|
this.apiKey = key2;
|
|
30
53
|
this.ua = opts.userAgent ?? `plures-js/${VERSION}`;
|
|
31
54
|
this.app = opts.app ?? (typeof process !== "undefined" ? process.env?.PLURES_APP : void 0) ?? null;
|
|
@@ -43,8 +66,7 @@ var Plures = class {
|
|
|
43
66
|
continue;
|
|
44
67
|
}
|
|
45
68
|
const data = await r.json().catch(() => ({}));
|
|
46
|
-
if (!r.ok)
|
|
47
|
-
throw new PluresError(data?.error?.message ?? `HTTP ${r.status}`, r.status, data?.error?.type ?? "api_error");
|
|
69
|
+
if (!r.ok) throw new PluresError(data?.error?.message ?? `HTTP ${r.status}`, r.status, data?.error?.type ?? "api_error");
|
|
48
70
|
return data;
|
|
49
71
|
}
|
|
50
72
|
}
|
|
@@ -62,10 +84,8 @@ var Plures = class {
|
|
|
62
84
|
const end = Date.now() + (opts.timeoutMs ?? 10 * 6e4);
|
|
63
85
|
for (; ; ) {
|
|
64
86
|
const t = await this.tasks.retrieve(id);
|
|
65
|
-
if (t.status !== "pending")
|
|
66
|
-
|
|
67
|
-
if (Date.now() > end)
|
|
68
|
-
return t;
|
|
87
|
+
if (t.status !== "pending") return t;
|
|
88
|
+
if (Date.now() > end) return t;
|
|
69
89
|
await sleep(opts.intervalMs ?? 3e3);
|
|
70
90
|
}
|
|
71
91
|
}
|
|
@@ -120,6 +140,8 @@ var Plures = class {
|
|
|
120
140
|
/** Starts a map run where Plures runs Claude (CI with no Anthropic key). npx plures map does this. */
|
|
121
141
|
startRun: (app2) => this.req("POST", `/api/v1/apps/${encodeURIComponent(app2)}/map_runs`, {}),
|
|
122
142
|
runs: (app2) => this.req("GET", `/api/v1/apps/${encodeURIComponent(app2)}/map_runs`),
|
|
143
|
+
/** While mapping, which files the coding agent is reading, shown live on the map page. File names only. npx plures map does this. */
|
|
144
|
+
progress: (app2, p) => this.req("POST", `/api/v1/apps/${encodeURIComponent(app2)}/map/progress`, p),
|
|
123
145
|
/** The GitHub Actions workflow that maps a repo on every push to its main branch. */
|
|
124
146
|
workflow: async (app2, branch2 = "main") => {
|
|
125
147
|
const r = await fetch(`${this.base}/api/v1/map_workflow?app=${encodeURIComponent(app2)}&branch=${encodeURIComponent(branch2)}`);
|
|
@@ -188,28 +210,195 @@ var Plures = class {
|
|
|
188
210
|
static async verifyWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) {
|
|
189
211
|
const parts = Object.fromEntries((signatureHeader ?? "").split(",").map((p) => p.split("=", 2)));
|
|
190
212
|
const t = Number(parts.t);
|
|
191
|
-
if (!t || !parts.v1)
|
|
192
|
-
|
|
193
|
-
if (Math.abs(Date.now() / 1e3 - t) > toleranceSec)
|
|
194
|
-
throw new Error("Webhook timestamp is too old");
|
|
213
|
+
if (!t || !parts.v1) throw new Error("Missing Plures-Signature header");
|
|
214
|
+
if (Math.abs(Date.now() / 1e3 - t) > toleranceSec) throw new Error("Webhook timestamp is too old");
|
|
195
215
|
const key2 = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
196
216
|
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key2, new TextEncoder().encode(`${t}.${rawBody}`)));
|
|
197
217
|
const expected = Array.from(mac, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
198
|
-
if (!timingSafeEqual(expected, parts.v1))
|
|
199
|
-
throw new Error("Webhook signature does not match");
|
|
218
|
+
if (!timingSafeEqual(expected, parts.v1)) throw new Error("Webhook signature does not match");
|
|
200
219
|
return JSON.parse(rawBody);
|
|
201
220
|
}
|
|
202
221
|
};
|
|
203
222
|
function timingSafeEqual(a, b) {
|
|
204
|
-
if (a.length !== b.length)
|
|
205
|
-
return false;
|
|
223
|
+
if (a.length !== b.length) return false;
|
|
206
224
|
let diff = 0;
|
|
207
|
-
for (let i = 0; i < a.length; i++)
|
|
208
|
-
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
225
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
209
226
|
return diff === 0;
|
|
210
227
|
}
|
|
211
228
|
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
212
229
|
|
|
230
|
+
// src/term.ts
|
|
231
|
+
var out = process.stdout;
|
|
232
|
+
var fancy = Boolean(out.isTTY && !process.env.NO_COLOR && !process.env.CI && process.env.TERM !== "dumb");
|
|
233
|
+
var rgb = (r, g, b) => (s) => `\x1B[38;2;${r};${g};${b}m${s}\x1B[0m`;
|
|
234
|
+
var LANES = [rgb(91, 75, 255), rgb(74, 107, 250), rgb(50, 144, 238), rgb(25, 198, 230)];
|
|
235
|
+
var C = {
|
|
236
|
+
text: rgb(243, 244, 247),
|
|
237
|
+
dim: rgb(110, 116, 136),
|
|
238
|
+
mute: rgb(160, 166, 186),
|
|
239
|
+
coral: rgb(255, 142, 122),
|
|
240
|
+
violet: rgb(185, 180, 255),
|
|
241
|
+
cyan: rgb(127, 227, 244),
|
|
242
|
+
amber: rgb(245, 184, 74),
|
|
243
|
+
bold: (s) => `\x1B[1m${s}\x1B[22m`
|
|
244
|
+
};
|
|
245
|
+
var width = () => Math.max(40, Math.min(out.columns || 80, 110));
|
|
246
|
+
var visible = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
247
|
+
var cut = (s, n) => s.length > n ? `${s.slice(0, Math.max(1, n - 1))}\u2026` : s;
|
|
248
|
+
function banner(app2, where) {
|
|
249
|
+
const bar = LANES.map((c2, i) => c2("\u2501".repeat(8 - i))).join(" ");
|
|
250
|
+
out.write(`
|
|
251
|
+
${bar}
|
|
252
|
+
|
|
253
|
+
${C.bold(C.text("Plures"))} ${C.dim("map")} ${C.mute(app2)}
|
|
254
|
+
${C.dim(where)}
|
|
255
|
+
|
|
256
|
+
`);
|
|
257
|
+
}
|
|
258
|
+
function liveLine() {
|
|
259
|
+
const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
260
|
+
let i = 0, count = 0, file = "", phase = "reading", timer = null;
|
|
261
|
+
const draw = () => {
|
|
262
|
+
const spin = LANES[Math.floor(i / 2) % 4](frames[i % frames.length]);
|
|
263
|
+
i++;
|
|
264
|
+
const left = phase === "drawing" ? ` ${spin} ${C.violet("Drawing the map")} ${C.dim(`from ${count} ${count === 1 ? "file" : "files"}`)}` : ` ${spin} ${C.bold(C.text(String(count).padStart(3)))} ${C.dim(count === 1 ? "file read " : "files read")} `;
|
|
265
|
+
const room = width() - visible(left).length - 2;
|
|
266
|
+
const f = phase === "reading" && file ? (() => {
|
|
267
|
+
const slash = file.lastIndexOf("/");
|
|
268
|
+
const s = cut(file, room);
|
|
269
|
+
return slash >= 0 && s === file ? `${C.dim(file.slice(0, slash + 1))}${C.text(file.slice(slash + 1))}` : C.text(s);
|
|
270
|
+
})() : "";
|
|
271
|
+
out.write(`\r\x1B[K${left}${f}`);
|
|
272
|
+
};
|
|
273
|
+
return {
|
|
274
|
+
start() {
|
|
275
|
+
out.write("\x1B[?25l");
|
|
276
|
+
draw();
|
|
277
|
+
timer = setInterval(draw, 80);
|
|
278
|
+
},
|
|
279
|
+
read(path) {
|
|
280
|
+
count++;
|
|
281
|
+
file = path;
|
|
282
|
+
},
|
|
283
|
+
drawing() {
|
|
284
|
+
phase = "drawing";
|
|
285
|
+
},
|
|
286
|
+
stop() {
|
|
287
|
+
if (timer) clearInterval(timer);
|
|
288
|
+
timer = null;
|
|
289
|
+
out.write("\r\x1B[K\x1B[?25h");
|
|
290
|
+
},
|
|
291
|
+
get count() {
|
|
292
|
+
return count;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
if (fancy) for (const sig of ["SIGINT", "SIGTERM"]) process.once(sig, () => {
|
|
297
|
+
out.write("\x1B[?25h\n");
|
|
298
|
+
process.exit(130);
|
|
299
|
+
});
|
|
300
|
+
var ICON = { input: "\u25B8", model: "\u25CE", tool: "\u2315", decision: "\u25C7", action: "\u2197", data: "\u25A4", output: "\u21A6", human: "\u263A" };
|
|
301
|
+
function drawMap(m, url) {
|
|
302
|
+
const nodes = new Map(m.nodes.map((n) => [n.id, n]));
|
|
303
|
+
const cps = new Map(m.checkpoints.filter((c2) => c2.status !== "dismissed").map((c2) => [c2.at, c2]));
|
|
304
|
+
const W = width();
|
|
305
|
+
const nb = new Map(m.nodes.map((n) => [n.id, []]));
|
|
306
|
+
for (const e of m.edges) {
|
|
307
|
+
nb.get(e.from)?.push(e.to);
|
|
308
|
+
nb.get(e.to)?.push(e.from);
|
|
309
|
+
}
|
|
310
|
+
const seen = /* @__PURE__ */ new Set(), flows = [];
|
|
311
|
+
for (const n of m.nodes) {
|
|
312
|
+
if (seen.has(n.id)) continue;
|
|
313
|
+
const part = [], st = [n.id];
|
|
314
|
+
seen.add(n.id);
|
|
315
|
+
while (st.length) {
|
|
316
|
+
const v = st.pop();
|
|
317
|
+
part.push(v);
|
|
318
|
+
for (const w of nb.get(v) ?? []) if (!seen.has(w)) {
|
|
319
|
+
seen.add(w);
|
|
320
|
+
st.push(w);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
flows.push(m.nodes.map((x) => x.id).filter((id) => part.includes(id)));
|
|
324
|
+
}
|
|
325
|
+
const chip = (id) => {
|
|
326
|
+
const n = nodes.get(id), c2 = cps.get(id);
|
|
327
|
+
const label = cut(n.label, 34);
|
|
328
|
+
const tag = !c2 ? "" : c2.status === "added" ? ` ${C.cyan("\u2713 checked")}` : ` ${C.violet("\u25CC check suggested")}`;
|
|
329
|
+
if (n.kind === "action") {
|
|
330
|
+
if (c2?.status === "added") return `${C.cyan(ICON.action)} ${C.text(label)}${tag}`;
|
|
331
|
+
const risky = n.risk === "high" || n.risk === "medium";
|
|
332
|
+
return `${risky ? C.coral(ICON.action) : C.mute(ICON.action)} ${risky ? C.coral(label) : C.mute(label)}${tag}`;
|
|
333
|
+
}
|
|
334
|
+
return `${C.dim(ICON[n.kind] ?? "\xB7")} ${C.mute(label)}${tag}`;
|
|
335
|
+
};
|
|
336
|
+
out.write("\n");
|
|
337
|
+
for (const ids of flows.filter((f) => f.length > 1)) {
|
|
338
|
+
const set = new Set(ids);
|
|
339
|
+
const es = m.edges.filter((e) => set.has(e.from) && set.has(e.to));
|
|
340
|
+
const layer = new Map(ids.map((id) => [id, 0]));
|
|
341
|
+
const order = [], state = /* @__PURE__ */ new Map(), back = /* @__PURE__ */ new Set();
|
|
342
|
+
const visit = (v) => {
|
|
343
|
+
state.set(v, 1);
|
|
344
|
+
for (const e of es.filter((x) => x.from === v)) {
|
|
345
|
+
if (state.get(e.to) === 1) back.add(`${v}>${e.to}`);
|
|
346
|
+
else if (!state.get(e.to)) visit(e.to);
|
|
347
|
+
}
|
|
348
|
+
state.set(v, 2);
|
|
349
|
+
order.push(v);
|
|
350
|
+
};
|
|
351
|
+
for (const id of ids) if (!state.get(id)) visit(id);
|
|
352
|
+
for (const v of order.reverse()) for (const e of es) if (e.from === v && !back.has(`${v}>${e.to}`)) layer.set(e.to, Math.max(layer.get(e.to), layer.get(v) + 1));
|
|
353
|
+
const rows = [];
|
|
354
|
+
for (const id of ids) (rows[layer.get(id)] ??= []).push(id);
|
|
355
|
+
const root = rows[0]?.length === 1 ? rows[0][0] : null;
|
|
356
|
+
out.write(` ${LANES[flows.indexOf(ids) % 4]("\u25CF")} ${C.bold(C.text(cut(root ? nodes.get(root).label : "Flow", W - 6)))}
|
|
357
|
+
`);
|
|
358
|
+
rows.forEach((row, ri) => {
|
|
359
|
+
if (!row || ri === 0 && root) return;
|
|
360
|
+
const lines = [[]];
|
|
361
|
+
let used = 6;
|
|
362
|
+
for (const id of row) {
|
|
363
|
+
const w = visible(chip(id)).length + 4;
|
|
364
|
+
if (used + w > W && lines[lines.length - 1].length) {
|
|
365
|
+
lines.push([]);
|
|
366
|
+
used = 6;
|
|
367
|
+
}
|
|
368
|
+
lines[lines.length - 1].push(id);
|
|
369
|
+
used += w;
|
|
370
|
+
}
|
|
371
|
+
for (const ln of lines) out.write(` ${C.dim("\u2502")} ${ln.map((id) => chip(id)).join(C.dim(" \xB7 "))}
|
|
372
|
+
`);
|
|
373
|
+
});
|
|
374
|
+
out.write("\n");
|
|
375
|
+
}
|
|
376
|
+
const actions = m.nodes.filter((n) => n.kind === "action");
|
|
377
|
+
const on = actions.filter((a) => cps.get(a.id)?.status === "added").length;
|
|
378
|
+
const sugg = m.checkpoints.filter((c2) => c2.status === "suggested").length;
|
|
379
|
+
out.write(` ${LANES.map((c2) => c2("\u2501")).join("")} ${C.bold(C.text(sugg ? `Plures found ${sugg} ${sugg === 1 ? "place" : "places"} a person should check.` : "Your map is ready."))}
|
|
380
|
+
`);
|
|
381
|
+
out.write(` ${C.mute(`${on} of ${actions.length} ${actions.length === 1 ? "action" : "actions"} with consequences ${on === 1 ? "has" : "have"} a human check.`)}
|
|
382
|
+
|
|
383
|
+
`);
|
|
384
|
+
out.write(` ${C.text("See it build:")} ${C.cyan(url)}
|
|
385
|
+
|
|
386
|
+
`);
|
|
387
|
+
}
|
|
388
|
+
async function openBrowser(url) {
|
|
389
|
+
const { spawn: spawn2 } = await import("child_process");
|
|
390
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
391
|
+
try {
|
|
392
|
+
const p = spawn2(cmd, args, { stdio: "ignore", detached: true });
|
|
393
|
+
p.on("error", () => {
|
|
394
|
+
});
|
|
395
|
+
p.unref();
|
|
396
|
+
return true;
|
|
397
|
+
} catch {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
213
402
|
// src/cli.ts
|
|
214
403
|
var argv = process.argv.slice(2);
|
|
215
404
|
var flag = (name) => {
|
|
@@ -220,6 +409,7 @@ var flag = (name) => {
|
|
|
220
409
|
return v;
|
|
221
410
|
};
|
|
222
411
|
var branch = flag("branch");
|
|
412
|
+
var noOpen = argv.includes("--no-open") ? (argv.splice(argv.indexOf("--no-open"), 1), true) : false;
|
|
223
413
|
var app = flag("app") ?? process.env.PLURES_APP ?? "default";
|
|
224
414
|
var key = flag("key") ?? process.env.PLURES_API_KEY;
|
|
225
415
|
var base = flag("base-url") ?? process.env.PLURES_BASE_URL ?? "https://plures.si";
|
|
@@ -247,14 +437,15 @@ async function upload(file) {
|
|
|
247
437
|
} catch {
|
|
248
438
|
die(`${file} isn't valid JSON.`);
|
|
249
439
|
}
|
|
250
|
-
console.log(dim(`Sending the map to Plures. No source code is sent.`));
|
|
440
|
+
if (!fancy) console.log(dim(`Sending the map to Plures. No source code is sent.`));
|
|
251
441
|
let m;
|
|
252
442
|
try {
|
|
253
443
|
m = await client().maps.upload(app, map2);
|
|
254
444
|
} catch (e) {
|
|
255
445
|
die(e instanceof PluresError ? `Plures said: ${e.message}` : String(e));
|
|
256
446
|
}
|
|
257
|
-
|
|
447
|
+
if (fancy) drawMap(m, m.url ?? `${base}/dashboard/apps/${m.app}/map`);
|
|
448
|
+
else report(m);
|
|
258
449
|
}
|
|
259
450
|
function report(m) {
|
|
260
451
|
const nodes = new Map(m.nodes.map((n) => [n.id, n]));
|
|
@@ -304,16 +495,89 @@ With plures-mcp connected, just ask: ${cyan("Map this app for Plures")}`);
|
|
|
304
495
|
childEnv.ANTHROPIC_API_KEY = run.token;
|
|
305
496
|
console.log(dim(run.free ? "Plures is mapping this repo. Your first map is free." : `Plures is mapping this repo, at most $${(run.max_cents / 100).toFixed(2)} from your credit.`));
|
|
306
497
|
}
|
|
307
|
-
|
|
498
|
+
const pageUrl = `${base}/dashboard/apps/${encodeURIComponent(app)}/map`;
|
|
499
|
+
if (fancy) {
|
|
500
|
+
banner(app, "Reading this repo on your machine with Claude Code. Only file names and the map go to Plures.");
|
|
501
|
+
if (key && !noOpen && await openBrowser(pageUrl)) console.log(` ${dim("Watch it live in your browser:")} ${cyan(pageUrl)}
|
|
502
|
+
`);
|
|
503
|
+
} else console.log(`Reading this repo ${process.env.CI ? "on this runner" : "on your machine"} with Claude Code. ${dim("Only the map is sent to Plures.")}
|
|
308
504
|
`);
|
|
309
505
|
const prompt = `${guide}
|
|
310
506
|
|
|
311
507
|
Write the map to ${FILE} in the current directory now. Read the code; don't run it or change anything else.`;
|
|
508
|
+
const live = key ? client() : null;
|
|
509
|
+
const session = (0, import_node_crypto.randomBytes)(12).toString("base64url");
|
|
510
|
+
const source = process.env.GITHUB_ACTIONS ? "github" : "cli";
|
|
511
|
+
let queue = [], phase = "reading", sent = "", reads = 0;
|
|
512
|
+
const flush = async () => {
|
|
513
|
+
if (!live) return;
|
|
514
|
+
const events = queue;
|
|
515
|
+
queue = [];
|
|
516
|
+
if (!events.length && sent === phase) return;
|
|
517
|
+
sent = phase;
|
|
518
|
+
await live.maps.progress(app, { session, phase, source, events }).catch(() => {
|
|
519
|
+
});
|
|
520
|
+
};
|
|
521
|
+
const tick = setInterval(() => {
|
|
522
|
+
void flush();
|
|
523
|
+
}, 900);
|
|
524
|
+
void flush();
|
|
525
|
+
const rel = (p) => {
|
|
526
|
+
if (typeof p !== "string" || !p) return null;
|
|
527
|
+
const r = (0, import_node_path.isAbsolute)(p) ? (0, import_node_path.relative)(process.cwd(), p) : p;
|
|
528
|
+
return r.startsWith("..") ? null : r.replace(/\\/g, "/");
|
|
529
|
+
};
|
|
530
|
+
let result = "";
|
|
531
|
+
const ticker = fancy ? liveLine() : null;
|
|
532
|
+
ticker?.start();
|
|
312
533
|
const code = await new Promise((resolve) => {
|
|
313
|
-
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", "
|
|
534
|
+
const p = (0, import_node_child_process.spawn)("claude", ["-p", prompt, "--output-format", "stream-json", "--verbose", "--allowedTools", "Read,Grep,Glob,Write,Bash(git rev-parse:*),Bash(git remote:*)"], { stdio: ["ignore", "pipe", "inherit"], env: childEnv });
|
|
535
|
+
let buf = "";
|
|
536
|
+
p.stdout.on("data", (chunk) => {
|
|
537
|
+
buf += chunk.toString();
|
|
538
|
+
let i;
|
|
539
|
+
while ((i = buf.indexOf("\n")) >= 0) {
|
|
540
|
+
const line = buf.slice(0, i);
|
|
541
|
+
buf = buf.slice(i + 1);
|
|
542
|
+
let ev;
|
|
543
|
+
try {
|
|
544
|
+
ev = JSON.parse(line);
|
|
545
|
+
} catch {
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (ev.type === "result" && typeof ev.result === "string") result = ev.result;
|
|
549
|
+
for (const b of ev.type === "assistant" ? ev.message?.content ?? [] : []) {
|
|
550
|
+
if (b.type !== "tool_use") continue;
|
|
551
|
+
if (b.name === "Read") {
|
|
552
|
+
const f = rel(b.input?.file_path);
|
|
553
|
+
if (!f) continue;
|
|
554
|
+
reads++;
|
|
555
|
+
queue.push({ path: f, tool: "Read" });
|
|
556
|
+
if (ticker) ticker.read(f);
|
|
557
|
+
else console.log(` ${dim(String(reads).padStart(3))} ${dim("reading")} ${f}`);
|
|
558
|
+
} else if (b.name === "Write" && String(b.input?.file_path ?? "").endsWith(FILE)) {
|
|
559
|
+
phase = "drawing";
|
|
560
|
+
if (ticker) ticker.drawing();
|
|
561
|
+
else console.log(`
|
|
562
|
+
${violet("drawing")} the map`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
});
|
|
314
567
|
p.on("close", (n) => resolve(n ?? 1));
|
|
315
568
|
p.on("error", () => resolve(1));
|
|
316
569
|
});
|
|
570
|
+
clearInterval(tick);
|
|
571
|
+
ticker?.stop();
|
|
572
|
+
if (result && !fancy) console.log(`
|
|
573
|
+
${result.trim()}
|
|
574
|
+
`);
|
|
575
|
+
if (fancy) console.log(` ${cyan("\u2713")} ${reads} ${reads === 1 ? "file" : "files"} read. ${dim("Sending the map to Plures. No source code is sent.")}`);
|
|
576
|
+
const ok = code === 0 && (0, import_node_fs.existsSync)(FILE) && (0, import_node_fs.statSync)(FILE).mtimeMs !== before;
|
|
577
|
+
if (!ok) {
|
|
578
|
+
phase = "failed";
|
|
579
|
+
await flush();
|
|
580
|
+
} else await flush();
|
|
317
581
|
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.`);
|
|
318
582
|
await upload(FILE);
|
|
319
583
|
}
|