diff --git a/.astra/project-context/architecture.md b/.astra/project-context/architecture.md index 0f51257..0496175 100644 --- a/.astra/project-context/architecture.md +++ b/.astra/project-context/architecture.md @@ -1,30 +1,308 @@ # ConnectAI — Project Architecture Context -## Project Name -ConnectAI -## Project Root -/Volumes/Data/project/Antigravity/ConnectAI +## Snapshot +- **Workspace**: `ConnectAI` `v2.0.2` _(absolute path varies by environment; resolved from the active VS Code workspace)_ +- **Description**: The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making. +- **Stack**: TypeScript, Node.js, VS Code Extension, LM Studio SDK, Test runner +- **Stats**: 184 source files, ~31,651 lines across 5 top-level modules. -## Description -The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making. +## Last Refresh +- **Time**: 2026-05-13T13:48:21.458Z +- **Files newly analysed**: 3 +- **Files reused from cache**: 181 -## Runtime / Stack -TypeScript, Node.js, VS Code Extension, LM Studio SDK +## Directory Map +```mermaid +mindmap + root((ConnectAI)) + src/ + core/ + features/ + memory/ + retrieval/ + docs/ + lib/ + media/ + tests/ + mocks/ + core_py/ + docs/ + records/ + docs/ +``` -## Main Modules -- `src/` — Source code (7 files — agents, core, docs, features, integrations, lib, +8 more) -- `media/` — Webview assets (HTML/CSS/JS) (6 files) -- `core_py/` — Python utilities (7 files) -- `tests/` — Test suite (26 files — mocks) +## Module Dependencies +> Arrows: which top-level module imports from which. +```mermaid +flowchart LR + src["src/
85 files"] + media["media/
6 files"] + tests["tests/
27 files"] + core_py["core_py/
6 files"] + docs["docs/
60 files"] + tests --> src +``` -## Important Files -- `package.json` -- `tsconfig.json` -- `README.md` +## Entry Points +> Files to read first when learning the codebase. +- `src/extension.ts` +- `media/sidebar.html` — Astra +- `package.json` — npm package manifest -_Last auto-scan: 2026-05-13T13:33:48.141Z_ +## Hub Files +> Imported by many other files — touching these has wide blast radius. +- `src/utils.ts` — referenced by **37** files +- `src/config.ts` — referenced by **11** files +- `src/lib/paths.ts` — referenced by **10** files +- `src/lib/engine.ts` — referenced by **6** files +- `src/sidebarProvider.ts` — referenced by **6** files +- `src/retrieval/scoring.ts` — referenced by **6** files · Scoring Engine — TF-IDF + Bilingual Tokenizer 단순 includes() 키워드 매칭을 넘어서, TF-IDF 가중치 기반의 문서 스코어링을 제공합니다. 한국어/영어 양국어 토크나이저를 포함합니다. +- `src/memory/types.ts` — referenced by **6** files · Memory Type Definitions (메모리 타입 정의) Astra의 5-Layer Cognitive Memory System의 모든 타입을 정의합니다. ① Short-Term ② Long-Term ③ Project ④ Procedural ⑤ Episodic +- `src/retrieval/lessonHelpers.ts` — referenced by **5** files · Lesson / Experience Memory — pure helpers (no vscode dependency) "Lesson" = a markdown file in the active brain that captures a past mistake/risk and how to avoid repeating it. Identified by a lessons + +## Modules + +### `src/` — 85 files, ~20,591 lines + +**Sub-directories** +- `src/core/` (15) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토 +- `src/features/` (14) — Project Architecture Context (Feature 2) Builds a markdown document that captures the durable facts about a project — it +- `src/memory/` (8) — Episodic Memory (일화 기억) 과거 대화/회의/결정의 맥락 흐름을 저장합니다. 세션 종료 시 자동으로 에피소드를 요약하여 저장합니다. "왜 이렇게 결정했는지", "어떤 흐름으로 진행했는지" 기록. 저장 +- `src/retrieval/` (8) — Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-I +- `src/docs/` (6) — src Chronicle Records +- `src/lib/` (6) — Context Manager (컨텍스트 한계 관리) "context length = 132k" 는 "답변을 132k 토큰까지 생성해도 된다" 가 아닙니다. 시스템 프롬프트 + 대화 기록 + 입력 문서 + 생성될 답변 +- `src/lmstudio/` (4) — 4 files (.ts) +- `src/sidebar/` (4) — 4 files (.ts) +- `src/skills/` (4) — 4 files (.ts) +- `src/integrations/` (3) — Subset of the Telegram Bot API types we actually consume. Source: https://core.telegram.org/bots/api Only fields the bot +- `src/agents/` (2) — 2 files (.ts) +- `src/scaffolder/` (2) — Scaffolder template catalog. Templates are pure data — (projectName) => { [relativePath]: contents }. New templates are + +**Key files** +- `src/utils.ts` (268 lines) +- `src/config.ts` (209 lines) +- `src/lib/paths.ts` (151 lines) +- `src/sidebarProvider.ts` (2603 lines) +- `src/memory/types.ts` (126 lines) — Memory Type Definitions (메모리 타입 정의) Astra의 5-Layer Cognitive Memory System의 모든 타입을 정의합니다. ① Short-Term ② Long-Term ③ Project ④ Procedural ⑤ Episodic +- `src/retrieval/scoring.ts` (518 lines) — Scoring Engine — TF-IDF + Bilingual Tokenizer 단순 includes() 키워드 매칭을 넘어서, TF-IDF 가중치 기반의 문서 스코어링을 제공합니다. 한국어/영어 양국어 토크나이저를 포함합니다. +- `src/skills/agentKnowledgeMap.ts` (374 lines) +- `src/agent.ts` (3207 lines) +- `src/retrieval/lessonHelpers.ts` (325 lines) — Lesson / Experience Memory — pure helpers (no vscode dependency) "Lesson" = a markdown file in the active brain that captures a past mistake/risk and how to avoid repeating it. Identified by a lessons +- `src/lib/engine.ts` (849 lines) +- `src/features/approval/approvalQueue.ts` (129 lines) +- `src/features/projectArchitecture/scanner.ts` (644 lines) — Deep static analyser for the Project Architecture Context generator. Walks the project tree (skipping the usual nodemodules / out / dist noise), pulls the role of each interesting file from its leadin +- `src/lib/contextManager.ts` (275 lines) — Context Manager (컨텍스트 한계 관리) "context length = 132k" 는 "답변을 132k 토큰까지 생성해도 된다" 가 아닙니다. 시스템 프롬프트 + 대화 기록 + 입력 문서 + 생성될 답변 + 여유분 ≤ context length 이 모듈은 요청을 보내기 전에 입력 토큰을 추정하고, - 동적으로 출력 상한(maxTokens)을 계 +- `src/core/astraPath.ts` (50 lines) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토리를 해결하여, 사용자 프로젝트 루트가 아닌 ConnectAI 패키지 내부에 데이터를 저장합니다. 이 모듈은 AAL(Astra Autonomou +- `src/features/projectChronicle/types.ts` (118 lines) +- `src/integrations/telegram/telegramClient.ts` (154 lines) +- `src/lmstudio/client.ts` (147 lines) +- `src/retrieval/brainIndex.ts` (325 lines) — Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-IDF 점수를 계산했습니다 — 파일 수가 많아지면 그게 병목입니다. 이 모듈은 /.astra/brain-index.json 에 +- `src/extension.ts` (757 lines) +- `src/features/projectArchitecture/index.ts` (515 lines) — Project Architecture Context (Feature 2) Builds a markdown document that captures the durable facts about a project — its purpose, modules, key files, constraints, decisions — so Astra can attach it t +- `src/lmstudio/activityTracker.ts` (19 lines) +- `src/memory/EpisodicMemory.ts` (278 lines) — Episodic Memory (일화 기억) 과거 대화/회의/결정의 맥락 흐름을 저장합니다. 세션 종료 시 자동으로 에피소드를 요약하여 저장합니다. "왜 이렇게 결정했는지", "어떤 흐름으로 진행했는지" 기록. 저장 위치: {brainPath}/memory/episodes/.json +- `src/memory/LongTermMemory.ts` (243 lines) — Long-Term Memory (장기 기억) 사용자의 취향, 프로젝트 목표, 반복 규칙, 과거 결정 사항을 영구적으로 저장하고 관리합니다. 저장 위치: {brainPath}/memory/longterm.json +- `src/memory/ProjectMemory.ts` (212 lines) — Project Memory (프로젝트 기억) 프로젝트별 요구사항, 코드 구조, 아키텍처 결정, 버그 기록 등을 Astra 확장 프로그램 내부에 저장하고 관리합니다. 저장 위치: {ConnectAI}/.astra/projectmemory.json (기존: {projectRoot}/.astra/ → 변경됨) +- `src/retrieval/index.ts` (514 lines) — RetrievalOrchestrator — Unified RAG Pipeline Astra의 모든 검색 소스를 통합 관리하는 오케스트레이터입니다. 검색 흐름: ① Query Planning — 의도 분류 + 검색 전략 결정 ② Parallel Search — Brain + Memory + Project + Episode 동시 검색 ③ Result Fusio + +### `media/` — 6 files, ~3,304 lines + +**Key files** +- `media/sidebar.css` (987 lines) — Stylesheet +- `media/sidebar.js` (1388 lines) +- `media/settings-panel.css` (210 lines) — Stylesheet +- `media/sidebar.html` (285 lines) — Astra +- `media/settings-panel.html` (164 lines) — Astra Settings +- `media/settings-panel.js` (270 lines) + +### `tests/` — 27 files, ~4,802 lines +*Depends on*: `src/` + +**Sub-directories** +- `tests/mocks/` (1) — 1 files (.js) + +**Key files** +- `tests/agentEngine.test.ts` (646 lines) — AgentEngine Integration Tests & Performance Benchmarks 검증 대상: 1. ErrorClassifier — 오류 유형(Transient/Permanent/Abort) 자동 분류 2. ErrorRecoveryMatrix — 각 규칙이 의도한 대응 전략으로 매핑되는지 검증 3. resilientExecute — 지수 백 +- `tests/lmStudioLifecycle.test.ts` (318 lines) — Unit tests for ModelLifecycleManager. Strategy: inject mock ILMStudioClient and a simple in-memory IActivityTracker. No real LM Studio or SDK is touched — the manager file does not import the SDK dire +- `tests/telegramBot.test.ts` (363 lines) — Unit tests for TelegramBot + truncateForTelegram. Strategy: - TelegramBot is driven by an injected ITelegramClient stub. We script getUpdates to return queued batches and assert that: - the offset cur +- `tests/lmStudioStreamer.test.ts` (220 lines) — Unit tests for LMStudioStreamer. Strategy: inject a fake ILMStudioClient that returns a fake model handle whose respond() yields a controllable async iterable. No real SDK or WebSocket touched. +- `tests/localPathPreflight.test.ts` (490 lines) +- `tests/secondBrainTrace.test.ts` (407 lines) +- `tests/approvalQueue.test.ts` (164 lines) — Unit tests for ApprovalQueue. Strategy: drive enqueue → approve / reject / clear / pre-empt directly, confirm the onChange event fires at the right moments and callbacks fire exactly once. +- `tests/projectScaffolder.test.ts` (135 lines) — Unit tests for FileSystemProjectScaffolder. Drives against a real temp directory so end-to-end file IO + path-traversal defenses are exercised. +- `tests/resilience_stress.test.ts` (183 lines) — Resilience & Boundary Stress Test Suite (v2.77.3) 이 테스트는 ConnectAI 엔진이 극한의 환경(인증 실패, 네트워크 차단, 타임아웃 등)에서 얼마나 안정적으로 복구되고, 신뢰성 지표(Resilience Metrics)를 정확히 기록하는지 검증합니다. +- `tests/skillInjectionService.test.ts` (172 lines) — Unit tests for FileSystemSkillInjectionService. Strategy: drive the service against a real temp directory so path-traversal defenses and writeFileSync paths are exercised end-to-end. The service accep +- `tests/dataProcessor.test.ts` (87 lines) — / +- `tests/findBrainFilesCache.test.ts` (80 lines) — Unit tests for findBrainFiles TTL cache. +- `tests/paths.test.ts` (84 lines) — Unit tests for the centralized path resolver. +- `tests/systemSpecs.test.ts` (90 lines) — Unit tests for SystemSpecs + HeuristicModelMemoryEstimator. Strategy: - HeuristicModelMemoryEstimator is pure — directly drive it with model ids. - NodeSystemSpecsProvider depends on os. so we test: a +- `tests/transaction.test.ts` (68 lines) — / +- `tests/vulnerability.test.ts` (60 lines) — / +- `tests/brainIndex.test.ts` (107 lines) +- `tests/contextManager.test.ts` (129 lines) +- `tests/lessonHelpers.test.ts` (191 lines) +- `tests/projectChronicle.test.ts` (199 lines) +- `tests/responseRecovery.test.ts` (151 lines) +- `tests/scoring.test.ts` (134 lines) +- `tests/integration_retrieval.test.ts` (91 lines) +- `tests/mocks/vscode.js` (68 lines) +- `tests/projectChronicleGuardPrompt.test.ts` (52 lines) + +### `core_py/` — 6 files, ~409 lines + +**Key files** +- `core_py/events.py` (64 lines) +- `core_py/inference.py` (91 lines) +- `core_py/loader.py` (61 lines) +- `core_py/monitoring.py` (56 lines) +- `core_py/optimizer.py` (55 lines) +- `core_py/queue_worker.py` (82 lines) + +### `docs/` — 60 files, ~2,545 lines + +**Sub-directories** +- `docs/records/` (48) — Astra Project Chronicle Records +- `docs/docs/` (5) — docs Chronicle Records + +**Key files** +- `docs/TELEGRAM_REMOTE_EXECUTION_PLAN.md` (452 lines) — Telegram Remote Execution 기획서 +- `docs/AgentEngine_Architecture.md` (314 lines) — AgentEngine Architecture Document +- `docs/EXPERIENCE_MEMORY_PLAN.md` (122 lines) — Experience Memory (Mistake / Lesson Loop) — Implementation Plan +- `docs/records/ConnectAI/development/2026-05-02_connectai_project_knowledge_overview.md` (121 lines) — Astra Project Knowledge Overview +- `docs/records/ConnectAI/development/2026-05-03_connectai_project_knowledge_overview.md` (121 lines) — Astra Project Knowledge Overview +- `docs/records/ConnectAI/timeline.md` (116 lines) — Project Timeline +- `docs/Advanced_Features_Implementation_Guide.md` (40 lines) — Advanced Features Implementation Guide +- `docs/PROJECT_CHRONICLE_GUARD_ROADMAP.md` (43 lines) — Project Chronicle Guard: Search Engine Roadmap +- `docs/UX_UI_Consistency_Guidelines.md` (44 lines) — UX/UI Consistency Guidelines +- `docs/docs/records/docs/README.md` (18 lines) — docs Chronicle Records +- `docs/docs/records/docs/bugs/BUG-0001-viewed-integration-retrieval-test-ts-1-59-integration-retrie.md` (16 lines) — Bug: Viewed integrationretrieval.test.ts:1-59 integrationretrieval.test.ts를 통해 ... +- `docs/docs/records/docs/chronicle.config.json` (11 lines) — JSON configuration +- `docs/docs/records/docs/project-profile.md` (31 lines) — Project Profile +- `docs/docs/records/docs/timeline.md` (7 lines) — Project Timeline +- `docs/records/ConnectAI/README.md` (18 lines) — Astra Project Chronicle Records +- `docs/records/ConnectAI/bugs/BUG-0001-volumes-data-project-antigravity-connectai-프로젝트-코드-리뷰-해줄-수-있.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 프로젝트 코드 리뷰 해줄 수 있어? 개선할 부분이 있는지, 그러고... +- `docs/records/ConnectAI/bugs/BUG-0002-지금-내가-분석-요청하고-너가-답을-줄때-아래-템플릿에-맞춰-답을-써주고-있는데-개선-포인트가-있는지-확인해.md` (16 lines) — Bug: 지금 내가 분석 요청하고 너가 답을 줄때 아래 템플릿에 맞춰 답을 써주고 있는데, 개선 포인트가 있는지 확인해줘. ## 내가 보는 위험 가장 큰... +- `docs/records/ConnectAI/bugs/BUG-0003-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused... +- `docs/records/ConnectAI/bugs/BUG-0004-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused... +- `docs/records/ConnectAI/bugs/BUG-0005-다시한번-답줘-volumes-data-project-antigravity-connectai-내-질문에-대한-.md` (16 lines) — Bug: 다시한번 답줘. /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는... +- `docs/records/ConnectAI/bugs/BUG-0006-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused... +- `docs/records/ConnectAI/bugs/BUG-0007-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused... +- `docs/records/ConnectAI/bugs/BUG-0008-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused... +- `docs/records/ConnectAI/bugs/BUG-0009-문제점을-읽고-어떻게-개선하는게-최선인지-분석해주면-좋겠어-알겠습니다-지금부터-connectai-프로젝트-에.md` (16 lines) — Bug: 문제점을 읽고 어떻게 개선하는게 최선인지 분석해주면 좋겠어. 알겠습니다. 지금부터 ConnectAI 프로젝트에만 완전히 집중하겠습니다. ... +- `docs/records/ConnectAI/bugs/BUG-0010-문제점을-읽고-어떻게-개선하는게-최선인지-분석해주면-좋겠어-알겠습니다-지금부터-connectai-프로젝트-에.md` (16 lines) — Bug: 문제점을 읽고 어떻게 개선하는게 최선인지 분석해주면 좋겠어. 알겠습니다. 지금부터 ConnectAI 프로젝트에만 완전히 집중하겠습니다. ... + +## VS Code Extension Surface +- **Extension ID**: `g1nation.astra` +- **Activation events**: `onStartupFinished` +- **Commands** (19): + - `g1nation.newChat` — Astra: New Chat + - `g1nation.exportChat` — Astra: Export Chat as Markdown + - `g1nation.explainSelection` — Astra: Explain Selected Code + - `g1nation.focusChat` — Astra: Focus Chat Input + - `g1nation.showBrainNetwork` — Astra: Show Brain Topology + - `g1nation.approval.focus` — Astra: Focus Approval Panel + - `g1nation.scaffoldProject` — Astra: Scaffold New Project + - `g1nation.telegram.setBotToken` — Astra: Set Telegram Bot Token + - `g1nation.telegram.clearBotToken` — Astra: Clear Telegram Bot Token + - `g1nation.telegram.testConnection` — Astra: Test Telegram Connection + - `g1nation.settings.focus` — Astra: Open Settings Panel + - `g1nation.skills.editKnowledgeMap` — Astra: Edit Agent ↔ Knowledge Map + - `g1nation.openChat` — Astra: Open Chat (Editor Column) + - `g1nation.lesson.create` — Astra: New Lesson (Experience Memory) + - `g1nation.lesson.fromConversation` — Astra: New Lesson from Current Conversation + - `g1nation.lesson.manage` — Astra: Browse / Manage Lessons + - `g1nation.architecture.refresh` — Astra: Refresh Project Architecture Context + - `g1nation.architecture.detach` — Astra: Detach Project Architecture Context + - `g1nation.architecture.open` — Astra: Open Project Architecture Doc +- **Configuration** (38 settings): + - `g1nation.multiAgentEnabled` *(boolean)* _(default: `false`)_ — Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks. + - `g1nation.memoryEnabled` *(boolean)* _(default: `true`)_ — Enable layered memory injection before each model response. + - `g1nation.memoryShortTermMessages` *(number)* _(default: `8`)_ — Number of recent conversation messages included as short-term memory. + - `g1nation.memoryMediumTermSessions` *(number)* _(default: `5`)_ — Number of recent saved chat sessions included as medium-term memory. + - `g1nation.memoryLongTermFiles` *(number)* _(default: `6`)_ — Number of relevant Second Brain markdown files included as long-term memory. + - `g1nation.ollamaUrl` *(string)* _(default: `"http://127.0.0.1:11434"`)_ — Base URL for Ollama or LM Studio. Default: http://127.0.0.1:11434 + - `g1nation.defaultModel` *(string)* _(default: `"gemma4:e2b"`)_ — Default model name to use for chat requests. + - `g1nation.requestTimeout` *(number)* _(default: `300`)_ — Request timeout in seconds. Default: 300 + - `g1nation.contextLength` *(number)* _(default: `32768`)_ — Model context window in tokens (prompt + generation combined). Set this to the value your loaded model is actually running with in LM Studio / Ollama. Astra budgets prompt and output against this so i + - `g1nation.maxOutputTokens` *(number)* _(default: `4096`)_ — Upper bound on tokens generated per response. The effective limit is reduced automatically when the prompt is large so input + output stays within g1nation.contextLength. Default: 4096 + - `g1nation.contextSafetyMargin` *(number)* _(default: `2048`)_ — Tokens kept free as a safety buffer for token-count estimation error. Default: 2048 + - `g1nation.contextOverflowPolicy` *(string)* _(default: `"stopAtLimit"`)_ — Fallback behavior (LM Studio) if the prompt still exceeds the context window after Astra's own budgeting. 'stopAtLimit' fails clearly so you notice; 'truncateMiddle'/'rollingWindow' drop content silen + - `g1nation.autoCompactHistory` *(boolean)* _(default: `true`)_ — Automatically drop the oldest conversation messages from the request when the prompt would exceed the context budget (the on-screen chat history is unaffected). Default: true + - `g1nation.smallModelContextCap` *(number)* _(default: `0`)_ — Optional safety knob, OFF by default (0). Some very small models (≤3B) emit an empty/EOS response when given a prompt near their context window even though it nominally fits. If you observe that with + - `g1nation.autoContinueOnOutputLimit` *(boolean)* _(default: `true`)_ — When a reply is cut off because it hit the output-token limit, Astra continues it internally (compressed request — original question + the answer so far, not the whole context again) and shows one mer + - `g1nation.maxAutoContinuations` *(number)* _(default: `4`)_ — Maximum number of automatic continuation rounds per reply (prevents runaway loops). Raise it (e.g. 5–6) for long-form answers on slow local models; set 0 to disable auto-continuation. Default: 4 + - `g1nation.finalOnlyRetryOnThoughtLeak` *(boolean)* _(default: `true`)_ — If the model emits only hidden reasoning (, <|channel|>thought, "Thinking Process:" …) and no user-visible answer, Astra silently re-asks it for the final answer only. Hidden reasoning is never + - `g1nation.lmStudio.idleTimeoutMs` *(number)* _(default: `300000`)_ — Auto-eject the loaded LM Studio model after this many milliseconds of inactivity. Set to 0 to disable. Default: 300000 (5 minutes). + - `g1nation.lmStudio.autoLoadOnSelect` *(boolean)* _(default: `true`)_ — Automatically load LM Studio models into memory when selected from the Astra sidebar. + - `g1nation.localBrainPath` *(string)* _(default: `""`)_ — Folder path for your local Second Brain knowledge base. Leave empty to use the default folder. + - `g1nation.brainProfiles` *(array)* _(default: `[]`)_ — Multiple brain profiles. Each item supports id, name, localBrainPath, secondBrainRepo, and description. + - `g1nation.activeBrainId` *(string)* _(default: `""`)_ — Active brain profile id used for the current chat context. + - `g1nation.secondBrainRepo` *(string)* _(default: `""`)_ — Optional GitHub repository URL used for Second Brain sync. + - `g1nation.autoPushBrain` *(boolean)* _(default: `false`)_ — Automatically commit and push Second Brain changes after updates. + - `g1nation.maxContextSize` *(number)* _(default: `32000`)_ — Maximum character count for active file context. Default: 32000 + - `g1nation.maxAutoSteps` *(number)* _(default: `50`)_ — Maximum autonomous steps the agent can take per request. Default: 50 + - `g1nation.dryRun` *(boolean)* _(default: `false`)_ — If enabled, the agent will ask for approval before committing any file changes. + - `g1nation.telegram.enabled` *(boolean)* _(default: `false`)_ — Enable the Telegram bot integration. When on, Astra polls a bot you configure and replies to incoming messages. Off by default — Astra remains 100% local until you opt in. + - `g1nation.telegram.allowedChatIds` *(array)* _(default: `[]`)_ — Optional allowlist of Telegram chat IDs that may message the bot. When empty, every chat that messages the bot is accepted (use with caution). + - `g1nation.telegram.defaultAgent` *(string)* _(default: `""`)_ — Agent name (matches an entry in the Agent ↔ Knowledge map) used to scope Second Brain retrieval for Telegram replies. Empty falls back to the map's defaultAgent, then to whole-brain search. + - `g1nation.telegram.agentByChatId` *(object)* _(default: `{}`)_ — Per-chat override of the Telegram agent. Keys are stringified chat IDs, values are agent names from the knowledge map. Overrides telegram.defaultAgent for the listed chats. + - `g1nation.telegram.contextChunks` *(number)* _(default: `6`)_ — How many Second Brain excerpts to inject into Telegram replies. Set 0 to disable RAG (plain prompt only). + - `g1nation.skillKnowledgeMapPath` *(string)* _(default: `""`)_ — Absolute path to the agent ↔ knowledge mapping JSON. When empty, defaults to '/.astra/agent-knowledge-map.json'. + - `g1nation.skillKnowledgeMap` *(object)* _(default: `{}`)_ — Inline fallback for the agent ↔ knowledge mapping. Used only when the JSON file is missing. Shape: { defaultAgent?, agents: [{ name, knowledgeFolders, model?, description? }] }. Folder paths can be ab + - `g1nation.agentSkillsPath` *(string)* _(default: `""`)_ — Absolute path to the agent skills folder (`.agent/skills/*.md`). When empty, defaults to '/.agent/skills'. Use this on Windows or when your skills live outside the workspace. + - `g1nation.embeddingModel` *(string)* _(default: `""`)_ — Embedding model registered in LM Studio / Ollama (e.g. 'text-embedding-bge-small-en-v1.5', 'nomic-embed-text', 'multilingual-e5-small'). When empty, Astra uses TF-IDF only. When set, the brain is embe + - `g1nation.embeddingBlendAlpha` *(number)* _(default: `0.5`)_ — Hybrid score blend: 0 = pure TF-IDF (sparse / keyword), 1 = pure embedding cosine (dense / semantic), 0.5 = balanced. Only used when g1nation.embeddingModel is set. Default 0.5. + - `g1nation.knowledgeMix.secondBrainWeight` *(number)* _(default: `50`)_ — Knowledge Mix (0–100): how heavily the assistant should lean on Second Brain evidence vs. its own general knowledge. 0 = Second Brain disabled (model knowledge only). 50 = balanced (legacy default). 1 + +## Dependencies +- **Runtime** (2): `@lmstudio/sdk`, `pdf-parse` +- **Dev** (8): `@types/jest`, `@types/node`, `@types/vscode`, `@vercel/ncc`, `esbuild`, `jest`, `ts-jest`, `typescript` + +## README Excerpt +> Pulled from the project root README — first ~2 KB. + +# Astra (by g1nation) + +Astra는 **Antigravity 및 VS Code** 환경에서 작동하는 대표님 전용 **지능형 운영 레이어(Personal Intelligence Layer)**입니다. 단순한 명령 수행을 넘어, 프로젝트의 맥락과 대표님의 의사결정 패턴을 학습하여 최적의 전략적 조언을 제공하는 독립적인 인지 파트너입니다. + +## 🌌 Antigravity & VS Code Unified Assistant + +Astra는 범용 AI와 달리 특정 플랫폼에 종속되지 않으며, Antigravity 워크스페이스의 깊은 맥락과 VS Code의 강력한 개발 도구를 하나로 연결합니다. + +### 1. 전용 지능형 판단 체계 (Personal Cognition Layer) +v4.0 운영 정책이 코어에 이식되어 데이터의 신뢰도를 대표님의 기준에 맞춰 스스로 평가합니다. 상충되는 정보 발견 시 즉각적인 **[CONFLICT WARNING]**을 통해 객관적인 판단 근거를 제시합니다. + +### 2. 고밀도 전략 지식망 (Strategic Knowledge Hub) +대표님의 Second Brain과 Antigravity 내의 모든 지식을 온톨로지 기반으로 구조화합니다. 비즈니스 전략, 기술 아키텍처, 리스크 관리가 하나로 통합된 지식 그래프를 통해 추론의 깊이를 보장합니다. + +### 3. 선제적 파트너십 (Proactive Partnership) +작업이 완료된 후, 대표님이 다음에 내려야 할 **전략적 의사결정 포크(Decision Forks)**를 선제적으로 제안합니다. 사용자의 명령을 기다리지 않고, 프로젝트의 흐름을 먼저 읽고 길을 제시합니다. + +## 🛠️ 주요 기능 및 권한 + +Astra는 대표님의 명시적인 승인 하에 로컬 시스템의 강력한 제어 권한을 행사하여 생산성을 극대화합니다. + +| 작업 범주 | 설명 | +| :--- | :--- | +| **플랫폼 최적화** | Antigravity 워크스페이스와 VS Code 사이의 유기적인 맥락 전환 및 동기화를 지원합니다. | +| **자율 워크플로우** | 다중 에이전트 협업을 통해 복잡한 비즈니스 요구사항을 즉시 실행 가능한 단계별 계획으로 분해합니다. | +| **지식 자산화** | 흩어진 정보들을 P-Reinforce v3.0 표준에 맞게 위키화하여 영구적인 지식 자산으로 전환합니다. | +| **보안 및 프라이버시** | 100% 로컬 환경에서 작동하여 대표님의 소중한 데이터가 외부로 유출되지 않음을 보장합니다. | + +## 🚀 설치 및 시작하기 + +### 패키지 설치 +1. **g1nation**에서 배포된 최신 **v2.65.0** VSIX 파일을 확보합니다. +2. VS Code 명령 팔레트(`Cmd+Shift+P`)에서 **Extensions: Install from VSIX**를 선택하여 설치합니다. +3. Antigravity 환경과 연동하여 나만의 지능형 레이어를 활성화합니다. + +--- +**Designed for High-Performance Decision Making.** +Copyright (C) **g1nation**. All rights reserved. + +_Last auto-scan: 2026-05-13T13:48:21.458Z · signature `fefc8c65`_ ## Purpose diff --git a/.astra/project-context/scan-cache.json b/.astra/project-context/scan-cache.json index 439304e..70da71d 100644 --- a/.astra/project-context/scan-cache.json +++ b/.astra/project-context/scan-cache.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-05-13T13:32:45.332Z", + "generatedAt": "2026-05-13T13:48:21.464Z", "files": { "src/agent.ts": { "mtimeMs": 1778677012000, @@ -1343,7 +1343,7 @@ "imports": [] }, "docs/records/ConnectAI/chronicle.config.json": { - "mtimeMs": 1778678912000, + "mtimeMs": 1778680095000, "size": 416, "lines": 11, "role": "JSON configuration", @@ -1545,6 +1545,13 @@ "role": "Development Log: 너는 분석 요청하거나 내가 작업 요청을 할때 connectai architecture.md 문서를 참고하고 작업을 하나?", "imports": [] }, + "docs/records/ConnectAI/discussions/2026-05-13_volumes-data-project-antigravity-connectai-이-프로젝트-작업할거야.md": { + "mtimeMs": 1778680095000, + "size": 651, + "lines": 16, + "role": "Discussion: /Volumes/Data/project/Antigravity/ConnectAI 이 프로젝트 작업할거야", + "imports": [] + }, "docs/records/ConnectAI/discussions/2026-05-13_volumes-data-project-antigravity-connectai-이-프로젝트를-작업할거야.md": { "mtimeMs": 1778677791000, "size": 719, @@ -1581,9 +1588,9 @@ "imports": [] }, "docs/records/ConnectAI/timeline.md": { - "mtimeMs": 1778678912000, - "size": 7720, - "lines": 113, + "mtimeMs": 1778680095000, + "size": 7871, + "lines": 116, "role": "Project Timeline", "imports": [] }, diff --git a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json index 62f729f..43c00fb 100644 --- a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json +++ b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1778679248269, + "createdAt": 1778682078361, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json b/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json index 11e8038..9b5c615 100644 --- a/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json +++ b/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json @@ -1,5 +1,5 @@ { "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", - "createdAt": 1778679248269, + "createdAt": 1778682078352, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json b/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json index dea30f3..d737ebe 100644 --- a/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json +++ b/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json @@ -1,5 +1,5 @@ { "result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.", - "createdAt": 1778679248268, + "createdAt": 1778682078348, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json b/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json index 5a78f02..8a88815 100644 --- a/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json +++ b/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json @@ -1,5 +1,5 @@ { - "result": "---\nid: stress_conflict_1778679248257\ndate: 2026-05-13T13:34:08.269Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (11ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (0ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (1ms)\n", - "createdAt": 1778679248270, + "result": "---\nid: stress_conflict_1778682078332\ndate: 2026-05-13T14:21:18.365Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (12ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (4ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (8ms)\n", + "createdAt": 1778682078365, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1778679248257.json b/.astra/tests/stress/.astra/missions/stress_conflict_1778682078332.json similarity index 78% rename from .astra/tests/stress/.astra/missions/stress_conflict_1778679248257.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1778682078332.json index 3d5a2d9..9bb1590 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1778679248257.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1778682078332.json @@ -1,8 +1,8 @@ { - "missionId": "stress_conflict_1778679248257", + "missionId": "stress_conflict_1778682078332", "status": "completed", - "startTime": "2026-05-13T13:34:08.257Z", - "totalElapsedMs": 13, + "startTime": "2026-05-13T14:21:18.332Z", + "totalElapsedMs": 33, "results": { "planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.", "researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", @@ -16,30 +16,30 @@ { "from": "idle", "to": "planner", - "durationMs": 11, + "durationMs": 12, "message": "전략 수립 중...", - "ts": "2026-05-13T13:34:08.268Z" + "ts": "2026-05-13T14:21:18.344Z" }, { "from": "planner", "to": "researcher", - "durationMs": 0, + "durationMs": 4, "message": "핵심 정보 수집 및 분석 중...", - "ts": "2026-05-13T13:34:08.268Z" + "ts": "2026-05-13T14:21:18.348Z" }, { "from": "researcher", "to": "writer", - "durationMs": 1, + "durationMs": 8, "message": "최종 리포트 작성 및 편집 중...", - "ts": "2026-05-13T13:34:08.269Z" + "ts": "2026-05-13T14:21:18.356Z" }, { "from": "writer", "to": "completed", - "durationMs": 1, + "durationMs": 9, "message": "미션 완료", - "ts": "2026-05-13T13:34:08.270Z" + "ts": "2026-05-13T14:21:18.365Z" } ], "resilienceMetrics": { diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 57484e7..a562ed6 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,15 @@ # Astra Patch Notes +## v2.0.3 (2026-05-13) +### 🏢 AI 1-Person Company Engine & Business Intelligence +- **AI 1인 기업(Company) 엔진 도입:** 비즈니스 전략 수립부터 자동화 실행까지 아우르는 `src/features/company/` 모듈을 신규 도입했습니다. +- **CEO 에이전트 워크플로우:** `ceoPlanner`와 `ceoReporter`를 통해 비즈니스 목표 설정과 결과 분석을 자율적으로 수행하는 지능형 워크플로우를 구축했습니다. +- **비즈니스 프롬프트 자산화:** 비즈니스 컨텍스트에 최적화된 프롬프트 관리 시스템(`promptAssets.ts`, `promptBuilder.ts`)을 통합했습니다. +- **사이드바 UI 및 인터랙션 최적화:** 비즈니스 에이전트와의 매끄러운 소통을 위해 사이드바 구성 요소와 대화 핸들러를 정교화했습니다. +- **신규 패키징:** `astra-2.0.3.vsix` 패키지를 통해 '지능형 비즈니스' 자동화 기능이 통합된 버전을 배포합니다. + +--- + ## v2.0.2 (2026-05-13) ### 🏛️ Structural Integrity & Automated Context Management - **프로젝트 컨텍스트 자동 관리:** `.astra/project-context/architecture.md`를 통해 프로젝트 구조, 스택, 주요 모듈 정보를 자동으로 스캔하고 관리하는 기능을 도입했습니다. diff --git a/docs/records/ConnectAI/chronicle.config.json b/docs/records/ConnectAI/chronicle.config.json index 623aba8..26df7bd 100644 --- a/docs/records/ConnectAI/chronicle.config.json +++ b/docs/records/ConnectAI/chronicle.config.json @@ -7,5 +7,5 @@ "corePurpose": "", "detailLevel": "standard", "createdAt": "2026-05-13T13:09:33.788Z", - "updatedAt": "2026-05-13T13:28:32.943Z" + "updatedAt": "2026-05-13T13:48:15.283Z" } diff --git a/docs/records/ConnectAI/discussions/2026-05-13_volumes-data-project-antigravity-connectai-이-프로젝트-작업할거야.md b/docs/records/ConnectAI/discussions/2026-05-13_volumes-data-project-antigravity-connectai-이-프로젝트-작업할거야.md new file mode 100644 index 0000000..774fd05 --- /dev/null +++ b/docs/records/ConnectAI/discussions/2026-05-13_volumes-data-project-antigravity-connectai-이-프로젝트-작업할거야.md @@ -0,0 +1,16 @@ +# Discussion: /Volumes/Data/project/Antigravity/ConnectAI 이 프로젝트 작업할거야 + +## User Request Summary +/Volumes/Data/project/Antigravity/ConnectAI 이 프로젝트 작업할거야 + +## Interpreted Intent +Capture a meaningful project discussion automatically instead of requiring manual record selection. + +## Questions +No explicit question was captured. + +## Main Discussion +- 네, `/Volumes/Data/project/Antigravity/ConnectAI` 프로젝트에 대해 작업하시려는군요. 어떤 부분부터 시작할까요? 구체적인 목표나 요청 사항을 알려주시면 바로 실행하겠습니다. + +## Decisions +No decisions captured yet. diff --git a/docs/records/ConnectAI/timeline.md b/docs/records/ConnectAI/timeline.md index b299e77..f63f7fd 100644 --- a/docs/records/ConnectAI/timeline.md +++ b/docs/records/ConnectAI/timeline.md @@ -111,3 +111,6 @@ ## 2026-05-13 - Auto development record created: development/2026-05-13_너는-분석-요청하거나-내가-작업-요청을-할때-connectai-architecture-md-문서를-참고하고-_implementation.md + +## 2026-05-13 +- Auto discussion record created: discussions/2026-05-13_volumes-data-project-antigravity-connectai-이-프로젝트-작업할거야.md diff --git a/media/sidebar.css b/media/sidebar.css index 737abe0..dd325fb 100644 --- a/media/sidebar.css +++ b/media/sidebar.css @@ -321,6 +321,95 @@ .input-footer { display: flex; align-items: center; justify-content: space-between; } .footer-left { display: flex; align-items: center; gap: 8px; } + /* Company chip — sits in the records-line beside the Records ▾ menu. */ + .company-chip { + display: inline-flex; align-items: center; gap: 5px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 999px; + padding: 3px 10px; + color: var(--text-dim); + font-size: 11px; font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + } + .company-chip:hover { border-color: var(--border-bright); color: var(--text-primary); } + .company-chip[data-active="true"] { + background: var(--accent-glow); + border-color: var(--accent); + color: var(--accent); + } + .company-chip-icon { font-size: 12px; } + .company-manage-btn { padding: 2px 6px; font-size: 11px; margin-left: 2px; } + .company-name-input { + flex: 1; background: var(--input-bg); border: 1px solid var(--border); + border-radius: 6px; padding: 6px 10px; color: var(--text-primary); font-size: 12px; + } + .company-name-input:focus { border-color: var(--accent); outline: none; } + + /* Agent cards inside the manage overlay. */ + .company-agent-list { display: flex; flex-direction: column; gap: 6px; padding: 0; } + .company-agent-card { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + list-style: none; + } + .company-agent-card[data-active="false"] { opacity: 0.55; } + .company-agent-card[data-locked="true"] .company-agent-toggle { cursor: not-allowed; opacity: 0.4; } + .company-agent-emoji { + font-size: 18px; flex-shrink: 0; + display: inline-flex; align-items: center; justify-content: center; + width: 28px; height: 28px; + border-radius: 6px; background: var(--bg-secondary); + } + .company-agent-body { flex: 1; min-width: 0; line-height: 1.35; } + .company-agent-name { + color: var(--text-bright); font-weight: 600; font-size: 12px; + display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; + } + .company-agent-role { color: var(--text-dim); font-size: 10px; } + .company-agent-tagline { + color: var(--text-primary); font-size: 10.5px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + margin-top: 1px; + } + .company-agent-controls { + display: flex; align-items: center; gap: 6px; flex-shrink: 0; + } + .company-agent-toggle { + background: transparent; border: 1px solid var(--border); + color: var(--text-dim); font-size: 10px; font-weight: 600; + padding: 3px 8px; border-radius: 999px; cursor: pointer; + } + .company-agent-card[data-active="true"] .company-agent-toggle { + border-color: var(--accent); color: var(--accent); + } + .company-agent-model { + background: var(--input-bg); border: 1px solid var(--border); + color: var(--text-primary); font-size: 10px; + padding: 3px 6px; border-radius: 6px; max-width: 130px; + } + + /* Per-phase company turn header in chat. */ + .company-phase-card { + border: 1px solid var(--border); + background: var(--surface); + border-radius: 8px; + padding: 8px 10px; + margin: 4px 0; + font-size: 11px; + color: var(--text-primary); + } + .company-phase-card .cph-head { + color: var(--text-bright); font-weight: 600; + display: flex; gap: 6px; align-items: center; margin-bottom: 4px; + } + .company-phase-card .cph-meta { color: var(--text-dim); font-size: 10px; } + .company-phase-card.report .cph-head { color: var(--accent); } + /* Project Architecture chip — sits just above the input when project mode is on. */ .arch-chip { display: none; diff --git a/media/sidebar.html b/media/sidebar.html index fc6e31c..f9a12ac 100644 --- a/media/sidebar.html +++ b/media/sidebar.html @@ -106,6 +106,16 @@ Auto Records + + +
@@ -118,6 +128,54 @@
+ +
+
+
+

🏢 1인 기업 모드

+

+ CEO가 사용자의 요청을 분석하고 활성화된 specialist에게 순차 dispatch합니다. + 동시에 메모리에 올라가는 모델은 항상 1개입니다. +

+
+ +
+ +
+
+
+
회사 정보
+
CEO와 보고서에 사용되는 회사명. 한국어/영어 모두 가능.
+
+
+
+ + +
+
+ +
+
+
+
활성 에이전트 + 모델
+
CEO는 항상 활성. 각 에이전트별로 모델을 따로 지정할 수 있습니다 — 다른 모델을 쓸 때만 LM Studio가 swap합니다.
+
+
+
    +
    + + +
    +
    +

    Chat History

    diff --git a/media/sidebar.js b/media/sidebar.js index 19f2ca7..c37635b 100644 --- a/media/sidebar.js +++ b/media/sidebar.js @@ -777,6 +777,25 @@ vscode.postMessage({ type: 'getKnowledgeScope', agentPath: msg.selected }); syncContextBar(); break; + case 'companyStatus': { + const v = msg.value || {}; + renderCompanyChip(!!v.enabled, v.summary || ''); + break; + } + case 'companyAgents': { + renderCompanyAgentCards(msg.value || {}); + break; + } + case 'openCompanyManageOverlay': { + // Triggered by the Command Palette `Manage 1인 기업 Agents`. + document.getElementById('companyOverlay')?.classList.add('visible'); + vscode.postMessage({ type: 'getCompanyAgents' }); + break; + } + case 'companyTurnUpdate': { + if (msg.value) renderCompanyPhase(msg.value); + break; + } case 'architectureStatus': { // Show / hide the chip + reflect current state. const chip = document.getElementById('archChip'); @@ -1350,6 +1369,7 @@ vscode.postMessage({ type: 'getChronicleRecords' }); vscode.postMessage({ type: 'getKnowledgeMix' }); vscode.postMessage({ type: 'getArchitectureStatus' }); + vscode.postMessage({ type: 'getCompanyStatus' }); vscode.postMessage({ type: 'ready' }); // ── Project Architecture chip buttons ───────────────────────────────── @@ -1360,6 +1380,180 @@ if (_archRefreshBtn) _archRefreshBtn.onclick = () => vscode.postMessage({ type: 'refreshArchitecture' }); if (_archDetachBtn) _archDetachBtn.onclick = () => vscode.postMessage({ type: 'detachArchitecture' }); + // ── 1인 기업 (Company) Mode chip + manage overlay ───────────────────── + // The chip itself toggles enabled/disabled. The ▾ button opens the + // manage overlay where the user picks active agents + per-agent + // model overrides. State round-trips through `companyStatus` / + // `companyAgents` messages so the webview and extension stay in sync. + const _companyChip = document.getElementById('companyChip'); + const _companyChipLabel = document.getElementById('companyChipLabel'); + const _companyManageBtn = document.getElementById('companyManageBtn'); + const _companyOverlay = document.getElementById('companyOverlay'); + const _closeCompanyBtns = [ + document.getElementById('closeCompanyOverlayBtn'), + document.getElementById('closeCompanyOverlayBtn2'), + ].filter(Boolean); + const _companyNameInput = document.getElementById('companyNameInput'); + const _saveCompanyNameBtn = document.getElementById('saveCompanyNameBtn'); + const _companyAgentList = document.getElementById('companyAgentList'); + const _companyStatusEl = document.getElementById('companyStatus'); + + const renderCompanyChip = (active, summary) => { + if (!_companyChip || !_companyChipLabel) return; + _companyChip.setAttribute('data-active', active ? 'true' : 'false'); + _companyChipLabel.textContent = active ? (summary || 'Company ON') : 'Company OFF'; + }; + + if (_companyChip) { + _companyChip.onclick = () => { + const isActive = _companyChip.getAttribute('data-active') === 'true'; + // Optimistic flip — backend echoes the canonical state back. + renderCompanyChip(!isActive, _companyChipLabel?.textContent || ''); + vscode.postMessage({ type: 'setCompanyEnabled', value: !isActive }); + }; + } + if (_companyManageBtn) { + _companyManageBtn.onclick = () => { + if (!_companyOverlay) return; + _companyOverlay.classList.add('visible'); + _companyStatusEl.textContent = '불러오는 중...'; + vscode.postMessage({ type: 'getCompanyAgents' }); + }; + } + for (const btn of _closeCompanyBtns) { + btn.onclick = () => _companyOverlay?.classList.remove('visible'); + } + if (_saveCompanyNameBtn && _companyNameInput) { + _saveCompanyNameBtn.onclick = () => { + vscode.postMessage({ type: 'setCompanyName', value: _companyNameInput.value }); + }; + } + + /** + * Render the agent cards in the manage overlay. Each card has a + * toggle (active on/off) and a model input (per-agent override). + * CEO is rendered but locked-on; clicking its toggle is a no-op. + */ + function renderCompanyAgentCards(payload) { + if (!_companyAgentList) return; + _companyAgentList.innerHTML = ''; + if (_companyNameInput && payload && typeof payload.companyName === 'string') { + _companyNameInput.value = payload.companyName; + } + const agents = (payload && Array.isArray(payload.agents)) ? payload.agents : []; + for (const a of agents) { + const li = document.createElement('li'); + li.className = 'company-agent-card'; + li.setAttribute('data-active', a.active ? 'true' : 'false'); + if (a.alwaysOn) li.setAttribute('data-locked', 'true'); + + const emoji = document.createElement('span'); + emoji.className = 'company-agent-emoji'; + emoji.textContent = a.emoji; + + const body = document.createElement('div'); + body.className = 'company-agent-body'; + const name = document.createElement('div'); + name.className = 'company-agent-name'; + name.innerHTML = `${escAttr(a.name)} ${escAttr(a.role)}`; + const tag = document.createElement('div'); + tag.className = 'company-agent-tagline'; + tag.textContent = a.tagline || ''; + tag.title = a.specialty || ''; + body.appendChild(name); + body.appendChild(tag); + + const controls = document.createElement('div'); + controls.className = 'company-agent-controls'; + + const modelInput = document.createElement('input'); + modelInput.type = 'text'; + modelInput.className = 'company-agent-model'; + modelInput.placeholder = 'default'; + modelInput.value = a.modelOverride || ''; + modelInput.title = '비워두면 글로벌 기본 모델 사용'; + modelInput.onchange = () => { + vscode.postMessage({ + type: 'setCompanyAgentModel', + agentId: a.id, + model: modelInput.value.trim(), + }); + }; + + const toggle = document.createElement('button'); + toggle.className = 'company-agent-toggle'; + toggle.textContent = a.active ? 'ON' : 'OFF'; + if (a.alwaysOn) { + toggle.disabled = true; + toggle.textContent = 'LOCKED'; + } else { + toggle.onclick = () => { + // Optimistic update + send the full new list so the + // backend has a single canonical replace operation. + const wantActive = !(li.getAttribute('data-active') === 'true'); + li.setAttribute('data-active', wantActive ? 'true' : 'false'); + toggle.textContent = wantActive ? 'ON' : 'OFF'; + const nextIds = Array.from(_companyAgentList.querySelectorAll('.company-agent-card')) + .filter(el => el.getAttribute('data-active') === 'true') + .map(el => el.dataset.agentId) + .filter(Boolean); + vscode.postMessage({ type: 'setCompanyActiveAgents', value: nextIds }); + }; + } + li.dataset.agentId = a.id; + controls.appendChild(modelInput); + controls.appendChild(toggle); + + li.appendChild(emoji); + li.appendChild(body); + li.appendChild(controls); + _companyAgentList.appendChild(li); + } + if (_companyStatusEl) _companyStatusEl.textContent = ''; + } + + /** + * Render one phase event from the dispatcher. The chat gets a + * card per phase so the user can follow progress in real time — + * "🧭 CEO 작업 분배 중..." → "📺 레오 작업 수행 중..." → final report. + */ + function renderCompanyPhase(ev) { + const chatEl = document.getElementById('chat'); + if (!chatEl) return; + const card = document.createElement('div'); + card.className = 'company-phase-card'; + if (ev.phase === 'plan-start') { + card.innerHTML = '
    🧭 CEO
    작업 분배 중…
    '; + } else if (ev.phase === 'plan-ready') { + const tasks = (ev.plan?.tasks || []).map((t, i) => `${i + 1}. ${escAttr(t.agent)} — ${escAttr(t.task)}`).join('
    '); + card.innerHTML = `
    🧭 CEO 브리프
    +
    ${escAttr(ev.plan?.brief || '(brief 없음)')}
    +
    ${tasks || '(no tasks — chat reply)'}
    `; + } else if (ev.phase === 'agent-start') { + card.innerHTML = `
    ${escAttr(ev.agentId)} 작업 수행 중…
    +
    ${escAttr(ev.task)} (${ev.index + 1}/${ev.total})
    `; + } else if (ev.phase === 'agent-done') { + const o = ev.output || {}; + const body = (o.response || '').slice(0, 4000); + card.innerHTML = `
    ${escAttr(ev.agentId)} 완료 ${(o.durationMs/1000).toFixed(1)}s${o.error ? ' · ⚠️ ' + escAttr(o.error) : ''}
    +
    ${fmt(body)}
    `; + } else if (ev.phase === 'report-start') { + card.innerHTML = '
    🧭 CEO 종합 보고서 작성 중…
    '; + } else if (ev.phase === 'report-done') { + card.className += ' report'; + card.innerHTML = `
    🧭 CEO 보고서${ev.ok ? '' : ' (fallback)'}
    +
    ${fmt(ev.report || '')}
    `; + } else if (ev.phase === 'session-saved') { + card.innerHTML = `
    세션 저장 완료 — 클릭하여 열기
    `; + card.style.cursor = 'pointer'; + card.onclick = () => vscode.postMessage({ type: 'openCompanySession', sessionDir: ev.sessionDir }); + } else if (ev.phase === 'aborted') { + card.innerHTML = `
    ⛔ 회사 모드 중단
    ${escAttr(ev.reason)}
    `; + } + chatEl.appendChild(card); + chatEl.scrollTop = chatEl.scrollHeight; + } + // ── Knowledge Mix: global slider ────────────────────────────────────── // Mirrors `g1nation.knowledgeMix.secondBrainWeight`. The hint label updates // live as the user drags; the value is committed (postMessage) on `change` diff --git a/package.json b/package.json index ae43d92..0afb3c0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "astra", "displayName": "Astra", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", - "version": "2.0.2", + "version": "2.0.3", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", @@ -114,6 +114,18 @@ { "command": "g1nation.architecture.open", "title": "Astra: Open Project Architecture Doc" + }, + { + "command": "g1nation.company.toggle", + "title": "Astra: Toggle 1인 기업 Mode" + }, + { + "command": "g1nation.company.manage", + "title": "Astra: Manage 1인 기업 Agents" + }, + { + "command": "g1nation.company.openSessions", + "title": "Astra: Open 1인 기업 Sessions Folder" } ], "keybindings": [ diff --git a/src/extension.ts b/src/extension.ts index fba393e..e8b1c41 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -164,6 +164,7 @@ export async function activate(context: vscode.ExtensionContext) { logError('Failed to start bridge server.', err); } + // 5. Register Core Commands context.subscriptions.push( vscode.commands.registerCommand('g1nation.focusInput', () => { @@ -449,6 +450,35 @@ export async function activate(context: vscode.ExtensionContext) { if (!provider) return; await provider._openArchitectureDoc(); }), + // ── 1인 기업 (Company) Mode commands ────────────────────────────────── + // Thin shells over sidebar-provider methods so the runtime owns all + // state mutation (chip status, watcher lifecycle, agent persistence). + vscode.commands.registerCommand('g1nation.company.toggle', async () => { + if (!provider) return; + const { readCompanyState, setCompanyEnabled } = await import('./features/company'); + const cur = readCompanyState(context); + const next = await setCompanyEnabled(context, !cur.enabled); + await provider._sendCompanyStatus(); + vscode.window.showInformationMessage(`Astra: 1인 기업 모드 ${next.enabled ? 'ON' : 'OFF'}`); + }), + vscode.commands.registerCommand('g1nation.company.manage', async () => { + if (!provider) return; + // Reveal the sidebar then ask the webview to open the overlay. + await vscode.commands.executeCommand('g1nation-v2-view.focus'); + provider._view?.webview.postMessage({ type: 'openCompanyManageOverlay' }); + await provider._sendCompanyAgents(); + }), + vscode.commands.registerCommand('g1nation.company.openSessions', async () => { + const { resolveCompanyBase } = await import('./features/company'); + const base = resolveCompanyBase(context); + const target = path.join(base, 'sessions'); + try { + if (!fs.existsSync(target)) fs.mkdirSync(target, { recursive: true }); + await vscode.env.openExternal(vscode.Uri.file(target)); + } catch (e: any) { + vscode.window.showErrorMessage(`Sessions 폴더 열기 실패: ${e?.message ?? e}`); + } + }), ); /** All lesson/playbook/qa-finding cards in the active brain. Uses the brain index for the lesson-kind diff --git a/src/features/company/agents.ts b/src/features/company/agents.ts new file mode 100644 index 0000000..5983e1e --- /dev/null +++ b/src/features/company/agents.ts @@ -0,0 +1,136 @@ +/** + * The 9-agent roster for 1인 기업 모드. + * + * Each entry is a *static* description — persona, role, specialty — used to + * build the specialist's system prompt at dispatch time. The set was adopted + * from Connect_origin's `src/agents.ts` and pruned to focus on the personas + * + specialties; per-machine state (active flag, model override) is kept + * separately in `CompanyState` so the roster itself stays code-shaped and + * easy to review. + * + * Editing rules: + * - `id` is a stable key — change only with a migration plan. + * - `persona` is *optional*. When set it nudges the agent's voice but + * never overrides the system prompt's core rules (file/command tags, + * output format). + * - Keep `specialty` task-oriented (verbs + nouns), not adjective-heavy — + * the CEO planner matches user keywords against it. + */ +import { CompanyAgentDef } from './types'; + +export const COMPANY_AGENTS: Record = { + ceo: { + id: 'ceo', + name: 'CEO', + role: 'Chief Executive Agent', + emoji: '🧭', + color: '#F8FAFC', + specialty: '오케스트레이션, 작업 분해, 종합 판단, 다음 액션 결정', + tagline: '회사 전체 의사결정과 작업 분배를 맡습니다', + alwaysOn: true, + }, + youtube: { + id: 'youtube', + name: '레오', + role: 'Head of YouTube', + emoji: '📺', + color: '#FF4444', + specialty: '유튜브 채널 운영, 영상 기획서(제목·후크·구조), 트렌드 분석, 썸네일 브리프, 업로드 메타데이터, 시청자 유지율 전략', + tagline: '유튜브 채널 기획·운영 전반을 책임집니다', + persona: '데이터 중심·솔직·자신감 있는 톤. 결론을 먼저 말한 뒤 데이터 근거로 뒷받침. 추측보다 숫자. 가끔 직설적이지만 따뜻함은 잃지 않음. 이모지는 자제하되 "🔥"·"📊"·"🎯" 같은 핵심 강조용은 OK.', + }, + instagram: { + id: 'instagram', + name: 'Instagram', + role: 'Head of Instagram', + emoji: '📷', + color: '#E1306C', + specialty: '인스타그램 릴스/피드 콘셉트, 캡션, 해시태그 전략, 게시 시간, 스토리, 팔로워 인게이지먼트', + tagline: '인스타 콘텐츠 기획과 인게이지먼트를 끌어올립니다', + }, + designer: { + id: 'designer', + name: 'Designer', + role: 'Lead Designer', + emoji: '🎨', + color: '#A78BFA', + specialty: '브랜드 디자인 브리프(컬러·타이포·레퍼런스), 썸네일 컨셉 3안, 비주얼 시스템, 디자인 가이드', + tagline: '브랜드와 시각 자산 디자인을 담당합니다', + }, + developer: { + id: 'developer', + name: '코다리', + role: '시니어 풀스택 엔지니어', + emoji: '💻', + color: '#22D3EE', + specialty: '코드 작성·편집·디버깅, 자동화 스크립트, API 통합, 웹사이트/봇, 데이터 파이프라인, git 워크플로, 자기 검증 루프', + tagline: '읽고·생각하고·짜고·검증한다 — 시니어 엔지니어', + persona: '시니어 풀스택 엔지니어. 코드 한 줄도 그냥 안 넘김. "왜?·어떻게?·이게 깨지나?" 늘 묻고 검증. 친근하지만 프로페셔널 톤. "확인 후 진행할게요"·"테스트 통과 확인했어요" 같은 책임감 있는 표현. 이모지는 💻·⚙️·🔧·✅·🐛 정도만.', + }, + business: { + id: 'business', + name: '현빈', + role: '비즈니스 전략가 · Head of Business', + emoji: '💼', + color: '#F5C518', + specialty: '수익화 모델, 가격 전략, 시장·경쟁 분석, ROI/KPI 설계, 비즈니스 의사결정', + tagline: '수익화·가격·전략 의사결정을 같이 봅니다', + }, + secretary: { + id: 'secretary', + name: '영숙', + role: '비서 · Personal Assistant', + emoji: '📱', + color: '#84CC16', + specialty: '일정·할 일 관리, 다른 에이전트 작업 요약·보고, 데일리 브리핑, 알림', + tagline: '일정·할 일·연락을 챙기고 소통을 정리합니다', + persona: '친근하고 정중한 톤. 짧고 정리된 문장. 이모지 적당히 (😊·📅·✅ 정도). 보고할 땐 한눈에 보이게 불릿 포인트 + 핵심만.', + }, + editor: { + id: 'editor', + name: '루나', + role: 'Sound Director & Composer', + emoji: '🎵', + color: '#F472B6', + specialty: '영상 BGM 기획, 사운드 디자인, 영상-음악 매칭, 자막·타이틀 동기화 가이드', + tagline: '영상의 톤에 맞는 사운드 방향을 잡습니다', + persona: '음악·사운드 감각이 좋고 영상의 톤을 한 마디로 잡아냄. "이 영상은 [장르/분위기]가 어울릴 것 같아요" 식으로 제안. BPM·키·길이를 정확히 표기. 데이터 중심이지만 창작자 감수성도 있음. 이모지는 🎵·🎼·🎚 정도만.', + }, + writer: { + id: 'writer', + name: 'Writer', + role: 'Copywriter', + emoji: '✍️', + color: '#FBBF24', + specialty: '카피라이팅, 영상 스크립트 초안, 인스타 캡션, 블로그 글, 메일 톤앤매너, 후크 작성', + tagline: '카피·스크립트·후크를 글로 풀어냅니다', + }, + researcher: { + id: 'researcher', + name: 'Researcher', + role: 'Trend & Data Researcher', + emoji: '🔍', + color: '#60A5FA', + specialty: '트렌드 리서치, 경쟁사 분석, 데이터 수집·요약, 인용 자료 정리, 사실 확인', + tagline: '트렌드와 데이터를 모아 사실 확인까지 끝냅니다', + }, +}; + +/** Display order for the manage panel. CEO first, then specialists. */ +export const COMPANY_AGENT_ORDER: string[] = [ + 'ceo', 'youtube', 'instagram', 'designer', 'developer', + 'business', 'secretary', 'editor', 'writer', 'researcher', +]; + +/** Specialists only (everything except the CEO). */ +export const COMPANY_SPECIALIST_IDS: string[] = COMPANY_AGENT_ORDER.filter((id) => id !== 'ceo'); + +/** Default activation set used when a user first opens the company panel. */ +export const DEFAULT_ACTIVE_AGENTS: string[] = [ + 'ceo', 'developer', 'writer', 'researcher', 'designer', 'business', +]; + +/** Lookup helper. Returns `undefined` for unknown ids instead of throwing. */ +export function getCompanyAgent(id: string): CompanyAgentDef | undefined { + return COMPANY_AGENTS[id]; +} diff --git a/src/features/company/ceoPlanner.ts b/src/features/company/ceoPlanner.ts new file mode 100644 index 0000000..4a2e999 --- /dev/null +++ b/src/features/company/ceoPlanner.ts @@ -0,0 +1,219 @@ +/** + * CEO planner — turns a user prompt into a `CompanyTaskPlan`. + * + * Lifecycle of one planner call: + * 1. Build the planner system prompt (template + active-agent list). + * 2. Hit the AI service with the user prompt as the user message. + * 3. Parse the response through a 4-stage JSON pipeline that tolerates + * ```json fences, leading thoughts, truncated outputs, and minor key + * misspellings. Smaller local models violate "no extra text" rules + * *constantly*, so a permissive parser is required. + * 4. Normalize agent ids: accept Korean nicknames (`레오` → `youtube`, + * `코다리` → `developer`) and filter out tasks for inactive agents. + * + * The function never throws — it always returns a `CompanyTaskPlan`. If + * everything fails we surface an empty plan with a brief that explains what + * happened, and the dispatcher treats that as "nothing to dispatch, just + * relay the chat-style reply". + */ +import { IAIService } from '../../core/services'; +import { logError, logInfo } from '../../utils'; +import { COMPANY_AGENTS } from './agents'; +import { isAgentActive } from './companyConfig'; +import { applyPromptVars, CEO_PLANNER_PROMPT } from './promptAssets'; +import { buildPlannerSystemPrompt } from './promptBuilder'; +import { CompanyState, CompanyTaskPlan } from './types'; + +export interface PlannerResult { + plan: CompanyTaskPlan; + /** True iff JSON parsing succeeded — false means we fell back to empty. */ + parsed: boolean; + /** Raw LLM output (kept for the chat / debug log). */ + raw: string; +} + +const EMPTY_PLAN: CompanyTaskPlan = { brief: '', tasks: [] }; + +/** + * Map Korean agent nicknames + likely typos to canonical ids. Built once + * from the static AGENTS map so it stays in sync with renames. + */ +const NAME_TO_ID: Record = (() => { + const out: Record = {}; + for (const [id, def] of Object.entries(COMPANY_AGENTS)) { + out[id.toLowerCase()] = id; + out[def.name.toLowerCase()] = id; + // Also catch the role keyword (e.g. "designer", "writer") + const roleHead = def.role.split(/[\s·]+/)[0]?.toLowerCase(); + if (roleHead && !out[roleHead]) out[roleHead] = id; + } + return out; +})(); + +function _canonicalAgentId(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + const key = raw.trim().toLowerCase(); + return NAME_TO_ID[key] ?? (COMPANY_AGENTS[key] ? key : null); +} + +/** + * 4-stage JSON extractor — same idea as Connect_origin's planner but built + * fresh here so we don't carry over its 21K-line file. Each stage is a fall- + * through: we keep trying until something gives us a parseable object. + */ +function _parsePlanJson(raw: string): CompanyTaskPlan | null { + if (!raw || !raw.trim()) return null; + + // Stage 1 — strip ```json … ``` fence + leading "okay let me think" prose. + const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); + const stage1 = (fenced ? fenced[1] : raw).trim(); + + // Stage 2 — direct JSON.parse. + try { + const obj = JSON.parse(stage1); + const plan = _coercePlan(obj); + if (plan) return plan; + } catch { /* fall through */ } + + // Stage 3 — find the first balanced `{ … }` and parse just that. Smaller + // models love to prepend explanations or append trailing notes. + const balanced = _extractFirstBalancedObject(stage1); + if (balanced) { + try { + const obj = JSON.parse(balanced); + const plan = _coercePlan(obj); + if (plan) return plan; + } catch { /* fall through */ } + } + + // Stage 4 — regex recovery. If JSON is truncated mid-task we still try + // to pull `brief` + any complete `{agent, task}` pairs from the text. + const briefMatch = stage1.match(/"brief"\s*:\s*"([\s\S]*?)"/); + const brief = briefMatch ? briefMatch[1] : ''; + const tasks: CompanyTaskPlan['tasks'] = []; + const taskRe = /\{\s*"agent"\s*:\s*"([^"]+)"\s*,\s*"task"\s*:\s*"([\s\S]*?)"\s*\}/g; + let m: RegExpExecArray | null; + while ((m = taskRe.exec(stage1))) { + tasks.push({ agent: m[1].trim(), task: m[2].trim() }); + } + if (brief || tasks.length > 0) return { brief: brief.trim(), tasks }; + return null; +} + +function _coercePlan(obj: unknown): CompanyTaskPlan | null { + if (!obj || typeof obj !== 'object') return null; + const o = obj as Record; + const brief = typeof o.brief === 'string' ? o.brief : ''; + const rawTasks = Array.isArray(o.tasks) ? o.tasks : []; + const tasks: CompanyTaskPlan['tasks'] = []; + for (const t of rawTasks) { + if (!t || typeof t !== 'object') continue; + const tt = t as Record; + if (typeof tt.agent === 'string' && typeof tt.task === 'string') { + tasks.push({ agent: tt.agent.trim(), task: tt.task.trim() }); + } + } + return { brief: brief.trim(), tasks }; +} + +/** Find the first complete `{ … }` block respecting brace nesting. */ +function _extractFirstBalancedObject(s: string): string | null { + const start = s.indexOf('{'); + if (start === -1) return null; + let depth = 0; + let inString = false; + let escape = false; + for (let i = start; i < s.length; i++) { + const ch = s[i]; + if (inString) { + if (escape) escape = false; + else if (ch === '\\') escape = true; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') { inString = true; continue; } + if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (depth === 0) return s.slice(start, i + 1); + } + } + return null; +} + +/** + * Filter + normalize a freshly-parsed plan against the current company + * state. Tasks targeting unknown / inactive agents are dropped, and Korean + * nicknames are rewritten to canonical ids. + */ +export function normalizePlan(plan: CompanyTaskPlan, state: CompanyState): CompanyTaskPlan { + const out: CompanyTaskPlan = { brief: plan.brief, tasks: [] }; + const dropped: string[] = []; + for (const t of plan.tasks) { + const canonical = _canonicalAgentId(t.agent); + if (!canonical) { + dropped.push(`unknown:${t.agent}`); + continue; + } + if (canonical === 'ceo') { + // CEO is the orchestrator — it never receives a task in `tasks` + // (the report phase calls it separately). Drop silently. + dropped.push('ceo:self-dispatch'); + continue; + } + if (!isAgentActive(state, canonical)) { + dropped.push(`inactive:${canonical}`); + continue; + } + out.tasks.push({ agent: canonical, task: t.task }); + } + if (dropped.length > 0) { + logInfo('ceoPlanner: dropped tasks during normalization.', { dropped }); + } + return out; +} + +/** + * Run the CEO planner end-to-end. Never throws. The caller decides what to + * do with `{ parsed: false, plan: { tasks: [] } }` — usually we surface the + * raw text as a casual CEO reply. + */ +export async function runCeoPlanner( + ai: IAIService, + userPrompt: string, + state: CompanyState, + options: { model?: string; timeoutMs?: number } = {}, +): Promise { + const system = buildPlannerSystemPrompt( + applyPromptVars(CEO_PLANNER_PROMPT, { company: state.companyName }), + state, + ); + let raw = ''; + try { + const result = await ai.chat({ + system, + user: userPrompt, + model: options.model, + timeoutMs: options.timeoutMs, + }); + raw = result.content || ''; + } catch (e: any) { + logError('ceoPlanner: AI call failed.', { error: e?.message ?? String(e) }); + return { plan: EMPTY_PLAN, parsed: false, raw: '' }; + } + + const parsed = _parsePlanJson(raw); + if (!parsed) { + // No JSON found — treat as a casual chat reply. The dispatcher's + // empty-plan branch will surface `raw` as the CEO's spoken response. + return { plan: { brief: raw.trim(), tasks: [] }, parsed: false, raw }; + } + + const plan = normalizePlan(parsed, state); + logInfo('ceoPlanner: parsed plan.', { + briefChars: plan.brief.length, + taskCount: plan.tasks.length, + agents: plan.tasks.map((t) => t.agent), + }); + return { plan, parsed: true, raw }; +} diff --git a/src/features/company/ceoReporter.ts b/src/features/company/ceoReporter.ts new file mode 100644 index 0000000..3df2464 --- /dev/null +++ b/src/features/company/ceoReporter.ts @@ -0,0 +1,120 @@ +/** + * CEO synthesis pass — runs after all specialists have finished. + * + * Given the per-agent outputs, this asks the CEO model to produce the final + * markdown report (✅ 완료 / 🚀 다음 / 💡 인사이트) that the user actually + * reads. The function deliberately doesn't try to *parse* the response — + * we trust the prompt to keep the structure and surface the text as-is. + * + * Failure mode: when the CEO call errors out we still return whatever raw + * text we managed to collect (typically empty). The dispatcher then + * concatenates the per-agent outputs into a fallback report so the user + * never sees a blank screen. + */ +import { IAIService } from '../../core/services'; +import { logError } from '../../utils'; +import { getCompanyAgent } from './agents'; +import { applyPromptVars, CEO_REPORT_PROMPT } from './promptAssets'; +import { AgentTurnOutput, CompanyState, CompanyTaskPlan } from './types'; + +/** Max characters of per-agent output to feed back into the CEO synthesis. */ +const PER_AGENT_REPORT_BUDGET = 2000; + +export interface ReportResult { + /** Generated markdown. Empty string on hard failure. */ + report: string; + /** True when the LLM call succeeded with non-empty content. */ + ok: boolean; +} + +/** + * Build the user-message payload the CEO sees: the brief, plus each agent's + * task + output, lightly trimmed so the planner-model's context window + * doesn't blow up on a verbose specialist. + */ +function _buildReportUserMessage( + plan: CompanyTaskPlan, + outputs: AgentTurnOutput[], +): string { + const lines: string[] = []; + if (plan.brief) { + lines.push('## 이번 작업 브리프'); + lines.push(plan.brief); + lines.push(''); + } + lines.push('## 에이전트별 산출물'); + if (outputs.length === 0) { + lines.push('_(no agent dispatched this turn — produce a brief acknowledgement instead)_'); + } else { + for (const out of outputs) { + const def = getCompanyAgent(out.agentId); + const head = def ? `### ${def.emoji} ${def.name}` : `### ${out.agentId}`; + lines.push(''); + lines.push(head); + lines.push(`**Task:** ${out.task}`); + if (out.error) { + lines.push(`**Note:** dispatch failed — \`${out.error}\`. 사용 가능한 부분만 인용해서 보고.`); + } + lines.push(''); + const body = out.response.length > PER_AGENT_REPORT_BUDGET + ? out.response.slice(0, PER_AGENT_REPORT_BUDGET) + '\n…(truncated)' + : out.response; + lines.push(body); + } + } + return lines.join('\n'); +} + +/** Build a fallback report by concatenating agent outputs verbatim. Used when the LLM synthesis fails. */ +export function buildFallbackReport( + plan: CompanyTaskPlan, + outputs: AgentTurnOutput[], +): string { + const parts: string[] = ['## ✅ 완료된 작업']; + if (outputs.length === 0) { + parts.push('- _(no agents ran this turn)_'); + } else { + for (const out of outputs) { + const def = getCompanyAgent(out.agentId); + const head = def ? `**${def.emoji} ${def.name}**` : `**${out.agentId}**`; + const firstLine = (out.response.split(/\n/).find((l) => l.trim()) || out.task).trim(); + parts.push(`- ${head} — ${firstLine.slice(0, 120)}`); + } + } + parts.push(''); + parts.push('## 🚀 다음 액션'); + parts.push('_(CEO 합성 실패 — 위 산출물을 직접 확인하세요)_'); + parts.push(''); + parts.push('## 💡 인사이트'); + parts.push(`- 이번 턴은 ${outputs.length}명의 에이전트가 작업했습니다.`); + if (plan.brief) parts.push(`- 브리프: ${plan.brief}`); + return parts.join('\n'); +} + +/** End-to-end synthesis call. Never throws — returns `{ ok: false, … }` on error. */ +export async function runCeoReporter( + ai: IAIService, + plan: CompanyTaskPlan, + outputs: AgentTurnOutput[], + state: CompanyState, + options: { model?: string; timeoutMs?: number } = {}, +): Promise { + const system = applyPromptVars(CEO_REPORT_PROMPT, { company: state.companyName }); + const user = _buildReportUserMessage(plan, outputs); + try { + const result = await ai.chat({ + system, + user, + model: options.model, + timeoutMs: options.timeoutMs, + }); + const text = (result.content || '').trim(); + if (!text) { + return { report: buildFallbackReport(plan, outputs), ok: false }; + } + return { report: text, ok: true }; + } catch (e: any) { + logError('ceoReporter: AI call failed.', { error: e?.message ?? String(e) }); + return { report: buildFallbackReport(plan, outputs), ok: false }; + } +} diff --git a/src/features/company/companyConfig.ts b/src/features/company/companyConfig.ts new file mode 100644 index 0000000..4c21f30 --- /dev/null +++ b/src/features/company/companyConfig.ts @@ -0,0 +1,182 @@ +/** + * State + config plumbing for 1인 기업 모드. + * + * Two surfaces: + * + * - **`CompanyState`** (runtime data: enabled flag, company name, which + * agents are active, per-agent model overrides). Persisted in VS Code's + * `globalState` so it survives reloads. Mutating it always goes through + * `update*()` helpers so the webview can re-render after the change. + * + * - **Read-only helpers** that derive useful data from the current state + + * the static `COMPANY_AGENTS` roster (active list, model-for-agent lookup, + * etc.). Keeping these in one module means the planner, dispatcher, and + * UI all consult one place. + * + * The choice of `globalState` over `workspaceState` is deliberate: the user + * wants the same company / agent set / nicknames available across every + * project they open. Per-workspace overrides can be added later as a layer + * on top without breaking this API. + */ +import * as vscode from 'vscode'; +import { COMPANY_AGENTS, DEFAULT_ACTIVE_AGENTS, getCompanyAgent } from './agents'; +import { CompanyState, COMPANY_STATE_KEY } from './types'; + +/** Default state for a brand-new user. CEO is always on. */ +function _defaultState(): CompanyState { + return { + enabled: false, + companyName: '1인 기업', + activeAgentIds: DEFAULT_ACTIVE_AGENTS.slice(), + modelOverrides: {}, + }; +} + +/** + * Normalize a state value loaded from globalState. Guards against schema + * drift (e.g. unknown agent ids that no longer exist, missing fields). + */ +function _normalize(raw: Partial | undefined): CompanyState { + const def = _defaultState(); + if (!raw || typeof raw !== 'object') return def; + const enabled = typeof raw.enabled === 'boolean' ? raw.enabled : def.enabled; + const companyName = typeof raw.companyName === 'string' && raw.companyName.trim() + ? raw.companyName.trim() + : def.companyName; + const validIds = Array.isArray(raw.activeAgentIds) + ? raw.activeAgentIds.filter((id): id is string => typeof id === 'string' && !!getCompanyAgent(id)) + : def.activeAgentIds; + // CEO is *implicitly* always active — keep it out of the persisted list + // so we never accidentally drop it, but the public reader re-includes it. + const withoutCeo = validIds.filter((id) => id !== 'ceo'); + const overrides: Record = {}; + if (raw.modelOverrides && typeof raw.modelOverrides === 'object') { + for (const [k, v] of Object.entries(raw.modelOverrides)) { + if (typeof v === 'string' && v.trim() && getCompanyAgent(k)) { + overrides[k] = v.trim(); + } + } + } + return { enabled, companyName, activeAgentIds: withoutCeo, modelOverrides: overrides }; +} + +/** Read the current company state. Always returns a fully-populated object. */ +export function readCompanyState(context: vscode.ExtensionContext): CompanyState { + const raw = context.globalState.get>(COMPANY_STATE_KEY); + return _normalize(raw); +} + +/** Persist a complete state object. Callers usually go through the `update*` + * helpers below; direct use is fine when you want to write multiple fields + * atomically. */ +export async function writeCompanyState( + context: vscode.ExtensionContext, + next: CompanyState, +): Promise { + await context.globalState.update(COMPANY_STATE_KEY, _normalize(next)); +} + +/** + * Toggle the whole mode on/off. Returns the new state so callers can + * immediately broadcast it to the webview without a re-read. + */ +export async function setCompanyEnabled( + context: vscode.ExtensionContext, + enabled: boolean, +): Promise { + const cur = readCompanyState(context); + const next: CompanyState = { ...cur, enabled }; + await writeCompanyState(context, next); + return next; +} + +/** Rename the company. Empty / whitespace input falls back to the default. */ +export async function setCompanyName( + context: vscode.ExtensionContext, + name: string, +): Promise { + const cur = readCompanyState(context); + const trimmed = (name || '').trim(); + const next: CompanyState = { ...cur, companyName: trimmed || '1인 기업' }; + await writeCompanyState(context, next); + return next; +} + +/** Replace the active-agent set. Order is preserved; unknown ids are dropped. */ +export async function setActiveAgents( + context: vscode.ExtensionContext, + ids: string[], +): Promise { + const cur = readCompanyState(context); + const next: CompanyState = { ...cur, activeAgentIds: ids }; + await writeCompanyState(context, next); + return next; +} + +/** + * Set / clear a per-agent model override. Passing empty string removes the + * override (the agent will fall back to the global default). + */ +export async function setAgentModelOverride( + context: vscode.ExtensionContext, + agentId: string, + model: string, +): Promise { + const cur = readCompanyState(context); + const overrides = { ...cur.modelOverrides }; + if (model && model.trim()) { + overrides[agentId] = model.trim(); + } else { + delete overrides[agentId]; + } + const next: CompanyState = { ...cur, modelOverrides: overrides }; + await writeCompanyState(context, next); + return next; +} + +// ── Derived helpers (no I/O) ──────────────────────────────────────────────── + +/** + * Resolve the full set of agent ids that should be available to the CEO + * planner on this turn. CEO is always included regardless of `activeAgentIds`. + */ +export function activeAgentIds(state: CompanyState): string[] { + const set = new Set(['ceo']); + for (const id of state.activeAgentIds) { + if (getCompanyAgent(id)) set.add(id); + } + return Array.from(set); +} + +/** Returns true when an agent is currently active (CEO always returns true). */ +export function isAgentActive(state: CompanyState, agentId: string): boolean { + if (agentId === 'ceo') return true; + return state.activeAgentIds.includes(agentId); +} + +/** + * The model to use when dispatching `agentId`. Returns the override when + * configured, otherwise `fallbackDefault` (typically the global + * `g1nation.defaultModel`). Empty string is treated as "no override". + */ +export function modelForAgent( + state: CompanyState, + agentId: string, + fallbackDefault: string, +): string { + const override = state.modelOverrides[agentId]; + return override && override.trim() ? override.trim() : fallbackDefault; +} + +/** + * Human-readable summary for the chip tooltip / status bar: + * "🏢 My Company · 5 agents · default model" + */ +export function summarizeForChip(state: CompanyState): string { + const count = activeAgentIds(state).length; + return `${state.companyName} · ${count} agents`; +} + +// Re-export the static catalogue so callers only have to import from one +// module to get the full picture. +export { COMPANY_AGENTS, getCompanyAgent }; diff --git a/src/features/company/dispatcher.ts b/src/features/company/dispatcher.ts new file mode 100644 index 0000000..068171b --- /dev/null +++ b/src/features/company/dispatcher.ts @@ -0,0 +1,252 @@ +/** + * Sequential dispatcher for 1인 기업 모드. + * + * Drives one company "turn": + * + * user prompt + * → CEO planner (JSON {brief, tasks}) + * → for each task in plan: dispatch one specialist (sequentially) + * - build specialist prompt (incl. peer context from earlier agents) + * - call the AI service + * - persist its output to disk + * - append its output to the peer-context buffer for the next agent + * → CEO reporter (synthesis markdown) + * → persist `_report.md`, update agent memory + decisions + * → emit `companyTurnUpdate` events to the webview at each phase + * + * Why sequential? The user runs Astra on a single GPU/CPU with limited RAM, + * and parallel agents would force us to keep multiple models loaded + * simultaneously. Sequential dispatch keeps "exactly one model resident at + * a time" — the LM Studio lifecycle manager unloads the previous model and + * loads the next when an agent has its own override. + * + * Why not use `AgentExecutor.handlePrompt` here? Because `handlePrompt` is + * built for the *interactive* chat path: it owns the conversation history, + * streaming UI, agent-mode injection, and a dozen other things we don't + * want triggered by a company turn. The company dispatcher needs a clean + * "one system + one user → one string back" primitive — `AIService.chat()` + * fits that perfectly. Specialists can still emit action tags + * (``, ``); we route their *raw* output through + * the existing action-tag executor afterwards so file/command tools work + * exactly as in chat. + */ +import * as vscode from 'vscode'; +import { IAIService } from '../../core/services'; +import { logError, logInfo } from '../../utils'; +import { getCompanyAgent } from './agents'; +import { modelForAgent, readCompanyState } from './companyConfig'; +import { runCeoPlanner } from './ceoPlanner'; +import { runCeoReporter } from './ceoReporter'; +import { buildSpecialistPrompt } from './promptBuilder'; +import { + appendAgentMemory, + appendDecision, + createSessionDir, + newSessionTimestamp, + readAgentMemory, + readDecisions, + writeAgentOutput, + writeBrief, + writeReport, + writeSessionJson, +} from './sessionStore'; +import { AgentTurnOutput, CompanyTaskPlan, SessionResult } from './types'; + +/** Trim length applied when an agent's output is fed into the next agent. */ +const PEER_OUTPUT_BUDGET = 1500; + +/** + * Events emitted during a turn. The sidebar webview subscribes to render + * progress (chips, headers, streamed agent replies). The shape is generic so + * the same channel can carry CEO/agent/report messages without per-type + * postMessage plumbing. + */ +export type CompanyTurnEvent = + | { phase: 'plan-start' } + | { phase: 'plan-ready'; plan: CompanyTaskPlan; parsed: boolean; raw: string } + | { phase: 'agent-start'; agentId: string; task: string; index: number; total: number } + | { phase: 'agent-done'; agentId: string; output: AgentTurnOutput; index: number; total: number } + | { phase: 'report-start' } + | { phase: 'report-done'; report: string; ok: boolean } + | { phase: 'session-saved'; sessionDir: string } + | { phase: 'aborted'; reason: string }; + +export type CompanyTurnEmitter = (event: CompanyTurnEvent) => void; + +export interface DispatcherDeps { + context: vscode.ExtensionContext; + ai: IAIService; + /** Default model to fall back to when an agent has no override. */ + defaultModel: string; + /** Per-call cancellation. The sidebar's Stop button flips this. */ + signal?: AbortSignal; + /** Optional event sink for the webview. Receives events synchronously. */ + onEvent?: CompanyTurnEmitter; +} + +/** + * Run a single company turn. Returns a fully-populated `SessionResult` even + * on partial failure (so callers can always render *something* in chat). + */ +export async function runCompanyTurn( + userPrompt: string, + deps: DispatcherDeps, +): Promise { + const startedAt = Date.now(); + const state = readCompanyState(deps.context); + const timestamp = newSessionTimestamp(); + const sessionDir = createSessionDir(deps.context, timestamp); + + const emit: CompanyTurnEmitter = deps.onEvent ?? (() => { /* noop */ }); + const isAborted = () => deps.signal?.aborted === true; + const fail = (reason: string): SessionResult => { + emit({ phase: 'aborted', reason }); + return { + timestamp, sessionDir, + userPrompt, + plan: { brief: '', tasks: [] }, + agentOutputs: [], + report: '', + totalDurationMs: Date.now() - startedAt, + }; + }; + if (isAborted()) return fail('signal-aborted'); + + // ── Phase 1: planner ── + emit({ phase: 'plan-start' }); + const ceoModel = modelForAgent(state, 'ceo', deps.defaultModel); + const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, { model: ceoModel }); + if (isAborted()) return fail('aborted-after-plan'); + emit({ + phase: 'plan-ready', + plan: plannerResult.plan, + parsed: plannerResult.parsed, + raw: plannerResult.raw, + }); + writeBrief(sessionDir, userPrompt, plannerResult.plan); + + // ── Phase 2: sequential dispatch ── + const outputs: AgentTurnOutput[] = []; + const total = plannerResult.plan.tasks.length; + for (let i = 0; i < total; i++) { + if (isAborted()) return fail('aborted-mid-dispatch'); + const task = plannerResult.plan.tasks[i]; + emit({ phase: 'agent-start', agentId: task.agent, task: task.task, index: i, total }); + const turn = await _dispatchOne(task.agent, task.task, outputs, state, deps); + outputs.push(turn); + writeAgentOutput(sessionDir, turn); + // Best-effort: append a one-line memory entry so the agent "remembers" + // having done this task. Verbose successes are summarized in the CEO + // report — memory is just the breadcrumb trail. + appendAgentMemory( + deps.context, + task.agent, + `[${timestamp}] ${task.task} — ${turn.error ? `❌ ${turn.error}` : '✅'}`, + ); + emit({ phase: 'agent-done', agentId: task.agent, output: turn, index: i, total }); + } + + // ── Phase 3: synthesis ── + if (isAborted()) return fail('aborted-before-report'); + emit({ phase: 'report-start' }); + const reportModel = modelForAgent(state, 'ceo', deps.defaultModel); + const reportResult = await runCeoReporter( + deps.ai, + plannerResult.plan, + outputs, + state, + { model: reportModel }, + ); + writeReport(sessionDir, reportResult.report); + emit({ phase: 'report-done', report: reportResult.report, ok: reportResult.ok }); + + // ── Phase 4: persist + side effects ── + const result: SessionResult = { + timestamp, sessionDir, + userPrompt, + plan: plannerResult.plan, + agentOutputs: outputs, + report: reportResult.report, + totalDurationMs: Date.now() - startedAt, + }; + writeSessionJson(sessionDir, result); + // Heuristic: if the report mentions a 🚀 line, extract it as a decision. + const decisionLine = reportResult.report.split(/\n/).find((l) => /^\d+\.\s+/.test(l.trim())); + if (decisionLine) appendDecision(deps.context, decisionLine.trim()); + emit({ phase: 'session-saved', sessionDir }); + + logInfo('company.dispatcher: turn complete.', { + sessionDir, agents: outputs.length, ok: reportResult.ok, + durationMs: result.totalDurationMs, + }); + return result; +} + +/** + * Dispatch one specialist. Wraps the AI call with try/catch so a single + * agent's failure never aborts the whole turn — we record the error and + * keep going so the user still gets the other agents' outputs. + */ +async function _dispatchOne( + agentId: string, + task: string, + earlierOutputs: AgentTurnOutput[], + state: ReturnType, + deps: DispatcherDeps, +): Promise { + const startedAt = Date.now(); + const def = getCompanyAgent(agentId); + if (!def) { + return { + agentId, task, response: '', durationMs: 0, + error: `Unknown agent id: ${agentId}`, + }; + } + const memory = readAgentMemory(deps.context, agentId); + const decisions = readDecisions(deps.context, 2000); + const peerOutputs = earlierOutputs + .filter((o) => !o.error) // skip failed peers — they'd just confuse the next agent + .map((o) => { + const peerDef = getCompanyAgent(o.agentId); + const body = o.response.length > PEER_OUTPUT_BUDGET + ? o.response.slice(0, PEER_OUTPUT_BUDGET) + '\n…(truncated)' + : o.response; + return { + agentId: o.agentId, + agentName: peerDef?.name ?? o.agentId, + emoji: peerDef?.emoji ?? '🤖', + content: body, + }; + }); + + const system = buildSpecialistPrompt({ + agentId, state, + agentMemory: memory, sharedDecisions: decisions, + peerOutputs, + }); + const model = modelForAgent(state, agentId, deps.defaultModel); + + try { + const result = await deps.ai.chat({ + system, + user: task, + model, + }); + const response = (result.content || '').trim(); + return { + agentId, task, + response: response || '_(empty response)_', + durationMs: Date.now() - startedAt, + error: response ? undefined : 'empty-response', + }; + } catch (e: any) { + const err = e?.message ?? String(e); + logError('company.dispatcher: agent dispatch failed.', { agentId, err }); + return { + agentId, task, + response: `⚠️ 호출 실패: ${err}`, + durationMs: Date.now() - startedAt, + error: err, + }; + } +} diff --git a/src/features/company/index.ts b/src/features/company/index.ts new file mode 100644 index 0000000..77e4513 --- /dev/null +++ b/src/features/company/index.ts @@ -0,0 +1,50 @@ +/** + * Public API for 1인 기업 모드. + * + * Consumers (sidebarProvider, chatHandlers, command handlers) import from + * this barrel so internal layout can move around without touching every + * call site. + */ +export { + COMPANY_AGENTS, + COMPANY_AGENT_ORDER, + COMPANY_SPECIALIST_IDS, + DEFAULT_ACTIVE_AGENTS, + getCompanyAgent, +} from './agents'; + +export { + readCompanyState, + writeCompanyState, + setCompanyEnabled, + setCompanyName, + setActiveAgents, + setAgentModelOverride, + activeAgentIds, + isAgentActive, + modelForAgent, + summarizeForChip, +} from './companyConfig'; + +export type { + CompanyAgentDef, + CompanyState, + CompanyTaskPlan, + AgentTurnOutput, + SessionResult, +} from './types'; + +export { + runCompanyTurn, +} from './dispatcher'; + +export type { + CompanyTurnEvent, + CompanyTurnEmitter, + DispatcherDeps, +} from './dispatcher'; + +export { + listSessions, + resolveCompanyBase, +} from './sessionStore'; diff --git a/src/features/company/promptAssets.ts b/src/features/company/promptAssets.ts new file mode 100644 index 0000000..db1d465 --- /dev/null +++ b/src/features/company/promptAssets.ts @@ -0,0 +1,114 @@ +/** + * Inlined prompt assets for the 1인 기업 mode. + * + * The CEO planner / reporter / casual-chat prompts are kept as TS string + * constants rather than loaded from `prompts/*.md` at runtime, for two reasons: + * + * 1. **Bundling.** esbuild collapses the whole extension into one file, + * so resolving a markdown path via `__dirname` would point inside + * `out/extension.js` and fail. Inlining sidesteps that entirely. + * 2. **Tamper resistance.** The planner prompt encodes the multi-agent + * contract (JSON shape, minimum-dispatch rule). Embedding it in code + * means a workspace can't quietly swap it for a malicious version. + * + * The `.md` files under `./prompts/` are kept as **reference copies** so + * developers can read/diff them in any editor — `promptAssets.ts` is the + * source of truth the runtime actually uses. + */ + +/** + * CEO planner prompt. The `{{COMPANY}}` placeholder is substituted with the + * user-configured company name before sending to the LLM. The model is + * required to return a single JSON object — see `ceoPlanner.ts` for the + * 4-stage parser that tolerates fenced/leading-noise variants. + */ +export const CEO_PLANNER_PROMPT = `당신은 "{{COMPANY}}"의 CEO입니다. 1인 AI 기업의 사령관이자 오케스트레이터입니다. + +당신의 팀(전문 에이전트): +- youtube (Head of YouTube) : 유튜브 채널 운영, 영상 기획, 트렌드, 썸네일 브리프 +- instagram (Head of Instagram) : 릴스/피드, 캡션, 해시태그, 게시 시간, 인게이지먼트 +- designer (Lead Designer) : 디자인 브리프, 썸네일·브랜드 비주얼, 컬러/타이포 +- developer (코다리 · 시니어 풀스택 엔지니어): 코드 작성·편집·디버깅, 자동화 스크립트, API 통합, 웹사이트, 테스트, git, 자기 검증 루프 +- business (Head of Business) : 수익화, 가격, 비즈니스 전략·분석, KPI +- secretary (Personal Assistant) : 일정·할 일, 작업 요약, 데일리 브리핑 +- editor (루나 · 사운드 감독) : BGM 기획, 사운드 디자인, 영상-음악 매칭 +- writer (Copywriter) : 카피라이팅, 영상 스크립트, 캡션, 블로그, 후크 +- researcher(Trend & Data Researcher) : 트렌드/경쟁사 리서치, 데이터 수집·요약, 사실 확인 + +사용자가 한 줄 명령을 내리면, 당신은 어떤 에이전트들을 어떤 순서로 동원할지 결정합니다. + +⚠️ 반드시 아래 JSON 형식으로만 출력하세요. 다른 텍스트(설명, \`\`\`json 펜스, 머리말, 꼬리말)는 절대 포함 금지. + +{ + "brief": "이번 작업이 무엇인지 2~3줄 한국어 요약", + "tasks": [ + {"agent": "youtube", "task": "구체적이고 실행 가능한 한국어 지시"} + ] +} + +🛑 **최소 동원 원칙 — 절대 위반 금지**: +1. **단순 데이터 조회·정보 확인 명령은 데이터 에이전트 1명만**. 예: "내 채널 분석", "구독자 수", "오늘 일정", "최근 영상" → tasks 배열에 1명. 추가 분석 에이전트(researcher/business/designer/writer) 절대 추가 금지. 사용자가 추가 분석을 *명시적으로* 요청해야만 추가. +2. **창작·기획 명령일 때만 multi-agent**. 예: "영상 기획해줘", "썸네일 만들어", "수익화 전략 짜줘" → 관련 에이전트 2~3명. 5명 이상 절대 금지. +3. **상관없는 에이전트 끌어오지 마라**. 사용자 명령이 유튜브 데이터인데 designer/writer 부르는 건 즉시 금지. 사용자가 "디자인"·"카피"·"썸네일" 같은 단어를 *직접* 썼을 때만. + +데이터 수집 키워드 매칭 (해당 에이전트만 1명): +- "유튜브"·"YouTube"·"내 채널"·"구독자"·"조회수"·"영상 분석" → youtube 1명만 +- "인스타"·"릴스"·"피드" → instagram 1명만 +- "캘린더"·"일정"·"오늘 미팅" → secretary 1명만 + +기타 규칙: +- 논리적 순서로 정렬 (예: 데이터 수집 → 분석 → 창작 — 사용자가 그 모두를 요청한 경우에만) +- 각 task는 모호함 없이 구체적·실행가능하게 +- JSON 외 텍스트는 단 한 글자도 출력 금지 +- 데이터 수집 없이 researcher/business만 호출하면 LLM이 가짜 분석을 출력합니다 — 절대 금지 +`; + +/** + * CEO synthesis prompt — runs at the end of every turn after all specialists + * have replied. Output is plain markdown (no JSON), structured into ✅/🚀/💡 + * sections that the chat surfaces verbatim. + */ +export const CEO_REPORT_PROMPT = `당신은 {{COMPANY}}의 CEO입니다. 방금 팀이 작업을 끝냈습니다. +각 에이전트의 산출물을 읽고 사장님께 올릴 종합 보고서를 작성하세요. + +형식 (한국어 마크다운, 정확히 이대로): + +## ✅ 완료된 작업 +- (에이전트별 핵심 산출물 1줄씩, 굵은 글씨로 에이전트명) + +## 🚀 다음 액션 (Top 3) +1. **(에이전트명)** — 무엇을 +2. **(에이전트명)** — 무엇을 +3. **(에이전트명)** — 무엇을 + +## 💡 인사이트 +- 이번 작업에서 발견한 핵심 통찰 1~2개 + +규칙: 간결, 사족 금지, 사과·면책 금지. 가능하면 300자 내외. + +⚠️ 데이터 우선 규칙 (반드시 준수): +- 산출물에 **실제 숫자/데이터**가 있으면 **그 데이터를 직접 인용**해 보고하세요. 추상적인 "분석 진행됨" 같은 말로 대체 금지. +- 추측·일반론·placeholder 절대 금지. 산출물에 없는 사실 만들어내지 마세요. +- 어떤 에이전트의 산출물에 에러 메시지가 있어도 다른 에이전트의 실제 결과는 정상적으로 인용하세요. +`; + +/** + * Fallback "casual chat" prompt used when the planner's JSON parse fails + * entirely (typically because the user wrote a greeting instead of a work + * command). Replies in 1–3 sentences without trying to dispatch agents. + */ +export const CEO_CHAT_PROMPT = `당신은 {{COMPANY}}의 CEO입니다. 사용자(사장님)와 짧게 인사·안부·잡담을 주고받습니다. +- 한국어로 1~3문장. 친근하지만 사장-CEO 관계는 유지. +- 인사·안부 질문이면 자연스럽게 응답하세요. 작업 지시가 아니면 굳이 작업 분배 제안 X. +- 회사 정체성·최근 결정이 컨텍스트에 있으면 자연스럽게 활용. +- JSON 출력 금지. 그냥 평문으로 짧게. +`; + +/** + * Substitute the `{{COMPANY}}` placeholder. Trivial today, but isolating it + * here keeps the door open for additional templating later (e.g. company + * mission statement, brand voice) without touching every call site. + */ +export function applyPromptVars(template: string, vars: { company: string }): string { + return template.replace(/\{\{COMPANY\}\}/g, vars.company || '1인 기업'); +} diff --git a/src/features/company/promptBuilder.ts b/src/features/company/promptBuilder.ts new file mode 100644 index 0000000..2d56c47 --- /dev/null +++ b/src/features/company/promptBuilder.ts @@ -0,0 +1,140 @@ +/** + * System-prompt construction for company-mode agents. + * + * Each specialist needs a prompt that includes: + * - Their identity (name, role, specialty) + optional persona. + * - The action-tag contract (``, ``, etc.) so + * ConnectAI's existing `_executeActions()` can handle tool calls + * transparently after the LLM responds. + * - The *peer context* — earlier agents' outputs in the same turn, so the + * second/third agent can build on what came before. + * - The agent's long-term memory (`memory.md`) when available. + * - Company-wide decisions, if recorded. + * + * Build-once-per-dispatch: the dispatcher calls `buildSpecialistPrompt()` for + * every task. Each call is pure (no I/O of its own — the caller fetches + * memory/decisions and passes them in), which keeps it trivial to test. + */ +import { COMPANY_AGENTS, getCompanyAgent } from './agents'; +import { CompanyState } from './types'; + +export interface SpecialistPromptInputs { + /** Active agent id. Must exist in `COMPANY_AGENTS`. */ + agentId: string; + /** Current persisted company state (used for company name + context). */ + state: CompanyState; + /** Long-term agent memory text (may be empty). Pre-read by caller. */ + agentMemory?: string; + /** Tail of `_shared/decisions.md` (may be empty). */ + sharedDecisions?: string; + /** + * Peer outputs from earlier agents in *this* dispatch, in execution order. + * Truncated by the dispatcher before passing — this builder doesn't trim + * again so we don't double-pay tokens for one transformation. + */ + peerOutputs?: Array<{ agentId: string; agentName: string; emoji: string; content: string }>; +} + +/** + * Build the full system prompt for one specialist. Returns plain markdown. + * + * The structure favours *short headed sections* over one giant blob because + * smaller local models (≤7B) respect markdown-headed blocks better than + * dense paragraphs. Order matters: identity first, then rules, then context. + */ +export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string { + const agent = getCompanyAgent(inputs.agentId); + if (!agent) { + // Defensive fallback — should never happen because the dispatcher + // filters tasks against the active agent set before calling us. + return `You are an agent named "${inputs.agentId}". Respond in Korean.`; + } + const company = inputs.state.companyName || '1인 기업'; + const parts: string[] = []; + + // ── Identity ── + parts.push(`# ${agent.emoji} ${agent.name} — ${agent.role}`); + parts.push(`당신은 ${company}의 ${agent.role}입니다.`); + parts.push(`전문 분야: ${agent.specialty}`); + if (agent.persona) { + parts.push(''); + parts.push('## 페르소나'); + parts.push(agent.persona); + } + + // ── Output contract ── + parts.push(''); + parts.push('## 출력 규칙'); + parts.push('- 한국어 마크다운으로 답변. 사장님(사용자)에게 보고하는 톤.'); + parts.push('- 작업이 끝나면 마지막에 두 줄로 자기 평가를 붙이세요:'); + parts.push(' - `📊 평가:` 한 줄로 산출물의 가치(데이터 기반·완성도·아이디어 신선도).'); + parts.push(' - `📝 다음:` 사장님 입장에서 다음에 할 만한 한 가지 액션 한 줄.'); + parts.push('- 추측·일반론·placeholder 금지. 가진 정보만 인용.'); + + // ── Tool contract ── + // ConnectAI's existing AgentExecutor parses these tags automatically + // after the streaming response completes. Keeping the syntax identical + // means specialists can write files / run commands the same way the + // base chat already does — no new plumbing on the agent side. + parts.push(''); + parts.push('## 도구 사용 규칙 (필요할 때만)'); + parts.push('실제 파일 생성·명령 실행이 필요하면 ConnectAI의 액션 태그를 사용하세요.'); + parts.push('예) `내용`, `npm test` 등.'); + parts.push('태그 없이 평문으로만 답해도 됩니다 — 기획·분석·아이디어 작업은 보통 태그가 필요 없습니다.'); + + // ── Peer context (this turn) ── + const peers = inputs.peerOutputs ?? []; + if (peers.length > 0) { + parts.push(''); + parts.push('## 같은 세션의 동료 산출물'); + parts.push('아래는 당신보다 먼저 작업한 동료들의 결과입니다. 인용·참조해서 일관된 흐름을 만드세요.'); + for (const p of peers) { + parts.push(''); + parts.push(`### ${p.emoji} ${p.agentName}`); + parts.push(p.content); + } + } + + // ── Long-term memory ── + const memory = (inputs.agentMemory ?? '').trim(); + if (memory) { + parts.push(''); + parts.push('## 당신의 장기 기억 (memory.md)'); + parts.push('과거 작업에서 누적된 학습입니다. 지금 task와 충돌하면 *현재 task가 우선*입니다.'); + parts.push(memory); + } + + // ── Company-wide decisions ── + const decisions = (inputs.sharedDecisions ?? '').trim(); + if (decisions) { + parts.push(''); + parts.push('## 회사 공통 결정 사항 (decisions.md)'); + parts.push(decisions); + } + + return parts.join('\n'); +} + +/** + * Build the planner system prompt. The base template is in `promptAssets.ts`; + * this helper layers on the currently active agent list so the planner can't + * dispatch to a disabled specialist. + */ +export function buildPlannerSystemPrompt( + baseTemplate: string, + state: CompanyState, +): string { + const active = new Set(state.activeAgentIds); + active.add('ceo'); + const inactive = Object.keys(COMPANY_AGENTS).filter((id) => !active.has(id)); + const tail: string[] = []; + if (inactive.length > 0) { + tail.push(''); + tail.push('현재 비활성화된 에이전트 (절대 dispatch 금지):'); + for (const id of inactive) { + const def = COMPANY_AGENTS[id]; + tail.push(`- ${id} (${def?.name ?? id})`); + } + } + return baseTemplate + tail.join('\n'); +} diff --git a/src/features/company/prompts/ceo-chat.md b/src/features/company/prompts/ceo-chat.md new file mode 100644 index 0000000..ff94a3a --- /dev/null +++ b/src/features/company/prompts/ceo-chat.md @@ -0,0 +1,5 @@ +당신은 {{COMPANY}}의 CEO입니다. 사용자(사장님)와 짧게 인사·안부·잡담을 주고받습니다. +- 한국어로 1~3문장. 친근하지만 사장-CEO 관계는 유지. +- 인사·안부 질문이면 자연스럽게 응답하세요. 작업 지시가 아니면 굳이 작업 분배 제안 X. +- 회사 정체성·최근 결정·추적기 상태가 컨텍스트에 있으면 자연스럽게 활용. +- JSON 출력 금지. 그냥 평문으로 짧게. \ No newline at end of file diff --git a/src/features/company/prompts/ceo-planner.md b/src/features/company/prompts/ceo-planner.md new file mode 100644 index 0000000..f04f2a3 --- /dev/null +++ b/src/features/company/prompts/ceo-planner.md @@ -0,0 +1,39 @@ +당신은 "{{COMPANY}}"의 CEO입니다. 1인 AI 기업의 사령관이자 오케스트레이터입니다. + +당신의 팀(전문 에이전트): +- youtube (Head of YouTube) : 유튜브 채널 운영, 영상 기획, 트렌드, 썸네일 브리프 +- instagram (Head of Instagram) : 릴스/피드, 캡션, 해시태그, 게시 시간, 인게이지먼트 +- designer (Lead Designer) : 디자인 브리프, 썸네일·브랜드 비주얼, 컬러/타이포 +- developer (코다리 · 시니어 풀스택 엔지니어): 코드 작성·편집·디버깅, 자동화 스크립트, API 통합, 웹사이트, 테스트, git, 자기 검증 루프 (Claude Code 수준) +- business (Head of Business) : 수익화, 가격, 비즈니스 전략·분석, KPI +- secretary (Personal Assistant) : 일정·할 일, 작업 요약, 텔레그램 보고, 데일리 브리핑 +- editor (루나 · 사운드 감독) : BGM 자동 생성(MusicGen/ACE-Step), 사운드 디자인, 영상-음악 합성, 오디오 후처리 +- writer (Copywriter) : 카피라이팅, 영상 스크립트, 캡션, 블로그, 후크 +- researcher(Trend & Data Researcher) : 트렌드/경쟁사 리서치, 데이터 수집·요약, 사실 확인 + +사용자가 한 줄 명령을 내리면, 당신은 어떤 에이전트들을 어떤 순서로 동원할지 결정합니다. + +⚠️ 반드시 아래 JSON 형식으로만 출력하세요. 다른 텍스트(설명, ```json 펜스, 머리말, 꼬리말)는 절대 포함 금지. + +{ + "brief": "이번 작업이 무엇인지 2~3줄 한국어 요약", + "tasks": [ + {"agent": "youtube", "task": "구체적이고 실행 가능한 한국어 지시"} + ] +} + +🛑 **최소 동원 원칙 — 절대 위반 금지**: +1. **단순 데이터 조회·정보 확인 명령은 데이터 에이전트 1명만**. 예: "내 채널 분석", "구독자 수", "오늘 일정", "최근 영상" → tasks 배열에 1명. 추가 분석 에이전트(researcher/business/designer/writer) 절대 추가 금지. 사용자가 추가 분석을 *명시적으로* 요청해야만 추가. +2. **창작·기획 명령일 때만 multi-agent**. 예: "영상 기획해줘", "썸네일 만들어", "수익화 전략 짜줘" → 관련 에이전트 2~3명. 5명 이상 절대 금지. +3. **상관없는 에이전트 끌어오지 마라**. 사용자 명령이 유튜브 데이터인데 designer/writer 부르는 건 즉시 금지. 사용자가 "디자인"·"카피"·"썸네일" 같은 단어를 *직접* 썼을 때만. + +데이터 수집 키워드 매칭 (해당 에이전트만 1명): +- "유튜브"·"YouTube"·"내 채널"·"구독자"·"조회수"·"영상 분석" → youtube 1명만 +- "인스타"·"릴스"·"피드" → instagram 1명만 +- "캘린더"·"일정"·"오늘 미팅" → secretary 1명만 + +기타 규칙: +- 논리적 순서로 정렬 (예: 데이터 수집 → 분석 → 창작 — 사용자가 그 모두를 요청한 경우에만) +- 각 task는 모호함 없이 구체적·실행가능하게 +- JSON 외 텍스트는 단 한 글자도 출력 금지 +- 데이터 수집 없이 researcher/business만 호출하면 LLM이 가짜 분석을 출력합니다 — 절대 금지 \ No newline at end of file diff --git a/src/features/company/prompts/ceo-report.md b/src/features/company/prompts/ceo-report.md new file mode 100644 index 0000000..3c3e2d0 --- /dev/null +++ b/src/features/company/prompts/ceo-report.md @@ -0,0 +1,22 @@ +당신은 {{COMPANY}}의 CEO입니다. 방금 팀이 작업을 끝냈습니다. +각 에이전트의 산출물을 읽고 사장님께 올릴 종합 보고서를 작성하세요. + +형식 (한국어 마크다운, 정확히 이대로): + +## ✅ 완료된 작업 +- (에이전트별 핵심 산출물 1줄씩, 굵은 글씨로 에이전트명) + +## 🚀 다음 액션 (Top 3) +1. **(에이전트명)** — 무엇을 +2. **(에이전트명)** — 무엇을 +3. **(에이전트명)** — 무엇을 + +## 💡 인사이트 +- 이번 작업에서 발견한 핵심 통찰 1~2개 + +규칙: 간결, 사족 금지, 사과·면책 금지. 200자 이내가 이상적. + +⚠️ 데이터 우선 규칙 (반드시 준수): +- 산출물에 **실제 숫자/데이터**가 있으면(예: "조회수 중간값 49,931", "영상 6개", "구독자 1,234") **그 데이터를 직접 인용**해 보고하세요. 추상적인 "분석 진행됨" 같은 말로 대체 금지. +- 산출물에 `⚠️ LLM 호출 실패` 헤더가 있어도 그 안에 `📊 LLM 실패에도 시스템이 가져온 실데이터` 섹션이 있으면 **데이터는 살아있는 것**입니다. "데이터 로드 실패"로 오해해서 보고하지 마세요. LLM 분석은 못했지만 데이터는 확보했다고 정확히 표시. +- 추측·일반론·placeholder 절대 금지. 산출물에 없는 사실 만들어내지 마세요. \ No newline at end of file diff --git a/src/features/company/sessionStore.ts b/src/features/company/sessionStore.ts new file mode 100644 index 0000000..f8d8417 --- /dev/null +++ b/src/features/company/sessionStore.ts @@ -0,0 +1,231 @@ +/** + * Disk persistence for company-mode session artefacts. + * + * Each company turn produces a timestamped directory: + * + * /.astra/company/sessions/2026-05-13T21-29/ + * ├─ _brief.md ← CEO's task decomposition + * ├─ .md ← Each specialist's raw output + * ├─ _report.md ← CEO's final synthesis + * └─ _session.json ← Structured copy of the SessionResult + * + * Long-lived per-agent memory + shared decisions live one level up under + * `_agents//memory.md` and `_shared/decisions.md`. The store is + * intentionally dumb — markdown files, append-only — so the user can read, + * grep, or git-commit them by hand without any tooling. + * + * Path resolution: we always prefer the **workspace root**. When the user + * opens Astra without a workspace (very rare), we fall back to the + * extension's globalStorage path so the feature still works rather than + * silently swallowing writes. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { logError, logInfo } from '../../utils'; +import { + AgentTurnOutput, + COMPANY_AGENTS_REL, + COMPANY_SESSIONS_REL, + COMPANY_SHARED_REL, + CompanyTaskPlan, + SessionResult, +} from './types'; + +/** + * Resolve the base directory for company data. Falls back to globalStorage + * when no workspace is open so the mode still works in a fresh window. + */ +export function resolveCompanyBase(context: vscode.ExtensionContext): string { + const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (ws) return path.join(ws, '.astra', 'company'); + return path.join(context.globalStorageUri.fsPath, 'company'); +} + +function _ensureDir(p: string): void { + try { + fs.mkdirSync(p, { recursive: true }); + } catch (e: any) { + logError('company.sessionStore: mkdir failed.', { path: p, error: e?.message ?? String(e) }); + } +} + +/** + * Build a stable, filesystem-safe timestamp like `2026-05-13T21-29-04`. + * Colons and milliseconds are stripped so the value is portable across + * macOS / Linux / Windows. + */ +export function newSessionTimestamp(now: Date = new Date()): string { + const iso = now.toISOString(); // 2026-05-13T21:29:04.123Z + return iso.slice(0, 19).replace(/:/g, '-'); +} + +/** + * Create a new session directory and return its absolute path. The directory + * is empty — callers populate it via `writeBrief`, `writeAgentOutput`, etc. + */ +export function createSessionDir( + context: vscode.ExtensionContext, + timestamp: string, +): string { + const base = resolveCompanyBase(context); + const dir = path.join(base, 'sessions', timestamp); + _ensureDir(dir); + return dir; +} + +/** Write the CEO planner's brief (`_brief.md`). */ +export function writeBrief( + sessionDir: string, + userPrompt: string, + plan: CompanyTaskPlan, +): void { + const lines = [ + `# Brief — ${path.basename(sessionDir)}`, + '', + '## User Prompt', + userPrompt.trim() || '_(empty)_', + '', + '## Summary', + plan.brief.trim() || '_(no brief)_', + '', + '## Dispatched Tasks', + ]; + if (plan.tasks.length === 0) { + lines.push('_(no tasks — CEO decided no dispatch was necessary)_'); + } else { + for (const [i, t] of plan.tasks.entries()) { + lines.push(`${i + 1}. **${t.agent}** — ${t.task}`); + } + } + fs.writeFileSync(path.join(sessionDir, '_brief.md'), lines.join('\n'), 'utf8'); +} + +/** Write a single specialist's output to `.md`. */ +export function writeAgentOutput(sessionDir: string, output: AgentTurnOutput): void { + const lines = [ + `# ${output.agentId} — ${path.basename(sessionDir)}`, + '', + `**Task:** ${output.task}`, + `**Duration:** ${(output.durationMs / 1000).toFixed(1)}s`, + output.error ? `**Error:** ${output.error}` : '', + '', + '---', + '', + output.response, + '', + ].filter((l) => l !== ''); + fs.writeFileSync(path.join(sessionDir, `${output.agentId}.md`), lines.join('\n'), 'utf8'); +} + +/** Write the CEO's final synthesis to `_report.md`. */ +export function writeReport(sessionDir: string, report: string): void { + const header = `# Report — ${path.basename(sessionDir)}\n\n`; + fs.writeFileSync(path.join(sessionDir, '_report.md'), header + report.trim() + '\n', 'utf8'); +} + +/** + * Write a machine-readable copy of the whole turn for tooling (debugging, + * replays, future analytics). Keeps the markdown files the source of truth + * for the user — the JSON is just a convenience for code that reads it back. + */ +export function writeSessionJson(sessionDir: string, result: SessionResult): void { + const cloned: SessionResult = { + ...result, + // Drop the absolute sessionDir from the JSON so the file is portable + // across machines — it's already implicit (its own directory). + sessionDir: path.basename(sessionDir), + }; + fs.writeFileSync(path.join(sessionDir, '_session.json'), JSON.stringify(cloned, null, 2), 'utf8'); +} + +// ── Long-lived per-agent memory + shared decisions ───────────────────────── + +function _agentMemoryPath(context: vscode.ExtensionContext, agentId: string): string { + return path.join(resolveCompanyBase(context), '_agents', agentId, 'memory.md'); +} + +/** + * Append a short note to `/memory.md`. Memory accumulates over time; + * the dispatcher reads it back as part of the specialist's system prompt so + * agents "remember" past work. Best-effort — failures are logged but never + * abort the turn. + */ +export function appendAgentMemory( + context: vscode.ExtensionContext, + agentId: string, + note: string, +): void { + if (!note.trim()) return; + const memPath = _agentMemoryPath(context, agentId); + try { + _ensureDir(path.dirname(memPath)); + const stamp = new Date().toISOString(); + const block = `\n\n## ${stamp}\n${note.trim()}\n`; + fs.appendFileSync(memPath, block, 'utf8'); + } catch (e: any) { + logError('company.sessionStore: agent memory append failed.', { + agentId, error: e?.message ?? String(e), + }); + } +} + +/** Read `/memory.md` (or empty string if missing). */ +export function readAgentMemory(context: vscode.ExtensionContext, agentId: string): string { + const memPath = _agentMemoryPath(context, agentId); + if (!fs.existsSync(memPath)) return ''; + try { return fs.readFileSync(memPath, 'utf8'); } catch { return ''; } +} + +function _sharedPath(context: vscode.ExtensionContext, fileName: string): string { + return path.join(resolveCompanyBase(context), '_shared', fileName); +} + +/** Append a decision/learning to `_shared/decisions.md`. */ +export function appendDecision(context: vscode.ExtensionContext, decision: string): void { + if (!decision.trim()) return; + const p = _sharedPath(context, 'decisions.md'); + try { + _ensureDir(path.dirname(p)); + const stamp = new Date().toISOString(); + fs.appendFileSync(p, `- ${stamp} — ${decision.trim()}\n`, 'utf8'); + } catch (e: any) { + logError('company.sessionStore: decisions append failed.', { error: e?.message ?? String(e) }); + } +} + +/** Read `_shared/decisions.md` (or empty string). Trimmed to the last N chars. */ +export function readDecisions(context: vscode.ExtensionContext, maxChars: number = 2000): string { + const p = _sharedPath(context, 'decisions.md'); + if (!fs.existsSync(p)) return ''; + try { + const raw = fs.readFileSync(p, 'utf8'); + return raw.length > maxChars ? '…' + raw.slice(-maxChars) : raw; + } catch { return ''; } +} + +/** List existing session directories, newest first. */ +export function listSessions(context: vscode.ExtensionContext): string[] { + const dir = path.join(resolveCompanyBase(context), 'sessions'); + if (!fs.existsSync(dir)) return []; + try { + return fs.readdirSync(dir) + .filter((name) => fs.statSync(path.join(dir, name)).isDirectory()) + .sort() + .reverse(); + } catch (e: any) { + logError('company.sessionStore: list failed.', { error: e?.message ?? String(e) }); + return []; + } +} + +/** Convenience used by the chip after a turn finishes. */ +export function logSessionCreated(sessionDir: string, agentCount: number): void { + logInfo('company.sessionStore: session created.', { + dir: path.basename(sessionDir), + agents: agentCount, + }); +} + +// Re-export path constants for callers that need to namespace under the same dirs. +export { COMPANY_AGENTS_REL, COMPANY_SESSIONS_REL, COMPANY_SHARED_REL }; diff --git a/src/features/company/types.ts b/src/features/company/types.ts new file mode 100644 index 0000000..643a035 --- /dev/null +++ b/src/features/company/types.ts @@ -0,0 +1,108 @@ +/** + * Type definitions for the 1인 기업 (One-Person Company) mode. + * + * The mode turns the user into a virtual CEO that dispatches work to a roster + * of specialist agents. Each turn produces a session directory containing the + * CEO's brief, every specialist's output, and the final synthesis report. The + * dispatcher runs agents *sequentially* — only one LLM is loaded at any + * moment — so the user can run multiple distinct agents on a single + * model-constrained machine without RAM thrash. + */ + +/** Static description of a company agent. Loaded from `agents.ts`. */ +export interface CompanyAgentDef { + /** Stable identifier used in JSON plans, file names, config keys. */ + id: string; + /** Display name (may be a Korean nickname like "레오" or "코다리"). */ + name: string; + /** Role title shown in the manage panel and used in system prompts. */ + role: string; + /** Single emoji used in chat headers and chip badges. */ + emoji: string; + /** Brand colour for the agent card UI. CSS hex. */ + color: string; + /** Comma-list of areas this agent owns. Drives the CEO's planner. */ + specialty: string; + /** One-line punchy tagline shown under the agent name. */ + tagline: string; + /** Optional voice / personality directive injected into the system prompt. */ + persona?: string; + /** + * When true, this agent can't be toggled off in the UI. CEO uses this so + * it's always available as the orchestrator. + */ + alwaysOn?: boolean; +} + +/** + * Persisted runtime state for the company mode. Stored in VS Code's + * `globalState` plus a small JSON file under `.astra/company/_shared/`. + */ +export interface CompanyState { + /** When false, the chip is shown but prompts route through normal chat. */ + enabled: boolean; + /** User-facing name surfaced in CEO prompts and the chip badge. */ + companyName: string; + /** Agents the user has toggled on. CEO is implicitly included. */ + activeAgentIds: string[]; + /** + * Optional per-agent model override. Empty string / missing key means + * "use the global default model". When the user assigns *different* + * models to two agents, the LM Studio lifecycle manager unloads one and + * loads the other between dispatches — RAM holds exactly one model at a + * time, by design. + */ + modelOverrides: Record; +} + +/** Output of the CEO planner LLM call after JSON parsing. */ +export interface CompanyTaskPlan { + /** 2-3 sentence Korean summary of what the company is going to do. */ + brief: string; + /** Ordered list of agent dispatches. Order is execution order. */ + tasks: Array<{ + /** Agent id (must exist in `AGENTS` and be active). */ + agent: string; + /** Concrete, actionable instruction for the specialist. */ + task: string; + }>; +} + +/** One agent's contribution to a turn. */ +export interface AgentTurnOutput { + agentId: string; + task: string; + /** Raw LLM output, before action-tag execution. */ + response: string; + /** Wall-clock milliseconds spent on this dispatch (LLM + tools). */ + durationMs: number; + /** Populated when the dispatch failed; `response` then holds the error. */ + error?: string; +} + +/** The whole result of a company turn — persisted under sessions//. */ +export interface SessionResult { + /** ISO timestamp used as the session directory name. */ + timestamp: string; + /** Absolute filesystem path of the session directory. */ + sessionDir: string; + /** What the user typed. */ + userPrompt: string; + /** The CEO's plan that drove this turn. */ + plan: CompanyTaskPlan; + /** Per-agent outputs, in execution order. */ + agentOutputs: AgentTurnOutput[]; + /** CEO's final synthesis. Empty when the synthesis call failed. */ + report: string; + /** Walls-clock milliseconds from prompt arrival to report emission. */ + totalDurationMs: number; +} + +/** Where on disk the company state lives, relative to the workspace root. */ +export const COMPANY_DIR_REL = '.astra/company'; +export const COMPANY_SHARED_REL = `${COMPANY_DIR_REL}/_shared`; +export const COMPANY_SESSIONS_REL = `${COMPANY_DIR_REL}/sessions`; +export const COMPANY_AGENTS_REL = `${COMPANY_DIR_REL}/_agents`; + +/** State-key namespaces used in VS Code's globalState. */ +export const COMPANY_STATE_KEY = 'g1nation.company.state'; diff --git a/src/sidebar/chatHandlers.ts b/src/sidebar/chatHandlers.ts index 33697cf..a99798d 100644 --- a/src/sidebar/chatHandlers.ts +++ b/src/sidebar/chatHandlers.ts @@ -18,6 +18,15 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any case 'promptWithFile': provider._lmStudio?.activity.bump(); await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, false); + // ── 1인 기업 모드 우선 분기 ── + // When company mode is active, route the prompt through the + // CEO planner / sequential dispatcher / synthesis pipeline + // instead of the normal single-agent path. The user-facing + // chat surface is the same — only the runtime differs. + if (provider.isCompanyModeEnabled() && typeof data.value === 'string' && data.value.trim()) { + await provider._runCompanyTurn(data.value.trim()); + return true; + } await provider._handlePrompt(data); await provider._autoWriteChronicleAfterPrompt(); await provider._saveCurrentSession(); @@ -36,6 +45,9 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any // Restore the Project Architecture chip + watcher if the active project // was already running in architecture mode in a previous VS Code session. await provider._sendArchitectureStatus(); + // Restore the Company chip from globalState so the user sees the same + // mode they had on at last shutdown. + await provider._sendCompanyStatus(); return true; case 'getReadyStatus': await provider._sendReadyStatus(); @@ -147,6 +159,45 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any } return true; } + // ── 1인 기업 모드 메시지 라우팅 ──────────────────────────────────── + case 'getCompanyStatus': + await provider._sendCompanyStatus(); + return true; + case 'getCompanyAgents': + await provider._sendCompanyAgents(); + return true; + case 'setCompanyEnabled': { + const { setCompanyEnabled } = await import('../features/company'); + await setCompanyEnabled(provider._context, !!data.value); + await provider._sendCompanyStatus(); + return true; + } + case 'setCompanyName': { + const { setCompanyName } = await import('../features/company'); + await setCompanyName(provider._context, typeof data.value === 'string' ? data.value : ''); + await provider._sendCompanyStatus(); + return true; + } + case 'setCompanyActiveAgents': { + const { setActiveAgents } = await import('../features/company'); + const ids = Array.isArray(data.value) + ? data.value.filter((v: unknown): v is string => typeof v === 'string') + : []; + await setActiveAgents(provider._context, ids); + await provider._sendCompanyStatus(); + await provider._sendCompanyAgents(); + return true; + } + case 'setCompanyAgentModel': { + const { setAgentModelOverride } = await import('../features/company'); + const agentId = typeof data.agentId === 'string' ? data.agentId : ''; + const model = typeof data.model === 'string' ? data.model : ''; + if (agentId) { + await setAgentModelOverride(provider._context, agentId, model); + await provider._sendCompanyAgents(); + } + return true; + } case 'proactiveTrigger': await provider._handleProactiveSuggestion(data.context); return true; diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts index 6f68330..546a763 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -34,6 +34,15 @@ import { scanProject, } from './features/projectArchitecture'; import { detectProjectIntent, KnownProject } from './features/projectArchitecture/intentDetector'; +import { + readCompanyState, + runCompanyTurn, + summarizeForChip, + CompanyTurnEvent, + COMPANY_AGENTS, + COMPANY_AGENT_ORDER, +} from './features/company'; +import { AIService } from './core/services'; export interface SidebarLmStudioDeps { lifecycle: ModelLifecycleManager; @@ -1177,6 +1186,94 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn }); } + // ─── 1인 기업 (Company) Mode ──────────────────────────────────────────── + // + // When `companyState.enabled` is true, prompts coming through the chat + // handler are routed to `_runCompanyTurn` instead of the normal + // AgentExecutor path. The dispatcher emits `companyTurnUpdate` events as + // each phase progresses; the webview shows a step-by-step header for + // CEO planning, each specialist's dispatch, and the final synthesis. + + /** True iff company mode is active. Cheap — read from globalState. */ + isCompanyModeEnabled(): boolean { + return readCompanyState(this._context).enabled; + } + + /** Send the chip state (active flag + agent count + name) to the webview. */ + async _sendCompanyStatus(): Promise { + if (!this._view) return; + const state = readCompanyState(this._context); + this._view.webview.postMessage({ + type: 'companyStatus', + value: { + enabled: state.enabled, + companyName: state.companyName, + summary: summarizeForChip(state), + activeAgentIds: state.activeAgentIds, + modelOverrides: state.modelOverrides, + }, + }); + } + + /** Push the full agent catalogue when the manage panel opens. */ + async _sendCompanyAgents(): Promise { + if (!this._view) return; + const state = readCompanyState(this._context); + const agents = COMPANY_AGENT_ORDER.map((id) => { + const def = COMPANY_AGENTS[id]; + return { + id, + name: def.name, + role: def.role, + emoji: def.emoji, + color: def.color, + tagline: def.tagline, + specialty: def.specialty, + hasPersona: !!def.persona, + alwaysOn: !!def.alwaysOn, + active: id === 'ceo' || state.activeAgentIds.includes(id), + modelOverride: state.modelOverrides[id] || '', + }; + }); + this._view.webview.postMessage({ + type: 'companyAgents', + value: { + companyName: state.companyName, + agents, + }, + }); + } + + /** + * Drive one full company turn. Caller is the chat handler; it's already + * persisted the user message and started a streaming bubble. We feed + * progress events back as `companyTurnUpdate` messages so the same bubble + * fills in as each agent finishes. + */ + async _runCompanyTurn(userPrompt: string): Promise { + const cfg = getConfig(); + const ai = new AIService(); + const emit = (event: CompanyTurnEvent) => { + this._view?.webview.postMessage({ type: 'companyTurnUpdate', value: event }); + }; + try { + await runCompanyTurn(userPrompt, { + context: this._context, + ai, + defaultModel: cfg.defaultModel || 'gemma4:e2b', + onEvent: emit, + }); + } catch (e: any) { + logError('company.runTurn: unexpected failure.', { error: e?.message ?? String(e) }); + this._view?.webview.postMessage({ + type: 'error', + value: `1인 기업 모드 실행 실패: ${e?.message ?? e}`, + }); + } finally { + void this._sendReadyStatus(); + } + } + /** Open the architecture doc in editor group 2. */ async _openArchitectureDoc(): Promise { const p = this._getActiveChronicleProject();