agent-file-stash 0.3.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.
- package/LICENSE +21 -0
- package/README.md +165 -39
- package/dist/cli.mjs +806 -176
- package/package.json +5 -2
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,21 @@ 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
|
+
|
|
205
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
206
|
+
session_id TEXT PRIMARY KEY,
|
|
207
|
+
pid INTEGER NOT NULL
|
|
208
|
+
);
|
|
209
|
+
|
|
163
210
|
CREATE TABLE IF NOT EXISTS stats (
|
|
164
211
|
key TEXT PRIMARY KEY,
|
|
165
212
|
value INTEGER NOT NULL DEFAULT 0
|
|
@@ -174,6 +221,40 @@ CREATE TABLE IF NOT EXISTS session_stats (
|
|
|
174
221
|
|
|
175
222
|
INSERT OR IGNORE INTO stats (key, value) VALUES ('tokens_saved', 0);
|
|
176
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
|
+
}
|
|
177
258
|
function estimateTokens(text) {
|
|
178
259
|
return Math.ceil(text.length / 4);
|
|
179
260
|
}
|
|
@@ -181,6 +262,74 @@ var HASH_LENGTH = 16;
|
|
|
181
262
|
function contentHash(content) {
|
|
182
263
|
return createHash("sha256").update(content).digest("hex").slice(0, HASH_LENGTH);
|
|
183
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
|
+
}
|
|
278
|
+
function isProcessAlive(pid) {
|
|
279
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
280
|
+
try {
|
|
281
|
+
process.kill(pid, 0);
|
|
282
|
+
return true;
|
|
283
|
+
} catch (err) {
|
|
284
|
+
return err.code === "EPERM";
|
|
285
|
+
}
|
|
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
|
+
}
|
|
184
333
|
function queryOne(db, sql, params = []) {
|
|
185
334
|
const rows = db.prepare(sql).all(...params);
|
|
186
335
|
return rows[0];
|
|
@@ -189,65 +338,250 @@ var StashStore = class {
|
|
|
189
338
|
db = null;
|
|
190
339
|
dbPath;
|
|
191
340
|
sessionId;
|
|
341
|
+
exclude;
|
|
192
342
|
initialized = false;
|
|
343
|
+
recoverCorrupt;
|
|
344
|
+
quiet;
|
|
345
|
+
maxLines;
|
|
346
|
+
maxChars;
|
|
347
|
+
maxStoreBytes;
|
|
348
|
+
recoveryAttempted = false;
|
|
349
|
+
recoveredFrom;
|
|
350
|
+
reason;
|
|
193
351
|
constructor(config) {
|
|
194
352
|
this.dbPath = config.dbPath;
|
|
195
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;
|
|
196
366
|
}
|
|
197
367
|
async init() {
|
|
198
368
|
if (this.initialized) return;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
369
|
+
try {
|
|
370
|
+
this.db = await this.openWithRetry();
|
|
371
|
+
} catch (err) {
|
|
372
|
+
await this.recoverOrDegrade(err);
|
|
373
|
+
}
|
|
203
374
|
this.initialized = true;
|
|
204
375
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
+
}
|
|
468
|
+
registerSession(db) {
|
|
469
|
+
db.prepare("INSERT OR REPLACE INTO sessions (session_id, pid) VALUES (?, ?)").run(this.sessionId, process.pid);
|
|
470
|
+
}
|
|
471
|
+
pruneClosedSessions(db) {
|
|
472
|
+
db.exec("BEGIN IMMEDIATE");
|
|
473
|
+
try {
|
|
474
|
+
this.purgeExcluded(db);
|
|
475
|
+
const sessions = db.prepare("SELECT session_id, pid FROM sessions").all();
|
|
476
|
+
for (const s of sessions) {
|
|
477
|
+
if (s.session_id !== this.sessionId && !isProcessAlive(s.pid)) {
|
|
478
|
+
db.prepare("DELETE FROM sessions WHERE session_id = ?").run(s.session_id);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
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)");
|
|
483
|
+
db.exec("DELETE FROM session_stats WHERE session_id NOT IN (SELECT session_id FROM sessions)");
|
|
484
|
+
db.exec(
|
|
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)"
|
|
486
|
+
);
|
|
487
|
+
db.exec("COMMIT");
|
|
488
|
+
} catch (err) {
|
|
489
|
+
try {
|
|
490
|
+
db.exec("ROLLBACK");
|
|
491
|
+
} catch {
|
|
492
|
+
}
|
|
493
|
+
throw err;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
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
|
+
}
|
|
208
503
|
}
|
|
209
504
|
readFileSnapshot(filePath) {
|
|
210
505
|
const absPath = resolve(filePath);
|
|
211
|
-
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
|
+
}
|
|
212
512
|
const content = readFileSync(absPath, "utf-8");
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
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);
|
|
220
532
|
}
|
|
221
533
|
async readFile(filePath, options) {
|
|
222
534
|
await this.init();
|
|
223
|
-
const
|
|
224
|
-
|
|
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;
|
|
225
538
|
const offset = options?.offset ?? 0;
|
|
226
539
|
const limit = options?.limit ?? 0;
|
|
227
|
-
const
|
|
540
|
+
const slice = sliceLines(currentContent, offset, limit, this.maxLines, this.maxChars);
|
|
228
541
|
const state = {
|
|
229
542
|
absPath,
|
|
230
543
|
currentContent,
|
|
231
544
|
currentHash,
|
|
232
545
|
currentLines,
|
|
233
|
-
isPartial: offset > 0 || limit > 0,
|
|
234
|
-
rangeStart,
|
|
235
|
-
rangeEnd:
|
|
546
|
+
isPartial: offset > 0 || limit > 0 || slice.truncated,
|
|
547
|
+
rangeStart: slice.start,
|
|
548
|
+
rangeEnd: slice.end,
|
|
236
549
|
offset,
|
|
237
550
|
limit,
|
|
238
|
-
now
|
|
551
|
+
now,
|
|
552
|
+
size: snap.size,
|
|
553
|
+
slice: slice.text,
|
|
554
|
+
midLine: slice.midLine
|
|
239
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
|
+
}
|
|
240
574
|
const lastRead = queryOne(
|
|
241
575
|
db,
|
|
242
576
|
"SELECT hash FROM session_reads WHERE session_id = ? AND path = ?",
|
|
243
|
-
[this.sessionId, absPath]
|
|
577
|
+
[this.sessionId, state.absPath]
|
|
244
578
|
);
|
|
245
579
|
if (!lastRead) {
|
|
246
580
|
return this.handleFirstRead(db, state);
|
|
247
581
|
}
|
|
248
582
|
const lastHash = lastRead.hash;
|
|
249
|
-
if (lastHash === currentHash) {
|
|
250
|
-
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);
|
|
251
585
|
}
|
|
252
586
|
return this.handleChanged(db, state, lastHash);
|
|
253
587
|
}
|
|
@@ -256,52 +590,106 @@ var StashStore = class {
|
|
|
256
590
|
db.prepare(
|
|
257
591
|
"INSERT OR REPLACE INTO session_reads (session_id, path, hash, read_at) VALUES (?, ?, ?, ?)"
|
|
258
592
|
).run(this.sessionId, s.absPath, s.currentHash, s.now);
|
|
259
|
-
|
|
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 };
|
|
260
646
|
}
|
|
261
647
|
handleUnchanged(db, s) {
|
|
262
|
-
const slicedTokens = estimateTokens(this.sliceContent(s));
|
|
263
|
-
this.addTokensSaved(db, slicedTokens);
|
|
264
648
|
db.prepare(
|
|
265
649
|
"UPDATE session_reads SET read_at = ? WHERE session_id = ? AND path = ?"
|
|
266
650
|
).run(s.now, this.sessionId, s.absPath);
|
|
267
|
-
|
|
268
|
-
|
|
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
|
+
);
|
|
269
656
|
}
|
|
270
657
|
handleChanged(db, s, lastHash) {
|
|
271
658
|
const oldVersion = queryOne(
|
|
272
659
|
db,
|
|
273
|
-
"SELECT content FROM file_versions WHERE path = ? AND hash = ?",
|
|
660
|
+
"SELECT content, lines FROM file_versions WHERE path = ? AND hash = ?",
|
|
274
661
|
[s.absPath, lastHash]
|
|
275
662
|
);
|
|
663
|
+
const oldFullyDelivered = !!oldVersion && coversRange(this.getRanges(db, s.absPath, lastHash), 1, oldVersion.lines);
|
|
276
664
|
this.storeVersion(db, s.absPath, s.currentHash, s.currentContent, s.currentLines, s.now);
|
|
277
665
|
db.prepare(
|
|
278
666
|
"UPDATE session_reads SET hash = ?, read_at = ? WHERE session_id = ? AND path = ?"
|
|
279
667
|
).run(s.currentHash, s.now, this.sessionId, s.absPath);
|
|
280
|
-
|
|
668
|
+
this.clearRanges(db, s.absPath);
|
|
669
|
+
if (oldVersion && oldFullyDelivered) {
|
|
281
670
|
const diffResult = computeDiff(oldVersion.content, s.currentContent, s.absPath);
|
|
282
671
|
if (diffResult.hasChanges) {
|
|
283
672
|
return s.isPartial ? this.handlePartialDiff(db, s, diffResult) : this.handleFullDiff(db, s, diffResult);
|
|
284
673
|
}
|
|
285
674
|
}
|
|
286
|
-
return
|
|
675
|
+
return this.fullSlice(db, s);
|
|
287
676
|
}
|
|
288
677
|
handlePartialDiff(db, s, diffResult) {
|
|
289
|
-
if (!this.rangeHasChanges(diffResult.changedNewLines, s.rangeStart, s.rangeEnd)) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
hash: s.currentHash,
|
|
296
|
-
totalLines: s.currentLines,
|
|
297
|
-
linesChanged: 0
|
|
298
|
-
};
|
|
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
|
+
);
|
|
299
684
|
}
|
|
300
|
-
return
|
|
685
|
+
return this.fullSlice(db, s);
|
|
301
686
|
}
|
|
302
687
|
handleFullDiff(db, s, diffResult) {
|
|
303
|
-
const
|
|
304
|
-
|
|
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);
|
|
305
693
|
return {
|
|
306
694
|
stashed: true,
|
|
307
695
|
content: diffResult.diff,
|
|
@@ -311,13 +699,6 @@ var StashStore = class {
|
|
|
311
699
|
totalLines: s.currentLines
|
|
312
700
|
};
|
|
313
701
|
}
|
|
314
|
-
sliceContent(s) {
|
|
315
|
-
if (!s.isPartial) return s.currentContent;
|
|
316
|
-
const lines = s.currentContent.split("\n");
|
|
317
|
-
const start = s.offset > 0 ? s.offset - 1 : 0;
|
|
318
|
-
const end = s.limit > 0 ? start + s.limit : lines.length;
|
|
319
|
-
return lines.slice(start, end).join("\n");
|
|
320
|
-
}
|
|
321
702
|
rangeHasChanges(changedLines, rangeStart, rangeEnd) {
|
|
322
703
|
for (let l = rangeStart; l <= rangeEnd; l++) {
|
|
323
704
|
if (changedLines.has(l)) return true;
|
|
@@ -329,67 +710,129 @@ var StashStore = class {
|
|
|
329
710
|
"INSERT OR IGNORE INTO file_versions (path, hash, content, lines, created_at) VALUES (?, ?, ?, ?, ?)"
|
|
330
711
|
).run(absPath, hash, content, lines, now);
|
|
331
712
|
}
|
|
332
|
-
//
|
|
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).
|
|
333
714
|
async readFileFull(filePath) {
|
|
334
715
|
await this.init();
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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;
|
|
342
737
|
}
|
|
343
738
|
async onFileDeleted(filePath) {
|
|
344
739
|
await this.init();
|
|
345
|
-
const db = this.getDb();
|
|
346
740
|
const absPath = resolve(filePath);
|
|
347
|
-
|
|
348
|
-
|
|
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
|
+
}
|
|
349
755
|
}
|
|
350
756
|
async getStats() {
|
|
351
757
|
await this.init();
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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;
|
|
360
774
|
return {
|
|
361
|
-
filesTracked:
|
|
362
|
-
tokensSaved:
|
|
363
|
-
sessionTokensSaved:
|
|
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 }
|
|
364
783
|
};
|
|
365
784
|
}
|
|
366
785
|
async clear() {
|
|
367
786
|
await this.init();
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
+
});
|
|
373
801
|
}
|
|
374
802
|
async close() {
|
|
375
|
-
if (this.db)
|
|
803
|
+
if (!this.db) return;
|
|
804
|
+
try {
|
|
805
|
+
this.db.prepare("DELETE FROM sessions WHERE session_id = ?").run(this.sessionId);
|
|
806
|
+
} catch {
|
|
807
|
+
}
|
|
808
|
+
try {
|
|
376
809
|
this.db.close();
|
|
377
|
-
|
|
378
|
-
this.initialized = false;
|
|
810
|
+
} catch {
|
|
379
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);
|
|
380
825
|
}
|
|
381
826
|
addTokensSaved(db, tokens) {
|
|
382
827
|
db.prepare(
|
|
383
828
|
"UPDATE stats SET value = value + ? WHERE key = 'tokens_saved'"
|
|
384
829
|
).run(tokens);
|
|
385
|
-
|
|
386
|
-
"INSERT INTO session_stats (session_id, key, value) VALUES (?, 'tokens_saved', ?) ON CONFLICT(session_id, key) DO UPDATE SET value = value + ?"
|
|
387
|
-
).run(this.sessionId, tokens, tokens);
|
|
830
|
+
this.bumpSessionStat(db, "tokens_saved", tokens);
|
|
388
831
|
}
|
|
389
832
|
};
|
|
390
833
|
|
|
391
834
|
// packages/sdk/src/watcher.ts
|
|
392
|
-
import { watch as fsWatch, existsSync } from "node:fs";
|
|
835
|
+
import { watch as fsWatch, existsSync as existsSync2 } from "node:fs";
|
|
393
836
|
import { resolve as resolve2 } from "node:path";
|
|
394
837
|
var FileWatcher = class {
|
|
395
838
|
watchers = [];
|
|
@@ -403,26 +846,39 @@ var FileWatcher = class {
|
|
|
403
846
|
watch(paths) {
|
|
404
847
|
for (const p of paths) {
|
|
405
848
|
const absPath = resolve2(p);
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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)}
|
|
421
866
|
`
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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);
|
|
426
882
|
});
|
|
427
883
|
this.watchers.push(watcher);
|
|
428
884
|
}
|
|
@@ -435,7 +891,7 @@ var FileWatcher = class {
|
|
|
435
891
|
}
|
|
436
892
|
async handleChange(filePath) {
|
|
437
893
|
try {
|
|
438
|
-
if (!
|
|
894
|
+
if (!existsSync2(filePath)) {
|
|
439
895
|
await this.stash.onFileDeleted(filePath);
|
|
440
896
|
}
|
|
441
897
|
} catch (e) {
|
|
@@ -457,7 +913,7 @@ function createStash(config) {
|
|
|
457
913
|
|
|
458
914
|
// packages/cli/src/index.ts
|
|
459
915
|
import { resolve as resolve4, join as join3 } from "node:path";
|
|
460
|
-
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";
|
|
461
917
|
import { homedir } from "node:os";
|
|
462
918
|
|
|
463
919
|
// packages/cli/src/mcp.ts
|
|
@@ -465,14 +921,28 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
465
921
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
466
922
|
import { z } from "zod";
|
|
467
923
|
import { resolve as resolve3, join, relative, isAbsolute } from "node:path";
|
|
468
|
-
import {
|
|
924
|
+
import { readFileSync as readFileSync2, realpathSync } from "node:fs";
|
|
469
925
|
import { randomUUID } from "node:crypto";
|
|
470
|
-
function
|
|
926
|
+
function resolveStashDir(baseDir = process.cwd()) {
|
|
471
927
|
const raw = process.env.FILESTASH_DIR ?? ".file-stash";
|
|
472
928
|
if (raw.includes("\0")) throw new Error("FILESTASH_DIR contains invalid characters");
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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 } };
|
|
476
946
|
}
|
|
477
947
|
function isPathAllowed(absPath, cwd) {
|
|
478
948
|
let realPath;
|
|
@@ -499,10 +969,90 @@ ${result.diff}`;
|
|
|
499
969
|
return `=== ${path} ===
|
|
500
970
|
${result.content}`;
|
|
501
971
|
}
|
|
502
|
-
|
|
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) {
|
|
503
1053
|
return `
|
|
504
1054
|
|
|
505
|
-
[filestash: ~${
|
|
1055
|
+
[filestash: net ~${(grossSaved - toolDefinitionTokens()).toLocaleString()} tokens this session (est.)]`;
|
|
506
1056
|
}
|
|
507
1057
|
async function readSingleFile(path, cwd, stash) {
|
|
508
1058
|
const absPath = resolve3(path);
|
|
@@ -531,15 +1081,16 @@ async function startMcpServer() {
|
|
|
531
1081
|
} catch {
|
|
532
1082
|
}
|
|
533
1083
|
const META_NAMESPACE = (packageJson.mcpName || "io.github.glommer/filestash").replaceAll("/", ".");
|
|
534
|
-
const
|
|
535
|
-
const dbPath = resolve3(stashDir, "stash.db");
|
|
1084
|
+
const dbPath = resolve3(resolveStashDir(), "stash.db");
|
|
536
1085
|
const cwd = process.cwd();
|
|
537
1086
|
const watchPaths = [cwd];
|
|
538
1087
|
const sessionId = randomUUID();
|
|
539
1088
|
const { stash, watcher } = createStash({
|
|
540
1089
|
dbPath,
|
|
541
1090
|
sessionId,
|
|
542
|
-
watchPaths
|
|
1091
|
+
watchPaths,
|
|
1092
|
+
exclude: parseExcludeEnv(),
|
|
1093
|
+
...limitOptions()
|
|
543
1094
|
});
|
|
544
1095
|
await stash.init();
|
|
545
1096
|
const server = new McpServer({
|
|
@@ -548,21 +1099,7 @@ async function startMcpServer() {
|
|
|
548
1099
|
});
|
|
549
1100
|
server.registerTool(
|
|
550
1101
|
"read_file",
|
|
551
|
-
|
|
552
|
-
description: `Read a file with stashing. Use this tool INSTEAD of the built-in Read tool for reading files.
|
|
553
|
-
On first read, returns full content and stashes it \u2014 identical to Read.
|
|
554
|
-
On subsequent reads, if the file hasn't changed, returns a short confirmation instead of the full content \u2014 saving significant tokens.
|
|
555
|
-
If the file changed, returns only the diff (changed lines) instead of the full file.
|
|
556
|
-
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.
|
|
557
|
-
Set force=true to bypass the stash and get the full file content (use when you no longer have the original in context).
|
|
558
|
-
ALWAYS prefer this over the Read tool. It is a drop-in replacement with stashing benefits.`,
|
|
559
|
-
inputSchema: {
|
|
560
|
-
path: z.string().describe("Path to the file to read"),
|
|
561
|
-
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."),
|
|
562
|
-
limit: z.number().optional().describe("Number of lines to read. Only provide if the file is too large to read at once."),
|
|
563
|
-
force: z.boolean().optional().describe("Bypass stash and return full content")
|
|
564
|
-
}
|
|
565
|
-
},
|
|
1102
|
+
TOOL_DEFS.read_file,
|
|
566
1103
|
async ({ path, force, offset, limit }) => {
|
|
567
1104
|
const absPath = resolve3(path);
|
|
568
1105
|
if (!isPathAllowed(absPath, cwd)) {
|
|
@@ -596,17 +1133,7 @@ ALWAYS prefer this over the Read tool. It is a drop-in replacement with stashing
|
|
|
596
1133
|
);
|
|
597
1134
|
server.registerTool(
|
|
598
1135
|
"read_files",
|
|
599
|
-
|
|
600
|
-
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.
|
|
601
|
-
Same behavior as read_file but batched. Returns stashed/diff results for each file.
|
|
602
|
-
ALWAYS prefer this over multiple Read calls \u2014 it's faster and saves significant tokens.`,
|
|
603
|
-
inputSchema: {
|
|
604
|
-
paths: z.preprocess(
|
|
605
|
-
(val) => typeof val === "string" ? JSON.parse(val) : val,
|
|
606
|
-
z.array(z.string())
|
|
607
|
-
).describe("Paths to the files to read")
|
|
608
|
-
}
|
|
609
|
-
},
|
|
1136
|
+
TOOL_DEFS.read_files,
|
|
610
1137
|
async ({ paths }) => {
|
|
611
1138
|
const results = await Promise.all(paths.map((p) => readSingleFile(p, cwd, stash)));
|
|
612
1139
|
const successfulPaths = paths.filter((_, i) => results[i].ok);
|
|
@@ -627,31 +1154,31 @@ ALWAYS prefer this over multiple Read calls \u2014 it's faster and saves signifi
|
|
|
627
1154
|
);
|
|
628
1155
|
server.registerTool(
|
|
629
1156
|
"stash_status",
|
|
630
|
-
|
|
631
|
-
description: `Show filestash statistics: files tracked, tokens saved, stash hit rates.
|
|
632
|
-
Use this to verify filestash is working and see how many tokens it has saved.`
|
|
633
|
-
},
|
|
1157
|
+
TOOL_DEFS.stash_status,
|
|
634
1158
|
async () => {
|
|
635
1159
|
const stats = await stash.getStats();
|
|
636
|
-
const
|
|
637
|
-
|
|
638
|
-
` Files tracked: ${stats.filesTracked}`,
|
|
639
|
-
` Tokens saved (this session): ~${stats.sessionTokensSaved.toLocaleString()}`,
|
|
640
|
-
` Tokens saved (all sessions): ~${stats.tokensSaved.toLocaleString()}`
|
|
641
|
-
].join("\n");
|
|
1160
|
+
const overhead = toolDefinitionTokens();
|
|
1161
|
+
const text = formatStatus(stats, overhead);
|
|
642
1162
|
return {
|
|
643
1163
|
content: [{ type: "text", text }],
|
|
644
|
-
_meta: {
|
|
1164
|
+
_meta: {
|
|
1165
|
+
[`${META_NAMESPACE}/stats`]: {
|
|
1166
|
+
...stats,
|
|
1167
|
+
toolDefinitionTokens: overhead,
|
|
1168
|
+
netTokensSaved: stats.sessionTokensSaved - overhead
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
645
1171
|
};
|
|
646
1172
|
}
|
|
647
1173
|
);
|
|
648
1174
|
server.registerTool(
|
|
649
1175
|
"stash_clear",
|
|
650
|
-
|
|
651
|
-
description: `Clear all stashed data. Use this to reset the stash completely.`
|
|
652
|
-
},
|
|
1176
|
+
TOOL_DEFS.stash_clear,
|
|
653
1177
|
async () => {
|
|
654
1178
|
await stash.clear();
|
|
1179
|
+
if (stash.isDegraded) {
|
|
1180
|
+
return { content: [{ type: "text", text: "Stash is in degraded mode, nothing to clear." }] };
|
|
1181
|
+
}
|
|
655
1182
|
return {
|
|
656
1183
|
content: [{ type: "text", text: "Stash cleared." }],
|
|
657
1184
|
_meta: { [`${META_NAMESPACE}/cleared`]: true }
|
|
@@ -703,20 +1230,104 @@ process.emitWarning = function(msg, ...args) {
|
|
|
703
1230
|
return _origEmitWarning.apply(process, [msg, ...args]);
|
|
704
1231
|
};
|
|
705
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
|
+
}
|
|
706
1309
|
async function runStatus() {
|
|
707
1310
|
const stashDir = resolve4(process.env.FILESTASH_DIR ?? ".file-stash");
|
|
708
1311
|
const dbPath = join3(stashDir, "stash.db");
|
|
709
1312
|
if (!existsSync4(dbPath)) {
|
|
710
|
-
console.log("No filestash database found. Run '
|
|
1313
|
+
console.log("No filestash database found. Run 'agent-file-stash serve' to start stashing.");
|
|
711
1314
|
process.exit(0);
|
|
712
1315
|
}
|
|
713
|
-
const { stash } = createStash({ dbPath, sessionId: CLI_STATUS_SESSION });
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
+
}
|
|
720
1331
|
}
|
|
721
1332
|
async function runStatusAll(root) {
|
|
722
1333
|
const databases = findStashDatabases(resolve4(root));
|
|
@@ -726,10 +1337,11 @@ async function runStatusAll(root) {
|
|
|
726
1337
|
}
|
|
727
1338
|
const rows = [];
|
|
728
1339
|
for (const dbPath of databases) {
|
|
729
|
-
const { stash } = createStash({ dbPath, sessionId: CLI_STATUS_SESSION });
|
|
1340
|
+
const { stash } = createStash({ dbPath, sessionId: CLI_STATUS_SESSION, recoverCorrupt: false, quiet: true });
|
|
730
1341
|
try {
|
|
731
1342
|
await stash.init();
|
|
732
1343
|
const stats = await stash.getStats();
|
|
1344
|
+
assertHealthy(stash);
|
|
733
1345
|
rows.push({ project: resolve4(dbPath, "../..").replace(/\/\.vscode$/, ""), files: stats.filesTracked, tokens: stats.tokensSaved });
|
|
734
1346
|
} catch (e) {
|
|
735
1347
|
console.error(` skipped ${dbPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -748,7 +1360,7 @@ async function runStatusAll(root) {
|
|
|
748
1360
|
console.log(`
|
|
749
1361
|
Savings count re-reads within a session (unchanged files and diffs); a new session always receives full content.`);
|
|
750
1362
|
}
|
|
751
|
-
async function runInit() {
|
|
1363
|
+
async function runInit(withHooks) {
|
|
752
1364
|
const home = homedir();
|
|
753
1365
|
const mcpServersEntry = {
|
|
754
1366
|
command: "npx",
|
|
@@ -798,16 +1410,16 @@ async function runInit() {
|
|
|
798
1410
|
continue;
|
|
799
1411
|
}
|
|
800
1412
|
config[target.key] = { ...section, filestash: target.entry };
|
|
801
|
-
|
|
1413
|
+
writeFileSync2(target.path, JSON.stringify(config, null, 2) + "\n");
|
|
802
1414
|
console.log(` ${target.name}: configured (${target.path})`);
|
|
803
1415
|
configured++;
|
|
804
1416
|
}
|
|
805
1417
|
if (configured === 0) {
|
|
806
|
-
console.log("No supported tools detected. You can manually add
|
|
1418
|
+
console.log("No supported tools detected. You can manually add agent-file-stash to your MCP config:");
|
|
807
1419
|
console.log(JSON.stringify({ mcpServers: { "agent-file-stash": mcpServersEntry } }, null, 2));
|
|
808
1420
|
} else {
|
|
809
1421
|
console.log(`
|
|
810
|
-
Done! Restart your editor to pick up
|
|
1422
|
+
Done! Restart your editor to pick up agent-file-stash.`);
|
|
811
1423
|
console.log(`
|
|
812
1424
|
Available MCP tools:`);
|
|
813
1425
|
console.log(` read_file Read a file, returning only a diff if unchanged since last read`);
|
|
@@ -817,12 +1429,25 @@ Available MCP tools:`);
|
|
|
817
1429
|
console.log(`
|
|
818
1430
|
Stash location: FILESTASH_DIR env var (default: .file-stash in cwd)`);
|
|
819
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
|
+
}
|
|
820
1441
|
}
|
|
821
1442
|
function runHelp() {
|
|
822
1443
|
console.log(`agent-file-stash - Agent file stash with diff tracking
|
|
823
1444
|
|
|
824
1445
|
Usage:
|
|
825
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)
|
|
826
1451
|
agent-file-stash serve Start the MCP server (default)
|
|
827
1452
|
agent-file-stash status Show stash statistics
|
|
828
1453
|
agent-file-stash status --all [dir]
|
|
@@ -830,7 +1455,10 @@ Usage:
|
|
|
830
1455
|
agent-file-stash help Show this help message
|
|
831
1456
|
|
|
832
1457
|
Environment:
|
|
833
|
-
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)`);
|
|
834
1462
|
}
|
|
835
1463
|
var command = process.argv[2];
|
|
836
1464
|
if (!command || command === "serve") {
|
|
@@ -840,10 +1468,12 @@ if (!command || command === "serve") {
|
|
|
840
1468
|
} else if (command === "status") {
|
|
841
1469
|
await runStatus();
|
|
842
1470
|
} else if (command === "init") {
|
|
843
|
-
await runInit();
|
|
1471
|
+
await runInit(process.argv.includes("--hooks"));
|
|
1472
|
+
} else if (command === "reset") {
|
|
1473
|
+
await runReset(process.argv.includes("--from-hook"));
|
|
844
1474
|
} else if (command === "help" || command === "--help") {
|
|
845
1475
|
runHelp();
|
|
846
1476
|
} else {
|
|
847
|
-
console.error(`Unknown command: ${command}. Run '
|
|
1477
|
+
console.error(`Unknown command: ${command}. Run 'agent-file-stash help' for usage.`);
|
|
848
1478
|
process.exit(1);
|
|
849
1479
|
}
|