chore: bump version to 2.80.27 and update core features

This commit is contained in:
g1nation
2026-05-09 01:16:12 +09:00
parent 5ffb472d22
commit 3220a126fd
41 changed files with 4457 additions and 72 deletions
+136 -44
View File
@@ -59,6 +59,19 @@ export interface AgentExecutorOptions {
start: () => void;
end: () => void;
};
/**
* Optional native LM Studio chat streamer. When provided AND the active engine is LM Studio,
* chat completions are streamed via @lmstudio/sdk's WebSocket transport instead of the
* OpenAI-compatible REST endpoint. Falls back to REST when omitted or when the streamer
* itself fails (e.g. SDK reachability error).
*/
lmStudioStreamer?: import('./lmstudio/streamer').IChatStreamer;
/**
* Optional pending-approval queue. When provided, dry-run transactions are also published
* into a queue that drives the Approval Panel webview + status bar badge. The existing
* inline `requiresApproval` chat message is preserved for backwards compatibility.
*/
approvalQueue?: import('./features/approval/approvalQueue').ApprovalQueue;
}
// --- Agent Roles & Workflows ---
@@ -135,6 +148,15 @@ export class AgentExecutor {
.replace(/<rationale>[\s\S]*?<\/rationale>/gi, '')
.replace(/^\s*\[PROBLEM\][\s\S]*?\[GOAL\][\s\S]*?\[REASONING\][^\n]*(?:\n+|$)/i, '')
.replace(/^\s*\[PROBLEM\][\s\S]*?(?:\n\s*\n|$)/i, '')
.replace(/(?:<think(?:ing)?>|<analysis>)[\s\S]*?(?:<\/think(?:ing)?>|<\/analysis>)/gi, '')
// Harmony / GPT-OSS-style channel markers: keep only the `final`
// channel; drop everything else (thought, analysis, commentary).
// The closing form varies by model: `<channel|>`, `<|channel|>`,
// `<|end|>`, `<|return|>`. Match conservatively.
.replace(/<\|?channel\|?>\s*(?:thought|analysis|commentary|reasoning)\b[\s\S]*?<\|?channel\|?>/gi, '')
.replace(/<\|?channel\|?>\s*(?:thought|analysis|commentary|reasoning)\b[\s\S]*?(?=<\|?channel\|?>\s*final\b)/gi, '')
.replace(/<\|?channel\|?>\s*final\b\s*(?:<\|?message\|?>)?/gi, '')
.replace(/<\|?(?:end|return|start|message)\|?>/gi, '')
.trim();
}
@@ -453,61 +475,91 @@ export class AgentExecutor {
logError('AI request timed out.', { timeoutMs: timeout, model: actualModel, loopDepth });
this.abortController?.abort();
}, timeout);
const request = await this.createStreamingRequest({
baseUrl: ollamaUrl,
modelName: actualModel,
reqMessages: messagesForRequest,
temperature
});
const { response, engine, apiUrl } = request;
if (this.isStaleRun(runId)) return;
const engine = resolveEngine(ollamaUrl);
const useLmStudioSdk = engine === 'lmstudio' && !!this.options.lmStudioStreamer;
let apiUrl = '';
let aiResponseText = '';
const reader = response.body?.getReader();
if (!reader) throw new Error("Response body is not readable.");
let buffer = '';
if (loopDepth === 0) {
this.webview.postMessage({ type: 'streamStart' });
this.options.onStreamLifecycle?.start();
}
let buffer = '';
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (this.isStaleRun(runId)) return;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed === 'data: [DONE]') continue;
try {
const raw = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;
const json = JSON.parse(raw);
const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || '';
if (token) {
aiResponseText += token;
}
} catch (e: any) {
logError('Failed to parse streaming chunk.', { engine, apiUrl, chunk: summarizeText(trimmed, 300), error: e?.message || String(e) });
}
if (useLmStudioSdk) {
apiUrl = `${ollamaUrl} (sdk)`;
logInfo('Streaming chat via LM Studio SDK.', { model: actualModel });
try {
const stream = this.options.lmStudioStreamer!.stream({
modelName: actualModel,
messages: messagesForRequest.map((m) => ({ role: m.role, content: m.content })),
temperature,
signal: this.abortController.signal,
});
for await (const { token } of stream) {
if (this.isStaleRun(runId)) return;
if (token) aiResponseText += token;
}
} catch (err: any) {
if (err?.name === 'AbortError' || this.abortController.signal.aborted) {
logInfo('Generation aborted by user.');
} else {
logError('LM Studio SDK chat failed.', { engine, error: err?.message ?? String(err) });
this.webview?.postMessage({ type: 'error', value: `LM Studio: ${err?.message ?? err}` });
}
}
} catch (err: any) {
if (err.name === 'AbortError') {
logInfo('Generation aborted by user.');
} else {
logError('Stream reading error.', { engine, apiUrl, error: err?.message || String(err) });
this.webview?.postMessage({ type: 'error', value: `Connection lost: ${err.message}` });
} else {
const request = await this.createStreamingRequest({
baseUrl: ollamaUrl,
modelName: actualModel,
reqMessages: messagesForRequest,
temperature
});
const { response, apiUrl: restApiUrl } = request;
apiUrl = restApiUrl;
if (this.isStaleRun(runId)) return;
const reader = response.body?.getReader();
if (!reader) throw new Error("Response body is not readable.");
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (this.isStaleRun(runId)) return;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed === 'data: [DONE]') continue;
try {
const raw = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;
const json = JSON.parse(raw);
const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || '';
if (token) {
aiResponseText += token;
}
} catch (e: any) {
logError('Failed to parse streaming chunk.', { engine, apiUrl, chunk: summarizeText(trimmed, 300), error: e?.message || String(e) });
}
}
}
} catch (err: any) {
if (err.name === 'AbortError') {
logInfo('Generation aborted by user.');
} else {
logError('Stream reading error.', { engine, apiUrl, error: err?.message || String(err) });
this.webview?.postMessage({ type: 'error', value: `Connection lost: ${err.message}` });
}
}
}
// Final buffer processing
if (buffer.trim() && buffer.trim() !== 'data: [DONE]') {
// Final buffer processing (REST SSE only — SDK has no trailing buffer)
if (!useLmStudioSdk && buffer.trim() && buffer.trim() !== 'data: [DONE]') {
try {
const trimmed = buffer.trim();
const raw = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;
@@ -717,13 +769,35 @@ export class AgentExecutor {
private async callAgent(role: AgentRole, prompt: string, modelName: string, options: any): Promise<string> {
const persona = AGENT_PROMPTS[role];
const { ollamaUrl, timeout } = getConfig();
const { ollamaUrl } = getConfig();
const messages: ChatMessage[] = [
{ role: 'system', content: persona },
{ role: 'user', content: prompt }
];
const engine = resolveEngine(ollamaUrl);
let responseText = '';
if (engine === 'lmstudio' && this.options.lmStudioStreamer) {
try {
const stream = this.options.lmStudioStreamer.stream({
modelName,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
temperature: 0.3,
signal: this.abortController?.signal,
});
for await (const { token } of stream) {
if (token) responseText += token;
}
return responseText;
} catch (err: any) {
if (err?.name === 'AbortError' || this.abortController?.signal.aborted) return responseText;
logError('LM Studio SDK callAgent stream failed.', { role, error: err?.message ?? String(err) });
throw err;
}
}
const request = await this.createStreamingRequest({
baseUrl: ollamaUrl,
modelName: modelName,
@@ -731,7 +805,6 @@ export class AgentExecutor {
temperature: 0.3 // Use lower temperature for planning and research
});
let responseText = '';
const reader = request.response.body?.getReader();
if (!reader) throw new Error("Agent response body is not readable.");
@@ -2304,6 +2377,25 @@ export class AgentExecutor {
if (config.dryRun) {
report.push(`\n⚠️ **Dry Run Mode Active**: 위 변경 사항을 확인하고 [승인] 또는 [롤백]을 선택해주세요.`);
this.webview?.postMessage({ type: 'requiresApproval' });
// Mirror the inline-chat approval into the queue feeding the dedicated panel + status bar.
const queue = this.options.approvalQueue;
if (queue) {
const recorded = this.transactionManager.getRecordedFiles();
queue.enqueue(
{
id: `txn-${Date.now()}`,
kind: 'transaction',
title: 'Pending file changes',
summary: `${recorded.length}개 파일 변경 대기 중`,
files: recorded.map(r => r.path),
createdAt: Date.now(),
},
{
approve: () => this.approveTransaction(),
reject: () => this.rejectTransaction(),
}
);
}
// Do NOT commit yet
} else {
this.transactionManager.commit();
+50 -1
View File
@@ -9,6 +9,12 @@ import {
} from './utils';
import { getConfig } from './config';
import { IAIService, IBrainService, AIService, BrainService } from './core/services';
import {
ISkillInjectionService,
FileSystemSkillInjectionService,
SkillInjectionError,
} from './skills/skillInjectionService';
import { resolveAgentSkillsDir } from './lib/paths';
export interface BridgeInterface {
injectSystemMessage(msg: string): void;
@@ -27,15 +33,25 @@ export class BridgeServer {
private server: http.Server | null = null;
private aiService: IAIService;
private brainService: IBrainService;
private skillService: ISkillInjectionService;
constructor(
private provider: BridgeInterface,
aiService?: IAIService,
brainService?: IBrainService
brainService?: IBrainService,
skillService?: ISkillInjectionService
) {
// 의존성 주입 (DIP): 기본값 제공 및 외부 주입 허용
this.aiService = aiService || new AIService();
this.brainService = brainService || new BrainService();
this.skillService = skillService || new FileSystemSkillInjectionService({
resolveSkillsDir: resolveAgentSkillsDir,
onInjected: (result, req) => {
this.provider.injectSystemMessage(
`**[Skill]** Injected agent skill: ${req.displayName || result.safeName}`
);
},
});
}
public start(port: number = 4825) {
@@ -64,6 +80,8 @@ export class BridgeServer {
this.processEvaluateHistory(res);
} else if (method === 'POST' && url === '/api/brain-inject') {
this.handlePost(req, res, this.processBrainInject.bind(this));
} else if (method === 'POST' && url === '/api/skill-inject') {
this.handlePost(req, res, this.processSkillInject.bind(this));
} else {
res.writeHead(404);
res.end();
@@ -175,4 +193,35 @@ export class BridgeServer {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, rawOutput: result }));
}
/**
* Inject an agent skill (markdown-only — see SkillInjectionService doc) from
* an external tool. Routing-only: validation, file IO, and telemetry live in
* the service.
*/
private async processSkillInject(data: any, res: http.ServerResponse) {
try {
const result = await this.skillService.inject({
name: data?.name,
content: data?.content ?? data?.markdown,
displayName: data?.displayName,
description: data?.description,
source: data?.source,
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
safeName: result.safeName,
filePath: result.filePath,
}));
} catch (e: any) {
const status = e instanceof SkillInjectionError && (e.code === 'INVALID_NAME' || e.code === 'EMPTY_CONTENT')
? 400
: 500;
const code = e instanceof SkillInjectionError ? e.code : 'UNKNOWN';
logError('Skill inject endpoint failed.', { code, error: e?.message ?? String(e) });
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: e?.message ?? String(e), code }));
}
}
}
+5
View File
@@ -122,6 +122,11 @@ export class TransactionManager {
public isActive(): boolean {
return this.isTransactionActive;
}
/** Snapshot of file paths currently recorded in the active transaction. */
public getRecordedFiles(): { path: string; type: 'modified' | 'created' | 'deleted' }[] {
return Array.from(this.backups.values()).map(b => ({ path: b.path, type: b.type }));
}
}
// Export a singleton instance if needed, or instantiate per AgentExecutor
+212 -2
View File
@@ -21,8 +21,22 @@ import { initAstraPathResolver } from './core/astraPath';
import { LMStudioClient } from './lmstudio/client';
import { ActivityTracker } from './lmstudio/activityTracker';
import { ModelLifecycleManager } from './lmstudio/lifecycleManager';
import { LMStudioStreamer } from './lmstudio/streamer';
import { NodeSystemSpecsProvider, HeuristicModelMemoryEstimator } from './system/specs';
import { ApprovalQueue } from './features/approval/approvalQueue';
import { ApprovalPanelProvider } from './features/approval/approvalPanelProvider';
import { ApprovalStatusBar } from './features/approval/approvalStatusBar';
import { FileSystemProjectScaffolder } from './scaffolder/projectScaffolder';
import type { ProjectTemplateId } from './scaffolder/templates';
import { TelegramHttpClient } from './integrations/telegram/telegramClient';
import { TelegramBot } from './integrations/telegram/telegramBot';
import { AIService } from './core/services';
import { SettingsPanelProvider } from './features/settings/settingsPanelProvider';
let _lifecycleManager: ModelLifecycleManager | undefined;
let _telegramBot: TelegramBot | undefined;
const TELEGRAM_TOKEN_SECRET_KEY = 'g1nation.telegram.botToken';
/**
* Astra Extension Entry Point
@@ -52,6 +66,9 @@ export async function activate(context: vscode.ExtensionContext) {
const initialUrl = getConfig().ollamaUrl;
const activityTracker = new ActivityTracker();
const lmStudioClient = new LMStudioClient(initialUrl);
const systemSpecs = new NodeSystemSpecsProvider();
const memoryEstimator = new HeuristicModelMemoryEstimator();
logInfo('System specs detected.', { summary: systemSpecs.get().summary });
const lifecycle = new ModelLifecycleManager({
client: lmStudioClient,
activity: activityTracker,
@@ -64,6 +81,8 @@ export async function activate(context: vscode.ExtensionContext) {
},
notifyError: (msg) => provider?.postLmStudioError(msg),
initialEngine: resolveEngine(initialUrl),
systemSpecs,
memoryEstimator,
});
_lifecycleManager = lifecycle;
context.subscriptions.push({ dispose: () => activityTracker.dispose() });
@@ -79,18 +98,48 @@ export async function activate(context: vscode.ExtensionContext) {
})
);
// 3. Initialize Agent Executor (with stream lifecycle hooks)
// Keep the sidebar's model dropdown in sync when defaultModel / ollamaUrl is
// changed from elsewhere (Settings panel, raw settings.json, …). Without
// this the user sees a desync: Settings shows the new model, sidebar still
// shows the old one until a manual refresh.
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration((e) => {
const touchedModel = e.affectsConfiguration('g1nation.defaultModel');
const touchedUrl = e.affectsConfiguration('g1nation.ollamaUrl');
if (!touchedModel && !touchedUrl) return;
// _sendModels is best-effort; the provider may not have a webview
// attached yet during very early activation.
void provider?._sendModels(touchedUrl);
})
);
// 3. Initialize Approval subsystem (queue + panel webview + status bar badge)
const approvalQueue = new ApprovalQueue();
const approvalPanel = new ApprovalPanelProvider(context.extensionUri, approvalQueue);
const approvalStatusBar = new ApprovalStatusBar(approvalQueue);
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(ApprovalPanelProvider.viewType, approvalPanel),
approvalStatusBar,
{ dispose: () => approvalQueue.dispose() },
vscode.commands.registerCommand(ApprovalStatusBar.focusCommand, () => approvalPanel.focus()),
);
// 4. Initialize Agent Executor (with stream lifecycle hooks + LM Studio SDK streamer + approval queue)
const lmStudioStreamer = new LMStudioStreamer(lmStudioClient);
const agent = new AgentExecutor(context, {
onStreamLifecycle: {
start: () => lifecycle.onStreamStart(),
end: () => lifecycle.onStreamEnd(),
},
lmStudioStreamer,
approvalQueue,
});
// 4. Initialize Sidebar Provider
provider = new SidebarChatProvider(context.extensionUri, context, agent, {
lifecycle,
activity: activityTracker,
loadedModels: () => lmStudioClient.listLoadedCached(),
});
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(SidebarChatProvider.viewType, provider)
@@ -120,7 +169,164 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand('g1nation.syncBrain', async () => {
await provider.syncBrain();
await provider!.syncBrain();
})
);
// Telegram Bot integration — opt-in (g1nation.telegram.enabled), token in SecretStorage.
let _cachedTelegramToken = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
const telegramClient = new TelegramHttpClient({
getToken: () => _cachedTelegramToken,
});
const telegramAi = new AIService();
const telegramBot = new TelegramBot({
client: telegramClient,
handle: async (text, chatId) => {
const cfg = vscode.workspace.getConfiguration('g1nation');
const allowed = cfg.get<number[]>('telegram.allowedChatIds', []) || [];
if (allowed.length > 0 && !allowed.includes(chatId)) {
logInfo('Telegram message from unallowed chat ignored.', { chatId });
return null;
}
try {
const reply = await telegramAi.call(text);
return (reply && reply.trim()) ? reply : '(빈 응답)';
} catch (e: any) {
return `⚠️ Astra error: ${e?.message ?? e}`;
}
},
});
_telegramBot = telegramBot;
const refreshTelegramBot = async () => {
const enabled = vscode.workspace.getConfiguration('g1nation').get<boolean>('telegram.enabled', false);
const tokenPresent = !!_cachedTelegramToken.trim();
if (enabled && tokenPresent) {
telegramBot.start();
} else if (telegramBot.isRunning()) {
await telegramBot.stop();
}
};
void refreshTelegramBot();
context.subscriptions.push(
{ dispose: () => { void telegramBot.stop(); } },
vscode.workspace.onDidChangeConfiguration(async (e) => {
if (e.affectsConfiguration('g1nation.telegram.enabled')) {
await refreshTelegramBot();
}
}),
context.secrets.onDidChange(async (e) => {
if (e.key !== TELEGRAM_TOKEN_SECRET_KEY) return;
_cachedTelegramToken = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
await refreshTelegramBot();
}),
vscode.commands.registerCommand('g1nation.telegram.setBotToken', async () => {
const token = await vscode.window.showInputBox({
prompt: 'Telegram bot token (BotFather에서 발급, 형식: 123456:ABC...)',
placeHolder: '123456789:AA...',
password: true,
ignoreFocusOut: true,
validateInput: (v) => /^\d+:[A-Za-z0-9_-]{20,}$/.test((v || '').trim())
? null
: '형식이 올바르지 않습니다 (숫자ID:문자열).',
});
if (!token) return;
await context.secrets.store(TELEGRAM_TOKEN_SECRET_KEY, token.trim());
vscode.window.showInformationMessage(
'Telegram bot token이 저장되었습니다. settings에서 g1nation.telegram.enabled = true 로 켜세요.'
);
}),
vscode.commands.registerCommand('g1nation.telegram.clearBotToken', async () => {
await context.secrets.delete(TELEGRAM_TOKEN_SECRET_KEY);
vscode.window.showInformationMessage('Telegram bot token이 삭제되었습니다.');
}),
vscode.commands.registerCommand('g1nation.telegram.testConnection', async () => {
const token = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
if (!token) {
vscode.window.showErrorMessage('먼저 "Astra: Set Telegram Bot Token" 명령으로 토큰을 등록하세요.');
return;
}
try {
const me = await telegramClient.getMe();
vscode.window.showInformationMessage(
`Telegram 연결 성공: @${me.username || me.first_name} (id ${me.id})`
);
} catch (e: any) {
vscode.window.showErrorMessage(`Telegram 연결 실패: ${e?.message ?? e}`);
}
}),
);
// Astra Settings webview — single entry point for user-facing config (Phase 5-A: Telegram only).
const settingsPanel = new SettingsPanelProvider({
extensionUri: context.extensionUri,
secrets: context.secrets,
telegramClient,
telegramBot,
});
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(SettingsPanelProvider.viewType, settingsPanel),
// Refresh the settings UI whenever any g1nation.* config changes (toggle, allowedChatIds, …).
vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration('g1nation')) void settingsPanel.refresh();
}),
// Same for SecretStorage updates (token saved/cleared from elsewhere).
context.secrets.onDidChange((e) => {
if (e.key === TELEGRAM_TOKEN_SECRET_KEY) void settingsPanel.refresh();
}),
vscode.commands.registerCommand('g1nation.settings.focus', () => settingsPanel.focus()),
vscode.commands.registerCommand('g1nation.settings.diagnose', async () => {
// Diagnostic helper: shows whether the view is registered + opens it if so.
// Useful when the user reports "Set 버튼이 안 먹는다" and we want to confirm
// the new build is actually loaded.
try {
await settingsPanel.focus();
vscode.window.showInformationMessage('Astra Settings 패널이 열렸습니다. 사이드바 Settings 항목을 확인하세요.');
} catch (e: any) {
vscode.window.showErrorMessage(`Settings 패널 열기 실패 (확장 reload가 필요할 수 있음): ${e?.message ?? e}`);
}
}),
);
// Project Scaffolder — Astra의 Developer 빠른 시작 명령
const scaffolder = new FileSystemProjectScaffolder();
context.subscriptions.push(
vscode.commands.registerCommand('g1nation.scaffoldProject', async () => {
const folders = vscode.workspace.workspaceFolders;
if (!folders || folders.length === 0) {
vscode.window.showErrorMessage('워크스페이스 폴더를 먼저 여세요.');
return;
}
const name = await vscode.window.showInputBox({
placeHolder: '프로젝트 이름 (영문/숫자/_/-, 2~40자)',
prompt: 'Astra가 워크스페이스 안에 만들 프로젝트 폴더 이름',
validateInput: (v) => /^[a-zA-Z0-9_-]{2,40}$/.test(v.trim()) ? null : '영문/숫자/_/- 만, 2~40자',
});
if (!name) return;
const picked = await vscode.window.showQuickPick(
scaffolder.listTemplates().map(t => ({ label: t.label, detail: t.detail, id: t.id })),
{ placeHolder: '템플릿 선택' }
);
if (!picked) return;
const result = await scaffolder.scaffold({
name: name.trim(),
template: picked.id as ProjectTemplateId,
rootDir: folders[0].uri.fsPath,
});
if (!result.ok) {
vscode.window.showErrorMessage(`프로젝트 생성 실패: ${result.error}`);
return;
}
const action = await vscode.window.showInformationMessage(
`${name} 생성 완료 — ${result.projectPath}`,
'폴더 열기',
'닫기'
);
if (action === '폴더 열기') {
await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(result.projectPath));
}
})
);
@@ -133,6 +339,10 @@ export async function activate(context: vscode.ExtensionContext) {
export async function deactivate() {
HealthCheckMonitor.dispose();
if (_telegramBot) {
try { await _telegramBot.stop(); } catch (e) { logError('Telegram bot stop during deactivate failed.', e); }
_telegramBot = undefined;
}
if (_lifecycleManager) {
try {
await _lifecycleManager.disposeAndUnload(2000);
@@ -0,0 +1,121 @@
import * as vscode from 'vscode';
import { ApprovalQueue, Approval } from './approvalQueue';
/**
* A small webview view that surfaces the currently pending approval, separate
* from the chat. The provider is intentionally thin: state lives in
* ApprovalQueue, this class only renders + relays button clicks.
*
* Mirroring the existing sidebar.html + media/ separation pattern would be
* appropriate once the panel grows, but the current UI is small enough
* (~40 lines of HTML) that an inline template keeps the diff focused.
*/
export class ApprovalPanelProvider implements vscode.WebviewViewProvider {
public static readonly viewType = 'g1nation-approval-panel';
private _view?: vscode.WebviewView;
private _subscription?: vscode.Disposable;
constructor(
private readonly _extensionUri: vscode.Uri,
private readonly _queue: ApprovalQueue
) {}
public resolveWebviewView(view: vscode.WebviewView): void {
this._view = view;
view.webview.options = { enableScripts: true, localResourceRoots: [this._extensionUri] };
view.webview.html = this._render(this._queue.current());
view.webview.onDidReceiveMessage((msg: { type: string; id?: string }) => {
if (msg?.type === 'approve') void this._queue.approve(msg.id);
else if (msg?.type === 'reject') void this._queue.reject(msg.id);
else if (msg?.type === 'refresh') view.webview.html = this._render(this._queue.current());
});
this._subscription?.dispose();
this._subscription = this._queue.onChange(() => {
if (this._view) this._view.webview.html = this._render(this._queue.current());
});
view.onDidDispose(() => {
this._subscription?.dispose();
this._subscription = undefined;
this._view = undefined;
});
}
/** Bring the panel into focus; used by the status bar badge. */
public focus(): void {
void vscode.commands.executeCommand(`${ApprovalPanelProvider.viewType}.focus`);
}
private _render(approval: Approval | null): string {
const csp = `default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline';`;
const empty = !approval;
const body = empty ? this._renderEmpty() : this._renderApproval(approval as Approval);
return `<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="${csp}">
<style>
body { font-family: var(--vscode-font-family); font-size: 12px; padding: 12px; color: var(--vscode-foreground); }
.empty { color: var(--vscode-descriptionForeground); padding: 24px 8px; text-align: center; }
.card { border: 1px solid var(--vscode-panel-border); border-radius: 6px; padding: 12px; margin-bottom: 8px; background: var(--vscode-editor-background); }
.title { font-weight: 600; margin-bottom: 4px; }
.summary { color: var(--vscode-descriptionForeground); margin-bottom: 8px; font-size: 11px; }
.files { margin: 8px 0; padding: 6px 8px; background: var(--vscode-textCodeBlock-background); border-radius: 4px; max-height: 160px; overflow-y: auto; }
.files li { font-family: var(--vscode-editor-font-family); font-size: 11px; line-height: 1.6; word-break: break-all; list-style: none; }
.files .badge { display: inline-block; min-width: 56px; padding: 1px 6px; border-radius: 3px; font-size: 10px; margin-right: 6px; text-align: center; background: var(--vscode-badge-background); color: var(--vscode-badge-foreground); }
.actions { display: flex; gap: 6px; margin-top: 10px; }
button { flex: 1; padding: 6px 10px; border: 1px solid var(--vscode-button-border, transparent); border-radius: 4px; cursor: pointer; font-size: 12px; }
button.approve { background: var(--vscode-button-background); color: var(--vscode-button-foreground); }
button.approve:hover { background: var(--vscode-button-hoverBackground); }
button.reject { background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); }
button.reject:hover { background: var(--vscode-button-secondaryHoverBackground); }
</style>
</head>
<body>
${body}
<script>
const vscode = acquireVsCodeApi();
for (const btn of document.querySelectorAll('button[data-action]')) {
btn.addEventListener('click', () => {
vscode.postMessage({ type: btn.dataset.action, id: btn.dataset.id });
});
}
</script>
</body>
</html>`;
}
private _renderEmpty(): string {
return `<div class="empty">대기 중인 승인이 없습니다.</div>`;
}
private _renderApproval(a: Approval): string {
const filesHtml = a.files.length === 0
? '<li style="color: var(--vscode-descriptionForeground);">파일 변경 없음</li>'
: a.files.map(f => `<li><span class="badge">변경</span>${this._escape(f)}</li>`).join('');
const elapsed = Math.max(0, Math.floor((Date.now() - a.createdAt) / 1000));
return `
<div class="card">
<div class="title">${this._escape(a.title)}</div>
<div class="summary">${this._escape(a.summary)} · ${elapsed}초 전</div>
<ul class="files">${filesHtml}</ul>
<div class="actions">
<button class="approve" data-action="approve" data-id="${this._escape(a.id)}">승인</button>
<button class="reject" data-action="reject" data-id="${this._escape(a.id)}">거부</button>
</div>
</div>`;
}
private _escape(s: string): string {
return String(s).replace(/[&<>"']/g, ch => (
ch === '&' ? '&amp;' :
ch === '<' ? '&lt;' :
ch === '>' ? '&gt;' :
ch === '"' ? '&quot;' : '&#39;'
));
}
}
+129
View File
@@ -0,0 +1,129 @@
import * as vscode from 'vscode';
import { logError, logInfo } from '../../utils';
/**
* Pending-approval coordination for ConnectAI.
*
* Why this module exists:
* - The agent already has a transaction-based "dry run" approval flow
* (see agent.ts:2362, transactionManager.commit/rollback). The decision
* UI lives inside the chat as an inline box, which is fine for a single
* prompt but misses the broader problem: the user wants a stable place
* to see "what is the agent waiting for me to approve?", with a status
* bar badge that pulls them in.
*
* - Connect_origin solves this with a queue of pending actions in a
* dashboard. ConnectAI today only ever has a single active transaction,
* so we model the queue as **0..1 current approval** to keep the surface
* small. Extending to N approvals later is a list change inside this
* module — no consumer needs to switch shapes.
*
* Wiring (read-only summary):
* agent.ts (dryRun) ──enqueue──▶ ApprovalQueue ──onChange──▶ ApprovalPanelProvider (webview)
* ──onChange──▶ ApprovalStatusBar (badge)
* webview button ──approve/reject──▶ ApprovalQueue ──invokes callback──▶ agent.approveTransaction()
*/
export type ApprovalKind = 'transaction' | 'file-write' | 'file-create' | 'file-delete' | 'command';
export interface Approval {
id: string;
kind: ApprovalKind;
title: string;
summary: string;
files: string[];
createdAt: number;
}
export interface ApprovalCallbacks {
approve: () => Promise<void> | void;
reject: () => Promise<void> | void;
}
interface ApprovalEntry {
approval: Approval;
callbacks: ApprovalCallbacks;
}
export class ApprovalQueue {
private _current: ApprovalEntry | null = null;
private readonly _emitter = new vscode.EventEmitter<void>();
/** Fires whenever the current approval changes (set / approved / rejected / cleared). */
public readonly onChange = this._emitter.event;
/**
* Replace the currently pending approval, if any. The previous approval is
* silently dropped — its callbacks are NOT invoked. This matches the
* agent's transaction model: the new dry-run pre-empts whatever was waiting.
*/
enqueue(approval: Approval, callbacks: ApprovalCallbacks): void {
if (this._current) {
logInfo('Approval pre-empted by newer pending approval.', {
droppedId: this._current.approval.id,
newId: approval.id,
});
}
this._current = { approval, callbacks };
logInfo('Approval enqueued.', { id: approval.id, kind: approval.kind, fileCount: approval.files.length });
this._emitter.fire();
}
/** Returns the currently pending approval, or null. */
current(): Approval | null {
return this._current?.approval ?? null;
}
/** 0 or 1 with the current model — future-proof for a real queue. */
pendingCount(): number {
return this._current ? 1 : 0;
}
/**
* Approve the pending entry whose id matches. If `id` is omitted, approve
* the current entry. Mismatched ids are ignored to avoid stale-button
* double-fire from the webview.
*/
async approve(id?: string): Promise<void> {
const entry = this._take(id);
if (!entry) return;
try {
await entry.callbacks.approve();
} catch (e: any) {
logError('Approval approve callback threw.', { id: entry.approval.id, error: e?.message ?? String(e) });
}
}
async reject(id?: string): Promise<void> {
const entry = this._take(id);
if (!entry) return;
try {
await entry.callbacks.reject();
} catch (e: any) {
logError('Approval reject callback threw.', { id: entry.approval.id, error: e?.message ?? String(e) });
}
}
/** Clear without firing callbacks (used by host on shutdown). */
clear(): void {
if (!this._current) return;
this._current = null;
this._emitter.fire();
}
dispose(): void {
this._current = null;
this._emitter.dispose();
}
private _take(id?: string): ApprovalEntry | null {
if (!this._current) return null;
if (id !== undefined && id !== this._current.approval.id) {
logInfo('Approval id mismatch — ignoring.', { requested: id, current: this._current.approval.id });
return null;
}
const entry = this._current;
this._current = null;
this._emitter.fire();
return entry;
}
}
@@ -0,0 +1,41 @@
import * as vscode from 'vscode';
import { ApprovalQueue } from './approvalQueue';
/**
* Status-bar badge that pulses while an approval is pending. Clicking it
* focuses the Approval Panel webview view. Hidden when nothing is pending.
*
* Lives separately from `src/core/statusBar.ts` (agent run-status indicator)
* because the two states change for completely different reasons and the
* single-item statusBarManager would lose the agent-status during a long
* dry-run review otherwise.
*/
export class ApprovalStatusBar implements vscode.Disposable {
private readonly _item: vscode.StatusBarItem;
private readonly _sub: vscode.Disposable;
public static readonly focusCommand = 'g1nation.approval.focus';
constructor(private readonly _queue: ApprovalQueue) {
this._item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 95);
this._item.command = ApprovalStatusBar.focusCommand;
this._item.tooltip = 'Astra: 승인 대기 중인 작업이 있습니다. 클릭해서 검토하세요.';
this._sub = this._queue.onChange(() => this._refresh());
this._refresh();
}
private _refresh(): void {
const count = this._queue.pendingCount();
if (count === 0) {
this._item.hide();
return;
}
this._item.text = `$(warning) 승인 대기 ${count}`;
this._item.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
this._item.show();
}
dispose(): void {
this._sub.dispose();
this._item.dispose();
}
}
@@ -0,0 +1,515 @@
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
import type { ITelegramClient } from '../../integrations/telegram/telegramClient';
import type { TelegramBot } from '../../integrations/telegram/telegramBot';
import { logError, logInfo } from '../../utils';
import { discoverModels } from '../../lib/discoverModels';
import { pickConfigTarget } from '../../lib/paths';
/**
* Astra Settings webview.
*
* Replaces the old `'openSettings'` shortcut (which dumped the user into the
* raw VS Code settings UI for `g1nation`) with a feature-aware panel. Phase
* 5-A only ships the Telegram section — other sections are stubs that link
* back to VS Code Settings until 5-B fills them in.
*
* Why a webview instead of contributing more to VS Code Settings:
* - Token input must be password-masked AND end up in SecretStorage, which
* VS Code Settings cannot do.
* - Chat ID auto-detection needs an interactive flow with feedback ("polling
* for next message…") that VS Code Settings cannot host.
* - We want one place that knows "is the bot connected right now?" — VS
* Code Settings cannot show live status.
*
* State flow (uni-directional, like a tiny redux):
*
* TelegramBot / SecretStorage / WorkspaceConfig ──┐
* ├──► provider.refreshState() ──► postMessage({type:'state', ...}) ──► webview rerenders
* webview button click ──postMessage(action)──────┘
*
* The webview is rendered every time we `refreshState()` so we don't need
* incremental DOM diffing — just a small string template.
*/
const TELEGRAM_TOKEN_SECRET_KEY = 'g1nation.telegram.botToken';
export interface SettingsPanelDeps {
extensionUri: vscode.Uri;
secrets: vscode.SecretStorage;
/** Returns the live Telegram client so we can call getMe for "test connection". */
telegramClient: ITelegramClient;
/** Returns the live bot instance for enrollNextChat. */
telegramBot: TelegramBot;
}
interface SettingsState {
telegram: {
hasToken: boolean;
enabled: boolean;
connected: boolean;
botName?: string;
allowedChatIds: number[];
enrolling: boolean;
lastError?: string;
lastSuccess?: string;
};
connection: {
ollamaUrl: string;
defaultModel: string;
requestTimeout: number;
availableModels: string[];
modelsLoading: boolean;
};
memory: {
memoryEnabled: boolean;
memoryShortTermMessages: number;
memoryMediumTermSessions: number;
memoryLongTermFiles: number;
};
brain: {
activeBrainId: string;
activeBrainName: string;
activeBrainPath: string;
profileCount: number;
autoPushBrain: boolean;
};
advanced: {
dryRun: boolean;
multiAgentEnabled: boolean;
maxAutoSteps: number;
maxContextSize: number;
};
/** Sectional banner shown when config.update fails (e.g. reload required). */
bannerError?: string;
}
export class SettingsPanelProvider implements vscode.WebviewViewProvider {
public static readonly viewType = 'g1nation-settings-panel';
private _view?: vscode.WebviewView;
private _panel?: vscode.WebviewPanel;
private _enrolling = false;
private _lastError?: string;
private _lastSuccess?: string;
private _botName?: string;
private _bannerError?: string;
private _modelsCache: { url: string; models: string[]; expiresAt: number } | undefined;
private _modelsLoading = false;
private static readonly MODELS_CACHE_TTL_MS = 30000;
constructor(private readonly _deps: SettingsPanelDeps) {}
public resolveWebviewView(view: vscode.WebviewView): void {
this._view = view;
this._setupWebview(view.webview);
view.onDidDispose(() => { this._view = undefined; });
void this._refreshState();
void this._fetchModelsAndRefresh();
}
/**
* Common webview wiring shared between sidebar `WebviewView` and floating
* `WebviewPanel` paths. Sets options, message handler, and initial HTML.
*/
private _setupWebview(webview: vscode.Webview): void {
webview.options = {
enableScripts: true,
localResourceRoots: [this._deps.extensionUri],
};
webview.onDidReceiveMessage((msg) => { void this._handleMessage(msg); });
webview.html = this._renderShell(webview);
}
public async focus(): Promise<void> {
// Reveal the Astra activity-bar container so a focus() doesn't silently
// no-op against a collapsed sidebar.
try {
await vscode.commands.executeCommand('workbench.view.extension.g1nation-sidebar');
} catch {
// Older VS Code versions may not expose this command.
}
try {
await vscode.commands.executeCommand(`${SettingsPanelProvider.viewType}.focus`);
} catch (e: any) {
// The view-focus command is auto-generated only when VS Code parsed
// the package.json `views` entry. If a stale .vsix is installed
// (or the user hasn't reloaded after a fresh install) the command
// is missing and we hit `command not found`. Fall back to a
// floating panel so the user still gets the same UI.
if (this._isCommandNotFound(e)) {
logInfo('Settings view command missing — opening as floating panel.');
await this.openAsPanel();
return;
}
throw e;
}
}
/**
* Open the same settings UI as a stand-alone editor panel. Used when the
* sidebar `WebviewView` isn't registered yet (e.g. user installed a fresh
* .vsix without reloading) — keeps the feature reachable without forcing
* the user back through `vsce package` cycles.
*/
public async openAsPanel(): Promise<void> {
if (this._panel) {
this._panel.reveal(vscode.ViewColumn.Active);
return;
}
const panel = vscode.window.createWebviewPanel(
'g1nation-settings-panel-floating',
'Astra Settings',
vscode.ViewColumn.Active,
{ enableScripts: true, localResourceRoots: [this._deps.extensionUri], retainContextWhenHidden: true }
);
this._panel = panel;
this._setupWebview(panel.webview);
panel.onDidDispose(() => { this._panel = undefined; });
await this._refreshState();
void this._fetchModelsAndRefresh();
}
private _isCommandNotFound(e: unknown): boolean {
const msg = (e as any)?.message ?? String(e ?? '');
return /command\s+'.+'\s+not found/i.test(msg);
}
/** Re-pull state from sources of truth and broadcast to the webview. */
public async refresh(): Promise<void> {
await this._refreshState();
// If the cached URL drifted from the live config, refetch models so the
// dropdown stays in sync with the sidebar (which may have triggered an
// engine switch).
const liveUrl = (vscode.workspace.getConfiguration('g1nation').get<string>('ollamaUrl', '') || '').trim();
if (this._modelsCache && this._modelsCache.url !== liveUrl) {
this._modelsCache = undefined;
void this._fetchModelsAndRefresh();
}
}
private async _handleMessage(msg: any): Promise<void> {
if (!msg || typeof msg.type !== 'string') return;
try {
switch (msg.type) {
case 'ready':
await this._refreshState();
return;
case 'telegram.saveToken':
await this._handleSaveToken(String(msg.token ?? ''));
return;
case 'telegram.clearToken':
await this._deps.secrets.delete(TELEGRAM_TOKEN_SECRET_KEY);
this._botName = undefined;
this._lastError = undefined;
await this._refreshState();
return;
case 'telegram.toggleEnabled':
await this._safeConfigUpdate('telegram.enabled', !!msg.enabled);
return; // onDidChangeConfiguration listener triggers a refresh
case 'telegram.testConnection':
await this._handleTestConnection();
return;
case 'telegram.enroll':
await this._handleEnroll();
return;
case 'telegram.cancelEnroll':
this._deps.telegramBot.cancelEnrollment();
this._enrolling = false;
await this._refreshState();
return;
case 'telegram.removeChatId':
await this._handleRemoveChatId(Number(msg.chatId));
return;
case 'connection.update':
await this._handleConnectionUpdate(msg);
return;
case 'memory.update':
await this._handleMemoryUpdate(msg);
return;
case 'brain.update':
await this._handleBrainUpdate(msg);
return;
case 'advanced.update':
await this._handleAdvancedUpdate(msg);
return;
case 'openVscodeSettings':
await vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation');
return;
default:
logInfo('SettingsPanel: unknown message', { type: msg.type });
}
} catch (e: any) {
this._lastError = e?.message ?? String(e);
logError('SettingsPanel message failed.', { type: msg?.type, error: this._lastError });
await this._refreshState();
}
}
private async _handleSaveToken(token: string): Promise<void> {
const trimmed = token.trim();
if (!/^\d+:[A-Za-z0-9_-]{20,}$/.test(trimmed)) {
this._lastError = 'Token 형식이 올바르지 않습니다 (예: 123456789:AAH...).';
this._lastSuccess = undefined;
await this._refreshState();
return;
}
await this._deps.secrets.store(TELEGRAM_TOKEN_SECRET_KEY, trimmed);
this._lastError = undefined;
this._lastSuccess = '토큰이 저장되었습니다. 자동 연결 테스트 중…';
await this._refreshState();
// Auto-test so the user gets immediate feedback.
await this._handleTestConnection();
}
private async _handleTestConnection(): Promise<void> {
this._lastError = undefined;
try {
const me = await this._deps.telegramClient.getMe();
this._botName = me.username ? `@${me.username}` : me.first_name || `id ${me.id}`;
this._lastSuccess = `연결 성공: ${this._botName}`;
vscode.window.setStatusBarMessage(`Telegram 연결 성공: ${this._botName}`, 3000);
} catch (e: any) {
this._botName = undefined;
this._lastError = e?.message ?? String(e);
this._lastSuccess = undefined;
}
await this._refreshState();
}
/**
* Update a g1nation config value with a friendly error path. VS Code throws
* `Unable to write to User Settings because <key> is not a registered
* configuration` when a stale extension build is loaded — we translate
* that into a panel banner suggesting the reload.
*/
private async _safeConfigUpdate(key: string, value: unknown): Promise<boolean> {
try {
const { target } = pickConfigTarget('g1nation', key);
await vscode.workspace
.getConfiguration('g1nation')
.update(key, value, target);
this._bannerError = undefined;
return true;
} catch (e: any) {
const msg = e?.message ?? String(e);
if (/not a registered configuration/i.test(msg)) {
this._bannerError =
'설정 저장 실패: 확장이 새 설정 정의를 아직 못 읽었습니다. ' +
'"Developer: Reload Window" 를 실행한 뒤 다시 시도하세요. ' +
'(또는 .vsix 재설치)';
} else {
this._bannerError = `설정 저장 실패: ${msg}`;
}
logError('SettingsPanel config.update failed.', { key, error: msg });
await this._refreshState();
return false;
}
}
private async _handleEnroll(): Promise<void> {
const cfg = vscode.workspace.getConfiguration('g1nation');
const token = (await this._deps.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
if (!token.trim()) {
this._lastError = '먼저 Bot Token을 저장하세요.';
await this._refreshState();
return;
}
// The bot must be running to receive the next message; auto-enable if needed.
if (!cfg.get<boolean>('telegram.enabled', false)) {
const ok = await this._safeConfigUpdate('telegram.enabled', true);
if (!ok) return; // banner already shown
}
// The settings change above triggers refreshTelegramBot via
// onDidChangeConfiguration, but that happens on a later microtask.
// Start the bot now so enrollNextChat actually has a polling loop
// that can intercept the next inbound update — otherwise the user
// sees "no response" until the listener catches up.
if (!this._deps.telegramBot.isRunning()) {
this._deps.telegramBot.start();
}
this._enrolling = true;
this._lastError = undefined;
this._lastSuccess = '봇 폴링 시작됨. 텔레그램에서 봇에게 아무 메시지나 한 번 보내주세요.';
await this._refreshState();
try {
const enrolled = await this._deps.telegramBot.enrollNextChat();
const existing = cfg.get<number[]>('telegram.allowedChatIds', []) || [];
const merged = existing.includes(enrolled.chatId) ? existing : [...existing, enrolled.chatId];
await this._safeConfigUpdate('telegram.allowedChatIds', merged);
const label = enrolled.username ? `@${enrolled.username}` : (enrolled.firstName || `id ${enrolled.chatId}`);
this._lastSuccess = `채널 등록 완료: ${label} (id ${enrolled.chatId})`;
vscode.window.showInformationMessage(`Telegram 채널이 등록되었습니다: ${label} (id ${enrolled.chatId}).`);
} catch (e: any) {
this._lastError = e?.message ?? String(e);
} finally {
this._enrolling = false;
await this._refreshState();
}
}
private async _handleRemoveChatId(chatId: number): Promise<void> {
if (!Number.isFinite(chatId)) return;
const cfg = vscode.workspace.getConfiguration('g1nation');
const existing = cfg.get<number[]>('telegram.allowedChatIds', []) || [];
const next = existing.filter((id) => id !== chatId);
await this._safeConfigUpdate('telegram.allowedChatIds', next);
}
private async _handleConnectionUpdate(msg: any): Promise<void> {
if (typeof msg.ollamaUrl === 'string') {
const ok = await this._safeConfigUpdate('ollamaUrl', msg.ollamaUrl.trim());
if (ok) this._modelsCache = undefined; // URL changed → invalidate model list
}
if (typeof msg.defaultModel === 'string') {
await this._safeConfigUpdate('defaultModel', msg.defaultModel.trim());
}
if (typeof msg.requestTimeout === 'number' && Number.isFinite(msg.requestTimeout)) {
await this._safeConfigUpdate('requestTimeout', Math.max(1, Math.floor(msg.requestTimeout)));
}
if (msg.refreshModels) {
this._modelsCache = undefined;
await this._fetchModelsAndRefresh();
}
}
/**
* Fetch the model list and broadcast (with a `loading` flag while in flight)
* so the settings panel's dropdown stays in sync with the sidebar's.
* Cached for `MODELS_CACHE_TTL_MS` per `ollamaUrl` to avoid hammering the
* engine while the panel is open.
*/
private async _fetchModelsAndRefresh(): Promise<void> {
const cfg = vscode.workspace.getConfiguration('g1nation');
const url = (cfg.get<string>('ollamaUrl', '') || '').trim();
const now = Date.now();
const cached = this._modelsCache;
if (cached && cached.url === url && cached.expiresAt > now) return;
this._modelsLoading = true;
await this._refreshState();
try {
const models = await discoverModels(url);
this._modelsCache = {
url,
models,
expiresAt: Date.now() + SettingsPanelProvider.MODELS_CACHE_TTL_MS,
};
} finally {
this._modelsLoading = false;
await this._refreshState();
}
}
private async _handleMemoryUpdate(msg: any): Promise<void> {
if (typeof msg.memoryEnabled === 'boolean') {
await this._safeConfigUpdate('memoryEnabled', msg.memoryEnabled);
}
const numericFields: Array<keyof SettingsState['memory']> = [
'memoryShortTermMessages',
'memoryMediumTermSessions',
'memoryLongTermFiles',
];
for (const f of numericFields) {
const v = (msg as any)[f];
if (typeof v === 'number' && Number.isFinite(v)) {
await this._safeConfigUpdate(f, Math.max(0, Math.floor(v)));
}
}
}
private async _handleBrainUpdate(msg: any): Promise<void> {
if (typeof msg.activeBrainId === 'string') {
await this._safeConfigUpdate('activeBrainId', msg.activeBrainId);
}
if (typeof msg.autoPushBrain === 'boolean') {
await this._safeConfigUpdate('autoPushBrain', msg.autoPushBrain);
}
}
private async _handleAdvancedUpdate(msg: any): Promise<void> {
if (typeof msg.dryRun === 'boolean') {
await this._safeConfigUpdate('dryRun', msg.dryRun);
}
if (typeof msg.multiAgentEnabled === 'boolean') {
await this._safeConfigUpdate('multiAgentEnabled', msg.multiAgentEnabled);
}
if (typeof msg.maxAutoSteps === 'number' && Number.isFinite(msg.maxAutoSteps)) {
await this._safeConfigUpdate('maxAutoSteps', Math.max(1, Math.floor(msg.maxAutoSteps)));
}
if (typeof msg.maxContextSize === 'number' && Number.isFinite(msg.maxContextSize)) {
await this._safeConfigUpdate('maxContextSize', Math.max(1000, Math.floor(msg.maxContextSize)));
}
}
private async _refreshState(): Promise<void> {
if (!this._view && !this._panel) return;
const cfg = vscode.workspace.getConfiguration('g1nation');
const token = (await this._deps.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
const profiles = (cfg.get<any[]>('brainProfiles', []) || []) as Array<{
id?: string; name?: string; localBrainPath?: string;
}>;
const activeBrainId = cfg.get<string>('activeBrainId', '') || '';
const activeProfile = profiles.find((p) => p.id === activeBrainId) || profiles[0];
const state: SettingsState = {
telegram: {
hasToken: !!token.trim(),
enabled: cfg.get<boolean>('telegram.enabled', false),
connected: !!this._botName && this._deps.telegramBot.isRunning(),
botName: this._botName,
allowedChatIds: cfg.get<number[]>('telegram.allowedChatIds', []) || [],
enrolling: this._enrolling,
lastError: this._lastError,
lastSuccess: this._lastSuccess,
},
connection: {
ollamaUrl: cfg.get<string>('ollamaUrl', '') || '',
defaultModel: cfg.get<string>('defaultModel', '') || '',
requestTimeout: cfg.get<number>('requestTimeout', 300) ?? 300,
availableModels: this._modelsCache?.models ?? [],
modelsLoading: this._modelsLoading,
},
memory: {
memoryEnabled: cfg.get<boolean>('memoryEnabled', true),
memoryShortTermMessages: cfg.get<number>('memoryShortTermMessages', 8) ?? 8,
memoryMediumTermSessions: cfg.get<number>('memoryMediumTermSessions', 5) ?? 5,
memoryLongTermFiles: cfg.get<number>('memoryLongTermFiles', 6) ?? 6,
},
brain: {
activeBrainId,
activeBrainName: activeProfile?.name || '(없음)',
activeBrainPath: activeProfile?.localBrainPath || '',
profileCount: profiles.length,
autoPushBrain: cfg.get<boolean>('autoPushBrain', false),
},
advanced: {
dryRun: cfg.get<boolean>('dryRun', false),
multiAgentEnabled: cfg.get<boolean>('multiAgentEnabled', false),
maxAutoSteps: cfg.get<number>('maxAutoSteps', 50) ?? 50,
maxContextSize: cfg.get<number>('maxContextSize', 32000) ?? 32000,
},
bannerError: this._bannerError,
};
const payload = { type: 'state', value: state };
// Broadcast to whichever surface(s) are currently open.
this._view?.webview.postMessage(payload);
this._panel?.webview.postMessage(payload);
}
private _renderShell(webview: vscode.Webview): string {
const mediaRoot = vscode.Uri.joinPath(this._deps.extensionUri, 'media');
const stylesUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'settings-panel.css')).toString();
const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'settings-panel.js')).toString();
const tplPath = path.join(this._deps.extensionUri.fsPath, 'media', 'settings-panel.html');
const tpl = fs.readFileSync(tplPath, 'utf8');
return tpl
.replace('__STYLES_URI__', stylesUri)
.replace('__SCRIPT_URI__', scriptUri);
}
}
export const SETTINGS_TELEGRAM_TOKEN_SECRET_KEY = TELEGRAM_TOKEN_SECRET_KEY;
+240
View File
@@ -0,0 +1,240 @@
import type { ITelegramClient, TelegramClientError } from './telegramClient';
import type { TelegramUpdate } from './types';
import { logError, logInfo } from '../../utils';
/**
* TelegramBot — long-polling loop with explicit lifecycle.
*
* Why this is split from the HTTP client:
* - The HTTP client knows how to make a single request; the bot knows how to
* coordinate a polling loop, an offset cursor, error backoff, and a clean
* shutdown via AbortController.
* - This separation makes the bot mock-friendly — tests inject a stub client
* that returns scripted update arrays without touching the network.
*
* Behavior:
* - On `start()`: kicks off an async loop that calls `client.getUpdates()`
* with a 25s timeout. The loop catches per-iteration errors so a single
* network blip cannot tear down the bot.
* - On `stop()`: aborts any in-flight request and exits the loop. Idempotent.
* - On `aborted` errors during a normal shutdown: silently exits.
* - On `no-token`/`api(401)` (token revoked / wrong): logs once, stops.
* - On generic `network` errors: exponential backoff capped at 30s.
*
* The handler signature is `(text, chatId) => Promise<string | null>`. Returning
* null suppresses the reply (e.g. for ignored messages).
*/
export type TelegramMessageHandler = (text: string, chatId: number) => Promise<string | null>;
export interface TelegramBotDeps {
client: ITelegramClient;
handle: TelegramMessageHandler;
/** Long-poll seconds passed to getUpdates. Default 25. */
pollTimeoutSec?: number;
/** Per-call wait when an error happens. Capped at maxBackoffMs. Default 1000ms. */
initialBackoffMs?: number;
/** Default 30000ms. */
maxBackoffMs?: number;
/** Optional sleep override for tests (defaults to setTimeout-based). */
sleep?: (ms: number) => Promise<void>;
}
const defaultSleep = (ms: number) =>
new Promise<void>((resolve) => {
const t = setTimeout(resolve, ms);
// Avoid blocking node exit if the bot is the only thing keeping the loop alive.
if (typeof t === 'object' && t && 'unref' in t) (t as any).unref();
});
export interface EnrolledChat {
chatId: number;
username?: string;
firstName?: string;
}
export class TelegramBot {
private _running = false;
private _abort: AbortController | undefined;
private _offset: number | undefined;
private _loopPromise: Promise<void> | undefined;
private _enrollPending: {
resolve: (chat: EnrolledChat) => void;
reject: (err: Error) => void;
} | undefined;
constructor(private readonly _deps: TelegramBotDeps) {}
isRunning(): boolean { return this._running; }
/**
* Wait for the next incoming message and resolve with its chat info.
*
* Used by the settings wizard: the user clicks "내 chat ID 자동 등록", we
* arm this one-shot capture, and the very next incoming Telegram message
* is intercepted (no AI reply, no handler call) and yielded back as the
* captured chat. The bot keeps polling normally afterwards.
*
* Only one enrollment can be pending at a time — calling this while
* already armed rejects the prior pending promise.
*/
enrollNextChat(timeoutMs: number = 120000): Promise<EnrolledChat> {
if (this._enrollPending) {
this._enrollPending.reject(new Error('Superseded by a new enrollment request.'));
this._enrollPending = undefined;
}
return new Promise<EnrolledChat>((resolve, reject) => {
const timer = setTimeout(() => {
if (this._enrollPending && this._enrollPending.resolve === wrappedResolve) {
this._enrollPending = undefined;
reject(new Error(`No message received within ${Math.round(timeoutMs / 1000)}s.`));
}
}, timeoutMs);
if (typeof timer === 'object' && timer && 'unref' in timer) (timer as any).unref();
const wrappedResolve = (chat: EnrolledChat) => {
clearTimeout(timer);
resolve(chat);
};
const wrappedReject = (err: Error) => {
clearTimeout(timer);
reject(err);
};
this._enrollPending = { resolve: wrappedResolve, reject: wrappedReject };
});
}
/** Cancel any pending enrollment without resolving it. */
cancelEnrollment(): void {
if (!this._enrollPending) return;
this._enrollPending.reject(new Error('Enrollment cancelled.'));
this._enrollPending = undefined;
}
/** Idempotent: starts the long-poll loop if not already running. */
start(): void {
if (this._running) return;
this._running = true;
this._abort = new AbortController();
this._loopPromise = this._loop().catch((e) => {
logError('Telegram bot loop crashed unexpectedly.', { error: e?.message ?? String(e) });
});
logInfo('Telegram bot started.');
}
/** Idempotent: aborts the in-flight call and waits for the loop to exit. */
async stop(): Promise<void> {
if (!this._running) return;
this._running = false;
try { this._abort?.abort(); } catch { /* noop */ }
const p = this._loopPromise;
this._abort = undefined;
this._loopPromise = undefined;
if (this._enrollPending) {
this._enrollPending.reject(new Error('Bot stopped before enrollment completed.'));
this._enrollPending = undefined;
}
if (p) {
try { await p; } catch { /* swallow — already logged */ }
}
logInfo('Telegram bot stopped.');
}
private async _loop(): Promise<void> {
const { client, handle } = this._deps;
const pollTimeoutSec = this._deps.pollTimeoutSec ?? 25;
const initialBackoff = this._deps.initialBackoffMs ?? 1000;
const maxBackoff = this._deps.maxBackoffMs ?? 30000;
const sleep = this._deps.sleep ?? defaultSleep;
let backoff = initialBackoff;
while (this._running) {
const signal = this._abort?.signal;
try {
const updates = await client.getUpdates({
offset: this._offset,
timeoutSec: pollTimeoutSec,
signal,
});
backoff = initialBackoff; // reset on success
for (const update of updates) {
if (!this._running) break;
this._offset = update.update_id + 1;
await this._processUpdate(update, handle);
}
} catch (e: any) {
if (!this._running) break;
const err = e as TelegramClientError;
if (err?.kind === 'aborted') break;
if (err?.kind === 'no-token') {
logError('Telegram bot stopping: token not configured.');
this._running = false;
break;
}
if (err?.kind === 'api' && (err.statusCode === 401 || err.statusCode === 404)) {
logError('Telegram bot stopping: invalid token (HTTP 401/404).', { statusCode: err.statusCode });
this._running = false;
break;
}
// Generic network / api errors: log and back off.
logError('Telegram poll error; backing off.', { backoff, error: e?.message ?? String(e) });
await sleep(backoff);
backoff = Math.min(backoff * 2, maxBackoff);
}
}
}
private async _processUpdate(update: TelegramUpdate, handle: TelegramMessageHandler): Promise<void> {
const msg = update.message ?? update.edited_message;
if (!msg) return;
const text = msg.text?.trim();
const chatId = msg.chat?.id;
if (!text || typeof chatId !== 'number') return;
// Enrollment intercept: if the settings wizard armed enrollNextChat(),
// hand this update off and skip the normal AI handler. We still send a
// friendly acknowledgement so the user knows enrollment worked.
if (this._enrollPending) {
const pending = this._enrollPending;
this._enrollPending = undefined;
pending.resolve({
chatId,
username: msg.from?.username,
firstName: msg.from?.first_name,
});
try {
await this._deps.client.sendMessage({
chatId,
text: '✅ 채널이 등록되었습니다. 이제부터 메시지를 보낼 수 있어요.',
signal: this._abort?.signal,
});
} catch (e: any) {
logError('Telegram enrollment ack send failed.', { chatId, error: e?.message ?? String(e) });
}
return;
}
let reply: string | null = null;
try {
reply = await handle(text, chatId);
} catch (e: any) {
logError('Telegram message handler threw.', { chatId, error: e?.message ?? String(e) });
reply = `⚠️ Astra 처리 중 오류: ${e?.message ?? e}`;
}
if (reply == null || !reply.trim()) return;
try {
await this._deps.client.sendMessage({
chatId,
text: reply,
signal: this._abort?.signal,
});
} catch (e: any) {
// Sending the reply failed — log and move on. Don't tear down the
// loop because of a single send failure.
logError('Telegram reply send failed.', { chatId, error: e?.message ?? String(e) });
}
}
}
+154
View File
@@ -0,0 +1,154 @@
import type {
TelegramApiResponse,
TelegramMessage,
TelegramUpdate,
TelegramUser,
} from './types';
import { TELEGRAM_MAX_TEXT_LENGTH } from './types';
import { logError, logInfo } from '../../utils';
/**
* Thin HTTP wrapper around the Telegram Bot API.
*
* Only the three endpoints the bot loop needs are exposed:
* - getMe() — token validity probe (used by the "test connection" command)
* - getUpdates() — long-polling driver
* - sendMessage() — outbound replies
*
* Design notes:
* - Uses native `fetch` so we don't pull axios in just for this integration.
* - All errors are normalized to `TelegramClientError` so callers can branch
* on `kind` (`network` / `api` / `aborted`) without inspecting raw fetch
* internals.
* - The `signal` parameter is honored on every call — long-polling depends on
* this for clean shutdown when the bot is disabled or the extension
* deactivates.
* - Tokens are passed by reference (`getToken: () => string | undefined`)
* instead of stored, so rotating the SecretStorage value takes effect on
* the next request without rebuilding the client.
*/
export type TelegramClientErrorKind = 'network' | 'api' | 'aborted' | 'no-token';
export class TelegramClientError extends Error {
constructor(
public readonly kind: TelegramClientErrorKind,
message: string,
public readonly statusCode?: number
) {
super(message);
this.name = 'TelegramClientError';
}
}
export interface SendMessageOptions {
chatId: number;
text: string;
/** Defaults to "Markdown" for clean formatting; pass null to disable. */
parseMode?: 'Markdown' | 'MarkdownV2' | 'HTML' | null;
signal?: AbortSignal;
}
export interface GetUpdatesOptions {
offset?: number;
/** Long-poll seconds. 0 = short poll. Telegram caps at 50; we default to 25. */
timeoutSec?: number;
signal?: AbortSignal;
}
export interface ITelegramClient {
getMe(signal?: AbortSignal): Promise<TelegramUser>;
getUpdates(opts: GetUpdatesOptions): Promise<TelegramUpdate[]>;
sendMessage(opts: SendMessageOptions): Promise<TelegramMessage>;
}
export interface TelegramHttpClientDeps {
/** Returns the current bot token (or empty when not configured). */
getToken: () => string | undefined;
/** Optional fetch override for tests. */
fetchImpl?: typeof fetch;
}
export class TelegramHttpClient implements ITelegramClient {
private readonly _fetch: typeof fetch;
constructor(private readonly _deps: TelegramHttpClientDeps) {
this._fetch = _deps.fetchImpl ?? fetch;
}
async getMe(signal?: AbortSignal): Promise<TelegramUser> {
return this._call<TelegramUser>('getMe', undefined, signal);
}
async getUpdates(opts: GetUpdatesOptions): Promise<TelegramUpdate[]> {
const body: Record<string, unknown> = {
timeout: Math.max(0, Math.min(opts.timeoutSec ?? 25, 50)),
};
if (typeof opts.offset === 'number') body.offset = opts.offset;
return this._call<TelegramUpdate[]>('getUpdates', body, opts.signal);
}
async sendMessage(opts: SendMessageOptions): Promise<TelegramMessage> {
const text = truncateForTelegram(opts.text);
const body: Record<string, unknown> = {
chat_id: opts.chatId,
text,
disable_web_page_preview: true,
};
const parseMode = opts.parseMode === undefined ? 'Markdown' : opts.parseMode;
if (parseMode) body.parse_mode = parseMode;
return this._call<TelegramMessage>('sendMessage', body, opts.signal);
}
private async _call<T>(
method: string,
body: Record<string, unknown> | undefined,
signal?: AbortSignal
): Promise<T> {
const token = (this._deps.getToken() || '').trim();
if (!token) {
throw new TelegramClientError('no-token', 'Telegram bot token is not configured.');
}
const url = `https://api.telegram.org/bot${token}/${method}`;
let response: Response;
try {
response = await this._fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
signal,
});
} catch (e: any) {
if (e?.name === 'AbortError' || signal?.aborted) {
throw new TelegramClientError('aborted', 'Request aborted.');
}
const msg = e?.message ?? String(e);
logError('Telegram API network error.', { method, error: msg });
throw new TelegramClientError('network', `Network error calling ${method}: ${msg}`);
}
let parsed: TelegramApiResponse<T>;
try {
parsed = (await response.json()) as TelegramApiResponse<T>;
} catch (e: any) {
throw new TelegramClientError('api', `Telegram API returned non-JSON for ${method}: ${e?.message ?? e}`);
}
if (!parsed.ok) {
logError('Telegram API error response.', { method, error_code: parsed.error_code, description: parsed.description });
throw new TelegramClientError('api', `${method} failed: ${parsed.description}`, parsed.error_code);
}
logInfo('Telegram API call succeeded.', { method, status: response.status });
return parsed.result;
}
}
/** Truncate text to Telegram's 4096-char limit, preserving a trailing ellipsis hint. */
export function truncateForTelegram(text: string): string {
if (typeof text !== 'string') return '';
if (text.length <= TELEGRAM_MAX_TEXT_LENGTH) return text;
const ellipsis = '\n\n... (truncated)';
return text.slice(0, TELEGRAM_MAX_TEXT_LENGTH - ellipsis.length) + ellipsis;
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Subset of the Telegram Bot API types we actually consume.
*
* Source: https://core.telegram.org/bots/api
*
* Only fields the bot reads or writes are typed — leaving the rest as `unknown`
* keeps the surface narrow and the JSON parsing strict.
*/
export interface TelegramUser {
id: number;
is_bot: boolean;
first_name: string;
last_name?: string;
username?: string;
}
export interface TelegramChat {
id: number;
type: 'private' | 'group' | 'supergroup' | 'channel' | string;
title?: string;
username?: string;
first_name?: string;
}
export interface TelegramMessage {
message_id: number;
date: number;
chat: TelegramChat;
from?: TelegramUser;
text?: string;
}
export interface TelegramUpdate {
update_id: number;
message?: TelegramMessage;
edited_message?: TelegramMessage;
}
export interface TelegramApiSuccess<T> {
ok: true;
result: T;
}
export interface TelegramApiError {
ok: false;
error_code: number;
description: string;
}
export type TelegramApiResponse<T> = TelegramApiSuccess<T> | TelegramApiError;
/** Maximum bytes per Telegram message payload (the API caps text at 4096 chars). */
export const TELEGRAM_MAX_TEXT_LENGTH = 4096;
+35
View File
@@ -0,0 +1,35 @@
import { resolveEngine, buildApiUrl, logError, logInfo } from '../utils';
/**
* Discover the model list exposed by the local AI engine at `baseUrl`.
*
* Same wire format as the sidebar's `_sendModels` (which still owns the
* sidebar-specific caching/UI logic) — extracted here so the settings panel
* can fetch the same list without depending on the sidebar provider.
*
* Returns an empty array on any failure (offline engine, parse error, etc.).
* Callers should treat the result as a hint, not a hard list.
*/
export async function discoverModels(baseUrl: string, timeoutMs: number = 5000): Promise<string[]> {
const url = (baseUrl || '').trim();
if (!url) return [];
const engine = resolveEngine(url);
const modelsUrl = buildApiUrl(url, engine, 'models');
try {
const res = await fetch(modelsUrl, { signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) {
logInfo('discoverModels: non-OK status', { engine, modelsUrl, status: res.status });
return [];
}
const text = await res.text();
if (!text) return [];
const data = JSON.parse(text) as any;
const list: string[] = engine === 'lmstudio'
? (data.data || []).map((m: any) => m.id)
: (data.models || []).map((m: any) => m.name);
return list.filter((m): m is string => typeof m === 'string' && m.length > 0);
} catch (e: any) {
logError('discoverModels failed.', { engine, modelsUrl, error: e?.message ?? String(e) });
return [];
}
}
+146
View File
@@ -0,0 +1,146 @@
import * as os from 'os';
import * as path from 'path';
import * as vscode from 'vscode';
/**
* Centralized path resolver for ConnectAI.
*
* Why this module exists:
* - Brain / agent-skills / workspace paths are read from many places (utils, sidebar,
* bridge, agent). Embedding the same `~`-expansion + abs-path-only check in each
* call site makes them drift over time.
* - New external integrations (skill-inject, future detached-company mode) need a
* single source of truth so they can't accidentally write outside the sandboxed
* user folders.
*
* Conventions:
* - All exported functions return absolute, normalized paths (or empty string if
* the user has not configured a value AND no fallback exists).
* - Relative-path inputs are silently rejected (returned as empty) to avoid
* surprising writes inside random workspaces.
* - This module never throws and never creates directories — callers ensure
* existence on their own (`fs.mkdirSync(..., { recursive: true })`).
*/
/** Expand a leading `~` / `~/` to the user's home directory. Pure function. */
export function expandTilde(raw: string): string {
const trimmed = (raw || '').trim();
if (!trimmed) return '';
if (trimmed === '~') return os.homedir();
if (trimmed.startsWith('~/')) return path.join(os.homedir(), trimmed.slice(2));
return trimmed;
}
/**
* Normalize a user-supplied path string. Returns an empty string for any input
* that is empty, blank, or non-absolute after `~` expansion. Relative paths are
* intentionally rejected — see module header.
*/
export function resolvePathInput(raw: string): string {
const expanded = expandTilde(raw);
if (!expanded) return '';
if (!path.isAbsolute(expanded)) return '';
return path.normalize(expanded);
}
/**
* Best-effort read of a string config value. Returns empty string when VS Code
* config is unavailable (e.g. unit tests not mocking workspace) so callers can
* fall through to defaults without try/catch noise.
*/
function _safeGetConfigString(section: string, key: string): string {
try {
return vscode.workspace.getConfiguration(section).get<string>(key, '') || '';
} catch {
return '';
}
}
/**
* Active brain directory.
*
* Resolution order:
* 1. VS Code config `g1nation.localBrainPath` (after `~` + abs-path normalization).
* 2. The first configured brain profile's `localBrainPath` (handled by callers).
* 3. Empty string — caller decides on a default (utils.ts already has the
* profile-aware logic; this function is only for the simple-path case).
*
* Note: this intentionally does NOT consult `g1nation.brainProfiles` — the
* profile-aware resolver lives in [src/utils.ts](../utils.ts) (`_getBrainDir`)
* and depends on the active-brain selection. Use this function only when you
* need a plain folder path without profile semantics (e.g. external HTTP
* endpoints injecting into the user's primary brain).
*/
export function resolveBrainDirFromConfig(): string {
const raw = _safeGetConfigString('g1nation', 'localBrainPath');
return resolvePathInput(raw);
}
/**
* Resolve the agent-skills directory used by `[.agent/skills/*.md]` markdown
* skill files (the per-workspace agent skill bank that the sidebar's
* `_sendAgentsList` and `_createAgent` operate on).
*
* Resolution order:
* 1. The first VS Code workspace folder + `/.agent/skills/` (creating the
* folder is the caller's responsibility).
* 2. Empty string when no workspace is open — callers must short-circuit.
*
* The legacy default `E:\Wiki\Agent\.agent\skills` from sidebarProvider.ts is
* preserved as a fall-through hint for the original author's machine.
*/
export function resolveAgentSkillsDir(): string {
const legacy = 'E:\\Wiki\\Agent\\.agent\\skills';
try {
const fs = require('fs') as typeof import('fs');
if (fs.existsSync(legacy)) return legacy;
} catch { /* fs unavailable in some isolated tests */ }
const folders = vscode.workspace.workspaceFolders;
if (folders && folders.length > 0) {
return path.join(folders[0].uri.fsPath, '.agent', 'skills');
}
return '';
}
/**
* Returns true iff `child` is the same as `parent` or a descendant of it
* (after path normalization). Used to harden file writes against `..` traversal.
*
* Both paths must be absolute.
*/
export function isInside(parent: string, child: string): boolean {
if (!parent || !child) return false;
const p = path.resolve(parent);
const c = path.resolve(child);
if (c === p) return true;
return c.startsWith(p + path.sep);
}
/**
* Pick the best `ConfigurationTarget` to write a key to: write into whichever
* scope already holds the value, falling back to Global.
*
* Why this matters: VS Code's `getConfiguration().get(key)` resolves through
* Folder → Workspace → User → default. If a Workspace value is set and we
* blindly write to Global, every subsequent read keeps returning the stale
* Workspace value — which is exactly the "sidebar shows e2b but Settings
* shows e4b" bug.
*
* Returns the section's effective inspect record alongside the target so
* callers can debug or surface conflicts to the user.
*/
export function pickConfigTarget(section: string, key: string): {
target: vscode.ConfigurationTarget;
inspect: ReturnType<vscode.WorkspaceConfiguration['inspect']>;
} {
const cfg = vscode.workspace.getConfiguration(section);
const inspect = cfg.inspect(key);
if (inspect?.workspaceFolderValue !== undefined) {
return { target: vscode.ConfigurationTarget.WorkspaceFolder, inspect };
}
if (inspect?.workspaceValue !== undefined) {
return { target: vscode.ConfigurationTarget.Workspace, inspect };
}
return { target: vscode.ConfigurationTarget.Global, inspect };
}
+33 -1
View File
@@ -1,10 +1,14 @@
import { LMStudioClient as SDKClient } from '@lmstudio/sdk';
import { LMStudioClient as SDKClient, LLM } 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[]>;
/** Like listLoaded() but caches the result for `ttlMs` to avoid hammering the SDK. */
listLoadedCached(ttlMs?: number): Promise<string[]>;
/** Resolve a chat-ready handle for an already-loaded (or just-loaded) model. */
getModelHandle(modelKey: string): Promise<LLM>;
isReachable(): Promise<boolean>;
setBaseUrl(httpBaseUrl: string): void;
}
@@ -36,6 +40,8 @@ export function httpToWebSocketUrl(httpBaseUrl: string): string | undefined {
export class LMStudioClient implements ILMStudioClient {
private _sdk: SDKClient | undefined;
private _wsUrl: string | undefined;
private _loadedCache: { value: string[]; expiresAt: number } | undefined;
private static readonly DEFAULT_LOADED_CACHE_TTL_MS = 5000;
constructor(httpBaseUrl: string) {
this.setBaseUrl(httpBaseUrl);
@@ -46,6 +52,7 @@ export class LMStudioClient implements ILMStudioClient {
if (ws !== this._wsUrl) {
this._wsUrl = ws;
this._sdk = undefined;
this._loadedCache = undefined;
}
}
@@ -59,6 +66,7 @@ export class LMStudioClient implements ILMStudioClient {
async load(modelKey: string, signal?: AbortSignal): Promise<void> {
try {
await this.getSdk().llm.load(modelKey, signal ? { signal } : undefined);
this._loadedCache = undefined;
logInfo('LM Studio model loaded.', { modelKey });
} catch (e: any) {
const msg = e?.message ?? String(e);
@@ -69,6 +77,7 @@ export class LMStudioClient implements ILMStudioClient {
async unload(modelKey: string): Promise<void> {
try {
await this.getSdk().llm.unload(modelKey);
this._loadedCache = undefined;
logInfo('LM Studio model unloaded.', { modelKey });
} catch (e: any) {
const msg = e?.message ?? String(e);
@@ -88,6 +97,29 @@ export class LMStudioClient implements ILMStudioClient {
}
}
async listLoadedCached(ttlMs: number = LMStudioClient.DEFAULT_LOADED_CACHE_TTL_MS): Promise<string[]> {
const now = Date.now();
if (this._loadedCache && this._loadedCache.expiresAt > now) {
return this._loadedCache.value.slice();
}
try {
const value = await this.listLoaded();
this._loadedCache = { value, expiresAt: now + ttlMs };
return value.slice();
} catch {
return [];
}
}
async getModelHandle(modelKey: string): Promise<LLM> {
try {
return await this.getSdk().llm.model(modelKey);
} catch (e: any) {
const msg = e?.message ?? String(e);
throw new LMStudioLifecycleError(`Failed to acquire LM Studio model handle "${modelKey}": ${msg}`, e);
}
}
async isReachable(): Promise<boolean> {
try {
await this.getSdk().llm.listLoaded();
+44
View File
@@ -1,6 +1,7 @@
import type { ILMStudioClient } from './client';
import type { IActivityTracker } from './activityTracker';
import type { EngineKind } from '../utils';
import type { ISystemSpecsProvider, IModelMemoryEstimator } from '../system/specs';
import { logError, logInfo } from '../utils';
export type LifecycleState = 'idle' | 'loading' | 'loaded' | 'streaming' | 'unloading';
@@ -19,6 +20,15 @@ export interface LifecycleManagerDeps {
switchDebounceMs?: number;
/** Initial engine. Default 'lmstudio'. */
initialEngine?: EngineKind;
/**
* Optional pre-load memory budget check. When both are provided, a warn-only
* advisory is emitted via `notifyError` (and a structured log line) before
* attempting to load a model that the heuristic predicts will not fit.
* The load is **not** blocked — the user may have a quantization the
* estimator does not recognize.
*/
systemSpecs?: ISystemSpecsProvider;
memoryEstimator?: IModelMemoryEstimator;
}
export class ModelLifecycleManager {
@@ -207,6 +217,38 @@ export class ModelLifecycleManager {
}
}
/**
* Warn-only RAM budget check. If the heuristic estimator says the model is
* unlikely to fit, surface a non-blocking advisory and log it. The load
* still proceeds — the heuristic can be wrong (unrecognized quantization,
* sparse / MoE models) and the user may have explicit intent.
*/
private checkMemoryBudget(modelKey: string): void {
const specsProvider = this.deps.systemSpecs;
const estimator = this.deps.memoryEstimator;
if (!specsProvider || !estimator) return;
try {
const specs = specsProvider.get();
const requiredGB = estimator.estimate(modelKey);
if (requiredGB > specs.safeModelBudgetGB) {
const msg =
`Model "${modelKey}" estimated at ~${requiredGB.toFixed(1)}GB ` +
`exceeds your safe RAM budget of ${specs.safeModelBudgetGB}GB. ` +
`If load fails, try a smaller quantization (q4 / q5).`;
logInfo('LM Studio pre-load memory advisory.', {
model: modelKey,
requiredGB: Number(requiredGB.toFixed(2)),
budgetGB: specs.safeModelBudgetGB,
totalRamGB: Number(specs.totalRamGB.toFixed(2)),
});
this.deps.notifyError?.(msg);
}
} catch (e: any) {
// Diagnostic-only; never block a load on advisory failures.
logError('Memory budget check failed.', { error: e?.message ?? String(e) });
}
}
private async doSwitch(modelKey: string): Promise<void> {
if (this.disposed) return;
if (this.engine !== 'lmstudio') return;
@@ -225,6 +267,8 @@ export class ModelLifecycleManager {
this.currentModel = null;
}
this.checkMemoryBudget(modelKey);
this.state = 'loading';
this.currentModel = modelKey;
const ac = new AbortController();
+64
View File
@@ -0,0 +1,64 @@
import type { ILMStudioClient } from './client';
import { LMStudioLifecycleError } from './client';
import { logError, logInfo } from '../utils';
export interface ChatStreamMessage {
role: 'user' | 'assistant' | 'system';
content: string;
}
export interface ChatStreamRequest {
modelName: string;
messages: ChatStreamMessage[];
temperature: number;
maxTokens?: number;
signal?: AbortSignal;
}
export interface IChatStreamer {
/** Token-level streaming for an LM Studio chat completion via the WebSocket SDK. */
stream(req: ChatStreamRequest): AsyncIterable<{ token: string }>;
}
/**
* Adapter that streams LM Studio chat completions via @lmstudio/sdk's `model.respond()`,
* replacing the manual fetch + SSE parser path used for the OpenAI-compatible REST endpoint.
*
* Benefits over the REST path:
* - No SSE parsing (no `data: [DONE]` / partial-chunk fragility).
* - Reuses the same WebSocket the lifecycle manager already opened — handle lookup is cheap
* if the model is already loaded, and load-on-first-use is implicit when it isn't.
* - First-class `signal` support for user-cancel and abort propagation.
*/
export class LMStudioStreamer implements IChatStreamer {
constructor(private readonly client: ILMStudioClient) {}
async *stream(req: ChatStreamRequest): AsyncIterable<{ token: string }> {
const trimmedModel = (req.modelName || '').trim();
if (!trimmedModel) {
throw new LMStudioLifecycleError('LMStudioStreamer.stream called without a model name.');
}
const model = await this.client.getModelHandle(trimmedModel);
logInfo('LM Studio SDK chat stream started.', { model: trimmedModel, messageCount: req.messages.length });
const prediction = (model as any).respond(req.messages, {
temperature: req.temperature,
maxTokens: req.maxTokens ?? 4096,
signal: req.signal,
});
try {
for await (const fragment of prediction as AsyncIterable<{ content: string }>) {
if (req.signal?.aborted) return;
const token = fragment?.content ?? '';
if (token) yield { token };
}
} catch (err: any) {
if (req.signal?.aborted) return;
if (err?.name === 'AbortError') return;
logError('LM Studio SDK chat stream failed.', { model: trimmedModel, error: err?.message ?? String(err) });
throw err;
}
}
}
+111
View File
@@ -0,0 +1,111 @@
import * as fs from 'fs';
import * as path from 'path';
import { findTemplate, ProjectTemplate, TEMPLATES, ProjectTemplateId } from './templates';
import { isInside } from '../lib/paths';
import { logError, logInfo } from '../utils';
/**
* Project scaffolder.
*
* Mirrors Connect_origin's Developer-agent quick-start (3 templates: static /
* vite-vanilla / vite-react), refactored as:
* - templates as data (see templates.ts) so adding new ones is a one-file change,
* - service interface + filesystem implementation for testability,
* - validation in the service (not the command handler) so callers can't bypass.
*/
export interface ScaffoldRequest {
/** User-supplied project name. Will be re-validated; service rejects bad input. */
name: string;
template: ProjectTemplateId;
/** Absolute parent directory. The project will land at `<rootDir>/<name>/`. */
rootDir: string;
}
export type ScaffoldResult =
| { ok: true; projectPath: string; filesWritten: string[] }
| { ok: false; error: string; code: ScaffoldErrorCode };
export type ScaffoldErrorCode =
| 'INVALID_NAME'
| 'NO_ROOT_DIR'
| 'ROOT_NOT_ABSOLUTE'
| 'ALREADY_EXISTS'
| 'UNKNOWN_TEMPLATE'
| 'WRITE_FAILED';
export interface IProjectScaffolder {
scaffold(req: ScaffoldRequest): Promise<ScaffoldResult>;
listTemplates(): ProjectTemplate[];
}
const NAME_RE = /^[a-zA-Z0-9_-]{2,40}$/;
/** Conservative project-name validator. Same constraints as Connect_origin's command UI. */
export function validateProjectName(raw: string): string | null {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
return NAME_RE.test(trimmed) ? trimmed : null;
}
export interface ScaffolderDeps {
/** Optional fs override for tests. Defaults to node:fs. */
fsImpl?: Pick<typeof fs, 'existsSync' | 'mkdirSync' | 'writeFileSync'>;
}
export class FileSystemProjectScaffolder implements IProjectScaffolder {
private readonly _fs: NonNullable<ScaffolderDeps['fsImpl']>;
constructor(deps: ScaffolderDeps = {}) {
this._fs = deps.fsImpl ?? fs;
}
listTemplates(): ProjectTemplate[] {
return TEMPLATES.slice();
}
async scaffold(req: ScaffoldRequest): Promise<ScaffoldResult> {
const name = validateProjectName(req.name);
if (!name) {
return { ok: false, error: '프로젝트 이름은 영문/숫자/_/- 만 허용되며 2~40자여야 합니다.', code: 'INVALID_NAME' };
}
if (!req.rootDir) {
return { ok: false, error: '대상 폴더가 지정되지 않았습니다. 워크스페이스를 먼저 여세요.', code: 'NO_ROOT_DIR' };
}
if (!path.isAbsolute(req.rootDir)) {
return { ok: false, error: '대상 폴더는 절대 경로여야 합니다.', code: 'ROOT_NOT_ABSOLUTE' };
}
const template = findTemplate(req.template);
if (!template) {
return { ok: false, error: `알 수 없는 템플릿: ${req.template}`, code: 'UNKNOWN_TEMPLATE' };
}
const projectPath = path.join(req.rootDir, name);
if (this._fs.existsSync(projectPath)) {
return { ok: false, error: `이미 존재하는 폴더입니다: ${projectPath}`, code: 'ALREADY_EXISTS' };
}
const fileMap = template.files(name);
const filesWritten: string[] = [];
try {
this._fs.mkdirSync(projectPath, { recursive: true });
for (const [rel, contents] of Object.entries(fileMap)) {
const target = path.join(projectPath, rel);
if (!isInside(projectPath, target)) {
// Defense in depth — a template author can't smuggle "../" into a path.
throw new Error(`Template path escapes project root: ${rel}`);
}
this._fs.mkdirSync(path.dirname(target), { recursive: true });
this._fs.writeFileSync(target, contents, 'utf8');
filesWritten.push(target);
}
} catch (e: any) {
const msg = e?.message ?? String(e);
logError('Project scaffold failed.', { name, template: template.id, error: msg });
return { ok: false, error: `생성 실패: ${msg}`, code: 'WRITE_FAILED' };
}
logInfo('Project scaffolded.', { name, template: template.id, projectPath, fileCount: filesWritten.length });
return { ok: true, projectPath, filesWritten };
}
}
+154
View File
@@ -0,0 +1,154 @@
/**
* Scaffolder template catalog.
*
* Templates are pure data — `(projectName) => { [relativePath]: contents }`. New
* templates are added by appending to `TEMPLATES`; the rest of the scaffolder
* (validation, IO, command UX) does not need to change.
*
* This intentionally mirrors the static/vite-vanilla/vite-react options that
* Connect_origin's Developer agent ships, but split out of the giant inline
* function so each template body is grep-friendly.
*/
export type ProjectTemplateId = 'static' | 'vite-vanilla' | 'vite-react';
export interface ProjectTemplate {
id: ProjectTemplateId;
label: string;
detail: string;
/** Returns map of `relativePath -> fileContents`. Project name is already sanitized. */
files(name: string): Record<string, string>;
}
const README = (name: string, template: string) =>
`# ${name}\n\n` +
`Astra의 Project Scaffolder가 ${new Date().toISOString().slice(0, 10)}\`${template}\` 템플릿으로 생성한 프로젝트입니다.\n`;
export const TEMPLATES: ProjectTemplate[] = [
{
id: 'static',
label: 'static',
detail: 'index.html 한 장 (Tailwind CDN)',
files: (name) => ({
'site/index.html':
`<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${name}</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-zinc-950 text-zinc-100 min-h-screen flex items-center justify-center">
<main class="text-center space-y-4">
<h1 class="text-4xl font-bold">${name}</h1>
<p class="text-zinc-400">Astra · Project Scaffolder</p>
</main>
</body>
</html>
`,
'README.md': README(name, 'static'),
}),
},
{
id: 'vite-vanilla',
label: 'vite-vanilla',
detail: 'Vite + 순수 JS',
files: (name) => ({
'site/package.json': JSON.stringify({
name,
private: true,
type: 'module',
scripts: { dev: 'vite', build: 'vite build', preview: 'vite preview' },
devDependencies: { vite: '^5.0.0' },
}, null, 2) + '\n',
'site/index.html':
`<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>${name}</title>
</head>
<body>
<h1>${name}</h1>
<script type="module" src="/main.js"></script>
</body>
</html>
`,
'site/main.js':
`document.querySelector('h1').addEventListener('click', () => {
console.log('hi from ${name}');
});
`,
'README.md': README(name, 'vite-vanilla'),
}),
},
{
id: 'vite-react',
label: 'vite-react',
detail: 'Vite + React + TypeScript',
files: (name) => ({
'site/package.json': JSON.stringify({
name,
private: true,
type: 'module',
scripts: { dev: 'vite', build: 'tsc && vite build', preview: 'vite preview' },
dependencies: { react: '^18.3.0', 'react-dom': '^18.3.0' },
devDependencies: {
'@types/react': '^18.3.0',
'@types/react-dom': '^18.3.0',
'@vitejs/plugin-react': '^4.3.0',
typescript: '^5.4.0',
vite: '^5.0.0',
},
}, null, 2) + '\n',
'site/tsconfig.json': JSON.stringify({
compilerOptions: {
target: 'ES2020',
useDefineForClassFields: true,
lib: ['ES2020', 'DOM', 'DOM.Iterable'],
module: 'ESNext',
skipLibCheck: true,
moduleResolution: 'bundler',
allowImportingTsExtensions: true,
resolveJsonModule: true,
isolatedModules: true,
noEmit: true,
jsx: 'react-jsx',
strict: true,
},
include: ['src'],
}, null, 2) + '\n',
'site/vite.config.ts':
`import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()] });
`,
'site/index.html':
`<!doctype html>
<html lang="ko">
<head><meta charset="utf-8"><title>${name}</title></head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
`,
'site/src/main.tsx':
`import React from 'react';
import { createRoot } from 'react-dom/client';
function App() {
return <h1>${name}</h1>;
}
createRoot(document.getElementById('root')!).render(<App />);
`,
'README.md': README(name, 'vite-react'),
}),
},
];
export function findTemplate(id: string): ProjectTemplate | undefined {
return TEMPLATES.find(t => t.id === id);
}
+23 -4
View File
@@ -2,6 +2,7 @@ import * as vscode from 'vscode';
import * as path from 'path';
import { SidebarChatProvider } from '../sidebarProvider';
import { getActiveBrainProfile, logInfo } from '../utils';
import { pickConfigTarget } from '../lib/paths';
/**
* Handles chat-domain messages: prompts, model selection, sessions, streaming control,
@@ -58,7 +59,20 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
await provider._deleteSession(data.id);
return true;
case 'openSettings':
vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation');
// Route the sidebar gear button to Astra's own settings webview.
// Falls back to VS Code Settings if the view hasn't registered yet
// (e.g. during the very first activation pass) and surfaces any
// unexpected error so the user isn't stuck with a silent button.
try {
await vscode.commands.executeCommand('g1nation.settings.focus');
} catch (e: any) {
logInfo('openSettings: settings.focus failed, falling back to VS Code Settings.', { error: e?.message ?? String(e) });
try {
await vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation');
} catch (e2: any) {
vscode.window.showErrorMessage(`Astra Settings 열기 실패: ${e2?.message ?? e2}`);
}
}
return true;
case 'addMessage':
provider._view?.webview.postMessage({ type: 'addMessage', role: data.role, value: data.value, rationale: data.rationale });
@@ -66,11 +80,16 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
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}`);
case 'model': {
// Write to whichever scope already holds the value so a stale
// Workspace override doesn't shadow our Global update — that was
// the "sidebar shows e2b but Settings shows e4b" desync.
const { target } = pickConfigTarget('g1nation', 'defaultModel');
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', data.value, target);
logInfo(`Default model updated to: ${data.value}`, { target });
provider._lmStudio?.lifecycle.onModelSelected(data.value);
return true;
}
case 'proactiveTrigger':
await provider._handleProactiveSuggestion(data.context);
return true;
+12 -1
View File
@@ -26,6 +26,8 @@ import { handleAgentMessage } from './sidebar/agentHandlers';
export interface SidebarLmStudioDeps {
lifecycle: ModelLifecycleManager;
activity: IActivityTracker;
/** Returns the list of model identifiers currently loaded in LM Studio (cached). */
loadedModels: () => Promise<string[]>;
}
interface LastVisibleChatSnapshot {
@@ -1899,7 +1901,16 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
models.unshift(defaultModel);
}
this._view.webview.postMessage({ type: 'modelsList', value: { models, selected: 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 {
+145
View File
@@ -0,0 +1,145 @@
import * as fs from 'fs';
import * as path from 'path';
import { isInside } from '../lib/paths';
import { logError, logInfo } from '../utils';
/**
* External-tool skill injection.
*
* Connect_origin's `/api/skill-inject` writes Python tool scripts into a
* per-agent `tools/` folder, on the assumption the agent can `<run_command>`
* them. ConnectAI doesn't run Python tools — its agent skills are markdown
* documents loaded by the sidebar (`<workspace>/.agent/skills/<name>.md`).
*
* So this service injects **markdown skills** (the ConnectAI primitive), not
* .py scripts. The endpoint shape stays similar (name / displayName /
* description / content / source) so the same external integrations
* (EZER / Agent University) can target either project with a thin adapter.
*
* What lands on disk per inject call:
* .agent/skills/<safeName>.md — markdown body
* .agent/skills/<safeName>.meta.json — injectedAt + injectedFrom + display fields
*
* The `.md` is what the existing sidebar `_sendAgentsList` already discovers.
* The sidecar `.meta.json` is read by future UI surfaces (provenance badges)
* but is invisible to the legacy loader, so back-compat is preserved.
*/
export interface SkillInjectionRequest {
/** Required. Slug-style identifier; will be sanitized to filesystem-safe form. */
name: string;
/** Required. The markdown body of the skill (system-prompt content). */
content: string;
/** Optional. Human-friendly display name. Falls back to sanitized `name`. */
displayName?: string;
/** Optional. One-line description shown in UI hints. */
description?: string;
/** Optional. External-source tag, e.g. `"ezer"` / `"agent-university"`. */
source?: string;
}
export interface SkillInjectionResult {
/** Sanitized name actually used on disk. */
safeName: string;
/** Absolute path to the written markdown file. */
filePath: string;
/** Absolute path to the sidecar metadata file. */
metaPath: string;
}
export class SkillInjectionError extends Error {
constructor(message: string, public readonly code: string) {
super(message);
this.name = 'SkillInjectionError';
}
}
export interface ISkillInjectionService {
inject(req: SkillInjectionRequest): Promise<SkillInjectionResult>;
}
export interface SkillInjectionDeps {
/** Resolves the skills directory at the time of each call (must be absolute). */
resolveSkillsDir: () => string;
/** Optional fs override for unit tests; defaults to node:fs. */
fsImpl?: Pick<typeof fs, 'existsSync' | 'mkdirSync' | 'writeFileSync'>;
/** Optional notification hook called on successful injection. */
onInjected?: (result: SkillInjectionResult, req: SkillInjectionRequest) => void;
}
/** Conservative skill-name sanitizer. Rejects empty / overlong / shell-unfriendly inputs. */
export function sanitizeSkillName(raw: string): string {
if (typeof raw !== 'string') return '';
const trimmed = raw.trim();
if (!trimmed) return '';
// Strip extensions if caller passed "foo.md" by mistake
const noExt = trimmed.replace(/\.(md|markdown)$/i, '');
// Allow only ASCII alphanumerics, `_`, `-`, `.` — block path separators,
// shell metas, and unicode that could confuse filesystems.
const safe = noExt.replace(/[^a-zA-Z0-9_.\-]/g, '_').replace(/^[._]+|[._]+$/g, '');
return safe.slice(0, 80);
}
export class FileSystemSkillInjectionService implements ISkillInjectionService {
private readonly _fs: NonNullable<SkillInjectionDeps['fsImpl']>;
constructor(private readonly deps: SkillInjectionDeps) {
this._fs = deps.fsImpl ?? fs;
}
async inject(req: SkillInjectionRequest): Promise<SkillInjectionResult> {
const safeName = sanitizeSkillName(req.name);
if (!safeName) {
throw new SkillInjectionError(
'Skill name is empty after sanitization. Use ASCII letters, digits, "_", "-", or "." only.',
'INVALID_NAME'
);
}
if (typeof req.content !== 'string' || !req.content.trim()) {
throw new SkillInjectionError('Skill content must be a non-empty markdown string.', 'EMPTY_CONTENT');
}
const skillsDir = this.deps.resolveSkillsDir();
if (!skillsDir) {
throw new SkillInjectionError(
'Agent skills directory is not available. Open a workspace folder first.',
'NO_SKILLS_DIR'
);
}
const targetMd = path.join(skillsDir, `${safeName}.md`);
const targetMeta = path.join(skillsDir, `${safeName}.meta.json`);
if (!isInside(skillsDir, targetMd) || !isInside(skillsDir, targetMeta)) {
// Defense in depth: sanitizer should already block traversal, but the
// path math runs again here in case skillsDir resolves to something
// surprising (symlink, weird casing on Windows, etc.).
throw new SkillInjectionError('Refusing to write outside the skills directory.', 'PATH_ESCAPE');
}
try {
if (!this._fs.existsSync(skillsDir)) {
this._fs.mkdirSync(skillsDir, { recursive: true });
}
this._fs.writeFileSync(targetMd, req.content, 'utf8');
const meta = {
name: safeName,
displayName: (req.displayName || '').trim() || safeName,
description: (req.description || '').trim() || '',
injectedAt: new Date().toISOString(),
injectedFrom: (req.source || '').trim() || 'external',
};
this._fs.writeFileSync(targetMeta, JSON.stringify(meta, null, 2), 'utf8');
} catch (e: any) {
const msg = e?.message ?? String(e);
logError('Skill injection write failed.', { safeName, skillsDir, error: msg });
throw new SkillInjectionError(`Failed to write skill files: ${msg}`, 'WRITE_FAILED');
}
const result: SkillInjectionResult = { safeName, filePath: targetMd, metaPath: targetMeta };
logInfo('Skill injected.', { safeName, source: req.source, skillsDir });
this.deps.onInjected?.(result, req);
return result;
}
}
+118
View File
@@ -0,0 +1,118 @@
import * as os from 'os';
/**
* System hardware specs surfaced to the rest of the app.
*
* All sizes are in **GiB** (1024^3 bytes). `safeModelBudgetGB` is a conservative
* estimate of how much RAM is actually available for an LLM after the OS, the
* editor, and the user's other apps take their cut — meant to be compared
* against `IModelMemoryEstimator.estimate()` to warn the user before LM Studio
* crashes on out-of-memory.
*/
export interface SystemSpecs {
totalRamGB: number;
freeRamGB: number;
cpuModel: string;
cpuCount: number;
platform: NodeJS.Platform;
arch: string;
isAppleSilicon: boolean;
safeModelBudgetGB: number;
/** Human-readable one-line summary, useful for logs and toasts. */
summary: string;
}
export interface ISystemSpecsProvider {
/** Returns the current system specs. Implementations should cache. */
get(): SystemSpecs;
}
export interface IModelMemoryEstimator {
/**
* Best-effort estimate of how many GB a given model identifier will need
* to load. Returns a positive number; the caller decides what to do with it.
*/
estimate(modelId: string): number;
}
/**
* Production system-specs provider. Reads `os.totalmem()`, `os.cpus()`, etc.
* and caches the result for the process lifetime — physical RAM does not
* change at runtime.
*
* The Apple Silicon ratio (0.65) is more generous than other platforms (0.5)
* because of unified memory: GPU inference shares the same pool, so a higher
* fraction is realistically usable for the model.
*/
export class NodeSystemSpecsProvider implements ISystemSpecsProvider {
private _cache: SystemSpecs | undefined;
get(): SystemSpecs {
if (this._cache) return this._cache;
const totalRamGB = os.totalmem() / (1024 ** 3);
const freeRamGB = os.freemem() / (1024 ** 3);
const cpus = os.cpus() || [];
const cpuModel = (cpus[0]?.model || 'unknown').replace(/\s+/g, ' ').trim();
const platform = os.platform();
const arch = os.arch();
const isAppleSilicon =
platform === 'darwin' && arch === 'arm64' && /Apple\s+M/i.test(cpuModel);
const ratio = isAppleSilicon ? 0.65 : 0.5;
const safeModelBudgetGB = Math.max(2, Math.floor(totalRamGB * ratio));
const platformLabel = platform === 'darwin' ? 'macOS' : platform;
const siliconLabel = isAppleSilicon ? ' (Apple Silicon)' : '';
const summary =
`${platformLabel} · ${arch}${siliconLabel} · RAM ${totalRamGB.toFixed(0)}GB ` +
`· CPU ${cpuModel.slice(0, 40)} (${cpus.length} cores)`;
this._cache = {
totalRamGB,
freeRamGB,
cpuModel,
cpuCount: cpus.length,
platform,
arch,
isAppleSilicon,
safeModelBudgetGB,
summary,
};
return this._cache;
}
}
/**
* Heuristic estimator that derives memory cost from common patterns in model
* identifiers (e.g. `gemma-2-9b-q4_K_M`).
*
* Approach:
* 1. Pull the parameter count by matching `\d+B`. Default 7B if absent.
* 2. Multiply by a per-param byte cost determined by quantization:
* - q4 / 4-bit: 0.6 GB/B (default)
* - q5 / 5-bit: 0.7
* - q6 / 6-bit: 0.8
* - q8 / 8-bit / fp8: 1.0
* - fp16 / bf16: 2.0
* 3. Add a 1 GB overhead for KV cache + runtime.
*
* This is a rough estimate, but consistently within ±20% of LM Studio's actual
* load footprint on the popular GGUF families — good enough to gate "your 16 GB
* machine probably can't load a 70B fp16" before LM Studio crashes.
*/
export class HeuristicModelMemoryEstimator implements IModelMemoryEstimator {
estimate(modelId: string): number {
const id = (modelId || '').toLowerCase();
const paramMatch = id.match(/(\d+(?:\.\d+)?)\s*b\b/);
const paramCountB = paramMatch ? parseFloat(paramMatch[1]) : 7;
let bytesPerParam = 0.6; // q4 default
if (/q8|8bit|fp8/i.test(id)) bytesPerParam = 1.0;
else if (/q6|6bit/i.test(id)) bytesPerParam = 0.8;
else if (/q5|5bit/i.test(id)) bytesPerParam = 0.7;
else if (/fp16|f16|bf16/i.test(id)) bytesPerParam = 2.0;
return paramCountB * bytesPerParam + 1.0;
}
}