chore: version up to v2.80.30 and package vsix

This commit is contained in:
g1nation
2026-05-11 12:44:38 +09:00
parent 789680ccb1
commit 3c2b62c018
9 changed files with 678 additions and 4 deletions
+91 -2
View File
@@ -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;
}
+17
View File
@@ -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;
+100
View File
@@ -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`
+159
View File
@@ -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();
}