agent-file-stash 0.4.0 → 0.6.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 +215 -41
- package/dist/cli.mjs +1435 -312
- package/package.json +4 -1
package/dist/cli.mjs
CHANGED
|
@@ -1,45 +1,117 @@
|
|
|
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
|
|
9
9
|
var CONTEXT = 3;
|
|
10
|
+
var MAX_EDIT_DISTANCE = 2500;
|
|
11
|
+
var MAX_SNAKE_STEPS = 2e7;
|
|
10
12
|
function computeDiff(oldContent, newContent, filePath) {
|
|
11
13
|
const oldLines = oldContent.split("\n");
|
|
12
14
|
const newLines = newContent.split("\n");
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
const shortest = Math.min(oldLines.length, newLines.length);
|
|
16
|
+
let prefix = 0;
|
|
17
|
+
while (prefix < shortest && oldLines[prefix] === newLines[prefix]) prefix++;
|
|
18
|
+
let suffix = 0;
|
|
19
|
+
while (suffix < shortest - prefix && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]) suffix++;
|
|
20
|
+
const n = oldLines.length - prefix - suffix;
|
|
21
|
+
const m = newLines.length - prefix - suffix;
|
|
22
|
+
if (n === 0 && m === 0) {
|
|
23
|
+
return { diff: "", linesChanged: 0, hasChanges: false, changedNewLines: /* @__PURE__ */ new Set() };
|
|
18
24
|
}
|
|
25
|
+
const edits = myersEdits(oldLines, newLines, prefix, n, m) ?? {
|
|
26
|
+
removed: new Uint8Array(n).fill(1),
|
|
27
|
+
added: new Uint8Array(m).fill(1)
|
|
28
|
+
};
|
|
29
|
+
const { rawLines, linesChanged } = buildRawLines(oldLines, newLines, prefix, suffix, edits);
|
|
30
|
+
const changedNewLines = collectChangedLines(rawLines);
|
|
19
31
|
const hunkGroups = groupIntoHunks(rawLines);
|
|
20
32
|
const diff = formatDiff(hunkGroups, filePath);
|
|
21
33
|
return { diff, linesChanged, hasChanges: true, changedNewLines };
|
|
22
34
|
}
|
|
23
|
-
function
|
|
35
|
+
function myersEdits(oldLines, newLines, off, n, m) {
|
|
36
|
+
const removed = new Uint8Array(n);
|
|
37
|
+
const added = new Uint8Array(m);
|
|
38
|
+
if (n === 0 || m === 0) return { removed: removed.fill(1), added: added.fill(1) };
|
|
39
|
+
if (Math.abs(n - m) > MAX_EDIT_DISTANCE) return null;
|
|
40
|
+
const max = Math.min(n + m, MAX_EDIT_DISTANCE);
|
|
41
|
+
const base = max + 1;
|
|
42
|
+
const v = new Int32Array(2 * max + 3);
|
|
43
|
+
const trace = [];
|
|
44
|
+
let steps = 0;
|
|
45
|
+
let found = -1;
|
|
46
|
+
for (let d = 0; d <= max && found < 0; d++) {
|
|
47
|
+
for (let k = -d; k <= d; k += 2) {
|
|
48
|
+
let x2 = k === -d || k !== d && v[base + k - 1] < v[base + k + 1] ? v[base + k + 1] : v[base + k - 1] + 1;
|
|
49
|
+
let y2 = x2 - k;
|
|
50
|
+
while (x2 < n && y2 < m && oldLines[off + x2] === newLines[off + y2]) {
|
|
51
|
+
x2++;
|
|
52
|
+
y2++;
|
|
53
|
+
steps++;
|
|
54
|
+
}
|
|
55
|
+
if (steps > MAX_SNAKE_STEPS) return null;
|
|
56
|
+
v[base + k] = x2;
|
|
57
|
+
if (x2 >= n && y2 >= m) {
|
|
58
|
+
found = d;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (found >= 0) break;
|
|
63
|
+
const snapshot = new Int32Array(d + 1);
|
|
64
|
+
for (let k = -d, i = 0; k <= d; k += 2, i++) snapshot[i] = v[base + k];
|
|
65
|
+
trace.push(snapshot);
|
|
66
|
+
}
|
|
67
|
+
if (found < 0) return null;
|
|
68
|
+
let x = n, y = m;
|
|
69
|
+
for (let d = found; d > 0; d--) {
|
|
70
|
+
const k = x - y;
|
|
71
|
+
const prev = trace[d - 1];
|
|
72
|
+
const at = (diagonal) => prev[diagonal + d - 1 >> 1];
|
|
73
|
+
const down = k === -d || k !== d && at(k - 1) < at(k + 1);
|
|
74
|
+
const prevK = down ? k + 1 : k - 1;
|
|
75
|
+
const prevX = at(prevK);
|
|
76
|
+
const prevY = prevX - prevK;
|
|
77
|
+
if (down) added[prevY] = 1;
|
|
78
|
+
else removed[prevX] = 1;
|
|
79
|
+
x = prevX;
|
|
80
|
+
y = prevY;
|
|
81
|
+
}
|
|
82
|
+
return { removed, added };
|
|
83
|
+
}
|
|
84
|
+
function buildRawLines(oldLines, newLines, prefix, suffix, edits) {
|
|
24
85
|
const rawLines = [];
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
86
|
+
const { removed, added } = edits;
|
|
87
|
+
let linesChanged = 0;
|
|
88
|
+
for (let i = 0; i < prefix; i++) {
|
|
89
|
+
rawLines.push({ type: "keep", line: oldLines[i], oldLine: i + 1, newLine: i + 1 });
|
|
90
|
+
}
|
|
91
|
+
let oi = 0, ni = 0;
|
|
92
|
+
while (oi < removed.length || ni < added.length) {
|
|
93
|
+
if (oi < removed.length && ni < added.length && !removed[oi] && !added[ni]) {
|
|
94
|
+
rawLines.push({ type: "keep", line: oldLines[prefix + oi], oldLine: prefix + oi + 1, newLine: prefix + ni + 1 });
|
|
95
|
+
oi++;
|
|
96
|
+
ni++;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
while (ni < added.length && added[ni]) {
|
|
100
|
+
rawLines.push({ type: "add", line: newLines[prefix + ni], oldLine: prefix + oi + 1, newLine: prefix + ni + 1 });
|
|
101
|
+
ni++;
|
|
36
102
|
linesChanged++;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
103
|
+
}
|
|
104
|
+
while (oi < removed.length && removed[oi]) {
|
|
105
|
+
rawLines.push({ type: "remove", line: oldLines[prefix + oi], oldLine: prefix + oi + 1, newLine: prefix + ni + 1 });
|
|
106
|
+
oi++;
|
|
40
107
|
linesChanged++;
|
|
41
108
|
}
|
|
42
109
|
}
|
|
110
|
+
const oldTail = prefix + removed.length;
|
|
111
|
+
const newTail = prefix + added.length;
|
|
112
|
+
for (let i = 0; i < suffix; i++) {
|
|
113
|
+
rawLines.push({ type: "keep", line: oldLines[oldTail + i], oldLine: oldTail + i + 1, newLine: newTail + i + 1 });
|
|
114
|
+
}
|
|
43
115
|
return { rawLines, linesChanged };
|
|
44
116
|
}
|
|
45
117
|
function collectChangedLines(rawLines) {
|
|
@@ -83,13 +155,8 @@ function groupIntoHunks(rawLines) {
|
|
|
83
155
|
}
|
|
84
156
|
function fillContextGap(current, rawLines, lastChangeIdx, upTo) {
|
|
85
157
|
const contextEnd = lastChangeIdx + CONTEXT + 1;
|
|
86
|
-
const inCurrent = new Set(current);
|
|
87
158
|
for (let c = contextEnd; c < upTo; c++) {
|
|
88
|
-
|
|
89
|
-
if (!inCurrent.has(line)) {
|
|
90
|
-
current.push(line);
|
|
91
|
-
inCurrent.add(line);
|
|
92
|
-
}
|
|
159
|
+
current.push(rawLines[c]);
|
|
93
160
|
}
|
|
94
161
|
}
|
|
95
162
|
function formatDiff(hunkGroups, filePath) {
|
|
@@ -108,41 +175,41 @@ function formatDiff(hunkGroups, filePath) {
|
|
|
108
175
|
}
|
|
109
176
|
return lines.join("\n");
|
|
110
177
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return
|
|
178
|
+
|
|
179
|
+
// packages/sdk/src/exclude.ts
|
|
180
|
+
import { basename, dirname } from "node:path";
|
|
181
|
+
var DEFAULT_PATTERNS = [
|
|
182
|
+
".env",
|
|
183
|
+
".env.*",
|
|
184
|
+
"*.pem",
|
|
185
|
+
"*.key",
|
|
186
|
+
"*.p12",
|
|
187
|
+
"*.pfx",
|
|
188
|
+
"*.keystore",
|
|
189
|
+
"id_rsa*",
|
|
190
|
+
"id_ed25519*",
|
|
191
|
+
"id_ecdsa*",
|
|
192
|
+
".npmrc",
|
|
193
|
+
".netrc",
|
|
194
|
+
"credentials*",
|
|
195
|
+
"secrets.*"
|
|
196
|
+
];
|
|
197
|
+
var ALLOWED_NAMES = /* @__PURE__ */ new Set([".env.example", ".env.sample", ".env.template", ".env.dist"]);
|
|
198
|
+
var SECRET_DIRS = /* @__PURE__ */ new Set([".ssh", ".aws", ".gnupg"]);
|
|
199
|
+
function globToRegExp(glob) {
|
|
200
|
+
const body = glob.toLowerCase().replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
201
|
+
return new RegExp(`^${body}$`);
|
|
202
|
+
}
|
|
203
|
+
var DEFAULT_MATCHERS = DEFAULT_PATTERNS.map(globToRegExp);
|
|
204
|
+
function isExcludedPath(absPath, extraPatterns = []) {
|
|
205
|
+
const name = basename(absPath).toLowerCase();
|
|
206
|
+
if (extraPatterns.some((p) => globToRegExp(p.trim()).test(name))) return true;
|
|
207
|
+
if (!ALLOWED_NAMES.has(name) && !name.endsWith(".pub") && DEFAULT_MATCHERS.some((re) => re.test(name))) return true;
|
|
208
|
+
return dirname(absPath).split(/[\\/]/).some((seg) => SECRET_DIRS.has(seg.toLowerCase()));
|
|
142
209
|
}
|
|
143
210
|
|
|
144
|
-
// packages/sdk/src/
|
|
145
|
-
var
|
|
211
|
+
// packages/sdk/src/migrations.ts
|
|
212
|
+
var SCHEMA_V1 = `
|
|
146
213
|
CREATE TABLE IF NOT EXISTS file_versions (
|
|
147
214
|
path TEXT NOT NULL,
|
|
148
215
|
hash TEXT NOT NULL,
|
|
@@ -160,6 +227,16 @@ CREATE TABLE IF NOT EXISTS session_reads (
|
|
|
160
227
|
PRIMARY KEY (session_id, path)
|
|
161
228
|
);
|
|
162
229
|
|
|
230
|
+
CREATE TABLE IF NOT EXISTS session_ranges (
|
|
231
|
+
session_id TEXT NOT NULL,
|
|
232
|
+
path TEXT NOT NULL,
|
|
233
|
+
hash TEXT NOT NULL,
|
|
234
|
+
start_line INTEGER NOT NULL,
|
|
235
|
+
end_line INTEGER NOT NULL
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
CREATE INDEX IF NOT EXISTS idx_session_ranges ON session_ranges (session_id, path);
|
|
239
|
+
|
|
163
240
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
164
241
|
session_id TEXT PRIMARY KEY,
|
|
165
242
|
pid INTEGER NOT NULL
|
|
@@ -179,6 +256,83 @@ CREATE TABLE IF NOT EXISTS session_stats (
|
|
|
179
256
|
|
|
180
257
|
INSERT OR IGNORE INTO stats (key, value) VALUES ('tokens_saved', 0);
|
|
181
258
|
`;
|
|
259
|
+
var MIGRATIONS = [{ version: 1, up: (db) => db.exec(SCHEMA_V1) }];
|
|
260
|
+
var SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
|
261
|
+
var SchemaTooNewError = class extends Error {
|
|
262
|
+
constructor(found, supported) {
|
|
263
|
+
super(`database schema version ${found} is newer than this release supports (${supported}); upgrade agent-file-stash`);
|
|
264
|
+
this.name = "SchemaTooNewError";
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
function readUserVersion(db) {
|
|
268
|
+
const row = db.prepare("PRAGMA user_version").get();
|
|
269
|
+
return row.user_version;
|
|
270
|
+
}
|
|
271
|
+
function runMigrations(db, migrations = MIGRATIONS) {
|
|
272
|
+
const target = migrations[migrations.length - 1].version;
|
|
273
|
+
const check = (version) => {
|
|
274
|
+
if (version > target) throw new SchemaTooNewError(version, target);
|
|
275
|
+
};
|
|
276
|
+
check(readUserVersion(db));
|
|
277
|
+
for (const step of migrations) {
|
|
278
|
+
if (readUserVersion(db) >= step.version) continue;
|
|
279
|
+
db.exec("BEGIN IMMEDIATE");
|
|
280
|
+
try {
|
|
281
|
+
const current = readUserVersion(db);
|
|
282
|
+
check(current);
|
|
283
|
+
if (current < step.version) {
|
|
284
|
+
step.up(db);
|
|
285
|
+
db.exec(`PRAGMA user_version = ${step.version}`);
|
|
286
|
+
}
|
|
287
|
+
db.exec("COMMIT");
|
|
288
|
+
} catch (err) {
|
|
289
|
+
try {
|
|
290
|
+
db.exec("ROLLBACK");
|
|
291
|
+
} catch {
|
|
292
|
+
}
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// packages/sdk/src/stash.ts
|
|
299
|
+
var DEFAULT_MAX_LINES = 2e3;
|
|
300
|
+
var DEFAULT_MAX_CHARS = 1e5;
|
|
301
|
+
var DEFAULT_MAX_STORE_BYTES = 1e6;
|
|
302
|
+
var HARD_MAX_READ_BYTES = 64 * 1024 * 1024;
|
|
303
|
+
var BINARY_SNIFF_CHARS = 8192;
|
|
304
|
+
var SCOPE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
305
|
+
var SCOPE_DELIMITER = "::";
|
|
306
|
+
var MAX_SCOPES_PER_SESSION = 32;
|
|
307
|
+
function positiveOr(value, fallback) {
|
|
308
|
+
return Number.isInteger(value) && value > 0 ? value : fallback;
|
|
309
|
+
}
|
|
310
|
+
function sliceLines(content, offset, limit, maxLines, maxChars) {
|
|
311
|
+
const lines = content.split("\n");
|
|
312
|
+
const total = lines.length;
|
|
313
|
+
const startIdx = offset > 0 ? offset - 1 : 0;
|
|
314
|
+
const start = startIdx + 1;
|
|
315
|
+
const requestedEnd = limit > 0 ? Math.min(startIdx + limit, total) : total;
|
|
316
|
+
const maxEnd = Math.min(requestedEnd, startIdx + maxLines);
|
|
317
|
+
let chars = 0;
|
|
318
|
+
let i = startIdx;
|
|
319
|
+
for (; i < maxEnd; i++) {
|
|
320
|
+
const next = chars + lines[i].length + (i > startIdx ? 1 : 0);
|
|
321
|
+
if (next > maxChars) break;
|
|
322
|
+
chars = next;
|
|
323
|
+
}
|
|
324
|
+
if (i >= requestedEnd) {
|
|
325
|
+
return { text: lines.slice(startIdx, i).join("\n"), start, end: limit > 0 ? start + limit - 1 : total, midLine: false, truncated: false };
|
|
326
|
+
}
|
|
327
|
+
if (i > startIdx) {
|
|
328
|
+
const notice2 = `[filestash: truncated, showing lines ${start}-${i} of ${total}; continue with offset=${i + 1}]`;
|
|
329
|
+
return { text: `${lines.slice(startIdx, i).join("\n")}
|
|
330
|
+
${notice2}`, start, end: i, midLine: false, truncated: true };
|
|
331
|
+
}
|
|
332
|
+
const notice = `[filestash: truncated, showing the first ${maxChars} chars of line ${start} of ${total}; continue with offset=${start + 1}]`;
|
|
333
|
+
return { text: `${lines[startIdx].slice(0, maxChars)}
|
|
334
|
+
${notice}`, start, end: start - 1, midLine: true, truncated: true };
|
|
335
|
+
}
|
|
182
336
|
function estimateTokens(text) {
|
|
183
337
|
return Math.ceil(text.length / 4);
|
|
184
338
|
}
|
|
@@ -186,6 +340,19 @@ var HASH_LENGTH = 16;
|
|
|
186
340
|
function contentHash(content) {
|
|
187
341
|
return createHash("sha256").update(content).digest("hex").slice(0, HASH_LENGTH);
|
|
188
342
|
}
|
|
343
|
+
function mergeIntervals(intervals) {
|
|
344
|
+
const sorted = intervals.filter(([a, b]) => b >= a).sort((x, y) => x[0] - y[0]);
|
|
345
|
+
const merged = [];
|
|
346
|
+
for (const [a, b] of sorted) {
|
|
347
|
+
const last = merged[merged.length - 1];
|
|
348
|
+
if (last && a <= last[1] + 1) last[1] = Math.max(last[1], b);
|
|
349
|
+
else merged.push([a, b]);
|
|
350
|
+
}
|
|
351
|
+
return merged;
|
|
352
|
+
}
|
|
353
|
+
function coversRange(intervals, start, end) {
|
|
354
|
+
return end >= start && intervals.some(([a, b]) => a <= start && b >= end);
|
|
355
|
+
}
|
|
189
356
|
function isProcessAlive(pid) {
|
|
190
357
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
191
358
|
try {
|
|
@@ -195,6 +362,53 @@ function isProcessAlive(pid) {
|
|
|
195
362
|
return err.code === "EPERM";
|
|
196
363
|
}
|
|
197
364
|
}
|
|
365
|
+
var SQLITE_BUSY = 5;
|
|
366
|
+
var SQLITE_LOCKED = 6;
|
|
367
|
+
var SQLITE_IOERR = 10;
|
|
368
|
+
var SQLITE_CORRUPT = 11;
|
|
369
|
+
var SQLITE_NOTADB = 26;
|
|
370
|
+
var OPEN_ATTEMPTS = 6;
|
|
371
|
+
var OPEN_BACKOFF_MS = 150;
|
|
372
|
+
var RECOVERY_LOCK_STALE_MS = 15e3;
|
|
373
|
+
var RECOVERY_LOCK_WAIT_MS = 8e3;
|
|
374
|
+
var RECOVERY_LOCK_POLL_MS = 50;
|
|
375
|
+
function errorMessage(err) {
|
|
376
|
+
return err instanceof Error ? err.message : String(err);
|
|
377
|
+
}
|
|
378
|
+
function sleep(ms) {
|
|
379
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
380
|
+
}
|
|
381
|
+
function isCorruptError(err) {
|
|
382
|
+
const code = err.errcode;
|
|
383
|
+
if (typeof code === "number" && [SQLITE_CORRUPT, SQLITE_NOTADB].includes(code & 255)) return true;
|
|
384
|
+
return /not a database|malformed|corrupt/i.test(errorMessage(err));
|
|
385
|
+
}
|
|
386
|
+
function isTransientError(err) {
|
|
387
|
+
const code = err.errcode;
|
|
388
|
+
if (typeof code === "number" && [SQLITE_BUSY, SQLITE_LOCKED, SQLITE_IOERR].includes(code & 255)) return true;
|
|
389
|
+
return /database is locked|database is busy|disk i\/o error/i.test(errorMessage(err));
|
|
390
|
+
}
|
|
391
|
+
function tryAcquireLock(lockPath) {
|
|
392
|
+
try {
|
|
393
|
+
writeFileSync(lockPath, String(process.pid), { flag: "wx" });
|
|
394
|
+
return true;
|
|
395
|
+
} catch (err) {
|
|
396
|
+
if (err.code !== "EEXIST") throw err;
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
if (Date.now() - statSync(lockPath).mtimeMs < RECOVERY_LOCK_STALE_MS) return false;
|
|
400
|
+
rmSync(lockPath, { force: true });
|
|
401
|
+
writeFileSync(lockPath, String(process.pid), { flag: "wx" });
|
|
402
|
+
return true;
|
|
403
|
+
} catch {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
async function waitForLockRelease(lockPath) {
|
|
408
|
+
const deadline = Date.now() + RECOVERY_LOCK_WAIT_MS;
|
|
409
|
+
while (existsSync(lockPath) && Date.now() < deadline) await sleep(RECOVERY_LOCK_POLL_MS);
|
|
410
|
+
}
|
|
411
|
+
var BASE_SESSION_SQL = `(CASE WHEN instr(session_id, '${SCOPE_DELIMITER}') > 0 THEN substr(session_id, 1, instr(session_id, '${SCOPE_DELIMITER}') - 1) ELSE session_id END)`;
|
|
198
412
|
function queryOne(db, sql, params = []) {
|
|
199
413
|
const rows = db.prepare(sql).all(...params);
|
|
200
414
|
return rows[0];
|
|
@@ -203,91 +417,269 @@ var StashStore = class {
|
|
|
203
417
|
db = null;
|
|
204
418
|
dbPath;
|
|
205
419
|
sessionId;
|
|
420
|
+
exclude;
|
|
206
421
|
initialized = false;
|
|
422
|
+
recoverCorrupt;
|
|
423
|
+
quiet;
|
|
424
|
+
maxLines;
|
|
425
|
+
maxChars;
|
|
426
|
+
maxStoreBytes;
|
|
427
|
+
recoveryAttempted = false;
|
|
428
|
+
recoveredFrom;
|
|
429
|
+
reason;
|
|
207
430
|
constructor(config) {
|
|
208
431
|
this.dbPath = config.dbPath;
|
|
209
|
-
this.sessionId = config.sessionId;
|
|
432
|
+
this.sessionId = config.sessionId.replace(/:{2,}/g, "_");
|
|
433
|
+
this.exclude = config.exclude ?? [];
|
|
434
|
+
this.recoverCorrupt = config.recoverCorrupt ?? true;
|
|
435
|
+
this.quiet = config.quiet ?? false;
|
|
436
|
+
this.maxLines = positiveOr(config.maxLines, DEFAULT_MAX_LINES);
|
|
437
|
+
this.maxChars = positiveOr(config.maxChars, DEFAULT_MAX_CHARS);
|
|
438
|
+
this.maxStoreBytes = positiveOr(config.maxStoreBytes, DEFAULT_MAX_STORE_BYTES);
|
|
439
|
+
}
|
|
440
|
+
get isDegraded() {
|
|
441
|
+
return this.reason !== void 0;
|
|
442
|
+
}
|
|
443
|
+
get degradedReason() {
|
|
444
|
+
return this.reason;
|
|
210
445
|
}
|
|
211
446
|
async init() {
|
|
212
447
|
if (this.initialized) return;
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
this.registerSession(this.db);
|
|
219
|
-
this.pruneClosedSessions(this.db);
|
|
448
|
+
try {
|
|
449
|
+
this.db = await this.openWithRetry();
|
|
450
|
+
} catch (err) {
|
|
451
|
+
await this.recoverOrDegrade(err);
|
|
452
|
+
}
|
|
220
453
|
this.initialized = true;
|
|
221
454
|
}
|
|
455
|
+
async openWithRetry() {
|
|
456
|
+
for (let attempt = 1; ; attempt++) {
|
|
457
|
+
try {
|
|
458
|
+
return await this.openDb();
|
|
459
|
+
} catch (err) {
|
|
460
|
+
if (attempt >= OPEN_ATTEMPTS || !isTransientError(err)) throw err;
|
|
461
|
+
await sleep(OPEN_BACKOFF_MS * attempt);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
async openDb() {
|
|
466
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
467
|
+
mkdirSync(dirname2(this.dbPath), { recursive: true, mode: 448 });
|
|
468
|
+
const db = new DatabaseSync(this.dbPath);
|
|
469
|
+
try {
|
|
470
|
+
db.exec("PRAGMA busy_timeout=5000");
|
|
471
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
472
|
+
runMigrations(db);
|
|
473
|
+
this.registerSession(db);
|
|
474
|
+
this.pruneClosedSessions(db);
|
|
475
|
+
} catch (err) {
|
|
476
|
+
try {
|
|
477
|
+
db.close();
|
|
478
|
+
} catch {
|
|
479
|
+
}
|
|
480
|
+
throw err;
|
|
481
|
+
}
|
|
482
|
+
this.restrictPermissions();
|
|
483
|
+
return db;
|
|
484
|
+
}
|
|
485
|
+
async recoverOrDegrade(err) {
|
|
486
|
+
if (!this.recoverCorrupt || this.recoveryAttempted || !isCorruptError(err)) return this.degrade(err);
|
|
487
|
+
this.recoveryAttempted = true;
|
|
488
|
+
const lockPath = `${this.dbPath}.recover.lock`;
|
|
489
|
+
try {
|
|
490
|
+
if (!tryAcquireLock(lockPath)) {
|
|
491
|
+
await waitForLockRelease(lockPath);
|
|
492
|
+
this.db = await this.openWithRetry();
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
try {
|
|
496
|
+
try {
|
|
497
|
+
this.db = await this.openWithRetry();
|
|
498
|
+
return;
|
|
499
|
+
} catch (again) {
|
|
500
|
+
if (!isCorruptError(again)) throw again;
|
|
501
|
+
}
|
|
502
|
+
const moved = this.moveAside();
|
|
503
|
+
this.db = await this.openWithRetry();
|
|
504
|
+
this.recoveredFrom = moved;
|
|
505
|
+
this.notice(`${this.dbPath} is corrupt (${errorMessage(err)}); moved to ${moved}, starting a new stash`);
|
|
506
|
+
} finally {
|
|
507
|
+
rmSync(lockPath, { force: true });
|
|
508
|
+
}
|
|
509
|
+
} catch (retryErr) {
|
|
510
|
+
this.degrade(retryErr);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
moveAside() {
|
|
514
|
+
const target = `${this.dbPath}.corrupt-${Date.now()}`;
|
|
515
|
+
for (const suffix of ["-wal", "-shm", ""]) {
|
|
516
|
+
if (!existsSync(this.dbPath + suffix)) continue;
|
|
517
|
+
try {
|
|
518
|
+
renameSync(this.dbPath + suffix, target + suffix);
|
|
519
|
+
} catch (e) {
|
|
520
|
+
if (e.code !== "ENOENT") throw e;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return target;
|
|
524
|
+
}
|
|
525
|
+
notice(message) {
|
|
526
|
+
if (!this.quiet) process.stderr.write(`[filestash] ${message}
|
|
527
|
+
`);
|
|
528
|
+
}
|
|
529
|
+
degrade(err) {
|
|
530
|
+
if (this.reason !== void 0) return;
|
|
531
|
+
this.reason = errorMessage(err);
|
|
532
|
+
this.notice(`stash unavailable (${this.reason}); files are read normally, nothing is stashed`);
|
|
533
|
+
try {
|
|
534
|
+
this.db?.close();
|
|
535
|
+
} catch {
|
|
536
|
+
}
|
|
537
|
+
this.db = null;
|
|
538
|
+
}
|
|
539
|
+
restrictPermissions() {
|
|
540
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
541
|
+
try {
|
|
542
|
+
chmodSync(this.dbPath + suffix, 384);
|
|
543
|
+
} catch {
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
222
547
|
registerSession(db) {
|
|
223
548
|
db.prepare("INSERT OR REPLACE INTO sessions (session_id, pid) VALUES (?, ?)").run(this.sessionId, process.pid);
|
|
224
549
|
}
|
|
225
550
|
pruneClosedSessions(db) {
|
|
226
551
|
db.exec("BEGIN IMMEDIATE");
|
|
227
552
|
try {
|
|
553
|
+
this.purgeExcluded(db);
|
|
228
554
|
const sessions = db.prepare("SELECT session_id, pid FROM sessions").all();
|
|
229
555
|
for (const s of sessions) {
|
|
230
556
|
if (s.session_id !== this.sessionId && !isProcessAlive(s.pid)) {
|
|
231
557
|
db.prepare("DELETE FROM sessions WHERE session_id = ?").run(s.session_id);
|
|
232
558
|
}
|
|
233
559
|
}
|
|
234
|
-
|
|
560
|
+
for (const table of ["session_reads", "session_ranges"]) {
|
|
561
|
+
db.exec(`DELETE FROM ${table} WHERE ${BASE_SESSION_SQL} NOT IN (SELECT session_id FROM sessions)`);
|
|
562
|
+
}
|
|
235
563
|
db.exec("DELETE FROM session_stats WHERE session_id NOT IN (SELECT session_id FROM sessions)");
|
|
236
564
|
db.exec(
|
|
237
565
|
"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
566
|
);
|
|
239
567
|
db.exec("COMMIT");
|
|
240
568
|
} catch (err) {
|
|
241
|
-
|
|
569
|
+
try {
|
|
570
|
+
db.exec("ROLLBACK");
|
|
571
|
+
} catch {
|
|
572
|
+
}
|
|
242
573
|
throw err;
|
|
243
574
|
}
|
|
244
575
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
576
|
+
purgeExcluded(db) {
|
|
577
|
+
for (const table of ["file_versions", "session_reads", "session_ranges"]) {
|
|
578
|
+
const rows = db.prepare(`SELECT DISTINCT path FROM ${table}`).all();
|
|
579
|
+
for (const { path } of rows) {
|
|
580
|
+
if (isExcludedPath(path, this.exclude)) db.prepare(`DELETE FROM ${table} WHERE path = ?`).run(path);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
248
583
|
}
|
|
249
584
|
readFileSnapshot(filePath) {
|
|
250
585
|
const absPath = resolve(filePath);
|
|
251
|
-
statSync(absPath);
|
|
586
|
+
const size = statSync(absPath).size;
|
|
587
|
+
const now = Date.now();
|
|
588
|
+
if (size > HARD_MAX_READ_BYTES) {
|
|
589
|
+
const result2 = { stashed: false, content: `[filestash: file too large (${size} bytes); not read]`, hash: "" };
|
|
590
|
+
return { absPath, size, content: "", hash: "", lines: 0, now, early: { result: result2, countRead: false } };
|
|
591
|
+
}
|
|
252
592
|
const content = readFileSync(absPath, "utf-8");
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
}
|
|
593
|
+
const hash = contentHash(content);
|
|
594
|
+
const lines = content.split("\n").length;
|
|
595
|
+
const snap = { absPath, size, content, hash, lines, now };
|
|
596
|
+
if (content.slice(0, BINARY_SNIFF_CHARS).includes("\0")) {
|
|
597
|
+
const result2 = { stashed: false, content: `[filestash: binary file (${size} bytes), not shown]`, hash, totalLines: lines };
|
|
598
|
+
snap.early = { result: result2, countRead: true };
|
|
599
|
+
}
|
|
600
|
+
return snap;
|
|
601
|
+
}
|
|
602
|
+
earlyResult(snap) {
|
|
603
|
+
const { result: result2, countRead } = snap.early;
|
|
604
|
+
if (countRead) {
|
|
605
|
+
const tokens = estimateTokens(result2.content);
|
|
606
|
+
this.guarded((db) => this.recordRead(db, tokens, tokens));
|
|
607
|
+
}
|
|
608
|
+
return result2;
|
|
609
|
+
}
|
|
610
|
+
isStoreable(snap) {
|
|
611
|
+
return snap.size <= this.maxStoreBytes && !isExcludedPath(snap.absPath, this.exclude);
|
|
260
612
|
}
|
|
261
613
|
async readFile(filePath, options) {
|
|
262
614
|
await this.init();
|
|
263
|
-
const
|
|
264
|
-
|
|
615
|
+
const snap = this.readFileSnapshot(filePath);
|
|
616
|
+
if (snap.early) return this.earlyResult(snap);
|
|
617
|
+
const { absPath, content: currentContent, hash: currentHash, lines: currentLines, now } = snap;
|
|
265
618
|
const offset = options?.offset ?? 0;
|
|
266
619
|
const limit = options?.limit ?? 0;
|
|
267
|
-
const
|
|
620
|
+
const slice = sliceLines(currentContent, offset, limit, this.maxLines, this.maxChars);
|
|
268
621
|
const state = {
|
|
269
622
|
absPath,
|
|
270
623
|
currentContent,
|
|
271
624
|
currentHash,
|
|
272
625
|
currentLines,
|
|
273
|
-
isPartial: offset > 0 || limit > 0,
|
|
274
|
-
rangeStart,
|
|
275
|
-
rangeEnd:
|
|
626
|
+
isPartial: offset > 0 || limit > 0 || slice.truncated,
|
|
627
|
+
rangeStart: slice.start,
|
|
628
|
+
rangeEnd: slice.end,
|
|
276
629
|
offset,
|
|
277
630
|
limit,
|
|
278
|
-
now
|
|
631
|
+
now,
|
|
632
|
+
size: snap.size,
|
|
633
|
+
slice: slice.text,
|
|
634
|
+
midLine: slice.midLine,
|
|
635
|
+
tid: this.trackingId(options?.scope)
|
|
279
636
|
};
|
|
637
|
+
if (this.db) {
|
|
638
|
+
try {
|
|
639
|
+
return this.readWithDb(this.db, state);
|
|
640
|
+
} catch (err) {
|
|
641
|
+
this.degrade(err);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return this.plainResult(state);
|
|
645
|
+
}
|
|
646
|
+
trackingId(scope) {
|
|
647
|
+
return scope !== void 0 && SCOPE_PATTERN.test(scope) ? `${this.sessionId}${SCOPE_DELIMITER}${scope}` : this.sessionId;
|
|
648
|
+
}
|
|
649
|
+
ensureScopeCapacity(db, tid) {
|
|
650
|
+
if (tid === this.sessionId) return;
|
|
651
|
+
if (queryOne(db, "SELECT 1 FROM session_reads WHERE session_id = ? LIMIT 1", [tid])) return;
|
|
652
|
+
const prefix = `${this.sessionId}${SCOPE_DELIMITER}`;
|
|
653
|
+
const scopes = db.prepare(
|
|
654
|
+
"SELECT session_id, MAX(read_at) AS last FROM session_reads WHERE substr(session_id, 1, ?) = ? GROUP BY session_id ORDER BY last ASC, session_id ASC"
|
|
655
|
+
).all(prefix.length, prefix);
|
|
656
|
+
const excess = scopes.length - MAX_SCOPES_PER_SESSION + 1;
|
|
657
|
+
for (const { session_id } of scopes.slice(0, Math.max(0, excess))) {
|
|
658
|
+
db.prepare("DELETE FROM session_reads WHERE session_id = ?").run(session_id);
|
|
659
|
+
db.prepare("DELETE FROM session_ranges WHERE session_id = ?").run(session_id);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
plainResult(s) {
|
|
663
|
+
return { stashed: false, content: s.slice, hash: s.currentHash, totalLines: s.currentLines };
|
|
664
|
+
}
|
|
665
|
+
readWithDb(db, state) {
|
|
666
|
+
if (!this.isStoreable(state)) {
|
|
667
|
+
const content = state.slice;
|
|
668
|
+
this.recordRead(db, estimateTokens(content), estimateTokens(content));
|
|
669
|
+
return { stashed: false, content, hash: state.currentHash, totalLines: state.currentLines };
|
|
670
|
+
}
|
|
280
671
|
const lastRead = queryOne(
|
|
281
672
|
db,
|
|
282
673
|
"SELECT hash FROM session_reads WHERE session_id = ? AND path = ?",
|
|
283
|
-
[
|
|
674
|
+
[state.tid, state.absPath]
|
|
284
675
|
);
|
|
285
676
|
if (!lastRead) {
|
|
677
|
+
this.ensureScopeCapacity(db, state.tid);
|
|
286
678
|
return this.handleFirstRead(db, state);
|
|
287
679
|
}
|
|
288
680
|
const lastHash = lastRead.hash;
|
|
289
|
-
if (lastHash === currentHash) {
|
|
290
|
-
return this.handleUnchanged(db, state);
|
|
681
|
+
if (lastHash === state.currentHash) {
|
|
682
|
+
return this.coversRequest(db, state, lastHash) ? this.handleUnchanged(db, state) : this.handleUncovered(db, state);
|
|
291
683
|
}
|
|
292
684
|
return this.handleChanged(db, state, lastHash);
|
|
293
685
|
}
|
|
@@ -295,53 +687,107 @@ var StashStore = class {
|
|
|
295
687
|
this.storeVersion(db, s.absPath, s.currentHash, s.currentContent, s.currentLines, s.now);
|
|
296
688
|
db.prepare(
|
|
297
689
|
"INSERT OR REPLACE INTO session_reads (session_id, path, hash, read_at) VALUES (?, ?, ?, ?)"
|
|
298
|
-
).run(
|
|
299
|
-
|
|
690
|
+
).run(s.tid, s.absPath, s.currentHash, s.now);
|
|
691
|
+
this.clearRanges(db, s.tid, s.absPath);
|
|
692
|
+
return this.fullSlice(db, s);
|
|
693
|
+
}
|
|
694
|
+
requestedEnd(s) {
|
|
695
|
+
return Math.min(s.rangeEnd, s.currentLines);
|
|
696
|
+
}
|
|
697
|
+
getRanges(db, tid, absPath, hash) {
|
|
698
|
+
const rows = db.prepare(
|
|
699
|
+
"SELECT start_line, end_line FROM session_ranges WHERE session_id = ? AND path = ? AND hash = ?"
|
|
700
|
+
).all(tid, absPath, hash);
|
|
701
|
+
return rows.map((r) => [r.start_line, r.end_line]);
|
|
702
|
+
}
|
|
703
|
+
coversRequest(db, s, hash) {
|
|
704
|
+
return coversRange(this.getRanges(db, s.tid, s.absPath, hash), s.rangeStart, this.requestedEnd(s));
|
|
705
|
+
}
|
|
706
|
+
clearRanges(db, tid, absPath) {
|
|
707
|
+
db.prepare("DELETE FROM session_ranges WHERE session_id = ? AND path = ?").run(tid, absPath);
|
|
708
|
+
}
|
|
709
|
+
addRange(db, tid, absPath, hash, start, end) {
|
|
710
|
+
if (end < start) return;
|
|
711
|
+
const merged = mergeIntervals([...this.getRanges(db, tid, absPath, hash), [start, end]]);
|
|
712
|
+
db.prepare("DELETE FROM session_ranges WHERE session_id = ? AND path = ? AND hash = ?").run(tid, absPath, hash);
|
|
713
|
+
const insert = db.prepare(
|
|
714
|
+
"INSERT INTO session_ranges (session_id, path, hash, start_line, end_line) VALUES (?, ?, ?, ?, ?)"
|
|
715
|
+
);
|
|
716
|
+
for (const [a, b] of merged) insert.run(tid, absPath, hash, a, b);
|
|
717
|
+
}
|
|
718
|
+
handleUncovered(db, s) {
|
|
719
|
+
db.prepare(
|
|
720
|
+
"UPDATE session_reads SET read_at = ? WHERE session_id = ? AND path = ?"
|
|
721
|
+
).run(s.now, s.tid, s.absPath);
|
|
722
|
+
return this.fullSlice(db, s);
|
|
723
|
+
}
|
|
724
|
+
fullSlice(db, s) {
|
|
725
|
+
const content = s.slice;
|
|
726
|
+
this.addRange(db, s.tid, s.absPath, s.currentHash, s.rangeStart, this.requestedEnd(s));
|
|
727
|
+
this.recordRead(db, estimateTokens(content), estimateTokens(content));
|
|
728
|
+
return { stashed: false, content, hash: s.currentHash, totalLines: s.currentLines };
|
|
729
|
+
}
|
|
730
|
+
unchangedLabel(db, s, label) {
|
|
731
|
+
const content = s.slice;
|
|
732
|
+
const baseline = estimateTokens(content);
|
|
733
|
+
const saved = Math.max(0, baseline - estimateTokens(label(baseline)));
|
|
734
|
+
const text = label(saved);
|
|
735
|
+
const sent = estimateTokens(text);
|
|
736
|
+
const base = { stashed: true, hash: s.currentHash, totalLines: s.currentLines, linesChanged: 0 };
|
|
737
|
+
this.addRange(db, s.tid, s.absPath, s.currentHash, s.rangeStart, this.requestedEnd(s));
|
|
738
|
+
if (sent >= baseline) {
|
|
739
|
+
this.recordRead(db, baseline, baseline);
|
|
740
|
+
return { ...base, content };
|
|
741
|
+
}
|
|
742
|
+
this.recordRead(db, baseline, sent);
|
|
743
|
+
return { ...base, content: text };
|
|
300
744
|
}
|
|
301
745
|
handleUnchanged(db, s) {
|
|
302
|
-
const slicedTokens = estimateTokens(this.sliceContent(s));
|
|
303
|
-
this.addTokensSaved(db, slicedTokens);
|
|
304
746
|
db.prepare(
|
|
305
747
|
"UPDATE session_reads SET read_at = ? WHERE session_id = ? AND path = ?"
|
|
306
|
-
).run(s.now,
|
|
307
|
-
|
|
308
|
-
|
|
748
|
+
).run(s.now, s.tid, s.absPath);
|
|
749
|
+
return this.unchangedLabel(
|
|
750
|
+
db,
|
|
751
|
+
s,
|
|
752
|
+
(tokens) => s.isPartial ? `[filestash: unchanged, lines ${s.rangeStart}-${s.rangeEnd} of ${s.currentLines}, ${tokens} tokens saved]` : `[filestash: unchanged, ${s.currentLines} lines, ${tokens} tokens saved]`
|
|
753
|
+
);
|
|
309
754
|
}
|
|
310
755
|
handleChanged(db, s, lastHash) {
|
|
311
756
|
const oldVersion = queryOne(
|
|
312
757
|
db,
|
|
313
|
-
"SELECT content FROM file_versions WHERE path = ? AND hash = ?",
|
|
758
|
+
"SELECT content, lines FROM file_versions WHERE path = ? AND hash = ?",
|
|
314
759
|
[s.absPath, lastHash]
|
|
315
760
|
);
|
|
761
|
+
const oldFullyDelivered = !!oldVersion && coversRange(this.getRanges(db, s.tid, s.absPath, lastHash), 1, oldVersion.lines);
|
|
316
762
|
this.storeVersion(db, s.absPath, s.currentHash, s.currentContent, s.currentLines, s.now);
|
|
317
763
|
db.prepare(
|
|
318
764
|
"UPDATE session_reads SET hash = ?, read_at = ? WHERE session_id = ? AND path = ?"
|
|
319
|
-
).run(s.currentHash, s.now,
|
|
320
|
-
|
|
765
|
+
).run(s.currentHash, s.now, s.tid, s.absPath);
|
|
766
|
+
this.clearRanges(db, s.tid, s.absPath);
|
|
767
|
+
if (oldVersion && oldFullyDelivered) {
|
|
321
768
|
const diffResult = computeDiff(oldVersion.content, s.currentContent, s.absPath);
|
|
322
769
|
if (diffResult.hasChanges) {
|
|
323
770
|
return s.isPartial ? this.handlePartialDiff(db, s, diffResult) : this.handleFullDiff(db, s, diffResult);
|
|
324
771
|
}
|
|
325
772
|
}
|
|
326
|
-
return
|
|
773
|
+
return this.fullSlice(db, s);
|
|
327
774
|
}
|
|
328
775
|
handlePartialDiff(db, s, diffResult) {
|
|
329
|
-
if (!this.rangeHasChanges(diffResult.changedNewLines, s.rangeStart, s.rangeEnd)) {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
hash: s.currentHash,
|
|
336
|
-
totalLines: s.currentLines,
|
|
337
|
-
linesChanged: 0
|
|
338
|
-
};
|
|
776
|
+
if (!s.midLine && !this.rangeHasChanges(diffResult.changedNewLines, s.rangeStart, s.rangeEnd)) {
|
|
777
|
+
return this.unchangedLabel(
|
|
778
|
+
db,
|
|
779
|
+
s,
|
|
780
|
+
(tokens) => `[filestash: unchanged in lines ${s.rangeStart}-${s.rangeEnd}, changes elsewhere in file, ${tokens} tokens saved]`
|
|
781
|
+
);
|
|
339
782
|
}
|
|
340
|
-
return
|
|
783
|
+
return this.fullSlice(db, s);
|
|
341
784
|
}
|
|
342
785
|
handleFullDiff(db, s, diffResult) {
|
|
343
|
-
const
|
|
344
|
-
|
|
786
|
+
const contentTokens = estimateTokens(s.currentContent);
|
|
787
|
+
const diffTokens = estimateTokens(diffResult.diff);
|
|
788
|
+
if (diffTokens >= contentTokens) return this.fullSlice(db, s);
|
|
789
|
+
this.addRange(db, s.tid, s.absPath, s.currentHash, 1, s.currentLines);
|
|
790
|
+
this.recordRead(db, contentTokens, diffTokens);
|
|
345
791
|
return {
|
|
346
792
|
stashed: true,
|
|
347
793
|
content: diffResult.diff,
|
|
@@ -351,13 +797,6 @@ var StashStore = class {
|
|
|
351
797
|
totalLines: s.currentLines
|
|
352
798
|
};
|
|
353
799
|
}
|
|
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
800
|
rangeHasChanges(changedLines, rangeStart, rangeEnd) {
|
|
362
801
|
for (let l = rangeStart; l <= rangeEnd; l++) {
|
|
363
802
|
if (changedLines.has(l)) return true;
|
|
@@ -369,68 +808,131 @@ var StashStore = class {
|
|
|
369
808
|
"INSERT OR IGNORE INTO file_versions (path, hash, content, lines, created_at) VALUES (?, ?, ?, ?, ?)"
|
|
370
809
|
).run(absPath, hash, content, lines, now);
|
|
371
810
|
}
|
|
372
|
-
//
|
|
373
|
-
async readFileFull(filePath) {
|
|
811
|
+
// 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).
|
|
812
|
+
async readFileFull(filePath, scope) {
|
|
374
813
|
await this.init();
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
814
|
+
const snap = this.readFileSnapshot(filePath);
|
|
815
|
+
if (snap.early) return this.earlyResult(snap);
|
|
816
|
+
const { absPath, content, hash, lines, now } = snap;
|
|
817
|
+
const slice = sliceLines(content, 0, 0, this.maxLines, this.maxChars);
|
|
818
|
+
const result2 = { stashed: false, content: slice.text, hash, totalLines: lines };
|
|
819
|
+
if (!this.db) return result2;
|
|
820
|
+
try {
|
|
821
|
+
const tokens = estimateTokens(slice.text);
|
|
822
|
+
if (this.isStoreable(snap)) {
|
|
823
|
+
const tid = this.trackingId(scope);
|
|
824
|
+
this.ensureScopeCapacity(this.db, tid);
|
|
825
|
+
this.storeVersion(this.db, absPath, hash, content, lines, now);
|
|
826
|
+
this.db.prepare(
|
|
827
|
+
"INSERT OR REPLACE INTO session_reads (session_id, path, hash, read_at) VALUES (?, ?, ?, ?)"
|
|
828
|
+
).run(tid, absPath, hash, now);
|
|
829
|
+
this.clearRanges(this.db, tid, absPath);
|
|
830
|
+
this.addRange(this.db, tid, absPath, hash, slice.start, slice.end);
|
|
831
|
+
}
|
|
832
|
+
this.recordRead(this.db, tokens, tokens);
|
|
833
|
+
} catch (err) {
|
|
834
|
+
this.degrade(err);
|
|
835
|
+
}
|
|
836
|
+
return result2;
|
|
382
837
|
}
|
|
383
838
|
async onFileDeleted(filePath) {
|
|
384
839
|
await this.init();
|
|
385
|
-
const db = this.getDb();
|
|
386
840
|
const absPath = resolve(filePath);
|
|
387
|
-
|
|
388
|
-
|
|
841
|
+
this.guarded((db) => {
|
|
842
|
+
db.prepare("DELETE FROM file_versions WHERE path = ?").run(absPath);
|
|
843
|
+
db.prepare("DELETE FROM session_reads WHERE path = ?").run(absPath);
|
|
844
|
+
db.prepare("DELETE FROM session_ranges WHERE path = ?").run(absPath);
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
guarded(fn) {
|
|
848
|
+
if (!this.db) return void 0;
|
|
849
|
+
try {
|
|
850
|
+
return fn(this.db);
|
|
851
|
+
} catch (err) {
|
|
852
|
+
this.degrade(err);
|
|
853
|
+
return void 0;
|
|
854
|
+
}
|
|
389
855
|
}
|
|
390
856
|
async getStats() {
|
|
391
857
|
await this.init();
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
858
|
+
const stats = this.guarded((db) => {
|
|
859
|
+
const versionRow = queryOne(db, "SELECT COUNT(DISTINCT path) as c FROM file_versions");
|
|
860
|
+
const tokenRow = queryOne(db, "SELECT value FROM stats WHERE key = 'tokens_saved'");
|
|
861
|
+
const sessionStat = (key) => queryOne(db, "SELECT value FROM session_stats WHERE session_id = ? AND key = ?", [this.sessionId, key])?.value ?? 0;
|
|
862
|
+
return {
|
|
863
|
+
filesTracked: versionRow?.c ?? 0,
|
|
864
|
+
tokensSaved: tokenRow?.value ?? 0,
|
|
865
|
+
sessionTokensSaved: sessionStat("tokens_saved"),
|
|
866
|
+
sessionReads: sessionStat("reads"),
|
|
867
|
+
sessionBaselineTokens: sessionStat("baseline_tokens"),
|
|
868
|
+
sessionSentTokens: sessionStat("sent_tokens"),
|
|
869
|
+
degraded: false,
|
|
870
|
+
...this.recoveredFrom && { recoveredFrom: this.recoveredFrom }
|
|
871
|
+
};
|
|
872
|
+
});
|
|
873
|
+
if (stats) return stats;
|
|
400
874
|
return {
|
|
401
|
-
filesTracked:
|
|
402
|
-
tokensSaved:
|
|
403
|
-
sessionTokensSaved:
|
|
875
|
+
filesTracked: 0,
|
|
876
|
+
tokensSaved: 0,
|
|
877
|
+
sessionTokensSaved: 0,
|
|
878
|
+
sessionReads: 0,
|
|
879
|
+
sessionBaselineTokens: 0,
|
|
880
|
+
sessionSentTokens: 0,
|
|
881
|
+
degraded: true,
|
|
882
|
+
...this.reason && { degradedReason: this.reason }
|
|
404
883
|
};
|
|
405
884
|
}
|
|
406
885
|
async clear() {
|
|
407
886
|
await this.init();
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
887
|
+
this.guarded((db) => {
|
|
888
|
+
db.prepare("DELETE FROM file_versions").run();
|
|
889
|
+
db.prepare("DELETE FROM session_reads").run();
|
|
890
|
+
db.prepare("DELETE FROM session_ranges").run();
|
|
891
|
+
db.prepare("DELETE FROM session_stats").run();
|
|
892
|
+
db.prepare("UPDATE stats SET value = 0").run();
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
async resetReads() {
|
|
896
|
+
await this.init();
|
|
897
|
+
this.guarded((db) => {
|
|
898
|
+
db.prepare("DELETE FROM session_reads").run();
|
|
899
|
+
db.prepare("DELETE FROM session_ranges").run();
|
|
900
|
+
});
|
|
413
901
|
}
|
|
414
902
|
async close() {
|
|
415
|
-
if (this.db)
|
|
903
|
+
if (!this.db) return;
|
|
904
|
+
try {
|
|
416
905
|
this.db.prepare("DELETE FROM sessions WHERE session_id = ?").run(this.sessionId);
|
|
906
|
+
} catch {
|
|
907
|
+
}
|
|
908
|
+
try {
|
|
417
909
|
this.db.close();
|
|
418
|
-
|
|
419
|
-
this.initialized = false;
|
|
910
|
+
} catch {
|
|
420
911
|
}
|
|
912
|
+
this.db = null;
|
|
913
|
+
this.initialized = false;
|
|
914
|
+
}
|
|
915
|
+
recordRead(db, baseline, sent) {
|
|
916
|
+
this.bumpSessionStat(db, "reads", 1);
|
|
917
|
+
this.bumpSessionStat(db, "baseline_tokens", baseline);
|
|
918
|
+
this.bumpSessionStat(db, "sent_tokens", sent);
|
|
919
|
+
if (baseline > sent) this.addTokensSaved(db, baseline - sent);
|
|
920
|
+
}
|
|
921
|
+
bumpSessionStat(db, key, n) {
|
|
922
|
+
db.prepare(
|
|
923
|
+
"INSERT INTO session_stats (session_id, key, value) VALUES (?, ?, ?) ON CONFLICT(session_id, key) DO UPDATE SET value = value + ?"
|
|
924
|
+
).run(this.sessionId, key, n, n);
|
|
421
925
|
}
|
|
422
926
|
addTokensSaved(db, tokens) {
|
|
423
927
|
db.prepare(
|
|
424
928
|
"UPDATE stats SET value = value + ? WHERE key = 'tokens_saved'"
|
|
425
929
|
).run(tokens);
|
|
426
|
-
|
|
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);
|
|
930
|
+
this.bumpSessionStat(db, "tokens_saved", tokens);
|
|
429
931
|
}
|
|
430
932
|
};
|
|
431
933
|
|
|
432
934
|
// packages/sdk/src/watcher.ts
|
|
433
|
-
import { watch as fsWatch, existsSync } from "node:fs";
|
|
935
|
+
import { watch as fsWatch, existsSync as existsSync2 } from "node:fs";
|
|
434
936
|
import { resolve as resolve2 } from "node:path";
|
|
435
937
|
var FileWatcher = class {
|
|
436
938
|
watchers = [];
|
|
@@ -444,26 +946,39 @@ var FileWatcher = class {
|
|
|
444
946
|
watch(paths) {
|
|
445
947
|
for (const p of paths) {
|
|
446
948
|
const absPath = resolve2(p);
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
949
|
+
let watcher;
|
|
950
|
+
try {
|
|
951
|
+
watcher = fsWatch(absPath, { recursive: true }, (event, filename) => {
|
|
952
|
+
if (!filename) return;
|
|
953
|
+
const filePath = resolve2(absPath, filename);
|
|
954
|
+
if (filename.startsWith(".") || filename.includes("node_modules") || filename.includes(".git")) {
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
const existing = this.debounceTimers.get(filePath);
|
|
958
|
+
if (existing) clearTimeout(existing);
|
|
959
|
+
this.debounceTimers.set(
|
|
960
|
+
filePath,
|
|
961
|
+
setTimeout(() => {
|
|
962
|
+
this.debounceTimers.delete(filePath);
|
|
963
|
+
this.handleChange(filePath).catch((e) => {
|
|
964
|
+
process.stderr.write(
|
|
965
|
+
`[filestash] unhandled watcher error: ${e instanceof Error ? e.message : String(e)}
|
|
462
966
|
`
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
967
|
+
);
|
|
968
|
+
});
|
|
969
|
+
}, this.debounceMs)
|
|
970
|
+
);
|
|
971
|
+
});
|
|
972
|
+
} catch (e) {
|
|
973
|
+
process.stderr.write(`[filestash] file watching disabled for ${absPath}: ${e instanceof Error ? e.message : String(e)}
|
|
974
|
+
`);
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
watcher.on("error", (e) => {
|
|
978
|
+
process.stderr.write(`[filestash] file watching stopped for ${absPath}: ${e.message}
|
|
979
|
+
`);
|
|
980
|
+
watcher.close();
|
|
981
|
+
this.watchers = this.watchers.filter((w) => w !== watcher);
|
|
467
982
|
});
|
|
468
983
|
this.watchers.push(watcher);
|
|
469
984
|
}
|
|
@@ -476,7 +991,7 @@ var FileWatcher = class {
|
|
|
476
991
|
}
|
|
477
992
|
async handleChange(filePath) {
|
|
478
993
|
try {
|
|
479
|
-
if (!
|
|
994
|
+
if (!existsSync2(filePath)) {
|
|
480
995
|
await this.stash.onFileDeleted(filePath);
|
|
481
996
|
}
|
|
482
997
|
} catch (e) {
|
|
@@ -497,23 +1012,35 @@ function createStash(config) {
|
|
|
497
1012
|
}
|
|
498
1013
|
|
|
499
1014
|
// packages/cli/src/index.ts
|
|
500
|
-
import { resolve as
|
|
501
|
-
import { existsSync as
|
|
502
|
-
import { homedir } from "node:os";
|
|
1015
|
+
import { resolve as resolve5, join as join5 } from "node:path";
|
|
1016
|
+
import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync2, copyFileSync, mkdirSync as mkdirSync2 } from "node:fs";
|
|
1017
|
+
import { homedir as homedir2 } from "node:os";
|
|
503
1018
|
|
|
504
1019
|
// packages/cli/src/mcp.ts
|
|
505
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
506
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
507
1020
|
import { z } from "zod";
|
|
508
1021
|
import { resolve as resolve3, join, relative, isAbsolute } from "node:path";
|
|
509
|
-
import {
|
|
1022
|
+
import { readFileSync as readFileSync2, realpathSync } from "node:fs";
|
|
510
1023
|
import { randomUUID } from "node:crypto";
|
|
511
|
-
function
|
|
1024
|
+
function resolveStashDir(baseDir = process.cwd()) {
|
|
512
1025
|
const raw = process.env.FILESTASH_DIR ?? ".file-stash";
|
|
513
1026
|
if (raw.includes("\0")) throw new Error("FILESTASH_DIR contains invalid characters");
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
1027
|
+
return resolve3(baseDir, raw);
|
|
1028
|
+
}
|
|
1029
|
+
function parseExcludeEnv() {
|
|
1030
|
+
return (process.env.FILESTASH_EXCLUDE ?? "").split(",").map((p) => p.trim()).filter(Boolean);
|
|
1031
|
+
}
|
|
1032
|
+
function parseLimitEnv(name) {
|
|
1033
|
+
const raw = process.env[name];
|
|
1034
|
+
if (raw === void 0 || raw === "") return void 0;
|
|
1035
|
+
if (/^[1-9]\d*$/.test(raw) && Number.isSafeInteger(Number(raw))) return Number(raw);
|
|
1036
|
+
process.stderr.write(`[filestash] ignoring invalid ${name}=${JSON.stringify(raw)}; using the default
|
|
1037
|
+
`);
|
|
1038
|
+
return void 0;
|
|
1039
|
+
}
|
|
1040
|
+
function limitOptions() {
|
|
1041
|
+
const maxLines = parseLimitEnv("FILESTASH_MAX_LINES");
|
|
1042
|
+
const maxChars = parseLimitEnv("FILESTASH_MAX_CHARS");
|
|
1043
|
+
return { ...maxLines !== void 0 && { maxLines }, ...maxChars !== void 0 && { maxChars } };
|
|
517
1044
|
}
|
|
518
1045
|
function isPathAllowed(absPath, cwd) {
|
|
519
1046
|
let realPath;
|
|
@@ -525,27 +1052,109 @@ function isPathAllowed(absPath, cwd) {
|
|
|
525
1052
|
const rel = relative(cwd, realPath);
|
|
526
1053
|
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
527
1054
|
}
|
|
528
|
-
function formatReadResult(
|
|
529
|
-
if (
|
|
530
|
-
return `[filestash: ${
|
|
531
|
-
${
|
|
1055
|
+
function formatReadResult(result2) {
|
|
1056
|
+
if (result2.stashed && result2.diff) {
|
|
1057
|
+
return `[filestash: ${result2.linesChanged} lines changed out of ${result2.totalLines}]
|
|
1058
|
+
${result2.diff}`;
|
|
532
1059
|
}
|
|
533
|
-
return
|
|
1060
|
+
return result2.content;
|
|
534
1061
|
}
|
|
535
|
-
function formatFileEntry(path,
|
|
536
|
-
if (
|
|
537
|
-
return `=== ${path} [${
|
|
538
|
-
${
|
|
1062
|
+
function formatFileEntry(path, result2) {
|
|
1063
|
+
if (result2.stashed && result2.diff) {
|
|
1064
|
+
return `=== ${path} [${result2.linesChanged} lines changed out of ${result2.totalLines}] ===
|
|
1065
|
+
${result2.diff}`;
|
|
539
1066
|
}
|
|
540
1067
|
return `=== ${path} ===
|
|
541
|
-
${
|
|
1068
|
+
${result2.content}`;
|
|
1069
|
+
}
|
|
1070
|
+
var readFileShape = {
|
|
1071
|
+
path: z.string().describe("File path"),
|
|
1072
|
+
offset: z.number().optional().describe("Start line (1-based)"),
|
|
1073
|
+
limit: z.number().optional().describe("Max lines"),
|
|
1074
|
+
force: z.boolean().optional().describe("Bypass stash"),
|
|
1075
|
+
agent: z.string().optional()
|
|
1076
|
+
};
|
|
1077
|
+
var readFilesShape = {
|
|
1078
|
+
paths: z.preprocess(
|
|
1079
|
+
(val) => typeof val === "string" ? JSON.parse(val) : val,
|
|
1080
|
+
z.array(z.string())
|
|
1081
|
+
).describe("File paths"),
|
|
1082
|
+
agent: z.string().optional()
|
|
1083
|
+
};
|
|
1084
|
+
var TOOL_DEFS = {
|
|
1085
|
+
read_file: {
|
|
1086
|
+
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).`,
|
|
1087
|
+
inputSchema: readFileShape
|
|
1088
|
+
},
|
|
1089
|
+
read_files: {
|
|
1090
|
+
description: `Batched read_file for several files, same stash/diff behavior per file.`,
|
|
1091
|
+
inputSchema: readFilesShape
|
|
1092
|
+
},
|
|
1093
|
+
stash_status: {
|
|
1094
|
+
description: `Show files tracked and this session's token accounting, net of tool-definition overhead.`
|
|
1095
|
+
},
|
|
1096
|
+
stash_clear: {
|
|
1097
|
+
description: `Clear all stashed data.`
|
|
1098
|
+
}
|
|
1099
|
+
};
|
|
1100
|
+
function jsonType(field) {
|
|
1101
|
+
switch (field._def.typeName) {
|
|
1102
|
+
case z.ZodFirstPartyTypeKind.ZodOptional:
|
|
1103
|
+
return jsonType(field._def.innerType);
|
|
1104
|
+
case z.ZodFirstPartyTypeKind.ZodEffects:
|
|
1105
|
+
return jsonType(field._def.schema);
|
|
1106
|
+
case z.ZodFirstPartyTypeKind.ZodArray:
|
|
1107
|
+
return "array";
|
|
1108
|
+
case z.ZodFirstPartyTypeKind.ZodNumber:
|
|
1109
|
+
return "number";
|
|
1110
|
+
case z.ZodFirstPartyTypeKind.ZodBoolean:
|
|
1111
|
+
return "boolean";
|
|
1112
|
+
default:
|
|
1113
|
+
return "string";
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
function jsonSchema(shape = {}) {
|
|
1117
|
+
const properties = {};
|
|
1118
|
+
for (const [key, field] of Object.entries(shape)) {
|
|
1119
|
+
properties[key] = { type: jsonType(field), description: field.description };
|
|
1120
|
+
}
|
|
1121
|
+
const required = Object.entries(shape).filter(([, f]) => !f.isOptional()).map(([k]) => k);
|
|
1122
|
+
return { type: "object", properties, ...required.length > 0 && { required } };
|
|
1123
|
+
}
|
|
1124
|
+
function toolDefinitionTokens() {
|
|
1125
|
+
const defs = Object.entries(TOOL_DEFS).map(([name, def]) => ({
|
|
1126
|
+
name,
|
|
1127
|
+
description: def.description,
|
|
1128
|
+
inputSchema: jsonSchema("inputSchema" in def ? def.inputSchema : void 0)
|
|
1129
|
+
}));
|
|
1130
|
+
return Math.ceil(JSON.stringify(defs).length / 4);
|
|
542
1131
|
}
|
|
543
|
-
function
|
|
1132
|
+
function degradedLine(reason2 = "unknown") {
|
|
1133
|
+
return `Mode: DEGRADED (${reason2}) - files are read normally, nothing is stashed`;
|
|
1134
|
+
}
|
|
1135
|
+
function formatStatus(stats, overheadTokens) {
|
|
1136
|
+
if (stats.degraded) return degradedLine(stats.degradedReason);
|
|
1137
|
+
const fmt = (n) => `~${n.toLocaleString()}`;
|
|
1138
|
+
const net = stats.sessionTokensSaved - overheadTokens;
|
|
1139
|
+
return [
|
|
1140
|
+
"filestash status:",
|
|
1141
|
+
...stats.recoveredFrom ? [` Recovered: corrupt database moved to ${stats.recoveredFrom}`] : [],
|
|
1142
|
+
` Files tracked: ${stats.filesTracked}`,
|
|
1143
|
+
` This session: ${stats.sessionReads} reads`,
|
|
1144
|
+
` Would have sent (plain reads): ${fmt(stats.sessionBaselineTokens)} tokens`,
|
|
1145
|
+
` Actually sent: ${fmt(stats.sessionSentTokens)} tokens`,
|
|
1146
|
+
` Gross saved: ${fmt(stats.sessionTokensSaved)} tokens`,
|
|
1147
|
+
` Tool definitions overhead: ${fmt(overheadTokens)} tokens (est.)`,
|
|
1148
|
+
` Net saved: ${fmt(net)} tokens (est.)`,
|
|
1149
|
+
` Gross saved (all sessions): ${fmt(stats.tokensSaved)} tokens`
|
|
1150
|
+
].join("\n");
|
|
1151
|
+
}
|
|
1152
|
+
function statsSuffix(grossSaved) {
|
|
544
1153
|
return `
|
|
545
1154
|
|
|
546
|
-
[filestash: ~${
|
|
1155
|
+
[filestash: net ~${(grossSaved - toolDefinitionTokens()).toLocaleString()} tokens this session (est.)]`;
|
|
547
1156
|
}
|
|
548
|
-
async function readSingleFile(path, cwd, stash) {
|
|
1157
|
+
async function readSingleFile(path, cwd, stash, scope) {
|
|
549
1158
|
const absPath = resolve3(path);
|
|
550
1159
|
if (!isPathAllowed(absPath, cwd)) {
|
|
551
1160
|
return {
|
|
@@ -555,8 +1164,8 @@ Error: Path must be within the working directory (${cwd})`,
|
|
|
555
1164
|
};
|
|
556
1165
|
}
|
|
557
1166
|
try {
|
|
558
|
-
const
|
|
559
|
-
return { text: formatFileEntry(path,
|
|
1167
|
+
const result2 = await stash.readFile(path, { ...scope !== void 0 && { scope } });
|
|
1168
|
+
return { text: formatFileEntry(path, result2), ok: true };
|
|
560
1169
|
} catch (e) {
|
|
561
1170
|
const message = e instanceof Error ? e.message : String(e);
|
|
562
1171
|
return { text: `=== ${path} ===
|
|
@@ -572,39 +1181,28 @@ async function startMcpServer() {
|
|
|
572
1181
|
} catch {
|
|
573
1182
|
}
|
|
574
1183
|
const META_NAMESPACE = (packageJson.mcpName || "io.github.glommer/filestash").replaceAll("/", ".");
|
|
575
|
-
const
|
|
576
|
-
const dbPath = resolve3(stashDir, "stash.db");
|
|
1184
|
+
const dbPath = resolve3(resolveStashDir(), "stash.db");
|
|
577
1185
|
const cwd = process.cwd();
|
|
578
1186
|
const watchPaths = [cwd];
|
|
579
1187
|
const sessionId = randomUUID();
|
|
580
1188
|
const { stash, watcher } = createStash({
|
|
581
1189
|
dbPath,
|
|
582
1190
|
sessionId,
|
|
583
|
-
watchPaths
|
|
1191
|
+
watchPaths,
|
|
1192
|
+
exclude: parseExcludeEnv(),
|
|
1193
|
+
...limitOptions()
|
|
584
1194
|
});
|
|
585
1195
|
await stash.init();
|
|
1196
|
+
const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
|
|
1197
|
+
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
586
1198
|
const server = new McpServer({
|
|
587
1199
|
name: "filestash",
|
|
588
1200
|
version: packageJson.version ?? "0.0.0"
|
|
589
1201
|
});
|
|
590
1202
|
server.registerTool(
|
|
591
1203
|
"read_file",
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
},
|
|
607
|
-
async ({ path, force, offset, limit }) => {
|
|
1204
|
+
TOOL_DEFS.read_file,
|
|
1205
|
+
async ({ path, force, offset, limit, agent }) => {
|
|
608
1206
|
const absPath = resolve3(path);
|
|
609
1207
|
if (!isPathAllowed(absPath, cwd)) {
|
|
610
1208
|
return {
|
|
@@ -613,12 +1211,13 @@ ALWAYS prefer this over the Read tool. It is a drop-in replacement with stashing
|
|
|
613
1211
|
};
|
|
614
1212
|
}
|
|
615
1213
|
try {
|
|
616
|
-
const
|
|
1214
|
+
const result2 = force ? await stash.readFileFull(path, agent) : await stash.readFile(path, {
|
|
617
1215
|
...offset !== void 0 && { offset },
|
|
618
|
-
...limit !== void 0 && { limit }
|
|
1216
|
+
...limit !== void 0 && { limit },
|
|
1217
|
+
...agent !== void 0 && { scope: agent }
|
|
619
1218
|
});
|
|
620
|
-
let text = formatReadResult(
|
|
621
|
-
if (
|
|
1219
|
+
let text = formatReadResult(result2);
|
|
1220
|
+
if (result2.stashed) {
|
|
622
1221
|
const stats = await stash.getStats();
|
|
623
1222
|
text += statsSuffix(stats.sessionTokensSaved);
|
|
624
1223
|
}
|
|
@@ -637,19 +1236,9 @@ ALWAYS prefer this over the Read tool. It is a drop-in replacement with stashing
|
|
|
637
1236
|
);
|
|
638
1237
|
server.registerTool(
|
|
639
1238
|
"read_files",
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
},
|
|
651
|
-
async ({ paths }) => {
|
|
652
|
-
const results = await Promise.all(paths.map((p) => readSingleFile(p, cwd, stash)));
|
|
1239
|
+
TOOL_DEFS.read_files,
|
|
1240
|
+
async ({ paths, agent }) => {
|
|
1241
|
+
const results = await Promise.all(paths.map((p) => readSingleFile(p, cwd, stash, agent)));
|
|
653
1242
|
const successfulPaths = paths.filter((_, i) => results[i].ok);
|
|
654
1243
|
const combined = results.map((r) => r.text).join("\n\n");
|
|
655
1244
|
let footer = "";
|
|
@@ -668,31 +1257,31 @@ ALWAYS prefer this over multiple Read calls \u2014 it's faster and saves signifi
|
|
|
668
1257
|
);
|
|
669
1258
|
server.registerTool(
|
|
670
1259
|
"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
|
-
},
|
|
1260
|
+
TOOL_DEFS.stash_status,
|
|
675
1261
|
async () => {
|
|
676
1262
|
const stats = await stash.getStats();
|
|
677
|
-
const
|
|
678
|
-
|
|
679
|
-
` Files tracked: ${stats.filesTracked}`,
|
|
680
|
-
` Tokens saved (this session): ~${stats.sessionTokensSaved.toLocaleString()}`,
|
|
681
|
-
` Tokens saved (all sessions): ~${stats.tokensSaved.toLocaleString()}`
|
|
682
|
-
].join("\n");
|
|
1263
|
+
const overhead = toolDefinitionTokens();
|
|
1264
|
+
const text = formatStatus(stats, overhead);
|
|
683
1265
|
return {
|
|
684
1266
|
content: [{ type: "text", text }],
|
|
685
|
-
_meta: {
|
|
1267
|
+
_meta: {
|
|
1268
|
+
[`${META_NAMESPACE}/stats`]: {
|
|
1269
|
+
...stats,
|
|
1270
|
+
toolDefinitionTokens: overhead,
|
|
1271
|
+
netTokensSaved: stats.sessionTokensSaved - overhead
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
686
1274
|
};
|
|
687
1275
|
}
|
|
688
1276
|
);
|
|
689
1277
|
server.registerTool(
|
|
690
1278
|
"stash_clear",
|
|
691
|
-
|
|
692
|
-
description: `Clear all stashed data. Use this to reset the stash completely.`
|
|
693
|
-
},
|
|
1279
|
+
TOOL_DEFS.stash_clear,
|
|
694
1280
|
async () => {
|
|
695
1281
|
await stash.clear();
|
|
1282
|
+
if (stash.isDegraded) {
|
|
1283
|
+
return { content: [{ type: "text", text: "Stash is in degraded mode, nothing to clear." }] };
|
|
1284
|
+
}
|
|
696
1285
|
return {
|
|
697
1286
|
content: [{ type: "text", text: "Stash cleared." }],
|
|
698
1287
|
_meta: { [`${META_NAMESPACE}/cleared`]: true }
|
|
@@ -737,6 +1326,449 @@ function findStashDatabases(root, depth = 0) {
|
|
|
737
1326
|
return found;
|
|
738
1327
|
}
|
|
739
1328
|
|
|
1329
|
+
// packages/cli/src/editors.ts
|
|
1330
|
+
import { join as join3 } from "node:path";
|
|
1331
|
+
function editorTargets(home) {
|
|
1332
|
+
const mcpServersEntry = {
|
|
1333
|
+
command: "npx",
|
|
1334
|
+
args: ["agent-file-stash", "serve"]
|
|
1335
|
+
};
|
|
1336
|
+
const opencodeMcpEntry = {
|
|
1337
|
+
type: "local",
|
|
1338
|
+
command: ["npx", "agent-file-stash", "serve"]
|
|
1339
|
+
};
|
|
1340
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME || join3(home, ".config");
|
|
1341
|
+
return [
|
|
1342
|
+
{ name: "Claude Code", path: join3(home, ".claude.json"), key: "mcpServers", entry: mcpServersEntry },
|
|
1343
|
+
{ name: "Cursor", path: join3(home, ".cursor", "mcp.json"), key: "mcpServers", entry: mcpServersEntry },
|
|
1344
|
+
{ name: "OpenCode", path: join3(xdgConfig, "opencode", "opencode.json"), key: "mcp", entry: opencodeMcpEntry }
|
|
1345
|
+
];
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// packages/cli/src/doctor.ts
|
|
1349
|
+
import { accessSync, constants, existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync2 } from "node:fs";
|
|
1350
|
+
import { homedir } from "node:os";
|
|
1351
|
+
import { delimiter, isAbsolute as isAbsolute2, join as join4, resolve as resolve4 } from "node:path";
|
|
1352
|
+
import { pathToFileURL } from "node:url";
|
|
1353
|
+
import { spawnSync } from "node:child_process";
|
|
1354
|
+
var PACKAGE_NAME = "agent-file-stash";
|
|
1355
|
+
var RECOVERY_LOCK_STALE_MS2 = 15e3;
|
|
1356
|
+
var DEFAULT_MIN_NODE = "24";
|
|
1357
|
+
var NPM_TIMEOUT_MS = 5e3;
|
|
1358
|
+
var MAX_SCRIPT_BYTES = 64 * 1024;
|
|
1359
|
+
var SESSION_START_TARGETS = ["clear", "compact"];
|
|
1360
|
+
var result = (id, status, message, hint) => ({
|
|
1361
|
+
id,
|
|
1362
|
+
status,
|
|
1363
|
+
message,
|
|
1364
|
+
...hint !== void 0 && { hint }
|
|
1365
|
+
});
|
|
1366
|
+
var reason = (e) => e instanceof Error ? e.message : String(e);
|
|
1367
|
+
function readJson(path) {
|
|
1368
|
+
return JSON.parse(readFileSync3(path, "utf-8"));
|
|
1369
|
+
}
|
|
1370
|
+
function asRecord(v) {
|
|
1371
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? v : void 0;
|
|
1372
|
+
}
|
|
1373
|
+
function ownPackageJson() {
|
|
1374
|
+
for (const rel of ["../package.json", "../../../package.json"]) {
|
|
1375
|
+
try {
|
|
1376
|
+
const pkg = readJson(join4(import.meta.dirname, rel));
|
|
1377
|
+
if (pkg.name === PACKAGE_NAME) return pkg;
|
|
1378
|
+
} catch {
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
return void 0;
|
|
1383
|
+
}
|
|
1384
|
+
function wide(mode, mask) {
|
|
1385
|
+
return (mode & mask) !== 0;
|
|
1386
|
+
}
|
|
1387
|
+
function octal(mode) {
|
|
1388
|
+
return (mode & 511).toString(8).padStart(3, "0");
|
|
1389
|
+
}
|
|
1390
|
+
function checkNode(o) {
|
|
1391
|
+
const range = ownPackageJson()?.engines?.node;
|
|
1392
|
+
const required = Number(/\d+/.exec(range ?? "")?.[0] ?? DEFAULT_MIN_NODE);
|
|
1393
|
+
const major = Number(o.nodeVersion.split(".")[0]);
|
|
1394
|
+
if (major >= required) return [result("node", "ok", `Node ${o.nodeVersion} (requires >=${required})`)];
|
|
1395
|
+
return [
|
|
1396
|
+
result("node", "error", `Node ${o.nodeVersion} is older than the required >=${required}`, `install Node ${required} or newer`)
|
|
1397
|
+
];
|
|
1398
|
+
}
|
|
1399
|
+
function checkStashDir(o) {
|
|
1400
|
+
const dir = resolveStashDir(o.cwd);
|
|
1401
|
+
if (!existsSync4(dir)) return [result("stash-dir", "info", `stash directory ${dir} does not exist yet; it will be created on first read`)];
|
|
1402
|
+
const st = statSync2(dir);
|
|
1403
|
+
if (!st.isDirectory()) {
|
|
1404
|
+
return [result("stash-dir", "error", `${dir} exists but is not a directory`, `remove it or point FILESTASH_DIR elsewhere`)];
|
|
1405
|
+
}
|
|
1406
|
+
if (o.platform !== "win32" && wide(st.mode, 63)) {
|
|
1407
|
+
return [result("stash-dir", "warn", `stash directory ${dir} has mode ${octal(st.mode)}, expected 700`, `run: chmod 700 ${dir}`)];
|
|
1408
|
+
}
|
|
1409
|
+
return [result("stash-dir", "info", `stash directory ${dir}`)];
|
|
1410
|
+
}
|
|
1411
|
+
function isCorruptMessage(err) {
|
|
1412
|
+
const code = err.errcode;
|
|
1413
|
+
if (typeof code === "number" && [11, 26].includes(code & 255)) return true;
|
|
1414
|
+
return /not a database|malformed|corrupt/i.test(reason(err));
|
|
1415
|
+
}
|
|
1416
|
+
function pidAlive(pid) {
|
|
1417
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
1418
|
+
try {
|
|
1419
|
+
process.kill(pid, 0);
|
|
1420
|
+
return true;
|
|
1421
|
+
} catch (e) {
|
|
1422
|
+
return e.code === "EPERM";
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
function formatBytes(n) {
|
|
1426
|
+
return n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
1427
|
+
}
|
|
1428
|
+
async function inspectDatabase(dbPath) {
|
|
1429
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
1430
|
+
const url = pathToFileURL(dbPath);
|
|
1431
|
+
url.search = existsSync4(`${dbPath}-wal`) || existsSync4(`${dbPath}-shm`) ? "mode=ro" : "mode=ro&immutable=1";
|
|
1432
|
+
const db = new DatabaseSync(url.href, { readOnly: true });
|
|
1433
|
+
try {
|
|
1434
|
+
const userVersion = db.prepare("PRAGMA user_version").get().user_version;
|
|
1435
|
+
const first = db.prepare("PRAGMA quick_check").all()[0] ?? {};
|
|
1436
|
+
const integrity = String(Object.values(first)[0] ?? "no result");
|
|
1437
|
+
const facts = { userVersion, integrity };
|
|
1438
|
+
if (integrity === "ok" && userVersion >= 1 && userVersion <= SCHEMA_VERSION) {
|
|
1439
|
+
facts.filesTracked = db.prepare("SELECT COUNT(DISTINCT path) AS c FROM file_versions").get().c;
|
|
1440
|
+
facts.pids = db.prepare("SELECT pid FROM sessions").all().map((r) => r.pid);
|
|
1441
|
+
}
|
|
1442
|
+
return facts;
|
|
1443
|
+
} finally {
|
|
1444
|
+
db.close();
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
function checkDatabaseFiles(dir, now) {
|
|
1448
|
+
const out = [];
|
|
1449
|
+
const names = readdirSync2(dir);
|
|
1450
|
+
const leftovers = names.filter((n) => /^stash\.db\.corrupt-\d+$/.test(n));
|
|
1451
|
+
if (leftovers.length > 0) {
|
|
1452
|
+
out.push(result("database-leftovers", "info", `${leftovers.length} moved-aside corrupt database file(s) in ${dir} (stash.db.corrupt-*); delete them when no longer needed`));
|
|
1453
|
+
}
|
|
1454
|
+
if (names.includes("stash.db.recover.lock")) {
|
|
1455
|
+
const lock = join4(dir, "stash.db.recover.lock");
|
|
1456
|
+
const age = now - statSync2(lock).mtimeMs;
|
|
1457
|
+
if (age > RECOVERY_LOCK_STALE_MS2) {
|
|
1458
|
+
out.push(result("database-lock", "warn", `stale recovery lock ${lock} (${Math.round(age / 1e3)}s old)`, `remove ${lock}; the next start also clears it`));
|
|
1459
|
+
} else {
|
|
1460
|
+
out.push(result("database-lock", "info", `a recovery is in progress (${lock})`));
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
return out;
|
|
1464
|
+
}
|
|
1465
|
+
async function checkDatabase(o) {
|
|
1466
|
+
const dir = resolveStashDir(o.cwd);
|
|
1467
|
+
const dbPath = join4(dir, "stash.db");
|
|
1468
|
+
const out = [];
|
|
1469
|
+
if (existsSync4(dir)) out.push(...checkDatabaseFiles(dir, o.now));
|
|
1470
|
+
if (!existsSync4(dbPath)) return [result("database", "info", `no database at ${dbPath} yet; it will be created on first read`), ...out];
|
|
1471
|
+
const st = statSync2(dbPath);
|
|
1472
|
+
let facts;
|
|
1473
|
+
try {
|
|
1474
|
+
facts = await inspectDatabase(dbPath);
|
|
1475
|
+
} catch (e) {
|
|
1476
|
+
if (isCorruptMessage(e)) {
|
|
1477
|
+
return [
|
|
1478
|
+
result("database", "error", `${dbPath} is corrupt (${reason(e)})`, `it will be moved aside to stash.db.corrupt-<ms> on next start; or delete it`),
|
|
1479
|
+
...out
|
|
1480
|
+
];
|
|
1481
|
+
}
|
|
1482
|
+
return [result("database", "warn", `could not inspect ${dbPath} without writing (${reason(e)})`, `stop running servers and re-run doctor`), ...out];
|
|
1483
|
+
}
|
|
1484
|
+
if (facts.integrity !== "ok") {
|
|
1485
|
+
out.unshift(result("database", "error", `${dbPath} failed quick_check (${facts.integrity})`, `it will be moved aside to stash.db.corrupt-<ms> on next start; or delete it`));
|
|
1486
|
+
return out;
|
|
1487
|
+
}
|
|
1488
|
+
if (facts.userVersion > SCHEMA_VERSION) {
|
|
1489
|
+
out.unshift(result("database-schema", "error", new SchemaTooNewError(facts.userVersion, SCHEMA_VERSION).message, "upgrade agent-file-stash or point FILESTASH_DIR at another directory"));
|
|
1490
|
+
} else if (facts.userVersion < SCHEMA_VERSION) {
|
|
1491
|
+
out.unshift(result("database-schema", "info", `schema version ${facts.userVersion} will be upgraded to ${SCHEMA_VERSION} on next start`));
|
|
1492
|
+
} else {
|
|
1493
|
+
out.unshift(result("database-schema", "ok", `database schema version ${facts.userVersion}, quick_check passed`));
|
|
1494
|
+
}
|
|
1495
|
+
if (o.platform !== "win32" && wide(st.mode, 63)) {
|
|
1496
|
+
out.push(result("database-mode", "warn", `${dbPath} has mode ${octal(st.mode)}, expected 600`, `run: chmod 600 ${dbPath}`));
|
|
1497
|
+
}
|
|
1498
|
+
const parts = [formatBytes(st.size)];
|
|
1499
|
+
if (facts.filesTracked !== void 0) parts.push(`${facts.filesTracked} files tracked`);
|
|
1500
|
+
if (facts.pids) {
|
|
1501
|
+
const live = facts.pids.filter(pidAlive).length;
|
|
1502
|
+
parts.push(`${live} live and ${facts.pids.length - live} dead session(s)`);
|
|
1503
|
+
}
|
|
1504
|
+
out.push(result("database-stats", "info", `database ${parts.join(", ")}`));
|
|
1505
|
+
return out;
|
|
1506
|
+
}
|
|
1507
|
+
function isExecutable(path) {
|
|
1508
|
+
try {
|
|
1509
|
+
if (!statSync2(path).isFile()) return false;
|
|
1510
|
+
accessSync(path, constants.X_OK);
|
|
1511
|
+
return true;
|
|
1512
|
+
} catch {
|
|
1513
|
+
return false;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
function commandResolves(command2, platform) {
|
|
1517
|
+
if (isAbsolute2(command2) || command2.includes("/") || command2.includes("\\")) return isExecutable(command2);
|
|
1518
|
+
const exts = platform === "win32" ? ["", ...(process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
|
|
1519
|
+
return (process.env.PATH ?? "").split(delimiter).filter(Boolean).some((dir) => exts.some((ext) => isExecutable(join4(dir, command2 + ext))));
|
|
1520
|
+
}
|
|
1521
|
+
function splitCommand(entry) {
|
|
1522
|
+
const raw = entry.command;
|
|
1523
|
+
if (Array.isArray(raw) && typeof raw[0] === "string") return { command: raw[0], args: raw.slice(1).map(String) };
|
|
1524
|
+
if (typeof raw !== "string") return void 0;
|
|
1525
|
+
return { command: raw, args: Array.isArray(entry.args) ? entry.args.map(String) : [] };
|
|
1526
|
+
}
|
|
1527
|
+
function checkMcpRegistration(o) {
|
|
1528
|
+
const out = [];
|
|
1529
|
+
let found = 0;
|
|
1530
|
+
for (const t of editorTargets(o.home)) {
|
|
1531
|
+
if (!existsSync4(t.path)) continue;
|
|
1532
|
+
found++;
|
|
1533
|
+
const id = `mcp-${t.name.toLowerCase().replace(/\s+/g, "-")}`;
|
|
1534
|
+
let config;
|
|
1535
|
+
try {
|
|
1536
|
+
config = readJson(t.path);
|
|
1537
|
+
} catch {
|
|
1538
|
+
out.push(result(id, "warn", `${t.name}: ${t.path} is not valid JSON`, `fix the file, then run: agent-file-stash init`));
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
const entry = asRecord(asRecord(asRecord(config)?.[t.key])?.filestash);
|
|
1542
|
+
if (!entry) {
|
|
1543
|
+
out.push(result(id, "warn", `${t.name}: no filestash entry in ${t.path}`, "run: agent-file-stash init"));
|
|
1544
|
+
continue;
|
|
1545
|
+
}
|
|
1546
|
+
const cmd = splitCommand(entry);
|
|
1547
|
+
if (!cmd) {
|
|
1548
|
+
out.push(result(id, "warn", `${t.name}: the filestash entry in ${t.path} has no command`, "run: agent-file-stash init"));
|
|
1549
|
+
} else if (!commandResolves(cmd.command, o.platform)) {
|
|
1550
|
+
out.push(result(id, "warn", `${t.name}: command "${cmd.command}" was not found or is not executable`, `install it or fix the command in ${t.path}`));
|
|
1551
|
+
} else if (!cmd.args.includes("serve")) {
|
|
1552
|
+
out.push(result(id, "warn", `${t.name}: the filestash entry does not run "serve"`, `set the arguments to: agent-file-stash serve`));
|
|
1553
|
+
} else {
|
|
1554
|
+
out.push(result(id, "ok", `${t.name}: filestash registered in ${t.path}`));
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
if (found === 0) return [result("mcp", "info", "no editor configuration found (Claude Code, Cursor, OpenCode)")];
|
|
1558
|
+
return out;
|
|
1559
|
+
}
|
|
1560
|
+
function matcherCovers(matcher, event) {
|
|
1561
|
+
if (matcher === void 0 || matcher === "" || matcher === "*") return true;
|
|
1562
|
+
if (typeof matcher !== "string") return false;
|
|
1563
|
+
try {
|
|
1564
|
+
return new RegExp(`^(?:${matcher})$`).test(event);
|
|
1565
|
+
} catch {
|
|
1566
|
+
return matcher.split("|").includes(event);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
function commandRuns(command2, o, subcommand) {
|
|
1570
|
+
const mentions = (text) => text.includes(PACKAGE_NAME) && subcommand.test(text);
|
|
1571
|
+
if (mentions(command2)) return true;
|
|
1572
|
+
const target = command2.trim().replace(/^(?:ba|z)?sh\s+/, "").split(/\s+/)[0].replace(/^["']|["']$/g, "").replace(/^~(?=\/)/, o.home).replace(/\$\{?(?:HOME)\}?/, o.home).replace(/\$\{?CLAUDE_PROJECT_DIR\}?/, o.cwd);
|
|
1573
|
+
try {
|
|
1574
|
+
const path = resolve4(o.cwd, target);
|
|
1575
|
+
if (!statSync2(path).isFile() || statSync2(path).size > MAX_SCRIPT_BYTES) return false;
|
|
1576
|
+
return mentions(readFileSync3(path, "utf-8"));
|
|
1577
|
+
} catch {
|
|
1578
|
+
return false;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
function checkResetHook(o) {
|
|
1582
|
+
const out = [];
|
|
1583
|
+
const covered = /* @__PURE__ */ new Set();
|
|
1584
|
+
let hookFile;
|
|
1585
|
+
let hookFound = false;
|
|
1586
|
+
const files = [
|
|
1587
|
+
join4(o.home, ".claude", "settings.json"),
|
|
1588
|
+
join4(o.cwd, ".claude", "settings.json"),
|
|
1589
|
+
join4(o.cwd, ".claude", "settings.local.json")
|
|
1590
|
+
];
|
|
1591
|
+
for (const file of files) {
|
|
1592
|
+
if (!existsSync4(file)) continue;
|
|
1593
|
+
let settings;
|
|
1594
|
+
try {
|
|
1595
|
+
settings = readJson(file);
|
|
1596
|
+
} catch {
|
|
1597
|
+
out.push(result("hook-settings", "warn", `${file} is not valid JSON`, "fix the file so hooks in it can be checked"));
|
|
1598
|
+
continue;
|
|
1599
|
+
}
|
|
1600
|
+
const sessionStart = asRecord(asRecord(settings)?.hooks)?.SessionStart;
|
|
1601
|
+
for (const entry of Array.isArray(sessionStart) ? sessionStart : []) {
|
|
1602
|
+
const e = asRecord(entry);
|
|
1603
|
+
const hooks = Array.isArray(e?.hooks) ? e.hooks : [];
|
|
1604
|
+
const runs = hooks.some((h) => typeof asRecord(h)?.command === "string" && commandRuns(asRecord(h).command, o, /\breset\b/));
|
|
1605
|
+
if (!runs) continue;
|
|
1606
|
+
hookFound = true;
|
|
1607
|
+
hookFile ??= file;
|
|
1608
|
+
for (const ev of SESSION_START_TARGETS) if (matcherCovers(e?.matcher, ev)) covered.add(ev);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
if (!hookFound) {
|
|
1612
|
+
out.push(result("hook", "warn", "no SessionStart hook runs 'agent-file-stash reset'", "run: agent-file-stash init --hooks (without it a read after /clear or /compact can come back as unchanged)"));
|
|
1613
|
+
return out;
|
|
1614
|
+
}
|
|
1615
|
+
const missing = SESSION_START_TARGETS.filter((ev) => !covered.has(ev));
|
|
1616
|
+
if (missing.length > 0) {
|
|
1617
|
+
out.push(result("hook", "warn", `reset hook in ${hookFile} does not match ${missing.join(" or ")}`, `set the matcher to "clear|compact"`));
|
|
1618
|
+
} else {
|
|
1619
|
+
out.push(result("hook", "ok", `reset hook configured for clear and compact (${hookFile})`));
|
|
1620
|
+
}
|
|
1621
|
+
return out;
|
|
1622
|
+
}
|
|
1623
|
+
function checkSubagentScopeHook(o) {
|
|
1624
|
+
const files = [
|
|
1625
|
+
join4(o.home, ".claude", "settings.json"),
|
|
1626
|
+
join4(o.cwd, ".claude", "settings.json"),
|
|
1627
|
+
join4(o.cwd, ".claude", "settings.local.json")
|
|
1628
|
+
];
|
|
1629
|
+
for (const file of files) {
|
|
1630
|
+
if (!existsSync4(file)) continue;
|
|
1631
|
+
let settings;
|
|
1632
|
+
try {
|
|
1633
|
+
settings = readJson(file);
|
|
1634
|
+
} catch {
|
|
1635
|
+
continue;
|
|
1636
|
+
}
|
|
1637
|
+
const preToolUse = asRecord(asRecord(settings)?.hooks)?.PreToolUse;
|
|
1638
|
+
for (const entry of Array.isArray(preToolUse) ? preToolUse : []) {
|
|
1639
|
+
const e = asRecord(entry);
|
|
1640
|
+
const hooks = Array.isArray(e?.hooks) ? e.hooks : [];
|
|
1641
|
+
const runs = hooks.some((h) => typeof asRecord(h)?.command === "string" && commandRuns(asRecord(h).command, o, /\bhook\s+subagent-scope\b/));
|
|
1642
|
+
if (runs && matcherCovers(e?.matcher, "mcp__filestash__read_file")) {
|
|
1643
|
+
return [result("hook-subagent-scope", "ok", `subagent scope hook configured (${file})`)];
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
return [result("hook-subagent-scope", "warn", "no PreToolUse hook runs 'agent-file-stash hook subagent-scope' for the stash read tools", "run: agent-file-stash init --hooks (without it subagents share read tracking with the main agent)")];
|
|
1648
|
+
}
|
|
1649
|
+
function checkEnv() {
|
|
1650
|
+
const out = [];
|
|
1651
|
+
const exclude = process.env.FILESTASH_EXCLUDE;
|
|
1652
|
+
if (exclude !== void 0 && exclude !== "") {
|
|
1653
|
+
const n = exclude.split(",").filter((p) => p.trim()).length;
|
|
1654
|
+
out.push(result("env-exclude", "ok", `FILESTASH_EXCLUDE: ${n} pattern(s)`));
|
|
1655
|
+
}
|
|
1656
|
+
for (const name of ["FILESTASH_MAX_LINES", "FILESTASH_MAX_CHARS"]) {
|
|
1657
|
+
const raw = process.env[name];
|
|
1658
|
+
if (raw === void 0 || raw === "") continue;
|
|
1659
|
+
const valid = /^[1-9]\d*$/.test(raw) && Number.isSafeInteger(Number(raw));
|
|
1660
|
+
out.push(
|
|
1661
|
+
valid ? result(`env-${name.toLowerCase()}`, "ok", `${name}=${raw}`) : result(`env-${name.toLowerCase()}`, "warn", `${name}=${JSON.stringify(raw)} is not a positive integer and is ignored`, `set ${name} to a positive integer or unset it`)
|
|
1662
|
+
);
|
|
1663
|
+
}
|
|
1664
|
+
return out;
|
|
1665
|
+
}
|
|
1666
|
+
function npmLatestVersion() {
|
|
1667
|
+
const res = spawnSync("npm", ["view", PACKAGE_NAME, "version"], {
|
|
1668
|
+
encoding: "utf-8",
|
|
1669
|
+
timeout: NPM_TIMEOUT_MS,
|
|
1670
|
+
shell: process.platform === "win32"
|
|
1671
|
+
});
|
|
1672
|
+
const version = res.status === 0 ? res.stdout.trim() : "";
|
|
1673
|
+
return /^\d+\.\d+\.\d+/.test(version) ? version : void 0;
|
|
1674
|
+
}
|
|
1675
|
+
function newer(a, b) {
|
|
1676
|
+
const pa = a.split(/[.-]/).map(Number);
|
|
1677
|
+
const pb = b.split(/[.-]/).map(Number);
|
|
1678
|
+
for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
|
|
1679
|
+
return false;
|
|
1680
|
+
}
|
|
1681
|
+
function checkUpdates(o) {
|
|
1682
|
+
if (!o.checkUpdates) return [];
|
|
1683
|
+
const installed = ownPackageJson()?.version;
|
|
1684
|
+
const latest = o.fetchLatest();
|
|
1685
|
+
if (!installed || !latest) return [result("updates", "info", "could not check for updates")];
|
|
1686
|
+
if (newer(latest, installed)) return [result("updates", "info", `update available: ${installed} -> ${latest} (npm install -g ${PACKAGE_NAME})`)];
|
|
1687
|
+
return [result("updates", "ok", `agent-file-stash ${installed} is up to date`)];
|
|
1688
|
+
}
|
|
1689
|
+
var CHECKS = [
|
|
1690
|
+
["node", checkNode],
|
|
1691
|
+
["stash-dir", checkStashDir],
|
|
1692
|
+
["database", checkDatabase],
|
|
1693
|
+
["mcp", checkMcpRegistration],
|
|
1694
|
+
["hook", checkResetHook],
|
|
1695
|
+
["hook-subagent-scope", checkSubagentScopeHook],
|
|
1696
|
+
["env", checkEnv],
|
|
1697
|
+
["updates", checkUpdates]
|
|
1698
|
+
];
|
|
1699
|
+
function defaultOptions(argv = []) {
|
|
1700
|
+
return {
|
|
1701
|
+
nodeVersion: process.versions.node,
|
|
1702
|
+
cwd: process.cwd(),
|
|
1703
|
+
home: homedir(),
|
|
1704
|
+
platform: process.platform,
|
|
1705
|
+
now: Date.now(),
|
|
1706
|
+
checkUpdates: argv.includes("--check-updates"),
|
|
1707
|
+
fetchLatest: npmLatestVersion
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
async function runChecks(o, checks = CHECKS) {
|
|
1711
|
+
const results = [];
|
|
1712
|
+
for (const [id, check] of checks) {
|
|
1713
|
+
try {
|
|
1714
|
+
results.push(...await check(o));
|
|
1715
|
+
} catch (e) {
|
|
1716
|
+
results.push(result(id, "warn", `check failed: ${reason(e)}`));
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
return results;
|
|
1720
|
+
}
|
|
1721
|
+
function summarize(results) {
|
|
1722
|
+
const count = (s) => results.filter((r) => r.status === s).length;
|
|
1723
|
+
return { ok: count("ok"), warnings: count("warn"), errors: count("error") };
|
|
1724
|
+
}
|
|
1725
|
+
function formatHuman(results) {
|
|
1726
|
+
const lines = results.flatMap((r) => [`[${r.status}] ${r.message}`, ...r.hint ? [` ${r.hint}`] : []]);
|
|
1727
|
+
const s = summarize(results);
|
|
1728
|
+
return [...lines, "", `${s.ok} ok, ${s.warnings} warnings, ${s.errors} errors`].join("\n");
|
|
1729
|
+
}
|
|
1730
|
+
async function runDoctor(argv, overrides = {}) {
|
|
1731
|
+
const results = await runChecks({ ...defaultOptions(argv), ...overrides });
|
|
1732
|
+
console.log(argv.includes("--json") ? JSON.stringify(results, null, 2) : formatHuman(results));
|
|
1733
|
+
return summarize(results).errors > 0 ? 1 : 0;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// packages/cli/src/hook.ts
|
|
1737
|
+
var HOOK_STDIN_TIMEOUT_MS = 1e3;
|
|
1738
|
+
var AGENT_ID_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
1739
|
+
var STASH_READ_TOOL = /__read_files?$/;
|
|
1740
|
+
async function readHookStdin() {
|
|
1741
|
+
if (process.stdin.isTTY) return "";
|
|
1742
|
+
const read = (async () => {
|
|
1743
|
+
let raw = "";
|
|
1744
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
1745
|
+
return raw;
|
|
1746
|
+
})();
|
|
1747
|
+
const timeout = new Promise((res) => setTimeout(() => res(""), HOOK_STDIN_TIMEOUT_MS).unref());
|
|
1748
|
+
return (await Promise.race([read, timeout])).trim();
|
|
1749
|
+
}
|
|
1750
|
+
function subagentScopeOutput(raw) {
|
|
1751
|
+
try {
|
|
1752
|
+
const input = JSON.parse(raw);
|
|
1753
|
+
const { agent_id: agentId, tool_name: toolName, tool_input: toolInput } = input;
|
|
1754
|
+
if (typeof agentId !== "string" || !AGENT_ID_PATTERN.test(agentId)) return void 0;
|
|
1755
|
+
if (typeof toolName !== "string" || !STASH_READ_TOOL.test(toolName)) return void 0;
|
|
1756
|
+
if (typeof toolInput !== "object" || toolInput === null || Array.isArray(toolInput)) return void 0;
|
|
1757
|
+
return JSON.stringify({
|
|
1758
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput: { ...toolInput, agent: agentId } }
|
|
1759
|
+
});
|
|
1760
|
+
} catch {
|
|
1761
|
+
return void 0;
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
async function runSubagentScopeHook() {
|
|
1765
|
+
try {
|
|
1766
|
+
const out = subagentScopeOutput(await readHookStdin());
|
|
1767
|
+
if (out) process.stdout.write(out + "\n");
|
|
1768
|
+
} catch {
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
|
|
740
1772
|
// packages/cli/src/index.ts
|
|
741
1773
|
var _origEmitWarning = process.emitWarning;
|
|
742
1774
|
process.emitWarning = function(msg, ...args) {
|
|
@@ -744,34 +1776,127 @@ process.emitWarning = function(msg, ...args) {
|
|
|
744
1776
|
return _origEmitWarning.apply(process, [msg, ...args]);
|
|
745
1777
|
};
|
|
746
1778
|
var CLI_STATUS_SESSION = "cli-status";
|
|
1779
|
+
var RESET_HOOK_COMMAND = "npx agent-file-stash reset --from-hook";
|
|
1780
|
+
var RESET_HOOK_ENTRY = {
|
|
1781
|
+
matcher: "clear|compact",
|
|
1782
|
+
hooks: [{ type: "command", command: RESET_HOOK_COMMAND }]
|
|
1783
|
+
};
|
|
1784
|
+
var SCOPE_HOOK_COMMAND = "npx agent-file-stash hook subagent-scope";
|
|
1785
|
+
var SCOPE_HOOK_ENTRY = {
|
|
1786
|
+
matcher: "mcp__(filestash|agent-file-stash)__read_files?",
|
|
1787
|
+
hooks: [{ type: "command", command: SCOPE_HOOK_COMMAND }]
|
|
1788
|
+
};
|
|
1789
|
+
function assertHealthy(stash) {
|
|
1790
|
+
if (stash.isDegraded) throw new Error(stash.degradedReason);
|
|
1791
|
+
}
|
|
1792
|
+
async function readHookCwd() {
|
|
1793
|
+
const raw = await readHookStdin();
|
|
1794
|
+
if (!raw) return void 0;
|
|
1795
|
+
try {
|
|
1796
|
+
const cwd = JSON.parse(raw)?.cwd;
|
|
1797
|
+
return typeof cwd === "string" && cwd !== "" ? cwd : void 0;
|
|
1798
|
+
} catch {
|
|
1799
|
+
return void 0;
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
async function runReset(fromHook) {
|
|
1803
|
+
try {
|
|
1804
|
+
const hookCwd = fromHook ? await readHookCwd() : void 0;
|
|
1805
|
+
const dbPath = join5(resolveStashDir(hookCwd), "stash.db");
|
|
1806
|
+
if (!existsSync5(dbPath)) {
|
|
1807
|
+
if (!fromHook) console.log("No filestash database found. Nothing to reset.");
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
const { stash } = createStash({ dbPath, sessionId: CLI_STATUS_SESSION, recoverCorrupt: false, quiet: true });
|
|
1811
|
+
try {
|
|
1812
|
+
await stash.init();
|
|
1813
|
+
assertHealthy(stash);
|
|
1814
|
+
await stash.resetReads();
|
|
1815
|
+
assertHealthy(stash);
|
|
1816
|
+
} finally {
|
|
1817
|
+
await stash.close();
|
|
1818
|
+
}
|
|
1819
|
+
if (!fromHook) console.log("Read tracking reset. The next read of each file returns full content.");
|
|
1820
|
+
} catch (e) {
|
|
1821
|
+
console.error(`filestash reset failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1822
|
+
process.exit(fromHook ? 0 : 1);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
function hooksSnippet() {
|
|
1826
|
+
return JSON.stringify({ hooks: { SessionStart: [RESET_HOOK_ENTRY], PreToolUse: [SCOPE_HOOK_ENTRY] } }, null, 2);
|
|
1827
|
+
}
|
|
1828
|
+
var HOOK_INSTALLS = [
|
|
1829
|
+
{ event: "SessionStart", entry: RESET_HOOK_ENTRY },
|
|
1830
|
+
{ event: "PreToolUse", entry: SCOPE_HOOK_ENTRY }
|
|
1831
|
+
];
|
|
1832
|
+
function hasHookCommand(entries, command2) {
|
|
1833
|
+
return Array.isArray(entries) && entries.some((e) => e?.hooks?.some((h) => h?.command === command2));
|
|
1834
|
+
}
|
|
1835
|
+
function installClaudeHook(home) {
|
|
1836
|
+
const settingsPath = join5(home, ".claude", "settings.json");
|
|
1837
|
+
let settings = {};
|
|
1838
|
+
const existed = existsSync5(settingsPath);
|
|
1839
|
+
if (existed) {
|
|
1840
|
+
try {
|
|
1841
|
+
settings = JSON.parse(readFileSync4(settingsPath, "utf-8"));
|
|
1842
|
+
} catch {
|
|
1843
|
+
console.error(` Claude Code hooks: ${settingsPath} is not valid JSON, left untouched`);
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
const hooks = settings.hooks ?? {};
|
|
1848
|
+
const missing = HOOK_INSTALLS.filter(({ event, entry }) => !hasHookCommand(hooks[event], entry.hooks[0].command));
|
|
1849
|
+
if (missing.length === 0) {
|
|
1850
|
+
console.log(" Claude Code hooks: already configured");
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
if (existed) copyFileSync(settingsPath, `${settingsPath}.bak`);
|
|
1854
|
+
else mkdirSync2(join5(home, ".claude"), { recursive: true });
|
|
1855
|
+
const merged = { ...hooks };
|
|
1856
|
+
for (const { event, entry } of missing) {
|
|
1857
|
+
merged[event] = [...Array.isArray(hooks[event]) ? hooks[event] : [], entry];
|
|
1858
|
+
}
|
|
1859
|
+
settings.hooks = merged;
|
|
1860
|
+
writeFileSync2(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
1861
|
+
console.log(` Claude Code hooks: configured (${settingsPath})`);
|
|
1862
|
+
}
|
|
747
1863
|
async function runStatus() {
|
|
748
|
-
const stashDir =
|
|
749
|
-
const dbPath =
|
|
750
|
-
if (!
|
|
751
|
-
console.log("No filestash database found. Run '
|
|
1864
|
+
const stashDir = resolve5(process.env.FILESTASH_DIR ?? ".file-stash");
|
|
1865
|
+
const dbPath = join5(stashDir, "stash.db");
|
|
1866
|
+
if (!existsSync5(dbPath)) {
|
|
1867
|
+
console.log("No filestash database found. Run 'agent-file-stash serve' to start stashing.");
|
|
752
1868
|
process.exit(0);
|
|
753
1869
|
}
|
|
754
|
-
const { stash } = createStash({ dbPath, sessionId: CLI_STATUS_SESSION });
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
1870
|
+
const { stash } = createStash({ dbPath, sessionId: CLI_STATUS_SESSION, recoverCorrupt: false, quiet: true });
|
|
1871
|
+
try {
|
|
1872
|
+
await stash.init();
|
|
1873
|
+
assertHealthy(stash);
|
|
1874
|
+
const stats = await stash.getStats();
|
|
1875
|
+
assertHealthy(stash);
|
|
1876
|
+
console.log(`filestash status:`);
|
|
1877
|
+
console.log(` Files tracked: ${stats.filesTracked}`);
|
|
1878
|
+
console.log(` Tokens saved (total): ~${stats.tokensSaved.toLocaleString()}`);
|
|
1879
|
+
} catch (e) {
|
|
1880
|
+
console.error(`filestash status failed for ${dbPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
1881
|
+
process.exitCode = 1;
|
|
1882
|
+
} finally {
|
|
1883
|
+
await stash.close();
|
|
1884
|
+
}
|
|
761
1885
|
}
|
|
762
1886
|
async function runStatusAll(root) {
|
|
763
|
-
const databases = findStashDatabases(
|
|
1887
|
+
const databases = findStashDatabases(resolve5(root));
|
|
764
1888
|
if (databases.length === 0) {
|
|
765
|
-
console.log(`No filestash databases found under ${
|
|
1889
|
+
console.log(`No filestash databases found under ${resolve5(root)}.`);
|
|
766
1890
|
return;
|
|
767
1891
|
}
|
|
768
1892
|
const rows = [];
|
|
769
1893
|
for (const dbPath of databases) {
|
|
770
|
-
const { stash } = createStash({ dbPath, sessionId: CLI_STATUS_SESSION });
|
|
1894
|
+
const { stash } = createStash({ dbPath, sessionId: CLI_STATUS_SESSION, recoverCorrupt: false, quiet: true });
|
|
771
1895
|
try {
|
|
772
1896
|
await stash.init();
|
|
773
1897
|
const stats = await stash.getStats();
|
|
774
|
-
|
|
1898
|
+
assertHealthy(stash);
|
|
1899
|
+
rows.push({ project: resolve5(dbPath, "../..").replace(/\/\.vscode$/, ""), files: stats.filesTracked, tokens: stats.tokensSaved });
|
|
775
1900
|
} catch (e) {
|
|
776
1901
|
console.error(` skipped ${dbPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
777
1902
|
} finally {
|
|
@@ -781,7 +1906,7 @@ async function runStatusAll(root) {
|
|
|
781
1906
|
rows.sort((a, b) => b.tokens - a.tokens);
|
|
782
1907
|
const totalTokens = rows.reduce((sum, r) => sum + r.tokens, 0);
|
|
783
1908
|
const totalFiles = rows.reduce((sum, r) => sum + r.files, 0);
|
|
784
|
-
console.log(`filestash status (${rows.length} databases under ${
|
|
1909
|
+
console.log(`filestash status (${rows.length} databases under ${resolve5(root)}):`);
|
|
785
1910
|
for (const r of rows) {
|
|
786
1911
|
console.log(` ~${r.tokens.toLocaleString().padStart(10)} tokens ${String(r.files).padStart(5)} files ${r.project}`);
|
|
787
1912
|
}
|
|
@@ -789,45 +1914,17 @@ async function runStatusAll(root) {
|
|
|
789
1914
|
console.log(`
|
|
790
1915
|
Savings count re-reads within a session (unchanged files and diffs); a new session always receives full content.`);
|
|
791
1916
|
}
|
|
792
|
-
async function runInit() {
|
|
793
|
-
const home =
|
|
794
|
-
const
|
|
795
|
-
command: "npx",
|
|
796
|
-
args: ["agent-file-stash", "serve"]
|
|
797
|
-
};
|
|
798
|
-
const opencodeMcpEntry = {
|
|
799
|
-
type: "local",
|
|
800
|
-
command: ["npx", "agent-file-stash", "serve"]
|
|
801
|
-
};
|
|
802
|
-
const xdgConfig = process.env.XDG_CONFIG_HOME || join3(home, ".config");
|
|
803
|
-
const targets = [
|
|
804
|
-
{
|
|
805
|
-
name: "Claude Code",
|
|
806
|
-
path: join3(home, ".claude.json"),
|
|
807
|
-
key: "mcpServers",
|
|
808
|
-
entry: mcpServersEntry
|
|
809
|
-
},
|
|
810
|
-
{
|
|
811
|
-
name: "Cursor",
|
|
812
|
-
path: join3(home, ".cursor", "mcp.json"),
|
|
813
|
-
key: "mcpServers",
|
|
814
|
-
entry: mcpServersEntry
|
|
815
|
-
},
|
|
816
|
-
{
|
|
817
|
-
name: "OpenCode",
|
|
818
|
-
path: join3(xdgConfig, "opencode", "opencode.json"),
|
|
819
|
-
key: "mcp",
|
|
820
|
-
entry: opencodeMcpEntry
|
|
821
|
-
}
|
|
822
|
-
];
|
|
1917
|
+
async function runInit(withHooks) {
|
|
1918
|
+
const home = homedir2();
|
|
1919
|
+
const targets = editorTargets(home);
|
|
823
1920
|
let configured = 0;
|
|
824
1921
|
for (const target of targets) {
|
|
825
|
-
const dir =
|
|
826
|
-
if (!
|
|
1922
|
+
const dir = join5(target.path, "..");
|
|
1923
|
+
if (!existsSync5(dir)) continue;
|
|
827
1924
|
let config = {};
|
|
828
|
-
if (
|
|
1925
|
+
if (existsSync5(target.path)) {
|
|
829
1926
|
try {
|
|
830
|
-
config = JSON.parse(
|
|
1927
|
+
config = JSON.parse(readFileSync4(target.path, "utf-8"));
|
|
831
1928
|
} catch {
|
|
832
1929
|
config = {};
|
|
833
1930
|
}
|
|
@@ -839,16 +1936,16 @@ async function runInit() {
|
|
|
839
1936
|
continue;
|
|
840
1937
|
}
|
|
841
1938
|
config[target.key] = { ...section, filestash: target.entry };
|
|
842
|
-
|
|
1939
|
+
writeFileSync2(target.path, JSON.stringify(config, null, 2) + "\n");
|
|
843
1940
|
console.log(` ${target.name}: configured (${target.path})`);
|
|
844
1941
|
configured++;
|
|
845
1942
|
}
|
|
846
1943
|
if (configured === 0) {
|
|
847
|
-
console.log("No supported tools detected. You can manually add
|
|
848
|
-
console.log(JSON.stringify({ mcpServers: { "agent-file-stash":
|
|
1944
|
+
console.log("No supported tools detected. You can manually add agent-file-stash to your MCP config:");
|
|
1945
|
+
console.log(JSON.stringify({ mcpServers: { "agent-file-stash": targets[0].entry } }, null, 2));
|
|
849
1946
|
} else {
|
|
850
1947
|
console.log(`
|
|
851
|
-
Done! Restart your editor to pick up
|
|
1948
|
+
Done! Restart your editor to pick up agent-file-stash.`);
|
|
852
1949
|
console.log(`
|
|
853
1950
|
Available MCP tools:`);
|
|
854
1951
|
console.log(` read_file Read a file, returning only a diff if unchanged since last read`);
|
|
@@ -858,33 +1955,59 @@ Available MCP tools:`);
|
|
|
858
1955
|
console.log(`
|
|
859
1956
|
Stash location: FILESTASH_DIR env var (default: .file-stash in cwd)`);
|
|
860
1957
|
}
|
|
1958
|
+
console.log(`
|
|
1959
|
+
Context resets: after /clear or /compact the model loses file contents the stash still considers read.`);
|
|
1960
|
+
if (withHooks) {
|
|
1961
|
+
installClaudeHook(home);
|
|
1962
|
+
} else {
|
|
1963
|
+
console.log(`Add this to ~/.claude/settings.json (or re-run 'init --hooks' to merge it):
|
|
1964
|
+
`);
|
|
1965
|
+
console.log(hooksSnippet());
|
|
1966
|
+
}
|
|
861
1967
|
}
|
|
862
1968
|
function runHelp() {
|
|
863
1969
|
console.log(`agent-file-stash - Agent file stash with diff tracking
|
|
864
1970
|
|
|
865
1971
|
Usage:
|
|
866
1972
|
agent-file-stash init Auto-configure for your editor
|
|
1973
|
+
agent-file-stash init --hooks
|
|
1974
|
+
Also add the Claude Code hooks (context reset, subagent read scope) to ~/.claude/settings.json
|
|
1975
|
+
agent-file-stash reset Forget what was read so the next read returns full content
|
|
1976
|
+
(--from-hook: quiet mode for Claude Code hooks)
|
|
1977
|
+
agent-file-stash hook subagent-scope
|
|
1978
|
+
PreToolUse hook that gives each subagent its own read tracking (no output otherwise)
|
|
867
1979
|
agent-file-stash serve Start the MCP server (default)
|
|
868
1980
|
agent-file-stash status Show stash statistics
|
|
869
1981
|
agent-file-stash status --all [dir]
|
|
870
1982
|
Sum statistics of every stash under dir (default: home)
|
|
1983
|
+
agent-file-stash doctor Check the install and report what to fix (read-only)
|
|
1984
|
+
(--json: machine-readable output, --check-updates: ask npm for the latest version)
|
|
871
1985
|
agent-file-stash help Show this help message
|
|
872
1986
|
|
|
873
1987
|
Environment:
|
|
874
|
-
FILESTASH_DIR Stash directory (default: .file-stash)
|
|
1988
|
+
FILESTASH_DIR Stash directory (default: .file-stash)
|
|
1989
|
+
FILESTASH_EXCLUDE Extra comma-separated file name globs that are never stored
|
|
1990
|
+
FILESTASH_MAX_LINES Max lines returned per read (default: 2000)
|
|
1991
|
+
FILESTASH_MAX_CHARS Max characters returned per read (default: 100000)`);
|
|
875
1992
|
}
|
|
876
1993
|
var command = process.argv[2];
|
|
877
1994
|
if (!command || command === "serve") {
|
|
878
1995
|
await startMcpServer();
|
|
879
1996
|
} else if (command === "status" && process.argv[3] === "--all") {
|
|
880
|
-
await runStatusAll(process.argv[4] ??
|
|
1997
|
+
await runStatusAll(process.argv[4] ?? homedir2());
|
|
881
1998
|
} else if (command === "status") {
|
|
882
1999
|
await runStatus();
|
|
883
2000
|
} else if (command === "init") {
|
|
884
|
-
await runInit();
|
|
2001
|
+
await runInit(process.argv.includes("--hooks"));
|
|
2002
|
+
} else if (command === "reset") {
|
|
2003
|
+
await runReset(process.argv.includes("--from-hook"));
|
|
2004
|
+
} else if (command === "hook" && process.argv[3] === "subagent-scope") {
|
|
2005
|
+
await runSubagentScopeHook();
|
|
2006
|
+
} else if (command === "doctor") {
|
|
2007
|
+
process.exitCode = await runDoctor(process.argv.slice(3));
|
|
885
2008
|
} else if (command === "help" || command === "--help") {
|
|
886
2009
|
runHelp();
|
|
887
2010
|
} else {
|
|
888
|
-
console.error(`Unknown command: ${command}. Run '
|
|
2011
|
+
console.error(`Unknown command: ${command}. Run 'agent-file-stash help' for usage.`);
|
|
889
2012
|
process.exit(1);
|
|
890
2013
|
}
|