feat(core): 자기지식 접지·웹 접근·환경 자가점검 — 할루시네이션 방어 3중화 (v2.2.247)
- Alignment Self-Learning: 자가 조사(질문 전 두뇌 검색)·사용자 답변 두뇌 저장·핵심메시지/프로젝트 컨텍스트 주입 (alignmentResearch.ts 신규)
- 웹 접근: Bridge 폴백 직접 fetch(webFetch.ts 신규)·<fetch_url> 액션 태그·기업 모드 URL/아키텍처 컨텍스트 주입·bare 도메인 인식
- 트리거 버그 수정: startsWith('/') 가 절대경로를 슬래시 명령으로 오인 — 분석 지시·URL 주입 전멸 원인 (회귀 테스트 고정)
- 자기지식 접지: 기능 인벤토리 lazy 재생성·학습 메커니즘 정본 섹션·[인벤토리 대조] 태그 의무화·결정론적 재구현 제안 정정 훅(featureConceptMap.ts 신규)
- 환경 자가점검: HealthCheckMonitor 에 Bridge/두뇌 볼륨/git 자격증명/확장 버전 검사 4종 + readyBar ⚠ 표시
- 두뇌 동기화: 원격 미설정 시 로컬 새로고침 모드·staged 기준 commit 판정·인증 부재 안내
- 기타: outputFormat 기본 markdown(제목 렌더 복구)·레슨/행동제약 truncation 보호 구역 이동·[CONTEXT] 절단 우선순위 재정렬
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,20 +3,20 @@
|
||||
<!-- ASTRA:AUTO-START -->
|
||||
|
||||
## Snapshot
|
||||
- **Workspace**: `connectai` `v2.2.231` _(absolute path varies by environment; resolved from the active VS Code workspace)_
|
||||
- **Workspace**: `ConnectAI` `v2.2.247` _(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**: 551 source files, ~81,540 lines across 5 top-level modules.
|
||||
- **Stats**: 560 source files, ~83,576 lines across 5 top-level modules.
|
||||
|
||||
## Last Refresh
|
||||
- **Time**: 2026-06-12T07:39:29.082Z
|
||||
- **Time**: 2026-06-12T14:30:58.311Z
|
||||
- **Files newly analysed**: 3
|
||||
- **Files reused from cache**: 548
|
||||
- **Files reused from cache**: 557
|
||||
|
||||
## Directory Map
|
||||
```mermaid
|
||||
mindmap
|
||||
root((connectai))
|
||||
root((ConnectAI))
|
||||
src/
|
||||
features/
|
||||
sidebar/
|
||||
@@ -33,18 +33,18 @@ mindmap
|
||||
docs/
|
||||
records/
|
||||
docs/
|
||||
Meeting/
|
||||
plans/
|
||||
```
|
||||
|
||||
## Module Dependencies
|
||||
> Arrows: which top-level module imports from which.
|
||||
```mermaid
|
||||
flowchart LR
|
||||
src["src/<br/>313 files"]
|
||||
src["src/<br/>317 files"]
|
||||
media["media/<br/>6 files"]
|
||||
tests["tests/<br/>55 files"]
|
||||
tests["tests/<br/>58 files"]
|
||||
core_py["core_py/<br/>6 files"]
|
||||
docs["docs/<br/>171 files"]
|
||||
docs["docs/<br/>173 files"]
|
||||
tests --> src
|
||||
```
|
||||
|
||||
@@ -56,71 +56,71 @@ flowchart LR
|
||||
|
||||
## Hub Files
|
||||
> Imported by many other files — touching these has wide blast radius.
|
||||
- `src/utils.ts` — referenced by **100** files
|
||||
- `src/utils.ts` — referenced by **102** files
|
||||
- `src/config.ts` — referenced by **38** files
|
||||
- `src/agent.ts` — referenced by **34** files
|
||||
- `src/core/services.ts` — referenced by **15** files
|
||||
- `src/core/services.ts` — referenced by **17** 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/retrieval/brainIndex.ts` — referenced by **12** files · Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-IDF 점수를 계산했습니다 — 파일 수가 많아지면 그게 병목입니다. 이 모듈은 <brainPath>/.astra/brain-index.json 에
|
||||
- `src/integrations/telegram/telegramClient.ts` — referenced by **12** files
|
||||
- `src/retrieval/index.ts` — referenced by **11** files · RetrievalOrchestrator — Unified RAG Pipeline Astra의 모든 검색 소스를 통합 관리하는 오케스트레이터입니다. 검색 흐름: ① Query Planning — 의도 분류 + 검색 전략 결정 ② Parallel Search — Brain + Memory + Project + Episode 동시 검색 ③ Result Fusio
|
||||
|
||||
## Modules
|
||||
|
||||
### `src/` — 313 files, ~59,911 lines
|
||||
### `src/` — 317 files, ~61,263 lines
|
||||
|
||||
**Sub-directories**
|
||||
- `src/features/` (110) — Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema 도 같은 entry 로 노출 예정. 현재 노출: full webview panel H
|
||||
- `src/features/` (112) — Generic event-sourced store — append-only .jsonl 파일 1개를 읽고/쓰는 공통 기반. 배경: customers, hire, runway, feedback 4개 store 가 같은
|
||||
- `src/sidebar/` (35) — Brain profile lifecycle 의 pure helpers — sidebarProvider 의 add/edit/delete 흐름에서 modal UI 와 config 쓰기를 제외한 데이터 변환 만 격리. 현
|
||||
- `src/lib/` (33) — Astra Mode Architecture Context Builder. 의도: 사용자가 Astra 자체의 mode 디자인 (Guard vs Multi-Agent 가 별도 모드여야 하는지) 을 묻는 메타 질문에 답할
|
||||
- `src/agent/` (30) — 한·영 깨진 토큰 감지·수리 — 소형 로컬 모델의 토큰 붕괴 보정. 증상: 한국어 단어 중간에 영문 토큰이 섞임 — "덩어리"→"덩ey", "결과적으로"→"결ently". 프롬프트 규칙([출력 위생])으로는 못 막는
|
||||
- `src/agent/` (31) — 한·영 깨진 토큰 감지·수리 — 소형 로컬 모델의 토큰 붕괴 보정. 증상: 한국어 단어 중간에 영문 토큰이 섞임 — "덩어리"→"덩ey", "결과적으로"→"결ently". 프롬프트 규칙([출력 위생])으로는 못 막는
|
||||
- `src/intelligence/` (18) — Confidence Engine — 답변 확신도 0~100 결정론적 산출. Self-Evolving OS 마스터 플랜 Phase 2 / Track 1-1. 신뢰 조건 T4 "확신이 없으면 사람에게 묻는다" 의 측정
|
||||
- `src/retrieval/` (18) — Actionability Scoring — 검색 결과를 "현재 작업 상태" 신호로 재가중. 기존 TF-IDF (단어 매칭) + recency (시간) 만으로는 "지금 이 사용자가 하고 있는 작업과 직접 연결 된 문서
|
||||
- `src/core/` (15) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토
|
||||
- `src/extension/` (12) — 두뇌(Second Brain) 기본 위치 부트스트랩 — 첫 실행 온보딩. 문제: 두뇌 미설정 시 config 가 ~/.g1nation-brain(숨김 점폴더)로 조용히 폴백했다. - 폴더가 실제로 생성되지 않고, 설
|
||||
- `src/memory/` (9) — Distillation Loop — stale Episodic Memory → Long-Term "episode-digest" 승급. 배경: Episodic Memory 가 무한히 누적되면 검색 노이즈. 30일+ 지
|
||||
- `src/docs/` (6) — Bug: Edited agent.ts Edited agent.ts Edited agent.ts Edited agent.ts Edited agent.ts ...
|
||||
- `src/extension/` (13) — 두뇌(Second Brain) 기본 위치 부트스트랩 — 첫 실행 온보딩. 문제: 두뇌 미설정 시 config 가 ~/.g1nation-brain(숨김 점폴더)로 조용히 폴백했다. - 폴더가 실제로 생성되지 않고, 설
|
||||
- `src/memory/` (9) — Episodic Memory (일화 기억) 과거 대화/회의/결정의 맥락 흐름을 저장합니다. 세션 종료 시 자동으로 에피소드를 요약하여 저장합니다. "왜 이렇게 결정했는지", "어떤 흐름으로 진행했는지" 기록. 저장
|
||||
- `src/docs/` (6) — src Chronicle Records
|
||||
- `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)
|
||||
|
||||
**Key files**
|
||||
- `src/utils.ts` (472 lines)
|
||||
- `src/config.ts` (637 lines)
|
||||
- `src/agent.ts` (1634 lines)
|
||||
- `src/utils.ts` (485 lines)
|
||||
- `src/config.ts` (661 lines)
|
||||
- `src/agent.ts` (1670 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/core/services.ts` (176 lines)
|
||||
- `src/sidebarProvider.ts` (3180 lines)
|
||||
- `src/sidebarProvider.ts` (3487 lines)
|
||||
- `src/integrations/telegram/telegramClient.ts` (154 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/lib/paths.ts` (151 lines)
|
||||
- `src/agent/actions/types.ts` (41 lines)
|
||||
- `src/lib/contextManager.ts` (278 lines) — Context Manager (컨텍스트 한계 관리) "context length = 132k" 는 "답변을 132k 토큰까지 생성해도 된다" 가 아닙니다. 시스템 프롬프트 + 대화 기록 + 입력 문서 + 생성될 답변 + 여유분 ≤ context length 이 모듈은 요청을 보내기 전에 입력 토큰을 추정하고, - 동적으로 출력 상한(maxTokens)을 계
|
||||
- `src/retrieval/brainIndex.ts` (566 lines) — Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-IDF 점수를 계산했습니다 — 파일 수가 많아지면 그게 병목입니다. 이 모듈은 <brainPath>/.astra/brain-index.json 에
|
||||
- `src/retrieval/scoring.ts` (541 lines) — Scoring Engine — TF-IDF + Bilingual Tokenizer 단순 includes() 키워드 매칭을 넘어서, TF-IDF 가중치 기반의 문서 스코어링을 제공합니다. 한국어/영어 양국어 토크나이저를 포함합니다.
|
||||
- `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/lib/paths.ts` (151 lines)
|
||||
- `src/skills/agentKnowledgeMap.ts` (374 lines)
|
||||
- `src/features/datacollect/slashRouter.ts` (201 lines)
|
||||
- `src/retrieval/types.ts` (66 lines) — Retrieval Types (검색 결과 통합 타입) 모든 검색 소스(Brain, Memory, Project, Episode)의 결과를 통합 인터페이스로 정의합니다.
|
||||
- `src/intelligence/requirementGraph.ts` (273 lines) — Requirement Graph — 업무 유형별 필수 요소 정의 + 감지 + 커버리지 검사. Self-Evolving Digital Employee OS 마스터 플랜(docs/SELFEVOLVINGOSMASTERPLAN.md) Phase 1 / Track 2-1. 신뢰 조건 T3 "품질이 일관적이다 — 필수 요소 누락 없음" 담당. 동작 2단계: 1. In
|
||||
- `src/lib/contextBuilders/promptDetection.ts` (107 lines) — 사용자 prompt 의 의도 분류 류 detection helpers. 모두 stateless 정규식 매칭. 옛 코드는 agent.ts 의 private 메서드로 박혀 있었는데, system prompt 빌더 (buildJarvisProjectBriefContext 등) 가 이걸 의존하면서 god-file 안에서 서로 얽힘. 헬퍼만 먼저 떼면 의존 그래프가
|
||||
- `src/memory/types.ts` (151 lines) — Memory Type Definitions (메모리 타입 정의) Astra의 5-Layer Cognitive Memory System의 모든 타입을 정의합니다. ① Short-Term ② Long-Term ③ Project ④ Procedural ⑤ Episodic
|
||||
- `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/intelligence/confidenceEngine.ts` (165 lines) — Confidence Engine — 답변 확신도 0~100 결정론적 산출. Self-Evolving OS 마스터 플랜 Phase 2 / Track 1-1. 신뢰 조건 T4 "확신이 없으면 사람에게 묻는다" 의 측정 기반 — Escalation Engine 의 입력. 설계 원칙 (termValidator 와 동일): LLM 호출 없음. 검색 그라운딩 신호(턴
|
||||
- `src/intelligence/reflectionStore.ts` (162 lines) — Reflection Store — 업무 turn 회고 기록 + Failure Pattern 집계. Self-Evolving OS 마스터 플랜 Phase 1 / Track 2-4 (Reflection Engine v1) + Phase 3 / Track 3-6 (Failure Pattern DB v1 시드). 신뢰 조건 T5 "같은 실수를 반복하지 않는다" 의
|
||||
- `src/extension/telegramCommands.ts` (103 lines)
|
||||
- `src/security.ts` (159 lines)
|
||||
|
||||
### `media/` — 6 files, ~7,785 lines
|
||||
### `media/` — 6 files, ~7,799 lines
|
||||
|
||||
**Key files**
|
||||
- `media/sidebar.css` (2114 lines) — Stylesheet
|
||||
- `media/sidebar.js` (3933 lines)
|
||||
- `media/sidebar.js` (3947 lines)
|
||||
- `media/sidebar.html` (539 lines) — Astra
|
||||
- `media/settings-panel.html` (440 lines) — Astra Settings
|
||||
- `media/settings-panel.js` (505 lines)
|
||||
- `media/settings-panel.css` (254 lines) — Stylesheet
|
||||
|
||||
### `tests/` — 55 files, ~7,902 lines
|
||||
### `tests/` — 58 files, ~8,276 lines
|
||||
*Depends on*: `src/`
|
||||
|
||||
**Sub-directories**
|
||||
@@ -151,9 +151,9 @@ 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/sleepDigest.test.ts` (101 lines) — Sleep-time 사전 소화 — 순수 로직 테스트 (대상 선정·노후화 판정·노트 형식). LLM 호출(runSleepDigestOnce)은 제외 — 통합 검증은 수동 명령으로.
|
||||
- `tests/stocksCriteria.test.ts` (129 lines) — criteriaEval — /stocks judge 결정론 평가기 테스트. 픽스처는 옛 LLM 프롬프트에 명시돼 있던 사용자의 실제 분류 예시 3종 (마녀공장/기가비스/엔켐) — 코드 판정이 사용자 패턴과 일치해야 한다.
|
||||
- `tests/alignmentResearch.test.ts` (208 lines)
|
||||
- `tests/conflictCheck.test.ts` (65 lines) — Schedule Conflict Check (Self-Evolving OS Track 6-2/6-3) 테스트.
|
||||
- `tests/dataProcessor.test.ts` (87 lines) — / <reference types="jest" />
|
||||
- `tests/featureInventory.test.ts` (58 lines) — 기능 인벤토리 자동 생성 + 충돌 스캔 대상 필터 — 순수 로직 테스트. (자기 지식 구식화 버그의 근본 수정: 인벤토리가 package.json 에서 기계 생성되는지)
|
||||
|
||||
### `core_py/` — 6 files, ~409 lines
|
||||
|
||||
@@ -165,39 +165,39 @@ flowchart LR
|
||||
- `core_py/optimizer.py` (55 lines)
|
||||
- `core_py/queue_worker.py` (82 lines)
|
||||
|
||||
### `docs/` — 171 files, ~5,533 lines
|
||||
### `docs/` — 173 files, ~5,829 lines
|
||||
|
||||
**Sub-directories**
|
||||
- `docs/records/` (157) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 프로젝트 코드 리뷰 해줄 수 있어? 개선할 부분이 있는지, 그러고...
|
||||
- `docs/docs/` (5) — Bug: Viewed integrationretrieval.test.ts:1-59 integrationretrieval.test.ts를 통해 ...
|
||||
- `docs/Meeting/` (0)
|
||||
- `docs/records/` (157) — Astra Project Chronicle Records
|
||||
- `docs/docs/` (5) — docs Chronicle Records
|
||||
- `docs/plans/` (2) — Alignment Self-Learning 개선 계획 (v2 — 적대적 리뷰 반영)
|
||||
|
||||
**Key files**
|
||||
- `docs/records/ConnectAI/timeline.md` (422 lines) — Project Timeline
|
||||
- `docs/TELEGRAM_REMOTE_EXECUTION_PLAN.md` (452 lines) — Telegram Remote Execution 기획서
|
||||
- `docs/records/ConnectAI/timeline.md` (422 lines) — Project Timeline
|
||||
- `docs/AgentEngine_Architecture.md` (314 lines) — AgentEngine Architecture Document
|
||||
- `docs/SELF_EVOLVING_OS_MASTER_PLAN.md` (275 lines) — ASTRA Self-Evolving Digital Employee OS — 마스터 개발 계획 v1.1
|
||||
- `docs/ASTRA_OFFICE_REFACTOR.md` (198 lines) — Astra Office Refactor — Design Doc
|
||||
- `docs/EXPERIENCE_MEMORY_PLAN.md` (122 lines) — Experience Memory (Mistake / Lesson Loop) — Implementation Plan
|
||||
- `docs/plans/alignment-self-learning-plan.md` (194 lines) — Alignment Self-Learning 개선 계획 (v2 — 적대적 리뷰 반영)
|
||||
- `docs/plans/web-fetch-and-mode-parity-plan.md` (102 lines) — 웹 접근 + 모드 동등성 수정 계획 (v2 — 적대적 리뷰 + 재검증 반영)
|
||||
- `docs/records/ConnectAI/development/2026-05-02_connectai_project_knowledge_overview.md` (121 lines) — Astra Project Knowledge Overview
|
||||
- `docs/records/ConnectAI/development/2026-05-03_connectai_project_knowledge_overview.md` (121 lines) — Astra Project Knowledge Overview
|
||||
- `docs/Advanced_Features_Implementation_Guide.md` (40 lines) — Advanced Features Implementation Guide
|
||||
- `docs/PROJECT_CHRONICLE_GUARD_ROADMAP.md` (43 lines) — Project Chronicle Guard: Search Engine Roadmap
|
||||
- `docs/UX_UI_Consistency_Guidelines.md` (44 lines) — UX/UI Consistency Guidelines
|
||||
- `docs/docs/records/docs/README.md` (18 lines) — docs Chronicle Records
|
||||
- `docs/docs/records/docs/bugs/BUG-0001-viewed-integration-retrieval-test-ts-1-59-integration-retrie.md` (16 lines) — Bug: Viewed integrationretrieval.test.ts:1-59 integrationretrieval.test.ts를 통해 ...
|
||||
- `docs/docs/records/docs/chronicle.config.json` (11 lines) — JSON configuration
|
||||
- `docs/docs/records/docs/project-profile.md` (31 lines) — Project Profile
|
||||
- `docs/docs/records/docs/README.md` (18 lines) — docs Chronicle Records
|
||||
- `docs/docs/records/docs/timeline.md` (7 lines) — Project Timeline
|
||||
- `docs/PROJECT_CHRONICLE_GUARD_ROADMAP.md` (43 lines) — Project Chronicle Guard: Search Engine Roadmap
|
||||
- `docs/records/ConnectAI/bugs/BUG-0001-volumes-data-project-antigravity-connectai-프로젝트-코드-리뷰-해줄-수-있.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 프로젝트 코드 리뷰 해줄 수 있어? 개선할 부분이 있는지, 그러고...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0002-지금-내가-분석-요청하고-너가-답을-줄때-아래-템플릿에-맞춰-답을-써주고-있는데-개선-포인트가-있는지-확인해.md` (16 lines) — Bug: 지금 내가 분석 요청하고 너가 답을 줄때 아래 템플릿에 맞춰 답을 써주고 있는데, 개선 포인트가 있는지 확인해줘. ## 내가 보는 위험 가장 큰...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0003-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0004-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0005-다시한번-답줘-volumes-data-project-antigravity-connectai-내-질문에-대한-.md` (16 lines) — Bug: 다시한번 답줘. /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0006-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0007-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0008-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0009-문제점을-읽고-어떻게-개선하는게-최선인지-분석해주면-좋겠어-알겠습니다-지금부터-connectai-프로젝트-에.md` (16 lines) — Bug: 문제점을 읽고 어떻게 개선하는게 최선인지 분석해주면 좋겠어. 알겠습니다. 지금부터 ConnectAI 프로젝트에만 완전히 집중하겠습니다. ...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0010-문제점을-읽고-어떻게-개선하는게-최선인지-분석해주면-좋겠어-알겠습니다-지금부터-connectai-프로젝트-에.md` (16 lines) — Bug: 문제점을 읽고 어떻게 개선하는게 최선인지 분석해주면 좋겠어. 알겠습니다. 지금부터 ConnectAI 프로젝트에만 완전히 집중하겠습니다. ...
|
||||
- `docs/records/ConnectAI/README.md` (18 lines) — Astra Project Chronicle Records
|
||||
- `docs/records/ConnectAI/bugs/BUG-0001-volumes-data-project-antigravity-connectai-프로젝트-코드-리뷰-해줄-수-있.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 프로젝트 코드 리뷰 해줄 수 있어? 개선할 부분이 있는지, 그러고...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0002-지금-내가-분석-요청하고-너가-답을-줄때-아래-템플릿에-맞춰-답을-써주고-있는데-개선-포인트가-있는지-확인해.md` (16 lines) — Bug: 지금 내가 분석 요청하고 너가 답을 줄때 아래 템플릿에 맞춰 답을 써주고 있는데, 개선 포인트가 있는지 확인해줘. ## 내가 보는 위험 가장 큰...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0003-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0004-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0005-다시한번-답줘-volumes-data-project-antigravity-connectai-내-질문에-대한-.md` (16 lines) — Bug: 다시한번 답줘. /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0006-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused...
|
||||
|
||||
## VS Code Extension Surface
|
||||
- **Extension ID**: `g1nation.astra`
|
||||
@@ -242,7 +242,7 @@ flowchart LR
|
||||
- `g1nation.calendar.refresh` — Astra: Google Calendar 새로고침 📅
|
||||
- `g1nation.calendar.connectOAuth` — Astra: Google Calendar OAuth 연결 (쓰기) 🔐
|
||||
- `g1nation.devilAgent.toggle` — Astra: Toggle Devil Agent 🎭
|
||||
- **Configuration** (146 settings):
|
||||
- **Configuration** (149 settings):
|
||||
- `g1nation.multiAgentEnabled` *(boolean)* _(default: `false`)_ — Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks.
|
||||
- `g1nation.datacollectBridgeTarget` *(string)* _(default: `"local"`)_
|
||||
- `g1nation.datacollectBridgeUrl` *(string)* _(default: `"http://127.0.0.1:3002"`)_ — [local 타깃] Wiki/Datacollect MCP Bridge URL. /benchmark, /youtube, /wikify chat slash commands route here. The Bridge must be running (`npm run bridge` in the Datacollect project).
|
||||
@@ -303,7 +303,7 @@ flowchart LR
|
||||
- `g1nation.maxContextSize` *(number)* _(default: `32000`)_ — Maximum character count for active file context. Default: 32000
|
||||
- `g1nation.maxAutoSteps` *(number)* _(default: `50`)_ — Maximum autonomous steps the agent can take per request. Default: 50
|
||||
- `g1nation.dryRun` *(boolean)* _(default: `false`)_ — If enabled, the agent will ask for approval before committing any file changes.
|
||||
- _…and 86 more_
|
||||
- _…and 89 more_
|
||||
|
||||
## Dependencies
|
||||
- **Runtime** (2): `@lmstudio/sdk`, `pdf-parse`
|
||||
@@ -351,7 +351,7 @@ Astra는 대표님의 명시적인 승인 하에 로컬 시스템의 강력한
|
||||
**Designed for High-Performance Decision Making.**
|
||||
Copyright (C) **g1nation**. All rights reserved.
|
||||
|
||||
_Last auto-scan: 2026-06-12T07:39:29.082Z · signature `72294281`_
|
||||
_Last auto-scan: 2026-06-12T14:30:58.311Z · signature `314f69`_
|
||||
<!-- ASTRA:AUTO-END -->
|
||||
|
||||
## Purpose
|
||||
|
||||
+2013
-1919
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "직답 결과 — single-pass mock 응답입니다.",
|
||||
"createdAt": 1781240203092,
|
||||
"createdAt": 1781274728276,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "---\nid: wiki_on\ndate: 2026-06-12T04:56:43.094Z\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) (18ms)\n",
|
||||
"createdAt": 1781240203095,
|
||||
"result": "---\nid: wiki_on\ndate: 2026-06-12T14:32:08.279Z\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) (11ms)\n",
|
||||
"createdAt": 1781274728280,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"missionId": "wiki_on",
|
||||
"status": "completed",
|
||||
"startTime": "2026-06-12T04:56:43.071Z",
|
||||
"totalElapsedMs": 25,
|
||||
"startTime": "2026-06-12T14:32:08.260Z",
|
||||
"totalElapsedMs": 21,
|
||||
"results": {
|
||||
"direct": "직답 결과 — single-pass mock 응답입니다."
|
||||
},
|
||||
@@ -12,16 +12,16 @@
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "direct",
|
||||
"durationMs": 18,
|
||||
"durationMs": 11,
|
||||
"message": "답변 작성 중... (단일 호출 fast-path)",
|
||||
"ts": "2026-06-12T04:56:43.089Z"
|
||||
"ts": "2026-06-12T14:32:08.271Z"
|
||||
},
|
||||
{
|
||||
"from": "direct",
|
||||
"to": "completed",
|
||||
"durationMs": 7,
|
||||
"durationMs": 10,
|
||||
"message": "미션 완료",
|
||||
"ts": "2026-06-12T04:56:43.096Z"
|
||||
"ts": "2026-06-12T14:32:08.281Z"
|
||||
}
|
||||
],
|
||||
"resilienceMetrics": {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1781240209816,
|
||||
"createdAt": 1781274734889,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1781240209815,
|
||||
"createdAt": 1781274734889,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||
"createdAt": 1781240209811,
|
||||
"createdAt": 1781274734878,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
"createdAt": 1781240209813,
|
||||
"createdAt": 1781274734884,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+11
-11
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"missionId": "stress_conflict_1781240209794",
|
||||
"missionId": "stress_conflict_1781274734861",
|
||||
"status": "completed",
|
||||
"startTime": "2026-06-12T04:56:49.794Z",
|
||||
"totalElapsedMs": 23,
|
||||
"startTime": "2026-06-12T14:32:14.861Z",
|
||||
"totalElapsedMs": 28,
|
||||
"results": {
|
||||
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||
"section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
@@ -14,30 +14,30 @@
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "outline",
|
||||
"durationMs": 16,
|
||||
"durationMs": 11,
|
||||
"message": "답변 구조 잡는 중...",
|
||||
"ts": "2026-06-12T04:56:49.810Z"
|
||||
"ts": "2026-06-12T14:32:14.872Z"
|
||||
},
|
||||
{
|
||||
"from": "outline",
|
||||
"to": "section",
|
||||
"durationMs": 2,
|
||||
"durationMs": 6,
|
||||
"message": "본문 작성 중...",
|
||||
"ts": "2026-06-12T04:56:49.812Z"
|
||||
"ts": "2026-06-12T14:32:14.878Z"
|
||||
},
|
||||
{
|
||||
"from": "section",
|
||||
"to": "polish",
|
||||
"durationMs": 2,
|
||||
"durationMs": 6,
|
||||
"message": "최종 다듬기 중...",
|
||||
"ts": "2026-06-12T04:56:49.814Z"
|
||||
"ts": "2026-06-12T14:32:14.884Z"
|
||||
},
|
||||
{
|
||||
"from": "polish",
|
||||
"to": "completed",
|
||||
"durationMs": 3,
|
||||
"durationMs": 5,
|
||||
"message": "미션 완료",
|
||||
"ts": "2026-06-12T04:56:49.817Z"
|
||||
"ts": "2026-06-12T14:32:14.889Z"
|
||||
}
|
||||
],
|
||||
"resilienceMetrics": {
|
||||
@@ -0,0 +1,194 @@
|
||||
# Alignment Self-Learning 개선 계획 (v2 — 적대적 리뷰 반영)
|
||||
|
||||
## v1 → v2 변경 (리뷰 지적 반영)
|
||||
1. **[❌→해결] webview 렌더링**: 2-C를 "권장"이 아닌 필수 구현으로 격상. 삽입 위치 media/sidebar.js 의 openQuestions 렌더 블록(~1228) 직후. `c.answeredQuestions` 중 자가 조사 marker 필터 후 최대 3건 표시.
|
||||
2. **[❌→해결] selfAnswerQuestions 시스템 프롬프트 전문 명시** (아래 2-A에 추가).
|
||||
3. **[❌→해결] `companyAlignmentKnowledgeSave` package.json 명세 추가** (3-C에 키 이름·기본값·설명 명시).
|
||||
4. **[⚠→해결] contract mutation → 비파괴적 변경**: `analysis.contract`를 직접 mutate하지 않고 spread로 새 객체 생성(`enrichedContract`). 이후 모든 경로(payload·_alignment.set·_runCompanyTurn)에 enriched를 전달.
|
||||
5. **[⚠→해결] 자가 조사 marker 명확화**: `(자가 조사) ` → `(자가 조사로 두뇌에서 확인) ` — dispatcher의 LLM이 사용자 직접 답변과 구분 가능하도록 의미를 텍스트에 내장. formatContractForPrompt 수정 불필요.
|
||||
6. **[⚠→확인완료] 파일 쓰기 권한**: 기존 lessons.ts(line 64, 82)가 이미 fs.writeFileSync로 brain 폴더에 직접 쓰고 있음 — extension host는 로컬 fs 권한 제약 없음. 위험 아님.
|
||||
7. **[⚠→영향없음] alignment 'off' 모드**: 자가 조사는 `_runIntentAlignment` 내부에서만 작동 — alignment 자체가 안 돌면 자가 조사도 비활성. 추가 처리 불필요.
|
||||
|
||||
## 목표
|
||||
1인 기업 모드의 Intent Alignment(요청 분석) 단계가:
|
||||
1. 묻기 전에 **프로젝트 컨텍스트를 먼저 본다** (Phase 1)
|
||||
2. 그래도 비는 항목은 **두뇌를 스스로 검색해 답을 찾는다** (Phase 2)
|
||||
3. 정말 모르는 것만 사용자에게 묻고, **받은 답을 두뇌에 저장**해 다음부터 안 묻는다 (Phase 3)
|
||||
|
||||
## 확인된 코드 사실
|
||||
- `analyzeIntent` 입력(`IntentAnalysisInput`)에 프로젝트/두뇌 정보가 전혀 없음 — [intentAlignment.ts:51-74]
|
||||
- `_buildProjectArchitectureContext()`가 이미 존재, `.astra/project-context/architecture.md`를 16,000자 cap으로 포맷 — [sidebarProvider.ts:1781, architecturePayloads.ts:97]
|
||||
- 분석기 모델은 `companyIntentClassifierModel || defaultModel` (작은 모델) — [sidebarProvider.ts:1942]
|
||||
- 두뇌 검색 빌딩블록: `findBrainFiles`(utils, 5s cache) + `getBrainTokenIndex` + `tokenize/expandQuery/scoreTfIdfPreTokenized` + `extractBestExcerpt` — [retrieval/]
|
||||
- `RetrievalOrchestrator`는 `MemoryManager` 의존이라 alignment에서 직접 쓰기 무거움 → 빌딩블록 직접 조합
|
||||
- webview alignment 카드는 `openQuestions`만 렌더, `answeredQuestions`는 미표시 — [media/sidebar.js:1209]
|
||||
- Lesson 시스템(kind: qa-finding)은 "실수 회피" 용도로 의미가 다름 → **일반 노트로 저장** (lesson frontmatter 사용 안 함)
|
||||
- `analyzeIntent`는 throw하지 않는 패턴 (실패 시 low-conf fallback) — 신규 코드도 동일 패턴 준수
|
||||
- alignment 흐름: `_runIntentAlignment` → `shouldAutoProceedAlignment` 판정 → auto-proceed 또는 카드 표시 → `_handleAlignmentAnswer`(답변 라운드) / `_proceedWithCurrentAlignment`(진행 버튼)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — 프로젝트 컨텍스트 주입
|
||||
|
||||
### 1-A. `src/features/company/intentAlignment.ts`
|
||||
- `IntentAnalysisInput`에 `projectContext?: string` 추가 (doc comment 포함)
|
||||
- `_buildUserMessage`: projectContext 있으면 블록 추가:
|
||||
```
|
||||
[프로젝트 컨텍스트 — 현재 워크스페이스에서 자동 수집]
|
||||
아래는 현재 열려 있는 프로젝트의 아키텍처 요약입니다. 여기서 직접 확인되는
|
||||
사실(프로젝트가 무엇인지·기술 스택·구조)은 이미 알려진 정보로 취급해 context
|
||||
슬롯에 반영하고, openQuestions에 다시 넣지 마세요.
|
||||
---
|
||||
<내용>
|
||||
---
|
||||
```
|
||||
- `SYSTEM_PROMPT`에 규칙 1줄 추가: "[프로젝트 컨텍스트] 블록이 있으면 그 내용으로 context를 채우고, 거기서 답이 확인되는 질문은 openQuestions에 만들지 마세요."
|
||||
|
||||
### 1-B. `src/sidebarProvider.ts` `_runIntentAlignment`
|
||||
- 첫 라운드만(`!opts.previousContract`) — priorChatSummary와 동일 패턴:
|
||||
```ts
|
||||
let projectContext: string | undefined;
|
||||
if (!opts.previousContract) {
|
||||
try {
|
||||
const arch = this._buildProjectArchitectureContext();
|
||||
if (arch) {
|
||||
projectContext = arch.length > 3000
|
||||
? arch.slice(0, 3000) + '\n…(이하 생략 — 전체는 architecture.md 참조)'
|
||||
: arch;
|
||||
}
|
||||
} catch { /* alignment는 계속 */ }
|
||||
}
|
||||
```
|
||||
- `analyzeIntent` 입력에 `projectContext` 전달
|
||||
- 주의: architecture detach(autoAttach=false) 사용자는 빈 문자열 → Phase 1 효과 없음 (의도된 동작)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — 자가 조사(Self-Research) 패스
|
||||
|
||||
### 2-A. 신규 `src/features/company/alignmentResearch.ts`
|
||||
```ts
|
||||
export const SELF_RESEARCH_PREFIX = '(자가 조사) ';
|
||||
|
||||
export interface QuestionEvidence {
|
||||
question: string;
|
||||
excerpts: Array<{ title: string; relativePath: string; excerpt: string }>;
|
||||
}
|
||||
|
||||
// 두뇌 TF-IDF 검색 — 질문별 top 2 파일에서 발췌. 전체 합계 4,000자 cap.
|
||||
// brainPath 없음/빈 두뇌/에러 → 빈 excerpts (throw 금지)
|
||||
export function gatherEvidenceForQuestions(
|
||||
brainPath: string,
|
||||
questions: string[],
|
||||
): QuestionEvidence[]
|
||||
|
||||
// LLM 1회 호출 — 근거만으로 답할 수 있는 질문 판별.
|
||||
// JSON: { answers: [{ question, status: 'answered'|'unanswered', answer }] }
|
||||
// 4-stage 관용 파서 (intentAlignment 패턴 복제). 실패 시 전원 unanswered.
|
||||
export async function selfAnswerQuestions(
|
||||
ai: IAIService,
|
||||
input: { userPrompt: string; evidence: QuestionEvidence[]; model?: string },
|
||||
): Promise<Array<{ question: string; answered: boolean; answer: string }>>
|
||||
```
|
||||
|
||||
### 2-B. `_runIntentAlignment` 통합 (순서 변경 포함)
|
||||
analyzeIntent 직후, `shouldAutoProceedAlignment` 판정 **전에** 삽입:
|
||||
```ts
|
||||
const contract = analysis.contract;
|
||||
const reachedLimit = opts.roundsAsked >= opts.roundsLimit;
|
||||
|
||||
// ── 자가 조사: 사용자에게 묻기 전에 두뇌에서 스스로 답 찾기 ──
|
||||
if (cfg.companyAlignmentSelfResearch !== false
|
||||
&& contract.openQuestions.length > 0 && !reachedLimit) {
|
||||
try {
|
||||
const brain = getActiveBrainProfile();
|
||||
const evidence = gatherEvidenceForQuestions(brain.localBrainPath, contract.openQuestions);
|
||||
if (evidence.some((e) => e.excerpts.length > 0)) {
|
||||
const answers = await selfAnswerQuestions(new AIService(), {
|
||||
userPrompt: opts.userPrompt, evidence,
|
||||
model: cfg.companyIntentClassifierModel || cfg.defaultModel,
|
||||
});
|
||||
const solved = answers.filter((a) => a.answered && a.answer.trim());
|
||||
if (solved.length > 0) {
|
||||
const solvedSet = new Set(solved.map((s) => s.question));
|
||||
contract.answeredQuestions.push(
|
||||
...solved.map((s) => ({ q: s.question, a: SELF_RESEARCH_PREFIX + s.answer })));
|
||||
contract.openQuestions = contract.openQuestions.filter((q) => !solvedSet.has(q));
|
||||
// pixelOffice 로그
|
||||
}
|
||||
}
|
||||
} catch { /* 실패해도 원래 질문 그대로 진행 */ }
|
||||
}
|
||||
|
||||
if (shouldAutoProceedAlignment(...)) { ... } // 기존 흐름 — openQuestions가 비면 자연히 confirm/진행
|
||||
```
|
||||
- 라운드 카운트 소비 없음 (사용자 응답이 아니므로)
|
||||
- latency: 질문 있을 때만 LLM 1회 추가
|
||||
|
||||
### 2-C. webview `media/sidebar.js` (line ~1209 부근)
|
||||
- `answeredQuestions` 중 `(자가 조사)` prefix 항목이 있으면 카드에 소섹션 추가:
|
||||
"🔎 스스로 확인한 정보" + q/a 목록 (최대 3건, a는 150자 cap)
|
||||
|
||||
### 2-D. config
|
||||
- `src/config.ts`: `companyAlignmentSelfResearch: boolean` (default true) — 기존 company* 키 패턴
|
||||
- `package.json`: `g1nation.company.alignmentSelfResearch` boolean default true + 설명
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — 지식 요청 답변의 두뇌 저장 (학습 루프 완성)
|
||||
|
||||
### 3-A. `alignmentResearch.ts`에 추가
|
||||
```ts
|
||||
// 사용자가 직접 답한 Q/A만 (SELF_RESEARCH_PREFIX 제외, a.length >= 20)
|
||||
// 저장: <brain>/Alignment Knowledge/YYYY-MM-DD <slug30>.md (일반 노트, frontmatter 없음)
|
||||
// 동일 경로 존재 시 skip (중복 방지). 반환: 저장 경로 | null. throw 금지.
|
||||
export function saveAlignmentKnowledge(
|
||||
brainPath: string,
|
||||
input: { userPrompt: string; qaList: Array<{ q: string; a: string }> },
|
||||
): string | null
|
||||
```
|
||||
본문 형식:
|
||||
```md
|
||||
# {userPrompt 앞 50자}
|
||||
|
||||
> 1인 기업 모드 Intent Alignment에서 사용자가 직접 제공한 정보. ({date})
|
||||
|
||||
## 원본 요청
|
||||
{userPrompt}
|
||||
|
||||
## 확인된 정보
|
||||
### Q. {q}
|
||||
{a}
|
||||
```
|
||||
|
||||
### 3-B. 트리거 2곳 (fire-and-forget)
|
||||
- `_runIntentAlignment`의 auto-proceed 분기: `_runCompanyTurn` 직전
|
||||
- `_proceedWithCurrentAlignment`: `_runCompanyTurn` 직전
|
||||
- 조건: `cfg.companyAlignmentKnowledgeSave !== false` && 사용자 직접 답변 존재
|
||||
- try/catch + void (alignment 흐름 차단 금지), pixelOffice 로그 "💾 확인된 정보 두뇌 저장"
|
||||
|
||||
### 3-C. config
|
||||
- `companyAlignmentKnowledgeSave: boolean` default true + package.json 노출
|
||||
|
||||
### 효과 (루프 완성)
|
||||
저장된 노트는 일반 brain 노트 → 다음 turn에서 Phase 2 자가 조사가 TF-IDF로 발견 → 같은 질문 재발 시 스스로 해결 → 사용자에게 두 번 묻지 않음.
|
||||
|
||||
---
|
||||
|
||||
## 구현 순서
|
||||
1. intentAlignment.ts (Phase 1-A) — 순수 추가
|
||||
2. alignmentResearch.ts 신규 (2-A + 3-A) — 독립 모듈
|
||||
3. config.ts + package.json (2-D + 3-C)
|
||||
4. sidebarProvider.ts 통합 (1-B + 2-B + 3-B)
|
||||
5. media/sidebar.js (2-C)
|
||||
6. tests/alignmentResearch.test.ts 신규 — 파서 관용성, marker 필터, slug 생성, 저장 skip, 빈 두뇌 안전성
|
||||
7. `npx tsc --noEmit` + `npm test`
|
||||
|
||||
## 리스크 및 완화
|
||||
| 리스크 | 완화 |
|
||||
|---|---|
|
||||
| 작은 모델 토큰 폭주 (arch 16K) | 3,000자 재절단 |
|
||||
| 자가 조사 LLM이 엉뚱한 답을 "answered" 처리 | 프롬프트에 "근거에 명시된 것만, 불확실하면 unanswered" + 근거 출처 표기 |
|
||||
| 두뇌 오염 (자동 저장) | 사용자 직접 답변만, 20자 미만 제외, 전용 폴더, config로 off 가능 |
|
||||
| alignment latency 증가 | 질문 존재 시에만 작동, 검색은 캐시된 인덱스 |
|
||||
| 기존 테스트 파손 | SYSTEM_PROMPT 변경은 순수 추가, 기존 입력 필드 시그니처 유지 |
|
||||
@@ -0,0 +1,102 @@
|
||||
# 웹 접근 + 모드 동등성 수정 계획 (v2 — 적대적 리뷰 + 재검증 반영)
|
||||
|
||||
## v1 → v2 핵심 변화: 근본 원인 재진단
|
||||
|
||||
리뷰 과정에서 **일반 챗에는 URL 주입 기능이 이미 존재**함이 확인됨
|
||||
(`src/lib/contextBuilders/urlContext.ts`, agent.ts:557-569에서 호출).
|
||||
|
||||
### 그런데 왜 실패했나 (정확한 원인)
|
||||
1. **일반 챗**: `buildUrlContext`가 **Datacollect Bridge(127.0.0.1:3002)에 100% 의존**.
|
||||
확장은 Bridge를 자동 시작하지 않음 → Bridge 꺼져 있으면 '접근 실패' 정직 블록
|
||||
→ 모델이 "사이트 방문 불가"라고 답함. (Bridge 추출 실패/JS 렌더링 페이지도 동일)
|
||||
2. **기업 모드**: dispatcher 경로에 URL 주입이 **아예 없음** → 항상 불가.
|
||||
3. 검증 완료된 사실:
|
||||
- `isCasualConversation` 게이트는 40자 초과 프롬프트에 영향 없음 (문제 아님)
|
||||
- `buildRequestHistory`는 internal 메시지를 필터링하지 않음 → internal push가 LLM에 도달
|
||||
- continuation loop 트리거 = "action이 chatHistory를 늘렸는가" (agent.ts:1238) → read_file과 동일 패턴이면 fetch_url도 자동 재분석
|
||||
- `_handleCompanyCasual`은 일반 챗 경로(_handlePrompt)를 타므로 별도 처리 불필요
|
||||
- BASE_SYSTEM_PROMPT/DispatcherDeps를 단언하는 기존 테스트 없음 (안전)
|
||||
|
||||
---
|
||||
|
||||
## 수정 설계 (v2)
|
||||
|
||||
### A. 신규 `src/features/web/webFetch.ts` — Bridge 무관 직접 fetch (vscode 의존 없음)
|
||||
```ts
|
||||
export function extractUrls(text: string, max = 2): string[]
|
||||
// http(s)만, dedupe, trailing 구두점 제거, 슬래시 명령(/...)으로 시작하면 빈 배열
|
||||
|
||||
export interface WebFetchResult { ok: boolean; url: string; title: string; text: string; error?: string }
|
||||
export async function fetchUrlDirect(url: string, opts?: { timeoutMs?: number /*15s*/; maxChars?: number /*20000*/ }): Promise<WebFetchResult>
|
||||
// global fetch (bridgeClient가 이미 사용 — 호스트 지원 확인됨) + typeof 가드
|
||||
// AbortController timeout / html이면 script·style·noscript 제거 → 태그 strip →
|
||||
// 엔티티 최소 디코드 → 공백 정리 + <title> 추출 / html 아니면 raw cap / throw 금지
|
||||
```
|
||||
|
||||
### B. `urlContext.ts` 개선 — Bridge → 직접 fetch 폴백 (기존 인터페이스 유지)
|
||||
- `buildUrlContext(url)`: ① Bridge `/api/web-extract` 시도 (타임아웃 45s→**15s** 단축)
|
||||
→ ② 실패/빈 본문이면 `fetchUrlDirect` 폴백 → ③ 둘 다 실패 시 기존 정직 블록
|
||||
- **모듈 레벨 TTL 캐시** (URL→블록, 5분, 최대 10개) — chat/alignment/dispatcher가
|
||||
같은 URL을 연달아 요청해도 네트워크 1회
|
||||
- `extractUrlFromPrompt`는 유지하되 호출부는 `extractUrls`(최대 2개)로 확장
|
||||
- 실패 안내 문구에서 "브리지 실행 확인" → "직접 접속도 실패" 반영
|
||||
|
||||
### C. `<fetch_url>` 액션 태그 (LLM 주도 — 양 모드 광고)
|
||||
- 신규 `src/agent/actions/webFetch.ts`: `<fetch_url url="..."/>` (회당 최대 2개)
|
||||
- fileDeleteRead.ts의 read_file 패턴 복제: regex → `buildUrlContext(url)` →
|
||||
`ctx.report.push('🌐 Fetched: <url>')` + `ctx.chatHistory.push({role:'system', internal:true})`
|
||||
- chatHistory push → 일반 챗 continuation loop 자동 트리거 (검증됨)
|
||||
- transactionManager 불필요 (read-only)
|
||||
- agent.ts `executeActions`에 `applyWebFetchActions(ctx)` 등록 (listFiles 다음)
|
||||
- `utils.ts` BASE_SYSTEM_PROMPT: [ACTION 15: FETCH URL] — 라인 401 부근, 기존 포맷 준수
|
||||
("링크의 실제 내용이 필요할 때만, 일반 지식 질문에는 사용 금지" 지침 포함)
|
||||
- `promptBuilder.ts` specialist 액션 목록(129-142)에 fetch_url 추가
|
||||
|
||||
### D. 기업 모드 — DispatcherDeps에 **2개 별도 필드** (리뷰 권고 반영)
|
||||
```ts
|
||||
// dispatcher.ts DispatcherDeps에 추가:
|
||||
architectureContextBlock?: string; // 현재 워크스페이스 아키텍처 (문제 2 해소)
|
||||
webContextBlock?: string; // 사용자 프롬프트 URL pre-fetch 결과
|
||||
```
|
||||
- 4개 합성 지점(planner ~358, specialist ~671, verifier ~699, inspector/CEO ~1053)에서:
|
||||
```ts
|
||||
const prefix = [deps.architectureContextBlock, deps.webContextBlock, contract...]
|
||||
.filter(Boolean).join('\n\n');
|
||||
```
|
||||
contract **앞에** 배치 (둘 다 optional — 미전달 시 기존 동작 100% 동일)
|
||||
- `_runCompanyTurn`(sidebarProvider:2189) deps 빌드 시:
|
||||
- `architectureContextBlock` = `this._buildProjectArchitectureContext()` 6,000자 절단
|
||||
- `webContextBlock` = `extractUrls(userPrompt)` → `buildUrlContext` (캐시 적중) → 8,000자 cap
|
||||
- 빌드는 try/catch — 실패해도 turn 진행
|
||||
|
||||
### E. Alignment 웹 컨텍스트
|
||||
- `_runIntentAlignment` 첫 라운드: URL 있으면 `buildUrlContext`(캐시) 결과를
|
||||
기존 `projectContext` 입력에 append (합계 3,000자 cap 유지 — web 부분은 별도 2,000자 cap 후 합산이 아니라, arch 먼저 + web 이어붙이고 총 5,000자로 상향)
|
||||
- 효과: "그 사이트가 뭐냐"는 alignment 질문 차단
|
||||
|
||||
### F. config + package.json
|
||||
- `webAutoFetchEnabled: boolean` 기본 true — `g1nation.web.autoFetchUrls`
|
||||
(pre-fetch 게이트: 일반 챗 주입부 + _runCompanyTurn + alignment 모두 이 키 확인.
|
||||
기존 일반 챗 주입부에도 게이트 추가 — 현재는 무조건 실행)
|
||||
|
||||
---
|
||||
|
||||
## 구현 순서
|
||||
1. `src/features/web/webFetch.ts` 신규 + `tests/webFetch.test.ts`
|
||||
2. `urlContext.ts` 폴백 + 캐시 + 타임아웃 단축
|
||||
3. config.ts + package.json 키
|
||||
4. `src/agent/actions/webFetch.ts` + agent.ts 등록 + BASE_SYSTEM_PROMPT + promptBuilder
|
||||
5. dispatcher.ts deps 2필드 + 4지점 합성
|
||||
6. sidebarProvider.ts: `_runCompanyTurn` + `_runIntentAlignment`
|
||||
7. agent.ts 일반 챗 주입부: extractUrls(2개) + config 게이트
|
||||
8. `npx tsc --noEmit` + `npm test`
|
||||
|
||||
## 리스크 (v2)
|
||||
| 리스크 | 완화 |
|
||||
|---|---|
|
||||
| Bridge 타임아웃 45→15s 단축으로 느린 추출 실패 ↑ | 직접 fetch 폴백이 받아줌 (총 최대 ~30s) |
|
||||
| EUC-KR 등 비UTF-8 직접 fetch 깨짐 | Bridge 우선 경로가 1차 방어, 한계 문서화 |
|
||||
| 거대 페이지 토큰 폭주 | 직접 20,000자 / 기업 블록 8,000자 / alignment 총 5,000자 cap |
|
||||
| dispatcher 테스트 파손 | 신규 필드 optional — 미전달 시 기존과 동일 |
|
||||
| 캐시 오염 (실패 결과 캐시) | 실패 블록은 캐시하지 않음 — 성공 결과만 TTL 캐시 |
|
||||
| LLM의 fetch_url 남발 | 회당 최대 2개 처리 + "필요할 때만" 프롬프트 지침 |
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"projectId": "connectai",
|
||||
"projectName": "connectai",
|
||||
"projectRoot": "E:\\Wiki\\connectai",
|
||||
"recordRoot": "E:\\Wiki\\connectai\\docs\\records\\connectai",
|
||||
"projectName": "ConnectAI",
|
||||
"projectRoot": "/Volumes/Data/project/Antigravity/ConnectAI",
|
||||
"recordRoot": "/Volumes/Data/project/Antigravity/ConnectAI/docs/records/ConnectAI",
|
||||
"description": "Auto-created by Project Architecture activation.",
|
||||
"corePurpose": "",
|
||||
"detailLevel": "standard",
|
||||
"createdAt": "2026-05-20T09:42:40.003Z",
|
||||
"updatedAt": "2026-06-12T07:39:22.751Z"
|
||||
"createdAt": "2026-05-23T03:51:11.620Z",
|
||||
"updatedAt": "2026-06-12T14:21:27.301Z"
|
||||
}
|
||||
|
||||
@@ -1037,6 +1037,17 @@
|
||||
border-left: 2px solid var(--accent);
|
||||
background: rgba(99,102,241,0.04);
|
||||
}
|
||||
.company-alignment-card .cal-self-research {
|
||||
margin-top: 6px; padding: 6px 8px;
|
||||
border-left: 2px solid #22c55e;
|
||||
background: rgba(34,197,94,0.05);
|
||||
}
|
||||
.company-alignment-card .cal-self-research ul {
|
||||
margin: 4px 0 4px 16px; padding: 0;
|
||||
}
|
||||
.company-alignment-card .cal-self-research li {
|
||||
margin-bottom: 2px; color: var(--text-primary);
|
||||
}
|
||||
.company-alignment-card .cal-q-head {
|
||||
font-weight: 600; color: var(--text-bright); margin-bottom: 4px;
|
||||
}
|
||||
|
||||
+31
-1
@@ -416,10 +416,15 @@
|
||||
if (s.lmStudioError) {
|
||||
segs.push(`<span class="rb-seg bad" title="${escAttr(s.lmStudioError)}">⚠ LM Studio 로드 실패</span>`);
|
||||
}
|
||||
// 환경 전제 조건 경고 (Bridge·두뇌 볼륨·자격증명·확장 버전) — 전체 목록은 hover.
|
||||
if (Array.isArray(s.healthWarnings) && s.healthWarnings.length > 0) {
|
||||
segs.push(`<span class="rb-seg bad" title="${escAttr(s.healthWarnings.join('\n'))}">⚠ 환경 ${s.healthWarnings.length}건</span>`);
|
||||
}
|
||||
rbContent.innerHTML = segs.join('<span class="rb-sep">·</span>');
|
||||
if (rbDot) {
|
||||
const on = s.engine && s.engine.online;
|
||||
rbDot.className = 'rb-dot ' + (on === true ? 'ok' : on === false ? 'bad' : 'warn');
|
||||
const hasEnvWarn = Array.isArray(s.healthWarnings) && s.healthWarnings.length > 0;
|
||||
rbDot.className = 'rb-dot ' + (on === false ? 'bad' : hasEnvWarn ? 'warn' : on === true ? 'ok' : 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1205,6 +1210,31 @@
|
||||
summary.appendChild(dl('형식', c.format));
|
||||
card.appendChild(summary);
|
||||
|
||||
// ── 자가 조사로 해결된 항목 — 묻기 전에 두뇌에서 스스로 찾은 답 ──
|
||||
const SELF_RESEARCH_PREFIX = '(자가 조사로 두뇌에서 확인) ';
|
||||
if (Array.isArray(c.answeredQuestions)) {
|
||||
const selfResearched = c.answeredQuestions
|
||||
.filter((qa) => qa && typeof qa.a === 'string' && qa.a.startsWith(SELF_RESEARCH_PREFIX))
|
||||
.slice(0, 3);
|
||||
if (selfResearched.length > 0) {
|
||||
const srBlock = document.createElement('div');
|
||||
srBlock.className = 'cal-self-research';
|
||||
const srHead = document.createElement('div');
|
||||
srHead.className = 'cal-q-head';
|
||||
srHead.textContent = '🔎 스스로 확인한 정보 (두뇌 검색):';
|
||||
srBlock.appendChild(srHead);
|
||||
const srUl = document.createElement('ul');
|
||||
for (const qa of selfResearched) {
|
||||
const li = document.createElement('li');
|
||||
const ans = qa.a.slice(SELF_RESEARCH_PREFIX.length);
|
||||
li.textContent = `${qa.q} → ${ans.length > 150 ? ans.slice(0, 150) + '…' : ans}`;
|
||||
srUl.appendChild(li);
|
||||
}
|
||||
srBlock.appendChild(srUl);
|
||||
card.appendChild(srBlock);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 미해결 질문 ──
|
||||
if (Array.isArray(c.openQuestions) && c.openQuestions.length > 0 && v.kind === 'questions') {
|
||||
const qBlock = document.createElement('div');
|
||||
|
||||
+18
-3
@@ -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.231",
|
||||
"version": "2.2.247",
|
||||
"publisher": "g1nation",
|
||||
"license": "MIT",
|
||||
"icon": "assets/icon.png",
|
||||
@@ -926,8 +926,8 @@
|
||||
"plain",
|
||||
"markdown"
|
||||
],
|
||||
"default": "plain",
|
||||
"markdownDescription": "최종 답변 표시 방식.\n\n- `plain` (기본): 모델이 무심코 내보낸 마크다운 마커(`##`, `**`, `__`, `> `, `* ` 등)를 후처리로 모두 제거. 섹션 라벨 텍스트(예: `핵심 요약`)는 유지되지만 헤더 마커는 사라져 깔끔한 plain text 로 보임. 작은 로컬 모델이 학습된 습관으로 `## 다음 한 수` 같은 마커를 흘리는 문제 차단.\n- `markdown`: legacy 동작. 모델 출력을 그대로 렌더러에 넘김."
|
||||
"default": "markdown",
|
||||
"markdownDescription": "최종 답변 표시 방식.\n\n- `markdown` (기본): 모델 출력을 그대로 렌더러에 넘김 — 채팅 UI 가 마크다운을 렌더하므로 `##` 제목이 크게, `**` 강조가 굵게 표시되어 가독성이 좋음.\n- `plain`: 마크다운 마커(`##`, `**`, `__`, `> `, `* ` 등)를 후처리로 모두 제거. 제목/본문이 같은 크기의 평문이 됨 — 답변을 평문으로 복사해 쓰는 워크플로 전용. (작은 로컬 모델의 마커 누수 차단 용도였으나 채팅 가독성을 해쳐 기본에서 제외)"
|
||||
},
|
||||
"g1nation.chronicleAutoRecord": {
|
||||
"type": "boolean",
|
||||
@@ -966,6 +966,21 @@
|
||||
"default": 3,
|
||||
"description": "Maximum back-and-forth rounds the Intent Alignment analyzer is allowed to ask before forcing a 'confirm or cancel' card (it stops asking new questions and shows the current contract for user approval). Each round = one LLM call. Default 3."
|
||||
},
|
||||
"g1nation.web.autoFetchUrls": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "URL 자동 수집 — 메시지에 http(s) 링크가 있으면 답변 생성 전에 페이지 본문을 가져와 모델에게 전달합니다 (일반 챗·기업 모드·Intent Alignment 공통). Datacollect Bridge가 켜져 있으면 고품질 추출을 우선 사용하고, 꺼져 있으면 확장이 직접 페이지를 가져옵니다. 끄면 모델이 링크 내용을 알 수 없습니다."
|
||||
},
|
||||
"g1nation.company.alignmentSelfResearch": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Alignment 자가 조사 — 분석기가 만든 질문을 사용자에게 보여주기 전에 두뇌(지식 폴더)를 먼저 검색해 스스로 답할 수 있는 질문을 걸러냅니다. 두뇌에서 답을 찾은 항목은 '(자가 조사로 두뇌에서 확인)' 표시와 함께 contract에 반영되고, 정말 모르는 질문만 카드에 노출됩니다. 질문이 있을 때만 LLM 1회가 추가됩니다."
|
||||
},
|
||||
"g1nation.company.alignmentKnowledgeSave": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Alignment 학습 루프 — 사용자가 alignment 라운드에서 직접 답해준 Q/A를 두뇌의 'Alignment Knowledge' 폴더에 노트로 저장합니다. 다음 turn의 자가 조사가 이 노트를 발견해 같은 질문을 두 번 묻지 않게 됩니다. 20자 미만의 짧은 답과 자가 조사 항목은 저장하지 않습니다."
|
||||
},
|
||||
"g1nation.selfReflector.enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
|
||||
+34
-7
@@ -20,8 +20,10 @@ import { SessionManager } from './core/session';
|
||||
import { AgentWorkflowManager } from './agents/AgentWorkflowManager';
|
||||
import { buildAstraModeArchitectureContext } from './lib/contextBuilders/astraModeArchitecture';
|
||||
import { isScheduleRequest, buildScheduleContext } from './lib/contextBuilders/scheduleContext';
|
||||
import { isSelfAssessRequest, buildSelfAssessContext } from './lib/contextBuilders/selfAssessContext';
|
||||
import { extractUrlFromPrompt, buildUrlContext } from './lib/contextBuilders/urlContext';
|
||||
import { isSelfAssessRequest, isAboutSelf, buildSelfAssessContext } from './lib/contextBuilders/selfAssessContext';
|
||||
import { ensureFeatureInventory } from './extension/featureInventory';
|
||||
import { buildUrlContext } from './lib/contextBuilders/urlContext';
|
||||
import { extractUrls } from './features/web/webFetch';
|
||||
import { looksLikeCorrection, captureCorrection } from './intelligence/correctionLoop';
|
||||
import { shouldUseMultiAgentWorkflow } from './lib/contextBuilders/multiAgentRouting';
|
||||
import { buildThinkingPartnerResponseContract } from './lib/contextBuilders/thinkingPartnerContract';
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
isExplicitSecondBrainRequest,
|
||||
isSecondBrainInventoryRequest,
|
||||
isNoBrainDataRefusal,
|
||||
isAnalysisRequest,
|
||||
} from './lib/contextBuilders/promptDetection';
|
||||
import { stripAstraFormattingForAgentMode, computeModeSignature } from './lib/contextBuilders/systemPromptShaping';
|
||||
import { sanitizeAssistantContent, isRestartedAnswer, parseRationale } from './lib/contextBuilders/outputSanitization';
|
||||
@@ -154,6 +157,7 @@ import { applyFileCreateEditActions } from './agent/actions/fileCreateEdit';
|
||||
import { applyFileDeleteReadActions } from './agent/actions/fileDeleteRead';
|
||||
import { applyRunCommandActions } from './agent/actions/runCommand';
|
||||
import { applyListFilesActions } from './agent/actions/listFiles';
|
||||
import { applyWebFetchActions } from './agent/actions/webFetch';
|
||||
import { applyBrainOpsActions } from './agent/actions/brainOps';
|
||||
import { applyCalendarActions } from './agent/actions/calendar';
|
||||
import { applySheetsActions } from './agent/actions/sheets';
|
||||
@@ -542,8 +546,14 @@ export class AgentExecutor {
|
||||
// [자기 평가 정본 주입] 기능 개선/자기 평가 질의는 RAG 경쟁에 맡기지 않고
|
||||
// 현행 기능 인벤토리를 결정론적으로 주입 — 모델이 검색 없이 기억으로 답해
|
||||
// 이미 있는 기능을 신규 제안하던 구식화 버그(3회 재발)의 마지막 구멍 봉쇄.
|
||||
if (prompt && loopDepth === 0 && !isCasualConversation && activeBrain?.localBrainPath && isSelfAssessRequest(prompt)) {
|
||||
if (prompt && loopDepth === 0 && !isCasualConversation && activeBrain?.localBrainPath
|
||||
&& (isSelfAssessRequest(prompt) || (isAnalysisRequest(prompt) && isAboutSelf(prompt)))) {
|
||||
try {
|
||||
// 인벤토리 lazy 재생성 — 활성화 시 1회 생성은 brain 볼륨이 늦게
|
||||
// 마운트되면 조용히 건너뛰어 파일이 영영 없는 상태가 됐다 (그 결과
|
||||
// "파일 없음" 안내만 주입돼 모델이 구현 여부를 알 수 없었음).
|
||||
// 질의 시점에 한 번 더 보장. idempotent — 있으면 즉시 return.
|
||||
await ensureFeatureInventory(this.context);
|
||||
const selfAssessBlock = buildSelfAssessContext(activeBrain.localBrainPath);
|
||||
contextBlock += `\n\n${selfAssessBlock}`;
|
||||
// 성공 로그 필수 — "주입이 됐는데 모델이 무시" vs "주입 자체가 안 됨"을
|
||||
@@ -554,11 +564,26 @@ export class AgentExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
// [URL 실데이터] 채팅 프롬프트에 URL 이 있으면 브리지로 본문을 추출해 주입.
|
||||
// [근거 기반 분석 강제] 분석/검토/의견형 요청인데 모델이 코드를 읽지 않고
|
||||
// "~로 보입니다" 추측으로 답하는 실패 모드 차단. 워크스페이스가 열려 있으면
|
||||
// "주장 전에 read_file 로 실제 확인하라"는 지시를 주입 — 강제 주입 패턴의
|
||||
// 5번째 적용 (일정→캘린더, 자기평가→인벤토리, 정정→캡처, URL→실데이터와 동일).
|
||||
if (prompt && loopDepth === 0 && !isCasualConversation && isAnalysisRequest(prompt)
|
||||
&& vscode.workspace.workspaceFolders?.length) {
|
||||
contextBlock += `\n\n[근거 기반 분석 규칙 — 이 요청은 분석/검토형]
|
||||
- 이 워크스페이스의 코드·문서·기능에 대한 주장은 *이 대화에서 실제로 읽은 파일*에만 근거하라.
|
||||
- 확인하지 않은 구현을 "~로 보입니다", "~일 것입니다"라고 추측 서술하는 것은 금지. 먼저 <list_files path="..."/> 와 <read_file path="..."/> 태그로 관련 파일을 직접 열어 확인한 뒤 답하라. 태그를 emit 하면 시스템이 파일 내용을 주입하고 자동으로 이어서 답변하게 된다.
|
||||
- ⚠️ "소스 코드 확인이 필요합니다"라고 말만 하고 끝내는 것은 금지다. 확인이 필요하다고 판단했다면 *바로 이 답변 안에서* <list_files>/<read_file> 태그를 emit 하라 — 그것이 확인하는 방법이다. 태그로 접근 불가능한 대상(외부 시스템·미설치 도구 등)에 한해서만 "확인하지 못함"으로 명시하라.
|
||||
- "X 기능을 추가하라"고 제안하기 전에 그 기능이 이미 구현돼 있는지 해당 모듈을 찾아 읽어라. 이미 있는 기능을 새로 만들라고 제안하는 것은 잘못된 분석이다.
|
||||
- 일반론·추측으로 빈칸을 채우지 마라.`;
|
||||
}
|
||||
|
||||
// [URL 실데이터] 채팅 프롬프트에 URL 이 있으면 본문을 추출해 주입.
|
||||
// /wikify 만 URL 접근이 가능하고 일반 채팅은 "접근 불가"라고 답하던 공백 수정.
|
||||
if (prompt && loopDepth === 0 && !isCasualConversation) {
|
||||
const url = extractUrlFromPrompt(prompt);
|
||||
if (url) {
|
||||
// v2: Bridge 추출 → 직접 fetch 폴백 (urlContext 내부) + 최대 2개 URL + config 게이트.
|
||||
if (prompt && loopDepth === 0 && !isCasualConversation && getConfig().webAutoFetchEnabled !== false) {
|
||||
const urls = extractUrls(prompt, 2);
|
||||
for (const url of urls) {
|
||||
try {
|
||||
contextBlock += `\n\n${await buildUrlContext(url)}`;
|
||||
logInfo('URL 컨텍스트 주입 시도.', { url });
|
||||
@@ -720,6 +745,7 @@ export class AgentExecutor {
|
||||
negativeCtx,
|
||||
actualModel,
|
||||
contextLength: config.contextLength,
|
||||
dynamicBlocks: this._turnCtx.dynamicBlocks,
|
||||
})
|
||||
: buildAstraModeSystemPrompt({
|
||||
prompt,
|
||||
@@ -1586,6 +1612,7 @@ export class AgentExecutor {
|
||||
await applyFileDeleteReadActions(ctx);
|
||||
await applyRunCommandActions(ctx);
|
||||
await applyListFilesActions(ctx);
|
||||
await applyWebFetchActions(ctx);
|
||||
await applyBrainOpsActions(ctx);
|
||||
await applyCalendarActions(ctx);
|
||||
await applySheetsActions(ctx);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { HandlerContext } from './types';
|
||||
import { buildUrlContext } from '../../lib/contextBuilders/urlContext';
|
||||
|
||||
/**
|
||||
* Action 15: Fetch URL (read-only — transaction record 불필요)
|
||||
*
|
||||
* LLM 이 작업 중 웹 페이지의 실제 내용이 필요할 때 emit 하는 태그.
|
||||
* 결과 블록을 chatHistory 에 internal push — read_file 과 동일 패턴이라
|
||||
* 일반 챗의 continuation loop(컨텍스트 주입 → 자동 재호출)가 그대로 작동해
|
||||
* fetch 직후 모델이 내용을 분석한다.
|
||||
*
|
||||
* buildUrlContext 재사용: Bridge 추출 우선 → 직접 fetch 폴백 → 정직 실패
|
||||
* 블록 + 5분 캐시. 실패 블록도 chatHistory 에 push 한다 — 모델이 "가져왔다"고
|
||||
* 지어내지 않고 실패 사실을 사용자에게 전달해야 하므로.
|
||||
*/
|
||||
const FETCH_URL_MAX_PER_TURN = 2;
|
||||
|
||||
export async function applyWebFetchActions(ctx: HandlerContext): Promise<void> {
|
||||
const { aiMessage, report } = ctx;
|
||||
const fetchRegex = /<fetch_url\s+url=['"]?(https?:\/\/[^'">\s]+)['"]?\s*\/?>(?:<\/fetch_url>)?/gi;
|
||||
let match;
|
||||
let handled = 0;
|
||||
const seen = new Set<string>();
|
||||
while ((match = fetchRegex.exec(aiMessage)) !== null) {
|
||||
if (handled >= FETCH_URL_MAX_PER_TURN) {
|
||||
report.push(`⚠️ fetch_url 한도 초과 — 회당 최대 ${FETCH_URL_MAX_PER_TURN}개만 처리합니다.`);
|
||||
break;
|
||||
}
|
||||
const url = match[1].trim();
|
||||
if (seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
handled++;
|
||||
try {
|
||||
const block = await buildUrlContext(url);
|
||||
const ok = block.includes('실데이터');
|
||||
report.push(ok ? `🌐 Fetched: ${url}` : `⚠️ Fetch failed: ${url}`);
|
||||
ctx.chatHistory.push({ role: 'system', content: block, internal: true });
|
||||
} catch (err: any) {
|
||||
// buildUrlContext 는 throw 하지 않지만 방어적으로.
|
||||
report.push(`⚠️ Fetch error: ${url} — ${String(err?.message ?? err).slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,11 @@ export interface BuildAgentModeSystemPromptInput {
|
||||
actualModel: string;
|
||||
/** For token-cost logging — getConfig().contextLength. */
|
||||
contextLength: number;
|
||||
/**
|
||||
* 행동 제약·동적 블록 (behavior-constraints / cove-checklist 등) — Astra 모드와
|
||||
* 동일하게 [CONTEXT] *밖* 보호 구역에 join. 없으면 옛 동작 그대로.
|
||||
*/
|
||||
dynamicBlocks?: Map<string, string>;
|
||||
}
|
||||
|
||||
export function buildAgentModeSystemPrompt(input: BuildAgentModeSystemPromptInput): string {
|
||||
@@ -38,6 +43,7 @@ export function buildAgentModeSystemPrompt(input: BuildAgentModeSystemPromptInpu
|
||||
negativeCtx,
|
||||
actualModel,
|
||||
contextLength,
|
||||
dynamicBlocks,
|
||||
} = input;
|
||||
|
||||
// The Agent's prompt IS the primary directive (role / persona / tone / output format),
|
||||
@@ -69,10 +75,20 @@ export function buildAgentModeSystemPrompt(input: BuildAgentModeSystemPromptInpu
|
||||
|
||||
// [CONTEXT] … [/CONTEXT] 사이만 컨텍스트 초과 시 trim 대상 — agentBlock(앞)·reminder(뒤)·negative 는 보호.
|
||||
// memoryCtx(RAG/메모리/lessons)도 [CONTEXT] 안에 넣어 토큰이 빡빡할 때 대화 기록보다 먼저 잘리게 한다.
|
||||
// 순서 = 잘림 우선순위 (truncation 은 body *뒤*부터 자름): contextBlock(사용자가
|
||||
// 지금 가리킨 실데이터 — URL 본문/열린 파일/일정)을 맨 앞에 둬 최후까지 보존하고,
|
||||
// 배경 지식(brain RAG)과 memory 는 그보다 먼저 잘리게 한다.
|
||||
const priorConclusionBlock = priorConclusionCtx ? '\n\n' + priorConclusionCtx : '';
|
||||
// [자기 지식 + 1인칭] Astra 모드와 공용 — Agent 모드에서도 자기 오보고/3인칭 화법 방지.
|
||||
const selfIdentityBlock = '\n\n' + buildSelfIdentityBlock();
|
||||
const fullSystemPrompt = `${agentBlock}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}\n\n${strippedSystemPrompt}${selfIdentityBlock}${designerCtx}${secondBrainTraceCtx}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}${agentTailReminder}`;
|
||||
// 동적 블록 (행동 제약·CoVe 등) — [CONTEXT] 밖에 join 해 truncation 보호.
|
||||
let dynamicBlocksJoined = '';
|
||||
if (dynamicBlocks && dynamicBlocks.size > 0) {
|
||||
for (const body of dynamicBlocks.values()) {
|
||||
if (body && body.trim()) dynamicBlocksJoined += '\n\n' + body;
|
||||
}
|
||||
}
|
||||
const fullSystemPrompt = `${agentBlock}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}\n\n${strippedSystemPrompt}${selfIdentityBlock}${designerCtx}${secondBrainTraceCtx}${dynamicBlocksJoined}\n\n[CONTEXT]\n${contextBlock}\n${knowledgeContextForPrompt}\n${memoryCtx}\n[/CONTEXT]\n${negativeCtx}${agentTailReminder}`;
|
||||
|
||||
return fullSystemPrompt;
|
||||
}
|
||||
|
||||
@@ -101,5 +101,8 @@ export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInpu
|
||||
if (body && body.trim()) dynamicBlocksJoined += '\n\n' + body;
|
||||
}
|
||||
}
|
||||
return `${systemPrompt}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${selfGrowthIdentityCtx}${knowledgeMixCtx}${casualCtx}${dynamicBlocksJoined}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`;
|
||||
// [CONTEXT] 내부 순서 = 잘림 우선순위 (truncation 은 body *뒤*부터 자름):
|
||||
// contextBlock(사용자가 지금 가리킨 실데이터 — URL 본문/열린 파일/일정)을 맨 앞에
|
||||
// 둬 최후까지 보존, 배경 지식(brain RAG)과 memory 는 그보다 먼저 잘리게 한다.
|
||||
return `${systemPrompt}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${selfGrowthIdentityCtx}${knowledgeMixCtx}${casualCtx}${dynamicBlocksJoined}\n\n[CONTEXT]\n${contextBlock}\n${knowledgeContextForPrompt}\n${memoryCtx}\n[/CONTEXT]\n${negativeCtx}`;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import { appendReflection } from '../../intelligence/reflectionStore';
|
||||
import { detectGaps } from '../../intelligence/gapDetector';
|
||||
import { appendSuccessPattern } from '../../intelligence/skillScore';
|
||||
import { getConfig } from '../../config';
|
||||
import { detectReimplementedProposals, formatReimplementationFooter } from '../../extension/featureConceptMap';
|
||||
import { isSelfAssessRequest, isAboutSelf } from '../../lib/contextBuilders/selfAssessContext';
|
||||
|
||||
const devilRebuttalHook: PostAnswerHook = {
|
||||
id: 'devil-rebuttal',
|
||||
@@ -226,6 +228,28 @@ const criticLoopHook: PostAnswerHook = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 인벤토리 자동 대조 — 자기 평가/자기 분석 턴에서 답변이 *이미 구현된 기능*을
|
||||
* 신규 도입하라고 제안하면 결정론적으로(LLM 콜 0) 정정 푸터를 붙인다.
|
||||
*
|
||||
* 배경: 인벤토리를 프롬프트에 주입해도 작은 로컬 모델이 mid-prompt 컨텍스트를
|
||||
* 무시하고 "Reflection Layer 도입하라"(이미 Self-Reflector 3단계 구현) 같은
|
||||
* 제안을 반복하는 실패가 재현됐다. 주입은 1차 방어선 — 이 훅이 최종 방어선.
|
||||
*/
|
||||
const inventoryCrossCheckHook: PostAnswerHook = {
|
||||
id: 'inventory-cross-check',
|
||||
runAsync: false,
|
||||
run(ctx: PostAnswerHookContext): void {
|
||||
if (!ctx.assistantAnswer || !ctx.assistantAnswer.trim()) return;
|
||||
// 자기 대상 턴에만 — 일반 코딩/리서치 답변에 오탐 푸터를 붙이지 않게.
|
||||
if (!(isSelfAssessRequest(ctx.userPrompt) || isAboutSelf(ctx.userPrompt))) return;
|
||||
const hits = detectReimplementedProposals(ctx.assistantAnswer);
|
||||
if (hits.length === 0) return;
|
||||
const footer = formatReimplementationFooter(hits);
|
||||
if (footer) ctx.getWebview()?.postMessage({ type: 'streamChunk', value: footer });
|
||||
},
|
||||
};
|
||||
|
||||
export const POST_ANSWER_HOOKS: PostAnswerHook[] = [
|
||||
devilRebuttalHook,
|
||||
postHocSelfCheckHook,
|
||||
@@ -233,6 +257,7 @@ export const POST_ANSWER_HOOKS: PostAnswerHook[] = [
|
||||
requirementCoverageHook,
|
||||
confidenceEscalationHook,
|
||||
criticLoopHook,
|
||||
inventoryCrossCheckHook,
|
||||
];
|
||||
|
||||
/** 모든 hook 을 안전하게 실행 — 한 hook 의 throw 가 다른 hook 막지 않음. */
|
||||
|
||||
+26
-2
@@ -269,6 +269,23 @@ export interface IAgentConfig {
|
||||
companyIntentAlignmentMode: 'off' | 'smart' | 'strict';
|
||||
/** alignment 라운드 최대 횟수 (질문→답변 사이클). 1~5. */
|
||||
companyIntentAlignmentMaxRounds: number;
|
||||
/**
|
||||
* URL 자동 수집 — 사용자 프롬프트에 http(s) URL 이 있으면 LLM 호출 전에
|
||||
* 본문을 가져와 컨텍스트로 주입 (일반 챗 + 기업 모드 + alignment 공통 게이트).
|
||||
* Bridge 추출 우선, 실패 시 직접 fetch 폴백.
|
||||
*/
|
||||
webAutoFetchEnabled: boolean;
|
||||
/**
|
||||
* Alignment 자가 조사 — openQuestions 를 사용자에게 보여주기 전에 두뇌를
|
||||
* 검색해 스스로 답할 수 있는 질문을 걸러낸다. 끄면 기존 동작 (질문 즉시 노출).
|
||||
*/
|
||||
companyAlignmentSelfResearch: boolean;
|
||||
/**
|
||||
* Alignment 학습 루프 — 사용자가 alignment 라운드에서 직접 답한 Q/A 를
|
||||
* 두뇌의 "Alignment Knowledge" 폴더에 노트로 저장. 다음 turn 의 자가 조사가
|
||||
* 이 노트를 발견해 같은 질문을 두 번 묻지 않게 된다.
|
||||
*/
|
||||
companyAlignmentKnowledgeSave: boolean;
|
||||
/**
|
||||
* Pixel Office 시각화 패널을 사이드바에 표시할지 여부. UI layer 전용 —
|
||||
* 끈다고 Agent 행동이 바뀌지 않는다. Off면 webview는 패널을 숨기고
|
||||
@@ -538,6 +555,9 @@ export function getConfig(): IAgentConfig {
|
||||
// 이유는 config 가 features/ 아래 모듈을 의존하면 의도치 않은 순환 import 가 생기기 때문.
|
||||
// 둘이 어긋나면 안 되므로 변경 시 양쪽 같이 갱신.
|
||||
companyIntentAlignmentMaxRounds: Math.max(1, Math.min(5, cfg.get<number>('company.intentAlignmentMaxRounds', 3))),
|
||||
webAutoFetchEnabled: cfg.get<boolean>('web.autoFetchUrls', true),
|
||||
companyAlignmentSelfResearch: cfg.get<boolean>('company.alignmentSelfResearch', true),
|
||||
companyAlignmentKnowledgeSave: cfg.get<boolean>('company.alignmentKnowledgeSave', true),
|
||||
selfReflectorEnabled: cfg.get<boolean>('selfReflector.enabled', false),
|
||||
hollowCheckEnabled: cfg.get<boolean>('hollowCheck.enabled', true),
|
||||
hollowCheckAutoRetry: cfg.get<boolean>('hollowCheck.autoRetry', true),
|
||||
@@ -557,8 +577,12 @@ export function getConfig(): IAgentConfig {
|
||||
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();
|
||||
return v === 'markdown' ? 'markdown' : 'plain';
|
||||
// 기본 'markdown' — 채팅 webview 가 marked 로 렌더하므로 plain 이 기본이면
|
||||
// 최종본(streamReplace)에서 ## 헤더가 제거되어 제목/본문이 같은 크기로
|
||||
// 보이는 가독성 문제가 생긴다 (스트리밍 중에는 raw 가 그대로 렌더되어
|
||||
// 멀쩡하다가 완료 순간 평문으로 변하는 증상).
|
||||
const v = (cfg.get<string>('outputFormat', 'markdown') || 'markdown').trim().toLowerCase();
|
||||
return v === 'plain' ? 'plain' : 'markdown';
|
||||
})(),
|
||||
chronicleAutoRecord: cfg.get<boolean>('chronicleAutoRecord', true),
|
||||
lmStudioTopP: Math.max(0, Math.min(1, cfg.get<number>('lmStudio.sampling.topP', 0.9))),
|
||||
|
||||
+92
-4
@@ -1,16 +1,33 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
import { getConfig } from '../config';
|
||||
import { logInfo, logWarn, logError } from '../utils';
|
||||
import { logInfo, logWarn, logError, getActiveBrainProfile } from '../utils';
|
||||
import { getBridgeBaseUrl } from '../features/datacollect/bridgeClient';
|
||||
|
||||
/**
|
||||
* HealthCheckMonitor: Periodically monitors the environment
|
||||
* HealthCheckMonitor: Periodically monitors the environment
|
||||
* (Ollama, Disk, API) to ensure the agent stays functional.
|
||||
*
|
||||
*
|
||||
* v2.2.245: 머신 로컬 전제 조건 4종 추가 — Bridge 응답·두뇌 볼륨 마운트·
|
||||
* git push 자격증명·Antigravity 확장 버전 정합. 한 세션에서 이 4가지가 전부
|
||||
* 사고로 터진 적이 있는데 (URL 분석 실패 / 인벤토리 미생성 / 동기화 실패 /
|
||||
* 구버전 실행), 전부 사후 디버깅으로만 발견됐다. 이제 readyBar ⚠️ 로 사전 가시화.
|
||||
*
|
||||
* Properly tracks the interval timer for cleanup on deactivation.
|
||||
*/
|
||||
export class HealthCheckMonitor {
|
||||
private static intervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||
/** 마지막 검사 결과 — readyBar payload 가 읽어간다 (재검사 비용 없이). */
|
||||
private static _lastReports: string[] = [];
|
||||
/** 직전 토스트 내용 — 같은 경고를 10분마다 반복 토스트하지 않기 위한 dedupe. */
|
||||
private static _lastToastKey = '';
|
||||
|
||||
public static get lastReports(): string[] {
|
||||
return this._lastReports.slice();
|
||||
}
|
||||
|
||||
public static async runAllChecks(): Promise<{ ok: boolean; reports: string[] }> {
|
||||
const reports: string[] = [];
|
||||
@@ -46,9 +63,80 @@ export class HealthCheckMonitor {
|
||||
reports.push('Write permissions denied in the current workspace.');
|
||||
}
|
||||
|
||||
// 4. Datacollect Bridge 응답 — 어떤 HTTP 응답이든(404 포함) 살아 있는 것.
|
||||
// 네트워크 오류만 다운으로 판정. Bridge 다운이어도 URL 분석은 직접
|
||||
// fetch 폴백으로 동작하므로 경고는 영향 범위를 정확히 알린다.
|
||||
try {
|
||||
const bridgeUrl = getBridgeBaseUrl();
|
||||
if (bridgeUrl) {
|
||||
try {
|
||||
await fetch(bridgeUrl, { signal: AbortSignal.timeout(3000) });
|
||||
} catch {
|
||||
reports.push(`Datacollect Bridge(${bridgeUrl})가 응답하지 않습니다 — /wikify·/benchmark 와 고품질 URL 추출이 비활성 (URL 분석 자체는 직접 fetch 폴백으로 동작).`);
|
||||
}
|
||||
}
|
||||
} catch { /* config 읽기 실패 등 — 검사 자체를 조용히 skip */ }
|
||||
|
||||
// 5. 두뇌 볼륨/경로 — 외장 볼륨이 늦게 마운트되면 검색·레슨·인벤토리가
|
||||
// 조용히 비활성화되던 사고의 사전 감지.
|
||||
let brain: ReturnType<typeof getActiveBrainProfile> | undefined;
|
||||
try {
|
||||
brain = getActiveBrainProfile();
|
||||
if (brain?.localBrainPath && !fs.existsSync(brain.localBrainPath)) {
|
||||
reports.push(`두뇌 폴더가 없습니다 (외장 볼륨 미마운트?): ${brain.localBrainPath} — 검색·레슨·인벤토리가 비활성화됩니다.`);
|
||||
}
|
||||
} catch { /* profile 읽기 실패 — skip */ }
|
||||
|
||||
// 6. git push 자격증명 — 원격 동기화를 *설정한* 두뇌만 검사 (secondBrainRepo
|
||||
// 비어 있으면 두뇌 동기화가 로컬 새로고침 모드라 자격증명 불필요).
|
||||
try {
|
||||
if (brain?.secondBrainRepo?.trim() && brain.localBrainPath && fs.existsSync(brain.localBrainPath)) {
|
||||
try {
|
||||
execSync('git push --dry-run', {
|
||||
cwd: brain.localBrainPath,
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||
timeout: 5000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.stderr || e?.message || '');
|
||||
if (/could not read Username|Authentication failed|terminal prompts disabled/i.test(msg)) {
|
||||
reports.push(`두뇌 원격 push 자격증명이 없습니다 — 터미널에서 "cd ${brain.localBrainPath} && git push" 1회 실행으로 키체인에 저장하세요.`);
|
||||
}
|
||||
// 그 외(오프라인·upstream 없음 등)는 소음 방지 — 보고하지 않음.
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
|
||||
// 7. Antigravity 확장 버전 정합 — VS Code 와 Antigravity 양쪽에 설치된
|
||||
// 경우 버전이 갈리면 "고쳤는데 안 고쳐짐" 혼란의 단골 원인 (실사례:
|
||||
// 2.2.19 vs 2.2.232 공존). Antigravity 미사용 머신에서는 자동 skip.
|
||||
try {
|
||||
const agExtRegistry = path.join(os.homedir(), '.antigravity', 'extensions', 'extensions.json');
|
||||
if (fs.existsSync(agExtRegistry)) {
|
||||
const arr = JSON.parse(fs.readFileSync(agExtRegistry, 'utf8'));
|
||||
const mine = Array.isArray(arr)
|
||||
? arr.find((e: any) => e?.identifier?.id === 'g1nation.astra')
|
||||
: null;
|
||||
const myVersion = vscode.extensions.getExtension('g1nation.astra')?.packageJSON?.version;
|
||||
if (mine?.version && myVersion && mine.version !== myVersion) {
|
||||
reports.push(`Antigravity 에 다른 버전의 Astra(${mine.version})가 설치되어 있습니다 — 현재 실행 중 ${myVersion}. Antigravity 쪽은 수동 업데이트가 필요합니다.`);
|
||||
}
|
||||
}
|
||||
} catch { /* registry 파싱 실패 — skip */ }
|
||||
|
||||
this._lastReports = reports;
|
||||
|
||||
if (reports.length > 0) {
|
||||
logWarn(`Health Check Warnings: ${reports.join(' | ')}`);
|
||||
vscode.window.showWarningMessage(`Astra Health Warning: ${reports[0]}`);
|
||||
// 동일 경고 반복 토스트 방지 — 내용이 바뀐 경우에만 1회 토스트.
|
||||
const toastKey = reports.join('|');
|
||||
if (toastKey !== this._lastToastKey) {
|
||||
this._lastToastKey = toastKey;
|
||||
vscode.window.showWarningMessage(`Astra Health Warning: ${reports[0]}${reports.length > 1 ? ` (외 ${reports.length - 1}건 — 준비 상태 바 참조)` : ''}`);
|
||||
}
|
||||
} else {
|
||||
this._lastToastKey = '';
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 기능 개념 지도 — vscode 의존 없는 순수 모듈 (테스트 용이).
|
||||
*
|
||||
* 두 소비처:
|
||||
* 1. featureInventory.ts — "ASTRA 기능 인벤토리" 마크다운 생성 (LLM 프롬프트 주입용)
|
||||
* 2. postAnswerHooks inventory-cross-check — 답변이 *이미 구현된 기능*을 신규
|
||||
* 제안하는지 결정론적으로 검사해 정정 푸터를 붙임
|
||||
*
|
||||
* 2번이 존재하는 이유: 인벤토리를 프롬프트에 주입해도 작은 로컬 모델은 mid-prompt
|
||||
* 컨텍스트를 무시하고 일반론으로 "X를 도입하라"고 제안하는 실패가 반복 재현됐다.
|
||||
* 프롬프트 주입은 1차 방어선일 뿐 — 모델이 무시해도 사용자에게 정정이 보이는
|
||||
* 결정론적 안전망(LLM 콜 0)이 최종 방어선이다.
|
||||
*/
|
||||
|
||||
export interface ConceptEntry {
|
||||
/** 학술/일반 명칭 (마크다운 표기용). */
|
||||
concept: string;
|
||||
/** ASTRA 구현 내역 한 줄. */
|
||||
impl: string;
|
||||
/** 답변 본문에서 이 개념을 식별하는 키워드 (소문자 비교, 한·영). */
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
export const CONCEPT_ENTRIES: ConceptEntry[] = [
|
||||
{
|
||||
concept: 'CoVe / Chain-of-Verification / Self-Critique',
|
||||
impl: '구현됨 — coveEnabled(답변 전 그라운딩 체크리스트) + critic-loop 훅(문제 신호 턴 LLM 검수) + citationTrace(출처 역추적)',
|
||||
keywords: ['cove', 'chain-of-verification', 'self-critique', 'selfcritique', 'self-correction', '자기 검증', '자기검증', '자기 수정', '크리틱 에이전트', '비판적 사고 루프', '스스로 검토하는 단계'],
|
||||
},
|
||||
{
|
||||
concept: '지식 노후 점검 자동화 / Automated Decay Audit',
|
||||
impl: '구현됨 — 주간 성장 사이클이 매주 자동 실행 (decay-report.md) + "Astra: 지식 노후 점검" 수동 명령',
|
||||
keywords: ['노후화', '노후 점검', 'decay', '지식 신선도'],
|
||||
},
|
||||
{
|
||||
concept: '지식 충돌 감지/해결 / Conflict Resolver',
|
||||
impl: '구현됨 — 검색 시점 [CONFLICT WARNING] + 일일 충돌 스캔 + 신뢰도(trust·confidence·최신성) 비교 우선 권고. 최종 결정만 사람',
|
||||
keywords: ['충돌 감지', '충돌 해결', 'conflict resolver', '상충되는 정보 발견'],
|
||||
},
|
||||
{
|
||||
concept: '피드백 태깅 / 오류 분류 / Feedback Tagging',
|
||||
impl: '구현됨 — Correction Loop가 사용자 정정을 자동 분류(사실오류/근거누락/맥락누락/추론오류/지시불이행/형식오류)해 레슨+회귀 케이스로 저장',
|
||||
keywords: ['피드백 태깅', '피드백 루프 자동화', '오류 분류', '교훈으로 자동', '자동으로 \'교훈\'', '교훈을 자동', 'feedback tagging'],
|
||||
},
|
||||
{
|
||||
concept: '멀티스텝 플래닝 / Multi-Step Planning / CoT 강제',
|
||||
impl: '구현됨 — multiAgentEnabled(Planner→Researcher→Writer, 기본 OFF) + 1인 기업 모드 디스패처',
|
||||
keywords: ['멀티스텝 플래닝', 'multi-step planning', 'cot 강제', 'reasoning chain', '추론 체인', 'chain of thought', '생각 단계(thought)', '생각의 단계'],
|
||||
},
|
||||
{
|
||||
concept: '골든셋 자동 평가 / Regression Test',
|
||||
impl: '구현됨 — 주간 사이클 자동 평가 + 직전 대비 회귀 경보(regression-alert.md) + 정정 회귀 재검사',
|
||||
keywords: ['골든셋', '회귀 테스트', 'regression test'],
|
||||
},
|
||||
{
|
||||
concept: 'Sleep-time / 유휴 시간 학습',
|
||||
impl: '구현됨 — 일일 지식 사전 소화 (Digests/)',
|
||||
keywords: ['sleep-time', '유휴 시간 학습', '유휴시간 학습'],
|
||||
},
|
||||
{
|
||||
concept: '확신도 게이팅 / 환각 방지 표명',
|
||||
impl: '구현됨 — [GROUNDING] 강함/보통/약함 + 약함 시 표명 강제 + 학습큐 자동 등록',
|
||||
keywords: ['확신도 게이팅', '환각 방지', '확신 편향'],
|
||||
},
|
||||
{
|
||||
concept: 'Reflection Layer / 자기 성찰 / 메타 학습 루프 / Self-Reflection',
|
||||
impl: '구현됨 — Self-Reflector Phase A(답변 자가 점검 블록, opt-in) + Phase B(외부 검증 LLM + 자동 재시도) + Phase C(생성 파일 syntax 검증) + Hollow Code Check(빈 깡통 감지 + 자동 재작업) + 약점 프로필→자기검토 블록(최근 정정 통계가 다음 턴 행동을 직접 변경)',
|
||||
keywords: ['reflection layer', 'self-reflection', '자기 성찰', '성찰 계층', '성찰 단계', '성찰(reflection)', '메타 학습'],
|
||||
},
|
||||
{
|
||||
concept: '질문 전 자가 조사 / Self-Research / 경험 기반 제약 주입',
|
||||
impl: '구현됨 — Intent Alignment 자가 조사(질문을 사용자에게 노출하기 전 두뇌 검색으로 선해결) + 사용자 답변 두뇌 자동 저장(Alignment Knowledge 학습 루프) + 레슨 체크리스트 truncation 보호 구역 주입',
|
||||
keywords: ['자가 조사', 'self-research', '경험 기반 제약'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 신규 도입/추가/구축을 제안하는 문장인지 (또는 부재를 단정하는 문장).
|
||||
* '합' 포함 — "추가합니다/구축합니다" 활용형 미매치 갭 (회귀 테스트로 발견).
|
||||
* '강화' 동사 + "도입:" 명사형(소제목 스타일) 추가 — 12B 답변의
|
||||
* "Reasoning Chain 도입: ..." 패턴 미매치 갭 (회귀 테스트로 발견).
|
||||
*/
|
||||
const PROPOSAL_RE = /(도입|추가|구축|신설|개발|보강|강화|만들)(하|해|할|합|을|를)|(도입|추가|구축|신설)\s*[::]|필요합니다|제안합니다|추천합니다|부족합니다|부재|없습니다|미흡/;
|
||||
/** 같은 문장에서 이미 구현 사실을 인지하고 있으면 정정 불필요. */
|
||||
const ACKNOWLEDGED_RE = /(이미|기)\s*(구현|존재|있)|구현돼|구현되어|작동\s*중/;
|
||||
|
||||
export interface ReimplementationHit {
|
||||
concept: string;
|
||||
impl: string;
|
||||
/** 감지된 문장 (검증/디버그용, 120자 cap). */
|
||||
sentence: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 답변이 이미 구현된 기능을 "도입하라/없다"고 제안·단정하는 문장을 결정론적으로
|
||||
* 감지. LLM 콜 0 — 문장 단위 키워드 + 제안 동사 공기(co-occurrence) 검사.
|
||||
* 보수적 설계: 같은 문장에 "이미 구현/존재" 인지가 있으면 제외 (정정 노이즈 방지).
|
||||
*/
|
||||
export function detectReimplementedProposals(answer: string): ReimplementationHit[] {
|
||||
if (!answer || !answer.trim()) return [];
|
||||
// 문장 분리 — 마침표/줄바꿈/콜론 기준의 느슨한 분할이면 충분.
|
||||
const sentences = answer.split(/(?<=[.!?다요음됨])\s+|\n+/);
|
||||
const hits: ReimplementationHit[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const sentence of sentences) {
|
||||
const lower = sentence.toLowerCase();
|
||||
if (!PROPOSAL_RE.test(sentence)) continue;
|
||||
if (ACKNOWLEDGED_RE.test(sentence)) continue;
|
||||
for (const entry of CONCEPT_ENTRIES) {
|
||||
if (seen.has(entry.concept)) continue;
|
||||
if (entry.keywords.some((k) => lower.includes(k.toLowerCase()))) {
|
||||
seen.add(entry.concept);
|
||||
hits.push({
|
||||
concept: entry.concept,
|
||||
impl: entry.impl,
|
||||
sentence: sentence.trim().slice(0, 120),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/** 정정 푸터 마크다운 — 빈 hits 면 ''. */
|
||||
export function formatReimplementationFooter(hits: ReimplementationHit[]): string {
|
||||
if (hits.length === 0) return '';
|
||||
const lines: string[] = [];
|
||||
lines.push('\n\n---');
|
||||
lines.push('⚠️ **기능 인벤토리 자동 대조** (결정론적 검사 — LLM 아님): 위 답변이 신규 도입을 제안한 항목 중 다음은 **이미 구현되어 있습니다**.');
|
||||
for (const h of hits) {
|
||||
lines.push(`- **${h.concept}** → ${h.impl}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('_위 제안을 채택하기 전에 기존 구현을 먼저 확인하세요. (두뇌의 "ASTRA 기능 인벤토리.md" 참조)_');
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -32,20 +32,14 @@ const HOOK_DESCRIPTIONS: Record<string, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 학술/일반 개념 명칭 ↔ ASTRA 구현 매핑. 자기 개선 제안에서 모델이 학술 명칭
|
||||
* ("CoVe 도입하라")으로 제안할 때 설정 키(coveEnabled)와 같은 것임을 모르는
|
||||
* 이름 매핑 갭을 막는다. 코드와 함께 배포되므로 릴리스마다 자동 최신화.
|
||||
* 학술/일반 개념 명칭 ↔ ASTRA 구현 매핑 — featureConceptMap.ts 로 이전 (순수
|
||||
* 모듈). 인벤토리 마크다운과 inventory-cross-check 훅(결정론적 재구현 제안
|
||||
* 감지)이 같은 정본을 공유한다.
|
||||
*/
|
||||
const CONCEPT_MAP: Array<[concept: string, impl: string]> = [
|
||||
['CoVe / Chain-of-Verification / Self-Critique', '구현됨 — coveEnabled(답변 전 그라운딩 체크리스트) + critic-loop 훅(문제 신호 턴 LLM 검수) + citationTrace(출처 역추적)'],
|
||||
['지식 노후 점검 자동화 / Automated Decay Audit', '구현됨 — 주간 성장 사이클이 매주 자동 실행 (decay-report.md)'],
|
||||
['지식 충돌 감지/해결 / Conflict Resolver', '구현됨 — 검색 시점 [CONFLICT WARNING] + 일일 충돌 스캔 + 신뢰도(trust·confidence·최신성) 비교 우선 권고. 최종 결정만 사람'],
|
||||
['피드백 태깅 / 오류 분류 / Feedback Tagging', '구현됨 — Correction Loop가 사용자 정정을 자동 분류(사실오류/근거누락/맥락누락/추론오류/지시불이행/형식오류)해 레슨+회귀 케이스로 저장'],
|
||||
['멀티스텝 플래닝 / Multi-Step Planning / CoT 강제', '구현됨 — multiAgentEnabled(Planner→Researcher→Writer, 기본 OFF) + 1인 기업 모드 디스패처'],
|
||||
['골든셋 자동 평가 / Regression Test', '구현됨 — 주간 사이클 자동 평가 + 직전 대비 회귀 경보(regression-alert.md) + 정정 회귀 재검사'],
|
||||
['Sleep-time / 유휴 시간 학습', '구현됨 — 일일 지식 사전 소화 (Digests/)'],
|
||||
['확신도 게이팅 / 환각 방지 표명', '구현됨 — [GROUNDING] 강함/보통/약함 + 약함 시 표명 강제 + 학습큐 자동 등록'],
|
||||
];
|
||||
import { CONCEPT_ENTRIES } from './featureConceptMap';
|
||||
|
||||
const CONCEPT_MAP: Array<[concept: string, impl: string]> =
|
||||
CONCEPT_ENTRIES.map((e) => [e.concept, e.impl]);
|
||||
|
||||
function stripMd(s: string): string {
|
||||
return (s || '').replace(/\*\*|`|\[|\]/g, '').replace(/\s+/g, ' ').trim();
|
||||
@@ -88,6 +82,24 @@ export function buildInventoryMarkdown(pkg: any, nowIso: string): string {
|
||||
'아래 개념들은 명칭이 달라도 **이미 구현되어 있다**. 이들을 "도입/추가하라"고 제안하면 오답이다:',
|
||||
...CONCEPT_MAP.map(([concept, impl]) => `- **${concept}**: ${impl}`),
|
||||
'',
|
||||
'## 📖 학습 메커니즘 — 정본 (자기 학습 방식 질문에는 이 섹션만 근거로 답할 것)',
|
||||
'',
|
||||
'**대원칙: 모델 가중치는 절대 학습되지 않는다.** 모든 "학습"은 두뇌 폴더',
|
||||
'(설정된 localBrainPath — ConnectAI 프로젝트 루트가 아님)에 *파일로* 쌓이고,',
|
||||
'다음 턴의 검색·프롬프트 주입을 통해 행동이 바뀌는 **외부 기억 기반** 방식이다.',
|
||||
'두뇌 폴더를 지우면 학습도 사라진다. "패턴을 학습해 선제적으로 제시한다" 류의',
|
||||
'서술은 거짓이다 — 선제 제시 엔진은 존재하지 않는다.',
|
||||
'',
|
||||
'자동 학습 회로는 정확히 다음 5가지뿐이다:',
|
||||
'1. **Correction Loop** — 사용자 정정 발화 감지 → 오류 유형 자동 분류 → 레슨 + 회귀 케이스(.astra/eval/corrections.jsonl) 저장',
|
||||
'2. **레슨(Experience Memory)** — 과거 실수가 "실패 방지 체크리스트"로 다음 턴 프롬프트에 주입 (truncation 보호 구역)',
|
||||
'3. **약점 프로필** — 최근 정정 통계가 자기검토 블록을 통해 다음 턴 행동을 직접 변경',
|
||||
'4. **Alignment Knowledge** — 1인 기업 모드 질문에 사용자가 답해준 내용을 두뇌 "Alignment Knowledge" 폴더에 저장 → 같은 질문 재발 방지',
|
||||
'5. **주간 성장 사이클** — decay 리포트(지식 노후), 골든셋 회귀 평가, 학습 큐 갱신',
|
||||
'',
|
||||
'보조 기억(학습이 아닌 *기억*): 단기 대화 히스토리, [PRIOR TURN CONCLUSION] 앵커,',
|
||||
'세션 요약(중기), 두뇌 RAG(장기), Project Chronicle(기록 생성 — 검색 두뇌와 별개).',
|
||||
'',
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Alignment Self-Research — 사용자에게 묻기 전에 두뇌를 먼저 검색.
|
||||
*
|
||||
* Intent Alignment 분석기가 만든 openQuestions 를 사용자에게 노출하기 전에,
|
||||
* 활성 두뇌(지식 폴더)를 TF-IDF 로 검색해 *스스로 답할 수 있는 질문* 을 걸러낸다.
|
||||
* 답을 찾은 질문은 answeredQuestions 로 옮겨지고(자가 조사 marker 부착),
|
||||
* 정말 모르는 질문만 사용자에게 도달한다.
|
||||
*
|
||||
* Phase 3(학습 루프): 사용자가 직접 답해준 Q/A 는 두뇌의 전용 폴더에 일반
|
||||
* 노트로 저장된다. 다음 turn 에서 같은 질문이 나오면 이 모듈의 자가 조사가
|
||||
* 그 노트를 발견해 스스로 해결 — 같은 것을 두 번 묻지 않는 구조.
|
||||
*
|
||||
* 설계 원칙 (intentAlignment 와 동일):
|
||||
* - 절대 throw 하지 않는다. 검색/LLM/IO 실패는 "자가 조사 안 한 것"과
|
||||
* 동일하게 동작해야 한다 — alignment 흐름 차단 금지.
|
||||
* - Lesson(Experience Memory) 시스템과 분리 — 그쪽은 "실수 회피" 카드라
|
||||
* 주입 위치/의미가 다르다. 여기 저장물은 평범한 지식 노트.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { IAIService } from '../../core/services';
|
||||
import { findBrainFiles, logError, logInfo } from '../../utils';
|
||||
import { getBrainTokenIndex } from '../../retrieval/brainIndex';
|
||||
import { tokenize, expandQuery, scoreTfIdfPreTokenized, extractBestExcerpt } from '../../retrieval/scoring';
|
||||
|
||||
/**
|
||||
* 자가 조사로 채워진 답변의 식별 prefix. formatContractForPrompt 를 거쳐
|
||||
* dispatcher 의 LLM 에게 그대로 전달되므로, "사용자가 직접 말한 사실"과
|
||||
* 구분되도록 의미를 텍스트 자체에 내장한다. webview 카드 렌더에서도 이
|
||||
* prefix 로 자가 조사 항목을 골라낸다.
|
||||
*/
|
||||
export const SELF_RESEARCH_PREFIX = '(자가 조사로 두뇌에서 확인) ';
|
||||
|
||||
/** 두뇌 저장 폴더 이름 — Phase 3 의 지식 노트가 쌓이는 곳. */
|
||||
export const ALIGNMENT_KNOWLEDGE_DIR = 'Alignment Knowledge';
|
||||
|
||||
/** 질문 1개에 대해 두뇌에서 모은 근거 발췌. */
|
||||
export interface QuestionEvidence {
|
||||
question: string;
|
||||
excerpts: Array<{ title: string; relativePath: string; excerpt: string }>;
|
||||
}
|
||||
|
||||
/** 질문별 검색 상한 — 질문당 top 2 파일, 발췌 600자, 전체 합계 4,000자. */
|
||||
const FILES_PER_QUESTION = 2;
|
||||
const EXCERPT_MAX_CHARS = 600;
|
||||
const TOTAL_EVIDENCE_CAP = 4000;
|
||||
|
||||
/**
|
||||
* openQuestions 각각을 두뇌에 TF-IDF 검색해 근거 발췌를 모은다.
|
||||
* 빈 두뇌 / 잘못된 경로 / IO 에러 → 해당 질문의 excerpts 가 빈 배열 (throw 금지).
|
||||
* 인덱스는 brainIndex 의 mtime 캐시를 그대로 활용하므로 재호출 비용이 낮다.
|
||||
*/
|
||||
export function gatherEvidenceForQuestions(
|
||||
brainPath: string,
|
||||
questions: string[],
|
||||
): QuestionEvidence[] {
|
||||
const empty = questions.map((q) => ({ question: q, excerpts: [] as QuestionEvidence['excerpts'] }));
|
||||
if (!brainPath || questions.length === 0) return empty;
|
||||
try {
|
||||
const files = findBrainFiles(brainPath);
|
||||
if (files.length === 0) return empty;
|
||||
const index = getBrainTokenIndex(brainPath, files);
|
||||
if (index.length === 0) return empty;
|
||||
|
||||
let totalChars = 0;
|
||||
return questions.map((question) => {
|
||||
const queryTokens = expandQuery(tokenize(question));
|
||||
if (queryTokens.length === 0) return { question, excerpts: [] };
|
||||
const scored = scoreTfIdfPreTokenized(queryTokens, index)
|
||||
.filter((s) => s.score > 0)
|
||||
.slice(0, FILES_PER_QUESTION);
|
||||
const excerpts: QuestionEvidence['excerpts'] = [];
|
||||
for (const s of scored) {
|
||||
if (totalChars >= TOTAL_EVIDENCE_CAP) break;
|
||||
const doc = index[s.index];
|
||||
let content = '';
|
||||
try {
|
||||
content = fs.readFileSync(doc.filePath, 'utf8');
|
||||
} catch {
|
||||
continue; // 인덱스에 있는데 디스크에서 사라진 파일 — skip
|
||||
}
|
||||
const excerpt = extractBestExcerpt(content, tokenize(question), EXCERPT_MAX_CHARS);
|
||||
if (!excerpt.trim()) continue;
|
||||
totalChars += excerpt.length;
|
||||
excerpts.push({ title: doc.title, relativePath: doc.relativePath, excerpt });
|
||||
}
|
||||
return { question, excerpts };
|
||||
});
|
||||
} catch (e: any) {
|
||||
logError('alignmentResearch: evidence gathering failed; skipping self-research.', {
|
||||
error: e?.message ?? String(e),
|
||||
});
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
const SELF_ANSWER_SYSTEM_PROMPT = `당신은 "1인 기업 모드"의 *자가 조사 판정가*입니다. 사용자에게 질문을 던지기 전에, 두뇌(저장된 지식 노트)에서 검색된 근거만으로 각 질문에 답할 수 있는지 판정합니다.
|
||||
|
||||
규칙:
|
||||
- 근거 발췌에 *명시적으로 적혀 있는* 내용으로만 답하세요. 일반 상식·추측으로 채우는 것은 금지입니다.
|
||||
- 근거가 부분적이거나 모호하면 그 질문은 "unanswered" 입니다. 확신이 없으면 unanswered 가 정답입니다.
|
||||
- answered 인 질문의 answer 는 근거에서 추출한 사실을 2~3문장으로 요약하고, 출처 노트 제목을 괄호로 덧붙이세요.
|
||||
|
||||
⚠️ 반드시 아래 JSON 한 번만 출력. 다른 텍스트(설명·코드펜스·머리말) 일체 금지.
|
||||
|
||||
{
|
||||
"answers": [
|
||||
{ "question": "<원문 그대로>", "status": "answered" | "unanswered", "answer": "<answered 일 때만, 아니면 빈 문자열>" }
|
||||
]
|
||||
}`;
|
||||
|
||||
/** 자가 조사 판정 결과 한 건. */
|
||||
export interface SelfAnswer {
|
||||
question: string;
|
||||
answered: boolean;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM 1회 호출로 "근거만으로 답 가능한 질문"을 판정. 호출/파싱 실패 시
|
||||
* 모든 질문을 unanswered 로 반환 — 원래의 질문 카드 흐름이 그대로 동작.
|
||||
*/
|
||||
export async function selfAnswerQuestions(
|
||||
ai: IAIService,
|
||||
input: {
|
||||
userPrompt: string;
|
||||
evidence: QuestionEvidence[];
|
||||
model?: string;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
): Promise<SelfAnswer[]> {
|
||||
const withEvidence = input.evidence.filter((e) => e.excerpts.length > 0);
|
||||
const fallback = input.evidence.map((e) => ({ question: e.question, answered: false, answer: '' }));
|
||||
if (withEvidence.length === 0) return fallback;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('[사용자 원본 요청]');
|
||||
lines.push(input.userPrompt);
|
||||
lines.push('');
|
||||
lines.push('[판정할 질문과 두뇌 검색 근거]');
|
||||
for (const e of withEvidence) {
|
||||
lines.push('');
|
||||
lines.push(`질문: ${e.question}`);
|
||||
for (const x of e.excerpts) {
|
||||
lines.push(`- 근거 (노트: "${x.title}", 경로: ${x.relativePath}):`);
|
||||
lines.push(` ${x.excerpt.replace(/\n/g, '\n ')}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('판정 JSON만 출력:');
|
||||
|
||||
let raw = '';
|
||||
try {
|
||||
const result = await ai.chat({
|
||||
system: SELF_ANSWER_SYSTEM_PROMPT,
|
||||
user: lines.join('\n'),
|
||||
model: input.model,
|
||||
timeoutMs: input.timeoutMs,
|
||||
});
|
||||
raw = result.content || '';
|
||||
} catch (e: any) {
|
||||
logError('alignmentResearch: self-answer call failed; all questions pass through.', {
|
||||
error: e?.message ?? String(e),
|
||||
});
|
||||
return fallback;
|
||||
}
|
||||
const parsed = _parseSelfAnswerJson(raw);
|
||||
if (!parsed) {
|
||||
logInfo('alignmentResearch: self-answer parse failed; all questions pass through.', {
|
||||
rawHead: raw.slice(0, 100),
|
||||
});
|
||||
return fallback;
|
||||
}
|
||||
// LLM 출력을 원래 질문 목록에 매핑 — 누락된 질문은 unanswered 로 채움.
|
||||
const byQuestion = new Map(parsed.map((a) => [a.question.trim(), a]));
|
||||
return input.evidence.map((e) => {
|
||||
const hit = byQuestion.get(e.question.trim());
|
||||
if (hit && hit.status === 'answered' && hit.answer.trim()) {
|
||||
return { question: e.question, answered: true, answer: hit.answer.trim() };
|
||||
}
|
||||
return { question: e.question, answered: false, answer: '' };
|
||||
});
|
||||
}
|
||||
|
||||
/** 4-stage 관용 파서 — intentAlignment 와 동일 패턴 (작은 모델의 펜스/머리말 대응). */
|
||||
export function _parseSelfAnswerJson(raw: string): Array<{
|
||||
question: string;
|
||||
status: 'answered' | 'unanswered';
|
||||
answer: string;
|
||||
}> | null {
|
||||
if (!raw || !raw.trim()) return null;
|
||||
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
||||
const stage1 = (fenced ? fenced[1] : raw).trim();
|
||||
for (const candidate of [stage1, _extractFirstBalancedObject(stage1)]) {
|
||||
if (!candidate) continue;
|
||||
try {
|
||||
const obj = JSON.parse(candidate);
|
||||
const coerced = _coerceSelfAnswers(obj);
|
||||
if (coerced) return coerced;
|
||||
} catch { /* 다음 stage */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _coerceSelfAnswers(obj: unknown): ReturnType<typeof _parseSelfAnswerJson> {
|
||||
if (!obj || typeof obj !== 'object') return null;
|
||||
const answers = (obj as Record<string, unknown>).answers;
|
||||
if (!Array.isArray(answers)) return null;
|
||||
const out: Array<{ question: string; status: 'answered' | 'unanswered'; answer: string }> = [];
|
||||
for (const a of answers) {
|
||||
if (!a || typeof a !== 'object') continue;
|
||||
const r = a as Record<string, unknown>;
|
||||
const question = typeof r.question === 'string' ? r.question.trim() : '';
|
||||
if (!question) continue;
|
||||
const status = r.status === 'answered' ? 'answered' : 'unanswered';
|
||||
const answer = typeof r.answer === 'string' ? r.answer.trim() : '';
|
||||
out.push({ question, status, answer });
|
||||
}
|
||||
return out.length > 0 ? out : null;
|
||||
}
|
||||
|
||||
function _extractFirstBalancedObject(s: string): string | null {
|
||||
const start = s.indexOf('{');
|
||||
if (start === -1) return null;
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
for (let i = start; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (inString) {
|
||||
if (escape) escape = false;
|
||||
else if (ch === '\\') escape = true;
|
||||
else if (ch === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') { inString = true; continue; }
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return s.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Phase 3: 사용자 답변의 두뇌 저장 (학습 루프) ────────────────────────────
|
||||
|
||||
/** 저장 대상 필터 — 사용자가 *직접* 답한 실질적 정보만. */
|
||||
const MIN_ANSWER_CHARS = 20;
|
||||
|
||||
/**
|
||||
* 사용자가 alignment 라운드에서 직접 답해준 Q/A 를 두뇌의 전용 폴더에 일반
|
||||
* 노트로 저장한다. 자가 조사 항목(SELF_RESEARCH_PREFIX)과 20자 미만의 짧은
|
||||
* 답은 제외 — 두뇌 오염 방지. 같은 날 같은 요청의 노트가 이미 있으면 skip.
|
||||
*
|
||||
* @returns 저장된 파일 경로, 저장할 것이 없거나 실패하면 null (throw 금지).
|
||||
*/
|
||||
export function saveAlignmentKnowledge(
|
||||
brainPath: string,
|
||||
input: { userPrompt: string; qaList: Array<{ q: string; a: string }> },
|
||||
): string | null {
|
||||
try {
|
||||
if (!brainPath || !fs.existsSync(brainPath)) return null;
|
||||
const userAnswered = input.qaList.filter(
|
||||
(qa) => !qa.a.startsWith(SELF_RESEARCH_PREFIX) && qa.a.trim().length >= MIN_ANSWER_CHARS,
|
||||
);
|
||||
if (userAnswered.length === 0) return null;
|
||||
|
||||
const date = new Date();
|
||||
const ymd = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
const slug = _slugify(input.userPrompt, 30);
|
||||
const dir = path.join(brainPath, ALIGNMENT_KNOWLEDGE_DIR);
|
||||
const filePath = path.join(dir, `${ymd} ${slug}.md`);
|
||||
if (fs.existsSync(filePath)) return null; // 같은 turn 재진입/재실행 — 중복 저장 방지
|
||||
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const body: string[] = [];
|
||||
body.push(`# ${input.userPrompt.replace(/\s+/g, ' ').trim().slice(0, 50)}`);
|
||||
body.push('');
|
||||
body.push(`> 1인 기업 모드 Intent Alignment 에서 사용자가 직접 제공한 정보. (${ymd})`);
|
||||
body.push('');
|
||||
body.push('## 원본 요청');
|
||||
body.push(input.userPrompt.trim());
|
||||
body.push('');
|
||||
body.push('## 확인된 정보');
|
||||
for (const qa of userAnswered) {
|
||||
body.push(`### Q. ${qa.q.replace(/\n/g, ' ').trim()}`);
|
||||
body.push(qa.a.trim());
|
||||
body.push('');
|
||||
}
|
||||
fs.writeFileSync(filePath, body.join('\n').trimEnd() + '\n', 'utf8');
|
||||
logInfo('alignmentResearch: saved user-provided knowledge to brain.', { filePath });
|
||||
return filePath;
|
||||
} catch (e: any) {
|
||||
logError('alignmentResearch: knowledge save failed (non-fatal).', {
|
||||
error: e?.message ?? String(e),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 파일명 안전 slug — 한글 보존, 경로 위험 문자만 제거. */
|
||||
export function _slugify(text: string, maxChars: number): string {
|
||||
const cleaned = text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|#%{}$!'@`+=[\]]/g, '')
|
||||
.slice(0, maxChars)
|
||||
.trim();
|
||||
return cleaned || 'alignment';
|
||||
}
|
||||
@@ -215,6 +215,30 @@ export interface DispatcherDeps {
|
||||
* 'off'였던 경우.
|
||||
*/
|
||||
requirementContract?: RequirementContract;
|
||||
/**
|
||||
* 현재 워크스페이스의 아키텍처 컨텍스트 (architecture.md 요약, 호출자가
|
||||
* 절단해 전달). 일반 챗은 이 컨텍스트를 자동 주입받지만 기업 모드는 빠져
|
||||
* 있던 공백 수정 — specialist 가 "이 프로젝트가 뭐냐"를 추측하지 않게.
|
||||
* contract 와 같은 4개 지점(planner/specialist/verifier/inspector)에 prepend.
|
||||
*/
|
||||
architectureContextBlock?: string;
|
||||
/**
|
||||
* 사용자 프롬프트에 포함된 URL 의 pre-fetch 결과 ([URL CONTENT] 블록).
|
||||
* 기업 모드에는 continuation loop 가 없어 LLM 주도 fetch 결과를 재분석할
|
||||
* 기회가 없으므로, dispatch 전에 호출자가 가져와 모든 에이전트에게 배포.
|
||||
*/
|
||||
webContextBlock?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* deps 의 자동 수집 컨텍스트(architecture + web)를 한 prefix 문자열로 합성.
|
||||
* 둘 다 없으면 빈 문자열 — 기존 동작과 100% 동일.
|
||||
*/
|
||||
function buildExtraContextPrefix(deps: DispatcherDeps): string {
|
||||
const blocks = [deps.architectureContextBlock, deps.webContextBlock]
|
||||
.map((b) => (b || '').trim())
|
||||
.filter(Boolean);
|
||||
return blocks.length > 0 ? blocks.join('\n\n') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -353,11 +377,13 @@ export async function runCompanyTurn(
|
||||
};
|
||||
} else {
|
||||
const ceoModel = modelForAgent(state, 'ceo', deps.defaultModel);
|
||||
const plannerExtraPrefix = buildExtraContextPrefix(deps);
|
||||
const plannerContract = deps.requirementContract
|
||||
? formatContractForPrompt(deps.requirementContract)
|
||||
: undefined;
|
||||
const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, {
|
||||
model: ceoModel,
|
||||
contractBlock: deps.requirementContract
|
||||
? formatContractForPrompt(deps.requirementContract)
|
||||
: undefined,
|
||||
contractBlock: [plannerExtraPrefix, plannerContract].filter(Boolean).join('\n\n') || undefined,
|
||||
signal: deps.signal,
|
||||
});
|
||||
plan = plannerResult.plan;
|
||||
@@ -666,11 +692,13 @@ async function _dispatchOne(
|
||||
peerOutputs,
|
||||
brainContext, // injected as `[SECOND BRAIN CONTEXT]` block
|
||||
knowledgeMixPolicy: policyBlock, // injected as `[KNOWLEDGE MIX POLICY]` block
|
||||
// alignment 단계에서 도출된 contract가 deps에 있으면 모든 specialist의
|
||||
// system 프롬프트에 같은 ground truth로 prepend된다. 추측 방지.
|
||||
contractBlock: deps.requirementContract
|
||||
? formatContractForPrompt(deps.requirementContract)
|
||||
: undefined,
|
||||
// alignment 단계에서 도출된 contract + 자동 수집 컨텍스트(architecture/web)가
|
||||
// deps에 있으면 모든 specialist의 system 프롬프트에 같은 ground truth로
|
||||
// prepend된다. 추측 방지.
|
||||
contractBlock: [
|
||||
buildExtraContextPrefix(deps),
|
||||
deps.requirementContract ? formatContractForPrompt(deps.requirementContract) : '',
|
||||
].filter(Boolean).join('\n\n') || undefined,
|
||||
});
|
||||
// 우선순위: stage > agent > global default.
|
||||
const model = (stageModelOverride && stageModelOverride.trim())
|
||||
@@ -696,9 +724,10 @@ async function _dispatchOne(
|
||||
// 옛 dynamic import 8회 → 정적 import 로 promote (파일 상단). 모듈 자체 cyclic 없음.
|
||||
const cfgRuntime = getDispatcherConfig();
|
||||
if (cfgRuntime.selfReflectorExternalEnabled && rawResponse) {
|
||||
const contractBlock = deps.requirementContract
|
||||
? formatContractForPrompt(deps.requirementContract)
|
||||
: undefined;
|
||||
const contractBlock = [
|
||||
buildExtraContextPrefix(deps),
|
||||
deps.requirementContract ? formatContractForPrompt(deps.requirementContract) : '',
|
||||
].filter(Boolean).join('\n\n') || undefined;
|
||||
const verdict = await verifyResponse(deps.ai, {
|
||||
task,
|
||||
response: rawResponse,
|
||||
@@ -1048,11 +1077,13 @@ async function _runReviewCycle(args: {
|
||||
return { verdict: 'aborted', rounds: round - 1 };
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
// contract가 있으면 검수자/CEO 모두에게 같은 ground truth를 prepend —
|
||||
// 검수 기준이 contract와 일치하는지를 정확히 평가할 수 있다.
|
||||
const contractPrefix = deps.requirementContract
|
||||
? formatContractForPrompt(deps.requirementContract) + '\n\n'
|
||||
: '';
|
||||
// contract + 자동 수집 컨텍스트가 있으면 검수자/CEO 모두에게 같은 ground
|
||||
// truth를 prepend — 검수 기준이 contract와 일치하는지를 정확히 평가할 수 있다.
|
||||
const contractPrefixParts = [
|
||||
buildExtraContextPrefix(deps),
|
||||
deps.requirementContract ? formatContractForPrompt(deps.requirementContract) : '',
|
||||
].filter(Boolean).join('\n\n');
|
||||
const contractPrefix = contractPrefixParts ? contractPrefixParts + '\n\n' : '';
|
||||
|
||||
// ── 1) 검수자 LLM 콜 ──
|
||||
const inspectorSystem = contractPrefix + '당신은 산출물 *감리*입니다. 작업자의 결과물을 객관적으로 검토하고 한국어 마크다운으로 응답하세요.\n\n반드시 첫 줄을 다음 둘 중 하나로 시작:\n - ✅ 통과 — 산출물이 task 요구 + 위 contract의 criteria를 모두 충족하면.\n - ❌ 보완 필요: <구체 항목 한 줄> — contract 기준 누락·오류·약점이 있으면.\n\n그 다음 줄들에 *구체적인* 피드백 또는 칭찬 1~3줄. 모호한 일반론 금지.';
|
||||
|
||||
@@ -95,6 +95,13 @@ export type { ChatIntent, IntentContext, IntentResult, PipelineHint } from './in
|
||||
|
||||
export { analyzeIntent, formatContractForPrompt } from './intentAlignment';
|
||||
export type { IntentAnalysisInput, IntentAnalysisResult } from './intentAlignment';
|
||||
export {
|
||||
gatherEvidenceForQuestions,
|
||||
selfAnswerQuestions,
|
||||
saveAlignmentKnowledge,
|
||||
SELF_RESEARCH_PREFIX,
|
||||
} from './alignmentResearch';
|
||||
export type { QuestionEvidence, SelfAnswer } from './alignmentResearch';
|
||||
export type { RequirementContract } from './types';
|
||||
|
||||
export {
|
||||
|
||||
@@ -71,6 +71,14 @@ export interface IntentAnalysisInput {
|
||||
* 없으면 undefined (첫 진입 / 모드 토글 없는 케이스).
|
||||
*/
|
||||
priorChatSummary?: string;
|
||||
/**
|
||||
* 현재 열린 워크스페이스의 아키텍처 컨텍스트 (architecture.md 요약).
|
||||
* 사용자가 *지금 열어둔 프로젝트* 에 대해 요청하는 경우가 많은데, 분석기가
|
||||
* 이걸 못 보면 "그 프로젝트가 뭐냐"는 — 워크스페이스가 이미 답하고 있는 —
|
||||
* 질문을 던진다. 첫 라운드에만 첨부 (후속 라운드는 contract 에 흡수됨).
|
||||
* 작은 모델 보호를 위해 호출자가 3,000자 내외로 절단해 전달.
|
||||
*/
|
||||
projectContext?: string;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `당신은 "1인 기업 모드"의 *요청 분석가*입니다. 사용자의 자연어 요청을 받아 그것을 실행 가능한 작업 조건 5가지(C-G-C-F-Q)로 정리합니다.
|
||||
@@ -85,6 +93,8 @@ const SYSTEM_PROMPT = `당신은 "1인 기업 모드"의 *요청 분석가*입
|
||||
|
||||
⚠️ **[모드 전환 시 context 우선 추출]**: 입력에 \`[모드 전환 직전 일반 채팅 요약]\` 블록이 있으면, 그것을 **사용자의 한 줄과 같은 권위로** 취급하세요. 거기서 context/goal/criteria/format 을 *직접 추출* 한 뒤, 그래도 빠진 항목만 openQuestions 에 넣으세요. 사용자가 이미 일반 채팅에서 충분히 설명한 내용을 다시 물어보면 안 됩니다 — 일반 채팅에서 *명시적으로 언급* 된 항목은 추측이 아니라 **명시된 사실** 입니다.
|
||||
|
||||
⚠️ **[프로젝트 컨텍스트 우선 활용]**: 입력에 \`[프로젝트 컨텍스트]\` 블록이 있으면 그것은 *사용자가 지금 열어둔 워크스페이스* 의 실제 구조 요약입니다. 거기서 직접 확인되는 사실(이 프로젝트가 무엇인지, 기술 스택, 폴더 구조, 주요 기능)은 이미 알려진 정보로 취급해 context 슬롯에 반영하고, 그 블록에서 답이 확인되는 질문은 openQuestions 에 만들지 마세요. 예: 사용자가 그 프로젝트 이름·경로를 언급하며 요청하면 "그 프로젝트가 무엇인가요?" 같은 질문은 금지입니다.
|
||||
|
||||
confidence는 다음 기준으로 자체 판정:
|
||||
- "high" : C·G·C·F 4개 모두 prompt에서 직접 추론 가능. openQuestions = [] 가능.
|
||||
- "medium" : 대체로 명확하지만 1~2개 항목에서 합리적 가정 필요. 추가 질문 1~2개.
|
||||
@@ -119,6 +129,16 @@ function _buildUserMessage(input: IntentAnalysisInput): string {
|
||||
lines.push(input.priorChatSummary);
|
||||
lines.push('---');
|
||||
}
|
||||
// 현재 워크스페이스 아키텍처 요약 — "이 프로젝트가 뭐냐"류의 재질문 차단.
|
||||
if (input.projectContext && input.projectContext.trim()) {
|
||||
lines.push('');
|
||||
lines.push('[프로젝트 컨텍스트]');
|
||||
lines.push('아래는 사용자가 현재 열어둔 워크스페이스의 아키텍처 요약입니다. 여기서 직접');
|
||||
lines.push('확인되는 사실은 이미 알려진 정보로 취급해 context 슬롯에 반영하고, 다시 묻지 마세요.');
|
||||
lines.push('---');
|
||||
lines.push(input.projectContext);
|
||||
lines.push('---');
|
||||
}
|
||||
if (input.activePipelineName) {
|
||||
lines.push('');
|
||||
lines.push(`(활성 파이프라인) "${input.activePipelineName}"`);
|
||||
|
||||
@@ -140,6 +140,7 @@ export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string {
|
||||
parts.push(' • `<add_task title="..." owner="@me" due="2026-05-24T18:00" notes="..."/>` — 작업 추적기에 task 추가');
|
||||
parts.push(' • `<update_task id="t_001" status="in_progress|blocked|done" notes="..."/>` — 진척·blocker 갱신');
|
||||
parts.push(' • `<complete_task id="t_001"/>` — task 완료 처리 (done 섹션으로 이동)');
|
||||
parts.push(' • `<fetch_url url="https://..."/>` — 웹 페이지 본문 가져오기 (회당 최대 2개). "사이트 방문 불가"라고 답하지 말고 이 태그를 사용할 것. 결과는 [URL CONTENT] 블록으로 주입됨.');
|
||||
parts.push('');
|
||||
parts.push('📋 **Task 사용 시점**:');
|
||||
parts.push('- 회의록·요청 처리 중 *명확한 할일* 마다 add_task 1개씩 emit. 추측·확장 금지.');
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Web Fetch — Bridge 무관 직접 URL fetch (vscode 의존 없음 — 테스트 용이).
|
||||
*
|
||||
* 배경: 일반 챗의 URL 주입(urlContext.ts)은 Datacollect Bridge(:3002)에 100%
|
||||
* 의존했다. Bridge가 꺼져 있으면 — 확장은 Bridge를 자동 시작하지 않는다 —
|
||||
* "접근 실패" 블록이 떠서 모델이 "사이트 방문 불가"라고 답하는 공백이 있었다.
|
||||
* 이 모듈은 그 폴백: extension host의 global fetch(Node 18+, bridgeClient가
|
||||
* 이미 사용 중)로 직접 페이지를 가져와 본문 텍스트를 추출한다.
|
||||
*
|
||||
* 설계 원칙: 절대 throw 하지 않는다 — 모든 실패는 {ok:false, error} 로 반환.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 스킴 없는 도메인 인식용 보수적 TLD 목록 — 사용자는 "koritips.com 가서 분석해줘"
|
||||
* 처럼 https:// 를 생략하기 마련이라, 흔한 TLD 만 허용해 파일명(utils.ts,
|
||||
* package.json 등) 오인을 차단한다.
|
||||
*/
|
||||
const BARE_DOMAIN_TLDS = 'com|net|org|io|co|kr|jp|dev|app|ai|me|info|blog|shop|site|xyz|cc|tv|us|uk|edu|gov';
|
||||
|
||||
/**
|
||||
* http(s) URL 추출 — dedupe + trailing 구두점 제거. 슬래시 명령은 자체 처리하므로
|
||||
* 제외. https:// 가 없는 bare 도메인(koritips.com, www.foo.net 등)도 인식해
|
||||
* https:// 를 붙여 반환한다.
|
||||
*/
|
||||
export function extractUrls(text: string, max = 2): string[] {
|
||||
const t = (text || '').trim();
|
||||
// 슬래시 *명령* (/wikify 등)만 제외 — 절대경로("/Volumes/... koritips.com 봐줘")로
|
||||
// 시작하는 프롬프트는 URL 추출 대상이다 (startsWith('/') 는 경로를 오인했던 버그).
|
||||
if (!t || /^\/[a-zA-Z][\w-]*(\s|$)/.test(t)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
|
||||
// ① 스킴 있는 URL 우선.
|
||||
const re = /https?:\/\/[^\s<>"'`)\]]+/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(t)) !== null && out.length < max) {
|
||||
// 문장 끝 구두점이 URL 에 붙는 흔한 오염 제거.
|
||||
const url = m[0].replace(/[.,;:!?…」』)]+$/, '');
|
||||
if (!seen.has(url)) {
|
||||
seen.add(url);
|
||||
out.push(url);
|
||||
}
|
||||
}
|
||||
if (out.length >= max) return out;
|
||||
|
||||
// ② Bare 도메인 — 이미 찾은 URL 영역은 마스킹해 이중 매칭 방지.
|
||||
// 직전 문자가 @(이메일)·/(경로 일부)·.(서브파트) 면 제외.
|
||||
let masked = t;
|
||||
for (const u of out) masked = masked.split(u).join(' '.repeat(Math.min(u.length, 8)));
|
||||
const bareRe = new RegExp(
|
||||
`(^|[\\s("'\`「『<>])((?:[a-z0-9-]+\\.)+(?:${BARE_DOMAIN_TLDS})(?:\\.[a-z]{2})?(?::\\d{2,5})?(?:/[^\\s<>"'\`)\\]]*)?)`,
|
||||
'gi',
|
||||
);
|
||||
while ((m = bareRe.exec(masked)) !== null && out.length < max) {
|
||||
const candidate = m[2].replace(/[.,;:!?…」』)]+$/, '');
|
||||
if (!candidate.includes('.')) continue;
|
||||
const url = `https://${candidate}`;
|
||||
if (!seen.has(url) && !seen.has(`http://${candidate}`)) {
|
||||
seen.add(url);
|
||||
out.push(url);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface WebFetchResult {
|
||||
ok: boolean;
|
||||
url: string;
|
||||
title: string;
|
||||
text: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_MAX_CHARS = 20_000;
|
||||
|
||||
/**
|
||||
* URL 본문을 직접 fetch 해 텍스트로 변환. HTML 이면 태그를 걷어내고,
|
||||
* 그 외(text/json 등)는 원문 그대로 cap. http/https 만 허용.
|
||||
*/
|
||||
export async function fetchUrlDirect(
|
||||
url: string,
|
||||
opts: { timeoutMs?: number; maxChars?: number } = {},
|
||||
): Promise<WebFetchResult> {
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
|
||||
const fail = (error: string): WebFetchResult => ({ ok: false, url, title: '', text: '', error });
|
||||
|
||||
if (!/^https?:\/\//i.test(url)) return fail('http/https URL만 지원합니다.');
|
||||
if (typeof fetch !== 'function') return fail('이 환경은 직접 fetch를 지원하지 않습니다.');
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
headers: {
|
||||
// 일부 사이트가 UA 없는 요청을 차단 — 평범한 브라우저 UA 로.
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/json;q=0.9,text/plain;q=0.8,*/*;q=0.5',
|
||||
},
|
||||
});
|
||||
if (!res.ok) return fail(`HTTP ${res.status} ${res.statusText || ''}`.trim());
|
||||
const contentType = (res.headers.get('content-type') || '').toLowerCase();
|
||||
const raw = await res.text();
|
||||
if (!raw.trim()) return fail('응답 본문이 비어 있습니다.');
|
||||
|
||||
if (contentType.includes('html') || /^\s*<(!doctype|html)/i.test(raw)) {
|
||||
const title = _extractTitle(raw);
|
||||
const text = htmlToText(raw).slice(0, maxChars);
|
||||
if (text.trim().length < 50) {
|
||||
return fail('본문 추출 실패 (콘텐츠 없음 또는 JS 전용 렌더링).');
|
||||
}
|
||||
return { ok: true, url, title, text };
|
||||
}
|
||||
return { ok: true, url, title: '', text: raw.slice(0, maxChars) };
|
||||
} catch (e: any) {
|
||||
const msg = e?.name === 'AbortError'
|
||||
? `타임아웃 (${Math.round(timeoutMs / 1000)}s)`
|
||||
: String(e?.message ?? e).slice(0, 120);
|
||||
return fail(msg);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function _extractTitle(html: string): string {
|
||||
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
return m ? decodeEntities(m[1]).replace(/\s+/g, ' ').trim().slice(0, 200) : '';
|
||||
}
|
||||
|
||||
/** HTML → 평문. script/style/noscript 제거 → 블록 태그를 줄바꿈으로 → 태그 strip → 엔티티 → 공백 정리. */
|
||||
export function htmlToText(html: string): string {
|
||||
let s = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
|
||||
.replace(/<!--[\s\S]*?-->/g, ' ');
|
||||
// 블록 요소 경계를 줄바꿈으로 보존 — 문단 구조가 텍스트에도 남게.
|
||||
s = s.replace(/<\/(p|div|section|article|li|tr|h[1-6]|blockquote|pre)>/gi, '\n')
|
||||
.replace(/<(br|hr)\s*\/?>/gi, '\n');
|
||||
s = s.replace(/<[^>]+>/g, ' ');
|
||||
s = decodeEntities(s);
|
||||
// 공백 정리: 줄 내 다중 공백 → 1개, 3개 이상 연속 줄바꿈 → 2개.
|
||||
return s
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/\s+/g, ' ').trim())
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** 최소 엔티티 디코드 — 본문 가독에 필요한 흔한 것만. */
|
||||
export function decodeEntities(s: string): string {
|
||||
return s
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/�?39;|'/gi, "'")
|
||||
.replace(/&#(\d+);/g, (_, code) => {
|
||||
const n = Number(code);
|
||||
return n > 0 && n < 0x110000 ? String.fromCodePoint(n) : '';
|
||||
});
|
||||
}
|
||||
@@ -387,8 +387,6 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
||||
excerpt: (c.content || '').slice(0, 200),
|
||||
}));
|
||||
|
||||
// Lessons 블록은 일반 RAG context 보다 앞 — context-overflow truncation 에서 먼저
|
||||
// 살아남게.
|
||||
const lessonBlock = buildLessonChecklistBlock(lessonChunks.map((c) => ({ title: c.title, content: c.content })));
|
||||
const memoryBlock = deps.retrievalOrchestrator.buildContextString(result);
|
||||
// [확신도 전역화] 검색 근거 강도를 평가해 답변 정책을 함께 주입 — /meet 의
|
||||
@@ -409,7 +407,14 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
||||
// [Correction Loop ③-c] 약점 프로필 → 자기검토 블록. 최근 정정 통계가 다음 턴의
|
||||
// 행동을 직접 바꾼다 (태그 2회 이상만 — 1회성 실수로 프롬프트를 어지럽히지 않게).
|
||||
const selfReviewBlock = buildSelfReviewBlock(loadWeaknessProfile(deps.activeBrain.localBrainPath));
|
||||
return [selfReviewBlock, groundingBlock, lessonBlock, memoryBlock].filter(Boolean).join('\n\n');
|
||||
|
||||
// 행동 제약 블록(자기검토·확신도 정책·레슨 체크리스트)은 dynamicBlocks 채널로 —
|
||||
// [CONTEXT] *밖* 보호 구역에 주입되어 context-overflow truncation 에서도 살아남는다.
|
||||
// 옛 구현은 이 블록들을 RAG 본문과 같은 문자열에 합쳐 [CONTEXT] 안에 넣었는데,
|
||||
// "실패 방지 제약"이 토큰 압박에서 배경 지식과 함께 잘려나가는 모순이 있었다.
|
||||
const constraintBlock = [selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
|
||||
if (constraintBlock) blocks.set('behavior-constraints', constraintBlock);
|
||||
return memoryBlock;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -83,3 +83,25 @@ export function isCasualConversationPrompt(prompt: string): boolean {
|
||||
if (/^(?:ha){2,}h?$|^(?:he){2,}h?$/.test(normalized)) return true; // haha, hahaha, hehe
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 슬래시 명령(/wikify, /benchmark 등)인지 — *명령만* 골라낸다. 절대경로
|
||||
* ("/Volumes/Data/... 분석해줘")는 명령이 아니다. 옛 `startsWith('/')` 가드가
|
||||
* 경로로 시작하는 프롬프트를 전부 명령으로 오인해 분석 지시·URL 주입이 통째로
|
||||
* 죽는 버그가 있었다 (이 사용자 패턴의 대표형이 "경로 + 분석해줘").
|
||||
* 패턴: 슬래시 + 명령어 + (공백|끝). 경로는 두 번째 '/'가 바로 이어져 미스매치.
|
||||
*/
|
||||
export function isSlashCommand(text: string): boolean {
|
||||
return /^\/[a-zA-Z][\w-]*(\s|$)/.test((text || '').trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석/검토/의견형 요청 감지 — 이런 요청에서 모델이 코드를 읽지 않고 "~로
|
||||
* 보입니다" 추측으로 답하는 실패 모드를 막기 위한 grounding 지시 주입 트리거.
|
||||
* 보수적 휴리스틱: 분석 동사 포함 + 슬래시 명령 아님 + 최소 길이.
|
||||
*/
|
||||
export function isAnalysisRequest(prompt: string): boolean {
|
||||
const p = (prompt || '').trim();
|
||||
if (p.length < 8 || isSlashCommand(p)) return false;
|
||||
return /(분석|검토|리뷰|평가|점검|진단|의견|개선점|개선 ?방향|review|analyze|audit|assess)/i.test(p);
|
||||
}
|
||||
|
||||
@@ -15,21 +15,38 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { INVENTORY_FILE } from '../../extension/featureInventory';
|
||||
|
||||
const IMPROVE_RE = /(개선|고도화|발전|보완|제안|평가|분석|방향|방법|아이디어|로드맵|업그레이드|날카롭|강화)/i;
|
||||
// "검토|리뷰|점검|진단|어때" 추가 — "아래 내용 검토해줘" 같은 실사용 재검토
|
||||
// 프롬프트가 매치 안 되던 갭 (회귀 테스트로 발견).
|
||||
// "학습|배우|기억|작동|어떻게" 추가 — "너는 어떻게 학습해?" 류 자기 작동 방식
|
||||
// 질문이 인벤토리(학습 메커니즘 정본 포함) 없이 답변되어 허구 설명이 나오던 갭.
|
||||
const IMPROVE_RE = /(개선|고도화|발전|보완|제안|평가|분석|검토|리뷰|점검|진단|어때|방향|방법|아이디어|로드맵|업그레이드|날카롭|강화|학습|배우|기억|작동|어떻게)/i;
|
||||
const SELF_RE = /(기능|역량|능력|아키텍처|구조|시스템|self.?evolv|자기\s*진화|자기\s*개선|아스트라|astra|connectai|프로젝트|업무\s*능력|너(가|의|는)?|네가)/i;
|
||||
const CAPABILITY_RE = /(무슨|어떤|할\s*수\s*있는)\s*(기능|일|것)|기능\s*(목록|리스트)|capabilit/i;
|
||||
|
||||
/**
|
||||
* 자기 평가·개선·기능 질의인지. 오탐 비용이 낮으므로(인벤토리 ~3KB 추가 주입뿐)
|
||||
* 누락(또 구식 제안)보다 과잉 감지를 택한다. 길이 상한 1500 — 사용자의 개선 요청은
|
||||
* 배경 설명이 붙어 길어지는 경우가 많다 (600 으로는 실사용 질문을 놓침).
|
||||
* 누락(또 구식 제안)보다 과잉 감지를 택한다. 길이 상한 없음 — 사용자가 이전 답변
|
||||
* 전문(5천자+)을 붙여넣고 "이거 어때?"라고 묻는 재검토 패턴이 흔한데, 1500/4000
|
||||
* 상한이 그런 실사용 프롬프트를 탈락시켜 인벤토리 미주입 → 기존 기능 재제안
|
||||
* 버그가 반복 재발했다. 정규식 검사는 큰 문자열에도 저비용.
|
||||
*/
|
||||
export function isSelfAssessRequest(prompt: string): boolean {
|
||||
const p = (prompt || '').trim();
|
||||
if (!p || p.length > 1500) return false;
|
||||
if (!p) return false;
|
||||
return CAPABILITY_RE.test(p) || (IMPROVE_RE.test(p) && SELF_RE.test(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석/검토 대상이 ASTRA/ConnectAI *자신*인지 — isSelfAssessRequest 보다 넓은
|
||||
* 보조 판정 (개선 키워드 없이 "검토해줘/어때?" 만으로 자기 분석을 요청하는 경우).
|
||||
* 분석 경로(isAnalysisRequest)와 결합해 인벤토리 주입 트리거를 보강한다.
|
||||
*/
|
||||
export function isAboutSelf(prompt: string): boolean {
|
||||
const p = (prompt || '').trim();
|
||||
if (!p) return false;
|
||||
return /(아스트라|astra|connectai|자기\s*진화|자기\s*개선|self.?evolv|기능\s*인벤토리)/i.test(p);
|
||||
}
|
||||
|
||||
const MAX_INVENTORY_CHARS = 7000;
|
||||
|
||||
/** 인벤토리 전문 + 대조 지시 블록. 파일 없으면 정직한 안내 (지어내기 방지). */
|
||||
@@ -54,6 +71,11 @@ export function buildSelfAssessContext(brainPath: string): string {
|
||||
'- 제안은 "현재 X가 있고, 빠진 증분은 Y" 형태로.',
|
||||
'- 아래에 없는 기능을 있다고 주장하지도 마라.',
|
||||
'- 직전 대화에서 본인이 했던 제안 목록을 그대로 반복하지 마라 — 이 인벤토리가 그 제안들보다 우선하는 최신 사실이다.',
|
||||
'⚠️ [의무 형식 — 위반 시 오답 처리] 개선 제안을 하나라도 포함하는 답변은, **각 제안의 첫 줄에** 아래 대조 태그를 반드시 붙여라:',
|
||||
' `[인벤토리 대조: 신규]` — 아래 문서 어디에도 없는 기능',
|
||||
' `[인벤토리 대조: 기구현 — <항목명>]` — 이미 있음 (이 경우 "도입" 대신 그 기능의 *증분 확장*만 제안 가능)',
|
||||
' `[인벤토리 대조: 부분 구현 — <항목명>]` — 일부 겹침',
|
||||
' 태그를 붙이려면 아래 문서를 실제로 읽어야 한다. 태그 없는 제안은 검증 안 된 추측으로 간주된다.',
|
||||
'- 답변 끝에 "출처: ASTRA 기능 인벤토리 v<버전>" 을 표기하라 (이 블록을 실제로 읽었다는 증거).',
|
||||
'',
|
||||
body,
|
||||
|
||||
@@ -4,16 +4,30 @@
|
||||
* 문제: /wikify 는 URL 에 접근하지만(브리지 /api/web-extract), 일반 채팅에 URL 을
|
||||
* 주면 추출 경로가 없어 모델이 "접근할 수 없습니다"라고 답하거나 내용을 추측했다.
|
||||
*
|
||||
* 수정: 강제 주입 패턴의 4번째 적용 (일정→캘린더, 자기평가→인벤토리, 정정→캡처와
|
||||
* 동일 설계). 이미 검증된 브리지 추출 인프라를 재사용 — 새 크롤러 없음.
|
||||
* 실패 시(브리지 다운/추출 실패) 정직한 안내 블록 — 모델이 내용을 지어내지 않게.
|
||||
* v2: Bridge 100% 의존이 두 번째 공백이었다 — Bridge 가 꺼져 있으면(확장은 자동
|
||||
* 시작하지 않음) 접근 실패 블록이 떠서 결국 "방문 불가"가 됐다. 이제 ① Bridge
|
||||
* 추출(품질 우선) → ② 직접 fetch 폴백(webFetch.fetchUrlDirect) → ③ 정직 실패
|
||||
* 블록의 3단계. 성공 결과는 5분 TTL 캐시 — chat/alignment/기업모드가 같은 URL 을
|
||||
* 연달아 요청해도 네트워크 1회.
|
||||
*/
|
||||
import { logInfo } from '../../utils';
|
||||
import { bridgeFetch, BRIDGE_API } from '../../features/datacollect/bridgeClient';
|
||||
import { fetchUrlDirect } from '../../features/web/webFetch';
|
||||
|
||||
const URL_RE = /https?:\/\/[^\s<>"'`)\]]+/i;
|
||||
const MAX_BODY_CHARS = 8000;
|
||||
const EXTRACT_TIMEOUT_MS = 45_000;
|
||||
// Bridge 추출 대기 — 직접 fetch 폴백이 있으므로 45s → 15s 로 단축 (총 대기 최대 ~30s).
|
||||
const EXTRACT_TIMEOUT_MS = 15_000;
|
||||
|
||||
// 성공 블록만 캐시 (실패는 캐시하지 않음 — Bridge 재기동/일시 오류 후 재시도 가능해야).
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const CACHE_MAX = 10;
|
||||
const _cache = new Map<string, { block: string; expiresAt: number }>();
|
||||
|
||||
/** 테스트/세션 전환용 캐시 초기화. */
|
||||
export function clearUrlContextCache(): void {
|
||||
_cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 프롬프트에서 추출 대상 URL 을 찾는다. 슬래시 명령(/wikify 등)은 자체 처리하므로
|
||||
@@ -21,7 +35,8 @@ const EXTRACT_TIMEOUT_MS = 45_000;
|
||||
*/
|
||||
export function extractUrlFromPrompt(prompt: string): string | null {
|
||||
const p = (prompt || '').trim();
|
||||
if (!p || p.startsWith('/')) return null;
|
||||
// 슬래시 *명령* 만 제외 — 절대경로 시작 프롬프트는 추출 대상 (경로 오인 버그 수정).
|
||||
if (!p || /^\/[a-zA-Z][\w-]*(\s|$)/.test(p)) return null;
|
||||
const m = URL_RE.exec(p);
|
||||
if (!m) return null;
|
||||
// 문장 끝 구두점이 URL 에 붙는 흔한 오염 제거.
|
||||
@@ -30,6 +45,12 @@ export function extractUrlFromPrompt(prompt: string): string | null {
|
||||
|
||||
/** URL 본문을 추출해 컨텍스트 블록 생성. 실패해도 throw 하지 않는다 (정직 블록 반환). */
|
||||
export async function buildUrlContext(url: string): Promise<string> {
|
||||
const now = Date.now();
|
||||
const cached = _cache.get(url);
|
||||
if (cached && cached.expiresAt > now) return cached.block;
|
||||
|
||||
// ① Bridge 추출 (readability 급 품질 — 우선).
|
||||
let bridgeError = '';
|
||||
try {
|
||||
const data = await bridgeFetch<{ success: boolean; title?: string; description?: string; text?: string; textLength?: number; truncated?: boolean }>(
|
||||
BRIDGE_API.web.extract,
|
||||
@@ -37,28 +58,51 @@ export async function buildUrlContext(url: string): Promise<string> {
|
||||
{ timeoutMs: EXTRACT_TIMEOUT_MS },
|
||||
);
|
||||
const body = String(data?.text || '').slice(0, MAX_BODY_CHARS);
|
||||
if (!body.trim() || body.trim().length < 50) {
|
||||
return [
|
||||
`[URL CONTENT — ${url}]`,
|
||||
'상태: 본문 추출 실패 (콘텐츠 없음 또는 JS 전용 렌더링).',
|
||||
'→ 사용자에게 이 URL 의 본문을 가져오지 못했다고 정직하게 알리고, 내용을 추측해 답하지 마라.',
|
||||
].join('\n');
|
||||
if (body.trim() && body.trim().length >= 50) {
|
||||
logInfo('URL 컨텍스트 주입 (bridge).', { url, chars: body.length, truncated: !!data?.truncated });
|
||||
const block = _successBlock(url, data?.title || '', data?.description || '', body, body.length >= MAX_BODY_CHARS || !!data?.truncated);
|
||||
_cachePut(url, block);
|
||||
return block;
|
||||
}
|
||||
logInfo('URL 컨텍스트 주입.', { url, chars: body.length, truncated: !!data?.truncated });
|
||||
return [
|
||||
`[URL CONTENT — 실데이터 · ${url}]`,
|
||||
`제목: ${data?.title || '(없음)'}`,
|
||||
data?.description ? `설명: ${data.description}` : '',
|
||||
'아래 본문만 근거로 답하라. 본문에 없는 내용은 "본문에서 확인되지 않음"이라고 답하고 지어내지 마라.' +
|
||||
(body.length >= MAX_BODY_CHARS || data?.truncated ? ' (본문 일부 잘림 — 전체가 필요하면 /wikify 사용을 안내하라.)' : ''),
|
||||
'',
|
||||
body,
|
||||
].filter(Boolean).join('\n');
|
||||
bridgeError = '본문 추출 실패 (콘텐츠 없음 또는 JS 전용 렌더링)';
|
||||
} catch (e: any) {
|
||||
return [
|
||||
`[URL CONTENT — ${url}]`,
|
||||
`상태: 접근 실패 — ${String(e?.message ?? e).slice(0, 120)}`,
|
||||
'→ Datacollect 브리지(:3002)가 실행 중인지 확인하라고 사용자에게 안내하고, URL 내용을 추측해 답하지 마라.',
|
||||
].join('\n');
|
||||
bridgeError = String(e?.message ?? e).slice(0, 120);
|
||||
}
|
||||
|
||||
// ② 직접 fetch 폴백 — Bridge 가 꺼져 있거나 추출 실패해도 웹 접근은 살아 있어야.
|
||||
const direct = await fetchUrlDirect(url, { maxChars: MAX_BODY_CHARS });
|
||||
if (direct.ok) {
|
||||
logInfo('URL 컨텍스트 주입 (direct fetch 폴백).', { url, chars: direct.text.length, bridgeError });
|
||||
const block = _successBlock(url, direct.title, '', direct.text, direct.text.length >= MAX_BODY_CHARS);
|
||||
_cachePut(url, block);
|
||||
return block;
|
||||
}
|
||||
|
||||
// ③ 둘 다 실패 — 모델이 내용을 지어내지 않게 정직 블록.
|
||||
return [
|
||||
`[URL CONTENT — ${url}]`,
|
||||
`상태: 접근 실패 — bridge: ${bridgeError || '(미시도)'} / direct: ${direct.error || '(불명)'}`,
|
||||
'→ 사용자에게 이 URL 의 본문을 가져오지 못했다고 정직하게 알리고, 내용을 추측해 답하지 마라.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function _successBlock(url: string, title: string, description: string, body: string, truncated: boolean): string {
|
||||
return [
|
||||
`[URL CONTENT — 실데이터 · ${url}]`,
|
||||
`제목: ${title || '(없음)'}`,
|
||||
description ? `설명: ${description}` : '',
|
||||
'아래 본문만 근거로 답하라. 본문에 없는 내용은 "본문에서 확인되지 않음"이라고 답하고 지어내지 마라.' +
|
||||
(truncated ? ' (본문 일부 잘림 — 전체가 필요하면 /wikify 사용을 안내하라.)' : ''),
|
||||
'',
|
||||
body,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function _cachePut(url: string, block: string): void {
|
||||
if (_cache.size >= CACHE_MAX) {
|
||||
// 가장 오래된 entry 제거 (Map 삽입 순서).
|
||||
const oldest = _cache.keys().next().value;
|
||||
if (oldest !== undefined) _cache.delete(oldest);
|
||||
}
|
||||
_cache.set(url, { block, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ export interface DispatcherDepsInputs {
|
||||
pipelineIdOverride?: string;
|
||||
/** Intent Alignment 가 도출한 사용자 합의 contract. 없으면 legacy 동작. */
|
||||
requirementContract?: RequirementContract;
|
||||
/** 현재 워크스페이스 아키텍처 컨텍스트 (호출자가 절단해 전달). */
|
||||
architectureContextBlock?: string;
|
||||
/** 사용자 프롬프트 URL pre-fetch 결과 ([URL CONTENT] 블록). */
|
||||
webContextBlock?: string;
|
||||
}
|
||||
|
||||
export function buildDispatcherDeps(inputs: DispatcherDepsInputs): DispatcherDeps {
|
||||
@@ -64,5 +68,7 @@ export function buildDispatcherDeps(inputs: DispatcherDepsInputs): DispatcherDep
|
||||
}),
|
||||
pipelineIdOverride: inputs.pipelineIdOverride,
|
||||
requirementContract: inputs.requirementContract,
|
||||
architectureContextBlock: inputs.architectureContextBlock,
|
||||
webContextBlock: inputs.webContextBlock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,6 +82,8 @@ export interface ReadyStatusDeps {
|
||||
effectiveContextLength: number;
|
||||
cappedForSmallModel: boolean;
|
||||
lmStudioError: string | null;
|
||||
/** HealthCheckMonitor 의 마지막 환경 경고 (Bridge·볼륨·자격증명·버전 등). 없으면 []. */
|
||||
healthWarnings: string[];
|
||||
}
|
||||
|
||||
export function buildReadyStatusPayload(d: ReadyStatusDeps) {
|
||||
@@ -102,6 +104,7 @@ export function buildReadyStatusPayload(d: ReadyStatusDeps) {
|
||||
nominalContextLength: d.contextLength,
|
||||
cappedForSmallModel: d.cappedForSmallModel,
|
||||
lmStudioError: d.lmStudioError,
|
||||
healthWarnings: d.healthWarnings,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+252
-8
@@ -8,6 +8,7 @@ import {
|
||||
buildApiUrl,
|
||||
getActiveBrainProfile,
|
||||
getBrainProfiles,
|
||||
invalidateBrainFilesCache,
|
||||
logError,
|
||||
logInfo,
|
||||
resolveEngine,
|
||||
@@ -108,6 +109,8 @@ registerSidebarHandler(handleChronicleMessage);
|
||||
registerSidebarHandler(handleAgentMessage);
|
||||
import { resolveScopeForAgent } from './skills/agentKnowledgeMap';
|
||||
import { clearBrainTokenIndex } from './retrieval/brainIndex';
|
||||
import { extractUrls } from './features/web/webFetch';
|
||||
import { buildUrlContext } from './lib/contextBuilders/urlContext';
|
||||
import { estimateModelParamsB } from './lib/contextManager';
|
||||
import {
|
||||
buildOrRefreshArchitectureDoc,
|
||||
@@ -123,8 +126,13 @@ import {
|
||||
listResumableSessions,
|
||||
analyzeIntent,
|
||||
resolveActivePipeline,
|
||||
gatherEvidenceForQuestions,
|
||||
selfAnswerQuestions,
|
||||
saveAlignmentKnowledge,
|
||||
SELF_RESEARCH_PREFIX,
|
||||
} from './features/company';
|
||||
import { AIService } from './core/services';
|
||||
import { HealthCheckMonitor } from './core/health';
|
||||
import { presentOfficeSnapshot } from './features/astraOffice';
|
||||
|
||||
export interface SidebarLmStudioDeps {
|
||||
@@ -1104,6 +1112,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
effectiveContextLength,
|
||||
cappedForSmallModel,
|
||||
lmStudioError: this._lmStudioLastError ?? null,
|
||||
healthWarnings: HealthCheckMonitor.lastReports,
|
||||
});
|
||||
this._view.webview.postMessage(payload);
|
||||
} catch (err: any) {
|
||||
@@ -1432,6 +1441,16 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
this._view?.webview.postMessage({ type: 'clearChat' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 두뇌 동기화 — 두 모드:
|
||||
* 1. 원격 미설정 (profile.secondBrainRepo 비어 있음, 기본): 검색 인덱스/캐시
|
||||
* 새로고침만. 사용자의 멘탈 모델("동기화 = 내 로컬 두뇌 폴더 인식")과 일치.
|
||||
* git 을 건드리지 않으므로 인증도 불필요.
|
||||
* 2. 원격 설정됨: pull(rebase) → commit/push → 인덱스 갱신 (백업/공유 모드).
|
||||
*
|
||||
* 배경: 옛 구현은 원격 설정 여부와 무관하게 git push 를 강행해서, 두뇌 폴더가
|
||||
* 우연히 다른 git 레포 안에 있으면 그 레포의 원격 인증을 요구하는 혼란이 있었다.
|
||||
*/
|
||||
public async syncBrain() {
|
||||
const activeBrain = getActiveBrainProfile();
|
||||
const brainDir = activeBrain.localBrainPath;
|
||||
@@ -1439,20 +1458,122 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
vscode.window.showErrorMessage("Second Brain directory not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 모드 1: 원격 미설정 → 로컬 새로고침만 (git 없음, 인증 불필요) ──
|
||||
if (!activeBrain.secondBrainRepo || !activeBrain.secondBrainRepo.trim()) {
|
||||
try {
|
||||
invalidateBrainFilesCache();
|
||||
clearBrainTokenIndex(brainDir);
|
||||
const count = findBrainFiles(brainDir).length;
|
||||
vscode.window.showInformationMessage(
|
||||
`두뇌 새로고침 완료 — 문서 ${count}개 인식됨. ` +
|
||||
`(이 두뇌는 원격 저장소가 설정되어 있지 않아 git 동기화는 건너뜁니다. ` +
|
||||
`새 문서는 평소에도 자동 인식되므로 이 버튼은 강제 새로고침 용도입니다.)`,
|
||||
);
|
||||
} catch (e: any) {
|
||||
vscode.window.showErrorMessage(`두뇌 새로고침 실패: ${String(e?.message ?? e).slice(0, 150)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
vscode.window.withProgress({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: "Astra: Syncing Second Brain...",
|
||||
cancellable: false
|
||||
}, async () => {
|
||||
const { execSync } = require('child_process');
|
||||
const run = (cmd: string): string =>
|
||||
execSync(cmd, { cwd: brainDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) as string;
|
||||
|
||||
// ── ⓪ upstream 확인 — 없으면 pull/push 없이 로컬 커밋만 (정직 보고) ──
|
||||
// 사례: 두뇌 폴더에 원격 없는 중첩 .git 이 생겨 있던 환경에서
|
||||
// "There is no tracking information" 으로 동기화 전체가 죽었다.
|
||||
// 원격 추적이 없는 두뇌는 로컬 버전 관리만 수행하고 그 사실을 알린다.
|
||||
let hasUpstream = true;
|
||||
try {
|
||||
const { execSync } = require('child_process');
|
||||
execSync(`git add .`, { cwd: brainDir });
|
||||
execSync(`git commit -m "[G1-Sync] Manual knowledge update"`, { cwd: brainDir });
|
||||
execSync(`git push`, { cwd: brainDir });
|
||||
vscode.window.showInformationMessage("Second Brain synced successfully.");
|
||||
} catch (err: any) {
|
||||
vscode.window.showInformationMessage("Brain Synced: Local knowledge is up-to-date (no changes to push).");
|
||||
run('git rev-parse --abbrev-ref --symbolic-full-name "@{u}"');
|
||||
} catch {
|
||||
hasUpstream = false;
|
||||
}
|
||||
|
||||
// ── ① 원격 변경 수신 (pull --rebase --autostash) ──
|
||||
let pulledFiles = 0;
|
||||
if (hasUpstream) {
|
||||
try {
|
||||
run('git fetch');
|
||||
// upstream 대비 새로 들어올 파일 수 — 사용자에게 "몇 개 받았는지" 보여주기 위해.
|
||||
try {
|
||||
const incoming = run('git diff --name-only HEAD "@{u}"').trim();
|
||||
pulledFiles = incoming ? incoming.split('\n').filter(Boolean).length : 0;
|
||||
} catch { /* 카운트만 생략 */ }
|
||||
run('git pull --rebase --autostash');
|
||||
} catch (err: any) {
|
||||
const detail = String(err?.stderr || err?.message || err).slice(0, 200);
|
||||
logError('Brain sync: pull failed.', { brainDir, detail });
|
||||
vscode.window.showErrorMessage(`두뇌 동기화 실패 (원격 수신 중): ${detail}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── ② 로컬 변경 송신 (add → commit → push) ──
|
||||
// 변경 판정은 *staged* 기준 (`git diff --cached --quiet`) — 두뇌 폴더가
|
||||
// 더 큰 레포의 하위 폴더일 때 `git status --porcelain` 은 폴더 *밖* 의
|
||||
// 변경까지 보고해서, staged 가 비어 있는데 commit 을 시도하다
|
||||
// "no changes added" 로 실패하는 버그가 있었다.
|
||||
let pushed = false;
|
||||
let committedLocally = false;
|
||||
try {
|
||||
run('git add .');
|
||||
let hasStaged = false;
|
||||
try {
|
||||
run('git diff --cached --quiet'); // exit 0 = staged 없음
|
||||
} catch {
|
||||
hasStaged = true; // exit 1 = staged 변경 존재
|
||||
}
|
||||
if (hasStaged) {
|
||||
run('git commit -m "[G1-Sync] Manual knowledge update"');
|
||||
committedLocally = true;
|
||||
if (hasUpstream) {
|
||||
run('git push');
|
||||
pushed = true;
|
||||
}
|
||||
} else if (hasUpstream) {
|
||||
// 커밋할 변경 없음 — ahead 상태(이전 커밋 미push)면 push 만.
|
||||
const ahead = run('git rev-list --count "@{u}"..HEAD').trim();
|
||||
if (ahead && ahead !== '0') {
|
||||
run('git push');
|
||||
pushed = true;
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
const detail = String(err?.stderr || err?.message || err).slice(0, 200);
|
||||
logError('Brain sync: push failed.', { brainDir, detail });
|
||||
// 인증 부재는 사용자가 직접 해결해야 하는 환경 문제 — git 원문 대신
|
||||
// 행동 가능한 안내. (확장은 비대화형이라 아이디/토큰을 물어볼 수 없다.)
|
||||
if (/could not read Username|Authentication failed|terminal prompts disabled|403/i.test(detail)) {
|
||||
vscode.window.showErrorMessage(
|
||||
`두뇌 동기화 실패: git 인증 정보가 저장되어 있지 않습니다. ` +
|
||||
`터미널에서 "cd ${brainDir} && git push" 를 1회 실행해 아이디/토큰을 입력하면 ` +
|
||||
`키체인에 저장되어 이후 버튼이 정상 작동합니다. (로컬 커밋은 완료된 상태)`,
|
||||
);
|
||||
} else {
|
||||
vscode.window.showErrorMessage(`두뇌 동기화 실패 (송신 중): ${detail}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── ③ 검색 인덱스/캐시 무효화 — 새 문서가 즉시 검색에 잡히게 ──
|
||||
try {
|
||||
invalidateBrainFilesCache();
|
||||
clearBrainTokenIndex(brainDir);
|
||||
} catch { /* 캐시 무효화 실패는 치명적이지 않음 — TTL/mtime 이 결국 따라잡음 */ }
|
||||
|
||||
const parts: string[] = [];
|
||||
if (pulledFiles > 0) parts.push(`원격 문서 ${pulledFiles}개 수신`);
|
||||
if (pushed) parts.push('로컬 변경 push 완료');
|
||||
if (committedLocally && !pushed) parts.push('로컬 커밋 완료');
|
||||
if (!hasUpstream) parts.push('⚠️ 원격 추적 브랜치 없음 — 로컬 버전 관리만 수행됨');
|
||||
if (parts.length === 0) parts.push('변경 없음 — 이미 최신 상태');
|
||||
vscode.window.showInformationMessage(`두뇌 동기화 완료: ${parts.join(' · ')} (검색 인덱스 갱신됨)`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1928,6 +2049,39 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
} catch { /* history 못 가져와도 alignment 자체는 동작 */ }
|
||||
}
|
||||
|
||||
// 현재 워크스페이스 아키텍처 컨텍스트 — "이 프로젝트가 뭐냐"류의 재질문 차단.
|
||||
// 첫 라운드만 (후속 라운드는 contract 에 이미 흡수). 분석기는 작은 모델이므로
|
||||
// 3,000자로 재절단 (architecture 원본 cap 은 16,000자).
|
||||
let projectContext: string | undefined;
|
||||
if (!opts.previousContract) {
|
||||
try {
|
||||
const arch = this._buildProjectArchitectureContext();
|
||||
if (arch) {
|
||||
projectContext = arch.length > 3000
|
||||
? arch.slice(0, 3000) + '\n…(이하 생략 — 전체는 architecture.md 참조)'
|
||||
: arch;
|
||||
}
|
||||
} catch { /* architecture 없어도 alignment 는 계속 */ }
|
||||
// 사용자 프롬프트에 URL 이 있으면 본문도 컨텍스트에 — "그 사이트가
|
||||
// 뭐냐"는 재질문 차단. 결과는 5분 캐시되어 dispatch 와 중복 비용 없음.
|
||||
try {
|
||||
if (cfg.webAutoFetchEnabled !== false) {
|
||||
const urls = extractUrls(opts.userPrompt, 2);
|
||||
if (urls.length > 0) {
|
||||
const webBlocks: string[] = [];
|
||||
for (const url of urls) {
|
||||
webBlocks.push(await buildUrlContext(url));
|
||||
}
|
||||
const web = webBlocks.join('\n\n');
|
||||
const webCapped = web.length > 2000 ? web.slice(0, 2000) + '\n…(이하 생략)' : web;
|
||||
projectContext = projectContext
|
||||
? `${projectContext}\n\n${webCapped}`
|
||||
: webCapped;
|
||||
}
|
||||
}
|
||||
} catch { /* URL 수집 실패해도 alignment 는 계속 */ }
|
||||
}
|
||||
|
||||
const analysis = await analyzeIntent(
|
||||
new AIService(),
|
||||
{
|
||||
@@ -1937,19 +2091,55 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
activePipelineName: activePipeline?.name,
|
||||
availableRoleCategories: extractActiveRoleCategories(state),
|
||||
priorChatSummary,
|
||||
projectContext,
|
||||
},
|
||||
// 분류기와 같은 작은 모델을 재사용 — 이 단계도 빠르고 가벼워야 함.
|
||||
{ model: cfg.companyIntentClassifierModel || cfg.defaultModel },
|
||||
);
|
||||
|
||||
const contract = analysis.contract;
|
||||
let contract = analysis.contract;
|
||||
const reachedLimit = opts.roundsAsked >= opts.roundsLimit;
|
||||
|
||||
// ── 자가 조사: 사용자에게 묻기 전에 두뇌에서 스스로 답 찾기 ──
|
||||
// 답을 찾은 질문은 answeredQuestions 로 이동(자가 조사 marker), 못 찾은
|
||||
// 것만 카드에 노출. 라운드 카운트는 소비하지 않는다 (사용자 응답이 아니므로).
|
||||
if (cfg.companyAlignmentSelfResearch !== false
|
||||
&& contract.openQuestions.length > 0 && !reachedLimit) {
|
||||
try {
|
||||
const brain = getActiveBrainProfile();
|
||||
const evidence = gatherEvidenceForQuestions(brain.localBrainPath, contract.openQuestions);
|
||||
if (evidence.some((e) => e.excerpts.length > 0)) {
|
||||
const answers = await selfAnswerQuestions(new AIService(), {
|
||||
userPrompt: opts.userPrompt,
|
||||
evidence,
|
||||
model: cfg.companyIntentClassifierModel || cfg.defaultModel,
|
||||
});
|
||||
const solved = answers.filter((a) => a.answered && a.answer.trim());
|
||||
if (solved.length > 0) {
|
||||
const solvedSet = new Set(solved.map((s) => s.question));
|
||||
// 비파괴적 갱신 — analysis.contract 원본은 건드리지 않는다.
|
||||
contract = {
|
||||
...contract,
|
||||
answeredQuestions: [
|
||||
...contract.answeredQuestions,
|
||||
...solved.map((s) => ({ q: s.question, a: SELF_RESEARCH_PREFIX + s.answer })),
|
||||
],
|
||||
openQuestions: contract.openQuestions.filter((q) => !solvedSet.has(q)),
|
||||
};
|
||||
this._pixelOfficeBroadcast({
|
||||
recentLogs: this._pixelOffice.appendLog(`🔎 자가 조사로 질문 ${solved.length}건 해결`),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch { /* 자가 조사 실패 — 원래 질문 그대로 노출 */ }
|
||||
}
|
||||
|
||||
if (shouldAutoProceedAlignment(opts.mode, contract, reachedLimit)) {
|
||||
// contract 한 줄 안내 후 곧장 pipeline. 사용자 friction 최소.
|
||||
this._view?.webview.postMessage(buildAlignmentAutoProceedPayload(contract, reachedLimit));
|
||||
try { this.pixelOfficeOnAlignmentResult('auto-proceed', contract); } catch { /* noop */ }
|
||||
this._alignment.clear();
|
||||
this._saveAlignmentKnowledgeIfAny(opts.userPrompt, contract.answeredQuestions);
|
||||
await this._runCompanyTurn(opts.userPrompt, undefined, opts.pipelineIdOverride, contract);
|
||||
return;
|
||||
}
|
||||
@@ -2014,6 +2204,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
async _proceedWithCurrentAlignment(): Promise<void> {
|
||||
const pending = this._alignment.consume();
|
||||
if (!pending) return;
|
||||
this._saveAlignmentKnowledgeIfAny(pending.userOriginalPrompt, pending.contract.answeredQuestions);
|
||||
await this._runCompanyTurn(
|
||||
pending.userOriginalPrompt,
|
||||
undefined,
|
||||
@@ -2022,6 +2213,26 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alignment 학습 루프 (Phase 3) — 사용자가 직접 답해준 Q/A 를 두뇌에 저장.
|
||||
* 자가 조사 항목과 짧은 답의 필터링은 saveAlignmentKnowledge 내부에서 처리.
|
||||
* fire-and-forget: 저장 실패가 dispatch 를 막으면 안 된다.
|
||||
*/
|
||||
private _saveAlignmentKnowledgeIfAny(userPrompt: string, qaList: Array<{ q: string; a: string }>): void {
|
||||
try {
|
||||
const cfg = getConfig();
|
||||
if (cfg.companyAlignmentKnowledgeSave === false) return;
|
||||
if (!qaList || qaList.length === 0) return;
|
||||
const brain = getActiveBrainProfile();
|
||||
const saved = saveAlignmentKnowledge(brain.localBrainPath, { userPrompt, qaList });
|
||||
if (saved) {
|
||||
this._pixelOfficeBroadcast({
|
||||
recentLogs: this._pixelOffice.appendLog('💾 확인된 정보 두뇌 저장'),
|
||||
});
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자가 카드의 "🛑 취소"를 누르거나 alignment를 그만두려 할 때.
|
||||
* 슬롯 비우고 webview에도 streamEnd push해서 input 풀어줌.
|
||||
@@ -2131,6 +2342,37 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
// `signal.aborted` between phases and short-circuits cleanly.
|
||||
const abort = this._companyTurn.startTurn();
|
||||
try {
|
||||
// ── 자동 수집 컨텍스트 (architecture + URL pre-fetch) ──
|
||||
// 일반 챗은 둘 다 자동 주입받지만 기업 모드 dispatcher 는 빠져 있던 공백.
|
||||
// 실패해도 turn 은 진행 (try/catch).
|
||||
let architectureContextBlock: string | undefined;
|
||||
try {
|
||||
const arch = this._buildProjectArchitectureContext();
|
||||
if (arch) {
|
||||
architectureContextBlock = arch.length > 6000
|
||||
? arch.slice(0, 6000) + '\n…(이하 생략 — 전체는 architecture.md 참조)'
|
||||
: arch;
|
||||
}
|
||||
} catch { /* architecture 없어도 진행 */ }
|
||||
let webContextBlock: string | undefined;
|
||||
try {
|
||||
const cfgTurn = getConfig();
|
||||
if (cfgTurn.webAutoFetchEnabled !== false && !resumeTimestamp) {
|
||||
const urls = extractUrls(userPrompt, 2);
|
||||
if (urls.length > 0) {
|
||||
this._pixelOfficeBroadcast({
|
||||
recentLogs: this._pixelOffice.appendLog(`🌐 URL ${urls.length}건 수집 중`),
|
||||
});
|
||||
const blocks: string[] = [];
|
||||
for (const url of urls) {
|
||||
blocks.push(await buildUrlContext(url)); // 5분 캐시 — alignment 와 중복 비용 없음
|
||||
}
|
||||
const joined = blocks.join('\n\n');
|
||||
webContextBlock = joined.length > 8000 ? joined.slice(0, 8000) + '\n…(이하 생략)' : joined;
|
||||
}
|
||||
}
|
||||
} catch { /* URL 수집 실패해도 진행 */ }
|
||||
|
||||
const deps = buildDispatcherDeps({
|
||||
context: this._context,
|
||||
ai,
|
||||
@@ -2140,6 +2382,8 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
approvalGates: this._approvalGates,
|
||||
pipelineIdOverride,
|
||||
requirementContract,
|
||||
architectureContextBlock,
|
||||
webContextBlock,
|
||||
});
|
||||
// 일반 새 turn vs 재개 turn 분기. 재개 시 _resume.json에서 plan + cursor를
|
||||
// 복원해 그 다음 stage부터 dispatch가 이어진다. resumeCompanyTurn이 null을
|
||||
|
||||
+14
-1
@@ -402,10 +402,23 @@ R7. GUESS-AND-ACT WITH STATED ASSUMPTION. When information is missing but a reas
|
||||
- 진척 보고가 들어오면 즉시 update / complete. "추적해뒀어요" 라고 말만 하지 말 것.
|
||||
- due 시각이 명확한 task 는 add_task + create_calendar_event 함께 emit (둘 다).
|
||||
|
||||
[ACTION 15: FETCH URL]
|
||||
웹 페이지의 *실제 내용* 이 필요할 때 사용. 절대 "사이트에 방문할 수 없다"고
|
||||
답하지 말 것 — 이 태그를 emit 하면 확장이 페이지 본문을 가져와 준다.
|
||||
|
||||
<fetch_url url="https://example.com/page"/>
|
||||
|
||||
- url: http/https URL (required)
|
||||
- 사용 시점: 사용자가 언급한 링크, 또는 작업에 필요한 페이지의 최신 내용이
|
||||
컨텍스트에 없을 때. 일반 지식 질문에는 쓰지 말 것.
|
||||
- 회당 최대 2개. 결과는 [URL CONTENT] 블록으로 주입되며, 실패하면 실패
|
||||
사실이 그대로 전달된다 — 내용을 추측해 채우지 말 것.
|
||||
|
||||
[OPERATIONAL RULES]
|
||||
1. Reply in the same language as the user.
|
||||
2. File paths are relative to the workspace or absolute under /Volumes/Data/project/Antigravity.
|
||||
3. When the user says "분석해줘", "봐줘", "확인해줘", "리뷰해줘" with a path — that IS the confirmation. Access the path immediately and run the full analysis in one continuous response. Do not pause to ask "진행할까요?" or "시작할까요?".`;
|
||||
3. When the user says "분석해줘", "봐줘", "확인해줘", "리뷰해줘" with a path — that IS the confirmation. Access the path immediately and run the full analysis in one continuous response. Do not pause to ask "진행할까요?" or "시작할까요?".
|
||||
4. Claims about THIS workspace's code or features must be grounded in files you actually read in this conversation (via read_file / list_files). Never describe implementation details with guesses like "~로 보입니다" — read the file first, or explicitly say you could not verify. Before proposing "add feature X", check whether X already exists in the codebase.`;
|
||||
|
||||
function getEnvironmentBlock(): string {
|
||||
const platform = process.platform;
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
gatherEvidenceForQuestions,
|
||||
selfAnswerQuestions,
|
||||
saveAlignmentKnowledge,
|
||||
SELF_RESEARCH_PREFIX,
|
||||
ALIGNMENT_KNOWLEDGE_DIR,
|
||||
_parseSelfAnswerJson,
|
||||
_slugify,
|
||||
} from '../src/features/company/alignmentResearch';
|
||||
import { clearBrainTokenIndex } from '../src/retrieval/brainIndex';
|
||||
import { invalidateBrainFilesCache } from '../src/utils';
|
||||
import type { IAIService, AIChatResult } from '../src/core/services';
|
||||
|
||||
function mkTmpBrain(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-alignment-'));
|
||||
}
|
||||
function writeMd(brain: string, rel: string, content: string): string {
|
||||
const p = path.join(brain, rel);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, content, 'utf8');
|
||||
return p;
|
||||
}
|
||||
function mockAi(content: string, opts?: { throwOnChat?: boolean }): IAIService {
|
||||
return {
|
||||
call: async () => content,
|
||||
chat: async (): Promise<AIChatResult> => {
|
||||
if (opts?.throwOnChat) throw new Error('connection refused');
|
||||
return { content, engine: 'lmstudio', model: 'test', empty: !content };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('alignmentResearch._parseSelfAnswerJson', () => {
|
||||
it('parses strict JSON', () => {
|
||||
const raw = '{"answers":[{"question":"Q1","status":"answered","answer":"A1"}]}';
|
||||
const parsed = _parseSelfAnswerJson(raw);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed![0]).toEqual({ question: 'Q1', status: 'answered', answer: 'A1' });
|
||||
});
|
||||
|
||||
it('parses fenced JSON with preamble (small-model tolerance)', () => {
|
||||
const raw = '판정 결과입니다.\n```json\n{"answers":[{"question":"Q1","status":"unanswered","answer":""}]}\n```';
|
||||
const parsed = _parseSelfAnswerJson(raw);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed![0].status).toBe('unanswered');
|
||||
});
|
||||
|
||||
it('extracts first balanced object from trailing garbage', () => {
|
||||
const raw = 'note: {"answers":[{"question":"Q","status":"answered","answer":"A"}]} 끝.';
|
||||
const parsed = _parseSelfAnswerJson(raw);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed![0].answer).toBe('A');
|
||||
});
|
||||
|
||||
it('returns null on garbage / empty', () => {
|
||||
expect(_parseSelfAnswerJson('')).toBeNull();
|
||||
expect(_parseSelfAnswerJson('그냥 텍스트')).toBeNull();
|
||||
expect(_parseSelfAnswerJson('{"answers": "not-an-array"}')).toBeNull();
|
||||
});
|
||||
|
||||
it('coerces unknown status to unanswered and skips entries without question', () => {
|
||||
const raw = '{"answers":[{"question":"Q1","status":"maybe","answer":"x"},{"status":"answered","answer":"no-q"}]}';
|
||||
const parsed = _parseSelfAnswerJson(raw);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.length).toBe(1);
|
||||
expect(parsed![0].status).toBe('unanswered');
|
||||
});
|
||||
});
|
||||
|
||||
describe('alignmentResearch._slugify', () => {
|
||||
it('preserves Korean, strips path-dangerous chars, caps length', () => {
|
||||
const s = _slugify('블로그 v3 프로젝트: "수정"해줘? <지금>', 30);
|
||||
expect(s).not.toMatch(/[\\/:*?"<>|]/);
|
||||
expect(s.length).toBeLessThanOrEqual(30);
|
||||
expect(s).toContain('블로그');
|
||||
});
|
||||
|
||||
it('falls back to "alignment" when everything is stripped', () => {
|
||||
expect(_slugify('???***///', 30)).toBe('alignment');
|
||||
expect(_slugify('', 30)).toBe('alignment');
|
||||
});
|
||||
});
|
||||
|
||||
describe('alignmentResearch.gatherEvidenceForQuestions', () => {
|
||||
let brain: string;
|
||||
beforeEach(() => { brain = mkTmpBrain(); });
|
||||
afterEach(() => {
|
||||
clearBrainTokenIndex(brain);
|
||||
invalidateBrainFilesCache();
|
||||
try { fs.rmSync(brain, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('returns empty excerpts for empty brain (no throw)', () => {
|
||||
const out = gatherEvidenceForQuestions(brain, ['ConnectAI는 무엇인가요?']);
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0].excerpts).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty for invalid brain path / no questions (no throw)', () => {
|
||||
expect(gatherEvidenceForQuestions('', ['Q'])[0].excerpts).toEqual([]);
|
||||
expect(gatherEvidenceForQuestions('/nonexistent/path/xyz', ['Q'])[0].excerpts).toEqual([]);
|
||||
expect(gatherEvidenceForQuestions(brain, [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('finds a relevant note and extracts an excerpt', () => {
|
||||
writeMd(brain, 'ConnectAI 소개.md',
|
||||
'# ConnectAI 소개\nConnectAI 는 Astra 라는 VS Code 확장 프로젝트입니다. 로컬 LLM 기반 사이드바 어시스턴트.');
|
||||
writeMd(brain, '무관한 노트.md', '# 김치찌개 레시피\n돼지고기와 김치를 볶는다.');
|
||||
invalidateBrainFilesCache();
|
||||
const out = gatherEvidenceForQuestions(brain, ['ConnectAI 프로젝트는 무엇인가요?']);
|
||||
expect(out[0].excerpts.length).toBeGreaterThan(0);
|
||||
expect(out[0].excerpts[0].excerpt).toContain('ConnectAI');
|
||||
});
|
||||
});
|
||||
|
||||
describe('alignmentResearch.selfAnswerQuestions', () => {
|
||||
const evidence = [
|
||||
{
|
||||
question: 'ConnectAI는 무엇인가요?',
|
||||
excerpts: [{ title: 'ConnectAI 소개', relativePath: 'ConnectAI 소개.md', excerpt: 'Astra VS Code 확장' }],
|
||||
},
|
||||
{ question: '예산은 얼마인가요?', excerpts: [] },
|
||||
];
|
||||
|
||||
it('maps answered questions and passes through evidence-less ones', async () => {
|
||||
const ai = mockAi(JSON.stringify({
|
||||
answers: [{ question: 'ConnectAI는 무엇인가요?', status: 'answered', answer: 'Astra VS Code 확장 (ConnectAI 소개)' }],
|
||||
}));
|
||||
const out = await selfAnswerQuestions(ai, { userPrompt: 'p', evidence });
|
||||
expect(out.find((a) => a.question === 'ConnectAI는 무엇인가요?')!.answered).toBe(true);
|
||||
expect(out.find((a) => a.question === '예산은 얼마인가요?')!.answered).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to all-unanswered on LLM call failure', async () => {
|
||||
const out = await selfAnswerQuestions(mockAi('', { throwOnChat: true }), { userPrompt: 'p', evidence });
|
||||
expect(out.every((a) => !a.answered)).toBe(true);
|
||||
expect(out.length).toBe(2);
|
||||
});
|
||||
|
||||
it('falls back to all-unanswered on unparseable output', async () => {
|
||||
const out = await selfAnswerQuestions(mockAi('자유 텍스트 답변'), { userPrompt: 'p', evidence });
|
||||
expect(out.every((a) => !a.answered)).toBe(true);
|
||||
});
|
||||
|
||||
it('skips the LLM entirely when no question has evidence', async () => {
|
||||
const ai = mockAi('{"answers":[]}');
|
||||
const chatSpy = jest.spyOn(ai, 'chat');
|
||||
const out = await selfAnswerQuestions(ai, {
|
||||
userPrompt: 'p',
|
||||
evidence: [{ question: 'Q', excerpts: [] }],
|
||||
});
|
||||
expect(chatSpy).not.toHaveBeenCalled();
|
||||
expect(out[0].answered).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('alignmentResearch.saveAlignmentKnowledge', () => {
|
||||
let brain: string;
|
||||
beforeEach(() => { brain = mkTmpBrain(); });
|
||||
afterEach(() => {
|
||||
try { fs.rmSync(brain, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it('saves user-provided answers as a plain note', () => {
|
||||
const saved = saveAlignmentKnowledge(brain, {
|
||||
userPrompt: '블로그 v3 개선 작업',
|
||||
qaList: [{ q: '대상 독자는 누구인가요?', a: '경제·재테크에 관심 있는 30대 직장인 구독자입니다.' }],
|
||||
});
|
||||
expect(saved).not.toBeNull();
|
||||
expect(saved!).toContain(ALIGNMENT_KNOWLEDGE_DIR);
|
||||
const content = fs.readFileSync(saved!, 'utf8');
|
||||
expect(content).toContain('## 원본 요청');
|
||||
expect(content).toContain('30대 직장인');
|
||||
});
|
||||
|
||||
it('filters out self-research entries and short answers', () => {
|
||||
const saved = saveAlignmentKnowledge(brain, {
|
||||
userPrompt: 'p',
|
||||
qaList: [
|
||||
{ q: 'Q1', a: SELF_RESEARCH_PREFIX + '두뇌에서 이미 확인된 충분히 긴 답변입니다만 저장 제외.' },
|
||||
{ q: 'Q2', a: '짧음' },
|
||||
],
|
||||
});
|
||||
expect(saved).toBeNull();
|
||||
expect(fs.existsSync(path.join(brain, ALIGNMENT_KNOWLEDGE_DIR))).toBe(false);
|
||||
});
|
||||
|
||||
it('skips duplicate save for the same day + prompt', () => {
|
||||
const input = {
|
||||
userPrompt: '같은 요청',
|
||||
qaList: [{ q: 'Q', a: '이 답은 스무 글자를 확실히 넘는 사용자 직접 답변입니다.' }],
|
||||
};
|
||||
const first = saveAlignmentKnowledge(brain, input);
|
||||
const second = saveAlignmentKnowledge(brain, input);
|
||||
expect(first).not.toBeNull();
|
||||
expect(second).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for missing brain path (no throw)', () => {
|
||||
expect(saveAlignmentKnowledge('/nonexistent/path/xyz', {
|
||||
userPrompt: 'p',
|
||||
qaList: [{ q: 'Q', a: '충분히 길고 진지한 사용자 답변이 여기 있습니다.' }],
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 자기 분석 트리거 + 인벤토리 자동 대조 회귀 테스트.
|
||||
*
|
||||
* 모든 케이스는 실제 사용자 세션에서 재현된 실패에서 가져왔다:
|
||||
* - "/Volumes/... 분석해줘" 프롬프트가 startsWith('/') 가드에 슬래시 명령으로
|
||||
* 오인되어 분석 지시·인벤토리가 전부 미주입 (v2.2.239 까지의 버그)
|
||||
* - 이전 답변 전문(5천자+)을 붙여넣는 재검토 프롬프트가 길이 상한에 탈락
|
||||
* - 모델이 이미 구현된 기능(Reflection/Decay/Correction)을 신규 도입 제안
|
||||
*/
|
||||
import { isSelfAssessRequest, isAboutSelf } from '../src/lib/contextBuilders/selfAssessContext';
|
||||
import { isAnalysisRequest, isSlashCommand } from '../src/lib/contextBuilders/promptDetection';
|
||||
import { extractUrls } from '../src/features/web/webFetch';
|
||||
import { detectReimplementedProposals, formatReimplementationFooter } from '../src/extension/featureConceptMap';
|
||||
|
||||
// 사용자가 실제로 입력한 프롬프트 원문
|
||||
const P_PATH_ANALYSIS = '/Volumes/Data/project/Antigravity/ConnectAI 분석하고 어떻게 하면 우리 아스트라를 작업을하면서 스스로 배우고 필요한 지식을 요청해서 사용자로 하여금 해당 지식을 가져오게 할 수 있을까? 의견줘';
|
||||
const P_LONG_PASTE = '아래 내용 검토해줘. 현재의 자기 진화(self-evolving) 기능은 지식 축적과 구조화 측면에서 ' + '내용 '.repeat(2000);
|
||||
|
||||
describe('isSlashCommand — 명령 vs 절대경로 구분', () => {
|
||||
it('슬래시 명령은 true', () => {
|
||||
expect(isSlashCommand('/wikify https://a.com')).toBe(true);
|
||||
expect(isSlashCommand('/benchmark')).toBe(true);
|
||||
expect(isSlashCommand('/stocks discover')).toBe(true);
|
||||
});
|
||||
it('절대경로는 false (v2.2.239 버그 회귀 방지)', () => {
|
||||
expect(isSlashCommand('/Volumes/Data/project 분석해줘')).toBe(false);
|
||||
expect(isSlashCommand('/Users/me/code 봐줘')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('실사용 프롬프트 트리거 (실패 재현 케이스)', () => {
|
||||
it('절대경로로 시작하는 분석 요청 → 분석 지시 발동', () => {
|
||||
expect(isAnalysisRequest(P_PATH_ANALYSIS)).toBe(true);
|
||||
expect(isSelfAssessRequest(P_PATH_ANALYSIS)).toBe(true);
|
||||
expect(isAboutSelf(P_PATH_ANALYSIS)).toBe(true);
|
||||
});
|
||||
it('이전 답변 전문 붙여넣기(5천자+) → 길이 상한 없이 발동', () => {
|
||||
expect(P_LONG_PASTE.length).toBeGreaterThan(4000);
|
||||
expect(isSelfAssessRequest(P_LONG_PASTE)).toBe(true);
|
||||
});
|
||||
it('자기 작동 방식 질문 ("어떻게 학습해?") → 인벤토리 주입 발동', () => {
|
||||
expect(isSelfAssessRequest('아스트라 너는 어떻게 학습하고 있어?')).toBe(true);
|
||||
expect(isSelfAssessRequest('아스트라가 학습하는 방식 설명해줘')).toBe(true);
|
||||
});
|
||||
it('절대경로로 시작 + URL 포함 → URL 추출 정상', () => {
|
||||
expect(extractUrls('/Volumes/Data/proj 보고 koritips.com 도 분석해줘')).toEqual(['https://koritips.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectReimplementedProposals — 인벤토리 자동 대조', () => {
|
||||
it('실제 Astra 답변의 재구현 제안들을 감지', () => {
|
||||
// 사용자 세션에서 모델이 실제로 출력한 문장 발췌
|
||||
const answer = [
|
||||
'개선 제안: handlePrompt 파이프라인 내에 Reflection Layer(성찰 계층)를 추가할 것을 강력히 추천합니다.',
|
||||
'지식 노후화 감지 시스템 (Knowledge Decay): 축적된 지식이 더 이상 유효하지 않음을 판단하는 메커니즘이 필요합니다.',
|
||||
'피드백 루프 자동화: 성공/실패의 원인을 분석하여 lessons/ 폴더에 자동으로 교훈으로 저장해야 합니다.',
|
||||
'자기 검증 및 비판적 사고 루프 (Self-Critique Loop): SelfCritiqueHandler를 추가합니다.',
|
||||
].join('\n');
|
||||
const hits = detectReimplementedProposals(answer);
|
||||
const concepts = hits.map((h) => h.concept);
|
||||
expect(concepts.some((c) => c.includes('Reflection'))).toBe(true);
|
||||
expect(concepts.some((c) => c.includes('Decay'))).toBe(true);
|
||||
expect(concepts.some((c) => c.includes('Self-Critique'))).toBe(true);
|
||||
const footer = formatReimplementationFooter(hits);
|
||||
expect(footer).toContain('이미 구현되어 있습니다');
|
||||
});
|
||||
|
||||
it('12B 답변의 변형 표현(Self-Correction/Reasoning Chain)도 감지', () => {
|
||||
// gemma-4-12b 가 실제로 출력한 문장 발췌 — 키워드 변형으로 정정기를 빗나갔던 케이스
|
||||
const answer = [
|
||||
'Reasoning Chain 도입: AgentExecutor 내에 "생각 단계(Thought)"를 명시적으로 분리하는 구조를 강화해야 합니다.',
|
||||
'Self-Correction 루프: 답변 생성 후 스스로 검토하는 단계를 추가하여 processFinalAnswer 단계에 강화해야 합니다.',
|
||||
].join('\n');
|
||||
const hits = detectReimplementedProposals(answer);
|
||||
const concepts = hits.map((h) => h.concept);
|
||||
expect(concepts.some((c) => c.includes('Multi-Step') || c.includes('멀티스텝'))).toBe(true);
|
||||
expect(concepts.some((c) => c.includes('Self-Critique'))).toBe(true);
|
||||
});
|
||||
|
||||
it('이미 구현됨을 인지한 문장은 정정하지 않음 (오탐 방지)', () => {
|
||||
const answer = 'Reflection Layer는 이미 Self-Reflector 3단계로 구현되어 있으므로, 이를 확장하는 방향을 제안합니다.';
|
||||
expect(detectReimplementedProposals(answer)).toEqual([]);
|
||||
});
|
||||
|
||||
it('무관한 일반 답변에는 무반응', () => {
|
||||
const answer = '블로그 글 작성을 위한 SEO 키워드는 다음과 같이 추가해야 합니다: 메인 키워드 1개, 서브 키워드 3개.';
|
||||
expect(detectReimplementedProposals(answer)).toEqual([]);
|
||||
expect(formatReimplementationFooter([])).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { extractUrls, htmlToText, decodeEntities, fetchUrlDirect } from '../src/features/web/webFetch';
|
||||
|
||||
describe('webFetch.extractUrls', () => {
|
||||
it('extracts http(s) URLs and strips trailing punctuation', () => {
|
||||
const urls = extractUrls('https://koritips.com 가서 내용 분석해줘.');
|
||||
expect(urls).toEqual(['https://koritips.com']);
|
||||
});
|
||||
|
||||
it('handles multiple URLs with dedupe and max cap', () => {
|
||||
const text = 'A: https://a.com/x, B: https://b.com/y 그리고 다시 https://a.com/x 또 https://c.com';
|
||||
const urls = extractUrls(text, 2);
|
||||
expect(urls).toEqual(['https://a.com/x', 'https://b.com/y']);
|
||||
});
|
||||
|
||||
it('ignores slash commands and empty input', () => {
|
||||
expect(extractUrls('/wikify https://a.com')).toEqual([]);
|
||||
expect(extractUrls('')).toEqual([]);
|
||||
expect(extractUrls('URL 없는 문장')).toEqual([]);
|
||||
});
|
||||
|
||||
it('strips Korean closing punctuation contamination', () => {
|
||||
expect(extractUrls('링크(https://a.com/page)를 봐줘')).toEqual(['https://a.com/page']);
|
||||
expect(extractUrls('「https://b.com」 분석')).toEqual(['https://b.com']);
|
||||
});
|
||||
|
||||
it('recognizes bare domains without scheme and prepends https://', () => {
|
||||
expect(extractUrls('koritips.com 가서 내용 분석해줘')).toEqual(['https://koritips.com']);
|
||||
expect(extractUrls('www.example.net/path/page 확인')).toEqual(['https://www.example.net/path/page']);
|
||||
expect(extractUrls('naver.co.kr 어때?')).toEqual(['https://naver.co.kr']);
|
||||
});
|
||||
|
||||
it('does not mistake filenames or emails for bare domains', () => {
|
||||
expect(extractUrls('utils.ts 와 package.json 수정해줘')).toEqual([]);
|
||||
expect(extractUrls('메일은 user@gmail.com 입니다')).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not double-count a scheme URL as a bare domain', () => {
|
||||
expect(extractUrls('https://koritips.com 그리고 koritips.com')).toEqual(['https://koritips.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('webFetch.htmlToText', () => {
|
||||
it('strips scripts, styles, and tags while preserving block structure', () => {
|
||||
const html = `<html><head><style>.x{color:red}</style><script>alert(1)</script></head>
|
||||
<body><h1>제목입니다</h1><p>첫 문단.</p><p>둘째 문단.</p></body></html>`;
|
||||
const text = htmlToText(html);
|
||||
expect(text).not.toContain('alert');
|
||||
expect(text).not.toContain('color:red');
|
||||
expect(text).toContain('제목입니다');
|
||||
expect(text).toMatch(/첫 문단\.\n/);
|
||||
});
|
||||
|
||||
it('decodes common entities', () => {
|
||||
expect(decodeEntities('A & B <tag> "q" 's' ')).toBe(`A & B <tag> "q" 's' `);
|
||||
expect(decodeEntities('김')).toBe('김');
|
||||
});
|
||||
|
||||
it('collapses excessive whitespace', () => {
|
||||
const text = htmlToText('<p>a</p>\n\n\n\n<p>b</p>');
|
||||
expect(text).not.toMatch(/\n{3,}/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('webFetch.fetchUrlDirect (no network)', () => {
|
||||
it('rejects non-http schemes without throwing', async () => {
|
||||
const r = await fetchUrlDirect('ftp://example.com');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toContain('http');
|
||||
});
|
||||
|
||||
it('returns honest failure for unreachable host (no throw)', async () => {
|
||||
const r = await fetchUrlDirect('http://127.0.0.1:1', { timeoutMs: 1500 });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(typeof r.error).toBe('string');
|
||||
}, 10_000);
|
||||
});
|
||||
Reference in New Issue
Block a user