pi-jev-auto-mode 0.1.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 +19 -0
- package/LICENSE +21 -0
- package/README.md +242 -0
- package/SECURITY.md +32 -0
- package/docs/calibration.md +134 -0
- package/docs/design.md +119 -0
- package/docs/security.md +122 -0
- package/index.ts +1 -0
- package/package.json +67 -0
- package/src/call.ts +180 -0
- package/src/decide.ts +81 -0
- package/src/extension.ts +705 -0
- package/src/intent.ts +68 -0
- package/src/jev/availability.ts +51 -0
- package/src/jev/criteria.ts +19 -0
- package/src/jev/decide.ts +187 -0
- package/src/jev/engine.ts +163 -0
- package/src/jev/index.ts +20 -0
- package/src/jev/questions.ts +228 -0
- package/src/jev/response.ts +64 -0
- package/src/jev/state.ts +20 -0
- package/src/jev/transport.ts +117 -0
- package/src/jev/types.ts +46 -0
- package/src/policy.ts +464 -0
- package/src/records.ts +118 -0
- package/src/settings.ts +274 -0
- package/src/ui.ts +125 -0
package/src/extension.ts
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JEV auto mode for the Pi coding agent.
|
|
3
|
+
*
|
|
4
|
+
* The gate has two layers, and the order matters:
|
|
5
|
+
*
|
|
6
|
+
* 1. A deterministic policy layer (hard-deny, user rules, dangerous-pattern
|
|
7
|
+
* detection, protected paths). Hard-deny is not negotiable.
|
|
8
|
+
* 2. A semantic layer (JEV) that only ever sees calls the deterministic layer
|
|
9
|
+
* decided to escalate, and whose "allow" can never resurrect a hard-denied
|
|
10
|
+
* call.
|
|
11
|
+
*
|
|
12
|
+
* Anything the semantic layer cannot decide — timeout, malformed response,
|
|
13
|
+
* cancelled request, missing engine — blocks the call. Silence is never consent.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import {
|
|
19
|
+
CONFIG_DIR_NAME,
|
|
20
|
+
getAgentDir,
|
|
21
|
+
type ExtensionAPI,
|
|
22
|
+
type ExtensionContext,
|
|
23
|
+
} from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { buildGatedCall, type GatedCall, type RepoFacts, type ToolCallEventLike } from "./call.ts";
|
|
25
|
+
import { createManualEngine, type CandidateInput, type DecisionEngine, type EngineEvidence, type EngineVerdict } from "./decide.ts";
|
|
26
|
+
import {
|
|
27
|
+
DEFAULT_RULES,
|
|
28
|
+
createJevEngine,
|
|
29
|
+
createSdkTransport,
|
|
30
|
+
describeJevAvailability,
|
|
31
|
+
describeKeySource,
|
|
32
|
+
formatThreshold,
|
|
33
|
+
ruleById,
|
|
34
|
+
verifyApiKey,
|
|
35
|
+
type JevAvailability,
|
|
36
|
+
type Observation,
|
|
37
|
+
type ObservationMeta,
|
|
38
|
+
} from "./jev/index.ts";
|
|
39
|
+
import { extractRecentIntent } from "./intent.ts";
|
|
40
|
+
import {
|
|
41
|
+
dangerousReasons,
|
|
42
|
+
evaluateUserCommandRules,
|
|
43
|
+
hardDenyReasons,
|
|
44
|
+
isSafeCommand,
|
|
45
|
+
PROTECTED_DIRECTORY_SEGMENTS,
|
|
46
|
+
unique,
|
|
47
|
+
} from "./policy.ts";
|
|
48
|
+
import {
|
|
49
|
+
createRecorder,
|
|
50
|
+
registerDecisionEntryRenderer,
|
|
51
|
+
type DecisionRecord,
|
|
52
|
+
type DecisionRecorder,
|
|
53
|
+
} from "./records.ts";
|
|
54
|
+
import { DEFAULT_SETTINGS, JevAutoModeStore, parseThreshold, type JevAutoModeSettings, type SettingsScope } from "./settings.ts";
|
|
55
|
+
import {
|
|
56
|
+
describeSettings,
|
|
57
|
+
formatRuleTable,
|
|
58
|
+
POLICY_HEADER,
|
|
59
|
+
statusText,
|
|
60
|
+
updateStatus,
|
|
61
|
+
USAGE_TEXT,
|
|
62
|
+
type ObservedCondition,
|
|
63
|
+
} from "./ui.ts";
|
|
64
|
+
|
|
65
|
+
export const AUTO_MODE_FLAG = "jev-auto-mode";
|
|
66
|
+
export const AUTO_MODE_COMMAND = "jev-auto-mode";
|
|
67
|
+
|
|
68
|
+
/** Structural context: what this extension needs from Pi, and nothing more. */
|
|
69
|
+
export interface GateUi {
|
|
70
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
71
|
+
select(title: string, options: string[]): Promise<string | undefined>;
|
|
72
|
+
confirm(title: string, message: string): Promise<boolean>;
|
|
73
|
+
input(title: string, placeholder?: string): Promise<string | undefined>;
|
|
74
|
+
editor(title: string, prefill?: string): Promise<string | undefined>;
|
|
75
|
+
setStatus(key: string, text: string | undefined): void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface GateContext {
|
|
79
|
+
readonly cwd: string;
|
|
80
|
+
readonly hasUI: boolean;
|
|
81
|
+
readonly mode?: string;
|
|
82
|
+
readonly sessionManager: { getBranch?: () => readonly unknown[]; getEntries?: () => readonly unknown[] };
|
|
83
|
+
readonly ui: GateUi;
|
|
84
|
+
readonly signal?: AbortSignal;
|
|
85
|
+
isProjectTrusted?(): boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface DecisionDeps {
|
|
89
|
+
readonly engine: DecisionEngine;
|
|
90
|
+
readonly record: DecisionRecorder;
|
|
91
|
+
readonly now: () => number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface GateState {
|
|
95
|
+
settings: JevAutoModeSettings;
|
|
96
|
+
policyNotes: string;
|
|
97
|
+
scope: SettingsScope;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface BlockResult {
|
|
101
|
+
readonly block: true;
|
|
102
|
+
readonly reason: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createInitialState(): GateState {
|
|
106
|
+
return { settings: DEFAULT_SETTINGS, policyNotes: "", scope: "global" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function conversationBranch(ctx: GateContext): readonly unknown[] {
|
|
110
|
+
const sessionManager = ctx.sessionManager as {
|
|
111
|
+
getBranch?: () => readonly unknown[];
|
|
112
|
+
getEntries?: () => readonly unknown[];
|
|
113
|
+
};
|
|
114
|
+
const branch = sessionManager.getBranch?.() ?? sessionManager.getEntries?.() ?? [];
|
|
115
|
+
return Array.isArray(branch) ? branch : [];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function repoFacts(cwd: string, call?: GatedCall): RepoFacts {
|
|
119
|
+
// The concrete protection that triggered escalation is listed alongside the
|
|
120
|
+
// configured roots, so a question about protected locations can be answered
|
|
121
|
+
// against the actual target rather than a generic path list.
|
|
122
|
+
const protectedPaths = unique([
|
|
123
|
+
...PROTECTED_DIRECTORY_SEGMENTS,
|
|
124
|
+
...(call?.protectedReason ? [call.protectedReason] : []),
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
cwd,
|
|
129
|
+
isGitRepository: existsSync(join(cwd, ".git")),
|
|
130
|
+
protectedPaths,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface RecordInput {
|
|
135
|
+
readonly call: GatedCall;
|
|
136
|
+
readonly reasons: readonly string[];
|
|
137
|
+
readonly status: DecisionRecord["status"];
|
|
138
|
+
readonly source: DecisionRecord["source"];
|
|
139
|
+
readonly rationale: string;
|
|
140
|
+
readonly evidence?: EngineEvidence;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function writeRecord(deps: DecisionDeps, input: RecordInput): void {
|
|
144
|
+
const record: DecisionRecord = {
|
|
145
|
+
tool: input.call.tool,
|
|
146
|
+
summary: input.call.summary,
|
|
147
|
+
reasons: [...input.reasons],
|
|
148
|
+
status: input.status,
|
|
149
|
+
source: input.source,
|
|
150
|
+
rationale: input.rationale,
|
|
151
|
+
...(input.evidence?.conditions ? { conditions: input.evidence.conditions } : {}),
|
|
152
|
+
...(input.evidence?.decidingRule ? { decidingRule: input.evidence.decidingRule } : {}),
|
|
153
|
+
...(input.evidence?.clearedByIntent ? { clearedByIntent: input.evidence.clearedByIntent } : {}),
|
|
154
|
+
...(input.evidence?.probabilities ? { probabilities: input.evidence.probabilities } : {}),
|
|
155
|
+
...(input.evidence?.model ? { model: input.evidence.model } : {}),
|
|
156
|
+
...(input.evidence?.latencyMs !== undefined ? { latencyMs: input.evidence.latencyMs } : {}),
|
|
157
|
+
timestamp: deps.now(),
|
|
158
|
+
};
|
|
159
|
+
deps.record(record);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function blocked(deps: DecisionDeps, input: RecordInput, reason?: string): BlockResult {
|
|
163
|
+
writeRecord(deps, input);
|
|
164
|
+
return { block: true, reason: reason ?? input.rationale };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function permit(deps: DecisionDeps, input: RecordInput): undefined {
|
|
168
|
+
writeRecord(deps, input);
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Wraps an engine rationale so the model gets an actionable reason, not a verdict. */
|
|
173
|
+
function blockReason(rationale: string): string {
|
|
174
|
+
return `JEV auto mode blocked this tool call. ${rationale} Do not repeat the same call unchanged; change the approach or ask the user.`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Decide one tool call.
|
|
179
|
+
*
|
|
180
|
+
* Exported so the whole policy path can be tested without a Pi runtime.
|
|
181
|
+
*/
|
|
182
|
+
export async function evaluateToolCall(
|
|
183
|
+
event: ToolCallEventLike,
|
|
184
|
+
ctx: GateContext,
|
|
185
|
+
state: GateState,
|
|
186
|
+
deps: DecisionDeps,
|
|
187
|
+
): Promise<BlockResult | undefined> {
|
|
188
|
+
if (!state.settings.enabled) return undefined;
|
|
189
|
+
|
|
190
|
+
const call = buildGatedCall(event, {
|
|
191
|
+
cwd: ctx.cwd,
|
|
192
|
+
extraProtectedPaths: state.settings.extraProtectedPaths,
|
|
193
|
+
});
|
|
194
|
+
if (!call) return undefined;
|
|
195
|
+
|
|
196
|
+
const ruleConfig = {
|
|
197
|
+
allowedCommands: state.settings.allowedCommands,
|
|
198
|
+
disallowedCommands: state.settings.disallowedCommands,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
let reasons: string[];
|
|
202
|
+
|
|
203
|
+
if (call.tool === "bash") {
|
|
204
|
+
const command = typeof event.input.command === "string" ? event.input.command : "";
|
|
205
|
+
|
|
206
|
+
const hardReasons = hardDenyReasons(command);
|
|
207
|
+
if (hardReasons.length > 0) {
|
|
208
|
+
const rationale = `Non-negotiable safety rule matched: ${hardReasons.join(", ")}.`;
|
|
209
|
+
return blocked(deps, { call, reasons: hardReasons, status: "blocked", source: "hard-deny", rationale });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const userRule = evaluateUserCommandRules(command, ruleConfig);
|
|
213
|
+
if (userRule?.decision === "deny") {
|
|
214
|
+
const rationale = `A user disallowed command pattern matched: ${userRule.pattern}.`;
|
|
215
|
+
return blocked(deps, { call, reasons: [userRule.pattern], status: "blocked", source: "user-rule", rationale });
|
|
216
|
+
}
|
|
217
|
+
if (userRule?.decision === "allow") {
|
|
218
|
+
return permit(deps, {
|
|
219
|
+
call,
|
|
220
|
+
reasons: [],
|
|
221
|
+
status: "allowed",
|
|
222
|
+
source: "user-rule",
|
|
223
|
+
rationale: `A user allow pattern matched: ${userRule.pattern}.`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Read-only built-ins plus the user's own safe commands run without a record.
|
|
228
|
+
if (isSafeCommand(command, state.settings.safeCommands)) return undefined;
|
|
229
|
+
|
|
230
|
+
reasons = dangerousReasons(command, ctx.cwd);
|
|
231
|
+
// Nothing dangerous matched: this is the fast path the gate exists to preserve.
|
|
232
|
+
if (reasons.length === 0) return undefined;
|
|
233
|
+
} else {
|
|
234
|
+
const protectedReasons = unique(
|
|
235
|
+
[call.protectedReason, call.outsideCwd ? "write outside the working directory" : undefined].filter(
|
|
236
|
+
(reason): reason is string => typeof reason === "string",
|
|
237
|
+
),
|
|
238
|
+
);
|
|
239
|
+
if (protectedReasons.length === 0) return undefined;
|
|
240
|
+
reasons = protectedReasons;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const input: CandidateInput = {
|
|
244
|
+
call,
|
|
245
|
+
reasons,
|
|
246
|
+
intent: extractRecentIntent(conversationBranch(ctx)),
|
|
247
|
+
policy: state.policyNotes,
|
|
248
|
+
repo: repoFacts(ctx.cwd, call),
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
let verdict: EngineVerdict;
|
|
252
|
+
try {
|
|
253
|
+
verdict = await deps.engine.judge(input, { signal: ctx.signal });
|
|
254
|
+
} catch {
|
|
255
|
+
// An engine that throws is an engine that cannot decide. Fail closed.
|
|
256
|
+
verdict = {
|
|
257
|
+
verdict: "unavailable",
|
|
258
|
+
reason: "engine_error",
|
|
259
|
+
rationale: "The decision engine threw an error.",
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (ctx.signal?.aborted) {
|
|
264
|
+
return blocked(deps, {
|
|
265
|
+
call,
|
|
266
|
+
reasons,
|
|
267
|
+
status: "blocked",
|
|
268
|
+
source: "unavailable",
|
|
269
|
+
rationale: "The request was cancelled before a decision was reached.",
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const evidence: EngineEvidence = {
|
|
274
|
+
...(verdict.probabilities ? { probabilities: verdict.probabilities } : {}),
|
|
275
|
+
...(verdict.thresholds ? { thresholds: verdict.thresholds } : {}),
|
|
276
|
+
...(verdict.conditions ? { conditions: verdict.conditions } : {}),
|
|
277
|
+
...(verdict.decidingRule ? { decidingRule: verdict.decidingRule } : {}),
|
|
278
|
+
...(verdict.clearedByIntent ? { clearedByIntent: verdict.clearedByIntent } : {}),
|
|
279
|
+
...(verdict.model ? { model: verdict.model } : {}),
|
|
280
|
+
...(verdict.latencyMs !== undefined ? { latencyMs: verdict.latencyMs } : {}),
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
switch (verdict.verdict) {
|
|
284
|
+
case "allow":
|
|
285
|
+
return permit(deps, {
|
|
286
|
+
call,
|
|
287
|
+
reasons,
|
|
288
|
+
status: "allowed",
|
|
289
|
+
source: "engine",
|
|
290
|
+
rationale: verdict.rationale,
|
|
291
|
+
evidence,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
case "deny":
|
|
295
|
+
return blocked(
|
|
296
|
+
deps,
|
|
297
|
+
{ call, reasons, status: "blocked", source: "engine", rationale: verdict.rationale, evidence },
|
|
298
|
+
blockReason(verdict.rationale),
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
case "unavailable": {
|
|
302
|
+
const rationale = `No decision was available (${verdict.reason}): ${verdict.rationale}`;
|
|
303
|
+
return blocked(
|
|
304
|
+
deps,
|
|
305
|
+
{ call, reasons, status: "blocked", source: "unavailable", rationale, evidence },
|
|
306
|
+
blockReason(rationale),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
case "uncertain": {
|
|
311
|
+
if (!ctx.hasUI) {
|
|
312
|
+
const rationale = `${verdict.rationale} No UI is available to confirm, so the call was blocked.`;
|
|
313
|
+
return blocked(
|
|
314
|
+
deps,
|
|
315
|
+
{ call, reasons, status: "blocked", source: "no-ui", rationale, evidence },
|
|
316
|
+
blockReason(rationale),
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const dialog = [
|
|
321
|
+
"JEV auto mode wants confirmation before this runs.",
|
|
322
|
+
"",
|
|
323
|
+
`Tool: ${call.tool}`,
|
|
324
|
+
...(call.command ? [call.command] : []),
|
|
325
|
+
...(call.path ? [call.path] : []),
|
|
326
|
+
"",
|
|
327
|
+
`Matched: ${reasons.join(", ")}`,
|
|
328
|
+
`Rationale: ${verdict.rationale}`,
|
|
329
|
+
].join("\n");
|
|
330
|
+
|
|
331
|
+
const choice = await ctx.ui.select(dialog, ["No", "Yes"]);
|
|
332
|
+
if (choice !== "Yes") {
|
|
333
|
+
writeRecord(deps, {
|
|
334
|
+
call,
|
|
335
|
+
reasons,
|
|
336
|
+
status: "cancelled",
|
|
337
|
+
source: "user",
|
|
338
|
+
rationale: "The user declined the confirmation.",
|
|
339
|
+
evidence,
|
|
340
|
+
});
|
|
341
|
+
return { block: true, reason: "Blocked by the user at the JEV auto mode confirmation." };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return permit(deps, {
|
|
345
|
+
call,
|
|
346
|
+
reasons,
|
|
347
|
+
status: "confirmed",
|
|
348
|
+
source: "user",
|
|
349
|
+
rationale: "The user confirmed the call.",
|
|
350
|
+
evidence,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export interface RegisterOptions {
|
|
357
|
+
/** Override the engine (tests, or a different judgment backend). */
|
|
358
|
+
readonly engine?: DecisionEngine;
|
|
359
|
+
readonly store?: JevAutoModeStore;
|
|
360
|
+
readonly record?: DecisionRecorder;
|
|
361
|
+
readonly now?: () => number;
|
|
362
|
+
/** Transport override, mainly for tests. */
|
|
363
|
+
readonly fetch?: (input: string, init?: RequestInit) => Promise<Response>;
|
|
364
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
365
|
+
/** Calibration channel: every condition of every judgment. */
|
|
366
|
+
readonly onObservation?: (observations: readonly Observation[], meta: ObservationMeta) => void;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Build the semantic engine for the current settings.
|
|
371
|
+
*
|
|
372
|
+
* Without a key the gate keeps working with the ask-only engine rather than
|
|
373
|
+
* dropping to "allow": the degradation stays visible and stays closed.
|
|
374
|
+
*/
|
|
375
|
+
export function createEngine(
|
|
376
|
+
settings: JevAutoModeSettings,
|
|
377
|
+
options: RegisterOptions = {},
|
|
378
|
+
storedApiKey?: string,
|
|
379
|
+
): DecisionEngine {
|
|
380
|
+
if (options.engine) return options.engine;
|
|
381
|
+
|
|
382
|
+
const availability = describeJevAvailability(options.env ?? process.env, storedApiKey);
|
|
383
|
+
if (!availability.available || !availability.apiKey) return createManualEngine();
|
|
384
|
+
|
|
385
|
+
return createJevEngine({
|
|
386
|
+
transport: createSdkTransport({
|
|
387
|
+
apiKey: availability.apiKey,
|
|
388
|
+
timeoutMs: settings.timeoutMs,
|
|
389
|
+
maxRetries: settings.maxRetries,
|
|
390
|
+
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
391
|
+
}),
|
|
392
|
+
model: availability.model,
|
|
393
|
+
maxStateCharacters: settings.maxStateCharacters,
|
|
394
|
+
thresholds: settings.thresholds,
|
|
395
|
+
...(options.onObservation === undefined ? {} : { onObservation: options.onObservation }),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function register(pi: ExtensionAPI, options: RegisterOptions = {}): void {
|
|
400
|
+
const store =
|
|
401
|
+
options.store ?? new JevAutoModeStore({ agentDir: getAgentDir(), configDirName: CONFIG_DIR_NAME });
|
|
402
|
+
const state = createInitialState();
|
|
403
|
+
const observed = new Map<string, ObservedCondition>();
|
|
404
|
+
const now = options.now ?? (() => Date.now());
|
|
405
|
+
|
|
406
|
+
// Wrap the calibration channel so the tuning table can show what the model last
|
|
407
|
+
// answered for each condition, even when the decision did not depend on it.
|
|
408
|
+
const engineOptions: RegisterOptions = {
|
|
409
|
+
...options,
|
|
410
|
+
onObservation: (observations, meta) => {
|
|
411
|
+
const at = now();
|
|
412
|
+
for (const observation of observations) {
|
|
413
|
+
observed.set(observation.ruleId, { probability: observation.probability, at });
|
|
414
|
+
}
|
|
415
|
+
options.onObservation?.(observations, meta);
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
let deps: DecisionDeps = {
|
|
420
|
+
engine: createEngine(state.settings, engineOptions),
|
|
421
|
+
record: options.record ?? createRecorder(pi),
|
|
422
|
+
now,
|
|
423
|
+
};
|
|
424
|
+
let availability: JevAvailability = describeJevAvailability(options.env ?? process.env);
|
|
425
|
+
let loaded = false;
|
|
426
|
+
|
|
427
|
+
/** Rebuild the engine from the settings and the currently resolvable key. */
|
|
428
|
+
const rebuildEngine = async (): Promise<void> => {
|
|
429
|
+
const storedApiKey = await store.readStoredApiKey();
|
|
430
|
+
availability = describeJevAvailability(options.env ?? process.env, storedApiKey);
|
|
431
|
+
deps = { ...deps, engine: createEngine(state.settings, engineOptions, storedApiKey) };
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const refresh = async (ctx: GateContext, applyFlag: boolean): Promise<void> => {
|
|
435
|
+
const trusted = ctx.isProjectTrusted?.() ?? false;
|
|
436
|
+
const loadedSettings = await store.loadSettings(ctx.cwd, trusted);
|
|
437
|
+
state.settings = loadedSettings.settings;
|
|
438
|
+
state.scope = loadedSettings.scope;
|
|
439
|
+
state.policyNotes = await store.loadPolicyNotes();
|
|
440
|
+
if (applyFlag && pi.getFlag(AUTO_MODE_FLAG) === true) {
|
|
441
|
+
state.settings = { ...state.settings, enabled: true };
|
|
442
|
+
}
|
|
443
|
+
await rebuildEngine();
|
|
444
|
+
loaded = true;
|
|
445
|
+
updateStatus(ctx, { enabled: state.settings.enabled, engineId: deps.engine.id, scope: state.scope });
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const save = async (ctx: GateContext): Promise<void> => {
|
|
449
|
+
await store.saveSettings(state.settings, "global", ctx.cwd);
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
pi.registerFlag(AUTO_MODE_FLAG, {
|
|
453
|
+
description: "Start with JEV auto mode enabled",
|
|
454
|
+
type: "boolean",
|
|
455
|
+
default: false,
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
registerDecisionEntryRenderer(pi);
|
|
459
|
+
|
|
460
|
+
pi.registerCommand(AUTO_MODE_COMMAND, {
|
|
461
|
+
description: "Show or change the JEV auto mode settings",
|
|
462
|
+
getArgumentCompletions: (argumentPrefix) => {
|
|
463
|
+
const value = String(argumentPrefix ?? "");
|
|
464
|
+
const tokens = value.split(/\s+/).filter(Boolean);
|
|
465
|
+
if (tokens.length === 0) {
|
|
466
|
+
return ["status", "on", "off", "policy", "threshold", "login", "logout"].map((item) => ({ value: item, label: item }));
|
|
467
|
+
}
|
|
468
|
+
if (tokens[0] === "threshold") {
|
|
469
|
+
if (tokens.length <= 1) {
|
|
470
|
+
return ["reset", ...DEFAULT_RULES.map((rule) => rule.id)]
|
|
471
|
+
.filter((item) => item.startsWith(tokens[1] ?? ""))
|
|
472
|
+
.map((item) => ({ value: `threshold ${item}`, label: item }));
|
|
473
|
+
}
|
|
474
|
+
if (tokens.length === 2) {
|
|
475
|
+
const rule = ruleById(tokens[1] ?? "");
|
|
476
|
+
if (!rule) return null;
|
|
477
|
+
return [
|
|
478
|
+
{ value: `threshold ${rule.id}`, label: `current: ${rule.threshold}` },
|
|
479
|
+
{ value: `threshold ${rule.id} reset`, label: "reset to the calibrated default" },
|
|
480
|
+
];
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return null;
|
|
484
|
+
},
|
|
485
|
+
handler: async (args, ctx) => {
|
|
486
|
+
const gateContext = toGateContext(ctx);
|
|
487
|
+
if (!loaded) await refresh(gateContext, true);
|
|
488
|
+
|
|
489
|
+
const value = String(args ?? "").trim();
|
|
490
|
+
const status = [
|
|
491
|
+
statusText({
|
|
492
|
+
enabled: state.settings.enabled,
|
|
493
|
+
engineId: deps.engine.id,
|
|
494
|
+
scope: state.scope,
|
|
495
|
+
}),
|
|
496
|
+
"",
|
|
497
|
+
describeSettings(state.settings, state.scope),
|
|
498
|
+
availability.available
|
|
499
|
+
? `semantic layer: ${availability.model} (key from ${describeKeySource(availability.source)})`
|
|
500
|
+
: `semantic layer: unavailable (${availability.reason ?? "unknown reason"})`,
|
|
501
|
+
].join("\n");
|
|
502
|
+
|
|
503
|
+
if (value === "" || value === "status") {
|
|
504
|
+
ctx.ui.notify(status, "info");
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (value === "on" || value === "off") {
|
|
509
|
+
state.settings = { ...state.settings, enabled: value === "on" };
|
|
510
|
+
await save(gateContext);
|
|
511
|
+
updateStatus(gateContext, {
|
|
512
|
+
enabled: state.settings.enabled,
|
|
513
|
+
engineId: deps.engine.id,
|
|
514
|
+
scope: state.scope,
|
|
515
|
+
});
|
|
516
|
+
ctx.ui.notify(`JEV auto mode ${value === "on" ? "enabled" : "disabled"}.`, "info");
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (value === "policy") {
|
|
521
|
+
ctx.ui.notify(state.policyNotes.trim() || "(no policy notes configured)", "info");
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (value === "policy edit") {
|
|
526
|
+
const edited = await ctx.ui.editor("JEV auto mode policy", state.policyNotes || POLICY_HEADER);
|
|
527
|
+
if (edited === undefined) return;
|
|
528
|
+
await store.savePolicyNotes(edited);
|
|
529
|
+
state.policyNotes = await store.loadPolicyNotes();
|
|
530
|
+
ctx.ui.notify("Policy notes saved.", "info");
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (value === "policy clear") {
|
|
535
|
+
const confirmed = await ctx.ui.confirm(
|
|
536
|
+
"Clear JEV auto mode policy notes?",
|
|
537
|
+
"The semantic layer will fall back to its built-in criteria.",
|
|
538
|
+
);
|
|
539
|
+
if (!confirmed) return;
|
|
540
|
+
await store.savePolicyNotes("");
|
|
541
|
+
state.policyNotes = "";
|
|
542
|
+
ctx.ui.notify("Policy notes cleared.", "info");
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (value === "login") {
|
|
547
|
+
const entered = await ctx.ui.input("TypeSafe API key", "apikey_...");
|
|
548
|
+
const apiKey = entered?.trim();
|
|
549
|
+
if (!apiKey) {
|
|
550
|
+
ctx.ui.notify("Login cancelled: no key entered.", "info");
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const verification = await verifyApiKey({
|
|
555
|
+
apiKey,
|
|
556
|
+
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
557
|
+
});
|
|
558
|
+
if (!verification.ok && verification.reason === "invalid") {
|
|
559
|
+
ctx.ui.notify("The API rejected that key, so nothing was saved. Check the key and try again.", "error");
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (!verification.ok) {
|
|
563
|
+
ctx.ui.notify(
|
|
564
|
+
"Could not reach the TypeSafe API to verify the key, so nothing was saved. Check the connection and try again.",
|
|
565
|
+
"error",
|
|
566
|
+
);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
await store.writeStoredApiKey(apiKey);
|
|
571
|
+
await rebuildEngine();
|
|
572
|
+
updateStatus(gateContext, {
|
|
573
|
+
enabled: state.settings.enabled,
|
|
574
|
+
engineId: deps.engine.id,
|
|
575
|
+
scope: state.scope,
|
|
576
|
+
});
|
|
577
|
+
ctx.ui.notify(
|
|
578
|
+
`API key verified and stored at ${store.credentialPath()} (mode 600).\n\nSemantic layer: ${availability.model} (key from ${describeKeySource(availability.source)})`,
|
|
579
|
+
"info",
|
|
580
|
+
);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (value === "logout") {
|
|
585
|
+
const storedApiKey = await store.readStoredApiKey();
|
|
586
|
+
if (!storedApiKey) {
|
|
587
|
+
ctx.ui.notify("No stored API key to remove.", "info");
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const confirmed = await ctx.ui.confirm(
|
|
591
|
+
"Remove the stored TypeSafe API key?",
|
|
592
|
+
availability.source === "env"
|
|
593
|
+
? "It is not in use anyway: TYPESAFE_API_KEY takes precedence."
|
|
594
|
+
: "The semantic layer will fall back to ask-only until a key is available again.",
|
|
595
|
+
);
|
|
596
|
+
if (!confirmed) return;
|
|
597
|
+
|
|
598
|
+
await store.deleteStoredApiKey();
|
|
599
|
+
await rebuildEngine();
|
|
600
|
+
updateStatus(gateContext, {
|
|
601
|
+
enabled: state.settings.enabled,
|
|
602
|
+
engineId: deps.engine.id,
|
|
603
|
+
scope: state.scope,
|
|
604
|
+
});
|
|
605
|
+
ctx.ui.notify(
|
|
606
|
+
availability.available
|
|
607
|
+
? `Stored key removed. Still using ${describeKeySource(availability.source)}.`
|
|
608
|
+
: "Stored key removed. The semantic layer is now ask-only.",
|
|
609
|
+
"info",
|
|
610
|
+
);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (value === "threshold" || value === "threshold list") {
|
|
615
|
+
ctx.ui.notify(formatRuleTable(DEFAULT_RULES, state.settings.thresholds, observed), "info");
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const thresholdMatch = /^threshold\s+(\S+)(?:\s+(\S+))?$/.exec(value);
|
|
620
|
+
if (thresholdMatch) {
|
|
621
|
+
const ruleId = thresholdMatch[1] ?? "";
|
|
622
|
+
const argument = thresholdMatch[2];
|
|
623
|
+
|
|
624
|
+
if (ruleId === "reset" || argument === "reset") {
|
|
625
|
+
const target = ruleId === "reset" ? argument : ruleId;
|
|
626
|
+
const thresholds = { ...state.settings.thresholds };
|
|
627
|
+
if (target && target !== "reset") {
|
|
628
|
+
if (!ruleById(target)) {
|
|
629
|
+
ctx.ui.notify(`Unknown rule \`${target}\`.\n\n${formatRuleTable(DEFAULT_RULES, state.settings.thresholds, observed)}`, "warning");
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
delete thresholds[target];
|
|
633
|
+
} else {
|
|
634
|
+
for (const key of Object.keys(thresholds)) delete thresholds[key];
|
|
635
|
+
}
|
|
636
|
+
state.settings = { ...state.settings, thresholds };
|
|
637
|
+
await save(gateContext);
|
|
638
|
+
await rebuildEngine();
|
|
639
|
+
ctx.ui.notify(
|
|
640
|
+
`Threshold overrides cleared${target && target !== "reset" ? ` for \`${target}\`` : ""}.\n\n${formatRuleTable(DEFAULT_RULES, state.settings.thresholds, observed)}`,
|
|
641
|
+
"info",
|
|
642
|
+
);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const rule = ruleById(ruleId);
|
|
647
|
+
if (!rule) {
|
|
648
|
+
ctx.ui.notify(`Unknown rule \`${ruleId}\`.\n\n${formatRuleTable(DEFAULT_RULES, state.settings.thresholds, observed)}`, "warning");
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (argument === undefined) {
|
|
653
|
+
ctx.ui.notify(formatRuleTable(DEFAULT_RULES, state.settings.thresholds, observed), "info");
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const threshold = parseThreshold(Number(argument));
|
|
658
|
+
if (threshold === undefined) {
|
|
659
|
+
ctx.ui.notify(`A threshold must be greater than 0.5 and at most 1.0 (got \`${argument}\`).`, "error");
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
state.settings = {
|
|
664
|
+
...state.settings,
|
|
665
|
+
thresholds: { ...state.settings.thresholds, [ruleId]: threshold },
|
|
666
|
+
};
|
|
667
|
+
await save(gateContext);
|
|
668
|
+
await rebuildEngine();
|
|
669
|
+
ctx.ui.notify(
|
|
670
|
+
`\`${ruleId}\` now requires p >= ${formatThreshold(threshold)} (rejecting at p <= ${formatThreshold(1 - threshold)}).\n\n${formatRuleTable(DEFAULT_RULES, state.settings.thresholds, observed)}`,
|
|
671
|
+
"info",
|
|
672
|
+
);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
ctx.ui.notify(USAGE_TEXT, "warning");
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
681
|
+
await refresh(toGateContext(ctx), true);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
685
|
+
const gateContext = toGateContext(ctx);
|
|
686
|
+
if (!loaded) await refresh(gateContext, false);
|
|
687
|
+
return evaluateToolCall(event as ToolCallEventLike, gateContext, state, deps);
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function toGateContext(ctx: ExtensionContext): GateContext {
|
|
692
|
+
return {
|
|
693
|
+
cwd: ctx.cwd,
|
|
694
|
+
hasUI: ctx.hasUI,
|
|
695
|
+
mode: ctx.mode,
|
|
696
|
+
sessionManager: ctx.sessionManager,
|
|
697
|
+
ui: ctx.ui,
|
|
698
|
+
signal: ctx.signal,
|
|
699
|
+
isProjectTrusted: () => ctx.isProjectTrusted(),
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
export default function jevAutoMode(pi: ExtensionAPI): void {
|
|
704
|
+
register(pi);
|
|
705
|
+
}
|