specpi 0.26.0 → 0.28.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.
Files changed (31) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +37 -3
  3. package/SECURITY_MODEL.md +44 -4
  4. package/THIRD_PARTY.md +9 -1
  5. package/extensions/jev-advisor/broker.mjs +277 -0
  6. package/extensions/jev-advisor/client.mjs +172 -0
  7. package/extensions/jev-advisor/config.mjs +270 -0
  8. package/extensions/jev-advisor/consent.mjs +133 -0
  9. package/extensions/jev-advisor/gate.mjs +263 -0
  10. package/extensions/jev-advisor/index.ts +999 -0
  11. package/extensions/jev-advisor/key-source.mjs +252 -0
  12. package/extensions/jev-advisor/layer.mjs +169 -0
  13. package/extensions/jev-advisor/ledger.mjs +138 -0
  14. package/extensions/jev-advisor/questions/capabilities.mjs +124 -0
  15. package/extensions/jev-advisor/questions/compaction.mjs +153 -0
  16. package/extensions/jev-advisor/questions/gap.mjs +140 -0
  17. package/extensions/jev-advisor/questions/guard.mjs +168 -0
  18. package/extensions/jev-advisor/questions/progress.mjs +195 -0
  19. package/extensions/jev-advisor/questions/retention.mjs +188 -0
  20. package/extensions/jev-advisor/questions/sources.mjs +91 -0
  21. package/extensions/jev-advisor/questions/untrusted.mjs +69 -0
  22. package/extensions/jev-advisor/risk.mjs +442 -0
  23. package/extensions/jev-advisor/sanitize.mjs +0 -0
  24. package/extensions/jev-advisor/usage.mjs +92 -0
  25. package/extensions/tool-wishlist/authoring-tools.mjs +42 -0
  26. package/extensions/tool-wishlist/index.ts +11 -0
  27. package/extensions/workflow-controls/capabilities.mjs +26 -0
  28. package/extensions/workflow-controls/index.ts +2 -2
  29. package/package.json +1 -1
  30. package/scripts/packages.mjs +56 -0
  31. package/scripts/specpi.mjs +73 -4
@@ -0,0 +1,277 @@
1
+ // The only thing in this extension that touches the network. Systems are questions plus a gate;
2
+ // they never hold a client, so two systems firing at one hook cost one call rather than two.
3
+ //
4
+ // Every gate is checked here, in one order, before anything leaves the process: master switch,
5
+ // per-system switch, human consent, per-system budget, session total budget, then the byte budget
6
+ // inside sanitize.
7
+ //
8
+ // AWAIT ONLY THE SYSTEMS THAT MUTATE WHAT THEY INSPECT. Retention must be awaited, because its
9
+ // answer replaces the tool result it was asked about; so must compaction, the branch hook and the
10
+ // two tool_call systems, which return a patch or edit `event.input` in place. A system that acts on
11
+ // a later turn must not be awaited: at roughly 300ms a call, a turn-level system firing thirty
12
+ // times would add nine seconds to an attempt that takes a hundred and thirty, to deliver advice
13
+ // that was never going to change the turn it was asked during.
14
+
15
+ import { randomUUID } from "node:crypto";
16
+ import { SYSTEM_NAMES, loadSettings } from "./config.mjs";
17
+ import { ensureConsent } from "./consent.mjs";
18
+ import { buildState } from "./sanitize.mjs";
19
+ import { ask } from "./client.mjs";
20
+ import { payloadDigest, record } from "./ledger.mjs";
21
+ import { writeUsage } from "./usage.mjs";
22
+
23
+ export const SYSTEM_LABELS = Object.freeze({
24
+ retention: "Tool-result retention",
25
+ compaction: "Compaction guidance",
26
+ gap: "Capability-gap triage",
27
+ sources: "Delegation source ranking",
28
+ progress: "Progress and thrash detection",
29
+ untrusted: "Untrusted-content classification",
30
+ capability: "Turn-zero capability arming",
31
+ guard: "Command guard",
32
+ });
33
+
34
+ export function createBroker(options = {}) {
35
+ // Injected in tests so master-off can be proven as "the transport was never reached" rather
36
+ // than "no socket was observed".
37
+ const transport = options.ask ?? ask;
38
+ const readSettings = options.loadSettings ?? loadSettings;
39
+ const resolveConsent = options.ensureConsent ?? ensureConsent;
40
+ const write = options.record ?? record;
41
+ // Separate from `record` because it answers a different question and is read by a different
42
+ // reader. The ledger is an audit trail for a person; this is a live counter for SpecPi Chat,
43
+ // which runs in another process and cannot see `callsUsed`.
44
+ const publish = options.recordUsage ?? writeUsage;
45
+
46
+ let callsUsed = 0;
47
+ let generation = 0;
48
+ let session = "";
49
+ let startedAt = "";
50
+ const usedBySystem = new Map();
51
+ const effects = new Map();
52
+ const warned = new Set();
53
+
54
+ /** Zero counts for every system, so a reader never has to distinguish absent from unused. */
55
+ const snapshot = (active) => {
56
+ const settings = readSettings();
57
+
58
+ return {
59
+ schema: 1,
60
+ session,
61
+ startedAt,
62
+ updatedAt: new Date().toISOString(),
63
+ active,
64
+ calls: callsUsed,
65
+ budgets: settings.budgets,
66
+ systems: Object.fromEntries(
67
+ SYSTEM_NAMES.map((name) => [
68
+ name,
69
+ {
70
+ calls: usedBySystem.get(name) ?? 0,
71
+ applied: effects.get(name)?.applied ?? 0,
72
+ failed: effects.get(name)?.failed ?? 0,
73
+ savedBytes: effects.get(name)?.savedBytes ?? 0,
74
+ },
75
+ ]),
76
+ ),
77
+ };
78
+ };
79
+
80
+ const clear = () => {
81
+ callsUsed = 0;
82
+ usedBySystem.clear();
83
+ effects.clear();
84
+ warned.clear();
85
+ // Bumped before anything else so an answer still in flight from the previous session is
86
+ // discarded rather than counted against the new one.
87
+ generation += 1;
88
+ };
89
+
90
+ /**
91
+ * Publishing is itself gated on the master switch. With the layer off this extension is meant
92
+ * to leave no trace at all, and a counts file appearing in every Pi session on every machine
93
+ * that merely has SpecPi installed is a trace. Once a call has been made there is something
94
+ * worth saying, so the count keeps being published for the rest of the session even if the
95
+ * master switch is turned back off.
96
+ */
97
+ const publishIf = (active) => {
98
+ if (callsUsed > 0 || readSettings().master === true) {
99
+ publish(snapshot(active));
100
+ }
101
+ };
102
+
103
+ const reset = () => {
104
+ clear();
105
+ session = randomUUID();
106
+ startedAt = new Date().toISOString();
107
+ publishIf(true);
108
+ };
109
+
110
+ /**
111
+ * End of session. The counts are published one last time with `active` false rather than
112
+ * cleared, because a reader that found no file could not tell "this layer has never run" from
113
+ * "the session that just ended spent its whole budget", and the second is the more useful
114
+ * thing to be able to see after the fact.
115
+ */
116
+ const finish = () => {
117
+ publishIf(false);
118
+ clear();
119
+ };
120
+
121
+ /**
122
+ * Running out of budget used to be indistinguishable from a system that had nothing to say.
123
+ * Both produce silence, and silence is this layer's normal state, so a session could spend an
124
+ * hour with retention switched on and quietly dead without anything ever saying so. The notice
125
+ * fires once per system per session -- repeating it every turn would be its own nuisance -- and
126
+ * only where there is a human to read it.
127
+ */
128
+ const warnExhausted = (system, ctx, scope) => {
129
+ if (warned.has(system) || !ctx?.hasUI || typeof ctx?.ui?.notify !== "function") {
130
+ return;
131
+ }
132
+
133
+ warned.add(system);
134
+ try {
135
+ ctx.ui.notify(
136
+ scope === "system"
137
+ ? `Jev: the ${SYSTEM_LABELS[system] ?? system} budget for this session is spent, so that system is now off until the session ends. Raise it in SpecPi Chat under package settings, or in the Jev layer's own settings file.`
138
+ : `Jev: this session's total call budget is spent, so the whole advisor is now quiet until the session ends. Raise it in SpecPi Chat under package settings, or in the Jev layer's own settings file.`,
139
+ "info",
140
+ );
141
+ } catch {
142
+ // A notice that cannot be delivered must not fail the call it was reporting on.
143
+ }
144
+ };
145
+
146
+ const status = () => {
147
+ const settings = readSettings();
148
+
149
+ return {
150
+ master: settings.master,
151
+ systems: settings.systems,
152
+ session,
153
+ callsUsed,
154
+ budgets: settings.budgets,
155
+ usedBySystem: Object.fromEntries(usedBySystem),
156
+ };
157
+ };
158
+
159
+ /**
160
+ * Ask one batch for one system. Returns `{ ok: false, reason }` for every refusal so a caller
161
+ * can log why it got no advice without having to distinguish "switched off" from "timed out".
162
+ *
163
+ * `decide` is how the ledger learns what the advice did. The ledger recorded bytes sent and
164
+ * never whether the answer was taken, so a system's effect could only be inferred from a cost
165
+ * delta it may not have caused. The callback runs here, before the ledger write, because that
166
+ * is the only point where the answers and the audit line exist together; its `decision` is
167
+ * handed back so the caller does not gate the same answers twice.
168
+ */
169
+ const request = async ({ system, state, questions, ctx, root, maxBytes, timeoutMs, signal, decide }) => {
170
+ const settings = readSettings();
171
+ if (!settings.master) {
172
+ return { ok: false, reason: "master-off", answers: {} };
173
+ }
174
+
175
+ if (settings.systems[system] !== true) {
176
+ return { ok: false, reason: "system-off", answers: {} };
177
+ }
178
+
179
+ // Per-system first, so an exhausted turn-level system reports its own exhaustion rather
180
+ // than looking like the session as a whole ran out.
181
+ if ((usedBySystem.get(system) ?? 0) >= (settings.budgets?.[system] ?? 0)) {
182
+ warnExhausted(system, ctx, "system");
183
+
184
+ return { ok: false, reason: "system-budget-exhausted", answers: {} };
185
+ }
186
+
187
+ if (callsUsed >= (settings.budgets?.total ?? 0)) {
188
+ warnExhausted("total", ctx, "total");
189
+
190
+ return { ok: false, reason: "budget-exhausted", answers: {} };
191
+ }
192
+
193
+ const consented = await resolveConsent(ctx, SYSTEM_LABELS[system] ?? system);
194
+ if (!consented) {
195
+ return { ok: false, reason: "no-consent", answers: {} };
196
+ }
197
+
198
+ // Settings can change while the dialog is open, and a session can end under it.
199
+ const current = readSettings();
200
+ if (!current.master || current.systems[system] !== true) {
201
+ return { ok: false, reason: "master-off", answers: {} };
202
+ }
203
+
204
+ const built = buildState(state, { root, maxBytes });
205
+ const questionKeys = Object.keys(questions);
206
+ callsUsed += 1;
207
+ usedBySystem.set(system, (usedBySystem.get(system) ?? 0) + 1);
208
+ const startedGeneration = generation;
209
+ const result = await transport(built.state, questions, { timeoutMs, signal });
210
+ // The session can end under a call that was never awaited, which is the normal shape of a
211
+ // turn-level system: the payload has already left the machine, and the answer now belongs
212
+ // to a session that no longer exists. It must not be acted on. It must still be recorded --
213
+ // the ledger's whole claim is that every transmission appears in it, and a run that sent 44
214
+ // and logged 43 is how this was found. So the line is written either way and says which.
215
+ const stale = startedGeneration !== generation;
216
+
217
+ // A gate that throws must not turn into a failed call: the caller's own catch would have
218
+ // swallowed it anyway, and recording it as unapplied is the truthful line.
219
+ let outcome = { applied: false };
220
+ if (!stale && result.ok && typeof decide === "function") {
221
+ try {
222
+ outcome = decide(result.answers) ?? { applied: false };
223
+ } catch {
224
+ outcome = { applied: false, gateThrew: true };
225
+ }
226
+ }
227
+
228
+ write({
229
+ system,
230
+ questionKeys,
231
+ stateBytes: built.bytes,
232
+ stateTruncated: built.truncated,
233
+ payloadSha256: payloadDigest({ state: built.state, questions }),
234
+ ok: result.ok,
235
+ reason: result.ok ? undefined : result.reason,
236
+ // A sent payload whose answer arrived too late to use. Distinguished from a refusal,
237
+ // because nothing was refused: it was asked, answered, and discarded.
238
+ discarded: stale ? true : undefined,
239
+ // Whether the advice changed anything, and what it saved when the change was a
240
+ // shortening. Zero is a real answer here and means "asked, and kept the result whole".
241
+ applied: outcome.applied === true,
242
+ // And why not, when nothing changed. Without this a system that asks and never acts is
243
+ // indistinguishable from one whose gate can never be satisfied, which is the exact
244
+ // failure the calibration pass had to go looking for by hand.
245
+ outcome: stale ? "session-changed" : typeof outcome.reason === "string" ? outcome.reason : undefined,
246
+ savedBytes: Number.isFinite(outcome.savedBytes) ? Math.max(0, Math.round(outcome.savedBytes)) : 0,
247
+ gateThrew: outcome.gateThrew === true ? true : undefined,
248
+ latencyMs: result.latencyMs,
249
+ model: result.model,
250
+ // Values only, never the state that produced them: enough to plot a calibration curve.
251
+ answers: Object.fromEntries(
252
+ Object.entries(result.answers ?? {}).map(([name, answer]) => [
253
+ name,
254
+ { kind: answer.kind, value: answer.value, confidence: answer.confidence },
255
+ ]),
256
+ ),
257
+ });
258
+
259
+ if (stale) {
260
+ // Counted against the session it was made in, which has already been published and
261
+ // cleared. Adding it to the new session's running total would attribute one session's
262
+ // spend to the next one.
263
+ return { ok: false, reason: "session-changed", answers: {} };
264
+ }
265
+
266
+ const effect = effects.get(system) ?? { applied: 0, failed: 0, savedBytes: 0 };
267
+ effect.applied += outcome.applied === true ? 1 : 0;
268
+ effect.failed += result.ok ? 0 : 1;
269
+ effect.savedBytes += Number.isFinite(outcome.savedBytes) ? Math.max(0, Math.round(outcome.savedBytes)) : 0;
270
+ effects.set(system, effect);
271
+ publishIf(true);
272
+
273
+ return { ...result, decision: outcome.decision };
274
+ };
275
+
276
+ return { request, reset, finish, status };
277
+ }
@@ -0,0 +1,172 @@
1
+ // One POST, no SDK. AGENTS.md forbids adding executable dependencies without need, and a single
2
+ // JSON request does not need one.
3
+ //
4
+ // Every failure mode returns `unavailable` rather than throwing: a timeout, an HTTP error, a
5
+ // missing key, a malformed body or an unparseable answer all mean "no advice", and the caller runs
6
+ // the path it would have run before this extension existed. That is fail-silent, not fail-closed —
7
+ // nothing here is ever the reason a tool is blocked.
8
+
9
+ import { backend, resolveKey } from "./key-source.mjs";
10
+
11
+ /**
12
+ * Where a key comes from is resolved in key-source.mjs, which follows Pi's own order: the
13
+ * `auth.json` entry `/login openrouter` writes, then the environment variable.
14
+ *
15
+ * Every one of these defaults its route to `backend()` rather than to the string "openrouter", so a
16
+ * no-arg call resolves against the backend actually in force. That matters because it briefly did
17
+ * not: when `apiKey` was first replaced by a re-export of a function whose parameter defaulted to a
18
+ * literal, `apiKey()` returned a stored OpenRouter key under `JEV_BACKEND=typesafe` -- a script's
19
+ * `if (!apiKey())` guard passed and it spent a run, while every request underneath came back
20
+ * `no-key`. The fix belongs at the definition, which is where it now is; a wrapper here would only
21
+ * have hidden that three sibling exports had the same defect.
22
+ */
23
+ export { backend, keyEnvName, keyPresent, keySource, keySources, resolveKey as apiKey } from "./key-source.mjs";
24
+
25
+ export const DEFAULT_MODEL = "jev-1.13.0";
26
+ export const OPENROUTER_MODEL = "typesafe/jev-1.13";
27
+ // Measured round trip is ~250-400ms through OpenRouter. 800ms left no headroom for a slow call,
28
+ // and a timeout costs the advice without saving the latency already spent, so the budget is set
29
+ // above the observed spread rather than at it.
30
+ export const DEFAULT_TIMEOUT_MS = 1500;
31
+ const MAX_TIMEOUT_MS = 5000;
32
+
33
+ /** Overridable so tests never reach the network and the eval proxy can price the traffic. */
34
+ export function baseUrl() {
35
+ const configured = process.env.TYPESAFE_BASE_URL;
36
+ if (configured && configured.trim().length > 0) {
37
+ return configured.trim().replace(/\/+$/u, "");
38
+ }
39
+
40
+ return backend() === "openrouter" ? "https://openrouter.ai" : "https://api.typesafe.ai";
41
+ }
42
+
43
+ // The two services expose the same state/questions body under different paths. Keeping both
44
+ // suffixes distinct is also what lets the eval proxy tell the traffic apart and forward it on.
45
+ export function endpoint() {
46
+ return `${baseUrl()}${backend() === "openrouter" ? "/api/alpha/decisions" : "/v1/systemone"}`;
47
+ }
48
+
49
+ export function defaultModel() {
50
+ return backend() === "openrouter" ? OPENROUTER_MODEL : DEFAULT_MODEL;
51
+ }
52
+
53
+ /** Choose one option from a set. Up to 255 options; output is free, so rich enums cost nothing. */
54
+ export function choice(instructions, criteria) {
55
+ return { type: "choice", instructions, criteria };
56
+ }
57
+
58
+ /** Rate against ordered levels. Two to ten; the returned score may be fractional. */
59
+ export function score(instructions, criteria) {
60
+ return { type: "score", instructions, criteria };
61
+ }
62
+
63
+ /** Yes or no as a probability. Returns a bare number with no confidence field. */
64
+ export function noul(instructions) {
65
+ return { type: "noul", instructions };
66
+ }
67
+
68
+ function unavailable(reason) {
69
+ return { ok: false, reason, answers: {} };
70
+ }
71
+
72
+ /**
73
+ * Answers are normalized to a single shape so gates never branch on which primitive produced them.
74
+ * A Noul has no confidence, and inventing one would let a caller gate on a number the model never
75
+ * reported, so it stays undefined.
76
+ */
77
+ function normalizeAnswer(raw) {
78
+ if (!raw || typeof raw !== "object") {
79
+ return undefined;
80
+ }
81
+
82
+ if (typeof raw.noul === "number") {
83
+ return { kind: "noul", value: raw.noul, probabilities: undefined, confidence: undefined };
84
+ }
85
+
86
+ if (typeof raw.choice === "string") {
87
+ return {
88
+ kind: "choice",
89
+ value: raw.choice,
90
+ probabilities: raw.probabilities && typeof raw.probabilities === "object" ? raw.probabilities : undefined,
91
+ confidence: typeof raw.confidence === "number" ? raw.confidence : undefined,
92
+ };
93
+ }
94
+
95
+ if (typeof raw.score === "number") {
96
+ return {
97
+ kind: "score",
98
+ value: raw.score,
99
+ probabilities: Array.isArray(raw.probabilities) ? raw.probabilities : undefined,
100
+ confidence: typeof raw.confidence === "number" ? raw.confidence : undefined,
101
+ };
102
+ }
103
+
104
+ return undefined;
105
+ }
106
+
107
+ /**
108
+ * Ask one batch. Questions are evaluated in parallel against one state, so callers should send
109
+ * every question that state can answer rather than paying for the state again.
110
+ */
111
+ export async function ask(state, questions, options = {}) {
112
+ const key = resolveKey(backend());
113
+ if (!key) {
114
+ return unavailable("no-key");
115
+ }
116
+
117
+ if (!questions || Object.keys(questions).length === 0) {
118
+ return unavailable("no-questions");
119
+ }
120
+
121
+ const timeoutMs = Math.min(Math.max(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, 50), MAX_TIMEOUT_MS);
122
+ const controller = new AbortController();
123
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
124
+ const startedAt = Date.now();
125
+ const payload = { model: options.model ?? defaultModel(), state, questions };
126
+ try {
127
+ const response = await fetch(endpoint(), {
128
+ method: "POST",
129
+ headers: {
130
+ "content-type": "application/json",
131
+ authorization: `Bearer ${key}`,
132
+ // OpenRouter attributes traffic by these; they are ignored by the direct API.
133
+ "HTTP-Referer": "https://pi.dev",
134
+ "X-Title": "specpi-jev-advisor",
135
+ },
136
+ body: JSON.stringify(payload),
137
+ signal: options.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal,
138
+ });
139
+ if (!response.ok) {
140
+ return { ...unavailable(`http-${response.status}`), latencyMs: Date.now() - startedAt };
141
+ }
142
+
143
+ const body = await response.json();
144
+ const answers = {};
145
+ for (const [name, raw] of Object.entries(body?.answers ?? {})) {
146
+ const normalized = normalizeAnswer(raw);
147
+ if (normalized) {
148
+ answers[name] = normalized;
149
+ }
150
+ }
151
+
152
+ if (Object.keys(answers).length === 0) {
153
+ return { ...unavailable("empty-answers"), latencyMs: Date.now() - startedAt };
154
+ }
155
+
156
+ return {
157
+ ok: true,
158
+ answers,
159
+ model: typeof body?.model === "string" ? body.model : undefined,
160
+ // Reported by OpenRouter, absent on the direct API. Preferred over an estimate wherever
161
+ // it exists, so the eval cost column rests on logged usage rather than a guess.
162
+ usage: body?.usage && typeof body.usage === "object" ? body.usage : undefined,
163
+ latencyMs: Date.now() - startedAt,
164
+ };
165
+ } catch (error) {
166
+ const reason = error?.name === "AbortError" ? "timeout" : "network";
167
+
168
+ return { ...unavailable(reason), latencyMs: Date.now() - startedAt };
169
+ } finally {
170
+ clearTimeout(timer);
171
+ }
172
+ }