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:
g1nation
2026-05-25 09:59:32 +09:00
parent 4153f640c2
commit 0a97324f1b
149 changed files with 14628 additions and 6927 deletions
@@ -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 },
};
}
+41
View File
@@ -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;
}
+49
View File
@@ -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,
})),
};
}
+202
View File
@@ -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();
}
+117
View File
@@ -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 분석에 실패했습니다. 이 파일을 텍스트로 변환하여 다시 시도해주세요.)`);
}
}
+63
View File
@@ -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,
},
};
}
+107
View File
@@ -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,
},
};
}
+34
View File
@@ -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;
}
+97
View File
@@ -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', '');
}