pi-cache-optimizer 2.8.3 → 2.8.4

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 (4) hide show
  1. package/README.md +19 -14
  2. package/README.zh-CN.md +19 -13
  3. package/index.ts +679 -165
  4. package/package.json +1 -1
package/index.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { createHash } from "node:crypto";
2
- import { chmod, copyFile, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
3
- import { constants as fsConstants, readFileSync } from "node:fs";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { chmod, copyFile, lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
+ import { constants as fsConstants, readFileSync, watch } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import {
@@ -102,7 +102,19 @@ const STATUS_KEY = "pi-cache-stats";
102
102
  const STATE_DIR = getAgentDir();
103
103
  const STATE_FILE_PATH = join(STATE_DIR, "pi-cache-optimizer-stats.json");
104
104
  const LEGACY_STATE_FILE_PATH = join(STATE_DIR, "deepseek-cache-optimizer-stats.json");
105
+ const SHARD_STATE_DIR = join(STATE_DIR, "pi-cache-optimizer-stats.d");
106
+ const SHARD_FILES_DIR = join(SHARD_STATE_DIR, "shards");
107
+ const SHARD_EPOCH_DIR = join(SHARD_STATE_DIR, "epochs");
108
+ const SHARD_MODEL_EPOCH_DIR = join(SHARD_EPOCH_DIR, "models");
109
+ const SHARD_MAINTENANCE_DIR = join(SHARD_STATE_DIR, "maintenance");
110
+ const SHARD_GLOBAL_EPOCH_PATH = join(SHARD_EPOCH_DIR, "global.json");
111
+ const SHARD_CLEANUP_LOCK_PATH = join(SHARD_MAINTENANCE_DIR, "cleanup.lock");
112
+ const SHARD_CLEANUP_MARKER_PATH = join(SHARD_MAINTENANCE_DIR, "last-cleanup");
105
113
  const CONFIG_FILE_PATH = join(STATE_DIR, "pi-cache-optimizer-config.json");
114
+ const SHARD_RETENTION_MS = 48 * 60 * 60 * 1000;
115
+ const SHARD_TEMP_RETENTION_MS = 24 * 60 * 60 * 1000;
116
+ const SHARD_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
117
+ const SHARD_CLEANUP_LOCK_STALE_MS = 60 * 60 * 1000;
106
118
  const CACHE_PROVIDER_IDS: CacheProviderId[] = ["deepseek", "openai", "claude", "gemini"];
107
119
  const OPENAI_CACHE_KEY_ENV = "PI_CACHE_OPTIMIZER_OPENAI_CACHE_KEY";
108
120
  const NO_OPENAI_CACHE_KEY_ENV = "PI_CACHE_OPTIMIZER_NO_OPENAI_CACHE_KEY";
@@ -379,6 +391,46 @@ type OptimizedSystemPrompt = {
379
391
  * Contains only numeric counters and booleans — never message content, prompts,
380
392
  * payloads, headers, API keys, or model outputs.
381
393
  */
394
+ type PersistedStatsShardV7 = {
395
+ version: 7;
396
+ kind: "pi-cache-optimizer-shard";
397
+ instanceId: string;
398
+ sessionHash: string;
399
+ process: {
400
+ pid: number;
401
+ ppid: number;
402
+ instanceStartedAt: number;
403
+ };
404
+ lifecycle: {
405
+ state: "active" | "closed";
406
+ createdAt: number;
407
+ updatedAt: number;
408
+ closedAt?: number;
409
+ };
410
+ day: string;
411
+ globalEpoch: string;
412
+ models: Record<string, {
413
+ modelEpoch: string;
414
+ provider: string;
415
+ modelId: string;
416
+ modelName?: string;
417
+ api?: string;
418
+ stats: CacheStats;
419
+ }>;
420
+ lastRoutedModel?: PersistedRoutedModelRef;
421
+ };
422
+
423
+ type ShardAggregate = {
424
+ bySession: Record<string, Record<string, CacheStats>>;
425
+ totalsByModel: Record<string, CacheStats>;
426
+ instancesBySession: Record<string, number>;
427
+ instancesBySessionModel: Record<string, Record<string, number>>;
428
+ sessionsByModel: Record<string, number>;
429
+ instancesByModel: Record<string, number>;
430
+ modelRefsByKey: Record<string, PersistedRoutedModelRef>;
431
+ lastRoutedModelBySession: Record<string, PersistedRoutedModelRef>;
432
+ };
433
+
382
434
  type CacheUsageSample = {
383
435
  timestamp: number;
384
436
  hit: boolean;
@@ -1171,6 +1223,7 @@ const CACHE_OPTIMIZER_COMMANDS = [
1171
1223
  ] as const;
1172
1224
  const CACHE_OPTIMIZER_CONFIG_ARGUMENTS = ["footer-mode"] as const;
1173
1225
  const CACHE_OPTIMIZER_FOOTER_MODES = ["total", "session", "process"] as const;
1226
+ const CACHE_OPTIMIZER_STATS_ARGUMENTS = ["all", "contributors"] as const;
1174
1227
 
1175
1228
  function filterCommandCompletionItems(
1176
1229
  values: readonly string[],
@@ -1209,9 +1262,18 @@ function getCacheOptimizerArgumentCompletions(argumentPrefix: string): CommandCo
1209
1262
  if (subcommandPrefix === "config") {
1210
1263
  return filterCommandCompletionItems(CACHE_OPTIMIZER_CONFIG_ARGUMENTS, "", "config");
1211
1264
  }
1265
+ if (subcommandPrefix === "stats") {
1266
+ return filterCommandCompletionItems(CACHE_OPTIMIZER_STATS_ARGUMENTS, "", "stats");
1267
+ }
1212
1268
  return filterCommandCompletionItems(CACHE_OPTIMIZER_COMMANDS, parts[0]);
1213
1269
  }
1214
1270
 
1271
+ if (parts[0].toLowerCase() === "stats") {
1272
+ return parts.length === 2
1273
+ ? filterCommandCompletionItems(CACHE_OPTIMIZER_STATS_ARGUMENTS, parts[1], "stats")
1274
+ : null;
1275
+ }
1276
+
1215
1277
  if (parts[0].toLowerCase() !== "config") return null;
1216
1278
 
1217
1279
  if (parts.length === 2) {
@@ -1267,7 +1329,7 @@ function resolveFooterStatsMode(
1267
1329
  ): { mode: FooterStatsMode; source: FooterStatsModeSource } {
1268
1330
  if (configuredMode) return { mode: configuredMode, source: "config" };
1269
1331
  const envMode = parseFooterStatsMode(env[FOOTER_MODE_ENV]);
1270
- return envMode ? { mode: envMode, source: "env" } : { mode: "total", source: "default" };
1332
+ return envMode ? { mode: envMode, source: "env" } : { mode: "session", source: "default" };
1271
1333
  }
1272
1334
 
1273
1335
  function footerStatsMode(
@@ -3840,6 +3902,87 @@ function formatRecentTrendSummary(samples: CacheUsageSample[], maxCount: number)
3840
3902
  /**
3841
3903
  * Build the output for `/cache-optimizer stats`.
3842
3904
  */
3905
+ function formatCompactStats(stats: CacheStats): string {
3906
+ const percent = stats.totalInputTokens > 0
3907
+ ? (stats.cachedInputTokens / stats.totalInputTokens) * 100
3908
+ : 0;
3909
+ return `${stats.hitRequests}/${stats.totalRequests}·${formatTokenCount(stats.cachedInputTokens)}/${formatTokenCount(stats.totalInputTokens)} ${percent.toFixed(1)}%`;
3910
+ }
3911
+
3912
+ function modelFromStatsKey(key: string, ref?: PersistedRoutedModelRef): PiModel | undefined {
3913
+ const slash = key.indexOf("/");
3914
+ if (slash <= 0 || slash >= key.length - 1) return undefined;
3915
+ return routedModelRefToPiModel(ref ?? { provider: key.slice(0, slash), id: key.slice(slash + 1) });
3916
+ }
3917
+
3918
+ function sortStatsEntries(entries: Array<[string, CacheStats]>, activeModelKey?: string): Array<[string, CacheStats]> {
3919
+ return entries.sort(([left], [right]) => {
3920
+ if (left === activeModelKey) return -1;
3921
+ if (right === activeModelKey) return 1;
3922
+ return left.localeCompare(right);
3923
+ });
3924
+ }
3925
+
3926
+ function buildSessionStatsOutput(
3927
+ sessionModels: Record<string, CacheStats>,
3928
+ activeModel?: PiModel,
3929
+ modelRefsByKey: Record<string, PersistedRoutedModelRef> = {},
3930
+ ): string {
3931
+ const activeKey = activeModel ? modelKey(activeModel) : undefined;
3932
+ const models = { ...sessionModels };
3933
+ if (activeModel && selectAdapterForModel(activeModel) && !models[activeKey!]) {
3934
+ models[activeKey!] = emptyCacheStats();
3935
+ }
3936
+ const entries = sortStatsEntries(Object.entries(models), activeKey);
3937
+ if (entries.length === 0) return "ℹ️ No cache statistics recorded for the current session today.";
3938
+ const lines = ["Scope: current session", `Day: ${currentLocalDay()}`, `Models: ${entries.length}`];
3939
+ for (const [key, stats] of entries) {
3940
+ const model = modelFromStatsKey(key, modelRefsByKey[key]);
3941
+ const adapter = model ? selectAdapterForModel(model) : undefined;
3942
+ lines.push("", `── ${key} ──`, `Adapter: ${adapter?.label ?? "Unknown cache adapter"}`);
3943
+ lines.push(`Requests: ${stats.hitRequests} hit / ${stats.totalRequests} total`);
3944
+ lines.push(`Cached tokens: ${formatTokenCount(stats.cachedInputTokens)} / ${formatTokenCount(stats.totalInputTokens)} input · ${stats.totalInputTokens > 0 ? `${((stats.cachedInputTokens / stats.totalInputTokens) * 100).toFixed(1)}%` : "0.0%"}`);
3945
+ lines.push(`Summary: ${formatCompactStats(stats)}`);
3946
+ if (stats.cacheWriteInputTokens > 0) lines.push(`Cache write: ${formatTokenCount(stats.cacheWriteInputTokens)}`);
3947
+ }
3948
+ return lines.join("\n");
3949
+ }
3950
+
3951
+ function buildAllStatsOutput(aggregate: ShardAggregate): string {
3952
+ const entries = sortStatsEntries(Object.entries(aggregate.totalsByModel));
3953
+ if (entries.length === 0) return "ℹ️ No local cache statistics recorded today.";
3954
+ const sessionCount = Object.keys(aggregate.instancesBySession).length;
3955
+ const instanceCount = Object.values(aggregate.instancesBySession).reduce((sum, value) => sum + value, 0);
3956
+ const lines = ["Scope: all local sessions", `Day: ${currentLocalDay()}`, `Sessions: ${sessionCount}`, `Instances: ${instanceCount}`];
3957
+ for (const [key, stats] of entries) {
3958
+ const model = modelFromStatsKey(key, aggregate.modelRefsByKey[key]);
3959
+ const adapter = model ? selectAdapterForModel(model) : undefined;
3960
+ lines.push("", `── ${key} ──`, `Adapter: ${adapter?.label ?? "Unknown cache adapter"}`);
3961
+ lines.push(`Sessions: ${aggregate.sessionsByModel[key] ?? 0} · Instances: ${aggregate.instancesByModel[key] ?? 0}`);
3962
+ lines.push(`Requests: ${stats.hitRequests} hit / ${stats.totalRequests} total`);
3963
+ lines.push(`Cached tokens: ${formatTokenCount(stats.cachedInputTokens)} / ${formatTokenCount(stats.totalInputTokens)} input · ${stats.totalInputTokens > 0 ? `${((stats.cachedInputTokens / stats.totalInputTokens) * 100).toFixed(1)}%` : "0.0%"}`);
3964
+ lines.push(`Summary: ${formatCompactStats(stats)}`);
3965
+ if (stats.cacheWriteInputTokens > 0) lines.push(`Cache write: ${formatTokenCount(stats.cacheWriteInputTokens)}`);
3966
+ }
3967
+ return lines.join("\n");
3968
+ }
3969
+
3970
+ function buildContributorsStatsOutput(aggregate: ShardAggregate, activeModel: PiModel | undefined, currentSessionHash?: string): string {
3971
+ if (!activeModel) return "ℹ️ No active model selected.";
3972
+ const key = modelKey(activeModel);
3973
+ const rows = Object.entries(aggregate.bySession)
3974
+ .filter(([, models]) => models[key])
3975
+ .sort(([left], [right]) => left === currentSessionHash ? -1 : right === currentSessionHash ? 1 : left.localeCompare(right));
3976
+ if (rows.length === 0) return `ℹ️ No contributors recorded for ${key} today.`;
3977
+ const lines = [`Model: ${key}`, "Scope: contributing local sessions today"];
3978
+ let otherIndex = 0;
3979
+ for (const [sessionHash, models] of rows) {
3980
+ const label = sessionHash === currentSessionHash ? "Current session" : `Other session ${++otherIndex}`;
3981
+ lines.push("", label, ` Instances: ${aggregate.instancesBySessionModel[sessionHash]?.[key] ?? 0}`, ` ${formatCompactStats(models[key])}`);
3982
+ }
3983
+ return lines.join("\n");
3984
+ }
3985
+
3843
3986
  function buildStatsOutput(model: PiModel | undefined, adapter: CacheProviderAdapter | undefined, stats: CacheStats | undefined, recentSamples: CacheUsageSample[]): string {
3844
3987
  const lines: string[] = [];
3845
3988
 
@@ -4248,8 +4391,8 @@ function filterRestorableStatsForSession(
4248
4391
  * `restoreCacheStats` can migrate them on the next load before the session
4249
4392
  * id is known.
4250
4393
  *
4251
- * Pure function (no I/O) — suitable for unit tests without touching the real
4252
- * state file at `~/.pi/agent/pi-cache-optimizer-stats.json`.
4394
+ * Historical v1-v6 pure helper (no runtime I/O). Permanent migration tests
4395
+ * exercise it directly; v7 startup never reads or writes the obsolete file.
4253
4396
  */
4254
4397
  function mergeCacheSessions(
4255
4398
  existingSessions: Record<string, Record<string, CacheStats>>,
@@ -4409,7 +4552,304 @@ async function writePersistedCacheStats(
4409
4552
  await rename(tempPath, STATE_FILE_PATH);
4410
4553
  }
4411
4554
 
4555
+ function modelEpochPath(modelKeyValue: string): string {
4556
+ return join(SHARD_MODEL_EPOCH_DIR, `${createHash("sha256").update(modelKeyValue).digest("hex")}.json`);
4557
+ }
4558
+
4559
+ function initialEpoch(scope: string): string {
4560
+ return `initial:${scope}`;
4561
+ }
4562
+
4563
+ function parseEpochRecord(value: unknown): string | undefined {
4564
+ const record = asRecord(value);
4565
+ return record?.version === 1 && isNonEmptyString(record.epoch) ? record.epoch.trim() : undefined;
4566
+ }
4567
+
4568
+ async function readEpochFile(path: string, fallback: string): Promise<string> {
4569
+ try {
4570
+ return parseEpochRecord(JSON.parse(await readFile(path, "utf8"))) ?? fallback;
4571
+ } catch {
4572
+ return fallback;
4573
+ }
4574
+ }
4575
+
4576
+ async function readGlobalStatsEpoch(): Promise<string> {
4577
+ return readEpochFile(SHARD_GLOBAL_EPOCH_PATH, initialEpoch("global"));
4578
+ }
4579
+
4580
+ async function readModelStatsEpoch(modelKeyValue: string): Promise<string> {
4581
+ return readEpochFile(modelEpochPath(modelKeyValue), initialEpoch(`model:${modelKeyValue}`));
4582
+ }
4583
+
4584
+ async function writeStatsEpoch(path: string, epoch: string): Promise<void> {
4585
+ await mkdir(dirname(path), { recursive: true });
4586
+ const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
4587
+ await writeFile(tempPath, JSON.stringify({ version: 1, epoch, createdAt: Date.now() }, null, 2) + "\n", "utf8");
4588
+ await rename(tempPath, path);
4589
+ }
4590
+
4591
+ async function advanceGlobalStatsEpoch(): Promise<string> {
4592
+ const epoch = `${Date.now()}-${randomUUID()}`;
4593
+ await writeStatsEpoch(SHARD_GLOBAL_EPOCH_PATH, epoch);
4594
+ return epoch;
4595
+ }
4596
+
4597
+ async function advanceModelStatsEpoch(modelKeyValue: string): Promise<string> {
4598
+ const epoch = `${Date.now()}-${randomUUID()}`;
4599
+ await writeStatsEpoch(modelEpochPath(modelKeyValue), epoch);
4600
+ return epoch;
4601
+ }
4602
+
4603
+ function parsePersistedStatsShardV7(value: unknown): PersistedStatsShardV7 | undefined {
4604
+ const record = asRecord(value);
4605
+ const processRecord = asRecord(record?.process);
4606
+ const lifecycle = asRecord(record?.lifecycle);
4607
+ const rawModels = asRecord(record?.models);
4608
+ if (
4609
+ record?.version !== 7 ||
4610
+ record.kind !== "pi-cache-optimizer-shard" ||
4611
+ !isNonEmptyString(record.instanceId) ||
4612
+ !isNonEmptyString(record.sessionHash) ||
4613
+ !processRecord ||
4614
+ !Number.isInteger(processRecord.pid) ||
4615
+ !Number.isInteger(processRecord.ppid) ||
4616
+ typeof processRecord.instanceStartedAt !== "number" ||
4617
+ !lifecycle ||
4618
+ (lifecycle.state !== "active" && lifecycle.state !== "closed") ||
4619
+ typeof lifecycle.createdAt !== "number" ||
4620
+ typeof lifecycle.updatedAt !== "number" ||
4621
+ !isNonEmptyString(record.day) ||
4622
+ !/^\d{4}-\d{2}-\d{2}$/.test(record.day) ||
4623
+ !isNonEmptyString(record.globalEpoch) ||
4624
+ !rawModels
4625
+ ) return undefined;
4626
+
4627
+ const models: PersistedStatsShardV7["models"] = {};
4628
+ for (const [key, rawEntry] of Object.entries(rawModels)) {
4629
+ const entry = asRecord(rawEntry);
4630
+ const stats = parseCacheStats(entry?.stats);
4631
+ if (
4632
+ !entry || !stats || !isNonEmptyString(entry.modelEpoch) ||
4633
+ !isNonEmptyString(entry.provider) || !isNonEmptyString(entry.modelId) ||
4634
+ key !== `${entry.provider.trim()}/${entry.modelId.trim()}`
4635
+ ) continue;
4636
+ models[key] = {
4637
+ modelEpoch: entry.modelEpoch.trim(),
4638
+ provider: entry.provider.trim(),
4639
+ modelId: entry.modelId.trim(),
4640
+ ...(isNonEmptyString(entry.modelName) ? { modelName: entry.modelName.trim() } : {}),
4641
+ ...(isNonEmptyString(entry.api) ? { api: entry.api.trim() } : {}),
4642
+ stats,
4643
+ };
4644
+ }
4645
+
4646
+ const lastRoutedModel = parsePersistedRoutedModelRef(record.lastRoutedModel);
4647
+ return {
4648
+ version: 7,
4649
+ kind: "pi-cache-optimizer-shard",
4650
+ instanceId: record.instanceId.trim(),
4651
+ sessionHash: record.sessionHash.trim(),
4652
+ process: {
4653
+ pid: Number(processRecord.pid),
4654
+ ppid: Number(processRecord.ppid),
4655
+ instanceStartedAt: processRecord.instanceStartedAt,
4656
+ },
4657
+ lifecycle: {
4658
+ state: lifecycle.state,
4659
+ createdAt: lifecycle.createdAt,
4660
+ updatedAt: lifecycle.updatedAt,
4661
+ ...(typeof lifecycle.closedAt === "number" ? { closedAt: lifecycle.closedAt } : {}),
4662
+ },
4663
+ day: record.day,
4664
+ globalEpoch: record.globalEpoch.trim(),
4665
+ models,
4666
+ ...(lastRoutedModel ? { lastRoutedModel } : {}),
4667
+ };
4668
+ }
4669
+
4670
+ async function writeStatsShardV7(path: string, shard: PersistedStatsShardV7): Promise<void> {
4671
+ await mkdir(dirname(path), { recursive: true });
4672
+ const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
4673
+ await writeFile(tempPath, JSON.stringify(shard, null, 2) + "\n", "utf8");
4674
+ await rename(tempPath, path);
4675
+ }
4676
+
4677
+ async function readValidStatsShardsV7(directory: string = SHARD_FILES_DIR): Promise<PersistedStatsShardV7[]> {
4678
+ let names: string[];
4679
+ try {
4680
+ names = await readdir(directory);
4681
+ } catch (error) {
4682
+ if (getErrorCode(error) === "ENOENT") return [];
4683
+ throw error;
4684
+ }
4685
+ const shards: PersistedStatsShardV7[] = [];
4686
+ for (const name of names) {
4687
+ if (!/^[0-9a-f-]+\.json$/i.test(name)) continue;
4688
+ const path = join(directory, name);
4689
+ try {
4690
+ const info = await lstat(path);
4691
+ if (!info.isFile() || info.isSymbolicLink()) continue;
4692
+ const parsed = parsePersistedStatsShardV7(JSON.parse(await readFile(path, "utf8")));
4693
+ const filenameInstanceId = name.slice(0, -".json".length);
4694
+ if (parsed && parsed.instanceId === filenameInstanceId) shards.push(parsed);
4695
+ } catch {
4696
+ // Ignore malformed or transiently unavailable shards. Atomic writers will
4697
+ // publish a complete replacement on the next successful update.
4698
+ }
4699
+ }
4700
+ return shards;
4701
+ }
4702
+
4703
+ async function aggregateStatsShardsV7(
4704
+ shards: PersistedStatsShardV7[],
4705
+ day: string = currentLocalDay(),
4706
+ ): Promise<ShardAggregate> {
4707
+ const globalEpoch = await readGlobalStatsEpoch();
4708
+ const modelEpochs = new Map<string, string>();
4709
+ const result: ShardAggregate = {
4710
+ bySession: {},
4711
+ totalsByModel: {},
4712
+ instancesBySession: {},
4713
+ instancesBySessionModel: {},
4714
+ sessionsByModel: {},
4715
+ instancesByModel: {},
4716
+ modelRefsByKey: {},
4717
+ lastRoutedModelBySession: {},
4718
+ };
4719
+ const routedUpdatedAt = new Map<string, number>();
4720
+ const modelRefUpdatedAt = new Map<string, number>();
4721
+ const modelSessions = new Map<string, Set<string>>();
4722
+
4723
+ for (const shard of shards) {
4724
+ if (shard.day !== day || shard.globalEpoch !== globalEpoch) continue;
4725
+ let contributed = false;
4726
+ if (shard.lastRoutedModel && (routedUpdatedAt.get(shard.sessionHash) ?? -1) < shard.lifecycle.updatedAt) {
4727
+ routedUpdatedAt.set(shard.sessionHash, shard.lifecycle.updatedAt);
4728
+ result.lastRoutedModelBySession[shard.sessionHash] = shard.lastRoutedModel;
4729
+ }
4730
+ for (const [key, entry] of Object.entries(shard.models)) {
4731
+ let epoch = modelEpochs.get(key);
4732
+ if (!epoch) {
4733
+ epoch = await readModelStatsEpoch(key);
4734
+ modelEpochs.set(key, epoch);
4735
+ }
4736
+ if (entry.modelEpoch !== epoch || entry.stats.day !== day) continue;
4737
+ contributed = true;
4738
+ const sessionModels = result.bySession[shard.sessionHash] ??= {};
4739
+ sessionModels[key] = mergeCacheStatsForTotal(sessionModels[key], entry.stats);
4740
+ result.totalsByModel[key] = mergeCacheStatsForTotal(result.totalsByModel[key], entry.stats);
4741
+ result.instancesByModel[key] = (result.instancesByModel[key] ?? 0) + 1;
4742
+ if ((modelRefUpdatedAt.get(key) ?? -1) < shard.lifecycle.updatedAt) {
4743
+ modelRefUpdatedAt.set(key, shard.lifecycle.updatedAt);
4744
+ result.modelRefsByKey[key] = {
4745
+ provider: entry.provider,
4746
+ id: entry.modelId,
4747
+ name: entry.modelName ?? entry.modelId,
4748
+ };
4749
+ }
4750
+ const sessionInstances = result.instancesBySessionModel[shard.sessionHash] ??= {};
4751
+ sessionInstances[key] = (sessionInstances[key] ?? 0) + 1;
4752
+ const sessions = modelSessions.get(key) ?? new Set<string>();
4753
+ sessions.add(shard.sessionHash);
4754
+ modelSessions.set(key, sessions);
4755
+ }
4756
+ if (contributed) {
4757
+ result.instancesBySession[shard.sessionHash] = (result.instancesBySession[shard.sessionHash] ?? 0) + 1;
4758
+ }
4759
+ }
4760
+ for (const [key, sessions] of modelSessions) result.sessionsByModel[key] = sessions.size;
4761
+ return result;
4762
+ }
4763
+
4764
+ async function loadStatsShardAggregateV7(directory: string = SHARD_FILES_DIR): Promise<ShardAggregate> {
4765
+ return aggregateStatsShardsV7(await readValidStatsShardsV7(directory));
4766
+ }
4767
+
4768
+ function isProcessAlive(pid: number): boolean {
4769
+ if (!Number.isInteger(pid) || pid <= 0) return false;
4770
+ try {
4771
+ process.kill(pid, 0);
4772
+ return true;
4773
+ } catch (error) {
4774
+ return getErrorCode(error) === "EPERM";
4775
+ }
4776
+ }
4777
+
4778
+ async function removeLegacyStatsFiles(): Promise<void> {
4779
+ for (const path of [STATE_FILE_PATH, LEGACY_STATE_FILE_PATH]) {
4780
+ try {
4781
+ await unlink(path);
4782
+ } catch (error) {
4783
+ if (getErrorCode(error) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to remove obsolete stats file ${path}`, error);
4784
+ }
4785
+ }
4786
+ }
4787
+
4788
+ async function cleanupStatsShardsV7(now = Date.now(), directory: string = SHARD_FILES_DIR): Promise<number> {
4789
+ let names: string[];
4790
+ try {
4791
+ names = await readdir(directory);
4792
+ } catch (error) {
4793
+ return getErrorCode(error) === "ENOENT" ? 0 : Promise.reject(error);
4794
+ }
4795
+ const today = currentLocalDay();
4796
+ let removed = 0;
4797
+ for (const name of names) {
4798
+ const isShard = /^[0-9a-f-]+\.json$/i.test(name);
4799
+ const isTemp = name.endsWith(".tmp");
4800
+ if (!isShard && !isTemp) continue;
4801
+ const path = join(directory, name);
4802
+ try {
4803
+ const info = await lstat(path);
4804
+ if (!info.isFile() || info.isSymbolicLink()) continue;
4805
+ if (isTemp) {
4806
+ if (now - info.mtimeMs < SHARD_TEMP_RETENTION_MS) continue;
4807
+ } else {
4808
+ const parsed = parsePersistedStatsShardV7(JSON.parse(await readFile(path, "utf8")));
4809
+ if (parsed?.day === today) continue;
4810
+ const updatedAt = parsed?.lifecycle.updatedAt ?? info.mtimeMs;
4811
+ if (now - updatedAt < SHARD_RETENTION_MS) continue;
4812
+ if (parsed?.lifecycle.state === "active" && isProcessAlive(parsed.process.pid)) continue;
4813
+ }
4814
+ await unlink(path);
4815
+ removed += 1;
4816
+ } catch {
4817
+ // Best-effort maintenance must never block the extension.
4818
+ }
4819
+ }
4820
+ return removed;
4821
+ }
4822
+
4823
+ async function maybeCleanupStatsShardsV7(now = Date.now()): Promise<void> {
4824
+ await mkdir(SHARD_MAINTENANCE_DIR, { recursive: true });
4825
+ try {
4826
+ const marker = await stat(SHARD_CLEANUP_MARKER_PATH);
4827
+ if (now - marker.mtimeMs < SHARD_CLEANUP_INTERVAL_MS) return;
4828
+ } catch {}
4829
+
4830
+ try {
4831
+ await mkdir(SHARD_CLEANUP_LOCK_PATH);
4832
+ } catch (error) {
4833
+ if (getErrorCode(error) !== "EEXIST") return;
4834
+ try {
4835
+ const lock = await stat(SHARD_CLEANUP_LOCK_PATH);
4836
+ if (now - lock.mtimeMs <= SHARD_CLEANUP_LOCK_STALE_MS) return;
4837
+ await rm(SHARD_CLEANUP_LOCK_PATH, { recursive: true, force: true });
4838
+ await mkdir(SHARD_CLEANUP_LOCK_PATH);
4839
+ } catch {
4840
+ return;
4841
+ }
4842
+ }
4412
4843
 
4844
+ try {
4845
+ await cleanupStatsShardsV7(now);
4846
+ const tempPath = `${SHARD_CLEANUP_MARKER_PATH}.${process.pid}.${randomUUID()}.tmp`;
4847
+ await writeFile(tempPath, `${now}\n`, "utf8");
4848
+ await rename(tempPath, SHARD_CLEANUP_MARKER_PATH);
4849
+ } finally {
4850
+ await rm(SHARD_CLEANUP_LOCK_PATH, { recursive: true, force: true });
4851
+ }
4852
+ }
4413
4853
 
4414
4854
  function isCompatCheckApplicable(model: PiModel): boolean {
4415
4855
  return isOpenAICompatibleProxyApi(model.api) && !isOfficialOpenAIBaseUrl(model) && !isPiBuiltInLlamaCppModel(model);
@@ -6876,6 +7316,10 @@ export const __internals_for_tests = {
6876
7316
  // Recent sample / stats output / diagnosis helpers
6877
7317
  MAX_RECENT_SAMPLES,
6878
7318
  buildStatsOutput,
7319
+ buildSessionStatsOutput,
7320
+ buildAllStatsOutput,
7321
+ buildContributorsStatsOutput,
7322
+ formatCompactStats,
6879
7323
  buildLowHitDiagnosis,
6880
7324
  formatRecentTrendSummary,
6881
7325
  formatHitRatio,
@@ -6923,8 +7367,23 @@ export const __internals_for_tests = {
6923
7367
  createSerializedAsyncRunner,
6924
7368
  writePersistedCacheStats,
6925
7369
  readPersistedCacheStats,
7370
+ parsePersistedStatsShardV7,
7371
+ writeStatsShardV7,
7372
+ readValidStatsShardsV7,
7373
+ aggregateStatsShardsV7,
7374
+ loadStatsShardAggregateV7,
7375
+ cleanupStatsShardsV7,
7376
+ removeLegacyStatsFiles,
7377
+ readGlobalStatsEpoch,
7378
+ readModelStatsEpoch,
7379
+ advanceGlobalStatsEpoch,
7380
+ advanceModelStatsEpoch,
7381
+ modelEpochPath,
6926
7382
  STATE_FILE_PATH,
6927
7383
  LEGACY_STATE_FILE_PATH,
7384
+ SHARD_STATE_DIR,
7385
+ SHARD_FILES_DIR,
7386
+ SHARD_GLOBAL_EPOCH_PATH,
6928
7387
  STATE_DIR,
6929
7388
  // JSONC surgical edit helpers
6930
7389
  MODELS_JSON_PATH,
@@ -6981,17 +7440,25 @@ export default function (pi: ExtensionAPI) {
6981
7440
  let lastStatusText: string | undefined;
6982
7441
  let persistenceWarningShown = false;
6983
7442
  let persistTimer: ReturnType<typeof setTimeout> | null = null;
7443
+ let shardRefreshTimer: ReturnType<typeof setTimeout> | null = null;
7444
+ let shardWatcher: ReturnType<typeof watch> | undefined;
6984
7445
  const enqueuePersist = createSerializedAsyncRunner();
6985
- let replacePersistedTotalsOnNextWrite = false;
6986
- let forceReplaceTotalsAfterFailure = false;
6987
- const pendingDeletedTotalModelKeys = new Set<string>();
7446
+ const instanceId = randomUUID();
7447
+ const instanceStartedAt = Date.now();
7448
+ const instanceShardPath = join(SHARD_FILES_DIR, `${instanceId}.json`);
7449
+ const modelApiByKey = new Map<string, string>();
7450
+ const modelNameByKey = new Map<string, string>();
7451
+ const modelEpochByKey = new Map<string, string>();
7452
+ let currentGlobalEpoch = initialEpoch("global");
6988
7453
  let integrityNotificationShown = false;
6989
7454
  let currentSessionId = "";
6990
7455
  let currentSessionHash = "";
6991
7456
  let currentSessionHashSet = false;
6992
7457
  let lastActualRoutedModel: PersistedRoutedModelRef | undefined;
6993
7458
  let latestCacheHint: PiCacheHintSnapshot | undefined;
7459
+ let shardCreatedAt = Date.now();
6994
7460
  const PERSIST_DEBOUNCE_MS = 2000;
7461
+ const SHARD_REFRESH_DEBOUNCE_MS = 250;
6995
7462
 
6996
7463
  function buildObservedRuntimeFixSuggestion(model: PiModel): FixSuggestion | undefined {
6997
7464
  const key = modelKey(model);
@@ -7106,17 +7573,102 @@ export default function (pi: ExtensionAPI) {
7106
7573
  recentSamplesByModelKey.clear();
7107
7574
  }
7108
7575
 
7109
- function getCacheStatsState(): CacheStatsState {
7576
+ async function refreshShardAggregate(): Promise<ShardAggregate> {
7577
+ const persistedShards = await readValidStatsShardsV7();
7578
+ const shards = persistedShards.filter((shard) => shard.instanceId !== instanceId);
7579
+ if (currentSessionHashSet) shards.push(buildCurrentStatsShard());
7580
+ const aggregate = await aggregateStatsShardsV7(shards);
7581
+
7582
+ // A reset from another process can advance epochs while this instance is
7583
+ // idle. Adopt those epochs during every aggregate refresh, not only when a
7584
+ // later message_end arrives, so this instance cannot keep an old in-memory
7585
+ // process bucket visible in `process` mode or rewrite stale counters during
7586
+ // shutdown. Model-scoped resets clear only the affected local bucket.
7587
+ const latestGlobalEpoch = await readGlobalStatsEpoch();
7588
+ if (currentGlobalEpoch !== latestGlobalEpoch) {
7589
+ currentGlobalEpoch = latestGlobalEpoch;
7590
+ cacheStatsProcessByModel = {};
7591
+ modelEpochByKey.clear();
7592
+ modelApiByKey.clear();
7593
+ modelNameByKey.clear();
7594
+ lastActualRoutedModel = undefined;
7595
+ } else {
7596
+ for (const key of Object.keys(cacheStatsProcessByModel)) {
7597
+ const latestModelEpoch = await readModelStatsEpoch(key);
7598
+ if ((modelEpochByKey.get(key) ?? initialEpoch(`model:${key}`)) === latestModelEpoch) continue;
7599
+ delete cacheStatsProcessByModel[key];
7600
+ modelEpochByKey.set(key, latestModelEpoch);
7601
+ modelApiByKey.delete(key);
7602
+ modelNameByKey.delete(key);
7603
+ }
7604
+ }
7605
+
7606
+ cacheStatsByModel = {};
7607
+ for (const [sessionHash, models] of Object.entries(aggregate.bySession)) {
7608
+ for (const [key, stats] of Object.entries(models)) {
7609
+ cacheStatsByModel[`${sessionHash}:${key}`] = stats;
7610
+ }
7611
+ }
7612
+ cacheStatsTotalsByModel = aggregate.totalsByModel;
7613
+ if (currentSessionHashSet) {
7614
+ lastActualRoutedModel = aggregate.lastRoutedModelBySession[currentSessionHash];
7615
+ }
7616
+ lastStatusText = undefined;
7617
+ return aggregate;
7618
+ }
7619
+
7620
+ async function ensureCurrentEpochs(): Promise<void> {
7621
+ currentGlobalEpoch = await readGlobalStatsEpoch();
7622
+ for (const key of Object.keys(cacheStatsProcessByModel)) {
7623
+ modelEpochByKey.set(key, await readModelStatsEpoch(key));
7624
+ }
7625
+ }
7626
+
7627
+ function buildCurrentStatsShard(state: "active" | "closed" = "active"): PersistedStatsShardV7 {
7628
+ const now = Date.now();
7629
+ const models: PersistedStatsShardV7["models"] = {};
7630
+ for (const [key, stats] of Object.entries(cacheStatsProcessByModel)) {
7631
+ const slash = key.indexOf("/");
7632
+ if (slash <= 0 || slash >= key.length - 1) continue;
7633
+ models[key] = {
7634
+ modelEpoch: modelEpochByKey.get(key) ?? initialEpoch(`model:${key}`),
7635
+ provider: key.slice(0, slash),
7636
+ modelId: key.slice(slash + 1),
7637
+ ...(modelNameByKey.get(key) ? { modelName: modelNameByKey.get(key) } : {}),
7638
+ ...(modelApiByKey.get(key) ? { api: modelApiByKey.get(key) } : {}),
7639
+ stats: cloneCacheStats(stats),
7640
+ };
7641
+ }
7110
7642
  return {
7111
- statsByModel: cacheStatsByModel,
7112
- totalsByModel: cacheStatsTotalsByModel,
7113
- legacyFamily: cacheStatsLegacyFamily,
7114
- ...(currentSessionHashSet && lastActualRoutedModel
7115
- ? { lastRoutedModelBySession: { [currentSessionHash]: lastActualRoutedModel } }
7116
- : {}),
7643
+ version: 7,
7644
+ kind: "pi-cache-optimizer-shard",
7645
+ instanceId,
7646
+ sessionHash: currentSessionHash || "_nosession",
7647
+ process: { pid: process.pid, ppid: process.ppid, instanceStartedAt },
7648
+ lifecycle: {
7649
+ state,
7650
+ createdAt: shardCreatedAt,
7651
+ updatedAt: now,
7652
+ ...(state === "closed" ? { closedAt: now } : {}),
7653
+ },
7654
+ day: currentLocalDay(),
7655
+ globalEpoch: currentGlobalEpoch,
7656
+ models,
7657
+ ...(lastActualRoutedModel ? { lastRoutedModel: { ...lastActualRoutedModel } } : {}),
7117
7658
  };
7118
7659
  }
7119
7660
 
7661
+ function scheduleShardRefresh(ctx?: ExtensionContext, model?: PiModel): void {
7662
+ if (shardRefreshTimer !== null) clearTimeout(shardRefreshTimer);
7663
+ shardRefreshTimer = setTimeout(() => {
7664
+ shardRefreshTimer = null;
7665
+ void refreshShardAggregate()
7666
+ .then(() => publishStatus(ctx as ExtensionContext, model ?? ctx?.model))
7667
+ .catch(() => undefined);
7668
+ }, SHARD_REFRESH_DEBOUNCE_MS);
7669
+ shardRefreshTimer.unref?.();
7670
+ }
7671
+
7120
7672
  /** Look up visible cumulative stats for a model, falling back to legacy family. */
7121
7673
  function getStatsForModel(model: PiModel | undefined, adapter: CacheProviderAdapter): CacheStats {
7122
7674
  if (model) {
@@ -7166,79 +7718,41 @@ export default function (pi: ExtensionAPI) {
7166
7718
  return created;
7167
7719
  }
7168
7720
 
7169
- function resetStatsForModel(model: PiModel): void {
7721
+ async function resetStatsForModel(model: PiModel): Promise<void> {
7170
7722
  const displayKey = modelKey(model);
7723
+ const nextEpoch = await advanceModelStatsEpoch(displayKey);
7724
+ modelEpochByKey.set(displayKey, nextEpoch);
7725
+ delete cacheStatsProcessByModel[displayKey];
7726
+ delete cacheStatsTotalsByModel[displayKey];
7171
7727
  for (const key of Object.keys(cacheStatsByModel)) {
7172
7728
  if (modelKeyFromSessionScoped(key) === displayKey) delete cacheStatsByModel[key];
7173
7729
  }
7174
- delete cacheStatsTotalsByModel[displayKey];
7175
- delete cacheStatsProcessByModel[displayKey];
7176
- pendingDeletedTotalModelKeys.add(displayKey);
7177
7730
  for (const key of Array.from(recentSamplesByModelKey.keys())) {
7178
7731
  if (modelKeyFromSessionScoped(key) === displayKey) recentSamplesByModelKey.delete(key);
7179
7732
  }
7180
7733
  lastStatusText = undefined;
7181
7734
  }
7182
7735
 
7183
- function resetCurrentSessionStats(): void {
7184
- const prefix = `${currentSessionHash || "_nosession"}:`;
7185
- for (const key of Object.keys(cacheStatsByModel)) {
7186
- if (key.startsWith(prefix)) delete cacheStatsByModel[key];
7187
- }
7188
- cacheStatsProcessByModel = {};
7736
+ async function resetCurrentSessionStats(): Promise<void> {
7737
+ currentGlobalEpoch = await advanceGlobalStatsEpoch();
7738
+ cacheStatsByModel = {};
7189
7739
  cacheStatsTotalsByModel = {};
7190
- replacePersistedTotalsOnNextWrite = true;
7191
- for (const key of Array.from(recentSamplesByModelKey.keys())) {
7192
- if (key.startsWith(prefix)) recentSamplesByModelKey.delete(key);
7193
- }
7740
+ cacheStatsProcessByModel = {};
7741
+ modelEpochByKey.clear();
7742
+ modelApiByKey.clear();
7743
+ modelNameByKey.clear();
7744
+ clearRecentSamples();
7194
7745
  lastActualRoutedModel = undefined;
7195
7746
  lastStatusText = undefined;
7196
7747
  }
7197
7748
 
7198
- function snapshotCacheStatsState(): CacheStatsState {
7199
- const state = getCacheStatsState();
7200
- const statsByModel = Object.fromEntries(
7201
- Object.entries(state.statsByModel).map(([key, stats]) => [key, { ...stats }]),
7202
- );
7203
- const totalsByModel = Object.fromEntries(
7204
- Object.entries(state.totalsByModel).map(([key, stats]) => [key, { ...stats }]),
7205
- );
7206
- const legacyFamily: Partial<Record<CacheProviderId, CacheStats>> = {};
7207
- for (const id of CACHE_PROVIDER_IDS) {
7208
- const stats = state.legacyFamily[id];
7209
- if (stats) legacyFamily[id] = { ...stats };
7210
- }
7211
- const lastRoutedModelBySession = state.lastRoutedModelBySession
7212
- ? Object.fromEntries(
7213
- Object.entries(state.lastRoutedModelBySession).map(([key, model]) => [key, { ...model }]),
7214
- )
7215
- : undefined;
7216
- return { statsByModel, totalsByModel, legacyFamily, lastRoutedModelBySession };
7217
- }
7218
-
7219
- function persistCacheStats(ctx?: ExtensionContext): Promise<void> {
7220
- const state = snapshotCacheStatsState();
7221
- const sessionHash = currentSessionHashSet ? currentSessionHash : undefined;
7222
- const deleteModelKeys = Array.from(pendingDeletedTotalModelKeys);
7223
- const replaceTotals = replacePersistedTotalsOnNextWrite;
7224
- // This queued write owns the current intents. Later mutations can enqueue
7225
- // independent intents without an earlier completion clearing them.
7226
- pendingDeletedTotalModelKeys.clear();
7227
- replacePersistedTotalsOnNextWrite = false;
7228
-
7749
+ function persistCacheStats(ctx?: ExtensionContext, lifecycleState: "active" | "closed" = "active"): Promise<void> {
7750
+ const shard = buildCurrentStatsShard(lifecycleState);
7229
7751
  return enqueuePersist(async () => {
7230
- const recoverFromEarlierFailure = forceReplaceTotalsAfterFailure;
7231
7752
  try {
7232
- await writePersistedCacheStats(state, sessionHash, {
7233
- deleteModelKeys,
7234
- replaceTotals: replaceTotals || recoverFromEarlierFailure,
7235
- });
7236
- if (recoverFromEarlierFailure) forceReplaceTotalsAfterFailure = false;
7753
+ await writeStatsShardV7(instanceShardPath, shard);
7237
7754
  } catch (error) {
7238
- // The next queued write must replace totals authoritatively so a failed
7239
- // reset/delete cannot merge stale persisted counters back into state.
7240
- forceReplaceTotalsAfterFailure = true;
7241
- console.warn(`${LOG_PREFIX}: failed to persist cache stats`, error);
7755
+ console.warn(`${LOG_PREFIX}: failed to persist cache stats shard`, error);
7242
7756
  if (!persistenceWarningShown) {
7243
7757
  persistenceWarningShown = true;
7244
7758
  ctx?.ui.notify(
@@ -7250,61 +7764,37 @@ export default function (pi: ExtensionAPI) {
7250
7764
  });
7251
7765
  }
7252
7766
 
7253
- /** Schedule a debounced persist. Coalesces rapid message_end writes
7254
- * into a single disk write after PERSIST_DEBOUNCE_MS of silence.
7255
- * In-memory stats remain instantly up-to-date for the footer; only
7256
- * the on-disk persistence is delayed. */
7257
7767
  function schedulePersistCacheStats(ctx?: ExtensionContext): void {
7258
7768
  if (persistTimer !== null) clearTimeout(persistTimer);
7259
7769
  persistTimer = setTimeout(() => {
7260
7770
  persistTimer = null;
7261
- persistCacheStats(ctx).catch((err) => {
7262
- console.warn(`${LOG_PREFIX}: debounced persist failed`, err);
7263
- });
7771
+ void persistCacheStats(ctx).then(() => refreshShardAggregate()).catch(() => undefined);
7264
7772
  }, PERSIST_DEBOUNCE_MS);
7773
+ persistTimer.unref?.();
7265
7774
  }
7266
7775
 
7267
- /** Flush any pending debounced persist immediately (cancels timer + writes).
7268
- * Used on reload and day-rollover where immediate durability matters. */
7269
- async function flushPersistCacheStats(ctx?: ExtensionContext): Promise<void> {
7776
+ async function flushPersistCacheStats(ctx?: ExtensionContext, lifecycleState: "active" | "closed" = "active"): Promise<void> {
7270
7777
  if (persistTimer !== null) {
7271
7778
  clearTimeout(persistTimer);
7272
7779
  persistTimer = null;
7273
7780
  }
7274
- await persistCacheStats(ctx);
7781
+ // Adopt resets performed by another Pi process before capturing the shard
7782
+ // snapshot. Otherwise shutdown/command flush could rewrite stale local
7783
+ // counters under an old epoch even though aggregation already hid them.
7784
+ await refreshShardAggregate();
7785
+ await persistCacheStats(ctx, lifecycleState);
7786
+ await refreshShardAggregate();
7275
7787
  }
7276
7788
 
7277
7789
  async function rollOverStatsIfNeeded(ctx?: ExtensionContext): Promise<void> {
7278
7790
  const day = currentLocalDay();
7279
7791
  let changed = false;
7280
-
7281
- // Roll over per-model entries.
7282
- for (const key of Object.keys(cacheStatsByModel)) {
7283
- const stats = cacheStatsByModel[key];
7284
- if (stats && stats.day !== day) {
7285
- cacheStatsByModel[key] = emptyCacheStats(day);
7286
- changed = true;
7287
- }
7288
- }
7289
-
7290
- // Roll over cumulative provider/model totals.
7291
7792
  for (const key of Object.keys(cacheStatsProcessByModel)) {
7292
- const stats = cacheStatsProcessByModel[key];
7293
- if (stats && stats.day !== day) {
7793
+ if (cacheStatsProcessByModel[key]?.day !== day) {
7294
7794
  cacheStatsProcessByModel[key] = emptyCacheStats(day);
7295
7795
  changed = true;
7296
7796
  }
7297
7797
  }
7298
-
7299
- for (const key of Object.keys(cacheStatsTotalsByModel)) {
7300
- const stats = cacheStatsTotalsByModel[key];
7301
- if (stats && stats.day !== day) {
7302
- cacheStatsTotalsByModel[key] = emptyCacheStats(day);
7303
- changed = true;
7304
- }
7305
- }
7306
-
7307
- // Roll over legacy family entries.
7308
7798
  for (const id of CACHE_PROVIDER_IDS) {
7309
7799
  const stats = cacheStatsLegacyFamily[id];
7310
7800
  if (stats && stats.day !== day) {
@@ -7312,57 +7802,37 @@ export default function (pi: ExtensionAPI) {
7312
7802
  changed = true;
7313
7803
  }
7314
7804
  }
7315
-
7316
7805
  if (changed) {
7806
+ shardCreatedAt = Date.now();
7317
7807
  lastStatusText = undefined;
7318
- await persistCacheStats(ctx);
7808
+ await ensureCurrentEpochs();
7809
+ await flushPersistCacheStats(ctx);
7319
7810
  }
7320
7811
  }
7321
7812
 
7322
7813
  async function restoreCacheStats(reason: string, ctx: ExtensionContext): Promise<void> {
7323
7814
  syncSessionHash(ctx);
7324
-
7815
+ lastStatusText = undefined;
7816
+ cacheStatsProcessByModel = {};
7817
+ cacheStatsLegacyFamily = emptyAllCacheStats();
7818
+ modelEpochByKey.clear();
7819
+ modelApiByKey.clear();
7820
+ modelNameByKey.clear();
7821
+ currentGlobalEpoch = await readGlobalStatsEpoch();
7325
7822
  if (reason === "reload") {
7326
- // /reload: preserve session-scoped stats (same session hash).
7327
- // Pi extension reload creates a fresh closure, so cacheStatsByModel
7328
- // starts empty. Read persisted data and filter for current session.
7329
- lastStatusText = undefined;
7330
7823
  lastPromptIntegrityWarningAt = 0;
7331
7824
  integrityNotificationShown = false;
7332
7825
  clearRecentSamples();
7333
-
7334
- const persisted = await readPersistedCacheStats();
7335
- cacheStatsByModel = filterRestorableStatsForSession(
7336
- persisted,
7337
- currentSessionHashSet ? currentSessionHash : undefined,
7338
- );
7339
- cacheStatsProcessByModel = {};
7340
- cacheStatsTotalsByModel = persisted?.totalsByModel ?? {};
7341
- cacheStatsLegacyFamily = persisted?.legacyFamily ?? emptyAllCacheStats();
7342
- lastActualRoutedModel = currentSessionHashSet
7343
- ? persisted?.lastRoutedModelBySession?.[currentSessionHash]
7344
- : undefined;
7345
-
7346
- await rollOverStatsIfNeeded(ctx);
7347
- return;
7348
7826
  }
7349
-
7350
- // First load / process start: read persisted stats and filter for
7351
- // this session's entries. If the session hash is unavailable, start
7352
- // fresh instead of loading all persisted session buckets.
7353
- const persisted = await readPersistedCacheStats();
7354
- cacheStatsByModel = filterRestorableStatsForSession(
7355
- persisted,
7356
- currentSessionHashSet ? currentSessionHash : undefined,
7357
- );
7358
- cacheStatsProcessByModel = {};
7359
- cacheStatsTotalsByModel = persisted?.totalsByModel ?? {};
7360
- cacheStatsLegacyFamily = persisted?.legacyFamily ?? emptyAllCacheStats();
7361
- lastActualRoutedModel = currentSessionHashSet
7362
- ? persisted?.lastRoutedModelBySession?.[currentSessionHash]
7363
- : undefined;
7364
- lastStatusText = undefined;
7365
- await rollOverStatsIfNeeded(ctx);
7827
+ await removeLegacyStatsFiles();
7828
+ await mkdir(SHARD_FILES_DIR, { recursive: true });
7829
+ await refreshShardAggregate();
7830
+ await flushPersistCacheStats(ctx);
7831
+ await maybeCleanupStatsShardsV7();
7832
+ // Maintenance may remove expired shard files after the initial refresh.
7833
+ // Re-scan before publishing so startup/footer command state cannot retain
7834
+ // counters from files that no longer exist.
7835
+ await refreshShardAggregate();
7366
7836
  }
7367
7837
 
7368
7838
  async function publishStatus(ctx: ExtensionContext, model: PiModel | undefined = ctx.model): Promise<void> {
@@ -7407,8 +7877,9 @@ export default function (pi: ExtensionAPI) {
7407
7877
  }
7408
7878
 
7409
7879
  if (adapter) {
7410
- // Footer mode defaults to daily provider/model totals. Users may select
7411
- // current-session counters through persistent command config or the env var.
7880
+ // Footer mode defaults to current-session provider/model totals. Users may
7881
+ // select all-current-day or current-process counters through persistent
7882
+ // command config or the environment variable.
7412
7883
  const stats = displayModel
7413
7884
  ? selectFooterStatsForModel(mode, sessionHash, cacheStatsByModel, cacheStatsTotalsByModel, displayModel, cacheStatsProcessByModel)
7414
7885
  : undefined;
@@ -7492,13 +7963,35 @@ export default function (pi: ExtensionAPI) {
7492
7963
  pi.on("session_start", async (event, ctx) => {
7493
7964
  if (runtimeOptimizerEnabled) requestLongCacheRetention();
7494
7965
  await restoreCacheStats(event.reason, ctx);
7966
+ if (ctx.mode === "tui" && !shardWatcher) {
7967
+ try {
7968
+ shardWatcher = watch(SHARD_FILES_DIR, () => scheduleShardRefresh(ctx));
7969
+ shardWatcher.unref?.();
7970
+ } catch {
7971
+ // Lifecycle and explicit command refreshes remain authoritative.
7972
+ }
7973
+ }
7974
+ await publishStatus(ctx);
7975
+ });
7976
+
7977
+ pi.on("tool_execution_end", async (_event, ctx) => {
7978
+ await refreshShardAggregate();
7979
+ await publishStatus(ctx);
7980
+ });
7981
+
7982
+ pi.on("agent_settled", async (_event, ctx) => {
7983
+ await refreshShardAggregate();
7495
7984
  await publishStatus(ctx);
7496
7985
  });
7497
7986
 
7498
7987
  pi.on("session_shutdown", async (_event, ctx) => {
7499
7988
  try {
7500
- await flushPersistCacheStats(ctx);
7989
+ await flushPersistCacheStats(ctx, "closed");
7501
7990
  } finally {
7991
+ if (shardRefreshTimer !== null) clearTimeout(shardRefreshTimer);
7992
+ shardRefreshTimer = null;
7993
+ shardWatcher?.close();
7994
+ shardWatcher = undefined;
7502
7995
  latestCacheHint = undefined;
7503
7996
  delete getProtocolGlobal().__piCacheOptimizerCacheKey__;
7504
7997
  uninstallCacheHintsService();
@@ -7508,6 +8001,7 @@ export default function (pi: ExtensionAPI) {
7508
8001
 
7509
8002
  pi.on("model_select", async (event, ctx) => {
7510
8003
  if (runtimeOptimizerEnabled) notifyCacheCompatIfNeeded(resolveRouteModel(event.model, ctx) ?? event.model, ctx, warnedModels);
8004
+ await refreshShardAggregate();
7511
8005
  await publishStatus(ctx, event.model);
7512
8006
  });
7513
8007
 
@@ -7862,6 +8356,20 @@ export default function (pi: ExtensionAPI) {
7862
8356
  // Update session, process-local, and cumulative buckets for the actual
7863
8357
  // routed model. The process bucket is intentionally never persisted.
7864
8358
  if (statsModel) {
8359
+ const key = modelKey(statsModel);
8360
+ const latestGlobalEpoch = await readGlobalStatsEpoch();
8361
+ const latestModelEpoch = await readModelStatsEpoch(key);
8362
+ if (currentGlobalEpoch !== latestGlobalEpoch) {
8363
+ currentGlobalEpoch = latestGlobalEpoch;
8364
+ cacheStatsProcessByModel = {};
8365
+ modelEpochByKey.clear();
8366
+ }
8367
+ if (modelEpochByKey.get(key) !== latestModelEpoch) {
8368
+ delete cacheStatsProcessByModel[key];
8369
+ modelEpochByKey.set(key, latestModelEpoch);
8370
+ }
8371
+ modelApiByKey.set(key, statsModel.api ?? "");
8372
+ modelNameByKey.set(key, statsModel.name || statsModel.id);
7865
8373
  const sk = sessionModelKey(statsModel);
7866
8374
  addUsageToCacheStats(getOrCreateStatsByModelKey(sk), usage);
7867
8375
  addUsageToCacheStats(getOrCreateProcessStatsForModel(statsModel), usage);
@@ -7871,6 +8379,7 @@ export default function (pi: ExtensionAPI) {
7871
8379
  }
7872
8380
 
7873
8381
  schedulePersistCacheStats(ctx);
8382
+ await refreshShardAggregate();
7874
8383
  await publishStatus(ctx, statsModel);
7875
8384
  });
7876
8385
 
@@ -7881,7 +8390,7 @@ export default function (pi: ExtensionAPI) {
7881
8390
  // disable — disable runtime prompt/cache optimizations for this process
7882
8391
  // doctor — show current model/provider/api/baseUrl/compat status
7883
8392
  // with low-hit diagnosis
7884
- // stats — show active model stats bucket, recent trend, usage
8393
+ // stats — show detailed current-session models; `stats all` shows all local shards
7885
8394
  // compat — show compat suggestion with file path
7886
8395
  // config footer-mode total|session|process — persist footer mode override
7887
8396
  // fix — auto-fix compat issues (writes models.json, requires UI)
@@ -7900,17 +8409,18 @@ export default function (pi: ExtensionAPI) {
7900
8409
 
7901
8410
  if (subcommand === "enable") {
7902
8411
  setRuntimeOptimizerEnabled(true);
7903
- resetCurrentSessionStats();
8412
+ await resetCurrentSessionStats();
7904
8413
  await flushPersistCacheStats(cmdCtx);
7905
8414
  await publishStatus(cmdCtx, model);
7906
8415
  cmdCtx.ui.notify(`✅ Pi Cache Optimizer enabled for this Pi process. Local footer stats were reset for before/after comparison.\n${formatOptimizerRuntimeMode()}`, "info");
7907
8416
  } else if (subcommand === "disable") {
7908
8417
  setRuntimeOptimizerEnabled(false);
7909
- resetCurrentSessionStats();
8418
+ await resetCurrentSessionStats();
7910
8419
  await flushPersistCacheStats(cmdCtx);
7911
8420
  await publishStatus(cmdCtx, model);
7912
8421
  cmdCtx.ui.notify(`⏸️ Pi Cache Optimizer disabled for this Pi process. Local footer stats were reset and will keep collecting while disabled for comparison.\n${formatOptimizerRuntimeMode()}`, "warning");
7913
8422
  } else if (subcommand === "doctor") {
8423
+ await refreshShardAggregate();
7914
8424
  if (!model) {
7915
8425
  cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
7916
8426
  return;
@@ -7926,16 +8436,18 @@ export default function (pi: ExtensionAPI) {
7926
8436
  : diagnosis;
7927
8437
  cmdCtx.ui.notify(fullDiagnosis, "info");
7928
8438
  } else if (subcommand === "stats") {
7929
- if (!model) {
7930
- cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
7931
- return;
8439
+ const aggregate = await refreshShardAggregate();
8440
+ const statsMode = commandParts[1];
8441
+ if (statsMode === "all") {
8442
+ cmdCtx.ui.notify(buildAllStatsOutput(aggregate), "info");
8443
+ } else if (statsMode === "contributors") {
8444
+ cmdCtx.ui.notify(buildContributorsStatsOutput(aggregate, model, currentSessionHashSet ? currentSessionHash : undefined), "info");
8445
+ } else if (statsMode) {
8446
+ cmdCtx.ui.notify("Usage: /cache-optimizer stats [all|contributors]", "info");
8447
+ } else {
8448
+ const sessionModels = currentSessionHashSet ? aggregate.bySession[currentSessionHash] ?? {} : {};
8449
+ cmdCtx.ui.notify(buildSessionStatsOutput(sessionModels, model, aggregate.modelRefsByKey), "info");
7932
8450
  }
7933
- const adapter = selectAdapterForModel(model);
7934
- const sk = model ? sessionModelKey(model) : undefined;
7935
- const statsState = model ? cacheStatsTotalsByModel[modelKey(model)] : undefined;
7936
- const samples = sk ? getRecentSamples(sk) : [];
7937
- const output = buildStatsOutput(model, adapter, statsState, samples);
7938
- cmdCtx.ui.notify(output, "info");
7939
8451
  } else if (subcommand === "config") {
7940
8452
  const configKey = commandParts[1];
7941
8453
  const requestedMode = commandParts[2];
@@ -7998,7 +8510,7 @@ export default function (pi: ExtensionAPI) {
7998
8510
  // Reset local footer stats for the effective active model. If the
7999
8511
  // selected model is a virtual router and the protocol exposes a live
8000
8512
  // route, this clears the real upstream bucket, not the router shell.
8001
- resetStatsForModel(model);
8513
+ await resetStatsForModel(model);
8002
8514
 
8003
8515
  // Persist immediately.
8004
8516
  await flushPersistCacheStats(cmdCtx);
@@ -8348,7 +8860,7 @@ export default function (pi: ExtensionAPI) {
8348
8860
  "Enable — Turn on runtime optimizations",
8349
8861
  "Disable — Turn off runtime optimizations",
8350
8862
  "Doctor — Show cache configuration",
8351
- "Stats — Show cache stats and trend",
8863
+ "Stats — Show current-session model statistics",
8352
8864
  "Compat — Show compat suggestion",
8353
8865
  "Fix — Auto-fix compat issues (writes models.json)",
8354
8866
  "Footer mode — Choose total, session, or process stats",
@@ -8369,12 +8881,12 @@ export default function (pi: ExtensionAPI) {
8369
8881
  } else if (choice === menuOptions[5]) {
8370
8882
  await handleCacheOptimizerCommand("fix", cmdCtx);
8371
8883
  } else if (choice === menuOptions[6]) {
8372
- const modeOptions = ["total — Daily cumulative totals (default)", "session — Current Pi conversation session", "process — Current Pi process only", "Cancel"];
8884
+ const modeOptions = ["session — Current Pi conversation session (default)", "total — All local sessions today", "process — Current extension instance only", "Cancel"];
8373
8885
  const modeChoice = await cmdCtx.ui.select("Footer cache stats mode", modeOptions);
8374
8886
  const nextMode = modeChoice === modeOptions[0]
8375
- ? "total"
8887
+ ? "session"
8376
8888
  : modeChoice === modeOptions[1]
8377
- ? "session"
8889
+ ? "total"
8378
8890
  : modeChoice === modeOptions[2]
8379
8891
  ? "process"
8380
8892
  : undefined;
@@ -8392,7 +8904,9 @@ export default function (pi: ExtensionAPI) {
8392
8904
  diagnosis.push(" enable — Enable prompt/cache optimizations for this Pi process");
8393
8905
  diagnosis.push(" disable — Disable prompt/cache optimizations for this Pi process");
8394
8906
  diagnosis.push(" doctor — Show current model/provider/api/baseUrl/compat and low-hit diagnosis");
8395
- diagnosis.push(" stats — Show active model stats bucket and recent trend");
8907
+ diagnosis.push(" stats — Show detailed statistics for every model in the current session");
8908
+ diagnosis.push(" stats all — Show detailed totals for every model across all local sessions");
8909
+ diagnosis.push(" stats contributors — Show per-session contributors for the active model");
8396
8910
  diagnosis.push(" compat — Show compat suggestion with edit location");
8397
8911
  diagnosis.push(" config footer-mode total|session|process — Persist the footer stats mode");
8398
8912
  diagnosis.push(" fix — Auto-fix compat issues (writes models.json, requires UI)");