diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 35fb66b..31efd26 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,14 @@ # 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) ### πŸ›‘οΈ Service Reliability & Telegram Integration - **ν…”λ ˆκ·Έλž¨ 봇 μ•ˆμ •ν™” (Telegram Bot Stabilization):** `telegramBot.ts` λ‚΄μ˜ 폴링 및 응닡 λ‘œμ§μ„ κ°œμ„ ν•˜μ—¬ λ©”μ‹œμ§€ λˆ„λ½ 및 μ—°κ²° μ§€μ—° 문제λ₯Ό ν•΄κ²°ν–ˆμŠ΅λ‹ˆλ‹€. diff --git a/media/sidebar.css b/media/sidebar.css index 37b9b21..a94f850 100644 --- a/media/sidebar.css +++ b/media/sidebar.css @@ -515,6 +515,128 @@ 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 --- */ button { transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); diff --git a/media/sidebar.html b/media/sidebar.html index a3e759f..e45a373 100644 --- a/media/sidebar.html +++ b/media/sidebar.html @@ -82,6 +82,57 @@
+
+
+
+

Agent Mapping

+

+ μ„ νƒν•œ μ—μ΄μ „νŠΈμ— 지식 폴더와 μ™ΈλΆ€ μŠ€ν‚¬ 폴더/νŒŒμΌμ„ μ—°κ²°ν•©λ‹ˆλ‹€. +

+
+ +
+ +
+
Agent
+
(μ„ νƒλœ μ—μ΄μ „νŠΈκ°€ μ—†μŠ΅λ‹ˆλ‹€)
+
+ +
+
+
+
πŸ“š Knowledge Folders
+
Brain μ•ˆμ—μ„œ 검색(RAG)에 μ‚¬μš©ν•  ν΄λ”μž…λ‹ˆλ‹€. 폴더가 Brain μ™ΈλΆ€λ©΄ μžλ™μœΌλ‘œ μ œμ™Έλ©λ‹ˆλ‹€.
+
+ +
+ +
+ +
+
+
+
πŸ›  External Skills
+
.md μŠ€ν‚¬ 파일이 λ“€μ–΄ μžˆλŠ” 폴더 λ˜λŠ” κ°œλ³„ .md νŒŒμΌμ„ μ—°κ²°ν•˜μ„Έμš”. μ±„νŒ… μ‹œ 항상 ν•¨κ»˜ λ‘œλ“œλ©λ‹ˆλ‹€.
+
+
+ + +
+
+ +
+ + + +
+
+
diff --git a/media/sidebar.js b/media/sidebar.js index e70892e..2db9392 100644 --- a/media/sidebar.js +++ b/media/sidebar.js @@ -157,6 +157,72 @@ const agentPrompt = document.getElementById('agentPrompt'); const negativePrompt = document.getElementById('negativePrompt'); 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 internetEnabled = false; @@ -387,6 +453,41 @@ } vscode.postMessage({ type: 'getKnowledgeScope', agentPath: msg.selected }); 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': if (knowledgeScopeSel) { knowledgeScopeSel.innerHTML = ''; @@ -706,11 +807,37 @@ }; if (editKnowledgeMapBtn) { - editKnowledgeMapBtn.onclick = () => vscode.postMessage({ type: 'editKnowledgeMap' }); + editKnowledgeMapBtn.onclick = () => openAgentMapModal(); } if (reloadKnowledgeMapBtn) { 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 = () => { if (agentSel.value === 'none') return; diff --git a/package.json b/package.json index a4a0c7d..492e616 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "astra", "displayName": "Astra", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", - "version": "2.80.29", + "version": "2.80.30", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", diff --git a/src/sidebar/agentHandlers.ts b/src/sidebar/agentHandlers.ts index f4039b8..49132a0 100644 --- a/src/sidebar/agentHandlers.ts +++ b/src/sidebar/agentHandlers.ts @@ -1,8 +1,13 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { SidebarChatProvider } from '../sidebarProvider'; -import { logInfo } from '../utils'; -import { resolveScopeForAgent, openKnowledgeMapEditor } from '../skills/agentKnowledgeMap'; +import { logError, logInfo } from '../utils'; +import { + resolveScopeForAgent, + openKnowledgeMapEditor, + getOrCreateAgentEntry, + upsertAgentEntry, +} from '../skills/agentKnowledgeMap'; import { getActiveBrainProfile } from '../utils'; /** @@ -54,6 +59,90 @@ export async function handleAgentMessage(provider: SidebarChatProvider, data: an case 'editKnowledgeMap': await openKnowledgeMapEditor(); 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: return false; } diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts index 8c94313..e2b2ead 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -22,6 +22,8 @@ import { handleChatMessage } from './sidebar/chatHandlers'; import { handleBrainMessage } from './sidebar/brainHandlers'; import { handleChronicleMessage } from './sidebar/chronicleHandlers'; import { handleAgentMessage } from './sidebar/agentHandlers'; +import { getOrCreateAgentEntry } from './skills/agentKnowledgeMap'; +import { loadExternalSkills, formatSkillsAsPromptBlock } from './skills/externalSkillLoader'; export interface SidebarLmStudioDeps { lifecycle: ModelLifecycleManager; @@ -1709,6 +1711,21 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn let agentSkillContext = undefined; if (agentFile && fs.existsSync(agentFile)) { 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; diff --git a/src/skills/agentKnowledgeMap.ts b/src/skills/agentKnowledgeMap.ts index 5883cca..a025783 100644 --- a/src/skills/agentKnowledgeMap.ts +++ b/src/skills/agentKnowledgeMap.ts @@ -33,6 +33,13 @@ export interface AgentKnowledgeEntry { name: string; /** Folders this agent should retrieve from. Absolute, ~-prefixed, or brain-relative. */ 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`). */ model?: string; /** Optional: human-friendly note shown in UI hints. */ @@ -82,9 +89,14 @@ function _coerceMap(raw: unknown): AgentKnowledgeMap { const folders = foldersRaw .map((f) => (typeof f === 'string' ? f.trim() : '')) .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({ name, knowledgeFolders: folders, + skillFolders: skillFolders.length > 0 ? skillFolders : 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, }); @@ -209,6 +221,94 @@ export function listMappedAgents(): AgentKnowledgeEntry[] { 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 * one doesn't exist yet. Idempotent β€” safe to wire to a `g1nation.skills.editKnowledgeMap` diff --git a/src/skills/externalSkillLoader.ts b/src/skills/externalSkillLoader.ts new file mode 100644 index 0000000..6088342 --- /dev/null +++ b/src/skills/externalSkillLoader.ts @@ -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(); + + 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, + 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(); +}