stashes 0.1.11 → 0.1.13

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 CHANGED
@@ -52,7 +52,7 @@ var DEFAULT_DIRECTIVES = [
52
52
  ];
53
53
  // ../server/dist/routes/api.js
54
54
  import { Hono } from "hono";
55
- import { join } from "path";
55
+ import { join, basename } from "path";
56
56
  import { existsSync, readFileSync } from "fs";
57
57
  var app = new Hono;
58
58
  app.get("/health", (c) => c.json({ status: "ok", service: "stashes" }));
@@ -84,14 +84,54 @@ app.get("/projects/:id", (c) => {
84
84
  if (!project)
85
85
  return c.json({ error: "Project not found" }, 404);
86
86
  const stashes = persistence.listStashes(project.id);
87
- const chat = persistence.getChatHistory(project.id);
88
- return c.json({ data: { ...project, stashes, chat } });
87
+ const chats = persistence.listChats(project.id);
88
+ return c.json({ data: { ...project, stashes, chats } });
89
89
  });
90
90
  app.delete("/projects/:id", (c) => {
91
91
  const id = c.req.param("id");
92
92
  getPersistence().deleteProject(id);
93
93
  return c.json({ data: { deleted: id } });
94
94
  });
95
+ app.get("/chats", (c) => {
96
+ const persistence = getPersistence();
97
+ const project = ensureProject(persistence);
98
+ const chats = persistence.listChats(project.id);
99
+ const stashes = persistence.listStashes(project.id);
100
+ return c.json({ data: { project, chats, stashCount: stashes.length } });
101
+ });
102
+ app.post("/chats", async (c) => {
103
+ const persistence = getPersistence();
104
+ const project = ensureProject(persistence);
105
+ const { title } = await c.req.json();
106
+ const chatCount = persistence.listChats(project.id).length;
107
+ const chat = {
108
+ id: `chat_${crypto.randomUUID().substring(0, 8)}`,
109
+ projectId: project.id,
110
+ title: title?.trim() || `Chat ${chatCount + 1}`,
111
+ createdAt: new Date().toISOString(),
112
+ updatedAt: new Date().toISOString()
113
+ };
114
+ persistence.saveChat(chat);
115
+ return c.json({ data: chat }, 201);
116
+ });
117
+ app.get("/chats/:chatId", (c) => {
118
+ const persistence = getPersistence();
119
+ const project = ensureProject(persistence);
120
+ const chatId = c.req.param("chatId");
121
+ const chat = persistence.getChat(project.id, chatId);
122
+ if (!chat)
123
+ return c.json({ error: "Chat not found" }, 404);
124
+ const messages = persistence.getChatMessages(project.id, chatId);
125
+ const stashes = persistence.listStashes(project.id).filter((s) => s.originChatId === chatId);
126
+ return c.json({ data: { ...chat, messages, stashes } });
127
+ });
128
+ app.delete("/chats/:chatId", (c) => {
129
+ const persistence = getPersistence();
130
+ const project = ensureProject(persistence);
131
+ const chatId = c.req.param("chatId");
132
+ persistence.deleteChat(project.id, chatId);
133
+ return c.json({ data: { deleted: chatId } });
134
+ });
95
135
  app.get("/screenshots/:filename", (c) => {
96
136
  const filename = c.req.param("filename");
97
137
  const filePath = join(serverState.projectPath, ".stashes", "screenshots", filename);
@@ -102,6 +142,20 @@ app.get("/screenshots/:filename", (c) => {
102
142
  headers: { "content-type": "image/png", "cache-control": "no-cache" }
103
143
  });
104
144
  });
145
+ function ensureProject(persistence) {
146
+ const projects = persistence.listProjects();
147
+ if (projects.length > 0)
148
+ return projects[0];
149
+ const project = {
150
+ id: `proj_${crypto.randomUUID().substring(0, 8)}`,
151
+ name: basename(serverState.projectPath),
152
+ createdAt: new Date().toISOString(),
153
+ updatedAt: new Date().toISOString()
154
+ };
155
+ persistence.saveProject(project);
156
+ persistence.migrateOldChat(project.id);
157
+ return project;
158
+ }
105
159
  var apiRoutes = app;
106
160
 
107
161
  // ../core/dist/generation.js
@@ -411,7 +465,7 @@ class WorktreeManager {
411
465
  }
412
466
 
413
467
  // ../core/dist/persistence.js
414
- import { readFileSync as readFileSync2, writeFileSync, mkdirSync as mkdirSync2, existsSync as existsSync4, rmSync as rmSync2 } from "fs";
468
+ import { readFileSync as readFileSync2, writeFileSync, mkdirSync as mkdirSync2, existsSync as existsSync4, rmSync as rmSync2, readdirSync } from "fs";
415
469
  import { join as join4, dirname } from "path";
416
470
  var STASHES_DIR = ".stashes";
417
471
  function ensureDir(dirPath) {
@@ -488,14 +542,64 @@ class PersistenceService {
488
542
  const filePath = join4(this.basePath, "projects", projectId, "stashes.json");
489
543
  writeJson(filePath, stashes);
490
544
  }
491
- getChatHistory(projectId) {
492
- const filePath = join4(this.basePath, "projects", projectId, "chat.json");
493
- return readJson(filePath, []);
494
- }
495
- saveChatMessage(projectId, message) {
496
- const messages = [...this.getChatHistory(projectId), message];
497
- const filePath = join4(this.basePath, "projects", projectId, "chat.json");
498
- writeJson(filePath, messages);
545
+ listChats(projectId) {
546
+ const dir = join4(this.basePath, "projects", projectId, "chats");
547
+ if (!existsSync4(dir))
548
+ return [];
549
+ const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
550
+ return files.map((f) => {
551
+ const data = readJson(join4(dir, f), null);
552
+ return data?.chat;
553
+ }).filter(Boolean).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
554
+ }
555
+ getChat(projectId, chatId) {
556
+ const filePath = join4(this.basePath, "projects", projectId, "chats", `${chatId}.json`);
557
+ const data = readJson(filePath, null);
558
+ return data?.chat;
559
+ }
560
+ saveChat(chat) {
561
+ const filePath = join4(this.basePath, "projects", chat.projectId, "chats", `${chat.id}.json`);
562
+ const existing = readJson(filePath, { chat, messages: [] });
563
+ writeJson(filePath, { ...existing, chat });
564
+ }
565
+ deleteChat(projectId, chatId) {
566
+ const filePath = join4(this.basePath, "projects", projectId, "chats", `${chatId}.json`);
567
+ if (existsSync4(filePath)) {
568
+ rmSync2(filePath);
569
+ }
570
+ }
571
+ getChatMessages(projectId, chatId) {
572
+ const filePath = join4(this.basePath, "projects", projectId, "chats", `${chatId}.json`);
573
+ const data = readJson(filePath, { messages: [] });
574
+ return data.messages;
575
+ }
576
+ saveChatMessage(projectId, chatId, message) {
577
+ const filePath = join4(this.basePath, "projects", projectId, "chats", `${chatId}.json`);
578
+ const data = readJson(filePath, { chat: this.getChat(projectId, chatId), messages: [] });
579
+ writeJson(filePath, { ...data, messages: [...data.messages, message] });
580
+ }
581
+ migrateOldChat(projectId) {
582
+ const oldPath = join4(this.basePath, "projects", projectId, "chat.json");
583
+ if (!existsSync4(oldPath))
584
+ return null;
585
+ const messages = readJson(oldPath, []);
586
+ if (messages.length === 0) {
587
+ rmSync2(oldPath);
588
+ return null;
589
+ }
590
+ const chatId = `chat_${crypto.randomUUID().substring(0, 8)}`;
591
+ const chat = {
592
+ id: chatId,
593
+ projectId,
594
+ title: "Initial conversation",
595
+ createdAt: messages[0].createdAt,
596
+ updatedAt: messages[messages.length - 1].createdAt
597
+ };
598
+ const filePath = join4(this.basePath, "projects", projectId, "chats", `${chatId}.json`);
599
+ writeJson(filePath, { chat, messages });
600
+ rmSync2(oldPath);
601
+ logger.info("persistence", `migrated old chat.json \u2192 ${chatId}`);
602
+ return chatId;
499
603
  }
500
604
  ensureGitignore(projectPath) {
501
605
  const gitignorePath = join4(projectPath, ".gitignore");
@@ -721,7 +825,7 @@ async function allocatePort() {
721
825
  throw new Error("No available ports in range 4010-4030");
722
826
  }
723
827
  async function generate(opts) {
724
- const { projectPath, projectId, prompt, component, count = DEFAULT_STASH_COUNT, directives = DEFAULT_DIRECTIVES, onProgress } = opts;
828
+ const { projectPath, projectId, chatId, prompt, component, count = DEFAULT_STASH_COUNT, directives = DEFAULT_DIRECTIVES, onProgress } = opts;
725
829
  const worktreeManager = new WorktreeManager(projectPath);
726
830
  const persistence = new PersistenceService(projectPath);
727
831
  const selectedDirectives = directives.slice(0, count);
@@ -733,12 +837,17 @@ async function generate(opts) {
733
837
  }
734
838
  }
735
839
  const completedStashes = [];
736
- const stashPromises = selectedDirectives.map(async (directive) => {
840
+ const existingStashes = persistence.listStashes(projectId);
841
+ const maxNumber = existingStashes.reduce((max, s) => Math.max(max, s.number ?? 0), 0);
842
+ const stashPromises = selectedDirectives.map(async (directive, idx) => {
737
843
  const stashId = `stash_${crypto.randomUUID().substring(0, 8)}`;
844
+ const stashNumber = maxNumber + idx + 1;
738
845
  const worktree = await worktreeManager.createForGeneration(stashId);
739
846
  const stash = {
740
847
  id: stashId,
848
+ number: stashNumber,
741
849
  projectId,
850
+ originChatId: chatId,
742
851
  prompt,
743
852
  componentPath: component?.filePath,
744
853
  branch: worktree.branch,
@@ -751,7 +860,7 @@ async function generate(opts) {
751
860
  createdAt: new Date().toISOString()
752
861
  };
753
862
  persistence.saveStash(stash);
754
- emit(onProgress, { type: "generating", stashId });
863
+ emit(onProgress, { type: "generating", stashId, number: stashNumber });
755
864
  let stashPrompt;
756
865
  if (component?.filePath) {
757
866
  stashPrompt = buildStashPrompt({ name: component.exportName || component.filePath, filePath: component.filePath, domSelector: "" }, sourceCode, prompt, directive);
@@ -840,7 +949,7 @@ async function allocatePort2() {
840
949
  throw new Error("No available ports in range 4010-4030");
841
950
  }
842
951
  async function vary(opts) {
843
- const { projectPath, sourceStashId, prompt, onProgress } = opts;
952
+ const { projectPath, sourceStashId, chatId, prompt, onProgress } = opts;
844
953
  const persistence = new PersistenceService(projectPath);
845
954
  const worktreeManager = new WorktreeManager(projectPath);
846
955
  let sourceStash;
@@ -856,10 +965,14 @@ async function vary(opts) {
856
965
  if (!sourceStash)
857
966
  throw new Error(`Source stash ${sourceStashId} not found`);
858
967
  const stashId = `stash_${crypto.randomUUID().substring(0, 8)}`;
968
+ const existingStashes = persistence.listStashes(projectId);
969
+ const stashNumber = existingStashes.reduce((max, s) => Math.max(max, s.number ?? 0), 0) + 1;
859
970
  const worktree = await worktreeManager.createForVary(stashId, sourceStash.branch);
860
971
  const stash = {
861
972
  id: stashId,
973
+ number: stashNumber,
862
974
  projectId,
975
+ originChatId: chatId,
863
976
  prompt,
864
977
  componentPath: sourceStash.componentPath,
865
978
  branch: worktree.branch,
@@ -872,7 +985,7 @@ async function vary(opts) {
872
985
  createdAt: new Date().toISOString()
873
986
  };
874
987
  persistence.saveStash(stash);
875
- emit2(onProgress, { type: "generating", stashId });
988
+ emit2(onProgress, { type: "generating", stashId, number: stashNumber });
876
989
  const varyPrompt = `The user wants to vary the current UI. Apply this change: ${prompt}`;
877
990
  const aiProcess = startAiProcess(stashId, varyPrompt, worktree.path);
878
991
  try {
@@ -1217,8 +1330,8 @@ class StashService {
1217
1330
  });
1218
1331
  }
1219
1332
  }
1220
- async chat(projectId, message, referenceStashIds) {
1221
- const component = this.selectedComponent;
1333
+ async message(projectId, chatId, message, referenceStashIds, componentContext) {
1334
+ const component = componentContext ? { name: componentContext.name, filePath: this.selectedComponent?.filePath || "" } : this.selectedComponent;
1222
1335
  let sourceCode = "";
1223
1336
  const filePath = component?.filePath || "";
1224
1337
  if (filePath && filePath !== "auto-detect") {
@@ -1238,8 +1351,11 @@ ${refs.join(`
1238
1351
  }
1239
1352
  }
1240
1353
  const chatPrompt = [
1241
- "The user is asking about their UI project. Answer concisely.",
1242
- "Do NOT modify any files.",
1354
+ "You are helping the user explore UI design variations for their project.",
1355
+ "You have access to stashes MCP tools to generate, list, browse, vary, and apply stashes.",
1356
+ "If the user asks you to generate, create, or make variations, use the stashes_generate tool.",
1357
+ "If the user asks to vary an existing stash, use the stashes_vary tool.",
1358
+ "Otherwise, respond conversationally about their project and stashes.",
1243
1359
  "",
1244
1360
  component ? `Component: ${component.name}` : "",
1245
1361
  filePath !== "auto-detect" ? `File: ${filePath}` : "",
@@ -1250,7 +1366,7 @@ ${sourceCode.substring(0, 3000)}
1250
1366
  \`\`\`` : "",
1251
1367
  stashContext,
1252
1368
  "",
1253
- `User question: ${message}`
1369
+ `User: ${message}`
1254
1370
  ].filter(Boolean).join(`
1255
1371
  `);
1256
1372
  const aiProcess = startAiProcess("chat", chatPrompt, this.projectPath);
@@ -1259,12 +1375,60 @@ ${sourceCode.substring(0, 3000)}
1259
1375
  for await (const chunk of parseClaudeStream(aiProcess.process)) {
1260
1376
  if (chunk.type === "text") {
1261
1377
  fullResponse += chunk.content;
1262
- this.broadcast({ type: "ai_stream", content: chunk.content, streamType: "text", source: "chat" });
1378
+ this.broadcast({
1379
+ type: "ai_stream",
1380
+ content: chunk.content,
1381
+ streamType: "text",
1382
+ source: "chat"
1383
+ });
1384
+ } else if (chunk.type === "thinking") {
1385
+ this.broadcast({
1386
+ type: "ai_stream",
1387
+ content: chunk.content,
1388
+ streamType: "thinking",
1389
+ source: "chat"
1390
+ });
1391
+ } else if (chunk.type === "tool_use") {
1392
+ let toolName = "unknown";
1393
+ let toolParams = {};
1394
+ try {
1395
+ const parsed = JSON.parse(chunk.content);
1396
+ toolName = parsed.tool || parsed.name || "unknown";
1397
+ toolParams = parsed.input || parsed.params || {};
1398
+ } catch {}
1399
+ this.broadcast({
1400
+ type: "ai_stream",
1401
+ content: chunk.content,
1402
+ streamType: "tool_start",
1403
+ source: "chat",
1404
+ toolName,
1405
+ toolParams,
1406
+ toolStatus: "running"
1407
+ });
1408
+ } else if (chunk.type === "tool_result") {
1409
+ let toolName = "unknown";
1410
+ let toolResult = "";
1411
+ try {
1412
+ const parsed = JSON.parse(chunk.content);
1413
+ toolName = parsed.tool || parsed.name || "unknown";
1414
+ toolResult = typeof parsed.result === "string" ? parsed.result.substring(0, 200) : JSON.stringify(parsed.result).substring(0, 200);
1415
+ } catch {
1416
+ toolResult = chunk.content.substring(0, 200);
1417
+ }
1418
+ this.broadcast({
1419
+ type: "ai_stream",
1420
+ content: chunk.content,
1421
+ streamType: "tool_end",
1422
+ source: "chat",
1423
+ toolName,
1424
+ toolStatus: "completed",
1425
+ toolResult
1426
+ });
1263
1427
  }
1264
1428
  }
1265
1429
  await aiProcess.process.exited;
1266
1430
  if (fullResponse) {
1267
- this.persistence.saveChatMessage(projectId, {
1431
+ this.persistence.saveChatMessage(projectId, chatId, {
1268
1432
  id: crypto.randomUUID(),
1269
1433
  role: "assistant",
1270
1434
  content: fullResponse,
@@ -1275,7 +1439,7 @@ ${sourceCode.substring(0, 3000)}
1275
1439
  } catch (err) {
1276
1440
  this.broadcast({
1277
1441
  type: "ai_stream",
1278
- content: `Chat error: ${err instanceof Error ? err.message : String(err)}`,
1442
+ content: `Error: ${err instanceof Error ? err.message : String(err)}`,
1279
1443
  streamType: "text",
1280
1444
  source: "chat"
1281
1445
  });
@@ -1288,7 +1452,12 @@ ${sourceCode.substring(0, 3000)}
1288
1452
  case "generating":
1289
1453
  case "screenshotting":
1290
1454
  case "ready":
1291
- this.broadcast({ type: "stash:status", stashId: event.stashId, status: event.type === "ready" ? "ready" : event.type });
1455
+ this.broadcast({
1456
+ type: "stash:status",
1457
+ stashId: event.stashId,
1458
+ status: event.type === "ready" ? "ready" : event.type,
1459
+ ..."number" in event ? { number: event.number } : {}
1460
+ });
1292
1461
  if (event.type === "ready" && "screenshotPath" in event && event.screenshotPath) {
1293
1462
  this.broadcast({ type: "stash:screenshot", stashId: event.stashId, url: event.screenshotPath });
1294
1463
  }
@@ -1296,9 +1465,11 @@ ${sourceCode.substring(0, 3000)}
1296
1465
  case "error":
1297
1466
  this.broadcast({ type: "stash:error", stashId: event.stashId, error: event.error });
1298
1467
  break;
1299
- case "ai_stream":
1300
- this.broadcast({ type: "ai_stream", content: event.content, streamType: event.streamType });
1468
+ case "ai_stream": {
1469
+ const streamType = event.streamType === "tool_use" ? "tool_start" : event.streamType;
1470
+ this.broadcast({ type: "ai_stream", content: event.content, streamType });
1301
1471
  break;
1472
+ }
1302
1473
  }
1303
1474
  }
1304
1475
  async generate(projectId, prompt, stashCount = DEFAULT_STASH_COUNT, referenceStashIds) {
@@ -1373,7 +1544,14 @@ function createWebSocketHandler(projectPath, userDevPort, appProxyPort) {
1373
1544
  open(ws) {
1374
1545
  clients.add(ws);
1375
1546
  logger.info("ws", "client connected", { total: clients.size });
1376
- ws.send(JSON.stringify({ type: "server_ready", port: userDevPort, appProxyPort }));
1547
+ const project = ensureProject(persistence);
1548
+ ws.send(JSON.stringify({
1549
+ type: "server_ready",
1550
+ port: userDevPort,
1551
+ appProxyPort,
1552
+ projectId: project.id,
1553
+ projectName: project.name
1554
+ }));
1377
1555
  },
1378
1556
  async message(ws, message) {
1379
1557
  const raw = typeof message === "string" ? message : new TextDecoder().decode(message);
@@ -1392,28 +1570,17 @@ function createWebSocketHandler(projectPath, userDevPort, appProxyPort) {
1392
1570
  case "select_component":
1393
1571
  stashService.setSelectedComponent(event.component);
1394
1572
  break;
1395
- case "chat":
1396
- persistence.saveChatMessage(event.projectId, {
1573
+ case "message":
1574
+ persistence.saveChatMessage(event.projectId, event.chatId, {
1397
1575
  id: crypto.randomUUID(),
1398
1576
  role: "user",
1399
1577
  content: event.message,
1400
1578
  type: "text",
1401
- createdAt: new Date().toISOString()
1402
- });
1403
- await stashService.chat(event.projectId, event.message, event.referenceStashIds);
1404
- break;
1405
- case "generate":
1406
- persistence.saveChatMessage(event.projectId, {
1407
- id: crypto.randomUUID(),
1408
- role: "user",
1409
- content: event.prompt,
1410
- type: "text",
1411
- createdAt: new Date().toISOString()
1579
+ createdAt: new Date().toISOString(),
1580
+ referenceStashIds: event.referenceStashIds,
1581
+ componentContext: event.componentContext
1412
1582
  });
1413
- await stashService.generate(event.projectId, event.prompt, event.stashCount, event.referenceStashIds);
1414
- break;
1415
- case "vary":
1416
- await stashService.vary(event.sourceStashId, event.prompt);
1583
+ await stashService.message(event.projectId, event.chatId, event.message, event.referenceStashIds, event.componentContext);
1417
1584
  break;
1418
1585
  case "interact":
1419
1586
  await stashService.switchPreview(event.stashId, event.sortedStashIds);