@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.
- package/dist/cli.js +238 -0
- 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);
|