@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.
Files changed (2) hide show
  1. package/dist/cli.js +84 -38
  2. 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: opportunistically save a fresh summary ───────
494
- if (eventName === 'PostToolUse' && event?.tool_name === 'Read') {
495
- await handlePostReadHook(cfg, event, cwd || process.cwd()).catch(() => { });
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-3 sentence summary describing:
737
- What the file does (purpose, the few exported symbols / commands / endpoints worth knowing).
738
- Any important pattern, convention, or non-obvious behaviour another agent would benefit from before reading the file in full.
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
- Skip preamble. Skip "this file ". Be specific. No markdown fences. No prose around it.
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 on Read: kick off a background `claude -p` to summarize this
820
- // file IF we don't already have a summary at the current hash. Fire-and-forget.
821
- async function handlePostReadHook(cfg, event, cwd) {
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
- const content = readFileContentFromHookEvent(event);
827
- // Fall back to reading from disk if hook payload didn't carry content.
828
- let body = content;
829
- if (!body) {
830
- try {
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
- if (!body || body.length < FILE_SUMMARY_MIN_BYTES)
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 a flurry of Read events doesn't fire N spawns.
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
- const DISTILL_COOLDOWN_MS = 10 * 60 * 1000; // distill at most once per 10 min per thread
968
- const DISTILL_MIN_NEW_TURNS = 5; // need 5 new turns before bothering
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 the Stop hook (fire-and-forget, never blocks the user).
1207
- async function maybeAutoDistill(thread) {
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
- // Cool-down: don't distill more than once per 30 min per thread.
1211
- if (Date.now() - meta.lastDistilledAt < DISTILL_COOLDOWN_MS)
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
- if (recallSize - meta.lastTurnCount < DISTILL_MIN_NEW_TURNS)
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 Stop hook returns immediately.
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: only fires for the Read tool, both pre and post.
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stitchdb/cli",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Stitch CLI — manage memory + run agents from your terminal",
5
5
  "type": "module",
6
6
  "bin": {