@stitchdb/cli 0.4.0 → 0.6.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 +350 -51
  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();
@@ -455,18 +427,62 @@ async function cmdHook(args) {
455
427
  catch {
456
428
  return;
457
429
  }
458
- if (!raw)
459
- return;
460
- let event;
461
- try {
462
- event = JSON.parse(raw);
463
- }
464
- catch {
465
- return;
430
+ let event = {};
431
+ if (raw) {
432
+ try {
433
+ event = JSON.parse(raw);
434
+ }
435
+ catch { /* keep empty */ }
466
436
  }
437
+ // hook_event_name may be missing for SessionStart on some Claude Code
438
+ // versions. Allow the caller to pass it as the first positional arg too.
467
439
  const eventName = event?.hook_event_name || args[0] || '';
468
- const cwd = event?.cwd;
440
+ // SessionStart's payload doesn't include cwd fall back to process.cwd()
441
+ // (which is the directory `claude` was launched from).
442
+ const cwd = event?.cwd || process.cwd();
469
443
  const threadName = inferThreadFor(cwd) || 'default';
444
+ // ── SessionStart: inject prior context (token-efficient) ─────────────
445
+ // Strategy: prefer distilled memories (dense facts) over raw turns. Only
446
+ // include raw turns for the last 5 to give the agent immediate continuation.
447
+ if (eventName === 'SessionStart') {
448
+ try {
449
+ const stitch = client(cfg);
450
+ const projectTag = (threadName.split('/')[0] || threadName).toLowerCase();
451
+ const [thread, memHits] = await Promise.all([
452
+ stitch.thread(threadName).recall({ last: 5 }).catch(() => ({ thread_id: '', recent: [], semantic: [] })),
453
+ stitch.recall(projectTag, { k: 8 }).catch(() => []),
454
+ ]);
455
+ const lines = [];
456
+ lines.push('<stitch-context>');
457
+ lines.push(`Project: ${threadName} · Stitch MCP server is available with tools: recall, remember, thread_recall, thread_append.`);
458
+ lines.push('');
459
+ if (Array.isArray(memHits) && memHits.length > 0) {
460
+ // Prefer distilled (auto-tagged) facts and decisions; fall back to anything else
461
+ const sortedMems = [...memHits].sort((a, b) => Number(b.score ?? 0) - Number(a.score ?? 0)).slice(0, 8);
462
+ lines.push('### Durable memories for this project');
463
+ for (const m of sortedMems) {
464
+ const isAuto = Array.isArray(m.tags) && m.tags.includes('auto');
465
+ const txt = String(m.content || '').replace(/\n+/g, ' ').slice(0, 350);
466
+ lines.push(`- **[${m.kind}${isAuto ? '·auto' : ''}]** ${txt}`);
467
+ }
468
+ lines.push('');
469
+ }
470
+ if (thread.recent && thread.recent.length > 0) {
471
+ lines.push('### Most recent turns (continue from here)');
472
+ for (const t of thread.recent.slice(-5)) {
473
+ const txt = String(t.content || '').replace(/\n+/g, ' ').slice(0, 300);
474
+ lines.push(`- **${t.role}**: ${txt}`);
475
+ }
476
+ lines.push('');
477
+ }
478
+ lines.push('Call `recall` for deeper search or `thread_recall` for older turns when needed.');
479
+ lines.push('</stitch-context>');
480
+ process.stdout.write(lines.join('\n'));
481
+ }
482
+ catch { /* silent — never break a session start */ }
483
+ return;
484
+ }
485
+ // ── UserPromptSubmit / Stop: log a turn ───────────────────────────────
470
486
  let role = null;
471
487
  let content = '';
472
488
  if (eventName === 'UserPromptSubmit') {
@@ -490,29 +506,55 @@ async function cmdHook(args) {
490
506
  catch {
491
507
  /* silent */
492
508
  }
509
+ // After Stop, opportunistically kick off a distillation pass in the
510
+ // background (fire-and-forget). Won't block the session; debouncing
511
+ // (cooldown + min-new-turns) is enforced inside maybeAutoDistill.
512
+ if (eventName === 'Stop') {
513
+ maybeAutoDistill(threadName).catch(() => { });
514
+ }
493
515
  }
516
+ /**
517
+ * Derive a thread name for the project at `cwd`. Strategy:
518
+ *
519
+ * 1. Walk up looking for a `.stitch/project.json` containing { "thread": "x" }
520
+ * — this is the authoritative cross-machine pin. Stays exactly the same
521
+ * on every machine that has the file. Created by `stitch link`.
522
+ * 2. Fall back to the folder basename (which already works fine if the user
523
+ * keeps the same folder name across their machines).
524
+ *
525
+ * Deliberately does NOT require git — works for any project structure.
526
+ */
494
527
  function inferThreadFor(cwd) {
495
528
  const dir = cwd && fs.existsSync(cwd) ? cwd : process.cwd();
496
529
  let cur = dir;
497
530
  for (let i = 0; i < 8; i++) {
498
- if (fs.existsSync(path.join(cur, '.git'))) {
499
- const repoName = path.basename(cur);
500
- let branch = 'main';
531
+ const projectFile = path.join(cur, '.stitch', 'project.json');
532
+ if (fs.existsSync(projectFile)) {
501
533
  try {
502
- const head = fs.readFileSync(path.join(cur, '.git', 'HEAD'), 'utf8').trim();
503
- const m = head.match(/^ref:\s+refs\/heads\/(.+)$/);
504
- if (m)
505
- branch = m[1];
534
+ const cfg = JSON.parse(fs.readFileSync(projectFile, 'utf8'));
535
+ if (cfg.thread && typeof cfg.thread === 'string')
536
+ return cfg.thread;
506
537
  }
507
- catch { /* detached */ }
508
- return `${repoName}/${branch}`;
538
+ catch { /* malformed — fall through */ }
509
539
  }
510
540
  const next = path.dirname(cur);
511
541
  if (next === cur)
512
542
  break;
513
543
  cur = next;
514
544
  }
515
- return path.basename(dir);
545
+ return path.basename(dir) || 'default';
546
+ }
547
+ function findProjectRoot(cwd) {
548
+ let cur = cwd;
549
+ for (let i = 0; i < 8; i++) {
550
+ if (fs.existsSync(path.join(cur, '.stitch', 'project.json')))
551
+ return cur;
552
+ const next = path.dirname(cur);
553
+ if (next === cur)
554
+ break;
555
+ cur = next;
556
+ }
557
+ return null;
516
558
  }
517
559
  function lastAssistantTextFromTranscript(transcriptPath) {
518
560
  if (!transcriptPath || !fs.existsSync(transcriptPath))
@@ -557,6 +599,238 @@ function lastAssistantTextFromTranscript(transcriptPath) {
557
599
  catch { /* ignore */ }
558
600
  return text;
559
601
  }
602
+ // ── stitch link — pin a project's thread name so it's the same on every machine
603
+ async function cmdLink(args) {
604
+ const positionals = positional(args);
605
+ const explicit = positionals[0];
606
+ const name = (explicit || path.basename(process.cwd()) || 'default').trim();
607
+ const dir = path.join(process.cwd(), '.stitch');
608
+ fs.mkdirSync(dir, { recursive: true });
609
+ const file = path.join(dir, 'project.json');
610
+ let cfg = {};
611
+ try {
612
+ cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
613
+ }
614
+ catch { }
615
+ cfg.thread = name;
616
+ cfg.linked_at = new Date().toISOString();
617
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2));
618
+ console.log(`Project pinned to thread "${name}"`);
619
+ console.log(` → ${file}`);
620
+ console.log();
621
+ console.log('Commit `.stitch/project.json` to your repo (or sync the file across');
622
+ console.log('machines) so every machine pinning this project uses the same thread.');
623
+ }
624
+ // ── stitch distill — extract durable facts from recent conversation ─────────
625
+ //
626
+ // Uses the user's own `claude -p` to run a strict-JSON extraction prompt over
627
+ // the last N turns of the project's thread. Each extracted fact becomes a
628
+ // Stitch memory tagged `auto:true` so the user can review or wipe them later.
629
+ //
630
+ // Triggered manually (`stitch distill`), and automatically by the Stop hook
631
+ // when conditions are met (cooldown + new-turn threshold).
632
+ const DISTILL_STATE_FILE = path.join(CONFIG_DIR, 'distill-state.json');
633
+ const DISTILL_COOLDOWN_MS = 30 * 60 * 1000; // don't distill more than once per 30 min
634
+ const DISTILL_MIN_NEW_TURNS = 10; // need 10 new turns before bothering
635
+ const DISTILL_BATCH_SIZE = 30; // turns per distillation pass
636
+ function loadDistillState() {
637
+ try {
638
+ return JSON.parse(fs.readFileSync(DISTILL_STATE_FILE, 'utf8'));
639
+ }
640
+ catch {
641
+ return { threads: {} };
642
+ }
643
+ }
644
+ function saveDistillState(s) {
645
+ if (!fs.existsSync(CONFIG_DIR))
646
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
647
+ fs.writeFileSync(DISTILL_STATE_FILE, JSON.stringify(s, null, 2));
648
+ }
649
+ const DISTILL_PROMPT = `You are a memory distiller for an AI coding assistant.
650
+
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.
655
+
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
+ }
662
+
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.
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 result = await run(claudeBin, ['-p', DISTILL_PROMPT + conversation], { cwd: process.cwd() });
705
+ if (result.exit_code !== 0) {
706
+ console.log('failed');
707
+ console.error(result.stderr.trim().slice(0, 400));
708
+ process.exit(1);
709
+ }
710
+ const memories = parseDistillationOutput(result.stdout);
711
+ console.log(`extracted ${memories.length} memories`);
712
+ if (memories.length === 0) {
713
+ bumpDistillCooldown(thread);
714
+ return;
715
+ }
716
+ // Push each to Stitch as a memory with auto:true tag.
717
+ const projectTag = thread.split('/')[0] || thread;
718
+ let saved = 0;
719
+ for (const m of memories) {
720
+ try {
721
+ const tags = ['auto', 'auto:distill', `thread:${thread}`, `project:${projectTag}`, ...(m.tags || [])];
722
+ await stitch.remember(m.content, { kind: m.kind, tags });
723
+ saved++;
724
+ }
725
+ catch (e) {
726
+ console.error(' ! failed to save:', e.message);
727
+ }
728
+ }
729
+ console.log(` saved ${saved}/${memories.length} to Stitch`);
730
+ bumpDistillCooldown(thread);
731
+ }
732
+ function bumpDistillCooldown(thread) {
733
+ const state = loadDistillState();
734
+ state.threads[thread] = {
735
+ lastDistilledAt: Date.now(),
736
+ lastTurnAt: Date.now(),
737
+ lastTurnCount: state.threads[thread]?.lastTurnCount ?? 0,
738
+ };
739
+ saveDistillState(state);
740
+ }
741
+ 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.
744
+ const text = stdout.trim();
745
+ const start = text.indexOf('[');
746
+ const end = text.lastIndexOf(']');
747
+ if (start === -1 || end === -1 || end < start)
748
+ 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));
757
+ }
758
+ catch {
759
+ return [];
760
+ }
761
+ }
762
+ async function distillReview(args) {
763
+ const cfg = loadConfig();
764
+ const stitch = client(cfg);
765
+ const limit = Number(parseFlag(args, ['--limit']) || '20');
766
+ const all = await stitch.list({ limit: 200 });
767
+ const autos = all.filter((m) => m.tags.includes('auto'));
768
+ console.log(`${autos.length} auto-distilled memories (showing first ${limit}):\n`);
769
+ for (const m of autos.slice(0, limit)) {
770
+ const date = new Date(m.created_at).toISOString().slice(0, 19).replace('T', ' ');
771
+ console.log(` ${m.id} ${date} [${m.kind}]`);
772
+ console.log(` ${m.content.split('\n')[0].slice(0, 200)}`);
773
+ if (m.content.length > 200 || m.content.includes('\n'))
774
+ console.log(` ...`);
775
+ console.log();
776
+ }
777
+ if (autos.length > limit)
778
+ console.log(`(${autos.length - limit} more — use --limit to see)`);
779
+ }
780
+ async function distillClear(args) {
781
+ const cfg = loadConfig();
782
+ const stitch = client(cfg);
783
+ const all = await stitch.list({ limit: 500 });
784
+ const autos = all.filter((m) => m.tags.includes('auto'));
785
+ if (autos.length === 0) {
786
+ console.log('No auto-distilled memories to clear.');
787
+ return;
788
+ }
789
+ if (!hasFlag(args, ['--yes', '-y'])) {
790
+ console.log(`Will soft-delete ${autos.length} auto-distilled memories. Re-run with --yes to confirm.`);
791
+ return;
792
+ }
793
+ let deleted = 0;
794
+ for (const m of autos) {
795
+ if (await stitch.forget(m.id))
796
+ deleted++;
797
+ }
798
+ console.log(`Cleared ${deleted} memories.`);
799
+ }
800
+ // Triggered from the Stop hook (fire-and-forget, never blocks the user).
801
+ async function maybeAutoDistill(thread) {
802
+ const state = loadDistillState();
803
+ const meta = state.threads[thread] || { lastDistilledAt: 0, lastTurnAt: 0, lastTurnCount: 0 };
804
+ // Cool-down: don't distill more than once per 30 min per thread.
805
+ if (Date.now() - meta.lastDistilledAt < DISTILL_COOLDOWN_MS)
806
+ return;
807
+ // Need at least N new turns since last pass.
808
+ const cfg = loadConfig();
809
+ const stitch = client(cfg);
810
+ let recallSize = 0;
811
+ try {
812
+ const r = await stitch.thread(thread).recall({ last: 200 });
813
+ recallSize = r.recent.length;
814
+ }
815
+ catch {
816
+ return;
817
+ }
818
+ if (recallSize - meta.lastTurnCount < DISTILL_MIN_NEW_TURNS)
819
+ return;
820
+ // Mark BEFORE running so we don't double-fire on overlapping Stop events.
821
+ state.threads[thread] = { lastDistilledAt: Date.now(), lastTurnAt: Date.now(), lastTurnCount: recallSize };
822
+ saveDistillState(state);
823
+ // Detach: spawn a background process so the Stop hook returns immediately.
824
+ // The detached child runs `stitch distill` for this thread.
825
+ try {
826
+ 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)], {
827
+ detached: true,
828
+ stdio: 'ignore',
829
+ });
830
+ child.unref();
831
+ }
832
+ catch { /* ignore */ }
833
+ }
560
834
  // ── Sync Claude's local memory dir into Stitch ────────────────────────────
561
835
  // Claude Code keeps per-project memory at ~/.claude/projects/<encoded>/memory/
562
836
  // Each file is a markdown memory with optional YAML frontmatter (name,
@@ -846,15 +1120,16 @@ async function cmdInstall(args) {
846
1120
  else
847
1121
  console.log(`failed (${stderr.trim().slice(0, 120)})`);
848
1122
  }
849
- // 2. Hooks for auto-logging conversations
1123
+ // 2. Hooks: auto-log every turn + auto-inject prior context at session start
850
1124
  if (!noHooks) {
851
- process.stdout.write('• Wiring auto-log hooks (UserPromptSubmit + Stop)… ');
1125
+ process.stdout.write('• Wiring hooks (SessionStart + UserPromptSubmit + Stop)… ');
852
1126
  try {
853
1127
  const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
854
1128
  const existing = fs.existsSync(settingsPath)
855
1129
  ? JSON.parse(fs.readFileSync(settingsPath, 'utf8'))
856
1130
  : {};
857
1131
  existing.hooks = existing.hooks || {};
1132
+ existing.hooks.SessionStart = mergeHook(existing.hooks.SessionStart, STITCH_SESSION_START_HOOK);
858
1133
  existing.hooks.UserPromptSubmit = mergeHook(existing.hooks.UserPromptSubmit, STITCH_USER_HOOK);
859
1134
  existing.hooks.Stop = mergeHook(existing.hooks.Stop, STITCH_STOP_HOOK);
860
1135
  fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
@@ -957,6 +1232,10 @@ const STITCH_STOP_HOOK = {
957
1232
  matcher: '*',
958
1233
  hooks: [{ type: 'command', command: 'stitch _hook' }],
959
1234
  };
1235
+ const STITCH_SESSION_START_HOOK = {
1236
+ matcher: '*',
1237
+ hooks: [{ type: 'command', command: 'stitch _hook SessionStart' }],
1238
+ };
960
1239
  function mergeHook(existing, entry) {
961
1240
  const arr = Array.isArray(existing) ? existing.slice() : [];
962
1241
  // Replace any earlier Stitch entry; identify by the marker.
@@ -1130,6 +1409,24 @@ function help() {
1130
1409
  stitch whoami Show the configured key.
1131
1410
  stitch logout
1132
1411
 
1412
+ stitch link [name] Pin this project to a canonical
1413
+ thread name. Writes
1414
+ .stitch/project.json — commit it
1415
+ (or sync the file across machines)
1416
+ so every machine that pins this
1417
+ project shares the same memory.
1418
+
1419
+ stitch distill [--thread <t>] [--n 30] [--dry-run]
1420
+ Read the last N turns of a thread
1421
+ and have your local Claude Code
1422
+ extract durable facts/decisions
1423
+ into Stitch memories. Tagged
1424
+ auto so they can be reviewed.
1425
+ stitch distill --review [--limit 20] Show recent auto-distilled
1426
+ memories for inspection.
1427
+ stitch distill --clear --yes Soft-delete every auto-distilled
1428
+ memory (recoverable 30 days).
1429
+
1133
1430
  stitch sync [--watch] [--dry-run] Mirror ~/.claude/projects/*/memory/
1134
1431
  files into Stitch as memories.
1135
1432
  --watch keeps running; otherwise it's
@@ -1177,6 +1474,8 @@ async function main(argv) {
1177
1474
  case 'thread': return cmdThread(rest);
1178
1475
  case 'install': return cmdInstall(rest);
1179
1476
  case 'sync': return cmdSync(rest);
1477
+ case 'link': return cmdLink(rest);
1478
+ case 'distill': return cmdDistill(rest);
1180
1479
  case '_hook': return cmdHook(rest);
1181
1480
  case 'update':
1182
1481
  case 'upgrade': return cmdUpdate(rest);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stitchdb/cli",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Stitch CLI — manage memory + run agents from your terminal",
5
5
  "type": "module",
6
6
  "bin": {