@stitchdb/cli 0.5.0 → 0.6.1
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 +407 -63
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -339,36 +339,8 @@ async function cmdAgent(args) {
|
|
|
339
339
|
}
|
|
340
340
|
// ── Threads — append / recall / current ───────────────────────────────────
|
|
341
341
|
function inferThread() {
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
try {
|
|
345
|
-
const dir = process.cwd();
|
|
346
|
-
let cur = dir;
|
|
347
|
-
for (let i = 0; i < 8; i++) {
|
|
348
|
-
if (fs.existsSync(path.join(cur, '.git'))) {
|
|
349
|
-
const parent = path.dirname(cur);
|
|
350
|
-
const repoName = path.basename(cur);
|
|
351
|
-
let branch = 'main';
|
|
352
|
-
try {
|
|
353
|
-
const head = fs.readFileSync(path.join(cur, '.git', 'HEAD'), 'utf8').trim();
|
|
354
|
-
const m = head.match(/^ref:\s+refs\/heads\/(.+)$/);
|
|
355
|
-
if (m)
|
|
356
|
-
branch = m[1];
|
|
357
|
-
}
|
|
358
|
-
catch { /* detached */ }
|
|
359
|
-
return `${repoName}/${branch}`;
|
|
360
|
-
void parent;
|
|
361
|
-
}
|
|
362
|
-
const next = path.dirname(cur);
|
|
363
|
-
if (next === cur)
|
|
364
|
-
break;
|
|
365
|
-
cur = next;
|
|
366
|
-
}
|
|
367
|
-
return path.basename(dir);
|
|
368
|
-
}
|
|
369
|
-
catch {
|
|
370
|
-
return 'default';
|
|
371
|
-
}
|
|
342
|
+
// Single source of truth: `.stitch/project.json` if present, else cwd basename.
|
|
343
|
+
return inferThreadFor(process.cwd());
|
|
372
344
|
}
|
|
373
345
|
async function cmdThread(args) {
|
|
374
346
|
const cfg = loadConfig();
|
|
@@ -448,6 +420,11 @@ async function cmdHook(args) {
|
|
|
448
420
|
const cfg = loadConfig();
|
|
449
421
|
if (!cfg.apiKey)
|
|
450
422
|
return; // not logged in, silently skip
|
|
423
|
+
// When the Stitch CLI spawns its own `claude -p` (e.g. for distillation),
|
|
424
|
+
// we don't want THAT inner conversation logged as a user/assistant turn —
|
|
425
|
+
// it would pollute the project thread with the distill prompt and JSON.
|
|
426
|
+
if (process.env.STITCH_HOOKS_DISABLED === '1')
|
|
427
|
+
return;
|
|
451
428
|
let raw = '';
|
|
452
429
|
try {
|
|
453
430
|
raw = await readStdinAll();
|
|
@@ -469,42 +446,43 @@ async function cmdHook(args) {
|
|
|
469
446
|
// (which is the directory `claude` was launched from).
|
|
470
447
|
const cwd = event?.cwd || process.cwd();
|
|
471
448
|
const threadName = inferThreadFor(cwd) || 'default';
|
|
472
|
-
// ── SessionStart: inject prior context
|
|
449
|
+
// ── SessionStart: inject prior context (token-efficient) ─────────────
|
|
450
|
+
// Strategy: prefer distilled memories (dense facts) over raw turns. Only
|
|
451
|
+
// include raw turns for the last 5 to give the agent immediate continuation.
|
|
473
452
|
if (eventName === 'SessionStart') {
|
|
474
453
|
try {
|
|
475
454
|
const stitch = client(cfg);
|
|
476
|
-
const projectTag =
|
|
477
|
-
// Pull both: recent dialogue from the per-repo thread + relevant
|
|
478
|
-
// durable memories tagged with the project.
|
|
455
|
+
const projectTag = (threadName.split('/')[0] || threadName).toLowerCase();
|
|
479
456
|
const [thread, memHits] = await Promise.all([
|
|
480
|
-
stitch.thread(threadName).recall({ last:
|
|
481
|
-
stitch.recall(projectTag, { k:
|
|
457
|
+
stitch.thread(threadName).recall({ last: 5 }).catch(() => ({ thread_id: '', recent: [], semantic: [] })),
|
|
458
|
+
stitch.recall(projectTag, { k: 8 }).catch(() => []),
|
|
482
459
|
]);
|
|
483
460
|
const lines = [];
|
|
484
|
-
lines.push('
|
|
485
|
-
lines.push(
|
|
486
|
-
lines.push(`Thread: \`${threadName}\` · Workspace recall available via the \`stitch\` MCP server.`);
|
|
461
|
+
lines.push('<stitch-context>');
|
|
462
|
+
lines.push(`Project: ${threadName} · Stitch MCP server is available with tools: recall, remember, thread_recall, thread_append.`);
|
|
487
463
|
lines.push('');
|
|
488
|
-
if (
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
464
|
+
if (Array.isArray(memHits) && memHits.length > 0) {
|
|
465
|
+
// Prefer distilled (auto-tagged) facts and decisions; fall back to anything else
|
|
466
|
+
const sortedMems = [...memHits].sort((a, b) => Number(b.score ?? 0) - Number(a.score ?? 0)).slice(0, 8);
|
|
467
|
+
lines.push('### Durable memories for this project');
|
|
468
|
+
for (const m of sortedMems) {
|
|
469
|
+
const isAuto = Array.isArray(m.tags) && m.tags.includes('auto');
|
|
470
|
+
const txt = String(m.content || '').replace(/\n+/g, ' ').slice(0, 350);
|
|
471
|
+
lines.push(`- **[${m.kind}${isAuto ? '·auto' : ''}]** ${txt}`);
|
|
493
472
|
}
|
|
494
473
|
lines.push('');
|
|
495
474
|
}
|
|
496
|
-
if (
|
|
497
|
-
lines.push('###
|
|
498
|
-
for (const
|
|
499
|
-
const txt = String(
|
|
500
|
-
lines.push(`-
|
|
475
|
+
if (thread.recent && thread.recent.length > 0) {
|
|
476
|
+
lines.push('### Most recent turns (continue from here)');
|
|
477
|
+
for (const t of thread.recent.slice(-5)) {
|
|
478
|
+
const txt = String(t.content || '').replace(/\n+/g, ' ').slice(0, 300);
|
|
479
|
+
lines.push(`- **${t.role}**: ${txt}`);
|
|
501
480
|
}
|
|
502
481
|
lines.push('');
|
|
503
482
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
}
|
|
483
|
+
lines.push('Call `recall` for deeper search or `thread_recall` for older turns when needed.');
|
|
484
|
+
lines.push('</stitch-context>');
|
|
485
|
+
process.stdout.write(lines.join('\n'));
|
|
508
486
|
}
|
|
509
487
|
catch { /* silent — never break a session start */ }
|
|
510
488
|
return;
|
|
@@ -533,29 +511,55 @@ async function cmdHook(args) {
|
|
|
533
511
|
catch {
|
|
534
512
|
/* silent */
|
|
535
513
|
}
|
|
514
|
+
// After Stop, opportunistically kick off a distillation pass in the
|
|
515
|
+
// background (fire-and-forget). Won't block the session; debouncing
|
|
516
|
+
// (cooldown + min-new-turns) is enforced inside maybeAutoDistill.
|
|
517
|
+
if (eventName === 'Stop') {
|
|
518
|
+
maybeAutoDistill(threadName).catch(() => { });
|
|
519
|
+
}
|
|
536
520
|
}
|
|
521
|
+
/**
|
|
522
|
+
* Derive a thread name for the project at `cwd`. Strategy:
|
|
523
|
+
*
|
|
524
|
+
* 1. Walk up looking for a `.stitch/project.json` containing { "thread": "x" }
|
|
525
|
+
* — this is the authoritative cross-machine pin. Stays exactly the same
|
|
526
|
+
* on every machine that has the file. Created by `stitch link`.
|
|
527
|
+
* 2. Fall back to the folder basename (which already works fine if the user
|
|
528
|
+
* keeps the same folder name across their machines).
|
|
529
|
+
*
|
|
530
|
+
* Deliberately does NOT require git — works for any project structure.
|
|
531
|
+
*/
|
|
537
532
|
function inferThreadFor(cwd) {
|
|
538
533
|
const dir = cwd && fs.existsSync(cwd) ? cwd : process.cwd();
|
|
539
534
|
let cur = dir;
|
|
540
535
|
for (let i = 0; i < 8; i++) {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
let branch = 'main';
|
|
536
|
+
const projectFile = path.join(cur, '.stitch', 'project.json');
|
|
537
|
+
if (fs.existsSync(projectFile)) {
|
|
544
538
|
try {
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
branch = m[1];
|
|
539
|
+
const cfg = JSON.parse(fs.readFileSync(projectFile, 'utf8'));
|
|
540
|
+
if (cfg.thread && typeof cfg.thread === 'string')
|
|
541
|
+
return cfg.thread;
|
|
549
542
|
}
|
|
550
|
-
catch { /*
|
|
551
|
-
return `${repoName}/${branch}`;
|
|
543
|
+
catch { /* malformed — fall through */ }
|
|
552
544
|
}
|
|
553
545
|
const next = path.dirname(cur);
|
|
554
546
|
if (next === cur)
|
|
555
547
|
break;
|
|
556
548
|
cur = next;
|
|
557
549
|
}
|
|
558
|
-
return path.basename(dir);
|
|
550
|
+
return path.basename(dir) || 'default';
|
|
551
|
+
}
|
|
552
|
+
function findProjectRoot(cwd) {
|
|
553
|
+
let cur = cwd;
|
|
554
|
+
for (let i = 0; i < 8; i++) {
|
|
555
|
+
if (fs.existsSync(path.join(cur, '.stitch', 'project.json')))
|
|
556
|
+
return cur;
|
|
557
|
+
const next = path.dirname(cur);
|
|
558
|
+
if (next === cur)
|
|
559
|
+
break;
|
|
560
|
+
cur = next;
|
|
561
|
+
}
|
|
562
|
+
return null;
|
|
559
563
|
}
|
|
560
564
|
function lastAssistantTextFromTranscript(transcriptPath) {
|
|
561
565
|
if (!transcriptPath || !fs.existsSync(transcriptPath))
|
|
@@ -600,6 +604,310 @@ function lastAssistantTextFromTranscript(transcriptPath) {
|
|
|
600
604
|
catch { /* ignore */ }
|
|
601
605
|
return text;
|
|
602
606
|
}
|
|
607
|
+
// ── stitch link — pin a project's thread name so it's the same on every machine
|
|
608
|
+
async function cmdLink(args) {
|
|
609
|
+
const positionals = positional(args);
|
|
610
|
+
const explicit = positionals[0];
|
|
611
|
+
const name = (explicit || path.basename(process.cwd()) || 'default').trim();
|
|
612
|
+
const dir = path.join(process.cwd(), '.stitch');
|
|
613
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
614
|
+
const file = path.join(dir, 'project.json');
|
|
615
|
+
let cfg = {};
|
|
616
|
+
try {
|
|
617
|
+
cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
618
|
+
}
|
|
619
|
+
catch { }
|
|
620
|
+
cfg.thread = name;
|
|
621
|
+
cfg.linked_at = new Date().toISOString();
|
|
622
|
+
fs.writeFileSync(file, JSON.stringify(cfg, null, 2));
|
|
623
|
+
console.log(`Project pinned to thread "${name}"`);
|
|
624
|
+
console.log(` → ${file}`);
|
|
625
|
+
console.log();
|
|
626
|
+
console.log('Commit `.stitch/project.json` to your repo (or sync the file across');
|
|
627
|
+
console.log('machines) so every machine pinning this project uses the same thread.');
|
|
628
|
+
}
|
|
629
|
+
// ── stitch distill — extract durable facts from recent conversation ─────────
|
|
630
|
+
//
|
|
631
|
+
// Uses the user's own `claude -p` to run a strict-JSON extraction prompt over
|
|
632
|
+
// the last N turns of the project's thread. Each extracted fact becomes a
|
|
633
|
+
// Stitch memory tagged `auto:true` so the user can review or wipe them later.
|
|
634
|
+
//
|
|
635
|
+
// Triggered manually (`stitch distill`), and automatically by the Stop hook
|
|
636
|
+
// when conditions are met (cooldown + new-turn threshold).
|
|
637
|
+
const DISTILL_STATE_FILE = path.join(CONFIG_DIR, 'distill-state.json');
|
|
638
|
+
const DISTILL_COOLDOWN_MS = 30 * 60 * 1000; // don't distill more than once per 30 min
|
|
639
|
+
const DISTILL_MIN_NEW_TURNS = 10; // need 10 new turns before bothering
|
|
640
|
+
const DISTILL_BATCH_SIZE = 30; // turns per distillation pass
|
|
641
|
+
function loadDistillState() {
|
|
642
|
+
try {
|
|
643
|
+
return JSON.parse(fs.readFileSync(DISTILL_STATE_FILE, 'utf8'));
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
return { threads: {} };
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
function saveDistillState(s) {
|
|
650
|
+
if (!fs.existsSync(CONFIG_DIR))
|
|
651
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
652
|
+
fs.writeFileSync(DISTILL_STATE_FILE, JSON.stringify(s, null, 2));
|
|
653
|
+
}
|
|
654
|
+
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.
|
|
655
|
+
|
|
656
|
+
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.
|
|
657
|
+
|
|
658
|
+
Format:
|
|
659
|
+
[{"kind":"fact|decision|snippet|preference","content":"...","tags":["short","keywords"]}, ...]
|
|
660
|
+
|
|
661
|
+
Capture:
|
|
662
|
+
- fact — anything concrete: file paths, endpoint URLs, commands, version
|
|
663
|
+
numbers, architecture, deployed services, schemas, dependencies,
|
|
664
|
+
bugs found, fixes shipped.
|
|
665
|
+
- decision — choices with rationale.
|
|
666
|
+
- snippet — reusable commands/config/code/CLI flags/env var names.
|
|
667
|
+
- preference — how the developer wants the AI to behave on this project.
|
|
668
|
+
|
|
669
|
+
Skip pleasantries and questions still being explored. One atomic idea per
|
|
670
|
+
memory — don't bundle. If truly nothing durable was discussed, output: [].
|
|
671
|
+
Output the JSON array and nothing else — no prose, no markdown fences.
|
|
672
|
+
|
|
673
|
+
Conversation:
|
|
674
|
+
`;
|
|
675
|
+
async function cmdDistill(args) {
|
|
676
|
+
if (hasFlag(args, ['--review']))
|
|
677
|
+
return distillReview(args);
|
|
678
|
+
if (hasFlag(args, ['--clear']))
|
|
679
|
+
return distillClear(args);
|
|
680
|
+
const cfg = loadConfig();
|
|
681
|
+
const stitch = client(cfg);
|
|
682
|
+
const explicitThread = parseFlag(args, ['--thread', '-t']);
|
|
683
|
+
const thread = explicitThread || inferThread();
|
|
684
|
+
const dryRun = hasFlag(args, ['--dry-run']);
|
|
685
|
+
const batchSize = Number(parseFlag(args, ['--n']) || String(DISTILL_BATCH_SIZE));
|
|
686
|
+
console.log(`Distilling last ${batchSize} turns of "${thread}"…`);
|
|
687
|
+
// Pull recent turns from the thread.
|
|
688
|
+
const recall = await stitch.thread(thread).recall({ last: batchSize });
|
|
689
|
+
if (!recall.thread_id || recall.recent.length === 0) {
|
|
690
|
+
console.log('No turns to distill yet.');
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
// Format conversation as plain text for the prompt.
|
|
694
|
+
const conversation = recall.recent
|
|
695
|
+
.map((t) => `[${t.role}] ${String(t.content || '').trim()}`)
|
|
696
|
+
.join('\n\n');
|
|
697
|
+
if (dryRun) {
|
|
698
|
+
console.log(`Would distill ${recall.recent.length} turns. Sample (first 200 chars):`);
|
|
699
|
+
console.log(conversation.slice(0, 200));
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const claudeBin = process.env.STITCH_CLAUDE_BIN || 'claude';
|
|
703
|
+
process.stdout.write(` → asking ${claudeBin} -p to extract facts… `);
|
|
704
|
+
const fullPrompt = DISTILL_PROMPT + conversation;
|
|
705
|
+
// Pipe the prompt via stdin: long conversations blow past ARG_MAX when
|
|
706
|
+
// passed as `-p <prompt>`, and stdin avoids claude interpreting any
|
|
707
|
+
// prompt-internal characters as flags. `claude -p` with no value reads
|
|
708
|
+
// from stdin. STITCH_HOOKS_DISABLED stops our own _hook command from
|
|
709
|
+
// logging this nested distill conversation as project thread turns.
|
|
710
|
+
const result = await runWithStdin(claudeBin, ['-p'], fullPrompt, {
|
|
711
|
+
cwd: process.cwd(),
|
|
712
|
+
env: { STITCH_HOOKS_DISABLED: '1' },
|
|
713
|
+
});
|
|
714
|
+
// Debug capture: STITCH_DEBUG_DISTILL=1 writes raw stdout/stderr/prompt
|
|
715
|
+
// to /tmp so we can see exactly what claude actually returned.
|
|
716
|
+
if (process.env.STITCH_DEBUG_DISTILL === '1') {
|
|
717
|
+
const ts = Date.now();
|
|
718
|
+
const dbgDir = '/tmp';
|
|
719
|
+
try {
|
|
720
|
+
fs.writeFileSync(path.join(dbgDir, `stitch-distill-${ts}.prompt.txt`), fullPrompt);
|
|
721
|
+
fs.writeFileSync(path.join(dbgDir, `stitch-distill-${ts}.stdout.txt`), result.stdout);
|
|
722
|
+
fs.writeFileSync(path.join(dbgDir, `stitch-distill-${ts}.stderr.txt`), result.stderr);
|
|
723
|
+
console.log(`\n [debug] wrote /tmp/stitch-distill-${ts}.{prompt,stdout,stderr}.txt`);
|
|
724
|
+
}
|
|
725
|
+
catch { /* ignore */ }
|
|
726
|
+
}
|
|
727
|
+
if (result.exit_code !== 0) {
|
|
728
|
+
console.log('failed');
|
|
729
|
+
console.error(result.stderr.trim().slice(0, 400));
|
|
730
|
+
process.exit(1);
|
|
731
|
+
}
|
|
732
|
+
const memories = parseDistillationOutput(result.stdout);
|
|
733
|
+
console.log(`extracted ${memories.length} memories`);
|
|
734
|
+
if (memories.length === 0) {
|
|
735
|
+
if (process.env.STITCH_DEBUG_DISTILL === '1') {
|
|
736
|
+
console.error(' [debug] claude stdout (first 600 chars):');
|
|
737
|
+
console.error(' ' + result.stdout.slice(0, 600).replace(/\n/g, '\n '));
|
|
738
|
+
}
|
|
739
|
+
bumpDistillCooldown(thread);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
// Push each to Stitch as a memory with auto:true tag.
|
|
743
|
+
const projectTag = thread.split('/')[0] || thread;
|
|
744
|
+
let saved = 0;
|
|
745
|
+
for (const m of memories) {
|
|
746
|
+
try {
|
|
747
|
+
const tags = ['auto', 'auto:distill', `thread:${thread}`, `project:${projectTag}`, ...(m.tags || [])];
|
|
748
|
+
await stitch.remember(m.content, { kind: m.kind, tags });
|
|
749
|
+
saved++;
|
|
750
|
+
}
|
|
751
|
+
catch (e) {
|
|
752
|
+
console.error(' ! failed to save:', e.message);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
console.log(` saved ${saved}/${memories.length} to Stitch`);
|
|
756
|
+
bumpDistillCooldown(thread);
|
|
757
|
+
}
|
|
758
|
+
function bumpDistillCooldown(thread) {
|
|
759
|
+
const state = loadDistillState();
|
|
760
|
+
state.threads[thread] = {
|
|
761
|
+
lastDistilledAt: Date.now(),
|
|
762
|
+
lastTurnAt: Date.now(),
|
|
763
|
+
lastTurnCount: state.threads[thread]?.lastTurnCount ?? 0,
|
|
764
|
+
};
|
|
765
|
+
saveDistillState(state);
|
|
766
|
+
}
|
|
767
|
+
function parseDistillationOutput(stdout) {
|
|
768
|
+
// claude -p often wraps the array in markdown fences, prepends prose like
|
|
769
|
+
// "Here are the extracted memories:", or follows it with a sign-off line.
|
|
770
|
+
// Strategy: try several extraction modes from most-precise to most-lenient,
|
|
771
|
+
// returning the first one that parses to a non-empty valid array.
|
|
772
|
+
const text = stdout.trim();
|
|
773
|
+
if (!text)
|
|
774
|
+
return [];
|
|
775
|
+
const candidates = [];
|
|
776
|
+
// 1. Fenced ```json block
|
|
777
|
+
const fenced = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/i);
|
|
778
|
+
if (fenced)
|
|
779
|
+
candidates.push(fenced[1].trim());
|
|
780
|
+
// 2. The first `[ {` … last `} ]` window (skips prose-level stray brackets).
|
|
781
|
+
const objStart = text.indexOf('[');
|
|
782
|
+
const objEnd = text.lastIndexOf(']');
|
|
783
|
+
if (objStart !== -1 && objEnd > objStart) {
|
|
784
|
+
candidates.push(text.slice(objStart, objEnd + 1));
|
|
785
|
+
}
|
|
786
|
+
// 3. Largest `[\n{ ... }\n]` block via balanced scan from each `[`
|
|
787
|
+
for (let i = 0; i < text.length; i++) {
|
|
788
|
+
if (text[i] !== '[')
|
|
789
|
+
continue;
|
|
790
|
+
let depth = 0;
|
|
791
|
+
let inStr = false;
|
|
792
|
+
let esc = false;
|
|
793
|
+
for (let j = i; j < text.length; j++) {
|
|
794
|
+
const ch = text[j];
|
|
795
|
+
if (esc) {
|
|
796
|
+
esc = false;
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
if (ch === '\\' && inStr) {
|
|
800
|
+
esc = true;
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
if (ch === '"') {
|
|
804
|
+
inStr = !inStr;
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
if (inStr)
|
|
808
|
+
continue;
|
|
809
|
+
if (ch === '[')
|
|
810
|
+
depth++;
|
|
811
|
+
else if (ch === ']') {
|
|
812
|
+
depth--;
|
|
813
|
+
if (depth === 0) {
|
|
814
|
+
candidates.push(text.slice(i, j + 1));
|
|
815
|
+
break;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
const seen = new Set();
|
|
821
|
+
for (const cand of candidates) {
|
|
822
|
+
if (seen.has(cand))
|
|
823
|
+
continue;
|
|
824
|
+
seen.add(cand);
|
|
825
|
+
try {
|
|
826
|
+
const parsed = JSON.parse(cand);
|
|
827
|
+
if (!Array.isArray(parsed))
|
|
828
|
+
continue;
|
|
829
|
+
const valid = parsed.filter((m) => m && typeof m === 'object'
|
|
830
|
+
&& typeof m.content === 'string' && m.content.length > 0
|
|
831
|
+
&& typeof m.kind === 'string' && ['fact', 'decision', 'snippet', 'preference', 'note'].includes(m.kind));
|
|
832
|
+
if (valid.length > 0)
|
|
833
|
+
return valid;
|
|
834
|
+
}
|
|
835
|
+
catch { /* try next candidate */ }
|
|
836
|
+
}
|
|
837
|
+
return [];
|
|
838
|
+
}
|
|
839
|
+
async function distillReview(args) {
|
|
840
|
+
const cfg = loadConfig();
|
|
841
|
+
const stitch = client(cfg);
|
|
842
|
+
const limit = Number(parseFlag(args, ['--limit']) || '20');
|
|
843
|
+
const all = await stitch.list({ limit: 200 });
|
|
844
|
+
const autos = all.filter((m) => m.tags.includes('auto'));
|
|
845
|
+
console.log(`${autos.length} auto-distilled memories (showing first ${limit}):\n`);
|
|
846
|
+
for (const m of autos.slice(0, limit)) {
|
|
847
|
+
const date = new Date(m.created_at).toISOString().slice(0, 19).replace('T', ' ');
|
|
848
|
+
console.log(` ${m.id} ${date} [${m.kind}]`);
|
|
849
|
+
console.log(` ${m.content.split('\n')[0].slice(0, 200)}`);
|
|
850
|
+
if (m.content.length > 200 || m.content.includes('\n'))
|
|
851
|
+
console.log(` ...`);
|
|
852
|
+
console.log();
|
|
853
|
+
}
|
|
854
|
+
if (autos.length > limit)
|
|
855
|
+
console.log(`(${autos.length - limit} more — use --limit to see)`);
|
|
856
|
+
}
|
|
857
|
+
async function distillClear(args) {
|
|
858
|
+
const cfg = loadConfig();
|
|
859
|
+
const stitch = client(cfg);
|
|
860
|
+
const all = await stitch.list({ limit: 500 });
|
|
861
|
+
const autos = all.filter((m) => m.tags.includes('auto'));
|
|
862
|
+
if (autos.length === 0) {
|
|
863
|
+
console.log('No auto-distilled memories to clear.');
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (!hasFlag(args, ['--yes', '-y'])) {
|
|
867
|
+
console.log(`Will soft-delete ${autos.length} auto-distilled memories. Re-run with --yes to confirm.`);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
let deleted = 0;
|
|
871
|
+
for (const m of autos) {
|
|
872
|
+
if (await stitch.forget(m.id))
|
|
873
|
+
deleted++;
|
|
874
|
+
}
|
|
875
|
+
console.log(`Cleared ${deleted} memories.`);
|
|
876
|
+
}
|
|
877
|
+
// Triggered from the Stop hook (fire-and-forget, never blocks the user).
|
|
878
|
+
async function maybeAutoDistill(thread) {
|
|
879
|
+
const state = loadDistillState();
|
|
880
|
+
const meta = state.threads[thread] || { lastDistilledAt: 0, lastTurnAt: 0, lastTurnCount: 0 };
|
|
881
|
+
// Cool-down: don't distill more than once per 30 min per thread.
|
|
882
|
+
if (Date.now() - meta.lastDistilledAt < DISTILL_COOLDOWN_MS)
|
|
883
|
+
return;
|
|
884
|
+
// Need at least N new turns since last pass.
|
|
885
|
+
const cfg = loadConfig();
|
|
886
|
+
const stitch = client(cfg);
|
|
887
|
+
let recallSize = 0;
|
|
888
|
+
try {
|
|
889
|
+
const r = await stitch.thread(thread).recall({ last: 200 });
|
|
890
|
+
recallSize = r.recent.length;
|
|
891
|
+
}
|
|
892
|
+
catch {
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (recallSize - meta.lastTurnCount < DISTILL_MIN_NEW_TURNS)
|
|
896
|
+
return;
|
|
897
|
+
// Mark BEFORE running so we don't double-fire on overlapping Stop events.
|
|
898
|
+
state.threads[thread] = { lastDistilledAt: Date.now(), lastTurnAt: Date.now(), lastTurnCount: recallSize };
|
|
899
|
+
saveDistillState(state);
|
|
900
|
+
// Detach: spawn a background process so the Stop hook returns immediately.
|
|
901
|
+
// The detached child runs `stitch distill` for this thread.
|
|
902
|
+
try {
|
|
903
|
+
const child = spawn(process.argv[0], [process.argv[1] || (await import('node:url')).fileURLToPath(import.meta.url), 'distill', '--thread', thread, '--n', String(DISTILL_BATCH_SIZE)], {
|
|
904
|
+
detached: true,
|
|
905
|
+
stdio: 'ignore',
|
|
906
|
+
});
|
|
907
|
+
child.unref();
|
|
908
|
+
}
|
|
909
|
+
catch { /* ignore */ }
|
|
910
|
+
}
|
|
603
911
|
// ── Sync Claude's local memory dir into Stitch ────────────────────────────
|
|
604
912
|
// Claude Code keeps per-project memory at ~/.claude/projects/<encoded>/memory/
|
|
605
913
|
// Each file is a markdown memory with optional YAML frontmatter (name,
|
|
@@ -1156,6 +1464,22 @@ function run(cmd, args, opts = {}) {
|
|
|
1156
1464
|
child.on('close', (code) => resolve({ stdout, stderr, exit_code: code ?? 0 }));
|
|
1157
1465
|
});
|
|
1158
1466
|
}
|
|
1467
|
+
function runWithStdin(cmd, args, stdinData, opts = {}) {
|
|
1468
|
+
return new Promise((resolve) => {
|
|
1469
|
+
const child = spawn(cmd, args, {
|
|
1470
|
+
cwd: opts.cwd || process.cwd(),
|
|
1471
|
+
env: { ...process.env, ...(opts.env || {}) },
|
|
1472
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1473
|
+
});
|
|
1474
|
+
let stdout = '';
|
|
1475
|
+
let stderr = '';
|
|
1476
|
+
child.stdout.on('data', (d) => stdout += d.toString());
|
|
1477
|
+
child.stderr.on('data', (d) => stderr += d.toString());
|
|
1478
|
+
child.on('error', (err) => resolve({ stdout, stderr: stderr + '\n[spawn error] ' + err.message, exit_code: 127 }));
|
|
1479
|
+
child.on('close', (code) => resolve({ stdout, stderr, exit_code: code ?? 0 }));
|
|
1480
|
+
child.stdin.end(stdinData);
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1159
1483
|
function readLine() {
|
|
1160
1484
|
return new Promise((resolve) => {
|
|
1161
1485
|
const onData = (d) => { process.stdin.off('data', onData); resolve(d.toString().trim()); };
|
|
@@ -1178,6 +1502,24 @@ function help() {
|
|
|
1178
1502
|
stitch whoami Show the configured key.
|
|
1179
1503
|
stitch logout
|
|
1180
1504
|
|
|
1505
|
+
stitch link [name] Pin this project to a canonical
|
|
1506
|
+
thread name. Writes
|
|
1507
|
+
.stitch/project.json — commit it
|
|
1508
|
+
(or sync the file across machines)
|
|
1509
|
+
so every machine that pins this
|
|
1510
|
+
project shares the same memory.
|
|
1511
|
+
|
|
1512
|
+
stitch distill [--thread <t>] [--n 30] [--dry-run]
|
|
1513
|
+
Read the last N turns of a thread
|
|
1514
|
+
and have your local Claude Code
|
|
1515
|
+
extract durable facts/decisions
|
|
1516
|
+
into Stitch memories. Tagged
|
|
1517
|
+
auto so they can be reviewed.
|
|
1518
|
+
stitch distill --review [--limit 20] Show recent auto-distilled
|
|
1519
|
+
memories for inspection.
|
|
1520
|
+
stitch distill --clear --yes Soft-delete every auto-distilled
|
|
1521
|
+
memory (recoverable 30 days).
|
|
1522
|
+
|
|
1181
1523
|
stitch sync [--watch] [--dry-run] Mirror ~/.claude/projects/*/memory/
|
|
1182
1524
|
files into Stitch as memories.
|
|
1183
1525
|
--watch keeps running; otherwise it's
|
|
@@ -1225,6 +1567,8 @@ async function main(argv) {
|
|
|
1225
1567
|
case 'thread': return cmdThread(rest);
|
|
1226
1568
|
case 'install': return cmdInstall(rest);
|
|
1227
1569
|
case 'sync': return cmdSync(rest);
|
|
1570
|
+
case 'link': return cmdLink(rest);
|
|
1571
|
+
case 'distill': return cmdDistill(rest);
|
|
1228
1572
|
case '_hook': return cmdHook(rest);
|
|
1229
1573
|
case 'update':
|
|
1230
1574
|
case 'upgrade': return cmdUpdate(rest);
|