@stitchdb/cli 0.7.1 → 0.7.3

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 +80 -11
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -495,7 +495,18 @@ async function cmdHook(args) {
495
495
  await handlePostReadHook(cfg, event, cwd || process.cwd()).catch(() => { });
496
496
  return;
497
497
  }
498
- // ── SessionStart: inject prior context (token-efficient) ─────────────
498
+ // ── SessionStart: self-heal hook config + inject prior context ─────────
499
+ // Self-heal: a CLI upgrade often introduces new hooks (e.g. PreToolUse on
500
+ // Read in 0.7.0). Without re-running `stitch install`, the old settings.json
501
+ // would silently miss them. Each SessionStart we (re)wire any missing
502
+ // Stitch hook entries — never touching non-Stitch ones.
503
+ if (eventName === 'SessionStart') {
504
+ ensureHooksUpToDate();
505
+ // Catch-up distillation: if a previous session ended with undistilled
506
+ // turns (user exited under threshold), distill them now in the
507
+ // background so they surface in this session's context very soon.
508
+ maybeAutoDistill(threadName, { force: true }).catch(() => { });
509
+ }
499
510
  // Strategy: prefer distilled memories (dense facts) over raw turns. Only
500
511
  // include raw turns for the last 5 to give the agent immediate continuation.
501
512
  if (eventName === 'SessionStart') {
@@ -957,9 +968,13 @@ async function cmdLink(args) {
957
968
  // Triggered manually (`stitch distill`), and automatically by the Stop hook
958
969
  // when conditions are met (cooldown + new-turn threshold).
959
970
  const DISTILL_STATE_FILE = path.join(CONFIG_DIR, 'distill-state.json');
960
- const DISTILL_COOLDOWN_MS = 30 * 60 * 1000; // don't distill more than once per 30 min
961
- const DISTILL_MIN_NEW_TURNS = 10; // need 10 new turns before bothering
971
+ // No time-based cooldown purely turn-count driven. The previous time gate
972
+ // could leave turns undistilled if the user exited Claude before the next
973
+ // firing window. Now: every Stop checks the pending-turn count, and if the
974
+ // user exits below threshold, the next SessionStart catches up.
975
+ const DISTILL_MIN_NEW_TURNS = 5; // distill once 5+ new turns are pending
962
976
  const DISTILL_BATCH_SIZE = 30; // turns per distillation pass
977
+ const DISTILL_DEBOUNCE_MS = 90 * 1000; // small in-flight guard so a flurry of Stops doesn't fire N spawns
963
978
  function loadDistillState() {
964
979
  try {
965
980
  return JSON.parse(fs.readFileSync(DISTILL_STATE_FILE, 'utf8'));
@@ -1196,14 +1211,24 @@ async function distillClear(args) {
1196
1211
  }
1197
1212
  console.log(`Cleared ${deleted} memories.`);
1198
1213
  }
1199
- // Triggered from the Stop hook (fire-and-forget, never blocks the user).
1200
- async function maybeAutoDistill(thread) {
1214
+ // Triggered from Stop hook AND SessionStart catch-up. Fire-and-forget.
1215
+ //
1216
+ // Decision logic:
1217
+ // - If `pending = currentTurns - lastDistilledTurns` >= DISTILL_MIN_NEW_TURNS,
1218
+ // spawn a distill pass. No time-based cooldown — leaving turns undistilled
1219
+ // because the user happened to exit before the next firing window is the
1220
+ // wrong behaviour.
1221
+ // - SessionStart calls this with `force = true` when ANY pending turns exist
1222
+ // so a session that ended mid-batch (e.g. 3 pending) still gets caught up.
1223
+ // - DISTILL_DEBOUNCE_MS is a tiny in-process guard so a flurry of Stop
1224
+ // events from a single agent run doesn't trigger N parallel claude -p
1225
+ // spawns; once one is in flight, others within the window become no-ops.
1226
+ async function maybeAutoDistill(thread, opts = {}) {
1201
1227
  const state = loadDistillState();
1202
1228
  const meta = state.threads[thread] || { lastDistilledAt: 0, lastTurnAt: 0, lastTurnCount: 0 };
1203
- // Cool-down: don't distill more than once per 30 min per thread.
1204
- if (Date.now() - meta.lastDistilledAt < DISTILL_COOLDOWN_MS)
1229
+ // In-flight debounce: if we just spawned one, skip.
1230
+ if (Date.now() - meta.lastDistilledAt < DISTILL_DEBOUNCE_MS)
1205
1231
  return;
1206
- // Need at least N new turns since last pass.
1207
1232
  const cfg = loadConfig();
1208
1233
  const stitch = client(cfg);
1209
1234
  let recallSize = 0;
@@ -1214,17 +1239,21 @@ async function maybeAutoDistill(thread) {
1214
1239
  catch {
1215
1240
  return;
1216
1241
  }
1217
- if (recallSize - meta.lastTurnCount < DISTILL_MIN_NEW_TURNS)
1242
+ const pending = recallSize - meta.lastTurnCount;
1243
+ // Default path: only distill once enough turns have piled up.
1244
+ // SessionStart catch-up: distill if there's ANY pending turn.
1245
+ const threshold = opts.force ? 1 : DISTILL_MIN_NEW_TURNS;
1246
+ if (pending < threshold)
1218
1247
  return;
1219
1248
  // Mark BEFORE running so we don't double-fire on overlapping Stop events.
1220
1249
  state.threads[thread] = { lastDistilledAt: Date.now(), lastTurnAt: Date.now(), lastTurnCount: recallSize };
1221
1250
  saveDistillState(state);
1222
- // Detach: spawn a background process so the Stop hook returns immediately.
1223
- // The detached child runs `stitch distill` for this thread.
1251
+ // Detach: spawn a background process so the hook returns immediately.
1224
1252
  try {
1225
1253
  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)], {
1226
1254
  detached: true,
1227
1255
  stdio: 'ignore',
1256
+ env: { ...process.env, STITCH_HOOKS_DISABLED: '1' },
1228
1257
  });
1229
1258
  child.unref();
1230
1259
  }
@@ -1676,6 +1705,46 @@ function mergeHook(existing, entry) {
1676
1705
  filtered.push(entry);
1677
1706
  return filtered;
1678
1707
  }
1708
+ /**
1709
+ * Idempotent re-wiring of every Stitch hook into ~/.claude/settings.json.
1710
+ * Called on every SessionStart so a CLI upgrade auto-installs any new hooks
1711
+ * (e.g. PreToolUse:Read added in 0.7.0) without the user re-running
1712
+ * `stitch install`. Never touches non-Stitch hook entries.
1713
+ */
1714
+ function ensureHooksUpToDate() {
1715
+ try {
1716
+ const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
1717
+ if (!fs.existsSync(path.dirname(settingsPath)))
1718
+ return;
1719
+ let existing = {};
1720
+ try {
1721
+ existing = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
1722
+ }
1723
+ catch { /* corrupt — skip */
1724
+ return;
1725
+ }
1726
+ existing.hooks = existing.hooks || {};
1727
+ const expected = [
1728
+ ['SessionStart', STITCH_SESSION_START_HOOK],
1729
+ ['UserPromptSubmit', STITCH_USER_HOOK],
1730
+ ['Stop', STITCH_STOP_HOOK],
1731
+ ['PreToolUse', STITCH_PRE_READ_HOOK],
1732
+ ['PostToolUse', STITCH_POST_READ_HOOK],
1733
+ ];
1734
+ let changed = false;
1735
+ for (const [key, entry] of expected) {
1736
+ const next = mergeHook(existing.hooks[key], entry);
1737
+ if (JSON.stringify(next) !== JSON.stringify(existing.hooks[key])) {
1738
+ existing.hooks[key] = next;
1739
+ changed = true;
1740
+ }
1741
+ }
1742
+ if (changed) {
1743
+ fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
1744
+ }
1745
+ }
1746
+ catch { /* never break a session start */ }
1747
+ }
1679
1748
  const STITCH_CLAUDE_MD_BLOCK = `<!-- stitch:auto -->
1680
1749
  ## Stitch memory
1681
1750
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stitchdb/cli",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Stitch CLI — manage memory + run agents from your terminal",
5
5
  "type": "module",
6
6
  "bin": {