diff --git a/package.json b/package.json index d507565..7bff388 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.27", + "version": "2.2.29", "publisher": "connectailab", "license": "MIT", "icon": "assets/icon.png", diff --git a/src/agent.ts b/src/agent.ts index 19def4c..ac899e2 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -9,13 +9,19 @@ import { EXCLUDED_DIRS, SYSTEM_PROMPT, shouldAutoPushBrain, - getSecondBrainRepo + getSecondBrainRepo, + buildApiUrl, + logError, + logInfo, + resolveEngine, + summarizeText } from './utils'; import { validatePath, sanitizeCommand } from './security'; export interface ChatMessage { role: 'user' | 'assistant' | 'system'; content: string | any[]; + internal?: boolean; } export class AgentExecutor { @@ -32,7 +38,7 @@ export class AgentExecutor { } public getHistory() { - return this.chatHistory; + return this.chatHistory.filter(message => !message.internal); } public setHistory(history: ChatMessage[]) { @@ -74,6 +80,10 @@ export class AgentExecutor { if (!this.webview) return; try { + if (loopDepth === 0) { + await this.context.workspaceState.update('lastActionStr', undefined); + } + // 1. Prepare Context const workspaceFolders = vscode.workspace.workspaceFolders; const rootPath = workspaceFolders ? workspaceFolders[0].uri.fsPath : ''; @@ -91,8 +101,12 @@ export class AgentExecutor { // 2. Setup History if (prompt !== null) { - this.chatHistory.push({ role: 'user', content: prompt }); - this.webview.postMessage({ type: 'streamChunk', value: '' }); // Trigger UI update if needed + if (loopDepth === 0) { + this.chatHistory.push({ role: 'user', content: prompt }); + this.webview.postMessage({ type: 'streamChunk', value: '' }); // Trigger UI update if needed + } else { + this.chatHistory.push({ role: 'system', content: prompt, internal: true }); + } } // 3. API Request Setup @@ -108,60 +122,24 @@ export class AgentExecutor { } // Inject System Directives - if (reqMessages.length > 0) { - 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}\n${internetCtx}`; - - const firstUserIdx = reqMessages.findIndex(m => m.role === 'user'); - if (firstUserIdx >= 0) { - let content = reqMessages[firstUserIdx].content; - if (typeof content === 'string') { - reqMessages[firstUserIdx].content = `${fullSystemPrompt}\n\n[USER QUERY]\n${content}`; - if (loopDepth > 0) { - reqMessages[firstUserIdx].content = `[Autonomous Step ${loopDepth}/${config.maxAutoSteps}]\n${reqMessages[firstUserIdx].content}`; - } - } - } - } + 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 messagesForRequest: ChatMessage[] = [ + { role: 'system', content: fullSystemPrompt, internal: true }, + ...reqMessages + ]; // 4. Call AI Engine - const isLMStudio = ollamaUrl.includes('1234') || ollamaUrl.includes('v1') || ollamaUrl.includes('localhost'); - // Note: Many users use LM Studio on localhost, we'll try to be smart or fallback to Ollama format if it fails. - - const apiUrl = isLMStudio ? - (ollamaUrl.endsWith('/v1') ? `${ollamaUrl}/chat/completions` : `${ollamaUrl}/v1/chat/completions`) : - `${ollamaUrl}/api/chat`; - this.abortController = new AbortController(); - - const streamBody = { - model: modelName || defaultModel, - messages: reqMessages, - stream: true, - ...(isLMStudio - ? { max_tokens: 4096, temperature } - : { options: { num_ctx: 32768, num_predict: 4096, temperature } }), - }; - - 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 + const request = await this.createStreamingRequest({ + baseUrl: ollamaUrl, + modelName: modelName || defaultModel, + reqMessages: messagesForRequest, + temperature }); - - if (!response.ok) { - const errText = await response.text(); - throw new Error(`AI Engine error: ${response.status} - ${errText}`); - } + const { response, engine, apiUrl } = request; let aiResponseText = ''; const reader = response.body?.getReader(); @@ -185,19 +163,21 @@ export class AgentExecutor { try { const raw = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed; const json = JSON.parse(raw); - const token = isLMStudio ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || ''; + 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) {} + } catch (e: any) { + logError('Failed to parse streaming chunk.', { engine, apiUrl, chunk: summarizeText(trimmed, 300), error: e?.message || String(e) }); + } } } } catch (err: any) { if (err.name === 'AbortError') { - console.log('[Agent] Generation aborted by user.'); + logInfo('Generation aborted by user.'); } else { - console.error('[Agent] Stream reading error:', err); + logError('Stream reading error.', { engine, apiUrl, error: err?.message || String(err) }); this.webview?.postMessage({ type: 'error', value: `Connection lost: ${err.message}` }); } } @@ -208,19 +188,25 @@ export class AgentExecutor { const trimmed = buffer.trim(); const raw = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed; const json = JSON.parse(raw); - const token = isLMStudio ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || ''; + 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) {} + } catch (e: any) { + logError('Failed to parse final streaming buffer.', { engine, apiUrl, buffer: summarizeText(buffer, 300), error: e?.message || String(e) }); + } } - if (loopDepth === 0) this.webview.postMessage({ type: 'streamEnd' }); this.chatHistory.push({ role: 'assistant', content: aiResponseText }); // 5. Execute Actions const report = await this.executeActions(aiResponseText, rootPath); + if (!aiResponseText.trim() && report.length === 0) { + 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) { const reportMsg = `\n\n> āš™ļø **System Action Report** (${loopDepth + 1}/${config.maxAutoSteps})\n> ${report.join("\n> ")}\n\n`; @@ -233,11 +219,11 @@ export class AgentExecutor { if (currentActionStr === lastActionStr) { this.webview.postMessage({ type: 'streamChunk', value: "\nāš ļø *Stopping to prevent infinite loop.*" }); - if (loopDepth === 0) this.webview.postMessage({ type: 'streamEnd' }); return; } await this.context.workspaceState.update('lastActionStr', currentActionStr); + 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."; @@ -249,10 +235,70 @@ export class AgentExecutor { } } 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}` }); + } finally { + if (loopDepth === 0) { + this.webview.postMessage({ type: 'streamEnd' }); + } } } + private async createStreamingRequest(params: { + baseUrl: string; + modelName: string; + reqMessages: ChatMessage[]; + temperature: number; + }): Promise<{ response: Response; engine: 'lmstudio' | 'ollama'; apiUrl: string }> { + const { baseUrl, modelName, reqMessages, temperature } = params; + const primaryEngine = resolveEngine(baseUrl); + const engines = primaryEngine === 'lmstudio' ? ['lmstudio', 'ollama'] as const : ['ollama', 'lmstudio'] as const; + let lastError: Error | null = null; + + 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 } }), + }; + + 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 + }); + + 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; + } + + 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 async executeActions(aiMessage: string, rootPath: string): Promise { const report: string[] = []; let brainModified = false; @@ -330,7 +376,7 @@ export class AgentExecutor { const content = fs.readFileSync(absPath, 'utf-8'); const preview = content.length > 8000 ? content.slice(0, 8000) + "\n... (truncated)" : content; report.push(`šŸ“– Read: ${relPath}`); - this.chatHistory.push({ role: 'user', content: `[Result of read_file ${relPath}]\n\`\`\`\n${preview}\n\`\`\`` }); + this.chatHistory.push({ role: 'system', content: `[Result of read_file ${relPath}]\n\`\`\`\n${preview}\n\`\`\``, internal: true }); } else { report.push(`āŒ Read failed: ${relPath} not found`); } @@ -368,7 +414,7 @@ export class AgentExecutor { } report.push(`šŸ“‚ Listed: ${relPath}`); - this.chatHistory.push({ role: 'user', content: `[Result of list_files ${relPath}]\n${listing}` }); + this.chatHistory.push({ role: 'system', content: `[Result of list_files ${relPath}]\n${listing}`, internal: true }); } } catch (err: any) { report.push(`āŒ Listing failed: ${err.message}`); } } @@ -392,7 +438,7 @@ export class AgentExecutor { } report.push(`🧠 Brain Listed: ${relPath}`); - this.chatHistory.push({ role: 'user', content: `[Result of list_brain ${relPath}]\n${listing}` }); + this.chatHistory.push({ role: 'system', content: `[Result of list_brain ${relPath}]\n${listing}`, internal: true }); } else { report.push(`āŒ Brain List failed: ${relPath} not found`); } @@ -411,7 +457,7 @@ export class AgentExecutor { if (targetFile && fs.existsSync(targetFile)) { const content = fs.readFileSync(targetFile, 'utf-8'); report.push(`🧠 Brain Read: ${fileName}`); - this.chatHistory.push({ role: 'user', content: `[Result of read_brain ${fileName}]\n\`\`\`\n${content}\n\`\`\`` }); + this.chatHistory.push({ role: 'system', content: `[Result of read_brain ${fileName}]\n\`\`\`\n${content}\n\`\`\``, internal: true }); } else { report.push(`āŒ Brain Read failed: ${fileName} not found in Second Brain`); } @@ -429,7 +475,7 @@ export class AgentExecutor { const content = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); const preview = content.length > 5000 ? content.slice(0, 5000) + "\n... (truncated)" : content; report.push(`🌐 Read URL: ${url}`); - this.chatHistory.push({ role: 'user', content: `[Result of read_url ${url}]\n${preview}` }); + this.chatHistory.push({ role: 'system', content: `[Result of read_url ${url}]\n${preview}`, internal: true }); } catch (err: any) { report.push(`āŒ URL Read failed: ${err.message}`); } } @@ -453,7 +499,7 @@ export class AgentExecutor { execSync(`git commit -m "[G1nation] Knowledge Update"`, { cwd: brainDir }); execSync(`git push`, { cwd: brainDir }); } catch (err) { - console.error('[Agent] Sync failed:', err); + logError('Second Brain sync failed.', err); } } } diff --git a/src/bridge.ts b/src/bridge.ts index 34876a9..c28be01 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -2,7 +2,17 @@ import * as http from 'http'; import * as fs from 'fs'; import * as path from 'path'; // axios removed -import { getConfig, _getBrainDir, _isBrainDirExplicitlySet, findBrainFiles } from './utils'; +import { + getConfig, + _getBrainDir, + _isBrainDirExplicitlySet, + findBrainFiles, + buildApiUrl, + logError, + logInfo, + resolveEngine, + summarizeText +} from './utils'; export interface BridgeInterface { injectSystemMessage(msg: string): void; @@ -48,7 +58,7 @@ export class BridgeServer { }); this.server.listen(port, '127.0.0.1', () => { - console.log(`[G1nation] Bridge Server active on port ${port}`); + logInfo(`Bridge server active on 127.0.0.1:${port}.`); }); } @@ -72,6 +82,7 @@ export class BridgeServer { const parsed = JSON.parse(body); await processor(parsed, res); } catch (e: any) { + logError('Bridge request failed.', { url: req.url, method: req.method, body: summarizeText(body), error: e?.message || String(e) }); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: e.message })); } @@ -145,27 +156,53 @@ export class BridgeServer { private async callAI(prompt: string): Promise { const config = getConfig(); - const isLMStudio = config.ollamaUrl.includes('1234') || config.ollamaUrl.includes('v1'); - const apiUrl = isLMStudio ? `${config.ollamaUrl}/v1/chat/completions` : `${config.ollamaUrl}/api/chat`; + const primaryEngine = resolveEngine(config.ollamaUrl); + const engines = primaryEngine === 'lmstudio' ? ['lmstudio', 'ollama'] as const : ['ollama', 'lmstudio'] as const; + let lastError: Error | null = null; - const payload = { - model: config.defaultModel, - messages: [{ role: 'user', content: prompt }], - stream: false - }; + for (const engine of engines) { + const apiUrl = buildApiUrl(config.ollamaUrl, engine, 'chat'); + const payload = engine === 'lmstudio' + ? { + model: config.defaultModel, + messages: [{ role: 'user', content: prompt }], + stream: false + } + : { + model: config.defaultModel, + messages: [{ role: 'user', content: prompt }], + stream: false + }; - const res = await fetch(apiUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - signal: AbortSignal.timeout(config.timeout) - }); + try { + logInfo('Bridge AI request started.', { engine, apiUrl, model: config.defaultModel }); + const res = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(config.timeout) + }); - if (!res.ok) { - throw new Error(`Bridge AI call failed: ${res.status}`); + const rawText = await res.text(); + if (!res.ok) { + lastError = new Error(`Bridge AI call failed: ${res.status} ${summarizeText(rawText, 250)}`); + logError('Bridge AI request returned non-OK status.', { engine, apiUrl, status: res.status, body: summarizeText(rawText, 500) }); + continue; + } + + const data = rawText ? JSON.parse(rawText) as any : {}; + const content = engine === 'lmstudio' + ? (data.choices?.[0]?.message?.content || '') + : (data.message?.content || data.response || ''); + + logInfo('Bridge AI request succeeded.', { engine, apiUrl, responsePreview: summarizeText(content, 200) }); + return content; + } catch (error: any) { + lastError = error instanceof Error ? error : new Error(String(error)); + logError('Bridge AI request failed.', { engine, apiUrl, error: lastError.message }); + } } - const data = await res.json() as any; - return isLMStudio ? (data.choices?.[0]?.message?.content || '') : (data.message?.content || ''); + throw lastError || new Error('Bridge AI call failed.'); } } diff --git a/src/extension.ts b/src/extension.ts index 462e2d1..3e9f706 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,7 +7,10 @@ import { _getBrainDir, _isBrainDirExplicitlySet, findBrainFiles, - SYSTEM_PROMPT + SYSTEM_PROMPT, + buildApiUrl, + logError, + logInfo } from './utils'; import { AgentExecutor } from './agent'; import { BridgeServer } from './bridge'; @@ -17,7 +20,7 @@ import { SidebarChatProvider } from './sidebarProvider'; * G1nation Extension Entry Point */ export async function activate(context: vscode.ExtensionContext) { - console.log('G1nation extension activated.'); + logInfo('Connect AI extension activated.'); // 1. Ensure Brain Directory await _ensureBrainDir(context); @@ -35,9 +38,9 @@ export async function activate(context: vscode.ExtensionContext) { const bridge = new BridgeServer(provider); try { bridge.start(); - console.log('G1nation Bridge Server started on port 4825'); + logInfo('Bridge server started on port 4825.'); } catch (err) { - console.error('Failed to start Bridge Server:', err); + logError('Failed to start bridge server.', err); } // 5. Register Core Commands @@ -74,16 +77,17 @@ async function runInitialSetup(context: vscode.ExtensionContext) { let modelName = ''; try { - const res = await fetch('http://127.0.0.1:1234/v1/models', { signal: AbortSignal.timeout(2000) }); + const res = await fetch(buildApiUrl('http://127.0.0.1:1234', 'lmstudio', 'models'), { signal: AbortSignal.timeout(2000) }); const data = await res.json() as any; if (data?.data?.length > 0) { engineName = 'LM Studio'; modelName = data.data[0].id; await vscode.workspace.getConfiguration('g1nation').update('ollamaUrl', 'http://127.0.0.1:1234', vscode.ConfigurationTarget.Global); await vscode.workspace.getConfiguration('g1nation').update('defaultModel', modelName, vscode.ConfigurationTarget.Global); + logInfo('Initial setup detected LM Studio.', { modelName }); } } catch (err) { - console.error('[Setup] LM Studio not found:', err); + logInfo('Initial setup could not reach LM Studio.', err); } if (!engineName) { @@ -95,9 +99,10 @@ async function runInitialSetup(context: vscode.ExtensionContext) { modelName = data.models[0].name; await vscode.workspace.getConfiguration('g1nation').update('ollamaUrl', 'http://127.0.0.1:11434', vscode.ConfigurationTarget.Global); await vscode.workspace.getConfiguration('g1nation').update('defaultModel', modelName, vscode.ConfigurationTarget.Global); + logInfo('Initial setup detected Ollama.', { modelName }); } } catch (err) { - console.error('[Setup] Ollama not found:', err); + logInfo('Initial setup could not reach Ollama.', err); } } @@ -106,7 +111,7 @@ async function runInitialSetup(context: vscode.ExtensionContext) { vscode.window.showInformationMessage(`Setup Complete: ${engineName} detected with model ${modelName}`); } } catch (e) { - console.error('[Setup] Initial setup failed:', e); + logError('Initial setup failed.', e); context.globalState.update('setupComplete', true); } } diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts index a7ecc17..eb6c3e8 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -4,6 +4,11 @@ import { getConfig, _getBrainDir, findBrainFiles, + buildApiUrl, + logError, + logInfo, + resolveEngine, + summarizeText } from './utils'; import { AgentExecutor } from './agent'; import { BridgeInterface } from './bridge'; @@ -228,6 +233,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn visionContent: files // Agent seems to handle files via visionContent }); } catch (error: any) { + logError('Prompt handling failed in sidebar provider.', { error: error?.message || String(error), promptPreview: summarizeText(value || '', 200) }); this._view.webview.postMessage({ type: 'error', value: error.message }); } } @@ -240,22 +246,32 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn let defaultModel = config.defaultModel; let models: string[] = []; - if (url.includes('1234') || url.includes('v1')) { + const primaryEngine = resolveEngine(url); + const engines = primaryEngine === 'lmstudio' ? ['lmstudio', 'ollama'] as const : ['ollama', 'lmstudio'] as const; + + for (const engine of engines) { + const modelsUrl = buildApiUrl(url, engine, 'models'); try { - const res = await fetch(`${url}/v1/models`, { signal: AbortSignal.timeout(5000) }); - if (res.ok) { - const data = await res.json() as any; - models = data.data.map((m: any) => m.id); + logInfo('Model discovery started.', { engine, modelsUrl }); + const res = await fetch(modelsUrl, { signal: AbortSignal.timeout(5000) }); + const rawText = await res.text(); + if (!res.ok) { + logError('Model discovery returned non-OK status.', { engine, modelsUrl, status: res.status, body: summarizeText(rawText, 300) }); + continue; } - } catch (e) { console.error("[G1] LM Studio models fetch failed:", e); } - } else { - try { - const res = await fetch(`${url}/api/tags`, { signal: AbortSignal.timeout(5000) }); - if (res.ok) { - const data = await res.json() as any; - models = data.models.map((m: any) => m.name); + + const data = rawText ? JSON.parse(rawText) as any : {}; + models = engine === 'lmstudio' + ? (data.data || []).map((m: any) => m.id) + : (data.models || []).map((m: any) => m.name); + + if (models.length > 0) { + logInfo('Model discovery succeeded.', { engine, count: models.length, modelsPreview: models.slice(0, 5) }); + break; } - } catch (e) { console.error("[G1] Ollama models fetch failed:", e); } + } catch (e: any) { + logError('Model discovery failed.', { engine, modelsUrl, error: e?.message || String(e) }); + } } if (models.length === 0) { @@ -278,6 +294,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn this._view.webview.postMessage({ type: 'modelsList', value: models }); } catch (err) { + logError('Model list update failed.', err); this._view.webview.postMessage({ type: 'modelsList', value: [getConfig().defaultModel] }); } } @@ -513,7 +530,7 @@ window.addEventListener('message',e=>{const msg=e.data;switch(msg.type){ case 'restoreHistory': chat.innerHTML=''; document.body.classList.remove('init'); - msg.value.forEach(m => addMsg(m.content, m.role === 'assistant' ? 'ai' : 'user')); + msg.value.filter(m => !m.internal).forEach(m => addMsg(m.content, m.role === 'assistant' ? 'ai' : 'user')); break; case 'sessionList': historyList.innerHTML=''; diff --git a/src/utils.ts b/src/utils.ts index d9d39a2..25b305f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -24,6 +24,82 @@ export function getConfig() { }; } +export type EngineKind = 'lmstudio' | 'ollama'; + +const outputChannel = vscode.window.createOutputChannel('Connect AI'); + +function timestamp() { + return new Date().toISOString(); +} + +function stringifyMeta(meta: unknown): string { + if (meta === undefined) return ''; + if (typeof meta === 'string') return meta; + if (meta instanceof Error) return `${meta.name}: ${meta.message}\n${meta.stack || ''}`; + try { + return JSON.stringify(meta, null, 2); + } catch { + return String(meta); + } +} + +function appendLog(level: 'INFO' | 'WARN' | 'ERROR', message: string, meta?: unknown) { + const suffix = meta === undefined ? '' : `\n${stringifyMeta(meta)}`; + outputChannel.appendLine(`[${timestamp()}] [${level}] ${message}${suffix}`); +} + +export function logInfo(message: string, meta?: unknown) { + appendLog('INFO', message, meta); +} + +export function logWarn(message: string, meta?: unknown) { + appendLog('WARN', message, meta); +} + +export function logError(message: string, meta?: unknown) { + appendLog('ERROR', message, meta); +} + +export function normalizeBaseUrl(rawUrl: string): string { + const trimmed = rawUrl.trim().replace(/\/+$/, ''); + if (!trimmed) { + return 'http://127.0.0.1:11434'; + } + return trimmed; +} + +export function resolveEngine(baseUrl: string): EngineKind { + const normalized = normalizeBaseUrl(baseUrl); + try { + const parsed = new URL(normalized); + if (parsed.pathname.endsWith('/v1') || parsed.port === '1234') return 'lmstudio'; + if (parsed.pathname.endsWith('/api') || parsed.port === '11434') return 'ollama'; + } catch { + if (normalized.includes('/v1') || normalized.includes(':1234')) return 'lmstudio'; + } + return 'ollama'; +} + +export function buildApiUrl(baseUrl: string, engine: EngineKind, endpoint: 'models' | 'chat'): string { + const normalized = normalizeBaseUrl(baseUrl); + if (engine === 'lmstudio') { + if (normalized.endsWith('/v1')) { + return endpoint === 'models' ? `${normalized}/models` : `${normalized}/chat/completions`; + } + return endpoint === 'models' ? `${normalized}/v1/models` : `${normalized}/v1/chat/completions`; + } + if (normalized.endsWith('/api')) { + return endpoint === 'models' ? `${normalized}/tags` : `${normalized}/chat`; + } + return endpoint === 'models' ? `${normalized}/api/tags` : `${normalized}/api/chat`; +} + +export function summarizeText(text: string, maxLength: number = 400): string { + const normalized = text.replace(/\s+/g, ' ').trim(); + if (normalized.length <= maxLength) return normalized; + return `${normalized.slice(0, maxLength)}...`; +} + export function shouldAutoPushBrain(): boolean { const cfg = vscode.workspace.getConfiguration('g1nation'); return cfg.get('autoPushBrain', false);