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();
+}