pi-jev-guard 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -13
- package/extensions/index.ts +59 -26
- package/package.json +1 -1
- package/src/automatic/guardian.ts +46 -3
- package/src/automatic/overlay.ts +27 -5
- package/src/commands.ts +68 -16
- package/src/config.ts +5 -1
package/README.md
CHANGED
|
@@ -26,7 +26,15 @@ export TYPESAFE_API_KEY="..." # backend typesafe diretto
|
|
|
26
26
|
|
|
27
27
|
## Uso normale (invariato)
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
Le nuove sessioni partono in `on-demand`: i modelli normali restano selezionabili e nessuna verifica parte da sola.
|
|
30
|
+
`/jev mode automatic|on-demand` salva la scelta **nella sessione**, non nella config globale.
|
|
31
|
+
La scelta sopravvive a `/reload`, riavvio e `/resume`; fork e `/tree` seguono lo stato del ramo.
|
|
32
|
+
Credenziali, policy e catalogo dei twin restano condivisi.
|
|
33
|
+
|
|
34
|
+
Migrazione: il vecchio `mode` in `jev-config.json` non controlla più l'estensione
|
|
35
|
+
(resta compatibile con i consumer standalone). Nelle sessioni senza uno stato salvato,
|
|
36
|
+
riattiva `automatic` una volta se desiderato. `JEV_MODE=automatic` può impostare il valore
|
|
37
|
+
iniziale in headless; una scelta già salvata nella sessione ha precedenza.
|
|
30
38
|
|
|
31
39
|
```text
|
|
32
40
|
/jev check ./src/example.ts # verifica singola di un file
|
|
@@ -48,29 +56,49 @@ In chat, l'LLM può chiamare `jev_validate` per controlli mirati.
|
|
|
48
56
|
|
|
49
57
|
**Più twin insieme**: ogni `/jev upstream` *aggiunge* un modello guarded, senza
|
|
50
58
|
rimuovere i precedenti. I twin vengono registrati anche all'avvio (i modelli
|
|
51
|
-
dinamici come Codex sono letti dal models-store),
|
|
52
|
-
|
|
53
|
-
|
|
59
|
+
dinamici come Codex sono letti dal models-store), quando gli originali sono disponibili.
|
|
60
|
+
Il **primario** salvato è il target di auto-selezione e revert; un twin già selezionato
|
|
61
|
+
nella sessione viene mantenuto al reload, anche se non è il primario.
|
|
54
62
|
|
|
55
63
|
Nessun login extra: i twin vivono nello stesso provider e riusano la credenziale già configurata (env, stored, OAuth/SSO come Codex business plan). I modelli originali restano intatti e selezionabili.
|
|
56
64
|
|
|
57
|
-
|
|
58
|
-
|
|
65
|
+
I twin salvati vengono registrati prima del ripristino del modello anche in
|
|
66
|
+
`on-demand`: registrazione e attivazione del gate sono indipendenti. Il suffisso
|
|
67
|
+
`__jev` è locale e non viene inviato al provider; anche la cronologia viene
|
|
68
|
+
normalizzata sull'identità originale senza perdere firme o metadati di continuazione.
|
|
59
69
|
|
|
60
|
-
Torna normale con `/
|
|
70
|
+
Torna normale con `/jev mode on-demand`, che seleziona anche il modello originale.
|
|
61
71
|
In `automatic`, selezionare un modello non protetto lo fa ritornare
|
|
62
72
|
subito al twin (enforcement a livello selezione).
|
|
63
73
|
`/jev upstream` senza argomenti ripristina tutti i twin salvati, `/jev untwin P M`
|
|
64
74
|
ne rimuove uno solo, `/jev off` li rimuove tutti
|
|
65
|
-
(e
|
|
66
|
-
|
|
75
|
+
(e salva on-demand nella sessione, selezionando l'originale prima di rimuovere il routing).
|
|
76
|
+
`/jev off` conserva il catalogo salvato: al reload gli alias tornano disponibili,
|
|
77
|
+
ma il gate rimane disattivato.
|
|
78
|
+
|
|
79
|
+
Se filtri i modelli con `enabledModels`, usa i glob invece delle voci esplicite,
|
|
80
|
+
così i twin futuri non restano nascosti:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
"enabledModels": ["openai-codex/*__jev", "deepseek/*__jev", "...altri pattern..."]
|
|
84
|
+
```
|
|
67
85
|
|
|
68
|
-
|
|
69
|
-
|
|
86
|
+
Dopo aver aggiunto un twin serve `/reload` (lo scope è risolto all'avvio della sessione).
|
|
87
|
+
`/jev upstream` avvisa subito quando il twin appena creato è fuori dal picker.
|
|
70
88
|
L'installazione punta alla cartella: dopo un aggiornamento del codice basta `/reload`.
|
|
71
89
|
|
|
72
|
-
Headless/CI: `JEV_AUTO_UPSTREAM=provider/model` crea
|
|
73
|
-
|
|
90
|
+
Headless/CI: `JEV_MODE=automatic JEV_AUTO_UPSTREAM=provider/model` crea e attiva
|
|
91
|
+
il guarded nelle sessioni senza uno stato Jev salvato.
|
|
92
|
+
|
|
93
|
+
Un hold `[JEV — OUTPUT NON PUBBLICATO]` interrompe intenzionalmente il turno:
|
|
94
|
+
controlla `/jev last`. Non viene aggirato con un proseguimento automatico.
|
|
95
|
+
|
|
96
|
+
Verdetto **incerto** (`review`): il gate tenta prima una riga riparazione privata
|
|
97
|
+
(`automatic.repairOnReview`, default `true`), poi applica la policy configurata:
|
|
98
|
+
`policy.onUncertain` e `policy.onUnavailable` (`"hold"` default, oppure `"pass"`).
|
|
99
|
+
Con `"pass"` il testo viene pubblicato con una nota di trasparenza sul verdetto.
|
|
100
|
+
Le conferme per comandi di rete sospetti attendono invece una decisione esplicita;
|
|
101
|
+
la normale sequenza tool → risposta successiva continua senza un nuovo prompt.
|
|
74
102
|
|
|
75
103
|
## Come decide
|
|
76
104
|
|
package/extensions/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { Model, Provider } from "@earendil-works/pi-ai";
|
|
3
3
|
import type { Api } from "@earendil-works/pi-ai";
|
|
4
|
-
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
4
|
+
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
5
|
+
import { registerJevCommand, type UpstreamResult } from "../src/commands.ts";
|
|
5
6
|
import { registerJevAskTool } from "../src/ask.ts";
|
|
6
7
|
import {
|
|
7
8
|
configSnapshot,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
isTwinId,
|
|
24
25
|
mergeModelsById,
|
|
25
26
|
twinIdFor,
|
|
27
|
+
untwinId,
|
|
26
28
|
} from "../src/automatic/overlay.ts";
|
|
27
29
|
import type { GateVerdictReport } from "../src/automatic/guardian.ts";
|
|
28
30
|
import { readStoreModels } from "../src/store-models.ts";
|
|
@@ -61,6 +63,20 @@ export interface GateStatus {
|
|
|
61
63
|
|
|
62
64
|
export default function (pi: ExtensionAPI) {
|
|
63
65
|
let config: JevConfig = loadConfig();
|
|
66
|
+
// Shared config holds credentials/policy/twins, not the session toggle.
|
|
67
|
+
const initialMode = process.env.JEV_MODE === "automatic" ? "automatic" : "on-demand";
|
|
68
|
+
let sessionMode: JevConfig["mode"] = initialMode;
|
|
69
|
+
function getConfig(): JevConfig {
|
|
70
|
+
return { ...config, mode: sessionMode };
|
|
71
|
+
}
|
|
72
|
+
function restoreMode(ctx: ExtensionContext): void {
|
|
73
|
+
sessionMode = initialMode;
|
|
74
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
75
|
+
if (entry.type !== "custom" || entry.customType !== "jev-mode") continue;
|
|
76
|
+
const mode = (entry.data as { mode?: unknown } | undefined)?.mode;
|
|
77
|
+
if (mode === "automatic" || mode === "on-demand") sessionMode = mode;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
64
80
|
// Full (3 regole) per tool/comandi/diagnostica; gate (difetti concreti) per provider.
|
|
65
81
|
let handle = createReviewer(configSnapshot(config));
|
|
66
82
|
let gateHandle = createReviewer(configSnapshot(config), { rules: DEFAULT_GATE_RULES });
|
|
@@ -85,7 +101,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
85
101
|
}
|
|
86
102
|
const overlays = new Map<string, OverlayState>();
|
|
87
103
|
|
|
88
|
-
const getConfig = () => config;
|
|
89
104
|
const getReview = () => handle.review;
|
|
90
105
|
const getGateReview = () => gateHandle.review;
|
|
91
106
|
const getInfo = () => ({ backend: handle.backend, model: handle.model });
|
|
@@ -150,7 +165,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
150
165
|
typedHandle = createTypedAsk(configSnapshot(config));
|
|
151
166
|
outputCache = createVerdictCache<OutputVerdict>({ ttlSeconds: config.outputJudge.cacheSeconds });
|
|
152
167
|
exfilCache = createVerdictCache<ExfilVerdict>({ ttlSeconds: config.exfilCheck.cacheSeconds });
|
|
153
|
-
return
|
|
168
|
+
return getConfig();
|
|
154
169
|
}
|
|
155
170
|
|
|
156
171
|
/** Provider upstream vero: il nostro upstream catturato, o il live dal registry. */
|
|
@@ -324,6 +339,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
324
339
|
async function switchToGuarded(
|
|
325
340
|
registry: ModelRegistry,
|
|
326
341
|
): Promise<{ ok: boolean; error?: string; model?: string }> {
|
|
342
|
+
if (overlays.size === 0) refreshUpstream(registry);
|
|
327
343
|
const primary = primaryTwin();
|
|
328
344
|
if (!primary) {
|
|
329
345
|
return { ok: false, error: "nessun guarded attivo — prima /jev upstream <provider> <model>" };
|
|
@@ -359,17 +375,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
359
375
|
registerJevCommand(pi, {
|
|
360
376
|
config: getConfig,
|
|
361
377
|
setMode: (mode) => {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
typedHandle = createTypedAsk(configSnapshot(config));
|
|
366
|
-
try {
|
|
367
|
-
saveConfig(config);
|
|
368
|
-
} catch {
|
|
369
|
-
// Resta in memoria anche se il salvataggio fallisce.
|
|
370
|
-
}
|
|
371
|
-
return config;
|
|
378
|
+
pi.appendEntry("jev-mode", { mode });
|
|
379
|
+
sessionMode = mode;
|
|
380
|
+
return getConfig();
|
|
372
381
|
},
|
|
382
|
+
sharedConfig: () => config,
|
|
373
383
|
reload: refresh,
|
|
374
384
|
review: getReview,
|
|
375
385
|
reviewerInfo: getInfo,
|
|
@@ -392,16 +402,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
392
402
|
// Seed all'avvio: pi ripristina il modello di sessione PRIMA di
|
|
393
403
|
// session_start, quindi un twin persistito non esisterebbe ancora e
|
|
394
404
|
// cadrebbe su "Could not restore model ...__jev". Pre-registriamo un
|
|
395
|
-
// overlay
|
|
396
|
-
//
|
|
397
|
-
// sostituisce col clone live dal registry.
|
|
405
|
+
// overlay per tutti i twin salvati, anche in on-demand: il mode della
|
|
406
|
+
// sessione non è disponibile prima di session_start. Un alias non attiva il gate.
|
|
398
407
|
const bootAuto = process.env.JEV_AUTO_UPSTREAM?.trim();
|
|
399
408
|
const autoOff = bootAuto === "0" || bootAuto?.toLowerCase() === "off";
|
|
400
409
|
let seedRefs: TwinRef[] = [];
|
|
401
410
|
if (bootAuto && !autoOff) {
|
|
402
411
|
const parsed = parseUpstreamRef([bootAuto]);
|
|
403
412
|
if (!("error" in parsed)) seedRefs = [parsed];
|
|
404
|
-
} else if (!bootAuto
|
|
413
|
+
} else if (!bootAuto) {
|
|
405
414
|
seedRefs = twinRefs(config);
|
|
406
415
|
}
|
|
407
416
|
if (seedRefs.length > 0) {
|
|
@@ -439,7 +448,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
439
448
|
}
|
|
440
449
|
}
|
|
441
450
|
|
|
442
|
-
|
|
451
|
+
async function startSession(ctx: ExtensionContext): Promise<void> {
|
|
452
|
+
restoreMode(ctx);
|
|
443
453
|
const auto = process.env.JEV_AUTO_UPSTREAM?.trim();
|
|
444
454
|
if (auto && auto !== "0" && auto.toLowerCase() !== "off") {
|
|
445
455
|
try {
|
|
@@ -458,9 +468,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
458
468
|
// Resta opt-in manuale.
|
|
459
469
|
}
|
|
460
470
|
}
|
|
461
|
-
//
|
|
471
|
+
// Automatic richiede il routing guarded della sessione.
|
|
462
472
|
try {
|
|
463
|
-
if (
|
|
473
|
+
if (sessionMode !== "automatic") {
|
|
474
|
+
// Reload can retain a stale alias after its registration was removed.
|
|
475
|
+
if (ctx.model && isTwinId(ctx.model.id) && !isGuardedModel(ctx.model.provider, ctx.model.id)) {
|
|
476
|
+
const original = ctx.modelRegistry.find(ctx.model.provider, untwinId(ctx.model.id));
|
|
477
|
+
if (original) await pi.setModel(original);
|
|
478
|
+
}
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (!config.automatic.requireGuardedModel) return;
|
|
464
482
|
if (overlays.size === 0) {
|
|
465
483
|
const restored = refreshUpstream(ctx.modelRegistry);
|
|
466
484
|
if (!restored.ok) {
|
|
@@ -473,7 +491,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
473
491
|
const primary = primaryTwin();
|
|
474
492
|
if (!primary) return;
|
|
475
493
|
const twinId = twinIdFor(primary.model);
|
|
476
|
-
if (ctx.model?.provider
|
|
494
|
+
if (isGuardedModel(ctx.model?.provider, ctx.model?.id)) return;
|
|
477
495
|
const twin = ctx.modelRegistry.find(primary.provider, twinId);
|
|
478
496
|
if (!twin) return;
|
|
479
497
|
const switched = await pi.setModel(twin);
|
|
@@ -488,13 +506,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
488
506
|
} catch {
|
|
489
507
|
// Non bloccare l'avvio sessione.
|
|
490
508
|
}
|
|
509
|
+
}
|
|
510
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
511
|
+
await startSession(ctx);
|
|
512
|
+
pi.appendEntry("jev-mode", { mode: sessionMode });
|
|
491
513
|
});
|
|
514
|
+
pi.on("session_tree", async (_event, ctx) => { await startSession(ctx); });
|
|
515
|
+
|
|
516
|
+
// Normalize persisted aliases even after /jev off removed their overlays.
|
|
517
|
+
// The event is a context copy: session history remains unchanged.
|
|
518
|
+
pi.on("context", async (event) => ({
|
|
519
|
+
messages: event.messages.map((message) =>
|
|
520
|
+
message.role === "assistant" && isTwinId(message.model)
|
|
521
|
+
? { ...message, model: untwinId(message.model) }
|
|
522
|
+
: message),
|
|
523
|
+
}));
|
|
492
524
|
|
|
493
525
|
// Enforcement a livello selezione: in automatic non si può restare
|
|
494
526
|
// su un modello non guarded (revert immediato al twin).
|
|
495
527
|
pi.on("model_select", async (event, ctx) => {
|
|
496
528
|
try {
|
|
497
|
-
if (
|
|
529
|
+
if (sessionMode !== "automatic" || !config.automatic.requireGuardedModel) return;
|
|
498
530
|
if (overlays.size === 0) return;
|
|
499
531
|
if (isGuardedModel(event.model.provider, event.model.id)) return;
|
|
500
532
|
|
|
@@ -527,7 +559,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
527
559
|
// Jev non approva; mostra stato invece di silenzio ambiguo.
|
|
528
560
|
pi.on("message_start", async (event, ctx) => {
|
|
529
561
|
try {
|
|
530
|
-
if (
|
|
562
|
+
if (sessionMode !== "automatic" || !ctx.hasUI) return;
|
|
531
563
|
const msg: any = event.message;
|
|
532
564
|
if (msg?.role !== "assistant") return;
|
|
533
565
|
const provider: string | undefined =
|
|
@@ -545,7 +577,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
545
577
|
// Policy pre-esecuzione tool (solo automatic).
|
|
546
578
|
pi.on("tool_call", async (event, ctx) => {
|
|
547
579
|
try {
|
|
548
|
-
if (
|
|
580
|
+
if (sessionMode !== "automatic") return;
|
|
549
581
|
if (!config.toolsPolicy.enabled) return;
|
|
550
582
|
|
|
551
583
|
if (event.toolName === "bash" || event.toolName === "powershell") {
|
|
@@ -660,6 +692,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
660
692
|
const allow = await ctx.ui.confirm(
|
|
661
693
|
"Jev: comando di rete sospetto",
|
|
662
694
|
`${toolName}\n${summary}\n\nEseguire comunque?`,
|
|
695
|
+
{ signal: ctx.signal },
|
|
663
696
|
);
|
|
664
697
|
return allow ? undefined : { block: true, reason: `Bloccato da jev exfil check (${summary}) (declined).` };
|
|
665
698
|
}
|
|
@@ -669,7 +702,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
669
702
|
// una riga al risultato che il modello legge.
|
|
670
703
|
pi.on("tool_result", async (event, ctx) => {
|
|
671
704
|
try {
|
|
672
|
-
if (
|
|
705
|
+
if (sessionMode !== "automatic") return;
|
|
673
706
|
if (!config.outputJudge.enabled) return;
|
|
674
707
|
if (!config.outputJudge.tools.includes(event.toolName)) return;
|
|
675
708
|
if (!resolveBackend(config).apiKeyPresent) return;
|
|
@@ -735,7 +768,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
735
768
|
pi.on("message_end", async (event, ctx) => {
|
|
736
769
|
try {
|
|
737
770
|
if (ctx.hasUI) ctx.ui.setStatus("jev", undefined);
|
|
738
|
-
if (
|
|
771
|
+
if (sessionMode !== "automatic") return;
|
|
739
772
|
if (event.message.role !== "assistant") return;
|
|
740
773
|
|
|
741
774
|
const msg = event.message as { provider?: string; model?: string };
|
package/package.json
CHANGED
|
@@ -130,6 +130,26 @@ export async function consumePrivate(
|
|
|
130
130
|
return final;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/** Aggiunge una riga di trasparenza sul verdetto non conclusivo. */
|
|
134
|
+
function uncertainNotice(verdict: ReviewResult): string {
|
|
135
|
+
const top = [...verdict.checks].sort((a, b) => b.pFlaw - a.pFlaw)[0];
|
|
136
|
+
const detail = top ? ` (${top.ruleId} p=${top.pFlaw.toFixed(3)})` : "";
|
|
137
|
+
const reason = verdict.errorCode ? ` errore=${verdict.errorCode}` : "";
|
|
138
|
+
return `\n\n> [jev] verdetto non conclusivo: ${verdict.status}${detail}${reason} — pubblicato per policy onUncertain/onUnavailable=pass.`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Copia del candidato con la nota accodata all'ultimo blocco di testo. */
|
|
142
|
+
function withNotice(message: AssistantMessage, notice: string): AssistantMessage {
|
|
143
|
+
const content = message.content.map((block) => ({ ...block }));
|
|
144
|
+
const last = [...content].reverse().find((block) => block.type === "text");
|
|
145
|
+
if (last && last.type === "text") {
|
|
146
|
+
last.text = `${last.text}${notice}`;
|
|
147
|
+
} else {
|
|
148
|
+
content.push({ type: "text", text: notice.trimStart() });
|
|
149
|
+
}
|
|
150
|
+
return { ...message, content };
|
|
151
|
+
}
|
|
152
|
+
|
|
133
153
|
function assertCompleteCandidate(message: AssistantMessage): void {
|
|
134
154
|
// Rifiuta terminali non supportati per pubblicazione.
|
|
135
155
|
if (
|
|
@@ -177,6 +197,10 @@ export function runGuardedStream(
|
|
|
177
197
|
const usages: Usage[] = [];
|
|
178
198
|
let attemptContext = originalContext;
|
|
179
199
|
const maxRegenerations = deps.config.automatic.maxRegenerations;
|
|
200
|
+
// Un verdetto incerto non è un difetto accertato: un solo tentativo di
|
|
201
|
+
// riparazione mirata evita l'hold immediato su prosa borderline.
|
|
202
|
+
const maxReviewRepairs = deps.config.automatic.repairOnReview === false ? 0 : 1;
|
|
203
|
+
let reviewRepairs = 0;
|
|
180
204
|
|
|
181
205
|
try {
|
|
182
206
|
for (let attempt = 0; attempt <= maxRegenerations; attempt++) {
|
|
@@ -224,10 +248,11 @@ export function runGuardedStream(
|
|
|
224
248
|
}
|
|
225
249
|
signal?.throwIfAborted();
|
|
226
250
|
|
|
227
|
-
|
|
251
|
+
const publishCandidate = async (notice?: string): Promise<void> => {
|
|
228
252
|
deps.onVerdict?.({ verdict, attempts: attempt + 1, outcome: "published" });
|
|
253
|
+
const approved = notice ? withNotice(candidate, notice) : candidate;
|
|
229
254
|
const aggregated = aggregateUsage(usages);
|
|
230
|
-
const replayed = replayApproved(
|
|
255
|
+
const replayed = replayApproved(approved, {
|
|
231
256
|
provider: deps.publishedProvider,
|
|
232
257
|
model: deps.publishedModel,
|
|
233
258
|
usage: aggregated,
|
|
@@ -239,10 +264,19 @@ export function runGuardedStream(
|
|
|
239
264
|
}
|
|
240
265
|
const final = await replayed.result();
|
|
241
266
|
out.end(final);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
if (verdict.status === "pass") {
|
|
270
|
+
await publishCandidate();
|
|
242
271
|
return;
|
|
243
272
|
}
|
|
244
273
|
|
|
245
|
-
|
|
274
|
+
const canRepair =
|
|
275
|
+
verdict.status === "block"
|
|
276
|
+
? attempt < maxRegenerations
|
|
277
|
+
: verdict.status === "review" && reviewRepairs < maxReviewRepairs;
|
|
278
|
+
if (canRepair) {
|
|
279
|
+
if (verdict.status === "review") reviewRepairs++;
|
|
246
280
|
attemptContext = buildRepairContext({
|
|
247
281
|
originalContext,
|
|
248
282
|
rejectedText: rawCandidate,
|
|
@@ -252,6 +286,15 @@ export function runGuardedStream(
|
|
|
252
286
|
continue;
|
|
253
287
|
}
|
|
254
288
|
|
|
289
|
+
// Policy esplicita per i verdetti non conclusivi (config, non silenziosa).
|
|
290
|
+
const uncertainPass = verdict.status === "review" && deps.config.policy.onUncertain === "pass";
|
|
291
|
+
const unavailablePass =
|
|
292
|
+
verdict.status === "unavailable" && deps.config.policy.onUnavailable === "pass";
|
|
293
|
+
if (uncertainPass || unavailablePass) {
|
|
294
|
+
await publishCandidate(uncertainNotice(verdict));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
255
298
|
// Hold: pubblica failure sicura, mai il candidato bocciato.
|
|
256
299
|
const attempts = attempt + 1;
|
|
257
300
|
deps.onVerdict?.({ verdict, attempts, outcome: "held" });
|
package/src/automatic/overlay.ts
CHANGED
|
@@ -20,6 +20,8 @@ import type {
|
|
|
20
20
|
SimpleStreamOptions,
|
|
21
21
|
} from "@earendil-works/pi-ai";
|
|
22
22
|
import type { Api } from "@earendil-works/pi-ai";
|
|
23
|
+
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
24
|
+
import { emptyUsage } from "./serialize.ts";
|
|
23
25
|
import type { JevConfig } from "../config.ts";
|
|
24
26
|
import { configSnapshot } from "../config.ts";
|
|
25
27
|
import type { ReviewFn } from "../reviewer.ts";
|
|
@@ -134,7 +136,7 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
|
|
|
134
136
|
const chosen = new Set(deps.twinModelIds);
|
|
135
137
|
// Dedup per id: sorgenti miste (builtin + store + statica) possono
|
|
136
138
|
// ripetere lo stesso modello e produrrebbero twin duplicati.
|
|
137
|
-
const originals = mergeModelsById(rawOriginals, []);
|
|
139
|
+
const originals = mergeModelsById(stripTwins(rawOriginals), []);
|
|
138
140
|
const twins: Model<Api>[] = [];
|
|
139
141
|
for (const original of originals) {
|
|
140
142
|
if (chosen.has(original.id)) {
|
|
@@ -155,10 +157,19 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
|
|
|
155
157
|
context: Context,
|
|
156
158
|
options?: SimpleStreamOptions,
|
|
157
159
|
): AssistantMessageEventStream {
|
|
160
|
+
// Public aliases are not model changes. Preserve reasoning signatures,
|
|
161
|
+
// response phases and tool ids when replaying the transcript upstream.
|
|
162
|
+
const originalContext: Context = {
|
|
163
|
+
...context,
|
|
164
|
+
messages: context.messages.map((message) =>
|
|
165
|
+
message.role === "assistant" && message.provider === upstream.id && isTwinId(message.model)
|
|
166
|
+
? { ...message, model: untwinId(message.model) }
|
|
167
|
+
: message),
|
|
168
|
+
};
|
|
158
169
|
if (deps.upstreamStreams) {
|
|
159
|
-
return deps.upstreamStreams.streamSimple(model,
|
|
170
|
+
return deps.upstreamStreams.streamSimple(model, originalContext, options);
|
|
160
171
|
}
|
|
161
|
-
return upstream.streamSimple(model,
|
|
172
|
+
return upstream.streamSimple(model, originalContext, options);
|
|
162
173
|
}
|
|
163
174
|
|
|
164
175
|
function guardedStreamSimple(
|
|
@@ -167,9 +178,20 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
|
|
|
167
178
|
options?: SimpleStreamOptions,
|
|
168
179
|
): AssistantMessageEventStream {
|
|
169
180
|
const twinOf = twinToOriginal.get(model.id);
|
|
170
|
-
//
|
|
181
|
+
// A stale/unknown alias must never leak to the upstream API, even if a
|
|
182
|
+
// dynamic refresh removed its original from the catalogue.
|
|
171
183
|
if (!twinOf) {
|
|
172
|
-
return upstreamCall(model, context, options);
|
|
184
|
+
if (!isTwinId(model.id)) return upstreamCall(model, context, options);
|
|
185
|
+
const stream = createAssistantMessageEventStream();
|
|
186
|
+
const error = {
|
|
187
|
+
role: "assistant" as const, content: [], api: model.api,
|
|
188
|
+
provider: model.provider, model: model.id, usage: emptyUsage(),
|
|
189
|
+
stopReason: "error" as const, timestamp: Date.now(),
|
|
190
|
+
errorMessage: `Jev: twin non disponibile ${model.provider}/${model.id}. Ripristina con /jev upstream o seleziona il modello originale.`,
|
|
191
|
+
};
|
|
192
|
+
stream.push({ type: "error", reason: "error", error });
|
|
193
|
+
stream.end(error);
|
|
194
|
+
return stream;
|
|
173
195
|
}
|
|
174
196
|
const snapshot = configSnapshot(deps.getConfig());
|
|
175
197
|
// Gemello in on-demand: passthrough (nessuna verifica implicita).
|
package/src/commands.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync, existsSync, statSync, realpathSync, writeFileSync, mkdirS
|
|
|
2
2
|
import { resolve, dirname } from "node:path";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import type { ExtensionAPI, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { isTwinId, untwinId } from "./automatic/overlay.ts";
|
|
5
6
|
import type { JevConfig, TwinRef } from "./config.ts";
|
|
6
7
|
import {
|
|
7
8
|
defaultConfigPath,
|
|
@@ -42,6 +43,8 @@ export interface UpstreamResult {
|
|
|
42
43
|
export interface JevCommandDeps {
|
|
43
44
|
config: () => JevConfig;
|
|
44
45
|
setMode: (mode: "on-demand" | "automatic") => JevConfig;
|
|
46
|
+
/** Shared settings without the session-only mode override. */
|
|
47
|
+
sharedConfig?: () => JevConfig;
|
|
45
48
|
reload: () => JevConfig;
|
|
46
49
|
review: () => ReviewFn;
|
|
47
50
|
reviewerInfo: () => { backend: string; model: string };
|
|
@@ -91,8 +94,8 @@ export const JEV_HELP_TEXT = [
|
|
|
91
94
|
"Jev guard — verifica risposte con TypeSafe Jev (via OpenRouter o diretto).",
|
|
92
95
|
"",
|
|
93
96
|
" /jev status Stato: modalità, backend, policy, gate, auth",
|
|
94
|
-
" /jev mode on-demand
|
|
95
|
-
" /jev mode automatic
|
|
97
|
+
" /jev mode on-demand Disattiva il gate nella sessione (scelta persistente)",
|
|
98
|
+
" /jev mode automatic Attiva il gate nella sessione (scelta persistente)",
|
|
96
99
|
" /jev models [filtro] Modelli pi (● twin guarded attivo, ★ ultimo salvato)",
|
|
97
100
|
" /jev upstream P M Aggiunge il twin M__jev nel provider P (salvato)",
|
|
98
101
|
" /jev upstream P/M Stessa cosa in forma compatta",
|
|
@@ -111,7 +114,7 @@ export const JEV_HELP_TEXT = [
|
|
|
111
114
|
"",
|
|
112
115
|
"Flusso tipico: /jev upstream deepseek deepseek-flash → /model (scegli __jev)",
|
|
113
116
|
"→ /jev mode automatic. Il twin usa la STESSA auth del provider: nessun login extra.",
|
|
114
|
-
"Torna normale con: /
|
|
117
|
+
"Torna normale con: /jev mode on-demand. La scelta sopravvive a /reload e /resume.",
|
|
115
118
|
"Env JEV_AUTO_UPSTREAM=P/M: crea il twin all'avvio (default: nessuno;",
|
|
116
119
|
"con pi --print solo deepseek/deepseek-flash).",
|
|
117
120
|
].join("\n");
|
|
@@ -130,6 +133,32 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
130
133
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
131
134
|
const sub = parts[0] ?? "status";
|
|
132
135
|
|
|
136
|
+
// Terza domanda del picker: registrato, visibile, selezionato. Il twin
|
|
137
|
+
// appena creato non è mai nello scope risolto a inizio sessione.
|
|
138
|
+
function twinInScope(provider: string, modelId: string): boolean {
|
|
139
|
+
try {
|
|
140
|
+
const scoped = ctx.scopedModels ?? [];
|
|
141
|
+
if (scoped.length === 0) return true;
|
|
142
|
+
return scoped.some(
|
|
143
|
+
(s) => s.model.provider === provider && s.model.id === `${modelId}__jev`,
|
|
144
|
+
);
|
|
145
|
+
} catch {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Never unregister the route while a synthetic model is still selected.
|
|
151
|
+
// The caller must disable automatic enforcement before switching away.
|
|
152
|
+
async function leaveSelectedTwin(): Promise<boolean> {
|
|
153
|
+
if (!ctx.model || !isTwinId(ctx.model.id)) return true;
|
|
154
|
+
const original = ctx.modelRegistry.find(ctx.model.provider, untwinId(ctx.model.id));
|
|
155
|
+
try {
|
|
156
|
+
if (original && await pi.setModel(original)) return true;
|
|
157
|
+
} catch { /* Keep the overlay on failure. */ }
|
|
158
|
+
ctx.ui.notify("Impossibile selezionare il modello originale; twin mantenuto per evitare richieste __jev al provider.", "error");
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
133
162
|
if (sub === "help" || sub === "--help" || sub === "-h") {
|
|
134
163
|
ctx.ui.notify(JEV_HELP_TEXT, "info");
|
|
135
164
|
return;
|
|
@@ -179,20 +208,21 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
179
208
|
if (m?.provider && m?.id) {
|
|
180
209
|
const ref = `${m.provider}/${m.id}`;
|
|
181
210
|
currentModel =
|
|
182
|
-
|
|
211
|
+
cfg.mode === "automatic" && twinView.active.some((r) => r.provider === m.provider && `${r.model}__jev` === m.id)
|
|
212
|
+
? `${ref} (twin: enforcement attivo)` : ref;
|
|
183
213
|
}
|
|
184
214
|
} catch {
|
|
185
215
|
currentModel = "sconosciuto";
|
|
186
216
|
}
|
|
187
217
|
ctx.ui.notify(
|
|
188
218
|
[
|
|
189
|
-
`Modalità: ${cfg.mode}`,
|
|
219
|
+
`Modalità: ${cfg.mode} (salvata nella sessione)`,
|
|
190
220
|
`Backend: ${info.backend} (setting=${cfg.jev.backend}, ${resolved.reason})`,
|
|
191
221
|
`Modello: ${info.model}`,
|
|
192
222
|
`Verificatore timeout: ${resolved.timeoutMs}ms`,
|
|
193
223
|
`Policy: ${cfg.policy.revision} pass<=${cfg.policy.passMaxFlawProbability} block>=${cfg.policy.blockMinFlawProbability}`,
|
|
194
224
|
`Chiave presente: ${resolved.apiKeyPresent ? "sì" : "no"} (${resolved.keySource})`,
|
|
195
|
-
`Gate: ${gate.active ? `
|
|
225
|
+
`Gate: ${cfg.mode === "automatic" && gate.active ? `disponibile (twin primario ${gate.twin})` : `non attivo (${cfg.mode === "on-demand" ? "on-demand" : gate.reason})`}`,
|
|
196
226
|
`Twin attivi: ${twinView.active.length > 0 ? twinView.active.map((r) => `${r.provider}/${r.model}__jev`).join(", ") : "nessuno"}`,
|
|
197
227
|
`Twin nel picker: ${twinScope}`,
|
|
198
228
|
`Modello corrente: ${currentModel}`,
|
|
@@ -220,14 +250,15 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
220
250
|
if (value === "automatic") {
|
|
221
251
|
const sw = await deps.switchToGuarded(ctx.modelRegistry);
|
|
222
252
|
ctx.ui.notify(
|
|
223
|
-
`Modalità automatic attivata. Testo/codice saranno inviati a ${deps.reviewerInfo().backend}. ` +
|
|
253
|
+
`Modalità automatic attivata per questa sessione. Testo/codice saranno inviati a ${deps.reviewerInfo().backend}. ` +
|
|
224
254
|
(sw.ok
|
|
225
255
|
? `Modello selezionato: ${sw.model} (enforcement attivo).`
|
|
226
256
|
: `ATTENZIONE: ${sw.error} (solo diagnostica, nessun enforcement).`),
|
|
227
257
|
sw.ok ? "info" : "warning",
|
|
228
258
|
);
|
|
229
259
|
} else {
|
|
230
|
-
|
|
260
|
+
await leaveSelectedTwin();
|
|
261
|
+
ctx.ui.notify("Modalità on-demand salvata per questa sessione. Nessuna verifica automatica.", "info");
|
|
231
262
|
}
|
|
232
263
|
return;
|
|
233
264
|
}
|
|
@@ -293,10 +324,15 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
293
324
|
return;
|
|
294
325
|
}
|
|
295
326
|
const total = deps.twinStatus().active.length;
|
|
327
|
+
const visible = twinInScope(parsed.provider, parsed.model);
|
|
296
328
|
ctx.ui.notify(
|
|
297
329
|
`Twin aggiunto: ${res.twin} (totale attivi: ${total}, stessa auth del provider). ` +
|
|
298
|
-
|
|
299
|
-
|
|
330
|
+
(visible
|
|
331
|
+
? "Selezionalo con /model per enforcement in automatic."
|
|
332
|
+
: `ATTENZIONE: non compare in /model — enabledModels non lo copre. ` +
|
|
333
|
+
`Aggiungi "${parsed.provider}/*__jev" a enabledModels e fai /reload, ` +
|
|
334
|
+
`oppure attivalo direttamente con /jev mode automatic.`),
|
|
335
|
+
visible ? "info" : "warning",
|
|
300
336
|
);
|
|
301
337
|
return;
|
|
302
338
|
}
|
|
@@ -319,7 +355,8 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
319
355
|
const key = `${ref.provider}/${ref.model}`;
|
|
320
356
|
const mark = activeKeys.has(key) ? "● " : "○ ";
|
|
321
357
|
const star = key === primaryKey ? "★" : " ";
|
|
322
|
-
|
|
358
|
+
const hidden = twinInScope(ref.provider, ref.model) ? "" : " (fuori dal picker)";
|
|
359
|
+
lines.push(`${mark}${star} ${key}${activeKeys.has(key) ? "" : " (non attivo)"}${hidden}`);
|
|
323
360
|
}
|
|
324
361
|
ctx.ui.notify(
|
|
325
362
|
[
|
|
@@ -342,8 +379,21 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
342
379
|
);
|
|
343
380
|
return;
|
|
344
381
|
}
|
|
382
|
+
const previousMode = deps.config().mode;
|
|
383
|
+
const selected = ctx.model?.provider === parsed.provider && ctx.model.id === `${parsed.model}__jev`;
|
|
384
|
+
if (selected) {
|
|
385
|
+
deps.setMode("on-demand");
|
|
386
|
+
if (!await leaveSelectedTwin()) {
|
|
387
|
+
deps.setMode(previousMode);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
345
391
|
const res = deps.removeTwin(ctx.modelRegistry, parsed.provider, parsed.model);
|
|
346
392
|
const total = deps.twinStatus().active.length;
|
|
393
|
+
if (selected && previousMode === "automatic" && total > 0) {
|
|
394
|
+
deps.setMode(previousMode);
|
|
395
|
+
await deps.switchToGuarded(ctx.modelRegistry);
|
|
396
|
+
}
|
|
347
397
|
ctx.ui.notify(
|
|
348
398
|
res.ok
|
|
349
399
|
? `Twin rimosso: ${parsed.provider}/${parsed.model} (attivi: ${total}).`
|
|
@@ -354,11 +404,13 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
354
404
|
}
|
|
355
405
|
|
|
356
406
|
if (sub === "off") {
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
deps.setMode(
|
|
407
|
+
const previousMode = deps.config().mode;
|
|
408
|
+
deps.setMode("on-demand");
|
|
409
|
+
if (!await leaveSelectedTwin()) {
|
|
410
|
+
deps.setMode(previousMode);
|
|
411
|
+
return;
|
|
361
412
|
}
|
|
413
|
+
const res = deps.removeGuarded();
|
|
362
414
|
ctx.ui.notify(
|
|
363
415
|
res.ok
|
|
364
416
|
? `Twin rimossi: ${res.removed ?? 0} (provider originali ripristinati), modalità on-demand.`
|
|
@@ -465,7 +517,7 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
465
517
|
// Punta la config al file e ricarica gli handle col nuovo stato.
|
|
466
518
|
cfg.jev.apiKeyFile = target;
|
|
467
519
|
try {
|
|
468
|
-
saveConfig(cfg);
|
|
520
|
+
saveConfig({ ...cfg, mode: deps.sharedConfig?.().mode ?? cfg.mode });
|
|
469
521
|
} catch {
|
|
470
522
|
// Resta in memoria anche se il salvataggio fallisce.
|
|
471
523
|
}
|
package/src/config.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface JevConfig {
|
|
|
31
31
|
automatic: {
|
|
32
32
|
transport: "provider-gate";
|
|
33
33
|
maxRegenerations: number;
|
|
34
|
+
/** Tentativo privato extra quando il verdetto è incerto (review). */
|
|
35
|
+
repairOnReview: boolean;
|
|
34
36
|
requireGuardedModel: boolean;
|
|
35
37
|
/** Twin salvati: più modelli guarded insieme (uno o più per provider). */
|
|
36
38
|
twins: TwinRef[];
|
|
@@ -121,6 +123,7 @@ export const DEFAULT_CONFIG: JevConfig = {
|
|
|
121
123
|
automatic: {
|
|
122
124
|
transport: "provider-gate",
|
|
123
125
|
maxRegenerations: 2,
|
|
126
|
+
repairOnReview: true,
|
|
124
127
|
requireGuardedModel: true,
|
|
125
128
|
twins: [],
|
|
126
129
|
// Vuoti di default: nessun twin "salvato" finché l'utente non ne aggiunge
|
|
@@ -408,7 +411,8 @@ export function withTwinRefs(config: JevConfig, refs: TwinRef[]): JevConfig {
|
|
|
408
411
|
automatic: {
|
|
409
412
|
...config.automatic,
|
|
410
413
|
twins: refs.map((r) => ({ ...r })),
|
|
411
|
-
|
|
414
|
+
upstreamProvider: primary?.provider ?? "",
|
|
415
|
+
upstreamModel: primary?.model ?? "",
|
|
412
416
|
},
|
|
413
417
|
};
|
|
414
418
|
}
|