import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { _getBrainDir, findBrainFiles, buildApiUrl, getActiveBrainProfile, getBrainProfiles, logError, logInfo, resolveEngine, summarizeText, openInEditorGroup } from './utils'; import { getConfig } from './config'; import { AgentExecutor, ChatMessage } from './agent'; import { BridgeInterface } from './bridge'; import { buildProjectChronicleGuardContext, ProjectChronicleManager, ProjectProfile } from './features/projectChronicle'; import { isThinFollowUp } from './lib/contextBuilders/thinFollowUp'; import type { ModelLifecycleManager } from './lmstudio/lifecycleManager'; import type { IActivityTracker } from './lmstudio/activityTracker'; import { handleChatMessage } from './sidebar/chatHandlers'; import { handleBrainMessage } from './sidebar/brainHandlers'; import { handleChronicleMessage } from './sidebar/chronicleHandlers'; import { handleAgentMessage } from './sidebar/agentHandlers'; import { registerSidebarHandler, dispatchSidebarMessage } from './sidebar/registry'; import { ApprovalGateManager } from './sidebar/managers/approvalGates'; import { ArchitectureWatchManager } from './sidebar/managers/architectureWatch'; import { CompanyTurnRuntime, AlignmentSession } from './sidebar/managers/companyTurn'; import { PixelOfficeState } from './sidebar/managers/pixelOfficeState'; import { mergeAgentWorkState, summariseContract, buildOfficeRoster, buildPixelOfficeUpdatePayload, buildPixelOfficeHtml, } from './sidebar/managers/pixelOfficeHelpers'; import { ChatSessionStore, ChatSession } from './sidebar/managers/chatSessionStore'; import { ModelDiscovery } from './sidebar/managers/modelDiscovery'; import { PixelOfficeLayoutStore } from './sidebar/managers/pixelOfficeLayoutStore'; import { buildBrainStatusPayload, buildBrainProfilesPayload, buildReadyStatusPayload, } from './sidebar/builders/statusPayloads'; import { buildCompanyPipelinesPayload, buildCompanyStatusPayload, buildCompanyAgentsPayload, buildCompanyResumablePayload, } from './sidebar/builders/companyPayloads'; import { slugify, summarizeForTitle, summarizeTextForWiki } from './sidebar/builders/textHelpers'; import { ChronicleProjectStore } from './sidebar/managers/chronicleProjectStore'; import { buildChronicleProjectsPayload, buildChronicleRecordsPayload } from './sidebar/builders/chroniclePayloads'; import { buildWikiRawMarkdown, formatTimestampForFile } from './sidebar/builders/wikiRaw'; import { AgentSkillStore } from './sidebar/managers/agentSkillStore'; import { proactiveSuggestionFor } from './sidebar/builders/proactiveSuggestions'; import { generateUniqueBrainId } from './sidebar/builders/brainProfileHelpers'; import { extractLocalProjectPath, inferAutoChronicleRecordType, } from './sidebar/builders/autoChronicleClassifier'; import { buildAutoBugRecord, buildAutoPlanningRecord, buildAutoDecisionRecord, buildAutoDevelopmentRecord, buildAutoDiscussionRecord, } from './sidebar/builders/autoChroniclePayloads'; import { buildArchitectureStatusPayload, buildArchitectureHiddenPayload, buildArchitectureRefreshFailedPayload, buildArchitectureRefreshResultPayload, buildProjectArchitectureContext, } from './sidebar/builders/architecturePayloads'; import { workspaceSynthesizedProfile, architectureActivationStub, normalizePathForCompare, } from './sidebar/builders/projectProfileFactories'; import { processAttachments } from './sidebar/builders/promptAttachments'; import { resolveAgentSkill } from './sidebar/builders/agentSkillResolver'; import { SessionStateStore, LastVisibleChatSnapshot } from './sidebar/managers/sessionStateStore'; import { buildSessionTitleFromHistory, buildSessionTitleFromText, buildSessionListPayload, buildSessionLoadedPayload, } from './sidebar/builders/sessionPayloads'; import { buildAlignmentAutoProceedPayload, buildAlignmentAskPayload, buildAlignmentCancelledPayload, } from './sidebar/builders/alignmentCardPayloads'; import { buildDispatcherDeps } from './sidebar/builders/dispatcherDepsBuilder'; import { createCompanyTurnEmit } from './sidebar/builders/companyTurnEmitter'; import { shouldAutoProceedAlignment, extractActiveRoleCategories } from './sidebar/builders/alignmentRouting'; // 기본 도메인 핸들러 4개를 모듈 load 시점에 한 번 registry 에 등록. // 외부 플러그인 / 새 도메인은 자기 모듈 init 시점에 registerSidebarHandler() 호출하면 // 별도 sidebarProvider 수정 없이 dispatch loop 에 합류. registerSidebarHandler(handleChatMessage); registerSidebarHandler(handleBrainMessage); registerSidebarHandler(handleChronicleMessage); registerSidebarHandler(handleAgentMessage); import { resolveScopeForAgent } from './skills/agentKnowledgeMap'; import { clearBrainTokenIndex } from './retrieval/brainIndex'; import { estimateModelParamsB } from './lib/contextManager'; import { buildOrRefreshArchitectureDoc, architectureDocPathFor, resolveActiveSubprojectRoot, scanProject, } from './features/projectArchitecture'; import { detectProjectIntent, KnownProject } from './features/projectArchitecture/intentDetector'; import { readCompanyState, runCompanyTurn, resumeCompanyTurn, listResumableSessions, analyzeIntent, resolveActivePipeline, } from './features/company'; import { AIService } from './core/services'; import { presentOfficeSnapshot } from './features/astraOffice'; export interface SidebarLmStudioDeps { lifecycle: ModelLifecycleManager; activity: IActivityTracker; /** Returns the list of model identifiers currently loaded in LM Studio (cached). */ loadedModels: () => Promise; /** * Returns every model downloaded into LM Studio (modelKey form). Used by the * dropdown so it does not depend on LM Studio's JIT setting — REST * `/v1/models` only lists loaded models when JIT is off, which on macOS * commonly leaves the dropdown with just the fallback `defaultModel`. */ downloadedModels: () => Promise; } // LastVisibleChatSnapshot 은 `src/sidebar/managers/sessionStateStore.ts` 로 이관. // ChatSession 정의는 `src/sidebar/managers/chatSessionStore.ts` 로 이관. /** * Sidebar UI Provider implementing BridgeInterface for BridgeServer */ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeInterface { public static readonly viewType = 'g1nation-v2-view'; // activeSessionId / blankChatActive / lastVisibleChat 세 키는 `SessionStateStore` 가 소유. static readonly lastAgentStateKey = 'g1nation.lastAgentPath'; // chronicleProjectsStateKey 는 ChronicleProjectStore (managers/) 가 소유. static readonly activeChronicleProjectStateKey = 'g1nation.activeChronicleProjectId'; static readonly lastAutoChronicleSignatureStateKey = 'g1nation.lastAutoChronicleSignature'; _view?: vscode.WebviewView; _panel?: vscode.WebviewPanel; /** * Pixel Office 의 모든 raw state slot 을 한 컨테이너로 통합. * - panel: 전체보기 webview panel reference (닫히면 undefined) * - current: 누적된 AgentWorkState (broadcast 직전 patch merge 결과) * - lastBubble Map: 같은 텍스트 연속 출력 회피 * - activity ring buffer: officeSnapshot 의 action-tag 히스토리 * Broadcast 메서드는 provider 에 남아 webview send 와 직접 통신. */ private readonly _pixelOffice = new PixelOfficeState(); public brainEnabled = true; _currentSessionBrainId: string | null = null; _currentNegativePrompt: string = ''; readonly _chronicle = new ProjectChronicleManager(); /** 모델 dropdown 의 cache + inflight + SDK/REST discovery 를 한 manager 로 통합. TTL 30s. */ private readonly _modelDiscovery = new ModelDiscovery(30000); /** * AbortController for the currently-running 1인 기업 turn. Cleared when * the turn ends (success or fail). The webview's Stop button routes * through `stopGeneration`, which calls `abortCompanyTurn()` to flip this * — the dispatcher's `signal` then short-circuits between phases. */ /** 옛 _companyAbort + _lastCompanyTurnSummary 두 slot 을 CompanyTurnRuntime 으로 통합. */ private readonly _companyTurn = new CompanyTurnRuntime(); /** * Open approval gates. The dispatcher emits `phase: 'awaiting-approval'` * for stages with `requiresApproval`, and waits on a Promise this map * stores. The webview surfaces 승인 / 수정요청 / 중단 buttons; clicks * route through `chatHandlers.respondCompanyApproval` which calls * `resolveApprovalGate(stageId, decision)` here. * * Keyed by stageId — only one approval may be pending per stage at a * time (sequential dispatch), but multiple stages across the same turn * each get their own entry as they hit their gate. On turn abort we * resolve all outstanding entries with `{ kind: 'abort' }` so the * dispatcher unblocks cleanly. */ // 옛 _pendingApprovals Map 을 ApprovalGateManager 로 격리. 사용처는 그대로 동작 // (등록 / resolve / abortAll 패턴 동일), 단 책임이 한 클래스로 모임. private _approvalGates = new ApprovalGateManager(); /** * Snapshot of the last completed company turn — fed into the intent * classifier so it can distinguish "follow-up on previous round" from * "brand-new task". Reset to undefined when the chat is cleared / new * session loaded. Only completed (non-aborted) reports populate this. */ // _lastCompanyTurnSummary 는 _companyTurn (CompanyTurnRuntime) 으로 통합. /** * Intent Alignment 진행 중인 상태. new_task가 분류되면 분석기를 돌리고, * confidence가 충분치 않으면 사용자에게 질문 카드를 띄운 뒤 이 슬롯에 * 현재 contract를 보관 → 다음 사용자 메시지를 *답변*으로 해석한다. * * 한 번에 한 alignment만 진행. 회사 모드는 sequential 방식이라 동시 * 다발 alignment가 생길 수 없음. 사용자가 도중 다른 chat을 던지면 * 그 메시지는 답변으로 합쳐진다 — "취소" 버튼이나 모드 토글로만 빠져 * 나갈 수 있게 하는 게 흐름상 안전. */ /** 옛 _pendingAlignment slot 을 AlignmentSession 으로 통합. */ private readonly _alignment = new AlignmentSession(); /** * Pixel Office 현재 작업 상태 캐시. 모든 emit/alignment hook이 한 슬롯에 * 모인 상태를 patch 형식으로 갱신 → broadcast. UI layer이므로 어떤 판단 * 로직에도 다시 영향 주지 않는다 — 단방향 read-only 흐름. */ // _pixelOfficeState / _pixelOfficeLastBubble / _pixelOfficeActivity / PIXEL_OFFICE_ACTIVITY_MAX // 는 _pixelOffice (PixelOfficeState) 컨테이너로 통합. /** * Astra Office 사용자 정의 레이아웃. workspace state에 저장되어 프로젝트별 * 다른 배치를 가질 수 있다. null/undefined면 webview가 디폴트 LAYOUT을 사용. * 편집 모드에서 사용자가 드래그로 위치 조정 후 저장 → 이 슬롯 업데이트 → * webview에 다시 broadcast. * * 데이터 shape: * { * cells: [{ roleKey, deskX, deskY, charX, charY }, ...], * decos: [{ id, type, x, y }, ...] * } */ // Pixel Office layout 영구 저장 — `src/sidebar/managers/pixelOfficeLayoutStore.ts` 의 // PixelOfficeLayoutStore 로 이관. constructor 에서 초기화. /** Phase B-2에서 chatHandlers가 alignment 진행 여부를 빠르게 확인하는 용도. */ isAlignmentPending(): boolean { return this._alignment.isPending(); } // ─────────────────────── Pixel Office collector ─────────────────────── // // 이 섹션은 *기존 emit / alignment / classifier 결과를 가로채* 그대로 // 모니터링용 상태로 변환하는 단방향 hub. 어떤 메서드도 추가 LLM 콜을 // 만들지 않고, dispatcher / planner / chatHandlers의 어느 분기에도 // 영향을 주지 않는다. 그래서 이 섹션 전체를 통째로 지워도 회사 모드는 // 평소와 동일하게 동작해야 한다 — 그 invariant가 깨지면 위반이다. /** webview로 보낼 직전 patch와 가벼운 reset/유틸. */ private _pixelOfficeBroadcast(patch: Partial, opts?: { bubbleStatus?: import('./features/company').AgentStatus; bubbleEvent?: import('./features/company').AgentEvent; bubbleAgentId?: string; }): void { const cfg = getConfig(); if (!cfg.companyPixelOfficeEnabled) return; const next = mergeAgentWorkState(this._pixelOffice.current, patch); this._pixelOffice.current = next; // 말풍선 — 상태 또는 이벤트가 지정된 경우만 생성. 풀에서 직전에 쓴 텍스트 // 회피해서 같은 말 연달아 안 나오게. const bubbles: import('./features/company').AgentBubble[] = []; if (cfg.companyPixelOfficeBubbles) { const aid = opts?.bubbleAgentId ?? next.agentId; if (opts?.bubbleStatus) { const lastKey = `status:${opts.bubbleStatus}`; // 동적 import 대신 require로 — 메서드 내부에서 너무 늦게 await 걸지 않게. const { getStatusBubbleText, makeBubble } = require('./features/company') as typeof import('./features/company'); const text = getStatusBubbleText(opts.bubbleStatus, this._pixelOffice.getLastBubble(lastKey)); if (text) { this._pixelOffice.setLastBubble(lastKey, text); bubbles.push(makeBubble({ agentId: aid, text, type: 'status' })); } } if (opts?.bubbleEvent) { const lastKey = `event:${opts.bubbleEvent}`; const { getEventBubbleText, eventBubbleType, makeBubble } = require('./features/company') as typeof import('./features/company'); const text = getEventBubbleText(opts.bubbleEvent, this._pixelOffice.getLastBubble(lastKey)); if (text) { this._pixelOffice.setLastBubble(lastKey, text); bubbles.push(makeBubble({ agentId: aid, text, type: eventBubbleType(opts.bubbleEvent) })); } } } const payload = { type: 'pixelOfficeUpdate' as const, value: { state: next, bubbles, config: { enabled: cfg.companyPixelOfficeEnabled, bubblesEnabled: cfg.companyPixelOfficeBubbles, maxVisibleBubbles: 3, bubbleDurationMs: 4500, }, }, }; // 사이드바 mini panel + 전체보기 webview panel 둘 다 같은 데이터 받음. this._view?.webview.postMessage(payload); this._pixelOffice.panel?.webview.postMessage(payload); // ── refactor #4 단계적 마이그레이션 ── // 옛 `pixelOfficeUpdate` 와 함께 새 `officeSnapshot` 도 emit. 어느 webview 가 // 새 message 를 listen 하기 시작하면 그때부터 자연스럽게 OfficeSnapshot 기반으로 // 동작. 양쪽 다 listen 안 해도 옛 동작 유지. 다음 세션에 webview 측 리시버 추가. try { // Roster derive (#G): 현재 turn 의 active agents 전체를 OfficeSnapshot 에 포함. // listActiveAgentsByCategory 는 CEO 를 항상 포함하고 사용자 activeAgentIds 만 // 통과시킨다. presenter 가 active agent 만 실제 상태로 표시, 나머지는 idle. const roster = buildOfficeRoster(readCompanyState(this._context)); const snapshot = presentOfficeSnapshot({ activeState: next, recentBubbles: bubbles, recentActivity: this._pixelOffice.getActivity(), roster, }); const snapMsg = { type: 'officeSnapshot' as const, value: snapshot }; this._view?.webview.postMessage(snapMsg); this._pixelOffice.panel?.webview.postMessage(snapMsg); } catch (e) { // presenter 가 throw 해도 옛 동작에는 영향 없도록 swallow. // 디버깅 필요 시 console.warn 으로 띄울 수 있음. } } /** * 현재 CompanyState 에서 active agents 를 평탄화 → presenter 의 roster 입력 모양으로. * read 실패 / company off → 빈 배열 (presenter 가 옛 single-agent fallback 사용). */ // _buildOfficeRoster / _pixelOfficeAppendLog → `src/sidebar/managers/pixelOfficeHelpers.ts` // + PixelOfficeState.appendLog. Activity push 도 PixelOfficeState.pushActivity 직접 호출. /** Intent classifier가 분류 직후 호출되어 한 줄 상태 진입을 표시. */ pixelOfficeOnIntentClassified(intent: 'chat' | 'followup' | 'new_task', userPrompt: string): void { if (intent !== 'new_task') { // 잡담/후속 — 회사 모드 표면적으로 일 안 하므로 'idle'로 잠시 복귀. this._pixelOfficeBroadcast({ status: 'idle', currentTask: userPrompt, currentStep: intent === 'followup' ? '직전 라운드 후속 응답' : '잡담 응답', message: undefined, recentLogs: this._pixelOffice.appendLog(`💬 분류: ${intent}`), }, { bubbleStatus: 'idle' }); return; } // new_task — intake → analyzing 흐름의 첫 신호. this._pixelOfficeBroadcast({ status: 'intake', currentTask: userPrompt, currentStep: '요청 접수', recentLogs: this._pixelOffice.appendLog(`📨 새 작업 요청 접수`), }, { bubbleStatus: 'intake' }); } /** Intent Alignment 분석 시작 (LLM 콜 직전). */ pixelOfficeOnAlignmentStart(userPrompt: string): void { this._pixelOfficeBroadcast({ status: 'analyzing', currentTask: this._pixelOffice.current?.currentTask || userPrompt, currentStep: '요청 분석 중 (C·G·C·F 추출)', message: undefined, recentLogs: this._pixelOffice.appendLog('🔍 의도 분석 시작'), }, { bubbleStatus: 'analyzing' }); } /** Alignment 결과를 받아 카드로 표시 직전. kind=auto-proceed면 곧장 planning 흐름. */ pixelOfficeOnAlignmentResult(kind: 'questions' | 'confirm' | 'auto-proceed', contract: import('./features/company').RequirementContract): void { if (kind === 'auto-proceed') { this._pixelOfficeBroadcast({ status: 'contract_ready', currentStep: 'Requirement Contract 확정 (자동 진행)', requirementContract: summariseContract(contract), needUserInput: undefined, recentLogs: this._pixelOffice.appendLog('✅ 계약 자동 확정'), }, { bubbleStatus: 'contract_ready', bubbleEvent: 'requirement_contract_created', }); return; } if (kind === 'questions') { this._pixelOfficeBroadcast({ status: 'need_clarification', currentStep: '확인 질문 대기 중', requirementContract: summariseContract(contract), needUserInput: contract.openQuestions, recentLogs: this._pixelOffice.appendLog(`🤔 ${contract.openQuestions.length}개 질문 대기`), }, { bubbleStatus: 'need_clarification', bubbleEvent: 'clarification_needed', }); return; } // confirm — 질문 없음, 사용자 OK만 받으면 됨. this._pixelOfficeBroadcast({ status: 'waiting_approval', currentStep: '계약 확인 대기', requirementContract: summariseContract(contract), needUserInput: undefined, awaitingApproval: '사용자 확인 — 그대로 진행할지 여부', recentLogs: this._pixelOffice.appendLog('🧭 계약 확인 카드 표시'), }, { bubbleStatus: 'waiting_approval' }); } /** Alignment 취소(사용자가 카드 닫음). */ pixelOfficeOnAlignmentCancelled(): void { this._pixelOfficeBroadcast({ status: 'idle', currentStep: '취소됨', needUserInput: undefined, awaitingApproval: undefined, recentLogs: this._pixelOffice.appendLog('🛑 작업 취소'), }); } // _summariseContract → `src/sidebar/managers/pixelOfficeHelpers.ts` /** * Dispatcher의 `CompanyTurnEvent`를 그대로 받아 Pixel Office 상태로 변환. * `_runCompanyTurn`의 emit 콜백이 매 이벤트를 한 번 더 이쪽으로 흘려준다. * 함수 안에서 어떤 dispatcher 분기도 다시 트리거하지 않는다 — read-only. */ pixelOfficeOnTurnEvent(ev: import('./features/company').CompanyTurnEvent): void { switch (ev.phase) { case 'plan-start': this._pixelOfficeBroadcast({ status: 'planning', currentStep: '계획 수립', recentLogs: this._pixelOffice.appendLog('📋 plan 작성'), }, { bubbleStatus: 'planning' }); return; case 'plan-ready': { // B. 미니 맵용 stage 리스트 초기화. 모두 pending. const stages = (ev.plan?.tasks ?? []).map((t) => ({ label: (t.task || '').slice(0, 24) || '단계', agent: t.agent, status: 'pending' as const, })); this._pixelOfficeBroadcast({ status: 'planning', currentStep: '계획 완료', nextStep: ev.plan?.tasks?.[0]?.task, message: ev.plan?.brief?.slice(0, 120), recentLogs: this._pixelOffice.appendLog(`📋 plan 완료 (${ev.plan?.tasks?.length ?? 0}개 task)`), pipelineStages: stages, }, { bubbleEvent: 'plan_completed' }); return; } case 'agent-start': { // B. 현재 stage를 active로, 이전 stage들은 done으로. const prev = this._pixelOffice.current?.pipelineStages; const stages = prev ? prev.map((s, i) => ({ ...s, status: (i < ev.index ? 'done' : i === ev.index ? 'active' : 'pending') as 'done'|'active'|'pending', })) : undefined; this._pixelOfficeBroadcast({ status: 'executing', currentStep: ev.task?.slice(0, 80), nextStep: undefined, message: `${ev.agentId} • ${ev.index + 1}/${ev.total}`, progress: ev.total > 0 ? ev.index / ev.total : undefined, recentLogs: this._pixelOffice.appendLog(`▶ ${ev.agentId} start (${ev.index + 1}/${ev.total})`), pipelineStages: stages, }, { bubbleAgentId: ev.agentId, bubbleStatus: 'executing', bubbleEvent: ev.index === 0 ? 'execution_started' : undefined, }); return; } case 'agent-done': // E. action-tag report가 있으면 ticker로 별도 push — 캐릭터가 *무슨 // 일을 했는지* 실시간 가시화. 사용자가 신뢰도/투명성을 즉시 체감. if (Array.isArray(ev.output?.actionReport) && ev.output.actionReport.length > 0) { const items = ev.output.actionReport.map((line: string) => ({ agentId: ev.agentId, text: line, ts: Date.now(), })); this._view?.webview.postMessage({ type: 'pixelOfficeActivity', value: { items } }); this._pixelOffice.panel?.webview.postMessage({ type: 'pixelOfficeActivity', value: { items } }); // 새 path 도 동기화 — ring buffer 에 누적 → 다음 officeSnapshot 에 포함. this._pixelOffice.pushActivity(items.map((it) => ({ agentId: it.agentId, text: it.text }))); } this._pixelOfficeBroadcast({ status: ev.output?.error ? 'error' : 'executing', progress: ev.total > 0 ? (ev.index + 1) / ev.total : undefined, recentLogs: this._pixelOffice.appendLog( ev.output?.error ? `❌ ${ev.agentId} ${ev.output.error}` : `✓ ${ev.agentId} 완료`, ), }, { bubbleAgentId: ev.agentId, bubbleEvent: ev.output?.error ? 'error_occurred' : undefined, }); return; case 'stage-loop': this._pixelOfficeBroadcast({ status: 'executing', currentStep: `재시도: ${ev.from} → ${ev.to} (${ev.iteration}회)`, recentLogs: this._pixelOffice.appendLog(`🔁 loop ${ev.from}→${ev.to} #${ev.iteration}`), }, { bubbleEvent: 'stage_loop_retry' }); return; case 'review-start': this._pixelOfficeBroadcast({ status: 'reviewing', currentStep: `검수 사이클 — ${ev.stageLabel}`, message: `검수자: ${ev.inspectorAgentId} · 최대 ${ev.maxRounds}라운드`, recentLogs: this._pixelOffice.appendLog(`🔍 검수 시작: ${ev.stageLabel}`), }, { bubbleStatus: 'reviewing' }); return; case 'review-round': this._pixelOfficeBroadcast({ status: 'reviewing', currentStep: `검수 라운드 ${ev.round} (${ev.inspectorVerdict}/${ev.ceoVerdict})`, recentLogs: this._pixelOffice.appendLog( `R${ev.round} insp:${ev.inspectorVerdict} ceo:${ev.ceoVerdict}`, ), }, { bubbleAgentId: ev.inspectorAgentId, bubbleEvent: ev.inspectorVerdict === 'pass' && ev.ceoVerdict === 'pass' ? 'review_passed' : (ev.inspectorVerdict === 'revise' ? 'review_failed' : undefined), }); return; case 'review-end': this._pixelOfficeBroadcast({ status: ev.final === 'aborted' ? 'error' : 'executing', currentStep: ev.final === 'pass' ? `검수 통과 (${ev.rounds}라운드)` : ev.final === 'maxed-out' ? `검수 한도 도달 — 진행 (${ev.rounds}라운드)` : '검수 중단', recentLogs: this._pixelOffice.appendLog(`🔍 검수 종료: ${ev.final}`), }, { bubbleEvent: ev.final === 'pass' ? 'review_passed' : ev.final === 'aborted' ? 'risky_change_detected' : undefined, }); return; case 'awaiting-approval': this._pixelOfficeBroadcast({ status: 'waiting_approval', currentStep: `승인 대기 — ${ev.stageLabel}`, awaitingApproval: `${ev.stageLabel} 단계 완료 검토`, recentLogs: this._pixelOffice.appendLog(`✋ 승인 대기: ${ev.stageLabel}`), }, { bubbleStatus: 'waiting_approval', bubbleEvent: 'approval_required', }); return; case 'approval-resolved': this._pixelOfficeBroadcast({ status: 'executing', awaitingApproval: undefined, currentStep: `승인 결과: ${ev.decision}`, recentLogs: this._pixelOffice.appendLog(`✋→ ${ev.decision}`), }); return; case 'report-start': this._pixelOfficeBroadcast({ status: 'reviewing', currentStep: 'CEO 종합 보고서 작성 중', recentLogs: this._pixelOffice.appendLog('🧭 보고서 작성'), }); return; case 'report-done': this._pixelOfficeBroadcast({ status: 'done', currentStep: ev.ok ? '보고서 완료' : '보고서 (fallback) 완료', progress: 1, recentLogs: this._pixelOffice.appendLog(ev.ok ? '✅ 보고서 OK' : '⚠ fallback 보고서'), }, { bubbleStatus: 'done', bubbleEvent: 'task_completed', }); return; case 'session-saved': this._pixelOfficeBroadcast({ status: 'done', message: `세션 저장됨`, recentLogs: this._pixelOffice.appendLog('💾 세션 저장'), }); return; case 'aborted': this._pixelOfficeBroadcast({ status: 'error', currentStep: `중단: ${ev.reason}`, recentLogs: this._pixelOffice.appendLog(`🛑 abort: ${ev.reason}`), }, { bubbleStatus: 'error', bubbleEvent: 'error_occurred', }); return; case 'telegram-mirror': default: return; // 시각화에 의미 약함 — log skip } } /** webview가 처음 로드되거나 사용자가 토글을 다시 켰을 때 캐시된 상태 재전송. */ pixelOfficeResend(): void { const cfg = getConfig(); const payload = buildPixelOfficeUpdatePayload(cfg, this._pixelOffice.current); this._view?.webview.postMessage(payload); this._pixelOffice.panel?.webview.postMessage(payload); if (cfg.companyPixelOfficeEnabled) { try { const snapshot = presentOfficeSnapshot({ activeState: payload.value.state ?? undefined, recentBubbles: [], recentActivity: this._pixelOffice.getActivity(), roster: buildOfficeRoster(readCompanyState(this._context)), }); const snapMsg = { type: 'officeSnapshot' as const, value: snapshot }; this._view?.webview.postMessage(snapMsg); this._pixelOffice.panel?.webview.postMessage(snapMsg); } catch { // Snapshot 재전송 실패가 기존 fallback 경로를 깨면 안 됨. } } } /** * editor area에 별도 Pixel Office 전체보기 panel을 띄움. 이미 열려 있으면 * 그 panel을 reveal. 사이드바 mini 패널과 동일한 데이터 스트림을 받지만 * 별도 HTML로 *사무실 그리드 + 직군별 캐릭터* 레이아웃을 보여준다. */ public openPixelOfficePanel(column: vscode.ViewColumn = vscode.ViewColumn.Beside): vscode.WebviewPanel { if (this._pixelOffice.panel) { this._pixelOffice.panel.reveal(column); return this._pixelOffice.panel; } const panel = vscode.window.createWebviewPanel( 'astra.pixelOffice', 'Astra Office', column, { enableScripts: true, localResourceRoots: [this._extensionUri], retainContextWhenHidden: true }, ); this._pixelOffice.panel = panel; panel.webview.html = buildPixelOfficeHtml(this._extensionUri, panel.webview); // panel과 백엔드 사이의 메시지 채널 — 닫기/리프레시 + 레이아웃 편집. panel.webview.onDidReceiveMessage((msg: any) => { if (!msg || typeof msg !== 'object') return; if (msg.type === 'getPixelOfficeState') this.pixelOfficeResend(); if (msg.type === 'closePixelOfficePanel') panel.dispose(); if (msg.type === 'getPixelOfficeLayout') { // webview가 layout 요청 — 저장된 게 있으면 그것, 없으면 null. panel.webview.postMessage({ type: 'pixelOfficeLayoutLoaded', value: this._layoutStore.read() ?? null, }); } if (msg.type === 'savePixelOfficeLayout') { // webview의 편집 모드에서 사용자가 저장 버튼 누름. void this._layoutStore.write(msg.value).then(() => { panel.webview.postMessage({ type: 'pixelOfficeLayoutSaved', value: { ok: true }, }); }); } if (msg.type === 'resetPixelOfficeLayout') { // 디폴트 LAYOUT으로 복귀. void this._layoutStore.clear().then(() => { panel.webview.postMessage({ type: 'pixelOfficeLayoutSaved', value: { ok: true, reset: true }, }); }); } if (msg.type === 'pixelOfficeCommand') { // D. 캐릭터 컨텍스트 메뉴 액션. 현재 abort만 처리 — Stop 버튼과 // 동일 경로(abortCompanyTurn + alignment 취소 + agent.stop)로 // 작업 즉시 중단. skip/pause 등은 dispatcher에 별도 API가 없어 미지원. if (msg.cmd === 'abort') { this.abortCompanyTurn(); this.cancelPendingAlignment(); this._agent.stop(); } } }); panel.onDidDispose(() => { if (this._pixelOffice.panel === panel) this._pixelOffice.panel = undefined; }); // 열자마자 현재 상태 한 번 push. this.pixelOfficeResend(); return panel; } // _buildPixelOfficeHtml → `src/sidebar/managers/pixelOfficeHelpers.ts` (buildPixelOfficeHtml) /** Alignment 슬롯 비우기 — 사용자가 "취소"를 눌렀거나 turn 시작/종료 시점 호출. */ clearPendingAlignment(): void { this._alignment.clear(); } /** * 프로젝트 아키텍처 watcher lifecycle. 옛 3개 state slot 을 manager 로 통합. * Callbacks 는 lazy 평가돼야 — provider 초기화 시점엔 `this._getActiveChronicleProject` * 가 아직 bind 안 됐을 수 있으므로 arrow 로 캡처. */ private readonly _archWatch = new ArchitectureWatchManager( () => this._getActiveChronicleProject(), () => this._refreshArchitecture() ); /** chat_sessions globalState 의 read/write/normalize 를 한 클래스로 격리. constructor 에서 초기화. */ private readonly _sessionStore: ChatSessionStore; /** Pixel Office 사용자 정의 layout 의 workspaceState read/write/clear. constructor 에서 초기화. */ private readonly _layoutStore: PixelOfficeLayoutStore; /** Chronicle 프로젝트 목록 globalState read/write + workspace fallback. constructor 에서 초기화. */ private readonly _chronicleStore: ChronicleProjectStore; /** Agent skill .md 파일 + lastSelected + negativePrompt 영구 저장. constructor 에서 초기화. */ private readonly _agentSkillStore: AgentSkillStore; /** * Session side-state (activeSessionId / blankChatActive / lastVisibleChat) 3 키 통합. * `sidebar/chatHandlers.ts` 같은 다른 핸들러 모듈도 직접 호출하므로 public. */ readonly _sessionState: SessionStateStore; constructor( readonly _extensionUri: vscode.Uri, readonly _context: vscode.ExtensionContext, readonly _agent: AgentExecutor, readonly _lmStudio?: SidebarLmStudioDeps ) { this._sessionStore = new ChatSessionStore(_context); this._layoutStore = new PixelOfficeLayoutStore(_context); this._chronicleStore = new ChronicleProjectStore(_context); this._agentSkillStore = new AgentSkillStore(_context); this._sessionState = new SessionStateStore(_context); this._agent.setHistoryChangeListener((history) => { void this._persistLastVisibleChat(history); }); } /** Latest LM Studio load/unload error — surfaced as a persistent segment in the readyBar * until the next successful model selection clears it. The transient chat toast alone * was getting missed (scrolled away, blended into chat noise). */ private _lmStudioLastError: string | undefined; /** Surface LM Studio lifecycle errors (load/unload failures) to the chat UI as a non-fatal toast. */ public postLmStudioError(message: string): void { this._lmStudioLastError = message; this._view?.webview.postMessage({ type: 'lmStudioError', value: message }); void this._sendReadyStatus(); } /** Clear the persistent LM Studio error segment. Called when the user picks a new * model so a stale failure does not haunt the next attempt. */ public clearLmStudioError(): void { if (this._lmStudioLastError === undefined) return; this._lmStudioLastError = undefined; void this._sendReadyStatus(); } public resolveWebviewView( webviewView: vscode.WebviewView, context: vscode.WebviewViewResolveContext, _token: vscode.CancellationToken, ) { this._initView(webviewView); } /** * Open the chat as a standalone editor panel (Column 3 by default). * Reuses the same view-init logic via a WebviewPanel→WebviewView adapter * so the rest of the provider keeps using `this._view` unchanged. */ public openAsPanel(column: vscode.ViewColumn = vscode.ViewColumn.Three): vscode.WebviewPanel { if (this._panel) { this._panel.reveal(column); return this._panel; } const panel = vscode.window.createWebviewPanel( SidebarChatProvider.viewType, 'Astra Chat', column, { enableScripts: true, localResourceRoots: [this._extensionUri], retainContextWhenHidden: true } ); this._panel = panel; const adapter = wrapPanelAsView(panel); panel.onDidDispose(() => { if (this._panel === panel) this._panel = undefined; if (this._view === adapter) this._view = undefined; }); this._initView(adapter); return panel; } /** * Sidebar 가 처음 열리거나 다시 보여질 때 dropdown / chip / readyBar 가 비지 않게 * 4 종의 status 를 한꺼번에 푸시. 호출 자체는 fire-and-forget — 각 send 는 * 내부에서 webview 부재면 noop. */ private _pushBootSnapshot(): void { void this._sendModels(); void this._sendBrainProfiles(); void this._sendAgentsList(); void this._sendReadyStatus(); } private _initView(webviewView: vscode.WebviewView) { this._view = webviewView; webviewView.webview.options = { enableScripts: true, localResourceRoots: [this._extensionUri] }; // Webview event listeners must be disposed — otherwise each re-init of the // view leaks a listener (and its captured `this`). We collect every // listener disposable here, dispose them when the view itself is disposed, // and also register them with the extension subscriptions as a backstop. const viewDisposables: vscode.Disposable[] = []; // [State Persistence Fix] 사이드바가 다시 보여질 때 세팅값 자동 복원 let _lastVisibilityRefresh = 0; viewDisposables.push(webviewView.onDidChangeVisibility(() => { if (!webviewView.visible) return; const now = Date.now(); // 5초 이내에 이미 갱신했으면 건너뜀 if (now - _lastVisibilityRefresh < 5000) return; _lastVisibilityRefresh = now; logInfo('Astra view became visible, restoring state...'); this._pushBootSnapshot(); })); webviewView.webview.html = this._getHtml(webviewView.webview); this._agent.setWebview(webviewView.webview); void this._restoreActiveSessionIntoView(); // v2.2.66 — initial-load 단계에서도 brain/models/agents 를 한 번 더 푸시한다. // 기존엔 webview 의 'ready' 핸드셰이크에만 의존했는데, 그 체인 도중 하나가 throw 하면 // 나머지 populate 가 통째로 안 돌아 dropdown 이 비는 회귀가 발생할 수 있다. 이중 보장. this._pushBootSnapshot(); viewDisposables.push(webviewView.webview.onDidReceiveMessage(async (data) => { // dispatch root 진입 trace — "/benchmark 입력했는데 아무 응답 없음" 같은 // 보고가 들어왔을 때 webview message가 정말 도착했는지부터 즉시 판별. const valuePreview = typeof data?.value === 'string' ? JSON.stringify(data.value.slice(0, 80)) : '(non-string)'; logInfo(`[DISPATCH] type=${data?.type} value=${valuePreview}`); if (await dispatchSidebarMessage(this, data)) return; logInfo(`Unhandled sidebar message: ${data?.type}`); })); webviewView.onDidDispose(() => { for (const d of viewDisposables.splice(0)) { try { d.dispose(); } catch { /* already disposed */ } } }); this._context.subscriptions.push(...viewDisposables); } _currentSessionId: string | null = null; async _restoreActiveSessionIntoView() { if (!this._view) return; const blankChatActive = this._sessionState.getBlankChatActive(); const currentHistory = this._agent.getHistory(); if (blankChatActive && currentHistory.length === 0) { return; } const activeSessionId = this._currentSessionId || this._sessionState.getActiveSessionId(); if (activeSessionId) { const loaded = await this._loadSession(activeSessionId, true); if (loaded) return; await this._sessionState.setActiveSessionId(null); } if (currentHistory.length > 0) { this._view.webview.postMessage({ type: 'restoreHistory', value: currentHistory }); await this._persistLastVisibleChat(currentHistory); return; } const snapshot = this._sessionState.getLastVisibleChat(); if (snapshot?.history?.length) { this._currentSessionId = snapshot.sessionId || null; this._currentSessionBrainId = snapshot.brainProfileId || getActiveBrainProfile().id; this._currentNegativePrompt = snapshot.negativePrompt || ''; await this._setActiveBrainProfile(this._currentSessionBrainId, true); this._agent.setHistory(snapshot.history); this._view.webview.postMessage({ type: 'restoreHistory', value: snapshot.history, negativePrompt: this._currentNegativePrompt, }); } } async _persistLastVisibleChat(history: ChatMessage[] = this._agent.getHistory()) { if (history.length === 0) { await this._sessionState.setLastVisibleChat(null); return; } await this._sessionState.setLastVisibleChat({ history, brainProfileId: this._currentSessionBrainId || getActiveBrainProfile().id, sessionId: this._currentSessionId, timestamp: Date.now(), negativePrompt: this._currentNegativePrompt, }); } async _saveCurrentSession() { try { await this._saveCurrentSessionInner(); } catch (err: any) { // Never let a persistence failure escape — callers run this from a // `finally` block, so a throw here would mask the original error // and (worse) is itself the thing that drops chats from 기록. logError('Failed to save current chat session.', { error: err?.message ?? String(err) }); } } private async _saveCurrentSessionInner() { const history = this._agent.getHistory(); if (history.length === 0) return; let sessions = this._sessionStore.getAll(); const title = buildSessionTitleFromHistory(history); const brainProfileId = this._currentSessionBrainId || getActiveBrainProfile().id; if (!this._currentSessionId) { this._currentSessionId = Date.now().toString(); sessions.unshift({ id: this._currentSessionId, title, timestamp: Date.now(), history, brainProfileId, negativePrompt: this._currentNegativePrompt, }); } else { const idx = sessions.findIndex((s) => s.id === this._currentSessionId); if (idx >= 0) { sessions[idx].history = history; sessions[idx].timestamp = Date.now(); sessions[idx].brainProfileId = brainProfileId; sessions[idx].negativePrompt = this._currentNegativePrompt; if (!sessions[idx].title || sessions[idx].title === 'New Chat') { sessions[idx].title = title; } } else { sessions.unshift({ id: this._currentSessionId, title, timestamp: Date.now(), history, brainProfileId, negativePrompt: this._currentNegativePrompt, }); } } // ChatSessionStore.putAll() 가 50 cap 적용 — 여기서는 단순 위임. await this._sessionStore.putAll(sessions); await this._sessionState.setActiveSessionId(this._currentSessionId); await this._persistLastVisibleChat(history); await this._sendSessionList(); } async _sendSessionList() { if (!this._view) return; this._view.webview.postMessage(buildSessionListPayload(this._sessionStore.getAll())); } async _loadSession(id: string, skipSessionListRefresh: boolean = false): Promise { if (!id) { logError('Session load requested without an id.'); this._postError('Chat session id is missing.'); return false; } const sessions = this._sessionStore.getAll(); const session = sessions.find((s) => s.id === id) || this._sessionStore.getById(id); if (!session) { logError('Session load failed because id was not found.', { id, sessionCount: sessions.length }); this._postError('Chat session was not found.'); return false; } const history = Array.isArray(session.history) ? session.history : []; if (history.length === 0) { logError('Session load failed because history is empty or invalid.', { id }); this._postError('This chat session has no saved messages.'); return false; } this._agent.stop(); this._currentSessionId = id; this._currentNegativePrompt = session.negativePrompt || ''; const sessionBrainId = session.brainProfileId || getActiveBrainProfile().id; await this._setActiveBrainProfile(sessionBrainId, true); this._agent.setHistory(history); await this._sessionState.setActiveSessionId(id); await this._sessionState.setBlankChatActive(false); await this._persistLastVisibleChat(history); this._view?.webview.postMessage(buildSessionLoadedPayload(id, session.title, history, this._currentNegativePrompt)); if (!skipSessionListRefresh) { await this._sendSessionList(); } logInfo('Chat session loaded.', { id, messages: history.length }); return true; } async _deleteSession(id: string) { let sessions = this._sessionStore.getAll(); sessions = sessions.filter((s) => s.id !== id); await this._sessionStore.putAll(sessions); if (this._currentSessionId === id) { this._currentSessionId = null; this._agent.resetConversation(); await this._sessionState.setActiveSessionId(null); await this._sessionState.setLastVisibleChat(null); await this._sessionState.setBlankChatActive(true); this.clearChat(); } await this._sendSessionList(); } // _getSessions / _getSessionById / _putSessions → `src/sidebar/managers/chatSessionStore.ts` // (`this._sessionStore.getAll()` / `.getById()` / `.putAll()`) async _sendBrainStatus() { if (!this._view) return; const activeBrain = getActiveBrainProfile(); const files = findBrainFiles(activeBrain.localBrainPath); this._view.webview.postMessage(buildBrainStatusPayload(activeBrain, files.length)); } /** * One-line "current readiness" snapshot for the sidebar's status bar: * engine online?, model loaded?, Brain file count, active Agent + mapped knowledge * folder count, memory on/off, context window. Cheap — no network calls except the * already-cached LM Studio loaded-models list and online flag. */ async _sendReadyStatus() { if (!this._view) return; try { const config = getConfig(); const engineKind = resolveEngine(config.ollamaUrl); const activeBrain = getActiveBrainProfile(); let brainFiles = 0; try { brainFiles = findBrainFiles(activeBrain.localBrainPath).length; } catch { /* ignore */ } const agentPath = this._context.globalState.get(SidebarChatProvider.lastAgentStateKey, 'none'); let agentName: string | null = null; let scopeFolders = 0; let mapped = false; if (agentPath && agentPath !== 'none') { agentName = path.basename(agentPath).replace(/\.md$/i, ''); try { const scope = resolveScopeForAgent(agentPath, activeBrain.localBrainPath || ''); scopeFolders = scope.folders.length; if (scope.agent?.name) agentName = scope.agent.name; mapped = scope.source !== 'none'; } catch { /* ignore */ } } let modelLoaded: boolean | null = null; if (engineKind === 'lmstudio') { try { const loaded = (await this._lmStudio?.loadedModels()) || []; modelLoaded = loaded.includes(config.defaultModel); } catch { modelLoaded = null; } } const paramB = estimateModelParamsB(config.defaultModel); const cappedForSmallModel = config.smallModelContextCap > 0 && paramB !== null && paramB <= 4 && config.contextLength > config.smallModelContextCap; const effectiveContextLength = cappedForSmallModel ? config.smallModelContextCap : config.contextLength; const payload = buildReadyStatusPayload({ engineKind, engineOnline: this._modelDiscovery.cachedOnline(config.ollamaUrl), modelName: config.defaultModel, modelLoaded, modelParamB: paramB, brainName: activeBrain.name, brainFiles, agentName, agentScopeFolders: scopeFolders, agentMapped: mapped, memoryEnabled: config.memoryEnabled, multiAgentEnabled: config.multiAgentEnabled, contextLength: config.contextLength, effectiveContextLength, cappedForSmallModel, lmStudioError: this._lmStudioLastError ?? null, }); this._view.webview.postMessage(payload); } catch (err: any) { logError('Failed to build ready status.', { error: err?.message || String(err) }); } } async _sendBrainProfiles() { if (!this._view) return; const activeBrain = getActiveBrainProfile(); this._currentSessionBrainId = this._currentSessionBrainId || activeBrain.id; const profiles = getBrainProfiles(); // v2.2.66 — dropdown 이 갑자기 비는 회귀가 보고됨. 무엇이 실제로 전송되는지 추적. logInfo(`[_sendBrainProfiles] profiles=${profiles.length} activeBrainId=${activeBrain.id} active=${activeBrain.name}`); this._view.webview.postMessage(buildBrainProfilesPayload(profiles, activeBrain.id)); void this._sendReadyStatus(); } _postBrainProfiles(profiles: any[], activeBrainId: string) { if (!this._view) return; this._view.webview.postMessage(buildBrainProfilesPayload(profiles, activeBrainId)); } async _setActiveBrainProfile(profileId: string, silent: boolean = false) { const profiles = getBrainProfiles(); const nextProfile = profiles.find((profile) => profile.id === profileId) || profiles[0]; if (!nextProfile) return; await vscode.workspace.getConfiguration('g1nation').update('activeBrainId', nextProfile.id, vscode.ConfigurationTarget.Global); this._currentSessionBrainId = nextProfile.id; // Drop the in-memory brain token index — the active brain (and its path) // may now differ, and the index's `_states` Map is otherwise never cleared. // The persisted on-disk index is left intact and reloads lazily on the // next query for whichever brain is now active. clearBrainTokenIndex(); await this._sendBrainProfiles(); await this._sendBrainStatus(); if (!silent) { this.injectSystemMessage(`**[Brain Switched]** ${nextProfile.name}\n\`${nextProfile.localBrainPath}\``); } } async _manageBrains() { const activeBrain = getActiveBrainProfile(); const choice = await vscode.window.showQuickPick([ { label: 'Add Brain Folder', description: 'Create a new brain profile from a local folder' }, { label: 'Open Active Brain Folder', description: activeBrain.localBrainPath }, { label: 'Open Brain Settings', description: 'Edit names, paths, repos, and descriptions' } ], { placeHolder: `Active Brain: ${activeBrain.name} (${activeBrain.localBrainPath})` }); if (!choice) return; if (choice.label === 'Add Brain Folder') { await this._addBrainProfile(); return; } if (choice.label === 'Open Active Brain Folder') { if (!fs.existsSync(activeBrain.localBrainPath)) { fs.mkdirSync(activeBrain.localBrainPath, { recursive: true }); } await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(activeBrain.localBrainPath)); return; } vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation.brainProfiles'); } /** * Add/edit/delete 의 공통 *persist + broadcast* 시퀀스. * * 1) `brainProfiles` 와 `activeBrainId` 두 설정을 globalState 에 동기화. * 2) `_currentSessionBrainId` 캐시 업데이트 — 다음 send 가 옳은 brain id 사용. * 3) `_postBrainProfiles` 로 freshly-built 목록 직접 송신 — cfg.update() 가 * async 라 캐시가 즉시 반영되지 않을 수 있어 raw nextProfiles 를 우선. * 4) `_sendBrainStatus` 로 chip 갱신. * 5) systemMessage 로 사용자에게 알림. */ private async _commitBrainProfileChange(nextProfiles: any[], nextActiveId: string, systemMessage: string): Promise { const cfg = vscode.workspace.getConfiguration('g1nation'); try { await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global); await cfg.update('activeBrainId', nextActiveId, vscode.ConfigurationTarget.Global); } catch (err: any) { logError('Failed to persist brain profiles.', { error: err?.message || String(err) }); vscode.window.showErrorMessage(`두뇌 프로필 저장 실패 (settings.json 쓰기 오류): ${err?.message ?? err}`); throw err; } // Read-back 검증 — cfg.update 가 성공처럼 반환해도 effective config 에 반영 안 될 수 있다: // (a) Workspace/Folder scope 의 g1nation.brainProfiles 가 Global 값을 가림, // (b) settings.json 쓰기 권한/프로필 문제. // 둘 다 화면상 "추가가 안 됨" 으로만 보였던 silent failure → 이제 명시적으로 알린다. const written = vscode.workspace.getConfiguration('g1nation').get('brainProfiles', []) || []; const landed = written.some((p) => p && p.id === nextActiveId); if (!landed) { const inspected = vscode.workspace.getConfiguration('g1nation').inspect('brainProfiles'); const hasWorkspace = !!(inspected?.workspaceValue || inspected?.workspaceFolderValue); const reason = hasWorkspace ? 'Workspace 설정(.vscode/settings.json)의 g1nation.brainProfiles 가 전역 값을 가리고 있습니다. 그 항목을 지우거나 그곳에 추가하세요.' : 'settings.json 쓰기가 반영되지 않았습니다 (파일 권한 또는 VS Code 프로필 설정을 확인하세요).'; logError('Brain profile write did not land in effective config.', { hasWorkspace }); vscode.window.showErrorMessage(`두뇌 추가 실패: ${reason}`); } this._currentSessionBrainId = nextActiveId; this._postBrainProfiles(nextProfiles, nextActiveId); await this._sendBrainStatus(); this.injectSystemMessage(systemMessage); } async _addBrainProfile() { try { const selected = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false, openLabel: '이 폴더를 두뇌로 사용' }); const folder = selected?.[0]?.fsPath; if (!folder) return; // 폴더 선택 취소 — 정상 종료 (에러 아님) // 구조 개선: 예전엔 폴더 선택 후 이름·설명·repo 입력창 3개가 연속으로 떴고, '이름' 입력창을 // Esc/바깥클릭으로 닫으면 `if (!name) return` 으로 전체 추가가 *조용히* 취소됐다. 이것이 // "추가가 안 된다" 의 주원인. 이제 폴더만 있으면 추가가 보장되고, 이름은 비우거나 취소해도 // 폴더명으로 진행한다. 설명/repo 는 추가 후 [수정] 에서 채운다 (다이얼로그 체인 최소화). const defaultName = path.basename(folder) || 'New Brain'; const nameInput = await vscode.window.showInputBox({ prompt: '두뇌 이름 (비워두면 폴더명 사용)', value: defaultName }); const name = (nameInput && nameInput.trim()) ? nameInput.trim() : defaultName; // getConfig() 가 메모리에 주입하는 가상 default-brain 이 저장되지 않도록 raw 설정을 직접 읽는다. const cfg = vscode.workspace.getConfiguration('g1nation'); const existingRaw: any[] = cfg.get('brainProfiles', []) || []; const id = generateUniqueBrainId(name, existingRaw); const newProfile = { id, name, localBrainPath: folder, secondBrainRepo: '', description: '' }; const nextProfiles = [...existingRaw, newProfile]; await this._commitBrainProfileChange(nextProfiles, id, `**[Brain Added]** ${name}\n\`${folder}\``); vscode.window.showInformationMessage(`두뇌 추가됨: ${name}`); } catch (err: any) { logError('Failed to add brain profile.', { error: err?.message || String(err) }); vscode.window.showErrorMessage(`두뇌 추가 중 오류: ${err?.message ?? err}`); } } async _editBrainProfile(profileId?: string) { const currentProfiles = getBrainProfiles(); const target = currentProfiles.find((profile) => profile.id === profileId) || getActiveBrainProfile(); if (!target) return; const name = await vscode.window.showInputBox({ prompt: 'Edit brain profile name', value: target.name, validateInput: (value) => value.trim() ? null : 'Brain name is required.' }); if (!name) return; const folder = await vscode.window.showInputBox({ prompt: 'Edit local brain folder path', value: target.localBrainPath, validateInput: (value) => value.trim() ? null : 'Brain folder path is required.' }); if (!folder) return; const description = await vscode.window.showInputBox({ prompt: 'Edit optional description shown in the Astra sidebar', value: target.description || '' }); const repo = await vscode.window.showInputBox({ prompt: 'Edit optional Second Brain Git repository URL', value: target.secondBrainRepo || '' }); const nextProfiles = currentProfiles.map((profile) => profile.id === target.id ? { ...profile, name: name.trim(), localBrainPath: folder.trim(), secondBrainRepo: (repo || '').trim(), description: (description || '').trim() } : profile ); await this._commitBrainProfileChange(nextProfiles, target.id, `**[Brain Updated]** ${name.trim()}\n\`${folder.trim()}\``); } async _deleteBrainProfile(profileId?: string) { const currentProfiles = getBrainProfiles(); const target = currentProfiles.find((profile) => profile.id === profileId) || getActiveBrainProfile(); if (!target) return; if (currentProfiles.length <= 1) { vscode.window.showWarningMessage('At least one brain profile is required.'); return; } const confirm = await vscode.window.showWarningMessage( `Delete brain profile "${target.name}"? The folder itself will not be deleted.`, { modal: true }, 'Delete Profile' ); if (confirm !== 'Delete Profile') return; const nextProfiles = currentProfiles.filter((profile) => profile.id !== target.id); const nextActive = nextProfiles[0]; await this._commitBrainProfileChange(nextProfiles, nextActive.id, `**[Brain Deleted]** ${target.name}`); } async _saveWikiRaw() { const history = this._agent.getHistory(); if (history.length === 0) { vscode.window.showWarningMessage('There is no conversation to save as wiki raw data.'); return; } const activeBrain = getActiveBrainProfile(); const rawDir = path.join(activeBrain.localBrainPath, 'raw-data'); if (!fs.existsSync(rawDir)) { fs.mkdirSync(rawDir, { recursive: true }); } const category = await vscode.window.showInputBox({ prompt: 'Wiki raw data category', value: 'Project Notes' }); if (category === undefined) return; const expectedValue = await vscode.window.showInputBox({ prompt: 'Expected value or future use', value: 'Reusable as source material for a future wiki document.' }); if (expectedValue === undefined) return; const timestamp = new Date(); const slug = slugify(history.find((message) => message.role === 'user')?.content || 'conversation'); const fileName = `${formatTimestampForFile(timestamp)}-${slug}.md`; const filePath = path.join(rawDir, fileName); const markdown = buildWikiRawMarkdown(history, { category: category.trim() || 'Project Notes', expectedValue: expectedValue.trim() || 'Reusable as source material for a future wiki document.', activeBrainName: activeBrain.name, activeBrainPath: activeBrain.localBrainPath, createdAt: timestamp.toISOString() }); await vscode.workspace.fs.writeFile(vscode.Uri.file(filePath), Buffer.from(markdown, 'utf8')); vscode.window.showInformationMessage(`Wiki raw data saved: ${path.basename(filePath)}`); this.injectSystemMessage(`**[Wiki Raw Saved]** \`${filePath}\``); } // _buildWikiRawMarkdown / _formatTimestampForFile → `src/sidebar/builders/wikiRaw.ts` // slugify / summarizeForTitle / summarizeTextForWiki / escapeYamlString // → `src/sidebar/builders/textHelpers.ts` // --- BridgeInterface Methods --- /** * `streamStart → streamChunk → streamEnd` triple — webview 가 한 덩이 메시지로 * 인식하게. injectSystemMessage / proactiveSuggestion 등 두 곳 이상이 같은 * 패턴을 쓰므로 한 메서드로 통일. */ private _streamSystemNotice(content: string): void { if (!this._view) return; this._view.webview.postMessage({ type: 'streamStart' }); this._view.webview.postMessage({ type: 'streamChunk', value: content }); this._view.webview.postMessage({ type: 'streamEnd' }); } /** Webview 에 error toast 한 줄 — 6+ 곳이 같은 inline payload 를 만들고 있었음. */ private _postError(value: string): void { this._view?.webview.postMessage({ type: 'error', value }); } /** streamEnd 단독 송신 — 회사 turn finally / alignment cancel 등 input 잠금 해제용. */ private _postStreamEnd(): void { this._view?.webview.postMessage({ type: 'streamEnd' }); } public injectSystemMessage(msg: string): void { this._streamSystemNotice(msg); } public getHistoryText(): string { return "Conversation history placeholder for evaluation."; } public sendPromptFromExtension(prompt: string): void { if (this._view) { this._view.show?.(true); this._view.webview.postMessage({ type: 'injectPrompt', value: prompt }); } } public findBrainFiles(dir: string): string[] { return findBrainFiles(dir); } // --- End BridgeInterface --- public focusInput() { this._view?.webview.postMessage({ type: 'focusInput' }); } public clearChat() { this._view?.webview.postMessage({ type: 'clearChat' }); } public async syncBrain() { const activeBrain = getActiveBrainProfile(); const brainDir = activeBrain.localBrainPath; if (!fs.existsSync(brainDir)) { vscode.window.showErrorMessage("Second Brain directory not found."); return; } vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "Astra: Syncing Second Brain...", cancellable: false }, async () => { try { const { execSync } = require('child_process'); execSync(`git add .`, { cwd: brainDir }); execSync(`git commit -m "[G1-Sync] Manual knowledge update"`, { cwd: brainDir }); execSync(`git push`, { cwd: brainDir }); vscode.window.showInformationMessage("Second Brain synced successfully."); } catch (err: any) { vscode.window.showInformationMessage("Brain Synced: Local knowledge is up-to-date (no changes to push)."); } }); } // _getChronicleProjects / _putChronicleProjects → `src/sidebar/managers/chronicleProjectStore.ts` // (`this._chronicleStore.getAll()` / `.putAll()`) /** * One-time repair for chronicle projects that were stored before the * multi-subproject fix landed. * * Old activation code always treated `workspaceFolders[0]` (= the open * parent folder, e.g. `/.../Antigravity`) as the project root, so every * subproject the user activated (`ConnectAI`, `Datacollector_MAC`, * `Skybound`, …) ended up with `projectRoot === ` in globalState. * That breaks reload: `_ensureActiveProjectForWorkspace` case 2 matches * the corrupted entry on every boot and force-switches the active * project to whichever bad row it found first. * * The repair: for each chronicle project whose `projectName` does NOT * match the basename of its stored `projectRoot` (i.e. it can't actually * be the project root), and where a same-name subfolder exists under * the current workspace, retarget the entry to that subfolder. Idempotent * — re-running it after the first pass changes nothing. */ async _repairCorruptedChronicleProjectRoots(): Promise { const wsRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; if (!wsRoot) return; const projects = this._chronicleStore.getAll(); if (projects.length === 0) return; const norm = (p: string | undefined) => (p || '').replace(/[\\/]+$/, '').toLowerCase(); const wsRootNorm = norm(wsRoot); let changed = false; const repaired = projects.map((p) => { if (!p.projectName || !p.projectRoot) return p; const rootBase = path.basename(p.projectRoot).toLowerCase(); const nameMatches = rootBase === p.projectName.toLowerCase(); if (nameMatches) return p; // root basename agrees with name → trust it // Only repair entries that look like they were silently captured at // the workspace parent. Anything else (different machine, custom // path) we leave alone. if (norm(p.projectRoot) !== wsRootNorm) return p; const candidate = path.join(wsRoot, p.projectName); try { if (!fs.existsSync(candidate) || !fs.statSync(candidate).isDirectory()) return p; } catch { return p; } const newDocPath = path.join(candidate, '.astra', 'project-context', 'architecture.md'); changed = true; return { ...p, projectRoot: candidate, recordRoot: path.join(candidate, 'docs', 'records', p.projectName), architectureDocPath: fs.existsSync(newDocPath) ? newDocPath : p.architectureDocPath, }; }); if (!changed) return; await this._chronicleStore.putAll(repaired); const fixedCount = repaired.filter((p, i) => p !== projects[i]).length; logInfo('architecture: repaired chronicle projects with wrong projectRoot.', { wsRoot, count: fixedCount, }); } // ─── 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. /** * 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._chronicleStore.getAll().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._chronicleStore.getAll(); let profile = projects.find((p) => p.projectId === projectId); // Materialise a stub when the user references a project by path that // isn't yet registered. The factory uses the path's basename as the // name and the standard records location so existing Chronicle code // keeps working. if (!profile) { const root = opts.fallbackRoot || ''; if (!root) { logError('architecture: cannot activate without project root.', { projectId }); return null; } profile = architectureActivationStub(projectId, root, opts.fallbackName); projects.push(profile); await this._chronicleStore.putAll(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, // [BUGFIX] Previously used `?? true`, but `??` only fallbacks on null/undefined. // After a user Detach, `architectureAutoAttach === false`, so `false ?? true` // stays `false` and the chip stays "detached — click Attach to re-enable" // forever no matter how many times the user clicks Attach. The activation // path is an explicit user intent to re-enable, so force `true` here. 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._chronicleStore.putAll(next); await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, projectId); // (Re)register the watcher for this project. this._archWatch.register(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._archWatch.dispose(); await this._sendArchitectureStatus(); return; } const projects = this._chronicleStore.getAll(); const next = projects.map((p) => p.projectId === profile.projectId ? { ...p, architectureAutoAttach: false } : p); await this._chronicleStore.putAll(next); this._archWatch.dispose(); await this._sendArchitectureStatus(); logInfo('architecture: detached.', { projectId: profile.projectId }); } /** * Force a refresh of the architecture doc for the active project. * * Always rewrites the auto-managed block (so the "Last Refresh" stamp + * stats reflect the click). Emits an `architectureRefreshResult` event * with the per-file work breakdown — that's what makes the operation * visibly trustworthy in the UI (no more "0.1s, nothing visible"). */ async _refreshArchitecture(): Promise { const startedAt = Date.now(); const profile = this._getActiveChronicleProject(); if (!profile || !profile.projectRoot) { this._view?.webview.postMessage(buildArchitectureRefreshFailedPayload('no-active-project')); return; } const result = buildOrRefreshArchitectureDoc(profile.projectRoot, profile.projectName); const now = new Date().toISOString(); const projects = this._chronicleStore.getAll(); const next = projects.map((p) => p.projectId === profile.projectId ? { ...p, architectureDocPath: result.docPath, architectureLastUpdated: now, architectureLastScanSignature: result.scan.signature, updatedAt: now, } : p); await this._chronicleStore.putAll(next); await this._sendArchitectureStatus(); // Tell the webview exactly what the scan did so the user can // trust the "Refresh" button actually ran. The three numbers // (newly / cached / deleted) together explain whether the doc // changed or just had its timestamp bumped. this._view?.webview.postMessage(buildArchitectureRefreshResultPayload({ projectName: profile.projectName, docPath: result.docPath, newlyAnalyzed: result.refreshStats.newlyAnalyzed, cached: result.refreshStats.cached, deleted: result.refreshStats.deleted.length, durationMs: Date.now() - startedAt, })); } /** * Re-attach the architecture context for the active project after a * prior Detach. Rebuilds the doc (so the user gets a fresh scan), * flips `architectureAutoAttach=true`, re-registers the watcher, and * broadcasts the chip back to its active state. The complement of * `_detachArchitecture`. */ async _attachArchitecture(): Promise { // `_ensureActiveProjectForWorkspace` guarantees the active project // matches the current VS Code workspace — without that, hitting // Attach right after opening a different folder would silently // attach to whatever was last active in the *previous* workspace. const profile = await this._ensureActiveProjectForWorkspace(); if (!profile || !profile.projectRoot) { this._view?.webview.postMessage(buildArchitectureRefreshFailedPayload('no-active-project')); return; } await this._activateArchitectureForProject(profile.projectId, { fallbackName: profile.projectName, fallbackRoot: profile.projectRoot, }); } /** * Make sure the active chronicle project actually corresponds to the * folder the user has open in VS Code. Three cases: * * 1. Active project already matches workspace → return it as-is. * 2. A *different* chronicle project matches the workspace → flip * the active id to that one (the user switched folders since * last session). * 3. No chronicle project matches → synthesise a new one from the * workspace folder name + register it. * * Returns the (possibly newly created) active project, or `null` when * no workspace is open. Idempotent — calling repeatedly with no change * is free. */ async _ensureActiveProjectForWorkspace(): Promise { const wsRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; if (!wsRoot) return null; // When the parent folder contains several subprojects, use the active // editor's location to pick the *effective* subproject root instead of // always reporting the parent. const hint = vscode.window.activeTextEditor?.document.uri.fsPath ?? vscode.window.visibleTextEditors[0]?.document.uri.fsPath; const resolved = resolveActiveSubprojectRoot(wsRoot, hint); const active = this._getActiveChronicleProject(); const projects = this._chronicleStore.getAll(); // Did the editor hint actually point at a nested subproject? If the // resolver fell back to the workspace root (no nested marker, or the // Astra sidebar itself was focused so no editor existed), we must NOT // clobber the user's current active project — they may have set it by // hand via the project picker, or by typing a project intent. Auto- // switching is only safe when there is a clear editor-driven signal. const isNestedHit = !!resolved && normalizePathForCompare(resolved) !== normalizePathForCompare(wsRoot); if (!isNestedHit && active) { return active; } const workspaceRoot = isNestedHit ? resolved : wsRoot; if (active && active.projectRoot && normalizePathForCompare(active.projectRoot) === normalizePathForCompare(workspaceRoot)) { return active; } // Case 2: another chronicle project matches → switch active to it const matching = projects.find((p) => normalizePathForCompare(p.projectRoot) === normalizePathForCompare(workspaceRoot)); if (matching) { await this._context.globalState.update( SidebarChatProvider.activeChronicleProjectStateKey, matching.projectId, ); logInfo('architecture: switched active project to match workspace.', { from: active?.projectId, to: matching.projectId, }); return matching; } // Case 3: synthesise a fresh entry for this workspace const profile = workspaceSynthesizedProfile(workspaceRoot); const nextProjects = projects.filter((p) => p.projectId !== profile.projectId).concat(profile); await this._chronicleStore.putAll(nextProjects); await this._context.globalState.update( SidebarChatProvider.activeChronicleProjectStateKey, profile.projectId, ); logInfo('architecture: registered new project from workspace.', { projectId: profile.projectId, projectRoot: workspaceRoot, }); return profile; } /** Active project 의 architecture context (agent.ts prompt prepend). 빌더에 위임. */ _buildProjectArchitectureContext(): string { return buildProjectArchitectureContext(this._getActiveChronicleProject()); } /** * Webview chip data. Three states: * * 1. **active** — project mode is on; doc is being auto-attached. * 2. **inactive** — there's a project + workspace, but architecture * is either never-activated or user-detached. * The chip shows an `[Attach]` button instead of * hiding entirely, so users always have a one- * click path back into project mode. * 3. **hidden** — no workspace open and no project at all. * * Also does an auto-activation pass for the *fresh-workspace* case: * when the active project has no `architectureDocPath` yet AND the * user hasn't explicitly detached, we generate the doc + flip * `autoAttach=true` so the user opens a new folder and immediately * sees the architecture context working. Existing detach choices are * always respected. */ async _sendArchitectureStatus(): Promise { if (!this._view) return; // Always sync the active project to the current VS Code workspace // before reporting — otherwise switching workspaces leaves the // chip pointing at the *previous* project's doc. const p = await this._ensureActiveProjectForWorkspace(); if (!p) { this._view.webview.postMessage(buildArchitectureHiddenPayload()); return; } const wasDetached = p.architectureAutoAttach === false; const hasDoc = !!(p.architectureDocPath && fs.existsSync(p.architectureDocPath)); // Auto-activation for fresh workspaces: never been activated AND // never been detached → kick off a build and re-broadcast. Single // recursion is safe because the post-activate state will hit the // `active` branch below. if (!hasDoc && !wasDetached && p.projectRoot) { try { await this._activateArchitectureForProject(p.projectId, { fallbackName: p.projectName, fallbackRoot: p.projectRoot, }); return; // _activateArchitectureForProject sends its own status } catch (e: any) { logError('architecture: auto-activate failed.', { error: e?.message ?? String(e) }); // Fall through to the inactive state so the user still sees an Attach button. } } this._view.webview.postMessage(buildArchitectureStatusPayload(p, hasDoc, wasDetached)); if (hasDoc && !wasDetached) { // Re-register the watcher in case it was disposed (e.g. workspace switch). this._archWatch.register(p); } } // ─── 1인 기업 (Company) Mode ──────────────────────────────────────────── // // When `companyState.enabled` is true, prompts coming through the chat // handler are routed to `_runCompanyTurn` instead of the normal // AgentExecutor path. The dispatcher emits `companyTurnUpdate` events as // each phase progresses; the webview shows a step-by-step header for // CEO planning, each specialist's dispatch, and the final synthesis. /** True iff company mode is active. Cheap — read from globalState. */ isCompanyModeEnabled(): boolean { return readCompanyState(this._context).enabled; } /** * Abort the currently-running 1인 기업 turn if any. Returns true when an * abort was actually fired (so the chat handler can skip `agent.stop()` * — the company path never touches AgentExecutor). The dispatcher will * see `signal.aborted` at its next phase boundary and emit * `phase: 'aborted'`; `_runCompanyTurn`'s finally clause then posts * `streamEnd` so the UI unlocks. */ abortCompanyTurn(): boolean { if (!this._companyTurn.abort()) return false; // 승인 게이트 대기 중인 모든 stage 를 'abort' 로 해제. 안 하면 dispatcher 가 // 영원히 await 상태로 남아 turn 이 절대 종료 안 됨. ApprovalGateManager 가 // 일괄 처리 + Map clear. this._approvalGates.abortAll(); return true; } /** * Clear the cached "last completed company turn" — called when the user * starts a new chat / loads a different session. Without this, an old * report would bleed into the next session's intent classifications and * make stale "follow-up" verdicts. */ clearLastCompanyTurnSummary(): void { this._companyTurn.clearLastSummary(); } /** Read accessor for the intent classifier. May return undefined on cold start. */ getLastCompanyTurnSummary() { return this._companyTurn.getLastSummary(); } /** * Intent Alignment 1라운드 실행. new_task 분류 직후 (또는 사용자 답변 후) * 호출되며 LLM 분석기를 한 번 돌려 contract를 채운다. confidence가 'high' * (또는 strict 모드 아니고 medium)이면 곧장 pipeline dispatch로 넘어가고, * 그 외엔 webview에 카드를 띄워 사용자 응답을 기다린다(_pendingAlignment). * * mode: * - 'smart': high → 자동 dispatch, medium → 사용자 확인 카드, low → 질문 카드 * - 'strict': confidence 무관 항상 사용자 확인 카드 * * roundsLimit를 넘기면 더 묻지 않고 현재 contract로 카드(확인)만 띄움. */ async _runIntentAlignment(opts: { userPrompt: string; previousContract?: import('./features/company').RequirementContract; previousAnswers?: Array<{ q: string; a: string }>; pipelineIdOverride?: string; mode: 'smart' | 'strict'; roundsLimit: number; roundsAsked: number; }): Promise { const cfg = getConfig(); const state = readCompanyState(this._context); const activePipeline = resolveActivePipeline(state); // Pixel Office: 분석 시작 표시 (LLM 콜 직전). try { this.pixelOfficeOnAlignmentStart(opts.userPrompt); } catch { /* noop */ } // 모드 전환 직전 일반 채팅 요약 — Intent Alignment 가 *이미 논의된 맥락* 을 // 재질문하지 않도록. 후속 라운드(previousContract 있음) 면 chatHistory 가 // 이미 contract 에 흡수됐으므로 중복 첨부 안 함. let priorChatSummary: string | undefined; if (!opts.previousContract) { try { const history = this._agent.getHistory(); const visible = history.filter((m) => !m.internal && (m.role === 'user' || m.role === 'assistant')); // 마지막 N=10 턴, content 200자 cap — 토큰 폭주 방지. const recent = visible.slice(-10); if (recent.length > 0) { priorChatSummary = recent .map((m) => `${m.role}: ${String(m.content || '').replace(/\s+/g, ' ').trim().slice(0, 200)}`) .join('\n'); } } catch { /* history 못 가져와도 alignment 자체는 동작 */ } } const analysis = await analyzeIntent( new AIService(), { userOriginalPrompt: opts.userPrompt, previousAnswers: opts.previousAnswers, previousContract: opts.previousContract, activePipelineName: activePipeline?.name, availableRoleCategories: extractActiveRoleCategories(state), priorChatSummary, }, // 분류기와 같은 작은 모델을 재사용 — 이 단계도 빠르고 가벼워야 함. { model: cfg.companyIntentClassifierModel || cfg.defaultModel }, ); const contract = analysis.contract; const reachedLimit = opts.roundsAsked >= opts.roundsLimit; if (shouldAutoProceedAlignment(opts.mode, contract, reachedLimit)) { // contract 한 줄 안내 후 곧장 pipeline. 사용자 friction 최소. this._view?.webview.postMessage(buildAlignmentAutoProceedPayload(contract, reachedLimit)); try { this.pixelOfficeOnAlignmentResult('auto-proceed', contract); } catch { /* noop */ } this._alignment.clear(); await this._runCompanyTurn(opts.userPrompt, undefined, opts.pipelineIdOverride, contract); return; } // 그 외 — 카드 표시 + 사용자 응답 대기. 라운드 한도 도달했거나 // openQuestions가 비어 있으면 "확인" 카드(질문 없음, 진행/취소 버튼만). const askMode: 'confirm' | 'questions' = reachedLimit || contract.openQuestions.length === 0 ? 'confirm' : 'questions'; this._view?.webview.postMessage(buildAlignmentAskPayload(askMode, contract, opts.roundsAsked, opts.roundsLimit)); try { this.pixelOfficeOnAlignmentResult(askMode, contract); } catch { /* noop */ } this._alignment.set({ userOriginalPrompt: opts.userPrompt, contract, roundsAsked: opts.roundsAsked, pipelineIdOverride: opts.pipelineIdOverride, }); // streamEnd 보내야 채팅 input 잠금이 풀려 사용자가 답변을 칠 수 있음. // 평소 1인 기업 turn은 _runCompanyTurn finally에서 보내지만, alignment는 // dispatcher를 안 거치고 사용자 입력으로 unlock해야 하므로 명시적으로 push. this._postStreamEnd(); void this._sendReadyStatus(); } /** * 사용자가 alignment 카드 상태에서 채팅 입력(답변)을 보낸 경우 호출. * 답변을 contract에 합쳐 분석기 재호출, 라운드를 한 칸 늘림. */ async _handleAlignmentAnswer(userMessage: string): Promise { const pending = this._alignment.get(); if (!pending) return; const cfg = getConfig(); const mode = (cfg.companyIntentAlignmentMode === 'strict') ? 'strict' : 'smart'; const { ALIGNMENT_DEFAULT_MAX_ROUNDS } = await import('./features/company/intentAlignment'); const roundsLimit = Math.max(1, Math.min(5, cfg.companyIntentAlignmentMaxRounds ?? ALIGNMENT_DEFAULT_MAX_ROUNDS)); // 사용자 답변을 미해결 질문들에 일괄 매핑 — 작은 모델이 자연어로 통째로 // 답하기 마련이라 질문별로 분리 안 함. 그냥 "이번 라운드 사용자 추가 // 답변" 한 덩어리로 넣어주면 분석기가 다음 라운드에 그걸 보고 알아서 // 갱신한다. const compositeAnswer = userMessage.trim(); const updatedAnswers = [ ...pending.contract.answeredQuestions, { q: pending.contract.openQuestions.join(' / ') || '(추가 정보 요청)', a: compositeAnswer }, ]; // 슬롯 비워두고 alignment 다시 돌림 — 새 결과가 다시 alignment 슬롯을 // 채울 것이고, 자동 진행 조건 충족 시 pipeline까지 갈 수도 있다. this._alignment.clear(); await this._runIntentAlignment({ userPrompt: pending.userOriginalPrompt, previousContract: pending.contract, previousAnswers: updatedAnswers, pipelineIdOverride: pending.pipelineIdOverride, mode, roundsLimit, roundsAsked: pending.roundsAsked + 1, }); } /** * 사용자가 카드의 "✅ 진행" 버튼을 눌러 현 contract 그대로 dispatch * 시키고 싶을 때. 슬롯 비우고 pipeline. */ async _proceedWithCurrentAlignment(): Promise { const pending = this._alignment.consume(); if (!pending) return; await this._runCompanyTurn( pending.userOriginalPrompt, undefined, pending.pipelineIdOverride, pending.contract, ); } /** * 사용자가 카드의 "🛑 취소"를 누르거나 alignment를 그만두려 할 때. * 슬롯 비우고 webview에도 streamEnd push해서 input 풀어줌. */ cancelPendingAlignment(): void { if (!this._alignment.isPending()) return; this._alignment.clear(); this._view?.webview.postMessage(buildAlignmentCancelledPayload()); this._postStreamEnd(); try { this.pixelOfficeOnAlignmentCancelled(); } catch { /* noop */ } void this._sendReadyStatus(); } /** * Casual chat path inside 1인 기업 모드 — used when the intent classifier * routes a message to `chat` or `followup` instead of `new_task`. We * deliberately *don't* spin up the dispatcher here: that surface is for * multi-step work. Instead we route through the normal chat path (same * AgentExecutor.handlePrompt used outside company mode) so streaming UI, * brain retrieval, and the action-tag executor all work as users expect. * * The `reason` from the classifier is surfaced as a small label so the * user can tell *why* their message wasn't treated as a new task — if * they meant it as one, they can rephrase or override with a keyword * like "파이프라인 돌려" / "기획해줘". */ async _handleCompanyCasual(prompt: string, intent: 'chat' | 'followup', reason: string, originalData: any): Promise { // 사용자에게 "왜 이게 가벼운 응답으로 갔는지" 보여주는 한 줄 라벨. // 잘못 분류된 거라면 사용자가 즉시 인지하고 다시 말할 수 있어야 한다. const label = intent === 'followup' ? '💬 후속 대화' : '💬 대화'; this._view?.webview.postMessage({ type: 'companyIntentDecision', value: { intent, reason, label }, }); try { await this._handlePrompt(originalData); await this._autoWriteChronicleAfterPrompt(); } finally { // Same guarantee as the normal chat path — a throw after the // answer streamed must not skip the 기록 save. await this._saveCurrentSession(); } } /** * Called by chatHandlers when the user clicks an approval card button. * Resolves the dispatcher's awaitApproval promise for `stageId`. Idempotent * — extra clicks after the first one are silently dropped. */ resolveApprovalGate(stageId: string, decision: import('./features/company/dispatcher').ApprovalDecision): boolean { return this._approvalGates.resolve(stageId, decision); } /** * Push the full pipeline catalogue + active id to the webview so the * editor overlay can render the cards. Pipelines are user-defined * (no built-ins) so an empty list is the default for new users. */ async _sendCompanyPipelines(): Promise { if (!this._view) return; this._view.webview.postMessage(buildCompanyPipelinesPayload(readCompanyState(this._context))); } /** Send the chip state (active flag + agent count + name) to the webview. */ async _sendCompanyStatus(): Promise { if (!this._view) return; this._view.webview.postMessage(buildCompanyStatusPayload(readCompanyState(this._context))); } /** * Push the full agent catalogue when the manage panel opens. Each entry * carries both the *default* (from `agents.ts`) and *override* (from * globalState) fields so the UI can show the user what they've edited, * gray out unchanged fields, and offer a Reset button per agent. */ async _sendCompanyAgents(): Promise { if (!this._view) return; const state = readCompanyState(this._context); const globalWeight = getConfig().knowledgeMixSecondBrainWeight ?? 50; this._view.webview.postMessage(buildCompanyAgentsPayload(state, globalWeight)); } /** * Drive one full company turn. Caller is the chat handler; it's already * persisted the user message and started a streaming bubble. We feed * progress events back as `companyTurnUpdate` messages so the same bubble * fills in as each agent finishes. */ async _runCompanyTurn( userPrompt: string, resumeTimestamp?: string, pipelineIdOverride?: string, requirementContract?: import('./features/company').RequirementContract, ): Promise { const ai = new AIService(); // dispatcher 의 매 phase 이벤트를 받아 (1) webview 송신 (2) plan-ready/report-done // summary 캐시 기록 (다음 turn 의 intent classifier 가 follow-up 판정 시 사용) // (3) Pixel Office monitoring hook 으로 분기. mutable finalReport 는 turn 종료 후 // emitter.getFinalReport() 로 회수해 기록 목록에 저장. const emitter = createCompanyTurnEmit({ postWebview: (msg) => { this._view?.webview.postMessage(msg); }, recordSummary: (brief, tail) => this._companyTurn.recordSummary(brief, tail), onPixelOffice: (event) => this.pixelOfficeOnTurnEvent(event), }); // Fresh AbortController per turn — the Stop button routes through // `abortCompanyTurn()` to fire `.abort()`. The dispatcher checks // `signal.aborted` between phases and short-circuits cleanly. const abort = this._companyTurn.startTurn(); try { const deps = buildDispatcherDeps({ context: this._context, ai, signal: abort.signal, onEvent: emitter.emit, executeActionTags: (text: string) => this._agent.executeActionTagsOnText(text), approvalGates: this._approvalGates, pipelineIdOverride, requirementContract, }); // 일반 새 turn vs 재개 turn 분기. 재개 시 _resume.json에서 plan + cursor를 // 복원해 그 다음 stage부터 dispatch가 이어진다. resumeCompanyTurn이 null을 // 돌려주면(파일 없음·이미 완료 등) 사용자에게 알리고 종료. if (resumeTimestamp) { const result = await resumeCompanyTurn(resumeTimestamp, deps); if (!result) { this._postError('재개 가능한 세션 정보를 찾지 못했습니다 (이미 완료되었거나 파일이 손상되었을 수 있습니다).'); } } else { await runCompanyTurn(userPrompt, deps); } } catch (e: any) { logError('company.runTurn: unexpected failure.', { error: e?.message ?? String(e) }); this._postError(`1인 기업 모드 실행 실패: ${e?.message ?? e}`); } finally { this._companyTurn.endTurn(abort); // The webview's send button is locked into the "generating" state // when the user submits; it only unlocks on `streamEnd`. The // normal chat path posts that from inside AgentExecutor, but // the company turn never touches AgentExecutor, so we have to // post it ourselves here — otherwise the input stays disabled // with the red Stop button after the round completes. this._postStreamEnd(); void this._sendReadyStatus(); // turn이 끝났으면(완료든 abort든) resume 가능 세션 목록을 새로 푸시 — // 방금 abort된 세션이 곧장 목록에 떠야 하므로. void this._sendCompanyResumable(); // 완료된 회사 turn을 채팅 기록(기록 목록)에도 한 줄로 남긴다 — 보고서가 // 나온 경우(report-done)에만. abort돼서 보고서가 없으면 기록 목록은 // 건드리지 않고 resume 목록에만 남는다. const finalReport = emitter.getFinalReport(); if (finalReport) { void this._saveCompanyTurnSession(userPrompt, finalReport); } } } /** * Records a completed 1인 기업 turn as a standalone entry in the 기록(Chat * History) list. Company turns run through the dispatcher, not AgentExecutor, * so they never populate `_agent` history and `_saveCurrentSession()` can't * see them — without this they only ever appeared in the resume list. * * Each turn gets its own entry (it doesn't touch `_currentSessionId`): a * dispatch is a discrete unit of work, not a back-and-forth chat thread. */ async _saveCompanyTurnSession(userPrompt: string, report: string) { try { const prompt = (userPrompt || '').trim(); const body = (report || '').trim(); if (!prompt && !body) return; const history: ChatMessage[] = [ { role: 'user', content: prompt || '(요청 내용 없음)' }, { role: 'assistant', content: body || '(보고서가 생성되지 않았습니다.)' }, ]; const sessions = this._sessionStore.getAll(); sessions.unshift({ id: Date.now().toString(), title: buildSessionTitleFromText(prompt, '1인 기업 업무'), timestamp: Date.now(), history, brainProfileId: this._currentSessionBrainId || getActiveBrainProfile().id, negativePrompt: this._currentNegativePrompt, }); // ChatSessionStore.putAll() 가 50 cap 적용 — 여기서는 단순 위임. await this._sessionStore.putAll(sessions); await this._sendSessionList(); } catch (err: any) { logError('Failed to record company turn into chat history.', { error: err?.message ?? String(err) }); } } /** * Webview에 "이어서 진행할 수 있는 세션" 목록을 push. 관리 패널이 열릴 때와 turn이 * 끝날 때마다 호출됨. 빈 목록도 그대로 보내서 UI가 섹션을 자동으로 숨길 수 있게 함. */ async _sendCompanyResumable(): Promise { if (!this._view) return; try { const items = listResumableSessions(this._context); const state = readCompanyState(this._context); this._view.webview.postMessage(buildCompanyResumablePayload(items, state)); } catch (e: any) { logError('company._sendCompanyResumable failed.', { error: e?.message ?? String(e) }); } } /** 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}`); } } // Architecture watcher 메서드는 ArchitectureWatchManager (`this._archWatch`) 로 이관. _getActiveChronicleProject(): ProjectProfile | null { const projects = this._chronicleStore.getAll(); if (projects.length === 0) return null; const activeId = this._context.globalState.get(SidebarChatProvider.activeChronicleProjectStateKey, ''); return projects.find(project => project.projectId === activeId) || projects[0]; } async _sendChronicleProjects() { if (!this._view) return; const projects = this._chronicleStore.getAll(); const active = this._getActiveChronicleProject(); this._view.webview.postMessage(buildChronicleProjectsPayload(projects, active?.projectId || '')); } async _createChronicleProject() { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; const defaultName = workspaceRoot ? path.basename(workspaceRoot) : 'New Project'; const projectName = await vscode.window.showInputBox({ prompt: 'Project name for Chronicle records', value: defaultName, validateInput: (value) => value.trim() ? null : 'Project name is required.' }); if (!projectName) return; const description = await vscode.window.showInputBox({ prompt: 'One-line project description', value: 'Project planning, decisions, development logs, and bug records.' }); if (description === undefined) return; const projectRoot = await vscode.window.showInputBox({ prompt: 'Project root path', value: workspaceRoot, validateInput: (value) => value.trim() ? null : 'Project root is required.' }); if (!projectRoot) return; const defaultRecordRoot = path.join(projectRoot.trim(), 'docs', 'records', projectName.trim()); const recordRoot = await vscode.window.showInputBox({ prompt: 'Markdown record folder path', value: defaultRecordRoot, validateInput: (value) => value.trim() ? null : 'Record folder path is required.' }); if (!recordRoot) return; const corePurpose = await vscode.window.showInputBox({ prompt: 'Core project purpose or guardrail', value: 'Keep project knowledge traceable through Markdown records.' }); if (corePurpose === undefined) return; const detailChoice = await vscode.window.showQuickPick([ { label: 'standard', description: 'Request, question intent, decisions, planning, development, and bugs' }, { label: 'simple', description: 'Request summary, decisions, and implementation result' }, { label: 'detailed', description: 'Standard records plus alternatives, lessons, and retrospectives' } ], { placeHolder: 'Chronicle record detail level' }); if (!detailChoice) return; const now = new Date().toISOString(); const projects = this._chronicleStore.getAll(); const idBase = slugify(projectName.trim()); let projectId = idBase; let suffix = 2; while (projects.some(project => project.projectId === projectId)) { projectId = `${idBase}-${suffix++}`; } const profile: ProjectProfile = { projectId, projectName: projectName.trim(), projectRoot: projectRoot.trim(), recordRoot: recordRoot.trim(), description: description.trim(), corePurpose: corePurpose.trim(), targetUsers: ['Project developer'], avoidDirections: ['Do not mix records across projects.', 'Do not tightly couple records to core agent execution.'], detailLevel: detailChoice.label as ProjectProfile['detailLevel'], createdAt: now, updatedAt: now }; this._chronicle.ensureProject(profile); const nextProjects = [...projects.filter(project => project.projectId !== profile.projectId), profile]; await this._chronicleStore.putAll(nextProjects); await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, profile.projectId); await this._sendChronicleProjects(); this.injectSystemMessage(`**[Designer Project Created]** ${profile.projectName}\n\`${profile.recordRoot}\``); } async _setActiveChronicleProject(projectId: string) { if (!projectId || projectId === 'new') { await this._createChronicleProject(); return; } const target = this._chronicleStore.getAll().find(project => project.projectId === projectId); if (!target) return; await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, target.projectId); await this._sendChronicleProjects(); await this._sendChronicleRecords(); this.injectSystemMessage(`**[Designer Project Selected]** ${target.projectName}\n\`${target.recordRoot}\``); } async _openChronicleFolder() { const profile = this._getActiveChronicleProject(); if (!profile) { vscode.window.showWarningMessage('No Chronicle project is selected.'); return; } try { this._chronicle.ensureProject(profile); await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(profile.recordRoot)); } catch (err: any) { vscode.window.showErrorMessage(`Failed to open Chronicle folder: ${err.message}`); } } async _sendChronicleRecords() { if (!this._view) return; const profile = this._getActiveChronicleProject(); if (!profile) { this._view.webview.postMessage(buildChronicleRecordsPayload([])); return; } try { const records = this._chronicle.listRecords(profile); this._view.webview.postMessage(buildChronicleRecordsPayload(records)); } catch (err: any) { vscode.window.showErrorMessage(`Failed to list Chronicle records: ${err.message}`); } } async _openChronicleRecord(recordPath: string) { const profile = this._getActiveChronicleProject(); if (!profile || !recordPath) { vscode.window.showWarningMessage('Select a Chronicle record first.'); return; } const root = path.resolve(profile.recordRoot); const target = path.resolve(recordPath); if (!target.startsWith(`${root}${path.sep}`) || path.extname(target) !== '.md') { vscode.window.showErrorMessage('Selected Chronicle record path is not valid.'); return; } if (!fs.existsSync(target)) { vscode.window.showErrorMessage('Selected Chronicle record no longer exists.'); await this._sendChronicleRecords(); return; } await openInEditorGroup(target); } /** * Chronicle write 메서드 6개의 공통 entry guard. 활성 project 없으면 warning * + null 반환 → 호출자가 early return. 6 곳 × 4 줄 boilerplate dedup. */ private _ensureActiveChronicleProfile(): ProjectProfile | null { const profile = this._getActiveChronicleProject(); if (!profile) { vscode.window.showWarningMessage('Select or create a Designer project first.'); return null; } return profile; } /** * Chronicle write 메서드 6개의 공통 post-write 시퀀스: * 1) 타임라인에 record 생성 이벤트 한 줄 append * 2) toast 알림 + webview 의 records list 갱신 * 3) chat 에 system message 로 저장 경로 노출 * `label` 은 한 단어 ('planning', 'discussion', 'decision', 'development', * 'bug', 'retrospective') — 인간 친화 toast/badge 문구는 `display` 가 결정. */ private async _commitChronicleWrite( profile: ProjectProfile, label: string, display: string, result: { relativePath: string; filePath: string }, createdAt: string, ): Promise { this._chronicle.appendTimeline(profile, [`${display} record created: ${result.relativePath}`], createdAt); vscode.window.showInformationMessage(`Chronicle ${label} saved: ${result.relativePath}`); await this._sendChronicleRecords(); this.injectSystemMessage(`**[Chronicle ${display} Saved]** \`${result.filePath}\``); } /** * 현재 chat history 의 *가장 최근* user / assistant 메시지 한 쌍. 4 개 write * 메서드가 같은 패턴으로 호출하던 걸 dedup. */ private _latestUserAssistantPair(): { latestUser: string; latestAssistant: string } { const history = this._agent.getHistory(); const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || ''; const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || ''; return { latestUser, latestAssistant }; } async _writeChroniclePlanningFromCurrentChat() { const profile = this._ensureActiveChronicleProfile(); if (!profile) return; const { latestUser, latestAssistant } = this._latestUserAssistantPair(); const featureName = await vscode.window.showInputBox({ prompt: 'Feature name for the planning document', value: summarizeForTitle(latestUser || 'Project Chronicle Feature') }); if (!featureName) return; try { const createdAt = new Date().toISOString(); const result = this._chronicle.writePlanning(profile, { featureName: featureName.trim(), purpose: 'Record the reason, scope, direction, and success criteria before implementation.', background: summarizeTextForWiki(latestAssistant || latestUser), userIntent: summarizeTextForWiki(latestUser), sourceRequest: latestUser || 'No user request captured in the current chat.', scope: [ 'Create a project-specific planning record.', 'Capture user intent and implementation direction.', 'Keep the record independent from chat execution internals.' ], outOfScope: [ 'Full automatic transcript capture.', 'External database integration.', 'Git automation.' ], developmentDirection: 'Use Project Chronicle as a low-dependency Markdown record layer.', dependencyStrategy: 'Use local filesystem writes through the independent projectChronicle module.', expectedValue: 'Future work can understand why this feature exists and what decisions shaped it.', successCriteria: [ 'The planning document is created under the selected project record folder.', 'The document includes user intent, scope, out-of-scope items, and success criteria.' ], developerInstruction: 'Use this document as the implementation guardrail for the next development step.', createdAt }); await this._commitChronicleWrite(profile, 'planning', 'Planning', result, createdAt); } catch (err: any) { vscode.window.showErrorMessage(`Failed to write planning record: ${err.message}`); } } async _writeChronicleDiscussionFromCurrentChat() { const profile = this._ensureActiveChronicleProfile(); if (!profile) return; const { latestUser, latestAssistant } = this._latestUserAssistantPair(); const title = await vscode.window.showInputBox({ prompt: 'Discussion title', value: summarizeForTitle(latestUser || 'Project Discussion') }); if (!title) return; const question = await vscode.window.showInputBox({ prompt: 'AI question to record (optional)', value: '' }); if (question === undefined) return; let questions: any[] = []; if (question.trim()) { const reason = await vscode.window.showInputBox({ prompt: 'Why was this question asked?', value: 'To avoid writing records to the wrong project or making an unclear design decision.' }); if (reason === undefined) return; const impact = await vscode.window.showInputBox({ prompt: 'How does this question affect the decision?', value: 'It determines the correct project context, scope, or implementation path.' }); if (impact === undefined) return; questions = [{ question: question.trim(), reason: reason.trim(), expectedInformation: 'Information needed to clarify project context, scope, or decision direction.', impactOnDecision: impact.trim() }]; } try { const createdAt = new Date().toISOString(); const result = this._chronicle.writeDiscussion(profile, { title: title.trim(), userRequest: summarizeTextForWiki(latestUser || 'No user request captured in the current chat.'), interpretedIntent: 'Capture the discussion as a reusable project knowledge record instead of leaving it only in chat history.', questions, discussions: [ summarizeTextForWiki(latestAssistant || latestUser || 'No discussion detail captured yet.') ], decisions: [], createdAt }); await this._commitChronicleWrite(profile, 'discussion', 'Discussion', result, createdAt); } catch (err: any) { vscode.window.showErrorMessage(`Failed to write discussion record: ${err.message}`); } } async _writeChronicleDecisionFromInput() { const profile = this._ensureActiveChronicleProfile(); if (!profile) return; const title = await vscode.window.showInputBox({ prompt: 'Decision title', value: 'Use independent Markdown record module' }); if (!title) return; const decision = await vscode.window.showInputBox({ prompt: 'Decision', value: 'Implement this behavior as an independent Project Chronicle module.' }); if (decision === undefined) return; const reason = await vscode.window.showInputBox({ prompt: 'Decision reason', value: 'To reduce coupling and keep project records portable.' }); if (reason === undefined) return; const alternatives = await vscode.window.showInputBox({ prompt: 'Rejected alternatives (comma-separated)', value: 'Integrate with Second Brain, integrate directly into Agent execution' }); if (alternatives === undefined) return; try { const createdAt = new Date().toISOString(); const adrNumber = this._chronicle.nextAdrNumber(profile); const result = this._chronicle.writeDecision(profile, { title: title.trim(), status: 'accepted', context: 'A project record needs to capture not only what changed, but why the direction was chosen.', decision: decision.trim(), reason: reason.trim(), alternatives: alternatives.split(',').map(item => item.trim()).filter(Boolean), consequences: [ 'Records can evolve independently from chat and agent internals.', 'Future automation can emit chronicle events without owning the core execution path.' ], createdAt }, adrNumber); await this._commitChronicleWrite(profile, 'decision', 'Decision', result, createdAt); } catch (err: any) { vscode.window.showErrorMessage(`Failed to write decision record: ${err.message}`); } } async _writeChronicleDevelopmentFromCurrentChat() { const profile = this._ensureActiveChronicleProfile(); if (!profile) return; const { latestUser, latestAssistant } = this._latestUserAssistantPair(); const featureName = await vscode.window.showInputBox({ prompt: 'Feature name for the development log', value: summarizeForTitle(latestUser || 'Implementation Log') }); if (!featureName) return; try { const createdAt = new Date().toISOString(); const result = this._chronicle.writeDevelopmentLog(profile, { featureName: featureName.trim(), purpose: 'Record the actual implementation outcome for later maintenance.', implementationSummary: summarizeTextForWiki(latestAssistant || 'Implementation summary was not captured from chat yet.'), architecture: 'Project Chronicle records are written through an independent Markdown module.', changedFiles: ['Capture exact changed files after verification.'], dependencyNotes: 'Keep dependencies limited to TypeScript, Node fs/path, and VS Code extension APIs.', bugs: [], lessons: [ 'Write implementation notes as soon as a stable development step finishes.', 'Keep generated records project-specific.' ], createdAt }); await this._commitChronicleWrite(profile, 'development log', 'Development', result, createdAt); } catch (err: any) { vscode.window.showErrorMessage(`Failed to write development record: ${err.message}`); } } async _writeChronicleBugFromInput() { const profile = this._ensureActiveChronicleProfile(); if (!profile) return; const title = await vscode.window.showInputBox({ prompt: 'Bug title', value: 'record-generation-issue' }); if (!title) return; const symptom = await vscode.window.showInputBox({ prompt: 'Bug symptom', value: 'Describe what failed or looked wrong.' }); if (symptom === undefined) return; const cause = await vscode.window.showInputBox({ prompt: 'Bug cause', value: 'Cause is not confirmed yet.' }); if (cause === undefined) return; const fix = await vscode.window.showInputBox({ prompt: 'Fix', value: 'Describe the fix or mitigation.' }); if (fix === undefined) return; try { const createdAt = new Date().toISOString(); const bugNumber = this._chronicle.nextBugNumber(profile); const result = this._chronicle.writeBug(profile, { title: title.trim(), symptom: symptom.trim(), cause: cause.trim(), fix: fix.trim(), prevention: 'Validate project selection, record path, and write permissions before generating files.', createdAt }, bugNumber); await this._commitChronicleWrite(profile, 'bug record', 'Bug', result, createdAt); } catch (err: any) { vscode.window.showErrorMessage(`Failed to write bug record: ${err.message}`); } } async _writeChronicleRetrospectiveFromInput() { const profile = this._ensureActiveChronicleProfile(); if (!profile) return; const title = await vscode.window.showInputBox({ prompt: 'Retrospective title', value: 'Project Chronicle Guard iteration' }); if (!title) return; const summary = await vscode.window.showInputBox({ prompt: 'Work summary', value: 'Completed an incremental development step and recorded the outcome.' }); if (summary === undefined) return; const wentWell = await vscode.window.showInputBox({ prompt: 'What went well? (comma-separated)', value: 'Kept the feature independent, Generated Markdown records, Preserved project context' }); if (wentWell === undefined) return; const toImprove = await vscode.window.showInputBox({ prompt: 'What should improve? (comma-separated)', value: 'More automatic question intent capture, Richer record editing UI' }); if (toImprove === undefined) return; const nextActions = await vscode.window.showInputBox({ prompt: 'Next actions (comma-separated)', value: 'Add tests, Improve Designer UI, Add event-based record capture' }); if (nextActions === undefined) return; try { const createdAt = new Date().toISOString(); const result = this._chronicle.writeRetrospective(profile, { title: title.trim(), summary: summary.trim(), wentWell: wentWell.split(',').map(item => item.trim()).filter(Boolean), toImprove: toImprove.split(',').map(item => item.trim()).filter(Boolean), nextActions: nextActions.split(',').map(item => item.trim()).filter(Boolean), createdAt }); await this._commitChronicleWrite(profile, 'retrospective', 'Retrospective', result, createdAt); } catch (err: any) { vscode.window.showErrorMessage(`Failed to write retrospective record: ${err.message}`); } } /** * v2.2.70 — 도구 드롭다운의 "자동 기록" 토글에서 호출. config 를 즉시 갱신하고 webview 에 * 새 상태를 푸시. globalState 갱신이 아닌 vscode 설정 갱신이므로 다음 세션까지 영구 유지. */ async _setChronicleAutoRecord(enabled: boolean): Promise { try { await vscode.workspace.getConfiguration('g1nation').update( 'chronicleAutoRecord', !!enabled, vscode.ConfigurationTarget.Global ); logInfo(`[chronicleAutoRecord] toggled → ${enabled ? 'ON' : 'OFF'}`); } catch (e: any) { logError('[chronicleAutoRecord] update failed', { error: e?.message || String(e) }); } await this._sendChronicleAutoRecordStatus(); } /** Send current 자동 기록 enabled flag to the webview so the Tools menu can render the toggle state. */ async _sendChronicleAutoRecordStatus(): Promise { if (!this._view) return; this._view.webview.postMessage({ type: 'chronicleAutoRecordStatus', value: { enabled: getConfig().chronicleAutoRecord !== false } }); } async _autoWriteChronicleAfterPrompt() { // v2.2.70 — 자동 기록 OFF (g1nation.chronicleAutoRecord=false) 면 즉시 종료. // 수동 기록 (도구 메뉴, /wiki 명령 등) 은 영향받지 않는다. if (getConfig().chronicleAutoRecord === false) { return; } const history = this._agent.getHistory(); const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || ''; const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || ''; const profile = this._getChronicleProjectForConversation(latestUser) || this._getActiveChronicleProject(); if (!profile) return; const recordType = inferAutoChronicleRecordType(latestUser, latestAssistant); if (!recordType) return; const signature = [ profile.projectId, recordType, summarizeTextForWiki(latestUser).slice(0, 240), summarizeTextForWiki(latestAssistant).slice(0, 240) ].join('|'); const lastSignature = this._context.globalState.get(SidebarChatProvider.lastAutoChronicleSignatureStateKey, ''); if (signature === lastSignature) return; try { this._chronicle.ensureProject(profile); const createdAt = new Date().toISOString(); const title = summarizeForTitle(latestUser || latestAssistant || 'Project Chronicle Auto Record'); const summary = summarizeTextForWiki(latestAssistant || latestUser); let result; if (recordType === 'bug') { const bugNumber = this._chronicle.nextBugNumber(profile); result = this._chronicle.writeBug(profile, buildAutoBugRecord(title, summary, latestUser, createdAt), bugNumber); } else if (recordType === 'planning') { result = this._chronicle.writePlanning(profile, buildAutoPlanningRecord(title, summary, latestUser, createdAt)); } else if (recordType === 'decision') { const adrNumber = this._chronicle.nextAdrNumber(profile); result = this._chronicle.writeDecision(profile, buildAutoDecisionRecord(title, summary, latestUser, createdAt), adrNumber); } else if (recordType === 'development') { result = this._chronicle.writeDevelopmentLog(profile, buildAutoDevelopmentRecord(title, summary, latestAssistant, createdAt)); } else { result = this._chronicle.writeDiscussion(profile, buildAutoDiscussionRecord(title, summary, latestUser, createdAt)); } this._chronicle.appendTimeline(profile, [`Auto ${recordType} record created: ${result.relativePath}`], createdAt); await this._context.globalState.update(SidebarChatProvider.lastAutoChronicleSignatureStateKey, signature); await this._sendChronicleRecords(); vscode.window.setStatusBarMessage(`Astra: Chronicle auto-saved ${recordType}`, 3500); } catch (err: any) { logError('Automatic Chronicle record write failed.', { error: err?.message || String(err), recordType }); } } _getChronicleProjectForConversation(text: string): ProjectProfile | null { const projectPath = extractLocalProjectPath(text); if (!projectPath) return null; const projects = this._chronicleStore.getAll(); const resolvedPath = path.resolve(projectPath); const existing = projects.find(project => { const root = project.projectRoot ? path.resolve(project.projectRoot) : ''; const recordRoot = path.resolve(project.recordRoot); return root === resolvedPath || recordRoot.startsWith(`${resolvedPath}${path.sep}`); }); if (existing) return existing; const projectName = path.basename(resolvedPath) || 'Current Project'; const now = new Date().toISOString(); return { projectId: slugify(projectName), projectName, projectRoot: resolvedPath, recordRoot: path.join(resolvedPath, 'docs', 'records', projectName), description: 'Auto-detected from the local project path in the conversation.', corePurpose: 'Capture project direction, architecture discussion, decisions, and development notes as Markdown.', targetUsers: ['Project developer'], avoidDirections: ['Do not mix records across projects.'], detailLevel: 'standard', createdAt: now, updatedAt: now }; } // _extractLocalProjectPath / _inferAutoChronicleRecordType / _extractChangedFilesFromText // → `src/sidebar/builders/autoChronicleClassifier.ts` async _writeChronicleRecord(recordType: string) { switch (recordType) { case 'planning': await this._writeChroniclePlanningFromCurrentChat(); break; case 'discussion': await this._writeChronicleDiscussionFromCurrentChat(); break; case 'decision': await this._writeChronicleDecisionFromInput(); break; case 'development': await this._writeChronicleDevelopmentFromCurrentChat(); break; case 'bug': await this._writeChronicleBugFromInput(); break; case 'retrospective': await this._writeChronicleRetrospectiveFromInput(); break; default: vscode.window.showWarningMessage('Select a Chronicle record type first.'); } } // _getAgentsDir → `this._agentSkillStore.getDir()` async _sendAgentsList() { if (!this._view) return; const agents = this._agentSkillStore.list(); const lastPath = this._agentSkillStore.getLastSelectedPath(); this._view.webview.postMessage({ type: 'agentsList', value: agents, selected: lastPath }); void this._sendReadyStatus(); } async _handleProactiveSuggestion(context: string) { if (!this._view) return; this._streamSystemNotice(`\n\n> [!TIP]\n> ${proactiveSuggestionFor(context)}\n`); } async _createAgent() { const name = await vscode.window.showInputBox({ prompt: 'Name of the new Agent (e.g., frontend_expert)', placeHolder: 'Agent name...' }); if (!name) return; const filePath = this._agentSkillStore.createNew(name); if (!filePath) { vscode.window.showErrorMessage('Agent directory could not be determined.'); return; } await openInEditorGroup(filePath); await this._sendAgentsList(); } async _sendAgentContent(agentPath: string) { if (!this._view || !agentPath || agentPath === 'none') return; const content = this._agentSkillStore.readContent(agentPath); if (content === null) return; const negativePrompt = this._agentSkillStore.getNegativePrompt(agentPath); this._view.webview.postMessage({ type: 'agentContent', value: content, negativePrompt }); await this._agentSkillStore.setLastSelectedPath(agentPath); } async _updateAgent(agentPath: string, content: string, negativePrompt?: string) { if (!agentPath || agentPath === 'none') return; try { this._agentSkillStore.writeContent(agentPath, content); if (negativePrompt !== undefined) { await this._agentSkillStore.setNegativePrompt(agentPath, negativePrompt); } vscode.window.showInformationMessage('Agent skill updated successfully.'); } catch (err: any) { vscode.window.showErrorMessage(`Failed to update agent: ${err.message}`); } } async _deleteAgent(agentPath: string) { if (!agentPath || agentPath === 'none') return; try { // delete() 가 invalid/missing/deleted 를 구분하지만, missing 케이스는 // confirm 없이 lastSelected 정리만 해야 하므로 분기를 provider 가 유지. const agentsDir = path.resolve(this._agentSkillStore.getDir()); const targetPath = path.resolve(agentPath); if (!targetPath.startsWith(`${agentsDir}${path.sep}`) || path.extname(targetPath) !== '.md') { vscode.window.showErrorMessage('Selected agent skill path is not valid.'); return; } if (!fs.existsSync(targetPath)) { await this._agentSkillStore.setLastSelectedPath('none'); await this._sendAgentsList(); return; } const confirm = await vscode.window.showWarningMessage( `Delete agent skill "${path.basename(targetPath, '.md')}"?`, { modal: true }, 'Delete Skill' ); if (confirm !== 'Delete Skill') return; this._agentSkillStore.delete(targetPath); await this._agentSkillStore.setLastSelectedPath('none'); await this._sendAgentsList(); this._view?.webview.postMessage({ type: 'agentDeleted' }); vscode.window.showInformationMessage('Agent skill deleted successfully.'); } catch (err: any) { vscode.window.showErrorMessage(`Failed to delete agent: ${err.message}`); } } async _handlePrompt(data: any) { if (!this._view) return; const { value, model, files, agentFile, negativePrompt, designerGuard, secondBrainTrace, secondBrainTraceDebug, brainProfileId } = data; this._currentNegativePrompt = negativePrompt || ''; const selectedBrainId = typeof brainProfileId === 'string' && brainProfileId && brainProfileId !== 'new' ? brainProfileId : getActiveBrainProfile().id; this._currentSessionBrainId = selectedBrainId; // Agent skill 본문 + per-agent model override 해석. notifications 는 placeholder // 경고/모델 override 안내를 webview 가 적절한 메시지로 띄울 수 있게 따로 반환. const skill = resolveAgentSkill(agentFile, typeof model === 'string' ? model : ''); const agentSkillContext = skill.agentSkillContext; const effectiveModel = skill.effectiveModel; for (const note of skill.notifications) { if (note.type === 'placeholder') { this._view?.webview.postMessage({ type: 'lmStudioError', value: '선택한 에이전트에 내용이 없습니다 — 에이전트 프롬프트를 작성한 뒤 다시 시도하세요. (이번 응답은 에이전트 없이 처리합니다)', }); } else if (note.type === 'modelOverride') { this._view?.webview.postMessage({ type: 'agentModelOverride', value: { agent: note.agent, model: note.model }, }); } } const designerContext = designerGuard !== false ? this._buildDesignerGuardContext(typeof value === 'string' ? value : null) : 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) }); } } // Re-resolve the active subproject from the currently-focused editor. // Without this, switching between subprojects (e.g. ConnectAI → // Datacollector) inside one VS Code window keeps loading the previous // subproject's architecture into the prompt. try { await this._ensureActiveProjectForWorkspace(); } catch (e: any) { logError('architecture: workspace resync failed.', { error: e?.message ?? String(e) }); } const projectArchitectureContext = this._buildProjectArchitectureContext(); // 첨부 파일 분류/추출 — PDF text+vision fallback, text decode, image passthrough. let processedPrompt = value || ''; let imageFiles: any[] | undefined = undefined; const attachments = await processAttachments(files); if (attachments.textContents.length > 0) { processedPrompt = `${processedPrompt}\n\n--- 첨부 파일 내용 ---${attachments.textContents.join('\n')}`; } if (attachments.imageFiles.length > 0) { imageFiles = attachments.imageFiles; } try { await this._agent.handlePrompt(processedPrompt, effectiveModel || model, { visionContent: imageFiles, agentSkillContext, agentSkillFile: typeof agentFile === 'string' ? agentFile : undefined, negativePrompt, designerContext, projectArchitectureContext: projectArchitectureContext || undefined, secondBrainTraceEnabled: secondBrainTrace !== false, secondBrainTraceDebug: !!secondBrainTraceDebug, brainProfileId: selectedBrainId }); } catch (error: any) { logError('Prompt handling failed in sidebar provider.', { error: error?.message || String(error), promptPreview: summarizeText(value || '', 200) }); this._postError(error.message); } finally { void this._sendReadyStatus(); } } _buildDesignerGuardContext(prompt: string | null = null): string { const suppressTemplate = isThinFollowUp(prompt, this._agent?.getHistory() ?? []); return buildProjectChronicleGuardContext(this._getActiveChronicleProject(), { suppressTemplate }); } async _sendModels(force: boolean = false) { if (!this._view) return; if (!this._modelDiscovery.tryEnter()) { logInfo('Model discovery already in progress, skipping.'); return; } try { const config = getConfig(); const url = config.ollamaUrl; let defaultModel = config.defaultModel; let models: string[] = []; let online = false; const cached = this._modelDiscovery.getCached(url, force); if (cached) { models = cached.models; online = cached.online; this._view.webview.postMessage({ type: 'engineStatus', value: { online, url } }); } else { const engine = resolveEngine(url); const modelsUrl = buildApiUrl(url, engine, 'models'); const result = await this._modelDiscovery.discoverFromEngine({ url, engine, modelsUrl, lmStudio: this._lmStudio, force, }); models = result.models; online = result.online; this._modelDiscovery.setCached(url, models, online); this._view.webview.postMessage({ type: 'engineStatus', value: { online, url } }); } if (models.length === 0) { models = defaultModel ? [defaultModel] : []; } const baseModel = defaultModel?.replace(/:\d+$/, ''); if (baseModel && baseModel !== defaultModel && models.includes(baseModel)) { defaultModel = baseModel; await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global); } if (models.length > 0 && !defaultModel) { // [State Persistence Fix v2] defaultModel이 완전히 비어있을 때만 첫 번째 모델로 설정 defaultModel = models[0]; await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global); } else if (models.length > 0 && defaultModel && !models.includes(defaultModel)) { // [State Persistence Fix v2] 저장된 모델이 로컬 엔진 목록에 없는 경우: // 강제 리셋하지 않고, 저장된 모델을 목록 선두에 추가하여 사용자 선택을 보존 logInfo('Saved model not in local engine list. Preserving user selection.', { saved: defaultModel, localModels: models.slice(0, 3) }); models.unshift(defaultModel); } const defaultIdx = models.indexOf(defaultModel); if (defaultIdx > 0) { models.splice(defaultIdx, 1); models.unshift(defaultModel); } let loadedModels: string[] = []; if (resolveEngine(url) === 'lmstudio' && this._lmStudio) { try { loadedModels = await this._lmStudio.loadedModels(); } catch (e) { logInfo('LM Studio loadedModels probe failed (non-fatal).', { error: (e as any)?.message ?? String(e) }); } } // Cloud provider 모델 합치기 — 활성화된 provider 의 모델들이 dropdown 끝에 추가됨. // 각 모델 id 는 'openrouter:...' 등 prefix 포함이라 사용자가 선택 시 agent 가 cloud 로 라우팅. try { const { listAllCloudModels } = await import('./features/providers'); const cloudModels = await listAllCloudModels(this._context); for (const m of cloudModels) { if (!models.includes(m.id)) models.push(m.id); } } catch (e) { logInfo('Cloud model list failed (non-fatal).', { error: (e as any)?.message ?? String(e) }); } this._view.webview.postMessage({ type: 'modelsList', value: { models, selected: defaultModel, loadedModels } }); } catch (err) { logError('Model list update failed.', err); } finally { this._modelDiscovery.release(); } void this._sendReadyStatus(); } static _htmlTemplateCache: string | undefined; _getHtml(webview: vscode.Webview): string { if (!SidebarChatProvider._htmlTemplateCache) { const tplPath = path.join(this._extensionUri.fsPath, 'media', 'sidebar.html'); SidebarChatProvider._htmlTemplateCache = fs.readFileSync(tplPath, 'utf8'); } const mediaRoot = vscode.Uri.joinPath(this._extensionUri, 'media'); const stylesUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'sidebar.css')).toString(); const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'sidebar.js')).toString(); // VS Code의 outer webview iframe이 codicon.ttf를 data:font/ttf 로 inject한다. // 기본 CSP는 font-src 'self' https://*.vscode-cdn.net 라 data: 가 빠져 있어 // DevTools에 violation 경고가 매번 찍힘. 우리가 명시적 CSP를 박아 data: 를 // 허용해 주면 호스트 iframe도 같은 CSP를 상속하면서 경고가 사라진다. const csp = [ `default-src 'none'`, `img-src ${webview.cspSource} https: data:`, `style-src ${webview.cspSource} 'unsafe-inline'`, `script-src ${webview.cspSource} https://cdn.jsdelivr.net 'unsafe-inline'`, `font-src ${webview.cspSource} https: data:`, `connect-src ${webview.cspSource} https:`, ].join('; '); return SidebarChatProvider._htmlTemplateCache .replace('__CSP__', csp) .replace('__STYLES_URI__', stylesUri) .replace('__SCRIPT_URI__', scriptUri); } } /** * Adapter that makes a {@link vscode.WebviewPanel} quack like a * {@link vscode.WebviewView}, so providers written against the view API can * mount inside an editor column without their internals knowing the difference. * * `onDidChangeVisibility` is synthesized from `onDidChangeViewState` — panels * fire that event for both visibility *and* column moves, but the listener * here only re-fires when the visible flag actually toggles. */ export function wrapPanelAsView(panel: vscode.WebviewPanel): vscode.WebviewView { const visibilityEmitter = new vscode.EventEmitter(); let _lastVisible = panel.visible; panel.onDidChangeViewState(() => { if (panel.visible !== _lastVisible) { _lastVisible = panel.visible; visibilityEmitter.fire(); } }); panel.onDidDispose(() => visibilityEmitter.dispose()); const adapter: any = { viewType: panel.viewType, webview: panel.webview, get visible() { return panel.visible; }, get title() { return panel.title; }, set title(v: string | undefined) { panel.title = v ?? ''; }, description: undefined as string | undefined, badge: undefined as vscode.ViewBadge | undefined, onDidChangeVisibility: visibilityEmitter.event, onDidDispose: panel.onDidDispose, show(preserveFocus?: boolean) { panel.reveal(panel.viewColumn ?? vscode.ViewColumn.Three, preserveFocus); }, }; return adapter as vscode.WebviewView; } // Astra Office full-panel HTML 은 src/features/astraOffice/ 로 이전됨 (refactor #1). // _buildPixelOfficeHtml() 가 renderAstraOfficePanelHtml() 을 직접 호출한다.