agent-file-stash 0.4.0 → 0.5.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +165 -39
  3. package/dist/cli.mjs +769 -180
  4. package/package.json +4 -1
package/dist/cli.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // packages/sdk/src/stash.ts
4
- import { readFileSync, statSync } from "node:fs";
5
- import { resolve } from "node:path";
4
+ import { readFileSync, writeFileSync, statSync, chmodSync, mkdirSync, existsSync, renameSync, rmSync } from "node:fs";
5
+ import { resolve, dirname as dirname2 } from "node:path";
6
6
  import { createHash } from "node:crypto";
7
7
 
8
8
  // packages/sdk/src/differ.ts
@@ -141,6 +141,38 @@ function longestCommonSubsequence(a, b) {
141
141
  return result;
142
142
  }
143
143
 
144
+ // packages/sdk/src/exclude.ts
145
+ import { basename, dirname } from "node:path";
146
+ var DEFAULT_PATTERNS = [
147
+ ".env",
148
+ ".env.*",
149
+ "*.pem",
150
+ "*.key",
151
+ "*.p12",
152
+ "*.pfx",
153
+ "*.keystore",
154
+ "id_rsa*",
155
+ "id_ed25519*",
156
+ "id_ecdsa*",
157
+ ".npmrc",
158
+ ".netrc",
159
+ "credentials*",
160
+ "secrets.*"
161
+ ];
162
+ var ALLOWED_NAMES = /* @__PURE__ */ new Set([".env.example", ".env.sample", ".env.template", ".env.dist"]);
163
+ var SECRET_DIRS = /* @__PURE__ */ new Set([".ssh", ".aws", ".gnupg"]);
164
+ function globToRegExp(glob) {
165
+ const body = glob.toLowerCase().replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
166
+ return new RegExp(`^${body}$`);
167
+ }
168
+ var DEFAULT_MATCHERS = DEFAULT_PATTERNS.map(globToRegExp);
169
+ function isExcludedPath(absPath, extraPatterns = []) {
170
+ const name = basename(absPath).toLowerCase();
171
+ if (extraPatterns.some((p) => globToRegExp(p.trim()).test(name))) return true;
172
+ if (!ALLOWED_NAMES.has(name) && !name.endsWith(".pub") && DEFAULT_MATCHERS.some((re) => re.test(name))) return true;
173
+ return dirname(absPath).split(/[\\/]/).some((seg) => SECRET_DIRS.has(seg.toLowerCase()));
174
+ }
175
+
144
176
  // packages/sdk/src/stash.ts
145
177
  var SCHEMA = `
146
178
  CREATE TABLE IF NOT EXISTS file_versions (
@@ -160,6 +192,16 @@ CREATE TABLE IF NOT EXISTS session_reads (
160
192
  PRIMARY KEY (session_id, path)
161
193
  );
162
194
 
195
+ CREATE TABLE IF NOT EXISTS session_ranges (
196
+ session_id TEXT NOT NULL,
197
+ path TEXT NOT NULL,
198
+ hash TEXT NOT NULL,
199
+ start_line INTEGER NOT NULL,
200
+ end_line INTEGER NOT NULL
201
+ );
202
+
203
+ CREATE INDEX IF NOT EXISTS idx_session_ranges ON session_ranges (session_id, path);
204
+
163
205
  CREATE TABLE IF NOT EXISTS sessions (
164
206
  session_id TEXT PRIMARY KEY,
165
207
  pid INTEGER NOT NULL
@@ -179,6 +221,40 @@ CREATE TABLE IF NOT EXISTS session_stats (
179
221
 
180
222
  INSERT OR IGNORE INTO stats (key, value) VALUES ('tokens_saved', 0);
181
223
  `;
224
+ var DEFAULT_MAX_LINES = 2e3;
225
+ var DEFAULT_MAX_CHARS = 1e5;
226
+ var DEFAULT_MAX_STORE_BYTES = 1e6;
227
+ var HARD_MAX_READ_BYTES = 64 * 1024 * 1024;
228
+ var BINARY_SNIFF_CHARS = 8192;
229
+ function positiveOr(value, fallback) {
230
+ return Number.isInteger(value) && value > 0 ? value : fallback;
231
+ }
232
+ function sliceLines(content, offset, limit, maxLines, maxChars) {
233
+ const lines = content.split("\n");
234
+ const total = lines.length;
235
+ const startIdx = offset > 0 ? offset - 1 : 0;
236
+ const start = startIdx + 1;
237
+ const requestedEnd = limit > 0 ? Math.min(startIdx + limit, total) : total;
238
+ const maxEnd = Math.min(requestedEnd, startIdx + maxLines);
239
+ let chars = 0;
240
+ let i = startIdx;
241
+ for (; i < maxEnd; i++) {
242
+ const next = chars + lines[i].length + (i > startIdx ? 1 : 0);
243
+ if (next > maxChars) break;
244
+ chars = next;
245
+ }
246
+ if (i >= requestedEnd) {
247
+ return { text: lines.slice(startIdx, i).join("\n"), start, end: limit > 0 ? start + limit - 1 : total, midLine: false, truncated: false };
248
+ }
249
+ if (i > startIdx) {
250
+ const notice2 = `[filestash: truncated, showing lines ${start}-${i} of ${total}; continue with offset=${i + 1}]`;
251
+ return { text: `${lines.slice(startIdx, i).join("\n")}
252
+ ${notice2}`, start, end: i, midLine: false, truncated: true };
253
+ }
254
+ const notice = `[filestash: truncated, showing the first ${maxChars} chars of line ${start} of ${total}; continue with offset=${start + 1}]`;
255
+ return { text: `${lines[startIdx].slice(0, maxChars)}
256
+ ${notice}`, start, end: start - 1, midLine: true, truncated: true };
257
+ }
182
258
  function estimateTokens(text) {
183
259
  return Math.ceil(text.length / 4);
184
260
  }
@@ -186,6 +262,19 @@ var HASH_LENGTH = 16;
186
262
  function contentHash(content) {
187
263
  return createHash("sha256").update(content).digest("hex").slice(0, HASH_LENGTH);
188
264
  }
265
+ function mergeIntervals(intervals) {
266
+ const sorted = intervals.filter(([a, b]) => b >= a).sort((x, y) => x[0] - y[0]);
267
+ const merged = [];
268
+ for (const [a, b] of sorted) {
269
+ const last = merged[merged.length - 1];
270
+ if (last && a <= last[1] + 1) last[1] = Math.max(last[1], b);
271
+ else merged.push([a, b]);
272
+ }
273
+ return merged;
274
+ }
275
+ function coversRange(intervals, start, end) {
276
+ return end >= start && intervals.some(([a, b]) => a <= start && b >= end);
277
+ }
189
278
  function isProcessAlive(pid) {
190
279
  if (!Number.isInteger(pid) || pid <= 0) return false;
191
280
  try {
@@ -195,6 +284,52 @@ function isProcessAlive(pid) {
195
284
  return err.code === "EPERM";
196
285
  }
197
286
  }
287
+ var SQLITE_BUSY = 5;
288
+ var SQLITE_LOCKED = 6;
289
+ var SQLITE_IOERR = 10;
290
+ var SQLITE_CORRUPT = 11;
291
+ var SQLITE_NOTADB = 26;
292
+ var OPEN_ATTEMPTS = 6;
293
+ var OPEN_BACKOFF_MS = 150;
294
+ var RECOVERY_LOCK_STALE_MS = 15e3;
295
+ var RECOVERY_LOCK_WAIT_MS = 8e3;
296
+ var RECOVERY_LOCK_POLL_MS = 50;
297
+ function errorMessage(err) {
298
+ return err instanceof Error ? err.message : String(err);
299
+ }
300
+ function sleep(ms) {
301
+ return new Promise((r) => setTimeout(r, ms));
302
+ }
303
+ function isCorruptError(err) {
304
+ const code = err.errcode;
305
+ if (typeof code === "number" && [SQLITE_CORRUPT, SQLITE_NOTADB].includes(code & 255)) return true;
306
+ return /not a database|malformed|corrupt/i.test(errorMessage(err));
307
+ }
308
+ function isTransientError(err) {
309
+ const code = err.errcode;
310
+ if (typeof code === "number" && [SQLITE_BUSY, SQLITE_LOCKED, SQLITE_IOERR].includes(code & 255)) return true;
311
+ return /database is locked|database is busy|disk i\/o error/i.test(errorMessage(err));
312
+ }
313
+ function tryAcquireLock(lockPath) {
314
+ try {
315
+ writeFileSync(lockPath, String(process.pid), { flag: "wx" });
316
+ return true;
317
+ } catch (err) {
318
+ if (err.code !== "EEXIST") throw err;
319
+ }
320
+ try {
321
+ if (Date.now() - statSync(lockPath).mtimeMs < RECOVERY_LOCK_STALE_MS) return false;
322
+ rmSync(lockPath, { force: true });
323
+ writeFileSync(lockPath, String(process.pid), { flag: "wx" });
324
+ return true;
325
+ } catch {
326
+ return false;
327
+ }
328
+ }
329
+ async function waitForLockRelease(lockPath) {
330
+ const deadline = Date.now() + RECOVERY_LOCK_WAIT_MS;
331
+ while (existsSync(lockPath) && Date.now() < deadline) await sleep(RECOVERY_LOCK_POLL_MS);
332
+ }
198
333
  function queryOne(db, sql, params = []) {
199
334
  const rows = db.prepare(sql).all(...params);
200
335
  return rows[0];
@@ -203,28 +338,140 @@ var StashStore = class {
203
338
  db = null;
204
339
  dbPath;
205
340
  sessionId;
341
+ exclude;
206
342
  initialized = false;
343
+ recoverCorrupt;
344
+ quiet;
345
+ maxLines;
346
+ maxChars;
347
+ maxStoreBytes;
348
+ recoveryAttempted = false;
349
+ recoveredFrom;
350
+ reason;
207
351
  constructor(config) {
208
352
  this.dbPath = config.dbPath;
209
353
  this.sessionId = config.sessionId;
354
+ this.exclude = config.exclude ?? [];
355
+ this.recoverCorrupt = config.recoverCorrupt ?? true;
356
+ this.quiet = config.quiet ?? false;
357
+ this.maxLines = positiveOr(config.maxLines, DEFAULT_MAX_LINES);
358
+ this.maxChars = positiveOr(config.maxChars, DEFAULT_MAX_CHARS);
359
+ this.maxStoreBytes = positiveOr(config.maxStoreBytes, DEFAULT_MAX_STORE_BYTES);
360
+ }
361
+ get isDegraded() {
362
+ return this.reason !== void 0;
363
+ }
364
+ get degradedReason() {
365
+ return this.reason;
210
366
  }
211
367
  async init() {
212
368
  if (this.initialized) return;
213
- const { DatabaseSync } = await import("node:sqlite");
214
- this.db = new DatabaseSync(this.dbPath);
215
- this.db.exec("PRAGMA journal_mode=WAL");
216
- this.db.exec("PRAGMA busy_timeout=5000");
217
- this.db.exec(SCHEMA);
218
- this.registerSession(this.db);
219
- this.pruneClosedSessions(this.db);
369
+ try {
370
+ this.db = await this.openWithRetry();
371
+ } catch (err) {
372
+ await this.recoverOrDegrade(err);
373
+ }
220
374
  this.initialized = true;
221
375
  }
376
+ async openWithRetry() {
377
+ for (let attempt = 1; ; attempt++) {
378
+ try {
379
+ return await this.openDb();
380
+ } catch (err) {
381
+ if (attempt >= OPEN_ATTEMPTS || !isTransientError(err)) throw err;
382
+ await sleep(OPEN_BACKOFF_MS * attempt);
383
+ }
384
+ }
385
+ }
386
+ async openDb() {
387
+ const { DatabaseSync } = await import("node:sqlite");
388
+ mkdirSync(dirname2(this.dbPath), { recursive: true, mode: 448 });
389
+ const db = new DatabaseSync(this.dbPath);
390
+ try {
391
+ db.exec("PRAGMA busy_timeout=5000");
392
+ db.exec("PRAGMA journal_mode=WAL");
393
+ db.exec(SCHEMA);
394
+ this.registerSession(db);
395
+ this.pruneClosedSessions(db);
396
+ } catch (err) {
397
+ try {
398
+ db.close();
399
+ } catch {
400
+ }
401
+ throw err;
402
+ }
403
+ this.restrictPermissions();
404
+ return db;
405
+ }
406
+ async recoverOrDegrade(err) {
407
+ if (!this.recoverCorrupt || this.recoveryAttempted || !isCorruptError(err)) return this.degrade(err);
408
+ this.recoveryAttempted = true;
409
+ const lockPath = `${this.dbPath}.recover.lock`;
410
+ try {
411
+ if (!tryAcquireLock(lockPath)) {
412
+ await waitForLockRelease(lockPath);
413
+ this.db = await this.openWithRetry();
414
+ return;
415
+ }
416
+ try {
417
+ try {
418
+ this.db = await this.openWithRetry();
419
+ return;
420
+ } catch (again) {
421
+ if (!isCorruptError(again)) throw again;
422
+ }
423
+ const moved = this.moveAside();
424
+ this.db = await this.openWithRetry();
425
+ this.recoveredFrom = moved;
426
+ this.notice(`${this.dbPath} is corrupt (${errorMessage(err)}); moved to ${moved}, starting a new stash`);
427
+ } finally {
428
+ rmSync(lockPath, { force: true });
429
+ }
430
+ } catch (retryErr) {
431
+ this.degrade(retryErr);
432
+ }
433
+ }
434
+ moveAside() {
435
+ const target = `${this.dbPath}.corrupt-${Date.now()}`;
436
+ for (const suffix of ["-wal", "-shm", ""]) {
437
+ if (!existsSync(this.dbPath + suffix)) continue;
438
+ try {
439
+ renameSync(this.dbPath + suffix, target + suffix);
440
+ } catch (e) {
441
+ if (e.code !== "ENOENT") throw e;
442
+ }
443
+ }
444
+ return target;
445
+ }
446
+ notice(message) {
447
+ if (!this.quiet) process.stderr.write(`[filestash] ${message}
448
+ `);
449
+ }
450
+ degrade(err) {
451
+ if (this.reason !== void 0) return;
452
+ this.reason = errorMessage(err);
453
+ this.notice(`stash unavailable (${this.reason}); files are read normally, nothing is stashed`);
454
+ try {
455
+ this.db?.close();
456
+ } catch {
457
+ }
458
+ this.db = null;
459
+ }
460
+ restrictPermissions() {
461
+ for (const suffix of ["", "-wal", "-shm"]) {
462
+ try {
463
+ chmodSync(this.dbPath + suffix, 384);
464
+ } catch {
465
+ }
466
+ }
467
+ }
222
468
  registerSession(db) {
223
469
  db.prepare("INSERT OR REPLACE INTO sessions (session_id, pid) VALUES (?, ?)").run(this.sessionId, process.pid);
224
470
  }
225
471
  pruneClosedSessions(db) {
226
472
  db.exec("BEGIN IMMEDIATE");
227
473
  try {
474
+ this.purgeExcluded(db);
228
475
  const sessions = db.prepare("SELECT session_id, pid FROM sessions").all();
229
476
  for (const s of sessions) {
230
477
  if (s.session_id !== this.sessionId && !isProcessAlive(s.pid)) {
@@ -232,62 +479,109 @@ var StashStore = class {
232
479
  }
233
480
  }
234
481
  db.exec("DELETE FROM session_reads WHERE session_id NOT IN (SELECT session_id FROM sessions)");
482
+ db.exec("DELETE FROM session_ranges WHERE session_id NOT IN (SELECT session_id FROM sessions)");
235
483
  db.exec("DELETE FROM session_stats WHERE session_id NOT IN (SELECT session_id FROM sessions)");
236
484
  db.exec(
237
485
  "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
486
  );
239
487
  db.exec("COMMIT");
240
488
  } catch (err) {
241
- db.exec("ROLLBACK");
489
+ try {
490
+ db.exec("ROLLBACK");
491
+ } catch {
492
+ }
242
493
  throw err;
243
494
  }
244
495
  }
245
- getDb() {
246
- if (!this.db) throw new Error("StashStore not initialized. Call init() first.");
247
- return this.db;
496
+ purgeExcluded(db) {
497
+ for (const table of ["file_versions", "session_reads", "session_ranges"]) {
498
+ const rows = db.prepare(`SELECT DISTINCT path FROM ${table}`).all();
499
+ for (const { path } of rows) {
500
+ if (isExcludedPath(path, this.exclude)) db.prepare(`DELETE FROM ${table} WHERE path = ?`).run(path);
501
+ }
502
+ }
248
503
  }
249
504
  readFileSnapshot(filePath) {
250
505
  const absPath = resolve(filePath);
251
- statSync(absPath);
506
+ const size = statSync(absPath).size;
507
+ const now = Date.now();
508
+ if (size > HARD_MAX_READ_BYTES) {
509
+ const result = { stashed: false, content: `[filestash: file too large (${size} bytes); not read]`, hash: "" };
510
+ return { absPath, size, content: "", hash: "", lines: 0, now, early: { result, countRead: false } };
511
+ }
252
512
  const content = readFileSync(absPath, "utf-8");
253
- return {
254
- absPath,
255
- content,
256
- hash: contentHash(content),
257
- lines: content.split("\n").length,
258
- now: Date.now()
259
- };
513
+ const hash = contentHash(content);
514
+ const lines = content.split("\n").length;
515
+ const snap = { absPath, size, content, hash, lines, now };
516
+ if (content.slice(0, BINARY_SNIFF_CHARS).includes("\0")) {
517
+ const result = { stashed: false, content: `[filestash: binary file (${size} bytes), not shown]`, hash, totalLines: lines };
518
+ snap.early = { result, countRead: true };
519
+ }
520
+ return snap;
521
+ }
522
+ earlyResult(snap) {
523
+ const { result, countRead } = snap.early;
524
+ if (countRead) {
525
+ const tokens = estimateTokens(result.content);
526
+ this.guarded((db) => this.recordRead(db, tokens, tokens));
527
+ }
528
+ return result;
529
+ }
530
+ isStoreable(snap) {
531
+ return snap.size <= this.maxStoreBytes && !isExcludedPath(snap.absPath, this.exclude);
260
532
  }
261
533
  async readFile(filePath, options) {
262
534
  await this.init();
263
- const db = this.getDb();
264
- const { absPath, content: currentContent, hash: currentHash, lines: currentLines, now } = this.readFileSnapshot(filePath);
535
+ const snap = this.readFileSnapshot(filePath);
536
+ if (snap.early) return this.earlyResult(snap);
537
+ const { absPath, content: currentContent, hash: currentHash, lines: currentLines, now } = snap;
265
538
  const offset = options?.offset ?? 0;
266
539
  const limit = options?.limit ?? 0;
267
- const rangeStart = offset > 0 ? offset : 1;
540
+ const slice = sliceLines(currentContent, offset, limit, this.maxLines, this.maxChars);
268
541
  const state = {
269
542
  absPath,
270
543
  currentContent,
271
544
  currentHash,
272
545
  currentLines,
273
- isPartial: offset > 0 || limit > 0,
274
- rangeStart,
275
- rangeEnd: limit > 0 ? rangeStart + limit - 1 : currentLines,
546
+ isPartial: offset > 0 || limit > 0 || slice.truncated,
547
+ rangeStart: slice.start,
548
+ rangeEnd: slice.end,
276
549
  offset,
277
550
  limit,
278
- now
551
+ now,
552
+ size: snap.size,
553
+ slice: slice.text,
554
+ midLine: slice.midLine
279
555
  };
556
+ if (this.db) {
557
+ try {
558
+ return this.readWithDb(this.db, state);
559
+ } catch (err) {
560
+ this.degrade(err);
561
+ }
562
+ }
563
+ return this.plainResult(state);
564
+ }
565
+ plainResult(s) {
566
+ return { stashed: false, content: s.slice, hash: s.currentHash, totalLines: s.currentLines };
567
+ }
568
+ readWithDb(db, state) {
569
+ if (!this.isStoreable(state)) {
570
+ const content = state.slice;
571
+ this.recordRead(db, estimateTokens(content), estimateTokens(content));
572
+ return { stashed: false, content, hash: state.currentHash, totalLines: state.currentLines };
573
+ }
280
574
  const lastRead = queryOne(
281
575
  db,
282
576
  "SELECT hash FROM session_reads WHERE session_id = ? AND path = ?",
283
- [this.sessionId, absPath]
577
+ [this.sessionId, state.absPath]
284
578
  );
285
579
  if (!lastRead) {
286
580
  return this.handleFirstRead(db, state);
287
581
  }
288
582
  const lastHash = lastRead.hash;
289
- if (lastHash === currentHash) {
290
- return this.handleUnchanged(db, state);
583
+ if (lastHash === state.currentHash) {
584
+ return this.coversRequest(db, state, lastHash) ? this.handleUnchanged(db, state) : this.handleUncovered(db, state);
291
585
  }
292
586
  return this.handleChanged(db, state, lastHash);
293
587
  }
@@ -296,52 +590,106 @@ var StashStore = class {
296
590
  db.prepare(
297
591
  "INSERT OR REPLACE INTO session_reads (session_id, path, hash, read_at) VALUES (?, ?, ?, ?)"
298
592
  ).run(this.sessionId, s.absPath, s.currentHash, s.now);
299
- return { stashed: false, content: this.sliceContent(s), hash: s.currentHash, totalLines: s.currentLines };
593
+ this.clearRanges(db, s.absPath);
594
+ return this.fullSlice(db, s);
595
+ }
596
+ requestedEnd(s) {
597
+ return Math.min(s.rangeEnd, s.currentLines);
598
+ }
599
+ getRanges(db, absPath, hash) {
600
+ const rows = db.prepare(
601
+ "SELECT start_line, end_line FROM session_ranges WHERE session_id = ? AND path = ? AND hash = ?"
602
+ ).all(this.sessionId, absPath, hash);
603
+ return rows.map((r) => [r.start_line, r.end_line]);
604
+ }
605
+ coversRequest(db, s, hash) {
606
+ return coversRange(this.getRanges(db, s.absPath, hash), s.rangeStart, this.requestedEnd(s));
607
+ }
608
+ clearRanges(db, absPath) {
609
+ db.prepare("DELETE FROM session_ranges WHERE session_id = ? AND path = ?").run(this.sessionId, absPath);
610
+ }
611
+ addRange(db, absPath, hash, start, end) {
612
+ if (end < start) return;
613
+ const merged = mergeIntervals([...this.getRanges(db, absPath, hash), [start, end]]);
614
+ db.prepare("DELETE FROM session_ranges WHERE session_id = ? AND path = ? AND hash = ?").run(this.sessionId, absPath, hash);
615
+ const insert = db.prepare(
616
+ "INSERT INTO session_ranges (session_id, path, hash, start_line, end_line) VALUES (?, ?, ?, ?, ?)"
617
+ );
618
+ for (const [a, b] of merged) insert.run(this.sessionId, absPath, hash, a, b);
619
+ }
620
+ handleUncovered(db, s) {
621
+ db.prepare(
622
+ "UPDATE session_reads SET read_at = ? WHERE session_id = ? AND path = ?"
623
+ ).run(s.now, this.sessionId, s.absPath);
624
+ return this.fullSlice(db, s);
625
+ }
626
+ fullSlice(db, s) {
627
+ const content = s.slice;
628
+ this.addRange(db, s.absPath, s.currentHash, s.rangeStart, this.requestedEnd(s));
629
+ this.recordRead(db, estimateTokens(content), estimateTokens(content));
630
+ return { stashed: false, content, hash: s.currentHash, totalLines: s.currentLines };
631
+ }
632
+ unchangedLabel(db, s, label) {
633
+ const content = s.slice;
634
+ const baseline = estimateTokens(content);
635
+ const saved = Math.max(0, baseline - estimateTokens(label(baseline)));
636
+ const text = label(saved);
637
+ const sent = estimateTokens(text);
638
+ const base = { stashed: true, hash: s.currentHash, totalLines: s.currentLines, linesChanged: 0 };
639
+ this.addRange(db, s.absPath, s.currentHash, s.rangeStart, this.requestedEnd(s));
640
+ if (sent >= baseline) {
641
+ this.recordRead(db, baseline, baseline);
642
+ return { ...base, content };
643
+ }
644
+ this.recordRead(db, baseline, sent);
645
+ return { ...base, content: text };
300
646
  }
301
647
  handleUnchanged(db, s) {
302
- const slicedTokens = estimateTokens(this.sliceContent(s));
303
- this.addTokensSaved(db, slicedTokens);
304
648
  db.prepare(
305
649
  "UPDATE session_reads SET read_at = ? WHERE session_id = ? AND path = ?"
306
650
  ).run(s.now, this.sessionId, s.absPath);
307
- const label = s.isPartial ? `[filestash: unchanged, lines ${s.rangeStart}-${s.rangeEnd} of ${s.currentLines}, ${slicedTokens} tokens saved]` : `[filestash: unchanged, ${s.currentLines} lines, ${slicedTokens} tokens saved]`;
308
- return { stashed: true, content: label, hash: s.currentHash, totalLines: s.currentLines, linesChanged: 0 };
651
+ return this.unchangedLabel(
652
+ db,
653
+ s,
654
+ (tokens) => s.isPartial ? `[filestash: unchanged, lines ${s.rangeStart}-${s.rangeEnd} of ${s.currentLines}, ${tokens} tokens saved]` : `[filestash: unchanged, ${s.currentLines} lines, ${tokens} tokens saved]`
655
+ );
309
656
  }
310
657
  handleChanged(db, s, lastHash) {
311
658
  const oldVersion = queryOne(
312
659
  db,
313
- "SELECT content FROM file_versions WHERE path = ? AND hash = ?",
660
+ "SELECT content, lines FROM file_versions WHERE path = ? AND hash = ?",
314
661
  [s.absPath, lastHash]
315
662
  );
663
+ const oldFullyDelivered = !!oldVersion && coversRange(this.getRanges(db, s.absPath, lastHash), 1, oldVersion.lines);
316
664
  this.storeVersion(db, s.absPath, s.currentHash, s.currentContent, s.currentLines, s.now);
317
665
  db.prepare(
318
666
  "UPDATE session_reads SET hash = ?, read_at = ? WHERE session_id = ? AND path = ?"
319
667
  ).run(s.currentHash, s.now, this.sessionId, s.absPath);
320
- if (oldVersion) {
668
+ this.clearRanges(db, s.absPath);
669
+ if (oldVersion && oldFullyDelivered) {
321
670
  const diffResult = computeDiff(oldVersion.content, s.currentContent, s.absPath);
322
671
  if (diffResult.hasChanges) {
323
672
  return s.isPartial ? this.handlePartialDiff(db, s, diffResult) : this.handleFullDiff(db, s, diffResult);
324
673
  }
325
674
  }
326
- return { stashed: false, content: this.sliceContent(s), hash: s.currentHash, totalLines: s.currentLines };
675
+ return this.fullSlice(db, s);
327
676
  }
328
677
  handlePartialDiff(db, s, diffResult) {
329
- if (!this.rangeHasChanges(diffResult.changedNewLines, s.rangeStart, s.rangeEnd)) {
330
- const slicedTokens = estimateTokens(this.sliceContent(s));
331
- this.addTokensSaved(db, slicedTokens);
332
- return {
333
- stashed: true,
334
- content: `[filestash: unchanged in lines ${s.rangeStart}-${s.rangeEnd}, changes elsewhere in file, ${slicedTokens} tokens saved]`,
335
- hash: s.currentHash,
336
- totalLines: s.currentLines,
337
- linesChanged: 0
338
- };
678
+ if (!s.midLine && !this.rangeHasChanges(diffResult.changedNewLines, s.rangeStart, s.rangeEnd)) {
679
+ return this.unchangedLabel(
680
+ db,
681
+ s,
682
+ (tokens) => `[filestash: unchanged in lines ${s.rangeStart}-${s.rangeEnd}, changes elsewhere in file, ${tokens} tokens saved]`
683
+ );
339
684
  }
340
- return { stashed: false, content: this.sliceContent(s), hash: s.currentHash, totalLines: s.currentLines };
685
+ return this.fullSlice(db, s);
341
686
  }
342
687
  handleFullDiff(db, s, diffResult) {
343
- const saved = Math.max(0, estimateTokens(s.currentContent) - estimateTokens(diffResult.diff));
344
- this.addTokensSaved(db, saved);
688
+ const contentTokens = estimateTokens(s.currentContent);
689
+ const diffTokens = estimateTokens(diffResult.diff);
690
+ if (diffTokens >= contentTokens) return this.fullSlice(db, s);
691
+ this.addRange(db, s.absPath, s.currentHash, 1, s.currentLines);
692
+ this.recordRead(db, contentTokens, diffTokens);
345
693
  return {
346
694
  stashed: true,
347
695
  content: diffResult.diff,
@@ -351,13 +699,6 @@ var StashStore = class {
351
699
  totalLines: s.currentLines
352
700
  };
353
701
  }
354
- sliceContent(s) {
355
- if (!s.isPartial) return s.currentContent;
356
- const lines = s.currentContent.split("\n");
357
- const start = s.offset > 0 ? s.offset - 1 : 0;
358
- const end = s.limit > 0 ? start + s.limit : lines.length;
359
- return lines.slice(start, end).join("\n");
360
- }
361
702
  rangeHasChanges(changedLines, rangeStart, rangeEnd) {
362
703
  for (let l = rangeStart; l <= rangeEnd; l++) {
363
704
  if (changedLines.has(l)) return true;
@@ -369,68 +710,129 @@ var StashStore = class {
369
710
  "INSERT OR IGNORE INTO file_versions (path, hash, content, lines, created_at) VALUES (?, ?, ?, ?, ?)"
370
711
  ).run(absPath, hash, content, lines, now);
371
712
  }
372
- // Always returns full content and resets session tracking. Never records token savings.
713
+ // Resets session tracking for the file and returns the first slice regardless of what was delivered before. Counts as a plain read (baseline equals sent).
373
714
  async readFileFull(filePath) {
374
715
  await this.init();
375
- const db = this.getDb();
376
- const { absPath, content, hash, lines, now } = this.readFileSnapshot(filePath);
377
- this.storeVersion(db, absPath, hash, content, lines, now);
378
- db.prepare(
379
- "INSERT OR REPLACE INTO session_reads (session_id, path, hash, read_at) VALUES (?, ?, ?, ?)"
380
- ).run(this.sessionId, absPath, hash, now);
381
- return { stashed: false, content, hash, totalLines: lines };
716
+ const snap = this.readFileSnapshot(filePath);
717
+ if (snap.early) return this.earlyResult(snap);
718
+ const { absPath, content, hash, lines, now } = snap;
719
+ const slice = sliceLines(content, 0, 0, this.maxLines, this.maxChars);
720
+ const result = { stashed: false, content: slice.text, hash, totalLines: lines };
721
+ if (!this.db) return result;
722
+ try {
723
+ const tokens = estimateTokens(slice.text);
724
+ if (this.isStoreable(snap)) {
725
+ this.storeVersion(this.db, absPath, hash, content, lines, now);
726
+ this.db.prepare(
727
+ "INSERT OR REPLACE INTO session_reads (session_id, path, hash, read_at) VALUES (?, ?, ?, ?)"
728
+ ).run(this.sessionId, absPath, hash, now);
729
+ this.clearRanges(this.db, absPath);
730
+ this.addRange(this.db, absPath, hash, slice.start, slice.end);
731
+ }
732
+ this.recordRead(this.db, tokens, tokens);
733
+ } catch (err) {
734
+ this.degrade(err);
735
+ }
736
+ return result;
382
737
  }
383
738
  async onFileDeleted(filePath) {
384
739
  await this.init();
385
- const db = this.getDb();
386
740
  const absPath = resolve(filePath);
387
- db.prepare("DELETE FROM file_versions WHERE path = ?").run(absPath);
388
- db.prepare("DELETE FROM session_reads WHERE path = ?").run(absPath);
741
+ this.guarded((db) => {
742
+ db.prepare("DELETE FROM file_versions WHERE path = ?").run(absPath);
743
+ db.prepare("DELETE FROM session_reads WHERE path = ?").run(absPath);
744
+ db.prepare("DELETE FROM session_ranges WHERE path = ?").run(absPath);
745
+ });
746
+ }
747
+ guarded(fn) {
748
+ if (!this.db) return void 0;
749
+ try {
750
+ return fn(this.db);
751
+ } catch (err) {
752
+ this.degrade(err);
753
+ return void 0;
754
+ }
389
755
  }
390
756
  async getStats() {
391
757
  await this.init();
392
- const db = this.getDb();
393
- const versionRow = queryOne(db, "SELECT COUNT(DISTINCT path) as c FROM file_versions");
394
- const tokenRow = queryOne(db, "SELECT value FROM stats WHERE key = 'tokens_saved'");
395
- const sessionTokenRow = queryOne(
396
- db,
397
- "SELECT value FROM session_stats WHERE session_id = ? AND key = 'tokens_saved'",
398
- [this.sessionId]
399
- );
758
+ const stats = this.guarded((db) => {
759
+ const versionRow = queryOne(db, "SELECT COUNT(DISTINCT path) as c FROM file_versions");
760
+ const tokenRow = queryOne(db, "SELECT value FROM stats WHERE key = 'tokens_saved'");
761
+ const sessionStat = (key) => queryOne(db, "SELECT value FROM session_stats WHERE session_id = ? AND key = ?", [this.sessionId, key])?.value ?? 0;
762
+ return {
763
+ filesTracked: versionRow?.c ?? 0,
764
+ tokensSaved: tokenRow?.value ?? 0,
765
+ sessionTokensSaved: sessionStat("tokens_saved"),
766
+ sessionReads: sessionStat("reads"),
767
+ sessionBaselineTokens: sessionStat("baseline_tokens"),
768
+ sessionSentTokens: sessionStat("sent_tokens"),
769
+ degraded: false,
770
+ ...this.recoveredFrom && { recoveredFrom: this.recoveredFrom }
771
+ };
772
+ });
773
+ if (stats) return stats;
400
774
  return {
401
- filesTracked: versionRow?.c ?? 0,
402
- tokensSaved: tokenRow?.value ?? 0,
403
- sessionTokensSaved: sessionTokenRow?.value ?? 0
775
+ filesTracked: 0,
776
+ tokensSaved: 0,
777
+ sessionTokensSaved: 0,
778
+ sessionReads: 0,
779
+ sessionBaselineTokens: 0,
780
+ sessionSentTokens: 0,
781
+ degraded: true,
782
+ ...this.reason && { degradedReason: this.reason }
404
783
  };
405
784
  }
406
785
  async clear() {
407
786
  await this.init();
408
- const db = this.getDb();
409
- db.prepare("DELETE FROM file_versions").run();
410
- db.prepare("DELETE FROM session_reads").run();
411
- db.prepare("DELETE FROM session_stats").run();
412
- db.prepare("UPDATE stats SET value = 0").run();
787
+ this.guarded((db) => {
788
+ db.prepare("DELETE FROM file_versions").run();
789
+ db.prepare("DELETE FROM session_reads").run();
790
+ db.prepare("DELETE FROM session_ranges").run();
791
+ db.prepare("DELETE FROM session_stats").run();
792
+ db.prepare("UPDATE stats SET value = 0").run();
793
+ });
794
+ }
795
+ async resetReads() {
796
+ await this.init();
797
+ this.guarded((db) => {
798
+ db.prepare("DELETE FROM session_reads").run();
799
+ db.prepare("DELETE FROM session_ranges").run();
800
+ });
413
801
  }
414
802
  async close() {
415
- if (this.db) {
803
+ if (!this.db) return;
804
+ try {
416
805
  this.db.prepare("DELETE FROM sessions WHERE session_id = ?").run(this.sessionId);
806
+ } catch {
807
+ }
808
+ try {
417
809
  this.db.close();
418
- this.db = null;
419
- this.initialized = false;
810
+ } catch {
420
811
  }
812
+ this.db = null;
813
+ this.initialized = false;
814
+ }
815
+ recordRead(db, baseline, sent) {
816
+ this.bumpSessionStat(db, "reads", 1);
817
+ this.bumpSessionStat(db, "baseline_tokens", baseline);
818
+ this.bumpSessionStat(db, "sent_tokens", sent);
819
+ if (baseline > sent) this.addTokensSaved(db, baseline - sent);
820
+ }
821
+ bumpSessionStat(db, key, n) {
822
+ db.prepare(
823
+ "INSERT INTO session_stats (session_id, key, value) VALUES (?, ?, ?) ON CONFLICT(session_id, key) DO UPDATE SET value = value + ?"
824
+ ).run(this.sessionId, key, n, n);
421
825
  }
422
826
  addTokensSaved(db, tokens) {
423
827
  db.prepare(
424
828
  "UPDATE stats SET value = value + ? WHERE key = 'tokens_saved'"
425
829
  ).run(tokens);
426
- db.prepare(
427
- "INSERT INTO session_stats (session_id, key, value) VALUES (?, 'tokens_saved', ?) ON CONFLICT(session_id, key) DO UPDATE SET value = value + ?"
428
- ).run(this.sessionId, tokens, tokens);
830
+ this.bumpSessionStat(db, "tokens_saved", tokens);
429
831
  }
430
832
  };
431
833
 
432
834
  // packages/sdk/src/watcher.ts
433
- import { watch as fsWatch, existsSync } from "node:fs";
835
+ import { watch as fsWatch, existsSync as existsSync2 } from "node:fs";
434
836
  import { resolve as resolve2 } from "node:path";
435
837
  var FileWatcher = class {
436
838
  watchers = [];
@@ -444,26 +846,39 @@ var FileWatcher = class {
444
846
  watch(paths) {
445
847
  for (const p of paths) {
446
848
  const absPath = resolve2(p);
447
- const watcher = fsWatch(absPath, { recursive: true }, (event, filename) => {
448
- if (!filename) return;
449
- const filePath = resolve2(absPath, filename);
450
- if (filename.startsWith(".") || filename.includes("node_modules") || filename.includes(".git")) {
451
- return;
452
- }
453
- const existing = this.debounceTimers.get(filePath);
454
- if (existing) clearTimeout(existing);
455
- this.debounceTimers.set(
456
- filePath,
457
- setTimeout(() => {
458
- this.debounceTimers.delete(filePath);
459
- this.handleChange(filePath).catch((e) => {
460
- process.stderr.write(
461
- `[filestash] unhandled watcher error: ${e instanceof Error ? e.message : String(e)}
849
+ let watcher;
850
+ try {
851
+ watcher = fsWatch(absPath, { recursive: true }, (event, filename) => {
852
+ if (!filename) return;
853
+ const filePath = resolve2(absPath, filename);
854
+ if (filename.startsWith(".") || filename.includes("node_modules") || filename.includes(".git")) {
855
+ return;
856
+ }
857
+ const existing = this.debounceTimers.get(filePath);
858
+ if (existing) clearTimeout(existing);
859
+ this.debounceTimers.set(
860
+ filePath,
861
+ setTimeout(() => {
862
+ this.debounceTimers.delete(filePath);
863
+ this.handleChange(filePath).catch((e) => {
864
+ process.stderr.write(
865
+ `[filestash] unhandled watcher error: ${e instanceof Error ? e.message : String(e)}
462
866
  `
463
- );
464
- });
465
- }, this.debounceMs)
466
- );
867
+ );
868
+ });
869
+ }, this.debounceMs)
870
+ );
871
+ });
872
+ } catch (e) {
873
+ process.stderr.write(`[filestash] file watching disabled for ${absPath}: ${e instanceof Error ? e.message : String(e)}
874
+ `);
875
+ continue;
876
+ }
877
+ watcher.on("error", (e) => {
878
+ process.stderr.write(`[filestash] file watching stopped for ${absPath}: ${e.message}
879
+ `);
880
+ watcher.close();
881
+ this.watchers = this.watchers.filter((w) => w !== watcher);
467
882
  });
468
883
  this.watchers.push(watcher);
469
884
  }
@@ -476,7 +891,7 @@ var FileWatcher = class {
476
891
  }
477
892
  async handleChange(filePath) {
478
893
  try {
479
- if (!existsSync(filePath)) {
894
+ if (!existsSync2(filePath)) {
480
895
  await this.stash.onFileDeleted(filePath);
481
896
  }
482
897
  } catch (e) {
@@ -498,7 +913,7 @@ function createStash(config) {
498
913
 
499
914
  // packages/cli/src/index.ts
500
915
  import { resolve as resolve4, join as join3 } from "node:path";
501
- import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync } from "node:fs";
916
+ import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2, copyFileSync, mkdirSync as mkdirSync2 } from "node:fs";
502
917
  import { homedir } from "node:os";
503
918
 
504
919
  // packages/cli/src/mcp.ts
@@ -506,14 +921,28 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
506
921
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
507
922
  import { z } from "zod";
508
923
  import { resolve as resolve3, join, relative, isAbsolute } from "node:path";
509
- import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, realpathSync } from "node:fs";
924
+ import { readFileSync as readFileSync2, realpathSync } from "node:fs";
510
925
  import { randomUUID } from "node:crypto";
511
- function getStashDir() {
926
+ function resolveStashDir(baseDir = process.cwd()) {
512
927
  const raw = process.env.FILESTASH_DIR ?? ".file-stash";
513
928
  if (raw.includes("\0")) throw new Error("FILESTASH_DIR contains invalid characters");
514
- const dir = resolve3(raw);
515
- if (!existsSync2(dir)) mkdirSync(dir, { recursive: true });
516
- return dir;
929
+ return resolve3(baseDir, raw);
930
+ }
931
+ function parseExcludeEnv() {
932
+ return (process.env.FILESTASH_EXCLUDE ?? "").split(",").map((p) => p.trim()).filter(Boolean);
933
+ }
934
+ function parseLimitEnv(name) {
935
+ const raw = process.env[name];
936
+ if (raw === void 0 || raw === "") return void 0;
937
+ if (/^[1-9]\d*$/.test(raw) && Number.isSafeInteger(Number(raw))) return Number(raw);
938
+ process.stderr.write(`[filestash] ignoring invalid ${name}=${JSON.stringify(raw)}; using the default
939
+ `);
940
+ return void 0;
941
+ }
942
+ function limitOptions() {
943
+ const maxLines = parseLimitEnv("FILESTASH_MAX_LINES");
944
+ const maxChars = parseLimitEnv("FILESTASH_MAX_CHARS");
945
+ return { ...maxLines !== void 0 && { maxLines }, ...maxChars !== void 0 && { maxChars } };
517
946
  }
518
947
  function isPathAllowed(absPath, cwd) {
519
948
  let realPath;
@@ -540,10 +969,90 @@ ${result.diff}`;
540
969
  return `=== ${path} ===
541
970
  ${result.content}`;
542
971
  }
543
- function statsSuffix(tokensSaved) {
972
+ var readFileShape = {
973
+ path: z.string().describe("File path"),
974
+ offset: z.number().optional().describe("Start line (1-based)"),
975
+ limit: z.number().optional().describe("Max lines"),
976
+ force: z.boolean().optional().describe("Bypass stash")
977
+ };
978
+ var readFilesShape = {
979
+ paths: z.preprocess(
980
+ (val) => typeof val === "string" ? JSON.parse(val) : val,
981
+ z.array(z.string())
982
+ ).describe("File paths")
983
+ };
984
+ var TOOL_DEFS = {
985
+ read_file: {
986
+ description: `Read a file (use instead of Read). Repeat reads of an unchanged file return a short label, changed files return only a diff. offset/limit supported and stashed. Long files are truncated, continue with offset. force=true returns full content (use when the original is no longer in context).`,
987
+ inputSchema: readFileShape
988
+ },
989
+ read_files: {
990
+ description: `Batched read_file for several files, same stash/diff behavior per file.`,
991
+ inputSchema: readFilesShape
992
+ },
993
+ stash_status: {
994
+ description: `Show files tracked and this session's token accounting, net of tool-definition overhead.`
995
+ },
996
+ stash_clear: {
997
+ description: `Clear all stashed data.`
998
+ }
999
+ };
1000
+ function jsonType(field) {
1001
+ switch (field._def.typeName) {
1002
+ case z.ZodFirstPartyTypeKind.ZodOptional:
1003
+ return jsonType(field._def.innerType);
1004
+ case z.ZodFirstPartyTypeKind.ZodEffects:
1005
+ return jsonType(field._def.schema);
1006
+ case z.ZodFirstPartyTypeKind.ZodArray:
1007
+ return "array";
1008
+ case z.ZodFirstPartyTypeKind.ZodNumber:
1009
+ return "number";
1010
+ case z.ZodFirstPartyTypeKind.ZodBoolean:
1011
+ return "boolean";
1012
+ default:
1013
+ return "string";
1014
+ }
1015
+ }
1016
+ function jsonSchema(shape = {}) {
1017
+ const properties = {};
1018
+ for (const [key, field] of Object.entries(shape)) {
1019
+ properties[key] = { type: jsonType(field), description: field.description };
1020
+ }
1021
+ const required = Object.entries(shape).filter(([, f]) => !f.isOptional()).map(([k]) => k);
1022
+ return { type: "object", properties, ...required.length > 0 && { required } };
1023
+ }
1024
+ function toolDefinitionTokens() {
1025
+ const defs = Object.entries(TOOL_DEFS).map(([name, def]) => ({
1026
+ name,
1027
+ description: def.description,
1028
+ inputSchema: jsonSchema("inputSchema" in def ? def.inputSchema : void 0)
1029
+ }));
1030
+ return Math.ceil(JSON.stringify(defs).length / 4);
1031
+ }
1032
+ function degradedLine(reason = "unknown") {
1033
+ return `Mode: DEGRADED (${reason}) - files are read normally, nothing is stashed`;
1034
+ }
1035
+ function formatStatus(stats, overheadTokens) {
1036
+ if (stats.degraded) return degradedLine(stats.degradedReason);
1037
+ const fmt = (n) => `~${n.toLocaleString()}`;
1038
+ const net = stats.sessionTokensSaved - overheadTokens;
1039
+ return [
1040
+ "filestash status:",
1041
+ ...stats.recoveredFrom ? [` Recovered: corrupt database moved to ${stats.recoveredFrom}`] : [],
1042
+ ` Files tracked: ${stats.filesTracked}`,
1043
+ ` This session: ${stats.sessionReads} reads`,
1044
+ ` Would have sent (plain reads): ${fmt(stats.sessionBaselineTokens)} tokens`,
1045
+ ` Actually sent: ${fmt(stats.sessionSentTokens)} tokens`,
1046
+ ` Gross saved: ${fmt(stats.sessionTokensSaved)} tokens`,
1047
+ ` Tool definitions overhead: ${fmt(overheadTokens)} tokens (est.)`,
1048
+ ` Net saved: ${fmt(net)} tokens (est.)`,
1049
+ ` Gross saved (all sessions): ${fmt(stats.tokensSaved)} tokens`
1050
+ ].join("\n");
1051
+ }
1052
+ function statsSuffix(grossSaved) {
544
1053
  return `
545
1054
 
546
- [filestash: ~${tokensSaved.toLocaleString()} tokens saved this session. Report this to the user when you complete their task.]`;
1055
+ [filestash: net ~${(grossSaved - toolDefinitionTokens()).toLocaleString()} tokens this session (est.)]`;
547
1056
  }
548
1057
  async function readSingleFile(path, cwd, stash) {
549
1058
  const absPath = resolve3(path);
@@ -572,15 +1081,16 @@ async function startMcpServer() {
572
1081
  } catch {
573
1082
  }
574
1083
  const META_NAMESPACE = (packageJson.mcpName || "io.github.glommer/filestash").replaceAll("/", ".");
575
- const stashDir = getStashDir();
576
- const dbPath = resolve3(stashDir, "stash.db");
1084
+ const dbPath = resolve3(resolveStashDir(), "stash.db");
577
1085
  const cwd = process.cwd();
578
1086
  const watchPaths = [cwd];
579
1087
  const sessionId = randomUUID();
580
1088
  const { stash, watcher } = createStash({
581
1089
  dbPath,
582
1090
  sessionId,
583
- watchPaths
1091
+ watchPaths,
1092
+ exclude: parseExcludeEnv(),
1093
+ ...limitOptions()
584
1094
  });
585
1095
  await stash.init();
586
1096
  const server = new McpServer({
@@ -589,21 +1099,7 @@ async function startMcpServer() {
589
1099
  });
590
1100
  server.registerTool(
591
1101
  "read_file",
592
- {
593
- description: `Read a file with stashing. Use this tool INSTEAD of the built-in Read tool for reading files.
594
- On first read, returns full content and stashes it \u2014 identical to Read.
595
- On subsequent reads, if the file hasn't changed, returns a short confirmation instead of the full content \u2014 saving significant tokens.
596
- If the file changed, returns only the diff (changed lines) instead of the full file.
597
- Supports offset and limit for partial reads \u2014 and partial reads are also stashed. If only lines outside the requested range changed, returns a short confirmation saving tokens.
598
- Set force=true to bypass the stash and get the full file content (use when you no longer have the original in context).
599
- ALWAYS prefer this over the Read tool. It is a drop-in replacement with stashing benefits.`,
600
- inputSchema: {
601
- path: z.string().describe("Path to the file to read"),
602
- offset: z.number().optional().describe("Line number to start reading from (1-based). Only provide if the file is too large to read at once."),
603
- limit: z.number().optional().describe("Number of lines to read. Only provide if the file is too large to read at once."),
604
- force: z.boolean().optional().describe("Bypass stash and return full content")
605
- }
606
- },
1102
+ TOOL_DEFS.read_file,
607
1103
  async ({ path, force, offset, limit }) => {
608
1104
  const absPath = resolve3(path);
609
1105
  if (!isPathAllowed(absPath, cwd)) {
@@ -637,17 +1133,7 @@ ALWAYS prefer this over the Read tool. It is a drop-in replacement with stashing
637
1133
  );
638
1134
  server.registerTool(
639
1135
  "read_files",
640
- {
641
- description: `Read multiple files at once with stashing. Use this tool INSTEAD of the built-in Read tool when you need to read several files.
642
- Same behavior as read_file but batched. Returns stashed/diff results for each file.
643
- ALWAYS prefer this over multiple Read calls \u2014 it's faster and saves significant tokens.`,
644
- inputSchema: {
645
- paths: z.preprocess(
646
- (val) => typeof val === "string" ? JSON.parse(val) : val,
647
- z.array(z.string())
648
- ).describe("Paths to the files to read")
649
- }
650
- },
1136
+ TOOL_DEFS.read_files,
651
1137
  async ({ paths }) => {
652
1138
  const results = await Promise.all(paths.map((p) => readSingleFile(p, cwd, stash)));
653
1139
  const successfulPaths = paths.filter((_, i) => results[i].ok);
@@ -668,31 +1154,31 @@ ALWAYS prefer this over multiple Read calls \u2014 it's faster and saves signifi
668
1154
  );
669
1155
  server.registerTool(
670
1156
  "stash_status",
671
- {
672
- description: `Show filestash statistics: files tracked, tokens saved, stash hit rates.
673
- Use this to verify filestash is working and see how many tokens it has saved.`
674
- },
1157
+ TOOL_DEFS.stash_status,
675
1158
  async () => {
676
1159
  const stats = await stash.getStats();
677
- const text = [
678
- `filestash status:`,
679
- ` Files tracked: ${stats.filesTracked}`,
680
- ` Tokens saved (this session): ~${stats.sessionTokensSaved.toLocaleString()}`,
681
- ` Tokens saved (all sessions): ~${stats.tokensSaved.toLocaleString()}`
682
- ].join("\n");
1160
+ const overhead = toolDefinitionTokens();
1161
+ const text = formatStatus(stats, overhead);
683
1162
  return {
684
1163
  content: [{ type: "text", text }],
685
- _meta: { [`${META_NAMESPACE}/stats`]: { filesTracked: stats.filesTracked, tokensSaved: stats.tokensSaved, sessionTokensSaved: stats.sessionTokensSaved } }
1164
+ _meta: {
1165
+ [`${META_NAMESPACE}/stats`]: {
1166
+ ...stats,
1167
+ toolDefinitionTokens: overhead,
1168
+ netTokensSaved: stats.sessionTokensSaved - overhead
1169
+ }
1170
+ }
686
1171
  };
687
1172
  }
688
1173
  );
689
1174
  server.registerTool(
690
1175
  "stash_clear",
691
- {
692
- description: `Clear all stashed data. Use this to reset the stash completely.`
693
- },
1176
+ TOOL_DEFS.stash_clear,
694
1177
  async () => {
695
1178
  await stash.clear();
1179
+ if (stash.isDegraded) {
1180
+ return { content: [{ type: "text", text: "Stash is in degraded mode, nothing to clear." }] };
1181
+ }
696
1182
  return {
697
1183
  content: [{ type: "text", text: "Stash cleared." }],
698
1184
  _meta: { [`${META_NAMESPACE}/cleared`]: true }
@@ -744,20 +1230,104 @@ process.emitWarning = function(msg, ...args) {
744
1230
  return _origEmitWarning.apply(process, [msg, ...args]);
745
1231
  };
746
1232
  var CLI_STATUS_SESSION = "cli-status";
1233
+ var RESET_HOOK_COMMAND = "npx agent-file-stash reset --from-hook";
1234
+ var RESET_HOOK_ENTRY = {
1235
+ matcher: "clear|compact",
1236
+ hooks: [{ type: "command", command: RESET_HOOK_COMMAND }]
1237
+ };
1238
+ var HOOK_STDIN_TIMEOUT_MS = 1e3;
1239
+ function assertHealthy(stash) {
1240
+ if (stash.isDegraded) throw new Error(stash.degradedReason);
1241
+ }
1242
+ async function readHookCwd() {
1243
+ if (process.stdin.isTTY) return void 0;
1244
+ const read = (async () => {
1245
+ let raw2 = "";
1246
+ for await (const chunk of process.stdin) raw2 += chunk;
1247
+ return raw2;
1248
+ })();
1249
+ const timeout = new Promise((res) => setTimeout(() => res(""), HOOK_STDIN_TIMEOUT_MS).unref());
1250
+ const raw = (await Promise.race([read, timeout])).trim();
1251
+ if (!raw) return void 0;
1252
+ try {
1253
+ const cwd = JSON.parse(raw)?.cwd;
1254
+ return typeof cwd === "string" && cwd !== "" ? cwd : void 0;
1255
+ } catch {
1256
+ return void 0;
1257
+ }
1258
+ }
1259
+ async function runReset(fromHook) {
1260
+ try {
1261
+ const hookCwd = fromHook ? await readHookCwd() : void 0;
1262
+ const dbPath = join3(resolveStashDir(hookCwd), "stash.db");
1263
+ if (!existsSync4(dbPath)) {
1264
+ if (!fromHook) console.log("No filestash database found. Nothing to reset.");
1265
+ return;
1266
+ }
1267
+ const { stash } = createStash({ dbPath, sessionId: CLI_STATUS_SESSION, recoverCorrupt: false, quiet: true });
1268
+ try {
1269
+ await stash.init();
1270
+ assertHealthy(stash);
1271
+ await stash.resetReads();
1272
+ assertHealthy(stash);
1273
+ } finally {
1274
+ await stash.close();
1275
+ }
1276
+ if (!fromHook) console.log("Read tracking reset. The next read of each file returns full content.");
1277
+ } catch (e) {
1278
+ console.error(`filestash reset failed: ${e instanceof Error ? e.message : String(e)}`);
1279
+ process.exit(fromHook ? 0 : 1);
1280
+ }
1281
+ }
1282
+ function hooksSnippet() {
1283
+ return JSON.stringify({ hooks: { SessionStart: [RESET_HOOK_ENTRY] } }, null, 2);
1284
+ }
1285
+ function installClaudeHook(home) {
1286
+ const settingsPath = join3(home, ".claude", "settings.json");
1287
+ let settings = {};
1288
+ const existed = existsSync4(settingsPath);
1289
+ if (existed) {
1290
+ try {
1291
+ settings = JSON.parse(readFileSync3(settingsPath, "utf-8"));
1292
+ } catch {
1293
+ console.error(` Claude Code hook: ${settingsPath} is not valid JSON, left untouched`);
1294
+ return;
1295
+ }
1296
+ }
1297
+ const hooks = settings.hooks ?? {};
1298
+ const sessionStart = Array.isArray(hooks.SessionStart) ? hooks.SessionStart : [];
1299
+ if (sessionStart.some((e) => e?.hooks?.some((h) => h?.command === RESET_HOOK_COMMAND))) {
1300
+ console.log(" Claude Code hook: already configured");
1301
+ return;
1302
+ }
1303
+ if (existed) copyFileSync(settingsPath, `${settingsPath}.bak`);
1304
+ else mkdirSync2(join3(home, ".claude"), { recursive: true });
1305
+ settings.hooks = { ...hooks, SessionStart: [...sessionStart, RESET_HOOK_ENTRY] };
1306
+ writeFileSync2(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1307
+ console.log(` Claude Code hook: configured (${settingsPath})`);
1308
+ }
747
1309
  async function runStatus() {
748
1310
  const stashDir = resolve4(process.env.FILESTASH_DIR ?? ".file-stash");
749
1311
  const dbPath = join3(stashDir, "stash.db");
750
1312
  if (!existsSync4(dbPath)) {
751
- console.log("No filestash database found. Run 'filestash serve' to start stashing.");
1313
+ console.log("No filestash database found. Run 'agent-file-stash serve' to start stashing.");
752
1314
  process.exit(0);
753
1315
  }
754
- const { stash } = createStash({ dbPath, sessionId: CLI_STATUS_SESSION });
755
- await stash.init();
756
- const stats = await stash.getStats();
757
- console.log(`filestash status:`);
758
- console.log(` Files tracked: ${stats.filesTracked}`);
759
- console.log(` Tokens saved (total): ~${stats.tokensSaved.toLocaleString()}`);
760
- await stash.close();
1316
+ const { stash } = createStash({ dbPath, sessionId: CLI_STATUS_SESSION, recoverCorrupt: false, quiet: true });
1317
+ try {
1318
+ await stash.init();
1319
+ assertHealthy(stash);
1320
+ const stats = await stash.getStats();
1321
+ assertHealthy(stash);
1322
+ console.log(`filestash status:`);
1323
+ console.log(` Files tracked: ${stats.filesTracked}`);
1324
+ console.log(` Tokens saved (total): ~${stats.tokensSaved.toLocaleString()}`);
1325
+ } catch (e) {
1326
+ console.error(`filestash status failed for ${dbPath}: ${e instanceof Error ? e.message : String(e)}`);
1327
+ process.exitCode = 1;
1328
+ } finally {
1329
+ await stash.close();
1330
+ }
761
1331
  }
762
1332
  async function runStatusAll(root) {
763
1333
  const databases = findStashDatabases(resolve4(root));
@@ -767,10 +1337,11 @@ async function runStatusAll(root) {
767
1337
  }
768
1338
  const rows = [];
769
1339
  for (const dbPath of databases) {
770
- const { stash } = createStash({ dbPath, sessionId: CLI_STATUS_SESSION });
1340
+ const { stash } = createStash({ dbPath, sessionId: CLI_STATUS_SESSION, recoverCorrupt: false, quiet: true });
771
1341
  try {
772
1342
  await stash.init();
773
1343
  const stats = await stash.getStats();
1344
+ assertHealthy(stash);
774
1345
  rows.push({ project: resolve4(dbPath, "../..").replace(/\/\.vscode$/, ""), files: stats.filesTracked, tokens: stats.tokensSaved });
775
1346
  } catch (e) {
776
1347
  console.error(` skipped ${dbPath}: ${e instanceof Error ? e.message : String(e)}`);
@@ -789,7 +1360,7 @@ async function runStatusAll(root) {
789
1360
  console.log(`
790
1361
  Savings count re-reads within a session (unchanged files and diffs); a new session always receives full content.`);
791
1362
  }
792
- async function runInit() {
1363
+ async function runInit(withHooks) {
793
1364
  const home = homedir();
794
1365
  const mcpServersEntry = {
795
1366
  command: "npx",
@@ -839,16 +1410,16 @@ async function runInit() {
839
1410
  continue;
840
1411
  }
841
1412
  config[target.key] = { ...section, filestash: target.entry };
842
- writeFileSync(target.path, JSON.stringify(config, null, 2) + "\n");
1413
+ writeFileSync2(target.path, JSON.stringify(config, null, 2) + "\n");
843
1414
  console.log(` ${target.name}: configured (${target.path})`);
844
1415
  configured++;
845
1416
  }
846
1417
  if (configured === 0) {
847
- console.log("No supported tools detected. You can manually add filestash to your MCP config:");
1418
+ console.log("No supported tools detected. You can manually add agent-file-stash to your MCP config:");
848
1419
  console.log(JSON.stringify({ mcpServers: { "agent-file-stash": mcpServersEntry } }, null, 2));
849
1420
  } else {
850
1421
  console.log(`
851
- Done! Restart your editor to pick up filestash.`);
1422
+ Done! Restart your editor to pick up agent-file-stash.`);
852
1423
  console.log(`
853
1424
  Available MCP tools:`);
854
1425
  console.log(` read_file Read a file, returning only a diff if unchanged since last read`);
@@ -858,12 +1429,25 @@ Available MCP tools:`);
858
1429
  console.log(`
859
1430
  Stash location: FILESTASH_DIR env var (default: .file-stash in cwd)`);
860
1431
  }
1432
+ console.log(`
1433
+ Context resets: after /clear or /compact the model loses file contents the stash still considers read.`);
1434
+ if (withHooks) {
1435
+ installClaudeHook(home);
1436
+ } else {
1437
+ console.log(`Add this to ~/.claude/settings.json (or re-run 'init --hooks' to merge it):
1438
+ `);
1439
+ console.log(hooksSnippet());
1440
+ }
861
1441
  }
862
1442
  function runHelp() {
863
1443
  console.log(`agent-file-stash - Agent file stash with diff tracking
864
1444
 
865
1445
  Usage:
866
1446
  agent-file-stash init Auto-configure for your editor
1447
+ agent-file-stash init --hooks
1448
+ Also add the Claude Code context-reset hook to ~/.claude/settings.json
1449
+ agent-file-stash reset Forget what was read so the next read returns full content
1450
+ (--from-hook: quiet mode for Claude Code hooks)
867
1451
  agent-file-stash serve Start the MCP server (default)
868
1452
  agent-file-stash status Show stash statistics
869
1453
  agent-file-stash status --all [dir]
@@ -871,7 +1455,10 @@ Usage:
871
1455
  agent-file-stash help Show this help message
872
1456
 
873
1457
  Environment:
874
- FILESTASH_DIR Stash directory (default: .file-stash)`);
1458
+ FILESTASH_DIR Stash directory (default: .file-stash)
1459
+ FILESTASH_EXCLUDE Extra comma-separated file name globs that are never stored
1460
+ FILESTASH_MAX_LINES Max lines returned per read (default: 2000)
1461
+ FILESTASH_MAX_CHARS Max characters returned per read (default: 100000)`);
875
1462
  }
876
1463
  var command = process.argv[2];
877
1464
  if (!command || command === "serve") {
@@ -881,10 +1468,12 @@ if (!command || command === "serve") {
881
1468
  } else if (command === "status") {
882
1469
  await runStatus();
883
1470
  } else if (command === "init") {
884
- await runInit();
1471
+ await runInit(process.argv.includes("--hooks"));
1472
+ } else if (command === "reset") {
1473
+ await runReset(process.argv.includes("--from-hook"));
885
1474
  } else if (command === "help" || command === "--help") {
886
1475
  runHelp();
887
1476
  } else {
888
- console.error(`Unknown command: ${command}. Run 'filestash help' for usage.`);
1477
+ console.error(`Unknown command: ${command}. Run 'agent-file-stash help' for usage.`);
889
1478
  process.exit(1);
890
1479
  }