@@ -187,6 +228,24 @@
-
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;