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.
- package/README.md +14 -0
- package/dist/cli.mjs +84 -2
- 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.
|
|
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",
|