@stitchdb/cli 0.7.3 → 0.8.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 +222 -52
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -84,12 +84,35 @@ function client(cfg) {
|
|
|
84
84
|
console.error('Not logged in. Run: stitch login');
|
|
85
85
|
process.exit(2);
|
|
86
86
|
}
|
|
87
|
+
// Per-project workspace pin: .stitch/project.json's workspace_id wins over
|
|
88
|
+
// the global default. Lets multiple projects share one machine without
|
|
89
|
+
// their memory pools polluting each other.
|
|
90
|
+
const projectWorkspace = readProjectWorkspaceId(process.cwd());
|
|
87
91
|
return new Stitch({
|
|
88
92
|
apiKey: cfg.apiKey,
|
|
89
93
|
baseUrl: cfg.baseUrl,
|
|
90
|
-
workspace: cfg.defaultWorkspace,
|
|
94
|
+
workspace: projectWorkspace || cfg.defaultWorkspace,
|
|
91
95
|
});
|
|
92
96
|
}
|
|
97
|
+
function readProjectWorkspaceId(cwd) {
|
|
98
|
+
let cur = cwd;
|
|
99
|
+
for (let i = 0; i < 8; i++) {
|
|
100
|
+
const projectFile = path.join(cur, '.stitch', 'project.json');
|
|
101
|
+
if (fs.existsSync(projectFile)) {
|
|
102
|
+
try {
|
|
103
|
+
const pj = JSON.parse(fs.readFileSync(projectFile, 'utf8'));
|
|
104
|
+
if (typeof pj.workspace_id === 'string' && pj.workspace_id.startsWith('wsp_'))
|
|
105
|
+
return pj.workspace_id;
|
|
106
|
+
}
|
|
107
|
+
catch { /* fall through */ }
|
|
108
|
+
}
|
|
109
|
+
const next = path.dirname(cur);
|
|
110
|
+
if (next === cur)
|
|
111
|
+
break;
|
|
112
|
+
cur = next;
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
93
116
|
// ─── Argv helpers ──────────────────────────────────────────────────────────
|
|
94
117
|
function parseFlag(args, names) {
|
|
95
118
|
for (const name of names) {
|
|
@@ -222,6 +245,9 @@ async function cmdWorkspace(args) {
|
|
|
222
245
|
const w = await stitch.workspaces.update(id, { name });
|
|
223
246
|
console.log(`Renamed ${w.id} → ${w.name}`);
|
|
224
247
|
}
|
|
248
|
+
else if (sub === 'migrate-by-project') {
|
|
249
|
+
return cmdWorkspaceMigrate(rest);
|
|
250
|
+
}
|
|
225
251
|
else if (sub === 'delete') {
|
|
226
252
|
const id = positional(rest)[0];
|
|
227
253
|
if (!id) {
|
|
@@ -375,6 +401,93 @@ async function cmdAgent(args) {
|
|
|
375
401
|
console.error('Usage: stitch agent [register|list|run|dispatch|revoke] …');
|
|
376
402
|
process.exit(2);
|
|
377
403
|
}
|
|
404
|
+
// ── Workspace migration: split a pooled workspace into per-project ones ───
|
|
405
|
+
// Reads every memory in the source workspace, groups by `project:*` tag,
|
|
406
|
+
// and copies each group into a workspace named after the project (creating
|
|
407
|
+
// it if missing), then soft-deletes the source rows. Memories with no
|
|
408
|
+
// `project:*` tag stay put. Idempotent re-runs are safe.
|
|
409
|
+
async function cmdWorkspaceMigrate(args) {
|
|
410
|
+
const cfg = loadConfig();
|
|
411
|
+
if (!cfg.apiKey) {
|
|
412
|
+
console.error('Run `stitch login` first.');
|
|
413
|
+
process.exit(2);
|
|
414
|
+
}
|
|
415
|
+
const baseUrl = cfg.baseUrl || 'https://db.stitchdb.com';
|
|
416
|
+
const dryRun = hasFlag(args, ['--dry-run']);
|
|
417
|
+
const stitch = new Stitch({ apiKey: cfg.apiKey, baseUrl });
|
|
418
|
+
const sourceId = parseFlag(args, ['--from']) || (await stitch.workspaces.list())[0]?.id;
|
|
419
|
+
if (!sourceId) {
|
|
420
|
+
console.error('No source workspace found.');
|
|
421
|
+
process.exit(1);
|
|
422
|
+
}
|
|
423
|
+
const source = new Stitch({ apiKey: cfg.apiKey, baseUrl, workspace: sourceId });
|
|
424
|
+
console.log(`Reading memories from ${sourceId}…`);
|
|
425
|
+
const all = [];
|
|
426
|
+
let offset = 0;
|
|
427
|
+
while (true) {
|
|
428
|
+
const batch = await source.list({ limit: 100, offset });
|
|
429
|
+
if (batch.length === 0)
|
|
430
|
+
break;
|
|
431
|
+
all.push(...batch);
|
|
432
|
+
if (batch.length < 100)
|
|
433
|
+
break;
|
|
434
|
+
offset += batch.length;
|
|
435
|
+
}
|
|
436
|
+
console.log(`Found ${all.length} memories.`);
|
|
437
|
+
const byProject = {};
|
|
438
|
+
let untagged = 0;
|
|
439
|
+
for (const m of all) {
|
|
440
|
+
const tag = m.tags.find((t) => t.startsWith('project:'));
|
|
441
|
+
if (!tag) {
|
|
442
|
+
untagged++;
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
const proj = tag.slice('project:'.length).trim();
|
|
446
|
+
if (!proj) {
|
|
447
|
+
untagged++;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const slug = slugify(proj);
|
|
451
|
+
(byProject[slug] ??= []).push(m);
|
|
452
|
+
}
|
|
453
|
+
console.log(` ${untagged} untagged → staying in source`);
|
|
454
|
+
for (const [slug, mems] of Object.entries(byProject)) {
|
|
455
|
+
console.log(` ${slug.padEnd(30)} ${mems.length} memories`);
|
|
456
|
+
}
|
|
457
|
+
if (dryRun) {
|
|
458
|
+
console.log('Dry run — nothing written.');
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const allWs = await stitch.workspaces.list();
|
|
462
|
+
let totalMoved = 0;
|
|
463
|
+
for (const [slug, mems] of Object.entries(byProject)) {
|
|
464
|
+
let target = allWs.find((w) => w.name === slug);
|
|
465
|
+
if (!target) {
|
|
466
|
+
target = await stitch.workspaces.create(slug);
|
|
467
|
+
console.log(` + created workspace "${slug}" (${target.id})`);
|
|
468
|
+
allWs.push(target);
|
|
469
|
+
}
|
|
470
|
+
if (target.id === sourceId) {
|
|
471
|
+
console.log(` · ${slug}: target = source — keeping in place`);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
const targetClient = new Stitch({ apiKey: cfg.apiKey, baseUrl, workspace: target.id });
|
|
475
|
+
let moved = 0;
|
|
476
|
+
for (const m of mems) {
|
|
477
|
+
try {
|
|
478
|
+
await targetClient.remember(m.content, { kind: m.kind, tags: m.tags, metadata: m.metadata });
|
|
479
|
+
await source.forget(m.id);
|
|
480
|
+
moved++;
|
|
481
|
+
}
|
|
482
|
+
catch (e) {
|
|
483
|
+
console.error(` ! ${m.id}: ${e?.message || e}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
totalMoved += moved;
|
|
487
|
+
console.log(` → ${slug}: moved ${moved}/${mems.length}`);
|
|
488
|
+
}
|
|
489
|
+
console.log(`Migration complete. ${totalMoved} memories moved across ${Object.keys(byProject).length} workspaces.`);
|
|
490
|
+
}
|
|
378
491
|
// ── Threads — append / recall / current ───────────────────────────────────
|
|
379
492
|
function inferThread() {
|
|
380
493
|
// Single source of truth: `.stitch/project.json` if present, else cwd basename.
|
|
@@ -490,9 +603,11 @@ async function cmdHook(args) {
|
|
|
490
603
|
await handlePreReadHook(cfg, event, cwd || process.cwd()).catch(() => { });
|
|
491
604
|
return;
|
|
492
605
|
}
|
|
493
|
-
// ── PostToolUse on Read:
|
|
494
|
-
|
|
495
|
-
|
|
606
|
+
// ── PostToolUse on Read/Edit/Write/MultiEdit: keep the file-summary
|
|
607
|
+
// cache fresh. After an edit we re-hash the on-disk file and
|
|
608
|
+
// re-summarise, so a stale "old version" memory never lingers.
|
|
609
|
+
if (eventName === 'PostToolUse' && ['Read', 'Edit', 'Write', 'MultiEdit'].includes(String(event?.tool_name))) {
|
|
610
|
+
await handlePostFileChangeHook(cfg, event, cwd || process.cwd()).catch(() => { });
|
|
496
611
|
return;
|
|
497
612
|
}
|
|
498
613
|
// ── SessionStart: self-heal hook config + inject prior context ─────────
|
|
@@ -735,13 +850,14 @@ function lastAssistantTextFromTranscript(transcriptPath) {
|
|
|
735
850
|
const FILE_SUMMARY_MIN_BYTES = 500; // skip tiny files
|
|
736
851
|
const FILE_SUMMARY_MAX_BYTES = 200_000; // skip absolutely huge files
|
|
737
852
|
const FILE_SUMMARY_COOLDOWN_MS = 5 * 60_000; // per-file: don't re-summarize more than once per 5 min
|
|
738
|
-
const FILE_SUMMARY_PROMPT = `You are summarising a single source file for an AI coding assistant cache.
|
|
853
|
+
const FILE_SUMMARY_PROMPT = `You are summarising a single source file for an AI coding assistant's persistent project cache. Other agents will read this summary before deciding whether to open the file in full, so it must enable a real mental model of the project — not just describe the file in isolation.
|
|
739
854
|
|
|
740
|
-
Return ONLY a 2-
|
|
741
|
-
|
|
742
|
-
|
|
855
|
+
Return ONLY a 2-4 sentence summary that covers:
|
|
856
|
+
1. PURPOSE — what this file does, its key exported symbols / routes / commands / classes worth knowing, any non-obvious invariant or pattern.
|
|
857
|
+
2. CONNECTIONS — concrete other files/modules this depends on (real import paths, real symbol names) AND who/what typically depends on this. Use real names from the file content; don't generalise.
|
|
858
|
+
3. WHEN TO OPEN — what kind of task forces an edit here vs a peek. (e.g. "edit this when adding a new auth provider; safe to skip for purely UI changes").
|
|
743
859
|
|
|
744
|
-
|
|
860
|
+
Be dense and specific. No preamble. No "this file ...". No markdown fences. No prose around it. Plain text only.
|
|
745
861
|
|
|
746
862
|
File path: {{PATH}}
|
|
747
863
|
|
|
@@ -820,47 +936,64 @@ async function handlePreReadHook(cfg, event, cwd) {
|
|
|
820
936
|
const payload = { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: note } };
|
|
821
937
|
process.stdout.write(JSON.stringify(payload));
|
|
822
938
|
}
|
|
823
|
-
// PostToolUse
|
|
824
|
-
//
|
|
825
|
-
|
|
939
|
+
// PostToolUse handler for tools that observe-or-modify a single file
|
|
940
|
+
// (Read, Edit, Write, MultiEdit). Kicks off a background `claude -p` to
|
|
941
|
+
// (re-)summarise this file unless we already have an up-to-date cached
|
|
942
|
+
// summary for this exact hash. Fire-and-forget.
|
|
943
|
+
//
|
|
944
|
+
// Read flow: file content same as cache → no-op. Different → re-summarise.
|
|
945
|
+
// Edit flow: file content always different from cache → re-summarise. The
|
|
946
|
+
// existing summary is replaced by cmdSummarizeFile so no stale
|
|
947
|
+
// memory persists.
|
|
948
|
+
// Write/MultiEdit: same as Edit.
|
|
949
|
+
//
|
|
950
|
+
// A 30 s "min-interval" floor on top of the hash-keyed cooldown prevents a
|
|
951
|
+
// flurry of edits within seconds from spawning N parallel claude -p calls
|
|
952
|
+
// for the same file.
|
|
953
|
+
async function handlePostFileChangeHook(cfg, event, cwd) {
|
|
826
954
|
const filePath = String(event?.tool_input?.file_path || '');
|
|
827
955
|
if (!filePath)
|
|
828
956
|
return;
|
|
829
957
|
const rel = relPathFor(cwd, filePath);
|
|
830
|
-
|
|
831
|
-
//
|
|
832
|
-
let body =
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
return;
|
|
839
|
-
}
|
|
958
|
+
// After Edit/Write the on-disk content is what we want; the hook payload
|
|
959
|
+
// may also carry it, but reading from disk is always correct.
|
|
960
|
+
let body = '';
|
|
961
|
+
try {
|
|
962
|
+
body = fs.readFileSync(filePath, 'utf8');
|
|
963
|
+
}
|
|
964
|
+
catch {
|
|
965
|
+
return;
|
|
840
966
|
}
|
|
841
|
-
if (!body
|
|
967
|
+
if (!body)
|
|
968
|
+
return;
|
|
969
|
+
// Tiny files: not worth a summary. Huge files: skip; would exhaust context.
|
|
970
|
+
if (body.length < FILE_SUMMARY_MIN_BYTES)
|
|
842
971
|
return;
|
|
843
972
|
if (body.length > FILE_SUMMARY_MAX_BYTES)
|
|
844
973
|
return;
|
|
845
974
|
const fullHash = await sha256Hex(body);
|
|
846
975
|
const hashPrefix = fullHash.slice(0, 16);
|
|
847
|
-
// Per-file cooldown so a hot-edited file doesn't re-summarize on every read.
|
|
848
976
|
const state = loadFileSummaryState();
|
|
849
977
|
const last = state.files[rel];
|
|
978
|
+
// Hash-keyed dedupe: same content as last spawn → nothing to do.
|
|
850
979
|
if (last && last.hash === hashPrefix && Date.now() - last.lastSummarizedAt < FILE_SUMMARY_COOLDOWN_MS)
|
|
851
980
|
return;
|
|
981
|
+
// Min-interval floor: regardless of hash change, don't spawn more often
|
|
982
|
+
// than once per 30 s for the same file (catches edit flurries).
|
|
983
|
+
const MIN_RESPAWN_MS = 30_000;
|
|
984
|
+
if (last && Date.now() - last.lastSummarizedAt < MIN_RESPAWN_MS)
|
|
985
|
+
return;
|
|
986
|
+
// Optimisation: confirm the API view is also stale before spending tokens.
|
|
852
987
|
const stitch = client(cfg);
|
|
853
988
|
const cached = await lookupFileSummary(stitch, rel);
|
|
854
989
|
if (cached && cached.hash === hashPrefix) {
|
|
855
|
-
// Already summarized this exact version — refresh cooldown and bail.
|
|
856
990
|
state.files[rel] = { hash: hashPrefix, lastSummarizedAt: Date.now() };
|
|
857
991
|
saveFileSummaryState(state);
|
|
858
992
|
return;
|
|
859
993
|
}
|
|
860
|
-
// Mark BEFORE spawning so
|
|
994
|
+
// Mark BEFORE spawning so overlapping events don't fire N spawns.
|
|
861
995
|
state.files[rel] = { hash: hashPrefix, lastSummarizedAt: Date.now() };
|
|
862
996
|
saveFileSummaryState(state);
|
|
863
|
-
// Spawn detached: stitch _summarize-file <abs-path> <hash> <rel>
|
|
864
997
|
try {
|
|
865
998
|
const cliPath = process.argv[1] || (await import('node:url')).fileURLToPath(import.meta.url);
|
|
866
999
|
const child = spawn(process.argv[0], [cliPath, '_summarize-file', filePath, hashPrefix, rel], {
|
|
@@ -1526,39 +1659,68 @@ async function cmdInstall(args) {
|
|
|
1526
1659
|
console.error('Run `stitch login` first.');
|
|
1527
1660
|
process.exit(2);
|
|
1528
1661
|
}
|
|
1529
|
-
const stitch = client(cfg);
|
|
1530
|
-
const ws = await stitch.resolveWorkspace();
|
|
1531
1662
|
const baseUrl = cfg.baseUrl || 'https://db.stitchdb.com';
|
|
1532
|
-
|
|
1533
|
-
//
|
|
1534
|
-
//
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
}
|
|
1663
|
+
// Use a "raw" SDK client (no project pin) for the bootstrap so we can list
|
|
1664
|
+
// and create workspaces freely. After this we write .stitch/project.json
|
|
1665
|
+
// and subsequent commands auto-route through the per-project workspace.
|
|
1666
|
+
const rawStitch = new Stitch({ apiKey: cfg.apiKey, baseUrl });
|
|
1667
|
+
// Step 0 — resolve the per-project workspace ─────────────────────────────
|
|
1668
|
+
const projectName = deriveWorkspaceName(process.cwd());
|
|
1669
|
+
process.stdout.write(`• Resolving workspace "${projectName}" for this project… `);
|
|
1670
|
+
let ws;
|
|
1671
|
+
try {
|
|
1672
|
+
const list = await rawStitch.workspaces.list();
|
|
1673
|
+
let target = list.find((w) => w.name === projectName);
|
|
1674
|
+
if (!target) {
|
|
1675
|
+
target = await rawStitch.workspaces.create(projectName);
|
|
1676
|
+
console.log(`created (${target.id})`);
|
|
1547
1677
|
}
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
console.log(`• Workspace rename skipped (${e?.message || e}).`);
|
|
1678
|
+
else {
|
|
1679
|
+
console.log(`found (${target.id})`);
|
|
1551
1680
|
}
|
|
1681
|
+
ws = target.id;
|
|
1552
1682
|
}
|
|
1683
|
+
catch (e) {
|
|
1684
|
+
console.log(`failed (${e?.message || e})`);
|
|
1685
|
+
process.exit(1);
|
|
1686
|
+
}
|
|
1687
|
+
// Step 0.5 — write .stitch/project.json so every CLI invocation in this
|
|
1688
|
+
// tree (incl. hooks) routes to the right workspace automatically.
|
|
1689
|
+
try {
|
|
1690
|
+
const dir = path.join(process.cwd(), '.stitch');
|
|
1691
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1692
|
+
const file = path.join(dir, 'project.json');
|
|
1693
|
+
let pj = {};
|
|
1694
|
+
try {
|
|
1695
|
+
pj = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1696
|
+
}
|
|
1697
|
+
catch { }
|
|
1698
|
+
pj.thread = pj.thread || projectName;
|
|
1699
|
+
pj.workspace_id = ws;
|
|
1700
|
+
pj.workspace_name = projectName;
|
|
1701
|
+
pj.linked_at = pj.linked_at || new Date().toISOString();
|
|
1702
|
+
fs.writeFileSync(file, JSON.stringify(pj, null, 2));
|
|
1703
|
+
console.log(`• Pinned project to workspace "${projectName}" (${file})`);
|
|
1704
|
+
}
|
|
1705
|
+
catch (e) {
|
|
1706
|
+
console.log(`• .stitch/project.json write skipped (${e?.message || e})`);
|
|
1707
|
+
}
|
|
1708
|
+
const mcpUrl = `${baseUrl}/mcp/v1/${ws}`;
|
|
1553
1709
|
const noMcp = hasFlag(args, ['--no-mcp']);
|
|
1554
1710
|
const noHooks = hasFlag(args, ['--no-hooks']);
|
|
1555
1711
|
const noClaudeMd = hasFlag(args, ['--no-claude-md']);
|
|
1556
|
-
// 1. claude mcp add
|
|
1712
|
+
// 1. claude mcp add — LOCAL scope (per-user-per-project) so each project's
|
|
1713
|
+
// Claude session loads only its own workspace's tools, not every project's.
|
|
1714
|
+
// If a stale entry exists with a different URL we replace it, since this
|
|
1715
|
+
// project's workspace_id may have changed (e.g. cloned to a new account).
|
|
1557
1716
|
if (!noMcp) {
|
|
1558
1717
|
const claudePath = process.env.STITCH_CLAUDE_BIN || 'claude';
|
|
1559
|
-
process.stdout.write('•
|
|
1718
|
+
process.stdout.write('• Registering Stitch MCP for this project (local scope)… ');
|
|
1719
|
+
// Best-effort remove of any prior local-scope `stitch` entry; ignore errors
|
|
1720
|
+
// (it's fine if there wasn't one).
|
|
1721
|
+
await runSilent(claudePath, ['mcp', 'remove', '--scope', 'local', 'stitch']).catch(() => { });
|
|
1560
1722
|
const { exit_code, stderr } = await runSilent(claudePath, [
|
|
1561
|
-
'mcp', 'add', '--transport', 'http', '--scope', '
|
|
1723
|
+
'mcp', 'add', '--transport', 'http', '--scope', 'local', 'stitch', mcpUrl,
|
|
1562
1724
|
'-H', `Authorization: Bearer ${cfg.apiKey}`,
|
|
1563
1725
|
]);
|
|
1564
1726
|
if (exit_code === 0)
|
|
@@ -1688,13 +1850,17 @@ const STITCH_SESSION_START_HOOK = {
|
|
|
1688
1850
|
matcher: '*',
|
|
1689
1851
|
hooks: [{ type: 'command', command: 'stitch _hook SessionStart' }],
|
|
1690
1852
|
};
|
|
1691
|
-
// File summary cache:
|
|
1853
|
+
// File summary cache:
|
|
1854
|
+
// • PreToolUse fires only on Read (cached summary surfaces before the read).
|
|
1855
|
+
// • PostToolUse fires on Read, Edit, Write, MultiEdit — Edit/Write/MultiEdit
|
|
1856
|
+
// invalidate any prior summary so a stale "old version" memory never
|
|
1857
|
+
// lingers. The matcher is a regex (Claude Code accepts pipe alternation).
|
|
1692
1858
|
const STITCH_PRE_READ_HOOK = {
|
|
1693
1859
|
matcher: 'Read',
|
|
1694
1860
|
hooks: [{ type: 'command', command: 'stitch _hook' }],
|
|
1695
1861
|
};
|
|
1696
1862
|
const STITCH_POST_READ_HOOK = {
|
|
1697
|
-
matcher: 'Read',
|
|
1863
|
+
matcher: 'Read|Edit|Write|MultiEdit',
|
|
1698
1864
|
hooks: [{ type: 'command', command: 'stitch _hook' }],
|
|
1699
1865
|
};
|
|
1700
1866
|
function mergeHook(existing, entry) {
|
|
@@ -1968,6 +2134,10 @@ function help() {
|
|
|
1968
2134
|
for the current repo / cwd.
|
|
1969
2135
|
|
|
1970
2136
|
stitch workspace [list | create <name> | use <id> | rename <id> <name> | delete <id> --yes]
|
|
2137
|
+
stitch workspace migrate-by-project [--from <id>] [--dry-run]
|
|
2138
|
+
Split memories from one pooled
|
|
2139
|
+
workspace into per-project
|
|
2140
|
+
workspaces by their project:* tag.
|
|
1971
2141
|
|
|
1972
2142
|
stitch agent register <name> Create an agent identity (id only).
|
|
1973
2143
|
stitch agent list
|