pi-jev-guard 0.6.0 → 0.6.1

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.
@@ -405,7 +405,7 @@ export default function (pi: ExtensionAPI) {
405
405
  refs: TwinRef[],
406
406
  registry: ModelRegistry,
407
407
  persist: boolean,
408
- ): { ok: boolean; error?: string; twins?: string[] } {
408
+ ): { ok: boolean; error?: string; stack?: string; twins?: string[] } {
409
409
  const byProvider = new Map<string, string[]>();
410
410
  for (const ref of refs) {
411
411
  const list = byProvider.get(ref.provider) ?? [];
@@ -443,6 +443,7 @@ export default function (pi: ExtensionAPI) {
443
443
  return {
444
444
  ok: false,
445
445
  error: error instanceof Error ? error.message : String(error),
446
+ ...(error instanceof Error && error.stack ? { stack: error.stack } : {}),
446
447
  };
447
448
  }
448
449
  overlays.set(providerId, { twinIds: [...twinIds], live });
@@ -466,6 +467,7 @@ export default function (pi: ExtensionAPI) {
466
467
  return {
467
468
  ok: false,
468
469
  error: error instanceof Error ? error.message : String(error),
470
+ ...(error instanceof Error && error.stack ? { stack: error.stack } : {}),
469
471
  };
470
472
  }
471
473
  }
@@ -483,7 +485,7 @@ export default function (pi: ExtensionAPI) {
483
485
  );
484
486
  refs.push({ provider: providerId, model: modelId });
485
487
  const res = applyTwins(refs, registry, true);
486
- if (!res.ok) return { ok: false, error: res.error };
488
+ if (!res.ok) return { ok: false, error: res.error, ...(res.stack ? { stack: res.stack } : {}) };
487
489
  return { ok: true, twin: `${providerId}/${twinIdFor(modelId)}` };
488
490
  }
489
491
 
@@ -709,6 +711,14 @@ export default function (pi: ExtensionAPI) {
709
711
  if (overlays.size === 0) {
710
712
  const restored = refreshUpstream(ctx.modelRegistry);
711
713
  if (!restored.ok) {
714
+ try {
715
+ pi.appendEntry("jev-refresh-upstream-error", {
716
+ error: restored.error,
717
+ ...(restored.stack ? { stack: String(restored.stack).slice(0, 4000) } : {}),
718
+ });
719
+ } catch {
720
+ // solo diagnostica
721
+ }
712
722
  if (ctx.hasUI) {
713
723
  ctx.ui.notify(`Jev automatic senza guarded: ${restored.error} (diagnostica sola).`, "warning");
714
724
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jev-guard",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
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": [
@@ -134,8 +134,11 @@ function stripTwins(models: readonly Model<Api>[]): Model<Api>[] {
134
134
  * config restano quelle pubblicate dal proprietario (builtin o altre
135
135
  * estensioni come pi-accounts), quindi i loro controlli di ownership e i
136
136
  * loro snapshot non vedono mai una "configuration changed" e non armano
137
- * abort. Rieseguibile: se l'oggetto è già patchato riparte dai metodi
138
- * originali con la nuova lista twin. Ritorna gli id gemello pubblicati.
137
+ * abort. Rieseguibile: gli originali sono catturati una sola volta e mai
138
+ * mutati dopo (niente "adozione" di funzioni altrui: adottare un wrapper
139
+ * che delega al nostro causava ricorsione infinita). Il record vive anche
140
+ * su Symbol.for, così sopravvive al /reload che ricarica i moduli.
141
+ * Ritorna gli id gemello pubblicati.
139
142
  */
140
143
  export interface PatchDeps {
141
144
  /** Id modelli (originali) da gemellare. */
@@ -161,57 +164,175 @@ interface PatchedOriginals {
161
164
  }
162
165
 
163
166
  interface PatchRecord {
164
- /** Originali veri (mai i nostri wrapper): evolve con le adozioni. */
167
+ /** Originali veri (mai i nostri wrapper, mai mutati dopo la cattura). */
165
168
  orig: PatchedOriginals;
166
- /** I nostri wrapper, per distinguerli dalle riassegnazioni altrui. */
169
+ /** getModels catturata grezza (per unpatch pulito, senza schermi). */
170
+ rawGetModels: Provider["getModels"];
171
+ /** I nostri wrapper correnti. */
167
172
  wrapGetModels: Provider["getModels"];
173
+ /** Base sopra un wrapper legacy pre-marcatura: unpatch best-effort. */
174
+ legacy?: boolean;
175
+ legacyGetModels?: Provider["getModels"];
168
176
  }
169
177
 
170
178
  const patchedOriginals = new WeakMap<object, PatchRecord>();
171
179
 
172
- /** True se l'oggetto provider è attualmente patchato da noi. */
180
+ /**
181
+ * Record oltre il /reload: pi ricarica le estensioni con moduleCache:false,
182
+ * quindi il WeakMap si azzera ma gli oggetti provider restano patchati con
183
+ * le closure vecchie. Symbol.for sopravvive tra le generazioni del modulo.
184
+ */
185
+ const ORIGINALS_KEY = Symbol.for("pi-jev-guard.patch-originals.v1");
186
+ /** Marca i nostri wrapper: mai usati come base. */
187
+ const WRAP_MARK = Symbol.for("pi-jev-guard.wrap.v1");
188
+ /** Guardia di rientranza per getModels (composizione con wrapper altrui). */
189
+ const DEPTH_KEY = Symbol.for("pi-jev-guard.getmodels-depth.v1");
190
+
191
+ function storedRecord(provider: Provider): PatchRecord | undefined {
192
+ return patchedOriginals.get(provider)
193
+ ?? (provider as unknown as Record<symbol, PatchRecord | undefined>)[ORIGINALS_KEY];
194
+ }
195
+
196
+ function storeRecord(provider: Provider, rec: PatchRecord): void {
197
+ patchedOriginals.set(provider, rec);
198
+ (provider as unknown as Record<symbol, PatchRecord>)[ORIGINALS_KEY] = rec;
199
+ }
200
+
201
+ function clearRecord(provider: Provider): void {
202
+ patchedOriginals.delete(provider);
203
+ try {
204
+ delete (provider as unknown as Record<symbol, unknown>)[ORIGINALS_KEY];
205
+ } catch {
206
+ // ignora
207
+ }
208
+ }
209
+
210
+ function isOurWrap(fn: unknown): boolean {
211
+ return typeof fn === "function"
212
+ && (fn as unknown as Record<symbol, unknown>)[WRAP_MARK] === true;
213
+ }
214
+
215
+ function markOurWrap<T extends object>(fn: T): T {
216
+ (fn as Record<symbol, unknown>)[WRAP_MARK] = true;
217
+ return fn;
218
+ }
219
+
220
+ function getDepth(provider: Provider): number {
221
+ const value = (provider as unknown as Record<symbol, unknown>)[DEPTH_KEY];
222
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
223
+ }
224
+
225
+ function setDepth(provider: Provider, depth: number): void {
226
+ (provider as unknown as Record<symbol, unknown>)[DEPTH_KEY] = depth;
227
+ }
228
+
229
+ function hasTwinIds(models: unknown): boolean {
230
+ return Array.isArray(models) && models.some(
231
+ (m) => m !== null && typeof m === "object"
232
+ && typeof (m as { id?: unknown }).id === "string"
233
+ && isTwinId((m as { id: string }).id),
234
+ );
235
+ }
236
+
237
+ /**
238
+ * True se l'oggetto provider è attualmente patchato da noi, anche da una
239
+ * generazione precedente al /reload (record su Symbol, non solo WeakMap).
240
+ */
173
241
  export function isPatched(provider: Provider): boolean {
174
- return patchedOriginals.has(provider);
242
+ if (patchedOriginals.has(provider)) return true;
243
+ return Boolean(storedRecord(provider)?.orig);
175
244
  }
176
245
 
177
246
  export function patchProvider(provider: Provider, deps: PatchDeps): string[] {
178
- const previous = patchedOriginals.get(provider);
179
- const rawStream: unknown = previous
180
- ? previous.orig.stream
181
- : (provider as { stream?: unknown }).stream;
182
- const base: PatchedOriginals = previous?.orig ?? {
183
- // Raw, mai bind: invocati con this=provider al momento della chiamata.
184
- getModels: provider.getModels,
185
- stream: typeof rawStream === "function"
186
- ? (rawStream as NonNullable<Provider["stream"]>)
187
- : undefined,
188
- streamSimple: provider.streamSimple,
189
- ...(provider.refreshModels
190
- ? { refreshModels: provider.refreshModels }
191
- : {}),
192
- ...(provider.filterModels
193
- ? { filterModels: provider.filterModels }
194
- : {}),
195
- };
196
247
  const twinIds = [...deps.twinModelIds];
197
248
  const providerId = provider.id;
198
249
 
199
- // Risoluzione pigra: legge SEMPRE i modelli correnti (nessuna snapshot
200
- // che diventa stale dopo refresh altrui o cataloghi dinamici). Se il
201
- // proprietario riassegna getModels dopo la patch, la nuova fn viene
202
- // adottata come base (i nostri wrapper si riconoscono e non si adottano).
203
- function currentBase(): PatchedOriginals {
204
- const rec = patchedOriginals.get(provider);
205
- const orig = rec?.orig ?? base;
206
- const cur = provider.getModels as Provider["getModels"];
207
- if (rec && cur !== rec.wrapGetModels && cur !== orig.getModels) {
208
- orig.getModels = cur;
250
+ let rec = storedRecord(provider);
251
+ if (!rec) {
252
+ // Prima patch di questo oggetto (in qualsiasi generazione): cattura i
253
+ // metodi correnti come originali stabili, mai mutati dopo. Se sono un
254
+ // wrapper legacy (pre-marcatura, con twin), la schermatura sotto evita
255
+ // che la sua vecchia logica di "adozione" richiami il nuovo wrapper in
256
+ // ricorsione infinita (Maximum call stack size exceeded).
257
+ const capturedGetModels = provider.getModels;
258
+ const rawStream: unknown = (provider as { stream?: unknown }).stream;
259
+ let legacy = false;
260
+ try {
261
+ legacy = !isOurWrap(capturedGetModels) && hasTwinIds(capturedGetModels.call(provider));
262
+ } catch (error) {
263
+ if (error instanceof RangeError) {
264
+ throw new Error(
265
+ "Jev: catalogo provider in stato inconsistente dopo il reload (stack overflow durante il probe); riavvia pi per continuare.",
266
+ );
267
+ }
268
+ throw error;
209
269
  }
210
- return orig;
270
+ // Schermo: durante la chiamata alla base, provider.getModels torna a
271
+ // essere la fn catturata, così un wrapper legacy vede se stesso e non
272
+ // adotta il nuovo wrapper. Per basi normali è una chiamata diretta.
273
+ const shieldedGetModels = (function (this: Provider) {
274
+ const installed = provider.getModels;
275
+ provider.getModels = capturedGetModels;
276
+ try {
277
+ return (capturedGetModels as Provider["getModels"]).call(provider);
278
+ } finally {
279
+ provider.getModels = installed;
280
+ }
281
+ }) as Provider["getModels"];
282
+ const orig: PatchedOriginals = {
283
+ // Raw, mai bind: invocati con this=provider al momento della chiamata.
284
+ getModels: shieldedGetModels,
285
+ stream: typeof rawStream === "function"
286
+ ? (rawStream as NonNullable<Provider["stream"]>)
287
+ : undefined,
288
+ streamSimple: provider.streamSimple,
289
+ ...(provider.refreshModels
290
+ ? { refreshModels: provider.refreshModels }
291
+ : {}),
292
+ ...(provider.filterModels
293
+ ? { filterModels: provider.filterModels }
294
+ : {}),
295
+ };
296
+ rec = {
297
+ orig,
298
+ rawGetModels: capturedGetModels,
299
+ wrapGetModels: capturedGetModels, // placeholder, sostituito sotto
300
+ ...(legacy
301
+ ? { legacy: true as const, legacyGetModels: capturedGetModels }
302
+ : {}),
303
+ };
304
+ }
305
+ // Const per le closure: il narrowing di `let` non sopravvive nelle closure.
306
+ const active: PatchRecord = rec;
307
+ const orig = active.orig;
308
+
309
+ function baseModels(): Model<Api>[] {
310
+ const out = orig.getModels.call(provider);
311
+ return Array.isArray(out) ? stripTwins(out) : [];
211
312
  }
212
313
 
314
+ // Originali correnti senza mai adottare funzioni altrui come base (la
315
+ // mutazione dell'originale era la causa della ricorsione infinita: un
316
+ // wrapper esterno che delega al nostro, adottato come base, richiama noi
317
+ // in loop). Se il proprietario riassegna getModels (cataloghi dinamici)
318
+ // o un'altra estensione avvolge lo stesso oggetto, deleghiamo per-chiamata
319
+ // sotto guardia di rientranza: il giro rientrante usa la base stabile.
213
320
  function currentOriginals(): Model<Api>[] {
214
- return mergeModelsById(stripTwins(currentBase().getModels.call(provider)), []);
321
+ const cur = provider.getModels;
322
+ if (cur === active.wrapGetModels || isOurWrap(cur) || cur === orig.getModels) {
323
+ return mergeModelsById(baseModels(), []);
324
+ }
325
+ if (getDepth(provider) > 0) return mergeModelsById(baseModels(), []);
326
+ setDepth(provider, getDepth(provider) + 1);
327
+ try {
328
+ const out = (cur as Provider["getModels"]).call(provider);
329
+ return mergeModelsById(stripTwins(Array.isArray(out) ? out : []), []);
330
+ } catch (error) {
331
+ if (error instanceof RangeError) return mergeModelsById(baseModels(), []);
332
+ throw error;
333
+ } finally {
334
+ setDepth(provider, getDepth(provider) - 1);
335
+ }
215
336
  }
216
337
 
217
338
  function resolveTwin(modelId: string): Model<Api> | undefined {
@@ -221,8 +342,7 @@ export function patchProvider(provider: Provider, deps: PatchDeps): string[] {
221
342
  return currentOriginals().find((m) => m.id === wanted);
222
343
  }
223
344
 
224
- function twinEntries(): Model<Api>[] {
225
- const originals = currentOriginals();
345
+ function twinEntries(originals: Model<Api>[]): Model<Api>[] {
226
346
  const chosen = new Set(twinIds);
227
347
  const twins: Model<Api>[] = [];
228
348
  const seen = new Set<string>();
@@ -250,7 +370,9 @@ export function patchProvider(provider: Provider, deps: PatchDeps): string[] {
250
370
  ? { ...message, model: untwinId(message.model) }
251
371
  : message),
252
372
  };
253
- return base.streamSimple.call(provider, model, originalContext, options);
373
+ // Solo id originali arrivano qui (il twin è già risolto sopra): anche
374
+ // sopra una base legacy il forward è un passthrough, mai un gate stantio.
375
+ return orig.streamSimple.call(provider, model, originalContext, options);
254
376
  }
255
377
 
256
378
  function guardedStreamSimple(
@@ -296,12 +418,15 @@ export function patchProvider(provider: Provider, deps: PatchDeps): string[] {
296
418
  });
297
419
  }
298
420
 
299
- const wrapGetModels: Provider["getModels"] = () => [
300
- ...currentOriginals().map((m) => ({ ...m })),
301
- ...twinEntries(),
302
- ];
421
+ const wrapGetModels = markOurWrap((() => {
422
+ const originals = currentOriginals();
423
+ return [
424
+ ...originals.map((m) => ({ ...m })),
425
+ ...twinEntries(originals),
426
+ ];
427
+ }) as Provider["getModels"]);
303
428
  provider.getModels = wrapGetModels;
304
- if (base.stream) {
429
+ if (orig.stream) {
305
430
  provider.stream = ((model: any, context: any, options: any) =>
306
431
  guardedStreamSimple(
307
432
  model as Model<Api>,
@@ -314,9 +439,9 @@ export function patchProvider(provider: Provider, deps: PatchDeps): string[] {
314
439
 
315
440
  // Forward refresh con persist depurato dai twin: lo store è letto anche
316
441
  // dal builtin a overlay assente, che li scambierebbe per modelli normali
317
- // (senza gate!). La risoluzione pigra non ha snapshot da ricostruire.
318
- if (base.refreshModels) {
319
- const upstreamRefresh = base.refreshModels;
442
+ // (senza gate!). Gli originali sono stabili: niente snapshot da ricostruire.
443
+ if (orig.refreshModels) {
444
+ const upstreamRefresh = orig.refreshModels;
320
445
  provider.refreshModels = (async (ctx: RefreshModelsContext): Promise<void> => {
321
446
  await upstreamRefresh.call(provider, {
322
447
  ...ctx,
@@ -343,8 +468,8 @@ export function patchProvider(provider: Provider, deps: PatchDeps): string[] {
343
468
 
344
469
  // Forward filtro credenziali: si applica agli originali, i twin seguono
345
470
  // il destino dell'originale corrispondente.
346
- if (base.filterModels) {
347
- const upstreamFilter = base.filterModels;
471
+ if (orig.filterModels) {
472
+ const upstreamFilter = orig.filterModels;
348
473
  provider.filterModels = ((
349
474
  models: readonly Model<Api>[],
350
475
  credential: Credential | undefined,
@@ -359,27 +484,38 @@ export function patchProvider(provider: Provider, deps: PatchDeps): string[] {
359
484
  }) as Provider["filterModels"];
360
485
  }
361
486
 
362
- if (previous) {
363
- // Re-patch: i wrapper si ricreano, gli originali veri restano.
364
- // Aggiorna il riferimento al wrapper corrente o l'adozione
365
- // scambierebbe il nuovo wrapper per una fn altrui.
366
- previous.wrapGetModels = wrapGetModels;
367
- patchedOriginals.set(provider, previous);
368
- } else {
369
- patchedOriginals.set(provider, { orig: base, wrapGetModels });
370
- }
371
- return twinEntries().map((t) => t.id);
487
+ // Re-patch: i wrapper si ricreano (nuova lista twin/closure), gli
488
+ // originali stabili restano. Il record è anche su Symbol: sopravvive al
489
+ // /reload e la nuova generazione riusa gli stessi originali.
490
+ active.wrapGetModels = wrapGetModels;
491
+ storeRecord(provider, active);
492
+ return twinEntries(currentOriginals()).map((t) => t.id);
372
493
  }
373
494
 
374
495
  /**
375
496
  * Ripristina i metodi originali. Ritorna false se l'oggetto non era patchato
376
- * (es. già sostituito dal proprietario: niente da ripristinare).
497
+ * (es. già sostituito dal proprietario: niente da ripristinare). Sopra una
498
+ * base legacy (pre-marcatura) il ripristino di getModels è best-effort con
499
+ * schermatura: i twin spariscono dal catalogo; un riavvio di pi pulisce tutto.
377
500
  */
378
501
  export function unpatchProvider(provider: Provider): boolean {
379
- const rec = patchedOriginals.get(provider);
502
+ const rec = storedRecord(provider);
380
503
  const originals = rec?.orig;
381
- if (!originals) return false;
382
- provider.getModels = originals.getModels;
504
+ if (!rec || !originals) return false;
505
+ if (rec.legacy && rec.legacyGetModels) {
506
+ const legacyFn = rec.legacyGetModels;
507
+ provider.getModels = (function (this: Provider) {
508
+ const installed = provider.getModels;
509
+ provider.getModels = legacyFn;
510
+ try {
511
+ return stripTwins(legacyFn.call(provider));
512
+ } finally {
513
+ provider.getModels = installed;
514
+ }
515
+ }) as Provider["getModels"];
516
+ } else {
517
+ provider.getModels = rec.rawGetModels;
518
+ }
383
519
  if (originals.stream) provider.stream = originals.stream;
384
520
  else delete (provider as { stream?: unknown }).stream;
385
521
  provider.streamSimple = originals.streamSimple;
@@ -387,6 +523,6 @@ export function unpatchProvider(provider: Provider): boolean {
387
523
  else delete (provider as { refreshModels?: unknown }).refreshModels;
388
524
  if (originals.filterModels) provider.filterModels = originals.filterModels;
389
525
  else delete (provider as { filterModels?: unknown }).filterModels;
390
- patchedOriginals.delete(provider);
526
+ clearRecord(provider);
391
527
  return true;
392
528
  }
package/src/commands.ts CHANGED
@@ -40,6 +40,8 @@ export interface TwinStatusView {
40
40
  export interface UpstreamResult {
41
41
  ok: boolean;
42
42
  error?: string;
43
+ /** Stack per la diagnostica (entry jev-refresh-upstream-error), mai all'utente. */
44
+ stack?: string;
43
45
  twin?: string;
44
46
  }
45
47