letmecook 0.0.21 → 0.0.23

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.
@@ -6,7 +6,6 @@ import { writeAgentsMd } from "../agents-md";
6
6
  import { recordRepoHistory } from "../repo-history";
7
7
  import { showAddReposPrompt } from "../ui/add-repos";
8
8
  import { runCommands, hideCommandRunner, type CommandTask } from "../ui/common/command-runner";
9
- import { ensureReferenceRepo, linkReferenceRepo, ensureReferencesDir } from "../reference-repo";
10
9
  import { rm } from "node:fs/promises";
11
10
 
12
11
  export interface AddReposParams {
@@ -20,10 +19,7 @@ export interface AddReposResult {
20
19
  }
21
20
 
22
21
  function reposToCommandTasks(repos: RepoSpec[], sessionPath: string): CommandTask[] {
23
- // Only create command tasks for non-reference repos
24
- const regularRepos = repos.filter((repo) => !repo.reference);
25
-
26
- return regularRepos.map((repo) => {
22
+ return repos.map((repo) => {
27
23
  const url = `https://github.com/${repo.owner}/${repo.name}.git`;
28
24
  const targetDir = join(sessionPath, repo.dir);
29
25
  const args = repo.branch
@@ -49,34 +45,6 @@ function reposToCommandTasks(repos: RepoSpec[], sessionPath: string): CommandTas
49
45
  });
50
46
  }
51
47
 
52
- async function setupReferenceRepos(
53
- repos: RepoSpec[],
54
- sessionPath: string,
55
- ): Promise<{ successful: RepoSpec[]; failed: Array<{ repo: RepoSpec; error: string }> }> {
56
- const referenceRepos = repos.filter((repo) => repo.reference);
57
- const successful: RepoSpec[] = [];
58
- const failed: Array<{ repo: RepoSpec; error: string }> = [];
59
-
60
- if (referenceRepos.length === 0) {
61
- return { successful, failed };
62
- }
63
-
64
- await ensureReferencesDir();
65
-
66
- for (const repo of referenceRepos) {
67
- try {
68
- await ensureReferenceRepo(repo);
69
- await linkReferenceRepo(repo, sessionPath);
70
- successful.push(repo);
71
- } catch (error) {
72
- const errorMsg = error instanceof Error ? error.message : String(error);
73
- failed.push({ repo, error: errorMsg });
74
- }
75
- }
76
-
77
- return { successful, failed };
78
- }
79
-
80
48
  export async function addReposFlow(params: AddReposParams): Promise<AddReposResult> {
81
49
  const { renderer, session } = params;
82
50
 
@@ -87,102 +55,70 @@ export async function addReposFlow(params: AddReposParams): Promise<AddReposResu
87
55
  const newRepos = addResult.repos.filter((r) => !existingSpecs.has(r.spec));
88
56
 
89
57
  if (newRepos.length > 0) {
90
- // Setup reference repos first (symlinks from cache)
91
- const { successful: successfulRefs, failed: failedRefs } = await setupReferenceRepos(
92
- newRepos,
93
- session.path,
94
- );
95
-
96
- // Report reference repo failures
97
- if (failedRefs.length > 0) {
98
- console.error(`\n⚠️ ${failedRefs.length} reference repo(s) failed:`);
99
- failedRefs.forEach(({ repo, error }) => {
100
- console.error(` ✗ ${repo.owner}/${repo.name}: ${error}`);
101
- });
102
- }
103
-
104
- // Clone regular repos
105
58
  const tasks = reposToCommandTasks(newRepos, session.path);
106
- let results: Awaited<ReturnType<typeof runCommands>> = [];
107
-
108
- if (tasks.length > 0) {
109
- results = await runCommands(renderer, {
110
- title: "Adding repositories",
111
- tasks,
112
- showOutput: true,
113
- outputLines: 5,
114
- allowAbort: true,
115
- allowSkip: tasks.length > 1,
116
- allowBackground: true,
117
- sessionName: session.name,
118
- });
119
59
 
120
- // Handle aborted operation - return without changes
121
- const wasAborted = results.some((r) => r.outcome === "aborted");
122
- if (wasAborted) {
123
- hideCommandRunner(renderer);
124
- // Clean up any partially cloned repos (non-reference only)
125
- const regularRepos = newRepos.filter((r) => !r.reference);
126
- for (const repo of regularRepos) {
127
- const repoPath = join(session.path, repo.dir);
128
- try {
129
- await rm(repoPath, { recursive: true, force: true });
130
- } catch {
131
- // Ignore cleanup errors
132
- }
133
- }
134
- // Also clean up reference symlinks
135
- for (const repo of successfulRefs) {
136
- const repoPath = join(session.path, repo.dir);
137
- try {
138
- await rm(repoPath, { recursive: true, force: true });
139
- } catch {
140
- // Ignore cleanup errors
141
- }
142
- }
143
- return { session, cancelled: true };
144
- }
60
+ const results = await runCommands(renderer, {
61
+ title: "Adding repositories",
62
+ tasks,
63
+ showOutput: true,
64
+ outputLines: 5,
65
+ allowAbort: true,
66
+ allowSkip: tasks.length > 1,
67
+ allowBackground: true,
68
+ sessionName: session.name,
69
+ });
145
70
 
146
- // Clean up skipped repo directories
147
- const skippedResults = results.filter((r) => r.outcome === "skipped");
148
- for (const skipped of skippedResults) {
149
- const repoSpec = skipped.task.label.replace("Cloning ", "");
150
- const repo = newRepos.find((r) => `${r.owner}/${r.name}` === repoSpec);
151
- if (repo) {
152
- const repoPath = join(session.path, repo.dir);
153
- try {
154
- await rm(repoPath, { recursive: true, force: true });
155
- } catch {
156
- // Ignore cleanup errors
157
- }
71
+ // Handle aborted operation - return without changes
72
+ const wasAborted = results.some((r) => r.outcome === "aborted");
73
+ if (wasAborted) {
74
+ hideCommandRunner(renderer);
75
+ // Clean up any partially cloned repos
76
+ for (const repo of newRepos) {
77
+ const repoPath = join(session.path, repo.dir);
78
+ try {
79
+ await rm(repoPath, { recursive: true, force: true });
80
+ } catch {
81
+ // Ignore cleanup errors
158
82
  }
159
83
  }
84
+ return { session, cancelled: true };
85
+ }
160
86
 
161
- // Check for errors (not skipped/aborted)
162
- const errors = results.filter((r) => r.outcome === "error");
163
- if (errors.length > 0) {
164
- console.error(`\n⚠️ ${errors.length} repository(ies) failed to clone:`);
165
- errors.forEach((err) => {
166
- console.error(` ✗ ${err.task.label}`);
167
- if (err.error) {
168
- console.error(` ${err.error}`);
169
- }
170
- });
87
+ // Clean up skipped repo directories
88
+ const skippedResults = results.filter((r) => r.outcome === "skipped");
89
+ for (const skipped of skippedResults) {
90
+ const repoSpec = skipped.task.label.replace("Cloning ", "");
91
+ const repo = newRepos.find((r) => `${r.owner}/${r.name}` === repoSpec);
92
+ if (repo) {
93
+ const repoPath = join(session.path, repo.dir);
94
+ try {
95
+ await rm(repoPath, { recursive: true, force: true });
96
+ } catch {
97
+ // Ignore cleanup errors
98
+ }
171
99
  }
172
100
  }
173
101
 
174
- await new Promise((resolve) => setTimeout(resolve, 700));
175
- hideCommandRunner(renderer);
176
-
177
102
  // Filter to only successfully cloned repos
178
- const successfulCloned = newRepos.filter((repo) => {
179
- if (repo.reference) return false; // Reference repos handled separately
103
+ const successfulRepos = newRepos.filter((repo) => {
180
104
  const result = results.find((r) => r.task.label === `Cloning ${repo.owner}/${repo.name}`);
181
105
  return result?.outcome === "completed";
182
106
  });
183
107
 
184
- // Combine successful reference and cloned repos
185
- const successfulRepos = [...successfulRefs, ...successfulCloned];
108
+ // Check for errors (not skipped/aborted)
109
+ const errors = results.filter((r) => r.outcome === "error");
110
+ if (errors.length > 0) {
111
+ console.error(`\n⚠️ ${errors.length} repository(ies) failed to clone:`);
112
+ errors.forEach((err) => {
113
+ console.error(` ✗ ${err.task.label}`);
114
+ if (err.error) {
115
+ console.error(` ${err.error}`);
116
+ }
117
+ });
118
+ }
119
+
120
+ await new Promise((resolve) => setTimeout(resolve, 700));
121
+ hideCommandRunner(renderer);
186
122
 
187
123
  if (successfulRepos.length > 0) {
188
124
  const allRepos = [...session.repos, ...successfulRepos];
@@ -0,0 +1,373 @@
1
+ import { streamText, type ToolSet } from "ai";
2
+ import { parseRepoSpec, type RepoSpec } from "../schemas";
3
+ import { INCREMENTAL_CHAT_PROMPT, generateRepoHistorySection } from "../prompts/chat-prompt";
4
+ import { z } from "zod";
5
+ import type { ConfigBuilder } from "../config-builder";
6
+ import { listRepoHistory } from "../repo-history";
7
+
8
+ export interface ChatMessage {
9
+ role: "user" | "assistant" | "system";
10
+ content: string;
11
+ }
12
+
13
+ export interface ChatConfig {
14
+ repos: string[];
15
+ skills: string[];
16
+ goal: string;
17
+ }
18
+
19
+ export interface ToolCallResult {
20
+ toolName: string;
21
+ input: unknown;
22
+ output: unknown;
23
+ durationMs: number;
24
+ }
25
+
26
+ export function configToRepoSpecs(config: ChatConfig): RepoSpec[] {
27
+ return config.repos.map((spec) => parseRepoSpec(spec));
28
+ }
29
+
30
+ // ============================================================================
31
+ // INCREMENTAL CHAT MODE - Tools add config items directly
32
+ // ============================================================================
33
+
34
+ export interface IncrementalChatResult {
35
+ response: string;
36
+ toolResults?: ToolCallResult[];
37
+ error?: string;
38
+ }
39
+
40
+ /**
41
+ * Process chat with incremental config building.
42
+ * Tools directly modify the ConfigBuilder, which emits events to update UI.
43
+ */
44
+ export async function chatToConfigIncremental(
45
+ messages: ChatMessage[],
46
+ configBuilder: ConfigBuilder,
47
+ onChunk: (chunk: string) => void,
48
+ ): Promise<IncrementalChatResult> {
49
+ const context = messages.map((m) => `${m.role}: ${m.content}`).join("\n");
50
+ const userMessage = messages.findLast((m) => m.role === "user")?.content ?? "";
51
+
52
+ const repoHistory = await generateRepoHistorySection();
53
+
54
+ const prompt = INCREMENTAL_CHAT_PROMPT.replace("{{context}}", context)
55
+ .replace("{{userMessage}}", userMessage)
56
+ .replace("{{repoHistory}}", repoHistory);
57
+
58
+ try {
59
+ const toolTimings: Map<string, number> = new Map();
60
+
61
+ const tools: ToolSet = {
62
+ // Config building tools
63
+ add_repo: {
64
+ title: "add_repo",
65
+ description: "Add a repository to the workspace configuration",
66
+ inputSchema: z.object({
67
+ repo: z.string().describe("Repository in format owner/repo or owner/repo:branch"),
68
+ }),
69
+ execute: async ({ repo }) => {
70
+ const startTime = Date.now();
71
+
72
+ // Validate repo exists on GitHub using gh CLI
73
+ const repoPath = repo.includes(":") ? repo.split(":")[0] : repo;
74
+ const branch = repo.includes(":") ? repo.split(":")[1] : undefined;
75
+
76
+ const validationProc = Bun.spawn(
77
+ ["gh", "repo", "view", repoPath, "--json", "nameWithOwner"],
78
+ {
79
+ stdout: "pipe",
80
+ stderr: "pipe",
81
+ },
82
+ );
83
+
84
+ const validationOutput = await new Response(validationProc.stdout).text();
85
+ const validationStderr = await new Response(validationProc.stderr).text();
86
+
87
+ if (validationStderr && !validationOutput) {
88
+ const duration = Date.now() - startTime;
89
+ toolTimings.set("add_repo", duration);
90
+ return {
91
+ success: false,
92
+ error: `Repository '${repoPath}' not found on GitHub. Please check the owner and repository name.`,
93
+ currentRepos: configBuilder.config.repos,
94
+ };
95
+ }
96
+
97
+ // If branch is specified, validate it exists
98
+ if (branch) {
99
+ const branchProc = Bun.spawn(
100
+ ["gh", "api", `repos/${repoPath}/git/refs/heads/${branch}`],
101
+ {
102
+ stdout: "pipe",
103
+ stderr: "pipe",
104
+ },
105
+ );
106
+
107
+ const branchOutput = await new Response(branchProc.stdout).text();
108
+ const branchStderr = await new Response(branchProc.stderr).text();
109
+
110
+ if (branchStderr && !branchOutput) {
111
+ const duration = Date.now() - startTime;
112
+ toolTimings.set("add_repo", duration);
113
+ return {
114
+ success: false,
115
+ error: `Branch '${branch}' not found in repository '${repoPath}'.`,
116
+ currentRepos: configBuilder.config.repos,
117
+ };
118
+ }
119
+ }
120
+
121
+ const added = configBuilder.addRepo(repo);
122
+ const duration = Date.now() - startTime;
123
+ toolTimings.set("add_repo", duration);
124
+ return {
125
+ success: added,
126
+ message: added ? `Added ${repo}` : `${repo} already in config`,
127
+ currentRepos: configBuilder.config.repos,
128
+ };
129
+ },
130
+ },
131
+ add_skill: {
132
+ title: "add_skill",
133
+ description: "Add a skill or package to install in the workspace",
134
+ inputSchema: z.object({
135
+ skill: z.string().describe("Skill or package name to install"),
136
+ }),
137
+ execute: async ({ skill }) => {
138
+ const startTime = Date.now();
139
+ const added = configBuilder.addSkill(skill);
140
+ const duration = Date.now() - startTime;
141
+ toolTimings.set("add_skill", duration);
142
+ return {
143
+ success: added,
144
+ message: added ? `Added skill ${skill}` : `${skill} already in config`,
145
+ currentSkills: configBuilder.config.skills,
146
+ };
147
+ },
148
+ },
149
+ set_goal: {
150
+ title: "set_goal",
151
+ description: "Set the goal/purpose for this workspace session",
152
+ inputSchema: z.object({
153
+ goal: z.string().describe("Brief description of what the user wants to accomplish"),
154
+ }),
155
+ execute: async ({ goal }) => {
156
+ const startTime = Date.now();
157
+ configBuilder.setGoal(goal);
158
+ const duration = Date.now() - startTime;
159
+ toolTimings.set("set_goal", duration);
160
+ return {
161
+ success: true,
162
+ message: `Goal set to: ${goal}`,
163
+ };
164
+ },
165
+ },
166
+ // Research tools (existing)
167
+ list_repos: {
168
+ title: "list_repos",
169
+ description: "List all repositories for a given owner/organization",
170
+ inputSchema: z.object({
171
+ owner: z.string(),
172
+ limit: z.number().optional(),
173
+ topic: z.string().optional(),
174
+ }),
175
+ execute: async ({ owner, limit, topic }) => {
176
+ const startTime = Date.now();
177
+ const args = [
178
+ "repo",
179
+ "list",
180
+ owner,
181
+ "--json",
182
+ "nameWithOwner",
183
+ "--jq",
184
+ ".[].nameWithOwner",
185
+ ];
186
+ if (limit) {
187
+ args.push("--limit", String(limit));
188
+ }
189
+ if (topic) {
190
+ args.push("--topic", topic);
191
+ }
192
+ const proc = Bun.spawn(["gh", ...args], {
193
+ stdout: "pipe",
194
+ stderr: "pipe",
195
+ });
196
+ const output = await new Response(proc.stdout).text();
197
+ const result = {
198
+ repos: output.trim().split("\n").filter(Boolean),
199
+ };
200
+ const duration = Date.now() - startTime;
201
+ toolTimings.set("list_repos", duration);
202
+ return result;
203
+ },
204
+ },
205
+ view_issue: {
206
+ title: "view_issue",
207
+ description:
208
+ "View a GitHub issue. Accepts either a full GitHub URL or owner/repo with issue number.",
209
+ inputSchema: z.object({
210
+ url: z.string().optional().describe("Full GitHub issue URL"),
211
+ owner: z.string().optional().describe("Repository owner"),
212
+ repo: z.string().optional().describe("Repository name"),
213
+ issue: z.number().optional().describe("Issue number"),
214
+ }),
215
+ execute: async ({ url, owner, repo, issue }) => {
216
+ const startTime = Date.now();
217
+
218
+ let repoPath: string;
219
+ let issueNumber: number;
220
+
221
+ if (url) {
222
+ // Parse URL like https://github.com/owner/repo/issues/123
223
+ const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
224
+ if (!match) {
225
+ return { error: "Invalid GitHub issue URL", markdown: "" };
226
+ }
227
+ repoPath = `${match[1]}/${match[2]}`;
228
+ issueNumber = parseInt(match[3], 10);
229
+ } else if (owner && repo && issue) {
230
+ repoPath = `${owner}/${repo}`;
231
+ issueNumber = issue;
232
+ } else {
233
+ return { error: "Provide either a URL or owner/repo/issue", markdown: "" };
234
+ }
235
+
236
+ const proc = Bun.spawn(
237
+ [
238
+ "gh",
239
+ "issue",
240
+ "view",
241
+ String(issueNumber),
242
+ "--repo",
243
+ repoPath,
244
+ "--json",
245
+ "title,body,state,author,labels,createdAt,comments,url",
246
+ ],
247
+ {
248
+ stdout: "pipe",
249
+ stderr: "pipe",
250
+ },
251
+ );
252
+
253
+ const output = await new Response(proc.stdout).text();
254
+ const stderr = await new Response(proc.stderr).text();
255
+
256
+ if (stderr && !output) {
257
+ return { error: stderr.trim(), markdown: "" };
258
+ }
259
+
260
+ try {
261
+ const data = JSON.parse(output);
262
+ const labels = data.labels?.map((l: { name: string }) => l.name).join(", ") || "None";
263
+ const commentCount = data.comments?.length || 0;
264
+
265
+ const markdown = `## ${data.title}
266
+
267
+ **Repository:** ${repoPath}
268
+ **Issue:** #${issueNumber}
269
+ **State:** ${data.state}
270
+ **Author:** ${data.author?.login || "Unknown"}
271
+ **Labels:** ${labels}
272
+ **Created:** ${new Date(data.createdAt).toLocaleDateString()}
273
+ **Comments:** ${commentCount}
274
+ **URL:** ${data.url}
275
+
276
+ ---
277
+
278
+ ${data.body || "*No description provided*"}`;
279
+
280
+ const duration = Date.now() - startTime;
281
+ toolTimings.set("view_issue", duration);
282
+
283
+ return { markdown, title: data.title, state: data.state, url: data.url, repoPath };
284
+ } catch {
285
+ return { error: "Failed to parse issue data", markdown: "" };
286
+ }
287
+ },
288
+ },
289
+ list_repo_history: {
290
+ title: "list_repo_history",
291
+ description:
292
+ "List previously used repositories from history. Useful when user mentions 'my repos' or wants to reuse repos.",
293
+ inputSchema: z.object({
294
+ owner: z.string().optional().describe("Filter by repository owner/organization"),
295
+ limit: z.number().optional().describe("Maximum number of results to return"),
296
+ }),
297
+ execute: async ({ owner, limit }) => {
298
+ const startTime = Date.now();
299
+ const items = await listRepoHistory(limit ?? 50);
300
+ const filtered = owner
301
+ ? items.filter((item) => item.owner.toLowerCase() === owner.toLowerCase())
302
+ : items;
303
+ const duration = Date.now() - startTime;
304
+ toolTimings.set("list_repo_history", duration);
305
+ return {
306
+ repos: filtered.map((item) => ({
307
+ spec: item.spec,
308
+ owner: item.owner,
309
+ name: item.name,
310
+ branch: item.branch,
311
+ timesUsed: item.timesUsed,
312
+ lastUsed: item.lastUsed,
313
+ })),
314
+ };
315
+ },
316
+ },
317
+ };
318
+
319
+ const result = streamText({
320
+ model: "moonshotai/kimi-k2.5",
321
+ temperature: 0.7,
322
+ prompt,
323
+ tools,
324
+ maxSteps: 100,
325
+ } as any);
326
+
327
+ let response = "";
328
+ const toolResults: ToolCallResult[] = [];
329
+
330
+ for await (const chunk of result.fullStream) {
331
+ switch (chunk.type) {
332
+ case "text-delta": {
333
+ const text = (chunk as any).text ?? (chunk as any).textDelta ?? "";
334
+ response += text;
335
+ onChunk(text);
336
+ break;
337
+ }
338
+ case "tool-call": {
339
+ // Tool is being called - show indicator to user
340
+ onChunk(`\n[${chunk.toolName}...]\n`);
341
+ break;
342
+ }
343
+ case "tool-result": {
344
+ const durationMs = toolTimings.get(chunk.toolName) || 0;
345
+ const chunkAny = chunk as any;
346
+ toolResults.push({
347
+ toolName: chunk.toolName,
348
+ input: chunkAny.input ?? chunkAny.args,
349
+ output: chunkAny.output ?? chunkAny.result,
350
+ durationMs,
351
+ });
352
+ break;
353
+ }
354
+ case "error": {
355
+ console.error("Stream error:", chunk.error);
356
+ break;
357
+ }
358
+ }
359
+ }
360
+
361
+ response = response.trim();
362
+
363
+ return {
364
+ response,
365
+ toolResults: toolResults.length > 0 ? toolResults : undefined,
366
+ };
367
+ } catch (llmError) {
368
+ return {
369
+ response: "",
370
+ error: `LLM error: ${llmError instanceof Error ? llmError.message : String(llmError)}`,
371
+ };
372
+ }
373
+ }