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}`; +}