import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { _getBrainDir, findBrainFiles, buildApiUrl, getActiveBrainProfile, getBrainProfiles, logError, logInfo, resolveEngine, summarizeText, openInEditorGroup } from './utils'; import { getConfig } from './config'; import { AgentExecutor, ChatMessage } from './agent'; import { BridgeInterface } from './bridge'; import { buildProjectChronicleGuardContext, ProjectChronicleManager, ProjectProfile } from './features/projectChronicle'; import type { ModelLifecycleManager } from './lmstudio/lifecycleManager'; import type { IActivityTracker } from './lmstudio/activityTracker'; import { handleChatMessage } from './sidebar/chatHandlers'; import { handleBrainMessage } from './sidebar/brainHandlers'; import { handleChronicleMessage } from './sidebar/chronicleHandlers'; import { handleAgentMessage } from './sidebar/agentHandlers'; import { getOrCreateAgentEntry, resolveScopeForAgent } from './skills/agentKnowledgeMap'; import { estimateModelParamsB } from './lib/contextManager'; import { loadExternalSkills, formatSkillsAsPromptBlock } from './skills/externalSkillLoader'; import { buildOrRefreshArchitectureDoc, architectureDocPathFor, formatArchitectureContextForPrompt, resolveActiveSubprojectRoot, scanProject, } from './features/projectArchitecture'; import { detectProjectIntent, KnownProject } from './features/projectArchitecture/intentDetector'; import { readCompanyState, runCompanyTurn, summarizeForChip, CompanyTurnEvent, COMPANY_AGENTS, COMPANY_AGENT_ORDER, } from './features/company'; import { AIService } from './core/services'; export interface SidebarLmStudioDeps { lifecycle: ModelLifecycleManager; activity: IActivityTracker; /** Returns the list of model identifiers currently loaded in LM Studio (cached). */ loadedModels: () => Promise; } interface LastVisibleChatSnapshot { history: ChatMessage[]; brainProfileId: string; sessionId: string | null; timestamp: number; negativePrompt?: string; } interface ChatSession { id: string; title: string; timestamp: number; history: ChatMessage[]; brainProfileId: string; negativePrompt?: string; } /** * Sidebar UI Provider implementing BridgeInterface for BridgeServer */ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeInterface { public static readonly viewType = 'g1nation-v2-view'; static readonly activeSessionStateKey = 'g1nation.activeSessionId'; static readonly lastVisibleChatStateKey = 'g1nation.lastVisibleChat'; static readonly blankChatStateKey = 'g1nation.blankChatActive'; static readonly lastAgentStateKey = 'g1nation.lastAgentPath'; static readonly chronicleProjectsStateKey = 'g1nation.chronicleProjects'; static readonly activeChronicleProjectStateKey = 'g1nation.activeChronicleProjectId'; static readonly lastAutoChronicleSignatureStateKey = 'g1nation.lastAutoChronicleSignature'; _view?: vscode.WebviewView; _panel?: vscode.WebviewPanel; public brainEnabled = true; _currentSessionBrainId: string | null = null; _currentNegativePrompt: string = ''; readonly _chronicle = new ProjectChronicleManager(); _modelDiscoveryInFlight = false; _modelsCache: { url: string; models: string[]; online: boolean; fetchedAt: number } | null = null; static readonly MODELS_CACHE_TTL_MS = 30000; /** Active file-system watcher for the project-architecture auto-update. Disposed on switch/detach. */ private _archWatcher?: vscode.FileSystemWatcher; /** Debounce timer for the architecture watcher. */ private _archWatchDebounce?: NodeJS.Timeout; /** Project ID the current watcher is watching — kept so we don't double-register. */ private _archWatchedProjectId?: string; constructor( readonly _extensionUri: vscode.Uri, readonly _context: vscode.ExtensionContext, readonly _agent: AgentExecutor, readonly _lmStudio?: SidebarLmStudioDeps ) { this._agent.setHistoryChangeListener((history) => { void this._persistLastVisibleChat(history); }); } /** Surface LM Studio lifecycle errors (load/unload failures) to the chat UI as a non-fatal toast. */ public postLmStudioError(message: string): void { this._view?.webview.postMessage({ type: 'lmStudioError', value: message }); } public resolveWebviewView( webviewView: vscode.WebviewView, context: vscode.WebviewViewResolveContext, _token: vscode.CancellationToken, ) { this._initView(webviewView); } /** * Open the chat as a standalone editor panel (Column 3 by default). * Reuses the same view-init logic via a WebviewPanel→WebviewView adapter * so the rest of the provider keeps using `this._view` unchanged. */ public openAsPanel(column: vscode.ViewColumn = vscode.ViewColumn.Three): vscode.WebviewPanel { if (this._panel) { this._panel.reveal(column); return this._panel; } const panel = vscode.window.createWebviewPanel( SidebarChatProvider.viewType, 'Astra Chat', column, { enableScripts: true, localResourceRoots: [this._extensionUri], retainContextWhenHidden: true } ); this._panel = panel; const adapter = wrapPanelAsView(panel); panel.onDidDispose(() => { if (this._panel === panel) this._panel = undefined; if (this._view === adapter) this._view = undefined; }); this._initView(adapter); return panel; } private _initView(webviewView: vscode.WebviewView) { this._view = webviewView; webviewView.webview.options = { enableScripts: true, localResourceRoots: [this._extensionUri] }; // [State Persistence Fix] 사이드바가 다시 보여질 때 세팅값 자동 복원 let _lastVisibilityRefresh = 0; webviewView.onDidChangeVisibility(() => { if (!webviewView.visible) return; const now = Date.now(); // 5초 이내에 이미 갱신했으면 건너뜀 if (now - _lastVisibilityRefresh < 5000) return; _lastVisibilityRefresh = now; logInfo('Astra view became visible, restoring state...'); void this._sendModels(); void this._sendBrainProfiles(); void this._sendAgentsList(); void this._sendReadyStatus(); }); webviewView.webview.html = this._getHtml(webviewView.webview); this._agent.setWebview(webviewView.webview); void this._restoreActiveSessionIntoView(); void this._sendReadyStatus(); webviewView.webview.onDidReceiveMessage(async (data) => { if (await handleChatMessage(this, data)) return; if (await handleBrainMessage(this, data)) return; if (await handleChronicleMessage(this, data)) return; if (await handleAgentMessage(this, data)) return; logInfo(`Unhandled sidebar message: ${data?.type}`); }); } _currentSessionId: string | null = null; async _restoreActiveSessionIntoView() { if (!this._view) return; const blankChatActive = this._context.globalState.get(SidebarChatProvider.blankChatStateKey, false); const currentHistory = this._agent.getHistory(); if (blankChatActive && currentHistory.length === 0) { 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); } 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; this._currentNegativePrompt = snapshot.negativePrompt || ''; await this._setActiveBrainProfile(this._currentSessionBrainId, true); this._agent.setHistory(snapshot.history); this._view.webview.postMessage({ type: 'restoreHistory', value: snapshot.history, negativePrompt: this._currentNegativePrompt }); } } 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(), negativePrompt: this._currentNegativePrompt }; await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, snapshot); } async _saveCurrentSession() { const history = this._agent.getHistory(); if (history.length === 0) return; 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(); sessions.unshift({ id: this._currentSessionId, title, timestamp: Date.now(), history, brainProfileId, negativePrompt: this._currentNegativePrompt }); } 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; sessions[idx].negativePrompt = this._currentNegativePrompt; 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, negativePrompt: this._currentNegativePrompt }); } } // Keep only last 50 sessions if (sessions.length > 50) sessions = sessions.slice(0, 50); await this._putSessions(sessions); await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, this._currentSessionId); await this._persistLastVisibleChat(history); await this._sendSessionList(); } async _sendSessionList() { if (!this._view) return; 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 }); } 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) || this._getSessionById(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._currentNegativePrompt = session.negativePrompt || ''; 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, negativePrompt: this._currentNegativePrompt } }); 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; } async _deleteSession(id: string) { let sessions = this._getSessions(); sessions = sessions.filter(s => s.id !== id); await this._putSessions(sessions); if (this._currentSessionId === id) { this._currentSessionId = null; 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(); } _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), negativePrompt: String(session.negativePrompt || '') }; }) .filter((session): session is ChatSession => !!session) .sort((a, b) => b.timestamp - a.timestamp) .slice(0, 50); } _getSessionById(id: string): ChatSession | null { const rawSessions = this._context.globalState.get('chat_sessions', []) || []; const raw = rawSessions.find((session: any) => String(session?.id) === String(id)); if (!raw) return null; const history = Array.isArray(raw.history) ? raw.history.filter((message: any) => message && (message.role === 'user' || message.role === 'assistant' || message.role === 'system') && message.content !== undefined ) : []; if (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 Session'; return { id: String(raw.id), title: String(raw.title || fallbackTitle), timestamp: typeof raw.timestamp === 'number' ? raw.timestamp : Date.now(), history, brainProfileId: String(raw.brainProfileId || getActiveBrainProfile().id), negativePrompt: String(raw.negativePrompt || '') }; } async _putSessions(sessions: ChatSession[]) { await this._context.globalState.update('chat_sessions', sessions.slice(0, 50)); } async _sendBrainStatus() { if (!this._view) return; const activeBrain = getActiveBrainProfile(); const brainDir = activeBrain.localBrainPath; const files = findBrainFiles(brainDir); this._view.webview.postMessage({ type: 'brainStatus', value: { count: files.length, path: brainDir, name: activeBrain.name, description: activeBrain.description || '', repo: activeBrain.secondBrainRepo || '' } }); } /** * One-line "current readiness" snapshot for the sidebar's status bar: * engine online?, model loaded?, Brain file count, active Agent + mapped knowledge * folder count, memory on/off, context window. Cheap — no network calls except the * already-cached LM Studio loaded-models list and online flag. */ async _sendReadyStatus() { if (!this._view) return; let payload: any; try { const config = getConfig(); const engineKind = resolveEngine(config.ollamaUrl); const activeBrain = getActiveBrainProfile(); let brainFiles = 0; try { brainFiles = findBrainFiles(activeBrain.localBrainPath).length; } catch { /* ignore */ } const agentPath = this._context.globalState.get(SidebarChatProvider.lastAgentStateKey, 'none'); let agentName: string | null = null; let scopeFolders = 0; let mapped = false; if (agentPath && agentPath !== 'none') { agentName = path.basename(agentPath).replace(/\.md$/i, ''); try { const scope = resolveScopeForAgent(agentPath, activeBrain.localBrainPath || ''); scopeFolders = scope.folders.length; if (scope.agent?.name) agentName = scope.agent.name; mapped = scope.source !== 'none'; } catch { /* ignore */ } } let modelLoaded: boolean | null = null; if (engineKind === 'lmstudio') { try { const loaded = (await this._lmStudio?.loadedModels()) || []; modelLoaded = loaded.includes(config.defaultModel); } catch { modelLoaded = null; } } const paramB = estimateModelParamsB(config.defaultModel); const cappedForSmallModel = config.smallModelContextCap > 0 && paramB !== null && paramB <= 4 && config.contextLength > config.smallModelContextCap; const effectiveContextLength = cappedForSmallModel ? config.smallModelContextCap : config.contextLength; payload = { engine: { kind: engineKind, label: engineKind === 'lmstudio' ? 'LM Studio' : 'Ollama', online: this._modelsCache?.online ?? null, }, model: { name: config.defaultModel, loaded: modelLoaded, paramB }, brain: { name: activeBrain.name, files: brainFiles }, agent: { name: agentName, scopeFolders, mapped }, memory: config.memoryEnabled, multiAgent: config.multiAgentEnabled, contextLength: effectiveContextLength, nominalContextLength: config.contextLength, cappedForSmallModel, }; } catch (err: any) { logError('Failed to build ready status.', { error: err?.message || String(err) }); return; } this._view.webview.postMessage({ type: 'readyStatus', value: payload }); } 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 } }); void this._sendReadyStatus(); } _postBrainProfiles(profiles: any[], activeBrainId: string) { if (!this._view) return; this._view.webview.postMessage({ type: 'brainProfiles', value: { activeBrainId, profiles: profiles.map((p: any) => ({ id: p.id || '', name: p.name || '', path: p.localBrainPath || '', description: p.description || '', repo: p.secondBrainRepo || '' })) } }); } 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}\``); } } 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'); } 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 Astra 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; // cfg.update() is async and VSCode's config cache may not reflect the new value // immediately, so we post the freshly-built profile list directly. this._postBrainProfiles(nextProfiles, id); await this._sendBrainStatus(); this.injectSystemMessage(`**[Brain Added]** ${name.trim()}\n\`${folder}\``); } async _editBrainProfile(profileId?: string) { const currentProfiles = getBrainProfiles(); const target = currentProfiles.find((profile) => profile.id === profileId) || getActiveBrainProfile(); if (!target) return; const name = await vscode.window.showInputBox({ prompt: 'Edit brain profile name', value: target.name, validateInput: (value) => value.trim() ? null : 'Brain name is required.' }); if (!name) return; const folder = await vscode.window.showInputBox({ prompt: 'Edit local brain folder path', value: target.localBrainPath, validateInput: (value) => value.trim() ? null : 'Brain folder path is required.' }); if (!folder) return; const description = await vscode.window.showInputBox({ prompt: 'Edit optional description shown in the Astra sidebar', value: target.description || '' }); const repo = await vscode.window.showInputBox({ prompt: 'Edit optional Second Brain Git repository URL', value: target.secondBrainRepo || '' }); const nextProfiles = currentProfiles.map((profile) => profile.id === target.id ? { ...profile, name: name.trim(), localBrainPath: folder.trim(), secondBrainRepo: (repo || '').trim(), description: (description || '').trim() } : profile ); const cfg = vscode.workspace.getConfiguration('g1nation'); await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global); await cfg.update('activeBrainId', target.id, vscode.ConfigurationTarget.Global); this._currentSessionBrainId = target.id; this._postBrainProfiles(nextProfiles, target.id); await this._sendBrainStatus(); this.injectSystemMessage(`**[Brain Updated]** ${name.trim()}\n\`${folder.trim()}\``); } async _deleteBrainProfile(profileId?: string) { const currentProfiles = getBrainProfiles(); const target = currentProfiles.find((profile) => profile.id === profileId) || getActiveBrainProfile(); if (!target) return; if (currentProfiles.length <= 1) { vscode.window.showWarningMessage('At least one brain profile is required.'); return; } const confirm = await vscode.window.showWarningMessage( `Delete brain profile "${target.name}"? The folder itself will not be deleted.`, { modal: true }, 'Delete Profile' ); if (confirm !== 'Delete Profile') return; const nextProfiles = currentProfiles.filter((profile) => profile.id !== target.id); const nextActive = nextProfiles[0]; const cfg = vscode.workspace.getConfiguration('g1nation'); await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global); await cfg.update('activeBrainId', nextActive.id, vscode.ConfigurationTarget.Global); this._currentSessionBrainId = nextActive.id; this._postBrainProfiles(nextProfiles, nextActive.id); await this._sendBrainStatus(); this.injectSystemMessage(`**[Brain Deleted]** ${target.name}`); } async _saveWikiRaw() { const history = this._agent.getHistory(); if (history.length === 0) { vscode.window.showWarningMessage('There is no conversation to save as wiki raw data.'); return; } const activeBrain = getActiveBrainProfile(); const rawDir = path.join(activeBrain.localBrainPath, 'raw-data'); if (!fs.existsSync(rawDir)) { fs.mkdirSync(rawDir, { recursive: true }); } const category = await vscode.window.showInputBox({ prompt: 'Wiki raw data category', value: 'Project Notes' }); if (category === undefined) return; const expectedValue = await vscode.window.showInputBox({ prompt: 'Expected value or future use', value: 'Reusable as source material for a future wiki document.' }); if (expectedValue === undefined) return; const timestamp = new Date(); const slug = this._slugify(history.find((message) => message.role === 'user')?.content || 'conversation'); const fileName = `${this._formatTimestampForFile(timestamp)}-${slug}.md`; const filePath = path.join(rawDir, fileName); const markdown = this._buildWikiRawMarkdown(history, { category: category.trim() || 'Project Notes', expectedValue: expectedValue.trim() || 'Reusable as source material for a future wiki document.', activeBrainName: activeBrain.name, activeBrainPath: activeBrain.localBrainPath, createdAt: timestamp.toISOString() }); await vscode.workspace.fs.writeFile(vscode.Uri.file(filePath), Buffer.from(markdown, 'utf8')); vscode.window.showInformationMessage(`Wiki raw data saved: ${path.basename(filePath)}`); this.injectSystemMessage(`**[Wiki Raw Saved]** \`${filePath}\``); } _buildWikiRawMarkdown(history: ChatMessage[], meta: { category: string; expectedValue: string; activeBrainName: string; activeBrainPath: string; createdAt: string; }): string { const firstUserMessage = history.find((message) => message.role === 'user')?.content || ''; const latestUserMessage = [...history].reverse().find((message) => message.role === 'user')?.content || ''; const latestAssistantMessage = [...history].reverse().find((message) => message.role === 'assistant')?.content || ''; const rationaleNotes = history .filter((message) => message.role === 'assistant' && message.rationale) .map((message, index) => [ `### Process Note ${index + 1}`, message.rationale?.problem ? `- Problem: ${message.rationale.problem}` : '', message.rationale?.goal ? `- Goal: ${message.rationale.goal}` : '', message.rationale?.reasoning ? `- Reasoning: ${message.rationale.reasoning}` : '' ].filter(Boolean).join('\n')) .join('\n\n'); const transcript = history .map((message, index) => [ `### ${index + 1}. ${message.role.toUpperCase()}`, '', message.content.trim() || '(empty)' ].join('\n')) .join('\n\n'); return [ '---', `title: "${this._escapeYamlString(this._summarizeForTitle(firstUserMessage))}"`, `category: "${this._escapeYamlString(meta.category)}"`, `created_at: "${meta.createdAt}"`, `source: "Astra conversation"`, `brain: "${this._escapeYamlString(meta.activeBrainName)}"`, 'status: raw', '---', '', `# ${this._summarizeForTitle(firstUserMessage)}`, '', '## Category', meta.category, '', '## What This Contains', this._summarizeTextForWiki(latestAssistantMessage || firstUserMessage), '', '## Expected Value', meta.expectedValue, '', '## Discovery Process', rationaleNotes || 'No explicit rationale metadata was captured. Use the transcript below to reconstruct the reasoning path.', '', '## Conclusion', [ 'This conclusion was derived from the latest user request and assistant response.', '', `- Latest user request: ${this._summarizeTextForWiki(latestUserMessage)}`, `- Latest assistant conclusion: ${this._summarizeTextForWiki(latestAssistantMessage)}` ].join('\n'), '', '## Coding Implementation Notes', 'Use this section to turn the raw transcript into a wiki-ready implementation note. Capture which files changed, why they changed, and what verification was run.', '', '## Source Brain', `- Name: ${meta.activeBrainName}`, `- Path: ${meta.activeBrainPath}`, '', '## Raw Conversation Transcript', transcript, '' ].join('\n'); } _formatTimestampForFile(date: Date): string { return date.toISOString().replace(/[:.]/g, '-').replace('T', '_').replace('Z', ''); } _slugify(value: string): string { const slug = value .toLowerCase() .replace(/[^a-z0-9가-힣]+/g, '-') .replace(/^-|-$/g, '') .slice(0, 48); return slug || 'conversation'; } _summarizeForTitle(value: string): string { const normalized = value.replace(/\s+/g, ' ').trim(); if (!normalized) return 'Astra Conversation Raw Data'; return normalized.length > 80 ? `${normalized.slice(0, 80)}...` : normalized; } _summarizeTextForWiki(value: string): string { const normalized = value.replace(/\s+/g, ' ').trim(); if (!normalized) return 'Not captured.'; return normalized.length > 500 ? `${normalized.slice(0, 500)}...` : normalized; } _escapeYamlString(value: string): string { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); } // --- BridgeInterface Methods --- public injectSystemMessage(msg: string): void { this._view?.webview.postMessage({ type: 'streamStart' }); this._view?.webview.postMessage({ type: 'streamChunk', value: msg }); this._view?.webview.postMessage({ type: 'streamEnd' }); } public getHistoryText(): string { return "Conversation history placeholder for evaluation."; } public sendPromptFromExtension(prompt: string): void { if (this._view) { this._view.show?.(true); this._view.webview.postMessage({ type: 'injectPrompt', value: prompt }); } } public findBrainFiles(dir: string): string[] { return findBrainFiles(dir); } // --- End BridgeInterface --- public focusInput() { this._view?.webview.postMessage({ type: 'focusInput' }); } public clearChat() { this._view?.webview.postMessage({ type: 'clearChat' }); } public async syncBrain() { const activeBrain = getActiveBrainProfile(); const brainDir = activeBrain.localBrainPath; if (!fs.existsSync(brainDir)) { vscode.window.showErrorMessage("Second Brain directory not found."); return; } vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "Astra: Syncing Second Brain...", cancellable: false }, async () => { try { const { execSync } = require('child_process'); execSync(`git add .`, { cwd: brainDir }); execSync(`git commit -m "[G1-Sync] Manual knowledge update"`, { cwd: brainDir }); execSync(`git push`, { cwd: brainDir }); vscode.window.showInformationMessage("Second Brain synced successfully."); } catch (err: any) { vscode.window.showInformationMessage("Brain Synced: Local knowledge is up-to-date (no changes to push)."); } }); } _getChronicleProjects(): ProjectProfile[] { const raw = this._context.globalState.get(SidebarChatProvider.chronicleProjectsStateKey, []) || []; const valid = raw.filter((profile: ProjectProfile) => profile && typeof profile.projectId === 'string' && typeof profile.projectName === 'string' && typeof profile.recordRoot === 'string' ); if (valid.length > 0) return valid; const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; if (!workspaceRoot) return []; const now = new Date().toISOString(); const projectName = path.basename(workspaceRoot) || 'Current Project'; return [{ projectId: this._slugify(projectName), projectName, projectRoot: workspaceRoot, recordRoot: path.join(workspaceRoot, 'docs', 'records', projectName), description: 'Auto-detected current workspace project.', corePurpose: 'Capture project planning, decisions, development notes, bugs, and retrospectives as Markdown.', targetUsers: ['Project developer'], avoidDirections: ['Do not tightly couple records to chat execution internals.'], detailLevel: 'standard', createdAt: now, updatedAt: now }]; } async _putChronicleProjects(projects: ProjectProfile[]) { await this._context.globalState.update(SidebarChatProvider.chronicleProjectsStateKey, projects); } // ─── Project Architecture Context (Feature 2) ────────────────────────────── // // Activation flow: // 1. Chat preprocessor (or an explicit "Activate" button) calls // _tryActivateArchitectureFromText(latestUserMessage). // 2. If the text yields a known/inferable project, we set it active, // ensure the architecture doc exists, register the file watcher, // and broadcast the state to the webview as a chip. // 3. On every subsequent prompt, _handlePrompt reads // _buildProjectArchitectureContext() and injects it into the model // call. Detach → empty context + watcher disposed. /** True if the active project has its architecture doc auto-attached. */ _isArchitectureAutoAttached(): boolean { const p = this._getActiveChronicleProject(); return !!(p && p.architectureDocPath && p.architectureAutoAttach !== false); } /** * Try to resolve a project handle from arbitrary user text. Combines: * • Korean / English natural-language activation phrasing. * • Absolute filesystem paths. * • The existing Chronicle project list as ground truth for name matches. */ _detectProjectFromText(text: string): KnownProject | null { const known = this._getChronicleProjects().map((p) => ({ projectId: p.projectId, projectName: p.projectName, projectRoot: p.projectRoot, })); const hit = detectProjectIntent(text || '', known); return hit?.project ?? null; } /** * Activate (or refresh) architecture context for the project resolved from * `text`. No-op when no project is detected. Returns the activated profile * id, or `null` if nothing changed. Side-effects: writes the architecture * doc, marks the project active, broadcasts the chip state. */ async _tryActivateArchitectureFromText(text: string): Promise { const detected = this._detectProjectFromText(text); if (!detected) return null; return this._activateArchitectureForProject(detected.projectId, { fallbackName: detected.projectName, fallbackRoot: detected.projectRoot, }); } /** * Make `projectId` the active project, ensure its architecture doc exists, * and register the file watcher. If the project isn't in the chronicle * store yet (path-only match), materialise a minimal profile so subsequent * turns can find it. */ async _activateArchitectureForProject( projectId: string, opts: { fallbackName?: string; fallbackRoot?: string } = {} ): Promise { const projects = this._getChronicleProjects(); let profile = projects.find((p) => p.projectId === projectId); // Materialise a stub when the user references a project by path that // isn't yet registered. We use the path's basename as the name and the // standard records location as recordRoot so existing Chronicle code // keeps working. if (!profile) { const root = opts.fallbackRoot || ''; if (!root) { logError('architecture: cannot activate without project root.', { projectId }); return null; } const name = opts.fallbackName || path.basename(root) || projectId; const now = new Date().toISOString(); profile = { projectId, projectName: name, projectRoot: root, recordRoot: path.join(root, 'docs', 'records', name), description: 'Auto-created by Project Architecture activation.', corePurpose: '', detailLevel: 'standard', createdAt: now, updatedAt: now, }; projects.push(profile); await this._putChronicleProjects(projects); } if (!profile.projectRoot) { logError('architecture: profile has no projectRoot; cannot scan.', { projectId }); return null; } // Generate or refresh the doc. Always idempotent — the generator // preserves user-owned sections. const result = buildOrRefreshArchitectureDoc(profile.projectRoot, profile.projectName); const now = new Date().toISOString(); const updated: ProjectProfile = { ...profile, architectureDocPath: result.docPath, // [BUGFIX] Previously used `?? true`, but `??` only fallbacks on null/undefined. // After a user Detach, `architectureAutoAttach === false`, so `false ?? true` // stays `false` and the chip stays "detached — click Attach to re-enable" // forever no matter how many times the user clicks Attach. The activation // path is an explicit user intent to re-enable, so force `true` here. architectureAutoAttach: true, architectureAutoUpdate: profile.architectureAutoUpdate ?? true, architectureLastUpdated: now, architectureLastScanSignature: result.scan.signature, updatedAt: now, }; const next = projects.map((p) => (p.projectId === updated.projectId ? updated : p)); await this._putChronicleProjects(next); await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, projectId); // (Re)register the watcher for this project. this._registerArchitectureWatcher(updated); // Tell the webview to show / refresh the chip. await this._sendArchitectureStatus(); logInfo('architecture: activated.', { projectId, docPath: result.docPath, created: result.created, }); return projectId; } /** Detach project mode: stop auto-attaching the doc and dispose the watcher. */ async _detachArchitecture(): Promise { const profile = this._getActiveChronicleProject(); if (!profile) { this._disposeArchitectureWatcher(); await this._sendArchitectureStatus(); return; } const projects = this._getChronicleProjects(); const next = projects.map((p) => p.projectId === profile.projectId ? { ...p, architectureAutoAttach: false } : p); await this._putChronicleProjects(next); this._disposeArchitectureWatcher(); await this._sendArchitectureStatus(); logInfo('architecture: detached.', { projectId: profile.projectId }); } /** * Force a refresh of the architecture doc for the active project. * * Always rewrites the auto-managed block (so the "Last Refresh" stamp + * stats reflect the click). Emits an `architectureRefreshResult` event * with the per-file work breakdown — that's what makes the operation * visibly trustworthy in the UI (no more "0.1s, nothing visible"). */ async _refreshArchitecture(): Promise { const startedAt = Date.now(); const profile = this._getActiveChronicleProject(); if (!profile || !profile.projectRoot) { this._view?.webview.postMessage({ type: 'architectureRefreshFailed', value: { reason: 'no-active-project' }, }); return; } const result = buildOrRefreshArchitectureDoc(profile.projectRoot, profile.projectName); const now = new Date().toISOString(); const projects = this._getChronicleProjects(); const next = projects.map((p) => p.projectId === profile.projectId ? { ...p, architectureDocPath: result.docPath, architectureLastUpdated: now, architectureLastScanSignature: result.scan.signature, updatedAt: now, } : p); await this._putChronicleProjects(next); await this._sendArchitectureStatus(); // Tell the webview exactly what the scan did so the user can // trust the "Refresh" button actually ran. The three numbers // (newly / cached / deleted) together explain whether the doc // changed or just had its timestamp bumped. this._view?.webview.postMessage({ type: 'architectureRefreshResult', value: { projectName: profile.projectName, docPath: result.docPath, newlyAnalyzed: result.refreshStats.newlyAnalyzed, cached: result.refreshStats.cached, deleted: result.refreshStats.deleted.length, durationMs: Date.now() - startedAt, }, }); } /** * Re-attach the architecture context for the active project after a * prior Detach. Rebuilds the doc (so the user gets a fresh scan), * flips `architectureAutoAttach=true`, re-registers the watcher, and * broadcasts the chip back to its active state. The complement of * `_detachArchitecture`. */ async _attachArchitecture(): Promise { // `_ensureActiveProjectForWorkspace` guarantees the active project // matches the current VS Code workspace — without that, hitting // Attach right after opening a different folder would silently // attach to whatever was last active in the *previous* workspace. const profile = await this._ensureActiveProjectForWorkspace(); if (!profile || !profile.projectRoot) { this._view?.webview.postMessage({ type: 'architectureRefreshFailed', value: { reason: 'no-active-project' }, }); return; } await this._activateArchitectureForProject(profile.projectId, { fallbackName: profile.projectName, fallbackRoot: profile.projectRoot, }); } /** * Make sure the active chronicle project actually corresponds to the * folder the user has open in VS Code. Three cases: * * 1. Active project already matches workspace → return it as-is. * 2. A *different* chronicle project matches the workspace → flip * the active id to that one (the user switched folders since * last session). * 3. No chronicle project matches → synthesise a new one from the * workspace folder name + register it. * * Returns the (possibly newly created) active project, or `null` when * no workspace is open. Idempotent — calling repeatedly with no change * is free. */ async _ensureActiveProjectForWorkspace(): Promise { const wsRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; if (!wsRoot) return null; // When the parent folder contains several subprojects, use the active // editor's location to pick the *effective* subproject root instead of // always reporting the parent. const hint = vscode.window.activeTextEditor?.document.uri.fsPath ?? vscode.window.visibleTextEditors[0]?.document.uri.fsPath; const workspaceRoot = resolveActiveSubprojectRoot(wsRoot, hint); const projects = this._getChronicleProjects(); const active = this._getActiveChronicleProject(); const norm = (p: string | undefined) => (p || '').replace(/[\\/]+$/, '').toLowerCase(); if (active && active.projectRoot && norm(active.projectRoot) === norm(workspaceRoot)) { return active; } // Case 2: another chronicle project matches → switch active to it const matching = projects.find((p) => norm(p.projectRoot) === norm(workspaceRoot)); if (matching) { await this._context.globalState.update( SidebarChatProvider.activeChronicleProjectStateKey, matching.projectId, ); logInfo('architecture: switched active project to match workspace.', { from: active?.projectId, to: matching.projectId, }); return matching; } // Case 3: synthesise a fresh entry for this workspace const projectName = path.basename(workspaceRoot) || 'Current Project'; const projectId = this._slugify(projectName); const now = new Date().toISOString(); const profile: ProjectProfile = { projectId, projectName, projectRoot: workspaceRoot, recordRoot: path.join(workspaceRoot, 'docs', 'records', projectName), description: 'Auto-detected from workspace folder.', corePurpose: '', detailLevel: 'standard', createdAt: now, updatedAt: now, }; const nextProjects = projects.filter((p) => p.projectId !== projectId).concat(profile); await this._putChronicleProjects(nextProjects); await this._context.globalState.update( SidebarChatProvider.activeChronicleProjectStateKey, projectId, ); logInfo('architecture: registered new project from workspace.', { projectId, projectRoot: workspaceRoot, }); return profile; } /** * Build the `projectArchitectureContext` string for the active prompt. * Returns empty string when auto-attach is off or the doc is missing — * agent.ts then treats it as "no block" and emits nothing extra. */ _buildProjectArchitectureContext(): string { const p = this._getActiveChronicleProject(); if (!p || p.architectureAutoAttach === false || !p.architectureDocPath) return ''; if (!fs.existsSync(p.architectureDocPath)) return ''; return formatArchitectureContextForPrompt({ projectName: p.projectName, docPath: p.architectureDocPath, // Pass the project root so the `Source:` header in the prompt is // workspace-relative — keeps the prompt portable across machines. projectRoot: p.projectRoot, lastUpdated: p.architectureLastUpdated, }); } /** * Webview chip data. Three states: * * 1. **active** — project mode is on; doc is being auto-attached. * 2. **inactive** — there's a project + workspace, but architecture * is either never-activated or user-detached. * The chip shows an `[Attach]` button instead of * hiding entirely, so users always have a one- * click path back into project mode. * 3. **hidden** — no workspace open and no project at all. * * Also does an auto-activation pass for the *fresh-workspace* case: * when the active project has no `architectureDocPath` yet AND the * user hasn't explicitly detached, we generate the doc + flip * `autoAttach=true` so the user opens a new folder and immediately * sees the architecture context working. Existing detach choices are * always respected. */ async _sendArchitectureStatus(): Promise { if (!this._view) return; // Always sync the active project to the current VS Code workspace // before reporting — otherwise switching workspaces leaves the // chip pointing at the *previous* project's doc. const p = await this._ensureActiveProjectForWorkspace(); if (!p) { this._view.webview.postMessage({ type: 'architectureStatus', value: { active: false } }); return; } const wasDetached = p.architectureAutoAttach === false; const hasDoc = !!(p.architectureDocPath && fs.existsSync(p.architectureDocPath)); // Auto-activation for fresh workspaces: never been activated AND // never been detached → kick off a build and re-broadcast. Single // recursion is safe because the post-activate state will hit the // `active` branch below. if (!hasDoc && !wasDetached && p.projectRoot) { try { await this._activateArchitectureForProject(p.projectId, { fallbackName: p.projectName, fallbackRoot: p.projectRoot, }); return; // _activateArchitectureForProject sends its own status } catch (e: any) { logError('architecture: auto-activate failed.', { error: e?.message ?? String(e) }); // Fall through to the inactive state so the user still sees an Attach button. } } const fullyActive = hasDoc && !wasDetached; if (fullyActive) { this._view.webview.postMessage({ type: 'architectureStatus', value: { active: true, projectId: p.projectId, projectName: p.projectName, docPath: p.architectureDocPath, lastUpdated: p.architectureLastUpdated || '', autoUpdate: p.architectureAutoUpdate !== false, }, }); // Re-register the watcher in case it was disposed (e.g. workspace switch). this._registerArchitectureWatcher(p); } else { // Inactive but attachable: surface the project name + an Attach hook. this._view.webview.postMessage({ type: 'architectureStatus', value: { active: false, canAttach: !!p.projectRoot, projectId: p.projectId, projectName: p.projectName, // Distinguishes "never activated" from "detached" so the // chip can choose the right label ("Activate" vs "Re-attach"). detached: wasDetached, }, }); } } // ─── 1인 기업 (Company) Mode ──────────────────────────────────────────── // // When `companyState.enabled` is true, prompts coming through the chat // handler are routed to `_runCompanyTurn` instead of the normal // AgentExecutor path. The dispatcher emits `companyTurnUpdate` events as // each phase progresses; the webview shows a step-by-step header for // CEO planning, each specialist's dispatch, and the final synthesis. /** True iff company mode is active. Cheap — read from globalState. */ isCompanyModeEnabled(): boolean { return readCompanyState(this._context).enabled; } /** Send the chip state (active flag + agent count + name) to the webview. */ async _sendCompanyStatus(): Promise { if (!this._view) return; const state = readCompanyState(this._context); this._view.webview.postMessage({ type: 'companyStatus', value: { enabled: state.enabled, companyName: state.companyName, summary: summarizeForChip(state), activeAgentIds: state.activeAgentIds, modelOverrides: state.modelOverrides, }, }); } /** * Push the full agent catalogue when the manage panel opens. Each entry * carries both the *default* (from `agents.ts`) and *override* (from * globalState) fields so the UI can show the user what they've edited, * gray out unchanged fields, and offer a Reset button per agent. */ async _sendCompanyAgents(): Promise { if (!this._view) return; const state = readCompanyState(this._context); const cfg = getConfig(); const globalWeight = cfg.knowledgeMixSecondBrainWeight ?? 50; const agents = COMPANY_AGENT_ORDER.map((id) => { const def = COMPANY_AGENTS[id]; const override = state.promptOverrides[id] || {}; const kmOverride = state.knowledgeMixOverrides[id]; const hasKmOverride = typeof kmOverride === 'number'; return { id, name: def.name, role: def.role, emoji: def.emoji, color: def.color, alwaysOn: !!def.alwaysOn, active: id === 'ceo' || state.activeAgentIds.includes(id), modelOverride: state.modelOverrides[id] || '', // Defaults — never change at runtime. defaultTagline: def.tagline, defaultSpecialty: def.specialty, defaultPersona: def.persona || '', // Current effective values (default + override merged). tagline: override.tagline || def.tagline, specialty: override.specialty || def.specialty, persona: override.persona || def.persona || '', // Per-field override flags for the UI. personaOverridden: !!override.persona, specialtyOverridden: !!override.specialty, taglineOverridden: !!override.tagline, // Knowledge Mix — null when using global default, number otherwise. knowledgeMixOverride: hasKmOverride ? kmOverride : null, // What the dispatcher *will actually use* this turn (for hint UI). effectiveKnowledgeMixWeight: hasKmOverride ? kmOverride : globalWeight, }; }); this._view.webview.postMessage({ type: 'companyAgents', value: { companyName: state.companyName, globalKnowledgeMixWeight: globalWeight, agents, }, }); } /** * Drive one full company turn. Caller is the chat handler; it's already * persisted the user message and started a streaming bubble. We feed * progress events back as `companyTurnUpdate` messages so the same bubble * fills in as each agent finishes. */ async _runCompanyTurn(userPrompt: string): Promise { const cfg = getConfig(); const ai = new AIService(); const emit = (event: CompanyTurnEvent) => { this._view?.webview.postMessage({ type: 'companyTurnUpdate', value: event }); }; try { await runCompanyTurn(userPrompt, { context: this._context, ai, defaultModel: cfg.defaultModel || 'gemma4:e2b', // Knowledge Mix wiring so company specialists *also* see the // user's Second Brain — same global default + per-agent // override semantics the chat path uses. Without this the // Knowledge Mix slider had no effect on company turns. globalKnowledgeMixWeight: cfg.knowledgeMixSecondBrainWeight ?? 50, brainFileBaseline: cfg.memoryLongTermFiles ?? 6, // Hand the dispatcher a thunk into ConnectAI's action-tag // executor so specialist outputs like `` actually // hit disk. Without this, agents would *claim* to create // files while nothing happened — the exact bug we just fixed. executeActionTags: (text) => this._agent.executeActionTagsOnText(text), onEvent: emit, }); } catch (e: any) { logError('company.runTurn: unexpected failure.', { error: e?.message ?? String(e) }); this._view?.webview.postMessage({ type: 'error', value: `1인 기업 모드 실행 실패: ${e?.message ?? e}`, }); } finally { // The webview's send button is locked into the "generating" state // when the user submits; it only unlocks on `streamEnd`. The // normal chat path posts that from inside AgentExecutor, but // the company turn never touches AgentExecutor, so we have to // post it ourselves here — otherwise the input stays disabled // with the red Stop button after the round completes. this._view?.webview.postMessage({ type: 'streamEnd' }); void this._sendReadyStatus(); } } /** Open the architecture doc in editor group 2. */ async _openArchitectureDoc(): Promise { const p = this._getActiveChronicleProject(); if (!p || !p.architectureDocPath) return; try { const doc = await vscode.workspace.openTextDocument(p.architectureDocPath); await vscode.window.showTextDocument(doc, { viewColumn: vscode.ViewColumn.Two, preview: false, }); } catch (e: any) { vscode.window.showErrorMessage(`Architecture doc 열기 실패: ${e?.message ?? e}`); } } /** * Register a debounced watcher over the project root. Only structural * changes regen the doc — the signature hash decides whether to write. * Files inside node_modules / out / dist are filtered by the glob to keep * the noise floor sane during normal development. */ private _registerArchitectureWatcher(profile: ProjectProfile): void { if (!profile.projectRoot) return; if (this._archWatchedProjectId === profile.projectId && this._archWatcher) return; this._disposeArchitectureWatcher(); if (profile.architectureAutoUpdate === false) { this._archWatchedProjectId = profile.projectId; return; } const pattern = new vscode.RelativePattern( profile.projectRoot, '{package.json,tsconfig.json,README.md,src/**,media/**,docs/**,tests/**,core_py/**,lib/**,app/**}' ); const watcher = vscode.workspace.createFileSystemWatcher(pattern); const onChange = () => this._scheduleArchitectureRefresh(); watcher.onDidCreate(onChange); watcher.onDidDelete(onChange); watcher.onDidChange(onChange); this._archWatcher = watcher; this._archWatchedProjectId = profile.projectId; logInfo('architecture: watcher registered.', { projectId: profile.projectId, root: profile.projectRoot }); } private _disposeArchitectureWatcher(): void { try { this._archWatcher?.dispose(); } catch { /* noop */ } this._archWatcher = undefined; if (this._archWatchDebounce) { clearTimeout(this._archWatchDebounce); this._archWatchDebounce = undefined; } this._archWatchedProjectId = undefined; } private _scheduleArchitectureRefresh(): void { if (this._archWatchDebounce) clearTimeout(this._archWatchDebounce); // 6 s debounce: long enough that a "save file" burst settles into one // regen, short enough that the chip's "updated 2m ago" badge stays // believable. this._archWatchDebounce = setTimeout(async () => { const profile = this._getActiveChronicleProject(); if (!profile || !profile.projectRoot || profile.architectureAutoUpdate === false) return; try { // Cheap signature check first — most file events don't change shape. const scan = scanProject(profile.projectRoot, profile.projectName); if (scan.signature === profile.architectureLastScanSignature) return; await this._refreshArchitecture(); } catch (e: any) { logError('architecture: auto-refresh failed.', { error: e?.message ?? String(e) }); } }, 6000); } _getActiveChronicleProject(): ProjectProfile | null { const projects = this._getChronicleProjects(); if (projects.length === 0) return null; const activeId = this._context.globalState.get(SidebarChatProvider.activeChronicleProjectStateKey, ''); return projects.find(project => project.projectId === activeId) || projects[0]; } async _sendChronicleProjects() { if (!this._view) return; const projects = this._getChronicleProjects(); const active = this._getActiveChronicleProject(); this._view.webview.postMessage({ type: 'chronicleProjects', value: { activeProjectId: active?.projectId || '', projects: projects.map(project => ({ id: project.projectId, name: project.projectName, root: project.projectRoot || '', recordRoot: project.recordRoot, description: project.description || '' })) } }); } async _createChronicleProject() { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; const defaultName = workspaceRoot ? path.basename(workspaceRoot) : 'New Project'; const projectName = await vscode.window.showInputBox({ prompt: 'Project name for Chronicle records', value: defaultName, validateInput: (value) => value.trim() ? null : 'Project name is required.' }); if (!projectName) return; const description = await vscode.window.showInputBox({ prompt: 'One-line project description', value: 'Project planning, decisions, development logs, and bug records.' }); if (description === undefined) return; const projectRoot = await vscode.window.showInputBox({ prompt: 'Project root path', value: workspaceRoot, validateInput: (value) => value.trim() ? null : 'Project root is required.' }); if (!projectRoot) return; const defaultRecordRoot = path.join(projectRoot.trim(), 'docs', 'records', projectName.trim()); const recordRoot = await vscode.window.showInputBox({ prompt: 'Markdown record folder path', value: defaultRecordRoot, validateInput: (value) => value.trim() ? null : 'Record folder path is required.' }); if (!recordRoot) return; const corePurpose = await vscode.window.showInputBox({ prompt: 'Core project purpose or guardrail', value: 'Keep project knowledge traceable through Markdown records.' }); if (corePurpose === undefined) return; const detailChoice = await vscode.window.showQuickPick([ { label: 'standard', description: 'Request, question intent, decisions, planning, development, and bugs' }, { label: 'simple', description: 'Request summary, decisions, and implementation result' }, { label: 'detailed', description: 'Standard records plus alternatives, lessons, and retrospectives' } ], { placeHolder: 'Chronicle record detail level' }); if (!detailChoice) return; const now = new Date().toISOString(); const projects = this._getChronicleProjects(); const idBase = this._slugify(projectName.trim()); let projectId = idBase; let suffix = 2; while (projects.some(project => project.projectId === projectId)) { projectId = `${idBase}-${suffix++}`; } const profile: ProjectProfile = { projectId, projectName: projectName.trim(), projectRoot: projectRoot.trim(), recordRoot: recordRoot.trim(), description: description.trim(), corePurpose: corePurpose.trim(), targetUsers: ['Project developer'], avoidDirections: ['Do not mix records across projects.', 'Do not tightly couple records to core agent execution.'], detailLevel: detailChoice.label as ProjectProfile['detailLevel'], createdAt: now, updatedAt: now }; this._chronicle.ensureProject(profile); const nextProjects = [...projects.filter(project => project.projectId !== profile.projectId), profile]; await this._putChronicleProjects(nextProjects); await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, profile.projectId); await this._sendChronicleProjects(); this.injectSystemMessage(`**[Designer Project Created]** ${profile.projectName}\n\`${profile.recordRoot}\``); } async _setActiveChronicleProject(projectId: string) { if (!projectId || projectId === 'new') { await this._createChronicleProject(); return; } const target = this._getChronicleProjects().find(project => project.projectId === projectId); if (!target) return; await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, target.projectId); await this._sendChronicleProjects(); await this._sendChronicleRecords(); this.injectSystemMessage(`**[Designer Project Selected]** ${target.projectName}\n\`${target.recordRoot}\``); } async _openChronicleFolder() { const profile = this._getActiveChronicleProject(); if (!profile) { vscode.window.showWarningMessage('No Chronicle project is selected.'); return; } try { this._chronicle.ensureProject(profile); await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(profile.recordRoot)); } catch (err: any) { vscode.window.showErrorMessage(`Failed to open Chronicle folder: ${err.message}`); } } async _sendChronicleRecords() { if (!this._view) return; const profile = this._getActiveChronicleProject(); if (!profile) { this._view.webview.postMessage({ type: 'chronicleRecords', value: [] }); return; } try { const records = this._chronicle.listRecords(profile).map(record => ({ section: record.section, fileName: record.fileName, path: record.filePath, relativePath: record.relativePath, updatedAt: record.updatedAt })); this._view.webview.postMessage({ type: 'chronicleRecords', value: records }); } catch (err: any) { vscode.window.showErrorMessage(`Failed to list Chronicle records: ${err.message}`); } } async _openChronicleRecord(recordPath: string) { const profile = this._getActiveChronicleProject(); if (!profile || !recordPath) { vscode.window.showWarningMessage('Select a Chronicle record first.'); return; } const root = path.resolve(profile.recordRoot); const target = path.resolve(recordPath); if (!target.startsWith(`${root}${path.sep}`) || path.extname(target) !== '.md') { vscode.window.showErrorMessage('Selected Chronicle record path is not valid.'); return; } if (!fs.existsSync(target)) { vscode.window.showErrorMessage('Selected Chronicle record no longer exists.'); await this._sendChronicleRecords(); return; } await openInEditorGroup(target); } async _writeChroniclePlanningFromCurrentChat() { const profile = this._getActiveChronicleProject(); if (!profile) { vscode.window.showWarningMessage('Select or create a Designer project first.'); return; } const history = this._agent.getHistory(); const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || ''; const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || ''; const featureName = await vscode.window.showInputBox({ prompt: 'Feature name for the planning document', value: this._summarizeForTitle(latestUser || 'Project Chronicle Feature') }); if (!featureName) return; try { const createdAt = new Date().toISOString(); const result = this._chronicle.writePlanning(profile, { featureName: featureName.trim(), purpose: 'Record the reason, scope, direction, and success criteria before implementation.', background: this._summarizeTextForWiki(latestAssistant || latestUser), userIntent: this._summarizeTextForWiki(latestUser), sourceRequest: latestUser || 'No user request captured in the current chat.', scope: [ 'Create a project-specific planning record.', 'Capture user intent and implementation direction.', 'Keep the record independent from chat execution internals.' ], outOfScope: [ 'Full automatic transcript capture.', 'External database integration.', 'Git automation.' ], developmentDirection: 'Use Project Chronicle as a low-dependency Markdown record layer.', dependencyStrategy: 'Use local filesystem writes through the independent projectChronicle module.', expectedValue: 'Future work can understand why this feature exists and what decisions shaped it.', successCriteria: [ 'The planning document is created under the selected project record folder.', 'The document includes user intent, scope, out-of-scope items, and success criteria.' ], developerInstruction: 'Use this document as the implementation guardrail for the next development step.', createdAt }); this._chronicle.appendTimeline(profile, [`Planning record created: ${result.relativePath}`], createdAt); vscode.window.showInformationMessage(`Chronicle planning saved: ${result.relativePath}`); await this._sendChronicleRecords(); this.injectSystemMessage(`**[Chronicle Planning Saved]** \`${result.filePath}\``); } catch (err: any) { vscode.window.showErrorMessage(`Failed to write planning record: ${err.message}`); } } async _writeChronicleDiscussionFromCurrentChat() { const profile = this._getActiveChronicleProject(); if (!profile) { vscode.window.showWarningMessage('Select or create a Designer project first.'); return; } const history = this._agent.getHistory(); const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || ''; const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || ''; const title = await vscode.window.showInputBox({ prompt: 'Discussion title', value: this._summarizeForTitle(latestUser || 'Project Discussion') }); if (!title) return; const question = await vscode.window.showInputBox({ prompt: 'AI question to record (optional)', value: '' }); if (question === undefined) return; let questions: any[] = []; if (question.trim()) { const reason = await vscode.window.showInputBox({ prompt: 'Why was this question asked?', value: 'To avoid writing records to the wrong project or making an unclear design decision.' }); if (reason === undefined) return; const impact = await vscode.window.showInputBox({ prompt: 'How does this question affect the decision?', value: 'It determines the correct project context, scope, or implementation path.' }); if (impact === undefined) return; questions = [{ question: question.trim(), reason: reason.trim(), expectedInformation: 'Information needed to clarify project context, scope, or decision direction.', impactOnDecision: impact.trim() }]; } try { const createdAt = new Date().toISOString(); const result = this._chronicle.writeDiscussion(profile, { title: title.trim(), userRequest: this._summarizeTextForWiki(latestUser || 'No user request captured in the current chat.'), interpretedIntent: 'Capture the discussion as a reusable project knowledge record instead of leaving it only in chat history.', questions, discussions: [ this._summarizeTextForWiki(latestAssistant || latestUser || 'No discussion detail captured yet.') ], decisions: [], createdAt }); this._chronicle.appendTimeline(profile, [`Discussion record created: ${result.relativePath}`], createdAt); vscode.window.showInformationMessage(`Chronicle discussion saved: ${result.relativePath}`); await this._sendChronicleRecords(); this.injectSystemMessage(`**[Chronicle Discussion Saved]** \`${result.filePath}\``); } catch (err: any) { vscode.window.showErrorMessage(`Failed to write discussion record: ${err.message}`); } } async _writeChronicleDecisionFromInput() { const profile = this._getActiveChronicleProject(); if (!profile) { vscode.window.showWarningMessage('Select or create a Designer project first.'); return; } const title = await vscode.window.showInputBox({ prompt: 'Decision title', value: 'Use independent Markdown record module' }); if (!title) return; const decision = await vscode.window.showInputBox({ prompt: 'Decision', value: 'Implement this behavior as an independent Project Chronicle module.' }); if (decision === undefined) return; const reason = await vscode.window.showInputBox({ prompt: 'Decision reason', value: 'To reduce coupling and keep project records portable.' }); if (reason === undefined) return; const alternatives = await vscode.window.showInputBox({ prompt: 'Rejected alternatives (comma-separated)', value: 'Integrate with Second Brain, integrate directly into Agent execution' }); if (alternatives === undefined) return; try { const createdAt = new Date().toISOString(); const adrNumber = this._chronicle.nextAdrNumber(profile); const result = this._chronicle.writeDecision(profile, { title: title.trim(), status: 'accepted', context: 'A project record needs to capture not only what changed, but why the direction was chosen.', decision: decision.trim(), reason: reason.trim(), alternatives: alternatives.split(',').map(item => item.trim()).filter(Boolean), consequences: [ 'Records can evolve independently from chat and agent internals.', 'Future automation can emit chronicle events without owning the core execution path.' ], createdAt }, adrNumber); this._chronicle.appendTimeline(profile, [`Decision record created: ${result.relativePath}`], createdAt); vscode.window.showInformationMessage(`Chronicle decision saved: ${result.relativePath}`); await this._sendChronicleRecords(); this.injectSystemMessage(`**[Chronicle Decision Saved]** \`${result.filePath}\``); } catch (err: any) { vscode.window.showErrorMessage(`Failed to write decision record: ${err.message}`); } } async _writeChronicleDevelopmentFromCurrentChat() { const profile = this._getActiveChronicleProject(); if (!profile) { vscode.window.showWarningMessage('Select or create a Designer project first.'); return; } const history = this._agent.getHistory(); const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || ''; const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || ''; const featureName = await vscode.window.showInputBox({ prompt: 'Feature name for the development log', value: this._summarizeForTitle(latestUser || 'Implementation Log') }); if (!featureName) return; try { const createdAt = new Date().toISOString(); const result = this._chronicle.writeDevelopmentLog(profile, { featureName: featureName.trim(), purpose: 'Record the actual implementation outcome for later maintenance.', implementationSummary: this._summarizeTextForWiki(latestAssistant || 'Implementation summary was not captured from chat yet.'), architecture: 'Project Chronicle records are written through an independent Markdown module.', changedFiles: ['Capture exact changed files after verification.'], dependencyNotes: 'Keep dependencies limited to TypeScript, Node fs/path, and VS Code extension APIs.', bugs: [], lessons: [ 'Write implementation notes as soon as a stable development step finishes.', 'Keep generated records project-specific.' ], createdAt }); this._chronicle.appendTimeline(profile, [`Development log created: ${result.relativePath}`], createdAt); vscode.window.showInformationMessage(`Chronicle development log saved: ${result.relativePath}`); await this._sendChronicleRecords(); this.injectSystemMessage(`**[Chronicle Development Saved]** \`${result.filePath}\``); } catch (err: any) { vscode.window.showErrorMessage(`Failed to write development record: ${err.message}`); } } async _writeChronicleBugFromInput() { const profile = this._getActiveChronicleProject(); if (!profile) { vscode.window.showWarningMessage('Select or create a Designer project first.'); return; } const title = await vscode.window.showInputBox({ prompt: 'Bug title', value: 'record-generation-issue' }); if (!title) return; const symptom = await vscode.window.showInputBox({ prompt: 'Bug symptom', value: 'Describe what failed or looked wrong.' }); if (symptom === undefined) return; const cause = await vscode.window.showInputBox({ prompt: 'Bug cause', value: 'Cause is not confirmed yet.' }); if (cause === undefined) return; const fix = await vscode.window.showInputBox({ prompt: 'Fix', value: 'Describe the fix or mitigation.' }); if (fix === undefined) return; try { const createdAt = new Date().toISOString(); const bugNumber = this._chronicle.nextBugNumber(profile); const result = this._chronicle.writeBug(profile, { title: title.trim(), symptom: symptom.trim(), cause: cause.trim(), fix: fix.trim(), prevention: 'Validate project selection, record path, and write permissions before generating files.', createdAt }, bugNumber); this._chronicle.appendTimeline(profile, [`Bug record created: ${result.relativePath}`], createdAt); vscode.window.showInformationMessage(`Chronicle bug record saved: ${result.relativePath}`); await this._sendChronicleRecords(); this.injectSystemMessage(`**[Chronicle Bug Saved]** \`${result.filePath}\``); } catch (err: any) { vscode.window.showErrorMessage(`Failed to write bug record: ${err.message}`); } } async _writeChronicleRetrospectiveFromInput() { const profile = this._getActiveChronicleProject(); if (!profile) { vscode.window.showWarningMessage('Select or create a Designer project first.'); return; } const title = await vscode.window.showInputBox({ prompt: 'Retrospective title', value: 'Project Chronicle Guard iteration' }); if (!title) return; const summary = await vscode.window.showInputBox({ prompt: 'Work summary', value: 'Completed an incremental development step and recorded the outcome.' }); if (summary === undefined) return; const wentWell = await vscode.window.showInputBox({ prompt: 'What went well? (comma-separated)', value: 'Kept the feature independent, Generated Markdown records, Preserved project context' }); if (wentWell === undefined) return; const toImprove = await vscode.window.showInputBox({ prompt: 'What should improve? (comma-separated)', value: 'More automatic question intent capture, Richer record editing UI' }); if (toImprove === undefined) return; const nextActions = await vscode.window.showInputBox({ prompt: 'Next actions (comma-separated)', value: 'Add tests, Improve Designer UI, Add event-based record capture' }); if (nextActions === undefined) return; try { const createdAt = new Date().toISOString(); const result = this._chronicle.writeRetrospective(profile, { title: title.trim(), summary: summary.trim(), wentWell: wentWell.split(',').map(item => item.trim()).filter(Boolean), toImprove: toImprove.split(',').map(item => item.trim()).filter(Boolean), nextActions: nextActions.split(',').map(item => item.trim()).filter(Boolean), createdAt }); this._chronicle.appendTimeline(profile, [`Retrospective created: ${result.relativePath}`], createdAt); vscode.window.showInformationMessage(`Chronicle retrospective saved: ${result.relativePath}`); await this._sendChronicleRecords(); this.injectSystemMessage(`**[Chronicle Retrospective Saved]** \`${result.filePath}\``); } catch (err: any) { vscode.window.showErrorMessage(`Failed to write retrospective record: ${err.message}`); } } async _autoWriteChronicleAfterPrompt() { const history = this._agent.getHistory(); const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || ''; const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || ''; const profile = this._getChronicleProjectForConversation(latestUser) || this._getActiveChronicleProject(); if (!profile) return; const recordType = this._inferAutoChronicleRecordType(latestUser, latestAssistant); if (!recordType) return; const signature = [ profile.projectId, recordType, this._summarizeTextForWiki(latestUser).slice(0, 240), this._summarizeTextForWiki(latestAssistant).slice(0, 240) ].join('|'); const lastSignature = this._context.globalState.get(SidebarChatProvider.lastAutoChronicleSignatureStateKey, ''); if (signature === lastSignature) return; try { this._chronicle.ensureProject(profile); const createdAt = new Date().toISOString(); const title = this._summarizeForTitle(latestUser || latestAssistant || 'Project Chronicle Auto Record'); const summary = this._summarizeTextForWiki(latestAssistant || latestUser); let result; if (recordType === 'bug') { const bugNumber = this._chronicle.nextBugNumber(profile); result = this._chronicle.writeBug(profile, { title, symptom: this._summarizeTextForWiki(latestUser || 'Issue was detected during the conversation.'), cause: 'Captured automatically from the current conversation. Confirm root cause during follow-up review if needed.', fix: summary, prevention: 'Keep automatic records tied to the active project and verify the relevant test or reproduction path.', createdAt }, bugNumber); } else if (recordType === 'planning') { result = this._chronicle.writePlanning(profile, { featureName: title, purpose: 'Capture the current planning or architecture direction before implementation continues.', background: summary, userIntent: this._summarizeTextForWiki(latestUser), sourceRequest: latestUser || 'No user request captured in the current chat.', scope: ['Continue from the active project conversation.', 'Use the selected project record folder automatically.'], outOfScope: ['Manual record type selection.', 'Blocking the user with record-writing prompts.'], developmentDirection: summary, dependencyStrategy: 'Prefer existing project modules and local Markdown records.', expectedValue: 'Future work can resume with the latest project intent and reasoning preserved.', successCriteria: ['The record is saved automatically after a meaningful project turn.', 'The record stays under the active project.'], developerInstruction: 'Use this record as lightweight context for the next development or review pass.', createdAt }); } else if (recordType === 'decision') { const adrNumber = this._chronicle.nextAdrNumber(profile); result = this._chronicle.writeDecision(profile, { title, status: 'accepted', context: this._summarizeTextForWiki(latestUser), decision: summary, reason: 'Captured automatically because the conversation contained decision-oriented language.', alternatives: [], consequences: ['Future prompts should treat this as project context unless the user changes direction.'], createdAt }, adrNumber); } else if (recordType === 'development') { result = this._chronicle.writeDevelopmentLog(profile, { featureName: title, purpose: 'Record the implementation or verification outcome from the current conversation.', implementationSummary: summary, architecture: 'Captured automatically from the assistant response and active project context.', changedFiles: this._extractChangedFilesFromText(latestAssistant), dependencyNotes: 'No new dependency note was captured automatically.', bugs: [], lessons: ['Automatic project records should be generated in the background when the turn contains durable project knowledge.'], createdAt }); } else { result = this._chronicle.writeDiscussion(profile, { title, userRequest: this._summarizeTextForWiki(latestUser || 'No user request captured in the current chat.'), interpretedIntent: 'Capture a meaningful project discussion automatically instead of requiring manual record selection.', questions: [], discussions: [summary], decisions: [], createdAt }); } this._chronicle.appendTimeline(profile, [`Auto ${recordType} record created: ${result.relativePath}`], createdAt); await this._context.globalState.update(SidebarChatProvider.lastAutoChronicleSignatureStateKey, signature); await this._sendChronicleRecords(); vscode.window.setStatusBarMessage(`Astra: Chronicle auto-saved ${recordType}`, 3500); } catch (err: any) { logError('Automatic Chronicle record write failed.', { error: err?.message || String(err), recordType }); } } _getChronicleProjectForConversation(text: string): ProjectProfile | null { const projectPath = this._extractLocalProjectPath(text); if (!projectPath) return null; const projects = this._getChronicleProjects(); const resolvedPath = path.resolve(projectPath); const existing = projects.find(project => { const root = project.projectRoot ? path.resolve(project.projectRoot) : ''; const recordRoot = path.resolve(project.recordRoot); return root === resolvedPath || recordRoot.startsWith(`${resolvedPath}${path.sep}`); }); if (existing) return existing; const projectName = path.basename(resolvedPath) || 'Current Project'; const now = new Date().toISOString(); return { projectId: this._slugify(projectName), projectName, projectRoot: resolvedPath, recordRoot: path.join(resolvedPath, 'docs', 'records', projectName), description: 'Auto-detected from the local project path in the conversation.', corePurpose: 'Capture project direction, architecture discussion, decisions, and development notes as Markdown.', targetUsers: ['Project developer'], avoidDirections: ['Do not mix records across projects.'], detailLevel: 'standard', createdAt: now, updatedAt: now }; } _extractLocalProjectPath(text: string): string | null { const match = text.match(/\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+/i); if (!match) return null; const candidate = match[0].replace(/[.,;:)\]]+$/, ''); try { if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) { return candidate; } } catch { return null; } return null; } _inferAutoChronicleRecordType(userText: string, assistantText: string): 'planning' | 'discussion' | 'decision' | 'development' | 'bug' | null { const combined = `${userText}\n${assistantText}`; if (!combined.trim()) return null; if (/(기록하지마|저장하지마|no\s+record|do\s+not\s+record)/i.test(combined)) return null; if (!/(프로젝트|코드|아키텍처|설계|개선|수정|구현|테스트|검증|이슈|문제|버그|오류|끊김|결정|방향|기록|지식|review|architecture|implement|fix|bug|issue|test|decision)/i.test(combined)) { return null; } if (/(버그|오류|에러|이슈|문제|끊김|안\s*됨|실패|bug|error|issue|failed|failure)/i.test(userText)) { return 'bug'; } if (/(수정 완료|개선 완료|구현 완료|패치|테스트.*통과|검증.*완료|변경.*파일|compile|jest|tsc|passed|implemented|fixed)/i.test(assistantText)) { return 'development'; } if (/(결정|확정|채택|방향은|하기로|하지 않기로|decision|decide|accepted)/i.test(combined)) { return 'decision'; } if (/(계획|설계|아키텍처|조사|방향|로드맵|mvp|planning|architecture|roadmap|design)/i.test(userText)) { return 'planning'; } if (/(개선|수정|구현|테스트|검증|패킹|compile|jest|tsc|implement|fix|test|verify)/i.test(combined)) { return 'development'; } return 'discussion'; } _extractChangedFilesFromText(text: string): string[] { const files = new Set(); for (const match of text.matchAll(/`([^`\n]+\.(?:ts|tsx|js|jsx|json|md|css|html|py|yml|yaml))`/gi)) { files.add(match[1].trim()); } return files.size > 0 ? Array.from(files).slice(0, 12) : ['No explicit changed file list was captured automatically.']; } async _writeChronicleRecord(recordType: string) { switch (recordType) { case 'planning': await this._writeChroniclePlanningFromCurrentChat(); break; case 'discussion': await this._writeChronicleDiscussionFromCurrentChat(); break; case 'decision': await this._writeChronicleDecisionFromInput(); break; case 'development': await this._writeChronicleDevelopmentFromCurrentChat(); break; case 'bug': await this._writeChronicleBugFromInput(); break; case 'retrospective': await this._writeChronicleRetrospectiveFromInput(); break; default: vscode.window.showWarningMessage('Select a Chronicle record type first.'); } } _getAgentsDir(): string { // 1) Explicit config override (works on any OS — useful on Windows or for skills outside the workspace). const configured = (vscode.workspace.getConfiguration('g1nation').get('agentSkillsPath', '') || '').trim(); const expanded = configured.startsWith('~/') || configured === '~' ? path.join(os.homedir(), configured.slice(1).replace(/^[\\/]/, '')) : configured; if (expanded && path.isAbsolute(expanded)) { if (!fs.existsSync(expanded)) { try { fs.mkdirSync(expanded, { recursive: true }); } catch { /* fall through to workspace */ } } if (fs.existsSync(expanded)) return expanded; } // 2) Default: /.agent/skills const workspaceFolders = vscode.workspace.workspaceFolders; if (workspaceFolders) { const localPath = path.join(workspaceFolders[0].uri.fsPath, '.agent', 'skills'); if (!fs.existsSync(localPath)) { fs.mkdirSync(localPath, { recursive: true }); } return localPath; } return ''; } async _sendAgentsList() { if (!this._view) return; const dir = this._getAgentsDir(); const agents = []; if (dir && fs.existsSync(dir)) { const files = fs.readdirSync(dir); for (const f of files) { if (f.endsWith('.md')) { agents.push({ name: f.replace('.md', ''), path: path.join(dir, f) }); } } } const lastPath = this._context.globalState.get(SidebarChatProvider.lastAgentStateKey, 'none'); this._view.webview.postMessage({ type: 'agentsList', value: agents, selected: lastPath }); void this._sendReadyStatus(); } async _handleProactiveSuggestion(context: string) { if (!this._view) return; let suggestion = ''; switch (context) { case 'settings_exploration': suggestion = '💡 **Tip:** 모델 설정을 최적화하여 답변 속도를 2배 이상 높일 수 있습니다. 설정에서 `Max Context Size`를 조정해보세요!'; break; case 'brain_sync_exploration': suggestion = '🧠 **Knowledge Sync:** 최근에 수정한 파일이 지식 베이스에 반영되지 않았나요? 지금 동기화 버튼을 눌러 최신 정보를 업데이트하세요.'; break; case 'agent_selection_exploration': suggestion = '🤖 **Agent Skills:** 특정 언어나 프레임워크에 특화된 에이전트 스킬을 선택하면 더 정확한 코드를 생성할 수 있습니다.'; break; default: suggestion = '💡 새로운 기능을 발견하셨나요? 궁금한 점이 있다면 언제든 물어보세요!'; } this._view.webview.postMessage({ type: 'streamStart' }); this._view.webview.postMessage({ type: 'streamChunk', value: `\n\n> [!TIP]\n> ${suggestion}\n` }); this._view.webview.postMessage({ type: 'streamEnd' }); } async _createAgent() { const name = await vscode.window.showInputBox({ prompt: 'Name of the new Agent (e.g., frontend_expert)', placeHolder: 'Agent name...' }); if (!name) return; const safeName = name.trim().replace(/[^a-zA-Z0-9_\-\u3131-\uD79D가-힣]/g, '_'); if (!safeName) return; const dir = this._getAgentsDir(); if (!dir) { vscode.window.showErrorMessage('Agent directory could not be determined.'); return; } const filePath = path.join(dir, `${safeName}.md`); if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, `# Agent Persona: ${safeName}\n\nAdd your instructions here...\n`, 'utf8'); } await openInEditorGroup(filePath); await this._sendAgentsList(); } async _sendAgentContent(agentPath: string) { if (!this._view || !agentPath || agentPath === 'none') return; if (fs.existsSync(agentPath)) { const content = fs.readFileSync(agentPath, 'utf8'); const negativePrompt = this._context.globalState.get(`negativePrompt:${agentPath}`, ''); this._view.webview.postMessage({ type: 'agentContent', value: content, negativePrompt: negativePrompt }); await this._context.globalState.update(SidebarChatProvider.lastAgentStateKey, agentPath); } } async _updateAgent(agentPath: string, content: string, negativePrompt?: string) { if (!agentPath || agentPath === 'none') return; try { fs.writeFileSync(agentPath, content, 'utf8'); if (negativePrompt !== undefined) { await this._context.globalState.update(`negativePrompt:${agentPath}`, negativePrompt); } vscode.window.showInformationMessage('Agent skill updated successfully.'); } catch (err: any) { vscode.window.showErrorMessage(`Failed to update agent: ${err.message}`); } } async _deleteAgent(agentPath: string) { if (!agentPath || agentPath === 'none') return; try { const agentsDir = path.resolve(this._getAgentsDir()); const targetPath = path.resolve(agentPath); if (!targetPath.startsWith(`${agentsDir}${path.sep}`) || path.extname(targetPath) !== '.md') { vscode.window.showErrorMessage('Selected agent skill path is not valid.'); return; } if (!fs.existsSync(targetPath)) { await this._context.globalState.update(SidebarChatProvider.lastAgentStateKey, 'none'); await this._sendAgentsList(); return; } const confirm = await vscode.window.showWarningMessage( `Delete agent skill "${path.basename(targetPath, '.md')}"?`, { modal: true }, 'Delete Skill' ); if (confirm !== 'Delete Skill') return; fs.unlinkSync(targetPath); await this._context.globalState.update(SidebarChatProvider.lastAgentStateKey, 'none'); await this._sendAgentsList(); this._view?.webview.postMessage({ type: 'agentDeleted' }); vscode.window.showInformationMessage('Agent skill deleted successfully.'); } catch (err: any) { vscode.window.showErrorMessage(`Failed to delete agent: ${err.message}`); } } async _handlePrompt(data: any) { if (!this._view) return; const { value, model, internet, files, agentFile, negativePrompt, designerGuard, secondBrainTrace, secondBrainTraceDebug, brainProfileId } = data; this._currentNegativePrompt = negativePrompt || ''; const selectedBrainId = typeof brainProfileId === 'string' && brainProfileId && brainProfileId !== 'new' ? brainProfileId : getActiveBrainProfile().id; this._currentSessionBrainId = selectedBrainId; let agentSkillContext = undefined; // Per-agent model override: if the active agent has a pinned model in the // knowledge map, it wins over the model the webview just sent. Falls back // to the incoming `model` (which is the global default the user picked). let effectiveModel: string = typeof model === 'string' ? model : ''; if (agentFile && agentFile !== 'none' && fs.existsSync(agentFile)) { const fileContent = fs.readFileSync(agentFile, 'utf8'); // Guard: a freshly-created agent still has only the placeholder template // ("# Agent Persona: …\n\nAdd your instructions here…"). Treating that as a real // agent prompt just confuses the model — fall back to normal mode and tell the user. const body = fileContent.replace(/^?#\s*Agent\s*Persona\s*:.*$/im, '').trim(); const isPlaceholder = !body || /^add your instructions here/i.test(body); if (isPlaceholder) { logInfo('Selected agent has no real instructions — running without agent mode.', { agentFile }); this._view?.webview.postMessage({ type: 'lmStudioError', value: '선택한 에이전트에 내용이 없습니다 — 에이전트 프롬프트를 작성한 뒤 다시 시도하세요. (이번 응답은 에이전트 없이 처리합니다)' }); } else { agentSkillContext = fileContent; // Merge in any external skill .md files the user has mapped to this agent. We concatenate // into the same agentSkillContext blob so the rest of the pipeline (agent.ts, agent-mode // override) treats them identically to the agent's own .md — no further changes needed. try { const entry = getOrCreateAgentEntry(agentFile); const bundle = loadExternalSkills(entry.skillFolders); const block = formatSkillsAsPromptBlock(bundle); if (block) { agentSkillContext = `${agentSkillContext.trim()}\n\n${block}`; } // Apply the per-agent model override, if any. const pinned = entry.model?.trim(); if (pinned && pinned !== effectiveModel) { logInfo('Per-agent model override applied.', { agent: entry.name, requested: effectiveModel, pinned, }); effectiveModel = pinned; // Inform the webview so its UI can reflect the model that's actually in use. this._view?.webview.postMessage({ type: 'agentModelOverride', value: { agent: entry.name, model: pinned }, }); } } catch (e: any) { logError('External skill load failed.', { error: e?.message || String(e) }); } } } const designerContext = designerGuard !== false ? this._buildDesignerGuardContext() : undefined; // Project Architecture activation (Feature 2): if the user just said // "나 X 프로젝트 진행할 거야" / mentioned an absolute path / etc., switch // to that project's mode before assembling the prompt. Best-effort: // failures here never block the actual answer. if (typeof value === 'string' && value.trim().length > 0) { try { await this._tryActivateArchitectureFromText(value); } catch (e: any) { logError('architecture: intent detection failed.', { error: e?.message ?? String(e) }); } } // Re-resolve the active subproject from the currently-focused editor. // Without this, switching between subprojects (e.g. ConnectAI → // Datacollector) inside one VS Code window keeps loading the previous // subproject's architecture into the prompt. try { await this._ensureActiveProjectForWorkspace(); } catch (e: any) { logError('architecture: workspace resync failed.', { error: e?.message ?? String(e) }); } const projectArchitectureContext = this._buildProjectArchitectureContext(); // [File Processing v2] 파일 타입별 분류 처리 let processedPrompt = value || ''; let imageFiles: any[] | undefined = undefined; if (files && Array.isArray(files) && files.length > 0) { const textContents: string[] = []; const images: any[] = []; for (const file of files) { const name = file.name?.toLowerCase() || ''; const type = file.type || ''; if (name.endsWith('.pdf') || type === 'application/pdf') { // PDF: 서버사이드 텍스트 추출 (pdf-parse v2 API) + Vision 폴백 let pdfTextOk = false; try { const { PDFParse } = require('pdf-parse'); const rawBuffer = Buffer.from(file.data, 'base64'); const uint8 = new Uint8Array(rawBuffer.buffer, rawBuffer.byteOffset, rawBuffer.byteLength); const parser = new PDFParse(uint8); await parser.load(); const textResult = await parser.getText(); const extracted = (typeof textResult === 'string' ? textResult : (textResult?.text || '')).trim(); const cleanText = extracted.replace(/\n*-- \d+ of \d+ --\n*/g, '\n').trim(); if (cleanText && cleanText.length > 30) { textContents.push(`\n[PDF: ${file.name}]\n${cleanText}`); logInfo(`PDF text extracted successfully.`, { fileName: file.name, chars: cleanText.length }); pdfTextOk = true; } // [Vision Fallback] 텍스트가 비어있으면 페이지 이미지 추출 -> Vision 모델에 전달 if (!pdfTextOk) { logInfo(`PDF has no text layer. Extracting page screenshots for vision analysis.`, { fileName: file.name }); const screenshots = await parser.getScreenshot({ page: 1 }); if (screenshots?.pages && screenshots.pages.length > 0) { const maxPages = Math.min(screenshots.pages.length, 8); // 메모리 보호: 최대 8페이지 for (let i = 0; i < maxPages; i++) { const page = screenshots.pages[i]; if (page?.data) { const pageBase64 = Buffer.from(page.data).toString('base64'); images.push({ name: `${file.name}_page${i + 1}.png`, type: 'image/png', data: pageBase64 }); } } textContents.push(`\n[PDF: ${file.name}]\n(이미지 기반 PDF ${screenshots.total}페이지 중 ${maxPages}페이지를 이미지로 추출하여 Vision 분석합니다. 각 페이지 이미지를 참조하여 문서의 내용을 상세히 분석하고 한국어로 정리하세요.)`); logInfo(`PDF vision fallback: extracted ${maxPages} page screenshots.`, { fileName: file.name, totalPages: screenshots.total }); pdfTextOk = true; // Vision 분석으로 처리 완료 } } } catch (pdfError: any) { logError(`PDF processing failed.`, { fileName: file.name, error: pdfError?.message || String(pdfError) }); } // 최종 폴백: 텍스트도 없고 이미지 추출도 실패한 경우 if (!pdfTextOk) { textContents.push(`\n[PDF: ${file.name}]\n(PDF 분석에 실패했습니다. 이 파일을 텍스트로 변환하여 다시 시도해주세요.)`); } } else if ( type.startsWith('text/') || type === 'application/json' || /\.(txt|md|csv|json|js|ts|jsx|tsx|html|css|py|java|rs|go|yaml|yml|xml|toml|sql|sh)$/i.test(name) ) { // 텍스트 파일: base64 디코딩 try { const decoded = Buffer.from(file.data, 'base64').toString('utf-8'); textContents.push(`\n[FILE: ${file.name}]\n\`\`\`\n${decoded}\n\`\`\``); } catch (decodeError: any) { logError(`Text file decode failed.`, { fileName: file.name, error: decodeError?.message }); textContents.push(`\n[FILE: ${file.name}]\n(디코딩 오류)`); } } else if (type.startsWith('image/')) { // 이미지: 기존 vision 방식 유지 images.push(file); } else { // 미지원 타입: 파일명만 기록 textContents.push(`\n[ATTACHMENT: ${file.name}]\n(지원하지 않는 파일 형식: ${type || 'unknown'})`); } } // 추출된 텍스트를 프롬프트에 주입 if (textContents.length > 0) { processedPrompt = `${processedPrompt}\n\n--- 첨부 파일 내용 ---${textContents.join('\n')}`; } imageFiles = images.length > 0 ? images : undefined; } try { await this._agent.handlePrompt(processedPrompt, effectiveModel || model, { internetEnabled: internet, visionContent: imageFiles, agentSkillContext, agentSkillFile: typeof agentFile === 'string' ? agentFile : undefined, negativePrompt, designerContext, projectArchitectureContext: projectArchitectureContext || undefined, secondBrainTraceEnabled: secondBrainTrace !== false, secondBrainTraceDebug: !!secondBrainTraceDebug, brainProfileId: selectedBrainId }); } 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 }); } finally { void this._sendReadyStatus(); } } _buildDesignerGuardContext(): string { return buildProjectChronicleGuardContext(this._getActiveChronicleProject()); } async _sendModels(force: boolean = false) { if (!this._view) return; if (this._modelDiscoveryInFlight) { logInfo('Model discovery already in progress, skipping.'); return; } this._modelDiscoveryInFlight = true; try { const config = getConfig(); const url = config.ollamaUrl; let defaultModel = config.defaultModel; let models: string[] = []; let online = false; const cache = this._modelsCache; const cacheFresh = !!cache && cache.url === url && (Date.now() - cache.fetchedAt) < SidebarChatProvider.MODELS_CACHE_TTL_MS; if (!force && cacheFresh && cache) { models = cache.models.slice(); online = cache.online; this._view.webview.postMessage({ type: 'engineStatus', value: { online, url } }); } else { const engine = resolveEngine(url); // 단일 엔진만 const modelsUrl = buildApiUrl(url, engine, 'models'); try { logInfo('Model discovery started.', { engine, modelsUrl, force }); 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) }); } else { 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) }); } } } catch (e: any) { logError('Model discovery failed.', { engine, modelsUrl, error: e?.message || String(e) }); } online = models.length > 0; this._modelsCache = { url, models: models.slice(), online, fetchedAt: Date.now() }; this._view.webview.postMessage({ type: 'engineStatus', value: { online, url } }); } if (models.length === 0) { models = defaultModel ? [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) { // [State Persistence Fix v2] defaultModel이 완전히 비어있을 때만 첫 번째 모델로 설정 defaultModel = models[0]; await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global); } else if (models.length > 0 && defaultModel && !models.includes(defaultModel)) { // [State Persistence Fix v2] 저장된 모델이 로컬 엔진 목록에 없는 경우: // 강제 리셋하지 않고, 저장된 모델을 목록 선두에 추가하여 사용자 선택을 보존 logInfo('Saved model not in local engine list. Preserving user selection.', { saved: defaultModel, localModels: models.slice(0, 3) }); models.unshift(defaultModel); } const defaultIdx = models.indexOf(defaultModel); if (defaultIdx > 0) { models.splice(defaultIdx, 1); models.unshift(defaultModel); } let loadedModels: string[] = []; if (resolveEngine(url) === 'lmstudio' && this._lmStudio) { try { loadedModels = await this._lmStudio.loadedModels(); } catch (e) { logInfo('LM Studio loadedModels probe failed (non-fatal).', { error: (e as any)?.message ?? String(e) }); } } this._view.webview.postMessage({ type: 'modelsList', value: { models, selected: defaultModel, loadedModels } }); } catch (err) { logError('Model list update failed.', err); } finally { this._modelDiscoveryInFlight = false; } void this._sendReadyStatus(); } static _htmlTemplateCache: string | undefined; _getHtml(webview: vscode.Webview): string { if (!SidebarChatProvider._htmlTemplateCache) { const tplPath = path.join(this._extensionUri.fsPath, 'media', 'sidebar.html'); SidebarChatProvider._htmlTemplateCache = fs.readFileSync(tplPath, 'utf8'); } const mediaRoot = vscode.Uri.joinPath(this._extensionUri, 'media'); const stylesUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'sidebar.css')).toString(); const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'sidebar.js')).toString(); return SidebarChatProvider._htmlTemplateCache .replace('__STYLES_URI__', stylesUri) .replace('__SCRIPT_URI__', scriptUri); } } /** * Adapter that makes a {@link vscode.WebviewPanel} quack like a * {@link vscode.WebviewView}, so providers written against the view API can * mount inside an editor column without their internals knowing the difference. * * `onDidChangeVisibility` is synthesized from `onDidChangeViewState` — panels * fire that event for both visibility *and* column moves, but the listener * here only re-fires when the visible flag actually toggles. */ export function wrapPanelAsView(panel: vscode.WebviewPanel): vscode.WebviewView { const visibilityEmitter = new vscode.EventEmitter(); let _lastVisible = panel.visible; panel.onDidChangeViewState(() => { if (panel.visible !== _lastVisible) { _lastVisible = panel.visible; visibilityEmitter.fire(); } }); panel.onDidDispose(() => visibilityEmitter.dispose()); const adapter: any = { viewType: panel.viewType, webview: panel.webview, get visible() { return panel.visible; }, get title() { return panel.title; }, set title(v: string | undefined) { panel.title = v ?? ''; }, description: undefined as string | undefined, badge: undefined as vscode.ViewBadge | undefined, onDidChangeVisibility: visibilityEmitter.event, onDidDispose: panel.onDidDispose, show(preserveFocus?: boolean) { panel.reveal(panel.viewColumn ?? vscode.ViewColumn.Three, preserveFocus); }, }; return adapter as vscode.WebviewView; }