@stacksjs/ai 0.70.54 → 0.70.55

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/src/buddy.ts ADDED
@@ -0,0 +1,619 @@
1
+ /**
2
+ * Buddy - Voice AI Code Assistant
3
+ *
4
+ * This module contains the shared state and utilities for Buddy,
5
+ * an AI-powered code assistant that helps users modify codebases
6
+ * through voice commands.
7
+ */
8
+
9
+ import type {
10
+ AIDriver,
11
+ AIMessage,
12
+ BuddyApiKeys,
13
+ BuddyConfig,
14
+ BuddyState,
15
+ GitHubCredentials,
16
+ RepoState,
17
+ StreamingResult,
18
+ } from './types'
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
20
+ import { homedir } from 'node:os'
21
+ import { dirname, join } from 'node:path'
22
+ import { claudeAgent } from './agents'
23
+ import { createAnthropicDriver, createClaudeAgentSDKDriver, createOllamaDriver, createOpenAIDriver } from './drivers'
24
+
25
+ // =============================================================================
26
+ // Configuration
27
+ // =============================================================================
28
+
29
+ export const CONFIG: BuddyConfig = {
30
+ workDir: join(homedir(), 'Code', '.buddy-repos'),
31
+ commitMessage: 'chore: wip',
32
+ ollamaHost: process.env.OLLAMA_HOST || 'http://localhost:11434',
33
+ ollamaModel: process.env.OLLAMA_MODEL || 'llama3.2',
34
+ }
35
+
36
+ // API Keys state (can be set at runtime via settings endpoint)
37
+ export const apiKeys: BuddyApiKeys = {
38
+ anthropic: process.env.ANTHROPIC_API_KEY,
39
+ openai: process.env.OPENAI_API_KEY,
40
+ claudeCliHost: process.env.BUDDY_EC2_HOST,
41
+ }
42
+
43
+ // =============================================================================
44
+ // State Management
45
+ // =============================================================================
46
+
47
+ // State singleton
48
+ const state: BuddyState = {
49
+ repo: null,
50
+ conversationHistory: [],
51
+ currentDriver: 'claude-cli-local',
52
+ github: null,
53
+ }
54
+
55
+ // Ensure work directory exists
56
+ if (!existsSync(CONFIG.workDir)) {
57
+ mkdirSync(CONFIG.workDir, { recursive: true })
58
+ }
59
+
60
+ // Buddy State Manager
61
+ export interface BuddyStateManager {
62
+ getState: () => BuddyState
63
+ setRepo: (repo: RepoState | null) => void
64
+ setCurrentDriver: (driver: string) => void
65
+ setGitHub: (github: GitHubCredentials | null) => void
66
+ addToHistory: (message: AIMessage) => void
67
+ clearHistory: () => void
68
+ }
69
+
70
+ export const buddyState: BuddyStateManager = {
71
+ getState: (): BuddyState => state,
72
+ setRepo: (repo: RepoState | null): void => { state.repo = repo },
73
+ setCurrentDriver: (driver: string): void => { state.currentDriver = driver },
74
+ setGitHub: (github: GitHubCredentials | null): void => { state.github = github },
75
+ addToHistory: (message: AIMessage): void => { state.conversationHistory.push(message) },
76
+ clearHistory: (): void => { state.conversationHistory = [] },
77
+ }
78
+
79
+ // =============================================================================
80
+ // AI Driver Utilities
81
+ // =============================================================================
82
+
83
+ /**
84
+ * Build system prompt for AI with repository context
85
+ */
86
+ export function buildSystemPrompt(context: string): string {
87
+ return `You are Buddy, an AI code assistant that helps users modify codebases through voice commands.
88
+
89
+ ${state.repo
90
+ ? `You are working on the repository: ${state.repo.name}
91
+ Branch: ${state.repo.branch}
92
+ Path: ${state.repo.path}
93
+
94
+ ${context}`
95
+ : 'No repository is currently open.'}
96
+
97
+ When the user gives you a command:
98
+ 1. Analyze what they want to do
99
+ 2. Identify the files that need to be modified
100
+ 3. Generate the exact code changes needed
101
+ 4. Respond with a structured format showing:
102
+ - Summary of changes
103
+ - Files to modify/create with full content
104
+ - Any additional notes
105
+
106
+ Format file changes as:
107
+ FILE: path/to/file.ts
108
+ \`\`\`typescript
109
+ // Full file content
110
+ \`\`\`
111
+
112
+ Be concise but thorough. The user will review and commit your changes.`
113
+ }
114
+
115
+ /**
116
+ * Create a mock driver for testing
117
+ */
118
+ function createMockDriver(): AIDriver {
119
+ return {
120
+ name: 'Mock',
121
+ async process(command: string): Promise<string> {
122
+ await new Promise(resolve => setTimeout(resolve, 1000))
123
+
124
+ const lowerCommand = command.toLowerCase()
125
+
126
+ if (lowerCommand.includes('readme') || lowerCommand.includes('documentation')) {
127
+ return `I'll update the README.md file for you.
128
+
129
+ Analyzing the repository structure...
130
+
131
+ FILE: README.md
132
+ \`\`\`markdown
133
+ # Project Name
134
+
135
+ ## Installation
136
+
137
+ \`\`\`bash
138
+ npm install
139
+ # or
140
+ bun install
141
+ \`\`\`
142
+
143
+ ## Usage
144
+
145
+ \`\`\`bash
146
+ npm run start
147
+ \`\`\`
148
+ \`\`\`
149
+
150
+ File modified: README.md
151
+ Lines added: 12`
152
+ }
153
+
154
+ if (lowerCommand.includes('fix') || lowerCommand.includes('bug')) {
155
+ return `I'll analyze and fix the issue.
156
+
157
+ Scanning for potential bugs...
158
+
159
+ FILE: src/utils.ts
160
+ \`\`\`typescript
161
+ export function getData(data: { value?: string }) {
162
+ return data?.value ?? 'default';
163
+ }
164
+ \`\`\`
165
+
166
+ Files modified: src/utils.ts
167
+ Lines changed: 4`
168
+ }
169
+
170
+ return `I understand you want to: "${command}"
171
+
172
+ I'll analyze the repository and implement this change.
173
+
174
+ FILE: src/main.ts
175
+ \`\`\`typescript
176
+ // Updated based on your request
177
+ export function main() {
178
+ console.log('Changes applied');
179
+ }
180
+ \`\`\`
181
+
182
+ Files modified: 1`
183
+ },
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Get driver instance by name
189
+ */
190
+ export function getDriver(driverName: string): AIDriver {
191
+ const currentState = buddyState.getState()
192
+
193
+ switch (driverName) {
194
+ case 'claude-cli-local':
195
+ return claudeAgent.createLocal({ cwd: currentState.repo?.path })
196
+
197
+ case 'claude-cli-ec2':
198
+ return claudeAgent.createEC2({
199
+ cwd: currentState.repo?.path,
200
+ ec2Host: apiKeys.claudeCliHost,
201
+ })
202
+
203
+ case 'claude':
204
+ case 'anthropic':
205
+ if (!apiKeys.anthropic) {
206
+ throw new Error('Anthropic API key not set. Configure your API key in settings.')
207
+ }
208
+ return createAnthropicDriver({ apiKey: apiKeys.anthropic })
209
+
210
+ case 'openai':
211
+ if (!apiKeys.openai) {
212
+ throw new Error('OpenAI API key not set. Configure your API key in settings.')
213
+ }
214
+ return createOpenAIDriver({ apiKey: apiKeys.openai })
215
+
216
+ case 'ollama':
217
+ return createOllamaDriver({
218
+ host: CONFIG.ollamaHost,
219
+ model: CONFIG.ollamaModel,
220
+ })
221
+
222
+ case 'claude-sdk':
223
+ case 'claude-agent-sdk':
224
+ return createClaudeAgentSDKDriver({
225
+ cwd: currentState.repo?.path,
226
+ maxTurns: 25,
227
+ permissionMode: 'bypassPermissions',
228
+ })
229
+
230
+ case 'mock':
231
+ return createMockDriver()
232
+
233
+ default:
234
+ throw new Error(`Unknown driver: ${driverName}. Available: claude-cli-local, claude-cli-ec2, claude, claude-sdk, openai, ollama, mock`)
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Get list of available AI drivers
240
+ */
241
+ export function getAvailableDrivers(): string[] {
242
+ return ['claude-cli-local', 'claude-cli-ec2', 'claude', 'claude-sdk', 'openai', 'ollama', 'mock']
243
+ }
244
+
245
+ // =============================================================================
246
+ // Repository Operations
247
+ // =============================================================================
248
+
249
+ /**
250
+ * Get repository structure for context
251
+ */
252
+ export async function getRepoContext(repoPath: string): Promise<string> {
253
+ const { $: _$ } = await import('bun')
254
+ const treeResult = await _$`cd ${repoPath} && find . -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -not -name "*.lock" | head -50`.quiet()
255
+ const files = treeResult.text().trim()
256
+
257
+ let readme = ''
258
+ const readmePath = join(repoPath, 'README.md')
259
+ if (existsSync(readmePath)) {
260
+ readme = readFileSync(readmePath, 'utf-8').slice(0, 2000)
261
+ }
262
+
263
+ let packageJson = ''
264
+ const packagePath = join(repoPath, 'package.json')
265
+ if (existsSync(packagePath)) {
266
+ packageJson = readFileSync(packagePath, 'utf-8')
267
+ }
268
+
269
+ return `
270
+ Repository Structure:
271
+ ${files}
272
+
273
+ ${readme ? `README.md (excerpt):\n${readme}\n` : ''}
274
+ ${packageJson ? `package.json:\n${packageJson}\n` : ''}
275
+ `.trim()
276
+ }
277
+
278
+ /**
279
+ * Clone or open a repository
280
+ */
281
+ export async function openRepository(input: string): Promise<RepoState> {
282
+ const { $: _$ } = await import('bun')
283
+ let repoPath: string
284
+ let repoName: string
285
+
286
+ if (input.includes('github.com') || input.startsWith('git@')) {
287
+ repoName = input.split('/').pop()?.replace('.git', '') || 'repo'
288
+ repoPath = join(CONFIG.workDir, repoName)
289
+
290
+ if (existsSync(repoPath)) {
291
+ // Repository already exists, pull latest
292
+ await _$`cd ${repoPath} && git pull --rebase`.quiet()
293
+ }
294
+ else {
295
+ // Clone the repository
296
+ await _$`git clone ${input} ${repoPath}`.quiet()
297
+ }
298
+ }
299
+ else {
300
+ repoPath = input.startsWith('~') ? input.replace('~', homedir()) : input
301
+
302
+ if (!existsSync(repoPath)) {
303
+ throw new Error(`Local path does not exist: ${repoPath}`)
304
+ }
305
+
306
+ if (!existsSync(join(repoPath, '.git'))) {
307
+ throw new Error(`Not a git repository: ${repoPath}`)
308
+ }
309
+
310
+ repoName = repoPath.split('/').pop() || 'repo'
311
+ }
312
+
313
+ const branchResult = await _$`cd ${repoPath} && git branch --show-current`.quiet()
314
+ const branch = branchResult.text().trim()
315
+
316
+ const statusResult = await _$`cd ${repoPath} && git status --porcelain`.quiet()
317
+ const hasChanges = statusResult.text().trim().length > 0
318
+
319
+ const lastCommitResult = await _$`cd ${repoPath} && git log -1 --format="%h %s"`.quiet()
320
+ const lastCommit = lastCommitResult.text().trim()
321
+
322
+ const repoState: RepoState = {
323
+ path: repoPath,
324
+ name: repoName,
325
+ branch,
326
+ hasChanges,
327
+ lastCommit,
328
+ }
329
+
330
+ buddyState.setRepo(repoState)
331
+ buddyState.clearHistory()
332
+ return repoState
333
+ }
334
+
335
+ /**
336
+ * Apply file changes from AI response
337
+ */
338
+ export async function applyChanges(aiResponse: string): Promise<string[]> {
339
+ const currentState = buddyState.getState()
340
+
341
+ if (!currentState.repo) {
342
+ throw new Error('No repository opened')
343
+ }
344
+
345
+ const modifiedFiles: string[] = []
346
+ const filePattern = /FILE:\s*([^\n]+)\n```\w*\n([\s\S]*?)```/g
347
+
348
+ for (const match of aiResponse.matchAll(filePattern)) {
349
+ const filePath = match[1].trim()
350
+ const content = match[2]
351
+
352
+ const fullPath = join(currentState.repo.path, filePath)
353
+
354
+ const dir = dirname(fullPath)
355
+ if (!existsSync(dir)) {
356
+ mkdirSync(dir, { recursive: true })
357
+ }
358
+
359
+ writeFileSync(fullPath, content)
360
+ modifiedFiles.push(filePath)
361
+ }
362
+
363
+ if (modifiedFiles.length > 0 && currentState.repo) {
364
+ currentState.repo.hasChanges = true
365
+ }
366
+
367
+ return modifiedFiles
368
+ }
369
+
370
+ /**
371
+ * Configure git user for commits
372
+ */
373
+ export async function configureGitUser(): Promise<void> {
374
+ const currentState = buddyState.getState()
375
+ if (!currentState.repo || !currentState.github) return
376
+
377
+ const { $: _$ } = await import('bun')
378
+ const { name, email } = currentState.github
379
+
380
+ await _$`cd ${currentState.repo.path} && git config user.name ${name}`.quiet()
381
+ await _$`cd ${currentState.repo.path} && git config user.email ${email}`.quiet()
382
+ }
383
+
384
+ /**
385
+ * Stage and commit changes
386
+ */
387
+ export async function commitChanges(): Promise<string> {
388
+ const currentState = buddyState.getState()
389
+
390
+ if (!currentState.repo) {
391
+ throw new Error('No repository opened')
392
+ }
393
+
394
+ const { $: _$ } = await import('bun')
395
+
396
+ if (currentState.github) {
397
+ await configureGitUser()
398
+ }
399
+
400
+ await _$`cd ${currentState.repo.path} && git add -A`.quiet()
401
+ await _$`cd ${currentState.repo.path} && git commit -m ${CONFIG.commitMessage}`.quiet()
402
+
403
+ const hashResult = await _$`cd ${currentState.repo.path} && git rev-parse --short HEAD`.quiet()
404
+ const commitHash = hashResult.text().trim()
405
+
406
+ currentState.repo.hasChanges = false
407
+ currentState.repo.lastCommit = commitHash
408
+
409
+ return commitHash
410
+ }
411
+
412
+ /**
413
+ * Push changes to remote
414
+ */
415
+ export async function pushChanges(): Promise<void> {
416
+ const currentState = buddyState.getState()
417
+
418
+ if (!currentState.repo) {
419
+ throw new Error('No repository opened')
420
+ }
421
+
422
+ const { $: _$ } = await import('bun')
423
+ await _$`cd ${currentState.repo.path} && git push`.quiet()
424
+ }
425
+
426
+ // =============================================================================
427
+ // Command Processing
428
+ // =============================================================================
429
+
430
+ /**
431
+ * Process command with selected AI driver
432
+ */
433
+ export async function processCommand(command: string, driverName?: string): Promise<string> {
434
+ const currentState = buddyState.getState()
435
+
436
+ if (!currentState.repo) {
437
+ throw new Error('No repository opened')
438
+ }
439
+
440
+ const normalizedDriver = driverName || currentState.currentDriver
441
+ const driver = getDriver(normalizedDriver)
442
+
443
+ if (driverName) {
444
+ buddyState.setCurrentDriver(driverName)
445
+ }
446
+
447
+ const context = await getRepoContext(currentState.repo.path)
448
+ const systemPrompt = buildSystemPrompt(context)
449
+ const response = await driver.process(command, systemPrompt, currentState.conversationHistory)
450
+
451
+ buddyState.addToHistory({ role: 'user', content: command })
452
+ buddyState.addToHistory({ role: 'assistant', content: response })
453
+
454
+ return response
455
+ }
456
+
457
+ /**
458
+ * Process command with streaming output using Claude CLI
459
+ */
460
+ export async function buddyProcessStreaming(
461
+ command: string,
462
+ driverName?: string,
463
+ history?: Array<{role: string; content: string}>,
464
+ ): Promise<StreamingResult> {
465
+ const currentState = buddyState.getState()
466
+
467
+ if (!currentState.repo) {
468
+ throw new Error('No repository opened')
469
+ }
470
+
471
+ const normalizedDriver = driverName || currentState.currentDriver
472
+ const streamingDrivers = ['claude-cli-local', 'claude-sdk']
473
+ if (!streamingDrivers.includes(normalizedDriver)) {
474
+ throw new Error(`Streaming only supported for ${streamingDrivers.join(', ')} drivers. Current: ${normalizedDriver}`)
475
+ }
476
+
477
+ if (driverName) {
478
+ buddyState.setCurrentDriver(driverName)
479
+ }
480
+
481
+ // Build command with conversation history for context
482
+ let contextualCommand = command
483
+ if (history && history.length > 0) {
484
+ let conversationContext = '## Previous Conversation\nHere is our conversation so far:\n\n'
485
+ for (const msg of history) {
486
+ const role = msg.role === 'user' ? 'User' : 'Assistant'
487
+ conversationContext += `**${role}:** ${msg.content}\n\n`
488
+ }
489
+ conversationContext += '---\n\n## Current Request\n'
490
+ contextualCommand = conversationContext + command
491
+ }
492
+
493
+ const result = await claudeAgent.processStreaming(contextualCommand, currentState.repo.path)
494
+
495
+ // Update history when streaming completes
496
+ result.fullResponse.then((response) => {
497
+ buddyState.addToHistory({ role: 'user', content: command })
498
+ buddyState.addToHistory({ role: 'assistant', content: response })
499
+ })
500
+
501
+ return result
502
+ }
503
+
504
+ /**
505
+ * Stream a simple Q&A response using the Anthropic API directly.
506
+ * This provides true token-by-token streaming like ChatGPT/Claude web.
507
+ * Use this for questions/explanations that don't require agentic tool use.
508
+ */
509
+ export async function buddyStreamSimple(
510
+ command: string,
511
+ history?: Array<{ role: string; content: string }>,
512
+ ): Promise<StreamingResult> {
513
+
514
+ if (!apiKeys.anthropic) {
515
+ throw new Error('Anthropic API key not set. Configure your API key in settings.')
516
+ }
517
+
518
+ // Build conversation messages
519
+ const messages: AIMessage[] = []
520
+ if (history && history.length > 0) {
521
+ for (const msg of history) {
522
+ messages.push({
523
+ role: msg.role as 'user' | 'assistant',
524
+ content: msg.content,
525
+ })
526
+ }
527
+ }
528
+ messages.push({ role: 'user', content: command })
529
+
530
+ // For simple Q&A mode, use a generic helpful assistant prompt
531
+ // Don't include repo context - this keeps answers general and not code-focused
532
+ const systemPrompt = `You are a helpful AI assistant. Answer questions naturally and conversationally.
533
+ You can discuss any topic - technology, science, philosophy, everyday questions, or anything else the user asks about.
534
+ Be concise but thorough. If the user asks about coding or their project specifically, help with that too.`
535
+
536
+ const encoder = new TextEncoder()
537
+ let fullResponse = ''
538
+ let resolveFullResponse: (value: string) => void
539
+ const fullResponsePromise = new Promise<string>((resolve) => {
540
+ resolveFullResponse = resolve
541
+ })
542
+
543
+ // Create streaming response using Anthropic API
544
+ const stream = new ReadableStream<Uint8Array>({
545
+ async start(controller) {
546
+ try {
547
+ const response = await fetch('https://api.anthropic.com/v1/messages', {
548
+ method: 'POST',
549
+ headers: {
550
+ 'Content-Type': 'application/json',
551
+ 'x-api-key': apiKeys.anthropic!,
552
+ 'anthropic-version': '2023-06-01',
553
+ },
554
+ body: JSON.stringify({
555
+ model: 'claude-sonnet-4-20250514',
556
+ max_tokens: 4096,
557
+ system: systemPrompt,
558
+ stream: true,
559
+ messages,
560
+ }),
561
+ })
562
+
563
+ if (!response.ok) {
564
+ const error = await response.text()
565
+ throw new Error(`Claude API error: ${error}`)
566
+ }
567
+
568
+ const reader = response.body?.getReader()
569
+ if (!reader) throw new Error('No response body')
570
+
571
+ const decoder = new TextDecoder()
572
+ let buffer = ''
573
+
574
+ while (true) {
575
+ const { done, value } = await reader.read()
576
+ if (done) break
577
+
578
+ buffer += decoder.decode(value, { stream: true })
579
+ const lines = buffer.split('\n')
580
+ buffer = lines.pop() || ''
581
+
582
+ for (const line of lines) {
583
+ if (line.startsWith('data: ')) {
584
+ const data = line.slice(6)
585
+ if (data === '[DONE]') continue
586
+
587
+ try {
588
+ const event = JSON.parse(data) as { type?: string; delta?: { text?: string } }
589
+ if (event.type === 'content_block_delta' && event.delta?.text) {
590
+ const text = event.delta.text
591
+ fullResponse += text
592
+ controller.enqueue(encoder.encode(text))
593
+ }
594
+ }
595
+ catch {
596
+ // Skip invalid JSON
597
+ }
598
+ }
599
+ }
600
+ }
601
+
602
+ // Update history and resolve the full response
603
+ buddyState.addToHistory({ role: 'user', content: command })
604
+ buddyState.addToHistory({ role: 'assistant', content: fullResponse })
605
+ resolveFullResponse(fullResponse)
606
+ controller.close()
607
+ }
608
+ catch (error) {
609
+ resolveFullResponse(fullResponse) // Resolve with what we have
610
+ controller.error(error)
611
+ }
612
+ },
613
+ })
614
+
615
+ return {
616
+ stream,
617
+ fullResponse: fullResponsePromise,
618
+ }
619
+ }