feat: v2.2.92 → v2.2.158 — god-file 분해 + Stocks feature + 대화 연속성
R56–R59: agent.ts 2731→1529줄 god-file 분해 (25 modules) · attrParsers + LLM 메서드 8개 (callNonStreaming, streamChatOnce 등) · executeActions 415줄 → 8 handler 그룹 (file/run/list/brain/calendar/sheets/tasks) · handlePrompt 1100줄 → 7 phase 모듈 (system prompt + budget + autoContinue 등) R50–R55: extension.ts 1145→349줄 (telegram/settings/provider commands 분리) Stocks feature 신규: /stocks slash command (v2.2.152~158) · .astra/stocks.json 저장소 + Yahoo Finance 현재가 갱신 · 8 키워드 필터 (ROE/성장성/유동성/수익성/영업효율/기술력/안정성/PBR) · Naver 시가총액 페이지 JSON API (m.stock.naver.com) 발굴 · LLM Top 5 매력도 분석 + Telegram 자동 보고서 · KST 09:00/15:00 watcher 자동 모니터링 대화 연속성 (v2.2.150~157): · [PRIOR TURN CONCLUSION] block 으로 직전 결론 anchor · thin follow-up 분류 → boilerplate 헤더 suppression · slash 명령 결과 chatHistory mirror (capture wrapper) · echo/parrot 금지 system prompt rule 기타: /stocks 슬래시 자동완성 dropdown UI, Naver JSON API 전환 (cheerio 제거) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import * as fs from 'fs';
|
||||
import { logInfo, logError } from '../../utils';
|
||||
import { getOrCreateAgentEntry } from '../../skills/agentKnowledgeMap';
|
||||
import { loadExternalSkills, formatSkillsAsPromptBlock } from '../../skills/externalSkillLoader';
|
||||
|
||||
/**
|
||||
* Chat turn 직전 *agent skill 컨텍스트* 와 *effective model* 해석. sidebarProvider
|
||||
* 의 `_handlePrompt` 안 50줄짜리 if/else 블록을 deps-free 함수로 분리.
|
||||
*
|
||||
* 흐름:
|
||||
* 1) agentFile 비어 있거나 'none' 이거나 디스크에 없으면 → context undefined,
|
||||
* base model 그대로.
|
||||
* 2) 파일을 읽고 placeholder 인지 검사 — 사용자가 새로 만들고 아직 내용 안 채운
|
||||
* 케이스. placeholder 면 'placeholder' notification + context undefined.
|
||||
* 3) 실제 본문이면 그걸 agentSkillContext 로 채우고, agent ↔ knowledge map 에서
|
||||
* external skills 를 가져와 append. Knowledge map entry 가 model 을 pinned 했으면
|
||||
* base 보다 우선시 + 'modelOverride' notification.
|
||||
*
|
||||
* Webview send 는 provider 가 담당 — 이 함수는 *어떤 알림이 필요한지* 만 결과에
|
||||
* 담아 반환 (notifications 배열). provider 가 그걸 보고 적절한 메시지 송신.
|
||||
*
|
||||
* Stateless — vscode/webview/instance state 의존 없음. fs/logger 만 사용.
|
||||
*/
|
||||
|
||||
export type AgentSkillNotification =
|
||||
| { type: 'placeholder' }
|
||||
| { type: 'modelOverride'; agent: string; model: string };
|
||||
|
||||
export interface AgentSkillResolution {
|
||||
/** Agent skill blob — placeholder/없음/실패 시 undefined. */
|
||||
agentSkillContext: string | undefined;
|
||||
/** Per-agent override 가 있으면 그 모델, 아니면 base model 그대로. */
|
||||
effectiveModel: string;
|
||||
/** Provider 가 webview 에 송신해야 할 알림들 (placeholder warning / model override 알림). */
|
||||
notifications: AgentSkillNotification[];
|
||||
}
|
||||
|
||||
export function resolveAgentSkill(
|
||||
agentFile: string | undefined | null,
|
||||
baseModel: string,
|
||||
): AgentSkillResolution {
|
||||
const effectiveModel = baseModel || '';
|
||||
if (!agentFile || agentFile === 'none' || !fs.existsSync(agentFile)) {
|
||||
return { agentSkillContext: undefined, effectiveModel, notifications: [] };
|
||||
}
|
||||
|
||||
const fileContent = fs.readFileSync(agentFile, 'utf8');
|
||||
// Placeholder guard: "# Agent Persona: …" 헤더 + "Add your instructions here…" 만 있으면
|
||||
// 사용자가 아직 본문을 안 채운 freshly-created agent. 그대로 agent prompt 로 쓰면
|
||||
// 모델이 "Add your instructions here" 를 instructions 로 잘못 해석.
|
||||
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 });
|
||||
return {
|
||||
agentSkillContext: undefined,
|
||||
effectiveModel,
|
||||
notifications: [{ type: 'placeholder' }],
|
||||
};
|
||||
}
|
||||
|
||||
let agentSkillContext: string = fileContent;
|
||||
let finalModel = effectiveModel;
|
||||
const notifications: AgentSkillNotification[] = [];
|
||||
|
||||
try {
|
||||
const entry = getOrCreateAgentEntry(agentFile);
|
||||
const bundle = loadExternalSkills(entry.skillFolders);
|
||||
const block = formatSkillsAsPromptBlock(bundle);
|
||||
if (block) {
|
||||
// External skills 를 agent .md 본문 끝에 append — 다음 파이프라인 (agent.ts /
|
||||
// agent-mode override) 이 동일 blob 으로 처리해서 추가 분기 불필요.
|
||||
agentSkillContext = `${agentSkillContext.trim()}\n\n${block}`;
|
||||
}
|
||||
const pinned = entry.model?.trim();
|
||||
if (pinned && pinned !== finalModel) {
|
||||
logInfo('Per-agent model override applied.', {
|
||||
agent: entry.name,
|
||||
requested: finalModel,
|
||||
pinned,
|
||||
});
|
||||
finalModel = pinned;
|
||||
notifications.push({ type: 'modelOverride', agent: entry.name, model: pinned });
|
||||
}
|
||||
} catch (e: any) {
|
||||
logError('External skill load failed.', { error: e?.message || String(e) });
|
||||
}
|
||||
|
||||
return { agentSkillContext, effectiveModel: finalModel, notifications };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { RequirementContract } from '../../features/company';
|
||||
|
||||
/**
|
||||
* Intent Alignment 카드의 4가지 표시 모드 payload 빌더. webview 송신은 provider 가
|
||||
* 담당 — 이 모듈은 모드별 payload shape 변환만.
|
||||
*
|
||||
* 카드 모드:
|
||||
* - 'auto-proceed' — high confidence + 질문 없음 → 사용자 확인 없이 진행, 알림용
|
||||
* - 'questions' — 모델이 묻고 싶은 게 있음, 사용자 답변 대기
|
||||
* - 'confirm' — 질문은 없으나 사용자가 진행 vs 취소 결정 필요
|
||||
* - 'cancelled' — 사용자가 카드를 닫음, 채팅 input 잠금 해제만 알림
|
||||
*
|
||||
* 같은 message type ('companyAlignmentCard') 안에서 `kind` 필드로 4 모드를
|
||||
* discriminate. 옛 코드는 sidebarProvider 의 3 곳에서 inline 으로 만들었음.
|
||||
*/
|
||||
|
||||
export function buildAlignmentAutoProceedPayload(contract: RequirementContract, reachedLimit: boolean) {
|
||||
return {
|
||||
type: 'companyAlignmentCard' as const,
|
||||
value: { kind: 'auto-proceed' as const, contract, reachedLimit },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAlignmentAskPayload(
|
||||
kind: 'questions' | 'confirm',
|
||||
contract: RequirementContract,
|
||||
roundsAsked: number,
|
||||
roundsLimit: number,
|
||||
) {
|
||||
return {
|
||||
type: 'companyAlignmentCard' as const,
|
||||
value: { kind, contract, roundsAsked, roundsLimit },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAlignmentCancelledPayload() {
|
||||
return {
|
||||
type: 'companyAlignmentCard' as const,
|
||||
value: { kind: 'cancelled' as const },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { CompanyState, RequirementContract } from '../../features/company';
|
||||
import { listActiveAgentsByCategory } from '../../features/company';
|
||||
|
||||
/**
|
||||
* Intent Alignment 흐름의 *pure 라우팅 결정*. webview 송신 / state mutation 은
|
||||
* provider 가 책임 — 이 모듈은 입력 → 결정 boolean / list 반환만.
|
||||
*
|
||||
* - shouldAutoProceedAlignment(mode, contract, reachedLimit)
|
||||
* → smart 모드일 때 사용자 확인 카드 skip 하고 곧장 pipeline 갈지 결정.
|
||||
* - extractActiveRoleCategories(state)
|
||||
* → 활성 에이전트가 있는 직군 enum 목록 (analyzer 가 회사 capability 알아야 함).
|
||||
*/
|
||||
|
||||
/**
|
||||
* 자동 진행 2 가지 조건 (smart 한정 — strict 는 항상 사용자 확인):
|
||||
* (a) high confidence + openQuestions 0 → 명백히 충분한 alignment
|
||||
* (b) round limit 도달 + medium 이상 confidence → 더 물어봐도 효용 없음, friction 최소
|
||||
* (b) 케이스에서 confidence='low' 면 여전히 confirm 카드 (사용자 결정 요청).
|
||||
*/
|
||||
export function shouldAutoProceedAlignment(
|
||||
mode: 'smart' | 'strict',
|
||||
contract: RequirementContract,
|
||||
reachedLimit: boolean,
|
||||
): boolean {
|
||||
if (mode !== 'smart') return false;
|
||||
if (contract.confidence === 'high' && contract.openQuestions.length === 0) return true;
|
||||
if (reachedLimit && (contract.confidence === 'high' || contract.confidence === 'medium')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 에이전트가 한 명이라도 있는 직군 (role category) 의 enum key 목록.
|
||||
* analyzer 가 "이 회사가 어떤 일을 할 수 있나" 알아야 goal/format 을 그 capability
|
||||
* 에 맞춰 추출할 수 있다.
|
||||
*/
|
||||
export function extractActiveRoleCategories(state: CompanyState): string[] {
|
||||
const byCat = listActiveAgentsByCategory(state);
|
||||
return Object.entries(byCat)
|
||||
.filter(([, list]) => list.length > 0)
|
||||
.map(([cat]) => cat);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import * as fs from 'fs';
|
||||
import type { ProjectProfile } from '../../features/projectChronicle';
|
||||
import { formatArchitectureContextForPrompt } from '../../features/projectArchitecture';
|
||||
|
||||
/**
|
||||
* Architecture (chronicle 프로젝트 자동 attach) 관련 webview payload + prompt
|
||||
* context 빌더. webview send / fs write / scan 같은 *상태성* 부분은 provider 와
|
||||
* `ArchitectureWatchManager` 가 계속 책임 — 이 모듈은 데이터 변환만.
|
||||
*
|
||||
* - buildArchitectureStatusPayload(profile, hasDoc, wasDetached)
|
||||
* → 'architectureStatus' 메시지의 active/inactive/hidden 분기
|
||||
* - buildProjectArchitectureContext(profile)
|
||||
* → agent.ts 가 prompt 에 prepend 하는 architecture context string
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sidebar chip 의 3 가지 상태:
|
||||
* 1) active — auto-attach 켜져 있고 doc 존재. 칩에 "마지막 갱신 N분 전" 표시.
|
||||
* 2) inactive (canAttach) — workspace + project 는 있으나 doc 미생성 or detached.
|
||||
* 칩에 [Attach] 버튼 노출. `detached` 플래그로 "Activate" vs "Re-attach" 구분.
|
||||
* 3) hidden — profile null. 호출자가 `{ active: false }` 만 보내고 끝.
|
||||
*
|
||||
* 빌더는 (1)(2) 만 만들고, hidden 은 호출자가 직접 보낸다 (profile 이 없는데
|
||||
* 빌더에 넘겨봤자 의미 없음).
|
||||
*/
|
||||
export function buildArchitectureStatusPayload(profile: ProjectProfile, hasDoc: boolean, wasDetached: boolean) {
|
||||
const fullyActive = hasDoc && !wasDetached;
|
||||
if (fullyActive) {
|
||||
return {
|
||||
type: 'architectureStatus' as const,
|
||||
value: {
|
||||
active: true,
|
||||
projectId: profile.projectId,
|
||||
projectName: profile.projectName,
|
||||
docPath: profile.architectureDocPath,
|
||||
lastUpdated: profile.architectureLastUpdated || '',
|
||||
autoUpdate: profile.architectureAutoUpdate !== false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'architectureStatus' as const,
|
||||
value: {
|
||||
active: false,
|
||||
canAttach: !!profile.projectRoot,
|
||||
projectId: profile.projectId,
|
||||
projectName: profile.projectName,
|
||||
// "never activated" 와 "detached" 구분 — chip 라벨 결정용.
|
||||
detached: wasDetached,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Workspace 도 profile 도 없는 hidden 상태 — chip 자체 숨김. */
|
||||
export function buildArchitectureHiddenPayload() {
|
||||
return {
|
||||
type: 'architectureStatus' as const,
|
||||
value: { active: false as const },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh / Attach 가 사전 가드에서 실패 (활성 project 없음 / projectRoot 없음).
|
||||
* webview 는 spinner 를 해제하고 사용자에게 안내 띄움.
|
||||
*/
|
||||
export function buildArchitectureRefreshFailedPayload(reason: string) {
|
||||
return {
|
||||
type: 'architectureRefreshFailed' as const,
|
||||
value: { reason },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh 가 성공적으로 수행된 후 webview 에 보낼 결과. 3가지 숫자 (newly /
|
||||
* cached / deleted) 가 함께 가야 사용자가 "Refresh 가 실제로 뭘 했는가" 를
|
||||
* 신뢰할 수 있다 — 그냥 timestamp 만 바뀐 경우와 분 단위로 큰 작업이 일어난
|
||||
* 경우를 구분.
|
||||
*/
|
||||
export function buildArchitectureRefreshResultPayload(opts: {
|
||||
projectName: string;
|
||||
docPath: string;
|
||||
newlyAnalyzed: number;
|
||||
cached: number;
|
||||
deleted: number;
|
||||
durationMs: number;
|
||||
}) {
|
||||
return {
|
||||
type: 'architectureRefreshResult' as const,
|
||||
value: opts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 의 prompt 에 prepend 되는 architecture context block. auto-attach 가 off
|
||||
* 거나 doc 이 없거나 디스크에서 사라졌으면 빈 문자열 (agent.ts 가 알아서 skip).
|
||||
*/
|
||||
export function buildProjectArchitectureContext(profile: ProjectProfile | null): string {
|
||||
if (!profile || profile.architectureAutoAttach === false || !profile.architectureDocPath) return '';
|
||||
if (!fs.existsSync(profile.architectureDocPath)) return '';
|
||||
return formatArchitectureContextForPrompt({
|
||||
projectName: profile.projectName,
|
||||
docPath: profile.architectureDocPath,
|
||||
// project root 를 같이 넘겨 Source: 헤더가 workspace-relative 로 출력되게.
|
||||
projectRoot: profile.projectRoot,
|
||||
lastUpdated: profile.architectureLastUpdated,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as fs from 'fs';
|
||||
|
||||
/**
|
||||
* "자동 chronicle 기록" 흐름의 *분류기* — 사용자 메시지 + assistant 답변을 보고
|
||||
* 어떤 record type (planning / discussion / decision / development / bug) 으로
|
||||
* 저장할지, 또 어떤 프로젝트 경로 / 변경 파일을 자동 캡처할지 결정.
|
||||
*
|
||||
* 모두 stateless 정규식 / fs.statSync 기반 — sidebarProvider 의 옛 private
|
||||
* 메서드를 그대로 추출.
|
||||
*
|
||||
* - extractLocalProjectPath(text) — 메시지 본문에서 `/Volumes/...` 절대 경로 추출 + 디렉토리 검증
|
||||
* - inferAutoChronicleRecordType(user, assistant) — 6 분기 키워드 매칭 (옵트아웃 우선)
|
||||
* - extractChangedFilesFromText(text) — assistant 응답에서 backtick 코드/문서 파일명 추출
|
||||
*/
|
||||
|
||||
/**
|
||||
* Antigravity workspace 절대 경로를 메시지에서 발견. 결과가 실제로 존재하는
|
||||
* 디렉토리일 때만 반환 — fs.statSync 가 throw 하면 null.
|
||||
*/
|
||||
export function 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메시지 한 쌍을 6 record type 중 하나로 분류. 분류 우선순위:
|
||||
* 0) opt-out 키워드 ("기록하지마" / "do not record") → null
|
||||
* 1) topic 키워드 (프로젝트/코드/아키텍처 등) 가 *전혀* 없으면 null
|
||||
* 2) bug 키워드가 user 에 있으면 'bug'
|
||||
* 3) "수정 완료/구현 완료/jest passed" 류가 assistant 에 있으면 'development'
|
||||
* 4) "결정/채택/decision" 류 있으면 'decision'
|
||||
* 5) "계획/설계/아키텍처/roadmap" 류가 user 에 있으면 'planning'
|
||||
* 6) "개선/구현/test" 류가 combined 에 있으면 'development'
|
||||
* 7) 그 외 'discussion'
|
||||
*
|
||||
* 순서는 *덮어쓰기* — 더 구체적인 매칭이 먼저. 변경 시 회귀 위험 있으니 유지.
|
||||
*/
|
||||
export function 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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Assistant 답변의 backtick 코드/문서 파일명 추출. 최대 12개로 cap.
|
||||
* 추출 결과가 0개면 placeholder 문구 한 줄 반환 (record 의 changedFiles 가
|
||||
* 빈 배열이면 template 이 깨지는 케이스 회피).
|
||||
*/
|
||||
export function 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.'];
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type {
|
||||
BugRecord,
|
||||
DecisionRecord,
|
||||
DevelopmentLog,
|
||||
DiscussionRecord,
|
||||
PlanningDocument,
|
||||
} from '../../features/projectChronicle';
|
||||
import { summarizeTextForWiki } from './textHelpers';
|
||||
import { extractChangedFilesFromText } from './autoChronicleClassifier';
|
||||
|
||||
/**
|
||||
* 자동 chronicle 기록 5가지 record type 의 *payload* 빌더. 모두 pure — 사용자
|
||||
* 메시지/응답/title/createdAt 만 받으면 ProjectChronicleManager.writeXxx() 에 그대로
|
||||
* 넘길 수 있는 record body 객체를 반환.
|
||||
*
|
||||
* 옛 코드: sidebarProvider 의 `_autoWriteChronicleAfterPrompt` 안 5분기 if/else 가
|
||||
* inline 으로 payload 를 만들고 있었음 (~70줄). 분류기는 이미 `autoChronicleClassifier`
|
||||
* 로 분리됐고, 이 모듈은 *그 결정 이후의 payload 조립* 만 책임.
|
||||
*
|
||||
* Provider 는 분류 → 이 모듈로 payload 받음 → `this._chronicle.writeXxx(profile, payload, [number])`
|
||||
* 흐름. 빌더는 instance state 없고 `summarizeTextForWiki` / `extractChangedFilesFromText`
|
||||
* 등 기존 builder 만 사용.
|
||||
*/
|
||||
|
||||
export function buildAutoBugRecord(
|
||||
title: string,
|
||||
summary: string,
|
||||
latestUser: string,
|
||||
createdAt: string,
|
||||
): BugRecord {
|
||||
return {
|
||||
title,
|
||||
symptom: 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,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAutoPlanningRecord(
|
||||
title: string,
|
||||
summary: string,
|
||||
latestUser: string,
|
||||
createdAt: string,
|
||||
): PlanningDocument {
|
||||
return {
|
||||
featureName: title,
|
||||
purpose: 'Capture the current planning or architecture direction before implementation continues.',
|
||||
background: summary,
|
||||
userIntent: 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,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAutoDecisionRecord(
|
||||
title: string,
|
||||
summary: string,
|
||||
latestUser: string,
|
||||
createdAt: string,
|
||||
): DecisionRecord {
|
||||
return {
|
||||
title,
|
||||
status: 'accepted',
|
||||
context: 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,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAutoDevelopmentRecord(
|
||||
title: string,
|
||||
summary: string,
|
||||
latestAssistant: string,
|
||||
createdAt: string,
|
||||
): DevelopmentLog {
|
||||
return {
|
||||
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: 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,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAutoDiscussionRecord(
|
||||
title: string,
|
||||
summary: string,
|
||||
latestUser: string,
|
||||
createdAt: string,
|
||||
): DiscussionRecord {
|
||||
return {
|
||||
title,
|
||||
userRequest: 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Brain profile lifecycle 의 pure helpers — sidebarProvider 의 add/edit/delete
|
||||
* 흐름에서 modal UI 와 config 쓰기를 제외한 *데이터 변환* 만 격리.
|
||||
*
|
||||
* 현재 한 함수만 있지만 향후 edit 의 "변경된 필드만 머지", delete 의 "next active
|
||||
* 선택 규칙" 등 다른 pure transform 도 같은 모듈에 모인다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 사용자 입력 이름으로부터 slug-style 아이디 후보를 만들고, 기존 목록과 충돌하지
|
||||
* 않는 가장 짧은 이름을 반환. slug 가 빈 문자열이면 `'brain'` 으로 fallback.
|
||||
* 충돌 시 `-2`, `-3` … suffix 를 붙여 회피.
|
||||
*/
|
||||
export function generateUniqueBrainId(name: string, existingProfiles: ReadonlyArray<{ id: string }>): string {
|
||||
const idBase = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'brain';
|
||||
if (!existingProfiles.some((p) => p.id === idBase)) return idBase;
|
||||
let suffix = 2;
|
||||
let id = `${idBase}-${suffix}`;
|
||||
while (existingProfiles.some((p) => p.id === id)) {
|
||||
suffix += 1;
|
||||
id = `${idBase}-${suffix}`;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ProjectProfile, ChronicleRecordEntry } from '../../features/projectChronicle';
|
||||
|
||||
/**
|
||||
* Chronicle (Designer) 모드 webview status payload 빌더. webview send 호출은
|
||||
* sidebarProvider 가 담당 — 이 모듈은 stateless 데이터 변환만.
|
||||
*
|
||||
* - buildChronicleProjectsPayload(projects, activeProjectId)
|
||||
* - buildChronicleRecordsPayload(records)
|
||||
*
|
||||
* `_chronicle.listRecords()` 자체는 ProjectChronicleManager 의 stateful 호출이라
|
||||
* provider 가 수행 후 결과만 builder 에게 넘긴다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Project 목록 + 활성 project id. 빈 active id 는 빈 문자열로 정규화 (webview 가
|
||||
* undefined 처리 분기 추가 필요 없게).
|
||||
*/
|
||||
export function buildChronicleProjectsPayload(projects: ProjectProfile[], activeProjectId: string) {
|
||||
return {
|
||||
type: 'chronicleProjects' as const,
|
||||
value: {
|
||||
activeProjectId: activeProjectId || '',
|
||||
projects: projects.map((project) => ({
|
||||
id: project.projectId,
|
||||
name: project.projectName,
|
||||
root: project.projectRoot || '',
|
||||
recordRoot: project.recordRoot,
|
||||
description: project.description || '',
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record list — 활성 project 없으면 호출자가 빈 배열을 넘긴다.
|
||||
* webview 는 항상 array 를 받으므로 분기 단순화.
|
||||
*/
|
||||
export function buildChronicleRecordsPayload(records: ChronicleRecordEntry[]) {
|
||||
return {
|
||||
type: 'chronicleRecords' as const,
|
||||
value: records.map((record) => ({
|
||||
section: record.section,
|
||||
fileName: record.fileName,
|
||||
path: record.filePath,
|
||||
relativePath: record.relativePath,
|
||||
updatedAt: record.updatedAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { CompanyResumeState, CompanyState } from '../../features/company';
|
||||
import {
|
||||
listActiveAgentsByCategory,
|
||||
summarizeForChip,
|
||||
PIPELINE_TEMPLATES,
|
||||
ROLE_CATEGORY_LABELS,
|
||||
ROLE_CATEGORY_ORDER,
|
||||
COMPANY_AGENTS,
|
||||
COMPANY_AGENT_ORDER,
|
||||
resolveAgent,
|
||||
} from '../../features/company';
|
||||
|
||||
/**
|
||||
* Company (1인 기업) 모드 webview status payload 빌더. webview send 호출은
|
||||
* 여전히 sidebarProvider 가 담당 — 이 모듈은 stateless 데이터 변환만.
|
||||
*
|
||||
* - buildCompanyPipelinesPayload(state) — pipeline 에디터용 (templates, active agents)
|
||||
* - buildCompanyStatusPayload(state) — sidebar chip (enabled, name, summary)
|
||||
* - buildCompanyResumablePayload(items, state) — 재개 가능 세션 목록
|
||||
*
|
||||
* `_sendCompanyAgents` 는 95+ 줄짜리 큰 빌더라 다음 라운드에서 별도로 다룬다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Pipeline 에디터가 그릴 때 필요한 모든 메타데이터를 한 페이로드로:
|
||||
* - 등록된 pipelines + 현재 active id
|
||||
* - 직군 라벨/순서 (UI 가 enum 값을 모르므로 백엔드가 결정)
|
||||
* - 직군별 활성 에이전트 슬림 정보 (cascading dropdown 채우기용)
|
||||
* - template 카탈로그 (가벼운 메타만, stages 는 stamp 시점에 따로 요청)
|
||||
*/
|
||||
export function buildCompanyPipelinesPayload(state: CompanyState) {
|
||||
const byCategory = listActiveAgentsByCategory(state);
|
||||
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 }));
|
||||
}
|
||||
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,
|
||||
}));
|
||||
return {
|
||||
type: 'companyPipelines' as const,
|
||||
value: {
|
||||
pipelines: state.pipelines ?? {},
|
||||
activePipelineId: state.activePipelineId ?? null,
|
||||
roleCategoryLabels: ROLE_CATEGORY_LABELS,
|
||||
roleCategoryOrder: ROLE_CATEGORY_ORDER,
|
||||
activeAgentsByCategory: slimByCategory,
|
||||
templates,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar chip 의 active/inactive + 회사명 + 한 줄 요약 + 활성 에이전트 id 목록.
|
||||
* 스코프 프리셋 활성 id 도 동봉 — sidebar segmented control 이 현재 선택을 표시.
|
||||
*/
|
||||
export function buildCompanyStatusPayload(state: CompanyState) {
|
||||
return {
|
||||
type: 'companyStatus' as const,
|
||||
value: {
|
||||
enabled: state.enabled,
|
||||
companyName: state.companyName,
|
||||
summary: summarizeForChip(state),
|
||||
activeAgentIds: state.activeAgentIds,
|
||||
modelOverrides: state.modelOverrides,
|
||||
activePipelineId: state.activePipelineId ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage panel 의 agent catalogue 페이로드. built-in + custom 합쳐 ordered list 로
|
||||
* 직렬화하고, 각 엔트리에는 *기본값(baseDef) + 현재값(effective) + override 플래그*
|
||||
* 까지 보내 UI 가 "수정됨" 표시 + Reset 버튼 + Edit 폼의 dirty 비교를 한 페이로드
|
||||
* 만으로 그릴 수 있게 한다.
|
||||
*
|
||||
* `pipelineUsage` 는 각 agent id 별로 *어떤 pipeline 들이 그를 stage 에 쓰고 있는가*
|
||||
* 를 미리 집계 — 비어 있으면 삭제 가능, 한 개 이상이면 UI 가 삭제 버튼 disabled +
|
||||
* tooltip 에 pipeline 이름 노출.
|
||||
*
|
||||
* `hiddenBuiltinIds` 는 사용자가 숨긴 빌트인들 — "복원" 옵션을 위해 한 줄 메타로
|
||||
* 같이 보낸다.
|
||||
*/
|
||||
export function buildCompanyAgentsPayload(state: CompanyState, globalKnowledgeMixWeight: number) {
|
||||
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];
|
||||
|
||||
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. dropdown 이 옳은 선택값 보이려면 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,
|
||||
// Display 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 + base default + override 플래그
|
||||
roleCategory: effective.roleCategory,
|
||||
defaultRoleCategory: baseDef.roleCategory,
|
||||
roleCategoryOverridden: !!roleOverride && roleOverride !== baseDef.roleCategory,
|
||||
knowledgeMixOverride: hasKmOverride ? kmOverride : null,
|
||||
effectiveKnowledgeMixWeight: hasKmOverride ? kmOverride : globalKnowledgeMixWeight,
|
||||
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);
|
||||
|
||||
return {
|
||||
type: 'companyAgents' as const,
|
||||
value: {
|
||||
companyName: state.companyName,
|
||||
globalKnowledgeMixWeight,
|
||||
agents,
|
||||
hiddenBuiltins,
|
||||
// 직군 라벨 사전 + 표시 순서 — webview 는 enum 값을 모르므로 백엔드가 결정.
|
||||
roleCategoryLabels: ROLE_CATEGORY_LABELS,
|
||||
roleCategoryOrder: ROLE_CATEGORY_ORDER,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 재개 가능 세션 list 페이로드. 세션 raw 에 pipeline name 을 resolve 해서 함께
|
||||
* 보냄 — webview 가 id 만 받으면 dropdown 에 "id" 만 표시되는 회귀 차단.
|
||||
*/
|
||||
export function buildCompanyResumablePayload(items: CompanyResumeState[], state: CompanyState) {
|
||||
return {
|
||||
type: 'companyResumable' as const,
|
||||
value: {
|
||||
items: items.map((s) => ({
|
||||
timestamp: s.timestamp,
|
||||
userPrompt: s.userPrompt.slice(0, 200),
|
||||
pipelineId: s.pipelineId,
|
||||
pipelineName: s.pipelineId
|
||||
? (state.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,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { CompanyTurnEvent } from '../../features/company';
|
||||
|
||||
/**
|
||||
* 1인 기업 turn 의 `CompanyTurnEvent` emit 사이드이펙트 묶음. dispatcher 가 매
|
||||
* phase 마다 호출하는 `onEvent` 콜백을 *3가지 사이드이펙트 (webview 송신 + summary
|
||||
* 기록 + pixel office hook)* 와 *turn 종료 후 caller 가 읽을 mutable state* 로
|
||||
* 분리해 한 factory 로 격리.
|
||||
*
|
||||
* 옛 코드: sidebarProvider 의 `_runCompanyTurn` 안 16줄짜리 closure. 사이드이펙트
|
||||
* 와 mutable 캡처가 한 함수에 섞여 있어 가독성/테스트성 모두 낮았다.
|
||||
*
|
||||
* 사용:
|
||||
* const emitter = createCompanyTurnEmit({ postWebview, recordSummary, onPixelOffice });
|
||||
* const deps = buildDispatcherDeps({ ..., onEvent: emitter.emit });
|
||||
* await runCompanyTurn(prompt, deps);
|
||||
* const report = emitter.getFinalReport();
|
||||
*/
|
||||
|
||||
export interface CompanyTurnEmitDeps {
|
||||
/** dispatcher event 를 webview 에 `companyTurnUpdate` 로 그대로 전달. */
|
||||
postWebview: (msg: { type: string; value: unknown }) => void;
|
||||
/**
|
||||
* Turn 의 *완료된* 결과 (plan brief + report tail) 를 caller 측 cache 에 기록.
|
||||
* 다음 메시지의 intent classifier 가 "follow-up 인가?" 판단할 때 사용. abort
|
||||
* 된 turn 은 호출 안 됨 (report-done 이 안 옴).
|
||||
*/
|
||||
recordSummary: (brief: string, reportTail: string) => void;
|
||||
/** Pixel Office 같은 read-only 모니터링 hub 알림. throw 해도 turn 흐름엔 영향 없음. */
|
||||
onPixelOffice: (event: CompanyTurnEvent) => void;
|
||||
}
|
||||
|
||||
export interface CompanyTurnEmitter {
|
||||
emit: (event: CompanyTurnEvent) => void;
|
||||
/** Turn 완료 후 caller 가 final report 읽기 — abort 된 turn 은 빈 문자열. */
|
||||
getFinalReport: () => string;
|
||||
}
|
||||
|
||||
export function createCompanyTurnEmit(deps: CompanyTurnEmitDeps): CompanyTurnEmitter {
|
||||
let stagingBrief = '';
|
||||
let finalReport = '';
|
||||
return {
|
||||
emit(event: CompanyTurnEvent) {
|
||||
deps.postWebview({ type: 'companyTurnUpdate', value: event });
|
||||
if (event.phase === 'plan-ready') {
|
||||
stagingBrief = event.plan?.brief || '';
|
||||
} else if (event.phase === 'report-done') {
|
||||
finalReport = (event.report || '').trim();
|
||||
deps.recordSummary(stagingBrief, finalReport.slice(-600));
|
||||
}
|
||||
try { deps.onPixelOffice(event); } catch { /* never break the turn */ }
|
||||
},
|
||||
getFinalReport: () => finalReport,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type {
|
||||
ApprovalDecision,
|
||||
CompanyTurnEvent,
|
||||
DispatcherDeps,
|
||||
RequirementContract,
|
||||
} from '../../features/company';
|
||||
import type { ApprovalGateManager } from '../managers/approvalGates';
|
||||
import type { AIService } from '../../core/services';
|
||||
import { getConfig } from '../../config';
|
||||
|
||||
/**
|
||||
* 1인 기업 turn 의 `DispatcherDeps` 객체를 조립하는 빌더. 옛 코드는 sidebarProvider
|
||||
* 의 `_runCompanyTurn` 안에서 inline 으로 37줄짜리 객체를 만들고 있었음 — 인자 6개
|
||||
* + closure 2개가 한 자리에 섞여 가독성이 낮았다.
|
||||
*
|
||||
* 빌더는 *순수 조립* 만 책임 — config 읽기 / approval 등록 등의 closure 는 호출자
|
||||
* 가 전달하는 deps 로부터 생성. 실제 turn 실행 (`runCompanyTurn` / `resumeCompanyTurn`)
|
||||
* 은 여전히 provider 가 호출.
|
||||
*/
|
||||
|
||||
/** Provider 가 미리 준비해서 넘기는 closure deps — 각 항목이 instance state 1개에 1대1 매칭. */
|
||||
export interface DispatcherDepsInputs {
|
||||
context: vscode.ExtensionContext;
|
||||
ai: AIService;
|
||||
signal: AbortSignal;
|
||||
onEvent: (event: CompanyTurnEvent) => void;
|
||||
/** Action tag (`<create_file>` 등) 를 실제 파일시스템에 적용. AgentExecutor 의 thunk. */
|
||||
executeActionTags: (text: string) => Promise<string[]>;
|
||||
/** 승인 게이트 — dispatcher 가 await 할 Promise 의 resolver 를 등록 받는다. */
|
||||
approvalGates: ApprovalGateManager;
|
||||
/** 이번 turn 한정 pipeline override. chatHandlers 가 의도 분류기 결과로 채움. */
|
||||
pipelineIdOverride?: string;
|
||||
/** Intent Alignment 가 도출한 사용자 합의 contract. 없으면 legacy 동작. */
|
||||
requirementContract?: RequirementContract;
|
||||
}
|
||||
|
||||
export function buildDispatcherDeps(inputs: DispatcherDepsInputs): DispatcherDeps {
|
||||
const cfg = getConfig();
|
||||
return {
|
||||
context: inputs.context,
|
||||
ai: inputs.ai,
|
||||
defaultModel: cfg.defaultModel || 'gemma4:e2b',
|
||||
// Knowledge Mix wiring — 회사 specialists 도 사용자의 Second Brain 을 같은
|
||||
// global 기본값 + per-agent override semantics 로 사용. 없으면 Knowledge Mix
|
||||
// 슬라이더가 회사 turn 에 영향 없음.
|
||||
globalKnowledgeMixWeight: cfg.knowledgeMixSecondBrainWeight ?? 50,
|
||||
brainFileBaseline: cfg.memoryLongTermFiles ?? 6,
|
||||
// Specialist 출력 (예: `<create_file>`) 을 실제 디스크에 적용. 없으면 agents 가
|
||||
// "파일 만들었다" 고만 주장하고 실제 디스크엔 아무 일도 안 일어남.
|
||||
executeActionTags: inputs.executeActionTags,
|
||||
signal: inputs.signal,
|
||||
onEvent: inputs.onEvent,
|
||||
// 승인 게이트 bridge — dispatcher 가 호출하면 Promise 생성 + resolver 를
|
||||
// approvalGates 에 등록. chatHandlers 가 카드 버튼 클릭으로 resolve 호출.
|
||||
// signal 이 이미 aborted 면 즉시 abort 결과로 resolve (await 무한 대기 방지).
|
||||
awaitApproval: ({ stageId }: { stageId: string; stageLabel: string }) =>
|
||||
new Promise<ApprovalDecision>((resolve) => {
|
||||
if (inputs.signal.aborted) {
|
||||
resolve({ kind: 'abort' });
|
||||
return;
|
||||
}
|
||||
inputs.approvalGates.register(stageId, resolve);
|
||||
}),
|
||||
pipelineIdOverride: inputs.pipelineIdOverride,
|
||||
requirementContract: inputs.requirementContract,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Proactive suggestion tip 한 줄 텍스트 — 사이드바가 사용자의 특정 탐색 행동
|
||||
* (settings 열기, brain sync 보기, agent 선택 등) 을 감지하면 채팅에 callout 으로
|
||||
* 잠깐 띄우는 도움말.
|
||||
*
|
||||
* Stateless lookup table — sidebarProvider 의 옛 `_handleProactiveSuggestion` 안에
|
||||
* 박혀 있던 switch 를 1:1 추출. webview send 호출은 provider 가 한다.
|
||||
*/
|
||||
|
||||
export type ProactiveContext =
|
||||
| 'settings_exploration'
|
||||
| 'brain_sync_exploration'
|
||||
| 'agent_selection_exploration'
|
||||
| string;
|
||||
|
||||
const SUGGESTIONS: Record<string, string> = {
|
||||
settings_exploration:
|
||||
'💡 **Tip:** 모델 설정을 최적화하여 답변 속도를 2배 이상 높일 수 있습니다. 설정에서 `Max Context Size`를 조정해보세요!',
|
||||
brain_sync_exploration:
|
||||
'🧠 **Knowledge Sync:** 최근에 수정한 파일이 지식 베이스에 반영되지 않았나요? 지금 동기화 버튼을 눌러 최신 정보를 업데이트하세요.',
|
||||
agent_selection_exploration:
|
||||
'🤖 **Agent Skills:** 특정 언어나 프레임워크에 특화된 에이전트 스킬을 선택하면 더 정확한 코드를 생성할 수 있습니다.',
|
||||
};
|
||||
|
||||
const DEFAULT_SUGGESTION =
|
||||
'💡 새로운 기능을 발견하셨나요? 궁금한 점이 있다면 언제든 물어보세요!';
|
||||
|
||||
export function proactiveSuggestionFor(context: ProactiveContext): string {
|
||||
return SUGGESTIONS[context] ?? DEFAULT_SUGGESTION;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import * as path from 'path';
|
||||
import type { ProjectProfile } from '../../features/projectChronicle';
|
||||
import { slugify } from './textHelpers';
|
||||
|
||||
/**
|
||||
* Chronicle ProjectProfile 의 *자동 생성* factory 모음. sidebarProvider 가 옛
|
||||
* 코드 3 곳에서 살짝 다른 default 들로 inline 생성하던 패턴을 한 파일에 모아
|
||||
* (a) recordRoot 경로 규약 단일화, (b) description/corePurpose 기본 카피라이팅
|
||||
* 한 곳에서 수정, (c) 새 origin 추가 시 다른 두 factory 와 일관성 비교 용이.
|
||||
*
|
||||
* - workspaceAutoDetectedProfile — ChronicleProjectStore.getAll() 의 빈 fallback
|
||||
* - workspaceSynthesizedProfile — _ensureActiveProjectForWorkspace case 3
|
||||
* - architectureActivationStub — _activateArchitectureForProject 가 store 에
|
||||
* 없는 projectId 로 호출됐을 때 즉석 stub
|
||||
*
|
||||
* 셋 다 stateless — vscode 같은 외부 API 의존 없음. workspace root 는 호출자가
|
||||
* 미리 해결해서 넘긴다.
|
||||
*/
|
||||
|
||||
const NOW = (): string => new Date().toISOString();
|
||||
|
||||
/**
|
||||
* recordRoot 표준 위치: `<projectRoot>/docs/records/<projectName>/`. 3 factory 가
|
||||
* 공통으로 따르는 규약 — 별도 helper 로 빼서 정책 변경 시 한 줄 수정.
|
||||
*/
|
||||
function defaultRecordRoot(projectRoot: string, projectName: string): string {
|
||||
return path.join(projectRoot, 'docs', 'records', projectName);
|
||||
}
|
||||
|
||||
/**
|
||||
* ChronicleProjectStore.getAll() 가 valid project 0 개일 때 즉석 만들어 반환하는
|
||||
* default. 사용자가 한 번도 chronicle project 를 등록 안 했어도 chronicle 기능이
|
||||
* 동작 가능하게. description/corePurpose 가 가장 풍부 — 진짜 "이게 default 야"
|
||||
* 라는 의도 전달.
|
||||
*/
|
||||
export function workspaceAutoDetectedProfile(workspaceRoot: string): ProjectProfile {
|
||||
const projectName = path.basename(workspaceRoot) || 'Current Project';
|
||||
const now = NOW();
|
||||
return {
|
||||
projectId: slugify(projectName),
|
||||
projectName,
|
||||
projectRoot: workspaceRoot,
|
||||
recordRoot: defaultRecordRoot(workspaceRoot, 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `_ensureActiveProjectForWorkspace` 가 case 3 (어떤 chronicle project 도
|
||||
* workspace 와 매칭 안 됨) 에서 만드는 새 entry. 명시 등록 직전이라 description
|
||||
* 만 가볍게, corePurpose 는 빈 문자열 (사용자가 나중에 채울 수 있게).
|
||||
*/
|
||||
export function workspaceSynthesizedProfile(workspaceRoot: string): ProjectProfile {
|
||||
const projectName = path.basename(workspaceRoot) || 'Current Project';
|
||||
const now = NOW();
|
||||
return {
|
||||
projectId: slugify(projectName),
|
||||
projectName,
|
||||
projectRoot: workspaceRoot,
|
||||
recordRoot: defaultRecordRoot(workspaceRoot, projectName),
|
||||
description: 'Auto-detected from workspace folder.',
|
||||
corePurpose: '',
|
||||
detailLevel: 'standard',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `_activateArchitectureForProject` 가 호출됐는데 해당 projectId 가 store 에 없을
|
||||
* 때 만드는 stub. 사용자가 *path-only* 매칭으로 activate 한 케이스 — 이후 흐름이
|
||||
* 같은 profile 로 chronicle 기능을 쓸 수 있게 해야 함.
|
||||
*/
|
||||
export function architectureActivationStub(
|
||||
projectId: string,
|
||||
projectRoot: string,
|
||||
fallbackName?: string,
|
||||
): ProjectProfile {
|
||||
const projectName = fallbackName || path.basename(projectRoot) || projectId;
|
||||
const now = NOW();
|
||||
return {
|
||||
projectId,
|
||||
projectName,
|
||||
projectRoot,
|
||||
recordRoot: defaultRecordRoot(projectRoot, projectName),
|
||||
description: 'Auto-created by Project Architecture activation.',
|
||||
corePurpose: '',
|
||||
detailLevel: 'standard',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로 비교용 정규화 — trailing slash 제거 + lowercase. case-insensitive
|
||||
* 파일시스템 (macOS default, Windows) 에서 자기 자신끼리 비교가 안전.
|
||||
*/
|
||||
export function normalizePathForCompare(p: string | undefined): string {
|
||||
return (p || '').replace(/[\\/]+$/, '').toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { logInfo, logError } from '../../utils';
|
||||
|
||||
/**
|
||||
* Chat prompt 의 *첨부 파일* 처리 — webview 가 보낸 base64 파일들을 type 별로
|
||||
* 분류해 (PDF / text / image / unknown) 적절한 형태로 변환.
|
||||
*
|
||||
* - PDF: pdf-parse v2 로 텍스트 추출 시도 → 텍스트 없으면 페이지 screenshot
|
||||
* 8장까지 vision 모델 입력으로 fallback.
|
||||
* - Text-like (.md, .json, .ts 등): base64 → utf-8 디코딩 후 ```code``` 블록.
|
||||
* - Image: 그대로 vision content array 에 추가.
|
||||
* - Unknown: 파일명만 기록 (사용자에게 형식 안내).
|
||||
*
|
||||
* 옛 코드: sidebarProvider 의 `_handlePrompt` 안 90줄짜리 if/else 블록 inline.
|
||||
* 분리해서 (a) PDF parsing 흐름 단위 테스트 가능, (b) 새 파일 타입 추가 시
|
||||
* 한 곳만 수정.
|
||||
*
|
||||
* 반환값: `textContents` 는 prompt 본문에 합칠 텍스트 블록 목록 (각 항목이 한
|
||||
* 파일에 대응). `imageFiles` 는 vision content 로 넘길 image 배열 (없으면 빈 배열).
|
||||
*/
|
||||
export interface AttachmentResult {
|
||||
/** prompt 에 합칠 텍스트 블록들 (PDF 추출 텍스트, decode 된 코드 파일 등). */
|
||||
textContents: string[];
|
||||
/** vision content 로 사용할 image attachments (PDF→screenshots 도 포함). */
|
||||
imageFiles: any[];
|
||||
}
|
||||
|
||||
const TEXT_FILE_EXTENSIONS = /\.(txt|md|csv|json|js|ts|jsx|tsx|html|css|py|java|rs|go|yaml|yml|xml|toml|sql|sh)$/i;
|
||||
|
||||
export async function processAttachments(files: any[] | undefined | null): Promise<AttachmentResult> {
|
||||
if (!Array.isArray(files) || files.length === 0) {
|
||||
return { textContents: [], imageFiles: [] };
|
||||
}
|
||||
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') {
|
||||
await processPdfFile(file, textContents, images);
|
||||
} else if (
|
||||
type.startsWith('text/')
|
||||
|| type === 'application/json'
|
||||
|| TEXT_FILE_EXTENSIONS.test(name)
|
||||
) {
|
||||
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/')) {
|
||||
images.push(file);
|
||||
} else {
|
||||
textContents.push(`\n[ATTACHMENT: ${file.name}]\n(지원하지 않는 파일 형식: ${type || 'unknown'})`);
|
||||
}
|
||||
}
|
||||
|
||||
return { textContents, imageFiles: images };
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF 한 개 처리. 텍스트 추출 → page screenshot vision fallback → 둘 다 실패 시
|
||||
* 안내 문구 1줄. 결과는 caller 의 textContents / images 배열에 push (in-place).
|
||||
*
|
||||
* pdf-parse v2 API: `new PDFParse(uint8) → load() → getText() / getScreenshot()`.
|
||||
* 메모리 보호 위해 vision fallback 은 최대 8 페이지로 cap.
|
||||
*/
|
||||
async function processPdfFile(file: any, textContents: string[], images: any[]): Promise<void> {
|
||||
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 — text layer 없는 PDF (스캔본 등) 의 마지막 수단.
|
||||
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 page screenshots.', { fileName: file.name, totalPages: screenshots.total, extracted: maxPages });
|
||||
pdfTextOk = true;
|
||||
}
|
||||
}
|
||||
} 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 분석에 실패했습니다. 이 파일을 텍스트로 변환하여 다시 시도해주세요.)`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ChatMessage } from '../../agent';
|
||||
import type { ChatSession } from '../managers/chatSessionStore';
|
||||
|
||||
/**
|
||||
* Chat session 관련 webview payload + 작은 pure helpers. send 호출은 provider 가
|
||||
* 담당 — 이 모듈은 데이터 변환만.
|
||||
*
|
||||
* - buildSessionListPayload(sessions) — 'sessionList' (sidebar list)
|
||||
* - buildSessionLoadedPayload(...) — 'sessionLoaded' (특정 세션 활성화 알림)
|
||||
* - buildSessionTitleFromHistory(history) — 첫 user 메시지 → 50자 trim title
|
||||
*/
|
||||
|
||||
/**
|
||||
* 임의 text 를 session title 로 50자 cap. 빈 / 비공백이면 `fallback`.
|
||||
* newline 은 공백으로 normalize. 50자 초과면 `...` suffix.
|
||||
*/
|
||||
export function buildSessionTitleFromText(text: string | undefined | null, fallback: string): string {
|
||||
if (typeof text !== 'string' || !text.trim()) return fallback;
|
||||
const normalized = text.substring(0, 50).replace(/\n/g, ' ');
|
||||
return normalized + (text.length > 50 ? '...' : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 첫 user 메시지 (없으면 'New Chat') 로부터 50자 cap title 생성. 너무 길면
|
||||
* `...` suffix. newline 은 공백으로 normalize. `buildSessionTitleFromText` 의 thin wrapper.
|
||||
*/
|
||||
export function buildSessionTitleFromHistory(history: ChatMessage[]): string {
|
||||
const firstMsg = history.find((m) => m.role === 'user')?.content;
|
||||
return buildSessionTitleFromText(typeof firstMsg === 'string' ? firstMsg : '', 'New Chat');
|
||||
}
|
||||
|
||||
/** Sidebar 의 sessions list — 각 entry 의 messageCount + history 함께 송신. */
|
||||
export function buildSessionListPayload(sessions: ChatSession[]) {
|
||||
return {
|
||||
type: 'sessionList' as const,
|
||||
value: sessions.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
timestamp: s.timestamp,
|
||||
brainProfileId: s.brainProfileId || '',
|
||||
messageCount: s.history.length,
|
||||
history: s.history,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** 특정 세션을 로드 완료했을 때 webview 에 전달 — UI 가 즉시 갱신. */
|
||||
export function buildSessionLoadedPayload(
|
||||
id: string,
|
||||
title: string,
|
||||
history: ChatMessage[],
|
||||
negativePrompt: string,
|
||||
) {
|
||||
return {
|
||||
type: 'sessionLoaded' as const,
|
||||
value: {
|
||||
id,
|
||||
title: title || 'Chat Session',
|
||||
history,
|
||||
negativePrompt,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { BrainProfile } from '../../config';
|
||||
|
||||
/**
|
||||
* Status broadcast 메시지의 *payload 빌더* 모음. webview send 호출은 여전히
|
||||
* sidebarProvider 가 담당 — 이 모듈은 stateless 데이터 변환만 책임.
|
||||
*
|
||||
* - buildBrainStatusPayload(profile, fileCount) — `brainStatus` 메시지
|
||||
* - buildBrainProfilesPayload(profiles, activeId) — `brainProfiles` 메시지
|
||||
* - buildReadyStatusPayload(deps) — `readyStatus` 메시지 (한 줄 readiness snapshot)
|
||||
*
|
||||
* 옛 코드: sidebarProvider 의 `_sendBrainStatus`, `_sendBrainProfiles`,
|
||||
* `_postBrainProfiles`, `_sendReadyStatus` 가 각자 payload 를 inline 으로 만들었음.
|
||||
* 단위 테스트 노출 + payload shape 변경 시 한 곳만 수정 + send/build 책임 분리.
|
||||
*
|
||||
* Why ready status 는 deps struct 패턴: provider 가 비싼 read (loadedModels probe,
|
||||
* findBrainFiles, scope 해상, etc.) 를 *수행* 한 뒤 결과만 넘기는 게 깔끔. 빌더가
|
||||
* 직접 I/O 해버리면 mock 어려움 + 캐시 무력화.
|
||||
*/
|
||||
|
||||
/** Sidebar 의 brain status 칩에 표시되는 정보 (count + path + name + 메타). */
|
||||
export function buildBrainStatusPayload(
|
||||
profile: BrainProfile,
|
||||
fileCount: number,
|
||||
): { type: 'brainStatus'; value: { count: number; path: string; name: string; description: string; repo: string } } {
|
||||
return {
|
||||
type: 'brainStatus',
|
||||
value: {
|
||||
count: fileCount,
|
||||
path: profile.localBrainPath,
|
||||
name: profile.name,
|
||||
description: profile.description || '',
|
||||
repo: profile.secondBrainRepo || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Brain 선택 dropdown 의 모델. config raw shape (any[]) 도 받아 동일 출력 shape
|
||||
* 으로 정규화 — 새로 추가/편집 직후 cfg.update() async 가 캐시에 반영되기 전에
|
||||
* 호출되는 케이스 대응.
|
||||
*/
|
||||
export function buildBrainProfilesPayload(
|
||||
profiles: ReadonlyArray<BrainProfile> | any[],
|
||||
activeBrainId: string,
|
||||
): { type: 'brainProfiles'; value: { activeBrainId: string; profiles: Array<{ id: string; name: string; path: string; description: string; repo: string }> } } {
|
||||
return {
|
||||
type: 'brainProfiles',
|
||||
value: {
|
||||
activeBrainId,
|
||||
profiles: (profiles as any[]).map((p: any) => ({
|
||||
id: p.id || '',
|
||||
name: p.name || '',
|
||||
path: p.localBrainPath || p.path || '',
|
||||
description: p.description || '',
|
||||
repo: p.secondBrainRepo || p.repo || '',
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar 한 줄 readiness 칩 ("engine online? model loaded? brain N files …").
|
||||
* Provider 가 비싼 lookup (loadedModels, findBrainFiles, scope 해상) 을 미리 수행
|
||||
* 한 뒤 결과만 deps 로 넘긴다.
|
||||
*/
|
||||
export interface ReadyStatusDeps {
|
||||
engineKind: 'lmstudio' | 'ollama';
|
||||
engineOnline: boolean | null;
|
||||
modelName: string;
|
||||
/** LM Studio 에서만 의미 — null = 미확인/probe 실패 */
|
||||
modelLoaded: boolean | null;
|
||||
modelParamB: number | null;
|
||||
brainName: string;
|
||||
brainFiles: number;
|
||||
agentName: string | null;
|
||||
agentScopeFolders: number;
|
||||
agentMapped: boolean;
|
||||
memoryEnabled: boolean;
|
||||
multiAgentEnabled: boolean;
|
||||
contextLength: number;
|
||||
/** 작은 모델 cap 적용 후 *실효* context. providers always 같지 않을 수 있음. */
|
||||
effectiveContextLength: number;
|
||||
cappedForSmallModel: boolean;
|
||||
lmStudioError: string | null;
|
||||
}
|
||||
|
||||
export function buildReadyStatusPayload(d: ReadyStatusDeps) {
|
||||
return {
|
||||
type: 'readyStatus' as const,
|
||||
value: {
|
||||
engine: {
|
||||
kind: d.engineKind,
|
||||
label: d.engineKind === 'lmstudio' ? 'LM Studio' : 'Ollama',
|
||||
online: d.engineOnline,
|
||||
},
|
||||
model: { name: d.modelName, loaded: d.modelLoaded, paramB: d.modelParamB },
|
||||
brain: { name: d.brainName, files: d.brainFiles },
|
||||
agent: { name: d.agentName, scopeFolders: d.agentScopeFolders, mapped: d.agentMapped },
|
||||
memory: d.memoryEnabled,
|
||||
multiAgent: d.multiAgentEnabled,
|
||||
contextLength: d.effectiveContextLength,
|
||||
nominalContextLength: d.contextLength,
|
||||
cappedForSmallModel: d.cappedForSmallModel,
|
||||
lmStudioError: d.lmStudioError,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* sidebarProvider 가 chronicle 기록 / 세션 title / YAML frontmatter 등에서 공통으로
|
||||
* 쓰는 작은 텍스트 변환 헬퍼. 모두 stateless.
|
||||
*
|
||||
* - slugify(value) — kebab-case slug (한/영/숫자 보존, 48자 cap)
|
||||
* - summarizeForTitle(value) — chat title 표시용 80자 trim
|
||||
* - escapeYamlString(value) — YAML double-quoted 문자열 안의 `\\` 와 `"` 이스케이프
|
||||
*/
|
||||
|
||||
export function slugify(value: string): string {
|
||||
const slug = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9가-힣]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 48);
|
||||
return slug || 'conversation';
|
||||
}
|
||||
|
||||
export function 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;
|
||||
}
|
||||
|
||||
export function escapeYamlString(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/** Wiki body snippet — 500자 trim. 비어 있으면 명시적 "Not captured." (chronicle 자동 채움 호환). */
|
||||
export function 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;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ChatMessage } from '../../agent';
|
||||
import { escapeYamlString, summarizeForTitle, summarizeTextForWiki } from './textHelpers';
|
||||
|
||||
/**
|
||||
* "Wiki raw data" 마크다운 빌더 — 사용자가 현재 chat 을 active brain 의
|
||||
* `raw-data/` 디렉토리에 저장할 때 쓰는 통합 markdown 포맷.
|
||||
*
|
||||
* 구조: YAML frontmatter (title, category, created_at, source, brain, status) +
|
||||
* Category / What This Contains / Expected Value / Discovery Process /
|
||||
* Conclusion / Coding Implementation Notes / Source Brain / Raw Conversation
|
||||
* Transcript 섹션. assistant 메시지 중 `rationale` 메타가 있는 것들은 "Process
|
||||
* Note" 로 따로 모아 *발견 과정* 섹션에 표시.
|
||||
*
|
||||
* Stateless — sidebarProvider 의 옛 private 메서드를 그대로 추출. 의존: textHelpers
|
||||
* 의 escape/summary 헬퍼들 + ChatMessage 타입.
|
||||
*/
|
||||
export function 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: "${escapeYamlString(summarizeForTitle(firstUserMessage))}"`,
|
||||
`category: "${escapeYamlString(meta.category)}"`,
|
||||
`created_at: "${meta.createdAt}"`,
|
||||
`source: "Astra conversation"`,
|
||||
`brain: "${escapeYamlString(meta.activeBrainName)}"`,
|
||||
'status: raw',
|
||||
'---',
|
||||
'',
|
||||
`# ${summarizeForTitle(firstUserMessage)}`,
|
||||
'',
|
||||
'## Category',
|
||||
meta.category,
|
||||
'',
|
||||
'## What This Contains',
|
||||
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: ${summarizeTextForWiki(latestUserMessage)}`,
|
||||
`- Latest assistant conclusion: ${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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일명 안전 timestamp — ISO 문자열의 `:` 와 `.` 을 `-` 로, `T` 를 `_` 로,
|
||||
* 끝의 `Z` 제거. 결과: `2026-05-24_14-30-45-123` 같은 패턴.
|
||||
*/
|
||||
export function formatTimestampForFile(date: Date): string {
|
||||
return date.toISOString().replace(/[:.]/g, '-').replace('T', '_').replace('Z', '');
|
||||
}
|
||||
@@ -35,12 +35,38 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
|
||||
return true;
|
||||
}
|
||||
logInfo(`[SLASH] handleSlashCommand entering`);
|
||||
await handleSlashCommand(data.value, provider._view.webview, provider._context);
|
||||
logInfo(`[SLASH] handleSlashCommand returned`);
|
||||
// Slash 명령 결과를 *chatHistory 에도* mirror — 다음 turn 의 LLM 이
|
||||
// 직전 명령 출력 (예: /stocks discover 발굴 결과) 을 컨텍스트로 보게 한다.
|
||||
// 안 하면 LLM 은 webview UI 만 보고 history 는 비어 있어서 "주식 데이터
|
||||
// 없음" 같은 환각 응답이 나옴.
|
||||
const realWebview = provider._view.webview;
|
||||
const captured: string[] = [];
|
||||
const captureWebview = {
|
||||
postMessage: (msg: any) => {
|
||||
if (msg && msg.type === 'streamChunk' && typeof msg.value === 'string') {
|
||||
captured.push(msg.value);
|
||||
}
|
||||
return realWebview.postMessage(msg);
|
||||
},
|
||||
};
|
||||
await handleSlashCommand(data.value, captureWebview as any, provider._context);
|
||||
const collected = captured.join('').trim();
|
||||
if (collected) {
|
||||
// CRITICAL: getHistory() 는 *filtered copy* 를 반환하므로 직접 push 하면
|
||||
// 원본 chatHistory 에 안 들어감 (silent fail). 새 배열로 setHistory.
|
||||
const current = provider._agent.getHistory();
|
||||
const updated = [
|
||||
...current,
|
||||
{ role: 'user' as const, content: data.value },
|
||||
{ role: 'assistant' as const, content: collected, internal: false },
|
||||
];
|
||||
provider._agent.setHistory(updated);
|
||||
}
|
||||
logInfo(`[SLASH] handleSlashCommand returned, captured ${collected.length} chars → history len ${provider._agent.getHistory().length}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, false);
|
||||
await provider._sessionState.setBlankChatActive(false);
|
||||
// ── 1인 기업 모드 우선 분기 ──
|
||||
// 회사 모드일 땐 들어온 메시지를 intent classifier에 한 번 통과시켜
|
||||
// (a) 잡담/질문/짧은 응답 → 일반 채팅 경로, (b) 후속 대화 → 일반 채팅
|
||||
@@ -214,6 +240,14 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
|
||||
await provider._sendChronicleProjects();
|
||||
await provider._restoreActiveSessionIntoView();
|
||||
await provider._sendReadyStatus();
|
||||
// Slash 명령 목록 — webview 의 `/` 자동완성 dropdown 이 사용.
|
||||
try {
|
||||
const { listSlashCommands } = await import('../features/datacollect/slashRouter');
|
||||
const cmds = listSlashCommands().map(c => ({ name: c.name, description: c.description || '' }));
|
||||
provider._view?.webview.postMessage({ type: 'slashCommandList', commands: cmds });
|
||||
} catch (e: any) {
|
||||
// Slash 라우터 로드 실패해도 채팅 자체는 동작해야 — silent skip.
|
||||
}
|
||||
// Restore the Project Architecture chip + watcher if the active project
|
||||
// was already running in architecture mode in a previous VS Code session.
|
||||
await provider._sendArchitectureStatus();
|
||||
@@ -243,9 +277,9 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
|
||||
provider._currentSessionId = null;
|
||||
provider._currentSessionBrainId = getActiveBrainProfile().id;
|
||||
provider._agent.resetConversation();
|
||||
await provider._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null);
|
||||
await provider._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null);
|
||||
await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, true);
|
||||
await provider._sessionState.setActiveSessionId(null);
|
||||
await provider._sessionState.setLastVisibleChat(null);
|
||||
await provider._sessionState.setBlankChatActive(true);
|
||||
// 직전 회사 turn 컨텍스트 캐시 비우기 — 새 세션은 followup 기준점이 없다.
|
||||
provider.clearLastCompanyTurnSummary();
|
||||
// 진행 중이던 alignment도 새 세션과 함께 폐기.
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
/**
|
||||
* Agent skill *.md 파일들과 그 부가 메타 (last selected, negativePrompt) 의
|
||||
* 영속 저장 layer. sidebarProvider 의 5개 file I/O 메서드 (`_getAgentsDir`,
|
||||
* `_sendAgentsList` 일부, `_createAgent` 일부, `_sendAgentContent` 일부,
|
||||
* `_updateAgent` 일부, `_deleteAgent` 일부) 에 흩어진 fs/globalState 호출을
|
||||
* 한 클래스로 묶음.
|
||||
*
|
||||
* Modal UI (showInputBox / showWarningMessage / openInEditorGroup) 와 webview
|
||||
* send 는 여전히 provider 가 담당 — 이 store 는 *파일 + globalState* 만 책임.
|
||||
*
|
||||
* 디렉토리 해상 우선순위:
|
||||
* 1) `g1nation.agentSkillsPath` 설정 (절대 경로, 없으면 mkdir, `~/...` expand)
|
||||
* 2) `<workspace>/.agent/skills` (없으면 mkdir)
|
||||
* 3) workspace 도 없으면 빈 문자열
|
||||
*/
|
||||
export class AgentSkillStore {
|
||||
private static readonly LAST_SELECTED_KEY = 'g1nation.lastAgentPath';
|
||||
|
||||
constructor(private readonly context: vscode.ExtensionContext) {}
|
||||
|
||||
/** Agent skill .md 들이 보관되는 디렉토리. 없으면 mkdir 시도. */
|
||||
getDir(): string {
|
||||
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 */ }
|
||||
}
|
||||
if (fs.existsSync(expanded)) return expanded;
|
||||
}
|
||||
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 '';
|
||||
}
|
||||
|
||||
/** Agent skill .md 파일 목록 — { name, path }. 디렉토리 없으면 빈 배열. */
|
||||
list(): Array<{ name: string; path: string }> {
|
||||
const dir = this.getDir();
|
||||
if (!dir || !fs.existsSync(dir)) return [];
|
||||
const result: Array<{ name: string; path: string }> = [];
|
||||
for (const f of fs.readdirSync(dir)) {
|
||||
if (f.endsWith('.md')) {
|
||||
result.push({ name: f.replace('.md', ''), path: path.join(dir, f) });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 마지막 선택 agent path — 없으면 'none'. */
|
||||
getLastSelectedPath(): string {
|
||||
return this.context.globalState.get<string>(AgentSkillStore.LAST_SELECTED_KEY, 'none');
|
||||
}
|
||||
|
||||
/** 'none' 도 명시 저장 가능 (selection 해제 케이스). */
|
||||
async setLastSelectedPath(agentPath: string): Promise<void> {
|
||||
await this.context.globalState.update(AgentSkillStore.LAST_SELECTED_KEY, agentPath);
|
||||
}
|
||||
|
||||
/** Negative prompt 는 agent 별로 따로 보관 — key = `negativePrompt:<path>`. */
|
||||
getNegativePrompt(agentPath: string): string {
|
||||
return this.context.globalState.get<string>(`negativePrompt:${agentPath}`, '');
|
||||
}
|
||||
|
||||
async setNegativePrompt(agentPath: string, prompt: string): Promise<void> {
|
||||
await this.context.globalState.update(`negativePrompt:${agentPath}`, prompt);
|
||||
}
|
||||
|
||||
/** Agent .md 본문 — 존재 안 하면 null. */
|
||||
readContent(agentPath: string): string | null {
|
||||
if (!fs.existsSync(agentPath)) return null;
|
||||
return fs.readFileSync(agentPath, 'utf8');
|
||||
}
|
||||
|
||||
/** 본문 덮어쓰기. 에러는 호출자에게 throw — UI 에서 toast 띄우는 책임 위로 위임. */
|
||||
writeContent(agentPath: string, content: string): void {
|
||||
fs.writeFileSync(agentPath, content, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 skill 파일 생성. name 을 sanitize 한 후 `<dir>/<safeName>.md` 에 템플릿 작성.
|
||||
* 이미 존재하면 그대로 둠 (idempotent — 사용자가 같은 이름 재진입해도 안전).
|
||||
* 디렉토리 해상 실패 / sanitize 결과 빈 문자열이면 null.
|
||||
*/
|
||||
createNew(name: string): string | null {
|
||||
const safeName = name.trim().replace(/[^a-zA-Z0-9_\-ㄱ-힝가-힣]/g, '_');
|
||||
if (!safeName) return null;
|
||||
const dir = this.getDir();
|
||||
if (!dir) return null;
|
||||
const filePath = path.join(dir, `${safeName}.md`);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, `# Agent Persona: ${safeName}\n\nAdd your instructions here...\n`, 'utf8');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 (path traversal 가드 포함). 결과:
|
||||
* - 'invalid' — agents 디렉토리 밖이거나 .md 가 아님
|
||||
* - 'missing' — 이미 사라짐 (lastSelected 정리 필요)
|
||||
* - 'deleted' — 정상 삭제
|
||||
* 에러 (perm 등) 는 throw — 호출자가 toast 처리.
|
||||
*/
|
||||
delete(agentPath: string): 'invalid' | 'missing' | 'deleted' {
|
||||
const agentsDir = path.resolve(this.getDir());
|
||||
const targetPath = path.resolve(agentPath);
|
||||
if (!targetPath.startsWith(`${agentsDir}${path.sep}`) || path.extname(targetPath) !== '.md') {
|
||||
return 'invalid';
|
||||
}
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
return 'missing';
|
||||
}
|
||||
fs.unlinkSync(targetPath);
|
||||
return 'deleted';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ApprovalDecision } from '../../features/company/dispatcher';
|
||||
|
||||
/**
|
||||
* 회사 모드 dispatcher 의 stage approval gate 들을 관리.
|
||||
*
|
||||
* 의도: dispatcher 가 stage 산출물 (예: 기획서 초안) 을 만든 후 사용자 승인을
|
||||
* 기다릴 때, sidebarProvider 가 *resolver* 를 등록해두고 사용자가 채팅 카드의
|
||||
* 버튼을 누르면 `resolve(stageId, decision)` 호출로 dispatcher 를 깨운다.
|
||||
*
|
||||
* 옛 코드는 이 Map 을 sidebarProvider 의 13개 state slot 중 하나로 박아두고
|
||||
* 직접 set/get/delete 했는데, 그 결과:
|
||||
* - turn abort 시 정리 책임이 어디에 있는지 모호 (clear() 호출이 누락된
|
||||
* code path 존재 가능성)
|
||||
* - 새 manager (예: 다른 종류의 gate) 추가 시 또 sidebarProvider 본문 부풀음
|
||||
*
|
||||
* Manager 클래스로 격리해 (a) 정리 책임 한 곳, (b) 단위 테스트 용이성,
|
||||
* (c) 새 매니저 추가 시 본문 변경 최소화 달성.
|
||||
*/
|
||||
export class ApprovalGateManager {
|
||||
private readonly pending = new Map<string, (d: ApprovalDecision) => void>();
|
||||
|
||||
/**
|
||||
* Dispatcher 가 stage approval 대기 진입 시 resolver 등록. 사용자가 카드 버튼을
|
||||
* 누르면 `resolve()` 가 호출돼 await 가 깨어남.
|
||||
*/
|
||||
register(stageId: string, resolver: (d: ApprovalDecision) => void): void {
|
||||
// 같은 stageId 가 이미 등록돼 있으면 그건 dispatcher 의 중복 호출 — 기존
|
||||
// resolver 를 abort 로 처리해 dangling promise 방지.
|
||||
const existing = this.pending.get(stageId);
|
||||
if (existing) {
|
||||
try { existing({ kind: 'abort' }); } catch { /* noop */ }
|
||||
}
|
||||
this.pending.set(stageId, resolver);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 카드 클릭 → 등록된 resolver 호출. 등록 없으면 false (이미 timeout / abort
|
||||
* 됐을 가능성). true 면 dispatcher 의 await 가 깨어났음.
|
||||
*/
|
||||
resolve(stageId: string, decision: ApprovalDecision): boolean {
|
||||
const resolver = this.pending.get(stageId);
|
||||
if (!resolver) return false;
|
||||
this.pending.delete(stageId);
|
||||
try { resolver(decision); } catch { /* dispatcher 가 try 안에서 await 함, 무시 */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn abort 시 모든 대기 중인 gate 를 abort 로 정리. dispatcher 의 모든 stage
|
||||
* await 가 깨어나면서 turn 이 깨끗하게 종료된다.
|
||||
*/
|
||||
abortAll(): void {
|
||||
for (const resolver of this.pending.values()) {
|
||||
try { resolver({ kind: 'abort' }); } catch { /* noop */ }
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
/** 디버그 — 현재 대기 중인 gate 수. */
|
||||
get count(): number {
|
||||
return this.pending.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { logInfo, logError } from '../../utils';
|
||||
import { scanProject } from '../../features/projectArchitecture';
|
||||
import type { ProjectProfile } from '../../features/projectChronicle';
|
||||
|
||||
/**
|
||||
* 프로젝트 아키텍처 문서 자동 갱신용 file-system watcher 의 lifecycle 관리.
|
||||
*
|
||||
* 의도: 사용자가 프로젝트 루트의 구조적 파일(src/**, package.json …) 을 만질 때
|
||||
* 아키텍처 문서가 자동으로 따라오게 하되,
|
||||
* - 저장 burst 가 N 번의 regen 으로 폭주하지 않도록 6초 debounce
|
||||
* - signature 해시가 같으면 실제 regen 은 skip (cheap path)
|
||||
* - 같은 프로젝트가 이미 watch 중이면 재등록 안함 (idempotent)
|
||||
*
|
||||
* 옛 코드는 sidebarProvider 의 3개 state slot + 3개 메서드로 흩어져 있어,
|
||||
* 프로젝트 전환 / 비활성화 경로마다 정리 호출이 빠지면 watcher 가 leak 되거나
|
||||
* 두 개의 watcher 가 동시에 trigger 되는 미묘한 버그가 있었음.
|
||||
*
|
||||
* Manager 로 격리해 (a) lifecycle 단일 진입점, (b) 정리 책임 한 곳, (c)
|
||||
* dispose() 로 한 줄에 처리 가능.
|
||||
*/
|
||||
export class ArchitectureWatchManager {
|
||||
private watcher?: vscode.FileSystemWatcher;
|
||||
private debounceTimer?: NodeJS.Timeout;
|
||||
private watchedProjectId?: string;
|
||||
|
||||
constructor(
|
||||
/** Debounce 만료 시 현재 활성 프로젝트 조회. provider 가 보유. */
|
||||
private readonly getActiveProject: () => ProjectProfile | null,
|
||||
/** Signature 변화가 있을 때 실제 regen 을 트리거. provider 가 보유. */
|
||||
private readonly refreshArchitecture: () => Promise<void>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 프로젝트 루트 watch 시작. 같은 projectId 가 이미 watch 중이면 noop.
|
||||
* autoUpdate=false 면 watchedProjectId 만 기록하고 실제 watcher 는 안 만든다
|
||||
* (다시 토글되면 정상 등록).
|
||||
*/
|
||||
register(profile: ProjectProfile): void {
|
||||
if (!profile.projectRoot) return;
|
||||
if (this.watchedProjectId === profile.projectId && this.watcher) return;
|
||||
this.dispose();
|
||||
if (profile.architectureAutoUpdate === false) {
|
||||
this.watchedProjectId = 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.scheduleRefresh();
|
||||
watcher.onDidCreate(onChange);
|
||||
watcher.onDidDelete(onChange);
|
||||
watcher.onDidChange(onChange);
|
||||
this.watcher = watcher;
|
||||
this.watchedProjectId = profile.projectId;
|
||||
logInfo('architecture: watcher registered.', { projectId: profile.projectId, root: profile.projectRoot });
|
||||
}
|
||||
|
||||
/** Watcher + debounce timer 모두 정리. 프로젝트 전환/비활성화 시 호출. */
|
||||
dispose(): void {
|
||||
try { this.watcher?.dispose(); } catch { /* noop */ }
|
||||
this.watcher = undefined;
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = undefined;
|
||||
}
|
||||
this.watchedProjectId = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 6 s debounce: 저장 burst 가 한 번의 regen 으로 합쳐지고, "updated 2m ago"
|
||||
* 뱃지가 여전히 믿을 만한 수준의 latency. signature 가 안 바뀌면 skip.
|
||||
*/
|
||||
private scheduleRefresh(): void {
|
||||
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(async () => {
|
||||
const profile = this.getActiveProject();
|
||||
if (!profile || !profile.projectRoot || profile.architectureAutoUpdate === false) return;
|
||||
try {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { getActiveBrainProfile } from '../../utils';
|
||||
import type { ChatMessage } from '../../agent';
|
||||
|
||||
/**
|
||||
* Chat 세션 영구 저장 layer — `chat_sessions` globalState key 에 대한 CRUD 와
|
||||
* 정규화(corrupt/legacy raw → 검증된 ChatSession) 를 한 곳에 모음.
|
||||
*
|
||||
* 옛 코드: sidebarProvider 의 3개 메서드 `_getSessions`, `_getSessionById`,
|
||||
* `_putSessions` 로 흩어져 있었고 normalize/validate 로직이 두 read 메서드에
|
||||
* 거의 같게 복붙돼 있었음 — 한 쪽만 수정하면 다른 쪽에서 버그가 새는 위험.
|
||||
*
|
||||
* Manager 로 격리해 (a) 정규화 로직 단일화, (b) 50개 cap 같은 정책 한 곳, (c)
|
||||
* "이 데이터가 어디서 왔는가" 가 import 만 봐도 명확.
|
||||
*
|
||||
* 한계 — *오직* `chat_sessions` key 만 책임. activeSessionStateKey /
|
||||
* blankChatStateKey / lastVisibleChatStateKey 는 여전히 provider 가 관리 (각각
|
||||
* UI flow / restore 와 강하게 엮여 있어 함께 옮기면 deps 가 폭증).
|
||||
*/
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
timestamp: number;
|
||||
history: ChatMessage[];
|
||||
brainProfileId: string;
|
||||
negativePrompt?: string;
|
||||
}
|
||||
|
||||
/** chat_sessions globalState 키 — provider 와 store 가 동의해야 하는 단일 source of truth. */
|
||||
const SESSIONS_KEY = 'chat_sessions';
|
||||
|
||||
/** 영구 저장에 보관할 최대 세션 수. 오래된 것부터 잘려나간다. */
|
||||
const MAX_SESSIONS = 50;
|
||||
|
||||
export class ChatSessionStore {
|
||||
constructor(private readonly context: vscode.ExtensionContext) {}
|
||||
|
||||
/**
|
||||
* 모든 세션을 timestamp 내림차순 정렬해 반환. 깨진/legacy entry 는 normalize
|
||||
* 단계에서 떨궈진다 (history 0개 이거나 id 없는 것 등). 최대 MAX_SESSIONS 개로
|
||||
* cap.
|
||||
*/
|
||||
getAll(): ChatSession[] {
|
||||
const raw = this.readRaw();
|
||||
return raw
|
||||
.map((session, index) => this.normalize(session, index))
|
||||
.filter((session): session is ChatSession => !!session)
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
.slice(0, MAX_SESSIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 id 의 세션 조회. 정상 normalize 통과한 것만 반환 — 깨졌으면 null.
|
||||
* `getAll().find(...)` 와 같은 효과지만 raw 1개만 처리하므로 가볍다.
|
||||
*/
|
||||
getById(id: string): ChatSession | null {
|
||||
const raw = this.readRaw();
|
||||
const found = raw.find((session: any) => String(session?.id) === String(id));
|
||||
if (!found) return null;
|
||||
return this.normalize(found, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 세션 배열 덮어쓰기. 호출자는 미리 정렬해 넘기든 말든 무관 — store 는
|
||||
* 50개 cap 만 강제. *덮어쓰기* 이므로 부분 갱신은 호출자가 read → modify → write
|
||||
* 패턴으로 해야 함.
|
||||
*/
|
||||
async putAll(sessions: ChatSession[]): Promise<void> {
|
||||
await this.context.globalState.update(SESSIONS_KEY, sessions.slice(0, MAX_SESSIONS));
|
||||
}
|
||||
|
||||
/** Raw globalState 읽기. 항상 배열 보장. */
|
||||
private readRaw(): any[] {
|
||||
return this.context.globalState.get<any[]>(SESSIONS_KEY, []) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw session entry → 검증된 ChatSession 또는 null. 검증 규칙:
|
||||
* - id 가 falsy 면 reject
|
||||
* - history 가 배열 아니면 빈 배열로 강제
|
||||
* - history 안의 message 는 (user/assistant/system) + content !== undefined 만 통과
|
||||
* - 검증 후 history 0개면 reject (빈 세션은 표시 가치 0)
|
||||
* Title 이 없거나 비면 첫 user 메시지에서 50자 fallback (없으면 `Chat <index>`).
|
||||
*/
|
||||
private normalize(raw: any, index: number): ChatSession | null {
|
||||
const history: ChatMessage[] = Array.isArray(raw?.history)
|
||||
? raw.history.filter((message: any) =>
|
||||
message
|
||||
&& (message.role === 'user' || message.role === 'assistant' || message.role === 'system')
|
||||
&& message.content !== undefined)
|
||||
: [];
|
||||
|
||||
if (!raw?.id || history.length === 0) return null;
|
||||
|
||||
const firstMsg = history.find((message) => 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(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 || ''),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { ProjectProfile } from '../../features/projectChronicle';
|
||||
import { workspaceAutoDetectedProfile } from '../builders/projectProfileFactories';
|
||||
|
||||
/**
|
||||
* Project Chronicle 의 *프로젝트 목록* globalState read/write layer.
|
||||
*
|
||||
* 옛 코드: sidebarProvider 의 `_getChronicleProjects` (40줄, validation + workspace
|
||||
* 자동 등록 fallback) + `_putChronicleProjects` (한 줄) + 두 static state key.
|
||||
* Store 로 격리해 (a) `chronicleProjects` key drift 방지, (b) workspace fallback
|
||||
* 정책 한 곳, (c) validation 규칙 단일화.
|
||||
*
|
||||
* 의도된 한계: `activeChronicleProjectStateKey` (현재 활성 project id) 는 여전히
|
||||
* provider 가 관리 — UI flow / 활성 전환 흐름과 강하게 엮여 있어 함께 옮기면
|
||||
* deps 폭증.
|
||||
*
|
||||
* `getAll()` 의 fallback: 저장된 valid project 가 0 개면 *현재 workspace 의 first
|
||||
* folder 를 즉석 등록* (사용자가 별도 설정 없이도 chronicle 기능 사용 가능). 이건
|
||||
* read 가 가질 수 있는 신선한 default → 호출자는 호출마다 같은 모양을 보장받음.
|
||||
*/
|
||||
export class ChronicleProjectStore {
|
||||
private static readonly PROJECTS_KEY = 'g1nation.chronicleProjects';
|
||||
|
||||
constructor(private readonly context: vscode.ExtensionContext) {}
|
||||
|
||||
/**
|
||||
* 저장된 chronicle project 목록. validation 통과한 entry 가 0 개면 현재
|
||||
* workspace folder 로 즉석 default 1개를 생성해 반환 (저장은 하지 않음 —
|
||||
* 호출자가 setActive 등을 할 때 별도로 putAll 됨).
|
||||
*/
|
||||
getAll(): ProjectProfile[] {
|
||||
const raw = this.context.globalState.get<ProjectProfile[]>(ChronicleProjectStore.PROJECTS_KEY, []) || [];
|
||||
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 [];
|
||||
return [workspaceAutoDetectedProfile(workspaceRoot)];
|
||||
}
|
||||
|
||||
async putAll(projects: ProjectProfile[]): Promise<void> {
|
||||
await this.context.globalState.update(ChronicleProjectStore.PROJECTS_KEY, projects);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { RequirementContract } from '../../features/company';
|
||||
|
||||
/**
|
||||
* 1인 기업 모드 turn 의 runtime 상태 — *진행 중* turn 의 abort handle 과
|
||||
* *방금 끝난* turn 의 요약 스냅샷을 함께 보관.
|
||||
*
|
||||
* 옛 코드는 sidebarProvider 의 두 slot(`_companyAbort`, `_lastCompanyTurnSummary`)
|
||||
* 으로 흩어져 있어, "turn 끝" 시 abort 정리와 summary 갱신이 서로 다른 분기에서
|
||||
* 일어났음. Manager 로 묶어 turn lifecycle 의 진입/종료 책임을 한 곳에 모았다.
|
||||
*
|
||||
* Summary 슬롯은 *완료된* turn 만 채운다 (abort/error 는 비움 유지) — intent
|
||||
* classifier 가 "이전 turn 후속" vs "신규 task" 를 판단할 때 abort 된 turn 을
|
||||
* 후속의 기준으로 삼으면 안 되기 때문.
|
||||
*/
|
||||
export class CompanyTurnRuntime {
|
||||
private abortController?: AbortController;
|
||||
private lastSummary?: {
|
||||
brief: string;
|
||||
reportTail: string;
|
||||
finishedAt: number;
|
||||
};
|
||||
|
||||
/** Turn 시작 — fresh AbortController 등록. dispatcher signal 로 전달됨. */
|
||||
startTurn(): AbortController {
|
||||
const ctrl = new AbortController();
|
||||
this.abortController = ctrl;
|
||||
return ctrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn 종료(완료/실패/abort 무관) — finally 절에서 호출. 우리가 발급한 그
|
||||
* controller 일 때만 비워 race 방지 (느린 cleanup 이 새 turn 의 controller
|
||||
* 를 잘못 지우는 경우 차단).
|
||||
*/
|
||||
endTurn(ctrl: AbortController): void {
|
||||
if (this.abortController === ctrl) this.abortController = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop 버튼 — abort 신호 emit. 호출 시점에 진행 중 turn 없으면 false.
|
||||
* 호출자(provider) 는 추가로 ApprovalGateManager.abortAll() 도 호출해야
|
||||
* dispatcher 의 stage gate await 가 함께 깨어남.
|
||||
*/
|
||||
abort(): boolean {
|
||||
if (!this.abortController) return false;
|
||||
this.abortController.abort();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** report-done 시점에 호출. abort / error 케이스는 호출 안 됨 (intent 의도). */
|
||||
recordSummary(brief: string, reportTail: string): void {
|
||||
this.lastSummary = { brief, reportTail, finishedAt: Date.now() };
|
||||
}
|
||||
|
||||
/** Intent classifier 가 follow-up 판단할 때 읽음. cold start 면 undefined. */
|
||||
getLastSummary() {
|
||||
return this.lastSummary;
|
||||
}
|
||||
|
||||
/** 새 chat / 다른 세션 load 시 — 옛 report 가 의도 분류 오염하지 않게. */
|
||||
clearLastSummary(): void {
|
||||
this.lastSummary = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Intent Alignment 의 *현재 미해결* 상태. 분석기가 round 를 끝낸 후 사용자
|
||||
* 확인이 필요한 경우 이 슬롯에 contract 를 보관 → 다음 사용자 메시지를 답변으로
|
||||
* 해석. 한 번에 한 alignment 만 진행 (회사 모드는 sequential).
|
||||
*
|
||||
* 옛 코드는 sidebarProvider 의 단일 slot 으로 4개 메서드에 흩어져 있었음.
|
||||
* Manager 로 모아 set/get/clear 책임 단일화 + 외부에서는 isPending() / consume()
|
||||
* 으로 안전하게 사용.
|
||||
*/
|
||||
export interface PendingAlignment {
|
||||
userOriginalPrompt: string;
|
||||
contract: RequirementContract;
|
||||
/** 이번 alignment 동안 사용자가 답한 누적 라운드 수 — 무한 라운드 방지. */
|
||||
roundsAsked: number;
|
||||
/** 이번 turn 한정 pipeline override (Phase 4 분류기에서 전달된 추천). */
|
||||
pipelineIdOverride?: string;
|
||||
}
|
||||
|
||||
export class AlignmentSession {
|
||||
private pending?: PendingAlignment;
|
||||
|
||||
isPending(): boolean {
|
||||
return !!this.pending;
|
||||
}
|
||||
|
||||
/** 카드 push 시 호출 — 분석기 결과 보관. */
|
||||
set(state: PendingAlignment): void {
|
||||
this.pending = state;
|
||||
}
|
||||
|
||||
/** 현재 보류 상태 조회 (consume 안 함). */
|
||||
get(): PendingAlignment | undefined {
|
||||
return this.pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 번에 *읽고 비움* — 사용자가 진행 / 답변 시 호출. consume 패턴으로 race
|
||||
* (사용자가 진행 누르고 곧바로 답변 메시지 보내는 경우) 의 두 번째 호출이
|
||||
* 자동으로 noop 이 되어 안전.
|
||||
*/
|
||||
consume(): PendingAlignment | undefined {
|
||||
const cur = this.pending;
|
||||
this.pending = undefined;
|
||||
return cur;
|
||||
}
|
||||
|
||||
/** 취소 — 카드 / streamEnd push 는 호출자(provider) 책임. */
|
||||
clear(): void {
|
||||
this.pending = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { logInfo, logError, summarizeText } from '../../utils';
|
||||
import type { SidebarLmStudioDeps } from '../../sidebarProvider';
|
||||
|
||||
/**
|
||||
* 모델 dropdown 갱신용 cache + discovery layer. sidebarProvider 의 `_sendModels`
|
||||
* 흐름에서 *순수 가져오기 + cache 책임* 만 분리. defaultModel 재배치 / loadedModels
|
||||
* probe / cloud model 합치기 / webview 송신 / readyStatus 갱신은 여전히 provider 가
|
||||
* 담당 (webview 송신 deps 가 깊어 함께 옮기면 코드만 늘어남).
|
||||
*
|
||||
* 옛 코드: provider 의 3개 state slot (`_modelsCache`, `_modelDiscoveryInFlight`,
|
||||
* static `MODELS_CACHE_TTL_MS`) + `_sendModels` 내부 50줄짜리 discovery 블록. 이걸
|
||||
* 하나의 class 로 묶어 (a) cache TTL 정책 한 곳, (b) inflight guard 단일화,
|
||||
* (c) SDK→REST fallback 시퀀스가 단위 테스트 가능.
|
||||
*
|
||||
* Cache 정책:
|
||||
* - url 이 바뀌면 즉시 stale (다른 엔진 hop 후 옛 결과 못 받게)
|
||||
* - fetchedAt + ttlMs 지나면 stale
|
||||
* - `force=true` 면 stale 취급
|
||||
*/
|
||||
export class ModelDiscovery {
|
||||
private cache: { url: string; models: string[]; online: boolean; fetchedAt: number } | null = null;
|
||||
private inflight = false;
|
||||
|
||||
constructor(private readonly ttlMs: number) {}
|
||||
|
||||
isInflight(): boolean {
|
||||
return this.inflight;
|
||||
}
|
||||
|
||||
/** Inflight 진입 시도. 이미 진행 중이면 false (호출자 skip 해야 함). */
|
||||
tryEnter(): boolean {
|
||||
if (this.inflight) return false;
|
||||
this.inflight = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Inflight 해제 — finally 절에서 무조건 호출. */
|
||||
release(): void {
|
||||
this.inflight = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache 가 유효하면 그 결과 반환, 아니면 null. force 면 unconditionally null.
|
||||
*/
|
||||
getCached(url: string, force: boolean): { models: string[]; online: boolean } | null {
|
||||
if (force) return null;
|
||||
const c = this.cache;
|
||||
if (!c) return null;
|
||||
if (c.url !== url) return null;
|
||||
if (Date.now() - c.fetchedAt >= this.ttlMs) return null;
|
||||
return { models: c.models.slice(), online: c.online };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ready status 가 빠르게 읽는 online flag. cache 없으면 null (모름).
|
||||
* URL mismatch 도 null — 이전 엔진의 online 을 새 엔진 패널에 표시하지 않게.
|
||||
*/
|
||||
cachedOnline(url: string): boolean | null {
|
||||
const c = this.cache;
|
||||
if (!c) return null;
|
||||
if (c.url !== url) return null;
|
||||
return c.online;
|
||||
}
|
||||
|
||||
setCached(url: string, models: string[], online: boolean): void {
|
||||
this.cache = { url, models: models.slice(), online, fetchedAt: Date.now() };
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 엔진에서 모델 목록을 가져옴. 시도 순서:
|
||||
* 1) LM Studio + SDK 있으면 `system.listDownloadedModels('llm')` — REST 와 달리
|
||||
* JIT 설정에 관계없이 다운로드된 모든 LLM 반환. macOS 에서 dropdown 비는
|
||||
* 회귀의 근본 해결책.
|
||||
* 2) (1) 실패/0개면 REST `/v1/models` (lmstudio) 또는 `/api/tags` (ollama).
|
||||
*
|
||||
* online = `models.length > 0`. 두 경로 모두 실패하면 빈 배열 + online=false.
|
||||
*/
|
||||
async discoverFromEngine(opts: {
|
||||
url: string;
|
||||
engine: 'lmstudio' | 'ollama';
|
||||
modelsUrl: string;
|
||||
lmStudio?: SidebarLmStudioDeps;
|
||||
force: boolean;
|
||||
}): Promise<{ models: string[]; online: boolean }> {
|
||||
let models: string[] = [];
|
||||
|
||||
if (opts.engine === 'lmstudio' && opts.lmStudio) {
|
||||
try {
|
||||
logInfo('Model discovery started (SDK).', { engine: opts.engine, force: opts.force });
|
||||
const sdkModels = await opts.lmStudio.downloadedModels();
|
||||
if (sdkModels.length > 0) {
|
||||
models = sdkModels;
|
||||
logInfo('Model discovery succeeded (SDK).', {
|
||||
engine: opts.engine,
|
||||
count: models.length,
|
||||
modelsPreview: models.slice(0, 5),
|
||||
});
|
||||
} else {
|
||||
logInfo('LM Studio SDK returned no downloaded models — falling back to REST /v1/models.', { engine: opts.engine });
|
||||
}
|
||||
} catch (e: any) {
|
||||
logError('LM Studio SDK model discovery failed — falling back to REST.', {
|
||||
engine: opts.engine,
|
||||
error: e?.message || String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
try {
|
||||
logInfo('Model discovery started (REST).', { engine: opts.engine, modelsUrl: opts.modelsUrl, force: opts.force });
|
||||
const res = await fetch(opts.modelsUrl, { signal: AbortSignal.timeout(5000) });
|
||||
const rawText = await res.text();
|
||||
if (!res.ok) {
|
||||
logError('Model discovery returned non-OK status.', {
|
||||
engine: opts.engine,
|
||||
modelsUrl: opts.modelsUrl,
|
||||
status: res.status,
|
||||
body: summarizeText(rawText, 300),
|
||||
});
|
||||
} else {
|
||||
const data = rawText ? JSON.parse(rawText) as any : {};
|
||||
models = opts.engine === 'lmstudio'
|
||||
? (data.data || []).map((m: any) => m.id)
|
||||
: (data.models || []).map((m: any) => m.name);
|
||||
|
||||
if (models.length > 0) {
|
||||
logInfo('Model discovery succeeded (REST).', {
|
||||
engine: opts.engine,
|
||||
count: models.length,
|
||||
modelsPreview: models.slice(0, 5),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
logError('Model discovery failed (REST).', {
|
||||
engine: opts.engine,
|
||||
modelsUrl: opts.modelsUrl,
|
||||
error: e?.message || String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { models, online: models.length > 0 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type {
|
||||
AgentStatus,
|
||||
AgentWorkState,
|
||||
AgentBubble,
|
||||
CompanyState,
|
||||
RequirementContract,
|
||||
} from '../../features/company';
|
||||
import { listActiveAgentsByCategory } from '../../features/company';
|
||||
import type { OfficeAgentSnapshot } from '../../features/astraOffice';
|
||||
import { renderAstraOfficePanelHtml } from '../../features/astraOffice';
|
||||
|
||||
/**
|
||||
* Pixel Office UI broadcast 에 쓰이는 pure helpers. 모두 stateless transform —
|
||||
* sidebarProvider 의 private 메서드를 그대로 추출.
|
||||
*
|
||||
* - mergeAgentWorkState(prev, patch) — broadcast 직전 state merge + updatedAt 갱신
|
||||
* - summariseContract(contract) — RequirementContract → webview 가 표시할 요약 모양
|
||||
* - buildOfficeRoster(state) — CompanyState → presenter 의 roster 입력
|
||||
*
|
||||
* webview send / 말풍선 sequencing / bubble dedupe 같은 *상태성* 흐름은 여전히
|
||||
* sidebarProvider 와 PixelOfficeState 에 남는다. 이 모듈은 *데이터 변환만* 책임.
|
||||
*/
|
||||
|
||||
const ROSTER_ORDER: Array<'ceo' | 'planner' | 'researcher' | 'designer' | 'developer' | 'qa' | 'inspector' | 'support'> = [
|
||||
'ceo', 'planner', 'researcher', 'designer', 'developer', 'qa', 'inspector', 'support',
|
||||
];
|
||||
|
||||
/**
|
||||
* 직전 상태에 patch 를 머지해 다음 state 생성. recentLogs 는 patch 가 명시
|
||||
* 안 했으면 prev 그대로 유지 (broadcast 중 일부 호출이 log 만 push 하지 않는
|
||||
* 케이스 — 그래도 옛 logs 가 화면에 살아 있어야 함). updatedAt 은 항상 갱신해
|
||||
* webview 의 "마지막 갱신" 뱃지가 작동.
|
||||
*/
|
||||
export function mergeAgentWorkState(
|
||||
prev: AgentWorkState | undefined,
|
||||
patch: Partial<AgentWorkState>,
|
||||
): AgentWorkState {
|
||||
const base = prev ?? {
|
||||
agentId: 'main',
|
||||
agentName: 'Agent',
|
||||
status: 'idle' as AgentWorkState['status'],
|
||||
recentLogs: [],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
...patch,
|
||||
recentLogs: patch.recentLogs ?? base.recentLogs ?? [],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RequirementContract 를 webview 카드 표시용 요약 객체로 변환. 빈 문자열 필드는
|
||||
* undefined 로 정규화해 카드 UI 에서 "값 없음" 분기를 단순화.
|
||||
*/
|
||||
export function summariseContract(c: RequirementContract): {
|
||||
goal: string | undefined;
|
||||
context: string | undefined;
|
||||
criteria: RequirementContract['criteria'];
|
||||
format: string | undefined;
|
||||
openQuestions: RequirementContract['openQuestions'];
|
||||
confidence: RequirementContract['confidence'];
|
||||
} {
|
||||
return {
|
||||
goal: c.goal || undefined,
|
||||
context: c.context || undefined,
|
||||
criteria: c.criteria,
|
||||
format: c.format || undefined,
|
||||
openQuestions: c.openQuestions,
|
||||
confidence: c.confidence,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 CompanyState 에서 active agents 를 평탄화해 presenter 의 roster 입력으로.
|
||||
* `state.enabled === false` 면 빈 배열 (presenter 가 옛 single-agent fallback 사용).
|
||||
* ROSTER_ORDER 순서 보존 — 화면에서 일관된 좌→우/위→아래 정렬.
|
||||
*/
|
||||
/**
|
||||
* 사이드바/패널 webview 가 받는 `pixelOfficeUpdate` 메시지 payload 빌드. 기능
|
||||
* 비활성화면 state=null + 빈 bubbles 로 webview 가 "꺼짐" 표시. 활성화면 cached
|
||||
* state (없으면 default idle agent) + 빈 bubbles + config snapshot.
|
||||
*
|
||||
* bubbles 는 *resend* (재전송) 용도라 항상 빈 배열 — 같은 말풍선을 reload 마다
|
||||
* 다시 띄우면 시각 노이즈. 새 broadcast 가 bubbles 를 채워준다.
|
||||
*/
|
||||
export function buildPixelOfficeUpdatePayload(
|
||||
cfg: { companyPixelOfficeEnabled: boolean; companyPixelOfficeBubbles: boolean },
|
||||
state: AgentWorkState | undefined,
|
||||
): { type: 'pixelOfficeUpdate'; value: { state: AgentWorkState | null; bubbles: AgentBubble[]; config: { enabled: boolean; bubblesEnabled: boolean; maxVisibleBubbles: number; bubbleDurationMs: number } } } {
|
||||
if (!cfg.companyPixelOfficeEnabled) {
|
||||
return {
|
||||
type: 'pixelOfficeUpdate',
|
||||
value: { state: null, bubbles: [], config: { enabled: false, bubblesEnabled: false, maxVisibleBubbles: 0, bubbleDurationMs: 0 } },
|
||||
};
|
||||
}
|
||||
const resolved: AgentWorkState = state ?? {
|
||||
agentId: 'main',
|
||||
agentName: 'Agent',
|
||||
status: 'idle' as AgentStatus,
|
||||
recentLogs: [],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
return {
|
||||
type: 'pixelOfficeUpdate',
|
||||
value: {
|
||||
state: resolved,
|
||||
bubbles: [],
|
||||
config: {
|
||||
enabled: cfg.companyPixelOfficeEnabled,
|
||||
bubblesEnabled: cfg.companyPixelOfficeBubbles,
|
||||
maxVisibleBubbles: 3,
|
||||
bubbleDurationMs: 4500,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pixel Office 전체보기 panel 의 HTML 본문. 사이드바 mini 와 같은 메시지 스키마를
|
||||
* 받지만 그리는 방식이 전혀 달라 (사무실 그리드 + 직군별 캐릭터) 별도 HTML 사용.
|
||||
* CSP source 와 derived asset base URI 만 webview 에서 가져오면 나머지는 정적.
|
||||
*/
|
||||
export function buildPixelOfficeHtml(extensionUri: vscode.Uri, webview: vscode.Webview): string {
|
||||
const cspSource = webview.cspSource;
|
||||
const derivedBase = webview.asWebviewUri(
|
||||
vscode.Uri.joinPath(extensionUri, 'assets', 'pixelOffice', 'derived'),
|
||||
).toString();
|
||||
return renderAstraOfficePanelHtml({ cspSource, derivedBase });
|
||||
}
|
||||
|
||||
export function buildOfficeRoster(state: CompanyState): Array<{
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
roleCategory: OfficeAgentSnapshot['roleCategory'];
|
||||
}> {
|
||||
try {
|
||||
if (!state.enabled) return [];
|
||||
const buckets = listActiveAgentsByCategory(state);
|
||||
const out: Array<{ agentId: string; agentName: string; roleCategory: OfficeAgentSnapshot['roleCategory'] }> = [];
|
||||
for (const cat of ROSTER_ORDER) {
|
||||
for (const def of buckets[cat] ?? []) {
|
||||
out.push({
|
||||
agentId: def.id,
|
||||
agentName: def.name ?? def.id,
|
||||
roleCategory: cat,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { migrateLayout } from '../../features/astraOffice';
|
||||
|
||||
/**
|
||||
* Pixel Office 사용자 정의 레이아웃 (cells/decos) 의 workspaceState 영구 저장 layer.
|
||||
*
|
||||
* 옛 코드: sidebarProvider 의 3개 private 메서드 (`_readPixelOfficeLayout`,
|
||||
* `_writePixelOfficeLayout`, `_clearPixelOfficeLayout`) + 1개 static key 상수.
|
||||
* 한 줄짜리 wrapper 들이지만 *workspaceState key + migration validator* 정책을
|
||||
* 한 클래스에 묶어 (a) key drift 방지, (b) 새 v2→v3 migration 추가 시 한 곳만,
|
||||
* (c) layout 형태를 모르는 webview 호출자도 안전한 unknown 반환.
|
||||
*
|
||||
* Read 는 legacy v1 데이터를 `migrateLayout` 으로 정규화 — 정상 통과 못 한 데이터는
|
||||
* null 로 fallback (webview 가 디폴트 LAYOUT 사용).
|
||||
*/
|
||||
export class PixelOfficeLayoutStore {
|
||||
private static readonly KEY = 'g1nation.pixelOfficeLayout';
|
||||
|
||||
constructor(private readonly context: vscode.ExtensionContext) {}
|
||||
|
||||
/** 저장된 layout 조회. raw → migrateLayout 통과 못 하면 null. */
|
||||
read(): unknown {
|
||||
const raw = this.context.workspaceState.get(PixelOfficeLayoutStore.KEY);
|
||||
if (raw == null) return null;
|
||||
return migrateLayout(raw);
|
||||
}
|
||||
|
||||
/** webview 의 편집 모드에서 저장 버튼 누름 시 호출. */
|
||||
async write(layout: unknown): Promise<void> {
|
||||
await this.context.workspaceState.update(PixelOfficeLayoutStore.KEY, layout);
|
||||
}
|
||||
|
||||
/** 디폴트 LAYOUT 으로 복귀 (entry 자체를 삭제). */
|
||||
async clear(): Promise<void> {
|
||||
await this.context.workspaceState.update(PixelOfficeLayoutStore.KEY, undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { AgentWorkState } from '../../features/company';
|
||||
import type { OfficeActivityItem } from '../../features/astraOffice';
|
||||
|
||||
/**
|
||||
* Pixel Office 의 *상태 컨테이너* — webview 송신/이벤트 핸들링은 provider 에 남기고,
|
||||
* 4개로 흩어져 있던 raw state slot 만 한 객체로 묶는다.
|
||||
*
|
||||
* 옛 코드는 sidebarProvider 의 4개 slot 으로 분리돼 있어 (panel, currentState,
|
||||
* lastBubble Map, activity ring buffer) "panel 닫혔는데 ring buffer 가 안 비워졌다"
|
||||
* 같은 미묘한 stale state bug 가 발생할 위험이 있었음. 컨테이너로 묶어
|
||||
* resetForNewSession() 한 줄로 일괄 초기화 가능하게 만들었다.
|
||||
*
|
||||
* 왜 메서드들은 안 옮겼나: pixelOfficeOnTurnEvent 등은 webview 송신 + payload
|
||||
* 빌드 + ring buffer push 가 한 데 엮여 있어 이걸 manager 로 옮기면 webview send
|
||||
* callback 을 constructor 로 주입해야 하고 코드 양이 오히려 늘어남. State 만
|
||||
* 격리해도 god-file 의 state slot 13→9 라는 목표 효과는 달성.
|
||||
*/
|
||||
export class PixelOfficeState {
|
||||
/** 전체보기 webview panel — 사이드바 mini 와 별개 instance. */
|
||||
panel?: vscode.WebviewPanel;
|
||||
|
||||
/** 현재 turn 의 누적 상태. broadcast 직전 patch 가 합쳐진 결과 보관. */
|
||||
current?: AgentWorkState;
|
||||
|
||||
/** 같은 말풍선 텍스트 연속 출력 방지용 — key: "status:<s>" or "event:<e>". */
|
||||
private readonly lastBubbleByKey = new Map<string, string>();
|
||||
|
||||
/** Activity ring buffer — officeSnapshot.activity 에 포함되는 누적 action-tag 결과. */
|
||||
private activityBuffer: OfficeActivityItem[] = [];
|
||||
|
||||
private static readonly ACTIVITY_MAX = 32;
|
||||
|
||||
/** 직전 말풍선 텍스트 조회. duplicate 회피 로직에서 사용. */
|
||||
getLastBubble(key: string): string | undefined {
|
||||
return this.lastBubbleByKey.get(key);
|
||||
}
|
||||
|
||||
/** 말풍선 텍스트 기록. */
|
||||
setLastBubble(key: string, text: string): void {
|
||||
this.lastBubbleByKey.set(key, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 activity 버퍼 전체 조회 (snapshot 빌드에 사용). presenter 가
|
||||
* mutable 배열을 요구해 일단 같은 참조 그대로 반환 — 외부는 read-only 로
|
||||
* 다뤄야 함 (push 는 pushActivity() 만 사용).
|
||||
*/
|
||||
getActivity(): OfficeActivityItem[] {
|
||||
return this.activityBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity 항목 push + ring buffer cap 유지. agent-done 시 action-tag report
|
||||
* 가 여러 줄이면 한 번에 호출.
|
||||
*/
|
||||
pushActivity(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.activityBuffer.push({ ts: now, agentId: it.agentId, text: it.text, kind: it.kind });
|
||||
}
|
||||
if (this.activityBuffer.length > PixelOfficeState.ACTIVITY_MAX) {
|
||||
this.activityBuffer = this.activityBuffer.slice(-PixelOfficeState.ACTIVITY_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* recentLogs ring buffer push — 새 라인을 현재 상태의 recentLogs 끝에 붙여
|
||||
* 최근 6개만 보존한 새 배열을 반환. 호출자는 이 배열을 다음 broadcast 의
|
||||
* `patch.recentLogs` 로 전달해 next state 에 반영.
|
||||
*/
|
||||
appendLog(line: string): string[] {
|
||||
const cur = this.current?.recentLogs ?? [];
|
||||
return [...cur, line].slice(-6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { ChatMessage } from '../../agent';
|
||||
|
||||
/**
|
||||
* Chat 세션의 *side-state* globalState 3 keys 를 한 클래스로 통합:
|
||||
*
|
||||
* - `g1nation.activeSessionId` → 현재 활성 세션 id (또는 null)
|
||||
* - `g1nation.blankChatActive` → "새 채팅" 비어 있는 상태 플래그
|
||||
* - `g1nation.lastVisibleChat` → 마지막 가시 채팅의 snapshot (restore 용)
|
||||
*
|
||||
* 옛 코드: sidebarProvider 가 3개 static key 상수 + 흩어진 globalState.get/update
|
||||
* 호출로 직접 만지고 있었음. 한 곳으로 모아 (a) key drift 방지, (b) 타입 안전성
|
||||
* (boolean / string|null / snapshot 각각 typed accessor), (c) 새 metadata 추가 시
|
||||
* 한 파일만 수정.
|
||||
*
|
||||
* Note: `chat_sessions` 배열 자체는 `ChatSessionStore` 가 책임. 이 store 는
|
||||
* sessions 배열 자체가 아니라 *어느 게 활성이고, 마지막에 뭐가 보였고, 비어 있는
|
||||
* 상태인가* 같은 *연결 metadata* 만.
|
||||
*/
|
||||
|
||||
export interface LastVisibleChatSnapshot {
|
||||
history: ChatMessage[];
|
||||
brainProfileId: string;
|
||||
sessionId: string | null;
|
||||
timestamp: number;
|
||||
negativePrompt?: string;
|
||||
}
|
||||
|
||||
export class SessionStateStore {
|
||||
private static readonly ACTIVE_SESSION_KEY = 'g1nation.activeSessionId';
|
||||
private static readonly BLANK_CHAT_KEY = 'g1nation.blankChatActive';
|
||||
private static readonly LAST_VISIBLE_KEY = 'g1nation.lastVisibleChat';
|
||||
|
||||
constructor(private readonly context: vscode.ExtensionContext) {}
|
||||
|
||||
/** 현재 활성 세션 id. 없으면 null. */
|
||||
getActiveSessionId(): string | null {
|
||||
return this.context.globalState.get<string | null>(SessionStateStore.ACTIVE_SESSION_KEY, null);
|
||||
}
|
||||
|
||||
/** 'none' 같은 sentinel 없이 null 명시. delete 시에도 null 로 설정. */
|
||||
async setActiveSessionId(id: string | null): Promise<void> {
|
||||
await this.context.globalState.update(SessionStateStore.ACTIVE_SESSION_KEY, id);
|
||||
}
|
||||
|
||||
/** 사용자가 "새 채팅" 버튼 누르고 아직 한 줄도 입력 안 한 상태인지. */
|
||||
getBlankChatActive(): boolean {
|
||||
return this.context.globalState.get<boolean>(SessionStateStore.BLANK_CHAT_KEY, false);
|
||||
}
|
||||
|
||||
async setBlankChatActive(active: boolean): Promise<void> {
|
||||
await this.context.globalState.update(SessionStateStore.BLANK_CHAT_KEY, active);
|
||||
}
|
||||
|
||||
/** 마지막으로 가시했던 채팅 — sidebar reopen 시 즉시 복원에 사용. 없으면 null. */
|
||||
getLastVisibleChat(): LastVisibleChatSnapshot | null {
|
||||
return this.context.globalState.get<LastVisibleChatSnapshot | null>(SessionStateStore.LAST_VISIBLE_KEY, null);
|
||||
}
|
||||
|
||||
async setLastVisibleChat(snapshot: LastVisibleChatSnapshot | null): Promise<void> {
|
||||
await this.context.globalState.update(SessionStateStore.LAST_VISIBLE_KEY, snapshot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { SidebarChatProvider } from '../sidebarProvider';
|
||||
|
||||
/**
|
||||
* Sidebar webview message handler 등록소.
|
||||
*
|
||||
* 의도: 옛 sidebarProvider 의 if-chain (`if (await handleChat()) return; if (await
|
||||
* handleBrain()) return; ...`) 은 새 도메인 추가 시 항상 sidebarProvider.ts 본문을
|
||||
* 손대야 했다. 이 레지스트리는 도메인 핸들러를 *배열* 로 관리해 그 의존성을
|
||||
* 끊는다 — 새 핸들러는 array push 한 줄, sidebarProvider 본문은 무수정.
|
||||
*
|
||||
* 외부 플러그인 / 다른 모듈도 활성화 시점에 `registerSidebarHandler()` 한 번
|
||||
* 호출하면 message dispatch loop 에 자동 합류.
|
||||
*
|
||||
* Handler 계약:
|
||||
* - 처리했으면 true 반환 (dispatch 종료)
|
||||
* - 자기 도메인 아니면 false 반환 (다음 handler 로 chain)
|
||||
* - throw 는 호출자 (sidebarProvider) 가 잡아 unhandled 로 로그
|
||||
*/
|
||||
export type SidebarMessageHandler = (
|
||||
provider: SidebarChatProvider,
|
||||
data: any,
|
||||
) => Promise<boolean>;
|
||||
|
||||
const HANDLERS: SidebarMessageHandler[] = [];
|
||||
|
||||
/**
|
||||
* 새 sidebar handler 등록. 중복 등록 시 *추가* 함 (같은 함수 여러 번 등록 가능,
|
||||
* 호출자가 dedup 해야 함). 일반적으로 모듈 load 시점 1회만 호출.
|
||||
*/
|
||||
export function registerSidebarHandler(h: SidebarMessageHandler): void {
|
||||
HANDLERS.push(h);
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록 순서대로 handler 들을 순회. true 반환한 첫 핸들러에서 stop.
|
||||
* sidebarProvider 의 webview message dispatch 가 이걸 호출.
|
||||
*/
|
||||
export async function dispatchSidebarMessage(
|
||||
provider: SidebarChatProvider,
|
||||
data: any,
|
||||
): Promise<boolean> {
|
||||
for (const h of HANDLERS) {
|
||||
if (await h(provider, data)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 디버그 — 현재 등록된 핸들러 수. 테스트 setup/teardown 검증용. */
|
||||
export function _sidebarHandlerCount(): number {
|
||||
return HANDLERS.length;
|
||||
}
|
||||
Reference in New Issue
Block a user