@stitchdb/cli 0.3.2 → 0.4.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 +238 -0
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -557,6 +557,187 @@ function lastAssistantTextFromTranscript(transcriptPath) {
557
557
  catch { /* ignore */ }
558
558
  return text;
559
559
  }
560
+ // ── Sync Claude's local memory dir into Stitch ────────────────────────────
561
+ // Claude Code keeps per-project memory at ~/.claude/projects/<encoded>/memory/
562
+ // Each file is a markdown memory with optional YAML frontmatter (name,
563
+ // description, type). When Stitch is set up, mirror those files as Stitch
564
+ // memories so the same context surfaces via recall + the dashboard.
565
+ const SYNC_STATE_FILE = path.join(CONFIG_DIR, 'sync-state.json');
566
+ function loadSyncState() {
567
+ try {
568
+ return JSON.parse(fs.readFileSync(SYNC_STATE_FILE, 'utf8'));
569
+ }
570
+ catch {
571
+ return { files: {} };
572
+ }
573
+ }
574
+ function saveSyncState(s) {
575
+ if (!fs.existsSync(CONFIG_DIR))
576
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
577
+ fs.writeFileSync(SYNC_STATE_FILE, JSON.stringify(s, null, 2));
578
+ }
579
+ const KIND_FROM_TYPE = {
580
+ feedback: 'preference',
581
+ user: 'fact',
582
+ project: 'note',
583
+ reference: 'fact',
584
+ };
585
+ function parseMemoryFile(text) {
586
+ const fields = {};
587
+ const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
588
+ if (!m)
589
+ return { fields, body: text.trim() };
590
+ for (const line of m[1].split('\n')) {
591
+ const kv = line.match(/^([\w-]+):\s*(.+)$/);
592
+ if (kv)
593
+ fields[kv[1].toLowerCase()] = kv[2].trim();
594
+ }
595
+ return { fields, body: m[2].trim() };
596
+ }
597
+ async function hashContent(s) {
598
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
599
+ return [...new Uint8Array(buf)].slice(0, 8).map((b) => b.toString(16).padStart(2, '0')).join('');
600
+ }
601
+ function listMemoryFiles() {
602
+ const root = path.join(os.homedir(), '.claude', 'projects');
603
+ if (!fs.existsSync(root))
604
+ return [];
605
+ const out = [];
606
+ for (const dir of fs.readdirSync(root)) {
607
+ const memDir = path.join(root, dir, 'memory');
608
+ if (!fs.existsSync(memDir))
609
+ continue;
610
+ for (const f of fs.readdirSync(memDir)) {
611
+ if (!f.endsWith('.md'))
612
+ continue;
613
+ if (f === 'MEMORY.md')
614
+ continue; // index, not content
615
+ out.push(path.join(memDir, f));
616
+ }
617
+ }
618
+ return out;
619
+ }
620
+ // Convert Claude's encoded path "-Users-x-Desktop-Projects-StitchDB" → "StitchDB" tag.
621
+ function projectTagFromPath(filePath) {
622
+ const m = filePath.match(/\/projects\/([^/]+)\/memory\//);
623
+ if (!m)
624
+ return 'claude';
625
+ const encoded = m[1];
626
+ // Last hyphen-separated segment is usually the repo name
627
+ const parts = encoded.split('-').filter(Boolean);
628
+ return parts[parts.length - 1] || encoded;
629
+ }
630
+ async function cmdSync(args) {
631
+ const cfg = loadConfig();
632
+ const stitch = client(cfg);
633
+ const watch = hasFlag(args, ['--watch', '-w']);
634
+ const dryRun = hasFlag(args, ['--dry-run']);
635
+ const state = loadSyncState();
636
+ async function syncFile(file) {
637
+ try {
638
+ const stat = fs.statSync(file);
639
+ const text = fs.readFileSync(file, 'utf8');
640
+ const hash = await hashContent(text);
641
+ const prev = state.files[file];
642
+ if (prev && prev.contentHash === hash)
643
+ return 'unchanged';
644
+ const { fields, body } = parseMemoryFile(text);
645
+ const tag = projectTagFromPath(file);
646
+ const fileTag = path.basename(file, '.md');
647
+ const kind = KIND_FROM_TYPE[fields.type ?? ''] ?? 'note';
648
+ // Compose content: prefer the body; if body is empty, use description.
649
+ const content = body || fields.description || `(${fileTag} memory)`;
650
+ const tags = [tag, `claude:${fileTag}`].concat(fields.type ? [`claude-type:${fields.type}`] : []);
651
+ if (dryRun)
652
+ return prev ? 'updated' : 'created';
653
+ // We don't have an "update memory" endpoint yet — soft-delete + re-create.
654
+ if (prev?.memoryId) {
655
+ try {
656
+ await stitch.forget(prev.memoryId);
657
+ }
658
+ catch { /* memory may already be gone */ }
659
+ }
660
+ const out = await stitch.remember(content, { kind: kind, tags });
661
+ state.files[file] = { memoryId: out.id, mtimeMs: stat.mtimeMs, contentHash: hash };
662
+ saveSyncState(state);
663
+ return prev ? 'updated' : 'created';
664
+ }
665
+ catch (e) {
666
+ console.error(` ! ${file}: ${e.message || e}`);
667
+ return 'error';
668
+ }
669
+ }
670
+ async function syncAll() {
671
+ const files = listMemoryFiles();
672
+ const counts = { created: 0, updated: 0, unchanged: 0, error: 0 };
673
+ for (const f of files) {
674
+ const r = await syncFile(f);
675
+ counts[r]++;
676
+ if (r !== 'unchanged')
677
+ process.stdout.write(` ${r === 'created' ? '+' : r === 'updated' ? '~' : 'x'} ${path.relative(os.homedir(), f)}\n`);
678
+ }
679
+ return counts;
680
+ }
681
+ const initial = await syncAll();
682
+ console.log(`\n${initial.created} created · ${initial.updated} updated · ${initial.unchanged} unchanged${initial.error ? ` · ${initial.error} errors` : ''}`);
683
+ if (!watch)
684
+ return;
685
+ console.log('\nWatching ~/.claude/projects/*/memory/ for changes (Ctrl+C to stop)…');
686
+ const root = path.join(os.homedir(), '.claude', 'projects');
687
+ // Walk per-project memory dirs; fs.watch is recursive on macOS but not Linux,
688
+ // so we attach a watcher per memory dir to keep the behaviour uniform.
689
+ const watched = new Set();
690
+ function attachWatcher(memDir) {
691
+ if (watched.has(memDir))
692
+ return;
693
+ watched.add(memDir);
694
+ try {
695
+ fs.watch(memDir, { persistent: true }, async (_event, name) => {
696
+ if (!name || !String(name).endsWith('.md') || name === 'MEMORY.md')
697
+ return;
698
+ const file = path.join(memDir, String(name));
699
+ if (!fs.existsSync(file)) {
700
+ // File deleted — soft-delete from Stitch too.
701
+ const prev = state.files[file];
702
+ if (prev) {
703
+ try {
704
+ await stitch.forget(prev.memoryId);
705
+ }
706
+ catch { /* ignore */ }
707
+ delete state.files[file];
708
+ saveSyncState(state);
709
+ console.log(` - ${path.relative(os.homedir(), file)}`);
710
+ }
711
+ return;
712
+ }
713
+ const r = await syncFile(file);
714
+ if (r !== 'unchanged')
715
+ console.log(` ${r === 'created' ? '+' : r === 'updated' ? '~' : 'x'} ${path.relative(os.homedir(), file)}`);
716
+ });
717
+ }
718
+ catch { /* dir likely removed */ }
719
+ }
720
+ if (fs.existsSync(root)) {
721
+ for (const dir of fs.readdirSync(root)) {
722
+ const memDir = path.join(root, dir, 'memory');
723
+ if (fs.existsSync(memDir))
724
+ attachWatcher(memDir);
725
+ }
726
+ // Watch the root for new project dirs too.
727
+ try {
728
+ fs.watch(root, { persistent: true }, (_event, name) => {
729
+ if (!name)
730
+ return;
731
+ const memDir = path.join(root, String(name), 'memory');
732
+ if (fs.existsSync(memDir))
733
+ attachWatcher(memDir);
734
+ });
735
+ }
736
+ catch { /* ignore */ }
737
+ }
738
+ // Hold open forever.
739
+ await new Promise(() => { });
740
+ }
560
741
  function loadUpdateCache() {
561
742
  try {
562
743
  return JSON.parse(fs.readFileSync(UPDATE_CACHE, 'utf8'));
@@ -707,11 +888,62 @@ async function cmdInstall(args) {
707
888
  fs.writeFileSync(claudeMd, body);
708
889
  console.log(`• ${action === 'created' ? 'Wrote' : action === 'appended' ? 'Appended to' : 'Found'} ./CLAUDE.md (${action})`);
709
890
  }
891
+ // 4. One-shot sync of any existing Claude memory files so they show up in
892
+ // Stitch immediately. The user can rerun `stitch sync --watch` later.
893
+ if (!hasFlag(args, ['--no-sync'])) {
894
+ try {
895
+ const files = listMemoryFiles();
896
+ if (files.length > 0) {
897
+ process.stdout.write(`• Syncing ${files.length} Claude memory file(s) into Stitch… `);
898
+ const state = loadSyncState();
899
+ const stitch = client(cfg);
900
+ let created = 0, updated = 0, errors = 0;
901
+ for (const file of files) {
902
+ try {
903
+ const text = fs.readFileSync(file, 'utf8');
904
+ const hash = await hashContent(text);
905
+ const prev = state.files[file];
906
+ if (prev && prev.contentHash === hash)
907
+ continue;
908
+ const { fields, body } = parseMemoryFile(text);
909
+ const tag = projectTagFromPath(file);
910
+ const fileTag = path.basename(file, '.md');
911
+ const kind = KIND_FROM_TYPE[fields.type ?? ''] ?? 'note';
912
+ const content = body || fields.description || `(${fileTag} memory)`;
913
+ const tags = [tag, `claude:${fileTag}`].concat(fields.type ? [`claude-type:${fields.type}`] : []);
914
+ if (prev?.memoryId) {
915
+ try {
916
+ await stitch.forget(prev.memoryId);
917
+ }
918
+ catch { }
919
+ }
920
+ const out = await stitch.remember(content, { kind: kind, tags });
921
+ state.files[file] = { memoryId: out.id, mtimeMs: fs.statSync(file).mtimeMs, contentHash: hash };
922
+ if (prev)
923
+ updated++;
924
+ else
925
+ created++;
926
+ }
927
+ catch {
928
+ errors++;
929
+ }
930
+ }
931
+ saveSyncState(state);
932
+ console.log(`+${created} ~${updated}${errors ? ` ✗${errors}` : ''}`);
933
+ }
934
+ }
935
+ catch (e) {
936
+ console.log(`skipped (${e.message})`);
937
+ }
938
+ }
710
939
  console.log();
711
940
  console.log('Done. Open a fresh `claude` session — Stitch is wired.');
712
941
  console.log(' • remember/recall tools are available');
713
942
  console.log(' • every turn auto-logs to the thread for this repo');
714
943
  console.log(' • Claude auto-pulls past context at session start (per CLAUDE.md)');
944
+ console.log(' • Claude memory files are mirrored to Stitch');
945
+ console.log();
946
+ console.log('Run `stitch sync --watch` to keep them in sync as Claude updates them.');
715
947
  }
716
948
  // The hook calls a single CLI subcommand (`stitch _hook`) that reads the event
717
949
  // JSON from stdin, picks the right field (or reads transcript_path for Stop),
@@ -898,6 +1130,11 @@ function help() {
898
1130
  stitch whoami Show the configured key.
899
1131
  stitch logout
900
1132
 
1133
+ stitch sync [--watch] [--dry-run] Mirror ~/.claude/projects/*/memory/
1134
+ files into Stitch as memories.
1135
+ --watch keeps running; otherwise it's
1136
+ a one-shot.
1137
+
901
1138
  stitch update Update to the latest @stitchdb/cli.
902
1139
  stitch version Print the installed version.
903
1140
 
@@ -939,6 +1176,7 @@ async function main(argv) {
939
1176
  case 'recall': return cmdRecall(rest);
940
1177
  case 'thread': return cmdThread(rest);
941
1178
  case 'install': return cmdInstall(rest);
1179
+ case 'sync': return cmdSync(rest);
942
1180
  case '_hook': return cmdHook(rest);
943
1181
  case 'update':
944
1182
  case 'upgrade': return cmdUpdate(rest);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stitchdb/cli",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Stitch CLI — manage memory + run agents from your terminal",
5
5
  "type": "module",
6
6
  "bin": {