pi-free 2.0.15 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +64 -79
  3. package/banner.svg +21 -36
  4. package/config.ts +123 -9
  5. package/constants.ts +3 -9
  6. package/index.ts +14 -15
  7. package/lib/built-in-toggle.ts +29 -16
  8. package/lib/json-persistence.ts +90 -22
  9. package/lib/logger.ts +21 -12
  10. package/lib/model-detection.ts +2 -12
  11. package/lib/model-enhancer.ts +11 -2
  12. package/lib/model-metadata.ts +387 -0
  13. package/lib/open-browser.ts +74 -24
  14. package/lib/paths.ts +90 -0
  15. package/lib/probe-cache.ts +19 -19
  16. package/lib/provider-cache.ts +74 -28
  17. package/lib/provider-compat.ts +53 -37
  18. package/lib/provider-probe.ts +188 -0
  19. package/lib/registry.ts +1 -5
  20. package/lib/session-start-metrics.ts +46 -0
  21. package/lib/telemetry.ts +115 -86
  22. package/lib/types.ts +22 -2
  23. package/lib/util.ts +80 -21
  24. package/package.json +7 -2
  25. package/provider-failover/benchmark-lookup.ts +17 -5
  26. package/provider-helper.ts +11 -2
  27. package/providers/cline/cline-models.ts +7 -1
  28. package/providers/cline/cline-xml-bridge.ts +974 -0
  29. package/providers/cline/cline.ts +67 -176
  30. package/providers/crofai/crofai.ts +6 -1
  31. package/providers/deepinfra/deepinfra.ts +69 -2
  32. package/providers/dynamic-built-in/index.ts +237 -2
  33. package/providers/kilo/kilo-models.ts +3 -1
  34. package/providers/kilo/kilo.ts +268 -41
  35. package/providers/model-fetcher.ts +18 -55
  36. package/providers/novita/novita.ts +69 -2
  37. package/providers/ollama/ollama.ts +48 -24
  38. package/providers/opencode-session.ts +67 -2
  39. package/providers/routeway/routeway.ts +25 -17
  40. package/providers/sambanova/sambanova.ts +67 -1
  41. package/providers/together/together.ts +69 -2
  42. package/providers/tokenrouter/tokenrouter.ts +378 -0
  43. package/providers/zenmux/zenmux.ts +6 -1
  44. package/scripts/check-extensions.mjs +32 -16
  45. package/providers/nvidia/nvidia.ts +0 -510
package/config.ts CHANGED
@@ -9,17 +9,28 @@
9
9
  * (e.g. after toggle-{provider}) are visible immediately.
10
10
  */
11
11
 
12
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  export {
15
15
  PROVIDER_CLINE,
16
16
  PROVIDER_KILO,
17
17
  PROVIDER_MODAL,
18
- PROVIDER_NVIDIA,
19
18
  PROVIDER_QWEN,
20
19
  PROVIDER_ROUTEWAY,
20
+ PROVIDER_TOKENROUTER,
21
21
  } from "./constants.ts";
22
22
  import { createLogger } from "./lib/logger.ts";
23
+ import { ensureDir, PI_DATA_DIR } from "./lib/paths.ts";
24
+
25
+ /**
26
+ * JSON.parse reviver that strips prototype-pollution payloads.
27
+ */
28
+ function safeJsonReviver(_key: string, value: unknown): unknown {
29
+ if (_key === "__proto__" || _key === "constructor") {
30
+ return undefined;
31
+ }
32
+ return value;
33
+ }
23
34
 
24
35
  const _logger = createLogger("config");
25
36
 
@@ -36,6 +47,7 @@ interface PiFreeConfig {
36
47
  novita_api_key?: string;
37
48
  routeway_api_key?: string;
38
49
  fastrouter_api_key?: string;
50
+ tokenrouter_api_key?: string;
39
51
  kilo_free_only?: boolean;
40
52
  hidden_models?: string[];
41
53
  free_only?: boolean;
@@ -52,6 +64,7 @@ interface PiFreeConfig {
52
64
  novita_show_paid?: boolean;
53
65
  routeway_show_paid?: boolean;
54
66
  fastrouter_show_paid?: boolean;
67
+ tokenrouter_show_paid?: boolean;
55
68
  openrouter_show_paid?: boolean;
56
69
  opencode_show_paid?: boolean;
57
70
  }
@@ -69,6 +82,7 @@ const CONFIG_TEMPLATE: PiFreeConfig = {
69
82
  novita_api_key: "",
70
83
  routeway_api_key: "",
71
84
  fastrouter_api_key: "",
85
+ tokenrouter_api_key: "",
72
86
 
73
87
  kilo_free_only: false,
74
88
  hidden_models: [],
@@ -86,16 +100,16 @@ const CONFIG_TEMPLATE: PiFreeConfig = {
86
100
  novita_show_paid: false,
87
101
  routeway_show_paid: false,
88
102
  fastrouter_show_paid: false,
103
+ tokenrouter_show_paid: false,
89
104
  openrouter_show_paid: false,
90
105
  opencode_show_paid: false,
91
106
  };
92
107
 
93
- const PI_DIR = join(process.env.HOME || process.env.USERPROFILE || "", ".pi");
94
- const CONFIG_PATH = join(PI_DIR, "free.json");
108
+ const CONFIG_PATH = join(PI_DATA_DIR, "free.json");
95
109
 
96
110
  function ensureConfigFile(): void {
97
111
  try {
98
- mkdirSync(PI_DIR, { recursive: true });
112
+ ensureDir(PI_DATA_DIR);
99
113
  if (existsSync(CONFIG_PATH)) {
100
114
  let existing: PiFreeConfig;
101
115
  try {
@@ -137,7 +151,10 @@ function ensureConfigFile(): void {
137
151
 
138
152
  export function loadConfigFile(): PiFreeConfig {
139
153
  try {
140
- return JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as PiFreeConfig;
154
+ return JSON.parse(
155
+ readFileSync(CONFIG_PATH, "utf8"),
156
+ safeJsonReviver,
157
+ ) as PiFreeConfig;
141
158
  } catch (err) {
142
159
  _logger.error("Could not parse config file — returning empty config", {
143
160
  path: CONFIG_PATH,
@@ -231,6 +248,13 @@ export function getRoutewayShowPaid(): boolean {
231
248
  return resolveBool("ROUTEWAY_SHOW_PAID", loadConfigFile().routeway_show_paid);
232
249
  }
233
250
 
251
+ export function getTokenrouterShowPaid(): boolean {
252
+ return resolveBool(
253
+ "TOKENROUTER_SHOW_PAID",
254
+ loadConfigFile().tokenrouter_show_paid,
255
+ );
256
+ }
257
+
234
258
  export function getFastrouterShowPaid(): boolean {
235
259
  return resolveBool(
236
260
  "FASTROUTER_SHOW_PAID",
@@ -277,6 +301,8 @@ export function getProviderShowPaid(providerId: string): boolean {
277
301
  return getNovitaShowPaid();
278
302
  case "routeway":
279
303
  return getRoutewayShowPaid();
304
+ case "tokenrouter":
305
+ return getTokenrouterShowPaid();
280
306
  case "fastrouter":
281
307
  return getFastrouterShowPaid();
282
308
  case "ollama-cloud":
@@ -350,6 +376,10 @@ export function getFastrouterApiKey(): string | undefined {
350
376
  return resolve("FASTROUTER_API_KEY", loadConfigFile().fastrouter_api_key);
351
377
  }
352
378
 
379
+ export function getTokenrouterApiKey(): string | undefined {
380
+ return resolve("TOKENROUTER_API_KEY", loadConfigFile().tokenrouter_api_key);
381
+ }
382
+
353
383
  export function getOllamaApiKey(): string | undefined {
354
384
  return resolve("OLLAMA_API_KEY", loadConfigFile().ollama_api_key);
355
385
  }
@@ -394,10 +424,10 @@ function readAuthJsonKey(
394
424
 
395
425
  // Check auth.json
396
426
  try {
397
- const authPath = join(PI_DIR, "agent", "auth.json");
427
+ const authPath = join(PI_DATA_DIR, "agent", "auth.json");
398
428
  if (!existsSync(authPath)) return undefined;
399
429
  const raw = readFileSync(authPath, "utf8");
400
- const auth = JSON.parse(raw) as Record<
430
+ const auth = JSON.parse(raw, safeJsonReviver) as Record<
401
431
  string,
402
432
  { type?: string; key?: string }
403
433
  >;
@@ -479,7 +509,7 @@ export function saveConfig(updates: Partial<PiFreeConfig>): void {
479
509
 
480
510
  let existing: PiFreeConfig;
481
511
  try {
482
- existing = JSON.parse(raw) as PiFreeConfig;
512
+ existing = JSON.parse(raw, safeJsonReviver) as PiFreeConfig;
483
513
  } catch (parseErr) {
484
514
  // File exists but is corrupt. REFUSE to overwrite it with a partial
485
515
  // config — that would permanently destroy the user's keys.
@@ -508,6 +538,90 @@ export function saveConfig(updates: Partial<PiFreeConfig>): void {
508
538
  }
509
539
  }
510
540
 
541
+ /**
542
+ * Serialise all config RMW operations to prevent concurrent updates
543
+ * from clobbering each other (e.g. two provider probes finishing at the
544
+ * same time both writing hidden_models and losing the other's update).
545
+ */
546
+ class ConfigLock {
547
+ private promise: Promise<void> = Promise.resolve();
548
+
549
+ async acquire(): Promise<() => void> {
550
+ let release: () => void;
551
+ const newPromise = new Promise<void>((resolve) => {
552
+ release = resolve;
553
+ });
554
+ const previous = this.promise;
555
+ this.promise = previous.then(() => newPromise);
556
+ await previous;
557
+ return release!;
558
+ }
559
+ }
560
+
561
+ const _configLock = new ConfigLock();
562
+
563
+ /**
564
+ * Atomically read-modify-write the config file. The updater function
565
+ * receives the current parsed config and returns the partial updates to
566
+ * merge. Concurrent calls are serialised by an internal lock.
567
+ *
568
+ * If the config file is corrupt, the updater is NOT called and the file
569
+ * is left untouched (matches saveConfig's safety behaviour).
570
+ */
571
+ export async function updateConfig(
572
+ updater: (current: PiFreeConfig) => Partial<PiFreeConfig>,
573
+ ): Promise<void> {
574
+ const release = await _configLock.acquire();
575
+ try {
576
+ const raw = readRawConfigFile();
577
+ if (raw === undefined) {
578
+ // File doesn't exist — start from template, apply updater once
579
+ const updated = updater({ ...CONFIG_TEMPLATE });
580
+ const merged = { ...CONFIG_TEMPLATE, ...updated };
581
+ writeFileSync(
582
+ CONFIG_PATH,
583
+ `${JSON.stringify(merged, null, 2)}\n`,
584
+ "utf8",
585
+ );
586
+ _logger.info("Config updated (new file)", {
587
+ path: CONFIG_PATH,
588
+ keys: Object.keys(updated),
589
+ });
590
+ return;
591
+ }
592
+
593
+ let existing: PiFreeConfig;
594
+ try {
595
+ existing = JSON.parse(raw, safeJsonReviver) as PiFreeConfig;
596
+ } catch (parseErr) {
597
+ _logger.error(
598
+ "REFUSING to update config — existing file is corrupt. Fix or delete ~/.pi/free.json manually.",
599
+ {
600
+ path: CONFIG_PATH,
601
+ error:
602
+ parseErr instanceof Error ? parseErr.message : String(parseErr),
603
+ },
604
+ );
605
+ return;
606
+ }
607
+
608
+ const updated = updater(existing);
609
+ const merged = { ...existing, ...updated };
610
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
611
+ _logger.info("Config updated", {
612
+ path: CONFIG_PATH,
613
+ keys: Object.keys(updated),
614
+ });
615
+ } catch (err) {
616
+ _logger.error("Failed to update config", {
617
+ path: CONFIG_PATH,
618
+ error: err instanceof Error ? err.message : String(err),
619
+ });
620
+ } finally {
621
+ release();
622
+ }
623
+ }
624
+
511
625
  export function getConfig(): PiFreeConfig {
512
626
  return loadConfigFile();
513
627
  }
package/constants.ts CHANGED
@@ -9,7 +9,6 @@
9
9
 
10
10
  export const PROVIDER_KILO = "kilo";
11
11
  export const PROVIDER_CLINE = "cline";
12
- export const PROVIDER_NVIDIA = "nvidia";
13
12
  export const PROVIDER_CLOUDFLARE = "cloudflare";
14
13
  export const PROVIDER_OLLAMA = "ollama-cloud";
15
14
  /** @deprecated Qwen provider is deprecated. The 1,000 req/day free tier is no longer available. */
@@ -24,11 +23,11 @@ export const PROVIDER_SAMBANOVA = "sambanova";
24
23
  export const PROVIDER_TOGETHER = "together";
25
24
  export const PROVIDER_NOVITA = "novita";
26
25
  export const PROVIDER_ROUTEWAY = "routeway";
26
+ export const PROVIDER_TOKENROUTER = "tokenrouter";
27
27
 
28
28
  export const ALL_UNIQUE_PROVIDERS = [
29
29
  PROVIDER_KILO,
30
30
  PROVIDER_CLINE,
31
- PROVIDER_NVIDIA,
32
31
  /** @deprecated Qwen free tier no longer available */
33
32
  PROVIDER_QWEN,
34
33
  PROVIDER_MODAL,
@@ -42,6 +41,7 @@ export const ALL_UNIQUE_PROVIDERS = [
42
41
  PROVIDER_TOGETHER,
43
42
  PROVIDER_NOVITA,
44
43
  PROVIDER_ROUTEWAY,
44
+ PROVIDER_TOKENROUTER,
45
45
  ] as const;
46
46
 
47
47
  // =============================================================================
@@ -49,7 +49,6 @@ export const ALL_UNIQUE_PROVIDERS = [
49
49
  // =============================================================================
50
50
 
51
51
  export const BASE_URL_KILO = "https://api.kilo.ai/api/gateway";
52
- export const BASE_URL_NVIDIA = "https://integrate.api.nvidia.com/v1";
53
52
  export const BASE_URL_CLOUDFLARE = "https://api.cloudflare.com/client/v4";
54
53
  export const BASE_URL_OLLAMA = "https://ollama.com/v1"; // OpenAI-compatible API endpoint
55
54
  export const BASE_URL_CLINE = "https://api.cline.bot/api/v1";
@@ -65,6 +64,7 @@ export const BASE_URL_SAMBANOVA = "https://api.sambanova.ai/v1";
65
64
  export const BASE_URL_TOGETHER = "https://api.together.xyz/v1";
66
65
  export const BASE_URL_NOVITA = "https://api.novita.ai/openai/v1";
67
66
  export const BASE_URL_ROUTEWAY = "https://api.routeway.ai/v1";
67
+ export const BASE_URL_TOKENROUTER = "https://api.tokenrouter.com/v1";
68
68
 
69
69
  /** Cline fetches free models from OpenRouter */
70
70
  export const BASE_URL_OPENROUTER = "https://openrouter.ai/api/v1";
@@ -85,12 +85,6 @@ export const URL_MODAL_TOS = "https://modal.com/terms";
85
85
 
86
86
  export const CLINE_AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
87
87
 
88
- // =============================================================================
89
- // Configuration thresholds
90
- // =============================================================================
91
-
92
- export const NVIDIA_MIN_SIZE_B = 70; // Minimum model size for NVIDIA NIM
93
-
94
88
  // =============================================================================
95
89
  // Timeouts (milliseconds)
96
90
  // =============================================================================
package/index.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  * - SambaNova: Fast inference on RDU hardware (free tier, no credit card)
16
16
  * - Together: Fast inference on 200+ open-source models ($1 trial credit)
17
17
  * - Routeway: OpenAI-compatible gateway with free `:free` models
18
+ * - TokenRouter: OpenAI-compatible gateway routing to 90+ models
18
19
  * - LLM7: AI gateway (free default/fast selectors)
19
20
  */
20
21
 
@@ -50,7 +51,7 @@ import sambanova from "./providers/sambanova/sambanova.ts";
50
51
  import together from "./providers/together/together.ts";
51
52
  import novita from "./providers/novita/novita.ts";
52
53
  import routeway from "./providers/routeway/routeway.ts";
53
- import nvidia from "./providers/nvidia/nvidia.ts";
54
+ import tokenRouter from "./providers/tokenrouter/tokenrouter.ts";
54
55
  import ollama from "./providers/ollama/ollama.ts";
55
56
  import zenmux from "./providers/zenmux/zenmux.ts";
56
57
 
@@ -67,7 +68,7 @@ function setupGlobalCommands(pi: ExtensionAPI) {
67
68
  handler: async (_args, ctx) => {
68
69
  const current = getGlobalFreeOnly();
69
70
  const next = !current;
70
- applyGlobalFilter(pi, next, { force: true });
71
+ applyGlobalFilter(next, { force: true });
71
72
 
72
73
  const registry = getProviderRegistry();
73
74
  const providerCount = registry.size;
@@ -111,11 +112,7 @@ function setupGlobalCommands(pi: ExtensionAPI) {
111
112
  "cerebras",
112
113
  ]);
113
114
  // Freemium providers - all models share a free tier quota
114
- const freemiumProviders = new Set([
115
- "nvidia",
116
- "sambanova",
117
- "ollama-cloud",
118
- ]);
115
+ const freemiumProviders = new Set(["sambanova", "ollama-cloud"]);
119
116
  // Trial credit providers - one-time credits, otherwise paid
120
117
  const trialCreditProviders = new Set(["deepinfra"]);
121
118
 
@@ -208,7 +205,7 @@ function setupGlobalCommands(pi: ExtensionAPI) {
208
205
  pi.registerCommand("clear-free-telemetry", {
209
206
  description: "Clear all model telemetry data",
210
207
  handler: async (_args, ctx) => {
211
- clearTelemetry();
208
+ await clearTelemetry();
212
209
  ctx.ui.notify("Telemetry data cleared", "info");
213
210
  },
214
211
  });
@@ -268,7 +265,7 @@ function setupTelemetry(pi: ExtensionAPI) {
268
265
  });
269
266
 
270
267
  // Record telemetry when a turn completes
271
- pi.on("turn_end", (event, ctx) => {
268
+ pi.on("turn_end", async (event, ctx) => {
272
269
  if (!ctx.model) return;
273
270
  if (!isFreeModel(ctx.model as any)) return;
274
271
 
@@ -302,14 +299,16 @@ function setupTelemetry(pi: ExtensionAPI) {
302
299
  const cost = usage?.cost?.total ?? 0;
303
300
  const isError = msg.stopReason === "error" || !!msg.errorMessage;
304
301
 
305
- recordModelCall(
302
+ await recordModelCall(
306
303
  provider,
307
304
  model,
308
305
  { input: inputTokens, output: outputTokens, totalTokens },
309
306
  cost,
310
- !isError,
311
- msg.stopReason,
312
- msg.errorMessage,
307
+ {
308
+ success: !isError,
309
+ stopReason: msg.stopReason,
310
+ errorMessage: msg.errorMessage,
311
+ },
313
312
  );
314
313
  });
315
314
  }
@@ -334,7 +333,6 @@ export default async function piFreeEntry(pi: ExtensionAPI) {
334
333
  // Load all unique providers
335
334
  // Each provider will register itself with the global toggle system
336
335
  await Promise.allSettled([
337
- nvidia(pi),
338
336
  kilo(pi),
339
337
  ollama(pi),
340
338
  cline(pi),
@@ -347,6 +345,7 @@ export default async function piFreeEntry(pi: ExtensionAPI) {
347
345
  together(pi),
348
346
  novita(pi),
349
347
  routeway(pi),
348
+ tokenRouter(pi),
350
349
  ]);
351
350
 
352
351
  // Setup dynamic built-in providers (Mistral, Groq, Cerebras, xAI, Hugging Face,
@@ -362,7 +361,7 @@ export default async function piFreeEntry(pi: ExtensionAPI) {
362
361
  // Apply initial global filter if free-only mode is enabled
363
362
  if (globalFreeOnly) {
364
363
  _logger.info("[pi-free] Applying initial free-only filter");
365
- applyGlobalFilter(pi, true);
364
+ applyGlobalFilter(true);
366
365
  }
367
366
 
368
367
  const registry = getProviderRegistry();
@@ -23,6 +23,7 @@ import {
23
23
  isFreeModel,
24
24
  registerWithGlobalToggle,
25
25
  } from "./registry.ts";
26
+ import { wrapSessionStartHandler } from "./session-start-metrics.ts";
26
27
  import { createToggleState } from "./toggle-state.ts";
27
28
  import {
28
29
  OPENCODE_DYNAMIC_API,
@@ -34,7 +35,16 @@ import {
34
35
  const _logger = createLogger("built-in-toggle");
35
36
 
36
37
  // OpenCode requires per-request ids; see createOpenCodeStreamSimple().
37
- const _opencodeSession = createOpenCodeSessionTracker();
38
+ // Lazy-initialised because the OpenCode dynamic fetcher in
39
+ // providers/dynamic-built-in/ usually wins the race for `opencode`,
40
+ // leaving this fallback capture unused — no point allocating the
41
+ // session tracker on every module import.
42
+ let _opencodeSession: ReturnType<typeof createOpenCodeSessionTracker> | null =
43
+ null;
44
+ function getOpenCodeSession() {
45
+ if (!_opencodeSession) _opencodeSession = createOpenCodeSessionTracker();
46
+ return _opencodeSession;
47
+ }
38
48
 
39
49
  // =============================================================================
40
50
  // Configuration
@@ -90,22 +100,25 @@ export function setupBuiltInProviderToggles(pi: ExtensionAPI): void {
90
100
  }
91
101
 
92
102
  // Capture built-in models on session start and apply initial filter
93
- pi.on("session_start", async (_event, ctx) => {
94
- for (const config of activeConfigs) {
95
- if (providerStates.has(config.id)) {
96
- // Already captured skip to avoid re-registering
97
- continue;
98
- }
103
+ pi.on(
104
+ "session_start",
105
+ wrapSessionStartHandler("built-in-toggle", async (_event, ctx) => {
106
+ for (const config of activeConfigs) {
107
+ if (providerStates.has(config.id)) {
108
+ // Already captured — skip to avoid re-registering
109
+ continue;
110
+ }
99
111
 
100
- const state = tryCaptureProvider(pi, config, ctx);
101
- if (!state) continue;
112
+ const state = tryCaptureProvider(pi, config, ctx);
113
+ if (!state) continue;
102
114
 
103
- const applied = state.toggleState.applyCurrent(state.reRegister);
104
- _logger.info(
105
- `[built-in-toggle] ${config.id}: applied ${applied.mode} mode with ${applied.models.length} models`,
106
- );
107
- }
108
- });
115
+ const applied = state.toggleState.applyCurrent(state.reRegister);
116
+ _logger.info(
117
+ `[built-in-toggle] ${config.id}: applied ${applied.mode} mode with ${applied.models.length} models`,
118
+ );
119
+ }
120
+ }),
121
+ );
109
122
  }
110
123
 
111
124
  // =============================================================================
@@ -140,7 +153,7 @@ function tryCaptureProvider(
140
153
  apiKey: apiKeyEnv,
141
154
  api: isOpenCodeProvider(config.id) ? OPENCODE_DYNAMIC_API : api,
142
155
  ...(isOpenCodeProvider(config.id)
143
- ? { streamSimple: createOpenCodeStreamSimple(_opencodeSession) }
156
+ ? { streamSimple: createOpenCodeStreamSimple(getOpenCodeSession()) }
144
157
  : {}),
145
158
  models,
146
159
  });
@@ -1,17 +1,47 @@
1
1
  /**
2
2
  * Shared JSON persistence utilities.
3
- * Consolidates file I/O patterns from usage-store.ts and free-tier-limits.ts
3
+ * Consolidated file I/O patterns from usage-store.ts and free-tier-limits.ts
4
4
  */
5
5
 
6
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { dirname } from "node:path";
8
8
  import { createLogger } from "./logger.ts";
9
+ import { ensureDir } from "./paths.ts";
9
10
 
10
11
  const _logger = createLogger("json-persistence");
11
12
 
13
+ /**
14
+ * JSON.parse reviver that strips prototype-pollution payloads.
15
+ * Filters out `__proto__` and `constructor` keys at every level of the
16
+ * parsed object, preventing attackers from polluting Object.prototype
17
+ * through crafted config/cache files.
18
+ */
19
+ function safeJsonReviver(_key: string, value: unknown): unknown {
20
+ if (_key === "__proto__" || _key === "constructor") {
21
+ return undefined;
22
+ }
23
+ return value;
24
+ }
25
+
12
26
  export interface JSONStore<T> {
13
27
  load(): T;
14
28
  save(data: T): void;
29
+ update(updater: (data: T) => T): Promise<T>;
30
+ }
31
+
32
+ class Lock {
33
+ private promise: Promise<void> = Promise.resolve();
34
+
35
+ async acquire(): Promise<() => void> {
36
+ let release: () => void;
37
+ const newPromise = new Promise<void>((resolve) => {
38
+ release = resolve;
39
+ });
40
+ const previous = this.promise;
41
+ this.promise = previous.then(() => newPromise);
42
+ await previous;
43
+ return release!;
44
+ }
15
45
  }
16
46
 
17
47
  /**
@@ -22,16 +52,23 @@ export function createJSONStore<T extends object>(
22
52
  defaultValue: T,
23
53
  ): JSONStore<T> {
24
54
  let cached: T | null = null;
55
+ const lock = new Lock();
25
56
 
26
57
  function load(): T {
27
58
  if (cached) return cached;
28
59
  try {
29
60
  if (existsSync(filepath)) {
30
- cached = JSON.parse(readFileSync(filepath, "utf-8")) as T;
61
+ cached = JSON.parse(
62
+ readFileSync(filepath, "utf-8"),
63
+ safeJsonReviver,
64
+ ) as T;
31
65
  return cached;
32
66
  }
33
67
  } catch (err) {
34
- _logger.warn("Failed to load JSON store, using default", { filepath, error: err });
68
+ _logger.warn("Failed to load JSON store, using default", {
69
+ filepath,
70
+ error: err,
71
+ });
35
72
  }
36
73
  cached = defaultValue;
37
74
  return cached;
@@ -40,62 +77,93 @@ export function createJSONStore<T extends object>(
40
77
  function save(data: T): void {
41
78
  cached = data;
42
79
  try {
43
- const dir = dirname(filepath);
44
- if (!existsSync(dir)) {
45
- mkdirSync(dir, { recursive: true });
46
- }
80
+ ensureDir(dirname(filepath));
47
81
  writeFileSync(filepath, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
48
82
  } catch (err) {
49
83
  _logger.warn("Failed to save JSON store", { filepath, error: err });
50
84
  }
51
85
  }
52
86
 
53
- return { load, save };
87
+ async function update(updater: (data: T) => T): Promise<T> {
88
+ const release = await lock.acquire();
89
+ try {
90
+ const data = load();
91
+ const updated = updater(data);
92
+ save(updated);
93
+ return updated;
94
+ } finally {
95
+ release();
96
+ }
97
+ }
98
+
99
+ return { load, save, update };
54
100
  }
55
101
 
56
102
  /**
57
103
  * Create a JSONL (newline-delimited JSON) store for append-only logs.
104
+ *
105
+ * `append` and `clear` are async and serialised by an internal lock to
106
+ * prevent interleaved writes (e.g. `clear` truncating the file while
107
+ * `append` is mid-write).
58
108
  */
59
109
  export function createJSONLStore<T extends object>(
60
110
  filepath: string,
61
111
  ): {
62
112
  load(): T[];
63
- append(entry: T): void;
64
- clear(): void;
113
+ append(entry: T): Promise<void>;
114
+ clear(): Promise<void>;
65
115
  } {
116
+ const lock = new Lock();
117
+
66
118
  function load(): T[] {
67
119
  try {
68
120
  if (existsSync(filepath)) {
69
121
  const content = readFileSync(filepath, "utf-8");
70
- return content
71
- .split("\n")
72
- .filter((line) => line.trim())
73
- .map((line) => JSON.parse(line) as T);
122
+ const lines = content.split("\n").filter((line) => line.trim());
123
+ const entries: T[] = [];
124
+ for (const [index, line] of lines.entries()) {
125
+ try {
126
+ entries.push(JSON.parse(line, safeJsonReviver) as T);
127
+ } catch (err) {
128
+ _logger.warn("Malformed JSONL line skipped", {
129
+ filepath,
130
+ line: index + 1,
131
+ error: err,
132
+ });
133
+ }
134
+ }
135
+ return entries;
74
136
  }
75
137
  } catch (err) {
76
- _logger.warn("Failed to load JSONL store, using empty array", { filepath, error: err });
138
+ _logger.warn("Failed to load JSONL store, using empty array", {
139
+ filepath,
140
+ error: err,
141
+ });
77
142
  }
78
143
  return [];
79
144
  }
80
145
 
81
- function append(entry: T): void {
146
+ async function append(entry: T): Promise<void> {
147
+ const release = await lock.acquire();
82
148
  try {
83
- const dir = dirname(filepath);
84
- if (!existsSync(dir)) {
85
- mkdirSync(dir, { recursive: true });
86
- }
149
+ ensureDir(dirname(filepath));
87
150
  const line = JSON.stringify(entry);
88
151
  writeFileSync(filepath, `${line}\n`, { flag: "a", encoding: "utf-8" });
89
152
  } catch (err) {
90
153
  _logger.warn("Failed to append to JSONL store", { filepath, error: err });
154
+ } finally {
155
+ release();
91
156
  }
92
157
  }
93
158
 
94
- function clear(): void {
159
+ async function clear(): Promise<void> {
160
+ const release = await lock.acquire();
95
161
  try {
96
162
  writeFileSync(filepath, "", "utf-8");
97
163
  } catch (err) {
98
164
  _logger.warn("Failed to clear JSONL store", { filepath, error: err });
165
+ } finally {
166
+ release();
99
167
  }
100
168
  }
101
169