agent-file-stash 0.2.5 → 0.4.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 (3) hide show
  1. package/README.md +16 -0
  2. package/dist/cli.mjs +113 -11
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -112,6 +112,22 @@ filestash status:
112
112
  Tokens saved (total): ~53,851
113
113
  ```
114
114
 
115
+ When each project keeps its own stash (for example with `FILESTASH_DIR=.vscode/file-stash`), sum all of them with `--all`. It scans the given directory (default: home) for `.file-stash/` and `file-stash/` folders:
116
+
117
+ ```bash
118
+ npx agent-file-stash status --all ~/Projects
119
+ ```
120
+
121
+ ```
122
+ filestash status (3 databases under /Users/me/Projects):
123
+ ~ 252,340 tokens 112 files /Users/me/Projects/api
124
+ ~ 45,157 tokens 128 files /Users/me/Projects/web
125
+ ~ 7,043 tokens 18 files /Users/me/Projects/legacy
126
+ ~ 304,540 tokens 258 files TOTAL
127
+ ```
128
+
129
+ Savings come from re-reads within a session (unchanged files and diffs). A new session always receives full content, since the file is not in its context yet.
130
+
115
131
  #### `help`
116
132
 
117
133
  ```bash
package/dist/cli.mjs CHANGED
@@ -160,6 +160,11 @@ CREATE TABLE IF NOT EXISTS session_reads (
160
160
  PRIMARY KEY (session_id, path)
161
161
  );
162
162
 
163
+ CREATE TABLE IF NOT EXISTS sessions (
164
+ session_id TEXT PRIMARY KEY,
165
+ pid INTEGER NOT NULL
166
+ );
167
+
163
168
  CREATE TABLE IF NOT EXISTS stats (
164
169
  key TEXT PRIMARY KEY,
165
170
  value INTEGER NOT NULL DEFAULT 0
@@ -181,6 +186,15 @@ var HASH_LENGTH = 16;
181
186
  function contentHash(content) {
182
187
  return createHash("sha256").update(content).digest("hex").slice(0, HASH_LENGTH);
183
188
  }
189
+ function isProcessAlive(pid) {
190
+ if (!Number.isInteger(pid) || pid <= 0) return false;
191
+ try {
192
+ process.kill(pid, 0);
193
+ return true;
194
+ } catch (err) {
195
+ return err.code === "EPERM";
196
+ }
197
+ }
184
198
  function queryOne(db, sql, params = []) {
185
199
  const rows = db.prepare(sql).all(...params);
186
200
  return rows[0];
@@ -199,9 +213,35 @@ var StashStore = class {
199
213
  const { DatabaseSync } = await import("node:sqlite");
200
214
  this.db = new DatabaseSync(this.dbPath);
201
215
  this.db.exec("PRAGMA journal_mode=WAL");
216
+ this.db.exec("PRAGMA busy_timeout=5000");
202
217
  this.db.exec(SCHEMA);
218
+ this.registerSession(this.db);
219
+ this.pruneClosedSessions(this.db);
203
220
  this.initialized = true;
204
221
  }
222
+ registerSession(db) {
223
+ db.prepare("INSERT OR REPLACE INTO sessions (session_id, pid) VALUES (?, ?)").run(this.sessionId, process.pid);
224
+ }
225
+ pruneClosedSessions(db) {
226
+ db.exec("BEGIN IMMEDIATE");
227
+ try {
228
+ const sessions = db.prepare("SELECT session_id, pid FROM sessions").all();
229
+ for (const s of sessions) {
230
+ if (s.session_id !== this.sessionId && !isProcessAlive(s.pid)) {
231
+ db.prepare("DELETE FROM sessions WHERE session_id = ?").run(s.session_id);
232
+ }
233
+ }
234
+ db.exec("DELETE FROM session_reads WHERE session_id NOT IN (SELECT session_id FROM sessions)");
235
+ db.exec("DELETE FROM session_stats WHERE session_id NOT IN (SELECT session_id FROM sessions)");
236
+ db.exec(
237
+ "DELETE FROM file_versions WHERE NOT EXISTS (SELECT 1 FROM session_reads r WHERE r.path = file_versions.path AND r.hash = file_versions.hash)"
238
+ );
239
+ db.exec("COMMIT");
240
+ } catch (err) {
241
+ db.exec("ROLLBACK");
242
+ throw err;
243
+ }
244
+ }
205
245
  getDb() {
206
246
  if (!this.db) throw new Error("StashStore not initialized. Call init() first.");
207
247
  return this.db;
@@ -373,6 +413,7 @@ var StashStore = class {
373
413
  }
374
414
  async close() {
375
415
  if (this.db) {
416
+ this.db.prepare("DELETE FROM sessions WHERE session_id = ?").run(this.sessionId);
376
417
  this.db.close();
377
418
  this.db = null;
378
419
  this.initialized = false;
@@ -456,8 +497,8 @@ function createStash(config) {
456
497
  }
457
498
 
458
499
  // packages/cli/src/index.ts
459
- import { resolve as resolve4, join as join2 } from "node:path";
460
- import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync } from "node:fs";
500
+ import { resolve as resolve4, join as join3 } from "node:path";
501
+ import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync } from "node:fs";
461
502
  import { homedir } from "node:os";
462
503
 
463
504
  // packages/cli/src/mcp.ts
@@ -669,6 +710,33 @@ Use this to verify filestash is working and see how many tokens it has saved.`
669
710
  process.on("SIGTERM", shutdown);
670
711
  }
671
712
 
713
+ // packages/cli/src/scan.ts
714
+ import { existsSync as existsSync3, readdirSync } from "node:fs";
715
+ import { join as join2 } from "node:path";
716
+ var STASH_DIR_NAMES = /* @__PURE__ */ new Set([".file-stash", "file-stash"]);
717
+ var SKIPPED_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "build", "dist", "target", "bin", "obj"]);
718
+ var MAX_SCAN_DEPTH = 8;
719
+ function findStashDatabases(root, depth = 0) {
720
+ if (depth > MAX_SCAN_DEPTH) return [];
721
+ let entries;
722
+ try {
723
+ entries = readdirSync(root, { withFileTypes: true });
724
+ } catch {
725
+ return [];
726
+ }
727
+ const found = [];
728
+ for (const entry of entries) {
729
+ if (!entry.isDirectory() || SKIPPED_DIRS.has(entry.name)) continue;
730
+ const dir = join2(root, entry.name);
731
+ if (STASH_DIR_NAMES.has(entry.name) && existsSync3(join2(dir, "stash.db"))) {
732
+ found.push(join2(dir, "stash.db"));
733
+ continue;
734
+ }
735
+ found.push(...findStashDatabases(dir, depth + 1));
736
+ }
737
+ return found;
738
+ }
739
+
672
740
  // packages/cli/src/index.ts
673
741
  var _origEmitWarning = process.emitWarning;
674
742
  process.emitWarning = function(msg, ...args) {
@@ -678,8 +746,8 @@ process.emitWarning = function(msg, ...args) {
678
746
  var CLI_STATUS_SESSION = "cli-status";
679
747
  async function runStatus() {
680
748
  const stashDir = resolve4(process.env.FILESTASH_DIR ?? ".file-stash");
681
- const dbPath = join2(stashDir, "stash.db");
682
- if (!existsSync3(dbPath)) {
749
+ const dbPath = join3(stashDir, "stash.db");
750
+ if (!existsSync4(dbPath)) {
683
751
  console.log("No filestash database found. Run 'filestash serve' to start stashing.");
684
752
  process.exit(0);
685
753
  }
@@ -691,6 +759,36 @@ async function runStatus() {
691
759
  console.log(` Tokens saved (total): ~${stats.tokensSaved.toLocaleString()}`);
692
760
  await stash.close();
693
761
  }
762
+ async function runStatusAll(root) {
763
+ const databases = findStashDatabases(resolve4(root));
764
+ if (databases.length === 0) {
765
+ console.log(`No filestash databases found under ${resolve4(root)}.`);
766
+ return;
767
+ }
768
+ const rows = [];
769
+ for (const dbPath of databases) {
770
+ const { stash } = createStash({ dbPath, sessionId: CLI_STATUS_SESSION });
771
+ try {
772
+ await stash.init();
773
+ const stats = await stash.getStats();
774
+ rows.push({ project: resolve4(dbPath, "../..").replace(/\/\.vscode$/, ""), files: stats.filesTracked, tokens: stats.tokensSaved });
775
+ } catch (e) {
776
+ console.error(` skipped ${dbPath}: ${e instanceof Error ? e.message : String(e)}`);
777
+ } finally {
778
+ await stash.close();
779
+ }
780
+ }
781
+ rows.sort((a, b) => b.tokens - a.tokens);
782
+ const totalTokens = rows.reduce((sum, r) => sum + r.tokens, 0);
783
+ const totalFiles = rows.reduce((sum, r) => sum + r.files, 0);
784
+ console.log(`filestash status (${rows.length} databases under ${resolve4(root)}):`);
785
+ for (const r of rows) {
786
+ console.log(` ~${r.tokens.toLocaleString().padStart(10)} tokens ${String(r.files).padStart(5)} files ${r.project}`);
787
+ }
788
+ console.log(` ~${totalTokens.toLocaleString().padStart(10)} tokens ${String(totalFiles).padStart(5)} files TOTAL`);
789
+ console.log(`
790
+ Savings count re-reads within a session (unchanged files and diffs); a new session always receives full content.`);
791
+ }
694
792
  async function runInit() {
695
793
  const home = homedir();
696
794
  const mcpServersEntry = {
@@ -701,33 +799,33 @@ async function runInit() {
701
799
  type: "local",
702
800
  command: ["npx", "agent-file-stash", "serve"]
703
801
  };
704
- const xdgConfig = process.env.XDG_CONFIG_HOME || join2(home, ".config");
802
+ const xdgConfig = process.env.XDG_CONFIG_HOME || join3(home, ".config");
705
803
  const targets = [
706
804
  {
707
805
  name: "Claude Code",
708
- path: join2(home, ".claude.json"),
806
+ path: join3(home, ".claude.json"),
709
807
  key: "mcpServers",
710
808
  entry: mcpServersEntry
711
809
  },
712
810
  {
713
811
  name: "Cursor",
714
- path: join2(home, ".cursor", "mcp.json"),
812
+ path: join3(home, ".cursor", "mcp.json"),
715
813
  key: "mcpServers",
716
814
  entry: mcpServersEntry
717
815
  },
718
816
  {
719
817
  name: "OpenCode",
720
- path: join2(xdgConfig, "opencode", "opencode.json"),
818
+ path: join3(xdgConfig, "opencode", "opencode.json"),
721
819
  key: "mcp",
722
820
  entry: opencodeMcpEntry
723
821
  }
724
822
  ];
725
823
  let configured = 0;
726
824
  for (const target of targets) {
727
- const dir = join2(target.path, "..");
728
- if (!existsSync3(dir)) continue;
825
+ const dir = join3(target.path, "..");
826
+ if (!existsSync4(dir)) continue;
729
827
  let config = {};
730
- if (existsSync3(target.path)) {
828
+ if (existsSync4(target.path)) {
731
829
  try {
732
830
  config = JSON.parse(readFileSync3(target.path, "utf-8"));
733
831
  } catch {
@@ -768,6 +866,8 @@ Usage:
768
866
  agent-file-stash init Auto-configure for your editor
769
867
  agent-file-stash serve Start the MCP server (default)
770
868
  agent-file-stash status Show stash statistics
869
+ agent-file-stash status --all [dir]
870
+ Sum statistics of every stash under dir (default: home)
771
871
  agent-file-stash help Show this help message
772
872
 
773
873
  Environment:
@@ -776,6 +876,8 @@ Environment:
776
876
  var command = process.argv[2];
777
877
  if (!command || command === "serve") {
778
878
  await startMcpServer();
879
+ } else if (command === "status" && process.argv[3] === "--all") {
880
+ await runStatusAll(process.argv[4] ?? homedir());
779
881
  } else if (command === "status") {
780
882
  await runStatus();
781
883
  } else if (command === "init") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-file-stash",
3
- "version": "0.2.5",
3
+ "version": "0.4.0",
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",
@@ -55,7 +55,7 @@
55
55
  "license": "MIT",
56
56
  "repository": {
57
57
  "type": "git",
58
- "url": "https://github.com/atilio-ts/agent-file-stash"
58
+ "url": "git+https://github.com/atilio-ts/agent-file-stash.git"
59
59
  },
60
60
  "homepage": "https://github.com/atilio-ts/agent-file-stash",
61
61
  "packageManager": "pnpm@10.29.3"