Update Astra: v2.80.19 - Refactoring Sidebar, LM Studio integration, and new tests
This commit is contained in:
+21
-2
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+28
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user