@stitchdb/cli 0.3.2 → 0.5.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 +297 -11
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -455,18 +455,61 @@ async function cmdHook(args) {
455
455
  catch {
456
456
  return;
457
457
  }
458
- if (!raw)
459
- return;
460
- let event;
461
- try {
462
- event = JSON.parse(raw);
463
- }
464
- catch {
465
- return;
458
+ let event = {};
459
+ if (raw) {
460
+ try {
461
+ event = JSON.parse(raw);
462
+ }
463
+ catch { /* keep empty */ }
466
464
  }
465
+ // hook_event_name may be missing for SessionStart on some Claude Code
466
+ // versions. Allow the caller to pass it as the first positional arg too.
467
467
  const eventName = event?.hook_event_name || args[0] || '';
468
- const cwd = event?.cwd;
468
+ // SessionStart's payload doesn't include cwd fall back to process.cwd()
469
+ // (which is the directory `claude` was launched from).
470
+ const cwd = event?.cwd || process.cwd();
469
471
  const threadName = inferThreadFor(cwd) || 'default';
472
+ // ── SessionStart: inject prior context as additional context ──────────
473
+ if (eventName === 'SessionStart') {
474
+ try {
475
+ 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.
479
+ 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(() => []),
482
+ ]);
483
+ 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.`);
487
+ 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}`);
493
+ }
494
+ lines.push('');
495
+ }
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}`);
501
+ }
502
+ lines.push('');
503
+ }
504
+ if (lines.length > 4) {
505
+ // Plain stdout is auto-injected as additionalContext for SessionStart.
506
+ process.stdout.write(lines.join('\n'));
507
+ }
508
+ }
509
+ catch { /* silent — never break a session start */ }
510
+ return;
511
+ }
512
+ // ── UserPromptSubmit / Stop: log a turn ───────────────────────────────
470
513
  let role = null;
471
514
  let content = '';
472
515
  if (eventName === 'UserPromptSubmit') {
@@ -557,6 +600,187 @@ function lastAssistantTextFromTranscript(transcriptPath) {
557
600
  catch { /* ignore */ }
558
601
  return text;
559
602
  }
603
+ // ── Sync Claude's local memory dir into Stitch ────────────────────────────
604
+ // Claude Code keeps per-project memory at ~/.claude/projects/<encoded>/memory/
605
+ // Each file is a markdown memory with optional YAML frontmatter (name,
606
+ // description, type). When Stitch is set up, mirror those files as Stitch
607
+ // memories so the same context surfaces via recall + the dashboard.
608
+ const SYNC_STATE_FILE = path.join(CONFIG_DIR, 'sync-state.json');
609
+ function loadSyncState() {
610
+ try {
611
+ return JSON.parse(fs.readFileSync(SYNC_STATE_FILE, 'utf8'));
612
+ }
613
+ catch {
614
+ return { files: {} };
615
+ }
616
+ }
617
+ function saveSyncState(s) {
618
+ if (!fs.existsSync(CONFIG_DIR))
619
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
620
+ fs.writeFileSync(SYNC_STATE_FILE, JSON.stringify(s, null, 2));
621
+ }
622
+ const KIND_FROM_TYPE = {
623
+ feedback: 'preference',
624
+ user: 'fact',
625
+ project: 'note',
626
+ reference: 'fact',
627
+ };
628
+ function parseMemoryFile(text) {
629
+ const fields = {};
630
+ const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
631
+ if (!m)
632
+ return { fields, body: text.trim() };
633
+ for (const line of m[1].split('\n')) {
634
+ const kv = line.match(/^([\w-]+):\s*(.+)$/);
635
+ if (kv)
636
+ fields[kv[1].toLowerCase()] = kv[2].trim();
637
+ }
638
+ return { fields, body: m[2].trim() };
639
+ }
640
+ async function hashContent(s) {
641
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
642
+ return [...new Uint8Array(buf)].slice(0, 8).map((b) => b.toString(16).padStart(2, '0')).join('');
643
+ }
644
+ function listMemoryFiles() {
645
+ const root = path.join(os.homedir(), '.claude', 'projects');
646
+ if (!fs.existsSync(root))
647
+ return [];
648
+ const out = [];
649
+ for (const dir of fs.readdirSync(root)) {
650
+ const memDir = path.join(root, dir, 'memory');
651
+ if (!fs.existsSync(memDir))
652
+ continue;
653
+ for (const f of fs.readdirSync(memDir)) {
654
+ if (!f.endsWith('.md'))
655
+ continue;
656
+ if (f === 'MEMORY.md')
657
+ continue; // index, not content
658
+ out.push(path.join(memDir, f));
659
+ }
660
+ }
661
+ return out;
662
+ }
663
+ // Convert Claude's encoded path "-Users-x-Desktop-Projects-StitchDB" → "StitchDB" tag.
664
+ function projectTagFromPath(filePath) {
665
+ const m = filePath.match(/\/projects\/([^/]+)\/memory\//);
666
+ if (!m)
667
+ return 'claude';
668
+ const encoded = m[1];
669
+ // Last hyphen-separated segment is usually the repo name
670
+ const parts = encoded.split('-').filter(Boolean);
671
+ return parts[parts.length - 1] || encoded;
672
+ }
673
+ async function cmdSync(args) {
674
+ const cfg = loadConfig();
675
+ const stitch = client(cfg);
676
+ const watch = hasFlag(args, ['--watch', '-w']);
677
+ const dryRun = hasFlag(args, ['--dry-run']);
678
+ const state = loadSyncState();
679
+ async function syncFile(file) {
680
+ try {
681
+ const stat = fs.statSync(file);
682
+ const text = fs.readFileSync(file, 'utf8');
683
+ const hash = await hashContent(text);
684
+ const prev = state.files[file];
685
+ if (prev && prev.contentHash === hash)
686
+ return 'unchanged';
687
+ const { fields, body } = parseMemoryFile(text);
688
+ const tag = projectTagFromPath(file);
689
+ const fileTag = path.basename(file, '.md');
690
+ const kind = KIND_FROM_TYPE[fields.type ?? ''] ?? 'note';
691
+ // Compose content: prefer the body; if body is empty, use description.
692
+ const content = body || fields.description || `(${fileTag} memory)`;
693
+ const tags = [tag, `claude:${fileTag}`].concat(fields.type ? [`claude-type:${fields.type}`] : []);
694
+ if (dryRun)
695
+ return prev ? 'updated' : 'created';
696
+ // We don't have an "update memory" endpoint yet — soft-delete + re-create.
697
+ if (prev?.memoryId) {
698
+ try {
699
+ await stitch.forget(prev.memoryId);
700
+ }
701
+ catch { /* memory may already be gone */ }
702
+ }
703
+ const out = await stitch.remember(content, { kind: kind, tags });
704
+ state.files[file] = { memoryId: out.id, mtimeMs: stat.mtimeMs, contentHash: hash };
705
+ saveSyncState(state);
706
+ return prev ? 'updated' : 'created';
707
+ }
708
+ catch (e) {
709
+ console.error(` ! ${file}: ${e.message || e}`);
710
+ return 'error';
711
+ }
712
+ }
713
+ async function syncAll() {
714
+ const files = listMemoryFiles();
715
+ const counts = { created: 0, updated: 0, unchanged: 0, error: 0 };
716
+ for (const f of files) {
717
+ const r = await syncFile(f);
718
+ counts[r]++;
719
+ if (r !== 'unchanged')
720
+ process.stdout.write(` ${r === 'created' ? '+' : r === 'updated' ? '~' : 'x'} ${path.relative(os.homedir(), f)}\n`);
721
+ }
722
+ return counts;
723
+ }
724
+ const initial = await syncAll();
725
+ console.log(`\n${initial.created} created · ${initial.updated} updated · ${initial.unchanged} unchanged${initial.error ? ` · ${initial.error} errors` : ''}`);
726
+ if (!watch)
727
+ return;
728
+ console.log('\nWatching ~/.claude/projects/*/memory/ for changes (Ctrl+C to stop)…');
729
+ const root = path.join(os.homedir(), '.claude', 'projects');
730
+ // Walk per-project memory dirs; fs.watch is recursive on macOS but not Linux,
731
+ // so we attach a watcher per memory dir to keep the behaviour uniform.
732
+ const watched = new Set();
733
+ function attachWatcher(memDir) {
734
+ if (watched.has(memDir))
735
+ return;
736
+ watched.add(memDir);
737
+ try {
738
+ fs.watch(memDir, { persistent: true }, async (_event, name) => {
739
+ if (!name || !String(name).endsWith('.md') || name === 'MEMORY.md')
740
+ return;
741
+ const file = path.join(memDir, String(name));
742
+ if (!fs.existsSync(file)) {
743
+ // File deleted — soft-delete from Stitch too.
744
+ const prev = state.files[file];
745
+ if (prev) {
746
+ try {
747
+ await stitch.forget(prev.memoryId);
748
+ }
749
+ catch { /* ignore */ }
750
+ delete state.files[file];
751
+ saveSyncState(state);
752
+ console.log(` - ${path.relative(os.homedir(), file)}`);
753
+ }
754
+ return;
755
+ }
756
+ const r = await syncFile(file);
757
+ if (r !== 'unchanged')
758
+ console.log(` ${r === 'created' ? '+' : r === 'updated' ? '~' : 'x'} ${path.relative(os.homedir(), file)}`);
759
+ });
760
+ }
761
+ catch { /* dir likely removed */ }
762
+ }
763
+ if (fs.existsSync(root)) {
764
+ for (const dir of fs.readdirSync(root)) {
765
+ const memDir = path.join(root, dir, 'memory');
766
+ if (fs.existsSync(memDir))
767
+ attachWatcher(memDir);
768
+ }
769
+ // Watch the root for new project dirs too.
770
+ try {
771
+ fs.watch(root, { persistent: true }, (_event, name) => {
772
+ if (!name)
773
+ return;
774
+ const memDir = path.join(root, String(name), 'memory');
775
+ if (fs.existsSync(memDir))
776
+ attachWatcher(memDir);
777
+ });
778
+ }
779
+ catch { /* ignore */ }
780
+ }
781
+ // Hold open forever.
782
+ await new Promise(() => { });
783
+ }
560
784
  function loadUpdateCache() {
561
785
  try {
562
786
  return JSON.parse(fs.readFileSync(UPDATE_CACHE, 'utf8'));
@@ -665,15 +889,16 @@ async function cmdInstall(args) {
665
889
  else
666
890
  console.log(`failed (${stderr.trim().slice(0, 120)})`);
667
891
  }
668
- // 2. Hooks for auto-logging conversations
892
+ // 2. Hooks: auto-log every turn + auto-inject prior context at session start
669
893
  if (!noHooks) {
670
- process.stdout.write('• Wiring auto-log hooks (UserPromptSubmit + Stop)… ');
894
+ process.stdout.write('• Wiring hooks (SessionStart + UserPromptSubmit + Stop)… ');
671
895
  try {
672
896
  const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
673
897
  const existing = fs.existsSync(settingsPath)
674
898
  ? JSON.parse(fs.readFileSync(settingsPath, 'utf8'))
675
899
  : {};
676
900
  existing.hooks = existing.hooks || {};
901
+ existing.hooks.SessionStart = mergeHook(existing.hooks.SessionStart, STITCH_SESSION_START_HOOK);
677
902
  existing.hooks.UserPromptSubmit = mergeHook(existing.hooks.UserPromptSubmit, STITCH_USER_HOOK);
678
903
  existing.hooks.Stop = mergeHook(existing.hooks.Stop, STITCH_STOP_HOOK);
679
904
  fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
@@ -707,11 +932,62 @@ async function cmdInstall(args) {
707
932
  fs.writeFileSync(claudeMd, body);
708
933
  console.log(`• ${action === 'created' ? 'Wrote' : action === 'appended' ? 'Appended to' : 'Found'} ./CLAUDE.md (${action})`);
709
934
  }
935
+ // 4. One-shot sync of any existing Claude memory files so they show up in
936
+ // Stitch immediately. The user can rerun `stitch sync --watch` later.
937
+ if (!hasFlag(args, ['--no-sync'])) {
938
+ try {
939
+ const files = listMemoryFiles();
940
+ if (files.length > 0) {
941
+ process.stdout.write(`• Syncing ${files.length} Claude memory file(s) into Stitch… `);
942
+ const state = loadSyncState();
943
+ const stitch = client(cfg);
944
+ let created = 0, updated = 0, errors = 0;
945
+ for (const file of files) {
946
+ try {
947
+ const text = fs.readFileSync(file, 'utf8');
948
+ const hash = await hashContent(text);
949
+ const prev = state.files[file];
950
+ if (prev && prev.contentHash === hash)
951
+ continue;
952
+ const { fields, body } = parseMemoryFile(text);
953
+ const tag = projectTagFromPath(file);
954
+ const fileTag = path.basename(file, '.md');
955
+ const kind = KIND_FROM_TYPE[fields.type ?? ''] ?? 'note';
956
+ const content = body || fields.description || `(${fileTag} memory)`;
957
+ const tags = [tag, `claude:${fileTag}`].concat(fields.type ? [`claude-type:${fields.type}`] : []);
958
+ if (prev?.memoryId) {
959
+ try {
960
+ await stitch.forget(prev.memoryId);
961
+ }
962
+ catch { }
963
+ }
964
+ const out = await stitch.remember(content, { kind: kind, tags });
965
+ state.files[file] = { memoryId: out.id, mtimeMs: fs.statSync(file).mtimeMs, contentHash: hash };
966
+ if (prev)
967
+ updated++;
968
+ else
969
+ created++;
970
+ }
971
+ catch {
972
+ errors++;
973
+ }
974
+ }
975
+ saveSyncState(state);
976
+ console.log(`+${created} ~${updated}${errors ? ` ✗${errors}` : ''}`);
977
+ }
978
+ }
979
+ catch (e) {
980
+ console.log(`skipped (${e.message})`);
981
+ }
982
+ }
710
983
  console.log();
711
984
  console.log('Done. Open a fresh `claude` session — Stitch is wired.');
712
985
  console.log(' • remember/recall tools are available');
713
986
  console.log(' • every turn auto-logs to the thread for this repo');
714
987
  console.log(' • Claude auto-pulls past context at session start (per CLAUDE.md)');
988
+ console.log(' • Claude memory files are mirrored to Stitch');
989
+ console.log();
990
+ console.log('Run `stitch sync --watch` to keep them in sync as Claude updates them.');
715
991
  }
716
992
  // The hook calls a single CLI subcommand (`stitch _hook`) that reads the event
717
993
  // JSON from stdin, picks the right field (or reads transcript_path for Stop),
@@ -725,6 +1001,10 @@ const STITCH_STOP_HOOK = {
725
1001
  matcher: '*',
726
1002
  hooks: [{ type: 'command', command: 'stitch _hook' }],
727
1003
  };
1004
+ const STITCH_SESSION_START_HOOK = {
1005
+ matcher: '*',
1006
+ hooks: [{ type: 'command', command: 'stitch _hook SessionStart' }],
1007
+ };
728
1008
  function mergeHook(existing, entry) {
729
1009
  const arr = Array.isArray(existing) ? existing.slice() : [];
730
1010
  // Replace any earlier Stitch entry; identify by the marker.
@@ -898,6 +1178,11 @@ function help() {
898
1178
  stitch whoami Show the configured key.
899
1179
  stitch logout
900
1180
 
1181
+ stitch sync [--watch] [--dry-run] Mirror ~/.claude/projects/*/memory/
1182
+ files into Stitch as memories.
1183
+ --watch keeps running; otherwise it's
1184
+ a one-shot.
1185
+
901
1186
  stitch update Update to the latest @stitchdb/cli.
902
1187
  stitch version Print the installed version.
903
1188
 
@@ -939,6 +1224,7 @@ async function main(argv) {
939
1224
  case 'recall': return cmdRecall(rest);
940
1225
  case 'thread': return cmdThread(rest);
941
1226
  case 'install': return cmdInstall(rest);
1227
+ case 'sync': return cmdSync(rest);
942
1228
  case '_hook': return cmdHook(rest);
943
1229
  case 'update':
944
1230
  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.5.0",
4
4
  "description": "Stitch CLI — manage memory + run agents from your terminal",
5
5
  "type": "module",
6
6
  "bin": {