@stitchdb/cli 0.3.1 → 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 +364 -8
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -440,6 +440,304 @@ function readStdinAll() {
440
440
  process.stdin.on('end', () => resolve(data.trim()));
441
441
  });
442
442
  }
443
+ // ── Claude Code hook handler ──────────────────────────────────────────────
444
+ // Reads hook event JSON from stdin, figures out what to log, posts to Stitch.
445
+ // Designed to never fail loudly — Claude Code shouldn't be interrupted because
446
+ // our memory layer hiccupped.
447
+ async function cmdHook(args) {
448
+ const cfg = loadConfig();
449
+ if (!cfg.apiKey)
450
+ return; // not logged in, silently skip
451
+ let raw = '';
452
+ try {
453
+ raw = await readStdinAll();
454
+ }
455
+ catch {
456
+ return;
457
+ }
458
+ if (!raw)
459
+ return;
460
+ let event;
461
+ try {
462
+ event = JSON.parse(raw);
463
+ }
464
+ catch {
465
+ return;
466
+ }
467
+ const eventName = event?.hook_event_name || args[0] || '';
468
+ const cwd = event?.cwd;
469
+ const threadName = inferThreadFor(cwd) || 'default';
470
+ let role = null;
471
+ let content = '';
472
+ if (eventName === 'UserPromptSubmit') {
473
+ role = 'user';
474
+ content = String(event?.prompt || '');
475
+ }
476
+ else if (eventName === 'Stop') {
477
+ role = 'assistant';
478
+ content = lastAssistantTextFromTranscript(event?.transcript_path);
479
+ }
480
+ else {
481
+ return;
482
+ }
483
+ content = content.trim();
484
+ if (!content)
485
+ return;
486
+ try {
487
+ const stitch = client(cfg);
488
+ await stitch.thread(threadName).append({ role, content });
489
+ }
490
+ catch {
491
+ /* silent */
492
+ }
493
+ }
494
+ function inferThreadFor(cwd) {
495
+ const dir = cwd && fs.existsSync(cwd) ? cwd : process.cwd();
496
+ let cur = dir;
497
+ 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';
501
+ 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];
506
+ }
507
+ catch { /* detached */ }
508
+ return `${repoName}/${branch}`;
509
+ }
510
+ const next = path.dirname(cur);
511
+ if (next === cur)
512
+ break;
513
+ cur = next;
514
+ }
515
+ return path.basename(dir);
516
+ }
517
+ function lastAssistantTextFromTranscript(transcriptPath) {
518
+ if (!transcriptPath || !fs.existsSync(transcriptPath))
519
+ return '';
520
+ let text = '';
521
+ try {
522
+ const raw = fs.readFileSync(transcriptPath, 'utf8');
523
+ const lines = raw.split('\n').filter((l) => l.trim());
524
+ // Walk from the end, find the last entry whose role is 'assistant'.
525
+ for (let i = lines.length - 1; i >= 0; i--) {
526
+ let entry;
527
+ try {
528
+ entry = JSON.parse(lines[i]);
529
+ }
530
+ catch {
531
+ continue;
532
+ }
533
+ const msg = entry?.message ?? entry;
534
+ const role = msg?.role ?? entry?.role ?? entry?.type;
535
+ if (role !== 'assistant')
536
+ continue;
537
+ const content = msg?.content ?? msg?.text ?? entry?.content;
538
+ if (typeof content === 'string') {
539
+ text = content;
540
+ }
541
+ else if (Array.isArray(content)) {
542
+ const parts = [];
543
+ for (const c of content) {
544
+ if (typeof c === 'string')
545
+ parts.push(c);
546
+ else if (c?.type === 'text' && typeof c.text === 'string')
547
+ parts.push(c.text);
548
+ else if (typeof c?.text === 'string')
549
+ parts.push(c.text);
550
+ }
551
+ text = parts.join('\n').trim();
552
+ }
553
+ if (text)
554
+ break;
555
+ }
556
+ }
557
+ catch { /* ignore */ }
558
+ return text;
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
+ }
443
741
  function loadUpdateCache() {
444
742
  try {
445
743
  return JSON.parse(fs.readFileSync(UPDATE_CACHE, 'utf8'));
@@ -590,28 +888,79 @@ async function cmdInstall(args) {
590
888
  fs.writeFileSync(claudeMd, body);
591
889
  console.log(`• ${action === 'created' ? 'Wrote' : action === 'appended' ? 'Appended to' : 'Found'} ./CLAUDE.md (${action})`);
592
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
+ }
593
939
  console.log();
594
940
  console.log('Done. Open a fresh `claude` session — Stitch is wired.');
595
941
  console.log(' • remember/recall tools are available');
596
942
  console.log(' • every turn auto-logs to the thread for this repo');
597
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.');
598
947
  }
948
+ // The hook calls a single CLI subcommand (`stitch _hook`) that reads the event
949
+ // JSON from stdin, picks the right field (or reads transcript_path for Stop),
950
+ // and posts to the right per-repo thread. Keeps user settings.json minimal and
951
+ // lets us evolve the parsing without touching their config later.
599
952
  const STITCH_USER_HOOK = {
600
953
  matcher: '*',
601
- hooks: [
602
- { type: 'command', command: `bash -c 'jq -r .prompt | stitch thread append --role user --thread "$(stitch thread current)" 2>/dev/null || true'` },
603
- ],
954
+ hooks: [{ type: 'command', command: 'stitch _hook' }],
604
955
  };
605
956
  const STITCH_STOP_HOOK = {
606
957
  matcher: '*',
607
- hooks: [
608
- { type: 'command', command: `bash -c 'jq -r .response.text 2>/dev/null | stitch thread append --role assistant --thread "$(stitch thread current)" 2>/dev/null || true'` },
609
- ],
958
+ hooks: [{ type: 'command', command: 'stitch _hook' }],
610
959
  };
611
960
  function mergeHook(existing, entry) {
612
961
  const arr = Array.isArray(existing) ? existing.slice() : [];
613
- // Replace any earlier Stitch entry; identify by marker substring.
614
- const isStitch = (h) => JSON.stringify(h).includes('stitch thread append');
962
+ // Replace any earlier Stitch entry; identify by the marker.
963
+ const isStitch = (h) => JSON.stringify(h).includes('stitch _hook') || JSON.stringify(h).includes('stitch thread append');
615
964
  const filtered = arr.filter((h) => !isStitch(h));
616
965
  filtered.push(entry);
617
966
  return filtered;
@@ -781,6 +1130,11 @@ function help() {
781
1130
  stitch whoami Show the configured key.
782
1131
  stitch logout
783
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
+
784
1138
  stitch update Update to the latest @stitchdb/cli.
785
1139
  stitch version Print the installed version.
786
1140
 
@@ -822,6 +1176,8 @@ async function main(argv) {
822
1176
  case 'recall': return cmdRecall(rest);
823
1177
  case 'thread': return cmdThread(rest);
824
1178
  case 'install': return cmdInstall(rest);
1179
+ case 'sync': return cmdSync(rest);
1180
+ case '_hook': return cmdHook(rest);
825
1181
  case 'update':
826
1182
  case 'upgrade': return cmdUpdate(rest);
827
1183
  case 'version':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stitchdb/cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Stitch CLI — manage memory + run agents from your terminal",
5
5
  "type": "module",
6
6
  "bin": {