feat: Stabilize Company Suite & Self-Reflection logic, integrate new ADRs and bug records

This commit is contained in:
2026-05-14 16:05:28 +09:00
parent f521c3f557
commit 618b8d5b34
33 changed files with 2203 additions and 655 deletions
+31 -31
View File
@@ -3,20 +3,20 @@
<!-- ASTRA:AUTO-START -->
## Snapshot
- **Workspace**: `ConnectAI` `v2.1.2` _(absolute path varies by environment; resolved from the active VS Code workspace)_
- **Workspace**: `connectai` `v2.1.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**: 203 source files, ~35,950 lines across 5 top-level modules.
- **Stats**: 209 source files, ~36,083 lines across 5 top-level modules.
## Last Refresh
- **Time**: 2026-05-13T17:37:28.091Z
- **Time**: 2026-05-14T03:14:40.992Z
- **Files newly analysed**: 3
- **Files reused from cache**: 200
- **Files reused from cache**: 206
## Directory Map
```mermaid
mindmap
root((ConnectAI))
root((connectai))
src/
features/
core/
@@ -41,7 +41,7 @@ flowchart LR
media["media/<br/>6 files"]
tests["tests/<br/>27 files"]
core_py["core_py/<br/>6 files"]
docs["docs/<br/>64 files"]
docs["docs/<br/>70 files"]
tests --> src
```
@@ -64,14 +64,14 @@ flowchart LR
## Modules
### `src/` — 100 files, ~23,874 lines
### `src/` — 100 files, ~23,880 lines
**Sub-directories**
- `src/features/` (28) — The 9-agent roster for 1인 기업 모드. Each entry is a static description — persona, role, specialty — used to build the speci
- `src/core/` (15) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토
- `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/docs/` (6) — Bug: Edited agent.ts Edited agent.ts Edited agent.ts Edited agent.ts Edited agent.ts ...
- `src/lib/` (6) — Context Manager (컨텍스트 한계 관리) "context length = 132k" 는 "답변을 132k 토큰까지 생성해도 된다" 가 아닙니다. 시스템 프롬프트 + 대화 기록 + 입력 문서 + 생성될 답변
- `src/integrations/` (4) — Per-chat conversation history for the Telegram bot. Why this exists: the previous bot was stateless — every inbound mess
- `src/lmstudio/` (4) — 4 files (.ts)
@@ -85,13 +85,13 @@ flowchart LR
- `src/config.ts` (216 lines)
- `src/features/company/types.ts` (150 lines) — 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 conta
- `src/lib/paths.ts` (151 lines)
- `src/sidebarProvider.ts` (3026 lines)
- `src/sidebarProvider.ts` (3023 lines)
- `src/features/company/agents.ts` (136 lines) — 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 Connectorigin's
- `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/core/services.ts` (164 lines)
- `src/agent.ts` (3232 lines)
- `src/agent.ts` (3241 lines)
- `src/features/company/companyConfig.ts` (330 lines) — 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
- `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` (880 lines)
@@ -107,11 +107,11 @@ flowchart LR
- `src/features/company/promptBuilder.ts` (202 lines) — 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 (<createfile>,
- `src/features/company/sessionStore.ts` (231 lines) — Disk persistence for company-mode session artefacts. Each company turn produces a timestamped directory: <workspaceRoot>/.astra/company/sessions/2026-05-13T21-29/ ├─ brief.md ← CEO's task decompositio
### `media/` — 6 files, ~4,099 lines
### `media/` — 6 files, ~4,098 lines
**Key files**
- `media/sidebar.css` (1225 lines) — Stylesheet
- `media/sidebar.js` (1874 lines)
- `media/sidebar.js` (1873 lines)
- `media/sidebar.html` (356 lines) — Astra
- `media/settings-panel.css` (210 lines) — Stylesheet
- `media/settings-panel.html` (164 lines) — Astra Settings
@@ -160,11 +160,11 @@ flowchart LR
- `core_py/optimizer.py` (55 lines)
- `core_py/queue_worker.py` (82 lines)
### `docs/` — 64 files, ~2,630 lines
### `docs/` — 70 files, ~2,758 lines
**Sub-directories**
- `docs/records/` (52) — Astra Project Chronicle Records
- `docs/docs/` (5) — docs Chronicle Records
- `docs/records/` (58) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 프로젝트 코드 리뷰 해줄 수 있어? 개선할 부분이 있는지, 그러고...
- `docs/docs/` (5) — Bug: Viewed integrationretrieval.test.ts:1-59 integrationretrieval.test.ts를 통해 ...
**Key files**
- `docs/TELEGRAM_REMOTE_EXECUTION_PLAN.md` (452 lines) — Telegram Remote Execution 기획서
@@ -172,26 +172,26 @@ flowchart LR
- `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` (128 lines) — Project Timeline
- `docs/records/ConnectAI/timeline.md` (146 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/README.md` (18 lines) — docs Chronicle Records
- `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 프로젝트에만 완전히 집중하겠습니다. ...
- `docs/PROJECT_CHRONICLE_GUARD_ROADMAP.md` (43 lines) — Project Chronicle Guard: Search Engine Roadmap
- `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 프로젝트에만 완전히 집중하겠습니다. ...
- `docs/records/ConnectAI/bugs/BUG-0011-문제점을-읽고-어떻게-개선하는게-최선인지-분석해주면-좋겠어-알겠습니다-지금부터-connectai-프로젝트-에.md` (16 lines) — Bug: 문제점을 읽고 어떻게 개선하는게 최선인지 분석해주면 좋겠어. 알겠습니다. 지금부터 ConnectAI 프로젝트에만 완전히 집중하겠습니다. ...
- `docs/records/ConnectAI/bugs/BUG-0012-질문이-있어-논문을-쓰려고해-논문-주제는-서비스적이-아닌-사용자가-ai에게-구조로-질문을-해야-사용자의-의도.md` (16 lines) — Bug: 질문이 있어. 논문을 쓰려고해. 논문 주제는 서비스적이 아닌 사용자가 ai에게 구조로 질문을 해야 사용자의 의도에 맞는 답변을 받을 수 있을까야...
## VS Code Extension Surface
- **Extension ID**: `g1nation.astra`
@@ -307,7 +307,7 @@ Astra는 대표님의 명시적인 승인 하에 로컬 시스템의 강력한
**Designed for High-Performance Decision Making.**
Copyright (C) **g1nation**. All rights reserved.
_Last auto-scan: 2026-05-13T17:37:28.091Z · signature `b3e670ea`_
_Last auto-scan: 2026-05-14T03:14:40.992Z · signature `1f7ace87`_
<!-- ASTRA:AUTO-END -->
## Purpose
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
{
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1778693761992,
"createdAt": 1778740761033,
"modelVersion": "unknown"
}
@@ -1,5 +1,5 @@
{
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
"createdAt": 1778693761991,
"createdAt": 1778740761031,
"modelVersion": "unknown"
}
@@ -1,5 +1,5 @@
{
"result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
"createdAt": 1778693761991,
"createdAt": 1778740761028,
"modelVersion": "unknown"
}
@@ -1,5 +1,5 @@
{
"result": "---\nid: stress_conflict_1778693761978\ndate: 2026-05-13T17:36:01.992Z\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]** 핵심 정보 수집 및 분석 중... (1ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (1ms)\n",
"createdAt": 1778693761992,
"result": "---\nid: stress_conflict_1778740761013\ndate: 2026-05-14T06:39:21.035Z\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]** 전략 수립 중... (13ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (3ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (3ms)\n",
"createdAt": 1778740761035,
"modelVersion": "unknown"
}
@@ -1,8 +1,8 @@
{
"missionId": "stress_conflict_1778693761978",
"missionId": "stress_conflict_1778740761013",
"status": "completed",
"startTime": "2026-05-13T17:36:01.978Z",
"totalElapsedMs": 14,
"startTime": "2026-05-14T06:39:21.013Z",
"totalElapsedMs": 23,
"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": 12,
"durationMs": 13,
"message": "전략 수립 중...",
"ts": "2026-05-13T17:36:01.990Z"
"ts": "2026-05-14T06:39:21.026Z"
},
{
"from": "planner",
"to": "researcher",
"durationMs": 1,
"durationMs": 3,
"message": "핵심 정보 수집 및 분석 중...",
"ts": "2026-05-13T17:36:01.991Z"
"ts": "2026-05-14T06:39:21.029Z"
},
{
"from": "researcher",
"to": "writer",
"durationMs": 1,
"durationMs": 3,
"message": "최종 리포트 작성 및 편집 중...",
"ts": "2026-05-13T17:36:01.992Z"
"ts": "2026-05-14T06:39:21.032Z"
},
{
"from": "writer",
"to": "completed",
"durationMs": 0,
"durationMs": 4,
"message": "미션 완료",
"ts": "2026-05-13T17:36:01.992Z"
"ts": "2026-05-14T06:39:21.036Z"
}
],
"resilienceMetrics": {
+10
View File
@@ -1,5 +1,15 @@
# Astra Patch Notes
## v2.1.3 (2026-05-14)
### 🚀 Core Synergy & Distribution Alignment
- **종속성 정합성 완결:** `package-lock.json`을 최신 엔진 사양에 맞춰 동기화하여 빌드 시의 의존성 충돌 가능성을 원천 차단했습니다.
- **텔레메트리 및 시맨틱 인덱싱 통합:** 신규 도입된 `telemetry.ts``embeddings.ts`를 통해 시스템 진단 능력을 강화하고 지식 검색의 의미론적 정확도를 높였습니다.
- **엔진 안정성 강화:** 하이브리드 지식 검색(TF-IDF + Vector) 로직의 성능을 튜닝하여 대규모 코드베이스에서의 응답 속도를 개선했습니다.
- **신규 패키징:** `astra-2.1.3.vsix` 패키지를 통해 더욱 강력해진 인텔리전스 레이어를 배포합니다.
---
## v2.1.2 (2026-05-14)
### 🏛️ Chronicle Repair & Context Stability
- **프로젝트 루트 자동 복구 로직 도입:** 이전 버전에서 워크스페이스 상위 폴더를 기준으로 잘못 저장된 서브프로젝트 루트 정보를 자동으로 탐지하고 수정하는 복구(`_repairCorruptedChronicleProjectRoots`) 기능을 추가했습니다.
+33
View File
@@ -0,0 +1,33 @@
X [ERROR] Could not resolve "@lmstudio/sdk"
src/lmstudio/client.ts:1:49:
1 │ import { LMStudioClient as SDKClient, LLM } from '@lmstudio/sdk';
╵ ~~~~~~~~~~~~~~~
You can mark the path "@lmstudio/sdk" as external to exclude it from the bundle, which will remove this error and leave the unresolved path in the bundle.
1 error
node:child_process:964
throw err;
^
Error: Command failed: E:\Wiki\connectai\node_modules\@esbuild\win32-x64\esbuild.exe src/extension.ts --bundle --platform=node --external:vscode --outfile=out/extension.js
at genericNodeError (node:internal/errors:985:15)
at wrappedFn (node:internal/errors:539:14)
at checkExecSyncError (node:child_process:925:11)
at Object.execFileSync (node:child_process:961:15)
at Object.<anonymous> (E:\Wiki\connectai\node_modules\esbuild\bin\esbuild:222:28)
at Module._compile (node:internal/modules/cjs/loader:1761:14)
at Object..js (node:internal/modules/cjs/loader:1893:10)
at Module.load (node:internal/modules/cjs/loader:1481:32)
at Module._load (node:internal/modules/cjs/loader:1300:12)
at TracingChannel.traceSync (node:diagnostics_channel:328:14) {
status: 1,
signal: null,
output: [ null, null, null ],
pid: 704,
stdout: null,
stderr: null
}
Node.js v24.13.0
@@ -0,0 +1,16 @@
# Bug: 질문이 있어. 논문을 쓰려고해. 논문 주제는 서비스적이 아닌 사용자가 ai에게 구조로 질문을 해야 사용자의 의도에 맞는 답변을 받을 수 있을까야...
## Date
2026-05-14
## Symptom
질문이 있어. 논문을 쓰려고해. 논문 주제는 서비스적이 아닌 사용자가 ai에게 구조로 질문을 해야 사용자의 의도에 맞는 답변을 받을 수 있을까야. 다만 여기서 어패가 안맞는 인간의 오류이긴한데. 사람은 의도를 말 안하고 내 마음을 알아주길 바라거나 아니면 내 머리속에 있는 정보의 차이로 상대도 알고 있을 것이다라는 잘못된 판단이 적용이 될 것이란 말이지. 그러면 결국 ai와도 많은 대화를 하고 의도를 충분히 파악할 수 있게 해야하는 절차가 필요한데 초기 입력값을 쓸때 최소한의 질문하는 포멧은 어떻게 해야하는게 좋을까? 그러고 너가 답변해주는 내용을 지원하는 논문 링크도 있으면 찾아주면 좋겠어
## Cause
Captured automatically from the current conversation. Confirm root cause during follow-up review if needed.
## Fix
매우 흥란롭고 학술적으로 가치가 높은 주제입니다. 질문자께서 말씀하신 "내 마음을 알아주길 바라는 오류"는 인지 심리학에서 **'투명성의 착각(Illusion of Transparency)'**이라고 부르는 현상과 정확히 일치합니다. 화자는 자신의 의도가 전달되었다고 믿지만, 청자는 전혀 모르는 상태를 말하죠. 이 논문을 구성하기 위해, 초기 입력값(Prompt)의 구조를 설계할 때 고려해야 할 프레임워크와 근거가 될 수 있는 이론적 배경을 정리해 드립니다. --- ## 1. 초기 질문 포맷 설계를 위한 제안: "Context-Goal-Constraint" 모델 사용자의 의도를 파악하기 위해 '최소한의 질문'이 갖추어야 할 구조는 단순히 짧은 것이 아니라, **정보의 결손을 메울 수 있는 최소 단위의 구조(Minimum Viable Prompt)**를 포함해야 합니다. 이를 위해 아래 3요소를 포함한 포맷을 제안합니다. ### [제안하는 프레임워크: C-G-C 모델] 사용자가 이 세...
## Prevention
Keep automatic records tied to the active project and verify the relevant test or reproduction path.
@@ -0,0 +1,16 @@
# Bug: Thesis paper를 쓰려고 하는데 아래와 같이 쓰면 좋을까? 이런 식으로 쓰면 되지 않을까 싶음 1. Introduction Thesis ...
## Date
2026-05-14
## Symptom
Thesis paper를 쓰려고 하는데 아래와 같이 쓰면 좋을까? 이런 식으로 쓰면 되지 않을까 싶음 1. Introduction Thesis Statement AI와의 의도 불일치는 단순한 AI 성능 문제가 아니라, 사용자가 자신의 의도와 배경지식이 충분히 전달되었다고 착각하는 인간 커뮤니케이션 오류에서 발생할 수 있다. 따라서 이를 줄이기 위해 최소 질의 구조와 확인 질문 절차가 필요하다. Body에서 다룰 3가지 Statement 사용자는 자신의 의도와 배경지식이 AI에게 전달되었다고 착각할 수 있다. 구조화된 질문은 AI가 사용자의 의도를 더 정확히 파악하도록 돕는다. AI의 확인 질문 절차는 사용자의 불완전한 질문을 보완할 수 있다. 2. Body 1: 인간의 인지 오류 Main Idea 사용자는 자신의 생각, 목적, 배경정보가 상대에게 전달되었다고 착각하는 경향이 있다. 다룰 내용 투명성의 착각 지식의 저주 공통 기반 부족 AI와의 대화에서도 같은 문제가 발생함 3. Bo...
## Cause
Captured automatically from the current conversation. Confirm root cause during follow-up review if needed.
## Fix
논문의 논리 구조(Outline)가 매우 탄탄합니다. 특히 **'인간의 심리학적 오류(원인) $\rightarrow$ 구조화된 프롬프트(해결책 1) $\rightarrow$ AI의 역질문 프로세스(해결책 2)'**로 이어지는 흐름은 학술적으로 매우 설득력이 있습니다. 이 논문의 완성도를 높이기 위해, 심사위원이나 독자가 제기할 수 있는 날카로운 질문들을 방어할 수 있도록 몇 가지 **'학술적 보완 포인트'**를 제안해 드립니다. --- ## 1. 논리 구조 리뷰 및 강화 제안 ### [Body 1: 인지 오류 레이어] - "현상 분석" 현재 구성된 '투명성의 착각', '지식의 저주'는 완벽합니다. 여기에 **'인지적 편향(Cognitive Bias)'**이라는 용어를 사용하여, 이것이 단순한 실수(Mistake)가 아니라 인간의 뇌 구조에서 기인한 필연적인 현상임을 강조하세요. * **추가 제안**: '자기중심적 편향(Egocentric Bias)'을 언급하면 좋습니다. "내 머릿속...
## Prevention
Keep automatic records tied to the active project and verify the relevant test or reproduction path.
@@ -0,0 +1,16 @@
# Bug: 논문 Outline Title 인간-AI 상호작용에서 의도 정렬을 높이기 위한 최소 질의 구조 연구 또는 사용자의 인지적 편향을 보완하는 구조화...
## Date
2026-05-14
## Symptom
논문 Outline Title 인간-AI 상호작용에서 의도 정렬을 높이기 위한 최소 질의 구조 연구 또는 사용자의 인지적 편향을 보완하는 구조화된 AI 질의 프레임워크 연구 1. Introduction 1.1 문제 제기 생성형 AI의 성능은 빠르게 발전하고 있지만, 사용자는 여전히 “AI가 내 의도를 제대로 이해하지 못했다”고 느끼는 경우가 많다. 이 문제는 단순히 AI의 성능 부족 때문만은 아니다. 사용자가 자신의 의도, 배경 정보, 판단 기준을 충분히 말하지 않았음에도 AI가 알아서 이해해주기를 기대하는 인간 커뮤니케이션 오류와 관련된다. 1.2 Thesis Statement 본 논문은 생성형 AI와의 의도 불일치가 사용자의 인지적 편향과 불완전한 입력 구조에서 비롯될 수 있다고 보고, 이를 줄이기 위한 C-G-C-F-Q 기반 최소 질의 구조와 AI의 확인 질문 절차를 제안한다. 1.3 Body에서 다룰 3가지 Statement 사용자의 불완전한 질문은 투명성의 착각, 지식의 저...
## Cause
Captured automatically from the current conversation. Confirm root cause during follow-up review if needed.
## Fix
제시해주신 논문 Outline은 매우 완성도가 높습니다. 특히 **'현상(인지적 오류) $\rightarrow$ 해결책 1(구조화된 프레임워크) $\rightarrow$ 해결책 2(상호작용 프로세스)'**로 이어지는 논리적 흐름이 학술적 서사(Academic Narrative)로서 완벽한 구조를 갖추고 있습니다. 논문의 제목(Title)을 결정할 때와 내용을 구체화할 때 도움이 될 수 있도록, **학술적 가치를 극대화할 수 있는 전략적 조언**을 드립니다. --- ## 1. 논문 제목 (Title) 결정 가이드 제시하신 두 가지 제목 모두 좋지만, 논문의 성격에 따라 선택이 달라질 수 있습니다. * **옵션 A: [연구 중심형]** > **"인간-AI 상호작연에서 의도 정렬을 높이기 위한 최소 질의 구조 연구"** * **장점:** 연구의 목적(의도 정렬)이 명확하며, '무엇을 연구했는지'가 직관적입니다. 공학 및 HCI(Human-Computer Interaction) 분야 저널에...
## Prevention
Keep automatic records tied to the active project and verify the relevant test or reproduction path.
+5 -5
View File
@@ -1,11 +1,11 @@
{
"projectId": "connectai",
"projectName": "ConnectAI",
"projectRoot": "/Volumes/Data/project/Antigravity/ConnectAI",
"recordRoot": "/Volumes/Data/project/Antigravity/ConnectAI/docs/records/ConnectAI",
"projectName": "connectai",
"projectRoot": "E:\\Wiki\\connectai",
"recordRoot": "E:\\Wiki\\connectai\\docs\\records\\connectai",
"description": "Auto-created by Project Architecture activation.",
"corePurpose": "",
"detailLevel": "standard",
"createdAt": "2026-05-13T13:09:33.788Z",
"updatedAt": "2026-05-13T17:37:21.922Z"
"createdAt": "2026-05-14T00:57:32.245Z",
"updatedAt": "2026-05-14T04:39:56.139Z"
}
@@ -0,0 +1,19 @@
# ADR: E:\Wiki\connectai self reflection 기능이 있는데 어떻게 SELF REFLECTION이 반영되는지 분석해줘.
## Status
accepted
## Context
E:\Wiki\connectai self reflection 기능이 있는데 어떻게 SELF REFLECTION이 반영되는지 분석해줘.
## Decision
[회의록] 이머시브 스토어 및 AI 스타일링 샵 UI/UX 수정안 리뷰 회의 날짜: 2026년 05월 12일 화요일 참석자: 한지원, 한예성, 한승현 (및 개발/기획팀 인원) 주제 요약: 이머시브 스토어 및 AI 스타일링 샵의 수정된 결과물(모바일/PC UI)을 리뷰하고, 최종 빌드 배포를 위한 개선 사항 및 향후 일정 확정 🔹 요약 보고 * 이머시브 스토어와 AI 스타일링 샵의 최신 빌드를 시연하며 모바일/PC 환경에서의 UI/UX 완성도 점검 * AI 어시스턴트 캐릭터(여성 모델) 도입 및 상품 정보 표시 방식 개선 논의 * 가격 표기 정책을 '정가 기준'으로 통일하여 혼선 방지 결정 * 5월 19일까지 최종 수정 빌드 완료 및 외부 시연용 영상 제작 계획 수동 1. 안건 제목: AI 스타일링 샵 UI/UX 및 이미지 최적화 현황: 썸네일 이미지 해상도 문제로 인한 깨짐 현상 발생 및 캐릭터(제품 중심)와 어시스턴트(사람 중심) 간의 정체성 혼선 핵심 논의: * 이미지 품질: ...
## Reason
Captured automatically because the conversation contained decision-oriented language.
## Alternatives
Not captured yet.
## Consequences
- Future prompts should treat this as project context unless the user changes direction.
@@ -0,0 +1,19 @@
# ADR: E:\Wiki\connectai self reflection 기능이 있는데 어떻게 SELF REFLECTION이 반영되는지 분석해줘.
## Status
accepted
## Context
E:\Wiki\connectai self reflection 기능이 있는데 어떻게 SELF REFLECTION이 반영되는지 분석해줘.
## Decision
현재 제공된 프로젝트 소스 코드와 아키텍처 문서를 바탕으로, ConnectAI 내에서 **Self-Reflection(자기 성찰)** 기능이 어떤 메커니즘을 통해 구현되어 있고 실제 응답 프로세스에 어떻게 반영되는지 분석하겠습니다. ## 요약 ConnectAI의 Self-Reflection은 단순한 텍록 생성 기능이 아니라, **Multi-Agent Workflow** 단계 중 `Reflector` 에이전트가 개입하여 생성된 계획이나 연구 결과의 결함을 검토하는 **'검증 레이어(Validation Layer)'**로 작동합니다. 이는 `g1nation.enableReflection` 설정에 의해 활성화되며, Planner와 Writer 사이에서 논리적 일관성을 강제하는 역할을 합니다. ## 상세 설명 ### 1. 구현 메커니즘: Multi-Agent Workflow 내의 개입 Self-Reflection은 독립적인 기능이 아니라, 에이전트 협업 프로세스의 중간 단계로 설계되어 있습...
## Reason
Captured automatically because the conversation contained decision-oriented language.
## Alternatives
Not captured yet.
## Consequences
- Future prompts should treat this as project context unless the user changes direction.
@@ -0,0 +1,24 @@
# Development Log: REFLECTOR 에이전트가 1인 기업 에이전트 목록에는 안보이는데
## Purpose
Record the implementation or verification outcome from the current conversation.
## Implementation Summary
맞습니다. 정확한 지적입니다. `src/features/company/agents.ts`에 정의된 **'1인 기업(One-Person Company) 모드'의 9개 전문 에이전트 목록**에는 `Reflector`가 명시적인 독립 에이전트로 존재하지 않습니다. 제가 앞서 설명드린 Self-Reflection은 '1인 기업 모드'의 구성원 목록에 있는 에이전트를 교체하는 것이 아니라, **Multi-Agent Workflow(Planner $\rightarrow$ Researcher $\rightarrow$ Writer)라는 별도의 실행 엔진(Engine) 레이어에서 작동하는 '프로세스 제어 로직'**입니다. 상황을 명확히 정리해 드립니다. ## 요약 `Reflector`는 1인 기업 모드의 '고용된 에이전트(Staff)'가 아니라, 에이전트들이 협업할 때 그 사이에서 감시 역할을 수행하는 **'시스템 워크플로우 레이어(System Workflow Layer)'**의 구성 요소입니다. ...
## Architecture
Captured automatically from the assistant response and active project context.
## Changed Files
- src/features/company/agents.ts
- agents.ts
- src/lib/engine.ts
## Dependency Notes
No new dependency note was captured automatically.
## Bugs
No bugs recorded.
## Lessons
- Automatic project records should be generated in the background when the turn contains durable project knowledge.
@@ -0,0 +1,16 @@
# Discussion: 최성연 알아? 칼리버스에서 근무한데
## User Request Summary
최성연 알아? 칼리버스에서 근무한데
## Interpreted Intent
Capture a meaningful project discussion automatically instead of requiring manual record selection.
## Questions
No explicit question was captured.
## Main Discussion
- 제공해주신 정보와 제가 보유한 지식 범위 내에서는 **'칼리버스(Caliverse)에서 근무하는 최성연'**이라는 특정 개인에 대한 구체적인 프로필이나 공개된 정보를 확인할 수 없습니다. 개인에 대한 정보는 공인(Public Figure)이 아닌 이상 일반적인 데이터베이스나 학습 데이터에 포함되지 않으며, 저의 지식은 공개된 웹 문서, 뉴스, 학술 자료 등을 바탕으로 합니다. 만약 그분이 업계에서 널리 알려진 인물이거나 특정 프로젝트의 핵심 인물로서 언론 등에 보도된 적이 있다면 확인이 가능하겠지만, 현재로서는 알 수 없습니다. 혹시 그분과 관련하여 제가 분석하거나 정리해야 할 문서(예: 인사 기록, 프로젝트 협업 이력 등)가 프로젝트 폴더 내에 있다면, 해당 파일을 읽어 분석해 드릴 수는 있습니다.
## Decisions
No decisions captured yet.
+21
View File
@@ -126,3 +126,24 @@
## 2026-05-13
- Auto development record created: development/2026-05-13_volumes-data-project-antigravity-connectai-이-프로젝트-작업할거야_implementation.md
## 2026-05-14
- Auto decision record created: decisions\ADR-0011-e-wiki-connectai-self-reflection-기능이-있는데-어떻게-self-reflection.md
## 2026-05-14
- Auto decision record created: decisions\ADR-0012-e-wiki-connectai-self-reflection-기능이-있는데-어떻게-self-reflection.md
## 2026-05-14
- Auto development record created: development\2026-05-14_reflector-에이전트가-1인-기업-에이전트-목록에는-안보이는데_implementation.md
## 2026-05-14
- Auto bug record created: bugs\BUG-0012-질문이-있어-논문을-쓰려고해-논문-주제는-서비스적이-아닌-사용자가-ai에게-구조로-질문을-해야-사용자의-의도.md
## 2026-05-14
- Auto bug record created: bugs\BUG-0013-thesis-paper를-쓰려고-하는데-아래와-같이-쓰면-좋을까-이런-식으로-쓰면-되지-않을까-싶음-1-in.md
## 2026-05-14
- Auto bug record created: bugs\BUG-0014-논문-outline-title-인간-ai-상호작용에서-의도-정렬을-높이기-위한-최소-질의-구조-연구-또는-사.md
## 2026-05-14
- Auto discussion record created: discussions\2026-05-14_최성연-알아-칼리버스에서-근무한데.md
+42
View File
@@ -417,6 +417,14 @@
cursor: pointer;
}
.company-agent-model option { color: var(--text-primary); background: var(--bg); }
.company-agent-role-select {
font-size: 10px; padding: 3px 6px; border-radius: 5px;
background: var(--surface); color: var(--text-primary);
border: 1px solid var(--border); cursor: pointer; max-width: 90px;
}
.company-agent-role-select:disabled { opacity: 0.6; cursor: not-allowed; }
.company-agent-role-select.overridden { border-color: var(--accent); color: var(--accent); }
.company-agent-role-select option { color: var(--text-primary); background: var(--bg); }
.company-agent-edit {
background: transparent; border: 1px solid var(--border);
color: var(--text-dim); font-size: 10px;
@@ -513,6 +521,35 @@
.company-agent-editor .editor-actions button.danger { color: var(--error); }
.company-agent-editor .editor-actions button:hover { border-color: var(--border-bright); }
/* Add-agent inline form. Default closed; toggled via [data-open="true"]. */
.company-agent-add-form { display: none; }
.company-agent-add-form[data-open="true"] { display: block; }
.company-agent-add-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 8px 10px;
}
.company-agent-add-grid .field-label {
display: flex; flex-direction: column; gap: 4px;
font-size: 10px; color: var(--text-dim);
}
.company-agent-add-grid input[type="text"],
.company-agent-add-grid textarea {
font-size: 11px; padding: 6px 8px; border-radius: 6px;
background: var(--bg); color: var(--text-primary);
border: 1px solid var(--border); font-family: inherit;
}
.company-agent-add-grid textarea { resize: vertical; min-height: 44px; }
.company-agent-add-form .editor-actions {
display: flex; gap: 6px; justify-content: flex-end;
}
.company-agent-add-form .editor-actions button {
font-size: 10px; padding: 4px 10px; border-radius: 5px; cursor: pointer;
background: var(--surface); color: var(--text-primary);
border: 1px solid var(--border);
}
.company-agent-add-form .editor-actions button.primary {
background: var(--accent); border-color: var(--accent); color: #fff;
}
/* Per-phase company turn header in chat. */
.company-phase-card {
border: 1px solid var(--border);
@@ -684,6 +721,11 @@
.history-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8);
backdrop-filter: blur(10px); z-index: 1000; display: none; flex-direction: column; padding: 20px;
/* 본문이 뷰포트보다 길어지면 자체 스크롤. companyOverlay의 에이전트 목록처럼
동적으로 늘어나는 컨텐츠가 잘리는 문제를 막는다.
`historyOverlay`처럼 내부에 별도 스크롤 div를 둔 곳도 있는데
그쪽은 본인 wrapper가 우선이라 영향 없음. */
overflow-y: auto;
}
.history-overlay.visible { display: flex; }
+94
View File
@@ -165,10 +165,104 @@
<div class="map-section-title">활성 에이전트 + 모델</div>
<div class="map-section-hint">CEO는 항상 활성. 각 에이전트별로 모델을 따로 지정할 수 있습니다 — 다른 모델을 쓸 때만 LM Studio가 swap합니다.</div>
</div>
<div class="map-btn-group">
<button class="secondary-btn" id="addCompanyAgentBtn" title="새 사용자 에이전트 추가">+ Agent</button>
</div>
</div>
<ul id="companyAgentList" class="map-list company-agent-list"></ul>
</div>
<!-- Inline form for adding a new custom agent. Hidden by default; the
"+ Agent" button toggles `data-open` to show it. Kept in-overlay
(no separate modal) so the user can see existing agents while
editing — easier to spot id collisions. -->
<div id="addCompanyAgentForm" class="map-section company-agent-add-form" data-open="false">
<div class="map-section-title" style="margin-bottom:8px;">새 에이전트 추가</div>
<div class="company-agent-add-grid">
<label class="field-label">ID (소문자/숫자/-/_, 예: marketer)
<input type="text" id="newAgentId" placeholder="marketer" />
</label>
<label class="field-label">이름
<input type="text" id="newAgentName" placeholder="현수" />
</label>
<label class="field-label">역할
<input type="text" id="newAgentRole" placeholder="Marketing Lead" />
</label>
<label class="field-label">이모지
<input type="text" id="newAgentEmoji" placeholder="📣" maxlength="4" />
</label>
<label class="field-label">색상 (#hex)
<input type="text" id="newAgentColor" placeholder="#3B82F6" />
</label>
<label class="field-label" style="grid-column:1/-1;">직군 (담당 역할 분류)
<select id="newAgentRoleCategory"></select>
</label>
<label class="field-label" style="grid-column:1/-1;">한 줄 태그라인
<input type="text" id="newAgentTagline" placeholder="브랜드 메시지·캠페인을 설계합니다" />
</label>
<label class="field-label" style="grid-column:1/-1;">전문 분야 (CEO가 매칭에 사용)
<textarea id="newAgentSpecialty" rows="2" placeholder="캠페인 기획, 메시지 설계, 채널 분석"></textarea>
</label>
<label class="field-label" style="grid-column:1/-1;">페르소나 (선택)
<textarea id="newAgentPersona" rows="2" placeholder="데이터 기반·간결한 톤. 가설 검증 사이클을 좋아함."></textarea>
</label>
</div>
<div class="editor-actions" style="margin-top:10px;">
<button id="cancelAddAgentBtn">Cancel</button>
<button class="primary" id="saveAddAgentBtn">추가</button>
</div>
<div id="addAgentError" class="map-status" style="color:var(--error); margin-top:6px;"></div>
</div>
<!-- Work Pipeline editor. The active pipeline (if any) drives the
dispatcher instead of the CEO planner. Empty list / "기본 (CEO
자유 분배)" → legacy planner behaviour. -->
<div class="map-section">
<div class="map-section-head">
<div>
<div class="map-section-title">워크 파이프라인</div>
<div class="map-section-hint">CEO 자유 분배 대신 사용자가 정한 stage 순서대로 dispatch합니다. loop-back 정규식이 매칭되면 이전 stage로 되돌아갑니다 (최대 maxIterations 회).</div>
</div>
<div class="map-btn-group">
<button class="secondary-btn" id="addCompanyPipelineBtn" title="새 파이프라인 추가">+ Pipeline</button>
</div>
</div>
<div class="control-row" style="margin-top:8px; gap:8px; align-items:center;">
<label style="font-size:10px; color:var(--text-dim);">활성:</label>
<select id="activePipelineSel" style="flex:1; padding:4px 8px; background:var(--bg); color:var(--text-primary); border:1px solid var(--border); border-radius:6px; font-size:11px;">
<option value="">기본 (CEO 자유 분배)</option>
</select>
</div>
<ul id="companyPipelineList" class="map-list" style="margin-top:8px;"></ul>
</div>
<!-- Pipeline editor: hidden until "+ Pipeline" or "Edit" is clicked.
MVP is a JSON textarea so we don't have to ship a stage-list
reorder widget yet. -->
<div id="pipelineEditForm" class="map-section company-agent-add-form" data-open="false">
<div class="map-section-title" style="margin-bottom:8px;">파이프라인 편집</div>
<div class="company-agent-add-grid">
<label class="field-label">ID
<input type="text" id="pipelineEditId" placeholder="product-dev-v1" />
</label>
<label class="field-label">이름
<input type="text" id="pipelineEditName" placeholder="제품 개발 v1" />
</label>
<label class="field-label" style="grid-column:1/-1;">Stages (JSON 배열)
<textarea id="pipelineEditStages" rows="14" style="font-family:monospace; font-size:11px;"></textarea>
</label>
</div>
<div class="map-section-hint" style="margin-top:6px;">
예시:
<code style="font-size:10px;">[{"id":"plan","label":"기획","agentId":"writer","instructionTemplate":"{{userPrompt}} 에 대한 기획서 작성"},{"id":"dev","label":"개발","agentId":"developer","instructionTemplate":"다음 기획대로 구현: {{stage.plan}}","loopBackPattern":"버그|오류|fail","loopBackTo":"plan","maxIterations":3}]</code>
</div>
<div class="editor-actions" style="margin-top:10px;">
<button id="cancelPipelineEditBtn">Cancel</button>
<button class="primary" id="savePipelineEditBtn">저장</button>
</div>
<div id="pipelineEditError" class="map-status" style="color:var(--error); margin-top:6px;"></div>
</div>
<div class="map-footer">
<button class="secondary-btn" id="openCompanySessionsBtn" title="이번 회사가 만든 세션 폴더 열기">세션 폴더 열기</button>
<div style="flex:1"></div>
+335
View File
@@ -633,6 +633,9 @@
// 생성 완료 시 Stop 버튼 숨기고 Send 복구
setGenerating(false);
resetStepper();
// 1인 기업 모드는 streamStart를 거치지 않아 thinkingBar가
// 그대로 남으므로 streamEnd에서 명시적으로 끄는 게 안전.
thinkingBar.classList.remove('active');
Sound.success();
vscode.postMessage({ type: 'getReadyStatus' });
break;
@@ -793,6 +796,64 @@
renderCompanyAgentCards(msg.value || {});
break;
}
case 'addCompanyAgentResult': {
const v = msg.value || {};
const errEl = document.getElementById('addAgentError');
const form = document.getElementById('addCompanyAgentForm');
if (v.ok) {
if (errEl) errEl.textContent = '';
// 폼 닫고 필드 초기화 — companyAgents 메시지가 목록을 다시 그려줌
if (form) form.setAttribute('data-open', 'false');
const fids = ['newAgentId','newAgentName','newAgentRole','newAgentEmoji','newAgentColor','newAgentTagline','newAgentSpecialty','newAgentPersona'];
for (const fid of fids) { const el = document.getElementById(fid); if (el) el.value = ''; }
showToast(`✅ '${v.agentId}' 에이전트 추가 완료`, 'info');
} else {
if (errEl) errEl.textContent = v.reason || '에이전트 추가 실패.';
}
break;
}
case 'deleteCompanyAgentResult': {
const v = msg.value || {};
if (v.ok) {
showToast(`🗑 '${v.agentId}' 에이전트 삭제됨`, 'warn');
} else {
showToast(`삭제 실패: ${v.reason || '알 수 없음'}`, 'warn');
}
break;
}
case 'companyPipelines': {
if (typeof window.__renderCompanyPipelines === 'function') {
window.__renderCompanyPipelines(msg.value || {});
}
break;
}
case 'upsertCompanyPipelineResult': {
const v = msg.value || {};
const errEl = document.getElementById('pipelineEditError');
if (v.ok) {
if (errEl) errEl.textContent = '';
if (typeof window.__closePipelineEditor === 'function') window.__closePipelineEditor();
showToast('✅ 파이프라인 저장 완료', 'info');
} else {
if (errEl) errEl.textContent = v.reason || '파이프라인 저장 실패.';
}
break;
}
case 'deleteCompanyPipelineResult': {
const v = msg.value || {};
if (v.ok) showToast(`🗑 파이프라인 '${v.pipelineId}' 삭제됨`, 'warn');
else showToast(`삭제 실패: ${v.reason || '알 수 없음'}`, 'warn');
break;
}
case 'setActiveCompanyPipelineResult': {
const v = msg.value || {};
if (v.ok) {
showToast(v.pipelineId ? `▶ 파이프라인 '${v.pipelineId}' 활성` : '▶ 기본(CEO 자유 분배)로 복귀', 'info');
} else {
showToast(`활성 설정 실패: ${v.reason || '알 수 없음'}`, 'warn');
}
break;
}
case 'openCompanyManageOverlay': {
// Triggered by the Command Palette `Manage 1인 기업 Agents`.
document.getElementById('companyOverlay')?.classList.add('visible');
@@ -1475,6 +1536,7 @@
_companyOverlay.classList.add('visible');
_companyStatusEl.textContent = '불러오는 중...';
vscode.postMessage({ type: 'getCompanyAgents' });
vscode.postMessage({ type: 'getCompanyPipelines' });
};
}
for (const btn of _closeCompanyBtns) {
@@ -1486,6 +1548,218 @@
};
}
// ── Add-agent form: toggle, clear, submit ──
const _addCompanyAgentBtn = document.getElementById('addCompanyAgentBtn');
const _addAgentForm = document.getElementById('addCompanyAgentForm');
const _cancelAddAgentBtn = document.getElementById('cancelAddAgentBtn');
const _saveAddAgentBtn = document.getElementById('saveAddAgentBtn');
const _addAgentError = document.getElementById('addAgentError');
const _addAgentFields = () => ({
id: document.getElementById('newAgentId'),
name: document.getElementById('newAgentName'),
role: document.getElementById('newAgentRole'),
emoji: document.getElementById('newAgentEmoji'),
color: document.getElementById('newAgentColor'),
roleCategory: document.getElementById('newAgentRoleCategory'),
tagline: document.getElementById('newAgentTagline'),
specialty: document.getElementById('newAgentSpecialty'),
persona: document.getElementById('newAgentPersona'),
});
const _populateAddAgentRoleSelect = () => {
const sel = document.getElementById('newAgentRoleCategory');
if (!sel) return;
sel.innerHTML = '';
for (const cat of _roleCategoryOrder) {
if (cat === 'ceo') continue; // ceo는 빌트인 전용
const opt = document.createElement('option');
opt.value = cat;
opt.textContent = _roleCategoryLabels[cat] || cat;
sel.appendChild(opt);
}
sel.value = 'support'; // 기본은 가장 무해한 직군
};
const _clearAddAgentForm = () => {
const f = _addAgentFields();
for (const k of Object.keys(f)) {
if (!f[k]) continue;
if (k === 'roleCategory') continue; // select는 별도 populate
f[k].value = '';
}
if (_addAgentError) _addAgentError.textContent = '';
_populateAddAgentRoleSelect();
};
if (_addCompanyAgentBtn && _addAgentForm) {
_addCompanyAgentBtn.onclick = () => {
const open = _addAgentForm.getAttribute('data-open') === 'true';
_addAgentForm.setAttribute('data-open', open ? 'false' : 'true');
if (!open) {
_clearAddAgentForm();
_addAgentFields().id?.focus();
}
};
}
if (_cancelAddAgentBtn && _addAgentForm) {
_cancelAddAgentBtn.onclick = () => {
_addAgentForm.setAttribute('data-open', 'false');
_clearAddAgentForm();
};
}
if (_saveAddAgentBtn) {
_saveAddAgentBtn.onclick = () => {
const f = _addAgentFields();
const def = {
id: (f.id?.value || '').trim().toLowerCase(),
name: (f.name?.value || '').trim(),
role: (f.role?.value || '').trim(),
emoji: (f.emoji?.value || '').trim(),
color: (f.color?.value || '').trim(),
roleCategory: (f.roleCategory?.value || 'support'),
tagline: (f.tagline?.value || '').trim(),
specialty: (f.specialty?.value || '').trim(),
persona: (f.persona?.value || '').trim(),
};
if (!def.id || !def.name || !def.role) {
if (_addAgentError) _addAgentError.textContent = 'id · 이름 · 역할은 필수입니다.';
return;
}
if (_addAgentError) _addAgentError.textContent = '';
vscode.postMessage({ type: 'addCompanyAgent', def });
};
}
// ── Work Pipeline editor ──
const _activePipelineSel = document.getElementById('activePipelineSel');
const _pipelineList = document.getElementById('companyPipelineList');
const _addPipelineBtn = document.getElementById('addCompanyPipelineBtn');
const _pipelineEditForm = document.getElementById('pipelineEditForm');
const _pipelineEditId = document.getElementById('pipelineEditId');
const _pipelineEditName = document.getElementById('pipelineEditName');
const _pipelineEditStages = document.getElementById('pipelineEditStages');
const _pipelineEditError = document.getElementById('pipelineEditError');
const _cancelPipelineEditBtn = document.getElementById('cancelPipelineEditBtn');
const _savePipelineEditBtn = document.getElementById('savePipelineEditBtn');
const _openPipelineEditor = (pipeline) => {
if (!_pipelineEditForm) return;
_pipelineEditForm.setAttribute('data-open', 'true');
if (_pipelineEditError) _pipelineEditError.textContent = '';
if (pipeline) {
if (_pipelineEditId) { _pipelineEditId.value = pipeline.id; _pipelineEditId.disabled = true; }
if (_pipelineEditName) _pipelineEditName.value = pipeline.name || '';
if (_pipelineEditStages) _pipelineEditStages.value = JSON.stringify(pipeline.stages || [], null, 2);
} else {
if (_pipelineEditId) { _pipelineEditId.value = ''; _pipelineEditId.disabled = false; }
if (_pipelineEditName) _pipelineEditName.value = '';
if (_pipelineEditStages) _pipelineEditStages.value = '[]';
}
};
const _closePipelineEditor = () => {
if (_pipelineEditForm) _pipelineEditForm.setAttribute('data-open', 'false');
if (_pipelineEditId) _pipelineEditId.disabled = false;
};
if (_addPipelineBtn) {
_addPipelineBtn.onclick = () => {
_openPipelineEditor(null);
_pipelineEditId?.focus();
};
}
if (_cancelPipelineEditBtn) {
_cancelPipelineEditBtn.onclick = _closePipelineEditor;
}
if (_savePipelineEditBtn) {
_savePipelineEditBtn.onclick = () => {
let stages;
try {
stages = JSON.parse(_pipelineEditStages?.value || '[]');
} catch (e) {
if (_pipelineEditError) _pipelineEditError.textContent = `Stages JSON 파싱 실패: ${e.message}`;
return;
}
if (!Array.isArray(stages)) {
if (_pipelineEditError) _pipelineEditError.textContent = 'Stages는 배열이어야 합니다.';
return;
}
const def = {
id: (_pipelineEditId?.value || '').trim().toLowerCase(),
name: (_pipelineEditName?.value || '').trim(),
stages,
};
if (!def.id) {
if (_pipelineEditError) _pipelineEditError.textContent = 'id는 필수입니다 (소문자/숫자/-/_).';
return;
}
vscode.postMessage({ type: 'upsertCompanyPipeline', def });
};
}
if (_activePipelineSel) {
_activePipelineSel.onchange = () => {
vscode.postMessage({
type: 'setActiveCompanyPipeline',
pipelineId: _activePipelineSel.value || null,
});
};
}
const renderCompanyPipelines = (payload) => {
if (!_pipelineList || !_activePipelineSel) return;
const pipelines = (payload && payload.pipelines && typeof payload.pipelines === 'object') ? payload.pipelines : {};
const activeId = payload && payload.activePipelineId ? payload.activePipelineId : '';
// active dropdown
_activePipelineSel.innerHTML = '<option value="">기본 (CEO 자유 분배)</option>';
for (const p of Object.values(pipelines)) {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = `${p.name || p.id} (${(p.stages || []).length} stages)`;
_activePipelineSel.appendChild(opt);
}
_activePipelineSel.value = activeId;
// list
_pipelineList.innerHTML = '';
const entries = Object.values(pipelines);
if (entries.length === 0) {
const li = document.createElement('li');
li.className = 'map-item';
li.style.cssText = 'color: var(--text-dim); font-style: italic; padding: 6px 4px;';
li.textContent = '아직 파이프라인이 없습니다. "+ Pipeline" 으로 새로 만드세요.';
_pipelineList.appendChild(li);
return;
}
for (const p of entries) {
const li = document.createElement('li');
li.className = 'map-item';
li.style.cssText = 'padding:8px 10px; background:var(--surface); border:1px solid var(--border); border-radius:6px;';
const head = document.createElement('div');
head.style.cssText = 'display:flex; gap:8px; align-items:center; justify-content:space-between;';
const title = document.createElement('div');
title.innerHTML = `<strong>${escAttr(p.name || p.id)}</strong> <span style="font-size:10px; color:var(--text-dim)">${escAttr(p.id)} · ${(p.stages || []).length} stages${p.id === activeId ? ' · <span style="color:var(--accent)">● 활성</span>' : ''}</span>`;
const actions = document.createElement('div');
actions.style.cssText = 'display:flex; gap:4px;';
const editBtn = document.createElement('button');
editBtn.className = 'company-agent-edit';
editBtn.textContent = '✎ 편집';
editBtn.onclick = () => _openPipelineEditor(p);
const delBtn = document.createElement('button');
delBtn.className = 'company-agent-edit';
delBtn.textContent = '🗑';
delBtn.title = '파이프라인 삭제';
delBtn.onclick = () => {
if (!confirm(`'${p.name || p.id}' 파이프라인을 삭제할까요?`)) return;
vscode.postMessage({ type: 'deleteCompanyPipeline', pipelineId: p.id });
};
actions.appendChild(editBtn);
actions.appendChild(delBtn);
head.appendChild(title);
head.appendChild(actions);
li.appendChild(head);
_pipelineList.appendChild(li);
}
};
// expose for the message handler below
window.__renderCompanyPipelines = renderCompanyPipelines;
window.__closePipelineEditor = _closePipelineEditor;
/**
* Keep the last payload around so we can re-render whenever the
* model list refreshes (the top `#modelSel` is the source of truth
@@ -1531,6 +1805,16 @@
* - an Edit button that toggles an inline prompt editor with
* tagline / specialty / persona textareas + Reset/Save/Cancel.
*/
// 직군 라벨/순서 캐시. 페이로드에 같이 오므로 첫 렌더 후엔 항상 채워져 있음.
// pipeline 에디터(다음 phase)도 같은 캐시를 본다.
let _roleCategoryLabels = {
ceo: 'CEO', planner: '기획', researcher: '리서치', designer: '디자인',
developer: '개발', qa: 'QA', inspector: '감리', support: '지원',
};
let _roleCategoryOrder = ['ceo','planner','researcher','designer','developer','qa','inspector','support'];
window.__getRoleLabels = () => _roleCategoryLabels;
window.__getRoleOrder = () => _roleCategoryOrder;
function renderCompanyAgentCards(payload) {
if (!_companyAgentList) return;
_lastCompanyAgentsPayload = payload;
@@ -1538,6 +1822,8 @@
if (_companyNameInput && payload && typeof payload.companyName === 'string') {
_companyNameInput.value = payload.companyName;
}
if (payload && payload.roleCategoryLabels) _roleCategoryLabels = payload.roleCategoryLabels;
if (payload && Array.isArray(payload.roleCategoryOrder)) _roleCategoryOrder = payload.roleCategoryOrder;
const agents = (payload && Array.isArray(payload.agents)) ? payload.agents : [];
for (const a of agents) {
const li = document.createElement('li');
@@ -1571,6 +1857,38 @@
const controls = document.createElement('div');
controls.className = 'company-agent-controls';
// 직군 select — CEO는 잠금. role 라벨 보다 먼저 와서 시선 흐름이
// "이 사람은 [직군] 직군"으로 자연스럽게 읽히도록 controls 첫번째에.
const roleSelEl = document.createElement('select');
roleSelEl.className = 'company-agent-role-select';
roleSelEl.title = '이 에이전트의 직군. 파이프라인 에디터에서 직군별로 담당자를 고를 때 사용됩니다.';
if (a.id === 'ceo') {
const opt = document.createElement('option');
opt.value = 'ceo'; opt.textContent = _roleCategoryLabels.ceo || 'CEO';
roleSelEl.appendChild(opt);
roleSelEl.value = 'ceo';
roleSelEl.disabled = true;
} else {
for (const cat of _roleCategoryOrder) {
if (cat === 'ceo') continue;
const opt = document.createElement('option');
opt.value = cat;
opt.textContent = _roleCategoryLabels[cat] || cat;
roleSelEl.appendChild(opt);
}
roleSelEl.value = a.roleCategory || a.defaultRoleCategory || 'support';
if (a.roleCategoryOverridden) roleSelEl.classList.add('overridden');
roleSelEl.onchange = () => {
const v = roleSelEl.value;
const sendValue = v === a.defaultRoleCategory ? null : v;
vscode.postMessage({
type: 'setCompanyAgentRoleCategory',
agentId: a.id,
value: sendValue,
});
};
}
const modelSelEl = document.createElement('select');
modelSelEl.className = 'company-agent-model';
modelSelEl.title = '비워두면 글로벌 기본 모델 사용';
@@ -1613,8 +1931,22 @@
vscode.postMessage({ type: 'setCompanyActiveAgents', value: nextIds });
};
}
controls.appendChild(roleSelEl);
controls.appendChild(modelSelEl);
controls.appendChild(editBtn);
// 사용자 추가 에이전트만 삭제 가능. 기본 9명은 코드에 박혀 있어
// 백엔드도 거부하므로 UI에서도 버튼을 노출하지 않음.
if (a.custom) {
const delBtn = document.createElement('button');
delBtn.className = 'company-agent-edit';
delBtn.textContent = '🗑';
delBtn.title = `'${a.name}' 에이전트 삭제`;
delBtn.onclick = () => {
if (!confirm(`'${a.name}' 에이전트를 삭제할까요? 이 에이전트의 모든 설정(모델·프롬프트·지식 믹스)도 함께 삭제됩니다.`)) return;
vscode.postMessage({ type: 'deleteCompanyAgent', agentId: a.id });
};
controls.appendChild(delBtn);
}
controls.appendChild(toggle);
row.appendChild(emoji);
@@ -1837,6 +2169,9 @@
const body = (o.response || '').slice(0, 4000);
card.innerHTML = `<div class="cph-head">${escAttr(ev.agentId)} 완료 <span class="cph-meta">${(o.durationMs/1000).toFixed(1)}s${o.error ? ' · ⚠️ ' + escAttr(o.error) : ''}</span></div>
<div class="markdown-body">${fmt(body)}</div>`;
} else if (ev.phase === 'stage-loop') {
card.innerHTML = `<div class="cph-head">🔁 Stage 재시도</div>
<div class="cph-meta">${escAttr(ev.from)} ${escAttr(ev.to)} (반복 ${ev.iteration})</div>`;
} else if (ev.phase === 'report-start') {
card.innerHTML = '<div class="cph-head">🧭 CEO 종합 보고서 작성 중…</div>';
} else if (ev.phase === 'report-done') {
+1 -1
View File
@@ -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.1.2",
"version": "2.1.3",
"publisher": "g1nation",
"license": "MIT",
"icon": "assets/icon.png",
+41 -2
View File
@@ -27,6 +27,7 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
color: '#F8FAFC',
specialty: '오케스트레이션, 작업 분해, 종합 판단, 다음 액션 결정',
tagline: '회사 전체 의사결정과 작업 분배를 맡습니다',
roleCategory: 'ceo',
alwaysOn: true,
},
youtube: {
@@ -37,6 +38,7 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
color: '#FF4444',
specialty: '유튜브 채널 운영, 영상 기획서(제목·후크·구조), 트렌드 분석, 썸네일 브리프, 업로드 메타데이터, 시청자 유지율 전략',
tagline: '유튜브 채널 기획·운영 전반을 책임집니다',
roleCategory: 'planner',
persona: '데이터 중심·솔직·자신감 있는 톤. 결론을 먼저 말한 뒤 데이터 근거로 뒷받침. 추측보다 숫자. 가끔 직설적이지만 따뜻함은 잃지 않음. 이모지는 자제하되 "🔥"·"📊"·"🎯" 같은 핵심 강조용은 OK.',
},
instagram: {
@@ -47,6 +49,7 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
color: '#E1306C',
specialty: '인스타그램 릴스/피드 콘셉트, 캡션, 해시태그 전략, 게시 시간, 스토리, 팔로워 인게이지먼트',
tagline: '인스타 콘텐츠 기획과 인게이지먼트를 끌어올립니다',
roleCategory: 'planner',
},
designer: {
id: 'designer',
@@ -56,6 +59,7 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
color: '#A78BFA',
specialty: '브랜드 디자인 브리프(컬러·타이포·레퍼런스), 썸네일 컨셉 3안, 비주얼 시스템, 디자인 가이드',
tagline: '브랜드와 시각 자산 디자인을 담당합니다',
roleCategory: 'designer',
},
developer: {
id: 'developer',
@@ -65,6 +69,7 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
color: '#22D3EE',
specialty: '코드 작성·편집·디버깅, 자동화 스크립트, API 통합, 웹사이트/봇, 데이터 파이프라인, git 워크플로, 자기 검증 루프',
tagline: '읽고·생각하고·짜고·검증한다 — 시니어 엔지니어',
roleCategory: 'developer',
persona: '시니어 풀스택 엔지니어. 코드 한 줄도 그냥 안 넘김. "왜?·어떻게?·이게 깨지나?" 늘 묻고 검증. 친근하지만 프로페셔널 톤. "확인 후 진행할게요"·"테스트 통과 확인했어요" 같은 책임감 있는 표현. 이모지는 💻·⚙️·🔧·✅·🐛 정도만.',
},
business: {
@@ -75,6 +80,7 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
color: '#F5C518',
specialty: '수익화 모델, 가격 전략, 시장·경쟁 분석, ROI/KPI 설계, 비즈니스 의사결정',
tagline: '수익화·가격·전략 의사결정을 같이 봅니다',
roleCategory: 'inspector',
},
secretary: {
id: 'secretary',
@@ -84,6 +90,7 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
color: '#84CC16',
specialty: '일정·할 일 관리, 다른 에이전트 작업 요약·보고, 데일리 브리핑, 알림',
tagline: '일정·할 일·연락을 챙기고 소통을 정리합니다',
roleCategory: 'support',
persona: '친근하고 정중한 톤. 짧고 정리된 문장. 이모지 적당히 (😊·📅·✅ 정도). 보고할 땐 한눈에 보이게 불릿 포인트 + 핵심만.',
},
editor: {
@@ -94,6 +101,7 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
color: '#F472B6',
specialty: '영상 BGM 기획, 사운드 디자인, 영상-음악 매칭, 자막·타이틀 동기화 가이드',
tagline: '영상의 톤에 맞는 사운드 방향을 잡습니다',
roleCategory: 'designer',
persona: '음악·사운드 감각이 좋고 영상의 톤을 한 마디로 잡아냄. "이 영상은 [장르/분위기]가 어울릴 것 같아요" 식으로 제안. BPM·키·길이를 정확히 표기. 데이터 중심이지만 창작자 감수성도 있음. 이모지는 🎵·🎼·🎚 정도만.',
},
writer: {
@@ -104,6 +112,7 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
color: '#FBBF24',
specialty: '카피라이팅, 영상 스크립트 초안, 인스타 캡션, 블로그 글, 메일 톤앤매너, 후크 작성',
tagline: '카피·스크립트·후크를 글로 풀어냅니다',
roleCategory: 'planner',
},
researcher: {
id: 'researcher',
@@ -113,6 +122,32 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
color: '#60A5FA',
specialty: '트렌드 리서치, 경쟁사 분석, 데이터 수집·요약, 인용 자료 정리, 사실 확인',
tagline: '트렌드와 데이터를 모아 사실 확인까지 끝냅니다',
roleCategory: 'researcher',
},
// ── 신규 직군 에이전트 ──
// QA·Inspector 직군이 없으면 사용자가 "기획 → 개발 → QA" 파이프라인을
// 처음부터 만들 수 없어서 onboarding이 막힌다. 코드로 같이 동봉.
qa: {
id: 'qa',
name: '재훈',
role: 'QA 엔지니어',
emoji: '🧪',
color: '#10B981',
specialty: '기능 테스트 시나리오 작성, 버그 재현·기록, 회귀 테스트, 엣지 케이스 발굴, 통과/실패 명확히 보고',
tagline: '기능 검증과 버그 발굴을 담당합니다',
roleCategory: 'qa',
persona: '꼼꼼하고 의심 많은 톤. "정상 동작합니다" 같은 모호한 표현 대신 "케이스 A: ✅ / 케이스 B: ❌ (재현 방법: ...)" 식의 검증 가능한 결론. 버그가 있으면 반드시 "❌ 버그 발견:"으로 시작 — loop-back regex가 잡을 수 있게.',
},
inspector: {
id: 'inspector',
name: '민지',
role: '기획·산출물 감리',
emoji: '🔎',
color: '#EF4444',
specialty: '기획서 검토, 요구사항 대비 산출물 정합성 확인, 누락된 케이스 지적, 최종 승인 또는 재작업 요청',
tagline: '기획 의도와 산출물의 일치 여부를 감리합니다',
roleCategory: 'inspector',
persona: '깐깐하지만 건설적인 톤. 무엇이 좋고 무엇이 부족한지 명확히 구분. 결론을 "✅ 승인" 또는 "❌ 재작업 필요: ..."로 명시 — loop-back regex가 잡을 수 있게. 사장님(사용자)이 시간 낭비 안 하게 핵심만.',
},
};
@@ -120,14 +155,18 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
export const COMPANY_AGENT_ORDER: string[] = [
'ceo', 'youtube', 'instagram', 'designer', 'developer',
'business', 'secretary', 'editor', 'writer', 'researcher',
'qa', 'inspector',
];
/** 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. */
/** Default activation set used when a user first opens the company panel.
* 기준: 13단계
* . business는 inspector
* , inspector() . */
export const DEFAULT_ACTIVE_AGENTS: string[] = [
'ceo', 'developer', 'writer', 'researcher', 'designer', 'business',
'ceo', 'writer', 'researcher', 'designer', 'developer', 'qa', 'inspector',
];
/** Lookup helper. Returns `undefined` for unknown ids instead of throwing. */
+14 -14
View File
@@ -18,8 +18,7 @@
*/
import { IAIService } from '../../core/services';
import { logError, logInfo } from '../../utils';
import { COMPANY_AGENTS } from './agents';
import { isAgentActive } from './companyConfig';
import { isAgentActive, listAllAgents, resolveAgent } from './companyConfig';
import { applyPromptVars, CEO_PLANNER_PROMPT } from './promptAssets';
import { buildPlannerSystemPrompt } from './promptBuilder';
import { CompanyState, CompanyTaskPlan } from './types';
@@ -35,25 +34,25 @@ export interface PlannerResult {
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.
* Build a nickname id map from a state snapshot. Built-ins + custom
* agents are merged so a user-added agent's name can match the planner's
* output too. Rebuilt per normalize call (cheap <50 entries).
*/
const NAME_TO_ID: Record<string, string> = (() => {
function _buildNameMap(state: CompanyState): Record<string, string> {
const out: Record<string, string> = {};
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")
for (const def of listAllAgents(state)) {
out[def.id.toLowerCase()] = def.id;
out[def.name.toLowerCase()] = def.id;
const roleHead = def.role.split(/[\s·]+/)[0]?.toLowerCase();
if (roleHead && !out[roleHead]) out[roleHead] = id;
if (roleHead && !out[roleHead]) out[roleHead] = def.id;
}
return out;
})();
}
function _canonicalAgentId(raw: unknown): string | null {
function _canonicalAgentId(raw: unknown, state: CompanyState, nameMap: Record<string, string>): string | null {
if (typeof raw !== 'string') return null;
const key = raw.trim().toLowerCase();
return NAME_TO_ID[key] ?? (COMPANY_AGENTS[key] ? key : null);
return nameMap[key] ?? (resolveAgent(state, key) ? key : null);
}
/**
@@ -149,8 +148,9 @@ function _extractFirstBalancedObject(s: string): string | null {
export function normalizePlan(plan: CompanyTaskPlan, state: CompanyState): CompanyTaskPlan {
const out: CompanyTaskPlan = { brief: plan.brief, tasks: [] };
const dropped: string[] = [];
const nameMap = _buildNameMap(state);
for (const t of plan.tasks) {
const canonical = _canonicalAgentId(t.agent);
const canonical = _canonicalAgentId(t.agent, state, nameMap);
if (!canonical) {
dropped.push(`unknown:${t.agent}`);
continue;
+8 -6
View File
@@ -13,7 +13,7 @@
*/
import { IAIService } from '../../core/services';
import { logError } from '../../utils';
import { getCompanyAgent } from './agents';
import { resolveAgent } from './companyConfig';
import { applyPromptVars, CEO_REPORT_PROMPT } from './promptAssets';
import { AgentTurnOutput, CompanyState, CompanyTaskPlan } from './types';
@@ -35,6 +35,7 @@ export interface ReportResult {
function _buildReportUserMessage(
plan: CompanyTaskPlan,
outputs: AgentTurnOutput[],
state: CompanyState,
): string {
const lines: string[] = [];
if (plan.brief) {
@@ -47,7 +48,7 @@ function _buildReportUserMessage(
lines.push('_(no agent dispatched this turn — produce a brief acknowledgement instead)_');
} else {
for (const out of outputs) {
const def = getCompanyAgent(out.agentId);
const def = resolveAgent(state, out.agentId);
const head = def ? `### ${def.emoji} ${def.name}` : `### ${out.agentId}`;
lines.push('');
lines.push(head);
@@ -69,13 +70,14 @@ function _buildReportUserMessage(
export function buildFallbackReport(
plan: CompanyTaskPlan,
outputs: AgentTurnOutput[],
state: CompanyState,
): 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 def = resolveAgent(state, 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)}`);
@@ -100,7 +102,7 @@ export async function runCeoReporter(
options: { model?: string; timeoutMs?: number } = {},
): Promise<ReportResult> {
const system = applyPromptVars(CEO_REPORT_PROMPT, { company: state.companyName });
const user = _buildReportUserMessage(plan, outputs);
const user = _buildReportUserMessage(plan, outputs, state);
try {
const result = await ai.chat({
system,
@@ -110,11 +112,11 @@ export async function runCeoReporter(
});
const text = (result.content || '').trim();
if (!text) {
return { report: buildFallbackReport(plan, outputs), ok: false };
return { report: buildFallbackReport(plan, outputs, state), 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 };
return { report: buildFallbackReport(plan, outputs, state), ok: false };
}
}
+386 -10
View File
@@ -20,7 +20,65 @@
*/
import * as vscode from 'vscode';
import { COMPANY_AGENTS, DEFAULT_ACTIVE_AGENTS, getCompanyAgent } from './agents';
import { AgentPromptOverride, CompanyState, COMPANY_STATE_KEY } from './types';
import {
AgentPromptOverride, AgentRoleCategory, CompanyAgentDef, CompanyState, COMPANY_STATE_KEY,
PipelineDef, PipelineStage, ROLE_CATEGORY_ORDER,
} from './types';
const VALID_ROLE_CATEGORIES = new Set<AgentRoleCategory>(ROLE_CATEGORY_ORDER);
function _coerceRoleCategory(raw: unknown, fallback: AgentRoleCategory): AgentRoleCategory {
if (typeof raw === 'string' && VALID_ROLE_CATEGORIES.has(raw as AgentRoleCategory)) {
return raw as AgentRoleCategory;
}
return fallback;
}
/**
* Validation for a user-supplied agent id. Lowercase ASCII, digits, hyphens,
* underscores. Built-in ids cannot be reused (would shadow code). Empty /
* invalid input returns an error string for the UI to surface.
*/
export function validateCustomAgentId(id: string): { ok: true } | { ok: false; reason: string } {
const trimmed = (id || '').trim();
if (!trimmed) return { ok: false, reason: 'id가 비어 있습니다.' };
if (!/^[a-z][a-z0-9_-]{1,40}$/.test(trimmed)) {
return { ok: false, reason: 'id는 소문자/숫자/-/_ 만 허용 (2~41자, 소문자 시작).' };
}
if (COMPANY_AGENTS[trimmed]) {
return { ok: false, reason: `'${trimmed}'은 기본 에이전트 id와 겹칩니다.` };
}
return { ok: true };
}
function _normalizeCustomAgentDef(raw: unknown): CompanyAgentDef | null {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
const id = typeof r.id === 'string' ? r.id.trim() : '';
const name = typeof r.name === 'string' ? r.name.trim() : '';
const role = typeof r.role === 'string' ? r.role.trim() : '';
if (!id || !name || !role) return null;
if (validateCustomAgentId(id).ok !== true) return null;
return {
id,
name,
role,
emoji: typeof r.emoji === 'string' && r.emoji.trim() ? r.emoji.trim() : '🤖',
color: typeof r.color === 'string' && /^#?[0-9a-fA-F]{3,8}$/.test(r.color.trim())
? (r.color.trim().startsWith('#') ? r.color.trim() : '#' + r.color.trim())
: '#94A3B8',
specialty: typeof r.specialty === 'string' ? r.specialty.trim() : '',
tagline: typeof r.tagline === 'string' ? r.tagline.trim() : '',
persona: typeof r.persona === 'string' && r.persona.trim() ? r.persona.trim() : undefined,
// CEO 직군은 빌트인 전용. 사용자가 ceo로 만들려 해도 'support'로 fallback.
roleCategory: (() => {
const rc = _coerceRoleCategory(r.roleCategory, 'support');
return rc === 'ceo' ? 'support' : rc;
})(),
// `alwaysOn` is reserved for built-ins (CEO). Custom agents never set it.
alwaysOn: false,
};
}
/** Default state for a brand-new user. CEO is always on. */
function _defaultState(): CompanyState {
@@ -31,9 +89,75 @@ function _defaultState(): CompanyState {
modelOverrides: {},
promptOverrides: {},
knowledgeMixOverrides: {},
customAgents: {},
roleCategoryOverrides: {},
pipelines: {},
activePipelineId: null,
};
}
/**
* Validation for a pipeline / stage id. Same alphabet rules as agent ids
* so the UI feels consistent. Empty / invalid input returns an error
* string for the UI to surface.
*/
function _validId(id: string): boolean {
return /^[a-z][a-z0-9_-]{1,40}$/.test((id || '').trim());
}
function _normalizeStage(raw: unknown): PipelineStage | null {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
const id = typeof r.id === 'string' ? r.id.trim() : '';
const agentId = typeof r.agentId === 'string' ? r.agentId.trim() : '';
const label = typeof r.label === 'string' && r.label.trim() ? r.label.trim() : id;
if (!_validId(id) || !agentId) return null;
const out: PipelineStage = {
id, label, agentId,
instructionTemplate: typeof r.instructionTemplate === 'string' ? r.instructionTemplate : '',
};
if (typeof r.loopBackPattern === 'string' && r.loopBackPattern.trim()) {
out.loopBackPattern = r.loopBackPattern.trim();
}
if (typeof r.loopBackTo === 'string' && _validId(r.loopBackTo)) {
out.loopBackTo = r.loopBackTo.trim();
}
if (typeof r.maxIterations === 'number' && Number.isFinite(r.maxIterations)) {
out.maxIterations = Math.max(1, Math.min(10, Math.round(r.maxIterations)));
}
return out;
}
function _normalizePipeline(raw: unknown): PipelineDef | null {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
const id = typeof r.id === 'string' ? r.id.trim() : '';
const name = typeof r.name === 'string' && r.name.trim() ? r.name.trim() : id;
if (!_validId(id)) return null;
const rawStages = Array.isArray(r.stages) ? r.stages : [];
const stages: PipelineStage[] = [];
const seen = new Set<string>();
for (const s of rawStages) {
const ns = _normalizeStage(s);
if (!ns) continue;
if (seen.has(ns.id)) continue; // stage ids must be unique within pipeline
seen.add(ns.id);
stages.push(ns);
}
// Drop loopBackTo references that point to a non-existent / later stage.
for (let i = 0; i < stages.length; i++) {
const s = stages[i];
if (s.loopBackTo) {
const earlierIdx = stages.findIndex((x) => x.id === s.loopBackTo);
if (earlierIdx === -1 || earlierIdx >= i) {
delete s.loopBackTo;
delete s.loopBackPattern;
}
}
}
return { id, name, stages };
}
/**
* Normalize a state value loaded from globalState. Guards against schema
* drift (e.g. unknown agent ids that no longer exist, missing fields).
@@ -45,8 +169,19 @@ function _normalize(raw: Partial<CompanyState> | undefined): CompanyState {
const companyName = typeof raw.companyName === 'string' && raw.companyName.trim()
? raw.companyName.trim()
: def.companyName;
// Custom agents come first so the merged-id check below is one-pass.
const customAgents: Record<string, CompanyAgentDef> = {};
if (raw.customAgents && typeof raw.customAgents === 'object') {
for (const [id, ad] of Object.entries(raw.customAgents as Record<string, unknown>)) {
const def = _normalizeCustomAgentDef(ad);
// Force id consistency: the record key wins over a mismatched .id
if (def && def.id === id) customAgents[id] = def;
}
}
const knownId = (id: string): boolean =>
!!getCompanyAgent(id) || Object.prototype.hasOwnProperty.call(customAgents, id);
const validIds = Array.isArray(raw.activeAgentIds)
? raw.activeAgentIds.filter((id): id is string => typeof id === 'string' && !!getCompanyAgent(id))
? raw.activeAgentIds.filter((id): id is string => typeof id === 'string' && knownId(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.
@@ -54,7 +189,7 @@ function _normalize(raw: Partial<CompanyState> | undefined): CompanyState {
const overrides: Record<string, string> = {};
if (raw.modelOverrides && typeof raw.modelOverrides === 'object') {
for (const [k, v] of Object.entries(raw.modelOverrides)) {
if (typeof v === 'string' && v.trim() && getCompanyAgent(k)) {
if (typeof v === 'string' && v.trim() && knownId(k)) {
overrides[k] = v.trim();
}
}
@@ -65,7 +200,7 @@ function _normalize(raw: Partial<CompanyState> | undefined): CompanyState {
const promptOverrides: Record<string, AgentPromptOverride> = {};
if (raw.promptOverrides && typeof raw.promptOverrides === 'object') {
for (const [agentId, v] of Object.entries(raw.promptOverrides as Record<string, unknown>)) {
if (!getCompanyAgent(agentId) || !v || typeof v !== 'object') continue;
if (!knownId(agentId) || !v || typeof v !== 'object') continue;
const ov = v as Record<string, unknown>;
const cleaned: AgentPromptOverride = {};
if (typeof ov.persona === 'string' && ov.persona.trim()) cleaned.persona = ov.persona.trim();
@@ -82,19 +217,48 @@ function _normalize(raw: Partial<CompanyState> | undefined): CompanyState {
const knowledgeMixOverrides: Record<string, number> = {};
if (raw.knowledgeMixOverrides && typeof raw.knowledgeMixOverrides === 'object') {
for (const [agentId, v] of Object.entries(raw.knowledgeMixOverrides as Record<string, unknown>)) {
if (!getCompanyAgent(agentId)) continue;
if (!knownId(agentId)) continue;
if (typeof v === 'number' && Number.isFinite(v)) {
const w = Math.max(0, Math.min(100, Math.round(v)));
knowledgeMixOverrides[agentId] = w;
}
}
}
// 직군 override — 사용자가 빌트인을 reclassify할 수 있게.
// 'ceo'로 설정하려는 시도는 무시 (지휘자 직군은 빌트인 CEO 전용).
const roleCategoryOverrides: Record<string, AgentRoleCategory> = {};
if (raw.roleCategoryOverrides && typeof raw.roleCategoryOverrides === 'object') {
for (const [agentId, v] of Object.entries(raw.roleCategoryOverrides as Record<string, unknown>)) {
if (!knownId(agentId) || agentId === 'ceo') continue;
if (typeof v !== 'string' || !VALID_ROLE_CATEGORIES.has(v as AgentRoleCategory)) continue;
if (v === 'ceo') continue;
roleCategoryOverrides[agentId] = v as AgentRoleCategory;
}
}
// Pipelines — drop malformed entries; stage agent ids that don't resolve
// are kept (the dispatcher will surface a per-stage error) so the user
// can fix them in the editor instead of losing their pipeline silently.
const pipelines: Record<string, PipelineDef> = {};
if (raw.pipelines && typeof raw.pipelines === 'object') {
for (const [pid, p] of Object.entries(raw.pipelines as Record<string, unknown>)) {
const np = _normalizePipeline(p);
if (np && np.id === pid) pipelines[pid] = np;
}
}
const activePipelineId = typeof raw.activePipelineId === 'string'
&& Object.prototype.hasOwnProperty.call(pipelines, raw.activePipelineId)
? raw.activePipelineId
: null;
return {
enabled, companyName,
activeAgentIds: withoutCeo,
modelOverrides: overrides,
promptOverrides,
knowledgeMixOverrides,
customAgents,
roleCategoryOverrides,
pipelines,
activePipelineId,
};
}
@@ -236,8 +400,220 @@ export async function setAgentKnowledgeMix(
return next;
}
// ── Custom agent CRUD ──────────────────────────────────────────────────────
/**
* Add or replace a user-defined agent. Returns either the new state on
* success, or an error object so the UI can surface validation failures
* (id collision with built-ins, malformed id, etc.). Accepts `unknown`
* because the payload comes off the postMessage channel `_normalizeCustomAgentDef`
* is responsible for shape validation; this wrapper only adds the id-collision
* guard and persists the merged state.
*/
export async function addCustomAgent(
context: vscode.ExtensionContext,
raw: unknown,
): Promise<{ ok: true; state: CompanyState } | { ok: false; reason: string }> {
const def = (raw && typeof raw === 'object') ? raw as Record<string, unknown> : {};
const idCheck = validateCustomAgentId(typeof def.id === 'string' ? def.id : '');
if (idCheck.ok !== true) return { ok: false, reason: idCheck.reason };
const normalized = _normalizeCustomAgentDef(def);
if (!normalized) return { ok: false, reason: '에이전트 정의가 유효하지 않습니다 (id · 이름 · 역할 필수).' };
const cur = readCompanyState(context);
const customAgents = { ...(cur.customAgents ?? {}), [normalized.id]: normalized };
const next: CompanyState = { ...cur, customAgents };
await writeCompanyState(context, next);
return { ok: true, state: next };
}
/**
* Remove a user-defined agent and its overrides. Built-in agents cannot be
* removed returns `ok:false` instead. The active-agent list is also
* cleaned so the removed id doesn't linger as a dangling reference.
*/
export async function removeCustomAgent(
context: vscode.ExtensionContext,
agentId: string,
): Promise<{ ok: true; state: CompanyState } | { ok: false; reason: string }> {
if (COMPANY_AGENTS[agentId]) {
return { ok: false, reason: '기본 에이전트는 삭제할 수 없습니다.' };
}
const cur = readCompanyState(context);
if (!cur.customAgents || !cur.customAgents[agentId]) {
return { ok: false, reason: `'${agentId}' 에이전트를 찾을 수 없습니다.` };
}
const { [agentId]: _gone, ...customAgents } = cur.customAgents;
const { [agentId]: _m, ...modelOverrides } = cur.modelOverrides;
const { [agentId]: _p, ...promptOverrides } = cur.promptOverrides;
const { [agentId]: _k, ...knowledgeMixOverrides } = cur.knowledgeMixOverrides;
const { [agentId]: _r, ...roleCategoryOverrides } = (cur.roleCategoryOverrides ?? {});
const activeAgentIds = cur.activeAgentIds.filter((id) => id !== agentId);
const next: CompanyState = {
...cur, customAgents, modelOverrides, promptOverrides, knowledgeMixOverrides,
roleCategoryOverrides, activeAgentIds,
};
await writeCompanyState(context, next);
return { ok: true, state: next };
}
/**
* Set / clear a per-agent override. Pass `null` to revert the agent
* to its def's own roleCategory. The CEO can't be reclassified (always 'ceo').
*/
export async function setAgentRoleCategory(
context: vscode.ExtensionContext,
agentId: string,
category: AgentRoleCategory | null,
): Promise<{ ok: true; state: CompanyState } | { ok: false; reason: string }> {
if (agentId === 'ceo') return { ok: false, reason: 'CEO 직군은 변경할 수 없습니다.' };
const cur = readCompanyState(context);
if (!getCompanyAgent(agentId) && !cur.customAgents?.[agentId]) {
return { ok: false, reason: `'${agentId}' 에이전트를 찾을 수 없습니다.` };
}
const overrides = { ...(cur.roleCategoryOverrides ?? {}) };
if (category === null || category === undefined) {
delete overrides[agentId];
} else {
if (!VALID_ROLE_CATEGORIES.has(category) || category === 'ceo') {
return { ok: false, reason: `'${category}'는 유효한 직군이 아닙니다.` };
}
overrides[agentId] = category;
}
const next: CompanyState = { ...cur, roleCategoryOverrides: overrides };
await writeCompanyState(context, next);
return { ok: true, state: next };
}
// ── Pipeline CRUD ──────────────────────────────────────────────────────────
/**
* Save a pipeline (insert or replace). Returns the new state on success,
* or an error reason if the payload normalizes to nothing valid.
*/
export async function upsertPipeline(
context: vscode.ExtensionContext,
raw: unknown,
): Promise<{ ok: true; state: CompanyState } | { ok: false; reason: string }> {
const def = _normalizePipeline(raw);
if (!def) return { ok: false, reason: '파이프라인 정의가 유효하지 않습니다 (id 필수, 소문자 시작).' };
const cur = readCompanyState(context);
const pipelines = { ...(cur.pipelines ?? {}), [def.id]: def };
const next: CompanyState = { ...cur, pipelines };
await writeCompanyState(context, next);
return { ok: true, state: next };
}
/**
* Remove a pipeline. Also clears `activePipelineId` if it was pointing at
* the deleted pipeline without that, the dispatcher would silently fall
* back to the legacy planner path, which is confusing.
*/
export async function deletePipeline(
context: vscode.ExtensionContext,
pipelineId: string,
): Promise<{ ok: true; state: CompanyState } | { ok: false; reason: string }> {
const cur = readCompanyState(context);
if (!cur.pipelines || !cur.pipelines[pipelineId]) {
return { ok: false, reason: `'${pipelineId}' 파이프라인을 찾을 수 없습니다.` };
}
const { [pipelineId]: _gone, ...pipelines } = cur.pipelines;
const activePipelineId = cur.activePipelineId === pipelineId ? null : (cur.activePipelineId ?? null);
const next: CompanyState = { ...cur, pipelines, activePipelineId };
await writeCompanyState(context, next);
return { ok: true, state: next };
}
/**
* Set the active pipeline. Pass `null` to revert to the legacy CEO-planner
* path. Unknown ids are rejected so the dispatcher can trust `state.activePipelineId`.
*/
export async function setActivePipeline(
context: vscode.ExtensionContext,
pipelineId: string | null,
): Promise<{ ok: true; state: CompanyState } | { ok: false; reason: string }> {
const cur = readCompanyState(context);
if (pipelineId !== null && pipelineId !== '' && !(cur.pipelines ?? {})[pipelineId]) {
return { ok: false, reason: `'${pipelineId}' 파이프라인을 찾을 수 없습니다.` };
}
const next: CompanyState = { ...cur, activePipelineId: pipelineId || null };
await writeCompanyState(context, next);
return { ok: true, state: next };
}
// ── Derived helpers (no I/O) ────────────────────────────────────────────────
/**
* Resolve the currently-active pipeline definition, or `null` when none is
* selected (legacy planner path).
*/
export function resolveActivePipeline(state: CompanyState): PipelineDef | null {
const id = state.activePipelineId;
if (!id) return null;
return state.pipelines?.[id] ?? null;
}
/**
* State-aware agent lookup with override applied. Built-ins first, then
* custom agents; the agent's effective `roleCategory` always reflects the
* user's override (if any). Returns `undefined` when neither table has the id.
*/
export function resolveAgent(state: CompanyState, agentId: string): CompanyAgentDef | undefined {
const base = getCompanyAgent(agentId) ?? state.customAgents?.[agentId];
if (!base) return undefined;
const ov = state.roleCategoryOverrides?.[agentId];
if (ov && VALID_ROLE_CATEGORIES.has(ov) && ov !== base.roleCategory && agentId !== 'ceo') {
return { ...base, roleCategory: ov };
}
return base;
}
/**
* Returns the effective for an agent (override > def). Falls back to
* 'support' when the agent is unknown so caller code doesn't have to handle
* undefined.
*/
export function resolveAgentRoleCategory(state: CompanyState, agentId: string): AgentRoleCategory {
const def = resolveAgent(state, agentId);
return def?.roleCategory ?? 'support';
}
/**
* All agent definitions visible to the user this turn built-ins followed
* by custom agents in insertion order, with overrides applied. Used by
* the CEO planner (to build the dispatch menu) and the manage UI (to render
* cards).
*/
export function listAllAgents(state: CompanyState): CompanyAgentDef[] {
const out: CompanyAgentDef[] = [];
for (const def of Object.values(COMPANY_AGENTS)) {
out.push(resolveAgent(state, def.id) ?? def);
}
if (state.customAgents) {
for (const def of Object.values(state.customAgents)) {
out.push(resolveAgent(state, def.id) ?? def);
}
}
return out;
}
/**
* All *active* agents grouped by . The pipeline editor uses this to
* populate its "직군 → 담당" cascading dropdown. Inactive agents are filtered
* because dispatching to a disabled agent would be a no-op anyway.
*/
export function listActiveAgentsByCategory(state: CompanyState): Record<AgentRoleCategory, CompanyAgentDef[]> {
const buckets: Record<AgentRoleCategory, CompanyAgentDef[]> = {
ceo: [], planner: [], researcher: [], designer: [],
developer: [], qa: [], inspector: [], support: [],
};
for (const def of listAllAgents(state)) {
const active = def.id === 'ceo' || state.activeAgentIds.includes(def.id);
if (!active) continue;
buckets[def.roleCategory].push(def);
}
return buckets;
}
/**
* 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`.
@@ -245,7 +621,7 @@ export async function setAgentKnowledgeMix(
export function activeAgentIds(state: CompanyState): string[] {
const set = new Set<string>(['ceo']);
for (const id of state.activeAgentIds) {
if (getCompanyAgent(id)) set.add(id);
if (resolveAgent(state, id)) set.add(id);
}
return Array.from(set);
}
@@ -304,9 +680,9 @@ export function resolveCompanyKnowledgeMix(
/**
* Resolve the *effective* prompt fields for an agent merge the static
* default from `agents.ts` with any user-saved override. Returns plain
* strings so the prompt builder doesn't have to worry about which source
* each field came from.
* default from `agents.ts` (or the custom agent def, for user-added agents)
* with any user-saved override. Returns plain strings so the prompt builder
* doesn't have to worry about which source each field came from.
*/
export function resolveAgentPrompt(state: CompanyState, agentId: string): {
persona: string;
@@ -315,7 +691,7 @@ export function resolveAgentPrompt(state: CompanyState, agentId: string): {
/** Whether *any* field is currently overridden — useful for UI hints. */
hasOverride: boolean;
} {
const def = getCompanyAgent(agentId);
const def = resolveAgent(state, agentId);
const ov = state.promptOverrides?.[agentId];
return {
persona: (ov?.persona ?? def?.persona ?? '').toString(),
+135 -18
View File
@@ -39,8 +39,9 @@ import {
mapWeightToBrainFileLimit,
buildKnowledgeMixPolicy,
} from '../../retrieval/knowledgeMix';
import { getCompanyAgent } from './agents';
import { modelForAgent, readCompanyState, resolveCompanyKnowledgeMix } from './companyConfig';
import {
modelForAgent, readCompanyState, resolveActivePipeline, resolveAgent, resolveCompanyKnowledgeMix,
} from './companyConfig';
import { runCeoPlanner } from './ceoPlanner';
import { runCeoReporter } from './ceoReporter';
import { buildSpecialistPrompt } from './promptBuilder';
@@ -57,7 +58,7 @@ import {
writeSessionJson,
} from './sessionStore';
import { buildTelegramReporter, formatCompanyTelegramReport } from './telegramReport';
import { AgentTurnOutput, CompanyState, CompanyTaskPlan, SessionResult } from './types';
import { AgentTurnOutput, CompanyState, CompanyTaskPlan, PipelineDef, PipelineStage, SessionResult } from './types';
/** Trim length applied when an agent's output is fed into the next agent. */
const PEER_OUTPUT_BUDGET = 1500;
@@ -73,6 +74,13 @@ export type CompanyTurnEvent =
| { 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 }
/**
* Pipeline-mode only: emitted when a stage's output matches the
* configured `loopBackPattern` and the dispatcher jumps back to a
* previous stage. The webview uses this to render "🔁 stage X Y
* ( N차)" in the chat.
*/
| { phase: 'stage-loop'; from: string; to: string; iteration: number }
| { phase: 'report-start' }
| { phase: 'report-done'; report: string; ok: boolean }
/**
@@ -147,32 +155,52 @@ export async function runCompanyTurn(
};
if (isAborted()) return fail('signal-aborted');
// ── Phase 1: planner ──
// ── Phase 1: plan (pipeline or legacy planner) ──
emit({ phase: 'plan-start' });
const pipeline = resolveActivePipeline(state);
let plan: CompanyTaskPlan;
let plannerRaw = '';
let plannerParsed = false;
if (pipeline) {
// Pipeline mode: the user has authored a fixed sequence of stages.
// We still surface a `plan` for the report writer and the session
// summary — derived directly from the pipeline definition.
plan = {
brief: `[Pipeline: ${pipeline.name}] ${userPrompt.slice(0, 200)}`,
tasks: pipeline.stages.map((s) => ({ agent: s.agentId, task: s.label })),
};
plannerParsed = true;
} else {
const ceoModel = modelForAgent(state, 'ceo', deps.defaultModel);
const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, { model: ceoModel });
plan = plannerResult.plan;
plannerRaw = plannerResult.raw;
plannerParsed = plannerResult.parsed;
}
if (isAborted()) return fail('aborted-after-plan');
emit({
phase: 'plan-ready',
plan: plannerResult.plan,
parsed: plannerResult.parsed,
raw: plannerResult.raw,
plan,
parsed: plannerParsed,
raw: plannerRaw,
});
writeBrief(sessionDir, userPrompt, plannerResult.plan);
writeBrief(sessionDir, userPrompt, plan);
// ── Phase 2: sequential dispatch ──
const outputs: AgentTurnOutput[] = [];
const total = plannerResult.plan.tasks.length;
if (pipeline) {
const runResult = await _runPipeline(pipeline, userPrompt, plan.brief, sessionDir, timestamp, state, deps, isAborted, emit);
if (runResult.aborted) return fail(runResult.aborted);
outputs.push(...runResult.outputs);
} else {
const total = plan.tasks.length;
for (let i = 0; i < total; i++) {
if (isAborted()) return fail('aborted-mid-dispatch');
const task = plannerResult.plan.tasks[i];
const task = 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,
@@ -180,6 +208,7 @@ export async function runCompanyTurn(
);
emit({ phase: 'agent-done', agentId: task.agent, output: turn, index: i, total });
}
}
// ── Phase 3: synthesis ──
if (isAborted()) return fail('aborted-before-report');
@@ -187,7 +216,7 @@ export async function runCompanyTurn(
const reportModel = modelForAgent(state, 'ceo', deps.defaultModel);
const reportResult = await runCeoReporter(
deps.ai,
plannerResult.plan,
plan,
outputs,
state,
{ model: reportModel },
@@ -209,7 +238,7 @@ export async function runCompanyTurn(
const tgText = formatCompanyTelegramReport({
state,
userPrompt,
plan: plannerResult.plan,
plan,
outputs,
report: reportResult.report,
sessionTimestamp: timestamp,
@@ -229,7 +258,7 @@ export async function runCompanyTurn(
const result: SessionResult = {
timestamp, sessionDir,
userPrompt,
plan: plannerResult.plan,
plan,
agentOutputs: outputs,
report: reportResult.report,
totalDurationMs: Date.now() - startedAt,
@@ -260,7 +289,7 @@ async function _dispatchOne(
deps: DispatcherDeps,
): Promise<AgentTurnOutput> {
const startedAt = Date.now();
const def = getCompanyAgent(agentId);
const def = resolveAgent(state, agentId);
if (!def) {
return {
agentId, task, response: '', durationMs: 0,
@@ -272,7 +301,7 @@ async function _dispatchOne(
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 peerDef = resolveAgent(state, o.agentId);
const body = o.response.length > PEER_OUTPUT_BUDGET
? o.response.slice(0, PEER_OUTPUT_BUDGET) + '\n…(truncated)'
: o.response;
@@ -399,6 +428,94 @@ async function _dispatchOne(
}
}
/**
* Run an authored pipeline: each stage dispatches its agent with a templated
* instruction. Stages can declare a `loopBackPattern` regex when it
* matches the stage's output, the dispatcher jumps back to `loopBackTo` (a
* stage that must precede the current one). Iteration count is bounded by
* `maxIterations` (default 3) to keep run-away loops from hanging the user.
*
* Returns `{ outputs, aborted }`: `aborted` is set only when the abort
* signal flipped mid-run; the outer dispatcher then short-circuits.
*/
async function _runPipeline(
pipeline: PipelineDef,
userPrompt: string,
brief: string,
sessionDir: string,
timestamp: string,
state: CompanyState,
deps: DispatcherDeps,
isAborted: () => boolean,
emit: CompanyTurnEmitter,
): Promise<{ outputs: AgentTurnOutput[]; aborted?: string }> {
const outputs: AgentTurnOutput[] = [];
// Keep the latest output per stage id so `{{stage.<id>}}` template
// tokens always resolve to the most recent value across loop-backs.
const latestByStage: Record<string, AgentTurnOutput> = {};
const iterations: Record<string, number> = {};
const total = pipeline.stages.length;
let i = 0;
let stepIndex = 0;
while (i < pipeline.stages.length) {
if (isAborted()) return { outputs, aborted: 'aborted-mid-pipeline' };
const stage = pipeline.stages[i];
const task = _renderStageInstruction(stage, userPrompt, brief, latestByStage);
emit({ phase: 'agent-start', agentId: stage.agentId, task, index: stepIndex, total });
const turn = await _dispatchOne(stage.agentId, task, outputs, state, deps);
outputs.push(turn);
latestByStage[stage.id] = turn;
writeAgentOutput(sessionDir, turn);
appendAgentMemory(
deps.context, stage.agentId,
`[${timestamp}][${pipeline.id}/${stage.id}] ${task.slice(0, 120)}${turn.error ? `${turn.error}` : '✅'}`,
);
emit({ phase: 'agent-done', agentId: stage.agentId, output: turn, index: stepIndex, total });
stepIndex++;
// Loop-back evaluation. We only loop on *successful* responses with
// non-empty body — an error or empty response would loop forever.
if (stage.loopBackTo && stage.loopBackPattern && !turn.error && turn.response.trim()) {
const limit = stage.maxIterations ?? 3;
const count = (iterations[stage.id] ?? 0) + 1;
iterations[stage.id] = count;
let re: RegExp | null = null;
try { re = new RegExp(stage.loopBackPattern, 'i'); } catch { re = null; }
if (re && re.test(turn.response) && count <= limit) {
const targetIdx = pipeline.stages.findIndex((s) => s.id === stage.loopBackTo);
if (targetIdx !== -1 && targetIdx < i) {
emit({ phase: 'stage-loop', from: stage.id, to: stage.loopBackTo, iteration: count });
i = targetIdx;
continue;
}
}
}
i++;
}
return { outputs };
}
/**
* Substitute template tokens in a stage's instruction. Falls back to the
* raw user prompt when the template is empty so the user doesn't have to
* fill every stage with a long template just to forward the original ask.
*/
function _renderStageInstruction(
stage: PipelineStage,
userPrompt: string,
brief: string,
latestByStage: Record<string, AgentTurnOutput>,
): string {
const tpl = (stage.instructionTemplate || '').trim();
if (!tpl) return userPrompt;
return tpl
.replace(/\{\{\s*userPrompt\s*\}\}/g, userPrompt)
.replace(/\{\{\s*brief\s*\}\}/g, brief)
.replace(/\{\{\s*stage\.([a-zA-Z0-9_-]+)\s*\}\}/g, (_m, sid) => {
const o = latestByStage[sid];
return o?.response ?? `[stage:${sid} 아직 실행되지 않음]`;
});
}
/**
* Cheap pre-check so we don't fire up the action-tag executor for every
* specialist response only the ones that actually contain a recognised
+20
View File
@@ -22,6 +22,18 @@ export {
setAgentModelOverride,
setAgentPromptOverride,
setAgentKnowledgeMix,
addCustomAgent,
removeCustomAgent,
validateCustomAgentId,
setAgentRoleCategory,
upsertPipeline,
deletePipeline,
setActivePipeline,
resolveActivePipeline,
resolveAgent,
resolveAgentRoleCategory,
listAllAgents,
listActiveAgentsByCategory,
resolveAgentPrompt,
resolveCompanyKnowledgeMix,
activeAgentIds,
@@ -30,6 +42,14 @@ export {
summarizeForChip,
} from './companyConfig';
export type {
AgentRoleCategory,
PipelineDef,
PipelineStage,
} from './types';
export { ROLE_CATEGORY_LABELS, ROLE_CATEGORY_ORDER } from './types';
export type {
CompanyAgentDef,
CompanyState,
+7 -5
View File
@@ -15,8 +15,8 @@
* 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 { resolveAgentPrompt } from './companyConfig';
import { COMPANY_AGENTS } from './agents';
import { listAllAgents, resolveAgent, resolveAgentPrompt } from './companyConfig';
import { CompanyState } from './types';
export interface SpecialistPromptInputs {
@@ -57,7 +57,7 @@ export interface SpecialistPromptInputs {
* dense paragraphs. Order matters: identity first, then rules, then context.
*/
export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string {
const agent = getCompanyAgent(inputs.agentId);
const agent = resolveAgent(inputs.state, inputs.agentId);
if (!agent) {
// Defensive fallback — should never happen because the dispatcher
// filters tasks against the active agent set before calling us.
@@ -188,13 +188,15 @@ export function buildPlannerSystemPrompt(
): string {
const active = new Set<string>(state.activeAgentIds);
active.add('ceo');
const inactive = Object.keys(COMPANY_AGENTS).filter((id) => !active.has(id));
// Built-ins + custom agents — both can be deactivated by the user.
const allIds = listAllAgents(state).map((a) => a.id);
const inactive = allIds.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];
const def = resolveAgent(state, id) ?? COMPANY_AGENTS[id];
tail.push(`- ${id} (${def?.name ?? id})`);
}
}
+2 -2
View File
@@ -26,7 +26,7 @@ import * as vscode from 'vscode';
import { logError, logInfo } from '../../utils';
import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
import { appendTelegramMessage } from '../../integrations/telegram/conversationHistory';
import { COMPANY_AGENTS } from './agents';
import { resolveAgent } from './companyConfig';
import { AgentTurnOutput, CompanyState, CompanyTaskPlan } from './types';
/** Same key the rest of the extension uses. Defined locally so this module is dependency-free. */
@@ -130,7 +130,7 @@ export function formatCompanyTelegramReport(opts: {
const brief = opts.plan.brief ? `\n\n*브리프:* ${opts.plan.brief}` : '';
const agentsLine = opts.plan.tasks.length > 0
? '\n\n*완료한 에이전트:*\n' + opts.plan.tasks.map((t) => {
const def = COMPANY_AGENTS[t.agent];
const def = resolveAgent(opts.state, t.agent);
const ranOk = opts.outputs.find((o) => o.agentId === t.agent && !o.error);
const mark = ranOk ? '✅' : '⚠️';
return `${mark} ${def?.emoji ?? ''} ${def?.name ?? t.agent}`;
+118
View File
@@ -9,6 +9,50 @@
* model-constrained machine without RAM thrash.
*/
/**
* (role category). Drives the pipeline editor's "직군 → 담당 에이전트"
* cascading dropdown and the CEO planner's "no 개발 before 기획" guard rail.
*
* Why a fixed enum instead of free-text categories: a small LLM (gemma e2b
* etc.) can't reliably *cluster* free-text specialties at plan time, but it
* can absolutely reason about "planner first, developer second" when the
* planner prompt enumerates these slugs. The UI also needs a stable set so
* the dropdown is short and the icons are pickable.
*
* ceo orchestrator (only the built-in CEO)
* planner ( ·· )
* researcher ··
* designer UI/UX···
* developer ·API··
* qa · ·
* inspector /
* support ·· ( X, )
*/
export type AgentRoleCategory =
| 'ceo'
| 'planner'
| 'researcher'
| 'designer'
| 'developer'
| 'qa'
| 'inspector'
| 'support';
export const ROLE_CATEGORY_LABELS: Record<AgentRoleCategory, string> = {
ceo: 'CEO',
planner: '기획',
researcher: '리서치',
designer: '디자인',
developer: '개발',
qa: 'QA',
inspector: '감리',
support: '지원',
};
export const ROLE_CATEGORY_ORDER: AgentRoleCategory[] = [
'ceo', 'planner', 'researcher', 'designer', 'developer', 'qa', 'inspector', 'support',
];
/** Static description of a company agent. Loaded from `agents.ts`. */
export interface CompanyAgentDef {
/** Stable identifier used in JSON plans, file names, config keys. */
@@ -27,6 +71,14 @@ export interface CompanyAgentDef {
tagline: string;
/** Optional voice / personality directive injected into the system prompt. */
persona?: string;
/**
* drives pipeline editor cascading dropdowns and CEO planner
* sequencing rules. Built-ins ship with a fixed category; users may
* override via state.roleCategoryOverrides (custom agents pick at
* create time and can re-edit later). Falls back to 'support' when
* a legacy state entry is missing this field.
*/
roleCategory: AgentRoleCategory;
/**
* When true, this agent can't be toggled off in the UI. CEO uses this so
* it's always available as the orchestrator.
@@ -88,6 +140,72 @@ export interface CompanyState {
* because their job is to cite recorded knowledge.
*/
knowledgeMixOverrides: Record<string, number>;
/**
* User-added agents. Merged with the built-in `COMPANY_AGENTS` roster at
* read time the built-ins are immutable code; everything in here is
* user-defined and editable. Keys are agent ids (lowercase, kebab-case),
* which must NOT collide with built-in ids.
*/
customAgents?: Record<string, CompanyAgentDef>;
/**
* Per-agent override. Lets the user re-classify a built-in agent
* without forking the code (e.g. promote Writer from `planner` to
* `inspector` if they want that workflow). Missing key use the
* agent def's own `roleCategory`.
*/
roleCategoryOverrides?: Record<string, AgentRoleCategory>;
/**
* User-defined work pipelines. When `activePipelineId` is set to a key
* here, the dispatcher runs the pipeline's stages in order instead of
* letting the CEO planner emit ad-hoc tasks. Empty / unset legacy
* behaviour (CEO planner linear dispatch).
*/
pipelines?: Record<string, PipelineDef>;
/**
* Which pipeline drives this turn. `null` / undefined legacy CEO
* planner path. Must reference a key in `pipelines` or it is ignored.
*/
activePipelineId?: string | null;
}
/**
* One step of a custom pipeline. Each stage runs a specific agent with a
* specific instruction template (supports `{{userPrompt}}` and stage-output
* variables like `{{stage.plan}}`). The stage can optionally loop back to
* a previous stage when its output matches a regex the canonical use
* case is "QA finds a bug → go back to dev".
*/
export interface PipelineStage {
/** Stable id within the pipeline. Used for `loopBackTo` references. */
id: string;
/** Human label shown in the chat phase header and the editor. */
label: string;
/** Which agent runs this stage. Must resolve via `resolveAgent`. */
agentId: string;
/**
* Instruction template. Tokens substituted before dispatch:
* - `{{userPrompt}}` what the user typed
* - `{{brief}}` CEO brief from the kickoff stage
* - `{{stage.<id>}}` full output of a previous stage
* Empty fall back to `{{userPrompt}}`.
*/
instructionTemplate: string;
/**
* Regex (string form, case-insensitive) applied to this stage's output.
* On match the dispatcher jumps back to `loopBackTo`. Iterations counted
* see `maxIterations`.
*/
loopBackPattern?: string;
/** Stage id to jump to on `loopBackPattern` match. Must precede this stage. */
loopBackTo?: string;
/** Safety cap on how many times this stage can loop back. Default 3. */
maxIterations?: number;
}
export interface PipelineDef {
id: string;
name: string;
stages: PipelineStage[];
}
/** Output of the CEO planner LLM call after JSON parsing. */
+91
View File
@@ -75,6 +75,9 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
await provider._sendBrainStatus();
return true;
case 'stopGeneration':
// 1인 기업 모드는 AgentExecutor를 거치지 않으므로 별도 abort 경로.
// 두 경로 모두 신호를 보내 두면 중간에 모드 전환되어도 안전.
provider.abortCompanyTurn();
provider._agent.stop();
return true;
case 'loadSession':
@@ -203,6 +206,21 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
}
return true;
}
case 'setCompanyAgentRoleCategory': {
// Override an agent's 직군. Empty / null payload value reverts to
// the def's own roleCategory. CEO is rejected by the backend.
const { setAgentRoleCategory } = await import('../features/company');
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
if (!agentId) return true;
const cat = (typeof data.value === 'string' && data.value.trim()) ? data.value.trim() : null;
const result = await setAgentRoleCategory(provider._context, agentId, cat as any);
provider._view?.webview.postMessage({
type: 'setCompanyAgentRoleCategoryResult',
value: result.ok ? { ok: true, agentId } : { ok: false, reason: result.reason },
});
if (result.ok) await provider._sendCompanyAgents();
return true;
}
case 'setCompanyAgentKnowledgeMix': {
// Per-agent Knowledge Mix override. `null`/missing value falls
// back to the global slider. The dispatcher reads this on the
@@ -238,6 +256,79 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
await provider._sendCompanyAgents();
return true;
}
case 'addCompanyAgent': {
// User-defined agent. Payload: { def: CompanyAgentDef }. Returns
// an `addCompanyAgentResult` so the UI overlay can keep its form
// open + show an error when validation fails (id collision etc.).
const { addCustomAgent } = await import('../features/company');
const def = data.def;
const result = await addCustomAgent(provider._context, def ?? {});
provider._view?.webview.postMessage({
type: 'addCompanyAgentResult',
value: result.ok
? { ok: true, agentId: def?.id }
: { ok: false, reason: result.reason },
});
if (result.ok) {
await provider._sendCompanyStatus();
await provider._sendCompanyAgents();
}
return true;
}
case 'deleteCompanyAgent': {
// Drop a user-defined agent. Built-ins refuse — backend enforces.
const { removeCustomAgent } = await import('../features/company');
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
if (!agentId) return true;
const result = await removeCustomAgent(provider._context, agentId);
provider._view?.webview.postMessage({
type: 'deleteCompanyAgentResult',
value: result.ok ? { ok: true, agentId } : { ok: false, reason: result.reason },
});
if (result.ok) {
await provider._sendCompanyStatus();
await provider._sendCompanyAgents();
}
return true;
}
case 'getCompanyPipelines':
await provider._sendCompanyPipelines();
return true;
case 'upsertCompanyPipeline': {
const { upsertPipeline } = await import('../features/company');
const result = await upsertPipeline(provider._context, data.def ?? {});
provider._view?.webview.postMessage({
type: 'upsertCompanyPipelineResult',
value: result.ok ? { ok: true } : { ok: false, reason: result.reason },
});
if (result.ok) await provider._sendCompanyPipelines();
return true;
}
case 'deleteCompanyPipeline': {
const { deletePipeline } = await import('../features/company');
const pid = typeof data.pipelineId === 'string' ? data.pipelineId : '';
if (!pid) return true;
const result = await deletePipeline(provider._context, pid);
provider._view?.webview.postMessage({
type: 'deleteCompanyPipelineResult',
value: result.ok ? { ok: true, pipelineId: pid } : { ok: false, reason: result.reason },
});
if (result.ok) await provider._sendCompanyPipelines();
return true;
}
case 'setActiveCompanyPipeline': {
const { setActivePipeline } = await import('../features/company');
const pid = typeof data.pipelineId === 'string' && data.pipelineId.trim()
? data.pipelineId.trim()
: null;
const result = await setActivePipeline(provider._context, pid);
provider._view?.webview.postMessage({
type: 'setActiveCompanyPipelineResult',
value: result.ok ? { ok: true, pipelineId: pid } : { ok: false, reason: result.reason },
});
if (result.ok) await provider._sendCompanyPipelines();
return true;
}
case 'proactiveTrigger':
await provider._handleProactiveSuggestion(data.context);
return true;
+99 -19
View File
@@ -42,6 +42,9 @@ import {
CompanyTurnEvent,
COMPANY_AGENTS,
COMPANY_AGENT_ORDER,
ROLE_CATEGORY_LABELS,
ROLE_CATEGORY_ORDER,
resolveAgent,
} from './features/company';
import { AIService } from './core/services';
@@ -91,6 +94,14 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
_modelsCache: { url: string; models: string[]; online: boolean; fetchedAt: number } | null = null;
static readonly MODELS_CACHE_TTL_MS = 30000;
/**
* AbortController for the currently-running 1 turn. Cleared when
* the turn ends (success or fail). The webview's Stop button routes
* through `stopGeneration`, which calls `abortCompanyTurn()` to flip this
* the dispatcher's `signal` then short-circuits between phases.
*/
private _companyAbort?: AbortController;
/** Active file-system watcher for the project-architecture auto-update. Disposed on switch/detach. */
private _archWatcher?: vscode.FileSystemWatcher;
/** Debounce timer for the architecture watcher. */
@@ -1453,6 +1464,49 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
return readCompanyState(this._context).enabled;
}
/**
* Abort the currently-running 1 turn if any. Returns true when an
* abort was actually fired (so the chat handler can skip `agent.stop()`
* the company path never touches AgentExecutor). The dispatcher will
* see `signal.aborted` at its next phase boundary and emit
* `phase: 'aborted'`; `_runCompanyTurn`'s finally clause then posts
* `streamEnd` so the UI unlocks.
*/
abortCompanyTurn(): boolean {
if (!this._companyAbort) return false;
this._companyAbort.abort();
return true;
}
/**
* Push the full pipeline catalogue + active id to the webview so the
* editor overlay can render the cards. Pipelines are user-defined
* (no built-ins) so an empty list is the default for new users.
*/
async _sendCompanyPipelines(): Promise<void> {
if (!this._view) return;
const state = readCompanyState(this._context);
// 직군별 활성 에이전트도 같이 — 파이프라인 에디터가 "직군 → 담당자"
// cascading dropdown을 채울 때 이 페이로드만 보고 그릴 수 있게.
const { listActiveAgentsByCategory } = await import('./features/company');
const byCategory = listActiveAgentsByCategory(state);
// CompanyAgentDef를 통째로 보내는 대신 UI에 필요한 필드만 추려서.
const slimByCategory: Record<string, Array<{ id: string; name: string; emoji: string }>> = {};
for (const [cat, defs] of Object.entries(byCategory)) {
slimByCategory[cat] = defs.map((d) => ({ id: d.id, name: d.name, emoji: d.emoji }));
}
this._view.webview.postMessage({
type: 'companyPipelines',
value: {
pipelines: state.pipelines ?? {},
activePipelineId: state.activePipelineId ?? null,
roleCategoryLabels: ROLE_CATEGORY_LABELS,
roleCategoryOrder: ROLE_CATEGORY_ORDER,
activeAgentsByCategory: slimByCategory,
},
});
}
/** Send the chip state (active flag + agent count + name) to the webview. */
async _sendCompanyStatus(): Promise<void> {
if (!this._view) return;
@@ -1480,44 +1534,63 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
const state = readCompanyState(this._context);
const cfg = getConfig();
const globalWeight = cfg.knowledgeMixSecondBrainWeight ?? 50;
const agents = COMPANY_AGENT_ORDER.map((id) => {
const def = COMPANY_AGENTS[id];
// Built-ins first (insertion order from agents.ts), then user-added
// customs in their own order. `custom: true` lets the UI render a
// delete button only for user-added entries.
const builtinIds = COMPANY_AGENT_ORDER.filter((id) => !!COMPANY_AGENTS[id]);
const customIds = state.customAgents ? Object.keys(state.customAgents) : [];
const orderedIds = [...builtinIds, ...customIds];
const renderEntry = (id: string) => {
const builtin = COMPANY_AGENTS[id];
const custom = state.customAgents?.[id];
const baseDef = builtin ?? custom;
if (!baseDef) return null;
// 직군 override 적용된 effective def. 카드의 드롭다운이 옳은 선택값을
// 보이려면 override 결과를 보내야 한다.
const effective = resolveAgent(state, id) ?? baseDef;
const isCustom = !builtin;
const override = state.promptOverrides[id] || {};
const kmOverride = state.knowledgeMixOverrides[id];
const hasKmOverride = typeof kmOverride === 'number';
const roleOverride = state.roleCategoryOverrides?.[id];
return {
id,
name: def.name,
role: def.role,
emoji: def.emoji,
color: def.color,
alwaysOn: !!def.alwaysOn,
name: effective.name,
role: effective.role,
emoji: effective.emoji,
color: effective.color,
alwaysOn: !!effective.alwaysOn,
custom: isCustom,
active: id === 'ceo' || state.activeAgentIds.includes(id),
modelOverride: state.modelOverrides[id] || '',
// Defaults — never change at runtime.
defaultTagline: def.tagline,
defaultSpecialty: def.specialty,
defaultPersona: def.persona || '',
// Current effective values (default + override merged).
tagline: override.tagline || def.tagline,
specialty: override.specialty || def.specialty,
persona: override.persona || def.persona || '',
// Per-field override flags for the UI.
defaultTagline: baseDef.tagline,
defaultSpecialty: baseDef.specialty,
defaultPersona: baseDef.persona || '',
tagline: override.tagline || baseDef.tagline,
specialty: override.specialty || baseDef.specialty,
persona: override.persona || baseDef.persona || '',
personaOverridden: !!override.persona,
specialtyOverridden: !!override.specialty,
taglineOverridden: !!override.tagline,
// Knowledge Mix — null when using global default, number otherwise.
// 직군: effective(override 반영) + def 기본값 + override 플래그
roleCategory: effective.roleCategory,
defaultRoleCategory: baseDef.roleCategory,
roleCategoryOverridden: !!roleOverride && roleOverride !== baseDef.roleCategory,
knowledgeMixOverride: hasKmOverride ? kmOverride : null,
// What the dispatcher *will actually use* this turn (for hint UI).
effectiveKnowledgeMixWeight: hasKmOverride ? kmOverride : globalWeight,
};
});
};
const agents = orderedIds.map(renderEntry).filter((x): x is NonNullable<ReturnType<typeof renderEntry>> => !!x);
this._view.webview.postMessage({
type: 'companyAgents',
value: {
companyName: state.companyName,
globalKnowledgeMixWeight: globalWeight,
agents,
// 직군 라벨 사전 + 표시 순서. 웹뷰는 enum 값을 모르므로
// 백엔드가 정한 라벨/순서를 같이 보내 UI 일관성을 유지.
roleCategoryLabels: ROLE_CATEGORY_LABELS,
roleCategoryOrder: ROLE_CATEGORY_ORDER,
},
});
}
@@ -1534,6 +1607,11 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
const emit = (event: CompanyTurnEvent) => {
this._view?.webview.postMessage({ type: 'companyTurnUpdate', value: event });
};
// Fresh AbortController per turn — the Stop button routes through
// `abortCompanyTurn()` to fire `.abort()`. The dispatcher checks
// `signal.aborted` between phases and short-circuits cleanly.
const abort = new AbortController();
this._companyAbort = abort;
try {
await runCompanyTurn(userPrompt, {
context: this._context,
@@ -1550,6 +1628,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
// hit disk. Without this, agents would *claim* to create
// files while nothing happened — the exact bug we just fixed.
executeActionTags: (text) => this._agent.executeActionTagsOnText(text),
signal: abort.signal,
onEvent: emit,
});
} catch (e: any) {
@@ -1559,6 +1638,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
value: `1인 기업 모드 실행 실패: ${e?.message ?? e}`,
});
} finally {
if (this._companyAbort === abort) this._companyAbort = undefined;
// The webview's send button is locked into the "generating" state
// when the user submits; it only unlocks on `streamEnd`. The
// normal chat path posts that from inside AgentExecutor, but