@stitchdb/cli 0.6.1 → 0.7.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.
Files changed (2) hide show
  1. package/dist/cli.js +328 -10
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -420,9 +420,10 @@ async function cmdHook(args) {
420
420
  const cfg = loadConfig();
421
421
  if (!cfg.apiKey)
422
422
  return; // not logged in, silently skip
423
- // When the Stitch CLI spawns its own `claude -p` (e.g. for distillation),
424
- // we don't want THAT inner conversation logged as a user/assistant turn —
425
- // it would pollute the project thread with the distill prompt and JSON.
423
+ // When the Stitch CLI spawns its own `claude -p` (e.g. for distillation
424
+ // or file summary extraction), we don't want THAT inner conversation
425
+ // logged or its tool calls cached it would create runaway recursion
426
+ // and pollute the thread with internal prompts.
426
427
  if (process.env.STITCH_HOOKS_DISABLED === '1')
427
428
  return;
428
429
  let raw = '';
@@ -446,6 +447,16 @@ async function cmdHook(args) {
446
447
  // (which is the directory `claude` was launched from).
447
448
  const cwd = event?.cwd || process.cwd();
448
449
  const threadName = inferThreadFor(cwd) || 'default';
450
+ // ── PreToolUse on Read: inject cached summary if hash matches ─────────
451
+ if (eventName === 'PreToolUse' && event?.tool_name === 'Read') {
452
+ await handlePreReadHook(cfg, event, cwd || process.cwd()).catch(() => { });
453
+ return;
454
+ }
455
+ // ── PostToolUse on Read: opportunistically save a fresh summary ───────
456
+ if (eventName === 'PostToolUse' && event?.tool_name === 'Read') {
457
+ await handlePostReadHook(cfg, event, cwd || process.cwd()).catch(() => { });
458
+ return;
459
+ }
449
460
  // ── SessionStart: inject prior context (token-efficient) ─────────────
450
461
  // Strategy: prefer distilled memories (dense facts) over raw turns. Only
451
462
  // include raw turns for the last 5 to give the agent immediate continuation.
@@ -453,16 +464,30 @@ async function cmdHook(args) {
453
464
  try {
454
465
  const stitch = client(cfg);
455
466
  const projectTag = (threadName.split('/')[0] || threadName).toLowerCase();
456
- const [thread, memHits] = await Promise.all([
467
+ const [thread, memHits, workspaces, fileSummaries, aboutMems] = await Promise.all([
457
468
  stitch.thread(threadName).recall({ last: 5 }).catch(() => ({ thread_id: '', recent: [], semantic: [] })),
458
469
  stitch.recall(projectTag, { k: 8 }).catch(() => []),
470
+ stitch.workspaces.list().catch(() => []),
471
+ stitch.list({ limit: 12 }).then((all) => all.filter((m) => m.tags.some((t) => t.startsWith('file:')))).catch(() => []),
472
+ stitch.list({ tag: 'workspace:about', limit: 1 }).catch(() => []),
459
473
  ]);
474
+ const currentWs = Array.isArray(workspaces) && workspaces.length > 0 ? workspaces[0] : null;
460
475
  const lines = [];
461
476
  lines.push('<stitch-context>');
462
- lines.push(`Project: ${threadName} · Stitch MCP server is available with tools: recall, remember, thread_recall, thread_append.`);
477
+ lines.push(`Project: ${threadName} · Workspace: ${currentWs?.name || '(unknown)'} · Stitch MCP tools: recall, remember, thread_recall, thread_append, workspace_setup, file_summary, file_summary_save.`);
463
478
  lines.push('');
479
+ // Nudge the AI to set a meaningful workspace name once.
480
+ if (currentWs?.name === 'default') {
481
+ lines.push('### ⚠ Workspace is still named "default"');
482
+ lines.push('Call the `workspace_setup` MCP tool with a slug-style name based on this project (e.g. the package.json name or repo dir).');
483
+ lines.push('');
484
+ }
485
+ if (Array.isArray(aboutMems) && aboutMems.length > 0) {
486
+ lines.push('### About this workspace');
487
+ lines.push(String(aboutMems[0].content || '').slice(0, 400));
488
+ lines.push('');
489
+ }
464
490
  if (Array.isArray(memHits) && memHits.length > 0) {
465
- // Prefer distilled (auto-tagged) facts and decisions; fall back to anything else
466
491
  const sortedMems = [...memHits].sort((a, b) => Number(b.score ?? 0) - Number(a.score ?? 0)).slice(0, 8);
467
492
  lines.push('### Durable memories for this project');
468
493
  for (const m of sortedMems) {
@@ -472,6 +497,16 @@ async function cmdHook(args) {
472
497
  }
473
498
  lines.push('');
474
499
  }
500
+ if (Array.isArray(fileSummaries) && fileSummaries.length > 0) {
501
+ lines.push('### File summaries (cached — call `file_summary` before reading)');
502
+ for (const m of fileSummaries.slice(0, 8)) {
503
+ const fileTag = m.tags.find((t) => t.startsWith('file:'));
504
+ const filePath = fileTag ? fileTag.slice(5) : '(unknown)';
505
+ const txt = String(m.content || '').replace(/\n+/g, ' ').slice(0, 250);
506
+ lines.push(`- **${filePath}** — ${txt}`);
507
+ }
508
+ lines.push('');
509
+ }
475
510
  if (thread.recent && thread.recent.length > 0) {
476
511
  lines.push('### Most recent turns (continue from here)');
477
512
  for (const t of thread.recent.slice(-5)) {
@@ -480,7 +515,7 @@ async function cmdHook(args) {
480
515
  }
481
516
  lines.push('');
482
517
  }
483
- lines.push('Call `recall` for deeper search or `thread_recall` for older turns when needed.');
518
+ lines.push('Call `recall` for deeper search, `thread_recall` for older turns, `file_summary` BEFORE reading any non-trivial file (saves tokens).');
484
519
  lines.push('</stitch-context>');
485
520
  process.stdout.write(lines.join('\n'));
486
521
  }
@@ -549,6 +584,38 @@ function inferThreadFor(cwd) {
549
584
  }
550
585
  return path.basename(dir) || 'default';
551
586
  }
587
+ /**
588
+ * Best-effort derive a workspace slug from a project directory:
589
+ * - package.json `name` (npm convention, slug-friendly already)
590
+ * - else folder basename, normalised to a lowercase slug
591
+ *
592
+ * The AI can later refine via the `workspace_setup` MCP tool — this is just
593
+ * the seed name so it isn't "default".
594
+ */
595
+ function deriveWorkspaceName(cwd) {
596
+ // 1. package.json `name`.
597
+ try {
598
+ const pkgPath = path.join(cwd, 'package.json');
599
+ if (fs.existsSync(pkgPath)) {
600
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
601
+ if (typeof pkg.name === 'string' && pkg.name.trim()) {
602
+ // Strip leading scope (@org/) if any — keeps the slug clean.
603
+ const stripped = pkg.name.replace(/^@[^/]+\//, '').trim();
604
+ if (stripped)
605
+ return slugify(stripped).slice(0, 60);
606
+ }
607
+ }
608
+ }
609
+ catch { /* fall through */ }
610
+ // 2. Folder basename.
611
+ return slugify(path.basename(cwd) || 'default').slice(0, 60) || 'default';
612
+ }
613
+ function slugify(s) {
614
+ return s
615
+ .toLowerCase()
616
+ .replace(/[^a-z0-9]+/g, '-')
617
+ .replace(/^-+|-+$/g, '') || 'default';
618
+ }
552
619
  function findProjectRoot(cwd) {
553
620
  let cur = cwd;
554
621
  for (let i = 0; i < 8; i++) {
@@ -604,6 +671,223 @@ function lastAssistantTextFromTranscript(transcriptPath) {
604
671
  catch { /* ignore */ }
605
672
  return text;
606
673
  }
674
+ // ─── File summary cache ────────────────────────────────────────────────────
675
+ //
676
+ // Goal: when Claude reads a non-trivial file we've seen before, surface our
677
+ // cached 2-3 sentence summary so the agent can decide whether to skim or
678
+ // re-read in full. When Claude reads a file we haven't summarized (or that's
679
+ // changed), kick off a background `claude -p` to extract a summary and save
680
+ // it as a memory tagged `file:<path>` + `hash:<sha-prefix>`.
681
+ //
682
+ // Trade-off: the cache is hash-bound, so any byte change invalidates the
683
+ // cached summary — fine for big stable files (docs, configs, third-party src),
684
+ // less useful for files Claude is actively editing. The PostToolUse hook
685
+ // re-summarizes after the next read, so the cache repopulates on its own.
686
+ const FILE_SUMMARY_MIN_BYTES = 500; // skip tiny files
687
+ const FILE_SUMMARY_MAX_BYTES = 200_000; // skip absolutely huge files
688
+ const FILE_SUMMARY_COOLDOWN_MS = 5 * 60_000; // per-file: don't re-summarize more than once per 5 min
689
+ const FILE_SUMMARY_PROMPT = `You are summarising a single source file for an AI coding assistant cache.
690
+
691
+ Return ONLY a 2-3 sentence summary describing:
692
+ • What the file does (purpose, the few exported symbols / commands / endpoints worth knowing).
693
+ • Any important pattern, convention, or non-obvious behaviour another agent would benefit from before reading the file in full.
694
+
695
+ Skip preamble. Skip "this file …". Be specific. No markdown fences. No prose around it.
696
+
697
+ File path: {{PATH}}
698
+
699
+ File content:
700
+ {{CONTENT}}
701
+ `;
702
+ function readFileContentFromHookEvent(event) {
703
+ // Claude Code hook 'tool_response' shape varies between versions; accept
704
+ // a few shapes. The Read tool's success path includes the file content
705
+ // somewhere in tool_response.
706
+ const r = event?.tool_response;
707
+ if (!r)
708
+ return '';
709
+ if (typeof r === 'string')
710
+ return r;
711
+ if (typeof r?.file?.content === 'string')
712
+ return r.file.content;
713
+ if (typeof r?.content === 'string')
714
+ return r.content;
715
+ if (Array.isArray(r?.content)) {
716
+ return r.content.map((c) => (typeof c === 'string' ? c : c?.text ?? '')).join('');
717
+ }
718
+ return '';
719
+ }
720
+ function relPathFor(cwd, abs) {
721
+ try {
722
+ return path.relative(cwd, abs) || abs;
723
+ }
724
+ catch {
725
+ return abs;
726
+ }
727
+ }
728
+ async function sha256Hex(s) {
729
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
730
+ return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
731
+ }
732
+ async function lookupFileSummary(stitch, relPath) {
733
+ try {
734
+ const list = await stitch.list({ tag: `file:${relPath}`, limit: 5 });
735
+ if (!list || list.length === 0)
736
+ return null;
737
+ const first = list[0];
738
+ const hashTag = first.tags.find((t) => t.startsWith('hash:'));
739
+ return {
740
+ id: first.id,
741
+ content: first.content,
742
+ hash: hashTag ? hashTag.slice(5) : null,
743
+ created_at: first.created_at,
744
+ };
745
+ }
746
+ catch {
747
+ return null;
748
+ }
749
+ }
750
+ // PreToolUse on Read: if we have a cached summary for this exact file path,
751
+ // emit Claude-Code-flavoured JSON to stdout that adds it as additional context.
752
+ async function handlePreReadHook(cfg, event, cwd) {
753
+ const filePath = String(event?.tool_input?.file_path || '');
754
+ if (!filePath)
755
+ return;
756
+ const rel = relPathFor(cwd, filePath);
757
+ const stitch = client(cfg);
758
+ const cached = await lookupFileSummary(stitch, rel);
759
+ if (!cached)
760
+ return;
761
+ const ageMin = Math.round((Date.now() - cached.created_at) / 60_000);
762
+ const note = [
763
+ `Stitch cache: a prior summary of ${rel} exists (${ageMin}m ago).`,
764
+ cached.hash ? `Cached hash prefix: ${cached.hash}.` : '',
765
+ `Summary: ${cached.content}`,
766
+ `If the file hasn't changed, you can act on this summary alone and skip a full read; otherwise the read will re-cache the summary.`,
767
+ ].filter(Boolean).join('\n');
768
+ // Claude Code accepts a JSON object on stdout for PreToolUse with
769
+ // hookSpecificOutput.additionalContext to inject text into the model's
770
+ // context for this tool call.
771
+ const payload = { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: note } };
772
+ process.stdout.write(JSON.stringify(payload));
773
+ }
774
+ // PostToolUse on Read: kick off a background `claude -p` to summarize this
775
+ // file IF we don't already have a summary at the current hash. Fire-and-forget.
776
+ async function handlePostReadHook(cfg, event, cwd) {
777
+ const filePath = String(event?.tool_input?.file_path || '');
778
+ if (!filePath)
779
+ return;
780
+ const rel = relPathFor(cwd, filePath);
781
+ const content = readFileContentFromHookEvent(event);
782
+ // Fall back to reading from disk if hook payload didn't carry content.
783
+ let body = content;
784
+ if (!body) {
785
+ try {
786
+ body = fs.readFileSync(filePath, 'utf8');
787
+ }
788
+ catch {
789
+ return;
790
+ }
791
+ }
792
+ if (!body || body.length < FILE_SUMMARY_MIN_BYTES)
793
+ return;
794
+ if (body.length > FILE_SUMMARY_MAX_BYTES)
795
+ return;
796
+ const fullHash = await sha256Hex(body);
797
+ const hashPrefix = fullHash.slice(0, 16);
798
+ // Per-file cooldown so a hot-edited file doesn't re-summarize on every read.
799
+ const state = loadFileSummaryState();
800
+ const last = state.files[rel];
801
+ if (last && last.hash === hashPrefix && Date.now() - last.lastSummarizedAt < FILE_SUMMARY_COOLDOWN_MS)
802
+ return;
803
+ const stitch = client(cfg);
804
+ const cached = await lookupFileSummary(stitch, rel);
805
+ if (cached && cached.hash === hashPrefix) {
806
+ // Already summarized this exact version — refresh cooldown and bail.
807
+ state.files[rel] = { hash: hashPrefix, lastSummarizedAt: Date.now() };
808
+ saveFileSummaryState(state);
809
+ return;
810
+ }
811
+ // Mark BEFORE spawning so a flurry of Read events doesn't fire N spawns.
812
+ state.files[rel] = { hash: hashPrefix, lastSummarizedAt: Date.now() };
813
+ saveFileSummaryState(state);
814
+ // Spawn detached: stitch _summarize-file <abs-path> <hash> <rel>
815
+ try {
816
+ const cliPath = process.argv[1] || (await import('node:url')).fileURLToPath(import.meta.url);
817
+ const child = spawn(process.argv[0], [cliPath, '_summarize-file', filePath, hashPrefix, rel], {
818
+ detached: true,
819
+ stdio: 'ignore',
820
+ env: { ...process.env, STITCH_HOOKS_DISABLED: '1' },
821
+ });
822
+ child.unref();
823
+ }
824
+ catch { /* ignore */ }
825
+ }
826
+ // File summary state: per-file cooldown so we don't re-fire on every Read.
827
+ const FILE_SUMMARY_STATE_FILE = path.join(CONFIG_DIR, 'file-summary-state.json');
828
+ function loadFileSummaryState() {
829
+ try {
830
+ return JSON.parse(fs.readFileSync(FILE_SUMMARY_STATE_FILE, 'utf8'));
831
+ }
832
+ catch {
833
+ return { files: {} };
834
+ }
835
+ }
836
+ function saveFileSummaryState(s) {
837
+ if (!fs.existsSync(CONFIG_DIR))
838
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
839
+ fs.writeFileSync(FILE_SUMMARY_STATE_FILE, JSON.stringify(s, null, 2));
840
+ }
841
+ // `stitch _summarize-file <abs> <hashPrefix> <rel>` — internal entry called
842
+ // from the detached PostToolUse spawn. Reads the file, asks claude -p for a
843
+ // 2-3 sentence summary, saves as a Stitch memory.
844
+ async function cmdSummarizeFile(args) {
845
+ const [abs, hashPrefix, rel] = args;
846
+ if (!abs || !hashPrefix || !rel)
847
+ return;
848
+ const cfg = loadConfig();
849
+ if (!cfg.apiKey)
850
+ return;
851
+ let body = '';
852
+ try {
853
+ body = fs.readFileSync(abs, 'utf8');
854
+ }
855
+ catch {
856
+ return;
857
+ }
858
+ if (!body || body.length > FILE_SUMMARY_MAX_BYTES)
859
+ return;
860
+ const claudeBin = process.env.STITCH_CLAUDE_BIN || 'claude';
861
+ const prompt = FILE_SUMMARY_PROMPT
862
+ .replace('{{PATH}}', rel)
863
+ .replace('{{CONTENT}}', body);
864
+ const result = await runWithStdin(claudeBin, ['-p'], prompt, {
865
+ cwd: process.cwd(),
866
+ env: { STITCH_HOOKS_DISABLED: '1' },
867
+ });
868
+ if (result.exit_code !== 0)
869
+ return;
870
+ const summary = result.stdout.trim().slice(0, 2000);
871
+ if (!summary)
872
+ return;
873
+ try {
874
+ const stitch = client(cfg);
875
+ const projectTag = (inferThread() || 'default').split('/')[0];
876
+ // Replace any prior summary for this path.
877
+ const prior = await stitch.list({ tag: `file:${rel}`, limit: 50 }).catch(() => []);
878
+ for (const p of prior) {
879
+ try {
880
+ await stitch.forget(p.id);
881
+ }
882
+ catch { }
883
+ }
884
+ await stitch.remember(summary, {
885
+ kind: 'snippet',
886
+ tags: [`file:${rel}`, `hash:${hashPrefix}`, 'auto:file-summary', `project:${projectTag}`],
887
+ });
888
+ }
889
+ catch { /* silent — never break a session */ }
890
+ }
607
891
  // ── stitch link — pin a project's thread name so it's the same on every machine
608
892
  async function cmdLink(args) {
609
893
  const positionals = positional(args);
@@ -1179,6 +1463,26 @@ async function cmdInstall(args) {
1179
1463
  const ws = await stitch.resolveWorkspace();
1180
1464
  const baseUrl = cfg.baseUrl || 'https://db.stitchdb.com';
1181
1465
  const mcpUrl = `${baseUrl}/mcp/v1/${ws}`;
1466
+ // 0. If the workspace is still called "default", upgrade its name to a
1467
+ // project-derived slug so memories surface under a meaningful identity.
1468
+ // The AI can later refine it via the workspace_setup MCP tool.
1469
+ if (!hasFlag(args, ['--no-rename-workspace'])) {
1470
+ try {
1471
+ const current = await stitch.workspaces.get(ws);
1472
+ if (current.name === 'default') {
1473
+ const derived = deriveWorkspaceName(process.cwd());
1474
+ if (derived && derived !== 'default') {
1475
+ process.stdout.write(`• Naming workspace "${derived}" (was "default")… `);
1476
+ await stitch.workspaces.update(ws, { name: derived });
1477
+ console.log('ok');
1478
+ }
1479
+ }
1480
+ }
1481
+ catch (e) {
1482
+ // Older deployments may not have the PATCH endpoint yet.
1483
+ console.log(`• Workspace rename skipped (${e?.message || e}).`);
1484
+ }
1485
+ }
1182
1486
  const noMcp = hasFlag(args, ['--no-mcp']);
1183
1487
  const noHooks = hasFlag(args, ['--no-hooks']);
1184
1488
  const noClaudeMd = hasFlag(args, ['--no-claude-md']);
@@ -1197,9 +1501,10 @@ async function cmdInstall(args) {
1197
1501
  else
1198
1502
  console.log(`failed (${stderr.trim().slice(0, 120)})`);
1199
1503
  }
1200
- // 2. Hooks: auto-log every turn + auto-inject prior context at session start
1504
+ // 2. Hooks: auto-log every turn + auto-inject prior context at session start +
1505
+ // file-summary cache (Pre/PostToolUse on Read).
1201
1506
  if (!noHooks) {
1202
- process.stdout.write('• Wiring hooks (SessionStart + UserPromptSubmit + Stop)… ');
1507
+ process.stdout.write('• Wiring hooks (SessionStart + UserPromptSubmit + Stop + Pre/PostToolUse:Read)… ');
1203
1508
  try {
1204
1509
  const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
1205
1510
  const existing = fs.existsSync(settingsPath)
@@ -1209,6 +1514,8 @@ async function cmdInstall(args) {
1209
1514
  existing.hooks.SessionStart = mergeHook(existing.hooks.SessionStart, STITCH_SESSION_START_HOOK);
1210
1515
  existing.hooks.UserPromptSubmit = mergeHook(existing.hooks.UserPromptSubmit, STITCH_USER_HOOK);
1211
1516
  existing.hooks.Stop = mergeHook(existing.hooks.Stop, STITCH_STOP_HOOK);
1517
+ existing.hooks.PreToolUse = mergeHook(existing.hooks.PreToolUse, STITCH_PRE_READ_HOOK);
1518
+ existing.hooks.PostToolUse = mergeHook(existing.hooks.PostToolUse, STITCH_POST_READ_HOOK);
1212
1519
  fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
1213
1520
  fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
1214
1521
  console.log('ok');
@@ -1290,10 +1597,11 @@ async function cmdInstall(args) {
1290
1597
  }
1291
1598
  console.log();
1292
1599
  console.log('Done. Open a fresh `claude` session — Stitch is wired.');
1293
- console.log(' • remember/recall tools are available');
1600
+ console.log(' • workspace_setup, recall, remember, thread tools are available');
1294
1601
  console.log(' • every turn auto-logs to the thread for this repo');
1295
1602
  console.log(' • Claude auto-pulls past context at session start (per CLAUDE.md)');
1296
1603
  console.log(' • Claude memory files are mirrored to Stitch');
1604
+ console.log(' • file Reads auto-cache 2-3 sentence summaries; Pre-Read injects them');
1297
1605
  console.log();
1298
1606
  console.log('Run `stitch sync --watch` to keep them in sync as Claude updates them.');
1299
1607
  }
@@ -1313,6 +1621,15 @@ const STITCH_SESSION_START_HOOK = {
1313
1621
  matcher: '*',
1314
1622
  hooks: [{ type: 'command', command: 'stitch _hook SessionStart' }],
1315
1623
  };
1624
+ // File summary cache: only fires for the Read tool, both pre and post.
1625
+ const STITCH_PRE_READ_HOOK = {
1626
+ matcher: 'Read',
1627
+ hooks: [{ type: 'command', command: 'stitch _hook' }],
1628
+ };
1629
+ const STITCH_POST_READ_HOOK = {
1630
+ matcher: 'Read',
1631
+ hooks: [{ type: 'command', command: 'stitch _hook' }],
1632
+ };
1316
1633
  function mergeHook(existing, entry) {
1317
1634
  const arr = Array.isArray(existing) ? existing.slice() : [];
1318
1635
  // Replace any earlier Stitch entry; identify by the marker.
@@ -1570,6 +1887,7 @@ async function main(argv) {
1570
1887
  case 'link': return cmdLink(rest);
1571
1888
  case 'distill': return cmdDistill(rest);
1572
1889
  case '_hook': return cmdHook(rest);
1890
+ case '_summarize-file': return cmdSummarizeFile(rest);
1573
1891
  case 'update':
1574
1892
  case 'upgrade': return cmdUpdate(rest);
1575
1893
  case 'version':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stitchdb/cli",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Stitch CLI — manage memory + run agents from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "license": "MIT",
17
17
  "engines": { "node": ">=20" },
18
18
  "dependencies": {
19
- "@stitchdb/agent": "^0.1.1"
19
+ "@stitchdb/agent": "^0.2.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "typescript": "^5.4.0",