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}초 전
    +
      ${filesHtml}
    +
    + + +
    +
    `; + } + + 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 }]); + }); +});