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