Files
connectai/src/sidebarProvider.ts
T
koriwebandClaude Fable 5 47b3b9f93a v2.2.299~307: 아키텍처 수렴 + 채팅 정리 + 벤치마킹 강화 + Claude 구독 엔진
- v2.2.299 아키텍처 수렴: coreChat 통일(엔진 휴리스틱 3벌 제거), 기업 모드
  검색 오케스트레이터 승격, lib/execUtil(실행 래퍼 6곳·Python 탐지 3벌 단일화),
  lib/kstSchedule(워처 4개 nowInKst 통합), estimateTokens 통합, 설정 접근 규칙 명문화
- v2.2.300 채팅 화면 정리: LiveReasoningFilter(스트리밍 중 <think>/Harmony 추론
  토큰 단위 차단), 확신도·검토요청 footer 기본 숨김(계산·Reflection 은 유지)
- v2.2.301 문맥·의도 이해: [답변 전 이해 원칙] 상시 주입, 워크플로우 의도 브리핑,
  Report QA 루프(규칙 레지스트리+실측치+회귀 게이트, 블로그_v3 개념 이식)
- v2.2.302 /benchmark 비즈니스 렌즈(가격·수익·운영)+빌드 프롬프트 모드+QA 연계
- v2.2.303 handoff 모드(측정치 무손실 인수인계 문서)+/claude(Claude Code 터미널 위임)
- v2.2.304 이식성: 지식 경로 두뇌-상대 규약(pickWikiDir 상대 해석), 이사 체크리스트
- v2.2.305 Claude 구독 엔진: claude: 프로바이더(CLI 위임, 모델 드롭다운 자동 노출,
  coreChat 지원 — 워크플로우·QA도 구독 모델 가능)
- v2.2.306 Tone Guard: AI 상투어 금지 레지스트리(상담사 화법 실사례 8종+대조 예시)
- v2.2.307 /benchmark 레이아웃 골격(sectionRoles 결정론 분석, 롤링 배너 즉답),
  파트별 실패 격리, 합성 타임아웃 120→300초

검증: tsc 무오류 + jest 888 통과 + esbuild 정상

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 21:02:15 +09:00

2942 lines
138 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,
invalidateBrainFilesCache,
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 { handleBridgeLauncherMessage } from './features/datacollect/bridgeLauncher';
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 { ChatSessionStore, ChatSession } from './sidebar/managers/chatSessionStore';
import { ModelDiscovery } from './sidebar/managers/modelDiscovery';
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);
// NotebookLM 로컬 백엔드 실행/종료 버튼 (도구 메뉴) — Research 기능용 편의 런처.
registerSidebarHandler(handleBridgeLauncherMessage);
import { resolveScopeForAgent } from './skills/agentKnowledgeMap';
import { clearBrainTokenIndex } from './retrieval/brainIndex';
import { extractUrls } from './features/web/webFetch';
import { buildUrlContext } from './lib/contextBuilders/urlContext';
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,
gatherEvidenceForQuestions,
selfAnswerQuestions,
saveAlignmentKnowledge,
SELF_RESEARCH_PREFIX,
} from './features/company';
import { AIService } from './core/services';
import { HealthCheckMonitor } from './core/health';
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;
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();
/** Phase B-2에서 chatHandlers가 alignment 진행 여부를 빠르게 확인하는 용도. */
isAlignmentPending(): boolean {
return this._alignment.isPending();
}
/** 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;
/** 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._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,
healthWarnings: HealthCheckMonitor.lastReports,
});
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' });
}
/**
* 두뇌 동기화 — 두 모드:
* 1. 원격 미설정 (profile.secondBrainRepo 비어 있음, 기본): 검색 인덱스/캐시
* 새로고침만. 사용자의 멘탈 모델("동기화 = 내 로컬 두뇌 폴더 인식")과 일치.
* git 을 건드리지 않으므로 인증도 불필요.
* 2. 원격 설정됨: pull(rebase) → commit/push → 인덱스 갱신 (백업/공유 모드).
*
* 배경: 옛 구현은 원격 설정 여부와 무관하게 git push 를 강행해서, 두뇌 폴더가
* 우연히 다른 git 레포 안에 있으면 그 레포의 원격 인증을 요구하는 혼란이 있었다.
*/
public async syncBrain() {
const activeBrain = getActiveBrainProfile();
const brainDir = activeBrain.localBrainPath;
if (!fs.existsSync(brainDir)) {
vscode.window.showErrorMessage("Second Brain directory not found.");
return;
}
// ── 모드 1: 원격 미설정 → 로컬 새로고침만 (git 없음, 인증 불필요) ──
if (!activeBrain.secondBrainRepo || !activeBrain.secondBrainRepo.trim()) {
try {
invalidateBrainFilesCache();
clearBrainTokenIndex(brainDir);
const count = findBrainFiles(brainDir).length;
vscode.window.showInformationMessage(
`두뇌 새로고침 완료 — 문서 ${count}개 인식됨. ` +
`(이 두뇌는 원격 저장소가 설정되어 있지 않아 git 동기화는 건너뜁니다. ` +
`새 문서는 평소에도 자동 인식되므로 이 버튼은 강제 새로고침 용도입니다.)`,
);
} catch (e: any) {
vscode.window.showErrorMessage(`두뇌 새로고침 실패: ${String(e?.message ?? e).slice(0, 150)}`);
}
return;
}
vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: "Astra: Syncing Second Brain...",
cancellable: false
}, async () => {
const { execSync } = require('child_process');
const run = (cmd: string): string =>
execSync(cmd, { cwd: brainDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) as string;
// ── ⓪ upstream 확인 — 없으면 pull/push 없이 로컬 커밋만 (정직 보고) ──
// 사례: 두뇌 폴더에 원격 없는 중첩 .git 이 생겨 있던 환경에서
// "There is no tracking information" 으로 동기화 전체가 죽었다.
// 원격 추적이 없는 두뇌는 로컬 버전 관리만 수행하고 그 사실을 알린다.
let hasUpstream = true;
try {
run('git rev-parse --abbrev-ref --symbolic-full-name "@{u}"');
} catch {
hasUpstream = false;
}
// ── ① 원격 변경 수신 (pull --rebase --autostash) ──
let pulledFiles = 0;
if (hasUpstream) {
try {
run('git fetch');
// upstream 대비 새로 들어올 파일 수 — 사용자에게 "몇 개 받았는지" 보여주기 위해.
try {
const incoming = run('git diff --name-only HEAD "@{u}"').trim();
pulledFiles = incoming ? incoming.split('\n').filter(Boolean).length : 0;
} catch { /* 카운트만 생략 */ }
run('git pull --rebase --autostash');
} catch (err: any) {
const detail = String(err?.stderr || err?.message || err).slice(0, 200);
logError('Brain sync: pull failed.', { brainDir, detail });
vscode.window.showErrorMessage(`두뇌 동기화 실패 (원격 수신 중): ${detail}`);
return;
}
}
// ── ② 로컬 변경 송신 (add → commit → push) ──
// 변경 판정은 *staged* 기준 (`git diff --cached --quiet`) — 두뇌 폴더가
// 더 큰 레포의 하위 폴더일 때 `git status --porcelain` 은 폴더 *밖* 의
// 변경까지 보고해서, staged 가 비어 있는데 commit 을 시도하다
// "no changes added" 로 실패하는 버그가 있었다.
let pushed = false;
let committedLocally = false;
try {
run('git add .');
let hasStaged = false;
try {
run('git diff --cached --quiet'); // exit 0 = staged 없음
} catch {
hasStaged = true; // exit 1 = staged 변경 존재
}
if (hasStaged) {
run('git commit -m "[G1-Sync] Manual knowledge update"');
committedLocally = true;
if (hasUpstream) {
run('git push');
pushed = true;
}
} else if (hasUpstream) {
// 커밋할 변경 없음 — ahead 상태(이전 커밋 미push)면 push 만.
const ahead = run('git rev-list --count "@{u}"..HEAD').trim();
if (ahead && ahead !== '0') {
run('git push');
pushed = true;
}
}
} catch (err: any) {
const detail = String(err?.stderr || err?.message || err).slice(0, 200);
logError('Brain sync: push failed.', { brainDir, detail });
// 인증 부재는 사용자가 직접 해결해야 하는 환경 문제 — git 원문 대신
// 행동 가능한 안내. (확장은 비대화형이라 아이디/토큰을 물어볼 수 없다.)
if (/could not read Username|Authentication failed|terminal prompts disabled|403/i.test(detail)) {
vscode.window.showErrorMessage(
`두뇌 동기화 실패: git 인증 정보가 저장되어 있지 않습니다. ` +
`터미널에서 "cd ${brainDir} && git push" 를 1회 실행해 아이디/토큰을 입력하면 ` +
`키체인에 저장되어 이후 버튼이 정상 작동합니다. (로컬 커밋은 완료된 상태)`,
);
} else {
vscode.window.showErrorMessage(`두뇌 동기화 실패 (송신 중): ${detail}`);
}
return;
}
// ── ③ 검색 인덱스/캐시 무효화 — 새 문서가 즉시 검색에 잡히게 ──
try {
invalidateBrainFilesCache();
clearBrainTokenIndex(brainDir);
} catch { /* 캐시 무효화 실패는 치명적이지 않음 — TTL/mtime 이 결국 따라잡음 */ }
const parts: string[] = [];
if (pulledFiles > 0) parts.push(`원격 문서 ${pulledFiles}개 수신`);
if (pushed) parts.push('로컬 변경 push 완료');
if (committedLocally && !pushed) parts.push('로컬 커밋 완료');
if (!hasUpstream) parts.push('⚠️ 원격 추적 브랜치 없음 — 로컬 버전 관리만 수행됨');
if (parts.length === 0) parts.push('변경 없음 — 이미 최신 상태');
vscode.window.showInformationMessage(`두뇌 동기화 완료: ${parts.join(' · ')} (검색 인덱스 갱신됨)`);
});
}
// _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);
// 모드 전환 직전 일반 채팅 요약 — 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 자체는 동작 */ }
}
// 현재 워크스페이스 아키텍처 컨텍스트 — "이 프로젝트가 뭐냐"류의 재질문 차단.
// 첫 라운드만 (후속 라운드는 contract 에 이미 흡수). 분석기는 작은 모델이므로
// 3,000자로 재절단 (architecture 원본 cap 은 16,000자).
let projectContext: string | undefined;
if (!opts.previousContract) {
try {
const arch = this._buildProjectArchitectureContext();
if (arch) {
projectContext = arch.length > 3000
? arch.slice(0, 3000) + '\n…(이하 생략 — 전체는 architecture.md 참조)'
: arch;
}
} catch { /* architecture 없어도 alignment 는 계속 */ }
// 사용자 프롬프트에 URL 이 있으면 본문도 컨텍스트에 — "그 사이트가
// 뭐냐"는 재질문 차단. 결과는 5분 캐시되어 dispatch 와 중복 비용 없음.
try {
if (cfg.webAutoFetchEnabled !== false) {
const urls = extractUrls(opts.userPrompt, 2);
if (urls.length > 0) {
const webBlocks: string[] = [];
for (const url of urls) {
webBlocks.push(await buildUrlContext(url));
}
const web = webBlocks.join('\n\n');
const webCapped = web.length > 2000 ? web.slice(0, 2000) + '\n…(이하 생략)' : web;
projectContext = projectContext
? `${projectContext}\n\n${webCapped}`
: webCapped;
}
}
} catch { /* URL 수집 실패해도 alignment 는 계속 */ }
}
const analysis = await analyzeIntent(
new AIService(),
{
userOriginalPrompt: opts.userPrompt,
previousAnswers: opts.previousAnswers,
previousContract: opts.previousContract,
activePipelineName: activePipeline?.name,
availableRoleCategories: extractActiveRoleCategories(state),
priorChatSummary,
projectContext,
},
// 분류기와 같은 작은 모델을 재사용 — 이 단계도 빠르고 가벼워야 함.
{ model: cfg.companyIntentClassifierModel || cfg.defaultModel },
);
let contract = analysis.contract;
const reachedLimit = opts.roundsAsked >= opts.roundsLimit;
// ── 자가 조사: 사용자에게 묻기 전에 두뇌에서 스스로 답 찾기 ──
// 답을 찾은 질문은 answeredQuestions 로 이동(자가 조사 marker), 못 찾은
// 것만 카드에 노출. 라운드 카운트는 소비하지 않는다 (사용자 응답이 아니므로).
if (cfg.companyAlignmentSelfResearch !== false
&& contract.openQuestions.length > 0 && !reachedLimit) {
try {
const brain = getActiveBrainProfile();
const evidence = gatherEvidenceForQuestions(brain.localBrainPath, contract.openQuestions);
if (evidence.some((e) => e.excerpts.length > 0)) {
const answers = await selfAnswerQuestions(new AIService(), {
userPrompt: opts.userPrompt,
evidence,
model: cfg.companyIntentClassifierModel || cfg.defaultModel,
});
const solved = answers.filter((a) => a.answered && a.answer.trim());
if (solved.length > 0) {
const solvedSet = new Set(solved.map((s) => s.question));
// 비파괴적 갱신 — analysis.contract 원본은 건드리지 않는다.
contract = {
...contract,
answeredQuestions: [
...contract.answeredQuestions,
...solved.map((s) => ({ q: s.question, a: SELF_RESEARCH_PREFIX + s.answer })),
],
openQuestions: contract.openQuestions.filter((q) => !solvedSet.has(q)),
};
}
}
} catch { /* 자가 조사 실패 — 원래 질문 그대로 노출 */ }
}
if (shouldAutoProceedAlignment(opts.mode, contract, reachedLimit)) {
// contract 한 줄 안내 후 곧장 pipeline. 사용자 friction 최소.
this._view?.webview.postMessage(buildAlignmentAutoProceedPayload(contract, reachedLimit));
this._alignment.clear();
this._saveAlignmentKnowledgeIfAny(opts.userPrompt, contract.answeredQuestions);
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));
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;
this._saveAlignmentKnowledgeIfAny(pending.userOriginalPrompt, pending.contract.answeredQuestions);
await this._runCompanyTurn(
pending.userOriginalPrompt,
undefined,
pending.pipelineIdOverride,
pending.contract,
);
}
/**
* Alignment 학습 루프 (Phase 3) — 사용자가 직접 답해준 Q/A 를 두뇌에 저장.
* 자가 조사 항목과 짧은 답의 필터링은 saveAlignmentKnowledge 내부에서 처리.
* fire-and-forget: 저장 실패가 dispatch 를 막으면 안 된다.
*/
private _saveAlignmentKnowledgeIfAny(userPrompt: string, qaList: Array<{ q: string; a: string }>): void {
try {
const cfg = getConfig();
if (cfg.companyAlignmentKnowledgeSave === false) return;
if (!qaList || qaList.length === 0) return;
const brain = getActiveBrainProfile();
saveAlignmentKnowledge(brain.localBrainPath, { userPrompt, qaList });
} catch { /* non-fatal */ }
}
/**
* 사용자가 카드의 "🛑 취소"를 누르거나 alignment를 그만두려 할 때.
* 슬롯 비우고 webview에도 streamEnd push해서 input 풀어줌.
*/
cancelPendingAlignment(): void {
if (!this._alignment.isPending()) return;
this._alignment.clear();
this._view?.webview.postMessage(buildAlignmentCancelledPayload());
this._postStreamEnd();
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),
});
// 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 {
// ── 자동 수집 컨텍스트 (architecture + URL pre-fetch) ──
// 일반 챗은 둘 다 자동 주입받지만 기업 모드 dispatcher 는 빠져 있던 공백.
// 실패해도 turn 은 진행 (try/catch).
let architectureContextBlock: string | undefined;
try {
const arch = this._buildProjectArchitectureContext();
if (arch) {
architectureContextBlock = arch.length > 6000
? arch.slice(0, 6000) + '\n…(이하 생략 — 전체는 architecture.md 참조)'
: arch;
}
} catch { /* architecture 없어도 진행 */ }
let webContextBlock: string | undefined;
try {
const cfgTurn = getConfig();
if (cfgTurn.webAutoFetchEnabled !== false && !resumeTimestamp) {
const urls = extractUrls(userPrompt, 2);
if (urls.length > 0) {
const blocks: string[] = [];
for (const url of urls) {
blocks.push(await buildUrlContext(url)); // 5분 캐시 — alignment 와 중복 비용 없음
}
const joined = blocks.join('\n\n');
webContextBlock = joined.length > 8000 ? joined.slice(0, 8000) + '\n…(이하 생략)' : joined;
}
}
} catch { /* URL 수집 실패해도 진행 */ }
const deps = buildDispatcherDeps({
context: this._context,
ai,
// [코어 수렴] 기업 모드 검색을 메인 오케스트레이터 경로로 승격.
retrieveBrainContext: (q, folders, limit) => this._agent.retrieveBrainBlockForCompany(q, folders, limit),
signal: abort.signal,
onEvent: emitter.emit,
executeActionTags: (text: string) => this._agent.executeActionTagsOnText(text),
approvalGates: this._approvalGates,
pipelineIdOverride,
requirementContract,
architectureContextBlock,
webContextBlock,
});
// 일반 새 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;
}