@stitchdb/cli 0.5.0 → 0.6.1

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 +407 -63
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -339,36 +339,8 @@ async function cmdAgent(args) {
339
339
  }
340
340
  // ── Threads — append / recall / current ───────────────────────────────────
341
341
  function inferThread() {
342
- // Reasonable default: <git-repo-name>/<branch> if we're in a git repo,
343
- // else the cwd's basename. Hooks call this to get a stable thread per project.
344
- try {
345
- const dir = process.cwd();
346
- let cur = dir;
347
- for (let i = 0; i < 8; i++) {
348
- if (fs.existsSync(path.join(cur, '.git'))) {
349
- const parent = path.dirname(cur);
350
- const repoName = path.basename(cur);
351
- let branch = 'main';
352
- try {
353
- const head = fs.readFileSync(path.join(cur, '.git', 'HEAD'), 'utf8').trim();
354
- const m = head.match(/^ref:\s+refs\/heads\/(.+)$/);
355
- if (m)
356
- branch = m[1];
357
- }
358
- catch { /* detached */ }
359
- return `${repoName}/${branch}`;
360
- void parent;
361
- }
362
- const next = path.dirname(cur);
363
- if (next === cur)
364
- break;
365
- cur = next;
366
- }
367
- return path.basename(dir);
368
- }
369
- catch {
370
- return 'default';
371
- }
342
+ // Single source of truth: `.stitch/project.json` if present, else cwd basename.
343
+ return inferThreadFor(process.cwd());
372
344
  }
373
345
  async function cmdThread(args) {
374
346
  const cfg = loadConfig();
@@ -448,6 +420,11 @@ async function cmdHook(args) {
448
420
  const cfg = loadConfig();
449
421
  if (!cfg.apiKey)
450
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.
426
+ if (process.env.STITCH_HOOKS_DISABLED === '1')
427
+ return;
451
428
  let raw = '';
452
429
  try {
453
430
  raw = await readStdinAll();
@@ -469,42 +446,43 @@ async function cmdHook(args) {
469
446
  // (which is the directory `claude` was launched from).
470
447
  const cwd = event?.cwd || process.cwd();
471
448
  const threadName = inferThreadFor(cwd) || 'default';
472
- // ── SessionStart: inject prior context as additional context ──────────
449
+ // ── SessionStart: inject prior context (token-efficient) ─────────────
450
+ // Strategy: prefer distilled memories (dense facts) over raw turns. Only
451
+ // include raw turns for the last 5 to give the agent immediate continuation.
473
452
  if (eventName === 'SessionStart') {
474
453
  try {
475
454
  const stitch = client(cfg);
476
- const projectTag = path.basename(cwd || process.cwd());
477
- // Pull both: recent dialogue from the per-repo thread + relevant
478
- // durable memories tagged with the project.
455
+ const projectTag = (threadName.split('/')[0] || threadName).toLowerCase();
479
456
  const [thread, memHits] = await Promise.all([
480
- stitch.thread(threadName).recall({ last: 20 }).catch(() => ({ thread_id: '', recent: [], semantic: [] })),
481
- stitch.recall(projectTag, { k: 5 }).catch(() => []),
457
+ stitch.thread(threadName).recall({ last: 5 }).catch(() => ({ thread_id: '', recent: [], semantic: [] })),
458
+ stitch.recall(projectTag, { k: 8 }).catch(() => []),
482
459
  ]);
483
460
  const lines = [];
484
- lines.push('## Stitch — prior memory for this project');
485
- lines.push('');
486
- lines.push(`Thread: \`${threadName}\` · Workspace recall available via the \`stitch\` MCP server.`);
461
+ lines.push('<stitch-context>');
462
+ lines.push(`Project: ${threadName} · Stitch MCP server is available with tools: recall, remember, thread_recall, thread_append.`);
487
463
  lines.push('');
488
- if (thread.recent && thread.recent.length > 0) {
489
- lines.push('### Recent conversation (continue from here)');
490
- for (const t of thread.recent.slice(-12)) {
491
- const txt = String(t.content || '').replace(/\n+/g, ' ').slice(0, 400);
492
- lines.push(`- **${t.role}**: ${txt}`);
464
+ if (Array.isArray(memHits) && memHits.length > 0) {
465
+ // Prefer distilled (auto-tagged) facts and decisions; fall back to anything else
466
+ const sortedMems = [...memHits].sort((a, b) => Number(b.score ?? 0) - Number(a.score ?? 0)).slice(0, 8);
467
+ lines.push('### Durable memories for this project');
468
+ for (const m of sortedMems) {
469
+ const isAuto = Array.isArray(m.tags) && m.tags.includes('auto');
470
+ const txt = String(m.content || '').replace(/\n+/g, ' ').slice(0, 350);
471
+ lines.push(`- **[${m.kind}${isAuto ? '·auto' : ''}]** ${txt}`);
493
472
  }
494
473
  lines.push('');
495
474
  }
496
- if (Array.isArray(memHits) && memHits.length > 0) {
497
- lines.push('### Durable memories');
498
- for (const m of memHits) {
499
- const txt = String(m.content || '').replace(/\n+/g, ' ').slice(0, 320);
500
- lines.push(`- **[${m.kind}]** ${txt}`);
475
+ if (thread.recent && thread.recent.length > 0) {
476
+ lines.push('### Most recent turns (continue from here)');
477
+ for (const t of thread.recent.slice(-5)) {
478
+ const txt = String(t.content || '').replace(/\n+/g, ' ').slice(0, 300);
479
+ lines.push(`- **${t.role}**: ${txt}`);
501
480
  }
502
481
  lines.push('');
503
482
  }
504
- if (lines.length > 4) {
505
- // Plain stdout is auto-injected as additionalContext for SessionStart.
506
- process.stdout.write(lines.join('\n'));
507
- }
483
+ lines.push('Call `recall` for deeper search or `thread_recall` for older turns when needed.');
484
+ lines.push('</stitch-context>');
485
+ process.stdout.write(lines.join('\n'));
508
486
  }
509
487
  catch { /* silent — never break a session start */ }
510
488
  return;
@@ -533,29 +511,55 @@ async function cmdHook(args) {
533
511
  catch {
534
512
  /* silent */
535
513
  }
514
+ // After Stop, opportunistically kick off a distillation pass in the
515
+ // background (fire-and-forget). Won't block the session; debouncing
516
+ // (cooldown + min-new-turns) is enforced inside maybeAutoDistill.
517
+ if (eventName === 'Stop') {
518
+ maybeAutoDistill(threadName).catch(() => { });
519
+ }
536
520
  }
521
+ /**
522
+ * Derive a thread name for the project at `cwd`. Strategy:
523
+ *
524
+ * 1. Walk up looking for a `.stitch/project.json` containing { "thread": "x" }
525
+ * — this is the authoritative cross-machine pin. Stays exactly the same
526
+ * on every machine that has the file. Created by `stitch link`.
527
+ * 2. Fall back to the folder basename (which already works fine if the user
528
+ * keeps the same folder name across their machines).
529
+ *
530
+ * Deliberately does NOT require git — works for any project structure.
531
+ */
537
532
  function inferThreadFor(cwd) {
538
533
  const dir = cwd && fs.existsSync(cwd) ? cwd : process.cwd();
539
534
  let cur = dir;
540
535
  for (let i = 0; i < 8; i++) {
541
- if (fs.existsSync(path.join(cur, '.git'))) {
542
- const repoName = path.basename(cur);
543
- let branch = 'main';
536
+ const projectFile = path.join(cur, '.stitch', 'project.json');
537
+ if (fs.existsSync(projectFile)) {
544
538
  try {
545
- const head = fs.readFileSync(path.join(cur, '.git', 'HEAD'), 'utf8').trim();
546
- const m = head.match(/^ref:\s+refs\/heads\/(.+)$/);
547
- if (m)
548
- branch = m[1];
539
+ const cfg = JSON.parse(fs.readFileSync(projectFile, 'utf8'));
540
+ if (cfg.thread && typeof cfg.thread === 'string')
541
+ return cfg.thread;
549
542
  }
550
- catch { /* detached */ }
551
- return `${repoName}/${branch}`;
543
+ catch { /* malformed — fall through */ }
552
544
  }
553
545
  const next = path.dirname(cur);
554
546
  if (next === cur)
555
547
  break;
556
548
  cur = next;
557
549
  }
558
- return path.basename(dir);
550
+ return path.basename(dir) || 'default';
551
+ }
552
+ function findProjectRoot(cwd) {
553
+ let cur = cwd;
554
+ for (let i = 0; i < 8; i++) {
555
+ if (fs.existsSync(path.join(cur, '.stitch', 'project.json')))
556
+ return cur;
557
+ const next = path.dirname(cur);
558
+ if (next === cur)
559
+ break;
560
+ cur = next;
561
+ }
562
+ return null;
559
563
  }
560
564
  function lastAssistantTextFromTranscript(transcriptPath) {
561
565
  if (!transcriptPath || !fs.existsSync(transcriptPath))
@@ -600,6 +604,310 @@ function lastAssistantTextFromTranscript(transcriptPath) {
600
604
  catch { /* ignore */ }
601
605
  return text;
602
606
  }
607
+ // ── stitch link — pin a project's thread name so it's the same on every machine
608
+ async function cmdLink(args) {
609
+ const positionals = positional(args);
610
+ const explicit = positionals[0];
611
+ const name = (explicit || path.basename(process.cwd()) || 'default').trim();
612
+ const dir = path.join(process.cwd(), '.stitch');
613
+ fs.mkdirSync(dir, { recursive: true });
614
+ const file = path.join(dir, 'project.json');
615
+ let cfg = {};
616
+ try {
617
+ cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
618
+ }
619
+ catch { }
620
+ cfg.thread = name;
621
+ cfg.linked_at = new Date().toISOString();
622
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2));
623
+ console.log(`Project pinned to thread "${name}"`);
624
+ console.log(` → ${file}`);
625
+ console.log();
626
+ console.log('Commit `.stitch/project.json` to your repo (or sync the file across');
627
+ console.log('machines) so every machine pinning this project uses the same thread.');
628
+ }
629
+ // ── stitch distill — extract durable facts from recent conversation ─────────
630
+ //
631
+ // Uses the user's own `claude -p` to run a strict-JSON extraction prompt over
632
+ // the last N turns of the project's thread. Each extracted fact becomes a
633
+ // Stitch memory tagged `auto:true` so the user can review or wipe them later.
634
+ //
635
+ // Triggered manually (`stitch distill`), and automatically by the Stop hook
636
+ // when conditions are met (cooldown + new-turn threshold).
637
+ const DISTILL_STATE_FILE = path.join(CONFIG_DIR, 'distill-state.json');
638
+ const DISTILL_COOLDOWN_MS = 30 * 60 * 1000; // don't distill more than once per 30 min
639
+ const DISTILL_MIN_NEW_TURNS = 10; // need 10 new turns before bothering
640
+ const DISTILL_BATCH_SIZE = 30; // turns per distillation pass
641
+ function loadDistillState() {
642
+ try {
643
+ return JSON.parse(fs.readFileSync(DISTILL_STATE_FILE, 'utf8'));
644
+ }
645
+ catch {
646
+ return { threads: {} };
647
+ }
648
+ }
649
+ function saveDistillState(s) {
650
+ if (!fs.existsSync(CONFIG_DIR))
651
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
652
+ fs.writeFileSync(DISTILL_STATE_FILE, JSON.stringify(s, null, 2));
653
+ }
654
+ 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.
655
+
656
+ 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.
657
+
658
+ Format:
659
+ [{"kind":"fact|decision|snippet|preference","content":"...","tags":["short","keywords"]}, ...]
660
+
661
+ Capture:
662
+ - fact — anything concrete: file paths, endpoint URLs, commands, version
663
+ numbers, architecture, deployed services, schemas, dependencies,
664
+ bugs found, fixes shipped.
665
+ - decision — choices with rationale.
666
+ - snippet — reusable commands/config/code/CLI flags/env var names.
667
+ - preference — how the developer wants the AI to behave on this project.
668
+
669
+ Skip pleasantries and questions still being explored. One atomic idea per
670
+ memory — don't bundle. If truly nothing durable was discussed, output: [].
671
+ Output the JSON array and nothing else — no prose, no markdown fences.
672
+
673
+ Conversation:
674
+ `;
675
+ async function cmdDistill(args) {
676
+ if (hasFlag(args, ['--review']))
677
+ return distillReview(args);
678
+ if (hasFlag(args, ['--clear']))
679
+ return distillClear(args);
680
+ const cfg = loadConfig();
681
+ const stitch = client(cfg);
682
+ const explicitThread = parseFlag(args, ['--thread', '-t']);
683
+ const thread = explicitThread || inferThread();
684
+ const dryRun = hasFlag(args, ['--dry-run']);
685
+ const batchSize = Number(parseFlag(args, ['--n']) || String(DISTILL_BATCH_SIZE));
686
+ console.log(`Distilling last ${batchSize} turns of "${thread}"…`);
687
+ // Pull recent turns from the thread.
688
+ const recall = await stitch.thread(thread).recall({ last: batchSize });
689
+ if (!recall.thread_id || recall.recent.length === 0) {
690
+ console.log('No turns to distill yet.');
691
+ return;
692
+ }
693
+ // Format conversation as plain text for the prompt.
694
+ const conversation = recall.recent
695
+ .map((t) => `[${t.role}] ${String(t.content || '').trim()}`)
696
+ .join('\n\n');
697
+ if (dryRun) {
698
+ console.log(`Would distill ${recall.recent.length} turns. Sample (first 200 chars):`);
699
+ console.log(conversation.slice(0, 200));
700
+ return;
701
+ }
702
+ const claudeBin = process.env.STITCH_CLAUDE_BIN || 'claude';
703
+ process.stdout.write(` → asking ${claudeBin} -p to extract facts… `);
704
+ const fullPrompt = DISTILL_PROMPT + conversation;
705
+ // Pipe the prompt via stdin: long conversations blow past ARG_MAX when
706
+ // passed as `-p <prompt>`, and stdin avoids claude interpreting any
707
+ // prompt-internal characters as flags. `claude -p` with no value reads
708
+ // from stdin. STITCH_HOOKS_DISABLED stops our own _hook command from
709
+ // logging this nested distill conversation as project thread turns.
710
+ const result = await runWithStdin(claudeBin, ['-p'], fullPrompt, {
711
+ cwd: process.cwd(),
712
+ env: { STITCH_HOOKS_DISABLED: '1' },
713
+ });
714
+ // Debug capture: STITCH_DEBUG_DISTILL=1 writes raw stdout/stderr/prompt
715
+ // to /tmp so we can see exactly what claude actually returned.
716
+ if (process.env.STITCH_DEBUG_DISTILL === '1') {
717
+ const ts = Date.now();
718
+ const dbgDir = '/tmp';
719
+ try {
720
+ fs.writeFileSync(path.join(dbgDir, `stitch-distill-${ts}.prompt.txt`), fullPrompt);
721
+ fs.writeFileSync(path.join(dbgDir, `stitch-distill-${ts}.stdout.txt`), result.stdout);
722
+ fs.writeFileSync(path.join(dbgDir, `stitch-distill-${ts}.stderr.txt`), result.stderr);
723
+ console.log(`\n [debug] wrote /tmp/stitch-distill-${ts}.{prompt,stdout,stderr}.txt`);
724
+ }
725
+ catch { /* ignore */ }
726
+ }
727
+ if (result.exit_code !== 0) {
728
+ console.log('failed');
729
+ console.error(result.stderr.trim().slice(0, 400));
730
+ process.exit(1);
731
+ }
732
+ const memories = parseDistillationOutput(result.stdout);
733
+ console.log(`extracted ${memories.length} memories`);
734
+ if (memories.length === 0) {
735
+ if (process.env.STITCH_DEBUG_DISTILL === '1') {
736
+ console.error(' [debug] claude stdout (first 600 chars):');
737
+ console.error(' ' + result.stdout.slice(0, 600).replace(/\n/g, '\n '));
738
+ }
739
+ bumpDistillCooldown(thread);
740
+ return;
741
+ }
742
+ // Push each to Stitch as a memory with auto:true tag.
743
+ const projectTag = thread.split('/')[0] || thread;
744
+ let saved = 0;
745
+ for (const m of memories) {
746
+ try {
747
+ const tags = ['auto', 'auto:distill', `thread:${thread}`, `project:${projectTag}`, ...(m.tags || [])];
748
+ await stitch.remember(m.content, { kind: m.kind, tags });
749
+ saved++;
750
+ }
751
+ catch (e) {
752
+ console.error(' ! failed to save:', e.message);
753
+ }
754
+ }
755
+ console.log(` saved ${saved}/${memories.length} to Stitch`);
756
+ bumpDistillCooldown(thread);
757
+ }
758
+ function bumpDistillCooldown(thread) {
759
+ const state = loadDistillState();
760
+ state.threads[thread] = {
761
+ lastDistilledAt: Date.now(),
762
+ lastTurnAt: Date.now(),
763
+ lastTurnCount: state.threads[thread]?.lastTurnCount ?? 0,
764
+ };
765
+ saveDistillState(state);
766
+ }
767
+ function parseDistillationOutput(stdout) {
768
+ // claude -p often wraps the array in markdown fences, prepends prose like
769
+ // "Here are the extracted memories:", or follows it with a sign-off line.
770
+ // Strategy: try several extraction modes from most-precise to most-lenient,
771
+ // returning the first one that parses to a non-empty valid array.
772
+ const text = stdout.trim();
773
+ if (!text)
774
+ return [];
775
+ const candidates = [];
776
+ // 1. Fenced ```json block
777
+ const fenced = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/i);
778
+ if (fenced)
779
+ candidates.push(fenced[1].trim());
780
+ // 2. The first `[ {` … last `} ]` window (skips prose-level stray brackets).
781
+ const objStart = text.indexOf('[');
782
+ const objEnd = text.lastIndexOf(']');
783
+ if (objStart !== -1 && objEnd > objStart) {
784
+ candidates.push(text.slice(objStart, objEnd + 1));
785
+ }
786
+ // 3. Largest `[\n{ ... }\n]` block via balanced scan from each `[`
787
+ for (let i = 0; i < text.length; i++) {
788
+ if (text[i] !== '[')
789
+ continue;
790
+ let depth = 0;
791
+ let inStr = false;
792
+ let esc = false;
793
+ for (let j = i; j < text.length; j++) {
794
+ const ch = text[j];
795
+ if (esc) {
796
+ esc = false;
797
+ continue;
798
+ }
799
+ if (ch === '\\' && inStr) {
800
+ esc = true;
801
+ continue;
802
+ }
803
+ if (ch === '"') {
804
+ inStr = !inStr;
805
+ continue;
806
+ }
807
+ if (inStr)
808
+ continue;
809
+ if (ch === '[')
810
+ depth++;
811
+ else if (ch === ']') {
812
+ depth--;
813
+ if (depth === 0) {
814
+ candidates.push(text.slice(i, j + 1));
815
+ break;
816
+ }
817
+ }
818
+ }
819
+ }
820
+ const seen = new Set();
821
+ for (const cand of candidates) {
822
+ if (seen.has(cand))
823
+ continue;
824
+ seen.add(cand);
825
+ try {
826
+ const parsed = JSON.parse(cand);
827
+ if (!Array.isArray(parsed))
828
+ continue;
829
+ const valid = parsed.filter((m) => m && typeof m === 'object'
830
+ && typeof m.content === 'string' && m.content.length > 0
831
+ && typeof m.kind === 'string' && ['fact', 'decision', 'snippet', 'preference', 'note'].includes(m.kind));
832
+ if (valid.length > 0)
833
+ return valid;
834
+ }
835
+ catch { /* try next candidate */ }
836
+ }
837
+ return [];
838
+ }
839
+ async function distillReview(args) {
840
+ const cfg = loadConfig();
841
+ const stitch = client(cfg);
842
+ const limit = Number(parseFlag(args, ['--limit']) || '20');
843
+ const all = await stitch.list({ limit: 200 });
844
+ const autos = all.filter((m) => m.tags.includes('auto'));
845
+ console.log(`${autos.length} auto-distilled memories (showing first ${limit}):\n`);
846
+ for (const m of autos.slice(0, limit)) {
847
+ const date = new Date(m.created_at).toISOString().slice(0, 19).replace('T', ' ');
848
+ console.log(` ${m.id} ${date} [${m.kind}]`);
849
+ console.log(` ${m.content.split('\n')[0].slice(0, 200)}`);
850
+ if (m.content.length > 200 || m.content.includes('\n'))
851
+ console.log(` ...`);
852
+ console.log();
853
+ }
854
+ if (autos.length > limit)
855
+ console.log(`(${autos.length - limit} more — use --limit to see)`);
856
+ }
857
+ async function distillClear(args) {
858
+ const cfg = loadConfig();
859
+ const stitch = client(cfg);
860
+ const all = await stitch.list({ limit: 500 });
861
+ const autos = all.filter((m) => m.tags.includes('auto'));
862
+ if (autos.length === 0) {
863
+ console.log('No auto-distilled memories to clear.');
864
+ return;
865
+ }
866
+ if (!hasFlag(args, ['--yes', '-y'])) {
867
+ console.log(`Will soft-delete ${autos.length} auto-distilled memories. Re-run with --yes to confirm.`);
868
+ return;
869
+ }
870
+ let deleted = 0;
871
+ for (const m of autos) {
872
+ if (await stitch.forget(m.id))
873
+ deleted++;
874
+ }
875
+ console.log(`Cleared ${deleted} memories.`);
876
+ }
877
+ // Triggered from the Stop hook (fire-and-forget, never blocks the user).
878
+ async function maybeAutoDistill(thread) {
879
+ const state = loadDistillState();
880
+ const meta = state.threads[thread] || { lastDistilledAt: 0, lastTurnAt: 0, lastTurnCount: 0 };
881
+ // Cool-down: don't distill more than once per 30 min per thread.
882
+ if (Date.now() - meta.lastDistilledAt < DISTILL_COOLDOWN_MS)
883
+ return;
884
+ // Need at least N new turns since last pass.
885
+ const cfg = loadConfig();
886
+ const stitch = client(cfg);
887
+ let recallSize = 0;
888
+ try {
889
+ const r = await stitch.thread(thread).recall({ last: 200 });
890
+ recallSize = r.recent.length;
891
+ }
892
+ catch {
893
+ return;
894
+ }
895
+ if (recallSize - meta.lastTurnCount < DISTILL_MIN_NEW_TURNS)
896
+ return;
897
+ // Mark BEFORE running so we don't double-fire on overlapping Stop events.
898
+ state.threads[thread] = { lastDistilledAt: Date.now(), lastTurnAt: Date.now(), lastTurnCount: recallSize };
899
+ saveDistillState(state);
900
+ // Detach: spawn a background process so the Stop hook returns immediately.
901
+ // The detached child runs `stitch distill` for this thread.
902
+ try {
903
+ 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)], {
904
+ detached: true,
905
+ stdio: 'ignore',
906
+ });
907
+ child.unref();
908
+ }
909
+ catch { /* ignore */ }
910
+ }
603
911
  // ── Sync Claude's local memory dir into Stitch ────────────────────────────
604
912
  // Claude Code keeps per-project memory at ~/.claude/projects/<encoded>/memory/
605
913
  // Each file is a markdown memory with optional YAML frontmatter (name,
@@ -1156,6 +1464,22 @@ function run(cmd, args, opts = {}) {
1156
1464
  child.on('close', (code) => resolve({ stdout, stderr, exit_code: code ?? 0 }));
1157
1465
  });
1158
1466
  }
1467
+ function runWithStdin(cmd, args, stdinData, opts = {}) {
1468
+ return new Promise((resolve) => {
1469
+ const child = spawn(cmd, args, {
1470
+ cwd: opts.cwd || process.cwd(),
1471
+ env: { ...process.env, ...(opts.env || {}) },
1472
+ stdio: ['pipe', 'pipe', 'pipe'],
1473
+ });
1474
+ let stdout = '';
1475
+ let stderr = '';
1476
+ child.stdout.on('data', (d) => stdout += d.toString());
1477
+ child.stderr.on('data', (d) => stderr += d.toString());
1478
+ child.on('error', (err) => resolve({ stdout, stderr: stderr + '\n[spawn error] ' + err.message, exit_code: 127 }));
1479
+ child.on('close', (code) => resolve({ stdout, stderr, exit_code: code ?? 0 }));
1480
+ child.stdin.end(stdinData);
1481
+ });
1482
+ }
1159
1483
  function readLine() {
1160
1484
  return new Promise((resolve) => {
1161
1485
  const onData = (d) => { process.stdin.off('data', onData); resolve(d.toString().trim()); };
@@ -1178,6 +1502,24 @@ function help() {
1178
1502
  stitch whoami Show the configured key.
1179
1503
  stitch logout
1180
1504
 
1505
+ stitch link [name] Pin this project to a canonical
1506
+ thread name. Writes
1507
+ .stitch/project.json — commit it
1508
+ (or sync the file across machines)
1509
+ so every machine that pins this
1510
+ project shares the same memory.
1511
+
1512
+ stitch distill [--thread <t>] [--n 30] [--dry-run]
1513
+ Read the last N turns of a thread
1514
+ and have your local Claude Code
1515
+ extract durable facts/decisions
1516
+ into Stitch memories. Tagged
1517
+ auto so they can be reviewed.
1518
+ stitch distill --review [--limit 20] Show recent auto-distilled
1519
+ memories for inspection.
1520
+ stitch distill --clear --yes Soft-delete every auto-distilled
1521
+ memory (recoverable 30 days).
1522
+
1181
1523
  stitch sync [--watch] [--dry-run] Mirror ~/.claude/projects/*/memory/
1182
1524
  files into Stitch as memories.
1183
1525
  --watch keeps running; otherwise it's
@@ -1225,6 +1567,8 @@ async function main(argv) {
1225
1567
  case 'thread': return cmdThread(rest);
1226
1568
  case 'install': return cmdInstall(rest);
1227
1569
  case 'sync': return cmdSync(rest);
1570
+ case 'link': return cmdLink(rest);
1571
+ case 'distill': return cmdDistill(rest);
1228
1572
  case '_hook': return cmdHook(rest);
1229
1573
  case 'update':
1230
1574
  case 'upgrade': return cmdUpdate(rest);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stitchdb/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Stitch CLI — manage memory + run agents from your terminal",
5
5
  "type": "module",
6
6
  "bin": {