chore: version up to v2.80.30 and package vsix
This commit is contained in:
@@ -1,5 +1,14 @@
|
|||||||
# Astra Patch Notes
|
# Astra Patch Notes
|
||||||
|
|
||||||
|
## v2.80.30 (2026-05-11)
|
||||||
|
### 🛠️ External Skills & Sidebar UX Enhancement
|
||||||
|
- **외부 스킬 로딩 지원 (External Skill Loading):** `externalSkillLoader.ts` 신규 도입 및 `agentKnowledgeMap.ts` 고도화를 통해 외부 정의 스킬을 동적으로 로드하고 활용하는 기반을 구축했습니다.
|
||||||
|
- **사이드바 UI 정교화:** `sidebar.html`, `sidebar.js`, `sidebar.css` 전면 갱신을 통해 스킬 선택 인터페이스를 개선하고 시각적 피드백을 강화했습니다.
|
||||||
|
- **에이전트 실행 로직 최적화:** `agentHandlers.ts` 내의 스킬 컨텍스트 주입 및 메시지 처리 파이프라인을 개선하여 실행 안정성을 확보했습니다.
|
||||||
|
- **신규 패키징:** `astra-2.80.30.vsix` 패키지를 생성하고 핵심 워크플로우에 대한 회귀 테스트를 완료했습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v2.80.29 (2026-05-10)
|
## v2.80.29 (2026-05-10)
|
||||||
### 🛡️ Service Reliability & Telegram Integration
|
### 🛡️ Service Reliability & Telegram Integration
|
||||||
- **텔레그램 봇 안정화 (Telegram Bot Stabilization):** `telegramBot.ts` 내의 폴링 및 응답 로직을 개선하여 메시지 누락 및 연결 지연 문제를 해결했습니다.
|
- **텔레그램 봇 안정화 (Telegram Bot Stabilization):** `telegramBot.ts` 내의 폴링 및 응답 로직을 개선하여 메시지 누락 및 연결 지연 문제를 해결했습니다.
|
||||||
|
|||||||
@@ -515,6 +515,128 @@
|
|||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Agent Map Modal --- */
|
||||||
|
.map-agent-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.map-agent-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
.map-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.map-section-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.map-section-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-bright);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.map-section-hint {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.4;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
.map-btn-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.map-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.map-list:empty::before {
|
||||||
|
content: '아직 연결된 항목이 없습니다.';
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 8px 4px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.map-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.map-item-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.map-item-path {
|
||||||
|
flex: 1;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
.map-item-meta {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.map-item-remove {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.map-item-remove:hover {
|
||||||
|
border-color: var(--error);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
.map-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.map-footer .send-btn {
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
.map-status {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
min-height: 16px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.map-status.ok { color: var(--success); }
|
||||||
|
.map-status.error { color: var(--error); }
|
||||||
|
|
||||||
/* --- Physics & Micro-interactions --- */
|
/* --- Physics & Micro-interactions --- */
|
||||||
button {
|
button {
|
||||||
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|||||||
@@ -82,6 +82,57 @@
|
|||||||
<div id="historyList" style="flex:1; overflow-y:auto;"></div>
|
<div id="historyList" style="flex:1; overflow-y:auto;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="agentMapOverlay" class="history-overlay">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:14px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="color:var(--text-bright); margin:0;">Agent Mapping</h2>
|
||||||
|
<p style="margin:4px 0 0; font-size:11px; color:var(--text-dim);">
|
||||||
|
선택한 에이전트에 <strong>지식 폴더</strong>와 <strong>외부 스킬 폴더/파일</strong>을 연결합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" id="closeAgentMapBtn">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="map-agent-row">
|
||||||
|
<div class="field-label">Agent</div>
|
||||||
|
<div id="agentMapAgentName" class="map-agent-name">(선택된 에이전트가 없습니다)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="map-section">
|
||||||
|
<div class="map-section-head">
|
||||||
|
<div>
|
||||||
|
<div class="map-section-title">📚 Knowledge Folders</div>
|
||||||
|
<div class="map-section-hint">Brain 안에서 검색(RAG)에 사용할 폴더입니다. 폴더가 Brain 외부면 자동으로 제외됩니다.</div>
|
||||||
|
</div>
|
||||||
|
<button class="secondary-btn" id="addKnowledgeFolderBtn">+ 폴더 추가</button>
|
||||||
|
</div>
|
||||||
|
<ul id="knowledgeFolderList" class="map-list"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="map-section">
|
||||||
|
<div class="map-section-head">
|
||||||
|
<div>
|
||||||
|
<div class="map-section-title">🛠 External Skills</div>
|
||||||
|
<div class="map-section-hint">.md 스킬 파일이 들어 있는 폴더 또는 개별 .md 파일을 연결하세요. 채팅 시 항상 함께 로드됩니다.</div>
|
||||||
|
</div>
|
||||||
|
<div class="map-btn-group">
|
||||||
|
<button class="secondary-btn" id="addSkillFolderBtn">+ 폴더 추가</button>
|
||||||
|
<button class="secondary-btn" id="addSkillFileBtn">+ 파일 추가</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul id="skillFolderList" class="map-list"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="map-footer">
|
||||||
|
<button class="secondary-btn" id="editAgentMapJsonBtn" title="고급 사용자용: JSON으로 직접 편집">JSON 편집</button>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="secondary-btn" id="cancelAgentMapBtn">취소</button>
|
||||||
|
<button class="send-btn" id="saveAgentMapBtn">저장</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="agentMapStatus" class="map-status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="thinking-bar" id="thinkingBar"></div>
|
<div class="thinking-bar" id="thinkingBar"></div>
|
||||||
|
|
||||||
<div id="stepper" class="stepper-container">
|
<div id="stepper" class="stepper-container">
|
||||||
|
|||||||
+128
-1
@@ -157,6 +157,72 @@
|
|||||||
const agentPrompt = document.getElementById('agentPrompt');
|
const agentPrompt = document.getElementById('agentPrompt');
|
||||||
const negativePrompt = document.getElementById('negativePrompt');
|
const negativePrompt = document.getElementById('negativePrompt');
|
||||||
const updateAgentBtn = document.getElementById('updateAgentBtn');
|
const updateAgentBtn = document.getElementById('updateAgentBtn');
|
||||||
|
const agentMapOverlay = document.getElementById('agentMapOverlay');
|
||||||
|
const closeAgentMapBtn = document.getElementById('closeAgentMapBtn');
|
||||||
|
const cancelAgentMapBtn = document.getElementById('cancelAgentMapBtn');
|
||||||
|
const saveAgentMapBtn = document.getElementById('saveAgentMapBtn');
|
||||||
|
const editAgentMapJsonBtn = document.getElementById('editAgentMapJsonBtn');
|
||||||
|
const addKnowledgeFolderBtn = document.getElementById('addKnowledgeFolderBtn');
|
||||||
|
const addSkillFolderBtn = document.getElementById('addSkillFolderBtn');
|
||||||
|
const addSkillFileBtn = document.getElementById('addSkillFileBtn');
|
||||||
|
const knowledgeFolderList = document.getElementById('knowledgeFolderList');
|
||||||
|
const skillFolderList = document.getElementById('skillFolderList');
|
||||||
|
const agentMapAgentName = document.getElementById('agentMapAgentName');
|
||||||
|
const agentMapStatus = document.getElementById('agentMapStatus');
|
||||||
|
|
||||||
|
let agentMapDraft = { agentPath: '', name: '', knowledgeFolders: [], skillFolders: [] };
|
||||||
|
|
||||||
|
function renderAgentMapLists() {
|
||||||
|
const renderList = (listEl, items, kind) => {
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
items.forEach((p, idx) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'map-item';
|
||||||
|
const icon = document.createElement('span');
|
||||||
|
icon.className = 'map-item-icon';
|
||||||
|
icon.textContent = kind === 'knowledge' ? '📁' : (p.endsWith('.md') || p.endsWith('.markdown') ? '📄' : '📁');
|
||||||
|
const pathEl = document.createElement('span');
|
||||||
|
pathEl.className = 'map-item-path';
|
||||||
|
pathEl.textContent = p;
|
||||||
|
pathEl.title = p;
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.className = 'map-item-remove';
|
||||||
|
removeBtn.textContent = '✕';
|
||||||
|
removeBtn.title = '연결 해제';
|
||||||
|
removeBtn.onclick = () => {
|
||||||
|
items.splice(idx, 1);
|
||||||
|
renderAgentMapLists();
|
||||||
|
};
|
||||||
|
li.appendChild(icon);
|
||||||
|
li.appendChild(pathEl);
|
||||||
|
li.appendChild(removeBtn);
|
||||||
|
listEl.appendChild(li);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
renderList(knowledgeFolderList, agentMapDraft.knowledgeFolders, 'knowledge');
|
||||||
|
renderList(skillFolderList, agentMapDraft.skillFolders, 'skill');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAgentMapModal() {
|
||||||
|
if (!agentSel || agentSel.value === 'none' || !agentSel.value) {
|
||||||
|
showToast('에이전트를 먼저 선택하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
agentMapStatus.className = 'map-status';
|
||||||
|
agentMapStatus.textContent = '불러오는 중...';
|
||||||
|
agentMapDraft = { agentPath: agentSel.value, name: agentSel.options[agentSel.selectedIndex]?.text || '', knowledgeFolders: [], skillFolders: [] };
|
||||||
|
agentMapAgentName.textContent = agentMapDraft.name;
|
||||||
|
knowledgeFolderList.innerHTML = '';
|
||||||
|
skillFolderList.innerHTML = '';
|
||||||
|
agentMapOverlay.classList.add('visible');
|
||||||
|
vscode.postMessage({ type: 'getAgentMap', agentPath: agentSel.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAgentMapModal() {
|
||||||
|
agentMapOverlay.classList.remove('visible');
|
||||||
|
agentMapStatus.textContent = '';
|
||||||
|
agentMapStatus.className = 'map-status';
|
||||||
|
}
|
||||||
|
|
||||||
let streamBody = null;
|
let streamBody = null;
|
||||||
let internetEnabled = false;
|
let internetEnabled = false;
|
||||||
@@ -387,6 +453,41 @@
|
|||||||
}
|
}
|
||||||
vscode.postMessage({ type: 'getKnowledgeScope', agentPath: msg.selected });
|
vscode.postMessage({ type: 'getKnowledgeScope', agentPath: msg.selected });
|
||||||
break;
|
break;
|
||||||
|
case 'agentMapData':
|
||||||
|
if (msg.value) {
|
||||||
|
agentMapDraft = {
|
||||||
|
agentPath: agentMapDraft.agentPath,
|
||||||
|
name: agentMapDraft.name,
|
||||||
|
knowledgeFolders: Array.isArray(msg.value.knowledgeFolders) ? msg.value.knowledgeFolders.slice() : [],
|
||||||
|
skillFolders: Array.isArray(msg.value.skillFolders) ? msg.value.skillFolders.slice() : [],
|
||||||
|
};
|
||||||
|
renderAgentMapLists();
|
||||||
|
agentMapStatus.textContent = msg.value.exists ? '' : '새 매핑입니다. 저장하면 생성됩니다.';
|
||||||
|
agentMapStatus.className = 'map-status';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'pickedPath':
|
||||||
|
if (msg.value && msg.value.path && agentMapOverlay.classList.contains('visible')) {
|
||||||
|
const target = (msg.value.kind === 'knowledgeFolder')
|
||||||
|
? agentMapDraft.knowledgeFolders
|
||||||
|
: agentMapDraft.skillFolders;
|
||||||
|
if (!target.includes(msg.value.path)) {
|
||||||
|
target.push(msg.value.path);
|
||||||
|
renderAgentMapLists();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'agentMapSaved':
|
||||||
|
if (msg.value && msg.value.ok) {
|
||||||
|
agentMapStatus.className = 'map-status ok';
|
||||||
|
agentMapStatus.textContent = '저장되었습니다.';
|
||||||
|
vscode.postMessage({ type: 'getKnowledgeScope', agentPath: agentMapDraft.agentPath });
|
||||||
|
setTimeout(closeAgentMapModal, 700);
|
||||||
|
} else {
|
||||||
|
agentMapStatus.className = 'map-status error';
|
||||||
|
agentMapStatus.textContent = '저장 실패: ' + (msg.value?.error || '알 수 없는 오류');
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'knowledgeScope':
|
case 'knowledgeScope':
|
||||||
if (knowledgeScopeSel) {
|
if (knowledgeScopeSel) {
|
||||||
knowledgeScopeSel.innerHTML = '';
|
knowledgeScopeSel.innerHTML = '';
|
||||||
@@ -706,11 +807,37 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (editKnowledgeMapBtn) {
|
if (editKnowledgeMapBtn) {
|
||||||
editKnowledgeMapBtn.onclick = () => vscode.postMessage({ type: 'editKnowledgeMap' });
|
editKnowledgeMapBtn.onclick = () => openAgentMapModal();
|
||||||
}
|
}
|
||||||
if (reloadKnowledgeMapBtn) {
|
if (reloadKnowledgeMapBtn) {
|
||||||
reloadKnowledgeMapBtn.onclick = () => vscode.postMessage({ type: 'getKnowledgeScope', agentPath: agentSel.value });
|
reloadKnowledgeMapBtn.onclick = () => vscode.postMessage({ type: 'getKnowledgeScope', agentPath: agentSel.value });
|
||||||
}
|
}
|
||||||
|
if (closeAgentMapBtn) closeAgentMapBtn.onclick = closeAgentMapModal;
|
||||||
|
if (cancelAgentMapBtn) cancelAgentMapBtn.onclick = closeAgentMapModal;
|
||||||
|
if (editAgentMapJsonBtn) {
|
||||||
|
editAgentMapJsonBtn.onclick = () => vscode.postMessage({ type: 'editKnowledgeMap' });
|
||||||
|
}
|
||||||
|
if (addKnowledgeFolderBtn) {
|
||||||
|
addKnowledgeFolderBtn.onclick = () => vscode.postMessage({ type: 'pickPath', kind: 'knowledgeFolder' });
|
||||||
|
}
|
||||||
|
if (addSkillFolderBtn) {
|
||||||
|
addSkillFolderBtn.onclick = () => vscode.postMessage({ type: 'pickPath', kind: 'skillFolder' });
|
||||||
|
}
|
||||||
|
if (addSkillFileBtn) {
|
||||||
|
addSkillFileBtn.onclick = () => vscode.postMessage({ type: 'pickPath', kind: 'skillFile' });
|
||||||
|
}
|
||||||
|
if (saveAgentMapBtn) {
|
||||||
|
saveAgentMapBtn.onclick = () => {
|
||||||
|
agentMapStatus.className = 'map-status';
|
||||||
|
agentMapStatus.textContent = '저장 중...';
|
||||||
|
vscode.postMessage({
|
||||||
|
type: 'saveAgentMap',
|
||||||
|
agentPath: agentMapDraft.agentPath,
|
||||||
|
knowledgeFolders: agentMapDraft.knowledgeFolders,
|
||||||
|
skillFolders: agentMapDraft.skillFolders,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
editAgentBtn.onclick = () => {
|
editAgentBtn.onclick = () => {
|
||||||
if (agentSel.value === 'none') return;
|
if (agentSel.value === 'none') return;
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "astra",
|
"name": "astra",
|
||||||
"displayName": "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.",
|
"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.29",
|
"version": "2.80.30",
|
||||||
"publisher": "g1nation",
|
"publisher": "g1nation",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"icon": "assets/icon.png",
|
"icon": "assets/icon.png",
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { SidebarChatProvider } from '../sidebarProvider';
|
import { SidebarChatProvider } from '../sidebarProvider';
|
||||||
import { logInfo } from '../utils';
|
import { logError, logInfo } from '../utils';
|
||||||
import { resolveScopeForAgent, openKnowledgeMapEditor } from '../skills/agentKnowledgeMap';
|
import {
|
||||||
|
resolveScopeForAgent,
|
||||||
|
openKnowledgeMapEditor,
|
||||||
|
getOrCreateAgentEntry,
|
||||||
|
upsertAgentEntry,
|
||||||
|
} from '../skills/agentKnowledgeMap';
|
||||||
import { getActiveBrainProfile } from '../utils';
|
import { getActiveBrainProfile } from '../utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,6 +59,90 @@ export async function handleAgentMessage(provider: SidebarChatProvider, data: an
|
|||||||
case 'editKnowledgeMap':
|
case 'editKnowledgeMap':
|
||||||
await openKnowledgeMapEditor();
|
await openKnowledgeMapEditor();
|
||||||
return true;
|
return true;
|
||||||
|
case 'getAgentMap': {
|
||||||
|
const view = (provider as any)._view as vscode.WebviewView | undefined;
|
||||||
|
if (!view) return true;
|
||||||
|
try {
|
||||||
|
const entry = getOrCreateAgentEntry(data.agentPath || '');
|
||||||
|
const knowledgeMapHasEntry = entry.knowledgeFolders.length > 0 || (entry.skillFolders?.length ?? 0) > 0;
|
||||||
|
view.webview.postMessage({
|
||||||
|
type: 'agentMapData',
|
||||||
|
value: {
|
||||||
|
name: entry.name,
|
||||||
|
knowledgeFolders: entry.knowledgeFolders,
|
||||||
|
skillFolders: entry.skillFolders || [],
|
||||||
|
exists: knowledgeMapHasEntry,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('agent-map: load failed.', { error: e?.message ?? String(e) });
|
||||||
|
view.webview.postMessage({
|
||||||
|
type: 'agentMapData',
|
||||||
|
value: { name: '', knowledgeFolders: [], skillFolders: [], exists: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case 'saveAgentMap': {
|
||||||
|
const view = (provider as any)._view as vscode.WebviewView | undefined;
|
||||||
|
if (!view) return true;
|
||||||
|
const agentPath = typeof data.agentPath === 'string' ? data.agentPath : '';
|
||||||
|
const name = path.basename(agentPath).replace(/\.(md|markdown)$/i, '').trim();
|
||||||
|
const knowledgeFolders = Array.isArray(data.knowledgeFolders)
|
||||||
|
? data.knowledgeFolders.filter((f: unknown) => typeof f === 'string')
|
||||||
|
: [];
|
||||||
|
const skillFolders = Array.isArray(data.skillFolders)
|
||||||
|
? data.skillFolders.filter((f: unknown) => typeof f === 'string')
|
||||||
|
: [];
|
||||||
|
const result = upsertAgentEntry({
|
||||||
|
name,
|
||||||
|
knowledgeFolders,
|
||||||
|
skillFolders,
|
||||||
|
});
|
||||||
|
view.webview.postMessage({
|
||||||
|
type: 'agentMapSaved',
|
||||||
|
value: { ok: result.ok, path: result.path, error: result.error },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case 'pickPath': {
|
||||||
|
const view = (provider as any)._view as vscode.WebviewView | undefined;
|
||||||
|
if (!view) return true;
|
||||||
|
const kind = data.kind === 'skillFile' ? 'skillFile'
|
||||||
|
: data.kind === 'skillFolder' ? 'skillFolder'
|
||||||
|
: 'knowledgeFolder';
|
||||||
|
const isFile = kind === 'skillFile';
|
||||||
|
const label = kind === 'knowledgeFolder' ? 'Select Knowledge Folder'
|
||||||
|
: kind === 'skillFolder' ? 'Select Skill Folder'
|
||||||
|
: 'Select Skill File (.md)';
|
||||||
|
const defaultUri = (() => {
|
||||||
|
if (kind === 'knowledgeFolder') {
|
||||||
|
const brain = getActiveBrainProfile();
|
||||||
|
if (brain?.localBrainPath) return vscode.Uri.file(brain.localBrainPath);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
const picked = await vscode.window.showOpenDialog({
|
||||||
|
canSelectFiles: isFile,
|
||||||
|
canSelectFolders: !isFile,
|
||||||
|
canSelectMany: false,
|
||||||
|
openLabel: label,
|
||||||
|
defaultUri,
|
||||||
|
filters: isFile ? { Markdown: ['md', 'markdown'] } : undefined,
|
||||||
|
});
|
||||||
|
const fsPath = picked?.[0]?.fsPath || '';
|
||||||
|
if (fsPath) {
|
||||||
|
view.webview.postMessage({
|
||||||
|
type: 'pickedPath',
|
||||||
|
value: { kind, path: fsPath },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('agent-map: pick failed.', { kind, error: e?.message ?? String(e) });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { handleChatMessage } from './sidebar/chatHandlers';
|
|||||||
import { handleBrainMessage } from './sidebar/brainHandlers';
|
import { handleBrainMessage } from './sidebar/brainHandlers';
|
||||||
import { handleChronicleMessage } from './sidebar/chronicleHandlers';
|
import { handleChronicleMessage } from './sidebar/chronicleHandlers';
|
||||||
import { handleAgentMessage } from './sidebar/agentHandlers';
|
import { handleAgentMessage } from './sidebar/agentHandlers';
|
||||||
|
import { getOrCreateAgentEntry } from './skills/agentKnowledgeMap';
|
||||||
|
import { loadExternalSkills, formatSkillsAsPromptBlock } from './skills/externalSkillLoader';
|
||||||
|
|
||||||
export interface SidebarLmStudioDeps {
|
export interface SidebarLmStudioDeps {
|
||||||
lifecycle: ModelLifecycleManager;
|
lifecycle: ModelLifecycleManager;
|
||||||
@@ -1709,6 +1711,21 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
let agentSkillContext = undefined;
|
let agentSkillContext = undefined;
|
||||||
if (agentFile && fs.existsSync(agentFile)) {
|
if (agentFile && fs.existsSync(agentFile)) {
|
||||||
agentSkillContext = fs.readFileSync(agentFile, 'utf8');
|
agentSkillContext = fs.readFileSync(agentFile, 'utf8');
|
||||||
|
|
||||||
|
// Merge in any external skill .md files the user has mapped to this
|
||||||
|
// agent. We concatenate into the same agentSkillContext blob so the
|
||||||
|
// rest of the pipeline (agent.ts, agent-mode override) treats them
|
||||||
|
// identically to the agent's own .md — no further changes needed.
|
||||||
|
try {
|
||||||
|
const entry = getOrCreateAgentEntry(agentFile);
|
||||||
|
const bundle = loadExternalSkills(entry.skillFolders);
|
||||||
|
const block = formatSkillsAsPromptBlock(bundle);
|
||||||
|
if (block) {
|
||||||
|
agentSkillContext = `${agentSkillContext.trim()}\n\n${block}`;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('External skill load failed.', { error: e?.message || String(e) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const designerContext = designerGuard !== false ? this._buildDesignerGuardContext() : undefined;
|
const designerContext = designerGuard !== false ? this._buildDesignerGuardContext() : undefined;
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ export interface AgentKnowledgeEntry {
|
|||||||
name: string;
|
name: string;
|
||||||
/** Folders this agent should retrieve from. Absolute, ~-prefixed, or brain-relative. */
|
/** Folders this agent should retrieve from. Absolute, ~-prefixed, or brain-relative. */
|
||||||
knowledgeFolders: string[];
|
knowledgeFolders: string[];
|
||||||
|
/**
|
||||||
|
* External skill folders or individual .md files mapped to this agent.
|
||||||
|
* Each entry is either an absolute folder/file path or ~-prefixed.
|
||||||
|
* Contents are loaded at chat time and concatenated into the agent's system
|
||||||
|
* prompt (always-on, not search-gated like knowledgeFolders).
|
||||||
|
*/
|
||||||
|
skillFolders?: string[];
|
||||||
/** Optional: pinned model override for this agent (e.g. `qwen3:8b`). */
|
/** Optional: pinned model override for this agent (e.g. `qwen3:8b`). */
|
||||||
model?: string;
|
model?: string;
|
||||||
/** Optional: human-friendly note shown in UI hints. */
|
/** Optional: human-friendly note shown in UI hints. */
|
||||||
@@ -82,9 +89,14 @@ function _coerceMap(raw: unknown): AgentKnowledgeMap {
|
|||||||
const folders = foldersRaw
|
const folders = foldersRaw
|
||||||
.map((f) => (typeof f === 'string' ? f.trim() : ''))
|
.map((f) => (typeof f === 'string' ? f.trim() : ''))
|
||||||
.filter((f) => f.length > 0);
|
.filter((f) => f.length > 0);
|
||||||
|
const skillsRaw = Array.isArray(a.skillFolders) ? a.skillFolders : [];
|
||||||
|
const skillFolders = skillsRaw
|
||||||
|
.map((f) => (typeof f === 'string' ? f.trim() : ''))
|
||||||
|
.filter((f) => f.length > 0);
|
||||||
agents.push({
|
agents.push({
|
||||||
name,
|
name,
|
||||||
knowledgeFolders: folders,
|
knowledgeFolders: folders,
|
||||||
|
skillFolders: skillFolders.length > 0 ? skillFolders : undefined,
|
||||||
model: typeof a.model === 'string' && a.model.trim() ? a.model.trim() : undefined,
|
model: typeof a.model === 'string' && a.model.trim() ? a.model.trim() : undefined,
|
||||||
description: typeof a.description === 'string' && a.description.trim() ? a.description.trim() : undefined,
|
description: typeof a.description === 'string' && a.description.trim() ? a.description.trim() : undefined,
|
||||||
});
|
});
|
||||||
@@ -209,6 +221,94 @@ export function listMappedAgents(): AgentKnowledgeEntry[] {
|
|||||||
return loadKnowledgeMap().map.agents;
|
return loadKnowledgeMap().map.agents;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the mapping to disk. Creates the `.astra` directory if needed.
|
||||||
|
* Used by the in-sidebar mapping UI so non-developers never have to touch JSON.
|
||||||
|
*/
|
||||||
|
export function saveKnowledgeMap(map: AgentKnowledgeMap): { ok: boolean; path: string; error?: string } {
|
||||||
|
const jsonPath = resolveKnowledgeMapJsonPath();
|
||||||
|
if (!jsonPath) {
|
||||||
|
return { ok: false, path: '', error: '워크스페이스가 열려있지 않습니다.' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const dir = path.dirname(jsonPath);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const normalized: AgentKnowledgeMap = {
|
||||||
|
defaultAgent: map.defaultAgent?.trim() || undefined,
|
||||||
|
agents: (map.agents || []).map((a) => ({
|
||||||
|
name: a.name.trim(),
|
||||||
|
knowledgeFolders: (a.knowledgeFolders || []).map((f) => f.trim()).filter(Boolean),
|
||||||
|
skillFolders: (a.skillFolders || []).map((f) => f.trim()).filter(Boolean),
|
||||||
|
model: a.model?.trim() || undefined,
|
||||||
|
description: a.description?.trim() || undefined,
|
||||||
|
})).filter((a) => a.name.length > 0),
|
||||||
|
};
|
||||||
|
// Drop empty skillFolders arrays so the JSON stays clean for entries
|
||||||
|
// that never used the new feature.
|
||||||
|
for (const a of normalized.agents) {
|
||||||
|
if (!a.skillFolders || a.skillFolders.length === 0) delete a.skillFolders;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(jsonPath, JSON.stringify(normalized, null, 2), 'utf8');
|
||||||
|
logInfo('agent-knowledge-map: saved.', { jsonPath, agents: normalized.agents.length });
|
||||||
|
return { ok: true, path: jsonPath };
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('agent-knowledge-map: save failed.', { jsonPath, error: e?.message ?? String(e) });
|
||||||
|
return { ok: false, path: jsonPath, error: e?.message ?? String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the mapping for one agent, creating a stub entry if missing.
|
||||||
|
* The stub is *not* written to disk — saveKnowledgeMap persists it only after
|
||||||
|
* the user actually changes something in the UI.
|
||||||
|
*/
|
||||||
|
export function getOrCreateAgentEntry(agentName: string): AgentKnowledgeEntry {
|
||||||
|
const normalized = _normalizeAgentName(agentName);
|
||||||
|
const { map } = loadKnowledgeMap();
|
||||||
|
const existing = map.agents.find((a) => a.name === normalized);
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
name: existing.name,
|
||||||
|
knowledgeFolders: [...(existing.knowledgeFolders || [])],
|
||||||
|
skillFolders: [...(existing.skillFolders || [])],
|
||||||
|
model: existing.model,
|
||||||
|
description: existing.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: normalized,
|
||||||
|
knowledgeFolders: [],
|
||||||
|
skillFolders: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert (insert-or-update) a single agent entry and persist the map.
|
||||||
|
* Used by the sidebar Save button.
|
||||||
|
*/
|
||||||
|
export function upsertAgentEntry(entry: AgentKnowledgeEntry): { ok: boolean; path: string; error?: string } {
|
||||||
|
const normalizedName = _normalizeAgentName(entry.name);
|
||||||
|
if (!normalizedName) {
|
||||||
|
return { ok: false, path: '', error: '에이전트 이름이 비어 있습니다.' };
|
||||||
|
}
|
||||||
|
const { map } = loadKnowledgeMap();
|
||||||
|
const next: AgentKnowledgeMap = {
|
||||||
|
defaultAgent: map.defaultAgent,
|
||||||
|
agents: [...map.agents],
|
||||||
|
};
|
||||||
|
const idx = next.agents.findIndex((a) => a.name === normalizedName);
|
||||||
|
const merged: AgentKnowledgeEntry = {
|
||||||
|
name: normalizedName,
|
||||||
|
knowledgeFolders: entry.knowledgeFolders || [],
|
||||||
|
skillFolders: entry.skillFolders || [],
|
||||||
|
model: entry.model,
|
||||||
|
description: entry.description,
|
||||||
|
};
|
||||||
|
if (idx >= 0) next.agents[idx] = merged;
|
||||||
|
else next.agents.push(merged);
|
||||||
|
return saveKnowledgeMap(next);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the JSON mapping file in the editor, scaffolding a starter document if
|
* Open the JSON mapping file in the editor, scaffolding a starter document if
|
||||||
* one doesn't exist yet. Idempotent — safe to wire to a `g1nation.skills.editKnowledgeMap`
|
* one doesn't exist yet. Idempotent — safe to wire to a `g1nation.skills.editKnowledgeMap`
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { resolvePathInput } from '../lib/paths';
|
||||||
|
import { logError, logInfo } from '../utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads markdown content from external skill folders/files mapped to an agent.
|
||||||
|
*
|
||||||
|
* Unlike `knowledgeFolders` (which feed the RAG retriever and are search-gated),
|
||||||
|
* `skillFolders` entries are loaded eagerly and concatenated into the agent's
|
||||||
|
* system prompt — same role as the agent's own `.md` file, just sourced from
|
||||||
|
* the user's own library outside `.agent/skills/`.
|
||||||
|
*
|
||||||
|
* Each spec can be:
|
||||||
|
* - an absolute folder path → every `.md` file directly inside is loaded
|
||||||
|
* - an absolute file path ending in `.md` → that single file is loaded
|
||||||
|
* - `~`-prefixed forms of either of the above
|
||||||
|
*
|
||||||
|
* We intentionally do NOT recurse into subfolders. Users who want hierarchy
|
||||||
|
* pick the specific subfolder; that keeps the contract predictable and avoids
|
||||||
|
* accidentally pulling in large trees.
|
||||||
|
*
|
||||||
|
* Skills outside the brain root are allowed (unlike knowledgeFolders) because
|
||||||
|
* skill libraries typically live with the user (e.g. ~/Documents/agent-skills),
|
||||||
|
* not inside a particular brain. Read-only, never written.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_SKILL_FILES = 64;
|
||||||
|
const MAX_SKILL_BYTES = 512 * 1024; // 512 KB per file ceiling
|
||||||
|
|
||||||
|
export interface LoadedSkill {
|
||||||
|
/** Display name derived from the filename (no extension). */
|
||||||
|
name: string;
|
||||||
|
/** Absolute path the content was read from. */
|
||||||
|
filePath: string;
|
||||||
|
/** Raw markdown body. */
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadedSkillBundle {
|
||||||
|
skills: LoadedSkill[];
|
||||||
|
/** Specs that couldn't be resolved or contained no .md files. For UI hints. */
|
||||||
|
skipped: { spec: string; reason: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadExternalSkills(specs: string[] | undefined): LoadedSkillBundle {
|
||||||
|
const skills: LoadedSkill[] = [];
|
||||||
|
const skipped: { spec: string; reason: string }[] = [];
|
||||||
|
if (!Array.isArray(specs) || specs.length === 0) return { skills, skipped };
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const rawSpec of specs) {
|
||||||
|
if (skills.length >= MAX_SKILL_FILES) {
|
||||||
|
skipped.push({ spec: rawSpec, reason: `최대 스킬 파일 수(${MAX_SKILL_FILES}) 초과` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const resolved = resolvePathInput(rawSpec || '');
|
||||||
|
if (!resolved) {
|
||||||
|
skipped.push({ spec: rawSpec, reason: '경로를 해석할 수 없음 (절대경로 또는 ~ 형식이어야 합니다)' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stat: fs.Stats;
|
||||||
|
try {
|
||||||
|
stat = fs.statSync(resolved);
|
||||||
|
} catch {
|
||||||
|
skipped.push({ spec: rawSpec, reason: '경로가 존재하지 않음' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stat.isFile()) {
|
||||||
|
if (!/\.(md|markdown)$/i.test(resolved)) {
|
||||||
|
skipped.push({ spec: rawSpec, reason: '.md 파일이 아님' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_tryAddFile(resolved, skills, skipped, seen, rawSpec);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
let entries: string[] = [];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(resolved);
|
||||||
|
} catch (e: any) {
|
||||||
|
skipped.push({ spec: rawSpec, reason: `폴더 읽기 실패: ${e?.message ?? e}` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const mdFiles = entries
|
||||||
|
.filter((n) => /\.(md|markdown)$/i.test(n))
|
||||||
|
.map((n) => path.join(resolved, n))
|
||||||
|
.sort();
|
||||||
|
if (mdFiles.length === 0) {
|
||||||
|
skipped.push({ spec: rawSpec, reason: '폴더에 .md 파일이 없음' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const filePath of mdFiles) {
|
||||||
|
if (skills.length >= MAX_SKILL_FILES) break;
|
||||||
|
_tryAddFile(filePath, skills, skipped, seen, rawSpec);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
skipped.push({ spec: rawSpec, reason: '파일도 폴더도 아님 (심볼릭 링크?)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skills.length > 0) {
|
||||||
|
logInfo('external-skills: loaded.', { count: skills.length, skipped: skipped.length });
|
||||||
|
}
|
||||||
|
return { skills, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _tryAddFile(
|
||||||
|
absPath: string,
|
||||||
|
skills: LoadedSkill[],
|
||||||
|
skipped: { spec: string; reason: string }[],
|
||||||
|
seen: Set<string>,
|
||||||
|
originalSpec: string
|
||||||
|
): void {
|
||||||
|
const key = path.normalize(absPath);
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
|
||||||
|
let content = '';
|
||||||
|
try {
|
||||||
|
const fileStat = fs.statSync(absPath);
|
||||||
|
if (fileStat.size > MAX_SKILL_BYTES) {
|
||||||
|
skipped.push({
|
||||||
|
spec: originalSpec,
|
||||||
|
reason: `${path.basename(absPath)}: 파일이 너무 큼 (${fileStat.size} bytes > ${MAX_SKILL_BYTES})`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
content = fs.readFileSync(absPath, 'utf8');
|
||||||
|
} catch (e: any) {
|
||||||
|
skipped.push({ spec: originalSpec, reason: `${path.basename(absPath)} 읽기 실패: ${e?.message ?? e}` });
|
||||||
|
logError('external-skills: file read failed.', { absPath, error: e?.message ?? String(e) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = path.basename(absPath).replace(/\.(md|markdown)$/i, '');
|
||||||
|
skills.push({ name, filePath: absPath, content });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the bundle as a single markdown block ready to append to the agent's
|
||||||
|
* system prompt. Returns empty string when no skills loaded — caller can then
|
||||||
|
* skip injection entirely without an empty section header.
|
||||||
|
*/
|
||||||
|
export function formatSkillsAsPromptBlock(bundle: LoadedSkillBundle): string {
|
||||||
|
if (!bundle.skills.length) return '';
|
||||||
|
const parts: string[] = ['## External Skills', ''];
|
||||||
|
for (const skill of bundle.skills) {
|
||||||
|
parts.push(`### Skill: ${skill.name}`);
|
||||||
|
parts.push(skill.content.trim());
|
||||||
|
parts.push('');
|
||||||
|
}
|
||||||
|
return parts.join('\n').trim();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user