pi-jev-guard 0.1.6 → 0.2.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/README.md CHANGED
@@ -39,11 +39,20 @@ In chat, l'LLM può chiamare `jev_validate` per controlli mirati.
39
39
  ## Uso protetto
40
40
 
41
41
  ```text
42
- /jev upstream deepseek deepseek-flash # crea deepseek/deepseek-flash__jev
43
- /jev mode automatic # seleziona il twin + attiva enforcement
42
+ /jev upstream deepseek deepseek-flash # crea deepseek/deepseek-flash__jev
43
+ /jev upstream openai-codex gpt-5.6-terra # aggiunge openai-codex/gpt-5.6-terra__jev
44
+ /jev twins # elenca attivi e salvati
45
+ /jev untwin openai-codex gpt-5.6-terra # rimuove uno solo
46
+ /jev mode automatic # seleziona il primario + attiva enforcement
44
47
  ```
45
48
 
46
- Nessun login extra: il twin vive nello stesso provider e riusa la credenziale già configurata (env, stored, OAuth/SSO come Codex business plan). I modelli originali restano intatti e selezionabili.
49
+ **Più twin insieme**: ogni `/jev upstream` *aggiunge* un modello guarded, senza
50
+ rimuovere i precedenti. I twin vengono registrati anche all'avvio (i modelli
51
+ dinamici come Codex sono letti dal models-store), quindi la sessione riapre
52
+ direttamente sul `__jev` senza warning. Il **primario** (ultimo aggiunto, o
53
+ quello salvato come tale) è il target di auto-selezione e revert.
54
+
55
+ 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.
47
56
 
48
57
  In `automatic` il twin viene registrato già all'avvio (prima del ripristino del
49
58
  modello di sessione), così la sessione può riaprire direttamente su `__jev`.
@@ -51,12 +60,13 @@ modello di sessione), così la sessione può riaprire direttamente su `__jev`.
51
60
  Torna normale con `/model` (modello normale) + `/jev mode on-demand`.
52
61
  In `automatic`, selezionare un modello non protetto lo fa ritornare
53
62
  subito al twin (enforcement a livello selezione).
54
- `/jev upstream` senza argomenti ripristina l'ultimo salvato, `/jev off` rimuove il twin
55
- (e torna a on-demand, provider originale ripristinato). In `automatic` il twin
56
- si ripristina da solo al riavvio.
63
+ `/jev upstream` senza argomenti ripristina tutti i twin salvati, `/jev untwin P M`
64
+ ne rimuove uno solo, `/jev off` li rimuove tutti
65
+ (e torna a on-demand, provider originali ripristinati). In `automatic` i twin
66
+ si ripristinano da soli al riavvio.
57
67
 
58
- Se filtri i modelli con `enabledModels`, tieni la voce del twin (es.
59
- `deepseek/deepseek-flash__jev`) o resta nascosto.
68
+ Se filtri i modelli con `enabledModels`, tieni le voci `__jev` dei twin che usi
69
+ o restano nascosti.
60
70
  L'installazione punta alla cartella: dopo un aggiornamento del codice basta `/reload`.
61
71
 
62
72
  Headless/CI: `JEV_AUTO_UPSTREAM=provider/model` crea il guarded all'avvio
@@ -8,7 +8,10 @@
8
8
  "timeoutMs": 4000,
9
9
  "timeoutMsTypesafe": 1500,
10
10
  "timeoutMsOpenRouter": 4000,
11
- "retryTransients": { "enabled": true, "maxRetries": 1 },
11
+ "retryTransients": {
12
+ "enabled": true,
13
+ "maxRetries": 1
14
+ },
12
15
  "apiKeyFile": "~/.pi/agent/jev-api-key.txt"
13
16
  },
14
17
  "policy": {
@@ -22,6 +25,16 @@
22
25
  "transport": "provider-gate",
23
26
  "maxRegenerations": 2,
24
27
  "requireGuardedModel": true,
28
+ "twins": [
29
+ {
30
+ "provider": "deepseek",
31
+ "model": "deepseek-flash"
32
+ },
33
+ {
34
+ "provider": "openai-codex",
35
+ "model": "gpt-5.6-terra"
36
+ }
37
+ ],
25
38
  "upstreamProvider": "deepseek",
26
39
  "upstreamModel": "deepseek-flash"
27
40
  },
@@ -44,7 +57,9 @@
44
57
  },
45
58
  "outputJudge": {
46
59
  "enabled": true,
47
- "tools": ["bash"],
60
+ "tools": [
61
+ "bash"
62
+ ],
48
63
  "outputChars": 2000,
49
64
  "leakThreshold": 0.9,
50
65
  "minConfidence": 0.6,
@@ -52,8 +67,16 @@
52
67
  },
53
68
  "exfilCheck": {
54
69
  "enabled": true,
55
- "tools": ["bash", "powershell"],
56
- "blockOn": { "destructive": 0.9, "exfiltration": 0.7, "beyondScope": 0.85, "impact": 2.5 },
70
+ "tools": [
71
+ "bash",
72
+ "powershell"
73
+ ],
74
+ "blockOn": {
75
+ "destructive": 0.9,
76
+ "exfiltration": 0.7,
77
+ "beyondScope": 0.85,
78
+ "impact": 2.5
79
+ },
57
80
  "minConfidence": 0.5,
58
81
  "cacheSeconds": 120,
59
82
  "blockWithoutUI": false
@@ -9,7 +9,10 @@ import {
9
9
  parseUpstreamRef,
10
10
  resolveBackend,
11
11
  saveConfig,
12
+ twinRefs,
13
+ withTwinRefs,
12
14
  type JevConfig,
15
+ type TwinRef,
13
16
  } from "../src/config.ts";
14
17
  import { createReviewer, createTypedAsk } from "../src/factory.ts";
15
18
  import { createMetrics } from "../src/metrics.ts";
@@ -18,6 +21,7 @@ import {
18
21
  STATIC_DEEPSEEK_FLASH,
19
22
  buildOverlaidProvider,
20
23
  isTwinId,
24
+ mergeModelsById,
21
25
  twinIdFor,
22
26
  } from "../src/automatic/overlay.ts";
23
27
  import type { GateVerdictReport } from "../src/automatic/guardian.ts";
@@ -73,24 +77,56 @@ export default function (pi: ExtensionAPI) {
73
77
  let lastExfilErrorAt = 0;
74
78
  const metrics = createMetrics();
75
79
 
76
- // Opt-in: nessun overlay all'avvio. Si crea solo con /jev upstream
77
- // (o ripristino automatico quando la config dice automatic).
78
- let overlaidProviderId: string | undefined;
79
- let upstreamRefs = new Map<string, Provider>();
80
- let twinTarget: { provider: string; model: string } | undefined;
80
+ // Twin attivi, uno o più per provider. Nessun overlay finché la config non
81
+ // li attiva (automatic) o l'utente non usa /jev upstream (opt-in).
82
+ interface OverlayState {
83
+ upstream: Provider;
84
+ twinIds: string[];
85
+ }
86
+ const overlays = new Map<string, OverlayState>();
81
87
 
82
88
  const getConfig = () => config;
83
89
  const getReview = () => handle.review;
84
90
  const getGateReview = () => gateHandle.review;
85
91
  const getInfo = () => ({ backend: handle.backend, model: handle.model });
86
92
 
93
+ /** Twin primario (auto-selezione e revert): ultimo aggiunto che è attivo. */
94
+ function primaryTwin(): TwinRef | undefined {
95
+ const saved = twinRefs(config);
96
+ const wanted =
97
+ saved.find(
98
+ (r) =>
99
+ r.provider === config.automatic.upstreamProvider &&
100
+ r.model === config.automatic.upstreamModel,
101
+ ) ?? saved[saved.length - 1];
102
+ if (wanted && overlays.get(wanted.provider)?.twinIds.includes(wanted.model)) {
103
+ return wanted;
104
+ }
105
+ // Ripiego: primo twin attivo.
106
+ for (const [providerId, state] of overlays) {
107
+ const model = state.twinIds[0];
108
+ if (model) return { provider: providerId, model };
109
+ }
110
+ return undefined;
111
+ }
112
+
113
+ function activeTwinRefs(): TwinRef[] {
114
+ const refs: TwinRef[] = [];
115
+ for (const [providerId, state] of overlays) {
116
+ for (const model of state.twinIds) refs.push({ provider: providerId, model });
117
+ }
118
+ return refs;
119
+ }
120
+
87
121
  function getGateStatus(): GateStatus {
88
- if (overlaidProviderId && twinTarget) {
122
+ const primary = primaryTwin();
123
+ if (primary) {
124
+ const count = activeTwinRefs().length;
89
125
  return {
90
126
  active: true,
91
- twin: `${twinTarget.provider}/${twinIdFor(twinTarget.model)}`,
92
- upstream: `${twinTarget.provider}/${twinTarget.model}`,
93
- reason: `overlay attivo su ${overlaidProviderId} (stessa auth del provider)`,
127
+ twin: `${primary.provider}/${twinIdFor(primary.model)}`,
128
+ upstream: `${primary.provider}/${primary.model}`,
129
+ reason: `${count} twin attiv${count === 1 ? "o" : "i"} su ${overlays.size} provider (stessa auth)`,
94
130
  };
95
131
  }
96
132
  return {
@@ -99,13 +135,12 @@ export default function (pi: ExtensionAPI) {
99
135
  };
100
136
  }
101
137
 
138
+ /** True se la coppia provider/modello è una twin da noi registrata. */
102
139
  function isGuardedModel(provider: string | undefined, modelId: string | undefined): boolean {
103
- if (!overlaidProviderId || !twinTarget || !provider || !modelId) return false;
104
- return (
105
- provider === twinTarget.provider &&
106
- modelId === twinIdFor(twinTarget.model) &&
107
- isTwinId(modelId)
108
- );
140
+ if (!provider || !modelId || !isTwinId(modelId)) return false;
141
+ const state = overlays.get(provider);
142
+ if (!state) return false;
143
+ return state.twinIds.some((model) => twinIdFor(model) === modelId);
109
144
  }
110
145
 
111
146
  function refresh() {
@@ -118,15 +153,12 @@ export default function (pi: ExtensionAPI) {
118
153
  return config;
119
154
  }
120
155
 
121
- /** Provider upstream vero, spacchettando il nostro overlay se già attivo. */
156
+ /** Provider upstream vero: il nostro upstream catturato, o il live dal registry. */
122
157
  function liveUpstreamProvider(
123
158
  registry: ModelRegistry,
124
159
  providerId: string,
125
160
  ): Provider | undefined {
126
- if (overlaidProviderId === providerId) {
127
- return upstreamRefs.get(providerId);
128
- }
129
- return registry.getProvider(providerId);
161
+ return overlays.get(providerId)?.upstream ?? registry.getProvider(providerId);
130
162
  }
131
163
 
132
164
  function snapshotUpstream(
@@ -147,56 +179,78 @@ export default function (pi: ExtensionAPI) {
147
179
  return { provider, model: model as Model<Api> };
148
180
  }
149
181
 
150
- function applyOverlay(
151
- snapshot: { provider: Provider; model: Model<Api> },
182
+ /** Registra (o aggiorna) l'overlay di un provider con i suoi twin. */
183
+ function registerOverlay(
184
+ providerId: string,
185
+ upstream: Provider,
186
+ twinIds: string[],
187
+ ): void {
188
+ const overlay = buildOverlaidProvider({
189
+ upstream,
190
+ twinModelIds: twinIds,
191
+ getConfig,
192
+ getReview: getGateReview,
193
+ onVerdict: recordGateVerdict,
194
+ });
195
+ pi.registerProvider(overlay);
196
+ overlays.set(providerId, { upstream, twinIds: [...twinIds] });
197
+ }
198
+
199
+ /**
200
+ * Allinea il registry ai twin richiesti: registra i provider coinvolti e
201
+ * ripristina (unregister) quelli che non hanno più twin.
202
+ */
203
+ function applyTwins(
204
+ refs: TwinRef[],
205
+ registry: ModelRegistry,
152
206
  persist: boolean,
153
- ): UpstreamResult {
154
- const providerId = snapshot.provider.id;
207
+ ): { ok: boolean; error?: string; twins?: string[] } {
208
+ const byProvider = new Map<string, string[]>();
209
+ for (const ref of refs) {
210
+ const list = byProvider.get(ref.provider) ?? [];
211
+ if (!list.includes(ref.model)) list.push(ref.model);
212
+ byProvider.set(ref.provider, list);
213
+ }
214
+
155
215
  try {
156
- // Rimuovi overlay precedente su provider diverso (ripristina builtin).
157
- if (overlaidProviderId && overlaidProviderId !== providerId) {
158
- try {
159
- pi.unregisterProvider(overlaidProviderId);
160
- } catch {
161
- // ignora
216
+ // Provider non più gemellati: unregister builtin ripristinato.
217
+ for (const providerId of [...overlays.keys()]) {
218
+ if (!byProvider.has(providerId)) {
219
+ try {
220
+ pi.unregisterProvider(providerId);
221
+ } catch {
222
+ // ignora
223
+ }
224
+ overlays.delete(providerId);
162
225
  }
163
- upstreamRefs.delete(overlaidProviderId);
164
226
  }
165
- // Cattura il live PRIMA dell'override (dopo, getProvider dà l'overlay).
166
- if (!upstreamRefs.has(providerId)) {
167
- upstreamRefs.set(providerId, snapshot.provider);
227
+
228
+ for (const [providerId, twinIds] of byProvider) {
229
+ // Cattura il live PRIMA dell'override (dopo, getProvider dà l'overlay).
230
+ const existing = overlays.get(providerId)?.upstream;
231
+ const live = existing ?? registry.getProvider(providerId);
232
+ if (!live) return { ok: false, error: `Provider non trovato: ${providerId}` };
233
+ for (const model of twinIds) {
234
+ const has = live.getModels().some((m) => m.id === model && !isTwinId(m.id));
235
+ if (!has) return { ok: false, error: `Modello non trovato: ${providerId}/${model}` };
236
+ }
237
+ registerOverlay(providerId, live, twinIds);
168
238
  }
169
- const overlay = buildOverlaidProvider({
170
- upstream: upstreamRefs.get(providerId) as Provider,
171
- twinModelIds: [snapshot.model.id],
172
- getConfig,
173
- getReview: getGateReview,
174
- onVerdict: recordGateVerdict,
175
- });
176
- pi.registerProvider(overlay);
177
- overlaidProviderId = providerId;
178
- twinTarget = { provider: providerId, model: snapshot.model.id };
179
- config = {
180
- ...config,
181
- automatic: {
182
- ...config.automatic,
183
- upstreamProvider: providerId,
184
- upstreamModel: snapshot.model.id,
185
- },
186
- };
239
+
240
+ config = withTwinRefs(config, refs);
187
241
  if (persist) {
188
242
  try {
189
243
  saveConfig(config);
190
244
  } catch (error) {
191
245
  return {
192
246
  ok: false,
193
- error: `Upstream aggiornato in memoria ma salvataggio fallito: ${
247
+ error: `Twin aggiornati in memoria ma salvataggio fallito: ${
194
248
  error instanceof Error ? error.message : String(error)
195
249
  }`,
196
250
  };
197
251
  }
198
252
  }
199
- return { ok: true, twin: `${providerId}/${twinIdFor(snapshot.model.id)}` };
253
+ return { ok: true, twins: activeTwinRefs().map((r) => `${r.provider}/${twinIdFor(r.model)}`) };
200
254
  } catch (error) {
201
255
  return {
202
256
  ok: false,
@@ -205,7 +259,7 @@ export default function (pi: ExtensionAPI) {
205
259
  }
206
260
  }
207
261
 
208
- /** Crea o sposta il twin guarded (persistente). */
262
+ /** Aggiunge un twin (persistente). Gli altri restano attivi. */
209
263
  function setUpstream(
210
264
  registry: ModelRegistry,
211
265
  providerId: string,
@@ -213,55 +267,78 @@ export default function (pi: ExtensionAPI) {
213
267
  ): UpstreamResult {
214
268
  const found = snapshotUpstream(registry, providerId, modelId);
215
269
  if ("error" in found) return { ok: false, error: found.error };
216
- return applyOverlay(found, true);
270
+ const refs = twinRefs(config).filter(
271
+ (r) => !(r.provider === providerId && r.model === modelId),
272
+ );
273
+ refs.push({ provider: providerId, model: modelId });
274
+ const res = applyTwins(refs, registry, true);
275
+ if (!res.ok) return { ok: false, error: res.error };
276
+ return { ok: true, twin: `${providerId}/${twinIdFor(modelId)}` };
217
277
  }
218
278
 
219
- /** Riapplica l'ultimo upstream salvato in config. */
279
+ /** Rimuove un twin; se il provider resta senza twin, ripristina il builtin. */
280
+ function removeTwin(
281
+ registry: ModelRegistry,
282
+ providerId: string,
283
+ modelId: string,
284
+ ): { ok: boolean; error?: string } {
285
+ const refs = twinRefs(config);
286
+ const next = refs.filter((r) => !(r.provider === providerId && r.model === modelId));
287
+ if (next.length === refs.length) {
288
+ return { ok: false, error: `twin non registrato: ${providerId}/${modelId}` };
289
+ }
290
+ const res = applyTwins(next, registry, true);
291
+ return res.ok ? { ok: true } : { ok: false, error: res.error };
292
+ }
293
+
294
+ /** Riapplica tutti i twin salvati in config (es. dopo il riavvio). */
220
295
  function refreshUpstream(registry: ModelRegistry): UpstreamResult {
221
- const found = snapshotUpstream(
222
- registry,
223
- config.automatic.upstreamProvider,
224
- config.automatic.upstreamModel,
225
- );
226
- if ("error" in found) return { ok: false, error: found.error };
227
- return applyOverlay(found, false);
296
+ const res = applyTwins(twinRefs(config), registry, false);
297
+ if (!res.ok) return { ok: false, error: res.error };
298
+ const primary = primaryTwin();
299
+ return {
300
+ ok: true,
301
+ twin: primary ? `${primary.provider}/${twinIdFor(primary.model)}` : undefined,
302
+ };
228
303
  }
229
304
 
230
- /** Rimuove l'overlay (il builtin torna da solo). */
231
- function removeGuarded(): { ok: boolean; error?: string } {
232
- if (!overlaidProviderId) return { ok: false, error: "nessun guarded attivo" };
233
- try {
234
- pi.unregisterProvider(overlaidProviderId);
235
- } catch (error) {
236
- return {
237
- ok: false,
238
- error: error instanceof Error ? error.message : String(error),
239
- };
305
+ /** Rimuove tutti gli overlay (i builtin tornano da soli). */
306
+ function removeGuarded(): { ok: boolean; error?: string; removed?: number } {
307
+ if (overlays.size === 0) return { ok: false, error: "nessun guarded attivo" };
308
+ const removed = overlays.size;
309
+ for (const providerId of [...overlays.keys()]) {
310
+ try {
311
+ pi.unregisterProvider(providerId);
312
+ } catch (error) {
313
+ return {
314
+ ok: false,
315
+ error: error instanceof Error ? error.message : String(error),
316
+ };
317
+ }
318
+ overlays.delete(providerId);
240
319
  }
241
- upstreamRefs.delete(overlaidProviderId);
242
- overlaidProviderId = undefined;
243
- twinTarget = undefined;
244
- return { ok: true };
320
+ return { ok: true, removed };
245
321
  }
246
322
 
247
- /** Seleziona il twin attivo come modello di sessione. */
323
+ /** Seleziona il twin primario come modello di sessione. */
248
324
  async function switchToGuarded(
249
325
  registry: ModelRegistry,
250
326
  ): Promise<{ ok: boolean; error?: string; model?: string }> {
251
- if (!overlaidProviderId || !twinTarget) {
327
+ const primary = primaryTwin();
328
+ if (!primary) {
252
329
  return { ok: false, error: "nessun guarded attivo — prima /jev upstream <provider> <model>" };
253
330
  }
254
- const twinId = twinIdFor(twinTarget.model);
255
- const model = registry.find(overlaidProviderId, twinId);
331
+ const twinId = twinIdFor(primary.model);
332
+ const model = registry.find(primary.provider, twinId);
256
333
  if (!model) {
257
- return { ok: false, error: `twin non in registry: ${overlaidProviderId}/${twinId}` };
334
+ return { ok: false, error: `twin non in registry: ${primary.provider}/${twinId}` };
258
335
  }
259
336
  try {
260
337
  const ok = await pi.setModel(model);
261
338
  if (!ok) {
262
- return { ok: false, error: `auth mancante per ${overlaidProviderId} — /login ${overlaidProviderId}` };
339
+ return { ok: false, error: `auth mancante per ${primary.provider} — /login ${primary.provider}` };
263
340
  }
264
- return { ok: true, model: `${overlaidProviderId}/${model.id}` };
341
+ return { ok: true, model: `${primary.provider}/${model.id}` };
265
342
  } catch (error) {
266
343
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
267
344
  }
@@ -298,9 +375,15 @@ export default function (pi: ExtensionAPI) {
298
375
  reviewerInfo: getInfo,
299
376
  metrics,
300
377
  gateStatus: getGateStatus,
378
+ twinStatus: () => ({
379
+ active: activeTwinRefs(),
380
+ saved: twinRefs(config),
381
+ primary: primaryTwin(),
382
+ }),
301
383
  setUpstream,
302
384
  refreshUpstream,
303
385
  removeGuarded,
386
+ removeTwin,
304
387
  switchToGuarded,
305
388
  getLastOutput: () => lastOutput,
306
389
  getLastGate: () => lastGate,
@@ -314,59 +397,45 @@ export default function (pi: ExtensionAPI) {
314
397
  // sostituisce col clone live dal registry.
315
398
  const bootAuto = process.env.JEV_AUTO_UPSTREAM?.trim();
316
399
  const autoOff = bootAuto === "0" || bootAuto?.toLowerCase() === "off";
317
- let seedTarget: { provider: string; model: string } | undefined;
400
+ let seedRefs: TwinRef[] = [];
318
401
  if (bootAuto && !autoOff) {
319
402
  const parsed = parseUpstreamRef([bootAuto]);
320
- if (!("error" in parsed)) seedTarget = parsed;
403
+ if (!("error" in parsed)) seedRefs = [parsed];
321
404
  } else if (!bootAuto && config.mode === "automatic") {
322
- seedTarget = {
323
- provider: config.automatic.upstreamProvider,
324
- model: config.automatic.upstreamModel,
325
- };
405
+ seedRefs = twinRefs(config);
326
406
  }
327
- if (seedTarget) {
328
- try {
329
- // providers/all è mappato dagli alias di pi; il subpath del singolo
330
- // provider non lo è e fallirebbe a load (solo installazioni npm).
331
- const base = builtinProviders().find((p) => p.id === seedTarget?.provider);
332
- if (!base) throw new Error(`provider ${seedTarget.provider} not in builtins`);
333
- // Il catalogo builtin può non avere il modello salvato: provider
334
- // dinamici (es. openai-codex) tengono la lista nel models-store,
335
- // altri (deepseek-flash su versioni vecchie) in una definizione statica.
336
- const storeModels = readStoreModels(seedTarget.provider);
337
- const extra = [
338
- ...(seedTarget.provider === "deepseek" && seedTarget.model === "deepseek-flash"
339
- ? [STATIC_DEEPSEEK_FLASH]
340
- : []),
341
- ...storeModels,
342
- ];
343
- const seedUpstream: Provider = {
344
- ...base,
345
- getModels: () => {
346
- const models = base.getModels();
347
- const missing = extra.filter(
348
- (m) => !models.some((existing) => existing.id === m.id),
349
- );
350
- return [...models, ...missing];
351
- },
352
- };
353
- const hasModel = seedUpstream
354
- .getModels()
355
- .some((m) => m.id === seedTarget?.model);
356
- if (!hasModel) throw new Error(`model ${seedTarget.model} not in builtins`);
357
- const overlay = buildOverlaidProvider({
358
- upstream: seedUpstream,
359
- twinModelIds: [seedTarget.model],
360
- getConfig,
361
- getReview: getGateReview,
362
- onVerdict: recordGateVerdict,
363
- });
364
- pi.registerProvider(overlay);
365
- upstreamRefs.set(seedTarget.provider, seedUpstream);
366
- overlaidProviderId = seedTarget.provider;
367
- twinTarget = { provider: seedTarget.provider, model: seedTarget.model };
368
- } catch {
369
- // Nessun seed: il twin arriverà da session_start se possibile.
407
+ if (seedRefs.length > 0) {
408
+ // Raggruppa per provider: un overlay per provider con tutti i suoi twin.
409
+ const byProvider = new Map<string, string[]>();
410
+ for (const ref of seedRefs) {
411
+ const list = byProvider.get(ref.provider) ?? [];
412
+ if (!list.includes(ref.model)) list.push(ref.model);
413
+ byProvider.set(ref.provider, list);
414
+ }
415
+ for (const [providerId, twinIds] of byProvider) {
416
+ try {
417
+ // providers/all è mappato dagli alias di pi; il subpath del singolo
418
+ // provider non lo è e fallirebbe a load (solo installazioni npm).
419
+ const base = builtinProviders().find((p) => p.id === providerId);
420
+ if (!base) throw new Error(`provider ${providerId} not in builtins`);
421
+ // Il catalogo builtin può non avere i modelli salvati: provider
422
+ // dinamici (es. openai-codex) li tengono nel models-store, altri
423
+ // (deepseek-flash su versioni vecchie) in una definizione statica.
424
+ const extra = [
425
+ ...(providerId === "deepseek" ? [STATIC_DEEPSEEK_FLASH] : []),
426
+ ...readStoreModels(providerId),
427
+ ];
428
+ const seedUpstream: Provider = {
429
+ ...base,
430
+ getModels: () => mergeModelsById(base.getModels(), extra),
431
+ };
432
+ const known = new Set(seedUpstream.getModels().map((m) => m.id));
433
+ const usable = twinIds.filter((model) => known.has(model));
434
+ if (usable.length === 0) continue;
435
+ registerOverlay(providerId, seedUpstream, usable);
436
+ } catch {
437
+ // Provider non gemellabile all'avvio: session_start riproverà dal registry.
438
+ }
370
439
  }
371
440
  }
372
441
 
@@ -382,7 +451,7 @@ export default function (pi: ExtensionAPI) {
382
451
  if ("error" in found) {
383
452
  if (ctx.hasUI) ctx.ui.notify(`JEV_AUTO_UPSTREAM fallito: ${found.error}`, "warning");
384
453
  } else {
385
- applyOverlay(found, false);
454
+ applyTwins([parsed], ctx.modelRegistry, false);
386
455
  }
387
456
  }
388
457
  } catch {
@@ -392,7 +461,7 @@ export default function (pi: ExtensionAPI) {
392
461
  // Config automatic senza guarded = contraddittorio: ripristina + seleziona.
393
462
  try {
394
463
  if (config.mode !== "automatic" || !config.automatic.requireGuardedModel) return;
395
- if (!overlaidProviderId || !twinTarget) {
464
+ if (overlays.size === 0) {
396
465
  const restored = refreshUpstream(ctx.modelRegistry);
397
466
  if (!restored.ok) {
398
467
  if (ctx.hasUI) {
@@ -401,17 +470,18 @@ export default function (pi: ExtensionAPI) {
401
470
  return;
402
471
  }
403
472
  }
404
- if (!overlaidProviderId || !twinTarget) return;
405
- const twinId = twinIdFor(twinTarget.model);
406
- if (ctx.model?.provider === overlaidProviderId && ctx.model?.id === twinId) return;
407
- const twin = ctx.modelRegistry.find(overlaidProviderId, twinId);
473
+ const primary = primaryTwin();
474
+ if (!primary) return;
475
+ const twinId = twinIdFor(primary.model);
476
+ if (ctx.model?.provider === primary.provider && ctx.model?.id === twinId) return;
477
+ const twin = ctx.modelRegistry.find(primary.provider, twinId);
408
478
  if (!twin) return;
409
479
  const switched = await pi.setModel(twin);
410
480
  if (ctx.hasUI) {
411
481
  ctx.ui.notify(
412
482
  switched
413
- ? `Jev automatic: selezionato ${overlaidProviderId}/${twinId}.`
414
- : `Jev automatic: auth mancante per ${overlaidProviderId} — /login ${overlaidProviderId}.`,
483
+ ? `Jev automatic: selezionato ${primary.provider}/${twinId}.`
484
+ : `Jev automatic: auth mancante per ${primary.provider} — /login ${primary.provider}.`,
415
485
  switched ? "info" : "warning",
416
486
  );
417
487
  }
@@ -425,13 +495,12 @@ export default function (pi: ExtensionAPI) {
425
495
  pi.on("model_select", async (event, ctx) => {
426
496
  try {
427
497
  if (config.mode !== "automatic" || !config.automatic.requireGuardedModel) return;
428
- if (!overlaidProviderId || !twinTarget) return;
498
+ if (overlays.size === 0) return;
429
499
  if (isGuardedModel(event.model.provider, event.model.id)) return;
430
500
 
431
- const twinId = twinIdFor(twinTarget.model);
432
- const twin = ctx.modelRegistry.find(overlaidProviderId, twinId);
501
+ const primary = primaryTwin();
433
502
  const fallback =
434
- twin ??
503
+ (primary ? ctx.modelRegistry.find(primary.provider, twinIdFor(primary.model)) : undefined) ??
435
504
  (event.previousModel && isGuardedModel(event.previousModel.provider, event.previousModel.id)
436
505
  ? event.previousModel
437
506
  : undefined);
@@ -671,9 +740,11 @@ export default function (pi: ExtensionAPI) {
671
740
 
672
741
  const msg = event.message as { provider?: string; model?: string };
673
742
  if (isGuardedModel(msg.provider, msg.model)) {
743
+ const primary = primaryTwin();
674
744
  pi.appendEntry("jev-guarded", {
675
- twin: twinTarget ? `${twinTarget.provider}/${twinIdFor(twinTarget.model)}` : undefined,
676
- upstream: twinTarget ? `${twinTarget.provider}/${twinTarget.model}` : undefined,
745
+ twin: msg.model ? `${msg.provider}/${msg.model}` : undefined,
746
+ upstream: primary ? `${primary.provider}/${primary.model}` : undefined,
747
+ active: activeTwinRefs().map((r) => `${r.provider}/${r.model}`),
677
748
  });
678
749
  return;
679
750
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jev-guard",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Jev validation guard for pi: on-demand tool + automatic provider gate. Dual backend: TypeSafe direct + OpenRouter.",
6
6
  "keywords": [
@@ -100,6 +100,21 @@ export function twinModelFor(
100
100
  };
101
101
  }
102
102
 
103
+ /** Unione di liste modelli con dedup per id (il primo vince). */
104
+ export function mergeModelsById(
105
+ primary: readonly Model<Api>[],
106
+ extra: readonly Model<Api>[],
107
+ ): Model<Api>[] {
108
+ const out: Model<Api>[] = [];
109
+ const seen = new Set<string>();
110
+ for (const model of [...primary, ...extra]) {
111
+ if (!model || typeof model.id !== "string" || seen.has(model.id)) continue;
112
+ seen.add(model.id);
113
+ out.push(model);
114
+ }
115
+ return out;
116
+ }
117
+
103
118
  function stripTwins(models: readonly Model<Api>[]): Model<Api>[] {
104
119
  return models.filter((m) => !isTwinId(m.id)).map((m) => ({ ...m }));
105
120
  }
@@ -114,13 +129,17 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
114
129
  let twinToOriginal = new Map<string, Model<Api>>();
115
130
  let currentModels: Model<Api>[] = [];
116
131
 
117
- function rebuildTwins(originals: readonly Model<Api>[]): Model<Api>[] {
132
+ function rebuildTwins(rawOriginals: readonly Model<Api>[]): Model<Api>[] {
118
133
  twinToOriginal = new Map();
119
134
  const chosen = new Set(deps.twinModelIds);
135
+ // Dedup per id: sorgenti miste (builtin + store + statica) possono
136
+ // ripetere lo stesso modello e produrrebbero twin duplicati.
137
+ const originals = mergeModelsById(rawOriginals, []);
120
138
  const twins: Model<Api>[] = [];
121
139
  for (const original of originals) {
122
140
  if (chosen.has(original.id)) {
123
141
  const twin = twinModelFor(original, upstream.id);
142
+ if (twinToOriginal.has(twin.id)) continue;
124
143
  twinToOriginal.set(twin.id, original);
125
144
  twins.push(twin);
126
145
  }
package/src/commands.ts CHANGED
@@ -2,8 +2,15 @@ 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 type { JevConfig } from "./config.ts";
6
- import { defaultConfigPath, expandHome, parseUpstreamRef, resolveBackend, saveConfig } from "./config.ts";
5
+ import type { JevConfig, TwinRef } from "./config.ts";
6
+ import {
7
+ defaultConfigPath,
8
+ expandHome,
9
+ parseUpstreamRef,
10
+ resolveBackend,
11
+ saveConfig,
12
+ twinRefs,
13
+ } from "./config.ts";
7
14
  import type { ReviewFn } from "./reviewer.ts";
8
15
  import { reviewWithCoverage } from "./reviewer.ts";
9
16
  import type { Metrics } from "./metrics.ts";
@@ -17,6 +24,15 @@ export interface GateStatusView {
17
24
  reason: string;
18
25
  }
19
26
 
27
+ export interface TwinStatusView {
28
+ /** Twin attivi adesso (overlay registrati). */
29
+ active: TwinRef[];
30
+ /** Twin salvati in config (ripristinati all'avvio/refresh). */
31
+ saved: TwinRef[];
32
+ /** Twin primario: auto-selezione e revert. */
33
+ primary?: TwinRef;
34
+ }
35
+
20
36
  export interface UpstreamResult {
21
37
  ok: boolean;
22
38
  error?: string;
@@ -37,7 +53,13 @@ export interface JevCommandDeps {
37
53
  modelId: string,
38
54
  ) => UpstreamResult;
39
55
  refreshUpstream: (registry: ModelRegistry) => UpstreamResult;
40
- removeGuarded: () => { ok: boolean; error?: string };
56
+ removeGuarded: () => { ok: boolean; error?: string; removed?: number };
57
+ removeTwin: (
58
+ registry: ModelRegistry,
59
+ providerId: string,
60
+ modelId: string,
61
+ ) => { ok: boolean; error?: string };
62
+ twinStatus: () => TwinStatusView;
41
63
  switchToGuarded: (registry: ModelRegistry) => Promise<{
42
64
  ok: boolean;
43
65
  error?: string;
@@ -52,6 +74,8 @@ export const JEV_SUBCOMMANDS = [
52
74
  "mode",
53
75
  "models",
54
76
  "upstream",
77
+ "twins",
78
+ "untwin",
55
79
  "off",
56
80
  "refresh-upstream",
57
81
  "check",
@@ -70,10 +94,12 @@ export const JEV_HELP_TEXT = [
70
94
  " /jev mode on-demand Solo verifiche esplicite (default, zero costi impliciti)",
71
95
  " /jev mode automatic Enforcement: seleziona il twin guarded + verifica",
72
96
  " /jev models [filtro] Modelli pi (● twin guarded attivo, ★ ultimo salvato)",
73
- " /jev upstream P M Crea twin guarded M__jev dentro il provider P (salvato)",
97
+ " /jev upstream P M Aggiunge il twin M__jev nel provider P (salvato)",
74
98
  " /jev upstream P/M Stessa cosa in forma compatta",
75
- " /jev upstream Riapplica l'ultimo upstream salvato",
76
- " /jev off Rimuove il twin (torna on-demand)",
99
+ " /jev upstream Ripristina tutti i twin salvati",
100
+ " /jev twins Elenca twin attivi e salvati ( attivo, ★ primario)",
101
+ " /jev untwin P M Rimuove un twin (anche P/M)",
102
+ " /jev off Rimuove tutti i twin (torna on-demand)",
77
103
  " /jev refresh-upstream Ricrea il twin dal registry (dopo refresh cataloghi)",
78
104
  " /jev check <file> [req] Verifica un file subito, senza cambiare modalità",
79
105
  " /jev stats Conteggi pass/block/review/unavailable + latenze",
@@ -114,7 +140,8 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
114
140
  const resolved = resolveBackend(cfg);
115
141
  const info = deps.reviewerInfo();
116
142
  const gate = deps.gateStatus();
117
- const authProvider = gate.upstream?.split("/")[0] ?? cfg.automatic.upstreamProvider;
143
+ const twinView = deps.twinStatus();
144
+ const authProvider = twinView.primary?.provider ?? cfg.automatic.upstreamProvider;
118
145
  let guardedAuth = "—";
119
146
  if (gate.active) {
120
147
  try {
@@ -165,7 +192,8 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
165
192
  `Verificatore timeout: ${resolved.timeoutMs}ms`,
166
193
  `Policy: ${cfg.policy.revision} pass<=${cfg.policy.passMaxFlawProbability} block>=${cfg.policy.blockMinFlawProbability}`,
167
194
  `Chiave presente: ${resolved.apiKeyPresent ? "sì" : "no"} (${resolved.keySource})`,
168
- `Gate: ${gate.active ? `attivo (twin ${gate.twin}, stessa auth di ${authProvider})` : `non attivo (${gate.reason})`}`,
195
+ `Gate: ${gate.active ? `attivo (twin ${gate.twin})` : `non attivo (${gate.reason})`}`,
196
+ `Twin attivi: ${twinView.active.length > 0 ? twinView.active.map((r) => `${r.provider}/${r.model}__jev`).join(", ") : "nessuno"}`,
169
197
  `Twin nel picker: ${twinScope}`,
170
198
  `Modello corrente: ${currentModel}`,
171
199
  `Ultimo upstream salvato: ${cfg.automatic.upstreamProvider}/${cfg.automatic.upstreamModel}`,
@@ -242,12 +270,13 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
242
270
  }
243
271
 
244
272
  if (sub === "upstream") {
245
- // Senza argomenti: riapplica l'ultimo upstream salvato.
273
+ // Senza argomenti: ripristina tutti i twin salvati.
246
274
  if (parts.length === 1) {
247
275
  const res = deps.refreshUpstream(ctx.modelRegistry);
276
+ const count = deps.twinStatus().active.length;
248
277
  ctx.ui.notify(
249
278
  res.ok
250
- ? `Twin ripristinato: ${res.twin}. Selezionalo con /model.`
279
+ ? `Twin ripristinati: ${count} (primario ${res.twin ?? "—"}). Seleziona con /model.`
251
280
  : `Ripristino fallito: ${res.error}`,
252
281
  res.ok ? "info" : "error",
253
282
  );
@@ -260,17 +289,70 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
260
289
  }
261
290
  const res = deps.setUpstream(ctx.modelRegistry, parsed.provider, parsed.model);
262
291
  if (!res.ok) {
263
- ctx.ui.notify(`Upstream non cambiato: ${res.error}`, "error");
292
+ ctx.ui.notify(`Twin non aggiunto: ${res.error}`, "error");
264
293
  return;
265
294
  }
295
+ const total = deps.twinStatus().active.length;
266
296
  ctx.ui.notify(
267
- `Twin creato: ${res.twin} da ${parsed.provider}/${parsed.model} (salvato, stessa auth del provider). ` +
297
+ `Twin aggiunto: ${res.twin} (totale attivi: ${total}, stessa auth del provider). ` +
268
298
  `Selezionalo con /model per enforcement in automatic.`,
269
299
  "info",
270
300
  );
271
301
  return;
272
302
  }
273
303
 
304
+ if (sub === "twins") {
305
+ const view = deps.twinStatus();
306
+ if (view.active.length === 0 && view.saved.length === 0) {
307
+ ctx.ui.notify(
308
+ "Jev: nessun twin — aggiungine con /jev upstream <provider> <model>.",
309
+ "info",
310
+ );
311
+ return;
312
+ }
313
+ const primaryKey = view.primary
314
+ ? `${view.primary.provider}/${view.primary.model}`
315
+ : undefined;
316
+ const activeKeys = new Set(view.active.map((r) => `${r.provider}/${r.model}`));
317
+ const lines: string[] = [];
318
+ for (const ref of view.saved) {
319
+ const key = `${ref.provider}/${ref.model}`;
320
+ const mark = activeKeys.has(key) ? "● " : "○ ";
321
+ const star = key === primaryKey ? "★" : " ";
322
+ lines.push(`${mark}${star} ${key}${activeKeys.has(key) ? "" : " (non attivo)"}`);
323
+ }
324
+ ctx.ui.notify(
325
+ [
326
+ `Twin attivi: ${view.active.length} · salvati: ${view.saved.length}`,
327
+ "● registrato ★ primario (auto-selezione/revert)",
328
+ "",
329
+ ...lines,
330
+ ].join("\n"),
331
+ "info",
332
+ );
333
+ return;
334
+ }
335
+
336
+ if (sub === "untwin") {
337
+ const parsed = parseUpstreamRef(parts.slice(1));
338
+ if ("error" in parsed) {
339
+ ctx.ui.notify(
340
+ `${parsed.error.replace("/jev upstream", "/jev untwin")}`,
341
+ "warning",
342
+ );
343
+ return;
344
+ }
345
+ const res = deps.removeTwin(ctx.modelRegistry, parsed.provider, parsed.model);
346
+ const total = deps.twinStatus().active.length;
347
+ ctx.ui.notify(
348
+ res.ok
349
+ ? `Twin rimosso: ${parsed.provider}/${parsed.model} (attivi: ${total}).`
350
+ : `Niente da rimuovere: ${res.error}`,
351
+ res.ok ? "info" : "warning",
352
+ );
353
+ return;
354
+ }
355
+
274
356
  if (sub === "off") {
275
357
  const res = deps.removeGuarded();
276
358
  if (res.ok) {
@@ -279,7 +361,7 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
279
361
  }
280
362
  ctx.ui.notify(
281
363
  res.ok
282
- ? "Twin rimosso (provider originale ripristinato), modalità on-demand."
364
+ ? `Twin rimossi: ${res.removed ?? 0} (provider originali ripristinati), modalità on-demand.`
283
365
  : `Niente da rimuovere: ${res.error}`,
284
366
  res.ok ? "info" : "warning",
285
367
  );
package/src/config.ts CHANGED
@@ -32,6 +32,9 @@ export interface JevConfig {
32
32
  transport: "provider-gate";
33
33
  maxRegenerations: number;
34
34
  requireGuardedModel: boolean;
35
+ /** Twin salvati: più modelli guarded insieme (uno o più per provider). */
36
+ twins: TwinRef[];
37
+ /** Ultimo twin aggiunto: primario per auto-selezione e revert. */
35
38
  upstreamProvider: string;
36
39
  upstreamModel: string;
37
40
  };
@@ -89,6 +92,11 @@ export interface JevConfig {
89
92
  };
90
93
  }
91
94
 
95
+ export interface TwinRef {
96
+ provider: string;
97
+ model: string;
98
+ }
99
+
92
100
  export const DEFAULT_CONFIG: JevConfig = {
93
101
  version: 1,
94
102
  mode: "on-demand",
@@ -114,8 +122,11 @@ export const DEFAULT_CONFIG: JevConfig = {
114
122
  transport: "provider-gate",
115
123
  maxRegenerations: 2,
116
124
  requireGuardedModel: true,
117
- upstreamProvider: "deepseek",
118
- upstreamModel: "deepseek-flash",
125
+ twins: [],
126
+ // Vuoti di default: nessun twin "salvato" finché l'utente non ne aggiunge
127
+ // uno con /jev upstream. Il seed usa questa lista (o l'env).
128
+ upstreamProvider: "",
129
+ upstreamModel: "",
119
130
  },
120
131
  limits: {
121
132
  maxPayloadBytes: 120000,
@@ -184,11 +195,15 @@ export function loadConfig(configPath?: string): JevConfig {
184
195
  }
185
196
  }
186
197
 
187
- const merged = mergeDeep(
198
+ const loaded = mergeDeep(
188
199
  JSON.parse(JSON.stringify(DEFAULT_CONFIG)),
189
200
  fileConfig,
190
201
  ) as JevConfig;
191
202
 
203
+ // Normalizza la lista twin (config scritta a mano o da versioni vecchie):
204
+ // dedup, scarta voci malformate, garantisce che il primario sia incluso.
205
+ let merged = withTwinRefs(loaded, twinRefs(loaded));
206
+
192
207
  const envMode = process.env.JEV_MODE;
193
208
  if (envMode === "on-demand" || envMode === "automatic") {
194
209
  merged.mode = envMode;
@@ -352,6 +367,56 @@ export function configSnapshot(config: JevConfig): JevConfig {
352
367
  return JSON.parse(JSON.stringify(config)) as JevConfig;
353
368
  }
354
369
 
370
+ /**
371
+ * Twin salvati: la lista `automatic.twins` se popolata, altrimenti il singolo
372
+ * upstream primario (migrazione dalle versioni precedenti). Semplifica e
373
+ * scarta voci malformate: la config è modificabile a mano.
374
+ */
375
+ export function twinRefs(config: JevConfig): TwinRef[] {
376
+ const raw = Array.isArray(config.automatic.twins) ? config.automatic.twins : [];
377
+ const cleaned: TwinRef[] = [];
378
+ const seen = new Set<string>();
379
+ for (const entry of raw) {
380
+ if (!entry || typeof entry.provider !== "string" || typeof entry.model !== "string") continue;
381
+ const provider = entry.provider.trim();
382
+ const model = entry.model.trim();
383
+ if (!provider || !model) continue;
384
+ const key = `${provider}\0${model}`;
385
+ if (seen.has(key)) continue;
386
+ seen.add(key);
387
+ cleaned.push({ provider, model });
388
+ }
389
+ if (cleaned.length > 0) return cleaned;
390
+ const provider = config.automatic.upstreamProvider?.trim();
391
+ const model = config.automatic.upstreamModel?.trim();
392
+ return provider && model ? [{ provider, model }] : [];
393
+ }
394
+
395
+ /**
396
+ * Applica una lista di twin alla config: la lista è la fonte di verità.
397
+ * Il campo primario (auto-selezione/revert) resta se già presente nella
398
+ * lista, altrimenti diventa l'ultimo aggiunto.
399
+ */
400
+ export function withTwinRefs(config: JevConfig, refs: TwinRef[]): JevConfig {
401
+ const primary = refs.find(
402
+ (r) =>
403
+ r.provider === config.automatic.upstreamProvider &&
404
+ r.model === config.automatic.upstreamModel,
405
+ ) ?? refs[refs.length - 1];
406
+ return {
407
+ ...config,
408
+ automatic: {
409
+ ...config.automatic,
410
+ twins: refs.map((r) => ({ ...r })),
411
+ ...(primary ? { upstreamProvider: primary.provider, upstreamModel: primary.model } : {}),
412
+ },
413
+ };
414
+ }
415
+
416
+ export function twinKey(ref: TwinRef): string {
417
+ return `${ref.provider}/${ref.model}`;
418
+ }
419
+
355
420
  export function saveConfig(config: JevConfig, configPath?: string): string {
356
421
  const path = configPath ?? defaultConfigPath();
357
422
  mkdirSync(dirname(path), { recursive: true });