Files
connectai/src/sidebarProvider.ts
T
2026-05-12 22:54:21 +09:00

2037 lines
92 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
} 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';
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;
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;
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._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('Sidebar 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);
}
_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;
}
const doc = await vscode.workspace.openTextDocument(target);
await vscode.window.showTextDocument(doc);
}
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');
}
const doc = await vscode.workspace.openTextDocument(filePath);
await vscode.window.showTextDocument(doc);
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;
if (agentFile && fs.existsSync(agentFile)) {
agentSkillContext = fs.readFileSync(agentFile, 'utf8');
// 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}`;
}
} catch (e: any) {
logError('External skill load failed.', { error: e?.message || String(e) });
}
}
const designerContext = designerGuard !== false ? this._buildDesignerGuardContext() : undefined;
// [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, model, {
internetEnabled: internet,
visionContent: imageFiles,
agentSkillContext,
agentSkillFile: typeof agentFile === 'string' ? agentFile : undefined,
negativePrompt,
designerContext,
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);
}
}