agent-file-stash 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.
Files changed (3) hide show
  1. package/README.md +14 -0
  2. package/dist/cli.mjs +84 -2
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -356,6 +356,20 @@ filestash status:
356
356
  Gross saved (all sessions): ~53,851 tokens
357
357
  ```
358
358
 
359
+ Session counters are discarded when a closed session is pruned, so the status also keeps lifetime counters in the database: sessions, reads, what plain reads would have returned, what the stash returned, and the estimated tool-definition overhead of every session. `agent-file-stash status`, `status --all` (summed over every database found) and `stash_status` print them as a block:
360
+
361
+ ```
362
+ Lifetime (since 2026-10-09):
363
+ Sessions: 14, reads: 312
364
+ Would have sent (plain reads): ~410,200 tokens
365
+ Actually sent: ~188,900 tokens
366
+ Gross saved: ~221,300 tokens
367
+ Tool definitions overhead: ~3,878 tokens (est., 14 sessions)
368
+ Net saved: ~217,422 tokens (est.), ~15,530 per session
369
+ ```
370
+
371
+ Net is gross saved minus the overhead of every counted session, so it shows whether the stash pays for its tool definitions on average; it can be negative. A session is counted once, when the MCP server starts; `status`, `reset` and `doctor` never count one. Counting starts when you upgrade to the release that introduced it, so on an older database `Gross saved (all sessions)` includes history the lifetime block does not, and the block is replaced by a note until the first session is counted. The overhead is an estimate (`ceil(characters / 4)` of the tool definitions), not a measurement. `stash_clear` resets the lifetime counters together with the other totals.
372
+
359
373
  ## Benchmark
360
374
 
361
375
  Tested on a real 268-file TypeScript codebase ([opencode](https://github.com/sst/opencode)) — same agent, same prompt, only the stash toggled:
package/dist/cli.mjs CHANGED
@@ -304,6 +304,14 @@ var BINARY_SNIFF_CHARS = 8192;
304
304
  var SCOPE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
305
305
  var SCOPE_DELIMITER = "::";
306
306
  var MAX_SCOPES_PER_SESSION = 32;
307
+ var LIFETIME_KEYS = [
308
+ "reads_total",
309
+ "baseline_tokens_total",
310
+ "sent_tokens_total",
311
+ "sessions_total",
312
+ "overhead_tokens_total",
313
+ "counters_since"
314
+ ];
307
315
  function positiveOr(value, fallback) {
308
316
  return Number.isInteger(value) && value > 0 ? value : fallback;
309
317
  }
@@ -427,6 +435,7 @@ var StashStore = class {
427
435
  recoveryAttempted = false;
428
436
  recoveredFrom;
429
437
  reason;
438
+ sessionCounted = false;
430
439
  constructor(config) {
431
440
  this.dbPath = config.dbPath;
432
441
  this.sessionId = config.sessionId.replace(/:{2,}/g, "_");
@@ -859,6 +868,8 @@ var StashStore = class {
859
868
  const versionRow = queryOne(db, "SELECT COUNT(DISTINCT path) as c FROM file_versions");
860
869
  const tokenRow = queryOne(db, "SELECT value FROM stats WHERE key = 'tokens_saved'");
861
870
  const sessionStat = (key) => queryOne(db, "SELECT value FROM session_stats WHERE session_id = ? AND key = ?", [this.sessionId, key])?.value ?? 0;
871
+ const lifetime = (key) => queryOne(db, "SELECT value FROM stats WHERE key = ?", [key])?.value ?? 0;
872
+ const since = queryOne(db, "SELECT value FROM stats WHERE key = 'counters_since'")?.value;
862
873
  return {
863
874
  filesTracked: versionRow?.c ?? 0,
864
875
  tokensSaved: tokenRow?.value ?? 0,
@@ -866,6 +877,12 @@ var StashStore = class {
866
877
  sessionReads: sessionStat("reads"),
867
878
  sessionBaselineTokens: sessionStat("baseline_tokens"),
868
879
  sessionSentTokens: sessionStat("sent_tokens"),
880
+ lifetimeSessions: lifetime("sessions_total"),
881
+ lifetimeReads: lifetime("reads_total"),
882
+ lifetimeBaselineTokens: lifetime("baseline_tokens_total"),
883
+ lifetimeSentTokens: lifetime("sent_tokens_total"),
884
+ lifetimeOverheadTokens: lifetime("overhead_tokens_total"),
885
+ ...since !== void 0 && since > 0 && { countersSince: since },
869
886
  degraded: false,
870
887
  ...this.recoveredFrom && { recoveredFrom: this.recoveredFrom }
871
888
  };
@@ -878,6 +895,11 @@ var StashStore = class {
878
895
  sessionReads: 0,
879
896
  sessionBaselineTokens: 0,
880
897
  sessionSentTokens: 0,
898
+ lifetimeSessions: 0,
899
+ lifetimeReads: 0,
900
+ lifetimeBaselineTokens: 0,
901
+ lifetimeSentTokens: 0,
902
+ lifetimeOverheadTokens: 0,
881
903
  degraded: true,
882
904
  ...this.reason && { degradedReason: this.reason }
883
905
  };
@@ -890,6 +912,7 @@ var StashStore = class {
890
912
  db.prepare("DELETE FROM session_ranges").run();
891
913
  db.prepare("DELETE FROM session_stats").run();
892
914
  db.prepare("UPDATE stats SET value = 0").run();
915
+ db.prepare(`DELETE FROM stats WHERE key IN (${LIFETIME_KEYS.map(() => "?").join(", ")})`).run(...LIFETIME_KEYS);
893
916
  });
894
917
  }
895
918
  async resetReads() {
@@ -917,6 +940,19 @@ var StashStore = class {
917
940
  this.bumpSessionStat(db, "baseline_tokens", baseline);
918
941
  this.bumpSessionStat(db, "sent_tokens", sent);
919
942
  if (baseline > sent) this.addTokensSaved(db, baseline - sent);
943
+ this.bumpLifetime(db, { reads_total: 1, baseline_tokens_total: baseline, sent_tokens_total: sent });
944
+ }
945
+ bumpLifetime(db, counters) {
946
+ db.prepare("INSERT OR IGNORE INTO stats (key, value) VALUES ('counters_since', ?)").run(Date.now());
947
+ const bump = db.prepare("INSERT INTO stats (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = value + ?");
948
+ for (const [key, n] of Object.entries(counters)) bump.run(key, n, n);
949
+ }
950
+ async recordSessionStart(overheadTokens) {
951
+ await this.init();
952
+ if (this.sessionCounted) return;
953
+ this.sessionCounted = true;
954
+ const overhead = Number.isFinite(overheadTokens) && overheadTokens > 0 ? Math.round(overheadTokens) : 0;
955
+ this.guarded((db) => this.bumpLifetime(db, { sessions_total: 1, overhead_tokens_total: overhead }));
920
956
  }
921
957
  bumpSessionStat(db, key, n) {
922
958
  db.prepare(
@@ -1001,6 +1037,23 @@ var FileWatcher = class {
1001
1037
  }
1002
1038
  };
1003
1039
 
1040
+ // packages/sdk/src/lifetime.ts
1041
+ function lifetimeView(c) {
1042
+ const grossSaved = c.lifetimeBaselineTokens - c.lifetimeSentTokens;
1043
+ const netSaved = grossSaved - c.lifetimeOverheadTokens;
1044
+ return {
1045
+ empty: c.lifetimeSessions === 0 && c.lifetimeReads === 0,
1046
+ sessions: c.lifetimeSessions,
1047
+ reads: c.lifetimeReads,
1048
+ baselineTokens: c.lifetimeBaselineTokens,
1049
+ sentTokens: c.lifetimeSentTokens,
1050
+ overheadTokens: c.lifetimeOverheadTokens,
1051
+ grossSaved,
1052
+ netSaved,
1053
+ netPerSession: c.lifetimeSessions > 0 ? Math.round(netSaved / c.lifetimeSessions) || 0 : null
1054
+ };
1055
+ }
1056
+
1004
1057
  // packages/sdk/src/index.ts
1005
1058
  function createStash(config) {
1006
1059
  const stash = new StashStore(config);
@@ -1132,6 +1185,22 @@ function toolDefinitionTokens() {
1132
1185
  function degradedLine(reason2 = "unknown") {
1133
1186
  return `Mode: DEGRADED (${reason2}) - files are read normally, nothing is stashed`;
1134
1187
  }
1188
+ function formatLifetime(c, since, saved) {
1189
+ const v = lifetimeView(c);
1190
+ if (v.empty) return [` Lifetime counters start with this version; ${saved} above includes older history.`];
1191
+ const fmt = (n) => `~${n.toLocaleString()}`;
1192
+ const date = since ? ` (since ${new Date(since).toISOString().slice(0, 10)})` : "";
1193
+ const perSession = v.netPerSession === null ? "" : `, ${fmt(v.netPerSession)} per session`;
1194
+ return [
1195
+ ` Lifetime${date}:`,
1196
+ ` Sessions: ${v.sessions}, reads: ${v.reads}`,
1197
+ ` Would have sent (plain reads): ${fmt(v.baselineTokens)} tokens`,
1198
+ ` Actually sent: ${fmt(v.sentTokens)} tokens`,
1199
+ ` Gross saved: ${fmt(v.grossSaved)} tokens`,
1200
+ ` Tool definitions overhead: ${fmt(v.overheadTokens)} tokens (est., ${v.sessions} sessions)`,
1201
+ ` Net saved: ${fmt(v.netSaved)} tokens (est.)${perSession}`
1202
+ ];
1203
+ }
1135
1204
  function formatStatus(stats, overheadTokens) {
1136
1205
  if (stats.degraded) return degradedLine(stats.degradedReason);
1137
1206
  const fmt = (n) => `~${n.toLocaleString()}`;
@@ -1146,7 +1215,8 @@ function formatStatus(stats, overheadTokens) {
1146
1215
  ` Gross saved: ${fmt(stats.sessionTokensSaved)} tokens`,
1147
1216
  ` Tool definitions overhead: ${fmt(overheadTokens)} tokens (est.)`,
1148
1217
  ` Net saved: ${fmt(net)} tokens (est.)`,
1149
- ` Gross saved (all sessions): ${fmt(stats.tokensSaved)} tokens`
1218
+ ` Gross saved (all sessions): ${fmt(stats.tokensSaved)} tokens`,
1219
+ ...formatLifetime(stats, stats.countersSince, `"Gross saved (all sessions)"`)
1150
1220
  ].join("\n");
1151
1221
  }
1152
1222
  function statsSuffix(grossSaved) {
@@ -1193,6 +1263,7 @@ async function startMcpServer() {
1193
1263
  ...limitOptions()
1194
1264
  });
1195
1265
  await stash.init();
1266
+ await stash.recordSessionStart(toolDefinitionTokens());
1196
1267
  const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
1197
1268
  const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
1198
1269
  const server = new McpServer({
@@ -1268,7 +1339,8 @@ async function startMcpServer() {
1268
1339
  [`${META_NAMESPACE}/stats`]: {
1269
1340
  ...stats,
1270
1341
  toolDefinitionTokens: overhead,
1271
- netTokensSaved: stats.sessionTokensSaved - overhead
1342
+ netTokensSaved: stats.sessionTokensSaved - overhead,
1343
+ lifetime: lifetimeView(stats)
1272
1344
  }
1273
1345
  }
1274
1346
  };
@@ -1876,6 +1948,7 @@ async function runStatus() {
1876
1948
  console.log(`filestash status:`);
1877
1949
  console.log(` Files tracked: ${stats.filesTracked}`);
1878
1950
  console.log(` Tokens saved (total): ~${stats.tokensSaved.toLocaleString()}`);
1951
+ console.log(formatLifetime(stats, stats.countersSince, `"Tokens saved (total)"`).join("\n"));
1879
1952
  } catch (e) {
1880
1953
  console.error(`filestash status failed for ${dbPath}: ${e instanceof Error ? e.message : String(e)}`);
1881
1954
  process.exitCode = 1;
@@ -1890,12 +1963,20 @@ async function runStatusAll(root) {
1890
1963
  return;
1891
1964
  }
1892
1965
  const rows = [];
1966
+ const lifetime = { lifetimeSessions: 0, lifetimeReads: 0, lifetimeBaselineTokens: 0, lifetimeSentTokens: 0, lifetimeOverheadTokens: 0 };
1967
+ let since;
1893
1968
  for (const dbPath of databases) {
1894
1969
  const { stash } = createStash({ dbPath, sessionId: CLI_STATUS_SESSION, recoverCorrupt: false, quiet: true });
1895
1970
  try {
1896
1971
  await stash.init();
1897
1972
  const stats = await stash.getStats();
1898
1973
  assertHealthy(stash);
1974
+ lifetime.lifetimeSessions += stats.lifetimeSessions;
1975
+ lifetime.lifetimeReads += stats.lifetimeReads;
1976
+ lifetime.lifetimeBaselineTokens += stats.lifetimeBaselineTokens;
1977
+ lifetime.lifetimeSentTokens += stats.lifetimeSentTokens;
1978
+ lifetime.lifetimeOverheadTokens += stats.lifetimeOverheadTokens;
1979
+ if (stats.countersSince !== void 0) since = Math.min(since ?? Infinity, stats.countersSince);
1899
1980
  rows.push({ project: resolve5(dbPath, "../..").replace(/\/\.vscode$/, ""), files: stats.filesTracked, tokens: stats.tokensSaved });
1900
1981
  } catch (e) {
1901
1982
  console.error(` skipped ${dbPath}: ${e instanceof Error ? e.message : String(e)}`);
@@ -1911,6 +1992,7 @@ async function runStatusAll(root) {
1911
1992
  console.log(` ~${r.tokens.toLocaleString().padStart(10)} tokens ${String(r.files).padStart(5)} files ${r.project}`);
1912
1993
  }
1913
1994
  console.log(` ~${totalTokens.toLocaleString().padStart(10)} tokens ${String(totalFiles).padStart(5)} files TOTAL`);
1995
+ console.log(formatLifetime(lifetime, since, "the per-project totals").join("\n"));
1914
1996
  console.log(`
1915
1997
  Savings count re-reads within a session (unchanged files and diffs); a new session always receives full content.`);
1916
1998
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-file-stash",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "mcpName": "io.github.atilio-ts/agent-file-stash",
5
5
  "description": "File stash with diff tracking for AI coding agents. Drop-in replacement for file reads that saves tokens.",
6
6
  "type": "module",