@stitchdb/cli 0.6.0 → 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 +454 -43
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -420,6 +420,12 @@ 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
+ // 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.
427
+ if (process.env.STITCH_HOOKS_DISABLED === '1')
428
+ return;
423
429
  let raw = '';
424
430
  try {
425
431
  raw = await readStdinAll();
@@ -441,6 +447,16 @@ async function cmdHook(args) {
441
447
  // (which is the directory `claude` was launched from).
442
448
  const cwd = event?.cwd || process.cwd();
443
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
+ }
444
460
  // ── SessionStart: inject prior context (token-efficient) ─────────────
445
461
  // Strategy: prefer distilled memories (dense facts) over raw turns. Only
446
462
  // include raw turns for the last 5 to give the agent immediate continuation.
@@ -448,16 +464,30 @@ async function cmdHook(args) {
448
464
  try {
449
465
  const stitch = client(cfg);
450
466
  const projectTag = (threadName.split('/')[0] || threadName).toLowerCase();
451
- const [thread, memHits] = await Promise.all([
467
+ const [thread, memHits, workspaces, fileSummaries, aboutMems] = await Promise.all([
452
468
  stitch.thread(threadName).recall({ last: 5 }).catch(() => ({ thread_id: '', recent: [], semantic: [] })),
453
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(() => []),
454
473
  ]);
474
+ const currentWs = Array.isArray(workspaces) && workspaces.length > 0 ? workspaces[0] : null;
455
475
  const lines = [];
456
476
  lines.push('<stitch-context>');
457
- 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.`);
458
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
+ }
459
490
  if (Array.isArray(memHits) && memHits.length > 0) {
460
- // Prefer distilled (auto-tagged) facts and decisions; fall back to anything else
461
491
  const sortedMems = [...memHits].sort((a, b) => Number(b.score ?? 0) - Number(a.score ?? 0)).slice(0, 8);
462
492
  lines.push('### Durable memories for this project');
463
493
  for (const m of sortedMems) {
@@ -467,6 +497,16 @@ async function cmdHook(args) {
467
497
  }
468
498
  lines.push('');
469
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
+ }
470
510
  if (thread.recent && thread.recent.length > 0) {
471
511
  lines.push('### Most recent turns (continue from here)');
472
512
  for (const t of thread.recent.slice(-5)) {
@@ -475,7 +515,7 @@ async function cmdHook(args) {
475
515
  }
476
516
  lines.push('');
477
517
  }
478
- 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).');
479
519
  lines.push('</stitch-context>');
480
520
  process.stdout.write(lines.join('\n'));
481
521
  }
@@ -544,6 +584,38 @@ function inferThreadFor(cwd) {
544
584
  }
545
585
  return path.basename(dir) || 'default';
546
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
+ }
547
619
  function findProjectRoot(cwd) {
548
620
  let cur = cwd;
549
621
  for (let i = 0; i < 8; i++) {
@@ -599,6 +671,223 @@ function lastAssistantTextFromTranscript(transcriptPath) {
599
671
  catch { /* ignore */ }
600
672
  return text;
601
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
+ }
602
891
  // ── stitch link — pin a project's thread name so it's the same on every machine
603
892
  async function cmdLink(args) {
604
893
  const positionals = positional(args);
@@ -646,29 +935,24 @@ function saveDistillState(s) {
646
935
  fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
647
936
  fs.writeFileSync(DISTILL_STATE_FILE, JSON.stringify(s, null, 2));
648
937
  }
649
- const DISTILL_PROMPT = `You are a memory distiller for an AI coding assistant.
938
+ const DISTILL_PROMPT = `You are a memory distiller. Output ONLY a JSON array of memory objects. Be GENEROUS — capture every concrete fact about the project that future sessions will care about. Aim for 10–30 memories on a substantial conversation.
650
939
 
651
- Read the conversation below (between a developer and an AI). Extract ONLY
652
- durable, project-specific facts the developer explicitly stated or decisions
653
- they explicitly made. Do not invent. Do not extrapolate from implications.
654
- Skip anything that's tentative, exploratory, or not directly stated.
940
+ Each memory is one atomic, self-contained statement (1–4 sentences) that someone reading it months later — without the conversation around it — will fully understand.
655
941
 
656
- Output a JSON array. Each entry:
657
- {
658
- "kind": "fact" | "decision" | "snippet" | "preference",
659
- "content": "<self-contained, single-paragraph statement>",
660
- "tags": ["<short-keyword>", ...]
661
- }
942
+ Format:
943
+ [{"kind":"fact|decision|snippet|preference","content":"...","tags":["short","keywords"]}, ...]
662
944
 
663
- Rules:
664
- - "fact" : stable knowledge about this project (architecture, services, schema)
665
- - "decision" : explicit choice with rationale (e.g. "we use X because Y")
666
- - "snippet" : a reusable code/config/command pattern the user wants kept
667
- - "preference" : how the developer wants the AI to behave on this project
668
- - Each entry must stand alone readable months later without context.
669
- - Tags should be 1–3 short keywords.
670
- - If nothing durable was said, output exactly: []
671
- - Output ONLY the JSON array. No prose, no code fences, no commentary.
945
+ Capture:
946
+ - fact anything concrete: file paths, endpoint URLs, commands, version
947
+ numbers, architecture, deployed services, schemas, dependencies,
948
+ bugs found, fixes shipped.
949
+ - decision — choices with rationale.
950
+ - snippet reusable commands/config/code/CLI flags/env var names.
951
+ - preference — how the developer wants the AI to behave on this project.
952
+
953
+ Skip pleasantries and questions still being explored. One atomic idea per
954
+ memory — don't bundle. If truly nothing durable was discussed, output: [].
955
+ Output the JSON array and nothing else — no prose, no markdown fences.
672
956
 
673
957
  Conversation:
674
958
  `;
@@ -701,7 +985,29 @@ async function cmdDistill(args) {
701
985
  }
702
986
  const claudeBin = process.env.STITCH_CLAUDE_BIN || 'claude';
703
987
  process.stdout.write(` → asking ${claudeBin} -p to extract facts… `);
704
- const result = await run(claudeBin, ['-p', DISTILL_PROMPT + conversation], { cwd: process.cwd() });
988
+ const fullPrompt = DISTILL_PROMPT + conversation;
989
+ // Pipe the prompt via stdin: long conversations blow past ARG_MAX when
990
+ // passed as `-p <prompt>`, and stdin avoids claude interpreting any
991
+ // prompt-internal characters as flags. `claude -p` with no value reads
992
+ // from stdin. STITCH_HOOKS_DISABLED stops our own _hook command from
993
+ // logging this nested distill conversation as project thread turns.
994
+ const result = await runWithStdin(claudeBin, ['-p'], fullPrompt, {
995
+ cwd: process.cwd(),
996
+ env: { STITCH_HOOKS_DISABLED: '1' },
997
+ });
998
+ // Debug capture: STITCH_DEBUG_DISTILL=1 writes raw stdout/stderr/prompt
999
+ // to /tmp so we can see exactly what claude actually returned.
1000
+ if (process.env.STITCH_DEBUG_DISTILL === '1') {
1001
+ const ts = Date.now();
1002
+ const dbgDir = '/tmp';
1003
+ try {
1004
+ fs.writeFileSync(path.join(dbgDir, `stitch-distill-${ts}.prompt.txt`), fullPrompt);
1005
+ fs.writeFileSync(path.join(dbgDir, `stitch-distill-${ts}.stdout.txt`), result.stdout);
1006
+ fs.writeFileSync(path.join(dbgDir, `stitch-distill-${ts}.stderr.txt`), result.stderr);
1007
+ console.log(`\n [debug] wrote /tmp/stitch-distill-${ts}.{prompt,stdout,stderr}.txt`);
1008
+ }
1009
+ catch { /* ignore */ }
1010
+ }
705
1011
  if (result.exit_code !== 0) {
706
1012
  console.log('failed');
707
1013
  console.error(result.stderr.trim().slice(0, 400));
@@ -710,6 +1016,10 @@ async function cmdDistill(args) {
710
1016
  const memories = parseDistillationOutput(result.stdout);
711
1017
  console.log(`extracted ${memories.length} memories`);
712
1018
  if (memories.length === 0) {
1019
+ if (process.env.STITCH_DEBUG_DISTILL === '1') {
1020
+ console.error(' [debug] claude stdout (first 600 chars):');
1021
+ console.error(' ' + result.stdout.slice(0, 600).replace(/\n/g, '\n '));
1022
+ }
713
1023
  bumpDistillCooldown(thread);
714
1024
  return;
715
1025
  }
@@ -739,25 +1049,76 @@ function bumpDistillCooldown(thread) {
739
1049
  saveDistillState(state);
740
1050
  }
741
1051
  function parseDistillationOutput(stdout) {
742
- // claude -p sometimes wraps in markdown fences or adds a "Here are the memories:" preamble.
743
- // Strip everything outside the first balanced [..] array.
1052
+ // claude -p often wraps the array in markdown fences, prepends prose like
1053
+ // "Here are the extracted memories:", or follows it with a sign-off line.
1054
+ // Strategy: try several extraction modes from most-precise to most-lenient,
1055
+ // returning the first one that parses to a non-empty valid array.
744
1056
  const text = stdout.trim();
745
- const start = text.indexOf('[');
746
- const end = text.lastIndexOf(']');
747
- if (start === -1 || end === -1 || end < start)
1057
+ if (!text)
748
1058
  return [];
749
- const json = text.slice(start, end + 1);
750
- try {
751
- const parsed = JSON.parse(json);
752
- if (!Array.isArray(parsed))
753
- return [];
754
- return parsed.filter((m) => m && typeof m === 'object'
755
- && typeof m.content === 'string' && m.content.length > 0
756
- && typeof m.kind === 'string' && ['fact', 'decision', 'snippet', 'preference', 'note'].includes(m.kind));
1059
+ const candidates = [];
1060
+ // 1. Fenced ```json block
1061
+ const fenced = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/i);
1062
+ if (fenced)
1063
+ candidates.push(fenced[1].trim());
1064
+ // 2. The first `[ {` last `} ]` window (skips prose-level stray brackets).
1065
+ const objStart = text.indexOf('[');
1066
+ const objEnd = text.lastIndexOf(']');
1067
+ if (objStart !== -1 && objEnd > objStart) {
1068
+ candidates.push(text.slice(objStart, objEnd + 1));
1069
+ }
1070
+ // 3. Largest `[\n{ ... }\n]` block via balanced scan from each `[`
1071
+ for (let i = 0; i < text.length; i++) {
1072
+ if (text[i] !== '[')
1073
+ continue;
1074
+ let depth = 0;
1075
+ let inStr = false;
1076
+ let esc = false;
1077
+ for (let j = i; j < text.length; j++) {
1078
+ const ch = text[j];
1079
+ if (esc) {
1080
+ esc = false;
1081
+ continue;
1082
+ }
1083
+ if (ch === '\\' && inStr) {
1084
+ esc = true;
1085
+ continue;
1086
+ }
1087
+ if (ch === '"') {
1088
+ inStr = !inStr;
1089
+ continue;
1090
+ }
1091
+ if (inStr)
1092
+ continue;
1093
+ if (ch === '[')
1094
+ depth++;
1095
+ else if (ch === ']') {
1096
+ depth--;
1097
+ if (depth === 0) {
1098
+ candidates.push(text.slice(i, j + 1));
1099
+ break;
1100
+ }
1101
+ }
1102
+ }
757
1103
  }
758
- catch {
759
- return [];
1104
+ const seen = new Set();
1105
+ for (const cand of candidates) {
1106
+ if (seen.has(cand))
1107
+ continue;
1108
+ seen.add(cand);
1109
+ try {
1110
+ const parsed = JSON.parse(cand);
1111
+ if (!Array.isArray(parsed))
1112
+ continue;
1113
+ const valid = parsed.filter((m) => m && typeof m === 'object'
1114
+ && typeof m.content === 'string' && m.content.length > 0
1115
+ && typeof m.kind === 'string' && ['fact', 'decision', 'snippet', 'preference', 'note'].includes(m.kind));
1116
+ if (valid.length > 0)
1117
+ return valid;
1118
+ }
1119
+ catch { /* try next candidate */ }
760
1120
  }
1121
+ return [];
761
1122
  }
762
1123
  async function distillReview(args) {
763
1124
  const cfg = loadConfig();
@@ -1102,6 +1463,26 @@ async function cmdInstall(args) {
1102
1463
  const ws = await stitch.resolveWorkspace();
1103
1464
  const baseUrl = cfg.baseUrl || 'https://db.stitchdb.com';
1104
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
+ }
1105
1486
  const noMcp = hasFlag(args, ['--no-mcp']);
1106
1487
  const noHooks = hasFlag(args, ['--no-hooks']);
1107
1488
  const noClaudeMd = hasFlag(args, ['--no-claude-md']);
@@ -1120,9 +1501,10 @@ async function cmdInstall(args) {
1120
1501
  else
1121
1502
  console.log(`failed (${stderr.trim().slice(0, 120)})`);
1122
1503
  }
1123
- // 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).
1124
1506
  if (!noHooks) {
1125
- process.stdout.write('• Wiring hooks (SessionStart + UserPromptSubmit + Stop)… ');
1507
+ process.stdout.write('• Wiring hooks (SessionStart + UserPromptSubmit + Stop + Pre/PostToolUse:Read)… ');
1126
1508
  try {
1127
1509
  const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
1128
1510
  const existing = fs.existsSync(settingsPath)
@@ -1132,6 +1514,8 @@ async function cmdInstall(args) {
1132
1514
  existing.hooks.SessionStart = mergeHook(existing.hooks.SessionStart, STITCH_SESSION_START_HOOK);
1133
1515
  existing.hooks.UserPromptSubmit = mergeHook(existing.hooks.UserPromptSubmit, STITCH_USER_HOOK);
1134
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);
1135
1519
  fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
1136
1520
  fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
1137
1521
  console.log('ok');
@@ -1213,10 +1597,11 @@ async function cmdInstall(args) {
1213
1597
  }
1214
1598
  console.log();
1215
1599
  console.log('Done. Open a fresh `claude` session — Stitch is wired.');
1216
- console.log(' • remember/recall tools are available');
1600
+ console.log(' • workspace_setup, recall, remember, thread tools are available');
1217
1601
  console.log(' • every turn auto-logs to the thread for this repo');
1218
1602
  console.log(' • Claude auto-pulls past context at session start (per CLAUDE.md)');
1219
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');
1220
1605
  console.log();
1221
1606
  console.log('Run `stitch sync --watch` to keep them in sync as Claude updates them.');
1222
1607
  }
@@ -1236,6 +1621,15 @@ const STITCH_SESSION_START_HOOK = {
1236
1621
  matcher: '*',
1237
1622
  hooks: [{ type: 'command', command: 'stitch _hook SessionStart' }],
1238
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
+ };
1239
1633
  function mergeHook(existing, entry) {
1240
1634
  const arr = Array.isArray(existing) ? existing.slice() : [];
1241
1635
  // Replace any earlier Stitch entry; identify by the marker.
@@ -1387,6 +1781,22 @@ function run(cmd, args, opts = {}) {
1387
1781
  child.on('close', (code) => resolve({ stdout, stderr, exit_code: code ?? 0 }));
1388
1782
  });
1389
1783
  }
1784
+ function runWithStdin(cmd, args, stdinData, opts = {}) {
1785
+ return new Promise((resolve) => {
1786
+ const child = spawn(cmd, args, {
1787
+ cwd: opts.cwd || process.cwd(),
1788
+ env: { ...process.env, ...(opts.env || {}) },
1789
+ stdio: ['pipe', 'pipe', 'pipe'],
1790
+ });
1791
+ let stdout = '';
1792
+ let stderr = '';
1793
+ child.stdout.on('data', (d) => stdout += d.toString());
1794
+ child.stderr.on('data', (d) => stderr += d.toString());
1795
+ child.on('error', (err) => resolve({ stdout, stderr: stderr + '\n[spawn error] ' + err.message, exit_code: 127 }));
1796
+ child.on('close', (code) => resolve({ stdout, stderr, exit_code: code ?? 0 }));
1797
+ child.stdin.end(stdinData);
1798
+ });
1799
+ }
1390
1800
  function readLine() {
1391
1801
  return new Promise((resolve) => {
1392
1802
  const onData = (d) => { process.stdin.off('data', onData); resolve(d.toString().trim()); };
@@ -1477,6 +1887,7 @@ async function main(argv) {
1477
1887
  case 'link': return cmdLink(rest);
1478
1888
  case 'distill': return cmdDistill(rest);
1479
1889
  case '_hook': return cmdHook(rest);
1890
+ case '_summarize-file': return cmdSummarizeFile(rest);
1480
1891
  case 'update':
1481
1892
  case 'upgrade': return cmdUpdate(rest);
1482
1893
  case 'version':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stitchdb/cli",
3
- "version": "0.6.0",
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",