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', '');
|
||||
}
|
||||
Reference in New Issue
Block a user