Update Astra: v2.80.19 - Refactoring Sidebar, LM Studio integration, and new tests

This commit is contained in:
g1nation
2026-05-08 23:14:47 +09:00
parent d083177d95
commit 5ffb472d22
28 changed files with 3125 additions and 1797 deletions
+21 -2
View File
@@ -53,6 +53,14 @@ export interface ChatMessage {
type HistoryChangeListener = (history: ChatMessage[]) => void | Promise<void>;
export interface AgentExecutorOptions {
/** Hooks fired around any LLM streaming run so external systems (LM Studio idle eject) can pause/resume. */
onStreamLifecycle?: {
start: () => void;
end: () => void;
};
}
// --- Agent Roles & Workflows ---
export type AgentRole = 'planner' | 'researcher' | 'writer';
type LocalProjectIntent = 'review-evaluation' | 'knowledge-creation' | 'implementation' | 'documentation' | 'thinking' | 'general';
@@ -86,9 +94,13 @@ export class AgentExecutor {
private retrievalOrchestrator: RetrievalOrchestrator;
private currentTaskId: string = 'default_session';
private readonly options: AgentExecutorOptions;
constructor(
private context: vscode.ExtensionContext
private context: vscode.ExtensionContext,
options: AgentExecutorOptions = {}
) {
this.options = options;
this.transactionManager = new TransactionManager();
this.sessionManager = new SessionManager(this.context);
this.statusBarManager = new StatusBarManager();
@@ -454,7 +466,10 @@ export class AgentExecutor {
const reader = response.body?.getReader();
if (!reader) throw new Error("Response body is not readable.");
if (loopDepth === 0) this.webview.postMessage({ type: 'streamStart' });
if (loopDepth === 0) {
this.webview.postMessage({ type: 'streamStart' });
this.options.onStreamLifecycle?.start();
}
let buffer = '';
const decoder = new TextDecoder();
@@ -618,6 +633,7 @@ export class AgentExecutor {
}
if (loopDepth === 0 && !this.isStaleRun(runId)) {
this.webview.postMessage({ type: 'streamEnd' });
this.options.onStreamLifecycle?.end();
}
}
}
@@ -634,6 +650,7 @@ export class AgentExecutor {
this.statusBarManager.updateStatus(AgentStatus.Thinking, 'Multi-Agent Workflow Running');
this.webview.postMessage({ type: 'streamStart' });
this.options.onStreamLifecycle?.start();
try {
let brainContext = 'No specific context available';
@@ -693,6 +710,8 @@ export class AgentExecutor {
value: `### ${friendly.title}\n\n**상태:** ${friendly.message}\n\n**해결 방법:** ${friendly.action}`
});
this.statusBarManager.updateStatus(AgentStatus.Idle, 'Error occurred');
} finally {
this.options.onStreamLifecycle?.end();
}
}
+63 -9
View File
@@ -2,14 +2,15 @@ import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
// axios removed in favor of native fetch
import {
_getBrainDir,
import {
_getBrainDir,
_isBrainDirExplicitlySet,
findBrainFiles,
SYSTEM_PROMPT,
buildApiUrl,
logError,
logInfo
logInfo,
resolveEngine
} from './utils';
import { getConfig, validateConfig } from './config';
import { AgentExecutor } from './agent';
@@ -17,6 +18,11 @@ import { BridgeServer } from './bridge';
import { SidebarChatProvider } from './sidebarProvider';
import { HealthCheckMonitor } from './core/health';
import { initAstraPathResolver } from './core/astraPath';
import { LMStudioClient } from './lmstudio/client';
import { ActivityTracker } from './lmstudio/activityTracker';
import { ModelLifecycleManager } from './lmstudio/lifecycleManager';
let _lifecycleManager: ModelLifecycleManager | undefined;
/**
* Astra Extension Entry Point
@@ -40,12 +46,52 @@ export async function activate(context: vscode.ExtensionContext) {
// 1. Ensure Brain Directory
await _ensureBrainDir(context);
// 2. Initialize Agent Executor
const agent = new AgentExecutor(context);
// 3. Initialize Sidebar Provider
const provider = new SidebarChatProvider(context.extensionUri, context, agent);
// 2. Initialize LM Studio Lifecycle Subsystem
let provider: SidebarChatProvider | undefined;
const initialUrl = getConfig().ollamaUrl;
const activityTracker = new ActivityTracker();
const lmStudioClient = new LMStudioClient(initialUrl);
const lifecycle = new ModelLifecycleManager({
client: lmStudioClient,
activity: activityTracker,
getConfig: () => {
const cfg = vscode.workspace.getConfiguration('g1nation');
return {
idleTimeoutMs: cfg.get<number>('lmStudio.idleTimeoutMs', 300000),
autoLoadOnSelect: cfg.get<boolean>('lmStudio.autoLoadOnSelect', true),
};
},
notifyError: (msg) => provider?.postLmStudioError(msg),
initialEngine: resolveEngine(initialUrl),
});
_lifecycleManager = lifecycle;
context.subscriptions.push({ dispose: () => activityTracker.dispose() });
context.subscriptions.push({ dispose: () => lifecycle.dispose() });
// React to engine URL changes — re-target the SDK and reset state.
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration((e) => {
if (!e.affectsConfiguration('g1nation.ollamaUrl')) return;
const newUrl = vscode.workspace.getConfiguration('g1nation').get<string>('ollamaUrl', '');
lmStudioClient.setBaseUrl(newUrl);
lifecycle.setEngine(resolveEngine(newUrl));
})
);
// 3. Initialize Agent Executor (with stream lifecycle hooks)
const agent = new AgentExecutor(context, {
onStreamLifecycle: {
start: () => lifecycle.onStreamStart(),
end: () => lifecycle.onStreamEnd(),
},
});
// 4. Initialize Sidebar Provider
provider = new SidebarChatProvider(context.extensionUri, context, agent, {
lifecycle,
activity: activityTracker,
});
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(SidebarChatProvider.viewType, provider)
);
@@ -85,8 +131,16 @@ export async function activate(context: vscode.ExtensionContext) {
}
}
export function deactivate() {
export async function deactivate() {
HealthCheckMonitor.dispose();
if (_lifecycleManager) {
try {
await _lifecycleManager.disposeAndUnload(2000);
} catch (e) {
logError('Lifecycle dispose during deactivate failed.', e);
}
_lifecycleManager = undefined;
}
}
async function runInitialSetup(context: vscode.ExtensionContext) {
+39 -24
View File
@@ -85,7 +85,12 @@ export function buildSecondBrainTrace(userQuery: string, brainRoot: string, opti
const terms = tokenize(retrievalQuery);
const knowledgeSlots = buildKnowledgeSlots(query, queryIntent);
const targetProject = inferTargetProject(query);
const scored = files.map((file) => scoreFile(file, brainRoot, terms, queryIntent, targetProject))
// Read each file from disk only once per request and reuse the parsed scan
// for every (query terms, slot terms…) re-scoring pass below.
const scans = files.map((file) => scanFile(file, brainRoot));
const scored = scans.map((scan) => scoreScan(scan, terms, queryIntent, targetProject))
.filter((doc) => doc.score >= 0.25)
.sort((a, b) => b.score - a.score)
.slice(0, options.limit || (knowledgeSlots.length > 0 ? 8 : 5));
@@ -94,9 +99,9 @@ export function buildSecondBrainTrace(userQuery: string, brainRoot: string, opti
const slotDocByPath = new Map<string, SecondBrainTraceDocument>();
const slotSelections = knowledgeSlots.map((slot) => {
const slotTerms = tokenize(slot.retrievalQuery);
const slotCandidates = files
.map((file) => {
const doc = scoreFile(file, brainRoot, slotTerms, queryIntent, targetProject);
const slotCandidates = scans
.map((scan) => {
const doc = scoreScan(scan, slotTerms, queryIntent, targetProject);
// 슬롯 ID와 문서 디렉토리명 매칭 보너스 (e.g. ontology 슬롯 → Ontology/ 디렉토리)
const dirName = path.dirname(doc.path).toLowerCase();
if (dirName.includes(slot.id.toLowerCase())) {
@@ -567,10 +572,21 @@ function inferTargetProject(query: string): string | undefined {
return namedProject?.[1]?.toLowerCase();
}
function scoreFile(file: string, brainRoot: string, terms: string[], intent: SecondBrainQueryIntent, targetProject?: string): SecondBrainTraceDocument {
interface FileScan {
file: string;
relative: string;
title: string;
titleWithPath: string;
content: string;
lower: string;
sourceType: SecondBrainSourceType;
knowledgeRole: SecondBrainKnowledgeRole;
documentProject: string | undefined;
}
function scanFile(file: string, brainRoot: string): FileScan {
const relative = path.relative(brainRoot, file);
const title = path.basename(file, path.extname(file));
const basename = relative.toLowerCase();
let content = '';
try {
content = fs.readFileSync(file, 'utf8');
@@ -579,37 +595,36 @@ function scoreFile(file: string, brainRoot: string, terms: string[], intent: Sec
}
const sourceType = classifySourceType(relative, content);
const knowledgeRole = classifyKnowledgeRole(relative, content, sourceType);
const lower = content.toLowerCase();
const documentProject = inferDocumentProject(relative, lower);
const projectMatchesTarget = !targetProject || !documentProject || documentProject === targetProject;
const canSupportProjectClaim = projectMatchesTarget && (sourceType === 'Project Evidence' || sourceType === 'User Decision');
let score = pathPriority(relative, intent);
const titleWithPath = `${relative.replace(/[\\/]/g, ' ')} ${title}`;
return { file, relative, title, titleWithPath, content, lower, sourceType, knowledgeRole, documentProject };
}
function scoreScan(scan: FileScan, terms: string[], intent: SecondBrainQueryIntent, targetProject?: string): SecondBrainTraceDocument {
const projectMatchesTarget = !targetProject || !scan.documentProject || scan.documentProject === targetProject;
const canSupportProjectClaim = projectMatchesTarget && (scan.sourceType === 'Project Evidence' || scan.sourceType === 'User Decision');
let score = pathPriority(scan.relative, intent);
if (targetProject) {
score += projectRelevanceScore(relative, lower, targetProject, documentProject);
score += projectRelevanceScore(scan.relative, scan.lower, targetProject, scan.documentProject);
}
const expandedTerms = expandQuery(terms);
// 디렉토리 경로를 title에 포함하여 카테고리 키워드 매칭 향상 (e.g. Ontology/ → 'ontology' 토큰)
const titleWithPath = `${relative.replace(/[\\/]/g, ' ')} ${title}`;
const scoredTfIdf = scoreTfIdf(expandedTerms, [{ title: titleWithPath, content, lastModified: Date.now() }])[0];
const scoredTfIdf = scoreTfIdf(expandedTerms, [{ title: scan.titleWithPath, content: scan.content, lastModified: Date.now() }])[0];
score += scoredTfIdf.score;
if (knowledgeRole === 'routing-hint') {
if (scan.knowledgeRole === 'routing-hint') {
score -= 8;
}
const finalExcerpt = extractBestExcerpt(content, expandedTerms, 420);
const finalExcerpt = extractBestExcerpt(scan.content, expandedTerms, 420);
return {
title,
path: relative,
absolutePath: file,
title: scan.title,
path: scan.relative,
absolutePath: scan.file,
// sqrt 정규화: 동의어 확장으로 분모가 과도하게 커지는 것을 방지
score: Number((Math.max(score, 0) / Math.max(Math.sqrt(expandedTerms.length), 1)).toFixed(2)),
excerpt: summarizeText(finalExcerpt, 420),
sourceType,
knowledgeRole,
sourceType: scan.sourceType,
knowledgeRole: scan.knowledgeRole,
canSupportProjectClaim,
warning: canSupportProjectClaim ? undefined : '이 문서는 현재 프로젝트의 실제 구현 근거가 아닙니다.',
usedInAnswer: false,
+19
View File
@@ -0,0 +1,19 @@
import * as vscode from 'vscode';
export interface IActivityTracker {
onActivity: vscode.Event<void>;
bump(): void;
}
export class ActivityTracker implements IActivityTracker {
private readonly _emitter = new vscode.EventEmitter<void>();
public readonly onActivity = this._emitter.event;
bump(): void {
this._emitter.fire();
}
dispose(): void {
this._emitter.dispose();
}
}
+100
View File
@@ -0,0 +1,100 @@
import { LMStudioClient as SDKClient } from '@lmstudio/sdk';
import { logError, logInfo } from '../utils';
export interface ILMStudioClient {
load(modelKey: string, signal?: AbortSignal): Promise<void>;
unload(modelKey: string): Promise<void>;
listLoaded(): Promise<string[]>;
isReachable(): Promise<boolean>;
setBaseUrl(httpBaseUrl: string): void;
}
export class LMStudioLifecycleError extends Error {
constructor(message: string, public readonly cause?: unknown) {
super(message);
this.name = 'LMStudioLifecycleError';
}
}
export function httpToWebSocketUrl(httpBaseUrl: string): string | undefined {
const trimmed = (httpBaseUrl || '').trim();
if (!trimmed) return undefined;
try {
const url = new URL(trimmed);
if (url.protocol === 'http:') url.protocol = 'ws:';
else if (url.protocol === 'https:') url.protocol = 'wss:';
else if (url.protocol !== 'ws:' && url.protocol !== 'wss:') return undefined;
if (url.pathname.endsWith('/v1')) url.pathname = url.pathname.slice(0, -3);
if (url.pathname.endsWith('/api')) url.pathname = url.pathname.slice(0, -4);
const out = url.toString().replace(/\/+$/, '');
return out;
} catch {
return undefined;
}
}
export class LMStudioClient implements ILMStudioClient {
private _sdk: SDKClient | undefined;
private _wsUrl: string | undefined;
constructor(httpBaseUrl: string) {
this.setBaseUrl(httpBaseUrl);
}
setBaseUrl(httpBaseUrl: string): void {
const ws = httpToWebSocketUrl(httpBaseUrl);
if (ws !== this._wsUrl) {
this._wsUrl = ws;
this._sdk = undefined;
}
}
private getSdk(): SDKClient {
if (!this._sdk) {
this._sdk = new SDKClient(this._wsUrl ? { baseUrl: this._wsUrl } : {});
}
return this._sdk;
}
async load(modelKey: string, signal?: AbortSignal): Promise<void> {
try {
await this.getSdk().llm.load(modelKey, signal ? { signal } : undefined);
logInfo('LM Studio model loaded.', { modelKey });
} catch (e: any) {
const msg = e?.message ?? String(e);
throw new LMStudioLifecycleError(`Failed to load LM Studio model "${modelKey}": ${msg}`, e);
}
}
async unload(modelKey: string): Promise<void> {
try {
await this.getSdk().llm.unload(modelKey);
logInfo('LM Studio model unloaded.', { modelKey });
} catch (e: any) {
const msg = e?.message ?? String(e);
throw new LMStudioLifecycleError(`Failed to unload LM Studio model "${modelKey}": ${msg}`, e);
}
}
async listLoaded(): Promise<string[]> {
try {
const items: any[] = await this.getSdk().llm.listLoaded();
return items
.map((m) => m?.identifier ?? m?.modelKey ?? m?.path ?? null)
.filter((id): id is string => typeof id === 'string' && id.length > 0);
} catch (e: any) {
const msg = e?.message ?? String(e);
throw new LMStudioLifecycleError(`Failed to list loaded LM Studio models: ${msg}`, e);
}
}
async isReachable(): Promise<boolean> {
try {
await this.getSdk().llm.listLoaded();
return true;
} catch (e: any) {
logError('LM Studio not reachable.', { error: e?.message ?? String(e) });
return false;
}
}
}
+247
View File
@@ -0,0 +1,247 @@
import type { ILMStudioClient } from './client';
import type { IActivityTracker } from './activityTracker';
import type { EngineKind } from '../utils';
import { logError, logInfo } from '../utils';
export type LifecycleState = 'idle' | 'loading' | 'loaded' | 'streaming' | 'unloading';
export interface LifecycleConfig {
idleTimeoutMs: number;
autoLoadOnSelect: boolean;
}
export interface LifecycleManagerDeps {
client: ILMStudioClient;
activity: IActivityTracker;
getConfig: () => LifecycleConfig;
notifyError?: (msg: string) => void;
/** Debounce window for rapid model switches. Default 300ms. Use 0 in tests for synchronous behavior. */
switchDebounceMs?: number;
/** Initial engine. Default 'lmstudio'. */
initialEngine?: EngineKind;
}
export class ModelLifecycleManager {
private state: LifecycleState = 'idle';
private currentModel: string | null = null;
private pendingModel: string | null = null;
private engine: EngineKind;
private idleTimer: ReturnType<typeof setTimeout> | undefined;
private switchDebounce: ReturnType<typeof setTimeout> | undefined;
private loadAbort: AbortController | undefined;
private readonly activitySub: { dispose(): void };
private disposed = false;
constructor(private readonly deps: LifecycleManagerDeps) {
this.engine = deps.initialEngine ?? 'lmstudio';
this.activitySub = deps.activity.onActivity(() => this.onActivity());
}
setEngine(engine: EngineKind): void {
if (engine === this.engine) return;
const wasLmStudio = this.engine === 'lmstudio';
this.engine = engine;
if (wasLmStudio && engine !== 'lmstudio') {
this.clearIdleTimer();
this.cancelPendingSwitch();
this.cancelLoad();
this.state = 'idle';
this.currentModel = null;
this.pendingModel = null;
}
}
onModelSelected(modelKey: string): void {
if (this.disposed) return;
if (this.engine !== 'lmstudio') return;
if (!this.deps.getConfig().autoLoadOnSelect) return;
const trimmed = (modelKey || '').trim();
if (!trimmed) return;
// Mid-stream: queue the latest selection, apply on streamEnd.
if (this.state === 'streaming') {
this.pendingModel = trimmed;
return;
}
// Same model already in flight or active — keep timer fresh, no reload.
if ((this.state === 'loaded' || this.state === 'loading') && this.currentModel === trimmed) {
if (this.state === 'loaded') this.resetIdleTimer();
return;
}
this.cancelPendingSwitch();
const delay = this.deps.switchDebounceMs ?? 300;
if (delay <= 0) {
void this.doSwitch(trimmed);
return;
}
this.switchDebounce = setTimeout(() => {
this.switchDebounce = undefined;
void this.doSwitch(trimmed);
}, delay);
}
onStreamStart(): void {
if (this.disposed) return;
if (this.engine !== 'lmstudio') return;
this.clearIdleTimer();
if (this.state === 'loaded') this.state = 'streaming';
}
onStreamEnd(): void {
if (this.disposed) return;
if (this.engine !== 'lmstudio') return;
if (this.state === 'streaming') {
this.state = 'loaded';
if (this.pendingModel && this.pendingModel !== this.currentModel) {
const next = this.pendingModel;
this.pendingModel = null;
void this.doSwitch(next);
} else {
this.pendingModel = null;
this.resetIdleTimer();
}
}
}
/** Best-effort eject before extension shutdown. Bounded by timeoutMs. */
async disposeAndUnload(timeoutMs: number = 2000): Promise<void> {
if (this.disposed) return;
this.disposed = true;
this.clearIdleTimer();
this.cancelPendingSwitch();
this.cancelLoad();
this.activitySub.dispose();
const shouldUnload =
this.engine === 'lmstudio' &&
(this.state === 'loaded' || this.state === 'streaming') &&
this.currentModel !== null;
if (!shouldUnload) {
this.state = 'idle';
this.currentModel = null;
return;
}
const target = this.currentModel as string;
this.state = 'unloading';
try {
await Promise.race([
this.deps.client.unload(target),
new Promise<void>((_, reject) =>
setTimeout(() => reject(new Error(`unload timed out after ${timeoutMs}ms`)), timeoutMs)
),
]);
} catch (e: any) {
logError('LM Studio unload during dispose failed.', { model: target, error: e?.message ?? String(e) });
}
this.state = 'idle';
this.currentModel = null;
}
/** vscode.Disposable shape — fire and forget. */
dispose(): void {
void this.disposeAndUnload();
}
// Test/inspection helpers
public _getState(): LifecycleState { return this.state; }
public _getCurrentModel(): string | null { return this.currentModel; }
public _hasIdleTimer(): boolean { return this.idleTimer !== undefined; }
// ---------- internals ----------
private onActivity(): void {
if (this.disposed) return;
if (this.engine !== 'lmstudio') return;
if (this.state !== 'loaded') return;
this.resetIdleTimer();
}
private clearIdleTimer(): void {
if (this.idleTimer) {
clearTimeout(this.idleTimer);
this.idleTimer = undefined;
}
}
private cancelPendingSwitch(): void {
if (this.switchDebounce) {
clearTimeout(this.switchDebounce);
this.switchDebounce = undefined;
}
}
private resetIdleTimer(): void {
this.clearIdleTimer();
const ms = this.deps.getConfig().idleTimeoutMs;
if (!Number.isFinite(ms) || ms <= 0) return;
this.idleTimer = setTimeout(() => {
this.idleTimer = undefined;
void this.doIdleEject();
}, ms);
}
private async doIdleEject(): Promise<void> {
if (this.state !== 'loaded' || !this.currentModel) return;
const target = this.currentModel;
this.state = 'unloading';
try {
await this.deps.client.unload(target);
logInfo('LM Studio model auto-ejected after idle.', { model: target });
} catch (e: any) {
logError('LM Studio auto-eject failed.', { model: target, error: e?.message ?? String(e) });
this.deps.notifyError?.(`LM Studio auto-eject failed: ${e?.message ?? e}`);
}
this.state = 'idle';
this.currentModel = null;
}
private cancelLoad(): void {
if (this.loadAbort) {
try { this.loadAbort.abort(); } catch { /* noop */ }
this.loadAbort = undefined;
}
}
private async doSwitch(modelKey: string): Promise<void> {
if (this.disposed) return;
if (this.engine !== 'lmstudio') return;
this.cancelLoad();
this.clearIdleTimer();
if (this.state === 'loaded' && this.currentModel && this.currentModel !== modelKey) {
const prev = this.currentModel;
this.state = 'unloading';
try {
await this.deps.client.unload(prev);
} catch (e: any) {
logError('LM Studio unload before switch failed.', { prev, error: e?.message ?? String(e) });
}
this.currentModel = null;
}
this.state = 'loading';
this.currentModel = modelKey;
const ac = new AbortController();
this.loadAbort = ac;
try {
await this.deps.client.load(modelKey, ac.signal);
if (this.loadAbort !== ac) return; // superseded by a newer switch
this.loadAbort = undefined;
this.state = 'loaded';
this.resetIdleTimer();
} catch (e: any) {
if (ac.signal.aborted) return; // superseded — newer switch owns state
logError('LM Studio model load failed.', { model: modelKey, error: e?.message ?? String(e) });
this.deps.notifyError?.(`LM Studio load failed: ${e?.message ?? e}`);
if (this.loadAbort === ac) this.loadAbort = undefined;
this.state = 'idle';
this.currentModel = null;
}
}
}
+32
View File
@@ -0,0 +1,32 @@
import { SidebarChatProvider } from '../sidebarProvider';
import { logInfo } from '../utils';
/**
* Handles agent-skill messages: the per-conversation agent picker, agent CRUD,
* and persisting the user's last selected agent.
*/
export async function handleAgentMessage(provider: SidebarChatProvider, data: any): Promise<boolean> {
switch (data.type) {
case 'getAgents':
await provider._sendAgentsList();
return true;
case 'createAgent':
await provider._createAgent();
return true;
case 'getAgentContent':
await provider._sendAgentContent(data.path);
return true;
case 'updateAgent':
await provider._updateAgent(data.path, data.content, data.negativePrompt);
return true;
case 'deleteAgent':
await provider._deleteAgent(data.path);
return true;
case 'saveAgentSelection':
await provider._context.globalState.update(SidebarChatProvider.lastAgentStateKey, data.path || 'none');
logInfo(`Agent selection saved: ${data.path}`);
return true;
default:
return false;
}
}
+33
View File
@@ -0,0 +1,33 @@
import { SidebarChatProvider } from '../sidebarProvider';
/**
* Handles brain-profile / wiki sync messages from the sidebar webview.
*/
export async function handleBrainMessage(provider: SidebarChatProvider, data: any): Promise<boolean> {
switch (data.type) {
case 'manageBrains':
await provider._manageBrains();
return true;
case 'syncBrain':
await provider.syncBrain();
await provider._sendBrainStatus();
return true;
case 'addBrain':
await provider._addBrainProfile();
return true;
case 'editBrain':
await provider._editBrainProfile(data.id);
return true;
case 'deleteBrain':
await provider._deleteBrainProfile(data.id);
return true;
case 'saveWikiRaw':
await provider._saveWikiRaw();
return true;
case 'setBrainProfile':
await provider._setActiveBrainProfile(data.id);
return true;
default:
return false;
}
}
+99
View File
@@ -0,0 +1,99 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { SidebarChatProvider } from '../sidebarProvider';
import { getActiveBrainProfile, logInfo } from '../utils';
/**
* Handles chat-domain messages: prompts, model selection, sessions, streaming control,
* generic webview transport (export, settings, addMessage), action approvals, and the
* cross-cutting `ready` bootstrap.
*
* Returns true when the message was handled by this domain, false otherwise — the
* caller chains domain handlers until one accepts the message.
*/
export async function handleChatMessage(provider: SidebarChatProvider, data: any): Promise<boolean> {
switch (data.type) {
case 'prompt':
case 'promptWithFile':
provider._lmStudio?.activity.bump();
await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, false);
await provider._handlePrompt(data);
await provider._autoWriteChronicleAfterPrompt();
await provider._saveCurrentSession();
return true;
case 'activity':
provider._lmStudio?.activity.bump();
return true;
case 'ready':
await provider._sendBrainStatus();
await provider._sendBrainProfiles();
await provider._sendSessionList();
await provider._sendModels();
await provider._sendChronicleProjects();
await provider._restoreActiveSessionIntoView();
return true;
case 'getModels':
await provider._sendModels();
return true;
case 'getSessions':
await provider._sendSessionList();
return true;
case 'newChat':
provider._currentSessionId = null;
provider._currentSessionBrainId = getActiveBrainProfile().id;
provider._agent.resetConversation();
await provider._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null);
await provider._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null);
await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, true);
provider.clearChat();
await provider._sendBrainStatus();
return true;
case 'stopGeneration':
provider._agent.stop();
return true;
case 'loadSession':
await provider._loadSession(data.id);
return true;
case 'deleteSession':
await provider._deleteSession(data.id);
return true;
case 'openSettings':
vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation');
return true;
case 'addMessage':
provider._view?.webview.postMessage({ type: 'addMessage', role: data.role, value: data.value, rationale: data.rationale });
return true;
case 'refreshModels':
await provider._sendModels(true);
return true;
case 'model':
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', data.value, vscode.ConfigurationTarget.Global);
logInfo(`Default model updated to: ${data.value}`);
provider._lmStudio?.lifecycle.onModelSelected(data.value);
return true;
case 'proactiveTrigger':
await provider._handleProactiveSuggestion(data.context);
return true;
case 'exportResponse': {
const workspacePath = vscode.workspace.workspaceFolders?.[0].uri.fsPath || '';
const defaultPath = path.join(workspacePath, 'g1_response.md');
const uri = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file(defaultPath),
filters: { 'Markdown': ['md'] }
});
if (uri) {
await vscode.workspace.fs.writeFile(uri, Buffer.from(data.text, 'utf8'));
vscode.window.showInformationMessage(`✅ Exported to ${path.basename(uri.fsPath)}`);
}
return true;
}
case 'approveAction':
await provider._agent.approveTransaction();
return true;
case 'rejectAction':
await provider._agent.rejectTransaction();
return true;
default:
return false;
}
}
+52
View File
@@ -0,0 +1,52 @@
import { SidebarChatProvider } from '../sidebarProvider';
/**
* Handles Project Chronicle messages: project CRUD, record listing/opening,
* and the various chronicle-write entry points (planning, discussion, decision,
* development, bug, retrospective).
*/
export async function handleChronicleMessage(provider: SidebarChatProvider, data: any): Promise<boolean> {
switch (data.type) {
case 'getChronicleProjects':
await provider._sendChronicleProjects();
return true;
case 'createChronicleProject':
await provider._createChronicleProject();
return true;
case 'setChronicleProject':
await provider._setActiveChronicleProject(data.id);
return true;
case 'openChronicleFolder':
await provider._openChronicleFolder();
return true;
case 'getChronicleRecords':
await provider._sendChronicleRecords();
return true;
case 'openChronicleRecord':
await provider._openChronicleRecord(data.path);
return true;
case 'writeChroniclePlanning':
await provider._writeChroniclePlanningFromCurrentChat();
return true;
case 'writeChronicleDiscussion':
await provider._writeChronicleDiscussionFromCurrentChat();
return true;
case 'writeChronicleDecision':
await provider._writeChronicleDecisionFromInput();
return true;
case 'writeChronicleDevelopment':
await provider._writeChronicleDevelopmentFromCurrentChat();
return true;
case 'writeChronicleBug':
await provider._writeChronicleBugFromInput();
return true;
case 'writeChronicleRetrospective':
await provider._writeChronicleRetrospectiveFromInput();
return true;
case 'writeChronicleRecord':
await provider._writeChronicleRecord(data.recordType);
return true;
default:
return false;
}
}
+141 -1707
View File
File diff suppressed because it is too large Load Diff
+28 -1
View File
@@ -102,7 +102,34 @@ export function _isBrainDirExplicitlySet(): boolean {
return getBrainProfiles().length > 0;
}
interface BrainFilesCacheEntry {
files: string[];
expiresAt: number;
}
const _brainFilesCache = new Map<string, BrainFilesCacheEntry>();
const BRAIN_FILES_CACHE_TTL_MS = 5000;
export function findBrainFiles(dir: string): string[] {
const now = Date.now();
const cached = _brainFilesCache.get(dir);
if (cached && cached.expiresAt > now) {
return cached.files.slice();
}
const files = _walkBrainFiles(dir);
_brainFilesCache.set(dir, { files, expiresAt: now + BRAIN_FILES_CACHE_TTL_MS });
return files.slice();
}
/** Force-invalidate the brain files cache (e.g. after sync or new file write). */
export function invalidateBrainFilesCache(dir?: string): void {
if (dir === undefined) {
_brainFilesCache.clear();
return;
}
_brainFilesCache.delete(dir);
}
function _walkBrainFiles(dir: string): string[] {
let results: string[] = [];
if (!fs.existsSync(dir)) return results;
const list = fs.readdirSync(dir);
@@ -111,7 +138,7 @@ export function findBrainFiles(dir: string): string[] {
const stat = fs.statSync(filePath);
if (stat && stat.isDirectory()) {
if (!EXCLUDED_DIRS.has(file)) {
results = results.concat(findBrainFiles(filePath));
results = results.concat(_walkBrainFiles(filePath));
}
} else if (file.endsWith('.md')) {
results.push(filePath);