@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.
- package/dist/cli.js +297 -11
- 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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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
|
|
892
|
+
// 2. Hooks: auto-log every turn + auto-inject prior context at session start
|
|
669
893
|
if (!noHooks) {
|
|
670
|
-
process.stdout.write('• Wiring
|
|
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);
|