@stitchdb/cli 0.7.2 → 0.7.4
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/dist/cli.js +84 -38
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -490,9 +490,11 @@ async function cmdHook(args) {
|
|
|
490
490
|
await handlePreReadHook(cfg, event, cwd || process.cwd()).catch(() => { });
|
|
491
491
|
return;
|
|
492
492
|
}
|
|
493
|
-
// ── PostToolUse on Read:
|
|
494
|
-
|
|
495
|
-
|
|
493
|
+
// ── PostToolUse on Read/Edit/Write/MultiEdit: keep the file-summary
|
|
494
|
+
// cache fresh. After an edit we re-hash the on-disk file and
|
|
495
|
+
// re-summarise, so a stale "old version" memory never lingers.
|
|
496
|
+
if (eventName === 'PostToolUse' && ['Read', 'Edit', 'Write', 'MultiEdit'].includes(String(event?.tool_name))) {
|
|
497
|
+
await handlePostFileChangeHook(cfg, event, cwd || process.cwd()).catch(() => { });
|
|
496
498
|
return;
|
|
497
499
|
}
|
|
498
500
|
// ── SessionStart: self-heal hook config + inject prior context ─────────
|
|
@@ -502,6 +504,10 @@ async function cmdHook(args) {
|
|
|
502
504
|
// Stitch hook entries — never touching non-Stitch ones.
|
|
503
505
|
if (eventName === 'SessionStart') {
|
|
504
506
|
ensureHooksUpToDate();
|
|
507
|
+
// Catch-up distillation: if a previous session ended with undistilled
|
|
508
|
+
// turns (user exited under threshold), distill them now in the
|
|
509
|
+
// background so they surface in this session's context very soon.
|
|
510
|
+
maybeAutoDistill(threadName, { force: true }).catch(() => { });
|
|
505
511
|
}
|
|
506
512
|
// Strategy: prefer distilled memories (dense facts) over raw turns. Only
|
|
507
513
|
// include raw turns for the last 5 to give the agent immediate continuation.
|
|
@@ -731,13 +737,14 @@ function lastAssistantTextFromTranscript(transcriptPath) {
|
|
|
731
737
|
const FILE_SUMMARY_MIN_BYTES = 500; // skip tiny files
|
|
732
738
|
const FILE_SUMMARY_MAX_BYTES = 200_000; // skip absolutely huge files
|
|
733
739
|
const FILE_SUMMARY_COOLDOWN_MS = 5 * 60_000; // per-file: don't re-summarize more than once per 5 min
|
|
734
|
-
const FILE_SUMMARY_PROMPT = `You are summarising a single source file for an AI coding assistant cache.
|
|
740
|
+
const FILE_SUMMARY_PROMPT = `You are summarising a single source file for an AI coding assistant's persistent project cache. Other agents will read this summary before deciding whether to open the file in full, so it must enable a real mental model of the project — not just describe the file in isolation.
|
|
735
741
|
|
|
736
|
-
Return ONLY a 2-
|
|
737
|
-
|
|
738
|
-
|
|
742
|
+
Return ONLY a 2-4 sentence summary that covers:
|
|
743
|
+
1. PURPOSE — what this file does, its key exported symbols / routes / commands / classes worth knowing, any non-obvious invariant or pattern.
|
|
744
|
+
2. CONNECTIONS — concrete other files/modules this depends on (real import paths, real symbol names) AND who/what typically depends on this. Use real names from the file content; don't generalise.
|
|
745
|
+
3. WHEN TO OPEN — what kind of task forces an edit here vs a peek. (e.g. "edit this when adding a new auth provider; safe to skip for purely UI changes").
|
|
739
746
|
|
|
740
|
-
|
|
747
|
+
Be dense and specific. No preamble. No "this file ...". No markdown fences. No prose around it. Plain text only.
|
|
741
748
|
|
|
742
749
|
File path: {{PATH}}
|
|
743
750
|
|
|
@@ -816,47 +823,64 @@ async function handlePreReadHook(cfg, event, cwd) {
|
|
|
816
823
|
const payload = { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: note } };
|
|
817
824
|
process.stdout.write(JSON.stringify(payload));
|
|
818
825
|
}
|
|
819
|
-
// PostToolUse
|
|
820
|
-
//
|
|
821
|
-
|
|
826
|
+
// PostToolUse handler for tools that observe-or-modify a single file
|
|
827
|
+
// (Read, Edit, Write, MultiEdit). Kicks off a background `claude -p` to
|
|
828
|
+
// (re-)summarise this file unless we already have an up-to-date cached
|
|
829
|
+
// summary for this exact hash. Fire-and-forget.
|
|
830
|
+
//
|
|
831
|
+
// Read flow: file content same as cache → no-op. Different → re-summarise.
|
|
832
|
+
// Edit flow: file content always different from cache → re-summarise. The
|
|
833
|
+
// existing summary is replaced by cmdSummarizeFile so no stale
|
|
834
|
+
// memory persists.
|
|
835
|
+
// Write/MultiEdit: same as Edit.
|
|
836
|
+
//
|
|
837
|
+
// A 30 s "min-interval" floor on top of the hash-keyed cooldown prevents a
|
|
838
|
+
// flurry of edits within seconds from spawning N parallel claude -p calls
|
|
839
|
+
// for the same file.
|
|
840
|
+
async function handlePostFileChangeHook(cfg, event, cwd) {
|
|
822
841
|
const filePath = String(event?.tool_input?.file_path || '');
|
|
823
842
|
if (!filePath)
|
|
824
843
|
return;
|
|
825
844
|
const rel = relPathFor(cwd, filePath);
|
|
826
|
-
|
|
827
|
-
//
|
|
828
|
-
let body =
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
body = fs.readFileSync(filePath, 'utf8');
|
|
832
|
-
}
|
|
833
|
-
catch {
|
|
834
|
-
return;
|
|
835
|
-
}
|
|
845
|
+
// After Edit/Write the on-disk content is what we want; the hook payload
|
|
846
|
+
// may also carry it, but reading from disk is always correct.
|
|
847
|
+
let body = '';
|
|
848
|
+
try {
|
|
849
|
+
body = fs.readFileSync(filePath, 'utf8');
|
|
836
850
|
}
|
|
837
|
-
|
|
851
|
+
catch {
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (!body)
|
|
855
|
+
return;
|
|
856
|
+
// Tiny files: not worth a summary. Huge files: skip; would exhaust context.
|
|
857
|
+
if (body.length < FILE_SUMMARY_MIN_BYTES)
|
|
838
858
|
return;
|
|
839
859
|
if (body.length > FILE_SUMMARY_MAX_BYTES)
|
|
840
860
|
return;
|
|
841
861
|
const fullHash = await sha256Hex(body);
|
|
842
862
|
const hashPrefix = fullHash.slice(0, 16);
|
|
843
|
-
// Per-file cooldown so a hot-edited file doesn't re-summarize on every read.
|
|
844
863
|
const state = loadFileSummaryState();
|
|
845
864
|
const last = state.files[rel];
|
|
865
|
+
// Hash-keyed dedupe: same content as last spawn → nothing to do.
|
|
846
866
|
if (last && last.hash === hashPrefix && Date.now() - last.lastSummarizedAt < FILE_SUMMARY_COOLDOWN_MS)
|
|
847
867
|
return;
|
|
868
|
+
// Min-interval floor: regardless of hash change, don't spawn more often
|
|
869
|
+
// than once per 30 s for the same file (catches edit flurries).
|
|
870
|
+
const MIN_RESPAWN_MS = 30_000;
|
|
871
|
+
if (last && Date.now() - last.lastSummarizedAt < MIN_RESPAWN_MS)
|
|
872
|
+
return;
|
|
873
|
+
// Optimisation: confirm the API view is also stale before spending tokens.
|
|
848
874
|
const stitch = client(cfg);
|
|
849
875
|
const cached = await lookupFileSummary(stitch, rel);
|
|
850
876
|
if (cached && cached.hash === hashPrefix) {
|
|
851
|
-
// Already summarized this exact version — refresh cooldown and bail.
|
|
852
877
|
state.files[rel] = { hash: hashPrefix, lastSummarizedAt: Date.now() };
|
|
853
878
|
saveFileSummaryState(state);
|
|
854
879
|
return;
|
|
855
880
|
}
|
|
856
|
-
// Mark BEFORE spawning so
|
|
881
|
+
// Mark BEFORE spawning so overlapping events don't fire N spawns.
|
|
857
882
|
state.files[rel] = { hash: hashPrefix, lastSummarizedAt: Date.now() };
|
|
858
883
|
saveFileSummaryState(state);
|
|
859
|
-
// Spawn detached: stitch _summarize-file <abs-path> <hash> <rel>
|
|
860
884
|
try {
|
|
861
885
|
const cliPath = process.argv[1] || (await import('node:url')).fileURLToPath(import.meta.url);
|
|
862
886
|
const child = spawn(process.argv[0], [cliPath, '_summarize-file', filePath, hashPrefix, rel], {
|
|
@@ -964,9 +988,13 @@ async function cmdLink(args) {
|
|
|
964
988
|
// Triggered manually (`stitch distill`), and automatically by the Stop hook
|
|
965
989
|
// when conditions are met (cooldown + new-turn threshold).
|
|
966
990
|
const DISTILL_STATE_FILE = path.join(CONFIG_DIR, 'distill-state.json');
|
|
967
|
-
|
|
968
|
-
|
|
991
|
+
// No time-based cooldown — purely turn-count driven. The previous time gate
|
|
992
|
+
// could leave turns undistilled if the user exited Claude before the next
|
|
993
|
+
// firing window. Now: every Stop checks the pending-turn count, and if the
|
|
994
|
+
// user exits below threshold, the next SessionStart catches up.
|
|
995
|
+
const DISTILL_MIN_NEW_TURNS = 5; // distill once 5+ new turns are pending
|
|
969
996
|
const DISTILL_BATCH_SIZE = 30; // turns per distillation pass
|
|
997
|
+
const DISTILL_DEBOUNCE_MS = 90 * 1000; // small in-flight guard so a flurry of Stops doesn't fire N spawns
|
|
970
998
|
function loadDistillState() {
|
|
971
999
|
try {
|
|
972
1000
|
return JSON.parse(fs.readFileSync(DISTILL_STATE_FILE, 'utf8'));
|
|
@@ -1203,14 +1231,24 @@ async function distillClear(args) {
|
|
|
1203
1231
|
}
|
|
1204
1232
|
console.log(`Cleared ${deleted} memories.`);
|
|
1205
1233
|
}
|
|
1206
|
-
// Triggered from
|
|
1207
|
-
|
|
1234
|
+
// Triggered from Stop hook AND SessionStart catch-up. Fire-and-forget.
|
|
1235
|
+
//
|
|
1236
|
+
// Decision logic:
|
|
1237
|
+
// - If `pending = currentTurns - lastDistilledTurns` >= DISTILL_MIN_NEW_TURNS,
|
|
1238
|
+
// spawn a distill pass. No time-based cooldown — leaving turns undistilled
|
|
1239
|
+
// because the user happened to exit before the next firing window is the
|
|
1240
|
+
// wrong behaviour.
|
|
1241
|
+
// - SessionStart calls this with `force = true` when ANY pending turns exist
|
|
1242
|
+
// so a session that ended mid-batch (e.g. 3 pending) still gets caught up.
|
|
1243
|
+
// - DISTILL_DEBOUNCE_MS is a tiny in-process guard so a flurry of Stop
|
|
1244
|
+
// events from a single agent run doesn't trigger N parallel claude -p
|
|
1245
|
+
// spawns; once one is in flight, others within the window become no-ops.
|
|
1246
|
+
async function maybeAutoDistill(thread, opts = {}) {
|
|
1208
1247
|
const state = loadDistillState();
|
|
1209
1248
|
const meta = state.threads[thread] || { lastDistilledAt: 0, lastTurnAt: 0, lastTurnCount: 0 };
|
|
1210
|
-
//
|
|
1211
|
-
if (Date.now() - meta.lastDistilledAt <
|
|
1249
|
+
// In-flight debounce: if we just spawned one, skip.
|
|
1250
|
+
if (Date.now() - meta.lastDistilledAt < DISTILL_DEBOUNCE_MS)
|
|
1212
1251
|
return;
|
|
1213
|
-
// Need at least N new turns since last pass.
|
|
1214
1252
|
const cfg = loadConfig();
|
|
1215
1253
|
const stitch = client(cfg);
|
|
1216
1254
|
let recallSize = 0;
|
|
@@ -1221,17 +1259,21 @@ async function maybeAutoDistill(thread) {
|
|
|
1221
1259
|
catch {
|
|
1222
1260
|
return;
|
|
1223
1261
|
}
|
|
1224
|
-
|
|
1262
|
+
const pending = recallSize - meta.lastTurnCount;
|
|
1263
|
+
// Default path: only distill once enough turns have piled up.
|
|
1264
|
+
// SessionStart catch-up: distill if there's ANY pending turn.
|
|
1265
|
+
const threshold = opts.force ? 1 : DISTILL_MIN_NEW_TURNS;
|
|
1266
|
+
if (pending < threshold)
|
|
1225
1267
|
return;
|
|
1226
1268
|
// Mark BEFORE running so we don't double-fire on overlapping Stop events.
|
|
1227
1269
|
state.threads[thread] = { lastDistilledAt: Date.now(), lastTurnAt: Date.now(), lastTurnCount: recallSize };
|
|
1228
1270
|
saveDistillState(state);
|
|
1229
|
-
// Detach: spawn a background process so the
|
|
1230
|
-
// The detached child runs `stitch distill` for this thread.
|
|
1271
|
+
// Detach: spawn a background process so the hook returns immediately.
|
|
1231
1272
|
try {
|
|
1232
1273
|
const child = spawn(process.argv[0], [process.argv[1] || (await import('node:url')).fileURLToPath(import.meta.url), 'distill', '--thread', thread, '--n', String(DISTILL_BATCH_SIZE)], {
|
|
1233
1274
|
detached: true,
|
|
1234
1275
|
stdio: 'ignore',
|
|
1276
|
+
env: { ...process.env, STITCH_HOOKS_DISABLED: '1' },
|
|
1235
1277
|
});
|
|
1236
1278
|
child.unref();
|
|
1237
1279
|
}
|
|
@@ -1666,13 +1708,17 @@ const STITCH_SESSION_START_HOOK = {
|
|
|
1666
1708
|
matcher: '*',
|
|
1667
1709
|
hooks: [{ type: 'command', command: 'stitch _hook SessionStart' }],
|
|
1668
1710
|
};
|
|
1669
|
-
// File summary cache:
|
|
1711
|
+
// File summary cache:
|
|
1712
|
+
// • PreToolUse fires only on Read (cached summary surfaces before the read).
|
|
1713
|
+
// • PostToolUse fires on Read, Edit, Write, MultiEdit — Edit/Write/MultiEdit
|
|
1714
|
+
// invalidate any prior summary so a stale "old version" memory never
|
|
1715
|
+
// lingers. The matcher is a regex (Claude Code accepts pipe alternation).
|
|
1670
1716
|
const STITCH_PRE_READ_HOOK = {
|
|
1671
1717
|
matcher: 'Read',
|
|
1672
1718
|
hooks: [{ type: 'command', command: 'stitch _hook' }],
|
|
1673
1719
|
};
|
|
1674
1720
|
const STITCH_POST_READ_HOOK = {
|
|
1675
|
-
matcher: 'Read',
|
|
1721
|
+
matcher: 'Read|Edit|Write|MultiEdit',
|
|
1676
1722
|
hooks: [{ type: 'command', command: 'stitch _hook' }],
|
|
1677
1723
|
};
|
|
1678
1724
|
function mergeHook(existing, entry) {
|