398703466f
- Implemented subproject root resolution based on active editor hint - Added debounced event listener for active editor changes to trigger chip status updates - Updated sidebar provider to re-resolve active subproject root on every chip build - This ensures correct architecture context is injected when working in a monorepo or multi-root-style parent folder
2766 lines
126 KiB
TypeScript
2766 lines
126 KiB
TypeScript
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<string[]>;
|
||
}
|
||
|
||
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<boolean>(SidebarChatProvider.blankChatStateKey, false);
|
||
const currentHistory = this._agent.getHistory();
|
||
|
||
if (blankChatActive && currentHistory.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const activeSessionId = this._currentSessionId || this._context.globalState.get<string | null>(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<LastVisibleChatSnapshot | null>(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<boolean> {
|
||
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<any[]>('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<any[]>('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<string>(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<any[]>('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<ProjectProfile[]>(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<KnownProject>((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<string | null> {
|
||
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<string | null> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
// `_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<ProjectProfile | null> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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 `<create_file>` 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<void> {
|
||
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<string>(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<string>(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<string>();
|
||
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<string>('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: <workspace>/.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<string>(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<string>(`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<void>();
|
||
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;
|
||
}
|