Files
connectai/src/sidebarProvider.ts
T

3961 lines
186 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import {
_getBrainDir,
findBrainFiles,
buildApiUrl,
getActiveBrainProfile,
getBrainProfiles,
logError,
logInfo,
resolveEngine,
summarizeText,
openInEditorGroup
} from './utils';
import { getConfig } from './config';
import { AgentExecutor, ChatMessage } from './agent';
import { BridgeInterface } from './bridge';
import { buildProjectChronicleGuardContext, ProjectChronicleManager, ProjectProfile } from './features/projectChronicle';
import 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 { getOrCreateAgentEntry, resolveScopeForAgent } from './skills/agentKnowledgeMap';
import { estimateModelParamsB } from './lib/contextManager';
import { loadExternalSkills, formatSkillsAsPromptBlock } from './skills/externalSkillLoader';
import {
buildOrRefreshArchitectureDoc,
architectureDocPathFor,
formatArchitectureContextForPrompt,
resolveActiveSubprojectRoot,
scanProject,
} from './features/projectArchitecture';
import { detectProjectIntent, KnownProject } from './features/projectArchitecture/intentDetector';
import {
readCompanyState,
runCompanyTurn,
resumeCompanyTurn,
listResumableSessions,
summarizeForChip,
CompanyTurnEvent,
DispatcherDeps,
ApprovalDecision,
COMPANY_AGENTS,
COMPANY_AGENT_ORDER,
ROLE_CATEGORY_LABELS,
ROLE_CATEGORY_ORDER,
resolveAgent,
PIPELINE_TEMPLATES,
} from './features/company';
import { AIService } from './core/services';
import { renderAstraOfficePanelHtml, migrateLayout, 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[]>;
}
interface LastVisibleChatSnapshot {
history: ChatMessage[];
brainProfileId: string;
sessionId: string | null;
timestamp: number;
negativePrompt?: string;
}
interface ChatSession {
id: string;
title: string;
timestamp: number;
history: ChatMessage[];
brainProfileId: string;
negativePrompt?: string;
}
/**
* Sidebar UI Provider implementing BridgeInterface for BridgeServer
*/
export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeInterface {
public static readonly viewType = 'g1nation-v2-view';
static readonly activeSessionStateKey = 'g1nation.activeSessionId';
static readonly lastVisibleChatStateKey = 'g1nation.lastVisibleChat';
static readonly blankChatStateKey = 'g1nation.blankChatActive';
static readonly lastAgentStateKey = 'g1nation.lastAgentPath';
static readonly chronicleProjectsStateKey = 'g1nation.chronicleProjects';
static readonly activeChronicleProjectStateKey = 'g1nation.activeChronicleProjectId';
static readonly lastAutoChronicleSignatureStateKey = 'g1nation.lastAutoChronicleSignature';
_view?: vscode.WebviewView;
_panel?: vscode.WebviewPanel;
/**
* Pixel Office "전체보기" — editor area에 띄운 별도 webview panel.
* 사이드바 mini 패널과 같은 `pixelOfficeUpdate` 메시지 스트림을 받고
* 더 큰 화면에서 직군별 캐릭터들을 사무실 배경 위에 배치해 보여준다.
* 닫혀 있으면 undefined — broadcast 시 안전하게 skip.
*/
private _pixelOfficePanel?: vscode.WebviewPanel;
public brainEnabled = true;
_currentSessionBrainId: string | null = null;
_currentNegativePrompt: string = '';
readonly _chronicle = new ProjectChronicleManager();
_modelDiscoveryInFlight = false;
_modelsCache: { url: string; models: string[]; online: boolean; fetchedAt: number } | null = null;
static readonly MODELS_CACHE_TTL_MS = 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.
*/
private _companyAbort?: AbortController;
/**
* 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.
*/
private _pendingApprovals = new Map<string, (d: import('./features/company/dispatcher').ApprovalDecision) => void>();
/**
* 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.
*/
private _lastCompanyTurnSummary?: {
brief: string;
reportTail: string;
finishedAt: number;
};
/**
* Intent Alignment 진행 중인 상태. new_task가 분류되면 분석기를 돌리고,
* confidence가 충분치 않으면 사용자에게 질문 카드를 띄운 뒤 이 슬롯에
* 현재 contract를 보관 → 다음 사용자 메시지를 *답변*으로 해석한다.
*
* 한 번에 한 alignment만 진행. 회사 모드는 sequential 방식이라 동시
* 다발 alignment가 생길 수 없음. 사용자가 도중 다른 chat을 던지면
* 그 메시지는 답변으로 합쳐진다 — "취소" 버튼이나 모드 토글로만 빠져
* 나갈 수 있게 하는 게 흐름상 안전.
*/
private _pendingAlignment?: {
userOriginalPrompt: string;
contract: import('./features/company').RequirementContract;
/** 이번 alignment 동안 사용자가 답한 누적 라운드 수 — 무한 라운드 방지. */
roundsAsked: number;
/** 이번 turn 한정 pipeline override (Phase 4 분류기에서 전달된 추천). */
pipelineIdOverride?: string;
};
/**
* Pixel Office 현재 작업 상태 캐시. 모든 emit/alignment hook이 한 슬롯에
* 모인 상태를 patch 형식으로 갱신 → broadcast. UI layer이므로 어떤 판단
* 로직에도 다시 영향 주지 않는다 — 단방향 read-only 흐름.
*/
private _pixelOfficeState?: import('./features/company').AgentWorkState;
/** 같은 말풍선 텍스트 연달아 안 나오게 추적용 (상태/이벤트 별). */
private _pixelOfficeLastBubble: Map<string, string> = new Map();
/**
* Activity ring buffer — 매 turn 의 action-tag 결과 누적 (옛 'pixelOfficeActivity' message
* 의 단일 source-of-truth). officeSnapshot.activity 에 그대로 포함. webview ticker 는
* 옛 message 도 받지만, 새 path 가 활성화되면 이 버퍼에서 직접 읽어가게 된다.
*/
private _pixelOfficeActivity: import('./features/astraOffice').OfficeActivityItem[] = [];
private static readonly PIXEL_OFFICE_ACTIVITY_MAX = 32;
/**
* Astra Office 사용자 정의 레이아웃. workspace state에 저장되어 프로젝트별
* 다른 배치를 가질 수 있다. null/undefined면 webview가 디폴트 LAYOUT을 사용.
* 편집 모드에서 사용자가 드래그로 위치 조정 후 저장 → 이 슬롯 업데이트 →
* webview에 다시 broadcast.
*
* 데이터 shape:
* {
* cells: [{ roleKey, deskX, deskY, charX, charY }, ...],
* decos: [{ id, type, x, y }, ...]
* }
*/
static readonly pixelOfficeLayoutKey = 'g1nation.pixelOfficeLayout';
private _readPixelOfficeLayout(): unknown {
// workspaceState 는 unknown 그대로 반환 — 깨진/옛 데이터 방어를 위해 validator
// (refactor #5) 를 한 번 통과시킨다. 통과 못한 데이터는 null → webview default 로 fallback.
const raw = this._context.workspaceState.get(SidebarChatProvider.pixelOfficeLayoutKey);
if (raw == null) return null;
return migrateLayout(raw); // v2 면 그대로, v1 면 변환, 어느 쪽도 아니면 null.
}
private async _writePixelOfficeLayout(layout: unknown): Promise<void> {
await this._context.workspaceState.update(SidebarChatProvider.pixelOfficeLayoutKey, layout);
}
private async _clearPixelOfficeLayout(): Promise<void> {
await this._context.workspaceState.update(SidebarChatProvider.pixelOfficeLayoutKey, undefined);
}
/** Phase B-2에서 chatHandlers가 alignment 진행 여부를 빠르게 확인하는 용도. */
isAlignmentPending(): boolean {
return !!this._pendingAlignment;
}
// ─────────────────────── 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 prev = this._pixelOfficeState ?? {
agentId: 'main',
agentName: 'Agent',
status: 'idle' as import('./features/company').AgentStatus,
recentLogs: [],
updatedAt: Date.now(),
};
const next: import('./features/company').AgentWorkState = {
...prev,
...patch,
recentLogs: patch.recentLogs ?? prev.recentLogs ?? [],
updatedAt: Date.now(),
};
this._pixelOfficeState = 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._pixelOfficeLastBubble.get(lastKey));
if (text) {
this._pixelOfficeLastBubble.set(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._pixelOfficeLastBubble.get(lastKey));
if (text) {
this._pixelOfficeLastBubble.set(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._pixelOfficePanel?.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 = this._buildOfficeRoster();
const snapshot = presentOfficeSnapshot({
activeState: next,
recentBubbles: bubbles,
recentActivity: this._pixelOfficeActivity,
roster,
});
const snapMsg = { type: 'officeSnapshot' as const, value: snapshot };
this._view?.webview.postMessage(snapMsg);
this._pixelOfficePanel?.webview.postMessage(snapMsg);
} catch (e) {
// presenter 가 throw 해도 옛 동작에는 영향 없도록 swallow.
// 디버깅 필요 시 console.warn 으로 띄울 수 있음.
}
}
/**
* 현재 CompanyState 에서 active agents 를 평탄화 → presenter 의 roster 입력 모양으로.
* read 실패 / company off → 빈 배열 (presenter 가 옛 single-agent fallback 사용).
*/
private _buildOfficeRoster(): Array<{
agentId: string;
agentName: string;
roleCategory: import('./features/astraOffice').OfficeAgentSnapshot['roleCategory'];
}> {
try {
const { listActiveAgentsByCategory } = require('./features/company') as typeof import('./features/company');
const state = readCompanyState(this._context);
if (!state.enabled) return [];
const buckets = listActiveAgentsByCategory(state);
const out: Array<{ agentId: string; agentName: string; roleCategory: import('./features/astraOffice').OfficeAgentSnapshot['roleCategory'] }> = [];
// ROLE_CATEGORY_ORDER 순서 보존 — 화면에서 일관된 좌→우/위→아래 정렬 가능.
const order: Array<keyof typeof buckets> = ['ceo', 'planner', 'researcher', 'designer', 'developer', 'qa', 'inspector', 'support'];
for (const cat of order) {
for (const def of buckets[cat] ?? []) {
out.push({
agentId: def.id,
agentName: def.name ?? def.id,
roleCategory: cat,
});
}
}
return out;
} catch {
return [];
}
}
/**
* Activity ring buffer 에 항목 추가. agent-done 시점 action-tag report 등에서 호출.
* 옛 'pixelOfficeActivity' message 와 같은 데이터를 누적 보관 → 다음 officeSnapshot 에 포함.
*/
private _pixelOfficePushActivity(items: Array<{ agentId: string; text: string; kind?: 'ok' | 'warn' | 'err' | 'info' }>): void {
if (!items?.length) return;
const now = Date.now();
for (const it of items) {
this._pixelOfficeActivity.push({ ts: now, agentId: it.agentId, text: it.text, kind: it.kind });
}
const max = SidebarChatProvider.PIXEL_OFFICE_ACTIVITY_MAX;
if (this._pixelOfficeActivity.length > max) {
this._pixelOfficeActivity = this._pixelOfficeActivity.slice(-max);
}
}
/** recentLogs ring buffer push — webview에서 보여주는 "최근 로그". */
private _pixelOfficeAppendLog(line: string): string[] {
const cur = this._pixelOfficeState?.recentLogs ?? [];
const next = [...cur, line].slice(-6);
return next;
}
/** 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._pixelOfficeAppendLog(`💬 분류: ${intent}`),
}, { bubbleStatus: 'idle' });
return;
}
// new_task — intake → analyzing 흐름의 첫 신호.
this._pixelOfficeBroadcast({
status: 'intake',
currentTask: userPrompt,
currentStep: '요청 접수',
recentLogs: this._pixelOfficeAppendLog(`📨 새 작업 요청 접수`),
}, { bubbleStatus: 'intake' });
}
/** Intent Alignment 분석 시작 (LLM 콜 직전). */
pixelOfficeOnAlignmentStart(userPrompt: string): void {
this._pixelOfficeBroadcast({
status: 'analyzing',
currentTask: this._pixelOfficeState?.currentTask || userPrompt,
currentStep: '요청 분석 중 (C·G·C·F 추출)',
message: undefined,
recentLogs: this._pixelOfficeAppendLog('🔍 의도 분석 시작'),
}, { 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: this._summariseContract(contract),
needUserInput: undefined,
recentLogs: this._pixelOfficeAppendLog('✅ 계약 자동 확정'),
}, {
bubbleStatus: 'contract_ready',
bubbleEvent: 'requirement_contract_created',
});
return;
}
if (kind === 'questions') {
this._pixelOfficeBroadcast({
status: 'need_clarification',
currentStep: '확인 질문 대기 중',
requirementContract: this._summariseContract(contract),
needUserInput: contract.openQuestions,
recentLogs: this._pixelOfficeAppendLog(`🤔 ${contract.openQuestions.length}개 질문 대기`),
}, {
bubbleStatus: 'need_clarification',
bubbleEvent: 'clarification_needed',
});
return;
}
// confirm — 질문 없음, 사용자 OK만 받으면 됨.
this._pixelOfficeBroadcast({
status: 'waiting_approval',
currentStep: '계약 확인 대기',
requirementContract: this._summariseContract(contract),
needUserInput: undefined,
awaitingApproval: '사용자 확인 — 그대로 진행할지 여부',
recentLogs: this._pixelOfficeAppendLog('🧭 계약 확인 카드 표시'),
}, { bubbleStatus: 'waiting_approval' });
}
/** Alignment 취소(사용자가 카드 닫음). */
pixelOfficeOnAlignmentCancelled(): void {
this._pixelOfficeBroadcast({
status: 'idle',
currentStep: '취소됨',
needUserInput: undefined,
awaitingApproval: undefined,
recentLogs: this._pixelOfficeAppendLog('🛑 작업 취소'),
});
}
private _summariseContract(c: import('./features/company').RequirementContract) {
return {
goal: c.goal || undefined,
context: c.context || undefined,
criteria: c.criteria,
format: c.format || undefined,
openQuestions: c.openQuestions,
confidence: c.confidence,
};
}
/**
* 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._pixelOfficeAppendLog('📋 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._pixelOfficeAppendLog(`📋 plan 완료 (${ev.plan?.tasks?.length ?? 0}개 task)`),
pipelineStages: stages,
}, { bubbleEvent: 'plan_completed' });
return;
}
case 'agent-start': {
// B. 현재 stage를 active로, 이전 stage들은 done으로.
const prev = this._pixelOfficeState?.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._pixelOfficeAppendLog(`${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._pixelOfficePanel?.webview.postMessage({ type: 'pixelOfficeActivity', value: { items } });
// 새 path 도 동기화 — ring buffer 에 누적 → 다음 officeSnapshot 에 포함.
this._pixelOfficePushActivity(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._pixelOfficeAppendLog(
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._pixelOfficeAppendLog(`🔁 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._pixelOfficeAppendLog(`🔍 검수 시작: ${ev.stageLabel}`),
}, { bubbleStatus: 'reviewing' });
return;
case 'review-round':
this._pixelOfficeBroadcast({
status: 'reviewing',
currentStep: `검수 라운드 ${ev.round} (${ev.inspectorVerdict}/${ev.ceoVerdict})`,
recentLogs: this._pixelOfficeAppendLog(
`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._pixelOfficeAppendLog(`🔍 검수 종료: ${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._pixelOfficeAppendLog(`✋ 승인 대기: ${ev.stageLabel}`),
}, {
bubbleStatus: 'waiting_approval',
bubbleEvent: 'approval_required',
});
return;
case 'approval-resolved':
this._pixelOfficeBroadcast({
status: 'executing',
awaitingApproval: undefined,
currentStep: `승인 결과: ${ev.decision}`,
recentLogs: this._pixelOfficeAppendLog(`✋→ ${ev.decision}`),
});
return;
case 'report-start':
this._pixelOfficeBroadcast({
status: 'reviewing',
currentStep: 'CEO 종합 보고서 작성 중',
recentLogs: this._pixelOfficeAppendLog('🧭 보고서 작성'),
});
return;
case 'report-done':
this._pixelOfficeBroadcast({
status: 'done',
currentStep: ev.ok ? '보고서 완료' : '보고서 (fallback) 완료',
progress: 1,
recentLogs: this._pixelOfficeAppendLog(ev.ok ? '✅ 보고서 OK' : '⚠ fallback 보고서'),
}, {
bubbleStatus: 'done',
bubbleEvent: 'task_completed',
});
return;
case 'session-saved':
this._pixelOfficeBroadcast({
status: 'done',
message: `세션 저장됨`,
recentLogs: this._pixelOfficeAppendLog('💾 세션 저장'),
});
return;
case 'aborted':
this._pixelOfficeBroadcast({
status: 'error',
currentStep: `중단: ${ev.reason}`,
recentLogs: this._pixelOfficeAppendLog(`🛑 abort: ${ev.reason}`),
}, {
bubbleStatus: 'error',
bubbleEvent: 'error_occurred',
});
return;
case 'telegram-mirror':
default:
return; // 시각화에 의미 약함 — log skip
}
}
/** webview가 처음 로드되거나 사용자가 토글을 다시 켰을 때 캐시된 상태 재전송. */
pixelOfficeResend(): void {
const cfg = getConfig();
const payload = (() => {
if (!cfg.companyPixelOfficeEnabled) {
return {
type: 'pixelOfficeUpdate' as const,
value: { state: null, bubbles: [], config: { enabled: false, bubblesEnabled: false, maxVisibleBubbles: 0, bubbleDurationMs: 0 } },
};
}
const state = this._pixelOfficeState ?? {
agentId: 'main', agentName: 'Agent',
status: 'idle' as import('./features/company').AgentStatus,
recentLogs: [],
updatedAt: Date.now(),
};
return {
type: 'pixelOfficeUpdate' as const,
value: {
state, bubbles: [],
config: {
enabled: cfg.companyPixelOfficeEnabled,
bubblesEnabled: cfg.companyPixelOfficeBubbles,
maxVisibleBubbles: 3,
bubbleDurationMs: 4500,
},
},
};
})();
this._view?.webview.postMessage(payload);
this._pixelOfficePanel?.webview.postMessage(payload);
if (cfg.companyPixelOfficeEnabled) {
try {
const state = payload.value.state ?? undefined;
const snapshot = presentOfficeSnapshot({
activeState: state ?? undefined,
recentBubbles: [],
recentActivity: this._pixelOfficeActivity,
roster: this._buildOfficeRoster(),
});
const snapMsg = { type: 'officeSnapshot' as const, value: snapshot };
this._view?.webview.postMessage(snapMsg);
this._pixelOfficePanel?.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._pixelOfficePanel) {
this._pixelOfficePanel.reveal(column);
return this._pixelOfficePanel;
}
const panel = vscode.window.createWebviewPanel(
'astra.pixelOffice',
'Astra Office',
column,
{ enableScripts: true, localResourceRoots: [this._extensionUri], retainContextWhenHidden: true },
);
this._pixelOfficePanel = panel;
panel.webview.html = this._buildPixelOfficeHtml(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._readPixelOfficeLayout() ?? null,
});
}
if (msg.type === 'savePixelOfficeLayout') {
// webview의 편집 모드에서 사용자가 저장 버튼 누름.
void this._writePixelOfficeLayout(msg.value).then(() => {
panel.webview.postMessage({
type: 'pixelOfficeLayoutSaved', value: { ok: true },
});
});
}
if (msg.type === 'resetPixelOfficeLayout') {
// 디폴트 LAYOUT으로 복귀.
void this._clearPixelOfficeLayout().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._pixelOfficePanel === panel) this._pixelOfficePanel = undefined;
});
// 열자마자 현재 상태 한 번 push.
this.pixelOfficeResend();
return panel;
}
/**
* Pixel Office panel용 HTML 본문. 사이드바 mini와 같은 메시지 스키마를
* 받지만 그리는 방식이 전혀 달라(사무실 그리드 + 직군별 캐릭터) 사이드바와
* 분리된 별도 HTML을 둔다. 외부 CSS/JS 파일을 안 쓰고 한 파일에 묶어
* VS Code의 localResourceRoots 제약을 신경 안 쓰도록 설계.
*/
private _buildPixelOfficeHtml(webview: vscode.Webview): string {
const cspSource = webview.cspSource;
const derivedBase = webview.asWebviewUri(
vscode.Uri.joinPath(this._extensionUri, 'assets', 'pixelOffice', 'derived')
).toString();
return renderAstraOfficePanelHtml({ cspSource, derivedBase });
}
/** Alignment 슬롯 비우기 — 사용자가 "취소"를 눌렀거나 turn 시작/종료 시점 호출. */
clearPendingAlignment(): void {
this._pendingAlignment = undefined;
}
/** Active file-system watcher for the project-architecture auto-update. Disposed on switch/detach. */
private _archWatcher?: vscode.FileSystemWatcher;
/** Debounce timer for the architecture watcher. */
private _archWatchDebounce?: NodeJS.Timeout;
/** Project ID the current watcher is watching — kept so we don't double-register. */
private _archWatchedProjectId?: string;
constructor(
readonly _extensionUri: vscode.Uri,
readonly _context: vscode.ExtensionContext,
readonly _agent: AgentExecutor,
readonly _lmStudio?: SidebarLmStudioDeps
) {
this._agent.setHistoryChangeListener((history) => {
void this._persistLastVisibleChat(history);
});
}
/** Surface LM Studio lifecycle errors (load/unload failures) to the chat UI as a non-fatal toast. */
public postLmStudioError(message: string): void {
this._view?.webview.postMessage({ type: 'lmStudioError', value: message });
}
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;
}
private _initView(webviewView: vscode.WebviewView) {
this._view = webviewView;
webviewView.webview.options = {
enableScripts: true,
localResourceRoots: [this._extensionUri]
};
// [State Persistence Fix] 사이드바가 다시 보여질 때 세팅값 자동 복원
let _lastVisibilityRefresh = 0;
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...');
void this._sendModels();
void this._sendBrainProfiles();
void this._sendAgentsList();
void this._sendReadyStatus();
});
webviewView.webview.html = this._getHtml(webviewView.webview);
this._agent.setWebview(webviewView.webview);
void this._restoreActiveSessionIntoView();
void this._sendReadyStatus();
webviewView.webview.onDidReceiveMessage(async (data) => {
if (await handleChatMessage(this, data)) return;
if (await handleBrainMessage(this, data)) return;
if (await handleChronicleMessage(this, data)) return;
if (await handleAgentMessage(this, data)) return;
logInfo(`Unhandled sidebar message: ${data?.type}`);
});
}
_currentSessionId: string | null = null;
async _restoreActiveSessionIntoView() {
if (!this._view) return;
const blankChatActive = this._context.globalState.get<boolean>(SidebarChatProvider.blankChatStateKey, false);
const currentHistory = this._agent.getHistory();
if (blankChatActive && currentHistory.length === 0) {
return;
}
const activeSessionId = this._currentSessionId || this._context.globalState.get<string | null>(SidebarChatProvider.activeSessionStateKey, null);
if (activeSessionId) {
const loaded = await this._loadSession(activeSessionId, true);
if (loaded) return;
await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null);
}
if (currentHistory.length > 0) {
this._view.webview.postMessage({ type: 'restoreHistory', value: currentHistory });
await this._persistLastVisibleChat(currentHistory);
return;
}
const snapshot = this._context.globalState.get<LastVisibleChatSnapshot | null>(SidebarChatProvider.lastVisibleChatStateKey, null);
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._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null);
return;
}
const snapshot: LastVisibleChatSnapshot = {
history,
brainProfileId: this._currentSessionBrainId || getActiveBrainProfile().id,
sessionId: this._currentSessionId,
timestamp: Date.now(),
negativePrompt: this._currentNegativePrompt
};
await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, snapshot);
}
async _saveCurrentSession() {
const history = this._agent.getHistory();
if (history.length === 0) return;
let sessions = this._getSessions();
const firstMsg = history.find(m => m.role === 'user')?.content;
const title = typeof firstMsg === 'string' ? firstMsg.substring(0, 50).replace(/\n/g, ' ') + (firstMsg.length > 50 ? '...' : '') : 'New Chat';
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
});
}
}
// Keep only last 50 sessions
if (sessions.length > 50) sessions = sessions.slice(0, 50);
await this._putSessions(sessions);
await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, this._currentSessionId);
await this._persistLastVisibleChat(history);
await this._sendSessionList();
}
async _sendSessionList() {
if (!this._view) return;
const sessions = this._getSessions();
const list = sessions.map(s => ({
id: s.id,
title: s.title,
timestamp: s.timestamp,
brainProfileId: s.brainProfileId || '',
messageCount: s.history.length,
history: s.history
}));
this._view.webview.postMessage({ type: 'sessionList', value: list });
}
async _loadSession(id: string, skipSessionListRefresh: boolean = false): Promise<boolean> {
if (!id) {
logError('Session load requested without an id.');
this._view?.webview.postMessage({ type: 'error', value: 'Chat session id is missing.' });
return false;
}
const sessions = this._getSessions();
const session = sessions.find(s => s.id === id) || this._getSessionById(id);
if (session) {
const history = Array.isArray(session.history) ? session.history : [];
if (history.length === 0) {
logError('Session load failed because history is empty or invalid.', { id });
this._view?.webview.postMessage({ type: 'error', value: '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._context.globalState.update(SidebarChatProvider.activeSessionStateKey, id);
await this._context.globalState.update(SidebarChatProvider.blankChatStateKey, false);
await this._persistLastVisibleChat(history);
this._view?.webview.postMessage({
type: 'sessionLoaded',
value: {
id,
title: session.title || 'Chat Session',
history,
negativePrompt: this._currentNegativePrompt
}
});
if (!skipSessionListRefresh) {
await this._sendSessionList();
}
logInfo('Chat session loaded.', { id, messages: history.length });
return true;
}
logError('Session load failed because id was not found.', { id, sessionCount: sessions.length });
this._view?.webview.postMessage({ type: 'error', value: 'Chat session was not found.' });
return false;
}
async _deleteSession(id: string) {
let sessions = this._getSessions();
sessions = sessions.filter(s => s.id !== id);
await this._putSessions(sessions);
if (this._currentSessionId === id) {
this._currentSessionId = null;
this._agent.resetConversation();
await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null);
await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null);
await this._context.globalState.update(SidebarChatProvider.blankChatStateKey, true);
this.clearChat();
}
await this._sendSessionList();
}
_getSessions(): ChatSession[] {
const rawSessions = this._context.globalState.get<any[]>('chat_sessions', []) || [];
return rawSessions
.map((session, index): ChatSession | null => {
const history = Array.isArray(session?.history)
? session.history.filter((message: any) =>
message
&& (message.role === 'user' || message.role === 'assistant' || message.role === 'system')
&& message.content !== undefined
)
: [];
if (!session?.id || history.length === 0) return null;
const firstMsg = history.find((message: ChatMessage) => message.role === 'user')?.content;
const fallbackTitle = typeof firstMsg === 'string'
? firstMsg.substring(0, 50).replace(/\n/g, ' ') + (firstMsg.length > 50 ? '...' : '')
: `Chat ${index + 1}`;
return {
id: String(session.id),
title: String(session.title || fallbackTitle),
timestamp: typeof session.timestamp === 'number' ? session.timestamp : Date.now(),
history,
brainProfileId: String(session.brainProfileId || getActiveBrainProfile().id),
negativePrompt: String(session.negativePrompt || '')
};
})
.filter((session): session is ChatSession => !!session)
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, 50);
}
_getSessionById(id: string): ChatSession | null {
const rawSessions = this._context.globalState.get<any[]>('chat_sessions', []) || [];
const raw = rawSessions.find((session: any) => String(session?.id) === String(id));
if (!raw) return null;
const history = Array.isArray(raw.history)
? raw.history.filter((message: any) =>
message
&& (message.role === 'user' || message.role === 'assistant' || message.role === 'system')
&& message.content !== undefined
)
: [];
if (history.length === 0) return null;
const firstMsg = history.find((message: ChatMessage) => message.role === 'user')?.content;
const fallbackTitle = typeof firstMsg === 'string'
? firstMsg.substring(0, 50).replace(/\n/g, ' ') + (firstMsg.length > 50 ? '...' : '')
: 'Chat Session';
return {
id: String(raw.id),
title: String(raw.title || fallbackTitle),
timestamp: typeof raw.timestamp === 'number' ? raw.timestamp : Date.now(),
history,
brainProfileId: String(raw.brainProfileId || getActiveBrainProfile().id),
negativePrompt: String(raw.negativePrompt || '')
};
}
async _putSessions(sessions: ChatSession[]) {
await this._context.globalState.update('chat_sessions', sessions.slice(0, 50));
}
async _sendBrainStatus() {
if (!this._view) return;
const activeBrain = getActiveBrainProfile();
const brainDir = activeBrain.localBrainPath;
const files = findBrainFiles(brainDir);
this._view.webview.postMessage({
type: 'brainStatus',
value: {
count: files.length,
path: brainDir,
name: activeBrain.name,
description: activeBrain.description || '',
repo: activeBrain.secondBrainRepo || ''
}
});
}
/**
* 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;
let payload: any;
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;
payload = {
engine: {
kind: engineKind,
label: engineKind === 'lmstudio' ? 'LM Studio' : 'Ollama',
online: this._modelsCache?.online ?? null,
},
model: { name: config.defaultModel, loaded: modelLoaded, paramB },
brain: { name: activeBrain.name, files: brainFiles },
agent: { name: agentName, scopeFolders, mapped },
memory: config.memoryEnabled,
multiAgent: config.multiAgentEnabled,
contextLength: effectiveContextLength,
nominalContextLength: config.contextLength,
cappedForSmallModel,
};
} catch (err: any) {
logError('Failed to build ready status.', { error: err?.message || String(err) });
return;
}
this._view.webview.postMessage({ type: 'readyStatus', value: payload });
}
async _sendBrainProfiles() {
if (!this._view) return;
const activeBrain = getActiveBrainProfile();
this._currentSessionBrainId = this._currentSessionBrainId || activeBrain.id;
const profiles = getBrainProfiles().map((profile) => ({
id: profile.id,
name: profile.name,
path: profile.localBrainPath,
description: profile.description || '',
repo: profile.secondBrainRepo || ''
}));
this._view.webview.postMessage({
type: 'brainProfiles',
value: {
activeBrainId: activeBrain.id,
profiles
}
});
void this._sendReadyStatus();
}
_postBrainProfiles(profiles: any[], activeBrainId: string) {
if (!this._view) return;
this._view.webview.postMessage({
type: 'brainProfiles',
value: {
activeBrainId,
profiles: profiles.map((p: any) => ({
id: p.id || '',
name: p.name || '',
path: p.localBrainPath || '',
description: p.description || '',
repo: p.secondBrainRepo || ''
}))
}
});
}
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;
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');
}
async _addBrainProfile() {
const selected = await vscode.window.showOpenDialog({
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
openLabel: 'Use as Brain'
});
const folder = selected?.[0]?.fsPath;
if (!folder) return;
const defaultName = path.basename(folder) || 'New Brain';
const name = await vscode.window.showInputBox({
prompt: 'Name this brain profile',
value: defaultName,
validateInput: (value) => value.trim() ? null : 'Brain name is required.'
});
if (!name) return;
const description = await vscode.window.showInputBox({
prompt: 'Optional description shown in the Astra sidebar',
value: ''
});
const repo = await vscode.window.showInputBox({
prompt: 'Optional Second Brain Git repository URL',
value: ''
});
// Read raw settings directly to avoid virtual default-brain (injected in memory by getConfig())
// being saved into the settings file and corrupting the profile list on next load.
const cfg = vscode.workspace.getConfiguration('g1nation');
const existingRaw: any[] = cfg.get<any[]>('brainProfiles', []) || [];
const idBase = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'brain';
let id = idBase;
let suffix = 2;
while (existingRaw.some((p: any) => p.id === id)) {
id = `${idBase}-${suffix++}`;
}
const newProfile = {
id,
name: name.trim(),
localBrainPath: folder,
secondBrainRepo: (repo || '').trim(),
description: (description || '').trim()
};
const nextProfiles = [...existingRaw, newProfile];
await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global);
await cfg.update('activeBrainId', id, vscode.ConfigurationTarget.Global);
this._currentSessionBrainId = id;
// cfg.update() is async and VSCode's config cache may not reflect the new value
// immediately, so we post the freshly-built profile list directly.
this._postBrainProfiles(nextProfiles, id);
await this._sendBrainStatus();
this.injectSystemMessage(`**[Brain Added]** ${name.trim()}\n\`${folder}\``);
}
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
);
const cfg = vscode.workspace.getConfiguration('g1nation');
await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global);
await cfg.update('activeBrainId', target.id, vscode.ConfigurationTarget.Global);
this._currentSessionBrainId = target.id;
this._postBrainProfiles(nextProfiles, target.id);
await this._sendBrainStatus();
this.injectSystemMessage(`**[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];
const cfg = vscode.workspace.getConfiguration('g1nation');
await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global);
await cfg.update('activeBrainId', nextActive.id, vscode.ConfigurationTarget.Global);
this._currentSessionBrainId = nextActive.id;
this._postBrainProfiles(nextProfiles, nextActive.id);
await this._sendBrainStatus();
this.injectSystemMessage(`**[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 = this._slugify(history.find((message) => message.role === 'user')?.content || 'conversation');
const fileName = `${this._formatTimestampForFile(timestamp)}-${slug}.md`;
const filePath = path.join(rawDir, fileName);
const markdown = this._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(history: ChatMessage[], meta: {
category: string;
expectedValue: string;
activeBrainName: string;
activeBrainPath: string;
createdAt: string;
}): string {
const firstUserMessage = history.find((message) => message.role === 'user')?.content || '';
const latestUserMessage = [...history].reverse().find((message) => message.role === 'user')?.content || '';
const latestAssistantMessage = [...history].reverse().find((message) => message.role === 'assistant')?.content || '';
const rationaleNotes = history
.filter((message) => message.role === 'assistant' && message.rationale)
.map((message, index) => [
`### Process Note ${index + 1}`,
message.rationale?.problem ? `- Problem: ${message.rationale.problem}` : '',
message.rationale?.goal ? `- Goal: ${message.rationale.goal}` : '',
message.rationale?.reasoning ? `- Reasoning: ${message.rationale.reasoning}` : ''
].filter(Boolean).join('\n'))
.join('\n\n');
const transcript = history
.map((message, index) => [
`### ${index + 1}. ${message.role.toUpperCase()}`,
'',
message.content.trim() || '(empty)'
].join('\n'))
.join('\n\n');
return [
'---',
`title: "${this._escapeYamlString(this._summarizeForTitle(firstUserMessage))}"`,
`category: "${this._escapeYamlString(meta.category)}"`,
`created_at: "${meta.createdAt}"`,
`source: "Astra conversation"`,
`brain: "${this._escapeYamlString(meta.activeBrainName)}"`,
'status: raw',
'---',
'',
`# ${this._summarizeForTitle(firstUserMessage)}`,
'',
'## Category',
meta.category,
'',
'## What This Contains',
this._summarizeTextForWiki(latestAssistantMessage || firstUserMessage),
'',
'## Expected Value',
meta.expectedValue,
'',
'## Discovery Process',
rationaleNotes || 'No explicit rationale metadata was captured. Use the transcript below to reconstruct the reasoning path.',
'',
'## Conclusion',
[
'This conclusion was derived from the latest user request and assistant response.',
'',
`- Latest user request: ${this._summarizeTextForWiki(latestUserMessage)}`,
`- Latest assistant conclusion: ${this._summarizeTextForWiki(latestAssistantMessage)}`
].join('\n'),
'',
'## Coding Implementation Notes',
'Use this section to turn the raw transcript into a wiki-ready implementation note. Capture which files changed, why they changed, and what verification was run.',
'',
'## Source Brain',
`- Name: ${meta.activeBrainName}`,
`- Path: ${meta.activeBrainPath}`,
'',
'## Raw Conversation Transcript',
transcript,
''
].join('\n');
}
_formatTimestampForFile(date: Date): string {
return date.toISOString().replace(/[:.]/g, '-').replace('T', '_').replace('Z', '');
}
_slugify(value: string): string {
const slug = value
.toLowerCase()
.replace(/[^a-z0-9가-힣]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 48);
return slug || 'conversation';
}
_summarizeForTitle(value: string): string {
const normalized = value.replace(/\s+/g, ' ').trim();
if (!normalized) return 'Astra Conversation Raw Data';
return normalized.length > 80 ? `${normalized.slice(0, 80)}...` : normalized;
}
_summarizeTextForWiki(value: string): string {
const normalized = value.replace(/\s+/g, ' ').trim();
if (!normalized) return 'Not captured.';
return normalized.length > 500 ? `${normalized.slice(0, 500)}...` : normalized;
}
_escapeYamlString(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
// --- BridgeInterface Methods ---
public injectSystemMessage(msg: string): void {
this._view?.webview.postMessage({ type: 'streamStart' });
this._view?.webview.postMessage({ type: 'streamChunk', value: msg });
this._view?.webview.postMessage({ type: 'streamEnd' });
}
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(): ProjectProfile[] {
const raw = this._context.globalState.get<ProjectProfile[]>(SidebarChatProvider.chronicleProjectsStateKey, []) || [];
const valid = raw.filter((profile: ProjectProfile) =>
profile
&& typeof profile.projectId === 'string'
&& typeof profile.projectName === 'string'
&& typeof profile.recordRoot === 'string'
);
if (valid.length > 0) return valid;
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) return [];
const now = new Date().toISOString();
const projectName = path.basename(workspaceRoot) || 'Current Project';
return [{
projectId: this._slugify(projectName),
projectName,
projectRoot: workspaceRoot,
recordRoot: path.join(workspaceRoot, 'docs', 'records', projectName),
description: 'Auto-detected current workspace project.',
corePurpose: 'Capture project planning, decisions, development notes, bugs, and retrospectives as Markdown.',
targetUsers: ['Project developer'],
avoidDirections: ['Do not tightly couple records to chat execution internals.'],
detailLevel: 'standard',
createdAt: now,
updatedAt: now
}];
}
async _putChronicleProjects(projects: ProjectProfile[]) {
await this._context.globalState.update(SidebarChatProvider.chronicleProjectsStateKey, projects);
}
/**
* 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._getChronicleProjects();
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._putChronicleProjects(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.
/** True if the active project has its architecture doc auto-attached. */
_isArchitectureAutoAttached(): boolean {
const p = this._getActiveChronicleProject();
return !!(p && p.architectureDocPath && p.architectureAutoAttach !== false);
}
/**
* Try to resolve a project handle from arbitrary user text. Combines:
* • Korean / English natural-language activation phrasing.
* • Absolute filesystem paths.
* • The existing Chronicle project list as ground truth for name matches.
*/
_detectProjectFromText(text: string): KnownProject | null {
const known = this._getChronicleProjects().map<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._getChronicleProjects();
let profile = projects.find((p) => p.projectId === projectId);
// Materialise a stub when the user references a project by path that
// isn't yet registered. We use the path's basename as the name and the
// standard records location as recordRoot so existing Chronicle code
// keeps working.
if (!profile) {
const root = opts.fallbackRoot || '';
if (!root) {
logError('architecture: cannot activate without project root.', { projectId });
return null;
}
const name = opts.fallbackName || path.basename(root) || projectId;
const now = new Date().toISOString();
profile = {
projectId,
projectName: name,
projectRoot: root,
recordRoot: path.join(root, 'docs', 'records', name),
description: 'Auto-created by Project Architecture activation.',
corePurpose: '',
detailLevel: 'standard',
createdAt: now,
updatedAt: now,
};
projects.push(profile);
await this._putChronicleProjects(projects);
}
if (!profile.projectRoot) {
logError('architecture: profile has no projectRoot; cannot scan.', { projectId });
return null;
}
// Generate or refresh the doc. Always idempotent — the generator
// preserves user-owned sections.
const result = buildOrRefreshArchitectureDoc(profile.projectRoot, profile.projectName);
const now = new Date().toISOString();
const updated: ProjectProfile = {
...profile,
architectureDocPath: result.docPath,
// [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._putChronicleProjects(next);
await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, projectId);
// (Re)register the watcher for this project.
this._registerArchitectureWatcher(updated);
// Tell the webview to show / refresh the chip.
await this._sendArchitectureStatus();
logInfo('architecture: activated.', {
projectId, docPath: result.docPath, created: result.created,
});
return projectId;
}
/** Detach project mode: stop auto-attaching the doc and dispose the watcher. */
async _detachArchitecture(): Promise<void> {
const profile = this._getActiveChronicleProject();
if (!profile) {
this._disposeArchitectureWatcher();
await this._sendArchitectureStatus();
return;
}
const projects = this._getChronicleProjects();
const next = projects.map((p) => p.projectId === profile.projectId
? { ...p, architectureAutoAttach: false }
: p);
await this._putChronicleProjects(next);
this._disposeArchitectureWatcher();
await this._sendArchitectureStatus();
logInfo('architecture: detached.', { projectId: profile.projectId });
}
/**
* Force a refresh of the architecture doc for the active project.
*
* 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({
type: 'architectureRefreshFailed',
value: { reason: 'no-active-project' },
});
return;
}
const result = buildOrRefreshArchitectureDoc(profile.projectRoot, profile.projectName);
const now = new Date().toISOString();
const projects = this._getChronicleProjects();
const next = projects.map((p) => p.projectId === profile.projectId
? {
...p,
architectureDocPath: result.docPath,
architectureLastUpdated: now,
architectureLastScanSignature: result.scan.signature,
updatedAt: now,
}
: p);
await this._putChronicleProjects(next);
await this._sendArchitectureStatus();
// 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({
type: 'architectureRefreshResult',
value: {
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({
type: 'architectureRefreshFailed',
value: { reason: '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 norm = (p: string | undefined) => (p || '').replace(/[\\/]+$/, '').toLowerCase();
const active = this._getActiveChronicleProject();
const projects = this._getChronicleProjects();
// 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 && norm(resolved) !== norm(wsRoot);
if (!isNestedHit && active) {
return active;
}
const workspaceRoot = isNestedHit ? resolved : wsRoot;
if (active && active.projectRoot && norm(active.projectRoot) === norm(workspaceRoot)) {
return active;
}
// Case 2: another chronicle project matches → switch active to it
const matching = projects.find((p) => norm(p.projectRoot) === norm(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 projectName = path.basename(workspaceRoot) || 'Current Project';
const projectId = this._slugify(projectName);
const now = new Date().toISOString();
const profile: ProjectProfile = {
projectId,
projectName,
projectRoot: workspaceRoot,
recordRoot: path.join(workspaceRoot, 'docs', 'records', projectName),
description: 'Auto-detected from workspace folder.',
corePurpose: '',
detailLevel: 'standard',
createdAt: now,
updatedAt: now,
};
const nextProjects = projects.filter((p) => p.projectId !== projectId).concat(profile);
await this._putChronicleProjects(nextProjects);
await this._context.globalState.update(
SidebarChatProvider.activeChronicleProjectStateKey,
projectId,
);
logInfo('architecture: registered new project from workspace.', {
projectId, projectRoot: workspaceRoot,
});
return profile;
}
/**
* Build the `projectArchitectureContext` string for the active prompt.
* Returns empty string when auto-attach is off or the doc is missing —
* agent.ts then treats it as "no block" and emits nothing extra.
*/
_buildProjectArchitectureContext(): string {
const p = this._getActiveChronicleProject();
if (!p || p.architectureAutoAttach === false || !p.architectureDocPath) return '';
if (!fs.existsSync(p.architectureDocPath)) return '';
return formatArchitectureContextForPrompt({
projectName: p.projectName,
docPath: p.architectureDocPath,
// Pass the project root so the `Source:` header in the prompt is
// workspace-relative — keeps the prompt portable across machines.
projectRoot: p.projectRoot,
lastUpdated: p.architectureLastUpdated,
});
}
/**
* 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({ type: 'architectureStatus', value: { active: false } });
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.
}
}
const fullyActive = hasDoc && !wasDetached;
if (fullyActive) {
this._view.webview.postMessage({
type: 'architectureStatus',
value: {
active: true,
projectId: p.projectId,
projectName: p.projectName,
docPath: p.architectureDocPath,
lastUpdated: p.architectureLastUpdated || '',
autoUpdate: p.architectureAutoUpdate !== false,
},
});
// Re-register the watcher in case it was disposed (e.g. workspace switch).
this._registerArchitectureWatcher(p);
} else {
// Inactive but attachable: surface the project name + an Attach hook.
this._view.webview.postMessage({
type: 'architectureStatus',
value: {
active: false,
canAttach: !!p.projectRoot,
projectId: p.projectId,
projectName: p.projectName,
// Distinguishes "never activated" from "detached" so the
// chip can choose the right label ("Activate" vs "Re-attach").
detached: wasDetached,
},
});
}
}
// ─── 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._companyAbort) return false;
this._companyAbort.abort();
// 승인 게이트 대기 중인 모든 stage를 'abort'로 해제. 안 하면 dispatcher가
// 영원히 await 상태로 남아 turn이 절대 종료 안 됨.
for (const resolve of this._pendingApprovals.values()) {
try { resolve({ kind: 'abort' }); } catch { /* noop */ }
}
this._pendingApprovals.clear();
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._lastCompanyTurnSummary = undefined;
}
/** Read accessor for the intent classifier. May return undefined on cold start. */
getLastCompanyTurnSummary() {
return this._lastCompanyTurnSummary;
}
/**
* 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 { analyzeIntent, readCompanyState, resolveActivePipeline, listActiveAgentsByCategory } =
await import('./features/company');
const { AIService } = await import('./core/services');
const cfg = getConfig();
const state = readCompanyState(this._context);
const activePipeline = resolveActivePipeline(state);
// 활성 직군 — 분석기가 "이 회사가 어떤 일을 할 수 있나"를 알아야
// goal/format을 그 능력에 맞춰 추출할 수 있다.
const byCat = listActiveAgentsByCategory(state);
const availableRoleCategories = Object.entries(byCat)
.filter(([, list]) => list.length > 0)
.map(([cat]) => cat);
// Pixel Office: 분석 시작 표시 (LLM 콜 직전).
try { this.pixelOfficeOnAlignmentStart(opts.userPrompt); } catch { /* noop */ }
const analysis = await analyzeIntent(
new AIService(),
{
userOriginalPrompt: opts.userPrompt,
previousAnswers: opts.previousAnswers,
previousContract: opts.previousContract,
activePipelineName: activePipeline?.name,
availableRoleCategories,
},
// 분류기와 같은 작은 모델을 재사용 — 이 단계도 빠르고 가벼워야 함.
{ model: cfg.companyIntentClassifierModel || cfg.defaultModel },
);
const contract = analysis.contract;
const mode = opts.mode;
const reachedLimit = opts.roundsAsked >= opts.roundsLimit;
// 자동 진행 조건: smart 모드 + high confidence + open question 없음.
// strict 모드면 절대 자동 진행 안 함 — 항상 사용자 확인.
const canAutoProceed = mode === 'smart'
&& contract.confidence === 'high'
&& contract.openQuestions.length === 0;
if (canAutoProceed) {
// contract 한 줄 안내 후 곧장 pipeline. 사용자 friction 최소.
this._view?.webview.postMessage({
type: 'companyAlignmentCard',
value: {
kind: 'auto-proceed',
contract,
},
});
try { this.pixelOfficeOnAlignmentResult('auto-proceed', contract); } catch { /* noop */ }
this._pendingAlignment = undefined;
await this._runCompanyTurn(opts.userPrompt, undefined, opts.pipelineIdOverride, contract);
return;
}
// 그 외 — 카드 표시 + 사용자 응답 대기. 라운드 한도 도달했거나
// openQuestions가 비어 있으면 "확인" 카드(질문 없음, 진행/취소 버튼만).
const askMode = reachedLimit || contract.openQuestions.length === 0
? 'confirm' : 'questions';
this._view?.webview.postMessage({
type: 'companyAlignmentCard',
value: {
kind: askMode,
contract,
roundsAsked: opts.roundsAsked,
roundsLimit: opts.roundsLimit,
},
});
try { this.pixelOfficeOnAlignmentResult(askMode, contract); } catch { /* noop */ }
this._pendingAlignment = {
userOriginalPrompt: opts.userPrompt,
contract,
roundsAsked: opts.roundsAsked,
pipelineIdOverride: opts.pipelineIdOverride,
};
// streamEnd 보내야 채팅 input 잠금이 풀려 사용자가 답변을 칠 수 있음.
// 평소 1인 기업 turn은 _runCompanyTurn finally에서 보내지만, alignment는
// dispatcher를 안 거치고 사용자 입력으로 unlock해야 하므로 명시적으로 push.
this._view?.webview.postMessage({ type: 'streamEnd' });
void this._sendReadyStatus();
}
/**
* 사용자가 alignment 카드 상태에서 채팅 입력(답변)을 보낸 경우 호출.
* 답변을 contract에 합쳐 분석기 재호출, 라운드를 한 칸 늘림.
*/
async _handleAlignmentAnswer(userMessage: string): Promise<void> {
const pending = this._pendingAlignment;
if (!pending) return;
const cfg = getConfig();
const mode = (cfg.companyIntentAlignmentMode === 'strict') ? 'strict' : 'smart';
const roundsLimit = Math.max(1, Math.min(5, cfg.companyIntentAlignmentMaxRounds ?? 3));
// 사용자 답변을 미해결 질문들에 일괄 매핑 — 작은 모델이 자연어로 통째로
// 답하기 마련이라 질문별로 분리 안 함. 그냥 "이번 라운드 사용자 추가
// 답변" 한 덩어리로 넣어주면 분석기가 다음 라운드에 그걸 보고 알아서
// 갱신한다.
const compositeAnswer = userMessage.trim();
const updatedAnswers = [
...pending.contract.answeredQuestions,
{ q: pending.contract.openQuestions.join(' / ') || '(추가 정보 요청)', a: compositeAnswer },
];
// 슬롯 비워두고 alignment 다시 돌림 — 새 결과가 다시 _pendingAlignment를
// 채울 것이고, 자동 진행 조건 충족 시 pipeline까지 갈 수도 있다.
this._pendingAlignment = undefined;
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._pendingAlignment;
if (!pending) return;
this._pendingAlignment = undefined;
await this._runCompanyTurn(
pending.userOriginalPrompt,
undefined,
pending.pipelineIdOverride,
pending.contract,
);
}
/**
* 사용자가 카드의 "🛑 취소"를 누르거나 alignment를 그만두려 할 때.
* 슬롯 비우고 webview에도 streamEnd push해서 input 풀어줌.
*/
cancelPendingAlignment(): void {
if (!this._pendingAlignment) return;
this._pendingAlignment = undefined;
this._view?.webview.postMessage({ type: 'companyAlignmentCard', value: { kind: 'cancelled' } });
this._view?.webview.postMessage({ type: 'streamEnd' });
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 },
});
await this._handlePrompt(originalData);
await this._autoWriteChronicleAfterPrompt();
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 {
const resolve = this._pendingApprovals.get(stageId);
if (!resolve) return false;
this._pendingApprovals.delete(stageId);
try { resolve(decision); } catch { /* noop */ }
return true;
}
/**
* 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;
const state = readCompanyState(this._context);
// 직군별 활성 에이전트도 같이 — 파이프라인 에디터가 "직군 → 담당자"
// cascading dropdown을 채울 때 이 페이로드만 보고 그릴 수 있게.
const { listActiveAgentsByCategory } = await import('./features/company');
const byCategory = listActiveAgentsByCategory(state);
// CompanyAgentDef를 통째로 보내는 대신 UI에 필요한 필드만 추려서.
const slimByCategory: Record<string, Array<{ id: string; name: string; emoji: string }>> = {};
for (const [cat, defs] of Object.entries(byCategory)) {
slimByCategory[cat] = defs.map((d) => ({ id: d.id, name: d.name, emoji: d.emoji }));
}
// 템플릿 카탈로그 — 가벼운 메타데이터만 (stages는 stamp 시점에 한 번
// 더 요청). UI는 dropdown 옵션 텍스트만 필요.
const templates = PIPELINE_TEMPLATES.map((t) => ({
templateId: t.templateId,
name: t.name,
description: t.description,
stageCount: t.stages.length,
suggestedPipelineId: t.suggestedPipelineId,
suggestedPipelineName: t.suggestedPipelineName,
}));
this._view.webview.postMessage({
type: 'companyPipelines',
value: {
pipelines: state.pipelines ?? {},
activePipelineId: state.activePipelineId ?? null,
roleCategoryLabels: ROLE_CATEGORY_LABELS,
roleCategoryOrder: ROLE_CATEGORY_ORDER,
activeAgentsByCategory: slimByCategory,
templates,
},
});
}
/** Send the chip state (active flag + agent count + name) to the webview. */
async _sendCompanyStatus(): Promise<void> {
if (!this._view) return;
const state = readCompanyState(this._context);
this._view.webview.postMessage({
type: 'companyStatus',
value: {
enabled: state.enabled,
companyName: state.companyName,
summary: summarizeForChip(state),
activeAgentIds: state.activeAgentIds,
modelOverrides: state.modelOverrides,
// 스코프 프리셋 (기획/개발/풀) 선택 상태 — sidebar segmented control 이 사용.
// pipeline 이 stamp 됐을 때 그 id 가 activePipelineId 에 저장됨. SCOPE_PRESETS
// 의 templateId 와 1:1 매칭 (현재 plan-only / dev-only / full-product-dev).
activePipelineId: state.activePipelineId ?? null,
},
});
}
/**
* 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 cfg = getConfig();
const globalWeight = cfg.knowledgeMixSecondBrainWeight ?? 50;
// Built-ins first (insertion order from agents.ts), then user-added
// customs in their own order. `custom: true` lets the UI render a
// delete button differently for user-added entries.
const hiddenSet = new Set(state.hiddenBuiltinIds ?? []);
const builtinIds = COMPANY_AGENT_ORDER.filter((id) => !!COMPANY_AGENTS[id] && !hiddenSet.has(id));
const customIds = state.customAgents ? Object.keys(state.customAgents) : [];
const orderedIds = [...builtinIds, ...customIds];
// 파이프라인에서 사용 중인 에이전트 id별로 사용처(파이프라인 이름) 목록을 미리 계산.
// 카드의 삭제 버튼 비활성화 + tooltip에 사용 중인 파이프라인 이름을 노출하는 데 사용.
const pipelineUsage: Record<string, string[]> = {};
for (const p of Object.values(state.pipelines ?? {})) {
for (const s of p.stages) {
if (!s.agentId) continue;
(pipelineUsage[s.agentId] = pipelineUsage[s.agentId] ?? []).push(p.name || p.id);
}
}
const renderEntry = (id: string) => {
const builtin = COMPANY_AGENTS[id];
const custom = state.customAgents?.[id];
const baseDef = builtin ?? custom;
if (!baseDef) return null;
// 직군 override 적용된 effective def. 카드의 드롭다운이 옳은 선택값을
// 보이려면 override 결과를 보내야 한다.
const effective = resolveAgent(state, id) ?? baseDef;
const isCustom = !builtin;
const override = state.promptOverrides[id] || {};
const kmOverride = state.knowledgeMixOverrides[id];
const hasKmOverride = typeof kmOverride === 'number';
const roleOverride = state.roleCategoryOverrides?.[id];
const displayOv = state.displayOverrides?.[id] || {};
return {
id,
name: effective.name,
role: effective.role,
emoji: effective.emoji,
color: effective.color,
alwaysOn: !!effective.alwaysOn,
custom: isCustom,
active: id === 'ceo' || state.activeAgentIds.includes(id),
modelOverride: state.modelOverrides[id] || '',
defaultTagline: baseDef.tagline,
defaultSpecialty: baseDef.specialty,
defaultPersona: baseDef.persona || '',
tagline: override.tagline || baseDef.tagline,
specialty: override.specialty || baseDef.specialty,
persona: override.persona || baseDef.persona || '',
personaOverridden: !!override.persona,
specialtyOverridden: !!override.specialty,
taglineOverridden: !!override.tagline,
// 디스플레이 override — Edit 폼이 default vs current를 비교해
// dirty 표시할 수 있도록 baseDef의 원본 값도 같이 보낸다.
defaultName: baseDef.name,
defaultRole: baseDef.role,
defaultEmoji: baseDef.emoji,
defaultColor: baseDef.color,
nameOverridden: !!displayOv.name,
roleOverridden: !!displayOv.role,
emojiOverridden: !!displayOv.emoji,
colorOverridden: !!displayOv.color,
// 직군: effective(override 반영) + def 기본값 + override 플래그
roleCategory: effective.roleCategory,
defaultRoleCategory: baseDef.roleCategory,
roleCategoryOverridden: !!roleOverride && roleOverride !== baseDef.roleCategory,
knowledgeMixOverride: hasKmOverride ? kmOverride : null,
effectiveKnowledgeMixWeight: hasKmOverride ? kmOverride : globalWeight,
// 파이프라인 사용 현황. 비어 있으면 삭제 가능, 한 개라도 있으면 UI에서
// 삭제 버튼을 disabled로 처리하고 tooltip에 사용 중인 파이프라인을 노출.
usedInPipelines: pipelineUsage[id] ? [...new Set(pipelineUsage[id])] : [],
};
};
const agents = orderedIds.map(renderEntry).filter((x): x is NonNullable<ReturnType<typeof renderEntry>> => !!x);
// 숨김 처리된 빌트인 — 사용자에게 "복원" 옵션을 제공하기 위해 한 줄 메타로 같이 보낸다.
const hiddenBuiltins = (state.hiddenBuiltinIds ?? [])
.map((id) => {
const def = COMPANY_AGENTS[id];
if (!def) return null;
return { id, name: def.name, role: def.role, emoji: def.emoji };
})
.filter((x): x is { id: string; name: string; role: string; emoji: string } => !!x);
this._view.webview.postMessage({
type: 'companyAgents',
value: {
companyName: state.companyName,
globalKnowledgeMixWeight: globalWeight,
agents,
hiddenBuiltins,
// 직군 라벨 사전 + 표시 순서. 웹뷰는 enum 값을 모르므로
// 백엔드가 정한 라벨/순서를 같이 보내 UI 일관성을 유지.
roleCategoryLabels: ROLE_CATEGORY_LABELS,
roleCategoryOrder: ROLE_CATEGORY_ORDER,
},
});
}
/**
* 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 cfg = getConfig();
const ai = new AIService();
// plan-ready / report-done 이벤트를 가로채 직전 turn 요약을 캐시에
// 저장. 다음 메시지의 intent classifier가 "이건 followup인가?" 판정에
// 사용한다. plan-ready로 brief를, report-done으로 보고서 끝부분을
// 잡아낸다 — turn이 중간 abort되면 plan만 남고 reportTail은 비어
// 있게 되는데, 그 상태로도 followup 매칭에는 충분히 도움된다.
let stagingBrief = '';
const emit = (event: CompanyTurnEvent) => {
this._view?.webview.postMessage({ type: 'companyTurnUpdate', value: event });
if (event.phase === 'plan-ready') {
stagingBrief = event.plan?.brief || '';
} else if (event.phase === 'report-done') {
const tail = (event.report || '').trim().slice(-600);
this._lastCompanyTurnSummary = {
brief: stagingBrief,
reportTail: tail,
finishedAt: Date.now(),
};
}
// Pixel Office hub — 같은 이벤트를 *추가로* read-only 변환. dispatcher
// 흐름엔 영향 없음(post 호출만 함).
try { this.pixelOfficeOnTurnEvent(event); } catch { /* never break the turn */ }
};
// 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 = new AbortController();
this._companyAbort = abort;
try {
const deps: DispatcherDeps = {
context: this._context,
ai,
defaultModel: cfg.defaultModel || 'gemma4:e2b',
// Knowledge Mix wiring so company specialists *also* see the
// user's Second Brain — same global default + per-agent
// override semantics the chat path uses. Without this the
// Knowledge Mix slider had no effect on company turns.
globalKnowledgeMixWeight: cfg.knowledgeMixSecondBrainWeight ?? 50,
brainFileBaseline: cfg.memoryLongTermFiles ?? 6,
// Hand the dispatcher a thunk into ConnectAI's action-tag
// executor so specialist outputs like `<create_file>` actually
// hit disk. Without this, agents would *claim* to create
// files while nothing happened — the exact bug we just fixed.
executeActionTags: (text: string) => this._agent.executeActionTagsOnText(text),
signal: abort.signal,
onEvent: emit,
// 승인 게이트 bridge — dispatcher가 호출하면 Promise를 만들어
// resolver를 _pendingApprovals에 보관 후 await. 사용자가 카드 버튼을
// 누르면 chatHandlers가 resolveApprovalGate(stageId, decision)을 호출
// 하고 그 resolve가 이 await을 풀어준다.
awaitApproval: ({ stageId }: { stageId: string; stageLabel: string }) =>
new Promise<ApprovalDecision>((resolve) => {
if (abort.signal.aborted) {
resolve({ kind: 'abort' });
return;
}
this._pendingApprovals.set(stageId, resolve);
}),
// 이번 turn 한정 pipeline override. chatHandlers가 의도 분류기
// 추천 또는 사용자 키워드 detection 결과를 채워서 넘긴다.
pipelineIdOverride,
// Intent Alignment에서 도출된 사용자 합의 contract. Phase D에서
// planner / specialist / reviewer prompt 모두에 주입됨. 없으면
// legacy 동작 (alignment 단계 자체를 거치지 않은 경우 또는 사용자가
// off 모드로 설정한 경우).
requirementContract,
};
// 일반 새 turn vs 재개 turn 분기. 재개 시 _resume.json에서 plan + cursor를
// 복원해 그 다음 stage부터 dispatch가 이어진다. resumeCompanyTurn이 null을
// 돌려주면(파일 없음·이미 완료 등) 사용자에게 알리고 종료.
if (resumeTimestamp) {
const result = await resumeCompanyTurn(resumeTimestamp, deps);
if (!result) {
this._view?.webview.postMessage({
type: 'error',
value: '재개 가능한 세션 정보를 찾지 못했습니다 (이미 완료되었거나 파일이 손상되었을 수 있습니다).',
});
}
} else {
await runCompanyTurn(userPrompt, deps);
}
} catch (e: any) {
logError('company.runTurn: unexpected failure.', { error: e?.message ?? String(e) });
this._view?.webview.postMessage({
type: 'error',
value: `1인 기업 모드 실행 실패: ${e?.message ?? e}`,
});
} finally {
if (this._companyAbort === abort) this._companyAbort = undefined;
// 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._view?.webview.postMessage({ type: 'streamEnd' });
void this._sendReadyStatus();
// turn이 끝났으면(완료든 abort든) resume 가능 세션 목록을 새로 푸시 —
// 방금 abort된 세션이 곧장 목록에 떠야 하므로.
void this._sendCompanyResumable();
}
}
/**
* Webview에 "이어서 진행할 수 있는 세션" 목록을 push. 관리 패널이 열릴 때와 turn이
* 끝날 때마다 호출됨. 빈 목록도 그대로 보내서 UI가 섹션을 자동으로 숨길 수 있게 함.
*/
async _sendCompanyResumable(): Promise<void> {
if (!this._view) return;
try {
const items = listResumableSessions(this._context).map((s) => ({
timestamp: s.timestamp,
userPrompt: s.userPrompt.slice(0, 200),
pipelineId: s.pipelineId,
pipelineName: s.pipelineId
? (readCompanyState(this._context).pipelines?.[s.pipelineId]?.name ?? s.pipelineId)
: null,
completedCount: s.agentOutputs.length,
totalCount: s.plan.tasks.length,
status: s.status,
abortReason: s.abortReason ?? '',
lastUpdatedAt: s.lastUpdatedAt,
startedAt: s.startedAt,
}));
this._view.webview.postMessage({
type: 'companyResumable',
value: { items },
});
} 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}`);
}
}
/**
* Register a debounced watcher over the project root. Only structural
* changes regen the doc — the signature hash decides whether to write.
* Files inside node_modules / out / dist are filtered by the glob to keep
* the noise floor sane during normal development.
*/
private _registerArchitectureWatcher(profile: ProjectProfile): void {
if (!profile.projectRoot) return;
if (this._archWatchedProjectId === profile.projectId && this._archWatcher) return;
this._disposeArchitectureWatcher();
if (profile.architectureAutoUpdate === false) {
this._archWatchedProjectId = profile.projectId;
return;
}
const pattern = new vscode.RelativePattern(
profile.projectRoot,
'{package.json,tsconfig.json,README.md,src/**,media/**,docs/**,tests/**,core_py/**,lib/**,app/**}'
);
const watcher = vscode.workspace.createFileSystemWatcher(pattern);
const onChange = () => this._scheduleArchitectureRefresh();
watcher.onDidCreate(onChange);
watcher.onDidDelete(onChange);
watcher.onDidChange(onChange);
this._archWatcher = watcher;
this._archWatchedProjectId = profile.projectId;
logInfo('architecture: watcher registered.', { projectId: profile.projectId, root: profile.projectRoot });
}
private _disposeArchitectureWatcher(): void {
try { this._archWatcher?.dispose(); } catch { /* noop */ }
this._archWatcher = undefined;
if (this._archWatchDebounce) { clearTimeout(this._archWatchDebounce); this._archWatchDebounce = undefined; }
this._archWatchedProjectId = undefined;
}
private _scheduleArchitectureRefresh(): void {
if (this._archWatchDebounce) clearTimeout(this._archWatchDebounce);
// 6 s debounce: long enough that a "save file" burst settles into one
// regen, short enough that the chip's "updated 2m ago" badge stays
// believable.
this._archWatchDebounce = setTimeout(async () => {
const profile = this._getActiveChronicleProject();
if (!profile || !profile.projectRoot || profile.architectureAutoUpdate === false) return;
try {
// Cheap signature check first — most file events don't change shape.
const scan = scanProject(profile.projectRoot, profile.projectName);
if (scan.signature === profile.architectureLastScanSignature) return;
await this._refreshArchitecture();
} catch (e: any) {
logError('architecture: auto-refresh failed.', { error: e?.message ?? String(e) });
}
}, 6000);
}
_getActiveChronicleProject(): ProjectProfile | null {
const projects = this._getChronicleProjects();
if (projects.length === 0) return null;
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._getChronicleProjects();
const active = this._getActiveChronicleProject();
this._view.webview.postMessage({
type: 'chronicleProjects',
value: {
activeProjectId: active?.projectId || '',
projects: projects.map(project => ({
id: project.projectId,
name: project.projectName,
root: project.projectRoot || '',
recordRoot: project.recordRoot,
description: project.description || ''
}))
}
});
}
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._getChronicleProjects();
const idBase = this._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._putChronicleProjects(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._getChronicleProjects().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({ type: 'chronicleRecords', value: [] });
return;
}
try {
const records = this._chronicle.listRecords(profile).map(record => ({
section: record.section,
fileName: record.fileName,
path: record.filePath,
relativePath: record.relativePath,
updatedAt: record.updatedAt
}));
this._view.webview.postMessage({ type: 'chronicleRecords', value: 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);
}
async _writeChroniclePlanningFromCurrentChat() {
const profile = this._getActiveChronicleProject();
if (!profile) {
vscode.window.showWarningMessage('Select or create a Designer project first.');
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 featureName = await vscode.window.showInputBox({
prompt: 'Feature name for the planning document',
value: this._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: this._summarizeTextForWiki(latestAssistant || latestUser),
userIntent: this._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
});
this._chronicle.appendTimeline(profile, [`Planning record created: ${result.relativePath}`], createdAt);
vscode.window.showInformationMessage(`Chronicle planning saved: ${result.relativePath}`);
await this._sendChronicleRecords();
this.injectSystemMessage(`**[Chronicle Planning Saved]** \`${result.filePath}\``);
} catch (err: any) {
vscode.window.showErrorMessage(`Failed to write planning record: ${err.message}`);
}
}
async _writeChronicleDiscussionFromCurrentChat() {
const profile = this._getActiveChronicleProject();
if (!profile) {
vscode.window.showWarningMessage('Select or create a Designer project first.');
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 title = await vscode.window.showInputBox({
prompt: 'Discussion title',
value: this._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: this._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: [
this._summarizeTextForWiki(latestAssistant || latestUser || 'No discussion detail captured yet.')
],
decisions: [],
createdAt
});
this._chronicle.appendTimeline(profile, [`Discussion record created: ${result.relativePath}`], createdAt);
vscode.window.showInformationMessage(`Chronicle discussion saved: ${result.relativePath}`);
await this._sendChronicleRecords();
this.injectSystemMessage(`**[Chronicle Discussion Saved]** \`${result.filePath}\``);
} catch (err: any) {
vscode.window.showErrorMessage(`Failed to write discussion record: ${err.message}`);
}
}
async _writeChronicleDecisionFromInput() {
const profile = this._getActiveChronicleProject();
if (!profile) {
vscode.window.showWarningMessage('Select or create a Designer project first.');
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);
this._chronicle.appendTimeline(profile, [`Decision record created: ${result.relativePath}`], createdAt);
vscode.window.showInformationMessage(`Chronicle decision saved: ${result.relativePath}`);
await this._sendChronicleRecords();
this.injectSystemMessage(`**[Chronicle Decision Saved]** \`${result.filePath}\``);
} catch (err: any) {
vscode.window.showErrorMessage(`Failed to write decision record: ${err.message}`);
}
}
async _writeChronicleDevelopmentFromCurrentChat() {
const profile = this._getActiveChronicleProject();
if (!profile) {
vscode.window.showWarningMessage('Select or create a Designer project first.');
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 featureName = await vscode.window.showInputBox({
prompt: 'Feature name for the development log',
value: this._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: this._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
});
this._chronicle.appendTimeline(profile, [`Development log created: ${result.relativePath}`], createdAt);
vscode.window.showInformationMessage(`Chronicle development log saved: ${result.relativePath}`);
await this._sendChronicleRecords();
this.injectSystemMessage(`**[Chronicle Development Saved]** \`${result.filePath}\``);
} catch (err: any) {
vscode.window.showErrorMessage(`Failed to write development record: ${err.message}`);
}
}
async _writeChronicleBugFromInput() {
const profile = this._getActiveChronicleProject();
if (!profile) {
vscode.window.showWarningMessage('Select or create a Designer project first.');
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);
this._chronicle.appendTimeline(profile, [`Bug record created: ${result.relativePath}`], createdAt);
vscode.window.showInformationMessage(`Chronicle bug record saved: ${result.relativePath}`);
await this._sendChronicleRecords();
this.injectSystemMessage(`**[Chronicle Bug Saved]** \`${result.filePath}\``);
} catch (err: any) {
vscode.window.showErrorMessage(`Failed to write bug record: ${err.message}`);
}
}
async _writeChronicleRetrospectiveFromInput() {
const profile = this._getActiveChronicleProject();
if (!profile) {
vscode.window.showWarningMessage('Select or create a Designer project first.');
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
});
this._chronicle.appendTimeline(profile, [`Retrospective created: ${result.relativePath}`], createdAt);
vscode.window.showInformationMessage(`Chronicle retrospective saved: ${result.relativePath}`);
await this._sendChronicleRecords();
this.injectSystemMessage(`**[Chronicle Retrospective Saved]** \`${result.filePath}\``);
} catch (err: any) {
vscode.window.showErrorMessage(`Failed to write retrospective record: ${err.message}`);
}
}
async _autoWriteChronicleAfterPrompt() {
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 = this._inferAutoChronicleRecordType(latestUser, latestAssistant);
if (!recordType) return;
const signature = [
profile.projectId,
recordType,
this._summarizeTextForWiki(latestUser).slice(0, 240),
this._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 = this._summarizeForTitle(latestUser || latestAssistant || 'Project Chronicle Auto Record');
const summary = this._summarizeTextForWiki(latestAssistant || latestUser);
let result;
if (recordType === 'bug') {
const bugNumber = this._chronicle.nextBugNumber(profile);
result = this._chronicle.writeBug(profile, {
title,
symptom: this._summarizeTextForWiki(latestUser || 'Issue was detected during the conversation.'),
cause: 'Captured automatically from the current conversation. Confirm root cause during follow-up review if needed.',
fix: summary,
prevention: 'Keep automatic records tied to the active project and verify the relevant test or reproduction path.',
createdAt
}, bugNumber);
} else if (recordType === 'planning') {
result = this._chronicle.writePlanning(profile, {
featureName: title,
purpose: 'Capture the current planning or architecture direction before implementation continues.',
background: summary,
userIntent: this._summarizeTextForWiki(latestUser),
sourceRequest: latestUser || 'No user request captured in the current chat.',
scope: ['Continue from the active project conversation.', 'Use the selected project record folder automatically.'],
outOfScope: ['Manual record type selection.', 'Blocking the user with record-writing prompts.'],
developmentDirection: summary,
dependencyStrategy: 'Prefer existing project modules and local Markdown records.',
expectedValue: 'Future work can resume with the latest project intent and reasoning preserved.',
successCriteria: ['The record is saved automatically after a meaningful project turn.', 'The record stays under the active project.'],
developerInstruction: 'Use this record as lightweight context for the next development or review pass.',
createdAt
});
} else if (recordType === 'decision') {
const adrNumber = this._chronicle.nextAdrNumber(profile);
result = this._chronicle.writeDecision(profile, {
title,
status: 'accepted',
context: this._summarizeTextForWiki(latestUser),
decision: summary,
reason: 'Captured automatically because the conversation contained decision-oriented language.',
alternatives: [],
consequences: ['Future prompts should treat this as project context unless the user changes direction.'],
createdAt
}, adrNumber);
} else if (recordType === 'development') {
result = this._chronicle.writeDevelopmentLog(profile, {
featureName: title,
purpose: 'Record the implementation or verification outcome from the current conversation.',
implementationSummary: summary,
architecture: 'Captured automatically from the assistant response and active project context.',
changedFiles: this._extractChangedFilesFromText(latestAssistant),
dependencyNotes: 'No new dependency note was captured automatically.',
bugs: [],
lessons: ['Automatic project records should be generated in the background when the turn contains durable project knowledge.'],
createdAt
});
} else {
result = this._chronicle.writeDiscussion(profile, {
title,
userRequest: this._summarizeTextForWiki(latestUser || 'No user request captured in the current chat.'),
interpretedIntent: 'Capture a meaningful project discussion automatically instead of requiring manual record selection.',
questions: [],
discussions: [summary],
decisions: [],
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 = this._extractLocalProjectPath(text);
if (!projectPath) return null;
const projects = this._getChronicleProjects();
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: this._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(text: string): string | null {
const match = text.match(/\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+/i);
if (!match) return null;
const candidate = match[0].replace(/[.,;:)\]]+$/, '');
try {
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
return candidate;
}
} catch {
return null;
}
return null;
}
_inferAutoChronicleRecordType(userText: string, assistantText: string): 'planning' | 'discussion' | 'decision' | 'development' | 'bug' | null {
const combined = `${userText}\n${assistantText}`;
if (!combined.trim()) return null;
if (/(기록하지마|저장하지마|no\s+record|do\s+not\s+record)/i.test(combined)) return null;
if (!/(프로젝트|코드|아키텍처|설계|개선|수정|구현|테스트|검증|이슈|문제|버그|오류|끊김|결정|방향|기록|지식|review|architecture|implement|fix|bug|issue|test|decision)/i.test(combined)) {
return null;
}
if (/(버그|오류|에러|이슈|문제|끊김|안\s*됨|실패|bug|error|issue|failed|failure)/i.test(userText)) {
return 'bug';
}
if (/(수정 완료|개선 완료|구현 완료|패치|테스트.*통과|검증.*완료|변경.*파일|compile|jest|tsc|passed|implemented|fixed)/i.test(assistantText)) {
return 'development';
}
if (/(결정|확정|채택|방향은|하기로|하지 않기로|decision|decide|accepted)/i.test(combined)) {
return 'decision';
}
if (/(계획|설계|아키텍처|조사|방향|로드맵|mvp|planning|architecture|roadmap|design)/i.test(userText)) {
return 'planning';
}
if (/(개선|수정|구현|테스트|검증|패킹|compile|jest|tsc|implement|fix|test|verify)/i.test(combined)) {
return 'development';
}
return 'discussion';
}
_extractChangedFilesFromText(text: string): string[] {
const files = new Set<string>();
for (const match of text.matchAll(/`([^`\n]+\.(?:ts|tsx|js|jsx|json|md|css|html|py|yml|yaml))`/gi)) {
files.add(match[1].trim());
}
return files.size > 0 ? Array.from(files).slice(0, 12) : ['No explicit changed file list was captured automatically.'];
}
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(): string {
// 1) Explicit config override (works on any OS — useful on Windows or for skills outside the workspace).
const configured = (vscode.workspace.getConfiguration('g1nation').get<string>('agentSkillsPath', '') || '').trim();
const expanded = configured.startsWith('~/') || configured === '~'
? path.join(os.homedir(), configured.slice(1).replace(/^[\\/]/, ''))
: configured;
if (expanded && path.isAbsolute(expanded)) {
if (!fs.existsSync(expanded)) {
try { fs.mkdirSync(expanded, { recursive: true }); } catch { /* fall through to workspace */ }
}
if (fs.existsSync(expanded)) return expanded;
}
// 2) Default: <workspace>/.agent/skills
const workspaceFolders = vscode.workspace.workspaceFolders;
if (workspaceFolders) {
const localPath = path.join(workspaceFolders[0].uri.fsPath, '.agent', 'skills');
if (!fs.existsSync(localPath)) {
fs.mkdirSync(localPath, { recursive: true });
}
return localPath;
}
return '';
}
async _sendAgentsList() {
if (!this._view) return;
const dir = this._getAgentsDir();
const agents = [];
if (dir && fs.existsSync(dir)) {
const files = fs.readdirSync(dir);
for (const f of files) {
if (f.endsWith('.md')) {
agents.push({ name: f.replace('.md', ''), path: path.join(dir, f) });
}
}
}
const lastPath = this._context.globalState.get<string>(SidebarChatProvider.lastAgentStateKey, 'none');
this._view.webview.postMessage({ type: 'agentsList', value: agents, selected: lastPath });
void this._sendReadyStatus();
}
async _handleProactiveSuggestion(context: string) {
if (!this._view) return;
let suggestion = '';
switch (context) {
case 'settings_exploration':
suggestion = '💡 **Tip:** 모델 설정을 최적화하여 답변 속도를 2배 이상 높일 수 있습니다. 설정에서 `Max Context Size`를 조정해보세요!';
break;
case 'brain_sync_exploration':
suggestion = '🧠 **Knowledge Sync:** 최근에 수정한 파일이 지식 베이스에 반영되지 않았나요? 지금 동기화 버튼을 눌러 최신 정보를 업데이트하세요.';
break;
case 'agent_selection_exploration':
suggestion = '🤖 **Agent Skills:** 특정 언어나 프레임워크에 특화된 에이전트 스킬을 선택하면 더 정확한 코드를 생성할 수 있습니다.';
break;
default:
suggestion = '💡 새로운 기능을 발견하셨나요? 궁금한 점이 있다면 언제든 물어보세요!';
}
this._view.webview.postMessage({ type: 'streamStart' });
this._view.webview.postMessage({ type: 'streamChunk', value: `\n\n> [!TIP]\n> ${suggestion}\n` });
this._view.webview.postMessage({ type: 'streamEnd' });
}
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 safeName = name.trim().replace(/[^a-zA-Z0-9_\-\u3131-\uD79D가-힣]/g, '_');
if (!safeName) return;
const dir = this._getAgentsDir();
if (!dir) {
vscode.window.showErrorMessage('Agent directory could not be determined.');
return;
}
const filePath = path.join(dir, `${safeName}.md`);
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, `# Agent Persona: ${safeName}\n\nAdd your instructions here...\n`, 'utf8');
}
await openInEditorGroup(filePath);
await this._sendAgentsList();
}
async _sendAgentContent(agentPath: string) {
if (!this._view || !agentPath || agentPath === 'none') return;
if (fs.existsSync(agentPath)) {
const content = fs.readFileSync(agentPath, 'utf8');
const negativePrompt = this._context.globalState.get<string>(`negativePrompt:${agentPath}`, '');
this._view.webview.postMessage({
type: 'agentContent',
value: content,
negativePrompt: negativePrompt
});
await this._context.globalState.update(SidebarChatProvider.lastAgentStateKey, agentPath);
}
}
async _updateAgent(agentPath: string, content: string, negativePrompt?: string) {
if (!agentPath || agentPath === 'none') return;
try {
fs.writeFileSync(agentPath, content, 'utf8');
if (negativePrompt !== undefined) {
await this._context.globalState.update(`negativePrompt:${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 {
const agentsDir = path.resolve(this._getAgentsDir());
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._context.globalState.update(SidebarChatProvider.lastAgentStateKey, '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;
fs.unlinkSync(targetPath);
await this._context.globalState.update(SidebarChatProvider.lastAgentStateKey, '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, internet, 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;
let agentSkillContext = undefined;
// Per-agent model override: if the active agent has a pinned model in the
// knowledge map, it wins over the model the webview just sent. Falls back
// to the incoming `model` (which is the global default the user picked).
let effectiveModel: string = typeof model === 'string' ? model : '';
if (agentFile && agentFile !== 'none' && fs.existsSync(agentFile)) {
const fileContent = fs.readFileSync(agentFile, 'utf8');
// Guard: a freshly-created agent still has only the placeholder template
// ("# Agent Persona: …\n\nAdd your instructions here…"). Treating that as a real
// agent prompt just confuses the model — fall back to normal mode and tell the user.
const body = fileContent.replace(/^?#\s*Agent\s*Persona\s*:.*$/im, '').trim();
const isPlaceholder = !body || /^add your instructions here/i.test(body);
if (isPlaceholder) {
logInfo('Selected agent has no real instructions — running without agent mode.', { agentFile });
this._view?.webview.postMessage({ type: 'lmStudioError', value: '선택한 에이전트에 내용이 없습니다 — 에이전트 프롬프트를 작성한 뒤 다시 시도하세요. (이번 응답은 에이전트 없이 처리합니다)' });
} else {
agentSkillContext = fileContent;
// Merge in any external skill .md files the user has mapped to this agent. We concatenate
// into the same agentSkillContext blob so the rest of the pipeline (agent.ts, agent-mode
// override) treats them identically to the agent's own .md — no further changes needed.
try {
const entry = getOrCreateAgentEntry(agentFile);
const bundle = loadExternalSkills(entry.skillFolders);
const block = formatSkillsAsPromptBlock(bundle);
if (block) {
agentSkillContext = `${agentSkillContext.trim()}\n\n${block}`;
}
// Apply the per-agent model override, if any.
const pinned = entry.model?.trim();
if (pinned && pinned !== effectiveModel) {
logInfo('Per-agent model override applied.', {
agent: entry.name,
requested: effectiveModel,
pinned,
});
effectiveModel = pinned;
// Inform the webview so its UI can reflect the model that's actually in use.
this._view?.webview.postMessage({
type: 'agentModelOverride',
value: { agent: entry.name, model: pinned },
});
}
} catch (e: any) {
logError('External skill load failed.', { error: e?.message || String(e) });
}
}
}
const designerContext = designerGuard !== false ? this._buildDesignerGuardContext() : undefined;
// Project Architecture activation (Feature 2): if the user just said
// "나 X 프로젝트 진행할 거야" / mentioned an absolute path / etc., switch
// to that project's mode before assembling the prompt. Best-effort:
// failures here never block the actual answer.
if (typeof value === 'string' && value.trim().length > 0) {
try {
await this._tryActivateArchitectureFromText(value);
} catch (e: any) {
logError('architecture: intent detection failed.', { error: e?.message ?? String(e) });
}
}
// 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();
// [File Processing v2] 파일 타입별 분류 처리
let processedPrompt = value || '';
let imageFiles: any[] | undefined = undefined;
if (files && Array.isArray(files) && files.length > 0) {
const textContents: string[] = [];
const images: any[] = [];
for (const file of files) {
const name = file.name?.toLowerCase() || '';
const type = file.type || '';
if (name.endsWith('.pdf') || type === 'application/pdf') {
// PDF: 서버사이드 텍스트 추출 (pdf-parse v2 API) + Vision 폴백
let pdfTextOk = false;
try {
const { PDFParse } = require('pdf-parse');
const rawBuffer = Buffer.from(file.data, 'base64');
const uint8 = new Uint8Array(rawBuffer.buffer, rawBuffer.byteOffset, rawBuffer.byteLength);
const parser = new PDFParse(uint8);
await parser.load();
const textResult = await parser.getText();
const extracted = (typeof textResult === 'string' ? textResult : (textResult?.text || '')).trim();
const cleanText = extracted.replace(/\n*-- \d+ of \d+ --\n*/g, '\n').trim();
if (cleanText && cleanText.length > 30) {
textContents.push(`\n[PDF: ${file.name}]\n${cleanText}`);
logInfo(`PDF text extracted successfully.`, { fileName: file.name, chars: cleanText.length });
pdfTextOk = true;
}
// [Vision Fallback] 텍스트가 비어있으면 페이지 이미지 추출 -> Vision 모델에 전달
if (!pdfTextOk) {
logInfo(`PDF has no text layer. Extracting page screenshots for vision analysis.`, { fileName: file.name });
const screenshots = await parser.getScreenshot({ page: 1 });
if (screenshots?.pages && screenshots.pages.length > 0) {
const maxPages = Math.min(screenshots.pages.length, 8); // 메모리 보호: 최대 8페이지
for (let i = 0; i < maxPages; i++) {
const page = screenshots.pages[i];
if (page?.data) {
const pageBase64 = Buffer.from(page.data).toString('base64');
images.push({
name: `${file.name}_page${i + 1}.png`,
type: 'image/png',
data: pageBase64
});
}
}
textContents.push(`\n[PDF: ${file.name}]\n(이미지 기반 PDF ${screenshots.total}페이지 중 ${maxPages}페이지를 이미지로 추출하여 Vision 분석합니다. 각 페이지 이미지를 참조하여 문서의 내용을 상세히 분석하고 한국어로 정리하세요.)`);
logInfo(`PDF vision fallback: extracted ${maxPages} page screenshots.`, { fileName: file.name, totalPages: screenshots.total });
pdfTextOk = true; // Vision 분석으로 처리 완료
}
}
} catch (pdfError: any) {
logError(`PDF processing failed.`, { fileName: file.name, error: pdfError?.message || String(pdfError) });
}
// 최종 폴백: 텍스트도 없고 이미지 추출도 실패한 경우
if (!pdfTextOk) {
textContents.push(`\n[PDF: ${file.name}]\n(PDF 분석에 실패했습니다. 이 파일을 텍스트로 변환하여 다시 시도해주세요.)`);
}
} else if (
type.startsWith('text/') ||
type === 'application/json' ||
/\.(txt|md|csv|json|js|ts|jsx|tsx|html|css|py|java|rs|go|yaml|yml|xml|toml|sql|sh)$/i.test(name)
) {
// 텍스트 파일: base64 디코딩
try {
const decoded = Buffer.from(file.data, 'base64').toString('utf-8');
textContents.push(`\n[FILE: ${file.name}]\n\`\`\`\n${decoded}\n\`\`\``);
} catch (decodeError: any) {
logError(`Text file decode failed.`, { fileName: file.name, error: decodeError?.message });
textContents.push(`\n[FILE: ${file.name}]\n(디코딩 오류)`);
}
} else if (type.startsWith('image/')) {
// 이미지: 기존 vision 방식 유지
images.push(file);
} else {
// 미지원 타입: 파일명만 기록
textContents.push(`\n[ATTACHMENT: ${file.name}]\n(지원하지 않는 파일 형식: ${type || 'unknown'})`);
}
}
// 추출된 텍스트를 프롬프트에 주입
if (textContents.length > 0) {
processedPrompt = `${processedPrompt}\n\n--- 첨부 파일 내용 ---${textContents.join('\n')}`;
}
imageFiles = images.length > 0 ? images : undefined;
}
try {
await this._agent.handlePrompt(processedPrompt, effectiveModel || model, {
internetEnabled: internet,
visionContent: imageFiles,
agentSkillContext,
agentSkillFile: typeof agentFile === 'string' ? agentFile : undefined,
negativePrompt,
designerContext,
projectArchitectureContext: projectArchitectureContext || undefined,
secondBrainTraceEnabled: secondBrainTrace !== false,
secondBrainTraceDebug: !!secondBrainTraceDebug,
brainProfileId: selectedBrainId
});
} catch (error: any) {
logError('Prompt handling failed in sidebar provider.', { error: error?.message || String(error), promptPreview: summarizeText(value || '', 200) });
this._view.webview.postMessage({ type: 'error', value: error.message });
} finally {
void this._sendReadyStatus();
}
}
_buildDesignerGuardContext(): string {
return buildProjectChronicleGuardContext(this._getActiveChronicleProject());
}
async _sendModels(force: boolean = false) {
if (!this._view) return;
if (this._modelDiscoveryInFlight) {
logInfo('Model discovery already in progress, skipping.');
return;
}
this._modelDiscoveryInFlight = true;
try {
const config = getConfig();
const url = config.ollamaUrl;
let defaultModel = config.defaultModel;
let models: string[] = [];
let online = false;
const cache = this._modelsCache;
const cacheFresh = !!cache
&& cache.url === url
&& (Date.now() - cache.fetchedAt) < SidebarChatProvider.MODELS_CACHE_TTL_MS;
if (!force && cacheFresh && cache) {
models = cache.models.slice();
online = cache.online;
this._view.webview.postMessage({ type: 'engineStatus', value: { online, url } });
} else {
const engine = resolveEngine(url); // 단일 엔진만
const modelsUrl = buildApiUrl(url, engine, 'models');
try {
logInfo('Model discovery started.', { engine, modelsUrl, force });
const res = await fetch(modelsUrl, { signal: AbortSignal.timeout(5000) });
const rawText = await res.text();
if (!res.ok) {
logError('Model discovery returned non-OK status.', { engine, modelsUrl, status: res.status, body: summarizeText(rawText, 300) });
} else {
const data = rawText ? JSON.parse(rawText) as any : {};
models = engine === 'lmstudio'
? (data.data || []).map((m: any) => m.id)
: (data.models || []).map((m: any) => m.name);
if (models.length > 0) {
logInfo('Model discovery succeeded.', { engine, count: models.length, modelsPreview: models.slice(0, 5) });
}
}
} catch (e: any) {
logError('Model discovery failed.', { engine, modelsUrl, error: e?.message || String(e) });
}
online = models.length > 0;
this._modelsCache = { url, models: models.slice(), online, fetchedAt: Date.now() };
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._modelDiscoveryInFlight = false;
}
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();
return SidebarChatProvider._htmlTemplateCache
.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() 을 직접 호출한다.