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