v2.2.292-296: 메모리·발열 최적화 + Pixel Office 완전 제거 + 소형 모델 추론 강화(calculate/run_code/지식 스코프)

- v2.2.292 메모리·발열: 헬스체크 워크스페이스 쓰기→fs.access(워처 연쇄 제거)·git 검사 비동기 30분 주기,
  웹뷰 retainContextWhenHidden 정리(채팅만 유지), 브레인 인덱스 유휴 30분 TTL 해제,
  채팅 DOM 200개 상한, @lmstudio/sdk 지연 로드
- v2.2.293 Pixel Office 시각화 폐기: astraOffice 모듈·사이드바 매니저 3종·스프라이트 17MB 삭제,
  sidebarProvider collector 섹션 통삭제 (기업 모드 판단 로직 무변경, vsix 12.2MB→1.3MB)
- v2.2.294 도구 메뉴 'NotebookLM 백엔드 실행/종료' 버튼: OS 자동 감지, 프로젝트 자동 탐색+폴더 선택 저장,
  포트 정리 폴백 (터미널 없이 Research 백엔드 켜고 끄기)
- v2.2.295 추론 강화 1탄: [ACTION 16] <calculate> Python 계산 위임(결과 재주입·자가수정 루프),
  단계별 지식 스코프(General+Specialty, 결정적 도메인 분류, 설정 지식·기억 탭 UI)
- v2.2.296 추론 강화 2탄: [ACTION 17] <run_code> 실행 확인(stdout 회수→수정→재실행 루프),
  문법 오류 재주입 자가수정, 도구 라우팅 힌트(dynamicBlocks), Grounding rescue 도메인 스코프 확장
- 검증: jest 818 통과(신규 29개), tsc 무오류, esbuild 정상

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 18:59:47 +09:00
co-authored by Claude Fable 5
parent fa397d7fa1
commit bb7cd6b879
466 changed files with 1285 additions and 6588 deletions
+15 -2
View File
@@ -158,6 +158,8 @@ import type { HandlerContext } from './agent/actions/types';
import { applyFileCreateEditActions } from './agent/actions/fileCreateEdit';
import { applyFileDeleteReadActions } from './agent/actions/fileDeleteRead';
import { applyRunCommandActions } from './agent/actions/runCommand';
import { applyCalculateActions } from './agent/actions/calculate';
import { applyRunCodeActions } from './agent/actions/runCode';
import { applyListFilesActions } from './agent/actions/listFiles';
import { applyWebFetchActions } from './agent/actions/webFetch';
import { applyBrainOpsActions } from './agent/actions/brainOps';
@@ -1275,15 +1277,22 @@ export class AgentExecutor {
// warrant a follow-up LLM call.
const historyLenBeforeActions = this.chatHistory.length;
const report = await this.executeActions(cleanedVisible, rootPath, activeBrain);
const actionsInjectedContext = this.chatHistory.length > historyLenBeforeActions;
let actionsInjectedContext = this.chatHistory.length > historyLenBeforeActions;
// Self-Reflector Phase C — 일반 채팅 경로에서도 코드 파일 생성 직후
// syntax 체크 실행. 옵션 OFF면 통째로 skip.
try {
const cfgC = getConfig();
if (cfgC.selfReflectorExecutionEnabled && report.length > 0) {
const { verifyCreatedFiles } = await import('./features/selfReflector/selfReflectorExecution');
const { verifyCreatedFiles, buildSyntaxFixInstruction } = await import('./features/selfReflector/selfReflectorExecution');
const extra = await verifyCreatedFiles(report, rootPath);
if (extra.length > 0) report.push(...extra);
// [실행 루프] 문법 오류를 보고로 끝내지 않고 대화 컨텍스트로 재주입 —
// 아래 actionsInjectedContext 재계산을 거쳐 자동 후속 턴이 돌고, 모델이
// <edit_file> 로 즉시 수정 → 다음 라운드 재검증되는 자가수정 루프.
const fails = extra.filter((l) => l.startsWith('❌'));
if (fails.length > 0) {
this.chatHistory.push({ role: 'system', internal: true, content: buildSyntaxFixInstruction(fails) });
}
}
} catch (e: any) {
logError('selfReflector.C (chat): hook failed; continuing.', { error: e?.message ?? String(e) });
@@ -1301,6 +1310,8 @@ export class AgentExecutor {
} catch (e: any) {
logError('selfReflector.hollow (chat): hook failed; continuing.', { error: e?.message ?? String(e) });
}
// 검증 훅이 재주입한 컨텍스트(문법 오류 수정 지시)도 후속 턴 트리거에 반영.
actionsInjectedContext = this.chatHistory.length > historyLenBeforeActions;
if (!assistantContent.trim() && report.length === 0) {
const promptCharCount = messagesForRequest.reduce((sum, m) => sum + (m.content?.length ?? 0), 0);
logError('Model returned an empty response without actions.', {
@@ -1706,6 +1717,8 @@ export class AgentExecutor {
await applyFileCreateEditActions(ctx);
await applyFileDeleteReadActions(ctx);
await applyRunCommandActions(ctx);
await applyCalculateActions(ctx);
await applyRunCodeActions(ctx);
await applyListFilesActions(ctx);
await applyWebFetchActions(ctx);
await applyBrainOpsActions(ctx);
+115
View File
@@ -0,0 +1,115 @@
import { execFile } from 'child_process';
import type { HandlerContext } from './types';
/**
* <calculate> — 수학·논리·수치 계산을 Python 에 위임하는 액션 (ACTION 16).
*
* 배경: 로컬 소형 모델(4b급)의 대표 약점은 다단계 계산 중의 산술 실수다.
* "모델이 직접 계산"을 "모델은 코드로 변환만 → 실행은 Python → 모델은 결과 해석"
* 으로 바꾸면 계산 오류의 원천 자체가 사라진다 (모델은 판단만, 계산은 도구가).
*
* 흐름: 모델이 <calculate>print(...)</calculate> 를 emit → 여기서 `python -I -c` 로
* 실행(10초 타임아웃) → stdout/stderr 를 chatHistory 에 internal system 메시지로
* 주입 → agent 루프가 '컨텍스트 주입됨'을 감지해 자동 후속 턴을 돌리고 모델이
* 결과를 해석한다. 에러도 그대로 주입되므로 모델이 코드를 고쳐 다시 emit 하는
* 자가수정 루프가 자연히 성립한다 (read_file 과 동일한 재주입 메커니즘).
*
* 신뢰 모델: run_command(실제 터미널 실행)와 동일한 로컬 신뢰 수준. 다만
* `-I`(isolated) 플래그로 사용자 site-packages/환경변수 영향을 차단하고,
* 타임아웃·출력 상한·회당 실행 횟수 상한을 둔다.
*/
const CALC_TIMEOUT_MS = 10_000;
const MAX_CALC_PER_TURN = 4; // 한 턴에 과도한 실행 방지
const MAX_CODE_CHARS = 4_000;
const MAX_OUTPUT_CHARS = 4_000; // 모델 컨텍스트 보호
/** 감지된 Python 실행 파일 캐시 (프로세스 생존 동안 유지, null = 미탐지/없음). */
let _pythonCmd: string | null | undefined;
function execFileP(cmd: string, args: string[], timeout: number): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
execFile(cmd, args, { timeout, maxBuffer: 512 * 1024 }, (err, stdout, stderr) => {
if (err) reject(Object.assign(err, { stdout: String(stdout || ''), stderr: String(stderr || '') }));
else resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') });
});
});
}
/** python3 → python → py 순으로 실행 가능한 인터프리터 탐지 (OS 자동 대응, run_code 도 공유). */
export async function detectPython(): Promise<string | null> {
if (_pythonCmd !== undefined) return _pythonCmd;
for (const cmd of ['python3', 'python', 'py']) {
try {
await execFileP(cmd, ['--version'], 3_000);
_pythonCmd = cmd;
return cmd;
} catch { /* 다음 후보 */ }
}
_pythonCmd = null;
return null;
}
/** 테스트용 — 캐시 초기화. */
export function _resetPythonCache(): void { _pythonCmd = undefined; }
export async function applyCalculateActions(ctx: HandlerContext): Promise<void> {
const { aiMessage, report } = ctx;
const calcRegex = /<calculate>([\s\S]*?)<\/calculate>/gi;
let match: RegExpExecArray | null;
let ran = 0;
while ((match = calcRegex.exec(aiMessage)) !== null) {
if (ran >= MAX_CALC_PER_TURN) {
report.push(`⚠️ Calculate skipped: 한 턴 실행 한도(${MAX_CALC_PER_TURN}회) 초과`);
break;
}
const code = match[1].trim();
if (!code) continue;
if (code.length > MAX_CODE_CHARS) {
report.push(`❌ Calculate blocked: 코드가 너무 깁니다 (${code.length}자 > ${MAX_CODE_CHARS})`);
continue;
}
ran++;
const python = await detectPython();
if (!python) {
report.push('❌ Calculate unavailable: Python 미설치');
ctx.chatHistory.push({
role: 'system', internal: true,
content: '[Result of calculate — UNAVAILABLE]\nPython 이 이 컴퓨터에 설치되어 있지 않아 <calculate> 를 실행할 수 없습니다. 계산을 직접 수행하되, 산술 결과는 신중히 검산해서 답하십시오.',
});
return; // 인터프리터가 없으면 나머지 calculate 도 전부 불가
}
const label = code.replace(/\s+/g, ' ').slice(0, 60);
try {
const { stdout } = await execFileP(python, ['-I', '-c', code], CALC_TIMEOUT_MS);
const out = stdout.trim();
if (!out) {
// print 누락 — 모델이 스스로 고치도록 힌트를 재주입한다.
report.push(`⚠️ Calculated (no output): ${label}`);
ctx.chatHistory.push({
role: 'system', internal: true,
content: '[Result of calculate — EMPTY OUTPUT]\n코드는 실행됐지만 출력이 없습니다. 결과는 반드시 print(...) 로 출력해야 합니다. print 를 추가해 <calculate> 를 다시 emit 하십시오.',
});
} else {
const capped = out.length > MAX_OUTPUT_CHARS ? out.slice(0, MAX_OUTPUT_CHARS) + '\n… (truncated)' : out;
report.push(`🧮 Calculated: ${label}`);
ctx.chatHistory.push({
role: 'system', internal: true,
content: `[Result of calculate]\n\`\`\`\n${capped}\n\`\`\`\n이 실행 결과를 신뢰하고 그대로 사용해 답하십시오 (임의로 다시 암산하지 말 것).`,
});
}
} catch (err: any) {
// 에러도 재주입 — 모델이 코드를 고쳐 재시도하는 자가수정 루프.
const tail = String(err?.stderr || err?.message || err).trim().split('\n').slice(-8).join('\n').slice(0, 1200);
const timedOut = err?.killed || /ETIMEDOUT|timed? ?out/i.test(String(err?.message || ''));
report.push(`❌ Calculate failed: ${label}`);
ctx.chatHistory.push({
role: 'system', internal: true,
content: `[Result of calculate — ERROR]\n${timedOut ? `실행이 ${CALC_TIMEOUT_MS / 1000}초 안에 끝나지 않아 중단되었습니다. 더 효율적인 방법으로 다시 시도하십시오.` : `\`\`\`\n${tail}\n\`\`\`\n에러를 참고해 코드를 고친 뒤 <calculate> 를 다시 emit 하십시오. (표준 라이브러리만 사용 가능)`}`,
});
}
}
}
+119
View File
@@ -0,0 +1,119 @@
import { execFile } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { validatePath } from '../../security';
import { detectPython } from './calculate';
import type { HandlerContext } from './types';
/**
* <run_code> — 작성한 코드를 실제 실행해 결과를 확인하는 액션 (ACTION 17).
*
* 배경: 소형 모델 추론 강화 2축 "코딩 피드백 루프". run_command 는 터미널로만
* 보내고 stdout 을 회수하지 못해 모델이 실행 결과를 볼 수 없었다. 이 액션은
* 워크스페이스 안의 파일을 인터프리터로 직접 실행하고 stdout/stderr/종료코드를
* 대화 컨텍스트로 재주입한다 → "생성 → 실행 → 에러 확인 → 수정" 루프가
* 모델 주도로 성립한다 (calculate 와 동일한 재주입 메커니즘).
*
* 안전장치: validatePath 로 워크스페이스 밖 실행 차단, 15초 타임아웃,
* 회당 2회 상한, 출력 4천자 상한, 지원 확장자 화이트리스트(.py/.js/.mjs/.cjs).
*/
const RUN_TIMEOUT_MS = 15_000;
const MAX_RUNS_PER_TURN = 2;
const MAX_OUTPUT_CHARS = 4_000;
function execFileP(cmd: string, args: string[], cwd: string): Promise<{ code: number; stdout: string; stderr: string; timedOut: boolean }> {
return new Promise((resolve) => {
execFile(cmd, args, { cwd, timeout: RUN_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (err: any, stdout, stderr) => {
resolve({
code: err ? (typeof err.code === 'number' ? err.code : 1) : 0,
stdout: String(stdout || ''),
stderr: String(stderr || ''),
timedOut: !!err?.killed,
});
});
});
}
const cap = (s: string) => (s.length > MAX_OUTPUT_CHARS ? s.slice(0, MAX_OUTPUT_CHARS) + '\n… (truncated)' : s);
export async function applyRunCodeActions(ctx: HandlerContext): Promise<void> {
const { aiMessage, rootPath, report } = ctx;
const runRegex = /<run_code\s+path=['"]?([^'">\s]+)['"]?\s*\/?>(?:<\/run_code>)?/gi;
let match: RegExpExecArray | null;
let ran = 0;
while ((match = runRegex.exec(aiMessage)) !== null) {
if (ran >= MAX_RUNS_PER_TURN) {
report.push(`⚠️ run_code skipped: 한 턴 실행 한도(${MAX_RUNS_PER_TURN}회) 초과`);
break;
}
const relPath = match[1].trim();
ran++;
try {
const absPath = validatePath(rootPath, relPath);
if (!fs.existsSync(absPath)) {
report.push(`❌ run_code: 파일 없음 — ${relPath}`);
ctx.chatHistory.push({
role: 'system', internal: true,
content: `[Result of run_code — ERROR]\n파일이 존재하지 않습니다: ${relPath}. 경로를 확인하거나 <list_files> 로 실제 파일명을 확인하십시오.`,
});
continue;
}
const ext = path.extname(absPath).toLowerCase();
let cmd: string;
let args: string[];
if (ext === '.py') {
const py = await detectPython();
if (!py) {
report.push('❌ run_code: Python 미설치');
ctx.chatHistory.push({
role: 'system', internal: true,
content: '[Result of run_code — UNAVAILABLE]\nPython 이 설치되어 있지 않아 .py 파일을 실행할 수 없습니다. 실행 없이 코드 리뷰로만 검증하십시오.',
});
continue;
}
cmd = py; args = [absPath];
} else if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
cmd = 'node'; args = [absPath];
} else {
report.push(`⚠️ run_code: 지원하지 않는 확장자(${ext || '없음'}) — ${relPath}`);
ctx.chatHistory.push({
role: 'system', internal: true,
content: `[Result of run_code — UNSUPPORTED]\n<run_code> 는 .py/.js/.mjs/.cjs 만 실행합니다. ${relPath} 는 <run_command> 로 터미널에서 실행하십시오 (단, 그 경우 출력은 회수되지 않습니다).`,
});
continue;
}
const rel = path.relative(rootPath, absPath) || relPath;
const res = await execFileP(cmd, args, rootPath);
const outBlock = [
res.stdout.trim() ? `--- stdout ---\n${cap(res.stdout.trim())}` : '(stdout 없음)',
res.stderr.trim() ? `--- stderr ---\n${cap(res.stderr.trim())}` : '',
].filter(Boolean).join('\n');
if (res.timedOut) {
report.push(`❌ run_code timeout: ${rel}`);
ctx.chatHistory.push({
role: 'system', internal: true,
content: `[Result of run_code — TIMEOUT]\n${rel} 실행이 ${RUN_TIMEOUT_MS / 1000}초 안에 끝나지 않아 중단되었습니다.\n${outBlock}\n무한 루프·대기 입력 여부를 점검해 <edit_file> 로 수정한 뒤 다시 실행하십시오.`,
});
} else if (res.code === 0) {
report.push(`▶️ run_code OK: ${rel}`);
ctx.chatHistory.push({
role: 'system', internal: true,
content: `[Result of run_code] ${rel} — exit 0\n${outBlock}\n실행 결과가 의도와 일치하는지 확인하고, 이 실제 출력을 근거로 답하십시오 (추측 금지).`,
});
} else {
report.push(`❌ run_code FAIL(exit ${res.code}): ${rel}`);
ctx.chatHistory.push({
role: 'system', internal: true,
content: `[Result of run_code — ERROR] ${rel} — exit ${res.code}\n${outBlock}\n에러를 분석해 <edit_file> 로 코드를 수정한 뒤, 다시 <run_code> 로 실행해 확인하십시오 (수정→실행→확인 루프).`,
});
}
} catch (err: any) {
report.push(`❌ run_code blocked: ${err.message}`);
}
}
}
+13 -8
View File
@@ -286,6 +286,15 @@ export interface IAgentConfig {
* Bridge 추출 우선, 실패 시 직접 fetch 폴백.
*/
webAutoFetchEnabled: boolean;
/**
* [단계별 지식 스코프] 질문이 수학/코딩/팩트로 분류되면 전체 두뇌 대신
* General + 해당 Specialty 폴더만 RAG 검색한다 (소형 모델 정밀도 최적화).
* 값은 활성 두뇌 루트 기준 상대 경로. 비어 있으면 해당 도메인 스코프 미적용.
*/
domainKnowledgeGeneralFolder: string;
domainKnowledgeMathFolder: string;
domainKnowledgeCodingFolder: string;
domainKnowledgeFactsFolder: string;
/**
* Alignment 자가 조사 — openQuestions 를 사용자에게 보여주기 전에 두뇌를
* 검색해 스스로 답할 수 있는 질문을 걸러낸다. 끄면 기존 동작 (질문 즉시 노출).
@@ -338,12 +347,6 @@ export interface IAgentConfig {
* 깔려 있어야 의미가 있다.
*/
selfReflectorExecutionEnabled: boolean;
companyPixelOfficeEnabled: boolean;
/**
* Pixel Office의 캐릭터 말풍선 연출을 켤지. enabled가 true이고 이 값도
* true일 때만 말풍선이 생성된다. 시끄럽게 느껴지면 사용자가 끌 수 있게.
*/
companyPixelOfficeBubbles: boolean;
/**
* Multi-Agent 발동 모드:
* - 'auto' (기본): 작은 모델(≤4B) 감지 OR prompt가 컨텍스트의 큰 비중을 차지할 때만 자동 발동.
@@ -572,6 +575,10 @@ export function getConfig(): IAgentConfig {
// 둘이 어긋나면 안 되므로 변경 시 양쪽 같이 갱신.
companyIntentAlignmentMaxRounds: Math.max(1, Math.min(5, cfg.get<number>('company.intentAlignmentMaxRounds', 3))),
webAutoFetchEnabled: cfg.get<boolean>('web.autoFetchUrls', true),
domainKnowledgeGeneralFolder: cfg.get<string>('domainKnowledge.generalFolder', '') || '',
domainKnowledgeMathFolder: cfg.get<string>('domainKnowledge.mathFolder', '') || '',
domainKnowledgeCodingFolder: cfg.get<string>('domainKnowledge.codingFolder', '') || '',
domainKnowledgeFactsFolder: cfg.get<string>('domainKnowledge.factsFolder', '') || '',
companyAlignmentSelfResearch: cfg.get<boolean>('company.alignmentSelfResearch', true),
companyAlignmentKnowledgeSave: cfg.get<boolean>('company.alignmentKnowledgeSave', true),
selfReflectorEnabled: cfg.get<boolean>('selfReflector.enabled', false),
@@ -579,8 +586,6 @@ export function getConfig(): IAgentConfig {
hollowCheckAutoRetry: cfg.get<boolean>('hollowCheck.autoRetry', true),
selfReflectorExternalEnabled: cfg.get<boolean>('selfReflector.externalVerification', false),
selfReflectorExecutionEnabled: cfg.get<boolean>('selfReflector.executionVerification', false),
companyPixelOfficeEnabled: cfg.get<boolean>('company.pixelOffice.enabled', true),
companyPixelOfficeBubbles: cfg.get<boolean>('company.pixelOffice.bubbles', true),
workflowMultiAgentMode: ((): 'auto' | 'always' | 'off' => {
const v = (cfg.get<string>('workflow.multiAgentMode', 'auto') || 'auto').trim().toLowerCase();
return v === 'always' || v === 'off' ? v : 'auto';
+70 -42
View File
@@ -2,8 +2,11 @@ import * as vscode from 'vscode';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { execSync } from 'child_process';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getConfig } from '../config';
const execAsync = promisify(exec);
import { logInfo, logWarn, logError, getActiveBrainProfile } from '../utils';
import { getBridgeBaseUrl } from '../features/datacollect/bridgeClient';
@@ -24,12 +27,21 @@ export class HealthCheckMonitor {
private static _lastReports: string[] = [];
/** 직전 토스트 내용 — 같은 경고를 10분마다 반복 토스트하지 않기 위한 dedupe. */
private static _lastToastKey = '';
/** 인터벌 틱 카운터 — 느린 검사(git push·레지스트리)를 매 틱이 아니라 N틱마다 돌리기 위함. */
private static _tick = 0;
/** 느린 검사(git 자격증명·Antigravity 버전)의 최근 결과 캐시 — 빠른 틱에도 경고 표시가 유지되게. */
private static _slowReports: string[] = [];
public static get lastReports(): string[] {
return this._lastReports.slice();
}
public static async runAllChecks(): Promise<{ ok: boolean; reports: string[] }> {
/**
* @param includeSlowChecks git push --dry-run(원격 네트워크)·Antigravity 레지스트리 읽기 같은
* 비싼 검사 포함 여부. 활성화 시·수동 호출은 true(기본), 주기 인터벌은 3틱(30분)마다만 true.
* false 틱에서는 직전 느린 검사 결과(_slowReports)를 그대로 합쳐 경고 표시가 깜빡이지 않게 한다.
*/
public static async runAllChecks(includeSlowChecks: boolean = true): Promise<{ ok: boolean; reports: string[] }> {
const reports: string[] = [];
const config = getConfig();
@@ -52,11 +64,12 @@ export class HealthCheckMonitor {
}
// 3. Simple Disk/Permissions Check
// [발열 개선] 예전엔 워크스페이스에 파일을 실제로 쓰고 지웠는데, 워크스페이스 파일 변경은
// VS Code 본체와 모든 확장의 파일 워처를 주기마다 연쇄로 깨운다. fs.access(W_OK)는
// 파일을 만들지 않고 같은 질문("여기 쓸 수 있나")에 답하므로 워처 연쇄가 없다.
try {
const testFile = workspaceFolders ? vscode.Uri.joinPath(workspaceFolders[0].uri, '.g1-health-check') : null;
if (testFile) {
await vscode.workspace.fs.writeFile(testFile, Buffer.from('ok'));
await vscode.workspace.fs.delete(testFile);
if (workspaceFolders && workspaceFolders[0].uri.scheme === 'file') {
await fs.promises.access(workspaceFolders[0].uri.fsPath, fs.constants.W_OK);
logInfo('Health Check: Write permissions OK.');
}
} catch {
@@ -87,43 +100,52 @@ export class HealthCheckMonitor {
}
} catch { /* profile 읽기 실패 — skip */ }
// 6. git push 자격증명 — 원격 동기화를 *설정한* 두뇌만 검사 (secondBrainRepo
// 비어 있으면 두뇌 동기화가 로컬 새로고침 모드라 자격증명 불필요).
try {
if (brain?.secondBrainRepo?.trim() && brain.localBrainPath && fs.existsSync(brain.localBrainPath)) {
try {
execSync('git push --dry-run', {
cwd: brain.localBrainPath,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
timeout: 5000,
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch (e: any) {
const msg = String(e?.stderr || e?.message || '');
if (/could not read Username|Authentication failed|terminal prompts disabled/i.test(msg)) {
reports.push(`두뇌 원격 push 자격증명이 없습니다 — 터미널에서 "cd ${brain.localBrainPath} && git push" 1회 실행으로 키체인에 저장하세요.`);
}
// 그 외(오프라인·upstream 없음 등)는 소음 방지 — 보고하지 않음.
}
}
} catch { /* skip */ }
// ── 느린 검사 (6·7): 원격 네트워크·프로세스 생성·레지스트리 파싱 — 매 틱 돌리기엔 비싸다.
// includeSlowChecks 틱에서만 실행하고, 그 외 틱은 직전 결과를 그대로 합친다.
if (includeSlowChecks) {
const slowReports: string[] = [];
// 7. Antigravity 확장 버전 정합 — VS Code 와 Antigravity 양쪽에 설치된
// 경우 버전이 갈리면 "고쳤는데 안 고쳐짐" 혼란의 단골 원인 (실사례:
// 2.2.19 vs 2.2.232 공존). Antigravity 미사용 머신에서는 자동 skip.
try {
const agExtRegistry = path.join(os.homedir(), '.antigravity', 'extensions', 'extensions.json');
if (fs.existsSync(agExtRegistry)) {
const arr = JSON.parse(fs.readFileSync(agExtRegistry, 'utf8'));
const mine = Array.isArray(arr)
? arr.find((e: any) => e?.identifier?.id === 'g1nation.astra')
: null;
const myVersion = vscode.extensions.getExtension('g1nation.astra')?.packageJSON?.version;
if (mine?.version && myVersion && mine.version !== myVersion) {
reports.push(`Antigravity 에 다른 버전의 Astra(${mine.version})가 설치되어 있습니다 — 현재 실행 중 ${myVersion}. Antigravity 쪽은 수동 업데이트가 필요합니다.`);
// 6. git push 자격증명 — 원격 동기화를 *설정한* 두뇌만 검사 (secondBrainRepo
// 비어 있으면 두뇌 동기화가 로컬 새로고침 모드라 자격증명 불필요).
// [발열 개선] execSync(최대 5초 확장 호스트 블로킹) → 비동기 exec.
try {
if (brain?.secondBrainRepo?.trim() && brain.localBrainPath && fs.existsSync(brain.localBrainPath)) {
try {
await execAsync('git push --dry-run', {
cwd: brain.localBrainPath,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
timeout: 5000,
});
} catch (e: any) {
const msg = String(e?.stderr || e?.message || '');
if (/could not read Username|Authentication failed|terminal prompts disabled/i.test(msg)) {
slowReports.push(`두뇌 원격 push 자격증명이 없습니다 — 터미널에서 "cd ${brain.localBrainPath} && git push" 1회 실행으로 키체인에 저장하세요.`);
}
// 그 외(오프라인·upstream 없음 등)는 소음 방지 — 보고하지 않음.
}
}
}
} catch { /* registry 파싱 실패 — skip */ }
} catch { /* skip */ }
// 7. Antigravity 확장 버전 정합 — VS Code 와 Antigravity 양쪽에 설치된
// 경우 버전이 갈리면 "고쳤는데 안 고쳐짐" 혼란의 단골 원인 (실사례:
// 2.2.19 vs 2.2.232 공존). Antigravity 미사용 머신에서는 자동 skip.
try {
const agExtRegistry = path.join(os.homedir(), '.antigravity', 'extensions', 'extensions.json');
if (fs.existsSync(agExtRegistry)) {
const arr = JSON.parse(fs.readFileSync(agExtRegistry, 'utf8'));
const mine = Array.isArray(arr)
? arr.find((e: any) => e?.identifier?.id === 'g1nation.astra')
: null;
const myVersion = vscode.extensions.getExtension('g1nation.astra')?.packageJSON?.version;
if (mine?.version && myVersion && mine.version !== myVersion) {
slowReports.push(`Antigravity 에 다른 버전의 Astra(${mine.version})가 설치되어 있습니다 — 현재 실행 중 ${myVersion}. Antigravity 쪽은 수동 업데이트가 필요합니다.`);
}
}
} catch { /* registry 파싱 실패 — skip */ }
this._slowReports = slowReports;
}
reports.push(...this._slowReports);
this._lastReports = reports;
@@ -150,7 +172,13 @@ export class HealthCheckMonitor {
if (this.intervalHandle !== null) {
clearInterval(this.intervalHandle);
}
this.intervalHandle = setInterval(() => this.runAllChecks(), ms);
// 빠른 검사(서버·권한·브리지·두뇌 경로)는 매 틱, 느린 검사(git 원격·레지스트리)는 3틱마다.
// (기본 10분 인터벌 기준: 빠른 검사 10분, 느린 검사 30분 주기)
this._tick = 0;
this.intervalHandle = setInterval(() => {
this._tick++;
this.runAllChecks(this._tick % 3 === 0);
}, ms);
}
/**
-5
View File
@@ -96,11 +96,6 @@ export function registerProviderCommands(
vscode.window.showErrorMessage(`Sessions 폴더 열기 실패: ${e?.message ?? e}`);
}
}),
vscode.commands.registerCommand('g1nation.company.pixelOffice.open', () => {
// 사이드바 mini 패널과 별도로 editor area 에 전체 사무실 뷰. 같은
// pixelOfficeUpdate 스트림 공유 → 백엔드 변경 최소.
deps.getProvider()?.openPixelOfficePanel();
}),
// ── Google Calendar (iCal 읽기 / OAuth 쓰기) ────────────────────────
vscode.commands.registerCommand('g1nation.calendar.connect', async () => {
@@ -37,7 +37,9 @@ export class ApprovalPanelProvider implements vscode.WebviewViewProvider {
ApprovalPanelProvider.viewType,
'Pending Approvals',
column,
{ enableScripts: true, localResourceRoots: [this._extensionUri], retainContextWhenHidden: true }
// [메모리 개선] retainContextWhenHidden 제거 — 이 패널은 클라이언트 상태가 없고
// 큐 변경 시 백엔드가 html 을 다시 밀어넣으므로, 숨김 동안 렌더러를 상주시킬 이유가 없다.
{ enableScripts: true, localResourceRoots: [this._extensionUri] }
);
this._panel = panel;
const adapter = wrapPanelAsView(panel);
-18
View File
@@ -1,18 +0,0 @@
// Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema
// 도 같은 entry 로 노출 예정.
//
// 현재 노출: full webview panel HTML 생성 함수. sidebarProvider.ts 는 이 한 줄만 import.
export { renderAstraOfficePanelHtml } from './view/panelHtml';
export type { AstraOfficePanelAssets } from './view/panelHtml';
export type {
OfficeSnapshot,
OfficeAgentSnapshot,
OfficePhase,
OfficeActivityItem,
OfficeBubbleSeed,
} from './schema';
export { validateOfficeSnapshot, makeEmptyOfficeSnapshot } from './schema';
export { presentOfficeSnapshot } from './presenter';
export type { LayoutV2, OfficeDeskCell, OfficeProp } from './view/layoutSchema';
export { validateLayout, migrateLayout } from './view/layoutSchema';
-181
View File
@@ -1,181 +0,0 @@
/**
* Presenter — 옛 AgentWorkState + bubble queue + activity items 를 OfficeSnapshot 으로
* 변환하는 *pure* 함수. mini / full view 둘 다 같은 OfficeSnapshot 을 받게 만드는 게 목표.
*
* 이번 세션의 범위: 인터페이스 + 스텁. 실제 wiring 은 다음 세션에서:
* - sidebarProvider 의 `_pixelOfficeBroadcast` 가 옛 message 대신 OfficeSnapshot 송신
* - mini view (media/sidebar.js) 와 full view (features/astraOffice/view/runtime.ts)
* 둘 다 `officeSnapshot` message 를 받아 자기 식대로 렌더
*
* 이번 세션에 옛 message 와 OfficeSnapshot 을 *동시에* 보낼 수도 있게 — 호환 모드. 다음
* 세션에서 옛 message 제거.
*/
import type { AgentWorkState, AgentBubble } from '../company/pixelOfficeState';
import type { CompanyState } from '../company/types';
import {
type OfficeSnapshot,
type OfficeAgentSnapshot,
type OfficePhase,
type OfficeActivityItem,
type OfficeBubbleSeed,
makeEmptyOfficeSnapshot,
} from './schema';
/** mini/full view 가 받는 message envelope. */
export interface OfficeSnapshotMessage {
type: 'officeSnapshot';
value: OfficeSnapshot;
}
/** 옛 AgentWorkState.status → OfficePhase 매핑. */
const STATUS_TO_PHASE: Record<string, OfficePhase> = {
idle: 'idle',
intake: 'intake',
analyzing: 'planning',
need_clarification: 'awaiting-approval',
contract_ready: 'planning',
planning: 'planning',
executing: 'executing',
reviewing: 'reviewing',
waiting_approval: 'awaiting-approval',
error: 'error',
done: 'done',
};
/** agentId 가 alias 면 정식 id 로 정규화. dispatcher / agents.ts 와 같은 규칙. */
const AGENT_ALIASES: Record<string, string> = {
writer: 'writer',
editor: 'designer',
secretary: 'support',
business: 'inspector',
};
export function normalizeAgentId(rawAgentId: string | undefined): string | null {
if (!rawAgentId) return null;
const lower = rawAgentId.toLowerCase();
return AGENT_ALIASES[lower] ?? lower;
}
/**
* 옛 입력들을 합쳐 새 OfficeSnapshot 을 만든다. 입력 중 일부가 undefined 라도 안전.
*
* 이번 세션 stub: 옛 AgentWorkState 의 *단일 슬롯* 정보로 roster 1개 짜리 snapshot 만
* 생성. 다음 세션에서 CompanyState 의 active roster 전체로 확장.
*/
export function presentOfficeSnapshot(input: {
activeState?: AgentWorkState;
recentBubbles?: AgentBubble[];
recentActivity?: OfficeActivityItem[];
company?: CompanyState;
/**
* 이 turn 의 active agent roster — `listActiveAgentsByCategory` 결과를 평탄화해서 전달.
* 빈 배열/undefined 면 옛 동작 (active agent 1명만) 으로 fallback.
*/
roster?: Array<{ agentId: string; agentName: string; roleCategory: OfficeAgentSnapshot['roleCategory'] }>;
}): OfficeSnapshot {
const snap = makeEmptyOfficeSnapshot();
const { activeState, recentBubbles, recentActivity, roster: rosterInput } = input;
if (activeState) {
const phase = STATUS_TO_PHASE[activeState.status] ?? 'idle';
snap.phase = phase;
snap.activeAgentId = normalizeAgentId(activeState.agentId);
const activeId = snap.activeAgentId;
// Roster:
// - rosterInput 이 주어지면 (#G) 회사 전체 active agent 사용. active agent 만
// activeState 의 상태로 표시하고 나머지는 idle.
// - 없으면 (옛 caller) 활성 agent 1명만 fallback.
if (rosterInput && rosterInput.length > 0) {
const now = Date.now();
snap.roster = rosterInput.map((r) => {
const isActive = activeId !== null && r.agentId === activeId;
return {
agentId: r.agentId,
agentName: r.agentName,
roleCategory: r.roleCategory,
status: isActive ? activeState.status : 'idle',
currentStep: isActive ? activeState.currentStep : undefined,
lastLog: isActive ? (activeState.recentLogs ?? []).slice(-1)[0] : undefined,
lastActivityAt: isActive ? (activeState.updatedAt ?? now) : 0,
};
});
} else {
const role = _inferRoleCategory(activeState.agentId);
const agent: OfficeAgentSnapshot = {
agentId: snap.activeAgentId ?? activeState.agentId,
agentName: activeState.agentName ?? activeState.agentId,
roleCategory: role,
status: activeState.status,
currentStep: activeState.currentStep,
lastLog: (activeState.recentLogs ?? []).slice(-1)[0],
lastActivityAt: activeState.updatedAt ?? Date.now(),
};
snap.roster = [agent];
}
if (activeState.currentTask) {
snap.task = {
goal: activeState.currentTask,
criteria: activeState.requirementContract?.criteria,
openQuestions: activeState.requirementContract?.openQuestions,
format: activeState.requirementContract?.format,
context: activeState.requirementContract?.context,
};
}
if (activeState.pipelineStages) {
snap.pipeline = {
stages: activeState.pipelineStages.map((s) => ({
label: s.label,
agentId: s.agent,
status: s.status,
})),
index: activeState.pipelineStages.findIndex((s) => s.status === 'active'),
};
}
if (activeState.needUserInput?.length || activeState.awaitingApproval) {
snap.awaiting = {
kind: activeState.awaitingApproval ? 'approval' : 'clarification',
questions: activeState.awaitingApproval
? [activeState.awaitingApproval]
: (activeState.needUserInput ?? []),
};
}
}
if (recentActivity?.length) snap.activity = recentActivity.slice(-32);
if (recentBubbles?.length) {
snap.newBubbles = recentBubbles
.map((b) => _toBubbleSeed(b))
.filter((b): b is OfficeBubbleSeed => b !== null);
}
snap.updatedAt = Date.now();
return snap;
}
// ── helpers ──
function _inferRoleCategory(rawAgentId: string | undefined): OfficeAgentSnapshot['roleCategory'] {
if (!rawAgentId) return 'support';
const id = rawAgentId.toLowerCase();
if (id.includes('ceo')) return 'ceo';
if (id.includes('plan')) return 'planner';
if (id.includes('research')) return 'researcher';
if (id.includes('design')) return 'designer';
if (id.includes('writer')) return 'writer';
if (id.includes('editor')) return 'designer';
if (id.includes('developer') || id.includes('dev')) return 'developer';
if (id.includes('qa')) return 'qa';
if (id.includes('inspect') || id.includes('business')) return 'inspector';
return 'support';
}
function _toBubbleSeed(b: AgentBubble): OfficeBubbleSeed | null {
if (!b || !b.text) return null;
return {
agentId: normalizeAgentId(b.agentId) ?? b.agentId,
text: b.text,
type: (b.type as OfficeBubbleSeed['type']) ?? 'status',
};
}
-305
View File
@@ -1,305 +0,0 @@
/**
* OfficeSnapshot — Astra Office 의 도메인 타입.
*
* 동시성 진실 (docs/ASTRA_OFFICE_REFACTOR.md §1): dispatcher 는 직렬이라 한 시점에
* active agent 는 0 또는 1명. 이걸 데이터로 강제하는 게 이 타입의 핵심 역할.
*
* 이 세션에서는 *타입 + validator + empty factory* 만. 백엔드 emit 측 wiring 은
* 다음 세션에서 단계적으로 옮긴다. 현재는 옛 AgentWorkState/CompanyTurnEvent 가
* presenter 입력으로 살아있고, 출력만 OfficeSnapshot.
*/
import type { AgentStatus } from '../company/pixelOfficeState';
export type OfficePhase =
| 'idle'
| 'intake'
| 'planning'
| 'executing'
| 'reviewing'
| 'awaiting-approval'
| 'reporting'
| 'done'
| 'error';
/** 한 명의 agent 가 한 시점에 가진 상태. */
export interface OfficeAgentSnapshot {
/** company roster 의 정식 id (built-in 또는 custom). */
agentId: string;
/** 표시 이름. */
agentName: string;
/** 책상 색깔 / 매핑에 쓰이는 role category. */
roleCategory:
| 'ceo'
| 'planner'
| 'researcher'
| 'designer'
| 'developer'
| 'qa'
| 'inspector'
| 'support'
| 'writer';
status: AgentStatus;
/** "검수 라운드 2/3", "타입 누락" 같은 짧은 부가 정보. */
currentStep?: string;
/** 머리 위 말풍선 source. presenter 가 비워서 보내면 webview 는 풍선 안 띄움. */
lastLog?: string;
/** 정렬 / 신선도 판정. */
lastActivityAt: number;
}
export interface OfficeActivityItem {
/** epoch ms. */
ts: number;
agentId: string;
text: string;
kind?: 'ok' | 'warn' | 'err' | 'info';
}
/** webview 가 풍선을 띄울 때 참고할 hint. presenter 가 매 update 마다 새 풍선 후보를 만들어 보냄. */
export interface OfficeBubbleSeed {
agentId: string;
text: string;
/** 색깔/스타일. presenter 가 status/event/warning/error/success 중 분류. */
type: 'status' | 'event' | 'warning' | 'error' | 'success';
}
export interface OfficeSnapshot {
/** 회사 전체 phase. activeAgent 가 어떤 단계에 있는지와 분리해서 추적. */
phase: OfficePhase;
/** 활성 agent id — 직렬 dispatch 가정. null 이면 idle. */
activeAgentId: string | null;
/** 이 turn 에 참여 가능한 모든 agent. webview 의 책상 그리드 source. */
roster: OfficeAgentSnapshot[];
/** 현재 요구사항 / 계약 요약. */
task?: {
goal: string;
context?: string;
format?: string;
criteria?: string[];
openQuestions?: string[];
};
/** 파이프라인 모드의 stage 진행도. */
pipeline?: {
stages: Array<{
label: string;
agentId?: string;
status: 'done' | 'active' | 'pending';
}>;
index: number;
};
/** 승인 / 추가 정보 대기. */
awaiting?: {
kind: 'approval' | 'clarification';
questions: string[];
};
/** 누적 활동 ring buffer — webview ticker 의 source. */
activity: OfficeActivityItem[];
/** 이 update 사이클에서 새로 생긴 풍선 후보. presenter 가 매 emit 마다 갱신. */
newBubbles: OfficeBubbleSeed[];
/** 디버깅/정렬용. */
updatedAt: number;
}
const VALID_PHASES: ReadonlySet<OfficePhase> = new Set<OfficePhase>([
'idle',
'intake',
'planning',
'executing',
'reviewing',
'awaiting-approval',
'reporting',
'done',
'error',
]);
const VALID_ROLES: ReadonlySet<OfficeAgentSnapshot['roleCategory']> = new Set([
'ceo',
'planner',
'researcher',
'designer',
'developer',
'qa',
'inspector',
'support',
'writer',
]);
const VALID_STATUSES: ReadonlySet<AgentStatus> = new Set<AgentStatus>([
'idle',
'intake',
'analyzing',
'need_clarification',
'contract_ready',
'planning',
'executing',
'reviewing',
'waiting_approval',
'error',
'done',
]);
const VALID_BUBBLE_TYPES = new Set<OfficeBubbleSeed['type']>([
'status',
'event',
'warning',
'error',
'success',
]);
const VALID_KINDS = new Set<NonNullable<OfficeActivityItem['kind']>>([
'ok',
'warn',
'err',
'info',
]);
/**
* 런타임 schema validation. 임의의 unknown 입력을 받아 *완전한 OfficeSnapshot* 또는
* null 을 반환한다. 누락된 필드는 안전한 기본값으로 채운다. 잘못된 enum 값은 'idle'/
* 기본 role 로 fall through. 호출자는 null 이면 default snapshot 으로 폴백 권장.
*/
export function validateOfficeSnapshot(raw: unknown): OfficeSnapshot | null {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
const phaseRaw = String(r.phase ?? 'idle');
const phase: OfficePhase = (VALID_PHASES as ReadonlySet<string>).has(phaseRaw)
? (phaseRaw as OfficePhase)
: 'idle';
const activeAgentId =
typeof r.activeAgentId === 'string' && r.activeAgentId.length > 0
? r.activeAgentId
: null;
const rosterRaw = Array.isArray(r.roster) ? r.roster : [];
const roster: OfficeAgentSnapshot[] = rosterRaw
.map((a) => _validateAgent(a))
.filter((a): a is OfficeAgentSnapshot => a !== null);
const taskRaw = r.task as Record<string, unknown> | undefined;
const task = taskRaw && typeof taskRaw === 'object' && typeof taskRaw.goal === 'string'
? {
goal: taskRaw.goal,
context: _optStr(taskRaw.context),
format: _optStr(taskRaw.format),
criteria: Array.isArray(taskRaw.criteria)
? taskRaw.criteria.filter((x): x is string => typeof x === 'string')
: undefined,
openQuestions: Array.isArray(taskRaw.openQuestions)
? taskRaw.openQuestions.filter((x): x is string => typeof x === 'string')
: undefined,
}
: undefined;
const pipelineRaw = r.pipeline as Record<string, unknown> | undefined;
const pipeline = pipelineRaw && Array.isArray(pipelineRaw.stages)
? {
stages: pipelineRaw.stages
.map((s) => _validateStage(s))
.filter((s): s is { label: string; agentId?: string; status: 'done' | 'active' | 'pending' } => s !== null),
index: typeof pipelineRaw.index === 'number' ? pipelineRaw.index : 0,
}
: undefined;
const awaitingRaw = r.awaiting as Record<string, unknown> | undefined;
const awaiting = awaitingRaw && (awaitingRaw.kind === 'approval' || awaitingRaw.kind === 'clarification')
? {
kind: awaitingRaw.kind as 'approval' | 'clarification',
questions: Array.isArray(awaitingRaw.questions)
? awaitingRaw.questions.filter((x): x is string => typeof x === 'string')
: [],
}
: undefined;
const activity: OfficeActivityItem[] = Array.isArray(r.activity)
? r.activity
.map((it) => _validateActivity(it))
.filter((it): it is OfficeActivityItem => it !== null)
: [];
const newBubbles: OfficeBubbleSeed[] = Array.isArray(r.newBubbles)
? r.newBubbles
.map((b) => _validateBubble(b))
.filter((b): b is OfficeBubbleSeed => b !== null)
: [];
const updatedAt = typeof r.updatedAt === 'number' ? r.updatedAt : Date.now();
return { phase, activeAgentId, roster, task, pipeline, awaiting, activity, newBubbles, updatedAt };
}
export function makeEmptyOfficeSnapshot(): OfficeSnapshot {
return {
phase: 'idle',
activeAgentId: null,
roster: [],
activity: [],
newBubbles: [],
updatedAt: Date.now(),
};
}
// ─────────── helpers ───────────
function _optStr(v: unknown): string | undefined {
return typeof v === 'string' ? v : undefined;
}
function _validateAgent(raw: unknown): OfficeAgentSnapshot | null {
if (!raw || typeof raw !== 'object') return null;
const a = raw as Record<string, unknown>;
if (typeof a.agentId !== 'string' || !a.agentId) return null;
const role = typeof a.roleCategory === 'string' && (VALID_ROLES as ReadonlySet<string>).has(a.roleCategory)
? (a.roleCategory as OfficeAgentSnapshot['roleCategory'])
: 'support';
const status: AgentStatus = typeof a.status === 'string' && (VALID_STATUSES as ReadonlySet<string>).has(a.status)
? (a.status as AgentStatus)
: 'idle';
return {
agentId: a.agentId,
agentName: typeof a.agentName === 'string' ? a.agentName : a.agentId,
roleCategory: role,
status,
currentStep: _optStr(a.currentStep),
lastLog: _optStr(a.lastLog),
lastActivityAt: typeof a.lastActivityAt === 'number' ? a.lastActivityAt : 0,
};
}
function _validateStage(raw: unknown): { label: string; agentId?: string; status: 'done' | 'active' | 'pending' } | null {
if (!raw || typeof raw !== 'object') return null;
const s = raw as Record<string, unknown>;
if (typeof s.label !== 'string') return null;
const statusRaw = String(s.status ?? 'pending');
const status = (statusRaw === 'done' || statusRaw === 'active' || statusRaw === 'pending') ? statusRaw : 'pending';
return { label: s.label, agentId: _optStr(s.agentId), status };
}
function _validateActivity(raw: unknown): OfficeActivityItem | null {
if (!raw || typeof raw !== 'object') return null;
const a = raw as Record<string, unknown>;
if (typeof a.text !== 'string' || typeof a.agentId !== 'string') return null;
const kind = typeof a.kind === 'string' && (VALID_KINDS as ReadonlySet<string>).has(a.kind)
? (a.kind as OfficeActivityItem['kind'])
: undefined;
return {
ts: typeof a.ts === 'number' ? a.ts : Date.now(),
agentId: a.agentId,
text: a.text,
kind,
};
}
function _validateBubble(raw: unknown): OfficeBubbleSeed | null {
if (!raw || typeof raw !== 'object') return null;
const b = raw as Record<string, unknown>;
if (typeof b.agentId !== 'string' || typeof b.text !== 'string') return null;
const type = typeof b.type === 'string' && (VALID_BUBBLE_TYPES as ReadonlySet<string>).has(b.type)
? (b.type as OfficeBubbleSeed['type'])
: 'status';
return { agentId: b.agentId, text: b.text, type };
}
@@ -1,180 +0,0 @@
/**
* Pixel Office layout 저장 스키마 — workspaceState 의 `g1nation.pixelOfficeLayout` 키
* 에 저장되는 객체의 런타임 validator + v1 → v2 migration.
*
* 옛 runtime.ts 의 `_isV2Snap()` heuristic 을 정식 schema 로 격상. webview 에서 받는
* 즉시 한 번 통과시키면 깨진 데이터 / 옛 데이터 모두 안전하게 처리된다.
*
* 백엔드는 unknown 그대로 저장하지만, *로드 직후* 이 validator 를 적용해 정규화한다.
*/
export interface OfficeDeskCell {
/** 안정적 식별자 — DOM dataset.role 로도 쓰임. */
roleKey: string;
/** 매핑된 agent id. 비어있으면 unmapped. */
agentKey: string;
label: string;
charRow: number; // 0~7
deskSprite: string;
/** 앉은 face. */
face: 'L' | 'R' | 'U' | 'D';
boss: boolean;
/** seat 에서 잠시 일어났다 가는 dock 좌표. */
dock?: [number, number];
/** 랜덤 roam 후보 좌표들. */
roam?: Array<[number, number]>;
deskX: number;
deskY: number;
deskW: number;
deskRot: number;
deskZ: number;
seatX: number;
seatY: number;
charRot: number;
charZ: number;
/** 캐릭터를 지운 빈 책상. */
noChar: boolean;
}
export interface OfficeProp {
id: string;
name: string;
x: number;
y: number;
w?: number;
rot: number;
z: number;
}
export interface LayoutV2 {
schema: 2;
cells: OfficeDeskCell[];
objs: OfficeProp[];
}
const VALID_FACES = new Set<OfficeDeskCell['face']>(['L', 'R', 'U', 'D']);
/**
* raw 가 valid v2 layout 이면 정규화된 LayoutV2 를, 아니면 null.
* v1 (옛 좌표만 있는 포맷) 은 별도 `migrateLayout()` 사용.
*/
export function validateLayout(raw: unknown): LayoutV2 | null {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
if (!Array.isArray(r.cells)) return null;
const isV2 = r.schema === 2 || r.cells.some(
(c) =>
c && typeof c === 'object' &&
(typeof (c as Record<string, unknown>).deskSprite === 'string'
|| typeof (c as Record<string, unknown>).agentKey === 'string'
|| typeof (c as Record<string, unknown>).charRow === 'number'),
);
if (!isV2) return null;
const cells = r.cells.map((c) => _normalizeCell(c)).filter((c): c is OfficeDeskCell => c !== null);
const objsRaw = Array.isArray(r.objs) ? r.objs : [];
const objs = objsRaw.map((o) => _normalizeProp(o)).filter((o): o is OfficeProp => o !== null);
return { schema: 2, cells, objs };
}
/**
* v1 (옛 좌표 패치 포맷) → v2 (전체 station 정의) 마이그레이션. v1 은 좌표만 갖고 있어
* default station 의 나머지 필드(charRow, deskSprite 등)를 채워줘야 한다. webview 의
* default station 매핑이 함께 주어져야 정확. 없으면 best-effort.
*
* 이번 세션 stub: v1 입력이 들어오면 v2 shape 으로 일단 변환 (default 필드는 0/빈 값).
* 다음 세션에서 default station 룩업과 결합.
*/
export function migrateLayout(raw: unknown): LayoutV2 | null {
const asV2 = validateLayout(raw);
if (asV2) return asV2;
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
if (!Array.isArray(r.cells)) return null;
const cells: OfficeDeskCell[] = r.cells
.map((cRaw) => {
if (!cRaw || typeof cRaw !== 'object') return null;
const c = cRaw as Record<string, unknown>;
if (typeof c.roleKey !== 'string') return null;
return _normalizeCell({
...c,
// v1 은 charRow / deskSprite 등이 없으니 안전한 기본값.
charRow: 0,
deskSprite: 'desk-main',
face: 'R',
boss: false,
agentKey: c.roleKey, // 옛 키가 곧 agent 였음.
label: c.roleKey,
noChar: false,
});
})
.filter((c): c is OfficeDeskCell => c !== null);
const objs = Array.isArray(r.objs)
? r.objs.map((o) => _normalizeProp(o)).filter((o): o is OfficeProp => o !== null)
: [];
return { schema: 2, cells, objs };
}
function _normalizeCell(raw: unknown): OfficeDeskCell | null {
if (!raw || typeof raw !== 'object') return null;
const c = raw as Record<string, unknown>;
if (typeof c.roleKey !== 'string' || !c.roleKey) return null;
const face = typeof c.face === 'string' && (VALID_FACES as ReadonlySet<string>).has(c.face)
? (c.face as OfficeDeskCell['face'])
: 'R';
return {
roleKey: c.roleKey,
agentKey: typeof c.agentKey === 'string' ? c.agentKey : '',
label: typeof c.label === 'string' ? c.label : c.roleKey,
charRow: _num(c.charRow, 0),
deskSprite: typeof c.deskSprite === 'string' ? c.deskSprite : 'desk-main',
face,
boss: !!c.boss,
dock: _pair(c.dock),
roam: Array.isArray(c.roam)
? (c.roam.map(_pair).filter(Boolean) as Array<[number, number]>)
: undefined,
deskX: _num(c.deskX, 0),
deskY: _num(c.deskY, 0),
deskW: _num(c.deskW, 112),
deskRot: _num(c.deskRot, 0),
deskZ: _num(c.deskZ, 0),
seatX: _num(c.seatX, 0),
seatY: _num(c.seatY, 0),
charRot: _num(c.charRot, 0),
charZ: _num(c.charZ, 0),
noChar: !!c.noChar,
};
}
function _normalizeProp(raw: unknown): OfficeProp | null {
if (!raw || typeof raw !== 'object') return null;
const o = raw as Record<string, unknown>;
if (typeof o.name !== 'string') return null;
return {
id: typeof o.id === 'string' ? o.id : `obj_${Math.random().toString(36).slice(2, 8)}`,
name: o.name,
x: _num(o.x, 0),
y: _num(o.y, 0),
w: typeof o.w === 'number' ? o.w : undefined,
rot: _num(o.rot, 0),
z: _num(o.z, 0),
};
}
function _num(v: unknown, fallback: number): number {
return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
}
function _pair(v: unknown): [number, number] | undefined {
if (!Array.isArray(v) || v.length !== 2) return undefined;
const a = typeof v[0] === 'number' ? v[0] : NaN;
const b = typeof v[1] === 'number' ? v[1] : NaN;
if (!Number.isFinite(a) || !Number.isFinite(b)) return undefined;
return [a, b];
}
-103
View File
@@ -1,103 +0,0 @@
export const OFFICE_BODY = `
<div class="office-app">
<header class="topbar">
<div class="brand-block">
<div class="eyebrow">ASTRA OFFICE</div>
<div class="h-title">Operations Floor</div>
</div>
<div class="topbar-center">
<div class="phase-pill" id="phasePill"><span class="phase-dot"></span><span id="status">대기 중</span></div>
<div class="agent-pill"><span class="agent-label">현재 담당</span><strong id="agent">Astra</strong></div>
</div>
<div class="topbar-actions">
<button id="editBtn" class="edit-btn" title="오피스 배치 편집 모드 토글">Customize</button>
</div>
</header>
<div id="editToolbar" class="edit-toolbar" style="display:none;">
<span class="et-hint">드래그로 이동 · <b>R</b> 회전 · <b>]</b>/<b>[</b> 레이어 · 4px snap</span>
<button id="addDeskBtn" class="add" title="책상 추가">+ 책상</button>
<button id="addPropBtn" class="add" title="가구 추가">+ 가구</button>
<button id="deleteSelBtn" class="del" title="선택 항목 삭제" disabled>삭제</button>
<button id="layerUpBtn" title="레이어 위로 (])">위로</button>
<button id="layerDownBtn" title="레이어 아래로 ([)">아래로</button>
<button id="saveBtn">저장</button>
<button id="resetBtn" title="기본 배치로 복귀">초기화</button>
<button id="cancelBtn" title="저장 안 하고 종료">취소</button>
</div>
<main class="workspace">
<aside class="side-panel team-panel">
<div class="panel-head">
<div>
<div class="panel-kicker">Team</div>
<h2>오늘의 라인업</h2>
</div>
<div class="count-badge" id="rosterCount">0</div>
</div>
<div id="rosterList" class="roster-list"></div>
</aside>
<section class="office-shell">
<div class="mission-strip">
<div>
<div class="panel-kicker">Current Mission</div>
<div class="mission-title" id="task">새 요청을 기다리고 있습니다.</div>
</div>
<div class="mission-step-wrap">
<span>현재 단계</span>
<strong id="step">대기 중</strong>
</div>
</div>
<div id="miniMap" class="mini-map" style="display:none;"></div>
<div class="office-stage-wrap">
<div class="office">
<div class="stage" id="stage">
<svg id="flowLayer" class="flow-layer" viewBox="0 0 720 585" preserveAspectRatio="none" aria-hidden="true"></svg>
<div class="wall-window w1"></div>
<div class="wall-window w2"></div>
</div>
<div id="propPanel" class="prop-panel"></div>
</div>
</div>
</section>
<aside class="side-panel mission-panel">
<div class="panel-head compact">
<div>
<div class="panel-kicker">Signal</div>
<h2>운영 브리프</h2>
</div>
</div>
<div class="brief-grid">
<section class="brief-card hero">
<span>현재 흐름</span>
<strong id="phaseLabel">대기 중</strong>
<p id="phaseNote">새로운 작업 요청을 기다리고 있습니다.</p>
</section>
<section class="brief-card">
<span>진행률</span>
<strong id="progressLabel">0%</strong>
<div class="progress"><div class="bar" id="bar"></div></div>
</section>
<section class="brief-card" id="attentionCard">
<span>주의 신호</span>
<strong id="attentionTitle">없음</strong>
<p id="attentionBody">현재 막힘 없이 진행 중입니다.</p>
</section>
</div>
</aside>
</main>
<footer class="activity-dock">
<div class="activity-head">
<div>
<div class="panel-kicker">Activity</div>
<strong>최근 실행</strong>
</div>
<div id="log" class="last-log">아직 기록된 활동이 없습니다.</div>
</div>
<div id="ticker" class="ticker" style="display:none;"><div class="tk-track" id="tickerTrack"></div></div>
</footer>
</div>
`;
@@ -1,419 +0,0 @@
export const OFFICE_CSS = `
:root{
--bg:#070A12;
--bg-soft:#0D1220;
--surface:rgba(15,21,36,.78);
--surface-strong:rgba(18,25,43,.94);
--surface-faint:rgba(255,255,255,.045);
--line:rgba(255,255,255,.09);
--line-strong:rgba(255,255,255,.16);
--text:#F5F7FC;
--muted:#9BA6BF;
--accent:#8A7CFF;
--accent-2:#46D8FF;
--success:#35D7A4;
--warning:#F5C45A;
--danger:#FF6B7A;
--shadow:0 18px 60px rgba(0,0,0,.42);
--radius-xl:24px;
--radius-lg:18px;
--radius-md:14px;
--radius-sm:10px;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background:
radial-gradient(circle at 12% 0%,rgba(138,124,255,.18),transparent 28%),
radial-gradient(circle at 90% 10%,rgba(70,216,255,.12),transparent 24%),
radial-gradient(circle at 50% 100%,rgba(53,215,164,.08),transparent 32%),
linear-gradient(180deg,#070A12 0%,#0A0F1A 100%);
color:var(--text);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
overflow:hidden;
}
button,input,select{font:inherit}
.office-app{height:100vh;display:grid;grid-template-rows:auto auto minmax(0,1fr) auto;gap:14px;padding:18px}
.topbar{
min-height:70px;
display:flex;
align-items:center;
justify-content:space-between;
gap:18px;
padding:14px 16px 14px 18px;
border:1px solid var(--line);
border-radius:var(--radius-xl);
background:linear-gradient(180deg,rgba(255,255,255,.08),rgba(255,255,255,.035));
backdrop-filter:blur(18px);
box-shadow:var(--shadow);
}
.brand-block{display:flex;flex-direction:column;gap:3px;min-width:180px}
.eyebrow,.panel-kicker{font-size:10px;line-height:1;letter-spacing:.16em;text-transform:uppercase;color:var(--muted)}
.h-title{font-size:22px;line-height:1.1;font-weight:750;letter-spacing:-.03em}
.topbar-center{display:flex;align-items:center;justify-content:center;gap:10px;flex:1;min-width:0}
.phase-pill,.agent-pill{
min-height:38px;
display:flex;
align-items:center;
gap:9px;
padding:0 14px;
border:1px solid var(--line);
border-radius:999px;
background:rgba(255,255,255,.05);
white-space:nowrap;
}
.phase-pill{font-weight:650}
.phase-pill .phase-dot{width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 16px rgba(138,124,255,.8)}
.phase-pill[data-tone="success"] .phase-dot{background:var(--success);box-shadow:0 0 16px rgba(53,215,164,.8)}
.phase-pill[data-tone="warning"] .phase-dot{background:var(--warning);box-shadow:0 0 16px rgba(245,196,90,.8)}
.phase-pill[data-tone="danger"] .phase-dot{background:var(--danger);box-shadow:0 0 16px rgba(255,107,122,.8)}
.agent-pill{color:var(--muted)}
.agent-pill strong{color:var(--text);font-size:13px}
.agent-label{font-size:11px}
.topbar-actions{display:flex;align-items:center;justify-content:flex-end;min-width:180px}
.edit-btn{
height:38px;
border-radius:999px;
padding:0 15px;
border:1px solid rgba(138,124,255,.35);
color:var(--text);
background:linear-gradient(180deg,rgba(138,124,255,.22),rgba(138,124,255,.12));
cursor:pointer;
transition:transform .16s ease,border-color .16s ease,background .16s ease;
}
.edit-btn:hover{transform:translateY(-1px);border-color:rgba(138,124,255,.62);background:linear-gradient(180deg,rgba(138,124,255,.3),rgba(138,124,255,.16))}
.edit-toolbar{
display:flex;
align-items:center;
gap:8px;
flex-wrap:wrap;
padding:10px 12px;
border:1px solid rgba(138,124,255,.28);
border-radius:var(--radius-lg);
background:rgba(138,124,255,.12);
backdrop-filter:blur(16px);
}
.edit-toolbar .et-hint{flex:1;min-width:220px;color:#DCE2F2;font-size:12px}
.edit-toolbar button{
min-height:32px;
padding:0 11px;
border-radius:999px;
border:1px solid var(--line-strong);
color:var(--text);
background:rgba(255,255,255,.07);
cursor:pointer;
}
.edit-toolbar button:hover{background:rgba(255,255,255,.12)}
.edit-toolbar button.add{border-color:rgba(53,215,164,.4);background:rgba(53,215,164,.12)}
.edit-toolbar button.del{border-color:rgba(255,107,122,.42);background:rgba(255,107,122,.12)}
.edit-toolbar button[disabled]{opacity:.42;cursor:not-allowed}
.workspace{
min-height:0;
display:grid;
grid-template-columns:260px minmax(620px,1fr) 280px;
gap:14px;
}
.side-panel,.office-shell,.activity-dock{
border:1px solid var(--line);
border-radius:var(--radius-xl);
background:var(--surface);
backdrop-filter:blur(20px);
box-shadow:var(--shadow);
}
.side-panel{padding:16px;min-height:0;overflow:hidden}
.panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px}
.panel-head.compact{margin-bottom:16px}
.panel-head h2{font-size:16px;line-height:1.2;letter-spacing:-.02em;margin:6px 0 0}
.count-badge{
min-width:28px;
height:28px;
display:grid;
place-items:center;
border-radius:999px;
border:1px solid rgba(70,216,255,.25);
background:rgba(70,216,255,.12);
color:#BDEFFF;
font-size:12px;
font-weight:700;
}
.roster-list{display:flex;flex-direction:column;gap:9px;overflow:auto;padding-right:2px;max-height:calc(100vh - 220px)}
.roster-item{
display:grid;
grid-template-columns:auto minmax(0,1fr);
gap:11px;
align-items:center;
padding:11px;
border-radius:var(--radius-md);
border:1px solid transparent;
background:rgba(255,255,255,.035);
transition:border-color .16s ease,background .16s ease,transform .16s ease;
}
.roster-item[data-agent]{cursor:pointer}
.roster-item.active{border-color:rgba(138,124,255,.48);background:linear-gradient(180deg,rgba(138,124,255,.16),rgba(138,124,255,.08));transform:translateX(2px)}
.roster-item.preview{border-color:rgba(255,255,255,.2);background:rgba(255,255,255,.07)}
.roster-avatar{
width:33px;height:33px;border-radius:12px;
display:grid;place-items:center;
background:rgba(255,255,255,.08);
border:1px solid var(--line);
color:var(--role-color,var(--accent));
font-size:11px;font-weight:700;
}
.roster-copy{min-width:0;display:flex;flex-direction:column;gap:3px}
.roster-copy strong{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.roster-meta{display:flex;align-items:center;gap:6px;color:var(--muted);font-size:11px}
.roster-status{width:6px;height:6px;border-radius:50%;background:rgba(255,255,255,.28)}
.roster-item.active .roster-status{background:var(--role-color,var(--accent));box-shadow:0 0 12px var(--role-color,var(--accent))}
.office-shell{min-width:0;display:grid;grid-template-rows:auto auto minmax(0,1fr);overflow:hidden}
.office-shell[data-roster-empty="true"] .office{filter:saturate(.72) brightness(.9)}
.office-shell[data-roster-empty="true"] .stage{opacity:.86}
.office-shell[data-status="executing"] .office{box-shadow:inset 0 0 0 1px rgba(138,124,255,.12),0 0 0 1px rgba(138,124,255,.08)}
.office-shell[data-status="reviewing"] .office{box-shadow:inset 0 0 0 1px rgba(70,216,255,.14),0 0 0 1px rgba(70,216,255,.08)}
.office-shell[data-status="waiting_approval"] .office{box-shadow:inset 0 0 0 1px rgba(245,196,90,.16),0 0 0 1px rgba(245,196,90,.08)}
.office-shell[data-status="error"] .office{box-shadow:inset 0 0 0 1px rgba(255,107,122,.18),0 0 0 1px rgba(255,107,122,.08)}
.mission-strip{
min-height:72px;
display:flex;
justify-content:space-between;
align-items:center;
gap:18px;
padding:15px 18px;
border-bottom:1px solid var(--line);
}
.mission-title{margin-top:6px;font-size:16px;font-weight:650;letter-spacing:-.02em;max-width:min(560px,52vw);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.mission-step-wrap{display:flex;flex-direction:column;align-items:flex-end;gap:5px;min-width:160px}
.mission-step-wrap span{font-size:11px;color:var(--muted)}
.mission-step-wrap strong{font-size:13px;font-weight:650;text-align:right;max-width:220px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.mini-map{display:flex;align-items:center;gap:6px;min-height:42px;padding:0 18px;border-bottom:1px solid var(--line);overflow-x:auto;scrollbar-width:none}
.mini-map::-webkit-scrollbar{display:none}
.mini-map .mm-dot{position:relative;width:10px;height:10px;border-radius:50%;background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.18);flex-shrink:0;transition:all .2s ease}
.mini-map .mm-dot[data-status="done"]{background:var(--success);border-color:var(--success);box-shadow:0 0 12px rgba(53,215,164,.35)}
.mini-map .mm-dot[data-status="active"]{width:14px;height:14px;background:var(--accent);border-color:var(--accent);box-shadow:0 0 0 4px rgba(138,124,255,.16)}
.mini-map .mm-bar{flex:1;height:1px;min-width:22px;background:linear-gradient(90deg,rgba(255,255,255,.08),rgba(255,255,255,.18))}
.mini-map .mm-label{position:absolute;left:50%;top:-29px;transform:translateX(-50%);padding:4px 7px;border-radius:999px;background:rgba(6,9,16,.95);border:1px solid var(--line);font-size:10px;white-space:nowrap;color:var(--text);opacity:0;pointer-events:none;transition:opacity .14s ease;z-index:40}
.mini-map .mm-dot:hover .mm-label{opacity:1}
.mini-map .mm-counter{margin-left:6px;color:var(--muted);font-size:11px;white-space:nowrap}
.office-stage-wrap{min-height:0;padding:14px;display:flex}
.office{
position:relative;
flex:1;
min-height:420px;
overflow:hidden;
display:flex;
align-items:center;
justify-content:center;
border-radius:20px;
border:1px solid rgba(255,255,255,.08);
background:
linear-gradient(180deg,rgba(18,28,48,.98) 0 16%,transparent 16%),
radial-gradient(circle at 50% -10%,rgba(138,124,255,.24),transparent 32%),
radial-gradient(circle at 18% 100%,rgba(70,216,255,.12),transparent 28%),
linear-gradient(135deg,#31283A,#201A29 72%);
}
.office.has-art{
background:
linear-gradient(180deg,rgba(6,10,18,.06),rgba(6,10,18,.12)),
var(--office-backdrop) center center / cover no-repeat;
}
.office:before{content:'';position:absolute;inset:0;background-image:linear-gradient(rgba(255,255,255,.018) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.018) 1px,transparent 1px);background-size:48px 48px;opacity:.28}
.office.has-art:before{background:linear-gradient(180deg,rgba(4,7,14,.06),transparent 28%,rgba(4,7,14,.1));opacity:1}
.office:after{content:'';position:absolute;inset:0;background:radial-gradient(circle at 50% 50%,transparent 0 42%,rgba(0,0,0,.2) 100%);pointer-events:none}
.stage{position:relative;width:720px;height:585px;margin:0;z-index:2}
.flow-layer{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible;z-index:3}
.flow-route{
fill:none;
stroke:rgba(138,124,255,.34);
stroke-width:2;
stroke-linecap:round;
stroke-linejoin:round;
stroke-dasharray:5 8;
filter:drop-shadow(0 0 6px rgba(138,124,255,.25));
}
.flow-node{
fill:rgba(138,124,255,.18);
stroke:rgba(255,255,255,.34);
stroke-width:1.2;
}
.flow-node[data-status="done"]{fill:rgba(53,215,164,.34);stroke:rgba(53,215,164,.8)}
.flow-node[data-status="active"]{fill:rgba(138,124,255,.6);stroke:#D8D2FF;filter:drop-shadow(0 0 8px rgba(138,124,255,.72))}
.flow-node[data-status="pending"]{fill:rgba(255,255,255,.08);stroke:rgba(255,255,255,.22)}
.wall-window{position:absolute;top:16px;width:86px;height:42px;border:2px solid rgba(215,228,255,.35);border-radius:8px;background:linear-gradient(180deg,rgba(160,208,255,.34),rgba(110,150,210,.08));box-shadow:inset 0 0 0 1px rgba(15,20,31,.55)}
.wall-window.w1{left:84px}.wall-window.w2{left:550px}
.office.has-art .wall-window{display:none}
.obj,.desk,.char{position:absolute;image-rendering:pixelated}
.obj{filter:drop-shadow(3px 5px 0 rgba(0,0,0,.28));z-index:4}
.desk{width:112px;z-index:5;filter:drop-shadow(4px 7px 0 rgba(0,0,0,.28))}
.desk.boss{width:136px}
.label{position:absolute;left:50%;bottom:-15px;transform:translateX(-50%);font-size:10px;color:rgba(245,247,252,.8);white-space:nowrap;text-shadow:0 1px 4px rgba(0,0,0,.8)}
.char{width:56px;height:72px;z-index:7;transition:left .9s cubic-bezier(.2,.7,.2,1),top .9s cubic-bezier(.2,.7,.2,1)}
.char.walking{z-index:14}
.char img{position:absolute;left:0;bottom:0;max-width:100%;max-height:100%;image-rendering:pixelated;filter:drop-shadow(2px 3px 0 rgba(0,0,0,.42));transform-origin:center bottom}
.char.active:before{content:'';position:absolute;left:19px;top:-14px;width:18px;height:18px;border-radius:50%;background:radial-gradient(circle,var(--role-color,var(--accent)) 0 28%,rgba(255,255,255,.18) 30%,transparent 72%);filter:blur(.2px);animation:focusPulse 1.8s ease-in-out infinite}
.char.active::after{content:'';position:absolute;left:-4px;right:-4px;bottom:-7px;height:8px;border-radius:999px;background:radial-gradient(circle,var(--role-color,var(--accent)),transparent 70%);opacity:.82;filter:blur(1px)}
.char.working img{animation:workLean 1.6s ease-in-out infinite}
@keyframes focusPulse{0%,100%{transform:scale(.95);opacity:.88}50%{transform:scale(1.12);opacity:1}}
@keyframes workLean{0%,100%{transform:translateY(0)}50%{transform:translateY(-2px)}}
.char[data-agent="ceo"],.desk[data-agent="ceo"],.roster-item[data-agent="ceo"]{--role-color:#A78BFA}
.char[data-agent="planner"],.desk[data-agent="planner"],.roster-item[data-agent="planner"]{--role-color:#60A5FA}
.char[data-agent="researcher"],.desk[data-agent="researcher"],.roster-item[data-agent="researcher"]{--role-color:#35D7A4}
.char[data-agent="designer"],.desk[data-agent="designer"],.roster-item[data-agent="designer"]{--role-color:#F472B6}
.char[data-agent="developer"],.desk[data-agent="developer"],.roster-item[data-agent="developer"]{--role-color:#F5C45A}
.char[data-agent="qa"],.desk[data-agent="qa"],.roster-item[data-agent="qa"]{--role-color:#46D8FF}
.char[data-agent="inspector"],.desk[data-agent="inspector"],.roster-item[data-agent="inspector"]{--role-color:#FB923C}
.char[data-agent="support"],.desk[data-agent="support"],.roster-item[data-agent="support"]{--role-color:#94A3B8}
.char[data-agent="writer"],.desk[data-agent="writer"],.roster-item[data-agent="writer"]{--role-color:#FBBF24}
.desk::after{content:'';position:absolute;inset:-4px;border-radius:10px;border:1px solid transparent;pointer-events:none;transition:border-color .2s ease,box-shadow .2s ease}
.desk.pipeline-desk::after{border-color:rgba(255,255,255,.12)}
.desk[data-flow="done"]::after{border-color:rgba(53,215,164,.26)}
.desk[data-flow="active"]::after{border-color:rgba(138,124,255,.72);box-shadow:0 0 0 1px rgba(255,255,255,.06),0 0 20px rgba(138,124,255,.34)}
.desk[data-flow="pending"]::after{border-color:rgba(255,255,255,.16)}
.flow-badge{
position:absolute;
right:-7px;
top:-12px;
min-width:28px;
height:22px;
display:grid;
place-items:center;
padding:0 7px;
border-radius:999px;
border:1px solid rgba(255,255,255,.18);
background:rgba(8,12,22,.88);
color:#E7E2FF;
font-size:10px;
font-weight:750;
letter-spacing:.01em;
box-shadow:0 10px 22px rgba(0,0,0,.24);
}
.desk[data-flow="done"] .flow-badge{color:#B7FFE7;border-color:rgba(53,215,164,.28)}
.desk[data-flow="active"] .flow-badge{color:#F2EFFF;border-color:rgba(138,124,255,.5);background:rgba(31,24,66,.92)}
.desk.preview::after{border-color:rgba(255,255,255,.28);box-shadow:0 0 0 1px rgba(255,255,255,.04),0 0 18px rgba(255,255,255,.16)}
.char.preview::before{content:'';position:absolute;left:18px;top:-14px;width:20px;height:20px;border-radius:50%;border:1px solid rgba(255,255,255,.42);box-shadow:0 0 18px rgba(255,255,255,.18)}
.stage:has(.char.active[data-agent="ceo"]) .desk[data-agent="ceo"]::after,
.stage:has(.char.active[data-agent="planner"]) .desk[data-agent="planner"]::after,
.stage:has(.char.active[data-agent="researcher"]) .desk[data-agent="researcher"]::after,
.stage:has(.char.active[data-agent="designer"]) .desk[data-agent="designer"]::after,
.stage:has(.char.active[data-agent="developer"]) .desk[data-agent="developer"]::after,
.stage:has(.char.active[data-agent="qa"]) .desk[data-agent="qa"]::after,
.stage:has(.char.active[data-agent="inspector"]) .desk[data-agent="inspector"]::after,
.stage:has(.char.active[data-agent="support"]) .desk[data-agent="support"]::after,
.stage:has(.char.active[data-agent="writer"]) .desk[data-agent="writer"]::after{border-color:var(--role-color);box-shadow:0 0 0 1px rgba(255,255,255,.06),0 0 18px color-mix(in srgb,var(--role-color) 35%,transparent)}
.shadow{position:absolute;left:12px;bottom:0;width:28px;height:7px;background:radial-gradient(ellipse,rgba(0,0,0,.55),transparent 70%)}
.bubble{position:absolute;z-index:20;transform:translate(-50%,-100%);max-width:180px;padding:7px 10px;border-radius:14px;border:1px solid rgba(255,255,255,.14);background:rgba(10,14,24,.92);color:var(--text);font-size:11px;line-height:1.35;box-shadow:0 10px 24px rgba(0,0,0,.28);white-space:normal;animation:bubble-pop .22s cubic-bezier(.2,1.4,.6,1)}
@keyframes bubble-pop{from{transform:translate(-50%,-88%) scale(.6);opacity:0}to{transform:translate(-50%,-100%) scale(1);opacity:1}}
/* 감정 태그별 변형 — 희노애락. webtoon 느낌으로 background / 색 / 이모지 prefix. */
.bubble-joy {background:rgba(254,243,199,.97);color:#7c5d11;border-color:rgba(252,211,77,.6)}
.bubble-joy::before {content:'😊 ';opacity:.85}
.bubble-anger {background:rgba(254,226,226,.97);color:#7f1d1d;border-color:#ef4444;animation:bubble-pop .22s cubic-bezier(.2,1.4,.6,1),bubble-shake .4s ease-in-out 1}
.bubble-anger::before {content:'😠 ';opacity:.85}
.bubble-sorrow {background:rgba(219,234,254,.97);color:#1e3a8a;border-color:rgba(96,165,250,.5)}
.bubble-sorrow::before {content:'😔 ';opacity:.85}
.bubble-panic {background:rgba(254,226,226,.97);color:#9a1c1c;border-color:rgba(248,113,113,.55);animation:bubble-pop .22s cubic-bezier(.2,1.4,.6,1),bubble-shake .5s ease-in-out 1}
.bubble-panic::before {content:'😱 ';opacity:.85}
.bubble-curious {background:rgba(243,232,255,.97);color:#5b21b6;border-color:rgba(167,139,250,.45)}
.bubble-curious::before {content:'🤔 ';opacity:.85}
.bubble-firm {background:rgba(255,255,255,.98);color:#111;border-color:rgba(0,0,0,.18);font-weight:700}
.bubble-firm::before {content:'✋ ';opacity:.85}
.bubble-gratitude{background:rgba(220,252,231,.97);color:#14532d;border-color:rgba(74,222,128,.5)}
.bubble-gratitude::before{content:'🙏 ';opacity:.85}
.bubble-thought {/* default 그대로 */}
@keyframes bubble-shake{0%,100%{transform:translate(-50%,-100%) rotate(0)}25%{transform:translate(-52%,-100%) rotate(-2deg)}75%{transform:translate(-48%,-100%) rotate(2deg)}}
.brief-grid{display:flex;flex-direction:column;gap:10px}
.brief-card{
padding:14px;
border-radius:var(--radius-md);
border:1px solid var(--line);
background:rgba(255,255,255,.035);
}
.brief-card.hero{background:linear-gradient(180deg,rgba(138,124,255,.16),rgba(255,255,255,.03));border-color:rgba(138,124,255,.28)}
.brief-card span{display:block;font-size:11px;color:var(--muted);margin-bottom:6px}
.brief-card strong{display:block;font-size:17px;line-height:1.2;letter-spacing:-.02em}
.brief-card p{margin:8px 0 0;color:#D6DDF0;font-size:12px;line-height:1.5}
#attentionCard[data-tone="warning"]{border-color:rgba(245,196,90,.32);background:rgba(245,196,90,.08)}
#attentionCard[data-tone="danger"]{border-color:rgba(255,107,122,.34);background:rgba(255,107,122,.08)}
.progress{height:8px;margin-top:12px;border-radius:999px;background:rgba(255,255,255,.08);overflow:hidden}
.bar{height:100%;width:0;border-radius:inherit;background:linear-gradient(90deg,var(--accent),var(--accent-2));transition:width .22s ease}
.activity-dock{padding:14px 16px 15px;display:flex;flex-direction:column;gap:10px}
.activity-head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px}
.activity-head strong{display:block;font-size:14px;margin-top:5px}
.last-log{max-width:65%;font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ticker{position:relative;overflow:hidden}
.tk-track{display:flex;gap:8px;overflow-x:auto;scrollbar-width:none;padding-bottom:1px}
.tk-track::-webkit-scrollbar{display:none}
.tk-item{
flex-shrink:0;
display:inline-flex;
align-items:center;
gap:7px;
min-height:30px;
padding:0 11px;
border-radius:999px;
border:1px solid var(--line);
background:rgba(255,255,255,.045);
color:#DCE3F3;
font-size:11px;
}
.tk-item.tk-ok{border-color:rgba(53,215,164,.26)}
.tk-item.tk-warn{border-color:rgba(245,196,90,.28)}
.tk-item.tk-err{border-color:rgba(255,107,122,.3)}
.tk-item .tk-agent{color:#C6BEFF;font-weight:650}
.ctx-menu{position:fixed;z-index:1000;background:rgba(10,14,24,.96);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 18px 48px rgba(0,0,0,.5);padding:6px;min-width:180px;font-size:12px;color:var(--text)}
.ctx-menu-head{padding:7px 10px 8px;font-size:10px;color:var(--muted);border-bottom:1px solid var(--line);margin-bottom:4px}
.ctx-menu-head .cmh-role{color:var(--role-color,#A78BFA);font-weight:700;text-transform:uppercase}
.ctx-menu-item{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;border-radius:10px;transition:background .12s ease}
.ctx-menu-item:hover{background:rgba(138,124,255,.16)}
.ctx-menu-item.danger:hover{background:rgba(255,107,122,.14);color:#FFC3CC}
.ctx-menu-divider{height:1px;background:var(--line);margin:4px}
body[data-edit-mode="true"] .ctx-menu{display:none!important}
body:not([data-edit-mode="true"]) .char{cursor:pointer}
.ctx-detail{position:fixed;z-index:1001;background:rgba(10,14,24,.96);border:1px solid var(--line-strong);border-radius:18px;box-shadow:0 20px 56px rgba(0,0,0,.56);padding:18px;color:var(--text);min-width:320px;max-width:520px;max-height:60vh;overflow-y:auto;font-size:12px;line-height:1.5}
.ctx-detail h3{margin:0 0 10px;font-size:14px;color:var(--role-color,#A78BFA);letter-spacing:.02em}
.ctx-detail .cd-close{position:absolute;top:10px;right:12px;background:transparent;border:none;color:var(--muted);font-size:16px;cursor:pointer}
.ctx-detail dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:4px 14px}
.ctx-detail dt{color:var(--muted);font-weight:600;white-space:nowrap}
.ctx-detail dd{margin:0;color:var(--text);overflow-wrap:anywhere}
.ctx-detail .cd-logs{margin-top:10px;padding:8px 10px;background:rgba(255,255,255,.045);border-radius:12px;font-family:ui-monospace,monospace;font-size:10.5px;max-height:120px;overflow-y:auto}
.prop-panel{position:absolute;right:14px;top:14px;width:250px;background:rgba(10,14,24,.96);border:1px solid var(--line-strong);border-radius:18px;box-shadow:0 18px 48px rgba(0,0,0,.5);padding:14px;font-size:11px;color:var(--text);z-index:25;display:none}
.prop-panel.show{display:block}
.prop-panel h4{margin:0 0 10px;font-size:12px;color:#C6BEFF;letter-spacing:.04em}
.prop-panel .pp-row{margin-bottom:9px}
.prop-panel label{display:block;font-size:10px;color:var(--muted);margin-bottom:4px}
.prop-panel select,.prop-panel input{width:100%;background:rgba(255,255,255,.04);color:var(--text);border:1px solid var(--line);border-radius:10px;padding:6px 8px;font-size:11px}
.prop-panel .pp-thumbs{display:grid;grid-template-columns:repeat(4,1fr);gap:5px;margin-top:5px}
.prop-panel .pp-thumb{width:100%;aspect-ratio:1/1;background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:10px;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:3px}
.prop-panel .pp-thumb img{max-width:100%;max-height:100%;image-rendering:pixelated}
.prop-panel .pp-thumb.active{border-color:rgba(138,124,255,.7);box-shadow:0 0 0 2px rgba(138,124,255,.18)}
.prop-picker{position:fixed;inset:0;background:rgba(3,5,10,.68);z-index:1100;display:flex;align-items:center;justify-content:center}
.prop-picker-box{background:rgba(10,14,24,.98);border:1px solid var(--line-strong);border-radius:20px;padding:16px;max-width:380px;max-height:80vh;overflow-y:auto;color:var(--text)}
.prop-picker-box h3{margin:0 0 12px;font-size:13px;color:#C6BEFF}
.prop-picker-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
.prop-pick{background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:14px;padding:7px;cursor:pointer;text-align:center}
.prop-pick:hover{border-color:rgba(138,124,255,.6)}
.prop-pick img{max-width:60px;max-height:60px;image-rendering:pixelated}
.prop-pick .pp-name{font-size:10px;color:var(--muted);margin-top:5px;word-break:keep-all}
body[data-edit-mode="true"] .stage{background-image:linear-gradient(rgba(138,124,255,.18) 1px,transparent 1px),linear-gradient(90deg,rgba(138,124,255,.18) 1px,transparent 1px);background-size:32px 32px}
body[data-edit-mode="true"] .desk,body[data-edit-mode="true"] .char,body[data-edit-mode="true"] .obj{cursor:grab;outline:1px dashed rgba(138,124,255,.45)}
body[data-edit-mode="true"] .desk:hover,body[data-edit-mode="true"] .char:hover,body[data-edit-mode="true"] .obj:hover{outline:2px solid rgba(138,124,255,.8);z-index:30}
body[data-edit-mode="true"] .dragging{cursor:grabbing!important;opacity:.72;outline:2px solid var(--warning)!important;z-index:40}
body[data-edit-mode="true"] .selected{outline:2px solid #F472B6!important;box-shadow:0 0 0 4px rgba(244,114,182,.22);z-index:35}
body[data-edit-mode="true"] .char .shadow{display:none}
@media (max-width:1180px){
.workspace{grid-template-columns:220px minmax(560px,1fr)}
.mission-panel{display:none}
}
@media (max-width:900px){
body{overflow:auto}
.office-app{height:auto;min-height:100vh;grid-template-rows:auto auto auto auto;padding:14px}
.topbar{flex-wrap:wrap}
.topbar-center{order:3;width:100%;justify-content:flex-start}
.workspace{grid-template-columns:1fr}
.team-panel{display:none}
.mission-panel{display:block}
.office-shell{min-height:620px}
.mission-title{max-width:calc(100vw - 140px)}
}
@media (prefers-reduced-motion: reduce){
*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}
}
`;
@@ -1,26 +0,0 @@
// Full Astra Office webview HTML composition.
// 옛 sidebarProvider.ts 의 거대한 _pixelOfficePanelHtml 을 4개 파일로 분리한 entry.
// 이번 세션은 *동작 동등 분리* 만. 다음 세션에 mini view 와 공통 presenter 도입.
import { OFFICE_CSS } from './officeStyles';
import { OFFICE_BODY } from './officeBody';
import { officeRuntimeJs } from './runtime';
export interface AstraOfficePanelAssets {
cspSource: string;
derivedBase: string;
}
export function renderAstraOfficePanelHtml(assets: AstraOfficePanelAssets): string {
return `<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${assets.cspSource} data:; style-src 'unsafe-inline'; script-src 'unsafe-inline';" />
<style>
${OFFICE_CSS}
</style></head>
<body>
${OFFICE_BODY}
${officeRuntimeJs(assets.derivedBase)}</body></html>`;
}
File diff suppressed because it is too large Load Diff
-7
View File
@@ -104,10 +104,3 @@ export {
export type { QuestionEvidence, SelfAnswer } from './alignmentResearch';
export type { RequirementContract } from './types';
export {
getStatusBubbleText, getEventBubbleText, eventBubbleType, makeBubble,
} from './pixelOfficeState';
export type {
AgentStatus, AgentEvent, AgentBubble, AgentWorkState,
PixelOfficeConfig, BubbleType,
} from './pixelOfficeState';
-286
View File
@@ -1,286 +0,0 @@
/**
* Pixel Office — Agent Work Pipeline 상태를 시각화하는 *UI Layer 전용* 모듈.
*
* ─────────────────── 설계 원칙 ───────────────────
* 1. **Agent 핵심 판단 로직을 절대 바꾸지 않는다.** Pipeline 진행, contract
* 합의, 검수 cycle, 승인 게이트 — 모두 기존 dispatcher / chatHandlers /
* SidebarChatProvider 안에서 결정된다. 이 모듈은 그 결정을 *읽고* webview용
* 상태 객체로 변환할 뿐이다.
* 2. 입력은 기존에 emit되던 이벤트 (CompanyTurnEvent + alignment phase +
* intent classifier 결과)뿐. 새로운 인터럽트 포인트를 만들지 않는다.
* 3. 출력은 두 종류 — `AgentWorkState` (현재 작업 패널)와 `AgentBubble` 큐
* (말풍선 연출). 둘 다 webview가 그대로 받아 그리기만 하면 된다.
*
* 즉 dispatcher 안의 어떤 한 줄도 "if pixelOffice ..."로 분기하지 않는다.
*/
/** Agent의 현재 단계. 사용자가 명세한 11개 상태값 전부 포함. */
export type AgentStatus =
| 'idle'
| 'intake'
| 'analyzing'
| 'need_clarification'
| 'contract_ready'
| 'planning'
| 'executing'
| 'reviewing'
| 'waiting_approval'
| 'error'
| 'done';
/** 말풍선이 어떤 카테고리에서 나왔는지 — 스타일링 / 우선순위에 활용. */
export type BubbleType = 'status' | 'event' | 'warning' | 'error' | 'success';
/** 말풍선 발생 트리거가 되는 이벤트 — 사용자가 명세한 10개 + 약간 확장. */
export type AgentEvent =
| 'missing_required_info'
| 'clarification_needed'
| 'requirement_contract_created'
| 'plan_completed'
| 'execution_started'
| 'review_failed'
| 'review_passed'
| 'risky_change_detected'
| 'approval_required'
| 'error_occurred'
| 'task_completed'
| 'stage_loop_retry';
export interface AgentWorkState {
agentId: string;
agentName: string;
status: AgentStatus;
/** 사용자 원본 요청 한 줄 — 패널 상단 "Current Task"에 표시. */
currentTask?: string;
/** 현재 stage / phase 라벨 — "기획 논의", "QA 진행" 등. */
currentStep?: string;
/** 다음 stage 라벨 (있으면) — 예측 표시용. */
nextStep?: string;
/** 짧은 보조 메시지 (예: "라운드 2/3", "검수자: 민지"). */
message?: string;
/** 진행률 0~1 — 파이프라인 모드일 때 stage index / total로 계산. */
progress?: number;
/** Requirement Contract 요약 (alignment 완료 후 채워짐). */
requirementContract?: {
goal?: string;
context?: string;
criteria?: string[];
format?: string;
openQuestions?: string[];
confidence?: 'low' | 'medium' | 'high';
};
/** 사용자에게 던지는 미해결 질문 목록 — need_clarification 상태에서 채움. */
needUserInput?: string[];
/** 승인 대기 중 항목 — waiting_approval 상태에서 채움. */
awaitingApproval?: string;
/** 짧은 최근 로그 — 사용자가 한눈에 흐름 파악. 최대 6개 ring buffer. */
recentLogs?: string[];
/**
* 파이프라인 미니 맵용 stage 리스트. `plan-ready` 시점에 채워지고 agent-start/
* agent-done 으로 상태가 갱신된다. webview는 이 리스트를 dot strip 으로 렌더.
* - status: 'done' | 'active' | 'pending'
*/
pipelineStages?: Array<{ label: string; agent?: string; status: 'done' | 'active' | 'pending' }>;
/** epoch ms — webview의 "n초 전" 표시용. */
updatedAt: number;
}
export interface AgentBubble {
id: string;
/** 어떤 캐릭터 위에 띄울지 — 단일 캐릭터 모드면 'main' 고정도 가능. */
agentId: string;
text: string;
type: BubbleType;
/** 생성 시각 epoch ms. */
createdAt: number;
/** 자동 사라짐 ms (webview가 사용). 기본값은 webview에서 결정. */
durationMs?: number;
}
/**
* 사용자가 설정으로 켜고 끌 수 있는 행동 옵션.
* webview는 broadcast마다 같이 받아서 즉시 반영.
*/
export interface PixelOfficeConfig {
enabled: boolean;
bubblesEnabled: boolean;
maxVisibleBubbles: number;
bubbleDurationMs: number;
}
// ─────────────────── 상태→말풍선 텍스트 풀 ───────────────────
// 사용자가 명세한 톤(가벼운 사무실 코미디) 유지. 무작위 선택을 위해 같은 상태에
// 여러 안을 두되 너무 길어지지 않게 4~5개로 제한.
const STATUS_BUBBLE_POOL: Record<AgentStatus, string[]> = {
idle: [
'오늘은 무슨 일을 할까?',
'주문 대기 중…',
'커피 한 잔 더 하고 시작할까.',
],
intake: [
'요청서 들어왔다.',
'한번 읽어보자.',
'오케이, 뭘 원하시는지 보자.',
],
analyzing: [
'음… 의도가 조금 모호한데?',
'맥락부터 정리해보자.',
'핵심이 뭐였더라.',
'이거 작업 범위가 어디까지지?',
],
need_clarification: [
'이건 사용자 확인이 먼저야.',
'질문 하나만 하고 가자.',
'추측으로 가면 위험해.',
'핵심 정보가 빠졌네.',
],
contract_ready: [
'좋아, 작업 조건 정리 완료.',
'이제 방향은 잡혔어.',
'계약서 도장 찍었다.',
'이제 진짜 시작.',
],
planning: [
'순서부터 잡아보자.',
'기존 기능은 건드리지 말자.',
'화이트보드 좀 빌릴게.',
'단계 나눠서 가자.',
],
executing: [
'코드 들어간다.',
'이번엔 단순하게 가자.',
'집중 모드 진입.',
'키보드 워밍업 완료.',
],
reviewing: [
'잠깐, 이건 다시 보자.',
'기존 기능 깨지는지 확인해야 해.',
'검수자 시점으로 한 번 더.',
'엣지 케이스 빠진 거 없나.',
],
waiting_approval: [
'이건 승인 없이 못 바꿔.',
'위험 작업 감지. 확인 필요!',
'사장님 결재 부탁드립니다.',
'도장 받기 전엔 멈춤.',
],
error: [
'앗, 이건 예상 못 했는데…',
'조건 하나 놓쳤네.',
'잠깐, 다시 정리.',
'엇, 이게 깨졌네.',
],
done: [
'좋아, 끝났다!',
'이번 작업 깔끔하게 완료.',
'커피 한 잔.',
'오늘치 끝!',
],
};
const EVENT_BUBBLE_POOL: Record<AgentEvent, string[]> = {
missing_required_info: [
'핵심 정보가 빠졌어.',
'이거 빠지면 추측해야 해.',
],
clarification_needed: [
'질문 하나만.',
'확실하지 않으면 먼저 물어보자.',
],
requirement_contract_created: [
'계약서 도장 찍었다.',
'이제 방향은 잡혔어.',
],
plan_completed: [
'계획 정리 끝!',
'순서대로 가자.',
],
execution_started: [
'코드 들어간다.',
'시작!',
],
review_failed: [
'조건 하나 놓쳤네. 다시 보자.',
'너무 복잡하게 가는 거 아냐?',
],
review_passed: [
'검수 통과!',
'셋 다 만족이래.',
],
risky_change_detected: [
'잠깐, 이건 승인 필요!',
'파일 삭제는 함부로 하면 안 돼.',
],
approval_required: [
'결재 부탁드립니다.',
'도장 받고 이어 갈게.',
],
error_occurred: [
'앗, 이건 예상 못 했는데…',
'엇, 이게 깨졌네.',
],
task_completed: [
'좋아, 끝났다!',
'오늘치 끝.',
],
stage_loop_retry: [
'한 번 더 가자.',
'버그 잡고 다시 시도.',
],
};
/**
* 텍스트 풀에서 하나 뽑기. 같은 결과가 연달아 나오지 않게 lastPicked를
* 받아 회피. 풀이 1개뿐이면 어쩔 수 없이 그걸 반환.
*/
export function pickBubbleText(pool: string[], lastPicked?: string): string {
if (!pool || pool.length === 0) return '';
if (pool.length === 1) return pool[0];
let candidates = pool;
if (lastPicked) candidates = pool.filter((s) => s !== lastPicked);
if (candidates.length === 0) candidates = pool;
return candidates[Math.floor(Math.random() * candidates.length)];
}
export function getStatusBubbleText(status: AgentStatus, lastPicked?: string): string {
return pickBubbleText(STATUS_BUBBLE_POOL[status] ?? [], lastPicked);
}
export function getEventBubbleText(event: AgentEvent, lastPicked?: string): string {
return pickBubbleText(EVENT_BUBBLE_POOL[event] ?? [], lastPicked);
}
/** 이벤트 → 말풍선 type 매핑. 색상/스타일을 webview가 결정할 때 사용. */
export function eventBubbleType(event: AgentEvent): BubbleType {
switch (event) {
case 'error_occurred': return 'error';
case 'review_failed':
case 'risky_change_detected':
case 'missing_required_info':
return 'warning';
case 'task_completed':
case 'review_passed':
case 'plan_completed':
case 'requirement_contract_created':
return 'success';
default: return 'event';
}
}
/** AgentBubble factory — id 자동 생성. */
export function makeBubble(opts: {
agentId: string;
text: string;
type?: BubbleType;
durationMs?: number;
}): AgentBubble {
return {
id: `b-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
agentId: opts.agentId,
text: opts.text,
type: opts.type ?? 'status',
createdAt: Date.now(),
durationMs: opts.durationMs,
};
}
+159
View File
@@ -0,0 +1,159 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
/**
* NotebookLM 로컬 백엔드(Datacollect Bridge) 원클릭 실행/종료.
*
* 배경: Datacollector 의 Research(NotebookLM) 기능은 NAS 브리지로 처리할 수 없다 —
* 로그인된 로컬 Chrome 세션과 PC 의 LM Studio(127.0.0.1) 프록시가 필요해서
* Datacollector 프론트가 리서치 요청을 localhost:3002 로 하드코딩해 보낸다.
* 그동안 사용자가 터미널에서 `npm run bridge` 를 직접 실행해야 했는데, 터미널이
* 익숙지 않은 사용자를 위해 사이드바 도구 메뉴의 버튼 두 개로 감쌌다.
*
* 설계:
* - 실행은 VS Code 통합 터미널("NotebookLM Bridge")에서 `npm run bridge` 를 돌린다.
* → 맥/윈도우 셸 차이를 VS Code 가 흡수하고(별도 OS 분기 불필요), 사용자가
* 브리지 로그(인증 만료 안내 등)를 그대로 볼 수 있으며, 종료 = 터미널 dispose.
* - 프로젝트 폴더는 설정(g1nation.datacollectLocalProjectPath) 우선, 비어 있으면
* 워크스페이스 폴더·부모·형제에서 자동 탐색. 못 찾으면 폴더 선택 대화상자 제공.
* - 종료는 우리 터미널 dispose + (버튼 밖에서 띄운 프로세스 대비) 포트 점유
* 프로세스를 OS별 명령으로 best-effort 정리한다 — 이 부분만 OS 자동 감지 분기.
*/
const TERMINAL_NAME = 'NotebookLM Bridge';
const BRIDGE_PORT = 3002;
/** 자동 탐색 시 찾는 프로젝트 폴더 이름 후보 (맥/윈도우 클론 이름이 다를 수 있음). */
const PROJECT_DIR_NAMES = ['Datacollector_MAC', 'Datacollector', 'Datacollect'];
/** 이 파일이 있어야 브리지 프로젝트로 인정. */
const BRIDGE_SCRIPT_REL = path.join('scripts', 'mcp_bridge.mjs');
/**
* 브리지 프로젝트 후보 경로를 우선순위대로 나열한다 (존재 검증은 안 함 — 테스트 가능한 순수 함수).
* 우선순위: 설정값 → 각 워크스페이스 폴더 자신 → 그 부모 아래 형제 폴더들.
*/
export function candidateProjectDirs(cfgPath: string, workspaceDirs: string[]): string[] {
const out: string[] = [];
const push = (p: string) => { if (p && !out.includes(p)) out.push(p); };
if (cfgPath && cfgPath.trim()) push(cfgPath.trim());
for (const ws of workspaceDirs) {
push(ws); // 워크스페이스가 곧 Datacollector 프로젝트인 경우
const parent = path.dirname(ws);
for (const name of PROJECT_DIR_NAMES) push(path.join(parent, name));
}
return out;
}
/** 포트를 점유한 프로세스를 죽이는 OS별 셸 명령 (순수 함수 — 테스트 대상). */
export function buildPortKillCommand(platform: NodeJS.Platform, port: number): string {
if (platform === 'win32') {
// PowerShell: 해당 포트 LISTEN 프로세스를 강제 종료. (netstat 파싱보다 안전)
return `powershell -NoProfile -Command "Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }"`;
}
// macOS / Linux
return `lsof -ti :${port} | xargs kill 2>/dev/null`;
}
function findBridgeProject(): string | null {
const cfgPath = vscode.workspace.getConfiguration('g1nation').get<string>('datacollectLocalProjectPath', '') || '';
const wsDirs = (vscode.workspace.workspaceFolders || [])
.filter(f => f.uri.scheme === 'file')
.map(f => f.uri.fsPath);
for (const dir of candidateProjectDirs(cfgPath, wsDirs)) {
try {
if (fs.existsSync(path.join(dir, BRIDGE_SCRIPT_REL))) return dir;
} catch { /* 접근 불가 경로 — skip */ }
}
return null;
}
/** 브리지 생존 확인 — 어떤 HTTP 응답이든(404 포함) 살아 있는 것. */
async function isBridgeAlive(): Promise<boolean> {
try {
await fetch(`http://127.0.0.1:${BRIDGE_PORT}/`, { signal: AbortSignal.timeout(1500) });
return true;
} catch {
return false;
}
}
function findOurTerminal(): vscode.Terminal | undefined {
return vscode.window.terminals.find(t => t.name === TERMINAL_NAME);
}
export async function startNotebookLmBridge(): Promise<void> {
if (await isBridgeAlive()) {
findOurTerminal()?.show(true);
vscode.window.showInformationMessage('NotebookLM 백엔드가 이미 실행 중입니다 (포트 3002 응답 확인).');
return;
}
let projectDir = findBridgeProject();
if (!projectDir) {
// 자동 탐색 실패 — 비개발자도 해결할 수 있게 폴더 선택 대화상자를 제공한다.
const pick = await vscode.window.showErrorMessage(
'Datacollector 프로젝트 폴더를 찾지 못했습니다. 폴더를 직접 선택하면 설정에 저장되어 다음부터는 버튼만 누르면 됩니다.',
'폴더 선택'
);
if (pick !== '폴더 선택') return;
const chosen = await vscode.window.showOpenDialog({
canSelectFiles: false, canSelectFolders: true, canSelectMany: false,
openLabel: 'Datacollector 폴더 선택',
});
const dir = chosen?.[0]?.fsPath;
if (!dir) return;
if (!fs.existsSync(path.join(dir, BRIDGE_SCRIPT_REL))) {
vscode.window.showErrorMessage(`선택한 폴더에 ${BRIDGE_SCRIPT_REL} 가 없습니다 — Datacollector 프로젝트 폴더가 맞는지 확인하세요.`);
return;
}
await vscode.workspace.getConfiguration('g1nation')
.update('datacollectLocalProjectPath', dir, vscode.ConfigurationTarget.Global);
projectDir = dir;
}
// 기존 우리 터미널이 남아 있으면 재사용하지 않고 정리 후 새로 연다 (죽은 셸 방지).
findOurTerminal()?.dispose();
const terminal = vscode.window.createTerminal({ name: TERMINAL_NAME, cwd: projectDir });
terminal.show(true);
terminal.sendText('npm run bridge');
vscode.window.showInformationMessage(
'NotebookLM 백엔드를 시작했습니다 — 준비까지 몇 초 걸릴 수 있어요. (로그는 "NotebookLM Bridge" 터미널에서 확인)'
);
}
export async function stopNotebookLmBridge(): Promise<void> {
const term = findOurTerminal();
if (term) term.dispose();
// 터미널 밖(예전 수동 실행 등)에서 뜬 프로세스까지 정리 — 포트 점유 시 OS별 명령으로 kill.
await new Promise(r => setTimeout(r, 600)); // 터미널 dispose 가 프로세스를 정리할 시간
if (await isBridgeAlive()) {
try {
await execAsync(buildPortKillCommand(process.platform, BRIDGE_PORT), { timeout: 8000 });
} catch { /* 이미 죽었거나 권한 문제 — 아래 재확인으로 판정 */ }
}
if (await isBridgeAlive()) {
vscode.window.showWarningMessage('NotebookLM 백엔드가 아직 응답합니다 — 다른 프로그램이 포트 3002 를 쓰고 있을 수 있습니다.');
} else {
vscode.window.showInformationMessage('NotebookLM 백엔드를 종료했습니다.');
}
}
/** 사이드바 웹뷰 메시지 핸들러 — sidebarProvider 의 핸들러 체인에 등록된다. */
export async function handleBridgeLauncherMessage(_provider: unknown, data: any): Promise<boolean> {
switch (data?.type) {
case 'startNotebookLmBridge':
await startNotebookLmBridge();
return true;
case 'stopNotebookLmBridge':
await stopNotebookLmBridge();
return true;
default:
return false;
}
}
@@ -159,6 +159,18 @@ function _extractPathFromReportLine(line: string): string | null {
* @param projectRoot 현재 워크스페이스 루트 (cwd로 사용)
* @returns 추가 report 라인 (없으면 빈 배열 — 검증 대상 파일 없음)
*/
/**
* [실행 루프] 문법 검증 실패 라인들을 모델이 즉시 수정하도록 하는 재주입 메시지.
* agent 가 이 메시지를 internal system 으로 chatHistory 에 넣으면 자동 후속 턴이 돌아
* <edit_file> 수정 → 다음 라운드 재검증의 자가수정 루프가 성립한다 (maxAutoSteps 한도).
*/
export function buildSyntaxFixInstruction(failLines: string[]): string {
return `[Result of execution check — SYNTAX ERRORS]
${failLines.join('\n')}
위 파일이 문법 검증에 실패했다. 지금 즉시 <edit_file> 로 오류를 수정하라 (수정하면 자동으로 재검증된다). 원인이 불명확하면 먼저 <read_file> 로 해당 파일을 읽어라. 사용자에게 수동 수정을 요구하지 마라.`;
}
export async function verifyCreatedFiles(report: string[], projectRoot: string): Promise<string[]> {
const candidates: string[] = [];
for (const line of report) {
+27 -1
View File
@@ -93,6 +93,7 @@ interface SettingsState {
chunkedMaxSections: number;
polishPersonaOverride: string;
};
domainKnowledge: { general: string; math: string; coding: string; facts: string };
datacollect: {
/** 'local' | 'nas' — 어느 Bridge 인스턴스를 호출할지. */
bridgeTarget: string;
@@ -206,7 +207,9 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
SettingsPanelProvider.viewType,
'Astra Settings',
column,
{ enableScripts: true, localResourceRoots: [this._deps.extensionUri], retainContextWhenHidden: true }
// [메모리 개선] retainContextWhenHidden 제거 — 재표시 시 웹뷰 JS 가 'ready' 를 보내
// 상태를 다시 받아오고 활성 탭도 getState() 로 복원하므로, 숨김 동안 렌더러 상주가 불필요.
{ enableScripts: true, localResourceRoots: [this._deps.extensionUri] }
);
this._panel = panel;
this._setupWebview(panel.webview);
@@ -281,6 +284,9 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
case 'datacollect.update':
await this._handleDatacollectUpdate(msg);
return;
case 'domainKnowledge.update':
await this._handleDomainKnowledgeUpdate(msg);
return;
case 'google.update':
await this._handleGoogleUpdate(msg);
return;
@@ -656,6 +662,20 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
}
/** [단계별 지식 스코프] 도메인별 지식 폴더 설정 저장 — 필드별 부분 업데이트. */
private async _handleDomainKnowledgeUpdate(msg: any): Promise<void> {
const keys: Array<[string, string]> = [
['general', 'domainKnowledge.generalFolder'],
['math', 'domainKnowledge.mathFolder'],
['coding', 'domainKnowledge.codingFolder'],
['facts', 'domainKnowledge.factsFolder'],
];
for (const [field, cfgKey] of keys) {
if (typeof msg[field] === 'string') await this._safeConfigUpdate(cfgKey, msg[field].trim());
}
await this._refreshState();
}
private async _handleDatacollectUpdate(msg: any): Promise<void> {
if (typeof msg.bridgeTarget === 'string') {
const t = msg.bridgeTarget.trim() === 'nas' ? 'nas' : 'local';
@@ -745,6 +765,12 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
chunkedMaxSections: cfg.get<number>('chunkedMaxSections', 3) ?? 3,
polishPersonaOverride: cfg.get<string>('polishPersonaOverride', '') ?? '',
},
domainKnowledge: {
general: cfg.get<string>('domainKnowledge.generalFolder', '') || '',
math: cfg.get<string>('domainKnowledge.mathFolder', '') || '',
coding: cfg.get<string>('domainKnowledge.codingFolder', '') || '',
facts: cfg.get<string>('domainKnowledge.factsFolder', '') || '',
},
datacollect: {
bridgeTarget: cfg.get<string>('datacollectBridgeTarget', 'local') || 'local',
bridgeUrl: cfg.get<string>('datacollectBridgeUrl', '') || '',
+19 -3
View File
@@ -10,6 +10,7 @@ import { embedQuery, embedTexts } from '../../retrieval/embeddings';
import { backfillBrainEmbeddings, backfillBrainChunkEmbeddings } from '../../retrieval/brainIndex';
import { loadWeaknessProfile, buildSelfReviewBlock, registerKnowledgeGap } from '../../intelligence/correctionLoop';
import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap';
import { resolveDomainScope, classifyKnowledgeDomain, buildDomainRoutingHint } from '../../retrieval/domainRouter';
import {
resolveKnowledgeMix,
mapWeightToBrainFileLimit,
@@ -154,6 +155,15 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
// Agent ↔ knowledge map. 매핑 없으면 folders=[] → orchestrator 가 whole-brain 사용 (legacy).
const scope = resolveScopeForAgent(deps.agentSkillFile, deps.activeBrain.localBrainPath);
// [단계별 지식 스코프] 에이전트 매핑이 없을 때만, 질문 도메인(수학/코딩/팩트)이 분류되고
// 해당 Specialty 폴더가 설정돼 있으면 General+Specialty 로 검색 범위를 좁힌다.
// 소형 모델 컨텍스트에서 무관 청크를 배제하는 정밀도 최적화 — 분류 실패·미설정 시 기존 전체 검색.
// (에이전트 스코프가 이미 있으면 그쪽 우선 — 기존 제한을 느슨하게 만들지 않는다.)
let effectiveScopeFolders = scope.folders;
if (effectiveScopeFolders.length === 0 && deps.activeBrain.localBrainPath) {
const domainScope = resolveDomainScope(deps.currentPrompt, deps.activeBrain.localBrainPath);
if (domainScope.folders.length > 0) effectiveScopeFolders = domainScope.folders;
}
// Context 윈도우 비례 retrieval 예산. 32K → 8K, 230K → 57K, 80K cap (scoring 속도).
const scaledTotalBudget = Math.min(
@@ -207,7 +217,7 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
retrievalRatio: mixedRetrievalRatio,
},
brainFileLimit: mixedBrainFileLimit,
scopeFolders: scope.folders,
scopeFolders: effectiveScopeFolders,
recentSessions,
mediumTermLimit: config.memoryMediumTermSessions ?? 0,
queryEmbedding,
@@ -222,7 +232,8 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
// [Correction Loop ③-a] 근거 약함 + agent scope 적용 중이면 전체 두뇌로 1회
// 재검색 — scope 가 정답 문서를 가리는 경우를 구제. 더 강한 근거가 나올 때만 채택.
if (scope.folders.length > 0 && assessGrounding(result).level === 'weak') {
// (도메인 스코프도 포함 — Specialty 폴더가 정답 문서를 가리는 경우 같은 방식으로 구제)
if (effectiveScopeFolders.length > 0 && assessGrounding(result).level === 'weak') {
try {
const retry = deps.retrievalOrchestrator.retrieve(deps.currentPrompt, { ...retrieveOpts, scopeFolders: [] });
if (assessGrounding(retry).level !== 'weak') {
@@ -412,7 +423,12 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
// [CONTEXT] *밖* 보호 구역에 주입되어 context-overflow truncation 에서도 살아남는다.
// 옛 구현은 이 블록들을 RAG 본문과 같은 문자열에 합쳐 [CONTEXT] 안에 넣었는데,
// "실패 방지 제약"이 토큰 압박에서 배경 지식과 함께 잘려나가는 모순이 있었다.
const constraintBlock = [selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
// [도구 라우팅 힌트] 도메인 분류(수학/코딩/팩트)가 그 턴의 도구 사용을 직접 지시한다 —
// 수학→<calculate> 위임, 코딩→<run_code> 실행 확인, 팩트→근거 없으면 확인 불가.
// 지식 스코프와 무관하게(에이전트 스코프가 있어도) 행동 제약으로 항상 주입.
const routingHint = buildDomainRoutingHint(classifyKnowledgeDomain(deps.currentPrompt));
const constraintBlock = [routingHint, selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
if (constraintBlock) blocks.set('behavior-constraints', constraintBlock);
return memoryBlock;
}
+7 -2
View File
@@ -1,4 +1,7 @@
import { LMStudioClient as SDKClient, LLM, type LLMLoadModelConfig } from '@lmstudio/sdk';
// [메모리·기동 개선] SDK 를 타입 전용으로만 import — 값 import 는 번들 로드(=확장 활성화) 시점에
// SDK 모듈 초기화를 실행시킨다. 실제 모듈 로드는 getSdk() 첫 호출 때 require 로 지연한다
// (LM Studio 를 안 쓰는 세션에서는 SDK 가 아예 초기화되지 않음).
import type { LMStudioClient as SDKClient, LLM, LLMLoadModelConfig } from '@lmstudio/sdk';
import { logError, logInfo } from '../utils';
/** Load-time options forwarded to LM Studio's `llm.load()`. Subset of `LLMLoadModelConfig`. */
@@ -117,7 +120,9 @@ export class LMStudioClient implements ILMStudioClient {
private getSdk(): SDKClient {
if (!this._sdk) {
this._sdk = new SDKClient(this._wsUrl ? { baseUrl: this._wsUrl } : {});
// 지연 로드: 첫 사용 시점에만 SDK 모듈을 초기화한다 (상단 주석 참조).
const { LMStudioClient } = require('@lmstudio/sdk') as typeof import('@lmstudio/sdk');
this._sdk = new LMStudioClient(this._wsUrl ? { baseUrl: this._wsUrl } : {});
}
return this._sdk;
}
+47 -17
View File
@@ -108,17 +108,37 @@ interface BrainState {
dirty: boolean;
diskPath: string | null; // null if we can't determine a writable path
writeTimer?: ReturnType<typeof setTimeout>;
evictTimer?: ReturnType<typeof setTimeout>;
}
const _states = new Map<string, BrainState>();
// [메모리 개선] 유휴 브레인 인덱스 자동 해제 — 예전엔 한 번 로드된 브레인의 토큰·임베딩 전체가
// 확장 호스트 메모리에 세션 내내 상주했다. 인덱스는 mtime 키 디스크 캐시에서 언제든 싸게
// 재로드되므로, 마지막 사용 후 IDLE_EVICT_MS 가 지나면 dirty 를 flush 하고 메모리에서 내린다.
// 상시 인터벌 없이 접근할 때마다 재예약되는 one-shot unref 타이머라 그 자체의 발열 비용은 없다.
const IDLE_EVICT_MS = 30 * 60_000;
function touchEviction(st: BrainState, brainPath: string): void {
if (st.evictTimer) clearTimeout(st.evictTimer);
const t = setTimeout(() => {
st.evictTimer = undefined;
if (st.writeTimer) { clearTimeout(st.writeTimer); st.writeTimer = undefined; }
flushToDisk(st, brainPath);
_states.delete(brainPath);
logInfo('Brain index evicted after idle — memory released (disk cache intact).', { brainPath });
}, IDLE_EVICT_MS);
if (typeof (t as any).unref === 'function') (t as any).unref();
st.evictTimer = t;
}
function indexFileFor(brainPath: string): string {
return path.join(brainPath, INDEX_DIR, INDEX_FILE);
}
function loadState(brainPath: string): BrainState {
const existing = _states.get(brainPath);
if (existing) return existing;
if (existing) { touchEviction(existing, brainPath); return existing; }
let index: PersistedIndex = { version: INDEX_VERSION, entries: {} };
let diskPath: string | null = null;
@@ -138,29 +158,35 @@ function loadState(brainPath: string): BrainState {
}
const st: BrainState = { index, dirty: false, diskPath };
_states.set(brainPath, st);
touchEviction(st, brainPath);
return st;
}
// 디스크 flush 본체 — 디바운스 쓰기(scheduleWrite)와 유휴 해제(touchEviction)가 공유한다.
function flushToDisk(st: BrainState, brainPath: string): void {
if (!st.dirty || !st.diskPath) return;
try {
const dir = path.dirname(st.diskPath);
fs.mkdirSync(dir, { recursive: true });
// One-time .gitignore so the cache dir never gets committed into a Second Brain git repo.
const gi = path.join(dir, '.gitignore');
if (!fs.existsSync(gi)) {
try { fs.writeFileSync(gi, '*\n', 'utf8'); } catch { /* non-fatal */ }
}
const tmp = `${st.diskPath}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(st.index), 'utf8');
fs.renameSync(tmp, st.diskPath);
st.dirty = false;
} catch (e: any) {
logInfo('Brain index write failed (continuing in-memory only).', { brainPath, error: e?.message || String(e) });
}
}
function scheduleWrite(st: BrainState, brainPath: string): void {
if (!st.dirty || !st.diskPath || st.writeTimer) return;
const timer = setTimeout(() => {
st.writeTimer = undefined;
if (!st.dirty || !st.diskPath) return;
try {
const dir = path.dirname(st.diskPath);
fs.mkdirSync(dir, { recursive: true });
// One-time .gitignore so the cache dir never gets committed into a Second Brain git repo.
const gi = path.join(dir, '.gitignore');
if (!fs.existsSync(gi)) {
try { fs.writeFileSync(gi, '*\n', 'utf8'); } catch { /* non-fatal */ }
}
const tmp = `${st.diskPath}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(st.index), 'utf8');
fs.renameSync(tmp, st.diskPath);
st.dirty = false;
} catch (e: any) {
logInfo('Brain index write failed (continuing in-memory only).', { brainPath, error: e?.message || String(e) });
}
flushToDisk(st, brainPath);
}, WRITE_DEBOUNCE_MS);
if (typeof (timer as any).unref === 'function') (timer as any).unref();
st.writeTimer = timer;
@@ -366,6 +392,7 @@ export function getBrainEmbeddings(brainPath: string, filePaths: string[], model
const embKey = embeddingKey(model);
const st = _states.get(brainPath);
if (!st) return out;
touchEviction(st, brainPath);
for (const fp of filePaths) {
const entry = st.index.entries[fp];
if (!entry?.embedding || entry.embeddingModel !== embKey) continue;
@@ -469,6 +496,7 @@ export function getBrainChunkEmbeddings(brainPath: string, model: string): Map<s
const embKey = embeddingKey(model);
const st = _states.get(brainPath);
if (!st) return out;
touchEviction(st, brainPath);
for (const [fp, entry] of Object.entries(st.index.entries)) {
if (!entry?.chunks) continue;
for (let ci = 0; ci < entry.chunks.length; ci++) {
@@ -556,11 +584,13 @@ export function clearBrainTokenIndex(brainPath?: string): void {
if (brainPath === undefined) {
for (const st of _states.values()) {
if (st.writeTimer) { clearTimeout(st.writeTimer); st.writeTimer = undefined; }
if (st.evictTimer) { clearTimeout(st.evictTimer); st.evictTimer = undefined; }
}
_states.clear();
return;
}
const st = _states.get(brainPath);
if (st?.writeTimer) clearTimeout(st.writeTimer);
if (st?.evictTimer) clearTimeout(st.evictTimer);
_states.delete(brainPath);
}
+158
View File
@@ -0,0 +1,158 @@
import * as fs from 'fs';
import * as path from 'path';
import { getConfig } from '../config';
/**
* 단계별 지식 스코프 라우터 — 소형 모델 정밀 검색의 핵심.
*
* 배경: 4b급 로컬 모델은 컨텍스트에 무관한 청크가 섞이면 크게 흔들린다.
* 질문을 수학/코딩/팩트 세 도메인으로 분류해, 해당 도메인의 Specialty 지식
* 폴더(+ 공통 General 폴더)로 RAG 검색 범위를 좁히면 같은 컨텍스트 예산에
* 훨씬 밀도 높은 근거가 들어간다. 사용자는 도메인별 위키를 해당 폴더에서
* 관리한다 (예: Topic_Programming 위키 → 코딩 Specialty).
*
* 설계 원칙:
* - 분류는 LLM 호출 없는 결정적 휴리스틱 — 매 턴 추가 지연/비용 0, 테스트 가능.
* - 확신이 없으면 'general' → 기존 전체 두뇌 검색으로 폴백 (안전한 opt-in).
* - 해당 도메인의 Specialty 폴더가 설정·존재할 때만 스코프 적용
* (General 폴더 하나만으로 좁히면 오히려 근거가 굶주린다).
* - 에이전트별 지식 매핑(agentKnowledgeMap)이 이미 있으면 그쪽이 우선 —
* 기존 제한을 이 기능이 느슨하게 만들면 안 된다 (호출부에서 보장).
*/
export type KnowledgeDomain = 'math' | 'coding' | 'facts' | 'general';
// ── 분류 휴리스틱 ─────────────────────────────────────────────────────────
// 각 도메인의 신호 패턴. 히트 수 가중 합산 → 최고점이 임계치 이상이면 그 도메인.
const CODING_PATTERNS: RegExp[] = [
/```/, // 코드 펜스
/\b(error|exception|traceback|stack ?trace|undefined is not|cannot read|segfault)\b/i,
/(에러|오류|버그|디버그|디버깅|컴파일|빌드 실패|함수|메서드|클래스|변수|리팩토링|구현해|코드 짜|코드를|스크립트)/,
/\.(ts|tsx|js|jsx|py|java|go|rs|rb|php|cs|cpp|c|h|css|scss|html|sql|json|yaml|yml|sh|mjs)\b/i,
/\b(python|typescript|javascript|java|kotlin|swift|rust|golang|node\.?js|react|vue|django|spring|sql|regex|api|endpoint)\b/i,
/\b(npm|pip|git|docker|jest|pytest|eslint|tsc)\b/i,
/\b[a-z]+[A-Z][a-zA-Z]*\(|[a-z_]+_[a-z_]+\(/, // camelCase( / snake_case( 함수 호출꼴
];
const MATH_PATTERNS: RegExp[] = [
/\d+\s*[+\-*/^×÷]\s*\d+/, // 숫자 연산식
/\d+(\.\d+)?\s*%/, // 백분율
/(계산해|계산기|얼마(야|인가|예요|입니까)|평균|합계|총합|확률|방정식|부등식|미분|적분|제곱|루트|소수점|반올림|나머지|몫)/,
/(이자|복리|단리|환율|환산|할인율|수익률|퍼센트|평형|넓이|부피|둘레|각도|속도.*(거리|시간))/,
/\b(solve|equation|probability|percentage|average|median|std ?dev|factorial|permutation|combination)\b/i,
/(조합|순열|경우의 수|표준편차|중앙값|분산)/,
];
const FACTS_PATTERNS: RegExp[] = [
/(뭐야|뭔가요|무엇인가|무엇입니까|누구(야|인가|예요)|언제(야|인가|였)|어디(야|인가|에 있)|왜 그런|정의(가|는|를)|뜻이|의미가)/,
/(설명해|알려줘|소개해|정리해|차이(가|는|점)|역사|유래|어원|개념)/,
/\b(what is|who is|when did|where is|why does|define|explain|difference between|history of)\b/i,
];
function countHits(text: string, patterns: RegExp[]): number {
let n = 0;
for (const re of patterns) { if (re.test(text)) n++; }
return n;
}
/**
* 질문을 지식 도메인으로 분류한다 (결정적 휴리스틱, LLM 호출 없음).
* 코드 펜스가 있으면 coding 확정. 그 외엔 도메인별 패턴 히트 수로 판정하고,
* 동점이거나 신호가 약하면 'general'(전체 두뇌 검색)로 보수적으로 폴백한다.
*/
export function classifyKnowledgeDomain(query: string): KnowledgeDomain {
const q = (query || '').trim();
if (!q) return 'general';
if (/```/.test(q)) return 'coding'; // 코드 블록 동봉 = 코딩 확정
const coding = countHits(q, CODING_PATTERNS);
const math = countHits(q, MATH_PATTERNS);
const facts = countHits(q, FACTS_PATTERNS);
// 전문 도메인(coding/math)은 강한 신호라 1히트로 충분하며 facts 보다 우선한다 —
// 정의형 질문("git rebase가 뭐야?")도 전문 지식 폴더가 더 정확한 근거를 갖는다.
// facts 는 일반 대화와 경계가 흐려서 전문 신호가 전혀 없을 때만 인정.
if (coding === 0 && math === 0 && facts === 0) return 'general';
if (coding > 0 && coding >= math) return 'coding';
if (math > 0) return 'math';
return 'facts';
}
/**
* [도구 라우팅] 도메인 분류 → 그 턴의 행동 지시. 분류 1회로 지식 스코프와 도구
* 유도를 동시에 결정한다 — 소형 모델에게 "풀어라"가 아니라 "이 도구를 써라"를 준다.
* dynamicBlocks 보호 구역에 주입되어 컨텍스트 truncation 에서도 살아남는다.
*/
export function buildDomainRoutingHint(domain: KnowledgeDomain): string {
switch (domain) {
case 'math':
return '[도구 라우팅] 이 질문은 수학·계산 도메인으로 분류되었다. 수치·수학 계산은 절대 암산하지 말고 <calculate> 태그로 Python 에 위임한 뒤, 주입된 실행 결과만 해석해 답하라.';
case 'coding':
return '[도구 라우팅] 이 질문은 코딩·디버깅 도메인으로 분류되었다. 실행 가능한 코드를 작성·수정했으면 <run_code path="..."/> 로 실제 실행해 결과를 확인한 뒤 답하라. 문법 오류나 실행 에러가 주입되면 즉시 <edit_file> 로 수정하고 다시 실행해 확인하라.';
case 'facts':
return '[도구 라우팅] 이 질문은 사실 확인(팩트) 도메인으로 분류되었다. 컨텍스트에 근거가 없는 사실은 지어내지 말고 "확인 불가"라고 답하거나, 최신 정보가 필요하면 <fetch_url> 로 확인하라.';
default:
return '';
}
}
// ── 도메인 → 지식 폴더 해석 ──────────────────────────────────────────────
/** 도메인별로 검색에 포함할 두뇌-상대 폴더 목록 (순수 함수 — 존재 검증 없음, 테스트 대상). */
export function combineDomainFolders(
domain: KnowledgeDomain,
cfg: { general: string; math: string; coding: string; facts: string },
): string[] {
if (domain === 'general') return []; // 분류 안 됨 → 전체 두뇌 (레거시 동작)
const specialty = (cfg[domain] || '').trim();
if (!specialty) return []; // Specialty 미설정 → 스코프 적용 안 함 (General 만으로 좁히면 굶주림)
const general = (cfg.general || '').trim();
return general ? [general, specialty] : [specialty];
}
export interface DomainScope {
domain: KnowledgeDomain;
/** 검색에 넘길 절대 경로 폴더 목록. 빈 배열 = 스코프 미적용(전체 두뇌). */
folders: string[];
/** 로그/근거 추적용 라벨. 예: "coding → General + Specialty_Coding" */
label: string;
}
/**
* 설정을 읽어 도메인의 유효한(존재하는·두뇌 안의) 스코프 폴더 절대경로를 만든다.
* 폴더가 두뇌 밖을 가리키거나 없으면 조용히 제외하지 않고 — 존재하는 것만 채택하되
* Specialty 가 무효이면 스코프 전체를 포기한다(전체 두뇌 폴백).
*/
export function resolveDomainScope(query: string, brainRoot: string): DomainScope {
const domain = classifyKnowledgeDomain(query);
const none: DomainScope = { domain, folders: [], label: `${domain} → 전체 두뇌` };
if (domain === 'general' || !brainRoot) return none;
const config = getConfig();
const relFolders = combineDomainFolders(domain, {
general: config.domainKnowledgeGeneralFolder,
math: config.domainKnowledgeMathFolder,
coding: config.domainKnowledgeCodingFolder,
facts: config.domainKnowledgeFactsFolder,
});
if (relFolders.length === 0) return none;
const rootAbs = path.resolve(brainRoot);
const valid: string[] = [];
for (const rel of relFolders) {
const abs = path.resolve(rootAbs, rel);
// 두뇌 밖 탈출(../) 차단 + 실제 존재하는 디렉토리만
if (!abs.startsWith(rootAbs + path.sep) && abs !== rootAbs) continue;
try { if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) valid.push(abs); } catch { /* skip */ }
}
// Specialty(마지막 항목)가 살아남지 못했으면 스코프 포기 — General 만으로는 좁히지 않는다.
const specialtyAbs = path.resolve(rootAbs, relFolders[relFolders.length - 1]);
if (!valid.includes(specialtyAbs)) return none;
return {
domain,
folders: valid,
label: `${domain} → ${relFolders.join(' + ')}`,
};
}
+2 -6
View File
@@ -2,15 +2,14 @@ import type { CompanyTurnEvent } from '../../features/company';
/**
* 1인 기업 turn 의 `CompanyTurnEvent` emit 사이드이펙트 묶음. dispatcher 가 매
* phase 마다 호출하는 `onEvent` 콜백을 *3가지 사이드이펙트 (webview 송신 + summary
* 기록 + pixel office hook)* 와 *turn 종료 후 caller 가 읽을 mutable state* 로
* phase 마다 호출하는 `onEvent` 콜백을 *2가지 사이드이펙트 (webview 송신 + summary 기록)* 와 *turn 종료 후 caller 가 읽을 mutable state* 로
* 분리해 한 factory 로 격리.
*
* 옛 코드: sidebarProvider 의 `_runCompanyTurn` 안 16줄짜리 closure. 사이드이펙트
* 와 mutable 캡처가 한 함수에 섞여 있어 가독성/테스트성 모두 낮았다.
*
* 사용:
* const emitter = createCompanyTurnEmit({ postWebview, recordSummary, onPixelOffice });
* const emitter = createCompanyTurnEmit({ postWebview, recordSummary });
* const deps = buildDispatcherDeps({ ..., onEvent: emitter.emit });
* await runCompanyTurn(prompt, deps);
* const report = emitter.getFinalReport();
@@ -25,8 +24,6 @@ export interface CompanyTurnEmitDeps {
* 된 turn 은 호출 안 됨 (report-done 이 안 옴).
*/
recordSummary: (brief: string, reportTail: string) => void;
/** Pixel Office 같은 read-only 모니터링 hub 알림. throw 해도 turn 흐름엔 영향 없음. */
onPixelOffice: (event: CompanyTurnEvent) => void;
}
export interface CompanyTurnEmitter {
@@ -47,7 +44,6 @@ export function createCompanyTurnEmit(deps: CompanyTurnEmitDeps): CompanyTurnEmi
finalReport = (event.report || '').trim();
deps.recordSummary(stagingBrief, finalReport.slice(-600));
}
try { deps.onPixelOffice(event); } catch { /* never break the turn */ }
},
getFinalReport: () => finalReport,
};
-8
View File
@@ -120,7 +120,6 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
// 작동(사용자가 alignment off로 설정하지 않은 한). 분류기 끄는
// 이유는 보통 "잡담도 다 pipeline으로"인데 그럴수록 alignment
// 효과가 큼.
try { provider.pixelOfficeOnIntentClassified('new_task', userPrompt); } catch { /* noop */ }
if (cfg.companyIntentAlignmentMode === 'off' || alignmentBypass) {
await provider._runCompanyTurn(userPrompt, undefined, keywordOverrideId);
} else {
@@ -161,8 +160,6 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
},
{ model: cfg.companyIntentClassifierModel || cfg.defaultModel },
);
// Pixel Office: 분류 결과를 UI layer로만 흘림. 아래 분기 자체엔 영향 없음.
try { provider.pixelOfficeOnIntentClassified(verdict.intent, userPrompt); } catch { /* noop */ }
if (verdict.intent === 'new_task') {
// 우선순위: (1) 사용자 키워드 (2) autoSelect가 켜져 있고 분류기 추천 있음 (3) 사용자 활성 파이프라인.
let effectiveOverride = keywordOverrideId;
@@ -254,9 +251,6 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
// Restore the Company chip from globalState so the user sees the same
// mode they had on at last shutdown.
await provider._sendCompanyStatus();
// Pixel Office — 첫 로드 시 빈 idle 상태라도 한 번 push해서 webview가
// 영역 자체를 그릴 수 있게.
provider.pixelOfficeResend();
return true;
case 'getReadyStatus':
await provider._sendReadyStatus();
@@ -413,8 +407,6 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
case 'upsertCompanyPipeline':
case 'deleteCompanyPipeline':
case 'getCompanyPipelineTemplate':
case 'getPixelOfficeState':
case 'openPixelOfficePanel':
case 'respondCompanyAlignment':
case 'respondCompanyApproval':
case 'setActiveCompanyPipeline':
+1 -10
View File
@@ -5,7 +5,7 @@ import { SidebarChatProvider } from '../sidebarProvider';
*
* 의도: chatHandlers 가 한때 모든 카테고리의 webview 메시지를 처리했는데, 회사
* 모드가 ~30개의 메시지 타입(getCompanyStatus / setCompanyAgentDisplay / resume /
* alignment / approval / pipeline / pixelOffice …)을 끌고 들어오면서 single
* alignment / approval / pipeline …)을 끌고 들어오면서 single
* file 이 700+ 줄로 부풀었다. chronicleHandlers 처럼 도메인별 분리 패턴이 이미
* 시작돼 있어서 회사 모드도 같은 모양으로 떼어낸다.
*
@@ -250,15 +250,6 @@ export async function handleCompanyMessage(
});
return true;
}
case 'getPixelOfficeState':
// webview가 처음 로드되었거나 사용자가 토글 ON 했을 때 캐시된
// 현재 상태를 다시 받기 위한 요청. read-only.
provider.pixelOfficeResend();
return true;
case 'openPixelOfficePanel':
// 사이드바 mini Pixel Office의 ⛶ 버튼 → editor area에 전체보기 panel 열기.
provider.openPixelOfficePanel();
return true;
case 'respondCompanyAlignment': {
// alignment 카드 버튼: 'proceed' = 현 contract로 dispatch, 'cancel' = 폐기.
const decision = typeof data.decision === 'string' ? data.decision : '';
-156
View File
@@ -1,156 +0,0 @@
import * as vscode from 'vscode';
import type {
AgentStatus,
AgentWorkState,
AgentBubble,
CompanyState,
RequirementContract,
} from '../../features/company';
import { listActiveAgentsByCategory } from '../../features/company';
import type { OfficeAgentSnapshot } from '../../features/astraOffice';
import { renderAstraOfficePanelHtml } from '../../features/astraOffice';
/**
* Pixel Office UI broadcast 에 쓰이는 pure helpers. 모두 stateless transform —
* sidebarProvider 의 private 메서드를 그대로 추출.
*
* - mergeAgentWorkState(prev, patch) — broadcast 직전 state merge + updatedAt 갱신
* - summariseContract(contract) — RequirementContract → webview 가 표시할 요약 모양
* - buildOfficeRoster(state) — CompanyState → presenter 의 roster 입력
*
* webview send / 말풍선 sequencing / bubble dedupe 같은 *상태성* 흐름은 여전히
* sidebarProvider 와 PixelOfficeState 에 남는다. 이 모듈은 *데이터 변환만* 책임.
*/
const ROSTER_ORDER: Array<'ceo' | 'planner' | 'researcher' | 'designer' | 'developer' | 'qa' | 'inspector' | 'support'> = [
'ceo', 'planner', 'researcher', 'designer', 'developer', 'qa', 'inspector', 'support',
];
/**
* 직전 상태에 patch 를 머지해 다음 state 생성. recentLogs 는 patch 가 명시
* 안 했으면 prev 그대로 유지 (broadcast 중 일부 호출이 log 만 push 하지 않는
* 케이스 — 그래도 옛 logs 가 화면에 살아 있어야 함). updatedAt 은 항상 갱신해
* webview 의 "마지막 갱신" 뱃지가 작동.
*/
export function mergeAgentWorkState(
prev: AgentWorkState | undefined,
patch: Partial<AgentWorkState>,
): AgentWorkState {
const base = prev ?? {
agentId: 'main',
agentName: 'Agent',
status: 'idle' as AgentWorkState['status'],
recentLogs: [],
updatedAt: Date.now(),
};
return {
...base,
...patch,
recentLogs: patch.recentLogs ?? base.recentLogs ?? [],
updatedAt: Date.now(),
};
}
/**
* RequirementContract 를 webview 카드 표시용 요약 객체로 변환. 빈 문자열 필드는
* undefined 로 정규화해 카드 UI 에서 "값 없음" 분기를 단순화.
*/
export function summariseContract(c: RequirementContract): {
goal: string | undefined;
context: string | undefined;
criteria: RequirementContract['criteria'];
format: string | undefined;
openQuestions: RequirementContract['openQuestions'];
confidence: RequirementContract['confidence'];
} {
return {
goal: c.goal || undefined,
context: c.context || undefined,
criteria: c.criteria,
format: c.format || undefined,
openQuestions: c.openQuestions,
confidence: c.confidence,
};
}
/**
* 현재 CompanyState 에서 active agents 를 평탄화해 presenter 의 roster 입력으로.
* `state.enabled === false` 면 빈 배열 (presenter 가 옛 single-agent fallback 사용).
* ROSTER_ORDER 순서 보존 — 화면에서 일관된 좌→우/위→아래 정렬.
*/
/**
* 사이드바/패널 webview 가 받는 `pixelOfficeUpdate` 메시지 payload 빌드. 기능
* 비활성화면 state=null + 빈 bubbles 로 webview 가 "꺼짐" 표시. 활성화면 cached
* state (없으면 default idle agent) + 빈 bubbles + config snapshot.
*
* bubbles 는 *resend* (재전송) 용도라 항상 빈 배열 — 같은 말풍선을 reload 마다
* 다시 띄우면 시각 노이즈. 새 broadcast 가 bubbles 를 채워준다.
*/
export function buildPixelOfficeUpdatePayload(
cfg: { companyPixelOfficeEnabled: boolean; companyPixelOfficeBubbles: boolean },
state: AgentWorkState | undefined,
): { type: 'pixelOfficeUpdate'; value: { state: AgentWorkState | null; bubbles: AgentBubble[]; config: { enabled: boolean; bubblesEnabled: boolean; maxVisibleBubbles: number; bubbleDurationMs: number } } } {
if (!cfg.companyPixelOfficeEnabled) {
return {
type: 'pixelOfficeUpdate',
value: { state: null, bubbles: [], config: { enabled: false, bubblesEnabled: false, maxVisibleBubbles: 0, bubbleDurationMs: 0 } },
};
}
const resolved: AgentWorkState = state ?? {
agentId: 'main',
agentName: 'Agent',
status: 'idle' as AgentStatus,
recentLogs: [],
updatedAt: Date.now(),
};
return {
type: 'pixelOfficeUpdate',
value: {
state: resolved,
bubbles: [],
config: {
enabled: cfg.companyPixelOfficeEnabled,
bubblesEnabled: cfg.companyPixelOfficeBubbles,
maxVisibleBubbles: 3,
bubbleDurationMs: 4500,
},
},
};
}
/**
* Pixel Office 전체보기 panel 의 HTML 본문. 사이드바 mini 와 같은 메시지 스키마를
* 받지만 그리는 방식이 전혀 달라 (사무실 그리드 + 직군별 캐릭터) 별도 HTML 사용.
* CSP source 와 derived asset base URI 만 webview 에서 가져오면 나머지는 정적.
*/
export function buildPixelOfficeHtml(extensionUri: vscode.Uri, webview: vscode.Webview): string {
const cspSource = webview.cspSource;
const derivedBase = webview.asWebviewUri(
vscode.Uri.joinPath(extensionUri, 'assets', 'pixelOffice', 'derived'),
).toString();
return renderAstraOfficePanelHtml({ cspSource, derivedBase });
}
export function buildOfficeRoster(state: CompanyState): Array<{
agentId: string;
agentName: string;
roleCategory: OfficeAgentSnapshot['roleCategory'];
}> {
try {
if (!state.enabled) return [];
const buckets = listActiveAgentsByCategory(state);
const out: Array<{ agentId: string; agentName: string; roleCategory: OfficeAgentSnapshot['roleCategory'] }> = [];
for (const cat of ROSTER_ORDER) {
for (const def of buckets[cat] ?? []) {
out.push({
agentId: def.id,
agentName: def.name ?? def.id,
roleCategory: cat,
});
}
}
return out;
} catch {
return [];
}
}
@@ -1,37 +0,0 @@
import * as vscode from 'vscode';
import { migrateLayout } from '../../features/astraOffice';
/**
* Pixel Office 사용자 정의 레이아웃 (cells/decos) 의 workspaceState 영구 저장 layer.
*
* 옛 코드: sidebarProvider 의 3개 private 메서드 (`_readPixelOfficeLayout`,
* `_writePixelOfficeLayout`, `_clearPixelOfficeLayout`) + 1개 static key 상수.
* 한 줄짜리 wrapper 들이지만 *workspaceState key + migration validator* 정책을
* 한 클래스에 묶어 (a) key drift 방지, (b) 새 v2→v3 migration 추가 시 한 곳만,
* (c) layout 형태를 모르는 webview 호출자도 안전한 unknown 반환.
*
* Read 는 legacy v1 데이터를 `migrateLayout` 으로 정규화 — 정상 통과 못 한 데이터는
* null 로 fallback (webview 가 디폴트 LAYOUT 사용).
*/
export class PixelOfficeLayoutStore {
private static readonly KEY = 'g1nation.pixelOfficeLayout';
constructor(private readonly context: vscode.ExtensionContext) {}
/** 저장된 layout 조회. raw → migrateLayout 통과 못 하면 null. */
read(): unknown {
const raw = this.context.workspaceState.get(PixelOfficeLayoutStore.KEY);
if (raw == null) return null;
return migrateLayout(raw);
}
/** webview 의 편집 모드에서 저장 버튼 누름 시 호출. */
async write(layout: unknown): Promise<void> {
await this.context.workspaceState.update(PixelOfficeLayoutStore.KEY, layout);
}
/** 디폴트 LAYOUT 으로 복귀 (entry 자체를 삭제). */
async clear(): Promise<void> {
await this.context.workspaceState.update(PixelOfficeLayoutStore.KEY, undefined);
}
}
-77
View File
@@ -1,77 +0,0 @@
import * as vscode from 'vscode';
import type { AgentWorkState } from '../../features/company';
import type { OfficeActivityItem } from '../../features/astraOffice';
/**
* Pixel Office 의 *상태 컨테이너* — webview 송신/이벤트 핸들링은 provider 에 남기고,
* 4개로 흩어져 있던 raw state slot 만 한 객체로 묶는다.
*
* 옛 코드는 sidebarProvider 의 4개 slot 으로 분리돼 있어 (panel, currentState,
* lastBubble Map, activity ring buffer) "panel 닫혔는데 ring buffer 가 안 비워졌다"
* 같은 미묘한 stale state bug 가 발생할 위험이 있었음. 컨테이너로 묶어
* resetForNewSession() 한 줄로 일괄 초기화 가능하게 만들었다.
*
* 왜 메서드들은 안 옮겼나: pixelOfficeOnTurnEvent 등은 webview 송신 + payload
* 빌드 + ring buffer push 가 한 데 엮여 있어 이걸 manager 로 옮기면 webview send
* callback 을 constructor 로 주입해야 하고 코드 양이 오히려 늘어남. State 만
* 격리해도 god-file 의 state slot 13→9 라는 목표 효과는 달성.
*/
export class PixelOfficeState {
/** 전체보기 webview panel — 사이드바 mini 와 별개 instance. */
panel?: vscode.WebviewPanel;
/** 현재 turn 의 누적 상태. broadcast 직전 patch 가 합쳐진 결과 보관. */
current?: AgentWorkState;
/** 같은 말풍선 텍스트 연속 출력 방지용 — key: "status:<s>" or "event:<e>". */
private readonly lastBubbleByKey = new Map<string, string>();
/** Activity ring buffer — officeSnapshot.activity 에 포함되는 누적 action-tag 결과. */
private activityBuffer: OfficeActivityItem[] = [];
private static readonly ACTIVITY_MAX = 32;
/** 직전 말풍선 텍스트 조회. duplicate 회피 로직에서 사용. */
getLastBubble(key: string): string | undefined {
return this.lastBubbleByKey.get(key);
}
/** 말풍선 텍스트 기록. */
setLastBubble(key: string, text: string): void {
this.lastBubbleByKey.set(key, text);
}
/**
* 현재 activity 버퍼 전체 조회 (snapshot 빌드에 사용). presenter 가
* mutable 배열을 요구해 일단 같은 참조 그대로 반환 — 외부는 read-only 로
* 다뤄야 함 (push 는 pushActivity() 만 사용).
*/
getActivity(): OfficeActivityItem[] {
return this.activityBuffer;
}
/**
* Activity 항목 push + ring buffer cap 유지. agent-done 시 action-tag report
* 가 여러 줄이면 한 번에 호출.
*/
pushActivity(items: Array<{ agentId: string; text: string; kind?: 'ok' | 'warn' | 'err' | 'info' }>): void {
if (!items?.length) return;
const now = Date.now();
for (const it of items) {
this.activityBuffer.push({ ts: now, agentId: it.agentId, text: it.text, kind: it.kind });
}
if (this.activityBuffer.length > PixelOfficeState.ACTIVITY_MAX) {
this.activityBuffer = this.activityBuffer.slice(-PixelOfficeState.ACTIVITY_MAX);
}
}
/**
* recentLogs ring buffer push — 새 라인을 현재 상태의 recentLogs 끝에 붙여
* 최근 6개만 보존한 새 배열을 반환. 호출자는 이 배열을 다음 broadcast 의
* `patch.recentLogs` 로 전달해 next state 에 반영.
*/
appendLog(line: string): string[] {
const cur = this.current?.recentLogs ?? [];
return [...cur, line].slice(-6);
}
}
+4 -514
View File
@@ -26,21 +26,13 @@ import { handleChatMessage } from './sidebar/chatHandlers';
import { handleBrainMessage } from './sidebar/brainHandlers';
import { handleChronicleMessage } from './sidebar/chronicleHandlers';
import { handleAgentMessage } from './sidebar/agentHandlers';
import { handleBridgeLauncherMessage } from './features/datacollect/bridgeLauncher';
import { registerSidebarHandler, dispatchSidebarMessage } from './sidebar/registry';
import { ApprovalGateManager } from './sidebar/managers/approvalGates';
import { ArchitectureWatchManager } from './sidebar/managers/architectureWatch';
import { CompanyTurnRuntime, AlignmentSession } from './sidebar/managers/companyTurn';
import { PixelOfficeState } from './sidebar/managers/pixelOfficeState';
import {
mergeAgentWorkState,
summariseContract,
buildOfficeRoster,
buildPixelOfficeUpdatePayload,
buildPixelOfficeHtml,
} from './sidebar/managers/pixelOfficeHelpers';
import { ChatSessionStore, ChatSession } from './sidebar/managers/chatSessionStore';
import { ModelDiscovery } from './sidebar/managers/modelDiscovery';
import { PixelOfficeLayoutStore } from './sidebar/managers/pixelOfficeLayoutStore';
import {
buildBrainStatusPayload,
buildBrainProfilesPayload,
@@ -107,6 +99,8 @@ registerSidebarHandler(handleChatMessage);
registerSidebarHandler(handleBrainMessage);
registerSidebarHandler(handleChronicleMessage);
registerSidebarHandler(handleAgentMessage);
// NotebookLM 로컬 백엔드 실행/종료 버튼 (도구 메뉴) — Research 기능용 편의 런처.
registerSidebarHandler(handleBridgeLauncherMessage);
import { resolveScopeForAgent } from './skills/agentKnowledgeMap';
import { clearBrainTokenIndex } from './retrieval/brainIndex';
import { extractUrls } from './features/web/webFetch';
@@ -133,7 +127,6 @@ import {
} from './features/company';
import { AIService } from './core/services';
import { HealthCheckMonitor } from './core/health';
import { presentOfficeSnapshot } from './features/astraOffice';
export interface SidebarLmStudioDeps {
lifecycle: ModelLifecycleManager;
@@ -165,15 +158,6 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
static readonly lastAutoChronicleSignatureStateKey = 'g1nation.lastAutoChronicleSignature';
_view?: vscode.WebviewView;
_panel?: vscode.WebviewPanel;
/**
* Pixel Office 의 모든 raw state slot 을 한 컨테이너로 통합.
* - panel: 전체보기 webview panel reference (닫히면 undefined)
* - current: 누적된 AgentWorkState (broadcast 직전 patch merge 결과)
* - lastBubble Map: 같은 텍스트 연속 출력 회피
* - activity ring buffer: officeSnapshot 의 action-tag 히스토리
* Broadcast 메서드는 provider 에 남아 webview send 와 직접 통신.
*/
private readonly _pixelOffice = new PixelOfficeState();
public brainEnabled = true;
_currentSessionBrainId: string | null = null;
_currentNegativePrompt: string = '';
@@ -228,484 +212,13 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
/** 옛 _pendingAlignment slot 을 AlignmentSession 으로 통합. */
private readonly _alignment = new AlignmentSession();
/**
* Pixel Office 현재 작업 상태 캐시. 모든 emit/alignment hook이 한 슬롯에
* 모인 상태를 patch 형식으로 갱신 → broadcast. UI layer이므로 어떤 판단
* 로직에도 다시 영향 주지 않는다 — 단방향 read-only 흐름.
*/
// _pixelOfficeState / _pixelOfficeLastBubble / _pixelOfficeActivity / PIXEL_OFFICE_ACTIVITY_MAX
// 는 _pixelOffice (PixelOfficeState) 컨테이너로 통합.
/**
* Astra Office 사용자 정의 레이아웃. workspace state에 저장되어 프로젝트별
* 다른 배치를 가질 수 있다. null/undefined면 webview가 디폴트 LAYOUT을 사용.
* 편집 모드에서 사용자가 드래그로 위치 조정 후 저장 → 이 슬롯 업데이트 →
* webview에 다시 broadcast.
*
* 데이터 shape:
* {
* cells: [{ roleKey, deskX, deskY, charX, charY }, ...],
* decos: [{ id, type, x, y }, ...]
* }
*/
// Pixel Office layout 영구 저장 — `src/sidebar/managers/pixelOfficeLayoutStore.ts` 의
// PixelOfficeLayoutStore 로 이관. constructor 에서 초기화.
/** Phase B-2에서 chatHandlers가 alignment 진행 여부를 빠르게 확인하는 용도. */
isAlignmentPending(): boolean {
return this._alignment.isPending();
}
// ─────────────────────── Pixel Office collector ───────────────────────
//
// 이 섹션은 *기존 emit / alignment / classifier 결과를 가로채* 그대로
// 모니터링용 상태로 변환하는 단방향 hub. 어떤 메서드도 추가 LLM 콜을
// 만들지 않고, dispatcher / planner / chatHandlers의 어느 분기에도
// 영향을 주지 않는다. 그래서 이 섹션 전체를 통째로 지워도 회사 모드는
// 평소와 동일하게 동작해야 한다 — 그 invariant가 깨지면 위반이다.
/** webview로 보낼 직전 patch와 가벼운 reset/유틸. */
private _pixelOfficeBroadcast(patch: Partial<import('./features/company').AgentWorkState>, opts?: {
bubbleStatus?: import('./features/company').AgentStatus;
bubbleEvent?: import('./features/company').AgentEvent;
bubbleAgentId?: string;
}): void {
const cfg = getConfig();
if (!cfg.companyPixelOfficeEnabled) return;
const next = mergeAgentWorkState(this._pixelOffice.current, patch);
this._pixelOffice.current = next;
// 말풍선 — 상태 또는 이벤트가 지정된 경우만 생성. 풀에서 직전에 쓴 텍스트
// 회피해서 같은 말 연달아 안 나오게.
const bubbles: import('./features/company').AgentBubble[] = [];
if (cfg.companyPixelOfficeBubbles) {
const aid = opts?.bubbleAgentId ?? next.agentId;
if (opts?.bubbleStatus) {
const lastKey = `status:${opts.bubbleStatus}`;
// 동적 import 대신 require로 — 메서드 내부에서 너무 늦게 await 걸지 않게.
const { getStatusBubbleText, makeBubble } = require('./features/company') as typeof import('./features/company');
const text = getStatusBubbleText(opts.bubbleStatus, this._pixelOffice.getLastBubble(lastKey));
if (text) {
this._pixelOffice.setLastBubble(lastKey, text);
bubbles.push(makeBubble({ agentId: aid, text, type: 'status' }));
}
}
if (opts?.bubbleEvent) {
const lastKey = `event:${opts.bubbleEvent}`;
const { getEventBubbleText, eventBubbleType, makeBubble } = require('./features/company') as typeof import('./features/company');
const text = getEventBubbleText(opts.bubbleEvent, this._pixelOffice.getLastBubble(lastKey));
if (text) {
this._pixelOffice.setLastBubble(lastKey, text);
bubbles.push(makeBubble({ agentId: aid, text, type: eventBubbleType(opts.bubbleEvent) }));
}
}
}
const payload = {
type: 'pixelOfficeUpdate' as const,
value: {
state: next,
bubbles,
config: {
enabled: cfg.companyPixelOfficeEnabled,
bubblesEnabled: cfg.companyPixelOfficeBubbles,
maxVisibleBubbles: 3,
bubbleDurationMs: 4500,
},
},
};
// 사이드바 mini panel + 전체보기 webview panel 둘 다 같은 데이터 받음.
this._view?.webview.postMessage(payload);
this._pixelOffice.panel?.webview.postMessage(payload);
// ── refactor #4 단계적 마이그레이션 ──
// 옛 `pixelOfficeUpdate` 와 함께 새 `officeSnapshot` 도 emit. 어느 webview 가
// 새 message 를 listen 하기 시작하면 그때부터 자연스럽게 OfficeSnapshot 기반으로
// 동작. 양쪽 다 listen 안 해도 옛 동작 유지. 다음 세션에 webview 측 리시버 추가.
try {
// Roster derive (#G): 현재 turn 의 active agents 전체를 OfficeSnapshot 에 포함.
// listActiveAgentsByCategory 는 CEO 를 항상 포함하고 사용자 activeAgentIds 만
// 통과시킨다. presenter 가 active agent 만 실제 상태로 표시, 나머지는 idle.
const roster = buildOfficeRoster(readCompanyState(this._context));
const snapshot = presentOfficeSnapshot({
activeState: next,
recentBubbles: bubbles,
recentActivity: this._pixelOffice.getActivity(),
roster,
});
const snapMsg = { type: 'officeSnapshot' as const, value: snapshot };
this._view?.webview.postMessage(snapMsg);
this._pixelOffice.panel?.webview.postMessage(snapMsg);
} catch (e) {
// presenter 가 throw 해도 옛 동작에는 영향 없도록 swallow.
// 디버깅 필요 시 console.warn 으로 띄울 수 있음.
}
}
/**
* 현재 CompanyState 에서 active agents 를 평탄화 → presenter 의 roster 입력 모양으로.
* read 실패 / company off → 빈 배열 (presenter 가 옛 single-agent fallback 사용).
*/
// _buildOfficeRoster / _pixelOfficeAppendLog → `src/sidebar/managers/pixelOfficeHelpers.ts`
// + PixelOfficeState.appendLog. Activity push 도 PixelOfficeState.pushActivity 직접 호출.
/** Intent classifier가 분류 직후 호출되어 한 줄 상태 진입을 표시. */
pixelOfficeOnIntentClassified(intent: 'chat' | 'followup' | 'new_task', userPrompt: string): void {
if (intent !== 'new_task') {
// 잡담/후속 — 회사 모드 표면적으로 일 안 하므로 'idle'로 잠시 복귀.
this._pixelOfficeBroadcast({
status: 'idle',
currentTask: userPrompt,
currentStep: intent === 'followup' ? '직전 라운드 후속 응답' : '잡담 응답',
message: undefined,
recentLogs: this._pixelOffice.appendLog(`💬 분류: ${intent}`),
}, { bubbleStatus: 'idle' });
return;
}
// new_task — intake → analyzing 흐름의 첫 신호.
this._pixelOfficeBroadcast({
status: 'intake',
currentTask: userPrompt,
currentStep: '요청 접수',
recentLogs: this._pixelOffice.appendLog(`📨 새 작업 요청 접수`),
}, { bubbleStatus: 'intake' });
}
/** Intent Alignment 분석 시작 (LLM 콜 직전). */
pixelOfficeOnAlignmentStart(userPrompt: string): void {
this._pixelOfficeBroadcast({
status: 'analyzing',
currentTask: this._pixelOffice.current?.currentTask || userPrompt,
currentStep: '요청 분석 중 (C·G·C·F 추출)',
message: undefined,
recentLogs: this._pixelOffice.appendLog('🔍 의도 분석 시작'),
}, { bubbleStatus: 'analyzing' });
}
/** Alignment 결과를 받아 카드로 표시 직전. kind=auto-proceed면 곧장 planning 흐름. */
pixelOfficeOnAlignmentResult(kind: 'questions' | 'confirm' | 'auto-proceed', contract: import('./features/company').RequirementContract): void {
if (kind === 'auto-proceed') {
this._pixelOfficeBroadcast({
status: 'contract_ready',
currentStep: 'Requirement Contract 확정 (자동 진행)',
requirementContract: summariseContract(contract),
needUserInput: undefined,
recentLogs: this._pixelOffice.appendLog('✅ 계약 자동 확정'),
}, {
bubbleStatus: 'contract_ready',
bubbleEvent: 'requirement_contract_created',
});
return;
}
if (kind === 'questions') {
this._pixelOfficeBroadcast({
status: 'need_clarification',
currentStep: '확인 질문 대기 중',
requirementContract: summariseContract(contract),
needUserInput: contract.openQuestions,
recentLogs: this._pixelOffice.appendLog(`🤔 ${contract.openQuestions.length}개 질문 대기`),
}, {
bubbleStatus: 'need_clarification',
bubbleEvent: 'clarification_needed',
});
return;
}
// confirm — 질문 없음, 사용자 OK만 받으면 됨.
this._pixelOfficeBroadcast({
status: 'waiting_approval',
currentStep: '계약 확인 대기',
requirementContract: summariseContract(contract),
needUserInput: undefined,
awaitingApproval: '사용자 확인 — 그대로 진행할지 여부',
recentLogs: this._pixelOffice.appendLog('🧭 계약 확인 카드 표시'),
}, { bubbleStatus: 'waiting_approval' });
}
/** Alignment 취소(사용자가 카드 닫음). */
pixelOfficeOnAlignmentCancelled(): void {
this._pixelOfficeBroadcast({
status: 'idle',
currentStep: '취소됨',
needUserInput: undefined,
awaitingApproval: undefined,
recentLogs: this._pixelOffice.appendLog('🛑 작업 취소'),
});
}
// _summariseContract → `src/sidebar/managers/pixelOfficeHelpers.ts`
/**
* Dispatcher의 `CompanyTurnEvent`를 그대로 받아 Pixel Office 상태로 변환.
* `_runCompanyTurn`의 emit 콜백이 매 이벤트를 한 번 더 이쪽으로 흘려준다.
* 함수 안에서 어떤 dispatcher 분기도 다시 트리거하지 않는다 — read-only.
*/
pixelOfficeOnTurnEvent(ev: import('./features/company').CompanyTurnEvent): void {
switch (ev.phase) {
case 'plan-start':
this._pixelOfficeBroadcast({
status: 'planning',
currentStep: '계획 수립',
recentLogs: this._pixelOffice.appendLog('📋 plan 작성'),
}, { bubbleStatus: 'planning' });
return;
case 'plan-ready': {
// B. 미니 맵용 stage 리스트 초기화. 모두 pending.
const stages = (ev.plan?.tasks ?? []).map((t) => ({
label: (t.task || '').slice(0, 24) || '단계',
agent: t.agent,
status: 'pending' as const,
}));
this._pixelOfficeBroadcast({
status: 'planning',
currentStep: '계획 완료',
nextStep: ev.plan?.tasks?.[0]?.task,
message: ev.plan?.brief?.slice(0, 120),
recentLogs: this._pixelOffice.appendLog(`📋 plan 완료 (${ev.plan?.tasks?.length ?? 0}개 task)`),
pipelineStages: stages,
}, { bubbleEvent: 'plan_completed' });
return;
}
case 'agent-start': {
// B. 현재 stage를 active로, 이전 stage들은 done으로.
const prev = this._pixelOffice.current?.pipelineStages;
const stages = prev ? prev.map((s, i) => ({
...s,
status: (i < ev.index ? 'done' : i === ev.index ? 'active' : 'pending') as 'done'|'active'|'pending',
})) : undefined;
this._pixelOfficeBroadcast({
status: 'executing',
currentStep: ev.task?.slice(0, 80),
nextStep: undefined,
message: `${ev.agentId} • ${ev.index + 1}/${ev.total}`,
progress: ev.total > 0 ? ev.index / ev.total : undefined,
recentLogs: this._pixelOffice.appendLog(`▶ ${ev.agentId} start (${ev.index + 1}/${ev.total})`),
pipelineStages: stages,
}, {
bubbleAgentId: ev.agentId,
bubbleStatus: 'executing',
bubbleEvent: ev.index === 0 ? 'execution_started' : undefined,
});
return;
}
case 'agent-done':
// E. action-tag report가 있으면 ticker로 별도 push — 캐릭터가 *무슨
// 일을 했는지* 실시간 가시화. 사용자가 신뢰도/투명성을 즉시 체감.
if (Array.isArray(ev.output?.actionReport) && ev.output.actionReport.length > 0) {
const items = ev.output.actionReport.map((line: string) => ({
agentId: ev.agentId, text: line, ts: Date.now(),
}));
this._view?.webview.postMessage({ type: 'pixelOfficeActivity', value: { items } });
this._pixelOffice.panel?.webview.postMessage({ type: 'pixelOfficeActivity', value: { items } });
// 새 path 도 동기화 — ring buffer 에 누적 → 다음 officeSnapshot 에 포함.
this._pixelOffice.pushActivity(items.map((it) => ({ agentId: it.agentId, text: it.text })));
}
this._pixelOfficeBroadcast({
status: ev.output?.error ? 'error' : 'executing',
progress: ev.total > 0 ? (ev.index + 1) / ev.total : undefined,
recentLogs: this._pixelOffice.appendLog(
ev.output?.error
? `❌ ${ev.agentId} ${ev.output.error}`
: `✓ ${ev.agentId} 완료`,
),
}, {
bubbleAgentId: ev.agentId,
bubbleEvent: ev.output?.error ? 'error_occurred' : undefined,
});
return;
case 'stage-loop':
this._pixelOfficeBroadcast({
status: 'executing',
currentStep: `재시도: ${ev.from} → ${ev.to} (${ev.iteration}회)`,
recentLogs: this._pixelOffice.appendLog(`🔁 loop ${ev.from}→${ev.to} #${ev.iteration}`),
}, { bubbleEvent: 'stage_loop_retry' });
return;
case 'review-start':
this._pixelOfficeBroadcast({
status: 'reviewing',
currentStep: `검수 사이클 — ${ev.stageLabel}`,
message: `검수자: ${ev.inspectorAgentId} · 최대 ${ev.maxRounds}라운드`,
recentLogs: this._pixelOffice.appendLog(`🔍 검수 시작: ${ev.stageLabel}`),
}, { bubbleStatus: 'reviewing' });
return;
case 'review-round':
this._pixelOfficeBroadcast({
status: 'reviewing',
currentStep: `검수 라운드 ${ev.round} (${ev.inspectorVerdict}/${ev.ceoVerdict})`,
recentLogs: this._pixelOffice.appendLog(
`R${ev.round} insp:${ev.inspectorVerdict} ceo:${ev.ceoVerdict}`,
),
}, {
bubbleAgentId: ev.inspectorAgentId,
bubbleEvent: ev.inspectorVerdict === 'pass' && ev.ceoVerdict === 'pass'
? 'review_passed'
: (ev.inspectorVerdict === 'revise' ? 'review_failed' : undefined),
});
return;
case 'review-end':
this._pixelOfficeBroadcast({
status: ev.final === 'aborted' ? 'error' : 'executing',
currentStep: ev.final === 'pass'
? `검수 통과 (${ev.rounds}라운드)`
: ev.final === 'maxed-out'
? `검수 한도 도달 — 진행 (${ev.rounds}라운드)`
: '검수 중단',
recentLogs: this._pixelOffice.appendLog(`🔍 검수 종료: ${ev.final}`),
}, {
bubbleEvent: ev.final === 'pass' ? 'review_passed'
: ev.final === 'aborted' ? 'risky_change_detected'
: undefined,
});
return;
case 'awaiting-approval':
this._pixelOfficeBroadcast({
status: 'waiting_approval',
currentStep: `승인 대기 — ${ev.stageLabel}`,
awaitingApproval: `${ev.stageLabel} 단계 완료 검토`,
recentLogs: this._pixelOffice.appendLog(`✋ 승인 대기: ${ev.stageLabel}`),
}, {
bubbleStatus: 'waiting_approval',
bubbleEvent: 'approval_required',
});
return;
case 'approval-resolved':
this._pixelOfficeBroadcast({
status: 'executing',
awaitingApproval: undefined,
currentStep: `승인 결과: ${ev.decision}`,
recentLogs: this._pixelOffice.appendLog(`✋→ ${ev.decision}`),
});
return;
case 'report-start':
this._pixelOfficeBroadcast({
status: 'reviewing',
currentStep: 'CEO 종합 보고서 작성 중',
recentLogs: this._pixelOffice.appendLog('🧭 보고서 작성'),
});
return;
case 'report-done':
this._pixelOfficeBroadcast({
status: 'done',
currentStep: ev.ok ? '보고서 완료' : '보고서 (fallback) 완료',
progress: 1,
recentLogs: this._pixelOffice.appendLog(ev.ok ? '✅ 보고서 OK' : '⚠ fallback 보고서'),
}, {
bubbleStatus: 'done',
bubbleEvent: 'task_completed',
});
return;
case 'session-saved':
this._pixelOfficeBroadcast({
status: 'done',
message: `세션 저장됨`,
recentLogs: this._pixelOffice.appendLog('💾 세션 저장'),
});
return;
case 'aborted':
this._pixelOfficeBroadcast({
status: 'error',
currentStep: `중단: ${ev.reason}`,
recentLogs: this._pixelOffice.appendLog(`🛑 abort: ${ev.reason}`),
}, {
bubbleStatus: 'error',
bubbleEvent: 'error_occurred',
});
return;
case 'telegram-mirror':
default:
return; // 시각화에 의미 약함 — log skip
}
}
/** webview가 처음 로드되거나 사용자가 토글을 다시 켰을 때 캐시된 상태 재전송. */
pixelOfficeResend(): void {
const cfg = getConfig();
const payload = buildPixelOfficeUpdatePayload(cfg, this._pixelOffice.current);
this._view?.webview.postMessage(payload);
this._pixelOffice.panel?.webview.postMessage(payload);
if (cfg.companyPixelOfficeEnabled) {
try {
const snapshot = presentOfficeSnapshot({
activeState: payload.value.state ?? undefined,
recentBubbles: [],
recentActivity: this._pixelOffice.getActivity(),
roster: buildOfficeRoster(readCompanyState(this._context)),
});
const snapMsg = { type: 'officeSnapshot' as const, value: snapshot };
this._view?.webview.postMessage(snapMsg);
this._pixelOffice.panel?.webview.postMessage(snapMsg);
} catch {
// Snapshot 재전송 실패가 기존 fallback 경로를 깨면 안 됨.
}
}
}
/**
* editor area에 별도 Pixel Office 전체보기 panel을 띄움. 이미 열려 있으면
* 그 panel을 reveal. 사이드바 mini 패널과 동일한 데이터 스트림을 받지만
* 별도 HTML로 *사무실 그리드 + 직군별 캐릭터* 레이아웃을 보여준다.
*/
public openPixelOfficePanel(column: vscode.ViewColumn = vscode.ViewColumn.Beside): vscode.WebviewPanel {
if (this._pixelOffice.panel) {
this._pixelOffice.panel.reveal(column);
return this._pixelOffice.panel;
}
const panel = vscode.window.createWebviewPanel(
'astra.pixelOffice',
'Astra Office',
column,
{ enableScripts: true, localResourceRoots: [this._extensionUri], retainContextWhenHidden: true },
);
this._pixelOffice.panel = panel;
panel.webview.html = buildPixelOfficeHtml(this._extensionUri, panel.webview);
// panel과 백엔드 사이의 메시지 채널 — 닫기/리프레시 + 레이아웃 편집.
panel.webview.onDidReceiveMessage((msg: any) => {
if (!msg || typeof msg !== 'object') return;
if (msg.type === 'getPixelOfficeState') this.pixelOfficeResend();
if (msg.type === 'closePixelOfficePanel') panel.dispose();
if (msg.type === 'getPixelOfficeLayout') {
// webview가 layout 요청 — 저장된 게 있으면 그것, 없으면 null.
panel.webview.postMessage({
type: 'pixelOfficeLayoutLoaded',
value: this._layoutStore.read() ?? null,
});
}
if (msg.type === 'savePixelOfficeLayout') {
// webview의 편집 모드에서 사용자가 저장 버튼 누름.
void this._layoutStore.write(msg.value).then(() => {
panel.webview.postMessage({
type: 'pixelOfficeLayoutSaved', value: { ok: true },
});
});
}
if (msg.type === 'resetPixelOfficeLayout') {
// 디폴트 LAYOUT으로 복귀.
void this._layoutStore.clear().then(() => {
panel.webview.postMessage({
type: 'pixelOfficeLayoutSaved', value: { ok: true, reset: true },
});
});
}
if (msg.type === 'pixelOfficeCommand') {
// D. 캐릭터 컨텍스트 메뉴 액션. 현재 abort만 처리 — Stop 버튼과
// 동일 경로(abortCompanyTurn + alignment 취소 + agent.stop)로
// 작업 즉시 중단. skip/pause 등은 dispatcher에 별도 API가 없어 미지원.
if (msg.cmd === 'abort') {
this.abortCompanyTurn();
this.cancelPendingAlignment();
this._agent.stop();
}
}
});
panel.onDidDispose(() => {
if (this._pixelOffice.panel === panel) this._pixelOffice.panel = undefined;
});
// 열자마자 현재 상태 한 번 push.
this.pixelOfficeResend();
return panel;
}
// _buildPixelOfficeHtml → `src/sidebar/managers/pixelOfficeHelpers.ts` (buildPixelOfficeHtml)
/** Alignment 슬롯 비우기 — 사용자가 "취소"를 눌렀거나 turn 시작/종료 시점 호출. */
clearPendingAlignment(): void {
this._alignment.clear();
@@ -723,8 +236,6 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
/** chat_sessions globalState 의 read/write/normalize 를 한 클래스로 격리. constructor 에서 초기화. */
private readonly _sessionStore: ChatSessionStore;
/** Pixel Office 사용자 정의 layout 의 workspaceState read/write/clear. constructor 에서 초기화. */
private readonly _layoutStore: PixelOfficeLayoutStore;
/** Chronicle 프로젝트 목록 globalState read/write + workspace fallback. constructor 에서 초기화. */
private readonly _chronicleStore: ChronicleProjectStore;
/** Agent skill .md 파일 + lastSelected + negativePrompt 영구 저장. constructor 에서 초기화. */
@@ -742,7 +253,6 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
readonly _lmStudio?: SidebarLmStudioDeps
) {
this._sessionStore = new ChatSessionStore(_context);
this._layoutStore = new PixelOfficeLayoutStore(_context);
this._chronicleStore = new ChronicleProjectStore(_context);
this._agentSkillStore = new AgentSkillStore(_context);
this._sessionState = new SessionStateStore(_context);
@@ -2028,9 +1538,6 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
const state = readCompanyState(this._context);
const activePipeline = resolveActivePipeline(state);
// Pixel Office: 분석 시작 표시 (LLM 콜 직전).
try { this.pixelOfficeOnAlignmentStart(opts.userPrompt); } catch { /* noop */ }
// 모드 전환 직전 일반 채팅 요약 — Intent Alignment 가 *이미 논의된 맥락* 을
// 재질문하지 않도록. 후속 라운드(previousContract 있음) 면 chatHistory 가
// 이미 contract 에 흡수됐으므로 중복 첨부 안 함.
@@ -2126,9 +1633,6 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
],
openQuestions: contract.openQuestions.filter((q) => !solvedSet.has(q)),
};
this._pixelOfficeBroadcast({
recentLogs: this._pixelOffice.appendLog(`🔎 자가 조사로 질문 ${solved.length}건 해결`),
});
}
}
} catch { /* 자가 조사 실패 — 원래 질문 그대로 노출 */ }
@@ -2137,7 +1641,6 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
if (shouldAutoProceedAlignment(opts.mode, contract, reachedLimit)) {
// contract 한 줄 안내 후 곧장 pipeline. 사용자 friction 최소.
this._view?.webview.postMessage(buildAlignmentAutoProceedPayload(contract, reachedLimit));
try { this.pixelOfficeOnAlignmentResult('auto-proceed', contract); } catch { /* noop */ }
this._alignment.clear();
this._saveAlignmentKnowledgeIfAny(opts.userPrompt, contract.answeredQuestions);
await this._runCompanyTurn(opts.userPrompt, undefined, opts.pipelineIdOverride, contract);
@@ -2149,7 +1652,6 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
const askMode: 'confirm' | 'questions' = reachedLimit || contract.openQuestions.length === 0
? 'confirm' : 'questions';
this._view?.webview.postMessage(buildAlignmentAskPayload(askMode, contract, opts.roundsAsked, opts.roundsLimit));
try { this.pixelOfficeOnAlignmentResult(askMode, contract); } catch { /* noop */ }
this._alignment.set({
userOriginalPrompt: opts.userPrompt,
contract,
@@ -2224,12 +1726,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
if (cfg.companyAlignmentKnowledgeSave === false) return;
if (!qaList || qaList.length === 0) return;
const brain = getActiveBrainProfile();
const saved = saveAlignmentKnowledge(brain.localBrainPath, { userPrompt, qaList });
if (saved) {
this._pixelOfficeBroadcast({
recentLogs: this._pixelOffice.appendLog('💾 확인된 정보 두뇌 저장'),
});
}
saveAlignmentKnowledge(brain.localBrainPath, { userPrompt, qaList });
} catch { /* non-fatal */ }
}
@@ -2242,7 +1739,6 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
this._alignment.clear();
this._view?.webview.postMessage(buildAlignmentCancelledPayload());
this._postStreamEnd();
try { this.pixelOfficeOnAlignmentCancelled(); } catch { /* noop */ }
void this._sendReadyStatus();
}
@@ -2335,7 +1831,6 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
const emitter = createCompanyTurnEmit({
postWebview: (msg) => { this._view?.webview.postMessage(msg); },
recordSummary: (brief, tail) => this._companyTurn.recordSummary(brief, tail),
onPixelOffice: (event) => this.pixelOfficeOnTurnEvent(event),
});
// Fresh AbortController per turn — the Stop button routes through
// `abortCompanyTurn()` to fire `.abort()`. The dispatcher checks
@@ -2360,9 +1855,6 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
if (cfgTurn.webAutoFetchEnabled !== false && !resumeTimestamp) {
const urls = extractUrls(userPrompt, 2);
if (urls.length > 0) {
this._pixelOfficeBroadcast({
recentLogs: this._pixelOffice.appendLog(`🌐 URL ${urls.length}건 수집 중`),
});
const blocks: string[] = [];
for (const url of urls) {
blocks.push(await buildUrlContext(url)); // 5분 캐시 — alignment 와 중복 비용 없음
@@ -3445,5 +2937,3 @@ export function wrapPanelAsView(panel: vscode.WebviewPanel): vscode.WebviewView
return adapter as vscode.WebviewView;
}
// Astra Office full-panel HTML 은 src/features/astraOffice/ 로 이전됨 (refactor #1).
// _buildPixelOfficeHtml() 가 renderAstraOfficePanelHtml() 을 직접 호출한다.
+28
View File
@@ -414,6 +414,34 @@ R7. GUESS-AND-ACT WITH STATED ASSUMPTION. When information is missing but a reas
- 회당 최대 2개. 결과는 [URL CONTENT] 블록으로 주입되며, 실패하면 실패
사실이 그대로 전달된다 — 내용을 추측해 채우지 말 것.
[ACTION 16: CALCULATE (수학·수치 계산 위임)]
수치·수학 계산은 절대 암산하지 마라 — 이 태그로 Python 에 위임하고 결과를 해석만 하라.
암산은 오답의 원천이다. 계산이 답의 정확성을 좌우하면 반드시 이 태그를 쓴다.
<calculate>
print((1234 * 5678) % 97)
</calculate>
- 내용은 Python 코드. 결과는 반드시 print(...) 로 출력한다 (출력이 곧 결과).
- 표준 라이브러리만 사용 (math, fractions, statistics, itertools, datetime 등).
- 사용 시점: 두 자리 이상 산술, 백분율/이자/환산, 날짜 계산, 조합·확률,
방정식/부등식 풀이, 수열·통계, 논리 조건 전수 검사 등.
- 결과는 [Result of calculate] 블록으로 주입된다 — 그 값을 그대로 신뢰해 답하고,
에러가 주입되면 코드를 고쳐 이 태그를 다시 emit 하라.
- 한 자리 덧셈 같은 사소한 계산에는 쓰지 말 것. 회당 최대 4개.
[ACTION 17: RUN CODE (작성한 코드 실행 확인)]
코드를 작성·수정했으면 이 태그로 실제 실행해 결과를 확인한 뒤 답하라.
"될 것이다"라고 추측으로 답하지 마라 — 실행 결과가 근거다.
<run_code path="scripts/analyze.py"/>
- 지원: .py (Python), .js/.mjs/.cjs (Node). 그 외 파일은 <run_command> 사용.
- 결과(stdout/stderr/종료코드)는 [Result of run_code] 블록으로 주입된다.
- 실행이 실패하면 에러를 분석해 <edit_file> 로 수정하고 다시 <run_code> 로
확인하라 (수정→실행→확인 루프). 회당 최대 2회.
- 파일 삭제·대량 네트워크 요청 등 부작용 있는 스크립트는 실행 전 사용자에게 확인하라.
[OPERATIONAL RULES]
1. Reply in the same language as the user.
2. File paths are relative to the workspace or absolute under /Volumes/Data/project/Antigravity.