chore: bump version to 2.80.27 and update core features

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