From e85e11aac64e2a8bc44611decf731732d248ced7 Mon Sep 17 00:00:00 2001 From: g1nation Date: Wed, 13 May 2026 22:05:39 +0900 Subject: [PATCH] release: v2.0.1 - Advanced Knowledge Mix & Architectural Intelligence --- ...d46d2ca2057b05c488be1dcf439166ac5a9a1.json | 2 +- ...9f4f39d2bc368f77456c37b5eef9a94a66b5c.json | 2 +- ...5c7a44d7661af673b24e3f49551a7a2e50280.json | 2 +- ...adc543795e4b427b64540a49c9ab27c7fe213.json | 4 +- ...son => stress_conflict_1778677516257.json} | 22 +- PATCHNOTES.md | 9 + media/sidebar.css | 94 ++++ media/sidebar.html | 67 ++- media/sidebar.js | 280 +++++++++++- package.json | 14 +- src/agent.ts | 70 ++- src/config.ts | 12 + src/extension.ts | 29 +- src/features/projectArchitecture/index.ts | 408 ++++++++++++++++++ .../projectArchitecture/intentDetector.ts | 148 +++++++ src/features/projectChronicle/types.ts | 14 + src/retrieval/index.ts | 36 +- src/retrieval/knowledgeMix.ts | 161 +++++++ src/sidebar/agentHandlers.ts | 22 +- src/sidebar/chatHandlers.ts | 47 ++ src/sidebarProvider.ts | 329 +++++++++++++- src/skills/agentKnowledgeMap.ts | 44 +- src/utils.ts | 20 + 23 files changed, 1758 insertions(+), 78 deletions(-) rename .astra/tests/stress/.astra/missions/{stress_conflict_1778600627483.json => stress_conflict_1778677516257.json} (78%) create mode 100644 src/features/projectArchitecture/index.ts create mode 100644 src/features/projectArchitecture/intentDetector.ts create mode 100644 src/retrieval/knowledgeMix.ts diff --git a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json index bef6205..995054d 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": 1778600627518, + "createdAt": 1778677516269, "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 c695385..5ed597a 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": 1778600627507, + "createdAt": 1778677516268, "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 da1bbee..be1e10b 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": 1778600627501, + "createdAt": 1778677516268, "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 a6c3d47..ebd08da 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_1778600627483\ndate: 2026-05-12T15:43:47.518Z\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]** 핵심 정보 수집 및 분석 중... (6ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (12ms)\n", - "createdAt": 1778600627518, + "result": "---\nid: stress_conflict_1778677516257\ndate: 2026-05-13T13:05:16.269Z\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]** 전략 수립 중... (10ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (1ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (1ms)\n", + "createdAt": 1778677516269, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1778600627483.json b/.astra/tests/stress/.astra/missions/stress_conflict_1778677516257.json similarity index 78% rename from .astra/tests/stress/.astra/missions/stress_conflict_1778600627483.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1778677516257.json index e6b3935..66f726d 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1778600627483.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1778677516257.json @@ -1,8 +1,8 @@ { - "missionId": "stress_conflict_1778600627483", + "missionId": "stress_conflict_1778677516257", "status": "completed", - "startTime": "2026-05-12T15:43:47.484Z", - "totalElapsedMs": 34, + "startTime": "2026-05-13T13:05:16.257Z", + "totalElapsedMs": 12, "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": 10, "message": "전략 수립 중...", - "ts": "2026-05-12T15:43:47.495Z" + "ts": "2026-05-13T13:05:16.267Z" }, { "from": "planner", "to": "researcher", - "durationMs": 6, + "durationMs": 1, "message": "핵심 정보 수집 및 분석 중...", - "ts": "2026-05-12T15:43:47.501Z" + "ts": "2026-05-13T13:05:16.268Z" }, { "from": "researcher", "to": "writer", - "durationMs": 12, + "durationMs": 1, "message": "최종 리포트 작성 및 편집 중...", - "ts": "2026-05-12T15:43:47.513Z" + "ts": "2026-05-13T13:05:16.269Z" }, { "from": "writer", "to": "completed", - "durationMs": 5, + "durationMs": 0, "message": "미션 완료", - "ts": "2026-05-12T15:43:47.518Z" + "ts": "2026-05-13T13:05:16.269Z" } ], "resilienceMetrics": { diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 351ea83..cc015ef 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,14 @@ # Astra Patch Notes +## v2.0.1 (2026-05-13) +### 🧠 Advanced Knowledge Mix & Architectural Intelligence +- **지식 믹스(Knowledge Mix) 엔진 도입:** 에이전트가 답변 시 '세컨드 브레인' 지식과 자체 학습 지식을 사용하는 비중을 정교하게 조절할 수 있는 `knowledgeMix.ts`를 구현했습니다. +- **프로젝트 아키텍처 인텐트 감지:** 프로젝트의 구조적 질문을 자동으로 식별하고 대응하는 `projectArchitecture` 기능을 추가하여 심층 분석 능력을 강화했습니다. +- **사이드바 UI 및 인터랙션 최적화:** 사이드바의 시각적 요소와 스크립트를 개선하여 대화 흐름의 매끄러움을 더했습니다. +- **신규 패키징:** `astra-2.0.1.vsix` 패키지를 통해 최신 지능 최적화와 아키텍처 분석 기능이 통합된 버전을 배포합니다. + +--- + ## v2.0.0 (2026-05-13) ### 🚀 Major Milestone & Intelligence Evolution - **지식 검색 엔진 고도화:** `embeddings.ts` 및 `scoring.ts`를 통해 시맨틱 검색과 키워드 검색이 결합된 하이브리드 검색 기능을 강화했습니다. diff --git a/media/sidebar.css b/media/sidebar.css index 9751093..78c642d 100644 --- a/media/sidebar.css +++ b/media/sidebar.css @@ -321,6 +321,61 @@ .input-footer { display: flex; align-items: center; justify-content: space-between; } .footer-left { display: flex; align-items: center; gap: 8px; } + /* Project Architecture chip — sits just above the input when project mode is on. */ + .arch-chip { + display: none; + align-items: center; + gap: 8px; + margin-bottom: 6px; + padding: 6px 10px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 11px; + } + .arch-chip[data-active="true"] { display: flex; } + .arch-chip-icon { font-size: 14px; flex-shrink: 0; } + .arch-chip-info { flex: 1; min-width: 0; line-height: 1.3; } + .arch-chip-title { + color: var(--text-bright); font-weight: 600; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .arch-chip-meta { color: var(--text-dim); font-size: 10px; } + .arch-chip-actions { display: flex; gap: 4px; flex-shrink: 0; } + .arch-chip-btn { + background: transparent; + color: var(--text-primary); + border: 1px solid var(--border); + padding: 3px 8px; + border-radius: 6px; + font-size: 10px; + cursor: pointer; + transition: all 0.12s ease; + } + .arch-chip-btn:hover { + background: var(--control-bg-hover); + border-color: var(--border-bright); + } + + /* Compact model picker placed directly below the input box. */ + .input-model-row { + display: flex; align-items: center; gap: 8px; + margin-top: 6px; padding: 4px 8px; + background: var(--surface); border: 1px solid var(--border); border-radius: 8px; + } + .input-model-label { + font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; + color: var(--text-dim); flex-shrink: 0; + } + .input-model-select-wrap { flex: 1; min-width: 0; } + .input-model-select-wrap select { + width: 100%; min-width: 0; + background: transparent; color: var(--text-primary); + border: none; outline: none; padding: 4px 6px; + font-size: 11px; cursor: pointer; + } + .input-model-select-wrap select:focus { box-shadow: 0 0 0 2px var(--accent-glow); border-radius: 4px; } + .send-btn { background: var(--accent); color: #fff; border: none; padding: 6px 14px; border-radius: 6px; font-weight: 600; font-size: 12px; cursor: pointer; @@ -755,6 +810,11 @@ font-size: 9.5px; line-height: 1.5; } + .msg-scope-footer .scope-mix { + margin-top: 4px; + color: var(--text-dim); + font-size: 9.5px; + } /* ── "Record a lesson?" prompt after a rollback / rejected change / repeated complaint ── */ .lesson-candidate-box { @@ -818,6 +878,40 @@ padding: 5px 8px 2px; } .hdr-menu-label:first-child { padding-top: 2px; } + .hdr-menu-hint { + font-weight: 500; + text-transform: none; + letter-spacing: 0; + color: var(--text-dim); + font-size: 9.5px; + margin-left: 6px; + } + + /* Compact bipolar slider used both in the header menu and inside the agent-map modal. */ + .knowledge-mix-control { + display: flex; align-items: center; gap: 8px; + padding: 4px 8px 6px; + } + .knowledge-mix-control .km-end-label { + font-size: 9.5px; + text-transform: uppercase; + letter-spacing: .04em; + color: var(--text-dim); + flex-shrink: 0; + min-width: 30px; + } + .knowledge-mix-control .km-end-label:last-of-type { text-align: right; } + .knowledge-mix-control .km-slider { + flex: 1; min-width: 0; cursor: pointer; accent-color: var(--accent); + } + .knowledge-mix-control .km-slider:disabled { opacity: 0.5; cursor: not-allowed; } + + .map-mix-control { padding: 0; } + .map-checkbox-row { + display: flex; align-items: center; gap: 6px; + font-size: 11px; color: var(--text-primary); cursor: pointer; + } + .map-checkbox-row input[type="checkbox"] { accent-color: var(--accent); } .hdr-menu-item { display: block; width: 100%; diff --git a/media/sidebar.html b/media/sidebar.html index 6eb726d..a0af71a 100644 --- a/media/sidebar.html +++ b/media/sidebar.html @@ -50,6 +50,16 @@
Model
+
+ Knowledge Mix + Model 50% · Brain 50% +
+
+ Model + + Brain +
+
Brain
@@ -132,6 +142,37 @@
(선택된 에이전트가 없습니다)
+
+
+
+
🤖 Model for this agent
+
이 에이전트를 선택했을 때 사용할 모델을 지정합니다. Use current model을 선택하면 상단에서 고른 기본 모델을 그대로 사용합니다.
+
+
+
+ +
+
+ +
+
+
+
🎚 Knowledge Mix for this agent
+
에이전트별 의존도. Use global setting을 켜두면 상단 슬라이더 값을 그대로 따릅니다.
+
+
+ +
+ Model + + Brain +
+
Model 50% · Brain 50%
+
+
@@ -187,6 +228,24 @@
+ +
+ 📋 +
+
+
Auto-load Off
+
+
+ + + +
+
Agent Persona/Instructions
@@ -213,9 +272,11 @@
-
- - +
+ +
+ +
diff --git a/media/sidebar.js b/media/sidebar.js index 0ed2830..33eece7 100644 --- a/media/sidebar.js +++ b/media/sidebar.js @@ -179,6 +179,23 @@ const recordsLatest = document.getElementById('recordsLatest'); function escAttr(t) { return String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } + function fmtMixHint(w) { return `Model ${100 - w}% · Brain ${w}%`; } + /** Compact relative-time formatter for chips (e.g. "2m ago", "just now"). */ + function formatRelativeTime(iso) { + try { + const then = new Date(iso).getTime(); + if (!Number.isFinite(then)) return iso; + const diffSec = Math.max(0, Math.floor((Date.now() - then) / 1000)); + if (diffSec < 45) return 'just now'; + if (diffSec < 90) return '1m ago'; + const m = Math.floor(diffSec / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + return `${d}d ago`; + } catch { return iso; } + } function fmtK(n) { if (typeof n !== 'number' || !isFinite(n)) return '?'; if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'k'; @@ -335,6 +352,19 @@ const titleAttr = files.length ? `사용된 브레인 파일:\n${files.join('\n')}` : '에이전트↔지식 매핑 편집'; footer.innerHTML = `🔎 참조: ${agentTag}${escAttr(scopeLabel)}${fileTag}${lessonTag}${layerTag}`; } + // Knowledge Mix indicator — shows the policy that actually drove this turn so the + // user can see *why* the answer leaned the way it did. + if (v.knowledgeMix && typeof v.knowledgeMix.weight === 'number') { + const w = Math.max(0, Math.min(100, v.knowledgeMix.weight)); + const src = v.knowledgeMix.source; + const srcLabel = src === 'agent' + ? `agent: ${v.knowledgeMix.agent || v.agentName || ''}` + : src === 'global' ? 'global' : 'default'; + const mix = document.createElement('div'); + mix.className = 'scope-mix'; + mix.innerHTML = `🎚 Knowledge Mix · Model ${100 - w}% / Brain ${w}% (${escAttr(srcLabel)})`; + footer.appendChild(mix); + } // Non-blocking flag: lesson Prevention-Checklist items the answer doesn't visibly address. const unaddressed = Array.isArray(v.unaddressedChecklist) ? v.unaddressedChecklist : []; if (unaddressed.length) { @@ -353,7 +383,66 @@ if (actions) target.insertBefore(footer, actions); else target.appendChild(footer); } - let agentMapDraft = { agentPath: '', name: '', knowledgeFolders: [], skillFolders: [] }; + // `model: ''` means "Use current model" (i.e. no per-agent override). + // `secondBrainWeight: null` means "Use global setting"; a number 0–100 overrides it. + let agentMapDraft = { agentPath: '', name: '', knowledgeFolders: [], skillFolders: [], model: '', secondBrainWeight: null }; + + /** + * Sync the per-agent Knowledge Mix UI (checkbox + slider + hint) to whatever + * is currently in `agentMapDraft.secondBrainWeight`. Called whenever the + * modal opens or the backend ships fresh data. + */ + function syncAgentMapMixUi() { + const cb = document.getElementById('agentMapMixUseGlobal'); + const slider = document.getElementById('agentMapMixSlider'); + const hint = document.getElementById('agentMapMixHint'); + if (!cb || !slider || !hint) return; + const useGlobal = agentMapDraft.secondBrainWeight === null || agentMapDraft.secondBrainWeight === undefined; + cb.checked = useGlobal; + slider.disabled = useGlobal; + const value = useGlobal + ? (parseInt((document.getElementById('knowledgeMixSlider') || {}).value, 10) || 50) + : Math.max(0, Math.min(100, agentMapDraft.secondBrainWeight | 0)); + slider.value = String(value); + hint.textContent = useGlobal + ? `Use global · ${fmtMixHint(value)}` + : fmtMixHint(value); + } + + /** + * Rebuild the per-agent model dropdown using whatever model list the top-bar + * #modelSel currently has. Called whenever the modal opens OR the model list + * is refreshed by the extension host. Preserves the current draft selection. + */ + function refreshAgentMapModelOptions() { + const sel = document.getElementById('agentMapModelSel'); + if (!sel) return; + const desired = agentMapDraft.model || ''; + sel.innerHTML = ''; + const useDefault = document.createElement('option'); + useDefault.value = ''; + useDefault.innerText = 'Use current model'; + sel.appendChild(useDefault); + const seen = new Set(); + // Source the available models from the populated top-bar dropdown so we don't + // need an additional round-trip; if a model is selected for this agent but + // is no longer in the list, we still surface it so the user sees the value. + for (const opt of modelSel.options) { + if (!opt.value || seen.has(opt.value)) continue; + seen.add(opt.value); + const o = document.createElement('option'); + o.value = opt.value; + o.innerText = opt.innerText; + sel.appendChild(o); + } + if (desired && !seen.has(desired)) { + const o = document.createElement('option'); + o.value = desired; + o.innerText = `${desired} (saved)`; + sel.appendChild(o); + } + sel.value = desired; + } function renderAgentMapLists() { const renderList = (listEl, items, kind) => { @@ -393,10 +482,12 @@ } agentMapStatus.className = 'map-status'; agentMapStatus.textContent = '불러오는 중...'; - agentMapDraft = { agentPath: agentSel.value, name: agentSel.options[agentSel.selectedIndex]?.text || '', knowledgeFolders: [], skillFolders: [] }; + agentMapDraft = { agentPath: agentSel.value, name: agentSel.options[agentSel.selectedIndex]?.text || '', knowledgeFolders: [], skillFolders: [], model: '', secondBrainWeight: null }; agentMapAgentName.textContent = agentMapDraft.name; knowledgeFolderList.innerHTML = ''; skillFolderList.innerHTML = ''; + refreshAgentMapModelOptions(); + syncAgentMapMixUi(); agentMapOverlay.classList.add('visible'); vscode.postMessage({ type: 'getAgentMap', agentPath: agentSel.value }); } @@ -569,6 +660,8 @@ break; case 'modelsList': { modelSel.innerHTML = ''; + const inlineModelSel = document.getElementById('inlineModelSel'); + if (inlineModelSel) inlineModelSel.innerHTML = ''; // [State Persistence - Tier 2] LocalStorage에서 마지막 선택 모델 복원 시도 const _savedModel = localStorage.getItem('g1nation_last_model'); // 서버 추천 모델 vs 로컬 저장 모델 중 우선순위 결정 @@ -577,13 +670,21 @@ ? _savedModel : msg.value.selected; const _loadedSet = new Set(Array.isArray(msg.value.loadedModels) ? msg.value.loadedModels : []); - msg.value.models.forEach(m => { - const o = document.createElement('option'); - o.value = m; - // ● = 현재 LM Studio 메모리에 로드된 모델 / ○ = 다운로드만 됨 - o.innerText = _loadedSet.has(m) ? `● ${m}` : m; - if (m === _preferredModel) o.selected = true; - modelSel.appendChild(o); + const _models = Array.isArray(msg.value.models) ? msg.value.models.slice() : []; + // Fallback: server returned nothing but we still know the configured model. + if (_models.length === 0 && _preferredModel) _models.push(_preferredModel); + _models.forEach(m => { + const label = _loadedSet.has(m) ? `● ${m}` : m; + const o1 = document.createElement('option'); + o1.value = m; o1.innerText = label; + if (m === _preferredModel) o1.selected = true; + modelSel.appendChild(o1); + if (inlineModelSel) { + const o2 = document.createElement('option'); + o2.value = m; o2.innerText = label; + if (m === _preferredModel) o2.selected = true; + inlineModelSel.appendChild(o2); + } }); // LocalStorage에 저장된 모델이 실제로 적용된 경우, 백엔드 설정도 동기화 if (_savedModel && _savedModel !== msg.value.selected && msg.value.models.includes(_savedModel)) { @@ -591,6 +692,8 @@ } if (typeof updateInputPlaceholder === 'function') updateInputPlaceholder(); statusLabel.innerText = `Model: ${_preferredModel}`; + // Refresh per-agent model dropdown options (if currently visible) so it stays in sync. + if (typeof refreshAgentMapModelOptions === 'function') refreshAgentMapModelOptions(); break; } case 'brainProfiles': @@ -672,6 +775,71 @@ vscode.postMessage({ type: 'getKnowledgeScope', agentPath: msg.selected }); syncContextBar(); break; + case 'architectureStatus': { + // Show / hide the chip + reflect current state. + const chip = document.getElementById('archChip'); + const title = document.getElementById('archChipTitle'); + const meta = document.getElementById('archChipMeta'); + if (!chip || !title || !meta) break; + const v = msg.value || {}; + if (!v.active) { + chip.setAttribute('data-active', 'false'); + break; + } + chip.setAttribute('data-active', 'true'); + title.textContent = `${v.projectName || 'Project'} architecture`; + const updatedLabel = v.lastUpdated ? `updated ${formatRelativeTime(v.lastUpdated)}` : 'just attached'; + const autoLabel = v.autoUpdate === false ? 'Auto-update Off' : 'Auto-update On'; + meta.textContent = `${updatedLabel} · ${autoLabel}`; + break; + } + case 'architectureRefreshFailed': { + const reason = msg.value && msg.value.reason; + if (reason === 'no-active-project') { + showToast('활성 프로젝트가 없습니다. 먼저 프로젝트를 선택하세요.', 'warn'); + } else { + showToast('Architecture 갱신 실패', 'warn'); + } + break; + } + case 'knowledgeMix': { + // Initial sync: reflect whatever weight is currently in settings. + if (msg.value && typeof msg.value.weight === 'number') { + const w = Math.max(0, Math.min(100, msg.value.weight)); + const slider = document.getElementById('knowledgeMixSlider'); + if (slider) slider.value = String(w); + const hint = document.getElementById('knowledgeMixHint'); + if (hint) hint.textContent = fmtMixHint(w); + } + break; + } + case 'agentModelOverride': { + // The extension chose a different model than what the dropdowns show + // (per-agent pinned model). Reflect that in the UI without persisting + // it as the new global default — selecting a different agent or + // clearing the override should restore the previous selection. + const pinned = msg.value && msg.value.model; + if (pinned) { + const inlineSel = document.getElementById('inlineModelSel'); + // Add an option if it isn't already known so the value can stick. + const ensureOption = (sel) => { + if (!sel) return; + const has = Array.from(sel.options).some(o => o.value === pinned); + if (!has) { + const o = document.createElement('option'); + o.value = pinned; + o.innerText = `${pinned} (agent)`; + sel.appendChild(o); + } + sel.value = pinned; + }; + ensureOption(modelSel); + ensureOption(inlineSel); + statusLabel.innerText = `Model: ${pinned} (agent override)`; + if (typeof updateInputPlaceholder === 'function') updateInputPlaceholder(); + } + break; + } case 'agentMapData': if (msg.value) { agentMapDraft = { @@ -679,8 +847,15 @@ name: agentMapDraft.name, knowledgeFolders: Array.isArray(msg.value.knowledgeFolders) ? msg.value.knowledgeFolders.slice() : [], skillFolders: Array.isArray(msg.value.skillFolders) ? msg.value.skillFolders.slice() : [], + model: typeof msg.value.model === 'string' ? msg.value.model : '', + secondBrainWeight: (typeof msg.value.secondBrainWeight === 'number' + && Number.isFinite(msg.value.secondBrainWeight)) + ? Math.max(0, Math.min(100, Math.round(msg.value.secondBrainWeight))) + : null, }; renderAgentMapLists(); + refreshAgentMapModelOptions(); + syncAgentMapMixUi(); agentMapStatus.textContent = msg.value.exists ? '' : '새 매핑입니다. 저장하면 생성됩니다.'; agentMapStatus.className = 'map-status'; } @@ -936,7 +1111,8 @@ const startNewChat = () => { Sound.play(660, 'sine', 0.1); vscode.postMessage({ type: 'newChat' }); }; document.getElementById('newChatBtn').onclick = startNewChat; - document.getElementById('inputNewChatBtn').onclick = startNewChat; + // Note: input-footer "New Chat" / "Sync Knowledge" buttons were removed. + // Both actions remain available in the top toolbar (newChatBtn / brainBtn / Tools menu). document.getElementById('settingsBtn').onclick = () => vscode.postMessage({ type: 'openSettings' }); document.getElementById('internetBtn').onclick = () => { @@ -969,7 +1145,7 @@ if (!brainSel.value || brainSel.value === 'new') return; vscode.postMessage({ type: 'deleteBrain', id: brainSel.value }); }; - document.getElementById('inputSyncBtn').onclick = syncBrain; + // (inputSyncBtn removed — Sync Knowledge is reachable via the top brainBtn / Tools menu.) document.getElementById('historyBtn').onclick = () => vscode.postMessage({ type: 'getSessions' }); document.getElementById('historyBtn').addEventListener('click', () => historyOverlay.classList.add('visible')); document.getElementById('closeHistoryBtn').onclick = () => historyOverlay.classList.remove('visible'); @@ -979,20 +1155,31 @@ } }; - modelSel.onchange = () => { - const _selectedModel = modelSel.value; + // Shared handler so the top-bar dropdown and the inline-below-input dropdown + // always commit the same way and stay visually synced. + const applyModelSelection = (selectedModel, originEl) => { + if (!selectedModel) return; // [State Persistence - Tier 2] 모델 변경 시 LocalStorage에 즉시 저장 (클라이언트 측 지속성) try { - localStorage.setItem('g1nation_last_model', _selectedModel); + localStorage.setItem('g1nation_last_model', selectedModel); } catch(e) { console.warn('[Astra] LocalStorage 저장 실패:', e); } // [State Persistence - Tier 1] VS Code 전역 설정에 동기화 (영구 저장) - vscode.postMessage({ type: 'model', value: _selectedModel }); + vscode.postMessage({ type: 'model', value: selectedModel }); + // Mirror the value to the *other* dropdown so both pickers reflect reality. + const inlineSel = document.getElementById('inlineModelSel'); + if (originEl !== modelSel && modelSel.value !== selectedModel) modelSel.value = selectedModel; + if (inlineSel && originEl !== inlineSel && inlineSel.value !== selectedModel) inlineSel.value = selectedModel; updateInputPlaceholder(); // 상태 레이블 즉시 업데이트 - statusLabel.innerText = `Model: ${_selectedModel}`; + statusLabel.innerText = `Model: ${selectedModel}`; }; + modelSel.onchange = () => applyModelSelection(modelSel.value, modelSel); + const _inlineModelSelEl = document.getElementById('inlineModelSel'); + if (_inlineModelSelEl) { + _inlineModelSelEl.onchange = () => applyModelSelection(_inlineModelSelEl.value, _inlineModelSelEl); + } brainSel.onchange = () => { if (brainSel.value === 'new') { vscode.postMessage({ type: 'addBrain' }); @@ -1057,9 +1244,41 @@ agentPath: agentMapDraft.agentPath, knowledgeFolders: agentMapDraft.knowledgeFolders, skillFolders: agentMapDraft.skillFolders, + // Empty string = "Use current model" (override removed). + model: agentMapDraft.model || '', + // null = "Use global setting" (override removed); number 0–100 = pinned. + secondBrainWeight: agentMapDraft.secondBrainWeight, }); }; } + // Track changes to the per-agent model dropdown so the draft stays in sync. + const _agentMapModelSelEl = document.getElementById('agentMapModelSel'); + if (_agentMapModelSelEl) { + _agentMapModelSelEl.onchange = () => { + agentMapDraft.model = _agentMapModelSelEl.value || ''; + }; + } + // ── Per-agent Knowledge Mix slider + "Use global" checkbox ──────────── + const _agentMapMixCb = document.getElementById('agentMapMixUseGlobal'); + const _agentMapMixSlider = document.getElementById('agentMapMixSlider'); + if (_agentMapMixCb && _agentMapMixSlider) { + _agentMapMixCb.addEventListener('change', () => { + if (_agentMapMixCb.checked) { + agentMapDraft.secondBrainWeight = null; + } else { + // Snap to whatever the slider currently shows so the user has a starting point. + agentMapDraft.secondBrainWeight = parseInt(_agentMapMixSlider.value, 10) || 50; + } + syncAgentMapMixUi(); + }); + _agentMapMixSlider.addEventListener('input', () => { + if (_agentMapMixCb.checked) return; // disabled state, but guard anyway + const w = Math.max(0, Math.min(100, parseInt(_agentMapMixSlider.value, 10) || 50)); + agentMapDraft.secondBrainWeight = w; + const hint = document.getElementById('agentMapMixHint'); + if (hint) hint.textContent = fmtMixHint(w); + }); + } editAgentBtn.onclick = () => { if (agentSel.value === 'none') return; @@ -1129,8 +1348,37 @@ vscode.postMessage({ type: 'getAgents' }); vscode.postMessage({ type: 'getChronicleProjects' }); vscode.postMessage({ type: 'getChronicleRecords' }); + vscode.postMessage({ type: 'getKnowledgeMix' }); + vscode.postMessage({ type: 'getArchitectureStatus' }); vscode.postMessage({ type: 'ready' }); + // ── Project Architecture chip buttons ───────────────────────────────── + const _archOpenBtn = document.getElementById('archOpenBtn'); + const _archRefreshBtn = document.getElementById('archRefreshBtn'); + const _archDetachBtn = document.getElementById('archDetachBtn'); + if (_archOpenBtn) _archOpenBtn.onclick = () => vscode.postMessage({ type: 'openArchitectureDoc' }); + if (_archRefreshBtn) _archRefreshBtn.onclick = () => vscode.postMessage({ type: 'refreshArchitecture' }); + if (_archDetachBtn) _archDetachBtn.onclick = () => vscode.postMessage({ type: 'detachArchitecture' }); + + // ── Knowledge Mix: global slider ────────────────────────────────────── + // Mirrors `g1nation.knowledgeMix.secondBrainWeight`. The hint label updates + // live as the user drags; the value is committed (postMessage) on `change` + // so we don't spam settings updates while scrubbing. + const knowledgeMixSlider = document.getElementById('knowledgeMixSlider'); + const knowledgeMixHint = document.getElementById('knowledgeMixHint'); + const renderGlobalMixHint = () => { + if (!knowledgeMixSlider || !knowledgeMixHint) return; + knowledgeMixHint.textContent = fmtMixHint(parseInt(knowledgeMixSlider.value, 10) || 50); + }; + if (knowledgeMixSlider) { + knowledgeMixSlider.addEventListener('input', renderGlobalMixHint); + knowledgeMixSlider.addEventListener('change', () => { + const w = Math.max(0, Math.min(100, parseInt(knowledgeMixSlider.value, 10) || 50)); + vscode.postMessage({ type: 'setKnowledgeMix', value: w }); + }); + renderGlobalMixHint(); + } + // --- Proactive Behavioral Tracking --- let hoverTimer = null; const trackBehavior = (elementId, context) => { diff --git a/package.json b/package.json index a5cef36..41b2a3e 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.0.0", + "version": "2.0.1", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", @@ -102,6 +102,18 @@ { "command": "g1nation.lesson.manage", "title": "Astra: Browse / Manage Lessons" + }, + { + "command": "g1nation.architecture.refresh", + "title": "Astra: Refresh Project Architecture Context" + }, + { + "command": "g1nation.architecture.detach", + "title": "Astra: Detach Project Architecture Context" + }, + { + "command": "g1nation.architecture.open", + "title": "Astra: Open Project Architecture Doc" } ], "keybindings": [ diff --git a/src/agent.ts b/src/agent.ts index 5b3bc8a..0be0e3d 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -44,6 +44,13 @@ import { buildLessonChecklistBlock, isQaRegressionFeedback, findUnaddressedCheck import { embedQuery, embedTexts } from './retrieval/embeddings'; import { backfillBrainEmbeddings } from './retrieval/brainIndex'; import { resolveScopeForAgent } from './skills/agentKnowledgeMap'; +import { + resolveKnowledgeMix, + mapWeightToBrainFileLimit, + mapWeightToRetrievalRatio, + buildKnowledgeMixPolicy, + ResolvedKnowledgeMix, +} from './retrieval/knowledgeMix'; import { extractVisibleFinal, shouldFinalOnlyRetry, @@ -202,6 +209,8 @@ export class AgentExecutor { } | null = null; /** Lesson card *contents* injected this turn — kept to check the answer against their Prevention Checklists. */ private _lastLessonContents: string[] = []; + /** Resolved Knowledge Mix for the most recent retrieval — surfaced in the scope footer. */ + private _lastKnowledgeMix: ResolvedKnowledgeMix | null = null; private readonly options: AgentExecutorOptions; @@ -346,6 +355,12 @@ export class AgentExecutor { agentSkillFile?: string, negativePrompt?: string, designerContext?: string, + /** + * Pre-formatted architecture-context block (`[ACTIVE PROJECT ARCHITECTURE CONTEXT]…`) + * built by sidebarProvider from the active project's architecture doc. + * Empty/undefined when project mode is off or auto-attach is disabled. + */ + projectArchitectureContext?: string, secondBrainTraceEnabled?: boolean, secondBrainTraceDebug?: boolean, brainProfileId?: string @@ -401,6 +416,7 @@ export class AgentExecutor { // "참조 범위" footer (the exact "안녕 → 🔎 참조: 에피소드기억" bug). this._lastRetrievalInfo = null; this._lastLessonContents = []; + this._lastKnowledgeMix = null; } // 1. Prepare Context @@ -520,6 +536,13 @@ export class AgentExecutor { const designerCtx = options.designerContext ? `\n\n[PROJECT CHRONICLE GUARD]\n${options.designerContext}` : ''; + // Project Architecture context (Feature 2): durable per-project ground truth. + // Already pre-formatted by sidebarProvider with header + markers, so we just + // sandwich it with newlines. Suppressed implicitly because the field is empty + // when project mode is off — no extra check needed here. + const projectArchitectureCtx = options.projectArchitectureContext + ? `\n\n${options.projectArchitectureContext}` + : ''; const secondBrainTraceCtx = secondBrainTrace ? `\n\n${renderSecondBrainTraceContext(secondBrainTrace)}` : ''; @@ -602,8 +625,17 @@ export class AgentExecutor { const casualCtx = isCasualConversation ? '\n\n[CASUAL CONVERSATION MODE]\nThe user sent a greeting, acknowledgement, or light conversational message. Reply naturally and briefly to the message itself. Do not use Second Brain, memory, project records, reports, references, or analysis unless the user explicitly asks for them.' : ''; + // Knowledge Mix policy: tells the model how strongly to lean on Second Brain + // evidence vs. its own general knowledge for this turn. Suppressed for casual + // chat — pure greetings don't need to be told anything about RAG balance. + const knowledgeMixCtx = (!isCasualConversation && this._lastKnowledgeMix) + ? (() => { + const block = buildKnowledgeMixPolicy(this._lastKnowledgeMix); + return block ? `\n\n${block}` : ''; + })() + : ''; // memoryCtx(RAG/메모리/lessons)는 [CONTEXT] 안에 — 토큰이 빡빡하면 대화 기록보다 먼저 잘림. - fullSystemPrompt = `${systemPrompt}${internetCtx}${designerCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${casualCtx}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`; + fullSystemPrompt = `${systemPrompt}${internetCtx}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${knowledgeMixCtx}${casualCtx}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`; } // ────────────────────────────────────────────────────────────────── // [Context Limit Manager] context length 는 "답변을 그만큼 길게 써도 된다" @@ -1199,7 +1231,19 @@ export class AgentExecutor { const unaddressedChecklist = findUnaddressedChecklistItems(finalAssistantContent, this._lastLessonContents); this.webview.postMessage({ type: 'usedScope', - value: { ...this._lastRetrievalInfo, hasAgentSelected: !!options.agentSkillFile, unaddressedChecklist }, + value: { + ...this._lastRetrievalInfo, + hasAgentSelected: !!options.agentSkillFile, + unaddressedChecklist, + // Knowledge Mix surfaced under the answer so the user can see what policy ran. + knowledgeMix: this._lastKnowledgeMix + ? { + weight: this._lastKnowledgeMix.weight, + source: this._lastKnowledgeMix.source, + agent: this._lastKnowledgeMix.agent, + } + : null, + }, }); } // Progressive answering: the bubble was filled live with raw tokens @@ -2441,6 +2485,7 @@ export class AgentExecutor { const config = getConfig(); this._lastRetrievalInfo = null; this._lastLessonContents = []; + this._lastKnowledgeMix = null; if (!config.memoryEnabled) return ''; // Update memory manager config in case settings changed @@ -2497,6 +2542,15 @@ export class AgentExecutor { } } + // Resolve the Knowledge Mix weight for this turn (per-agent → global → default). + // The weight scales how many brain files we feed the retriever and how big a + // slice of the context budget RAG can claim. At weight=50 the numbers below + // equal the legacy defaults, so users who never touch the slider see no change. + const knowledgeMix = resolveKnowledgeMix(agentSkillFile); + this._lastKnowledgeMix = knowledgeMix; + const mixedBrainFileLimit = mapWeightToBrainFileLimit(knowledgeMix.weight, config.memoryLongTermFiles); + const mixedRetrievalRatio = mapWeightToRetrievalRatio(knowledgeMix.weight); + // Use the Unified RAG Pipeline const result = this.retrievalOrchestrator.retrieve(currentPrompt, { brain: activeBrain, @@ -2505,9 +2559,9 @@ export class AgentExecutor { chatHistory: visibleHistory, contextBudget: { totalBudget: scaledTotalBudget, - retrievalRatio: 0.4 + retrievalRatio: mixedRetrievalRatio, }, - brainFileLimit: config.memoryLongTermFiles, + brainFileLimit: mixedBrainFileLimit, scopeFolders: scope.folders, recentSessions, mediumTermLimit: config.memoryMediumTermSessions ?? 0, @@ -3196,7 +3250,13 @@ export class AgentExecutor { } if (firstCreatedFile) { - vscode.window.showTextDocument(vscode.Uri.file(firstCreatedFile), { preview: false }); + // Always open file results in the editor group (column 2) — the ConnectAI + // sidebar lives in column 3 and we don't want freshly-written files to + // hijack the chat panel. + vscode.window.showTextDocument(vscode.Uri.file(firstCreatedFile), { + preview: false, + viewColumn: vscode.ViewColumn.Two, + }); } // Brain Sync Logic diff --git a/src/config.ts b/src/config.ts index e4c7535..2904bdf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -58,6 +58,15 @@ export interface IAgentConfig { * Default 0.5 = equal weight, a reasonable starting point. */ embeddingBlendAlpha: number; + /** + * Global Knowledge Mix weight (0–100). Controls how much the assistant leans on + * Second Brain evidence vs. model general knowledge when answering. + * 0 → Second Brain disabled; model knowledge only. + * 50 → Balanced (default). + * 100 → Second Brain is primary evidence; model knowledge only fills gaps. + * Per-agent overrides live in AgentKnowledgeEntry.secondBrainWeight and win. + */ + knowledgeMixSecondBrainWeight: number; } // ─── 경로 정규화 유틸리티 ─── @@ -141,6 +150,9 @@ export function getConfig(): IAgentConfig { finalOnlyRetryOnThoughtLeak: cfg.get('finalOnlyRetryOnThoughtLeak', true), embeddingModel: (cfg.get('embeddingModel', '') || '').trim(), embeddingBlendAlpha: Math.max(0, Math.min(1, cfg.get('embeddingBlendAlpha', 0.5))), + knowledgeMixSecondBrainWeight: Math.max(0, Math.min(100, Math.round( + cfg.get('knowledgeMix.secondBrainWeight', 50) + ))), }; } diff --git a/src/extension.ts b/src/extension.ts index f4e762e..fba393e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,7 +11,8 @@ import { logError, logInfo, resolveEngine, - getActiveBrainProfile + getActiveBrainProfile, + openInEditorGroup } from './utils'; import { getConfig, validateConfig } from './config'; import { AgentExecutor } from './agent'; @@ -431,6 +432,23 @@ export async function activate(context: vscode.ExtensionContext) { return createLessonCard(situation); }), vscode.commands.registerCommand('g1nation.lesson.manage', () => manageLessons()), + // ── Project Architecture commands (Feature 2) ───────────────────────── + // Thin shells that defer to the sidebar provider so all state mutations + // go through one code path (chip state, watcher lifecycle, etc.). + vscode.commands.registerCommand('g1nation.architecture.refresh', async () => { + if (!provider) return; + await provider._refreshArchitecture(); + vscode.window.showInformationMessage('Astra: Project architecture context refreshed.'); + }), + vscode.commands.registerCommand('g1nation.architecture.detach', async () => { + if (!provider) return; + await provider._detachArchitecture(); + vscode.window.showInformationMessage('Astra: Project architecture auto-attach turned off.'); + }), + vscode.commands.registerCommand('g1nation.architecture.open', async () => { + if (!provider) return; + await provider._openArchitectureDoc(); + }), ); /** All lesson/playbook/qa-finding cards in the active brain. Uses the brain index for the lesson-kind @@ -484,8 +502,7 @@ export async function activate(context: vscode.ExtensionContext) { vscode.window.showErrorMessage(`교훈 갱신 실패: ${e?.message ?? e}`); return; } - const doc = await vscode.workspace.openTextDocument(existing.filePath); - await vscode.window.showTextDocument(doc); + await openInEditorGroup(existing.filePath); vscode.window.showInformationMessage(`기존 교훈을 갱신했습니다 (occurrences: ${existing.occurrences + 1}). 필요하면 내용을 보강하세요.`); return; } @@ -503,8 +520,7 @@ export async function activate(context: vscode.ExtensionContext) { vscode.window.showErrorMessage(`교훈 파일 생성 실패: ${e?.message ?? e}`); return; } - const doc = await vscode.workspace.openTextDocument(filePath); - await vscode.window.showTextDocument(doc); + await openInEditorGroup(filePath); vscode.window.showInformationMessage('교훈 카드를 만들었습니다. 내용을 채워 저장하면 다음 유사 작업 시 자동으로 체크리스트에 들어갑니다.'); } @@ -547,8 +563,7 @@ export async function activate(context: vscode.ExtensionContext) { const sel = qp.selectedItems[0]; qp.hide(); if (sel) { - const doc = await vscode.workspace.openTextDocument(sel._file); - await vscode.window.showTextDocument(doc); + await openInEditorGroup(sel._file); } }); qp.onDidHide(() => qp.dispose()); diff --git a/src/features/projectArchitecture/index.ts b/src/features/projectArchitecture/index.ts new file mode 100644 index 0000000..a9d34a5 --- /dev/null +++ b/src/features/projectArchitecture/index.ts @@ -0,0 +1,408 @@ +/** + * Project Architecture Context (Feature 2) + * + * Builds a markdown document that captures the *durable* facts about a project + * — its purpose, modules, key files, constraints, decisions — so Astra can + * attach it to every prompt instead of re-discovering the project on each + * turn. + * + * Two-layer design so we get the best of both deterministic generation and + * user-curated knowledge: + * + * AUTO-MANAGED sections – regenerated on every refresh from static + * analysis (package.json, top-level tree, etc.). + * Bracketed by ` … + * ` markers so the file + * watcher can rewrite them without trampling + * anything the user wrote. + * USER-OWNED sections – created with TODO placeholders on first build, + * never overwritten thereafter. Users (or the + * assistant, when asked) fill in Purpose, + * Key Workflows, Constraints, Risks, Decisions. + * + * The generator is purely synchronous, never makes network calls, and never + * touches the model — by design. Refresh runs are cheap (single-digit ms on + * a project this size) so they can fire after every file change without + * starving the rest of the extension. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import { logError, logInfo } from '../../utils'; + +/** Sub-folder under the project root where the architecture doc lives. */ +const ARCH_DIR_REL = path.join('.astra', 'project-context'); +const ARCH_FILE = 'architecture.md'; + +/** Top-level directories we consider "code" worth listing under Main Modules. */ +const CODE_DIRS = ['src', 'media', 'core_py', 'lib', 'app', 'apps', 'packages', 'tests']; + +/** Files at the project root worth highlighting under "Important Files". */ +const ROOT_IMPORTANT = [ + 'package.json', 'pnpm-workspace.yaml', 'tsconfig.json', + 'README.md', 'CHANGELOG.md', 'ARCHITECTURE.md', + 'pyproject.toml', 'requirements.txt', 'Cargo.toml', 'go.mod', + 'Dockerfile', 'docker-compose.yml', +]; + +const AUTO_START = ''; +const AUTO_END = ''; + +export interface ArchitectureScanResult { + projectName: string; + projectRoot: string; + description: string; + runtimes: string[]; // e.g. ["TypeScript", "Node", "VS Code Extension"] + mainModules: { dir: string; description: string }[]; + importantFiles: string[]; // root-relative + /** Cheap hash of the scan inputs — used by the watcher to skip no-ops. */ + signature: string; +} + +export interface BuildResult { + /** Absolute path to the architecture markdown. */ + docPath: string; + /** True if the file was newly created (vs. an in-place auto-block refresh). */ + created: boolean; + /** Result of the scan that fed this build. */ + scan: ArchitectureScanResult; +} + +/** Resolve the architecture doc path for a given project root. */ +export function architectureDocPathFor(projectRoot: string): string { + return path.join(projectRoot, ARCH_DIR_REL, ARCH_FILE); +} + +/** + * Scan a project root and return a structured summary. Pure, side-effect free + * (apart from reading the file system) so we can unit-test the signature/diff + * logic without writing any files. + */ +export function scanProject(projectRoot: string, projectName?: string): ArchitectureScanResult { + const safeRoot = projectRoot && fs.existsSync(projectRoot) ? projectRoot : ''; + const name = (projectName?.trim()) || (safeRoot ? path.basename(safeRoot) : 'Unknown Project'); + + // ── package.json ───────────────────────────────────────────────────────── + let description = ''; + let pkgJson: any = null; + const pkgPath = safeRoot ? path.join(safeRoot, 'package.json') : ''; + if (pkgPath && fs.existsSync(pkgPath)) { + try { + pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (typeof pkgJson?.description === 'string') description = pkgJson.description.trim(); + } catch (e: any) { + logError('projectArchitecture: package.json parse failed.', { error: e?.message ?? String(e) }); + } + } + + // ── Runtime / framework fingerprint ───────────────────────────────────── + const runtimes: string[] = []; + if (safeRoot && fs.existsSync(path.join(safeRoot, 'tsconfig.json'))) runtimes.push('TypeScript'); + if (pkgJson) { + runtimes.push('Node.js'); + const deps = { ...(pkgJson.dependencies || {}), ...(pkgJson.devDependencies || {}) } as Record; + if (deps['@types/vscode'] || pkgJson.engines?.vscode) runtimes.push('VS Code Extension'); + if (deps['react']) runtimes.push('React'); + if (deps['next']) runtimes.push('Next.js'); + if (deps['express'] || deps['fastify']) runtimes.push('HTTP server'); + if (deps['@anthropic-ai/sdk']) runtimes.push('Anthropic SDK'); + if (deps['openai']) runtimes.push('OpenAI SDK'); + if (deps['@lmstudio/sdk']) runtimes.push('LM Studio SDK'); + } + if (safeRoot && fs.existsSync(path.join(safeRoot, 'pyproject.toml'))) runtimes.push('Python'); + if (safeRoot && fs.existsSync(path.join(safeRoot, 'Cargo.toml'))) runtimes.push('Rust'); + if (safeRoot && fs.existsSync(path.join(safeRoot, 'go.mod'))) runtimes.push('Go'); + + // ── Main modules (top-level code directories) ─────────────────────────── + const mainModules: ArchitectureScanResult['mainModules'] = []; + if (safeRoot) { + for (const candidate of CODE_DIRS) { + const dirAbs = path.join(safeRoot, candidate); + if (!_isDir(dirAbs)) continue; + const entries = _readDirSafe(dirAbs); + const fileCount = entries.filter((e) => _isFileLike(path.join(dirAbs, e))).length; + const subDirs = entries.filter((e) => _isDir(path.join(dirAbs, e))); + const desc = _describeModule(candidate, fileCount, subDirs); + mainModules.push({ dir: candidate, description: desc }); + } + } + + // ── Important files at the root ───────────────────────────────────────── + const importantFiles: string[] = []; + if (safeRoot) { + for (const f of ROOT_IMPORTANT) { + if (fs.existsSync(path.join(safeRoot, f))) importantFiles.push(f); + } + } + + // Signature: hash of the structural inputs only. We do NOT hash file + // *contents* — the goal is "did the shape of the project change" so the + // watcher doesn't re-render the doc for every keystroke in a TS file. + const signature = _hashSignature({ + name, + runtimes, + mainModules: mainModules.map((m) => `${m.dir}|${m.description}`), + importantFiles, + pkgVersion: pkgJson?.version || '', + pkgDeps: pkgJson ? Object.keys({ ...(pkgJson.dependencies || {}), ...(pkgJson.devDependencies || {}) }).sort().join(',') : '', + }); + + return { + projectName: name, + projectRoot: safeRoot, + description, + runtimes, + mainModules, + importantFiles, + signature, + }; +} + +function _describeModule(dir: string, fileCount: number, subDirs: string[]): string { + const subSummary = subDirs.length > 0 + ? ` — ${subDirs.slice(0, 6).join(', ')}${subDirs.length > 6 ? `, +${subDirs.length - 6} more` : ''}` + : ''; + const known: Record = { + src: 'Source code', + media: 'Webview assets (HTML/CSS/JS)', + core_py: 'Python utilities', + tests: 'Test suite', + lib: 'Library code', + app: 'Application entry', + apps: 'Application bundles', + packages: 'Monorepo packages', + }; + const label = known[dir] || 'Module'; + return `${label} (${fileCount} files${subSummary})`; +} + +function _isDir(p: string): boolean { + try { return fs.statSync(p).isDirectory(); } catch { return false; } +} +function _isFileLike(p: string): boolean { + try { return fs.statSync(p).isFile(); } catch { return false; } +} +function _readDirSafe(p: string): string[] { + try { + // Skip hidden + heavy noise dirs so the listing reads usefully. + return fs.readdirSync(p).filter((e) => !e.startsWith('.') && e !== 'node_modules' && e !== 'out' && e !== 'dist' && e !== '__pycache__'); + } catch { return []; } +} + +function _hashSignature(obj: unknown): string { + return crypto.createHash('sha1').update(JSON.stringify(obj)).digest('hex').slice(0, 16); +} + +/** + * Build or refresh the architecture doc. Idempotent: + * • If the file doesn't exist: scaffold full doc with auto + user-owned blocks. + * • If it exists: rewrite only the auto-managed block; preserve everything else. + */ +export function buildOrRefreshArchitectureDoc( + projectRoot: string, + projectName?: string, + nowIso: string = new Date().toISOString() +): BuildResult { + const scan = scanProject(projectRoot, projectName); + const docPath = architectureDocPathFor(projectRoot); + const docDir = path.dirname(docPath); + try { + fs.mkdirSync(docDir, { recursive: true }); + } catch (e: any) { + logError('projectArchitecture: mkdir failed.', { docDir, error: e?.message ?? String(e) }); + } + + const autoBlock = _renderAutoBlock(scan, nowIso); + + if (!fs.existsSync(docPath)) { + const full = _renderFullDoc(scan, autoBlock); + fs.writeFileSync(docPath, full, 'utf8'); + logInfo('projectArchitecture: created.', { docPath, signature: scan.signature }); + return { docPath, created: true, scan }; + } + + // In-place refresh: rewrite the auto-managed block, keep user-owned sections. + const existing = fs.readFileSync(docPath, 'utf8'); + const replaced = _replaceAutoBlock(existing, autoBlock); + if (replaced !== existing) { + fs.writeFileSync(docPath, replaced, 'utf8'); + logInfo('projectArchitecture: refreshed.', { docPath, signature: scan.signature }); + } + return { docPath, created: false, scan }; +} + +function _renderAutoBlock(scan: ArchitectureScanResult, nowIso: string): string { + const modules = scan.mainModules.length > 0 + ? scan.mainModules.map((m) => `- \`${m.dir}/\` — ${m.description}`).join('\n') + : '_(no top-level code directories detected)_'; + const importantFiles = scan.importantFiles.length > 0 + ? scan.importantFiles.map((f) => `- \`${f}\``).join('\n') + : '_(none detected)_'; + const runtimes = scan.runtimes.length > 0 ? scan.runtimes.join(', ') : '_(unknown)_'; + return [ + AUTO_START, + '## Project Name', + scan.projectName, + '', + '## Project Root', + scan.projectRoot || '_(not set)_', + '', + '## Description', + scan.description || '_(no package.json description)_', + '', + '## Runtime / Stack', + runtimes, + '', + '## Main Modules', + modules, + '', + '## Important Files', + importantFiles, + '', + `_Last auto-scan: ${nowIso}_`, + AUTO_END, + ].join('\n'); +} + +function _renderFullDoc(scan: ArchitectureScanResult, autoBlock: string): string { + // User-owned sections start as placeholders so first-time activation gives + // the user a clear "fill these in" surface without confusing the model. + return [ + `# ${scan.projectName} — Project Architecture Context`, + '', + '> Auto-managed sections (between the AUTO markers) are rewritten by Astra on every refresh.', + '> The rest is yours — Astra never touches it once this file exists.', + '', + autoBlock, + '', + '## Purpose', + '_TODO: 이 프로젝트가 해결하려는 문제를 1–3문장으로._', + '', + '## Key Workflows', + '_TODO: 사용자/시스템의 주요 흐름 (예: 입력 → context assembly → model 호출 → action)._', + '', + '## Current Constraints', + '_TODO: 의도된 제약 (local-first, offline, 특정 API 의존 등)._', + '', + '## Known Risks', + '_TODO: 알려진 위험/디버깅 함정._', + '', + '## Active Decisions', + '_TODO: 살아 있는 ADR/원칙 (e.g. "기록은 markdown으로", "agent별 model override 우선")._', + '', + ].join('\n'); +} + +function _replaceAutoBlock(existing: string, autoBlock: string): string { + const startIdx = existing.indexOf(AUTO_START); + const endIdx = existing.indexOf(AUTO_END); + if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) { + // No marker pair (likely an older file or hand-edited). Prepend the new + // auto block at the top so refreshes never silently lose the scan. + return `${autoBlock}\n\n${existing}`; + } + const before = existing.slice(0, startIdx); + const after = existing.slice(endIdx + AUTO_END.length); + return `${before}${autoBlock}${after}`; +} + +/** + * Read the architecture doc, returning the trimmed content suitable for + * injection into a prompt. Returns empty string if the file can't be read. + * + * Truncation strategy: try to keep the most decision-relevant sections — + * Purpose, Main Modules, Key Workflows, Current Constraints, Known Risks, + * Active Decisions — and drop the long auto-listing of files first. + */ +export function readArchitectureForPrompt(docPath: string, maxChars: number = 8000): string { + if (!docPath || !fs.existsSync(docPath)) return ''; + let raw: string; + try { + raw = fs.readFileSync(docPath, 'utf8'); + } catch (e: any) { + logError('projectArchitecture: read failed.', { docPath, error: e?.message ?? String(e) }); + return ''; + } + if (raw.length <= maxChars) return raw; + + // Section-aware trim: parse `## ` headers, prioritise the high-signal + // sections, drop the rest until we fit. Important Files is the longest + // auto section so it gets dropped first. + const sections = _splitSections(raw); + const priority = [ + 'Purpose', + 'Project Name', + 'Description', + 'Active Decisions', + 'Current Constraints', + 'Known Risks', + 'Key Workflows', + 'Main Modules', + 'Runtime / Stack', + 'Project Root', + 'Important Files', // drop first + ]; + sections.sort((a, b) => { + const ai = priority.indexOf(a.title); const bi = priority.indexOf(b.title); + const aw = ai === -1 ? 999 : ai; + const bw = bi === -1 ? 999 : bi; + return aw - bw; + }); + const out: string[] = [sections.find((s) => s.title === '__HEADER__')?.body || '']; + let used = out[0].length; + for (const sec of sections) { + if (sec.title === '__HEADER__') continue; + const block = `\n\n## ${sec.title}\n${sec.body}`; + if (used + block.length > maxChars) continue; + out.push(block); + used += block.length; + } + const trimmed = out.join(''); + return trimmed.length < raw.length + ? `${trimmed}\n\n_(architecture doc truncated to fit context budget)_` + : trimmed; +} + +function _splitSections(raw: string): { title: string; body: string }[] { + const lines = raw.split('\n'); + const sections: { title: string; body: string }[] = []; + let currentTitle = '__HEADER__'; + let currentBody: string[] = []; + for (const line of lines) { + const m = /^##\s+(.+)$/.exec(line); + if (m) { + sections.push({ title: currentTitle, body: currentBody.join('\n').trim() }); + currentTitle = m[1].trim(); + currentBody = []; + } else { + currentBody.push(line); + } + } + sections.push({ title: currentTitle, body: currentBody.join('\n').trim() }); + return sections; +} + +/** + * Format the doc content for injection into the system prompt. Includes a + * minimal preamble so the model knows what the block is and treats it as + * authoritative project ground truth (not just background reading). + */ +export function formatArchitectureContextForPrompt(opts: { + projectName: string; + docPath: string; + lastUpdated?: string; + maxChars?: number; +}): string { + const content = readArchitectureForPrompt(opts.docPath, opts.maxChars ?? 8000); + if (!content) return ''; + const stamp = opts.lastUpdated ? `\nLast updated: ${opts.lastUpdated}` : ''; + return [ + '[ACTIVE PROJECT ARCHITECTURE CONTEXT]', + `Source: ${opts.docPath}`, + `Project: ${opts.projectName}${stamp}`, + 'Use this as authoritative ground truth about the project structure, constraints, and active decisions. Do not contradict it without flagging the conflict.', + '---', + content, + '---', + ].join('\n'); +} diff --git a/src/features/projectArchitecture/intentDetector.ts b/src/features/projectArchitecture/intentDetector.ts new file mode 100644 index 0000000..369d01f --- /dev/null +++ b/src/features/projectArchitecture/intentDetector.ts @@ -0,0 +1,148 @@ +/** + * Project-intent detection from a chat message. + * + * Goal: when the user says "나 ConnectAI 프로젝트 진행할 거야" (or similar), + * spot the intent + project handle so the sidebar can activate Project Mode + * and auto-attach the architecture doc. + * + * Design philosophy: + * - Heuristic only. No LLM call. This is a routing decision, not a chat + * reply — false positives are cheap to correct (a chip the user can detach) + * but a 200 ms latency on every message would be unacceptable. + * - Multi-modal: pick up either an absolute project path OR a project NAME + * that matches an already-registered project. We avoid inventing brand + * new projects from arbitrary noun extraction — that produces too much + * noise. + * - Bilingual: Korean phrasing is the primary surface, English second. + */ + +/** A registered project the detector can match a name against. */ +export interface KnownProject { + projectId: string; + projectName: string; + /** Optional aliases (lowercased) the user might say instead of the name. */ + aliases?: string[]; + projectRoot?: string; +} + +export interface DetectionResult { + /** Project entry the message refers to. */ + project: KnownProject; + /** How we matched it — surfaced in logs so we can tune the regexes. */ + via: 'path' | 'name' | 'alias'; + /** The text fragment that triggered the match (for debugging). */ + matchedText: string; +} + +// Korean activation verbs that strongly imply "start / continue working on X". +// We keep this small and high-precision rather than trying to enumerate every +// phrasing — a missed match just means the user has to click the activate chip. +const KO_INTENT_PATTERNS: RegExp[] = [ + /(?:나|이제|오늘은|이번엔)?\s*([\p{L}\p{N}_\-\.]+)\s*(?:프로젝트)?\s*(?:진행|작업|시작|할\s*거야|볼\s*거야|볼게|하자|시작하자)/u, + /([\p{L}\p{N}_\-\.]+)\s*(?:프로젝트)\s*(?:열어|열자|확인)/u, +]; + +// English equivalents — same precision-first stance. +const EN_INTENT_PATTERNS: RegExp[] = [ + /\b(?:i'?m\s+working\s+on|let'?s\s+work\s+on|switching\s+to|i\s+want\s+to\s+work\s+on)\s+(?:the\s+)?([\w\-\.]+)\s*(?:project)?/i, + /\bopen\s+(?:the\s+)?([\w\-\.]+)\s+project\b/i, +]; + +const STOPWORDS = new Set([ + // High-frequency Korean particles/verbs that show up where the regex + // greedily captures a "noun" but shouldn't be treated as a project handle. + '나', '이제', '오늘은', '이번엔', '나도', '나는', + // English filler. + 'the', 'this', 'that', 'my', 'your', 'a', +]); + +/** + * Try to detect a project handle in `text` and resolve it against the list of + * `known` projects. Returns `null` when no high-confidence match is found. + */ +export function detectProjectIntent(text: string, known: KnownProject[]): DetectionResult | null { + const trimmed = (text || '').trim(); + if (!trimmed) return null; + + // 1) Direct absolute path (highest confidence). We also accept ~-prefixed + // paths because the chat history is full of them. The path doesn't have + // to match an existing project — sidebarProvider handles ephemeral + // project creation when needed. + const pathMatch = _matchPath(trimmed); + if (pathMatch) { + const exact = known.find((k) => k.projectRoot && _samePath(k.projectRoot, pathMatch)); + if (exact) return { project: exact, via: 'path', matchedText: pathMatch }; + // Synthesise an ephemeral entry — caller decides whether to materialise it. + return { + project: { + projectId: _slugify(pathMatch.split(/[\\/]/).filter(Boolean).pop() || pathMatch), + projectName: pathMatch.split(/[\\/]/).filter(Boolean).pop() || pathMatch, + projectRoot: pathMatch, + }, + via: 'path', + matchedText: pathMatch, + }; + } + + // 2) Phrase-based extraction → match against known project names/aliases. + // We require an intent pattern AND a known-name match: this rules out + // "나는 글을 쓸 거야" → it has the verb but no project handle. + const candidates: string[] = []; + for (const re of KO_INTENT_PATTERNS) { + const m = re.exec(trimmed); + if (m && m[1] && !STOPWORDS.has(m[1].toLowerCase())) candidates.push(m[1]); + } + for (const re of EN_INTENT_PATTERNS) { + const m = re.exec(trimmed); + if (m && m[1] && !STOPWORDS.has(m[1].toLowerCase())) candidates.push(m[1]); + } + for (const candidate of candidates) { + const hit = _findKnown(known, candidate); + if (hit) return hit; + } + return null; +} + +function _matchPath(text: string): string | null { + // Absolute POSIX path (including macOS volumes) or Windows drive path. + // We're permissive on what characters can appear — anything quoted or + // surrounded by whitespace counts. + const macVol = /\/Volumes\/[^\s`'"<>]+/; + const posix = /(?:^|\s)(\/[^\s`'"<>]+)/; + const win = /[A-Za-z]:[\\/][^\s`'"<>]+/; + return (text.match(macVol) || [])[0] + || (text.match(win) || [])[0] + || ((): string | null => { + const m = posix.exec(text); + return m ? m[1] : null; + })(); +} + +function _samePath(a: string, b: string): boolean { + return a.replace(/[\\/]+$/, '').toLowerCase() === b.replace(/[\\/]+$/, '').toLowerCase(); +} + +function _findKnown(known: KnownProject[], handle: string): DetectionResult | null { + const needle = _slugify(handle); + if (!needle) return null; + for (const k of known) { + if (_slugify(k.projectName) === needle) { + return { project: k, via: 'name', matchedText: handle }; + } + for (const alias of k.aliases ?? []) { + if (_slugify(alias) === needle) { + return { project: k, via: 'alias', matchedText: handle }; + } + } + } + return null; +} + +/** Same slug logic the chronicle module uses — lowercase, non-word→hyphen. */ +function _slugify(s: string): string { + return (s || '') + .toLowerCase() + .replace(/[^a-z0-9가-힣]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); +} diff --git a/src/features/projectChronicle/types.ts b/src/features/projectChronicle/types.ts index 1a370ac..cea9222 100644 --- a/src/features/projectChronicle/types.ts +++ b/src/features/projectChronicle/types.ts @@ -12,6 +12,20 @@ export interface ProjectProfile { detailLevel: ChronicleDetailLevel; createdAt: string; updatedAt: string; + // ── Project Architecture Context (Feature 2) ─────────────────────────────── + /** Absolute path to the auto-generated architecture markdown. */ + architectureDocPath?: string; + /** When true, the architecture doc is auto-attached to every prompt. */ + architectureAutoAttach?: boolean; + /** When true, file changes under projectRoot trigger a debounced refresh. */ + architectureAutoUpdate?: boolean; + /** ISO timestamp of the last (auto or manual) refresh. */ + architectureLastUpdated?: string; + /** + * Cheap hash of the inputs used by the last scan (package.json + top-level tree). + * Used by the file watcher to skip no-op regenerations. + */ + architectureLastScanSignature?: string; } export interface QuestionRecord { diff --git a/src/retrieval/index.ts b/src/retrieval/index.ts index c1ae52f..8ac0fbc 100644 --- a/src/retrieval/index.ts +++ b/src/retrieval/index.ts @@ -98,23 +98,31 @@ export class RetrievalOrchestrator { fusionLog.push(`Expanded tokens: [${expandedTokens.slice(0, 15).join(', ')}]`); // ── ① Brain File Search (TF-IDF enhanced, optionally hybrid with embeddings) ── + // `brainFileLimit === 0` is meaningful (Knowledge Mix "model knowledge only" + // mode), so use `??` rather than `||`. When the caller explicitly passes 0, + // we skip retrieval entirely instead of falling back to the default of 8. const scopeFolders = options.scopeFolders ?? []; - const brainChunks = this.searchBrainFiles( - query, - expandedTokens, - options.brain, - options.brainFileLimit || 8, - options.includeRawConversations || false, - scopeFolders, - options.queryEmbedding, - options.embeddingModel, - options.embeddingBlendAlpha - ); + const brainFileLimit = options.brainFileLimit ?? 8; + const brainChunks = brainFileLimit > 0 + ? this.searchBrainFiles( + query, + expandedTokens, + options.brain, + brainFileLimit, + options.includeRawConversations || false, + scopeFolders, + options.queryEmbedding, + options.embeddingModel, + options.embeddingBlendAlpha + ) + : []; allChunks.push(...brainChunks); fusionLog.push( - scopeFolders.length > 0 - ? `Brain search (scoped to ${scopeFolders.length} folder(s)): ${brainChunks.length} chunks` - : `Brain search: ${brainChunks.length} chunks found` + brainFileLimit === 0 + ? 'Brain search: skipped (Knowledge Mix weight = 0)' + : scopeFolders.length > 0 + ? `Brain search (scoped to ${scopeFolders.length} folder(s)): ${brainChunks.length} chunks` + : `Brain search: ${brainChunks.length} chunks found` ); // ── ② Memory Layers ── diff --git a/src/retrieval/knowledgeMix.ts b/src/retrieval/knowledgeMix.ts new file mode 100644 index 0000000..b53e90e --- /dev/null +++ b/src/retrieval/knowledgeMix.ts @@ -0,0 +1,161 @@ +/** + * Knowledge Mix — controls how much the assistant leans on Second Brain + * evidence vs. the model's own general knowledge for a given query. + * + * The single integer "secondBrainWeight" (0–100) drives three things: + * + * 1. RAG chunk budget — how many brain files we feed the model. + * 2. Retrieval ratio — what fraction of the context budget RAG can claim. + * 3. Prompt policy — natural-language instruction injected into the + * system prompt telling the model how to balance + * its own knowledge against the evidence shown. + * + * Per-agent overrides (AgentKnowledgeEntry.secondBrainWeight) win over the + * global config (g1nation.knowledgeMix.secondBrainWeight). Both fall back to + * the default `DEFAULT_WEIGHT` (balanced) when nothing is set. + * + * Keeping this module isolated and pure makes it trivial to unit-test the + * mapping curve and to extend it later (e.g. add a "creative" axis) without + * touching retrieval or prompt assembly. + */ +import { getConfig } from '../config'; +import { getOrCreateAgentEntry } from '../skills/agentKnowledgeMap'; + +export const DEFAULT_WEIGHT = 50; + +/** Where the resolved weight came from — surfaced in `_lastRetrievalInfo` for UX. */ +export type KnowledgeMixSource = 'agent' | 'global' | 'default'; + +export interface ResolvedKnowledgeMix { + /** Integer in [0, 100]. */ + weight: number; + source: KnowledgeMixSource; + /** Agent name when `source === 'agent'`, else undefined. */ + agent?: string; +} + +/** + * Resolve the effective weight for the active turn. + * + * Precedence: per-agent → global config → default. Out-of-range values from + * either source are clamped, never silently zeroed (a typo in JSON should not + * disable retrieval entirely). + */ +export function resolveKnowledgeMix(agentFileOrName?: string): ResolvedKnowledgeMix { + if (agentFileOrName && agentFileOrName !== 'none') { + try { + const entry = getOrCreateAgentEntry(agentFileOrName); + if (typeof entry.secondBrainWeight === 'number' && Number.isFinite(entry.secondBrainWeight)) { + return { + weight: _clamp(entry.secondBrainWeight), + source: 'agent', + agent: entry.name, + }; + } + } catch { + // Map missing or unreadable — fall through to global. + } + } + try { + const cfg = getConfig(); + if (typeof cfg.knowledgeMixSecondBrainWeight === 'number') { + return { weight: _clamp(cfg.knowledgeMixSecondBrainWeight), source: 'global' }; + } + } catch { + // getConfig should never throw in practice, but keep this safe in tests. + } + return { weight: DEFAULT_WEIGHT, source: 'default' }; +} + +function _clamp(n: number): number { + if (!Number.isFinite(n)) return DEFAULT_WEIGHT; + return Math.max(0, Math.min(100, Math.round(n))); +} + +/** + * Map a weight to the maximum number of brain files (long-term memory) the + * retriever is allowed to consider for this turn. + * + * Curve was chosen so that: + * - 0 fully disables brain-file retrieval (model-only mode). + * - 50 maps to roughly the existing default (`memoryLongTermFiles`), so + * behaviour without any per-agent setting matches the status quo. + * - 100 pushes the cap toward `selectWithinBudget`'s hard ceiling (12). + * + * The configured `memoryLongTermFiles` is treated as the "balanced" baseline: + * it's scaled up at high weights and damped at low weights. + */ +export function mapWeightToBrainFileLimit(weight: number, configuredLimit: number): number { + const w = _clamp(weight); + if (w === 0) return 0; + const baseline = Math.max(1, configuredLimit || 6); + // Linear interpolation: + // w=0 → 0 + // w=25 → baseline * 0.5 + // w=50 → baseline + // w=75 → baseline * 1.5 + // w=100 → baseline * 2 (capped at 12 elsewhere) + const scaled = Math.round((w / 50) * baseline); + // Honour the orchestrator's hard cap (12) so we never blow the budget. + return Math.max(0, Math.min(12, scaled)); +} + +/** + * Map a weight to the retrieval ratio (fraction of the context-budget that + * RAG can claim). Lower weights mean RAG gets a smaller slice and leaves more + * room for conversation history / system prompt. + */ +export function mapWeightToRetrievalRatio(weight: number): number { + const w = _clamp(weight); + // 0 → 0.05 (still room for tiny lessons block), 50 → 0.40 (status quo), 100 → 0.60. + return Math.max(0.05, Math.min(0.6, 0.05 + (w / 100) * 0.55)); +} + +/** + * Build the natural-language policy block injected into the system prompt. + * Returns `''` when the weight is exactly the default (50) — at the midpoint + * there's nothing useful to tell the model, and quieter prompts behave better. + */ +export function buildKnowledgeMixPolicy(mix: ResolvedKnowledgeMix): string { + const w = _clamp(mix.weight); + if (w === DEFAULT_WEIGHT) return ''; + const header = `[KNOWLEDGE MIX POLICY]\nSecond Brain reliance: ${w}% (model knowledge: ${100 - w}%).`; + let body: string; + if (w === 0) { + body = [ + 'Second Brain retrieval is disabled for this turn.', + 'Answer from your general knowledge and the conversation history alone.', + 'Do not invent file citations.', + ].join('\n'); + } else if (w < 25) { + body = [ + 'Rely primarily on your own general knowledge.', + 'Treat any Second Brain notes shown below as light reference material only.', + 'Brainstorming, broad explanations and creative synthesis are encouraged.', + ].join('\n'); + } else if (w < 50) { + body = [ + 'Lean on your general knowledge; use Second Brain notes as supporting context.', + 'Cite Brain files only when they materially shape the answer.', + ].join('\n'); + } else if (w < 75) { + body = [ + 'Prefer Second Brain evidence when it is present.', + 'Use your general knowledge to connect, explain, and fill harmless background.', + 'Do not override explicit Second Brain evidence with model assumptions.', + ].join('\n'); + } else if (w < 100) { + body = [ + 'Treat Second Brain notes as the primary evidence for this answer.', + 'Cite Brain files for any non-trivial claim and quote relevant lines when needed.', + 'If the notes do not cover a point, say so explicitly instead of guessing.', + ].join('\n'); + } else { + body = [ + 'Second Brain notes are the only authoritative source for this answer.', + 'Cite Brain files for every substantive claim.', + 'If a point is not in the notes, reply that it is outside the recorded knowledge — do not fall back to general knowledge.', + ].join('\n'); + } + return `${header}\n${body}`; +} diff --git a/src/sidebar/agentHandlers.ts b/src/sidebar/agentHandlers.ts index b049b9a..06f3689 100644 --- a/src/sidebar/agentHandlers.ts +++ b/src/sidebar/agentHandlers.ts @@ -66,13 +66,21 @@ export async function handleAgentMessage(provider: SidebarChatProvider, data: an if (!view) return true; try { const entry = getOrCreateAgentEntry(data.agentPath || ''); - const knowledgeMapHasEntry = entry.knowledgeFolders.length > 0 || (entry.skillFolders?.length ?? 0) > 0; + const hasWeightOverride = typeof entry.secondBrainWeight === 'number'; + const knowledgeMapHasEntry = entry.knowledgeFolders.length > 0 + || (entry.skillFolders?.length ?? 0) > 0 + || !!(entry.model && entry.model.trim()) + || hasWeightOverride; view.webview.postMessage({ type: 'agentMapData', value: { name: entry.name, knowledgeFolders: entry.knowledgeFolders, skillFolders: entry.skillFolders || [], + // Per-agent model override — empty string means "use current default model". + model: entry.model || '', + // null = no override (fall back to global slider); number = pinned 0–100. + secondBrainWeight: hasWeightOverride ? entry.secondBrainWeight : null, exists: knowledgeMapHasEntry, }, }); @@ -80,7 +88,7 @@ export async function handleAgentMessage(provider: SidebarChatProvider, data: an logError('agent-map: load failed.', { error: e?.message ?? String(e) }); view.webview.postMessage({ type: 'agentMapData', - value: { name: '', knowledgeFolders: [], skillFolders: [], exists: false }, + value: { name: '', knowledgeFolders: [], skillFolders: [], model: '', secondBrainWeight: null, exists: false }, }); } return true; @@ -96,10 +104,20 @@ export async function handleAgentMessage(provider: SidebarChatProvider, data: an const skillFolders = Array.isArray(data.skillFolders) ? data.skillFolders.filter((f: unknown) => typeof f === 'string') : []; + // Treat blank / "Use current model" as no override — drop the field entirely + // so the JSON stays clean and the resolver falls back to the global default. + const modelOverride = typeof data.model === 'string' ? data.model.trim() : ''; + // null / undefined / non-finite = "Use global setting" → drop the field. + let weightOverride: number | undefined; + if (typeof data.secondBrainWeight === 'number' && Number.isFinite(data.secondBrainWeight)) { + weightOverride = Math.max(0, Math.min(100, Math.round(data.secondBrainWeight))); + } const result = upsertAgentEntry({ name, knowledgeFolders, skillFolders, + model: modelOverride || undefined, + secondBrainWeight: weightOverride, }); view.webview.postMessage({ type: 'agentMapSaved', diff --git a/src/sidebar/chatHandlers.ts b/src/sidebar/chatHandlers.ts index d59dbc9..33697cf 100644 --- a/src/sidebar/chatHandlers.ts +++ b/src/sidebar/chatHandlers.ts @@ -33,6 +33,9 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any await provider._sendChronicleProjects(); await provider._restoreActiveSessionIntoView(); await provider._sendReadyStatus(); + // Restore the Project Architecture chip + watcher if the active project + // was already running in architecture mode in a previous VS Code session. + await provider._sendArchitectureStatus(); return true; case 'getReadyStatus': await provider._sendReadyStatus(); @@ -100,6 +103,50 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any provider._lmStudio?.lifecycle.onModelSelected(data.value); return true; } + case 'getKnowledgeMix': { + // Ship the current global Knowledge Mix to the webview so the slider can + // initialize. Per-agent overrides ride along with the agent map data. + const cfg = vscode.workspace.getConfiguration('g1nation'); + const w = cfg.get('knowledgeMix.secondBrainWeight', 50); + const clamped = Math.max(0, Math.min(100, Math.round(typeof w === 'number' ? w : 50))); + provider._view?.webview.postMessage({ + type: 'knowledgeMix', + value: { weight: clamped, source: 'global' }, + }); + return true; + } + case 'setKnowledgeMix': { + const raw = typeof data.value === 'number' ? data.value : NaN; + if (!Number.isFinite(raw)) return true; + const clamped = Math.max(0, Math.min(100, Math.round(raw))); + // Use whichever scope already holds the value to avoid the same "Workspace + // override shadows Global update" desync that the `model` case guards against. + const { target } = pickConfigTarget('g1nation', 'knowledgeMix.secondBrainWeight'); + await vscode.workspace.getConfiguration('g1nation').update('knowledgeMix.secondBrainWeight', clamped, target); + logInfo(`Knowledge Mix (global) updated to: ${clamped}`, { target }); + return true; + } + // ── Project Architecture (Feature 2) ────────────────────────────────── + case 'getArchitectureStatus': + await provider._sendArchitectureStatus(); + return true; + case 'openArchitectureDoc': + await provider._openArchitectureDoc(); + return true; + case 'refreshArchitecture': + await provider._refreshArchitecture(); + return true; + case 'detachArchitecture': + await provider._detachArchitecture(); + return true; + case 'activateArchitectureFromText': { + // Optional explicit-toggle path: webview can pass arbitrary text + // (e.g. the current input draft) for one-shot intent detection. + if (typeof data.text === 'string') { + await provider._tryActivateArchitectureFromText(data.text); + } + return true; + } case 'proactiveTrigger': await provider._handleProactiveSuggestion(data.context); return true; diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts index 4a343d0..38b276e 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -11,7 +11,8 @@ import { logError, logInfo, resolveEngine, - summarizeText + summarizeText, + openInEditorGroup } from './utils'; import { getConfig } from './config'; import { AgentExecutor, ChatMessage } from './agent'; @@ -26,6 +27,13 @@ import { handleAgentMessage } from './sidebar/agentHandlers'; import { getOrCreateAgentEntry, resolveScopeForAgent } from './skills/agentKnowledgeMap'; import { estimateModelParamsB } from './lib/contextManager'; import { loadExternalSkills, formatSkillsAsPromptBlock } from './skills/externalSkillLoader'; +import { + buildOrRefreshArchitectureDoc, + architectureDocPathFor, + formatArchitectureContextForPrompt, + scanProject, +} from './features/projectArchitecture'; +import { detectProjectIntent, KnownProject } from './features/projectArchitecture/intentDetector'; export interface SidebarLmStudioDeps { lifecycle: ModelLifecycleManager; @@ -73,6 +81,13 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn _modelsCache: { url: string; models: string[]; online: boolean; fetchedAt: number } | null = null; static readonly MODELS_CACHE_TTL_MS = 30000; + /** Active file-system watcher for the project-architecture auto-update. Disposed on switch/detach. */ + private _archWatcher?: vscode.FileSystemWatcher; + /** Debounce timer for the architecture watcher. */ + private _archWatchDebounce?: NodeJS.Timeout; + /** Project ID the current watcher is watching — kept so we don't double-register. */ + private _archWatchedProjectId?: string; + constructor( readonly _extensionUri: vscode.Uri, readonly _context: vscode.ExtensionContext, @@ -957,6 +972,277 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn await this._context.globalState.update(SidebarChatProvider.chronicleProjectsStateKey, projects); } + // ─── Project Architecture Context (Feature 2) ────────────────────────────── + // + // Activation flow: + // 1. Chat preprocessor (or an explicit "Activate" button) calls + // _tryActivateArchitectureFromText(latestUserMessage). + // 2. If the text yields a known/inferable project, we set it active, + // ensure the architecture doc exists, register the file watcher, + // and broadcast the state to the webview as a chip. + // 3. On every subsequent prompt, _handlePrompt reads + // _buildProjectArchitectureContext() and injects it into the model + // call. Detach → empty context + watcher disposed. + + /** True if the active project has its architecture doc auto-attached. */ + _isArchitectureAutoAttached(): boolean { + const p = this._getActiveChronicleProject(); + return !!(p && p.architectureDocPath && p.architectureAutoAttach !== false); + } + + /** + * Try to resolve a project handle from arbitrary user text. Combines: + * • Korean / English natural-language activation phrasing. + * • Absolute filesystem paths. + * • The existing Chronicle project list as ground truth for name matches. + */ + _detectProjectFromText(text: string): KnownProject | null { + const known = this._getChronicleProjects().map((p) => ({ + projectId: p.projectId, + projectName: p.projectName, + projectRoot: p.projectRoot, + })); + const hit = detectProjectIntent(text || '', known); + return hit?.project ?? null; + } + + /** + * Activate (or refresh) architecture context for the project resolved from + * `text`. No-op when no project is detected. Returns the activated profile + * id, or `null` if nothing changed. Side-effects: writes the architecture + * doc, marks the project active, broadcasts the chip state. + */ + async _tryActivateArchitectureFromText(text: string): Promise { + const detected = this._detectProjectFromText(text); + if (!detected) return null; + return this._activateArchitectureForProject(detected.projectId, { + fallbackName: detected.projectName, + fallbackRoot: detected.projectRoot, + }); + } + + /** + * Make `projectId` the active project, ensure its architecture doc exists, + * and register the file watcher. If the project isn't in the chronicle + * store yet (path-only match), materialise a minimal profile so subsequent + * turns can find it. + */ + async _activateArchitectureForProject( + projectId: string, + opts: { fallbackName?: string; fallbackRoot?: string } = {} + ): Promise { + const projects = this._getChronicleProjects(); + let profile = projects.find((p) => p.projectId === projectId); + + // Materialise a stub when the user references a project by path that + // isn't yet registered. We use the path's basename as the name and the + // standard records location as recordRoot so existing Chronicle code + // keeps working. + if (!profile) { + const root = opts.fallbackRoot || ''; + if (!root) { + logError('architecture: cannot activate without project root.', { projectId }); + return null; + } + const name = opts.fallbackName || path.basename(root) || projectId; + const now = new Date().toISOString(); + profile = { + projectId, + projectName: name, + projectRoot: root, + recordRoot: path.join(root, 'docs', 'records', name), + description: 'Auto-created by Project Architecture activation.', + corePurpose: '', + detailLevel: 'standard', + createdAt: now, + updatedAt: now, + }; + projects.push(profile); + await this._putChronicleProjects(projects); + } + + if (!profile.projectRoot) { + logError('architecture: profile has no projectRoot; cannot scan.', { projectId }); + return null; + } + + // Generate or refresh the doc. Always idempotent — the generator + // preserves user-owned sections. + const result = buildOrRefreshArchitectureDoc(profile.projectRoot, profile.projectName); + const now = new Date().toISOString(); + const updated: ProjectProfile = { + ...profile, + architectureDocPath: result.docPath, + architectureAutoAttach: profile.architectureAutoAttach ?? true, + architectureAutoUpdate: profile.architectureAutoUpdate ?? true, + architectureLastUpdated: now, + architectureLastScanSignature: result.scan.signature, + updatedAt: now, + }; + const next = projects.map((p) => (p.projectId === updated.projectId ? updated : p)); + await this._putChronicleProjects(next); + await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, projectId); + + // (Re)register the watcher for this project. + this._registerArchitectureWatcher(updated); + + // Tell the webview to show / refresh the chip. + await this._sendArchitectureStatus(); + logInfo('architecture: activated.', { + projectId, docPath: result.docPath, created: result.created, + }); + return projectId; + } + + /** Detach project mode: stop auto-attaching the doc and dispose the watcher. */ + async _detachArchitecture(): Promise { + const profile = this._getActiveChronicleProject(); + if (!profile) { + this._disposeArchitectureWatcher(); + await this._sendArchitectureStatus(); + return; + } + const projects = this._getChronicleProjects(); + const next = projects.map((p) => p.projectId === profile.projectId + ? { ...p, architectureAutoAttach: false } + : p); + await this._putChronicleProjects(next); + this._disposeArchitectureWatcher(); + await this._sendArchitectureStatus(); + logInfo('architecture: detached.', { projectId: profile.projectId }); + } + + /** Force a refresh of the architecture doc for the active project. */ + async _refreshArchitecture(): Promise { + const profile = this._getActiveChronicleProject(); + if (!profile || !profile.projectRoot) { + this._view?.webview.postMessage({ + type: 'architectureRefreshFailed', + value: { reason: 'no-active-project' }, + }); + return; + } + const result = buildOrRefreshArchitectureDoc(profile.projectRoot, profile.projectName); + const now = new Date().toISOString(); + const projects = this._getChronicleProjects(); + const next = projects.map((p) => p.projectId === profile.projectId + ? { + ...p, + architectureDocPath: result.docPath, + architectureLastUpdated: now, + architectureLastScanSignature: result.scan.signature, + updatedAt: now, + } + : p); + await this._putChronicleProjects(next); + await this._sendArchitectureStatus(); + } + + /** + * Build the `projectArchitectureContext` string for the active prompt. + * Returns empty string when auto-attach is off or the doc is missing — + * agent.ts then treats it as "no block" and emits nothing extra. + */ + _buildProjectArchitectureContext(): string { + const p = this._getActiveChronicleProject(); + if (!p || p.architectureAutoAttach === false || !p.architectureDocPath) return ''; + if (!fs.existsSync(p.architectureDocPath)) return ''; + return formatArchitectureContextForPrompt({ + projectName: p.projectName, + docPath: p.architectureDocPath, + lastUpdated: p.architectureLastUpdated, + }); + } + + /** Webview chip data — shown above the input box when active. */ + async _sendArchitectureStatus(): Promise { + if (!this._view) return; + const p = this._getActiveChronicleProject(); + const active = !!(p && p.architectureDocPath && p.architectureAutoAttach !== false); + this._view.webview.postMessage({ + type: 'architectureStatus', + value: active && p + ? { + active: true, + projectId: p.projectId, + projectName: p.projectName, + docPath: p.architectureDocPath, + lastUpdated: p.architectureLastUpdated || '', + autoUpdate: p.architectureAutoUpdate !== false, + } + : { active: false }, + }); + } + + /** Open the architecture doc in editor group 2. */ + async _openArchitectureDoc(): Promise { + const p = this._getActiveChronicleProject(); + if (!p || !p.architectureDocPath) return; + try { + const doc = await vscode.workspace.openTextDocument(p.architectureDocPath); + await vscode.window.showTextDocument(doc, { + viewColumn: vscode.ViewColumn.Two, + preview: false, + }); + } catch (e: any) { + vscode.window.showErrorMessage(`Architecture doc 열기 실패: ${e?.message ?? e}`); + } + } + + /** + * Register a debounced watcher over the project root. Only structural + * changes regen the doc — the signature hash decides whether to write. + * Files inside node_modules / out / dist are filtered by the glob to keep + * the noise floor sane during normal development. + */ + private _registerArchitectureWatcher(profile: ProjectProfile): void { + if (!profile.projectRoot) return; + if (this._archWatchedProjectId === profile.projectId && this._archWatcher) return; + this._disposeArchitectureWatcher(); + if (profile.architectureAutoUpdate === false) { + this._archWatchedProjectId = profile.projectId; + return; + } + const pattern = new vscode.RelativePattern( + profile.projectRoot, + '{package.json,tsconfig.json,README.md,src/**,media/**,docs/**,tests/**,core_py/**,lib/**,app/**}' + ); + const watcher = vscode.workspace.createFileSystemWatcher(pattern); + const onChange = () => this._scheduleArchitectureRefresh(); + watcher.onDidCreate(onChange); + watcher.onDidDelete(onChange); + watcher.onDidChange(onChange); + this._archWatcher = watcher; + this._archWatchedProjectId = profile.projectId; + logInfo('architecture: watcher registered.', { projectId: profile.projectId, root: profile.projectRoot }); + } + + private _disposeArchitectureWatcher(): void { + try { this._archWatcher?.dispose(); } catch { /* noop */ } + this._archWatcher = undefined; + if (this._archWatchDebounce) { clearTimeout(this._archWatchDebounce); this._archWatchDebounce = undefined; } + this._archWatchedProjectId = undefined; + } + + private _scheduleArchitectureRefresh(): void { + if (this._archWatchDebounce) clearTimeout(this._archWatchDebounce); + // 6 s debounce: long enough that a "save file" burst settles into one + // regen, short enough that the chip's "updated 2m ago" badge stays + // believable. + this._archWatchDebounce = setTimeout(async () => { + const profile = this._getActiveChronicleProject(); + if (!profile || !profile.projectRoot || profile.architectureAutoUpdate === false) return; + try { + // Cheap signature check first — most file events don't change shape. + const scan = scanProject(profile.projectRoot, profile.projectName); + if (scan.signature === profile.architectureLastScanSignature) return; + await this._refreshArchitecture(); + } catch (e: any) { + logError('architecture: auto-refresh failed.', { error: e?.message ?? String(e) }); + } + }, 6000); + } + _getActiveChronicleProject(): ProjectProfile | null { const projects = this._getChronicleProjects(); if (projects.length === 0) return null; @@ -1132,8 +1418,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn return; } - const doc = await vscode.workspace.openTextDocument(target); - await vscode.window.showTextDocument(doc); + await openInEditorGroup(target); } async _writeChroniclePlanningFromCurrentChat() { @@ -1743,8 +2028,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn fs.writeFileSync(filePath, `# Agent Persona: ${safeName}\n\nAdd your instructions here...\n`, 'utf8'); } - const doc = await vscode.workspace.openTextDocument(filePath); - await vscode.window.showTextDocument(doc); + await openInEditorGroup(filePath); await this._sendAgentsList(); } @@ -1820,6 +2104,10 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn this._currentSessionBrainId = selectedBrainId; let agentSkillContext = undefined; + // Per-agent model override: if the active agent has a pinned model in the + // knowledge map, it wins over the model the webview just sent. Falls back + // to the incoming `model` (which is the global default the user picked). + let effectiveModel: string = typeof model === 'string' ? model : ''; if (agentFile && agentFile !== 'none' && fs.existsSync(agentFile)) { const fileContent = fs.readFileSync(agentFile, 'utf8'); // Guard: a freshly-created agent still has only the placeholder template @@ -1842,6 +2130,21 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn if (block) { agentSkillContext = `${agentSkillContext.trim()}\n\n${block}`; } + // Apply the per-agent model override, if any. + const pinned = entry.model?.trim(); + if (pinned && pinned !== effectiveModel) { + logInfo('Per-agent model override applied.', { + agent: entry.name, + requested: effectiveModel, + pinned, + }); + effectiveModel = pinned; + // Inform the webview so its UI can reflect the model that's actually in use. + this._view?.webview.postMessage({ + type: 'agentModelOverride', + value: { agent: entry.name, model: pinned }, + }); + } } catch (e: any) { logError('External skill load failed.', { error: e?.message || String(e) }); } @@ -1850,6 +2153,19 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn const designerContext = designerGuard !== false ? this._buildDesignerGuardContext() : undefined; + // Project Architecture activation (Feature 2): if the user just said + // "나 X 프로젝트 진행할 거야" / mentioned an absolute path / etc., switch + // to that project's mode before assembling the prompt. Best-effort: + // failures here never block the actual answer. + if (typeof value === 'string' && value.trim().length > 0) { + try { + await this._tryActivateArchitectureFromText(value); + } catch (e: any) { + logError('architecture: intent detection failed.', { error: e?.message ?? String(e) }); + } + } + const projectArchitectureContext = this._buildProjectArchitectureContext(); + // [File Processing v2] 파일 타입별 분류 처리 let processedPrompt = value || ''; let imageFiles: any[] | undefined = undefined; @@ -1940,13 +2256,14 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } try { - await this._agent.handlePrompt(processedPrompt, model, { + await this._agent.handlePrompt(processedPrompt, effectiveModel || model, { internetEnabled: internet, visionContent: imageFiles, agentSkillContext, agentSkillFile: typeof agentFile === 'string' ? agentFile : undefined, negativePrompt, designerContext, + projectArchitectureContext: projectArchitectureContext || undefined, secondBrainTraceEnabled: secondBrainTrace !== false, secondBrainTraceDebug: !!secondBrainTraceDebug, brainProfileId: selectedBrainId diff --git a/src/skills/agentKnowledgeMap.ts b/src/skills/agentKnowledgeMap.ts index a025783..e848b35 100644 --- a/src/skills/agentKnowledgeMap.ts +++ b/src/skills/agentKnowledgeMap.ts @@ -42,6 +42,12 @@ export interface AgentKnowledgeEntry { skillFolders?: string[]; /** Optional: pinned model override for this agent (e.g. `qwen3:8b`). */ model?: string; + /** + * Optional: per-agent Knowledge Mix override (0–100). Higher = lean harder on + * Second Brain notes. When undefined the resolver falls back to the global + * `g1nation.knowledgeMix.secondBrainWeight` setting. Stored as integer. + */ + secondBrainWeight?: number; /** Optional: human-friendly note shown in UI hints. */ description?: string; } @@ -93,11 +99,19 @@ function _coerceMap(raw: unknown): AgentKnowledgeMap { const skillFolders = skillsRaw .map((f) => (typeof f === 'string' ? f.trim() : '')) .filter((f) => f.length > 0); + // Per-agent Knowledge Mix weight: only accept integers within [0, 100]. + // `null` and out-of-range numbers fall back to undefined (use global). + let secondBrainWeight: number | undefined; + if (typeof a.secondBrainWeight === 'number' && Number.isFinite(a.secondBrainWeight)) { + const w = Math.round(a.secondBrainWeight); + if (w >= 0 && w <= 100) secondBrainWeight = w; + } agents.push({ name, knowledgeFolders: folders, skillFolders: skillFolders.length > 0 ? skillFolders : undefined, model: typeof a.model === 'string' && a.model.trim() ? a.model.trim() : undefined, + secondBrainWeight, description: typeof a.description === 'string' && a.description.trim() ? a.description.trim() : undefined, }); } @@ -235,13 +249,21 @@ export function saveKnowledgeMap(map: AgentKnowledgeMap): { ok: boolean; path: s 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), + agents: (map.agents || []).map((a) => { + let secondBrainWeight: number | undefined; + if (typeof a.secondBrainWeight === 'number' && Number.isFinite(a.secondBrainWeight)) { + const w = Math.round(a.secondBrainWeight); + if (w >= 0 && w <= 100) secondBrainWeight = w; + } + return { + 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, + secondBrainWeight, + 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. @@ -272,6 +294,7 @@ export function getOrCreateAgentEntry(agentName: string): AgentKnowledgeEntry { knowledgeFolders: [...(existing.knowledgeFolders || [])], skillFolders: [...(existing.skillFolders || [])], model: existing.model, + secondBrainWeight: existing.secondBrainWeight, description: existing.description, }; } @@ -302,6 +325,7 @@ export function upsertAgentEntry(entry: AgentKnowledgeEntry): { ok: boolean; pat knowledgeFolders: entry.knowledgeFolders || [], skillFolders: entry.skillFolders || [], model: entry.model, + secondBrainWeight: entry.secondBrainWeight, description: entry.description, }; if (idx >= 0) next.agents[idx] = merged; @@ -338,7 +362,11 @@ export async function openKnowledgeMapEditor(): Promise { logInfo('agent-knowledge-map: starter created.', { jsonPath }); } const doc = await vscode.workspace.openTextDocument(jsonPath); - await vscode.window.showTextDocument(doc); + // Keep the ConnectAI sidebar (column 3) untouched — open the JSON in the editor group. + await vscode.window.showTextDocument(doc, { + viewColumn: vscode.ViewColumn.Two, + preview: false, + }); } 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/utils.ts b/src/utils.ts index 96cc19e..3ec9898 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -75,6 +75,26 @@ export function buildApiUrl(baseUrl: string, engine: EngineKind, endpoint: 'mode return `${apiRoot}/chat`; } +/** + * Open a file in the editor and keep ConnectAI's sidebar (typically ViewColumn.Three) + * undisturbed. Markdown records, wiki docs, agent skill files, knowledge-map JSON, + * lessons — all should land in the *editor* area (group 2), never in the sidebar group. + * + * Falls back to whatever ViewColumn ends up being default if `Two` is unavailable + * (VS Code creates the column on demand when one doesn't exist yet). + */ +export async function openInEditorGroup( + target: string | vscode.Uri, + options: { preview?: boolean } = {} +): Promise { + const uri = typeof target === 'string' ? vscode.Uri.file(target) : target; + const doc = await vscode.workspace.openTextDocument(uri); + return vscode.window.showTextDocument(doc, { + viewColumn: vscode.ViewColumn.Two, + preview: options.preview ?? false, + }); +} + export function summarizeText(text: string, maxLength: number = 400): string { const normalized = text.replace(/\s+/g, ' ').trim(); if (normalized.length <= maxLength) return normalized;