specpi 0.26.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.
- package/CHANGELOG.md +43 -0
- package/README.md +38 -4
- package/SECURITY_MODEL.md +36 -2
- package/THIRD_PARTY.md +10 -1
- package/extensions/jev-advisor/broker.mjs +276 -0
- package/extensions/jev-advisor/client.mjs +182 -0
- package/extensions/jev-advisor/config.mjs +254 -0
- package/extensions/jev-advisor/consent.mjs +133 -0
- package/extensions/jev-advisor/gate.mjs +249 -0
- package/extensions/jev-advisor/guard.mjs +140 -0
- package/extensions/jev-advisor/index.ts +849 -0
- package/extensions/jev-advisor/ledger.mjs +138 -0
- package/extensions/jev-advisor/questions/capabilities.mjs +124 -0
- package/extensions/jev-advisor/questions/compaction.mjs +153 -0
- package/extensions/jev-advisor/questions/gap.mjs +140 -0
- package/extensions/jev-advisor/questions/progress.mjs +195 -0
- package/extensions/jev-advisor/questions/retention.mjs +188 -0
- package/extensions/jev-advisor/questions/sources.mjs +91 -0
- package/extensions/jev-advisor/questions/untrusted.mjs +69 -0
- package/extensions/jev-advisor/sanitize.mjs +0 -0
- package/extensions/jev-advisor/usage.mjs +92 -0
- package/extensions/tool-wishlist/authoring-tools.mjs +42 -0
- package/extensions/tool-wishlist/index.ts +11 -0
- package/extensions/workflow-controls/capabilities.mjs +26 -0
- package/extensions/workflow-controls/index.ts +2 -2
- package/package.json +1 -1
- package/scripts/specpi.mjs +33 -1
- package/templates/settings.json +2 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// Jev is the first thing in SpecPi that talks to a third party, so its switch is the first thing
|
|
2
|
+
// every other module in this directory consults. Master off means no key read, no consent read, no
|
|
3
|
+
// network call and no prompt injection: the harness behaves exactly as it did before the extension
|
|
4
|
+
// existed.
|
|
5
|
+
//
|
|
6
|
+
// The file is SpecPi's own, hardened the same way as web-access and capability-policy: atomic
|
|
7
|
+
// write, mode 0600, symlinks refused, and a missing or unreadable file read as off.
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
/** Systems that may run inside a session. Offline scripts are not gated here. */
|
|
15
|
+
export const SYSTEM_NAMES = Object.freeze([
|
|
16
|
+
"retention",
|
|
17
|
+
"compaction",
|
|
18
|
+
"gap",
|
|
19
|
+
"sources",
|
|
20
|
+
"progress",
|
|
21
|
+
"untrusted",
|
|
22
|
+
"capability",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What a confident stuck verdict is allowed to do. `notify` tells the person and cannot be wrong in
|
|
27
|
+
* a way that costs anything; `message` appends a fixed line the model reads before its next
|
|
28
|
+
* request, which changes behaviour.
|
|
29
|
+
*
|
|
30
|
+
* It ships on `notify`. Not because the gate cannot tell the cases apart -- it demonstrably can: on
|
|
31
|
+
* the recorded fixtures a session repeating one failing call scores 0.89 for stuck with the mode at
|
|
32
|
+
* 0.99 confidence, and a session working steadily scores 0.30 and reports "unknown" below the gate.
|
|
33
|
+
* The missing number is the false-positive rate on real sessions, and the two things that bear on it
|
|
34
|
+
* point the other way: running the same taxonomy over the 24 recorded failures left 14 of them
|
|
35
|
+
* ungated, and a wrong nudge costs a turn, which is the exact quantity this system exists to save.
|
|
36
|
+
*
|
|
37
|
+
* So the condition for changing this default is a measurement, not an opinion, and the eval suite
|
|
38
|
+
* is where it comes from: `--harness=specpi-jev` sets `message` and discloses it, because a
|
|
39
|
+
* notification in a headless run reaches nobody and would measure the cost of the system with none
|
|
40
|
+
* of its effect.
|
|
41
|
+
*/
|
|
42
|
+
export const NUDGE_MODES = Object.freeze(["notify", "message"]);
|
|
43
|
+
|
|
44
|
+
const MAX_SETTINGS_BYTES = 4096;
|
|
45
|
+
const MAX_CALL_BUDGET = 256;
|
|
46
|
+
const MAX_TOTAL_BUDGET = 512;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One shared budget could not survive a turn-level system. A system that fires once per turn would
|
|
50
|
+
* reach a shared ceiling of 8 inside the first few turns and starve retention for the rest of the
|
|
51
|
+
* session, and which one won would be decided by event ordering rather than by anyone's policy.
|
|
52
|
+
*
|
|
53
|
+
* So the ceiling is two-level: each system gets its own, and the total is a real constraint because
|
|
54
|
+
* it is deliberately less than their sum. Running out of one system's budget stops that system and
|
|
55
|
+
* nothing else.
|
|
56
|
+
*
|
|
57
|
+
* The per-system numbers follow how often each one can fire: retention on every large read-only
|
|
58
|
+
* result, compaction once or twice in a long session, gap per report, sources per delegation batch.
|
|
59
|
+
*/
|
|
60
|
+
export const DEFAULT_BUDGETS = Object.freeze({
|
|
61
|
+
// A backstop, not a working limit, and the number says which. Measured, a full tier-3 task -- a
|
|
62
|
+
// 120-step repair chain over about 25 model requests -- spends 4 to 7 calls, and the busiest
|
|
63
|
+
// attempt ever recorded spent 12. A session would have to run for days before 512 bound
|
|
64
|
+
// anything a person was actually doing, which is the point: the ceiling should only ever be hit
|
|
65
|
+
// by a loop, and hitting it should therefore be information rather than an inconvenience.
|
|
66
|
+
//
|
|
67
|
+
// The earlier 120 was sized against eval attempts, which is the wrong reference. An attempt
|
|
68
|
+
// runs for two minutes; an interactive session runs for a day, and a turn-level system at one
|
|
69
|
+
// call every four turns reaches 120 somewhere in the afternoon and then goes quiet without
|
|
70
|
+
// having found anything wrong. A ceiling that a normal long session reaches is not protecting
|
|
71
|
+
// anyone, it is just failing later than it looks.
|
|
72
|
+
//
|
|
73
|
+
// Cost is not what these are for. A call is about $0.00003, so the whole total is about a cent
|
|
74
|
+
// and a half. They bound two things that do not get cheaper with scale: how much digest leaves
|
|
75
|
+
// the machine for a third party, at up to 1 KB a call, and how much awaited latency a runaway
|
|
76
|
+
// loop can add before something stops it. Half a megabyte of digest and an announced stop is
|
|
77
|
+
// the shape of the trade.
|
|
78
|
+
total: 512,
|
|
79
|
+
retention: 208,
|
|
80
|
+
compaction: 12,
|
|
81
|
+
gap: 48,
|
|
82
|
+
sources: 32,
|
|
83
|
+
// Turn-level, but gated behind local signals and a four-turn cooldown, so it only spends on
|
|
84
|
+
// sessions that already look wrong. The ceiling is what stops a genuinely thrashing session
|
|
85
|
+
// from spending the total on being told it is thrashing.
|
|
86
|
+
progress: 176,
|
|
87
|
+
// Usually free: when retention is on, system 7's question rides the call retention was already
|
|
88
|
+
// making against the same state. This ceiling only binds when retention is off, or when the
|
|
89
|
+
// fetched result is too small for retention to be interested in it.
|
|
90
|
+
untrusted: 104,
|
|
91
|
+
// Once per session by construction, and only when local signals already suggest it. Two rather
|
|
92
|
+
// than one so a retried first turn is not silently un-served.
|
|
93
|
+
capability: 2,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/** 0 is a real budget meaning no calls. Switching a system off is what `systems[name] = false` is for. */
|
|
97
|
+
export const MAX_BUDGETS = Object.freeze({ system: MAX_CALL_BUDGET, total: MAX_TOTAL_BUDGET });
|
|
98
|
+
|
|
99
|
+
export function agentDirectory() {
|
|
100
|
+
const configured = process.env.PI_CODING_AGENT_DIR;
|
|
101
|
+
|
|
102
|
+
return path.resolve(configured && configured.length > 0 ? configured : path.join(os.homedir(), ".pi", "agent"));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function jevDirectory() {
|
|
106
|
+
return path.join(agentDirectory(), "specpi", "jev");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function settingsFile() {
|
|
110
|
+
return path.join(jevDirectory(), "settings.json");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Refuses links and irregular files so the preference cannot redirect a write. */
|
|
114
|
+
export function regularFile(file, label) {
|
|
115
|
+
const stat = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
116
|
+
if (!stat) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > MAX_SETTINGS_BYTES) {
|
|
121
|
+
throw new Error(`Unsupported ${label} file`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Every unknown shape collapses to the same all-off default rather than a partial enable. */
|
|
128
|
+
export function defaultSettings() {
|
|
129
|
+
return {
|
|
130
|
+
schema: 2,
|
|
131
|
+
master: false,
|
|
132
|
+
startup: false,
|
|
133
|
+
systems: Object.fromEntries(SYSTEM_NAMES.map((name) => [name, false])),
|
|
134
|
+
budgets: { ...DEFAULT_BUDGETS },
|
|
135
|
+
progressNudge: "notify",
|
|
136
|
+
// The guard is a separate package with its own gate, so it carries its own switch rather
|
|
137
|
+
// than riding the advisor's master. Both ship off: nothing in the Jev layer is active on a
|
|
138
|
+
// fresh install, and `startup` is how a user chooses to default one on.
|
|
139
|
+
guard: { enabled: false, startup: false },
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function clamp(value, fallback, ceiling) {
|
|
144
|
+
return Number.isInteger(value) ? Math.min(Math.max(value, 0), ceiling) : fallback;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function normalizeBudgets(raw) {
|
|
148
|
+
const budgets = { total: clamp(raw?.total, DEFAULT_BUDGETS.total, MAX_TOTAL_BUDGET) };
|
|
149
|
+
for (const name of SYSTEM_NAMES) {
|
|
150
|
+
budgets[name] = clamp(raw?.[name], DEFAULT_BUDGETS[name] ?? 0, MAX_CALL_BUDGET);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return budgets;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Schema 1 carried one `callBudgetPerSession`. Reading it as an unknown shape would switch the
|
|
158
|
+
* layer off for anyone who had turned it on, which is a reset the user never asked for -- "unknown
|
|
159
|
+
* shapes collapse to all-off" is a rule for corrupt input, not for our own previous version. The
|
|
160
|
+
* one number becomes the total, and each system gets the smaller of its default and that total, so
|
|
161
|
+
* a user who set a deliberately tight ceiling keeps it.
|
|
162
|
+
*/
|
|
163
|
+
function migrate(raw) {
|
|
164
|
+
// Schema 1 read 0 as "no ceiling"; schema 2 reads it as "no calls", because a per-system 0 that
|
|
165
|
+
// silently meant unlimited is the wrong way for a budget to fail. Carrying the old meaning
|
|
166
|
+
// forward here is what stops the bump from inverting a user's intent.
|
|
167
|
+
const stored = raw?.callBudgetPerSession === 0 ? MAX_TOTAL_BUDGET : raw?.callBudgetPerSession;
|
|
168
|
+
const total = clamp(stored, DEFAULT_BUDGETS.total, MAX_TOTAL_BUDGET);
|
|
169
|
+
const budgets = { total };
|
|
170
|
+
for (const name of SYSTEM_NAMES) {
|
|
171
|
+
budgets[name] = Math.min(DEFAULT_BUDGETS[name] ?? 0, total);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { ...raw, schema: 2, budgets, callBudgetPerSession: undefined };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function normalize(raw) {
|
|
178
|
+
const source = raw?.schema === 1 ? migrate(raw) : raw;
|
|
179
|
+
if (source?.schema !== 2) {
|
|
180
|
+
return defaultSettings();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const systems = Object.fromEntries(SYSTEM_NAMES.map((name) => [name, source.systems?.[name] === true]));
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
schema: 2,
|
|
187
|
+
master: source.master === true,
|
|
188
|
+
startup: source.startup === true,
|
|
189
|
+
systems,
|
|
190
|
+
budgets: normalizeBudgets(source.budgets),
|
|
191
|
+
progressNudge: NUDGE_MODES.includes(source.progressNudge) ? source.progressNudge : "notify",
|
|
192
|
+
guard: { enabled: source.guard?.enabled === true, startup: source.guard?.startup === true },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function loadSettings() {
|
|
197
|
+
try {
|
|
198
|
+
const file = settingsFile();
|
|
199
|
+
if (!regularFile(file, "Jev settings")) {
|
|
200
|
+
return defaultSettings();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return normalize(JSON.parse(fs.readFileSync(file, "utf8")));
|
|
204
|
+
} catch {
|
|
205
|
+
return defaultSettings();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Shared atomic write for every file this extension owns. */
|
|
210
|
+
export function writeFileAtomic(file, contents) {
|
|
211
|
+
const directory = path.dirname(file);
|
|
212
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
213
|
+
const temporary = path.join(directory, `.${path.basename(file)}.${randomUUID()}.tmp`);
|
|
214
|
+
try {
|
|
215
|
+
fs.writeFileSync(temporary, contents, { mode: 0o600, flag: "wx" });
|
|
216
|
+
fs.renameSync(temporary, file);
|
|
217
|
+
} finally {
|
|
218
|
+
fs.rmSync(temporary, { force: true });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function saveSettings(settings) {
|
|
223
|
+
// A caller handing back a schema-1 shape is migrated rather than reset, so a round trip through
|
|
224
|
+
// an old reader cannot quietly disable the layer.
|
|
225
|
+
const next = normalize(settings?.schema === 1 ? settings : { ...settings, schema: 2 });
|
|
226
|
+
const file = settingsFile();
|
|
227
|
+
if (fs.existsSync(file)) {
|
|
228
|
+
regularFile(file, "Jev settings");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
writeFileAtomic(file, `${JSON.stringify(next, null, 4)}\n`);
|
|
232
|
+
|
|
233
|
+
return next;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function settingsPath() {
|
|
237
|
+
return settingsFile();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The key is never read into any structure that gets logged or serialized. Callers only ever ask
|
|
242
|
+
* whether one is present; the client reads it directly at call time.
|
|
243
|
+
*/
|
|
244
|
+
export function keyPresent() {
|
|
245
|
+
// OPENROUTER_API_KEY on the default path, TYPESAFE_API_KEY on the direct one; see client.mjs.
|
|
246
|
+
for (const name of ["OPENROUTER_API_KEY", "TYPESAFE_API_KEY"]) {
|
|
247
|
+
const value = process.env[name];
|
|
248
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// A config flag records an intention. It does not record that a human was told what the intention
|
|
2
|
+
// costs. This file holds the separate, explicit grant: the first time any system would put session
|
|
3
|
+
// state on the wire, a dialog names the endpoint, the shape of the data and the byte budget, and
|
|
4
|
+
// nothing is sent until someone says yes.
|
|
5
|
+
//
|
|
6
|
+
// Mirrors capability-policy: SpecPi's own file, atomic, mode 0600, symlinks refused, missing or
|
|
7
|
+
// unreadable read as "ask". No interactive UI means no send, ever.
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { jevDirectory, regularFile, writeFileAtomic } from "./config.mjs";
|
|
12
|
+
import { baseUrl } from "./client.mjs";
|
|
13
|
+
import { MAX_STATE_BYTES } from "./sanitize.mjs";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The host the bytes will actually reach, not the service they are nominally for. This was a fixed
|
|
17
|
+
* "api.typesafe.ai" until the default backend became OpenRouter, at which point the dialog named a
|
|
18
|
+
* host the data no longer went to, which is the one thing a consent dialog may never do. It is
|
|
19
|
+
* derived now, so each of the three selectable destinations (OpenRouter, the direct TypeSafe API,
|
|
20
|
+
* and a TYPESAFE_BASE_URL override) names itself, and so the rule below is true rather than
|
|
21
|
+
* aspirational: a grant is keyed on this string, so repointing the client really does ask again.
|
|
22
|
+
*/
|
|
23
|
+
export function endpointLabel() {
|
|
24
|
+
const base = baseUrl();
|
|
25
|
+
try {
|
|
26
|
+
return new URL(base).host;
|
|
27
|
+
} catch {
|
|
28
|
+
// Unparseable means the fetch will fail anyway. Returning the raw value keeps the dialog
|
|
29
|
+
// honest and cannot collide with a host a real grant was given for.
|
|
30
|
+
return base;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function consentFile() {
|
|
35
|
+
return path.join(jevDirectory(), "consent.json");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function loadConsent() {
|
|
39
|
+
try {
|
|
40
|
+
const file = consentFile();
|
|
41
|
+
if (!regularFile(file, "Jev consent")) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const stored = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
46
|
+
if (stored?.schema !== 1 || stored.granted !== true || typeof stored.endpoint !== "string") {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A grant is for the endpoint it was given for. Repointing the client asks again.
|
|
51
|
+
return stored.endpoint === endpointLabel() ? stored : undefined;
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function granted() {
|
|
58
|
+
return loadConsent() !== undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function saveConsent() {
|
|
62
|
+
const file = consentFile();
|
|
63
|
+
if (fs.existsSync(file)) {
|
|
64
|
+
regularFile(file, "Jev consent");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const stored = {
|
|
68
|
+
schema: 1,
|
|
69
|
+
granted: true,
|
|
70
|
+
endpoint: endpointLabel(),
|
|
71
|
+
maxStateBytes: MAX_STATE_BYTES,
|
|
72
|
+
grantedAt: new Date().toISOString(),
|
|
73
|
+
};
|
|
74
|
+
writeFileAtomic(file, `${JSON.stringify(stored, null, 4)}\n`);
|
|
75
|
+
|
|
76
|
+
return stored;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function revokeConsent() {
|
|
80
|
+
try {
|
|
81
|
+
fs.rmSync(consentFile(), { force: true });
|
|
82
|
+
} catch {
|
|
83
|
+
// A consent file that cannot be removed still reads as granted; the master switch is the
|
|
84
|
+
// reliable stop, and /jev status reports both.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function consentPath() {
|
|
89
|
+
return consentFile();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const CONSENT_TITLE = "Allow SpecPi to send task summaries to Jev?";
|
|
93
|
+
|
|
94
|
+
export function consentBody(systemLabel) {
|
|
95
|
+
return [
|
|
96
|
+
`${systemLabel} wants to ask TypeSafe's Jev classifier a question about this session.`,
|
|
97
|
+
"",
|
|
98
|
+
`What is sent: a summary object of at most ${MAX_STATE_BYTES} bytes to ${endpointLabel()}, over HTTPS.`,
|
|
99
|
+
"It carries tool names, byte counts, relative paths and short descriptions.",
|
|
100
|
+
"It never carries file contents, command output, credentials, URLs or session history.",
|
|
101
|
+
"",
|
|
102
|
+
"Every call is recorded locally in transmissions.jsonl with a hash of exactly what was sent,",
|
|
103
|
+
"which you can read with /jev ledger. Turn this off at any time with /jev off.",
|
|
104
|
+
].join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve consent for a system, prompting once. Returns false without prompting when there is no
|
|
109
|
+
* interactive human: an advisor must never be the reason a headless run sends data.
|
|
110
|
+
*/
|
|
111
|
+
export async function ensureConsent(ctx, systemLabel) {
|
|
112
|
+
if (granted()) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!ctx?.hasUI || typeof ctx.ui?.confirm !== "function") {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const accepted = await ctx.ui.confirm(CONSENT_TITLE, consentBody(systemLabel));
|
|
121
|
+
if (!accepted) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
saveConsent();
|
|
127
|
+
} catch {
|
|
128
|
+
// An unwritable grant means asking again next time, which is the safe direction.
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return true;
|
|
133
|
+
}
|