diff --git a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json
index eb60165..b76b2f4 100644
--- a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json
+++ b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json
@@ -1,5 +1,5 @@
{
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
- "createdAt": 1778256848559,
+ "createdAt": 1778419501265,
"modelVersion": "unknown"
}
\ No newline at end of file
diff --git a/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json b/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json
index 24d6f4a..b83f3e5 100644
--- a/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json
+++ b/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json
@@ -1,5 +1,5 @@
{
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
- "createdAt": 1778256848551,
+ "createdAt": 1778419501264,
"modelVersion": "unknown"
}
\ No newline at end of file
diff --git a/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json b/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json
index 13ed00b..59d1cae 100644
--- a/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json
+++ b/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json
@@ -1,5 +1,5 @@
{
"result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
- "createdAt": 1778256848546,
+ "createdAt": 1778419501204,
"modelVersion": "unknown"
}
\ No newline at end of file
diff --git a/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json b/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json
index 946dbbc..f9a4576 100644
--- a/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json
+++ b/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json
@@ -1,5 +1,5 @@
{
- "result": "---\nid: stress_conflict_1778256848530\ndate: 2026-05-08T16:14:08.563Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (11ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (5ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (9ms)\n",
- "createdAt": 1778256848563,
+ "result": "---\nid: stress_conflict_1778419501171\ndate: 2026-05-10T13:25:01.265Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.1s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (32ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (1ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (61ms)\n",
+ "createdAt": 1778419501265,
"modelVersion": "unknown"
}
\ No newline at end of file
diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1778256848530.json b/.astra/tests/stress/.astra/missions/stress_conflict_1778419501171.json
similarity index 78%
rename from .astra/tests/stress/.astra/missions/stress_conflict_1778256848530.json
rename to .astra/tests/stress/.astra/missions/stress_conflict_1778419501171.json
index 24da0e8..5d894e2 100644
--- a/.astra/tests/stress/.astra/missions/stress_conflict_1778256848530.json
+++ b/.astra/tests/stress/.astra/missions/stress_conflict_1778419501171.json
@@ -1,8 +1,8 @@
{
- "missionId": "stress_conflict_1778256848530",
+ "missionId": "stress_conflict_1778419501171",
"status": "completed",
- "startTime": "2026-05-08T16:14:08.530Z",
- "totalElapsedMs": 34,
+ "startTime": "2026-05-10T13:25:01.171Z",
+ "totalElapsedMs": 94,
"results": {
"planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
"researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
@@ -16,30 +16,30 @@
{
"from": "idle",
"to": "planner",
- "durationMs": 11,
+ "durationMs": 32,
"message": "전략 수립 중...",
- "ts": "2026-05-08T16:14:08.541Z"
+ "ts": "2026-05-10T13:25:01.203Z"
},
{
"from": "planner",
"to": "researcher",
- "durationMs": 5,
+ "durationMs": 1,
"message": "핵심 정보 수집 및 분석 중...",
- "ts": "2026-05-08T16:14:08.546Z"
+ "ts": "2026-05-10T13:25:01.204Z"
},
{
"from": "researcher",
"to": "writer",
- "durationMs": 9,
+ "durationMs": 61,
"message": "최종 리포트 작성 및 편집 중...",
- "ts": "2026-05-08T16:14:08.555Z"
+ "ts": "2026-05-10T13:25:01.265Z"
},
{
"from": "writer",
"to": "completed",
- "durationMs": 9,
+ "durationMs": 0,
"message": "미션 완료",
- "ts": "2026-05-08T16:14:08.564Z"
+ "ts": "2026-05-10T13:25:01.265Z"
}
],
"resilienceMetrics": {
diff --git a/docs/records/ConnectAI/chronicle.config.json b/docs/records/ConnectAI/chronicle.config.json
index ea44e31..cb28e81 100644
--- a/docs/records/ConnectAI/chronicle.config.json
+++ b/docs/records/ConnectAI/chronicle.config.json
@@ -6,6 +6,6 @@
"description": "Auto-detected from the local project path in the conversation.",
"corePurpose": "Capture project direction, architecture discussion, decisions, and development notes as Markdown.",
"detailLevel": "standard",
- "createdAt": "2026-05-05T13:14:39.155Z",
- "updatedAt": "2026-05-05T13:14:39.160Z"
+ "createdAt": "2026-05-10T07:42:38.921Z",
+ "updatedAt": "2026-05-10T07:42:38.928Z"
}
diff --git a/docs/records/ConnectAI/decisions/ADR-0007-volumes-data-project-antigravity-connectai-이거에-기능-개선을-하고-싶어-.md b/docs/records/ConnectAI/decisions/ADR-0007-volumes-data-project-antigravity-connectai-이거에-기능-개선을-하고-싶어-.md
new file mode 100644
index 0000000..145a992
--- /dev/null
+++ b/docs/records/ConnectAI/decisions/ADR-0007-volumes-data-project-antigravity-connectai-이거에-기능-개선을-하고-싶어-.md
@@ -0,0 +1,19 @@
+# ADR: /Volumes/Data/project/Antigravity/ConnectAI 이거에 기능 개선을 하고 싶어. 너가 기획과 방향성을 정해주면 좋...
+
+## Status
+accepted
+
+## Context
+/Volumes/Data/project/Antigravity/ConnectAI 이거에 기능 개선을 하고 싶어. 너가 기획과 방향성을 정해주면 좋겠어. 내가 원하는 것은 아래와 같아. 1. 에이전트 선택 시 에이전트에 연관할 젠2뇌 지식 선택, 모델 선택을 맵핑 하고, 에이전트가 사용되는 skill 을 폴더로 선택할 수 있게 해주면 좋겠어.
+
+## Decision
+## 간단 요약 제안하신 기능은 에이전트와 지식 간의 연결을 명확히 하여 **사용자 경험(UX)과 지식 활용도를 극대화**하는 데 매우 긍정적입니다. 하지만 이는 단순한 UI 추가가 아니라, 기존의 복잡한 지식 그래프와 에이전트 워크플로우를 더욱 견고하게 만드는 아키텍처 개선을 요구합니다. ## 상세 설명 - **근본적인 문제:** 현재 시스템은 에이전트 선택과 지식 검색이 분리되어 있어, 사용자가 어떤 에이전트를 선택했을 때 그 에이전트가 가장 적합한 지식(Second Brain)을 자동으로 연결해주는 '맥락적 매핑' 기능이 부족합니다. - **기능적 개선 방향:** 에이전트의 역할(Skill/Folder)과 연관된 지식 세트(Knowledge Base)를 명시적으로 매핑하는 중간 계층이 필요합니다. 이는 사용자가 원하는 결과물을 얻기 위해 '어떤 지식을 불러와야 하는지'에 대한 불확실성을 제거해줍니다. - **사용자 경험 개선:** 폴더 기반 선택은 사용자가 자신의 작업 영역(W...
+
+## Reason
+Captured automatically because the conversation contained decision-oriented language.
+
+## Alternatives
+Not captured yet.
+
+## Consequences
+- Future prompts should treat this as project context unless the user changes direction.
diff --git a/docs/records/ConnectAI/timeline.md b/docs/records/ConnectAI/timeline.md
index 96f82b3..3ecc53a 100644
--- a/docs/records/ConnectAI/timeline.md
+++ b/docs/records/ConnectAI/timeline.md
@@ -81,3 +81,6 @@
## 2026-05-05
- Auto development record created: development/2026-05-05_volumes-data-project-antigravity-connectai-오늘-많은-것을-업데이트했어-많_implementation.md
+
+## 2026-05-10
+- Auto decision record created: decisions/ADR-0007-volumes-data-project-antigravity-connectai-이거에-기능-개선을-하고-싶어-.md
diff --git a/media/sidebar.html b/media/sidebar.html
index 56215cb..a3e759f 100644
--- a/media/sidebar.html
+++ b/media/sidebar.html
@@ -45,6 +45,13 @@
+
+
+
+
+
+
+
diff --git a/media/sidebar.js b/media/sidebar.js
index a5fd645..e70892e 100644
--- a/media/sidebar.js
+++ b/media/sidebar.js
@@ -146,6 +146,9 @@
const editAgentBtn = document.getElementById('editAgentBtn');
const addAgentBtn = document.getElementById('addAgentBtn');
const deleteAgentBtn = document.getElementById('deleteAgentBtn');
+ const knowledgeScopeSel = document.getElementById('knowledgeScopeSel');
+ const editKnowledgeMapBtn = document.getElementById('editKnowledgeMapBtn');
+ const reloadKnowledgeMapBtn = document.getElementById('reloadKnowledgeMapBtn');
const addBrainBtn = document.getElementById('addBrainBtn');
const editBrainBtn = document.getElementById('editBrainBtn');
const deleteBrainBtn = document.getElementById('deleteBrainBtn');
@@ -382,6 +385,32 @@
if (msg.selected && msg.selected !== 'none') {
vscode.postMessage({ type: 'getAgentContent', path: msg.selected });
}
+ vscode.postMessage({ type: 'getKnowledgeScope', agentPath: msg.selected });
+ break;
+ case 'knowledgeScope':
+ if (knowledgeScopeSel) {
+ knowledgeScopeSel.innerHTML = '';
+ const folders = (msg.value && msg.value.folders) || [];
+ if (folders.length === 0) {
+ const o = document.createElement('option');
+ o.value = '';
+ const label = (msg.value && msg.value.agent)
+ ? `매핑된 폴더 없음 (agent: ${msg.value.agent})`
+ : '매핑 없음 — 전체 브레인 검색';
+ o.innerText = label;
+ knowledgeScopeSel.appendChild(o);
+ knowledgeScopeSel.disabled = true;
+ } else {
+ knowledgeScopeSel.disabled = false;
+ folders.forEach(f => {
+ const o = document.createElement('option');
+ o.value = f.absolute;
+ o.innerText = f.relative || f.absolute;
+ o.title = f.absolute;
+ knowledgeScopeSel.appendChild(o);
+ });
+ }
+ }
break;
case 'chronicleProjects':
designerSel.innerHTML = '';
@@ -673,8 +702,16 @@
// [State Persistence Fix] 에이전트 해제도 즉시 저장
vscode.postMessage({ type: 'saveAgentSelection', path: 'none' });
}
+ vscode.postMessage({ type: 'getKnowledgeScope', agentPath: agentSel.value });
};
+ if (editKnowledgeMapBtn) {
+ editKnowledgeMapBtn.onclick = () => vscode.postMessage({ type: 'editKnowledgeMap' });
+ }
+ if (reloadKnowledgeMapBtn) {
+ reloadKnowledgeMapBtn.onclick = () => vscode.postMessage({ type: 'getKnowledgeScope', agentPath: agentSel.value });
+ }
+
editAgentBtn.onclick = () => {
if (agentSel.value === 'none') return;
editMode = !editMode;
diff --git a/package.json b/package.json
index 41a8743..2a4eef3 100644
--- a/package.json
+++ b/package.json
@@ -79,6 +79,10 @@
{
"command": "g1nation.settings.focus",
"title": "Astra: Open Settings Panel"
+ },
+ {
+ "command": "g1nation.skills.editKnowledgeMap",
+ "title": "Astra: Edit Agent ↔ Knowledge Map"
}
],
"keybindings": [
@@ -260,6 +264,34 @@
"default": [],
"items": { "type": "number" },
"description": "Optional allowlist of Telegram chat IDs that may message the bot. When empty, every chat that messages the bot is accepted (use with caution)."
+ },
+ "g1nation.telegram.defaultAgent": {
+ "type": "string",
+ "default": "",
+ "description": "Agent name (matches an entry in the Agent ↔ Knowledge map) used to scope Second Brain retrieval for Telegram replies. Empty falls back to the map's defaultAgent, then to whole-brain search."
+ },
+ "g1nation.telegram.agentByChatId": {
+ "type": "object",
+ "default": {},
+ "additionalProperties": { "type": "string" },
+ "description": "Per-chat override of the Telegram agent. Keys are stringified chat IDs, values are agent names from the knowledge map. Overrides telegram.defaultAgent for the listed chats."
+ },
+ "g1nation.telegram.contextChunks": {
+ "type": "number",
+ "default": 6,
+ "minimum": 0,
+ "maximum": 20,
+ "description": "How many Second Brain excerpts to inject into Telegram replies. Set 0 to disable RAG (plain prompt only)."
+ },
+ "g1nation.skillKnowledgeMapPath": {
+ "type": "string",
+ "default": "",
+ "description": "Absolute path to the agent ↔ knowledge mapping JSON. When empty, defaults to '/.astra/agent-knowledge-map.json'."
+ },
+ "g1nation.skillKnowledgeMap": {
+ "type": "object",
+ "default": {},
+ "description": "Inline fallback for the agent ↔ knowledge mapping. Used only when the JSON file is missing. Shape: { defaultAgent?, agents: [{ name, knowledgeFolders, model?, description? }] }. Folder paths can be absolute, ~-prefixed, or relative to the active brain root."
}
}
}
diff --git a/src/extension.ts b/src/extension.ts
index 3a0c8af..ae4ac60 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -10,7 +10,8 @@ import {
buildApiUrl,
logError,
logInfo,
- resolveEngine
+ resolveEngine,
+ getActiveBrainProfile
} from './utils';
import { getConfig, validateConfig } from './config';
import { AgentExecutor } from './agent';
@@ -32,6 +33,8 @@ import { TelegramHttpClient } from './integrations/telegram/telegramClient';
import { TelegramBot } from './integrations/telegram/telegramBot';
import { AIService } from './core/services';
import { SettingsPanelProvider } from './features/settings/settingsPanelProvider';
+import { resolveScopeForAgent, openKnowledgeMapEditor } from './skills/agentKnowledgeMap';
+import { retrieveScoped, buildContextBlock } from './skills/scopedBrainRetriever';
let _lifecycleManager: ModelLifecycleManager | undefined;
let _telegramBot: TelegramBot | undefined;
@@ -188,8 +191,47 @@ export async function activate(context: vscode.ExtensionContext) {
logInfo('Telegram message from unallowed chat ignored.', { chatId });
return null;
}
+
+ // Per-chat agent override → fall back to global default → fall back to mapping default.
+ const perChatAgents = cfg.get>('telegram.agentByChatId', {}) || {};
+ const perChatAgent = perChatAgents[String(chatId)];
+ const defaultAgent = cfg.get('telegram.defaultAgent', '') || '';
+ const agentName = (perChatAgent || defaultAgent || '').trim();
+
+ const brain = getActiveBrainProfile();
+ const brainRoot = brain?.localBrainPath || '';
+ const scope = resolveScopeForAgent(agentName, brainRoot);
+
+ // RAG retrieval — even with no agent match we still search the whole
+ // brain so the bot stays useful. The buildContextBlock label tells
+ // the user which mode they're in.
+ let contextBlock = '';
+ if (brainRoot) {
+ try {
+ const result = retrieveScoped(text, brainRoot, scope.folders, {
+ maxResults: cfg.get('telegram.contextChunks', 6) ?? 6,
+ });
+ contextBlock = buildContextBlock(result);
+ logInfo('Telegram RAG retrieval done.', {
+ chatId,
+ agent: scope.agent?.name ?? '(none)',
+ scopedFolders: scope.folders.length,
+ candidates: result.candidateCount,
+ chunks: result.chunks.length,
+ });
+ } catch (e: any) {
+ logError('Telegram RAG retrieval failed; falling back to plain prompt.', {
+ chatId, error: e?.message ?? String(e),
+ });
+ }
+ }
+
+ const composed = contextBlock
+ ? `${contextBlock}\n\n[사용자 질문]\n${text}\n\n[지시] 위 컨텍스트가 관련 있을 때만 활용하고, 답변에는 출처(파일 경로)를 인용하세요.`
+ : text;
+
try {
- const reply = await telegramAi.call(text);
+ const reply = await telegramAi.call(composed);
return (reply && reply.trim()) ? reply : '(빈 응답)';
} catch (e: any) {
return `⚠️ Astra error: ${e?.message ?? e}`;
@@ -256,6 +298,9 @@ export async function activate(context: vscode.ExtensionContext) {
vscode.window.showErrorMessage(`Telegram 연결 실패: ${e?.message ?? e}`);
}
}),
+ vscode.commands.registerCommand('g1nation.skills.editKnowledgeMap', async () => {
+ await openKnowledgeMapEditor();
+ }),
);
// Astra Settings webview — single entry point for user-facing config (Phase 5-A: Telegram only).
diff --git a/src/sidebar/agentHandlers.ts b/src/sidebar/agentHandlers.ts
index 20a921a..f4039b8 100644
--- a/src/sidebar/agentHandlers.ts
+++ b/src/sidebar/agentHandlers.ts
@@ -1,9 +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 { getActiveBrainProfile } from '../utils';
/**
* Handles agent-skill messages: the per-conversation agent picker, agent CRUD,
- * and persisting the user's last selected agent.
+ * persisting the user's last selected agent, and the knowledge-map dropdown.
*/
export async function handleAgentMessage(provider: SidebarChatProvider, data: any): Promise {
switch (data.type) {
@@ -26,6 +30,30 @@ export async function handleAgentMessage(provider: SidebarChatProvider, data: an
await provider._context.globalState.update(SidebarChatProvider.lastAgentStateKey, data.path || 'none');
logInfo(`Agent selection saved: ${data.path}`);
return true;
+ case 'getKnowledgeScope': {
+ const view = (provider as any)._view as vscode.WebviewView | undefined;
+ if (!view) return true;
+ const brain = getActiveBrainProfile();
+ const brainRoot = brain?.localBrainPath || '';
+ const scope = resolveScopeForAgent(data.agentPath || '', brainRoot);
+ const folders = scope.folders.map((abs) => ({
+ absolute: abs,
+ relative: brainRoot ? path.relative(brainRoot, abs) || abs : abs,
+ }));
+ view.webview.postMessage({
+ type: 'knowledgeScope',
+ value: {
+ agent: scope.agent?.name ?? null,
+ folders,
+ source: scope.source,
+ brainRoot,
+ },
+ });
+ return true;
+ }
+ case 'editKnowledgeMap':
+ await openKnowledgeMapEditor();
+ return true;
default:
return false;
}
diff --git a/src/skills/agentKnowledgeMap.ts b/src/skills/agentKnowledgeMap.ts
new file mode 100644
index 0000000..5883cca
--- /dev/null
+++ b/src/skills/agentKnowledgeMap.ts
@@ -0,0 +1,246 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import * as vscode from 'vscode';
+import { resolvePathInput, isInside } from '../lib/paths';
+import { logError, logInfo } from '../utils';
+
+/**
+ * Agent ↔ Knowledge mapping.
+ *
+ * MVP per the architecture proposal: each agent (markdown skill in
+ * `.agent/skills/.md`) is linked to one or more knowledge folders
+ * inside the active Brain. The mapping is the explicit middle layer that
+ * removes the "어떤 지식을 불러와야 하는가" 불확실성.
+ *
+ * Resolution order at load time:
+ * 1. JSON file at `/.astra/agent-knowledge-map.json`
+ * (or override path via `g1nation.skillKnowledgeMapPath`).
+ * 2. VS Code setting `g1nation.skillKnowledgeMap` (fallback / shared default).
+ * 3. Empty mapping — caller falls back to the whole brain.
+ *
+ * Folder paths inside an entry can be:
+ * - Absolute (`/Users/.../Wiki/10_Wiki/Topics`) — used verbatim.
+ * - Tilde-prefixed (`~/Wiki/10_Wiki/Topics`) — expanded.
+ * - Brain-relative (`10_Wiki/Topics`) — resolved against the active brain.
+ *
+ * The brain-relative form is the recommended one because it makes the same
+ * map portable across machines / brains: as long as each environment's brain
+ * root contains a `10_Wiki/Topics`, the mapping just works.
+ */
+
+export interface AgentKnowledgeEntry {
+ /** Agent name. Matches `.md` in the skills folder OR a free-form id. */
+ name: string;
+ /** Folders this agent should retrieve from. Absolute, ~-prefixed, or brain-relative. */
+ knowledgeFolders: string[];
+ /** Optional: pinned model override for this agent (e.g. `qwen3:8b`). */
+ model?: string;
+ /** Optional: human-friendly note shown in UI hints. */
+ description?: string;
+}
+
+export interface AgentKnowledgeMap {
+ /** Agent name used when no explicit selection is made (e.g. Telegram default). */
+ defaultAgent?: string;
+ agents: AgentKnowledgeEntry[];
+}
+
+export interface ResolvedScope {
+ agent: AgentKnowledgeEntry | null;
+ /** Absolute folder paths constrained to live inside `brainRoot`. */
+ folders: string[];
+ /** Source of the mapping that produced this scope (for debug surfaces). */
+ source: 'json' | 'settings' | 'none';
+}
+
+const EMPTY_MAP: AgentKnowledgeMap = { agents: [] };
+
+const DEFAULT_JSON_RELATIVE = path.join('.astra', 'agent-knowledge-map.json');
+
+function _safeReadJson(filePath: string): unknown | null {
+ try {
+ if (!fs.existsSync(filePath)) return null;
+ const raw = fs.readFileSync(filePath, 'utf8');
+ return JSON.parse(raw);
+ } catch (e: any) {
+ logError('agent-knowledge-map: JSON read failed.', { filePath, error: e?.message ?? String(e) });
+ return null;
+ }
+}
+
+function _coerceMap(raw: unknown): AgentKnowledgeMap {
+ if (!raw || typeof raw !== 'object') return EMPTY_MAP;
+ const obj = raw as Record;
+ const agentsRaw = Array.isArray(obj.agents) ? obj.agents : [];
+ const agents: AgentKnowledgeEntry[] = [];
+ for (const item of agentsRaw) {
+ if (!item || typeof item !== 'object') continue;
+ const a = item as Record;
+ const name = typeof a.name === 'string' ? a.name.trim() : '';
+ if (!name) continue;
+ const foldersRaw = Array.isArray(a.knowledgeFolders) ? a.knowledgeFolders : [];
+ const folders = foldersRaw
+ .map((f) => (typeof f === 'string' ? f.trim() : ''))
+ .filter((f) => f.length > 0);
+ agents.push({
+ name,
+ knowledgeFolders: folders,
+ model: typeof a.model === 'string' && a.model.trim() ? a.model.trim() : undefined,
+ description: typeof a.description === 'string' && a.description.trim() ? a.description.trim() : undefined,
+ });
+ }
+ const defaultAgent = typeof obj.defaultAgent === 'string' && obj.defaultAgent.trim()
+ ? obj.defaultAgent.trim()
+ : undefined;
+ return { defaultAgent, agents };
+}
+
+/**
+ * Resolve the JSON path the user has configured (or the default convention).
+ * Returns empty string when no workspace is open and no absolute override is set.
+ */
+export function resolveKnowledgeMapJsonPath(): string {
+ const cfg = vscode.workspace.getConfiguration('g1nation');
+ const override = (cfg.get('skillKnowledgeMapPath', '') || '').trim();
+ if (override) {
+ const abs = resolvePathInput(override);
+ if (abs) return abs;
+ }
+ const folders = vscode.workspace.workspaceFolders;
+ if (folders && folders.length > 0) {
+ return path.join(folders[0].uri.fsPath, DEFAULT_JSON_RELATIVE);
+ }
+ return '';
+}
+
+/**
+ * Load the mapping. Stateless: each call re-reads disk + settings, so callers
+ * always observe the latest map after `editKnowledgeMap` / settings changes.
+ */
+export function loadKnowledgeMap(): { map: AgentKnowledgeMap; source: ResolvedScope['source'] } {
+ const jsonPath = resolveKnowledgeMapJsonPath();
+ if (jsonPath) {
+ const raw = _safeReadJson(jsonPath);
+ if (raw) {
+ return { map: _coerceMap(raw), source: 'json' };
+ }
+ }
+ const settingsRaw = vscode.workspace.getConfiguration('g1nation').get('skillKnowledgeMap');
+ if (settingsRaw && typeof settingsRaw === 'object') {
+ return { map: _coerceMap(settingsRaw), source: 'settings' };
+ }
+ return { map: EMPTY_MAP, source: 'none' };
+}
+
+function _normalizeAgentName(raw: string | undefined | null): string {
+ if (!raw) return '';
+ // Accept full filesystem paths from sidebar (`.../skills/foo.md`) and
+ // collapse them to the agent name `foo`.
+ const trimmed = raw.trim();
+ if (!trimmed) return '';
+ const base = path.basename(trimmed);
+ return base.replace(/\.(md|markdown)$/i, '').trim();
+}
+
+/**
+ * Resolve a single folder spec (absolute / ~-prefixed / brain-relative) to an
+ * absolute path that is guaranteed to live inside `brainRoot`. Returns `null`
+ * when the path can't be made safe (escapes brain root, doesn't exist, etc.).
+ */
+function _resolveFolderInsideBrain(spec: string, brainRoot: string): string | null {
+ const trimmed = (spec || '').trim();
+ if (!trimmed || !brainRoot) return null;
+
+ let candidate = '';
+ if (trimmed.startsWith('~') || path.isAbsolute(trimmed)) {
+ candidate = resolvePathInput(trimmed);
+ } else {
+ candidate = path.normalize(path.join(brainRoot, trimmed));
+ }
+ if (!candidate) return null;
+
+ // Defense in depth: even an absolute spec must resolve inside the brain
+ // so the Telegram bot cannot be tricked into reading arbitrary disk via
+ // a malicious mapping.
+ if (!isInside(brainRoot, candidate)) {
+ logError('agent-knowledge-map: folder escapes brain root, ignored.', {
+ spec, candidate, brainRoot,
+ });
+ return null;
+ }
+ return candidate;
+}
+
+/**
+ * Resolve which folders the named agent should retrieve from, constrained to
+ * the active brain. Caller passes `brainRoot` (already resolved) so this stays
+ * a pure function of inputs — easy to unit test, no VS Code coupling besides
+ * the load step.
+ *
+ * If `agentName` is empty/unknown, falls through to `defaultAgent`. If still
+ * unresolved, returns an empty folder list and the caller decides whether to
+ * search the whole brain (typical chat) or refuse to answer (strict mode).
+ */
+export function resolveScopeForAgent(
+ agentName: string | undefined | null,
+ brainRoot: string
+): ResolvedScope {
+ const { map, source } = loadKnowledgeMap();
+ const normalized = _normalizeAgentName(agentName) || (map.defaultAgent ?? '');
+ const agent = normalized
+ ? (map.agents.find((a) => a.name === normalized) ?? null)
+ : null;
+ if (!agent) {
+ return { agent: null, folders: [], source };
+ }
+ const folders: string[] = [];
+ for (const spec of agent.knowledgeFolders) {
+ const resolved = _resolveFolderInsideBrain(spec, brainRoot);
+ if (resolved) folders.push(resolved);
+ }
+ return { agent, folders, source };
+}
+
+/**
+ * Convenience used by the sidebar: list every agent name in the map (for the
+ * "available agents" dropdown alongside the existing skills list).
+ */
+export function listMappedAgents(): AgentKnowledgeEntry[] {
+ return loadKnowledgeMap().map.agents;
+}
+
+/**
+ * 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`
+ * command.
+ */
+export async function openKnowledgeMapEditor(): Promise {
+ const jsonPath = resolveKnowledgeMapJsonPath();
+ if (!jsonPath) {
+ vscode.window.showErrorMessage('워크스페이스가 열려있지 않거나 skillKnowledgeMapPath가 잘못되었습니다.');
+ return;
+ }
+ try {
+ if (!fs.existsSync(jsonPath)) {
+ const dir = path.dirname(jsonPath);
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+ const starter: AgentKnowledgeMap = {
+ defaultAgent: 'wiki',
+ agents: [
+ {
+ name: 'wiki',
+ description: 'Second Brain (Wiki/10_Wiki/Topics) 위주 답변 에이전트',
+ knowledgeFolders: ['10_Wiki/Topics'],
+ },
+ ],
+ };
+ fs.writeFileSync(jsonPath, JSON.stringify(starter, null, 2), 'utf8');
+ logInfo('agent-knowledge-map: starter created.', { jsonPath });
+ }
+ const doc = await vscode.workspace.openTextDocument(jsonPath);
+ await vscode.window.showTextDocument(doc);
+ } catch (e: any) {
+ logError('agent-knowledge-map: open failed.', { jsonPath, error: e?.message ?? String(e) });
+ vscode.window.showErrorMessage(`매핑 파일 열기 실패: ${e?.message ?? e}`);
+ }
+}
diff --git a/src/skills/scopedBrainRetriever.ts b/src/skills/scopedBrainRetriever.ts
new file mode 100644
index 0000000..5217136
--- /dev/null
+++ b/src/skills/scopedBrainRetriever.ts
@@ -0,0 +1,153 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import { findBrainFiles, summarizeText } from '../utils';
+import { isInside } from '../lib/paths';
+import { tokenize, expandQuery, scoreTfIdf, extractBestExcerpt } from '../retrieval/scoring';
+import { estimateTokens } from '../retrieval/contextBudget';
+
+/**
+ * Lightweight RAG that only searches a subset of the active brain.
+ *
+ * Why this is separate from RetrievalOrchestrator:
+ * - The orchestrator pulls in MemoryManager (5 cognitive layers) plus chat
+ * history. That payload makes sense for in-IDE chat, but not for a Telegram
+ * handler that has no chat-history continuity per chat-id and no
+ * workspace-scoped memory. Attaching memory layers to a Telegram thread
+ * would also leak unrelated short-term context across users.
+ * - This retriever is a pure function of (query, brainRoot, scopeFolders) —
+ * easy to reason about, no side effects, no coupling to VS Code.
+ *
+ * Folder scoping is the whole point: the agent-knowledge-map says
+ * "this agent only sees `10_Wiki/Topics`" and the Telegram bot must respect
+ * that. When `scopeFolders` is empty, we fall back to the entire brain
+ * (matching the legacy behavior so a missing mapping doesn't silently
+ * starve the bot of context).
+ */
+
+export interface ScopedRetrievalOptions {
+ /** Cap on returned excerpts. Default 6. */
+ maxResults?: number;
+ /** Per-excerpt length cap (chars). Default 400. */
+ excerptLength?: number;
+ /** Whether to include `00_Raw` / `conversations` style folders. Default false. */
+ includeRawConversations?: boolean;
+}
+
+export interface ScopedRetrievalChunk {
+ /** Path relative to brain root, used as the title in assembled context. */
+ relativePath: string;
+ /** Absolute file path on disk (logging / debug). */
+ filePath: string;
+ excerpt: string;
+ score: number;
+ tokenEstimate: number;
+}
+
+export interface ScopedRetrievalResult {
+ query: string;
+ chunks: ScopedRetrievalChunk[];
+ /** Number of files considered after scope filtering. */
+ candidateCount: number;
+ /** True iff `scopeFolders` constrained the search. */
+ scoped: boolean;
+}
+
+function _isRawConversation(relativePath: string): boolean {
+ return /(^|[\\/])(00_Raw|raw-data|conversations?|transcripts?)([\\/]|$)/i.test(relativePath);
+}
+
+function _filterToScope(allFiles: string[], scopeFolders: string[]): string[] {
+ if (scopeFolders.length === 0) return allFiles;
+ return allFiles.filter((file) => scopeFolders.some((folder) => isInside(folder, file)));
+}
+
+/**
+ * Run TF-IDF retrieval over the scope-filtered subset of the brain.
+ * Returns the top `maxResults` excerpts ranked by score.
+ */
+export function retrieveScoped(
+ query: string,
+ brainRoot: string,
+ scopeFolders: string[],
+ options: ScopedRetrievalOptions = {}
+): ScopedRetrievalResult {
+ const maxResults = options.maxResults ?? 6;
+ const excerptLength = options.excerptLength ?? 400;
+ const includeRaw = options.includeRawConversations ?? false;
+
+ const empty: ScopedRetrievalResult = {
+ query,
+ chunks: [],
+ candidateCount: 0,
+ scoped: scopeFolders.length > 0,
+ };
+ if (!brainRoot || !fs.existsSync(brainRoot)) return empty;
+
+ const allBrainFiles = findBrainFiles(brainRoot);
+ const scopeFiltered = _filterToScope(allBrainFiles, scopeFolders);
+ const candidates = scopeFiltered.filter((file) => {
+ const rel = path.relative(brainRoot, file);
+ return includeRaw || !_isRawConversation(rel);
+ });
+ if (candidates.length === 0) return { ...empty, candidateCount: 0 };
+
+ const documents = candidates.map((file) => {
+ let content = '';
+ let lastModified = 0;
+ try {
+ content = fs.readFileSync(file, 'utf8');
+ lastModified = fs.statSync(file).mtimeMs;
+ } catch { /* skip unreadable file */ }
+ return {
+ title: path.basename(file, '.md'),
+ content,
+ lastModified,
+ filePath: file,
+ relativePath: path.relative(brainRoot, file),
+ };
+ });
+
+ const queryTokens = tokenize(query);
+ const expanded = expandQuery(queryTokens);
+ const scored = scoreTfIdf(expanded, documents);
+
+ const chunks = scored
+ .filter((s) => s.score > 0)
+ .sort((a, b) => b.score - a.score)
+ .slice(0, maxResults)
+ .map((s) => {
+ const doc = documents[s.index];
+ const excerpt = extractBestExcerpt(doc.content, expanded, excerptLength);
+ const summary = summarizeText(excerpt, excerptLength);
+ return {
+ relativePath: doc.relativePath,
+ filePath: doc.filePath,
+ excerpt: summary,
+ score: s.score,
+ tokenEstimate: estimateTokens(summary),
+ };
+ });
+
+ return {
+ query,
+ chunks,
+ candidateCount: candidates.length,
+ scoped: scopeFolders.length > 0,
+ };
+}
+
+/**
+ * Render the retrieval result as a single context block suitable for prefixing
+ * a chat prompt. Returns an empty string when there are no chunks (so callers
+ * can simply concatenate without a conditional).
+ */
+export function buildContextBlock(result: ScopedRetrievalResult): string {
+ if (result.chunks.length === 0) return '';
+ const header = result.scoped
+ ? '[제2뇌 컨텍스트 — 매핑된 지식 폴더에서 검색]'
+ : '[제2뇌 컨텍스트 — 전체 브레인 검색]';
+ const body = result.chunks
+ .map((c, i) => `(#${i + 1}) ${c.relativePath}\n${c.excerpt}`)
+ .join('\n\n---\n\n');
+ return `${header}\n\n${body}`;
+}