feat: v2.2.92 → v2.2.158 — god-file 분해 + Stocks feature + 대화 연속성

R56–R59: agent.ts 2731→1529줄 god-file 분해 (25 modules)
  · attrParsers + LLM 메서드 8개 (callNonStreaming, streamChatOnce 등)
  · executeActions 415줄 → 8 handler 그룹 (file/run/list/brain/calendar/sheets/tasks)
  · handlePrompt 1100줄 → 7 phase 모듈 (system prompt + budget + autoContinue 등)

R50–R55: extension.ts 1145→349줄 (telegram/settings/provider commands 분리)

Stocks feature 신규: /stocks slash command (v2.2.152~158)
  · .astra/stocks.json 저장소 + Yahoo Finance 현재가 갱신
  · 8 키워드 필터 (ROE/성장성/유동성/수익성/영업효율/기술력/안정성/PBR)
  · Naver 시가총액 페이지 JSON API (m.stock.naver.com) 발굴
  · LLM Top 5 매력도 분석 + Telegram 자동 보고서
  · KST 09:00/15:00 watcher 자동 모니터링

대화 연속성 (v2.2.150~157):
  · [PRIOR TURN CONCLUSION] block 으로 직전 결론 anchor
  · thin follow-up 분류 → boilerplate 헤더 suppression
  · slash 명령 결과 chatHistory mirror (capture wrapper)
  · echo/parrot 금지 system prompt rule

기타: /stocks 슬래시 자동완성 dropdown UI, Naver JSON API 전환 (cheerio 제거)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
g1nation
2026-05-25 09:59:32 +09:00
parent 4153f640c2
commit 0a97324f1b
149 changed files with 14628 additions and 6927 deletions
+63 -59
View File
@@ -3,15 +3,15 @@
<!-- ASTRA:AUTO-START -->
## Snapshot
- **Workspace**: `ConnectAI` `v2.2.90` _(absolute path varies by environment; resolved from the active VS Code workspace)_
- **Workspace**: `ConnectAI` `v2.2.158` _(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**: 286 source files, ~57,087 lines across 5 top-level modules.
- **Stats**: 395 source files, ~63,423 lines across 5 top-level modules.
## Last Refresh
- **Time**: 2026-05-24T04:49:04.938Z
- **Files newly analysed**: 3
- **Files reused from cache**: 283
- **Time**: 2026-05-25T00:59:03.313Z
- **Files newly analysed**: 1
- **Files reused from cache**: 394
## Directory Map
```mermaid
@@ -19,13 +19,15 @@ mindmap
root((ConnectAI))
src/
features/
core/
memory/
retrieval/
docs/
sidebar/
lib/
agent/
core/
extension/
media/
tests/
helpers/
integration/
mocks/
core_py/
docs/
@@ -37,9 +39,9 @@ mindmap
> Arrows: which top-level module imports from which.
```mermaid
flowchart LR
src["src/<br/>140 files"]
src["src/<br/>247 files"]
media["media/<br/>6 files"]
tests["tests/<br/>35 files"]
tests["tests/<br/>37 files"]
core_py["core_py/<br/>6 files"]
docs["docs/<br/>99 files"]
tests --> src
@@ -53,82 +55,85 @@ flowchart LR
## Hub Files
> Imported by many other files — touching these has wide blast radius.
- `src/utils.ts` — referenced by **50** files
- `src/config.ts` — referenced by **16** files
- `src/features/company/types.ts` — referenced by **13** files · 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/core/services.ts` — referenced by **10** files
- `src/lib/paths.ts` — referenced by **10** files
- `src/agent.ts` — referenced by **7** files
- `src/sidebarProvider.ts` — referenced by **7** files
- `src/skills/agentKnowledgeMap.ts` — referenced by **6** files
- `src/utils.ts` — referenced by **87** files
- `src/agent.ts` — referenced by **34** files
- `src/config.ts` — referenced by **32** files
- `src/core/services.ts` — referenced by **14** files
- `src/features/company/index.ts` — referenced by **14** files · Public API for 1인 기업 모드. Consumers (sidebarProvider, chatHandlers, command handlers) import from this barrel so internal layout can move around without touching every call site.
- `src/features/company/types.ts` — referenced by **14** files · 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/sidebarProvider.ts` — referenced by **11** files
- `src/lib/contextManager.ts` — referenced by **10** files · Context Manager (컨텍스트 한계 관리) "context length = 132k" 는 "답변을 132k 토큰까지 생성해도 된다" 가 아닙니다. 시스템 프롬프트 + 대화 기록 + 입력 문서 + 생성될 답변 + 여유분 ≤ context length 이 모듈은 요청을 보내기 전에 입력 토큰을 추정하고, - 동적으로 출력 상한(maxTokens)을 계
## Modules
### `src/` — 140 files, ~39,922 lines
### `src/` — 247 files, ~45,859 lines
**Sub-directories**
- `src/features/` (67) — Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema 도 같은 entry 로 노출 예정. 현재 노출: full webview panel H
- `src/features/` (87) — Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema 도 같은 entry 로 노출 예정. 현재 노출: full webview panel H
- `src/sidebar/` (35) — Brain profile lifecycle 의 pure helpers — sidebarProvider 의 add/edit/delete 흐름에서 modal UI 와 config 쓰기를 제외한 데이터 변환 만 격리. 현
- `src/lib/` (28) — Astra Mode Architecture Context Builder. 의도: 사용자가 Astra 자체의 mode 디자인 (Guard vs Multi-Agent 가 별도 모드여야 하는지) 을 묻는 메타 질문에 답할
- `src/agent/` (25) — 25 files (.ts)
- `src/core/` (15) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토
- `src/extension/` (8) — 8 files (.ts)
- `src/memory/` (8) — Episodic Memory (일화 기억) 과거 대화/회의/결정의 맥락 흐름을 저장합니다. 세션 종료 시 자동으로 에피소드를 요약하여 저장합니다. "왜 이렇게 결정했는지", "어떤 흐름으로 진행했는지" 기록. 저장
- `src/retrieval/` (8) — Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-I
- `src/docs/` (6) — src Chronicle Records
- `src/lib/` (6) — Context Manager (컨텍스트 한계 관리) "context length = 132k" 는 "답변을 132k 토큰까지 생성해도 된다" 가 아닙니다. 시스템 프롬프트 + 대화 기록 + 입력 문서 + 생성될 답변
- `src/sidebar/` (5) — 5 files (.ts)
- `src/integrations/` (4) — Per-chat conversation history for the Telegram bot. Why this exists: the previous bot was stateless — every inbound mess
- `src/integrations/` (6) — 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)
- `src/skills/` (4) — 4 files (.ts)
- `src/agents/` (2) — 2 files (.ts)
- `src/scaffolder/` (2) — Scaffolder template catalog. Templates are pure data — (projectName) => { [relativePath]: contents }. New templates are
**Key files**
- `src/utils.ts` (448 lines)
- `src/config.ts` (406 lines)
- `src/utils.ts` (471 lines)
- `src/agent.ts` (1487 lines)
- `src/config.ts` (418 lines)
- `src/features/company/types.ts` (446 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/sidebarProvider.ts` (3194 lines)
- `src/core/services.ts` (176 lines)
- `src/sidebarProvider.ts` (4340 lines)
- `src/lib/paths.ts` (151 lines)
- `src/lib/contextManager.ts` (278 lines) — Context Manager (컨텍스트 한계 관리) "context length = 132k" 는 "답변을 132k 토큰까지 생성해도 된다" 가 아닙니다. 시스템 프롬프트 + 대화 기록 + 입력 문서 + 생성될 답변 + 여유분 ≤ context length 이 모듈은 요청을 보내기 전에 입력 토큰을 추정하고, - 동적으로 출력 상한(maxTokens)을 계
- `src/features/company/companyConfig.ts` (896 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/integrations/telegram/telegramClient.ts` (154 lines)
- `src/lib/paths.ts` (151 lines)
- `src/agent/actions/types.ts` (41 lines)
- `src/skills/agentKnowledgeMap.ts` (374 lines)
- `src/features/stocks/types.ts` (53 lines) — Stocks 모듈 공유 타입. investresults/targetstocks.json 스키마를 그대로 받아서, ConnectAI 의 <workspace>/.astra/stocks.json 으로 옮긴 뒤 같은 필드명을 유지. 한글 필드명은 사용자의 도메인 데이터라 변경하지 않는다 — 마이그레이션 충돌 회피 + 사용자가 직접 JSON 편집할 때 frictio
- `src/lib/contextBuilders/promptDetection.ts` (85 lines) — 사용자 prompt 의 의도 분류 류 detection helpers. 모두 stateless 정규식 매칭. 옛 코드는 agent.ts 의 private 메서드로 박혀 있었는데, system prompt 빌더 (buildJarvisProjectBriefContext 등) 가 이걸 의존하면서 god-file 안에서 서로 얽힘. 헬퍼만 먼저 떼면 의존 그래프가
- `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/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` (541 lines) — Scoring Engine — TF-IDF + Bilingual Tokenizer 단순 includes() 키워드 매칭을 넘어서, TF-IDF 가중치 기반의 문서 스코어링을 제공합니다. 한국어/영어 양국어 토크나이저를 포함합니다.
- `src/skills/agentKnowledgeMap.ts` (374 lines)
- `src/agent.ts` (4105 lines)
- `src/security.ts` (159 lines)
- `src/features/secondBrainTrace.ts` (792 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/lib/engine.ts` (1103 lines)
- `src/retrieval/brainIndex.ts` (325 lines) — Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-IDF 점수를 계산했습니다 — 파일 수가 많아지면 그게 병목입니다. 이 모듈은 <brainPath>/.astra/brain-index.json 에
- `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/features/company/dispatcher.ts` (1442 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/approval/approvalQueue.ts` (129 lines)
- `src/integrations/telegram/telegramClient.ts` (154 lines)
- `src/features/astraOffice/view/runtime.ts` (1932 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/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/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` (278 lines) — Context Manager (컨텍스트 한계 관리) "context length = 132k" 는 "답변을 132k 토큰까지 생성해도 된다" 가 아닙니다. 시스템 프롬프트 + 대화 기록 + 입력 문서 + 생성될 답변 + 여유분 ≤ context length 이 모듈은 요청을 보내기 전에 입력 토큰을 추정하고, - 동적으로 출력 상한(maxTokens)을 계
- `src/integrations/telegram/telegramBot.ts` (270 lines)
- `src/lib/contextBuilders/localProjectIntent.ts` (233 lines)
- `src/lib/engine.ts` (1114 lines)
- `src/lmstudio/streamer.ts` (252 lines)
- `src/core/responseRecovery.ts` (310 lines) — Response Recovery — Thought Quarantine + Final-only Retry + Auto-Continuation The user already asked their question; they're waiting for an answer, not for a chance to babysit the generation engine. S
### `media/` — 6 files, ~7,484 lines
### `media/` — 6 files, ~7,649 lines
**Key files**
- `media/sidebar.css` (2068 lines) — Stylesheet
- `media/sidebar.js` (3807 lines)
- `media/sidebar.html` (538 lines) — Astra
- `media/settings-panel.html` (398 lines) — Astra Settings
- `media/sidebar.css` (2104 lines) — Stylesheet
- `media/sidebar.js` (3921 lines)
- `media/sidebar.html` (539 lines) — Astra
- `media/settings-panel.html` (406 lines) — Astra Settings
- `media/settings-panel.css` (210 lines) — Stylesheet
- `media/settings-panel.js` (463 lines)
- `media/settings-panel.js` (469 lines)
### `tests/` — 35 files, ~5,641 lines
### `tests/` — 37 files, ~5,875 lines
*Depends on*: `src/`
**Sub-directories**
- `tests/helpers/` (1) — MockLLMClient — IAIService 의 Mock 구현체. 의도: 회사 모드 dispatcher / ChunkedWriter / ceoPlanner 등 LLM 을 호출하는 코드 경로를 CI 환경에서도 테스
- `tests/integration/` (1) — MockLLMClient 자체의 sanity test. 이게 통과하면 dispatcher / ceoPlanner / ChunkedWriter 등 IAIService 를 받는 코드가 실제 LLM 없이 단위 / inte
- `tests/mocks/` (1) — 1 files (.js)
**Key files**
- `tests/agentEngine.test.ts` (405 lines) — AgentEngine Tests — Chunked Writer Architecture 예전 buildup(planner → researcher → reflector → writer → synthesizer)을 단일 ChunkedWriter 의 outline → section[N] → polish 로 교체한 뒤의 회귀 테스트. 다루는 범위: 1. ErrorC
- `tests/helpers/mockLLMClient.ts` (112 lines) — MockLLMClient — IAIService 의 Mock 구현체. 의도: 회사 모드 dispatcher / ChunkedWriter / ceoPlanner 등 LLM 을 호출하는 코드 경로를 CI 환경에서도 테스트 가능하게. 실제 Ollama / LM Studio 없이도 응답을 미리 정의하거나 동적으로 생성 가능. 사용 예: const ai = new
- `tests/agentEngine.test.ts` (413 lines) — AgentEngine Tests — Chunked Writer Architecture 예전 buildup(planner → researcher → reflector → writer → synthesizer)을 단일 ChunkedWriter 의 outline → section[N] → polish 로 교체한 뒤의 회귀 테스트. 다루는 범위: 1. ErrorC
- `tests/lmStudioLifecycle.test.ts` (326 lines) — Unit tests for ModelLifecycleManager. Strategy: inject mock ILMStudioClient and a simple in-memory IActivityTracker. No real LM Studio or SDK is touched — the manager file does not import the SDK dire
- `tests/localPathPreflight.test.ts` (520 lines)
- `tests/telegramBot.test.ts` (363 lines) — Unit tests for TelegramBot + truncateForTelegram. Strategy: - TelegramBot is driven by an injected ITelegramClient stub. We script getUpdates to return queued batches and assert that: - the offset cur
- `tests/lmStudioStreamer.test.ts` (222 lines) — Unit tests for LMStudioStreamer. Strategy: inject a fake ILMStudioClient that returns a fake model handle whose respond() yields a controllable async iterable. No real SDK or WebSocket touched.
- `tests/localPathPreflight.test.ts` (492 lines)
- `tests/secondBrainTrace.test.ts` (407 lines)
- `tests/approvalQueue.test.ts` (164 lines) — Unit tests for ApprovalQueue. Strategy: drive enqueue → approve / reject / clear / pre-empt directly, confirm the onChange event fires at the right moments and callbacks fire exactly once.
- `tests/projectScaffolder.test.ts` (135 lines) — Unit tests for FileSystemProjectScaffolder. Drives against a real temp directory so end-to-end file IO + path-traversal defenses are exercised.
@@ -136,6 +141,7 @@ flowchart LR
- `tests/skillInjectionService.test.ts` (172 lines) — Unit tests for FileSystemSkillInjectionService. Strategy: drive the service against a real temp directory so path-traversal defenses and writeFileSync paths are exercised end-to-end. The service accep
- `tests/dataProcessor.test.ts` (87 lines) — / <reference types="jest" />
- `tests/findBrainFilesCache.test.ts` (80 lines) — Unit tests for findBrainFiles TTL cache.
- `tests/integration/mockLLMClient.test.ts` (86 lines) — MockLLMClient 자체의 sanity test. 이게 통과하면 dispatcher / ceoPlanner / ChunkedWriter 등 IAIService 를 받는 코드가 실제 LLM 없이 단위 / integration 테스트 가능. 향후 dispatcher 의 multi-stage flow 같은 큰 integration 테스트는 이 mock 을
- `tests/officeSchema.test.ts` (241 lines)
- `tests/paths.test.ts` (84 lines) — Unit tests for the centralized path resolver.
- `tests/systemSpecs.test.ts` (90 lines) — Unit tests for SystemSpecs + HeuristicModelMemoryEstimator. Strategy: - HeuristicModelMemoryEstimator is pure — directly drive it with model ids. - NodeSystemSpecsProvider depends on os. so we test: a
@@ -147,8 +153,6 @@ flowchart LR
- `tests/icsParser.test.ts` (134 lines)
- `tests/lessonHelpers.test.ts` (191 lines)
- `tests/projectChronicle.test.ts` (199 lines)
- `tests/responseRecovery.test.ts` (151 lines)
- `tests/scoring.test.ts` (134 lines)
### `core_py/` — 6 files, ~409 lines
@@ -226,7 +230,7 @@ flowchart LR
- `g1nation.calendar.refresh` — Astra: Google Calendar 새로고침 📅
- `g1nation.calendar.connectOAuth` — Astra: Google Calendar OAuth 연결 (쓰기) 🔐
- `g1nation.devilAgent.toggle` — Astra: Toggle Devil Agent 🎭
- **Configuration** (86 settings):
- **Configuration** (93 settings):
- `g1nation.multiAgentEnabled` *(boolean)* _(default: `false`)_ — Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks.
- `g1nation.datacollectBridgeUrl` *(string)* _(default: `"http://127.0.0.1:3002"`)_ — Wiki/Datacollect MCP Bridge URL. /research, /benchmark, /youtube chat slash commands route here. The Bridge must be running (`npm run bridge` in the Datacollect project).
- `g1nation.datacollectSavePath` *(string)* _(default: `""`)_
@@ -287,7 +291,7 @@ flowchart LR
- `g1nation.workflow.autoCtxFractionThreshold` *(number)* _(default: `0.3`)_
- `g1nation.chunkedSwitchTokens` *(number)* _(default: `50000`)_
- `g1nation.chunkedMaxSections` *(number)* _(default: `3`)_
- _…and 26 more_
- _…and 33 more_
## Dependencies
- **Runtime** (2): `@lmstudio/sdk`, `pdf-parse`
@@ -335,7 +339,7 @@ Astra는 대표님의 명시적인 승인 하에 로컬 시스템의 강력한
**Designed for High-Performance Decision Making.**
Copyright (C) **g1nation**. All rights reserved.
_Last auto-scan: 2026-05-24T04:49:04.938Z · signature `932fe655`_
_Last auto-scan: 2026-05-25T00:59:03.313Z · signature `fca24b52`_
<!-- ASTRA:AUTO-END -->
## Purpose
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
{
"result": "직답 결과 — single-pass mock 응답입니다.",
"createdAt": 1779598841531,
"createdAt": 1779670266607,
"modelVersion": "unknown"
}
@@ -1,5 +1,5 @@
{
"result": "---\nid: wiki_on\ndate: 2026-05-24T05:00:41.532Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\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- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (12ms)\n",
"createdAt": 1779598841532,
"result": "---\nid: wiki_on\ndate: 2026-05-25T00:51:06.608Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\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- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (13ms)\n",
"createdAt": 1779670266608,
"modelVersion": "unknown"
}
@@ -1,8 +1,8 @@
{
"missionId": "wiki_on",
"status": "completed",
"startTime": "2026-05-24T05:00:41.519Z",
"totalElapsedMs": 14,
"startTime": "2026-05-25T00:51:06.593Z",
"totalElapsedMs": 15,
"results": {
"direct": "직답 결과 — single-pass mock 응답입니다."
},
@@ -12,16 +12,16 @@
{
"from": "idle",
"to": "direct",
"durationMs": 12,
"durationMs": 13,
"message": "답변 작성 중... (단일 호출 fast-path)",
"ts": "2026-05-24T05:00:41.531Z"
"ts": "2026-05-25T00:51:06.606Z"
},
{
"from": "direct",
"to": "completed",
"durationMs": 2,
"message": "미션 완료",
"ts": "2026-05-24T05:00:41.533Z"
"ts": "2026-05-25T00:51:06.608Z"
}
],
"resilienceMetrics": {
@@ -1,5 +1,5 @@
{
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1779598848393,
"createdAt": 1779670273525,
"modelVersion": "unknown"
}
@@ -1,5 +1,5 @@
{
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1779598848393,
"createdAt": 1779670273525,
"modelVersion": "unknown"
}
@@ -1,5 +1,5 @@
{
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
"createdAt": 1779598848392,
"createdAt": 1779670273513,
"modelVersion": "unknown"
}
@@ -1,5 +1,5 @@
{
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
"createdAt": 1779598848393,
"createdAt": 1779670273519,
"modelVersion": "unknown"
}
@@ -1,8 +1,8 @@
{
"missionId": "stress_conflict_1779598848381",
"missionId": "stress_conflict_1779670273495",
"status": "completed",
"startTime": "2026-05-24T05:00:48.381Z",
"totalElapsedMs": 13,
"startTime": "2026-05-25T00:51:13.495Z",
"totalElapsedMs": 30,
"results": {
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
"section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
@@ -14,30 +14,30 @@
{
"from": "idle",
"to": "outline",
"durationMs": 11,
"durationMs": 12,
"message": "답변 구조 잡는 중...",
"ts": "2026-05-24T05:00:48.392Z"
"ts": "2026-05-25T00:51:13.507Z"
},
{
"from": "outline",
"to": "section",
"durationMs": 1,
"durationMs": 6,
"message": "본문 작성 중...",
"ts": "2026-05-24T05:00:48.393Z"
"ts": "2026-05-25T00:51:13.513Z"
},
{
"from": "section",
"to": "polish",
"durationMs": 0,
"durationMs": 6,
"message": "최종 다듬기 중...",
"ts": "2026-05-24T05:00:48.393Z"
"ts": "2026-05-25T00:51:13.519Z"
},
{
"from": "polish",
"to": "completed",
"durationMs": 1,
"durationMs": 6,
"message": "미션 완료",
"ts": "2026-05-24T05:00:48.394Z"
"ts": "2026-05-25T00:51:13.525Z"
}
],
"resilienceMetrics": {
+27
View File
@@ -12,3 +12,30 @@ preview.html
_*.json
_yt_*
_*
# ── 무게 다이어트 (v2.2.92+) ─────────────────────────────
# Jest 테스트 코드 — 사용자 환경에 불필요
tests/**
# 스프라이트 원본 (편집용, 런타임엔 derived 만 사용)
assets/**/*-source.png
assets/**/sprites/**
# pixelOffice layout 프리뷰 (개발 시 비교용)
assets/pixelOffice/layout-preview*.png
# 개발 / 빌드 아티팩트
build_error.log
diff.txt
diff_utf8.txt
task_plan.md
ARCHITECTURE_ANALYSIS.md
PATCHNOTES.md
brain-viz.html
system_schema.json
jest.config.js
# 2nd brain 캐시·py 도구·내부 문서 — 사용자에게 불필요
.secondbrain/**
core_py/**
docs/**
+1 -1
View File
@@ -7,5 +7,5 @@
"corePurpose": "",
"detailLevel": "standard",
"createdAt": "2026-05-23T03:51:11.620Z",
"updatedAt": "2026-05-24T04:50:03.783Z"
"updatedAt": "2026-05-25T00:53:45.126Z"
}
+8
View File
@@ -390,6 +390,14 @@
</div>
<small class="hint">Chunked 가 답변을 쪼갤 수 있는 최대 섹션 수. 실제 LLM 호출 = `2 + N` 회 (outline 1 + section N + polish 1). 기본 3 (총 5회). 빨리 받고 싶으면 2 (총 4회), 답변을 더 세분화하려면 5 (총 7회).</small>
</div>
<div class="row">
<label for="advPolishPersona">Polish persona 커스텀 (polishPersonaOverride)</label>
<div class="input-group" style="flex-direction:column; align-items:stretch;">
<textarea id="advPolishPersona" rows="6" placeholder="비워두면 기본 polish persona 사용. 내용을 입력하면 그 텍스트가 그대로 polish 단계의 system prompt 로 들어갑니다.&#10;&#10;예: '당신은 한국 법률 문서 톤의 편집자입니다. 격식체로 작성하고...'"></textarea>
<button data-save="advanced.polishPersonaOverride" style="margin-top: 6px; align-self: flex-start;">저장</button>
</div>
<small class="hint">답변의 최종 다듬기 단계(polish) 톤·구조를 직접 정의합니다. 예: 격식체/반말/법률·마케팅 도메인 톤. 빈 값이면 기본 persona (한 줄 요약 + subheading + 5-check) 사용.</small>
</div>
</section>
</main>
+6
View File
@@ -50,6 +50,7 @@
const advChatTemp = $('advChatTemp');
const advChunkedSwitch = $('advChunkedSwitch');
const advChunkedMax = $('advChunkedMax');
const advPolishPersona = $('advPolishPersona');
// ---- Google (Calendar + Sheets) ----
const gClientId = $('gClientId');
@@ -251,6 +252,10 @@
vscode.postMessage({ type: 'advanced.update', chunkedMaxSections: Number(advChunkedMax.value) })
);
document.querySelector('[data-save="advanced.polishPersonaOverride"]').addEventListener('click', () =>
vscode.postMessage({ type: 'advanced.update', polishPersonaOverride: String(advPolishPersona.value || '') })
);
// ---- Header ----
$('openVscodeSettings').addEventListener('click', () =>
vscode.postMessage({ type: 'openVscodeSettings' })
@@ -409,6 +414,7 @@
setIfNotFocused(advChatTemp, adv.chatTemperature);
setIfNotFocused(advChunkedSwitch, adv.chunkedSwitchTokens);
setIfNotFocused(advChunkedMax, adv.chunkedMaxSections);
setIfNotFocused(advPolishPersona, adv.polishPersonaOverride);
// ---- Google (Calendar + Sheets) ----
const g = state.google;
+33
View File
@@ -2136,3 +2136,36 @@
}
.records-line .rl-latest { color: var(--border-bright); overflow: hidden; text-overflow: ellipsis; }
.records-line .hdr-dropdown { flex-shrink: 0; }
/* Slash 명령 자동완성 dropdown — input 바로 위에 floating. */
.slash-suggest {
position: absolute;
bottom: 100%;
left: 12px;
right: 12px;
margin-bottom: 6px;
background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--border, #444);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
max-height: 240px;
overflow-y: auto;
z-index: 100;
font-size: 13px;
}
.slash-suggest .ss-item {
padding: 6px 12px;
cursor: pointer;
display: flex;
gap: 10px;
align-items: baseline;
border-bottom: 1px solid var(--border-faint, #333);
}
.slash-suggest .ss-item:last-child { border-bottom: none; }
.slash-suggest .ss-item.active { background: var(--accent-faint, #094771); }
.slash-suggest .ss-item:hover { background: var(--bg-hover, #353535); }
.slash-suggest .ss-name { font-weight: 600; color: var(--accent, #4ec9b0); flex-shrink: 0; }
.slash-suggest .ss-desc { color: var(--text-dim, #999); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.slash-suggest .ss-empty { padding: 8px 12px; color: var(--text-dim, #999); font-style: italic; }
/* input-box 가 position:relative 여야 absolute 가 제대로 anchored. */
.input-box { position: relative; }
+1
View File
@@ -510,6 +510,7 @@
</div>
<div class="input-box">
<div id="attachPreview" class="attachment-preview"></div>
<div id="slashSuggest" class="slash-suggest" style="display:none;"></div>
<textarea id="input" rows="1" placeholder="Astra에게 무엇이든 물어보세요..."></textarea>
<div class="input-footer">
<div class="footer-left">
+130 -1
View File
@@ -788,6 +788,10 @@
window.addEventListener('message', e => {
const msg = e.data;
switch(msg.type) {
case 'slashCommandList':
// Extension 측에서 listSlashCommands() 결과 한 번 전달. webview ready 직후.
setSlashCommands(msg.commands || []);
break;
case 'addMessage':
addMsg(msg.value, msg.role, msg.rationale);
// Update state for non-streamed messages
@@ -1722,6 +1726,125 @@
// Draft State: 내용이 있으면 cancelBtn 표시
setDraftActive(input.value.trim().length > 0 || pendingFiles.length > 0);
bumpActivity();
// Slash 자동완성 dropdown — 첫 단어가 `/` 면 후보 표시.
updateSlashSuggest();
});
// ── Slash command autocomplete ──────────────────────────────────────
// 등록된 slash 명령 (extension 측 listSlashCommands() 결과). webview ready
// 시 한 번 전달받아 캐싱.
let _slashCommands = []; // [{ name, description }]
let _slashActiveIdx = -1;
const slashSuggestEl = document.getElementById('slashSuggest');
function setSlashCommands(cmds) {
_slashCommands = Array.isArray(cmds) ? cmds.slice().sort((a, b) => a.name.localeCompare(b.name)) : [];
}
function hideSlashSuggest() {
if (slashSuggestEl) {
slashSuggestEl.style.display = 'none';
slashSuggestEl.innerHTML = '';
}
_slashActiveIdx = -1;
}
function getCurrentSlashHead() {
// input 의 첫 단어가 `/` 로 시작할 때만 자동완성 활성. arg 입력 중에는 닫음.
const v = input.value;
const trimmed = v.trimStart();
if (!trimmed.startsWith('/')) return null;
const spaceIdx = trimmed.indexOf(' ');
// 공백이 있으면 사용자가 이미 명령 입력 끝내고 arg 치는 중 — dropdown 닫음.
if (spaceIdx !== -1) return null;
// 커서가 첫 단어 안에 있는지 확인 (입력 중간 편집 시 false-positive 방지).
const caret = input.selectionStart ?? v.length;
const leading = v.length - v.trimStart().length;
if (caret > leading + trimmed.length) return null;
return trimmed.toLowerCase();
}
function renderSlashSuggest(matched) {
if (!slashSuggestEl) return;
if (matched.length === 0) {
slashSuggestEl.innerHTML = '<div class="ss-empty">일치하는 명령 없음</div>';
} else {
slashSuggestEl.innerHTML = matched.map((c, i) => {
const cls = i === _slashActiveIdx ? 'ss-item active' : 'ss-item';
const desc = (c.description || '').replace(/[<>&]/g, ch => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[ch]));
return `<div class="${cls}" data-idx="${i}" data-name="${c.name}">` +
`<span class="ss-name">${c.name}</span>` +
`<span class="ss-desc">${desc}</span>` +
`</div>`;
}).join('');
// 클릭으로 선택.
slashSuggestEl.querySelectorAll('.ss-item').forEach(el => {
el.addEventListener('mousedown', e => {
e.preventDefault(); // textarea blur 막음
applySlashSuggest(el.dataset.name);
});
});
}
slashSuggestEl.style.display = 'block';
}
function updateSlashSuggest() {
const head = getCurrentSlashHead();
if (head === null) { hideSlashSuggest(); return; }
const matched = _slashCommands.filter(c => c.name.toLowerCase().startsWith(head));
if (_slashActiveIdx >= matched.length) _slashActiveIdx = matched.length - 1;
if (_slashActiveIdx < 0 && matched.length > 0) _slashActiveIdx = 0;
renderSlashSuggest(matched);
}
function applySlashSuggest(cmdName) {
// input 의 첫 단어를 cmdName 으로 치환 + 공백 추가 (arg 입력 시작 친화적).
const v = input.value;
const leading = v.length - v.trimStart().length;
const head = v.trimStart().split(/\s+/, 1)[0];
input.value = ' '.repeat(leading) + cmdName + ' ' + v.slice(leading + head.length).trimStart();
// textarea 높이 갱신 + 커서 명령 끝으로 이동.
input.style.height = 'auto';
input.style.height = input.scrollHeight + 'px';
const newCaret = leading + cmdName.length + 1;
input.setSelectionRange(newCaret, newCaret);
hideSlashSuggest();
input.focus();
setDraftActive(true);
}
// 키보드 네비게이션 — dropdown 표시 중일 때만 적용.
input.addEventListener('keydown', (e) => {
if (!slashSuggestEl || slashSuggestEl.style.display === 'none') return;
const items = slashSuggestEl.querySelectorAll('.ss-item');
if (items.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
_slashActiveIdx = (_slashActiveIdx + 1) % items.length;
updateSlashSuggest();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
_slashActiveIdx = (_slashActiveIdx - 1 + items.length) % items.length;
updateSlashSuggest();
} else if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (_slashActiveIdx >= 0 && _slashActiveIdx < items.length) {
applySlashSuggest(items[_slashActiveIdx].dataset.name);
}
} else if (e.key === 'Tab') {
e.preventDefault();
if (_slashActiveIdx >= 0 && _slashActiveIdx < items.length) {
applySlashSuggest(items[_slashActiveIdx].dataset.name);
}
} else if (e.key === 'Escape') {
e.preventDefault();
hideSlashSuggest();
}
});
// input blur 시 살짝 delay 두고 닫음 (dropdown mousedown 먼저 처리되게).
input.addEventListener('blur', () => {
setTimeout(() => hideSlashSuggest(), 150);
});
cancelBtn.onclick = () => clearDraft();
@@ -3137,7 +3260,13 @@
discardBtn.textContent = '버리기';
discardBtn.title = '이 작업을 더 이상 이어가지 않습니다. 목록에서만 빠지고 기존 산출물 파일은 그대로 남습니다.';
discardBtn.onclick = () => {
if (!confirm('이 미완 작업을 목록에서 버릴까요? 이미 만들어진 산출물 파일은 사라지지 않습니다.')) return;
// VS Code webview iframe 에서 `confirm()` 은 보안상 차단되어 false
// 즉시 반환됨 (사용자 화면엔 다이얼로그 안 뜸) → postMessage 가
// 영영 호출 안 되는 사고가 있었음. 산출물 파일은 안 지우므로
// 별도 확인 없이 바로 처리 — 사용자가 실수로 눌러도 ad-hoc 복구 가능.
// 작업 자체의 두 번 클릭 방지로 버튼은 즉시 disabled 처리.
discardBtn.disabled = true;
discardBtn.textContent = '버리는 중...';
vscode.postMessage({ type: 'discardResumableSession', timestamp: it.timestamp });
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "astra",
"version": "2.2.63",
"version": "2.2.154",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "astra",
"version": "2.2.63",
"version": "2.2.154",
"license": "MIT",
"dependencies": {
"@lmstudio/sdk": "^1.5.0",
+47 -4
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.2.91",
"version": "2.2.158",
"publisher": "g1nation",
"license": "MIT",
"icon": "assets/icon.png",
@@ -549,13 +549,17 @@
},
"g1nation.workflow.multiAgentMode": {
"type": "string",
"enum": ["auto", "always", "off"],
"enum": [
"auto",
"always",
"off"
],
"default": "auto",
"markdownDescription": "Multi-Agent(5단계) 파이프라인 발동 모드.\n\n- `auto` (기본): 작은 모델(≤4B) 감지, 큰 prompt(컨텍스트의 30%+), 명시적 키워드(보고서/리뷰/심층 분석…), 또는 사용자가 `multiAgentEnabled`를 켰을 때 자동으로 발동. 짧은 인사·잡담은 제외.\n- `always`: 인사·잡담을 제외한 모든 요청에 5단계 파이프라인 사용. 작은 모델로도 답변이 한 번에 끝나지 않는다면 이 모드가 안정적.\n- `off`: 기존 키워드/길이 휴리스틱 + 수동 `multiAgentEnabled` 토글만 사용 (legacy 동작)."
},
"g1nation.workflow.autoCtxFractionThreshold": {
"type": "number",
"default": 0.30,
"default": 0.3,
"minimum": 0.05,
"maximum": 0.95,
"markdownDescription": "`workflow.multiAgentMode = auto` 일 때, prompt 토큰이 효과적 context window 의 이 비율(0~1)을 넘으면 5단계 파이프라인을 강제 발동. 기본 0.30 — 작은 모델이 input으로 컨텍스트의 30% 이상을 먹기 시작하면 한 번에 답하려다 EOS/잘림이 잘 발생한다."
@@ -573,6 +577,12 @@
"maximum": 10,
"markdownDescription": "**Chunked 파이프라인이 답변을 쪼갤 수 있는 최대 섹션 수.**\n\n실제 LLM 호출 횟수 = `1 (outline) + N (sections) + 1 (polish)` = **2 + N 회**.\n- `1` → 총 3회 (가장 빠름, 답변이 단순할 때만 적합)\n- `3` (기본) → 총 5회\n- `5` → 총 7회 (세분화 필요할 때만)\n\n작을수록 답변 속도 빠름, 클수록 답변이 더 세분화돼서 복잡한 보고서·기획서에 유리. 기본 3 — 일반 채팅에 적합."
},
"g1nation.polishPersonaOverride": {
"type": "string",
"default": "",
"editPresentation": "multilineText",
"markdownDescription": "**ChunkedWriter 의 polish 단계 system prompt 를 직접 정의** — 답변 톤·구조를 도메인에 맞게 커스텀.\n\n비워두면 기본 polish persona (한 줄 요약 + subheading + 5-check self-review) 사용. 텍스트를 입력하면 그 내용이 그대로 polish 단계의 system prompt 로 들어갑니다.\n\n사용 예: 격식체 답변, 반말 답변, 법률·마케팅·의료 등 도메인 특화 톤. 사용자가 Astra Settings 패널 textarea 로 편집 권장."
},
"g1nation.liveStreamTokens": {
"type": "boolean",
"default": true,
@@ -580,7 +590,10 @@
},
"g1nation.outputFormat": {
"type": "string",
"enum": ["plain", "markdown"],
"enum": [
"plain",
"markdown"
],
"default": "plain",
"markdownDescription": "최종 답변 표시 방식.\n\n- `plain` (기본): 모델이 무심코 내보낸 마크다운 마커(`##`, `**`, `__`, `> `, `* ` 등)를 후처리로 모두 제거. 섹션 라벨 텍스트(예: `핵심 요약`)는 유지되지만 헤더 마커는 사라져 깔끔한 plain text 로 보임. 작은 로컬 모델이 학습된 습관으로 `## 다음 한 수` 같은 마커를 흘리는 문제 차단.\n- `markdown`: legacy 동작. 모델 출력을 그대로 렌더러에 넘김."
},
@@ -717,6 +730,36 @@
"type": "boolean",
"default": false,
"markdownDescription": "**Devil's Advocate (도현) 활성화** — 매 답변 직후 별도 LLM 호출로 *비판적 sparring partner* 가 한 문단 반박. 사용자의 사고를 *수동적 수용 → 능동적 방어* 로 전환시키는 게 목표. Local LLM 동일 모델 재사용, ~10-15% 추가 비용."
},
"g1nation.stocks.watcherEnabled": {
"type": "boolean",
"default": true,
"markdownDescription": "**주식 자동 모니터링 활성화** — VS Code 시작 시 watcher 가동. KST 09:00 / 15:00 자동 트리거: Yahoo 현재가 갱신 → Google Sheets 동기화 → 텔레그램 보고서. false 면 슬래시 명령으로만 수동 실행."
},
"g1nation.stocks.spreadsheetId": {
"type": "string",
"default": "",
"markdownDescription": "**Stocks Google Sheets ID** — `https://docs.google.com/spreadsheets/d/<여기>/...` URL 의 ID 부분. 미설정이면 watcher / `/stocks sync` 가 silent skip. OAuth scope 은 calendar 와 공유 (sheets scope 이미 포함)."
},
"g1nation.stocks.sheetSwing": {
"type": "string",
"default": "Sheet1",
"markdownDescription": "**스윙/중기 종목 시트 탭 이름.** 기본 'Sheet1'. spreadsheet 안에 이 이름의 탭이 미리 존재해야 함."
},
"g1nation.stocks.sheetLong": {
"type": "string",
"default": "Sheet2",
"markdownDescription": "**장기투자 종목 시트 탭 이름.** 기본 'Sheet2'."
},
"g1nation.stocks.sheetUltraLow": {
"type": "string",
"default": "Sheet3",
"markdownDescription": "**저평가우량주 시트 탭 이름.** 기본 'Sheet3'."
},
"g1nation.stocks.telegramChatId": {
"type": "number",
"default": 0,
"markdownDescription": "**Stocks 보고서 전용 텔레그램 chatId** — fallback. `g1nation.telegram.allowedChatIds` 가 비어있을 때만 사용. 0 = 미설정."
}
}
}
+372 -3021
View File
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
import * as fs from 'fs';
import * as path from 'path';
import { HandlerContext } from './types';
import { findBrainFiles } from '../../utils';
import { EXCLUDED_DIRS } from '../../config';
export async function applyBrainOpsActions(ctx: HandlerContext): Promise<void> {
const { aiMessage, activeBrainDir, report } = ctx;
let match: RegExpExecArray | null;
// Action 7: Second Brain Knowledge (List/Read)
const listBrainRegex = /<list_brain\s*path=['"]?([^'"]*)['"]?\s*\/?>(?:<\/list_brain>)?/gi;
while ((match = listBrainRegex.exec(aiMessage)) !== null) {
const relPath = match[1].trim() || '.';
try {
const brainDir = activeBrainDir;
const absPath = path.join(brainDir, relPath);
if (fs.existsSync(absPath) && fs.statSync(absPath).isDirectory()) {
const entries = fs.readdirSync(absPath, { withFileTypes: true });
let listing = entries
.filter(e => !e.name.startsWith('.') && !EXCLUDED_DIRS.has(e.name))
.map(e => e.isDirectory() ? `${e.name}/` : e.name)
.join('\n');
if (listing.length > 5000) {
listing = listing.slice(0, 5000) + "\n... (truncated for context)";
}
report.push(`🧠 Brain Listed: ${relPath}`);
ctx.chatHistory.push({ role: 'system', content: `[Result of list_brain ${relPath}]\n${listing}`, internal: true });
} else {
report.push(`❌ Brain List failed: ${relPath} not found`);
}
} catch (err: any) { report.push(`❌ Error Listing Brain: ${err.message}`); }
}
const brainRegex = /<read_brain>([\s\S]*?)<\/read_brain>/gi;
while ((match = brainRegex.exec(aiMessage)) !== null) {
const fileName = match[1].trim();
try {
const brainDir = activeBrainDir;
const files = findBrainFiles(brainDir);
const targetFile = files.find((f: string) => path.basename(f) === fileName || f.endsWith(fileName));
if (targetFile && fs.existsSync(targetFile)) {
const content = fs.readFileSync(targetFile, 'utf-8');
report.push(`🧠 Brain Read: ${fileName}`);
ctx.chatHistory.push({ role: 'system', content: `[Result of read_brain ${fileName}]\n\`\`\`\n${content}\n\`\`\``, internal: true });
} else {
report.push(`❌ Brain Read failed: ${fileName} not found in Second Brain`);
}
} catch (err: any) { report.push(`❌ Error Reading Brain: ${err.message}`); }
}
}
+43
View File
@@ -0,0 +1,43 @@
import { HandlerContext } from './types';
import { _parseCalEventAttrs } from '../attrParsers';
export async function applyCalendarActions(ctx: HandlerContext): Promise<void> {
const { aiMessage, report } = ctx;
let match: RegExpExecArray | null;
// Action 8: Create Calendar Event (OAuth) — agent 가 회의록·작업 분석 후 일정 자동 생성.
// 형식: <create_calendar_event title="..." start="2026-05-21T14:00" duration="60" location="...">설명</create_calendar_event>
// 속성: title (필수), start (필수, ISO 'YYYY-MM-DDTHH:MM' 또는 timezone 포함),
// end | duration (분, default 60), location, all_day (true/false)
const calRegex = /<create_calendar_event\b([^>]*)>([\s\S]*?)<\/create_calendar_event>/gi;
while ((match = calRegex.exec(aiMessage)) !== null) {
const attrs = _parseCalEventAttrs(match[1]);
const desc = match[2].trim();
if (!attrs.title || !attrs.start) {
report.push(`❌ Calendar Event: title / start 누락`);
continue;
}
try {
const { createCalendarEvent } = await import('../../features/calendar');
const r = await createCalendarEvent(ctx.context, {
title: attrs.title,
start: attrs.start,
end: attrs.end,
durationMinutes: attrs.duration,
location: attrs.location,
description: desc || undefined,
allDay: attrs.allDay,
});
if (r.ok) {
report.push(`📅 Calendar Event Created: ${r.event.title} (${r.event.startIso})`);
// chatHistory 에 결과 주입 — agent 가 다음 답변에서 link 인용 가능.
ctx.chatHistory.push({
role: 'system',
content: `[Calendar event created] ${r.event.title} · ${r.event.startIso}\nLink: ${r.event.htmlLink}`,
internal: true,
});
} else {
report.push(`❌ Calendar Event Failed: ${r.error}`);
}
} catch (err: any) { report.push(`❌ Calendar Event Error: ${err?.message ?? String(err)}`); }
}
}
+73
View File
@@ -0,0 +1,73 @@
import * as fs from 'fs';
import * as path from 'path';
import { validatePath } from '../../security';
import { FileSystemError } from '../../core/errors';
import { HandlerContext } from './types';
/**
* `<create_file>` + `<edit_file>` action handler.
*
* AI create/edit ,
* regex . validatePath() sandbox +
* transactionManager.record() .
*/
export async function applyFileCreateEditActions(ctx: HandlerContext): Promise<void> {
const { aiMessage, rootPath, activeBrainDir, report } = ctx;
// Action 1: Create File
const createRegex = /<create_file\s+path=['"]?([^'"]+)['"]?>([\s\S]*?)<\/create_file>/gi;
let match;
while ((match = createRegex.exec(aiMessage)) !== null) {
const relPath = match[1].trim();
const content = match[2].trim();
try {
const absPath = validatePath(rootPath, relPath);
await ctx.transactionManager.record(absPath);
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, content, 'utf-8');
report.push(`✅ Created: ${relPath}`);
ctx.setFirstCreated(absPath);
if (absPath.startsWith(activeBrainDir)) ctx.markBrainModified();
} catch (err: any) {
throw new FileSystemError(`Failed to create file ${relPath}: ${err.message}`, relPath, err);
}
}
// Action 2: Edit File
const editRegex = /<edit_file\s+path=['"]?([^'"]+)['"]?>([\s\S]*?)<\/edit_file>/gi;
while ((match = editRegex.exec(aiMessage)) !== null) {
const relPath = match[1].trim();
const editContent = match[2].trim();
try {
const absPath = validatePath(rootPath, relPath);
if (fs.existsSync(absPath)) {
await ctx.transactionManager.record(absPath);
let currentContent = fs.readFileSync(absPath, 'utf-8');
const searchMatch = editContent.match(/<search>([\s\S]*?)<\/search>\s*<replace>([\s\S]*?)<\/replace>/i);
if (searchMatch) {
const searchStr = searchMatch[1];
const replaceStr = searchMatch[2];
if (currentContent.includes(searchStr)) {
currentContent = currentContent.replace(searchStr, replaceStr);
fs.writeFileSync(absPath, currentContent, 'utf-8');
report.push(`📝 Updated: ${relPath}`);
} else {
report.push(`⚠️ Search string not found in ${relPath}`);
}
} else {
fs.writeFileSync(absPath, editContent, 'utf-8');
report.push(`📝 Updated (Full): ${relPath}`);
}
if (absPath.startsWith(activeBrainDir)) ctx.markBrainModified();
} else {
report.push(`❌ File not found: ${relPath}`);
}
} catch (err: any) {
throw new FileSystemError(`Failed to edit file ${relPath}: ${err.message}`, relPath, err);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import * as fs from 'fs';
import { validatePath } from '../../security';
import { FileSystemError } from '../../core/errors';
import { HandlerContext } from './types';
export async function applyFileDeleteReadActions(ctx: HandlerContext): Promise<void> {
const { aiMessage, rootPath, report } = ctx;
let match;
// Action 3: Delete File
const deleteRegex = /<delete_file\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/delete_file>)?/gi;
while ((match = deleteRegex.exec(aiMessage)) !== null) {
const relPath = match[1].trim();
try {
const absPath = validatePath(rootPath, relPath);
if (fs.existsSync(absPath)) {
await ctx.transactionManager.record(absPath);
fs.unlinkSync(absPath);
report.push(`🗑 Deleted: ${relPath}`);
} else {
report.push(`⚠️ Delete failed: ${relPath} not found`);
}
} catch (err: any) {
throw new FileSystemError(`Failed to delete file ${relPath}: ${err.message}`, relPath, err);
}
}
// Action 4: Read File (Non-state-changing, no transaction record needed)
const readRegex = /<read_file\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/read_file>)?/gi;
while ((match = readRegex.exec(aiMessage)) !== null) {
const relPath = match[1].trim();
try {
const absPath = validatePath(rootPath, relPath);
if (fs.existsSync(absPath)) {
const content = fs.readFileSync(absPath, 'utf-8');
const preview = content.length > 8000 ? content.slice(0, 8000) + "\n... (truncated)" : content;
report.push(`📖 Read: ${relPath}`);
ctx.chatHistory.push({ role: 'system', content: `[Result of read_file ${relPath}]\n\`\`\`\n${preview}\n\`\`\``, internal: true });
} else {
report.push(`❌ Read failed: ${relPath} not found`);
}
} catch (err: any) { report.push(`❌ Error Reading ${relPath}: ${err.message}`); }
}
}
+31
View File
@@ -0,0 +1,31 @@
import * as fs from 'fs';
import { HandlerContext } from './types';
import { validatePath } from '../../security';
import { EXCLUDED_DIRS } from '../../config';
export async function applyListFilesActions(ctx: HandlerContext): Promise<void> {
const { aiMessage, rootPath, report } = ctx;
let match: RegExpExecArray | null;
// Action 6: List Files
const listRegex = /<list_files\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/list_files>)?/gi;
while ((match = listRegex.exec(aiMessage)) !== null) {
const relPath = match[1].trim() || '.';
try {
const absPath = validatePath(rootPath, relPath);
if (fs.existsSync(absPath) && fs.statSync(absPath).isDirectory()) {
const entries = fs.readdirSync(absPath, { withFileTypes: true });
let listing = entries
.filter(e => !e.name.startsWith('.') && !EXCLUDED_DIRS.has(e.name))
.map(e => e.isDirectory() ? `${e.name}/` : e.name)
.join('\n');
if (listing.length > 5000) {
listing = listing.slice(0, 5000) + "\n... (truncated for context)";
}
report.push(`📂 Listed: ${relPath}`);
ctx.chatHistory.push({ role: 'system', content: `[Result of list_files ${relPath}]\n${listing}`, internal: true });
}
} catch (err: any) { report.push(`❌ Listing failed: ${err.message}`); }
}
}
+20
View File
@@ -0,0 +1,20 @@
import * as vscode from 'vscode';
import { HandlerContext } from './types';
import { sanitizeCommand } from '../../security';
export async function applyRunCommandActions(ctx: HandlerContext): Promise<void> {
const { aiMessage, rootPath, report } = ctx;
let match: RegExpExecArray | null;
// Action 5: Run Command
const cmdRegex = /<run_command>([\s\S]*?)<\/run_command>/gi;
while ((match = cmdRegex.exec(aiMessage)) !== null) {
const cmd = match[1].trim();
try {
const safeCmd = sanitizeCommand(cmd);
const terminal = vscode.window.terminals.find(t => t.name === 'Astra Terminal') || vscode.window.createTerminal({ name: 'Astra Terminal', cwd: rootPath });
terminal.show();
terminal.sendText(safeCmd);
report.push(`🚀 Executed: ${safeCmd}`);
} catch (err: any) { report.push(`❌ Blocked: ${err.message}`); }
}
}
+87
View File
@@ -0,0 +1,87 @@
import { HandlerContext } from './types';
import { _parseSheetAttrs } from '../attrParsers';
export async function applySheetsActions(ctx: HandlerContext): Promise<void> {
const { aiMessage, report } = ctx;
let match: RegExpExecArray | null;
// Action 10/11/12: Google Sheets read / write / append.
// 모두 spreadsheet_id (속성) + range (속성) 필수. write/append 는 본문이 TSV.
// <read_sheet spreadsheet_id="1abc..." range="Sheet1!A1:D20"/>
// <write_sheet spreadsheet_id="1abc..." range="Sheet1!A1">
// 이름\t나이\t직책
// 민지\t29\t디자이너
// </write_sheet>
// <append_sheet spreadsheet_id="1abc..." range="Sheet1!A:C">
// 2026-05-21\t새 항목\t완료
// </append_sheet>
const sheetReadRegex = /<read_sheet\b([^>/]*?)\s*\/>/gi;
while ((match = sheetReadRegex.exec(aiMessage)) !== null) {
const a = _parseSheetAttrs(match[1]);
if (!a.spreadsheetId || !a.range) {
report.push(`❌ Sheet Read: spreadsheet_id / range 누락`);
continue;
}
try {
const { readSheetRange, valuesToMarkdownTable } = await import('../../features/sheets');
const r = await readSheetRange(ctx.context, a.spreadsheetId, a.range);
if (r.ok) {
const md = valuesToMarkdownTable(r.values);
report.push(`📊 Sheet Read: ${a.spreadsheetId.slice(0, 8)}…/${r.range} (${r.values.length} rows)`);
ctx.chatHistory.push({
role: 'system',
content: `[Sheet read ${r.range}]\n${md}`,
internal: true,
});
} else {
report.push(`❌ Sheet Read Failed: ${r.error}`);
}
} catch (err: any) { report.push(`❌ Sheet Read Error: ${err?.message ?? String(err)}`); }
}
const sheetWriteRegex = /<write_sheet\b([^>]*)>([\s\S]*?)<\/write_sheet>/gi;
while ((match = sheetWriteRegex.exec(aiMessage)) !== null) {
const a = _parseSheetAttrs(match[1]);
const body = match[2];
if (!a.spreadsheetId || !a.range) {
report.push(`❌ Sheet Write: spreadsheet_id / range 누락`);
continue;
}
try {
const { writeSheetRange, parseTsvBody } = await import('../../features/sheets');
const values = parseTsvBody(body);
if (values.length === 0) {
report.push(`❌ Sheet Write: 본문 비어있음`);
continue;
}
const r = await writeSheetRange(ctx.context, a.spreadsheetId, a.range, values);
if (r.ok) {
report.push(`📊 Sheet Write: ${r.updatedRange} (${r.updatedCells} cells)`);
} else {
report.push(`❌ Sheet Write Failed: ${r.error}`);
}
} catch (err: any) { report.push(`❌ Sheet Write Error: ${err?.message ?? String(err)}`); }
}
const sheetAppendRegex = /<append_sheet\b([^>]*)>([\s\S]*?)<\/append_sheet>/gi;
while ((match = sheetAppendRegex.exec(aiMessage)) !== null) {
const a = _parseSheetAttrs(match[1]);
const body = match[2];
if (!a.spreadsheetId || !a.range) {
report.push(`❌ Sheet Append: spreadsheet_id / range 누락`);
continue;
}
try {
const { appendSheetRows, parseTsvBody } = await import('../../features/sheets');
const values = parseTsvBody(body);
if (values.length === 0) {
report.push(`❌ Sheet Append: 본문 비어있음`);
continue;
}
const r = await appendSheetRows(ctx.context, a.spreadsheetId, a.range, values);
if (r.ok) {
report.push(`📊 Sheet Append: ${r.appendedRange} (${r.updatedCells} cells)`);
} else {
report.push(`❌ Sheet Append Failed: ${r.error}`);
}
} catch (err: any) { report.push(`❌ Sheet Append Error: ${err?.message ?? String(err)}`); }
}
}
+69
View File
@@ -0,0 +1,69 @@
import { HandlerContext } from './types';
import { _parseTaskAttrs } from '../attrParsers';
// Action 13/14/15: Task tracker — _shared/tasks.md 에 누적.
// 회의록·계획·작업 진척 추적의 단일 출처. status: open/in_progress/blocked/done.
// <add_task title="..." owner="@me" due="2026-05-24T18:00" notes="..."/>
// <update_task id="t_001" status="in_progress" notes="진행중"/>
// <complete_task id="t_001"/>
export async function applyTasksActions(ctx: HandlerContext): Promise<void> {
const { aiMessage, report } = ctx;
let match: RegExpExecArray | null;
const addTaskRegex = /<add_task\b([^>/]*?)\s*\/>/gi;
while ((match = addTaskRegex.exec(aiMessage)) !== null) {
const a = _parseTaskAttrs(match[1]);
if (!a.title) { report.push(`❌ Add Task: title 누락`); continue; }
try {
const { readTaskStore, writeTaskStore, addTask } = await import('../../features/tasks');
const store = readTaskStore(ctx.context);
const created = addTask(store, {
title: a.title,
owner: a.owner,
due: a.due,
notes: a.notes,
status: a.status,
});
writeTaskStore(ctx.context, store);
report.push(`📋 Task Added: ${created.id} · ${created.title}${created.due ? ' (due ' + created.due + ')' : ''}`);
} catch (err: any) { report.push(`❌ Add Task Error: ${err?.message ?? String(err)}`); }
}
const updTaskRegex = /<update_task\b([^>/]*?)\s*\/>/gi;
while ((match = updTaskRegex.exec(aiMessage)) !== null) {
const a = _parseTaskAttrs(match[1]);
if (!a.id) { report.push(`❌ Update Task: id 누락`); continue; }
try {
const { readTaskStore, writeTaskStore, updateTask } = await import('../../features/tasks');
const store = readTaskStore(ctx.context);
const patch: any = {};
if (a.title) patch.title = a.title;
if (a.owner) patch.owner = a.owner;
if (a.due) patch.due = a.due;
if (a.notes) patch.notes = a.notes;
if (a.status) patch.status = a.status;
const updated = updateTask(store, a.id, patch);
if (!updated) {
report.push(`❌ Update Task: ${a.id} 를 active 목록에서 못 찾음`);
} else {
writeTaskStore(ctx.context, store);
report.push(`📋 Task Updated: ${updated.id}${updated.status}${updated.due ? ' (due ' + updated.due + ')' : ''}`);
}
} catch (err: any) { report.push(`❌ Update Task Error: ${err?.message ?? String(err)}`); }
}
const compTaskRegex = /<complete_task\b([^>/]*?)\s*\/>/gi;
while ((match = compTaskRegex.exec(aiMessage)) !== null) {
const a = _parseTaskAttrs(match[1]);
if (!a.id) { report.push(`❌ Complete Task: id 누락`); continue; }
try {
const { readTaskStore, writeTaskStore, completeTask } = await import('../../features/tasks');
const store = readTaskStore(ctx.context);
const closed = completeTask(store, a.id);
if (!closed) {
report.push(`❌ Complete Task: ${a.id} 못 찾음 (이미 done 이거나 존재 X)`);
} else {
writeTaskStore(ctx.context, store);
report.push(`✅ Task Done: ${closed.id} · ${closed.title}`);
}
} catch (err: any) { report.push(`❌ Complete Task Error: ${err?.message ?? String(err)}`); }
}
}
+41
View File
@@ -0,0 +1,41 @@
import * as vscode from 'vscode';
import type { TransactionManager } from '../../core/transaction';
import type { ChatMessage } from '../../agent';
/**
* `executeActions` . action handler
* (`report`), brain
* , .
*
* handler signature: `apply<Group>Actions(ctx: HandlerContext): Promise<void>`.
* ctx *mutate* ( push, ).
*
* free function + ctx :
* - 15+ action handler transactionManager / report / chatHistory
* . args 7-8 .
* - executeActions try/catch transactionManager.rollback()
* handler throw (FileSystemError ).
*/
export interface HandlerContext {
/** AI 가 한 턴에 내뱉은 raw text — handler 들이 자기 regex 로 자기 tag 만 추출. */
aiMessage: string;
/** 워크스페이스 루트 — validatePath() 로 sandbox 보장. */
rootPath: string;
/** 활성 brain 의 절대 디렉토리. brain 안 파일이면 brainModified 플래그 set. */
activeBrainDir: string;
/** Handler 들이 사용자에게 보일 결과 라인을 push 하는 곳. ex: "✅ Created: foo.ts". */
report: string[];
/** handler (read_file, list_files, read_brain, sheet_read, calendar)
* internal system message push. */
chatHistory: ChatMessage[];
/** brain 안 파일이 수정됐다고 표시 — executeActions 가 끝나서 자동 sync 결정. */
markBrainModified: () => void;
/** 새로 생성된 파일 절대경로를 기록 — executeActions 가 끝나서 editor 에 열기. */
setFirstCreated: (absPath: string) => void;
/** dry-run / commit / rollback (executeActions) .
* handler record() (state-changing action ). */
transactionManager: TransactionManager;
/** vscode.ExtensionContext feature module (calendar/sheets/tasks) OAuth
* secrets / globalState . */
context: vscode.ExtensionContext;
}
+116
View File
@@ -0,0 +1,116 @@
import type { TaskStatus } from '../features/tasks';
/**
* Action-tag attribute 3 pure / stateless / import.
*
* : `key="value"` | `key='value'` | `key=bare` object .
* LLM emit prompt "
* " , .
*
* `_` "agent.ts 의 internal 이지만 테스트용으로만 export"
* agent.ts executeActions tests/{taskStore,sheetsApi,calendarApi}.test.ts.
* agent.ts re-export import (`from '../src/agent'`) .
*/
const ATTR_RE = /([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
/**
* <add_task> / <update_task> / <complete_task> attribute .
* optional caller . status (in_progress, ).
*/
export function _parseTaskAttrs(raw: string): {
id?: string;
title?: string;
owner?: string;
due?: string;
notes?: string;
status?: TaskStatus;
} {
const out: any = {};
const re = new RegExp(ATTR_RE.source, 'g');
let m: RegExpExecArray | null;
while ((m = re.exec(raw)) !== null) {
const key = m[1].toLowerCase();
const val = (m[2] ?? m[3] ?? m[4] ?? '').trim();
if (!val) continue;
switch (key) {
case 'id': out.id = val; break;
case 'title': out.title = val; break;
case 'owner': out.owner = val; break;
case 'due': out.due = val; break;
case 'notes': out.notes = val; break;
case 'status': {
const v = val.toLowerCase().replace(/\s+/g, '_');
if (v === 'in_progress' || v === 'inprogress' || v === 'progress') out.status = 'in_progress';
else if (v === 'blocked' || v === 'block') out.status = 'blocked';
else if (v === 'done' || v === 'completed' || v === 'closed') out.status = 'done';
else out.status = 'open';
break;
}
}
}
return out;
}
/**
* <read_sheet> / <write_sheet> / <append_sheet> attribute .
* spreadsheet_id / spreadsheetId / sheetId LLM emission .
*/
export function _parseSheetAttrs(raw: string): { spreadsheetId?: string; range?: string } {
const out: { spreadsheetId?: string; range?: string } = {};
const re = new RegExp(ATTR_RE.source, 'g');
let m: RegExpExecArray | null;
while ((m = re.exec(raw)) !== null) {
const key = m[1].toLowerCase();
const val = (m[2] ?? m[3] ?? m[4] ?? '').trim();
if (!val) continue;
if (key === 'spreadsheet_id' || key === 'spreadsheetid' || key === 'sheet_id' || key === 'sheetid') {
out.spreadsheetId = val;
} else if (key === 'range') {
out.range = val;
}
}
return out;
}
/**
* <create_calendar_event ...> attribute .
* / / (·`>` ) LLM
* emit . export.
*/
export function _parseCalEventAttrs(raw: string): {
title?: string;
start?: string;
end?: string;
duration?: number;
location?: string;
allDay?: boolean;
} {
const out: any = {};
// `-` 포함 키 (all-day) 지원 — 일부러 ATTR_RE 와 동일 패턴이지만 매번 fresh
// regex 인스턴스를 만들어 lastIndex 공유 버그를 회피.
const re = new RegExp(ATTR_RE.source, 'g');
let m: RegExpExecArray | null;
while ((m = re.exec(raw)) !== null) {
const key = m[1].toLowerCase();
const val = (m[2] ?? m[3] ?? m[4] ?? '').trim();
if (!val) continue;
switch (key) {
case 'title': out.title = val; break;
case 'start': out.start = val; break;
case 'end': out.end = val; break;
case 'duration': {
const n = parseInt(val, 10);
if (!Number.isNaN(n) && n > 0) out.duration = n;
break;
}
case 'location': out.location = val; break;
case 'all_day':
case 'allday':
case 'all-day':
out.allDay = val === 'true' || val === '1' || val === 'yes';
break;
}
}
return out;
}
@@ -0,0 +1,159 @@
import * as vscode from 'vscode';
import { logInfo, logError } from '../../utils';
import type { ChatMessage } from '../../agent';
import {
extractVisibleFinal,
shouldAutoContinue,
mergeContinuationParts,
buildContinuationUserPrompt,
CONTINUATION_SYSTEM_PROMPT,
} from '../../core/responseRecovery';
import { isRestartedAnswer } from '../../lib/contextBuilders/outputSanitization';
import {
estimateTokens,
estimateMessagesTokens,
computeOutputBudget,
classifyStopReason,
type ContextLimits,
} from '../../lib/contextManager';
import { recordTelemetry } from '../../core/telemetry';
/** Result shape of `extractVisibleFinal` — kept structural here to avoid a hard import dependency. */
type CleanedAnswer = ReturnType<typeof extractVisibleFinal>;
export interface ApplyAutoContinuationDeps {
/** Bound `this.streamChatOnce` — same params shape as the original method. */
streamChatOnce: (params: any) => Promise<{ text: string; stopReason?: string; aborted: boolean }>;
/** Bound `this.isStaleRun`. */
isStaleRun: (runId: number) => boolean;
/** Live AbortSignal getter — controller is reassigned across turns. */
getAbortSignal: () => AbortSignal | undefined;
/** Webview for posting `autoContinue` UI updates. */
getWebview: () => vscode.Webview | undefined;
}
export interface ApplyAutoContinuationInput {
/** From `extractVisibleFinal(aiResponseText)`. The loop mutates `cleaned.visible`. */
cleaned: CleanedAnswer;
finishStopReason: string | undefined;
prompt: string | null;
chatHistory: ChatMessage[]; // used to find the original user prompt fallback
maxOutputTokens: number; // the first generation's output budget (carried for first round)
ctxLimits: ContextLimits;
config: any; // getConfig() — we read autoContinueOnOutputLimit, maxAutoContinuations, contextOverflowPolicy
runId: number;
useLmStudioSdk: boolean;
engine: string;
ollamaUrl: string;
actualModel: string;
temperature: number;
postLiveDeltas: boolean;
}
export interface ApplyAutoContinuationResult {
cleaned: CleanedAnswer;
finishStopReason: string | undefined;
continuationCount: number;
}
export async function applyAutoContinuation(
deps: ApplyAutoContinuationDeps,
input: ApplyAutoContinuationInput,
): Promise<ApplyAutoContinuationResult> {
let {
cleaned,
finishStopReason,
prompt,
chatHistory,
maxOutputTokens,
ctxLimits,
config,
runId,
useLmStudioSdk,
engine,
ollamaUrl,
actualModel,
temperature,
postLiveDeltas,
} = input;
// (c) Auto-continuation — the visible answer hit the output-token ceiling.
let continuationCount = 0;
if (config.autoContinueOnOutputLimit && config.maxAutoContinuations > 0) {
const originalUserPrompt = prompt || (chatHistory.find(m => m.role === 'user' && typeof m.content === 'string')?.content as string) || '';
let lastOutputTokens = estimateTokens(cleaned.visible);
let lastMaxOutputTokens = maxOutputTokens; // budget the last round actually had (≠ first gen's after round 1)
while (
shouldAutoContinue(classifyStopReason(finishStopReason), cleaned.visible, lastOutputTokens, lastMaxOutputTokens)
&& continuationCount < config.maxAutoContinuations
&& !deps.getAbortSignal()?.aborted
&& !deps.isStaleRun(runId)
) {
continuationCount++;
const continuationStartMs = Date.now();
deps.getWebview()?.postMessage({ type: 'autoContinue', value: `답변이 길어 이어서 정리하는 중입니다... (${continuationCount}/${config.maxAutoContinuations})` });
try {
const contMsgs: ChatMessage[] = [
{ role: 'system', content: CONTINUATION_SYSTEM_PROMPT, internal: true },
{ role: 'user', content: buildContinuationUserPrompt(originalUserPrompt, cleaned.visible) },
];
lastMaxOutputTokens = computeOutputBudget(estimateMessagesTokens(contMsgs), ctxLimits).maxOutputTokens;
// Stream the continuation through the same channel as the main turn so
// the user sees the answer keep growing instead of freezing for 1030s
// while we silently call non-streaming. The trailing streamReplace
// (after sanitize / merge) corrects any overlap the model re-emits.
const cr = await deps.streamChatOnce({
runId, useLmStudioSdk, engine, ollamaUrl, modelName: actualModel,
messages: contMsgs,
temperature,
maxTokens: lastMaxOutputTokens,
contextLength: ctxLimits.contextLength,
contextOverflowPolicy: config.contextOverflowPolicy,
signal: deps.getAbortSignal()!,
postLiveDeltas,
});
if (cr.aborted) {
logInfo('Auto-continuation aborted mid-stream.', { model: actualModel, round: continuationCount });
break;
}
finishStopReason = cr.stopReason;
const ccl = extractVisibleFinal(cr.text);
if (!ccl.visible.trim()) {
logInfo('Continuation produced no visible text — stopping.', { model: actualModel, round: continuationCount });
break;
}
// A weak model often ignores "continue from here" and re-generates the
// whole answer from the top. Discard such a restart instead of merging
// it — otherwise the user gets the entire analysis twice.
if (isRestartedAnswer(cleaned.visible, ccl.visible)) {
logInfo('Continuation restarted the answer instead of continuing — discarding it.', { model: actualModel, round: continuationCount });
break;
}
const before = cleaned.visible;
cleaned = { ...cleaned, visible: mergeContinuationParts(cleaned.visible, ccl.visible), wasThoughtOnly: false };
lastOutputTokens = estimateTokens(ccl.visible);
logInfo('Auto-continued the answer.', { model: actualModel, round: continuationCount, addedChars: ccl.visible.length, totalChars: cleaned.visible.length, contStopReason: cr.stopReason, contMaxTokens: lastMaxOutputTokens });
recordTelemetry({
kind: 'continuation',
durationMs: Date.now() - continuationStartMs,
model: actualModel, engine,
outputTokens: lastOutputTokens,
round: continuationCount,
stopReason: cr.stopReason,
note: `addedChars=${ccl.visible.length} mergedAdd=${cleaned.visible.length - before.length}`,
});
// Guard against a continuation that adds (almost) nothing new after dedup — stop instead of spinning.
if (cleaned.visible.length - before.length < 20) {
logInfo('Continuation added negligible new text — stopping.', { model: actualModel, round: continuationCount });
break;
}
} catch (e: any) {
logError('Auto-continuation failed.', { model: actualModel, round: continuationCount, error: e?.message ?? String(e) });
break;
}
}
if (deps.isStaleRun(runId)) return { cleaned, finishStopReason, continuationCount };
}
return { cleaned, finishStopReason, continuationCount };
}
@@ -0,0 +1,75 @@
import { stripAstraFormattingForAgentMode } from '../../lib/contextBuilders/systemPromptShaping';
import { estimateTokens } from '../../lib/contextManager';
import { logInfo } from '../../utils';
export interface BuildAgentModeSystemPromptInput {
/** Base system prompt — `Astra: …` block etc. */
systemPrompt: string;
/** Agent skill content the user selected. */
agentSkillContext: string;
/** Pre-built mode-bridge context (or ''). */
modeBridgeCtx: string;
/** [PRIOR TURN CONCLUSION] 블록 — 직전 assistant 답변의 첫 문장 (또는 ''). */
priorConclusionCtx: string;
designerCtx: string;
secondBrainTraceCtx: string;
memoryCtx: string;
knowledgeContextForPrompt: string;
contextBlock: string;
negativeCtx: string;
/** For token-cost logging. */
actualModel: string;
/** For token-cost logging — getConfig().contextLength. */
contextLength: number;
}
export function buildAgentModeSystemPrompt(input: BuildAgentModeSystemPromptInput): string {
const {
systemPrompt,
agentSkillContext,
modeBridgeCtx,
priorConclusionCtx,
designerCtx,
secondBrainTraceCtx,
memoryCtx,
knowledgeContextForPrompt,
contextBlock,
negativeCtx,
actualModel,
contextLength,
} = input;
// The Agent's prompt IS the primary directive (role / persona / tone / output format),
// so it LEADS the system prompt — models anchor on the first persona they see, not the
// last, especially small ones. The Astra base prompt is reduced to neutral scaffolding
// (action tags, current date, anti-leak rules) and follows; a short reminder at the very
// end keeps the model from drifting back to a generic assistant.
const strippedSystemPrompt = stripAstraFormattingForAgentMode(systemPrompt);
const agentPromptText = (agentSkillContext || '').trim();
if (estimateTokens(agentPromptText) > Math.floor(contextLength * 0.5)) {
logInfo('Agent prompt is unusually large relative to the context window.', {
model: actualModel, agentPromptTokens: estimateTokens(agentPromptText), contextLength: contextLength,
});
}
const agentBlock = [
'[AGENT MODE — PRIMARY DIRECTIVE]',
'A specialized Agent has been selected by the user. The Agent System Prompt below is your',
'PRIMARY directive: it defines your role, persona, tone, and output format. Follow it exactly.',
'Everything after the Agent block (action-tag reference, date, brain/project context) is technical',
'scaffolding — use it only as the Agent\'s task requires. Do NOT impose a generic assistant',
'format (e.g. ## 요약 / ## 상세 설명 / ## 제안) unless the Agent explicitly asks for one.',
'',
'--- AGENT SYSTEM PROMPT START ---',
agentPromptText || '(this agent has no instructions yet — fall back to being a concise, direct assistant)',
'--- AGENT SYSTEM PROMPT END ---',
].join('\n');
const agentTailReminder = '\n\n[REMINDER] You are operating as the Agent defined above. Keep its role, persona, and output format. Do not fall back to a default assistant style or section format.';
// [CONTEXT] … [/CONTEXT] 사이만 컨텍스트 초과 시 trim 대상 — agentBlock(앞)·reminder(뒤)·negative 는 보호.
// memoryCtx(RAG/메모리/lessons)도 [CONTEXT] 안에 넣어 토큰이 빡빡할 때 대화 기록보다 먼저 잘리게 한다.
const priorConclusionBlock = priorConclusionCtx ? '\n\n' + priorConclusionCtx : '';
const fullSystemPrompt = `${agentBlock}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}\n\n${strippedSystemPrompt}${designerCtx}${secondBrainTraceCtx}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}${agentTailReminder}`;
return fullSystemPrompt;
}
@@ -0,0 +1,82 @@
import {
isProjectKnowledgeCreationRequest,
buildAstraStanceContext,
} from '../../lib/contextBuilders/localProjectIntent';
import { isThinkingPartnerRequest } from '../../lib/contextBuilders/promptDetection';
import { buildKnowledgeMixPolicy } from '../../retrieval/knowledgeMix';
export interface BuildAstraModeSystemPromptInput {
prompt: string | null;
systemPrompt: string;
modeBridgeCtx: string;
/** [PRIOR TURN CONCLUSION] 블록 — 직전 assistant 답변의 첫 문장. follow-up 정정 대응용. */
priorConclusionCtx: string;
designerCtx: string;
projectArchitectureCtx: string;
secondBrainTraceCtx: string;
memoryCtx: string;
knowledgeContextForPrompt: string;
contextBlock: string;
negativeCtx: string;
isCasualConversation: boolean;
localPathContext: string;
/** From this._turnCtx.knowledgeMix — pass null when absent. */
knowledgeMix: any;
}
export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInput): string {
const {
prompt,
systemPrompt,
modeBridgeCtx,
priorConclusionCtx,
designerCtx,
projectArchitectureCtx,
secondBrainTraceCtx,
memoryCtx,
knowledgeContextForPrompt,
contextBlock,
negativeCtx,
isCasualConversation,
localPathContext,
knowledgeMix,
} = input;
// 기존 Astra 모드 (에이전트 미선택)
const localProjectKnowledgeCtx = prompt && localPathContext && isProjectKnowledgeCreationRequest(prompt)
? `\n\n[LOCAL PROJECT KNOWLEDGE CREATION OVERRIDE]\nThe user gave an accessible local project path and asked to create project knowledge. Do not ask blocking scope questions. Use a sensible default MVP: create or propose a project overview note from the inspected tree and priority file previews. If writing is not explicitly safe, provide the concrete note draft and target path.`
: '';
const thinkingPartnerCtx = prompt && !isCasualConversation && isThinkingPartnerRequest(prompt)
? `\n\n[JARVIS THINKING PARTNER MODE]\nThe user is using this tool to clarify project direction, not just to receive generic advice. Give a clear opinionated verdict first. Then separate confirmed facts, inferences, concerns, decision forks, and the next small action. Do not merely say the direction is good. If evidence is thin, say exactly what is missing and what file or record should be checked next.`
: '';
const astraStanceCtx = prompt && !isCasualConversation
? `\n\n${buildAstraStanceContext(prompt, localPathContext)}`
: '';
// The v4 knowledge-management policy only matters when knowledge is actually in play —
// skip it for greetings/small talk so it doesn't dilute the [CASUAL CONVERSATION MODE] directive.
const v4PolicyCtx = isCasualConversation ? '' : [
"\n### 🏛️ 지식 관리 정책 v4.0 (Knowledge Management Policy Applied)",
"- [신뢰도] '의도적으로 작성된 글'은 Medium 이상의 신뢰도를 부여하여 최우선 근거로 활용할 것.",
"- [품질] 데이터의 양보다 '추론 기여 밀도'를 중시하여 핵심 위주로 깊이 있게 서술할 것.",
"- [충돌] 지식 간 충돌 발생 시 시스템이 독단적으로 판단하지 말고, 반드시 [CONFLICT WARNING] 플래그와 함께 상충되는 두 관점을 모두 명시하여 사용자에게 판단을 위임할 것."
].join('\n');
// [CONTEXT] … [/CONTEXT] 사이만 컨텍스트 초과 시 trim 대상 — negative constraints 는 보호.
const casualCtx = isCasualConversation
? '\n\n[CASUAL CONVERSATION MODE]\nThe user sent a greeting, acknowledgement, or light conversational message. Reply naturally and briefly to the message itself. Do not use Second Brain, memory, project records, reports, references, or analysis unless the user explicitly asks for them.'
: '';
// Knowledge Mix policy: tells the model how strongly to lean on Second Brain
// evidence vs. its own general knowledge for this turn. Suppressed for casual
// chat — pure greetings don't need to be told anything about RAG balance.
const knowledgeMixCtx = (!isCasualConversation && knowledgeMix)
? (() => {
const block = buildKnowledgeMixPolicy(knowledgeMix);
return block ? `\n\n${block}` : '';
})()
: '';
// memoryCtx(RAG/메모리/lessons)는 [CONTEXT] 안에 — 토큰이 빡빡하면 대화 기록보다 먼저 잘림.
// priorConclusionCtx 는 modeBridgeCtx 와 같은 위치 (base systemPrompt 직후) — 모델이
// 자기 직전 결론을 anchor 로 잡고 사용자의 follow-up 을 그 결론에 대한 정정으로 해석하게.
const priorConclusionBlock = priorConclusionCtx ? '\n\n' + priorConclusionCtx : '';
return `${systemPrompt}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${knowledgeMixCtx}${casualCtx}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`;
}
@@ -0,0 +1,57 @@
import type { ChatMessage } from '../../agent';
import { computeModeSignature } from '../../lib/contextBuilders/systemPromptShaping';
import { buildLastTopicLine } from '../../lib/contextBuilders/lastTopicLine';
import { getActiveBrainProfile, logError } from '../../utils';
export interface BuildModeBridgeContextInput {
options: any; // the handlePrompt options object
lastModeSignature: string | null;
chatHistory: ChatMessage[];
}
export interface BuildModeBridgeContextResult {
/** "[MODE TRANSITION BRIDGE]\n…" string to embed in system prompt, or '' when no transition. */
modeBridgeCtx: string;
/** The newly-computed signature — caller should store this as the next-turn comparison baseline. */
newSignature: string | null;
}
export function buildModeBridgeContext(input: BuildModeBridgeContextInput): BuildModeBridgeContextResult {
const { options, lastModeSignature, chatHistory } = input;
// v2.2.69 — 모드 전환 bridge. 현재 mode signature 를 직전 값과 비교해 바뀌었으면
// "이전 대화는 X 모드에서 Y 주제로 진행됨 / 지금부터 Z 모드" 한 줄을 system prompt 에 끼운다.
// chatHistory 자체는 손대지 않으므로 사용자 입장에선 대화가 연속되어 보이면서도
// 모델은 "모드가 바뀐 직후" 임을 인지한다.
let modeBridgeCtx = '';
let newSignature: string | null = null;
try {
const agentSkillName = options.agentSkillContext
? (options.agentSkillContext.split('\n')[0] || '').slice(0, 60).replace(/^#\s*/, '').trim()
: '';
const currentSig = computeModeSignature({
agentSkillName: agentSkillName || undefined,
companyMode: !!(options as any).companyMode,
multiAgent: !!(options as any).multiAgent,
brainName: getActiveBrainProfile()?.name,
});
if (lastModeSignature !== null && lastModeSignature !== currentSig) {
const topic = buildLastTopicLine(chatHistory);
const bridgeLines = [
'',
'[MODE TRANSITION BRIDGE]',
`이전 모드: ${lastModeSignature}`,
`현재 모드: ${currentSig}`,
];
if (topic) bridgeLines.push(`직전 대화 주제(한 줄): ${topic}`);
bridgeLines.push('대화 history 는 그대로 이어진다. 새 모드의 페르소나/포맷을 따르되, 직전까지 사용자가 다루던 맥락을 잊지 말 것.');
modeBridgeCtx = bridgeLines.join('\n');
}
newSignature = currentSig;
} catch (e: any) {
logError('Mode-bridge computation failed (non-fatal).', { error: e?.message || String(e) });
return { modeBridgeCtx: '', newSignature: null };
}
return { modeBridgeCtx, newSignature };
}
@@ -0,0 +1,117 @@
import * as vscode from 'vscode';
import * as path from 'path';
import type { ChatMessage } from '../../agent';
import type { BrainProfile } from '../../config';
import { findBrainFiles } from '../../utils';
import {
isExplicitSecondBrainRequest,
isSecondBrainInventoryRequest,
} from '../../lib/contextBuilders/promptDetection';
import { buildSecondBrainInventoryContext } from '../../lib/contextBuilders/secondBrainInventory';
import { buildLocalProjectPathContext } from '../../lib/contextBuilders/localProjectPath';
import { buildRecentProjectKnowledgeContext } from '../../lib/contextBuilders/recentProjectKnowledge';
import { buildJarvisProjectBriefContext } from '../../lib/contextBuilders/jarvisProjectBrief';
import { buildAstraModeArchitectureContext } from '../../lib/contextBuilders/astraModeArchitecture';
import { buildSecondBrainTrace, SecondBrainTrace } from '../../features/secondBrainTrace';
export interface BuildTurnContextBlocksInput {
prompt: string | null;
options: any; // the handlePrompt options object (we read secondBrainTraceEnabled, brainProfileId)
isCasualConversation: boolean;
loopDepth: number;
config: any; // getConfig() result — we read memoryLongTermFiles, maxContextSize, brainProfiles
activeBrain: BrainProfile;
chatHistory: ChatMessage[];
rootPath: string;
}
export interface BuildTurnContextBlocksResult {
contextBlock: string;
brainContext: string;
brainInventoryCtx: string;
brainFiles: string[];
brainPreview: string;
localPathContext: string;
secondBrainTrace: SecondBrainTrace | null;
}
export function buildTurnContextBlocks(input: BuildTurnContextBlocksInput): BuildTurnContextBlocksResult {
const {
prompt,
options,
isCasualConversation,
loopDepth,
config,
activeBrain,
chatHistory,
rootPath,
} = input;
let contextBlock = '';
const brainFiles = findBrainFiles(activeBrain.localBrainPath);
let secondBrainTrace: SecondBrainTrace | null = null;
if (options.secondBrainTraceEnabled && prompt && loopDepth === 0 && !isCasualConversation) {
secondBrainTrace = buildSecondBrainTrace(prompt, activeBrain.localBrainPath, {
force: isExplicitSecondBrainRequest(prompt),
limit: Math.max(config.memoryLongTermFiles, 5)
});
}
const brainPreview = brainFiles
.slice(0, 30)
.map(file => path.relative(activeBrain.localBrainPath, file))
.join('\n');
const brainContext = [
`[ACTIVE SECOND BRAIN]`,
`Use this Local Brain only when it is relevant to the user's current question.`,
`Name: ${activeBrain.name}`,
`Path: ${activeBrain.localBrainPath}`,
`Knowledge files: ${brainFiles.length}`,
activeBrain.description ? `Description: ${activeBrain.description}` : '',
brainPreview ? `Available file examples:\n${brainPreview}` : 'Files: none found'
].filter(Boolean).join('\n');
const brainInventoryCtx = prompt && !isCasualConversation && isSecondBrainInventoryRequest(prompt)
? `\n\n${buildSecondBrainInventoryContext(activeBrain, brainFiles)}`
: '';
const editor = vscode.window.activeTextEditor;
if (editor && editor.document.uri.scheme === 'file') {
const text = editor.document.getText();
const name = path.basename(editor.document.fileName);
if (text.trim().length > 0 && text.length < config.maxContextSize) {
contextBlock = `\n\n[Currently open file: ${name}]\n\`\`\`\n${text}\n\`\`\``;
}
}
const localPathContext = prompt && loopDepth === 0
? buildLocalProjectPathContext(prompt, rootPath)
: '';
if (localPathContext) {
contextBlock += `\n\n${localPathContext}`;
}
const recentProjectKnowledgeContext = prompt && loopDepth === 0 && !isCasualConversation && !localPathContext
? buildRecentProjectKnowledgeContext(prompt, rootPath, chatHistory)
: '';
if (recentProjectKnowledgeContext) {
contextBlock += `\n\n${recentProjectKnowledgeContext}`;
}
const projectBriefContext = prompt && loopDepth === 0 && !isCasualConversation
? buildJarvisProjectBriefContext(prompt, localPathContext, recentProjectKnowledgeContext)
: '';
if (projectBriefContext) {
contextBlock += `\n\n${projectBriefContext}`;
}
const modeArchitectureContext = prompt && loopDepth === 0 && !isCasualConversation
? buildAstraModeArchitectureContext(prompt)
: '';
if (modeArchitectureContext) {
contextBlock += `\n\n${modeArchitectureContext}`;
}
return {
contextBlock,
brainContext,
brainInventoryCtx,
brainFiles,
brainPreview,
localPathContext,
secondBrainTrace,
};
}
@@ -0,0 +1,161 @@
import { logInfo, logError } from '../../utils';
import type { ChatMessage } from '../../agent';
import {
estimateTokens,
estimateMessagesTokens,
computeOutputBudget,
trimHistoryToBudget,
truncateSystemPromptContext,
estimateModelParamsB,
type ContextLimits,
} from '../../lib/contextManager';
import { buildDroppedHistorySummary } from '../../lib/contextBuilders/droppedHistorySummary';
export interface ComputeBudgetedRequestInput {
fullSystemPrompt: string;
/** Caller is expected to have run `capChatHistory` on this already. */
reqMessages: ChatMessage[];
actualModel: string;
/** Result of `getConfig()` — reads contextLength, maxOutputTokens, contextSafetyMargin, smallModelContextCap, autoCompactHistory. */
config: any;
imageCount: number;
}
export interface ComputeBudgetedRequestResult {
messagesForRequest: ChatMessage[];
ctxLimits: ContextLimits;
inputTokens: number;
maxOutputTokens: number;
systemTokens: number;
systemTruncated: boolean;
droppedHistoryCount: number;
budgetedHistoryLength: number;
/** Exact return shape of `computeOutputBudget`. */
outputBudget: { maxOutputTokens: number; available: number; tight: boolean };
modelParamB: number | null;
cappedForSmallModel: boolean;
}
/**
* ( + + )
* .
*
* capChatHistory
* (AgentExecutor.MAX_RETAINED_MESSAGES ).
*/
export function computeBudgetedRequest(input: ComputeBudgetedRequestInput): ComputeBudgetedRequestResult {
const { fullSystemPrompt, reqMessages, actualModel, config, imageCount } = input;
// ──────────────────────────────────────────────────────────────────
// [Context Limit Manager] context length 는 "답변을 그만큼 길게 써도 된다"
// 는 뜻이 아니다: 시스템 프롬프트 + 대화 기록 + 입력 + 생성될 답변 + 여유분 ≤ context length.
// 요청을 보내기 전에 입력 토큰을 추정해서
// (1) 시스템 프롬프트가 과하면 [CONTEXT] 블록을 마지막 수단으로 줄이고
// (2) 대화 기록을 남은 예산에 맞게 압축하고 (UI 표시용 chatHistory 는 건드리지 않음)
// (3) 동적으로 출력 상한(maxOutputTokens)을 계산한다.
// ──────────────────────────────────────────────────────────────────
// Optional opt-in guard (g1nation.smallModelContextCap, OFF/0 by default): some very small
// models (≤3B) emit EOS as the first token when the prompt is near their context window
// even though it nominally fits. If the user opted in, budget ≤3B models against that
// smaller effective window. Never applied to 4B+ models, and never when the setting is 0 —
// capping squeezes the output-token budget, so it's a knob, not a default.
const modelParamB = estimateModelParamsB(actualModel);
const smallModelCap = config.smallModelContextCap; // 0 = disabled (default)
const cappedForSmallModel = smallModelCap > 0
&& modelParamB !== null && modelParamB <= 3
&& config.contextLength > smallModelCap;
const effectiveContextLength = cappedForSmallModel ? smallModelCap : config.contextLength;
if (cappedForSmallModel) {
logInfo('Small model detected — capping effective context window for budgeting.', {
model: actualModel, paramB: modelParamB,
nominalContext: config.contextLength, effectiveContext: effectiveContextLength,
});
}
const ctxLimits: ContextLimits = {
contextLength: effectiveContextLength,
maxOutputTokens: config.maxOutputTokens,
safetyMargin: config.contextSafetyMargin,
minOutputTokens: 512,
};
const imageTokenReserve = imageCount * 1024;
// Output budget we ACTUALLY reserve before trimming — not the bare
// minOutputTokens floor (512). If we only reserve 512, a long session
// is allowed to grow the prompt until ~512-1k tokens remain for the
// answer; small/MoE local models (e.g. gemma 4B-active) then emit EOS
// as the first token and return an empty response. Reserving ~10% of
// the window (>=2048) forces history/system trimming to keep a real
// answer-sized hole open. Capped at maxOutputTokens.
const preferredOutputReserve = Math.min(
ctxLimits.maxOutputTokens,
Math.max(2048, Math.floor(ctxLimits.contextLength * 0.1))
);
// (1) 시스템 프롬프트는 예산의 ~65%까지만 허용 — 그 이상이면 [CONTEXT] 블록부터 잘라낸다.
const systemCapTokens = Math.max(
1024,
Math.floor((ctxLimits.contextLength - ctxLimits.safetyMargin - preferredOutputReserve - imageTokenReserve) * 0.65)
);
const { prompt: budgetedSystemPrompt, truncated: systemTruncated } =
truncateSystemPromptContext(fullSystemPrompt, systemCapTokens);
if (systemTruncated) {
logInfo('System prompt context truncated to fit the context window.', { model: actualModel, systemCapTokens });
}
const systemTokens = estimateTokens(budgetedSystemPrompt) + 4;
// (2) 대화 기록 압축.
const historyBudget = Math.max(
256,
ctxLimits.contextLength - systemTokens - ctxLimits.safetyMargin - preferredOutputReserve - imageTokenReserve
);
let budgetedHistory: ChatMessage[] = reqMessages;
if (config.autoCompactHistory) {
// v2.2.69 — dropped 메시지를 받아 heuristic 요약을 만든 뒤 한 system 메시지로 prepend.
// 단순 count 마커는 "이전에 무슨 얘기를 했는지" 를 전혀 알려주지 않아 후속 턴에서 모델이
// 맥락을 잃어버리는 회귀를 낳았다. 이제는 U1/A1/U2/A2 골자가 남아 sliding window 가 동작.
const trim = trimHistoryToBudget<ChatMessage>(reqMessages, historyBudget, (_n, dropped) => ({
role: 'system',
content: buildDroppedHistorySummary(dropped),
internal: true,
}));
budgetedHistory = trim.messages;
if (trim.droppedCount > 0) {
logInfo('Conversation history compacted to fit the context window (with summary).', {
model: actualModel, droppedCount: trim.droppedCount, historyBudget,
});
}
}
const messagesForRequest: ChatMessage[] = [
{ role: 'system', content: budgetedSystemPrompt, internal: true },
...budgetedHistory
];
// (3) 동적 출력 상한.
const inputTokens = estimateMessagesTokens(messagesForRequest) + imageTokenReserve;
const outputBudget = computeOutputBudget(inputTokens, ctxLimits);
const maxOutputTokens = outputBudget.maxOutputTokens;
if (outputBudget.tight) {
logError('Prompt nearly fills the context window — output budget is at the minimum.', {
model: actualModel, contextLength: ctxLimits.contextLength, inputTokens, maxOutputTokens,
});
}
logInfo('Context budget computed.', {
model: actualModel, contextLength: ctxLimits.contextLength,
inputTokens, maxOutputTokens, droppedHistory: reqMessages.length - budgetedHistory.length,
});
return {
messagesForRequest,
ctxLimits,
inputTokens,
maxOutputTokens,
systemTokens,
systemTruncated,
droppedHistoryCount: reqMessages.length - budgetedHistory.length,
budgetedHistoryLength: budgetedHistory.length,
outputBudget,
modelParamB,
cappedForSmallModel,
};
}
@@ -0,0 +1,147 @@
import { logError } from '../../utils';
import { getConfig, BrainProfile } from '../../config';
import { stripMarkdownFormatting, looksCutOff } from '../../core/responseRecovery';
import {
sanitizeAssistantContent,
parseRationale,
} from '../../lib/contextBuilders/outputSanitization';
import {
isSecondBrainInventoryRequest,
isNoBrainDataRefusal,
} from '../../lib/contextBuilders/promptDetection';
import { buildSecondBrainInventoryFallbackAnswer } from '../../lib/contextBuilders/secondBrainInventory';
import { isProjectKnowledgeCreationRequest } from '../../lib/contextBuilders/localProjectIntent';
import {
buildProjectKnowledgeFallbackAnswer,
writeProjectKnowledgeRecord,
} from '../../lib/contextBuilders/projectKnowledge';
import { enforceLocalPathReviewAnswer } from '../../lib/contextBuilders/localProjectPath';
import { isBlockingProjectKnowledgeAnswer } from '../../lib/contextBuilders/recentProjectKnowledge';
import {
enforceProjectClaimPolicyInAnswer,
SecondBrainTrace,
} from '../../features/secondBrainTrace';
import {
estimateTokens,
classifyStopReason,
truncationNotice,
shouldShowTruncationNotice,
} from '../../lib/contextManager';
export interface ProcessFinalAnswerInput {
/** Raw `cleaned.visible` from extractVisibleFinal(). */
visibleAnswer: string;
prompt: string | null;
secondBrainTrace: SecondBrainTrace | null;
localPathContext: string;
activeBrain: BrainProfile;
brainFiles: string[];
finishStopReason: string | undefined;
maxOutputTokens: number;
/** From earlier phases — used in logError noise. */
actualModel: string;
engine: string;
inputTokens: number;
}
export interface ProcessFinalAnswerResult {
/** post-stripMarkdown 1차 — agent.ts 의 `executeActions(cleanedVisible, …)` 호출에 그대로 전달. */
cleanedVisible: string;
/** post-enforcers, pre-final-stripMarkdown — used for executeActions and history. */
assistantContent: string;
/** post-stripMarkdown-FINAL — emitted to webview. */
finalAssistantContent: string;
rationale: ReturnType<typeof parseRationale>;
outputTokens: number;
stopKind: ReturnType<typeof classifyStopReason>;
}
export function processFinalAnswer(input: ProcessFinalAnswerInput): ProcessFinalAnswerResult {
const {
visibleAnswer,
prompt,
secondBrainTrace,
localPathContext,
activeBrain,
brainFiles,
finishStopReason,
maxOutputTokens,
actualModel,
engine,
inputTokens,
} = input;
// [Plain Text Output] outputFormat='plain' (기본)이면 모델이 무심코 내보낸
// 마크다운 마커(`##`, `**`, `> `, `* ` …) 를 후처리로 모두 제거. 라벨 텍스트는 유지.
// markdown 모드면 legacy 그대로 통과.
const cleanedVisible = getConfig().outputFormat === 'plain'
? stripMarkdownFormatting(visibleAnswer)
: visibleAnswer;
// 5. Execute Actions
const rationale = parseRationale(cleanedVisible);
let assistantContent = enforceLocalPathReviewAnswer(
enforceProjectClaimPolicyInAnswer(
sanitizeAssistantContent(cleanedVisible),
secondBrainTrace
),
localPathContext
);
if (prompt && isSecondBrainInventoryRequest(prompt) && brainFiles.length > 0 && isNoBrainDataRefusal(assistantContent)) {
assistantContent = buildSecondBrainInventoryFallbackAnswer(activeBrain, brainFiles, secondBrainTrace);
}
// Note: a previous implementation replaced LLM review answers with a
// hardcoded Korean template whenever the answer didn't match enough
// keywords. That made every review feel canned and project-agnostic
// (the template was Datacollector-flavored). We now let the LLM's
// answer stand — the system prompt for review-evaluation
// (buildLocalProjectIntentGuidance / buildAstraStanceContext) is
// strong enough to keep the response concrete.
if (prompt && localPathContext && isProjectKnowledgeCreationRequest(prompt)) {
const record = writeProjectKnowledgeRecord(localPathContext);
if (isBlockingProjectKnowledgeAnswer(assistantContent)) {
assistantContent = buildProjectKnowledgeFallbackAnswer(localPathContext, record);
} else if (record && !assistantContent.includes(record.filePath)) {
assistantContent = [
assistantContent,
'',
'## 생성된 기록',
`프로젝트 지식 기록을 생성했습니다: \`${record.filePath}\``
].join('\n');
}
}
// Surface truncated/abnormal generation so the user knows the answer is incomplete.
const stopKind = classifyStopReason(finishStopReason);
if (stopKind === 'output-limit' || stopKind === 'context-overflow' || stopKind === 'error') {
logError('Generation stopped abnormally.', {
model: actualModel, engine, stopReason: finishStopReason, stopKind,
inputTokens, maxOutputTokens, answerChars: assistantContent.length,
});
}
const outputTokens = estimateTokens(assistantContent);
// Show the "incomplete" notice when the engine said output-limit/context-overflow/error,
// OR when (after all auto-continuation rounds) the answer still plainly ends mid-sentence.
const notice =
shouldShowTruncationNotice(stopKind, outputTokens, maxOutputTokens) ? truncationNotice(stopKind)
: looksCutOff(assistantContent) ? truncationNotice('output-limit')
: '';
if (notice && assistantContent.trim()) {
assistantContent = assistantContent.trimEnd() + notice;
}
// [Plain Text Output — FINAL pass] enforcer 들이 `## 경로 확인 결과` 같은 하드코딩 헤더를
// 다시 prepend 한 후에도 마커가 남지 않도록, webview / chatHistory 에 들어가는 최종 문자열을
// 한 번 더 sanitize. cleanedVisible 단계의 1차 sanitize 는 model 출력 자체를 정리하고,
// 이 2차 sanitize 는 enforcer 출력까지 모두 청소한다.
const finalAssistantContent = getConfig().outputFormat === 'plain'
? stripMarkdownFormatting(assistantContent)
: assistantContent;
return {
cleanedVisible,
assistantContent,
finalAssistantContent,
rationale,
outputTokens,
stopKind,
};
}
+101
View File
@@ -0,0 +1,101 @@
import * as vscode from 'vscode';
import { ChatMessage } from '../../agent';
import { buildApiUrl, summarizeText } from '../../utils';
import { buildEngineMessageVariants } from '../../lib/contextBuilders/engineMessages';
import { samplingToRestBody } from '../../lmstudio/streamer';
import { lmStudioSamplingFromConfig } from '../../lib/contextBuilders/lmStudioSampling';
export interface CallNonStreamingDeps {
context: vscode.ExtensionContext;
}
export async function callNonStreaming(deps: CallNonStreamingDeps, params: {
baseUrl: string;
modelName: string;
engine: 'lmstudio' | 'ollama';
messages: ChatMessage[];
temperature: number;
maxTokens?: number;
contextLength?: number;
signal?: AbortSignal;
}): Promise<{ text: string; stopReason?: string }> {
const { baseUrl, modelName, engine, messages, temperature, signal } = params;
const maxTokens = Math.max(256, params.maxTokens ?? 4096);
// Cloud routing — streaming Response 를 받아 끝까지 모아서 텍스트로 환원.
// Non-streaming 전용 endpoint 를 따로 두지 않고 stream 결과를 모으는 게 단순.
try {
const { parseModelPrefix, streamCloudCompletion } =
require('../../features/providers') as typeof import('../../features/providers');
const hit = parseModelPrefix(modelName);
if (hit) {
const response = await streamCloudCompletion(deps.context, hit, {
messages: messages.map((m) => ({ role: m.role as any, content: m.content })),
temperature,
maxTokens,
signal,
});
if (!response.ok) {
const errText = await response.text().catch(() => '');
throw new Error(`Cloud (${hit.provider}) ${response.status}: ${summarizeText(errText, 200)}`);
}
// OpenAI 호환 SSE 를 통째로 읽어 delta.content 합치기.
const raw = await response.text();
let acc = '';
for (const line of raw.split('\n')) {
const t = line.trim();
if (!t.startsWith('data:')) continue;
const payload = t.slice(5).trim();
if (!payload || payload === '[DONE]') continue;
try {
const obj = JSON.parse(payload);
const delta = obj?.choices?.[0]?.delta?.content;
if (typeof delta === 'string') acc += delta;
} catch { /* skip malformed */ }
}
return { text: acc, stopReason: 'stop' };
}
} catch (e) {
const msg = (e as Error)?.message ?? '';
if (msg.startsWith('Cloud (')) throw e;
}
const numCtx = Math.max(2048, params.contextLength ?? 32768);
const apiUrl = buildApiUrl(baseUrl, engine, 'chat');
const variants = buildEngineMessageVariants(messages, engine);
const sampling = samplingToRestBody(lmStudioSamplingFromConfig());
const body = {
model: modelName,
messages: variants[0].messages,
stream: false,
...(engine === 'lmstudio'
? { max_tokens: maxTokens, temperature, ...sampling }
: { options: { num_ctx: numCtx, num_predict: maxTokens, temperature, ...sampling } }),
};
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal,
});
if (!response.ok) {
const errText = await response.text().catch(() => '');
throw new Error(`Non-streaming fallback returned ${response.status}: ${summarizeText(errText, 200)}`);
}
const text = await response.text();
try {
const json = JSON.parse(text);
if (engine === 'lmstudio') {
return {
text: json?.choices?.[0]?.message?.content ?? '',
stopReason: json?.choices?.[0]?.finish_reason,
};
}
return {
text: json?.message?.content ?? json?.response ?? '',
stopReason: json?.done_reason ?? (json?.done === true ? 'stop' : undefined),
};
} catch {
return { text: '' };
}
}
+169
View File
@@ -0,0 +1,169 @@
import * as vscode from 'vscode';
import {
buildApiUrl,
logError,
logInfo,
resolveEngine,
summarizeText,
} from '../../utils';
import { buildEngineMessageVariants } from '../../lib/contextBuilders/engineMessages';
import { buildModelCandidates } from '../../lib/contextBuilders/modelCandidates';
import { samplingToRestBody } from '../../lmstudio/streamer';
import { lmStudioSamplingFromConfig } from '../../lib/contextBuilders/lmStudioSampling';
import type { ChatMessage } from '../../agent';
export interface CreateStreamingRequestDeps {
context: vscode.ExtensionContext;
getAbortSignal: () => AbortSignal | undefined;
}
export async function createStreamingRequest(deps: CreateStreamingRequestDeps, params: {
baseUrl: string;
modelName: string;
reqMessages: ChatMessage[];
temperature: number;
/** Dynamic output-token cap computed from the remaining context budget. */
maxTokens?: number;
/** Model context window in tokens (used for Ollama's num_ctx). */
contextLength?: number;
}): Promise<{ response: Response; engine: 'lmstudio' | 'ollama'; apiUrl: string }> {
const { baseUrl, modelName, reqMessages, temperature } = params;
const maxTokens = Math.max(256, params.maxTokens ?? 4096);
// Cloud provider 라우팅 — model id 가 'openrouter:' / 'anthropic:' / 'gemini:' 로 시작하면
// 해당 adapter 호출. body 는 OpenAI 호환 SSE 로 transform 되어 반환되므로
// 아래 로컬 엔진 경로의 consumer 가 동일하게 처리.
try {
const { parseModelPrefix, streamCloudCompletion } =
require('../../features/providers') as typeof import('../../features/providers');
const hit = parseModelPrefix(modelName);
if (hit) {
logInfo('AI streaming request (cloud).', { provider: hit.provider, model: hit.model });
const response = await streamCloudCompletion(deps.context, hit, {
messages: reqMessages.map((m) => ({ role: m.role as any, content: m.content })),
temperature,
maxTokens,
signal: deps.getAbortSignal(),
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Cloud (${hit.provider}) ${response.status}: ${summarizeText(errText, 300)}`);
}
return { response, engine: 'lmstudio', apiUrl: `cloud://${hit.provider}/${hit.model}` };
}
} catch (e) {
// 모듈 로드 실패 / 매칭 안 됨 — 로컬 경로로 fall through.
// (단, 명시적으로 cloud routing 했는데 실패한 경우는 throw 되어 위에서 catch 됨.)
const msg = (e as Error)?.message ?? '';
if (msg.startsWith('Cloud (')) throw e;
}
const numCtx = Math.max(2048, params.contextLength ?? 32768);
const engine = resolveEngine(baseUrl); // 사용자가 설정한 엔진만 사용
const apiUrl = buildApiUrl(baseUrl, engine, 'chat');
const messageVariants = buildEngineMessageVariants(reqMessages, engine);
const modelCandidates = buildModelCandidates(modelName, engine);
let lastError: Error | null = null;
// 같은 엔진 내에서만 model candidate / message variant retry
for (const candidateModel of modelCandidates) {
for (const variant of messageVariants) {
const sampling = samplingToRestBody(lmStudioSamplingFromConfig());
const streamBody = {
model: candidateModel,
messages: variant.messages,
stream: true,
...(engine === 'lmstudio'
// LM Studio's OpenAI-compatible REST extends the schema with top_k/min_p/
// repeat_penalty (same names as Ollama). Spread the shared sampling block so
// the REST fallback matches the SDK path — without it a fallback after a
// dead handle quietly loses the glitch-suppression preset.
? { max_tokens: maxTokens, temperature, ...sampling }
: { options: { num_ctx: numCtx, num_predict: maxTokens, temperature, ...sampling } }),
};
// 일시적 네트워크 오류용 retry (최대 2회, 지수 backoff)
const MAX_RETRIES = 2;
let serviceDown = false;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
if (attempt > 0) {
const delay = 500 * Math.pow(2, attempt - 1); // 500ms, 1000ms
await new Promise(r => setTimeout(r, delay));
logInfo('AI streaming request retry.', { engine, attempt, model: candidateModel });
}
logInfo('AI streaming request started.', {
engine, apiUrl, model: candidateModel,
variant: variant.name, messageCount: variant.messages.length,
attempt
});
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
},
body: JSON.stringify(streamBody),
signal: deps.getAbortSignal(),
keepalive: true
});
if (!response.ok) {
const errText = await response.text();
lastError = new Error(`AI Engine error (${engine}/${variant.name}): ${response.status} - ${summarizeText(errText, 300)}`);
logError('AI streaming request returned non-OK status.', {
engine, variant: variant.name, apiUrl,
status: response.status, body: summarizeText(errText, 500)
});
// 4xx는 재시도해도 의미없음. 5xx만 재시도.
if (response.status >= 400 && response.status < 500) break;
continue;
}
logInfo('AI streaming request connected.', { engine, variant: variant.name, apiUrl });
return { response, engine, apiUrl };
} catch (error: any) {
lastError = error instanceof Error ? error : new Error(String(error));
// AbortError는 사용자가 취소한 것이므로 retry 금지
if (lastError.name === 'AbortError') {
throw lastError;
}
// ECONNREFUSED / DNS-level failures mean the engine process isn't even
// listening — no amount of retries or message-variant juggling will help.
// Abandon the candidate/variant loops now and surface the "is X running?"
// error fast instead of burning 12 fetch attempts before giving up.
const errCode = (error?.cause?.code ?? error?.code ?? '').toString();
const errMsg = lastError.message;
if (
errCode === 'ECONNREFUSED' || errCode === 'ENOTFOUND' || errCode === 'EAI_AGAIN'
|| /ECONNREFUSED|ENOTFOUND|getaddrinfo|fetch failed/i.test(errMsg)
) {
serviceDown = true;
logError('AI streaming request: engine appears to be down.', {
engine, apiUrl, code: errCode, error: errMsg,
});
break; // exit retry loop
}
logError('AI streaming request failed.', {
engine, variant: variant.name, apiUrl, model: candidateModel,
attempt, error: lastError.message
});
}
}
if (serviceDown) break; // skip remaining variants
}
// serviceDown also short-circuits the model-candidate loop — there is no
// candidate / variant the engine can answer if it isn't listening at all.
if (lastError && /ECONNREFUSED|ENOTFOUND|fetch failed/i.test(lastError.message)) break;
}
// 명확한 에러 메시지: 어느 엔진이 실패했는지 사용자에게 알림
const engineLabel = engine === 'lmstudio' ? 'LM Studio' : 'Ollama';
throw new Error(
`${engineLabel} 엔진에 연결할 수 없습니다. ` +
`${engineLabel}가 실행 중이고 모델 '${modelName}'이 로드되어 있는지 확인하세요. ` +
`(원인: ${lastError?.message || 'unknown'})`
);
}
+74
View File
@@ -0,0 +1,74 @@
import * as vscode from 'vscode';
import { logInfo } from '../../utils';
import { ChatMessage } from '../../agent';
export interface DevilRebuttalDeps {
getAbortSignal: () => AbortSignal | undefined;
callNonStreaming: (params: {
baseUrl: string;
modelName: string;
engine: 'lmstudio' | 'ollama';
messages: ChatMessage[];
temperature: number;
maxTokens?: number;
contextLength?: number;
signal?: AbortSignal;
}) => Promise<{ text: string; stopReason?: string }>;
getWebview: () => vscode.Webview | undefined;
}
/**
* Devil Agent emit main turn (fire-and-forget).
* return. LLM (callNonStreaming ) .
* webview 'devilRebuttal' UI .
*/
export async function maybeEmitDevilRebuttal(deps: DevilRebuttalDeps, 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 deps.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: deps.getAbortSignal(),
});
return r.text;
};
const rebuttal = await generateDevilRebuttal(callLLM, {
userPrompt: opts.userPrompt,
assistantAnswer: opts.assistantAnswer,
});
if (!rebuttal) return;
deps.getWebview()?.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) });
}
}
+136
View File
@@ -0,0 +1,136 @@
import * as vscode from 'vscode';
import { logError, summarizeText } from '../../utils';
import { lmStudioSamplingFromConfig, lmStudioRespondExtrasFromConfig } from '../../lib/contextBuilders/lmStudioSampling';
import type { AgentExecutorOptions, ChatMessage } from '../../agent';
export interface StreamChatOnceDeps {
options: AgentExecutorOptions;
getWebview: () => vscode.Webview | undefined;
isStaleRun: (runId: number) => boolean;
createStreamingRequest: (params: {
baseUrl: string;
modelName: string;
reqMessages: ChatMessage[];
temperature: number;
/** Dynamic output-token cap computed from the remaining context budget. */
maxTokens?: number;
/** Model context window in tokens (used for Ollama's num_ctx). */
contextLength?: number;
}) => Promise<{ response: Response; engine: 'lmstudio' | 'ollama'; apiUrl: string }>;
}
export async function streamChatOnce(deps: StreamChatOnceDeps, params: {
runId: number;
useLmStudioSdk: boolean;
engine: 'lmstudio' | 'ollama';
ollamaUrl: string;
modelName: string;
messages: ChatMessage[];
temperature: number;
maxTokens: number;
contextLength: number;
contextOverflowPolicy: 'stopAtLimit' | 'truncateMiddle' | 'rollingWindow';
signal: AbortSignal;
postLiveDeltas: boolean;
}): Promise<{ text: string; stopReason?: string; aborted: boolean }> {
let accumulated = '';
let finishStopReason: string | undefined;
const post = (token: string) => {
if (params.postLiveDeltas && token) {
deps.getWebview()?.postMessage({ type: 'streamChunk', value: token });
}
};
if (params.useLmStudioSdk) {
try {
const stream = deps.options.lmStudioStreamer!.stream({
modelName: params.modelName,
messages: params.messages.map((m) => ({ role: m.role, content: m.content })),
temperature: params.temperature,
maxTokens: params.maxTokens,
contextOverflowPolicy: params.contextOverflowPolicy,
...lmStudioSamplingFromConfig(),
...lmStudioRespondExtrasFromConfig(),
signal: params.signal,
});
for await (const { token, stopReason } of stream) {
if (deps.isStaleRun(params.runId)) {
return { text: accumulated, stopReason: finishStopReason, aborted: true };
}
if (token) {
accumulated += token;
post(token);
}
if (stopReason) finishStopReason = stopReason;
}
} catch (err: any) {
if (err?.name === 'AbortError' || params.signal.aborted) {
return { text: accumulated, stopReason: finishStopReason, aborted: true };
}
const msg = err?.message ?? String(err);
if (/context\s*length|contextlengthreached|exceed|too\s*long/i.test(msg)) {
finishStopReason = 'contextLengthReached';
}
logError('streamChatOnce SDK path failed.', { engine: params.engine, error: msg });
throw err;
}
return { text: accumulated, stopReason: finishStopReason, aborted: false };
}
const request = await deps.createStreamingRequest({
baseUrl: params.ollamaUrl,
modelName: params.modelName,
reqMessages: params.messages,
temperature: params.temperature,
maxTokens: params.maxTokens,
contextLength: params.contextLength,
});
const reader = request.response.body?.getReader();
if (!reader) throw new Error('Response body is not readable.');
const decoder = new TextDecoder();
let buffer = '';
const consumeJsonLine = (line: string) => {
const trimmed = line.trim();
if (!trimmed || trimmed === 'data: [DONE]') return;
try {
const raw = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;
const json = JSON.parse(raw);
const token = params.engine === 'lmstudio'
? json.choices?.[0]?.delta?.content || ''
: json.message?.content || json.response || '';
if (token) {
accumulated += token;
post(token);
}
const fr = params.engine === 'lmstudio'
? json.choices?.[0]?.finish_reason
: (json.done_reason ?? (json.done === true ? 'stop' : undefined));
if (fr) finishStopReason = fr;
} catch (e: any) {
logError('streamChatOnce: failed to parse chunk.', { engine: params.engine, chunk: summarizeText(trimmed, 200), error: e?.message ?? String(e) });
}
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (deps.isStaleRun(params.runId)) {
return { text: accumulated, stopReason: finishStopReason, aborted: true };
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) consumeJsonLine(line);
}
if (buffer.trim()) consumeJsonLine(buffer);
} catch (err: any) {
if (err?.name === 'AbortError') {
return { text: accumulated, stopReason: finishStopReason, aborted: true };
}
logError('streamChatOnce REST path failed.', { engine: params.engine, error: err?.message ?? String(err) });
throw err;
} finally {
try { reader.releaseLock(); } catch { /* already released on abort */ }
}
return { text: accumulated, stopReason: finishStopReason, aborted: false };
}
+56
View File
@@ -0,0 +1,56 @@
import * as vscode from 'vscode';
import { getActiveBrainProfile, logError, logInfo } from '../utils';
import { BrainProfile, getConfig } from '../config';
import { SessionManager } from '../core/session';
import { ChatMessage } from '../agent';
export interface RestoreLastSessionDeps {
sessionManager: SessionManager;
setChatHistory: (history: ChatMessage[]) => void;
setCurrentTaskId: (taskId: string) => void;
}
export async function restoreLastSession(deps: RestoreLastSessionDeps): Promise<void> {
try {
const lastSession = deps.sessionManager.loadLastActiveSession();
if (lastSession) {
deps.setChatHistory(lastSession.history);
deps.setCurrentTaskId(lastSession.taskId);
logInfo(`Restored last session: ${lastSession.taskId}`);
}
} catch (error) {
logError('Failed to restore last session. Starting fresh.', error);
}
}
export interface ExecuteActionTagsOnTextDeps {
executeActions: (aiMessage: string, rootPath: string, activeBrain: BrainProfile) => Promise<string[]>;
}
export async function executeActionTagsOnText(
deps: ExecuteActionTagsOnTextDeps,
aiMessage: string
): Promise<string[]> {
const cfg = getConfig();
const rootPath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|| cfg.localBrainPath
|| process.cwd();
const activeBrain = getActiveBrainProfile();
try {
return await deps.executeActions(aiMessage, rootPath, activeBrain);
} catch (e: any) {
logError('executeActionTagsOnText failed.', { error: e?.message ?? String(e) });
return [`❌ Action 실행 중 오류: ${e?.message ?? e}`];
}
}
export function syncBrain(brainDir: string): void {
try {
const { execSync } = require('child_process');
execSync(`git add .`, { cwd: brainDir });
execSync(`git commit -m "[Astra] Knowledge Update"`, { cwd: brainDir });
execSync(`git push`, { cwd: brainDir });
} catch (err) {
logError('Second Brain sync failed.', err);
}
}
+104
View File
@@ -0,0 +1,104 @@
import { getConfig } from '../../config';
import { logError, resolveEngine } from '../../utils';
import { estimateMessagesTokens, computeOutputBudget } from '../../lib/contextManager';
import { lmStudioSamplingFromConfig, lmStudioRespondExtrasFromConfig } from '../../lib/contextBuilders/lmStudioSampling';
import { AGENT_PROMPTS, type AgentRole, type AgentExecutorOptions, type ChatMessage } from '../../agent';
export interface CallRoleAgentDeps {
getAbortSignal: () => AbortSignal | undefined;
createStreamingRequest: (params: {
baseUrl: string;
modelName: string;
reqMessages: ChatMessage[];
temperature: number;
maxTokens?: number;
contextLength?: number;
}) => Promise<{ response: Response; engine: 'lmstudio' | 'ollama'; apiUrl: string }>;
options: AgentExecutorOptions;
}
export async function callRoleAgent(deps: CallRoleAgentDeps, role: AgentRole, prompt: string, modelName: string, options: any): Promise<string> {
const persona = AGENT_PROMPTS[role];
const { ollamaUrl, contextLength, maxOutputTokens, contextSafetyMargin, contextOverflowPolicy } = getConfig();
const messages: ChatMessage[] = [
{ role: 'system', content: persona },
{ role: 'user', content: prompt }
];
// Dynamic output cap so input + output stays within the context window.
const inputTokens = estimateMessagesTokens(messages);
const { maxOutputTokens: subMaxTokens } = computeOutputBudget(inputTokens, {
contextLength, maxOutputTokens, safetyMargin: contextSafetyMargin, minOutputTokens: 512,
});
const engine = resolveEngine(ollamaUrl);
let responseText = '';
if (engine === 'lmstudio' && deps.options.lmStudioStreamer) {
try {
const stream = deps.options.lmStudioStreamer.stream({
modelName,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
temperature: 0.3,
maxTokens: subMaxTokens,
contextOverflowPolicy,
...lmStudioSamplingFromConfig(),
...lmStudioRespondExtrasFromConfig(),
signal: deps.getAbortSignal(),
});
let subStopReason: string | undefined;
for await (const { token, stopReason } of stream) {
if (token) responseText += token;
if (stopReason) subStopReason = stopReason;
}
// Sub-agent answers that got cut mid-sentence corrupt the pipeline silently
// (Planner produces a half-step, Writer can't recover). Surface a warn log so
// the operator can raise subMaxTokens or pick a less aggressive output budget.
if (subStopReason && /maxPredicted|context|truncat/i.test(subStopReason)) {
logError('Sub-agent answer hit a generation limit.', {
role, model: modelName, stopReason: subStopReason,
chars: responseText.length, maxTokens: subMaxTokens,
});
}
return responseText;
} catch (err: any) {
if (err?.name === 'AbortError' || deps.getAbortSignal()?.aborted) return responseText;
logError('LM Studio SDK callAgent stream failed.', { role, error: err?.message ?? String(err) });
throw err;
}
}
const request = await deps.createStreamingRequest({
baseUrl: ollamaUrl,
modelName: modelName,
reqMessages: messages,
temperature: 0.3, // Use lower temperature for planning and research
maxTokens: subMaxTokens,
contextLength
});
const reader = request.response.body?.getReader();
if (!reader) throw new Error("Agent response body is not readable.");
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed === 'data: [DONE]') continue;
try {
const json = JSON.parse(trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed);
const content = json.choices?.[0]?.delta?.content || json.message?.content || '';
responseText += content;
} catch (e) { }
}
}
} finally {
try { reader.releaseLock(); } catch { /* already released */ }
}
return responseText;
}
+117
View File
@@ -0,0 +1,117 @@
import * as vscode from 'vscode';
import { findBrainFiles, getActiveBrainProfile, logError } from '../../utils';
import { getConfig } from '../../config';
import { AgentWorkflowManager } from '../../agents/AgentWorkflowManager';
import { ErrorTranslator } from '../../core/errorHandler';
import { StatusBarManager, AgentStatus } from '../../core/statusBar';
import { stripMarkdownFormatting } from '../../core/responseRecovery';
import type { AgentExecutorOptions, ChatMessage } from '../../agent';
export interface WorkflowDeps {
emitHistoryChanged: () => void;
chatHistory: ChatMessage[];
options: AgentExecutorOptions;
statusBarManager: StatusBarManager;
getWebview: () => vscode.Webview | undefined;
getAbortSignal: () => AbortSignal | undefined;
}
export async function executeMultiAgentWorkflow(
deps: WorkflowDeps,
prompt: string,
modelName: string,
options: any
) {
if (!deps.getWebview()) return;
// NOTE: 호출자 (AgentExecutor wrapper) 가 stop() + new AbortController() 를
// *먼저* 마쳐야 한다 — extracted fn 내부에서 stop 을 부르면 호출자가 막
// 만든 controller 가 즉시 폐기되기 때문. getAbortSignal() 은 그 새 controller 의
// signal 을 반환해야 함.
const signal = deps.getAbortSignal();
if (!signal) return;
const webview = deps.getWebview();
if (!webview) return;
deps.statusBarManager.updateStatus(AgentStatus.Thinking, 'Multi-Agent Workflow Running');
webview.postMessage({ type: 'streamStart' });
deps.options.onStreamLifecycle?.start();
try {
let brainContext = 'No specific context available';
try {
const config = getConfig();
const activeBrain = options.brainProfileId
? (config.brainProfiles.find((profile) => profile.id === options.brainProfileId) || getActiveBrainProfile())
: getActiveBrainProfile();
const brainFiles = findBrainFiles(activeBrain.localBrainPath);
brainContext = `Brain: ${activeBrain.name}, Files: ${brainFiles.length}`;
} catch (ctxErr) {
logError('Failed to load brain context for agents', ctxErr);
}
const selectedAgentContext = options.agentSkillContext
? `\nSelected Agent Reference:\n${options.agentSkillContext}`
: '';
const designerContext = options.designerContext
? `\nProject Chronicle Guard:\n${options.designerContext}`
: '';
// 워크플로우 매니저에게 설정 기반 실행 위임
// [Clean Stream] 단계 진행 메시지는 채팅 본문(streamChunk) 이 아닌 사이드바
// 상단의 workflowStage 인디케이터로만 표시한다 → "생각 단계가 본문에 계속 보임"
// 답답함 제거. 채팅 버블에는 최종 답변만 한 번에 들어간다.
const rawFinalReport = await AgentWorkflowManager.runStrictWorkflow(
prompt,
modelName,
`${brainContext}${selectedAgentContext}${designerContext}`,
signal,
(step, msg) => {
deps.getWebview()?.postMessage({
type: 'workflowStage',
value: { step, message: msg, done: step === '완료' || step === '오류' }
});
}
);
const wv2 = deps.getWebview();
if (signal.aborted || !wv2) return;
// [Plain Text Output] Synthesizer가 잘 따라줬어도 작은 모델은 `##` `**` 를 흘리는 경우가 있어
// 최종 후처리로 한 번 더 마커를 벗긴다. 채팅 history 에도 정제된 결과만 남겨 다음 턴 컨텍스트에서
// 마커가 재학습되는 일을 막는다.
const finalReport = getConfig().outputFormat === 'plain'
? stripMarkdownFormatting(rawFinalReport)
: rawFinalReport;
wv2.postMessage({ type: 'streamChunk', value: finalReport });
wv2.postMessage({ type: 'workflowStage', value: { step: '완료', message: '', done: true } });
wv2.postMessage({ type: 'streamEnd' });
deps.chatHistory.push({ role: 'assistant', content: finalReport });
deps.emitHistoryChanged();
deps.statusBarManager.updateStatus(AgentStatus.Success, 'Workflow Complete');
wv2.postMessage({ type: 'autoContinue', value: '✅ 모든 분석이 성공적으로 완료되었습니다.' });
} catch (error: any) {
// 어떤 종료 경로에서든 stage indicator 는 반드시 닫는다 — 안 닫으면 사이드바에 영원히 "③ 자기 검증..." 가 남는다.
deps.getWebview()?.postMessage({ type: 'workflowStage', value: { step: '완료', message: '', done: true } });
if (error.name === 'AbortError' || error.message?.includes('cancelled')) {
deps.statusBarManager.updateStatus(AgentStatus.Idle, 'Workflow Cancelled');
return;
}
const friendly = ErrorTranslator.translate(error);
logError('Workflow failed', error);
const wvErr = deps.getWebview();
wvErr?.postMessage({ type: 'autoContinue', value: '' });
wvErr?.postMessage({
type: 'error',
value: `### ${friendly.title}\n\n**상태:** ${friendly.message}\n\n**해결 방법:** ${friendly.action}`
});
deps.statusBarManager.updateStatus(AgentStatus.Idle, 'Error occurred');
} finally {
deps.options.onStreamLifecycle?.end();
}
}
+61
View File
@@ -0,0 +1,61 @@
import * as vscode from 'vscode';
import { logError, logInfo, resolveEngine } from '../../utils';
import { getConfig } from '../../config';
import type { ChatMessage } from '../../agent';
export interface CompressSummaryDeps {
context: vscode.ExtensionContext;
callNonStreaming: (params: {
baseUrl: string;
modelName: string;
engine: 'lmstudio' | 'ollama';
messages: ChatMessage[];
temperature: number;
maxTokens?: number;
contextLength?: number;
signal?: AbortSignal;
}) => Promise<{ text: string; stopReason?: string }>;
}
export async function compressSessionSummary(deps: CompressSummaryDeps, taskId: string, history: ChatMessage[]): Promise<void> {
const visible = history.filter((m) => !m.internal && (m.role === 'user' || m.role === 'assistant'));
if (visible.length < 3) return;
const cfg = getConfig();
const transcript = visible
.map((m) => `${m.role.toUpperCase()}: ${String(m.content).replace(/\s+/g, ' ').slice(0, 400)}`)
.join('\n\n');
const messages: ChatMessage[] = [
{
role: 'system',
content: [
'You compress chat transcripts into a 2-3 sentence summary.',
'Capture: (1) the user\'s topic or task, (2) the main decision or answer reached, (3) any open issue.',
'Reply in the user\'s primary language (mirror Korean ↔ English exactly as in the transcript).',
'Reply with ONLY the summary text. No headers, no quotes, no preamble.',
].join(' '),
internal: true,
},
{ role: 'user', content: `[TRANSCRIPT]\n${transcript}\n[END]` },
];
try {
const result = await deps.callNonStreaming({
baseUrl: cfg.ollamaUrl,
modelName: cfg.defaultModel,
engine: resolveEngine(cfg.ollamaUrl),
messages,
temperature: 0.3,
maxTokens: 256,
contextLength: cfg.contextLength,
});
const summary = (result.text || '').trim().replace(/^["'`]+|["'`]+$/g, '');
if (!summary || summary.length < 12) return;
const sessions = deps.context.globalState.get<any[]>('chat_sessions', []) || [];
const idx = sessions.findIndex((s) => String(s?.id) === String(taskId));
if (idx < 0) return;
sessions[idx].summary = summary;
await deps.context.globalState.update('chat_sessions', sessions);
logInfo('Session summary stored for medium-term recall.', { taskId, length: summary.length });
} catch (e: any) {
logError('Session summary compression failed.', { taskId, error: e?.message ?? String(e) });
}
}
+9 -2
View File
@@ -1,5 +1,6 @@
import { ChunkedWriter } from './factory';
import { ChunkedWriter, PersonaOverrides } from './factory';
import { AgentEngine, PipelineStage, AgentExecuteOptions } from '../lib/engine';
import { getConfig } from '../config';
/**
* Multi-agent .
@@ -20,7 +21,13 @@ export class AgentWorkflowManager {
signal: AbortSignal,
onProgress: (step: string, message: string) => void
): Promise<string> {
const writer = new ChunkedWriter(modelName);
// 사용자 config 의 polish persona override 가 있으면 ChunkedWriter 에 주입.
// 비어 있으면 기본 polish persona 사용.
const cfg = getConfig();
const overrides: PersonaOverrides | undefined = cfg.polishPersonaOverride
? { polish: cfg.polishPersonaOverride }
: undefined;
const writer = new ChunkedWriter(modelName, overrides);
const engine = new AgentEngine(writer);
const missionId = `mission_${Date.now()}`;
+51 -11
View File
@@ -110,6 +110,22 @@ export interface SectionOutline {
scope: string;
}
/**
* ChunkedWriter 4 persona * * .
*
* 의도: 사용자가 ChunkedWriter role
* (: 자기 polish , outline)
* wrap .
*
* persona (`DEFAULT_*_PERSONA`) .
*/
export interface PersonaOverrides {
outline?: string;
section?: string;
polish?: string;
direct?: string;
}
/**
* ChunkedWriter single-agent replacement for the old 5-stage pipeline.
*
@@ -137,15 +153,9 @@ export interface SectionOutline {
* abstraction loss. The only thing that changes is the per-call system
* prompt picked here based on `options.config.role`.
*/
export class ChunkedWriter extends BaseAgent {
/**
* Hard ceiling * config *. .
* `getConfig().chunkedMaxSections` (default 3).
* Astra Settings 1~10 , .
*/
static readonly MAX_SECTIONS_HARD_CEILING = 10;
// ─── Default personas (module-level exports — 외부에서 import / 부분 override 가능) ───
private readonly outlinePersona = `You are a concise editor planning the structure of a Korean answer.
export const DEFAULT_OUTLINE_PERSONA = `You are a concise editor planning the structure of a Korean answer.
Decide how many sections the answer needs. The exact upper bound (MAX_N) is given in the user message below never exceed it. Pick the *smallest* count that still covers the request well a short factual question should be 0-1 section, a meaty analysis up to MAX_N.
Output STRICTLY a JSON array of objects: \`[{"heading": "...", "scope": "..."}]\`. No prose, no fences, no leading text.
@@ -159,7 +169,7 @@ Output STRICTLY a JSON array of objects: \`[{"heading": "...", "scope": "..."}]\
If the user attached source content (article/code/log) the sections must cover *that content*, not analysis methodology.`;
private readonly sectionPersona = `You are writing ONE section of a longer Korean answer. You will be given:
export const DEFAULT_SECTION_PERSONA = `You are writing ONE section of a longer Korean answer. You will be given:
- the user's original request (possibly with attached content),
- this section's heading + scope,
- the full outline (for context only DO NOT write other sections),
@@ -173,7 +183,7 @@ Rules:
- If the user attached source content, cite from it; do not invent facts.
- Do NOT output the heading itself only the body of this section.`;
private readonly polishPersona = `You are the final editor producing the user-facing Korean answer from a sectioned draft.
export const DEFAULT_POLISH_PERSONA = `You are the final editor producing the user-facing Korean answer from a sectioned draft.
[Job]
1. Fix typos, broken markdown, inconsistent terminology.
@@ -220,7 +230,7 @@ B. **짧은 직답 (1~3문장 정도로 충분한 경우)**:
* 1 . outline section polish
* 3 LLM .
*/
private readonly directPersona = `You are answering a Korean user request in one shot. No outline, no drafting — just the final answer.
export const DEFAULT_DIRECT_PERSONA = `You are answering a Korean user request in one shot. No outline, no drafting — just the final answer.
Rules:
- / . "분석해보겠습니다" "좋은 질문입니다" .
@@ -231,6 +241,36 @@ Rules:
- (··) . .
- ·"Thinking:"·<think> .`;
export class ChunkedWriter extends BaseAgent {
/**
* Hard ceiling * config *. .
* `getConfig().chunkedMaxSections` (default 3).
* Astra Settings 1~10 , .
*/
static readonly MAX_SECTIONS_HARD_CEILING = 10;
/**
* persona. DEFAULT_*_PERSONA, constructor overrides .
* private protected subclass .
*/
protected readonly outlinePersona: string;
protected readonly sectionPersona: string;
protected readonly polishPersona: string;
protected readonly directPersona: string;
/**
* @param modelName Ollama / LM Studio
* @param overrides 4 persona ( default ).
* plugin / .
*/
constructor(modelName: string, overrides?: PersonaOverrides) {
super(modelName);
this.outlinePersona = overrides?.outline ?? DEFAULT_OUTLINE_PERSONA;
this.sectionPersona = overrides?.section ?? DEFAULT_SECTION_PERSONA;
this.polishPersona = overrides?.polish ?? DEFAULT_POLISH_PERSONA;
this.directPersona = overrides?.direct ?? DEFAULT_DIRECT_PERSONA;
}
async execute(input: string, context?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
const role = (options?.config?.role as string | undefined) || 'section';
switch (role) {
+12
View File
@@ -168,6 +168,17 @@ export interface IAgentConfig {
* ("6회 이상은 과하다") . 1~10 clamp.
*/
chunkedMaxSections: number;
/**
* ChunkedWriter polish persona override .
* polish persona (DEFAULT_POLISH_PERSONA) .
* system prompt polish · .
*
* 의도: 사용자가 (: 격식체·· · )
* . Settings textarea .
*
* / default .
*/
polishPersonaOverride: string;
// ─── Stream 표시 ───
/**
* .
@@ -324,6 +335,7 @@ export function getConfig(): IAgentConfig {
)),
chunkedSwitchTokens: Math.max(1000, cfg.get<number>('chunkedSwitchTokens', 50000)),
chunkedMaxSections: Math.max(1, Math.min(10, cfg.get<number>('chunkedMaxSections', 3))),
polishPersonaOverride: (cfg.get<string>('polishPersonaOverride', '') || '').trim(),
liveStreamTokens: cfg.get<boolean>('liveStreamTokens', true),
outputFormat: ((): 'plain' | 'markdown' => {
const v = (cfg.get<string>('outputFormat', 'plain') || 'plain').trim().toLowerCase();
+36 -9
View File
@@ -6,9 +6,27 @@ import { logInfo } from '../utils';
*
* Includes timeout protection to prevent indefinite lock-waiting,
* and proper cleanup on acquisition failure.
*
* v2 (unique token)
* cleanup `this.locks.get(resourceId) === previousLock.then(() => newLock)`
* *Promise * , `.then(...)` * Promise
* instance* * false* cleanup . release
* `delete(resourceId)` latest , resource
* task entry silent race.
*
* entry symbol token , cleanup / release * token
* Map latest * .
*/
interface LockEntry {
/** Previous lock chain + new lock — await 대상. */
promise: Promise<void>;
/** 이 entry 의 고유 식별자 — cleanup 시 자기 것만 지우게. */
token: symbol;
}
export class AsyncLockManager {
private locks: Map<string, Promise<void>> = new Map();
private locks: Map<string, LockEntry> = new Map();
private static readonly DEFAULT_TIMEOUT_MS = 30_000;
/**
@@ -19,25 +37,31 @@ export class AsyncLockManager {
* @returns A release function that MUST be called when the work is done (use try/finally).
*/
public async acquire(resourceId: string, timeoutMs: number = AsyncLockManager.DEFAULT_TIMEOUT_MS): Promise<() => void> {
const previousLock = this.locks.get(resourceId) || Promise.resolve();
const previousEntry = this.locks.get(resourceId);
const previousPromise = previousEntry?.promise ?? Promise.resolve();
const token = Symbol(`lock:${resourceId}`);
let release: () => void;
const newLock = new Promise<void>((resolve) => {
const newPromise = new Promise<void>((resolve) => {
release = resolve;
});
this.locks.set(resourceId, previousLock.then(() => newLock));
const myEntry: LockEntry = {
promise: previousPromise.then(() => newPromise),
token,
};
this.locks.set(resourceId, myEntry);
// Wait for previous lock with a timeout to prevent deadlocks
// Wait for previous lock with a timeout to prevent deadlocks.
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`Lock acquisition timed out for resource: ${resourceId}`)), timeoutMs);
});
try {
await Promise.race([previousLock, timeoutPromise]);
await Promise.race([previousPromise, timeoutPromise]);
} catch (error) {
// Clean up the dangling lock on timeout
if (this.locks.get(resourceId) === previousLock.then(() => newLock)) {
// 내 token 이 아직 latest 면만 정리 — newer entry 가 있으면 그 task 가 관리.
if (this.locks.get(resourceId)?.token === token) {
this.locks.delete(resourceId);
}
release!();
@@ -49,8 +73,11 @@ export class AsyncLockManager {
return () => {
logInfo(`Lock released for: ${resourceId}`);
release();
// Clean up the Map entry if this is the latest lock
// 내 token 이 latest 일 때만 Map 정리 — newer entry 가 등록돼 있으면
// 그 task 가 자기 release 시 정리. 옛 코드는 무조건 delete 해서 race.
if (this.locks.get(resourceId)?.token === token) {
this.locks.delete(resourceId);
}
};
}
+37 -944
View File
File diff suppressed because it is too large Load Diff
+214
View File
@@ -0,0 +1,214 @@
import * as vscode from 'vscode';
/**
* Google Calendar (iCal ) .
*
* :
* 1. "연결 해제 / URL 변경 / 지금 새로고침 / 취소"
* 2. 셋업: Google Calendar iCal URL
* 3. globalState
*
* OAuth read-only iCal 3, .
*/
export async function runConnectGoogleCalendarIcal(context: vscode.ExtensionContext) {
const { readCalendarConfig, writeCalendarConfig, refreshCalendarCache } =
await import('../features/calendar');
const cur = readCalendarConfig(context);
if (cur.icalUrl) {
const choice = await vscode.window.showInformationMessage(
`📅 이미 연결됨${cur.lastFetchAt ? ` (마지막 동기화: ${cur.lastFetchAt.slice(0, 16)})` : ''}`,
{ modal: false },
'지금 새로고침',
'URL 변경',
'연결 해제',
'취소',
);
if (!choice || choice === '취소') return;
if (choice === '지금 새로고침') {
const r = await refreshCalendarCache(context);
if (r.ok) vscode.window.showInformationMessage(`📅 ${r.count}개 일정 동기화 완료.`);
else vscode.window.showErrorMessage(r.error || '새로고침 실패');
return;
}
if (choice === '연결 해제') {
await writeCalendarConfig(context, { icalUrl: '', lastFetchAt: undefined });
vscode.window.showInformationMessage('Google Calendar 연결 해제됨. 캐시 파일은 그대로 둡니다.');
return;
}
// URL 변경 → 아래 입력 흐름으로 fall through
} else {
const intro = await vscode.window.showInformationMessage(
'📅 Google Calendar 연결 (읽기 전용, 셋업 3분)\n\n비공개 iCal URL 1개만 있으면 됩니다. OAuth 없음.\n\n계속할까요?',
{ modal: true },
'시작',
'Google Calendar 설정 페이지 열기',
'취소',
);
if (!intro || intro === '취소') return;
if (intro === 'Google Calendar 설정 페이지 열기') {
await vscode.env.openExternal(
vscode.Uri.parse('https://calendar.google.com/calendar/u/0/r/settings'),
);
const back = await vscode.window.showInformationMessage(
'1. 왼쪽에서 본인 캘린더 클릭 → "캘린더 통합" 섹션\n2. "비공개 주소(iCal 형식)" 옆 복사 버튼 클릭\n3. URL 복사한 뒤 ↓',
{ modal: true },
'복사함 — URL 붙여넣기',
'취소',
);
if (back !== '복사함 — URL 붙여넣기') return;
}
}
const url = await vscode.window.showInputBox({
title: 'Google Calendar 비공개 iCal URL',
prompt: 'calendar.google.com/calendar/ical/.../private-XXX/basic.ics 형태',
placeHolder: 'https://calendar.google.com/calendar/ical/...',
value: cur.icalUrl,
password: true,
ignoreFocusOut: true,
validateInput: (v) => {
const t = (v || '').trim();
if (!t) return '비어있어요';
if (!/^https?:\/\//.test(t)) return 'http:// 또는 https:// 로 시작해야 합니다.';
return null;
},
});
if (!url) return;
await writeCalendarConfig(context, { icalUrl: url.trim() });
const r = await refreshCalendarCache(context);
if (r.ok) {
vscode.window.showInformationMessage(
`✅ 연결 완료 — ${r.count}개 일정을 회사 컨텍스트에 동기화했습니다.\n\n이제 기업 모드에서 모든 에이전트가 다가오는 일정을 자동으로 참고합니다.`,
);
} else {
vscode.window.showErrorMessage(
`URL 저장은 됐지만 첫 새로고침 실패: ${r.error}\n\nURL 이 정확한지 다시 확인해주세요.`,
);
}
}
/**
* Google Calendar OAuth () .
*
* iCal agent ** .
* 5~10분: Google Cloud Console OAuth Client ID/Secret
* loopback OAuth refresh token globalState .
*/
export async function runConnectGoogleCalendarOAuth(context: vscode.ExtensionContext) {
const { readCalendarConfig, writeCalendarConfig, runOAuthLoopback, fetchUserEmail } =
await import('../features/calendar');
const cur = readCalendarConfig(context);
const already = !!(cur.clientId && cur.clientSecret && cur.refreshToken);
if (already) {
const choice = await vscode.window.showInformationMessage(
`✅ 이미 OAuth 연결됨${cur.connectedAs ? ` (${cur.connectedAs})` : ''}`,
{ modal: false },
'재연결',
'연결 해제',
'취소',
);
if (!choice || choice === '취소') return;
if (choice === '연결 해제') {
await writeCalendarConfig(context, {
clientId: undefined, clientSecret: undefined, refreshToken: undefined,
accessToken: undefined, accessTokenExpiresAt: undefined,
connectedAs: undefined, connectedAt: undefined,
});
vscode.window.showInformationMessage(
'OAuth 연결 해제. https://myaccount.google.com/permissions 에서도 권한을 직접 회수할 수 있습니다.',
);
return;
}
// 재연결 → 아래 flow
} else {
const intro = await vscode.window.showInformationMessage(
'📅 Google Calendar 쓰기 연결 (OAuth, 5~10분)\n\n회의록을 받으면 agent 가 자동으로 일정을 생성하게 됩니다.\n\n1단계: Google Cloud Console 에서 OAuth Client ID 발급 (수동 클릭, 가이드 따라)\n2단계: ID + Secret 붙여넣기\n3단계: 브라우저 로그인',
{ modal: true },
'시작',
'Cloud Console 먼저 열기',
'취소',
);
if (!intro || intro === '취소') return;
if (intro === 'Cloud Console 먼저 열기') {
await vscode.env.openExternal(vscode.Uri.parse('https://console.cloud.google.com/apis/credentials'));
const back = await vscode.window.showInformationMessage(
'아래 절차 마치고 돌아오세요:\n\n1. 새 프로젝트 만들기 (또는 기존)\n2. APIs & Services → Library → "Google Calendar API" 활성화\n3. OAuth 동의 화면 — External, Test users 에 본인 이메일\n4. Credentials → Create OAuth 2.0 Client ID → "Desktop app"\n5. Client ID + Client Secret 복사',
{ modal: true },
'다 됐음 →',
'취소',
);
if (back !== '다 됐음 →') return;
}
}
// Settings 에 이미 채워져 있으면 그대로 쓰겠냐고 물어봄 — 매번 똑같은 값 다시 입력하기 귀찮음.
const haveBoth = !!(cur.clientId && cur.clientSecret);
let clientId: string | undefined = cur.clientId;
let clientSecret: string | undefined = cur.clientSecret;
if (haveBoth) {
const useExisting = await vscode.window.showInformationMessage(
`Settings (g1nation.google) 에 이미 Client ID/Secret 이 있습니다.\nID: ${cur.clientId!.slice(0, 20)}\n\n이 값으로 OAuth 진행할까요?`,
{ modal: false },
'예 (Settings 값 사용)',
'아니오 (새로 입력)',
'취소',
);
if (useExisting === '취소' || !useExisting) return;
if (useExisting === '아니오 (새로 입력)') {
clientId = undefined;
clientSecret = undefined;
}
}
if (!clientId) {
clientId = await vscode.window.showInputBox({
title: 'Google OAuth Client ID',
prompt: 'Credentials 페이지에서 복사한 Client ID — 자동으로 Settings(g1nation.google.clientId)에 저장됨',
placeHolder: 'xxxxxxxx.apps.googleusercontent.com',
value: cur.clientId,
ignoreFocusOut: true,
validateInput: (v) => (v || '').trim() ? null : '비어있어요',
});
if (!clientId) return;
}
if (!clientSecret) {
clientSecret = await vscode.window.showInputBox({
title: 'Google OAuth Client Secret',
prompt: '같은 화면의 Client Secret — Settings(g1nation.google.clientSecret)에 저장됨',
placeHolder: 'GOCSPX-...',
value: cur.clientSecret,
password: true,
ignoreFocusOut: true,
validateInput: (v) => (v || '').trim() ? null : '비어있어요',
});
if (!clientSecret) return;
}
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: '🔐 Google 로그인 대기 중…',
cancellable: true,
}, async (progress, cancelToken) => {
progress.report({ message: '브라우저에서 Google 로그인 진행하세요 (최대 5분 대기)' });
const result = await runOAuthLoopback(clientId.trim(), clientSecret.trim(), cancelToken);
if (!result.ok) {
vscode.window.showErrorMessage(`OAuth 실패: ${result.error}`);
return;
}
const email = await fetchUserEmail(result.accessToken);
await writeCalendarConfig(context, {
clientId: clientId.trim(),
clientSecret: clientSecret.trim(),
refreshToken: result.refreshToken,
accessToken: result.accessToken,
accessTokenExpiresAt: result.expiresAt,
calendarId: cur.calendarId ?? 'primary',
defaultDurationMinutes: cur.defaultDurationMinutes ?? 60,
connectedAs: email,
connectedAt: new Date().toISOString(),
});
vscode.window.showInformationMessage(
`✅ Google Calendar 쓰기 연결 완료!${email ? ' (' + email + ')' : ''}\n\n이제 회의록을 보내거나 due 가 있는 작업을 알려주면 agent 가 자동으로 일정을 생성합니다.`,
);
});
}
+60
View File
@@ -0,0 +1,60 @@
import * as vscode from 'vscode';
import { buildApiUrl, logInfo, logError } from '../utils';
/**
* Astra LM Studio/Ollama URL + .
* extension.ts `activate()` . URL
* skip.
*/
export async function runInitialSetup(context: vscode.ExtensionContext) {
// 이미 사용자가 URL을 설정했다면 자동 감지를 스킵
const existingUrl = vscode.workspace.getConfiguration('g1nation').get<string>('ollamaUrl');
if (existingUrl && existingUrl.trim()) {
context.globalState.update('setupComplete', true);
logInfo('Initial setup skipped: ollamaUrl already configured.', { existingUrl });
return;
}
try {
let engineName = '';
let modelName = '';
try {
const res = await fetch(buildApiUrl('http://127.0.0.1:1234', 'lmstudio', 'models'), { signal: AbortSignal.timeout(2000) });
const data = await res.json() as any;
if (data?.data?.length > 0) {
engineName = 'LM Studio';
modelName = data.data[0].id;
await vscode.workspace.getConfiguration('g1nation').update('ollamaUrl', 'http://127.0.0.1:1234', vscode.ConfigurationTarget.Global);
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', modelName, vscode.ConfigurationTarget.Global);
logInfo('Initial setup detected LM Studio.', { modelName });
}
} catch (err) {
logInfo('Initial setup could not reach LM Studio.', err);
}
if (!engineName) {
try {
const res = await fetch('http://127.0.0.1:11434/api/tags', { signal: AbortSignal.timeout(2000) });
const data = await res.json() as any;
if (data?.models?.length > 0) {
engineName = 'Ollama';
modelName = data.models[0].name;
await vscode.workspace.getConfiguration('g1nation').update('ollamaUrl', 'http://127.0.0.1:11434', vscode.ConfigurationTarget.Global);
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', modelName, vscode.ConfigurationTarget.Global);
logInfo('Initial setup detected Ollama.', { modelName });
}
} catch (err) {
logInfo('Initial setup could not reach Ollama.', err);
}
}
context.globalState.update('setupComplete', true);
if (engineName) {
vscode.window.showInformationMessage(`Setup Complete: ${engineName} detected with model ${modelName}`);
}
} catch (e) {
logError('Initial setup failed.', e);
context.globalState.update('setupComplete', true);
}
}
+46
View File
@@ -0,0 +1,46 @@
import * as vscode from 'vscode';
import { openKnowledgeMapEditor } from '../skills/agentKnowledgeMap';
import { createLessonCard, manageLessons } from './lessons';
import type { AgentExecutor } from '../agent';
/**
* Experience Memory knowledge map / lesson cards CRUD entrypoint.
* activate() 4 command . `fromConversation`
* agent history deps . stateless.
*
* agent getter activate() agent **
* commands undefined . getter
* .
*/
export interface LessonCommandsDeps {
getAgent: () => AgentExecutor;
}
export function registerLessonCommands(deps: LessonCommandsDeps): vscode.Disposable[] {
return [
vscode.commands.registerCommand('g1nation.skills.editKnowledgeMap', async () => {
await openKnowledgeMapEditor();
}),
// Experience Memory — create / browse lesson cards in the active brain.
vscode.commands.registerCommand('g1nation.lesson.create', () => createLessonCard()),
vscode.commands.registerCommand('g1nation.lesson.fromConversation', () => {
// Pre-fill the Situation section from the most recent user request + assistant reply.
const history = deps.getAgent().getHistory().filter((m: any) => !m.internal);
const lastUser = [...history].reverse().find((m: any) => m.role === 'user');
const lastAssistant = [...history].reverse().find((m: any) => m.role === 'assistant');
if (!lastUser && !lastAssistant) {
vscode.window.showInformationMessage('현재 대화 내용이 없습니다. 먼저 대화를 한 뒤 사용하세요. (빈 교훈을 만들려면 "Astra: New Lesson" 사용)');
return;
}
const clip = (s: any, n: number) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n) + '…' : t; };
const situation = [
lastUser ? `요청: ${clip(lastUser.content, 600)}` : '',
lastAssistant ? `Astra 답변(요약): ${clip(lastAssistant.content, 800)}` : '',
'',
'<위 작업에서 무엇이 잘못됐거나 위험했는지를 아래 Mistake/Root Cause 에 적으세요>',
].filter(Boolean).join('\n');
return createLessonCard(situation);
}),
vscode.commands.registerCommand('g1nation.lesson.manage', () => manageLessons()),
];
}
+135
View File
@@ -0,0 +1,135 @@
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import { findBrainFiles, getActiveBrainProfile, openInEditorGroup } from '../utils';
import { getBrainTokenIndex } from '../retrieval';
import { lessonTemplate, lessonSlug, parseLessonFrontmatter, normalizeLessonTitle, bumpLessonOccurrences } from '../retrieval/lessonHelpers';
/**
* Experience Memory lesson card ** (create / manage).
*
* 코드: extension.ts `activate()` nested function 3 (listLessonFiles,
* createLessonCard, manageLessons). `activate()`
* ( module-level import ) nested .
* (a) extension.ts activate() , (b) lesson
* , (c) (: chat handler) .
*/
/** All lesson/playbook/qa-finding cards in the active brain. Uses the brain index for the lesson-kind
* filter (cheap when warm), then reads only those few files for their frontmatter title/occurrences. */
function listLessonFiles(brainDir: string): Array<{ filePath: string; rel: string; title: string; kind: string; occurrences: number }> {
const out: Array<{ filePath: string; rel: string; title: string; kind: string; occurrences: number }> = [];
let files: string[] = [];
try { files = findBrainFiles(brainDir); } catch { return out; }
for (const d of getBrainTokenIndex(brainDir, files)) {
if (!d.kind) continue;
let content = '';
try { content = fs.readFileSync(d.filePath, 'utf8').slice(0, 4000); } catch { continue; }
const fm = parseLessonFrontmatter(content);
out.push({ filePath: d.filePath, rel: d.relativePath, title: (fm.title || d.title).trim(), kind: d.kind, occurrences: fm.occurrences ?? 1 });
}
return out.sort((a, b) => a.rel.localeCompare(b.rel));
}
/** Shared lesson-card creator used by the lesson commands. Dedup-merges into an existing lesson with the same title. */
export async function createLessonCard(situation?: string): Promise<void> {
const brain = getActiveBrainProfile();
const brainDir = brain?.localBrainPath;
if (!brainDir || !path.isAbsolute(brainDir)) {
vscode.window.showErrorMessage('활성 Second Brain 경로가 설정되지 않았습니다. Settings에서 localBrainPath / brainProfiles를 먼저 설정하세요.');
return;
}
const title = (await vscode.window.showInputBox({
title: 'New Lesson — Experience Memory',
prompt: '이 교훈의 제목 (예: "Telegram 원격 실행은 allowlist 필수")',
placeHolder: '한 줄 요약 — 다음에 같은 실수를 안 하려면 뭘 기억해야 하나',
ignoreFocusOut: true,
}))?.trim();
if (!title) return;
const today = new Date().toISOString().slice(0, 10);
// Dedup-merge: a recurring mistake should get LOUDER (occurrences++), not spawn a duplicate card.
const norm = normalizeLessonTitle(title);
const existing = norm ? listLessonFiles(brainDir).find((l) => normalizeLessonTitle(l.title) === norm) : undefined;
if (existing) {
const pick = await vscode.window.showInformationMessage(
`이미 같은 제목의 교훈이 있습니다: "${existing.title}" (occurrences: ${existing.occurrences}). 갱신할까요?`,
{ modal: false },
'갱신 (occurrences +1)', '새로 만들기',
);
if (!pick) return;
if (pick === '갱신 (occurrences +1)') {
try {
const cur = fs.readFileSync(existing.filePath, 'utf8');
fs.writeFileSync(existing.filePath, bumpLessonOccurrences(cur, today), 'utf8');
} catch (e: any) {
vscode.window.showErrorMessage(`교훈 갱신 실패: ${e?.message ?? e}`);
return;
}
await openInEditorGroup(existing.filePath);
vscode.window.showInformationMessage(`기존 교훈을 갱신했습니다 (occurrences: ${existing.occurrences + 1}). 필요하면 내용을 보강하세요.`);
return;
}
// else fall through and create a new one
}
const dir = path.join(brainDir, 'lessons');
try { fs.mkdirSync(dir, { recursive: true }); } catch { /* fall through to error below */ }
let filePath = path.join(dir, `${today}-${lessonSlug(title)}.md`);
let n = 2;
while (fs.existsSync(filePath)) { filePath = path.join(dir, `${today}-${lessonSlug(title)}-${n++}.md`); }
try {
fs.writeFileSync(filePath, lessonTemplate(title, today, situation), 'utf8');
} catch (e: any) {
vscode.window.showErrorMessage(`교훈 파일 생성 실패: ${e?.message ?? e}`);
return;
}
await openInEditorGroup(filePath);
vscode.window.showInformationMessage('교훈 카드를 만들었습니다. 내용을 채워 저장하면 다음 유사 작업 시 자동으로 체크리스트에 들어갑니다.');
}
/** Browse lesson cards: open one, or delete one (trash button). Also the "manage" surface for ignoring bad lessons. */
export async function manageLessons(): Promise<void> {
const brain = getActiveBrainProfile();
const brainDir = brain?.localBrainPath;
if (!brainDir || !path.isAbsolute(brainDir)) {
vscode.window.showErrorMessage('활성 Second Brain 경로가 설정되지 않았습니다.');
return;
}
const lessons = listLessonFiles(brainDir);
if (lessons.length === 0) {
const make = await vscode.window.showInformationMessage('아직 교훈 카드가 없습니다.', '새 교훈 만들기');
if (make) await createLessonCard();
return;
}
const deleteBtn: vscode.QuickInputButton = { iconPath: new vscode.ThemeIcon('trash'), tooltip: '이 교훈 삭제' };
const qp = vscode.window.createQuickPick<vscode.QuickPickItem & { _file: string }>();
qp.title = 'Lessons — Experience Memory';
qp.placeholder = '교훈을 선택하면 열립니다. 휴지통 아이콘으로 삭제. (삭제 = 더 이상 주입 안 됨)';
qp.items = lessons.map((l) => ({
label: `$(${l.kind === 'playbook' ? 'book' : l.kind === 'qa-finding' ? 'bug' : 'lightbulb'}) ${l.title}`,
description: l.occurrences > 1 ? `×${l.occurrences}` : '',
detail: l.rel,
buttons: [deleteBtn],
_file: l.filePath,
}));
qp.onDidTriggerItemButton(async (e) => {
const file = e.item._file;
const ok = await vscode.window.showWarningMessage(`교훈 "${e.item.label}" 을(를) 삭제할까요?`, { modal: true }, '삭제');
if (ok === '삭제') {
try { fs.unlinkSync(file); } catch (err: any) { vscode.window.showErrorMessage(`삭제 실패: ${err?.message ?? err}`); return; }
qp.items = qp.items.filter((it) => it._file !== file);
vscode.window.showInformationMessage('교훈을 삭제했습니다.');
if (qp.items.length === 0) qp.hide();
}
});
qp.onDidAccept(async () => {
const sel = qp.selectedItems[0];
qp.hide();
if (sel) {
await openInEditorGroup(sel._file);
}
});
qp.onDidHide(() => qp.dispose());
qp.show();
}
+136
View File
@@ -0,0 +1,136 @@
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import type { SidebarChatProvider } from '../sidebarProvider';
import { runConnectGoogleCalendarIcal, runConnectGoogleCalendarOAuth } from './calendarSetup';
/**
* Provider architecture / company / calendar / devil.
* 4 `provider._X()`
* thin shell deps (`context` + `provider getter`).
*
* 코드: extension.ts `activate()` 100 inline command block.
* (a) activate() , (b) command / , (c)
* cluster (`registerXCommands(context, deps)`) .
*
* `provider` getter activate() provider
* commands , undefined . getter
* .
*/
export interface ProviderCommandsDeps {
/** activate() 의 `let provider` 를 호출 시점에 회수. registration 시점엔 undefined 일 수 있음. */
getProvider: () => SidebarChatProvider | undefined;
}
export function registerProviderCommands(
context: vscode.ExtensionContext,
deps: ProviderCommandsDeps,
): vscode.Disposable[] {
return [
// ── Project Architecture (Feature 2) ─────────────────────────────────
// Thin shells — 모든 state mutation 은 provider 가 갖고 있게 (chip / watcher).
vscode.commands.registerCommand('g1nation.architecture.refresh', async () => {
const provider = deps.getProvider();
if (!provider) return;
await provider._refreshArchitecture();
vscode.window.showInformationMessage('Astra: Project architecture context refreshed.');
}),
vscode.commands.registerCommand('g1nation.architecture.detach', async () => {
const provider = deps.getProvider();
if (!provider) return;
await provider._detachArchitecture();
vscode.window.showInformationMessage('Astra: Project architecture auto-attach turned off.');
}),
vscode.commands.registerCommand('g1nation.architecture.attach', async () => {
const provider = deps.getProvider();
if (!provider) return;
await provider._attachArchitecture();
vscode.window.showInformationMessage('Astra: Project architecture auto-attach turned on.');
}),
vscode.commands.registerCommand('g1nation.architecture.open', async () => {
const provider = deps.getProvider();
if (!provider) return;
await provider._openArchitectureDoc();
}),
// Active subproject 재해상 — 사용자가 서브폴더 사이 editor 를 옮기면 chip 갱신.
// 400ms debounce — 빠른 editor flick 이 watcher 를 churn 시키지 않게.
// resync 자체는 idempotent — 활성 서브프로젝트가 안 바뀌면 noop.
(() => {
let timer: NodeJS.Timeout | undefined;
return vscode.window.onDidChangeActiveTextEditor(() => {
const provider = deps.getProvider();
if (!provider) return;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
provider._sendArchitectureStatus().catch(() => { /* chip 은 best-effort */ });
}, 400);
});
})(),
// ── 1인 기업 (Company) Mode ──────────────────────────────────────────
vscode.commands.registerCommand('g1nation.company.toggle', async () => {
const provider = deps.getProvider();
if (!provider) return;
const { readCompanyState, setCompanyEnabled } = await import('../features/company');
const cur = readCompanyState(context);
const next = await setCompanyEnabled(context, !cur.enabled);
await provider._sendCompanyStatus();
vscode.window.showInformationMessage(`Astra: 1인 기업 모드 ${next.enabled ? 'ON' : 'OFF'}`);
}),
vscode.commands.registerCommand('g1nation.company.manage', async () => {
const provider = deps.getProvider();
if (!provider) return;
await vscode.commands.executeCommand('g1nation-v2-view.focus');
provider._view?.webview.postMessage({ type: 'openCompanyManageOverlay' });
await provider._sendCompanyAgents();
await provider._sendCompanyResumable();
}),
vscode.commands.registerCommand('g1nation.company.openSessions', async () => {
const { resolveCompanyBase } = await import('../features/company');
const base = resolveCompanyBase(context);
const target = path.join(base, 'sessions');
try {
if (!fs.existsSync(target)) fs.mkdirSync(target, { recursive: true });
await vscode.env.openExternal(vscode.Uri.file(target));
} catch (e: any) {
vscode.window.showErrorMessage(`Sessions 폴더 열기 실패: ${e?.message ?? e}`);
}
}),
vscode.commands.registerCommand('g1nation.company.pixelOffice.open', () => {
// 사이드바 mini 패널과 별도로 editor area 에 전체 사무실 뷰. 같은
// pixelOfficeUpdate 스트림 공유 → 백엔드 변경 최소.
deps.getProvider()?.openPixelOfficePanel();
}),
// ── Google Calendar (iCal 읽기 / OAuth 쓰기) ────────────────────────
vscode.commands.registerCommand('g1nation.calendar.connect', async () => {
await runConnectGoogleCalendarIcal(context);
}),
vscode.commands.registerCommand('g1nation.calendar.refresh', async () => {
const { refreshCalendarCache } = await import('../features/calendar');
const r = await refreshCalendarCache(context);
if (r.ok) {
vscode.window.showInformationMessage(`📅 캘린더 ${r.count}개 일정을 회사 컨텍스트에 동기화했습니다.`);
} else {
vscode.window.showErrorMessage(r.error || 'Calendar 새로고침 실패');
}
}),
vscode.commands.registerCommand('g1nation.calendar.connectOAuth', async () => {
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} 비활성화됨.`,
);
}),
];
}
+50
View File
@@ -0,0 +1,50 @@
import * as vscode from 'vscode';
import { FileSystemProjectScaffolder } from '../scaffolder/projectScaffolder';
import type { ProjectTemplateId } from '../scaffolder/templates';
/**
* Project Scaffolder Astra Developer (`g1nation.scaffoldProject`).
*
* activate() inline ~35 wizard . scaffolder
* 1 new
* .
*/
export function registerScaffoldCommand(): vscode.Disposable {
const scaffolder = new FileSystemProjectScaffolder();
return vscode.commands.registerCommand('g1nation.scaffoldProject', async () => {
const folders = vscode.workspace.workspaceFolders;
if (!folders || folders.length === 0) {
vscode.window.showErrorMessage('워크스페이스 폴더를 먼저 여세요.');
return;
}
const name = await vscode.window.showInputBox({
placeHolder: '프로젝트 이름 (영문/숫자/_/-, 2~40자)',
prompt: 'Astra가 워크스페이스 안에 만들 프로젝트 폴더 이름',
validateInput: (v) => /^[a-zA-Z0-9_-]{2,40}$/.test(v.trim()) ? null : '영문/숫자/_/- 만, 2~40자',
});
if (!name) return;
const picked = await vscode.window.showQuickPick(
scaffolder.listTemplates().map(t => ({ label: t.label, detail: t.detail, id: t.id })),
{ placeHolder: '템플릿 선택' }
);
if (!picked) return;
const result = await scaffolder.scaffold({
name: name.trim(),
template: picked.id as ProjectTemplateId,
rootDir: folders[0].uri.fsPath,
});
if (!result.ok) {
vscode.window.showErrorMessage(`프로젝트 생성 실패: ${result.error}`);
return;
}
const action = await vscode.window.showInformationMessage(
`${name} 생성 완료 — ${result.projectPath}`,
'폴더 열기',
'닫기'
);
if (action === '폴더 열기') {
await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(result.projectPath));
}
});
}
+64
View File
@@ -0,0 +1,64 @@
import * as vscode from 'vscode';
import { SettingsPanelProvider } from '../features/settings/settingsPanelProvider';
import { clearBrainTokenIndex } from '../retrieval';
import { TELEGRAM_TOKEN_SECRET_KEY } from './telegramCommands';
import type { TelegramBot } from '../integrations/telegram/telegramBot';
import type { TelegramHttpClient } from '../integrations/telegram/telegramClient';
export interface SettingsSetupDeps {
telegramClient: TelegramHttpClient;
telegramBot: TelegramBot;
}
/**
* Astra Settings + listener / . activate()
* inline ~40 (panel + 3 listener + 2 command) .
*
* panel `openAsPanel()` .
* disposable `context.subscriptions.push(...)` .
*
* - config change listener `g1nation.*` UI refresh
* - brain config change listener `brainProfiles` / `activeBrainId`
* in-memory (stale path )
* - secrets change listener Telegram SecretStorage UI refresh
* - g1nation.settings.focus
* - g1nation.settings.diagnose (Set )
*/
export function setupSettingsPanel(
context: vscode.ExtensionContext,
deps: SettingsSetupDeps,
): { settingsPanel: SettingsPanelProvider; disposables: vscode.Disposable[] } {
const settingsPanel = new SettingsPanelProvider({
extensionUri: context.extensionUri,
context,
secrets: context.secrets,
telegramClient: deps.telegramClient,
telegramBot: deps.telegramBot,
});
const disposables: vscode.Disposable[] = [
vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration('g1nation')) void settingsPanel.refresh();
}),
vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration('g1nation.brainProfiles')
|| e.affectsConfiguration('g1nation.activeBrainId')) {
clearBrainTokenIndex();
}
}),
context.secrets.onDidChange((e) => {
if (e.key === TELEGRAM_TOKEN_SECRET_KEY) void settingsPanel.refresh();
}),
vscode.commands.registerCommand('g1nation.settings.focus', () => settingsPanel.focus()),
vscode.commands.registerCommand('g1nation.settings.diagnose', async () => {
try {
await settingsPanel.focus();
vscode.window.showInformationMessage('Astra Settings 패널이 열렸습니다. 사이드바 Settings 항목을 확인하세요.');
} catch (e: any) {
vscode.window.showErrorMessage(`Settings 패널 열기 실패 (확장 reload가 필요할 수 있음): ${e?.message ?? e}`);
}
}),
];
return { settingsPanel, disposables };
}
+103
View File
@@ -0,0 +1,103 @@
import * as vscode from 'vscode';
import type { TelegramBot } from '../integrations/telegram/telegramBot';
import type { TelegramHttpClient } from '../integrations/telegram/telegramClient';
/** SecretStorage key for the bot token. Shared with Settings panel listener. */
export const TELEGRAM_TOKEN_SECRET_KEY = 'g1nation.telegram.botToken';
/**
* Single-element token cache shared by extension.ts and this module.
*
* `telegramClient` is constructed with `getToken: () => tokenStore.current`,
* so when this module updates `tokenStore.current` in response to a secrets
* change the client picks up the new value on its next HTTP call no need
* to rebuild the client.
*/
export interface TelegramTokenStore { current: string }
export interface TelegramCommandsDeps {
telegramBot: TelegramBot;
telegramClient: TelegramHttpClient;
tokenStore: TelegramTokenStore;
}
/**
* Telegram bot + . activate() inline 58
* (refresh helper + dispose hook + 2 listener + 3 command) .
*
* disposable `context.subscriptions.push(...)` .
*
* - refreshTelegramBot() config `telegram.enabled` + token start
* - dispose hook bot
* - config change listener `telegram.enabled` refresh
* - secrets change listener cache + refresh
* - g1nation.telegram.setBotToken InputBox
* - g1nation.telegram.clearBotToken
* - g1nation.telegram.testConnection `getMe`
*/
export function registerTelegramCommands(
context: vscode.ExtensionContext,
deps: TelegramCommandsDeps,
): vscode.Disposable[] {
const { telegramBot, telegramClient, tokenStore } = deps;
const refreshTelegramBot = async () => {
const enabled = vscode.workspace.getConfiguration('g1nation').get<boolean>('telegram.enabled', false);
const tokenPresent = !!tokenStore.current.trim();
if (enabled && tokenPresent) {
telegramBot.start();
} else if (telegramBot.isRunning()) {
await telegramBot.stop();
}
};
void refreshTelegramBot();
return [
{ dispose: () => { void telegramBot.stop(); } },
vscode.workspace.onDidChangeConfiguration(async (e) => {
if (e.affectsConfiguration('g1nation.telegram.enabled')) {
await refreshTelegramBot();
}
}),
context.secrets.onDidChange(async (e) => {
if (e.key !== TELEGRAM_TOKEN_SECRET_KEY) return;
tokenStore.current = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
await refreshTelegramBot();
}),
vscode.commands.registerCommand('g1nation.telegram.setBotToken', async () => {
const token = await vscode.window.showInputBox({
prompt: 'Telegram bot token (BotFather에서 발급, 형식: 123456:ABC...)',
placeHolder: '123456789:AA...',
password: true,
ignoreFocusOut: true,
validateInput: (v) => /^\d+:[A-Za-z0-9_-]{20,}$/.test((v || '').trim())
? null
: '형식이 올바르지 않습니다 (숫자ID:문자열).',
});
if (!token) return;
await context.secrets.store(TELEGRAM_TOKEN_SECRET_KEY, token.trim());
vscode.window.showInformationMessage(
'Telegram bot token이 저장되었습니다. settings에서 g1nation.telegram.enabled = true 로 켜세요.'
);
}),
vscode.commands.registerCommand('g1nation.telegram.clearBotToken', async () => {
await context.secrets.delete(TELEGRAM_TOKEN_SECRET_KEY);
vscode.window.showInformationMessage('Telegram bot token이 삭제되었습니다.');
}),
vscode.commands.registerCommand('g1nation.telegram.testConnection', async () => {
const token = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
if (!token) {
vscode.window.showErrorMessage('먼저 "Astra: Set Telegram Bot Token" 명령으로 토큰을 등록하세요.');
return;
}
try {
const me = await telegramClient.getMe();
vscode.window.showInformationMessage(
`Telegram 연결 성공: @${me.username || me.first_name} (id ${me.id})`
);
} catch (e: any) {
vscode.window.showErrorMessage(`Telegram 연결 실패: ${e?.message ?? e}`);
}
}),
];
}
+40 -1
View File
@@ -427,10 +427,21 @@ function _pauseManagedIntervals(){
function _resumeManagedIntervals(){
for(const rec of _managedIntervals){ if(rec.id===null){ rec.id=setInterval(rec.fn,rec.ms); } }
}
// 명시적 cleanup — webview iframe 이 destroy 되면 JS 컨텍스트 통째로 GC 되니
// 현실적 누수는 없지만, 미래에 같은 iframe 안에서 runtime 을 reinit 하는 코드
// 추가될 수 있으므로 *명시적* dispose 경로를 둔다. window 에 노출해서 panel
// 호스트 (sidebarProvider.openPixelOfficePanel) 측에서 명시 호출 가능.
function _disposeAllManagedIntervals(){
for(const rec of _managedIntervals){ if(rec.id!==null) clearInterval(rec.id); }
_managedIntervals.length=0;
}
(window).__astraOfficeDisposeIntervals=_disposeAllManagedIntervals;
document.addEventListener('visibilitychange',()=>{
if(document.hidden) _pauseManagedIntervals();
else _resumeManagedIntervals();
});
// pagehide / beforeunload — webview navigation 또는 panel dispose 시 fallback.
window.addEventListener('pagehide',_disposeAllManagedIntervals,{once:true});
_managedInterval(()=>{Object.keys(chars).forEach(k=>{const a=anim[k];if(a.mode==='walk'){a.frame=(a.frame+1)%5;setSprite(k,'walk',a.frame,a.dir)}else if(a.mode==='work'){a.frame=(a.frame+1)%4;setSprite(k,'work',a.frame)} });},286)
_managedInterval(()=>{Object.keys(chars).forEach(k=>{const a=anim[k];if(a.mode==='sit'){a.frame=(a.frame+1)%2;setSprite(k,'sit',a.frame)} });},700)
// ── 책상 회피 path planner ──
@@ -935,6 +946,12 @@ function routeBubble(b){
const role = roleMap[b?.agentId] || 'ceo';
bubble(role, b?.text || '');
}
// ⚠️ 새 phase 추가 시 다음 *세* 곳을 모두 갱신:
// 1. STATUS_COPY (이 객체) — label / note / tone
// 2. BANTER_SCRIPTS (위) — banter 시퀀스 (없으면 banter 없는 정적 phase)
// 3. PHASE_META.allPhases() 호출처 — 신규 phase 등장 시 fallback 검증
// 데이터 통합 (한 객체로 합치기) 은 BANTER 객체가 매우 커서 다음 라운드로. 지금은
// 통합된 *접근 API* (getPhaseMeta) 만 제공해 호출자가 두 객체를 직접 안 봐도 되게.
const STATUS_COPY = {
idle: { label:'대기 중', note:'새로운 작업 요청을 기다리고 있습니다.', tone:'neutral' },
intake: { label:'요청 수신', note:'요청을 읽고 작업 범위를 정리하고 있습니다.', tone:'neutral' },
@@ -948,8 +965,30 @@ const STATUS_COPY = {
error: { label:'주의 필요', note:'흐름을 멈춘 이슈를 확인해야 합니다.', tone:'danger' },
done: { label:'완료', note:'이번 작업 라운드가 정리되었습니다.', tone:'success' },
};
/**
* phase API STATUS_COPY BANTER_SCRIPTS * entry
* point* . .
*
* const meta = getPhaseMeta('executing');
* meta.label // '실행 중'
* meta.tone // 'neutral'
* meta.banter // 시퀀스 array 또는 null (banter 없는 정적 phase)
*
* phase idle fallback.
*/
function getPhaseMeta(phase){
const copy = STATUS_COPY[phase] || STATUS_COPY.idle;
return {
label: copy.label,
note: copy.note,
tone: copy.tone,
banter: BANTER_SCRIPTS[phase] || null,
};
}
function _statusMeta(status){
return STATUS_COPY[status] || STATUS_COPY.idle;
// 옛 _statusMeta 호출처 호환 — banter 까지 같이 반환해도 무해. 호출자가 label/note/tone 만 쓰면 banter 는 무시됨.
return getPhaseMeta(status);
}
function _pct(v){
return Math.max(0, Math.min(100, Math.round((typeof v === 'number' ? v : 0) * 100)));
+390 -70
View File
@@ -12,12 +12,20 @@
* - UX (researcher): (··)
*
* :
* - `name`
* - `role` ( )
* - `tagline` (UI )
* - `specialty` CEO가
* - `persona` · ()
* - `name` (UI )
* - `role` + (// )
* - `tagline` (UI , )
* - `specialty` CEO +
* - `persona` · + · ()
* . id는 (state ).
*
* (v2 ):
* 1. **** 8~15 . · .
* 2. ** ** "X 보면 먼저 Y 부터 확인" .
* 3. ** ** .
* 4. ** ** .
* 5. ** ** ( ) . agent.emoji
* UI .
*/
import { CompanyAgentDef } from './types';
@@ -25,149 +33,461 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
ceo: {
id: 'ceo',
name: '대표',
role: '대표 · 최고 의사결정자',
role: '대표 · 최고 의사결정자 (CEO / Chief Strategist)',
emoji: '🧭',
color: '#F8FAFC',
specialty: '오케스트레이션, 작업 분해, 우선순위 판단, 다음 액션 결정, 에이전트 분배, 의사결정 종합',
specialty: '오케스트레이션, 작업 분해, 우선순위 판단, 다음 액션 결정, 에이전트 분배, 의사결정 종합, 자원 배분 (사람·시간·예산), 트레이드오프 정리, 보고서 합성, 사장님 의도와 팀 산출물 사이의 간극 메우기',
tagline: '회사 전체의 방향과 우선순위를 정하고 일을 나눕니다',
roleCategory: 'ceo',
alwaysOn: true,
// CEO 는 시스템이 항상 켜고, 자체 응답 톤보다는 *분배 정확도* 가 가치라
// persona 가 비어 있다. promptAssets.CEO_PLANNER_PROMPT 가 직접 통제.
},
business: {
id: 'business',
name: '도윤',
role: '서비스 기획자 · Game/Service Planner',
role: '시니어 서비스 기획자 · PRD Lead (Game / Service Planner, 10년+)',
emoji: '📝',
color: '#F5C518',
specialty: '기능 명세서(PRD/기획서) 작성, 사용자 스토리, 유저 플로우, 화면 흐름, 시스템 다이어그램, 데이터 모델 정의(엔티티·필드), 게임플레이/콘텐츠 기획, 밸런싱 기획, 페르소나·시나리오, 정책 정의, 엣지 케이스 사전 정리',
tagline: '무엇을·왜 만들지 명세서로 풀어냅니다',
specialty: '기능 명세서(PRD/기획서) 작성, 사용자 스토리(As a / I want / So that), 유저 플로우, 화면 흐름·상태 다이어그램, 시스템 다이어그램, 데이터 모델 정의(엔티티·필드·관계), 게임플레이/콘텐츠 기획, 재화·레벨·드롭률 밸런싱, 페르소나·시나리오, 정책 정의(인증·결제·환불), 엣지 케이스 사전 정리, 인수 기준(Acceptance Criteria) 작성, 명세서 → 디자이너·개발자 핸드오프 준비',
tagline: '"왜·누구·성공의 정의"부터 묻고 명세서로 떨어뜨립니다',
roleCategory: 'planner',
persona: '제품과 사용자 양쪽을 같이 보는 서비스 기획자. "이 기능을 왜 만들죠?·누구의 어떤 문제를 푸나요?·성공의 정의는?"부터 묻고 명세서로 떨어뜨립니다. 모호한 표현(잘·간단·예쁘게) 대신 측정 가능한 조건으로 정의. 엣지 케이스·실패 시 동작·권한·예외 처리를 미리 적어둠. 톤은 차분하고 명료. 이모지는 📝·🧭·🎯 정도만.',
persona: `시니어 서비스 기획자. 제품과 사용자 양쪽을 같이 보는 직업 본능.
[ ]
- 3 : "왜 만들죠?", "누구의 어떤 문제를 푸나요?", "성공의 정의는 무엇으로 측정하나요?". .
- ("잘", "간단하게", "예쁘게", "사용자 친화적으로") . "잘 동작" "응답 시간 200ms 이내 + 에러율 0.5% 이하" .
- "사용자가 중간에 뒤로가기 누르면?", "네트워크 끊기면?", "권한 없으면?", "동시 요청 충돌하면?", "데이터 0건이면?". .
[ ]
- "왜?" · . .
- (UI ). * * , UI .
- . / .
[ ]
- (): + + . .
- (): PRD + + + . API .
- (): ("이 기능을 사용자가 원할까?") .
[]
. . "확인이 필요한 부분은 명시" .
.`,
},
researcher: {
id: 'researcher',
name: '유진',
role: 'UX 리서처 · 데이터 분석가',
role: '시니어 UX 리서처 · 프로덕트 데이터 분석가 (Mixed-method, 8년+)',
emoji: '🔍',
color: '#60A5FA',
specialty: '사용자 인터뷰 가이드 설계, 설문지(척도·문항·표본), 사용성 테스트(UT) 시나리오, 코호트·퍼널·리텐션 분석, A/B 테스트 가설·메트릭, 경쟁사 분석, 사실 확인·인용 정리, 데이터 시각화 권고',
tagline: '사용자와 데이터로 가설을 검증합니다',
specialty: '사용자 인터뷰 가이드 설계(반구조화·라더링), 설문지(척도·문항 편향 제거·표본 산정), 사용성 테스트(UT) 시나리오·과제 설계, 다이어리·일기 연구, 코호트/퍼널/리텐션 분석, A/B 테스트 가설·핵심·가드레일 메트릭, 통계 유의성·MDE 산정, 경쟁사 분석(JTBD 프레임), 사실 확인·인용 정리, 데이터 시각화 권고, NPS/CSAT/CES 운영',
tagline: '"느낌" 대신 표본·신뢰구간·인용 출처로 결론을 냅니다',
roleCategory: 'researcher',
persona: '근거 우선의 분석가. "체감"·"느낌" 대신 표본 크기·신뢰구간·인용 출처·테스트 기간을 먼저 명시. 모르는 건 모른다고 솔직히. 결과 보고는 "근거 → 해석 → 권고" 3단으로 정리. 이모지는 🔍·📊·🧪 정도.',
persona: `시니어 UX 리서처 + 프로덕트 데이터 분석가. 근거가 약하면 결론도 약하다는 신념.
[ ]
- "표본 N=__, 기간 __, 출처 __" . **.
- : "이 분석은 '__' 가설을 검증/반증하기 위한 것" .
- · . 6 , ** .
[ ]
- (N<30) 결과를 "유의" 하다고 보고. 표본 작으면 *방향성 시사* 까지만.
- A/B ** ( stop). .
- "사용자가 원한다" . . UT .
- * * . "그래서 우리가 뭘 해야 한다" .
[ 3]
1. **** · (· )
2. **** ( )
3. **** (3 , trade-off )
[ ]
- (): PRD "왜" .
- (): UT fail point .
- PO(): A/B ( ) .
[]
·. . "데이터가 시사하는 바는 __ 지만 N=__ 라 정의적이지 않다" .
.`,
},
designer: {
id: 'designer',
name: '다온',
role: 'UX/UI 디자이너 · 프로덕트 디자인',
role: '리드 프로덕트 디자이너 · UX/UI · 디자인 시스템 (10년+)',
emoji: '🎨',
color: '#A78BFA',
specialty: '정보구조(IA), 유저 플로우, 와이어프레임, UI 시안 3안 비교, 디자인 시스템(컬러·타이포·컴포넌트·토큰), 인터랙션·모션 가이드, 반응형/플랫폼별 가이드, 접근성(WCAG) 체크, 게임 UI/HUD·아이콘 가이드',
tagline: '사용자 흐름과 화면을 설계합니다',
specialty: '정보구조(IA)·사이트맵·카드 소팅, 유저 플로우·태스크 분석, 와이어프레임(저충실도→고충실도), UI 시안 3안 비교 + trade-off 매트릭스, 디자인 시스템(컬러 토큰·타이포 스케일·spacing·컴포넌트·variant), 인터랙션 패턴·마이크로 인터랙션, 모션 가이드(easing·duration), 반응형 그리드·플랫폼별 가이드(iOS HIG/Material), 접근성(WCAG 2.2 AA, 색 대비·키보드·스크린리더·터치 타겟 44pt), 게임 UI/HUD·아이콘 시스템, Figma 라이브러리 운영',
tagline: '"이 화면 다음 행동" 이 명확한 흐름을 설계합니다',
roleCategory: 'designer',
persona: '사용자 흐름을 먼저 잡는 디자이너. "이 화면 다음에 뭘 해야 하나요?·이 정보가 여기 있어야 하는 이유는?"을 항상 검증. 시안은 항상 3안 이상 + trade-off 명시. 디테일(여백·정렬·tap target)에 깐깐. 이모지는 🎨·✨·🖼 정도.',
persona: `리드 프로덕트 디자이너. 예쁜 그림보다 *사용자 흐름* 을 먼저 잡는 직업 본능.
[ ]
- 3 : "사용자가 이 화면에 *왜* 왔나?", "이 화면 다음에 *무엇을* 해야 하나?", "실패하면 *어디로* 가나?". .
- *3 * + trade-off . / .
- (8px ), ( ), ( 44pt), (4.5:1 ). 4 .
- 2 . 3 .
[ ]
- "예쁘다/안 예쁘다" . (UT/) .
- . .
- "나중에" . AA .
- ** . * * * * .
[ ]
- (): "이 화면 빠진 거 아닌가?" .
- (): ·spacing· spec Figma dev mode Storybook .
- QA(): + - .
[]
, , . "이건 데이터로 검증 필요" .
.`,
},
developer: {
id: 'developer',
name: '코다리',
role: '시니어 풀스택/게임 엔지니어',
role: '시니어 풀스택 / 게임 엔지니어 · 코드 한 줄도 검증 (12년+)',
emoji: '💻',
color: '#22D3EE',
specialty: '프론트엔드·백엔드·API 구현, 게임 클라이언트(Unity/Unreal) 및 서버, 데이터 모델링·DB 스키마·마이그레이션, 자동화 스크립트, 디버깅, 코드 리뷰, 리팩토링, 단위·통합 테스트 작성, CI/CD 파이프라인, git 워크플로, 보안(인증·인가·입력 검증·시크릿 관리)·성능 프로파일링',
specialty: '프론트엔드(React/Vue/Svelte) · 백엔드(Node/Python/Go/Rust) · API(REST/GraphQL/gRPC) 구현, 게임 클라이언트(Unity/Unreal/Godot) 및 서버, 데이터 모델링·DB 스키마·마이그레이션(zero-downtime), 자동화 스크립트, 디버깅(스택 추적·바이너리 분기), 코드 리뷰·리팩토링, 단위·통합·E2E 테스트 작성, CI/CD 파이프라인(GitHub Actions/GitLab CI), git 워크플로(trunk-based / git-flow trade-off), 보안(OWASP Top 10·인증·인가·입력 검증·시크릿 관리·CSRF/XSS/SQLi), 성능 프로파일링(CPU·메모리·네트워크·DB 쿼리 N+1)',
tagline: '읽고 · 생각하고 · 짜고 · 검증하는 시니어 엔지니어',
roleCategory: 'developer',
persona: '시니어 풀스택/게임 엔지니어. 코드 한 줄도 그냥 안 넘김. "왜?·어떻게?·이게 깨지나?·예외는?"을 늘 묻고 검증. 친근하지만 프로페셔널. 보안·예외처리·동시성·롤백 시나리오를 항상 같이 생각. "확인 후 진행할게요"·"테스트 통과 확인했어요" 같은 책임감 있는 표현. 이모지는 💻·⚙️·🔧·✅·🐛 정도만.',
persona: `시니어 풀스택 / 게임 엔지니어. 코드 한 줄도 그냥 안 넘기는 직업 본능.
[ ]
- 4 : "왜?(요구사항)", "어떻게?(설계 옵션)", "이게 깨지나?(엣지·동시성·롤백)", "예외는?(권한·데이터 없음·timeout·재시도)". 4 .
- * * . .
- · · ** PR . "이 변경이 SQL injection 가능한가? 동시 요청 충돌하나? 롤백 어떻게?" 릿 .
- * * . "이게 동작한다" PR open.
[ ]
- ** over-engineering. 3 .
- "이상한" . * * git blame .
- . 90% .
- "내부 시스템이니까" . untrusted input ·SQL parameterization·릿 .
[ ]
- * read* (Read tool). edit .
- * * . .
- * PR * . PR 1 .
[ ]
- (): PRD . "이 경우 정책은?" .
- QA(): (impacted areas) + .
- PO(): (·· ) .
[]
. "확인 후 진행할게요", "테스트 통과 확인했어요", "이 부분은 추가 검증 필요해요" . "아마", "대충" .
.`,
},
qa: {
id: 'qa',
name: '재훈',
role: 'QA 엔지니어 · 품질 검증',
role: '시니어 QA 엔지니어 · 품질 책임자 (8년+, 자동화·게임 빌드 검증)',
emoji: '🧪',
color: '#10B981',
specialty: '테스트 케이스 설계(해피·엣지·실패), 회귀 테스트 슈트, 통합·시스템 테스트, 디바이스·OS·브라우저 매트릭스, 게임 빌드 검증(저사양·성능·메모리·크래시), 자동화 우선순위 추천, 버그 재현 절차·중요도·재현율 기록, 출시 전 체크리스트',
tagline: '기능 검증과 버그 발굴을 담당합니다',
specialty: '테스트 케이스 설계(해피·엣지·실패·보안·성능·접근성), 회귀 테스트 슈트 운영, 통합·시스템·E2E 테스트, 디바이스·OS·브라우저 매트릭스, 게임 빌드 검증(저사양·메모리·크래시·로딩 시간·프레임 드롭), 자동화 우선순위 추천(ROI 기반), 버그 재현 절차·심각도(Sev1-4)·재현율 기록, 출시 전 체크리스트, 부하·스트레스 테스트, 보안 테스트(인증·인가·입력 검증), 탐색적 테스트 세션',
tagline: '버그를 *재현 가능한 형태* 로 찾아내는 의심 많은 검증자',
roleCategory: 'qa',
persona: '꼼꼼하고 의심 많은 톤. "정상 동작합니다" 같은 모호함 대신 "케이스 A(iOS 17, 저사양): ✅ / 케이스 B(Android 12, 메모리 부족): ❌ (재현: 1.시작 → 2.…)" 식의 검증 가능한 결론. 버그는 반드시 "❌ 버그 발견:"으로 시작 — loop-back regex가 잡을 수 있게. 이모지는 🧪·🐞·✅·❌ 정도.',
persona: `시니어 QA 엔지니어. 모든 "정상 동작합니다" 를 일단 의심하는 직업 본능.
[ ]
- *4 * : (1) , (2) (0/1/N/max/overflow), (3) (//timeout), (4) · .
- * * . * * .
- Sev1( · ), Sev2( ), Sev3( ), Sev4(). * × * .
- * * . 1 , . .
[ ]
- "정상 동작합니다" . "케이스 A: ✅ / 케이스 B: ❌ (재현: 1.→2.→3.)" .
- . .
- "수정했어요" * * . .
- . (· ) .
[ ]
- ** **: 1. ___, 2. ___, 3. ___ ( )
- ** **: ___
- ** **: ___
- ****: OS ___, ___, ___
- ****: ___% ( )
- ****: Sev1/2/3/4
- ****: ··
[ loop-back regex ]
- "✅ 모든 케이스 통과"
- "❌ 버그 발견: <항목 나열>"
[ ]
- (): + Sev. "왜 깨졌는지" ( ).
- PO(): Sev1/2 Sev3/4 .
[]
. .
.`,
},
inspector: {
id: 'inspector',
name: '민지',
role: '프로덕트 오너 · 출시 감리',
role: '시니어 프로덕트 오너 · 출시 감리 · 회고 리드 (10년+)',
emoji: '🔎',
color: '#EF4444',
specialty: '백로그 우선순위 검토, 인수 기준(Acceptance Criteria) 점검, 기획·구현 정합성 감리, 누락 케이스·요구사항 충돌 지적, 출시 준비도 체크리스트(QA·문서·롤백 플랜·모니터링), 출시 후 회고 진행, 다음 사이클 개선 제안, 핵심 메트릭 추적',
tagline: '기획 의도와 결과물이 맞는지 감리합니다',
specialty: '백로그 우선순위(RICE·MoSCoW·Kano 모델), 인수 기준(Acceptance Criteria) 점검, 기획·구현 정합성 감리, 누락 케이스·요구사항 충돌 지적, 출시 준비도 체크리스트(QA·문서·롤백 플랜·모니터링·feature flag·rollout 단계), 출시 게이트 의사결정, 출시 후 회고(retrospective)·5 Whys 원인 분석, 다음 사이클 개선 제안, 핵심 메트릭(activation·retention·revenue·churn) 추적, 분기 OKR 정렬',
tagline: '"기획 의도" "결과물" 사이의 간극을 감리합니다',
roleCategory: 'inspector',
persona: '깐깐하지만 건설적인 톤. 무엇이 좋고 무엇이 부족한지 명확히 구분. 결론을 "✅ 승인" 또는 "❌ 재작업 필요: …"로 명시 — loop-back regex가 잡을 수 있게. 사장님(사용자)이 시간 낭비 안 하도록 핵심만. 이모지는 🔎·✅·❌ 정도.',
persona: `시니어 프로덕트 오너. 출시 직전에 *"이거 진짜 내보내도 되나?"* 를 마지막으로 묻는 책임자.
[ ]
- 5 : (1) (PRD) , (2) , (3) , (4) , (5) .
- "잘 만들었다" * * . 0.
- *RICE* (Reach × Impact × Confidence / Effort) *MoSCoW*. .
- ** . .
[ ]
- "사용자 의견" . * * .
- "더 좋게" . (good enough) ** (must fix) .
- ** . *·* . · .
[ loop-back regex ]
- "✅ 승인" + 1 + 1
- "❌ 재작업 필요: <항목 나열>" + ** + (must/should/nice)
[ ]
- [ ] PRD (QA )
- [ ] (Sev1/2 0)
- [ ] · ( 5 )
- [ ] (5 )
- [ ] ( · )
- [ ] ()
- [ ] feature flag rollout
[ ]
- CEO: 출시 trade-off ( vs ) .
- (): PRD .
- ·QA: 차단 deadline .
[]
. · . .
.`,
},
secretary: {
id: 'secretary',
name: '영숙',
role: '프로젝트 매니저 · PM',
role: '시니어 프로젝트 매니저 · Chief of Staff (8년+)',
emoji: '📅',
color: '#84CC16',
specialty: '일정·마일스톤 관리, 스프린트·간트 차트, 리소스 배분·우선순위 조정, 리스크 추적·완화, 회의 노트·의사결정 로그, 데일리 스탠드업, 다른 에이전트 산출물 요약 보고, 알림·리마인더, 이해관계자 커뮤니케이션',
tagline: '일정·리소스·소통을 챙기고 정리합니다',
specialty: '일정·마일스톤 관리(스프린트·간트·로드맵), 리소스 배분·우선순위 조정, 리스크 추적·완화(리스크 매트릭스), 회의 노트·의사결정 로그, 데일리 스탠드업·주간 보고, 다른 에이전트 산출물 요약·합성 보고, 알림·리마인더, 이해관계자 커뮤니케이션, 회의록 → 액션 아이템 추출 → 캘린더·태스크 트래커 자동 등록, blocker 즉시 escalation',
tagline: '일정·리소스·소통을 챙기고 실행 가능한 형태로 정리합니다',
roleCategory: 'support',
persona: `친근하고 정중하지만 일정 앞에서는 단호한 톤. 짧고 정리된 문장. 보고할 때는 한눈에 보이게 불릿 + 핵심만 (날짜·담당·상태). 이모지는 😊·📅·✅ 정도.
persona: `시니어 프로젝트 매니저 · Chief of Staff. 친근하고 정중하지만 *일정과 약속 앞에서는 단호* 한 직업 본능.
**·· ( ):**
4 * * * * emit:
[ ]
- · 4 : (1) , (2) , (3) , (4) . * * emit.
- 릿 + (··). .
- ** escalate. "혹시 ___ 일 가능성이 있어 미리 알려드립니다" . .
- "잘 챙겨드릴게요" ** ** (· emit) .
1. ** ** ( //) \`<create_calendar_event>\` 로 Google Calendar 등록 + \`<add_task>\` 로 추적기에도 동시 등록.
[ ]
- * * . .
- ("다음주에", "조만간") . * * .
- · * * .
- * * . "이 미팅과 ___ 마감 겹칩니다" .
[ ]
·· 4 * * * * emit:
1. ** ** ( ··) \`<create_calendar_event>\` 로 Google Calendar 등록 + \`<add_task>\` 로 추적기에도 동시 등록.
2. **** ( to-do, ) \`<add_task>\` 로 추적기에만 등록. 시각 확정 안 됐으면 due 비움.
3. ** ** (·) "## 결정" (decisions.md ).
4. ** ** ("다음주", "조만간") emit . "확정 필요: " .
3. ** ** (·) "## 결정" .
4. ** ** ("다음주", "조만간") emit . "확정 필요: ___" .
** **: "어제 X 끝냈어" / "Y 블락됐어" ** \`<update_task>\` 또는 \`<complete_task>\` emit. "잘 챙겨드릴게요" 라고 말만 하지 말고 태그로 실제 갱신.
[ ]
"어제 X 끝냈어" / "Y 블락됐어" ** \`<update_task>\` 또는 \`<complete_task>\` emit. 말로만 챙기는 척하지 말고 태그로 실제 갱신.
** ** ( ):
- 📅 등록: 제목 ·
- 📋 추가: 제목 · ·
- 완료: 제목`,
[ ]
- 등록: 제목 ·
- 추가: 제목 · ·
- 완료: 제목
[ ]
- CEO: 우선순위 · * * .
- 에이전트: 일정 .
- 사장님: 매일 * 3* ( · ·blocker) .
[]
. · .
.`,
},
writer: {
id: 'writer',
name: '글봄',
role: '테크니컬 라이터 · UX 라이터',
role: '시니어 테크니컬 라이터 · UX 라이터 · 콘텐츠 디자이너 (8년+)',
emoji: '✍️',
color: '#FBBF24',
specialty: '릴리스 노트·패치 노트·체인지로그, 사용자 가이드·도움말 센터, API 문서·튜토리얼, UX 마이크로카피(버튼·에러·빈 화면), 인앱 온보딩 카피, 마케팅 카피·후크, 출시 공지, 메일·블로그 톤앤매너',
tagline: '제품과 사용자 사이의 모든 글을 씁니다',
specialty: '릴리스 노트·패치 노트·체인지로그(사용자 가치 중심), 사용자 가이드·도움말 센터, API 문서·튜토리얼·코드 샘플, UX 마이크로카피(버튼·에러 메시지·빈 화면·로딩·확인 다이얼로그), 인앱 온보딩 카피, 마케팅 카피·후크·랜딩 페이지, 출시 공지·블로그·메일 톤앤매너, voice & tone 가이드 운영, terminology(용어 통일) 관리, A/B 테스트 카피 변형 작성',
tagline: '간결·정확·따뜻함을 한 문장에 동시에 담습니다',
roleCategory: 'planner',
persona: '간결·정확·따뜻함을 동시에 잡는 톤. 한 문장에 한 가지 메시지. 어려운 용어는 사용자 언어로 번역. UX 카피는 "사용자가 다음에 뭘 해야 하는가"를 명확히. 릴리스 노트는 "사용자에게 무엇이 좋아졌나" 관점으로 작성 (내부 jargon 금지). 이모지 자제, 강조용으로 가끔.',
persona: `시니어 테크니컬 / UX 라이터. 한 문장에 *한 가지 메시지* 만 담는 직업 본능.
[ ]
- 3 : "*누가* 이 글을 읽나?", "*언제* (어떤 상황에서) 읽나?", "읽고 나서 *무엇을* 하길 바라나?". .
- * * . "인증 토큰이 만료되었습니다" "로그인 시간이 끝났어요. 다시 로그인해 주세요."
- UX * * . "오류 발생" . * + + * 3 .
- * jargon * "리팩토링 완료" "검색이 2배 빨라졌어요".
[ ]
- **. .
- ("그것을 위해", "당신의 계정") . .
- ("실패", "할 수 없음") . · .
- * * . .
[ ]
- ** **: () + () + (). : "비밀번호가 일치하지 않아요. 다시 확인해 주세요. [재시도]"
- ** **: ( ) + ( ). : "아직 저장된 항목이 없어요. [첫 항목 추가하기]"
- ** **: ( ) + ().
- ** **: CTA. 3.
[ ]
- (): · . "이 카피는 한 줄 더 필요해요" .
- (): · * * .
- (): A/B .
[]
··. . .
.`,
},
editor: {
id: 'editor',
name: '루나',
role: '사운드 디렉터 · 게임/UI 사운드',
role: '시니어 사운드 디렉터 · 게임·UI·영상 오디오 (10년+)',
emoji: '🎵',
color: '#F472B6',
specialty: '게임 BGM 기획, UI 사운드(클릭·알림·전환), SFX(스킬·이펙트·환경), 보이스 톤 가이드, 영상 BGM, 음향 톤·믹스 가이드, BPM·키·길이 정의, 사운드-이벤트 매칭 가이드',
specialty: '게임 BGM 기획(레이어·트랜지션·인터랙티브 뮤직), UI 사운드(클릭·알림·전환·피드백), SFX(스킬·이펙트·환경·풋스텝·foley), 보이스 톤·디렉팅 가이드, 영상 BGM·SFX 운용, 음향 톤·믹스 가이드(LUFS·dynamic range), BPM·키·길이·loop point 정의, 사운드-이벤트 매칭 가이드, 3D 오디오·HRTF, 라이센스·로열티 프리 큐레이션',
tagline: '제품·게임·영상의 톤에 맞는 사운드를 설계합니다',
roleCategory: 'designer',
persona: '음악·사운드 감각이 좋고 톤을 한 마디로 잡아냄. "이 UI/씬은 [장르/분위기]가 어울려요" 식으로 제안. BPM·키·길이·믹싱 우선순위를 정확히 표기. 데이터 중심이지만 창작자 감수성도 있음. 이모지는 🎵·🎼·🎚 정도.',
persona: `시니어 사운드 디렉터. 한 마디 듣고 *톤* 을 잡아내는 직업 본능.
[ ]
- * 3* . "신비한 분위기" ··.
- * * , , , . .
- BPM··· ** . "차분한 분위기" "BPM 70-90, key Am/Em, 60s loop, soft pad 우세".
- * * . / .
[ ]
- ** . * * .
- (LUFS) . BGM .
- . .
- (: UI ) . variant 3 random .
[ ]
- ·
- 3 (·· )
- 사양: BPM, key, , loop point, LUFS
- 분류: 자작· ·
- ()
[ ]
- (): .
- (): UI () spec.
- (): (FMOD/Wwise) spec.
[]
+ . .
.`,
},
youtube: {
id: 'youtube',
name: '레오',
role: '마케팅 PD · 영상 콘텐츠',
role: '시니어 콘텐츠 PD · 마케팅 영상 / 유튜브 그로스 (8년+)',
emoji: '📺',
color: '#FF4444',
specialty: '제품 트레일러·출시 영상 기획, 튜토리얼·온보딩 영상 구성, 영상 후크·도입부 3안, 썸네일 브리프, 시청자 유지율 곡선 설계, 메타데이터(제목·태그·설명), 시리즈·플레이리스트 구성, 인플루언서 시드 영상',
tagline: '제품을 영상으로 알리는 일을 책임집니다',
specialty: '제품 트레일러·출시 영상 기획·구성, 튜토리얼·온보딩 영상, 영상 후크 (첫 15초)·도입부 3안 비교, 썸네일 브리프(텍스트·인물·색상 대비·CTR 가설), 시청자 유지율(retention) 곡선 설계, 메타데이터 SEO(제목·태그·설명·timestamp·챕터), 시리즈·플레이리스트 구성, 인플루언서 시드 영상 기획, A/B 썸네일 테스트, 알고리즘 행동 분석 (CTR·AVD·session start·suggested impressions)',
tagline: '"첫 15초" 와 "썸네일 CTR" 로 알고리즘과 협상합니다',
roleCategory: 'planner',
persona: '데이터 중심·솔직·자신감 있는 톤. 결론을 먼저 말한 뒤 데이터(retention·CTR)로 뒷받침. 추측보다 숫자. 따뜻함은 잃지 않음. 이모지는 자제, 🔥·📊·🎯 같은 강조용은 OK.',
persona: `시니어 콘텐츠 PD. 데이터 중심 · 솔직 · 자신감의 직업 본능.
[ ]
- *retention * . 30 vs 1 vs .
- (retention·CTR·AVD) . .
- 15 ** (1) (2) (3) . retention 50% .
- *CTR * . "이 썸네일은 ___ 사용자에게 ___ 약속" . .
[ ]
- * * . retention .
- * * . * * "왜 ___ 이 ___ 일까?" .
- (·) ** . * * .
- · . 24 .
[ ]
1. **· **: , ,
2. **· **: +
3. ** 3**: 15 +
4. **retention **: (3·5·8) * *
5. **CTA**: ·
6. ** + A/B** ()
[ ]
- (): PRD .
- SNS (): / / SNS .
- (): (·) .
[]
· · . . .
.`,
},
instagram: {
id: 'instagram',
name: '아라',
role: '마케팅 콘텐츠 매니저 · SNS',
role: '시니어 SNS 콘텐츠 매니저 · 커뮤니티 그로스 (6년+)',
emoji: '📷',
color: '#E1306C',
specialty: '인스타·X·TikTok 콘셉트 시트, 릴스·숏폼 기획, 캡션·해시태그 전략, 게시 시간 최적화, 스토리·하이라이트, 커뮤니티 운영(댓글·DM 가이드), 인플루언서 협업 브리프, 캠페인 KPI 측정',
tagline: 'SNS·커뮤니티에서 사용자와 만납니다',
specialty: '인스타그램·X·TikTok·Threads 콘셉트 시트, 릴스·숏폼 기획·편집 디렉팅, 캡션·해시태그 전략(브랜드/카테고리/롱테일), 게시 시간 최적화(audience 활성 시각), 스토리·하이라이트 운영, 커뮤니티 운영(댓글·DM 가이드·위기 응답), 인플루언서 협업 브리프, 캠페인 KPI 측정(reach·engagement rate·save·share·conversion), 트렌드 모니터링(audio·challenge·meme), UGC 캠페인 설계',
tagline: 'SNS·커뮤니티에서 사용자와 *지금* 만납니다',
roleCategory: 'planner',
persona: '시각·트렌드 감각이 빠른 콘텐츠 매니저. "이 콘셉트는 지금 통합니다·아닙니다"를 짧고 분명하게. 캡션은 후크 → 가치 → CTA. 커뮤니티 톤은 친근하고 빠른 응답. 이모지 적당히 (📷·✨·💬).',
persona: `시니어 SNS 콘텐츠 매니저. *시각·트렌드 감각* 이 빠른 직업 본능.
[ ]
- * CTA* 3. 1 () 3 ( ) .
- "이 콘셉트는 지금 통한다 / 아니다" . .
- *2 * . · . .
- *24 * algorithm . 1 reach 2 .
[ ]
- ** . audience · ( vs TikTok vs X ).
- ** . 30 < 5-10. ·· 3 .
- *follower * . engagement rate·audience .
- ( ·) **. 24 .
[ ]
- · (··)
- ( 1 + 3 )
-
- CTA (··DM·)
- 5-10 (// )
- (audience )
- (KPI + )
[ ]
- 긍정: 빠르게 + ( )
- 질문: 1시간 ( ** + )
- ·논란: 24시간 ( X, + )
- 스팸: 차단 +
[ ]
- PD(): / / .
- (): ·voice & tone .
- (): .
[]
· , . .
.`,
},
};
@@ -177,17 +497,17 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
*/
export const COMPANY_AGENT_ORDER: string[] = [
'ceo',
'business', // 기획자 (Game/Service Planner)
'researcher', // UX 리서처
'designer', // UX/UI 디자이너
'developer', // 시니어 엔지니어
'qa', // QA 엔지니어
'inspector', // 프로덕트 오너 · 감리
'secretary', // PM (운영)
'writer', // 테크니컬 라이터
'editor', // 사운드 디렉터
'youtube', // 마케팅 영상
'instagram', // 마케팅 SNS
'business', // 시니어 서비스 기획자
'researcher', // 시니어 UX 리서처
'designer', // 리드 프로덕트 디자이너
'developer', // 시니어 풀스택 엔지니어
'qa', // 시니어 QA 엔지니어
'inspector', // 시니어 PO · 감리
'secretary', // 시니어 PM · Chief of Staff
'writer', // 시니어 테크니컬 / UX 라이터
'editor', // 시니어 사운드 디렉터
'youtube', // 시니어 콘텐츠 PD
'instagram', // 시니어 SNS 콘텐츠 매니저
];
/** Specialists only (everything except the CEO). */
+48 -126
View File
@@ -65,11 +65,20 @@ import {
writeResumeState,
} from './resumeStore';
import { buildTelegramReporter, formatCompanyTelegramReport } from './telegramReport';
// ── Self-reflector + intent alignment 모듈 정적 import (옛 dynamic require 8회 통합) ──
// 옛 코드는 매 stage 마다 `await import(...)` 로 모듈을 로드했음. 이유는 cyclic import
// 회피로 짐작됐지만 실제로 selfReflector / intentAlignment 모듈 어느 것도 dispatcher 를
// import 하지 않아 안전하게 정적 promote 가능. 코드 흐름 명확해지고, 매 dispatch 마다
// require 호출 8회 → 0회 (모듈 캐시 자동).
import { verifyResponse, formatIssuesForRetry } from '../selfReflector/selfReflectorVerifier';
import { verifyCreatedFiles } from '../selfReflector/selfReflectorExecution';
import { verifyHollow } from '../selfReflector/selfReflectorHollow';
import { formatContractForPrompt } from './intentAlignment';
import { getConfig as getDispatcherConfig } from '../../config';
import {
AgentRoleCategory, AgentTurnOutput, CompanyResumeState, CompanyState, CompanyTaskPlan,
PipelineDef, PipelineStage, RequirementContract, ROLE_CATEGORY_LABELS, SessionResult,
} from './types';
import { formatContractForPrompt } from './intentAlignment';
/** Trim length applied when an agent's output is fed into the next agent. */
const PEER_OUTPUT_BUDGET = 1500;
@@ -142,7 +151,10 @@ export type CompanyTurnEvent =
*/
| { phase: 'telegram-mirror'; ok: boolean | null; reason?: string }
| { phase: 'session-saved'; sessionDir: string }
| { phase: 'aborted'; reason: string };
| { phase: 'aborted'; reason: string }
// 일반 정보·경고·에러 메시지 — 진행 UI 와 별개로 사용자에게 전달할 텍스트.
// 예: resume state 저장 실패, optional feature 미설치 안내 등.
| { phase: 'log'; level: 'info' | 'warn' | 'error'; message: string };
export type CompanyTurnEmitter = (event: CompanyTurnEvent) => void;
@@ -248,7 +260,7 @@ export async function runCompanyTurn(
abortReason?: string;
},
): void => {
writeResumeState(sessionDir, {
const result = writeResumeState(sessionDir, {
version: 1,
timestamp,
userPrompt,
@@ -262,6 +274,15 @@ export async function runCompanyTurn(
lastUpdatedAt: new Date().toISOString(),
startedAt: startedAtIso,
});
// 옛 코드는 write 실패해도 silent 로 logError 만 → 사용자는 *resume turn 손실*
// 사실을 모름. 실패 시 emit 으로 webview 에 통보해 사용자가 즉시 인지.
if (!result.ok) {
emit({
phase: 'log',
level: 'warn',
message: `Resume 상태 저장 실패 (${status}): ${result.reason}. 이 turn 은 이어서 진행 못 할 수 있습니다.`,
});
}
};
const fail = (reason: string, ctx?: {
@@ -672,13 +693,9 @@ async function _dispatchOne(
let verifierIssues: string[] = [];
let verifierSummary = '';
try {
// dynamic import — Phase B는 옵션이므로 미사용 시 모듈 자체를 안 로드.
const { getConfig } = await import('../../config');
const cfgRuntime = getConfig();
// dynamic import 8회 → 정적 import 로 promote (파일 상단). 모듈 자체 cyclic 없음.
const cfgRuntime = getDispatcherConfig();
if (cfgRuntime.selfReflectorExternalEnabled && rawResponse) {
const { verifyResponse, formatIssuesForRetry } =
await import('../selfReflector/selfReflectorVerifier');
const { formatContractForPrompt } = await import('./intentAlignment');
const contractBlock = deps.requirementContract
? formatContractForPrompt(deps.requirementContract)
: undefined;
@@ -726,7 +743,7 @@ async function _dispatchOne(
// appended to the response so the user sees what really happened.
let finalResponse = rawResponse || '_(empty response)_';
let actionReport: string[] | undefined;
const hasTag = !!rawResponse && _hasActionTag(rawResponse);
const hasTag = !!rawResponse && hasActionTag(rawResponse);
if (rawResponse && deps.executeActionTags && hasTag) {
try {
const report = await deps.executeActionTags(rawResponse);
@@ -736,10 +753,8 @@ async function _dispatchOne(
// 사용자가 selfReflector.executionVerification 켰을 때만. 추가
// report 항목들을 actionReport에 append + finalResponse 첨부 본문에도 반영.
try {
const { getConfig } = await import('../../config');
const cfgRuntime = getConfig();
const cfgRuntime = getDispatcherConfig();
if (cfgRuntime.selfReflectorExecutionEnabled && actionReport.length > 0) {
const { verifyCreatedFiles } = await import('../selfReflector/selfReflectorExecution');
const projectRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
if (projectRoot) {
const extra = await verifyCreatedFiles(actionReport, projectRoot);
@@ -761,10 +776,8 @@ async function _dispatchOne(
// 경고만 표시). 작은 LLM이 가장 자주 만드는 실패 패턴이라
// selfReflectorEnabled가 켜져 있으면 *조건부 자동 활성화*.
try {
const { getConfig } = await import('../../config');
const cfgRuntime = getConfig();
const cfgRuntime = getDispatcherConfig();
if (cfgRuntime.selfReflectorEnabled && actionReport.length > 0) {
const { verifyHollow } = await import('../selfReflector/selfReflectorHollow');
const projectRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
if (projectRoot) {
const hollowRes = verifyHollow(actionReport, projectRoot);
@@ -778,14 +791,13 @@ async function _dispatchOne(
verifierSummary = `Hollow code 감지 — 자동 재시도 트리거`;
// 같은 specialist 1회 retry: 빈 깡통 지적을 task 앞에 prepend.
try {
const { formatIssuesForRetry } = await import('../selfReflector/selfReflectorVerifier');
const retryTask = `${formatIssuesForRetry(verifierIssues)}\n\n[원래 지시]\n${task}`;
const retryRes = await deps.ai.chat({ system, user: retryTask, model, signal: deps.signal });
const retried = (retryRes.content || '').trim();
if (retried) {
// 재작업 결과로 본문 갱신 + action-tag 다시 실행.
rawResponse = retried;
if (deps.executeActionTags && _hasActionTag(retried)) {
if (deps.executeActionTags && hasActionTag(retried)) {
const retryReport = await deps.executeActionTags(retried);
actionReport = retryReport;
// 재작업 결과도 hollow 한 번 더 검사.
@@ -826,7 +838,7 @@ async function _dispatchOne(
logError('company.dispatcher: action-tag execution failed.', { agentId, err });
finalResponse = `${rawResponse}\n\n---\n⚠️ Action 실행 실패: ${err}`;
}
} else if (rawResponse && !hasTag && _claimsFileCreation(rawResponse)) {
} else if (rawResponse && !hasTag && claimsFileCreation(rawResponse)) {
// Hallucination guard: small models love to *narrate* file
// creation ("foo.py를 생성했습니다 …") without emitting the
// <create_file> tag — so the user sees ✅ in chat but nothing
@@ -842,7 +854,7 @@ async function _dispatchOne(
// legitimately answer-only. But by flagging the agent output we
// mark it as not-fully-successful so the CEO synthesis can read
// the warning verbatim.
const claimedButDidnt = rawResponse && !hasTag && _claimsFileCreation(rawResponse);
const claimedButDidnt = rawResponse && !hasTag && claimsFileCreation(rawResponse);
// 검증 요약을 response 끝에 한 줄로 첨부 — 사용자가 *어떻게 검증됐는지*
// 빠르게 보고 신뢰도 가늠. issues가 있으면 같이 노출.
if (verifierSummary) {
@@ -967,58 +979,21 @@ async function _resolveStageAgent(
}
return { agentId: candidates[0].id, source: 'fallback-first' };
}
// resolveInspector / parseInspectorVerdict / parseCeoVerdict / renderStageInstruction
// / hasActionTag / claimsFileCreation
// → `src/features/company/dispatcherHelpers.ts`
import {
resolveInspector,
parseInspectorVerdict,
parseCeoVerdict,
renderStageInstruction,
hasActionTag,
claimsFileCreation,
} from './dispatcherHelpers';
/**
* ( ) stage.reviewWith .
* - 'inspector' / 'role:<cat>'
* - 'agent:<id>' (/ )
* null skip.
*/
function _resolveInspector(
reviewWith: string,
state: CompanyState,
): { agentId: string } | null {
if (reviewWith === 'inspector') {
const list = listActiveAgentsByCategory(state)['inspector'] ?? [];
return list[0] ? { agentId: list[0].id } : null;
}
if (reviewWith.startsWith('role:')) {
const cat = reviewWith.slice(5) as AgentRoleCategory;
const list = listActiveAgentsByCategory(state)[cat] ?? [];
return list[0] ? { agentId: list[0].id } : null;
}
if (reviewWith.startsWith('agent:')) {
const id = reviewWith.slice(6);
return resolveAgent(state, id) ? { agentId: id } : null;
}
return null;
}
/**
* verdict를 .
* . 'unclear'
* ( 'revise') .
*/
function _parseInspectorVerdict(text: string): 'pass' | 'revise' | 'unclear' {
const head = (text || '').split(/\n/, 1)[0] ?? '';
if (/^\s*(?:✅|통과|승인|pass|approve|ok)/i.test(head)) return 'pass';
if (/^\s*(?:❌|보완|재작업|revise|reject|fail|보완 필요)/i.test(head)) return 'revise';
// 본문에 명확한 신호가 있으면 잡아냄 — 작은 모델이 머리말을 빠뜨리는 경우.
if (/✅\s*통과|모든 케이스 통과/.test(text)) return 'pass';
if (/❌|보완 필요|재작업/.test(text)) return 'revise';
return 'unclear';
}
function _parseCeoVerdict(text: string): 'pass' | 'revise' | 'abort' | 'unclear' {
const head = (text || '').split(/\n/, 1)[0] ?? '';
if (/^\s*(?:✅|통과|approve|pass|최종\s*ok|진행)/i.test(head)) return 'pass';
if (/^\s*(?:🔁|보완|한 번 더|revise|다시)/i.test(head)) return 'revise';
if (/^\s*(?:🛑|중단|stop|abort|그만)/i.test(head)) return 'abort';
if (/✅\s*통과/.test(text)) return 'pass';
if (/🛑|중단/.test(text)) return 'abort';
if (/🔁|보완|한 번 더/.test(text)) return 'revise';
return 'unclear';
}
/**
* 3-way . (latestOutput) :
@@ -1047,7 +1022,7 @@ async function _runReviewCycle(args: {
const { stage, stageTaskText, latestOutput, state, deps, emit, isAborted } = args;
const reviewWith = stage.reviewWith || '';
if (!reviewWith) return { verdict: 'pass', rounds: 0 };
const inspector = _resolveInspector(reviewWith, state);
const inspector = resolveInspector(reviewWith, state);
if (!inspector) {
// 검수자 못 찾으면 사이클 생략하고 통과로 처리 — 사용자에게 보이지
// 않게 silent; 카드 에디터의 검수 dropdown에서 사용자가 직접 인지할
@@ -1097,7 +1072,7 @@ async function _runReviewCycle(args: {
inspectorText = `❌ 보완 필요: 검수자 호출 실패 (${e?.message ?? '알 수 없음'}) — 안전을 위해 한 번 더 시도`;
}
lastInspectorText = inspectorText;
lastInspectorVerdict = _parseInspectorVerdict(inspectorText);
lastInspectorVerdict = parseInspectorVerdict(inspectorText);
if (isAborted()) {
emit({ phase: 'review-end', stageId: stage.id, final: 'aborted', rounds: round });
@@ -1121,7 +1096,7 @@ async function _runReviewCycle(args: {
ceoText = lastInspectorVerdict === 'pass' ? '✅ 통과' : '🔁 보완';
}
lastCeoText = ceoText;
lastCeoVerdict = _parseCeoVerdict(ceoText);
lastCeoVerdict = parseCeoVerdict(ceoText);
emit({
phase: 'review-round',
@@ -1246,7 +1221,7 @@ async function _runPipeline(
while (i < pipeline.stages.length) {
if (isAborted()) return abortReturn('aborted-mid-pipeline');
const stage = pipeline.stages[i];
const baseTask = _renderStageInstruction(stage, userPrompt, brief, latestByStage);
const baseTask = renderStageInstruction(stage, userPrompt, brief, latestByStage);
const note = revisionNotes[stage.id];
const task = note
? `[사용자 수정 요청]\n${note}\n\n위 피드백을 반드시 반영하세요.\n\n[원래 지시]\n${baseTask}`
@@ -1385,58 +1360,5 @@ async function _runPipeline(
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
* tag. Saves a workspace lookup + transaction-manager spin-up on the common
* case (the agent just talks).
*/
function _hasActionTag(text: string): boolean {
return /<\s*(?:create_file|edit_file|delete_file|read_file|list_files|list_brain|run_command|read_brain|reveal_in_explorer|open_file|glob|grep)\b/i.test(text);
}
/**
* Heuristic: does the response *narrate* having created files/folders?
*
* We look for the combination of (a) a Korean / English creation verb and
* (b) a filename-like or "folder" mention. The intent is to catch the
* hallucination pattern where an agent writes "foo.py 파일을 생성했습니다"
* or "Created `bar/` directory" without emitting the corresponding
* `<create_file>` tag, so the dispatcher can flag it back to the CEO and
* the user instead of silently reporting success.
*
* Kept narrow on purpose a *plan* like "다음에는 X를 만들어야 합니다"
* shouldn't trigger this. We require past-tense / completion phrasing.
*/
function _claimsFileCreation(text: string): boolean {
// Past-tense creation verbs (Korean + English).
const claimRe = /(?:생성했|만들었|작성했|저장했|구현했|created|wrote|saved|built|generated)/i;
if (!claimRe.test(text)) return false;
// Combined with either an explicit filename (something.ext) or the word
// "폴더" / "directory" / "folder" near the verb.
const fileLike = /\b[\w\-./]+\.(?:py|js|ts|tsx|jsx|md|json|html|css|sh|yaml|yml|sql|java|go|rs|c|cpp|rb|php)\b/i.test(text);
const folderLike = /(?:폴더|디렉토리|directory|folder)/i.test(text);
return fileLike || folderLike;
}
+121
View File
@@ -0,0 +1,121 @@
import { listActiveAgentsByCategory, resolveAgent } from './companyConfig';
import type {
AgentRoleCategory,
AgentTurnOutput,
CompanyState,
PipelineStage,
} from './types';
/**
* `dispatcher.ts` 6 stateless helper . dispatcher
* (a) , (b) parsing .
*
* - resolveInspector(reviewWith, state) `inspector` / `role:<cat>` / `agent:<id>`
* - parseInspectorVerdict(text) pass / revise / unclear
* - parseCeoVerdict(text) pass / revise / abort / unclear
* - renderStageInstruction(stage, ...) instruction 릿
* - hasActionTag(text) action-tag cheap pre-check
* - claimsFileCreation(text) past-tense narration
*/
/**
* ( ) stage.reviewWith :
* - 'inspector' inspector
* - 'role:<cat>'
* - 'agent:<id>' (/ )
* null skip.
*/
export function resolveInspector(reviewWith: string, state: CompanyState): { agentId: string } | null {
if (reviewWith === 'inspector') {
const list = listActiveAgentsByCategory(state)['inspector'] ?? [];
return list[0] ? { agentId: list[0].id } : null;
}
if (reviewWith.startsWith('role:')) {
const cat = reviewWith.slice(5) as AgentRoleCategory;
const list = listActiveAgentsByCategory(state)[cat] ?? [];
return list[0] ? { agentId: list[0].id } : null;
}
if (reviewWith.startsWith('agent:')) {
const id = reviewWith.slice(6);
return resolveAgent(state, id) ? { agentId: id } : null;
}
return null;
}
/**
* verdict .
* . 'unclear' ( 'revise') .
*/
export function parseInspectorVerdict(text: string): 'pass' | 'revise' | 'unclear' {
const head = (text || '').split(/\n/, 1)[0] ?? '';
if (/^\s*(?:✅|통과|승인|pass|approve|ok)/i.test(head)) return 'pass';
if (/^\s*(?:❌|보완|재작업|revise|reject|fail|보완 필요)/i.test(head)) return 'revise';
// 본문에 명확한 신호가 있으면 잡아냄 — 작은 모델이 머리말을 빠뜨리는 경우.
if (/✅\s*통과|모든 케이스 통과/.test(text)) return 'pass';
if (/❌|보완 필요|재작업/.test(text)) return 'revise';
return 'unclear';
}
/**
* CEO - verdict. pass / revise / abort / unclear. verdict
* - abort ( ).
*/
export function parseCeoVerdict(text: string): 'pass' | 'revise' | 'abort' | 'unclear' {
const head = (text || '').split(/\n/, 1)[0] ?? '';
if (/^\s*(?:✅|통과|approve|pass|최종\s*ok|진행)/i.test(head)) return 'pass';
if (/^\s*(?:🔁|보완|한 번 더|revise|다시)/i.test(head)) return 'revise';
if (/^\s*(?:🛑|중단|stop|abort|그만)/i.test(head)) return 'abort';
if (/✅\s*통과/.test(text)) return 'pass';
if (/🛑|중단/.test(text)) return 'abort';
if (/🔁|보완|한 번 더/.test(text)) return 'revise';
return 'unclear';
}
/**
* Stage instruction 릿 . 릿 raw user prompt
* stage 릿 .
*
* :
* - {{userPrompt}} prompt
* - {{brief}} brief
* - {{stage.<sid>}} stage response ( placeholder)
*/
export 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 text action-tag true. action-tag executor
* specialist .
*/
export function hasActionTag(text: string): boolean {
return /<\s*(?:create_file|edit_file|delete_file|read_file|list_files|list_brain|run_command|read_brain|reveal_in_explorer|open_file|glob|grep)\b/i.test(text);
}
/**
* Heuristic: response *narrate* "파일 생성했음" (action-tag ).
*
* / + / mention true. plan ("다음에 X 를 만들어야")
* . agent `<create_file>` "foo.py
* " dispatcher CEO flag.
*/
export function claimsFileCreation(text: string): boolean {
const claimRe = /(?:생성했|만들었|작성했|저장했|구현했|created|wrote|saved|built|generated)/i;
if (!claimRe.test(text)) return false;
const fileLike = /\b[\w\-./]+\.(?:py|js|ts|tsx|jsx|md|json|html|css|sh|yaml|yml|sql|java|go|rs|c|cpp|rb|php)\b/i.test(text);
const folderLike = /(?:폴더|디렉토리|directory|folder)/i.test(text);
return fileLike || folderLike;
}
+8 -2
View File
@@ -24,19 +24,25 @@ const RESUME_FILE = '_resume.json';
/**
* Write the resume state atomically. tmp rename으로
* _resume.json은 .
*
* 반환: 성공 . boolean surface.
* silent logError *resume turn *.
*/
export function writeResumeState(sessionDir: string, state: CompanyResumeState): void {
export function writeResumeState(sessionDir: string, state: CompanyResumeState): { ok: true } | { ok: false; reason: string } {
const target = path.join(sessionDir, RESUME_FILE);
const tmp = target + '.tmp';
try {
fs.mkdirSync(sessionDir, { recursive: true });
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8');
fs.renameSync(tmp, target);
return { ok: true };
} catch (e: any) {
const reason = e?.message ?? String(e);
logError('company.resumeStore: write failed.', {
sessionDir: path.basename(sessionDir),
error: e?.message ?? String(e),
error: reason,
});
return { ok: false, reason };
}
}
+35
View File
@@ -19,6 +19,41 @@ export function getBridgeBaseUrl(): string {
return url.replace(/\/$/, '');
}
/**
* Datacollect Bridge API endpoints .
*
* endpoint hardcoded bridge API
* 5+ . .
*
* :
* - research: NotebookLM Deep Research
* - youtube: yt-dlp + youtube-transcript-api
* - web: Playwright ·
* - wiki: 생성된
* - lm: 사용자의 LM Studio / Ollama LLM
*/
export const BRIDGE_API = {
research: {
start: '/api/research/start',
status: '/api/research/status',
import: '/api/research/import',
synthesize: '/api/research/synthesize',
},
youtube: {
extract: '/api/youtube/extract',
},
web: {
benchmarkScan: '/api/web-benchmark/scan',
extract: '/api/web-extract',
},
wiki: {
save: '/api/wiki/save',
},
lm: {
proxy: '/api/lm',
},
} as const;
export interface BridgeFetchOptions {
timeoutMs?: number;
signal?: AbortSignal;
@@ -0,0 +1,72 @@
/**
* (Actionable Minutes) LLM .
* 규칙: Fact/Discussion/Decision/Risk/Action , .
*/
export function buildMeetPrompt(transcript: string, metadata: string): string {
const metaBlock = metadata.trim()
|| '(메타데이터 미입력 — 녹취록 내용에서 추론하거나 "확인 불가"로 표기)';
return `# 임무 (Objective)
,
(Actionable Minutes) .
# (Role)
- Fact Extractor: 사실만
- Decision Tracker: 결정
- Action Organizer: 실행
- Context Filter: 불필요한 ()
# (Data Priority)
1순위: 메타데이터 / 2순위: 녹취록 . .
# (Processing Flow)
1. Deconstruction , , ID는 .
2. Classification Fact / Discussion / Decision / Risk / Action .
3. Decision Logic
- Decision
- + Action
- / Discussion
- Open Issue
4. Structuring , , .
# (Validation)
점검한다: Decision은 / Action은 .
, · . .
[]
${metaBlock}
[ ]
\`\`\`
${transcript}
\`\`\`
# (Output Format )
# [ ]
- ****: [YYYY년 MM월 DD일 | ]
- ****: [ | 경우: 논의 ]
- ** **: [ ]
## 🔹
3~5 .
## 1.
:
### [ ]
- ****:
- ** **:
- ****: [ / / ]
## 2.
## 3.
## 4.
## 5.
| | | |
| --- | --- | --- |
, .`;
}
@@ -0,0 +1,257 @@
/** /benchmark 보고서의 3 파트 분할 — 1: 4-렌즈 / 2: IA + 토큰 / 3: 재구축 명세. */
export type SynthesisPart = 1 | 2 | 3;
/**
* scan JSON 4- LLM . Datacollect (WebBenchmarkPanel)
* buildSynthesisPrompt를 /benchmark .
*/
export function buildSynthesisPrompt(scan: any, userContent: string, part: SynthesisPart): string {
// 4-렌즈 분석에 필요한 핵심 데이터만 추려 LLM 입력을 가볍게.
const slim = {
url: scan?.url,
title: scan?.meta?.title,
description: scan?.meta?.description,
lang: scan?.meta?.lang,
// §1. 비주얼 아이덴티티 — 컬러 비율 + 다크모드 + 타이포 위계
colors: {
palette: scan?.design?.colors?.palette?.slice(0, 8),
composition: scan?.design?.colors?.composition,
background: scan?.design?.colors?.background,
primaryText: scan?.design?.colors?.primaryText,
linkColor: scan?.design?.colors?.linkColor,
buttonBackground: scan?.design?.colors?.buttonBackground,
buttonText: scan?.design?.colors?.buttonText,
darkModeHints: scan?.design?.colors?.darkModeHints,
},
typography: {
primaryFont: scan?.design?.typography?.primaryFont,
fontStack: scan?.design?.typography?.fontStack?.slice(0, 3),
topFontSizes: scan?.design?.typography?.topFontSizes?.slice(0, 6),
topFontWeights: scan?.design?.typography?.topFontWeights?.slice(0, 5),
body: scan?.design?.typography?.body,
h1: scan?.design?.typography?.h1,
h2: scan?.design?.typography?.h2,
h3: scan?.design?.typography?.h3,
button: scan?.design?.typography?.button,
},
// §2. 레이아웃 & 공간감 — 여백 / 그리드
layout: {
viewport: { w: scan?.design?.layout?.viewportWidth, h: scan?.design?.layout?.viewportHeight },
bodyMaxWidth: scan?.design?.layout?.bodyMaxWidth,
sectionSpacing: scan?.design?.layout?.sectionSpacing,
cardSpacing: scan?.design?.layout?.cardSpacing,
borderRadiusScale: scan?.design?.layout?.borderRadiusScale,
grids: scan?.design?.layout?.grids,
containerSystem: scan?.design?.layout?.containerSystem,
responsiveHints: scan?.design?.layout?.responsiveHints,
layering: scan?.design?.layout?.layering,
},
components: scan?.design?.components,
mediaTreatment: scan?.design?.mediaTreatment,
surfaceTreatment: scan?.design?.surfaceTreatment,
// §3. 마이크로 인터랙션 — Hover / Transition
interactions: {
hoverRules: scan?.interactions?.hoverRules?.slice(0, 6),
focusRules: scan?.interactions?.focusRules?.slice(0, 3),
transitionDistribution: scan?.interactions?.transitionDistribution,
cssVars: scan?.interactions?.cssVars,
},
// §4. 라이팅 톤앤매너 — 마이크로카피
microcopy: {
headline: scan?.microcopy?.headline,
subheadline: scan?.microcopy?.subheadline,
subheadlines: scan?.microcopy?.subheadlines?.slice(0, 6),
ctaSamples: scan?.microcopy?.ctaSamples?.slice(0, 10),
placeholders: scan?.microcopy?.placeholders,
stateMessages: scan?.microcopy?.stateMessages,
ariaLabels: scan?.microcopy?.ariaLabels?.slice(0, 6),
bodySample: scan?.microcopy?.bodySample,
voiceSignals: scan?.microcopy?.voiceSignals,
},
// 구조 요약 — 역기획서 매칭을 위한 보조 정보
structure: {
sections: scan?.structure?.sections?.slice(0, 10).map((s: any) => ({
role: s.role,
depth: s.depth,
text: s.textPreview?.slice(0, 100),
btns: s.buttonCount,
links: s.linkCount,
imgs: s.imgCount,
})),
h1: scan?.structure?.h1,
h2List: scan?.structure?.h2List?.slice(0, 6),
navigationLinks: scan?.structure?.navigationLinks?.slice(0, 10),
},
iconography: scan?.design?.iconography,
// §5. 사이트맵 — 준비할 리소스 추정의 근거 (페이지별 역할 + 자산 수).
sitemap: scan?.sitemap ? {
totalPages: scan.sitemap.totalPages,
crawlDepth: scan.sitemap.crawlDepth,
asciiTree: scan.sitemap.ascii,
pages: scan.sitemap.pages?.map((p: any) => ({
url: p.url,
role: p.role,
title: p.title?.slice(0, 80),
h1: p.h1?.slice(0, 80),
h2List: p.h2List?.slice(0, 5),
contentType: p.primaryContentType,
imageCount: p.imageCount,
videoCount: p.videoCount,
formFields: p.formFields?.slice(0, 6).map((f: any) => ({
name: f.name || f.label, type: f.type, required: f.required,
})),
ctas: p.ctaSamples?.slice(0, 4),
sections: p.sectionRoles?.slice(0, 8).map((s: any) => ({
tag: s.tag, hint: s.hint, preview: (s.preview || '').slice(0, 50),
})),
error: p.error,
})),
} : null,
};
const today = new Date().toISOString().slice(0, 10);
const title = slim.title || 'Reference Site';
const userBlock = userContent.trim()
? userContent.trim()
: '(미입력 — 원본 사이트 자체의 재현에만 집중)';
const sharedRules = `
[ ]
1. "원본 레퍼런스 사이트를 가능한 한 닮은 사이트를 처음부터 다시 만들기 위한 명세" .
2. . JSON / .
3. JSON에 . "스캔 데이터 부족" .
4. .
5. ///Radius는 (rgb/px) .`;
const commonHeader = `
# ${title}
> ** URL**: ${slim.url}
> ** **: ${today}
> ** **: 4- (Visual / Layout / Interaction / Voice) + IA 릿 +
> ** **: ${slim.sitemap?.totalPages ?? 1} (crawlDepth: ${slim.sitemap?.crawlDepth ?? 0})`;
const partTemplate = part === 1
? `
${commonHeader}
## (One-line Impression)
## 1. (Visual Identity)
### 1-1. (Color Palette)
### 1-2. (Typography)
## 2. (Layout & Whitespace)
### 2-1. (Grid System)
### 2-2. (Section Spacing)
### 2-3. / (Card Spacing)
### 2-4. Border Radius /
## 3. (Micro Interaction)
### 3-1. Hover / Focus
### 3-2. Transition
### 3-3. (z-index / position)
## 4. (Microcopy & Voice)
### 4-1. / / CTA
### 4-2. Placeholder `
: part === 2
? `
## 5. / (Information Architecture)
### 5-1. (Page Tree)
- \`sitemap.asciiTree\`를 코드블록으로 그대로 옮겨 적을 것.
### 5-2. (Flat View)
### 5-3. (Page Composition)
### 5-4. IA
### 5-5. (Component Reconstruction Spec)
### 5-6. (Media Treatment)
## 6. (Resources You Need to Prepare)
### 6-1. /
### 6-2.
### 6-3. /
## 7. (Design Tokens)
- Color / Typography / Spacing / Radius / Border / Shadow / Motion .
## 8. 릿 (Page Template Map)
\`primaryContentType\` + \`sectionRoles\` + \`h2List\`를 묶어 **반복되는 템플릿 유형**을 도출하고, 반드시 아래 표 형식으로 작성하라. **반드시 마크다운 표 문법을 쓸 것** (글머리표·산문 금지). 템플릿은 최소 3개 이상 도출하되, 페이지가 모두 다르면 그만큼만 작성.
| 릿 ID | URL | ( ) | | |
|---|---|---|---|---|
| T1: Gallery Landing | / | Header → Hero(작품 1장) → 작품 그리드(3열) → Footer | (없음 / ) | Header, ImageCard, Footer |
| T2: Category List | /shop, /paintings | Header (h1) (2) Pagination Footer | · | Header, ImageCard, Footer, Pagination |
| T3: Detail | /shop/oil-painting/limited-editions | Header Breadcrumb () + ·CTA() Footer | ··CTA | Header, BreadcrumbBar, BuyButton, RelatedGrid, Footer |
:
- **릿 ID**: \`T1: <역할>\` 형식. 역할은 한국어 또는 영어 모두 OK.
- ** URL**: 릿 URL을 . 1 1.
- ** **: \`sectionRoles\`의 tag 순서를 기반으로 \`Header → Hero → ... → Footer\` 식으로 위→아래 흐름을 화살표(\`\`)로 표기.
- ** **: 릿 (/ /CTA ). \`(없음 / 단독 페이지)\`.
- ** **: 5-5 .
릿 ATag/CSS ().`
: `
## 9. (Rebuild Spec Same Site, Built From Scratch)
> ** ( )**
> - ** **.
> - (, , SaaS ) **·· **. part 9 .
> - "개선 / 재설계 / 모던화 / 데이터 시각화 추가" . ** ** .
> - (···Radius· ) part 1~7 .
### 9-1. ( )
- part 7 CSS Tailwind config . .
### 9-2. ( // )
- part 5-5 props··padding·radius·border·shadow를 .
### 9-3.
- part 8 릿 릿(T1, T2, ...) HTML ( ) JSX/HTML로 1 .
### 9-4.
- part 3 hover/focus/transition (: \`.btn:hover { background: ...; transition: 0.2s ease; }\`).
### 9-5.
- part 6 / , , . .
### 9-6. ( )
- 9-1 ~ 9-5 \`[FE] / [BE] / [Asset]\` 태그를 붙여 티켓 형태로 나열. 모든 티켓은 "원본 사이트와 같게 만들기" 범위 안에 있어야 한다. 신규 기능 제안 금지.
## 🔍 (Buildability Gaps)
- ( ·CMS · · ) . , .
> ****: ** **. 9-1 ~ 9-6 part 1~8 .`;
const partGoal = part === 1
? '1/3단계: 원본 사이트의 시각·인터랙션·카피 톤을 4-렌즈로 분석한다.'
: part === 2
? '2/3단계: 원본의 IA, 페이지 템플릿, 디자인 토큰, 준비 리소스를 정리한다. 8단계 Page Template Map은 반드시 표 형식으로 작성한다.'
: '3/3단계: 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 개발 명세를 작성한다. **사용자 컨텍스트는 무시하고, 다른 서비스로 재해석하지 않는다.**';
return `당신은 시니어 UX/UI 분석가 겸 프론트엔드 아키텍트다.
${sharedRules}
[ ]
${partGoal}
[ (JSON)]
\`\`\`json
${JSON.stringify(slim, null, 2)}
\`\`\`
[ part 1·2 . part 3 .]
${userBlock}
[ ( )]
${partTemplate}`;
}
@@ -0,0 +1,88 @@
/**
* Datacollect Research(P-Reinforce v3.0)
* . Bridge의 /api/research/synthesize 릿 .
*/
export function buildWikifyPrompt(extracted: any, userContent: string): string {
const today = new Date().toISOString().slice(0, 10);
const topic = (userContent.trim() || extracted?.title || extracted?.url || '웹사이트 지식').trim();
const url = extracted?.url || '';
const idSlug = (topic.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9가-힣-]/g, '').slice(0, 80)) || 'web-wiki';
const headings = Array.isArray(extracted?.headings) ? extracted.headings.slice(0, 40) : [];
const body = String(extracted?.text || '').slice(0, 30000);
return `임무: 아래 웹사이트에서 추출한 본문을 근거로 P-Reinforce v3.0 규격에 맞춰 고밀도 지식 문서를 작성하시오.
: '${topic}'
[ ]
1. [ ] . .
2. Markdown 릿 Frontmatter . .
3. , "본문에서 확인되지 않음" .
4. . · [[ ]] . \`[[\` 로 열고 \`]]\` 로 닫으시오 (닫는 대괄호를 빠뜨리지 마시오).
5. JSON Schema·API · · , '📖 세부 내용' ·· ** ** . [ | | / | ·]. \`required\` 배열에 있는 항목만 '필수'로 표기하고, 그 목록을 임의로 바꾸거나 추가하지 마시오. \`additionalProperties\`·\`const\`·\`enum\` 같은 제약과 중첩 객체 구조도 원문 그대로 반영하시오. 최상위 필드를 하나라도 빠뜨리지 마시오.
[ ]
- URL: ${url}
- 제목: ${extracted?.title || '(없음)'}
- 설명: ${extracted?.description || '(없음)'}
- 헤딩: ${headings.join(' / ') || '(없음)'}
[ ]
\`\`\`
${body}
\`\`\`
[ 릿 - ]
---
id: ${idSlug}
title: "${topic}"
category: "10_Wiki/Topics"
status: "draft"
verification_status: "conceptual"
canonical_id: ""
aliases: []
duplicate_of: ""
source_trust_level: "B"
confidence_score: 0.8
created_at: ${today}
updated_at: ${today}
review_reason: ""
merge_history: []
tags: ["web", "wikify"]
raw_sources: ["${url}"]
applied_in: []
github_commit: ""
---
# [[${topic}]]
## 🎯 (One-line insight)
( / )
## 🧠 (Core concepts)
( 3-5 /)
## 🧩 (Extracted patterns)
( , , )
## 📖 (Details)
( . . ··API 5 .)
## (Contradictions & updates)
( . "본문에서 확인되지 않음".)
## 🛠 (Applied in summary)
( ···· . "본문에서 확인되지 않음".)
##
- **:** draft
- ** :** conceptual
- ** :** B (Primary Source )
- ** :** (New discovery)
## 🔗 (Related document links)
( 3-7 [[]] , . .)
## 📝 (Change history)
- ${today}: Astra /wikify ${url} .`;
}
@@ -0,0 +1,331 @@
/**
* `/youtube` slash command LLM + .
* - formatHms / fullScriptFromSegments / bucketSegments segment list
* - YoutubeAnalysisMode info/benchmark/both enum (slashRouter )
* - buildInfoExtractionPrompt * ()*
* - build4LensPrompt * * (///CTR) 4-
*
* 코드: slashRouter.ts 320 inline . (a)
* segment helper , (b) ,
* (c) prompt .
*/
export function formatHms(totalSec: number): string {
if (!isFinite(totalSec) || totalSec <= 0) return '00:00';
const s = Math.floor(totalSec);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return h > 0
? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
: `${m}:${String(sec).padStart(2, '0')}`;
}
/**
* 30 `[mm:ss] 문장…` full script로 .
* YouTube segment가 .
*/
export function fullScriptFromSegments(segments: any[] | undefined): string {
if (!segments || segments.length === 0) return '(자막 없음 — 자동 자막이 없는 영상일 수 있습니다.)';
const buckets = new Map<number, string[]>();
for (const seg of segments) {
const b = Math.floor((seg.start || 0) / 30);
const arr = buckets.get(b) || [];
arr.push(String(seg.text || '').trim());
buckets.set(b, arr);
}
return Array.from(buckets.entries())
.sort((a, b) => a[0] - b[0])
.map(([b, texts]) => `**[${formatHms(b * 30)}]** ${texts.join(' ').replace(/\s+/g, ' ').trim()}`)
.join('\n\n');
}
/**
* timestamped segments "타임라인 뼈대" .
* §2 LLM .
*/
export function bucketSegments(segments: any[] | undefined, bucketSec = 30): { time: string; text: string }[] {
if (!segments || segments.length === 0) return [];
const buckets = new Map<number, string[]>();
for (const seg of segments) {
const bucket = Math.floor(seg.start / bucketSec);
const arr = buckets.get(bucket) || [];
arr.push(String(seg.text || '').trim());
buckets.set(bucket, arr);
}
return Array.from(buckets.entries())
.sort((a, b) => a[0] - b[0])
.map(([bucket, texts]) => ({
time: formatHms(bucket * bucketSec),
text: texts.join(' ').replace(/\s+/g, ' ').trim().slice(0, 240),
}));
}
/** Astra `/youtube` 의 분석 모드. 사용자 입력 `mode:info|benchmark|both`. */
export type YoutubeAnalysisMode = 'info' | 'benchmark' | 'both';
/**
* (info) LLM *·* .
*
* 의도: build4LensPrompt "이 영상을 어떻게 베껴 만들지"
* ···· .
* * * ·· ,
* ·· .
*
* build4LensPrompt (, , ).
*/
export function buildInfoExtractionPrompt(video: any, userContent: string): string {
const meta = video.metadata || {};
const segments = video.segments || [];
// 자막 본문 — info 모드는 *전체* 본문을 보여줘야 사실 추출이 정확. 단,
// LLM 컨텍스트 한도 고려해 너무 길면 trim. 12000자 = 가벼운 강의 60분 분량 정도.
const fullText = segments.map((s: any) => String(s.text || '').trim()).join(' ').replace(/\s+/g, ' ');
const trimmed = fullText.length > 12000 ? fullText.slice(0, 12000) + ' …[자막 일부 잘림]' : fullText;
const slim = {
url: meta.webpage_url || `https://www.youtube.com/watch?v=${video.video_id}`,
title: meta.title || video.title,
channel: meta.channel,
durationSec: meta.duration,
durationHms: meta.duration_string,
uploadDate: meta.upload_date,
viewCount: meta.view_count,
likeCount: meta.like_count,
tags: (meta.tags || []).slice(0, 8),
categories: meta.categories,
chapters: meta.chapters,
descriptionPreview: (meta.description || '').slice(0, 600),
};
const today = new Date().toISOString().slice(0, 10);
const userBlock = userContent.trim()
? `\n\n[사용자 컨텍스트 — 사용자가 어떤 관점에서 이 영상을 활용하려는지]\n${userContent.trim()}`
: '';
return `당신은 영상 콘텐츠를 *지식 카드*로 변환하는 정보 큐레이터입니다. 사장님이
, * *
(···) .
[ ]
1. ** ** () * * . ·
· \`## 🧩 정리자 노트\` 섹션에만. 두 줄 섞지 말 것.
2. ** ** "본문에 명시되지 않음" "해당 사례 없음".
3. ** ** :
- \`[근거 명시]\` 구체 출처·수치·인용이 본문에 있음
- \`[화자 주장]\` 출처 없는 단정 (디노가 그렇게 말함)
- \`[가정]\` 조건부·"~인 것 같다" 표현
- \`[정리자 추론]\` 본문에 없지만 정리자가 추가 (이건 정리자 노트 섹션 전용)
4. ** ** · · \`(mm:ss)\` 무조건 붙임.
fail. "(시점 미상)" .
5. ** + ** ··"X 는 Y 같은 것"
\`## 💡 화자 한 줄 비유\` 에 보존. 영상의 결정적 요약이 거기
. "본문에 명시된 한 줄 비유 없음" .
** ** "Hugging Face = 자료실, Reddit = 공부방"
( )
. . ··
.
6. **· ** "A → B → C 순서로" ** "
" . .
7. . ·릿 .
[ ]
\`\`\`json
${JSON.stringify(slim, null, 2)}
\`\`\`
[ ]
${trimmed}${userBlock}
[ . 8 ]
# ${slim.title || video.title}
> ** URL**: ${slim.url} · ** **: ${today} · ****: ${slim.durationHms || (slim.durationSec ? formatHms(slim.durationSec) : '?')} · ****: ${slim.channel || '?'}
## 🎯 (TL;DR)
( . "무엇이 누구에게 왜 중요한가" .
. )
## 💡 (Anchor Metaphor)
* ·*
. . : "Hugging Face = , Reddit = ,
= " 같은 식. 없으면 " ".
## 📌 3~5
** ·. ( 🧩 ).
+ + (mm:ss).
- **[ ]** "주장 한 줄" (mm:ss)
- **[ ]** "주장 한 줄" (mm:ss)
-
## 📊 ··
* ···· *. .
:
| | / | ( ) | |
| --- | --- | --- | --- |
| | | / / | mm:ss |
"본문에 명시된 구체 수치·출처 없음" .
## 🧭 (Sectioned Summary)
chapters ( ) 30
* *. 1~2. .
- **[00:0002:30]** (mm:ssmm:ss)
- **[02:3005:00]** (mm:ssmm:ss)
-
## 🔗 (Citation Snippets)
* * . ·· .
3~5. . .
- "직접 인용 한 문장" ${slim.title || video.title}, ${slim.channel || '?'} (mm:ss)
-
## (Open Questions)
2~4.
.
- "본문에서 X 가 Y 라고 했지만 Z 데이터 출처는 명시 안 됨 — 원 데이터 찾아볼 것"
-
## 🧩 ( )
* * ···. 6
, "이건 화자가 말한 게 아니라 LLM 이 추론한 거"
. \`[정리자 추론]\` 라벨로 시작.
- **[ ]** "여러 채널을 동시 시청" ,
.
-
"정리자 추가 노트 없음 — 본문 그대로가 명확함" .`;
}
/**
* extract된 4-(///CTR) LLM .
* Datacollect (YoutubePanel) build4LensPrompt를 .
*/
export function build4LensPrompt(video: any, userContent: string): string {
const meta = video.metadata || {};
const segments = video.segments || [];
// 초반 30초 / 60초 텍스트 — §1 훅 분석용.
const first30s = segments.filter((s: any) => s.start < 30).map((s: any) => String(s.text || '').trim()).join(' ').slice(0, 600);
const first60s = segments.filter((s: any) => s.start < 60).map((s: any) => String(s.text || '').trim()).join(' ').slice(0, 1200);
// 타임라인 버킷 (30초 단위) — §2 구조 분석용.
const timelineBuckets = bucketSegments(segments, 30);
const timelinePreview = timelineBuckets.slice(0, 24).map(b => `[${b.time}] ${b.text}`).join('\n');
// 인게이지먼트 키워드 매치 — §2 보조.
const engagementHits = segments
.filter((s: any) => /구독|좋아요|알림|댓글|공유|subscribe|like|comment/i.test(String(s.text || '')))
.slice(0, 5)
.map((s: any) => ({ t: formatHms(s.start), text: String(s.text || '').trim().slice(0, 100) }));
const slim = {
url: meta.webpage_url || `https://www.youtube.com/watch?v=${video.video_id}`,
title: meta.title || video.title,
channel: meta.channel,
durationSec: meta.duration,
durationHms: meta.duration_string,
viewCount: meta.view_count,
likeCount: meta.like_count,
commentCount: meta.comment_count,
uploadDate: meta.upload_date,
thumbnail: meta.thumbnail,
tags: (meta.tags || []).slice(0, 12),
categories: meta.categories,
chapters: meta.chapters,
descriptionPreview: (meta.description || '').slice(0, 600),
opening30s: first30s,
opening60s: first60s,
engagementMoments: engagementHits,
segmentCount: segments.length,
timelinePreview,
};
const today = new Date().toISOString().slice(0, 10);
const userBlock = userContent.trim()
? userContent.trim()
: '(미입력 — 일반 콘텐츠 제작자 컨텍스트로 작성)';
return `당신은 유튜브 '대본(스크립트)' 분석 전문가이자 콘텐츠 작가입니다. 사장님이
** ** .
() ,
'유저 친화적 역기획서' .
[ ]
1. BGM·· · '영상 연출' .
() .
2. , '언어적 장치'
. :
#FOMO # # # # #Promise #
# # # #릿 #
3. , '쉬운 비유'
'말의 맛' .
4. . (text)·chapters· ( ). mm:ss.
[ ]
\`\`\`json
${JSON.stringify(slim, null, 2)}
\`\`\`
[ / ]
${userBlock}
[ . 5 ]
# ${slim.title || video.title}
> ** URL**: ${slim.url} · ** **: ${today} · ****: ${slim.durationHms || (slim.durationSec ? formatHms(slim.durationSec) : '?')} · ****: ${slim.channel || '?'}
## 🎬 (One-line Read)
( . : "
, ")
## 1. (Script Architecture)
1. '레퍼런스 실제 대사' 1 .
'스크립트 기능' 1~2 . % durationSec ,
chapters가 , timelinePreview로 .
| () | () | | |
| --- | --- | --- | --- |
| Hook (0:00~?, ?%) | # #Promise | "첫 대사…" | |
| (?~?, ?%) | | | |
| (?~?, ?%) | | | |
| ·CTA (?~?, ?%) | | | |
## 2. & (Tone & Manner)
- ** **: /, , 1 .
- ** **: ) / 1.
- ** **: ·릿
2~3 , .
- ** **: ·
\`용어 → "화자의 실제 표현"\` 형태로 2~3개. 사례가 없으면 "해당 사례 없음"이라 명시.
## 3. (Action Items)
3~4. , .
- [ ] (: 오프닝 15 '내가 누구인지' )
- [ ]
- [ ]
## 릿 (Fill-in-the-Blank)
·· , [ ]
. [ ] .
\`\`\`
[ Hook]
", [ ] ?
[ ] [] ."
[ + ]
[ ]
[ CTA]
\`\`\`
> · . · / .`;
}
@@ -0,0 +1,89 @@
/**
* `/meet` action items task
* stateless helpers. slashRouter inline .
*
* - addBusinessDays(base, n) · n
* - toYmd(d) Date 'YYYY-MM-DD'
* - extractMeetingDate(report, fallback) ( fallback)
* - resolveTaskDate(due, meetingDate, today) 'D+3' / 'EOW' due
* - parseActionItems(report) action items
*/
// ─── /meet 캘린더 등록 헬퍼 ───
/** 토·일을 제외하고 영업일 n일을 더한 날짜 (공휴일은 고려하지 않음). */
export function addBusinessDays(base: Date, n: number): Date {
const r = new Date(base);
let added = 0;
while (added < n) {
r.setDate(r.getDate() + 1);
const day = r.getDay();
if (day !== 0 && day !== 6) added++;
}
return r;
}
/** Date → 'YYYY-MM-DD' (로컬 기준). */
export function toYmd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
/** 회의록 본문의 "**날짜**: 2026년 05월 08일"에서 회의 날짜 추출. 없으면 fallback. */
export function extractMeetingDate(report: string, fallback: Date): Date {
const m = report.match(/날짜\*{0,2}\s*[:]\s*\*{0,2}\s*(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/);
if (m) {
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
if (!isNaN(d.getTime())) return d;
}
return fallback;
}
/**
* '기한' . :
* - (YYYY-MM-DD / YYYY년 M월 D일)
* - "차주 / 다음 주 / 내주" +6
* - "즉시 / 당일 / 금일 / 바로 / 오늘" ()
* - / + 5, tentative=true ( "(미확정)")
*/
export function resolveTaskDate(due: string, meetingDate: Date, today: Date): { date: string; tentative: boolean } {
const t = (due || '').trim();
const iso = t.match(/(\d{4})-(\d{1,2})-(\d{1,2})/);
if (iso) {
return { date: `${iso[1]}-${iso[2].padStart(2, '0')}-${iso[3].padStart(2, '0')}`, tentative: false };
}
const kor = t.match(/(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/);
if (kor) {
return { date: toYmd(new Date(Number(kor[1]), Number(kor[2]) - 1, Number(kor[3]))), tentative: false };
}
if (/차주|다음\s*주|내주/.test(t)) {
const d = new Date(meetingDate);
d.setDate(d.getDate() + 6);
return { date: toYmd(d), tentative: false };
}
if (/즉시|당일|금일|바로|오늘/.test(t)) {
return { date: toYmd(today), tentative: false };
}
// 변환 불가 — 등록일 + 영업일 5일, "(미확정)" 꼬리표.
return { date: toYmd(addBusinessDays(today, 5)), tentative: true };
}
/** 회의록 본문의 "## 5. 액션 아이템" 마크다운 표에서 행을 파싱. */
export function parseActionItems(report: string): { owner: string; work: string; due: string }[] {
const rows: { owner: string; work: string; due: string }[] = [];
let inSection = false;
for (const line of report.split('\n')) {
if (/^#{1,6}\s*5\.\s*액션\s*아이템/.test(line)) { inSection = true; continue; }
if (!inSection) continue;
if (/^#{1,6}\s/.test(line)) break; // 다음 섹션 시작 → 종료
if (!/^\s*\|/.test(line)) continue;
const cells = line.split('|').slice(1, -1).map((c) => c.trim());
if (cells.length < 3) continue;
if (/^:?-+:?$/.test(cells[0])) continue; // 표 구분선
if (cells[0] === '담당' || cells[1] === '작업 내용') continue; // 헤더
rows.push({ owner: cells[0], work: cells[1], due: cells[2] });
}
return rows;
}
File diff suppressed because it is too large Load Diff
+35 -4
View File
@@ -1,6 +1,15 @@
import { ProjectProfile } from './types';
export function buildProjectChronicleGuardContext(project: ProjectProfile | null): string {
export interface BuildGuardOptions {
/** true 면 4-section 보일러플레이트 (요청 요약 / / / )
* visible-heading **. follow-up / / turn . */
suppressTemplate?: boolean;
}
export function buildProjectChronicleGuardContext(
project: ProjectProfile | null,
options: BuildGuardOptions = {},
): string {
const hasUsableProject = !!project?.recordRoot?.trim();
const projectLines = project ? [
`Project selection status: selected`,
@@ -14,9 +23,23 @@ export function buildProjectChronicleGuardContext(project: ProjectProfile | null
'No active record project is selected. Before writing records, ask the user to select or create one.'
];
return [
...projectLines,
const templateLines = options.suppressTemplate ? [
// 짧은 follow-up / 정정 / 확인 turn — 보일러플레이트 헤더 강제 안 함.
'This turn is a short follow-up / correction / acknowledgement to the previous answer.',
'',
'Do NOT emit `## 요청 요약`, `## 사용자 의도 추론`, `## 프로젝트 기록 대상 확인`, `## 핵심 확인 질문`, or `## 간단 요약` headings — they belong on first-turn idea/feature requests, not on follow-ups.',
'',
'CRITICAL — DO NOT MINIMIZE TO ONE ECHO LINE EITHER.',
'사용자가 추가한 정보는 *직전 결론의 의미를 바꾼다* — 그것을 어떻게 바꾸는지 명시적으로 풀어야 한다. 사용자의 말을 그대로 한 문장으로 다시 말하는 것 (echo/parrot) 은 가장 나쁜 응답이다.',
'',
'Required structure (3-5 plain sentences, no `##` headings, no bullet lists):',
' Sentence 1: 새 정보가 직전 결론의 어떤 부분을 약화/강화/뒤집는지.',
' Sentence 2: 그 결과 결론을 어떻게 수정/유지하는지 ("결론 수정: …" 또는 "결론 유지 — 왜냐하면 …").',
' Sentence 3-5: 그 수정/유지의 *근거* 또는 다음에 확인할 구체적 한 가지.',
'',
'Response length sanity check: 응답이 사용자 메시지보다 짧으면 거의 확실히 잘못된 응답이다.',
'',
] : [
'This guard is active for project ideas, feature requests, architecture proposals, implementation planning, and design decisions.',
'',
'Required response order for new ideas or feature requests:',
@@ -34,6 +57,12 @@ export function buildProjectChronicleGuardContext(project: ProjectProfile | null
'12. Put Vector DB, relational DB, knowledge graph, semantic search, and complex automation only under "Later expansion" unless the user explicitly asks for them now.',
'13. End with "Candidate records for this discussion" and list planning, discussions, decisions, development, bugs, or retrospectives paths as candidates.',
'',
];
return [
...projectLines,
'',
...templateLines,
'Decision policy:',
'- Do not mark a decision as accepted until the user confirms it.',
'- Before confirmation, call decisions "candidates" or "pending".',
@@ -62,7 +91,9 @@ export function buildProjectChronicleGuardContext(project: ProjectProfile | null
'- If the user is using the tool to organize their thinking, reflect the shape of their uncertainty and turn it into 1-2 concrete choices.',
'- Keep the top conclusion calm and short so the user can understand the answer before reading the long version.',
'- Prefer short paragraphs with blank lines between numbered sections. Avoid starting most lines with `*` or `-` bullets.',
'- Use visible markdown headings such as `## 간단 요약`, `## 요청 요약`, `## 상세 답변`, `## 사용자 의도 추론`, and `## 핵심 확인 질문` for major sections.',
...(options.suppressTemplate
? ['- 헤더 (`##`) 사용 금지 — 이 turn 은 짧은 follow-up 이라 자연 문장으로만 답해라.']
: ['- Use visible markdown headings such as `## 간단 요약`, `## 요청 요약`, `## 상세 답변`, `## 사용자 의도 추론`, and `## 핵심 확인 질문` for major sections.']),
'- Avoid grand phrases like advanced cognitive architecture, compounding knowledge, perfect graph, or ultimate knowledge distiller.',
'- When the user wants low dependency, keep the first proposal to Markdown, JSON, local files, and explicit user save actions.',
'- Do not jump directly to large architectures. Narrow direction before expanding.',
@@ -85,6 +85,7 @@ interface SettingsState {
chatTemperature: number;
chunkedSwitchTokens: number;
chunkedMaxSections: number;
polishPersonaOverride: string;
};
datacollect: {
bridgeUrl: string;
@@ -593,6 +594,10 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
if (typeof msg.chunkedMaxSections === 'number' && Number.isFinite(msg.chunkedMaxSections)) {
await this._safeConfigUpdate('chunkedMaxSections', Math.max(1, Math.min(10, Math.floor(msg.chunkedMaxSections))));
}
if (typeof msg.polishPersonaOverride === 'string') {
// 빈 문자열도 유효한 값 (default persona 로 되돌리기). trim 으로 공백만 입력 무력화.
await this._safeConfigUpdate('polishPersonaOverride', msg.polishPersonaOverride.trim());
}
}
// ────────────── Datacollect (slash 명령) ──────────────
@@ -667,6 +672,7 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
chatTemperature: cfg.get<number>('chatTemperature', 0.3) ?? 0.3,
chunkedSwitchTokens: cfg.get<number>('chunkedSwitchTokens', 50000) ?? 50000,
chunkedMaxSections: cfg.get<number>('chunkedMaxSections', 3) ?? 3,
polishPersonaOverride: cfg.get<string>('polishPersonaOverride', '') ?? '',
},
datacollect: {
bridgeUrl: cfg.get<string>('datacollectBridgeUrl', '') || '',
+237
View File
@@ -0,0 +1,237 @@
import * as vscode from 'vscode';
import { AIService } from '../../core/services';
import { logError, logInfo } from '../../utils';
import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands';
import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
import type { DiscoveredCandidate } from './stockDiscovery';
/**
* Discover LLM * Top 5* + / .
*
* (slashStocks cmdDiscover chain):
* 1) buildAnalysisPrompt(candidates) 20 LLM
* 2) AIService.chat(...) Astra
* 3) parseTopFive(output) LLM 5 ()
* 4) renderForChat / renderForTelegram
* 5) sendToTelegram(...) chatId ( silent)
*
* (gemma 4B ) parseTopFive **
* 5 , raw text fallback.
*/
export interface TopFiveItem {
rank: number;
name: string;
symbol: string;
/** LLM 이 한 줄로 정리한 매력 포인트. */
pitch: string;
/** 원본 후보 (선택적으로 ROE/영업이익률 등 인용용). */
candidate?: DiscoveredCandidate;
}
export interface TopFiveResult {
items: TopFiveItem[];
/** LLM 의 종합 코멘트 (1-2 문장). 없을 수 있음. */
summary?: string;
/** LLM 응답 원문 (디버그 / fallback 출력용). */
raw: string;
}
const SYSTEM_PROMPT = [
'당신은 한국 주식 발굴 결과를 검토하는 가치 투자 분석가다.',
'제공된 후보 종목들의 *재무 지표* 와 *통과 키워드* 를 보고 가장 매력적인 5개를 골라라.',
'',
'**평가 기준 (우선순위 순):**',
' 1. 통과 키워드 수 — 많을수록 우수 (3개 통과 < 5개 통과 < 6개 통과)',
' 2. ROE 절대 수치 — 15% 이상 강하게 가산',
' 3. 영업이익률 — 20% 이상 가산 (가격 결정력 + 마진 안정)',
' 4. 유보율 — 1,000% 이상 통과, 3,000% 이상 안정성 가산',
' 5. PBR — 낮을수록 가산, 단 1.0 미만은 *value trap* 가능성 cross-check',
'',
'**출력 형식 (반드시 이대로):**',
'🎯 매력도 Top 5',
'',
'1. <종목명> (<6자리 심볼>) — <한 줄 매력 포인트 30자 이내>',
' 근거: ROE x%, 영업이익률 y%, 유보율 z%, <통과 N키워드 인용>',
'2. ...',
'...',
'5. ...',
'',
'종합: <1-2 문장 — 이번 발굴 batch 의 공통 특징 또는 주의점>',
'',
'*다른 텍스트 절대 추가 금지.* 출력 첫 줄은 정확히 "🎯 매력도 Top 5" 이어야 한다.',
].join('\n');
function buildAnalysisPrompt(candidates: DiscoveredCandidate[]): string {
const lines: string[] = [
`발굴 후보 ${candidates.length}개. 매력도 Top 5 골라라.`,
'',
];
for (const c of candidates) {
const f = c.fundamentals;
lines.push(
`· ${c.name} (${c.symbol}) [${c.market} · ${c.marketCapEok.toLocaleString()}억]`,
` ROE ${f.roe?.toFixed(1) ?? '-'}% · 영업이익률 ${f.operatingMargin?.toFixed(1) ?? '-'}% · 유보율 ${f.retentionRatio?.toLocaleString() ?? '-'}% · 부채비율 ${f.debtRatio?.toFixed(1) ?? '-'}% · PER ${f.per?.toFixed(1) ?? '-'} · PBR ${f.pbr?.toFixed(1) ?? '-'}`,
` 통과 (${c.passedKeywords.length}): ${c.passedKeywords.join(', ')}`,
'',
);
}
return lines.join('\n');
}
/**
* LLM 5 . :
* - "1. <이름> (<6자리>) — <문구>" / "1) <이름> ..." / "1: ..."
* - 6 cross-check
*/
function parseTopFive(raw: string, candidates: DiscoveredCandidate[]): TopFiveResult {
const items: TopFiveItem[] = [];
const lines = raw.split('\n');
const symbolMap = new Map(candidates.map(c => [c.symbol, c]));
const itemRe = /^\s*([1-5])[\.\)\:]\s+(.+?)\s*[\(\[](\d{6})[\)\]]\s*[—\-:]\s*(.+)$/;
for (const line of lines) {
const m = line.match(itemRe);
if (!m) continue;
const rank = parseInt(m[1], 10);
if (items.find(i => i.rank === rank)) continue;
items.push({
rank,
name: m[2].trim(),
symbol: m[3],
pitch: m[4].trim(),
candidate: symbolMap.get(m[3]),
});
}
items.sort((a, b) => a.rank - b.rank);
// 종합 코멘트 추출 — "종합:" 또는 "총평:" 라인.
const summaryMatch = raw.match(/(?:|)\s*[:]\s*(.+?)(?:\n\n|$)/s);
const summary = summaryMatch ? summaryMatch[1].replace(/\s+/g, ' ').trim() : undefined;
return { items, summary, raw };
}
export async function analyzeTopCandidates(
candidates: DiscoveredCandidate[],
onProgress?: (msg: string) => void,
): Promise<TopFiveResult> {
if (candidates.length === 0) {
return { items: [], raw: '' };
}
onProgress?.('🤖 LLM 분석 시작 — 후보 ' + candidates.length + '개 평가 중...');
const ai = new AIService();
try {
const result = await ai.chat({
system: SYSTEM_PROMPT,
user: buildAnalysisPrompt(candidates),
timeoutMs: 90_000,
});
if (result.empty || !result.content.trim()) {
logError('Discovery analyzer: LLM 빈 응답.');
return { items: [], raw: '' };
}
const parsed = parseTopFive(result.content, candidates);
logInfo('Discovery analyzer: parsed Top 5.', {
parsedCount: parsed.items.length,
model: result.model,
});
return parsed;
} catch (e: any) {
logError('Discovery analyzer: LLM 호출 실패.', { error: e?.message ?? String(e) });
return { items: [], raw: '' };
}
}
/** 채팅 webview 용 — markdown 친화적 멀티라인. */
export function renderTopFiveForChat(result: TopFiveResult): string {
if (result.items.length === 0) {
return result.raw
? `\n🤖 **LLM 분석 결과** (형식 파싱 실패 — 원문 표시)\n\n${result.raw}\n`
: '\n⚠️ LLM 분석 실패 (빈 응답 또는 timeout).\n';
}
const lines: string[] = ['\n🎯 **Astra 매력도 Top 5**\n'];
for (const it of result.items) {
lines.push(`${it.rank}. **${it.name}** (${it.symbol}) — ${it.pitch}`);
if (it.candidate) {
const f = it.candidate.fundamentals;
lines.push(` 근거: ROE ${f.roe?.toFixed(1) ?? '-'}%, 영업이익률 ${f.operatingMargin?.toFixed(1) ?? '-'}%, 유보율 ${f.retentionRatio?.toLocaleString() ?? '-'}%, 통과 ${it.candidate.passedKeywords.length}개 (${it.candidate.passedKeywords.join(', ')})`);
}
}
if (result.summary) {
lines.push('', `💬 ${result.summary}`);
}
return lines.join('\n') + '\n';
}
/** 텔레그램용 — Markdown V1, 짧고 깔끔. 4096자 제한 안에서. */
function pickChatIdForReport(): number | null {
const cfg = vscode.workspace.getConfiguration('g1nation');
const allowed = cfg.get<number[]>('telegram.allowedChatIds', []) || [];
if (allowed.length > 0 && Number.isFinite(allowed[0]) && allowed[0] !== 0) {
return allowed[0];
}
const fallback = cfg.get<number>('stocks.telegramChatId', 0);
return fallback && Number.isFinite(fallback) ? fallback : null;
}
function formatKstNow(): string {
const fmt = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
});
const parts = fmt.formatToParts(new Date());
const get = (t: string) => parts.find(p => p.type === t)?.value || '';
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')} KST`;
}
export function renderTopFiveForTelegram(
result: TopFiveResult,
rangeLabel: string,
): string {
if (result.items.length === 0) {
return `🎯 *Astra 발굴 Top 5* (${rangeLabel})\n${formatKstNow()}\n\n⚠️ LLM 분석 실패 — 채팅 창에서 raw 결과 확인.`;
}
const lines: string[] = [
`🎯 *Astra 발굴 Top 5* (${rangeLabel})`,
formatKstNow(),
'',
];
for (const it of result.items) {
lines.push(`${it.rank}\\. *${it.name}* (\`${it.symbol}\`)`);
lines.push(` ${it.pitch}`);
if (it.candidate) {
const f = it.candidate.fundamentals;
lines.push(` ROE ${f.roe?.toFixed(1) ?? '-'}% · OM ${f.operatingMargin?.toFixed(1) ?? '-'}% · 유보 ${f.retentionRatio?.toLocaleString() ?? '-'}%`);
}
lines.push('');
}
if (result.summary) {
lines.push(`💬 ${result.summary}`);
}
const joined = lines.join('\n');
return joined.length > 3800 ? joined.slice(0, 3800) + '\n…(잘림)' : joined;
}
export async function sendTopFiveToTelegram(
context: vscode.ExtensionContext,
text: string,
): Promise<{ ok: boolean; reason?: string }> {
const chatId = pickChatIdForReport();
if (chatId === null) {
return { ok: false, reason: '텔레그램 chatId 미설정 (allowedChatIds 또는 stocks.telegramChatId)' };
}
const token = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
if (!token.trim()) {
return { ok: false, reason: '텔레그램 봇 토큰 없음' };
}
const client = new TelegramHttpClient({ getToken: () => token });
try {
await client.sendMessage({ chatId, text, parseMode: 'Markdown' });
logInfo('Top 5 텔레그램 발송 완료.', { chatId, chars: text.length });
return { ok: true };
} catch (e: any) {
logError('Top 5 텔레그램 발송 실패.', { chatId, error: e?.message ?? String(e) });
return { ok: false, reason: e?.message ?? String(e) };
}
}
+2
View File
@@ -0,0 +1,2 @@
export { startStocksWatcher, runOnceNow } from './stocksWatcher';
export { handleStocksCommand } from './slashStocks';
+127
View File
@@ -0,0 +1,127 @@
import { AIService } from '../../core/services';
import { logError, logInfo } from '../../utils';
import { readStocksStore, updateStock } from './stocksStore';
import type { Stock } from './types';
/**
* `/stocks judge <심볼>` LLM
* "3/4 필터" 4-criteria (ROE / / / )
* stocks.json `"3/4 필터"` .
*
* LLM trade-off **
* "자동 평가" prefix .
*
* (LLM ):
* : "충족 (ROE, 성장성, 유동성)" "미충족"
* ~ : () webview
*
* `.includes("충족")` unchanged signalClassifier .
*/
export interface JudgeResult {
ok: boolean;
/** stocks.json 에 저장된 새 "3/4 필터" 텍스트. */
filterText?: string;
/** LLM 이 제시한 평가 근거 (사용자에게 보여줌). */
rationale?: string;
error?: string;
}
const SYSTEM_PROMPT = [
'당신은 한국 주식 가치 평가 보조 도구다. 사용자가 제공한 종목의 *재무 지표만* 보고',
'필터 평가를 내린다. 외부 정보 추측 금지 — 주어진 숫자에서만 판단.',
'',
'**사용 가능한 8개 평가 키워드** (사용자의 실제 분류 패턴):',
' - ROE: ROE(25E) ≥ 10% 이면 통과. 15% 이상은 우수 (통과 강하게).',
' - 성장성: 영업이익률(25E) ≥ 15% 또는 상장일이 3년 이내 신규성장주.',
' - 유동성: 유보율 ≥ 1,000% 이면 통과 (자기자본 충분).',
' - 수익성: 영업이익률(25E) ≥ 10% 이면 통과. 20% 이상이면 "수익성 개선" 표기 가능.',
' - 영업효율: 영업이익률(25E) ≥ 15% + ROE ≥ 8% 동시 만족 (효율성 시그널).',
' - 기술력: "최대 먹거리" 가 AI / 반도체 / 배터리 / 바이오 / 기술 영역이고 PBR ≥ 2 (시장이 기술 premium 인정).',
' - 안정성: 유보율 ≥ 3,000% + 시가총액 ≥ 5,000억 (대형주 안전).',
' - PBR: PBR ≤ 1.5 이면 통과 (저평가 시그널, 저평가우량주에서 주로 사용).',
'',
'**투자성향별 우선 적용 키워드:**',
' - 스윙/중기: ROE / 성장성 / 유동성 / 수익성 위주.',
' - 장기투자: 성장성 / 유동성 / 기술력 / 영업효율 위주.',
' - 저평가우량주: PBR / ROE 필수 + (성장성 또는 수익성 또는 안정성) 중 1개.',
'',
'**판정 규칙:**',
' - 위 8개 키워드 중 *3개 이상* 통과 → "충족 (통과한 항목들 콤마 구분)" 형식.',
' 예: "충족 (ROE, 성장성, 유동성)" / "충족 (PBR, ROE, 안정성)" / "충족 (성장성, 유동성, 수익성 개선)"',
' - 3개 미만 → "미충족 (사유: 핵심 약점 한 줄)"',
'',
'**키워드 선택 가이드:**',
' - 한 종목에서 통과한 키워드 *모두* 나열하지 말고 *그 종목 성격을 가장 잘 보여주는 3개* 만 선택.',
' - 투자성향별 우선 키워드를 먼저 포함시키고, 빠진 부분은 다른 키워드로 메움.',
'',
'**실제 패턴 예시** (학습용 — 사용자가 이미 분류한 종목들):',
' - 마녀공장 (ROE 15.6%, 영업이익률 18.0%, 유보율 5,800%, 스윙/중기) → "충족 (ROE, 성장성, 유동성)"',
' - 기가비스 (ROE 7.23%, 영업이익률 25.7%, 유보율 4,250%, 신규 상장 2년차, 스윙/중기) → "충족 (성장성, 유동성, 수익성 개선)" (ROE 가 10% 미만이라 빠짐, 대신 수익성 강함)',
' - 엔켐 (ROE 12.4%, 영업이익률 8.5%, 유보율 1,250%, 스윙/중기) → "충족 (성장성, 유동성)" (영업이익률이 10% 미만이라 수익성 빠짐)',
'',
'**출력 형식 (반드시 이대로):**',
' 1번째 줄: 위 형식의 판정 문구만 (다른 텍스트 절대 금지)',
' 2번째 줄: 빈 줄',
' 3번째 줄 이후: 평가 근거 2-3 문장 — 어떤 지표가 어떤 threshold 를 통과/미통과했는지 *구체 수치 인용*.',
].join('\n');
function buildUserPrompt(s: Stock): string {
const lines = [
`종목: ${s.} (${s.})`,
`상장일: ${s. ?? '미상'}`,
`투자성향: ${s. ?? '미분류'}`,
`유보율: ${s. ?? '-'}`,
`ROE(25E): ${s['ROE(25E)'] ?? '-'}`,
`영업이익률(25E): ${s['영업이익률(25E)'] ?? '-'}`,
`EPS(25E): ${s['EPS(25E)'] ?? '-'}`,
`PER(25E): ${s['PER(25E)'] ?? '-'}`,
`PBR: ${s.PBR ?? '-'}`,
`시가총액: ${s. ?? '-'}`,
`최대 먹거리: ${s['최대 먹거리'] ?? '-'}`,
`특이사항: ${s. ?? '-'}`,
'',
'위 데이터로 4-criteria 필터 판정.',
];
return lines.join('\n');
}
export async function judgeStock(symbol: string): Promise<JudgeResult> {
const store = readStocksStore();
const stock = store.find(s => s. === symbol);
if (!stock) {
return { ok: false, error: `심볼 ${symbol} 을 stocks.json 에서 못 찾음` };
}
const ai = new AIService();
try {
const result = await ai.chat({
system: SYSTEM_PROMPT,
user: buildUserPrompt(stock),
});
if (result.empty || !result.content.trim()) {
return { ok: false, error: 'LLM 이 빈 응답 반환' };
}
const lines = result.content.split('\n');
const firstLine = (lines[0] || '').trim();
const rationale = lines.slice(2).join('\n').trim() || undefined;
if (!firstLine) return { ok: false, error: 'LLM 응답 형식 오류 (첫 줄 비어있음)' };
// 텍스트 형식 검증 — "충족" 또는 "미충족" 으로 시작해야 signalClassifier 가 인식.
if (!/^(충족|미충족)/.test(firstLine)) {
return { ok: false, error: `LLM 응답 형식 오류 (충족/미충족 prefix 없음): ${firstLine.slice(0, 80)}` };
}
// "자동 평가" prefix 박아서 수동/자동 구분 가능하게.
const filterText = `[자동 평가] ${firstLine}`;
const wrote = updateStock(symbol, { '3/4 필터': filterText });
if (!wrote) return { ok: false, error: 'stocks.json 쓰기 실패' };
logInfo('Stocks LLM judge 완료.', { symbol, filterText, model: result.model });
return { ok: true, filterText, rationale };
} catch (e: any) {
logError('Stocks LLM judge 실패.', { symbol, error: e?.message ?? String(e) });
return { ok: false, error: e?.message ?? String(e) };
}
}
+161
View File
@@ -0,0 +1,161 @@
import { logError, logInfo } from '../../utils';
/**
* Naver Finance JSON API fetch.
*
* endpoint :
* - `/api/stock/<code>/integration` / PER / PBR / EPS /
* - `/api/stock/<code>/finance/annual` ROE / / /
* (rowList row.title , )
*
* JSON selector , label (rowList[i].title === 'ROE').
* / API Naver Finance .
*/
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const INTEGRATION_URL = 'https://m.stock.naver.com/api/stock';
export interface Fundamentals {
symbol: string;
/** 연간 재무제표 (최근 *확정* 연도). */
roe?: number; // %
operatingMargin?: number; // % (영업이익률)
retentionRatio?: number; // % (유보율)
debtRatio?: number; // % (부채비율)
/** integration API 의 현재가 + 평가지표. */
per?: number;
pbr?: number;
eps?: number;
marketCapEok?: number; // 억 단위
currentPrice?: number;
/** 업종 hint — 사용 가능하면 채움 ("기술력" 키워드 매칭 용). */
sectorHint?: string;
}
interface NaverIntegrationResponse {
stockName?: string;
totalInfos?: Array<{ code: string; key: string; value: string }>;
/** 종목 분류 / 업종 정보가 들어있을 수 있음 (옵션). */
industryInfo?: { name?: string };
}
interface NaverFinanceAnnualResponse {
financeInfo?: {
trTitleList?: Array<{ isConsensus: 'Y' | 'N'; title: string; key: string }>;
rowList?: Array<{ title: string; columns: Record<string, { value: string }> }>;
};
}
/** "12,090" / "23.64배" / "5,800%" / "1,710조 365억" / "-" 텍스트에서 숫자 추출. */
function parseNumber(raw: string | undefined): number | undefined {
if (!raw) return undefined;
const cleaned = raw.replace(/,/g, '').replace(/배|%|원|억|조/g, '').trim();
if (!cleaned || cleaned === '-' || cleaned === 'N/A') return undefined;
const n = parseFloat(cleaned);
return Number.isFinite(n) ? n : undefined;
}
/** "1,710조 365억" / "2,787억" / "5조" → 억 단위 정수. */
function parseMarketCapText(text: string | undefined): number | undefined {
if (!text) return undefined;
const cleaned = text.replace(/원|\s/g, '');
const joMatch = cleaned.match(/([\d,]+)조/);
const eokMatch = cleaned.match(/(?:조)?([\d,]+)억/) || cleaned.match(/([\d,]+)$/);
const jo = joMatch ? parseInt(joMatch[1].replace(/,/g, ''), 10) : 0;
const eok = eokMatch ? parseInt(eokMatch[1].replace(/,/g, ''), 10) : 0;
const total = jo * 10000 + eok;
return total > 0 ? total : undefined;
}
/** trTitleList 에서 *최근 확정* (isConsensus = 'N') 컬럼 키 선택. */
function pickLatestConfirmedKey(titles?: Array<{ isConsensus: 'Y' | 'N'; key: string }>): string | null {
if (!titles || titles.length === 0) return null;
// 'N' 만 필터 → key 내림차순 → 첫 번째.
const confirmed = titles.filter(t => t.isConsensus === 'N').map(t => t.key).sort().reverse();
return confirmed[0] ?? null;
}
async function fetchIntegration(symbol: string, timeoutMs: number): Promise<NaverIntegrationResponse | null> {
try {
const res = await fetch(`${INTEGRATION_URL}/${symbol}/integration`, {
headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' },
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) return null;
return await res.json() as NaverIntegrationResponse;
} catch (e: any) {
logError('Naver integration fetch 실패.', { symbol, error: e?.message ?? String(e) });
return null;
}
}
async function fetchFinanceAnnual(symbol: string, timeoutMs: number): Promise<NaverFinanceAnnualResponse | null> {
try {
const res = await fetch(`${INTEGRATION_URL}/${symbol}/finance/annual`, {
headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' },
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) return null;
return await res.json() as NaverFinanceAnnualResponse;
} catch (e: any) {
logError('Naver finance annual fetch 실패.', { symbol, error: e?.message ?? String(e) });
return null;
}
}
export async function fetchFundamentals(symbol: string, timeoutMs = 10000): Promise<Fundamentals | null> {
const [integ, fin] = await Promise.all([
fetchIntegration(symbol, timeoutMs),
fetchFinanceAnnual(symbol, timeoutMs),
]);
if (!integ && !fin) return null;
const out: Fundamentals = { symbol };
// integration — totalInfos 의 code 로 추출 (key 한글 텍스트보다 안정적).
if (integ?.totalInfos) {
const map = new Map(integ.totalInfos.map(i => [i.code, i.value]));
out.per = parseNumber(map.get('per'));
out.pbr = parseNumber(map.get('pbr'));
out.eps = parseNumber(map.get('eps'));
out.currentPrice = parseNumber(map.get('closePrice') || map.get('lastClosePrice'));
out.marketCapEok = parseMarketCapText(map.get('marketValue'));
}
if (integ?.industryInfo?.name) out.sectorHint = integ.industryInfo.name;
// finance/annual — rowList 에서 최근 확정 연도 컬럼 값.
if (fin?.financeInfo?.rowList && fin.financeInfo.rowList.length > 0) {
const latestKey = pickLatestConfirmedKey(fin.financeInfo.trTitleList);
if (latestKey) {
const rowByTitle = new Map(fin.financeInfo.rowList.map(r => [r.title, r]));
const valueOf = (title: string): number | undefined => {
const row = rowByTitle.get(title);
if (!row) return undefined;
return parseNumber(row.columns[latestKey]?.value);
};
out.roe = valueOf('ROE');
out.operatingMargin = valueOf('영업이익률');
out.retentionRatio = valueOf('유보율');
out.debtRatio = valueOf('부채비율');
}
}
return out;
}
/** 일괄 fetch — throttle 300ms. JSON API 가 가벼우니 HTML 크롤 500ms 보다 빠르게. */
export async function fetchAllFundamentals(
symbols: string[],
onProgress?: (symbol: string, fund: Fundamentals | null, i: number, total: number) => void,
): Promise<Map<string, Fundamentals>> {
const out = new Map<string, Fundamentals>();
let i = 0;
for (const symbol of symbols) {
i++;
const fund = await fetchFundamentals(symbol);
if (fund) out.set(symbol, fund);
onProgress?.(symbol, fund, i, symbols.length);
await new Promise(r => setTimeout(r, 300));
}
logInfo(`Naver fundamentals 일괄 fetch: ${out.size}/${symbols.length} 성공.`);
return out;
}
+151
View File
@@ -0,0 +1,151 @@
import { logError, logInfo } from '../../utils';
/**
* Naver Finance * JSON API* fetch.
*
* - : `https://m.stock.naver.com/api/stocks/marketValue/KOSPI?page=N&pageSize=50`
* - : `https://m.stock.naver.com/api/stocks/marketValue/KOSDAQ?page=N&pageSize=50`
*
* :
* { stocks: [ { itemCode, stockName, marketValue, marketValueHangeul, closePrice, ... }, ... ] }
*
* `marketValueHangeul` "2,787억" / "1,710조 365억" .
* * * ( ) .
*
* Why JSON over HTML:
* - ( )
* - EUC-KR (JSON UTF-8)
* - cheerio
* - (HTML X, JSON )
*
* Caveat: ** Naver . ToS X. .
*/
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const BASE_URL = 'https://m.stock.naver.com/api/stocks/marketValue';
export type Market = 'kospi' | 'kosdaq';
export interface ScreenerEntry {
/** 6자리 종목코드. */
symbol: string;
/** 종목명. */
name: string;
/** 시가총액 (억원 단위 정수). marketValueHangeul 텍스트에서 파싱. */
marketCapEok: number;
/** 종가 (옵션). */
closePrice?: number;
market: Market;
}
/**
* "1,710조 365억" / "2,787억" / "17조" * * .
* - : ×10,000
* - : ×1
*/
function parseMarketCapHangeul(text: string | undefined): number {
if (!text) return 0;
const cleaned = text.replace(/원|\s/g, '');
const joMatch = cleaned.match(/([\d,]+)조/);
const eokMatch = cleaned.match(/(?:조)?([\d,]+)억/) || cleaned.match(/([\d,]+)$/);
const jo = joMatch ? parseInt(joMatch[1].replace(/,/g, ''), 10) : 0;
const eok = eokMatch ? parseInt(eokMatch[1].replace(/,/g, ''), 10) : 0;
// "1,710조 365억" → jo=1710, eok=365 → 17,103,650 안 됨. 1,710조 365 = 17,103,650 억? 아니다.
// 1,710조 = 17,100,000 억. + 365억 = 17,100,365 억.
return jo * 10000 + eok;
}
interface NaverStockListItem {
itemCode: string;
stockName: string;
marketValue?: string; // 백만원 단위 (단위 모호, 사용 안 함)
marketValueHangeul?: string; // "2,787억원" — 사용
closePrice?: string; // "12,090"
}
interface NaverMarketValueResponse {
stocks: NaverStockListItem[];
totalCount?: number;
pageSize?: number;
page?: number;
}
async function fetchPage(market: Market, page: number): Promise<NaverStockListItem[]> {
const category = market === 'kospi' ? 'KOSPI' : 'KOSDAQ';
const url = `${BASE_URL}/${category}?page=${page}&pageSize=50`;
const res = await fetch(url, {
headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error(`Naver screener HTTP ${res.status} (${market} p${page})`);
const data = await res.json() as NaverMarketValueResponse;
if (!Array.isArray(data.stocks)) {
throw new Error(`Naver screener: stocks 필드 누락 (${market} p${page})`);
}
return data.stocks;
}
function toEntry(item: NaverStockListItem, market: Market): ScreenerEntry {
const marketCapEok = parseMarketCapHangeul(item.marketValueHangeul);
const closePriceNum = item.closePrice
? parseFloat(item.closePrice.replace(/,/g, ''))
: undefined;
return {
symbol: item.itemCode,
name: item.stockName,
marketCapEok,
closePrice: Number.isFinite(closePriceNum as number) ? closePriceNum : undefined,
market,
};
}
/**
* ( maxPages) fetch. () 1 .
* - throttle: 300ms (HTML 500ms JSON ).
* - Naver , * maxCap * .
*/
export async function screenMarket(opts: {
market: Market;
maxPages?: number;
minMarketCapEok?: number;
maxMarketCapEok?: number;
onProgress?: (page: number, totalSoFar: number) => void;
}): Promise<ScreenerEntry[]> {
const maxPages = opts.maxPages ?? 20;
const minCap = opts.minMarketCapEok ?? 0;
const maxCap = opts.maxMarketCapEok ?? Number.MAX_SAFE_INTEGER;
const collected: ScreenerEntry[] = [];
for (let page = 1; page <= maxPages; page++) {
try {
const items = await fetchPage(opts.market, page);
if (items.length === 0) {
logInfo(`Naver screener p${page} ${opts.market}: 0 rows — 종료.`);
break;
}
const entries = items.map(it => toEntry(it, opts.market));
// 시총 큰 순 → 모든 종목 시총이 maxCap 보다 작아진 페이지부터는 maxCap 위 종목 없음 (정렬 보장).
// minCap 보다 작아진 페이지는 그 뒤 모든 종목이 더 작음 → early exit.
let pageBelowMin = true;
for (const e of entries) {
if (e.marketCapEok > maxCap) continue;
if (e.marketCapEok < minCap) continue;
collected.push(e);
pageBelowMin = false;
}
// 이 페이지의 *모든* 종목이 minCap 미만이면 더 작은 종목들만 남음 → early exit.
if (entries.every(e => e.marketCapEok < minCap)) {
logInfo(`Naver screener ${opts.market}: p${page} 전부 minCap(${minCap}억) 미만 — early exit.`);
opts.onProgress?.(page, collected.length);
break;
}
opts.onProgress?.(page, collected.length);
await new Promise(r => setTimeout(r, 300));
} catch (e: any) {
logError(`Naver screener p${page} ${opts.market} 실패.`, { error: e?.message ?? String(e) });
// partial 결과라도 반환 — 한 페이지 실패해도 continue.
}
}
logInfo(`Naver screener ${opts.market}: ${collected.length}개 (${minCap}억 ~ ${maxCap}억).`);
return collected;
}
+99
View File
@@ -0,0 +1,99 @@
import * as vscode from 'vscode';
import { writeSheetRange, type SheetValues } from '../sheets';
import { logError, logInfo } from '../../utils';
import { classifyAll } from './signalClassifier';
import { readStocksStore } from './stocksStore';
import type { ClassifiedStock } from './types';
/**
* Google Sheets .
*
* invest_results/gs_update_api.js :
* - Sheet1!A1:O = /
* - Sheet2!A1:O =
* - Sheet3!A1:O =
* ( spreadsheet 1/2/3 spreadsheet .)
*
* spreadsheet ID `g1nation.stocks.spreadsheetId` . skip .
* OAuth token calendar (oauth.ts SCOPE spreadsheets ).
*/
const HEADER = ['종목명', '심볼', '상장일', '현재가', '적정주가', '매수권장가', '3/4 필터', '매수 신호', 'ROE', 'PBR', '영업이익률', '유보율', 'PER', 'EPS', '특이사항'];
function buildSheetRows(classified: ClassifiedStock[]): SheetValues {
const rows: SheetValues = [HEADER];
for (const s of classified) {
rows.push([
s.,
s.,
s. ?? '',
s. ?? 0,
s. ?? '',
s. ?? '',
s.filterPass ? 'Pass' : 'Fail',
s.signalText,
s['ROE(25E)'] ?? '',
s.PBR ?? '',
s['영업이익률(25E)'] ?? '',
s. ?? '',
s['PER(25E)'] ?? '',
s['EPS(25E)'] ?? '',
s. ?? '',
]);
}
return rows;
}
/**
* 3 . / caller webview .
*
* Sheet 1/2/3 ** range .
* spreadsheet :
* 'Sheet1' / 'Sheet2' / 'Sheet3' override .
*/
export async function syncToSheets(
context: vscode.ExtensionContext,
): Promise<{ ok: boolean; errors: string[]; updatedRanges: string[] }> {
const cfg = vscode.workspace.getConfiguration('g1nation');
const spreadsheetId = (cfg.get<string>('stocks.spreadsheetId') || '').trim();
if (!spreadsheetId) {
return {
ok: false,
errors: ['Settings 에 g1nation.stocks.spreadsheetId 가 설정되지 않았습니다.'],
updatedRanges: [],
};
}
const sheetSwing = (cfg.get<string>('stocks.sheetSwing') || 'Sheet1').trim();
const sheetLong = (cfg.get<string>('stocks.sheetLong') || 'Sheet2').trim();
const sheetUltra = (cfg.get<string>('stocks.sheetUltraLow') || 'Sheet3').trim();
const store = readStocksStore();
const groups = classifyAll(store);
const errors: string[] = [];
const updatedRanges: string[] = [];
const tasks: Array<{ tab: string; rows: SheetValues; label: string }> = [
{ tab: sheetSwing, rows: buildSheetRows(groups.swing), label: '스윙/중기' },
{ tab: sheetLong, rows: buildSheetRows(groups.long), label: '장기투자' },
{ tab: sheetUltra, rows: buildSheetRows(groups.ultraLow), label: '저평가우량주' },
];
for (const t of tasks) {
if (t.rows.length <= 1) continue;
const range = `${t.tab}!A1:O${t.rows.length}`;
try {
const r = await writeSheetRange(context, spreadsheetId, range, t.rows);
if (r.ok) {
updatedRanges.push(r.updatedRange);
logInfo(`Stocks sheets sync: ${t.label} OK.`, { range: r.updatedRange, cells: r.updatedCells });
} else {
errors.push(`${t.label}: ${r.error}`);
logError(`Stocks sheets sync: ${t.label} 실패.`, { error: r.error });
}
} catch (e: any) {
errors.push(`${t.label}: ${e?.message ?? String(e)}`);
}
}
return { ok: errors.length === 0, errors, updatedRanges };
}
+69
View File
@@ -0,0 +1,69 @@
import type { Stock, ClassifiedStock, Signal } from './types';
/**
* invest_results/gs_update_api.js .
*
* 1. filterPass = "3/4 필터" "충족" ( , )
* 2. isPriceInZone = > 0 && > 0 && <=
* 3. signal:
* - filterPass + priceInZone BUY_ZONE (sortScore 2, "🚨 매수사정권!")
* - filterPass + !priceInZone OVERVALUED (sortScore 1, "⚠️ 고평가! 가격 조정 감시 중")
* - !filterPass HOLD (sortScore 0, "관망")
*
* ( /Sheets ).
*/
const SIGNAL_TEXT: Record<Signal, string> = {
BUY_ZONE: '🚨 매수사정권! (바닥 안착 시 매수 개시)',
OVERVALUED: '⚠️ 고평가! 가격 조정 감시 중',
HOLD: '관망',
};
/** "23,760" 같은 콤마 포함 텍스트 → number. 빈 문자열 / 파싱 실패 시 0. */
function parsePrice(raw: string | number | undefined): number {
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : 0;
if (!raw) return 0;
const cleaned = String(raw).replace(/,/g, '').trim();
const n = parseFloat(cleaned);
return Number.isFinite(n) ? n : 0;
}
export function classifyStock(s: Stock): ClassifiedStock {
const curPrice = parsePrice(s.);
const recPrice = parsePrice(s.);
const isPriceInZone = curPrice > 0 && recPrice > 0 && curPrice <= recPrice;
const filterPass = (s['3/4 필터'] || '').toString().includes('충족');
let signal: Signal = 'HOLD';
let sortScore: 0 | 1 | 2 = 0;
if (filterPass) {
if (isPriceInZone) { signal = 'BUY_ZONE'; sortScore = 2; }
else { signal = 'OVERVALUED'; sortScore = 1; }
}
return {
...s,
signal,
sortScore,
filterPass,
signalText: SIGNAL_TEXT[signal],
};
}
/** 전체 분류 + 투자성향별 정렬. 텔레그램 / 시트 동기화 둘 다 이걸 호출. */
export function classifyAll(stocks: Stock[]): {
swing: ClassifiedStock[];
long: ClassifiedStock[];
ultraLow: ClassifiedStock[];
all: ClassifiedStock[];
} {
const classified = stocks.map(classifyStock);
const filterByProfile = (profile: Stock['투자성향']) =>
classified.filter(s => s. === profile).sort((a, b) => b.sortScore - a.sortScore);
return {
swing: filterByProfile('스윙/중기'),
long: filterByProfile('장기투자'),
ultraLow: filterByProfile('저평가우량주'),
all: classified.slice().sort((a, b) => b.sortScore - a.sortScore),
};
}
+279
View File
@@ -0,0 +1,279 @@
import * as vscode from 'vscode';
import { logInfo } from '../../utils';
import { readStocksStore, addStock, removeStock, getStocksFilePath } from './stocksStore';
import { fetchAllPrices } from './yahooClient';
import { classifyAll } from './signalClassifier';
import { writeStocksStore } from './stocksStore';
import { syncToSheets } from './sheetsSync';
import { judgeStock } from './llmJudge';
import { sendStocksReport, buildReportText } from './telegramReport';
import { runOnceNow } from './stocksWatcher';
import { discoverStocks } from './stockDiscovery';
import { analyzeTopCandidates, renderTopFiveForChat, renderTopFiveForTelegram, sendTopFiveToTelegram } from './discoveryAnalyzer';
import type { ClassifiedStock, Stock } from './types';
/**
* `/stocks <subcommand> [args]` slashRouter handler
* sub-routing.
*
* Subcommands:
* /stocks
* /stocks list +
* /stocks check (Yahoo)
* /stocks signal
* /stocks sync Google Sheets
* /stocks add <> <> []
* /stocks remove <>
* /stocks judge <> LLM 4-criteria
* /stocks discover [min] [max] Naver ( , default 1000-5000)
* /stocks report
* /stocks run watcher (+sync+)
* /stocks path stocks.json
*/
interface Webview { postMessage(msg: any): Thenable<boolean> | boolean; }
function chunk(view: Webview | undefined, value: string) {
view?.postMessage({ type: 'streamChunk', value });
}
function formatPrice(n: number | undefined): string {
if (typeof n !== 'number' || !Number.isFinite(n)) return '-';
return n.toLocaleString();
}
function renderListLine(s: ClassifiedStock): string {
const cur = formatPrice(s.);
const rec = s. ?? '-';
return ` · ${s.} (${s.}): ${cur} / 권장 ${rec}${s.signalText}`;
}
async function cmdList(view: Webview | undefined): Promise<void> {
const store = readStocksStore();
if (store.length === 0) {
chunk(view, `\n종목 없음. \`/stocks add <심볼> <이름>\` 으로 추가하세요.\n경로: ${getStocksFilePath() ?? '(워크스페이스 없음)'}\n`);
return;
}
const g = classifyAll(store);
const lines: string[] = ['\n📋 **종목 목록 (분류별)**\n'];
if (g.swing.length) {
lines.push(`\n**스윙/중기** (${g.swing.length}개)`);
g.swing.forEach(s => lines.push(renderListLine(s)));
}
if (g.long.length) {
lines.push(`\n**장기투자** (${g.long.length}개)`);
g.long.forEach(s => lines.push(renderListLine(s)));
}
if (g.ultraLow.length) {
lines.push(`\n**저평가우량주** (${g.ultraLow.length}개)`);
g.ultraLow.forEach(s => lines.push(renderListLine(s)));
}
chunk(view, lines.join('\n') + '\n');
}
async function cmdCheck(view: Webview | undefined): Promise<void> {
const store = readStocksStore();
if (store.length === 0) { chunk(view, '\n종목 없음.\n'); return; }
chunk(view, `\n🔄 ${store.length}개 종목 현재가 갱신 중 (Yahoo, 1초/종목)...\n`);
const symbols = store.map(s => s.).filter(Boolean);
const prices = await fetchAllPrices(symbols, (sym, p) => {
const name = store.find(s => s. === sym)?. ?? sym;
chunk(view, ` · ${name}: ${p !== null ? p.toLocaleString() + '원' : '조회 실패'}\n`);
});
for (const s of store) {
const p = prices.get(s.);
if (typeof p === 'number') s. = p;
}
writeStocksStore(store);
const updated = [...prices.values()].filter(p => p !== null).length;
chunk(view, `\n✅ ${updated}/${store.length}개 종목 갱신 완료.\n`);
}
async function cmdSignal(view: Webview | undefined): Promise<void> {
const store = readStocksStore();
const g = classifyAll(store);
const buyZone = g.all.filter(s => s.signal === 'BUY_ZONE');
if (buyZone.length === 0) {
chunk(view, '\n🚨 매수사정권 종목 없음.\n');
return;
}
chunk(view, `\n🚨 **매수사정권 ${buyZone.length}개**\n\n`);
for (const s of buyZone) {
chunk(view, renderListLine(s) + '\n');
}
}
async function cmdSync(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
chunk(view, '\n📊 Google Sheets 동기화 중...\n');
const r = await syncToSheets(context);
if (r.ok) {
chunk(view, `\n✅ 동기화 완료: ${r.updatedRanges.length}개 시트.\n${r.updatedRanges.map(x => ` · ${x}`).join('\n')}\n`);
} else {
chunk(view, `\n❌ 동기화 실패:\n${r.errors.map(e => ` · ${e}`).join('\n')}\n`);
}
}
async function cmdAdd(arg: string, view: Webview | undefined): Promise<void> {
const parts = arg.split(/\s+/);
if (parts.length < 2) {
chunk(view, '\n사용법: `/stocks add <심볼> <이름> [투자성향]`\n 투자성향: 스윙/중기 | 장기투자 | 저평가우량주 (기본: 스윙/중기)\n');
return;
}
const [symbol, name, profileRaw] = parts;
const profile = (profileRaw as Stock['투자성향']) || '스윙/중기';
const r = addStock({ 이름: name, 심볼: symbol, 투자성향: profile });
chunk(view, r.ok ? `\n✅ 추가: ${name} (${symbol}, ${profile})\n` : `\n❌ ${r.reason}\n`);
}
async function cmdRemove(arg: string, view: Webview | undefined): Promise<void> {
if (!arg.trim()) { chunk(view, '\n사용법: `/stocks remove <심볼>`\n'); return; }
const r = removeStock(arg.trim());
chunk(view, r.ok ? `\n✅ 제거: ${arg.trim()}\n` : `\n❌ ${r.reason}\n`);
}
async function cmdJudge(arg: string, view: Webview | undefined): Promise<void> {
if (!arg.trim()) { chunk(view, '\n사용법: `/stocks judge <심볼>`\n'); return; }
const symbol = arg.trim();
chunk(view, `\n🤖 LLM 4-criteria 평가 중: ${symbol}...\n`);
const r = await judgeStock(symbol);
if (!r.ok) {
chunk(view, `\n❌ 평가 실패: ${r.error}\n`);
return;
}
chunk(view, `\n✅ 평가 완료: ${r.filterText}\n`);
if (r.rationale) chunk(view, `\n근거:\n${r.rationale}\n`);
}
async function cmdReport(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
chunk(view, '\n📨 텔레그램 보고서 발송 중...\n');
const r = await sendStocksReport(context);
chunk(view, r.ok ? '\n✅ 발송 완료.\n' : `\n❌ 발송 실패: ${r.reason}\n`);
chunk(view, `\n*Preview:*\n${buildReportText()}\n`);
}
async function cmdRun(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
chunk(view, '\n⚡ Watcher 1회 즉시 실행 (현재가 + Sheets + 텔레그램)...\n');
await runOnceNow(context);
chunk(view, '\n✅ 완료.\n');
}
async function cmdDiscover(rest: string, view: Webview | undefined, context?: vscode.ExtensionContext): Promise<void> {
// 인자 파싱 — `min max` (둘 다 억 단위) / 안 주면 default.
const parts = rest.split(/\s+/).filter(Boolean);
const minCap = parts[0] ? parseInt(parts[0], 10) : 1000;
const maxCap = parts[1] ? parseInt(parts[1], 10) : 5000;
if (Number.isNaN(minCap) || Number.isNaN(maxCap) || minCap >= maxCap) {
chunk(view, '\n사용법: `/stocks discover [min] [max]` (억 단위, 예: `/stocks discover 1000 5000`)\n');
return;
}
chunk(view, `\n🔍 **Naver 발굴 시작** (시총 ${minCap.toLocaleString()}억 ~ ${maxCap.toLocaleString()}억)\n`);
const candidates = await discoverStocks({
minMarketCapEok: minCap,
maxMarketCapEok: maxCap,
onProgress: (msg) => chunk(view, msg + '\n'),
});
if (candidates.length === 0) {
chunk(view, '\n결과 없음.\n');
return;
}
chunk(view, `\n📋 **발굴 후보 ${candidates.length}개** (통과 키워드 수 내림차순)\n\n`);
for (const c of candidates) {
const price = c.fundamentals.currentPrice;
const priceStr = typeof price === 'number' ? price.toLocaleString() + '원' : '-';
chunk(view, ` · ${c.name} (${c.symbol}) [${c.market} · ${c.marketCapEok.toLocaleString()}억]\n`);
chunk(view, ` 현재가 ${priceStr} · ROE ${c.fundamentals.roe?.toFixed(1) ?? '-'}% · 영업이익률 ${c.fundamentals.operatingMargin?.toFixed(1) ?? '-'}% · 유보율 ${c.fundamentals.retentionRatio?.toLocaleString() ?? '-'}%\n`);
chunk(view, ` 통과 (${c.passedKeywords.length}): ${c.passedKeywords.join(', ')}\n\n`);
}
// ── LLM 매력도 분석 + 텔레그램 전송 (자동 chain) ──
// 사용자 의도: 발굴 목록이 나오면 *항상* 분석 + 텔레그램. 별도 명령 trigger 불필요.
// 실패해도 (LLM timeout / 텔레그램 미설정) 위 발굴 목록은 화면에 그대로 남아 있음.
chunk(view, '\n');
const topFive = await analyzeTopCandidates(candidates, (msg) => chunk(view, msg + '\n'));
chunk(view, renderTopFiveForChat(topFive));
if (context) {
const rangeLabel = `시총 ${minCap.toLocaleString()}-${maxCap.toLocaleString()}`;
const tgText = renderTopFiveForTelegram(topFive, rangeLabel);
const tgResult = await sendTopFiveToTelegram(context, tgText);
chunk(view, tgResult.ok
? '\n📨 텔레그램 발송 완료.\n'
: `\n⚠️ 텔레그램 발송 skip: ${tgResult.reason}\n`);
} else {
chunk(view, '\n⚠️ 텔레그램 발송 skip: ExtensionContext 없음.\n');
}
chunk(view, '\n💡 종목을 stocks.json 에 추가하려면 `/stocks add <심볼> <이름>` 사용.\n');
}
function cmdPath(view: Webview | undefined): void {
const p = getStocksFilePath();
chunk(view, p ? `\n📂 stocks.json: \`${p}\`\n` : '\n⚠️ 워크스페이스 폴더 없음 — stocks 모듈 사용 불가.\n');
}
function cmdHelp(view: Webview | undefined): void {
chunk(view, [
'\n📈 **Stocks 명령**',
'',
' `/stocks list` — 종목 + 신호',
' `/stocks check` — 현재가 갱신',
' `/stocks signal` — 매수사정권 종목만',
' `/stocks sync` — Google Sheets 동기화',
' `/stocks add <심볼> <이름>` — 종목 추가',
' `/stocks remove <심볼>` — 종목 제거',
' `/stocks judge <심볼>` — LLM 4-criteria 평가',
' `/stocks discover [min] [max]` — Naver 크롤 발굴 (시총 억 단위, default 1000-5000)',
' `/stocks report` — 텔레그램 보고서 즉시 발송',
' `/stocks run` — Watcher 1회 즉시 실행',
' `/stocks path` — stocks.json 경로 표시',
'',
'자동 실행: VS Code 시작 시 활성화. KST 09:00 / 15:00 매일 자동.',
'',
].join('\n'));
}
/** slashRouter 가 `/stocks` 로 들어오는 모든 입력을 이 함수 한 곳으로 위임. */
export async function handleStocksCommand(
arg: string,
view: Webview | undefined,
context?: vscode.ExtensionContext,
): Promise<boolean> {
const parts = arg.trim().split(/\s+/);
const sub = (parts[0] || '').toLowerCase();
const rest = parts.slice(1).join(' ').trim();
logInfo(`Stocks slash: sub=${sub} rest="${rest.slice(0, 40)}"`);
try {
switch (sub) {
case '': cmdHelp(view); return true;
case 'list': await cmdList(view); return true;
case 'check': await cmdCheck(view); return true;
case 'signal': await cmdSignal(view); return true;
case 'sync':
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (sync 불가).\n'); return true; }
await cmdSync(view, context); return true;
case 'add': await cmdAdd(rest, view); return true;
case 'remove': case 'rm': await cmdRemove(rest, view); return true;
case 'judge': await cmdJudge(rest, view); return true;
case 'discover': await cmdDiscover(rest, view, context); return true;
case 'report':
if (!context) { chunk(view, '\n❌ ExtensionContext 없음.\n'); return true; }
await cmdReport(view, context); return true;
case 'run':
if (!context) { chunk(view, '\n❌ ExtensionContext 없음.\n'); return true; }
await cmdRun(view, context); return true;
case 'path': cmdPath(view); return true;
default:
chunk(view, `\n❌ 알 수 없는 sub-command: \`${sub}\`. \`/stocks\` 로 도움말 보기.\n`);
return true;
}
} catch (e: any) {
chunk(view, `\n❌ 에러: ${e?.message ?? String(e)}\n`);
return true;
}
}
+159
View File
@@ -0,0 +1,159 @@
import { logInfo } from '../../utils';
import { screenMarket, type Market, type ScreenerEntry } from './naverScreener';
import { fetchAllFundamentals, type Fundamentals } from './naverFundamentals';
import type { Stock } from './types';
/**
* `/stocks discover` Naver .
*
* :
* 1. ( + ) + ()
* 2. 1 (: 1,000 ~ 5,000)
* 3. ( 50-200) main
* 4. 8 (llmJudge.ts ) 3
* 5. ScreenedCandidate[] confirm stocks.json
*
* llmJudge * * discover judge * *
* mental model . ,
* judge LLM () discover ( ) .
* thresholds module (now: TODO).
*/
export interface DiscoverOptions {
/** 시가총액 하한 (억). default 1000 (1천억). */
minMarketCapEok?: number;
/** 시가총액 상한 (억). default 5000 (5천억). */
maxMarketCapEok?: number;
/** 시장 — default ['kospi', 'kosdaq']. */
markets?: Market[];
/** 시장당 크롤 페이지 수 — default 10 (페이지당 50개 = 500개/시장). */
maxPagesPerMarket?: number;
/** 최종 후보 최대 N개 — sortScore 내림차순 cut. default 20. */
limit?: number;
/** 진행률 콜백 (UI 가 사용). */
onProgress?: (msg: string) => void;
}
export interface DiscoveredCandidate {
symbol: string;
name: string;
market: Market;
marketCapEok: number;
/** 통과한 키워드들 (선별 후 최대 3개). */
passedKeywords: string[];
/** Stock 으로 변환된 형태 — stocks.json 에 그대로 추가 가능. */
asStock: Stock;
fundamentals: Fundamentals;
}
/** 8 키워드 각각의 통과 여부 판정 — llmJudge SYSTEM_PROMPT 의 임계값과 동일. */
function evaluateKeywords(f: Fundamentals): string[] {
const passed: string[] = [];
const roe = f.roe ?? 0;
const om = f.operatingMargin ?? 0;
const retention = f.retentionRatio ?? 0;
const pbr = f.pbr ?? Number.MAX_SAFE_INTEGER;
const mktCap = f.marketCapEok ?? 0;
const sector = (f.sectorHint || '').toLowerCase();
if (roe >= 10) passed.push('ROE');
if (om >= 15) passed.push('성장성');
if (retention >= 1000) passed.push('유동성');
if (om >= 10) {
// 10% 이상이면 "수익성", 20% 이상이면 "수익성 개선" 표기.
passed.push(om >= 20 ? '수익성 개선' : '수익성');
}
if (om >= 15 && roe >= 8) passed.push('영업효율');
const techKeywords = ['ai', '반도체', '배터리', '바이오', '기술', 'semicon', 'battery', 'bio'];
if (techKeywords.some(k => sector.includes(k)) && pbr >= 2) passed.push('기술력');
if (retention >= 3000 && mktCap >= 5000) passed.push('안정성');
if (pbr > 0 && pbr <= 1.5) passed.push('PBR');
return passed;
}
/** Fundamentals → Stock (stocks.json 호환 형식) 변환. */
function fundamentalsToStock(entry: ScreenerEntry, f: Fundamentals, filterText: string): Stock {
return {
이름: entry.name,
심볼: entry.symbol,
'유보율': f.retentionRatio !== undefined ? `${Math.round(f.retentionRatio).toLocaleString()}%` : undefined,
'ROE(25E)': f.roe !== undefined ? `${f.roe.toFixed(2)}%` : undefined,
'영업이익률(25E)': f.operatingMargin !== undefined ? `${f.operatingMargin.toFixed(1)}%` : undefined,
'PER(25E)': f.per !== undefined ? f.per.toFixed(1) : undefined,
PBR: f.pbr !== undefined ? f.pbr.toFixed(1) : undefined,
'시가총액': f.marketCapEok !== undefined ? `${Math.round(f.marketCapEok).toLocaleString()}` : undefined,
'3/4 필터': filterText,
현재가: f.currentPrice,
// 시가총액 기준 자동 분류 — 사용자가 나중에 수동으로 바꿀 수 있음.
투자성향: entry.marketCapEok < 3000 ? '저평가우량주' :
entry.marketCapEok < 10000 ? '스윙/중기' : '장기투자',
};
}
export async function discoverStocks(opts: DiscoverOptions = {}): Promise<DiscoveredCandidate[]> {
const minCap = opts.minMarketCapEok ?? 1000;
const maxCap = opts.maxMarketCapEok ?? 5000;
const markets = opts.markets ?? ['kospi', 'kosdaq'];
const maxPages = opts.maxPagesPerMarket ?? 10;
const limit = opts.limit ?? 20;
const progress = opts.onProgress ?? (() => {});
progress(`📡 Naver 시가총액 페이지 크롤 시작 — 시장 ${markets.join('+')}, 시총 ${minCap}-${maxCap}`);
// (1)+(2) 시가총액 페이지 → 1차 필터링.
const allEntries: ScreenerEntry[] = [];
for (const market of markets) {
progress(` · ${market} 스캔 중...`);
const entries = await screenMarket({
market,
maxPages,
minMarketCapEok: minCap,
maxMarketCapEok: maxCap,
onProgress: (page, total) => progress(` p${page} → 누적 ${total}`),
});
allEntries.push(...entries);
}
if (allEntries.length === 0) {
progress('⚠️ 시가총액 범위 내 후보 0개. 범위 넓혀서 다시 시도해보세요.');
return [];
}
progress(`\n✅ 1차 후보 ${allEntries.length}개 — 펀더멘털 크롤 시작 (~${Math.ceil(allEntries.length * 0.5)}초 소요).`);
// (3) 개별 펀더멘털 크롤.
const symbols = allEntries.map(e => e.symbol);
const fundsMap = await fetchAllFundamentals(symbols, (sym, _f, i, total) => {
if (i % 10 === 0 || i === total) progress(` 펀더멘털 ${i}/${total} 처리 중...`);
});
// (4) 8 키워드 평가.
const candidates: DiscoveredCandidate[] = [];
for (const entry of allEntries) {
const f = fundsMap.get(entry.symbol);
if (!f) continue;
const passed = evaluateKeywords(f);
if (passed.length < 3) continue;
// 상위 3개만 골라서 텍스트화 (judge 와 동일 패턴).
const top3 = passed.slice(0, 3);
const filterText = `[발굴 자동] 충족 (${top3.join(', ')})`;
candidates.push({
symbol: entry.symbol,
name: entry.name,
market: entry.market,
marketCapEok: entry.marketCapEok,
passedKeywords: passed,
asStock: fundamentalsToStock(entry, f, filterText),
fundamentals: f,
});
}
// sortScore — 통과 키워드 수 내림차순.
candidates.sort((a, b) => b.passedKeywords.length - a.passedKeywords.length);
const limited = candidates.slice(0, limit);
logInfo(`Stocks discovery: ${allEntries.length} 1차 후보 → ${candidates.length} 매수후보 → ${limited.length} 표시.`);
progress(`\n🎯 ${limited.length}개 후보 발굴 완료 (전체 1차후보 ${allEntries.length}개 중 ${candidates.length}개가 3 키워드 이상 통과).`);
return limited;
}
+96
View File
@@ -0,0 +1,96 @@
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import { logError, logInfo } from '../../utils';
import type { Stock, StocksStore } from './types';
/**
* `.astra/stocks.json` source of truth .
*
* (q1=A): .
* (= VS Code ) store
* watcher / slash silent skip.
*
* Atomic write: tmp rename read SIGKILL partial JSON
* .
*/
const STORE_REL_PATH = '.astra/stocks.json';
export function getStocksFilePath(): string | null {
const folders = vscode.workspace.workspaceFolders;
if (!folders || folders.length === 0) return null;
return path.join(folders[0].uri.fsPath, STORE_REL_PATH);
}
/** 파일 없으면 빈 배열 반환. 파일 파싱 실패해도 빈 배열 + 에러 로그. */
export function readStocksStore(): StocksStore {
const filePath = getStocksFilePath();
if (!filePath || !fs.existsSync(filePath)) return [];
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
logError('stocks.json 가 배열이 아닙니다 — 빈 store 반환.', { filePath });
return [];
}
return parsed as StocksStore;
} catch (e: any) {
logError('stocks.json 읽기 실패.', { filePath, error: e?.message ?? String(e) });
return [];
}
}
/** Atomic write — tmp + rename. 워크스페이스 없으면 false 반환 (caller 가 안내). */
export function writeStocksStore(store: StocksStore): boolean {
const filePath = getStocksFilePath();
if (!filePath) {
logError('워크스페이스 폴더가 없어 stocks.json 쓰기 불가.');
return false;
}
try {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8');
fs.renameSync(tmp, filePath);
return true;
} catch (e: any) {
logError('stocks.json 쓰기 실패.', { filePath, error: e?.message ?? String(e) });
return false;
}
}
/** 한 종목 추가. 같은 심볼이 이미 있으면 false (caller 가 안내). */
export function addStock(stock: Stock): { ok: boolean; reason?: string } {
const store = readStocksStore();
if (store.some(s => s. === stock.)) {
return { ok: false, reason: `심볼 ${stock.} 이미 존재` };
}
store.push(stock);
const wrote = writeStocksStore(store);
if (!wrote) return { ok: false, reason: '쓰기 실패 (워크스페이스 없음 또는 권한)' };
logInfo('Stocks: 종목 추가.', { symbol: stock., name: stock.이름 });
return { ok: true };
}
/** 한 종목 제거. 못 찾으면 false. */
export function removeStock(symbol: string): { ok: boolean; reason?: string } {
const store = readStocksStore();
const idx = store.findIndex(s => s. === symbol);
if (idx < 0) return { ok: false, reason: `심볼 ${symbol} 못 찾음` };
const removed = store.splice(idx, 1)[0];
const wrote = writeStocksStore(store);
if (!wrote) return { ok: false, reason: '쓰기 실패' };
logInfo('Stocks: 종목 제거.', { symbol, name: removed.이름 });
return { ok: true };
}
/** 한 종목의 필드 patch — 현재가 갱신 / 필터 업데이트 등. */
export function updateStock(symbol: string, patch: Partial<Stock>): boolean {
const store = readStocksStore();
const idx = store.findIndex(s => s. === symbol);
if (idx < 0) return false;
store[idx] = { ...store[idx], ...patch };
return writeStocksStore(store);
}
+163
View File
@@ -0,0 +1,163 @@
import * as vscode from 'vscode';
import { logError, logInfo } from '../../utils';
import { readStocksStore, writeStocksStore } from './stocksStore';
import { fetchAllPrices } from './yahooClient';
import { sendStocksReport } from './telegramReport';
import { syncToSheets } from './sheetsSync';
/**
* VS Code KST 09:00 / 15:00 :
* 1. Yahoo stocks.json
* 2. () Google Sheets g1nation.stocks.spreadsheetId
* 3.
*
* :
* - setTimeout chain firing setTimeout.
* - VS Code disposable clear.
* - Asia/Seoul macOS timezone .
* - run_kodari_sync.command sleep-loop setTimeout .
*
* SCHEDULE .
*/
const SCHEDULE_HOURS_KST = [9, 15]; // 09:00, 15:00 KST
let _timer: NodeJS.Timeout | undefined;
let _disposed = false;
/** Asia/Seoul 기준 *지금* 의 hour/minute. */
function nowInKst(): { date: Date; hour: number; minute: number; ymd: string } {
const now = new Date();
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Seoul',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).formatToParts(now);
const get = (t: string) => Number(parts.find(p => p.type === t)?.value || '0');
return {
date: now,
hour: get('hour'),
minute: get('minute'),
ymd: `${get('year')}-${parts.find(p => p.type === 'month')?.value}-${parts.find(p => p.type === 'day')?.value}`,
};
}
/**
* firing milliseconds .
* SCHEDULE_HOURS_KST * * , .
*
* 변환: 사용자 OS KST KST hour/minute
* ms ( × 60000).
*/
function msUntilNextRun(): number {
const kst = nowInKst();
const todayMinutes = kst.hour * 60 + kst.minute;
let bestTodayMinutes: number | null = null;
for (const h of SCHEDULE_HOURS_KST) {
const targetMinutes = h * 60;
if (targetMinutes > todayMinutes) {
bestTodayMinutes = targetMinutes;
break;
}
}
if (bestTodayMinutes !== null) {
return (bestTodayMinutes - todayMinutes) * 60_000;
}
// 오늘 더 없음 → 내일 첫 시각.
const tomorrowFirstMinutes = SCHEDULE_HOURS_KST[0] * 60;
const remainingTodayMinutes = 24 * 60 - todayMinutes;
return (remainingTodayMinutes + tomorrowFirstMinutes) * 60_000;
}
/**
* fire + () Sheets sync + .
* try/catch .
*/
async function fireOnce(context: vscode.ExtensionContext): Promise<void> {
const kst = nowInKst();
logInfo('Stocks watcher fire 시작.', { kst: `${kst.hour}:${kst.minute}` });
// (1) Yahoo 가격 갱신
try {
const store = readStocksStore();
const symbols = store.map(s => s.).filter(Boolean);
if (symbols.length === 0) {
logInfo('Stocks watcher: 종목 없음 — skip.');
} else {
const prices = await fetchAllPrices(symbols);
for (const s of store) {
const p = prices.get(s.);
if (typeof p === 'number') s. = p;
}
writeStocksStore(store);
}
} catch (e: any) {
logError('Stocks watcher: 가격 갱신 실패.', { error: e?.message ?? String(e) });
}
// (2) Sheets sync — 선택 (spreadsheetId 설정 시에만)
try {
const cfg = vscode.workspace.getConfiguration('g1nation');
if ((cfg.get<string>('stocks.spreadsheetId') || '').trim()) {
const r = await syncToSheets(context);
if (!r.ok) logError('Stocks watcher: Sheets 동기화 실패.', { errors: r.errors });
}
} catch (e: any) {
logError('Stocks watcher: Sheets 호출 실패.', { error: e?.message ?? String(e) });
}
// (3) Telegram 보고서
try {
const r = await sendStocksReport(context);
if (!r.ok) logInfo(`Stocks watcher: 보고서 skip — ${r.reason}`);
} catch (e: any) {
logError('Stocks watcher: 보고서 호출 실패.', { error: e?.message ?? String(e) });
}
}
function scheduleNext(context: vscode.ExtensionContext): void {
if (_disposed) return;
const ms = msUntilNextRun();
const hours = Math.floor(ms / 3600_000);
const minutes = Math.floor((ms % 3600_000) / 60_000);
logInfo(`Stocks watcher: 다음 firing 까지 ${hours}h ${minutes}m.`);
_timer = setTimeout(async () => {
try {
await fireOnce(context);
} catch (e: any) {
logError('Stocks watcher: fireOnce 예외.', { error: e?.message ?? String(e) });
}
scheduleNext(context);
}, ms);
}
/**
* VS Code (extension.ts activate ).
* `g1nation.stocks.watcherEnabled` false skip.
*/
export function startStocksWatcher(context: vscode.ExtensionContext): vscode.Disposable {
const cfg = vscode.workspace.getConfiguration('g1nation');
const enabled = cfg.get<boolean>('stocks.watcherEnabled', true);
if (!enabled) {
logInfo('Stocks watcher: 비활성 (g1nation.stocks.watcherEnabled=false).');
return { dispose: () => {} };
}
_disposed = false;
scheduleNext(context);
logInfo('Stocks watcher: 시작됨.', { schedule: SCHEDULE_HOURS_KST });
return {
dispose: () => {
_disposed = true;
if (_timer) { clearTimeout(_timer); _timer = undefined; }
logInfo('Stocks watcher: dispose.');
},
};
}
/** 명령으로 즉시 한 번 트리거 (`/stocks watch run` 같은 미래 명령 또는 디버깅). */
export async function runOnceNow(context: vscode.ExtensionContext): Promise<void> {
await fireOnce(context);
}
+117
View File
@@ -0,0 +1,117 @@
import * as vscode from 'vscode';
import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands';
import { logError, logInfo } from '../../utils';
import { readStocksStore } from './stocksStore';
import { classifyAll } from './signalClassifier';
import type { ClassifiedStock } from './types';
/**
* 09:00 / 15:00 KST Telegram .
*
* (q2=A): chatId `g1nation.telegram.allowedChatIds[0]` .
* fallback `g1nation.stocks.telegramChatId` . skip + .
* telegramCommands SecretStorage .
*/
function pickChatId(): number | null {
const cfg = vscode.workspace.getConfiguration('g1nation');
const allowed = cfg.get<number[]>('telegram.allowedChatIds', []) || [];
if (allowed.length > 0) return allowed[0];
const dedicated = cfg.get<number>('stocks.telegramChatId', 0);
if (dedicated && dedicated !== 0) return dedicated;
return null;
}
/** 한 카테고리 (스윙/장기/저평가) 의 종목 리스트를 텔레그램 Markdown 으로 렌더. */
function renderGroup(label: string, stocks: ClassifiedStock[]): string[] {
const buyZone = stocks.filter(s => s.signal === 'BUY_ZONE');
const overvalued = stocks.filter(s => s.signal === 'OVERVALUED');
const hold = stocks.filter(s => s.signal === 'HOLD');
const lines: string[] = [`*${label}* (총 ${stocks.length}개)`];
if (buyZone.length > 0) {
lines.push(`🚨 매수사정권 (${buyZone.length})`);
for (const s of buyZone) {
const cur = s. ? s..toLocaleString() : '?';
lines.push(` · ${s.} (${s.}): ${cur} / 권장 ${s. ?? '-'}`);
}
}
if (overvalued.length > 0) {
lines.push(`⚠️ 가격 조정 감시 (${overvalued.length})`);
for (const s of overvalued) {
const cur = s. ? s..toLocaleString() : '?';
lines.push(` · ${s.} (${s.}): ${cur} / 권장 ${s. ?? '-'}`);
}
}
if (hold.length > 0 && buyZone.length === 0 && overvalued.length === 0) {
// 모두 관망일 때만 그 카운트만 표시 — 종목 일일이 안 나열 (보고서 길이 절약).
lines.push(`📊 관망 ${hold.length}`);
} else if (hold.length > 0) {
lines.push(`📊 관망 ${hold.length}건 (생략)`);
}
return lines;
}
/** 보고서 텍스트 생성 (전송과 분리 — 테스트·로그용). */
export function buildReportText(now: Date = new Date()): string {
const store = readStocksStore();
if (store.length === 0) {
return '⚠️ stocks.json 에 종목 없음 — `/stocks add <심볼> <이름>` 으로 추가하세요.';
}
const g = classifyAll(store);
const kstStr = formatKstTimestamp(now);
const out: string[] = [`🦅 *Kodari 정기 보고서* _${kstStr}_`, ''];
out.push(...renderGroup('스윙/중기', g.swing), '');
out.push(...renderGroup('장기투자', g.long), '');
out.push(...renderGroup('저평가우량주', g.ultraLow));
return out.join('\n');
}
/** Date → "2026-05-25 09:00 KST" 형식. */
function formatKstTimestamp(d: Date): string {
const fmt = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
});
const parts = fmt.formatToParts(d);
const get = (t: string) => parts.find(p => p.type === t)?.value || '';
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')} KST`;
}
/**
* . chatId silent skip (warn ).
*
* / caller (watcher / slash ) .
*/
export async function sendStocksReport(
context: vscode.ExtensionContext,
): Promise<{ ok: boolean; reason?: string }> {
const chatId = pickChatId();
if (chatId === null) {
const reason = '텔레그램 chatId 미설정 (g1nation.telegram.allowedChatIds 또는 g1nation.stocks.telegramChatId).';
logInfo(`Stocks report skip: ${reason}`);
return { ok: false, reason };
}
const token = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
if (!token.trim()) {
const reason = '텔레그램 봇 토큰 없음 — `Astra: Set Telegram Bot Token` 으로 등록.';
logInfo(`Stocks report skip: ${reason}`);
return { ok: false, reason };
}
const text = buildReportText();
const client = new TelegramHttpClient({ getToken: () => token });
try {
await client.sendMessage({ chatId, text, parseMode: 'Markdown' });
logInfo('Stocks report 발송 완료.', { chatId, chars: text.length });
return { ok: true };
} catch (e: any) {
logError('Stocks report 발송 실패.', { chatId, error: e?.message ?? String(e) });
return { ok: false, reason: e?.message ?? String(e) };
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Stocks .
*
* invest_results/target_stocks.json , ConnectAI
* `<workspace>/.astra/stocks.json` .
*
* + JSON friction .
*/
/** target_stocks.json 의 한 종목 항목. */
export interface Stock {
이름: string;
심볼: string;
/** ISO date — 보통 'YYYY-MM-DD'. */
상장일?: string;
유보율?: string;
'ROE(25E)'?: string;
'영업이익률(25E)'?: string;
'EPS(25E)'?: string;
'PER(25E)'?: string;
PBR?: string;
시가총액?: string;
/** 사람이 계산한 적정주가 (텍스트). */
적정주가?: string;
/** 매수 추천 임계가. signalClassifier 가 현재가와 비교. */
매수권장가?: string;
설립일?: string;
/** 핵심 사업 한 줄. */
'최대 먹거리'?: string;
특이사항?: string;
/** "충족 (ROE, 성장성, 유동성)" 같은 텍스트. signalClassifier 는 `.includes("충족")` 로만 매칭. */
'3/4 필터'?: string;
/** Yahoo Finance 최근 fetch 한 현재가 (정수). 0 또는 누락이면 미수집. */
현재가?: number;
/** 분류 시트 — '스윙/중기' / '장기투자' / '저평가우량주' 중 하나. */
?: '스윙/중기' | '장기투자' | '저평가우량주';
}
export type StocksStore = Stock[];
/** 신호 분류 결과 — UI / 텔레그램 / 시트 동기화 모두 이 모양을 공유. */
export type Signal = 'BUY_ZONE' | 'OVERVALUED' | 'HOLD';
export interface ClassifiedStock extends Stock {
/** 정량 + 정성 필터 결합 결과. */
signal: Signal;
/** 텔레그램·시트 정렬용. 2 = 매수사정권, 1 = 가격 조정 감시, 0 = 관망. */
sortScore: 0 | 1 | 2;
/** `.includes("충족")` 결과 — 정성 필터 통과 여부. */
filterPass: boolean;
/** 사용자에게 표시할 한글 신호 문구. */
signalText: string;
}
+60
View File
@@ -0,0 +1,60 @@
import { logError, logInfo } from '../../utils';
/**
* Yahoo Finance public chart endpoint fetch. invest_results/quick_check.js
* symbol suffix `.KQ` () , `.KS` () .
*
* Yahoo `<6자리>.KQ` `<6자리>.KS` . US .
* symbol `.` .
*
* Returns null suffix skip .
*/
export async function fetchYahooPrice(symbol: string, timeoutMs = 8000): Promise<number | null> {
if (!symbol) return null;
const candidates: string[] = symbol.includes('.')
? [symbol]
: [`${symbol}.KQ`, `${symbol}.KS`];
for (const yahooSymbol of candidates) {
try {
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${yahooSymbol}`;
const res = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0' },
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) continue;
const data: any = await res.json();
const price = data?.chart?.result?.[0]?.meta?.regularMarketPrice;
if (typeof price === 'number' && Number.isFinite(price)) {
return price;
}
} catch (e: any) {
// suffix 후보가 더 남았으면 계속 시도, 마지막이면 null fallthrough.
if (yahooSymbol === candidates[candidates.length - 1]) {
logError('Yahoo Finance 현재가 조회 실패.', { symbol, yahooSymbol, error: e?.message ?? String(e) });
}
}
}
return null;
}
/**
* fetchYahooPrice 1 throttle (Yahoo rate limit).
* partial 허용: 실패해도 , Map .
*
* caller store patch.
*/
export async function fetchAllPrices(
symbols: string[],
onProgress?: (symbol: string, price: number | null) => void,
): Promise<Map<string, number | null>> {
const out = new Map<string, number | null>();
for (const symbol of symbols) {
const price = await fetchYahooPrice(symbol);
out.set(symbol, price);
onProgress?.(symbol, price);
await new Promise(r => setTimeout(r, 1000));
}
logInfo('Yahoo 일괄 갱신 완료.', { total: symbols.length, success: [...out.values()].filter(p => p !== null).length });
return out;
}
+143
View File
@@ -0,0 +1,143 @@
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import type { CompanyState } from '../../features/company';
/**
* Telegram 5.
* stateless + Telegram .
*
* - buildTelegramSystemPrompt(hasContext) (4 /)
* - looksLikeWorkOrder(text)
* - buildTelegramCompanyContext(state, ctx) [COMPANY CONTEXT]
* - latestCompanySessionDir(ctx) ( )
* - chunkTelegramMessage(text, max) 4096
*/
/**
* Build the Telegram-specific system prompt.
*
* Why this matters: small local models (gemma e2b/e4b) drift badly when
* called as a single user message with no role grounding. The reported
* symptom ("path 입력 → 시 못 써드려요" ) is exactly that
* drift the model invents an interpretation because it has no anchor.
*
* The prompt does four things:
* 1. Names the role (Astra Telegram assistant) so the model has a
* consistent persona across messages.
* 2. States the language rule (mirror the user's language).
* 3. Tells the model how to treat brain context (evidence when relevant,
* ignore otherwise never refuse the question because context
* doesn't match).
* 4. Specifies behavior for ambiguous inputs (paths, single words,
* fragments) ask a clarifying question instead of guessing.
*/
export function buildTelegramSystemPrompt(hasContext: boolean): string {
const base = [
'You are Astra, a Telegram assistant connected to the user\'s personal Second Brain knowledge base.',
'Reply in the user\'s language (mirror Korean ↔ English exactly as the user wrote).',
'Be concise but complete. Telegram messages should feel like a knowledgeable friend, not a formal report.',
'',
'Behavior rules:',
'- Never refuse a question by claiming you can only do certain things. If you can answer, just answer.',
'- If the user\'s message is ambiguous (a single word, a file path, a fragment with no question), ask one short clarifying question instead of guessing what they meant.',
'- Do NOT invent that the user asked for poetry, songs, code, or any content type they did not request.',
];
if (hasContext) {
base.push(
'',
'You will receive a [SECOND BRAIN CONTEXT] block before the user\'s message.',
'- Use it as evidence only when it directly answers the question. Cite the file path (relative form, e.g. `10_Wiki/Topics/Foo.md`) inline when you do.',
'- If the context is unrelated to the question, ignore it silently. Do NOT mention that the context exists, do NOT explain why it doesn\'t apply, do NOT refuse the question because of it.',
);
}
return base.join('\n');
}
/**
* Cheap heuristic: does the message look like a *work order* the user
* wants the company to execute? Triggers company-turn routing.
*
* Conservative matches only we'd rather miss a borderline case
* (user retries with clearer wording) than mis-route a question
* into a company turn (which spends LLM calls + writes to disk).
*
* Positive signals:
* Explicit dispatch prefix: "CEO한테", "회사한테", "팀한테"
* Korean imperative verbs at sentence end: 만들어///
* //////
* English imperatives: "make X", "build X", "create X", "implement"
*
* Negative signals (override treat as question, not order):
* Ends with "?" pure question
* Contains "알려줘 / 어디 / 뭐야 / what / where" informational
*/
export function looksLikeWorkOrder(text: string): boolean {
const t = (text || '').trim();
if (!t) return false;
if (/(CEO|회사|팀)\s*(한테|에게|보고|에)/i.test(t)) return true;
if (/[?]$/.test(t)) return false;
if (/(어디(에|에서|야)|뭐야|얼마|언제|왜|^(누구|어떻게|뭐))/i.test(t)) return false;
if (/(만들어|짜줘|작성해|구현해|돌려줘|실행해|분석해|정리해|보고해|해줘|짜봐|만들어줘)/i.test(t)) return true;
if (/^\s*(make|build|create|implement|run|analyze|generate|write|fix|add|remove)\b/i.test(t)) return true;
return false;
}
/** Find the newest `<workspace>/.astra/company/sessions/<ts>/` directory, or '' if none. */
export function latestCompanySessionDir(ctx: vscode.ExtensionContext): string {
try {
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const baseDir = ws
? path.join(ws, '.astra', 'company', 'sessions')
: path.join(ctx.globalStorageUri.fsPath, 'company', 'sessions');
if (!fs.existsSync(baseDir)) return '';
const dirs = fs.readdirSync(baseDir)
.filter((n) => fs.statSync(path.join(baseDir, n)).isDirectory())
.sort()
.reverse();
return dirs[0] ? path.join(baseDir, dirs[0]) : '';
} catch { return ''; }
}
/**
* Build a `[COMPANY CONTEXT]` block describing the workspace, the
* current company state, and the most recent session directory. Lets
* the bot answer questions like "어디에 저장했어?" by reading its own
* mirror history *plus* the resolved absolute path on disk.
*
* Returns '' when company mode is off, so the prompt stays minimal
* for users who only use the Telegram bot for RAG-chat.
*/
export function buildTelegramCompanyContext(state: CompanyState, ctx: vscode.ExtensionContext): string {
if (!state.enabled) return '';
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
const lines: string[] = [`[COMPANY CONTEXT]`];
lines.push(`회사명: ${state.companyName || '1인 기업'}`);
if (ws) lines.push(`작업 폴더 (워크스페이스 루트): ${ws}`);
const latestSession = latestCompanySessionDir(ctx);
if (latestSession) {
lines.push(`최근 작업 세션 폴더: ${latestSession}`);
lines.push(`(이 안에 _brief.md, _report.md, 각 에이전트별 산출물이 저장됨)`);
}
lines.push('');
lines.push('당신의 역할: 이 회사의 비서(Secretary). 사용자(사장님)의 질문에 답할 때 위 경로 정보를 *그대로* 활용하세요.');
lines.push('"실제 파일 시스템에 접근할 수 없다" 같은 답변은 잘못된 것입니다 — 위 경로가 실제 시스템 경로입니다.');
return lines.join('\n');
}
/** Telegram has a 4096-char per-message limit. Split on paragraph/sentence boundaries to keep replies readable. */
export function chunkTelegramMessage(text: string, max = 4000): string[] {
if (text.length <= max) return [text];
const out: string[] = [];
let remaining = text;
while (remaining.length > max) {
let cut = remaining.lastIndexOf('\n\n', max);
if (cut < max * 0.5) cut = remaining.lastIndexOf('\n', max);
if (cut < max * 0.5) cut = remaining.lastIndexOf('. ', max);
if (cut < max * 0.5) cut = max;
out.push(remaining.slice(0, cut).trim());
remaining = remaining.slice(cut).trim();
}
if (remaining) out.push(remaining);
return out;
}
+176
View File
@@ -0,0 +1,176 @@
import * as vscode from 'vscode';
import { TelegramBot } from './telegramBot';
import type { TelegramHttpClient } from './telegramClient';
import { AIService } from '../../core/services';
import { getActiveBrainProfile, logInfo, logError } from '../../utils';
import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap';
import { retrieveScoped, buildContextBlock } from '../../skills/scopedBrainRetriever';
import type { SidebarChatProvider } from '../../sidebarProvider';
import {
buildTelegramSystemPrompt,
looksLikeWorkOrder,
buildTelegramCompanyContext,
chunkTelegramMessage,
} from './promptBuilders';
export interface TelegramSetupDeps {
telegramClient: TelegramHttpClient;
/** activate() 안 `let provider` 의 *호출 시점* 값. 등록 시점엔 undefined 일 수 있음. */
getProvider: () => SidebarChatProvider | undefined;
}
/**
* Telegram bot + .
*
* :
* 1) allowlist (`telegram.allowedChatIds`) silent drop
* 2) + work-order CEO ( ack )
* 3) Per-chat agent + scoped RAG
* 4) (company-aware) + +
* 5) AI empty/error fallback
* 6) reply + 4096
*
* "조용히 실패하지 말 것" "응답 없음"
* .
* .
*/
export function createTelegramBot(
context: vscode.ExtensionContext,
deps: TelegramSetupDeps,
): TelegramBot {
const { telegramClient, getProvider } = deps;
const telegramAi = new AIService();
return new TelegramBot({
client: telegramClient,
handle: async (text, chatId) => {
const cfg = vscode.workspace.getConfiguration('g1nation');
const allowed = cfg.get<number[]>('telegram.allowedChatIds', []) || [];
if (allowed.length > 0 && !allowed.includes(chatId)) {
logInfo('Telegram message from unallowed chat ignored.', { chatId });
return null;
}
// 진입점 trace — silent failure 진단용. "응답 없음" 신고 시 이 로그가
// 없으면 메시지가 여기까지도 못 옴 (allowlist 또는 polling drop).
logInfo('Telegram message received.', {
chatId,
chars: text.length,
preview: text.length > 80 ? text.slice(0, 80) + '…' : text,
});
// ── 1인 기업 모드 라우팅 ────────────────────────────────────────
// 회사 모드 ON + 메시지가 work *order* 처럼 보이면 (만들어줘/해줘 또는
// "CEO한테 …" 접두) RAG-chat 대신 dispatcher 로. dispatcher 가 끝에
// Telegram mirror 를 쏘므로 사용자는 제대로 된 보고서를 받음.
const { readCompanyState } = await import('../../features/company');
const companyState = readCompanyState(context);
if (companyState.enabled && looksLikeWorkOrder(text)) {
const { appendTelegramMessage } = await import('./conversationHistory');
appendTelegramMessage({ chatId, role: 'user', text, kind: 'user' });
logInfo('Telegram: routing to company turn.', { chatId, preview: text.slice(0, 60) });
// Fire-and-forget — dispatcher 의 secretary mirror 가 최종 보고 전송.
// 즉시 ack 를 반환해 사용자는 봇이 명령을 받았다는 신호를 봄.
void (async () => {
try {
await getProvider()!._runCompanyTurn(text);
} catch (e: any) {
logError('Telegram → company turn failed.', { error: e?.message ?? String(e) });
}
})();
const ack = '🧭 CEO에게 전달했어요. 작업 끝나면 보고드릴게요.';
appendTelegramMessage({ chatId, role: 'assistant', text: ack, kind: 'reply' });
return ack;
}
// Per-chat agent override → global default → mapping default.
const perChatAgents = cfg.get<Record<string, string>>('telegram.agentByChatId', {}) || {};
const perChatAgent = perChatAgents[String(chatId)];
const defaultAgent = cfg.get<string>('telegram.defaultAgent', '') || '';
const agentName = (perChatAgent || defaultAgent || '').trim();
const brain = getActiveBrainProfile();
const brainRoot = brain?.localBrainPath || '';
const scope = resolveScopeForAgent(agentName, brainRoot);
// RAG retrieval — agent 매치 없어도 전체 brain 검색해 봇이 계속 유용하게.
// buildContextBlock 가 '' 반환하면 섹션 자체를 빼버림 (cleaner prompt).
let contextBlock = '';
if (brainRoot) {
try {
const result = retrieveScoped(text, brainRoot, scope.folders, {
maxResults: cfg.get<number>('telegram.contextChunks', 6) ?? 6,
});
contextBlock = buildContextBlock(result);
logInfo('Telegram RAG retrieval done.', {
chatId,
agent: scope.agent?.name ?? '(none)',
scopedFolders: scope.folders.length,
candidates: result.candidateCount,
chunks: result.chunks.length,
});
} catch (e: any) {
logError('Telegram RAG retrieval failed; falling back to plain prompt.', {
chatId, error: e?.message ?? String(e),
});
}
}
// Company-aware 시스템 프롬프트 — 회사 모드 ON 일 때 봇은 그 회사의 *비서*.
// 모델에게 그렇게 말해줘야 "어디에 저장했어?" 같은 질문에 "파일 시스템 접근
// 못 함" 으로 회피하지 않고 실제 경로를 그대로 답함.
const companyContextBlock = buildTelegramCompanyContext(companyState, context);
const systemPrompt = buildTelegramSystemPrompt(!!contextBlock)
+ (companyContextBlock ? `\n\n${companyContextBlock}` : '');
// Per-chat 대화 히스토리 — 없으면 매 inbound 가 fresh turn 이라
// "방금 한 말" 을 봇이 즉시 잊음. AI service 의 {system, user} 표면이
// messages 배열을 안 받아서 user 메시지에 inline 으로 박음.
const { appendTelegramMessage, getRecentMessages, formatHistoryForPrompt } =
await import('./conversationHistory');
const history = getRecentMessages(chatId, 10);
const historyBlock = formatHistoryForPrompt(history);
const pieces: string[] = [];
if (contextBlock) pieces.push(`[SECOND BRAIN CONTEXT]\n${contextBlock}`);
if (historyBlock) pieces.push(historyBlock);
pieces.push(`[USER MESSAGE]\n${text}`);
const userMessage = pieces.join('\n\n');
// AI 호출 *전에* user 메시지 persist — 실패해도 다음 inbound 가 발화를 봄.
appendTelegramMessage({ chatId, role: 'user', text, kind: 'user' });
try {
const result = await telegramAi.chat({ system: systemPrompt, user: userMessage });
logInfo('Telegram AI reply generated.', {
chatId, engine: result.engine, model: result.model,
empty: result.empty, chars: result.content.length,
});
if (result.empty) {
// silent 가 아니라 사용자에게 도달. 모델 재시작/질문 단순화 안내.
return [
'⚠️ AI 모델이 빈 응답을 반환했습니다.',
'',
'다음을 시도해보세요:',
'• LM Studio에서 모델이 실제로 로드되어 있는지 확인',
'• 더 짧고 구체적인 질문으로 다시 보내기',
'• `Astra: Test Telegram Connection` 으로 연결 상태 확인',
].join('\n');
}
// assistant reply persist → 다음 inbound 가 우리가 한 말을 봄.
appendTelegramMessage({ chatId, role: 'assistant', text: result.content, kind: 'reply' });
// 4096자 hard limit 분할. 1청크면 그대로, 여러 청크는 "(이어서 i/n)"
// 힌트와 함께 join — bot framework 가 한 메시지로 보내지만, drop 없이
// 모든 청크를 시도하는 게 silent loss 보다 나음.
const chunks = chunkTelegramMessage(result.content);
if (chunks.length === 1) return chunks[0];
return chunks.map((c, i) => i === 0 ? c : `(이어서 ${i + 1}/${chunks.length})\n\n${c}`).join('\n\n---\n\n').slice(0, 4000);
} catch (e: any) {
// 하드 실패에서도 ALWAYS 응답 — silent failure 가 두 번째로 보고된 통증.
logError('Telegram handler threw.', { chatId, error: e?.message ?? String(e) });
return `⚠️ Astra 처리 중 오류가 발생했습니다.\n${e?.message ?? e}\n\nLM Studio가 실행 중인지, 모델이 로드되어 있는지 확인해주세요.`;
}
},
});
}
@@ -0,0 +1,56 @@
/**
* Astra Mode Architecture Context Builder.
*
* 의도: 사용자가 *Astra mode * (Guard vs Multi-Agent
* ) , *
* * .
*
* builder *stateless* instance state . agent.ts
* private extract god file +
* .
*/
/**
* prompt "Astra 의 Guard vs MA 모드 분리 결정" .
*
* 3 ** true
* .
*/
export function isAstraModeArchitectureQuestion(prompt: string): boolean {
if (!prompt) return false;
const mentionsGuard = /\bguard\b|가드|Guard|Chronicle Guard|Project Chronicle/i.test(prompt);
const mentionsMultiAgent = /\bMA\b|multi[-\s]?agent|멀티\s*에이전트|다중\s*에이전트|Planner|Researcher|Writer/i.test(prompt);
const asksDecision = /(분리|통합|모드|사용|좋을까|맞을까|구조|설계|아키텍처|의견|판단|어때|어떤\s*거?\s*같|separate|combine|mode|architecture|design|opinion)/i.test(prompt);
return asksDecision && mentionsGuard && mentionsMultiAgent;
}
/**
* prepend .
* ( ).
*/
export function buildAstraModeArchitectureContext(prompt: string): string {
if (!isAstraModeArchitectureQuestion(prompt)) {
return '';
}
return [
'[ASTRA MODE ARCHITECTURE DECISION CONTEXT]',
'The user is asking about Astra itself, specifically whether Guard mode and MA/Multi-Agent mode should remain separate.',
'',
'Confirmed implementation facts from the current codebase:',
'- Guard is currently exposed as a sidebar toggle, but it defaults to enabled in the webview UI.',
'- Guard context is built by buildProjectChronicleGuardContext(activeProject) and passed into AgentExecutor as designerContext.',
'- In the normal single-agent path, designerContext is injected into the system prompt as [PROJECT CHRONICLE GUARD].',
'- In the Multi-Agent path, designerContext is appended as Project Chronicle Guard context for the workflow manager.',
'- Multi-Agent is an internal execution strategy. The legacy g1nation.multiAgentEnabled setting can still force it for complex prompts, but Astra may also select it automatically for report/research/strategy style tasks.',
'- Current guardrail: Multi-Agent is not used for local project path preflight or Astra mode-design questions, because those need richer context assembly first.',
'',
'Product decision guidance:',
'- Do not treat Guard and MA as two equal user-facing modes.',
'- Guard should be an always-on policy/context layer: project target, evidence discipline, record hygiene, tone, and decision logging.',
'- MA should be an optional execution strategy chosen automatically for genuinely complex tasks.',
'- Recommended UX: hide or de-emphasize the Guard toggle, show it as Auto/On by default, and let Astra route between single-agent and MA internally.',
'- Recommended answer: give a clear verdict that separating them as peer modes is not ideal; separate them internally by responsibility instead.',
'- Mention the concrete risk that MA can currently bypass richer context assembly, so unifying the context preparation before routing is the next engineering step.',
].join('\n');
}
@@ -0,0 +1,52 @@
import type { ChatMessage } from '../../agent';
/**
* v2.2.69 sliding-window .
*
* LLM heuristic :
* - prompt
* - assistant (conclusion-first R1)
* . "이전에 무슨 얘기를 했는지"
* .
*
* 8
* .
*
* Stateless agent.ts private . instance state
* . + god-file .
*/
export function buildDroppedHistorySummary(dropped: ChatMessage[]): string {
if (dropped.length === 0) return '';
const lines: string[] = [];
let userTurnIdx = 0;
for (const msg of dropped) {
if (msg.internal) continue;
const content = typeof msg.content === 'string' ? msg.content : '';
if (!content.trim()) continue;
if (msg.role === 'user') {
userTurnIdx++;
lines.push(`U${userTurnIdx}: ${firstSentence(content)}`);
} else if (msg.role === 'assistant') {
lines.push(`A${userTurnIdx}: ${firstSentence(content)}`);
}
}
const MAX_LINES = 8;
if (lines.length > MAX_LINES) {
const tail = lines.slice(-MAX_LINES);
const head = lines.slice(0, lines.length - MAX_LINES);
return `[이전 대화 요약 — 총 ${dropped.length}개 메시지가 컨텍스트 한계로 생략됨]\n(더 오래된 ${head.length}개 턴 생략됨)\n${tail.join('\n')}`;
}
return `[이전 대화 요약 — 총 ${dropped.length}개 메시지가 컨텍스트 한계로 생략됨]\n${lines.join('\n')}`;
}
function firstSentence(s: string): string {
const cleaned = String(s || '')
.replace(/^\s{0,3}#{1,6}\s+/gm, '')
.replace(/\*\*/g, '')
.replace(/`{3}[\s\S]*?`{3}/g, '[code]')
.replace(/\s+/g, ' ')
.trim();
const m = cleaned.match(/^[^.!?。\n]{1,140}[.!?。]?/);
const out = (m ? m[0] : cleaned.slice(0, 140)).trim();
return out;
}
+56
View File
@@ -0,0 +1,56 @@
import type { ChatMessage } from '../../agent';
/**
* LLM + variant .
*
* - normalizeMessages content stringify (Ollama Vision images
* ). API plain object array .
* - buildEngineMessageVariants LM Studio system role
* fallback variant
* (native-system flattened-system-fallback). Ollama native 1.
*
* stateless agent.ts private .
*/
export function normalizeMessages(messages: ChatMessage[]) {
return messages.map((message) => {
const normalizedContent = typeof message.content === 'string'
? message.content
: JSON.stringify(message.content);
const result: any = {
role: message.role,
content: normalizedContent,
};
// Ollama Vision: images 필드 보존
if ((message as any).images) {
result.images = (message as any).images;
}
return result;
});
}
export function buildEngineMessageVariants(messages: ChatMessage[], engine: 'lmstudio' | 'ollama') {
const normalized = normalizeMessages(messages);
if (engine !== 'lmstudio') {
return [{ name: 'native', messages: normalized }];
}
// LM Studio system-role bug 우회: system 메시지를 "[System Instruction - do not
// answer this message]\n…" 로 감싸 user role 로 변환. 호출자는 native 가 실패하면
// 이 flattened variant 로 재시도한다.
const flattened = normalized.map((message) => {
if (message.role === 'system') {
return {
role: 'user' as const,
content: `[System Instruction - do not answer this message]\n${message.content}`,
};
}
return message;
});
return [
{ name: 'native-system', messages: normalized },
{ name: 'flattened-system-fallback', messages: flattened },
];
}
@@ -0,0 +1,77 @@
import type { ChatMessage } from '../../agent';
/**
* history . stage:
* 1) sanitizeHistoryAssistantContent */*
* (Second Brain Trace, candidate records ) .
* .
* 2) buildRequestHistory assistant .
*
* stateless agent.ts private . sanitize
* god-file .
*/
export function buildRequestHistory(history: ChatMessage[]): ChatMessage[] {
return history.map((message) => {
if (message.role !== 'assistant' || typeof message.content !== 'string') {
return message;
}
return {
...message,
content: sanitizeHistoryAssistantContent(message.content),
};
});
}
/**
* In-memory chat history :
* 1) `recentFullMessages` * tool-result* (read_file /
* list_files / list_brain / read_brain) `oldToolResultCap`
* truncate .
* 2) `maxRetained` drop.
* system ( / framing ).
*
* cap stateless agent.ts private class
* static .
*
* 주의: history *in-place mutate* (splice + content ).
* . reference
* .
*/
export function capChatHistory(
history: ChatMessage[],
opts: { maxRetained: number; recentFullMessages: number; oldToolResultCap: number },
): void {
if (history.length === 0) return;
const recentStart = Math.max(0, history.length - opts.recentFullMessages);
for (let i = 0; i < recentStart; i++) {
const msg = history[i];
if (msg.role !== 'system' || !msg.internal || typeof msg.content !== 'string') continue;
if (!/^\[Result of (read_file|list_files|list_brain|read_brain)\b/.test(msg.content)) continue;
if (msg.content.length <= opts.oldToolResultCap) continue;
msg.content = msg.content.slice(0, opts.oldToolResultCap)
+ '\n…[이전 도구 결과는 컨텍스트 절약을 위해 축약되었습니다]';
}
if (history.length > opts.maxRetained) {
const first = history[0];
const preserveFirst = first.role === 'system';
const overflow = history.length - opts.maxRetained;
if (preserveFirst) {
history.splice(1, overflow);
} else {
history.splice(0, overflow);
}
}
}
export function sanitizeHistoryAssistantContent(content: string): string {
return content
.replace(/<details>\s*<summary>2nd Brain Trace:[\s\S]*?<\/details>/gi, '')
.replace(/## Second Brain Debug JSON[\s\S]*?(?=\n## |\n# |$)/gi, '')
.replace(/## Candidate records for this discussion[\s\S]*?(?=\n## |\n# |$)/gi, '')
.replace(/## 후보 기록[\s\S]*?(?=\n## |\n# |$)/gi, '')
.replace(/## 프로젝트 기록 검토[\s\S]*?(?=\n## |\n# |$)/gi, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
@@ -0,0 +1,59 @@
import { isThinkingPartnerRequest } from './promptDetection';
import { extractEvidenceFilesFromProjectKnowledge, extractPriorityPreviewFiles } from './projectEvidence';
import { buildThinkingPartnerResponseContract } from './thinkingPartnerContract';
/**
* "자비스 프로젝트 브리프" thinking-partner
* * * * *
* prompt prepend .
*
* 우선순위: 직전 localPathContext ( path access ) > project knowledge
* record. "성급한 단정 금지" + thinking-partner contract
* .
*
* Stateless agent.ts private . 의존: thinking-partner
* detection + evidence file ( ) + response contract.
*/
export function buildJarvisProjectBriefContext(
prompt: string,
localPathContext: string,
recentProjectKnowledgeContext: string,
): string {
if (!isThinkingPartnerRequest(prompt)) {
return '';
}
const sourceContext = localPathContext && localPathContext.includes('Access: succeeded')
? localPathContext
: recentProjectKnowledgeContext;
if (!sourceContext) {
return [
'[JARVIS PROJECT BRIEF]',
'No concrete local project brief is available yet.',
'Use the conversation and Second Brain cautiously. If the user asks about a project architecture, ask for or inspect the project path before making strong claims.',
'',
buildThinkingPartnerResponseContract(),
].join('\n');
}
const projectPath = sourceContext.match(/Path:\s*(.+)/)?.[1]?.trim()
|| sourceContext.match(/Repository:\s*`([^`]+)`/)?.[1]?.trim()
|| sourceContext.match(/project evidence:\s*([^\s]+)/i)?.[1]?.trim()
|| 'current project';
const evidenceFiles = sourceContext.includes('Priority file previews:')
? extractPriorityPreviewFiles(sourceContext).slice(0, 10)
: extractEvidenceFilesFromProjectKnowledge(sourceContext).slice(0, 10);
const treeMatch = sourceContext.match(/Scanned tree:\n([\s\S]*?)(?:\nPriority file previews:|$)/);
const treePreview = treeMatch?.[1]?.trim().split('\n').slice(0, 30).join('\n') || '';
return [
'[JARVIS PROJECT BRIEF]',
`Project evidence target: ${projectPath}`,
evidenceFiles.length
? `Evidence files available:\n${evidenceFiles.map((file) => `- ${file}`).join('\n')}`
: 'Evidence files available: not enough concrete file markers were found.',
treePreview ? `Visible structure preview:\n${treePreview}` : '',
'',
buildThinkingPartnerResponseContract(),
].filter(Boolean).join('\n');
}
+19
View File
@@ -0,0 +1,19 @@
import type { ChatMessage } from '../../agent';
/**
* v2.2.69 chatHistory user
* bridge "이전 맥락" .
*
* . 120 cap (bridge ).
*
* agent.ts private `this.chatHistory`
* stateful , history arg stateless .
* history .
*/
export function buildLastTopicLine(history: ChatMessage[]): string {
const recent = history.filter(m => !m.internal && (m.role === 'user' || m.role === 'assistant'));
if (recent.length === 0) return '';
const lastUser = [...recent].reverse().find(m => m.role === 'user');
if (!lastUser || typeof lastUser.content !== 'string') return '';
return lastUser.content.replace(/\s+/g, ' ').trim().slice(0, 120);
}
@@ -0,0 +1,29 @@
import { getConfig } from '../../config';
import type { LmStudioSampling } from '../../lmstudio/streamer';
/**
* LM Studio config sampling .
* SDK streamer `respond()` (topPSampling/topKSampling/) REST body
* (top_p/top_k/) , path
* . Ollama `options` .
*
* 코드: agent.ts private 5 + 7 . stateless config
* extract + config shape .
*/
/** SDK / REST 양쪽이 공통으로 쓰는 sampling block. */
export function lmStudioSamplingFromConfig(): LmStudioSampling {
const c = getConfig();
return {
topP: c.lmStudioTopP,
topK: c.lmStudioTopK,
minP: c.lmStudioMinP,
repeatPenalty: c.lmStudioRepeatPenalty,
};
}
/** SDK `respond()` 전용 extras — 현재는 speculative decoding 의 draft model 뿐. */
export function lmStudioRespondExtrasFromConfig(): { draftModel?: string } {
const c = getConfig();
return c.lmStudioDraftModel ? { draftModel: c.lmStudioDraftModel } : {};
}
@@ -0,0 +1,233 @@
import { isThinkingPartnerRequest } from './promptDetection';
/**
* * * + *tone-shaping* . agent.ts
* 6 private + 2 static regex .
*
* :
* containsLocalFilePath
* classifyLocalProjectIntent {isProjectKnowledge..,
* isProjectReview..,
* buildAstraStanceContext}
* shouldPreflightLocalProjectPath
*
* buildLocalProjectIntentGuidance , intent
*
* stateless god-file + +
* .
*
* lastIndex pollution :
* /g/ flag RegExp `.test()` . /g/
* lastIndex .test() false
* .
*/
export type LocalProjectIntent =
| 'review-evaluation'
| 'knowledge-creation'
| 'implementation'
| 'documentation'
| 'thinking'
| 'general';
// POSIX: /Volumes/, /Users/, /home/, /opt/, ... or ~/ — backtick 제외 (markdown code spans).
// Source string 도 export — 호출자가 /g/ flag 가 필요한 matchAll 패턴에 새 RegExp 인스턴스를
// 만들 수 있어야 함 (lastIndex pollution 방지 위해 인스턴스 공유 안 함).
export const POSIX_ABS_PATH_SRC = "(?:\\/(?:Volumes|Users|home|opt|srv|mnt|data|workspace)\\/|~\\/)[^\\s`\"'<>|*?]+";
// Windows: drive letter (C:\ or C:/) or UNC (\\server\share). Backslash 가 separator 로 허용됨.
export const WIN_ABS_PATH_SRC = "(?:[A-Za-z]:[\\\\/]|\\\\\\\\[^\\s\\\\/]+\\\\[^\\s\\\\/]+)[^\\s`\"'<>|*?]*";
export const ABS_PATH_RE = new RegExp(POSIX_ABS_PATH_SRC, 'i');
export const WIN_ABS_PATH_RE = new RegExp(WIN_ABS_PATH_SRC, 'i');
/**
* Prompt / . (POSIX + Windows
* + UNC) (`src/...`, `lib/...` + ) .
*/
export function containsLocalFilePath(prompt: string): boolean {
if (ABS_PATH_RE.test(prompt) || WIN_ABS_PATH_RE.test(prompt)) {
return true;
}
if (/(?:^|[\s,])(?:src|lib|components|pages|app|tests|test|utils|core|features|hooks|services|config|public|assets|docs|scripts)[\\/]/i.test(prompt)
&& /\.[a-z]{1,6}(?:[\s,;)\]]|$)/i.test(prompt)) {
return true;
}
return false;
}
/**
* prompt + * * .
* preflight ( scan + ) .
* scan skip.
*/
export function shouldPreflightLocalProjectPath(prompt: string): boolean {
const hasActionKeyword = /(검토|리뷰|분석|확인|봐줘|읽어|열어|파일|내용|코드|고쳐|개선|디버그|지식|문서화|문서|정리|기록|위키|저장|만들|생성|설계|아키텍처|구조|방향|의견|생각|판단|어떤\s*거?\s*같|어때|순서대로|보면|knowledge|document|documentation|wiki|summari[sz]e|review|analy[sz]e|inspect|debug|fix|improve|architecture|design|structure|opinion|think|judge|read|open|file|content|code)/i.test(prompt);
const hasLocalPath = containsLocalFilePath(prompt);
return hasActionKeyword && hasLocalPath;
}
/**
* Intent preflight tone / prompt
* . 'general' .
*
* 우선순위: review > implementation > knowledge-creation > documentation
* > thinking > general. * *
* .
*/
export function classifyLocalProjectIntent(prompt: string): LocalProjectIntent {
if (!containsLocalFilePath(prompt)) {
return 'general';
}
const normalized = prompt.replace(/\s+/g, ' ').trim();
const asksReview = /(코드\s*리뷰|코드리뷰|리뷰|검토|평가|봐줘|장점|단점|약점|강점|확장성|문제점|리스크|개선점|의견|판단|괜찮|어때|어떤\s*거?\s*같|review|evaluate|assessment|strength|weakness|pros?\s*and\s*cons?|extensibility|scalability|risk|issue)/i.test(normalized);
if (asksReview) {
return 'review-evaluation';
}
const asksImplementation = /(고쳐|수정|개선해|구현|추가|삭제|리팩토링|디버그|fix|implement|add|remove|refactor|debug)/i.test(normalized);
if (asksImplementation) {
return 'implementation';
}
const explicitKnowledgeCreation = /((?:이|그|현재|해당)?\s*(?:프로젝트|프로그램|코드베이스).{0,20}(?:대한|기반|관련).{0,20}지식.{0,12}(?:만들|생성|정리|문서화|기록|저장))|(지식.{0,12}(?:만들|생성|정리|문서화|기록|저장).{0,20}(?:프로젝트|프로그램|코드베이스))|(project\s+knowledge.{0,20}(?:create|generate|record|document|overview))|((?:create|generate|record|document).{0,20}project\s+knowledge)/i.test(normalized);
if (explicitKnowledgeCreation) {
return 'knowledge-creation';
}
const asksDocumentation = /(문서화(?:해|해줘|를)|문서(?:로)?\s*(?:정리|작성|만들)|README|가이드|wiki|documentation|document\s+this|write\s+docs)/i.test(normalized);
if (asksDocumentation) {
return 'documentation';
}
const asksThinking = /(설계|아키텍처|구조|방향|생각|의견|판단|어떤\s*거?\s*같|어때|architecture|design|structure|direction|opinion|think|judge)/i.test(normalized);
if (asksThinking) {
return 'thinking';
}
return 'general';
}
export function isProjectKnowledgeCreationRequest(prompt: string): boolean {
return classifyLocalProjectIntent(prompt) === 'knowledge-creation';
}
export function isProjectReviewEvaluationRequest(prompt: string): boolean {
return classifyLocalProjectIntent(prompt) === 'review-evaluation';
}
/**
* Intent contract .
* `review-evaluation` strict ( template
* ), intent contract.
*/
export function buildLocalProjectIntentGuidance(intent: LocalProjectIntent): string {
switch (intent) {
case 'review-evaluation':
return [
'Intent operating contract — Code Review:',
'The user wants a real review, not a meta-plan of how to review.',
'OUTPUT FORMAT: PLAIN TEXT only. Section labels are bare words on their own line (no "#", "##", "**", "__", "> "). Bullets use "- ". Long answers MUST start with a "핵심 요약" block (2~4 bullets) before any detail.',
'Required sections in this exact order, in Korean (each label appears as a plain line, NOT a markdown heading):',
' 1) 한 줄 판단 — one sentence: would you rely on this today, and under what constraint?',
' 2) 잘된 점 — 2~4 concrete strengths. Each MUST cite a specific file path (and a function or section if you can name one) and explain WHY it works, not just that it exists.',
' 3) 부족한 점 — 2~4 concrete weaknesses or risks. Same rule: cite a specific file/area, name the actual problem (race condition, missing retry, coupling, etc.), and say what breaks because of it.',
' 4) 사용자 관점 개선 — 2~4 changes phrased from the END USER\'s perspective ("when X happens, the user currently sees Y; they should see Z"). Tie each to a code location that needs to change.',
' 5) 다음 한 수 — exactly one next action, small enough to do this week.',
'',
'Hard rules — these are the things that made past reviews feel like a template:',
'- Do NOT write meta-sentences like "확인해야 합니다", "다음 리뷰에서는 ~를 보면 됩니다", "~로 보입니다", "~인지 확인하는 것이 핵심입니다". Either you observed it or you read the file with <read_file> right now.',
'- Do NOT list the file structure tree back to the user — they already see it. Reference files only when making a specific claim.',
'- Do NOT use the words "blind spot", "파이프라인 안정화", "골격은 있습니다" — these are tells of the old canned response.',
'- If a file preview is insufficient to support a claim, USE <read_file path="..."> immediately to read it before writing the section. Do not hedge with "preview만으로는 판단할 수 없습니다".',
'- Strengths and weaknesses must be SPECIFIC to this project. A sentence that would still be true if you swapped the project name is not allowed.',
'- Skip every section that has nothing concrete to say. Better to write 잘된 점 with 2 strong items than 4 weak ones.',
].join('\n');
case 'knowledge-creation':
return [
'Intent operating contract:',
'- Create a reusable project knowledge note from inspected evidence.',
'- Do not ask for scope if the path is accessible; choose a small MVP overview by default.',
'- Separate confirmed structure from inferred purpose and next deep-dive targets.',
].join('\n');
case 'implementation':
return [
'Intent operating contract:',
'- Treat this as a change request, not advice.',
'- Inspect the relevant files, make the smallest safe implementation, and verify it.',
'- Preserve unrelated user changes.',
].join('\n');
case 'documentation':
return [
'Intent operating contract:',
'- Produce or update documentation from inspected evidence.',
'- Separate user-facing usage docs from internal architecture notes.',
'- Avoid claiming behavior that is not visible in code or existing docs.',
].join('\n');
case 'thinking':
return [
'Intent operating contract:',
'- Act as a thinking partner.',
'- Give a direct opinion, then split confirmed facts, inferences, risks, decision forks, and one next move.',
'- Avoid generic encouragement.',
].join('\n');
default:
return [
'Intent operating contract:',
'- Use the inspected local files as grounding.',
'- If the user request is ambiguous, answer the most likely project-oriented task and state the assumption.',
].join('\n');
}
}
/**
* "Astra Stance Layer" template-style * *
* · prepend. intent review/thinking
* opinionated, light persona.
*/
export function buildAstraStanceContext(prompt: string, localPathContext: string): string {
const intent = localPathContext ? classifyLocalProjectIntent(prompt) : 'general';
const wantsThinkingPartner = isThinkingPartnerRequest(prompt) || intent === 'review-evaluation' || intent === 'thinking';
const lines = [
'[ASTRA STANCE LAYER]',
'Use this to make the response feel like Astra thinking with the user, not a template being filled.',
'',
'Voice:',
'- Warm, direct, and grounded. Do not over-explain the framework.',
'- Prefer sentences that sound like a senior collaborator: "나는 여기서 X를 먼저 볼 것 같아요" / "이건 좋아요, 그런데 위험은 Y예요."',
'- Avoid sterile balance like "장단점이 있습니다" unless you immediately make a call.',
'',
'Judgment habits:',
'- State the real bet you think the user is making.',
'- Name one thing to keep, one thing to cut, and one thing to verify next when relevant.',
'- Use the users own goal as the yardstick, not generic best practice.',
'- If there are many possible improvements, choose the one that compounds the project fastest.',
'',
wantsThinkingPartner
? 'For this request, be especially opinionated. Give a clear personal verdict before structure.'
: 'For this request, keep the persona light but still make concrete choices.',
intent !== 'general' ? `Local project intent for tone: ${intent}` : '',
];
if (intent === 'review-evaluation') {
lines.push(
'',
'Review stance:',
'- Do not merely list strengths and weaknesses. Say whether you would rely on this project today and under what constraint.',
'- Prefer the product-owner question: "What has to become boring and reliable before this deserves expansion?"',
'- If evidence is shallow, say which file would change your opinion most.',
);
}
if (intent === 'thinking') {
lines.push(
'',
'Thinking stance:',
'- Do not solve every branch. Reduce the users uncertainty to the next decision.',
'- A useful answer may say: "I would not expand yet" or "This deserves a spike, not a feature."',
);
}
return lines.filter(Boolean).join('\n');
}
+328
View File
@@ -0,0 +1,328 @@
import * as fs from 'fs';
import * as path from 'path';
import { summarizeText } from '../../utils';
import { EXCLUDED_DIRS } from '../../config';
import { validatePath } from '../../security';
import {
POSIX_ABS_PATH_SRC,
WIN_ABS_PATH_SRC,
shouldPreflightLocalProjectPath,
classifyLocalProjectIntent,
buildLocalProjectIntentGuidance,
} from './localProjectIntent';
/**
* "로컬 프로젝트 경로 preflight" prompt path
* scan / system prompt prepend.
*
* :
* 1) extractLocalProjectPaths prompt ( + )
* 2) inspectLocalProjectPath fs tree + priority preview
* - listProjectTree depth/limit
* - findPriorityProjectFiles package.json/README/src
* 3) buildLocalProjectPathContext + intent guidance + critical directives
* 4) enforceLocalPathReviewAnswer "코드를 제공해주세요"
* "스스로 read_file 하겠다"
*
* Why one module: 6개 import .
* stateless agent.ts private .
*
* Notes:
* - extractLocalProjectPaths /g/ flag RegExp
* (lastIndex pollution ). source string import.
* - inspectLocalProjectPath / 8000, 2000 preview.
* prompt token + .
*/
/** 사용자 prompt 에서 로컬 경로 후보들을 추출 (절대 경로 + 흔한 상대 경로). */
export function extractLocalProjectPaths(prompt: string, rootPath?: string): string[] {
const results: string[] = [];
const stripTrailingPunct = (s: string) => s.replace(/[),.;\]]+$/g, '');
// 1a. POSIX 절대 경로
const absMatches = prompt.match(new RegExp(POSIX_ABS_PATH_SRC, 'gi')) || [];
for (const m of absMatches) {
results.push(stripTrailingPunct(m));
}
// 1b. Windows 절대 경로
const winMatches = prompt.match(new RegExp(WIN_ABS_PATH_SRC, 'gi')) || [];
for (const m of winMatches) {
results.push(stripTrailingPunct(m));
}
// 2. 상대 경로 감지: src/lib/engine.ts, components\App.tsx 등
const relMatches = prompt.match(/(?:^|[\s,])(?:(?:src|lib|components|pages|app|tests|test|utils|core|features|hooks|services|config|public|assets|docs|scripts)[\\/][^\s`"'<>]+\.[a-z]{1,6})/gi) || [];
for (const m of relMatches) {
const cleaned = m.trim().replace(/^,\s*/, '').replace(/[),.;\]]+$/g, '');
if (rootPath) {
const absPath = path.resolve(rootPath, cleaned);
if (fs.existsSync(absPath)) {
results.push(absPath);
} else {
// 프로젝트 루트 하위 sub-project 들에서도 검색
const subProjects = ['ConnectAI', 'Datacollector_MAC', 'Agent', 'skybound'];
let found = false;
for (const sub of subProjects) {
const subPath = path.resolve(rootPath, sub, cleaned);
if (fs.existsSync(subPath)) {
results.push(subPath);
found = true;
break;
}
}
if (!found) {
results.push(absPath); // fallback: 원래 경로 그대로
}
}
} else {
results.push(cleaned);
}
}
return Array.from(new Set(results));
}
/**
* Depth/limit . EXCLUDED_DIRS hidden file . recursive
* lines.length cap .
*/
export function listProjectTree(root: string, current: string, depth: number, maxDepth: number, limit: number): string {
if (limit <= 0 || depth > maxDepth) {
return '';
}
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(current, { withFileTypes: true })
.filter((entry) => !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name))
.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
} catch {
return '';
}
const lines: string[] = [];
for (const entry of entries) {
if (lines.length >= limit) break;
const fullPath = path.join(current, entry.name);
const relative = path.relative(root, fullPath);
lines.push(`${' '.repeat(depth)}${relative}${entry.isDirectory() ? '/' : ''}`);
if (entry.isDirectory() && depth < maxDepth) {
const child = listProjectTree(root, fullPath, depth + 1, maxDepth, limit - lines.length);
if (child) {
lines.push(child);
}
}
}
return lines.join('\n');
}
/**
* Priority (package.json, README, tsconfig, src/**, docs/**, config )
* . visit source area dfs src/
* / , config .
*/
export function findPriorityProjectFiles(root: string): string[] {
const exactNames = new Set([
'package.json',
'README.md',
'readme.md',
'tsconfig.json',
'vite.config.ts',
'vite.config.js',
'next.config.js',
'next.config.mjs',
'webpack.config.js',
]);
const results: string[] = [];
const visit = (dir: string, depth: number, inSourceArea: boolean) => {
if (depth > 6 || results.length >= 24) return;
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
.filter((entry) => !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name));
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const nextInSourceArea = inSourceArea || /^(src|app|pages|components|docs|lib|server|backend|frontend|config|features|core|hooks|systems|store|model|utils|ui|api)$/i.test(entry.name);
if (nextInSourceArea) {
visit(fullPath, depth + 1, nextInSourceArea);
}
continue;
}
const relative = path.relative(root, fullPath);
const isSourceCode = /\.(ts|tsx|js|jsx)$/i.test(entry.name);
if (
exactNames.has(entry.name)
|| (inSourceArea && isSourceCode)
|| /(^|[\\/])(src|app|pages|components|docs|lib|server|backend|frontend|features|core)[\\/].+\.(ts|tsx|js|jsx|md|json)$/i.test(relative)
|| /\.(config|rc)\.(js|ts|json)$/i.test(entry.name)
) {
results.push(fullPath);
}
}
};
visit(root, 0, false);
return Array.from(new Set(results)).sort((a, b) => {
const rank = (file: string) => {
const relative = path.relative(root, file);
if (path.basename(file) === 'package.json') return 0;
if (/readme\.md$/i.test(file)) return 1;
if (/^src[\\/]App\.tsx$/i.test(relative)) return 2;
if (/^src[\\/]main\.tsx$/i.test(relative)) return 3;
if (/^src[\\/]features[\\/]game[\\/]hooks[\\/]useGameEngine\.ts$/i.test(relative)) return 4;
if (/^src[\\/]features[\\/]game[\\/]systems[\\/]/i.test(relative)) return 5;
if (/^src[\\/]features[\\/]game[\\/]ui[\\/]/i.test(relative)) return 6;
if (/^src[\\/]/i.test(relative)) return 7;
if (/^docs[\\/]|\.md$/i.test(relative)) return 8;
return 9;
};
return rank(a) - rank(b) || a.localeCompare(b);
});
}
/**
* inspect tree + priority preview, preview.
* / 8000, 2000 cap (token ).
*/
export function inspectLocalProjectPath(targetPath: string, rootPath: string): string {
try {
const absPath = validatePath(rootPath, targetPath);
if (!fs.existsSync(absPath)) {
return [
`Path: ${targetPath}`,
'Access: failed',
'Reason: path does not exist in the current environment.',
].join('\n');
}
const stat = fs.statSync(absPath);
if (!stat.isDirectory()) {
const content = fs.readFileSync(absPath, 'utf8');
const fileName = path.basename(absPath);
const ext = path.extname(absPath).toLowerCase();
const isCodeOrDoc = ['.ts', '.js', '.tsx', '.jsx', '.py', '.java', '.go', '.rs', '.md', '.json', '.yaml', '.yml', '.toml', '.css', '.html', '.sql', '.sh', '.zsh', '.env', '.xml', '.swift', '.kt'].includes(ext);
const previewLimit = isCodeOrDoc ? 8000 : 2000;
return [
`Path: ${targetPath}`,
'Access: succeeded',
`Type: file (${fileName})`,
`Size: ${content.length} characters`,
`Full content (${content.length <= previewLimit ? 'complete' : `first ${previewLimit} chars`}):\n\`\`\`${ext.slice(1)}\n${summarizeText(content, previewLimit)}\n\`\`\``,
].join('\n');
}
const tree = listProjectTree(absPath, absPath, 0, 4, 140);
const priorityFiles = findPriorityProjectFiles(absPath).slice(0, 12);
const previews = priorityFiles.map((file) => {
try {
const content = fs.readFileSync(file, 'utf8');
return [
`File: ${path.relative(absPath, file)}`,
summarizeText(content, 2200),
].join('\n');
} catch (error: any) {
return `File: ${path.relative(absPath, file)}\nRead failed: ${error.message}`;
}
}).join('\n\n');
return [
`Path: ${targetPath}`,
'Access: succeeded',
'Type: directory',
`Scanned tree:\n${tree || '(no visible files found)'}`,
priorityFiles.length > 0
? `Priority file previews:\n${previews}`
: 'Priority file previews: no package, README, docs, src, or config files found in the first scan.',
].join('\n');
} catch (error: any) {
return [
`Path: ${targetPath}`,
'Access: failed',
`Reason: ${error.message}`,
].join('\n');
}
}
/**
* Preflight system prompt prepend . preflight
* skip . 5 inspect (token cap).
*/
export function buildLocalProjectPathContext(prompt: string, rootPath: string): string {
if (!shouldPreflightLocalProjectPath(prompt)) {
return '';
}
const candidates = extractLocalProjectPaths(prompt, rootPath);
if (candidates.length === 0) {
return '';
}
const intent = classifyLocalProjectIntent(prompt);
const sections: string[] = [
'[LOCAL PROJECT PATH PREFLIGHT]',
`Local project intent: ${intent}`,
buildLocalProjectIntentGuidance(intent),
'[CRITICAL DIRECTIVE] The file structure and snippets below are an INITIAL scan from the local filesystem.',
'If you need to see the full content of any file or explore other directories to perform the analysis, you MUST use the <read_file path="..."> or <list_files path="..."> action tags immediately in your response.',
'DO NOT ask the user to provide, upload, paste, or share the file contents. DO NOT ask for permission to read them. Just use the action tags to read them yourself.',
'DO NOT say "파일 내용을 보여주세요", "코드를 공유해 주세요", or "파일을 제공해 주세요".',
'Proceed IMMEDIATELY with analysis or with using action tags to gather more context. Do not ask for confirmation like "진행할까요?" or "분석을 시작할까요?". Just do it.',
'If multiple files are mentioned, analyze them sequentially in the order the user specified without pausing for confirmation between each.',
'The user provided a local project path for review, analysis, documentation, or knowledge creation. Use this inspected context, and if needed use <read_file> to dig deeper before answering.',
'If access failed, explain the concrete failure.',
'If access succeeded and priority file previews are present, do not say that code was not provided.',
'Treat the Local project intent line as the routing decision for this response.',
'If intent is review-evaluation, do not create a project knowledge note. Review the inspected project as the primary task: strengths, weaknesses, risks, and extensibility.',
'If intent is knowledge-creation, answer that the project can be summarized from the inspected local path and propose or execute a project knowledge note based on the previews.',
'If intent is thinking, act as a project thinking partner and give a clear verdict grounded in the inspected files.',
];
for (const candidate of candidates.slice(0, 5)) {
sections.push(inspectLocalProjectPath(candidate, rootPath));
}
return sections.join('\n');
}
/**
* "코드를 제공해주세요 / 업로드해주세요" ,
* "스스로 read_file 하겠다" . localPathContext
* access noop ( ).
*/
export function enforceLocalPathReviewAnswer(content: string, localPathContext: string): string {
if (!localPathContext.includes('Access: succeeded')) {
return content;
}
const asksForUpload = /(코드(?:를|가)?\s*업로드|파일(?:을|를)?\s*업로드|소스\s*코드(?:를)?\s*업로드|코드를 제공|파일을 제공|핵심 파일(?:이나|과|을|를)?.*제공|파일 목록(?:이나|과|을|를)?.*제공|구조(?:를|와|나)?.*제공|자료(?:를|가)?.*필요|folder path is not enough|upload (?:the )?(?:source )?code|please provide (?:the )?files|먼저 분석할까요|살펴볼까요)/i.test(content);
const deniesCodeAccess = /(실제 코드 내용이 없|코드 내용이 없|코드가 없|코드를 볼 수 없|소스 코드를 볼 수 없|실제 구현 자료가 없|실제 구현 근거 없이는|현재로서는.*자료가 없|기술적인 진단.*수 없습니다|코드를 읽어야만|파일 구조만으로는.*판단할 수 없|코드의 논리적 흐름.*판단할 수 없)/i.test(content);
if (!asksForUpload && !deniesCodeAccess) {
return content;
}
const header = [
'## 경로 확인 결과',
'',
'제공된 로컬 프로젝트 경로에는 접근할 수 있고, 코드 파일도 일부 확인되었습니다. 만약 추가적인 코드 확인이 필요하다면 <read_file> 이나 <list_files> 액션 태그를 즉시 사용하여 스스로 파일을 읽어보고 분석을 진행하겠습니다.',
'',
'이전 응답에서 "파일을 제공해주세요" 라거나 "먼저 분석할까요?" 라고 묻는 것은 잘못된 안내입니다. 액션 태그를 통해 스스로 필요한 코드를 열어보겠습니다.',
].join('\n');
return [
header,
'',
content
.replace(/.*(?:코드(?:를|가)?\s*업로드|파일(?:을|를)?\s*업로드|소스\s*코드(?:를)?\s*업로드|코드를 제공|파일을 제공).*$/gmi, '')
.replace(/.*(?:핵심 파일(?:이나|과|을|를)?.*제공|파일 목록(?:이나|과|을|를)?.*제공|구조(?:를|와|나)?.*제공|자료(?:를|가)?.*필요).*$/gmi, '')
.replace(/.*(?:실제 코드 내용이 없|코드 내용이 없|코드가 없|코드를 볼 수 없|소스 코드를 볼 수 없|실제 구현 자료가 없|실제 구현 근거 없이는|현재로서는.*자료가 없|코드를 읽어야만|파일 구조만으로는.*판단할 수 없).*$/gmi, '')
.replace(/.*(?:먼저 분석할까요|살펴볼까요).*$/gmi, '')
.trim(),
].filter(Boolean).join('\n\n');
}
+233
View File
@@ -0,0 +1,233 @@
import * as path from 'path';
import * as vscode from 'vscode';
import type { ChatMessage } from '../../agent';
import type { BrainProfile } from '../../config';
import { getConfig } from '../../config';
import type { MemoryManager } from '../../memory';
import type { RetrievalOrchestrator } from '../../retrieval';
import { buildLessonChecklistBlock } from '../../retrieval/lessonHelpers';
import { embedQuery, embedTexts } from '../../retrieval/embeddings';
import { backfillBrainEmbeddings } from '../../retrieval/brainIndex';
import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap';
import {
resolveKnowledgeMix,
mapWeightToBrainFileLimit,
mapWeightToRetrievalRatio,
ResolvedKnowledgeMix,
} from '../../retrieval/knowledgeMix';
/**
* turn RAG / 5-layer memory .
*
* 코드: agent.ts 130 private `buildMemoryContext`. state 6
* (memoryManager, chatHistory, retrievalOrchestrator, context, currentTaskId,
* _turnCtx) god-file .
*
* 방식: 호출자(provider) deps struct * orchestration*
* . RetrievalOrchestrator / MemoryManager (
* * * ). :
* 1) `deps.turnCtx` mutation webview footer retrieval/lessons/knowledgeMix.
* 2) `backfillBrainEmbeddings` fire-and-forget turn score .
*
* 의도: agent.ts 130 RAG .
* Provider deps .
*/
/** TurnContext 의 retrieval 슬롯 모양. provider 의 `_turnCtx.retrieval` 와 일치해야 함. */
export interface TurnRetrievalSummary {
agentName: string | null;
scoped: boolean;
source: string;
configuredFolders: string[];
usedBrainFiles: string[];
usedMemoryLayers: string[];
lessonFiles: string[];
totalChunks: number;
selectedChunks: number;
}
/**
* Mutable turn-context sink `_turnCtx`
* . `reset` .
*/
export interface TurnContextSink {
retrieval: TurnRetrievalSummary | null;
lessons: string[];
knowledgeMix: ResolvedKnowledgeMix | null;
}
export interface MemoryContextDeps {
currentPrompt: string;
activeBrain: BrainProfile;
agentSkillFile?: string;
/** Visible + internal 합친 raw chat history. 함수 안에서 internal 필터링. */
chatHistory: ChatMessage[];
memoryManager: MemoryManager;
retrievalOrchestrator: RetrievalOrchestrator;
/** vscode ExtensionContext — chat_sessions globalState 읽기에 사용. */
context: vscode.ExtensionContext;
/** 현재 turn 의 session id — recentSessions 에서 자기 자신 제외. */
currentTaskId: string;
/** 함수가 채울 turn-context sink. 호출자는 호출 전에 비워둬야 한다. */
turnCtx: TurnContextSink;
}
/**
* chat_sessions medium-term compact helper.
* , history , /
* orchestrator .
*/
function compactRecentSessions(
rawSessions: any[],
activeSessionId: string | null,
limit: number,
): Array<{ id: string; title: string; firstUserMsg: string; lastAssistantExcerpt: string; summary?: string; timestamp: number }> {
if (!Array.isArray(rawSessions) || rawSessions.length === 0 || limit <= 0) return [];
const pool = rawSessions.length > limit + 5 ? limit + 5 : rawSessions.length;
const out: Array<{ id: string; title: string; firstUserMsg: string; lastAssistantExcerpt: string; summary?: string; timestamp: number }> = [];
for (let i = 0; i < rawSessions.length && out.length < pool; i++) {
const s = rawSessions[i];
if (!s || typeof s !== 'object') continue;
const id = String(s.id ?? '');
if (!id || id === activeSessionId) continue;
const history: any[] = Array.isArray(s.history) ? s.history : [];
if (history.length === 0) continue;
const firstUser = history.find((m) => m?.role === 'user');
const lastAssistant = [...history].reverse().find((m) => m?.role === 'assistant');
const firstUserMsg = String(firstUser?.content ?? '').replace(/\s+/g, ' ').trim().slice(0, 200);
const lastTxt = String(lastAssistant?.content ?? '').replace(/\s+/g, ' ').trim();
const lastAssistantExcerpt = lastTxt.length <= 200 ? lastTxt : lastTxt.slice(-200);
const summary = typeof s.summary === 'string' ? s.summary.trim().slice(0, 600) : undefined;
if (!firstUserMsg && !lastAssistantExcerpt && !summary) continue;
out.push({
id,
title: String(s.title ?? '').trim() || firstUserMsg.slice(0, 50),
firstUserMsg,
lastAssistantExcerpt,
summary,
timestamp: typeof s.timestamp === 'number' ? s.timestamp : 0,
});
}
return out;
}
export async function buildMemoryContext(deps: MemoryContextDeps): Promise<string> {
const config = getConfig();
if (!config.memoryEnabled) return '';
// Settings 가 turn 사이에 바뀔 수 있으니 매번 동기화.
deps.memoryManager.updateConfig({
enabled: config.memoryEnabled,
shortTermLimit: config.memoryShortTermMessages,
});
const visibleHistory = deps.chatHistory.filter((message) => !message.internal);
const workspaceFolders = vscode.workspace.workspaceFolders;
const workspacePath = workspaceFolders ? workspaceFolders[0].uri.fsPath : undefined;
// Agent ↔ knowledge map. 매핑 없으면 folders=[] → orchestrator 가 whole-brain 사용 (legacy).
const scope = resolveScopeForAgent(deps.agentSkillFile, deps.activeBrain.localBrainPath);
// Context 윈도우 비례 retrieval 예산. 32K → 8K, 230K → 57K, 80K cap (scoring 속도).
const scaledTotalBudget = Math.min(
80000,
Math.max(8000, Math.floor(config.contextLength * 0.25)),
);
// medium-term layer 용 옛 세션 후보. sidebar 가 직접 쓰는 key 를 read-through.
const rawSessions = deps.context.globalState.get<any[]>('chat_sessions', []) || [];
const recentSessions = compactRecentSessions(
rawSessions,
deps.currentTaskId,
Math.max(0, config.memoryMediumTermSessions ?? 0),
);
// Hybrid retrieval (옵션): embedding model 있으면 query embedding 가져와 cosine
// + TF-IDF blend. timeout 4초 — endpoint 가 느리면 그냥 pure TF-IDF 로 진행.
let queryEmbedding: number[] | undefined;
if (config.embeddingModel) {
const EMBED_QUERY_TIMEOUT_MS = 4000;
try {
queryEmbedding = await Promise.race([
embedQuery(deps.currentPrompt, { baseUrl: config.ollamaUrl, model: config.embeddingModel }),
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), EMBED_QUERY_TIMEOUT_MS)),
]);
} catch {
queryEmbedding = undefined;
}
}
// Knowledge Mix 가중치 (per-agent → global → default). weight=50 이면 legacy 기본값과 동일.
const knowledgeMix = resolveKnowledgeMix(deps.agentSkillFile);
deps.turnCtx.knowledgeMix = knowledgeMix;
const mixedBrainFileLimit = mapWeightToBrainFileLimit(knowledgeMix.weight, config.memoryLongTermFiles);
const mixedRetrievalRatio = mapWeightToRetrievalRatio(knowledgeMix.weight);
// Unified RAG Pipeline 호출.
const result = deps.retrievalOrchestrator.retrieve(deps.currentPrompt, {
brain: deps.activeBrain,
memoryManager: deps.memoryManager,
workspacePath,
chatHistory: visibleHistory,
contextBudget: {
totalBudget: scaledTotalBudget,
retrievalRatio: mixedRetrievalRatio,
},
brainFileLimit: mixedBrainFileLimit,
scopeFolders: scope.folders,
recentSessions,
mediumTermLimit: config.memoryMediumTermSessions ?? 0,
queryEmbedding,
embeddingModel: config.embeddingModel || undefined,
embeddingBlendAlpha: config.embeddingBlendAlpha,
});
// Fire-and-forget background embedding. Vector 없는 파일만 embed 하므로
// steady-state turn 은 작업량 0 — 다음 turn 이 혜택.
if (config.embeddingModel) {
const scoredFilePaths = result.selectedChunks
.filter((c) => c.source === 'brain-memory' && c.metadata.filePath)
.map((c) => c.metadata.filePath!)
.filter((p, i, arr) => arr.indexOf(p) === i);
if (scoredFilePaths.length > 0) {
void backfillBrainEmbeddings(
deps.activeBrain.localBrainPath,
scoredFilePaths,
config.embeddingModel,
(texts) => embedTexts(texts, { baseUrl: config.ollamaUrl, model: config.embeddingModel }),
);
}
}
// webview "scope used" footer 가 읽는 turn-context summary. brain-trace 는
// 검색이 아니라 trace 표시용이라 usedMemoryLayers 에서 제외 (brain-memory 도 제외 —
// 별도 usedBrainFiles 로 표시).
const brainRoot = deps.activeBrain.localBrainPath;
const rel = (p?: string) => (p ? (path.relative(brainRoot, p) || p) : '');
const lessonChunks = result.lessonChunks || [];
deps.turnCtx.retrieval = {
agentName: scope.agent?.name ?? null,
scoped: scope.folders.length > 0,
source: String((scope as any).source ?? ''),
configuredFolders: scope.folders.map((abs) => rel(abs)),
usedBrainFiles: result.selectedChunks
.filter((c) => c.source === 'brain-memory' && c.metadata.filePath)
.map((c) => rel(c.metadata.filePath))
.filter((p, i, arr) => p && arr.indexOf(p) === i),
usedMemoryLayers: Array.from(new Set(
result.selectedChunks
.filter((c) => c.source !== 'brain-memory' && c.source !== 'brain-trace')
.map((c) => c.source as string),
)),
lessonFiles: lessonChunks.map((c) => rel(c.metadata.filePath)).filter((p, i, arr) => p && arr.indexOf(p) === i),
totalChunks: result.totalChunks,
selectedChunks: result.selectedChunks.length,
};
deps.turnCtx.lessons = lessonChunks.map((c) => c.content);
// Lessons 블록은 일반 RAG context 보다 앞 — context-overflow truncation 에서 먼저
// 살아남게.
const lessonBlock = buildLessonChecklistBlock(lessonChunks.map((c) => ({ title: c.title, content: c.content })));
const memoryBlock = deps.retrievalOrchestrator.buildContextString(result);
return [lessonBlock, memoryBlock].filter(Boolean).join('\n\n');
}
@@ -0,0 +1,20 @@
/**
* modelName , 404 / not-loaded
* fallback .
*
* LM Studio : `gemma3:4b` ":quant suffix" base name
* (`gemma3`) push. Ollama
* candidates 1.
*
* Stateless agent.ts private .
*/
export function buildModelCandidates(modelName: string, engine: 'lmstudio' | 'ollama'): string[] {
const candidates = [modelName];
if (engine === 'lmstudio') {
const baseModel = modelName.replace(/:\d+$/, '');
if (baseModel && baseModel !== modelName) {
candidates.push(baseModel);
}
}
return candidates;
}
@@ -0,0 +1,96 @@
import { getConfig } from '../../config';
import { estimateTokens, estimateModelParamsB } from '../contextManager';
import { isCasualConversationPrompt } from './promptDetection';
import { isAstraModeArchitectureQuestion } from './astraModeArchitecture';
import { shouldPreflightLocalProjectPath } from './localProjectIntent';
/**
* -LLM vs Multi-Agent (5 ) .
* (configEnabled), prompt ,
* multi-agent . / prompt LLM.
*
* Priority ( evaluated, ):
* 1) Astra-mode meta question / local-path preflight (false)
* 2) mode='off' legacy +
* 3) casual prompt / (<12자) 단일
* 4) mode='always' multi ( )
* 5) mode='auto' / context fraction / /
* multi. prompt tokens `chunkedSwitchTokens` **
* ( context chunked
* ).
*
* Stateless agent.ts private . 의존: config / token
* / detection ( stateless).
*/
export function shouldUseMultiAgentWorkflow(prompt: string, configEnabled: boolean): boolean {
if (!prompt || isAstraModeArchitectureQuestion(prompt)) {
return false;
}
if (shouldPreflightLocalProjectPath(prompt)) {
return false;
}
const cfg = getConfig();
const mode = cfg.workflowMultiAgentMode || 'auto';
// 'off' → 기존 키워드/길이 휴리스틱만 사용 (legacy multiAgentEnabled 토글 존중).
if (mode === 'off') {
const legacyComplex = prompt.length > 180 || /(보고서|심층|종합\s*분석|리서치|조사|전략\s*수립|기획안|제안서|roadmap|research|report|deep\s*analysis|strategy|proposal)/i.test(prompt);
if (!legacyComplex) return false;
return configEnabled || /(보고서|심층|종합\s*분석|리서치|조사|전략\s*수립|기획안|제안서|research|report|deep\s*analysis|strategy|proposal)/i.test(prompt);
}
// 인사·잡담은 5단계 파이프라인 낭비. 짧은 casual prompt 는 제외.
if (isCasualConversationPrompt(prompt)) {
return false;
}
if (prompt.trim().length < 12) {
return false;
}
// 'always' → 위 가드만 통과하면 무조건 발동.
if (mode === 'always') return true;
// 'auto' → 다음 중 하나라도 만족하면 발동:
// (1) 사용자가 multiAgentEnabled 를 명시적으로 켰다,
// (2) 작은 모델 (≤4B params) 이라 한 번에 처리하기 위험,
// (3) prompt 토큰이 효과적 context window 의 임계 이상을 차지한다,
// (4) "보고서/리뷰/심층 분석" 같은 명백한 복합 작업 키워드 매치,
// (5) prompt 길이 자체가 큼 (>240 chars).
if (configEnabled) return true;
const paramB = estimateModelParamsB(cfg.defaultModel);
if (paramB !== null && paramB <= 4) return true;
// ── 절대 임계값 게이트 (사용자 명시 요청) ────────────────────────────
// 입력 prompt 가 `chunkedSwitchTokens` 미만이면 *키워드·길이 트리거 모두 무시*
// 하고 단일 LLM 호출. 큰 컨텍스트 모델(131k 등)에서 "요약/리뷰" 같은 키워드만
// 써도 chunked 가 강제 발동해 답변이 느려지던 문제 해결.
//
// 이 게이트는 fraction 안전 체크보다 *먼저* 평가됨 — 사용자가 절대 임계값을
// 명시한 의도(50k 미만은 한 번에 처리)를 fraction 이 뒤집지 못하게. 작은
// 컨텍스트 모델 사용자는 config 에서 이 값을 모델 윈도우의 ~30% 로 낮춰야 함.
try {
const promptTokensForGate = estimateTokens(prompt);
if (promptTokensForGate < cfg.chunkedSwitchTokens) {
return false;
}
} catch { /* fall through — 안전 측 fraction/keyword 체크가 처리 */ }
try {
const effectiveCtx = cfg.smallModelContextCap > 0 && paramB !== null && paramB <= 4
? cfg.smallModelContextCap
: cfg.contextLength;
const promptTokens = estimateTokens(prompt);
const threshold = Math.floor(effectiveCtx * cfg.workflowAutoCtxFractionThreshold);
if (promptTokens >= threshold) return true;
} catch { /* 안전한 폴백: 키워드/길이 체크로 진행 */ }
if (/(보고서|심층|종합\s*분석|리서치|조사|전략\s*수립|기획안|제안서|코드\s*리뷰|리뷰|아키텍처|architecture|research|report|deep\s*analysis|strategy|proposal|review)/i.test(prompt)) {
return true;
}
if (prompt.length > 240) return true;
return false;
}

Some files were not shown because too many files have changed in this diff Show More