3966 lines
186 KiB
TypeScript
3966 lines
186 KiB
TypeScript
import * as vscode from 'vscode';
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
import * as os from 'os';
|
||
import {
|
||
_getBrainDir,
|
||
findBrainFiles,
|
||
buildApiUrl,
|
||
getActiveBrainProfile,
|
||
getBrainProfiles,
|
||
logError,
|
||
logInfo,
|
||
resolveEngine,
|
||
summarizeText,
|
||
openInEditorGroup
|
||
} from './utils';
|
||
import { getConfig } from './config';
|
||
import { AgentExecutor, ChatMessage } from './agent';
|
||
import { BridgeInterface } from './bridge';
|
||
import { buildProjectChronicleGuardContext, ProjectChronicleManager, ProjectProfile } from './features/projectChronicle';
|
||
import 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) => {
|
||
// dispatch root 진입 trace — "/benchmark 입력했는데 아무 응답 없음" 같은
|
||
// 보고가 들어왔을 때 webview message가 정말 도착했는지부터 즉시 판별.
|
||
const valuePreview = typeof data?.value === 'string'
|
||
? JSON.stringify(data.value.slice(0, 80))
|
||
: '(non-string)';
|
||
logInfo(`[DISPATCH] type=${data?.type} value=${valuePreview}`);
|
||
if (await 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, 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, {
|
||
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() 을 직접 호출한다.
|