@stitchdb/cli 0.6.0 → 0.7.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 +454 -43
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -420,6 +420,12 @@ async function cmdHook(args) {
|
|
|
420
420
|
const cfg = loadConfig();
|
|
421
421
|
if (!cfg.apiKey)
|
|
422
422
|
return; // not logged in, silently skip
|
|
423
|
+
// When the Stitch CLI spawns its own `claude -p` (e.g. for distillation
|
|
424
|
+
// or file summary extraction), we don't want THAT inner conversation
|
|
425
|
+
// logged or its tool calls cached — it would create runaway recursion
|
|
426
|
+
// and pollute the thread with internal prompts.
|
|
427
|
+
if (process.env.STITCH_HOOKS_DISABLED === '1')
|
|
428
|
+
return;
|
|
423
429
|
let raw = '';
|
|
424
430
|
try {
|
|
425
431
|
raw = await readStdinAll();
|
|
@@ -441,6 +447,16 @@ async function cmdHook(args) {
|
|
|
441
447
|
// (which is the directory `claude` was launched from).
|
|
442
448
|
const cwd = event?.cwd || process.cwd();
|
|
443
449
|
const threadName = inferThreadFor(cwd) || 'default';
|
|
450
|
+
// ── PreToolUse on Read: inject cached summary if hash matches ─────────
|
|
451
|
+
if (eventName === 'PreToolUse' && event?.tool_name === 'Read') {
|
|
452
|
+
await handlePreReadHook(cfg, event, cwd || process.cwd()).catch(() => { });
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
// ── PostToolUse on Read: opportunistically save a fresh summary ───────
|
|
456
|
+
if (eventName === 'PostToolUse' && event?.tool_name === 'Read') {
|
|
457
|
+
await handlePostReadHook(cfg, event, cwd || process.cwd()).catch(() => { });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
444
460
|
// ── SessionStart: inject prior context (token-efficient) ─────────────
|
|
445
461
|
// Strategy: prefer distilled memories (dense facts) over raw turns. Only
|
|
446
462
|
// include raw turns for the last 5 to give the agent immediate continuation.
|
|
@@ -448,16 +464,30 @@ async function cmdHook(args) {
|
|
|
448
464
|
try {
|
|
449
465
|
const stitch = client(cfg);
|
|
450
466
|
const projectTag = (threadName.split('/')[0] || threadName).toLowerCase();
|
|
451
|
-
const [thread, memHits] = await Promise.all([
|
|
467
|
+
const [thread, memHits, workspaces, fileSummaries, aboutMems] = await Promise.all([
|
|
452
468
|
stitch.thread(threadName).recall({ last: 5 }).catch(() => ({ thread_id: '', recent: [], semantic: [] })),
|
|
453
469
|
stitch.recall(projectTag, { k: 8 }).catch(() => []),
|
|
470
|
+
stitch.workspaces.list().catch(() => []),
|
|
471
|
+
stitch.list({ limit: 12 }).then((all) => all.filter((m) => m.tags.some((t) => t.startsWith('file:')))).catch(() => []),
|
|
472
|
+
stitch.list({ tag: 'workspace:about', limit: 1 }).catch(() => []),
|
|
454
473
|
]);
|
|
474
|
+
const currentWs = Array.isArray(workspaces) && workspaces.length > 0 ? workspaces[0] : null;
|
|
455
475
|
const lines = [];
|
|
456
476
|
lines.push('<stitch-context>');
|
|
457
|
-
lines.push(`Project: ${threadName} ·
|
|
477
|
+
lines.push(`Project: ${threadName} · Workspace: ${currentWs?.name || '(unknown)'} · Stitch MCP tools: recall, remember, thread_recall, thread_append, workspace_setup, file_summary, file_summary_save.`);
|
|
458
478
|
lines.push('');
|
|
479
|
+
// Nudge the AI to set a meaningful workspace name once.
|
|
480
|
+
if (currentWs?.name === 'default') {
|
|
481
|
+
lines.push('### ⚠ Workspace is still named "default"');
|
|
482
|
+
lines.push('Call the `workspace_setup` MCP tool with a slug-style name based on this project (e.g. the package.json name or repo dir).');
|
|
483
|
+
lines.push('');
|
|
484
|
+
}
|
|
485
|
+
if (Array.isArray(aboutMems) && aboutMems.length > 0) {
|
|
486
|
+
lines.push('### About this workspace');
|
|
487
|
+
lines.push(String(aboutMems[0].content || '').slice(0, 400));
|
|
488
|
+
lines.push('');
|
|
489
|
+
}
|
|
459
490
|
if (Array.isArray(memHits) && memHits.length > 0) {
|
|
460
|
-
// Prefer distilled (auto-tagged) facts and decisions; fall back to anything else
|
|
461
491
|
const sortedMems = [...memHits].sort((a, b) => Number(b.score ?? 0) - Number(a.score ?? 0)).slice(0, 8);
|
|
462
492
|
lines.push('### Durable memories for this project');
|
|
463
493
|
for (const m of sortedMems) {
|
|
@@ -467,6 +497,16 @@ async function cmdHook(args) {
|
|
|
467
497
|
}
|
|
468
498
|
lines.push('');
|
|
469
499
|
}
|
|
500
|
+
if (Array.isArray(fileSummaries) && fileSummaries.length > 0) {
|
|
501
|
+
lines.push('### File summaries (cached — call `file_summary` before reading)');
|
|
502
|
+
for (const m of fileSummaries.slice(0, 8)) {
|
|
503
|
+
const fileTag = m.tags.find((t) => t.startsWith('file:'));
|
|
504
|
+
const filePath = fileTag ? fileTag.slice(5) : '(unknown)';
|
|
505
|
+
const txt = String(m.content || '').replace(/\n+/g, ' ').slice(0, 250);
|
|
506
|
+
lines.push(`- **${filePath}** — ${txt}`);
|
|
507
|
+
}
|
|
508
|
+
lines.push('');
|
|
509
|
+
}
|
|
470
510
|
if (thread.recent && thread.recent.length > 0) {
|
|
471
511
|
lines.push('### Most recent turns (continue from here)');
|
|
472
512
|
for (const t of thread.recent.slice(-5)) {
|
|
@@ -475,7 +515,7 @@ async function cmdHook(args) {
|
|
|
475
515
|
}
|
|
476
516
|
lines.push('');
|
|
477
517
|
}
|
|
478
|
-
lines.push('Call `recall` for deeper search
|
|
518
|
+
lines.push('Call `recall` for deeper search, `thread_recall` for older turns, `file_summary` BEFORE reading any non-trivial file (saves tokens).');
|
|
479
519
|
lines.push('</stitch-context>');
|
|
480
520
|
process.stdout.write(lines.join('\n'));
|
|
481
521
|
}
|
|
@@ -544,6 +584,38 @@ function inferThreadFor(cwd) {
|
|
|
544
584
|
}
|
|
545
585
|
return path.basename(dir) || 'default';
|
|
546
586
|
}
|
|
587
|
+
/**
|
|
588
|
+
* Best-effort derive a workspace slug from a project directory:
|
|
589
|
+
* - package.json `name` (npm convention, slug-friendly already)
|
|
590
|
+
* - else folder basename, normalised to a lowercase slug
|
|
591
|
+
*
|
|
592
|
+
* The AI can later refine via the `workspace_setup` MCP tool — this is just
|
|
593
|
+
* the seed name so it isn't "default".
|
|
594
|
+
*/
|
|
595
|
+
function deriveWorkspaceName(cwd) {
|
|
596
|
+
// 1. package.json `name`.
|
|
597
|
+
try {
|
|
598
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
599
|
+
if (fs.existsSync(pkgPath)) {
|
|
600
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
601
|
+
if (typeof pkg.name === 'string' && pkg.name.trim()) {
|
|
602
|
+
// Strip leading scope (@org/) if any — keeps the slug clean.
|
|
603
|
+
const stripped = pkg.name.replace(/^@[^/]+\//, '').trim();
|
|
604
|
+
if (stripped)
|
|
605
|
+
return slugify(stripped).slice(0, 60);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
catch { /* fall through */ }
|
|
610
|
+
// 2. Folder basename.
|
|
611
|
+
return slugify(path.basename(cwd) || 'default').slice(0, 60) || 'default';
|
|
612
|
+
}
|
|
613
|
+
function slugify(s) {
|
|
614
|
+
return s
|
|
615
|
+
.toLowerCase()
|
|
616
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
617
|
+
.replace(/^-+|-+$/g, '') || 'default';
|
|
618
|
+
}
|
|
547
619
|
function findProjectRoot(cwd) {
|
|
548
620
|
let cur = cwd;
|
|
549
621
|
for (let i = 0; i < 8; i++) {
|
|
@@ -599,6 +671,223 @@ function lastAssistantTextFromTranscript(transcriptPath) {
|
|
|
599
671
|
catch { /* ignore */ }
|
|
600
672
|
return text;
|
|
601
673
|
}
|
|
674
|
+
// ─── File summary cache ────────────────────────────────────────────────────
|
|
675
|
+
//
|
|
676
|
+
// Goal: when Claude reads a non-trivial file we've seen before, surface our
|
|
677
|
+
// cached 2-3 sentence summary so the agent can decide whether to skim or
|
|
678
|
+
// re-read in full. When Claude reads a file we haven't summarized (or that's
|
|
679
|
+
// changed), kick off a background `claude -p` to extract a summary and save
|
|
680
|
+
// it as a memory tagged `file:<path>` + `hash:<sha-prefix>`.
|
|
681
|
+
//
|
|
682
|
+
// Trade-off: the cache is hash-bound, so any byte change invalidates the
|
|
683
|
+
// cached summary — fine for big stable files (docs, configs, third-party src),
|
|
684
|
+
// less useful for files Claude is actively editing. The PostToolUse hook
|
|
685
|
+
// re-summarizes after the next read, so the cache repopulates on its own.
|
|
686
|
+
const FILE_SUMMARY_MIN_BYTES = 500; // skip tiny files
|
|
687
|
+
const FILE_SUMMARY_MAX_BYTES = 200_000; // skip absolutely huge files
|
|
688
|
+
const FILE_SUMMARY_COOLDOWN_MS = 5 * 60_000; // per-file: don't re-summarize more than once per 5 min
|
|
689
|
+
const FILE_SUMMARY_PROMPT = `You are summarising a single source file for an AI coding assistant cache.
|
|
690
|
+
|
|
691
|
+
Return ONLY a 2-3 sentence summary describing:
|
|
692
|
+
• What the file does (purpose, the few exported symbols / commands / endpoints worth knowing).
|
|
693
|
+
• Any important pattern, convention, or non-obvious behaviour another agent would benefit from before reading the file in full.
|
|
694
|
+
|
|
695
|
+
Skip preamble. Skip "this file …". Be specific. No markdown fences. No prose around it.
|
|
696
|
+
|
|
697
|
+
File path: {{PATH}}
|
|
698
|
+
|
|
699
|
+
File content:
|
|
700
|
+
{{CONTENT}}
|
|
701
|
+
`;
|
|
702
|
+
function readFileContentFromHookEvent(event) {
|
|
703
|
+
// Claude Code hook 'tool_response' shape varies between versions; accept
|
|
704
|
+
// a few shapes. The Read tool's success path includes the file content
|
|
705
|
+
// somewhere in tool_response.
|
|
706
|
+
const r = event?.tool_response;
|
|
707
|
+
if (!r)
|
|
708
|
+
return '';
|
|
709
|
+
if (typeof r === 'string')
|
|
710
|
+
return r;
|
|
711
|
+
if (typeof r?.file?.content === 'string')
|
|
712
|
+
return r.file.content;
|
|
713
|
+
if (typeof r?.content === 'string')
|
|
714
|
+
return r.content;
|
|
715
|
+
if (Array.isArray(r?.content)) {
|
|
716
|
+
return r.content.map((c) => (typeof c === 'string' ? c : c?.text ?? '')).join('');
|
|
717
|
+
}
|
|
718
|
+
return '';
|
|
719
|
+
}
|
|
720
|
+
function relPathFor(cwd, abs) {
|
|
721
|
+
try {
|
|
722
|
+
return path.relative(cwd, abs) || abs;
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
return abs;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
async function sha256Hex(s) {
|
|
729
|
+
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
|
|
730
|
+
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
731
|
+
}
|
|
732
|
+
async function lookupFileSummary(stitch, relPath) {
|
|
733
|
+
try {
|
|
734
|
+
const list = await stitch.list({ tag: `file:${relPath}`, limit: 5 });
|
|
735
|
+
if (!list || list.length === 0)
|
|
736
|
+
return null;
|
|
737
|
+
const first = list[0];
|
|
738
|
+
const hashTag = first.tags.find((t) => t.startsWith('hash:'));
|
|
739
|
+
return {
|
|
740
|
+
id: first.id,
|
|
741
|
+
content: first.content,
|
|
742
|
+
hash: hashTag ? hashTag.slice(5) : null,
|
|
743
|
+
created_at: first.created_at,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
catch {
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
// PreToolUse on Read: if we have a cached summary for this exact file path,
|
|
751
|
+
// emit Claude-Code-flavoured JSON to stdout that adds it as additional context.
|
|
752
|
+
async function handlePreReadHook(cfg, event, cwd) {
|
|
753
|
+
const filePath = String(event?.tool_input?.file_path || '');
|
|
754
|
+
if (!filePath)
|
|
755
|
+
return;
|
|
756
|
+
const rel = relPathFor(cwd, filePath);
|
|
757
|
+
const stitch = client(cfg);
|
|
758
|
+
const cached = await lookupFileSummary(stitch, rel);
|
|
759
|
+
if (!cached)
|
|
760
|
+
return;
|
|
761
|
+
const ageMin = Math.round((Date.now() - cached.created_at) / 60_000);
|
|
762
|
+
const note = [
|
|
763
|
+
`Stitch cache: a prior summary of ${rel} exists (${ageMin}m ago).`,
|
|
764
|
+
cached.hash ? `Cached hash prefix: ${cached.hash}.` : '',
|
|
765
|
+
`Summary: ${cached.content}`,
|
|
766
|
+
`If the file hasn't changed, you can act on this summary alone and skip a full read; otherwise the read will re-cache the summary.`,
|
|
767
|
+
].filter(Boolean).join('\n');
|
|
768
|
+
// Claude Code accepts a JSON object on stdout for PreToolUse with
|
|
769
|
+
// hookSpecificOutput.additionalContext to inject text into the model's
|
|
770
|
+
// context for this tool call.
|
|
771
|
+
const payload = { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: note } };
|
|
772
|
+
process.stdout.write(JSON.stringify(payload));
|
|
773
|
+
}
|
|
774
|
+
// PostToolUse on Read: kick off a background `claude -p` to summarize this
|
|
775
|
+
// file IF we don't already have a summary at the current hash. Fire-and-forget.
|
|
776
|
+
async function handlePostReadHook(cfg, event, cwd) {
|
|
777
|
+
const filePath = String(event?.tool_input?.file_path || '');
|
|
778
|
+
if (!filePath)
|
|
779
|
+
return;
|
|
780
|
+
const rel = relPathFor(cwd, filePath);
|
|
781
|
+
const content = readFileContentFromHookEvent(event);
|
|
782
|
+
// Fall back to reading from disk if hook payload didn't carry content.
|
|
783
|
+
let body = content;
|
|
784
|
+
if (!body) {
|
|
785
|
+
try {
|
|
786
|
+
body = fs.readFileSync(filePath, 'utf8');
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (!body || body.length < FILE_SUMMARY_MIN_BYTES)
|
|
793
|
+
return;
|
|
794
|
+
if (body.length > FILE_SUMMARY_MAX_BYTES)
|
|
795
|
+
return;
|
|
796
|
+
const fullHash = await sha256Hex(body);
|
|
797
|
+
const hashPrefix = fullHash.slice(0, 16);
|
|
798
|
+
// Per-file cooldown so a hot-edited file doesn't re-summarize on every read.
|
|
799
|
+
const state = loadFileSummaryState();
|
|
800
|
+
const last = state.files[rel];
|
|
801
|
+
if (last && last.hash === hashPrefix && Date.now() - last.lastSummarizedAt < FILE_SUMMARY_COOLDOWN_MS)
|
|
802
|
+
return;
|
|
803
|
+
const stitch = client(cfg);
|
|
804
|
+
const cached = await lookupFileSummary(stitch, rel);
|
|
805
|
+
if (cached && cached.hash === hashPrefix) {
|
|
806
|
+
// Already summarized this exact version — refresh cooldown and bail.
|
|
807
|
+
state.files[rel] = { hash: hashPrefix, lastSummarizedAt: Date.now() };
|
|
808
|
+
saveFileSummaryState(state);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
// Mark BEFORE spawning so a flurry of Read events doesn't fire N spawns.
|
|
812
|
+
state.files[rel] = { hash: hashPrefix, lastSummarizedAt: Date.now() };
|
|
813
|
+
saveFileSummaryState(state);
|
|
814
|
+
// Spawn detached: stitch _summarize-file <abs-path> <hash> <rel>
|
|
815
|
+
try {
|
|
816
|
+
const cliPath = process.argv[1] || (await import('node:url')).fileURLToPath(import.meta.url);
|
|
817
|
+
const child = spawn(process.argv[0], [cliPath, '_summarize-file', filePath, hashPrefix, rel], {
|
|
818
|
+
detached: true,
|
|
819
|
+
stdio: 'ignore',
|
|
820
|
+
env: { ...process.env, STITCH_HOOKS_DISABLED: '1' },
|
|
821
|
+
});
|
|
822
|
+
child.unref();
|
|
823
|
+
}
|
|
824
|
+
catch { /* ignore */ }
|
|
825
|
+
}
|
|
826
|
+
// File summary state: per-file cooldown so we don't re-fire on every Read.
|
|
827
|
+
const FILE_SUMMARY_STATE_FILE = path.join(CONFIG_DIR, 'file-summary-state.json');
|
|
828
|
+
function loadFileSummaryState() {
|
|
829
|
+
try {
|
|
830
|
+
return JSON.parse(fs.readFileSync(FILE_SUMMARY_STATE_FILE, 'utf8'));
|
|
831
|
+
}
|
|
832
|
+
catch {
|
|
833
|
+
return { files: {} };
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
function saveFileSummaryState(s) {
|
|
837
|
+
if (!fs.existsSync(CONFIG_DIR))
|
|
838
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
839
|
+
fs.writeFileSync(FILE_SUMMARY_STATE_FILE, JSON.stringify(s, null, 2));
|
|
840
|
+
}
|
|
841
|
+
// `stitch _summarize-file <abs> <hashPrefix> <rel>` — internal entry called
|
|
842
|
+
// from the detached PostToolUse spawn. Reads the file, asks claude -p for a
|
|
843
|
+
// 2-3 sentence summary, saves as a Stitch memory.
|
|
844
|
+
async function cmdSummarizeFile(args) {
|
|
845
|
+
const [abs, hashPrefix, rel] = args;
|
|
846
|
+
if (!abs || !hashPrefix || !rel)
|
|
847
|
+
return;
|
|
848
|
+
const cfg = loadConfig();
|
|
849
|
+
if (!cfg.apiKey)
|
|
850
|
+
return;
|
|
851
|
+
let body = '';
|
|
852
|
+
try {
|
|
853
|
+
body = fs.readFileSync(abs, 'utf8');
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (!body || body.length > FILE_SUMMARY_MAX_BYTES)
|
|
859
|
+
return;
|
|
860
|
+
const claudeBin = process.env.STITCH_CLAUDE_BIN || 'claude';
|
|
861
|
+
const prompt = FILE_SUMMARY_PROMPT
|
|
862
|
+
.replace('{{PATH}}', rel)
|
|
863
|
+
.replace('{{CONTENT}}', body);
|
|
864
|
+
const result = await runWithStdin(claudeBin, ['-p'], prompt, {
|
|
865
|
+
cwd: process.cwd(),
|
|
866
|
+
env: { STITCH_HOOKS_DISABLED: '1' },
|
|
867
|
+
});
|
|
868
|
+
if (result.exit_code !== 0)
|
|
869
|
+
return;
|
|
870
|
+
const summary = result.stdout.trim().slice(0, 2000);
|
|
871
|
+
if (!summary)
|
|
872
|
+
return;
|
|
873
|
+
try {
|
|
874
|
+
const stitch = client(cfg);
|
|
875
|
+
const projectTag = (inferThread() || 'default').split('/')[0];
|
|
876
|
+
// Replace any prior summary for this path.
|
|
877
|
+
const prior = await stitch.list({ tag: `file:${rel}`, limit: 50 }).catch(() => []);
|
|
878
|
+
for (const p of prior) {
|
|
879
|
+
try {
|
|
880
|
+
await stitch.forget(p.id);
|
|
881
|
+
}
|
|
882
|
+
catch { }
|
|
883
|
+
}
|
|
884
|
+
await stitch.remember(summary, {
|
|
885
|
+
kind: 'snippet',
|
|
886
|
+
tags: [`file:${rel}`, `hash:${hashPrefix}`, 'auto:file-summary', `project:${projectTag}`],
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
catch { /* silent — never break a session */ }
|
|
890
|
+
}
|
|
602
891
|
// ── stitch link — pin a project's thread name so it's the same on every machine
|
|
603
892
|
async function cmdLink(args) {
|
|
604
893
|
const positionals = positional(args);
|
|
@@ -646,29 +935,24 @@ function saveDistillState(s) {
|
|
|
646
935
|
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
647
936
|
fs.writeFileSync(DISTILL_STATE_FILE, JSON.stringify(s, null, 2));
|
|
648
937
|
}
|
|
649
|
-
const DISTILL_PROMPT = `You are a memory distiller for
|
|
938
|
+
const DISTILL_PROMPT = `You are a memory distiller. Output ONLY a JSON array of memory objects. Be GENEROUS — capture every concrete fact about the project that future sessions will care about. Aim for 10–30 memories on a substantial conversation.
|
|
650
939
|
|
|
651
|
-
|
|
652
|
-
durable, project-specific facts the developer explicitly stated or decisions
|
|
653
|
-
they explicitly made. Do not invent. Do not extrapolate from implications.
|
|
654
|
-
Skip anything that's tentative, exploratory, or not directly stated.
|
|
940
|
+
Each memory is one atomic, self-contained statement (1–4 sentences) that someone reading it months later — without the conversation around it — will fully understand.
|
|
655
941
|
|
|
656
|
-
|
|
657
|
-
{
|
|
658
|
-
"kind": "fact" | "decision" | "snippet" | "preference",
|
|
659
|
-
"content": "<self-contained, single-paragraph statement>",
|
|
660
|
-
"tags": ["<short-keyword>", ...]
|
|
661
|
-
}
|
|
942
|
+
Format:
|
|
943
|
+
[{"kind":"fact|decision|snippet|preference","content":"...","tags":["short","keywords"]}, ...]
|
|
662
944
|
|
|
663
|
-
|
|
664
|
-
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
-
|
|
668
|
-
-
|
|
669
|
-
-
|
|
670
|
-
|
|
671
|
-
|
|
945
|
+
Capture:
|
|
946
|
+
- fact — anything concrete: file paths, endpoint URLs, commands, version
|
|
947
|
+
numbers, architecture, deployed services, schemas, dependencies,
|
|
948
|
+
bugs found, fixes shipped.
|
|
949
|
+
- decision — choices with rationale.
|
|
950
|
+
- snippet — reusable commands/config/code/CLI flags/env var names.
|
|
951
|
+
- preference — how the developer wants the AI to behave on this project.
|
|
952
|
+
|
|
953
|
+
Skip pleasantries and questions still being explored. One atomic idea per
|
|
954
|
+
memory — don't bundle. If truly nothing durable was discussed, output: [].
|
|
955
|
+
Output the JSON array and nothing else — no prose, no markdown fences.
|
|
672
956
|
|
|
673
957
|
Conversation:
|
|
674
958
|
`;
|
|
@@ -701,7 +985,29 @@ async function cmdDistill(args) {
|
|
|
701
985
|
}
|
|
702
986
|
const claudeBin = process.env.STITCH_CLAUDE_BIN || 'claude';
|
|
703
987
|
process.stdout.write(` → asking ${claudeBin} -p to extract facts… `);
|
|
704
|
-
const
|
|
988
|
+
const fullPrompt = DISTILL_PROMPT + conversation;
|
|
989
|
+
// Pipe the prompt via stdin: long conversations blow past ARG_MAX when
|
|
990
|
+
// passed as `-p <prompt>`, and stdin avoids claude interpreting any
|
|
991
|
+
// prompt-internal characters as flags. `claude -p` with no value reads
|
|
992
|
+
// from stdin. STITCH_HOOKS_DISABLED stops our own _hook command from
|
|
993
|
+
// logging this nested distill conversation as project thread turns.
|
|
994
|
+
const result = await runWithStdin(claudeBin, ['-p'], fullPrompt, {
|
|
995
|
+
cwd: process.cwd(),
|
|
996
|
+
env: { STITCH_HOOKS_DISABLED: '1' },
|
|
997
|
+
});
|
|
998
|
+
// Debug capture: STITCH_DEBUG_DISTILL=1 writes raw stdout/stderr/prompt
|
|
999
|
+
// to /tmp so we can see exactly what claude actually returned.
|
|
1000
|
+
if (process.env.STITCH_DEBUG_DISTILL === '1') {
|
|
1001
|
+
const ts = Date.now();
|
|
1002
|
+
const dbgDir = '/tmp';
|
|
1003
|
+
try {
|
|
1004
|
+
fs.writeFileSync(path.join(dbgDir, `stitch-distill-${ts}.prompt.txt`), fullPrompt);
|
|
1005
|
+
fs.writeFileSync(path.join(dbgDir, `stitch-distill-${ts}.stdout.txt`), result.stdout);
|
|
1006
|
+
fs.writeFileSync(path.join(dbgDir, `stitch-distill-${ts}.stderr.txt`), result.stderr);
|
|
1007
|
+
console.log(`\n [debug] wrote /tmp/stitch-distill-${ts}.{prompt,stdout,stderr}.txt`);
|
|
1008
|
+
}
|
|
1009
|
+
catch { /* ignore */ }
|
|
1010
|
+
}
|
|
705
1011
|
if (result.exit_code !== 0) {
|
|
706
1012
|
console.log('failed');
|
|
707
1013
|
console.error(result.stderr.trim().slice(0, 400));
|
|
@@ -710,6 +1016,10 @@ async function cmdDistill(args) {
|
|
|
710
1016
|
const memories = parseDistillationOutput(result.stdout);
|
|
711
1017
|
console.log(`extracted ${memories.length} memories`);
|
|
712
1018
|
if (memories.length === 0) {
|
|
1019
|
+
if (process.env.STITCH_DEBUG_DISTILL === '1') {
|
|
1020
|
+
console.error(' [debug] claude stdout (first 600 chars):');
|
|
1021
|
+
console.error(' ' + result.stdout.slice(0, 600).replace(/\n/g, '\n '));
|
|
1022
|
+
}
|
|
713
1023
|
bumpDistillCooldown(thread);
|
|
714
1024
|
return;
|
|
715
1025
|
}
|
|
@@ -739,25 +1049,76 @@ function bumpDistillCooldown(thread) {
|
|
|
739
1049
|
saveDistillState(state);
|
|
740
1050
|
}
|
|
741
1051
|
function parseDistillationOutput(stdout) {
|
|
742
|
-
// claude -p
|
|
743
|
-
//
|
|
1052
|
+
// claude -p often wraps the array in markdown fences, prepends prose like
|
|
1053
|
+
// "Here are the extracted memories:", or follows it with a sign-off line.
|
|
1054
|
+
// Strategy: try several extraction modes from most-precise to most-lenient,
|
|
1055
|
+
// returning the first one that parses to a non-empty valid array.
|
|
744
1056
|
const text = stdout.trim();
|
|
745
|
-
|
|
746
|
-
const end = text.lastIndexOf(']');
|
|
747
|
-
if (start === -1 || end === -1 || end < start)
|
|
1057
|
+
if (!text)
|
|
748
1058
|
return [];
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
1059
|
+
const candidates = [];
|
|
1060
|
+
// 1. Fenced ```json block
|
|
1061
|
+
const fenced = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/i);
|
|
1062
|
+
if (fenced)
|
|
1063
|
+
candidates.push(fenced[1].trim());
|
|
1064
|
+
// 2. The first `[ {` … last `} ]` window (skips prose-level stray brackets).
|
|
1065
|
+
const objStart = text.indexOf('[');
|
|
1066
|
+
const objEnd = text.lastIndexOf(']');
|
|
1067
|
+
if (objStart !== -1 && objEnd > objStart) {
|
|
1068
|
+
candidates.push(text.slice(objStart, objEnd + 1));
|
|
1069
|
+
}
|
|
1070
|
+
// 3. Largest `[\n{ ... }\n]` block via balanced scan from each `[`
|
|
1071
|
+
for (let i = 0; i < text.length; i++) {
|
|
1072
|
+
if (text[i] !== '[')
|
|
1073
|
+
continue;
|
|
1074
|
+
let depth = 0;
|
|
1075
|
+
let inStr = false;
|
|
1076
|
+
let esc = false;
|
|
1077
|
+
for (let j = i; j < text.length; j++) {
|
|
1078
|
+
const ch = text[j];
|
|
1079
|
+
if (esc) {
|
|
1080
|
+
esc = false;
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
if (ch === '\\' && inStr) {
|
|
1084
|
+
esc = true;
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
if (ch === '"') {
|
|
1088
|
+
inStr = !inStr;
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
if (inStr)
|
|
1092
|
+
continue;
|
|
1093
|
+
if (ch === '[')
|
|
1094
|
+
depth++;
|
|
1095
|
+
else if (ch === ']') {
|
|
1096
|
+
depth--;
|
|
1097
|
+
if (depth === 0) {
|
|
1098
|
+
candidates.push(text.slice(i, j + 1));
|
|
1099
|
+
break;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
757
1103
|
}
|
|
758
|
-
|
|
759
|
-
|
|
1104
|
+
const seen = new Set();
|
|
1105
|
+
for (const cand of candidates) {
|
|
1106
|
+
if (seen.has(cand))
|
|
1107
|
+
continue;
|
|
1108
|
+
seen.add(cand);
|
|
1109
|
+
try {
|
|
1110
|
+
const parsed = JSON.parse(cand);
|
|
1111
|
+
if (!Array.isArray(parsed))
|
|
1112
|
+
continue;
|
|
1113
|
+
const valid = parsed.filter((m) => m && typeof m === 'object'
|
|
1114
|
+
&& typeof m.content === 'string' && m.content.length > 0
|
|
1115
|
+
&& typeof m.kind === 'string' && ['fact', 'decision', 'snippet', 'preference', 'note'].includes(m.kind));
|
|
1116
|
+
if (valid.length > 0)
|
|
1117
|
+
return valid;
|
|
1118
|
+
}
|
|
1119
|
+
catch { /* try next candidate */ }
|
|
760
1120
|
}
|
|
1121
|
+
return [];
|
|
761
1122
|
}
|
|
762
1123
|
async function distillReview(args) {
|
|
763
1124
|
const cfg = loadConfig();
|
|
@@ -1102,6 +1463,26 @@ async function cmdInstall(args) {
|
|
|
1102
1463
|
const ws = await stitch.resolveWorkspace();
|
|
1103
1464
|
const baseUrl = cfg.baseUrl || 'https://db.stitchdb.com';
|
|
1104
1465
|
const mcpUrl = `${baseUrl}/mcp/v1/${ws}`;
|
|
1466
|
+
// 0. If the workspace is still called "default", upgrade its name to a
|
|
1467
|
+
// project-derived slug so memories surface under a meaningful identity.
|
|
1468
|
+
// The AI can later refine it via the workspace_setup MCP tool.
|
|
1469
|
+
if (!hasFlag(args, ['--no-rename-workspace'])) {
|
|
1470
|
+
try {
|
|
1471
|
+
const current = await stitch.workspaces.get(ws);
|
|
1472
|
+
if (current.name === 'default') {
|
|
1473
|
+
const derived = deriveWorkspaceName(process.cwd());
|
|
1474
|
+
if (derived && derived !== 'default') {
|
|
1475
|
+
process.stdout.write(`• Naming workspace "${derived}" (was "default")… `);
|
|
1476
|
+
await stitch.workspaces.update(ws, { name: derived });
|
|
1477
|
+
console.log('ok');
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
catch (e) {
|
|
1482
|
+
// Older deployments may not have the PATCH endpoint yet.
|
|
1483
|
+
console.log(`• Workspace rename skipped (${e?.message || e}).`);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1105
1486
|
const noMcp = hasFlag(args, ['--no-mcp']);
|
|
1106
1487
|
const noHooks = hasFlag(args, ['--no-hooks']);
|
|
1107
1488
|
const noClaudeMd = hasFlag(args, ['--no-claude-md']);
|
|
@@ -1120,9 +1501,10 @@ async function cmdInstall(args) {
|
|
|
1120
1501
|
else
|
|
1121
1502
|
console.log(`failed (${stderr.trim().slice(0, 120)})`);
|
|
1122
1503
|
}
|
|
1123
|
-
// 2. Hooks: auto-log every turn + auto-inject prior context at session start
|
|
1504
|
+
// 2. Hooks: auto-log every turn + auto-inject prior context at session start +
|
|
1505
|
+
// file-summary cache (Pre/PostToolUse on Read).
|
|
1124
1506
|
if (!noHooks) {
|
|
1125
|
-
process.stdout.write('• Wiring hooks (SessionStart + UserPromptSubmit + Stop)… ');
|
|
1507
|
+
process.stdout.write('• Wiring hooks (SessionStart + UserPromptSubmit + Stop + Pre/PostToolUse:Read)… ');
|
|
1126
1508
|
try {
|
|
1127
1509
|
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
1128
1510
|
const existing = fs.existsSync(settingsPath)
|
|
@@ -1132,6 +1514,8 @@ async function cmdInstall(args) {
|
|
|
1132
1514
|
existing.hooks.SessionStart = mergeHook(existing.hooks.SessionStart, STITCH_SESSION_START_HOOK);
|
|
1133
1515
|
existing.hooks.UserPromptSubmit = mergeHook(existing.hooks.UserPromptSubmit, STITCH_USER_HOOK);
|
|
1134
1516
|
existing.hooks.Stop = mergeHook(existing.hooks.Stop, STITCH_STOP_HOOK);
|
|
1517
|
+
existing.hooks.PreToolUse = mergeHook(existing.hooks.PreToolUse, STITCH_PRE_READ_HOOK);
|
|
1518
|
+
existing.hooks.PostToolUse = mergeHook(existing.hooks.PostToolUse, STITCH_POST_READ_HOOK);
|
|
1135
1519
|
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
1136
1520
|
fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
|
|
1137
1521
|
console.log('ok');
|
|
@@ -1213,10 +1597,11 @@ async function cmdInstall(args) {
|
|
|
1213
1597
|
}
|
|
1214
1598
|
console.log();
|
|
1215
1599
|
console.log('Done. Open a fresh `claude` session — Stitch is wired.');
|
|
1216
|
-
console.log(' •
|
|
1600
|
+
console.log(' • workspace_setup, recall, remember, thread tools are available');
|
|
1217
1601
|
console.log(' • every turn auto-logs to the thread for this repo');
|
|
1218
1602
|
console.log(' • Claude auto-pulls past context at session start (per CLAUDE.md)');
|
|
1219
1603
|
console.log(' • Claude memory files are mirrored to Stitch');
|
|
1604
|
+
console.log(' • file Reads auto-cache 2-3 sentence summaries; Pre-Read injects them');
|
|
1220
1605
|
console.log();
|
|
1221
1606
|
console.log('Run `stitch sync --watch` to keep them in sync as Claude updates them.');
|
|
1222
1607
|
}
|
|
@@ -1236,6 +1621,15 @@ const STITCH_SESSION_START_HOOK = {
|
|
|
1236
1621
|
matcher: '*',
|
|
1237
1622
|
hooks: [{ type: 'command', command: 'stitch _hook SessionStart' }],
|
|
1238
1623
|
};
|
|
1624
|
+
// File summary cache: only fires for the Read tool, both pre and post.
|
|
1625
|
+
const STITCH_PRE_READ_HOOK = {
|
|
1626
|
+
matcher: 'Read',
|
|
1627
|
+
hooks: [{ type: 'command', command: 'stitch _hook' }],
|
|
1628
|
+
};
|
|
1629
|
+
const STITCH_POST_READ_HOOK = {
|
|
1630
|
+
matcher: 'Read',
|
|
1631
|
+
hooks: [{ type: 'command', command: 'stitch _hook' }],
|
|
1632
|
+
};
|
|
1239
1633
|
function mergeHook(existing, entry) {
|
|
1240
1634
|
const arr = Array.isArray(existing) ? existing.slice() : [];
|
|
1241
1635
|
// Replace any earlier Stitch entry; identify by the marker.
|
|
@@ -1387,6 +1781,22 @@ function run(cmd, args, opts = {}) {
|
|
|
1387
1781
|
child.on('close', (code) => resolve({ stdout, stderr, exit_code: code ?? 0 }));
|
|
1388
1782
|
});
|
|
1389
1783
|
}
|
|
1784
|
+
function runWithStdin(cmd, args, stdinData, opts = {}) {
|
|
1785
|
+
return new Promise((resolve) => {
|
|
1786
|
+
const child = spawn(cmd, args, {
|
|
1787
|
+
cwd: opts.cwd || process.cwd(),
|
|
1788
|
+
env: { ...process.env, ...(opts.env || {}) },
|
|
1789
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1790
|
+
});
|
|
1791
|
+
let stdout = '';
|
|
1792
|
+
let stderr = '';
|
|
1793
|
+
child.stdout.on('data', (d) => stdout += d.toString());
|
|
1794
|
+
child.stderr.on('data', (d) => stderr += d.toString());
|
|
1795
|
+
child.on('error', (err) => resolve({ stdout, stderr: stderr + '\n[spawn error] ' + err.message, exit_code: 127 }));
|
|
1796
|
+
child.on('close', (code) => resolve({ stdout, stderr, exit_code: code ?? 0 }));
|
|
1797
|
+
child.stdin.end(stdinData);
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1390
1800
|
function readLine() {
|
|
1391
1801
|
return new Promise((resolve) => {
|
|
1392
1802
|
const onData = (d) => { process.stdin.off('data', onData); resolve(d.toString().trim()); };
|
|
@@ -1477,6 +1887,7 @@ async function main(argv) {
|
|
|
1477
1887
|
case 'link': return cmdLink(rest);
|
|
1478
1888
|
case 'distill': return cmdDistill(rest);
|
|
1479
1889
|
case '_hook': return cmdHook(rest);
|
|
1890
|
+
case '_summarize-file': return cmdSummarizeFile(rest);
|
|
1480
1891
|
case 'update':
|
|
1481
1892
|
case 'upgrade': return cmdUpdate(rest);
|
|
1482
1893
|
case 'version':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stitchdb/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Stitch CLI — manage memory + run agents from your terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"engines": { "node": ">=20" },
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@stitchdb/agent": "^0.
|
|
19
|
+
"@stitchdb/agent": "^0.2.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"typescript": "^5.4.0",
|