Files
connectai/src/sidebarProvider.ts
T
koriweb d39eb27c90 feat(retrieval): 청킹/평가 하니스 + 검색 인덱스 개선
- src/retrieval/chunker.ts: 문서 청킹 로직 추가
- src/retrieval/evalHarness.ts + src/extension/evalCommands.ts: 검색 품질 평가 하니스
- brainIndex.ts / retrieval/index.ts / memoryContext.ts: 인덱싱·컨텍스트 빌더 개선
- config.ts / extension.ts / sidebarProvider.ts / package.json 갱신
- ADR-0030~0032 및 개발 기록, .astra 런타임 상태 동기화

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:27:10 +09:00

3206 lines
150 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string[]>;
/**
* 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<string[]>;
}
// 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<import('./features/company').AgentWorkState>, 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<boolean> {
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<string>(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<void> {
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<any[]>('brainProfiles', []) || [];
const landed = written.some((p) => p && p.id === nextActiveId);
if (!landed) {
const inspected = vscode.workspace.getConfiguration('g1nation').inspect<any[]>('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<any[]>('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 === <parent>` 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<void> {
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<KnownProject>((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<string | null> {
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<string | null> {
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<void> {
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<void> {
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<void> {
// `_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<ProjectProfile | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// 사용자에게 "왜 이게 가벼운 응답으로 갔는지" 보여주는 한 줄 라벨.
// 잘못 분류된 거라면 사용자가 즉시 인지하고 다시 말할 수 있어야 한다.
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string>(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<void> {
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<void> {
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<void> {
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<string>(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<void>();
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() 을 직접 호출한다.