diff --git a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json
index 5d742f8..eb60165 100644
--- a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json
+++ b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json
@@ -1,5 +1,5 @@
{
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
- "createdAt": 1778249295071,
+ "createdAt": 1778256848559,
"modelVersion": "unknown"
}
\ No newline at end of file
diff --git a/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json b/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json
index 4549a49..24d6f4a 100644
--- a/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json
+++ b/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json
@@ -1,5 +1,5 @@
{
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
- "createdAt": 1778249295065,
+ "createdAt": 1778256848551,
"modelVersion": "unknown"
}
\ No newline at end of file
diff --git a/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json b/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json
index 6766962..13ed00b 100644
--- a/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json
+++ b/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json
@@ -1,5 +1,5 @@
{
"result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
- "createdAt": 1778249295060,
+ "createdAt": 1778256848546,
"modelVersion": "unknown"
}
\ No newline at end of file
diff --git a/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json b/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json
index c18db3c..946dbbc 100644
--- a/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json
+++ b/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json
@@ -1,5 +1,5 @@
{
- "result": "---\nid: stress_conflict_1778249295044\ndate: 2026-05-08T14:08:15.076Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (10ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (6ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (9ms)\n",
- "createdAt": 1778249295076,
+ "result": "---\nid: stress_conflict_1778256848530\ndate: 2026-05-08T16:14:08.563Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (11ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (5ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (9ms)\n",
+ "createdAt": 1778256848563,
"modelVersion": "unknown"
}
\ No newline at end of file
diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1778249295044.json b/.astra/tests/stress/.astra/missions/stress_conflict_1778256848530.json
similarity index 80%
rename from .astra/tests/stress/.astra/missions/stress_conflict_1778249295044.json
rename to .astra/tests/stress/.astra/missions/stress_conflict_1778256848530.json
index 9aa8516..24da0e8 100644
--- a/.astra/tests/stress/.astra/missions/stress_conflict_1778249295044.json
+++ b/.astra/tests/stress/.astra/missions/stress_conflict_1778256848530.json
@@ -1,8 +1,8 @@
{
- "missionId": "stress_conflict_1778249295044",
+ "missionId": "stress_conflict_1778256848530",
"status": "completed",
- "startTime": "2026-05-08T14:08:15.044Z",
- "totalElapsedMs": 32,
+ "startTime": "2026-05-08T16:14:08.530Z",
+ "totalElapsedMs": 34,
"results": {
"planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
"researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
@@ -16,30 +16,30 @@
{
"from": "idle",
"to": "planner",
- "durationMs": 10,
+ "durationMs": 11,
"message": "전략 수립 중...",
- "ts": "2026-05-08T14:08:15.054Z"
+ "ts": "2026-05-08T16:14:08.541Z"
},
{
"from": "planner",
"to": "researcher",
- "durationMs": 6,
+ "durationMs": 5,
"message": "핵심 정보 수집 및 분석 중...",
- "ts": "2026-05-08T14:08:15.060Z"
+ "ts": "2026-05-08T16:14:08.546Z"
},
{
"from": "researcher",
"to": "writer",
"durationMs": 9,
"message": "최종 리포트 작성 및 편집 중...",
- "ts": "2026-05-08T14:08:15.069Z"
+ "ts": "2026-05-08T16:14:08.555Z"
},
{
"from": "writer",
"to": "completed",
- "durationMs": 7,
+ "durationMs": 9,
"message": "미션 완료",
- "ts": "2026-05-08T14:08:15.076Z"
+ "ts": "2026-05-08T16:14:08.564Z"
}
],
"resilienceMetrics": {
diff --git a/media/settings-panel.css b/media/settings-panel.css
new file mode 100644
index 0000000..aa77d96
--- /dev/null
+++ b/media/settings-panel.css
@@ -0,0 +1,210 @@
+:root {
+ --gap: 12px;
+}
+
+body {
+ font-family: var(--vscode-font-family);
+ font-size: 13px;
+ color: var(--vscode-foreground);
+ background: var(--vscode-sideBar-background);
+ margin: 0;
+ padding: 12px 14px 24px;
+}
+
+.hd {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 16px;
+}
+
+.hd h1 {
+ font-size: 14px;
+ font-weight: 600;
+ margin: 0;
+}
+
+.section {
+ border: 1px solid var(--vscode-panel-border);
+ border-radius: 8px;
+ padding: 14px;
+ margin-bottom: 14px;
+ background: var(--vscode-editor-background);
+}
+
+.section h2 {
+ font-size: 13px;
+ font-weight: 600;
+ margin: 0 0 6px 0;
+}
+
+.section.stub {
+ opacity: 0.7;
+}
+
+.hint {
+ color: var(--vscode-descriptionForeground);
+ font-size: 11px;
+ line-height: 1.5;
+ margin: 0 0 12px 0;
+}
+
+.row {
+ margin-bottom: var(--gap);
+}
+
+.row label {
+ display: block;
+ font-size: 11px;
+ color: var(--vscode-descriptionForeground);
+ margin-bottom: 4px;
+}
+
+.row.toggle label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--vscode-foreground);
+ font-size: 12px;
+}
+
+.input-group {
+ display: flex;
+ gap: 6px;
+}
+
+input[type="password"], input[type="text"] {
+ flex: 1;
+ padding: 6px 8px;
+ background: var(--vscode-input-background);
+ color: var(--vscode-input-foreground);
+ border: 1px solid var(--vscode-input-border, transparent);
+ border-radius: 4px;
+ font-size: 12px;
+ font-family: var(--vscode-editor-font-family);
+}
+
+input[type="checkbox"] {
+ accent-color: var(--vscode-button-background);
+}
+
+button {
+ padding: 6px 10px;
+ border: 1px solid var(--vscode-button-border, transparent);
+ border-radius: 4px;
+ background: var(--vscode-button-background);
+ color: var(--vscode-button-foreground);
+ cursor: pointer;
+ font-size: 12px;
+}
+
+button:hover { background: var(--vscode-button-hoverBackground); }
+
+button.ghost {
+ background: var(--vscode-button-secondaryBackground);
+ color: var(--vscode-button-secondaryForeground);
+}
+button.ghost:hover { background: var(--vscode-button-secondaryHoverBackground); }
+
+button.link {
+ background: transparent;
+ color: var(--vscode-textLink-foreground);
+ border: none;
+ padding: 4px 0;
+ font-size: 11px;
+ text-decoration: underline;
+ cursor: pointer;
+}
+button.link:hover { color: var(--vscode-textLink-activeForeground); }
+
+.status {
+ display: block;
+ margin-top: 6px;
+ font-size: 11px;
+ color: var(--vscode-descriptionForeground);
+}
+
+.status-inline {
+ margin-left: 8px;
+ font-size: 11px;
+ color: var(--vscode-descriptionForeground);
+}
+
+.status-inline.ok { color: var(--vscode-charts-green, #4ec9b0); }
+
+.error {
+ margin-top: 10px;
+ padding: 8px 10px;
+ border-radius: 4px;
+ background: var(--vscode-inputValidation-errorBackground);
+ color: var(--vscode-inputValidation-errorForeground);
+ border: 1px solid var(--vscode-inputValidation-errorBorder);
+ font-size: 11px;
+}
+
+.chips {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.chips li {
+ background: var(--vscode-badge-background);
+ color: var(--vscode-badge-foreground);
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: 11px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+.chips .remove {
+ cursor: pointer;
+ opacity: 0.6;
+}
+.chips .remove:hover { opacity: 1; }
+
+.empty {
+ color: var(--vscode-descriptionForeground);
+ font-size: 11px;
+ font-style: italic;
+}
+
+.banner {
+ margin: 0 0 12px 0;
+ padding: 10px 12px;
+ border-radius: 6px;
+ background: var(--vscode-inputValidation-warningBackground, #5a4a14);
+ color: var(--vscode-inputValidation-warningForeground, #fff);
+ border: 1px solid var(--vscode-inputValidation-warningBorder, transparent);
+ font-size: 11px;
+ line-height: 1.5;
+}
+
+.feedback {
+ margin-top: 10px;
+ padding: 6px 10px;
+ border-radius: 4px;
+ background: rgba(78, 201, 176, 0.15);
+ color: var(--vscode-charts-green, #4ec9b0);
+ border: 1px solid rgba(78, 201, 176, 0.4);
+ font-size: 11px;
+}
+
+.input-group.narrow input { max-width: 120px; }
+
+.readout {
+ padding: 6px 8px;
+ background: var(--vscode-textCodeBlock-background);
+ border-radius: 4px;
+ font-family: var(--vscode-editor-font-family);
+ font-size: 12px;
+ word-break: break-all;
+}
+
+.section.stub {
+ /* 5-A had this stub class; 5-B fills the sections so we no longer dim them. */
+ opacity: 1;
+}
diff --git a/media/settings-panel.html b/media/settings-panel.html
new file mode 100644
index 0000000..bc75170
--- /dev/null
+++ b/media/settings-panel.html
@@ -0,0 +1,164 @@
+
+
+
+
+
+ Astra Settings
+
+
+
+
+ Astra Settings
+
+
+
+
+
+
+
+
+ 연결
+ 로컬 AI 엔진(Ollama 또는 LM Studio) 위치와 기본 모델을 설정합니다.
+
+
+
+
+
+
+
Ollama 기본 11434 / LM Studio 기본 1234.
+
+
+
+
+
+
+
+
+
사이드바에서 선택한 모델이 여기에도 동기화됩니다.
+
+
+
+
+
+
+
+
+
+
+
+
+ 메모리
+ 대화 응답 전에 주입되는 단기/중기/장기 메모리의 양을 조정합니다.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Brain
+ 현재 활성 brain 프로필 정보입니다. 프로필 추가·수정은 사이드바의 Brain 메뉴 또는 VS Code Settings에서 처리합니다.
+
+
+
+
+
+
+
+
+
+
+
+ Telegram 봇
+ 텔레그램으로 Astra와 대화하고 싶다면 BotFather에서 봇을 만들고 토큰을 여기에 저장하세요. Astra의 다른 기능에는 영향이 없습니다.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
목록이 비어 있으면 누구나 봇에 메시지를 보낼 수 있습니다 (자동 등록을 한 번 하시는 것을 권장).
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/media/settings-panel.js b/media/settings-panel.js
new file mode 100644
index 0000000..2a07066
--- /dev/null
+++ b/media/settings-panel.js
@@ -0,0 +1,270 @@
+(function () {
+ const vscode = acquireVsCodeApi();
+ const $ = (id) => document.getElementById(id);
+
+ // ---- Telegram ----
+ const tokenInput = $('tgToken');
+ const saveBtn = $('tgSaveToken');
+ const clearBtn = $('tgClearToken');
+ const testBtn = $('tgTest');
+ const enabledChk = $('tgEnabled');
+ const enrollBtn = $('tgEnroll');
+ const enrollCancelBtn = $('tgEnrollCancel');
+ const enrollStatus = $('tgEnrollStatus');
+ const tokenStatus = $('tgTokenStatus');
+ const botName = $('tgBotName');
+ const tgFeedback = $('tgFeedback');
+ const tgError = $('tgError');
+ const chatList = $('tgChatIds');
+
+ // ---- Connection ----
+ const cnUrl = $('cnUrl');
+ const cnModel = $('cnModel');
+ const cnTimeout = $('cnTimeout');
+ const cnRefreshModels = $('cnRefreshModels');
+ const cnModelHint = $('cnModelHint');
+
+ // ---- Memory ----
+ const memEnabled = $('memEnabled');
+ const memShort = $('memShort');
+ const memMid = $('memMid');
+ const memLong = $('memLong');
+
+ // ---- Brain ----
+ const brainName = $('brainName');
+ const brainPath = $('brainPath');
+ const brainAutoPush = $('brainAutoPush');
+
+ // ---- Advanced ----
+ const advDryRun = $('advDryRun');
+ const advMulti = $('advMulti');
+ const advAutoSteps = $('advAutoSteps');
+ const advCtxSize = $('advCtxSize');
+
+ // ---- Banner ----
+ const bannerError = $('bannerError');
+
+ // ---- Telegram listeners ----
+ saveBtn.addEventListener('click', () => {
+ const t = (tokenInput.value || '').trim();
+ if (!t) return;
+ vscode.postMessage({ type: 'telegram.saveToken', token: t });
+ tokenInput.value = '';
+ });
+ clearBtn.addEventListener('click', () => vscode.postMessage({ type: 'telegram.clearToken' }));
+ testBtn.addEventListener('click', () => vscode.postMessage({ type: 'telegram.testConnection' }));
+ enabledChk.addEventListener('change', (e) =>
+ vscode.postMessage({ type: 'telegram.toggleEnabled', enabled: e.target.checked })
+ );
+ enrollBtn.addEventListener('click', () => vscode.postMessage({ type: 'telegram.enroll' }));
+ enrollCancelBtn.addEventListener('click', () => vscode.postMessage({ type: 'telegram.cancelEnroll' }));
+ chatList.addEventListener('click', (e) => {
+ if (!(e.target instanceof HTMLElement)) return;
+ if (e.target.classList.contains('remove')) {
+ const id = Number(e.target.dataset.id);
+ if (Number.isFinite(id)) vscode.postMessage({ type: 'telegram.removeChatId', chatId: id });
+ }
+ });
+
+ // ---- Connection listeners ----
+ document.querySelector('[data-save="connection.url"]').addEventListener('click', () =>
+ vscode.postMessage({ type: 'connection.update', ollamaUrl: cnUrl.value })
+ );
+ document.querySelector('[data-save="connection.model"]').addEventListener('click', () =>
+ vscode.postMessage({ type: 'connection.update', defaultModel: cnModel.value })
+ );
+ cnRefreshModels.addEventListener('click', () =>
+ vscode.postMessage({ type: 'connection.update', refreshModels: true })
+ );
+ cnModel.addEventListener('change', () =>
+ vscode.postMessage({ type: 'connection.update', defaultModel: cnModel.value })
+ );
+ document.querySelector('[data-save="connection.timeout"]').addEventListener('click', () =>
+ vscode.postMessage({ type: 'connection.update', requestTimeout: Number(cnTimeout.value) })
+ );
+
+ // ---- Memory listeners ----
+ memEnabled.addEventListener('change', (e) =>
+ vscode.postMessage({ type: 'memory.update', memoryEnabled: e.target.checked })
+ );
+ document.querySelector('[data-save="memory.short"]').addEventListener('click', () =>
+ vscode.postMessage({ type: 'memory.update', memoryShortTermMessages: Number(memShort.value) })
+ );
+ document.querySelector('[data-save="memory.mid"]').addEventListener('click', () =>
+ vscode.postMessage({ type: 'memory.update', memoryMediumTermSessions: Number(memMid.value) })
+ );
+ document.querySelector('[data-save="memory.long"]').addEventListener('click', () =>
+ vscode.postMessage({ type: 'memory.update', memoryLongTermFiles: Number(memLong.value) })
+ );
+
+ // ---- Brain listeners ----
+ brainAutoPush.addEventListener('change', (e) =>
+ vscode.postMessage({ type: 'brain.update', autoPushBrain: e.target.checked })
+ );
+ $('brainOpenSettings').addEventListener('click', () =>
+ vscode.postMessage({ type: 'openVscodeSettings' })
+ );
+
+ // ---- Advanced listeners ----
+ advDryRun.addEventListener('change', (e) =>
+ vscode.postMessage({ type: 'advanced.update', dryRun: e.target.checked })
+ );
+ advMulti.addEventListener('change', (e) =>
+ vscode.postMessage({ type: 'advanced.update', multiAgentEnabled: e.target.checked })
+ );
+ document.querySelector('[data-save="advanced.autoSteps"]').addEventListener('click', () =>
+ vscode.postMessage({ type: 'advanced.update', maxAutoSteps: Number(advAutoSteps.value) })
+ );
+ document.querySelector('[data-save="advanced.ctxSize"]').addEventListener('click', () =>
+ vscode.postMessage({ type: 'advanced.update', maxContextSize: Number(advCtxSize.value) })
+ );
+
+ // ---- Header ----
+ $('openVscodeSettings').addEventListener('click', () =>
+ vscode.postMessage({ type: 'openVscodeSettings' })
+ );
+
+ // ---- State sync ----
+ window.addEventListener('message', (e) => {
+ const msg = e.data;
+ if (!msg || msg.type !== 'state') return;
+ renderState(msg.value);
+ });
+
+ /** Set input.value only when the field is not currently focused, so user edits aren't clobbered mid-typing. */
+ function setIfNotFocused(input, value) {
+ if (document.activeElement === input) return;
+ const next = value === undefined || value === null ? '' : String(value);
+ if (input.value !== next) input.value = next;
+ }
+
+ function renderState(state) {
+ // ---- Banner ----
+ if (state.bannerError) {
+ bannerError.hidden = false;
+ bannerError.textContent = state.bannerError;
+ } else {
+ bannerError.hidden = true;
+ bannerError.textContent = '';
+ }
+
+ // ---- Telegram ----
+ const tg = state.telegram;
+ tokenStatus.textContent = tg.hasToken ? '저장된 토큰이 있습니다.' : '아직 토큰이 등록되지 않았습니다.';
+ clearBtn.disabled = !tg.hasToken;
+
+ if (tg.botName) {
+ botName.textContent = `연결됨: ${tg.botName}` + (tg.connected ? ' · 폴링 중' : ' · 비활성화 상태');
+ botName.classList.toggle('ok', tg.connected);
+ } else {
+ botName.textContent = '';
+ botName.classList.remove('ok');
+ }
+
+ enabledChk.checked = !!tg.enabled;
+ enabledChk.disabled = !tg.hasToken;
+
+ if (tg.enrolling) {
+ enrollBtn.hidden = true;
+ enrollCancelBtn.hidden = false;
+ enrollStatus.textContent = '봇에게 메시지를 한 번 보내주세요. 다음 메시지의 채널이 자동 등록됩니다.';
+ } else {
+ enrollBtn.hidden = false;
+ enrollCancelBtn.hidden = true;
+ enrollStatus.textContent = '';
+ }
+ enrollBtn.disabled = !tg.hasToken;
+
+ chatList.innerHTML = '';
+ if (!tg.allowedChatIds || tg.allowedChatIds.length === 0) {
+ const li = document.createElement('li');
+ li.className = 'empty';
+ li.textContent = '등록된 채널 없음';
+ chatList.appendChild(li);
+ } else {
+ for (const id of tg.allowedChatIds) {
+ const li = document.createElement('li');
+ li.textContent = String(id);
+ const x = document.createElement('span');
+ x.className = 'remove';
+ x.dataset.id = String(id);
+ x.textContent = '✕';
+ x.title = '허용 목록에서 제거';
+ li.appendChild(x);
+ chatList.appendChild(li);
+ }
+ }
+
+ if (tg.lastSuccess) {
+ tgFeedback.hidden = false;
+ tgFeedback.textContent = tg.lastSuccess;
+ } else {
+ tgFeedback.hidden = true;
+ tgFeedback.textContent = '';
+ }
+ if (tg.lastError) {
+ tgError.hidden = false;
+ tgError.textContent = tg.lastError;
+ } else {
+ tgError.hidden = true;
+ tgError.textContent = '';
+ }
+
+ // ---- Connection ----
+ const cn = state.connection;
+ setIfNotFocused(cnUrl, cn.ollamaUrl);
+ setIfNotFocused(cnTimeout, cn.requestTimeout);
+
+ // Model dropdown — preserve selection, allow current value even if not in list
+ const wantedModel = cn.defaultModel || '';
+ const list = Array.isArray(cn.availableModels) ? cn.availableModels.slice() : [];
+ if (wantedModel && !list.includes(wantedModel)) list.unshift(wantedModel);
+ // Only repaint when list actually changed (otherwise we lose the user's
+ // open dropdown state).
+ const currentOptions = Array.from(cnModel.options).map((o) => o.value);
+ const listChanged = currentOptions.length !== list.length
+ || currentOptions.some((v, i) => v !== list[i]);
+ if (listChanged) {
+ cnModel.innerHTML = '';
+ for (const m of list) {
+ const opt = document.createElement('option');
+ opt.value = m;
+ opt.textContent = m;
+ cnModel.appendChild(opt);
+ }
+ if (list.length === 0) {
+ const opt = document.createElement('option');
+ opt.value = '';
+ opt.textContent = '(모델 없음 — 엔진 연결 확인)';
+ opt.disabled = true;
+ cnModel.appendChild(opt);
+ }
+ }
+ cnModel.value = wantedModel;
+ cnModelHint.textContent = cn.modelsLoading
+ ? '모델 목록 가져오는 중…'
+ : `사이드바에서 선택한 모델이 여기에도 동기화됩니다. (${list.length}개 발견)`;
+
+ // ---- Memory ----
+ const mem = state.memory;
+ memEnabled.checked = !!mem.memoryEnabled;
+ setIfNotFocused(memShort, mem.memoryShortTermMessages);
+ setIfNotFocused(memMid, mem.memoryMediumTermSessions);
+ setIfNotFocused(memLong, mem.memoryLongTermFiles);
+
+ // ---- Brain ----
+ const br = state.brain;
+ brainName.textContent = br.activeBrainName + (br.profileCount > 0 ? ` (전체 ${br.profileCount}개)` : '');
+ brainPath.textContent = br.activeBrainPath || '경로 없음';
+ brainAutoPush.checked = !!br.autoPushBrain;
+
+ // ---- Advanced ----
+ const adv = state.advanced;
+ advDryRun.checked = !!adv.dryRun;
+ advMulti.checked = !!adv.multiAgentEnabled;
+ setIfNotFocused(advAutoSteps, adv.maxAutoSteps);
+ setIfNotFocused(advCtxSize, adv.maxContextSize);
+ }
+
+ vscode.postMessage({ type: 'ready' });
+})();
diff --git a/media/sidebar.js b/media/sidebar.js
index 99aaba5..a5fd645 100644
--- a/media/sidebar.js
+++ b/media/sidebar.js
@@ -311,8 +311,12 @@
const _preferredModel = (_savedModel && msg.value.models.includes(_savedModel))
? _savedModel
: msg.value.selected;
+ const _loadedSet = new Set(Array.isArray(msg.value.loadedModels) ? msg.value.loadedModels : []);
msg.value.models.forEach(m => {
- const o = document.createElement('option'); o.value = m; o.innerText = m;
+ const o = document.createElement('option');
+ o.value = m;
+ // ● = 현재 LM Studio 메모리에 로드된 모델 / ○ = 다운로드만 됨
+ o.innerText = _loadedSet.has(m) ? `● ${m}` : m;
if (m === _preferredModel) o.selected = true;
modelSel.appendChild(o);
});
diff --git a/package-lock.json b/package-lock.json
index 48fee06..fa4e6d0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "astra",
- "version": "2.80.18",
+ "version": "2.80.21",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "astra",
- "version": "2.80.18",
+ "version": "2.80.21",
"license": "MIT",
"dependencies": {
"@lmstudio/sdk": "^1.5.0",
diff --git a/package.json b/package.json
index 6ab869f..41a8743 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "astra",
"displayName": "Astra",
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
- "version": "2.80.19",
+ "version": "2.80.27",
"publisher": "g1nation",
"license": "MIT",
"icon": "assets/icon.png",
@@ -55,6 +55,30 @@
{
"command": "g1nation.showBrainNetwork",
"title": "Astra: Show Brain Topology"
+ },
+ {
+ "command": "g1nation.approval.focus",
+ "title": "Astra: Focus Approval Panel"
+ },
+ {
+ "command": "g1nation.scaffoldProject",
+ "title": "Astra: Scaffold New Project"
+ },
+ {
+ "command": "g1nation.telegram.setBotToken",
+ "title": "Astra: Set Telegram Bot Token"
+ },
+ {
+ "command": "g1nation.telegram.clearBotToken",
+ "title": "Astra: Clear Telegram Bot Token"
+ },
+ {
+ "command": "g1nation.telegram.testConnection",
+ "title": "Astra: Test Telegram Connection"
+ },
+ {
+ "command": "g1nation.settings.focus",
+ "title": "Astra: Open Settings Panel"
}
],
"keybindings": [
@@ -89,6 +113,18 @@
"id": "g1nation-v2-view",
"name": "Chat",
"icon": "assets/icon.png"
+ },
+ {
+ "type": "webview",
+ "id": "g1nation-approval-panel",
+ "name": "Pending Approvals",
+ "icon": "$(check)"
+ },
+ {
+ "type": "webview",
+ "id": "g1nation-settings-panel",
+ "name": "Settings",
+ "icon": "$(gear)"
}
]
},
@@ -213,6 +249,17 @@
"type": "boolean",
"default": false,
"description": "If enabled, the agent will ask for approval before committing any file changes."
+ },
+ "g1nation.telegram.enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Enable the Telegram bot integration. When on, Astra polls a bot you configure and replies to incoming messages. Off by default — Astra remains 100% local until you opt in."
+ },
+ "g1nation.telegram.allowedChatIds": {
+ "type": "array",
+ "default": [],
+ "items": { "type": "number" },
+ "description": "Optional allowlist of Telegram chat IDs that may message the bot. When empty, every chat that messages the bot is accepted (use with caution)."
}
}
}
diff --git a/src/agent.ts b/src/agent.ts
index 52aa1f0..e3c3e96 100644
--- a/src/agent.ts
+++ b/src/agent.ts
@@ -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(/[\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(/(?:|)[\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|>`,
+ // `<|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 {
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();
diff --git a/src/bridge.ts b/src/bridge.ts
index 78855d4..86683b0 100644
--- a/src/bridge.ts
+++ b/src/bridge.ts
@@ -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 }));
+ }
+ }
}
diff --git a/src/core/transaction.ts b/src/core/transaction.ts
index 2a1f751..3bdb3cf 100644
--- a/src/core/transaction.ts
+++ b/src/core/transaction.ts
@@ -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
diff --git a/src/extension.ts b/src/extension.ts
index 4429d84..3a0c8af 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -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('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('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);
diff --git a/src/features/approval/approvalPanelProvider.ts b/src/features/approval/approvalPanelProvider.ts
new file mode 100644
index 0000000..3fac639
--- /dev/null
+++ b/src/features/approval/approvalPanelProvider.ts
@@ -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 `
+
+
+
+
+
+
+
+ ${body}
+
+
+`;
+ }
+
+ private _renderEmpty(): string {
+ return `대기 중인 승인이 없습니다.
`;
+ }
+
+ private _renderApproval(a: Approval): string {
+ const filesHtml = a.files.length === 0
+ ? '파일 변경 없음'
+ : a.files.map(f => `변경${this._escape(f)}`).join('');
+ const elapsed = Math.max(0, Math.floor((Date.now() - a.createdAt) / 1000));
+ return `
+
+
${this._escape(a.title)}
+
${this._escape(a.summary)} · ${elapsed}초 전
+
+
+
+
+
+
`;
+ }
+
+ private _escape(s: string): string {
+ return String(s).replace(/[&<>"']/g, ch => (
+ ch === '&' ? '&' :
+ ch === '<' ? '<' :
+ ch === '>' ? '>' :
+ ch === '"' ? '"' : '''
+ ));
+ }
+}
diff --git a/src/features/approval/approvalQueue.ts b/src/features/approval/approvalQueue.ts
new file mode 100644
index 0000000..f6a780e
--- /dev/null
+++ b/src/features/approval/approvalQueue.ts
@@ -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;
+ reject: () => Promise | void;
+}
+
+interface ApprovalEntry {
+ approval: Approval;
+ callbacks: ApprovalCallbacks;
+}
+
+export class ApprovalQueue {
+ private _current: ApprovalEntry | null = null;
+ private readonly _emitter = new vscode.EventEmitter();
+ /** 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 {
+ 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 {
+ 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;
+ }
+}
diff --git a/src/features/approval/approvalStatusBar.ts b/src/features/approval/approvalStatusBar.ts
new file mode 100644
index 0000000..b28cfc3
--- /dev/null
+++ b/src/features/approval/approvalStatusBar.ts
@@ -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();
+ }
+}
diff --git a/src/features/settings/settingsPanelProvider.ts b/src/features/settings/settingsPanelProvider.ts
new file mode 100644
index 0000000..b2cbb0e
--- /dev/null
+++ b/src/features/settings/settingsPanelProvider.ts
@@ -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 {
+ // 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 {
+ 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 {
+ 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('ollamaUrl', '') || '').trim();
+ if (this._modelsCache && this._modelsCache.url !== liveUrl) {
+ this._modelsCache = undefined;
+ void this._fetchModelsAndRefresh();
+ }
+ }
+
+ private async _handleMessage(msg: any): Promise {
+ 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 {
+ 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 {
+ 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 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 {
+ 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 {
+ 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('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('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 {
+ if (!Number.isFinite(chatId)) return;
+ const cfg = vscode.workspace.getConfiguration('g1nation');
+ const existing = cfg.get('telegram.allowedChatIds', []) || [];
+ const next = existing.filter((id) => id !== chatId);
+ await this._safeConfigUpdate('telegram.allowedChatIds', next);
+ }
+
+ private async _handleConnectionUpdate(msg: any): Promise {
+ 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 {
+ const cfg = vscode.workspace.getConfiguration('g1nation');
+ const url = (cfg.get('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 {
+ if (typeof msg.memoryEnabled === 'boolean') {
+ await this._safeConfigUpdate('memoryEnabled', msg.memoryEnabled);
+ }
+ const numericFields: Array = [
+ '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 {
+ 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 {
+ 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 {
+ 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('brainProfiles', []) || []) as Array<{
+ id?: string; name?: string; localBrainPath?: string;
+ }>;
+ const activeBrainId = cfg.get('activeBrainId', '') || '';
+ const activeProfile = profiles.find((p) => p.id === activeBrainId) || profiles[0];
+
+ const state: SettingsState = {
+ telegram: {
+ hasToken: !!token.trim(),
+ enabled: cfg.get('telegram.enabled', false),
+ connected: !!this._botName && this._deps.telegramBot.isRunning(),
+ botName: this._botName,
+ allowedChatIds: cfg.get('telegram.allowedChatIds', []) || [],
+ enrolling: this._enrolling,
+ lastError: this._lastError,
+ lastSuccess: this._lastSuccess,
+ },
+ connection: {
+ ollamaUrl: cfg.get('ollamaUrl', '') || '',
+ defaultModel: cfg.get('defaultModel', '') || '',
+ requestTimeout: cfg.get('requestTimeout', 300) ?? 300,
+ availableModels: this._modelsCache?.models ?? [],
+ modelsLoading: this._modelsLoading,
+ },
+ memory: {
+ memoryEnabled: cfg.get('memoryEnabled', true),
+ memoryShortTermMessages: cfg.get('memoryShortTermMessages', 8) ?? 8,
+ memoryMediumTermSessions: cfg.get('memoryMediumTermSessions', 5) ?? 5,
+ memoryLongTermFiles: cfg.get('memoryLongTermFiles', 6) ?? 6,
+ },
+ brain: {
+ activeBrainId,
+ activeBrainName: activeProfile?.name || '(없음)',
+ activeBrainPath: activeProfile?.localBrainPath || '',
+ profileCount: profiles.length,
+ autoPushBrain: cfg.get('autoPushBrain', false),
+ },
+ advanced: {
+ dryRun: cfg.get('dryRun', false),
+ multiAgentEnabled: cfg.get('multiAgentEnabled', false),
+ maxAutoSteps: cfg.get('maxAutoSteps', 50) ?? 50,
+ maxContextSize: cfg.get('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;
diff --git a/src/integrations/telegram/telegramBot.ts b/src/integrations/telegram/telegramBot.ts
new file mode 100644
index 0000000..4d2691f
--- /dev/null
+++ b/src/integrations/telegram/telegramBot.ts
@@ -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`. Returning
+ * null suppresses the reply (e.g. for ignored messages).
+ */
+
+export type TelegramMessageHandler = (text: string, chatId: number) => Promise;
+
+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;
+}
+
+const defaultSleep = (ms: number) =>
+ new Promise((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 | 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 {
+ if (this._enrollPending) {
+ this._enrollPending.reject(new Error('Superseded by a new enrollment request.'));
+ this._enrollPending = undefined;
+ }
+ return new Promise((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 {
+ 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 {
+ 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 {
+ 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) });
+ }
+ }
+}
diff --git a/src/integrations/telegram/telegramClient.ts b/src/integrations/telegram/telegramClient.ts
new file mode 100644
index 0000000..f48e878
--- /dev/null
+++ b/src/integrations/telegram/telegramClient.ts
@@ -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;
+ getUpdates(opts: GetUpdatesOptions): Promise;
+ sendMessage(opts: SendMessageOptions): Promise;
+}
+
+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 {
+ return this._call('getMe', undefined, signal);
+ }
+
+ async getUpdates(opts: GetUpdatesOptions): Promise {
+ const body: Record = {
+ timeout: Math.max(0, Math.min(opts.timeoutSec ?? 25, 50)),
+ };
+ if (typeof opts.offset === 'number') body.offset = opts.offset;
+ return this._call('getUpdates', body, opts.signal);
+ }
+
+ async sendMessage(opts: SendMessageOptions): Promise {
+ const text = truncateForTelegram(opts.text);
+ const body: Record = {
+ 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('sendMessage', body, opts.signal);
+ }
+
+ private async _call(
+ method: string,
+ body: Record | undefined,
+ signal?: AbortSignal
+ ): Promise {
+ 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;
+ try {
+ parsed = (await response.json()) as TelegramApiResponse;
+ } 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;
+}
diff --git a/src/integrations/telegram/types.ts b/src/integrations/telegram/types.ts
new file mode 100644
index 0000000..334ad3a
--- /dev/null
+++ b/src/integrations/telegram/types.ts
@@ -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 {
+ ok: true;
+ result: T;
+}
+
+export interface TelegramApiError {
+ ok: false;
+ error_code: number;
+ description: string;
+}
+
+export type TelegramApiResponse = TelegramApiSuccess | TelegramApiError;
+
+/** Maximum bytes per Telegram message payload (the API caps text at 4096 chars). */
+export const TELEGRAM_MAX_TEXT_LENGTH = 4096;
diff --git a/src/lib/discoverModels.ts b/src/lib/discoverModels.ts
new file mode 100644
index 0000000..98fb83e
--- /dev/null
+++ b/src/lib/discoverModels.ts
@@ -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 {
+ 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 [];
+ }
+}
diff --git a/src/lib/paths.ts b/src/lib/paths.ts
new file mode 100644
index 0000000..7849bdd
--- /dev/null
+++ b/src/lib/paths.ts
@@ -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(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;
+} {
+ 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 };
+}
diff --git a/src/lmstudio/client.ts b/src/lmstudio/client.ts
index 16495a9..45e576a 100644
--- a/src/lmstudio/client.ts
+++ b/src/lmstudio/client.ts
@@ -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;
unload(modelKey: string): Promise;
listLoaded(): Promise;
+ /** Like listLoaded() but caches the result for `ttlMs` to avoid hammering the SDK. */
+ listLoadedCached(ttlMs?: number): Promise;
+ /** Resolve a chat-ready handle for an already-loaded (or just-loaded) model. */
+ getModelHandle(modelKey: string): Promise;
isReachable(): Promise;
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 {
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 {
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 {
+ 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 {
+ 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 {
try {
await this.getSdk().llm.listLoaded();
diff --git a/src/lmstudio/lifecycleManager.ts b/src/lmstudio/lifecycleManager.ts
index e434494..171bac7 100644
--- a/src/lmstudio/lifecycleManager.ts
+++ b/src/lmstudio/lifecycleManager.ts
@@ -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 {
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();
diff --git a/src/lmstudio/streamer.ts b/src/lmstudio/streamer.ts
new file mode 100644
index 0000000..2448704
--- /dev/null
+++ b/src/lmstudio/streamer.ts
@@ -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;
+ }
+ }
+}
diff --git a/src/scaffolder/projectScaffolder.ts b/src/scaffolder/projectScaffolder.ts
new file mode 100644
index 0000000..65918b1
--- /dev/null
+++ b/src/scaffolder/projectScaffolder.ts
@@ -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: 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;
+ 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;
+}
+
+export class FileSystemProjectScaffolder implements IProjectScaffolder {
+ private readonly _fs: NonNullable;
+
+ constructor(deps: ScaffolderDeps = {}) {
+ this._fs = deps.fsImpl ?? fs;
+ }
+
+ listTemplates(): ProjectTemplate[] {
+ return TEMPLATES.slice();
+ }
+
+ async scaffold(req: ScaffoldRequest): Promise {
+ 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 };
+ }
+}
diff --git a/src/scaffolder/templates.ts b/src/scaffolder/templates.ts
new file mode 100644
index 0000000..8f226cc
--- /dev/null
+++ b/src/scaffolder/templates.ts
@@ -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;
+}
+
+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':
+`
+
+
+
+
+${name}
+
+
+
+
+ ${name}
+ Astra · Project Scaffolder
+
+
+
+`,
+ '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':
+`
+
+
+
+${name}
+
+
+${name}
+
+
+
+`,
+ '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':
+`
+
+${name}
+
+
+
+
+
+`,
+ 'site/src/main.tsx':
+`import React from 'react';
+import { createRoot } from 'react-dom/client';
+
+function App() {
+ return ${name}
;
+}
+
+createRoot(document.getElementById('root')!).render();
+`,
+ 'README.md': README(name, 'vite-react'),
+ }),
+ },
+];
+
+export function findTemplate(id: string): ProjectTemplate | undefined {
+ return TEMPLATES.find(t => t.id === id);
+}
diff --git a/src/sidebar/chatHandlers.ts b/src/sidebar/chatHandlers.ts
index bb6ab1e..9a756a5 100644
--- a/src/sidebar/chatHandlers.ts
+++ b/src/sidebar/chatHandlers.ts
@@ -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;
diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts
index ffc33f9..8f3b22c 100644
--- a/src/sidebarProvider.ts
+++ b/src/sidebarProvider.ts
@@ -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;
}
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 {
diff --git a/src/skills/skillInjectionService.ts b/src/skills/skillInjectionService.ts
new file mode 100644
index 0000000..6fbad2e
--- /dev/null
+++ b/src/skills/skillInjectionService.ts
@@ -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 ``
+ * them. ConnectAI doesn't run Python tools — its agent skills are markdown
+ * documents loaded by the sidebar (`/.agent/skills/.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/.md — markdown body
+ * .agent/skills/.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;
+}
+
+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;
+ /** 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;
+
+ constructor(private readonly deps: SkillInjectionDeps) {
+ this._fs = deps.fsImpl ?? fs;
+ }
+
+ async inject(req: SkillInjectionRequest): Promise {
+ 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;
+ }
+}
diff --git a/src/system/specs.ts b/src/system/specs.ts
new file mode 100644
index 0000000..26db616
--- /dev/null
+++ b/src/system/specs.ts
@@ -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;
+ }
+}
diff --git a/tests/approvalQueue.test.ts b/tests/approvalQueue.test.ts
new file mode 100644
index 0000000..f978a89
--- /dev/null
+++ b/tests/approvalQueue.test.ts
@@ -0,0 +1,164 @@
+/**
+ * Unit tests for ApprovalQueue.
+ *
+ * Strategy: drive enqueue → approve / reject / clear / pre-empt directly,
+ * confirm the onChange event fires at the right moments and callbacks fire
+ * exactly once.
+ */
+
+import { ApprovalQueue, Approval } from '../src/features/approval/approvalQueue';
+
+function makeApproval(id: string = 'txn-1'): Approval {
+ return {
+ id,
+ kind: 'transaction',
+ title: 'Pending file changes',
+ summary: '2 files',
+ files: ['/tmp/a.ts', '/tmp/b.ts'],
+ createdAt: Date.now(),
+ };
+}
+
+describe('ApprovalQueue', () => {
+ test('starts empty', () => {
+ const q = new ApprovalQueue();
+ expect(q.current()).toBeNull();
+ expect(q.pendingCount()).toBe(0);
+ });
+
+ test('enqueue sets current and fires onChange', () => {
+ const q = new ApprovalQueue();
+ let fired = 0;
+ q.onChange(() => fired++);
+ q.enqueue(makeApproval(), { approve: () => {}, reject: () => {} });
+ expect(q.pendingCount()).toBe(1);
+ expect(q.current()?.id).toBe('txn-1');
+ expect(fired).toBe(1);
+ });
+
+ test('approve invokes the approve callback exactly once and clears state', async () => {
+ const q = new ApprovalQueue();
+ let approveCount = 0;
+ let rejectCount = 0;
+ q.enqueue(makeApproval(), {
+ approve: () => { approveCount++; },
+ reject: () => { rejectCount++; },
+ });
+ await q.approve('txn-1');
+ expect(approveCount).toBe(1);
+ expect(rejectCount).toBe(0);
+ expect(q.current()).toBeNull();
+ // Idempotent — second approve does nothing.
+ await q.approve('txn-1');
+ expect(approveCount).toBe(1);
+ });
+
+ test('reject invokes the reject callback exactly once', async () => {
+ const q = new ApprovalQueue();
+ let approveCount = 0;
+ let rejectCount = 0;
+ q.enqueue(makeApproval(), {
+ approve: () => { approveCount++; },
+ reject: () => { rejectCount++; },
+ });
+ await q.reject('txn-1');
+ expect(rejectCount).toBe(1);
+ expect(approveCount).toBe(0);
+ expect(q.current()).toBeNull();
+ });
+
+ test('mismatched id is ignored — protects against stale webview button clicks', async () => {
+ const q = new ApprovalQueue();
+ let count = 0;
+ q.enqueue(makeApproval('txn-1'), {
+ approve: () => { count++; },
+ reject: () => { count++; },
+ });
+ await q.approve('txn-OLD');
+ expect(count).toBe(0);
+ expect(q.current()?.id).toBe('txn-1');
+ });
+
+ test('approve/reject without id picks current', async () => {
+ const q = new ApprovalQueue();
+ let approveCount = 0;
+ q.enqueue(makeApproval(), { approve: () => { approveCount++; }, reject: () => {} });
+ await q.approve();
+ expect(approveCount).toBe(1);
+ });
+
+ test('enqueue while pending pre-empts the previous one without firing its callbacks', () => {
+ const q = new ApprovalQueue();
+ let oldApprove = 0, oldReject = 0;
+ q.enqueue(makeApproval('old'), {
+ approve: () => { oldApprove++; },
+ reject: () => { oldReject++; },
+ });
+ let newApprove = 0;
+ q.enqueue(makeApproval('new'), {
+ approve: () => { newApprove++; },
+ reject: () => {},
+ });
+ expect(q.current()?.id).toBe('new');
+ expect(oldApprove).toBe(0);
+ expect(oldReject).toBe(0);
+ // Approving "new" must hit only the new callback.
+ return q.approve('new').then(() => {
+ expect(newApprove).toBe(1);
+ expect(oldApprove).toBe(0);
+ });
+ });
+
+ test('clear() resets without firing callbacks', () => {
+ const q = new ApprovalQueue();
+ let cb = 0;
+ q.enqueue(makeApproval(), { approve: () => { cb++; }, reject: () => { cb++; } });
+ q.clear();
+ expect(q.current()).toBeNull();
+ expect(cb).toBe(0);
+ });
+
+ test('onChange fires on enqueue, approve, reject, clear', async () => {
+ const q = new ApprovalQueue();
+ const events: string[] = [];
+ q.onChange(() => events.push(`change-${q.pendingCount()}`));
+ q.enqueue(makeApproval('a'), { approve: () => {}, reject: () => {} });
+ await q.approve('a');
+ q.enqueue(makeApproval('b'), { approve: () => {}, reject: () => {} });
+ await q.reject('b');
+ q.enqueue(makeApproval('c'), { approve: () => {}, reject: () => {} });
+ q.clear();
+ expect(events).toEqual([
+ 'change-1', 'change-0', // enqueue a, approve a
+ 'change-1', 'change-0', // enqueue b, reject b
+ 'change-1', 'change-0', // enqueue c, clear
+ ]);
+ });
+
+ test('callback exception is swallowed (next enqueue still works)', async () => {
+ const q = new ApprovalQueue();
+ q.enqueue(makeApproval('boom'), {
+ approve: () => { throw new Error('callback boom'); },
+ reject: () => {},
+ });
+ await q.approve('boom');
+ expect(q.current()).toBeNull();
+ // Verify we can still operate the queue afterwards.
+ let next = 0;
+ q.enqueue(makeApproval('next'), { approve: () => { next++; }, reject: () => {} });
+ await q.approve('next');
+ expect(next).toBe(1);
+ });
+
+ test('dispose drops state and prevents further events', () => {
+ const q = new ApprovalQueue();
+ let fired = 0;
+ q.onChange(() => fired++);
+ q.enqueue(makeApproval(), { approve: () => {}, reject: () => {} });
+ expect(fired).toBe(1);
+ q.dispose();
+ expect(q.current()).toBeNull();
+ // Re-enqueueing after dispose: the emitter is disposed but should not crash.
+ // The contract is "dispose terminates the queue" — callers shouldn't reuse it.
+ });
+});
diff --git a/tests/lmStudioLifecycle.test.ts b/tests/lmStudioLifecycle.test.ts
index 6cc759e..c1c2d73 100644
--- a/tests/lmStudioLifecycle.test.ts
+++ b/tests/lmStudioLifecycle.test.ts
@@ -73,6 +73,14 @@ class FakeLMStudioClient implements ILMStudioClient {
async isReachable(): Promise {
return true;
}
+
+ async listLoadedCached(): Promise {
+ return [];
+ }
+
+ async getModelHandle(_modelKey: string): Promise {
+ return {};
+ }
}
function makeManager(overrides: Partial = {}, depOverrides: Partial = {}) {
diff --git a/tests/lmStudioStreamer.test.ts b/tests/lmStudioStreamer.test.ts
new file mode 100644
index 0000000..1a7d5cd
--- /dev/null
+++ b/tests/lmStudioStreamer.test.ts
@@ -0,0 +1,185 @@
+/**
+ * Unit tests for LMStudioStreamer.
+ *
+ * Strategy: inject a fake ILMStudioClient that returns a fake model handle whose
+ * `respond()` yields a controllable async iterable. No real SDK or WebSocket touched.
+ */
+
+import { LMStudioStreamer } from '../src/lmstudio/streamer';
+import type { ILMStudioClient } from '../src/lmstudio/client';
+
+class FakeModel {
+ public lastChat: any = null;
+ public lastOpts: any = null;
+ public cancelCount = 0;
+ public failNext: Error | null = null;
+ public chunks: string[] = [];
+
+ constructor(opts: { chunks?: string[]; failAfter?: number; throwOnRespond?: Error } = {}) {
+ this.chunks = opts.chunks ?? ['Hel', 'lo, ', 'world'];
+ this._failAfter = opts.failAfter;
+ this._throwOnRespond = opts.throwOnRespond;
+ }
+
+ private _failAfter?: number;
+ private _throwOnRespond?: Error;
+
+ respond(chat: any, opts: any) {
+ if (this._throwOnRespond) {
+ throw this._throwOnRespond;
+ }
+ this.lastChat = chat;
+ this.lastOpts = opts;
+ const chunks = this.chunks;
+ const failAfter = this._failAfter;
+ let i = 0;
+ const self = this;
+ return {
+ cancel: async () => { self.cancelCount++; },
+ [Symbol.asyncIterator]() {
+ return {
+ async next() {
+ if (opts?.signal?.aborted) {
+ return { value: undefined, done: true };
+ }
+ if (failAfter !== undefined && i >= failAfter) {
+ throw new Error('mid-stream failure');
+ }
+ if (i >= chunks.length) {
+ return { value: undefined, done: true };
+ }
+ const fragment = { content: chunks[i++] };
+ return { value: fragment, done: false };
+ },
+ };
+ },
+ };
+ }
+}
+
+class FakeClient implements ILMStudioClient {
+ public model: FakeModel;
+ public getModelHandleCalls: string[] = [];
+
+ constructor(model: FakeModel = new FakeModel()) {
+ this.model = model;
+ }
+
+ setBaseUrl(_: string): void { /* noop */ }
+ async load(_: string): Promise { /* noop */ }
+ async unload(_: string): Promise { /* noop */ }
+ async listLoaded(): Promise { return []; }
+ async listLoadedCached(): Promise { return []; }
+ async isReachable(): Promise { return true; }
+
+ async getModelHandle(modelKey: string): Promise {
+ this.getModelHandleCalls.push(modelKey);
+ return this.model;
+ }
+}
+
+async function collect(stream: AsyncIterable<{ token: string }>): Promise {
+ const out: string[] = [];
+ for await (const { token } of stream) out.push(token);
+ return out;
+}
+
+describe('LMStudioStreamer', () => {
+ test('streams tokens from the SDK respond iterator', async () => {
+ const client = new FakeClient(new FakeModel({ chunks: ['Hel', 'lo'] }));
+ const streamer = new LMStudioStreamer(client);
+ const tokens = await collect(streamer.stream({
+ modelName: 'm1',
+ messages: [{ role: 'user', content: 'hi' }],
+ temperature: 0.4,
+ }));
+ expect(tokens).toEqual(['Hel', 'lo']);
+ expect(client.getModelHandleCalls).toEqual(['m1']);
+ expect(client.model.lastOpts.temperature).toBe(0.4);
+ });
+
+ test('passes signal through to the SDK', async () => {
+ const client = new FakeClient();
+ const streamer = new LMStudioStreamer(client);
+ const ac = new AbortController();
+ await collect(streamer.stream({
+ modelName: 'm1',
+ messages: [{ role: 'user', content: 'hi' }],
+ temperature: 0.2,
+ signal: ac.signal,
+ }));
+ expect(client.model.lastOpts.signal).toBe(ac.signal);
+ });
+
+ test('aborting mid-stream stops cleanly without throwing', async () => {
+ const client = new FakeClient(new FakeModel({ chunks: ['a', 'b', 'c', 'd'] }));
+ const streamer = new LMStudioStreamer(client);
+ const ac = new AbortController();
+ const out: string[] = [];
+ const iter = streamer.stream({
+ modelName: 'm1',
+ messages: [{ role: 'user', content: 'hi' }],
+ temperature: 0.3,
+ signal: ac.signal,
+ });
+ for await (const { token } of iter) {
+ out.push(token);
+ if (out.length === 2) ac.abort();
+ }
+ expect(out.length).toBeGreaterThanOrEqual(2);
+ expect(out.length).toBeLessThanOrEqual(3);
+ });
+
+ test('rejects when modelName is empty', async () => {
+ const client = new FakeClient();
+ const streamer = new LMStudioStreamer(client);
+ await expect(collect(streamer.stream({
+ modelName: '',
+ messages: [{ role: 'user', content: 'hi' }],
+ temperature: 0.2,
+ }))).rejects.toThrow(/without a model name/i);
+ });
+
+ test('mid-stream SDK failure is re-thrown when signal not aborted', async () => {
+ const client = new FakeClient(new FakeModel({ chunks: ['a', 'b'], failAfter: 1 }));
+ const streamer = new LMStudioStreamer(client);
+ await expect(collect(streamer.stream({
+ modelName: 'm1',
+ messages: [{ role: 'user', content: 'hi' }],
+ temperature: 0.2,
+ }))).rejects.toThrow(/mid-stream failure/);
+ });
+
+ test('mid-stream SDK failure swallowed if signal already aborted', async () => {
+ const client = new FakeClient(new FakeModel({ chunks: ['a', 'b'], failAfter: 1 }));
+ const streamer = new LMStudioStreamer(client);
+ const ac = new AbortController();
+ const iter = streamer.stream({
+ modelName: 'm1',
+ messages: [{ role: 'user', content: 'hi' }],
+ temperature: 0.2,
+ signal: ac.signal,
+ });
+ const out: string[] = [];
+ try {
+ for await (const { token } of iter) {
+ out.push(token);
+ ac.abort(); // abort right after first token, before failure point
+ }
+ } catch (e) {
+ // expected to be swallowed
+ }
+ expect(out).toEqual(['a']);
+ });
+
+ test('passes messages through to model.respond', async () => {
+ const client = new FakeClient();
+ const streamer = new LMStudioStreamer(client);
+ const messages = [
+ { role: 'system' as const, content: 'sys' },
+ { role: 'user' as const, content: 'hi' },
+ ];
+ await collect(streamer.stream({ modelName: 'm1', messages, temperature: 0.5 }));
+ expect(client.model.lastChat).toEqual(messages);
+ });
+});
diff --git a/tests/paths.test.ts b/tests/paths.test.ts
new file mode 100644
index 0000000..e5cc6ad
--- /dev/null
+++ b/tests/paths.test.ts
@@ -0,0 +1,84 @@
+/**
+ * Unit tests for the centralized path resolver.
+ */
+
+import * as os from 'os';
+import * as path from 'path';
+import {
+ expandTilde,
+ resolvePathInput,
+ isInside,
+} from '../src/lib/paths';
+
+describe('expandTilde', () => {
+ test('expands "~" to home', () => {
+ expect(expandTilde('~')).toBe(os.homedir());
+ });
+
+ test('expands "~/foo" to home/foo', () => {
+ expect(expandTilde('~/foo')).toBe(path.join(os.homedir(), 'foo'));
+ });
+
+ test('leaves absolute paths untouched', () => {
+ expect(expandTilde('/tmp/x')).toBe('/tmp/x');
+ });
+
+ test('returns empty for blank input', () => {
+ expect(expandTilde('')).toBe('');
+ expect(expandTilde(' ')).toBe('');
+ });
+});
+
+describe('resolvePathInput', () => {
+ test('accepts absolute paths', () => {
+ expect(resolvePathInput('/tmp/abc')).toBe(path.normalize('/tmp/abc'));
+ });
+
+ test('accepts ~/-prefixed paths after expansion', () => {
+ expect(resolvePathInput('~/notes')).toBe(path.normalize(path.join(os.homedir(), 'notes')));
+ });
+
+ test('rejects relative paths to prevent surprises', () => {
+ expect(resolvePathInput('relative/dir')).toBe('');
+ expect(resolvePathInput('./local')).toBe('');
+ });
+
+ test('returns empty on blank / undefined', () => {
+ expect(resolvePathInput('')).toBe('');
+ expect(resolvePathInput(undefined as any)).toBe('');
+ });
+});
+
+describe('isInside', () => {
+ test('a path is inside itself', () => {
+ expect(isInside('/tmp/a', '/tmp/a')).toBe(true);
+ });
+
+ test('detects direct descendants', () => {
+ expect(isInside('/tmp/a', '/tmp/a/b')).toBe(true);
+ });
+
+ test('detects deep descendants', () => {
+ expect(isInside('/tmp/a', '/tmp/a/b/c/d.txt')).toBe(true);
+ });
+
+ test('rejects siblings', () => {
+ expect(isInside('/tmp/a', '/tmp/b')).toBe(false);
+ });
+
+ test('rejects path-traversal escapes', () => {
+ // Even though string-prefix would say "/tmp/a/../b" starts with "/tmp/a/",
+ // path.resolve normalizes the dotdot back to the parent → not inside.
+ expect(isInside('/tmp/a', '/tmp/a/../b')).toBe(false);
+ });
+
+ test('rejects siblings whose name shares a prefix', () => {
+ // "/tmp/agents-evil" must not be considered inside "/tmp/agents".
+ expect(isInside('/tmp/agents', '/tmp/agents-evil')).toBe(false);
+ });
+
+ test('returns false on empty inputs', () => {
+ expect(isInside('', '/tmp/a')).toBe(false);
+ expect(isInside('/tmp/a', '')).toBe(false);
+ });
+});
diff --git a/tests/projectScaffolder.test.ts b/tests/projectScaffolder.test.ts
new file mode 100644
index 0000000..ebced61
--- /dev/null
+++ b/tests/projectScaffolder.test.ts
@@ -0,0 +1,135 @@
+/**
+ * Unit tests for FileSystemProjectScaffolder.
+ *
+ * Drives against a real temp directory so end-to-end file IO + path-traversal
+ * defenses are exercised.
+ */
+
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import {
+ FileSystemProjectScaffolder,
+ validateProjectName,
+} from '../src/scaffolder/projectScaffolder';
+
+function tmp(): string {
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-scaffold-test-'));
+}
+
+describe('validateProjectName', () => {
+ test('accepts allowed names', () => {
+ expect(validateProjectName('foo')).toBe('foo');
+ expect(validateProjectName('foo-bar_v2')).toBe('foo-bar_v2');
+ expect(validateProjectName(' trimmed ')).toBe('trimmed');
+ });
+
+ test('rejects too short', () => {
+ expect(validateProjectName('a')).toBeNull();
+ });
+
+ test('rejects too long', () => {
+ expect(validateProjectName('a'.repeat(41))).toBeNull();
+ });
+
+ test('rejects invalid chars', () => {
+ expect(validateProjectName('foo bar')).toBeNull();
+ expect(validateProjectName('foo/bar')).toBeNull();
+ expect(validateProjectName('foo.bar')).toBeNull();
+ expect(validateProjectName('한글이름')).toBeNull();
+ });
+
+ test('rejects empty / non-string', () => {
+ expect(validateProjectName('')).toBeNull();
+ expect(validateProjectName(undefined as any)).toBeNull();
+ });
+});
+
+describe('FileSystemProjectScaffolder', () => {
+ let root: string;
+ let scaffolder: FileSystemProjectScaffolder;
+
+ beforeEach(() => {
+ root = tmp();
+ scaffolder = new FileSystemProjectScaffolder();
+ });
+
+ afterEach(() => {
+ try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
+ });
+
+ test('listTemplates returns the catalog', () => {
+ const list = scaffolder.listTemplates();
+ const ids = list.map(t => t.id);
+ expect(ids).toContain('static');
+ expect(ids).toContain('vite-vanilla');
+ expect(ids).toContain('vite-react');
+ });
+
+ test('static template writes index.html + README.md', async () => {
+ const result = await scaffolder.scaffold({ name: 'demo-static', template: 'static', rootDir: root });
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(fs.existsSync(path.join(result.projectPath, 'site', 'index.html'))).toBe(true);
+ expect(fs.existsSync(path.join(result.projectPath, 'README.md'))).toBe(true);
+ const html = fs.readFileSync(path.join(result.projectPath, 'site', 'index.html'), 'utf8');
+ expect(html).toContain('demo-static');
+ });
+
+ test('vite-vanilla template writes package.json + main.js', async () => {
+ const result = await scaffolder.scaffold({ name: 'vv', template: 'vite-vanilla', rootDir: root });
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ const pkg = JSON.parse(fs.readFileSync(path.join(result.projectPath, 'site', 'package.json'), 'utf8'));
+ expect(pkg.name).toBe('vv');
+ expect(pkg.devDependencies.vite).toBeDefined();
+ expect(fs.existsSync(path.join(result.projectPath, 'site', 'main.js'))).toBe(true);
+ });
+
+ test('vite-react template includes tsconfig + main.tsx', async () => {
+ const result = await scaffolder.scaffold({ name: 'vr', template: 'vite-react', rootDir: root });
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(fs.existsSync(path.join(result.projectPath, 'site', 'tsconfig.json'))).toBe(true);
+ expect(fs.existsSync(path.join(result.projectPath, 'site', 'src', 'main.tsx'))).toBe(true);
+ const tsx = fs.readFileSync(path.join(result.projectPath, 'site', 'src', 'main.tsx'), 'utf8');
+ expect(tsx).toContain('vr
');
+ });
+
+ test('rejects invalid name with INVALID_NAME', async () => {
+ const result = await scaffolder.scaffold({ name: 'foo bar', template: 'static', rootDir: root });
+ expect(result).toEqual(expect.objectContaining({ ok: false, code: 'INVALID_NAME' }));
+ });
+
+ test('rejects unknown template with UNKNOWN_TEMPLATE', async () => {
+ const result = await scaffolder.scaffold({ name: 'foo', template: 'made-up' as any, rootDir: root });
+ expect(result).toEqual(expect.objectContaining({ ok: false, code: 'UNKNOWN_TEMPLATE' }));
+ });
+
+ test('rejects empty rootDir with NO_ROOT_DIR', async () => {
+ const result = await scaffolder.scaffold({ name: 'foo', template: 'static', rootDir: '' });
+ expect(result).toEqual(expect.objectContaining({ ok: false, code: 'NO_ROOT_DIR' }));
+ });
+
+ test('rejects relative rootDir with ROOT_NOT_ABSOLUTE', async () => {
+ const result = await scaffolder.scaffold({ name: 'foo', template: 'static', rootDir: 'relative/path' });
+ expect(result).toEqual(expect.objectContaining({ ok: false, code: 'ROOT_NOT_ABSOLUTE' }));
+ });
+
+ test('rejects when target already exists with ALREADY_EXISTS', async () => {
+ const r1 = await scaffolder.scaffold({ name: 'twice', template: 'static', rootDir: root });
+ expect(r1.ok).toBe(true);
+ const r2 = await scaffolder.scaffold({ name: 'twice', template: 'static', rootDir: root });
+ expect(r2).toEqual(expect.objectContaining({ ok: false, code: 'ALREADY_EXISTS' }));
+ });
+
+ test('reports filesWritten for downstream UI', async () => {
+ const result = await scaffolder.scaffold({ name: 'count', template: 'vite-react', rootDir: root });
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.filesWritten.length).toBeGreaterThanOrEqual(5);
+ for (const file of result.filesWritten) {
+ expect(fs.existsSync(file)).toBe(true);
+ }
+ });
+});
diff --git a/tests/skillInjectionService.test.ts b/tests/skillInjectionService.test.ts
new file mode 100644
index 0000000..c285075
--- /dev/null
+++ b/tests/skillInjectionService.test.ts
@@ -0,0 +1,172 @@
+/**
+ * Unit tests for FileSystemSkillInjectionService.
+ *
+ * Strategy: drive the service against a real temp directory so path-traversal
+ * defenses and writeFileSync paths are exercised end-to-end. The service
+ * accepts a fs override but the prod path uses node:fs — testing real fs
+ * gives stronger confidence here.
+ */
+
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import {
+ FileSystemSkillInjectionService,
+ SkillInjectionError,
+ sanitizeSkillName,
+} from '../src/skills/skillInjectionService';
+
+function makeTempDir(): string {
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-skill-test-'));
+}
+
+describe('sanitizeSkillName', () => {
+ test('keeps ASCII alphanumerics and -_.', () => {
+ expect(sanitizeSkillName('frontend_expert-v2.alpha')).toBe('frontend_expert-v2.alpha');
+ });
+
+ test('strips .md extension', () => {
+ expect(sanitizeSkillName('foo.md')).toBe('foo');
+ expect(sanitizeSkillName('foo.markdown')).toBe('foo');
+ });
+
+ test('replaces invalid chars with underscore', () => {
+ expect(sanitizeSkillName('hello world!')).toBe('hello_world');
+ expect(sanitizeSkillName('foo/bar')).toBe('foo_bar');
+ expect(sanitizeSkillName('파이썬')).toBe('');
+ });
+
+ test('strips leading/trailing dots and underscores', () => {
+ expect(sanitizeSkillName('___foo___')).toBe('foo');
+ expect(sanitizeSkillName('...foo...')).toBe('foo');
+ });
+
+ test('returns empty for path-traversal attempts', () => {
+ // ".." after stripping leading dots collapses to ""
+ expect(sanitizeSkillName('..')).toBe('');
+ expect(sanitizeSkillName('../etc/passwd')).toBe('etc_passwd');
+ });
+
+ test('caps length at 80', () => {
+ const long = 'a'.repeat(200);
+ expect(sanitizeSkillName(long).length).toBe(80);
+ });
+
+ test('rejects blank / non-string input', () => {
+ expect(sanitizeSkillName('')).toBe('');
+ expect(sanitizeSkillName(' ')).toBe('');
+ expect(sanitizeSkillName(undefined as any)).toBe('');
+ });
+});
+
+describe('FileSystemSkillInjectionService.inject', () => {
+ let dir: string;
+ let service: FileSystemSkillInjectionService;
+
+ beforeEach(() => {
+ dir = makeTempDir();
+ service = new FileSystemSkillInjectionService({
+ resolveSkillsDir: () => dir,
+ });
+ });
+
+ afterEach(() => {
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
+ });
+
+ test('writes .md and .meta.json', async () => {
+ const result = await service.inject({
+ name: 'react_expert',
+ content: '# React Expert\n\nKnow React deeply.',
+ displayName: 'React Expert',
+ description: 'React specialist agent.',
+ source: 'ezer',
+ });
+ expect(result.safeName).toBe('react_expert');
+ expect(fs.existsSync(result.filePath)).toBe(true);
+ expect(fs.existsSync(result.metaPath)).toBe(true);
+
+ const md = fs.readFileSync(result.filePath, 'utf8');
+ expect(md).toContain('# React Expert');
+
+ const meta = JSON.parse(fs.readFileSync(result.metaPath, 'utf8'));
+ expect(meta.name).toBe('react_expert');
+ expect(meta.displayName).toBe('React Expert');
+ expect(meta.description).toBe('React specialist agent.');
+ expect(meta.injectedFrom).toBe('ezer');
+ expect(meta.injectedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
+ });
+
+ test('sanitizes the name on disk', async () => {
+ const result = await service.inject({
+ name: 'hello world!.md',
+ content: 'body',
+ });
+ expect(result.safeName).toBe('hello_world');
+ expect(path.basename(result.filePath)).toBe('hello_world.md');
+ });
+
+ test('rejects empty name', async () => {
+ await expect(service.inject({ name: '', content: 'body' }))
+ .rejects.toMatchObject({ code: 'INVALID_NAME' });
+ });
+
+ test('rejects empty content', async () => {
+ await expect(service.inject({ name: 'foo', content: '' }))
+ .rejects.toMatchObject({ code: 'EMPTY_CONTENT' });
+ await expect(service.inject({ name: 'foo', content: ' ' }))
+ .rejects.toMatchObject({ code: 'EMPTY_CONTENT' });
+ });
+
+ test('rejects when skills dir cannot be resolved', async () => {
+ const noDirService = new FileSystemSkillInjectionService({
+ resolveSkillsDir: () => '',
+ });
+ await expect(noDirService.inject({ name: 'foo', content: 'body' }))
+ .rejects.toMatchObject({ code: 'NO_SKILLS_DIR' });
+ });
+
+ test('creates skills dir if it does not exist', async () => {
+ fs.rmSync(dir, { recursive: true, force: true });
+ expect(fs.existsSync(dir)).toBe(false);
+ const result = await service.inject({ name: 'foo', content: 'body' });
+ expect(fs.existsSync(result.filePath)).toBe(true);
+ });
+
+ test('falls back to safeName when displayName is blank', async () => {
+ const result = await service.inject({ name: 'bare', content: 'body' });
+ const meta = JSON.parse(fs.readFileSync(result.metaPath, 'utf8'));
+ expect(meta.displayName).toBe('bare');
+ expect(meta.injectedFrom).toBe('external');
+ });
+
+ test('overwrites existing skill on repeated inject', async () => {
+ await service.inject({ name: 'foo', content: 'v1' });
+ const r2 = await service.inject({ name: 'foo', content: 'v2' });
+ expect(fs.readFileSync(r2.filePath, 'utf8')).toBe('v2');
+ });
+
+ test('fires onInjected hook on success', async () => {
+ const calls: any[] = [];
+ const hooked = new FileSystemSkillInjectionService({
+ resolveSkillsDir: () => dir,
+ onInjected: (result, req) => calls.push({ result, req }),
+ });
+ await hooked.inject({ name: 'hooked', content: 'body', displayName: 'Hooked Skill' });
+ expect(calls).toHaveLength(1);
+ expect(calls[0].result.safeName).toBe('hooked');
+ expect(calls[0].req.displayName).toBe('Hooked Skill');
+ });
+
+ test('SkillInjectionError is thrown for write failures', async () => {
+ // Point at a path that exists but is not a directory — mkdirSync will
+ // throw. The service should wrap it as WRITE_FAILED.
+ const file = path.join(dir, 'notadir');
+ fs.writeFileSync(file, 'x');
+ const broken = new FileSystemSkillInjectionService({
+ resolveSkillsDir: () => path.join(file, 'sub'),
+ });
+ await expect(broken.inject({ name: 'foo', content: 'body' }))
+ .rejects.toMatchObject({ name: 'SkillInjectionError', code: 'WRITE_FAILED' });
+ });
+});
diff --git a/tests/systemSpecs.test.ts b/tests/systemSpecs.test.ts
new file mode 100644
index 0000000..5eb91ef
--- /dev/null
+++ b/tests/systemSpecs.test.ts
@@ -0,0 +1,90 @@
+/**
+ * Unit tests for SystemSpecs + HeuristicModelMemoryEstimator.
+ *
+ * Strategy:
+ * - HeuristicModelMemoryEstimator is pure — directly drive it with model ids.
+ * - NodeSystemSpecsProvider depends on `os.*` so we test:
+ * a) caching (same instance returned twice),
+ * b) shape (all required fields present, sane numbers).
+ * We don't pin platform-specific values since CI hardware varies.
+ */
+
+import {
+ NodeSystemSpecsProvider,
+ HeuristicModelMemoryEstimator,
+} from '../src/system/specs';
+
+describe('HeuristicModelMemoryEstimator', () => {
+ const est = new HeuristicModelMemoryEstimator();
+
+ test('extracts parameter count from "7B" suffix', () => {
+ // 7B q4 default: 7 * 0.6 + 1 = 5.2
+ expect(est.estimate('llama-3.2-7b-q4_K_M')).toBeCloseTo(5.2, 1);
+ });
+
+ test('extracts parameter count from "70B"', () => {
+ // 70B q4 default: 70 * 0.6 + 1 = 43
+ expect(est.estimate('llama-3-70b-instruct-q4_0')).toBeCloseTo(43, 0);
+ });
+
+ test('q8 quantization uses higher byte/param', () => {
+ // 7B q8: 7 * 1.0 + 1 = 8
+ expect(est.estimate('mistral-7b-q8_0')).toBeCloseTo(8, 1);
+ });
+
+ test('fp16 uses 2 bytes/param', () => {
+ // 7B fp16: 7 * 2.0 + 1 = 15
+ expect(est.estimate('mistral-7b-fp16')).toBeCloseTo(15, 1);
+ });
+
+ test('q5 sits between q4 and q6', () => {
+ const q4 = est.estimate('foo-7b-q4');
+ const q5 = est.estimate('foo-7b-q5');
+ const q6 = est.estimate('foo-7b-q6');
+ expect(q4).toBeLessThan(q5);
+ expect(q5).toBeLessThan(q6);
+ });
+
+ test('falls back to 7B when parameter count is absent', () => {
+ // unknown size → 7B q4 default → 5.2
+ expect(est.estimate('some-model-no-size')).toBeCloseTo(5.2, 1);
+ });
+
+ test('decimal parameter counts like "3.8b"', () => {
+ // 3.8B q4: 3.8 * 0.6 + 1 = 3.28
+ expect(est.estimate('phi-3.8b-q4')).toBeCloseTo(3.28, 1);
+ });
+
+ test('handles empty / undefined input gracefully', () => {
+ expect(est.estimate('')).toBeCloseTo(5.2, 1); // defaults
+ expect(est.estimate(undefined as any)).toBeCloseTo(5.2, 1);
+ });
+});
+
+describe('NodeSystemSpecsProvider', () => {
+ test('returns the same cached object on repeated calls', () => {
+ const provider = new NodeSystemSpecsProvider();
+ const a = provider.get();
+ const b = provider.get();
+ expect(a).toBe(b);
+ });
+
+ test('produces a sane shape', () => {
+ const specs = new NodeSystemSpecsProvider().get();
+ expect(specs.totalRamGB).toBeGreaterThan(0);
+ expect(specs.cpuCount).toBeGreaterThanOrEqual(1);
+ expect(specs.platform).toMatch(/^(darwin|linux|win32|freebsd|openbsd|sunos|aix)$/);
+ expect(specs.arch.length).toBeGreaterThan(0);
+ expect(typeof specs.isAppleSilicon).toBe('boolean');
+ expect(specs.safeModelBudgetGB).toBeGreaterThanOrEqual(2);
+ expect(specs.safeModelBudgetGB).toBeLessThanOrEqual(specs.totalRamGB);
+ expect(specs.summary).toMatch(/RAM/);
+ });
+
+ test('safe budget is at most ~65% of total RAM (Apple Silicon ceiling)', () => {
+ const specs = new NodeSystemSpecsProvider().get();
+ // Even on Apple Silicon (most generous ratio) the budget is capped at
+ // 0.65 of total. Use 0.7 as a soft upper bound for any platform.
+ expect(specs.safeModelBudgetGB).toBeLessThanOrEqual(specs.totalRamGB * 0.7 + 1);
+ });
+});
diff --git a/tests/telegramBot.test.ts b/tests/telegramBot.test.ts
new file mode 100644
index 0000000..833e307
--- /dev/null
+++ b/tests/telegramBot.test.ts
@@ -0,0 +1,363 @@
+/**
+ * Unit tests for TelegramBot + truncateForTelegram.
+ *
+ * Strategy:
+ * - TelegramBot is driven by an injected ITelegramClient stub. We script
+ * `getUpdates` to return queued batches and assert that:
+ * - the offset cursor advances correctly,
+ * - replies fire through sendMessage,
+ * - errors trigger backoff via the injected sleep,
+ * - aborted shutdown exits cleanly without sending residual messages,
+ * - invalid-token (401) stops the loop without burning retries.
+ * - The `sleep` injection turns the polling backoff into a counter we can
+ * drain synchronously inside the test loop.
+ */
+
+import { TelegramBot } from '../src/integrations/telegram/telegramBot';
+import {
+ TelegramClientError,
+ truncateForTelegram,
+ type ITelegramClient,
+ type GetUpdatesOptions,
+ type SendMessageOptions,
+} from '../src/integrations/telegram/telegramClient';
+import type { TelegramMessage, TelegramUpdate, TelegramUser } from '../src/integrations/telegram/types';
+
+class StubClient implements ITelegramClient {
+ public sent: SendMessageOptions[] = [];
+ public getUpdatesCalls: GetUpdatesOptions[] = [];
+ private _queue: Array = [];
+ private _onDrain?: () => void;
+ private _waiters: Array<() => void> = [];
+ public failSendOnce: Error | null = null;
+
+ constructor(public meValue: TelegramUser = { id: 1, is_bot: true, first_name: 'TestBot' }) {}
+
+ queueBatch(updates: TelegramUpdate[]) {
+ this._queue.push(updates);
+ this._wakeWaiters();
+ }
+ queueError(err: Error) {
+ this._queue.push(err);
+ this._wakeWaiters();
+ }
+ onceDrained(cb: () => void) { this._onDrain = cb; }
+
+ private _wakeWaiters() {
+ const w = this._waiters.splice(0);
+ for (const fn of w) fn();
+ }
+
+ async getMe(): Promise { return this.meValue; }
+
+ async getUpdates(opts: GetUpdatesOptions): Promise {
+ this.getUpdatesCalls.push(opts);
+ if (this._queue.length === 0) {
+ this._onDrain?.();
+ await new Promise((resolve, reject) => {
+ const onAbort = () => reject(new TelegramClientError('aborted', 'aborted'));
+ opts.signal?.addEventListener('abort', onAbort);
+ this._waiters.push(() => {
+ opts.signal?.removeEventListener('abort', onAbort);
+ resolve();
+ });
+ });
+ if (this._queue.length === 0) return [];
+ }
+ const next = this._queue.shift()!;
+ if (next instanceof Error) throw next;
+ return next;
+ }
+
+ async sendMessage(opts: SendMessageOptions): Promise {
+ if (this.failSendOnce) {
+ const err = this.failSendOnce;
+ this.failSendOnce = null;
+ throw err;
+ }
+ this.sent.push(opts);
+ return {
+ message_id: 1,
+ date: 0,
+ chat: { id: opts.chatId, type: 'private' },
+ text: opts.text,
+ };
+ }
+}
+
+function update(id: number, chatId: number, text: string): TelegramUpdate {
+ return {
+ update_id: id,
+ message: { message_id: id, date: 0, chat: { id: chatId, type: 'private' }, text },
+ };
+}
+
+const flush = () => new Promise((r) => setImmediate(r));
+
+describe('truncateForTelegram', () => {
+ test('passes through short text', () => {
+ expect(truncateForTelegram('hello')).toBe('hello');
+ });
+
+ test('truncates over 4096 chars with ellipsis', () => {
+ const long = 'x'.repeat(5000);
+ const out = truncateForTelegram(long);
+ expect(out.length).toBeLessThanOrEqual(4096);
+ expect(out.endsWith('(truncated)')).toBe(true);
+ });
+
+ test('handles non-string defensively', () => {
+ expect(truncateForTelegram(undefined as any)).toBe('');
+ });
+});
+
+describe('TelegramBot', () => {
+ let client: StubClient;
+ let handled: Array<{ text: string; chatId: number }>;
+ let bot: TelegramBot;
+
+ beforeEach(() => {
+ client = new StubClient();
+ handled = [];
+ });
+
+ afterEach(async () => {
+ if (bot) await bot.stop();
+ });
+
+ test('start is idempotent', () => {
+ bot = new TelegramBot({ client, handle: async () => null });
+ bot.start();
+ expect(bot.isRunning()).toBe(true);
+ bot.start();
+ expect(bot.isRunning()).toBe(true);
+ });
+
+ test('processes updates and advances offset', async () => {
+ bot = new TelegramBot({
+ client,
+ handle: async (text, chatId) => { handled.push({ text, chatId }); return `echo: ${text}`; },
+ sleep: () => Promise.resolve(),
+ });
+ client.queueBatch([update(101, 999, 'hi'), update(102, 999, 'second')]);
+ client.onceDrained(() => { /* now hanging, safe to stop */ void bot.stop(); });
+
+ bot.start();
+ await new Promise((r) => setTimeout(r, 30));
+ await bot.stop();
+
+ expect(handled).toEqual([
+ { text: 'hi', chatId: 999 },
+ { text: 'second', chatId: 999 },
+ ]);
+ expect(client.sent.map(s => s.text)).toEqual(['echo: hi', 'echo: second']);
+ // Second poll uses offset = 103 (lastId + 1)
+ const offsets = client.getUpdatesCalls.map(c => c.offset);
+ expect(offsets[1]).toBe(103);
+ });
+
+ test('skips reply when handler returns null', async () => {
+ bot = new TelegramBot({
+ client,
+ handle: async () => null,
+ sleep: () => Promise.resolve(),
+ });
+ client.queueBatch([update(1, 1, 'ignored')]);
+ client.onceDrained(() => void bot.stop());
+
+ bot.start();
+ await new Promise((r) => setTimeout(r, 20));
+ await bot.stop();
+
+ expect(client.sent).toEqual([]);
+ });
+
+ test('handler exception is converted to a reply (loop survives)', async () => {
+ bot = new TelegramBot({
+ client,
+ handle: async () => { throw new Error('boom'); },
+ sleep: () => Promise.resolve(),
+ });
+ client.queueBatch([update(1, 1, 'hi')]);
+ client.onceDrained(() => void bot.stop());
+
+ bot.start();
+ await new Promise((r) => setTimeout(r, 20));
+ await bot.stop();
+
+ expect(client.sent).toHaveLength(1);
+ expect(client.sent[0].text).toContain('boom');
+ });
+
+ test('network error triggers backoff and retries', async () => {
+ const sleeps: number[] = [];
+ bot = new TelegramBot({
+ client,
+ handle: async () => null,
+ initialBackoffMs: 100,
+ maxBackoffMs: 800,
+ sleep: async (ms) => { sleeps.push(ms); },
+ });
+ client.queueError(new TelegramClientError('network', 'temp net'));
+ client.queueError(new TelegramClientError('network', 'temp net'));
+ client.queueBatch([update(1, 1, 'after-recovery')]);
+ client.onceDrained(() => void bot.stop());
+
+ bot.start();
+ await new Promise((r) => setTimeout(r, 30));
+ await bot.stop();
+
+ // First two failures should produce 100ms then 200ms (exponential, capped at 800).
+ expect(sleeps.length).toBeGreaterThanOrEqual(2);
+ expect(sleeps[0]).toBe(100);
+ expect(sleeps[1]).toBe(200);
+ // Loop continued past errors and eventually saw the success batch.
+ expect(client.getUpdatesCalls.length).toBeGreaterThanOrEqual(3);
+ });
+
+ test('401 invalid-token stops loop without retries', async () => {
+ const sleeps: number[] = [];
+ bot = new TelegramBot({
+ client,
+ handle: async () => null,
+ sleep: async (ms) => { sleeps.push(ms); },
+ });
+ client.queueError(new TelegramClientError('api', 'unauthorized', 401));
+
+ bot.start();
+ await new Promise((r) => setTimeout(r, 20));
+ // Bot should have stopped itself.
+ expect(bot.isRunning()).toBe(false);
+ expect(sleeps).toEqual([]);
+ });
+
+ test('aborted exits cleanly without backoff', async () => {
+ const sleeps: number[] = [];
+ bot = new TelegramBot({
+ client,
+ handle: async () => null,
+ sleep: async (ms) => { sleeps.push(ms); },
+ });
+ client.onceDrained(() => void bot.stop());
+
+ bot.start();
+ await new Promise((r) => setTimeout(r, 20));
+ await bot.stop();
+ expect(sleeps).toEqual([]); // no backoff on graceful abort
+ });
+
+ test('reply send failure is logged but loop continues', async () => {
+ bot = new TelegramBot({
+ client,
+ handle: async () => 'reply',
+ sleep: () => Promise.resolve(),
+ });
+ client.failSendOnce = new Error('send failed');
+ client.queueBatch([update(1, 1, 'first'), update(2, 1, 'second')]);
+ client.onceDrained(() => void bot.stop());
+
+ bot.start();
+ await new Promise((r) => setTimeout(r, 30));
+ await bot.stop();
+
+ // First send failed, second still attempted.
+ expect(client.sent).toHaveLength(1);
+ expect(client.sent[0].text).toBe('reply');
+ });
+
+ test('enrollNextChat captures the next message and skips the AI handler', async () => {
+ bot = new TelegramBot({
+ client,
+ handle: async (text, chatId) => { handled.push({ text, chatId }); return 'should-not-fire'; },
+ sleep: () => Promise.resolve(),
+ });
+ client.queueBatch([
+ {
+ update_id: 50,
+ message: {
+ message_id: 1, date: 0,
+ chat: { id: 777, type: 'private' },
+ from: { id: 777, is_bot: false, first_name: 'Alice', username: 'alice' },
+ text: 'hello',
+ },
+ },
+ ]);
+ client.onceDrained(() => void bot.stop());
+ bot.start();
+ const captured = await bot.enrollNextChat(5000);
+ await bot.stop();
+
+ expect(captured.chatId).toBe(777);
+ expect(captured.username).toBe('alice');
+ expect(captured.firstName).toBe('Alice');
+ // Normal handler must NOT have fired for the captured update.
+ expect(handled).toEqual([]);
+ // Bot should have sent the enrollment ack (not a real reply).
+ expect(client.sent).toHaveLength(1);
+ expect(client.sent[0].text).toContain('등록');
+ });
+
+ test('enrollNextChat times out cleanly when no message arrives', async () => {
+ bot = new TelegramBot({
+ client,
+ handle: async () => null,
+ sleep: () => Promise.resolve(),
+ });
+ bot.start();
+ await expect(bot.enrollNextChat(50)).rejects.toThrow(/within|received/i);
+ await bot.stop();
+ });
+
+ test('a second enrollNextChat supersedes the first', async () => {
+ bot = new TelegramBot({
+ client,
+ handle: async () => null,
+ sleep: () => Promise.resolve(),
+ });
+ bot.start();
+ const first = bot.enrollNextChat(60_000);
+ // Eat the unhandled rejection by attaching a handler immediately.
+ const firstFailed = first.catch((e) => e.message);
+ const secondP = bot.enrollNextChat(60_000);
+ client.queueBatch([
+ { update_id: 1, message: { message_id: 1, date: 0, chat: { id: 5, type: 'private' }, text: 'hi' } },
+ ]);
+ client.onceDrained(() => void bot.stop());
+ const second = await secondP;
+ await bot.stop();
+ expect(second.chatId).toBe(5);
+ await expect(firstFailed).resolves.toMatch(/superseded/i);
+ });
+
+ test('cancelEnrollment rejects the pending promise', async () => {
+ bot = new TelegramBot({
+ client,
+ handle: async () => null,
+ sleep: () => Promise.resolve(),
+ });
+ bot.start();
+ const p = bot.enrollNextChat(60_000);
+ bot.cancelEnrollment();
+ await expect(p).rejects.toThrow(/cancel/i);
+ await bot.stop();
+ });
+
+ test('updates without text or chatId are ignored', async () => {
+ bot = new TelegramBot({
+ client,
+ handle: async (text, chatId) => { handled.push({ text, chatId }); return null; },
+ sleep: () => Promise.resolve(),
+ });
+ const malformed: TelegramUpdate = { update_id: 1, message: { message_id: 1, date: 0, chat: { id: 0, type: 'private' } } };
+ const noChat: TelegramUpdate = { update_id: 2, message: { message_id: 2, date: 0, chat: undefined as any, text: 'hi' } };
+ const valid = update(3, 99, 'real');
+ client.queueBatch([malformed, noChat, valid]);
+ client.onceDrained(() => void bot.stop());
+
+ bot.start();
+ await new Promise((r) => setTimeout(r, 20));
+ await bot.stop();
+
+ expect(handled).toEqual([{ text: 'real', chatId: 99 }]);
+ });
+});