Update ConnectAI codebase

This commit is contained in:
g1nation
2026-05-18 08:15:01 +09:00
parent 88664c7c6e
commit 86cacaeb03
38 changed files with 1043 additions and 99 deletions
+27 -26
View File
@@ -3,15 +3,15 @@
<!-- ASTRA:AUTO-START --> <!-- ASTRA:AUTO-START -->
## Snapshot ## Snapshot
- **Workspace**: `ConnectAI` `v2.2.18` _(absolute path varies by environment; resolved from the active VS Code workspace)_ - **Workspace**: `ConnectAI` `v2.2.19` _(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. - **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 - **Stack**: TypeScript, Node.js, VS Code Extension, LM Studio SDK, Test runner
- **Stats**: 257 source files, ~52,121 lines across 5 top-level modules. - **Stats**: 265 source files, ~52,981 lines across 5 top-level modules.
## Last Refresh ## Last Refresh
- **Time**: 2026-05-16T14:34:03.239Z - **Time**: 2026-05-17T14:50:50.535Z
- **Files newly analysed**: 3 - **Files newly analysed**: 5
- **Files reused from cache**: 254 - **Files reused from cache**: 260
## Directory Map ## Directory Map
```mermaid ```mermaid
@@ -37,11 +37,11 @@ mindmap
> Arrows: which top-level module imports from which. > Arrows: which top-level module imports from which.
```mermaid ```mermaid
flowchart LR flowchart LR
src["src/<br/>134 files"] src["src/<br/>137 files"]
media["media/<br/>6 files"] media["media/<br/>6 files"]
tests["tests/<br/>33 files"] tests["tests/<br/>35 files"]
core_py["core_py/<br/>6 files"] core_py["core_py/<br/>6 files"]
docs["docs/<br/>78 files"] docs["docs/<br/>81 files"]
tests --> src tests --> src
``` ```
@@ -64,10 +64,10 @@ flowchart LR
## Modules ## Modules
### `src/` — 134 files, ~35,756 lines ### `src/` — 137 files, ~36,157 lines
**Sub-directories** **Sub-directories**
- `src/features/` (61) — Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema 도 같은 entry 로 노출 예정. 현재 노출: full webview panel H - `src/features/` (64) — Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema 도 같은 entry 로 노출 예정. 현재 노출: full webview panel H
- `src/core/` (15) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토 - `src/core/` (15) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토
- `src/memory/` (8) — Episodic Memory (일화 기억) 과거 대화/회의/결정의 맥락 흐름을 저장합니다. 세션 종료 시 자동으로 에피소드를 요약하여 저장합니다. "왜 이렇게 결정했는지", "어떤 흐름으로 진행했는지" 기록. 저장 - `src/memory/` (8) — Episodic Memory (일화 기억) 과거 대화/회의/결정의 맥락 흐름을 저장합니다. 세션 종료 시 자동으로 에피소드를 요약하여 저장합니다. "왜 이렇게 결정했는지", "어떤 흐름으로 진행했는지" 기록. 저장
- `src/retrieval/` (8) — Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-I - `src/retrieval/` (8) — Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-I
@@ -92,32 +92,32 @@ flowchart LR
- `src/retrieval/scoring.ts` (518 lines) — Scoring Engine — TF-IDF + Bilingual Tokenizer 단순 includes() 키워드 매칭을 넘어서, TF-IDF 가중치 기반의 문서 스코어링을 제공합니다. 한국어/영어 양국어 토크나이저를 포함합니다. - `src/retrieval/scoring.ts` (518 lines) — Scoring Engine — TF-IDF + Bilingual Tokenizer 단순 includes() 키워드 매칭을 넘어서, TF-IDF 가중치 기반의 문서 스코어링을 제공합니다. 한국어/영어 양국어 토크나이저를 포함합니다.
- `src/skills/agentKnowledgeMap.ts` (374 lines) - `src/skills/agentKnowledgeMap.ts` (374 lines)
- `src/retrieval/lessonHelpers.ts` (325 lines) — Lesson / Experience Memory — pure helpers (no vscode dependency) "Lesson" = a markdown file in the active brain that captures a past mistake/risk and how to avoid repeating it. Identified by a lessons - `src/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/agent.ts` (3579 lines) - `src/agent.ts` (3633 lines)
- `src/features/providers/types.ts` (63 lines) — Cloud LLM provider routing — model id prefix → provider id 매핑. Prefix 규칙: openrouter:anthropic/claude-3.5-sonnet → { provider: 'openrouter', model: 'anthropic/claude-3.5-sonnet' } anthropic:claude-3-5 - `src/features/providers/types.ts` (63 lines) — Cloud LLM provider routing — model id prefix → provider id 매핑. Prefix 규칙: openrouter:anthropic/claude-3.5-sonnet → { provider: 'openrouter', model: 'anthropic/claude-3.5-sonnet' } anthropic:claude-3-5
- `src/lib/engine.ts` (906 lines) - `src/lib/engine.ts` (906 lines)
- `src/features/company/dispatcher.ts` (1435 lines) — Sequential dispatcher for 1인 기업 모드. Drives one company "turn": user prompt → CEO planner (JSON {brief, tasks}) → for each task in plan: dispatch one specialist (sequentially) - build specialist prompt - `src/features/company/dispatcher.ts` (1435 lines) — Sequential dispatcher for 1인 기업 모드. Drives one company "turn": user prompt → CEO planner (JSON {brief, tasks}) → for each task in plan: dispatch one specialist (sequentially) - build specialist prompt
- `src/features/providers/providerConfig.ts` (78 lines) — Provider 별 API key + enable 토글 저장소. 설계: - API key 자체는 vscode.SecretStorage (secrets) 에 — settings.json / Settings Sync 침범 안 받음. - enabled 토글은 일반 settings (g1nation.providers.<id>.enabled) — 사용자가 패널에서 - `src/features/providers/providerConfig.ts` (78 lines) — Provider 별 API key + enable 토글 저장소. 설계: - API key 자체는 vscode.SecretStorage (secrets) 에 — settings.json / Settings Sync 침범 안 받음. - enabled 토글은 일반 settings (g1nation.providers.<id>.enabled) — 사용자가 패널에서
- `src/features/approval/approvalQueue.ts` (129 lines) - `src/features/approval/approvalQueue.ts` (129 lines)
- `src/integrations/telegram/telegramClient.ts` (154 lines) - `src/integrations/telegram/telegramClient.ts` (154 lines)
- `src/features/astraOffice/view/runtime.ts` (1765 lines) — 자동 분리: src/sidebarProvider.ts 4002-5116 (IIFE 본문) 에서 추출. 동작 동등. ${assets.derivedBase} placeholder 는 panelHtml 에서 .replace() 로 실제 값 주입. 다음 세션에서 OfficeSnapshot 기반으로 단계적으로 잘라낼 예정. - `src/features/astraOffice/view/runtime.ts` (1887 lines) — 자동 분리: src/sidebarProvider.ts 4002-5116 (IIFE 본문) 에서 추출. 동작 동등. ${assets.derivedBase} placeholder 는 panelHtml 에서 .replace() 로 실제 값 주입. 다음 세션에서 OfficeSnapshot 기반으로 단계적으로 잘라낼 예정.
- `src/features/company/agents.ts` (211 lines) — 기본 에이전트 로스터 — 1인 기업 모드의 출고 디폴트. 설계 의도: 소프트웨어/게임 개발 IT 회사의 1인 기업 운영을 가정. 한 사람이 기획 → 디자인 → 개발 → QA → 출시 → 운영/마케팅을 모두 책임질 때 필요한 직군을 빠짐없이 커버하되 역할이 겹치지 않게 분리한다. 직군 구분 (혼동 방지): - 기획자(business) : 무엇을 만들지 정의 - `src/features/company/agents.ts` (211 lines) — 기본 에이전트 로스터 — 1인 기업 모드의 출고 디폴트. 설계 의도: 소프트웨어/게임 개발 IT 회사의 1인 기업 운영을 가정. 한 사람이 기획 → 디자인 → 개발 → QA → 출시 → 운영/마케팅을 모두 책임질 때 필요한 직군을 빠짐없이 커버하되 역할이 겹치지 않게 분리한다. 직군 구분 (혼동 방지): - 기획자(business) : 무엇을 만들지 정의
- `src/features/company/pixelOfficeState.ts` (286 lines) — Pixel Office — Agent Work Pipeline 상태를 시각화하는 UI Layer 전용 모듈. ─────────────────── 설계 원칙 ─────────────────── 1. Agent 핵심 판단 로직을 절대 바꾸지 않는다. Pipeline 진행, contract 합의, 검수 cycle, 승인 게이트 — 모두 기존 dispatcher - `src/features/company/pixelOfficeState.ts` (286 lines) — Pixel Office — Agent Work Pipeline 상태를 시각화하는 UI Layer 전용 모듈. ─────────────────── 설계 원칙 ─────────────────── 1. Agent 핵심 판단 로직을 절대 바꾸지 않는다. Pipeline 진행, contract 합의, 검수 cycle, 승인 게이트 — 모두 기존 dispatcher
- `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 - `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
- `src/features/projectArchitecture/scanner.ts` (644 lines) — Deep static analyser for the Project Architecture Context generator. Walks the project tree (skipping the usual nodemodules / out / dist noise), pulls the role of each interesting file from its leadin - `src/features/projectArchitecture/scanner.ts` (644 lines) — Deep static analyser for the Project Architecture Context generator. Walks the project tree (skipping the usual nodemodules / out / dist noise), pulls the role of each interesting file from its leadin
- `src/lib/contextManager.ts` (275 lines) — Context Manager (컨텍스트 한계 관리) "context length = 132k" 는 "답변을 132k 토큰까지 생성해도 된다" 가 아닙니다. 시스템 프롬프트 + 대화 기록 + 입력 문서 + 생성될 답변 + 여유분 ≤ context length 이 모듈은 요청을 보내기 전에 입력 토큰을 추정하고, - 동적으로 출력 상한(maxTokens)을 계 - `src/lib/contextManager.ts` (275 lines) — Context Manager (컨텍스트 한계 관리) "context length = 132k" 는 "답변을 132k 토큰까지 생성해도 된다" 가 아닙니다. 시스템 프롬프트 + 대화 기록 + 입력 문서 + 생성될 답변 + 여유분 ≤ context length 이 모듈은 요청을 보내기 전에 입력 토큰을 추정하고, - 동적으로 출력 상한(maxTokens)을 계
- `src/extension.ts` (1202 lines) - `src/extension.ts` (1215 lines)
### `media/` — 6 files, ~7,011 lines ### `media/` — 6 files, ~7,243 lines
**Key files** **Key files**
- `media/sidebar.css` (2016 lines) — Stylesheet - `media/sidebar.css` (2068 lines) — Stylesheet
- `media/sidebar.js` (3657 lines) - `media/sidebar.js` (3680 lines)
- `media/sidebar.html` (546 lines) — Astra - `media/sidebar.html` (546 lines) — Astra
- `media/settings-panel.html` (328 lines) — Astra Settings
- `media/settings-panel.css` (210 lines) — Stylesheet - `media/settings-panel.css` (210 lines) — Stylesheet
- `media/settings-panel.html` (244 lines) — Astra Settings - `media/settings-panel.js` (411 lines)
- `media/settings-panel.js` (338 lines)
### `tests/` — 33 files, ~5,811 lines ### `tests/` — 35 files, ~5,969 lines
*Depends on*: `src/` *Depends on*: `src/`
**Sub-directories** **Sub-directories**
@@ -160,10 +160,10 @@ flowchart LR
- `core_py/optimizer.py` (55 lines) - `core_py/optimizer.py` (55 lines)
- `core_py/queue_worker.py` (82 lines) - `core_py/queue_worker.py` (82 lines)
### `docs/` — 78 files, ~3,134 lines ### `docs/` — 81 files, ~3,203 lines
**Sub-directories** **Sub-directories**
- `docs/records/` (65) — Astra Project Chronicle Records - `docs/records/` (68) — Astra Project Chronicle Records
- `docs/docs/` (5) — docs Chronicle Records - `docs/docs/` (5) — docs Chronicle Records
**Key files** **Key files**
@@ -173,7 +173,7 @@ flowchart LR
- `docs/EXPERIENCE_MEMORY_PLAN.md` (122 lines) — Experience Memory (Mistake / Lesson Loop) — Implementation Plan - `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-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/development/2026-05-03_connectai_project_knowledge_overview.md` (121 lines) — Astra Project Knowledge Overview
- `docs/records/ConnectAI/timeline.md` (146 lines) — Project Timeline - `docs/records/ConnectAI/timeline.md` (155 lines) — Project Timeline
- `docs/Advanced_Features_Implementation_Guide.md` (40 lines) — Advanced Features Implementation Guide - `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/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/UX_UI_Consistency_Guidelines.md` (44 lines) — UX/UI Consistency Guidelines
@@ -196,7 +196,7 @@ flowchart LR
## VS Code Extension Surface ## VS Code Extension Surface
- **Extension ID**: `g1nation.astra` - **Extension ID**: `g1nation.astra`
- **Activation events**: `onStartupFinished` - **Activation events**: `onStartupFinished`
- **Commands** (27): - **Commands** (28):
- `g1nation.newChat` — Astra: New Chat - `g1nation.newChat` — Astra: New Chat
- `g1nation.exportChat` — Astra: Export Chat as Markdown - `g1nation.exportChat` — Astra: Export Chat as Markdown
- `g1nation.explainSelection` — Astra: Explain Selected Code - `g1nation.explainSelection` — Astra: Explain Selected Code
@@ -224,7 +224,8 @@ flowchart LR
- `g1nation.calendar.connect` — Astra: Google Calendar (iCal) 연결 📅 - `g1nation.calendar.connect` — Astra: Google Calendar (iCal) 연결 📅
- `g1nation.calendar.refresh` — Astra: Google Calendar 새로고침 📅 - `g1nation.calendar.refresh` — Astra: Google Calendar 새로고침 📅
- `g1nation.calendar.connectOAuth` — Astra: Google Calendar OAuth 연결 (쓰기) 🔐 - `g1nation.calendar.connectOAuth` — Astra: Google Calendar OAuth 연결 (쓰기) 🔐
- **Configuration** (62 settings): - `g1nation.devilAgent.toggle` — Astra: Toggle Devil Agent 🎭
- **Configuration** (63 settings):
- `g1nation.multiAgentEnabled` *(boolean)* _(default: `false`)_ — Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks. - `g1nation.multiAgentEnabled` *(boolean)* _(default: `false`)_ — Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks.
- `g1nation.memoryEnabled` *(boolean)* _(default: `true`)_ — Enable layered memory injection before each model response. - `g1nation.memoryEnabled` *(boolean)* _(default: `true`)_ — Enable layered memory injection before each model response.
- `g1nation.memoryShortTermMessages` *(number)* _(default: `8`)_ — Number of recent conversation messages included as short-term memory. - `g1nation.memoryShortTermMessages` *(number)* _(default: `8`)_ — Number of recent conversation messages included as short-term memory.
@@ -285,7 +286,7 @@ flowchart LR
- `g1nation.providers.openrouter.defaultModel` *(string)* _(default: `""`)_ — OpenRouter 의 기본 모델 (예: 'anthropic/claude-3.5-sonnet'). 모델 선택 시 'openrouter:<model>' 형식으로 사이드바 dropdown 에 표시. - `g1nation.providers.openrouter.defaultModel` *(string)* _(default: `""`)_ — OpenRouter 의 기본 모델 (예: 'anthropic/claude-3.5-sonnet'). 모델 선택 시 'openrouter:<model>' 형식으로 사이드바 dropdown 에 표시.
- `g1nation.providers.anthropic.enabled` *(boolean)* _(default: `false`)_ — Anthropic Claude 직접 API 활성화. OpenRouter 보다 마진 적고 prompt caching 등 native 기능 사용 가능. API key 는 Secret Storage. - `g1nation.providers.anthropic.enabled` *(boolean)* _(default: `false`)_ — Anthropic Claude 직접 API 활성화. OpenRouter 보다 마진 적고 prompt caching 등 native 기능 사용 가능. API key 는 Secret Storage.
- `g1nation.providers.anthropic.defaultModel` *(string)* _(default: `"claude-3-5-sonnet-20241022"`)_ — Anthropic 의 기본 모델. 예: 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022'. - `g1nation.providers.anthropic.defaultModel` *(string)* _(default: `"claude-3-5-sonnet-20241022"`)_ — Anthropic 의 기본 모델. 예: 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022'.
- _…and 2 more_ - _…and 3 more_
## Dependencies ## Dependencies
- **Runtime** (2): `@lmstudio/sdk`, `pdf-parse` - **Runtime** (2): `@lmstudio/sdk`, `pdf-parse`
@@ -333,7 +334,7 @@ Astra는 대표님의 명시적인 승인 하에 로컬 시스템의 강력한
**Designed for High-Performance Decision Making.** **Designed for High-Performance Decision Making.**
Copyright (C) **g1nation**. All rights reserved. Copyright (C) **g1nation**. All rights reserved.
_Last auto-scan: 2026-05-16T14:34:03.239Z · signature `857a73ab`_ _Last auto-scan: 2026-05-17T14:50:50.535Z · signature `5421df2d`_
<!-- ASTRA:AUTO-END --> <!-- ASTRA:AUTO-END -->
## Purpose ## Purpose
+102 -34
View File
@@ -1,11 +1,11 @@
{ {
"version": 1, "version": 1,
"generatedAt": "2026-05-16T14:34:03.249Z", "generatedAt": "2026-05-17T14:50:50.547Z",
"files": { "files": {
"src/agent.ts": { "src/agent.ts": {
"mtimeMs": 1778941597000, "mtimeMs": 1778943938000,
"size": 205909, "size": 209036,
"lines": 3579, "lines": 3633,
"role": "", "role": "",
"imports": [ "imports": [
"src/utils", "src/utils",
@@ -260,9 +260,9 @@
"imports": [] "imports": []
}, },
"src/extension.ts": { "src/extension.ts": {
"mtimeMs": 1778938013000, "mtimeMs": 1778943661000,
"size": 62397, "size": 63135,
"lines": 1202, "lines": 1215,
"role": "", "role": "",
"imports": [ "imports": [
"src/utils", "src/utils",
@@ -292,7 +292,8 @@
"src/retrieval/lessonHelpers", "src/retrieval/lessonHelpers",
"src/skills/scopedBrainRetriever", "src/skills/scopedBrainRetriever",
"src/integrations/telegram/conversationHistory", "src/integrations/telegram/conversationHistory",
"src/features/calendar" "src/features/calendar",
"src/features/devilAgent"
] ]
}, },
"src/features/approval/approvalPanelProvider.ts": { "src/features/approval/approvalPanelProvider.ts": {
@@ -370,9 +371,9 @@
"imports": [] "imports": []
}, },
"src/features/astraOffice/view/officeStyles.ts": { "src/features/astraOffice/view/officeStyles.ts": {
"mtimeMs": 1778938908000, "mtimeMs": 1778942662000,
"size": 23999, "size": 25747,
"lines": 401, "lines": 419,
"role": "", "role": "",
"imports": [] "imports": []
}, },
@@ -388,9 +389,9 @@
] ]
}, },
"src/features/astraOffice/view/runtime.ts": { "src/features/astraOffice/view/runtime.ts": {
"mtimeMs": 1778941900000, "mtimeMs": 1779028539000,
"size": 82015, "size": 90525,
"lines": 1765, "lines": 1887,
"role": "자동 분리: src/sidebarProvider.ts 4002-5116 (IIFE 본문) 에서 추출. 동작 동등. ${assets.derivedBase} placeholder 는 panelHtml 에서 .replace() 로 실제 값 주입. 다음 세션에서 OfficeSnapshot 기반으로 단계적으로 잘라낼 예정.", "role": "자동 분리: src/sidebarProvider.ts 4002-5116 (IIFE 본문) 에서 추출. 동작 동등. ${assets.derivedBase} placeholder 는 panelHtml 에서 .replace() 로 실제 값 주입. 다음 세션에서 OfficeSnapshot 기반으로 단계적으로 잘라낼 예정.",
"imports": [] "imports": []
}, },
@@ -650,6 +651,32 @@
"role": "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", "role": "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",
"imports": [] "imports": []
}, },
"src/features/devilAgent/devilPrompt.ts": {
"mtimeMs": 1778943576000,
"size": 3591,
"lines": 71,
"role": "Devil's Advocate (도현) — system prompt 빌더. 설계 원칙: - 모든 약점을 나열하지 않음. 한 turn 에 1개 — 사용자가 깊게 생각하게. - 'X 입장에서 본다면' framing — 본인 의견이 아니라 다른 시점 으로 제시. 이는 LLM 의 '내가 옳다' 경향을 줄이는 잘 알려진 패턴. - 통계 / 구체 수치 / 외부 사례 ",
"imports": []
},
"src/features/devilAgent/devilService.ts": {
"mtimeMs": 1778943601000,
"size": 2457,
"lines": 60,
"role": "Devil Agent — 직전 답변에 대한 반박 생성기. 동작: 1. agent.ts 가 main turn 완료 직후 호출 2. 같은 model/engine 으로 별도 LLM call 1회 — 짧은 비판 한 문단 생성 3. 결과를 webview 로 'devilRebuttal' message 로 send 4. 실패 / 비활성 시 silent skip — ma",
"imports": [
"src/features/devilAgent/devilPrompt"
]
},
"src/features/devilAgent/index.ts": {
"mtimeMs": 1778943609000,
"size": 263,
"lines": 13,
"role": "",
"imports": [
"src/features/devilAgent/devilPrompt",
"src/features/devilAgent/devilService"
]
},
"src/features/projectArchitecture/index.ts": { "src/features/projectArchitecture/index.ts": {
"mtimeMs": 1778691699000, "mtimeMs": 1778691699000,
"size": 25373, "size": 25373,
@@ -844,9 +871,9 @@
] ]
}, },
"src/features/settings/settingsPanelProvider.ts": { "src/features/settings/settingsPanelProvider.ts": {
"mtimeMs": 1778937992000, "mtimeMs": 1778943856000,
"size": 26837, "size": 29429,
"lines": 596, "lines": 646,
"role": "", "role": "",
"imports": [ "imports": [
"src/integrations/telegram/telegramClient", "src/integrations/telegram/telegramClient",
@@ -854,7 +881,8 @@
"src/utils", "src/utils",
"src/lib/discoverModels", "src/lib/discoverModels",
"src/lib/paths", "src/lib/paths",
"src/features/calendar" "src/features/calendar",
"src/features/providers"
] ]
}, },
"src/features/sheets/index.ts": { "src/features/sheets/index.ts": {
@@ -1354,23 +1382,23 @@
"imports": [] "imports": []
}, },
"media/settings-panel.html": { "media/settings-panel.html": {
"mtimeMs": 1778937850000, "mtimeMs": 1778943830000,
"size": 12095, "size": 17362,
"lines": 244, "lines": 328,
"role": "Astra Settings", "role": "Astra Settings",
"imports": [] "imports": []
}, },
"media/settings-panel.js": { "media/settings-panel.js": {
"mtimeMs": 1778938082000, "mtimeMs": 1778943881000,
"size": 15189, "size": 19114,
"lines": 338, "lines": 411,
"role": "", "role": "",
"imports": [] "imports": []
}, },
"media/sidebar.css": { "media/sidebar.css": {
"mtimeMs": 1778934126000, "mtimeMs": 1778943817000,
"size": 86702, "size": 88173,
"lines": 2016, "lines": 2068,
"role": "Stylesheet", "role": "Stylesheet",
"imports": [] "imports": []
}, },
@@ -1382,9 +1410,9 @@
"imports": [] "imports": []
}, },
"media/sidebar.js": { "media/sidebar.js": {
"mtimeMs": 1778934151000, "mtimeMs": 1778943769000,
"size": 211710, "size": 214022,
"lines": 3657, "lines": 3680,
"role": "", "role": "",
"imports": [] "imports": []
}, },
@@ -1443,6 +1471,15 @@
"src/core/dataProcessor" "src/core/dataProcessor"
] ]
}, },
"tests/devilAgent.test.ts": {
"mtimeMs": 1778943907000,
"size": 3179,
"lines": 61,
"role": "",
"imports": [
"src/features/devilAgent/devilPrompt"
]
},
"tests/findBrainFilesCache.test.ts": { "tests/findBrainFilesCache.test.ts": {
"mtimeMs": 1778247665000, "mtimeMs": 1778247665000,
"size": 2720, "size": 2720,
@@ -1573,6 +1610,16 @@
"src/scaffolder/projectScaffolder" "src/scaffolder/projectScaffolder"
] ]
}, },
"tests/providers.test.ts": {
"mtimeMs": 1778942187000,
"size": 4118,
"lines": 97,
"role": "",
"imports": [
"src/features/providers/types",
"src/features/providers/streamHelpers"
]
},
"tests/resilience_stress.test.ts": { "tests/resilience_stress.test.ts": {
"mtimeMs": 1777968922000, "mtimeMs": 1777968922000,
"size": 6981, "size": 6981,
@@ -1930,7 +1977,7 @@
"imports": [] "imports": []
}, },
"docs/records/ConnectAI/chronicle.config.json": { "docs/records/ConnectAI/chronicle.config.json": {
"mtimeMs": 1778940893000, "mtimeMs": 1779029444000,
"size": 416, "size": 416,
"lines": 11, "lines": 11,
"role": "JSON configuration", "role": "JSON configuration",
@@ -2034,6 +2081,20 @@
"role": "ADR: ASTRA OFFICE 부분 관련해서 개선할 부분이 너무 많아. 개선점이 무었이 있는지 의견 주면 좋겠어.", "role": "ADR: ASTRA OFFICE 부분 관련해서 개선할 부분이 너무 많아. 개선점이 무었이 있는지 의견 주면 좋겠어.",
"imports": [] "imports": []
}, },
"docs/records/ConnectAI/decisions/ADR-0015-너의-생각은-사용자가-다음-행동을-추측-안-해도-되는-astra-이게-가장-큰-효과-있을-거-같습니다-3개-.md": {
"mtimeMs": 1778943211000,
"size": 2482,
"lines": 19,
"role": "ADR: 너의 생각은? \"사용자가 다음 행동을 추측 안 해도 되는 Astra\" 이게 가장 큰 효과 있을 거 같습니다. 3개 작업으로 묶어볼 수 있어요: ...",
"imports": []
},
"docs/records/ConnectAI/decisions/ADR-0016-내가-블로그글-작성시-사용하고-있는-지침서인데-slot1번-기준으로-더-자연스럽고-트랜디하고-구독자에게-자연.md": {
"mtimeMs": 1779029444000,
"size": 2432,
"lines": 19,
"role": "ADR: 내가 블로그글 작성시 사용하고 있는 지침서인데 slot1번 기준으로 더 자연스럽고 트랜디하고 구독자에게 자연스럽게 정보를 전달 할 수 있게 개선...",
"imports": []
},
"docs/records/ConnectAI/development/2026-05-02_answer-format-readability-tuning.md": { "docs/records/ConnectAI/development/2026-05-02_answer-format-readability-tuning.md": {
"mtimeMs": 1777808065000, "mtimeMs": 1777808065000,
"size": 1534, "size": 1534,
@@ -2195,6 +2256,13 @@
"role": "Development Log: Astra: Google Calendar OAuth 연결", "role": "Development Log: Astra: Google Calendar OAuth 연결",
"imports": [] "imports": []
}, },
"docs/records/ConnectAI/development/2026-05-16_astra-office가-아직은-많이-부족한것-같은데-어떻게-하면-트랜디하고-재미있는-볼거리가-될-수-있을지_implementation.md": {
"mtimeMs": 1778942937000,
"size": 1750,
"lines": 22,
"role": "Development Log: astra office가 아직은 많이 부족한것 같은데 어떻게 하면 트랜디하고 재미있는 볼거리가 될 수 있을지. 개선점 줄 수 있어?",
"imports": []
},
"docs/records/ConnectAI/discussions/2026-05-13_volumes-data-project-antigravity-connectai-이-프로젝트-작업-할-거야.md": { "docs/records/ConnectAI/discussions/2026-05-13_volumes-data-project-antigravity-connectai-이-프로젝트-작업-할-거야.md": {
"mtimeMs": 1778690673000, "mtimeMs": 1778690673000,
"size": 652, "size": 652,
@@ -2273,9 +2341,9 @@
"imports": [] "imports": []
}, },
"docs/records/ConnectAI/timeline.md": { "docs/records/ConnectAI/timeline.md": {
"mtimeMs": 1778937290000, "mtimeMs": 1779029444000,
"size": 9336, "size": 9978,
"lines": 146, "lines": 155,
"role": "Project Timeline", "role": "Project Timeline",
"imports": [] "imports": []
}, },
@@ -1,5 +1,5 @@
{ {
"result": "Final report with inconsistencies. This should be long enough to pass validation.", "result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1778941922044, "createdAt": 1778943957572,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
"createdAt": 1778941922043, "createdAt": 1778943957562,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.", "result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
"createdAt": 1778941922042, "createdAt": 1778943957561,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "---\nid: stress_conflict_1778941922031\ndate: 2026-05-16T14:32:02.046Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (11ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (1ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (0ms)\n", "result": "---\nid: stress_conflict_1778943957542\ndate: 2026-05-16T15:05:57.576Z\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]** 핵심 정보 수집 및 분석 중... (6ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (6ms)\n",
"createdAt": 1778941922046, "createdAt": 1778943957576,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,8 +1,8 @@
{ {
"missionId": "stress_conflict_1778941922031", "missionId": "stress_conflict_1778943957542",
"status": "completed", "status": "completed",
"startTime": "2026-05-16T14:32:02.031Z", "startTime": "2026-05-16T15:05:57.542Z",
"totalElapsedMs": 15, "totalElapsedMs": 35,
"results": { "results": {
"planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.", "planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
"researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", "researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
@@ -16,30 +16,30 @@
{ {
"from": "idle", "from": "idle",
"to": "planner", "to": "planner",
"durationMs": 11, "durationMs": 13,
"message": "전략 수립 중...", "message": "전략 수립 중...",
"ts": "2026-05-16T14:32:02.042Z" "ts": "2026-05-16T15:05:57.555Z"
}, },
{ {
"from": "planner", "from": "planner",
"to": "researcher", "to": "researcher",
"durationMs": 1, "durationMs": 6,
"message": "핵심 정보 수집 및 분석 중...", "message": "핵심 정보 수집 및 분석 중...",
"ts": "2026-05-16T14:32:02.043Z" "ts": "2026-05-16T15:05:57.561Z"
}, },
{ {
"from": "researcher", "from": "researcher",
"to": "writer", "to": "writer",
"durationMs": 0, "durationMs": 6,
"message": "최종 리포트 작성 및 편집 중...", "message": "최종 리포트 작성 및 편집 중...",
"ts": "2026-05-16T14:32:02.043Z" "ts": "2026-05-16T15:05:57.567Z"
}, },
{ {
"from": "writer", "from": "writer",
"to": "completed", "to": "completed",
"durationMs": 3, "durationMs": 10,
"message": "미션 완료", "message": "미션 완료",
"ts": "2026-05-16T14:32:02.046Z" "ts": "2026-05-16T15:05:57.577Z"
} }
], ],
"resilienceMetrics": { "resilienceMetrics": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 KiB

+1 -1
View File
@@ -7,5 +7,5 @@
"corePurpose": "", "corePurpose": "",
"detailLevel": "standard", "detailLevel": "standard",
"createdAt": "2026-05-13T13:09:33.788Z", "createdAt": "2026-05-13T13:09:33.788Z",
"updatedAt": "2026-05-16T14:14:53.934Z" "updatedAt": "2026-05-17T14:50:44.364Z"
} }
@@ -0,0 +1,19 @@
# ADR: 너의 생각은? "사용자가 다음 행동을 추측 안 해도 되는 Astra" 이게 가장 큰 효과 있을 거 같습니다. 3개 작업으로 묶어볼 수 있어요: ...
## Status
accepted
## Context
너의 생각은? "사용자가 다음 행동을 추측 안 해도 되는 Astra" 이게 가장 큰 효과 있을 거 같습니다. 3개 작업으로 묶어볼 수 있어요: 첫 화면 "오늘의 추천" 카드 — Astra 사이드바 열 때 빈 chat 대신 "최근 작업 / 다가오는 마감 / 어제 멈춘 곳 / 추천 다음 작업" 4-카드 dashboard 사용자 정의 파이프라인 UI — Pipeline editor 가 이미 있지만 발견 어려움. "내 자주 쓰는 패턴 만들기" 메뉴 부각 "악마의 변호인" agent 추가 — company roster 에 devil agent 한 명 추가, 의도적 반대 의견 emit. Self-Reflector 와 다른 점은 대화 형식 으로 토론 각 1-2 시간 분량. 셋 다 기존 인프라 위에서 동작 — 새 의존성 없음. 트레이드오프 위 제안 문서를 그대로 따라가면 6개월짜리 product 재정의가 됩니다. Astra 의 핵심 강점 (로컬 LLM, privacy, VS Code 통합) 을 잃...
## Decision
**결론부터 말씀드리자면, 방향성 자체는 매우 정확합니다.** 현재의 핵심 강점(로컬 LLM, 프라이버시)을 잃고 트렌디한 SaaS를 따라 하려 하는 위험성은 반드시 피해야 합니다. 따라서 **(B) 다른 1-2개 골라서 깊게** 가는 것이 맞습니다. 다만, 선택할 기능은 단순히 '트렌디해 보이는 것'이 아니라, Astra의 핵심 USP인 **"프라이버시가 보장되는 환경에서 AI와 함께하는 지능적인 사고 과정(Intelligence Workflow)"**을 극대화할 수 있는 방향으로 재선정해야 합니다. --- ### 📝 간단 요약 및 상세 답변 Astra Office는 화려한 UI 경쟁에 뛰어들기보다, 사용자의 '사고의 깊이'와 '작업의 흐름' 자체를 업그레이드하는 데 집중해야 합니다. 가장 강력한 초기 초점은 **'악마의 변호인(Devil Agent)'**과 **'워크플로우 자동화 매핑'**을 결합하여, 사용자가 스스로 생각하지 못했던 비판적 관점을 강제로 제시받는 경험에 두어야...
## 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: 내가 블로그글 작성시 사용하고 있는 지침서인데 slot1번 기준으로 더 자연스럽고 트랜디하고 구독자에게 자연스럽게 정보를 전달 할 수 있게 개선...
## Status
accepted
## Context
내가 블로그글 작성시 사용하고 있는 지침서인데 slot1번 기준으로 더 자연스럽고 트랜디하고 구독자에게 자연스럽게 정보를 전달 할 수 있게 개선해야할 부분이 있을까? --- 첨부 파일 내용 --- [FILE: seo_workflow_2026-05-17.json] ``` [ { "id": "1", "role": "analyst", "name": "스타일 분석가", "iconKey": "Search", "model": "gemini-3-flash-preview", "prompt": "당신은 독자의 마음을 읽고 진짜 원하는 정보를 찾아내는 조사 전문가입니다. 주제에 대해 사람들이 가장 궁금해하는 점과 실질적으로 도움이 되는 팩트 기반 정보를 파악하십시오.\n\n[지침]\n1. 독자가 이 주제를 찾아본 '진짜 이유'를 3가지로 정리하십시오.\n2. 어려운 개념은 일상적인 비유로 쉽게 풀어서 설명하십시오.\n3. 구체적인 숫자나 실제 사례를 포함해 할루시네이션 없는 사실 기반 정보를 수집하십...
## Decision
내가 블로그글 작성시 사용하고 있는 지침서인데 slot1번 기준으로 더 자연스럽고 트랜디하고 구독자에게 자연스럽게 정보를 전달 할 수 있게 개선해야할 부분이 있을까? --- 첨부 파일 내용 --- [FILE: seo_workflow_2026-05-17.json] ``` [ { "id": "1", "role": "analyst", "name": "스타일 분석가", "iconKey": "Search", "model": "gemini-3-flash-preview", "prompt": "당신은 독자의 마음을 읽고 진짜 원하는 정보를 찾아내는 조사 전문가입니다. 주제에 대해 사람들이 가장 궁금해하는 점과 실질적으로 도움이 되는 팩트 기반 정보를 파악하십시오.\n\n[지침]\n1. 독자가 이 주제를 찾아본 '진짜 이유'를 3가지로 정리하십시오.\n2. 어려운 개념은 일상적인 비유로 쉽게 풀어서 설명하십시오.\n3. 구체적인 숫자나 실제 사례를 포함해 할루시네이션 없는 사실 기반 정보를 수집하십...
## 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,22 @@
# Development Log: astra office가 아직은 많이 부족한것 같은데 어떻게 하면 트랜디하고 재미있는 볼거리가 될 수 있을지. 개선점 줄 수 있어?
## Purpose
Record the implementation or verification outcome from the current conversation.
## Implementation Summary
이건 전형적인 '기능은 갖추었지만, 경험이 부족한' 서비스의 문제입니다. 단순히 기능을 추가하는 것보다 **사용자가 플랫폼을 사용하는 과정 자체를 재미있게 만들고, 필요성을 느끼게 하는 방향**으로 접근해야 합니다. Astra Office가 트렌디하고 매력적인 공간이 되려면, '도구(Tool)'로서의 역할 외에 **'경험(Experience)'과 '커뮤니티(Community)'의 중심지**라는 인상을 주어야 합니다. 세 가지 핵심 관점에서 개선점을 제안합니다. --- ### 💡 1. 경험 및 UX 측면 (Trendy & Aesthetic) 현재의 작업 환경을 단순한 문서 편집 공간이 아니라, 개인화된 '디지털 아틀리에'처럼 느껴지게 해야 합니다. * **초개인화 대시보드:** 사용자가 자신의 주력 워크플로우(프로젝트 A용 AI 요약 위젯, B 프로젝트용 To-Do 리스트 등)만 한눈에 볼 수 있도록 모듈형/위젯 기반의 메인 화면을 제공해야 합니다. 마치 Notion이나 Obsidi...
## Architecture
Captured automatically from the assistant response and active project context.
## Changed Files
- No explicit changed file list was captured automatically.
## 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.
+9
View File
@@ -144,3 +144,12 @@
## 2026-05-16 ## 2026-05-16
- Auto development record created: development/2026-05-16_astra-google-calendar-oauth-연결_implementation-2.md - Auto development record created: development/2026-05-16_astra-google-calendar-oauth-연결_implementation-2.md
## 2026-05-16
- Auto development record created: development/2026-05-16_astra-office가-아직은-많이-부족한것-같은데-어떻게-하면-트랜디하고-재미있는-볼거리가-될-수-있을지_implementation.md
## 2026-05-16
- Auto decision record created: decisions/ADR-0015-너의-생각은-사용자가-다음-행동을-추측-안-해도-되는-astra-이게-가장-큰-효과-있을-거-같습니다-3개-.md
## 2026-05-17
- Auto decision record created: decisions/ADR-0016-내가-블로그글-작성시-사용하고-있는-지침서인데-slot1번-기준으로-더-자연스럽고-트랜디하고-구독자에게-자연.md
+12
View File
@@ -284,6 +284,18 @@
<div id="providersError" class="error" hidden></div> <div id="providersError" class="error" hidden></div>
</section> </section>
<!-- Devil Agent (도현) -->
<section class="section" data-section="devilAgent">
<h2>🎭 Devil's Advocate (도현)</h2>
<p class="hint">매 답변 직후 별도 LLM 호출로 *비판적 sparring partner* 가 한 문단 반박. 사용자의 사고를 능동적 방어로 전환. 같은 모델 재사용 (~10-15% 추가 비용).</p>
<div class="row toggle">
<label><input id="devilEnabled" type="checkbox"> 도현 활성화 — 답변마다 반박 카드</label>
</div>
<div class="row">
<small class="hint">규칙: 한 답변당 약점 1개만, 통계·수치 인용 금지 (환각 차단), 끝에 우려+검증 방법 명시. 명령 팔레트 <code>Astra: Toggle Devil Agent 🎭</code> 로도 토글.</small>
</div>
</section>
<!-- Advanced --> <!-- Advanced -->
<section class="section" data-section="advanced"> <section class="section" data-section="advanced">
<h2>고급</h2> <h2>고급</h2>
+73
View File
@@ -57,6 +57,20 @@
const googleFeedback = $('googleFeedback'); const googleFeedback = $('googleFeedback');
const googleError = $('googleError'); const googleError = $('googleError');
// ---- Devil Agent ----
const devilEnabled = $('devilEnabled');
// ---- Cloud LLM Providers ----
const prOpenrouterEnabled = $('prOpenrouterEnabled');
const prOpenrouterKey = $('prOpenrouterKey');
const prOpenrouterDefault = $('prOpenrouterDefault');
const prAnthropicEnabled = $('prAnthropicEnabled');
const prAnthropicKey = $('prAnthropicKey');
const prAnthropicDefault = $('prAnthropicDefault');
const prGeminiEnabled = $('prGeminiEnabled');
const prGeminiKey = $('prGeminiKey');
const prGeminiDefault = $('prGeminiDefault');
// ---- Banner ---- // ---- Banner ----
const bannerError = $('bannerError'); const bannerError = $('bannerError');
@@ -157,6 +171,43 @@
googleDisconnectBtn.addEventListener('click', () => vscode.postMessage({ type: 'google.disconnect' })); googleDisconnectBtn.addEventListener('click', () => vscode.postMessage({ type: 'google.disconnect' }));
googleIcalRefreshBtn.addEventListener('click', () => vscode.postMessage({ type: 'google.icalRefresh' })); googleIcalRefreshBtn.addEventListener('click', () => vscode.postMessage({ type: 'google.icalRefresh' }));
// ---- Devil Agent listener ----
devilEnabled.addEventListener('change', (e) =>
vscode.postMessage({ type: 'devilAgent.toggle', enabled: e.target.checked })
);
// ---- Cloud LLM Providers listeners ----
prOpenrouterEnabled.addEventListener('change', (e) =>
vscode.postMessage({ type: 'providers.update', providerId: 'openrouter', enabled: e.target.checked })
);
document.querySelector('[data-save="providers.openrouter.apiKey"]').addEventListener('click', () => {
vscode.postMessage({ type: 'providers.update', providerId: 'openrouter', apiKey: prOpenrouterKey.value });
prOpenrouterKey.value = '';
});
document.querySelector('[data-save="providers.openrouter.defaultModel"]').addEventListener('click', () =>
vscode.postMessage({ type: 'providers.update', providerId: 'openrouter', defaultModel: prOpenrouterDefault.value })
);
prAnthropicEnabled.addEventListener('change', (e) =>
vscode.postMessage({ type: 'providers.update', providerId: 'anthropic', enabled: e.target.checked })
);
document.querySelector('[data-save="providers.anthropic.apiKey"]').addEventListener('click', () => {
vscode.postMessage({ type: 'providers.update', providerId: 'anthropic', apiKey: prAnthropicKey.value });
prAnthropicKey.value = '';
});
document.querySelector('[data-save="providers.anthropic.defaultModel"]').addEventListener('click', () =>
vscode.postMessage({ type: 'providers.update', providerId: 'anthropic', defaultModel: prAnthropicDefault.value })
);
prGeminiEnabled.addEventListener('change', (e) =>
vscode.postMessage({ type: 'providers.update', providerId: 'gemini', enabled: e.target.checked })
);
document.querySelector('[data-save="providers.gemini.apiKey"]').addEventListener('click', () => {
vscode.postMessage({ type: 'providers.update', providerId: 'gemini', apiKey: prGeminiKey.value });
prGeminiKey.value = '';
});
document.querySelector('[data-save="providers.gemini.defaultModel"]').addEventListener('click', () =>
vscode.postMessage({ type: 'providers.update', providerId: 'gemini', defaultModel: prGeminiDefault.value })
);
document.querySelector('[data-save="advanced.ctxSize"]').addEventListener('click', () => document.querySelector('[data-save="advanced.ctxSize"]').addEventListener('click', () =>
vscode.postMessage({ type: 'advanced.update', maxContextSize: Number(advCtxSize.value) }) vscode.postMessage({ type: 'advanced.update', maxContextSize: Number(advCtxSize.value) })
); );
@@ -332,6 +383,28 @@
? `마지막 새로고침: ${g.lastIcalFetchAt.slice(0, 16).replace('T', ' ')}` ? `마지막 새로고침: ${g.lastIcalFetchAt.slice(0, 16).replace('T', ' ')}`
: ''; : '';
} }
// ---- Devil Agent ----
if (state.devilAgent) {
devilEnabled.checked = !!state.devilAgent.enabled;
}
// ---- Cloud LLM Providers ----
const pr = state.providers;
if (pr) {
// OpenRouter
prOpenrouterEnabled.checked = !!pr.openrouter.enabled;
prOpenrouterKey.placeholder = pr.openrouter.hasApiKey ? '••• 저장됨 (덮어쓰려면 새 값)' : 'sk-or-...';
setIfNotFocused(prOpenrouterDefault, pr.openrouter.defaultModel);
// Anthropic
prAnthropicEnabled.checked = !!pr.anthropic.enabled;
prAnthropicKey.placeholder = pr.anthropic.hasApiKey ? '••• 저장됨 (덮어쓰려면 새 값)' : 'sk-ant-...';
setIfNotFocused(prAnthropicDefault, pr.anthropic.defaultModel);
// Gemini
prGeminiEnabled.checked = !!pr.gemini.enabled;
prGeminiKey.placeholder = pr.gemini.hasApiKey ? '••• 저장됨 (덮어쓰려면 새 값)' : 'AIzaSy...';
setIfNotFocused(prGeminiDefault, pr.gemini.defaultModel);
}
} }
vscode.postMessage({ type: 'ready' }); vscode.postMessage({ type: 'ready' });
+44
View File
@@ -953,6 +953,50 @@
border-top: 1px dashed var(--border); border-top: 1px dashed var(--border);
} }
/* Devil's Advocate (도현) 반박 카드 — main 답변 직후 한 장씩.
accent 와 구분되도록 보라색 톤 + 마스크 이모지 prefix 로 시선 끌기. */
.devil-rebuttal-card {
border: 1px solid rgba(167, 139, 250, 0.55);
background: rgba(167, 139, 250, 0.06);
border-radius: 10px;
padding: 10px 12px;
margin: 8px 0;
font-size: 12px;
line-height: 1.55;
color: var(--text);
}
.devil-rebuttal-head {
font-size: 11px;
color: #a78bfa;
margin-bottom: 6px;
letter-spacing: 0.02em;
}
.devil-rebuttal-body {
white-space: pre-wrap;
margin-bottom: 8px;
}
.devil-rebuttal-actions {
display: flex;
gap: 8px;
}
.devil-rebuttal-actions button {
background: rgba(167, 139, 250, 0.16);
border: 1px solid rgba(167, 139, 250, 0.4);
color: #c7b7ff;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
cursor: pointer;
}
.devil-rebuttal-actions button.ghost {
background: transparent;
border-color: var(--line);
color: var(--muted);
}
.devil-rebuttal-actions button:hover {
background: rgba(167, 139, 250, 0.28);
}
/* Intent Alignment 카드 — new_task 요청 직후 C-G-C-F 분석 결과를 보여주고 /* Intent Alignment 카드 — new_task 요청 직후 C-G-C-F 분석 결과를 보여주고
질문 / 확인 버튼을 띄움. 다른 phase 카드보다 살짝 무게감을 주려고 질문 / 확인 버튼을 띄움. 다른 phase 카드보다 살짝 무게감을 주려고
accent 테두리. */ accent 테두리. */
+36
View File
@@ -1011,6 +1011,42 @@
}); });
break; break;
} }
case 'devilRebuttal': {
// Devil Agent (도현) 카드. main assistant 답변 직후 chat 하단에 한 장 추가.
// 사용자가 '재반박' 누르면 그 텍스트가 다음 user prompt 로 가서 main turn 한 번 더 돌고,
// 재반박-입장 hint 가 prompt 에 prepend 됨.
const v = msg.value || {};
const chatEl = document.getElementById('chat');
if (!chatEl) break;
const card = document.createElement('div');
card.className = 'devil-rebuttal-card';
const persona = String(v.persona || '도현');
const text = String(v.text || '');
card.innerHTML = `
<div class="devil-rebuttal-head">🎭 <strong>${escAttr(persona)}</strong>() </div>
<div class="devil-rebuttal-body"></div>
<div class="devil-rebuttal-actions">
<button class="devil-reply">재반박</button>
<button class="devil-dismiss ghost">넘기기</button>
</div>`;
// body 는 textContent 로 안전하게.
card.querySelector('.devil-rebuttal-body').textContent = text;
chatEl.appendChild(card);
chatEl.scrollTop = chatEl.scrollHeight;
card.querySelector('.devil-dismiss').addEventListener('click', () => card.remove());
card.querySelector('.devil-reply').addEventListener('click', () => {
// 입력창에 prefix 채워서 사용자가 자기 반박 입력하게.
const input = document.getElementById('input');
if (input) {
input.value = `[${persona}의 반박에 답변] `;
input.focus();
// 커서 끝으로.
try { input.setSelectionRange(input.value.length, input.value.length); } catch {}
}
card.remove();
});
break;
}
case 'companyAlignmentCard': { case 'companyAlignmentCard': {
// Intent Alignment 카드. kind에 따라 4가지 모드: // Intent Alignment 카드. kind에 따라 4가지 모드:
// - 'auto-proceed' : confidence high → 자동 진행 안내(읽기 전용) // - 'auto-proceed' : confidence high → 자동 진행 안내(읽기 전용)
+9
View File
@@ -146,6 +146,10 @@
{ {
"command": "g1nation.calendar.connectOAuth", "command": "g1nation.calendar.connectOAuth",
"title": "Astra: Google Calendar OAuth 연결 (쓰기) 🔐" "title": "Astra: Google Calendar OAuth 연결 (쓰기) 🔐"
},
{
"command": "g1nation.devilAgent.toggle",
"title": "Astra: Toggle Devil Agent 🎭"
} }
], ],
"keybindings": [ "keybindings": [
@@ -557,6 +561,11 @@
"type": "string", "type": "string",
"default": "gemini-2.0-flash-exp", "default": "gemini-2.0-flash-exp",
"description": "Gemini 의 기본 모델. 예: 'gemini-2.0-flash-exp', 'gemini-1.5-pro', 'gemini-1.5-flash'." "description": "Gemini 의 기본 모델. 예: 'gemini-2.0-flash-exp', 'gemini-1.5-pro', 'gemini-1.5-flash'."
},
"g1nation.devilAgent.enabled": {
"type": "boolean",
"default": false,
"markdownDescription": "**Devil's Advocate (도현) 활성화** — 매 답변 직후 별도 LLM 호출로 *비판적 sparring partner* 가 한 문단 반박. 사용자의 사고를 *수동적 수용 → 능동적 방어* 로 전환시키는 게 목표. Local LLM 동일 모델 재사용, ~10-15% 추가 비용."
} }
} }
} }
+66
View File
@@ -1302,6 +1302,16 @@ export class AgentExecutor {
memoryLayers: this._lastRetrievalInfo?.usedMemoryLayers ?? [], memoryLayers: this._lastRetrievalInfo?.usedMemoryLayers ?? [],
note: `continuations=${continuationCount} historyDropped=${reqMessages.length - budgetedHistory.length}`, note: `continuations=${continuationCount} historyDropped=${reqMessages.length - budgetedHistory.length}`,
}); });
// ── Devil Agent (도현) — 비활성 시 silent skip. 활성 시 별도 LLM 호출로 반박 카드 emit. ──
// 비동기 — main turn 완료에 영향 없음. 실패해도 main 답변은 보존됨.
void this._maybeEmitDevilRebuttal({
userPrompt: prompt || '',
assistantAnswer: finalAssistantContent,
baseUrl: ollamaUrl,
modelName: actualModel,
contextLength: ctxLimits.contextLength,
engine,
});
} else { } else {
this.webview.postMessage({ type: 'streamChunk', value: finalAssistantContent }); this.webview.postMessage({ type: 'streamChunk', value: finalAssistantContent });
} }
@@ -2885,6 +2895,62 @@ export class AgentExecutor {
* "lock() request could not be registered" error this method is helping * "lock() request could not be registered" error this method is helping
* to avoid. * to avoid.
*/ */
/**
* Devil Agent emit main turn (fire-and-forget).
* return. LLM (callNonStreaming ) .
* webview 'devilRebuttal' UI .
*/
private async _maybeEmitDevilRebuttal(opts: {
userPrompt: string;
assistantAnswer: string;
baseUrl: string;
modelName: string;
contextLength: number;
engine: 'lmstudio' | 'ollama';
}): Promise<void> {
try {
const { isDevilAgentEnabled, generateDevilRebuttal, DEVIL_PERSONA_NAME } =
await import('./features/devilAgent');
if (!isDevilAgentEnabled()) return;
if (!opts.userPrompt.trim() || !opts.assistantAnswer.trim()) return;
// Local callLLM wrapper — callNonStreaming 재사용 (cloud / local 자동 라우팅).
const callLLM = async (system: string, userMessage: string, maxTokens: number) => {
const r = await this.callNonStreaming({
baseUrl: opts.baseUrl,
modelName: opts.modelName,
engine: opts.engine,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: userMessage },
],
temperature: 0.7,
maxTokens,
contextLength: opts.contextLength,
signal: this.abortController?.signal,
});
return r.text;
};
const rebuttal = await generateDevilRebuttal(callLLM, {
userPrompt: opts.userPrompt,
assistantAnswer: opts.assistantAnswer,
});
if (!rebuttal) return;
this.webview?.postMessage({
type: 'devilRebuttal',
value: {
persona: DEVIL_PERSONA_NAME,
text: rebuttal,
// 사용자가 '재반박' 누를 때 원래 컨텍스트로 돌아갈 수 있게 stash.
userPrompt: opts.userPrompt,
assistantAnswer: opts.assistantAnswer,
},
});
} catch (e: any) {
// Devil 실패는 main 답변에 영향 없음 — silent log.
logInfo('Devil rebuttal skipped.', { error: e?.message ?? String(e) });
}
}
private async callNonStreaming(params: { private async callNonStreaming(params: {
baseUrl: string; baseUrl: string;
modelName: string; modelName: string;
+13
View File
@@ -680,6 +680,19 @@ export async function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand('g1nation.calendar.connectOAuth', async () => { vscode.commands.registerCommand('g1nation.calendar.connectOAuth', async () => {
await runConnectGoogleCalendarOAuth(context); await runConnectGoogleCalendarOAuth(context);
}), }),
// Devil Agent (도현) — 매 답변 직후 비판적 반박. 토글 명령.
vscode.commands.registerCommand('g1nation.devilAgent.toggle', async () => {
const { isDevilAgentEnabled, setDevilAgentEnabled, DEVIL_PERSONA_NAME } =
await import('./features/devilAgent');
const wasOn = isDevilAgentEnabled();
await setDevilAgentEnabled(!wasOn);
const nowOn = !wasOn;
vscode.window.showInformationMessage(
nowOn
? `🎭 ${DEVIL_PERSONA_NAME} 활성화됨 — 이제 매 답변 뒤에 비판적 반박 카드가 떠요.`
: `🎭 ${DEVIL_PERSONA_NAME} 비활성화됨.`
);
}),
); );
/** All lesson/playbook/qa-finding cards in the active brain. Uses the brain index for the lesson-kind /** All lesson/playbook/qa-finding cards in the active brain. Uses the brain index for the lesson-kind
+1 -1
View File
@@ -17,7 +17,7 @@ export const OFFICE_BODY = `
<div id="editToolbar" class="edit-toolbar" style="display:none;"> <div id="editToolbar" class="edit-toolbar" style="display:none;">
<span class="et-hint"> · <b>R</b> · <b>]</b>/<b>[</b> · 4px snap</span> <span class="et-hint"> · <b>R</b> · <b>]</b>/<b>[</b> · 4px snap</span>
<button id="addDeskBtn" class="add" title="책상 추가">+ </button> <button id="addDeskBtn" class="add" title="책상 추가">+ </button>
<button id="addPropBtn" class="add" title="프랍(소품) 추가">+ </button> <button id="addPropBtn" class="add" title="가구 추가">+ </button>
<button id="deleteSelBtn" class="del" title="선택 항목 삭제" disabled></button> <button id="deleteSelBtn" class="del" title="선택 항목 삭제" disabled></button>
<button id="layerUpBtn" title="레이어 위로 (])"></button> <button id="layerUpBtn" title="레이어 위로 (])"></button>
<button id="layerDownBtn" title="레이어 아래로 ([)"></button> <button id="layerDownBtn" title="레이어 아래로 ([)"></button>
+22 -4
View File
@@ -300,7 +300,25 @@ button,input,select{font:inherit}
.stage:has(.char.active[data-agent="support"]) .desk[data-agent="support"]::after, .stage:has(.char.active[data-agent="support"]) .desk[data-agent="support"]::after,
.stage:has(.char.active[data-agent="writer"]) .desk[data-agent="writer"]::after{border-color:var(--role-color);box-shadow:0 0 0 1px rgba(255,255,255,.06),0 0 18px color-mix(in srgb,var(--role-color) 35%,transparent)} .stage:has(.char.active[data-agent="writer"]) .desk[data-agent="writer"]::after{border-color:var(--role-color);box-shadow:0 0 0 1px rgba(255,255,255,.06),0 0 18px color-mix(in srgb,var(--role-color) 35%,transparent)}
.shadow{position:absolute;left:12px;bottom:0;width:28px;height:7px;background:radial-gradient(ellipse,rgba(0,0,0,.55),transparent 70%)} .shadow{position:absolute;left:12px;bottom:0;width:28px;height:7px;background:radial-gradient(ellipse,rgba(0,0,0,.55),transparent 70%)}
.bubble{position:absolute;z-index:20;transform:translate(-50%,-100%);max-width:180px;padding:7px 10px;border-radius:14px;border:1px solid rgba(255,255,255,.14);background:rgba(10,14,24,.92);color:var(--text);font-size:11px;line-height:1.35;box-shadow:0 10px 24px rgba(0,0,0,.28);white-space:normal} .bubble{position:absolute;z-index:20;transform:translate(-50%,-100%);max-width:180px;padding:7px 10px;border-radius:14px;border:1px solid rgba(255,255,255,.14);background:rgba(10,14,24,.92);color:var(--text);font-size:11px;line-height:1.35;box-shadow:0 10px 24px rgba(0,0,0,.28);white-space:normal;animation:bubble-pop .22s cubic-bezier(.2,1.4,.6,1)}
@keyframes bubble-pop{from{transform:translate(-50%,-88%) scale(.6);opacity:0}to{transform:translate(-50%,-100%) scale(1);opacity:1}}
/* 감정 태그별 변형 — 희노애락. webtoon 느낌으로 background / 색 / 이모지 prefix. */
.bubble-joy {background:rgba(254,243,199,.97);color:#7c5d11;border-color:rgba(252,211,77,.6)}
.bubble-joy::before {content:'😊 ';opacity:.85}
.bubble-anger {background:rgba(254,226,226,.97);color:#7f1d1d;border-color:#ef4444;animation:bubble-pop .22s cubic-bezier(.2,1.4,.6,1),bubble-shake .4s ease-in-out 1}
.bubble-anger::before {content:'😠 ';opacity:.85}
.bubble-sorrow {background:rgba(219,234,254,.97);color:#1e3a8a;border-color:rgba(96,165,250,.5)}
.bubble-sorrow::before {content:'😔 ';opacity:.85}
.bubble-panic {background:rgba(254,226,226,.97);color:#9a1c1c;border-color:rgba(248,113,113,.55);animation:bubble-pop .22s cubic-bezier(.2,1.4,.6,1),bubble-shake .5s ease-in-out 1}
.bubble-panic::before {content:'😱 ';opacity:.85}
.bubble-curious {background:rgba(243,232,255,.97);color:#5b21b6;border-color:rgba(167,139,250,.45)}
.bubble-curious::before {content:'🤔 ';opacity:.85}
.bubble-firm {background:rgba(255,255,255,.98);color:#111;border-color:rgba(0,0,0,.18);font-weight:700}
.bubble-firm::before {content:'✋ ';opacity:.85}
.bubble-gratitude{background:rgba(220,252,231,.97);color:#14532d;border-color:rgba(74,222,128,.5)}
.bubble-gratitude::before{content:'🙏 ';opacity:.85}
.bubble-thought {/* default 그대로 */}
@keyframes bubble-shake{0%,100%{transform:translate(-50%,-100%) rotate(0)}25%{transform:translate(-52%,-100%) rotate(-2deg)}75%{transform:translate(-48%,-100%) rotate(2deg)}}
.brief-grid{display:flex;flex-direction:column;gap:10px} .brief-grid{display:flex;flex-direction:column;gap:10px}
.brief-card{ .brief-card{
padding:14px; padding:14px;
@@ -367,13 +385,13 @@ body:not([data-edit-mode="true"]) .char{cursor:pointer}
.prop-panel .pp-thumb img{max-width:100%;max-height:100%;image-rendering:pixelated} .prop-panel .pp-thumb img{max-width:100%;max-height:100%;image-rendering:pixelated}
.prop-panel .pp-thumb.active{border-color:rgba(138,124,255,.7);box-shadow:0 0 0 2px rgba(138,124,255,.18)} .prop-panel .pp-thumb.active{border-color:rgba(138,124,255,.7);box-shadow:0 0 0 2px rgba(138,124,255,.18)}
.prop-picker{position:fixed;inset:0;background:rgba(3,5,10,.68);z-index:1100;display:flex;align-items:center;justify-content:center} .prop-picker{position:fixed;inset:0;background:rgba(3,5,10,.68);z-index:1100;display:flex;align-items:center;justify-content:center}
.prop-picker-box{background:rgba(10,14,24,.98);border:1px solid var(--line-strong);border-radius:20px;padding:16px;max-width:520px;max-height:80vh;overflow-y:auto;color:var(--text)} .prop-picker-box{background:rgba(10,14,24,.98);border:1px solid var(--line-strong);border-radius:20px;padding:16px;max-width:380px;max-height:80vh;overflow-y:auto;color:var(--text)}
.prop-picker-box h3{margin:0 0 12px;font-size:13px;color:#C6BEFF} .prop-picker-box h3{margin:0 0 12px;font-size:13px;color:#C6BEFF}
.prop-picker-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px} .prop-picker-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
.prop-pick{background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:14px;padding:7px;cursor:pointer;text-align:center} .prop-pick{background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:14px;padding:7px;cursor:pointer;text-align:center}
.prop-pick:hover{border-color:rgba(138,124,255,.6)} .prop-pick:hover{border-color:rgba(138,124,255,.6)}
.prop-pick img{max-width:60px;max-height:60px;image-rendering:pixelated} .prop-pick img{max-width:60px;max-height:60px;image-rendering:pixelated}
.prop-pick .pp-name{font-size:10px;color:var(--muted);margin-top:5px;word-break:break-all} .prop-pick .pp-name{font-size:10px;color:var(--muted);margin-top:5px;word-break:keep-all}
body[data-edit-mode="true"] .stage{background-image:linear-gradient(rgba(138,124,255,.18) 1px,transparent 1px),linear-gradient(90deg,rgba(138,124,255,.18) 1px,transparent 1px);background-size:32px 32px} body[data-edit-mode="true"] .stage{background-image:linear-gradient(rgba(138,124,255,.18) 1px,transparent 1px),linear-gradient(90deg,rgba(138,124,255,.18) 1px,transparent 1px);background-size:32px 32px}
body[data-edit-mode="true"] .desk,body[data-edit-mode="true"] .char,body[data-edit-mode="true"] .obj{cursor:grab;outline:1px dashed rgba(138,124,255,.45)} body[data-edit-mode="true"] .desk,body[data-edit-mode="true"] .char,body[data-edit-mode="true"] .obj{cursor:grab;outline:1px dashed rgba(138,124,255,.45)}
body[data-edit-mode="true"] .desk:hover,body[data-edit-mode="true"] .char:hover,body[data-edit-mode="true"] .obj:hover{outline:2px solid rgba(138,124,255,.8);z-index:30} body[data-edit-mode="true"] .desk:hover,body[data-edit-mode="true"] .char:hover,body[data-edit-mode="true"] .obj:hover{outline:2px solid rgba(138,124,255,.8);z-index:30}
+200 -17
View File
@@ -7,7 +7,7 @@ const OFFICE_RUNTIME_JS_TEMPLATE = `
const base='\${assets.derivedBase}'; const stage=document.getElementById('stage'); const base='\${assets.derivedBase}'; const stage=document.getElementById('stage');
const officeEl = stage && stage.closest ? stage.closest('.office') : null; const officeEl = stage && stage.closest ? stage.closest('.office') : null;
if(officeEl){ if(officeEl){
officeEl.style.setProperty('--office-backdrop', 'url(\"'+base+'/office-backdrop-astra-v2.png\")'); officeEl.style.setProperty('--office-backdrop', 'url(\"'+base+'/office-backdrop-astra-v3.png\")');
officeEl.classList.add('has-art'); officeEl.classList.add('has-art');
} }
let _stageScale = 1; let _stageScale = 1;
@@ -41,9 +41,20 @@ const AGENT_ALIASES={writer:'planner',editor:'designer',secretary:'support',busi
// \uB9E4\uD551 dropdown \uC5D0 \uBCF4\uC5EC\uC904 \uC5D0\uC774\uC804\uD2B8 \uD6C4\uBCF4. agentKey \uAC00 unique \uD55C base set. // \uB9E4\uD551 dropdown \uC5D0 \uBCF4\uC5EC\uC904 \uC5D0\uC774\uC804\uD2B8 \uD6C4\uBCF4. agentKey \uAC00 unique \uD55C base set.
const AGENT_CHOICES=['','ceo','planner','researcher','designer','developer','qa','inspector','support']; const AGENT_CHOICES=['','ceo','planner','researcher','designer','developer','qa','inspector','support'];
// \uCD94\uAC00 \uAC00\uB2A5\uD55C \uCC45\uC0C1 sprite \uD6C4\uBCF4 (assets/pixelOffice/derived \uC5D0 \uC788\uB294 desk-* PNG). // \uCD94\uAC00 \uAC00\uB2A5\uD55C \uCC45\uC0C1 sprite \uD6C4\uBCF4 (assets/pixelOffice/derived \uC5D0 \uC788\uB294 desk-* PNG).
const DESK_SPRITE_CHOICES=['desk-main','desk-boss','desk-dark-mirror','desk-main-mirror','desk-dark','desk-boss-mirror','desk-main-front','desk-dark-front','desk-boss-front','desk-partition']; const DESK_SPRITE_CHOICES=['astra-desk-work','astra-desk-dark','astra-desk-exec'];
// \uCD94\uAC00 \uAC00\uB2A5\uD55C \uD504\uB78D sprite \uD6C4\uBCF4. // \uCD94\uAC00 \uAC00\uB2A5\uD55C \uD504\uB78D sprite \uD6C4\uBCF4.
const PROP_SPRITE_CHOICES=['board','plant-tall','bookshelf','plant-bushy','partition','cooler','filing','couch','rug','shelf','printer','monitor-blue','monitor-black','chair-blue','crt']; const PROP_SPRITE_CHOICES=['astra-storage-low','astra-bookshelf-slim','astra-plant-corner','astra-cooler-slim','astra-printer-compact','astra-chair-task'];
const SPRITE_LABELS={
'astra-desk-work':'워크 데스크',
'astra-desk-dark':'다크 데스크',
'astra-desk-exec':'대표 데스크',
'astra-storage-low':'로우 스토리지',
'astra-bookshelf-slim':'북케이스',
'astra-plant-corner':'코너 플랜트',
'astra-cooler-slim':'정수기',
'astra-printer-compact':'프린터',
'astra-chair-task':'태스크 체어',
};
// 후면 배경 자체가 이미 충분히 강하므로, 목적 없는 프랍은 기본 장면에서 제거. // 후면 배경 자체가 이미 충분히 강하므로, 목적 없는 프랍은 기본 장면에서 제거.
// 커스텀 편집 모드에선 여전히 사용자가 원하는 프랍을 직접 추가할 수 있다. // 커스텀 편집 모드에선 여전히 사용자가 원하는 프랍을 직접 추가할 수 있다.
const DEFAULT_PROPS=[]; const DEFAULT_PROPS=[];
@@ -63,6 +74,7 @@ const anim={}; // role \u2192 animation state.
const _autoDeskedFor = new Set(); const _autoDeskedFor = new Set();
function png(name){return base+'/'+name+'.png'} function png(name){return base+'/'+name+'.png'}
function _spriteLabel(name){ return SPRITE_LABELS[name] || name; }
function _rebuildStationIndex(){ function _rebuildStationIndex(){
Object.keys(stationByKey).forEach(k=>delete stationByKey[k]); Object.keys(stationByKey).forEach(k=>delete stationByKey[k]);
@@ -113,8 +125,8 @@ function _pipelineRosterOrder(roster, pipeline){
return _uniqueAgentsById(ordered); return _uniqueAgentsById(ordered);
} }
function _stationSpriteFor(slot, roleCategory){ function _stationSpriteFor(slot, roleCategory){
if(roleCategory==='ceo') return 'desk-boss'; if(roleCategory==='ceo') return 'astra-desk-exec';
return slot % 2 ? 'desk-dark' : 'desk-main'; return 'astra-desk-work';
} }
function _stationSlots(count){ function _stationSlots(count){
const rows=[]; const rows=[];
@@ -131,7 +143,7 @@ function _stationSlots(count){
const remaining = count - row*cols; const remaining = count - row*cols;
const inRow = Math.min(cols, remaining); const inRow = Math.min(cols, remaining);
const xs = xSets[inRow] || xSets[3]; const xs = xSets[inRow] || xSets[3];
const rowYs = rowCount === 1 ? [330] : (rowCount === 2 ? [304,432] : [270,380,490]); const rowYs = rowCount === 1 ? [300] : (rowCount === 2 ? [304,432] : [270,380,490]);
const y = rowYs[row] ?? (490 + Math.max(0,row-rowYs.length+1)*88); const y = rowYs[row] ?? (490 + Math.max(0,row-rowYs.length+1)*88);
xs.forEach((x)=>rows.push([x,y,'R'])); xs.forEach((x)=>rows.push([x,y,'R']));
} }
@@ -141,12 +153,12 @@ function _makeStationForRosterAgent(agent, slot, slots){
if(agent.roleCategory==='ceo' || agent.agentId==='ceo'){ if(agent.roleCategory==='ceo' || agent.agentId==='ceo'){
return { return {
key:'ceo',agentKey:agent.agentId,label:agent.agentName||'CEO',charRow:_roleCategoryToCharRow(agent.roleCategory), key:'ceo',agentKey:agent.agentId,label:agent.agentName||'CEO',charRow:_roleCategoryToCharRow(agent.roleCategory),
deskSprite:'desk-boss',deskX:294,deskY:188,deskW:124,seatX:319,seatY:218,face:'R', deskSprite:'astra-desk-exec',deskX:286,deskY:188,deskW:140,seatX:319,seatY:218,face:'R',
dock:[350,268],roam:[[304,280],[402,280]],boss:true, dock:[350,268],roam:[[304,280],[402,280]],boss:true,
}; };
} }
const [deskX,deskY,face]=(slots && slots[slot]) || [308,318,'R']; const [deskX,deskY,face]=(slots && slots[slot]) || [308,318,'R'];
const deskW=104; const deskW=112;
const seatX=deskX+4; const seatX=deskX+4;
const seatY=deskY+36; const seatY=deskY+36;
return { return {
@@ -677,6 +689,8 @@ const REVIEWER_KEYWORD_BANK = [
// 6~9초 random 간격으로 활성 character 중 하나에서 속마음 emit. mode 가 work / sit 인 것만. // 6~9초 random 간격으로 활성 character 중 하나에서 속마음 emit. mode 가 work / sit 인 것만.
// 한 tick 에 *한 명만* 골라서 띄움 — 동시에 여러 풍선 뜨면 시각 부담. // 한 tick 에 *한 명만* 골라서 띄움 — 동시에 여러 풍선 뜨면 시각 부담.
function _innerThoughtTick(){ function _innerThoughtTick(){
// Banter 가 재생 중이면 일반 inner-thought 는 잠시 멈춤 — 동시 다발 풍선으로 산만해지지 않게.
if(_activeBanter) return;
// Reviewing 중: 검수자 본인은 keyword 던지고, 옮겨와 있는 작성자는 nervous thought. // Reviewing 중: 검수자 본인은 keyword 던지고, 옮겨와 있는 작성자는 nervous thought.
if(_prevStatus === 'reviewing'){ if(_prevStatus === 'reviewing'){
const inspectorRole = roleMap['inspector']; const inspectorRole = roleMap['inspector'];
@@ -712,6 +726,171 @@ function _innerThoughtTick(){
if(text) _bubbleFromLog(role, text); if(text) _bubbleFromLog(role, text);
} }
setInterval(_innerThoughtTick, 7500); setInterval(_innerThoughtTick, 7500);
// ── Webtoon-style 티키타카 banter (refactor: pipeline-aware) ──
// 각 phase 에 *시퀀스화된 대화 script* 가 있어 phase 진입 시 한 줄씩 시간 차로 emit.
// 각 line 은 (agentKey, text, emotion) — emotion 은 bubble class 로 변환 (희노애락).
//
// 한 phase 마다 여러 variant 중 1개 random pick — 같은 흐름을 매번 안 보게.
// 진행 중 phase 가 바뀌면 즉시 중단.
const BANTER_SCRIPTS = {
// 의도 분석 — CEO 가 planner 와 함께 요청 정리
analyzing: [
[
{ agent: 'ceo', text: '의도 다시 보자.', emotion: 'thought' },
{ agent: 'planner', text: '핵심은 명확한데…', emotion: 'thought' },
{ agent: 'ceo', text: '뭐가 막혀?', emotion: 'curious' },
{ agent: 'planner', text: '성공 기준이 모호해요.', emotion: 'sorrow' },
{ agent: 'ceo', text: '좋아, 그것부터 정하자.', emotion: 'firm' },
],
[
{ agent: 'planner', text: '범위가 너무 넓은데…', emotion: 'sorrow' },
{ agent: 'ceo', text: '첫 사용자 한 명만 그려보자.', emotion: 'firm' },
{ agent: 'planner', text: '오, 그러면 깔끔해지네요.', emotion: 'joy' },
],
],
intake: [
[
{ agent: 'ceo', text: '오, 새 요청.', emotion: 'joy' },
{ agent: 'support', text: '브리프 정리해드릴게요.', emotion: 'thought' },
{ agent: 'ceo', text: '바로 보자.', emotion: 'firm' },
],
],
// 사용자에게 확인 필요
need_clarification: [
[
{ agent: 'planner', text: '이건 추측으로 가면 위험.', emotion: 'panic' },
{ agent: 'ceo', text: '맞아. 사장님께 묻자.', emotion: 'firm' },
{ agent: 'support', text: '질문 정리해서 띄울게요.', emotion: 'thought' },
],
],
// 계획
planning: [
[
{ agent: 'ceo', text: '순서 잡자. 누가 먼저?', emotion: 'firm' },
{ agent: 'planner', text: '리서치 → 설계 → 구현 흐름.', emotion: 'thought' },
{ agent: 'developer', text: '설계 전에 데이터 모델 확정', emotion: 'firm' },
{ agent: 'planner', text: '맞아요, 그게 우선.', emotion: 'joy' },
],
[
{ agent: 'planner', text: '여기 분기점이 두 개.', emotion: 'thought' },
{ agent: 'ceo', text: 'A 안 비용은?', emotion: 'curious' },
{ agent: 'planner', text: 'B 보다 절반.', emotion: 'thought' },
{ agent: 'ceo', text: 'A 로 가자. 확정.', emotion: 'firm' },
],
],
// 구현
executing: [
[
{ agent: 'developer', text: '집중 모드 들어갑니다.', emotion: 'firm' },
{ agent: 'qa', text: '엣지케이스 같이 봐드려요?', emotion: 'curious' },
{ agent: 'developer', text: '아, 빈 입력 케이스 빠뜨릴 뻔', emotion: 'panic' },
{ agent: 'qa', text: '네, 그거 자주 빼먹어요.', emotion: 'thought' },
{ agent: 'developer', text: '고마워요.', emotion: 'gratitude' },
],
[
{ agent: 'developer', text: '여기 너무 복잡한데.', emotion: 'sorrow' },
{ agent: 'planner', text: '단계 둘로 나누면?', emotion: 'curious' },
{ agent: 'developer', text: '오, 그러면 되겠네요.', emotion: 'joy' },
],
[
{ agent: 'designer', text: '여백 좀 더 줄까요?', emotion: 'curious' },
{ agent: 'developer', text: '여기 컴포넌트 정렬 잘 못맞춰요…', emotion: 'sorrow' },
{ agent: 'designer', text: 'flex gap 8 로 가면 깔끔.', emotion: 'firm' },
{ agent: 'developer', text: '오케이 반영.', emotion: 'gratitude' },
],
],
// 검수
reviewing: [
[
{ agent: 'inspector', text: '시나리오 빠진 것 있나…', emotion: 'thought' },
{ agent: 'planner', text: '괜찮을까…', emotion: 'panic' },
{ agent: 'inspector', text: '여기 측정 기준 누락.', emotion: 'firm' },
{ agent: 'planner', text: '아 KPI 부분, 보강할게요.', emotion: 'sorrow' },
{ agent: 'inspector', text: '그거만 채우면 통과.', emotion: 'thought' },
],
[
{ agent: 'inspector', text: '전체 흐름은 OK.', emotion: 'thought' },
{ agent: 'inspector', text: '근데 여기 over-engineering.', emotion: 'firm' },
{ agent: 'developer', text: '단순화 어떻게…', emotion: 'curious' },
{ agent: 'inspector', text: '함수 셋을 하나로.', emotion: 'firm' },
{ agent: 'developer', text: '바로 줄일게요.', emotion: 'gratitude' },
],
[
{ agent: 'qa', text: '회귀 케이스 1개 실패.', emotion: 'panic' },
{ agent: 'developer', text: '어… 어떤 거?', emotion: 'curious' },
{ agent: 'qa', text: '동시 호출 race.', emotion: 'firm' },
{ agent: 'developer', text: '아 그거… 죄송, 빨리 고칠게요.', emotion: 'sorrow' },
],
],
// 승인 대기
waiting_approval: [
[
{ agent: 'inspector', text: '위험 작업 감지.', emotion: 'panic' },
{ agent: 'ceo', text: '사장님 결재 필요.', emotion: 'firm' },
{ agent: 'support', text: '승인 카드 띄울게요.', emotion: 'thought' },
],
],
error: [
[
{ agent: 'developer', text: '앗, 이건 예상 못 했어요.', emotion: 'panic' },
{ agent: 'qa', text: '재현 단계는?', emotion: 'curious' },
{ agent: 'developer', text: '지금 정리 중…', emotion: 'sorrow' },
{ agent: 'inspector', text: '뭐가 깨졌나 같이 보자.', emotion: 'firm' },
],
],
done: [
[
{ agent: 'ceo', text: '좋아, 끝났다!', emotion: 'joy' },
{ agent: 'planner', text: '이번 작업 깔끔하게 완료.', emotion: 'joy' },
{ agent: 'developer', text: '커피 한 잔!', emotion: 'joy' },
{ agent: 'support', text: '회의록·일정 자동 정리 했어요.', emotion: 'joy' },
],
],
};
// 감정 태그 → CSS class. _emoteBubble 가 .bubble + .bubble-<emotion> 으로 렌더.
function _emoteBubble(role, text, emotion){
const ch = chars[role];
if(!ch || !text) return;
const b = document.createElement('div');
b.className = 'bubble bubble-' + (emotion || 'thought');
b.textContent = text;
b.style.left = (parseFloat(ch.style.left) + 28) + 'px';
b.style.top = (parseFloat(ch.style.top) - 6) + 'px';
stage.appendChild(b);
setTimeout(() => b.remove(), 3200);
}
// 활성 banter 추적 — phase 가 바뀌면 즉시 중단해서 새 script 시작.
let _activeBanter = null;
function _stopBanter(){
if(_activeBanter && _activeBanter.timer){ clearTimeout(_activeBanter.timer); }
_activeBanter = null;
}
function _stepBanter(){
if(!_activeBanter) return;
if(_activeBanter.idx >= _activeBanter.lines.length){ _activeBanter = null; return; }
const line = _activeBanter.lines[_activeBanter.idx++];
const role = roleMap[line.agent];
if(role){
_emoteBubble(role, line.text, line.emotion);
}
// 다음 줄까지 1.8~2.4초 — 자연스러운 대화 호흡.
const delay = 1800 + Math.floor(Math.random() * 600);
_activeBanter.timer = setTimeout(_stepBanter, delay);
}
function _playBanterForPhase(phaseOrStatus){
if(!phaseOrStatus) return;
const variants = BANTER_SCRIPTS[phaseOrStatus];
if(!Array.isArray(variants) || variants.length === 0) return;
const script = variants[Math.floor(Math.random() * variants.length)];
if(!Array.isArray(script) || script.length === 0) return;
_stopBanter();
_activeBanter = { lines: script, idx: 0, timer: null };
// 첫 줄 즉시.
_stepBanter();
}
// ── A. 상태 계층화 ── // ── A. 상태 계층화 ──
// 모든 phase event에 시퀀스를 큐에 넣으면 캐릭터가 끊임없이 걸어다녀 산만함. // 모든 phase event에 시퀀스를 큐에 넣으면 캐릭터가 끊임없이 걸어다녀 산만함.
// 일반 상태(executing/reviewing/planning/analyzing 등)는 *정적 갱신*만 하고, // 일반 상태(executing/reviewing/planning/analyzing 등)는 *정적 갱신*만 하고,
@@ -871,6 +1050,10 @@ function apply(s){
_restoreWriterHome(r); _restoreWriterHome(r);
} }
} }
// ── Webtoon-style banter trigger ──
// Phase 바뀔 때 해당 phase 의 대화 script 한 variant 무작위 선택해서 시퀀스 재생.
// 진행 중 다른 phase 진입하면 자동 중단되고 새 script 가 시작.
_playBanterForPhase(st);
} }
if(isTransition){ if(isTransition){
// 상태가 바뀔 때 작업 마무리한 캐릭터들 자리 정리(work→sit). // 상태가 바뀔 때 작업 마무리한 캐릭터들 자리 정리(work→sit).
@@ -1123,7 +1306,7 @@ function _autoCreateDeskForAgent(r){
agentKey: r.agentId, agentKey: r.agentId,
label: r.agentName || r.agentId, label: r.agentName || r.agentId,
charRow: _roleCategoryToCharRow(r.roleCategory), charRow: _roleCategoryToCharRow(r.roleCategory),
deskSprite: isBoss ? 'desk-boss' : 'desk-main', deskSprite: isBoss ? 'astra-desk-exec' : 'astra-desk-work',
face: 'R', face: 'R',
boss: isBoss, boss: isBoss,
deskX: baseX, deskY: baseY, deskW: isBoss ? 136 : 112, deskX: baseX, deskY: baseY, deskW: isBoss ? 136 : 112,
@@ -1259,7 +1442,7 @@ function _snapshotLayout(){
agentKey: st.agentKey || '', agentKey: st.agentKey || '',
label: st.label || '', label: st.label || '',
charRow: st.charRow ?? 0, charRow: st.charRow ?? 0,
deskSprite: st.deskSprite || 'desk-main', deskSprite: st.deskSprite || 'astra-desk-work',
face: st.face || 'R', face: st.face || 'R',
boss: !!st.boss, boss: !!st.boss,
noChar: !!st.noChar, noChar: !!st.noChar,
@@ -1330,7 +1513,7 @@ function _restoreLayout(snap){
agentKey: c.agentKey || '', agentKey: c.agentKey || '',
label: c.label || c.roleKey, label: c.label || c.roleKey,
charRow: typeof c.charRow === 'number' ? c.charRow : 0, charRow: typeof c.charRow === 'number' ? c.charRow : 0,
deskSprite: c.deskSprite || 'desk-main', deskSprite: c.deskSprite || 'astra-desk-work',
face: c.face || 'R', face: c.face || 'R',
boss: !!c.boss, boss: !!c.boss,
noChar: !!c.noChar, noChar: !!c.noChar,
@@ -1448,7 +1631,7 @@ function _renderDeskProps(deskEl){
// 에이전트 매핑 dropdown. // 에이전트 매핑 dropdown.
const agentOpts = AGENT_CHOICES.map(a=>'<option value="'+a+'"'+(a===(st.agentKey||'')?' selected':'')+'>'+(a||'(매핑 없음)')+'</option>').join(''); const agentOpts = AGENT_CHOICES.map(a=>'<option value="'+a+'"'+(a===(st.agentKey||'')?' selected':'')+'>'+(a||'(매핑 없음)')+'</option>').join('');
// 책상 sprite picker. // 책상 sprite picker.
const deskOpts = DESK_SPRITE_CHOICES.map(s=>'<option value="'+s+'"'+(s===st.deskSprite?' selected':'')+'>'+s+'</option>').join(''); const deskOpts = DESK_SPRITE_CHOICES.map(s=>'<option value="'+s+'"'+(s===st.deskSprite?' selected':'')+'>'+_spriteLabel(s)+'</option>').join('');
// charRow 썸네일 picker (idle-r<n>-f0.png). // charRow 썸네일 picker (idle-r<n>-f0.png).
let thumbs=''; let thumbs='';
for(let r=0;r<8;r++){ for(let r=0;r<8;r++){
@@ -1513,9 +1696,9 @@ function _renderObjProps(el){
panel.classList.add('show'); panel.classList.add('show');
const name = el.dataset.objName || ''; const name = el.dataset.objName || '';
const w = el.dataset.objW || ''; const w = el.dataset.objW || '';
const opts = PROP_SPRITE_CHOICES.map(s=>'<option value="'+s+'"'+(s===name?' selected':'')+'>'+s+'</option>').join(''); const opts = PROP_SPRITE_CHOICES.map(s=>'<option value="'+s+'"'+(s===name?' selected':'')+'>'+_spriteLabel(s)+'</option>').join('');
panel.innerHTML = panel.innerHTML =
'<h4>프랍 속성</h4>'+ '<h4>가구 속성</h4>'+
'<div class="pp-row"><label>sprite</label><select id="ppObjName">'+opts+'</select></div>'+ '<div class="pp-row"><label>sprite</label><select id="ppObjName">'+opts+'</select></div>'+
'<div class="pp-row"><label>너비 (px, 비우면 원본)</label><input id="ppObjW" type="number" value="'+w+'" placeholder="원본"></div>'; '<div class="pp-row"><label>너비 (px, 비우면 원본)</label><input id="ppObjW" type="number" value="'+w+'" placeholder="원본"></div>';
panel.querySelector('#ppObjName').onchange = (ev)=>{ panel.querySelector('#ppObjName').onchange = (ev)=>{
@@ -1536,7 +1719,7 @@ function _addNewDesk(){
const baseX = 280 + ((__nextDeskN%5)*16); const baseX = 280 + ((__nextDeskN%5)*16);
const baseY = 260 + ((__nextDeskN%5)*16); const baseY = 260 + ((__nextDeskN%5)*16);
const st = { const st = {
key: id, agentKey: '', label: '새 책상', charRow: 0, deskSprite: 'desk-main', face: 'R', key: id, agentKey: '', label: '새 책상', charRow: 0, deskSprite: 'astra-desk-work', face: 'R',
boss: false, boss: false,
deskX: baseX, deskY: baseY, deskW: 112, deskX: baseX, deskY: baseY, deskW: 112,
seatX: baseX+4, seatY: baseY+36, seatX: baseX+4, seatY: baseY+36,
@@ -1557,9 +1740,9 @@ function _openPropPicker(){
overlay.className = 'prop-picker'; overlay.className = 'prop-picker';
const box = document.createElement('div'); const box = document.createElement('div');
box.className = 'prop-picker-box'; box.className = 'prop-picker-box';
box.innerHTML = '<h3>프랍 추가 — sprite 선택</h3>'+ box.innerHTML = '<h3>가구 추가</h3>'+
'<div class="prop-picker-grid">'+ '<div class="prop-picker-grid">'+
PROP_SPRITE_CHOICES.map(n=>'<div class="prop-pick" data-name="'+n+'"><img src="'+png(n)+'"><div class="pp-name">'+n+'</div></div>').join('')+ PROP_SPRITE_CHOICES.map(n=>'<div class="prop-pick" data-name="'+n+'"><img src="'+png(n)+'"><div class="pp-name">'+_spriteLabel(n)+'</div></div>').join('')+
'</div>'; '</div>';
overlay.appendChild(box); overlay.appendChild(box);
overlay.onclick = (e)=>{ if(e.target === overlay) overlay.remove(); }; overlay.onclick = (e)=>{ if(e.target === overlay) overlay.remove(); };
+71
View File
@@ -0,0 +1,71 @@
/**
* Devil's Advocate () system prompt .
*
* :
* - . * turn 1* .
* - 'X 입장에서 본다면' framing * * .
* LLM '내가 옳다' .
* - / / ** .
* Phase 2 .
* - '(근거: 추론)' '(근거: brain/<file>)' .
*
* :
* 3~5 . "① 우려" + "② 검증 방법 1개" .
*/
export const DEVIL_PERSONA_NAME = '도현';
export interface DevilPromptInput {
/** 사용자가 던진 원래 질문. */
userPrompt: string;
/** Astra 의 직전 답변. 도현이 반박할 대상. */
assistantAnswer: string;
/** Optional Second Brain context — 있으면 인용 근거로 사용 가능. */
brainContext?: string;
/** Optional re-rebuttal — 사용자가 '재반박' 클릭 후 보낸 메시지. 있으면 다시 한 라운드. */
userRebuttal?: string;
}
const BASE_RULES = [
'당신은 \'도현\', 사용자의 사고를 더 깊게 만드는 *비판적 sparring partner* 입니다.',
'',
'규칙:',
'1. *한 turn 에 한 가지 약점* 만 골라 반박. 모든 약점을 나열하지 말 것 — 사용자가 깊게 생각하도록.',
'2. 본인 의견이 아닌 *다른 시점* 제시: "만약 비관적으로 본다면", "사용자 입장에서는", "1년 뒤 본다면" 등으로 framing 시작.',
'3. 칭찬 / 동의 / "좋은 답변입니다" 금지. 건설적이지만 *단호*.',
'4. 통계·연구·구체 수치·외부 사례 *인용 금지* — 당신은 그런 데이터를 보유하지 않음. 구조적·논리적·시나리오 비판만.',
'5. 답변은 *3~5 문장 한 문단*. 마지막에 반드시 두 줄:',
' ① 우려: <한 문장 핵심 우려>',
' ② 검증: <한 가지 구체적 검증 방법>',
'6. 답변 끝에 출처 태그: brain 자료 인용했으면 `(근거: brain/<filename>)`, 안 했으면 `(근거: 추론)`.',
].join('\n');
export function buildDevilSystemPrompt(): string {
return BASE_RULES;
}
export function buildDevilUserPrompt(input: DevilPromptInput): string {
const parts: string[] = [];
parts.push('## 사용자 원래 질문');
parts.push(input.userPrompt.trim() || '(빈 질문)');
parts.push('');
parts.push('## Astra 의 직전 답변 (당신이 반박할 대상)');
parts.push(input.assistantAnswer.trim() || '(빈 답변)');
if (input.brainContext && input.brainContext.trim()) {
parts.push('');
parts.push('## 참고 가능한 Second Brain 자료');
parts.push('(필요할 때만 인용. 인용하지 않으면 추론으로 표시.)');
parts.push(input.brainContext.trim());
}
if (input.userRebuttal && input.userRebuttal.trim()) {
parts.push('');
parts.push('## 사용자의 재반박');
parts.push(input.userRebuttal.trim());
parts.push('');
parts.push('재반박을 받았으니 *기존 입장을 굽히지 말되 한 단계 더 깊은 약점* 으로 이동하세요. 같은 약점을 반복하지 말 것.');
} else {
parts.push('');
parts.push('위 답변에서 *가장 본질적인 약점 하나* 를 골라 반박하세요.');
}
return parts.join('\n');
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Devil Agent .
*
* :
* 1. agent.ts main turn
* 2. model/engine * LLM call 1*
* 3. webview 'devilRebuttal' message send
* 4. / silent skip main
*
* :
* - turn LLM 1 . settings ON .
* - prompt + maxTokens 350 turn ~10-15%.
*/
import * as vscode from 'vscode';
import { buildDevilSystemPrompt, buildDevilUserPrompt, DevilPromptInput, DEVIL_PERSONA_NAME } from './devilPrompt';
const SETTING_KEY = 'g1nation.devilAgent.enabled';
export function isDevilAgentEnabled(): boolean {
const cfg = vscode.workspace.getConfiguration('g1nation');
return !!cfg.get<boolean>('devilAgent.enabled', false);
}
export async function setDevilAgentEnabled(enabled: boolean): Promise<void> {
const cfg = vscode.workspace.getConfiguration('g1nation');
await cfg.update('devilAgent.enabled', enabled, vscode.ConfigurationTarget.Global);
}
export { DEVIL_PERSONA_NAME, SETTING_KEY };
/**
* Main turn caller (agent.ts) .
* - input + + () brain context
* - 반환값: 도현의 null (· )
*
* webview 'devilRebuttal' message *pure*.
*/
export async function generateDevilRebuttal(
callLLM: (system: string, userMessage: string, maxTokens: number) => Promise<string>,
input: DevilPromptInput,
): Promise<string | null> {
if (!isDevilAgentEnabled()) return null;
try {
const system = buildDevilSystemPrompt();
const userMsg = buildDevilUserPrompt(input);
const out = await callLLM(system, userMsg, 350);
const cleaned = (out || '').trim();
if (!cleaned) return null;
// 환각 가드: 통계 / 수치 패턴이 보이면 *(근거: 추론)* 로 강제 변경 — 도현이 환각 자신 가질 가능성 줄임.
return _appendSourceTagIfMissing(cleaned);
} catch {
return null;
}
}
function _appendSourceTagIfMissing(text: string): string {
if (/\(근거:/.test(text)) return text;
return text.trim() + '\n\n(근거: 추론)';
}
+13
View File
@@ -0,0 +1,13 @@
export {
buildDevilSystemPrompt,
buildDevilUserPrompt,
DevilPromptInput,
DEVIL_PERSONA_NAME,
} from './devilPrompt';
export {
isDevilAgentEnabled,
setDevilAgentEnabled,
generateDevilRebuttal,
SETTING_KEY,
} from './devilService';
@@ -98,6 +98,18 @@ interface SettingsState {
connectedAt?: string; connectedAt?: string;
lastIcalFetchAt?: string; lastIcalFetchAt?: string;
}; };
/**
* Cloud LLM providers (OpenRouter / Anthropic / Gemini). API key echo
* hasApiKey boolean . enabled defaultModel settings .
*/
providers: {
openrouter: { enabled: boolean; hasApiKey: boolean; defaultModel: string };
anthropic: { enabled: boolean; hasApiKey: boolean; defaultModel: string };
gemini: { enabled: boolean; hasApiKey: boolean; defaultModel: string };
};
devilAgent: {
enabled: boolean;
};
/** Sectional banner shown when config.update fails (e.g. reload required). */ /** Sectional banner shown when config.update fails (e.g. reload required). */
bannerError?: string; bannerError?: string;
} }
@@ -238,6 +250,12 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
case 'google.icalRefresh': case 'google.icalRefresh':
await this._handleGoogleIcalRefresh(); await this._handleGoogleIcalRefresh();
return; return;
case 'providers.update':
await this._handleProvidersUpdate(msg);
return;
case 'devilAgent.toggle':
await this._safeConfigUpdate('devilAgent.enabled', !!msg.enabled);
return;
case 'openVscodeSettings': case 'openVscodeSettings':
await vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation'); await vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation');
return; return;
@@ -509,6 +527,36 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
await this._refreshState(); await this._refreshState();
} }
// ────────────── Cloud LLM Providers ──────────────
// OpenRouter / Anthropic / Gemini API key + enable 토글. API key 는 Secret Storage 만.
// settings 패널은 *값 자체는 안 보여줌* (hasApiKey boolean 만). 사용자가 새로 입력 시 덮어씀.
private async _buildProvidersState(): Promise<SettingsState['providers']> {
const { readProviderStatus } = require('../providers') as typeof import('../providers');
const ctx = this._deps.context;
const [or, an, ge] = await Promise.all([
readProviderStatus(ctx, 'openrouter'),
readProviderStatus(ctx, 'anthropic'),
readProviderStatus(ctx, 'gemini'),
]);
return { openrouter: or, anthropic: an, gemini: ge };
}
private async _handleProvidersUpdate(msg: any): Promise<void> {
const { writeProviderConfig } = require('../providers') as typeof import('../providers');
const id = msg.providerId;
if (id !== 'openrouter' && id !== 'anthropic' && id !== 'gemini') return;
const patch: any = {};
if (typeof msg.enabled === 'boolean') patch.enabled = msg.enabled;
if (typeof msg.apiKey === 'string') patch.apiKey = msg.apiKey;
if (typeof msg.defaultModel === 'string') patch.defaultModel = msg.defaultModel;
if (Object.keys(patch).length === 0) return;
await writeProviderConfig(this._deps.context, id, patch);
this._lastSuccess = `${id} 저장 완료`;
this._lastError = undefined;
await this._refreshState();
}
private async _handleAdvancedUpdate(msg: any): Promise<void> { private async _handleAdvancedUpdate(msg: any): Promise<void> {
if (typeof msg.dryRun === 'boolean') { if (typeof msg.dryRun === 'boolean') {
await this._safeConfigUpdate('dryRun', msg.dryRun); await this._safeConfigUpdate('dryRun', msg.dryRun);
@@ -573,6 +621,8 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
maxContextSize: cfg.get<number>('maxContextSize', 32000) ?? 32000, maxContextSize: cfg.get<number>('maxContextSize', 32000) ?? 32000,
}, },
google: this._buildGoogleState(), google: this._buildGoogleState(),
providers: await this._buildProvidersState(),
devilAgent: { enabled: cfg.get<boolean>('devilAgent.enabled', false) },
bannerError: this._bannerError, bannerError: this._bannerError,
}; };
const payload = { type: 'state', value: state }; const payload = { type: 'state', value: state };
+61
View File
@@ -0,0 +1,61 @@
import { buildDevilSystemPrompt, buildDevilUserPrompt } from '../src/features/devilAgent/devilPrompt';
describe('Devil Agent prompt builder', () => {
test('system prompt enforces the 6 core rules', () => {
const sys = buildDevilSystemPrompt();
// 핵심 규칙들이 모두 들어있는지 검증 — 정확한 문자열은 prompt 본문 변경에 깨지지만,
// 가치 보존되는 핵심 키워드 기준으로 체크.
expect(sys).toContain('도현');
expect(sys).toContain('한 가지 약점'); // rule 1
expect(sys).toContain('만약'); // rule 2 framing
expect(sys).toContain('칭찬'); // rule 3 — 금지
expect(sys).toContain('통계'); // rule 4 — 환각 가드
expect(sys).toContain('우려'); // rule 5 출력 형식
expect(sys).toContain('검증'); // rule 5 출력 형식
expect(sys).toContain('근거:'); // rule 6 출처 태그
});
test('user prompt includes both user prompt and assistant answer', () => {
const u = buildDevilUserPrompt({
userPrompt: '회원가입 흐름 설계 도와줘',
assistantAnswer: '이메일+비번 기본 flow 를 권장합니다.',
});
expect(u).toContain('회원가입 흐름 설계 도와줘');
expect(u).toContain('이메일+비번 기본 flow 를 권장합니다');
expect(u).toContain('Astra 의 직전 답변');
expect(u).toContain('가장 본질적인 약점 하나');
});
test('user prompt with brainContext adds reference section', () => {
const u = buildDevilUserPrompt({
userPrompt: 'Q',
assistantAnswer: 'A',
brainContext: '## brain/sample.md\n샘플 자료입니다.',
});
expect(u).toContain('참고 가능한 Second Brain 자료');
expect(u).toContain('샘플 자료입니다');
});
test('user prompt with userRebuttal switches mode to deeper round', () => {
const u = buildDevilUserPrompt({
userPrompt: 'Q',
assistantAnswer: 'A',
userRebuttal: '그건 사용자 입장에선 오히려 편리한데?',
});
expect(u).toContain('사용자의 재반박');
expect(u).toContain('그건 사용자 입장에선 오히려 편리한데');
expect(u).toContain('한 단계 더 깊은 약점');
// 첫 라운드 표시는 보이지 말아야 — userRebuttal 있으면 다른 분기.
expect(u).not.toContain('가장 본질적인 약점 하나를 골라');
});
test('user prompt handles empty fields gracefully', () => {
const u = buildDevilUserPrompt({ userPrompt: '', assistantAnswer: '' });
expect(u).toContain('(빈 질문)');
expect(u).toContain('(빈 답변)');
});
});
// generateDevilRebuttal 은 vscode 의존 — settings 토글 / module dynamic import 흐름 때문에
// 본 unit suite 에서는 직접 호출 X. 대신 prompt 빌더에 집중.
// settings 통합 흐름은 통합 테스트(향후)로 분리.
+97
View File
@@ -0,0 +1,97 @@
import { parseModelPrefix, makeModelId, providerLabel } from '../src/features/providers/types';
import { _internals } from '../src/features/providers/streamHelpers';
describe('parseModelPrefix', () => {
test('matches openrouter / anthropic / gemini prefixes', () => {
expect(parseModelPrefix('openrouter:anthropic/claude-3.5-sonnet')).toEqual({
provider: 'openrouter', model: 'anthropic/claude-3.5-sonnet',
});
expect(parseModelPrefix('anthropic:claude-3-5-sonnet-20241022')).toEqual({
provider: 'anthropic', model: 'claude-3-5-sonnet-20241022',
});
expect(parseModelPrefix('gemini:gemini-2.0-flash-exp')).toEqual({
provider: 'gemini', model: 'gemini-2.0-flash-exp',
});
});
test('returns null for local engine model ids', () => {
expect(parseModelPrefix('gemma4:e2b')).toBeNull();
expect(parseModelPrefix('llama-3.2:8b')).toBeNull();
expect(parseModelPrefix('google/gemma-4-e4b')).toBeNull();
});
test('returns null for empty / undefined input', () => {
expect(parseModelPrefix('')).toBeNull();
expect(parseModelPrefix(undefined as any)).toBeNull();
});
test('makeModelId round-trips with parseModelPrefix', () => {
const id = makeModelId('openrouter', 'meta/llama-3.3-70b');
expect(id).toBe('openrouter:meta/llama-3.3-70b');
expect(parseModelPrefix(id)).toEqual({ provider: 'openrouter', model: 'meta/llama-3.3-70b' });
});
test('providerLabel returns Korean-friendly labels', () => {
expect(providerLabel('openrouter')).toBe('OpenRouter');
expect(providerLabel('anthropic')).toBe('Anthropic');
expect(providerLabel('gemini')).toBe('Gemini');
});
});
describe('Anthropic event → OpenAI SSE conversion', () => {
const conv = _internals.anthropicEventToOpenAI;
test('content_block_delta with text emits OpenAI chunk', () => {
const out = conv('content_block_delta', '{"delta":{"type":"text_delta","text":"안녕"}}');
expect(out).toBeTruthy();
expect(out).toContain('"content":"안녕"');
expect(out).toMatch(/^data: /);
expect(out!.endsWith('\n\n')).toBe(true);
});
test('non-delta events (message_start, content_block_start, etc.) are ignored', () => {
expect(conv('message_start', '{}')).toBeNull();
expect(conv('content_block_start', '{}')).toBeNull();
expect(conv('message_stop', '{}')).toBeNull();
});
test('malformed JSON returns null', () => {
expect(conv('content_block_delta', 'not json')).toBeNull();
expect(conv('content_block_delta', '{')).toBeNull();
});
test('empty text field returns null (no zero-length chunks)', () => {
expect(conv('content_block_delta', '{"delta":{"type":"text_delta","text":""}}')).toBeNull();
expect(conv('content_block_delta', '{"delta":{}}')).toBeNull();
});
});
describe('Gemini event → OpenAI SSE conversion', () => {
const conv = _internals.geminiEventToOpenAI;
test('extracts text from candidates[0].content.parts', () => {
const out = conv(null, '{"candidates":[{"content":{"parts":[{"text":"안녕"}],"role":"model"}}]}');
expect(out).toBeTruthy();
expect(out).toContain('"content":"안녕"');
expect(out).toMatch(/^data: /);
});
test('joins multiple parts into one chunk', () => {
const out = conv(null, '{"candidates":[{"content":{"parts":[{"text":"안"},{"text":"녕"}]}}]}');
expect(out).toContain('"content":"안녕"');
});
test('returns null when candidates missing / empty', () => {
expect(conv(null, '{}')).toBeNull();
expect(conv(null, '{"candidates":[]}')).toBeNull();
expect(conv(null, '{"candidates":[{"content":{}}]}')).toBeNull();
});
test('malformed JSON returns null', () => {
expect(conv(null, 'not json')).toBeNull();
});
test('empty text returns null', () => {
expect(conv(null, '{"candidates":[{"content":{"parts":[{"text":""}]}}]}')).toBeNull();
});
});