agent-file-stash 0.5.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/README.md +56 -8
- package/dist/cli.mjs +728 -194
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -7,39 +7,111 @@ 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
|
+
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() };
|
|
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);
|
|
15
30
|
const changedNewLines = collectChangedLines(rawLines);
|
|
16
|
-
if (linesChanged === 0) {
|
|
17
|
-
return { diff: "", linesChanged: 0, hasChanges: false, changedNewLines };
|
|
18
|
-
}
|
|
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,38 +175,6 @@ function formatDiff(hunkGroups, filePath) {
|
|
|
108
175
|
}
|
|
109
176
|
return lines.join("\n");
|
|
110
177
|
}
|
|
111
|
-
var LCS_LINE_LIMIT = 5e3;
|
|
112
|
-
function longestCommonSubsequence(a, b) {
|
|
113
|
-
const m = a.length;
|
|
114
|
-
const n = b.length;
|
|
115
|
-
if (m > LCS_LINE_LIMIT || n > LCS_LINE_LIMIT) {
|
|
116
|
-
return [];
|
|
117
|
-
}
|
|
118
|
-
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
119
|
-
for (let i2 = 1; i2 <= m; i2++) {
|
|
120
|
-
for (let j2 = 1; j2 <= n; j2++) {
|
|
121
|
-
if (a[i2 - 1] === b[j2 - 1]) {
|
|
122
|
-
dp[i2][j2] = dp[i2 - 1][j2 - 1] + 1;
|
|
123
|
-
} else {
|
|
124
|
-
dp[i2][j2] = Math.max(dp[i2 - 1][j2], dp[i2][j2 - 1]);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
const result = [];
|
|
129
|
-
let i = m, j = n;
|
|
130
|
-
while (i > 0 && j > 0) {
|
|
131
|
-
if (a[i - 1] === b[j - 1]) {
|
|
132
|
-
result.unshift(a[i - 1]);
|
|
133
|
-
i--;
|
|
134
|
-
j--;
|
|
135
|
-
} else if (dp[i - 1][j] > dp[i][j - 1]) {
|
|
136
|
-
i--;
|
|
137
|
-
} else {
|
|
138
|
-
j--;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
return result;
|
|
142
|
-
}
|
|
143
178
|
|
|
144
179
|
// packages/sdk/src/exclude.ts
|
|
145
180
|
import { basename, dirname } from "node:path";
|
|
@@ -173,8 +208,8 @@ function isExcludedPath(absPath, extraPatterns = []) {
|
|
|
173
208
|
return dirname(absPath).split(/[\\/]/).some((seg) => SECRET_DIRS.has(seg.toLowerCase()));
|
|
174
209
|
}
|
|
175
210
|
|
|
176
|
-
// packages/sdk/src/
|
|
177
|
-
var
|
|
211
|
+
// packages/sdk/src/migrations.ts
|
|
212
|
+
var SCHEMA_V1 = `
|
|
178
213
|
CREATE TABLE IF NOT EXISTS file_versions (
|
|
179
214
|
path TEXT NOT NULL,
|
|
180
215
|
hash TEXT NOT NULL,
|
|
@@ -221,11 +256,54 @@ CREATE TABLE IF NOT EXISTS session_stats (
|
|
|
221
256
|
|
|
222
257
|
INSERT OR IGNORE INTO stats (key, value) VALUES ('tokens_saved', 0);
|
|
223
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
|
|
224
299
|
var DEFAULT_MAX_LINES = 2e3;
|
|
225
300
|
var DEFAULT_MAX_CHARS = 1e5;
|
|
226
301
|
var DEFAULT_MAX_STORE_BYTES = 1e6;
|
|
227
302
|
var HARD_MAX_READ_BYTES = 64 * 1024 * 1024;
|
|
228
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;
|
|
229
307
|
function positiveOr(value, fallback) {
|
|
230
308
|
return Number.isInteger(value) && value > 0 ? value : fallback;
|
|
231
309
|
}
|
|
@@ -330,6 +408,7 @@ async function waitForLockRelease(lockPath) {
|
|
|
330
408
|
const deadline = Date.now() + RECOVERY_LOCK_WAIT_MS;
|
|
331
409
|
while (existsSync(lockPath) && Date.now() < deadline) await sleep(RECOVERY_LOCK_POLL_MS);
|
|
332
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)`;
|
|
333
412
|
function queryOne(db, sql, params = []) {
|
|
334
413
|
const rows = db.prepare(sql).all(...params);
|
|
335
414
|
return rows[0];
|
|
@@ -350,7 +429,7 @@ var StashStore = class {
|
|
|
350
429
|
reason;
|
|
351
430
|
constructor(config) {
|
|
352
431
|
this.dbPath = config.dbPath;
|
|
353
|
-
this.sessionId = config.sessionId;
|
|
432
|
+
this.sessionId = config.sessionId.replace(/:{2,}/g, "_");
|
|
354
433
|
this.exclude = config.exclude ?? [];
|
|
355
434
|
this.recoverCorrupt = config.recoverCorrupt ?? true;
|
|
356
435
|
this.quiet = config.quiet ?? false;
|
|
@@ -390,7 +469,7 @@ var StashStore = class {
|
|
|
390
469
|
try {
|
|
391
470
|
db.exec("PRAGMA busy_timeout=5000");
|
|
392
471
|
db.exec("PRAGMA journal_mode=WAL");
|
|
393
|
-
db
|
|
472
|
+
runMigrations(db);
|
|
394
473
|
this.registerSession(db);
|
|
395
474
|
this.pruneClosedSessions(db);
|
|
396
475
|
} catch (err) {
|
|
@@ -478,8 +557,9 @@ var StashStore = class {
|
|
|
478
557
|
db.prepare("DELETE FROM sessions WHERE session_id = ?").run(s.session_id);
|
|
479
558
|
}
|
|
480
559
|
}
|
|
481
|
-
|
|
482
|
-
|
|
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
|
+
}
|
|
483
563
|
db.exec("DELETE FROM session_stats WHERE session_id NOT IN (SELECT session_id FROM sessions)");
|
|
484
564
|
db.exec(
|
|
485
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)"
|
|
@@ -506,26 +586,26 @@ var StashStore = class {
|
|
|
506
586
|
const size = statSync(absPath).size;
|
|
507
587
|
const now = Date.now();
|
|
508
588
|
if (size > HARD_MAX_READ_BYTES) {
|
|
509
|
-
const
|
|
510
|
-
return { absPath, size, content: "", hash: "", lines: 0, now, early: { result, countRead: false } };
|
|
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 } };
|
|
511
591
|
}
|
|
512
592
|
const content = readFileSync(absPath, "utf-8");
|
|
513
593
|
const hash = contentHash(content);
|
|
514
594
|
const lines = content.split("\n").length;
|
|
515
595
|
const snap = { absPath, size, content, hash, lines, now };
|
|
516
596
|
if (content.slice(0, BINARY_SNIFF_CHARS).includes("\0")) {
|
|
517
|
-
const
|
|
518
|
-
snap.early = { result, countRead: true };
|
|
597
|
+
const result2 = { stashed: false, content: `[filestash: binary file (${size} bytes), not shown]`, hash, totalLines: lines };
|
|
598
|
+
snap.early = { result: result2, countRead: true };
|
|
519
599
|
}
|
|
520
600
|
return snap;
|
|
521
601
|
}
|
|
522
602
|
earlyResult(snap) {
|
|
523
|
-
const { result, countRead } = snap.early;
|
|
603
|
+
const { result: result2, countRead } = snap.early;
|
|
524
604
|
if (countRead) {
|
|
525
|
-
const tokens = estimateTokens(
|
|
605
|
+
const tokens = estimateTokens(result2.content);
|
|
526
606
|
this.guarded((db) => this.recordRead(db, tokens, tokens));
|
|
527
607
|
}
|
|
528
|
-
return
|
|
608
|
+
return result2;
|
|
529
609
|
}
|
|
530
610
|
isStoreable(snap) {
|
|
531
611
|
return snap.size <= this.maxStoreBytes && !isExcludedPath(snap.absPath, this.exclude);
|
|
@@ -551,7 +631,8 @@ var StashStore = class {
|
|
|
551
631
|
now,
|
|
552
632
|
size: snap.size,
|
|
553
633
|
slice: slice.text,
|
|
554
|
-
midLine: slice.midLine
|
|
634
|
+
midLine: slice.midLine,
|
|
635
|
+
tid: this.trackingId(options?.scope)
|
|
555
636
|
};
|
|
556
637
|
if (this.db) {
|
|
557
638
|
try {
|
|
@@ -562,6 +643,22 @@ var StashStore = class {
|
|
|
562
643
|
}
|
|
563
644
|
return this.plainResult(state);
|
|
564
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
|
+
}
|
|
565
662
|
plainResult(s) {
|
|
566
663
|
return { stashed: false, content: s.slice, hash: s.currentHash, totalLines: s.currentLines };
|
|
567
664
|
}
|
|
@@ -574,9 +671,10 @@ var StashStore = class {
|
|
|
574
671
|
const lastRead = queryOne(
|
|
575
672
|
db,
|
|
576
673
|
"SELECT hash FROM session_reads WHERE session_id = ? AND path = ?",
|
|
577
|
-
[
|
|
674
|
+
[state.tid, state.absPath]
|
|
578
675
|
);
|
|
579
676
|
if (!lastRead) {
|
|
677
|
+
this.ensureScopeCapacity(db, state.tid);
|
|
580
678
|
return this.handleFirstRead(db, state);
|
|
581
679
|
}
|
|
582
680
|
const lastHash = lastRead.hash;
|
|
@@ -589,43 +687,43 @@ var StashStore = class {
|
|
|
589
687
|
this.storeVersion(db, s.absPath, s.currentHash, s.currentContent, s.currentLines, s.now);
|
|
590
688
|
db.prepare(
|
|
591
689
|
"INSERT OR REPLACE INTO session_reads (session_id, path, hash, read_at) VALUES (?, ?, ?, ?)"
|
|
592
|
-
).run(
|
|
593
|
-
this.clearRanges(db, s.absPath);
|
|
690
|
+
).run(s.tid, s.absPath, s.currentHash, s.now);
|
|
691
|
+
this.clearRanges(db, s.tid, s.absPath);
|
|
594
692
|
return this.fullSlice(db, s);
|
|
595
693
|
}
|
|
596
694
|
requestedEnd(s) {
|
|
597
695
|
return Math.min(s.rangeEnd, s.currentLines);
|
|
598
696
|
}
|
|
599
|
-
getRanges(db, absPath, hash) {
|
|
697
|
+
getRanges(db, tid, absPath, hash) {
|
|
600
698
|
const rows = db.prepare(
|
|
601
699
|
"SELECT start_line, end_line FROM session_ranges WHERE session_id = ? AND path = ? AND hash = ?"
|
|
602
|
-
).all(
|
|
700
|
+
).all(tid, absPath, hash);
|
|
603
701
|
return rows.map((r) => [r.start_line, r.end_line]);
|
|
604
702
|
}
|
|
605
703
|
coversRequest(db, s, hash) {
|
|
606
|
-
return coversRange(this.getRanges(db, s.absPath, hash), s.rangeStart, this.requestedEnd(s));
|
|
704
|
+
return coversRange(this.getRanges(db, s.tid, s.absPath, hash), s.rangeStart, this.requestedEnd(s));
|
|
607
705
|
}
|
|
608
|
-
clearRanges(db, absPath) {
|
|
609
|
-
db.prepare("DELETE FROM session_ranges WHERE session_id = ? AND path = ?").run(
|
|
706
|
+
clearRanges(db, tid, absPath) {
|
|
707
|
+
db.prepare("DELETE FROM session_ranges WHERE session_id = ? AND path = ?").run(tid, absPath);
|
|
610
708
|
}
|
|
611
|
-
addRange(db, absPath, hash, start, end) {
|
|
709
|
+
addRange(db, tid, absPath, hash, start, end) {
|
|
612
710
|
if (end < start) return;
|
|
613
|
-
const merged = mergeIntervals([...this.getRanges(db, absPath, hash), [start, end]]);
|
|
614
|
-
db.prepare("DELETE FROM session_ranges WHERE session_id = ? AND path = ? AND hash = ?").run(
|
|
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);
|
|
615
713
|
const insert = db.prepare(
|
|
616
714
|
"INSERT INTO session_ranges (session_id, path, hash, start_line, end_line) VALUES (?, ?, ?, ?, ?)"
|
|
617
715
|
);
|
|
618
|
-
for (const [a, b] of merged) insert.run(
|
|
716
|
+
for (const [a, b] of merged) insert.run(tid, absPath, hash, a, b);
|
|
619
717
|
}
|
|
620
718
|
handleUncovered(db, s) {
|
|
621
719
|
db.prepare(
|
|
622
720
|
"UPDATE session_reads SET read_at = ? WHERE session_id = ? AND path = ?"
|
|
623
|
-
).run(s.now,
|
|
721
|
+
).run(s.now, s.tid, s.absPath);
|
|
624
722
|
return this.fullSlice(db, s);
|
|
625
723
|
}
|
|
626
724
|
fullSlice(db, s) {
|
|
627
725
|
const content = s.slice;
|
|
628
|
-
this.addRange(db, s.absPath, s.currentHash, s.rangeStart, this.requestedEnd(s));
|
|
726
|
+
this.addRange(db, s.tid, s.absPath, s.currentHash, s.rangeStart, this.requestedEnd(s));
|
|
629
727
|
this.recordRead(db, estimateTokens(content), estimateTokens(content));
|
|
630
728
|
return { stashed: false, content, hash: s.currentHash, totalLines: s.currentLines };
|
|
631
729
|
}
|
|
@@ -636,7 +734,7 @@ var StashStore = class {
|
|
|
636
734
|
const text = label(saved);
|
|
637
735
|
const sent = estimateTokens(text);
|
|
638
736
|
const base = { stashed: true, hash: s.currentHash, totalLines: s.currentLines, linesChanged: 0 };
|
|
639
|
-
this.addRange(db, s.absPath, s.currentHash, s.rangeStart, this.requestedEnd(s));
|
|
737
|
+
this.addRange(db, s.tid, s.absPath, s.currentHash, s.rangeStart, this.requestedEnd(s));
|
|
640
738
|
if (sent >= baseline) {
|
|
641
739
|
this.recordRead(db, baseline, baseline);
|
|
642
740
|
return { ...base, content };
|
|
@@ -647,7 +745,7 @@ var StashStore = class {
|
|
|
647
745
|
handleUnchanged(db, s) {
|
|
648
746
|
db.prepare(
|
|
649
747
|
"UPDATE session_reads SET read_at = ? WHERE session_id = ? AND path = ?"
|
|
650
|
-
).run(s.now,
|
|
748
|
+
).run(s.now, s.tid, s.absPath);
|
|
651
749
|
return this.unchangedLabel(
|
|
652
750
|
db,
|
|
653
751
|
s,
|
|
@@ -660,12 +758,12 @@ var StashStore = class {
|
|
|
660
758
|
"SELECT content, lines FROM file_versions WHERE path = ? AND hash = ?",
|
|
661
759
|
[s.absPath, lastHash]
|
|
662
760
|
);
|
|
663
|
-
const oldFullyDelivered = !!oldVersion && coversRange(this.getRanges(db, s.absPath, lastHash), 1, oldVersion.lines);
|
|
761
|
+
const oldFullyDelivered = !!oldVersion && coversRange(this.getRanges(db, s.tid, s.absPath, lastHash), 1, oldVersion.lines);
|
|
664
762
|
this.storeVersion(db, s.absPath, s.currentHash, s.currentContent, s.currentLines, s.now);
|
|
665
763
|
db.prepare(
|
|
666
764
|
"UPDATE session_reads SET hash = ?, read_at = ? WHERE session_id = ? AND path = ?"
|
|
667
|
-
).run(s.currentHash, s.now,
|
|
668
|
-
this.clearRanges(db, s.absPath);
|
|
765
|
+
).run(s.currentHash, s.now, s.tid, s.absPath);
|
|
766
|
+
this.clearRanges(db, s.tid, s.absPath);
|
|
669
767
|
if (oldVersion && oldFullyDelivered) {
|
|
670
768
|
const diffResult = computeDiff(oldVersion.content, s.currentContent, s.absPath);
|
|
671
769
|
if (diffResult.hasChanges) {
|
|
@@ -688,7 +786,7 @@ var StashStore = class {
|
|
|
688
786
|
const contentTokens = estimateTokens(s.currentContent);
|
|
689
787
|
const diffTokens = estimateTokens(diffResult.diff);
|
|
690
788
|
if (diffTokens >= contentTokens) return this.fullSlice(db, s);
|
|
691
|
-
this.addRange(db, s.absPath, s.currentHash, 1, s.currentLines);
|
|
789
|
+
this.addRange(db, s.tid, s.absPath, s.currentHash, 1, s.currentLines);
|
|
692
790
|
this.recordRead(db, contentTokens, diffTokens);
|
|
693
791
|
return {
|
|
694
792
|
stashed: true,
|
|
@@ -711,29 +809,31 @@ var StashStore = class {
|
|
|
711
809
|
).run(absPath, hash, content, lines, now);
|
|
712
810
|
}
|
|
713
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).
|
|
714
|
-
async readFileFull(filePath) {
|
|
812
|
+
async readFileFull(filePath, scope) {
|
|
715
813
|
await this.init();
|
|
716
814
|
const snap = this.readFileSnapshot(filePath);
|
|
717
815
|
if (snap.early) return this.earlyResult(snap);
|
|
718
816
|
const { absPath, content, hash, lines, now } = snap;
|
|
719
817
|
const slice = sliceLines(content, 0, 0, this.maxLines, this.maxChars);
|
|
720
|
-
const
|
|
721
|
-
if (!this.db) return
|
|
818
|
+
const result2 = { stashed: false, content: slice.text, hash, totalLines: lines };
|
|
819
|
+
if (!this.db) return result2;
|
|
722
820
|
try {
|
|
723
821
|
const tokens = estimateTokens(slice.text);
|
|
724
822
|
if (this.isStoreable(snap)) {
|
|
823
|
+
const tid = this.trackingId(scope);
|
|
824
|
+
this.ensureScopeCapacity(this.db, tid);
|
|
725
825
|
this.storeVersion(this.db, absPath, hash, content, lines, now);
|
|
726
826
|
this.db.prepare(
|
|
727
827
|
"INSERT OR REPLACE INTO session_reads (session_id, path, hash, read_at) VALUES (?, ?, ?, ?)"
|
|
728
|
-
).run(
|
|
729
|
-
this.clearRanges(this.db, absPath);
|
|
730
|
-
this.addRange(this.db, absPath, hash, slice.start, slice.end);
|
|
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);
|
|
731
831
|
}
|
|
732
832
|
this.recordRead(this.db, tokens, tokens);
|
|
733
833
|
} catch (err) {
|
|
734
834
|
this.degrade(err);
|
|
735
835
|
}
|
|
736
|
-
return
|
|
836
|
+
return result2;
|
|
737
837
|
}
|
|
738
838
|
async onFileDeleted(filePath) {
|
|
739
839
|
await this.init();
|
|
@@ -912,13 +1012,11 @@ function createStash(config) {
|
|
|
912
1012
|
}
|
|
913
1013
|
|
|
914
1014
|
// packages/cli/src/index.ts
|
|
915
|
-
import { resolve as
|
|
916
|
-
import { existsSync as
|
|
917
|
-
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";
|
|
918
1018
|
|
|
919
1019
|
// packages/cli/src/mcp.ts
|
|
920
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
921
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
922
1020
|
import { z } from "zod";
|
|
923
1021
|
import { resolve as resolve3, join, relative, isAbsolute } from "node:path";
|
|
924
1022
|
import { readFileSync as readFileSync2, realpathSync } from "node:fs";
|
|
@@ -954,32 +1052,34 @@ function isPathAllowed(absPath, cwd) {
|
|
|
954
1052
|
const rel = relative(cwd, realPath);
|
|
955
1053
|
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
956
1054
|
}
|
|
957
|
-
function formatReadResult(
|
|
958
|
-
if (
|
|
959
|
-
return `[filestash: ${
|
|
960
|
-
${
|
|
1055
|
+
function formatReadResult(result2) {
|
|
1056
|
+
if (result2.stashed && result2.diff) {
|
|
1057
|
+
return `[filestash: ${result2.linesChanged} lines changed out of ${result2.totalLines}]
|
|
1058
|
+
${result2.diff}`;
|
|
961
1059
|
}
|
|
962
|
-
return
|
|
1060
|
+
return result2.content;
|
|
963
1061
|
}
|
|
964
|
-
function formatFileEntry(path,
|
|
965
|
-
if (
|
|
966
|
-
return `=== ${path} [${
|
|
967
|
-
${
|
|
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}`;
|
|
968
1066
|
}
|
|
969
1067
|
return `=== ${path} ===
|
|
970
|
-
${
|
|
1068
|
+
${result2.content}`;
|
|
971
1069
|
}
|
|
972
1070
|
var readFileShape = {
|
|
973
1071
|
path: z.string().describe("File path"),
|
|
974
1072
|
offset: z.number().optional().describe("Start line (1-based)"),
|
|
975
1073
|
limit: z.number().optional().describe("Max lines"),
|
|
976
|
-
force: z.boolean().optional().describe("Bypass stash")
|
|
1074
|
+
force: z.boolean().optional().describe("Bypass stash"),
|
|
1075
|
+
agent: z.string().optional()
|
|
977
1076
|
};
|
|
978
1077
|
var readFilesShape = {
|
|
979
1078
|
paths: z.preprocess(
|
|
980
1079
|
(val) => typeof val === "string" ? JSON.parse(val) : val,
|
|
981
1080
|
z.array(z.string())
|
|
982
|
-
).describe("File paths")
|
|
1081
|
+
).describe("File paths"),
|
|
1082
|
+
agent: z.string().optional()
|
|
983
1083
|
};
|
|
984
1084
|
var TOOL_DEFS = {
|
|
985
1085
|
read_file: {
|
|
@@ -1029,8 +1129,8 @@ function toolDefinitionTokens() {
|
|
|
1029
1129
|
}));
|
|
1030
1130
|
return Math.ceil(JSON.stringify(defs).length / 4);
|
|
1031
1131
|
}
|
|
1032
|
-
function degradedLine(
|
|
1033
|
-
return `Mode: DEGRADED (${
|
|
1132
|
+
function degradedLine(reason2 = "unknown") {
|
|
1133
|
+
return `Mode: DEGRADED (${reason2}) - files are read normally, nothing is stashed`;
|
|
1034
1134
|
}
|
|
1035
1135
|
function formatStatus(stats, overheadTokens) {
|
|
1036
1136
|
if (stats.degraded) return degradedLine(stats.degradedReason);
|
|
@@ -1054,7 +1154,7 @@ function statsSuffix(grossSaved) {
|
|
|
1054
1154
|
|
|
1055
1155
|
[filestash: net ~${(grossSaved - toolDefinitionTokens()).toLocaleString()} tokens this session (est.)]`;
|
|
1056
1156
|
}
|
|
1057
|
-
async function readSingleFile(path, cwd, stash) {
|
|
1157
|
+
async function readSingleFile(path, cwd, stash, scope) {
|
|
1058
1158
|
const absPath = resolve3(path);
|
|
1059
1159
|
if (!isPathAllowed(absPath, cwd)) {
|
|
1060
1160
|
return {
|
|
@@ -1064,8 +1164,8 @@ Error: Path must be within the working directory (${cwd})`,
|
|
|
1064
1164
|
};
|
|
1065
1165
|
}
|
|
1066
1166
|
try {
|
|
1067
|
-
const
|
|
1068
|
-
return { text: formatFileEntry(path,
|
|
1167
|
+
const result2 = await stash.readFile(path, { ...scope !== void 0 && { scope } });
|
|
1168
|
+
return { text: formatFileEntry(path, result2), ok: true };
|
|
1069
1169
|
} catch (e) {
|
|
1070
1170
|
const message = e instanceof Error ? e.message : String(e);
|
|
1071
1171
|
return { text: `=== ${path} ===
|
|
@@ -1093,6 +1193,8 @@ async function startMcpServer() {
|
|
|
1093
1193
|
...limitOptions()
|
|
1094
1194
|
});
|
|
1095
1195
|
await stash.init();
|
|
1196
|
+
const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
|
|
1197
|
+
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
1096
1198
|
const server = new McpServer({
|
|
1097
1199
|
name: "filestash",
|
|
1098
1200
|
version: packageJson.version ?? "0.0.0"
|
|
@@ -1100,7 +1202,7 @@ async function startMcpServer() {
|
|
|
1100
1202
|
server.registerTool(
|
|
1101
1203
|
"read_file",
|
|
1102
1204
|
TOOL_DEFS.read_file,
|
|
1103
|
-
async ({ path, force, offset, limit }) => {
|
|
1205
|
+
async ({ path, force, offset, limit, agent }) => {
|
|
1104
1206
|
const absPath = resolve3(path);
|
|
1105
1207
|
if (!isPathAllowed(absPath, cwd)) {
|
|
1106
1208
|
return {
|
|
@@ -1109,12 +1211,13 @@ async function startMcpServer() {
|
|
|
1109
1211
|
};
|
|
1110
1212
|
}
|
|
1111
1213
|
try {
|
|
1112
|
-
const
|
|
1214
|
+
const result2 = force ? await stash.readFileFull(path, agent) : await stash.readFile(path, {
|
|
1113
1215
|
...offset !== void 0 && { offset },
|
|
1114
|
-
...limit !== void 0 && { limit }
|
|
1216
|
+
...limit !== void 0 && { limit },
|
|
1217
|
+
...agent !== void 0 && { scope: agent }
|
|
1115
1218
|
});
|
|
1116
|
-
let text = formatReadResult(
|
|
1117
|
-
if (
|
|
1219
|
+
let text = formatReadResult(result2);
|
|
1220
|
+
if (result2.stashed) {
|
|
1118
1221
|
const stats = await stash.getStats();
|
|
1119
1222
|
text += statsSuffix(stats.sessionTokensSaved);
|
|
1120
1223
|
}
|
|
@@ -1134,8 +1237,8 @@ async function startMcpServer() {
|
|
|
1134
1237
|
server.registerTool(
|
|
1135
1238
|
"read_files",
|
|
1136
1239
|
TOOL_DEFS.read_files,
|
|
1137
|
-
async ({ paths }) => {
|
|
1138
|
-
const results = await Promise.all(paths.map((p) => readSingleFile(p, cwd, stash)));
|
|
1240
|
+
async ({ paths, agent }) => {
|
|
1241
|
+
const results = await Promise.all(paths.map((p) => readSingleFile(p, cwd, stash, agent)));
|
|
1139
1242
|
const successfulPaths = paths.filter((_, i) => results[i].ok);
|
|
1140
1243
|
const combined = results.map((r) => r.text).join("\n\n");
|
|
1141
1244
|
let footer = "";
|
|
@@ -1223,6 +1326,449 @@ function findStashDatabases(root, depth = 0) {
|
|
|
1223
1326
|
return found;
|
|
1224
1327
|
}
|
|
1225
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
|
+
|
|
1226
1772
|
// packages/cli/src/index.ts
|
|
1227
1773
|
var _origEmitWarning = process.emitWarning;
|
|
1228
1774
|
process.emitWarning = function(msg, ...args) {
|
|
@@ -1235,19 +1781,16 @@ var RESET_HOOK_ENTRY = {
|
|
|
1235
1781
|
matcher: "clear|compact",
|
|
1236
1782
|
hooks: [{ type: "command", command: RESET_HOOK_COMMAND }]
|
|
1237
1783
|
};
|
|
1238
|
-
var
|
|
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
|
+
};
|
|
1239
1789
|
function assertHealthy(stash) {
|
|
1240
1790
|
if (stash.isDegraded) throw new Error(stash.degradedReason);
|
|
1241
1791
|
}
|
|
1242
1792
|
async function readHookCwd() {
|
|
1243
|
-
|
|
1244
|
-
const read = (async () => {
|
|
1245
|
-
let raw2 = "";
|
|
1246
|
-
for await (const chunk of process.stdin) raw2 += chunk;
|
|
1247
|
-
return raw2;
|
|
1248
|
-
})();
|
|
1249
|
-
const timeout = new Promise((res) => setTimeout(() => res(""), HOOK_STDIN_TIMEOUT_MS).unref());
|
|
1250
|
-
const raw = (await Promise.race([read, timeout])).trim();
|
|
1793
|
+
const raw = await readHookStdin();
|
|
1251
1794
|
if (!raw) return void 0;
|
|
1252
1795
|
try {
|
|
1253
1796
|
const cwd = JSON.parse(raw)?.cwd;
|
|
@@ -1259,8 +1802,8 @@ async function readHookCwd() {
|
|
|
1259
1802
|
async function runReset(fromHook) {
|
|
1260
1803
|
try {
|
|
1261
1804
|
const hookCwd = fromHook ? await readHookCwd() : void 0;
|
|
1262
|
-
const dbPath =
|
|
1263
|
-
if (!
|
|
1805
|
+
const dbPath = join5(resolveStashDir(hookCwd), "stash.db");
|
|
1806
|
+
if (!existsSync5(dbPath)) {
|
|
1264
1807
|
if (!fromHook) console.log("No filestash database found. Nothing to reset.");
|
|
1265
1808
|
return;
|
|
1266
1809
|
}
|
|
@@ -1280,36 +1823,47 @@ async function runReset(fromHook) {
|
|
|
1280
1823
|
}
|
|
1281
1824
|
}
|
|
1282
1825
|
function hooksSnippet() {
|
|
1283
|
-
return JSON.stringify({ hooks: { SessionStart: [RESET_HOOK_ENTRY] } }, null, 2);
|
|
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));
|
|
1284
1834
|
}
|
|
1285
1835
|
function installClaudeHook(home) {
|
|
1286
|
-
const settingsPath =
|
|
1836
|
+
const settingsPath = join5(home, ".claude", "settings.json");
|
|
1287
1837
|
let settings = {};
|
|
1288
|
-
const existed =
|
|
1838
|
+
const existed = existsSync5(settingsPath);
|
|
1289
1839
|
if (existed) {
|
|
1290
1840
|
try {
|
|
1291
|
-
settings = JSON.parse(
|
|
1841
|
+
settings = JSON.parse(readFileSync4(settingsPath, "utf-8"));
|
|
1292
1842
|
} catch {
|
|
1293
|
-
console.error(` Claude Code
|
|
1843
|
+
console.error(` Claude Code hooks: ${settingsPath} is not valid JSON, left untouched`);
|
|
1294
1844
|
return;
|
|
1295
1845
|
}
|
|
1296
1846
|
}
|
|
1297
1847
|
const hooks = settings.hooks ?? {};
|
|
1298
|
-
const
|
|
1299
|
-
if (
|
|
1300
|
-
console.log(" Claude Code
|
|
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");
|
|
1301
1851
|
return;
|
|
1302
1852
|
}
|
|
1303
1853
|
if (existed) copyFileSync(settingsPath, `${settingsPath}.bak`);
|
|
1304
|
-
else mkdirSync2(
|
|
1305
|
-
|
|
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;
|
|
1306
1860
|
writeFileSync2(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
1307
|
-
console.log(` Claude Code
|
|
1861
|
+
console.log(` Claude Code hooks: configured (${settingsPath})`);
|
|
1308
1862
|
}
|
|
1309
1863
|
async function runStatus() {
|
|
1310
|
-
const stashDir =
|
|
1311
|
-
const dbPath =
|
|
1312
|
-
if (!
|
|
1864
|
+
const stashDir = resolve5(process.env.FILESTASH_DIR ?? ".file-stash");
|
|
1865
|
+
const dbPath = join5(stashDir, "stash.db");
|
|
1866
|
+
if (!existsSync5(dbPath)) {
|
|
1313
1867
|
console.log("No filestash database found. Run 'agent-file-stash serve' to start stashing.");
|
|
1314
1868
|
process.exit(0);
|
|
1315
1869
|
}
|
|
@@ -1330,9 +1884,9 @@ async function runStatus() {
|
|
|
1330
1884
|
}
|
|
1331
1885
|
}
|
|
1332
1886
|
async function runStatusAll(root) {
|
|
1333
|
-
const databases = findStashDatabases(
|
|
1887
|
+
const databases = findStashDatabases(resolve5(root));
|
|
1334
1888
|
if (databases.length === 0) {
|
|
1335
|
-
console.log(`No filestash databases found under ${
|
|
1889
|
+
console.log(`No filestash databases found under ${resolve5(root)}.`);
|
|
1336
1890
|
return;
|
|
1337
1891
|
}
|
|
1338
1892
|
const rows = [];
|
|
@@ -1342,7 +1896,7 @@ async function runStatusAll(root) {
|
|
|
1342
1896
|
await stash.init();
|
|
1343
1897
|
const stats = await stash.getStats();
|
|
1344
1898
|
assertHealthy(stash);
|
|
1345
|
-
rows.push({ project:
|
|
1899
|
+
rows.push({ project: resolve5(dbPath, "../..").replace(/\/\.vscode$/, ""), files: stats.filesTracked, tokens: stats.tokensSaved });
|
|
1346
1900
|
} catch (e) {
|
|
1347
1901
|
console.error(` skipped ${dbPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
1348
1902
|
} finally {
|
|
@@ -1352,7 +1906,7 @@ async function runStatusAll(root) {
|
|
|
1352
1906
|
rows.sort((a, b) => b.tokens - a.tokens);
|
|
1353
1907
|
const totalTokens = rows.reduce((sum, r) => sum + r.tokens, 0);
|
|
1354
1908
|
const totalFiles = rows.reduce((sum, r) => sum + r.files, 0);
|
|
1355
|
-
console.log(`filestash status (${rows.length} databases under ${
|
|
1909
|
+
console.log(`filestash status (${rows.length} databases under ${resolve5(root)}):`);
|
|
1356
1910
|
for (const r of rows) {
|
|
1357
1911
|
console.log(` ~${r.tokens.toLocaleString().padStart(10)} tokens ${String(r.files).padStart(5)} files ${r.project}`);
|
|
1358
1912
|
}
|
|
@@ -1361,44 +1915,16 @@ async function runStatusAll(root) {
|
|
|
1361
1915
|
Savings count re-reads within a session (unchanged files and diffs); a new session always receives full content.`);
|
|
1362
1916
|
}
|
|
1363
1917
|
async function runInit(withHooks) {
|
|
1364
|
-
const home =
|
|
1365
|
-
const
|
|
1366
|
-
command: "npx",
|
|
1367
|
-
args: ["agent-file-stash", "serve"]
|
|
1368
|
-
};
|
|
1369
|
-
const opencodeMcpEntry = {
|
|
1370
|
-
type: "local",
|
|
1371
|
-
command: ["npx", "agent-file-stash", "serve"]
|
|
1372
|
-
};
|
|
1373
|
-
const xdgConfig = process.env.XDG_CONFIG_HOME || join3(home, ".config");
|
|
1374
|
-
const targets = [
|
|
1375
|
-
{
|
|
1376
|
-
name: "Claude Code",
|
|
1377
|
-
path: join3(home, ".claude.json"),
|
|
1378
|
-
key: "mcpServers",
|
|
1379
|
-
entry: mcpServersEntry
|
|
1380
|
-
},
|
|
1381
|
-
{
|
|
1382
|
-
name: "Cursor",
|
|
1383
|
-
path: join3(home, ".cursor", "mcp.json"),
|
|
1384
|
-
key: "mcpServers",
|
|
1385
|
-
entry: mcpServersEntry
|
|
1386
|
-
},
|
|
1387
|
-
{
|
|
1388
|
-
name: "OpenCode",
|
|
1389
|
-
path: join3(xdgConfig, "opencode", "opencode.json"),
|
|
1390
|
-
key: "mcp",
|
|
1391
|
-
entry: opencodeMcpEntry
|
|
1392
|
-
}
|
|
1393
|
-
];
|
|
1918
|
+
const home = homedir2();
|
|
1919
|
+
const targets = editorTargets(home);
|
|
1394
1920
|
let configured = 0;
|
|
1395
1921
|
for (const target of targets) {
|
|
1396
|
-
const dir =
|
|
1397
|
-
if (!
|
|
1922
|
+
const dir = join5(target.path, "..");
|
|
1923
|
+
if (!existsSync5(dir)) continue;
|
|
1398
1924
|
let config = {};
|
|
1399
|
-
if (
|
|
1925
|
+
if (existsSync5(target.path)) {
|
|
1400
1926
|
try {
|
|
1401
|
-
config = JSON.parse(
|
|
1927
|
+
config = JSON.parse(readFileSync4(target.path, "utf-8"));
|
|
1402
1928
|
} catch {
|
|
1403
1929
|
config = {};
|
|
1404
1930
|
}
|
|
@@ -1416,7 +1942,7 @@ async function runInit(withHooks) {
|
|
|
1416
1942
|
}
|
|
1417
1943
|
if (configured === 0) {
|
|
1418
1944
|
console.log("No supported tools detected. You can manually add agent-file-stash to your MCP config:");
|
|
1419
|
-
console.log(JSON.stringify({ mcpServers: { "agent-file-stash":
|
|
1945
|
+
console.log(JSON.stringify({ mcpServers: { "agent-file-stash": targets[0].entry } }, null, 2));
|
|
1420
1946
|
} else {
|
|
1421
1947
|
console.log(`
|
|
1422
1948
|
Done! Restart your editor to pick up agent-file-stash.`);
|
|
@@ -1445,13 +1971,17 @@ function runHelp() {
|
|
|
1445
1971
|
Usage:
|
|
1446
1972
|
agent-file-stash init Auto-configure for your editor
|
|
1447
1973
|
agent-file-stash init --hooks
|
|
1448
|
-
Also add the Claude Code context
|
|
1974
|
+
Also add the Claude Code hooks (context reset, subagent read scope) to ~/.claude/settings.json
|
|
1449
1975
|
agent-file-stash reset Forget what was read so the next read returns full content
|
|
1450
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)
|
|
1451
1979
|
agent-file-stash serve Start the MCP server (default)
|
|
1452
1980
|
agent-file-stash status Show stash statistics
|
|
1453
1981
|
agent-file-stash status --all [dir]
|
|
1454
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)
|
|
1455
1985
|
agent-file-stash help Show this help message
|
|
1456
1986
|
|
|
1457
1987
|
Environment:
|
|
@@ -1464,13 +1994,17 @@ var command = process.argv[2];
|
|
|
1464
1994
|
if (!command || command === "serve") {
|
|
1465
1995
|
await startMcpServer();
|
|
1466
1996
|
} else if (command === "status" && process.argv[3] === "--all") {
|
|
1467
|
-
await runStatusAll(process.argv[4] ??
|
|
1997
|
+
await runStatusAll(process.argv[4] ?? homedir2());
|
|
1468
1998
|
} else if (command === "status") {
|
|
1469
1999
|
await runStatus();
|
|
1470
2000
|
} else if (command === "init") {
|
|
1471
2001
|
await runInit(process.argv.includes("--hooks"));
|
|
1472
2002
|
} else if (command === "reset") {
|
|
1473
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));
|
|
1474
2008
|
} else if (command === "help" || command === "--help") {
|
|
1475
2009
|
runHelp();
|
|
1476
2010
|
} else {
|