From 42ca873d45e7acf3d791a7386c8b7e0aab0acf1e Mon Sep 17 00:00:00 2001 From: Wonseok Jung Date: Sat, 25 Apr 2026 19:07:15 +0900 Subject: [PATCH] chore: release v2.2.46 with critical bug fixes for AI communication and brain management --- PATCHNOTES.md | 26 ++ package-lock.json | 4 +- package.json | 37 ++- src/agent.ts | 594 +++++++++++++++++++++++++++++++++++++---- src/sidebarProvider.ts | 501 ++++++++++++++++++++++++++++++---- src/utils.ts | 143 ++++++++-- 6 files changed, 1176 insertions(+), 129 deletions(-) create mode 100644 PATCHNOTES.md diff --git a/PATCHNOTES.md b/PATCHNOTES.md new file mode 100644 index 0000000..d299399 --- /dev/null +++ b/PATCHNOTES.md @@ -0,0 +1,26 @@ +# Patch Notes - v2.2.46 + +## ๐Ÿš€ Key Improvements & Bug Fixes + +### 1. AI Communication Protocol Fix +- **Issue:** Previously, when a file was attached, the text prompt was completely overwritten by the `visionContent` structure, leading to empty messages being sent to LM Studio. +- **Fix:** Merged the text prompt with the vision content array so that both text and file metadata are correctly transmitted. + +### 2. Autonomous Loop Optimization +- **Issue:** Broad keyword matching (e.g., "์กฐ์‚ฌ", "์„ค๋ช…") triggered local file analysis tasks even for general questions, causing the agent to bypass the LLM and give incomplete answers. +- **Fix:** Refined the `isProjectAnalysisRequest` patterns to be more conservative, ensuring generic conversational requests are always handled by the AI model. + +### 3. Second Brain Profile Management +- **Issue:** Adding new brain profiles was inconsistent because a virtual "default-brain" (injected in memory) was being saved into the permanent settings, causing profile list corruption. +- **Fix:** + - Decoupled runtime virtual profiles from persistence logic. + - Implemented direct settings access for profile addition to avoid stale config caches. + - Fixed UI sync issues immediately after adding a new brain folder. + +### 4. Build & Reliability +- Removed premature empty stream chunks that were causing UI flickering. +- Verified build stability with `v2.2.46` VSIX package. + +--- +*Date: 2026-04-25* +*Version: 2.2.46* diff --git a/package-lock.json b/package-lock.json index 3f57a8b..c49caaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "g1nation", - "version": "2.2.15", + "version": "2.2.46", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "g1nation", - "version": "2.2.15", + "version": "2.2.46", "license": "MIT", "dependencies": { "jsdom": "^29.0.2" diff --git a/package.json b/package.json index 7bff388..1afdc5d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "g1nation", "displayName": "G1nation", "description": "100% local AI coding agent for VS Code. Create files, edit code, run commands, and work offline with Ollama or LM Studio.", - "version": "2.2.29", + "version": "2.2.46", "publisher": "connectailab", "license": "MIT", "icon": "assets/icon.png", @@ -115,6 +115,41 @@ "default": "", "description": "Folder path for your local Second Brain knowledge base. Leave empty to use the default folder." }, + "g1nation.brainProfiles": { + "type": "array", + "default": [], + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Stable brain profile id." + }, + "name": { + "type": "string", + "description": "Display name shown in the G1nation brain selector." + }, + "localBrainPath": { + "type": "string", + "description": "Local folder path used as this brain's markdown knowledge base." + }, + "secondBrainRepo": { + "type": "string", + "description": "Optional Git repository URL for this brain." + }, + "description": { + "type": "string", + "description": "Short note shown under the active brain status." + } + } + }, + "description": "Multiple brain profiles. Each item supports id, name, localBrainPath, secondBrainRepo, and description." + }, + "g1nation.activeBrainId": { + "type": "string", + "default": "", + "description": "Active brain profile id used for the current chat context." + }, "g1nation.secondBrainRepo": { "type": "string", "default": "", diff --git a/src/agent.ts b/src/agent.ts index ac899e2..abbd5f5 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -7,10 +7,11 @@ import { _getBrainDir, findBrainFiles, EXCLUDED_DIRS, - SYSTEM_PROMPT, + getSystemPrompt, shouldAutoPushBrain, getSecondBrainRepo, buildApiUrl, + getActiveBrainProfile, logError, logInfo, resolveEngine, @@ -24,10 +25,15 @@ export interface ChatMessage { internal?: boolean; } +type HistoryChangeListener = (history: ChatMessage[]) => void | Promise; + export class AgentExecutor { private chatHistory: ChatMessage[] = []; private abortController: AbortController | null = null; private webview: vscode.Webview | undefined; + private historyChangeListener: HistoryChangeListener | undefined; + private runSerial = 0; + private activeRunId = 0; constructor( private context: vscode.ExtensionContext @@ -37,25 +43,38 @@ export class AgentExecutor { this.webview = webview; } + public setHistoryChangeListener(listener: HistoryChangeListener) { + this.historyChangeListener = listener; + } + public getHistory() { return this.chatHistory.filter(message => !message.internal); } public setHistory(history: ChatMessage[]) { this.chatHistory = history; + this.emitHistoryChanged(); } public clearHistory() { this.chatHistory = []; + this.emitHistoryChanged(); } public stop() { + this.activeRunId = ++this.runSerial; if (this.abortController) { this.abortController.abort(); this.abortController = null; } } + public resetConversation() { + this.stop(); + this.chatHistory = []; + this.emitHistoryChanged(); + } + public async handlePrompt( prompt: string | null, modelName: string, @@ -65,7 +84,8 @@ export class AgentExecutor { loopDepth?: number, visionContent?: any[], temperature?: number, - systemPrompt?: string + systemPrompt?: string, + runId?: number } ) { const { @@ -74,22 +94,60 @@ export class AgentExecutor { loopDepth = 0, visionContent, temperature = 0.7, - systemPrompt = SYSTEM_PROMPT + systemPrompt = getSystemPrompt() } = options; + const runId = options.runId ?? (loopDepth === 0 ? ++this.runSerial : this.activeRunId); + const hasVisionContent = Array.isArray(visionContent) ? visionContent.length > 0 : !!visionContent; + let requestTimeoutHandle: ReturnType | undefined; if (!this.webview) return; try { if (loopDepth === 0) { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + this.activeRunId = runId; await this.context.workspaceState.update('lastActionStr', undefined); } + if (prompt !== null && loopDepth === 0 && !hasVisionContent) { + const localReply = await this.tryHandleLocalTask(prompt); + if (this.isStaleRun(runId)) return; + if (localReply) { + this.chatHistory.push({ role: 'user', content: prompt }); + this.emitHistoryChanged(); + this.webview.postMessage({ type: 'streamStart' }); + this.webview.postMessage({ type: 'streamChunk', value: localReply }); + this.webview.postMessage({ type: 'streamEnd' }); + this.chatHistory.push({ role: 'assistant', content: localReply }); + this.emitHistoryChanged(); + return; + } + } + // 1. Prepare Context const workspaceFolders = vscode.workspace.workspaceFolders; const rootPath = workspaceFolders ? workspaceFolders[0].uri.fsPath : ''; let contextBlock = ''; const config = getConfig(); + const activeBrain = getActiveBrainProfile(); + const brainFiles = findBrainFiles(activeBrain.localBrainPath); + const brainPreview = brainFiles + .slice(0, 30) + .map(file => path.relative(activeBrain.localBrainPath, file)) + .join('\n'); + const brainContext = [ + `[ACTIVE SECOND BRAIN]`, + `Use this Local Brain only when it is relevant to the user's current question.`, + `Name: ${activeBrain.name}`, + `Path: ${activeBrain.localBrainPath}`, + `Knowledge files: ${brainFiles.length}`, + activeBrain.description ? `Description: ${activeBrain.description}` : '', + brainPreview ? `Available file examples:\n${brainPreview}` : 'Files: none found' + ].filter(Boolean).join('\n'); const editor = vscode.window.activeTextEditor; if (editor && editor.document.uri.scheme === 'file') { const text = editor.document.getText(); @@ -103,7 +161,7 @@ export class AgentExecutor { if (prompt !== null) { if (loopDepth === 0) { this.chatHistory.push({ role: 'user', content: prompt }); - this.webview.postMessage({ type: 'streamChunk', value: '' }); // Trigger UI update if needed + this.emitHistoryChanged(); } else { this.chatHistory.push({ role: 'system', content: prompt, internal: true }); } @@ -114,10 +172,18 @@ export class AgentExecutor { const reqMessages = [...this.chatHistory]; // Handle Vision Content Injection - if (visionContent && reqMessages.length > 0) { + // Merge text prompt with file content instead of replacing, so the user's message is never lost + if (hasVisionContent && reqMessages.length > 0) { const lastUserIdx = reqMessages.map(m => m.role).lastIndexOf('user'); if (lastUserIdx >= 0) { - reqMessages[lastUserIdx] = { role: 'user', content: visionContent }; + const existingContent = reqMessages[lastUserIdx].content; + const textParts: any[] = (typeof existingContent === 'string' && existingContent.trim()) + ? [{ type: 'text', text: existingContent }] + : []; + reqMessages[lastUserIdx] = { + role: 'user', + content: [...textParts, ...(visionContent || [])] + }; } } @@ -125,7 +191,7 @@ export class AgentExecutor { const internetCtx = internetEnabled ? `\n\n[CRITICAL: INTERNET ACCESS ENABLED]\nYou can use to search. Current time: ${new Date().toLocaleString()}` : ''; - const fullSystemPrompt = `${systemPrompt}${internetCtx}\n\n[CONTEXT]\n${contextBlock}`; + const fullSystemPrompt = `${systemPrompt}${internetCtx}\n\n[CONTEXT]\n${brainContext}\n${contextBlock}`; const messagesForRequest: ChatMessage[] = [ { role: 'system', content: fullSystemPrompt, internal: true }, ...reqMessages @@ -133,6 +199,10 @@ export class AgentExecutor { // 4. Call AI Engine this.abortController = new AbortController(); + requestTimeoutHandle = setTimeout(() => { + logError('AI request timed out.', { timeoutMs: timeout, model: modelName || defaultModel, loopDepth }); + this.abortController?.abort(); + }, timeout); const request = await this.createStreamingRequest({ baseUrl: ollamaUrl, modelName: modelName || defaultModel, @@ -140,6 +210,7 @@ export class AgentExecutor { temperature }); const { response, engine, apiUrl } = request; + if (this.isStaleRun(runId)) return; let aiResponseText = ''; const reader = response.body?.getReader(); @@ -153,6 +224,7 @@ export class AgentExecutor { while (true) { const { done, value } = await reader.read(); if (done) break; + if (this.isStaleRun(runId)) return; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); @@ -166,7 +238,6 @@ export class AgentExecutor { const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || ''; if (token) { aiResponseText += token; - this.webview?.postMessage({ type: 'streamChunk', value: token }); } } catch (e: any) { logError('Failed to parse streaming chunk.', { engine, apiUrl, chunk: summarizeText(trimmed, 300), error: e?.message || String(e) }); @@ -191,26 +262,41 @@ export class AgentExecutor { const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || ''; if (token) { aiResponseText += token; - this.webview?.postMessage({ type: 'streamChunk', value: token }); } } catch (e: any) { logError('Failed to parse final streaming buffer.', { engine, apiUrl, buffer: summarizeText(buffer, 300), error: e?.message || String(e) }); } } - this.chatHistory.push({ role: 'assistant', content: aiResponseText }); + if (this.isStaleRun(runId)) return; + if (requestTimeoutHandle) { + clearTimeout(requestTimeoutHandle); + requestTimeoutHandle = undefined; + } // 5. Execute Actions + const assistantMessage: ChatMessage = { role: 'assistant', content: aiResponseText, internal: true }; + this.chatHistory.push(assistantMessage); const report = await this.executeActions(aiResponseText, rootPath); if (!aiResponseText.trim() && report.length === 0) { + this.chatHistory.pop(); logError('Model returned an empty response without actions.', { model: modelName || defaultModel, loopDepth }); this.webview.postMessage({ type: 'error', value: 'AI model returned an empty response.' }); return; } + if (report.length === 0 && loopDepth === 0 && this.isUnproductiveWaitingReply(aiResponseText)) { + assistantMessage.internal = false; + const correctedReply = this.buildUnproductiveReplyCorrection(prompt || ''); + assistantMessage.content = correctedReply; + this.emitHistoryChanged(); + this.webview.postMessage({ type: 'streamChunk', value: correctedReply }); + return; + } + if (report.length > 0) { - const reportMsg = `\n\n> โš™๏ธ **System Action Report** (${loopDepth + 1}/${config.maxAutoSteps})\n> ${report.join("\n> ")}\n\n`; - this.webview.postMessage({ type: 'streamChunk', value: reportMsg }); + this.emitHistoryChanged(); + logInfo('Agent actions executed.', { loopDepth: loopDepth + 1, report }); // Continue loop if needed if (loopDepth < config.maxAutoSteps) { @@ -226,24 +312,370 @@ export class AgentExecutor { logInfo('Autonomous loop continuing after actions.', { loopDepth: loopDepth + 1, actions: report }); // Explicitly tell the AI to look at the results and continue - const continuationPrompt = "I have executed your actions. Above is the result. Please analyze it and provide the next step or the final answer."; + const continuationPrompt = "The requested local action has been executed. Use the action result messages already in the conversation to answer the user's original request directly, in the user's language. Do not say you are waiting for the next instruction."; - this.webview.postMessage({ type: 'autoContinue', value: `Thinking... (${loopDepth + 1}/${config.maxAutoSteps})` }); + this.webview.postMessage({ type: 'autoContinue', value: `์ž๋ฃŒ๋ฅผ ํ™•์ธํ•˜๊ณ  ๋‹ต๋ณ€์„ ์ •๋ฆฌํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค... (${loopDepth + 1}/${config.maxAutoSteps})` }); await new Promise(r => setTimeout(r, 800)); - await this.handlePrompt(continuationPrompt, modelName, { ...options, loopDepth: loopDepth + 1 }); + if (this.isStaleRun(runId)) return; + await this.handlePrompt(continuationPrompt, modelName, { ...options, loopDepth: loopDepth + 1, runId }); } + return; } + assistantMessage.internal = false; + this.emitHistoryChanged(); + this.webview.postMessage({ type: 'streamChunk', value: aiResponseText }); + } catch (error: any) { logError('Agent prompt failed.', { error: error?.message || String(error), promptPreview: summarizeText(prompt || '', 200) }); - this.webview.postMessage({ type: "error", value: `[Agent Error]: ${error.message}` }); + if (!this.isStaleRun(runId)) { + this.webview.postMessage({ type: "error", value: `[Agent Error]: ${error.message}` }); + } } finally { - if (loopDepth === 0) { + if (requestTimeoutHandle) { + clearTimeout(requestTimeoutHandle); + } + if (loopDepth === 0 && !this.isStaleRun(runId)) { this.webview.postMessage({ type: 'streamEnd' }); } } } + private async tryHandleLocalTask(prompt: string): Promise { + const normalized = prompt.trim().toLowerCase(); + if (!normalized) return null; + + const projectPath = this.resolveProjectReference(prompt); + if (projectPath && this.isProjectAnalysisRequest(normalized)) { + return this.buildProjectAnalysisReply(projectPath); + } + + if (this.isBrainOverviewRequest(normalized)) { + return this.buildBrainOverviewReply(); + } + + return null; + } + + private extractExistingProjectPath(prompt: string): string | null { + const pathMatches = prompt.match(/(?:~|\/)[^\s`'"]+/g) || []; + for (const rawPath of pathMatches) { + const expandedPath = rawPath.startsWith('~/') + ? path.join(require('os').homedir(), rawPath.slice(2)) + : rawPath; + if (fs.existsSync(expandedPath)) { + return expandedPath; + } + } + return null; + } + + private resolveProjectReference(prompt: string): string | null { + const explicitPath = this.extractExistingProjectPath(prompt); + if (explicitPath) return explicitPath; + + const namedProject = prompt.match(/([A-Za-z0-9._-]+)\s*(?:ํ”„๋กœ์ ํŠธ|project)/i)?.[1]; + if (!namedProject) return null; + + const searchRoots = [ + '/Volumes/Data/project/Antigravity', + '/Volumes/Data/project', + vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '' + ].filter(Boolean); + + for (const root of searchRoots) { + const resolved = this.findDirectoryByName(root, namedProject, 4); + if (resolved) return resolved; + } + + return null; + } + + private findDirectoryByName(root: string, targetName: string, maxDepth: number): string | null { + if (!root || maxDepth < 0 || !fs.existsSync(root)) return null; + const normalizedTarget = targetName.toLowerCase(); + + try { + const entries = fs.readdirSync(root, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name)); + + const exact = entries.find(entry => entry.name.toLowerCase() === normalizedTarget); + if (exact) return path.join(root, exact.name); + + const partial = entries.find(entry => entry.name.toLowerCase().includes(normalizedTarget)); + if (partial) return path.join(root, partial.name); + + for (const entry of entries) { + const found = this.findDirectoryByName(path.join(root, entry.name), targetName, maxDepth - 1); + if (found) return found; + } + } catch (error: any) { + logError('Project name search failed.', { root, targetName, error: error?.message || String(error) }); + } + + return null; + } + + private isProjectAnalysisRequest(normalized: string): boolean { + // Intentionally conservative: omit generic words like '์กฐ์‚ฌ', '์„ค๋ช…', 'ํŒŒ์•…' + // that appear in ordinary chat and would incorrectly bypass LM Studio + return /(๋ถ„์„|๋ฆฌ๋ทฐ|์„ค๊ณ„|๊ตฌ์กฐ|์–ด๋–ค ํ”„๋กœ๊ทธ๋žจ|๋ฌด์Šจ ํ”„๋กœ๊ทธ๋žจ|์ œํ’ˆ ์„ค๋ช…)/.test(normalized); + } + + private buildProjectAnalysisReply(projectPath: string): string { + const stat = fs.statSync(projectPath); + if (!stat.isDirectory()) { + const content = fs.readFileSync(projectPath, 'utf-8'); + return [ + `์š”์ฒญํ•˜์‹  ํŒŒ์ผ์„ ์‹ค์ œ๋กœ ์ฝ์—ˆ์Šต๋‹ˆ๋‹ค: \`${projectPath}\``, + '', + `ํŒŒ์ผ ํฌ๊ธฐ: ${content.length.toLocaleString()}์ž`, + '', + '์ฒซ ๋ถ€๋ถ„ ์š”์•ฝ:', + '```', + content.slice(0, 1800), + content.length > 1800 ? '\n... (truncated)' : '', + '```' + ].join('\n'); + } + + const packagePath = path.join(projectPath, 'package.json'); + const readmePath = this.findFirstExisting(projectPath, ['README.md', 'readme.md', 'README.MD']); + const files = this.collectProjectFiles(projectPath, 600); + const sourceFiles = files.filter(file => /\/src\/|\/app\/|\/pages\/|\/components\/|\/lib\//.test(file)); + const testFiles = files.filter(file => /\.(test|spec)\.[jt]sx?$|\/__tests__\//.test(file)); + const configFiles = files.filter(file => /(^|\/)(package\.json|tsconfig\.json|vite\.config\.|next\.config\.|tailwind\.config\.|eslint\.config\.|\.eslintrc|dockerfile|docker-compose|README\.md)/i.test(file)); + + let pkg: any = null; + if (fs.existsSync(packagePath)) { + try { + pkg = JSON.parse(fs.readFileSync(packagePath, 'utf-8')); + } catch (error: any) { + logError('Failed to parse package.json during local project analysis.', { projectPath, error: error?.message || String(error) }); + } + } + + const readmeText = readmePath ? fs.readFileSync(readmePath, 'utf-8') : ''; + const topDirs = this.summarizeTopDirectories(projectPath); + const stack = this.inferStack(pkg, files); + const entryPoints = this.inferEntryPoints(pkg, files); + const readmeSummary = this.summarizeReadme(readmeText); + const reviewFindings = this.buildProjectReviewFindings({ pkg, files, sourceFiles, testFiles, readmeText }); + + return [ + `์ œ๊ฐ€ ์‹ค์ œ๋กœ \`${projectPath}\` ํด๋”๋ฅผ ์ฝ๊ณ  1์ฐจ ๋ถ„์„ํ–ˆ์Šต๋‹ˆ๋‹ค.`, + '', + '**์ œํ’ˆ ์„ค๋ช…**', + pkg?.name + ? `- ์ด ํ”„๋กœ์ ํŠธ๋Š” \`${pkg.name}\`${pkg.description ? ` (${pkg.description})` : ''}๋กœ ์‹๋ณ„๋ฉ๋‹ˆ๋‹ค.` + : '- `package.json` ๊ธฐ์ค€์˜ ์ œํ’ˆ๋ช…์€ ํ™•์ธ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.', + readmeSummary ? `- README ๊ธฐ์ค€ ํ•ต์‹ฌ ์„ค๋ช…: ${readmeSummary}` : '- README ๊ธฐ๋ฐ˜ ์ œํ’ˆ ์„ค๋ช…์€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค. ์ œํ’ˆ ์†Œ๊ฐœ ๋ฌธ์„œ๋ฅผ ๋ณด๊ฐ•ํ•˜๋Š” ํŽธ์ด ์ข‹์Šต๋‹ˆ๋‹ค.', + stack.length ? `- ๊ฐ์ง€๋œ ๊ธฐ์ˆ  ์Šคํƒ: ${stack.join(', ')}` : '- ๊ธฐ์ˆ  ์Šคํƒ์€ ํŒŒ์ผ ๊ตฌ์กฐ๋งŒ์œผ๋กœ๋Š” ๋ช…ํ™•ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.', + '', + '**์„ค๊ณ„ ๊ตฌ์กฐ**', + topDirs.length ? topDirs.map(item => `- ${item}`).join('\n') : '- ์ƒ์œ„ ๋””๋ ‰ํ„ฐ๋ฆฌ ๊ตฌ์กฐ๊ฐ€ ๋‹จ์ˆœํ•˜๊ฑฐ๋‚˜ ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค.', + entryPoints.length ? `- ์ฃผ์š” ์ง„์ž…์  ํ›„๋ณด: ${entryPoints.join(', ')}` : '- ๋ช…ํ™•ํ•œ ์•ฑ ์ง„์ž…์ ์€ ์•„์ง ์‹๋ณ„๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.', + configFiles.length ? `- ์ฃผ์š” ์„ค์ • ํŒŒ์ผ: ${configFiles.slice(0, 12).join(', ')}` : '- ์ฃผ์š” ์„ค์ • ํŒŒ์ผ์ด ๋งŽ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.', + '', + '**์ฝ”๋“œ ๋ฆฌ๋ทฐ ๊ด€์ ์˜ 1์ฐจ ์†Œ๊ฒฌ**', + reviewFindings.join('\n'), + '', + '**๋‹ค์Œ์— ๋” ๊นŠ๊ฒŒ ๋ณผ ๋ถ€๋ถ„**', + '- ์‹ค์ œ ๋น„์ฆˆ๋‹ˆ์Šค ํ”Œ๋กœ์šฐ๋Š” `src`, `app`, `lib`, `components` ๋‚ด๋ถ€ ํ•ต์‹ฌ ํŒŒ์ผ์„ 2์ฐจ๋กœ ์ฝ์–ด์•ผ ์ •ํ™•ํžˆ ํŒ๋‹จํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', + '- ์ง€๊ธˆ ๋‹จ๊ณ„์—์„œ๋Š” โ€œ์•„๋ฌด๊ฒƒ๋„ ์•ˆ ํ•˜๊ณ  ๋ง๋งŒ ํ•˜๋Š”โ€ ๋‹ต๋ณ€์ด ์•„๋‹ˆ๋ผ, ์‹ค์ œ ํŒŒ์ผ ์‹œ์Šคํ…œ์„ ๊ธฐ์ค€์œผ๋กœ ํ”„๋กœ์ ํŠธ ํ˜•ํƒœ๋ฅผ ๋จผ์ € ํŒŒ์•…ํ•œ ๊ฒฐ๊ณผ์ž…๋‹ˆ๋‹ค.', + '- ์›ํ•˜์‹œ๋ฉด ๋‹ค์Œ ํ„ด์—์„œ ์ œ๊ฐ€ ํ•ต์‹ฌ ์†Œ์Šค ํŒŒ์ผ์„ ๋” ์ฝ์–ด ์•„ํ‚คํ…์ฒ˜ ๋‹ค์ด์–ด๊ทธ๋žจ ์ˆ˜์ค€์œผ๋กœ ์ด์–ด์„œ ์ •๋ฆฌํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.' + ].join('\n'); + } + + private findFirstExisting(basePath: string, names: string[]): string | null { + for (const name of names) { + const candidate = path.join(basePath, name); + if (fs.existsSync(candidate)) return candidate; + } + return null; + } + + private collectProjectFiles(dir: string, limit: number, baseDir: string = dir): string[] { + if (limit <= 0 || !fs.existsSync(dir)) return []; + const entries = fs.readdirSync(dir, { withFileTypes: true }) + .filter(entry => !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name)) + .sort((a, b) => a.name.localeCompare(b.name)); + const results: string[] = []; + + for (const entry of entries) { + if (results.length >= limit) break; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...this.collectProjectFiles(fullPath, limit - results.length, baseDir)); + } else { + results.push(path.relative(baseDir, fullPath)); + } + } + + return results.slice(0, limit); + } + + private summarizeTopDirectories(projectPath: string): string[] { + return fs.readdirSync(projectPath, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name)) + .slice(0, 12) + .map(entry => { + const count = this.collectProjectFiles(path.join(projectPath, entry.name), 200, projectPath).length; + return `\`${entry.name}/\`: ์•ฝ ${count}๊ฐœ ํŒŒ์ผ`; + }); + } + + private inferStack(pkg: any, files: string[]): string[] { + const deps = { ...(pkg?.dependencies || {}), ...(pkg?.devDependencies || {}) }; + const stack = new Set(); + if (deps.next || files.some(file => file.startsWith('app/') || file.startsWith('pages/'))) stack.add('Next.js'); + if (deps.react || files.some(file => /\.(jsx|tsx)$/.test(file))) stack.add('React'); + if (deps.typescript || files.some(file => file.endsWith('.ts') || file.endsWith('.tsx'))) stack.add('TypeScript'); + if (deps.vite || files.some(file => file.startsWith('vite.config.'))) stack.add('Vite'); + if (deps.tailwindcss || files.some(file => file.startsWith('tailwind.config.'))) stack.add('Tailwind CSS'); + if (deps.express) stack.add('Express'); + if (deps.electron) stack.add('Electron'); + if (deps.prisma || files.some(file => file.startsWith('prisma/'))) stack.add('Prisma'); + if (files.some(file => file.includes('docker-compose') || file.toLowerCase() === 'dockerfile')) stack.add('Docker'); + return Array.from(stack); + } + + private inferEntryPoints(pkg: any, files: string[]): string[] { + const candidates = [ + pkg?.main, + 'src/main.ts', + 'src/index.ts', + 'src/App.tsx', + 'src/app.ts', + 'app/page.tsx', + 'pages/index.tsx', + 'server/index.ts', + 'index.js' + ].filter(Boolean) as string[]; + return candidates.filter((candidate, index) => candidates.indexOf(candidate) === index && files.includes(candidate)); + } + + private summarizeReadme(readmeText: string): string { + if (!readmeText.trim()) return ''; + const usefulLines = readmeText + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#') && !line.startsWith('![') && !line.startsWith('<')) + .slice(0, 3) + .join(' '); + return summarizeText(usefulLines, 260); + } + + private buildProjectReviewFindings(params: { + pkg: any; + files: string[]; + sourceFiles: string[]; + testFiles: string[]; + readmeText: string; + }): string[] { + const findings: string[] = []; + if (!params.readmeText.trim()) { + findings.push('- README๊ฐ€ ์—†๊ฑฐ๋‚˜ ๋น„์–ด ์žˆ์–ด, ์‚ฌ์šฉ์ž๊ฐ€ ์ œํ’ˆ ๋ชฉ์ /์‹คํ–‰ ๋ฐฉ๋ฒ•์„ ์ดํ•ดํ•˜๊ธฐ ์–ด๋ ต์Šต๋‹ˆ๋‹ค.'); + } + if (params.testFiles.length === 0) { + findings.push('- ํ…Œ์ŠคํŠธ ํŒŒ์ผ์ด ๊ฐ์ง€๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ํ•ต์‹ฌ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์ด๋‚˜ API ๋ ˆ์ด์–ด๋ถ€ํ„ฐ ์ตœ์†Œ ํšŒ๊ท€ ํ…Œ์ŠคํŠธ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.'); + } + if (!params.pkg?.scripts || Object.keys(params.pkg.scripts).length === 0) { + findings.push('- `package.json` scripts๊ฐ€ ์—†๊ฑฐ๋‚˜ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค. `dev`, `build`, `test`, `lint` ๊ฐ™์€ ํ‘œ์ค€ ๋ช…๋ น์ด ์žˆ์œผ๋ฉด ์ œํ’ˆ ์šด์šฉ์„ฑ์ด ์ข‹์•„์ง‘๋‹ˆ๋‹ค.'); + } else { + const scripts = Object.keys(params.pkg.scripts); + const missing = ['build', 'test', 'lint'].filter(script => !scripts.includes(script)); + if (missing.length) findings.push(`- ๋ˆ„๋ฝ๋œ ํ‘œ์ค€ ์Šคํฌ๋ฆฝํŠธ ํ›„๋ณด: ${missing.map(script => `\`${script}\``).join(', ')}.`); + } + if (params.sourceFiles.length > 80 && params.testFiles.length < 3) { + findings.push('- ์†Œ์Šค ํŒŒ์ผ ๊ทœ๋ชจ์— ๋น„ํ•ด ํ…Œ์ŠคํŠธ ๋ฐ€๋„๊ฐ€ ๋‚ฎ์•„ ๋ณด์ž…๋‹ˆ๋‹ค. ํ™”๋ฉด/์ƒํƒœ/API ๊ฒฝ๊ณ„๋ฅผ ๊ธฐ์ค€์œผ๋กœ ํ…Œ์ŠคํŠธ ์ „๋žต์„ ๋ถ„๋ฆฌํ•˜๋Š” ํŽธ์ด ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค.'); + } + if (!params.files.some(file => /env\.example|\.env\.example|\.env\.sample/i.test(file))) { + findings.push('- ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์˜ˆ์‹œ ํŒŒ์ผ์ด ๋ณด์ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋กœ์ปฌ ์‹คํ–‰๊ณผ ๋ฐฐํฌ ์žฌํ˜„์„ฑ์„ ์œ„ํ•ด `.env.example`์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.'); + } + if (findings.length === 0) { + findings.push('- 1์ฐจ ๊ตฌ์กฐ์ƒ ํฐ ๊ฒฐํ•จ์€ ๋ฐ”๋กœ ๋ณด์ด์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋‹ค์Œ ๋‹จ๊ณ„์—์„œ๋Š” ํ•ต์‹ฌ ์†Œ์Šค ํŒŒ์ผ์„ ์ฝ์–ด ๋ฐ์ดํ„ฐ ํ๋ฆ„, ์—๋Ÿฌ ์ฒ˜๋ฆฌ, ์ƒํƒœ ๊ด€๋ฆฌ ๊ฒฝ๊ณ„๋ฅผ ๋ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.'); + } + return findings; + } + + private isUnproductiveWaitingReply(reply: string): boolean { + const normalized = reply.replace(/\s+/g, ' ').trim(); + return /(์ค€๋น„๋˜์—ˆ์Šต๋‹ˆ๋‹ค|๋‹ค์Œ ์ง€์‹œ|๋ง์”€ํ•ด ์ฃผ์„ธ์š”|๋ช…๋ น์„ ๊ธฐ๋‹ค|์ž‘์—…์„ ๋„์™€๋“œ๋ฆด๊นŒ์š”)/.test(normalized) + && !/<(list_files|read_file|list_brain|read_brain|run_command|edit_file|create_file)/i.test(reply); + } + + private buildUnproductiveReplyCorrection(prompt: string): string { + const projectPath = this.resolveProjectReference(prompt); + if (projectPath && this.isProjectAnalysisRequest(prompt.toLowerCase())) { + return this.buildProjectAnalysisReply(projectPath); + } + + return '๋ฐฉ๊ธˆ ๋‹ต๋ณ€์€ ์ž˜๋ชป๋œ ์‘๋‹ต์ž…๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž์˜ ๋ง์€ โ€œ๋‹ค์Œ ์ง€์‹œ๋ฅผ ๋‹ฌ๋ผโ€๊ฐ€ ์•„๋‹ˆ๋ผ ์ง€๊ธˆ ๋ฐ”๋กœ ์ฒ˜๋ฆฌํ•ด์•ผ ํ•˜๋Š” ์ž‘์—… ์ง€์‹œ์ž…๋‹ˆ๋‹ค. ์ œ๊ฐ€ ๋จผ์ € ๊ด€๋ จ ์ž๋ฃŒ๋ฅผ ํ™•์ธํ•˜๊ณ , ํ™•์ธํ•œ ๋‚ด์šฉ ๊ธฐ์ค€์œผ๋กœ ๋‹ต๋ณ€ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ๊ฐ€๋Šฅํ•˜๋ฉด ํ”„๋กœ์ ํŠธ๋ช… ๋Œ€์‹  ์ •ํ™•ํ•œ ํด๋” ๊ฒฝ๋กœ๋ฅผ ํ•จ๊ป˜ ์ฃผ์‹œ๋ฉด ๋” ์•ˆ์ •์ ์œผ๋กœ ๋ถ„์„ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.'; + } + + private isBrainOverviewRequest(normalized: string): boolean { + const mentionsBrain = /(์ œ2๋‡Œ|second brain|local brain|๋ธŒ๋ ˆ์ธ|brain)/.test(normalized); + const asksOverview = /(์–ด๋– |๋ญ๊ฐ€|๋ฌด์—‡|๋‚ด์šฉ|์ •๋ณด|๊ตฌ์„ฑ|์ค€๋น„|๋ถ„์„|์ •๋ฆฌ|ํŒŒ์•…)/.test(normalized); + return mentionsBrain && asksOverview; + } + + private buildBrainOverviewReply(): string { + const activeBrain = getActiveBrainProfile(); + const brainDir = activeBrain.localBrainPath; + const files = findBrainFiles(brainDir); + + if (!fs.existsSync(brainDir)) { + return `ํ˜„์žฌ ์„ ํƒ๋œ ์ œ2๋‡Œ๋Š” **${activeBrain.name}**์ธ๋ฐ, ํด๋”๊ฐ€ ์•„์ง ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.\n\n๊ฒฝ๋กœ: \`${brainDir}\`\n\n์ƒ๋‹จ์˜ Brain ์„ค์ •์—์„œ ์‹ค์ œ ์ง€์‹ ํด๋”๋ฅผ ์—ฐ๊ฒฐํ•˜๋ฉด ์ œ๊ฐ€ ๊ทธ ์ž๋ฃŒ๋ฅผ ๋จผ์ € ์ฐธ๊ณ ํ•ด์„œ ๋‹ต๋ณ€ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.`; + } + + const entries = fs.readdirSync(brainDir, { withFileTypes: true }) + .filter((entry) => !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name)); + + const directorySummaries = entries + .filter((entry) => entry.isDirectory()) + .slice(0, 12) + .map((entry) => { + const dirPath = path.join(brainDir, entry.name); + const count = findBrainFiles(dirPath).length; + return `- ${entry.name}/: ${count}๊ฐœ ๋ฌธ์„œ`; + }); + + const fileSummaries = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.md')) + .slice(0, 8) + .map((entry) => `- ${entry.name}`); + + const sampleFiles = files + .slice(0, 10) + .map((file) => `- ${path.relative(brainDir, file)}`); + + const sections = [ + `ํ˜„์žฌ ์—ฐ๊ฒฐ๋œ ์ œ2๋‡Œ๋Š” **${activeBrain.name}**์ž…๋‹ˆ๋‹ค.`, + `๊ฒฝ๋กœ: \`${brainDir}\``, + `์ด Markdown ์ง€์‹ ๋ฌธ์„œ: **${files.length}๊ฐœ**`, + activeBrain.description ? `์„ค๋ช…: ${activeBrain.description}` : '', + directorySummaries.length ? `\n์ƒ์œ„ ํด๋” ๊ตฌ์กฐ๋Š” ์ด๋ ‡๊ฒŒ ๋ณด์ž…๋‹ˆ๋‹ค.\n${directorySummaries.join('\n')}` : '', + fileSummaries.length ? `\n๋ฃจํŠธ์— ์žˆ๋Š” ์ฃผ์š” ๋ฌธ์„œ๋Š” ์ด๋Ÿฐ ๊ฒƒ๋“ค์ด ์žˆ์Šต๋‹ˆ๋‹ค.\n${fileSummaries.join('\n')}` : '', + sampleFiles.length ? `\n์ œ๊ฐ€ ์šฐ์„  ์ฐธ๊ณ ํ•  ์ˆ˜ ์žˆ๋Š” ์ƒ˜ํ”Œ ๋ฌธ์„œ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค.\n${sampleFiles.join('\n')}` : '', + `\n์ด ์ž๋ฃŒ๋Š” ์ถฉ๋ถ„ํžˆ ๋„์›€์ด ๋ฉ๋‹ˆ๋‹ค. ์•ž์œผ๋กœ ์งˆ๋ฌธ์„ ๋ฐ›์œผ๋ฉด ๋จผ์ € ์ด ์ œ2๋‡Œ์—์„œ ๊ด€๋ จ ๊ธฐ์ค€์ด๋‚˜ ๋งฅ๋ฝ์„ ์ฐพ๊ณ , ๋ถ€์กฑํ•  ๋•Œ๋งŒ ์ œ ์ผ๋ฐ˜ ์ง€์‹์œผ๋กœ ๋ณด์™„ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.` + ].filter(Boolean); + + return sections.join('\n'); + } + + private isStaleRun(runId: number): boolean { + return runId !== this.activeRunId; + } + + private emitHistoryChanged() { + if (!this.historyChangeListener) return; + + Promise.resolve(this.historyChangeListener(this.getHistory())).catch((error: any) => { + logError('History change listener failed.', { error: error?.message || String(error) }); + }); + } + private async createStreamingRequest(params: { baseUrl: string; modelName: string; @@ -257,48 +689,110 @@ export class AgentExecutor { for (const engine of engines) { const apiUrl = buildApiUrl(baseUrl, engine, 'chat'); - const streamBody = { - model: modelName, - messages: reqMessages, - stream: true, - ...(engine === 'lmstudio' - ? { max_tokens: 4096, temperature } - : { options: { num_ctx: 32768, num_predict: 4096, temperature } }), - }; + const messageVariants = this.buildEngineMessageVariants(reqMessages, engine); + const modelCandidates = this.buildModelCandidates(modelName, engine); - try { - logInfo('AI streaming request started.', { engine, apiUrl, model: modelName, messageCount: reqMessages.length }); - const response = await fetch(apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive' - }, - body: JSON.stringify(streamBody), - signal: this.abortController?.signal, - keepalive: true - }); + for (const candidateModel of modelCandidates) { + for (const variant of messageVariants) { + const streamBody = { + model: candidateModel, + messages: variant.messages, + stream: true, + ...(engine === 'lmstudio' + ? { max_tokens: 4096, temperature } + : { options: { num_ctx: 32768, num_predict: 4096, temperature } }), + }; - if (!response.ok) { - const errText = await response.text(); - lastError = new Error(`AI Engine error (${engine}): ${response.status} - ${summarizeText(errText, 300)}`); - logError('AI streaming request returned non-OK status.', { engine, apiUrl, status: response.status, body: summarizeText(errText, 500) }); - continue; + try { + logInfo('AI streaming request started.', { + engine, + apiUrl, + model: candidateModel, + variant: variant.name, + messageCount: variant.messages.length, + roles: variant.messages.map(message => message.role), + firstUserPreview: summarizeText(String(variant.messages.find(message => message.role === 'user')?.content || ''), 300) + }); + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + }, + body: JSON.stringify(streamBody), + signal: this.abortController?.signal, + keepalive: true + }); + + if (!response.ok) { + const errText = await response.text(); + lastError = new Error(`AI Engine error (${engine}/${variant.name}): ${response.status} - ${summarizeText(errText, 300)}`); + logError('AI streaming request returned non-OK status.', { engine, variant: variant.name, apiUrl, status: response.status, body: summarizeText(errText, 500) }); + continue; + } + + logInfo('AI streaming request connected.', { engine, variant: variant.name, apiUrl }); + return { response, engine, apiUrl }; + } catch (error: any) { + lastError = error instanceof Error ? error : new Error(String(error)); + logError('AI streaming request failed.', { engine, variant: variant.name, apiUrl, model: candidateModel, error: lastError.message }); } - - logInfo('AI streaming request connected.', { engine, apiUrl }); - return { response, engine, apiUrl }; - } catch (error: any) { - lastError = error instanceof Error ? error : new Error(String(error)); - logError('AI streaming request failed.', { engine, apiUrl, error: lastError.message }); + } } } throw lastError || new Error('Unable to connect to AI engine.'); } + private normalizeMessages(messages: ChatMessage[]) { + return messages.map((message) => { + const normalizedContent = typeof message.content === 'string' + ? message.content + : JSON.stringify(message.content); + + return { + role: message.role, + content: normalizedContent + }; + }); + } + + private buildEngineMessageVariants(messages: ChatMessage[], engine: 'lmstudio' | 'ollama') { + const normalized = this.normalizeMessages(messages); + if (engine !== 'lmstudio') { + return [{ name: 'native', messages: normalized }]; + } + + const flattened = normalized.map((message) => { + if (message.role === 'system') { + return { + role: 'user' as const, + content: `[System Instruction - do not answer this message]\n${message.content}` + }; + } + + return message; + }); + + return [ + { name: 'native-system', messages: normalized }, + { name: 'flattened-system-fallback', messages: flattened } + ]; + } + + private buildModelCandidates(modelName: string, engine: 'lmstudio' | 'ollama'): string[] { + const candidates = [modelName]; + if (engine === 'lmstudio') { + const baseModel = modelName.replace(/:\d+$/, ''); + if (baseModel && baseModel !== modelName) { + candidates.push(baseModel); + } + } + return candidates; + } + private async executeActions(aiMessage: string, rootPath: string): Promise { const report: string[] = []; let brainModified = false; diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts index eb6c3e8..8f139c6 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -1,31 +1,57 @@ import * as vscode from 'vscode'; import * as fs from 'fs'; +import * as path from 'path'; import { getConfig, _getBrainDir, findBrainFiles, buildApiUrl, + getActiveBrainProfile, + getBrainProfiles, logError, logInfo, resolveEngine, summarizeText } from './utils'; -import { AgentExecutor } from './agent'; +import { AgentExecutor, ChatMessage } from './agent'; import { BridgeInterface } from './bridge'; +interface LastVisibleChatSnapshot { + history: ChatMessage[]; + brainProfileId: string; + sessionId: string | null; + timestamp: number; +} + +interface ChatSession { + id: string; + title: string; + timestamp: number; + history: ChatMessage[]; + brainProfileId: string; +} + /** * Sidebar UI Provider implementing BridgeInterface for BridgeServer */ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeInterface { public static readonly viewType = 'g1nation-v2-view'; + private static readonly activeSessionStateKey = 'g1nation.activeSessionId'; + private static readonly lastVisibleChatStateKey = 'g1nation.lastVisibleChat'; + private static readonly blankChatStateKey = 'g1nation.blankChatActive'; private _view?: vscode.WebviewView; public brainEnabled = true; + private _currentSessionBrainId: string | null = null; constructor( private readonly _extensionUri: vscode.Uri, private readonly _context: vscode.ExtensionContext, private readonly _agent: AgentExecutor - ) {} + ) { + this._agent.setHistoryChangeListener((history) => { + void this._persistLastVisibleChat(history); + }); + } public resolveWebviewView( webviewView: vscode.WebviewView, @@ -42,33 +68,36 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn webviewView.webview.html = this._getHtml(webviewView.webview); this._agent.setWebview(webviewView.webview); - // Re-hydrate existing history - const currentHistory = this._agent.getHistory(); - if (currentHistory.length > 0) { - setTimeout(() => { - webviewView.webview.postMessage({ type: 'restoreHistory', value: currentHistory }); - }, 500); - } + void this._restoreActiveSessionIntoView(); webviewView.webview.onDidReceiveMessage(async (data) => { switch (data.type) { case 'prompt': case 'promptWithFile': + await this._context.globalState.update(SidebarChatProvider.blankChatStateKey, false); await this._handlePrompt(data); // After prompt, save the session automatically await this._saveCurrentSession(); break; case 'ready': await this._sendBrainStatus(); + await this._sendBrainProfiles(); await this._sendSessionList(); + await this._sendModels(); + await this._restoreActiveSessionIntoView(); break; case 'getModels': await this._sendModels(); break; case 'newChat': this._currentSessionId = null; - this._agent.clearHistory(); + this._currentSessionBrainId = getActiveBrainProfile().id; + this._agent.resetConversation(); + await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null); + await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null); + await this._context.globalState.update(SidebarChatProvider.blankChatStateKey, true); this.clearChat(); + await this._sendBrainStatus(); break; case 'stopGeneration': this._agent.stop(); @@ -82,23 +111,81 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn case 'openSettings': vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation'); break; + case 'manageBrains': + await this._manageBrains(); + break; case 'syncBrain': await this.syncBrain(); await this._sendBrainStatus(); break; + case 'setBrainProfile': + await this._setActiveBrainProfile(data.id); + break; + case 'refreshModels': + await this._sendModels(); + break; } }); } private _currentSessionId: string | null = null; + private async _restoreActiveSessionIntoView() { + if (!this._view) return; + + const blankChatActive = this._context.globalState.get(SidebarChatProvider.blankChatStateKey, false); + if (blankChatActive) { + return; + } + + const activeSessionId = this._currentSessionId || this._context.globalState.get(SidebarChatProvider.activeSessionStateKey, null); + if (activeSessionId) { + const loaded = await this._loadSession(activeSessionId, true); + if (loaded) return; + + await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null); + } + + const currentHistory = this._agent.getHistory(); + if (currentHistory.length > 0) { + this._view.webview.postMessage({ type: 'restoreHistory', value: currentHistory }); + await this._persistLastVisibleChat(currentHistory); + return; + } + + const snapshot = this._context.globalState.get(SidebarChatProvider.lastVisibleChatStateKey, null); + if (snapshot?.history?.length) { + this._currentSessionId = snapshot.sessionId || null; + this._currentSessionBrainId = snapshot.brainProfileId || getActiveBrainProfile().id; + await this._setActiveBrainProfile(this._currentSessionBrainId, true); + this._agent.setHistory(snapshot.history); + this._view.webview.postMessage({ type: 'restoreHistory', value: snapshot.history }); + } + } + + private async _persistLastVisibleChat(history: ChatMessage[] = this._agent.getHistory()) { + if (history.length === 0) { + await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null); + return; + } + + const snapshot: LastVisibleChatSnapshot = { + history, + brainProfileId: this._currentSessionBrainId || getActiveBrainProfile().id, + sessionId: this._currentSessionId, + timestamp: Date.now() + }; + await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, snapshot); + } + private async _saveCurrentSession() { const history = this._agent.getHistory(); if (history.length === 0) return; - let sessions = this._context.globalState.get('chat_sessions', []) || []; + let sessions = this._getSessions(); const firstMsg = history.find(m => m.role === 'user')?.content; const title = typeof firstMsg === 'string' ? firstMsg.substring(0, 50).replace(/\n/g, ' ') + (firstMsg.length > 50 ? '...' : '') : 'New Chat'; + const brainProfileId = this._currentSessionBrainId || getActiveBrainProfile().id; if (!this._currentSessionId) { this._currentSessionId = Date.now().toString(); @@ -106,66 +193,313 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn id: this._currentSessionId, title, timestamp: Date.now(), - history + history, + brainProfileId }); } else { const idx = sessions.findIndex(s => s.id === this._currentSessionId); if (idx >= 0) { sessions[idx].history = history; sessions[idx].timestamp = Date.now(); + sessions[idx].brainProfileId = brainProfileId; + if (!sessions[idx].title || sessions[idx].title === 'New Chat') { + sessions[idx].title = title; + } + } else { + sessions.unshift({ + id: this._currentSessionId, + title, + timestamp: Date.now(), + history, + brainProfileId + }); } } // Keep only last 50 sessions if (sessions.length > 50) sessions = sessions.slice(0, 50); - await this._context.globalState.update('chat_sessions', sessions); + await this._putSessions(sessions); + await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, this._currentSessionId); + await this._persistLastVisibleChat(history); await this._sendSessionList(); } private async _sendSessionList() { if (!this._view) return; - const sessions = this._context.globalState.get('chat_sessions', []) || []; - const list = sessions.map(s => ({ id: s.id, title: s.title, timestamp: s.timestamp })); + const sessions = this._getSessions(); + const list = sessions.map(s => ({ + id: s.id, + title: s.title, + timestamp: s.timestamp, + brainProfileId: s.brainProfileId || '', + messageCount: s.history.length, + history: s.history + })); this._view.webview.postMessage({ type: 'sessionList', value: list }); } - private async _loadSession(id: string) { - const sessions = this._context.globalState.get('chat_sessions', []) || []; + private async _loadSession(id: string, skipSessionListRefresh: boolean = false): Promise { + if (!id) { + logError('Session load requested without an id.'); + this._view?.webview.postMessage({ type: 'error', value: 'Chat session id is missing.' }); + return false; + } + + const sessions = this._getSessions(); const session = sessions.find(s => s.id === id); if (session) { + const history = Array.isArray(session.history) ? session.history : []; + if (history.length === 0) { + logError('Session load failed because history is empty or invalid.', { id }); + this._view?.webview.postMessage({ type: 'error', value: 'This chat session has no saved messages.' }); + return false; + } + + this._agent.stop(); this._currentSessionId = id; - this._agent.setHistory(session.history); - this._view?.webview.postMessage({ type: 'clearChat' }); - this._view?.webview.postMessage({ type: 'restoreHistory', value: session.history }); + const sessionBrainId = session.brainProfileId || getActiveBrainProfile().id; + await this._setActiveBrainProfile(sessionBrainId, true); + this._agent.setHistory(history); + await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, id); + await this._context.globalState.update(SidebarChatProvider.blankChatStateKey, false); + await this._persistLastVisibleChat(history); + this._view?.webview.postMessage({ + type: 'sessionLoaded', + value: { + id, + title: session.title || 'Chat Session', + history + } + }); + if (!skipSessionListRefresh) { + await this._sendSessionList(); + } + logInfo('Chat session loaded.', { id, messages: history.length }); + return true; } + + logError('Session load failed because id was not found.', { id, sessionCount: sessions.length }); + this._view?.webview.postMessage({ type: 'error', value: 'Chat session was not found.' }); + return false; } private async _deleteSession(id: string) { - let sessions = this._context.globalState.get('chat_sessions', []) || []; + let sessions = this._getSessions(); sessions = sessions.filter(s => s.id !== id); - await this._context.globalState.update('chat_sessions', sessions); + await this._putSessions(sessions); if (this._currentSessionId === id) { this._currentSessionId = null; - this._agent.clearHistory(); + this._agent.resetConversation(); + await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null); + await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null); + await this._context.globalState.update(SidebarChatProvider.blankChatStateKey, true); this.clearChat(); } await this._sendSessionList(); } + private _getSessions(): ChatSession[] { + const rawSessions = this._context.globalState.get('chat_sessions', []) || []; + return rawSessions + .map((session, index): ChatSession | null => { + const history = Array.isArray(session?.history) + ? session.history.filter((message: any) => + message + && (message.role === 'user' || message.role === 'assistant' || message.role === 'system') + && message.content !== undefined + ) + : []; + + if (!session?.id || history.length === 0) return null; + + const firstMsg = history.find((message: ChatMessage) => message.role === 'user')?.content; + const fallbackTitle = typeof firstMsg === 'string' + ? firstMsg.substring(0, 50).replace(/\n/g, ' ') + (firstMsg.length > 50 ? '...' : '') + : `Chat ${index + 1}`; + + return { + id: String(session.id), + title: String(session.title || fallbackTitle), + timestamp: typeof session.timestamp === 'number' ? session.timestamp : Date.now(), + history, + brainProfileId: String(session.brainProfileId || getActiveBrainProfile().id) + }; + }) + .filter((session): session is ChatSession => !!session) + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, 50); + } + + private async _putSessions(sessions: ChatSession[]) { + await this._context.globalState.update('chat_sessions', sessions.slice(0, 50)); + } + private async _sendBrainStatus() { if (!this._view) return; - const brainDir = _getBrainDir(); + const activeBrain = getActiveBrainProfile(); + const brainDir = activeBrain.localBrainPath; const files = findBrainFiles(brainDir); this._view.webview.postMessage({ type: 'brainStatus', value: { count: files.length, - path: brainDir + path: brainDir, + name: activeBrain.name, + description: activeBrain.description || '', + repo: activeBrain.secondBrainRepo || '' } }); } + private async _sendBrainProfiles() { + if (!this._view) return; + const activeBrain = getActiveBrainProfile(); + this._currentSessionBrainId = this._currentSessionBrainId || activeBrain.id; + const profiles = getBrainProfiles().map((profile) => ({ + id: profile.id, + name: profile.name, + path: profile.localBrainPath, + description: profile.description || '', + repo: profile.secondBrainRepo || '' + })); + this._view.webview.postMessage({ + type: 'brainProfiles', + value: { + activeBrainId: activeBrain.id, + profiles + } + }); + } + + private async _setActiveBrainProfile(profileId: string, silent: boolean = false) { + const profiles = getBrainProfiles(); + const nextProfile = profiles.find((profile) => profile.id === profileId) || profiles[0]; + if (!nextProfile) return; + + await vscode.workspace.getConfiguration('g1nation').update('activeBrainId', nextProfile.id, vscode.ConfigurationTarget.Global); + this._currentSessionBrainId = nextProfile.id; + await this._sendBrainProfiles(); + await this._sendBrainStatus(); + + if (!silent) { + this.injectSystemMessage(`**[Brain Switched]** ${nextProfile.name}\n\`${nextProfile.localBrainPath}\``); + } + } + + private async _manageBrains() { + const activeBrain = getActiveBrainProfile(); + const choice = await vscode.window.showQuickPick([ + { + label: 'Add Brain Folder', + description: 'Create a new brain profile from a local folder' + }, + { + label: 'Open Active Brain Folder', + description: activeBrain.localBrainPath + }, + { + label: 'Open Brain Settings', + description: 'Edit names, paths, repos, and descriptions' + } + ], { + placeHolder: `Active Brain: ${activeBrain.name} (${activeBrain.localBrainPath})` + }); + + if (!choice) return; + + if (choice.label === 'Add Brain Folder') { + await this._addBrainProfile(); + return; + } + + if (choice.label === 'Open Active Brain Folder') { + if (!fs.existsSync(activeBrain.localBrainPath)) { + fs.mkdirSync(activeBrain.localBrainPath, { recursive: true }); + } + await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(activeBrain.localBrainPath)); + return; + } + + vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation.brainProfiles'); + } + + private async _addBrainProfile() { + const selected = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: 'Use as Brain' + }); + + const folder = selected?.[0]?.fsPath; + if (!folder) return; + + const defaultName = path.basename(folder) || 'New Brain'; + const name = await vscode.window.showInputBox({ + prompt: 'Name this brain profile', + value: defaultName, + validateInput: (value) => value.trim() ? null : 'Brain name is required.' + }); + if (!name) return; + + const description = await vscode.window.showInputBox({ + prompt: 'Optional description shown in the G1nation sidebar', + value: '' + }); + + const repo = await vscode.window.showInputBox({ + prompt: 'Optional Second Brain Git repository URL', + value: '' + }); + + // Read raw settings directly to avoid virtual default-brain (injected in memory by getConfig()) + // being saved into the settings file and corrupting the profile list on next load. + const cfg = vscode.workspace.getConfiguration('g1nation'); + const existingRaw: any[] = cfg.get('brainProfiles', []) || []; + + const idBase = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'brain'; + let id = idBase; + let suffix = 2; + while (existingRaw.some((p: any) => p.id === id)) { + id = `${idBase}-${suffix++}`; + } + + const newProfile = { + id, + name: name.trim(), + localBrainPath: folder, + secondBrainRepo: (repo || '').trim(), + description: (description || '').trim() + }; + const nextProfiles = [...existingRaw, newProfile]; + + await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global); + await cfg.update('activeBrainId', id, vscode.ConfigurationTarget.Global); + this._currentSessionBrainId = id; + + // Directly post the freshly-built profile list to the webview. + // cfg.update() is async and VSCode's config cache may not reflect the new value + // immediately, so we avoid re-reading via getBrainProfiles() here. + if (this._view) { + this._view.webview.postMessage({ + type: 'brainProfiles', + value: { + activeBrainId: id, + profiles: nextProfiles.map((p: any) => ({ + id: p.id || '', + name: p.name || '', + path: p.localBrainPath || '', + description: p.description || '', + repo: p.secondBrainRepo || '' + })) + } + }); + } + await this._sendBrainStatus(); + this.injectSystemMessage(`**[Brain Added]** ${name.trim()}\n\`${folder}\``); + } + // --- BridgeInterface Methods --- public injectSystemMessage(msg: string): void { @@ -200,7 +534,8 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } public async syncBrain() { - const brainDir = _getBrainDir(); + const activeBrain = getActiveBrainProfile(); + const brainDir = activeBrain.localBrainPath; if (!fs.existsSync(brainDir)) { vscode.window.showErrorMessage("Second Brain directory not found."); return; @@ -226,6 +561,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn if (!this._view) return; const { value, model, internet, files } = data; + this._currentSessionBrainId = getActiveBrainProfile().id; try { await this._agent.handlePrompt(value, model, { @@ -275,13 +611,19 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } if (models.length === 0) { - models = [defaultModel]; + models = defaultModel ? [defaultModel] : []; this._view.webview.postMessage({ type: 'engineStatus', value: { online: false, url } }); } else { this._view.webview.postMessage({ type: 'engineStatus', value: { online: true, url } }); } - if (models.length > 0 && !models.includes(defaultModel)) { + const baseModel = defaultModel?.replace(/:\d+$/, ''); + if (baseModel && baseModel !== defaultModel && models.includes(baseModel)) { + defaultModel = baseModel; + await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global); + } + + if (models.length > 0 && (!defaultModel || !models.includes(defaultModel))) { defaultModel = models[0]; await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global); } @@ -292,10 +634,11 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn models.unshift(defaultModel); } - this._view.webview.postMessage({ type: 'modelsList', value: models }); + this._view.webview.postMessage({ type: 'modelsList', value: { models, selected: defaultModel } }); } catch (err) { logError('Model list update failed.', err); - this._view.webview.postMessage({ type: 'modelsList', value: [getConfig().defaultModel] }); + const fallbackModel = getConfig().defaultModel; + this._view.webview.postMessage({ type: 'modelsList', value: { models: fallbackModel ? [fallbackModel] : [], selected: fallbackModel } }); } } @@ -331,11 +674,17 @@ body::before{content:'';position:fixed;top:-50%;left:-50%;width:200%;height:200% .thinking-bar.active{background:rgba(124,106,255,.1)} .thinking-bar.active::after{content:'';position:absolute;top:0;left:-40%;width:40%;height:100%;background:linear-gradient(90deg,transparent,var(--accent),var(--accent2),var(--accent3),transparent);animation:thinkSlide 1.5s ease-in-out infinite} @keyframes thinkSlide{0%{left:-40%}100%{left:100%}} +.brain-strip{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:7px 14px;border-bottom:1px solid var(--border);background:rgba(0,10,2,.64);z-index:8;position:relative} +.brain-strip-main{font-size:11px;color:var(--accent);font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.brain-strip-sub{font-size:10px;color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:45%} .header-left{display:flex;align-items:center;gap:8px} .logo{width:26px;height:26px;border-radius:6px;background:#050505;border:1px solid rgba(0,255,65,.3);display:flex;align-items:center;justify-content:center;font-size:16px;color:var(--accent);box-shadow:0 0 15px rgba(0,255,65,.15);animation:logoPulse 3s ease-in-out infinite;position:relative;text-shadow:0 0 8px var(--accent)} @keyframes logoPulse{0%,100%{box-shadow:0 0 10px rgba(0,255,65,.1)}50%{box-shadow:0 0 25px rgba(0,255,65,.3)}} .brand{font-weight:800;font-size:14px;color:var(--text-bright);letter-spacing:-.5px;background:linear-gradient(135deg,#fff 40%,var(--accent) 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent} -.header-right{display:flex;align-items:center;gap:5px} +.header-right{display:flex;align-items:center;gap:5px;flex-wrap:wrap;justify-content:flex-end} +.select-wrap{display:flex;align-items:center;gap:5px} +.select-wrap select{max-width:140px} +.brain-meta{font-size:10px;color:var(--text-dim);max-width:180px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .history-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.85);backdrop-filter:blur(15px);z-index:100;display:none;flex-direction:column;padding:20px;animation:fadeIn 0.3s ease-out} .history-overlay.visible{display:flex} .history-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px} @@ -382,6 +731,7 @@ select:hover,select:focus{border-color:var(--accent);box-shadow:0 0 12px var(--a .loading-dots span:nth-child(3){animation-delay:.4s;background:var(--accent3)} @keyframes dotBounce{0%,80%,100%{transform:scale(.6);opacity:.4}40%{transform:scale(1.2);opacity:1}} .loading-text{font-size:11px;color:var(--text-dim);animation:pulse 2s ease-in-out infinite;letter-spacing:.3px} +.auto-status{margin:12px 0 4px 29px;padding:10px 12px;border:1px solid rgba(255,255,255,.16);border-radius:10px;background:rgba(0,255,65,.08);color:#fff;font-size:13px;font-weight:700;line-height:1.5;box-shadow:0 0 18px rgba(0,255,65,.12)} .input-wrap{padding:8px 14px 14px;flex-shrink:0;position:relative;z-index:1} .input-box{background:var(--input-bg);border:1px solid var(--border2);border-radius:14px;padding:12px 14px;display:flex;flex-direction:column;gap:8px;transition:all .3s;position:relative;backdrop-filter:blur(12px)} .input-box:focus-within{border-color:var(--accent);box-shadow:0 0 24px rgba(0,255,65,.15)} @@ -414,19 +764,19 @@ body.init .input-wrap{max-width:680px;width:100%;margin:0 auto} .regen-btn{display:inline-flex;align-items:center;gap:4px;background:transparent;border:none;color:var(--text-dim);padding:4px 6px;border-radius:4px;font-size:11px;cursor:pointer;transition:color 0.2s;margin-top:6px;margin-left:29px;opacity:0.7} .regen-btn:hover{color:var(--text);opacity:1} -
G1nation
+
G1nation
Chat Sessions
+
Brain: checking...
G1nation
-
Security ยท Optimized ยท Knowledge Mesh
Understands your project, writes code, and executes tasks.
- +
Security ยท Optimized ยท Knowledge Mesh
Choose a brain above, then ask what it knows.
@@ -439,7 +789,7 @@ body.init .input-wrap{max-width:680px;width:100%;margin:0 auto} try { const vscode=acquireVsCodeApi(),chat=document.getElementById('chat'),input=document.getElementById('input'), sendBtn=document.getElementById('sendBtn'),stopBtn=document.getElementById('stopBtn'), -modelSel=document.getElementById('modelSel'),newChatBtn=document.getElementById('newChatBtn'),settingsBtn=document.getElementById('settingsBtn'),brainBtn=document.getElementById('brainBtn'), +modelSel=document.getElementById('modelSel'),brainSel=document.getElementById('brainSel'),refreshModelsBtn=document.getElementById('refreshModelsBtn'),manageBrainsBtn=document.getElementById('manageBrainsBtn'),newChatBtn=document.getElementById('newChatBtn'),settingsBtn=document.getElementById('settingsBtn'),brainBtn=document.getElementById('brainBtn'), historyBtn=document.getElementById('historyBtn'),historyOverlay=document.getElementById('historyOverlay'),closeHistoryBtn=document.getElementById('closeHistoryBtn'),historyList=document.getElementById('historyList'), internetBtn=document.getElementById('internetBtn'),attachBtn=document.getElementById('attachBtn'),fileInput=document.getElementById('fileInput'),attachPreview=document.getElementById('attachPreview'), thinkingBar=document.getElementById('thinkingBar'); @@ -447,6 +797,9 @@ let loader=null,sending=false,pendingFiles=[],internetEnabled=false; historyBtn.addEventListener('click', ()=>historyOverlay.classList.add('visible')); closeHistoryBtn.addEventListener('click', ()=>historyOverlay.classList.remove('visible')); +refreshModelsBtn.addEventListener('click', ()=>vscode.postMessage({type:'refreshModels'})); +manageBrainsBtn.addEventListener('click', ()=>vscode.postMessage({type:'manageBrains'})); +brainSel.addEventListener('change', ()=>vscode.postMessage({type:'setBrainProfile',id:brainSel.value})); internetBtn.addEventListener('click', ()=>{ internetEnabled=!internetEnabled; @@ -467,6 +820,27 @@ function fmt(t){ return formatted; } +function normalizeMsgContent(content){ + if(Array.isArray(content)) return content.map(part=>typeof part==='string'?part:(part?.text||part?.name||JSON.stringify(part))).join('\\n'); + return String(content||''); +} + +function resetChatView(){ + chat.innerHTML=''; + streamBody=null;hideLoader();setSending(false); + document.body.classList.remove('init'); +} + +function renderHistory(history){ + resetChatView(); + (history||[]).filter(m => !m.internal).forEach(m => addMsg(normalizeMsgContent(m.content), m.role === 'assistant' ? 'ai' : 'user')); + if((history||[]).length===0){ + chat.innerHTML='
G1nation
No messages in this session.
'; + document.body.classList.add('init'); + } + chat.scrollTop=chat.scrollHeight; +} + function addMsg(text,role){ const isUser=role==='user',isErr=role==='error'; const el=document.createElement('div');el.className='msg'+(isUser?' msg-user':'')+(isErr?' msg-error':''); @@ -488,7 +862,7 @@ function send(){ const w=document.querySelector('.welcome');if(w)w.remove(); addMsg(text,'user'); input.value='';input.style.height='auto';setSending(true);showLoader(); - vscode.postMessage({type:'prompt',value:text,model:modelSel.value,internet:internetEnabled,files:pendingFiles}); + vscode.postMessage({type:'prompt',value:text,model:modelSel.value,internet:internetEnabled,files:pendingFiles.length?pendingFiles:undefined}); pendingFiles=[];renderPreview(); } @@ -525,22 +899,32 @@ settingsBtn.addEventListener('click',()=>vscode.postMessage({type:'openSettings' brainBtn.addEventListener('click',()=>vscode.postMessage({type:'syncBrain'})); stopBtn.addEventListener('click',()=>{vscode.postMessage({type:'stopGeneration'});hideLoader();setSending(false);}); -let streamBody=null; +let streamBody=null,sessionCache={}; window.addEventListener('message',e=>{const msg=e.data;switch(msg.type){ case 'restoreHistory': - chat.innerHTML=''; - document.body.classList.remove('init'); - msg.value.filter(m => !m.internal).forEach(m => addMsg(m.content, m.role === 'assistant' ? 'ai' : 'user')); + renderHistory(msg.value||[]); + break; + case 'sessionLoaded': + renderHistory(msg.value.history||[]); + historyOverlay.classList.remove('visible'); break; case 'sessionList': historyList.innerHTML=''; + sessionCache={}; + if(!msg.value || msg.value.length===0){ + historyList.innerHTML='
No saved chats yet
Start a conversation and it will appear here.
'; + break; + } msg.value.forEach(s=>{ + sessionCache[s.id]=s; const el=document.createElement('div');el.className='history-item'; - el.innerHTML='
'+esc(s.title)+'
'+new Date(s.timestamp).toLocaleString()+'
'; + el.dataset.sessionId=s.id; + el.innerHTML='
'+esc(s.title)+'
'+new Date(s.timestamp).toLocaleString()+' ยท '+(s.messageCount||0)+' messages
'; el.addEventListener('click',(e)=>{ - if(e.target.classList.contains('history-del-btn')) return; + if(e.target.closest('.history-del-btn')) return; + const cached=sessionCache[s.id]; + if(cached && cached.history){renderHistory(cached.history);historyOverlay.classList.remove('visible');} vscode.postMessage({type:'loadSession',id:s.id}); - historyOverlay.classList.remove('visible'); }); el.querySelector('.history-del-btn').addEventListener('click',()=>{ vscode.postMessage({type:'deleteSession',id:s.id}); @@ -563,7 +947,16 @@ window.addEventListener('message',e=>{const msg=e.data;switch(msg.type){ break; case 'modelsList': modelSel.innerHTML=''; - msg.value.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;modelSel.appendChild(o)}); + (msg.value.models||[]).forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;modelSel.appendChild(o)}); + if(msg.value.selected) modelSel.value=msg.value.selected; + break; + case 'brainProfiles': + brainSel.innerHTML=''; + (msg.value.profiles||[]).forEach(profile=>{ + const o=document.createElement('option');o.value=profile.id;o.textContent=profile.name;brainSel.appendChild(o); + o.title=profile.path; + }); + if(msg.value.activeBrainId) brainSel.value=msg.value.activeBrainId; break; case 'engineStatus': const dot = document.getElementById('engineStatusDot'); @@ -575,18 +968,24 @@ window.addEventListener('message',e=>{const msg=e.data;switch(msg.type){ case 'brainStatus': const badge = document.getElementById('brainCountBadge'); const info = document.getElementById('brainStatusInfo'); + const meta = document.getElementById('brainStatusMeta'); if(badge){ badge.innerText = msg.value.count > 999 ? '999+' : msg.value.count; badge.style.display = msg.value.count > 0 ? 'block' : 'none'; } if(info){ - info.innerText = \`๐Ÿง  Second Brain: \${msg.value.count} knowledge files connected\`; - info.style.display = 'block'; + info.innerText = \`Brain: \${msg.value.name} ยท \${msg.value.count} files\`; + info.title = msg.value.path; } - const brainBtn = document.getElementById('brainBtn'); - if(brainBtn) brainBtn.title = \`Sync Brain (Connected: \${msg.value.count} files at \${msg.value.path})\`; + if(meta){ + meta.innerText = msg.value.description ? \`\${msg.value.description} ยท \${msg.value.path}\` : msg.value.path; + meta.title = msg.value.path; + } + const syncBrainBtn = document.getElementById('brainBtn'); + if(syncBrainBtn) syncBrainBtn.title = \`Sync Brain: \${msg.value.name} (\${msg.value.count} files at \${msg.value.path})\`; break; case 'clearChat': + streamBody=null;hideLoader();setSending(false); chat.innerHTML='
G1nation
System Cleaned.
'; document.body.classList.add('init'); break; @@ -594,11 +993,9 @@ window.addEventListener('message',e=>{const msg=e.data;switch(msg.type){ input.value=msg.value;send(); break; case 'autoContinue': - showLoader(); + showLoader();setSending(true); const hint = document.createElement('div'); - hint.className = 'input-hint'; - hint.style.textAlign = 'center'; - hint.style.margin = '10px 0'; + hint.className = 'auto-status'; hint.innerText = msg.value; chat.appendChild(hint); chat.scrollTop = chat.scrollHeight; diff --git a/src/utils.ts b/src/utils.ts index 25b305f..263d605 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -11,14 +11,91 @@ export const EXCLUDED_DIRS = new Set([ // Configuration constants moved to package.json and getConfig() +export interface BrainProfile { + id: string; + name: string; + localBrainPath: string; + secondBrainRepo?: string; + description?: string; +} + +function normalizePath(p: string): string { + if (!p) return p; + if (p.startsWith('~/')) { + return path.join(os.homedir(), p.substring(2)); + } + return p.trim(); +} + +function toBrainProfile(raw: Partial | undefined, fallbackIndex: number): BrainProfile | null { + if (!raw) return null; + const localBrainPath = normalizePath(raw.localBrainPath || ''); + if (!localBrainPath) return null; + return { + id: (raw.id || `brain-${fallbackIndex + 1}`).trim(), + name: (raw.name || path.basename(localBrainPath) || `Brain ${fallbackIndex + 1}`).trim(), + localBrainPath, + secondBrainRepo: (raw.secondBrainRepo || '').trim(), + description: (raw.description || '').trim() + }; +} + +const STEVE_SKILL_PATH = '/Volumes/Data/project/Antigravity/Agent/.agent/skills/steve_jobs/SKILL.md'; + +function extractSteveRuntimeSection(skillContent: string): string { + const sectionStart = skillContent.indexOf('## ๐ŸŽฏ Steve Single-Contact Runtime Protocol'); + if (sectionStart < 0) return ''; + const sectionEnd = skillContent.indexOf('### 4. The "One More Thing" Mandate', sectionStart); + return skillContent.slice(sectionStart, sectionEnd > sectionStart ? sectionEnd : undefined).trim(); +} + +function loadSteveRuntimePrompt(): string { + try { + if (!fs.existsSync(STEVE_SKILL_PATH)) return ''; + const skillContent = fs.readFileSync(STEVE_SKILL_PATH, 'utf-8'); + return extractSteveRuntimeSection(skillContent); + } catch { + return ''; + } +} + export function getConfig() { const cfg = vscode.workspace.getConfiguration('g1nation'); + const legacyBrainPath = cfg.get('localBrainPath', ''); + const legacyBrainRepo = cfg.get('secondBrainRepo', ''); + const configuredProfiles = cfg.get[]>('brainProfiles', []); + const profiles = configuredProfiles + .map((profile, index) => toBrainProfile(profile, index)) + .filter((profile): profile is BrainProfile => !!profile); + + // IMPORTANT: This virtual default-brain exists only in memory at runtime. + // It must NEVER be written back to the settings file (g1nation.brainProfiles). + // _addBrainProfile() reads cfg.get('brainProfiles') directly to avoid this contamination. + if (profiles.length === 0) { + const fallbackPath = normalizePath(legacyBrainPath) || path.join(os.homedir(), '.g1nation-brain'); + profiles.push({ + id: 'default-brain', + name: 'Local Brain', + localBrainPath: fallbackPath, + secondBrainRepo: legacyBrainRepo.trim(), + description: legacyBrainPath + ? 'Migrated from your existing localBrainPath setting' + : 'Auto-created local knowledge folder. Add a real brain via the โœŽ button.' + }); + } + + const activeBrainId = cfg.get('activeBrainId', profiles[0].id) || profiles[0].id; + const activeBrain = profiles.find((profile) => profile.id === activeBrainId) || profiles[0]; + return { ollamaUrl: cfg.get('ollamaUrl', 'http://127.0.0.1:11434'), defaultModel: cfg.get('defaultModel', 'gemma4:e2b'), maxTreeFiles: 200, timeout: cfg.get('requestTimeout', 300) * 1000, - localBrainPath: cfg.get('localBrainPath', ''), + localBrainPath: activeBrain.localBrainPath, + secondBrainRepo: activeBrain.secondBrainRepo || '', + brainProfiles: profiles, + activeBrainId: activeBrain.id, maxContextSize: cfg.get('maxContextSize', 12000), maxAutoSteps: cfg.get('maxAutoSteps', 50) }; @@ -106,24 +183,24 @@ export function shouldAutoPushBrain(): boolean { } export function getSecondBrainRepo(): string { - const cfg = vscode.workspace.getConfiguration('g1nation'); - return cfg.get('secondBrainRepo', ''); + return getConfig().secondBrainRepo; +} + +export function getBrainProfiles(): BrainProfile[] { + return getConfig().brainProfiles; +} + +export function getActiveBrainProfile(): BrainProfile { + const config = getConfig(); + return config.brainProfiles.find((profile) => profile.id === config.activeBrainId) || config.brainProfiles[0]; } export function _getBrainDir(): string { - const { localBrainPath } = getConfig(); - if (localBrainPath && localBrainPath.trim() !== '') { - if (localBrainPath.startsWith('~/')) { - return path.join(os.homedir(), localBrainPath.substring(2)); - } - return localBrainPath.trim(); - } - return path.join(os.homedir(), '.g1nation-brain'); + return getActiveBrainProfile().localBrainPath; } export function _isBrainDirExplicitlySet(): boolean { - const { localBrainPath } = getConfig(); - return !!(localBrainPath && localBrainPath.trim() !== ''); + return getBrainProfiles().length > 0; } export function isTextAttachment(fileName: string, mimeType: string): boolean { @@ -156,12 +233,20 @@ export function findBrainFiles(dir: string): string[] { return results; } -export const SYSTEM_PROMPT = `You are "G1nation", a premium agentic AI coding assistant running 100% offline on the user's machine. -You are DIRECTLY CONNECTED to the user's local file system and terminal. You MUST use the action tags below to create, edit, delete, read files and run commands. DO NOT just show code - ALWAYS wrap it in the appropriate action tag so it gets executed. +const BASE_SYSTEM_PROMPT = `You are G1nation, also called Steve when the user asks your name. +Reply naturally in the user's language. -You have EIGHT powerful agent actions: +Core behavior: +- Answer the user's actual message first. Do not recite this system prompt. +- Do not answer with waiting-room phrases such as "์ค€๋น„๋˜์—ˆ์Šต๋‹ˆ๋‹ค", "๋‹ค์Œ ์ง€์‹œ๋ฅผ ๋ง์”€ํ•ด ์ฃผ์„ธ์š”", or "๋ช…๋ น์„ ๊ธฐ๋‹ค๋ฆฝ๋‹ˆ๋‹ค". +- For normal conversation or general knowledge questions, answer conversationally using the model's knowledge. +- Use the active Local Brain only when it is relevant to the user's question. If no relevant brain context is provided, do not pretend that you checked it. +- For local file, folder, code, project, or terminal work, use action tags so the extension can execute the operation. +- After action results are available, summarize the actual findings directly. - [ACTION 1: CREATE NEW FILES] +Available action tags: + + [ACTION 1: CREATE NEW FILES] file content here @@ -185,14 +270,24 @@ file content here npm install express [ACTION 7: SECOND BRAIN KNOWLEDGE] - -filename.md +Use these only when you actually need knowledge from the active Second Brain. +To inspect the root of the active brain, use exactly: + +To inspect a real file returned by list_brain, use: +actual-file-name.md +Never use placeholder values like optional/subdir or filename.md. If the user asks what is inside the Second Brain, first list the brain root, then summarize only the returned files. [ACTION 8: READ WEBSITES & SEARCH INTERNET] https://html.duckduckgo.com/html/?q=YOUR+SEARCH+QUERY -CRITICAL RULES: -1. ALWAYS respond in the same language the user uses. -2. You MUST use action tags for any file/terminal operations. -3. Be concise and professional. -4. File paths are RELATIVE to the workspace.`; +Operational rules: +1. Same language as the user. +2. File paths can be relative to the workspace or absolute paths under /Volumes/Data/project/Antigravity. +3. When the user gives a file/folder path and asks you to analyze/check/review it, use or ; do not merely say you are ready. +4. Keep persona light. Do not introduce yourself unless the user greets you or asks who you are.`; + +export function getSystemPrompt(): string { + return BASE_SYSTEM_PROMPT; +} + +export const SYSTEM_PROMPT = BASE_SYSTEM_PROMPT;