chore: bump version to 2.80.27 and update core features
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1778249295071,
|
||||
"createdAt": 1778256848559,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
"createdAt": 1778249295065,
|
||||
"createdAt": 1778256848551,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
|
||||
"createdAt": 1778249295060,
|
||||
"createdAt": 1778256848546,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+10
-10
@@ -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": {
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+136
-44
@@ -59,6 +59,19 @@ export interface AgentExecutorOptions {
|
||||
start: () => void;
|
||||
end: () => void;
|
||||
};
|
||||
/**
|
||||
* Optional native LM Studio chat streamer. When provided AND the active engine is LM Studio,
|
||||
* chat completions are streamed via @lmstudio/sdk's WebSocket transport instead of the
|
||||
* OpenAI-compatible REST endpoint. Falls back to REST when omitted or when the streamer
|
||||
* itself fails (e.g. SDK reachability error).
|
||||
*/
|
||||
lmStudioStreamer?: import('./lmstudio/streamer').IChatStreamer;
|
||||
/**
|
||||
* Optional pending-approval queue. When provided, dry-run transactions are also published
|
||||
* into a queue that drives the Approval Panel webview + status bar badge. The existing
|
||||
* inline `requiresApproval` chat message is preserved for backwards compatibility.
|
||||
*/
|
||||
approvalQueue?: import('./features/approval/approvalQueue').ApprovalQueue;
|
||||
}
|
||||
|
||||
// --- Agent Roles & Workflows ---
|
||||
@@ -135,6 +148,15 @@ export class AgentExecutor {
|
||||
.replace(/<rationale>[\s\S]*?<\/rationale>/gi, '')
|
||||
.replace(/^\s*\[PROBLEM\][\s\S]*?\[GOAL\][\s\S]*?\[REASONING\][^\n]*(?:\n+|$)/i, '')
|
||||
.replace(/^\s*\[PROBLEM\][\s\S]*?(?:\n\s*\n|$)/i, '')
|
||||
.replace(/(?:<think(?:ing)?>|<analysis>)[\s\S]*?(?:<\/think(?:ing)?>|<\/analysis>)/gi, '')
|
||||
// Harmony / GPT-OSS-style channel markers: keep only the `final`
|
||||
// channel; drop everything else (thought, analysis, commentary).
|
||||
// The closing form varies by model: `<channel|>`, `<|channel|>`,
|
||||
// `<|end|>`, `<|return|>`. Match conservatively.
|
||||
.replace(/<\|?channel\|?>\s*(?:thought|analysis|commentary|reasoning)\b[\s\S]*?<\|?channel\|?>/gi, '')
|
||||
.replace(/<\|?channel\|?>\s*(?:thought|analysis|commentary|reasoning)\b[\s\S]*?(?=<\|?channel\|?>\s*final\b)/gi, '')
|
||||
.replace(/<\|?channel\|?>\s*final\b\s*(?:<\|?message\|?>)?/gi, '')
|
||||
.replace(/<\|?(?:end|return|start|message)\|?>/gi, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
@@ -453,61 +475,91 @@ export class AgentExecutor {
|
||||
logError('AI request timed out.', { timeoutMs: timeout, model: actualModel, loopDepth });
|
||||
this.abortController?.abort();
|
||||
}, timeout);
|
||||
const request = await this.createStreamingRequest({
|
||||
baseUrl: ollamaUrl,
|
||||
modelName: actualModel,
|
||||
reqMessages: messagesForRequest,
|
||||
temperature
|
||||
});
|
||||
const { response, engine, apiUrl } = request;
|
||||
if (this.isStaleRun(runId)) return;
|
||||
|
||||
const engine = resolveEngine(ollamaUrl);
|
||||
const useLmStudioSdk = engine === 'lmstudio' && !!this.options.lmStudioStreamer;
|
||||
let apiUrl = '';
|
||||
let aiResponseText = '';
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Response body is not readable.");
|
||||
let buffer = '';
|
||||
|
||||
if (loopDepth === 0) {
|
||||
this.webview.postMessage({ type: 'streamStart' });
|
||||
this.options.onStreamLifecycle?.start();
|
||||
}
|
||||
|
||||
let buffer = '';
|
||||
const decoder = new TextDecoder();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (this.isStaleRun(runId)) return;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed === 'data: [DONE]') continue;
|
||||
try {
|
||||
const raw = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;
|
||||
const json = JSON.parse(raw);
|
||||
const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || '';
|
||||
if (token) {
|
||||
aiResponseText += token;
|
||||
}
|
||||
} catch (e: any) {
|
||||
logError('Failed to parse streaming chunk.', { engine, apiUrl, chunk: summarizeText(trimmed, 300), error: e?.message || String(e) });
|
||||
}
|
||||
if (useLmStudioSdk) {
|
||||
apiUrl = `${ollamaUrl} (sdk)`;
|
||||
logInfo('Streaming chat via LM Studio SDK.', { model: actualModel });
|
||||
try {
|
||||
const stream = this.options.lmStudioStreamer!.stream({
|
||||
modelName: actualModel,
|
||||
messages: messagesForRequest.map((m) => ({ role: m.role, content: m.content })),
|
||||
temperature,
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
for await (const { token } of stream) {
|
||||
if (this.isStaleRun(runId)) return;
|
||||
if (token) aiResponseText += token;
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.name === 'AbortError' || this.abortController.signal.aborted) {
|
||||
logInfo('Generation aborted by user.');
|
||||
} else {
|
||||
logError('LM Studio SDK chat failed.', { engine, error: err?.message ?? String(err) });
|
||||
this.webview?.postMessage({ type: 'error', value: `LM Studio: ${err?.message ?? err}` });
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') {
|
||||
logInfo('Generation aborted by user.');
|
||||
} else {
|
||||
logError('Stream reading error.', { engine, apiUrl, error: err?.message || String(err) });
|
||||
this.webview?.postMessage({ type: 'error', value: `Connection lost: ${err.message}` });
|
||||
} else {
|
||||
const request = await this.createStreamingRequest({
|
||||
baseUrl: ollamaUrl,
|
||||
modelName: actualModel,
|
||||
reqMessages: messagesForRequest,
|
||||
temperature
|
||||
});
|
||||
const { response, apiUrl: restApiUrl } = request;
|
||||
apiUrl = restApiUrl;
|
||||
if (this.isStaleRun(runId)) return;
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Response body is not readable.");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (this.isStaleRun(runId)) return;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed === 'data: [DONE]') continue;
|
||||
try {
|
||||
const raw = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;
|
||||
const json = JSON.parse(raw);
|
||||
const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || '';
|
||||
if (token) {
|
||||
aiResponseText += token;
|
||||
}
|
||||
} catch (e: any) {
|
||||
logError('Failed to parse streaming chunk.', { engine, apiUrl, chunk: summarizeText(trimmed, 300), error: e?.message || String(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') {
|
||||
logInfo('Generation aborted by user.');
|
||||
} else {
|
||||
logError('Stream reading error.', { engine, apiUrl, error: err?.message || String(err) });
|
||||
this.webview?.postMessage({ type: 'error', value: `Connection lost: ${err.message}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final buffer processing
|
||||
if (buffer.trim() && buffer.trim() !== 'data: [DONE]') {
|
||||
// Final buffer processing (REST SSE only — SDK has no trailing buffer)
|
||||
if (!useLmStudioSdk && buffer.trim() && buffer.trim() !== 'data: [DONE]') {
|
||||
try {
|
||||
const trimmed = buffer.trim();
|
||||
const raw = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;
|
||||
@@ -717,13 +769,35 @@ export class AgentExecutor {
|
||||
|
||||
private async callAgent(role: AgentRole, prompt: string, modelName: string, options: any): Promise<string> {
|
||||
const persona = AGENT_PROMPTS[role];
|
||||
const { ollamaUrl, timeout } = getConfig();
|
||||
const { ollamaUrl } = getConfig();
|
||||
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: 'system', content: persona },
|
||||
{ role: 'user', content: prompt }
|
||||
];
|
||||
|
||||
const engine = resolveEngine(ollamaUrl);
|
||||
let responseText = '';
|
||||
|
||||
if (engine === 'lmstudio' && this.options.lmStudioStreamer) {
|
||||
try {
|
||||
const stream = this.options.lmStudioStreamer.stream({
|
||||
modelName,
|
||||
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
temperature: 0.3,
|
||||
signal: this.abortController?.signal,
|
||||
});
|
||||
for await (const { token } of stream) {
|
||||
if (token) responseText += token;
|
||||
}
|
||||
return responseText;
|
||||
} catch (err: any) {
|
||||
if (err?.name === 'AbortError' || this.abortController?.signal.aborted) return responseText;
|
||||
logError('LM Studio SDK callAgent stream failed.', { role, error: err?.message ?? String(err) });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const request = await this.createStreamingRequest({
|
||||
baseUrl: ollamaUrl,
|
||||
modelName: modelName,
|
||||
@@ -731,7 +805,6 @@ export class AgentExecutor {
|
||||
temperature: 0.3 // Use lower temperature for planning and research
|
||||
});
|
||||
|
||||
let responseText = '';
|
||||
const reader = request.response.body?.getReader();
|
||||
if (!reader) throw new Error("Agent response body is not readable.");
|
||||
|
||||
@@ -2304,6 +2377,25 @@ export class AgentExecutor {
|
||||
if (config.dryRun) {
|
||||
report.push(`\n⚠️ **Dry Run Mode Active**: 위 변경 사항을 확인하고 [승인] 또는 [롤백]을 선택해주세요.`);
|
||||
this.webview?.postMessage({ type: 'requiresApproval' });
|
||||
// Mirror the inline-chat approval into the queue feeding the dedicated panel + status bar.
|
||||
const queue = this.options.approvalQueue;
|
||||
if (queue) {
|
||||
const recorded = this.transactionManager.getRecordedFiles();
|
||||
queue.enqueue(
|
||||
{
|
||||
id: `txn-${Date.now()}`,
|
||||
kind: 'transaction',
|
||||
title: 'Pending file changes',
|
||||
summary: `${recorded.length}개 파일 변경 대기 중`,
|
||||
files: recorded.map(r => r.path),
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
approve: () => this.approveTransaction(),
|
||||
reject: () => this.rejectTransaction(),
|
||||
}
|
||||
);
|
||||
}
|
||||
// Do NOT commit yet
|
||||
} else {
|
||||
this.transactionManager.commit();
|
||||
|
||||
+50
-1
@@ -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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 === '&' ? '&' :
|
||||
ch === '<' ? '<' :
|
||||
ch === '>' ? '>' :
|
||||
ch === '"' ? '"' : '''
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
});
|
||||
});
|
||||
@@ -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> = {}) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user